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