Files
hms-mediaengine/packages/artnet/hms_artnet/receiver.py
T
HMS MediaEngine Agent 0922cc1d68 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).
2026-09-11 00:36:59 +02:00

170 lines
5.9 KiB
Python

"""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)