satellite.py 40 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184
  1. # -*- coding: utf-8 -*-
  2. """
  3. Satellite-Klasse für die Verwaltung einzelner Satellites.
  4. """
  5. import asyncio
  6. import time
  7. import uuid
  8. import wave
  9. from dataclasses import dataclass, field
  10. from datetime import datetime
  11. from enum import IntEnum, auto
  12. from typing import Any, TYPE_CHECKING
  13. from trixy_core.utils.debug import pinfo, pdebug, perror, pwarn
  14. if TYPE_CHECKING:
  15. from asyncio import StreamWriter
  16. from trixy_core.application import IApplication
  17. class ConnectionState(IntEnum):
  18. """Verbindungszustand eines Satellites."""
  19. DISCONNECTED = auto()
  20. CONNECTING = auto()
  21. CONNECTED = auto()
  22. AUTHENTICATED = auto()
  23. DISCONNECTING = auto()
  24. ERROR = auto()
  25. @dataclass
  26. class SatelliteSocket:
  27. """Referenzen zu den Socket-Verbindungen."""
  28. command: Any = None # StreamWriter für Command-Socket
  29. audio_in: Any = None # StreamWriter für Audio-Input
  30. audio_out: Any = None # StreamWriter für Audio-Output
  31. music_out: Any = None # StreamWriter für Music-Output
  32. class Satellite:
  33. """
  34. Repräsentiert einen verbundenen Satellite.
  35. Speichert alle Informationen über einen Satellite und
  36. bietet Methoden zur Kommunikation.
  37. """
  38. def __init__(
  39. self,
  40. satellite_id: str | None = None,
  41. room_id: str = "",
  42. mac_address: str = "",
  43. alias: str = "",
  44. ip_address: str = "",
  45. application: "IApplication | None" = None
  46. ) -> None:
  47. """
  48. Initialisiert einen Satellite.
  49. Args:
  50. satellite_id: Eindeutige ID (wird generiert wenn None)
  51. room_id: Raum-Kennung
  52. mac_address: MAC-Adresse des Satellites
  53. alias: Anzeigename
  54. ip_address: IP-Adresse
  55. application: Referenz zur Hauptanwendung (für Event-Zugriff)
  56. """
  57. self._id = satellite_id or str(uuid.uuid4())
  58. self._room_id = room_id
  59. self._mac_address = mac_address
  60. self._alias = alias
  61. self._ip_address = ip_address
  62. self._application = application
  63. self._state = ConnectionState.DISCONNECTED
  64. self._sockets = SatelliteSocket()
  65. self._conversation_id: str | None = None
  66. self._connected_at: datetime | None = None
  67. self._last_heartbeat: datetime | None = None
  68. self._version: str = ""
  69. self._metadata: dict[str, Any] = {}
  70. # Request/Response-Korrelation (fuer Config-Proxy)
  71. self._pending_responses: dict[str, asyncio.Future] = {}
  72. # Wakeword-Steuerung
  73. self._wakeword_paused: bool = False
  74. self._wakeword_active: bool = True
  75. # Live-Audio-Streaming
  76. self._streaming_active: bool = False
  77. self._stream_id: str = ""
  78. self._stream_target: "Satellite | None" = None
  79. self._stream_start_time: float = 0.0
  80. self._stream_bytes_forwarded: int = 0
  81. self._stream_timeout_handle: asyncio.TimerHandle | None = None
  82. self._stream_listener_registered: bool = False
  83. # Stream-Aufnahme (Disk-basiert, nicht RAM)
  84. self._stream_recording_active: bool = False
  85. self._stream_recording_filename: str = ""
  86. self._stream_recording_wav: Any = None # wave.Wave_write
  87. self._stream_recording_start_time: float = 0.0
  88. self._stream_recording_bytes: int = 0
  89. # Server-gesteuerte Aufnahme
  90. self._recording_active: bool = False
  91. self._recording_id: str = ""
  92. self._recording_buffer: bytearray = bytearray()
  93. self._recording_start_time: float = 0.0
  94. self._recording_filename: str | None = None
  95. self._recording_timeout_handle: asyncio.TimerHandle | None = None
  96. self._recording_listener_registered: bool = False
  97. @property
  98. def id(self) -> str:
  99. """Eindeutige Satellite-ID."""
  100. return self._id
  101. @property
  102. def application(self) -> "IApplication | None":
  103. """Referenz zur Hauptanwendung."""
  104. return self._application
  105. @application.setter
  106. def application(self, value: "IApplication | None") -> None:
  107. """Setzt die Application-Referenz."""
  108. self._application = value
  109. @property
  110. def room_id(self) -> str:
  111. """Raum-Kennung."""
  112. return self._room_id
  113. @room_id.setter
  114. def room_id(self, value: str) -> None:
  115. self._room_id = value
  116. @property
  117. def mac_address(self) -> str:
  118. """MAC-Adresse."""
  119. return self._mac_address
  120. @property
  121. def alias(self) -> str:
  122. """Anzeigename."""
  123. return self._alias
  124. @alias.setter
  125. def alias(self, value: str) -> None:
  126. self._alias = value
  127. @property
  128. def ip_address(self) -> str:
  129. """IP-Adresse."""
  130. return self._ip_address
  131. @ip_address.setter
  132. def ip_address(self, value: str) -> None:
  133. self._ip_address = value
  134. @property
  135. def state(self) -> ConnectionState:
  136. """Aktueller Verbindungszustand."""
  137. return self._state
  138. @state.setter
  139. def state(self, value: ConnectionState) -> None:
  140. self._state = value
  141. @property
  142. def is_connected(self) -> bool:
  143. """Prüft, ob der Satellite verbunden ist."""
  144. return self._state in (ConnectionState.CONNECTED, ConnectionState.AUTHENTICATED)
  145. @property
  146. def sockets(self) -> SatelliteSocket:
  147. """Socket-Referenzen."""
  148. return self._sockets
  149. @property
  150. def conversation_id(self) -> str | None:
  151. """Aktuelle Konversations-ID."""
  152. return self._conversation_id
  153. @conversation_id.setter
  154. def conversation_id(self, value: str | None) -> None:
  155. self._conversation_id = value
  156. @property
  157. def has_active_conversation(self) -> bool:
  158. """Prüft, ob eine aktive Konversation läuft."""
  159. return self._conversation_id is not None
  160. @property
  161. def connected_at(self) -> datetime | None:
  162. """Zeitpunkt der Verbindung."""
  163. return self._connected_at
  164. @property
  165. def last_heartbeat(self) -> datetime | None:
  166. """Letzter Heartbeat."""
  167. return self._last_heartbeat
  168. @property
  169. def version(self) -> str:
  170. """Client-Version."""
  171. return self._version
  172. @version.setter
  173. def version(self, value: str) -> None:
  174. self._version = value
  175. @property
  176. def metadata(self) -> dict[str, Any]:
  177. """Zusätzliche Metadaten."""
  178. return self._metadata
  179. @property
  180. def wakeword_paused(self) -> bool:
  181. """Wakeword-Erkennung extern pausiert."""
  182. return self._wakeword_paused
  183. @wakeword_paused.setter
  184. def wakeword_paused(self, value: bool) -> None:
  185. """Setzt externen Pause-Zustand. Sendet automatisch Command."""
  186. if self._wakeword_paused == value:
  187. return
  188. self._wakeword_paused = value
  189. if value:
  190. from trixy_core.network.cmd import WakewordPause
  191. cmd = WakewordPause(reason="server-controlled")
  192. else:
  193. from trixy_core.network.cmd import WakewordResume
  194. cmd = WakewordResume()
  195. asyncio.ensure_future(self.send_command(cmd))
  196. @property
  197. def wakeword_active(self) -> bool:
  198. """Wakeword-Erkennung aktiv (Modelle geladen)."""
  199. return self._wakeword_active
  200. @wakeword_active.setter
  201. def wakeword_active(self, value: bool) -> None:
  202. """Aktiviert/Deaktiviert Wakeword-Erkennung. Sendet automatisch Command."""
  203. if self._wakeword_active == value:
  204. return
  205. self._wakeword_active = value
  206. if value:
  207. from trixy_core.network.cmd import WakewordControlStart
  208. cmd = WakewordControlStart()
  209. else:
  210. from trixy_core.network.cmd import WakewordControlStop
  211. cmd = WakewordControlStop(reason="server-controlled")
  212. asyncio.ensure_future(self.send_command(cmd))
  213. def set_connected(self) -> None:
  214. """Markiert den Satellite als verbunden."""
  215. self._state = ConnectionState.CONNECTED
  216. self._connected_at = datetime.now()
  217. self.update_heartbeat()
  218. def update_heartbeat(self) -> None:
  219. """Aktualisiert den Heartbeat-Zeitstempel."""
  220. self._last_heartbeat = datetime.now()
  221. async def say(self, audio_data: bytes, chunk_size: int = 32768) -> bool:
  222. """
  223. Sendet rohe Audio-Daten zum Satellite in Chunks.
  224. Args:
  225. audio_data: Audio-Bytes (16KHz, 16-bit, mono)
  226. chunk_size: Größe der Chunks in Bytes (default: 32KB)
  227. Returns:
  228. True bei Erfolg
  229. """
  230. import asyncio
  231. from trixy_core.utils.debug import pdebug
  232. if not self.is_connected or self._sockets.audio_out is None:
  233. return False
  234. try:
  235. writer: "StreamWriter" = self._sockets.audio_out
  236. offset = 0
  237. chunk_count = 0
  238. while offset < len(audio_data):
  239. chunk = audio_data[offset:offset + chunk_size]
  240. writer.write(chunk)
  241. await writer.drain()
  242. chunk_count += 1
  243. offset += chunk_size
  244. # Minimale Pause für Flow-Control (nicht zu lang!)
  245. if offset < len(audio_data):
  246. await asyncio.sleep(0.001)
  247. pdebug(f"[SERVER] Audio gesendet: {len(audio_data)} bytes in {chunk_count} Chunks")
  248. return True
  249. except Exception as e:
  250. from trixy_core.utils.debug import perror
  251. perror(f"[SERVER] Audio senden fehlgeschlagen: {e}")
  252. return False
  253. async def speak(
  254. self,
  255. text: str,
  256. voice: str = "default",
  257. speed: float = 1.0,
  258. volume: float = 1.0,
  259. ) -> bool:
  260. """
  261. Spricht Text über TTS auf diesem Satellite.
  262. Löst das 'tts_request' Event aus, das von einem TTS-Plugin
  263. verarbeitet wird.
  264. Args:
  265. text: Der zu sprechende Text.
  266. voice: Stimme/Profil für TTS.
  267. speed: Sprechgeschwindigkeit.
  268. volume: Lautstärke.
  269. Returns:
  270. True wenn Event ausgelöst wurde.
  271. Example:
  272. satellite = satellite_manager[0]
  273. await satellite.speak("Hallo Welt")
  274. """
  275. if not self._application:
  276. return False
  277. from trixy_core.events.event_data.basic import TTSRequest
  278. from trixy_core.utils.template_formatter import format_template
  279. # Template-Platzhalter aufloesen
  280. text = format_template(text, application=self._application)
  281. request_id = str(uuid.uuid4())
  282. event_data = TTSRequest(
  283. request_id=request_id,
  284. satellite_id=self._id,
  285. text=text,
  286. voice=voice,
  287. speed=speed,
  288. volume=volume,
  289. source="satellite.speak"
  290. )
  291. await self._application.events.trigger("tts_request", event_data)
  292. return not event_data.is_cancelled()
  293. async def play(
  294. self,
  295. source: str,
  296. volume: float = 1.0,
  297. ) -> bool:
  298. """
  299. Startet Audio/Musik-Wiedergabe auf diesem Satellite.
  300. Args:
  301. source: Audio-Quelle (URL oder Dateipfad).
  302. volume: Lautstärke.
  303. Returns:
  304. True wenn gestartet.
  305. Example:
  306. await satellite.play("http://radio.mp3")
  307. """
  308. if not self._application:
  309. return False
  310. from trixy_core.events.event_data.basic import StreamStart
  311. event_data = StreamStart(
  312. satellite_id=self._id,
  313. source=source,
  314. volume=volume,
  315. stream_type="music"
  316. )
  317. event_data.metadata["origin"] = "satellite.play"
  318. await self._application.events.trigger("stream_start", event_data)
  319. return not event_data.is_cancelled()
  320. async def stop_playback(self) -> bool:
  321. """
  322. Stoppt alle Wiedergabe auf diesem Satellite.
  323. Returns:
  324. True bei Erfolg.
  325. """
  326. if not self._application:
  327. return False
  328. from trixy_core.events.event_data.basic import StreamStop
  329. event_data = StreamStop(
  330. satellite_id=self._id,
  331. stream_type="all",
  332. source="satellite.stop_playback"
  333. )
  334. await self._application.events.trigger("stream_stop", event_data)
  335. return not event_data.is_cancelled()
  336. async def send_command(self, message: Any) -> bool:
  337. """
  338. Sendet einen Befehl zum Satellite.
  339. Args:
  340. message: Das zu sendende Command-Objekt
  341. Returns:
  342. True bei Erfolg
  343. """
  344. if not self.is_connected or self._sockets.command is None:
  345. return False
  346. try:
  347. from trixy_core.network.protocol import TrixyProtocol, ProtocolFlags
  348. from trixy_core.utils.debug import pdebug
  349. protocol = TrixyProtocol()
  350. data = protocol.serialize(message, ProtocolFlags.PICKLE)
  351. writer: "StreamWriter" = self._sockets.command
  352. writer.write(data)
  353. await writer.drain()
  354. pdebug(f"Command gesendet an {self._alias}: {type(message).__name__}")
  355. return True
  356. except Exception as e:
  357. from trixy_core.utils.debug import perror
  358. perror(f"Command senden fehlgeschlagen: {e}")
  359. return False
  360. async def request_and_wait(
  361. self, message: Any, timeout: float = 10.0
  362. ) -> Any | None:
  363. """
  364. Sendet eine Anfrage und wartet auf die Antwort.
  365. Nutzt message_id fuer Request/Response-Korrelation.
  366. Args:
  367. message: Das zu sendende Request-Objekt (muss message_id haben)
  368. timeout: Maximale Wartezeit in Sekunden
  369. Returns:
  370. Die Antwort-Nachricht oder None bei Timeout/Fehler
  371. """
  372. message_id = getattr(message, "message_id", "")
  373. if not message_id:
  374. return None
  375. future: asyncio.Future = asyncio.get_event_loop().create_future()
  376. self._pending_responses[message_id] = future
  377. try:
  378. success = await self.send_command(message)
  379. if not success:
  380. return None
  381. return await asyncio.wait_for(future, timeout=timeout)
  382. except asyncio.TimeoutError:
  383. pdebug(f"Request-Timeout fuer {self._alias}: {type(message).__name__}")
  384. return None
  385. except Exception as e:
  386. perror(f"Request fehlgeschlagen fuer {self._alias}: {e}")
  387. return None
  388. finally:
  389. self._pending_responses.pop(message_id, None)
  390. def resolve_pending(self, message_id: str, response: Any) -> bool:
  391. """
  392. Loest eine wartende Anfrage mit der empfangenen Antwort auf.
  393. Args:
  394. message_id: Die korrelierte message_id
  395. response: Die Antwort-Nachricht
  396. Returns:
  397. True wenn eine wartende Anfrage aufgeloest wurde
  398. """
  399. future = self._pending_responses.get(message_id)
  400. if future and not future.done():
  401. future.set_result(response)
  402. return True
  403. return False
  404. # === Server-gesteuerte Aufnahme ===
  405. @property
  406. def is_recording(self) -> bool:
  407. """Prüft ob eine server-gesteuerte Aufnahme aktiv ist."""
  408. return self._recording_active
  409. @property
  410. def recording_id(self) -> str:
  411. """Aktuelle Recording-ID."""
  412. return self._recording_id
  413. async def start_recording(
  414. self, timeout: float = 300.0, filename: str | None = None
  415. ) -> bool:
  416. """
  417. Startet eine server-gesteuerte Aufnahme auf diesem Satellite.
  418. Args:
  419. timeout: Maximale Aufnahmedauer in Sekunden
  420. filename: Optionaler Dateiname für WAV-Export
  421. Returns:
  422. True wenn Aufnahme gestartet wurde
  423. """
  424. if not self._application:
  425. perror("Keine Application-Referenz für Recording")
  426. return False
  427. if not self.is_connected:
  428. perror(f"Satellite {self._alias} nicht verbunden")
  429. return False
  430. if self._recording_active:
  431. pwarn(f"Satellite {self._alias} nimmt bereits auf")
  432. return False
  433. if self._streaming_active:
  434. pwarn(f"Satellite {self._alias} hat aktiven Stream — Aufnahme nicht möglich")
  435. return False
  436. if self.has_active_conversation:
  437. pwarn(f"Satellite {self._alias} hat aktive Konversation")
  438. return False
  439. # Recording-ID generieren
  440. self._recording_id = f"rec-{uuid.uuid4().hex[:12]}"
  441. self._recording_active = True
  442. self._recording_buffer = bytearray()
  443. self._recording_start_time = time.monotonic()
  444. self._recording_filename = filename
  445. # Event-Listener für audio_input_received registrieren
  446. self._application.events.register(
  447. "audio_input_received", self._on_recording_audio_received
  448. )
  449. # Event-Listener für satellite_record_stopped (Client-seitige Beendigung)
  450. self._application.events.register(
  451. "satellite_record_stopped", self._on_satellite_record_stopped_event
  452. )
  453. self._recording_listener_registered = True
  454. # Timeout-Timer starten
  455. loop = asyncio.get_event_loop()
  456. self._recording_timeout_handle = loop.call_later(
  457. timeout, lambda: asyncio.ensure_future(self._on_recording_timeout())
  458. )
  459. # SatelliteRecordStart Command an Satellite senden
  460. from trixy_core.network.cmd import SatelliteRecordStart
  461. cmd = SatelliteRecordStart(
  462. recording_id=self._recording_id,
  463. timeout_seconds=timeout,
  464. )
  465. await self.send_command(cmd)
  466. # Event triggern
  467. from trixy_core.events.event_data import SatelliteRecordingStarted
  468. await self._application.events.trigger(
  469. "satellite_recording_started",
  470. SatelliteRecordingStarted(
  471. satellite_id=self._id,
  472. recording_id=self._recording_id,
  473. timeout_seconds=timeout,
  474. source="satellite.start_recording",
  475. ),
  476. )
  477. pinfo(f"Server-Recording gestartet: {self._alias} ({self._recording_id}, timeout={timeout}s)")
  478. return True
  479. async def stop_recording(self, reason: str = "manual") -> bool:
  480. """
  481. Stoppt eine laufende server-gesteuerte Aufnahme.
  482. Args:
  483. reason: Grund für das Stoppen ("manual", "timeout", "wakeword", "disconnect")
  484. Returns:
  485. True wenn Aufnahme gestoppt wurde
  486. """
  487. if not self._recording_active:
  488. return False
  489. # Timeout-Timer abbrechen
  490. if self._recording_timeout_handle:
  491. self._recording_timeout_handle.cancel()
  492. self._recording_timeout_handle = None
  493. # Event-Listener entfernen
  494. if self._recording_listener_registered and self._application:
  495. self._application.events.unregister(
  496. "audio_input_received", self._on_recording_audio_received
  497. )
  498. self._application.events.unregister(
  499. "satellite_record_stopped", self._on_satellite_record_stopped_event
  500. )
  501. self._recording_listener_registered = False
  502. # Dauer berechnen
  503. duration = time.monotonic() - self._recording_start_time
  504. audio_size = len(self._recording_buffer)
  505. # WAV speichern falls Dateiname gesetzt
  506. saved_filename = ""
  507. if self._recording_filename and self._recording_buffer:
  508. self._save_recording_wav(self._recording_filename, bytes(self._recording_buffer))
  509. saved_filename = self._recording_filename
  510. recording_id = self._recording_id
  511. self._recording_active = False
  512. # SatelliteRecordStop Command an Satellite senden
  513. from trixy_core.network.cmd import SatelliteRecordStop
  514. cmd = SatelliteRecordStop(
  515. recording_id=recording_id,
  516. reason=reason,
  517. )
  518. await self.send_command(cmd)
  519. # Event triggern
  520. if self._application:
  521. from trixy_core.events.event_data import SatelliteRecordingStopped
  522. await self._application.events.trigger(
  523. "satellite_recording_stopped",
  524. SatelliteRecordingStopped(
  525. satellite_id=self._id,
  526. recording_id=recording_id,
  527. reason=reason,
  528. duration_seconds=duration,
  529. audio_size_bytes=audio_size,
  530. filename=saved_filename,
  531. source="satellite.stop_recording",
  532. ),
  533. )
  534. pinfo(
  535. f"Server-Recording gestoppt: {self._alias} ({recording_id}, "
  536. f"reason={reason}, {duration:.1f}s, {audio_size} bytes)"
  537. )
  538. return True
  539. def get_recording(self) -> bytes:
  540. """Gibt die aufgenommenen Audio-Daten zurück."""
  541. return bytes(self._recording_buffer)
  542. def clear_recording(self) -> None:
  543. """Leert den Recording-Buffer und setzt Status zurück."""
  544. cleared_bytes = len(self._recording_buffer)
  545. recording_id = self._recording_id
  546. self._recording_buffer = bytearray()
  547. self._recording_id = ""
  548. self._recording_filename = None
  549. # Event triggern
  550. if self._application and recording_id:
  551. from trixy_core.events.event_data import SatelliteRecordingCleared
  552. asyncio.ensure_future(
  553. self._application.events.trigger(
  554. "satellite_recording_cleared",
  555. SatelliteRecordingCleared(
  556. satellite_id=self._id,
  557. recording_id=recording_id,
  558. cleared_bytes=cleared_bytes,
  559. source="satellite.clear_recording",
  560. ),
  561. )
  562. )
  563. pdebug(f"Recording-Buffer geleert: {cleared_bytes} bytes")
  564. async def _on_recording_audio_received(self, event_name: str, event_data: Any) -> None:
  565. """Handler für audio_input_received Events während Recording."""
  566. # satellite_id filtern
  567. satellite_id = getattr(event_data, "satellite_id", "") or ""
  568. if isinstance(event_data, dict):
  569. satellite_id = event_data.get("satellite_id", "")
  570. if satellite_id != self._id:
  571. return
  572. if not self._recording_active:
  573. return
  574. # Audio-Daten extrahieren
  575. audio_data = getattr(event_data, "audio_data", b"") or b""
  576. if isinstance(event_data, dict):
  577. audio_data = event_data.get("audio_data", b"")
  578. if audio_data:
  579. self._recording_buffer.extend(audio_data)
  580. async def _on_recording_timeout(self) -> None:
  581. """Timeout-Handler für server-gesteuerte Aufnahme."""
  582. if self._recording_active:
  583. pwarn(f"Recording-Timeout erreicht: {self._alias} ({self._recording_id})")
  584. await self.stop_recording(reason="timeout")
  585. async def _on_satellite_record_stopped_event(self, event_name: str, event_data: Any) -> None:
  586. """Handler für satellite_record_stopped Events (Client-seitig durch Wakeword gestoppt)."""
  587. satellite_id = getattr(event_data, "satellite_id", "") or ""
  588. if isinstance(event_data, dict):
  589. satellite_id = event_data.get("satellite_id", "")
  590. if satellite_id != self._id:
  591. return
  592. if not self._recording_active:
  593. return
  594. reason = getattr(event_data, "reason", "wakeword") or "wakeword"
  595. if isinstance(event_data, dict):
  596. reason = event_data.get("reason", "wakeword")
  597. pdebug(f"Recording vom Client gestoppt: {self._alias} (reason={reason})")
  598. await self.stop_recording(reason=reason)
  599. def _save_recording_wav(self, filename: str, audio_data: bytes) -> None:
  600. """Speichert Audio-Daten als WAV-Datei (16KHz, 16-bit, mono)."""
  601. try:
  602. with wave.open(filename, "wb") as wf:
  603. wf.setnchannels(1)
  604. wf.setsampwidth(2)
  605. wf.setframerate(16000)
  606. wf.writeframes(audio_data)
  607. pinfo(f"Recording gespeichert: {filename} ({len(audio_data)} bytes)")
  608. except Exception as e:
  609. perror(f"Recording speichern fehlgeschlagen: {e}")
  610. # === Live-Audio-Streaming ===
  611. @property
  612. def is_streaming(self) -> bool:
  613. """Prüft ob ein Live-Audio-Stream aktiv ist."""
  614. return self._streaming_active
  615. @property
  616. def stream_id(self) -> str:
  617. """Aktuelle Stream-ID."""
  618. return self._stream_id
  619. @property
  620. def stream_target(self) -> "Satellite | None":
  621. """Ziel-Satellite des Streams."""
  622. return self._stream_target
  623. @property
  624. def is_stream_recording(self) -> bool:
  625. """Prüft ob der Stream parallel auf Disk aufgenommen wird."""
  626. return self._stream_recording_active
  627. async def start_stream(
  628. self, target_satellite: "Satellite", timeout: float = 0.0
  629. ) -> bool:
  630. """
  631. Startet Live-Audio-Streaming von diesem Satellite zu einem Ziel-Satellite.
  632. Audio wird in Echtzeit vom Mikrofon (Port 2102) zum Lautsprecher
  633. des Ziel-Satellites (Port 2103) weitergeleitet.
  634. Args:
  635. target_satellite: Ziel-Satellite (Lautsprecher)
  636. timeout: Maximale Stream-Dauer in Sekunden (0 = unbegrenzt)
  637. Returns:
  638. True wenn Stream gestartet wurde
  639. """
  640. if not self._application:
  641. perror("Keine Application-Referenz für Streaming")
  642. return False
  643. if not self.is_connected:
  644. perror(f"Satellite {self._alias} nicht verbunden")
  645. return False
  646. if self._streaming_active:
  647. pwarn(f"Satellite {self._alias} streamt bereits")
  648. return False
  649. if self.has_active_conversation:
  650. pwarn(f"Satellite {self._alias} hat aktive Konversation")
  651. return False
  652. if target_satellite is self:
  653. perror(f"Satellite {self._alias} kann nicht zu sich selbst streamen")
  654. return False
  655. # Laufende Aufnahme stoppen (Mutual Exclusion)
  656. if self._recording_active:
  657. pwarn(f"Satellite {self._alias} hat aktive Aufnahme — wird gestoppt für Stream")
  658. await self.stop_recording(reason="stream_start")
  659. # Ziel prüfen
  660. if not target_satellite.is_connected:
  661. perror(f"Ziel-Satellite {target_satellite.alias} nicht verbunden")
  662. return False
  663. if target_satellite.sockets.audio_out is None:
  664. perror(f"Ziel-Satellite {target_satellite.alias} hat keinen audio_out Socket")
  665. return False
  666. # Stream-State setzen
  667. self._stream_id = f"stream-{uuid.uuid4().hex[:12]}"
  668. self._streaming_active = True
  669. self._stream_target = target_satellite
  670. self._stream_start_time = time.monotonic()
  671. self._stream_bytes_forwarded = 0
  672. # Event-Listener registrieren
  673. self._application.events.register(
  674. "audio_input_received", self._on_stream_audio_received
  675. )
  676. self._application.events.register(
  677. "satellite_record_stopped", self._on_stream_record_stopped_event
  678. )
  679. self._stream_listener_registered = True
  680. # Timeout-Timer (wenn > 0)
  681. if timeout > 0:
  682. loop = asyncio.get_event_loop()
  683. self._stream_timeout_handle = loop.call_later(
  684. timeout, lambda: asyncio.ensure_future(self._on_stream_timeout())
  685. )
  686. # SatelliteRecordStart Command an Quell-Satellite senden
  687. from trixy_core.network.cmd import SatelliteRecordStart
  688. cmd = SatelliteRecordStart(
  689. recording_id=self._stream_id,
  690. timeout_seconds=timeout if timeout > 0 else 0,
  691. )
  692. await self.send_command(cmd)
  693. # Event triggern
  694. from trixy_core.events.event_data import SatelliteStreamStarted
  695. await self._application.events.trigger(
  696. "satellite_stream_started",
  697. SatelliteStreamStarted(
  698. satellite_id=self._id,
  699. target_satellite_id=target_satellite.id,
  700. stream_id=self._stream_id,
  701. source="satellite.start_stream",
  702. ),
  703. )
  704. pinfo(
  705. f"Live-Stream gestartet: {self._alias} → {target_satellite.alias} "
  706. f"({self._stream_id}, timeout={timeout}s)"
  707. )
  708. return True
  709. async def stop_stream(self, reason: str = "manual") -> bool:
  710. """
  711. Stoppt einen laufenden Live-Audio-Stream.
  712. Args:
  713. reason: Grund für das Stoppen ("manual", "timeout", "wakeword",
  714. "disconnect", "target_disconnect")
  715. Returns:
  716. True wenn Stream gestoppt wurde
  717. """
  718. if not self._streaming_active:
  719. return False
  720. # Stream-Aufnahme stoppen falls aktiv
  721. if self._stream_recording_active:
  722. await self.stop_record_stream(reason=reason)
  723. # Timeout-Timer abbrechen
  724. if self._stream_timeout_handle:
  725. self._stream_timeout_handle.cancel()
  726. self._stream_timeout_handle = None
  727. # Event-Listener entfernen
  728. if self._stream_listener_registered and self._application:
  729. self._application.events.unregister(
  730. "audio_input_received", self._on_stream_audio_received
  731. )
  732. self._application.events.unregister(
  733. "satellite_record_stopped", self._on_stream_record_stopped_event
  734. )
  735. self._stream_listener_registered = False
  736. # Dauer und Stats berechnen
  737. duration = time.monotonic() - self._stream_start_time
  738. bytes_forwarded = self._stream_bytes_forwarded
  739. stream_id = self._stream_id
  740. target_id = self._stream_target.id if self._stream_target else ""
  741. target_alias = self._stream_target.alias if self._stream_target else ""
  742. # State zurücksetzen
  743. self._streaming_active = False
  744. self._stream_id = ""
  745. self._stream_target = None
  746. self._stream_bytes_forwarded = 0
  747. # SatelliteRecordStop Command an Quell-Satellite senden
  748. from trixy_core.network.cmd import SatelliteRecordStop
  749. cmd = SatelliteRecordStop(
  750. recording_id=stream_id,
  751. reason=reason,
  752. )
  753. await self.send_command(cmd)
  754. # Event triggern
  755. if self._application:
  756. from trixy_core.events.event_data import SatelliteStreamStopped
  757. await self._application.events.trigger(
  758. "satellite_stream_stopped",
  759. SatelliteStreamStopped(
  760. satellite_id=self._id,
  761. target_satellite_id=target_id,
  762. stream_id=stream_id,
  763. reason=reason,
  764. duration_seconds=duration,
  765. bytes_forwarded=bytes_forwarded,
  766. source="satellite.stop_stream",
  767. ),
  768. )
  769. pinfo(
  770. f"Live-Stream gestoppt: {self._alias} → {target_alias} "
  771. f"({stream_id}, reason={reason}, {duration:.1f}s, {bytes_forwarded} bytes)"
  772. )
  773. return True
  774. async def start_record_stream(self, filename: str) -> bool:
  775. """
  776. Startet parallele Aufnahme des laufenden Streams in eine WAV-Datei.
  777. Args:
  778. filename: Pfad zur WAV-Datei
  779. Returns:
  780. True wenn Aufnahme gestartet wurde
  781. """
  782. if not self._streaming_active:
  783. perror("Kein aktiver Stream — Stream-Aufnahme nicht möglich")
  784. return False
  785. if self._stream_recording_active:
  786. pwarn("Stream-Aufnahme läuft bereits")
  787. return False
  788. try:
  789. wf = wave.open(filename, "wb")
  790. wf.setnchannels(1)
  791. wf.setsampwidth(2)
  792. wf.setframerate(16000)
  793. except Exception as e:
  794. perror(f"WAV-Datei öffnen fehlgeschlagen: {e}")
  795. return False
  796. self._stream_recording_active = True
  797. self._stream_recording_filename = filename
  798. self._stream_recording_wav = wf
  799. self._stream_recording_start_time = time.monotonic()
  800. self._stream_recording_bytes = 0
  801. # Event triggern
  802. if self._application:
  803. from trixy_core.events.event_data import SatelliteStreamRecordStarted
  804. await self._application.events.trigger(
  805. "satellite_stream_record_started",
  806. SatelliteStreamRecordStarted(
  807. satellite_id=self._id,
  808. stream_id=self._stream_id,
  809. filename=filename,
  810. source="satellite.start_record_stream",
  811. ),
  812. )
  813. pinfo(f"Stream-Aufnahme gestartet: {filename} ({self._stream_id})")
  814. return True
  815. async def stop_record_stream(self, reason: str = "manual") -> bool:
  816. """
  817. Stoppt die parallele Stream-Aufnahme.
  818. Args:
  819. reason: Grund für das Stoppen
  820. Returns:
  821. True wenn Aufnahme gestoppt wurde
  822. """
  823. if not self._stream_recording_active:
  824. return False
  825. # WAV-Datei schließen
  826. duration = time.monotonic() - self._stream_recording_start_time
  827. filename = self._stream_recording_filename
  828. file_size = self._stream_recording_bytes
  829. if self._stream_recording_wav:
  830. try:
  831. self._stream_recording_wav.close()
  832. except Exception as e:
  833. perror(f"WAV-Datei schließen fehlgeschlagen: {e}")
  834. self._stream_recording_wav = None
  835. # State zurücksetzen
  836. self._stream_recording_active = False
  837. self._stream_recording_filename = ""
  838. self._stream_recording_bytes = 0
  839. # Event triggern
  840. if self._application:
  841. from trixy_core.events.event_data import SatelliteStreamRecordStopped
  842. await self._application.events.trigger(
  843. "satellite_stream_record_stopped",
  844. SatelliteStreamRecordStopped(
  845. satellite_id=self._id,
  846. stream_id=self._stream_id,
  847. filename=filename,
  848. reason=reason,
  849. duration_seconds=duration,
  850. file_size_bytes=file_size,
  851. source="satellite.stop_record_stream",
  852. ),
  853. )
  854. pinfo(f"Stream-Aufnahme gestoppt: {filename} (reason={reason}, {duration:.1f}s, {file_size} bytes)")
  855. return True
  856. async def _on_stream_audio_received(self, event_name: str, event_data: Any) -> None:
  857. """Handler für audio_input_received Events während Streaming."""
  858. # satellite_id filtern
  859. satellite_id = getattr(event_data, "satellite_id", "") or ""
  860. if isinstance(event_data, dict):
  861. satellite_id = event_data.get("satellite_id", "")
  862. if satellite_id != self._id:
  863. return
  864. if not self._streaming_active or not self._stream_target:
  865. return
  866. # Audio-Daten extrahieren
  867. audio_data = getattr(event_data, "audio_data", b"") or b""
  868. if isinstance(event_data, dict):
  869. audio_data = event_data.get("audio_data", b"")
  870. if not audio_data:
  871. return
  872. # Direkter Socket-Write zum Ziel-Satellite
  873. target = self._stream_target
  874. if target.sockets.audio_out is None or not target.is_connected:
  875. pwarn(f"Ziel-Satellite {target.alias} nicht mehr erreichbar")
  876. await self.stop_stream(reason="target_disconnect")
  877. return
  878. try:
  879. writer: "StreamWriter" = target.sockets.audio_out
  880. writer.write(audio_data)
  881. await writer.drain()
  882. self._stream_bytes_forwarded += len(audio_data)
  883. except Exception as e:
  884. perror(f"Stream-Weiterleitung fehlgeschlagen: {e}")
  885. await self.stop_stream(reason="target_disconnect")
  886. return
  887. # Parallel auf Disk schreiben falls Stream-Aufnahme aktiv
  888. if self._stream_recording_active and self._stream_recording_wav:
  889. try:
  890. self._stream_recording_wav.writeframes(audio_data)
  891. self._stream_recording_bytes += len(audio_data)
  892. except Exception as e:
  893. perror(f"Stream-Aufnahme schreiben fehlgeschlagen: {e}")
  894. await self.stop_record_stream(reason="write_error")
  895. async def _on_stream_record_stopped_event(self, event_name: str, event_data: Any) -> None:
  896. """Handler für satellite_record_stopped Events (Client-seitig durch Wakeword gestoppt)."""
  897. satellite_id = getattr(event_data, "satellite_id", "") or ""
  898. if isinstance(event_data, dict):
  899. satellite_id = event_data.get("satellite_id", "")
  900. if satellite_id != self._id:
  901. return
  902. if not self._streaming_active:
  903. return
  904. reason = getattr(event_data, "reason", "wakeword") or "wakeword"
  905. if isinstance(event_data, dict):
  906. reason = event_data.get("reason", "wakeword")
  907. pdebug(f"Stream vom Client gestoppt: {self._alias} (reason={reason})")
  908. await self.stop_stream(reason=reason)
  909. async def _on_stream_timeout(self) -> None:
  910. """Timeout-Handler für Live-Audio-Stream."""
  911. if self._streaming_active:
  912. pwarn(f"Stream-Timeout erreicht: {self._alias} ({self._stream_id})")
  913. await self.stop_stream(reason="timeout")
  914. async def disconnect(self, reason: str = "") -> None:
  915. """
  916. Trennt die Verbindung zum Satellite.
  917. Sendet zuerst eine Benachrichtigung an den Client,
  918. dann werden alle Sockets geschlossen.
  919. Args:
  920. reason: Grund für die Trennung
  921. """
  922. # Laufenden Stream beenden
  923. if self._streaming_active:
  924. await self.stop_stream(reason="disconnect")
  925. # Laufende Aufnahme beenden
  926. if self._recording_active:
  927. await self.stop_recording(reason="disconnect")
  928. # Benachrichtige Client vor dem Ändern des States
  929. # (send_command prüft is_connected, daher erst senden)
  930. if self._sockets.command is not None and self.is_connected:
  931. try:
  932. from trixy_core.network.cmd import ServerShutdown
  933. shutdown_msg = ServerShutdown(
  934. reason=reason,
  935. reconnect_after_seconds=0,
  936. )
  937. await self.send_command(shutdown_msg)
  938. # Kurz warten, damit die Nachricht ankommt
  939. await asyncio.sleep(0.1)
  940. except Exception:
  941. pass # Fehler ignorieren beim Shutdown
  942. self._state = ConnectionState.DISCONNECTING
  943. # Schließe alle Sockets
  944. for socket_name in ("command", "audio_in", "audio_out", "music_out"):
  945. socket = getattr(self._sockets, socket_name)
  946. if socket is not None:
  947. try:
  948. socket.close()
  949. await socket.wait_closed()
  950. except Exception:
  951. pass
  952. setattr(self._sockets, socket_name, None)
  953. self._state = ConnectionState.DISCONNECTED
  954. self._conversation_id = None
  955. def to_dict(self) -> dict[str, Any]:
  956. """Konvertiert den Satellite in ein Dictionary."""
  957. return {
  958. "id": self._id,
  959. "room_id": self._room_id,
  960. "mac_address": self._mac_address,
  961. "alias": self._alias,
  962. "ip_address": self._ip_address,
  963. "state": self._state.name,
  964. "connected_at": self._connected_at.isoformat() if self._connected_at else None,
  965. "last_heartbeat": self._last_heartbeat.isoformat() if self._last_heartbeat else None,
  966. "version": self._version,
  967. "has_active_conversation": self.has_active_conversation,
  968. }
  969. def __repr__(self) -> str:
  970. return (
  971. f"Satellite(id={self._id!r}, room={self._room_id!r}, "
  972. f"alias={self._alias!r}, state={self._state.name})"
  973. )