diff --git a/README.md b/README.md index 30e00db..161d722 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,15 @@ 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. + +See https://www.rdmprotocol.org/rdm/ + +![RDM-DMX logo](./docs/rdm-dmx-logo.jpg) + +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() diff --git a/docs/rdm-dmx-logo.jpg b/docs/rdm-dmx-logo.jpg new file mode 100644 index 0000000..b388656 Binary files /dev/null and b/docs/rdm-dmx-logo.jpg differ