Phase 0: Repository-Initialisierung nach Bauplan v1.2

- Struktur gemäß §8 (Eigentumsgrenzen), PLAN.md als normative Basis
- Pflichtdokumente: STATUS.md, ERRORS.md, TEST_REPORT.md, CHANGELOG.md, ADRs
- ADR-0001 Python 3.13-Pin, ADR-0002 GStreamer 1.28.6-Pin (Windows),
  ADR-0003 IPC TCP+MessagePack v1
- Kernpakete: hms_protocol, hms_domain, hms_parameter, hms_artnet,
  hms_adaptive, hms_capabilities, hms_plugin_sdk
- Renderer-Spike: D3D11-Primärpfad + Dev-GL-Pfad (§36 Nr. 4-5)
- Control Core: FastAPI REST + WebSocket (§36 Nr. 9)
- Beispielplugins: Passthrough + Gaussian Blur (3 Adaptive-Quality-
  Varianten, HLSL/GLSL/GLES)
- Tools: Art-Net-Emulator, Fixture-Generator (Master32/Layer64-CSV),
  Capability-Probe
- JSON-Schemas: IPC, Plugin, Projekt, Cluster
- 121 Unit-/Integrationstests grün, Ruff grün

Gate 0 bleibt offen: Hardwaremessungen nur auf echter Windows-Referenz-
hardware gültig (§29.7, §33).
This commit is contained in:
HMS MediaEngine Agent
2026-09-11 00:36:59 +02:00
commit 0922cc1d68
133 changed files with 7939 additions and 0 deletions
View File
+36
View File
@@ -0,0 +1,36 @@
"""hms_artnet Art-Net 4 Steuerung (PLAN.md §16).
ArtDMX-Empfang, ArtPoll/ArtPollReply (Discovery als Media Server, Style 0x02),
konfigurierbare Universen/Adressen, Sequenzprüfung, Signalverlust-Verhalten.
Layouts gegen die offizielle Art-Net-4-Spezifikation verifiziert.
"""
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.receiver import ArtNetReceiver, DmxUpdate, LossBehavior
__all__ = [
"OPDMX",
"OPPOLL",
"OPPOLLREPLY",
"UDP_PORT",
"build_dmx",
"parse_dmx",
"build_poll",
"parse_poll",
"build_artpoll_reply",
"parse_poll_reply",
"ArtNetReceiver",
"DmxUpdate",
"LossBehavior",
]
+111
View File
@@ -0,0 +1,111 @@
"""DMX→Parameter-Mapping (PLAN.md §16.416.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, 23 = 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)
+225
View File
@@ -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,
)
+169
View File
@@ -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)