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