0922cc1d68
- Struktur gemäß §8 (Eigentumsgrenzen), PLAN.md als normative Basis - Pflichtdokumente: STATUS.md, ERRORS.md, TEST_REPORT.md, CHANGELOG.md, ADRs - ADR-0001 Python 3.13-Pin, ADR-0002 GStreamer 1.28.6-Pin (Windows), ADR-0003 IPC TCP+MessagePack v1 - Kernpakete: hms_protocol, hms_domain, hms_parameter, hms_artnet, hms_adaptive, hms_capabilities, hms_plugin_sdk - Renderer-Spike: D3D11-Primärpfad + Dev-GL-Pfad (§36 Nr. 4-5) - Control Core: FastAPI REST + WebSocket (§36 Nr. 9) - Beispielplugins: Passthrough + Gaussian Blur (3 Adaptive-Quality- Varianten, HLSL/GLSL/GLES) - Tools: Art-Net-Emulator, Fixture-Generator (Master32/Layer64-CSV), Capability-Probe - JSON-Schemas: IPC, Plugin, Projekt, Cluster - 121 Unit-/Integrationstests grün, Ruff grün Gate 0 bleibt offen: Hardwaremessungen nur auf echter Windows-Referenz- hardware gültig (§29.7, §33).
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)
|