362e089be0
Der Nutzer hat recht: Der Ordner war voller Entwicklungs-Muell. Jetzt ist sauber getrennt: ROOT (was der Nutzer sieht und braucht): - run.py = das Programm - hms_app/ = der Anwendungscode - HMS MediaEngine.app = macOS Doppelklick-Starter - HMS-Start.vbs = Windows Doppelklick-Starter - HMS-Install.vbs = Windows Erst-Installation - HMS-Mac-Install.command = macOS Homebrew-Installation - HMS-Portable-Install.command = macOS Portable-Installation (16GB-Fix) - installer_gui.py = grafischer Installer - launcher.pyw + launcher_core.py = interne Start-Logik - LIESMICH.txt = 10-Zeilen-Kurzanleitung - .gitignore _entwicklung/ (alles andere, NICHT benoetigt): - packages/ apps/ native/ plugins/ tools/ schemas/ tests/ docs/ build/ fixture_profiles/ - PLAN.md STATUS.md ERRORS.md TEST_REPORT.md CHANGELOG.md README.md - pyproject.toml uv.lock setup_*.sh/ps1 make_mac_app.py Diese Trennung gilt ab sofort fuer alle Commits. Der Nutzer kann _entwicklung/ loeschen wenn er Platz braucht - die App laeuft ohne. Verifiziert: App startet nach Aufraeumen unveraendert (Health 200).
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)
|