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:
@@ -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",
|
||||
]
|
||||
@@ -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,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)
|
||||
Reference in New Issue
Block a user