client.py 50 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278
  1. # -*- coding: utf-8 -*-
  2. """
  3. Client/Satellite-Anwendung für Trixy.
  4. Der Client ist ein leichtgewichtiger Satellite, der:
  5. - Wakeword-Erkennung durchführt
  6. - Audio zum Server streamt
  7. - Audio-Ausgaben abspielt
  8. """
  9. import asyncio
  10. import wave
  11. import time
  12. from pathlib import Path
  13. from trixy_core.application import IApplication
  14. from trixy_core.config.datasets.client import ClientConfig
  15. from trixy_core.events.event_data.basic import SystemShutdown
  16. from trixy_core.network.client_connection import ClientConnection, ConnectionConfig, get_mac_address, parse_host_list
  17. from trixy_core.network.protocol import ProtocolMessage
  18. from trixy_core.audio.player import AudioOutputManager
  19. from trixy_core.input.keyboard_input import create_keyboard_input, KeyboardInputService, SimpleKeyboardInput
  20. from trixy_core.wakeword.service import WakewordService, WakewordServiceConfig
  21. from trixy_core.wakeword.detector import DetectorConfig
  22. from trixy_core.wakeword.audio_buffer import BufferConfig
  23. from trixy_core.wakeword.vad import VADConfig
  24. from trixy_core.audio.microphone import MicrophoneCapture
  25. from trixy_core.network.config_listener import ConfigListener
  26. from trixy_core.utils.debug import pinfo, pdebug, perror, pwarn
  27. class ClientApplication(IApplication):
  28. """
  29. Client/Satellite-Anwendung für Trixy.
  30. Verbindet sich mit einem Server und führt
  31. lokale Wakeword-Erkennung durch.
  32. """
  33. def __init__(
  34. self,
  35. host: str | None = None,
  36. port: int | None = None,
  37. room: str | None = None,
  38. alias: str | None = None,
  39. config_path: str | Path = "config/client_config.json",
  40. debug: bool = False,
  41. keyboard_input: bool = False,
  42. ) -> None:
  43. """
  44. Initialisiert den Client.
  45. Args:
  46. host: Server-Host
  47. port: Server-Port
  48. room: Raum-Kennung
  49. alias: Anzeigename
  50. config_path: Pfad zur Konfigurationsdatei
  51. debug: Debug-Modus aktivieren
  52. keyboard_input: Keyboard-Eingabe aktivieren (Ctrl+I oder Enter)
  53. """
  54. super().__init__(debug)
  55. self._config_path = Path(config_path)
  56. self._client_config: ClientConfig | None = None
  57. # Überschreibbare CLI-Parameter (None = aus Config lesen)
  58. self._cli_host: str | None = host
  59. self._cli_port: int | None = port
  60. self._cli_room: str | None = room
  61. self._cli_alias: str | None = alias
  62. # Im Debug-Modus immer Keyboard-Shortcuts aktivieren (Ctrl+P/N/S)
  63. self._keyboard_input_enabled = keyboard_input or debug
  64. # Verbindungsstatus
  65. self._connected = False
  66. self._reconnect_task: asyncio.Task | None = None
  67. self._connection: ClientConnection | None = None
  68. # Audio-Ausgabe Manager
  69. self._audio_manager: AudioOutputManager | None = None
  70. # Keyboard-Input Service
  71. self._keyboard_service: KeyboardInputService | SimpleKeyboardInput | None = None
  72. # Wakeword-Service
  73. self._wakeword_service: WakewordService | None = None
  74. self._microphone: MicrophoneCapture | None = None
  75. # ConfigListener
  76. self._config_listener: ConfigListener | None = None
  77. # Server-gesteuerte Aufnahme (Fallback ohne WakewordService)
  78. self._direct_streaming_active: bool = False
  79. # DEBUG: Audio-Chunks für WAV-Export sammeln
  80. self._debug_tts_chunks: list[bytes] = []
  81. self._debug_music_chunks: list[bytes] = []
  82. @property
  83. def client_config(self) -> ClientConfig:
  84. """Client-Konfiguration."""
  85. if self._client_config is None:
  86. raise RuntimeError("Client nicht initialisiert")
  87. return self._client_config
  88. @property
  89. def is_connected(self) -> bool:
  90. """Ist der Client mit dem Server verbunden?"""
  91. return self._connected and self._connection is not None and self._connection.is_connected
  92. @property
  93. def connection(self) -> ClientConnection | None:
  94. """Die aktive Verbindung zum Server."""
  95. return self._connection
  96. @property
  97. def audio_manager(self) -> AudioOutputManager | None:
  98. """Audio-Ausgabe Manager."""
  99. return self._audio_manager
  100. @property
  101. def server_host(self) -> str:
  102. """Server-Host (erster Eintrag der Host-Liste fuer Backward-Compat)."""
  103. hosts = self.server_hosts
  104. return hosts[0] if hosts else "localhost"
  105. @property
  106. def server_hosts(self) -> list[str]:
  107. """Liste aller Server-Hosts die nacheinander probiert werden.
  108. Quellen (in Reihenfolge):
  109. 1. CLI-Argument (--host kommagetrennt oder mehrfach)
  110. 2. Client-Config (server.host als String oder Liste)
  111. 3. Default ["localhost"]
  112. """
  113. # CLI-Wert hat Vorrang
  114. if self._cli_host is not None:
  115. return parse_host_list(self._cli_host)
  116. # Aus Client-Config
  117. if self._client_config:
  118. return parse_host_list(self._client_config.server.host) or ["localhost"]
  119. return ["localhost"]
  120. @property
  121. def server_port(self) -> int:
  122. """Server-Port."""
  123. if self._cli_port is not None:
  124. return self._cli_port
  125. if self._client_config:
  126. return self._client_config.server.port
  127. return 2101
  128. @property
  129. def room(self) -> str:
  130. """Raum-Kennung."""
  131. if self._cli_room is not None:
  132. return self._cli_room
  133. if self._client_config:
  134. return self._client_config.identity.room
  135. return ""
  136. @property
  137. def alias(self) -> str:
  138. """Anzeigename."""
  139. if self._cli_alias is not None:
  140. return self._cli_alias
  141. if self._client_config:
  142. return self._client_config.identity.alias
  143. return ""
  144. async def initialize(self) -> None:
  145. """Initialisiert den Client."""
  146. pinfo("Initialisiere Client...")
  147. # Konfiguration laden (optional, kann fehlen)
  148. if self._config_path.exists():
  149. self._client_config = self.config_manager.load(
  150. self._config_path,
  151. ClientConfig,
  152. name="client",
  153. auto_reload=True
  154. )
  155. else:
  156. self._client_config = ClientConfig()
  157. pwarn(f"Keine Konfigurationsdatei gefunden: {self._config_path}")
  158. # CLI-Parameter in Konfiguration überschreiben
  159. if self._cli_host is not None:
  160. self._client_config.server.host = self._cli_host
  161. if self._cli_port is not None:
  162. self._client_config.server.port = self._cli_port
  163. if self._cli_room is not None:
  164. self._client_config.identity.room = self._cli_room
  165. if self._cli_alias is not None:
  166. self._client_config.identity.alias = self._cli_alias
  167. # Audio-Ausgabe Manager initialisieren (Sample-Rates aus Config)
  168. audio_config = self._client_config.audio
  169. music_sample_rate = getattr(audio_config, 'music_sample_rate', 48000)
  170. music_channels = getattr(audio_config, 'music_channels', 2)
  171. self._audio_manager = AudioOutputManager(
  172. tts_sample_rate=22050, # Piper Standard (wird vom Server überschrieben)
  173. music_sample_rate=music_sample_rate,
  174. )
  175. audio_ok = await self._audio_manager.initialize()
  176. if audio_ok:
  177. pinfo(f"Audio-Manager initialisiert: Musik={music_sample_rate}Hz, {music_channels}ch")
  178. else:
  179. pwarn("Audio-Manager konnte nicht initialisiert werden (keine Audio-Wiedergabe)")
  180. # Wakeword-Service erstellen
  181. ww_config = self._client_config.wakeword
  182. if ww_config.enabled:
  183. try:
  184. service_config = WakewordServiceConfig(
  185. detector=DetectorConfig(
  186. model_directory=ww_config.model_directory,
  187. models=ww_config.models,
  188. threshold=ww_config.threshold,
  189. use_onnx=ww_config.use_onnx,
  190. ),
  191. buffer=BufferConfig(
  192. pre_buffer_seconds=ww_config.pre_buffer_seconds,
  193. max_duration_seconds=ww_config.max_recording_seconds,
  194. ),
  195. vad=VADConfig(
  196. silence_duration_ms=int(ww_config.silence_timeout_seconds * 1000),
  197. max_duration_ms=int(ww_config.max_recording_seconds * 1000),
  198. no_speech_timeout_ms=int(ww_config.no_speech_timeout_seconds * 1000),
  199. ),
  200. standalone_mode=False,
  201. wakeword_cooldown_seconds=ww_config.wakeword_cooldown_seconds,
  202. )
  203. self._wakeword_service = WakewordService(self, service_config)
  204. self._microphone = MicrophoneCapture(
  205. device=self._client_config.audio.input_device,
  206. )
  207. pinfo("Wakeword-Service erstellt")
  208. except Exception as e:
  209. pwarn(f"Wakeword-Service nicht verfügbar: {e}")
  210. # HID Media Key Service (Konferenzmikrofone, Headsets)
  211. try:
  212. from trixy_core.hid import HIDService, HIDServiceConfig
  213. self._hid_service = HIDService(self, HIDServiceConfig())
  214. self.services.register_instance(self._hid_service)
  215. pdebug("HID-Service registriert")
  216. except Exception as e:
  217. pdebug(f"HID-Service nicht verfuegbar: {e}")
  218. # ALSA Volume Handler (Hardware-Lautstaerke via amixer)
  219. try:
  220. from trixy_core.audio.volume import VolumeHandler
  221. self._volume_handler = VolumeHandler(
  222. self,
  223. output_control=self._client_config.audio.output_mixer_control,
  224. input_control=self._client_config.audio.input_mixer_control,
  225. config_name="client",
  226. )
  227. pdebug("Volume Handler erstellt")
  228. except Exception as e:
  229. pdebug(f"Volume Handler nicht verfuegbar: {e}")
  230. # PulseAudio Combined-Sink (Multi-Speaker, Phase 2)
  231. try:
  232. from trixy_core.audio.pulse_combined import PulseCombinedSink
  233. self._combined_sink = PulseCombinedSink(
  234. sink_name=self._client_config.audio.output_combine_sink_name,
  235. )
  236. pdebug("Combined-Sink Wrapper erstellt")
  237. except Exception as e:
  238. pdebug(f"Combined-Sink nicht verfuegbar: {e}")
  239. # PulseAudio Mic-Mix (Multi-Mic, Phase 3)
  240. try:
  241. from trixy_core.audio.pulse_mic_mix import PulseMicrophoneMix
  242. self._mic_mix = PulseMicrophoneMix(
  243. sink_name=self._client_config.audio.input_mix_sink_name,
  244. )
  245. pdebug("Mic-Mix Wrapper erstellt")
  246. except Exception as e:
  247. pdebug(f"Mic-Mix nicht verfuegbar: {e}")
  248. # LED-Service (WS2812/NeoPixel fuer visuelle Status-Anzeige)
  249. if self._client_config.led.enabled:
  250. try:
  251. from trixy_core.led.service import LEDService
  252. led_config = {
  253. "gpio_pin": self._client_config.led.gpio_pin,
  254. "led_count": self._client_config.led.led_count,
  255. "type": self._client_config.led.type,
  256. "brightness": self._client_config.led.brightness,
  257. "states": self._client_config.led.states,
  258. }
  259. self._led_service = LEDService(self, led_config)
  260. self.services.register_instance(self._led_service)
  261. pdebug("LED-Service erstellt")
  262. except Exception as e:
  263. pdebug(f"LED-Service nicht verfuegbar: {e}")
  264. # ConfigListener erstellen und registrieren (Remote-Config-Tool Zugriff)
  265. if self._client_config.config_listener_enabled:
  266. self._config_listener = ConfigListener(
  267. self,
  268. port=self._client_config.config_port,
  269. bind_address="0.0.0.0",
  270. encryption_key_path="certs/encryption.key",
  271. instance_type="client",
  272. )
  273. self.services.register_instance(self._config_listener)
  274. else:
  275. pdebug("ConfigListener deaktiviert")
  276. pdebug(f"Client initialisiert: {self.alias} in {self.room}")
  277. async def start(self) -> None:
  278. """Startet den Client."""
  279. pinfo(f"Starte Client als '{self.alias}' in '{self.room}'...")
  280. from trixy_core.utils.version import VERSION_STRING
  281. await self.events.emit("application_starting", {
  282. "mode": "client",
  283. "version": VERSION_STRING,
  284. })
  285. # Services starten
  286. await self.services.start_all()
  287. # Keyboard-Input vorbereiten (falls aktiviert)
  288. # Wird nach Verbindungsaufbau gestartet um Connection zu haben
  289. if self._keyboard_input_enabled:
  290. self._keyboard_service = create_keyboard_input(
  291. self,
  292. connection=None, # Wird nach Connect gesetzt
  293. prompt=f"{self.alias}> ",
  294. )
  295. # Verbindung zum Server herstellen
  296. await self._connect()
  297. # Volume Handler starten (nach Services, braucht Events)
  298. if hasattr(self, "_volume_handler"):
  299. await self._volume_handler.start()
  300. # Combined-Sink anwenden falls Multi-Speaker konfiguriert
  301. if hasattr(self, "_combined_sink"):
  302. await self._apply_combined_sink()
  303. # Mic-Mix anwenden falls Multi-Mic konfiguriert
  304. if hasattr(self, "_mic_mix"):
  305. await self._apply_mic_mix()
  306. hosts = self.server_hosts
  307. if len(hosts) > 1:
  308. pinfo(f"Client gestartet, verbinde zu einem von {len(hosts)} Hosts: {', '.join(hosts)} (Port {self.server_port})")
  309. else:
  310. pinfo(f"Client gestartet, verbinde zu {hosts[0]}:{self.server_port}")
  311. from trixy_core.utils.version import VERSION_STRING
  312. await self.events.emit("application_ready", {
  313. "mode": "client",
  314. "version": VERSION_STRING,
  315. "services_count": len(self.services.services),
  316. })
  317. async def _apply_combined_sink(self) -> None:
  318. """Erstellt/aktualisiert den Combined-Sink anhand der Config.
  319. Wird beim Start aufgerufen und kann zur Laufzeit (z.B. ueber
  320. F6 Audio View) erneut getriggert werden um eine neue Speaker-
  321. Auswahl scharfzuschalten.
  322. """
  323. if not hasattr(self, "_combined_sink"):
  324. return
  325. try:
  326. speakers = self._client_config.audio.output_speakers or []
  327. enabled = [
  328. sp for sp in speakers
  329. if getattr(sp, "enabled", False) and getattr(sp, "device", "")
  330. ]
  331. if len(enabled) < 2:
  332. # Keine bzw. nur ein Sprecher → Combined-Sink abbauen,
  333. # klassischer Single-Output-Pfad bleibt aktiv.
  334. await self._combined_sink.teardown()
  335. return
  336. if not await self._combined_sink.available():
  337. pwarn(
  338. "Multi-Speaker konfiguriert aber PulseAudio/PipeWire "
  339. "nicht verfuegbar — bleibe auf Single-Output."
  340. )
  341. return
  342. slaves = [sp.device for sp in enabled]
  343. ok = await self._combined_sink.setup(slaves)
  344. if not ok:
  345. return
  346. # Per-Slave-Lautstaerken anwenden
  347. for sp in enabled:
  348. await self._combined_sink.set_sink_volume(sp.device, int(sp.volume))
  349. # Combined-Sink zum System-Default machen damit der
  350. # sounddevice-Player (oeffnet ohne explizites device=)
  351. # automatisch im Verbund landet. Vorheriger Default wird
  352. # gemerkt und bei stop()/teardown() wiederhergestellt.
  353. await self._combined_sink.make_default()
  354. pinfo(
  355. f"Multi-Speaker aktiv: {len(slaves)} Lautsprecher → "
  356. f"Combined-Sink '{self._combined_sink.sink_name}'"
  357. )
  358. except Exception as e:
  359. perror(f"Combined-Sink konnte nicht angewendet werden: {e}")
  360. async def _apply_mic_mix(self) -> None:
  361. """Erstellt/aktualisiert den Multi-Mic-Mix anhand der Config.
  362. Bei ≥2 enabled Mikrofonen wird ein Pulse null-sink + loopback
  363. pro Mic erstellt und der System-Default-Source auf den Monitor
  364. des Null-Sinks gesetzt. Dadurch sieht der bestehende
  365. MicrophoneCapture-Code weiterhin nur EINE Quelle.
  366. """
  367. if not hasattr(self, "_mic_mix"):
  368. return
  369. try:
  370. mics = self._client_config.audio.input_microphones or []
  371. enabled = [
  372. m for m in mics
  373. if getattr(m, "enabled", False) and getattr(m, "device", "")
  374. ]
  375. if len(enabled) < 2:
  376. await self._mic_mix.teardown()
  377. return
  378. if not await self._mic_mix.available():
  379. pwarn(
  380. "Multi-Mic konfiguriert aber PulseAudio/PipeWire "
  381. "nicht verfuegbar — bleibe auf Single-Input."
  382. )
  383. return
  384. sources = [m.device for m in enabled]
  385. ok = await self._mic_mix.setup(sources)
  386. if not ok:
  387. return
  388. # Monitor-Source zum System-Default machen damit der
  389. # Wakeword-Service (oeffnet ohne explizites device=)
  390. # automatisch aus dem Mix liest. Vorheriger Default wird
  391. # gemerkt und bei stop()/teardown() wiederhergestellt.
  392. await self._mic_mix.make_default()
  393. pinfo(
  394. f"Multi-Mic aktiv: {len(sources)} Mikrofone → "
  395. f"'{self._mic_mix.monitor_source_name}'"
  396. )
  397. except Exception as e:
  398. perror(f"Mic-Mix konnte nicht angewendet werden: {e}")
  399. async def stop(self) -> None:
  400. """Stoppt den Client."""
  401. pinfo("Stoppe Client...")
  402. await self.events.emit("application_stopping", {"reason": "shutdown"})
  403. # Mic-Mix entladen (vor Combined-Sink, weil beide den Default
  404. # zuruecksetzen koennen — Reihenfolge macht's deterministischer)
  405. if hasattr(self, "_mic_mix"):
  406. try:
  407. await self._mic_mix.teardown()
  408. except Exception as e:
  409. pdebug(f"Mic-Mix teardown Fehler: {e}")
  410. # Combined-Sink Modul entladen falls vorhanden
  411. if hasattr(self, "_combined_sink"):
  412. try:
  413. await self._combined_sink.teardown()
  414. except Exception as e:
  415. pdebug(f"Combined-Sink teardown Fehler: {e}")
  416. # DEBUG: Gesammelte Audio-Chunks als WAV speichern
  417. if self._debug_tts_chunks:
  418. self._debug_save_wav(self._debug_tts_chunks, "tts")
  419. self._debug_tts_chunks = []
  420. if self._debug_music_chunks:
  421. self._debug_save_wav(self._debug_music_chunks, "music")
  422. self._debug_music_chunks = []
  423. # Wakeword-Service stoppen
  424. if self._wakeword_service:
  425. await self._wakeword_service.stop_listening()
  426. await self._wakeword_service.on_stop()
  427. if self._microphone:
  428. self._microphone.stop()
  429. # Keyboard-Input stoppen
  430. if self._keyboard_service:
  431. await self._keyboard_service.stop()
  432. self._keyboard_service = None
  433. # Reconnect-Task abbrechen
  434. if self._reconnect_task and not self._reconnect_task.done():
  435. self._reconnect_task.cancel()
  436. try:
  437. await self._reconnect_task
  438. except asyncio.CancelledError:
  439. pass
  440. # Shutdown-Event auslösen
  441. await self.events.trigger(
  442. "system_shutdown",
  443. SystemShutdown(reason="Client shutdown", source="client")
  444. )
  445. # Verbindung trennen
  446. await self._disconnect()
  447. # Audio-Manager beenden
  448. if self._audio_manager:
  449. await self._audio_manager.shutdown()
  450. self._audio_manager = None
  451. # Services stoppen
  452. await self.services.stop_all()
  453. # ConfigManager aufräumen
  454. self.config_manager.stop_watching()
  455. pinfo("Client gestoppt")
  456. async def _connect(self) -> bool:
  457. """
  458. Verbindet sich mit dem Server.
  459. Returns:
  460. True bei erfolgreicher Verbindung
  461. """
  462. try:
  463. pdebug(f"Verbinde zu {self.server_host}:{self.server_port}...")
  464. # MAC-Adresse ermitteln
  465. mac_address = self._client_config.identity.mac_address if self._client_config else ""
  466. if not mac_address:
  467. mac_address = get_mac_address()
  468. pdebug(f"MAC-Adresse ermittelt: {mac_address}")
  469. # Verbindungskonfiguration
  470. connection_config = ConnectionConfig(
  471. connect_timeout=self._client_config.server.connection_timeout_seconds if self._client_config else 10.0,
  472. reconnect_interval=self._client_config.server.reconnect_interval_seconds if self._client_config else 5.0,
  473. )
  474. # ClientConnection erstellen mit allen konfigurierten Hosts
  475. # ClientConnection probiert die Hosts der Reihe nach durch
  476. self._connection = ClientConnection(
  477. host=self.server_hosts,
  478. port=self.server_port,
  479. room=self.room,
  480. alias=self.alias,
  481. mac_address=mac_address,
  482. config=connection_config,
  483. )
  484. # Callbacks registrieren
  485. self._connection.on_connected(self._on_connected)
  486. self._connection.on_disconnected(self._on_disconnected)
  487. self._connection.on_message(self._on_message)
  488. self._connection.on_audio_out(self._on_audio_out)
  489. self._connection.on_music(self._on_music)
  490. # Verbinden
  491. success = await self._connection.connect()
  492. if success:
  493. self._connected = True
  494. pinfo(f"Verbunden mit Server {self.server_host}:{self.server_port}")
  495. # Audio-Format vom Server anwenden
  496. if self._audio_manager:
  497. tts_format = self._connection.tts_format
  498. music_format = self._connection.music_format
  499. await self._audio_manager.update_tts_format(
  500. sample_rate=tts_format["sample_rate"],
  501. channels=tts_format["channels"],
  502. sample_width=tts_format["sample_width"]
  503. )
  504. await self._audio_manager.update_music_format(
  505. sample_rate=music_format["sample_rate"],
  506. channels=music_format["channels"],
  507. sample_width=music_format["sample_width"]
  508. )
  509. pinfo(f"Audio-Format: TTS={tts_format['sample_rate']}Hz, Musik={music_format['sample_rate']}Hz")
  510. # Event auslösen
  511. await self.events.emit("client_connected", {
  512. "host": self.server_host,
  513. "port": self.server_port,
  514. "satellite_id": self._connection.satellite_id,
  515. })
  516. return True
  517. else:
  518. self._connected = False
  519. self._schedule_reconnect()
  520. return False
  521. except Exception as e:
  522. perror(f"Verbindungsfehler: {e}")
  523. self._connected = False
  524. self._schedule_reconnect()
  525. return False
  526. async def _on_connected(self) -> None:
  527. """Callback wenn Verbindung hergestellt."""
  528. pdebug("Verbindungs-Callback: connected")
  529. # Keyboard-Input mit Verbindung starten
  530. if self._keyboard_service and self._connection:
  531. self._keyboard_service.set_connection(self._connection)
  532. await self._keyboard_service.start()
  533. # Wakeword-Service konfigurieren und starten
  534. if self._wakeword_service and self._connection and self._microphone:
  535. self._wakeword_service.setup_for_client(self._connection, self._microphone)
  536. # Callback für Wakeword-getriggertes Recording-Stop registrieren
  537. connection = self._connection
  538. async def on_recording_stopped_by_wakeword(recording_id: str, duration: float) -> None:
  539. if connection:
  540. from trixy_core.network.cmd import SatelliteRecordStopped
  541. cmd = SatelliteRecordStopped(
  542. recording_id=recording_id,
  543. reason="wakeword",
  544. duration_seconds=duration,
  545. )
  546. await connection.send_command(cmd)
  547. self._wakeword_service.set_server_recording_stopped_callback(
  548. on_recording_stopped_by_wakeword
  549. )
  550. try:
  551. await self._wakeword_service.on_start()
  552. await self._wakeword_service.start_listening()
  553. pinfo("Wakeword-Erkennung aktiv")
  554. except Exception as e:
  555. pwarn(f"Wakeword-Start fehlgeschlagen: {e}")
  556. async def _on_disconnected(self, reason: str) -> None:
  557. """Callback wenn Verbindung getrennt."""
  558. pwarn(f"Verbindung getrennt: {reason}")
  559. self._connected = False
  560. # Event auslösen
  561. await self.events.emit("client_disconnected", {
  562. "reason": reason,
  563. })
  564. # Wiederverbindung planen
  565. if self._running:
  566. self._schedule_reconnect()
  567. async def _on_message(self, message: ProtocolMessage) -> None:
  568. """Callback für eingehende Nachrichten vom Server."""
  569. pdebug(f"Nachricht empfangen: {message.class_name}")
  570. # Spezielle Behandlung für bestimmte Commands
  571. if message.class_name == "StreamStop":
  572. await self._handle_stream_stop(message.data)
  573. elif message.class_name == "TTSStop":
  574. await self._handle_tts_stop()
  575. elif message.class_name == "AudioConfigUpdate":
  576. await self._handle_audio_config_update(message.data)
  577. elif message.class_name == "FollowUpRequest":
  578. await self._handle_follow_up_request(message.data)
  579. elif message.class_name == "WakewordSelected":
  580. if self._wakeword_service:
  581. await self._wakeword_service.handle_wakeword_selected(message.data)
  582. elif message.class_name == "WakewordAbort":
  583. if self._wakeword_service:
  584. await self._wakeword_service.handle_wakeword_abort(message.data)
  585. elif message.class_name == "RecordingStop":
  586. if self._wakeword_service:
  587. await self._wakeword_service.handle_conversation_end()
  588. elif message.class_name == "ConversationEnd":
  589. await self._handle_conversation_end(message.data)
  590. if self._wakeword_service:
  591. await self._wakeword_service.handle_conversation_end()
  592. elif message.class_name == "WakewordPause":
  593. if self._wakeword_service:
  594. self._wakeword_service.external_pause()
  595. elif message.class_name == "WakewordResume":
  596. if self._wakeword_service:
  597. self._wakeword_service.external_resume()
  598. elif message.class_name == "WakewordControlStop":
  599. if self._wakeword_service:
  600. await self._wakeword_service.external_stop()
  601. elif message.class_name == "WakewordControlStart":
  602. if self._wakeword_service:
  603. await self._wakeword_service.external_start()
  604. elif message.class_name == "TextInputAck":
  605. await self._handle_text_input_ack(message.data)
  606. elif message.class_name == "ServerShutdown":
  607. await self._handle_server_shutdown(message.data)
  608. # Server-gesteuerte Aufnahme
  609. elif message.class_name == "SatelliteRecordStart":
  610. await self._handle_satellite_record_start(message.data)
  611. elif message.class_name == "SatelliteRecordStop":
  612. await self._handle_satellite_record_stop(message.data)
  613. # Media-Commands
  614. elif message.class_name == "MusicPlayPause":
  615. await self._handle_music_play_pause()
  616. elif message.class_name == "MusicNext":
  617. await self._handle_music_next(message.data)
  618. elif message.class_name == "MusicStop":
  619. await self._handle_music_stop()
  620. elif message.class_name == "MediaStopAll":
  621. await self._handle_media_stop_all()
  622. # Config-Proxy-Anfragen vom Server (fuer Config-Tool)
  623. elif message.class_name == "ConfigReadRequest":
  624. await self._handle_config_read_proxy(message.data)
  625. elif message.class_name == "ConfigWriteRequest":
  626. await self._handle_config_write_proxy(message.data)
  627. # Event auslösen
  628. await self.events.emit("network_message_received", {
  629. "class_name": message.class_name,
  630. "data": message.data,
  631. "flags": int(message.flags),
  632. })
  633. async def _handle_stream_stop(self, data: dict | object) -> None:
  634. """Behandelt Stream-Stop-Befehle vom Server."""
  635. if not self._audio_manager:
  636. return
  637. # Stream-Type und immediate Flag ermitteln
  638. stream_type = getattr(data, "stream_type", None) or "all"
  639. immediate = getattr(data, "immediate", False)
  640. if isinstance(data, dict):
  641. stream_type = data.get("stream_type", "all")
  642. immediate = data.get("immediate", False)
  643. pdebug(f"Stream-Stop empfangen: {stream_type}, immediate={immediate}")
  644. # DEBUG: WAV speichern vor Stop
  645. if stream_type in ("all", "tts") and self._debug_tts_chunks:
  646. self._debug_save_wav(self._debug_tts_chunks, "tts")
  647. self._debug_tts_chunks = []
  648. if stream_type in ("all", "music") and self._debug_music_chunks:
  649. self._debug_save_wav(self._debug_music_chunks, "music")
  650. self._debug_music_chunks = []
  651. # Bei immediate=True sofort stoppen, sonst Buffer abspielen (finish)
  652. if immediate:
  653. if stream_type == "all":
  654. self._audio_manager.stop_all()
  655. elif stream_type == "tts":
  656. self._audio_manager.stop_tts()
  657. elif stream_type == "music":
  658. self._audio_manager.stop_music()
  659. else:
  660. if stream_type == "all":
  661. self._audio_manager.finish_tts()
  662. self._audio_manager.finish_music()
  663. elif stream_type == "tts":
  664. self._audio_manager.finish_tts()
  665. elif stream_type == "music":
  666. self._audio_manager.finish_music()
  667. # Flags zurücksetzen
  668. if stream_type in ("all", "tts"):
  669. self._tts_playing = False
  670. if stream_type in ("all", "music"):
  671. self._music_playing = False
  672. async def _handle_tts_stop(self) -> None:
  673. """Behandelt TTS-Stop-Befehle vom Server."""
  674. # DEBUG: WAV speichern vor Stop
  675. if self._debug_tts_chunks:
  676. self._debug_save_wav(self._debug_tts_chunks, "tts")
  677. self._debug_tts_chunks = []
  678. # Flag zurücksetzen für nächsten TTS-Stream
  679. self._tts_playing = False
  680. if self._audio_manager:
  681. # finish_tts() signalisiert Ende und spielt Rest ab
  682. # stop_tts() würde sofort stoppen und Buffer leeren
  683. self._audio_manager.finish_tts()
  684. pdebug("TTS-Stream beendet (finish)")
  685. async def _handle_audio_config_update(self, data: dict | object) -> None:
  686. """
  687. Behandelt Audio-Konfigurationsänderungen vom Server.
  688. Wird aufgerufen wenn sich das TTS-Plugin ändert und
  689. der Client seine Audio-Ausgabe anpassen muss.
  690. """
  691. if not self._audio_manager:
  692. return
  693. # TTS-Format aktualisieren
  694. tts_sample_rate = getattr(data, "tts_sample_rate", 0)
  695. tts_channels = getattr(data, "tts_channels", 0)
  696. tts_sample_width = getattr(data, "tts_sample_width", 0)
  697. if isinstance(data, dict):
  698. tts_sample_rate = data.get("tts_sample_rate", 0)
  699. tts_channels = data.get("tts_channels", 0)
  700. tts_sample_width = data.get("tts_sample_width", 0)
  701. if tts_sample_rate > 0:
  702. await self._audio_manager.update_tts_format(
  703. sample_rate=tts_sample_rate,
  704. channels=tts_channels or 1,
  705. sample_width=tts_sample_width or 2
  706. )
  707. pinfo(f"TTS-Format aktualisiert: {tts_sample_rate}Hz, {tts_channels}ch")
  708. # Musik-Format aktualisieren (falls angegeben)
  709. music_sample_rate = getattr(data, "music_sample_rate", 0)
  710. music_channels = getattr(data, "music_channels", 0)
  711. music_sample_width = getattr(data, "music_sample_width", 0)
  712. if isinstance(data, dict):
  713. music_sample_rate = data.get("music_sample_rate", 0)
  714. music_channels = data.get("music_channels", 0)
  715. music_sample_width = data.get("music_sample_width", 0)
  716. if music_sample_rate > 0:
  717. await self._audio_manager.update_music_format(
  718. sample_rate=music_sample_rate,
  719. channels=music_channels or 2,
  720. sample_width=music_sample_width or 2
  721. )
  722. pinfo(f"Musik-Format aktualisiert: {music_sample_rate}Hz, {music_channels}ch")
  723. async def _handle_follow_up_request(self, data: dict | object) -> None:
  724. """
  725. Behandelt Follow-Up-Anfragen vom Server.
  726. Bei Keyboard-Input wird automatisch ein neuer Input-Prompt geöffnet.
  727. """
  728. from trixy_core.network.cmd import FollowUpRequest
  729. # Daten extrahieren
  730. if isinstance(data, FollowUpRequest):
  731. request = data
  732. else:
  733. session_id = getattr(data, "session_id", "") or data.get("session_id", "")
  734. question = getattr(data, "question", "") or data.get("question", "")
  735. request = FollowUpRequest(session_id=session_id, question=question)
  736. pdebug(f"Follow-Up-Request empfangen: {request.question[:50]}...")
  737. # Keyboard-Input für Antwort öffnen (falls aktiv)
  738. if self._keyboard_service:
  739. self._keyboard_service.handle_follow_up_request(request)
  740. async def _handle_conversation_end(self, data: dict | object) -> None:
  741. """
  742. Behandelt das Ende einer Conversation.
  743. Setzt den Keyboard-Input-Status zurück.
  744. """
  745. session_id = getattr(data, "session_id", "") or ""
  746. if isinstance(data, dict):
  747. session_id = data.get("session_id", "")
  748. pdebug(f"Conversation beendet: {session_id}")
  749. # Keyboard-Input zurücksetzen
  750. if self._keyboard_service:
  751. self._keyboard_service.handle_conversation_end(session_id)
  752. async def _handle_text_input_ack(self, data: dict | object) -> None:
  753. """
  754. Behandelt Bestätigung für Text-Eingabe.
  755. """
  756. session_id = getattr(data, "session_id", "") or ""
  757. received = getattr(data, "received", True)
  758. error = getattr(data, "error", "") or ""
  759. if isinstance(data, dict):
  760. session_id = data.get("session_id", "")
  761. received = data.get("received", True)
  762. error = data.get("error", "")
  763. if received:
  764. pdebug(f"TextInput bestätigt: session={session_id}")
  765. else:
  766. perror(f"TextInput fehlgeschlagen: {error}")
  767. async def _handle_server_shutdown(self, data: dict | object) -> None:
  768. """
  769. Behandelt Server-Shutdown-Benachrichtigung.
  770. Der Server fährt herunter, keine Wiederverbindung versuchen.
  771. """
  772. reason = getattr(data, "reason", "") or ""
  773. reconnect_after = getattr(data, "reconnect_after_seconds", 0) or 0
  774. if isinstance(data, dict):
  775. reason = data.get("reason", "")
  776. reconnect_after = data.get("reconnect_after_seconds", 0)
  777. pinfo(f"Server-Shutdown empfangen: {reason}")
  778. # Der Client bleibt laufend und versucht periodisch neu zu verbinden.
  779. # reconnect_after_seconds ist nur ein Hinweis wie lange gewartet werden soll,
  780. # bevor der erste Neuverbindungsversuch startet.
  781. if reconnect_after > 0:
  782. pinfo(f"Warte {reconnect_after}s bevor Wiederverbindung startet...")
  783. await asyncio.sleep(reconnect_after)
  784. if self._running:
  785. pinfo("Starte Wiederverbindungsversuche...")
  786. self._schedule_reconnect()
  787. async def _handle_music_play_pause(self) -> None:
  788. """Toggle Musik Play/Pause."""
  789. if self._audio_manager:
  790. is_paused = self._audio_manager.toggle_music_pause()
  791. status = "pausiert" if is_paused else "fortgesetzt"
  792. pinfo(f"Musik {status}")
  793. await self.events.emit("music_paused" if is_paused else "music_resumed", {})
  794. async def _handle_music_next(self, data: dict | object) -> None:
  795. """Nächstes Lied - Signal an Musik-Plugin weitergeben."""
  796. use_crossfade = getattr(data, "use_crossfade", True)
  797. if isinstance(data, dict):
  798. use_crossfade = data.get("use_crossfade", True)
  799. pdebug(f"MusicNext empfangen, crossfade={use_crossfade}")
  800. await self.events.emit("music_next", {
  801. "use_crossfade": use_crossfade,
  802. })
  803. async def _handle_music_stop(self) -> None:
  804. """Musik stoppen."""
  805. if self._audio_manager:
  806. self._audio_manager.stop_music()
  807. pinfo("Musik gestoppt")
  808. await self.events.emit("music_stopped", {})
  809. async def _handle_media_stop_all(self) -> None:
  810. """Alle Medien stoppen (Musik + TTS)."""
  811. if self._audio_manager:
  812. self._audio_manager.stop_all()
  813. pinfo("Alle Wiedergabe gestoppt")
  814. await self.events.emit("media_stopped", {})
  815. async def _handle_satellite_record_start(self, data: dict | object) -> None:
  816. """
  817. Behandelt SatelliteRecordStart vom Server.
  818. Aktiviert Audio-Streaming zum Server — entweder über den
  819. WakewordService oder als Fallback direkt über das Mikrofon.
  820. """
  821. recording_id = getattr(data, "recording_id", "") or ""
  822. timeout_seconds = getattr(data, "timeout_seconds", 300.0)
  823. if isinstance(data, dict):
  824. recording_id = data.get("recording_id", "")
  825. timeout_seconds = data.get("timeout_seconds", 300.0)
  826. pinfo(f"Server-Recording empfangen: {recording_id} (timeout={timeout_seconds}s)")
  827. if self._wakeword_service:
  828. # Mit WakewordService: Audio-Streaming im LISTENING-Zustand aktivieren
  829. self._wakeword_service.start_server_recording(recording_id)
  830. elif self._microphone and self._connection:
  831. # Fallback ohne WakewordService: Mikrofon direkt streamen
  832. connection = self._connection
  833. loop = asyncio.get_event_loop()
  834. self._microphone.set_callback(
  835. lambda audio: loop.call_soon_threadsafe(
  836. asyncio.ensure_future, connection.send_audio(audio)
  837. )
  838. )
  839. self._microphone.start()
  840. self._direct_streaming_active = True
  841. pinfo("Direktes Mikrofon-Streaming gestartet (ohne WakewordService)")
  842. async def _handle_satellite_record_stop(self, data: dict | object) -> None:
  843. """
  844. Behandelt SatelliteRecordStop vom Server.
  845. Beendet das Audio-Streaming und sendet Bestätigung.
  846. """
  847. recording_id = getattr(data, "recording_id", "") or ""
  848. reason = getattr(data, "reason", "manual") or "manual"
  849. if isinstance(data, dict):
  850. recording_id = data.get("recording_id", "")
  851. reason = data.get("reason", "manual")
  852. pinfo(f"Server-Recording-Stop empfangen: {recording_id} (reason={reason})")
  853. duration = 0.0
  854. if self._wakeword_service:
  855. # WakewordService: Recording stoppen
  856. duration = self._wakeword_service.stop_server_recording()
  857. elif self._direct_streaming_active:
  858. # Fallback: Direktes Streaming stoppen
  859. if self._microphone:
  860. self._microphone.stop()
  861. self._direct_streaming_active = False
  862. pinfo("Direktes Mikrofon-Streaming gestoppt")
  863. # Bestätigung an Server senden
  864. if self._connection:
  865. from trixy_core.network.cmd import SatelliteRecordStopped
  866. cmd = SatelliteRecordStopped(
  867. recording_id=recording_id,
  868. reason=reason,
  869. duration_seconds=duration,
  870. )
  871. await self._connection.send_command(cmd)
  872. async def _on_audio_out(self, audio_data: bytes) -> None:
  873. """
  874. Callback für Audio-Ausgabe (TTS) vom Server.
  875. Die Chunks werden direkt in den Streaming-Player eingespeist
  876. und sofort abgespielt.
  877. """
  878. # DEBUG: Chunks sammeln
  879. self._debug_tts_chunks.append(audio_data)
  880. # In Audio-Manager einspeisen
  881. if self._audio_manager:
  882. # Debug: Ersten Chunk und Playback-Start loggen
  883. if not hasattr(self, "_tts_playing"):
  884. self._tts_playing = False
  885. was_playing = self._tts_playing
  886. success = self._audio_manager.play_tts_chunk(audio_data)
  887. if not success:
  888. pwarn(f"[CLIENT] TTS-Chunk abgelehnt! ({len(audio_data)} bytes)")
  889. if success and not was_playing:
  890. pdebug(f"[CLIENT] TTS-Wiedergabe gestartet ({len(audio_data)} bytes erster Chunk)")
  891. self._tts_playing = True
  892. # Event für zusätzliche Verarbeitung auslösen (fire-and-forget, blockiert nicht)
  893. tts_format = self._audio_manager.tts_format
  894. asyncio.create_task(self.events.emit("audio_output_received", {
  895. "audio_data": audio_data,
  896. "sample_rate": tts_format["sample_rate"],
  897. "channels": tts_format["channels"],
  898. }))
  899. async def _on_music(self, audio_data: bytes) -> None:
  900. """
  901. Callback für Musik-Stream vom Server.
  902. Die Chunks werden direkt in den Streaming-Player eingespeist
  903. und sofort abgespielt.
  904. """
  905. # DEBUG: Chunks sammeln
  906. self._debug_music_chunks.append(audio_data)
  907. # In Audio-Manager einspeisen
  908. if self._audio_manager:
  909. # Debug: Ersten Chunk und Playback-Start loggen
  910. if not hasattr(self, "_music_playing"):
  911. self._music_playing = False
  912. was_playing = self._music_playing
  913. success = self._audio_manager.play_music_chunk(audio_data)
  914. if success and not was_playing:
  915. pdebug(f"[CLIENT] Music-Wiedergabe gestartet ({len(audio_data)} bytes erster Chunk)")
  916. self._music_playing = True
  917. # Event für zusätzliche Verarbeitung auslösen (fire-and-forget, blockiert nicht)
  918. music_format = self._audio_manager.music_format
  919. asyncio.create_task(self.events.emit("music_output_received", {
  920. "audio_data": audio_data,
  921. "sample_rate": music_format["sample_rate"],
  922. "channels": music_format["channels"],
  923. }))
  924. def _debug_save_wav(self, chunks: list[bytes], stream_type: str) -> None:
  925. """DEBUG: Speichert gesammelte Audio-Chunks als WAV-Datei."""
  926. if not chunks:
  927. pdebug(f"[DEBUG] Keine {stream_type}-Chunks zum Speichern")
  928. return
  929. if not self._audio_manager:
  930. return
  931. # Format ermitteln
  932. if stream_type == "tts":
  933. fmt = self._audio_manager.tts_format
  934. else:
  935. fmt = self._audio_manager.music_format
  936. sample_rate = fmt["sample_rate"]
  937. channels = fmt["channels"]
  938. sample_width = fmt.get("sample_width", 2)
  939. # Alle Chunks zusammenfügen
  940. all_data = b"".join(chunks)
  941. # Dateiname mit Timestamp
  942. timestamp = int(time.time())
  943. filename = f"/tmp/debug_{stream_type}_{timestamp}.wav"
  944. try:
  945. with wave.open(filename, "wb") as wf:
  946. wf.setnchannels(channels)
  947. wf.setsampwidth(sample_width)
  948. wf.setframerate(sample_rate)
  949. wf.writeframes(all_data)
  950. pinfo(f"[DEBUG] WAV gespeichert: {filename} ({len(all_data)} bytes, {sample_rate}Hz, {channels}ch)")
  951. except Exception as e:
  952. perror(f"[DEBUG] WAV speichern fehlgeschlagen: {e}")
  953. async def _handle_config_read_proxy(self, data: object) -> None:
  954. """Beantwortet eine Config-Lese-Anfrage vom Server (Config-Tool Proxy)."""
  955. import dataclasses
  956. from trixy_core.network.cmd.config_cmd import ConfigReadResponse
  957. message_id = getattr(data, "message_id", "") or ""
  958. config_name = getattr(data, "config_name", "") or ""
  959. section = getattr(data, "section", "") or ""
  960. response = ConfigReadResponse(
  961. message_id=message_id,
  962. config_name=config_name,
  963. section=section,
  964. )
  965. try:
  966. config_obj = self._client_config
  967. if config_obj is None:
  968. response.data = {"error": "Keine Client-Konfiguration verfuegbar"}
  969. else:
  970. config_dict = dataclasses.asdict(config_obj)
  971. if section and section in config_dict:
  972. response.data = {section: config_dict[section]}
  973. else:
  974. response.data = config_dict
  975. except Exception as e:
  976. response.data = {"error": str(e)}
  977. if self._connection:
  978. await self._connection.send_command(response)
  979. async def _handle_config_write_proxy(self, data: object) -> None:
  980. """Beantwortet eine Config-Schreib-Anfrage vom Server (Config-Tool Proxy)."""
  981. from trixy_core.network.cmd.config_cmd import ConfigWriteResponse
  982. message_id = getattr(data, "message_id", "") or ""
  983. key_path = getattr(data, "key_path", "") or ""
  984. value = getattr(data, "value", None)
  985. response = ConfigWriteResponse(message_id=message_id)
  986. try:
  987. config_obj = self._client_config
  988. if config_obj is None:
  989. response.error = "Keine Client-Konfiguration verfuegbar"
  990. else:
  991. # Punkt-Pfad navigieren und Wert setzen
  992. parts = key_path.split(".")
  993. target = config_obj
  994. for part in parts[:-1]:
  995. if hasattr(target, part):
  996. target = getattr(target, part)
  997. else:
  998. response.error = f"Pfad '{key_path}' nicht gefunden"
  999. break
  1000. if not response.error:
  1001. field_name = parts[-1]
  1002. if not hasattr(target, field_name):
  1003. response.error = f"Feld '{field_name}' nicht gefunden"
  1004. else:
  1005. # Typ-Konvertierung
  1006. current_value = getattr(target, field_name)
  1007. if isinstance(current_value, bool):
  1008. if isinstance(value, str):
  1009. value = value.lower() in ("true", "1", "ja", "yes")
  1010. else:
  1011. value = bool(value)
  1012. elif isinstance(current_value, int):
  1013. value = int(value)
  1014. elif isinstance(current_value, float):
  1015. value = float(value)
  1016. elif isinstance(current_value, list):
  1017. if isinstance(value, str):
  1018. value = [v.strip() for v in value.split(",") if v.strip()]
  1019. elif isinstance(current_value, str):
  1020. value = str(value)
  1021. setattr(target, field_name, value)
  1022. # Konfiguration speichern
  1023. self.config_manager.save("client")
  1024. response.success = True
  1025. if any(p in key_path for p in ("port", "bind_address", "host")):
  1026. response.requires_restart = True
  1027. except Exception as e:
  1028. response.error = str(e)
  1029. if self._connection:
  1030. await self._connection.send_command(response)
  1031. async def _disconnect(self) -> None:
  1032. """Trennt die Verbindung zum Server."""
  1033. if self._connection:
  1034. await self._connection.disconnect("Client shutdown")
  1035. self._connection = None
  1036. self._connected = False
  1037. pdebug("Verbindung getrennt")
  1038. def _schedule_reconnect(self) -> None:
  1039. """Plant einen Wiederverbindungsversuch.
  1040. Laeuft in einer Endlosschleife solange der Client laeuft und nicht
  1041. verbunden ist. Alle Fehler werden abgefangen, damit die Schleife
  1042. nicht durch Exceptions abbricht.
  1043. Wenn der Server via SatelliteDenied eine Wartezeit vorgibt
  1044. (z.B. "Server wird gestartet"), wird diese respektiert — aber
  1045. mindestens das konfigurierte reconnect_interval gewartet.
  1046. """
  1047. if self._reconnect_task and not self._reconnect_task.done():
  1048. return
  1049. async def reconnect() -> None:
  1050. default_interval = (
  1051. self._client_config.server.reconnect_interval_seconds
  1052. if self._client_config else 10.0
  1053. )
  1054. attempt = 0
  1055. while self._running and not self._connected:
  1056. attempt += 1
  1057. # Wartezeit bestimmen: wenn der Server eine Wartezeit vorgegeben
  1058. # hat (via SatelliteDenied), diese nutzen — sonst Default
  1059. wait = default_interval
  1060. if self._connection and self._connection.last_denied_retry_after > 0:
  1061. wait = max(default_interval, self._connection.last_denied_retry_after)
  1062. pwarn(f"Wiederverbindungsversuch #{attempt} in {wait:.0f} Sekunden...")
  1063. try:
  1064. await asyncio.sleep(wait)
  1065. except asyncio.CancelledError:
  1066. raise
  1067. if not self._running:
  1068. break
  1069. try:
  1070. await self._connect()
  1071. except asyncio.CancelledError:
  1072. raise
  1073. except Exception as e:
  1074. perror(f"Reconnect-Versuch #{attempt} fehlgeschlagen: {e}")
  1075. # Weitermachen — naechster Versuch in naechster Iteration
  1076. self._reconnect_task = asyncio.create_task(reconnect())