satellite_manager.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415
  1. # -*- coding: utf-8 -*-
  2. """
  3. SatelliteManager für die Verwaltung aller verbundenen Satellites.
  4. """
  5. import asyncio
  6. from typing import Iterator, overload, TYPE_CHECKING
  7. from trixy_core.satellite.satellite import Satellite, ConnectionState
  8. from trixy_core.utils.debug import pinfo, pdebug, perror, pwarn
  9. if TYPE_CHECKING:
  10. from trixy_core.application import IApplication
  11. class SatelliteManager:
  12. """
  13. Verwaltet alle registrierten und verbundenen Satellites.
  14. Bietet:
  15. - Registry aller Satellites
  16. - Suche nach Room, MAC, Verbindungsstatus
  17. - Index-Zugriff mit __getitem__
  18. - Verbindungsmanagement
  19. """
  20. def __init__(self, application: "IApplication") -> None:
  21. """
  22. Initialisiert den SatelliteManager.
  23. Args:
  24. application: Referenz zur Hauptanwendung
  25. """
  26. self._application = application
  27. self._satellites: dict[str, Satellite] = {}
  28. self._mac_index: dict[str, str] = {} # MAC -> Satellite-ID
  29. self._room_index: dict[str, set[str]] = {} # Room -> Set von Satellite-IDs
  30. @property
  31. def count(self) -> int:
  32. """Anzahl registrierter Satellites."""
  33. return len(self._satellites)
  34. @property
  35. def connected_count(self) -> int:
  36. """Anzahl verbundener Satellites."""
  37. return sum(1 for s in self._satellites.values() if s.is_connected)
  38. def add(self, satellite: Satellite) -> None:
  39. """
  40. Fügt einen Satellite hinzu.
  41. Args:
  42. satellite: Der hinzuzufügende Satellite
  43. """
  44. # Application-Referenz setzen für Event-Zugriff
  45. satellite.application = self._application
  46. self._satellites[satellite.id] = satellite
  47. # MAC-Index aktualisieren
  48. if satellite.mac_address:
  49. self._mac_index[satellite.mac_address] = satellite.id
  50. # Room-Index aktualisieren
  51. if satellite.room_id:
  52. if satellite.room_id not in self._room_index:
  53. self._room_index[satellite.room_id] = set()
  54. self._room_index[satellite.room_id].add(satellite.id)
  55. pdebug(f"Satellite hinzugefügt: {satellite.alias} ({satellite.id})")
  56. def remove(self, satellite_id: str) -> Satellite | None:
  57. """
  58. Entfernt einen Satellite.
  59. Args:
  60. satellite_id: ID des zu entfernenden Satellites
  61. Returns:
  62. Der entfernte Satellite oder None
  63. """
  64. satellite = self._satellites.pop(satellite_id, None)
  65. if satellite:
  66. # Indizes bereinigen
  67. if satellite.mac_address in self._mac_index:
  68. del self._mac_index[satellite.mac_address]
  69. if satellite.room_id in self._room_index:
  70. self._room_index[satellite.room_id].discard(satellite_id)
  71. if not self._room_index[satellite.room_id]:
  72. del self._room_index[satellite.room_id]
  73. pdebug(f"Satellite entfernt: {satellite.alias} ({satellite_id})")
  74. return satellite
  75. def get(self, satellite_id: str) -> Satellite | None:
  76. """
  77. Gibt einen Satellite nach ID zurück.
  78. Args:
  79. satellite_id: ID des Satellites
  80. Returns:
  81. Satellite oder None
  82. """
  83. return self._satellites.get(satellite_id)
  84. def get_by_mac(self, mac_address: str) -> Satellite | None:
  85. """
  86. Gibt einen Satellite nach MAC-Adresse zurück.
  87. Args:
  88. mac_address: MAC-Adresse
  89. Returns:
  90. Satellite oder None
  91. """
  92. satellite_id = self._mac_index.get(mac_address)
  93. if satellite_id:
  94. return self._satellites.get(satellite_id)
  95. return None
  96. def get_by_room(self, room_id: str) -> list[Satellite]:
  97. """
  98. Gibt alle Satellites in einem Raum zurück.
  99. Args:
  100. room_id: Raum-Kennung
  101. Returns:
  102. Liste der Satellites im Raum
  103. """
  104. satellite_ids = self._room_index.get(room_id, set())
  105. return [self._satellites[sid] for sid in satellite_ids if sid in self._satellites]
  106. def get_connected(self) -> list[Satellite]:
  107. """Gibt alle verbundenen Satellites zurück."""
  108. return [s for s in self._satellites.values() if s.is_connected]
  109. def get_disconnected(self) -> list[Satellite]:
  110. """Gibt alle getrennten Satellites zurück."""
  111. return [s for s in self._satellites.values() if not s.is_connected]
  112. def get_all(self) -> list[Satellite]:
  113. """Gibt alle Satellites zurück."""
  114. return list(self._satellites.values())
  115. @overload
  116. def __getitem__(self, key: int) -> Satellite: ...
  117. @overload
  118. def __getitem__(self, key: str) -> Satellite | list[Satellite]: ...
  119. def __getitem__(self, key: int | str) -> Satellite | list[Satellite]:
  120. """
  121. Index-Zugriff auf Satellites.
  122. Unterstützt:
  123. - Integer: Zugriff nach Position
  124. - String mit ID: Direkter ID-Zugriff
  125. - String mit Selektor: "room:wohnzimmer", "mac:AA:BB:CC:..."
  126. Args:
  127. key: Index, ID oder Selektor
  128. Returns:
  129. Einzelner Satellite oder Liste
  130. Raises:
  131. KeyError: Wenn nicht gefunden
  132. IndexError: Bei ungültigem Index
  133. """
  134. if isinstance(key, int):
  135. satellites = list(self._satellites.values())
  136. if key < 0 or key >= len(satellites):
  137. raise IndexError(f"Satellite-Index außerhalb des Bereichs: {key}")
  138. return satellites[key]
  139. # String-Selektoren
  140. if ":" in key:
  141. selector, value = key.split(":", 1)
  142. selector = selector.lower()
  143. if selector == "room":
  144. result = self.get_by_room(value)
  145. if not result:
  146. raise KeyError(f"Keine Satellites im Raum: {value}")
  147. return result
  148. elif selector == "mac":
  149. satellite = self.get_by_mac(value)
  150. if satellite is None:
  151. raise KeyError(f"Kein Satellite mit MAC: {value}")
  152. return satellite
  153. elif selector == "id":
  154. satellite = self._satellites.get(value)
  155. if satellite is None:
  156. raise KeyError(f"Kein Satellite mit ID: {value}")
  157. return satellite
  158. # Direkter ID-Zugriff
  159. if key in self._satellites:
  160. return self._satellites[key]
  161. raise KeyError(f"Satellite nicht gefunden: {key}")
  162. def __contains__(self, key: str) -> bool:
  163. """Prüft, ob ein Satellite existiert."""
  164. return key in self._satellites or key in self._mac_index
  165. def __iter__(self) -> Iterator[Satellite]:
  166. """Iterator über alle Satellites."""
  167. return iter(self._satellites.values())
  168. def __len__(self) -> int:
  169. """Anzahl der Satellites."""
  170. return len(self._satellites)
  171. async def disconnect(self, satellite_id: str, reason: str = "") -> bool:
  172. """
  173. Trennt einen einzelnen Satellite.
  174. Args:
  175. satellite_id: ID des Satellites
  176. reason: Grund für die Trennung
  177. Returns:
  178. True bei Erfolg
  179. """
  180. satellite = self._satellites.get(satellite_id)
  181. if satellite is None:
  182. return False
  183. await satellite.disconnect(reason)
  184. pinfo(f"Satellite getrennt: {satellite.alias} - {reason}")
  185. return True
  186. async def disconnect_all(self, reason: str = "Server shutdown") -> int:
  187. """
  188. Trennt alle verbundenen Satellites.
  189. Args:
  190. reason: Grund für die Trennung
  191. Returns:
  192. Anzahl getrennter Satellites
  193. """
  194. count = 0
  195. for satellite in self.get_connected():
  196. await satellite.disconnect(reason)
  197. count += 1
  198. pinfo(f"{count} Satellites getrennt")
  199. return count
  200. async def reconnect(self, satellite_id: str) -> bool:
  201. """
  202. Fordert einen Satellite zur Wiederverbindung auf.
  203. Args:
  204. satellite_id: ID des Satellites
  205. Returns:
  206. True bei Erfolg
  207. """
  208. satellite = self._satellites.get(satellite_id)
  209. if satellite is None:
  210. return False
  211. # Implementierung abhängig von der Netzwerk-Schicht
  212. # Hier nur Zustandsänderung
  213. satellite.state = ConnectionState.CONNECTING
  214. pwarn(f"Wiederverbindung angefordert: {satellite.alias}")
  215. return True
  216. async def reconnect_all(self) -> int:
  217. """
  218. Fordert alle getrennten Satellites zur Wiederverbindung auf.
  219. Returns:
  220. Anzahl angeforderter Wiederverbindungen
  221. """
  222. count = 0
  223. for satellite in self.get_disconnected():
  224. await self.reconnect(satellite.id)
  225. count += 1
  226. return count
  227. def find(
  228. self,
  229. room: str | None = None,
  230. alias: str | None = None,
  231. connected: bool | None = None
  232. ) -> list[Satellite]:
  233. """
  234. Sucht Satellites nach Kriterien.
  235. Args:
  236. room: Filter nach Raum
  237. alias: Filter nach Alias (Teilübereinstimmung)
  238. connected: Filter nach Verbindungsstatus
  239. Returns:
  240. Liste passender Satellites
  241. """
  242. result: list[Satellite] = []
  243. for satellite in self._satellites.values():
  244. if room is not None and satellite.room_id != room:
  245. continue
  246. if alias is not None and alias.lower() not in satellite.alias.lower():
  247. continue
  248. if connected is not None and satellite.is_connected != connected:
  249. continue
  250. result.append(satellite)
  251. return result
  252. async def say(
  253. self,
  254. text: str,
  255. room: str | None = None,
  256. voice: str = "default",
  257. ) -> bool:
  258. """
  259. Spricht Text auf allen (oder bestimmten) Satellites.
  260. Löst das 'tts_request' Event aus, das von einem TTS-Plugin
  261. verarbeitet wird.
  262. Args:
  263. text: Der zu sprechende Text.
  264. room: Nur Satellites in diesem Raum (optional).
  265. voice: Stimme/Profil für TTS.
  266. Returns:
  267. True wenn Event ausgelöst wurde.
  268. Example:
  269. # Auf allen Satellites
  270. await satellite_manager.say("Guten Morgen")
  271. # Nur im Wohnzimmer
  272. await satellite_manager.say("Willkommen", room="wohnzimmer")
  273. """
  274. from trixy_core.satellite.api import get_satellite_api
  275. api = get_satellite_api(self._application)
  276. return await api.say(text, room=room, voice=voice)
  277. async def say_raw(
  278. self,
  279. audio_data: bytes,
  280. room: str | None = None,
  281. ) -> int:
  282. """
  283. Sendet rohe Audio-Daten an Satellites.
  284. Args:
  285. audio_data: Audio-Bytes (16KHz, 16-bit, mono).
  286. room: Nur Satellites in diesem Raum (optional).
  287. Returns:
  288. Anzahl erfolgreich gesendeter Satellites.
  289. Example:
  290. count = await satellite_manager.say_raw(audio_bytes)
  291. pinfo(f"Audio an {count} Satellites gesendet")
  292. """
  293. targets = self.get_by_room(room) if room else self.get_connected()
  294. count = 0
  295. for satellite in targets:
  296. if satellite.is_connected:
  297. if await satellite.say(audio_data):
  298. count += 1
  299. return count
  300. async def play(
  301. self,
  302. source: str,
  303. room: str | None = None,
  304. volume: float = 1.0,
  305. ):
  306. """
  307. Startet Audio/Musik-Streaming auf Satellites.
  308. Args:
  309. source: Audio-Quelle (URL oder Dateipfad).
  310. room: Nur Satellites in diesem Raum (optional).
  311. volume: Lautstärke (0.0-1.0).
  312. Returns:
  313. StreamSession für Kontrolle des Streams.
  314. Example:
  315. # Musik auf allen Satellites
  316. stream = await satellite_manager.play("http://radio.mp3")
  317. # Später: Satellite hinzufügen
  318. await stream.add_satellite("küche")
  319. """
  320. from trixy_core.satellite.api import get_satellite_api
  321. api = get_satellite_api(self._application)
  322. return await api.play(source, room=room, volume=volume)