openral-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,464 @@
1
+ """USB VID/PID enumeration and DDS topic discovery for ``openral detect``.
2
+
3
+ This module provides two independent probes:
4
+
5
+ 1. **USB enumeration** — finds USB serial adapters attached to the host and
6
+ matches their VID/PID against a table of known robot controllers. On
7
+ Linux it uses ``pyudev`` (no root required); on macOS it falls back to
8
+ ``system_profiler``; everywhere it also falls back to ``/dev/tty*`` globs.
9
+
10
+ 2. **DDS discovery** — runs ``ros2 topic list`` for a bounded timeout and
11
+ maps observed topic name prefixes to known robot types (Unitree, ALOHA, …).
12
+
13
+ Both probes are pure functions with no global state and can be called from
14
+ tests without hardware.
15
+
16
+ Example:
17
+ >>> from openral_cli.autodetect import enumerate_usb_devices
18
+ >>> devs = enumerate_usb_devices() # returns [] on machines with no USB adapters
19
+ >>> isinstance(devs, list)
20
+ True
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ import platform
27
+ import subprocess
28
+ from glob import glob
29
+ from typing import NamedTuple
30
+
31
+ __all__ = [
32
+ "DdsTopic",
33
+ "KnownDevice",
34
+ "UsbDevice",
35
+ "UsbMatch",
36
+ "enumerate_usb_devices",
37
+ "infer_robot_from_topics",
38
+ "match_known_devices",
39
+ "scan_dds_topics",
40
+ ]
41
+
42
+ # ── Data types ─────────────────────────────────────────────────────────────────
43
+
44
+
45
+ class UsbDevice(NamedTuple):
46
+ """A USB serial device detected on the host.
47
+
48
+ Attributes:
49
+ port: Device path, e.g. ``"/dev/ttyUSB0"``.
50
+ vid: Vendor ID as an integer (0 if unknown).
51
+ pid: Product ID as an integer (0 if unknown).
52
+ description: Human-readable product string from the USB descriptor.
53
+ """
54
+
55
+ port: str
56
+ vid: int
57
+ pid: int
58
+ description: str
59
+
60
+
61
+ class KnownDevice(NamedTuple):
62
+ """A known USB adapter/controller from the VID/PID table.
63
+
64
+ Attributes:
65
+ chip: USB chip name, e.g. ``"CH340"``.
66
+ driver_hint: Human-readable hint about the robot this adapter drives.
67
+ embodiment_tag: openral embodiment tag, e.g. ``"so101_follower"``.
68
+ Empty string means multiple robots are possible with this adapter.
69
+ bh_robot_type: Short robot type string for ``openral connect --robot``.
70
+ Empty string means ambiguous.
71
+ """
72
+
73
+ chip: str
74
+ driver_hint: str
75
+ embodiment_tag: str
76
+ bh_robot_type: str
77
+
78
+
79
+ class UsbMatch(NamedTuple):
80
+ """A detected USB device that matched the known-device table.
81
+
82
+ Attributes:
83
+ device: The detected USB device.
84
+ known: The matching entry from the VID/PID table.
85
+ """
86
+
87
+ device: UsbDevice
88
+ known: KnownDevice
89
+
90
+
91
+ class DdsTopic(NamedTuple):
92
+ """A ROS 2 topic observed during the DDS discovery scan.
93
+
94
+ Attributes:
95
+ name: Full topic name, e.g. ``"/lowstate"``.
96
+ type_name: Message type string, e.g. ``"unitree_go/msg/LowState"``.
97
+ """
98
+
99
+ name: str
100
+ type_name: str
101
+
102
+
103
+ # ── VID/PID table ─────────────────────────────────────────────────────────────
104
+ # Maps (vendor_id, product_id) → KnownDevice.
105
+ # Add entries here as new robot controllers are supported.
106
+
107
+ _VID_PID_TABLE: dict[tuple[int, int], KnownDevice] = {
108
+ # ── CH34x USB-serial chips (WCH Semiconductor) ───────────────────────────
109
+ # Used on cheap USB-to-TTL dongles, the SO-100/SO-101 control boards, LeKiwi.
110
+ # The SO-101 is electrically identical to the SO-100 over USB (same Feetech
111
+ # controller / VID/PID), so the bus alone cannot distinguish them. The SO-101
112
+ # is the current revision, so a bare plug-in defaults to `so101_follower`;
113
+ # an SO-100 is selected explicitly with `openral detect --robot so100`.
114
+ (0x1A86, 0x7523): KnownDevice(
115
+ "CH340",
116
+ "Feetech serial bus — SO-100 / SO-101 / Koch / LeKiwi arm",
117
+ "so101_follower",
118
+ "so101",
119
+ ),
120
+ (0x1A86, 0x7522): KnownDevice(
121
+ "CH340C/K",
122
+ "Feetech serial bus — SO-100 / SO-101 / Koch / LeKiwi arm",
123
+ "so101_follower",
124
+ "so101",
125
+ ),
126
+ (0x1A86, 0x55D3): KnownDevice(
127
+ "CH343",
128
+ "Feetech serial bus — SO-100 / SO-101 / Koch / LeKiwi arm",
129
+ "so101_follower",
130
+ "so101",
131
+ ),
132
+ (0x1A86, 0x55D4): KnownDevice(
133
+ "CH9102",
134
+ "Feetech serial bus — SO-100 / SO-101 / Koch / LeKiwi arm",
135
+ "so101_follower",
136
+ "so101",
137
+ ),
138
+ # ── Silicon Labs CP210x ───────────────────────────────────────────────────
139
+ # Used on many microcontroller dev boards and Feetech adapters.
140
+ (0x10C4, 0xEA60): KnownDevice(
141
+ "CP2102",
142
+ "Feetech / Dynamixel serial bus",
143
+ "so101_follower",
144
+ "so101",
145
+ ),
146
+ (0x10C4, 0xEA6A): KnownDevice(
147
+ "CP2104",
148
+ "Feetech / Dynamixel serial bus",
149
+ "so101_follower",
150
+ "so101",
151
+ ),
152
+ (0x10C4, 0xEA70): KnownDevice(
153
+ "CP2105",
154
+ "dual-port Feetech / Dynamixel serial bus",
155
+ "so101_follower",
156
+ "so101",
157
+ ),
158
+ # ── FTDI ─────────────────────────────────────────────────────────────────
159
+ # Used by Dynamixel USB2Dynamixel, OpenCM 9.04, ALOHA leader/follower.
160
+ (0x0403, 0x6001): KnownDevice(
161
+ "FT232RL",
162
+ "Dynamixel USB2Dynamixel — ALOHA / OpenManipulator",
163
+ "aloha",
164
+ "", # ambiguous until topic scan
165
+ ),
166
+ (0x0403, 0x6010): KnownDevice(
167
+ "FT2232H",
168
+ "Dynamixel / custom dual-port",
169
+ "",
170
+ "",
171
+ ),
172
+ (0x0403, 0x6014): KnownDevice(
173
+ "FT232H",
174
+ "Dynamixel / custom single-port",
175
+ "",
176
+ "",
177
+ ),
178
+ # ── Arduino ──────────────────────────────────────────────────────────────
179
+ (0x2341, 0x0043): KnownDevice(
180
+ "Arduino Uno",
181
+ "custom firmware robot controller",
182
+ "",
183
+ "",
184
+ ),
185
+ (0x2341, 0x0042): KnownDevice(
186
+ "Arduino Mega 2560",
187
+ "custom firmware robot controller",
188
+ "",
189
+ "",
190
+ ),
191
+ # ── STM32 virtual COM port ────────────────────────────────────────────────
192
+ (0x0483, 0x5740): KnownDevice(
193
+ "STM32 VCP",
194
+ "custom STM32 robot controller",
195
+ "",
196
+ "",
197
+ ),
198
+ # ── Unitree robotics ─────────────────────────────────────────────────────
199
+ # Unitree G1/H1/B1 connect over Ethernet (DDS), not USB serial.
200
+ # Their USB port (0x0483:0xDF11) is only used for DFU firmware flashing.
201
+ (0x0483, 0xDF11): KnownDevice(
202
+ "STM32 DFU",
203
+ "Unitree firmware update port (not the control interface — use Ethernet)",
204
+ "unitree_g1",
205
+ "",
206
+ ),
207
+ }
208
+
209
+ # ── DDS topic → robot type map ────────────────────────────────────────────────
210
+ # Maps topic name prefix (lower-cased) → ral robot type string.
211
+
212
+ _TOPIC_ROBOT_MAP: dict[str, str] = {
213
+ "/lowstate": "unitree_g1", # Unitree G1 / H1 / B1 / Go2
214
+ "/lowcmd": "unitree_g1",
215
+ "/sportmodestate": "unitree_g1",
216
+ "/follower_arms_position_goal": "aloha", # HiLSeRL ALOHA / ACT
217
+ "/bimanual_stretch": "aloha",
218
+ "/so101": "so101", # openral SO-101 lifecycle node (current default arm)
219
+ "/so100": "so100", # explicit openral SO-100 lifecycle node
220
+ "/joint_trajectory_controller/joint_trajectory": "ros2_control",
221
+ "/lekiwi": "lekiwi",
222
+ }
223
+
224
+
225
+ # ── USB enumeration ────────────────────────────────────────────────────────────
226
+
227
+
228
+ def _enumerate_linux_pyudev() -> list[UsbDevice]:
229
+ """Enumerate USB serial devices via pyudev (Linux only).
230
+
231
+ Returns:
232
+ List of detected ``UsbDevice`` instances. Empty if pyudev is not
233
+ installed or no USB serial devices are present.
234
+ """
235
+ try:
236
+ import pyudev # noqa: PLC0415 # reason: Linux-only optional dep; lazy import keeps macOS clean
237
+
238
+ ctx = pyudev.Context()
239
+ devices: list[UsbDevice] = []
240
+ for dev in ctx.list_devices(subsystem="tty"):
241
+ # VID/PID/model live on the usb_device node, not the usb_interface
242
+ # — CDC-ACM boards (/dev/ttyACM*, e.g. the SO-101's CH343) leave them
243
+ # unset on the interface, so read them from the usb_device parent.
244
+ parent = dev.find_parent("usb", "usb_device")
245
+ if parent is None:
246
+ continue
247
+ port = dev.get("DEVNAME", "")
248
+ if not port:
249
+ continue
250
+ vid_str = parent.get("ID_VENDOR_ID", "0") or "0"
251
+ pid_str = parent.get("ID_MODEL_ID", "0") or "0"
252
+ desc = parent.get("ID_MODEL", "") or parent.get("ID_VENDOR", "") or ""
253
+ try:
254
+ vid = int(vid_str, 16)
255
+ pid = int(pid_str, 16)
256
+ except ValueError:
257
+ vid, pid = 0, 0
258
+ devices.append(UsbDevice(port=port, vid=vid, pid=pid, description=desc))
259
+ return sorted(devices, key=lambda d: d.port)
260
+ except Exception: # reason: pyudev failure → fall through to glob
261
+ return []
262
+
263
+
264
+ def _enumerate_macos_system_profiler() -> list[UsbDevice]:
265
+ """Enumerate USB devices via ``system_profiler`` on macOS.
266
+
267
+ Returns:
268
+ List of detected ``UsbDevice`` instances. Empty if the command fails
269
+ or no USB serial devices are present.
270
+ """
271
+ try:
272
+ raw = subprocess.check_output(
273
+ ["system_profiler", "SPUSBDataType", "-json"],
274
+ text=True,
275
+ timeout=10,
276
+ stderr=subprocess.DEVNULL,
277
+ )
278
+ data = json.loads(raw)
279
+ except Exception: # reason: subprocess or json failure → fall through
280
+ return []
281
+
282
+ devices: list[UsbDevice] = []
283
+
284
+ def _walk(node: object) -> None:
285
+ if isinstance(node, list):
286
+ for item in node:
287
+ _walk(item)
288
+ elif isinstance(node, dict):
289
+ # A USB device entry has vendor_id + product_id
290
+ vid_str: str = node.get("vendor_id", "") or ""
291
+ pid_str: str = node.get("product_id", "") or ""
292
+ bsd_name: str = node.get("bsd_name", "") or ""
293
+ desc: str = node.get("_name", "") or ""
294
+ if vid_str and pid_str and bsd_name and bsd_name.startswith("cu."):
295
+ port = f"/dev/{bsd_name}"
296
+ try:
297
+ vid = int(vid_str.replace("0x", ""), 16)
298
+ pid = int(pid_str.replace("0x", ""), 16)
299
+ except ValueError:
300
+ vid, pid = 0, 0
301
+ devices.append(UsbDevice(port=port, vid=vid, pid=pid, description=desc))
302
+ for v in node.values():
303
+ _walk(v)
304
+
305
+ _walk(data)
306
+ return sorted(devices, key=lambda d: d.port)
307
+
308
+
309
+ def _enumerate_glob_fallback() -> list[UsbDevice]:
310
+ """Enumerate USB serial devices via ``/dev/tty*`` glob patterns.
311
+
312
+ This fallback provides port paths but no VID/PID information.
313
+
314
+ Returns:
315
+ List of ``UsbDevice`` with vid=0, pid=0.
316
+ """
317
+ sys = platform.system()
318
+ if sys == "Linux":
319
+ patterns = ["/dev/ttyUSB*", "/dev/ttyACM*"]
320
+ elif sys == "Darwin":
321
+ patterns = ["/dev/cu.usbserial*", "/dev/cu.usbmodem*"]
322
+ else:
323
+ return []
324
+
325
+ ports: list[str] = []
326
+ for pattern in patterns:
327
+ ports.extend(sorted(glob(pattern)))
328
+
329
+ return [UsbDevice(port=p, vid=0, pid=0, description="") for p in sorted(set(ports))]
330
+
331
+
332
+ def enumerate_usb_devices() -> list[UsbDevice]:
333
+ """Enumerate USB serial adapters attached to this host.
334
+
335
+ Tries platform-specific methods in order:
336
+
337
+ - **Linux**: ``pyudev`` (VID/PID + port); falls back to ``/dev/tty*`` glob.
338
+ - **macOS**: ``system_profiler SPUSBDataType -json`` (VID/PID + port);
339
+ falls back to ``/dev/cu.*`` glob.
340
+ - **Other**: returns empty list.
341
+
342
+ Returns:
343
+ List of `UsbDevice`, sorted by port path. May be empty.
344
+
345
+ Example:
346
+ >>> devs = enumerate_usb_devices()
347
+ >>> isinstance(devs, list)
348
+ True
349
+ """
350
+ sys = platform.system()
351
+ if sys == "Linux":
352
+ devs = _enumerate_linux_pyudev()
353
+ if not devs:
354
+ devs = _enumerate_glob_fallback()
355
+ return devs
356
+ if sys == "Darwin":
357
+ devs = _enumerate_macos_system_profiler()
358
+ if not devs:
359
+ devs = _enumerate_glob_fallback()
360
+ return devs
361
+ return []
362
+
363
+
364
+ def match_known_devices(devices: list[UsbDevice]) -> list[UsbMatch]:
365
+ """Match detected USB devices against the known VID/PID table.
366
+
367
+ Args:
368
+ devices: Output of `enumerate_usb_devices`.
369
+
370
+ Returns:
371
+ List of `UsbMatch` for every device whose ``(vid, pid)``
372
+ pair appears in :data:`_VID_PID_TABLE`. Devices with unknown
373
+ VID/PID (vid=0) are excluded.
374
+
375
+ Example:
376
+ >>> matches = match_known_devices([])
377
+ >>> matches
378
+ []
379
+ """
380
+ matches: list[UsbMatch] = []
381
+ for dev in devices:
382
+ known = _VID_PID_TABLE.get((dev.vid, dev.pid))
383
+ if known is not None:
384
+ matches.append(UsbMatch(device=dev, known=known))
385
+ return matches
386
+
387
+
388
+ # ── DDS discovery ─────────────────────────────────────────────────────────────
389
+
390
+
391
+ def scan_dds_topics(timeout_s: float = 5.0) -> list[DdsTopic]:
392
+ """Run ``ros2 topic list -t`` and return observed topics.
393
+
394
+ Requires ``ros2`` to be on ``PATH`` and the ROS 2 environment sourced.
395
+ Returns an empty list if ``ros2`` is not available or times out.
396
+
397
+ Args:
398
+ timeout_s: Maximum seconds to wait for ``ros2 topic list`` to return.
399
+ Defaults to ``5.0``.
400
+
401
+ Returns:
402
+ List of `DdsTopic` sorted by topic name. Empty if ROS 2 is
403
+ not available or no topics are discovered within the timeout.
404
+
405
+ Example:
406
+ >>> topics = scan_dds_topics(timeout_s=0.1) # too short → empty in most envs
407
+ >>> isinstance(topics, list)
408
+ True
409
+ """
410
+ import shutil # noqa: PLC0415 # reason: keep top-level imports minimal
411
+
412
+ if not shutil.which("ros2"):
413
+ return []
414
+
415
+ try:
416
+ raw = subprocess.check_output(
417
+ ["ros2", "topic", "list", "-t"],
418
+ text=True,
419
+ timeout=timeout_s,
420
+ stderr=subprocess.DEVNULL,
421
+ )
422
+ except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError):
423
+ return []
424
+
425
+ topics: list[DdsTopic] = []
426
+ for raw_line in raw.strip().splitlines():
427
+ # Format: "/topic_name [pkg/msg/Type]"
428
+ line = raw_line.strip()
429
+ if not line:
430
+ continue
431
+ parts = line.split(None, 1)
432
+ name = parts[0]
433
+ type_name = parts[1].strip("[]") if len(parts) > 1 else ""
434
+ topics.append(DdsTopic(name=name, type_name=type_name))
435
+
436
+ return sorted(topics, key=lambda t: t.name)
437
+
438
+
439
+ def infer_robot_from_topics(topics: list[DdsTopic]) -> str | None:
440
+ """Infer a robot type from DDS topic names.
441
+
442
+ Checks each topic name against :data:`_TOPIC_ROBOT_MAP` prefix matches.
443
+ Returns the first match found (in topic-name alphabetical order).
444
+
445
+ Args:
446
+ topics: Output of `scan_dds_topics`.
447
+
448
+ Returns:
449
+ A robot type string (e.g. ``"unitree_g1"``) if a known topic prefix
450
+ is found, otherwise ``None``.
451
+
452
+ Example:
453
+ >>> from openral_cli.autodetect import DdsTopic, infer_robot_from_topics
454
+ >>> infer_robot_from_topics([DdsTopic("/lowstate", "unitree_go/msg/LowState")])
455
+ 'unitree_g1'
456
+ >>> infer_robot_from_topics([]) is None
457
+ True
458
+ """
459
+ for topic in topics:
460
+ name_lower = topic.name.lower()
461
+ for prefix, robot_type in _TOPIC_ROBOT_MAP.items():
462
+ if name_lower.startswith(prefix.lower()):
463
+ return robot_type
464
+ return None