Files
hms-mediaengine/packages/persistence/hms_persistence/state_store.py
T
HMS MediaEngine Agent cc119d3751 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
2026-09-11 01:31:26 +02:00

166 lines
5.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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