service.py 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251
  1. # -*- coding: utf-8 -*-
  2. """
  3. LED-Service fuer Trixy Satellites.
  4. Steuert WS2812/NeoPixel LEDs basierend auf dem aktuellen
  5. System-Zustand. Reagiert auf Events wie Wakeword-Erkennung,
  6. Audio-Streaming, TTS-Ausgabe und Verbindungsstatus.
  7. Laeuft nur auf Geraeten mit GPIO-Unterstuetzung (Raspberry Pi).
  8. Ohne rpi-ws281x deaktiviert sich der Service automatisch.
  9. Farben und Animationen sind pro Zustand individuell
  10. in der client_config.json konfigurierbar.
  11. """
  12. from __future__ import annotations
  13. import asyncio
  14. from typing import Any, TYPE_CHECKING
  15. from trixy_core.service.iservice import IService
  16. from trixy_core.service.enums import ServicePriority, ServiceGroup
  17. from trixy_core.events.decorators import TrixyEvent
  18. from trixy_core.events.enums import EventPriority
  19. from trixy_core.utils.debug import pinfo, pdebug, perror
  20. from trixy_core.led.driver import WS2812Driver
  21. from trixy_core.led.engine import AnimationEngine
  22. from trixy_core.led.layouts import get_layout
  23. if TYPE_CHECKING:
  24. from trixy_core.application import BaseApplication
  25. # Standard-Zustandskonfigurationen (Fallback wenn in Config nicht definiert)
  26. _DEFAULT_STATES: dict[str, dict] = {
  27. "boot": {"color": {"r": 0, "g": 255, "b": 0}, "animation": "solid", "speed": 100},
  28. "standby": {"color": {"r": 0, "g": 60, "b": 0}, "animation": "solid", "speed": 100},
  29. "wakeword": {"color": {"r": 0, "g": 0, "b": 255}, "animation": "double-blink", "speed": 120},
  30. "listening": {"color": {"r": 255, "g": 0, "b": 0}, "animation": "solid", "speed": 100},
  31. "thinking": {"color": {"r": 255, "g": 165, "b": 0}, "animation": "pulse", "speed": 80},
  32. "speaking": {"color": {"r": 0, "g": 100, "b": 255}, "animation": "breathe", "speed": 60},
  33. "error": {"color": {"r": 255, "g": 0, "b": 0}, "animation": "triple-blink", "speed": 150},
  34. "disconnect": {"color": {"r": 255, "g": 0, "b": 0}, "animation": "pulse", "speed": 40},
  35. }
  36. class LEDService(IService):
  37. """
  38. LED-Steuerung fuer Trixy Satellites.
  39. Reagiert auf System-Events und zeigt den aktuellen Zustand
  40. ueber WS2812/NeoPixel LEDs an. Unterstuetzt verschiedene
  41. LED-Layouts (Single, Jewel 7, Ring 12) mit konfigurierbaren
  42. Animationen pro Zustand.
  43. """
  44. PRIORITY = ServicePriority.OPTIONAL
  45. GROUP = ServiceGroup.UTILITY
  46. DEPENDENCIES = ["EventManager"]
  47. NAME = "LEDService"
  48. def __init__(self, application: "BaseApplication", led_config: dict) -> None:
  49. super().__init__(application)
  50. self._config = led_config
  51. self._driver: WS2812Driver | None = None
  52. self._engine: AnimationEngine | None = None
  53. self._current_state: str = ""
  54. self._states: dict[str, dict] = {}
  55. @property
  56. def current_state(self) -> str:
  57. """Aktueller LED-Zustand."""
  58. return self._current_state
  59. @property
  60. def available(self) -> bool:
  61. """True wenn LED-Hardware verfuegbar ist."""
  62. return self._driver is not None and self._driver.available
  63. async def start(self) -> None:
  64. """Initialisiert LED-Hardware und zeigt Boot-Animation."""
  65. gpio_pin = self._config.get("gpio_pin", 18)
  66. led_count = self._config.get("led_count", 1)
  67. brightness = self._config.get("brightness", 80)
  68. led_type = self._config.get("type", "single")
  69. # Zustandskonfigurationen laden (Config → Defaults Fallback)
  70. config_states = self._config.get("states", {})
  71. self._states = {}
  72. for state_name, default_cfg in _DEFAULT_STATES.items():
  73. if state_name in config_states:
  74. # Config-Werte mit Defaults mergen
  75. merged = {**default_cfg, **config_states[state_name]}
  76. self._states[state_name] = merged
  77. else:
  78. self._states[state_name] = dict(default_cfg)
  79. # Hardware initialisieren
  80. self._driver = WS2812Driver(
  81. gpio_pin=gpio_pin,
  82. led_count=led_count,
  83. brightness=brightness,
  84. )
  85. if not self._driver.init():
  86. pdebug("LEDService: Keine LED-Hardware verfuegbar — Service deaktiviert")
  87. self._driver = None
  88. return
  89. # Layout und Engine erstellen
  90. layout = get_layout(led_type, led_count)
  91. self._engine = AnimationEngine(self._driver, layout)
  92. # Boot-Animation abspielen
  93. await self._set_state("boot")
  94. pinfo(
  95. f"LEDService: Gestartet — {led_count} LEDs auf GPIO {gpio_pin} "
  96. f"(Typ: {led_type}, Helligkeit: {brightness})"
  97. )
  98. async def stop(self) -> None:
  99. """Stoppt LED-Service und schaltet LEDs aus."""
  100. if self._engine:
  101. await self._engine.stop()
  102. if self._driver:
  103. self._driver.cleanup()
  104. self._driver = None
  105. self._engine = None
  106. pdebug("LEDService: Gestoppt")
  107. async def health_check(self) -> bool:
  108. """Prueft ob der LED-Service funktioniert."""
  109. return self.available
  110. # === Zustandssteuerung ===
  111. async def _set_state(self, state_name: str) -> None:
  112. """
  113. Wechselt den LED-Zustand und startet die passende Animation.
  114. Args:
  115. state_name: Name des Zustands (z.B. "standby", "wakeword")
  116. """
  117. if not self._engine:
  118. return
  119. state_config = self._states.get(state_name)
  120. if not state_config:
  121. pdebug(f"LEDService: Unbekannter Zustand '{state_name}'")
  122. return
  123. self._current_state = state_name
  124. pdebug(f"LEDService: Zustand → {state_name} ({state_config.get('animation', 'solid')})")
  125. await self._engine.play(state_name, state_config)
  126. async def _set_state_with_followup(
  127. self, state_name: str, followup_state: str,
  128. ) -> None:
  129. """
  130. Spielt eine Einmal-Animation und wechselt danach zum Folgezustand.
  131. Wird fuer Wakeword verwendet: 2× blau blinken, dann → listening.
  132. """
  133. if not self._engine:
  134. return
  135. state_config = self._states.get(state_name)
  136. if not state_config:
  137. return
  138. self._current_state = state_name
  139. pdebug(f"LEDService: Zustand → {state_name} → {followup_state}")
  140. async def on_complete():
  141. await self._set_state(followup_state)
  142. await self._engine.play(
  143. state_name, state_config,
  144. on_complete=on_complete,
  145. )
  146. # === Event-Handler ===
  147. @TrixyEvent(["application_ready"], priority=EventPriority.LOW)
  148. async def _on_ready(self, event_name: str, event_data: Any) -> None:
  149. """System bereit → Standby."""
  150. await self._set_state("standby")
  151. @TrixyEvent(["wakeword_detected"], priority=EventPriority.HIGHEST)
  152. async def _on_wakeword(self, event_name: str, event_data: Any) -> None:
  153. """Wakeword erkannt → Blinken, dann Listening."""
  154. await self._set_state_with_followup("wakeword", "listening")
  155. @TrixyEvent(["recording_started"], priority=EventPriority.HIGH)
  156. async def _on_recording_started(self, event_name: str, event_data: Any) -> None:
  157. """Aufnahme gestartet → Listening (rot)."""
  158. # Nur setzen wenn nicht schon durch Wakeword-Handler gesetzt
  159. if self._current_state not in ("wakeword", "listening"):
  160. await self._set_state("listening")
  161. @TrixyEvent(["recording_stopped"], priority=EventPriority.NORMAL)
  162. async def _on_recording_stopped(self, event_name: str, event_data: Any) -> None:
  163. """Aufnahme beendet → Thinking (verarbeiten)."""
  164. await self._set_state("thinking")
  165. @TrixyEvent(["tts_started"], priority=EventPriority.NORMAL)
  166. async def _on_tts_started(self, event_name: str, event_data: Any) -> None:
  167. """TTS-Ausgabe gestartet → Speaking."""
  168. await self._set_state("speaking")
  169. @TrixyEvent(["tts_completed", "conversation_ended"], priority=EventPriority.LOW)
  170. async def _on_done(self, event_name: str, event_data: Any) -> None:
  171. """TTS fertig oder Konversation beendet → Standby."""
  172. await self._set_state("standby")
  173. @TrixyEvent(["client_connected"], priority=EventPriority.LOW)
  174. async def _on_connected(self, event_name: str, event_data: Any) -> None:
  175. """Server-Verbindung hergestellt → Standby."""
  176. await self._set_state("standby")
  177. @TrixyEvent(["client_disconnected"], priority=EventPriority.HIGH)
  178. async def _on_disconnected(self, event_name: str, event_data: Any) -> None:
  179. """Server-Verbindung verloren → Disconnect."""
  180. await self._set_state("disconnect")
  181. # === Oeffentliche API ===
  182. async def set_color(self, r: int, g: int, b: int) -> None:
  183. """Setzt alle LEDs auf eine feste Farbe (z.B. fuer Config-Tool)."""
  184. if not self._engine:
  185. return
  186. await self._engine.play("custom", {
  187. "color": {"r": r, "g": g, "b": b},
  188. "animation": "solid",
  189. })
  190. async def set_animation(self, animation: str, color: dict, speed: int = 100, **opts) -> None:
  191. """Spielt eine beliebige Animation ab (z.B. fuer Config-Tool)."""
  192. if not self._engine:
  193. return
  194. config = {"color": color, "animation": animation, "speed": speed, **opts}
  195. await self._engine.play("custom", config)
  196. async def turn_off(self) -> None:
  197. """Schaltet alle LEDs aus."""
  198. if self._engine:
  199. await self._engine.stop()
  200. self._current_state = "off"