connection.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454
  1. # -*- coding: utf-8 -*-
  2. """
  3. SyncConnection — Command-Only TCP-Verbindung zum Server.
  4. Leichtgewichtige Verbindung fuer Datensynchronisation.
  5. Nur Command-Socket (Port 2101), keine Audio-Ports.
  6. Handshake analog zu ClientConnection.
  7. """
  8. from __future__ import annotations
  9. import asyncio
  10. import struct
  11. from dataclasses import dataclass
  12. from typing import TYPE_CHECKING, Callable, Awaitable
  13. from trixy_core.network.protocol import (
  14. TrixyProtocol,
  15. ProtocolFlags,
  16. ProtocolMessage,
  17. MAGIC,
  18. MAGIC_LENGTH,
  19. COMMAND_HELLO,
  20. COMMAND_PING,
  21. COMMAND_PONG,
  22. )
  23. from trixy_core.network.cmd import (
  24. SatelliteConnect,
  25. SatelliteAccepted,
  26. SatelliteDenied,
  27. )
  28. from trixy_core.utils.debug import pinfo, pdebug, perror, pwarn
  29. from trixy_core.utils.version import VERSION_STRING
  30. if TYPE_CHECKING:
  31. pass
  32. @dataclass
  33. class SyncConnectionConfig:
  34. """Konfiguration fuer die Sync-Verbindung."""
  35. host: str = ""
  36. port: int = 2101
  37. probe_interval_seconds: float = 30.0
  38. sync_interval_seconds: float = 300.0
  39. connect_timeout_seconds: float = 5.0
  40. class SyncConnection:
  41. """
  42. Command-Only TCP-Verbindung zum Server fuer Sync.
  43. Fuehrt den Standard-Handshake durch (TRXYHELO → SatelliteConnect → SatelliteAccepted),
  44. registriert den Standalone als Satellite.
  45. Kein Audio — nur Command-Socket.
  46. """
  47. def __init__(
  48. self,
  49. config: SyncConnectionConfig,
  50. room: str,
  51. alias: str,
  52. mac_address: str,
  53. ) -> None:
  54. """
  55. Initialisiert die SyncConnection.
  56. Args:
  57. config: Verbindungskonfiguration
  58. room: Raum-Kennung
  59. alias: Anzeigename
  60. mac_address: MAC-Adresse des Standalone
  61. """
  62. self._config = config
  63. self._room = room
  64. self._alias = alias
  65. self._mac_address = mac_address
  66. # Protokoll
  67. self._protocol = TrixyProtocol()
  68. # Socket
  69. self._reader: asyncio.StreamReader | None = None
  70. self._writer: asyncio.StreamWriter | None = None
  71. # Zustand
  72. self._connected = False
  73. self._satellite_id: str = ""
  74. self._running = False
  75. # Tasks
  76. self._receive_task: asyncio.Task | None = None
  77. self._keepalive_task: asyncio.Task | None = None
  78. # Callbacks
  79. self._on_connected: Callable[[], Awaitable[None]] | None = None
  80. self._on_disconnected: Callable[[str], Awaitable[None]] | None = None
  81. self._on_message: Callable[[ProtocolMessage], Awaitable[None]] | None = None
  82. @property
  83. def is_connected(self) -> bool:
  84. """Ist die Verbindung aktiv?"""
  85. return self._connected
  86. @property
  87. def satellite_id(self) -> str:
  88. """Vom Server zugewiesene Satellite-ID."""
  89. return self._satellite_id
  90. def on_connected(self, callback: Callable[[], Awaitable[None]]) -> None:
  91. """Registriert Callback fuer Verbindungsaufbau."""
  92. self._on_connected = callback
  93. def on_disconnected(self, callback: Callable[[str], Awaitable[None]]) -> None:
  94. """Registriert Callback fuer Verbindungsabbruch."""
  95. self._on_disconnected = callback
  96. def on_message(self, callback: Callable[[ProtocolMessage], Awaitable[None]]) -> None:
  97. """Registriert Callback fuer eingehende Nachrichten."""
  98. self._on_message = callback
  99. async def connect(self) -> bool:
  100. """
  101. Verbindet sich mit dem Server (Command-Socket + Handshake).
  102. Returns:
  103. True bei erfolgreicher Verbindung
  104. """
  105. try:
  106. pdebug(f"SyncConnection: Verbinde zu {self._config.host}:{self._config.port}...")
  107. # Command-Socket verbinden
  108. try:
  109. self._reader, self._writer = await asyncio.wait_for(
  110. asyncio.open_connection(self._config.host, self._config.port),
  111. timeout=self._config.connect_timeout_seconds,
  112. )
  113. except asyncio.TimeoutError:
  114. pdebug(f"SyncConnection: Timeout zu {self._config.host}:{self._config.port}")
  115. return False
  116. except OSError as e:
  117. pdebug(f"SyncConnection: Verbindungsfehler: {e}")
  118. return False
  119. # Handshake durchfuehren
  120. if not await self._perform_handshake():
  121. await self._close_socket()
  122. return False
  123. # Empfangs-Tasks starten
  124. self._running = True
  125. self._receive_task = asyncio.create_task(self._receive_loop())
  126. self._keepalive_task = asyncio.create_task(self._keepalive_loop())
  127. self._connected = True
  128. pinfo(f"SyncConnection: Verbunden mit Server {self._config.host}:{self._config.port}")
  129. # Callback
  130. if self._on_connected:
  131. try:
  132. await self._on_connected()
  133. except Exception as e:
  134. perror(f"SyncConnection: on_connected Fehler: {e}")
  135. return True
  136. except Exception as e:
  137. perror(f"SyncConnection: Verbindungsfehler: {e}")
  138. await self._close_socket()
  139. return False
  140. async def disconnect(self, reason: str = "Sync-Disconnect") -> None:
  141. """
  142. Trennt die Verbindung zum Server.
  143. Args:
  144. reason: Grund fuer die Trennung
  145. """
  146. if not self._connected:
  147. return
  148. pdebug(f"SyncConnection: Trenne Verbindung: {reason}")
  149. self._running = False
  150. # Tasks beenden
  151. for task in [self._receive_task, self._keepalive_task]:
  152. if task and not task.done():
  153. task.cancel()
  154. try:
  155. await task
  156. except asyncio.CancelledError:
  157. pass
  158. await self._close_socket()
  159. self._connected = False
  160. self._satellite_id = ""
  161. # Callback
  162. if self._on_disconnected:
  163. try:
  164. await self._on_disconnected(reason)
  165. except Exception as e:
  166. perror(f"SyncConnection: on_disconnected Fehler: {e}")
  167. async def send_command(self, cmd: object) -> bool:
  168. """
  169. Sendet eine Nachricht zum Server.
  170. Args:
  171. cmd: Die zu sendende CommandMessage
  172. Returns:
  173. True bei Erfolg
  174. """
  175. if not self._connected or not self._writer:
  176. return False
  177. return await self._send_message(cmd)
  178. # =========================================================================
  179. # Handshake
  180. # =========================================================================
  181. async def _perform_handshake(self) -> bool:
  182. """Fuehrt den Handshake mit dem Server durch."""
  183. pdebug("SyncConnection: Starte Handshake...")
  184. # HELLO senden
  185. try:
  186. self._writer.write(COMMAND_HELLO)
  187. await self._writer.drain()
  188. except Exception as e:
  189. perror(f"SyncConnection: HELLO senden fehlgeschlagen: {e}")
  190. return False
  191. # SatelliteConnect senden
  192. connect_msg = SatelliteConnect(
  193. mac_address=self._mac_address,
  194. room=self._room,
  195. alias=self._alias,
  196. ip_address="",
  197. version=VERSION_STRING,
  198. )
  199. if not await self._send_message(connect_msg):
  200. perror("SyncConnection: SatelliteConnect senden fehlgeschlagen")
  201. return False
  202. # Antwort empfangen
  203. try:
  204. response = await asyncio.wait_for(
  205. self._read_message(),
  206. timeout=self._config.connect_timeout_seconds,
  207. )
  208. except asyncio.TimeoutError:
  209. perror("SyncConnection: Timeout beim Warten auf Server-Antwort")
  210. return False
  211. if response is None:
  212. perror("SyncConnection: Keine Antwort vom Server")
  213. return False
  214. # Antwort verarbeiten
  215. if response.class_name == "SatelliteDenied":
  216. data = response.data
  217. reason = "Unbekannt"
  218. if isinstance(data, dict):
  219. reason = data.get("reason", "Unbekannt")
  220. elif isinstance(data, SatelliteDenied):
  221. reason = data.reason
  222. pwarn(f"SyncConnection: Verbindung abgelehnt: {reason}")
  223. return False
  224. if response.class_name != "SatelliteAccepted":
  225. perror(f"SyncConnection: Unerwartete Antwort: {response.class_name}")
  226. return False
  227. # SatelliteAccepted verarbeiten
  228. data = response.data
  229. if isinstance(data, dict):
  230. self._satellite_id = data.get("satellite_id", "")
  231. elif isinstance(data, SatelliteAccepted):
  232. self._satellite_id = data.satellite_id
  233. else:
  234. perror(f"SyncConnection: Ungueltiges SatelliteAccepted: {type(data)}")
  235. return False
  236. pinfo(f"SyncConnection: Handshake erfolgreich, ID: {self._satellite_id}")
  237. return True
  238. # =========================================================================
  239. # Empfangs-Loop
  240. # =========================================================================
  241. async def _receive_loop(self) -> None:
  242. """Empfaengt Nachrichten vom Server."""
  243. while self._running:
  244. try:
  245. message = await self._read_message()
  246. if message is None:
  247. if self._running:
  248. pwarn("SyncConnection: Verbindung zum Server verloren")
  249. asyncio.create_task(self._handle_disconnect("Verbindung verloren"))
  250. break
  251. await self._handle_message(message)
  252. except asyncio.CancelledError:
  253. break
  254. except Exception as e:
  255. if self._running:
  256. perror(f"SyncConnection: Empfangsfehler: {e}")
  257. asyncio.create_task(self._handle_disconnect(f"Fehler: {e}"))
  258. break
  259. async def _handle_message(self, message: ProtocolMessage) -> None:
  260. """Verarbeitet eine empfangene Nachricht."""
  261. class_name = message.class_name
  262. # PING beantworten
  263. if class_name == "TRXIPING":
  264. await self._send_pong()
  265. return
  266. # PONG ignorieren
  267. if class_name == "TRXIPONG":
  268. return
  269. # An Callback weiterleiten
  270. if self._on_message:
  271. try:
  272. await self._on_message(message)
  273. except Exception as e:
  274. perror(f"SyncConnection: Message-Callback Fehler: {e}")
  275. async def _keepalive_loop(self) -> None:
  276. """Sendet regelmaessige Pings."""
  277. while self._running:
  278. try:
  279. await asyncio.sleep(30.0)
  280. if not self._running:
  281. break
  282. await self._send_ping()
  283. except asyncio.CancelledError:
  284. break
  285. except Exception:
  286. pass
  287. async def _handle_disconnect(self, reason: str) -> None:
  288. """Behandelt einen Verbindungsabbruch."""
  289. was_connected = self._connected
  290. self._connected = False
  291. self._running = False
  292. await self._close_socket()
  293. if was_connected and self._on_disconnected:
  294. try:
  295. await self._on_disconnected(reason)
  296. except Exception as e:
  297. perror(f"SyncConnection: on_disconnected Fehler: {e}")
  298. # =========================================================================
  299. # Low-Level I/O
  300. # =========================================================================
  301. async def _send_message(self, message: object, flags: ProtocolFlags = ProtocolFlags.PICKLE) -> bool:
  302. """Sendet eine Nachricht ueber den Command-Socket."""
  303. if not self._writer:
  304. return False
  305. try:
  306. data = self._protocol.serialize(message, flags)
  307. self._writer.write(data)
  308. await self._writer.drain()
  309. return True
  310. except Exception as e:
  311. perror(f"SyncConnection: Sende-Fehler: {e}")
  312. return False
  313. async def _send_ping(self) -> None:
  314. """Sendet einen Ping."""
  315. if self._writer:
  316. try:
  317. self._writer.write(COMMAND_PING)
  318. await self._writer.drain()
  319. except Exception:
  320. pass
  321. async def _send_pong(self) -> None:
  322. """Sendet einen Pong."""
  323. if self._writer:
  324. try:
  325. self._writer.write(COMMAND_PONG)
  326. await self._writer.drain()
  327. except Exception:
  328. pass
  329. async def _read_message(self) -> ProtocolMessage | None:
  330. """Liest eine Nachricht vom Command-Socket."""
  331. if not self._reader:
  332. return None
  333. try:
  334. header_size = MAGIC_LENGTH + 2 + 8 + 4 + 16 + 2 # 36 bytes
  335. # Prüfe auf Hard-coded Befehle (8 bytes)
  336. peek_data = await self._reader.readexactly(8)
  337. if self._protocol.is_hard_command(peek_data):
  338. return self._protocol.deserialize(peek_data)
  339. # Kein Hard-coded Befehl — lese Rest des Headers
  340. remaining_header = await self._reader.readexactly(header_size - 8)
  341. header = peek_data + remaining_header
  342. if header[:MAGIC_LENGTH] != MAGIC:
  343. perror(f"SyncConnection: Ungueltige Magic: {header[:MAGIC_LENGTH]}")
  344. return None
  345. # Parse Klassennamen-Laenge
  346. class_name_length = struct.unpack(">H", header[34:36])[0]
  347. # Lese Klassenname
  348. class_name = await self._reader.readexactly(class_name_length)
  349. # Lese Datenlaenge (4 bytes)
  350. data_length_bytes = await self._reader.readexactly(4)
  351. data_length = struct.unpack(">I", data_length_bytes)[0]
  352. # Lese Daten
  353. data = await self._reader.readexactly(data_length) if data_length > 0 else b""
  354. # Baue vollstaendige Nachricht zusammen
  355. full_message = header + class_name + data_length_bytes + data
  356. return self._protocol.deserialize(full_message)
  357. except (ConnectionResetError, asyncio.IncompleteReadError):
  358. return None
  359. except Exception as e:
  360. perror(f"SyncConnection: Lese-Fehler: {e}")
  361. return None
  362. async def _close_socket(self) -> None:
  363. """Schliesst den Socket."""
  364. if self._writer:
  365. try:
  366. self._writer.close()
  367. await self._writer.wait_closed()
  368. except Exception:
  369. pass
  370. self._reader = None
  371. self._writer = None