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
This commit is contained in:
HMS MediaEngine Agent
2026-09-11 02:06:58 +02:00
parent 985f156525
commit abffff3711
3 changed files with 776 additions and 0 deletions
+31
View File
@@ -1,10 +1,26 @@
"""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),
konfigurierbare Universen/Adressen, Sequenzprüfung, Signalverlust-Verhalten.
Layouts gegen die offizielle Art-Net-4-Spezifikation verifiziert.
"""
from hms_artnet.fixtures import (
Layer64Engine,
LayerControl,
LayerEvent,
Master32Engine,
MasterControl,
MasterEvent,
SourceType,
TransportCommand,
UniverseCollisionError,
UniversePlan,
UniverseRange,
map_speed,
)
from hms_artnet.mapping import DmxLayerMapper, LayerDmxMapping, RisingEdge
from hms_artnet.packets import (
OPDMX,
OPPOLL,
@@ -33,4 +49,19 @@ __all__ = [
"ArtNetReceiver",
"DmxUpdate",
"LossBehavior",
"DmxLayerMapper",
"LayerDmxMapping",
"RisingEdge",
"Master32Engine",
"MasterControl",
"MasterEvent",
"Layer64Engine",
"LayerControl",
"LayerEvent",
"SourceType",
"TransportCommand",
"map_speed",
"UniversePlan",
"UniverseRange",
"UniverseCollisionError",
]
+423
View File
@@ -0,0 +1,423 @@
"""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)
+322
View File
@@ -0,0 +1,322 @@
"""Unit-Tests Fixture-Engine: Master32/Layer64, Speed, Universe-Plan
(PLAN.md §16.2–§16.6, §29.1)."""
from __future__ import annotations
import pytest
from hms_artnet.fixtures import (
Layer64Engine,
Master32Engine,
SourceType,
TransportCommand,
UniverseCollisionError,
UniversePlan,
UniverseRange,
map_speed,
)
# ---------- 16-Bit-Dekodierung (MSB zuerst) ----------
def test_speed_mapping_reference_values() -> None:
"""§16.6: Mittelpunkt = Pause (0); untere Hälfte negativ, obere positiv;
definierter Wert 40960 entspricht exakt +1×."""
assert map_speed(32768) == 0.0 # Mittelpunkt = Pause (§16.6)
assert map_speed(0) == pytest.approx(-4.0) # unterer Rand: 4x
assert map_speed(65535) == pytest.approx(4.0, rel=1e-3) # oberer Rand: +4x
assert map_speed(40960) == pytest.approx(1.0) # exakt 1x (definierter Wert)
assert map_speed(16384) == pytest.approx(-2.0) # untere Hälfte: 2x
assert map_speed(49152) == pytest.approx(2.0) # obere Hälfte: +2x
def test_speed_mapping_monotonic_and_centered() -> None:
"""Monotonie je Hälfte; keine Sprünge am Übergang."""
# untere Hälfte steigt monoton von 4x Richtung 0
assert map_speed(0) < map_speed(8192) < map_speed(16384) < map_speed(24576) < 0.0
# obere Hälfte steigt monoton von 0+ Richtung +4x
assert 0.0 <= map_speed(32769) < map_speed(40960) < map_speed(49152) < map_speed(65535)
# Mitte = Pause, kein Sprung zwischen den Hälften
assert map_speed(32767) < 0.0 < map_speed(32769)
# ---------- Master32 (§16.3) ----------
def _master_channels(**overrides) -> bytearray:
"""32 Kanäle, Default neutral; overrides als (kanal_nr_1basiert, wert)."""
ch = bytearray(32)
# neutrale Defaults: Intensität voll (32768), Rest 0
ch[0] = 0x80
ch[1] = 0x00 # 32768/65535 ≈ 0.5 neutral genug für Tests
for k, v in overrides.items():
ch[int(k) - 1] = v
return ch
def test_master32_decodes_16bit_intensity() -> None:
engine = Master32Engine()
control = engine.decode(_master_channels(**{"1": 0xFF, "2": 0xFF}))
assert control.master_intensity == pytest.approx(1.0)
def test_master32_blackout_switch() -> None:
engine = Master32Engine()
control = engine.decode(_master_channels(**{"3": 255}))
assert control.blackout is True # höchste Priorität (§16.3)
assert engine.decode(_master_channels()).blackout is False
def test_master32_preset_recall_rising_edge_only() -> None:
"""§16.3: Preset Recall ist Flanken-Trigger, kein Dauerzustand."""
engine = Master32Engine()
# erster Frame: Schwelle überschritten → Event
control = engine.decode(_master_channels(**{"8": 255}))
assert [e.kind for e in control.events] == ["preset_recall"]
# gehaltener Wert: kein neues Event
control = engine.decode(_master_channels(**{"8": 255}))
assert control.events == []
# zurück unter Schwelle, dann erneut: neues Event
engine.decode(_master_channels(**{"8": 0}))
control = engine.decode(_master_channels(**{"8": 200}))
assert [e.kind for e in control.events] == ["preset_recall"]
def test_master32_tap_tempo_and_release_edges() -> None:
engine = Master32Engine()
control = engine.decode(_master_channels(**{"16": 255, "30": 255}))
kinds = [e.kind for e in control.events]
assert "tap_tempo" in kinds
assert "release_overrides" in kinds
def test_master32_bpm_range_20_to_300() -> None:
engine = Master32Engine()
control = engine.decode(_master_channels(**{"14": 0, "15": 0}))
assert control.bpm == pytest.approx(20.0)
control = engine.decode(_master_channels(**{"14": 0xFF, "15": 0xFF}))
assert control.bpm == pytest.approx(300.0)
def test_master32_reserved_channels_ignored_neutrally() -> None:
"""§16.3: Kanäle 17-21, 31-32 müssen neutral ignoriert werden."""
engine = Master32Engine()
control = engine.decode(
_master_channels(**{"17": 255, "18": 255, "19": 255, "31": 255, "32": 255})
)
assert control.events == [] # keine Fehler, keine Events
def test_master32_requires_32_channels() -> None:
with pytest.raises(ValueError, match="32"):
Master32Engine().decode(bytearray(31))
def test_master32_global_hue_range() -> None:
engine = Master32Engine()
control = engine.decode(_master_channels(**{"27": 255}))
assert control.global_hue == pytest.approx(0.5)
control = engine.decode(_master_channels(**{"27": 0}))
assert control.global_hue == pytest.approx(-0.5)
# ---------- Layer64 (§16.4) ----------
def _layer_channels(**overrides) -> bytearray:
"""64 Kanäle; overrides als (kanal_nr_1basiert, wert)."""
ch = bytearray(64)
ch[0] = 255 # Layer Enable
ch[1] = 0xFF
ch[2] = 0xFF # Opacity voll
for k, v in overrides.items():
ch[int(k) - 1] = v
return ch
def test_layer64_opacity_16bit() -> None:
engine = Layer64Engine()
control = engine.decode(_layer_channels(**{"2": 0x80, "3": 0x00}))
assert control.opacity == pytest.approx(0x8000 / 65535)
def test_layer64_source_type_enum() -> None:
engine = Layer64Engine()
assert engine.decode(_layer_channels(**{"4": 0})).source_type is SourceType.MEDIA
control = engine.decode(_layer_channels(**{"4": 1}))
assert control.source_type is SourceType.GENERATOR
assert engine.decode(_layer_channels(**{"4": 3})).source_type is SourceType.LIVE
# unbekannter Wert fällt auf MEDIA zurück (§16.4: neutral)
assert engine.decode(_layer_channels(**{"4": 99})).source_type is SourceType.MEDIA
def test_layer64_transport_enum() -> None:
engine = Layer64Engine()
control = engine.decode(_layer_channels(**{"10": 3}))
assert control.transport is TransportCommand.RETRIGGER
assert engine.decode(_layer_channels(**{"10": 1})).transport is TransportCommand.PLAY
def test_layer64_media_mode_speed_position_inout() -> None:
"""Media-Modus: Kanäle 13-20 = Speed/Position/In/Out (§16.4)."""
engine = Layer64Engine()
control = engine.decode(
_layer_channels(
**{
"4": 0, # Media
"13": 0xA0,
"14": 0x00, # Speed = 40960 = exakt 1x (§16.6)
"15": 0x40,
"16": 0x00, # Position ~0.25
"17": 0x10,
"18": 0x00, # In ~0.06
"19": 0xF0,
"20": 0x00, # Out ~0.94
}
)
)
assert control.speed == pytest.approx(1.0) # 40960: definierter 1x-Wert
assert control.position == pytest.approx(0x4000 / 65535)
assert control.generator_params == () # Media-Modus: keine G-Slots
def test_layer64_generator_mode_g_slots() -> None:
"""Generator-Modus: Kanäle 13-20 = G1..G8 (§16.4 modusabhängiger Block)."""
engine = Layer64Engine()
control = engine.decode(
_layer_channels(**{"4": 1, **{str(k): (25 * (k - 12)) & 0xFF for k in range(13, 21)}})
)
assert control.source_type is SourceType.GENERATOR
assert len(control.generator_params) == 8
assert control.generator_params[0] == pytest.approx(25 / 255)
assert control.generator_params[7] == pytest.approx(200 / 255)
assert control.speed == 1.0 # Media-Werte im Generator-Modus neutral
def test_layer64_transform_channels() -> None:
engine = Layer64Engine()
control = engine.decode(
_layer_channels(
**{
"23": 0xFF,
"24": 0xFF, # Position X +1
"25": 0x00,
"26": 0x00, # Position Y -1
"27": 0x40,
"28": 0x00, # Scale X ~1x (16384/65535*4)
"31": 0x80,
"32": 0x00, # Rotation 180°
}
)
)
assert control.position_x == pytest.approx(1.0, abs=1e-4)
assert control.position_y == pytest.approx(-1.0)
assert control.scale_x == pytest.approx(16384 / 65535 * 4)
assert control.rotation_deg == pytest.approx(0x8000 / 65535 * 360)
def test_layer64_fx_blocks() -> None:
engine = Layer64Engine()
control = engine.decode(
_layer_channels(
**{
"41": 255, # FX1 Enable
"42": 7, # FX1 Plugin Select
"43": 128, # FX1 Mix ~0.5
"44": 255, # P1 voll
"52": 255, # FX2 Enable
"55": 64, # FX2 P1
}
)
)
assert control.fx1_enabled is True
assert control.fx1_plugin == 7
assert control.fx1_mix == pytest.approx(128 / 255)
assert control.fx1_params[0] == pytest.approx(1.0)
assert control.fx2_enabled is True
assert control.fx2_params[0] == pytest.approx(64 / 255)
assert len(control.fx1_params) == 8 and len(control.fx2_params) == 8
def test_layer64_reserved_channel_64_neutral() -> None:
"""§16.4: Kanal 64 reserviert, muss neutral ignoriert werden."""
engine = Layer64Engine()
control = engine.decode(_layer_channels(**{"64": 255}))
assert control.events == []
def test_layer64_requires_64_channels() -> None:
with pytest.raises(ValueError, match="64"):
Layer64Engine().decode(bytearray(63))
# ---------- Load/Commit-Semantik (§16.5) ----------
def test_layer64_load_commit_rising_edge() -> None:
"""§16.5: Auswahl erzeugt pending; Flanke auf Kanal 9 lädt."""
engine = Layer64Engine()
# Auswahl ändern (Bank 2, Folder 3, Index 42) noch kein Commit
engine.decode(_layer_channels(**{"5": 2, "6": 3, "7": 0, "8": 42}))
assert engine.selection_pending is True # pending, nicht geladen
# Commit-Flanke: Kanal 9 über Schwelle → load_commit-Event mit Auswahl
control = engine.decode(_layer_channels(**{"5": 2, "6": 3, "7": 0, "8": 42, "9": 255}))
assert engine.selection_pending is False # geladen
assert len(control.events) == 1
event = control.events[0]
assert event.kind == "load_commit"
assert event.pending_bank == 2
assert event.pending_folder == 3
assert event.pending_index == 42
# gehaltener Commit-Kanal: kein weiteres Event
control = engine.decode(_layer_channels(**{"5": 2, "6": 3, "7": 0, "8": 42, "9": 255}))
assert control.events == []
def test_layer64_selection_change_remarks_pending() -> None:
"""Änderung nach Commit → wieder pending; langsamer Fader lädt nichts."""
engine = Layer64Engine()
engine.decode(_layer_channels(**{"5": 1, "9": 255})) # erstes Laden
assert engine.selection_pending is False
# Fader bewegt sich langsam über mehrere Werte → nur pending, kein Load
for index in (10, 50, 100):
engine.decode(_layer_channels(**{"7": index >> 8, "8": index & 0xFF}))
assert engine.selection_pending is True # §16.5: kein dutzendfaches Laden
def test_layer64_retrigger_edge() -> None:
engine = Layer64Engine()
engine.decode(_layer_channels()) # Baseline
control = engine.decode(_layer_channels(**{"63": 255}))
assert [e.kind for e in control.events] == ["retrigger"]
# ---------- Universe-Plan (§16.2) ----------
def test_universe_plan_accepts_disjoint_ranges() -> None:
plan = UniversePlan()
plan.add(UniverseRange(node_id="node-a", first=0, last=3))
plan.add(UniverseRange(node_id="node-b", first=4, last=7))
assert len(plan.ranges()) == 2
def test_universe_plan_rejects_collision() -> None:
"""§16.2: Überschneidung = Blocker, kein still zusammenführen."""
plan = UniversePlan()
plan.add(UniverseRange(node_id="node-a", first=0, last=5))
with pytest.raises(UniverseCollisionError, match="Kollision"):
plan.add(UniverseRange(node_id="node-b", first=4, last=9))
def test_universe_plan_overlap_preflight() -> None:
plan = UniversePlan()
plan.add(UniverseRange(node_id="node-a", first=10, last=20))
assert plan.overlaps(UniverseRange(node_id="x", first=15, last=16)) is True
assert plan.overlaps(UniverseRange(node_id="x", first=21, last=30)) is False
def test_eight_layers_exactly_one_universe() -> None:
"""§16.2: 8 Layer-Fixtures à 64 Kanäle = exakt 512 DMX-Kanäle."""
assert 8 * 64 == 512