From 0c98e1853094e410fe11c2ed7270fb67ffa59a9d Mon Sep 17 00:00:00 2001 From: Chris Shucksmith Date: Sun, 22 Jun 2025 22:28:36 +0100 Subject: [PATCH 1/2] wip: art-net RDM table-of-devices support --- README.md | 19 +++++++-- aioartnet/client.py | 94 ++++++++++++++++++++++++++++++++++++++++++--- aioartnet/main.py | 4 +- aioartnet/models.py | 10 ++++- aioartnet/rdm.py | 44 +++++++++++++++++++++ 5 files changed, 161 insertions(+), 10 deletions(-) create mode 100644 aioartnet/rdm.py diff --git a/README.md b/README.md index 30e00db..364173d 100644 --- a/README.md +++ b/README.md @@ -114,7 +114,7 @@ Features | 15-bit port addresses | :heavy_check_mark: | :heavy_check_mark: | | >4 ports (bindIndex) on same IP | :heavy_check_mark: | :heavy_check_mark: | | merge-mode (LTP/HTP) in reciever | - | - | -| RDM commands to enumerate fixtures | - | - | +| RDM commands to enumerate fixtures | :heavy_check_mark: | :heavy_check_mark: | | Timecode | - | - | | Multi-universe sync message | - | - | | local node reconfigure by API | :heavy_check_mark: | :heavy_check_mark: | @@ -135,9 +135,11 @@ Implemented Messages | ArtDiagData | - | - | | ArtTimeCode | - | - | | ArtCommand | - | - | +| ArtNzs | - | - | | ArtTrigger | - | - | | ArtSync | - | - | -| RDM ArtTodRequest / ArtTodData / ArtTodControl / ArtRdm / ArtRdmSub | - | - | +| RDM ArtTodRequest / ArtTodData / ArtTodControl | :heavy_check_mark: | :heavy_check_mark: | +| RDM ArtRdm / ArtRdmSub | - | - | Art-Net @@ -150,5 +152,16 @@ This application aims to be fully compatible with Art-Net devices. We have teste * iOS [DMX Monitor app](https://apps.apple.com/us/app/dmx-monitor/id1544911427) * Many other iOS apps - ![Art-Net logo](./docs/art-net-master-logo.svg) Art-Netâ„¢ Designed by and Copyright Artistic Licence Engineering Ltd. + +DMX Remote Device Management (RDM) Support +--- + +The Art-Net protocol can carry RDM messages to allow for table-of-device enumeration, and then inspection of the devices by uid. + +https://www.rdmprotocol.org/rdm/wp-content/uploads/2011/09/logo2.jpg + +See https://www.rdmprotocol.org/rdm/ + +RDM specs are https://tsp.esta.org/tsp/documents/published_docs.php + diff --git a/aioartnet/client.py b/aioartnet/client.py index 8c9c374..1d0b5e4 100644 --- a/aioartnet/client.py +++ b/aioartnet/client.py @@ -25,6 +25,7 @@ DatagramAddr, ) from .network import AF_PACKET, getifaddrs +from .rdm import RDMDevice # Art-Net implementation for Python asyncio # Any page references to 'spec' refer to @@ -98,6 +99,8 @@ def __init__(self, client: "ArtNetClient"): 0x2000: self.on_art_poll, 0x2100: self.on_art_poll_reply, 0x5000: self.on_art_dmx, + 0x8000: self.on_art_tod_request, + 0x8100: self.on_art_tod_data, } client.protocol = self self.node_report_counter = 0 @@ -115,7 +118,7 @@ def datagram_received(self, data: bytes, addr: DatagramAddr) -> None: if h: h(addr, data[10:]) else: - logger.debug( + logger.info( f"Received unsupported Art-Net: op {hex(opcode)} from {addr}: {data[10:]!r}" ) else: @@ -196,7 +199,9 @@ def on_art_poll_reply(self, addr: DatagramAddr, data: bytes) -> None: # iterate through the ports and create ports and universes portList = [] - for _type, _in, _out, _swin, _swout in zip(ptype, ins, outs, swin, swout): + for _type, _in, _out, _swin, _swout, _goodout in zip( + ptype, ins, outs, swin, swout, goodout + ): in_port_addr = ( ((netsw & 0x7F) << 8) + ((subsw & 0x0F) << 4) + (_swin & 0x0F) ) @@ -206,11 +211,13 @@ def on_art_poll_reply(self, addr: DatagramAddr, data: bytes) -> None: if _type & 0b10000000: outu = self.client._get_create_universe(out_port_addr) portList.append( - ArtNetPort(nn, False, _type & 0x1F, out_port_addr, outu) + ArtNetPort(nn, False, _type & 0x1F, out_port_addr, outu, _goodout) ) if _type & 0b01000000: inu = self.client._get_create_universe(in_port_addr) - portList.append(ArtNetPort(nn, True, _type & 0x1F, in_port_addr, inu)) + portList.append( + ArtNetPort(nn, True, _type & 0x1F, in_port_addr, inu, 0) + ) # track which 'pages' of port bindings we have seen old_ports = nn._portBinds[bindindex] @@ -284,6 +291,44 @@ def on_art_dmx(self, addr: DatagramAddr, data: bytes) -> None: if u in self.client._subscribing: self.client._dispatch_event(UniverseDMX(u, channel_data)) + def on_art_tod_request(self, addr: DatagramAddr, data: bytes) -> None: + (ver,) = struct.unpack(" universe address + for universe in [int(x) + net << 8 for x in data[14 : 14 + address_count]]: + logger.debug( + f"Received Art-Net TOD request: ver {ver} cmd {cmd} universe {universe} from {addr}" + ) + # TODO: if we are an output, answer this with our cached tod table by sending + # art_tod_data packets + + def on_art_tod_data(self, addr: DatagramAddr, data: bytes) -> None: + ver, rdm_ver, port = struct.unpack(" None: while True: await asyncio.sleep(0.1) @@ -292,6 +337,8 @@ async def art_poll_task(self) -> None: for u in self.client._publishing: if t > u._last_publish + 1.0: self._send_art_dmx(u) + if t > u._last_tod_request + 5.0: + self._send_art_tod_request(u) if t > self._last_poll + 2.0: self._send_art_poll() @@ -422,6 +469,37 @@ def _send_art_dmx_subscriber( if self.transport: self.transport.sendto(message, addr=(node.ip, node.udpport)) + def _send_art_tod_request(self, universe: ArtNetUniverse) -> None: + logger.debug(f"sending art tod request for {universe}") + universe._last_tod_request = time.time() + + subuni = universe.portaddress & 0xFF + net = universe.portaddress >> 8 + message = ARTNET_PREFIX + struct.pack( + " None: logger.warn("Error received:", exc) @@ -540,6 +618,7 @@ def set_port_config( universe: UniverseKey, is_input: bool = False, is_output: bool = False, + rdm: bool = False, ) -> ArtNetUniverse: port_addr = self._parse_universe(universe) @@ -560,7 +639,12 @@ def set_port_config( if is_input or is_output: port = ArtNetPort( - node=None, is_input=is_input, media=0, portaddr=port_addr, universe=u + node=None, + is_input=is_input, + media=0, + portaddr=port_addr, + universe=u, + flags=0, ) self.ports.append(port) logger.debug(f"configured own port {port}") diff --git a/aioartnet/main.py b/aioartnet/main.py index dd4f653..1d737b9 100644 --- a/aioartnet/main.py +++ b/aioartnet/main.py @@ -23,6 +23,8 @@ async def main(client: ArtNetClient) -> None: parser.add_argument("-i", "--interface") parser.add_argument("-n", "--portName") parser.add_argument("-p", "--publish", action="store_true") + parser.add_argument("-r", "--rdm", action="store_true") + args = parser.parse_args() level = {False: logging.INFO, True: logging.DEBUG}[args.verbose] @@ -35,7 +37,7 @@ async def main(client: ArtNetClient) -> None: kwargs["portName"] = args.portName client = ArtNetClient(**kwargs) if args.publish: - u1 = client.set_port_config("0:0:0", is_input=True) + u1 = client.set_port_config("0:0:0", is_input=True, rdm=args.rdm) u1.set_dmx(bytes(list(range(128)) * 4)) asyncio.run(main(client)) asyncio.get_event_loop().run_forever() diff --git a/aioartnet/models.py b/aioartnet/models.py index 0501d55..678cd86 100644 --- a/aioartnet/models.py +++ b/aioartnet/models.py @@ -1,6 +1,8 @@ from collections import defaultdict from dataclasses import dataclass -from typing import Any, Optional, Protocol, Tuple, Union +from typing import Any, Optional, Protocol, Sequence, Tuple, Union + +from .rdm import RDMDevice DMX_UNIVERSE_SIZE = 512 @@ -43,8 +45,10 @@ def __init__(self, portaddress: int, client: UnivActuator): self.last_data = bytearray(DMX_UNIVERSE_SIZE) self._last_seq = 1 self._last_publish: float = 0.0 + self._last_tod_request: float = 0.0 self.publisherseq: dict[Tuple[DatagramAddr, int], int] = {} self._client = client + self._tod: dict[bytes, RDMDevice] = {} def split(self) -> Tuple[int, int, int]: # name net:sub_net:universe @@ -66,6 +70,9 @@ def set_dmx(self, data: bytes) -> None: def get_dmx(self) -> bytes: return self.last_data + def get_rdm_uuids(self) -> Sequence[bytes]: + return list(self._tod.keys()) + # eq/hash based on portaddress only def __hash__(self) -> int: return hash(self.portaddress) @@ -84,6 +91,7 @@ class ArtNetPort: media: int portaddr: int universe: ArtNetUniverse + flags: int def __repr__(self) -> str: inout = {True: "Input", False: "Output"}[self.is_input] diff --git a/aioartnet/rdm.py b/aioartnet/rdm.py new file mode 100644 index 0000000..e54b8ee --- /dev/null +++ b/aioartnet/rdm.py @@ -0,0 +1,44 @@ +from dataclasses import dataclass +from typing import Sequence + +# Can be subclassed to mock responses to more complex RDM queries. Built in has: +# RDM uuid +# DMX base address +# DMX profile name +# DMX channel count + + +@dataclass +class RDMDevice: + def __init__(self, uuid: bytes): + self.uuid = uuid + self.dmx_base_address = 0 + self.dmx_profile_name = "" + self.dmx_profile_width = 1 + + +# the RDM interrogator is installed into the ArtNetClient, since it might need to be configurable +# by default, we get the device uuids and ignore them. We also have a standard one that will fetch +# the base address, profile name and channel count of any discovered fixtures, and dispatch events +# for them. +class RDMInterrogator: + def on_uuids(self, uuids: Sequence[bytes]) -> None: + self.uuids = uuids + + def poll(self) -> None: + # do nothing + pass + + def on_rdm_response(self, data: bytes) -> None: + pass + + +class RDMResponder: + def __init__(self) -> None: + self.devices: list[RDMDevice] = [] + + def get_tod_uuids(self) -> Sequence[RDMDevice]: + return self.devices + + def answer_rdm(self, data: bytes) -> bytes: + return bytes() From 651451f08add3b3302cce12178a2495ca176e837 Mon Sep 17 00:00:00 2001 From: Chris Shucksmith Date: Mon, 30 Jun 2025 11:02:46 +0100 Subject: [PATCH 2/2] add RDM logo --- README.md | 5 ++--- docs/rdm-dmx-logo.jpg | Bin 0 -> 5696 bytes 2 files changed, 2 insertions(+), 3 deletions(-) create mode 100644 docs/rdm-dmx-logo.jpg diff --git a/README.md b/README.md index 364173d..161d722 100644 --- a/README.md +++ b/README.md @@ -159,9 +159,8 @@ DMX Remote Device Management (RDM) Support The Art-Net protocol can carry RDM messages to allow for table-of-device enumeration, and then inspection of the devices by uid. -https://www.rdmprotocol.org/rdm/wp-content/uploads/2011/09/logo2.jpg - See https://www.rdmprotocol.org/rdm/ -RDM specs are https://tsp.esta.org/tsp/documents/published_docs.php +![RDM-DMX logo](./docs/rdm-dmx-logo.jpg) +RDM specs are https://tsp.esta.org/tsp/documents/published_docs.php diff --git a/docs/rdm-dmx-logo.jpg b/docs/rdm-dmx-logo.jpg new file mode 100644 index 0000000000000000000000000000000000000000..b3886564d160df41dd1e1c018325f33dd6175b55 GIT binary patch literal 5696 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 literal 0 HcmV?d00001