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).
229 lines
9.4 KiB
Python
229 lines
9.4 KiB
Python
"""DMX-zu-Parameter-Verkabelung (PLAN.md §11, §16, §29.2).
|
||
|
||
Verbindet Art-Net-Receiver → FixturePatch → Master32/Layer64-Engines →
|
||
ParameterEngine:
|
||
|
||
- jedes DMX-Update wird über den Patch dekodiert
|
||
- dekodierte Werte fließen über stabile Parameterpfade in die Engine
|
||
(§11: alle Quellen über die Parameter-Engine, nie direkt in den Renderer)
|
||
- Master-Blackout läuft mit SAFETY-Priorität (§11.2: überstimmt alles)
|
||
- Load/Commit-Events steuern PreloadSlot für atomaren Clipwechsel (§16.5,
|
||
§12.2): Preload bei pending selection, Commit wechselt atomar
|
||
- Signalverlust: konfigurierbare Policy je Patch (§16.1, §11.3)
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from dataclasses import dataclass
|
||
|
||
from hms_parameter.engine import ControlSource, ParameterEngine
|
||
|
||
from hms_artnet.fixtures import (
|
||
Layer64Engine,
|
||
Master32Engine,
|
||
TransportCommand,
|
||
)
|
||
from hms_artnet.patch import MASTER_CHANNELS, FixturePatch
|
||
from hms_artnet.receiver import DmxUpdate, LossBehavior
|
||
|
||
|
||
@dataclass
|
||
class RouterStats:
|
||
"""Telemetrie je Router (§28.2: Paketrate, Events)."""
|
||
|
||
updates_processed: int = 0
|
||
master_updates: int = 0
|
||
layer_updates: int = 0
|
||
load_commits: int = 0
|
||
blackouts: int = 0
|
||
|
||
|
||
class DmxToParameterRouter:
|
||
"""Routet DMX-Daten über den Patch in die Parameter-Engine.
|
||
|
||
Aufbau (§6.1B, §11):
|
||
- ein Master32Engine für das Master-Fixture
|
||
- je gepatchtem Layer ein Layer64Engine (Flankenzustand je Instanz)
|
||
- PreloadSlot je Layer für atomaren Clipwechsel (§12.2, §16.5)
|
||
|
||
Blackout (§16.3): Master-Kanal 3 mit SAFETY-Priorität; beim Aufheben
|
||
wird der Override releast (nicht auf 0 gesetzt) – der darunterliegende
|
||
Zustand bleibt erhalten (§11.3 Release-Semantik).
|
||
"""
|
||
|
||
def __init__(
|
||
self,
|
||
patch: FixturePatch,
|
||
engine: ParameterEngine,
|
||
loss_behavior: LossBehavior = LossBehavior.HOLD,
|
||
) -> None:
|
||
self._patch = patch
|
||
self._engine = engine
|
||
self._loss_behavior = loss_behavior
|
||
self._master_engine = Master32Engine()
|
||
self._layer_engines: dict[str, Layer64Engine] = {}
|
||
self._master_blackout_active = False
|
||
self.stats = RouterStats()
|
||
# Preload-Slots je Layer (später vom Renderer bedient, §12.2)
|
||
self._pending_loads: dict[str, dict] = {} # layer_id → Auswahl
|
||
|
||
# ---------- DMX-Verarbeitung ----------
|
||
|
||
def handle_update(self, update: DmxUpdate) -> None:
|
||
"""Verarbeitet ein DMX-Update über den Patch.
|
||
|
||
- Master-Universe: 32 Kanäle ab Master-Basisadresse dekodieren
|
||
- Layer-Fixtures: 64 Kanäle je gepatchtem Layer im Universe
|
||
- Signalverlust (leere Daten): Policy je Verhalten (§16.1)
|
||
"""
|
||
self.stats.updates_processed += 1
|
||
if update.sequence == -1 and not update.data:
|
||
self._handle_signal_loss(update.universe)
|
||
return
|
||
self._process_master(update)
|
||
self._process_layers(update)
|
||
|
||
# ---------- Master (§16.3) ----------
|
||
|
||
def _process_master(self, update: DmxUpdate) -> None:
|
||
if update.universe != self._patch.master_universe:
|
||
return
|
||
start = self._patch.master_base_address - 1
|
||
segment = bytes(update.data[start : start + MASTER_CHANNELS])
|
||
if len(segment) < MASTER_CHANNELS:
|
||
return # Universe noch nicht vollständig; kein Fehler
|
||
control = self._master_engine.decode(segment)
|
||
self.stats.master_updates += 1
|
||
|
||
# Blackout: SAFETY-Priorität, Release beim Aufheben (§11.2, §11.3)
|
||
if control.blackout and not self._master_blackout_active:
|
||
self._engine.set_value("master/blackout", 1.0, ControlSource.SAFETY)
|
||
self._master_blackout_active = True
|
||
self.stats.blackouts += 1
|
||
elif not control.blackout and self._master_blackout_active:
|
||
self._engine.release("master/blackout", ControlSource.SAFETY)
|
||
self._master_blackout_active = False
|
||
|
||
src = ControlSource.CONSOLE # Lichtpult (§11.2 Priorität 3)
|
||
self._engine.set_value("master/intensity", control.master_intensity, src)
|
||
self._engine.set_value("master/global_speed", control.global_speed, src)
|
||
self._engine.set_value("master/bpm", control.bpm, src)
|
||
self._engine.set_value("master/global_hue", control.global_hue, src)
|
||
self._engine.set_value(
|
||
"master/global_saturation", control.global_saturation, src
|
||
)
|
||
self._engine.set_value(
|
||
"master/test_pattern", float(control.test_pattern), src
|
||
)
|
||
if control.events:
|
||
self.stats.load_commits += len(control.events)
|
||
|
||
# ---------- Layer (§16.4, §16.5) ----------
|
||
|
||
def _process_layers(self, update: DmxUpdate) -> None:
|
||
for entry in self._patch.layers():
|
||
if entry.universe != update.universe:
|
||
continue
|
||
layer_engine = self._layer_engines.setdefault(
|
||
entry.layer_id, Layer64Engine()
|
||
)
|
||
start = entry.base_address - 1
|
||
segment = bytes(update.data[start : start + 64])
|
||
if len(segment) < 64:
|
||
continue # Universe unvollständig: Layer still auslassen
|
||
control = layer_engine.decode(segment)
|
||
self.stats.layer_updates += 1
|
||
base = f"composition/{entry.composition_id}/layer/{entry.layer_id}"
|
||
src = ControlSource.CONSOLE
|
||
|
||
# Grundsteuerung (§16.4)
|
||
if control.enabled:
|
||
self._engine.set_value(f"{base}/enabled", 1.0, src)
|
||
else:
|
||
self._engine.set_value(f"{base}/enabled", 0.0, src)
|
||
self._engine.set_value(f"{base}/opacity", control.opacity, src)
|
||
self._engine.set_value(f"{base}/blend_mode", float(control.blend_mode), src)
|
||
self._engine.set_value(
|
||
f"{base}/transform/position_x", control.position_x, src
|
||
)
|
||
self._engine.set_value(
|
||
f"{base}/transform/position_y", control.position_y, src
|
||
)
|
||
self._engine.set_value(f"{base}/transform/scale_x", control.scale_x, src)
|
||
self._engine.set_value(f"{base}/transform/scale_y", control.scale_y, src)
|
||
self._engine.set_value(
|
||
f"{base}/transform/rotation_deg", control.rotation_deg, src
|
||
)
|
||
self._engine.set_value(f"{base}/color/hue", control.hue, src)
|
||
self._engine.set_value(
|
||
f"{base}/color/saturation", control.saturation, src
|
||
)
|
||
self._engine.set_value(
|
||
f"{base}/color/brightness", control.brightness, src
|
||
)
|
||
self._engine.set_value(f"{base}/color/contrast", control.contrast, src)
|
||
|
||
# Medien-Transport (§16.4 Kanäle 10-20, Media-Modus)
|
||
self._engine.set_value(
|
||
f"{base}/source/speed", control.speed, src
|
||
)
|
||
self._engine.set_value(
|
||
f"{base}/source/position", control.position, src
|
||
)
|
||
self._engine.set_value(
|
||
f"{base}/source/in_point", control.in_point, src
|
||
)
|
||
self._engine.set_value(
|
||
f"{base}/source/out_point", control.out_point, src
|
||
)
|
||
if control.transport is TransportCommand.RETRIGGER:
|
||
self._engine.set_value(f"{base}/source/retrigger", 1.0, src)
|
||
|
||
# FX-Steuerung (§16.4 Kanäle 41-62)
|
||
self._engine.set_value(
|
||
f"{base}/fx1/enabled", 1.0 if control.fx1_enabled else 0.0, src
|
||
)
|
||
self._engine.set_value(f"{base}/fx1/mix", control.fx1_mix, src)
|
||
self._engine.set_value(
|
||
f"{base}/fx2/enabled", 1.0 if control.fx2_enabled else 0.0, src
|
||
)
|
||
self._engine.set_value(f"{base}/fx2/mix", control.fx2_mix, src)
|
||
|
||
# Load/Commit-Events: atomare Auswahl (§16.5, §12.2)
|
||
for event in control.events:
|
||
if event.kind == "load_commit":
|
||
self.stats.load_commits += 1
|
||
self._pending_loads[entry.layer_id] = {
|
||
"bank": event.pending_bank,
|
||
"folder": event.pending_folder,
|
||
"index": event.pending_index,
|
||
"universe": update.universe,
|
||
}
|
||
elif event.kind == "retrigger":
|
||
self._engine.set_value(f"{base}/source/retrigger", 1.0, src)
|
||
|
||
# ---------- Ausstehende Loads (§12.2: Renderer bedient Preload) ----------
|
||
|
||
def pending_load_for(self, layer_id: str) -> dict | None:
|
||
"""Liefert die ausstehende Load-Auswahl eines Layers (für den
|
||
Renderer-Preload) und entfernt sie (Verbrauch durch Aufrufer)."""
|
||
return self._pending_loads.pop(layer_id, None)
|
||
|
||
# ---------- Signalverlust (§16.1, §11.3) ----------
|
||
|
||
def _handle_signal_loss(self, universe: int) -> None:
|
||
"""DMX-Ausfall auf einem Universe: Policy je Konfiguration.
|
||
|
||
- HOLD: nichts tun (letzter Zustand bleibt, §11.3)
|
||
- FADE_TO_BLACK: Master-Override auf 0 setzen (SAFETY)
|
||
"""
|
||
if self._loss_behavior is LossBehavior.HOLD:
|
||
return
|
||
if (
|
||
self._loss_behavior is LossBehavior.FADE_TO_BLACK
|
||
and universe == self._patch.master_universe
|
||
):
|
||
# Intensität über SAFETY auf 0; beim Wiederkommen releast die
|
||
# Master-Verarbeitung den Override nicht – hier explizit setzen
|
||
self._engine.set_value("master/intensity", 0.0, ControlSource.SAFETY)
|