Files
hms-mediaengine/packages/artnet/hms_artnet/wiring.py
T
HMS MediaEngine Agent 423bd00b4e Phase 4 abgeschlossen: Patch-Verwaltung und DMX-Verkabelung (§16.2, §11)
- FixturePatch: Layer-Fixtures mit 64-Kanal-Adressen, Validierung
  (UUIDs, Universe-Grenzen, keine Überlappung), 8 Layer = 1 Universe,
  Master-Overlap-Prüfung, CSV-Export nach §16.2 (Node-ID, Short Name,
  IP, Universe, Startadresse, Layernummer, Fixture-Version)
- PatchEntry: stabile UUID-basierte Layer-/Kompositions-IDs (§10.2)
- DmxToParameterRouter: DMX → Patch → Master32/Layer64 → ParameterEngine
  - Master-Blackout mit SAFETY-Priorität (§11.2), Release beim Aufheben
  - Load/Commit-Events erzeugen pending_loads für Renderer-Preload (§16.5)
  - Signalverlust: HOLD (keine Änderung) oder FADE_TO_BLACK (SAFETY)
  - Layer-Flankenzustand je Instanz; Telemetrie (Updates, Commits, Blackouts)
- 17 Integrationstests ohne Mocks: Adressvalidierung, Überlappungen,
  8-Layer-Voll-Universe, CSV-Export-Felder, Blackout-SAFETY-Release,
  Load/Commit-Kette, Signalverlust-Policies
- Gesamtsuite 549 grün, Ruff grün
2026-09-11 02:18:00 +02:00

229 lines
9.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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)