service.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313
  1. # -*- coding: utf-8 -*-
  2. """
  3. SyncService — Standalone-seitiger Sync-Orchestrator.
  4. Probe-Loop: Alle probe_interval_seconds prüfen ob Server erreichbar.
  5. Bei Verbindung: Initialer Sync, danach periodischer Sync alle sync_interval_seconds.
  6. """
  7. from __future__ import annotations
  8. import asyncio
  9. from datetime import datetime, timezone
  10. from typing import TYPE_CHECKING
  11. from trixy_core.sync.connection import SyncConnection, SyncConnectionConfig
  12. from trixy_core.sync.models import SyncItem, _now_iso
  13. from trixy_core.network.cmd.sync import SyncPush, SyncRequest, SyncResponse, SyncAck
  14. from trixy_core.network.protocol import ProtocolMessage
  15. from trixy_core.network.client_connection import get_mac_address
  16. from trixy_core.utils.debug import pinfo, pdebug, perror, pwarn
  17. if TYPE_CHECKING:
  18. from trixy_core.config.datasets.standalone import SyncConfig
  19. from trixy_core.standalone import StandaloneApplication
  20. class SyncService:
  21. """
  22. Orchestriert die Datensynchronisation zwischen Standalone und Server.
  23. - Probe-Loop: Regelmaessig versuchen, den Server zu erreichen
  24. - Bei Verbindung: Handshake → initialer Sync → periodischer Sync
  25. - Bei Verbindungsverlust: Zurueck zum Probe-Loop
  26. """
  27. def __init__(self, application: StandaloneApplication, config: SyncConfig) -> None:
  28. """
  29. Initialisiert den SyncService.
  30. Args:
  31. application: Die Standalone-Anwendung
  32. config: Sync-Konfiguration
  33. """
  34. self._app = application
  35. self._config = config
  36. self._mac_address = get_mac_address()
  37. # Verbindung
  38. conn_config = SyncConnectionConfig(
  39. host=config.server_host,
  40. port=config.server_port,
  41. probe_interval_seconds=config.probe_interval_seconds,
  42. sync_interval_seconds=config.sync_interval_seconds,
  43. connect_timeout_seconds=config.connect_timeout_seconds,
  44. )
  45. self._connection = SyncConnection(
  46. config=conn_config,
  47. room=application.room,
  48. alias=application.alias,
  49. mac_address=self._mac_address,
  50. )
  51. self._connection.on_connected(self._on_connected)
  52. self._connection.on_disconnected(self._on_disconnected)
  53. self._connection.on_message(self._on_message)
  54. # Zustand
  55. self._running = False
  56. self._probe_task: asyncio.Task | None = None
  57. self._sync_task: asyncio.Task | None = None
  58. self._sync_in_progress = False
  59. # Fuer Antwort-Handling
  60. self._pending_response: asyncio.Future[SyncResponse] | None = None
  61. async def start(self) -> None:
  62. """Startet den SyncService (Probe-Loop)."""
  63. if not self._config.server_host:
  64. pwarn("SyncService: Kein Server-Host konfiguriert, Sync deaktiviert")
  65. return
  66. self._running = True
  67. self._probe_task = asyncio.create_task(self._probe_loop())
  68. pinfo(f"SyncService: Gestartet (Probe alle {self._config.probe_interval_seconds}s zu {self._config.server_host}:{self._config.server_port})")
  69. async def stop(self) -> None:
  70. """Stoppt den SyncService."""
  71. self._running = False
  72. # Tasks beenden
  73. for task in [self._probe_task, self._sync_task]:
  74. if task and not task.done():
  75. task.cancel()
  76. try:
  77. await task
  78. except asyncio.CancelledError:
  79. pass
  80. # Verbindung trennen
  81. if self._connection.is_connected:
  82. await self._connection.disconnect("SyncService gestoppt")
  83. pinfo("SyncService: Gestoppt")
  84. async def sync_now(self) -> bool:
  85. """
  86. Fuehrt einen manuellen Sync durch (z.B. per Sprachbefehl).
  87. Returns:
  88. True bei Erfolg
  89. """
  90. if not self._connection.is_connected:
  91. pwarn("SyncService: Nicht verbunden, Sync nicht moeglich")
  92. return False
  93. if self._sync_in_progress:
  94. pdebug("SyncService: Sync bereits aktiv")
  95. return False
  96. return await self._perform_sync()
  97. # =========================================================================
  98. # Probe-Loop
  99. # =========================================================================
  100. async def _probe_loop(self) -> None:
  101. """Versucht regelmaessig, den Server zu erreichen."""
  102. while self._running:
  103. try:
  104. if not self._connection.is_connected:
  105. pdebug(f"SyncService: Probe zu {self._config.server_host}:{self._config.server_port}...")
  106. connected = await self._connection.connect()
  107. if connected:
  108. # Initialer Sync wird in _on_connected ausgeloest
  109. # Periodischen Sync starten
  110. self._sync_task = asyncio.create_task(self._periodic_sync_loop())
  111. await asyncio.sleep(self._config.probe_interval_seconds)
  112. except asyncio.CancelledError:
  113. break
  114. except Exception as e:
  115. perror(f"SyncService: Probe-Fehler: {e}")
  116. await asyncio.sleep(self._config.probe_interval_seconds)
  117. async def _periodic_sync_loop(self) -> None:
  118. """Fuehrt periodische Syncs durch solange verbunden."""
  119. while self._running and self._connection.is_connected:
  120. try:
  121. await asyncio.sleep(self._config.sync_interval_seconds)
  122. if not self._running or not self._connection.is_connected:
  123. break
  124. await self._perform_sync()
  125. except asyncio.CancelledError:
  126. break
  127. except Exception as e:
  128. perror(f"SyncService: Periodischer Sync Fehler: {e}")
  129. # =========================================================================
  130. # Sync-Ablauf
  131. # =========================================================================
  132. async def _perform_sync(self) -> bool:
  133. """
  134. Fuehrt einen vollstaendigen Sync-Zyklus durch.
  135. 1. Lokale Aenderungen an Server pushen
  136. 2. Server-Aenderungen anfragen
  137. 3. Server-Items lokal mergen
  138. 4. last_sync_time aktualisieren
  139. """
  140. store = self._app.sync_store
  141. if store is None:
  142. perror("SyncService: Kein SyncStore verfuegbar")
  143. return False
  144. self._sync_in_progress = True
  145. try:
  146. last_sync = store.get_last_sync_time()
  147. pdebug(f"SyncService: Starte Sync (seit: {last_sync or 'nie'})")
  148. # 1. Lokale Aenderungen pushen
  149. local_changes = store.get_changes_since(last_sync) if last_sync else []
  150. if local_changes:
  151. push_msg = SyncPush(
  152. items=[item.to_dict() for item in local_changes]
  153. )
  154. if not await self._connection.send_command(push_msg):
  155. perror("SyncService: SyncPush senden fehlgeschlagen")
  156. return False
  157. pdebug(f"SyncService: {len(local_changes)} lokale Aenderungen gepusht")
  158. # 2. Server-Aenderungen anfragen
  159. request_msg = SyncRequest(since=last_sync)
  160. # Future fuer Antwort erstellen
  161. loop = asyncio.get_running_loop()
  162. self._pending_response = loop.create_future()
  163. if not await self._connection.send_command(request_msg):
  164. perror("SyncService: SyncRequest senden fehlgeschlagen")
  165. self._pending_response = None
  166. return False
  167. # 3. Auf Antwort warten (mit Timeout)
  168. try:
  169. response = await asyncio.wait_for(self._pending_response, timeout=30.0)
  170. except asyncio.TimeoutError:
  171. perror("SyncService: Timeout beim Warten auf SyncResponse")
  172. self._pending_response = None
  173. return False
  174. finally:
  175. self._pending_response = None
  176. # 4. Server-Items lokal mergen
  177. merged_count = 0
  178. for item_dict in response.items:
  179. item = SyncItem.from_dict(item_dict)
  180. if store.merge_incoming(item):
  181. merged_count += 1
  182. # 5. last_sync_time aktualisieren
  183. server_time = response.server_time or _now_iso()
  184. store.set_last_sync_time(server_time)
  185. pinfo(f"SyncService: Sync abgeschlossen ({len(local_changes)} gepusht, {merged_count} gemerged)")
  186. # 6. Event emittieren
  187. await self._app.events.emit("sync_completed", {
  188. "pushed_count": len(local_changes),
  189. "merged_count": merged_count,
  190. "server_time": server_time,
  191. })
  192. return True
  193. except Exception as e:
  194. perror(f"SyncService: Sync-Fehler: {e}")
  195. return False
  196. finally:
  197. self._sync_in_progress = False
  198. # =========================================================================
  199. # Callbacks
  200. # =========================================================================
  201. async def _on_connected(self) -> None:
  202. """Callback bei Verbindungsaufbau."""
  203. pinfo("SyncService: Mit Server verbunden, starte initialen Sync...")
  204. await self._perform_sync()
  205. async def _on_disconnected(self, reason: str) -> None:
  206. """Callback bei Verbindungsabbruch."""
  207. pinfo(f"SyncService: Verbindung verloren: {reason}")
  208. # Periodischen Sync stoppen
  209. if self._sync_task and not self._sync_task.done():
  210. self._sync_task.cancel()
  211. try:
  212. await self._sync_task
  213. except asyncio.CancelledError:
  214. pass
  215. async def _on_message(self, message: ProtocolMessage) -> None:
  216. """Callback fuer eingehende Nachrichten vom Server."""
  217. class_name = message.class_name
  218. if class_name == "SyncResponse":
  219. # Antwort auf SyncRequest
  220. data = message.data
  221. if isinstance(data, SyncResponse):
  222. response = data
  223. elif isinstance(data, dict):
  224. response = SyncResponse(
  225. items=data.get("items", []),
  226. server_time=data.get("server_time", ""),
  227. )
  228. else:
  229. perror(f"SyncService: Ungueltiges SyncResponse-Format: {type(data)}")
  230. return
  231. if self._pending_response and not self._pending_response.done():
  232. self._pending_response.set_result(response)
  233. elif class_name == "SyncAck":
  234. pdebug("SyncService: SyncAck empfangen")
  235. elif class_name == "SyncPush":
  236. # Server pusht Aenderungen (z.B. nach Aenderung durch anderen Satellite)
  237. store = self._app.sync_store
  238. if store is None:
  239. return
  240. data = message.data
  241. items: list[dict] = []
  242. if isinstance(data, SyncPush):
  243. items = data.items
  244. elif isinstance(data, dict):
  245. items = data.get("items", [])
  246. merged_count = 0
  247. for item_dict in items:
  248. item = SyncItem.from_dict(item_dict)
  249. if store.merge_incoming(item):
  250. merged_count += 1
  251. if merged_count > 0:
  252. pdebug(f"SyncService: {merged_count} Server-Push Items gemerged")
  253. await self._app.events.emit("sync_push_received", {
  254. "merged_count": merged_count,
  255. })