0922cc1d68
- 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).
170 lines
5.9 KiB
Python
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)
|