Files
hms-mediaengine/packages/artnet/hms_artnet/wiring.py
T

229 lines
9.4 KiB
Python
Raw Normal View History

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