423bd00b4e
- FixturePatch: Layer-Fixtures mit 64-Kanal-Adressen, Validierung (UUIDs, Universe-Grenzen, keine Überlappung), 8 Layer = 1 Universe, Master-Overlap-Prüfung, CSV-Export nach §16.2 (Node-ID, Short Name, IP, Universe, Startadresse, Layernummer, Fixture-Version) - PatchEntry: stabile UUID-basierte Layer-/Kompositions-IDs (§10.2) - DmxToParameterRouter: DMX → Patch → Master32/Layer64 → ParameterEngine - Master-Blackout mit SAFETY-Priorität (§11.2), Release beim Aufheben - Load/Commit-Events erzeugen pending_loads für Renderer-Preload (§16.5) - Signalverlust: HOLD (keine Änderung) oder FADE_TO_BLACK (SAFETY) - Layer-Flankenzustand je Instanz; Telemetrie (Updates, Commits, Blackouts) - 17 Integrationstests ohne Mocks: Adressvalidierung, Überlappungen, 8-Layer-Voll-Universe, CSV-Export-Felder, Blackout-SAFETY-Release, Load/Commit-Kette, Signalverlust-Policies - Gesamtsuite 549 grün, Ruff grün
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)
|