client_connection.py 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804
  1. # -*- coding: utf-8 -*-
  2. """
  3. ClientConnection - Client-seitige Verbindung zum Server.
  4. Verwaltet alle 4 Sockets und den Handshake für Satellites.
  5. """
  6. from __future__ import annotations
  7. import asyncio
  8. import struct
  9. import uuid
  10. from typing import TYPE_CHECKING, Callable, Awaitable, Any
  11. from dataclasses import dataclass
  12. from trixy_core.network.protocol import (
  13. TrixyProtocol,
  14. ProtocolFlags,
  15. ProtocolMessage,
  16. MAGIC,
  17. MAGIC_LENGTH,
  18. COMMAND_HELLO,
  19. COMMAND_PING,
  20. COMMAND_PONG,
  21. )
  22. from trixy_core.network.encryption import TrixyEncryption
  23. from trixy_core.network.cmd import (
  24. SatelliteConnect,
  25. SatelliteDisconnect,
  26. SatelliteAccepted,
  27. SatelliteDenied,
  28. )
  29. from trixy_core.utils.debug import pinfo, pdebug, perror, pwarn
  30. from trixy_core.utils.version import VERSION_STRING
  31. if TYPE_CHECKING:
  32. from trixy_core.client import ClientApplication
  33. @dataclass
  34. class ConnectionConfig:
  35. """Konfiguration für die Verbindung."""
  36. connect_timeout: float = 10.0
  37. reconnect_interval: float = 5.0
  38. max_reconnect_attempts: int = 0 # 0 = unbegrenzt
  39. ping_interval: float = 30.0
  40. pong_timeout: float = 10.0
  41. class ClientConnection:
  42. """
  43. Client-seitige Verbindung zum Trixy-Server.
  44. Verwaltet alle 4 Sockets:
  45. - Command: Befehle und Steuerung
  46. - Audio In: Mikrofon-Daten zum Server
  47. - Audio Out: Sprachausgabe vom Server
  48. - Music: Musik-Stream vom Server
  49. Usage:
  50. connection = ClientConnection(
  51. host="192.168.1.100",
  52. port=2101,
  53. room="wohnzimmer",
  54. alias="Echo",
  55. mac_address="AA:BB:CC:DD:EE:FF"
  56. )
  57. if await connection.connect():
  58. # Verbunden
  59. await connection.send_command(WakewordDetected(...))
  60. """
  61. def __init__(
  62. self,
  63. host: str,
  64. port: int,
  65. room: str,
  66. alias: str,
  67. mac_address: str,
  68. config: ConnectionConfig | None = None,
  69. ) -> None:
  70. """
  71. Initialisiert die ClientConnection.
  72. Args:
  73. host: Server-Host
  74. port: Server-Port (Command-Port, Standard 2101)
  75. room: Raum-Kennung
  76. alias: Anzeigename des Satellites
  77. mac_address: MAC-Adresse des Satellites
  78. config: Verbindungskonfiguration
  79. """
  80. self._host = host
  81. self._port = port
  82. self._room = room
  83. self._alias = alias
  84. self._mac_address = mac_address
  85. self._config = config or ConnectionConfig()
  86. # Protokoll
  87. self._protocol = TrixyProtocol()
  88. self._encryption: TrixyEncryption | None = None
  89. self._session_key: bytes = b""
  90. # Sockets
  91. self._command_reader: asyncio.StreamReader | None = None
  92. self._command_writer: asyncio.StreamWriter | None = None
  93. self._audio_in_reader: asyncio.StreamReader | None = None
  94. self._audio_in_writer: asyncio.StreamWriter | None = None
  95. self._audio_out_reader: asyncio.StreamReader | None = None
  96. self._audio_out_writer: asyncio.StreamWriter | None = None
  97. self._music_reader: asyncio.StreamReader | None = None
  98. self._music_writer: asyncio.StreamWriter | None = None
  99. # Zustand
  100. self._connected = False
  101. self._satellite_id: str = ""
  102. self._audio_ports: dict[str, int] = {}
  103. self._running = False
  104. self._reconnect_attempts = 0
  105. # Audio-Format (vom Server mitgeteilt)
  106. self._tts_format: dict[str, int] = {
  107. "sample_rate": 22050,
  108. "channels": 1,
  109. "sample_width": 2,
  110. }
  111. self._music_format: dict[str, int] = {
  112. "sample_rate": 44100,
  113. "channels": 2,
  114. "sample_width": 2,
  115. }
  116. # Tasks
  117. self._receive_task: asyncio.Task | None = None
  118. self._keepalive_task: asyncio.Task | None = None
  119. self._audio_out_task: asyncio.Task | None = None
  120. self._music_task: asyncio.Task | None = None
  121. # Callbacks
  122. self._on_connected: Callable[[], Awaitable[None]] | None = None
  123. self._on_disconnected: Callable[[str], Awaitable[None]] | None = None
  124. self._on_message: Callable[[ProtocolMessage], Awaitable[None]] | None = None
  125. self._on_audio_out: Callable[[bytes], Awaitable[None]] | None = None
  126. self._on_music: Callable[[bytes], Awaitable[None]] | None = None
  127. @property
  128. def is_connected(self) -> bool:
  129. """Ist die Verbindung aktiv?"""
  130. return self._connected
  131. @property
  132. def satellite_id(self) -> str:
  133. """Die vom Server zugewiesene Satellite-ID."""
  134. return self._satellite_id
  135. @property
  136. def host(self) -> str:
  137. """Server-Host."""
  138. return self._host
  139. @property
  140. def port(self) -> int:
  141. """Server-Port."""
  142. return self._port
  143. @property
  144. def tts_format(self) -> dict[str, int]:
  145. """TTS-Audio-Format vom Server."""
  146. return self._tts_format.copy()
  147. @property
  148. def music_format(self) -> dict[str, int]:
  149. """Musik-Audio-Format vom Server."""
  150. return self._music_format.copy()
  151. def on_connected(self, callback: Callable[[], Awaitable[None]]) -> None:
  152. """Registriert Callback für Verbindungsaufbau."""
  153. self._on_connected = callback
  154. def on_disconnected(self, callback: Callable[[str], Awaitable[None]]) -> None:
  155. """Registriert Callback für Verbindungsabbruch."""
  156. self._on_disconnected = callback
  157. def on_message(self, callback: Callable[[ProtocolMessage], Awaitable[None]]) -> None:
  158. """Registriert Callback für eingehende Nachrichten."""
  159. self._on_message = callback
  160. def on_audio_out(self, callback: Callable[[bytes], Awaitable[None]]) -> None:
  161. """Registriert Callback für Audio-Ausgabe."""
  162. self._on_audio_out = callback
  163. def on_music(self, callback: Callable[[bytes], Awaitable[None]]) -> None:
  164. """Registriert Callback für Musik-Stream."""
  165. self._on_music = callback
  166. async def connect(self) -> bool:
  167. """
  168. Verbindet sich mit dem Server.
  169. Führt den Handshake durch und verbindet alle Audio-Ports.
  170. Returns:
  171. True bei erfolgreicher Verbindung
  172. """
  173. try:
  174. pdebug(f"Verbinde zu {self._host}:{self._port}...")
  175. # Command-Socket verbinden
  176. try:
  177. self._command_reader, self._command_writer = await asyncio.wait_for(
  178. asyncio.open_connection(self._host, self._port),
  179. timeout=self._config.connect_timeout
  180. )
  181. except asyncio.TimeoutError:
  182. perror(f"Verbindungs-Timeout zu {self._host}:{self._port}")
  183. return False
  184. except OSError as e:
  185. perror(f"Verbindungsfehler: {e}")
  186. return False
  187. pdebug("Command-Socket verbunden")
  188. # Handshake durchführen
  189. if not await self._perform_handshake():
  190. await self._close_sockets()
  191. return False
  192. # Audio-Sockets verbinden
  193. await self._connect_audio_sockets()
  194. # Empfangs-Tasks starten
  195. self._running = True
  196. self._receive_task = asyncio.create_task(self._receive_loop())
  197. self._keepalive_task = asyncio.create_task(self._keepalive_loop())
  198. if self._audio_out_reader:
  199. self._audio_out_task = asyncio.create_task(self._audio_out_loop())
  200. if self._music_reader:
  201. self._music_task = asyncio.create_task(self._music_loop())
  202. self._connected = True
  203. self._reconnect_attempts = 0
  204. pinfo(f"Verbunden mit Server {self._host}:{self._port}")
  205. # Callback
  206. if self._on_connected:
  207. try:
  208. await self._on_connected()
  209. except Exception as e:
  210. perror(f"on_connected Callback Fehler: {e}")
  211. return True
  212. except Exception as e:
  213. perror(f"Verbindungsfehler: {e}")
  214. await self._close_sockets()
  215. return False
  216. async def disconnect(self, reason: str = "Client-Disconnect") -> None:
  217. """
  218. Trennt die Verbindung zum Server.
  219. Args:
  220. reason: Grund für die Trennung
  221. """
  222. if not self._connected:
  223. return
  224. pinfo(f"Trenne Verbindung: {reason}")
  225. self._running = False
  226. # Disconnect-Nachricht senden
  227. try:
  228. if self._command_writer:
  229. disconnect_msg = SatelliteDisconnect(
  230. mac_address=self._mac_address,
  231. reason=reason
  232. )
  233. await self._send_message(disconnect_msg)
  234. except Exception:
  235. pass
  236. # Tasks beenden
  237. for task in [self._receive_task, self._keepalive_task, self._audio_out_task, self._music_task]:
  238. if task and not task.done():
  239. task.cancel()
  240. try:
  241. await task
  242. except asyncio.CancelledError:
  243. pass
  244. # Sockets schließen
  245. await self._close_sockets()
  246. self._connected = False
  247. self._satellite_id = ""
  248. # Callback
  249. if self._on_disconnected:
  250. try:
  251. await self._on_disconnected(reason)
  252. except Exception as e:
  253. perror(f"on_disconnected Callback Fehler: {e}")
  254. async def send_command(self, message: object, flags: ProtocolFlags = ProtocolFlags.PICKLE) -> bool:
  255. """
  256. Sendet eine Nachricht zum Server.
  257. Args:
  258. message: Die zu sendende Nachricht
  259. flags: Protokoll-Flags
  260. Returns:
  261. True bei Erfolg
  262. """
  263. if not self._connected or not self._command_writer:
  264. return False
  265. return await self._send_message(message, flags)
  266. async def send_audio(self, audio_data: bytes) -> bool:
  267. """
  268. Sendet Audio-Daten zum Server.
  269. Args:
  270. audio_data: Audio-Bytes (16KHz, 16-bit, mono)
  271. Returns:
  272. True bei Erfolg
  273. """
  274. if not self._audio_in_writer:
  275. return False
  276. try:
  277. self._audio_in_writer.write(audio_data)
  278. await self._audio_in_writer.drain()
  279. return True
  280. except Exception as e:
  281. perror(f"Audio-Sende-Fehler: {e}")
  282. return False
  283. async def _perform_handshake(self) -> bool:
  284. """
  285. Führt den Handshake mit dem Server durch.
  286. Returns:
  287. True bei erfolgreichem Handshake
  288. """
  289. pdebug("Starte Handshake...")
  290. # HELLO senden
  291. try:
  292. self._command_writer.write(COMMAND_HELLO)
  293. await self._command_writer.drain()
  294. except Exception as e:
  295. perror(f"Fehler beim Senden von HELLO: {e}")
  296. return False
  297. pdebug("HELLO gesendet")
  298. # SatelliteConnect senden
  299. import platform
  300. import socket as _socket
  301. connect_msg = SatelliteConnect(
  302. mac_address=self._mac_address,
  303. room=self._room,
  304. alias=self._alias,
  305. ip_address="", # Server ermittelt IP
  306. version=VERSION_STRING,
  307. hostname=_socket.gethostname(),
  308. os_name=platform.system(),
  309. os_version=platform.release()
  310. )
  311. if not await self._send_message(connect_msg):
  312. perror("Fehler beim Senden von SatelliteConnect")
  313. return False
  314. pdebug("SatelliteConnect gesendet")
  315. # Antwort empfangen
  316. try:
  317. response = await asyncio.wait_for(
  318. self._read_message(),
  319. timeout=self._config.connect_timeout
  320. )
  321. except asyncio.TimeoutError:
  322. perror("Timeout beim Warten auf Server-Antwort")
  323. return False
  324. if response is None:
  325. perror("Keine Antwort vom Server")
  326. return False
  327. # Antwort verarbeiten
  328. if response.class_name == "SatelliteDenied":
  329. data = response.data
  330. if isinstance(data, dict):
  331. reason = data.get("reason", "Unbekannt")
  332. retry_after = data.get("retry_after_seconds", 60)
  333. elif isinstance(data, SatelliteDenied):
  334. reason = data.reason
  335. retry_after = data.retry_after_seconds
  336. else:
  337. reason = "Unbekannt"
  338. retry_after = 60
  339. pwarn(f"Verbindung abgelehnt: {reason}")
  340. pwarn(f"Erneuter Versuch in {retry_after} Sekunden möglich")
  341. return False
  342. if response.class_name != "SatelliteAccepted":
  343. perror(f"Unerwartete Antwort: {response.class_name}")
  344. return False
  345. # SatelliteAccepted verarbeiten
  346. data = response.data
  347. if isinstance(data, dict):
  348. self._satellite_id = data.get("satellite_id", "")
  349. self._session_key = data.get("session_key", b"")
  350. self._audio_ports = {
  351. "audio_in": data.get("audio_input_port", 0),
  352. "audio_out": data.get("audio_output_port", 0),
  353. "music": data.get("music_port", 0),
  354. }
  355. # Audio-Format vom Server
  356. if data.get("tts_sample_rate"):
  357. self._tts_format = {
  358. "sample_rate": data.get("tts_sample_rate", 22050),
  359. "channels": data.get("tts_channels", 1),
  360. "sample_width": data.get("tts_sample_width", 2),
  361. }
  362. if data.get("music_sample_rate"):
  363. self._music_format = {
  364. "sample_rate": data.get("music_sample_rate", 44100),
  365. "channels": data.get("music_channels", 2),
  366. "sample_width": data.get("music_sample_width", 2),
  367. }
  368. elif isinstance(data, SatelliteAccepted):
  369. self._satellite_id = data.satellite_id
  370. self._session_key = data.session_key
  371. self._audio_ports = {
  372. "audio_in": data.audio_input_port,
  373. "audio_out": data.audio_output_port,
  374. "music": data.music_port,
  375. }
  376. # Audio-Format vom Server
  377. if data.tts_sample_rate:
  378. self._tts_format = {
  379. "sample_rate": data.tts_sample_rate,
  380. "channels": data.tts_channels,
  381. "sample_width": data.tts_sample_width,
  382. }
  383. if data.music_sample_rate:
  384. self._music_format = {
  385. "sample_rate": data.music_sample_rate,
  386. "channels": data.music_channels,
  387. "sample_width": data.music_sample_width,
  388. }
  389. else:
  390. perror(f"Ungültige SatelliteAccepted-Daten: {type(data)}")
  391. return False
  392. # Session-Verschlüsselung einrichten
  393. if self._session_key:
  394. try:
  395. self._encryption = TrixyEncryption(self._session_key)
  396. self._protocol.set_encryption(self._encryption)
  397. pdebug("Session-Verschlüsselung aktiviert")
  398. except Exception as e:
  399. pwarn(f"Session-Verschlüsselung fehlgeschlagen: {e}")
  400. pinfo(f"Handshake erfolgreich, Satellite-ID: {self._satellite_id}")
  401. return True
  402. async def _connect_audio_sockets(self) -> None:
  403. """Verbindet die Audio-Sockets."""
  404. # Audio-Input Socket
  405. if self._audio_ports.get("audio_in"):
  406. try:
  407. self._audio_in_reader, self._audio_in_writer = await asyncio.wait_for(
  408. asyncio.open_connection(self._host, self._audio_ports["audio_in"]),
  409. timeout=self._config.connect_timeout
  410. )
  411. # Satellite-ID senden
  412. self._audio_in_writer.write(self._satellite_id.ljust(36).encode("utf-8"))
  413. await self._audio_in_writer.drain()
  414. pdebug(f"Audio-Input verbunden auf Port {self._audio_ports['audio_in']}")
  415. except Exception as e:
  416. pwarn(f"Audio-Input Verbindung fehlgeschlagen: {e}")
  417. # Audio-Output Socket
  418. if self._audio_ports.get("audio_out"):
  419. try:
  420. self._audio_out_reader, self._audio_out_writer = await asyncio.wait_for(
  421. asyncio.open_connection(self._host, self._audio_ports["audio_out"]),
  422. timeout=self._config.connect_timeout
  423. )
  424. # Satellite-ID senden
  425. self._audio_out_writer.write(self._satellite_id.ljust(36).encode("utf-8"))
  426. await self._audio_out_writer.drain()
  427. pdebug(f"Audio-Output verbunden auf Port {self._audio_ports['audio_out']}")
  428. except Exception as e:
  429. pwarn(f"Audio-Output Verbindung fehlgeschlagen: {e}")
  430. # Music Socket
  431. if self._audio_ports.get("music"):
  432. try:
  433. self._music_reader, self._music_writer = await asyncio.wait_for(
  434. asyncio.open_connection(self._host, self._audio_ports["music"]),
  435. timeout=self._config.connect_timeout
  436. )
  437. # Satellite-ID senden
  438. self._music_writer.write(self._satellite_id.ljust(36).encode("utf-8"))
  439. await self._music_writer.drain()
  440. pdebug(f"Music verbunden auf Port {self._audio_ports['music']}")
  441. except Exception as e:
  442. pwarn(f"Music Verbindung fehlgeschlagen: {e}")
  443. async def _receive_loop(self) -> None:
  444. """Empfängt Nachrichten vom Server."""
  445. while self._running:
  446. try:
  447. message = await self._read_message()
  448. if message is None:
  449. if self._running:
  450. pwarn("Verbindung zum Server verloren")
  451. asyncio.create_task(self._handle_disconnect("Verbindung verloren"))
  452. break
  453. await self._handle_message(message)
  454. except asyncio.CancelledError:
  455. break
  456. except Exception as e:
  457. if self._running:
  458. perror(f"Empfangsfehler: {e}")
  459. asyncio.create_task(self._handle_disconnect(f"Fehler: {e}"))
  460. break
  461. async def _handle_message(self, message: ProtocolMessage) -> None:
  462. """Verarbeitet eine empfangene Nachricht."""
  463. class_name = message.class_name
  464. # PING beantworten
  465. if class_name == "TRXIPING":
  466. await self._send_pong()
  467. return
  468. # PONG ignorieren (KeepAlive)
  469. if class_name == "TRXIPONG":
  470. return
  471. # An Callback weiterleiten
  472. if self._on_message:
  473. try:
  474. await self._on_message(message)
  475. except Exception as e:
  476. perror(f"Message-Callback Fehler: {e}")
  477. async def _keepalive_loop(self) -> None:
  478. """Sendet regelmäßige Pings."""
  479. while self._running:
  480. try:
  481. await asyncio.sleep(self._config.ping_interval)
  482. if not self._running:
  483. break
  484. # Ping senden
  485. await self._send_ping()
  486. except asyncio.CancelledError:
  487. break
  488. except Exception as e:
  489. pdebug(f"KeepAlive-Fehler: {e}")
  490. async def _audio_out_loop(self) -> None:
  491. """Empfängt Audio-Ausgabe vom Server."""
  492. first_chunk = True
  493. total_bytes = 0
  494. chunk_count = 0
  495. # Frame-Alignment Buffer (TTS: Mono 16-bit = 2 bytes pro Frame)
  496. frame_size = self._tts_format["channels"] * self._tts_format["sample_width"]
  497. leftover = b""
  498. while self._running and self._audio_out_reader:
  499. try:
  500. chunk = await self._audio_out_reader.read(32768) # 32KB chunks
  501. if not chunk:
  502. break
  503. # Kombiniere mit Resten vom letzten Chunk
  504. data = leftover + chunk
  505. # Nur komplette Frames weitergeben
  506. aligned_size = (len(data) // frame_size) * frame_size
  507. if aligned_size > 0:
  508. aligned_chunk = data[:aligned_size]
  509. leftover = data[aligned_size:]
  510. chunk_count += 1
  511. total_bytes += len(aligned_chunk)
  512. if first_chunk:
  513. pdebug(f"[CLIENT] Audio-Out: Erster Chunk empfangen ({len(aligned_chunk)} bytes)")
  514. first_chunk = False
  515. if self._on_audio_out:
  516. await self._on_audio_out(aligned_chunk)
  517. else:
  518. leftover = data
  519. except asyncio.CancelledError:
  520. break
  521. except Exception as e:
  522. pdebug(f"Audio-Out Fehler: {e}")
  523. break
  524. if chunk_count > 0:
  525. pdebug(f"[CLIENT] Audio-Out Stream beendet: {chunk_count} chunks, {total_bytes} bytes total")
  526. async def _music_loop(self) -> None:
  527. """Empfängt Music-Stream vom Server."""
  528. first_chunk = True
  529. total_bytes = 0
  530. chunk_count = 0
  531. # Frame-Alignment Buffer (Music: Stereo 16-bit = 4 bytes pro Frame)
  532. frame_size = self._music_format["channels"] * self._music_format["sample_width"]
  533. leftover = b""
  534. while self._running and self._music_reader:
  535. try:
  536. chunk = await self._music_reader.read(32768) # 32KB chunks
  537. if not chunk:
  538. break
  539. # Kombiniere mit Resten vom letzten Chunk
  540. data = leftover + chunk
  541. # Nur komplette Frames weitergeben
  542. aligned_size = (len(data) // frame_size) * frame_size
  543. if aligned_size > 0:
  544. aligned_chunk = data[:aligned_size]
  545. leftover = data[aligned_size:]
  546. chunk_count += 1
  547. total_bytes += len(aligned_chunk)
  548. if first_chunk:
  549. pdebug(f"[CLIENT] Music: Erster Chunk empfangen ({len(aligned_chunk)} bytes)")
  550. first_chunk = False
  551. if self._on_music:
  552. await self._on_music(aligned_chunk)
  553. else:
  554. leftover = data
  555. except asyncio.CancelledError:
  556. break
  557. except Exception as e:
  558. pdebug(f"Music Fehler: {e}")
  559. break
  560. if chunk_count > 0:
  561. pdebug(f"[CLIENT] Music Stream beendet: {chunk_count} chunks, {total_bytes} bytes total")
  562. async def _handle_disconnect(self, reason: str) -> None:
  563. """Behandelt einen Verbindungsabbruch."""
  564. was_connected = self._connected
  565. self._connected = False
  566. self._running = False
  567. await self._close_sockets()
  568. if was_connected and self._on_disconnected:
  569. try:
  570. await self._on_disconnected(reason)
  571. except Exception as e:
  572. perror(f"on_disconnected Callback Fehler: {e}")
  573. async def _send_message(self, message: object, flags: ProtocolFlags = ProtocolFlags.PICKLE) -> bool:
  574. """Sendet eine Nachricht über den Command-Socket."""
  575. if not self._command_writer:
  576. return False
  577. try:
  578. data = self._protocol.serialize(message, flags)
  579. self._command_writer.write(data)
  580. await self._command_writer.drain()
  581. return True
  582. except Exception as e:
  583. perror(f"Sende-Fehler: {e}")
  584. return False
  585. async def _send_ping(self) -> None:
  586. """Sendet einen Ping."""
  587. if self._command_writer:
  588. try:
  589. self._command_writer.write(COMMAND_PING)
  590. await self._command_writer.drain()
  591. except Exception:
  592. pass
  593. async def _send_pong(self) -> None:
  594. """Sendet einen Pong."""
  595. if self._command_writer:
  596. try:
  597. self._command_writer.write(COMMAND_PONG)
  598. await self._command_writer.drain()
  599. except Exception:
  600. pass
  601. async def _read_message(self) -> ProtocolMessage | None:
  602. """Liest eine Nachricht vom Command-Socket."""
  603. if not self._command_reader:
  604. return None
  605. try:
  606. # Lese Header (Magic + Version + Timestamp + Flags + Checksum + ClassNameLength)
  607. header_size = MAGIC_LENGTH + 2 + 8 + 4 + 16 + 2 # 36 bytes
  608. # Prüfe zuerst auf Hard-coded Befehle (8 bytes)
  609. # Verwende readexactly um sicherzustellen, dass wir alle 8 Bytes bekommen
  610. peek_data = await self._command_reader.readexactly(8)
  611. # Prüfe auf Hard-coded Befehl
  612. if self._protocol.is_hard_command(peek_data):
  613. return self._protocol.deserialize(peek_data)
  614. # Kein Hard-coded Befehl - lese Rest des Headers
  615. remaining_header = await self._command_reader.readexactly(header_size - 8)
  616. header = peek_data + remaining_header
  617. # Prüfe Magic
  618. if header[:MAGIC_LENGTH] != MAGIC:
  619. perror(f"Ungültige Magic: {header[:MAGIC_LENGTH]}")
  620. return None
  621. # Parse Klassennamen-Länge
  622. class_name_length = struct.unpack(">H", header[34:36])[0]
  623. # Lese Klassenname
  624. class_name = await self._command_reader.readexactly(class_name_length)
  625. # Lese Datenlänge (4 bytes)
  626. data_length_bytes = await self._command_reader.readexactly(4)
  627. data_length = struct.unpack(">I", data_length_bytes)[0]
  628. # Lese Daten
  629. data = await self._command_reader.readexactly(data_length) if data_length > 0 else b""
  630. # Baue vollständige Nachricht zusammen
  631. full_message = header + class_name + data_length_bytes + data
  632. return self._protocol.deserialize(full_message)
  633. except (ConnectionResetError, asyncio.IncompleteReadError):
  634. return None
  635. except Exception as e:
  636. perror(f"Lese-Fehler: {e}")
  637. return None
  638. async def _close_sockets(self) -> None:
  639. """Schließt alle Sockets."""
  640. for writer in [
  641. self._command_writer,
  642. self._audio_in_writer,
  643. self._audio_out_writer,
  644. self._music_writer
  645. ]:
  646. if writer:
  647. try:
  648. writer.close()
  649. await writer.wait_closed()
  650. except Exception:
  651. pass
  652. self._command_reader = None
  653. self._command_writer = None
  654. self._audio_in_reader = None
  655. self._audio_in_writer = None
  656. self._audio_out_reader = None
  657. self._audio_out_writer = None
  658. self._music_reader = None
  659. self._music_writer = None
  660. def get_mac_address() -> str:
  661. """
  662. Ermittelt die MAC-Adresse des Systems.
  663. Returns:
  664. MAC-Adresse im Format "AA:BB:CC:DD:EE:FF"
  665. """
  666. import uuid as uuid_module
  667. mac = uuid_module.getnode()
  668. return ":".join(f"{(mac >> (8 * i)) & 0xFF:02X}" for i in range(5, -1, -1))