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:
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user