"""Zeitgestempelte Preset-Aktivierung über Gruppen (§6.5, §6.4). Koordinierter Show-Modus (§6.3): Der Coordinator verteilt zeitgestempelte Preset-/Parameterkommandos mit Preload/Arm/Ack und execute_at typischerweise 100–300 ms im Voraus. Zwei-Phasen-Aktivierung (§6.5): vollständig ins Staging übertragen und validieren, danach atomar auf dieselbe Revision schalten. execute_at ist eine Showzeit (Coordinator-Zeitbasis); jeder Node bildet sie über seinen Clock-Offset auf die lokale monotone Zeit ab (§6.4). """ from __future__ import annotations import uuid from dataclasses import dataclass, field from enum import StrEnum from hms_cluster.clock import now_monotonic_ns from hms_cluster.groups import GroupRouter, TargetKind DEFAULT_LEAD_NS = 200_000_000 # 200 ms Vorlauf (§6.4: typisch 100–300 ms) class ArmState(StrEnum): """Phasen eines geplanten Preset-Starts (§6.5).""" CREATED = "created" ARMED = "armed" EXECUTED = "executed" FAILED = "failed" @dataclass class PlannedActivation: """Ein zeitgestempelter Gruppen-Command (§6.5).""" command_id: str scene_id: str target_kind: TargetKind target_id: str | None execute_at_show_ns: int # Coordinator-Showzeit created_ns: int state: ArmState = ArmState.CREATED armed_nodes: frozenset[str] = field(default_factory=frozenset) executed_nodes: frozenset[str] = field(default_factory=frozenset) @classmethod def plan( cls, scene_id: str, target_kind: TargetKind, target_id: str | None, now_show_ns: int, lead_ns: int = DEFAULT_LEAD_NS, ) -> PlannedActivation: """Plant die Ausführung `lead_ns` im Voraus (§6.4).""" return cls( command_id=str(uuid.uuid4()), scene_id=scene_id, target_kind=target_kind, target_id=target_id, execute_at_show_ns=now_show_ns + lead_ns, created_ns=now_monotonic_ns(), ) class ActivationCoordinator: """Koordiniert Preload/Arm/Execute über eine Node-Gruppe (§6.5). - schedule(): plant die Aktivierung mit Vorlauf - acknowledge_arm(): Node bestätigt arm (Preflight grün) - acknowledge_execute(): Node hat ausgeführt (mit Ist-Zeit) - due(): Aktivierungen, deren Showzeit erreicht ist (pro Tick) Fehlerfälle (§6.5): fehlt eine Arm-Bestätigung, blockiert die Ausführung standardmäßig; das wird als FAILED sichtbar dokumentiert und nicht still überbrückt. """ def __init__(self, router: GroupRouter) -> None: self._router = router self._planned: dict[str, PlannedActivation] = {} def schedule( self, scene_id: str, target_kind: TargetKind = TargetKind.ALL, target_id: str | None = None, now_show_ns: int | None = None, lead_ns: int = DEFAULT_LEAD_NS, ) -> PlannedActivation: """Plant eine Aktivierung; Ziel-Nodes werden sofort aufgelöst.""" now = now_show_ns if now_show_ns is not None else now_monotonic_ns() planned = PlannedActivation.plan( scene_id=scene_id, target_kind=target_kind, target_id=target_id, now_show_ns=now, lead_ns=lead_ns, ) self._planned[planned.command_id] = planned return planned def expected_nodes(self, planned: PlannedActivation) -> frozenset[str]: """Ziel-Nodes dieser Aktivierung (§17.5: vor Commit sichtbar).""" return self._router.preview_targets(planned.target_kind, planned.target_id) def acknowledge_arm(self, command_id: str, node_id: str) -> ArmState: """Node hat geprüft und armed (§6.5).""" planned = self._planned.get(command_id) if planned is None: raise KeyError(f"unbekannter Command {command_id}") planned.armed_nodes = frozenset(set(planned.armed_nodes) | {node_id}) return planned.state def acknowledge_execute(self, command_id: str, node_id: str) -> ArmState: """Node hat ausgeführt; Ist-Zeit wird vom Node gemeldet (§6.5).""" planned = self._planned.get(command_id) if planned is None: raise KeyError(f"unbekannter Command {command_id}") planned.executed_nodes = frozenset(set(planned.executed_nodes) | {node_id}) if planned.executed_nodes >= self.expected_nodes(planned): planned.state = ArmState.EXECUTED return planned.state def due(self, now_show_ns: int | None = None) -> list[PlannedActivation]: """Aktivierungen, deren Showzeit gekommen ist – nur für armed. Nicht voll armbare Aktivierungen werden FAILED, nicht still ausgeführt (§6.5: fehlende Bestätigung blockiert standardmäßig). """ now = now_show_ns if now_show_ns is not None else now_monotonic_ns() due_list: list[PlannedActivation] = [] for planned in list(self._planned.values()): if planned.state is not ArmState.CREATED: continue if now >= planned.execute_at_show_ns: expected = self.expected_nodes(planned) if expected and planned.armed_nodes >= expected: planned.state = ArmState.ARMED due_list.append(planned) else: planned.state = ArmState.FAILED # §6.5: Blockade sichtbar return due_list def get(self, command_id: str) -> PlannedActivation | None: return self._planned.get(command_id)