Skip to content

Commit e1c469d

Browse files
committed
Add LLRP discovery (untested)
1 parent 8828fb0 commit e1c469d

File tree

3 files changed

+328
-4
lines changed

3 files changed

+328
-4
lines changed

tui/llrp.py

Lines changed: 277 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,277 @@
1+
# Copyright (C) 2025 vanous
2+
#
3+
# This file is part of PollToMVR.
4+
#
5+
# This program is free software: you can redistribute it and/or modify
6+
# it under the terms of the GNU Affero General Public License as published by
7+
# the Free Software Foundation, either version 3 of the License, or
8+
# (at your option) any later version.
9+
#
10+
# This program is distributed in the hope that it will be useful,
11+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
# GNU Affero General Public License for more details.
14+
#
15+
# You should have received a copy of the GNU Affero General Public License
16+
# along with this program. If not, see <http://www.gnu.org/licenses/>.
17+
18+
import random
19+
import select
20+
import socket
21+
import struct
22+
import time
23+
import uuid
24+
25+
26+
LLRP_REQUEST_GRP = "239.255.250.133"
27+
LLRP_RESPONSE_GRP = "239.255.250.134"
28+
LLRP_PORT = 5569
29+
LLRP_BROADCAST_CID = "fbad822c-bd0c-4d4c-bdc8-7eabebc85aff"
30+
31+
VECTOR_ROOT_LLRP = 0x0000000A
32+
VECTOR_LLRP_PROBE_REQUEST = 0x00000001
33+
VECTOR_LLRP_PROBE_REPLY = 0x00000002
34+
VECTOR_LLRP_RDM_CMD = 0x00000003
35+
36+
VECTOR_PROBE_REQUEST_DATA = 0x01
37+
VECTOR_PROBE_REPLY_DATA = 0x01
38+
VECTOR_RDM_CMD_RDM_DATA = 0xCC
39+
40+
E120_GET_COMMAND = 0x20
41+
E120_GET_COMMAND_RESPONSE = 0x21
42+
E120_DEVICE_LABEL = 0x0082
43+
44+
RDM_START_CODE = 0xCC
45+
RDM_SUB_START_CODE = 0x01
46+
47+
ACN_PACKET_IDENTIFIER = b"ASC-E1.17\x00\x00\x00"
48+
49+
50+
def _flags_length(length: int) -> bytes:
51+
return bytes(
52+
[(0x70 | ((length >> 16) & 0x0F)), (length >> 8) & 0xFF, length & 0xFF]
53+
)
54+
55+
56+
def _rdm_checksum(data: bytes) -> int:
57+
return sum(data) & 0xFFFF
58+
59+
60+
def _build_probe_request(manager_cid: uuid.UUID, transaction: int) -> bytes:
61+
lower_uid = b"\x00" * 6
62+
upper_uid = b"\xff" * 6
63+
llrp_filter = 0x0000
64+
65+
probe_len = 3 + 1 + 6 + 6 + 2
66+
llrp_len = 3 + 4 + 16 + 4 + probe_len
67+
root_len = 3 + 4 + 16 + llrp_len
68+
69+
preamble = struct.pack(">HH12s", 0x0010, 0x0000, ACN_PACKET_IDENTIFIER)
70+
root_pdu = _flags_length(root_len) + struct.pack(
71+
">I16s", VECTOR_ROOT_LLRP, manager_cid.bytes
72+
)
73+
llrp_pdu = _flags_length(llrp_len) + struct.pack(
74+
">I16sI",
75+
VECTOR_LLRP_PROBE_REQUEST,
76+
uuid.UUID(LLRP_BROADCAST_CID).bytes,
77+
transaction,
78+
)
79+
probe_pdu = _flags_length(probe_len) + struct.pack(
80+
">B6s6sH", VECTOR_PROBE_REQUEST_DATA, lower_uid, upper_uid, llrp_filter
81+
)
82+
return preamble + root_pdu + llrp_pdu + probe_pdu
83+
84+
85+
def _build_rdm_get_label(
86+
manager_cid: uuid.UUID,
87+
target_cid: bytes,
88+
manager_uid: bytes,
89+
target_uid: bytes,
90+
transaction: int,
91+
rdm_transaction: int,
92+
) -> bytes:
93+
message_length = 24
94+
rdm_message = bytearray([RDM_START_CODE, RDM_SUB_START_CODE, message_length])
95+
rdm_message.extend(target_uid)
96+
rdm_message.extend(manager_uid)
97+
rdm_message.extend([rdm_transaction & 0xFF, 1, 0])
98+
rdm_message.extend(b"\x00\x00")
99+
rdm_message.append(E120_GET_COMMAND)
100+
rdm_message.extend(struct.pack(">H", E120_DEVICE_LABEL))
101+
rdm_message.append(0x00)
102+
103+
checksum = _rdm_checksum(rdm_message)
104+
rdm_message_no_sc = rdm_message[1:]
105+
rdm_message_no_sc.extend(struct.pack(">H", checksum))
106+
107+
rdm_pdu_len = 3 + 1 + len(rdm_message_no_sc)
108+
llrp_len = 3 + 4 + 16 + 4 + rdm_pdu_len
109+
root_len = 3 + 4 + 16 + llrp_len
110+
111+
preamble = struct.pack(">HH12s", 0x0010, 0x0000, ACN_PACKET_IDENTIFIER)
112+
root_pdu = _flags_length(root_len) + struct.pack(
113+
">I16s", VECTOR_ROOT_LLRP, manager_cid.bytes
114+
)
115+
llrp_pdu = _flags_length(llrp_len) + struct.pack(
116+
">I16sI", VECTOR_LLRP_RDM_CMD, target_cid, transaction
117+
)
118+
rdm_pdu = (
119+
_flags_length(rdm_pdu_len)
120+
+ struct.pack(">B", VECTOR_RDM_CMD_RDM_DATA)
121+
+ rdm_message_no_sc
122+
)
123+
return preamble + root_pdu + llrp_pdu + rdm_pdu
124+
125+
126+
def _parse_probe_reply(data: bytes):
127+
if len(data) < 16 + 23 + 27 + 17:
128+
return None
129+
130+
offset = 16
131+
root_vector = struct.unpack(">I", data[offset + 3 : offset + 7])[0]
132+
if root_vector != VECTOR_ROOT_LLRP:
133+
return None
134+
sender_cid = data[offset + 7 : offset + 23]
135+
136+
offset += 23
137+
llrp_vector = struct.unpack(">I", data[offset + 3 : offset + 7])[0]
138+
if llrp_vector != VECTOR_LLRP_PROBE_REPLY:
139+
return None
140+
141+
offset += 27
142+
if data[offset + 3] != VECTOR_PROBE_REPLY_DATA:
143+
return None
144+
145+
uid = data[offset + 4 : offset + 10]
146+
hw = data[offset + 10 : offset + 16]
147+
comp_type = data[offset + 16]
148+
return sender_cid, uid, hw, comp_type
149+
150+
151+
def _parse_rdm_label_response(data: bytes):
152+
if len(data) < 16 + 23 + 27 + 4:
153+
return None
154+
155+
offset = 16 + 23
156+
llrp_vector = struct.unpack(">I", data[offset + 3 : offset + 7])[0]
157+
if llrp_vector != VECTOR_LLRP_RDM_CMD:
158+
return None
159+
160+
offset += 27
161+
if data[offset + 3] != VECTOR_RDM_CMD_RDM_DATA:
162+
return None
163+
164+
rdm = data[offset + 4 :]
165+
if len(rdm) < 24:
166+
return None
167+
168+
command_class = rdm[20]
169+
pid = struct.unpack(">H", rdm[21:23])[0]
170+
pdl = rdm[23]
171+
if command_class != E120_GET_COMMAND_RESPONSE or pid != E120_DEVICE_LABEL:
172+
return None
173+
174+
label = rdm[24 : 24 + pdl].decode("ascii", errors="ignore").strip("\x00")
175+
return label
176+
177+
178+
class LlrpDiscovery:
179+
def __init__(self, bind_ip: str | None = None, manufacturer_id: int = 0x7FF0):
180+
self.bind_ip = bind_ip or "0.0.0.0"
181+
self.manager_cid = uuid.uuid4()
182+
device_id = random.getrandbits(32)
183+
self.manager_uid = struct.pack(">H", manufacturer_id & 0xFFFF) + struct.pack(
184+
">I", device_id
185+
)
186+
self.rx_socket = None
187+
self.tx_socket = None
188+
189+
def start(self):
190+
self.rx_socket = socket.socket(
191+
socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP
192+
)
193+
self.rx_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
194+
self.rx_socket.bind((self.bind_ip, LLRP_PORT))
195+
mreq = socket.inet_aton(LLRP_RESPONSE_GRP) + socket.inet_aton(self.bind_ip)
196+
self.rx_socket.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq)
197+
198+
self.tx_socket = socket.socket(
199+
socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP
200+
)
201+
self.tx_socket.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 1)
202+
if self.bind_ip != "0.0.0.0":
203+
self.tx_socket.setsockopt(
204+
socket.IPPROTO_IP,
205+
socket.IP_MULTICAST_IF,
206+
socket.inet_aton(self.bind_ip),
207+
)
208+
209+
def stop(self):
210+
if self.rx_socket:
211+
self.rx_socket.close()
212+
if self.tx_socket:
213+
self.tx_socket.close()
214+
215+
def discover_devices(self, timeout: float = 1.5):
216+
devices = {}
217+
transaction = int(time.time()) & 0xFFFFFFFF
218+
probe = _build_probe_request(self.manager_cid, transaction=transaction)
219+
self.tx_socket.sendto(probe, (LLRP_REQUEST_GRP, LLRP_PORT))
220+
221+
deadline = time.time() + timeout
222+
while time.time() < deadline:
223+
remaining = deadline - time.time()
224+
r, _, _ = select.select([self.rx_socket], [], [], min(0.1, remaining))
225+
if not r:
226+
continue
227+
data, addr = self.rx_socket.recvfrom(1500)
228+
parsed = _parse_probe_reply(data)
229+
if not parsed:
230+
continue
231+
sender_cid, uid, _hw, comp_type = parsed
232+
ip = addr[0]
233+
if ip in devices:
234+
continue
235+
devices[ip] = {
236+
"source_ip": ip,
237+
"short_name": "",
238+
"long_name": "",
239+
"uid": ":".join(f"{b:02x}" for b in uid),
240+
"component_type": comp_type,
241+
"target_cid": sender_cid,
242+
"target_uid": uid,
243+
}
244+
245+
if not devices:
246+
return []
247+
248+
for index, device in enumerate(devices.values()):
249+
rdm_packet = _build_rdm_get_label(
250+
manager_cid=self.manager_cid,
251+
target_cid=device["target_cid"],
252+
manager_uid=self.manager_uid,
253+
target_uid=device["target_uid"],
254+
transaction=(transaction + index + 1) & 0xFFFFFFFF,
255+
rdm_transaction=index + 1,
256+
)
257+
self.tx_socket.sendto(rdm_packet, (LLRP_REQUEST_GRP, LLRP_PORT))
258+
259+
label_deadline = time.time() + timeout
260+
while time.time() < label_deadline:
261+
remaining = label_deadline - time.time()
262+
r, _, _ = select.select([self.rx_socket], [], [], min(0.1, remaining))
263+
if not r:
264+
continue
265+
data, addr = self.rx_socket.recvfrom(1500)
266+
label = _parse_rdm_label_response(data)
267+
if not label:
268+
continue
269+
ip = addr[0]
270+
if ip in devices:
271+
devices[ip]["short_name"] = label
272+
273+
for device in devices.values():
274+
device.pop("target_cid", None)
275+
device.pop("target_uid", None)
276+
277+
return list(devices.values())

tui/rdm_search.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,20 @@
1+
# Copyright (C) 2025 vanous
2+
#
3+
# This file is part of PollToMVR.
4+
#
5+
# This program is free software: you can redistribute it and/or modify
6+
# it under the terms of the GNU Affero General Public License as published by
7+
# the Free Software Foundation, either version 3 of the License, or
8+
# (at your option) any later version.
9+
#
10+
# This program is distributed in the hope that it will be useful,
11+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
# GNU Affero General Public License for more details.
14+
#
15+
# You should have received a copy of the GNU Affero General Public License
16+
# along with this program. If not, see <http://www.gnu.org/licenses/>.
17+
118
import serial
219
import time
320
import struct

tui/screens.py

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
)
3030
from tui.network import get_network_cards
3131
from tui.artnet import ArtNetDiscovery
32+
from tui.llrp import LlrpDiscovery
3233
from tui.rdm_search import get_device_info, get_devices, get_port, get_device_details
3334
import re
3435
import sys
@@ -260,11 +261,40 @@ async def run_network_discovery(self) -> str:
260261
results_widget.update(
261262
f"Searching... timeout is {self.app.configuration.artnet_timeout} sec."
262263
)
263-
discovery = ArtNetDiscovery(bind_ip=self.network)
264-
discovery.start()
265264
timeout = float(self.app.configuration.artnet_timeout)
266-
result = discovery.discover_devices(timeout=timeout)
267-
discovery.stop() # not really needed, as the thread will close...
265+
artnet = ArtNetDiscovery(bind_ip=self.network)
266+
artnet.start()
267+
artnet_result = artnet.discover_devices(timeout=timeout)
268+
artnet.stop()
269+
270+
llrp_result = []
271+
try:
272+
llrp = LlrpDiscovery(bind_ip=self.network)
273+
llrp.start()
274+
llrp_result = llrp.discover_devices(timeout=timeout)
275+
llrp.stop()
276+
except Exception as llrp_error:
277+
print(f"LLRP discovery failed: {llrp_error}")
278+
279+
device_map = {}
280+
for device in artnet_result:
281+
ip_key = device.get("source_ip") or device.get("reported_ip")
282+
if ip_key:
283+
device_map[ip_key] = device
284+
for device in llrp_result:
285+
ip_key = device.get("source_ip")
286+
if not ip_key:
287+
continue
288+
if ip_key in device_map:
289+
if not device_map[ip_key].get("short_name"):
290+
device_map[ip_key]["short_name"] = device.get("short_name", "")
291+
if not device_map[ip_key].get("long_name"):
292+
device_map[ip_key]["long_name"] = device.get("long_name", "")
293+
device_map[ip_key].setdefault("uid", device.get("uid"))
294+
else:
295+
device_map[ip_key] = device
296+
297+
result = list(device_map.values())
268298
self.post_message(NetworkDevicesDiscovered(devices=result))
269299
self.post_message(RdmDiscoveryMessage(label="Discover", disabled=False))
270300
except Exception as e:

0 commit comments

Comments
 (0)