b2610e35e5
- RemoteStateMirror (Renderer): Snapshot-Pflicht nach (Re-)Connect, Deltas mit neu/geaendert/geloescht, Deltas vor erstem Snapshot werden abgelehnt (Re-Sync), Duplikate/Veraltete idempotent - RendererStateLink (Control Core): connect_and_sync mit Pflicht-Snapshot, sync_if_changed versendet Deltas seit letzter gesendeter Revision, Reconnect loest immer neuen Snapshot aus (kein halber Zustand) - End-to-End-Integrationstests ueber echtes TCP-Loopback ohne Mocks: Snapshot->Delta-Kette, Reconnect-Re-Sync, Delta-vor-Snapshot-Ablehnung, Duplikat-No-Op - Gesamtsuite 297 gruen, Ruff gruen
91 lines
3.4 KiB
Python
91 lines
3.4 KiB
Python
"""RendererStateLink auf der Control-Core-Seite (PLAN.md §6.2, §6.4).
|
||
|
||
Verbindet ProjectStateStore (autoritativ) mit dem Renderer über IPC:
|
||
- connect_and_sync(): Handshake + vollständiger Snapshot (§6.2 Pflicht)
|
||
- sync_if_changed(): Delta seit letzter gesendeter Revision
|
||
- Reconnect: immer neuer Snapshot, danach erst wieder Deltas (§6.2)
|
||
|
||
Der Link kennt den letzten beim Renderer angekommenen Zustand und
|
||
berechnet Deltas daraus – keine halben Zustände (§11.4, §6.4).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from hms_persistence.state_store import ProjectStateStore
|
||
from hms_protocol import Envelope, IpcClient, MessageType
|
||
|
||
|
||
class RendererStateLink:
|
||
"""Synchronisiert den Showzustand Control Core → Renderer über IPC."""
|
||
|
||
def __init__(self, client: IpcClient, store: ProjectStateStore) -> None:
|
||
self._client = client
|
||
self._store = store
|
||
self._last_sent_revision = 0
|
||
self._renderer_known_state: dict[str, float] = {}
|
||
|
||
@property
|
||
def last_sent_revision(self) -> int:
|
||
return self._last_sent_revision
|
||
|
||
@property
|
||
def in_sync(self) -> bool:
|
||
"""True, wenn der Renderer die aktuelle Revision besitzt."""
|
||
return self._last_sent_revision == self._store.state_revision
|
||
|
||
# ---------- Verbindung (§6.2) ----------
|
||
|
||
async def connect_and_sync(self) -> None:
|
||
"""Verbindung aufbauen und vollständigen Snapshot senden.
|
||
|
||
Nach jedem (Re-)Connect wird immer zuerst der vollständige Snapshot
|
||
übertragen; Deltas folgen erst danach (§6.2: „Re-Sync nach
|
||
Reconnect", „Deltas erst nach erfolgreichem Re-Sync akzeptiert").
|
||
"""
|
||
await self._client.connect()
|
||
await self.send_full_snapshot()
|
||
|
||
async def send_full_snapshot(self) -> int:
|
||
"""Sendet den vollständigen Zustand; liefert die gesendete Revision."""
|
||
snap = self._store.snapshot()
|
||
self._last_sent_revision = snap["state_revision"]
|
||
self._renderer_known_state = dict(snap["values"])
|
||
envelope = Envelope(
|
||
type=MessageType.SNAPSHOT,
|
||
revision=snap["state_revision"],
|
||
payload=snap,
|
||
)
|
||
await self._client.send(envelope)
|
||
return snap["state_revision"]
|
||
|
||
# ---------- Delta-Versand (§6.4) ----------
|
||
|
||
async def sync_if_changed(self) -> bool:
|
||
"""Sendet ein Delta, falls sich die Revision seit dem letzten Versand
|
||
geändert hat. Rückgabe: True, wenn etwas gesendet wurde.
|
||
|
||
Das Delta wird aus dem zuletzt bekannten Renderer-Zustand berechnet
|
||
(neu/geändert/gelöscht); die Semantik folgt StateDelta (§6.4).
|
||
"""
|
||
if self._store.state_revision == self._last_sent_revision:
|
||
return False # nichts Neues
|
||
delta = self._store.delta_since(
|
||
self._last_sent_revision, self._renderer_known_state
|
||
)
|
||
if delta is None:
|
||
return False
|
||
# Buchhaltung: was weiß der Renderer ab jetzt?
|
||
for path, value in delta.changes.items():
|
||
if value is None:
|
||
self._renderer_known_state.pop(path, None)
|
||
else:
|
||
self._renderer_known_state[path] = value
|
||
self._last_sent_revision = delta.state_revision
|
||
envelope = Envelope(
|
||
type=MessageType.EVENT,
|
||
revision=delta.state_revision,
|
||
payload=delta.to_dict(),
|
||
)
|
||
await self._client.send(envelope)
|
||
return True
|