Files
hms-mediaengine/packages/artnet/hms_artnet/fixtures.py
T
HMS MediaEngine Agent abffff3711 Phase 4: Fixture-Engine Master32/Layer64 (§16.2–§16.6)
- Master32Engine: 32-Kanal-Dekodierung: 16-Bit Intensitaet/BPM/Dauer,
  Blackout (hoechste Prioritaet), Freeze, Preset Bank/Index,
  Flanken-Trigger Preset Recall/Tap Tempo/Release, Audio/AI-Freigabe,
  Testpattern, Global Hue/Sat, Fallback; reservierte Kanaele neutral
- Layer64Engine: 64-Kanal-Dekodierung: Enable, 16-Bit Opacity,
  Source-Type Media/Generator/Solid/Live, modusabhaengiger Source-Block
  (Kanal 13-20: Media=Speed/Position/In/Out, Generator=G1-G8),
  Transport-Enum, Blend, 16-Bit signed Position, Scale, Rotation,
  Crop, Color, FX1/FX2 mit P1-P8, Load/Commit-Semantik mit pending
  selection (§16.5: Fader bewegt nichts ohne Flanke), Retrigger
- map_speed (§16.6): Mittelpunkt=Pause, untere Haelfte -4x..0,
  obere 0..+4x, definierter Wert 40960 = exakt 1x; monoton,
  dokumentiert und getestet (Fehler im ersten Entwurf korrigiert:
  32768 war faelschlich 1x statt Pause)
- UniversePlan: kollisionsfreie Bereiche je Node, Ueberschneidung
  = Blocker-Fehler (§16.2), Preflight-Check
- 26 Unit-Tests: Speed-Referenzwerte/-Monotonie, Master32-Kanaele,
  Trigger-Flanken, reservierte Kanaele, Layer64-Modi, Load/Commit,
  Retrigger, FX-Bloecke, Universe-Kollisionen; 8x64=512-Verifikation
- Gesamtsuite 532 gruen, Ruff gruen
2026-09-11 02:06:58 +02:00

424 lines
16 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.
"""Art-Net-Fixture-Engine: Master32- und Layer64-Dekodierung
(PLAN.md §16.3, §16.4, §16.5, §16.6).
Dekodiert DMX-Kanäle in strukturale Steuerdaten und Emitting von
Trigger-Ereignissen (Flanken). Rein datenverarbeitend; alle Ergebnisse
fließen über die Parameter-Engine (§11), nie direkt in den Renderer.
Fixtures:
- HMS MediaEngine Master 32ch (§16.3)
- HMS MediaEngine Layer 64ch (§16.4): ein Layer-Fixture = exakt 64 Kanäle,
8 Layer = exakt 1 Universe (§16.2)
Verhalten:
- 16-Bit: MSB zuerst (DMX-Konvention)
- Trigger: steigende Flanke über Schwelle, kein Dauerzustand (§16.3/§16.5)
- Load/Commit (§16.5): Bank/Folder/Index-Änderungen erzeugen pending
selection; erst Flanke auf Load/Commit löst Preload+Umschaltung aus;
optional Auto-Load mit Debounce (V1: manuell)
- Pickup/Takeover (§16.5): Wertänderungen nach Plugin-/Quellwechsel werden
erst nach neuem Load/Commit interpretiert keine Parametersprünge
- Speed-Mapping (§16.6): signed 16-Bit; Mittelpunkt (32768) = Pause (0),
untere Hälfte 4x bis 0, obere Hälfte 0 bis +4x, definierter Wert 40960
entspricht exakt 1×
- reservierte Kanäle: neutral ignorieren (§16.3/§16.4), keine Fehler
"""
from __future__ import annotations
from dataclasses import dataclass, field
from enum import StrEnum
# ---------- Master32 (§16.3) ----------
@dataclass(frozen=True)
class MasterEvent:
"""Ereignis des Master-Fixtures (nur bei Flanken)."""
kind: str # preset_recall | tap_tempo | release_overrides
@dataclass
class MasterControl:
"""Dekodierte Master32-Steuerdaten (§16.3)."""
master_intensity: float = 1.0 # 16 Bit Kanal 1-2 → 0..1
blackout: bool = False # Kanal 3: >=128, höchste Priorität
freeze_output: bool = False # Kanal 4
preset_bank: int = 0 # Kanal 5
preset_index: int = 0 # 16 Bit Kanal 6-7
transition_type: int = 0 # Kanal 9: Enum
transition_duration: float = 0.0 # 16 Bit Kanal 10-11 → 0..1 (Max konfiguriert)
global_speed: float = 1.0 # 16 Bit Kanal 12-13 (Speed-Mapping §16.6)
bpm: float = 120.0 # 16 Bit Kanal 14-15 → 20..300
audio_reactive: bool = False # Kanal 22
audio_gain: float = 1.0 # Kanal 23 → 0..2
automation_ai_enable: bool = False # Kanal 24: nur Freigabe
test_pattern: int = 0 # Kanal 25: Enum, 0 = aus
preview_enable: bool = False # Kanal 26
global_hue: float = 0.0 # Kanal 27 → -0.5..0.5
global_saturation: float = 1.0 # Kanal 28 → 0..2
fallback_preset: int = 0 # Kanal 29
events: list[MasterEvent] = field(default_factory=list)
class Master32Engine:
"""Dekodiert ein 32-Kanal-DMX-Segment in MasterControl.
Kanäle 1721 und 3132 sind reserviert und werden neutral ignoriert
(§16.3). Trigger-Kanäle (8 Preset Recall, 16 Tap Tempo, 30 Release)
emittieren Flanken-Ereignisse, gehaltene Werte nicht.
"""
TRIGGER_THRESHOLD = 128
def __init__(self) -> None:
self._prev: bytearray | None = None # letzter Kanalstand für Flanken
def decode(self, channels: bytes) -> MasterControl:
"""Dekodiert exakt 32 Kanäle; reservierte neutral ignorierend."""
if len(channels) < 32:
raise ValueError(f"Master32 benötigt 32 Kanäle, erhalten {len(channels)}")
prev = self._prev
curr = bytearray(channels[:32])
events: list[MasterEvent] = []
threshold = self.TRIGGER_THRESHOLD
def _rising(channel_index: int) -> bool:
"""Steigende Flanke: jetzt über Schwelle, vorher darunter."""
now_active = channels[channel_index] >= threshold
before_active = bool(prev and prev[channel_index] >= threshold)
return now_active and not before_active
if _rising(7): # Kanal 8: Preset Recall (§16.3: direkter Abruf)
events.append(MasterEvent(kind="preset_recall"))
if _rising(15): # Kanal 16: Tap Tempo
events.append(MasterEvent(kind="tap_tempo"))
if _rising(29): # Kanal 30: Release Manual Overrides (Schutzlogik §16.3)
events.append(MasterEvent(kind="release_overrides"))
self._prev = curr
speed_16 = decode_u16(channels, 11) # Kanal 12-13 (0-basiert 11-12)
bpm_16 = decode_u16(channels, 13) # Kanal 14-15
return MasterControl(
master_intensity=decode_u16(channels, 0) / 65535.0,
blackout=channels[2] >= self.TRIGGER_THRESHOLD,
freeze_output=channels[3] >= self.TRIGGER_THRESHOLD,
preset_bank=channels[4],
preset_index=decode_u16(channels, 5),
transition_type=channels[8],
transition_duration=decode_u16(channels, 9) / 65535.0,
global_speed=map_speed(speed_16),
bpm=20.0 + (bpm_16 / 65535.0) * 280.0, # 20..300 BPM
audio_reactive=channels[21] >= self.TRIGGER_THRESHOLD,
audio_gain=channels[22] / 255.0 * 2.0,
automation_ai_enable=channels[23] >= self.TRIGGER_THRESHOLD,
test_pattern=channels[24],
preview_enable=channels[25] >= self.TRIGGER_THRESHOLD,
global_hue=(channels[26] / 255.0) - 0.5,
global_saturation=channels[27] / 255.0 * 2.0,
fallback_preset=channels[28],
events=events,
)
# ---------- Layer64 (§16.4) ----------
class SourceType(StrEnum):
"""Kanal 4: Source Type (§16.4)."""
MEDIA = "media"
GENERATOR = "generator"
SOLID = "solid"
LIVE = "live" # später: Capture (§16.4)
class TransportCommand(StrEnum):
"""Kanal 10: Transport (§16.4)."""
STOP = "stop"
PLAY = "play"
PAUSE = "pause"
RETRIGGER = "retrigger"
@dataclass(frozen=True)
class LayerEvent:
"""Ereignisse des Layer-Fixtures (nur Flanken, §16.5)."""
kind: str # load_commit | retrigger
pending_bank: int = 0
pending_folder: int = 0
pending_index: int = 0
@dataclass
class LayerControl:
"""Dekodierte Layer64-Steuerdaten (§16.4).
source_block (Kanal 13-20) ist modusabhängig: bei Media =
Speed/Position/In/Out; bei Generator/Solid = G1..G8 (§16.4).
"""
enabled: bool = True # Kanal 1
opacity: float = 1.0 # 16 Bit Kanal 2-3
source_type: SourceType = SourceType.MEDIA # Kanal 4
media_bank: int = 0 # Kanal 5
media_folder: int = 0 # Kanal 6
media_index: int = 0 # 16 Bit Kanal 7-8 (pending bis Load)
transport: TransportCommand = TransportCommand.STOP # Kanal 10
loop_mode: int = 0 # Kanal 11: Enum
playback_direction: int = 0 # Kanal 12: Enum
speed: float = 1.0 # Kanal 13-14: signed Speed-Mapping (§16.6), Media-Modus
position: float = 0.0 # Kanal 15-16 → 0..1, Media-Modus
in_point: float = 0.0 # Kanal 17-18 → 0..1, Media-Modus
out_point: float = 1.0 # Kanal 19-20 → 0..1, Media-Modus
generator_params: tuple[float, ...] = () # G1..G8 (0..1), Generator/Solid
blend_mode: int = 0 # Kanal 21: Enum
position_x: float = 0.0 # 16 Bit signed Kanal 23-24 → -1..1
position_y: float = 0.0 # Kanal 25-26
scale_x: float = 1.0 # 16 Bit Kanal 27-28 → 0..4
scale_y: float = 1.0 # Kanal 29-30
rotation_deg: float = 0.0 # 16 Bit Kanal 31-32 → 0..360
crop_left: float = 0.0 # Kanal 33-36 → 0..1
crop_right: float = 0.0
crop_top: float = 0.0
crop_bottom: float = 0.0
hue: float = 0.0 # Kanal 37-40 → Reglerbereiche
saturation: float = 1.0
brightness: float = 1.0
contrast: float = 1.0
fx1_enabled: bool = False # Kanal 41
fx1_plugin: int = 0 # Kanal 42: Show-Registry
fx1_mix: float = 1.0 # Kanal 43 → 0..1
fx1_params: tuple[float, ...] = () # Kanal 44-51: P1-P8
fx2_enabled: bool = False # Kanal 52
fx2_plugin: int = 0 # Kanal 53
fx2_mix: float = 1.0 # Kanal 54
fx2_params: tuple[float, ...] = () # Kanal 55-62: P1-P8
events: list[LayerEvent] = field(default_factory=list)
def decode_u16(channels: bytes, msb_index: int) -> int:
"""16-Bit: MSB zuerst (DMX-Konvention)."""
return (channels[msb_index] << 8) | channels[msb_index + 1]
def decode_s16_normalized(channels: bytes, msb_index: int) -> float:
"""16-Bit signed → -1..1 (Position, §16.4)."""
raw = decode_u16(channels, msb_index)
return (raw / 32768.0) - 1.0
def map_speed(raw_u16: int) -> float:
"""Speed-Mapping nach §16.6 (signed 16-Bit):
- untere Hälfte (0..32767): 4× (bei 0) bis 0 (Richtung Mittelpunkt)
- Mittelpunkt (32768): Pause (0.0)
- obere Hälfte (32769..65535): 0+ bis +4× (Max konfiguriert)
- definierter Referenzwert 40960 entspricht exakt +1×
(denn (4096032768)/32768 × 4 = 1.0; dokumentiert in fixtures und
Fixture-Handbuch)
- Totzone um Pause optional (hier nicht implementiert, §16.6)
"""
half = 32768
if raw_u16 == half:
return 0.0 # Mittelpunkt = Pause (§16.6)
if raw_u16 < half:
# untere Hälfte: 0 → 4x, Richtung 32768 → 0 (monoton steigend)
return -(1.0 - raw_u16 / half) * 4.0
# obere Hälfte: 32769 → 0+, Richtung 65535 → +4x (monoton steigend)
return ((raw_u16 - half) / half) * 4.0
class Layer64Engine:
"""Dekodiert ein 64-Kanal-DMX-Segment in LayerControl (§16.4/§16.5).
Load/Commit (§16.5):
- Änderungen an Bank/Folder/Index/SourceType erzeugen nur eine pending
selection (kein direktes Laden)
- erst eine steigende Flanke auf Kanal 9 (Load/Commit) emittiert ein
load_commit-Ereignis mit der vollständigen Auswahl
- Pickup/Takeover: nach Load/Commit gilt die Auswahl als bestätigt;
Parameteränderungen wirken sofort (keine Sprünge, da Werte erst
nach Commit neu interpretiert werden)
"""
TRIGGER_THRESHOLD = 128
def __init__(self) -> None:
self._prev: bytearray | None = None
self._pending_loaded = True # beim Start: keine pending selection
def decode(self, channels: bytes) -> LayerControl:
if len(channels) < 64:
raise ValueError(f"Layer64 benötigt 64 Kanäle, erhalten {len(channels)}")
prev = self._prev
curr = bytearray(channels[:64])
events: list[LayerEvent] = []
def _rising(idx: int) -> bool:
now = channels[idx] >= self.TRIGGER_THRESHOLD
before = bool(prev and prev[idx] >= self.TRIGGER_THRESHOLD)
return now and not before
source_type = _source_type_of(channels[3])
# pending selection (§16.5): Auswahl ändert sich, laden erst bei Flanke
pending_changed = (
prev is None
or curr[4] != prev[4]
or curr[5] != prev[5]
or decode_u16(curr, 6) != decode_u16(prev, 6)
or curr[3] != prev[3]
)
if pending_changed:
self._pending_loaded = False
if _rising(8): # Kanal 9: Load/Commit Selection
events.append(
LayerEvent(
kind="load_commit",
pending_bank=channels[4],
pending_folder=channels[5],
pending_index=decode_u16(channels, 6),
)
)
self._pending_loaded = True
if _rising(62): # Kanal 63: Layer Retrigger/Reset
events.append(LayerEvent(kind="retrigger"))
self._prev = curr
# modusabhängiger Source-Block (Kanäle 13-20, §16.4)
speed = 1.0
position = 0.0
in_point = 0.0
out_point = 1.0
generator_params: tuple[float, ...] = ()
if source_type is SourceType.MEDIA:
speed = map_speed(decode_u16(channels, 12))
position = decode_u16(channels, 14) / 65535.0
in_point = decode_u16(channels, 16) / 65535.0
out_point = decode_u16(channels, 18) / 65535.0
else:
# Generator/Solid/Live: G1..G8 über Kanäle 13-20 (§16.4)
generator_params = tuple(c / 255.0 for c in channels[12:20])
return LayerControl(
enabled=channels[0] >= self.TRIGGER_THRESHOLD,
opacity=decode_u16(channels, 1) / 65535.0,
source_type=source_type,
media_bank=channels[4],
media_folder=channels[5],
media_index=decode_u16(channels, 6),
transport=_transport_of(channels[9]),
loop_mode=channels[10],
playback_direction=channels[11],
speed=speed,
position=position,
in_point=in_point,
out_point=out_point,
generator_params=generator_params,
blend_mode=channels[20],
position_x=decode_s16_normalized(channels, 22),
position_y=decode_s16_normalized(channels, 24),
scale_x=decode_u16(channels, 26) / 65535.0 * 4.0,
scale_y=decode_u16(channels, 28) / 65535.0 * 4.0,
rotation_deg=decode_u16(channels, 30) / 65535.0 * 360.0,
crop_left=channels[32] / 255.0,
crop_right=channels[33] / 255.0,
crop_top=channels[34] / 255.0,
crop_bottom=channels[35] / 255.0,
hue=(channels[36] / 255.0) - 0.5,
saturation=channels[37] / 255.0 * 2.0,
brightness=channels[38] / 255.0 * 2.0,
contrast=channels[39] / 255.0 * 2.0,
fx1_enabled=channels[40] >= self.TRIGGER_THRESHOLD,
fx1_plugin=channels[41],
fx1_mix=channels[42] / 255.0,
fx1_params=tuple(c / 255.0 for c in channels[43:51]),
fx2_enabled=channels[51] >= self.TRIGGER_THRESHOLD,
fx2_plugin=channels[52],
fx2_mix=channels[53] / 255.0,
fx2_params=tuple(c / 255.0 for c in channels[54:62]),
events=events,
)
@property
def selection_pending(self) -> bool:
"""True, wenn eine Auswahl geändert, aber noch nicht committed wurde."""
return not self._pending_loaded
def _source_type_of(value: int) -> SourceType:
"""Kanal 4: 0=Media, 1=Generator, 2=Solid, 3=Live (§16.4)."""
mapping = {
0: SourceType.MEDIA,
1: SourceType.GENERATOR,
2: SourceType.SOLID,
3: SourceType.LIVE,
}
return mapping.get(value, SourceType.MEDIA)
def _transport_of(value: int) -> TransportCommand:
"""Kanal 10: 0=Stop, 1=Play, 2=Pause, 3=Retrigger (§16.4)."""
mapping = {
0: TransportCommand.STOP,
1: TransportCommand.PLAY,
2: TransportCommand.PAUSE,
3: TransportCommand.RETRIGGER,
}
return mapping.get(value, TransportCommand.STOP)
# ---------- Universe-Plan (§16.2 Mehrserver-Patch) ----------
class UniverseCollisionError(Exception):
"""Überlappende Universe-Bereiche zweier Nodes (§16.2: Blocker)."""
@dataclass(frozen=True)
class UniverseRange:
"""Zusammenhängender Universe-Bereich eines Nodes (§16.2)."""
node_id: str
first: int # inklusiv
last: int # inklusiv
class UniversePlan:
"""Zentraler Universe-Plan: kollisionsfreie Bereiche je Node (§16.2).
- add: registriert einen Bereich; Überschneidung = Fehler (Blocker vor
Show-Lock), kein stillsches Zusammenführen
- overlaps: Preflight-Prüfung vor dem Show-Lock
"""
def __init__(self) -> None:
self._ranges: list[UniverseRange] = []
def add(self, rng: UniverseRange) -> None:
if rng.first > rng.last:
raise ValueError("first muss <= last sein")
for other in self._ranges:
if self._overlaps(rng, other):
raise UniverseCollisionError(
f"Universe-Kollision: {rng.node_id} [{rng.first}..{rng.last}] "
f"vs {other.node_id} [{other.first}..{other.last}] (§16.2)"
)
self._ranges.append(rng)
@staticmethod
def _overlaps(a: UniverseRange, b: UniverseRange) -> bool:
return not (a.last < b.first or b.last < a.first)
def overlaps(self, rng: UniverseRange) -> bool:
"""True, wenn der Bereich einen bestehenden schneidet (Preflight)."""
return any(self._overlaps(rng, other) for other in self._ranges)
def ranges(self) -> tuple[UniverseRange, ...]:
return tuple(self._ranges)