Skip to content

Commit 0c98e18

Browse files
committed
wip: art-net RDM table-of-devices support
1 parent ddacdcb commit 0c98e18

File tree

5 files changed

+161
-10
lines changed

5 files changed

+161
-10
lines changed

README.md

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ Features
114114
| 15-bit port addresses | :heavy_check_mark: | :heavy_check_mark: |
115115
| >4 ports (bindIndex) on same IP | :heavy_check_mark: | :heavy_check_mark: |
116116
| merge-mode (LTP/HTP) in reciever | - | - |
117-
| RDM commands to enumerate fixtures | - | - |
117+
| RDM commands to enumerate fixtures | :heavy_check_mark: | :heavy_check_mark: |
118118
| Timecode | - | - |
119119
| Multi-universe sync message | - | - |
120120
| local node reconfigure by API | :heavy_check_mark: | :heavy_check_mark: |
@@ -135,9 +135,11 @@ Implemented Messages
135135
| ArtDiagData | - | - |
136136
| ArtTimeCode | - | - |
137137
| ArtCommand | - | - |
138+
| ArtNzs | - | - |
138139
| ArtTrigger | - | - |
139140
| ArtSync | - | - |
140-
| RDM ArtTodRequest / ArtTodData / ArtTodControl / ArtRdm / ArtRdmSub | - | - |
141+
| RDM ArtTodRequest / ArtTodData / ArtTodControl | :heavy_check_mark: | :heavy_check_mark: |
142+
| RDM ArtRdm / ArtRdmSub | - | - |
141143

142144

143145
Art-Net
@@ -150,5 +152,16 @@ This application aims to be fully compatible with Art-Net devices. We have teste
150152
* iOS [DMX Monitor app](https://apps.apple.com/us/app/dmx-monitor/id1544911427)
151153
* Many other iOS apps
152154

153-
154155
![Art-Net logo](./docs/art-net-master-logo.svg) Art-Net™ Designed by and Copyright Artistic Licence Engineering Ltd.
156+
157+
DMX Remote Device Management (RDM) Support
158+
---
159+
160+
The Art-Net protocol can carry RDM messages to allow for table-of-device enumeration, and then inspection of the devices by uid.
161+
162+
https://www.rdmprotocol.org/rdm/wp-content/uploads/2011/09/logo2.jpg
163+
164+
See https://www.rdmprotocol.org/rdm/
165+
166+
RDM specs are https://tsp.esta.org/tsp/documents/published_docs.php
167+

aioartnet/client.py

Lines changed: 89 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
DatagramAddr,
2626
)
2727
from .network import AF_PACKET, getifaddrs
28+
from .rdm import RDMDevice
2829

2930
# Art-Net implementation for Python asyncio
3031
# Any page references to 'spec' refer to
@@ -98,6 +99,8 @@ def __init__(self, client: "ArtNetClient"):
9899
0x2000: self.on_art_poll,
99100
0x2100: self.on_art_poll_reply,
100101
0x5000: self.on_art_dmx,
102+
0x8000: self.on_art_tod_request,
103+
0x8100: self.on_art_tod_data,
101104
}
102105
client.protocol = self
103106
self.node_report_counter = 0
@@ -115,7 +118,7 @@ def datagram_received(self, data: bytes, addr: DatagramAddr) -> None:
115118
if h:
116119
h(addr, data[10:])
117120
else:
118-
logger.debug(
121+
logger.info(
119122
f"Received unsupported Art-Net: op {hex(opcode)} from {addr}: {data[10:]!r}"
120123
)
121124
else:
@@ -196,7 +199,9 @@ def on_art_poll_reply(self, addr: DatagramAddr, data: bytes) -> None:
196199

197200
# iterate through the ports and create ports and universes
198201
portList = []
199-
for _type, _in, _out, _swin, _swout in zip(ptype, ins, outs, swin, swout):
202+
for _type, _in, _out, _swin, _swout, _goodout in zip(
203+
ptype, ins, outs, swin, swout, goodout
204+
):
200205
in_port_addr = (
201206
((netsw & 0x7F) << 8) + ((subsw & 0x0F) << 4) + (_swin & 0x0F)
202207
)
@@ -206,11 +211,13 @@ def on_art_poll_reply(self, addr: DatagramAddr, data: bytes) -> None:
206211
if _type & 0b10000000:
207212
outu = self.client._get_create_universe(out_port_addr)
208213
portList.append(
209-
ArtNetPort(nn, False, _type & 0x1F, out_port_addr, outu)
214+
ArtNetPort(nn, False, _type & 0x1F, out_port_addr, outu, _goodout)
210215
)
211216
if _type & 0b01000000:
212217
inu = self.client._get_create_universe(in_port_addr)
213-
portList.append(ArtNetPort(nn, True, _type & 0x1F, in_port_addr, inu))
218+
portList.append(
219+
ArtNetPort(nn, True, _type & 0x1F, in_port_addr, inu, 0)
220+
)
214221

215222
# track which 'pages' of port bindings we have seen
216223
old_ports = nn._portBinds[bindindex]
@@ -284,6 +291,44 @@ def on_art_dmx(self, addr: DatagramAddr, data: bytes) -> None:
284291
if u in self.client._subscribing:
285292
self.client._dispatch_event(UniverseDMX(u, channel_data))
286293

294+
def on_art_tod_request(self, addr: DatagramAddr, data: bytes) -> None:
295+
(ver,) = struct.unpack("<H", data[0:2])
296+
net = data[11]
297+
cmd = data[12]
298+
address_count = data[13]
299+
# net+address => universe address
300+
for universe in [int(x) + net << 8 for x in data[14 : 14 + address_count]]:
301+
logger.debug(
302+
f"Received Art-Net TOD request: ver {ver} cmd {cmd} universe {universe} from {addr}"
303+
)
304+
# TODO: if we are an output, answer this with our cached tod table by sending
305+
# art_tod_data packets
306+
307+
def on_art_tod_data(self, addr: DatagramAddr, data: bytes) -> None:
308+
ver, rdm_ver, port = struct.unpack("<HBB", data[0:4])
309+
# 6 bytes spare
310+
bind_index, net, response, address, tot_uid, block_count, uid_count = (
311+
struct.unpack("<BBBBHBB", data[10:18])
312+
)
313+
portaddress = net << 8 + address
314+
TOD_FULL = 0
315+
316+
if response != TOD_FULL:
317+
logger.info(
318+
"Got unexpected art_tod_data with response {response} from {addr}"
319+
)
320+
return
321+
322+
u = self.client._get_create_universe(portaddress)
323+
324+
# I think tot_uid and block_count are *paging* related if more tod UIDs than can fit in a packet
325+
for i in range(uid_count):
326+
uid = data[18 + i * 6 : 18 + (i + 1) * 6]
327+
u._tod[uid] = RDMDevice(uid)
328+
logger.info(
329+
f"Received Art-Net TOD entry: universe {portaddress} uid={uid.hex()} from {addr}"
330+
)
331+
287332
async def art_poll_task(self) -> None:
288333
while True:
289334
await asyncio.sleep(0.1)
@@ -292,6 +337,8 @@ async def art_poll_task(self) -> None:
292337
for u in self.client._publishing:
293338
if t > u._last_publish + 1.0:
294339
self._send_art_dmx(u)
340+
if t > u._last_tod_request + 5.0:
341+
self._send_art_tod_request(u)
295342

296343
if t > self._last_poll + 2.0:
297344
self._send_art_poll()
@@ -422,6 +469,37 @@ def _send_art_dmx_subscriber(
422469
if self.transport:
423470
self.transport.sendto(message, addr=(node.ip, node.udpport))
424471

472+
def _send_art_tod_request(self, universe: ArtNetUniverse) -> None:
473+
logger.debug(f"sending art tod request for {universe}")
474+
universe._last_tod_request = time.time()
475+
476+
subuni = universe.portaddress & 0xFF
477+
net = universe.portaddress >> 8
478+
message = ARTNET_PREFIX + struct.pack(
479+
"<HBBBBBBBBBBBBBBB",
480+
0x8000,
481+
0,
482+
14,
483+
0,
484+
0,
485+
0,
486+
0,
487+
0,
488+
0,
489+
0,
490+
0,
491+
0,
492+
net,
493+
0, # command TodFull
494+
1,
495+
subuni,
496+
)
497+
498+
if self.transport:
499+
# send direct to node?
500+
# self.transport.sendto(message, addr=(node.ip, node.udpport))
501+
self.transport.sendto(message, addr=(self.client.broadcast_ip, ARTNET_PORT))
502+
425503
def error_received(self, exc: Exception) -> None:
426504
logger.warn("Error received:", exc)
427505

@@ -540,6 +618,7 @@ def set_port_config(
540618
universe: UniverseKey,
541619
is_input: bool = False,
542620
is_output: bool = False,
621+
rdm: bool = False,
543622
) -> ArtNetUniverse:
544623
port_addr = self._parse_universe(universe)
545624

@@ -560,7 +639,12 @@ def set_port_config(
560639

561640
if is_input or is_output:
562641
port = ArtNetPort(
563-
node=None, is_input=is_input, media=0, portaddr=port_addr, universe=u
642+
node=None,
643+
is_input=is_input,
644+
media=0,
645+
portaddr=port_addr,
646+
universe=u,
647+
flags=0,
564648
)
565649
self.ports.append(port)
566650
logger.debug(f"configured own port {port}")

aioartnet/main.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ async def main(client: ArtNetClient) -> None:
2323
parser.add_argument("-i", "--interface")
2424
parser.add_argument("-n", "--portName")
2525
parser.add_argument("-p", "--publish", action="store_true")
26+
parser.add_argument("-r", "--rdm", action="store_true")
27+
2628
args = parser.parse_args()
2729

2830
level = {False: logging.INFO, True: logging.DEBUG}[args.verbose]
@@ -35,7 +37,7 @@ async def main(client: ArtNetClient) -> None:
3537
kwargs["portName"] = args.portName
3638
client = ArtNetClient(**kwargs)
3739
if args.publish:
38-
u1 = client.set_port_config("0:0:0", is_input=True)
40+
u1 = client.set_port_config("0:0:0", is_input=True, rdm=args.rdm)
3941
u1.set_dmx(bytes(list(range(128)) * 4))
4042
asyncio.run(main(client))
4143
asyncio.get_event_loop().run_forever()

aioartnet/models.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
from collections import defaultdict
22
from dataclasses import dataclass
3-
from typing import Any, Optional, Protocol, Tuple, Union
3+
from typing import Any, Optional, Protocol, Sequence, Tuple, Union
4+
5+
from .rdm import RDMDevice
46

57
DMX_UNIVERSE_SIZE = 512
68

@@ -43,8 +45,10 @@ def __init__(self, portaddress: int, client: UnivActuator):
4345
self.last_data = bytearray(DMX_UNIVERSE_SIZE)
4446
self._last_seq = 1
4547
self._last_publish: float = 0.0
48+
self._last_tod_request: float = 0.0
4649
self.publisherseq: dict[Tuple[DatagramAddr, int], int] = {}
4750
self._client = client
51+
self._tod: dict[bytes, RDMDevice] = {}
4852

4953
def split(self) -> Tuple[int, int, int]:
5054
# name net:sub_net:universe
@@ -66,6 +70,9 @@ def set_dmx(self, data: bytes) -> None:
6670
def get_dmx(self) -> bytes:
6771
return self.last_data
6872

73+
def get_rdm_uuids(self) -> Sequence[bytes]:
74+
return list(self._tod.keys())
75+
6976
# eq/hash based on portaddress only
7077
def __hash__(self) -> int:
7178
return hash(self.portaddress)
@@ -84,6 +91,7 @@ class ArtNetPort:
8491
media: int
8592
portaddr: int
8693
universe: ArtNetUniverse
94+
flags: int
8795

8896
def __repr__(self) -> str:
8997
inout = {True: "Input", False: "Output"}[self.is_input]

aioartnet/rdm.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
from dataclasses import dataclass
2+
from typing import Sequence
3+
4+
# Can be subclassed to mock responses to more complex RDM queries. Built in has:
5+
# RDM uuid
6+
# DMX base address
7+
# DMX profile name
8+
# DMX channel count
9+
10+
11+
@dataclass
12+
class RDMDevice:
13+
def __init__(self, uuid: bytes):
14+
self.uuid = uuid
15+
self.dmx_base_address = 0
16+
self.dmx_profile_name = ""
17+
self.dmx_profile_width = 1
18+
19+
20+
# the RDM interrogator is installed into the ArtNetClient, since it might need to be configurable
21+
# by default, we get the device uuids and ignore them. We also have a standard one that will fetch
22+
# the base address, profile name and channel count of any discovered fixtures, and dispatch events
23+
# for them.
24+
class RDMInterrogator:
25+
def on_uuids(self, uuids: Sequence[bytes]) -> None:
26+
self.uuids = uuids
27+
28+
def poll(self) -> None:
29+
# do nothing
30+
pass
31+
32+
def on_rdm_response(self, data: bytes) -> None:
33+
pass
34+
35+
36+
class RDMResponder:
37+
def __init__(self) -> None:
38+
self.devices: list[RDMDevice] = []
39+
40+
def get_tod_uuids(self) -> Sequence[RDMDevice]:
41+
return self.devices
42+
43+
def answer_rdm(self, data: bytes) -> bytes:
44+
return bytes()

0 commit comments

Comments
 (0)