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
This commit is contained in:
HMS MediaEngine Agent
2026-09-11 02:18:00 +02:00
parent 4e15fe9d47
commit 423bd00b4e
4 changed files with 850 additions and 1 deletions
+13 -1
View File
@@ -1,7 +1,8 @@
"""hms_artnet Art-Net 4 Steuerung (PLAN.md §16).
ArtDMX-Empfang, ArtPoll/ArtPollReply (Discovery als Media Server, Style 0x02),
Fixture-Engine (Master32/Layer64, §16.3–§16.6), Universe-Plan (§16.2),
Fixture-Engine (Master32/Layer64, §16.3–§16.6), Patch-Verwaltung (§16.2),
DMX-zu-Parameter-Verkabelung (§11, §16), Universe-Plan (§16.2),
konfigurierbare Universen/Adressen, Sequenzprüfung, Signalverlust-Verhalten.
Layouts gegen die offizielle Art-Net-4-Spezifikation verifiziert.
"""
@@ -33,7 +34,13 @@ from hms_artnet.packets import (
parse_poll,
parse_poll_reply,
)
from hms_artnet.patch import (
FixturePatch,
PatchEntry,
PatchError,
)
from hms_artnet.receiver import ArtNetReceiver, DmxUpdate, LossBehavior
from hms_artnet.wiring import DmxToParameterRouter, RouterStats
__all__ = [
"OPDMX",
@@ -64,4 +71,9 @@ __all__ = [
"UniversePlan",
"UniverseRange",
"UniverseCollisionError",
"FixturePatch",
"PatchEntry",
"PatchError",
"DmxToParameterRouter",
"RouterStats",
]
+196
View File
@@ -0,0 +1,196 @@
"""Patch-Verwaltung: Layer→DMX-Adressen und Export (PLAN.md §16.2).
Ein Patch ordnet Show-Layern DMX-Adressen zu:
- Master-Fixture: eine 32-Kanal-Instanz (§16.3)
- Layer-Fixtures: bis zu 8 Instanzen à 64 Kanäle je Universe (§16.2)
Validierung:
- Adressen lassen ihr Fixture vollständig ins Universe passen
- keine Überlappung im selben Universe (Fehler, kein stilles Verschieben)
- Layer-IDs eindeutig; Komposition/Layer müssen UUIDs sein (§10.2)
Export (§16.2): enthält Node-ID, Art-Net Short Name, IP, Net/SubNet/Universe,
Startadresse, Layernummer und Fixture-Version.
"""
from __future__ import annotations
import csv
import io
import uuid
from dataclasses import dataclass
MASTER_CHANNELS = 32 # §16.3
LAYER_CHANNELS = 64 # §16.4
DMX_CHANNELS = 512 # DMX512
class PatchError(Exception):
"""Ungültiger Patch (Überlappung, außerhalb des Universes, …)."""
@dataclass(frozen=True)
class PatchEntry:
"""Ein Layer-Fixture im Patch (§16.2).
- layer_id/composition_id: stabile UUIDs (§10.2)
- universe: 15-Bit Port-Address (Net<<8 | SubUni)
- base_address: 1-basierte DMX-Startadresse (1..449 für 64 Kanäle)
- layer_number: menschenlesbare Layernummer für den Export (1-basiert)
"""
layer_id: str
composition_id: str
universe: int
base_address: int
layer_number: int
def __post_init__(self) -> None:
for name, value in (("layer_id", self.layer_id), ("composition_id", self.composition_id)):
try:
uuid.UUID(value)
except (ValueError, AttributeError) as exc:
raise ValueError(f"{name} muss eine UUID sein (§10.2)") from exc
if not 0 <= self.universe < 0x8000:
raise ValueError("universe muss 0..0x7FFF sein")
if not 1 <= self.base_address <= DMX_CHANNELS - LAYER_CHANNELS + 1:
raise ValueError(
f"base_address {self.base_address} passt nicht für 64 Kanäle "
f"(erlaubt 1..{DMX_CHANNELS - LAYER_CHANNELS + 1})"
)
if self.layer_number < 1:
raise ValueError("layer_number muss 1-basiert sein")
@property
def end_address(self) -> int:
"""Letzte belegte DMX-Adresse (inklusiv)."""
return self.base_address + LAYER_CHANNELS - 1
class FixturePatch:
"""Vollständiger DMX-Patch eines Nodes (§16.2).
- master: Adresse des Master32-Fixtures (ein Universe, 1-basiert)
- layers: Liste von PatchEntry (Layer-Fixtures)
validate(): prüft Überlappungen; add/remove verwalten Einträge.
"""
def __init__(
self,
node_id: str,
short_name: str,
master_universe: int,
master_base_address: int = 1,
) -> None:
self.node_id = node_id
self.short_name = short_name
self.master_universe = master_universe
self.master_base_address = master_base_address
self._layers: dict[str, PatchEntry] = {} # layer_id → entry
# ---------- Verwaltung ----------
def add_layer(self, entry: PatchEntry) -> None:
"""Fügt ein Layer-Fixture hinzu; Überlappung im selben Universe = Fehler."""
if entry.layer_id in self._layers:
raise PatchError(f"Layer {entry.layer_id} bereits im Patch")
for existing in self._layers.values():
if existing.universe == entry.universe and self._overlaps(existing, entry):
raise PatchError(
f"Adressüberlappung Universe {entry.universe}: "
f"L{existing.layer_number} [{existing.base_address}.."
f"{existing.end_address}] vs "
f"L{entry.layer_number} [{entry.base_address}..{entry.end_address}]"
)
self._layers[entry.layer_id] = entry
def remove_layer(self, layer_id: str) -> None:
self._layers.pop(layer_id, None)
def layers(self) -> list[PatchEntry]:
return list(self._layers.values())
def layer_by_id(self, layer_id: str) -> PatchEntry | None:
return self._layers.get(layer_id)
def validate(self) -> list[str]:
"""Liefert Fehlerliste; leer = gültig. Master darf kein Layer überlappen."""
errors: list[str] = []
if not 1 <= self.master_base_address <= DMX_CHANNELS - MASTER_CHANNELS + 1:
errors.append(
f"Master-Adresse {self.master_base_address} passt nicht für "
f"32 Kanäle (erlaubt 1..{DMX_CHANNELS - MASTER_CHANNELS + 1})"
)
master_range = (
self.master_base_address,
self.master_base_address + MASTER_CHANNELS - 1,
)
for entry in self._layers.values():
if entry.universe == self.master_universe and self._range_overlap(
(entry.base_address, entry.end_address), master_range
):
errors.append(
f"Layer {entry.layer_number} überlappt Master-Fixture "
f"im Universe {self.master_universe}"
)
return errors
# ---------- Export (§16.2) ----------
def export_csv(self, ip_or_host: str = "", fixture_version: str = "1.0.0") -> str:
"""Menschenlesbare Kanalliste nach §16.2: enthält Node-ID, Short
Name, IP, Net/SubNet/Universe, Startadresse, Layernummer, Version."""
buf = io.StringIO()
writer = csv.writer(buf)
writer.writerow(
[
"node_id",
"artnet_short_name",
"ip_or_host",
"fixture",
"universe",
"start_address",
"channels",
"layer_number",
"fixture_version",
]
)
writer.writerow(
[
self.node_id,
self.short_name,
ip_or_host,
"HMS MediaEngine Master 32ch",
self.master_universe,
self.master_base_address,
MASTER_CHANNELS,
"",
fixture_version,
]
)
for entry in sorted(self._layers.values(), key=lambda e: (e.universe, e.base_address)):
writer.writerow(
[
self.node_id,
self.short_name,
ip_or_host,
"HMS MediaEngine Layer 64ch",
entry.universe,
entry.base_address,
LAYER_CHANNELS,
entry.layer_number,
fixture_version,
]
)
return buf.getvalue()
# ---------- Interna ----------
@staticmethod
def _overlaps(a: PatchEntry, b: PatchEntry) -> bool:
return not (a.end_address < b.base_address or b.end_address < a.base_address)
@staticmethod
def _range_overlap(a: tuple[int, int], b: tuple[int, int]) -> bool:
return not (a[1] < b[0] or b[1] < a[0])
+228
View File
@@ -0,0 +1,228 @@
"""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)