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).
226 lines
7.1 KiB
Python
226 lines
7.1 KiB
Python
"""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,
|
|
)
|