Phase 2: State-Sync-Fundament – State-Store, Gruppen, Clock, Aktivierung
- ProjectStateStore (§6.4, §24.2): monotone State-/Projekt-Revisionen, Snapshot nach Verbindung, Delta mit neu/geaendert/geloescht, Szenenabruf als Zielzustand (Uebergang macht der Renderer, §18.1), Projekt-/Livezustand strikt getrennt - GroupRouter (§6.3, §10.1): Ziele All/Node/Output/ServerGroup, Regeln selected/tag_query/all, Commit-Vorschau (§17.5) - ClockEstimator (§6.4): RTT-Min-Filter (<=2x Min gegen Jitter), Offset-/Drift-Schaetzung (Drift erst ab 1 s Fenster belastbar), Showzeit -> lokale Node-Zeit - ActivationCoordinator (§6.5): zeitgestempelte Preset-Aktivierung, 200 ms Vorlauf, Arm/Execute/Ack-Kette, FAILED bei fehlender Arm-Bestaetigung (kein stilles Ueberbruecken) - 35 neue Unit-Tests; Gesamtsuite 293 gruen, Ruff gruen
This commit is contained in:
@@ -0,0 +1,165 @@
|
||||
"""Autoritativer Projekt- und Showzustand (PLAN.md §6.4 State Sync, §24.2).
|
||||
|
||||
- Vollständiger Snapshot nach Verbindung; danach inkrementelle Deltas
|
||||
- monotone Revisionen: kein halber Zustand (§11.4, §6.4)
|
||||
- Livezustand und dauerhafter Projektzustand sind getrennt (§24.2)
|
||||
- Szenenaktivierung erzeugt eine neue State-Revision
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from hms_domain.model import PresetScene, Project
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StateDelta:
|
||||
"""Inkrementelle Änderung mit monotoner Revision (§6.4).
|
||||
|
||||
- project_revision: Projekt-Inhaltsrevision (Manifest-Ebene)
|
||||
- state_revision: monotone Showzustands-Revision
|
||||
- changes: Parameterpfad → Wert; gelöschte Pfade als None markiert
|
||||
"""
|
||||
|
||||
state_revision: int
|
||||
project_revision: int
|
||||
changes: dict[str, float | None]
|
||||
monotonic_ns: int
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"state_revision": self.state_revision,
|
||||
"project_revision": self.project_revision,
|
||||
"changes": self.changes,
|
||||
"monotonic_ns": self.monotonic_ns,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProjectStateStore:
|
||||
"""Autoritative Instanz im Control Core (§6.1B, §6.4).
|
||||
|
||||
Trennung (§24.2):
|
||||
- project: dauerhafter Projektzustand (Domänenmodell, persistiert)
|
||||
- live: Show-Livezustand (Parameterwerte je Pfad, nicht persistent)
|
||||
|
||||
Revisions:
|
||||
- state_revision steigt bei jeder Livezustandsänderung monoton
|
||||
- project_revision steigt bei Projektinhalts-Änderungen (z. B. neue
|
||||
Szenen, Medien-Revision) – Grundlage für Preflight (§6.5)
|
||||
"""
|
||||
|
||||
_state_revision: int = 0
|
||||
_project_revision: int = 0
|
||||
_live: dict[str, float] = field(default_factory=dict)
|
||||
_project: Project | None = None
|
||||
_project_name: str = ""
|
||||
|
||||
# ---------- Projekt ----------
|
||||
|
||||
def activate_project(self, project: Project) -> int:
|
||||
"""Aktiviert ein Projekt als autoritative Basis; erhöht die
|
||||
Projektrevision. Livezustand wird zurückgesetzt (kein Mischzustand)."""
|
||||
self._project = project
|
||||
self._project_name = project.name
|
||||
self._project_revision += 1
|
||||
self._live.clear()
|
||||
self._state_revision += 1 # neuer Zustand nach Projektwechsel
|
||||
return self._state_revision
|
||||
|
||||
@property
|
||||
def project(self) -> Project | None:
|
||||
return self._project
|
||||
|
||||
@property
|
||||
def project_revision(self) -> int:
|
||||
return self._project_revision
|
||||
|
||||
@property
|
||||
def state_revision(self) -> int:
|
||||
return self._state_revision
|
||||
|
||||
# ---------- Livezustand ----------
|
||||
|
||||
def set_value(self, path: str, value: float) -> int:
|
||||
"""Setzt einen Liveparameter; gibt die neue State-Revision zurück."""
|
||||
self._live[path] = float(value)
|
||||
self._state_revision += 1
|
||||
return self._state_revision
|
||||
|
||||
def clear_value(self, path: str) -> int:
|
||||
"""Entfernt einen Liveparameter (z. B. Release); neue Revision."""
|
||||
self._live.pop(path, None)
|
||||
self._state_revision += 1
|
||||
return self._state_revision
|
||||
|
||||
def get_value(self, path: str) -> float | None:
|
||||
return self._live.get(path)
|
||||
|
||||
# ---------- Snapshot / Delta (§6.4) ----------
|
||||
|
||||
def snapshot(self) -> dict:
|
||||
"""Vollständiger Zustand nach Verbindungsaufbau (§6.2, §6.4)."""
|
||||
return {
|
||||
"state_revision": self._state_revision,
|
||||
"project_revision": self._project_revision,
|
||||
"project_name": self._project_name,
|
||||
"values": dict(self._live),
|
||||
"monotonic_ns": time.monotonic_ns(),
|
||||
}
|
||||
|
||||
def delta_since(self, last_seen_revision: int, pending: dict[str, float]) -> StateDelta | None:
|
||||
"""Delta seit einer gesehenen Revision; None, wenn nichts Neues.
|
||||
|
||||
pending: letzter BEKANNTER Zustand des Empfängers (Pfad → Wert,
|
||||
wie er bei last_seen_revision beim Client stand). Das Delta enthält:
|
||||
- neue Pfade (in live, nicht in pending) mit ihrem Wert
|
||||
- geänderte Pfade mit dem neuen Wert
|
||||
- gelöschte Pfade als None
|
||||
Ein vollständiger Re-Sync (neuer Snapshot) ist Aufgabe des
|
||||
Transports, wenn last_seen_revision zu alt ist (§6.2).
|
||||
"""
|
||||
if last_seen_revision > self._state_revision:
|
||||
raise ValueError(
|
||||
f"gesehene Revision {last_seen_revision} liegt in der Zukunft"
|
||||
)
|
||||
if last_seen_revision == self._state_revision:
|
||||
return None # nichts Neues
|
||||
changes: dict[str, float | None] = {}
|
||||
for path, value in self._live.items():
|
||||
if path not in pending:
|
||||
changes[path] = value # neu seit last_seen
|
||||
elif pending[path] != value:
|
||||
changes[path] = value # geändert
|
||||
for path in pending:
|
||||
if path not in self._live:
|
||||
changes[path] = None # gelöscht
|
||||
return StateDelta(
|
||||
state_revision=self._state_revision,
|
||||
project_revision=self._project_revision,
|
||||
changes=changes,
|
||||
monotonic_ns=time.monotonic_ns(),
|
||||
)
|
||||
|
||||
# ---------- Szenen (§18) ----------
|
||||
|
||||
def apply_scene(self, scene: PresetScene) -> int:
|
||||
"""Aktiviert eine Szene direkt (§18.1): ÜBERNAHME der Snapshot-Werte
|
||||
in den Livezustand als neue State-Revision. Übergänge (Crossfade
|
||||
etc.) berechnet der Renderer aus vorher/nachher – hier entsteht nur
|
||||
der Zielzustand (§18.1: Diff zur Laufzeit).
|
||||
"""
|
||||
snapshot_values = scene.composition_snapshot.get("values", {})
|
||||
if not isinstance(snapshot_values, dict):
|
||||
raise ValueError("Szene enthält keine Werte")
|
||||
for path, value in snapshot_values.items():
|
||||
self._live[str(path)] = float(value)
|
||||
self._state_revision += 1
|
||||
return self._state_revision
|
||||
|
||||
def bump_project_revision(self) -> int:
|
||||
"""Projektinhalt geändert (Medien/Plugins/Szenen) → neue Revision."""
|
||||
self._project_revision += 1
|
||||
return self._project_revision
|
||||
Reference in New Issue
Block a user