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)
|