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.
- openral_cli/__init__.py +6 -0
- openral_cli/_hf_publish.py +125 -0
- openral_cli/_rskill_doc_validator.py +562 -0
- openral_cli/_rskill_intel.py +334 -0
- openral_cli/_rskill_readme.py +217 -0
- openral_cli/_rskill_scaffolder.py +267 -0
- openral_cli/autodetect.py +464 -0
- openral_cli/check.py +397 -0
- openral_cli/collision.py +369 -0
- openral_cli/dataset.py +438 -0
- openral_cli/deploy_sim.py +2664 -0
- openral_cli/install.py +422 -0
- openral_cli/main.py +4108 -0
- openral_cli/prompt.py +128 -0
- openral_cli/py.typed +0 -0
- openral_cli/robot.py +301 -0
- openral_cli-0.1.0.dist-info/METADATA +33 -0
- openral_cli-0.1.0.dist-info/RECORD +20 -0
- openral_cli-0.1.0.dist-info/WHEEL +4 -0
- openral_cli-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -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
|