"""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", 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, 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, 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", 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("