149 lines
5.4 KiB
Python
149 lines
5.4 KiB
Python
|
|
"""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)
|