monitor.py 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260
  1. # -*- coding: utf-8 -*-
  2. """
  3. Homematic Geraeteueberwachung.
  4. Prueft periodisch Batterie-Status, Verbindung und Geraetezustaende.
  5. Gibt Warnungen per TTS aus und persistiert den Status in status.json.
  6. """
  7. from __future__ import annotations
  8. import asyncio
  9. import json
  10. import time
  11. from pathlib import Path
  12. from typing import Any
  13. from trixy_core.utils.debug import pdebug, pinfo, pwarn
  14. from plugins.homematic.api import HomematicApi
  15. # Batterie-Schwellwerte (Volt) fuer HM-CC-RT-DN (2x AA, 3.0V max)
  16. BATTERY_LOW_VOLTAGE = 2.2
  17. BATTERY_CRITICAL_VOLTAGE = 1.8
  18. class DeviceStatus:
  19. """Gespeicherter Geraete-Status fuer Aenderungserkennung."""
  20. def __init__(self, status_file: Path) -> None:
  21. self._file = status_file
  22. self._devices: dict[str, dict[str, Any]] = {}
  23. self._load()
  24. def _load(self) -> None:
  25. """Laedt den gespeicherten Status."""
  26. if self._file.exists():
  27. try:
  28. with open(self._file, "r", encoding="utf-8") as f:
  29. self._devices = json.load(f)
  30. except Exception:
  31. self._devices = {}
  32. def _save(self) -> None:
  33. """Speichert den aktuellen Status."""
  34. try:
  35. with open(self._file, "w", encoding="utf-8") as f:
  36. json.dump(self._devices, f, indent=2, ensure_ascii=False)
  37. except Exception as e:
  38. pwarn(f"[Homematic Monitor] Status speichern fehlgeschlagen: {e}")
  39. def get(self, device_id: str) -> dict[str, Any]:
  40. """Gibt den gespeicherten Status eines Geraets zurueck."""
  41. return self._devices.get(device_id, {})
  42. def update(self, device_id: str, status: dict[str, Any]) -> None:
  43. """Aktualisiert den Status eines Geraets."""
  44. self._devices[device_id] = status
  45. self._save()
  46. def update_batch(self, updates: dict[str, dict[str, Any]]) -> None:
  47. """Aktualisiert mehrere Geraete und speichert einmal."""
  48. self._devices.update(updates)
  49. self._save()
  50. class HomematicMonitor:
  51. """Ueberwacht Homematic-Geraete im Hintergrund.
  52. Prueft periodisch:
  53. - Batterie-Status (LOWBAT, BATTERY_STATE)
  54. - Verbindungsstatus (UNREACH)
  55. - Geraetezustaende (STATE, LEVEL, etc.)
  56. Gibt Warnungen per Event aus, die vom Plugin als TTS gesprochen werden.
  57. """
  58. def __init__(
  59. self,
  60. api: HomematicApi,
  61. plugin_path: Path,
  62. event_callback: Any = None,
  63. ) -> None:
  64. self._api = api
  65. self._status = DeviceStatus(plugin_path / "status.json")
  66. self._event_callback = event_callback
  67. self._task: asyncio.Task | None = None
  68. # Konfiguration (wird vom Plugin gesetzt)
  69. self.interval: int = 120
  70. self.warn_battery_low: bool = True
  71. self.warn_connection_changed: bool = True
  72. self.warn_status_changed: bool = False
  73. @property
  74. def is_enabled(self) -> bool:
  75. """Prueft ob mindestens eine Ueberwachung aktiv ist."""
  76. return (
  77. self.interval > 0
  78. and (self.warn_battery_low or self.warn_connection_changed or self.warn_status_changed)
  79. )
  80. def start(self) -> None:
  81. """Startet den Monitor-Task."""
  82. if self._task and not self._task.done():
  83. return
  84. if not self.is_enabled:
  85. return
  86. self._task = asyncio.create_task(self._monitor_loop())
  87. pinfo(f"[Homematic Monitor] Gestartet (Intervall: {self.interval}s)")
  88. def stop(self) -> None:
  89. """Stoppt den Monitor-Task."""
  90. if self._task and not self._task.done():
  91. self._task.cancel()
  92. self._task = None
  93. pinfo("[Homematic Monitor] Gestoppt")
  94. def restart(self) -> None:
  95. """Neustart mit aktueller Konfiguration."""
  96. self.stop()
  97. if self.is_enabled:
  98. self.start()
  99. async def _monitor_loop(self) -> None:
  100. """Periodische Pruefschleife."""
  101. # Erster Check nach kurzem Delay (Server-Start abwarten)
  102. await asyncio.sleep(30)
  103. while True:
  104. try:
  105. await self._check_devices()
  106. except asyncio.CancelledError:
  107. break
  108. except Exception as e:
  109. pdebug(f"[Homematic Monitor] Pruefung fehlgeschlagen: {e}")
  110. try:
  111. await asyncio.sleep(self.interval)
  112. except asyncio.CancelledError:
  113. break
  114. async def _check_devices(self) -> None:
  115. """Prueft alle Geraete und gibt bei Aenderungen Warnungen aus."""
  116. try:
  117. devices = await self._api.get_state_list()
  118. except Exception as e:
  119. pdebug(f"[Homematic Monitor] API-Fehler: {e}")
  120. return
  121. new_battery_low: list[str] = []
  122. new_connection_lost: list[str] = []
  123. new_connection_restored: list[str] = []
  124. status_changes: list[str] = []
  125. status_updates: dict[str, dict[str, Any]] = {}
  126. for dev in devices:
  127. dev_id = dev.ise_id
  128. old = self._status.get(dev_id)
  129. current: dict[str, Any] = {"name": dev.name}
  130. # Batterie und Connection aus allen Channels sammeln
  131. unreach = False
  132. lowbat = False
  133. battery_voltage = 0.0
  134. for ch in dev.channels.values():
  135. ur = ch.datapoints.get("UNREACH")
  136. if ur:
  137. unreach = ur.value_bool
  138. lb = ch.datapoints.get("LOWBAT")
  139. if lb and lb.value_bool:
  140. lowbat = True
  141. bs = ch.datapoints.get("BATTERY_STATE")
  142. if bs and bs.value:
  143. try:
  144. battery_voltage = float(bs.value)
  145. except (ValueError, TypeError):
  146. pass
  147. battery_low = lowbat or (battery_voltage > 0 and battery_voltage <= BATTERY_LOW_VOLTAGE)
  148. current["unreach"] = unreach
  149. current["battery_low"] = battery_low
  150. current["battery_voltage"] = battery_voltage
  151. # Batterie-Warnung (nur wenn sich Status geaendert hat)
  152. if self.warn_battery_low and battery_low:
  153. was_low = old.get("battery_low", False)
  154. if not was_low:
  155. new_battery_low.append(dev.name)
  156. # Verbindungs-Warnung
  157. if self.warn_connection_changed:
  158. was_unreach = old.get("unreach", False)
  159. if unreach and not was_unreach:
  160. new_connection_lost.append(dev.name)
  161. elif not unreach and was_unreach:
  162. new_connection_restored.append(dev.name)
  163. # Status-Aenderungen (Fenster, Schalter, etc.)
  164. if self.warn_status_changed:
  165. for ch in dev.channels.values():
  166. if ch.visible != "true":
  167. continue
  168. state = ch.datapoints.get("STATE")
  169. if state:
  170. ch_key = f"state_{ch.ise_id}"
  171. old_state = old.get(ch_key)
  172. if old_state is not None and old_state != state.value:
  173. if state.value_bool:
  174. status_changes.append(f"{ch.name} geoeffnet")
  175. else:
  176. status_changes.append(f"{ch.name} geschlossen")
  177. current[ch_key] = state.value
  178. status_updates[dev_id] = current
  179. # Status speichern
  180. self._status.update_batch(status_updates)
  181. # Warnungen zusammenbauen
  182. warnings: list[str] = []
  183. if new_battery_low:
  184. if len(new_battery_low) == 1:
  185. warnings.append(f"Die Batterie von {new_battery_low[0]} ist fast leer")
  186. else:
  187. aufzaehlung = ", ".join(new_battery_low[:-1]) + " und " + new_battery_low[-1]
  188. warnings.append(f"Die Batterien von {aufzaehlung} sind fast leer")
  189. if new_connection_lost:
  190. if len(new_connection_lost) == 1:
  191. warnings.append(f"{new_connection_lost[0]} hat keine Verbindung")
  192. else:
  193. aufzaehlung = ", ".join(new_connection_lost[:-1]) + " und " + new_connection_lost[-1]
  194. warnings.append(f"{aufzaehlung} haben keine Verbindung")
  195. if new_connection_restored:
  196. if len(new_connection_restored) == 1:
  197. warnings.append(f"{new_connection_restored[0]} ist wieder erreichbar")
  198. else:
  199. aufzaehlung = ", ".join(new_connection_restored[:-1]) + " und " + new_connection_restored[-1]
  200. warnings.append(f"{aufzaehlung} sind wieder erreichbar")
  201. if status_changes:
  202. warnings.append(", ".join(status_changes))
  203. # Warnungen ausgeben
  204. if warnings:
  205. text = " und ".join(warnings) + "."
  206. pinfo(f"[Homematic Monitor] {text}")
  207. if self._event_callback:
  208. await self._event_callback("homematic_warning", {
  209. "text": text,
  210. "battery_low": new_battery_low,
  211. "connection_lost": new_connection_lost,
  212. "connection_restored": new_connection_restored,
  213. "status_changes": status_changes,
  214. })