From abffff3711aa4d2a12c60943c1fe39884cc36abc Mon Sep 17 00:00:00 2001 From: HMS MediaEngine Agent Date: Fri, 11 Sep 2026 02:06:58 +0200 Subject: [PATCH] =?UTF-8?q?Phase=204:=20Fixture-Engine=20Master32/Layer64?= =?UTF-8?q?=20(=C2=A716.2=E2=80=93=C2=A716.6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- packages/artnet/hms_artnet/__init__.py | 31 ++ packages/artnet/hms_artnet/fixtures.py | 423 +++++++++++++++++++++++++ tests/unit/test_fixture_engine.py | 322 +++++++++++++++++++ 3 files changed, 776 insertions(+) create mode 100644 packages/artnet/hms_artnet/fixtures.py create mode 100644 tests/unit/test_fixture_engine.py diff --git a/packages/artnet/hms_artnet/__init__.py b/packages/artnet/hms_artnet/__init__.py index 8186343..f5a5212 100644 --- a/packages/artnet/hms_artnet/__init__.py +++ b/packages/artnet/hms_artnet/__init__.py @@ -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", ] diff --git a/packages/artnet/hms_artnet/fixtures.py b/packages/artnet/hms_artnet/fixtures.py new file mode 100644 index 0000000..cdf9d1a --- /dev/null +++ b/packages/artnet/hms_artnet/fixtures.py @@ -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 17–21 und 31–32 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 (40960−32768)/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) diff --git a/tests/unit/test_fixture_engine.py b/tests/unit/test_fixture_engine.py new file mode 100644 index 0000000..0738396 --- /dev/null +++ b/tests/unit/test_fixture_engine.py @@ -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