Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 15 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand All @@ -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
Expand All @@ -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
94 changes: 89 additions & 5 deletions aioartnet/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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)
)
Expand All @@ -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]
Expand Down Expand Up @@ -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("<H", data[0:2])
net = data[11]
cmd = data[12]
address_count = data[13]
# net+address => 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("<HBB", data[0:4])
# 6 bytes spare
bind_index, net, response, address, tot_uid, block_count, uid_count = (
struct.unpack("<BBBBHBB", data[10:18])
)
portaddress = net << 8 + address
TOD_FULL = 0

if response != TOD_FULL:
logger.info(
"Got unexpected art_tod_data with response {response} from {addr}"
)
return

u = self.client._get_create_universe(portaddress)

# I think tot_uid and block_count are *paging* related if more tod UIDs than can fit in a packet
for i in range(uid_count):
uid = data[18 + i * 6 : 18 + (i + 1) * 6]
u._tod[uid] = RDMDevice(uid)
logger.info(
f"Received Art-Net TOD entry: universe {portaddress} uid={uid.hex()} from {addr}"
)

async def art_poll_task(self) -> None:
while True:
await asyncio.sleep(0.1)
Expand All @@ -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()
Expand Down Expand Up @@ -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(
"<HBBBBBBBBBBBBBBB",
0x8000,
0,
14,
0,
0,
0,
0,
0,
0,
0,
0,
0,
net,
0, # command TodFull
1,
subuni,
)

if self.transport:
# send direct to node?
# self.transport.sendto(message, addr=(node.ip, node.udpport))
self.transport.sendto(message, addr=(self.client.broadcast_ip, ARTNET_PORT))

def error_received(self, exc: Exception) -> None:
logger.warn("Error received:", exc)

Expand Down Expand Up @@ -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)

Expand All @@ -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}")
Expand Down
4 changes: 3 additions & 1 deletion aioartnet/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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()
10 changes: 9 additions & 1 deletion aioartnet/models.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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]
Expand Down
44 changes: 44 additions & 0 deletions aioartnet/rdm.py
Original file line number Diff line number Diff line change
@@ -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()
Binary file added docs/rdm-dmx-logo.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.