AUFGERAUMT: Root auf 10 sichtbare Elemente reduziert
Der Nutzer hat recht: Der Ordner war voller Entwicklungs-Muell. Jetzt ist sauber getrennt: ROOT (was der Nutzer sieht und braucht): - run.py = das Programm - hms_app/ = der Anwendungscode - HMS MediaEngine.app = macOS Doppelklick-Starter - HMS-Start.vbs = Windows Doppelklick-Starter - HMS-Install.vbs = Windows Erst-Installation - HMS-Mac-Install.command = macOS Homebrew-Installation - HMS-Portable-Install.command = macOS Portable-Installation (16GB-Fix) - installer_gui.py = grafischer Installer - launcher.pyw + launcher_core.py = interne Start-Logik - LIESMICH.txt = 10-Zeilen-Kurzanleitung - .gitignore _entwicklung/ (alles andere, NICHT benoetigt): - packages/ apps/ native/ plugins/ tools/ schemas/ tests/ docs/ build/ fixture_profiles/ - PLAN.md STATUS.md ERRORS.md TEST_REPORT.md CHANGELOG.md README.md - pyproject.toml uv.lock setup_*.sh/ps1 make_mac_app.py Diese Trennung gilt ab sofort fuer alle Commits. Der Nutzer kann _entwicklung/ loeschen wenn er Platz braucht - die App laeuft ohne. Verifiziert: App startet nach Aufraeumen unveraendert (Health 200).
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
"""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), Patch-Verwaltung (§16.2),
|
||||
DMX-zu-Parameter-Verkabelung (§11, §16), Universe-Plan (§16.2),
|
||||
konfigurierbare Universen/Adressen, Sequenzprüfung, Signalverlust-Verhalten.
|
||||
Layouts gegen die offizielle Art-Net-4-Spezifikation verifiziert.
|
||||
"""
|
||||
|
||||
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,
|
||||
OPPOLLREPLY,
|
||||
UDP_PORT,
|
||||
build_artpoll_reply,
|
||||
build_dmx,
|
||||
build_poll,
|
||||
parse_dmx,
|
||||
parse_poll,
|
||||
parse_poll_reply,
|
||||
)
|
||||
from hms_artnet.patch import (
|
||||
FixturePatch,
|
||||
PatchEntry,
|
||||
PatchError,
|
||||
)
|
||||
from hms_artnet.receiver import ArtNetReceiver, DmxUpdate, LossBehavior
|
||||
from hms_artnet.wiring import DmxToParameterRouter, RouterStats
|
||||
|
||||
__all__ = [
|
||||
"OPDMX",
|
||||
"OPPOLL",
|
||||
"OPPOLLREPLY",
|
||||
"UDP_PORT",
|
||||
"build_dmx",
|
||||
"parse_dmx",
|
||||
"build_poll",
|
||||
"parse_poll",
|
||||
"build_artpoll_reply",
|
||||
"parse_poll_reply",
|
||||
"ArtNetReceiver",
|
||||
"DmxUpdate",
|
||||
"LossBehavior",
|
||||
"DmxLayerMapper",
|
||||
"LayerDmxMapping",
|
||||
"RisingEdge",
|
||||
"Master32Engine",
|
||||
"MasterControl",
|
||||
"MasterEvent",
|
||||
"Layer64Engine",
|
||||
"LayerControl",
|
||||
"LayerEvent",
|
||||
"SourceType",
|
||||
"TransportCommand",
|
||||
"map_speed",
|
||||
"UniversePlan",
|
||||
"UniverseRange",
|
||||
"UniverseCollisionError",
|
||||
"FixturePatch",
|
||||
"PatchEntry",
|
||||
"PatchError",
|
||||
"DmxToParameterRouter",
|
||||
"RouterStats",
|
||||
]
|
||||
@@ -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,111 @@
|
||||
"""DMX→Parameter-Mapping (PLAN.md §16.4–16.6, §36 Nr. 8).
|
||||
|
||||
Bildet DMX-Kanäle eines Universes auf stabile Parameterpfade ab:
|
||||
- 8-Bit: Byte / 255
|
||||
- 16-Bit: (MSB << 8 | LSB) / 65535, MSB zuerst (DMX-Konvention)
|
||||
- Flankenerkennung für Trigger (steigende Flanke, §16.3/§16.5)
|
||||
- Signalverlust je konfigurierter Policy; HOLD ist V1-Standard (§11.3)
|
||||
|
||||
Alle Werte fließen ausschließlich über die Parameter-Engine in den Control
|
||||
Core (§11); der Renderer wird nie direkt berührt.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from hms_parameter.engine import ControlSource, ParameterEngine
|
||||
from hms_parameter.paths import layer_opacity_path
|
||||
|
||||
from hms_artnet.receiver import DmxUpdate, LossBehavior
|
||||
|
||||
|
||||
class RisingEdge:
|
||||
"""Erkennt steigende Flanken über einer Schwelle (§16.5).
|
||||
|
||||
Ein Trigger ist ein Ereignis, kein Dauerzustand: derselbe gehaltene
|
||||
Faderwert löst genau einmal aus; erst nach Rückkehr unter die Schwelle
|
||||
kann erneut getriggert werden.
|
||||
"""
|
||||
|
||||
def __init__(self, threshold: int = 64) -> None:
|
||||
if not 0 <= threshold <= 255:
|
||||
raise ValueError("threshold must be 0..255")
|
||||
self.threshold = threshold
|
||||
self._was_active = False
|
||||
|
||||
def feed(self, value: int) -> bool:
|
||||
active = value >= self.threshold
|
||||
triggered = active and not self._was_active
|
||||
self._was_active = active
|
||||
return triggered
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LayerDmxMapping:
|
||||
"""Layer-Fixture-Belegung (Auszug Layer64, §16.4).
|
||||
|
||||
base_address: 1-basierte DMX-Startadresse des Layer-Fixtures
|
||||
Kanäle relativ: 1 = Enable, 2–3 = Opacity (16 Bit, MSB zuerst)
|
||||
"""
|
||||
|
||||
universe: int
|
||||
base_address: int
|
||||
composition_id: str
|
||||
layer_id: str
|
||||
loss_behavior: LossBehavior = LossBehavior.HOLD
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not 0 <= self.universe < 0x8000:
|
||||
raise ValueError("universe must be 0..0x7FFF")
|
||||
if not 1 <= self.base_address <= 512 - 2:
|
||||
raise ValueError("base_address must leave room for channels 1..3")
|
||||
|
||||
@property
|
||||
def enable_path(self) -> str:
|
||||
return f"composition/{self.composition_id}/layer/{self.layer_id}/enabled"
|
||||
|
||||
@property
|
||||
def opacity_path(self) -> str:
|
||||
return layer_opacity_path(self.composition_id, self.layer_id)
|
||||
|
||||
|
||||
class DmxLayerMapper:
|
||||
"""Wandelt DmxUpdates eines Universes in Parameter-Engine-Werte.
|
||||
|
||||
Phase-0-Umfang (§36 Nr. 8): DMX-Kanal auf Layer-Opacity mappen.
|
||||
Media-Auswahl mit Load/Commit-Semantik (§16.5) folgt in Phase 2;
|
||||
RisingEdge ist bereits getestet verfügbar.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
mapping: LayerDmxMapping,
|
||||
engine: ParameterEngine,
|
||||
source: ControlSource = ControlSource.CONSOLE,
|
||||
) -> None:
|
||||
self._mapping = mapping
|
||||
self._engine = engine
|
||||
self._source = source
|
||||
|
||||
def _channel(self, data: bytes, relative: int) -> int:
|
||||
"""Liest Kanal relativ zur Base-Adresse (1-basiert); 0 wenn zu kurz."""
|
||||
idx = self._mapping.base_address - 1 + (relative - 1)
|
||||
if 0 <= idx < len(data):
|
||||
return data[idx]
|
||||
return 0
|
||||
|
||||
def handle(self, update: DmxUpdate) -> None:
|
||||
if update.universe != self._mapping.universe:
|
||||
return
|
||||
if update.sequence == -1 and not update.data:
|
||||
# Signalverlust (§11.3, §16.1): Policy anwenden, niemals still
|
||||
if self._mapping.loss_behavior is LossBehavior.FADE_TO_BLACK:
|
||||
self._engine.release(self._mapping.opacity_path, self._source)
|
||||
self._engine.release(self._mapping.enable_path, self._source)
|
||||
# HOLD: letzten Zustand behalten – keine Aktion
|
||||
return
|
||||
enable = 1.0 if self._channel(update.data, 1) >= 128 else 0.0
|
||||
opacity = ((self._channel(update.data, 2) << 8) | self._channel(update.data, 3)) / 65535.0
|
||||
self._engine.set_value(self._mapping.enable_path, enable, self._source)
|
||||
self._engine.set_value(self._mapping.opacity_path, opacity, self._source)
|
||||
@@ -0,0 +1,225 @@
|
||||
"""Art-Net-Pakete: Bau und Parse (offizielle Art-Net-4-Spezifikation).
|
||||
|
||||
Verifizierte Regeln:
|
||||
- ID: 'Art-Net\\0' (8 Bytes)
|
||||
- OpCode: Int16 little-endian (low byte first)
|
||||
- ProtVer: 14, high byte first (0x00 0x0E)
|
||||
- ArtDMX: OpCode 0x5000, 18-Byte-Header + 2..512 Datenbytes, gerade Länge
|
||||
- ArtPoll: OpCode 0x2000, 14 Bytes Kern, >= 14 akzeptieren
|
||||
- ArtPollReply: OpCode 0x2100, 210 Bytes, Style 0x02 = StMedia,
|
||||
NodeReport-Format '#hhhh [hhhh] text', Port 0x1936
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import struct
|
||||
from dataclasses import dataclass
|
||||
|
||||
ARTNET_ID = b"Art-Net\x00"
|
||||
PROTVER = 14
|
||||
OPDMX = 0x5000
|
||||
OPPOLL = 0x2000
|
||||
OPPOLLREPLY = 0x2100
|
||||
UDP_PORT = 0x1936 # 6454
|
||||
STYLE_STMEDIA = 0x02
|
||||
|
||||
|
||||
def _header(opcode: int) -> bytes:
|
||||
"""ID + OpCode (little-endian) + ProtVer 14 (high byte first).
|
||||
|
||||
Endianness gemäß Spezifikation: OpCode low byte first, ProtVer
|
||||
dagegen high byte first (0x00 0x0E).
|
||||
"""
|
||||
return ARTNET_ID + struct.pack("<H", opcode) + struct.pack(">H", PROTVER)
|
||||
|
||||
|
||||
def build_dmx(universe: int, data: bytes, sequence: int = 0, physical: int = 0) -> bytes:
|
||||
"""Baut ein ArtDMX-Paket (OpCode 0x5000).
|
||||
|
||||
universe: 15-bit Port-Address (Net<<8 | SubUni)
|
||||
data: 2..512 Kanalbytes; Länge muss gerade sein, wird aufgerundet.
|
||||
"""
|
||||
if not 0 <= universe < 0x8000:
|
||||
raise ValueError("universe must be 0..0x7FFF")
|
||||
if not 2 <= len(data) <= 512:
|
||||
raise ValueError("data must be 2..512 bytes")
|
||||
if len(data) % 2:
|
||||
data = data + b"\x00"
|
||||
length = len(data)
|
||||
sub_uni = universe & 0xFF
|
||||
net = (universe >> 8) & 0x7F
|
||||
return (
|
||||
_header(OPDMX)
|
||||
+ struct.pack(">BB", sequence & 0xFF, physical & 0xFF)
|
||||
+ struct.pack(">BB", sub_uni, net)
|
||||
+ struct.pack(">H", length)
|
||||
+ data
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DmxPacket:
|
||||
sequence: int
|
||||
physical: int
|
||||
universe: int
|
||||
data: bytes
|
||||
|
||||
|
||||
def parse_dmx(packet: bytes) -> DmxPacket | None:
|
||||
"""Parst ein ArtDMX-Paket; None wenn kein gültiges ArtDMX."""
|
||||
if len(packet) < 18 or packet[:8] != ARTNET_ID:
|
||||
return None
|
||||
(opcode,) = struct.unpack_from("<H", packet, 8)
|
||||
if opcode != OPDMX:
|
||||
return None
|
||||
(protver,) = struct.unpack_from(">H", packet, 10)
|
||||
if protver < 14:
|
||||
return None
|
||||
sequence = packet[12]
|
||||
physical = packet[13]
|
||||
sub_uni = packet[14]
|
||||
net = packet[15] & 0x7F
|
||||
(length,) = struct.unpack_from(">H", packet, 16)
|
||||
if length < 2 or length > 512:
|
||||
return None
|
||||
if len(packet) < 18 + length:
|
||||
return None
|
||||
return DmxPacket(sequence, physical, (net << 8) | sub_uni, bytes(packet[18 : 18 + length]))
|
||||
|
||||
|
||||
def build_poll(talk_to_me: int = 0x00, priority: int = 0x0A) -> bytes:
|
||||
"""Baut ein ArtPoll-Paket (OpCode 0x2000, 14 Bytes Kern)."""
|
||||
return _header(OPPOLL) + struct.pack(">BB", talk_to_me, priority)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PollPacket:
|
||||
talk_to_me: int
|
||||
priority: int
|
||||
|
||||
|
||||
def parse_poll(packet: bytes) -> PollPacket | None:
|
||||
"""Parst ein ArtPoll; akzeptiert >= 14 Bytes (fehlende Felder = 0)."""
|
||||
if len(packet) < 14 or packet[:8] != ARTNET_ID:
|
||||
return None
|
||||
(opcode,) = struct.unpack_from("<H", packet, 8)
|
||||
if opcode != OPPOLL:
|
||||
return None
|
||||
(protver,) = struct.unpack_from(">H", packet, 10)
|
||||
if protver < 14:
|
||||
return None
|
||||
return PollPacket(packet[12], packet[13])
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PollReplyInfo:
|
||||
ip: str
|
||||
short_name: str
|
||||
long_name: str
|
||||
node_report: str
|
||||
style: int
|
||||
bind_index: int
|
||||
net_switch: int
|
||||
sub_switch: int
|
||||
num_ports: int
|
||||
port_types: bytes
|
||||
sw_in: bytes
|
||||
sw_out: bytes
|
||||
mac: bytes
|
||||
|
||||
|
||||
def build_artpoll_reply(
|
||||
ip: bytes,
|
||||
short_name: str,
|
||||
long_name: str,
|
||||
node_report: str = "Media Server Ready",
|
||||
report_code: int = 0x0000,
|
||||
error_count: int = 0,
|
||||
style: int = STYLE_STMEDIA,
|
||||
mac: bytes = b"\x00" * 6,
|
||||
net_switch: int = 0,
|
||||
sub_switch: int = 0,
|
||||
num_ports: int = 1,
|
||||
port_types: bytes = b"\x80\x00\x00\x00", # Port 0: DMX512, Output-fähig
|
||||
good_output: bytes = b"\x00\x00\x00\x00",
|
||||
sw_out: bytes = b"\x00\x00\x00\x00",
|
||||
bind_index: int = 1,
|
||||
esta_man: int = 0x0000, # unregistriert; ESTA-Code später beantragen
|
||||
vers_info: int = 0x00010000,
|
||||
) -> bytes:
|
||||
"""Baut ein ArtPollReply (exakt 210 Bytes) als Media Server (Style 0x02)."""
|
||||
if len(ip) != 4:
|
||||
raise ValueError("ip must be 4 bytes")
|
||||
if len(mac) != 6:
|
||||
raise ValueError("mac must be 6 bytes")
|
||||
short = short_name.encode("ascii", errors="replace")[:17]
|
||||
long = long_name.encode("ascii", errors="replace")[:63]
|
||||
report = f"#{report_code:04X} [{error_count:04X}] {node_report}".encode(
|
||||
"ascii", errors="replace"
|
||||
)[:63]
|
||||
pkt = bytearray()
|
||||
pkt += ARTNET_ID
|
||||
pkt += struct.pack("<H", OPPOLLREPLY)
|
||||
pkt += ip
|
||||
pkt += struct.pack(">H", UDP_PORT)
|
||||
pkt += struct.pack(">I", vers_info)
|
||||
pkt += struct.pack(">B", net_switch & 0x7F)
|
||||
pkt += struct.pack(">B", sub_switch & 0x0F)
|
||||
pkt += struct.pack(">H", 0x0000) # OEM: Platzhalter bis Registrierung
|
||||
pkt += b"\x00" # UbeaVersion
|
||||
pkt += b"\x00" # Status1
|
||||
pkt += struct.pack(">H", esta_man) # ESTA Manufacturer, high byte first
|
||||
pkt += short.ljust(18, b"\x00")
|
||||
pkt += long.ljust(64, b"\x00")
|
||||
pkt += report.ljust(64, b"\x00")
|
||||
pkt += struct.pack(">BB", 0, num_ports & 0x03) # NumPortsLo 0..4
|
||||
pkt += port_types[:4].ljust(4, b"\x00")
|
||||
pkt += b"\x00" * 4 # GoodInput (kein DMX-In in V1)
|
||||
pkt += good_output[:4].ljust(4, b"\x00")
|
||||
pkt += b"\x00" * 4 # SwIn
|
||||
pkt += sw_out[:4].ljust(4, b"\x00")
|
||||
pkt += b"\x00" * 3 # SwVideo, SwMacro, SwRemote (deprecated = 0)
|
||||
pkt += b"\x00" * 3 # Spare1..3
|
||||
pkt += struct.pack(">B", style)
|
||||
pkt += mac
|
||||
pkt += struct.pack(">B", bind_index)
|
||||
if len(pkt) != 210:
|
||||
raise AssertionError(f"ArtPollReply must be 210 bytes, got {len(pkt)}")
|
||||
return bytes(pkt)
|
||||
|
||||
|
||||
def parse_poll_reply(packet: bytes) -> PollReplyInfo | None:
|
||||
"""Parst ein ArtPollReply (akzeptiert >= 210 Bytes)."""
|
||||
if len(packet) < 210 or packet[:8] != ARTNET_ID:
|
||||
return None
|
||||
(opcode,) = struct.unpack_from("<H", packet, 8)
|
||||
if opcode != OPPOLLREPLY:
|
||||
return None
|
||||
ip = ".".join(str(b) for b in packet[10:14])
|
||||
net_switch = packet[20] & 0x7F
|
||||
sub_switch = packet[21] & 0x0F
|
||||
short_name = packet[28:46].split(b"\x00")[0].decode("ascii", errors="replace")
|
||||
long_name = packet[46:110].split(b"\x00")[0].decode("ascii", errors="replace")
|
||||
node_report = packet[110:174].split(b"\x00")[0].decode("ascii", errors="replace")
|
||||
num_ports = packet[175]
|
||||
port_types = bytes(packet[176:180])
|
||||
sw_in = bytes(packet[188:192])
|
||||
sw_out = bytes(packet[192:196])
|
||||
style = packet[202]
|
||||
mac = bytes(packet[203:209])
|
||||
bind_index = packet[209]
|
||||
return PollReplyInfo(
|
||||
ip=ip,
|
||||
short_name=short_name,
|
||||
long_name=long_name,
|
||||
node_report=node_report,
|
||||
style=style,
|
||||
bind_index=bind_index,
|
||||
net_switch=net_switch,
|
||||
sub_switch=sub_switch,
|
||||
num_ports=num_ports,
|
||||
port_types=port_types,
|
||||
sw_in=sw_in,
|
||||
sw_out=sw_out,
|
||||
mac=mac,
|
||||
)
|
||||
@@ -0,0 +1,196 @@
|
||||
"""Patch-Verwaltung: Layer→DMX-Adressen und Export (PLAN.md §16.2).
|
||||
|
||||
Ein Patch ordnet Show-Layern DMX-Adressen zu:
|
||||
- Master-Fixture: eine 32-Kanal-Instanz (§16.3)
|
||||
- Layer-Fixtures: bis zu 8 Instanzen à 64 Kanäle je Universe (§16.2)
|
||||
|
||||
Validierung:
|
||||
- Adressen lassen ihr Fixture vollständig ins Universe passen
|
||||
- keine Überlappung im selben Universe (Fehler, kein stilles Verschieben)
|
||||
- Layer-IDs eindeutig; Komposition/Layer müssen UUIDs sein (§10.2)
|
||||
|
||||
Export (§16.2): enthält Node-ID, Art-Net Short Name, IP, Net/SubNet/Universe,
|
||||
Startadresse, Layernummer und Fixture-Version.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
|
||||
MASTER_CHANNELS = 32 # §16.3
|
||||
LAYER_CHANNELS = 64 # §16.4
|
||||
DMX_CHANNELS = 512 # DMX512
|
||||
|
||||
|
||||
class PatchError(Exception):
|
||||
"""Ungültiger Patch (Überlappung, außerhalb des Universes, …)."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PatchEntry:
|
||||
"""Ein Layer-Fixture im Patch (§16.2).
|
||||
|
||||
- layer_id/composition_id: stabile UUIDs (§10.2)
|
||||
- universe: 15-Bit Port-Address (Net<<8 | SubUni)
|
||||
- base_address: 1-basierte DMX-Startadresse (1..449 für 64 Kanäle)
|
||||
- layer_number: menschenlesbare Layernummer für den Export (1-basiert)
|
||||
"""
|
||||
|
||||
layer_id: str
|
||||
composition_id: str
|
||||
universe: int
|
||||
base_address: int
|
||||
layer_number: int
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for name, value in (("layer_id", self.layer_id), ("composition_id", self.composition_id)):
|
||||
try:
|
||||
uuid.UUID(value)
|
||||
except (ValueError, AttributeError) as exc:
|
||||
raise ValueError(f"{name} muss eine UUID sein (§10.2)") from exc
|
||||
if not 0 <= self.universe < 0x8000:
|
||||
raise ValueError("universe muss 0..0x7FFF sein")
|
||||
if not 1 <= self.base_address <= DMX_CHANNELS - LAYER_CHANNELS + 1:
|
||||
raise ValueError(
|
||||
f"base_address {self.base_address} passt nicht für 64 Kanäle "
|
||||
f"(erlaubt 1..{DMX_CHANNELS - LAYER_CHANNELS + 1})"
|
||||
)
|
||||
if self.layer_number < 1:
|
||||
raise ValueError("layer_number muss 1-basiert sein")
|
||||
|
||||
@property
|
||||
def end_address(self) -> int:
|
||||
"""Letzte belegte DMX-Adresse (inklusiv)."""
|
||||
return self.base_address + LAYER_CHANNELS - 1
|
||||
|
||||
|
||||
class FixturePatch:
|
||||
"""Vollständiger DMX-Patch eines Nodes (§16.2).
|
||||
|
||||
- master: Adresse des Master32-Fixtures (ein Universe, 1-basiert)
|
||||
- layers: Liste von PatchEntry (Layer-Fixtures)
|
||||
|
||||
validate(): prüft Überlappungen; add/remove verwalten Einträge.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
node_id: str,
|
||||
short_name: str,
|
||||
master_universe: int,
|
||||
master_base_address: int = 1,
|
||||
) -> None:
|
||||
self.node_id = node_id
|
||||
self.short_name = short_name
|
||||
self.master_universe = master_universe
|
||||
self.master_base_address = master_base_address
|
||||
self._layers: dict[str, PatchEntry] = {} # layer_id → entry
|
||||
|
||||
# ---------- Verwaltung ----------
|
||||
|
||||
def add_layer(self, entry: PatchEntry) -> None:
|
||||
"""Fügt ein Layer-Fixture hinzu; Überlappung im selben Universe = Fehler."""
|
||||
if entry.layer_id in self._layers:
|
||||
raise PatchError(f"Layer {entry.layer_id} bereits im Patch")
|
||||
for existing in self._layers.values():
|
||||
if existing.universe == entry.universe and self._overlaps(existing, entry):
|
||||
raise PatchError(
|
||||
f"Adressüberlappung Universe {entry.universe}: "
|
||||
f"L{existing.layer_number} [{existing.base_address}.."
|
||||
f"{existing.end_address}] vs "
|
||||
f"L{entry.layer_number} [{entry.base_address}..{entry.end_address}]"
|
||||
)
|
||||
self._layers[entry.layer_id] = entry
|
||||
|
||||
def remove_layer(self, layer_id: str) -> None:
|
||||
self._layers.pop(layer_id, None)
|
||||
|
||||
def layers(self) -> list[PatchEntry]:
|
||||
return list(self._layers.values())
|
||||
|
||||
def layer_by_id(self, layer_id: str) -> PatchEntry | None:
|
||||
return self._layers.get(layer_id)
|
||||
|
||||
def validate(self) -> list[str]:
|
||||
"""Liefert Fehlerliste; leer = gültig. Master darf kein Layer überlappen."""
|
||||
errors: list[str] = []
|
||||
if not 1 <= self.master_base_address <= DMX_CHANNELS - MASTER_CHANNELS + 1:
|
||||
errors.append(
|
||||
f"Master-Adresse {self.master_base_address} passt nicht für "
|
||||
f"32 Kanäle (erlaubt 1..{DMX_CHANNELS - MASTER_CHANNELS + 1})"
|
||||
)
|
||||
master_range = (
|
||||
self.master_base_address,
|
||||
self.master_base_address + MASTER_CHANNELS - 1,
|
||||
)
|
||||
for entry in self._layers.values():
|
||||
if entry.universe == self.master_universe and self._range_overlap(
|
||||
(entry.base_address, entry.end_address), master_range
|
||||
):
|
||||
errors.append(
|
||||
f"Layer {entry.layer_number} überlappt Master-Fixture "
|
||||
f"im Universe {self.master_universe}"
|
||||
)
|
||||
return errors
|
||||
|
||||
# ---------- Export (§16.2) ----------
|
||||
|
||||
def export_csv(self, ip_or_host: str = "", fixture_version: str = "1.0.0") -> str:
|
||||
"""Menschenlesbare Kanalliste nach §16.2: enthält Node-ID, Short
|
||||
Name, IP, Net/SubNet/Universe, Startadresse, Layernummer, Version."""
|
||||
buf = io.StringIO()
|
||||
writer = csv.writer(buf)
|
||||
writer.writerow(
|
||||
[
|
||||
"node_id",
|
||||
"artnet_short_name",
|
||||
"ip_or_host",
|
||||
"fixture",
|
||||
"universe",
|
||||
"start_address",
|
||||
"channels",
|
||||
"layer_number",
|
||||
"fixture_version",
|
||||
]
|
||||
)
|
||||
writer.writerow(
|
||||
[
|
||||
self.node_id,
|
||||
self.short_name,
|
||||
ip_or_host,
|
||||
"HMS MediaEngine Master 32ch",
|
||||
self.master_universe,
|
||||
self.master_base_address,
|
||||
MASTER_CHANNELS,
|
||||
"",
|
||||
fixture_version,
|
||||
]
|
||||
)
|
||||
for entry in sorted(self._layers.values(), key=lambda e: (e.universe, e.base_address)):
|
||||
writer.writerow(
|
||||
[
|
||||
self.node_id,
|
||||
self.short_name,
|
||||
ip_or_host,
|
||||
"HMS MediaEngine Layer 64ch",
|
||||
entry.universe,
|
||||
entry.base_address,
|
||||
LAYER_CHANNELS,
|
||||
entry.layer_number,
|
||||
fixture_version,
|
||||
]
|
||||
)
|
||||
return buf.getvalue()
|
||||
|
||||
# ---------- Interna ----------
|
||||
|
||||
@staticmethod
|
||||
def _overlaps(a: PatchEntry, b: PatchEntry) -> bool:
|
||||
return not (a.end_address < b.base_address or b.end_address < a.base_address)
|
||||
|
||||
@staticmethod
|
||||
def _range_overlap(a: tuple[int, int], b: tuple[int, int]) -> bool:
|
||||
return not (a[1] < b[0] or b[1] < a[0])
|
||||
@@ -0,0 +1,169 @@
|
||||
"""Art-Net-Empfänger (PLAN.md §16.1).
|
||||
|
||||
- UDP 6454, wählbare Schnittstelle, optional Sender-Allowlist
|
||||
- ArtPoll → ArtPollReply als Media Server (Style 0x02)
|
||||
- ArtDMX-Sequenznummern auswerten, soweit vorhanden
|
||||
- mehrere Sender werden nicht still zusammengeführt: je Universe wird der
|
||||
aktive Sender vermerkt; ein Senderwechsel wird protokolliert (§16.1, §16.2)
|
||||
- Signalverlust je Universe konfigurierbar (hold/fade_to_scene/fade_to_black)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
|
||||
from hms_artnet.packets import build_artpoll_reply, parse_dmx, parse_poll
|
||||
|
||||
|
||||
class LossBehavior(Enum):
|
||||
"""Verhalten bei DMX-Signalverlust (§11.3)."""
|
||||
|
||||
HOLD = "hold"
|
||||
FADE_TO_BLACK = "fade_to_black"
|
||||
FADE_TO_SCENE = "fade_to_scene"
|
||||
DISABLE_SOURCE = "disable_source"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DmxUpdate:
|
||||
"""Ein DMX-Update je Universe; sequence=-1 und data=b'' = Signalverlust."""
|
||||
|
||||
universe: int
|
||||
data: bytes
|
||||
sender_ip: str
|
||||
received_ns: int
|
||||
sequence: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class UniverseTelemetry:
|
||||
universe: int
|
||||
packets: int = 0
|
||||
last_received_ns: int = 0
|
||||
last_sender_ip: str = ""
|
||||
sender_changed: int = 0
|
||||
sequence_gaps: int = 0
|
||||
loss_reported: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class ArtNetReceiver:
|
||||
"""Asynchroner Art-Net-Empfänger (asyncio-Datagramm, ein Socket)."""
|
||||
|
||||
universes: set[int] = field(default_factory=set)
|
||||
bind_host: str = "0.0.0.0"
|
||||
port: int = 6454
|
||||
short_name: str = "HMS MediaEngine"
|
||||
long_name: str = "HMS MediaEngine Render Node"
|
||||
node_ip: bytes = b"\x7f\x00\x00\x01"
|
||||
mac: bytes = b"\x00\x00\x00\x00\x00\x00"
|
||||
sender_allowlist: set[str] = field(default_factory=set)
|
||||
timeout_ms: int = 2500
|
||||
loss_behavior: LossBehavior = LossBehavior.HOLD
|
||||
_transport: object | None = field(default=None, repr=False)
|
||||
_telemetry: dict[int, UniverseTelemetry] = field(default_factory=dict, repr=False)
|
||||
_last_sequence: dict[int, int] = field(default_factory=dict, repr=False)
|
||||
_handlers: list[Callable[[DmxUpdate], None]] = field(default_factory=list, repr=False)
|
||||
_watchdog_task: object | None = field(default=None, repr=False)
|
||||
|
||||
def on_dmx(self, handler: Callable[[DmxUpdate], None]) -> None:
|
||||
self._handlers.append(handler)
|
||||
|
||||
async def start(self) -> None:
|
||||
loop = asyncio.get_running_loop()
|
||||
self._transport, _ = await loop.create_datagram_endpoint(
|
||||
lambda: _Protocol(self), local_addr=(self.bind_host, self.port)
|
||||
)
|
||||
self._watchdog_task = loop.create_task(self._signal_watchdog())
|
||||
|
||||
async def stop(self) -> None:
|
||||
if self._watchdog_task:
|
||||
self._watchdog_task.cancel()
|
||||
self._watchdog_task = None
|
||||
if self._transport:
|
||||
self._transport.close()
|
||||
self._transport = None
|
||||
|
||||
def telemetry(self) -> dict[int, UniverseTelemetry]:
|
||||
return dict(self._telemetry)
|
||||
|
||||
def _handle_datagram(self, data: bytes, addr: tuple) -> None:
|
||||
sender_ip = addr[0] if addr else ""
|
||||
if self.sender_allowlist and sender_ip not in self.sender_allowlist:
|
||||
return
|
||||
if parse_poll(data) is not None:
|
||||
reply = build_artpoll_reply(
|
||||
ip=self.node_ip,
|
||||
short_name=self.short_name,
|
||||
long_name=self.long_name,
|
||||
node_report="Media Server Ready",
|
||||
mac=self.mac,
|
||||
)
|
||||
if self._transport is not None:
|
||||
self._transport.sendto(reply, addr)
|
||||
return
|
||||
dmx = parse_dmx(data)
|
||||
if dmx is None or dmx.universe not in self.universes:
|
||||
return
|
||||
tel = self._telemetry.setdefault(dmx.universe, UniverseTelemetry(dmx.universe))
|
||||
if dmx.sequence != 0:
|
||||
last = self._last_sequence.get(dmx.universe)
|
||||
if last is not None and dmx.sequence != ((last + 1) & 0xFF):
|
||||
tel.sequence_gaps += 1
|
||||
self._last_sequence[dmx.universe] = dmx.sequence
|
||||
if tel.last_sender_ip and tel.last_sender_ip != sender_ip:
|
||||
tel.sender_changed += 1
|
||||
tel.packets += 1
|
||||
tel.last_received_ns = time.monotonic_ns()
|
||||
tel.last_sender_ip = sender_ip
|
||||
tel.loss_reported = False
|
||||
update = DmxUpdate(
|
||||
universe=dmx.universe,
|
||||
data=dmx.data,
|
||||
sender_ip=sender_ip,
|
||||
received_ns=tel.last_received_ns,
|
||||
sequence=dmx.sequence,
|
||||
)
|
||||
for handler in list(self._handlers):
|
||||
handler(update)
|
||||
|
||||
async def _signal_watchdog(self) -> None:
|
||||
while True:
|
||||
await asyncio.sleep(0.5)
|
||||
now = time.monotonic_ns()
|
||||
threshold = self.timeout_ms * 1_000_000
|
||||
for uni, tel in list(self._telemetry.items()):
|
||||
if (
|
||||
tel.last_received_ns
|
||||
and not tel.loss_reported
|
||||
and now - tel.last_received_ns > threshold
|
||||
):
|
||||
tel.loss_reported = True
|
||||
for handler in list(self._handlers):
|
||||
handler(
|
||||
DmxUpdate(
|
||||
universe=uni,
|
||||
data=b"",
|
||||
sender_ip=tel.last_sender_ip,
|
||||
received_ns=now,
|
||||
sequence=-1,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class _Protocol(asyncio.DatagramProtocol):
|
||||
def __init__(self, receiver: ArtNetReceiver) -> None:
|
||||
self._receiver = receiver
|
||||
|
||||
def datagram_received(self, data: bytes, addr: tuple) -> None:
|
||||
self._receiver._handle_datagram(data, addr)
|
||||
|
||||
def error_received(self, exc: Exception) -> None:
|
||||
# Socket-Fehler nicht schlucken (§33); an Watchdog-Protokoll escalate via log
|
||||
import logging
|
||||
|
||||
logging.getLogger("hms.artnet").error("Art-Net socket error: %s", exc)
|
||||
@@ -0,0 +1,228 @@
|
||||
"""DMX-zu-Parameter-Verkabelung (PLAN.md §11, §16, §29.2).
|
||||
|
||||
Verbindet Art-Net-Receiver → FixturePatch → Master32/Layer64-Engines →
|
||||
ParameterEngine:
|
||||
|
||||
- jedes DMX-Update wird über den Patch dekodiert
|
||||
- dekodierte Werte fließen über stabile Parameterpfade in die Engine
|
||||
(§11: alle Quellen über die Parameter-Engine, nie direkt in den Renderer)
|
||||
- Master-Blackout läuft mit SAFETY-Priorität (§11.2: überstimmt alles)
|
||||
- Load/Commit-Events steuern PreloadSlot für atomaren Clipwechsel (§16.5,
|
||||
§12.2): Preload bei pending selection, Commit wechselt atomar
|
||||
- Signalverlust: konfigurierbare Policy je Patch (§16.1, §11.3)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from hms_parameter.engine import ControlSource, ParameterEngine
|
||||
|
||||
from hms_artnet.fixtures import (
|
||||
Layer64Engine,
|
||||
Master32Engine,
|
||||
TransportCommand,
|
||||
)
|
||||
from hms_artnet.patch import MASTER_CHANNELS, FixturePatch
|
||||
from hms_artnet.receiver import DmxUpdate, LossBehavior
|
||||
|
||||
|
||||
@dataclass
|
||||
class RouterStats:
|
||||
"""Telemetrie je Router (§28.2: Paketrate, Events)."""
|
||||
|
||||
updates_processed: int = 0
|
||||
master_updates: int = 0
|
||||
layer_updates: int = 0
|
||||
load_commits: int = 0
|
||||
blackouts: int = 0
|
||||
|
||||
|
||||
class DmxToParameterRouter:
|
||||
"""Routet DMX-Daten über den Patch in die Parameter-Engine.
|
||||
|
||||
Aufbau (§6.1B, §11):
|
||||
- ein Master32Engine für das Master-Fixture
|
||||
- je gepatchtem Layer ein Layer64Engine (Flankenzustand je Instanz)
|
||||
- PreloadSlot je Layer für atomaren Clipwechsel (§12.2, §16.5)
|
||||
|
||||
Blackout (§16.3): Master-Kanal 3 mit SAFETY-Priorität; beim Aufheben
|
||||
wird der Override releast (nicht auf 0 gesetzt) – der darunterliegende
|
||||
Zustand bleibt erhalten (§11.3 Release-Semantik).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
patch: FixturePatch,
|
||||
engine: ParameterEngine,
|
||||
loss_behavior: LossBehavior = LossBehavior.HOLD,
|
||||
) -> None:
|
||||
self._patch = patch
|
||||
self._engine = engine
|
||||
self._loss_behavior = loss_behavior
|
||||
self._master_engine = Master32Engine()
|
||||
self._layer_engines: dict[str, Layer64Engine] = {}
|
||||
self._master_blackout_active = False
|
||||
self.stats = RouterStats()
|
||||
# Preload-Slots je Layer (später vom Renderer bedient, §12.2)
|
||||
self._pending_loads: dict[str, dict] = {} # layer_id → Auswahl
|
||||
|
||||
# ---------- DMX-Verarbeitung ----------
|
||||
|
||||
def handle_update(self, update: DmxUpdate) -> None:
|
||||
"""Verarbeitet ein DMX-Update über den Patch.
|
||||
|
||||
- Master-Universe: 32 Kanäle ab Master-Basisadresse dekodieren
|
||||
- Layer-Fixtures: 64 Kanäle je gepatchtem Layer im Universe
|
||||
- Signalverlust (leere Daten): Policy je Verhalten (§16.1)
|
||||
"""
|
||||
self.stats.updates_processed += 1
|
||||
if update.sequence == -1 and not update.data:
|
||||
self._handle_signal_loss(update.universe)
|
||||
return
|
||||
self._process_master(update)
|
||||
self._process_layers(update)
|
||||
|
||||
# ---------- Master (§16.3) ----------
|
||||
|
||||
def _process_master(self, update: DmxUpdate) -> None:
|
||||
if update.universe != self._patch.master_universe:
|
||||
return
|
||||
start = self._patch.master_base_address - 1
|
||||
segment = bytes(update.data[start : start + MASTER_CHANNELS])
|
||||
if len(segment) < MASTER_CHANNELS:
|
||||
return # Universe noch nicht vollständig; kein Fehler
|
||||
control = self._master_engine.decode(segment)
|
||||
self.stats.master_updates += 1
|
||||
|
||||
# Blackout: SAFETY-Priorität, Release beim Aufheben (§11.2, §11.3)
|
||||
if control.blackout and not self._master_blackout_active:
|
||||
self._engine.set_value("master/blackout", 1.0, ControlSource.SAFETY)
|
||||
self._master_blackout_active = True
|
||||
self.stats.blackouts += 1
|
||||
elif not control.blackout and self._master_blackout_active:
|
||||
self._engine.release("master/blackout", ControlSource.SAFETY)
|
||||
self._master_blackout_active = False
|
||||
|
||||
src = ControlSource.CONSOLE # Lichtpult (§11.2 Priorität 3)
|
||||
self._engine.set_value("master/intensity", control.master_intensity, src)
|
||||
self._engine.set_value("master/global_speed", control.global_speed, src)
|
||||
self._engine.set_value("master/bpm", control.bpm, src)
|
||||
self._engine.set_value("master/global_hue", control.global_hue, src)
|
||||
self._engine.set_value(
|
||||
"master/global_saturation", control.global_saturation, src
|
||||
)
|
||||
self._engine.set_value(
|
||||
"master/test_pattern", float(control.test_pattern), src
|
||||
)
|
||||
if control.events:
|
||||
self.stats.load_commits += len(control.events)
|
||||
|
||||
# ---------- Layer (§16.4, §16.5) ----------
|
||||
|
||||
def _process_layers(self, update: DmxUpdate) -> None:
|
||||
for entry in self._patch.layers():
|
||||
if entry.universe != update.universe:
|
||||
continue
|
||||
layer_engine = self._layer_engines.setdefault(
|
||||
entry.layer_id, Layer64Engine()
|
||||
)
|
||||
start = entry.base_address - 1
|
||||
segment = bytes(update.data[start : start + 64])
|
||||
if len(segment) < 64:
|
||||
continue # Universe unvollständig: Layer still auslassen
|
||||
control = layer_engine.decode(segment)
|
||||
self.stats.layer_updates += 1
|
||||
base = f"composition/{entry.composition_id}/layer/{entry.layer_id}"
|
||||
src = ControlSource.CONSOLE
|
||||
|
||||
# Grundsteuerung (§16.4)
|
||||
if control.enabled:
|
||||
self._engine.set_value(f"{base}/enabled", 1.0, src)
|
||||
else:
|
||||
self._engine.set_value(f"{base}/enabled", 0.0, src)
|
||||
self._engine.set_value(f"{base}/opacity", control.opacity, src)
|
||||
self._engine.set_value(f"{base}/blend_mode", float(control.blend_mode), src)
|
||||
self._engine.set_value(
|
||||
f"{base}/transform/position_x", control.position_x, src
|
||||
)
|
||||
self._engine.set_value(
|
||||
f"{base}/transform/position_y", control.position_y, src
|
||||
)
|
||||
self._engine.set_value(f"{base}/transform/scale_x", control.scale_x, src)
|
||||
self._engine.set_value(f"{base}/transform/scale_y", control.scale_y, src)
|
||||
self._engine.set_value(
|
||||
f"{base}/transform/rotation_deg", control.rotation_deg, src
|
||||
)
|
||||
self._engine.set_value(f"{base}/color/hue", control.hue, src)
|
||||
self._engine.set_value(
|
||||
f"{base}/color/saturation", control.saturation, src
|
||||
)
|
||||
self._engine.set_value(
|
||||
f"{base}/color/brightness", control.brightness, src
|
||||
)
|
||||
self._engine.set_value(f"{base}/color/contrast", control.contrast, src)
|
||||
|
||||
# Medien-Transport (§16.4 Kanäle 10-20, Media-Modus)
|
||||
self._engine.set_value(
|
||||
f"{base}/source/speed", control.speed, src
|
||||
)
|
||||
self._engine.set_value(
|
||||
f"{base}/source/position", control.position, src
|
||||
)
|
||||
self._engine.set_value(
|
||||
f"{base}/source/in_point", control.in_point, src
|
||||
)
|
||||
self._engine.set_value(
|
||||
f"{base}/source/out_point", control.out_point, src
|
||||
)
|
||||
if control.transport is TransportCommand.RETRIGGER:
|
||||
self._engine.set_value(f"{base}/source/retrigger", 1.0, src)
|
||||
|
||||
# FX-Steuerung (§16.4 Kanäle 41-62)
|
||||
self._engine.set_value(
|
||||
f"{base}/fx1/enabled", 1.0 if control.fx1_enabled else 0.0, src
|
||||
)
|
||||
self._engine.set_value(f"{base}/fx1/mix", control.fx1_mix, src)
|
||||
self._engine.set_value(
|
||||
f"{base}/fx2/enabled", 1.0 if control.fx2_enabled else 0.0, src
|
||||
)
|
||||
self._engine.set_value(f"{base}/fx2/mix", control.fx2_mix, src)
|
||||
|
||||
# Load/Commit-Events: atomare Auswahl (§16.5, §12.2)
|
||||
for event in control.events:
|
||||
if event.kind == "load_commit":
|
||||
self.stats.load_commits += 1
|
||||
self._pending_loads[entry.layer_id] = {
|
||||
"bank": event.pending_bank,
|
||||
"folder": event.pending_folder,
|
||||
"index": event.pending_index,
|
||||
"universe": update.universe,
|
||||
}
|
||||
elif event.kind == "retrigger":
|
||||
self._engine.set_value(f"{base}/source/retrigger", 1.0, src)
|
||||
|
||||
# ---------- Ausstehende Loads (§12.2: Renderer bedient Preload) ----------
|
||||
|
||||
def pending_load_for(self, layer_id: str) -> dict | None:
|
||||
"""Liefert die ausstehende Load-Auswahl eines Layers (für den
|
||||
Renderer-Preload) und entfernt sie (Verbrauch durch Aufrufer)."""
|
||||
return self._pending_loads.pop(layer_id, None)
|
||||
|
||||
# ---------- Signalverlust (§16.1, §11.3) ----------
|
||||
|
||||
def _handle_signal_loss(self, universe: int) -> None:
|
||||
"""DMX-Ausfall auf einem Universe: Policy je Konfiguration.
|
||||
|
||||
- HOLD: nichts tun (letzter Zustand bleibt, §11.3)
|
||||
- FADE_TO_BLACK: Master-Override auf 0 setzen (SAFETY)
|
||||
"""
|
||||
if self._loss_behavior is LossBehavior.HOLD:
|
||||
return
|
||||
if (
|
||||
self._loss_behavior is LossBehavior.FADE_TO_BLACK
|
||||
and universe == self._patch.master_universe
|
||||
):
|
||||
# Intensität über SAFETY auf 0; beim Wiederkommen releast die
|
||||
# Master-Verarbeitung den Override nicht – hier explizit setzen
|
||||
self._engine.set_value("master/intensity", 0.0, ControlSource.SAFETY)
|
||||
Reference in New Issue
Block a user