112 lines
4.0 KiB
Python
112 lines
4.0 KiB
Python
|
|
"""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)
|