"""DMX→Parameter-Mapping (PLAN.md §16.4–16.6, §36 Nr. 8). Bildet DMX-Kanäle eines Universes auf stabile Parameterpfade ab: - 8-Bit: Byte / 255 - 16-Bit: (MSB << 8 | LSB) / 65535, MSB zuerst (DMX-Konvention) - Flankenerkennung für Trigger (steigende Flanke, §16.3/§16.5) - Signalverlust je konfigurierter Policy; HOLD ist V1-Standard (§11.3) Alle Werte fließen ausschließlich über die Parameter-Engine in den Control Core (§11); der Renderer wird nie direkt berührt. """ from __future__ import annotations from dataclasses import dataclass from hms_parameter.engine import ControlSource, ParameterEngine from hms_parameter.paths import layer_opacity_path from hms_artnet.receiver import DmxUpdate, LossBehavior class RisingEdge: """Erkennt steigende Flanken über einer Schwelle (§16.5). Ein Trigger ist ein Ereignis, kein Dauerzustand: derselbe gehaltene Faderwert löst genau einmal aus; erst nach Rückkehr unter die Schwelle kann erneut getriggert werden. """ def __init__(self, threshold: int = 64) -> None: if not 0 <= threshold <= 255: raise ValueError("threshold must be 0..255") self.threshold = threshold self._was_active = False def feed(self, value: int) -> bool: active = value >= self.threshold triggered = active and not self._was_active self._was_active = active return triggered @dataclass(frozen=True) class LayerDmxMapping: """Layer-Fixture-Belegung (Auszug Layer64, §16.4). base_address: 1-basierte DMX-Startadresse des Layer-Fixtures Kanäle relativ: 1 = Enable, 2–3 = Opacity (16 Bit, MSB zuerst) """ universe: int base_address: int composition_id: str layer_id: str loss_behavior: LossBehavior = LossBehavior.HOLD def __post_init__(self) -> None: if not 0 <= self.universe < 0x8000: raise ValueError("universe must be 0..0x7FFF") if not 1 <= self.base_address <= 512 - 2: raise ValueError("base_address must leave room for channels 1..3") @property def enable_path(self) -> str: return f"composition/{self.composition_id}/layer/{self.layer_id}/enabled" @property def opacity_path(self) -> str: return layer_opacity_path(self.composition_id, self.layer_id) class DmxLayerMapper: """Wandelt DmxUpdates eines Universes in Parameter-Engine-Werte. Phase-0-Umfang (§36 Nr. 8): DMX-Kanal auf Layer-Opacity mappen. Media-Auswahl mit Load/Commit-Semantik (§16.5) folgt in Phase 2; RisingEdge ist bereits getestet verfügbar. """ def __init__( self, mapping: LayerDmxMapping, engine: ParameterEngine, source: ControlSource = ControlSource.CONSOLE, ) -> None: self._mapping = mapping self._engine = engine self._source = source def _channel(self, data: bytes, relative: int) -> int: """Liest Kanal relativ zur Base-Adresse (1-basiert); 0 wenn zu kurz.""" idx = self._mapping.base_address - 1 + (relative - 1) if 0 <= idx < len(data): return data[idx] return 0 def handle(self, update: DmxUpdate) -> None: if update.universe != self._mapping.universe: return if update.sequence == -1 and not update.data: # Signalverlust (§11.3, §16.1): Policy anwenden, niemals still if self._mapping.loss_behavior is LossBehavior.FADE_TO_BLACK: self._engine.release(self._mapping.opacity_path, self._source) self._engine.release(self._mapping.enable_path, self._source) # HOLD: letzten Zustand behalten – keine Aktion return enable = 1.0 if self._channel(update.data, 1) >= 128 else 0.0 opacity = ((self._channel(update.data, 2) << 8) | self._channel(update.data, 3)) / 65535.0 self._engine.set_value(self._mapping.enable_path, enable, self._source) self._engine.set_value(self._mapping.opacity_path, opacity, self._source)