Files
hms-mediaengine/_entwicklung/packages/parameter_engine/hms_parameter/engine.py
T
HMS MediaEngine Agent 362e089be0 AUFGERAUMT: Root auf 10 sichtbare Elemente reduziert
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).
2026-09-11 23:44:06 +02:00

157 lines
5.6 KiB
Python

"""Parameter-Engine: Prioritäten, Übernahme, Frame-Snapshot (PLAN.md §11).
Alle Steuerquellen (Browser, Art-Net, später Timeline/Audio/KI) laufen über
diese Engine; direkte Renderer-Zugriffe sind verboten (§11, §33).
"""
from __future__ import annotations
import enum
import time
from dataclasses import dataclass, field
from hms_parameter.paths import validate_parameter_path
class ControlSource(enum.IntEnum):
"""Steuerquellen in Prioritätsordnung (§11.2)."""
SAFETY = 1 # Not-Aus/Blackout, überstimmt alles
OPERATOR = 2 # expliziter manueller Override
CONSOLE = 3 # freigegebenes Lichtpult (Art-Net)
WEB = 4 # Browser-Livebedienung
TIMELINE = 5 # reserviert
AUDIO = 6 # reserviert (Modulatoren)
AI = 7 # reserviert, niedrigste Priorität
class MergeMode(enum.Enum):
"""Übernahmeverfahren (§11.3)."""
LTP = "ltp" # letzte Änderung gewinnt (Standard)
HTP = "htp" # höchster Wert gewinnt (optional für Intensität)
@dataclass
class _Binding:
value: float
last_change_ns: int
class ParameterFrame:
"""Unveränderlicher Snapshot aller Parameter für genau einen Frame (§11.4)."""
__slots__ = ("_values", "revision", "created_ns")
def __init__(self, values: dict[str, float], revision: int) -> None:
object.__setattr__(self, "_values", dict(values))
object.__setattr__(self, "revision", revision)
object.__setattr__(self, "created_ns", time.monotonic_ns())
def get(self, path: str, default: float = 0.0) -> float:
return self._values.get(path, default)
def as_dict(self) -> dict[str, float]:
return dict(self._values)
def __contains__(self, path: str) -> bool:
return path in self._values
class RevisionConflict(Exception):
"""Erwartete Revision stimmt nicht (optimistische Sperre, §23.2)."""
def __init__(self, current: int, expected: int) -> None:
self.current = current
self.expected = expected
super().__init__(f"revision conflict: current={current}, expected={expected}")
@dataclass
class ParameterEngine:
"""Autoritative Parameter-Instanz des Control Core.
- set_value: Override einer Quelle mit Prioritätsprüfung
- release: Rückgabe an nächstniedrigere Quelle (§11.3)
- snapshot: atomarer Frame-Snapshot (§11.4)
"""
revision: int = 0
default: float = 0.0
merge_mode: MergeMode = MergeMode.LTP
_bindings: dict[str, dict[ControlSource, _Binding]] = field(
default_factory=dict, repr=False
)
_defaults: dict[str, float] = field(default_factory=dict, repr=False)
def set_value(
self,
path: str,
value: float,
source: ControlSource,
expected_revision: int | None = None,
) -> int:
"""Setzt einen Override; gibt die neue Revision zurück."""
if not validate_parameter_path(path):
raise ValueError(f"invalid parameter path: {path!r}")
value = float(value)
if value != value or value in (float("inf"), float("-inf")):
raise ValueError(f"value must be finite, got {value}")
if expected_revision is not None and expected_revision != self.revision:
raise RevisionConflict(self.revision, expected_revision)
per_source = self._bindings.setdefault(path, {})
# Priorität: eine niedrigere Quelle kann eine höhere Quelle nicht
# verdrängen, aber ihre eigene Bindung jederzeit aktualisieren.
existing = per_source.get(source)
now = time.monotonic_ns()
if existing is None:
per_source[source] = _Binding(value, now)
self.revision += 1
elif self.merge_mode is MergeMode.LTP:
# LTP: jede Übernahme aktualisiert Bindung und Revision (§11.3)
per_source[source] = _Binding(value, now)
self.revision += 1
elif value > existing.value:
# HTP: nur ein höherer Wert übernimmt; Maximum bleibt (§11.3)
per_source[source] = _Binding(value, now)
self.revision += 1
return self.revision
def effective_value(self, path: str) -> float:
"""Wirksamer Wert: höchste Priorität gewinnt; sonst Default (§11.1)."""
per_source = self._bindings.get(path)
if not per_source:
return self._defaults.get(path, self.default)
source = min(per_source) # kleinster IntEnum-Wert = höchste Priorität
return per_source[source].value
def current_source(self, path: str) -> ControlSource | None:
per_source = self._bindings.get(path)
if not per_source:
return None
return min(per_source)
def release(self, path: str, source: ControlSource) -> int:
"""Gibt den Override zurück; nächstniedrigere Quelle übernimmt (§11.3)."""
per_source = self._bindings.get(path)
if per_source and source in per_source:
del per_source[source]
if not per_source:
self._bindings.pop(path, None)
self.revision += 1
return self.revision
def snapshot(self) -> ParameterFrame:
"""Atomarer Snapshot aller wirksamen Werte für einen Frame (§11.4)."""
values = {p: self._defaults[p] for p in self._defaults}
for path, per_source in self._bindings.items():
if per_source:
values[path] = per_source[min(per_source)].value
return ParameterFrame(values, self.revision)
def set_default(self, path: str, value: float) -> None:
if not validate_parameter_path(path):
raise ValueError(f"invalid parameter path: {path!r}")
self._defaults[path] = float(value)