notifications.py 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280
  1. # -*- coding: utf-8 -*-
  2. """
  3. Notification Manager fuer das Calendar-Plugin.
  4. Prueft periodisch gecachte Events und benachrichtigt
  5. Satellites bei Erinnerungen und Terminbeginn.
  6. """
  7. import asyncio
  8. from datetime import datetime, timedelta, time
  9. from trixy_core.utils.debug import pinfo, pdebug, perror, pwarn
  10. from plugins.calendar.models import CalendarEvent, NotificationRecord
  11. class NotificationManager:
  12. """
  13. Verwaltet Erinnerungen und Start-Benachrichtigungen fuer Kalender-Events.
  14. - Eigener asyncio-Task prueft alle 60 Sekunden
  15. - Duplikat-Vermeidung ueber (event_id, notification_type)
  16. - Quiet Hours optional konfigurierbar
  17. """
  18. def __init__(
  19. self,
  20. application: object,
  21. sync_manager: object,
  22. mapper: object,
  23. config: dict,
  24. ) -> None:
  25. """
  26. Args:
  27. application: Application-Instanz
  28. sync_manager: CalendarSyncManager
  29. mapper: SatelliteCalendarMapper
  30. config: notifications-Abschnitt der Plugin-Config
  31. """
  32. self._application = application
  33. self._sync_manager = sync_manager
  34. self._mapper = mapper
  35. self._config = config
  36. self._sent_notifications: dict[tuple[str, str], NotificationRecord] = {}
  37. self._check_task: asyncio.Task | None = None
  38. self._reminder_enabled = config.get("reminder_enabled", True)
  39. self._start_enabled = config.get("start_enabled", True)
  40. self._default_reminder_minutes = config.get("default_reminder_minutes", 15)
  41. # Sounds (werden vom Plugin geladen)
  42. self._reminder_pcm: bytes | None = None
  43. self._start_pcm: bytes | None = None
  44. # Quiet Hours
  45. quiet_config = config.get("quiet_hours", {})
  46. self._quiet_enabled = quiet_config.get("enabled", False)
  47. self._quiet_start = self._parse_time(quiet_config.get("start", "22:00"))
  48. self._quiet_end = self._parse_time(quiet_config.get("end", "07:00"))
  49. def set_sounds(
  50. self,
  51. reminder_pcm: bytes | None,
  52. start_pcm: bytes | None,
  53. ) -> None:
  54. """Setzt die PCM-Audiodaten fuer Benachrichtigungs-Sounds."""
  55. self._reminder_pcm = reminder_pcm
  56. self._start_pcm = start_pcm
  57. async def start_checking(self) -> None:
  58. """Startet den periodischen Benachrichtigungs-Check."""
  59. if self._check_task and not self._check_task.done():
  60. return
  61. self._check_task = asyncio.create_task(self._check_loop())
  62. pdebug("NotificationManager: Check-Task gestartet")
  63. async def stop_checking(self) -> None:
  64. """Stoppt den Benachrichtigungs-Check."""
  65. if self._check_task and not self._check_task.done():
  66. self._check_task.cancel()
  67. try:
  68. await self._check_task
  69. except asyncio.CancelledError:
  70. pass
  71. self._check_task = None
  72. pdebug("NotificationManager: Check-Task gestoppt")
  73. async def check_notifications(self) -> None:
  74. """
  75. Prueft alle gecachten Events auf faellige Benachrichtigungen.
  76. Wird alle 60 Sekunden vom Check-Task aufgerufen.
  77. """
  78. now = datetime.now()
  79. # Quiet Hours pruefen
  80. if self._is_quiet_time(now):
  81. return
  82. # Alte Records aufraeumen (aelter als 24h)
  83. self._cleanup_old_records(now)
  84. events = self._sync_manager.get_all_cached_events()
  85. for event in events:
  86. # Erinnerung pruefen
  87. if self._reminder_enabled:
  88. await self._check_reminder(event, now)
  89. # Terminbeginn pruefen
  90. if self._start_enabled:
  91. await self._check_start(event, now)
  92. # =========================================================================
  93. # Interne Pruefungen
  94. # =========================================================================
  95. async def _check_reminder(self, event: CalendarEvent, now: datetime) -> None:
  96. """Prueft ob eine Erinnerung fuer ein Event faellig ist."""
  97. key = (event.event_id, "reminder")
  98. if key in self._sent_notifications:
  99. return
  100. reminder_minutes = event.reminder_minutes or self._default_reminder_minutes
  101. reminder_time = event.start - timedelta(minutes=reminder_minutes)
  102. if reminder_time <= now < event.start:
  103. minutes_left = int((event.start - now).total_seconds() / 60) + 1
  104. text = f"In {minutes_left} Minuten: {event.title}"
  105. await self._send_notification(event, "reminder", text)
  106. async def _check_start(self, event: CalendarEvent, now: datetime) -> None:
  107. """Prueft ob ein Terminbeginn gemeldet werden soll."""
  108. key = (event.event_id, "start")
  109. if key in self._sent_notifications:
  110. return
  111. if event.start <= now < event.start + timedelta(minutes=1):
  112. text = f"Termin jetzt: {event.title}"
  113. await self._send_notification(event, "start", text)
  114. async def _send_notification(
  115. self,
  116. event: CalendarEvent,
  117. notification_type: str,
  118. text: str,
  119. ) -> None:
  120. """
  121. Sendet eine Benachrichtigung an die betroffenen Satellites.
  122. Args:
  123. event: Das Event
  124. notification_type: "reminder" oder "start"
  125. text: TTS-Text
  126. """
  127. # Betroffene Satellites ermitteln
  128. calendar_key = self._sync_manager.get_calendar_key_for_event(event)
  129. satellite_ids = self._mapper.get_satellites_for_calendar(calendar_key)
  130. if not satellite_ids:
  131. pdebug(f"NotificationManager: Keine Satellites fuer '{calendar_key}'")
  132. # Als gesendet markieren (damit nicht wiederholt versucht wird)
  133. self._mark_sent(event.event_id, notification_type, [])
  134. return
  135. # Sound und TTS an jeden Satellite senden
  136. sent_to: list[str] = []
  137. for sat_id in satellite_ids:
  138. success = await self._notify_satellite(sat_id, notification_type, text)
  139. if success:
  140. sent_to.append(sat_id)
  141. self._mark_sent(event.event_id, notification_type, sent_to)
  142. pinfo(f"NotificationManager: {notification_type} fuer '{event.title}' an {len(sent_to)} Satellites gesendet")
  143. async def _notify_satellite(
  144. self,
  145. satellite_id: str,
  146. notification_type: str,
  147. text: str,
  148. ) -> bool:
  149. """
  150. Sendet Sound + TTS an einen einzelnen Satellite.
  151. Returns:
  152. True bei Erfolg
  153. """
  154. satellites = getattr(self._application, "satellites", None)
  155. if not satellites:
  156. # Standalone-Modus: TTS via Event
  157. await self._application.events.emit("tts_request", {
  158. "text": text,
  159. "source": "calendar",
  160. })
  161. return True
  162. satellite = satellites.get(satellite_id)
  163. if not satellite or not satellite.is_connected:
  164. return False
  165. try:
  166. # Sound abspielen
  167. pcm = self._reminder_pcm if notification_type == "reminder" else self._start_pcm
  168. if pcm:
  169. await satellite.say(pcm)
  170. # TTS-Meldung
  171. await satellite.speak(text)
  172. return True
  173. except Exception as e:
  174. perror(f"NotificationManager: Fehler bei Satellite '{satellite_id}': {e}")
  175. return False
  176. # =========================================================================
  177. # Hilfsmethoden
  178. # =========================================================================
  179. async def _check_loop(self) -> None:
  180. """Endlos-Schleife fuer periodische Benachrichtigungs-Pruefung."""
  181. try:
  182. while True:
  183. try:
  184. await self.check_notifications()
  185. except Exception as e:
  186. perror(f"NotificationManager: Fehler im Check-Loop: {e}")
  187. await asyncio.sleep(60)
  188. except asyncio.CancelledError:
  189. pass
  190. def _mark_sent(
  191. self,
  192. event_id: str,
  193. notification_type: str,
  194. satellite_ids: list[str],
  195. ) -> None:
  196. """Markiert eine Benachrichtigung als gesendet."""
  197. key = (event_id, notification_type)
  198. self._sent_notifications[key] = NotificationRecord(
  199. event_id=event_id,
  200. notification_type=notification_type,
  201. sent_at=datetime.now(),
  202. satellite_ids=satellite_ids,
  203. )
  204. def _cleanup_old_records(self, now: datetime) -> None:
  205. """Entfernt Notification-Records die aelter als 24 Stunden sind."""
  206. cutoff = now - timedelta(hours=24)
  207. expired = [
  208. key for key, record in self._sent_notifications.items()
  209. if record.sent_at < cutoff
  210. ]
  211. for key in expired:
  212. del self._sent_notifications[key]
  213. def _is_quiet_time(self, now: datetime) -> bool:
  214. """Prueft ob gerade Quiet Hours aktiv sind."""
  215. if not self._quiet_enabled:
  216. return False
  217. current_time = now.time()
  218. if self._quiet_start <= self._quiet_end:
  219. # z.B. 22:00 - 23:00
  220. return self._quiet_start <= current_time <= self._quiet_end
  221. else:
  222. # z.B. 22:00 - 07:00 (ueber Mitternacht)
  223. return current_time >= self._quiet_start or current_time <= self._quiet_end
  224. @staticmethod
  225. def _parse_time(time_str: str) -> time:
  226. """Parst einen Zeit-String (HH:MM) in ein time-Objekt."""
  227. try:
  228. parts = time_str.split(":")
  229. return time(int(parts[0]), int(parts[1]))
  230. except (ValueError, IndexError):
  231. return time(0, 0)