main.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360
  1. # -*- coding: utf-8 -*-
  2. """
  3. Timer-Plugin fuer Trixy.
  4. Ermoeglicht das Setzen, Stoppen und Auflisten von Timern per Sprache.
  5. Bei Ablauf wird ein Notification-Sound und eine TTS-Meldung abgespielt.
  6. """
  7. import asyncio
  8. import time
  9. from dataclasses import dataclass, field
  10. from pathlib import Path
  11. from typing import Any
  12. from trixy_core.plugins import TrixyPlugin
  13. from trixy_core.nlp.intent_registry import IntentRegistry, IntentDefinition, IntentSlot
  14. from trixy_core.nlp.decorators import intent, pattern, example
  15. from trixy_core.nlp.handler import IntentReceivedData, IntentResult
  16. from trixy_core.utils.debug import pinfo, pdebug, perror, pwarn
  17. @dataclass
  18. class TimerEntry:
  19. """Ein aktiver Timer."""
  20. name: str
  21. duration: int # Sekunden
  22. satellite_id: str
  23. room_id: str
  24. created_at: float # time.time()
  25. task: asyncio.Task | None = None
  26. @property
  27. def remaining(self) -> int:
  28. """Verbleibende Sekunden."""
  29. elapsed = time.time() - self.created_at
  30. return max(0, int(self.duration - elapsed))
  31. class TimerPlugin(TrixyPlugin):
  32. """
  33. Timer-Plugin.
  34. Bietet:
  35. - Timer setzen mit optionalem Namen
  36. - Timer stoppen/entfernen
  37. - Aktive Timer auflisten
  38. - Notification-Sound und TTS bei Ablauf
  39. """
  40. NAME = "timer"
  41. VERSION = "1.0.0"
  42. DESCRIPTION = "Timer per Sprachbefehl setzen und verwalten"
  43. AUTHOR = "Trixy"
  44. def __init__(self, application, plugin_path, config=None):
  45. super().__init__(application, plugin_path, config)
  46. self._registry = IntentRegistry.get_instance()
  47. self._timers: dict[str, TimerEntry] = {}
  48. self._notification_pcm: bytes | None = None
  49. # =========================================================================
  50. # Lifecycle
  51. # =========================================================================
  52. async def on_load(self) -> None:
  53. """Initialisiert das Plugin und registriert Intents."""
  54. pinfo(f"TimerPlugin: Lade Plugin v{self.VERSION}")
  55. # Notification-Sound vorladen
  56. self._load_notification_sound()
  57. # Intents werden automatisch vom PluginManager registriert
  58. # (via @intent/@pattern/@example Dekoratoren auf den Handler-Methoden)
  59. pinfo("TimerPlugin: Geladen")
  60. async def on_unload(self) -> None:
  61. """Entlaedt das Plugin, stoppt alle Timer."""
  62. # Alle laufenden Timer abbrechen
  63. for entry in self._timers.values():
  64. if entry.task and not entry.task.done():
  65. entry.task.cancel()
  66. self._timers.clear()
  67. # Intents deregistrieren
  68. self._registry.unregister_plugin(self.NAME)
  69. self._notification_pcm = None
  70. pdebug("TimerPlugin: Entladen")
  71. async def on_enable(self) -> None:
  72. """Aktiviert das Plugin."""
  73. pinfo("TimerPlugin: Aktiviert")
  74. async def on_disable(self) -> None:
  75. """Deaktiviert das Plugin."""
  76. pinfo("TimerPlugin: Deaktiviert")
  77. # =========================================================================
  78. # Notification-Sound
  79. # =========================================================================
  80. def _load_notification_sound(self) -> None:
  81. """Laedt den Notification-Sound und entfernt den WAV-Header."""
  82. sound_name = self.get_config_value("notification_sound", "notification.wav")
  83. asset_service = self.application.services.get_service("AssetService")
  84. if not asset_service:
  85. pwarn("TimerPlugin: AssetService nicht verfuegbar, kein Notification-Sound")
  86. return
  87. audio_path: Path | None = asset_service.get_audio(sound_name)
  88. if not audio_path or not audio_path.exists():
  89. pwarn(f"TimerPlugin: Notification-Sound nicht gefunden: {sound_name}")
  90. return
  91. try:
  92. raw = audio_path.read_bytes()
  93. # WAV-Header (44 Bytes) abschneiden -> rohe PCM-Daten
  94. self._notification_pcm = raw[44:]
  95. pdebug(f"TimerPlugin: Notification-Sound geladen ({len(self._notification_pcm)} bytes PCM)")
  96. except Exception as e:
  97. perror(f"TimerPlugin: Fehler beim Laden des Notification-Sounds: {e}")
  98. # =========================================================================
  99. # Intent-Handler
  100. # =========================================================================
  101. @intent("set_timer", description="Timer mit optionalem Namen setzen")
  102. @pattern("(setze|stelle|erstelle|starte) [einen|den] timer [auf|fuer] {duration}")
  103. @pattern("timer {duration}")
  104. @pattern("weck [mich] [in] {duration}")
  105. @example("Setze Timer auf 5 Minuten", "Timer 30 Sekunden")
  106. @example("Weck mich in einer Stunde", "Stell einen Timer auf 3 Minuten")
  107. async def handle_set_timer(self, data: IntentReceivedData, duration: int = 0, name: str = "") -> IntentResult:
  108. """Verarbeitet den set_timer Intent."""
  109. # Dauer pruefen
  110. duration = data.get_slot("duration")
  111. if duration is None:
  112. return IntentResult.failure(
  113. "Keine Dauer angegeben",
  114. response_text="Ich habe keine Dauer verstanden. Wie lange soll der Timer laufen?",
  115. )
  116. try:
  117. duration = int(duration)
  118. except (TypeError, ValueError):
  119. return IntentResult.failure(
  120. f"Ungueltige Dauer: {duration}",
  121. response_text="Ich konnte die Dauer nicht verstehen.",
  122. )
  123. if duration <= 0:
  124. return IntentResult.failure(
  125. "Dauer muss positiv sein",
  126. response_text="Die Dauer muss groesser als null sein.",
  127. )
  128. # Maximale Timer-Anzahl pruefen
  129. max_timers = self.get_config_value("max_timers", 10)
  130. if len(self._timers) >= max_timers:
  131. return IntentResult.failure(
  132. f"Maximale Timer-Anzahl erreicht ({max_timers})",
  133. response_text=f"Es laufen bereits {max_timers} Timer. Stoppe zuerst einen.",
  134. )
  135. # Timer-Name bestimmen
  136. timer_name = data.get_slot("name", "Timer")
  137. if not timer_name:
  138. timer_name = "Timer"
  139. # Eindeutigen Namen sicherstellen
  140. base_name = timer_name
  141. counter = 2
  142. while timer_name in self._timers:
  143. timer_name = f"{base_name} {counter}"
  144. counter += 1
  145. # Timer erstellen
  146. entry = TimerEntry(
  147. name=timer_name,
  148. duration=duration,
  149. satellite_id=data.satellite_id,
  150. room_id=data.room_id,
  151. created_at=time.time(),
  152. )
  153. entry.task = asyncio.create_task(self._run_timer(entry))
  154. self._timers[timer_name] = entry
  155. # TTS-Bestaetigung
  156. response = f"{timer_name} auf {self._format_duration(duration)} gestellt."
  157. pinfo(f"TimerPlugin: {response} (satellite={data.satellite_id})")
  158. return IntentResult.success_with_response(response)
  159. @intent("stop_timer", description="Laufenden Timer stoppen")
  160. @pattern("(stoppe|stopp) [den] [timer] {name}")
  161. @pattern("timer [aus|abbrechen]")
  162. @example("Stoppe den Timer", "Timer aus", "Timer abbrechen")
  163. async def handle_stop_timer(self, data: IntentReceivedData, name: str = "") -> IntentResult:
  164. """Verarbeitet den stop_timer Intent."""
  165. return await self._cancel_timer(data)
  166. @intent("remove_timer", description="Timer entfernen")
  167. @pattern("(entferne|loesche) [den] [timer] {name}")
  168. @pattern("timer (entfernen|loeschen)")
  169. @example("Entferne den Pizza Timer", "Loesche Timer")
  170. async def handle_remove_timer(self, data: IntentReceivedData, name: str = "") -> IntentResult:
  171. """Verarbeitet den remove_timer Intent (delegiert an stop)."""
  172. return await self._cancel_timer(data)
  173. @intent("list_timers", description="Alle aktiven Timer auflisten")
  174. @pattern("welche timer laufen")
  175. @pattern("timer status")
  176. @pattern("zeig [meine] timer")
  177. @pattern("wie viel zeit ist [noch]")
  178. @example("Welche Timer laufen?", "Timer Status")
  179. @example("Zeig mir meine Timer", "Wie viel Zeit ist noch?")
  180. async def handle_list_timers(self, data: IntentReceivedData) -> IntentResult:
  181. """Listet alle aktiven Timer auf."""
  182. if not self._timers:
  183. return IntentResult.success_with_response(
  184. "Es laufen keine Timer."
  185. )
  186. lines: list[str] = []
  187. for entry in self._timers.values():
  188. remaining = self._format_duration(entry.remaining)
  189. lines.append(f"{entry.name}: noch {remaining}")
  190. if len(lines) == 1:
  191. response = f"Ein Timer laeuft. {lines[0]}."
  192. else:
  193. timer_list = ", ".join(lines)
  194. response = f"Es laufen {len(lines)} Timer. {timer_list}."
  195. return IntentResult.success_with_response(response)
  196. # =========================================================================
  197. # Timer-Logik
  198. # =========================================================================
  199. async def _run_timer(self, entry: TimerEntry) -> None:
  200. """Fuehrt einen Timer aus und benachrichtigt bei Ablauf."""
  201. try:
  202. await asyncio.sleep(entry.duration)
  203. except asyncio.CancelledError:
  204. pdebug(f"TimerPlugin: Timer '{entry.name}' abgebrochen")
  205. return
  206. pinfo(f"TimerPlugin: Timer '{entry.name}' abgelaufen")
  207. # Satellite holen
  208. satellites = getattr(self.application, "satellites", None)
  209. satellite = None
  210. if satellites:
  211. satellite = satellites.get(entry.satellite_id)
  212. if satellite and satellite.is_connected:
  213. # Notification-Sound abspielen
  214. if self._notification_pcm:
  215. try:
  216. await satellite.say(self._notification_pcm)
  217. except Exception as e:
  218. perror(f"TimerPlugin: Notification-Sound fehlgeschlagen: {e}")
  219. # TTS-Meldung
  220. try:
  221. await satellite.speak(f"Der {entry.name} ist abgelaufen.")
  222. except Exception as e:
  223. perror(f"TimerPlugin: TTS-Meldung fehlgeschlagen: {e}")
  224. else:
  225. pwarn(f"TimerPlugin: Satellite '{entry.satellite_id}' nicht erreichbar fuer Timer '{entry.name}'")
  226. # Timer aus dict entfernen
  227. self._timers.pop(entry.name, None)
  228. async def _cancel_timer(self, data: IntentReceivedData) -> IntentResult:
  229. """Stoppt und entfernt einen Timer."""
  230. timer_name = data.get_slot("name", "")
  231. if not timer_name:
  232. # Kein Name angegeben: Falls nur ein Timer laeuft, diesen stoppen
  233. if len(self._timers) == 0:
  234. return IntentResult.success_with_response(
  235. "Es laeuft kein Timer."
  236. )
  237. if len(self._timers) == 1:
  238. timer_name = next(iter(self._timers))
  239. else:
  240. names = ", ".join(self._timers.keys())
  241. return IntentResult.failure(
  242. "Mehrere Timer aktiv, kein Name angegeben",
  243. response_text=f"Es laufen mehrere Timer: {names}. Welchen soll ich stoppen?",
  244. )
  245. entry = self._timers.pop(timer_name, None)
  246. if not entry:
  247. return IntentResult.failure(
  248. f"Timer '{timer_name}' nicht gefunden",
  249. response_text=f"Ich habe keinen Timer mit dem Namen {timer_name} gefunden.",
  250. )
  251. # Task abbrechen
  252. if entry.task and not entry.task.done():
  253. entry.task.cancel()
  254. response = f"{timer_name} gestoppt."
  255. pinfo(f"TimerPlugin: {response}")
  256. return IntentResult.success_with_response(response)
  257. # =========================================================================
  258. # Hilfsmethoden
  259. # =========================================================================
  260. @staticmethod
  261. def _format_duration(seconds: int) -> str:
  262. """Formatiert Sekunden als lesbaren Text."""
  263. if seconds < 60:
  264. if seconds == 1:
  265. return "1 Sekunde"
  266. return f"{seconds} Sekunden"
  267. minutes = seconds // 60
  268. remaining_secs = seconds % 60
  269. if minutes >= 60:
  270. hours = minutes // 60
  271. remaining_mins = minutes % 60
  272. parts: list[str] = []
  273. if hours == 1:
  274. parts.append("1 Stunde")
  275. else:
  276. parts.append(f"{hours} Stunden")
  277. if remaining_mins == 1:
  278. parts.append("1 Minute")
  279. elif remaining_mins > 0:
  280. parts.append(f"{remaining_mins} Minuten")
  281. return " und ".join(parts)
  282. if remaining_secs == 0:
  283. if minutes == 1:
  284. return "1 Minute"
  285. return f"{minutes} Minuten"
  286. if minutes == 1:
  287. min_text = "1 Minute"
  288. else:
  289. min_text = f"{minutes} Minuten"
  290. if remaining_secs == 1:
  291. sec_text = "1 Sekunde"
  292. else:
  293. sec_text = f"{remaining_secs} Sekunden"
  294. return f"{min_text} und {sec_text}"