Phase 0: Repository-Initialisierung nach Bauplan v1.2

- 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).
This commit is contained in:
HMS MediaEngine Agent
2026-09-11 00:36:59 +02:00
commit 0922cc1d68
133 changed files with 7939 additions and 0 deletions
+111
View File
@@ -0,0 +1,111 @@
"""DMX→Parameter-Mapping (PLAN.md §16.416.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, 23 = 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)