engine.py 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245
  1. # -*- coding: utf-8 -*-
  2. """
  3. LED Animations-Engine.
  4. Spielt LED-Animationen ab und verwaltet Zustandswechsel.
  5. Sorgt fuer sauberes Abbrechen laufender Animationen bei
  6. neuen Zustaenden.
  7. """
  8. from __future__ import annotations
  9. import asyncio
  10. from typing import Callable
  11. from trixy_core.led.animations import AnimationRegistry, IAnimation, RGB
  12. from trixy_core.led.driver import WS2812Driver
  13. from trixy_core.led.layouts import LEDLayout
  14. from trixy_core.utils.debug import pdebug, perror, pwarn
  15. class AnimationEngine:
  16. """Spielt LED-Animationen ab.
  17. Verwaltet den aktuellen Animations-Task und sorgt fuer
  18. sauberes Abbrechen bei Zustandswechseln. Unterstuetzt
  19. sowohl endlose (CONTINUOUS) als auch einmalige Animationen.
  20. Attributes:
  21. _driver: WS2812-Hardware-Treiber.
  22. _layout: Aktives LED-Layout.
  23. _registry: Registry mit verfuegbaren Animationen.
  24. _current_task: Aktuell laufender asyncio-Task.
  25. _current_state: Name des aktuellen Zustands.
  26. """
  27. def __init__(self, driver: WS2812Driver, layout: LEDLayout) -> None:
  28. """Erstellt eine neue AnimationEngine.
  29. Args:
  30. driver: WS2812-Hardware-Treiber (kann im No-Op-Modus sein).
  31. layout: LED-Layout fuer die Animationen.
  32. """
  33. self._driver = driver
  34. self._layout = layout
  35. self._registry = AnimationRegistry()
  36. self._registry.register_builtins()
  37. self._current_task: asyncio.Task[None] | None = None
  38. self._current_state: str = ""
  39. @property
  40. def current_state(self) -> str:
  41. """Name des aktuell aktiven Zustands."""
  42. return self._current_state
  43. @property
  44. def available_animations(self) -> list[str]:
  45. """Liste aller registrierten Animations-Namen."""
  46. return self._registry.list_names()
  47. async def play(
  48. self,
  49. state_name: str,
  50. state_config: dict[str, object],
  51. on_complete: Callable[[], None] | None = None,
  52. ) -> None:
  53. """Startet eine Animation fuer einen Zustand.
  54. Bricht eine eventuell laufende Animation ab und startet
  55. die neue. Bei einmaligen Animationen (CONTINUOUS=False)
  56. wird on_complete nach Abschluss aufgerufen.
  57. Args:
  58. state_name: Name des Zustands (z.B. "wakeword", "listening").
  59. state_config: Konfigurations-Dict mit Feldern:
  60. - color: Dict mit r, g, b (0-255)
  61. - animation: Name der Animation (z.B. "breathe")
  62. - speed: Frame-Geschwindigkeit in ms (optional, Standard: 50)
  63. - center_color: Dict mit r, g, b fuer Center-LED (optional)
  64. - Weitere animations-spezifische Parameter
  65. on_complete: Callback wenn eine einmalige Animation
  66. fertig ist. Wird bei endlosen Animationen ignoriert.
  67. """
  68. # Laufende Animation abbrechen
  69. self._cancel_current()
  70. # Zustand aktualisieren
  71. self._current_state = state_name
  72. # Konfiguration parsen
  73. color = self._parse_color(state_config.get("color", {})) # type: ignore[arg-type]
  74. animation_name: str = str(state_config.get("animation", "solid"))
  75. speed: int = int(state_config.get("speed", 50)) # type: ignore[arg-type]
  76. # Zusaetzliche Optionen extrahieren (alles ausser den Standardfeldern)
  77. reserved_keys = {"color", "animation", "speed"}
  78. opts: dict[str, object] = {
  79. k: v for k, v in state_config.items() if k not in reserved_keys
  80. }
  81. # Center-Color separat parsen wenn vorhanden
  82. if "center_color" in opts and isinstance(opts["center_color"], dict):
  83. opts["center_color"] = self._parse_color(opts["center_color"]) # type: ignore[arg-type]
  84. # Animation aus Registry holen
  85. anim_cls = self._registry.get(animation_name)
  86. if anim_cls is None:
  87. pwarn(
  88. f"Animation '{animation_name}' nicht gefunden — "
  89. f"verfuegbar: {', '.join(self.available_animations)}"
  90. )
  91. return
  92. anim = anim_cls()
  93. pdebug(
  94. f"LED-Zustand '{state_name}': Animation '{animation_name}', "
  95. f"Farbe {color}, Speed {speed}ms"
  96. )
  97. # Animation starten
  98. if anim.CONTINUOUS:
  99. self._current_task = asyncio.create_task(
  100. self._run_continuous(anim, color, speed, opts),
  101. name=f"led-anim-{state_name}",
  102. )
  103. else:
  104. self._current_task = asyncio.create_task(
  105. self._run_once_with_callback(anim, color, speed, opts, on_complete),
  106. name=f"led-anim-{state_name}",
  107. )
  108. async def stop(self) -> None:
  109. """Stoppt die aktuelle Animation und schaltet alle LEDs aus."""
  110. self._cancel_current()
  111. self._current_state = ""
  112. self._driver.clear()
  113. pdebug("LED-Engine gestoppt — alle LEDs aus")
  114. async def _run_continuous(
  115. self,
  116. anim: IAnimation,
  117. color: RGB,
  118. speed: int,
  119. opts: dict[str, object],
  120. ) -> None:
  121. """Spielt eine endlose Animation ab bis sie abgebrochen wird.
  122. Args:
  123. anim: Animations-Instanz.
  124. color: Hauptfarbe als RGB-Tupel.
  125. speed: Frame-Dauer in Millisekunden.
  126. opts: Zusaetzliche Animations-Parameter.
  127. """
  128. try:
  129. frame_delay = speed / 1000.0
  130. for frame in anim.frames(
  131. self._driver.led_count, color, speed, self._layout, **opts
  132. ):
  133. self._driver.set_pixels(frame)
  134. self._driver.show()
  135. await asyncio.sleep(frame_delay)
  136. except asyncio.CancelledError:
  137. pdebug("Endlos-Animation abgebrochen")
  138. except Exception as e:
  139. perror(f"Fehler in Endlos-Animation: {e}")
  140. async def _run_once(
  141. self,
  142. anim: IAnimation,
  143. color: RGB,
  144. speed: int,
  145. opts: dict[str, object],
  146. ) -> None:
  147. """Spielt eine einmalige Animation ab.
  148. Args:
  149. anim: Animations-Instanz.
  150. color: Hauptfarbe als RGB-Tupel.
  151. speed: Frame-Dauer in Millisekunden.
  152. opts: Zusaetzliche Animations-Parameter.
  153. """
  154. try:
  155. frame_delay = speed / 1000.0
  156. for frame in anim.frames(
  157. self._driver.led_count, color, speed, self._layout, **opts
  158. ):
  159. self._driver.set_pixels(frame)
  160. self._driver.show()
  161. await asyncio.sleep(frame_delay)
  162. except asyncio.CancelledError:
  163. pdebug("Einmal-Animation abgebrochen")
  164. except Exception as e:
  165. perror(f"Fehler in Einmal-Animation: {e}")
  166. async def _run_once_with_callback(
  167. self,
  168. anim: IAnimation,
  169. color: RGB,
  170. speed: int,
  171. opts: dict[str, object],
  172. on_complete: Callable[[], None] | None,
  173. ) -> None:
  174. """Spielt einmalige Animation ab und ruft Callback auf.
  175. Args:
  176. anim: Animations-Instanz.
  177. color: Hauptfarbe als RGB-Tupel.
  178. speed: Frame-Dauer in Millisekunden.
  179. opts: Zusaetzliche Animations-Parameter.
  180. on_complete: Callback nach Abschluss der Animation.
  181. """
  182. await self._run_once(anim, color, speed, opts)
  183. # Callback nur ausfuehren wenn nicht abgebrochen
  184. if on_complete is not None and self._current_task is not None:
  185. if not self._current_task.cancelled():
  186. try:
  187. on_complete()
  188. except Exception as e:
  189. perror(f"Fehler im on_complete-Callback: {e}")
  190. def _parse_color(self, color_dict: dict[str, int]) -> RGB:
  191. """Konvertiert ein Farb-Dict in ein RGB-Tupel.
  192. Args:
  193. color_dict: Dict mit 'r', 'g', 'b' Schluesseln (0-255).
  194. Fehlende Werte werden als 0 behandelt.
  195. Returns:
  196. RGB-Tupel (r, g, b) mit Werten von 0-255.
  197. """
  198. r = min(255, max(0, int(color_dict.get("r", 0))))
  199. g = min(255, max(0, int(color_dict.get("g", 0))))
  200. b = min(255, max(0, int(color_dict.get("b", 0))))
  201. return (r, g, b)
  202. def _cancel_current(self) -> None:
  203. """Bricht den aktuellen Animations-Task ab.
  204. Wartet nicht auf das Ende des Tasks — der Cancel wird
  205. asynchron verarbeitet.
  206. """
  207. if self._current_task is not None and not self._current_task.done():
  208. self._current_task.cancel()
  209. pdebug(f"Animation fuer Zustand '{self._current_state}' abgebrochen")
  210. self._current_task = None