main.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322
  1. # -*- coding: utf-8 -*-
  2. """
  3. Trixy Fritzbox-Plugin.
  4. Ueberwacht die Fritzbox auf:
  5. - Hohen Upload/Download (konfigurierbare Schwellwerte)
  6. - Neue/unbekannte Geraete im Netzwerk
  7. - Offline-gegangene Geraete (optional)
  8. Benachrichtigung per TTS auf konfigurierten Satellites.
  9. Sprachsteuerung fuer Status-Abfragen.
  10. """
  11. from __future__ import annotations
  12. import asyncio
  13. from pathlib import Path
  14. from trixy_core.plugins.trixy_plugin import TrixyPlugin
  15. from trixy_core.utils.debug import pinfo, pdebug, perror, pwarn
  16. from trixy_core.nlp.decorators import intent, admin_only
  17. from trixy_core.nlp.handler import IntentResult, IntentReceivedData
  18. from plugins.fritzbox.monitor import FritzboxMonitor, TrafficSnapshot, NetworkDevice
  19. class FritzboxPlugin(TrixyPlugin):
  20. """Fritzbox-Ueberwachung mit Sprachsteuerung."""
  21. NAME = "fritzbox"
  22. VERSION = "1.0.0"
  23. DESCRIPTION = "Fritzbox-Netzwerkueberwachung mit Warnungen"
  24. AUTHOR = "Trixy"
  25. def __init__(self, **kwargs) -> None:
  26. super().__init__(**kwargs)
  27. self._monitor: FritzboxMonitor | None = None
  28. self._check_task: asyncio.Task | None = None
  29. self._notification_pcm: bytes = b""
  30. self._known_macs: set[str] = set()
  31. # Cooldown: verhindert Spam bei anhaltendem hohem Traffic
  32. self._last_upload_alert: float = 0.0
  33. self._last_download_alert: float = 0.0
  34. self._alert_cooldown_seconds: float = 300.0 # 5 Min zwischen gleichen Warnungen
  35. async def on_load(self) -> None:
  36. """Initialisiert Monitor und startet periodische Pruefung."""
  37. fritz_cfg = self.config.get("fritzbox", {})
  38. host = fritz_cfg.get("host", "192.168.178.1")
  39. port = fritz_cfg.get("port", 49000)
  40. username = fritz_cfg.get("username", "")
  41. password = fritz_cfg.get("password", "")
  42. use_tls = fritz_cfg.get("use_tls", False)
  43. if not password:
  44. pwarn("[Fritzbox] Kein Passwort konfiguriert — Plugin inaktiv")
  45. return
  46. self._monitor = FritzboxMonitor(host, port, username, password, use_tls)
  47. if not self._monitor.is_available:
  48. perror("[Fritzbox] fritzconnection nicht installiert")
  49. return
  50. # Verbindung herstellen
  51. if not self._monitor.connect():
  52. return
  53. # Benachrichtigungs-Sound laden
  54. self._load_notification_sound()
  55. # Bekannte Geraete aus Config laden
  56. known = self.config.get("known_devices", [])
  57. self._known_macs = {mac.upper() for mac in known}
  58. # Initial alle aktiven Geraete als bekannt erfassen
  59. if not self._known_macs:
  60. await self._init_known_devices()
  61. # Periodische Pruefung starten
  62. interval = self.get_config_value("check_interval_seconds", 30)
  63. self._check_task = asyncio.create_task(self._check_loop(interval))
  64. pinfo(f"[Fritzbox] Plugin geladen — Monitoring alle {interval}s, "
  65. f"{len(self._known_macs)} bekannte Geraete")
  66. async def on_unload(self) -> None:
  67. """Stoppt Monitoring und trennt Verbindung."""
  68. if self._check_task and not self._check_task.done():
  69. self._check_task.cancel()
  70. try:
  71. await self._check_task
  72. except asyncio.CancelledError:
  73. pass
  74. if self._monitor:
  75. self._monitor.disconnect()
  76. def _load_notification_sound(self) -> None:
  77. """Laedt den Benachrichtigungs-Sound."""
  78. try:
  79. asset_service = self.application.services.get_service("AssetService")
  80. if asset_service:
  81. sound_name = self.get_config_value("notification_sound", "notification.wav")
  82. audio_path = asset_service.get_audio(sound_name)
  83. if audio_path and audio_path.exists():
  84. raw = audio_path.read_bytes()
  85. self._notification_pcm = raw[44:] # WAV-Header entfernen
  86. except Exception:
  87. pass
  88. async def _init_known_devices(self) -> None:
  89. """Erfasst alle aktuell aktiven Geraete als bekannt."""
  90. if not self._monitor:
  91. return
  92. devices = await asyncio.get_event_loop().run_in_executor(
  93. None, self._monitor.get_active_devices,
  94. )
  95. for dev in devices:
  96. self._known_macs.add(dev.mac.upper())
  97. # In Config speichern
  98. self.set_config_value("known_devices", sorted(self._known_macs))
  99. self.save_config()
  100. pdebug(f"[Fritzbox] {len(self._known_macs)} Geraete als bekannt erfasst")
  101. # === Periodische Pruefung ===
  102. async def _check_loop(self, interval: float) -> None:
  103. """Hauptschleife fuer periodische Pruefungen."""
  104. while True:
  105. try:
  106. await asyncio.sleep(interval)
  107. await self._run_checks()
  108. except asyncio.CancelledError:
  109. break
  110. except Exception as e:
  111. perror(f"[Fritzbox] Check-Fehler: {e}")
  112. await asyncio.sleep(interval)
  113. async def _run_checks(self) -> None:
  114. """Fuehrt alle aktivierten Pruefungen durch."""
  115. if not self._monitor or not self._monitor.is_connected:
  116. return
  117. loop = asyncio.get_event_loop()
  118. alerts = self.config.get("alerts", {})
  119. # Traffic pruefen
  120. traffic = await loop.run_in_executor(None, self._monitor.get_traffic)
  121. if traffic:
  122. await self._check_traffic(traffic, alerts)
  123. # Geraete pruefen
  124. if alerts.get("new_device", {}).get("enabled") or alerts.get("device_offline", {}).get("enabled"):
  125. devices = await loop.run_in_executor(None, self._monitor.get_active_devices)
  126. await self._check_devices(devices, alerts)
  127. async def _check_traffic(self, traffic: TrafficSnapshot, alerts: dict) -> None:
  128. """Prueft Traffic gegen Schwellwerte."""
  129. import time
  130. now = time.monotonic()
  131. # Upload
  132. up_cfg = alerts.get("high_upload", {})
  133. if up_cfg.get("enabled") and traffic.upload_mbit >= up_cfg.get("threshold_mbit", 5.0):
  134. if now - self._last_upload_alert >= self._alert_cooldown_seconds:
  135. self._last_upload_alert = now
  136. msg = up_cfg.get("message", "Hoher Upload: {rate} Mbit/s")
  137. msg = msg.replace("{rate}", f"{traffic.upload_mbit:.1f}")
  138. pinfo(f"[Fritzbox] WARNUNG: {msg}")
  139. await self._notify(msg)
  140. # Download
  141. down_cfg = alerts.get("high_download", {})
  142. if down_cfg.get("enabled") and traffic.download_mbit >= down_cfg.get("threshold_mbit", 50.0):
  143. if now - self._last_download_alert >= self._alert_cooldown_seconds:
  144. self._last_download_alert = now
  145. msg = down_cfg.get("message", "Hoher Download: {rate} Mbit/s")
  146. msg = msg.replace("{rate}", f"{traffic.download_mbit:.1f}")
  147. pinfo(f"[Fritzbox] WARNUNG: {msg}")
  148. await self._notify(msg)
  149. async def _check_devices(self, devices: list[NetworkDevice], alerts: dict) -> None:
  150. """Prueft auf neue/offline Geraete."""
  151. active_macs = {d.mac.upper() for d in devices}
  152. # Neue Geraete
  153. new_cfg = alerts.get("new_device", {})
  154. if new_cfg.get("enabled"):
  155. for dev in devices:
  156. mac = dev.mac.upper()
  157. if mac not in self._known_macs:
  158. self._known_macs.add(mac)
  159. msg = new_cfg.get("message", "Neues Geraet: {name} ({ip})")
  160. msg = msg.replace("{name}", dev.name or "Unbekannt")
  161. msg = msg.replace("{ip}", dev.ip)
  162. msg = msg.replace("{mac}", dev.mac)
  163. pinfo(f"[Fritzbox] {msg}")
  164. await self._notify(msg)
  165. # Bekannte Geraete aktualisieren
  166. self.set_config_value("known_devices", sorted(self._known_macs))
  167. self.save_config()
  168. # Offline-Geraete
  169. offline_cfg = alerts.get("device_offline", {})
  170. if offline_cfg.get("enabled"):
  171. watch = {m.upper() for m in offline_cfg.get("watch_devices", [])}
  172. for mac in watch:
  173. if mac not in active_macs:
  174. # Geraetename finden
  175. name = mac
  176. for dev in devices:
  177. if dev.mac.upper() == mac:
  178. name = dev.name or mac
  179. break
  180. msg = offline_cfg.get("message", "Geraet offline: {name}")
  181. msg = msg.replace("{name}", name)
  182. await self._notify(msg)
  183. # === Benachrichtigung an Satellites ===
  184. async def _notify(self, text: str) -> None:
  185. """Sendet Warnung per TTS an konfigurierte Satellites."""
  186. satellites = getattr(self.application, "satellites", None)
  187. if not satellites:
  188. return
  189. notify_cfg = self.config.get("notify_satellites", {})
  190. mode = notify_cfg.get("mode", "all")
  191. target_aliases = [a.lower() for a in notify_cfg.get("aliases", [])]
  192. target_rooms = [r.lower() for r in notify_cfg.get("rooms", [])]
  193. for sat in satellites.get_connected():
  194. if not sat.is_connected:
  195. continue
  196. if mode == "all":
  197. pass # Alle Satellites
  198. elif mode == "alias":
  199. alias = getattr(sat, "alias", "").lower()
  200. if alias not in target_aliases:
  201. continue
  202. elif mode == "room":
  203. room = getattr(sat, "room_id", "").lower()
  204. if room not in target_rooms:
  205. continue
  206. else:
  207. continue
  208. try:
  209. if self._notification_pcm:
  210. await sat.say(self._notification_pcm)
  211. await sat.speak(text)
  212. except Exception as e:
  213. pdebug(f"[Fritzbox] Benachrichtigung an {sat_id} fehlgeschlagen: {e}")
  214. # === Sprachsteuerung ===
  215. @intent("fritzbox_status", description="Fritzbox-Netzwerkstatus abfragen")
  216. @admin_only
  217. async def handle_status(self, data: IntentReceivedData) -> IntentResult:
  218. """Gibt aktuellen Traffic und Geraete-Anzahl aus."""
  219. if not self._monitor or not self._monitor.is_connected:
  220. return IntentResult.failure("not_connected", "Die Fritzbox ist nicht verbunden.")
  221. loop = asyncio.get_event_loop()
  222. traffic = await loop.run_in_executor(None, self._monitor.get_traffic)
  223. devices = await loop.run_in_executor(None, self._monitor.get_active_devices)
  224. parts = []
  225. if traffic:
  226. parts.append(
  227. f"Download {traffic.download_mbit:.1f} Mbit, "
  228. f"Upload {traffic.upload_mbit:.1f} Mbit pro Sekunde"
  229. )
  230. parts.append(f"{len(devices)} Geraete im Netzwerk")
  231. return IntentResult.success_with_response(". ".join(parts))
  232. @intent("fritzbox_devices", description="Verbundene Geraete auflisten")
  233. @admin_only
  234. async def handle_devices(self, data: IntentReceivedData) -> IntentResult:
  235. """Listet verbundene Geraete auf."""
  236. if not self._monitor or not self._monitor.is_connected:
  237. return IntentResult.failure("not_connected", "Die Fritzbox ist nicht verbunden.")
  238. loop = asyncio.get_event_loop()
  239. devices = await loop.run_in_executor(None, self._monitor.get_active_devices)
  240. if not devices:
  241. return IntentResult.success_with_response("Keine aktiven Geraete gefunden.")
  242. # Max 10 per Sprache ausgeben
  243. names = [d.name or d.ip for d in devices[:10]]
  244. rest = len(devices) - 10 if len(devices) > 10 else 0
  245. text = f"{len(devices)} Geraete online: {', '.join(names)}"
  246. if rest > 0:
  247. text += f" und {rest} weitere"
  248. return IntentResult.success_with_response(text)
  249. @intent("fritzbox_traffic", description="Aktuelle Internetgeschwindigkeit")
  250. @admin_only
  251. async def handle_traffic(self, data: IntentReceivedData) -> IntentResult:
  252. """Gibt aktuelle Up-/Download-Raten aus."""
  253. if not self._monitor or not self._monitor.is_connected:
  254. return IntentResult.failure("not_connected", "Die Fritzbox ist nicht verbunden.")
  255. loop = asyncio.get_event_loop()
  256. traffic = await loop.run_in_executor(None, self._monitor.get_traffic)
  257. if not traffic:
  258. return IntentResult.failure("no_data", "Konnte keine Traffic-Daten abrufen.")
  259. return IntentResult.success_with_response(
  260. f"Download: {traffic.download_mbit:.1f} Mbit pro Sekunde. "
  261. f"Upload: {traffic.upload_mbit:.1f} Mbit pro Sekunde."
  262. )