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
openral_cli/main.py
ADDED
|
@@ -0,0 +1,4108 @@
|
|
|
1
|
+
"""openral CLI entry point — ``openral`` command.
|
|
2
|
+
|
|
3
|
+
Two modes of use:
|
|
4
|
+
|
|
5
|
+
* **One-shot**: ``openral <subcommand> [args...]`` runs a single command and
|
|
6
|
+
exits. Use this in scripts and CI. ``openral --help`` lists the surface.
|
|
7
|
+
* **Interactive REPL**: ``openral`` with no arguments drops into a prompt
|
|
8
|
+
where subcommands run bare (``sim run --config …`` instead of
|
|
9
|
+
``openral sim run --config …``). Type ``help`` for the menu, ``exit`` or
|
|
10
|
+
Ctrl-D to leave.
|
|
11
|
+
|
|
12
|
+
Sub-commands
|
|
13
|
+
------------
|
|
14
|
+
doctor Diagnose the host environment (Python, OS, ROS 2, GPU, USB).
|
|
15
|
+
detect Probe hardware and write a full RobotDescription robot.yaml.
|
|
16
|
+
connect Open a HAL connection to a robot and verify it responds.
|
|
17
|
+
calibrate camera Calibrate a camera sensor using ros2 camera_calibration.
|
|
18
|
+
install Install opt-in dependency groups (sim, ros, libero, …).
|
|
19
|
+
rskill search Find installable rSkills on the OpenRAL HF Hub org.
|
|
20
|
+
rskill install Download an rSkill from the HF Hub and register it locally.
|
|
21
|
+
rskill list List all locally installed rSkills.
|
|
22
|
+
rskill check Report which installed rSkills will run on the current host.
|
|
23
|
+
rskill new Scaffold a new local rSkill from rskills/template/.
|
|
24
|
+
collision lower Lower a robot's URDF/SRDF into its self-collision model.
|
|
25
|
+
collision check Fail if a manifest drifts from its lowered collision model.
|
|
26
|
+
check Cross-validate every robot/skill/scene manifest in one pass.
|
|
27
|
+
|
|
28
|
+
Run ``openral --help`` for full usage.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import contextlib
|
|
34
|
+
import json as _json
|
|
35
|
+
import os
|
|
36
|
+
import platform
|
|
37
|
+
import re
|
|
38
|
+
import shlex
|
|
39
|
+
import shutil
|
|
40
|
+
import socket
|
|
41
|
+
import subprocess
|
|
42
|
+
import sys
|
|
43
|
+
from glob import glob
|
|
44
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
45
|
+
from pathlib import Path
|
|
46
|
+
from typing import TYPE_CHECKING, Final, NamedTuple, cast
|
|
47
|
+
from urllib.parse import urlparse
|
|
48
|
+
|
|
49
|
+
import click
|
|
50
|
+
import typer
|
|
51
|
+
from openral_core.exceptions import ROSConfigError, ROSRuntimeError
|
|
52
|
+
from openral_observability import (
|
|
53
|
+
cli_command_span,
|
|
54
|
+
configure_observability,
|
|
55
|
+
semconv,
|
|
56
|
+
)
|
|
57
|
+
from openral_sim.cli import sim_app
|
|
58
|
+
from rich.box import MINIMAL, ROUNDED
|
|
59
|
+
from rich.console import Console, Group, RenderableType
|
|
60
|
+
from rich.panel import Panel
|
|
61
|
+
from rich.rule import Rule
|
|
62
|
+
from rich.table import Table
|
|
63
|
+
from rich.text import Text
|
|
64
|
+
|
|
65
|
+
from openral_cli.check import check_command
|
|
66
|
+
from openral_cli.collision import collision_app
|
|
67
|
+
from openral_cli.dataset import dataset_app
|
|
68
|
+
from openral_cli.deploy_sim import deploy_sim_command
|
|
69
|
+
from openral_cli.install import install_app
|
|
70
|
+
from openral_cli.prompt import prompt_command
|
|
71
|
+
|
|
72
|
+
if TYPE_CHECKING:
|
|
73
|
+
from openral_core import (
|
|
74
|
+
BenchmarkScene,
|
|
75
|
+
RobotDescription,
|
|
76
|
+
RSkillEvalResult,
|
|
77
|
+
SensorSpec,
|
|
78
|
+
VLASpec,
|
|
79
|
+
)
|
|
80
|
+
from openral_core.schemas import RSkillManifest
|
|
81
|
+
from openral_detect import CompatibilityReport, DetectionReport, RSkillCompatRow
|
|
82
|
+
from openral_detect.report import GpuProbeResult
|
|
83
|
+
|
|
84
|
+
from openral_cli._rskill_intel import RSkillFamily, RSkillPatch
|
|
85
|
+
|
|
86
|
+
app = typer.Typer(
|
|
87
|
+
name="openral",
|
|
88
|
+
help="OpenRAL — open-source robot agent harness for rSkill / VLA models",
|
|
89
|
+
invoke_without_command=True,
|
|
90
|
+
)
|
|
91
|
+
console = Console()
|
|
92
|
+
|
|
93
|
+
# ── REPL ──────────────────────────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
# Solid-block OpenRAL logo mark (single white weight, no gradient), sized to the
|
|
96
|
+
# wordmark's 6 rows: horns flaring out and down into a rounded head, eyes below.
|
|
97
|
+
_LOGO_ART: Final[str] = "\n".join(
|
|
98
|
+
[
|
|
99
|
+
"█ █",
|
|
100
|
+
"██▄ ▄██",
|
|
101
|
+
"████▄▄ ▄▄████",
|
|
102
|
+
"▀██████ ██████▀",
|
|
103
|
+
" ▀███████▀ ",
|
|
104
|
+
" ▀ ▀███▀ ▀ ",
|
|
105
|
+
]
|
|
106
|
+
)
|
|
107
|
+
# OPENRAL block-letter wordmark.
|
|
108
|
+
_WORDMARK_ART: Final[str] = "\n".join(
|
|
109
|
+
[
|
|
110
|
+
" ██████╗ ██████╗ ███████╗███╗ ██╗██████╗ █████╗ ██╗",
|
|
111
|
+
"██╔═══██╗██╔══██╗██╔════╝████╗ ██║██╔══██╗██╔══██╗██║",
|
|
112
|
+
"██║ ██║██████╔╝█████╗ ██╔██╗ ██║██████╔╝███████║██║",
|
|
113
|
+
"██║ ██║██╔═══╝ ██╔══╝ ██║╚██╗██║██╔══██╗██╔══██║██║",
|
|
114
|
+
"╚██████╔╝██║ ███████╗██║ ╚████║██║ ██║██║ ██║███████╗",
|
|
115
|
+
" ╚═════╝ ╚═╝ ╚══════╝╚═╝ ╚═══╝╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝",
|
|
116
|
+
]
|
|
117
|
+
)
|
|
118
|
+
_TAGLINE_TAIL: Final[str] = " — Open Robot Agentic Layer (harness) for embodied AI"
|
|
119
|
+
_CAPABILITIES: Final[str] = "fast policies · slow reasoning · rewards · perception · control"
|
|
120
|
+
# Community links rendered in the top-right cell.
|
|
121
|
+
_LINKS: Final[tuple[tuple[str, str], ...]] = (
|
|
122
|
+
("Discord", "discord.gg/3paXT2bVyB"),
|
|
123
|
+
("GitHub", "github.com/OpenRAL/openral"),
|
|
124
|
+
("Hugging Face", "huggingface.co/OpenRAL"),
|
|
125
|
+
("Website", "openral.com"),
|
|
126
|
+
)
|
|
127
|
+
# Quick-start commands rendered in the bottom-right cell.
|
|
128
|
+
_COMMANDS: Final[tuple[tuple[str, str], ...]] = (
|
|
129
|
+
("doctor", "diagnose your host setup"),
|
|
130
|
+
("rskill search", "find installable skills"),
|
|
131
|
+
("help", "list every command"),
|
|
132
|
+
("exit", "leave the repl · Ctrl-D"),
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
# Minimum terminal columns each (content-sized) layout occupies — measured from
|
|
136
|
+
# the rendered box so the richest layout that still fits the terminal is chosen and
|
|
137
|
+
# the box never overflows. Below the side-by-side floor the logo stacks above the
|
|
138
|
+
# wordmark; below the stacked width the terminal is simply too narrow to fit.
|
|
139
|
+
_WIDE_MIN: Final[int] = 127 # two-column (logo|wordmark · links/divider/commands)
|
|
140
|
+
_SIDE_BY_SIDE_MIN: Final[int] = 82 # single column, logo + wordmark share a line
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _kv_grid(rows: tuple[tuple[str, str], ...], key_style: str) -> Table:
|
|
144
|
+
"""A two-column ``key value`` grid (styled key, dim value) with no borders."""
|
|
145
|
+
grid = Table.grid(padding=(0, 2))
|
|
146
|
+
grid.add_column(style=key_style, no_wrap=True)
|
|
147
|
+
grid.add_column(style="dim")
|
|
148
|
+
for key, value in rows:
|
|
149
|
+
grid.add_row(key, value)
|
|
150
|
+
return grid
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _logo_wordmark(*, stacked: bool) -> RenderableType:
|
|
154
|
+
"""Logo mark + OPENRAL wordmark, side by side (wide) or stacked (narrow)."""
|
|
155
|
+
logo = Text(_LOGO_ART, style="bold white")
|
|
156
|
+
wordmark = Text(_WORDMARK_ART, style="bold white")
|
|
157
|
+
if stacked:
|
|
158
|
+
return Group(logo, wordmark)
|
|
159
|
+
grid = Table.grid(padding=(0, 2))
|
|
160
|
+
grid.add_column(vertical="middle")
|
|
161
|
+
grid.add_column(vertical="middle")
|
|
162
|
+
grid.add_row(logo, wordmark)
|
|
163
|
+
return grid
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _identity(*, stacked: bool) -> RenderableType:
|
|
167
|
+
"""Logo + wordmark above the centred tagline and capability strip."""
|
|
168
|
+
return Group(
|
|
169
|
+
_logo_wordmark(stacked=stacked),
|
|
170
|
+
Text(""),
|
|
171
|
+
Text.assemble(("OpenRAL", "bold white"), (_TAGLINE_TAIL, "white"), justify="center"),
|
|
172
|
+
Text(_CAPABILITIES, style="dim", justify="center"),
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def render_banner(version_str: str, *, width: int | None = None) -> RenderableType:
|
|
177
|
+
"""Build the REPL welcome box as a rich renderable (Claude-Code style).
|
|
178
|
+
|
|
179
|
+
Returns a :class:`rich.panel.Panel` so the same renderable can be printed to
|
|
180
|
+
the live console *and* exported to plain text in tests, independent of
|
|
181
|
+
terminal/TTY/colour state. The white-bordered rounded box carries ``OPENRAL
|
|
182
|
+
v<version>`` inline in the top border and adapts to ``width``:
|
|
183
|
+
|
|
184
|
+
* **Wide** (``>= _WIDE_MIN``): two columns — the logo mark beside the OPENRAL
|
|
185
|
+
wordmark with the tagline below on the left; the community links above a
|
|
186
|
+
horizontal divider above the quick-start commands on the right, split by a
|
|
187
|
+
vertical divider.
|
|
188
|
+
* **Narrow**: a single stacked column keeping every section; the logo sits
|
|
189
|
+
beside the wordmark while it fits and stacks above it once it does not.
|
|
190
|
+
|
|
191
|
+
Args:
|
|
192
|
+
version_str: The installed ``openral-cli`` version, rendered as ``vX.Y.Z``.
|
|
193
|
+
width: Target terminal width in columns; defaults to a wide layout.
|
|
194
|
+
|
|
195
|
+
Example:
|
|
196
|
+
>>> from io import StringIO
|
|
197
|
+
>>> from rich.console import Console
|
|
198
|
+
>>> con = Console(file=StringIO(), width=120)
|
|
199
|
+
>>> con.print(render_banner("0.1.0", width=120))
|
|
200
|
+
>>> "OPENRAL v0.1.0" in con.file.getvalue()
|
|
201
|
+
True
|
|
202
|
+
"""
|
|
203
|
+
cols = width if width is not None else _WIDE_MIN
|
|
204
|
+
links = _kv_grid(_LINKS, "bold white")
|
|
205
|
+
commands = _kv_grid(_COMMANDS, "bold cyan")
|
|
206
|
+
|
|
207
|
+
body: RenderableType
|
|
208
|
+
if cols >= _WIDE_MIN:
|
|
209
|
+
columns = Table(
|
|
210
|
+
box=MINIMAL,
|
|
211
|
+
show_header=False,
|
|
212
|
+
show_edge=False,
|
|
213
|
+
show_lines=False,
|
|
214
|
+
pad_edge=False,
|
|
215
|
+
padding=(0, 2),
|
|
216
|
+
border_style="dim",
|
|
217
|
+
expand=False,
|
|
218
|
+
)
|
|
219
|
+
columns.add_column(vertical="top")
|
|
220
|
+
columns.add_column(vertical="top")
|
|
221
|
+
columns.add_row(
|
|
222
|
+
_identity(stacked=False),
|
|
223
|
+
Group(links, Rule(style="dim"), commands),
|
|
224
|
+
)
|
|
225
|
+
body = columns
|
|
226
|
+
else:
|
|
227
|
+
body = Group(
|
|
228
|
+
_identity(stacked=cols < _SIDE_BY_SIDE_MIN),
|
|
229
|
+
Text(""),
|
|
230
|
+
links,
|
|
231
|
+
Rule(style="dim"),
|
|
232
|
+
commands,
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
# Size the box to its content (expand=False) rather than stretching it to the
|
|
236
|
+
# terminal: a compact box keeps slack on wide terminals and only breaks if the
|
|
237
|
+
# window is later dragged narrower than the box itself (printed output cannot
|
|
238
|
+
# reflow). The layout above is chosen so the content always fits ``cols``.
|
|
239
|
+
return Panel(
|
|
240
|
+
body,
|
|
241
|
+
box=ROUNDED,
|
|
242
|
+
border_style="white",
|
|
243
|
+
title=f"OPENRAL v{version_str}",
|
|
244
|
+
title_align="left",
|
|
245
|
+
padding=(1, 2),
|
|
246
|
+
expand=False,
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _cli_version() -> str:
|
|
251
|
+
"""Best-effort ``openral-cli`` version string for the banner (never raises)."""
|
|
252
|
+
with contextlib.suppress(PackageNotFoundError):
|
|
253
|
+
return version("openral-cli")
|
|
254
|
+
return "0.0.0"
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _print_banner() -> None:
|
|
258
|
+
"""Print the OpenRAL welcome box, sized to the live terminal width."""
|
|
259
|
+
console.print(render_banner(_cli_version(), width=console.width))
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _dispatch_repl_line(line: str) -> None:
|
|
263
|
+
"""Tokenise a REPL line and re-enter the Typer app as if invoked from a shell.
|
|
264
|
+
|
|
265
|
+
Uses ``shlex.split`` so quoting works (``sim run --config 'path with
|
|
266
|
+
spaces.yaml'``). The Typer app is invoked with ``standalone_mode=False``
|
|
267
|
+
so ``typer.Exit`` and ``click.exceptions.UsageError`` don't tear down
|
|
268
|
+
the REPL. Each line spawns its own top-level callback + tracing scope.
|
|
269
|
+
"""
|
|
270
|
+
try:
|
|
271
|
+
tokens = shlex.split(line)
|
|
272
|
+
except ValueError as exc:
|
|
273
|
+
console.print(f"[red]parse error:[/red] {exc}")
|
|
274
|
+
return
|
|
275
|
+
if not tokens:
|
|
276
|
+
return
|
|
277
|
+
head = tokens[0].lower()
|
|
278
|
+
if head in {"exit", "quit", ":q"}:
|
|
279
|
+
raise EOFError
|
|
280
|
+
if head in {"help", "?"}:
|
|
281
|
+
# Re-enter with --help so Typer prints the full surface.
|
|
282
|
+
tokens = ["--help"]
|
|
283
|
+
try:
|
|
284
|
+
app(args=tokens, prog_name="openral", standalone_mode=False)
|
|
285
|
+
except click.exceptions.UsageError as exc:
|
|
286
|
+
exc.show()
|
|
287
|
+
except click.exceptions.Abort:
|
|
288
|
+
console.print("[yellow]aborted[/yellow]")
|
|
289
|
+
except SystemExit:
|
|
290
|
+
# Some commands still call sys.exit; swallow it so the REPL survives.
|
|
291
|
+
pass
|
|
292
|
+
except Exception as exc: # reason: keep REPL alive on subcommand crashes
|
|
293
|
+
console.print(f"[red]error:[/red] {exc}")
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _path_completer(text: str, state: int) -> str | None:
|
|
297
|
+
"""``readline``-shaped tab-completion function for filesystem paths.
|
|
298
|
+
|
|
299
|
+
Expands a leading ``~`` against ``$HOME``, globs ``<text>*``, suffixes
|
|
300
|
+
directory matches with ``/`` so a second Tab descends into them, and
|
|
301
|
+
rewrites the home prefix back to ``~`` on return so a user who typed
|
|
302
|
+
``~/foo`` does not see their line buffer silently rewritten to an
|
|
303
|
+
absolute path. ``state`` is readline's call-counter contract: state=0
|
|
304
|
+
returns the first match, state=N returns the (N+1)-th, and we return
|
|
305
|
+
``None`` past the end to signal exhaustion.
|
|
306
|
+
"""
|
|
307
|
+
import glob
|
|
308
|
+
import os
|
|
309
|
+
|
|
310
|
+
expanded = os.path.expanduser(text) if text else ""
|
|
311
|
+
raw = sorted(glob.glob(expanded + "*"))
|
|
312
|
+
matches = [m + "/" if os.path.isdir(m) else m for m in raw]
|
|
313
|
+
|
|
314
|
+
if text.startswith("~"):
|
|
315
|
+
home = os.path.expanduser("~")
|
|
316
|
+
if home and home != "~":
|
|
317
|
+
matches = [
|
|
318
|
+
"~" + m[len(home) :] if m == home or m.startswith(home + os.sep) else m
|
|
319
|
+
for m in matches
|
|
320
|
+
]
|
|
321
|
+
|
|
322
|
+
if state < len(matches):
|
|
323
|
+
return matches[state]
|
|
324
|
+
return None
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _run_repl() -> None:
|
|
328
|
+
"""Run the interactive ``openral>`` shell until EOF or ``exit``.
|
|
329
|
+
|
|
330
|
+
Uses stdlib ``input()`` + optional ``readline`` (stdlib) for arrow-key
|
|
331
|
+
history and Tab path completion. Deliberately avoids a hard dependency
|
|
332
|
+
on ``prompt_toolkit`` so the curl-bash Tier-0 install (uv +
|
|
333
|
+
openral-cli only) is enough.
|
|
334
|
+
"""
|
|
335
|
+
import contextlib
|
|
336
|
+
|
|
337
|
+
with contextlib.suppress(ImportError):
|
|
338
|
+
# readline is absent on Windows; REPL still works, just without
|
|
339
|
+
# history or Tab completion.
|
|
340
|
+
import readline
|
|
341
|
+
|
|
342
|
+
readline.set_completer(_path_completer)
|
|
343
|
+
# Shell-shaped delimiters: split on whitespace and shell
|
|
344
|
+
# metacharacters only, so a path token like "~/foo/bar.yaml" is
|
|
345
|
+
# passed to the completer whole instead of being chopped at "~",
|
|
346
|
+
# "/", or ".".
|
|
347
|
+
readline.set_completer_delims(" \t\n=;|&><")
|
|
348
|
+
# macOS ships libedit-backed readline whose bind syntax differs.
|
|
349
|
+
if "libedit" in getattr(readline, "__doc__", "") or "":
|
|
350
|
+
readline.parse_and_bind("bind ^I rl_complete")
|
|
351
|
+
else:
|
|
352
|
+
readline.parse_and_bind("tab: complete")
|
|
353
|
+
|
|
354
|
+
_print_banner()
|
|
355
|
+
while True:
|
|
356
|
+
try:
|
|
357
|
+
line = input("openral> ").strip()
|
|
358
|
+
except (EOFError, KeyboardInterrupt):
|
|
359
|
+
console.print() # newline after ^D / ^C
|
|
360
|
+
break
|
|
361
|
+
if not line:
|
|
362
|
+
continue
|
|
363
|
+
try:
|
|
364
|
+
_dispatch_repl_line(line)
|
|
365
|
+
except EOFError:
|
|
366
|
+
break
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
_RUN_MODE_BY_SUBCOMMAND: dict[str, str] = {
|
|
370
|
+
"sim": semconv.RUN_MODE_SIM,
|
|
371
|
+
"benchmark": semconv.RUN_MODE_BENCHMARK,
|
|
372
|
+
"deploy": semconv.RUN_MODE_HARDWARE,
|
|
373
|
+
"connect": semconv.RUN_MODE_HARDWARE,
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
# Hardware deployments at >=100 Hz over 24 h would emit millions of tick spans
|
|
377
|
+
# per day at ALWAYS_ON. The 2026-05-17 sampling-policy amendment calls for a
|
|
378
|
+
# 10% ratio sampler on hardware mode and ALWAYS_ON for sim / benchmark
|
|
379
|
+
# / one-shot subcommands (doctor / detect / skill install / …) where the
|
|
380
|
+
# total volume is bounded by a single invocation.
|
|
381
|
+
_SAMPLE_RATIO_BY_MODE: dict[str, float] = {
|
|
382
|
+
semconv.RUN_MODE_HARDWARE: 0.1,
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
@app.callback()
|
|
387
|
+
def _root(ctx: typer.Context) -> None:
|
|
388
|
+
"""Initialise tracing+logs, open the ``cli.command`` root span, or enter REPL.
|
|
389
|
+
|
|
390
|
+
Bare ``openral`` invocations (no subcommand) drop into the interactive
|
|
391
|
+
REPL where each entered line is re-dispatched through the Typer app as
|
|
392
|
+
if typed on the shell. Subcommand invocations behave exactly as before:
|
|
393
|
+
a single ``cli.command`` root span wraps the call and the sampler is
|
|
394
|
+
chosen by ``openral.run.mode`` (hardware → 10% ratio, others → always-on)
|
|
395
|
+
per the 2026-05-17 sampling-policy amendment. ``OPENRAL_OTEL_SAMPLE_RATIO``
|
|
396
|
+
overrides for ad-hoc debugging.
|
|
397
|
+
"""
|
|
398
|
+
if ctx.invoked_subcommand is None:
|
|
399
|
+
# Configure tracing with always-on (REPL == bounded session), then
|
|
400
|
+
# drop into the prompt. Each dispatched line re-enters this callback
|
|
401
|
+
# with its own subcommand, so the per-command span tree stays intact.
|
|
402
|
+
configure_observability(service_name="openral", sample_ratio=None)
|
|
403
|
+
_run_repl()
|
|
404
|
+
return
|
|
405
|
+
|
|
406
|
+
subcommand = ctx.invoked_subcommand
|
|
407
|
+
mode = _RUN_MODE_BY_SUBCOMMAND.get(subcommand)
|
|
408
|
+
sample_ratio = _SAMPLE_RATIO_BY_MODE.get(mode) if mode is not None else None
|
|
409
|
+
configure_observability(service_name="openral", sample_ratio=sample_ratio)
|
|
410
|
+
ctx.with_resource(cli_command_span(subcommand, mode=mode))
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
# Status values used throughout; kept as plain str for JSON serialisation.
|
|
414
|
+
# Colour mapping: ok→green, absent/info→yellow, everything else→red.
|
|
415
|
+
_YELLOW_STATUSES = frozenset({"absent", "info", "warn"})
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
class CheckResult(NamedTuple):
|
|
419
|
+
"""One row in the ``openral doctor`` output table.
|
|
420
|
+
|
|
421
|
+
Attributes:
|
|
422
|
+
check: Short name of the thing being checked.
|
|
423
|
+
status: One of ``ok``, ``fail``, ``missing``, ``absent``, ``info``, ``warn``.
|
|
424
|
+
details: Human-readable detail string (path, version, device list, …).
|
|
425
|
+
"""
|
|
426
|
+
|
|
427
|
+
check: str
|
|
428
|
+
status: str
|
|
429
|
+
details: str
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
# ── Individual check functions (each independently testable) ──────────────────
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _check_python() -> CheckResult:
|
|
436
|
+
ok = sys.version_info >= (3, 10)
|
|
437
|
+
return CheckResult("Python", "ok" if ok else "fail", platform.python_version())
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _check_platform() -> CheckResult:
|
|
441
|
+
return CheckResult("Platform", "info", f"{platform.system()} {platform.release()}")
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def _check_openral_core() -> CheckResult:
|
|
445
|
+
try:
|
|
446
|
+
v = version("openral-core")
|
|
447
|
+
return CheckResult("openral-core", "ok", v)
|
|
448
|
+
except PackageNotFoundError as exc:
|
|
449
|
+
return CheckResult("openral-core", "fail", str(exc))
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def _check_ros2() -> list[CheckResult]:
|
|
453
|
+
"""Return one or more rows covering the ROS 2 binary, distro, and RMW."""
|
|
454
|
+
results: list[CheckResult] = []
|
|
455
|
+
|
|
456
|
+
ros2_path = shutil.which("ros2")
|
|
457
|
+
if not ros2_path:
|
|
458
|
+
results.append(CheckResult("ROS 2 binary", "missing", "not found"))
|
|
459
|
+
return results
|
|
460
|
+
results.append(CheckResult("ROS 2 binary", "ok", ros2_path))
|
|
461
|
+
|
|
462
|
+
# Distro — set by sourcing /opt/ros/<distro>/setup.bash
|
|
463
|
+
distro = os.environ.get("ROS_DISTRO", "")
|
|
464
|
+
if distro:
|
|
465
|
+
results.append(CheckResult("ROS 2 distro", "ok", distro))
|
|
466
|
+
else:
|
|
467
|
+
installed = sorted(glob("/opt/ros/*/setup.bash"))
|
|
468
|
+
if installed:
|
|
469
|
+
names = [p.split("/")[3] for p in installed]
|
|
470
|
+
results.append(
|
|
471
|
+
CheckResult(
|
|
472
|
+
"ROS 2 distro",
|
|
473
|
+
"info",
|
|
474
|
+
f"installed: {', '.join(names)} — run: source /opt/ros/<distro>/setup.bash",
|
|
475
|
+
)
|
|
476
|
+
)
|
|
477
|
+
else:
|
|
478
|
+
results.append(
|
|
479
|
+
CheckResult(
|
|
480
|
+
"ROS 2 distro",
|
|
481
|
+
"missing",
|
|
482
|
+
"ROS_DISTRO not set and no /opt/ros/* found",
|
|
483
|
+
)
|
|
484
|
+
)
|
|
485
|
+
|
|
486
|
+
# RMW implementation
|
|
487
|
+
rmw = os.environ.get("RMW_IMPLEMENTATION", "rmw_fastrtps_cpp (default)")
|
|
488
|
+
results.append(CheckResult("RMW", "info", rmw))
|
|
489
|
+
|
|
490
|
+
return results
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def _check_colcon() -> CheckResult:
|
|
494
|
+
path = shutil.which("colcon")
|
|
495
|
+
return CheckResult("colcon", "ok" if path else "missing", path or "")
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def _check_gpu(result: GpuProbeResult, warnings: list[str]) -> list[CheckResult]:
|
|
499
|
+
"""Return one row per detected GPU / SoC accelerator.
|
|
500
|
+
|
|
501
|
+
Args:
|
|
502
|
+
result: Pre-probed :class:`~openral_detect.GpuProbeResult` (shared
|
|
503
|
+
with :func:`_check_compute_spec` so the probe runs exactly once).
|
|
504
|
+
warnings: Non-fatal probe warnings to surface when no GPU is found.
|
|
505
|
+
"""
|
|
506
|
+
rows: list[CheckResult] = []
|
|
507
|
+
for gpu in result.nvidia:
|
|
508
|
+
rows.append(
|
|
509
|
+
CheckResult(
|
|
510
|
+
f"GPU {gpu.index}",
|
|
511
|
+
"ok",
|
|
512
|
+
f"{gpu.name} ({gpu.vram_total_mib} MiB, "
|
|
513
|
+
f"sm_{gpu.cuda_compute_capability[0]}{gpu.cuda_compute_capability[1]})",
|
|
514
|
+
)
|
|
515
|
+
)
|
|
516
|
+
if result.jetson is not None:
|
|
517
|
+
rows.append(
|
|
518
|
+
CheckResult(
|
|
519
|
+
"Jetson",
|
|
520
|
+
"ok",
|
|
521
|
+
f"{result.jetson.board} ({result.jetson.tops:.0f} TOPS, "
|
|
522
|
+
f"{result.jetson.ram_gb:.0f} GB unified)",
|
|
523
|
+
)
|
|
524
|
+
)
|
|
525
|
+
if result.apple_silicon is not None:
|
|
526
|
+
rows.append(CheckResult("GPU", "info", f"Apple Silicon — {result.apple_silicon.chip}"))
|
|
527
|
+
if not rows:
|
|
528
|
+
if warnings:
|
|
529
|
+
rows.append(CheckResult("GPU", "absent", warnings[0]))
|
|
530
|
+
else:
|
|
531
|
+
rows.append(CheckResult("GPU", "absent", "no accelerator detected"))
|
|
532
|
+
return rows
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def _check_compute_spec(result: GpuProbeResult) -> list[CheckResult]:
|
|
536
|
+
"""Build :class:`~openral_core.ComputeSpec` rows from the GPU probe.
|
|
537
|
+
|
|
538
|
+
Shares the same :class:`~openral_detect.GpuProbeResult` already obtained
|
|
539
|
+
by :func:`_check_gpu` so no second probe is issued. The assembled
|
|
540
|
+
``ComputeSpec`` mirrors exactly what ``openral detect`` would write into
|
|
541
|
+
``RobotDescription.compute_edge`` / ``compute_local`` — doctor and detect
|
|
542
|
+
stay in sync.
|
|
543
|
+
|
|
544
|
+
Tier labelling:
|
|
545
|
+
- Jetson SoC detected → rows prefixed ``ComputeSpec (edge)``
|
|
546
|
+
- Discrete NVIDIA / Apple Silicon / CPU-only → ``ComputeSpec (local)``
|
|
547
|
+
|
|
548
|
+
Rows emitted per tier:
|
|
549
|
+
- **runtimes** — comma-separated list of supported runtimes.
|
|
550
|
+
- **dtypes** — comma-separated list of supported quantization dtypes.
|
|
551
|
+
- **cuMotion** — ok when ``supports_cumotion()`` is True, info otherwise.
|
|
552
|
+
- **NVMM** — ok/absent for zero-copy NVMM availability.
|
|
553
|
+
"""
|
|
554
|
+
import datetime
|
|
555
|
+
|
|
556
|
+
from openral_detect import build_compute_spec
|
|
557
|
+
from openral_detect.report import DetectionReport, GpuProbeResult
|
|
558
|
+
|
|
559
|
+
def _spec_rows(gpu: GpuProbeResult, tier: str) -> list[CheckResult]:
|
|
560
|
+
report = DetectionReport(
|
|
561
|
+
detected_at=datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
|
562
|
+
gpu=gpu,
|
|
563
|
+
)
|
|
564
|
+
spec = build_compute_spec(gpu, report)
|
|
565
|
+
prefix = f"ComputeSpec ({tier})"
|
|
566
|
+
rows: list[CheckResult] = []
|
|
567
|
+
|
|
568
|
+
runtimes_str = ", ".join(r.value for r in spec.gpu_supported_runtimes) or "none"
|
|
569
|
+
rows.append(CheckResult(f"{prefix} / runtimes", "info", runtimes_str))
|
|
570
|
+
|
|
571
|
+
dtypes_str = ", ".join(d.value for d in spec.gpu_supported_dtypes) or "none"
|
|
572
|
+
rows.append(CheckResult(f"{prefix} / dtypes", "info", dtypes_str))
|
|
573
|
+
|
|
574
|
+
cumotion = spec.supports_cumotion()
|
|
575
|
+
rows.append(
|
|
576
|
+
CheckResult(
|
|
577
|
+
f"{prefix} / cuMotion",
|
|
578
|
+
"ok" if cumotion else "info",
|
|
579
|
+
"supported" if cumotion else "not supported (needs Ampere+, CUDA≥13, ≥8 GB VRAM)",
|
|
580
|
+
)
|
|
581
|
+
)
|
|
582
|
+
|
|
583
|
+
rows.append(
|
|
584
|
+
CheckResult(
|
|
585
|
+
f"{prefix} / NVMM",
|
|
586
|
+
"ok" if spec.nvmm_available else "absent",
|
|
587
|
+
"zero-copy NVMM available" if spec.nvmm_available else "not available",
|
|
588
|
+
)
|
|
589
|
+
)
|
|
590
|
+
return rows
|
|
591
|
+
|
|
592
|
+
all_rows: list[CheckResult] = []
|
|
593
|
+
if result.jetson is not None:
|
|
594
|
+
gpu_edge = GpuProbeResult(jetson=result.jetson, backend="jtop")
|
|
595
|
+
all_rows.extend(_spec_rows(gpu_edge, "edge"))
|
|
596
|
+
if result.nvidia or result.apple_silicon or result.jetson is None:
|
|
597
|
+
gpu_local = GpuProbeResult(
|
|
598
|
+
nvidia=result.nvidia,
|
|
599
|
+
apple_silicon=result.apple_silicon,
|
|
600
|
+
backend=result.backend,
|
|
601
|
+
)
|
|
602
|
+
all_rows.extend(_spec_rows(gpu_local, "local"))
|
|
603
|
+
return all_rows
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
def _check_usb() -> list[CheckResult]:
|
|
607
|
+
"""Return one row listing USB serial devices that could be robot controllers."""
|
|
608
|
+
if platform.system() == "Linux":
|
|
609
|
+
patterns = ["/dev/ttyUSB*", "/dev/ttyACM*"]
|
|
610
|
+
elif platform.system() == "Darwin":
|
|
611
|
+
patterns = ["/dev/cu.usbserial*", "/dev/cu.usbmodem*"]
|
|
612
|
+
else:
|
|
613
|
+
return [CheckResult("USB devices", "info", "enumeration not supported on this OS")]
|
|
614
|
+
|
|
615
|
+
devices: list[str] = []
|
|
616
|
+
for pattern in patterns:
|
|
617
|
+
devices.extend(sorted(glob(pattern)))
|
|
618
|
+
|
|
619
|
+
if devices:
|
|
620
|
+
return [CheckResult("USB devices", "ok", ", ".join(devices))]
|
|
621
|
+
return [CheckResult("USB devices", "info", "none found")]
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def _check_just() -> CheckResult:
|
|
625
|
+
path = shutil.which("just")
|
|
626
|
+
# `just` is a developer-convenience task runner, not a runtime requirement
|
|
627
|
+
# of `openral`; report absence with `warn` rather than `missing` so doctor
|
|
628
|
+
# still exits 0 on hosts that only need to run skills.
|
|
629
|
+
return CheckResult("just", "ok" if path else "warn", path or "not found")
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
# PROVIDER values whose endpoint enforces auth and so require
|
|
633
|
+
# OPENRAL_REASONER_LLM_API_KEY. Bare ``openai-compatible`` and ``ollama``
|
|
634
|
+
# are the exceptions because a local Ollama / llama-server doesn't.
|
|
635
|
+
_REASONER_PROVIDERS_REQUIRING_KEY: frozenset[str] = frozenset(
|
|
636
|
+
{"anthropic", "openrouter", "gemini", "xai", "deepseek", "huggingface"}
|
|
637
|
+
)
|
|
638
|
+
|
|
639
|
+
# Provider-default base URLs used when the user hasn't set
|
|
640
|
+
# OPENRAL_REASONER_LLM_BASE_URL. Mirrors tool_use.py constants but kept
|
|
641
|
+
# local to avoid forcing the CLI to import the (optionally-installed)
|
|
642
|
+
# reasoner package on every `openral doctor` invocation.
|
|
643
|
+
_REASONER_PROVIDER_DEFAULT_BASE_URL: dict[str, str] = {
|
|
644
|
+
"anthropic": "https://api.anthropic.com",
|
|
645
|
+
"openai-compatible": "https://api.openai.com/v1",
|
|
646
|
+
"openrouter": "https://openrouter.ai/api/v1",
|
|
647
|
+
"ollama": "http://localhost:11434/v1",
|
|
648
|
+
"vllm": "http://localhost:8000/v1",
|
|
649
|
+
"gemini": "https://generativelanguage.googleapis.com/v1beta/openai/",
|
|
650
|
+
"xai": "https://api.x.ai/v1",
|
|
651
|
+
"deepseek": "https://api.deepseek.com",
|
|
652
|
+
"huggingface": "https://router.huggingface.co/v1",
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
def _is_local_base_url(url: str) -> bool:
|
|
657
|
+
"""Return True when ``url``'s host resolves to a loopback name."""
|
|
658
|
+
host = urlparse(url).hostname or ""
|
|
659
|
+
return host in {"localhost", "127.0.0.1", "::1", "0.0.0.0"}
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
def _probe_tcp(host: str, port: int, *, timeout_s: float = 0.2) -> bool:
|
|
663
|
+
"""Return True if a TCP connection to ``host:port`` succeeds quickly."""
|
|
664
|
+
with contextlib.suppress(OSError), socket.create_connection((host, port), timeout=timeout_s):
|
|
665
|
+
return True
|
|
666
|
+
return False
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
def _check_reasoner_llm() -> list[CheckResult]:
|
|
670
|
+
"""Return rows describing the reasoner LLM env configuration.
|
|
671
|
+
|
|
672
|
+
Always emits a leading ``Reasoner LLM`` summary row. When the
|
|
673
|
+
provider is set but the rest of the config is incomplete, follow-up
|
|
674
|
+
rows name each missing variable so the user can read the table
|
|
675
|
+
top-to-bottom and see exactly what to export.
|
|
676
|
+
|
|
677
|
+
When the resolved endpoint is loopback (Ollama / local vLLM /
|
|
678
|
+
llama-server), an additional probe row (labelled ``vLLM`` for
|
|
679
|
+
``provider=vllm``, else ``Ollama``) TCP-probes the port so a user
|
|
680
|
+
trying the local baseline gets an immediate diagnosis if the daemon
|
|
681
|
+
isn't running.
|
|
682
|
+
|
|
683
|
+
The API key value is never printed — only ``set`` / ``unset``.
|
|
684
|
+
"""
|
|
685
|
+
rows: list[CheckResult] = []
|
|
686
|
+
provider_raw = os.environ.get("OPENRAL_REASONER_LLM_PROVIDER", "").strip()
|
|
687
|
+
provider = provider_raw.lower()
|
|
688
|
+
|
|
689
|
+
if not provider:
|
|
690
|
+
rows.append(
|
|
691
|
+
CheckResult(
|
|
692
|
+
"Reasoner LLM",
|
|
693
|
+
"absent",
|
|
694
|
+
"OPENRAL_REASONER_LLM_PROVIDER unset — see "
|
|
695
|
+
"packages/openral_reasoner_ros/README.md for the three baseline configs "
|
|
696
|
+
"(anthropic / openrouter / openai-compatible).",
|
|
697
|
+
)
|
|
698
|
+
)
|
|
699
|
+
return rows
|
|
700
|
+
|
|
701
|
+
if provider not in _REASONER_PROVIDER_DEFAULT_BASE_URL:
|
|
702
|
+
rows.append(
|
|
703
|
+
CheckResult(
|
|
704
|
+
"Reasoner LLM",
|
|
705
|
+
"fail",
|
|
706
|
+
f"OPENRAL_REASONER_LLM_PROVIDER={provider_raw!r}; expected one of "
|
|
707
|
+
f"{sorted(_REASONER_PROVIDER_DEFAULT_BASE_URL)!r}.",
|
|
708
|
+
)
|
|
709
|
+
)
|
|
710
|
+
return rows
|
|
711
|
+
|
|
712
|
+
model = os.environ.get("OPENRAL_REASONER_LLM_MODEL", "").strip()
|
|
713
|
+
api_key = os.environ.get("OPENRAL_REASONER_LLM_API_KEY", "").strip()
|
|
714
|
+
base_url_env = os.environ.get("OPENRAL_REASONER_LLM_BASE_URL", "").strip()
|
|
715
|
+
base_url = base_url_env or _REASONER_PROVIDER_DEFAULT_BASE_URL[provider]
|
|
716
|
+
|
|
717
|
+
key_required = provider in _REASONER_PROVIDERS_REQUIRING_KEY
|
|
718
|
+
key_status = "set" if api_key else "unset"
|
|
719
|
+
parts = [
|
|
720
|
+
f"provider={provider}",
|
|
721
|
+
f"model={model or '<unset>'}",
|
|
722
|
+
f"api_key={key_status}",
|
|
723
|
+
f"base_url={base_url}",
|
|
724
|
+
]
|
|
725
|
+
summary = " ".join(parts)
|
|
726
|
+
|
|
727
|
+
incomplete: list[CheckResult] = []
|
|
728
|
+
if not model:
|
|
729
|
+
incomplete.append(
|
|
730
|
+
CheckResult(
|
|
731
|
+
"Reasoner MODEL",
|
|
732
|
+
"missing",
|
|
733
|
+
"OPENRAL_REASONER_LLM_MODEL unset — required for every provider.",
|
|
734
|
+
)
|
|
735
|
+
)
|
|
736
|
+
if key_required and not api_key:
|
|
737
|
+
incomplete.append(
|
|
738
|
+
CheckResult(
|
|
739
|
+
"Reasoner API_KEY",
|
|
740
|
+
"missing",
|
|
741
|
+
f"OPENRAL_REASONER_LLM_API_KEY unset — required for provider={provider}.",
|
|
742
|
+
)
|
|
743
|
+
)
|
|
744
|
+
|
|
745
|
+
if incomplete:
|
|
746
|
+
rows.append(CheckResult("Reasoner LLM", "warn", summary))
|
|
747
|
+
rows.extend(incomplete)
|
|
748
|
+
else:
|
|
749
|
+
rows.append(CheckResult("Reasoner LLM", "ok", summary))
|
|
750
|
+
|
|
751
|
+
# Local-endpoint probe. Only meaningful when the resolved base_url is
|
|
752
|
+
# loopback; we never reach out to a cloud endpoint from `openral doctor`.
|
|
753
|
+
# Label + remediation are provider-aware so a vLLM endpoint isn't
|
|
754
|
+
# mislabelled as Ollama. The default port matches the daemon's own:
|
|
755
|
+
# 8000 for vLLM, 11434 otherwise.
|
|
756
|
+
if _is_local_base_url(base_url):
|
|
757
|
+
parsed = urlparse(base_url)
|
|
758
|
+
host = parsed.hostname or "localhost"
|
|
759
|
+
if provider == "vllm":
|
|
760
|
+
probe_label = "vLLM"
|
|
761
|
+
default_port = 8000
|
|
762
|
+
hint = "start the server with `vllm serve <model>`"
|
|
763
|
+
else:
|
|
764
|
+
probe_label = "Ollama"
|
|
765
|
+
default_port = 11434
|
|
766
|
+
hint = "run `just bootstrap-ollama` or `ollama serve`"
|
|
767
|
+
port = parsed.port or default_port
|
|
768
|
+
if _probe_tcp(host, port):
|
|
769
|
+
rows.append(
|
|
770
|
+
CheckResult(
|
|
771
|
+
probe_label,
|
|
772
|
+
"ok",
|
|
773
|
+
f"endpoint reachable at {host}:{port}",
|
|
774
|
+
)
|
|
775
|
+
)
|
|
776
|
+
else:
|
|
777
|
+
rows.append(
|
|
778
|
+
CheckResult(
|
|
779
|
+
probe_label,
|
|
780
|
+
"warn",
|
|
781
|
+
f"endpoint unreachable at {host}:{port} — {hint}.",
|
|
782
|
+
)
|
|
783
|
+
)
|
|
784
|
+
|
|
785
|
+
return rows
|
|
786
|
+
|
|
787
|
+
|
|
788
|
+
def _gather_checks() -> list[CheckResult]:
|
|
789
|
+
"""Run all checks and return the combined result list.
|
|
790
|
+
|
|
791
|
+
The GPU probe is issued exactly once and shared between
|
|
792
|
+
:func:`_check_gpu` (hardware rows) and :func:`_check_compute_spec`
|
|
793
|
+
(derived ``ComputeSpec`` rows) so ``openral doctor`` and
|
|
794
|
+
``openral detect`` build the same ``ComputeSpec`` from the same data.
|
|
795
|
+
|
|
796
|
+
Returns:
|
|
797
|
+
List of `CheckResult` in display order.
|
|
798
|
+
"""
|
|
799
|
+
from openral_detect.probes import probe_gpus
|
|
800
|
+
|
|
801
|
+
checks: list[CheckResult] = []
|
|
802
|
+
checks.append(_check_python())
|
|
803
|
+
checks.append(_check_platform())
|
|
804
|
+
checks.append(_check_openral_core())
|
|
805
|
+
checks.extend(_check_ros2())
|
|
806
|
+
checks.append(_check_colcon())
|
|
807
|
+
gpu_warnings: list[str] = []
|
|
808
|
+
gpu_result = probe_gpus(warnings=gpu_warnings)
|
|
809
|
+
checks.extend(_check_gpu(gpu_result, gpu_warnings))
|
|
810
|
+
checks.extend(_check_compute_spec(gpu_result))
|
|
811
|
+
checks.extend(_check_usb())
|
|
812
|
+
checks.append(_check_just())
|
|
813
|
+
checks.extend(_check_reasoner_llm())
|
|
814
|
+
return checks
|
|
815
|
+
|
|
816
|
+
|
|
817
|
+
# ── CLI commands ──────────────────────────────────────────────────────────────
|
|
818
|
+
|
|
819
|
+
|
|
820
|
+
@app.command()
|
|
821
|
+
def doctor(
|
|
822
|
+
json: bool = typer.Option(False, "--json", help="Output machine-readable JSON"),
|
|
823
|
+
) -> None:
|
|
824
|
+
"""Diagnose the host: Python, OS, ROS 2 distro, GPU, USB devices.
|
|
825
|
+
|
|
826
|
+
Exits 0 when every check is ``ok``, ``absent``, or ``info``; exits 1 if
|
|
827
|
+
any check has status ``fail`` or ``missing``.
|
|
828
|
+
|
|
829
|
+
Example:
|
|
830
|
+
>>> # openral doctor
|
|
831
|
+
>>> # openral doctor --json
|
|
832
|
+
"""
|
|
833
|
+
checks = _gather_checks()
|
|
834
|
+
|
|
835
|
+
if json:
|
|
836
|
+
result = [{"check": c.check, "status": c.status, "details": c.details} for c in checks]
|
|
837
|
+
console.print_json(_json.dumps(result))
|
|
838
|
+
else:
|
|
839
|
+
table = Table(title="openral doctor")
|
|
840
|
+
table.add_column("check", style="bold")
|
|
841
|
+
table.add_column("status")
|
|
842
|
+
table.add_column("details")
|
|
843
|
+
for c in checks:
|
|
844
|
+
style = (
|
|
845
|
+
"green" if c.status == "ok" else "yellow" if c.status in _YELLOW_STATUSES else "red"
|
|
846
|
+
)
|
|
847
|
+
table.add_row(c.check, f"[{style}]{c.status}[/{style}]", c.details)
|
|
848
|
+
console.print(table)
|
|
849
|
+
|
|
850
|
+
fatal = {"fail", "missing"}
|
|
851
|
+
if any(c.status in fatal for c in checks):
|
|
852
|
+
raise typer.Exit(code=1)
|
|
853
|
+
|
|
854
|
+
|
|
855
|
+
@app.command()
|
|
856
|
+
def detect(
|
|
857
|
+
output: Path = typer.Option(
|
|
858
|
+
Path("robot.yaml"), "--output", "-o", help="Output robot.yaml path"
|
|
859
|
+
),
|
|
860
|
+
robot_type: str | None = typer.Option(
|
|
861
|
+
None,
|
|
862
|
+
"--robot",
|
|
863
|
+
"--as",
|
|
864
|
+
help="Force the canonical base manifest (slug e.g. 'so100' or dir name "
|
|
865
|
+
"'so100_follower'), overriding USB/DDS inference. A bare Feetech "
|
|
866
|
+
"plug-in defaults to the SO-101; use this to select the SO-100 (the "
|
|
867
|
+
"two are indistinguishable over USB).",
|
|
868
|
+
),
|
|
869
|
+
report: Path | None = typer.Option(
|
|
870
|
+
None,
|
|
871
|
+
"--report",
|
|
872
|
+
help="Optional path to dump the raw DetectionReport as JSON.",
|
|
873
|
+
),
|
|
874
|
+
dds_timeout: float = typer.Option(
|
|
875
|
+
5.0, "--dds-timeout", help="DDS topic discovery timeout in seconds"
|
|
876
|
+
),
|
|
877
|
+
include: str | None = typer.Option(
|
|
878
|
+
None,
|
|
879
|
+
"--include",
|
|
880
|
+
help="Comma-separated probe names to run (default: all). "
|
|
881
|
+
"Choices: usb, dds, gpu, cameras_v4l2, cameras_realsense, network.",
|
|
882
|
+
),
|
|
883
|
+
no_write: bool = typer.Option(
|
|
884
|
+
False, "--no-write", help="Print summary and skip writing robot.yaml"
|
|
885
|
+
),
|
|
886
|
+
deployment: Path | None = typer.Option(
|
|
887
|
+
None,
|
|
888
|
+
"--deployment",
|
|
889
|
+
help="Also scaffold a DeployScene YAML at this path (robot_id + "
|
|
890
|
+
"`sensors:` bindings from the camera wizard; safety left to the "
|
|
891
|
+
"robot manifest). Paste-able as `openral deploy run --config`.",
|
|
892
|
+
),
|
|
893
|
+
yes: bool = typer.Option(
|
|
894
|
+
False, "--yes", "-y", help="Overwrite existing file without prompting"
|
|
895
|
+
),
|
|
896
|
+
) -> None:
|
|
897
|
+
"""Probe the host and emit a complete RobotDescription robot.yaml.
|
|
898
|
+
|
|
899
|
+
Runs the auto-provisioning flow from ``openral_detect``:
|
|
900
|
+
|
|
901
|
+
1. Probe USB / DDS / GPU / V4L2 / RealSense / network.
|
|
902
|
+
2. Identify the rig (USB VID/PID match or DDS topology). If a
|
|
903
|
+
known robot is detected, load the canonical
|
|
904
|
+
``robots/<name>/robot.yaml`` directly; otherwise synthesize a
|
|
905
|
+
minimal scaffold.
|
|
906
|
+
3. Reverse-look up each detected sensor in the catalog so its
|
|
907
|
+
``SensorSpec`` carries **real** intrinsics, FOV, encoding, rate.
|
|
908
|
+
4. Promote detected GPU / Jetson / Apple Silicon caps onto
|
|
909
|
+
``RobotCapabilities`` so ``openral rskill check`` can match
|
|
910
|
+
``RSkillManifest.runtime`` / ``quantization.dtype``.
|
|
911
|
+
|
|
912
|
+
Example:
|
|
913
|
+
>>> # openral detect
|
|
914
|
+
>>> # openral detect --include gpu,network --no-write
|
|
915
|
+
"""
|
|
916
|
+
import yaml as _yaml
|
|
917
|
+
from openral_detect import (
|
|
918
|
+
assemble_robot_description,
|
|
919
|
+
detect_hardware,
|
|
920
|
+
)
|
|
921
|
+
|
|
922
|
+
include_set: set[str] | None = (
|
|
923
|
+
{p.strip() for p in include.split(",") if p.strip()} if include else None
|
|
924
|
+
)
|
|
925
|
+
|
|
926
|
+
console.print("[bold]openral detect[/bold] — probing host …")
|
|
927
|
+
detection = detect_hardware(dds_timeout_s=dds_timeout, include=include_set)
|
|
928
|
+
|
|
929
|
+
_render_detection_summary(detection)
|
|
930
|
+
|
|
931
|
+
if report is not None:
|
|
932
|
+
report.write_text(detection.model_dump_json(indent=2), encoding="utf-8")
|
|
933
|
+
console.print(f"[green]Wrote[/green] {report} (raw DetectionReport)")
|
|
934
|
+
|
|
935
|
+
# Probe-only inspection short-circuits the builder (keeps CI / --report
|
|
936
|
+
# non-interactive). Building a manifest is always interactive.
|
|
937
|
+
if no_write:
|
|
938
|
+
try:
|
|
939
|
+
canonical = assemble_robot_description(
|
|
940
|
+
detection, force_robot_type=robot_type, enrich_cameras=True
|
|
941
|
+
)
|
|
942
|
+
except ROSConfigError as exc:
|
|
943
|
+
console.print(f"[red]detect:[/red] {exc}")
|
|
944
|
+
raise typer.Exit(code=1) from exc
|
|
945
|
+
console.print("\n[dim]--no-write set — printing yaml to stdout:[/dim]\n")
|
|
946
|
+
console.print(
|
|
947
|
+
_yaml.safe_dump(
|
|
948
|
+
canonical.model_dump(mode="json"), sort_keys=False, default_flow_style=False
|
|
949
|
+
)
|
|
950
|
+
)
|
|
951
|
+
if deployment is not None:
|
|
952
|
+
console.print(
|
|
953
|
+
"[yellow]--deployment ignored under --no-write — no files written.[/yellow]"
|
|
954
|
+
)
|
|
955
|
+
return
|
|
956
|
+
|
|
957
|
+
try:
|
|
958
|
+
canonical = assemble_robot_description(
|
|
959
|
+
detection, force_robot_type=robot_type, enrich_cameras=False
|
|
960
|
+
)
|
|
961
|
+
except ROSConfigError as exc:
|
|
962
|
+
console.print(f"[red]detect:[/red] {exc}")
|
|
963
|
+
raise typer.Exit(code=1) from exc
|
|
964
|
+
|
|
965
|
+
default_name = str(canonical.name)
|
|
966
|
+
robot_name = _prompt_robot_name(default_name)
|
|
967
|
+
|
|
968
|
+
robot_sensors, scene_sensor_specs = _run_camera_binding_wizard(canonical, detection)
|
|
969
|
+
description = canonical.model_copy(
|
|
970
|
+
update={"name": robot_name, "sensors": robot_sensors}, deep=True
|
|
971
|
+
)
|
|
972
|
+
description = _maybe_customize_limits(description)
|
|
973
|
+
|
|
974
|
+
if output == Path("robot.yaml"):
|
|
975
|
+
output = Path("robots") / robot_name / "robot.yaml"
|
|
976
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
977
|
+
|
|
978
|
+
from openral_detect.registry import canonical_robot_path
|
|
979
|
+
|
|
980
|
+
inferred = (
|
|
981
|
+
robot_type
|
|
982
|
+
or detection.ros2.inferred_robot_type
|
|
983
|
+
or next((m.bh_robot_type for m in detection.usb.matches if m.bh_robot_type), None)
|
|
984
|
+
)
|
|
985
|
+
canon_path = canonical_robot_path(inferred) if inferred else None
|
|
986
|
+
canonical_dir = canon_path.parent if canon_path is not None else None
|
|
987
|
+
description = _relocate_file_assets(
|
|
988
|
+
description, canonical_dir=canonical_dir, output_path=output
|
|
989
|
+
)
|
|
990
|
+
|
|
991
|
+
yaml_text = _yaml.safe_dump(
|
|
992
|
+
description.model_dump(mode="json"),
|
|
993
|
+
sort_keys=False,
|
|
994
|
+
default_flow_style=False,
|
|
995
|
+
)
|
|
996
|
+
|
|
997
|
+
if output.exists() and not yes:
|
|
998
|
+
overwrite = typer.confirm(f"{output} already exists. Overwrite?", default=False)
|
|
999
|
+
if not overwrite:
|
|
1000
|
+
console.print("[yellow]Aborted.[/yellow]")
|
|
1001
|
+
raise typer.Exit(code=0)
|
|
1002
|
+
|
|
1003
|
+
output.write_text(yaml_text, encoding="utf-8")
|
|
1004
|
+
console.print(f"\n[green]Wrote[/green] {output} (RobotDescription, {description.name})")
|
|
1005
|
+
console.print(f"[dim]Next step:[/dim] openral rskill check --robot {output}")
|
|
1006
|
+
|
|
1007
|
+
if deployment is not None:
|
|
1008
|
+
_write_deploy_scene_scaffold(
|
|
1009
|
+
deployment, description, scene_sensor_specs, detection=detection, assume_yes=yes
|
|
1010
|
+
)
|
|
1011
|
+
|
|
1012
|
+
|
|
1013
|
+
def _relocate_file_assets(
|
|
1014
|
+
description: RobotDescription, *, canonical_dir: Path | None, output_path: Path
|
|
1015
|
+
) -> RobotDescription:
|
|
1016
|
+
"""Rewrite ``file:`` asset refs to repo-root-relative for a relocated manifest.
|
|
1017
|
+
|
|
1018
|
+
``file:<rel>`` resolves against the manifest dir then the repo root; a
|
|
1019
|
+
relocated manifest loses the manifest-dir hit, so we repoint each ref at
|
|
1020
|
+
``file:<repo-root-relative path to the canonical file>`` (which the repo-root
|
|
1021
|
+
fallback resolves). Mesh paths inside the URDF keep resolving from the
|
|
1022
|
+
canonical file's own location because we point at it in place, not copy it.
|
|
1023
|
+
"""
|
|
1024
|
+
from openral_core.assets import _REPO_ROOT # reason: match the resolver's root exactly
|
|
1025
|
+
|
|
1026
|
+
if canonical_dir is None or canonical_dir.resolve() == output_path.parent.resolve():
|
|
1027
|
+
return description
|
|
1028
|
+
|
|
1029
|
+
root = _REPO_ROOT.resolve()
|
|
1030
|
+
|
|
1031
|
+
def _rewrite(ref: str | None) -> str | None:
|
|
1032
|
+
if not ref or not ref.startswith("file:"):
|
|
1033
|
+
return ref
|
|
1034
|
+
abs_path = (canonical_dir / ref[len("file:") :]).resolve()
|
|
1035
|
+
try:
|
|
1036
|
+
return f"file:{abs_path.relative_to(root)}"
|
|
1037
|
+
except ValueError:
|
|
1038
|
+
return f"file:{abs_path}" # canonical dir outside repo → absolute
|
|
1039
|
+
|
|
1040
|
+
assets = description.assets
|
|
1041
|
+
new_urdf = (
|
|
1042
|
+
assets.urdf.model_copy(update={"ref": _rewrite(assets.urdf.ref)})
|
|
1043
|
+
if assets.urdf is not None
|
|
1044
|
+
else None
|
|
1045
|
+
)
|
|
1046
|
+
new_assets = assets.model_copy(
|
|
1047
|
+
update={"urdf": new_urdf, "mjcf": _rewrite(assets.mjcf), "srdf": _rewrite(assets.srdf)}
|
|
1048
|
+
)
|
|
1049
|
+
return description.model_copy(update={"assets": new_assets})
|
|
1050
|
+
|
|
1051
|
+
|
|
1052
|
+
_ROBOT_NAME_RE = re.compile(r"[a-z0-9_]+")
|
|
1053
|
+
|
|
1054
|
+
|
|
1055
|
+
def _prompt_robot_name(default: str) -> str:
|
|
1056
|
+
"""Prompt for the custom rig name written into robots/<name>/robot.yaml.
|
|
1057
|
+
|
|
1058
|
+
Constrained to the slug convention every in-tree ``robots/<name>/`` dir
|
|
1059
|
+
already uses (lowercase letters, digits, underscores) so the answer can't
|
|
1060
|
+
escape the ``robots/`` tree via ``/`` or break the ``robot_id`` used by
|
|
1061
|
+
``deploy run`` / ``rskill check`` via spaces or other punctuation.
|
|
1062
|
+
Re-prompts on an invalid answer instead of writing it through.
|
|
1063
|
+
|
|
1064
|
+
``default`` itself isn't guaranteed slug-safe — an unrecognised rig's
|
|
1065
|
+
scaffolded name embeds the raw hostname (e.g. ``unknown_my-laptop``),
|
|
1066
|
+
which can carry a hyphen. Sanitize it before offering it so accepting the
|
|
1067
|
+
default with a bare Enter always succeeds instead of re-prompting.
|
|
1068
|
+
"""
|
|
1069
|
+
if not _ROBOT_NAME_RE.fullmatch(default):
|
|
1070
|
+
default = re.sub(r"[^a-z0-9_]+", "_", default.lower()).strip("_") or "robot"
|
|
1071
|
+
while True:
|
|
1072
|
+
name = typer.prompt("Robot name (writes robots/<name>/robot.yaml)", default=default).strip()
|
|
1073
|
+
name = name or default
|
|
1074
|
+
if _ROBOT_NAME_RE.fullmatch(name):
|
|
1075
|
+
return name
|
|
1076
|
+
console.print(
|
|
1077
|
+
f" [yellow]{name!r} invalid — use lowercase letters, digits, underscores "
|
|
1078
|
+
f"(e.g. my_so101_bench).[/yellow]"
|
|
1079
|
+
)
|
|
1080
|
+
|
|
1081
|
+
|
|
1082
|
+
def _prompt_float(label: str, default: float) -> float:
|
|
1083
|
+
"""Enter-to-keep-default float prompt; non-numeric input keeps the default."""
|
|
1084
|
+
raw = typer.prompt(f" {label}", default=str(default), show_default=True).strip()
|
|
1085
|
+
if not raw:
|
|
1086
|
+
return default
|
|
1087
|
+
try:
|
|
1088
|
+
return float(raw)
|
|
1089
|
+
except ValueError:
|
|
1090
|
+
console.print(f" [yellow]not a number — keeping {default}[/yellow]")
|
|
1091
|
+
return default
|
|
1092
|
+
|
|
1093
|
+
|
|
1094
|
+
def _maybe_customize_limits(description: RobotDescription) -> RobotDescription:
|
|
1095
|
+
"""Opt-in per-joint limit + safety-scalar override; declining inherits canonical.
|
|
1096
|
+
|
|
1097
|
+
Every ``JointSpec`` field (``position_limits``/``velocity_limit``/
|
|
1098
|
+
``effort_limit``) is Optional — a canonical manifest may leave any of them
|
|
1099
|
+
unset. Only prompt for a field when the canonical value is present; a
|
|
1100
|
+
``None`` field is carried through unchanged (never coerced to a number).
|
|
1101
|
+
"""
|
|
1102
|
+
if not typer.confirm("Customize joint limits & safety envelope?", default=False):
|
|
1103
|
+
return description
|
|
1104
|
+
|
|
1105
|
+
new_joints = []
|
|
1106
|
+
for j in description.joints:
|
|
1107
|
+
update: dict[str, object] = {}
|
|
1108
|
+
if j.position_limits is not None:
|
|
1109
|
+
lo = _prompt_float(f"{j.name} position min", j.position_limits[0])
|
|
1110
|
+
hi = _prompt_float(f"{j.name} position max", j.position_limits[1])
|
|
1111
|
+
update["position_limits"] = (lo, hi)
|
|
1112
|
+
if j.velocity_limit is not None:
|
|
1113
|
+
update["velocity_limit"] = _prompt_float(f"{j.name} velocity_limit", j.velocity_limit)
|
|
1114
|
+
if j.effort_limit is not None:
|
|
1115
|
+
update["effort_limit"] = _prompt_float(f"{j.name} effort_limit", j.effort_limit)
|
|
1116
|
+
new_joints.append(j.model_copy(update=update) if update else j)
|
|
1117
|
+
|
|
1118
|
+
s = description.safety
|
|
1119
|
+
new_safety = s.model_copy(
|
|
1120
|
+
update={
|
|
1121
|
+
"max_ee_speed_m_s": _prompt_float("safety max_ee_speed_m_s", s.max_ee_speed_m_s),
|
|
1122
|
+
"max_joint_speed_factor": _prompt_float(
|
|
1123
|
+
"safety max_joint_speed_factor", s.max_joint_speed_factor
|
|
1124
|
+
),
|
|
1125
|
+
"max_force_n": _prompt_float("safety max_force_n", s.max_force_n),
|
|
1126
|
+
"max_torque_nm": _prompt_float("safety max_torque_nm", s.max_torque_nm),
|
|
1127
|
+
}
|
|
1128
|
+
)
|
|
1129
|
+
return description.model_copy(update={"joints": new_joints, "safety": new_safety}, deep=True)
|
|
1130
|
+
|
|
1131
|
+
|
|
1132
|
+
def _iter_wizard_cameras(detection: DetectionReport) -> list[tuple[str, str, str]]:
|
|
1133
|
+
"""(device_path, human_label, serial) per bindable camera.
|
|
1134
|
+
|
|
1135
|
+
V4L2 cameras bind on ``device_path`` (``serial`` empty). RealSense/Orbbec
|
|
1136
|
+
depth cameras have no ``/dev/video`` path here, so ``device_path`` is
|
|
1137
|
+
empty and the binding keys on ``serial`` instead.
|
|
1138
|
+
"""
|
|
1139
|
+
cams: list[tuple[str, str, str]] = [(c.device_path, c.name, "") for c in detection.cameras.v4l2]
|
|
1140
|
+
cams += [
|
|
1141
|
+
("", f"{d.name} (realsense {d.serial})", d.serial) for d in detection.cameras.realsense
|
|
1142
|
+
]
|
|
1143
|
+
cams += [("", f"{d.name} (orbbec {d.serial})", d.serial) for d in detection.cameras.orbbec]
|
|
1144
|
+
return cams
|
|
1145
|
+
|
|
1146
|
+
|
|
1147
|
+
def _run_camera_binding_wizard( # noqa: PLR0915 # reason: one linear per-camera bind loop (thumbnail → prompt → dedup → reuse/new/workcell branch); splitting it hurts readability
|
|
1148
|
+
canonical: RobotDescription, detection: DetectionReport
|
|
1149
|
+
) -> tuple[list[SensorSpec], list[SensorSpec]]:
|
|
1150
|
+
"""Bind each detected camera to a sensor.
|
|
1151
|
+
|
|
1152
|
+
Returns ``(robot_sensors, workcell_sensors)``: a canonical name reuses that
|
|
1153
|
+
manifest ``SensorSpec`` verbatim (+ real device binding) → robot manifest;
|
|
1154
|
+
a ``w:<name>`` answer → workcell camera → DeployScene; a new name → new
|
|
1155
|
+
robot sensor. Enter skips the device (dropping unbound canonical sensors).
|
|
1156
|
+
"""
|
|
1157
|
+
import tempfile
|
|
1158
|
+
|
|
1159
|
+
from openral_core import SensorDeployBinding, SensorSpec
|
|
1160
|
+
|
|
1161
|
+
canonical_rgb = {s.name: s for s in canonical.sensors if s.modality == "rgb"}
|
|
1162
|
+
robot_sensors: list[SensorSpec] = []
|
|
1163
|
+
workcell_sensors: list[SensorSpec] = []
|
|
1164
|
+
# Names actually bound so far, keyed by target manifest — a canonical-name
|
|
1165
|
+
# reuse or a brand-new sensor both claim a robot-sensor name; a `w:<name>`
|
|
1166
|
+
# answer claims a workcell-sensor name. Skipping a camera (Enter) never
|
|
1167
|
+
# claims anything. Seeded empty: canonical names only become claimed once
|
|
1168
|
+
# an operator actually binds a camera to them.
|
|
1169
|
+
robot_claimed: set[str] = set()
|
|
1170
|
+
workcell_claimed: set[str] = set()
|
|
1171
|
+
thumb_dir = Path(tempfile.mkdtemp(prefix="openral_detect_cams_"))
|
|
1172
|
+
|
|
1173
|
+
for device_path, label, serial in _iter_wizard_cameras(detection):
|
|
1174
|
+
thumb = _grab_camera_thumbnail(device_path, thumb_dir) if device_path else None
|
|
1175
|
+
console.print(
|
|
1176
|
+
f"\n[bold]{device_path or label}[/bold] — {label}"
|
|
1177
|
+
+ (f" [dim](thumbnail: {thumb})[/dim]" if thumb else " [dim](no thumbnail)[/dim]")
|
|
1178
|
+
)
|
|
1179
|
+
options = ", ".join(canonical_rgb) if canonical_rgb else "<none in manifest>"
|
|
1180
|
+
|
|
1181
|
+
# Re-prompt the same camera when the resolved target name is already
|
|
1182
|
+
# claimed, so two cameras can never land on the same sensor name
|
|
1183
|
+
# (RobotDescription/DeployScene have no uniqueness validator).
|
|
1184
|
+
answer = ""
|
|
1185
|
+
while True:
|
|
1186
|
+
answer = typer.prompt(
|
|
1187
|
+
f" Which sensor is this? [{options}] or w:<name> for a workcell "
|
|
1188
|
+
f"camera (Enter = skip)",
|
|
1189
|
+
default="",
|
|
1190
|
+
show_default=False,
|
|
1191
|
+
).strip()
|
|
1192
|
+
if not answer:
|
|
1193
|
+
break
|
|
1194
|
+
if answer.startswith("w:") and answer.removeprefix("w:").strip():
|
|
1195
|
+
name = answer.removeprefix("w:").strip()
|
|
1196
|
+
if name in workcell_claimed:
|
|
1197
|
+
console.print(
|
|
1198
|
+
f" [yellow]{name!r} already bound to another camera — choose "
|
|
1199
|
+
f"a different sensor.[/yellow]"
|
|
1200
|
+
)
|
|
1201
|
+
continue
|
|
1202
|
+
elif answer in robot_claimed:
|
|
1203
|
+
console.print(
|
|
1204
|
+
f" [yellow]{answer!r} already bound to another camera — choose "
|
|
1205
|
+
f"a different sensor.[/yellow]"
|
|
1206
|
+
)
|
|
1207
|
+
continue
|
|
1208
|
+
break
|
|
1209
|
+
|
|
1210
|
+
if not answer:
|
|
1211
|
+
continue
|
|
1212
|
+
params: dict[str, object] = {"fps": 30}
|
|
1213
|
+
if device_path:
|
|
1214
|
+
params["device"] = device_path
|
|
1215
|
+
elif serial:
|
|
1216
|
+
params["serial"] = serial
|
|
1217
|
+
binding = SensorDeployBinding(backend_params=params if (device_path or serial) else {})
|
|
1218
|
+
if answer in canonical_rgb:
|
|
1219
|
+
ref = canonical_rgb[answer]
|
|
1220
|
+
console.print(
|
|
1221
|
+
f" [yellow]reusing canonical intrinsics for {answer!r} — best to "
|
|
1222
|
+
f"supply your rig's calibrated fx/fy/cx/cy.[/yellow]"
|
|
1223
|
+
)
|
|
1224
|
+
robot_sensors.append(ref.model_copy(update={"deploy_binding": binding}, deep=True))
|
|
1225
|
+
robot_claimed.add(answer)
|
|
1226
|
+
console.print(
|
|
1227
|
+
f" [green]bound[/green] {answer} → {device_path or label} (robot sensor)"
|
|
1228
|
+
)
|
|
1229
|
+
elif answer.startswith("w:") and answer.removeprefix("w:").strip():
|
|
1230
|
+
name = answer.removeprefix("w:").strip()
|
|
1231
|
+
workcell_sensors.append(
|
|
1232
|
+
SensorSpec(
|
|
1233
|
+
name=name,
|
|
1234
|
+
modality="rgb",
|
|
1235
|
+
frame_id=name,
|
|
1236
|
+
rate_hz=30.0,
|
|
1237
|
+
deploy_binding=binding,
|
|
1238
|
+
)
|
|
1239
|
+
)
|
|
1240
|
+
workcell_claimed.add(name)
|
|
1241
|
+
console.print(f" [green]workcell[/green] {name} → {device_path or label}")
|
|
1242
|
+
else:
|
|
1243
|
+
parent = typer.prompt(" New robot sensor — parent_frame", default="base").strip()
|
|
1244
|
+
console.print(
|
|
1245
|
+
f" [yellow]new sensor {answer!r} uses generic intrinsics — supply "
|
|
1246
|
+
f"calibrated fx/fy/cx/cy before production.[/yellow]"
|
|
1247
|
+
)
|
|
1248
|
+
robot_sensors.append(
|
|
1249
|
+
SensorSpec(
|
|
1250
|
+
name=answer,
|
|
1251
|
+
modality="rgb",
|
|
1252
|
+
frame_id=f"{answer}_optical_frame",
|
|
1253
|
+
parent_frame=parent or "base",
|
|
1254
|
+
rate_hz=30.0,
|
|
1255
|
+
encoding="rgb8",
|
|
1256
|
+
deploy_binding=binding,
|
|
1257
|
+
)
|
|
1258
|
+
)
|
|
1259
|
+
robot_claimed.add(answer)
|
|
1260
|
+
console.print(
|
|
1261
|
+
f" [green]bound[/green] {answer} → {device_path or label} (new robot sensor)"
|
|
1262
|
+
)
|
|
1263
|
+
|
|
1264
|
+
return robot_sensors, workcell_sensors
|
|
1265
|
+
|
|
1266
|
+
|
|
1267
|
+
# Print-once guard for the "opencv missing" hint. A set (mutated, never
|
|
1268
|
+
# rebound) avoids a `global` statement while staying a single-process latch.
|
|
1269
|
+
_CV2_WARNED: set[str] = set()
|
|
1270
|
+
|
|
1271
|
+
|
|
1272
|
+
def _grab_camera_thumbnail(device_path: str, out_dir: Path) -> Path | None:
|
|
1273
|
+
"""Grab one JPEG frame from ``device_path`` so the operator can SEE the camera.
|
|
1274
|
+
|
|
1275
|
+
Best-effort: returns ``None`` when opencv is missing or the device won't
|
|
1276
|
+
deliver a frame (in use, no permission). Never raises — a thumbnail is a
|
|
1277
|
+
convenience, not a requirement.
|
|
1278
|
+
"""
|
|
1279
|
+
try:
|
|
1280
|
+
import cv2 # reason: optional dep, ships with the CLI
|
|
1281
|
+
except ImportError:
|
|
1282
|
+
if not _CV2_WARNED:
|
|
1283
|
+
console.print("[yellow]opencv not installed — camera thumbnails disabled.[/yellow]")
|
|
1284
|
+
_CV2_WARNED.add("warned")
|
|
1285
|
+
return None
|
|
1286
|
+
cap = cv2.VideoCapture(device_path)
|
|
1287
|
+
try:
|
|
1288
|
+
ok, frame = cap.read()
|
|
1289
|
+
finally:
|
|
1290
|
+
cap.release()
|
|
1291
|
+
if not ok or frame is None:
|
|
1292
|
+
return None
|
|
1293
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
1294
|
+
out = out_dir / (device_path.strip("/").replace("/", "_") + ".jpg")
|
|
1295
|
+
if not cv2.imwrite(str(out), frame):
|
|
1296
|
+
return None
|
|
1297
|
+
return out
|
|
1298
|
+
|
|
1299
|
+
|
|
1300
|
+
def _write_deploy_scene_scaffold(
|
|
1301
|
+
path: Path,
|
|
1302
|
+
description: RobotDescription,
|
|
1303
|
+
sensor_specs: list[SensorSpec],
|
|
1304
|
+
*,
|
|
1305
|
+
detection: DetectionReport,
|
|
1306
|
+
assume_yes: bool,
|
|
1307
|
+
) -> None:
|
|
1308
|
+
"""Scaffold a ``DeployScene`` YAML next to the detected robot.
|
|
1309
|
+
|
|
1310
|
+
Carries ``robot_id`` + the wizard's workspace/workcell camera bindings —
|
|
1311
|
+
``sensor_specs`` here are always workcell cameras; robot-mounted cameras
|
|
1312
|
+
live in the robot manifest itself and never reach this scaffold. ``safety``
|
|
1313
|
+
is left unset so the robot manifest's own envelope applies. Validated
|
|
1314
|
+
through :class:`DeployScene` before writing so a malformed scaffold fails
|
|
1315
|
+
here, not on ``deploy run``.
|
|
1316
|
+
"""
|
|
1317
|
+
import yaml as _yaml
|
|
1318
|
+
from openral_core import DeployScene, HalParameters, SceneSpec
|
|
1319
|
+
|
|
1320
|
+
robot_id = str(description.name)
|
|
1321
|
+
# Seed the scene's HAL binding from the robot manifest's own
|
|
1322
|
+
# hal.parameters.defaults (e.g. serial port) + lerobot calibration
|
|
1323
|
+
# placeholders, so the scaffolded scene is a self-contained `deploy run`
|
|
1324
|
+
# target (no `--hal` needed once the operator fills the real port + commits
|
|
1325
|
+
# the calibration). Only serial (`port`) HALs get the calibration stub.
|
|
1326
|
+
hal_defaults: dict[str, object] = dict(description.hal.parameters.defaults)
|
|
1327
|
+
if "port" in hal_defaults:
|
|
1328
|
+
hal_defaults.setdefault("id", f"{robot_id}")
|
|
1329
|
+
hal_defaults.setdefault("calibration_dir", "calibration")
|
|
1330
|
+
hal_defaults.setdefault("calibrate_on_connect", False)
|
|
1331
|
+
# Override the stale manifest default port with the one actually probed on
|
|
1332
|
+
# this host, so the scaffolded scene targets real hardware out of the box.
|
|
1333
|
+
detected_port = next((m.device.port for m in detection.usb.matches if m.device.port), None)
|
|
1334
|
+
if detected_port and "port" in hal_defaults:
|
|
1335
|
+
hal_defaults["port"] = detected_port
|
|
1336
|
+
scene = DeployScene(
|
|
1337
|
+
scene=SceneSpec(id=f"{robot_id}_workcell"),
|
|
1338
|
+
robot_id=robot_id,
|
|
1339
|
+
hal=HalParameters(defaults=hal_defaults) if hal_defaults else None,
|
|
1340
|
+
sensors=list(sensor_specs),
|
|
1341
|
+
)
|
|
1342
|
+
if path.exists() and not assume_yes:
|
|
1343
|
+
overwrite = typer.confirm(f"{path} already exists. Overwrite?", default=False)
|
|
1344
|
+
if not overwrite:
|
|
1345
|
+
console.print("[yellow]Deployment scaffold aborted.[/yellow]")
|
|
1346
|
+
return
|
|
1347
|
+
banner = (
|
|
1348
|
+
"# DeployScene scaffolded by `openral detect --deployment` — review before\n"
|
|
1349
|
+
"# `openral deploy run --config <this file>`.\n"
|
|
1350
|
+
"# - safety: unset → the robot manifest's envelope applies as-is.\n"
|
|
1351
|
+
"# - hal: host-specific HAL binding — set the real serial `port` for this\n"
|
|
1352
|
+
"# host and commit the lerobot calibration to `<scene dir>/calibration/`\n"
|
|
1353
|
+
"# (a relative `calibration_dir` resolves against this file's directory).\n"
|
|
1354
|
+
"# - sensors: workspace (workcell) camera bindings; robot-mounted cameras\n"
|
|
1355
|
+
"# live in the robot manifest.\n"
|
|
1356
|
+
"# - No rSkill is pinned here: the reasoner selects it at runtime.\n"
|
|
1357
|
+
)
|
|
1358
|
+
path.write_text(
|
|
1359
|
+
banner
|
|
1360
|
+
+ _yaml.safe_dump(
|
|
1361
|
+
scene.model_dump(mode="json", exclude_none=True),
|
|
1362
|
+
sort_keys=False,
|
|
1363
|
+
default_flow_style=False,
|
|
1364
|
+
),
|
|
1365
|
+
encoding="utf-8",
|
|
1366
|
+
)
|
|
1367
|
+
console.print(f"[green]Wrote[/green] {path} (DeployScene, {robot_id})")
|
|
1368
|
+
console.print(f"[dim]Next step:[/dim] openral deploy run --config {path}")
|
|
1369
|
+
|
|
1370
|
+
# Serial (`port`) HALs are the lerobot Feetech followers (SO-100/SO-101/…);
|
|
1371
|
+
# they cannot connect without a lerobot calibration (per-servo IDs + homing
|
|
1372
|
+
# offsets). Nudge the operator to supply one when the calibration dir is
|
|
1373
|
+
# still empty, rather than letting `deploy run` fail on first connect.
|
|
1374
|
+
if "port" in hal_defaults:
|
|
1375
|
+
cal_dir = path.parent / str(hal_defaults.get("calibration_dir", "calibration"))
|
|
1376
|
+
cal_file = f"{hal_defaults.get('id', robot_id)}.json"
|
|
1377
|
+
has_cal = cal_dir.is_dir() and any(cal_dir.glob("*.json"))
|
|
1378
|
+
if not has_cal:
|
|
1379
|
+
console.print(
|
|
1380
|
+
"[yellow]Calibration required:[/yellow] this arm's serial HAL needs a "
|
|
1381
|
+
"lerobot calibration (per-servo IDs + homing offsets) before "
|
|
1382
|
+
"`deploy run` can connect.\n"
|
|
1383
|
+
f" Link an existing calibration into [bold]{cal_dir}/{cal_file}[/bold], "
|
|
1384
|
+
"or generate one with the lerobot calibrate flow:\n"
|
|
1385
|
+
" [cyan]https://huggingface.co/docs/lerobot/v0.6.0/en/so101#calibrate[/cyan]"
|
|
1386
|
+
)
|
|
1387
|
+
|
|
1388
|
+
|
|
1389
|
+
def _render_detection_summary(detection: object) -> None:
|
|
1390
|
+
"""Print a compact per-probe summary table of the detection report."""
|
|
1391
|
+
from openral_detect import DetectionReport
|
|
1392
|
+
|
|
1393
|
+
assert isinstance(detection, DetectionReport) # reason: typed input
|
|
1394
|
+
table = Table(title="openral detect")
|
|
1395
|
+
table.add_column("probe", style="bold")
|
|
1396
|
+
table.add_column("result")
|
|
1397
|
+
table.add_row(
|
|
1398
|
+
"usb", f"{len(detection.usb.devices)} device(s), {len(detection.usb.matches)} matched"
|
|
1399
|
+
)
|
|
1400
|
+
if detection.gpu.nvidia:
|
|
1401
|
+
table.add_row(
|
|
1402
|
+
"gpu (nvidia)",
|
|
1403
|
+
", ".join(f"{g.name} ({g.vram_total_mib // 1024} GiB)" for g in detection.gpu.nvidia),
|
|
1404
|
+
)
|
|
1405
|
+
if detection.gpu.jetson is not None:
|
|
1406
|
+
table.add_row(
|
|
1407
|
+
"gpu (jetson)", f"{detection.gpu.jetson.board} ({detection.gpu.jetson.tops:.0f} TOPS)"
|
|
1408
|
+
)
|
|
1409
|
+
if detection.gpu.apple_silicon is not None:
|
|
1410
|
+
table.add_row("gpu (apple)", detection.gpu.apple_silicon.chip)
|
|
1411
|
+
if (
|
|
1412
|
+
not detection.gpu.nvidia
|
|
1413
|
+
and detection.gpu.jetson is None
|
|
1414
|
+
and detection.gpu.apple_silicon is None
|
|
1415
|
+
):
|
|
1416
|
+
table.add_row("gpu", "[yellow]none detected[/yellow]")
|
|
1417
|
+
table.add_row(
|
|
1418
|
+
"cameras",
|
|
1419
|
+
f"v4l2={len(detection.cameras.v4l2)}, "
|
|
1420
|
+
f"realsense={len(detection.cameras.realsense)}, "
|
|
1421
|
+
f"orbbec={len(detection.cameras.orbbec)}",
|
|
1422
|
+
)
|
|
1423
|
+
inferred = detection.ros2.inferred_robot_type or "-"
|
|
1424
|
+
table.add_row(
|
|
1425
|
+
"ros2",
|
|
1426
|
+
f"{len(detection.ros2.topics)} topic(s), inferred={inferred}",
|
|
1427
|
+
)
|
|
1428
|
+
table.add_row(
|
|
1429
|
+
"network",
|
|
1430
|
+
f"{detection.network.hostname}, "
|
|
1431
|
+
f"{len(detection.network.interfaces)} iface(s), "
|
|
1432
|
+
f"route={detection.network.default_route or '-'}",
|
|
1433
|
+
)
|
|
1434
|
+
console.print(table)
|
|
1435
|
+
if detection.warnings:
|
|
1436
|
+
console.print("[dim]warnings:[/dim]")
|
|
1437
|
+
for w in detection.warnings:
|
|
1438
|
+
console.print(f" [yellow]·[/yellow] {w}")
|
|
1439
|
+
|
|
1440
|
+
|
|
1441
|
+
@app.command()
|
|
1442
|
+
def connect(
|
|
1443
|
+
robot: str = typer.Option(..., help="Robot type (so100, so101, g1, ur5e, …)"),
|
|
1444
|
+
port: str = typer.Option("", "--port", help="USB/serial port override, e.g. /dev/ttyUSB0"),
|
|
1445
|
+
) -> None:
|
|
1446
|
+
"""Open a HAL connection to a robot, read one joint state, and disconnect.
|
|
1447
|
+
|
|
1448
|
+
Exits 0 on success; exits 1 with an error message on failure.
|
|
1449
|
+
|
|
1450
|
+
Supported robots: so100, so101 (both drive the shared ``SO100FollowerHAL``
|
|
1451
|
+
Feetech serial bus — the SO-101 is the same controller as the SO-100).
|
|
1452
|
+
|
|
1453
|
+
Example:
|
|
1454
|
+
>>> # openral connect --robot so100
|
|
1455
|
+
>>> # openral connect --robot so101 --port /dev/ttyUSB1
|
|
1456
|
+
"""
|
|
1457
|
+
# so100 and so101 share the Feetech SO100FollowerHAL (identical USB
|
|
1458
|
+
# controller + driver); the label only changes the console banner.
|
|
1459
|
+
so_follower_labels = {"so100": "SO-100", "so101": "SO-101"}
|
|
1460
|
+
if robot in so_follower_labels:
|
|
1461
|
+
_connect_so_follower(so_follower_labels[robot], port or "/dev/ttyUSB0")
|
|
1462
|
+
else:
|
|
1463
|
+
console.print(f"[red]Unknown robot '{robot}'. Supported: so100, so101[/red]")
|
|
1464
|
+
raise typer.Exit(code=1)
|
|
1465
|
+
|
|
1466
|
+
|
|
1467
|
+
def _connect_so_follower(label: str, port: str) -> None:
|
|
1468
|
+
"""Connect to an SO-100/SO-101 follower arm, read state, and disconnect."""
|
|
1469
|
+
try:
|
|
1470
|
+
from openral_hal.so100_follower import SO100FollowerHAL
|
|
1471
|
+
except ImportError:
|
|
1472
|
+
console.print("[red]openral-hal is not installed. Run: uv sync --all-packages[/red]")
|
|
1473
|
+
raise typer.Exit(code=1) # noqa: B904
|
|
1474
|
+
|
|
1475
|
+
hal = SO100FollowerHAL(port=port)
|
|
1476
|
+
console.print(f"Connecting to {label} on [bold]{port}[/bold] …")
|
|
1477
|
+
try:
|
|
1478
|
+
hal.connect()
|
|
1479
|
+
except ROSConfigError as exc:
|
|
1480
|
+
console.print(f"[red]Configuration error:[/red] {exc}")
|
|
1481
|
+
raise typer.Exit(code=1) # noqa: B904
|
|
1482
|
+
except ROSRuntimeError as exc:
|
|
1483
|
+
console.print(f"[red]Runtime error:[/red] {exc}")
|
|
1484
|
+
raise typer.Exit(code=1) # noqa: B904
|
|
1485
|
+
|
|
1486
|
+
try:
|
|
1487
|
+
state = hal.read_state()
|
|
1488
|
+
joint_summary = ", ".join(
|
|
1489
|
+
f"{n}={v:.3f} rad" for n, v in zip(state.name, state.position, strict=True)
|
|
1490
|
+
)
|
|
1491
|
+
console.print(f"[green]Connected.[/green] Joint state: {joint_summary}")
|
|
1492
|
+
finally:
|
|
1493
|
+
hal.disconnect()
|
|
1494
|
+
console.print("Disconnected.")
|
|
1495
|
+
|
|
1496
|
+
|
|
1497
|
+
# ── calibrate sub-app ─────────────────────────────────────────────────────────
|
|
1498
|
+
|
|
1499
|
+
calibrate_app = typer.Typer(
|
|
1500
|
+
name="calibrate",
|
|
1501
|
+
help="Sensor calibration helpers.",
|
|
1502
|
+
no_args_is_help=True,
|
|
1503
|
+
)
|
|
1504
|
+
app.add_typer(calibrate_app, name="calibrate")
|
|
1505
|
+
|
|
1506
|
+
|
|
1507
|
+
@calibrate_app.command("camera")
|
|
1508
|
+
def calibrate_camera(
|
|
1509
|
+
sensor: str = typer.Option(
|
|
1510
|
+
...,
|
|
1511
|
+
"--sensor",
|
|
1512
|
+
"-s",
|
|
1513
|
+
help="Sensor name as it appears in robot.yaml (e.g. head_color).",
|
|
1514
|
+
),
|
|
1515
|
+
topic: str = typer.Option(
|
|
1516
|
+
"",
|
|
1517
|
+
"--topic",
|
|
1518
|
+
help="Override the image topic (default: derived from sensor name).",
|
|
1519
|
+
),
|
|
1520
|
+
chessboard_size: str = typer.Option(
|
|
1521
|
+
"8x6",
|
|
1522
|
+
"--chessboard-size",
|
|
1523
|
+
help="Internal corners COLSxROWS of the calibration target.",
|
|
1524
|
+
),
|
|
1525
|
+
square_size: float = typer.Option(
|
|
1526
|
+
0.025,
|
|
1527
|
+
"--square-size",
|
|
1528
|
+
help="Physical size of one chessboard square in metres.",
|
|
1529
|
+
),
|
|
1530
|
+
dry_run: bool = typer.Option(
|
|
1531
|
+
False,
|
|
1532
|
+
"--dry-run",
|
|
1533
|
+
help="Print the command instead of executing it.",
|
|
1534
|
+
),
|
|
1535
|
+
) -> None:
|
|
1536
|
+
r"""Calibrate a camera sensor using the ROS 2 camera_calibration package.
|
|
1537
|
+
|
|
1538
|
+
Builds and optionally runs::
|
|
1539
|
+
|
|
1540
|
+
ros2 run camera_calibration cameracalibrator \
|
|
1541
|
+
--size COLSxROWS --square SIZE \
|
|
1542
|
+
--ros-args -r image:=TOPIC -r camera_info:=INFO_TOPIC
|
|
1543
|
+
|
|
1544
|
+
Requires ``ros2_camera_calibration`` to be installed and ROS 2 sourced.
|
|
1545
|
+
|
|
1546
|
+
Example:
|
|
1547
|
+
>>> # openral calibrate camera --sensor head_color --chessboard-size 8x6 --square-size 0.025
|
|
1548
|
+
>>> # openral calibrate camera --sensor head_color --dry-run
|
|
1549
|
+
"""
|
|
1550
|
+
try:
|
|
1551
|
+
cols_str, rows_str = chessboard_size.lower().split("x")
|
|
1552
|
+
cols, rows = int(cols_str), int(rows_str)
|
|
1553
|
+
except ValueError:
|
|
1554
|
+
console.print(
|
|
1555
|
+
f"[red]Invalid --chessboard-size '{chessboard_size}'. "
|
|
1556
|
+
"Expected format: COLSxROWS, e.g. 8x6[/red]"
|
|
1557
|
+
)
|
|
1558
|
+
raise typer.Exit(code=1) # noqa: B904
|
|
1559
|
+
|
|
1560
|
+
image_topic = topic or f"/{sensor}/image_raw"
|
|
1561
|
+
info_topic = image_topic.replace("/image_raw", "/camera_info").replace(
|
|
1562
|
+
"/image_rect_raw", "/camera_info"
|
|
1563
|
+
)
|
|
1564
|
+
|
|
1565
|
+
cmd = [
|
|
1566
|
+
"ros2",
|
|
1567
|
+
"run",
|
|
1568
|
+
"camera_calibration",
|
|
1569
|
+
"cameracalibrator",
|
|
1570
|
+
"--size",
|
|
1571
|
+
f"{cols}x{rows}",
|
|
1572
|
+
"--square",
|
|
1573
|
+
str(square_size),
|
|
1574
|
+
"--ros-args",
|
|
1575
|
+
"-r",
|
|
1576
|
+
f"image:={image_topic}",
|
|
1577
|
+
"-r",
|
|
1578
|
+
f"camera_info:={info_topic}",
|
|
1579
|
+
]
|
|
1580
|
+
|
|
1581
|
+
if dry_run:
|
|
1582
|
+
console.print("[bold]openral calibrate camera[/bold] — dry run:")
|
|
1583
|
+
console.print(" ".join(cmd))
|
|
1584
|
+
return
|
|
1585
|
+
|
|
1586
|
+
ros2_bin = shutil.which("ros2")
|
|
1587
|
+
if ros2_bin is None:
|
|
1588
|
+
console.print(
|
|
1589
|
+
"[red]ros2 not found. Source your ROS 2 installation first:[/red]\n"
|
|
1590
|
+
" source /opt/ros/<distro>/setup.bash"
|
|
1591
|
+
)
|
|
1592
|
+
raise typer.Exit(code=1)
|
|
1593
|
+
|
|
1594
|
+
console.print(f"[bold]openral calibrate camera[/bold] — sensor: [cyan]{sensor}[/cyan]")
|
|
1595
|
+
console.print(f" image topic : [dim]{image_topic}[/dim]")
|
|
1596
|
+
console.print(f" camera info : [dim]{info_topic}[/dim]")
|
|
1597
|
+
console.print(f" target size : [dim]{cols}x{rows} squares @ {square_size} m[/dim]")
|
|
1598
|
+
console.print("Running camera_calibration … (Ctrl+C to abort)")
|
|
1599
|
+
|
|
1600
|
+
result = subprocess.run(cmd, check=False)
|
|
1601
|
+
if result.returncode != 0:
|
|
1602
|
+
console.print(f"[red]cameracalibrator exited with code {result.returncode}.[/red]")
|
|
1603
|
+
raise typer.Exit(code=result.returncode)
|
|
1604
|
+
|
|
1605
|
+
|
|
1606
|
+
if __name__ == "__main__":
|
|
1607
|
+
app()
|
|
1608
|
+
|
|
1609
|
+
|
|
1610
|
+
# ── rskill sub-app ────────────────────────────────────────────────────────────
|
|
1611
|
+
|
|
1612
|
+
rskill_app = typer.Typer(
|
|
1613
|
+
name="rskill",
|
|
1614
|
+
help="rSkill package management — install and list robot skills from the HF Hub.",
|
|
1615
|
+
no_args_is_help=True,
|
|
1616
|
+
)
|
|
1617
|
+
app.add_typer(rskill_app, name="rskill")
|
|
1618
|
+
|
|
1619
|
+
#: Canonical HF Hub org for first-party rSkills. Used to suggest a
|
|
1620
|
+
#: repair when ``rskill install`` is handed an org-less id, and as the ``author``
|
|
1621
|
+
#: filter for ``rskill search``.
|
|
1622
|
+
_DEFAULT_RSKILL_ORG: Final[str] = "OpenRAL"
|
|
1623
|
+
|
|
1624
|
+
|
|
1625
|
+
@rskill_app.command("install")
|
|
1626
|
+
def rskill_install(
|
|
1627
|
+
hub_id: str = typer.Argument(
|
|
1628
|
+
...,
|
|
1629
|
+
metavar="HUB_ID",
|
|
1630
|
+
help="HF Hub repository, e.g. OpenRAL/rskill-smolvla-libero",
|
|
1631
|
+
),
|
|
1632
|
+
revision: str = typer.Option(
|
|
1633
|
+
"",
|
|
1634
|
+
"--revision",
|
|
1635
|
+
"-r",
|
|
1636
|
+
help="Git commit SHA or branch to pin (recommended for reproducibility).",
|
|
1637
|
+
),
|
|
1638
|
+
force: bool = typer.Option(
|
|
1639
|
+
False,
|
|
1640
|
+
"--force",
|
|
1641
|
+
"-f",
|
|
1642
|
+
help="Re-download even if cached files already exist.",
|
|
1643
|
+
),
|
|
1644
|
+
non_commercial: bool = typer.Option(
|
|
1645
|
+
False,
|
|
1646
|
+
"--non-commercial",
|
|
1647
|
+
help="Declare non-commercial research intent (relaxes NVIDIA non-commercial guard).",
|
|
1648
|
+
),
|
|
1649
|
+
yes: bool = typer.Option(
|
|
1650
|
+
False,
|
|
1651
|
+
"--yes",
|
|
1652
|
+
"-y",
|
|
1653
|
+
help="Skip confirmation prompt for proprietary or non-commercial licenses.",
|
|
1654
|
+
),
|
|
1655
|
+
) -> None:
|
|
1656
|
+
"""Download an rSkill from the HF Hub, validate it, and register it locally.
|
|
1657
|
+
|
|
1658
|
+
Fetches ``rskill.yaml`` from the repository, validates the manifest,
|
|
1659
|
+
surfaces the license to the terminal, then downloads the weights snapshot
|
|
1660
|
+
into the local HF Hub cache (``~/.cache/openral/rskills/``).
|
|
1661
|
+
|
|
1662
|
+
The rSkill is registered in ``~/.local/share/openral/rskills.json`` and
|
|
1663
|
+
can be listed with ``openral rskill list``.
|
|
1664
|
+
|
|
1665
|
+
Example:
|
|
1666
|
+
>>> # openral rskill install OpenRAL/rskill-smolvla-libero
|
|
1667
|
+
>>> # openral rskill install OpenRAL/rskill-smolvla-libero --revision abc1234
|
|
1668
|
+
"""
|
|
1669
|
+
from openral_rskill.loader import rSkill
|
|
1670
|
+
|
|
1671
|
+
# ── Step 0: a HF repo id needs an `org/name` shape. A bare name (the most
|
|
1672
|
+
# common paste mistake) otherwise 404s against a non-existent top-level repo
|
|
1673
|
+
# — fail fast with the canonical suggestion instead of a raw Hub stack trace.
|
|
1674
|
+
if "/" not in hub_id:
|
|
1675
|
+
suggestion = f"{_DEFAULT_RSKILL_ORG}/{hub_id}"
|
|
1676
|
+
console.print(
|
|
1677
|
+
f"[red]Not a Hub repo id:[/red] '{hub_id}' has no org prefix (expected `org/name`)."
|
|
1678
|
+
)
|
|
1679
|
+
console.print(f" Did you mean: [cyan]openral rskill install {suggestion}[/cyan]")
|
|
1680
|
+
console.print(f" Or find it: [cyan]openral rskill search {hub_id}[/cyan]")
|
|
1681
|
+
raise typer.Exit(code=1)
|
|
1682
|
+
|
|
1683
|
+
console.print(f"[bold]openral rskill install[/bold] — fetching [cyan]{hub_id}[/cyan] …")
|
|
1684
|
+
|
|
1685
|
+
# ── Step 1: fetch manifest only (to surface license before downloading weights)
|
|
1686
|
+
try:
|
|
1687
|
+
from huggingface_hub import hf_hub_download
|
|
1688
|
+
from openral_core.schemas import RSkillManifest
|
|
1689
|
+
except ImportError as exc:
|
|
1690
|
+
console.print(f"[red]Missing dependency:[/red] {exc}")
|
|
1691
|
+
raise typer.Exit(code=1) # noqa: B904
|
|
1692
|
+
|
|
1693
|
+
try:
|
|
1694
|
+
manifest_path = hf_hub_download(
|
|
1695
|
+
repo_id=hub_id,
|
|
1696
|
+
filename="rskill.yaml",
|
|
1697
|
+
revision=revision or None,
|
|
1698
|
+
)
|
|
1699
|
+
manifest = RSkillManifest.from_yaml(manifest_path)
|
|
1700
|
+
except Exception as exc: # reason: surface any download/parse error to user
|
|
1701
|
+
console.print(f"[red]Failed to fetch manifest:[/red] {exc}")
|
|
1702
|
+
if "404" in str(exc) or "Repository Not Found" in str(exc):
|
|
1703
|
+
bare = hub_id.rsplit("/", 1)[-1]
|
|
1704
|
+
console.print(f" Browse available skills: [cyan]openral rskill search {bare}[/cyan]")
|
|
1705
|
+
raise typer.Exit(code=1) # noqa: B904
|
|
1706
|
+
|
|
1707
|
+
# ── Step 2: display license + confirm if non-permissive
|
|
1708
|
+
_display_license_banner(manifest.name, manifest.license.value, manifest.version, console)
|
|
1709
|
+
|
|
1710
|
+
_permissive = {"apache-2.0", "mit", "bsd"}
|
|
1711
|
+
if manifest.license.value not in _permissive and not yes:
|
|
1712
|
+
confirmed = typer.confirm("Proceed with installation?", default=False)
|
|
1713
|
+
if not confirmed:
|
|
1714
|
+
console.print("[yellow]Aborted.[/yellow]")
|
|
1715
|
+
raise typer.Exit(code=0)
|
|
1716
|
+
|
|
1717
|
+
# ── Step 3: full install (snapshot download + registry)
|
|
1718
|
+
console.print(" Downloading weights snapshot …")
|
|
1719
|
+
try:
|
|
1720
|
+
pkg = rSkill.from_pretrained(
|
|
1721
|
+
hub_id,
|
|
1722
|
+
revision=revision or None,
|
|
1723
|
+
force_download=force,
|
|
1724
|
+
commercial_use=not non_commercial,
|
|
1725
|
+
)
|
|
1726
|
+
except Exception as exc: # reason: surface ROSConfigError + network errors
|
|
1727
|
+
console.print(f"[red]Install failed:[/red] {exc}")
|
|
1728
|
+
raise typer.Exit(code=1) # noqa: B904
|
|
1729
|
+
|
|
1730
|
+
console.print(
|
|
1731
|
+
f"[green]Installed[/green] [bold]{pkg.manifest.name}[/bold] "
|
|
1732
|
+
f"v{pkg.manifest.version} → {pkg.local_dir}"
|
|
1733
|
+
)
|
|
1734
|
+
if not revision:
|
|
1735
|
+
console.print(
|
|
1736
|
+
"[yellow]Tip:[/yellow] Pin a revision for reproducibility: "
|
|
1737
|
+
f"openral rskill install {hub_id} --revision <sha>"
|
|
1738
|
+
)
|
|
1739
|
+
|
|
1740
|
+
|
|
1741
|
+
#: Max description chars rendered in the `rskill search` table before eliding.
|
|
1742
|
+
_RSKILL_SEARCH_DESC_MAX: Final[int] = 60
|
|
1743
|
+
|
|
1744
|
+
|
|
1745
|
+
def _load_hub_rskill_manifest(repo_id: str) -> RSkillManifest | None:
|
|
1746
|
+
"""Fetch + validate one Hub repo's ``rskill.yaml``; ``None`` if absent/invalid.
|
|
1747
|
+
|
|
1748
|
+
A repo with no manifest (or one that fails schema validation) is not an
|
|
1749
|
+
rSkill — the caller counts and surfaces these rather than failing the search.
|
|
1750
|
+
"""
|
|
1751
|
+
from huggingface_hub import hf_hub_download
|
|
1752
|
+
from openral_core.schemas import RSkillManifest
|
|
1753
|
+
|
|
1754
|
+
try:
|
|
1755
|
+
path = hf_hub_download(repo_id=repo_id, filename="rskill.yaml")
|
|
1756
|
+
return RSkillManifest.from_yaml(path)
|
|
1757
|
+
except Exception: # reason: not an rSkill repo / invalid manifest — skip (counted by caller)
|
|
1758
|
+
return None
|
|
1759
|
+
|
|
1760
|
+
|
|
1761
|
+
def _rskill_matches_filters(
|
|
1762
|
+
m: RSkillManifest, *, kind: str, role: str, embodiment: str, license_: str
|
|
1763
|
+
) -> bool:
|
|
1764
|
+
"""Return whether a manifest passes every non-empty facet filter."""
|
|
1765
|
+
if kind and m.kind != kind:
|
|
1766
|
+
return False
|
|
1767
|
+
if role and m.role != role:
|
|
1768
|
+
return False
|
|
1769
|
+
if embodiment and embodiment not in m.embodiment_tags:
|
|
1770
|
+
return False
|
|
1771
|
+
return not (license_ and m.license.value != license_)
|
|
1772
|
+
|
|
1773
|
+
|
|
1774
|
+
def _render_rskill_search_results(
|
|
1775
|
+
rows: list[tuple[str, RSkillManifest]], skipped: int, query: str
|
|
1776
|
+
) -> None:
|
|
1777
|
+
"""Print the human-readable `rskill search` table (or a no-results notice)."""
|
|
1778
|
+
if not rows:
|
|
1779
|
+
suffix = f" ({skipped} repo(s) skipped — no valid manifest)" if skipped else ""
|
|
1780
|
+
console.print(
|
|
1781
|
+
f"[dim]No rSkills found in {_DEFAULT_RSKILL_ORG}/ for query "
|
|
1782
|
+
f"{query!r} with the given filters.{suffix}[/dim]"
|
|
1783
|
+
)
|
|
1784
|
+
return
|
|
1785
|
+
|
|
1786
|
+
_permissive = {"apache-2.0", "mit", "bsd"}
|
|
1787
|
+
table = Table(title=f"rSkills on the Hub — {_DEFAULT_RSKILL_ORG}/")
|
|
1788
|
+
for col in ("repo_id", "kind", "role", "license", "embodiment_tags", "description"):
|
|
1789
|
+
table.add_column(col, style="cyan bold" if col == "repo_id" else None)
|
|
1790
|
+
for repo_id, m in rows:
|
|
1791
|
+
lic = m.license.value
|
|
1792
|
+
lic_color = "green" if lic in _permissive else "yellow"
|
|
1793
|
+
desc = (
|
|
1794
|
+
m.description
|
|
1795
|
+
if len(m.description) <= _RSKILL_SEARCH_DESC_MAX + 1
|
|
1796
|
+
else m.description[:_RSKILL_SEARCH_DESC_MAX] + "…"
|
|
1797
|
+
)
|
|
1798
|
+
table.add_row(
|
|
1799
|
+
repo_id,
|
|
1800
|
+
m.kind,
|
|
1801
|
+
m.role,
|
|
1802
|
+
f"[{lic_color}]{lic}[/{lic_color}]",
|
|
1803
|
+
", ".join(m.embodiment_tags) or "—",
|
|
1804
|
+
desc,
|
|
1805
|
+
)
|
|
1806
|
+
console.print(table)
|
|
1807
|
+
if skipped:
|
|
1808
|
+
console.print(
|
|
1809
|
+
f"[dim]{skipped} {_DEFAULT_RSKILL_ORG} repo(s) skipped — no valid rskill.yaml.[/dim]"
|
|
1810
|
+
)
|
|
1811
|
+
console.print("[dim]Install one with:[/dim] [cyan]openral rskill install <repo_id>[/cyan]")
|
|
1812
|
+
|
|
1813
|
+
|
|
1814
|
+
@rskill_app.command("search")
|
|
1815
|
+
def rskill_search(
|
|
1816
|
+
query: str = typer.Argument(
|
|
1817
|
+
"",
|
|
1818
|
+
metavar="[QUERY]",
|
|
1819
|
+
help="Free-text query matched against rSkill repo ids on the Hub.",
|
|
1820
|
+
),
|
|
1821
|
+
kind: str = typer.Option(
|
|
1822
|
+
"", "--kind", help="Filter by manifest kind (vla, ros_action, detector, …)."
|
|
1823
|
+
),
|
|
1824
|
+
role: str = typer.Option("", "--role", help="Filter by control role (s0, s1, s2)."),
|
|
1825
|
+
embodiment: str = typer.Option(
|
|
1826
|
+
"", "--embodiment", help="Filter by embodiment tag (e.g. franka_panda)."
|
|
1827
|
+
),
|
|
1828
|
+
license_: str = typer.Option(
|
|
1829
|
+
"", "--license", help="Filter by license posture (e.g. apache-2.0)."
|
|
1830
|
+
),
|
|
1831
|
+
limit: int = typer.Option(50, "--limit", help="Max OpenRAL repos to inspect."),
|
|
1832
|
+
json: bool = typer.Option(False, "--json", help="Output machine-readable JSON."),
|
|
1833
|
+
) -> None:
|
|
1834
|
+
"""Search the OpenRAL HF Hub org for installable rSkills.
|
|
1835
|
+
|
|
1836
|
+
Lists every ``OpenRAL/*`` repo whose ``rskill.yaml`` manifest validates and
|
|
1837
|
+
matches the optional facet filters, so the printed ids are paste-able into
|
|
1838
|
+
``openral rskill install <repo_id>``. The HF Hub is the index — there is no
|
|
1839
|
+
bespoke catalog service. Repos without a valid manifest are skipped and the
|
|
1840
|
+
count surfaced.
|
|
1841
|
+
|
|
1842
|
+
Example:
|
|
1843
|
+
>>> # openral rskill search aloha
|
|
1844
|
+
>>> # openral rskill search --kind detector --license apache-2.0
|
|
1845
|
+
"""
|
|
1846
|
+
import json as _json_mod
|
|
1847
|
+
|
|
1848
|
+
try:
|
|
1849
|
+
from huggingface_hub import HfApi
|
|
1850
|
+
except ImportError as exc:
|
|
1851
|
+
console.print(f"[red]Missing dependency:[/red] {exc}")
|
|
1852
|
+
raise typer.Exit(code=1) # noqa: B904
|
|
1853
|
+
|
|
1854
|
+
try:
|
|
1855
|
+
models = list(
|
|
1856
|
+
HfApi().list_models(author=_DEFAULT_RSKILL_ORG, search=query or None, limit=limit)
|
|
1857
|
+
)
|
|
1858
|
+
except Exception as exc: # reason: surface Hub/network errors to the user
|
|
1859
|
+
console.print(f"[red]Search failed:[/red] {exc}")
|
|
1860
|
+
raise typer.Exit(code=1) # noqa: B904
|
|
1861
|
+
|
|
1862
|
+
rows: list[tuple[str, RSkillManifest]] = []
|
|
1863
|
+
skipped = 0
|
|
1864
|
+
for model in models:
|
|
1865
|
+
manifest = _load_hub_rskill_manifest(model.id)
|
|
1866
|
+
if manifest is None:
|
|
1867
|
+
skipped += 1
|
|
1868
|
+
elif _rskill_matches_filters(
|
|
1869
|
+
manifest, kind=kind, role=role, embodiment=embodiment, license_=license_
|
|
1870
|
+
):
|
|
1871
|
+
rows.append((model.id, manifest))
|
|
1872
|
+
|
|
1873
|
+
if json:
|
|
1874
|
+
console.print_json(
|
|
1875
|
+
_json_mod.dumps(
|
|
1876
|
+
[
|
|
1877
|
+
{
|
|
1878
|
+
"repo_id": repo_id,
|
|
1879
|
+
"name": m.name,
|
|
1880
|
+
"version": m.version,
|
|
1881
|
+
"kind": m.kind,
|
|
1882
|
+
"role": m.role,
|
|
1883
|
+
"license": m.license.value,
|
|
1884
|
+
"embodiment_tags": list(m.embodiment_tags),
|
|
1885
|
+
"description": m.description,
|
|
1886
|
+
}
|
|
1887
|
+
for repo_id, m in rows
|
|
1888
|
+
]
|
|
1889
|
+
)
|
|
1890
|
+
)
|
|
1891
|
+
return
|
|
1892
|
+
|
|
1893
|
+
_render_rskill_search_results(rows, skipped, query)
|
|
1894
|
+
|
|
1895
|
+
|
|
1896
|
+
@rskill_app.command("list")
|
|
1897
|
+
def rskill_list(
|
|
1898
|
+
json: bool = typer.Option(False, "--json", help="Output machine-readable JSON"),
|
|
1899
|
+
) -> None:
|
|
1900
|
+
"""List every available rSkill — in-tree (``rskills/``) + HF-Hub-installed.
|
|
1901
|
+
|
|
1902
|
+
Each row shows the source so users can tell at a glance which entries
|
|
1903
|
+
are paste-able as ``--rskill <name>`` (in-tree) versus which
|
|
1904
|
+
need a HF Hub install first. JSON output keeps the same fields.
|
|
1905
|
+
"""
|
|
1906
|
+
import json as _json_mod
|
|
1907
|
+
|
|
1908
|
+
from openral_rskill.loader import discover_intree_rskills, rSkill
|
|
1909
|
+
|
|
1910
|
+
intree = discover_intree_rskills()
|
|
1911
|
+
try:
|
|
1912
|
+
installed = rSkill.list_installed()
|
|
1913
|
+
except Exception as exc: # reason: surface corrupt registry error
|
|
1914
|
+
console.print(f"[red]Failed to read installed registry:[/red] {exc}")
|
|
1915
|
+
raise typer.Exit(code=1) # noqa: B904
|
|
1916
|
+
|
|
1917
|
+
def _bare_name_from_repo_id(repo_id: str) -> str:
|
|
1918
|
+
"""Recover the bare rskill name that the loader would resolve to.
|
|
1919
|
+
|
|
1920
|
+
Mirrors `_candidate_local_paths`: strip the org prefix and the
|
|
1921
|
+
`rskill-`/`rskill_` prefix so the URI matches the in-tree form.
|
|
1922
|
+
"""
|
|
1923
|
+
tail = repo_id.rsplit("/", 1)[-1]
|
|
1924
|
+
return tail.removeprefix("rskill-").removeprefix("rskill_") or repo_id
|
|
1925
|
+
|
|
1926
|
+
if json:
|
|
1927
|
+
data = [
|
|
1928
|
+
{
|
|
1929
|
+
"source": "in-tree",
|
|
1930
|
+
"name": name,
|
|
1931
|
+
"repo_id": m.name,
|
|
1932
|
+
"version": m.version,
|
|
1933
|
+
"model_family": m.model_family,
|
|
1934
|
+
"role": str(m.role),
|
|
1935
|
+
"license": m.license.value,
|
|
1936
|
+
"embodiment_tags": list(m.embodiment_tags),
|
|
1937
|
+
"uri": name,
|
|
1938
|
+
}
|
|
1939
|
+
for name, m in intree
|
|
1940
|
+
] + [
|
|
1941
|
+
{
|
|
1942
|
+
"source": "installed",
|
|
1943
|
+
"name": _bare_name_from_repo_id(e.repo_id),
|
|
1944
|
+
"repo_id": e.repo_id,
|
|
1945
|
+
"version": e.version,
|
|
1946
|
+
"model_family": None,
|
|
1947
|
+
"role": e.role,
|
|
1948
|
+
"license": e.license,
|
|
1949
|
+
"embodiment_tags": list(e.embodiment_tags),
|
|
1950
|
+
"uri": _bare_name_from_repo_id(e.repo_id),
|
|
1951
|
+
"installed_at": e.installed_at,
|
|
1952
|
+
}
|
|
1953
|
+
for e in installed
|
|
1954
|
+
]
|
|
1955
|
+
console.print_json(_json_mod.dumps(data))
|
|
1956
|
+
return
|
|
1957
|
+
|
|
1958
|
+
if not intree and not installed:
|
|
1959
|
+
console.print(
|
|
1960
|
+
"[dim]No rSkills available. Drop one under rskills/<name>/ or run: "
|
|
1961
|
+
"openral rskill install <hub-id>[/dim]"
|
|
1962
|
+
)
|
|
1963
|
+
return
|
|
1964
|
+
|
|
1965
|
+
table = Table(title="Available rSkills")
|
|
1966
|
+
table.add_column("source", style="dim")
|
|
1967
|
+
table.add_column("name / repo_id", style="cyan bold")
|
|
1968
|
+
table.add_column("version")
|
|
1969
|
+
table.add_column("family")
|
|
1970
|
+
table.add_column("license")
|
|
1971
|
+
table.add_column("embodiment_tags")
|
|
1972
|
+
table.add_column("paste-able --rskill")
|
|
1973
|
+
|
|
1974
|
+
_permissive = {"apache-2.0", "mit", "bsd"}
|
|
1975
|
+
for name, m in intree:
|
|
1976
|
+
lic = m.license.value
|
|
1977
|
+
lic_color = "green" if lic in _permissive else "yellow"
|
|
1978
|
+
table.add_row(
|
|
1979
|
+
"in-tree",
|
|
1980
|
+
name,
|
|
1981
|
+
m.version,
|
|
1982
|
+
m.model_family or "—",
|
|
1983
|
+
f"[{lic_color}]{lic}[/{lic_color}]",
|
|
1984
|
+
", ".join(m.embodiment_tags) or "—",
|
|
1985
|
+
name,
|
|
1986
|
+
)
|
|
1987
|
+
for entry in installed:
|
|
1988
|
+
lic_color = "green" if entry.license in _permissive else "yellow"
|
|
1989
|
+
bare = _bare_name_from_repo_id(entry.repo_id)
|
|
1990
|
+
table.add_row(
|
|
1991
|
+
"installed",
|
|
1992
|
+
entry.repo_id,
|
|
1993
|
+
entry.version,
|
|
1994
|
+
"—",
|
|
1995
|
+
f"[{lic_color}]{entry.license}[/{lic_color}]",
|
|
1996
|
+
", ".join(entry.embodiment_tags) or "—",
|
|
1997
|
+
bare,
|
|
1998
|
+
)
|
|
1999
|
+
console.print(table)
|
|
2000
|
+
|
|
2001
|
+
|
|
2002
|
+
_SECTION_DISPLAY: dict[str, str] = {
|
|
2003
|
+
"embodiment": "Embodiment",
|
|
2004
|
+
"capability_flags": "Capability flags",
|
|
2005
|
+
"gpu_runtime": "GPU runtime",
|
|
2006
|
+
"gpu_dtype": "GPU dtype",
|
|
2007
|
+
"sensors": "Sensors",
|
|
2008
|
+
"actuators": "Actuators",
|
|
2009
|
+
}
|
|
2010
|
+
|
|
2011
|
+
|
|
2012
|
+
def _render_single_rskill_table(row: RSkillCompatRow, robot_name: str) -> None:
|
|
2013
|
+
"""Print the per-section table for `openral rskill check <rskill_id>`."""
|
|
2014
|
+
console.print(f"[bold]rSkill compatibility[/bold] for [cyan]{robot_name}[/cyan]")
|
|
2015
|
+
header = f"rSkill: [cyan bold]{row.repo_id}[/cyan bold]"
|
|
2016
|
+
if row.version:
|
|
2017
|
+
header += f" v{row.version}"
|
|
2018
|
+
if row.role:
|
|
2019
|
+
header += f" role={row.role}"
|
|
2020
|
+
console.print(header)
|
|
2021
|
+
|
|
2022
|
+
if not row.sections:
|
|
2023
|
+
console.print(f"[red]✗ {row.failure_kind}[/red] {row.reason or ''}")
|
|
2024
|
+
return
|
|
2025
|
+
|
|
2026
|
+
section_table = Table(show_header=True, header_style="bold")
|
|
2027
|
+
section_table.add_column("Section")
|
|
2028
|
+
section_table.add_column("Status")
|
|
2029
|
+
section_table.add_column("Reason")
|
|
2030
|
+
for section in row.sections:
|
|
2031
|
+
label = _SECTION_DISPLAY.get(section.label, section.label)
|
|
2032
|
+
if section.informational:
|
|
2033
|
+
status = "[dim]· informational[/dim]"
|
|
2034
|
+
elif section.compatible:
|
|
2035
|
+
status = "[green]✓[/green]"
|
|
2036
|
+
else:
|
|
2037
|
+
status = f"[red]✗ {section.failure_kind or 'fail'}[/red]"
|
|
2038
|
+
section_table.add_row(label, status, section.reason or "")
|
|
2039
|
+
console.print(section_table)
|
|
2040
|
+
|
|
2041
|
+
blocking = [s for s in row.sections if not s.informational and not s.compatible]
|
|
2042
|
+
if blocking:
|
|
2043
|
+
plural = "s" if len(blocking) != 1 else ""
|
|
2044
|
+
console.print(
|
|
2045
|
+
f"[red]Overall: ✗ incompatible ({len(blocking)} failing section{plural})[/red]"
|
|
2046
|
+
)
|
|
2047
|
+
else:
|
|
2048
|
+
console.print("[green]Overall: ✓ compatible[/green]")
|
|
2049
|
+
|
|
2050
|
+
|
|
2051
|
+
def _render_walk_all_table(report: CompatibilityReport, robot_name: str) -> None:
|
|
2052
|
+
"""Print the walk-all (no-arg) table for `openral rskill check`."""
|
|
2053
|
+
if not report.rows:
|
|
2054
|
+
console.print(
|
|
2055
|
+
"[dim]No rSkills evaluated. Install some with `openral rskill install <hub-id>` "
|
|
2056
|
+
"or pass `--rskills-dir rskills/`.[/dim]"
|
|
2057
|
+
)
|
|
2058
|
+
else:
|
|
2059
|
+
table = Table(title=f"rSkill compatibility for {robot_name}")
|
|
2060
|
+
table.add_column("repo_id", style="cyan bold")
|
|
2061
|
+
table.add_column("role")
|
|
2062
|
+
table.add_column("status")
|
|
2063
|
+
table.add_column("reason")
|
|
2064
|
+
for row in report.rows:
|
|
2065
|
+
if row.compatible:
|
|
2066
|
+
status = "[green]✓ compatible[/green]"
|
|
2067
|
+
reason = ""
|
|
2068
|
+
else:
|
|
2069
|
+
status = f"[red]✗ {row.failure_kind or 'fail'}[/red]"
|
|
2070
|
+
reason = row.reason or ""
|
|
2071
|
+
table.add_row(row.repo_id, row.role, status, reason)
|
|
2072
|
+
console.print(table)
|
|
2073
|
+
if report.incompatible:
|
|
2074
|
+
console.print(
|
|
2075
|
+
f"[yellow]{len(report.incompatible)} of {len(report.rows)} "
|
|
2076
|
+
"installed rSkill(s) cannot run on this host.[/yellow]"
|
|
2077
|
+
)
|
|
2078
|
+
|
|
2079
|
+
|
|
2080
|
+
@rskill_app.command("check")
|
|
2081
|
+
def rskill_check(
|
|
2082
|
+
rskill_id: str | None = typer.Argument(
|
|
2083
|
+
None,
|
|
2084
|
+
metavar="RSKILL_ID",
|
|
2085
|
+
help=(
|
|
2086
|
+
"rSkill to check — bare in-tree name, path (rskills/<name>), "
|
|
2087
|
+
"or HF Hub repo id (e.g. OpenRAL/rskill-smolvla-libero). "
|
|
2088
|
+
"Omit to walk every installed / in-tree rSkill (walk-all mode)."
|
|
2089
|
+
),
|
|
2090
|
+
),
|
|
2091
|
+
robot: Path = typer.Option(
|
|
2092
|
+
Path("robot.yaml"),
|
|
2093
|
+
"--robot",
|
|
2094
|
+
help="Path to a RobotDescription yaml (typically the output of `openral detect`).",
|
|
2095
|
+
),
|
|
2096
|
+
rskills_dir: Path = typer.Option(
|
|
2097
|
+
Path("rskills"),
|
|
2098
|
+
"--rskills-dir",
|
|
2099
|
+
help=(
|
|
2100
|
+
"(Walk-all mode) in-tree rSkills directory to scan in addition to "
|
|
2101
|
+
"the installed registry. Skipped if it does not exist."
|
|
2102
|
+
),
|
|
2103
|
+
),
|
|
2104
|
+
json_output: bool = typer.Option(
|
|
2105
|
+
False, "--json", help="Output the CompatibilityReport as JSON."
|
|
2106
|
+
),
|
|
2107
|
+
) -> None:
|
|
2108
|
+
"""Report whether one (or every) rSkill will run on the current host.
|
|
2109
|
+
|
|
2110
|
+
Two modes:
|
|
2111
|
+
|
|
2112
|
+
* ``openral rskill check <rskill_id>`` resolves the id the same way as
|
|
2113
|
+
``openral rskill list`` / ``openral benchmark run --rskill <id>``
|
|
2114
|
+
(in-tree, installed registry, or HF Hub) and prints a per-section
|
|
2115
|
+
breakdown — embodiment, capability flags, GPU runtime, GPU dtype,
|
|
2116
|
+
sensors, actuators.
|
|
2117
|
+
* ``openral rskill check`` (no arg) walks every installed / in-tree
|
|
2118
|
+
rSkill via `openral_detect.check_installed_rskills` and prints a
|
|
2119
|
+
one-row-per-rSkill compatibility table.
|
|
2120
|
+
|
|
2121
|
+
Exits 1 if any non-informational section fails (single-rSkill mode)
|
|
2122
|
+
or if any installed rSkill is incompatible (walk-all mode); exits 0
|
|
2123
|
+
otherwise.
|
|
2124
|
+
|
|
2125
|
+
Example:
|
|
2126
|
+
>>> # openral rskill check smolvla-libero
|
|
2127
|
+
>>> # openral rskill check OpenRAL/rskill-smolvla-libero --robot /tmp/robot.yaml --json
|
|
2128
|
+
>>> # openral rskill check # walk-all
|
|
2129
|
+
"""
|
|
2130
|
+
import json as _json_mod
|
|
2131
|
+
|
|
2132
|
+
from openral_core.schemas import RobotDescription
|
|
2133
|
+
from openral_detect import check_installed_rskills, check_single_rskill
|
|
2134
|
+
|
|
2135
|
+
if not robot.exists():
|
|
2136
|
+
console.print(
|
|
2137
|
+
f"[red]Robot description not found:[/red] {robot}\n"
|
|
2138
|
+
"Run [bold]openral detect[/bold] first."
|
|
2139
|
+
)
|
|
2140
|
+
raise typer.Exit(code=1)
|
|
2141
|
+
|
|
2142
|
+
description = RobotDescription.from_yaml(str(robot))
|
|
2143
|
+
|
|
2144
|
+
if rskill_id is not None:
|
|
2145
|
+
single_report = check_single_rskill(rskill_id, description)
|
|
2146
|
+
single_row = single_report.rows[0]
|
|
2147
|
+
if json_output:
|
|
2148
|
+
console.print_json(_json_mod.dumps(single_report.model_dump(mode="json")))
|
|
2149
|
+
else:
|
|
2150
|
+
_render_single_rskill_table(single_row, description.name)
|
|
2151
|
+
if not single_row.compatible:
|
|
2152
|
+
raise typer.Exit(code=1)
|
|
2153
|
+
return
|
|
2154
|
+
|
|
2155
|
+
# ── Walk-all (no-arg) ─────────────────────────────────────────────────────
|
|
2156
|
+
walk_dir = rskills_dir if rskills_dir.is_dir() else None
|
|
2157
|
+
report = check_installed_rskills(description, rskills_dir=walk_dir)
|
|
2158
|
+
if json_output:
|
|
2159
|
+
console.print_json(_json_mod.dumps(report.model_dump(mode="json")))
|
|
2160
|
+
else:
|
|
2161
|
+
_render_walk_all_table(report, description.name)
|
|
2162
|
+
|
|
2163
|
+
if report.incompatible:
|
|
2164
|
+
raise typer.Exit(code=1)
|
|
2165
|
+
|
|
2166
|
+
|
|
2167
|
+
_DEFAULT_OWNER = "your-org"
|
|
2168
|
+
_DEFAULT_LICENSE = "apache-2.0"
|
|
2169
|
+
_DEFAULT_EMBODIMENT = "franka_panda"
|
|
2170
|
+
|
|
2171
|
+
|
|
2172
|
+
@rskill_app.command("new")
|
|
2173
|
+
def rskill_new(
|
|
2174
|
+
rskill_id: str = typer.Argument(
|
|
2175
|
+
...,
|
|
2176
|
+
metavar="ID",
|
|
2177
|
+
help="Local rSkill id, convention <policy>-<task> e.g. pi05-pick-cube.",
|
|
2178
|
+
),
|
|
2179
|
+
out_dir: Path | None = typer.Option(
|
|
2180
|
+
None,
|
|
2181
|
+
"--out-dir",
|
|
2182
|
+
help="Destination directory. Defaults to rskills/<ID>/ under the cwd.",
|
|
2183
|
+
),
|
|
2184
|
+
owner: str | None = typer.Option(
|
|
2185
|
+
None,
|
|
2186
|
+
"--owner",
|
|
2187
|
+
help="HF Hub owner segment for the manifest 'name' field.",
|
|
2188
|
+
),
|
|
2189
|
+
license_: str | None = typer.Option(
|
|
2190
|
+
None,
|
|
2191
|
+
"--license",
|
|
2192
|
+
help=(
|
|
2193
|
+
"One of: apache-2.0 | mit | bsd | permissive_research | "
|
|
2194
|
+
"nvidia_non_commercial | proprietary | unknown."
|
|
2195
|
+
),
|
|
2196
|
+
),
|
|
2197
|
+
embodiment_tag: str | None = typer.Option(
|
|
2198
|
+
None,
|
|
2199
|
+
"--embodiment-tag",
|
|
2200
|
+
help="One of the canonical EmbodimentTag literals (see CLAUDE.md §6.4).",
|
|
2201
|
+
),
|
|
2202
|
+
family: str | None = typer.Option(
|
|
2203
|
+
None,
|
|
2204
|
+
"--family",
|
|
2205
|
+
"-f",
|
|
2206
|
+
help=(
|
|
2207
|
+
"Policy family — one of act | smolvla | pi05 | xvla | diffusion. "
|
|
2208
|
+
"Sets model_family / chunk_size / dtype / latency budget from the "
|
|
2209
|
+
"matching in-tree reference manifest so a fresh ACT scaffold "
|
|
2210
|
+
"doesn't ship pi0.5 numbers. Inferred from --from-hf when set; "
|
|
2211
|
+
"interactively prompted otherwise."
|
|
2212
|
+
),
|
|
2213
|
+
),
|
|
2214
|
+
from_hf: str | None = typer.Option(
|
|
2215
|
+
None,
|
|
2216
|
+
"--from-hf",
|
|
2217
|
+
help=(
|
|
2218
|
+
"HF Hub repo id (e.g. 'Deepkar/libero-test-act' or "
|
|
2219
|
+
"'hf://Deepkar/libero-test-act'). Fetches the checkpoint's "
|
|
2220
|
+
"config.json, infers the family, and pre-fills chunk_size, "
|
|
2221
|
+
"sensors_required, state_contract.dim, image_preprocessing aliases, "
|
|
2222
|
+
"and weights_uri. Eliminates manual rewriting after scaffold."
|
|
2223
|
+
),
|
|
2224
|
+
),
|
|
2225
|
+
yes: bool = typer.Option(
|
|
2226
|
+
False,
|
|
2227
|
+
"--yes",
|
|
2228
|
+
"-y",
|
|
2229
|
+
help="Skip interactive prompts and accept the defaults (for scripting / CI).",
|
|
2230
|
+
),
|
|
2231
|
+
overwrite: bool = typer.Option(
|
|
2232
|
+
False,
|
|
2233
|
+
"--overwrite",
|
|
2234
|
+
help="Replace an existing destination directory instead of refusing.",
|
|
2235
|
+
),
|
|
2236
|
+
) -> None:
|
|
2237
|
+
"""Scaffold a new local rSkill from ``rskills/template/``.
|
|
2238
|
+
|
|
2239
|
+
Three modes:
|
|
2240
|
+
|
|
2241
|
+
- ``--from-hf <owner/repo>`` — most intuitive. Fetches the
|
|
2242
|
+
checkpoint's ``config.json`` and pre-fills ``model_family`` /
|
|
2243
|
+
``chunk_size`` / ``sensors_required`` / ``state_contract`` /
|
|
2244
|
+
``image_preprocessing.aliases`` / ``weights_uri`` from the real
|
|
2245
|
+
Hub-side contract. No more hand-rewriting after scaffold.
|
|
2246
|
+
- ``--family <act|smolvla|pi05|xvla|diffusion>`` — family-aware
|
|
2247
|
+
defaults without Hub introspection. Use when you know the family
|
|
2248
|
+
but the weights live somewhere else (private repo, local mirror).
|
|
2249
|
+
- Neither — interactive. Prompts for ``--owner`` / ``--license`` /
|
|
2250
|
+
``--embodiment-tag`` / ``--family`` (and offers ``--from-hf`` as
|
|
2251
|
+
a one-shot alternative). Pass ``--yes`` to skip all prompts and
|
|
2252
|
+
accept the defaults (your-org / apache-2.0 / franka_panda /
|
|
2253
|
+
no-family).
|
|
2254
|
+
|
|
2255
|
+
The generated manifest is round-tripped through
|
|
2256
|
+
`RSkillManifest.from_yaml` and `rSkill.from_yaml`
|
|
2257
|
+
so a malformed scaffold fails at scaffold-time, not on first load.
|
|
2258
|
+
|
|
2259
|
+
Example:
|
|
2260
|
+
>>> # openral rskill new act-libero --from-hf Deepkar/libero-test-act
|
|
2261
|
+
>>> # openral rskill new pi05-pick-cube --family pi05 --embodiment-tag franka_panda
|
|
2262
|
+
>>> # openral rskill new act-aloha-insertion --owner foo --embodiment-tag aloha
|
|
2263
|
+
"""
|
|
2264
|
+
from typing import get_args
|
|
2265
|
+
|
|
2266
|
+
from openral_core.schemas import EmbodimentTag, RSkillLicensePosture
|
|
2267
|
+
|
|
2268
|
+
from openral_cli._rskill_scaffolder import scaffold_rskill
|
|
2269
|
+
|
|
2270
|
+
valid_tags = list(get_args(EmbodimentTag))
|
|
2271
|
+
valid_licenses = [v.value for v in RSkillLicensePosture]
|
|
2272
|
+
|
|
2273
|
+
resolved_owner = _resolve_or_prompt(
|
|
2274
|
+
owner,
|
|
2275
|
+
prompt=f"HF Hub owner (e.g. your username or org) [{_DEFAULT_OWNER}]",
|
|
2276
|
+
default=_DEFAULT_OWNER,
|
|
2277
|
+
skip_prompt=yes,
|
|
2278
|
+
)
|
|
2279
|
+
resolved_license = _resolve_or_prompt(
|
|
2280
|
+
license_,
|
|
2281
|
+
prompt=f"License posture [{_DEFAULT_LICENSE}]",
|
|
2282
|
+
default=_DEFAULT_LICENSE,
|
|
2283
|
+
skip_prompt=yes,
|
|
2284
|
+
)
|
|
2285
|
+
resolved_embodiment = _resolve_or_prompt(
|
|
2286
|
+
embodiment_tag,
|
|
2287
|
+
prompt=f"Embodiment tag (canonical robot id) [{_DEFAULT_EMBODIMENT}]",
|
|
2288
|
+
default=_DEFAULT_EMBODIMENT,
|
|
2289
|
+
skip_prompt=yes,
|
|
2290
|
+
)
|
|
2291
|
+
|
|
2292
|
+
try:
|
|
2293
|
+
license_enum = RSkillLicensePosture(resolved_license)
|
|
2294
|
+
except ValueError as exc:
|
|
2295
|
+
console.print(
|
|
2296
|
+
f"[red]Invalid license:[/red] {resolved_license!r}. "
|
|
2297
|
+
f"Valid values: {', '.join(valid_licenses)}"
|
|
2298
|
+
)
|
|
2299
|
+
raise typer.Exit(code=1) from exc
|
|
2300
|
+
|
|
2301
|
+
if resolved_embodiment not in valid_tags:
|
|
2302
|
+
console.print(
|
|
2303
|
+
f"[red]Invalid embodiment_tag:[/red] {resolved_embodiment!r}. "
|
|
2304
|
+
f"Valid values: {', '.join(valid_tags)}"
|
|
2305
|
+
)
|
|
2306
|
+
raise typer.Exit(code=1)
|
|
2307
|
+
|
|
2308
|
+
resolved_family, intel_patch = _resolve_family_and_patch(
|
|
2309
|
+
family=family, from_hf=from_hf, yes=yes
|
|
2310
|
+
)
|
|
2311
|
+
|
|
2312
|
+
resolved_out = out_dir if out_dir is not None else Path("rskills") / rskill_id
|
|
2313
|
+
|
|
2314
|
+
try:
|
|
2315
|
+
result = scaffold_rskill(
|
|
2316
|
+
rskill_id,
|
|
2317
|
+
out_dir=resolved_out,
|
|
2318
|
+
owner=resolved_owner,
|
|
2319
|
+
license_=license_enum,
|
|
2320
|
+
embodiment_tag=cast(EmbodimentTag, resolved_embodiment),
|
|
2321
|
+
family=resolved_family,
|
|
2322
|
+
patch=intel_patch,
|
|
2323
|
+
overwrite=overwrite,
|
|
2324
|
+
)
|
|
2325
|
+
except ROSConfigError as exc:
|
|
2326
|
+
console.print(f"[red]Scaffold failed:[/red] {exc}")
|
|
2327
|
+
raise typer.Exit(code=1) from exc
|
|
2328
|
+
|
|
2329
|
+
console.print(f"[green]Scaffolded[/green] [cyan]{rskill_id}[/cyan] → {result}")
|
|
2330
|
+
if intel_patch is not None:
|
|
2331
|
+
console.print(
|
|
2332
|
+
"[dim]Next steps: edit description / README.md, optionally adjust "
|
|
2333
|
+
"image_preprocessing.flip_180 for your scene, add eval/<benchmark>.json "
|
|
2334
|
+
"results, then publish with tools/rskill_publisher.py.[/dim]"
|
|
2335
|
+
)
|
|
2336
|
+
else:
|
|
2337
|
+
console.print(
|
|
2338
|
+
"[dim]Next steps: edit rskill.yaml (weights_uri / chunk_size / "
|
|
2339
|
+
"sensors_required / image_preprocessing), update README.md, add "
|
|
2340
|
+
"eval/<benchmark>.json results, then publish with "
|
|
2341
|
+
"tools/rskill_publisher.py. Tip: pass `--from-hf <owner/repo>` next "
|
|
2342
|
+
"time to auto-fill the manifest from a published checkpoint.[/dim]"
|
|
2343
|
+
)
|
|
2344
|
+
|
|
2345
|
+
|
|
2346
|
+
def _resolve_family_and_patch(
|
|
2347
|
+
*,
|
|
2348
|
+
family: str | None,
|
|
2349
|
+
from_hf: str | None,
|
|
2350
|
+
yes: bool,
|
|
2351
|
+
) -> tuple[RSkillFamily | None, RSkillPatch | None]:
|
|
2352
|
+
"""Resolve ``--family`` / ``--from-hf`` for ``openral rskill new``.
|
|
2353
|
+
|
|
2354
|
+
Three paths in order of priority:
|
|
2355
|
+
|
|
2356
|
+
1. ``--from-hf`` set → introspect the Hub config and derive both the
|
|
2357
|
+
family and a manifest patch carrying real chunk_size / sensors /
|
|
2358
|
+
state_contract / aliases / weights_uri.
|
|
2359
|
+
2. ``--family`` set → take its family defaults, no Hub call.
|
|
2360
|
+
3. neither + interactive → offer the menu. Skipped under ``--yes``
|
|
2361
|
+
so scripted callers keep the historical "no-family, template
|
|
2362
|
+
baseline" behaviour.
|
|
2363
|
+
|
|
2364
|
+
Returns ``(resolved_family, patch)``; either or both may be ``None``.
|
|
2365
|
+
"""
|
|
2366
|
+
from openral_cli._rskill_intel import (
|
|
2367
|
+
RSKILL_FAMILIES,
|
|
2368
|
+
introspect_hf,
|
|
2369
|
+
)
|
|
2370
|
+
|
|
2371
|
+
typed_family: RSkillFamily | None = (
|
|
2372
|
+
cast("RSkillFamily", family) if family in RSKILL_FAMILIES else None
|
|
2373
|
+
)
|
|
2374
|
+
|
|
2375
|
+
if from_hf is not None:
|
|
2376
|
+
try:
|
|
2377
|
+
resolved_family, intel_patch = introspect_hf(from_hf, default_family=typed_family)
|
|
2378
|
+
except ValueError as exc:
|
|
2379
|
+
console.print(f"[red]--from-hf failed:[/red] {exc}")
|
|
2380
|
+
raise typer.Exit(code=1) from exc
|
|
2381
|
+
console.print(
|
|
2382
|
+
f"[green]Auto-detected[/green] family=[cyan]{resolved_family}[/cyan] "
|
|
2383
|
+
f"from [dim]{from_hf}[/dim]"
|
|
2384
|
+
)
|
|
2385
|
+
return resolved_family, intel_patch
|
|
2386
|
+
|
|
2387
|
+
if family is not None:
|
|
2388
|
+
if family not in RSKILL_FAMILIES:
|
|
2389
|
+
console.print(
|
|
2390
|
+
f"[red]Invalid --family:[/red] {family!r}. "
|
|
2391
|
+
f"Valid values: {', '.join(RSKILL_FAMILIES)}"
|
|
2392
|
+
)
|
|
2393
|
+
raise typer.Exit(code=1)
|
|
2394
|
+
return family, None
|
|
2395
|
+
|
|
2396
|
+
if yes:
|
|
2397
|
+
return None, None
|
|
2398
|
+
|
|
2399
|
+
menu = " | ".join(RSKILL_FAMILIES)
|
|
2400
|
+
response = typer.prompt(
|
|
2401
|
+
f"Policy family [{menu}, empty for template baseline]",
|
|
2402
|
+
default="",
|
|
2403
|
+
show_default=False,
|
|
2404
|
+
).strip()
|
|
2405
|
+
if not response:
|
|
2406
|
+
return None, None
|
|
2407
|
+
if response not in RSKILL_FAMILIES:
|
|
2408
|
+
console.print(
|
|
2409
|
+
f"[red]Invalid family:[/red] {response!r}. Valid values: {', '.join(RSKILL_FAMILIES)}"
|
|
2410
|
+
)
|
|
2411
|
+
raise typer.Exit(code=1)
|
|
2412
|
+
return cast("RSkillFamily", response), None
|
|
2413
|
+
|
|
2414
|
+
|
|
2415
|
+
def _resolve_or_prompt(value: str | None, *, prompt: str, default: str, skip_prompt: bool) -> str:
|
|
2416
|
+
"""Return ``value`` if provided, else prompt (or fall back to ``default``).
|
|
2417
|
+
|
|
2418
|
+
Used by ``openral rskill new`` to drive the interactive prompts only when
|
|
2419
|
+
the user didn't pass the flag AND didn't request non-interactive
|
|
2420
|
+
mode with ``--yes``.
|
|
2421
|
+
"""
|
|
2422
|
+
if value is not None:
|
|
2423
|
+
return value
|
|
2424
|
+
if skip_prompt:
|
|
2425
|
+
return default
|
|
2426
|
+
response: str = typer.prompt(prompt, default=default, show_default=False)
|
|
2427
|
+
return response
|
|
2428
|
+
|
|
2429
|
+
|
|
2430
|
+
def _display_license_banner(
|
|
2431
|
+
name: str,
|
|
2432
|
+
license_value: str,
|
|
2433
|
+
version: str,
|
|
2434
|
+
con: Console,
|
|
2435
|
+
) -> None:
|
|
2436
|
+
"""Print a colour-coded license banner to the console.
|
|
2437
|
+
|
|
2438
|
+
Args:
|
|
2439
|
+
name: rSkill name from the manifest.
|
|
2440
|
+
license_value: License posture value string.
|
|
2441
|
+
version: SemVer version string.
|
|
2442
|
+
con: Rich Console instance.
|
|
2443
|
+
"""
|
|
2444
|
+
_permissive = {"apache-2.0", "mit", "bsd"}
|
|
2445
|
+
_warn = {"permissive_research", "unknown"}
|
|
2446
|
+
if license_value in _permissive:
|
|
2447
|
+
color, icon = "green", "✓"
|
|
2448
|
+
elif license_value in _warn:
|
|
2449
|
+
color, icon = "yellow", "!"
|
|
2450
|
+
else:
|
|
2451
|
+
color, icon = "red", "⚠"
|
|
2452
|
+
|
|
2453
|
+
con.print(
|
|
2454
|
+
f" [{color}]{icon} License:[/{color}] [bold]{license_value}[/bold] ({name} v{version})"
|
|
2455
|
+
)
|
|
2456
|
+
|
|
2457
|
+
|
|
2458
|
+
# ── sensor sub-app ────────────────────────────────────────────────────────────
|
|
2459
|
+
|
|
2460
|
+
sensor_app = typer.Typer(
|
|
2461
|
+
name="sensor",
|
|
2462
|
+
help="Sensor catalog browsing — list and inspect registered sensor specs.",
|
|
2463
|
+
no_args_is_help=True,
|
|
2464
|
+
)
|
|
2465
|
+
app.add_typer(sensor_app, name="sensor")
|
|
2466
|
+
|
|
2467
|
+
|
|
2468
|
+
@sensor_app.command("list")
|
|
2469
|
+
def sensor_list(
|
|
2470
|
+
vendor: str = typer.Option(
|
|
2471
|
+
"",
|
|
2472
|
+
"--vendor",
|
|
2473
|
+
help="Filter by vendor (lowercase, e.g. intel, orbbec, livox).",
|
|
2474
|
+
),
|
|
2475
|
+
modality: str = typer.Option(
|
|
2476
|
+
"",
|
|
2477
|
+
"--modality",
|
|
2478
|
+
help="Filter by sensor modality (e.g. rgb, depth, lidar_2d, point_cloud).",
|
|
2479
|
+
),
|
|
2480
|
+
kind: str = typer.Option(
|
|
2481
|
+
"",
|
|
2482
|
+
"--kind",
|
|
2483
|
+
help="Filter by kind: 'sensor' or 'bundle'.",
|
|
2484
|
+
),
|
|
2485
|
+
json_output: bool = typer.Option(
|
|
2486
|
+
False,
|
|
2487
|
+
"--json",
|
|
2488
|
+
help="Emit machine-readable JSON instead of a table.",
|
|
2489
|
+
),
|
|
2490
|
+
) -> None:
|
|
2491
|
+
"""List every sensor registered in the openral sensor catalog.
|
|
2492
|
+
|
|
2493
|
+
Example:
|
|
2494
|
+
>>> # openral sensor list
|
|
2495
|
+
>>> # openral sensor list --vendor intel
|
|
2496
|
+
>>> # openral sensor list --modality lidar_2d --json
|
|
2497
|
+
"""
|
|
2498
|
+
# Imported lazily so `openral --help` doesn't pay the side-effect import cost.
|
|
2499
|
+
from openral_core.schemas import SensorModality
|
|
2500
|
+
from openral_sensors import CATALOG
|
|
2501
|
+
|
|
2502
|
+
modality_enum: SensorModality | None = None
|
|
2503
|
+
if modality:
|
|
2504
|
+
try:
|
|
2505
|
+
modality_enum = SensorModality(modality)
|
|
2506
|
+
except ValueError:
|
|
2507
|
+
valid = ", ".join(m.value for m in SensorModality)
|
|
2508
|
+
console.print(f"[red]Unknown --modality {modality!r}. Valid: {valid}[/red]")
|
|
2509
|
+
raise typer.Exit(code=1) from None
|
|
2510
|
+
|
|
2511
|
+
kind_filter: str | None = None
|
|
2512
|
+
if kind:
|
|
2513
|
+
if kind not in ("sensor", "bundle"):
|
|
2514
|
+
console.print(f"[red]--kind must be 'sensor' or 'bundle', got {kind!r}.[/red]")
|
|
2515
|
+
raise typer.Exit(code=1)
|
|
2516
|
+
kind_filter = kind
|
|
2517
|
+
|
|
2518
|
+
entries = CATALOG.filter(
|
|
2519
|
+
vendor=vendor or None,
|
|
2520
|
+
modality=modality_enum,
|
|
2521
|
+
kind=kind_filter, # type: ignore[arg-type] # reason: narrowed above
|
|
2522
|
+
)
|
|
2523
|
+
|
|
2524
|
+
if json_output:
|
|
2525
|
+
payload = [
|
|
2526
|
+
{
|
|
2527
|
+
"id": e.id,
|
|
2528
|
+
"vendor": e.vendor,
|
|
2529
|
+
"model": e.model,
|
|
2530
|
+
"kind": e.kind,
|
|
2531
|
+
"modalities": [m.value for m in e.modalities],
|
|
2532
|
+
"description": e.description,
|
|
2533
|
+
"docs_url": e.docs_url,
|
|
2534
|
+
}
|
|
2535
|
+
for e in entries
|
|
2536
|
+
]
|
|
2537
|
+
console.print_json(_json.dumps(payload))
|
|
2538
|
+
return
|
|
2539
|
+
|
|
2540
|
+
if not entries:
|
|
2541
|
+
console.print("[yellow]No sensors match the requested filters.[/yellow]")
|
|
2542
|
+
return
|
|
2543
|
+
|
|
2544
|
+
table = Table(title=f"openral sensor catalog ({len(entries)} entries)")
|
|
2545
|
+
table.add_column("id", style="cyan", no_wrap=True)
|
|
2546
|
+
table.add_column("kind")
|
|
2547
|
+
table.add_column("modalities", style="magenta")
|
|
2548
|
+
table.add_column("description")
|
|
2549
|
+
for e in entries:
|
|
2550
|
+
table.add_row(
|
|
2551
|
+
e.id,
|
|
2552
|
+
e.kind,
|
|
2553
|
+
",".join(m.value for m in e.modalities),
|
|
2554
|
+
e.description,
|
|
2555
|
+
)
|
|
2556
|
+
console.print(table)
|
|
2557
|
+
|
|
2558
|
+
|
|
2559
|
+
@sensor_app.command("show")
|
|
2560
|
+
def sensor_show(
|
|
2561
|
+
sensor_id: str = typer.Argument(
|
|
2562
|
+
...,
|
|
2563
|
+
metavar="SENSOR_ID",
|
|
2564
|
+
help="Catalog id, e.g. intel/realsense_d435i or slamtec/rplidar_a2",
|
|
2565
|
+
),
|
|
2566
|
+
name: str = typer.Option(
|
|
2567
|
+
"sensor",
|
|
2568
|
+
"--name",
|
|
2569
|
+
help="Instance name passed to the factory (used as topic / frame prefix).",
|
|
2570
|
+
),
|
|
2571
|
+
parent_frame: str = typer.Option(
|
|
2572
|
+
"base_link",
|
|
2573
|
+
"--parent-frame",
|
|
2574
|
+
help="tf2 parent frame for the static transform.",
|
|
2575
|
+
),
|
|
2576
|
+
json_output: bool = typer.Option(
|
|
2577
|
+
False,
|
|
2578
|
+
"--json",
|
|
2579
|
+
help="Emit the resolved SensorSpec / SensorBundle as JSON.",
|
|
2580
|
+
),
|
|
2581
|
+
) -> None:
|
|
2582
|
+
"""Resolve a catalog entry to a concrete ``SensorSpec`` / ``SensorBundle``.
|
|
2583
|
+
|
|
2584
|
+
Example:
|
|
2585
|
+
>>> # openral sensor show intel/realsense_d435i --name head
|
|
2586
|
+
>>> # openral sensor show slamtec/rplidar_a2 --json
|
|
2587
|
+
"""
|
|
2588
|
+
from openral_sensors import CATALOG
|
|
2589
|
+
|
|
2590
|
+
try:
|
|
2591
|
+
entry = CATALOG.get(sensor_id)
|
|
2592
|
+
except KeyError as exc:
|
|
2593
|
+
console.print(f"[red]{exc.args[0]}[/red]")
|
|
2594
|
+
raise typer.Exit(code=1) from None
|
|
2595
|
+
|
|
2596
|
+
resolved = entry.factory(name=name, parent_frame=parent_frame)
|
|
2597
|
+
|
|
2598
|
+
if json_output:
|
|
2599
|
+
console.print_json(resolved.model_dump_json(indent=2))
|
|
2600
|
+
return
|
|
2601
|
+
|
|
2602
|
+
console.print(f"[bold cyan]{entry.id}[/bold cyan] ({entry.kind})")
|
|
2603
|
+
console.print(f" vendor : [magenta]{entry.vendor}[/magenta]")
|
|
2604
|
+
console.print(f" model : [magenta]{entry.model}[/magenta]")
|
|
2605
|
+
console.print(f" modalities : {', '.join(m.value for m in entry.modalities)}")
|
|
2606
|
+
console.print(f" description : [dim]{entry.description}[/dim]")
|
|
2607
|
+
if entry.docs_url:
|
|
2608
|
+
console.print(f" docs_url : [dim]{entry.docs_url}[/dim]")
|
|
2609
|
+
console.print()
|
|
2610
|
+
console.print("[bold]Resolved:[/bold]")
|
|
2611
|
+
console.print_json(resolved.model_dump_json(indent=2))
|
|
2612
|
+
|
|
2613
|
+
|
|
2614
|
+
# ── openral benchmark ────────────────────────────────────────────────────────────
|
|
2615
|
+
|
|
2616
|
+
benchmark_app = typer.Typer(
|
|
2617
|
+
name="benchmark",
|
|
2618
|
+
help=(
|
|
2619
|
+
"Run a benchmark suite end-to-end (`openral benchmark run`), list available "
|
|
2620
|
+
"suites (`openral benchmark list`), or aggregate per-rSkill JSON results "
|
|
2621
|
+
"(`openral benchmark report`)."
|
|
2622
|
+
),
|
|
2623
|
+
no_args_is_help=True,
|
|
2624
|
+
)
|
|
2625
|
+
app.add_typer(benchmark_app, name="benchmark")
|
|
2626
|
+
|
|
2627
|
+
|
|
2628
|
+
@benchmark_app.command("list")
|
|
2629
|
+
def benchmark_list(
|
|
2630
|
+
benchmarks_dir: Path = typer.Option(
|
|
2631
|
+
Path("benchmarks"),
|
|
2632
|
+
"--benchmarks-dir",
|
|
2633
|
+
help="Search directory for benchmark suite YAMLs.",
|
|
2634
|
+
),
|
|
2635
|
+
) -> None:
|
|
2636
|
+
"""List every benchmark suite id available under ``benchmarks/*.yaml``.
|
|
2637
|
+
|
|
2638
|
+
Each entry is a paste-able ``--suite`` value for ``openral benchmark run``.
|
|
2639
|
+
No rollout, no GPU.
|
|
2640
|
+
"""
|
|
2641
|
+
if not benchmarks_dir.is_dir():
|
|
2642
|
+
console.print(f"[red]No benchmarks dir at {benchmarks_dir}[/red]")
|
|
2643
|
+
raise typer.Exit(code=1)
|
|
2644
|
+
suites = sorted(p.stem for p in benchmarks_dir.glob("*.yaml") if p.is_file())
|
|
2645
|
+
if not suites:
|
|
2646
|
+
print("<none>")
|
|
2647
|
+
return
|
|
2648
|
+
for suite in suites:
|
|
2649
|
+
print(suite)
|
|
2650
|
+
|
|
2651
|
+
|
|
2652
|
+
@benchmark_app.command("run")
|
|
2653
|
+
def benchmark_run(
|
|
2654
|
+
suite: str = typer.Option(
|
|
2655
|
+
...,
|
|
2656
|
+
"--suite",
|
|
2657
|
+
help=(
|
|
2658
|
+
"Benchmark suite to evaluate — a bare ``list[BenchmarkScene]`` YAML. "
|
|
2659
|
+
"Either a built-in id (resolved to "
|
|
2660
|
+
"`benchmarks/<id>.yaml`) or a direct YAML path."
|
|
2661
|
+
),
|
|
2662
|
+
),
|
|
2663
|
+
rskill: str = typer.Option(
|
|
2664
|
+
...,
|
|
2665
|
+
"--rskill",
|
|
2666
|
+
help=(
|
|
2667
|
+
"rSkill reference — a bare name ('smolvla-libero'), a "
|
|
2668
|
+
"path ('rskills/smolvla-libero'), or an HF Hub repo id "
|
|
2669
|
+
"('OpenRAL/rskill-smolvla-libero'). "
|
|
2670
|
+
"Raw hf:// is rejected (weights must come from a manifest). "
|
|
2671
|
+
"The policy adapter id is read from the manifest's `model_family` "
|
|
2672
|
+
"field."
|
|
2673
|
+
),
|
|
2674
|
+
),
|
|
2675
|
+
out: Path | None = typer.Option(
|
|
2676
|
+
None,
|
|
2677
|
+
"--out",
|
|
2678
|
+
help=(
|
|
2679
|
+
"Output path for the RSkillEvalResult JSON. Defaults to "
|
|
2680
|
+
"rskills/<dir>/eval/<suite_id>.json derived from the rSkill ref."
|
|
2681
|
+
),
|
|
2682
|
+
),
|
|
2683
|
+
device: str | None = typer.Option(
|
|
2684
|
+
None,
|
|
2685
|
+
"--device",
|
|
2686
|
+
help="Torch device override for the policy (cpu, cuda:0, mps, auto).",
|
|
2687
|
+
),
|
|
2688
|
+
save_dir: Path | None = typer.Option(
|
|
2689
|
+
None,
|
|
2690
|
+
"--save-dir",
|
|
2691
|
+
help="Optional adapter-side artefact directory (videos, traces).",
|
|
2692
|
+
),
|
|
2693
|
+
benchmarks_dir: Path = typer.Option(
|
|
2694
|
+
Path("benchmarks"),
|
|
2695
|
+
"--benchmarks-dir",
|
|
2696
|
+
help="Search directory for built-in benchmark suite YAMLs.",
|
|
2697
|
+
),
|
|
2698
|
+
task: str | None = typer.Option(
|
|
2699
|
+
None,
|
|
2700
|
+
"--task",
|
|
2701
|
+
help=(
|
|
2702
|
+
"Run only this single task id from the suite (e.g. "
|
|
2703
|
+
"'libero_spatial/3' or 'maniskill3/PushCube-v1'). Omit to run "
|
|
2704
|
+
"every task the rSkill supports (the suite is auto-filtered to "
|
|
2705
|
+
"the rSkill's evaluated_tasks)."
|
|
2706
|
+
),
|
|
2707
|
+
),
|
|
2708
|
+
n_episodes: int | None = typer.Option(
|
|
2709
|
+
None,
|
|
2710
|
+
"--n-episodes",
|
|
2711
|
+
help=(
|
|
2712
|
+
"Override ``BenchmarkScene.n_episodes`` for every scene in the "
|
|
2713
|
+
"suite (lower for quick smoke runs). The published-protocol value "
|
|
2714
|
+
"lives in the suite YAML; this flag is for fast iteration, not "
|
|
2715
|
+
"for paper-comparison numbers."
|
|
2716
|
+
),
|
|
2717
|
+
),
|
|
2718
|
+
dry_run: bool = typer.Option(
|
|
2719
|
+
False,
|
|
2720
|
+
"--dry-run",
|
|
2721
|
+
help=(
|
|
2722
|
+
"Resolve the suite + VLA and print the planned (task x seed) "
|
|
2723
|
+
"matrix without running any rollouts. Useful in CI to "
|
|
2724
|
+
"validate config wiring."
|
|
2725
|
+
),
|
|
2726
|
+
),
|
|
2727
|
+
update_manifest: bool = typer.Option(
|
|
2728
|
+
True,
|
|
2729
|
+
"--update-manifest/--no-update-manifest",
|
|
2730
|
+
help=(
|
|
2731
|
+
"On success, write the avg_success_rate back into the rSkill "
|
|
2732
|
+
"manifest at `benchmarks.<suite_id>`. Surgical edit — "
|
|
2733
|
+
"preserves comments. Only fires for locally-resolvable rSkills. "
|
|
2734
|
+
"Disable for read-only paper-number runs."
|
|
2735
|
+
),
|
|
2736
|
+
),
|
|
2737
|
+
dashboard: bool = typer.Option(
|
|
2738
|
+
False,
|
|
2739
|
+
"--dashboard",
|
|
2740
|
+
help=(
|
|
2741
|
+
"Boot `openral dashboard` as a child process, point OTel at it, "
|
|
2742
|
+
"and shut it down on exit (same semantics as `openral sim run "
|
|
2743
|
+
"--dashboard`)."
|
|
2744
|
+
),
|
|
2745
|
+
),
|
|
2746
|
+
dashboard_port: int = typer.Option(
|
|
2747
|
+
4318,
|
|
2748
|
+
"--dashboard-port",
|
|
2749
|
+
help="Port for the spawned dashboard when --dashboard is set.",
|
|
2750
|
+
),
|
|
2751
|
+
) -> None:
|
|
2752
|
+
r"""Run a benchmark suite and write a validated `RSkillEvalResult` JSON.
|
|
2753
|
+
|
|
2754
|
+
The runner iterates ``scenes x range(seed, seed + n_episodes)``,
|
|
2755
|
+
delegating each rollout to ``openral_sim.SimRunner`` so the
|
|
2756
|
+
rSkill compatibility check, OTel spans, and latency-budget reporting
|
|
2757
|
+
are identical to ``openral sim run``. Each :class:`BenchmarkScene`
|
|
2758
|
+
carries its own scene + task + robot; the ``BenchmarkSpec`` wrapper
|
|
2759
|
+
class was removed so the suite is a bare list of scenes whose id is
|
|
2760
|
+
the YAML filename stem.
|
|
2761
|
+
|
|
2762
|
+
Example:
|
|
2763
|
+
>>> # openral benchmark run --suite libero_spatial \\
|
|
2764
|
+
>>> # --rskill smolvla-libero
|
|
2765
|
+
"""
|
|
2766
|
+
scenes, suite_id = _resolve_benchmark_suite(suite, benchmarks_dir)
|
|
2767
|
+
vla_spec = _parse_rskill_cli_arg(rskill)
|
|
2768
|
+
|
|
2769
|
+
# --task selects a single explicit task from the suite. The rSkill's
|
|
2770
|
+
# evaluated_tasks auto-filter (in run_benchmark) still applies, so an
|
|
2771
|
+
# explicitly-picked task the rSkill was not trained for is rejected — same
|
|
2772
|
+
# contract as `openral benchmark scene`.
|
|
2773
|
+
if task is not None:
|
|
2774
|
+
matched = [s for s in scenes if s.task.id == task]
|
|
2775
|
+
if not matched:
|
|
2776
|
+
available = [s.task.id for s in scenes]
|
|
2777
|
+
console.print(
|
|
2778
|
+
f"[red]--task {task!r} is not in suite {suite_id!r}.[/red] "
|
|
2779
|
+
f"Available tasks: {available}"
|
|
2780
|
+
)
|
|
2781
|
+
raise typer.Exit(1)
|
|
2782
|
+
scenes = matched
|
|
2783
|
+
|
|
2784
|
+
# Apply --n-episodes override to every scene before dry-run or real run.
|
|
2785
|
+
if n_episodes is not None:
|
|
2786
|
+
scenes = [s.model_copy(update={"n_episodes": n_episodes}) for s in scenes]
|
|
2787
|
+
|
|
2788
|
+
if dry_run:
|
|
2789
|
+
# Suite invariants (openral_core.raise_on_invalid_suite) guarantee
|
|
2790
|
+
# every BenchmarkScene shares robot_id / n_episodes / seed; read
|
|
2791
|
+
# from scenes[0] for the summary.
|
|
2792
|
+
first = scenes[0]
|
|
2793
|
+
eff_episodes = first.n_episodes
|
|
2794
|
+
# ``robot_id`` is non-None per raise_on_invalid_suite; coerce for printing.
|
|
2795
|
+
robot_id = first.robot_id or "<unset>"
|
|
2796
|
+
# When every scene shares one scene.id we print it; otherwise
|
|
2797
|
+
# show how many distinct scenes the suite covers.
|
|
2798
|
+
scene_ids = {scene.scene.id for scene in scenes}
|
|
2799
|
+
scene_summary = next(iter(scene_ids)) if len(scene_ids) == 1 else f"{len(scene_ids)} scenes"
|
|
2800
|
+
console.print(
|
|
2801
|
+
f"[cyan]suite[/cyan] {suite_id} — robot={robot_id} "
|
|
2802
|
+
f"scene={scene_summary} tasks={len(scenes)} "
|
|
2803
|
+
f"n_episodes={eff_episodes}"
|
|
2804
|
+
)
|
|
2805
|
+
console.print(f"[cyan]vla[/cyan] id={vla_spec.id} weights={vla_spec.weights_uri}")
|
|
2806
|
+
console.print(
|
|
2807
|
+
f"[cyan]plan[/cyan] {len(scenes) * eff_episodes} "
|
|
2808
|
+
f"episodes ({len(scenes)} tasks x {eff_episodes} reps)"
|
|
2809
|
+
)
|
|
2810
|
+
return
|
|
2811
|
+
|
|
2812
|
+
out_path = out if out is not None else _default_benchmark_out_path(vla_spec, suite_id)
|
|
2813
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
2814
|
+
|
|
2815
|
+
from openral_observability.dashboard import attached_dashboard
|
|
2816
|
+
from openral_sim.benchmark import run_benchmark
|
|
2817
|
+
|
|
2818
|
+
# attached_dashboard is a no-op when enabled=False — wrap
|
|
2819
|
+
# unconditionally so the run_benchmark call is un-duplicated.
|
|
2820
|
+
with attached_dashboard(enabled=dashboard, port=dashboard_port):
|
|
2821
|
+
result, episodes = run_benchmark(
|
|
2822
|
+
scenes,
|
|
2823
|
+
suite_id=suite_id,
|
|
2824
|
+
vla=vla_spec,
|
|
2825
|
+
device=device,
|
|
2826
|
+
save_dir=str(save_dir) if save_dir is not None else None,
|
|
2827
|
+
)
|
|
2828
|
+
|
|
2829
|
+
out_path.write_text(result.model_dump_json(indent=2))
|
|
2830
|
+
avg = result.results.get("avg_success_rate", 0.0)
|
|
2831
|
+
console.print(
|
|
2832
|
+
f"[green]wrote {out_path}[/green] — avg success "
|
|
2833
|
+
f"= {float(avg) if isinstance(avg, (int, float)) else avg:.3f} "
|
|
2834
|
+
f"over {len(episodes)} episodes"
|
|
2835
|
+
)
|
|
2836
|
+
|
|
2837
|
+
if update_manifest:
|
|
2838
|
+
from openral_rskill.loader import resolve_rskill_local_dir
|
|
2839
|
+
from openral_sim.benchmark import update_rskill_benchmarks
|
|
2840
|
+
|
|
2841
|
+
# Resolve to the in-tree dir so the surgical write hits
|
|
2842
|
+
# rskills/<name>/rskill.yaml even when the user supplied a bare
|
|
2843
|
+
# name or a Hub-style repo id. Falls through to a cwd-relative
|
|
2844
|
+
# path for Hub-only references with no in-tree shim — the
|
|
2845
|
+
# update is then a no-op (FileNotFoundError handled below).
|
|
2846
|
+
local_dir = resolve_rskill_local_dir(vla_spec.weights_uri)
|
|
2847
|
+
skill_dir = str(local_dir) if local_dir is not None else vla_spec.weights_uri
|
|
2848
|
+
|
|
2849
|
+
try:
|
|
2850
|
+
manifest_path = update_rskill_benchmarks(
|
|
2851
|
+
skill_dir,
|
|
2852
|
+
suite_id,
|
|
2853
|
+
float(avg) if isinstance(avg, (int, float)) else 0.0,
|
|
2854
|
+
)
|
|
2855
|
+
console.print(
|
|
2856
|
+
f"[green]updated {manifest_path}[/green] — "
|
|
2857
|
+
f"benchmarks.{suite_id} = "
|
|
2858
|
+
f"{float(avg) if isinstance(avg, (int, float)) else avg:.3f}"
|
|
2859
|
+
)
|
|
2860
|
+
except FileNotFoundError as exc:
|
|
2861
|
+
console.print(
|
|
2862
|
+
f"[yellow]skipped manifest update:[/yellow] {exc} (eval JSON was still written)"
|
|
2863
|
+
)
|
|
2864
|
+
|
|
2865
|
+
|
|
2866
|
+
def _resolve_benchmark_suite(
|
|
2867
|
+
suite: str,
|
|
2868
|
+
benchmarks_dir: Path,
|
|
2869
|
+
) -> tuple[list[BenchmarkScene], str]:
|
|
2870
|
+
"""Map a ``--suite`` argument to a validated ``(scenes, suite_id)`` tuple.
|
|
2871
|
+
|
|
2872
|
+
A benchmark suite is a bare ``list[BenchmarkScene]`` YAML;
|
|
2873
|
+
the suite id is the filename stem. Accepts either a built-in id
|
|
2874
|
+
(resolved to ``benchmarks/<id>.yaml``) or a direct path. Bare ids
|
|
2875
|
+
that don't resolve raise ``typer.BadParameter`` listing the catalogue
|
|
2876
|
+
entries that ARE present so typos are easy to fix. Per-scene Pydantic
|
|
2877
|
+
validation and suite-level invariant checks (uniformity, uniqueness,
|
|
2878
|
+
non-empty) run here; any failure surfaces as a ``typer.BadParameter``
|
|
2879
|
+
so the CLI exit code stays informative.
|
|
2880
|
+
"""
|
|
2881
|
+
from openral_core import load_benchmark_suite, raise_on_invalid_suite
|
|
2882
|
+
from openral_core.exceptions import ROSConfigError
|
|
2883
|
+
|
|
2884
|
+
candidate = Path(suite)
|
|
2885
|
+
if candidate.suffix in {".yaml", ".yml"} or candidate.exists():
|
|
2886
|
+
if not candidate.exists():
|
|
2887
|
+
raise typer.BadParameter(f"benchmark suite YAML not found: {candidate}")
|
|
2888
|
+
resolved_path = candidate
|
|
2889
|
+
else:
|
|
2890
|
+
resolved_path = benchmarks_dir / f"{suite}.yaml"
|
|
2891
|
+
if not resolved_path.exists():
|
|
2892
|
+
available = (
|
|
2893
|
+
sorted(p.stem for p in benchmarks_dir.glob("*.yaml") if p.is_file())
|
|
2894
|
+
if benchmarks_dir.is_dir()
|
|
2895
|
+
else []
|
|
2896
|
+
)
|
|
2897
|
+
raise typer.BadParameter(
|
|
2898
|
+
f"unknown benchmark suite {suite!r}; "
|
|
2899
|
+
f"available in {benchmarks_dir}/: {available if available else '<empty>'}"
|
|
2900
|
+
)
|
|
2901
|
+
|
|
2902
|
+
suite_id = resolved_path.stem
|
|
2903
|
+
try:
|
|
2904
|
+
scenes = load_benchmark_suite(str(resolved_path))
|
|
2905
|
+
raise_on_invalid_suite(scenes, suite_id=suite_id)
|
|
2906
|
+
except ROSConfigError as exc:
|
|
2907
|
+
raise typer.BadParameter(str(exc)) from exc
|
|
2908
|
+
return scenes, suite_id
|
|
2909
|
+
|
|
2910
|
+
|
|
2911
|
+
def _parse_rskill_cli_arg(raw: str) -> VLASpec:
|
|
2912
|
+
"""Parse ``--rskill <reference>`` into a `VLASpec`.
|
|
2913
|
+
|
|
2914
|
+
Accepts a bare rSkill reference — a name (``smolvla-libero``),
|
|
2915
|
+
a path (``rskills/smolvla-libero``), or an HF repo id
|
|
2916
|
+
(``OpenRAL/rskill-smolvla-libero``). The adapter id is read from
|
|
2917
|
+
the loaded manifest's ``model_family`` field.
|
|
2918
|
+
"""
|
|
2919
|
+
from openral_core import VLASpec
|
|
2920
|
+
from openral_core.exceptions import ROSConfigError
|
|
2921
|
+
from openral_rskill.loader import _validate_skill_ref, load_rskill_manifest
|
|
2922
|
+
|
|
2923
|
+
try:
|
|
2924
|
+
uri = _validate_skill_ref(raw)
|
|
2925
|
+
except ROSConfigError as exc:
|
|
2926
|
+
raise typer.BadParameter(str(exc)) from exc
|
|
2927
|
+
manifest = load_rskill_manifest(uri)
|
|
2928
|
+
if manifest.model_family is None:
|
|
2929
|
+
# Only `kind='vla'` skills carry a model_family; a detector/reward skill
|
|
2930
|
+
# has none and cannot drive a VLASpec.
|
|
2931
|
+
raise typer.BadParameter(
|
|
2932
|
+
f"rSkill {raw!r} has no model_family (kind={manifest.kind!r}); "
|
|
2933
|
+
f"--rskill expects a VLA skill."
|
|
2934
|
+
)
|
|
2935
|
+
return VLASpec(
|
|
2936
|
+
id=manifest.model_family,
|
|
2937
|
+
weights_uri=uri,
|
|
2938
|
+
extra=dict(manifest.policy_extras),
|
|
2939
|
+
)
|
|
2940
|
+
|
|
2941
|
+
|
|
2942
|
+
def _default_benchmark_out_path(vla_spec: VLASpec, suite_id: str) -> Path:
|
|
2943
|
+
"""Derive ``rskills/<vla>/eval/<suite_id>.json`` from a VLASpec + suite id.
|
|
2944
|
+
|
|
2945
|
+
Resolves the rSkill to its in-tree directory via
|
|
2946
|
+
:func:`openral_rskill.loader.resolve_rskill_local_dir` so the JSON
|
|
2947
|
+
lands in the right place regardless of which URI form the user typed
|
|
2948
|
+
(bare name, ``rskills/<name>``, Hub repo id). Falls back to the
|
|
2949
|
+
library's :func:`openral_sim.benchmark.default_output_path` when no
|
|
2950
|
+
in-tree shim exists (Hub-only references).
|
|
2951
|
+
"""
|
|
2952
|
+
from openral_rskill.loader import resolve_rskill_local_dir
|
|
2953
|
+
from openral_sim.benchmark import default_output_path
|
|
2954
|
+
|
|
2955
|
+
local_dir = resolve_rskill_local_dir(vla_spec.weights_uri)
|
|
2956
|
+
if local_dir is not None:
|
|
2957
|
+
return local_dir / "eval" / f"{suite_id}.json"
|
|
2958
|
+
return Path(default_output_path(vla_spec.weights_uri, suite_id))
|
|
2959
|
+
|
|
2960
|
+
|
|
2961
|
+
@benchmark_app.command("scene")
|
|
2962
|
+
def benchmark_scene(
|
|
2963
|
+
config: Path = typer.Option(
|
|
2964
|
+
...,
|
|
2965
|
+
"--config",
|
|
2966
|
+
help=(
|
|
2967
|
+
"Path to a BenchmarkScene YAML "
|
|
2968
|
+
"(`scenes/benchmark/<id>.yaml`). DeployScene and SimScene "
|
|
2969
|
+
"YAMLs are rejected with a redirect — `openral benchmark "
|
|
2970
|
+
"scene` accepts BenchmarkScene only (scene + task + "
|
|
2971
|
+
"`n_episodes` + `seed` + `metadata.paper` + "
|
|
2972
|
+
"`metadata.honest_scope`)."
|
|
2973
|
+
),
|
|
2974
|
+
),
|
|
2975
|
+
rskill: str = typer.Option(
|
|
2976
|
+
...,
|
|
2977
|
+
"--rskill",
|
|
2978
|
+
help=(
|
|
2979
|
+
"rSkill reference — a bare name ('smolvla-libero'), a "
|
|
2980
|
+
"path ('rskills/smolvla-libero'), or an HF Hub repo id "
|
|
2981
|
+
"('OpenRAL/rskill-smolvla-libero'). "
|
|
2982
|
+
"Raw hf:// is rejected (weights must come from a manifest). "
|
|
2983
|
+
"The policy adapter id is read from the manifest's `model_family` "
|
|
2984
|
+
"field."
|
|
2985
|
+
),
|
|
2986
|
+
),
|
|
2987
|
+
out: Path | None = typer.Option(
|
|
2988
|
+
None,
|
|
2989
|
+
"--out",
|
|
2990
|
+
help=(
|
|
2991
|
+
"Output path for the RSkillEvalResult JSON. Defaults to "
|
|
2992
|
+
"rskills/<dir>/eval/scene_<scene_id>.json derived from the "
|
|
2993
|
+
"rSkill ref."
|
|
2994
|
+
),
|
|
2995
|
+
),
|
|
2996
|
+
device: str | None = typer.Option(
|
|
2997
|
+
None,
|
|
2998
|
+
"--device",
|
|
2999
|
+
help="Torch device override for the policy (cpu, cuda:0, mps, auto).",
|
|
3000
|
+
),
|
|
3001
|
+
save_dir: Path | None = typer.Option(
|
|
3002
|
+
None,
|
|
3003
|
+
"--save-dir",
|
|
3004
|
+
help="Optional adapter-side artefact directory (videos, traces).",
|
|
3005
|
+
),
|
|
3006
|
+
save_video: Path | None = typer.Option(
|
|
3007
|
+
None,
|
|
3008
|
+
"--save-video",
|
|
3009
|
+
help=(
|
|
3010
|
+
"Write a clean single-view world MP4 per episode to this "
|
|
3011
|
+
"directory, named <task>_<rskill>_<success|fail>.mp4, plus a "
|
|
3012
|
+
"videos.json manifest — for website hero clips (overlays are "
|
|
3013
|
+
"rendered by the page, not burned into pixels). The task slug "
|
|
3014
|
+
"keeps benchmark scenes sharing one backend from overwriting each "
|
|
3015
|
+
"other. Enables per-step frame capture. Pair with --n-episodes 1 "
|
|
3016
|
+
"for a single demo clip."
|
|
3017
|
+
),
|
|
3018
|
+
),
|
|
3019
|
+
video_size: int = typer.Option(
|
|
3020
|
+
1024,
|
|
3021
|
+
"--video-size",
|
|
3022
|
+
help=(
|
|
3023
|
+
"Square edge (px) for --save-video output. Frames are "
|
|
3024
|
+
"center-cropped to a square and resized to this size. Source "
|
|
3025
|
+
"sharpness is bounded by the scene's native render resolution."
|
|
3026
|
+
),
|
|
3027
|
+
),
|
|
3028
|
+
n_episodes: int | None = typer.Option(
|
|
3029
|
+
None,
|
|
3030
|
+
"--n-episodes",
|
|
3031
|
+
help=(
|
|
3032
|
+
"Override `BenchmarkScene.n_episodes` (lower for quick smoke "
|
|
3033
|
+
"runs). The published-protocol value lives in the YAML; this "
|
|
3034
|
+
"flag is for fast iteration, not for paper-comparison numbers."
|
|
3035
|
+
),
|
|
3036
|
+
),
|
|
3037
|
+
view: bool | None = typer.Option(
|
|
3038
|
+
None,
|
|
3039
|
+
"--view/--no-view",
|
|
3040
|
+
help=(
|
|
3041
|
+
"Open a passive mujoco.viewer window and stream the rollout in "
|
|
3042
|
+
"real time (parity with `openral sim run --view`). Default "
|
|
3043
|
+
"(unset): headless — benchmark eval artefacts and CI/deploy "
|
|
3044
|
+
"runs are unaffected. Pass --view to require a window (errors "
|
|
3045
|
+
"loud if unsupported), or --no-view to force offscreen. "
|
|
3046
|
+
"Incompatible with MUJOCO_GL=egl."
|
|
3047
|
+
),
|
|
3048
|
+
),
|
|
3049
|
+
dry_run: bool = typer.Option(
|
|
3050
|
+
False,
|
|
3051
|
+
"--dry-run",
|
|
3052
|
+
help=(
|
|
3053
|
+
"Resolve the scene + rSkill and print the planned (task x "
|
|
3054
|
+
"seed) matrix without running any rollouts. Useful in CI to "
|
|
3055
|
+
"validate config wiring."
|
|
3056
|
+
),
|
|
3057
|
+
),
|
|
3058
|
+
update_manifest: bool = typer.Option(
|
|
3059
|
+
True,
|
|
3060
|
+
"--update-manifest/--no-update-manifest",
|
|
3061
|
+
help=(
|
|
3062
|
+
"On success, write the avg_success_rate back into the rSkill "
|
|
3063
|
+
"manifest at `benchmarks.<scene_id>`. Surgical edit — "
|
|
3064
|
+
"preserves comments. Only fires for locally-resolvable rSkills."
|
|
3065
|
+
),
|
|
3066
|
+
),
|
|
3067
|
+
write_eval: bool = typer.Option(
|
|
3068
|
+
True,
|
|
3069
|
+
"--write-eval/--no-write-eval",
|
|
3070
|
+
help=(
|
|
3071
|
+
"Persist the RSkillEvalResult JSON to --out (default "
|
|
3072
|
+
"rskills/<dir>/eval/scene_<scene_id>.json, a tracked path). "
|
|
3073
|
+
"Pass --no-write-eval for a fully non-mutating smoke run: the "
|
|
3074
|
+
"rollout still executes and prints its score, but nothing is "
|
|
3075
|
+
"written to the rSkill package (implies --no-update-manifest)."
|
|
3076
|
+
),
|
|
3077
|
+
),
|
|
3078
|
+
dashboard: bool = typer.Option(
|
|
3079
|
+
False,
|
|
3080
|
+
"--dashboard",
|
|
3081
|
+
help=(
|
|
3082
|
+
"Boot `openral dashboard` as a child process, point OTel at it, "
|
|
3083
|
+
"and shut it down on exit (same semantics as `openral sim run "
|
|
3084
|
+
"--dashboard`)."
|
|
3085
|
+
),
|
|
3086
|
+
),
|
|
3087
|
+
dashboard_port: int = typer.Option(
|
|
3088
|
+
4318,
|
|
3089
|
+
"--dashboard-port",
|
|
3090
|
+
help="Port for the spawned dashboard when --dashboard is set.",
|
|
3091
|
+
),
|
|
3092
|
+
) -> None:
|
|
3093
|
+
r"""Run a single-scene benchmark and write a validated `RSkillEvalResult` JSON.
|
|
3094
|
+
|
|
3095
|
+
Single-scene sibling of ``openral benchmark run --suite`` — accepts
|
|
3096
|
+
exactly one :class:`BenchmarkScene` YAML and emits the same eval JSON
|
|
3097
|
+
shape so ``openral benchmark report`` does not need to distinguish
|
|
3098
|
+
the two entrypoints.
|
|
3099
|
+
|
|
3100
|
+
Example:
|
|
3101
|
+
>>> # openral benchmark scene \\
|
|
3102
|
+
>>> # --config scenes/benchmark/pusht.yaml \\
|
|
3103
|
+
>>> # --rskill diffusion-pusht
|
|
3104
|
+
"""
|
|
3105
|
+
from openral_core import BenchmarkScene, load_scene_strict
|
|
3106
|
+
|
|
3107
|
+
scene = load_scene_strict(str(config), BenchmarkScene)
|
|
3108
|
+
if n_episodes is not None:
|
|
3109
|
+
scene = scene.model_copy(update={"n_episodes": n_episodes})
|
|
3110
|
+
|
|
3111
|
+
if dry_run:
|
|
3112
|
+
# Dry-run validates config wiring only — do not touch the Hub or
|
|
3113
|
+
# load weights. Print the raw --rskill argument as-typed.
|
|
3114
|
+
console.print(
|
|
3115
|
+
f"[cyan]scene[/cyan] {scene.scene.id} — robot={scene.robot_id} "
|
|
3116
|
+
f"task={scene.task.id} n_episodes={scene.n_episodes} "
|
|
3117
|
+
f"seed={scene.seed}"
|
|
3118
|
+
)
|
|
3119
|
+
console.print(f"[cyan]vla[/cyan] rskill={rskill}")
|
|
3120
|
+
console.print(
|
|
3121
|
+
f"[cyan]plan[/cyan] {scene.n_episodes} episodes (seeds "
|
|
3122
|
+
f"{scene.seed}..{scene.seed + scene.n_episodes - 1})"
|
|
3123
|
+
)
|
|
3124
|
+
return
|
|
3125
|
+
|
|
3126
|
+
vla_spec = _parse_rskill_cli_arg(rskill)
|
|
3127
|
+
out_path = out if out is not None else _default_benchmark_scene_out_path(vla_spec, scene)
|
|
3128
|
+
|
|
3129
|
+
from openral_observability.dashboard import attached_dashboard
|
|
3130
|
+
from openral_sim.benchmark import run_benchmark_scene
|
|
3131
|
+
|
|
3132
|
+
with attached_dashboard(enabled=dashboard, port=dashboard_port):
|
|
3133
|
+
result, episodes = run_benchmark_scene(
|
|
3134
|
+
scene,
|
|
3135
|
+
vla_spec,
|
|
3136
|
+
device=device,
|
|
3137
|
+
save_dir=str(save_dir) if save_dir is not None else None,
|
|
3138
|
+
config_path=str(config),
|
|
3139
|
+
view=view,
|
|
3140
|
+
record_video=save_video is not None,
|
|
3141
|
+
)
|
|
3142
|
+
|
|
3143
|
+
if save_video is not None:
|
|
3144
|
+
from openral_sim._website_video import write_world_videos
|
|
3145
|
+
|
|
3146
|
+
write_world_videos(
|
|
3147
|
+
episodes,
|
|
3148
|
+
save_video,
|
|
3149
|
+
scene=scene.task.id,
|
|
3150
|
+
rskill=Path(rskill).name,
|
|
3151
|
+
section=Path(config).parent.name,
|
|
3152
|
+
size=video_size,
|
|
3153
|
+
)
|
|
3154
|
+
|
|
3155
|
+
avg = result.results.get("avg_success_rate", 0.0)
|
|
3156
|
+
avg_f = float(avg) if isinstance(avg, (int, float)) else 0.0
|
|
3157
|
+
if _persist_scene_eval(result, out_path, write_eval=write_eval):
|
|
3158
|
+
console.print(
|
|
3159
|
+
f"[green]wrote {out_path}[/green] — avg success "
|
|
3160
|
+
f"= {avg_f:.3f} over {len(episodes)} episodes"
|
|
3161
|
+
)
|
|
3162
|
+
else:
|
|
3163
|
+
console.print(
|
|
3164
|
+
f"[yellow]--no-write-eval:[/yellow] not persisting result — avg success "
|
|
3165
|
+
f"= {avg_f:.3f} over {len(episodes)} episodes (nothing written to the rSkill)"
|
|
3166
|
+
)
|
|
3167
|
+
|
|
3168
|
+
if not write_eval:
|
|
3169
|
+
# Non-mutating smoke run: skip the manifest writeback too.
|
|
3170
|
+
return
|
|
3171
|
+
|
|
3172
|
+
if update_manifest and not _scene_id_is_benchmark_suite(scene.scene.id):
|
|
3173
|
+
# The rskill.yaml `benchmarks:` block holds canonical SUITE headlines
|
|
3174
|
+
# (RSkillManifest.benchmarks is keyed by the BenchmarkName literal).
|
|
3175
|
+
# A single scene whose id is not itself a suite id (e.g. 'metaworld',
|
|
3176
|
+
# 'robocasa/PickPlaceCounterToCabinet') has no headline slot — writing
|
|
3177
|
+
# it would raise ROSConfigError. The per-scene result is already
|
|
3178
|
+
# captured in the eval JSON, so we skip the manifest write rather than
|
|
3179
|
+
# crash. (Scenes whose id IS a suite id — pusht, libero_spatial — still
|
|
3180
|
+
# update the headline below.)
|
|
3181
|
+
console.print(
|
|
3182
|
+
f"[yellow]skipped manifest update:[/yellow] scene id {scene.scene.id!r} "
|
|
3183
|
+
f"is not a canonical benchmark suite id; per-scene result written to "
|
|
3184
|
+
f"{out_path} only (rskill.yaml benchmarks: holds suite headlines)."
|
|
3185
|
+
)
|
|
3186
|
+
elif update_manifest:
|
|
3187
|
+
from openral_rskill.loader import resolve_rskill_local_dir
|
|
3188
|
+
from openral_sim.benchmark import update_rskill_benchmarks
|
|
3189
|
+
|
|
3190
|
+
local_dir = resolve_rskill_local_dir(vla_spec.weights_uri)
|
|
3191
|
+
skill_dir = str(local_dir) if local_dir is not None else vla_spec.weights_uri
|
|
3192
|
+
try:
|
|
3193
|
+
manifest_path = update_rskill_benchmarks(
|
|
3194
|
+
skill_dir,
|
|
3195
|
+
scene.scene.id,
|
|
3196
|
+
float(avg) if isinstance(avg, (int, float)) else 0.0,
|
|
3197
|
+
)
|
|
3198
|
+
console.print(
|
|
3199
|
+
f"[green]updated {manifest_path}[/green] — "
|
|
3200
|
+
f"benchmarks.{scene.scene.id} = "
|
|
3201
|
+
f"{float(avg) if isinstance(avg, (int, float)) else avg:.3f}"
|
|
3202
|
+
)
|
|
3203
|
+
except FileNotFoundError as exc:
|
|
3204
|
+
console.print(
|
|
3205
|
+
f"[yellow]skipped manifest update:[/yellow] {exc} (eval JSON was still written)"
|
|
3206
|
+
)
|
|
3207
|
+
|
|
3208
|
+
|
|
3209
|
+
def _scene_id_is_benchmark_suite(scene_id: str) -> bool:
|
|
3210
|
+
"""True iff ``scene_id`` is a canonical ``BenchmarkName`` suite id.
|
|
3211
|
+
|
|
3212
|
+
``openral benchmark scene`` only writes ``rskill.yaml``'s ``benchmarks:``
|
|
3213
|
+
block (the suite-headline map keyed by the ``BenchmarkName`` literal) when
|
|
3214
|
+
the scene's id IS one of those suite ids — e.g. ``"pusht"``,
|
|
3215
|
+
``"libero_spatial"``. Arbitrary single-scene ids such as ``"metaworld"``
|
|
3216
|
+
(suite is ``"metaworld_mt50"``) or ``"robocasa/PickPlaceCounterToCabinet"``
|
|
3217
|
+
have no headline slot, so the manifest write is skipped (the per-scene
|
|
3218
|
+
eval JSON still records the result).
|
|
3219
|
+
"""
|
|
3220
|
+
from typing import get_args
|
|
3221
|
+
|
|
3222
|
+
from openral_core import BenchmarkName
|
|
3223
|
+
|
|
3224
|
+
return scene_id in set(get_args(BenchmarkName))
|
|
3225
|
+
|
|
3226
|
+
|
|
3227
|
+
def _persist_scene_eval(result: RSkillEvalResult, out_path: Path, *, write_eval: bool) -> bool:
|
|
3228
|
+
"""Persist a benchmark-scene ``RSkillEvalResult`` to ``out_path``.
|
|
3229
|
+
|
|
3230
|
+
Returns ``True`` if the file was written, ``False`` when ``write_eval``
|
|
3231
|
+
is ``False`` (the ``--no-write-eval`` non-mutating smoke-run mode — the
|
|
3232
|
+
rollout still runs, but nothing touches the tracked rSkill package).
|
|
3233
|
+
"""
|
|
3234
|
+
if not write_eval:
|
|
3235
|
+
return False
|
|
3236
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
3237
|
+
out_path.write_text(result.model_dump_json(indent=2))
|
|
3238
|
+
return True
|
|
3239
|
+
|
|
3240
|
+
|
|
3241
|
+
def _default_benchmark_scene_out_path(vla_spec: VLASpec, scene: BenchmarkScene) -> Path:
|
|
3242
|
+
"""Derive ``rskills/<vla>/eval/scene_<scene_id>.json`` from a VLASpec.
|
|
3243
|
+
|
|
3244
|
+
Mirrors :func:`_default_benchmark_out_path` for the single-scene
|
|
3245
|
+
entrypoint. The ``scene_`` prefix distinguishes per-scene JSONs from
|
|
3246
|
+
multi-task suite JSONs so both can coexist under the same rSkill.
|
|
3247
|
+
"""
|
|
3248
|
+
from openral_rskill.loader import resolve_rskill_local_dir
|
|
3249
|
+
from openral_sim.benchmark import default_output_path
|
|
3250
|
+
|
|
3251
|
+
local_dir = resolve_rskill_local_dir(vla_spec.weights_uri)
|
|
3252
|
+
if local_dir is not None:
|
|
3253
|
+
return local_dir / "eval" / f"scene_{scene.scene.id}.json"
|
|
3254
|
+
return Path(default_output_path(vla_spec.weights_uri, f"scene_{scene.scene.id}"))
|
|
3255
|
+
|
|
3256
|
+
|
|
3257
|
+
@benchmark_app.command("report")
|
|
3258
|
+
def benchmark_report(
|
|
3259
|
+
rskills_dir: Path = typer.Option(
|
|
3260
|
+
Path("rskills"),
|
|
3261
|
+
"--rskills-dir",
|
|
3262
|
+
help="Directory containing rSkill packages (each with optional eval/*.json).",
|
|
3263
|
+
),
|
|
3264
|
+
json_output: bool = typer.Option(
|
|
3265
|
+
False,
|
|
3266
|
+
"--json",
|
|
3267
|
+
help="Emit a JSON dump instead of the rich-table summary.",
|
|
3268
|
+
),
|
|
3269
|
+
) -> None:
|
|
3270
|
+
"""Walk every ``<skill>/eval/*.json`` and print a benchmark roll-up.
|
|
3271
|
+
|
|
3272
|
+
Validates each JSON against `openral_core.RSkillEvalResult`
|
|
3273
|
+
(the same schema the rSkill loader uses at install time) so a rotted
|
|
3274
|
+
file fails loudly instead of silently being skipped.
|
|
3275
|
+
|
|
3276
|
+
Example:
|
|
3277
|
+
>>> # openral benchmark report
|
|
3278
|
+
>>> # openral benchmark report --json > /tmp/report.json
|
|
3279
|
+
"""
|
|
3280
|
+
from openral_core import RSkillEvalResult
|
|
3281
|
+
from pydantic import ValidationError
|
|
3282
|
+
|
|
3283
|
+
if not rskills_dir.is_dir():
|
|
3284
|
+
console.print(f"[red]rskills directory not found: {rskills_dir}[/red]")
|
|
3285
|
+
raise typer.Exit(code=1)
|
|
3286
|
+
|
|
3287
|
+
rows: list[dict[str, object]] = []
|
|
3288
|
+
for skill_dir in sorted(p for p in rskills_dir.iterdir() if p.is_dir()):
|
|
3289
|
+
eval_dir = skill_dir / "eval"
|
|
3290
|
+
if not eval_dir.is_dir():
|
|
3291
|
+
continue
|
|
3292
|
+
for json_path in sorted(eval_dir.glob("*.json")):
|
|
3293
|
+
try:
|
|
3294
|
+
result = RSkillEvalResult.from_json(str(json_path))
|
|
3295
|
+
except (ValidationError, _json.JSONDecodeError) as exc:
|
|
3296
|
+
console.print(f"[red]invalid {json_path}:[/red] {exc}")
|
|
3297
|
+
raise typer.Exit(code=1) from exc
|
|
3298
|
+
rows.append(
|
|
3299
|
+
{
|
|
3300
|
+
"rskill": skill_dir.name,
|
|
3301
|
+
"benchmark": result.benchmark.name,
|
|
3302
|
+
"robot": result.benchmark.robot,
|
|
3303
|
+
"simulator": result.benchmark.simulator,
|
|
3304
|
+
"reproduced_locally": result.source.reproduced_locally,
|
|
3305
|
+
"model_variant": result.source.model_variant,
|
|
3306
|
+
"status": result.source.status,
|
|
3307
|
+
"results": result.results,
|
|
3308
|
+
"path": (
|
|
3309
|
+
str(json_path.relative_to(Path.cwd()))
|
|
3310
|
+
if json_path.is_relative_to(Path.cwd())
|
|
3311
|
+
else str(json_path)
|
|
3312
|
+
),
|
|
3313
|
+
}
|
|
3314
|
+
)
|
|
3315
|
+
|
|
3316
|
+
if json_output:
|
|
3317
|
+
console.print_json(_json.dumps(rows, indent=2, default=str))
|
|
3318
|
+
return
|
|
3319
|
+
|
|
3320
|
+
if not rows:
|
|
3321
|
+
console.print(f"[yellow]no rskills/<id>/eval/*.json files under {rskills_dir}[/yellow]")
|
|
3322
|
+
return
|
|
3323
|
+
|
|
3324
|
+
rows.sort(key=lambda r: (str(r["benchmark"]), str(r["rskill"])))
|
|
3325
|
+
table = Table(title=f"rSkill benchmark report — {len(rows)} entries")
|
|
3326
|
+
table.add_column("Benchmark", style="cyan")
|
|
3327
|
+
table.add_column("rSkill", style="magenta")
|
|
3328
|
+
table.add_column("Variant", style="dim")
|
|
3329
|
+
table.add_column("Robot", style="dim")
|
|
3330
|
+
table.add_column("Repro local?", justify="center")
|
|
3331
|
+
table.add_column("Headline result", style="green")
|
|
3332
|
+
table.add_column("Status", style="dim")
|
|
3333
|
+
for row in rows:
|
|
3334
|
+
results = row["results"]
|
|
3335
|
+
headline = _summarize_results(results) if isinstance(results, dict) else "—"
|
|
3336
|
+
table.add_row(
|
|
3337
|
+
str(row["benchmark"]),
|
|
3338
|
+
str(row["rskill"]),
|
|
3339
|
+
str(row["model_variant"]),
|
|
3340
|
+
str(row["robot"]),
|
|
3341
|
+
"✓" if row["reproduced_locally"] else "✗",
|
|
3342
|
+
headline,
|
|
3343
|
+
str(row["status"] or ""),
|
|
3344
|
+
)
|
|
3345
|
+
console.print(table)
|
|
3346
|
+
|
|
3347
|
+
|
|
3348
|
+
def _summarize_results(results: dict[str, object]) -> str:
|
|
3349
|
+
"""Produce a one-line headline from a freeform ``results`` block.
|
|
3350
|
+
|
|
3351
|
+
Picks ``*_avg`` keys first, then falls back to a single-numeric value
|
|
3352
|
+
or a status string. Returns ``"—"`` when nothing summarisable is found.
|
|
3353
|
+
"""
|
|
3354
|
+
avg_keys = [k for k in results if k.endswith("_avg") or k == "avg"]
|
|
3355
|
+
if avg_keys:
|
|
3356
|
+
v = results[avg_keys[0]]
|
|
3357
|
+
if isinstance(v, (int, float)):
|
|
3358
|
+
return f"avg = {v:.3f}"
|
|
3359
|
+
if isinstance(v, dict) and "success_rate" in v:
|
|
3360
|
+
sr = v["success_rate"]
|
|
3361
|
+
if isinstance(sr, (int, float)):
|
|
3362
|
+
return f"avg success = {sr:.3f}"
|
|
3363
|
+
numeric_keys = [
|
|
3364
|
+
k for k, v in results.items() if isinstance(v, (int, float)) and not isinstance(v, bool)
|
|
3365
|
+
]
|
|
3366
|
+
if len(numeric_keys) == 1:
|
|
3367
|
+
v = results[numeric_keys[0]]
|
|
3368
|
+
return f"{numeric_keys[0]} = {v:.3f}" if isinstance(v, (int, float)) else "—"
|
|
3369
|
+
if "status" in results and isinstance(results["status"], str):
|
|
3370
|
+
return f"status: {results['status']}"
|
|
3371
|
+
return "—"
|
|
3372
|
+
|
|
3373
|
+
|
|
3374
|
+
# ── sim sub-app ───────────────────────────────────────────────────────────────
|
|
3375
|
+
#
|
|
3376
|
+
# Mounts the ``openral sim`` Typer group exported by ``openral_sim.cli`` so
|
|
3377
|
+
# users can invoke the sim eval runner as ``openral sim run …``.
|
|
3378
|
+
#
|
|
3379
|
+
# Lazy-import discipline: importing `openral_sim.cli` at module load is
|
|
3380
|
+
# light (only the Typer option metadata + a couple of pydantic / structlog
|
|
3381
|
+
# imports). The heavy sim dependencies (torch, mujoco, gymnasium, lerobot)
|
|
3382
|
+
# load inside `openral_sim.runner` and the per-adapter modules under
|
|
3383
|
+
# `openral_sim.policies/backends`, which `_run()` imports lazily.
|
|
3384
|
+
# `tests/unit/test_cli_eval.py::test_bh_cli_import_is_light` guards this.
|
|
3385
|
+
app.add_typer(sim_app, name="sim")
|
|
3386
|
+
|
|
3387
|
+
# `openral install <group>` — post-install escape hatch for the
|
|
3388
|
+
# Tier-0 curl-bash installer (`scripts/install.sh`). The base install puts
|
|
3389
|
+
# `openral` on $PATH with the CLI's own thin runtime; sim physics, LIBERO,
|
|
3390
|
+
# MetaWorld, RoboCasa, and the sudo+apt ROS 2 bootstrap layer in on demand.
|
|
3391
|
+
app.add_typer(install_app, name="install")
|
|
3392
|
+
|
|
3393
|
+
# `openral dataset push` — publish a LeRobotDataset v3 to the HF Hub.
|
|
3394
|
+
# Importing `dataset` at module top is cheap; the `push` command itself lazy-
|
|
3395
|
+
# imports huggingface_hub only when actually publishing so `openral --help` stays
|
|
3396
|
+
# sub-second.
|
|
3397
|
+
app.add_typer(dataset_app, name="dataset")
|
|
3398
|
+
|
|
3399
|
+
# `openral collision lower|check` — offline URDF/SRDF → manifest
|
|
3400
|
+
# self-collision model. The `lower_robot` import is deferred inside the commands
|
|
3401
|
+
# (it pulls yourdfpy/trimesh) so `openral --help` stays fast.
|
|
3402
|
+
app.add_typer(collision_app, name="collision")
|
|
3403
|
+
|
|
3404
|
+
# `openral check` — static, host-independent validation of the declarative
|
|
3405
|
+
# robot/skill/scene set (manifests parse, asset refs resolve, scene robot_ids and
|
|
3406
|
+
# rSkill embodiment tags resolve). Complements `openral rskill check`. Manifest
|
|
3407
|
+
# JSON-Schema emission lives in `tools/schema_export.py` (CI-gated), not here.
|
|
3408
|
+
app.command("check")(check_command)
|
|
3409
|
+
|
|
3410
|
+
# `openral robot vendor-urdf <id>` — expand an upstream xacro to a
|
|
3411
|
+
# flat, committed URDF so end users need no xacro tooling at runtime. The
|
|
3412
|
+
# `vendor_urdf` import is deferred inside the command (it pulls robot_descriptions/
|
|
3413
|
+
# xacrodoc/yourdfpy) so `openral --help` stays fast.
|
|
3414
|
+
robot_app = typer.Typer(
|
|
3415
|
+
name="robot",
|
|
3416
|
+
help="Robot description assets — vendor a flat URDF from an upstream xacro.",
|
|
3417
|
+
no_args_is_help=True,
|
|
3418
|
+
)
|
|
3419
|
+
app.add_typer(robot_app, name="robot")
|
|
3420
|
+
|
|
3421
|
+
|
|
3422
|
+
@robot_app.command("vendor-urdf")
|
|
3423
|
+
def robot_vendor_urdf(
|
|
3424
|
+
robot_id: str = typer.Argument(
|
|
3425
|
+
...,
|
|
3426
|
+
help="OpenRAL robot id; names the output file (e.g. ur5e → ur5e.urdf).",
|
|
3427
|
+
),
|
|
3428
|
+
upstream: str = typer.Option(
|
|
3429
|
+
...,
|
|
3430
|
+
"--upstream",
|
|
3431
|
+
help=(
|
|
3432
|
+
"Upstream source: 'rd:<robot_descriptions module>' (xacro, expanded "
|
|
3433
|
+
"via xacrodoc) or 'file:<path>' to an already-flat URDF."
|
|
3434
|
+
),
|
|
3435
|
+
),
|
|
3436
|
+
out: Path = typer.Option(
|
|
3437
|
+
...,
|
|
3438
|
+
"--out",
|
|
3439
|
+
help="Output directory; '<robot_id>.urdf' is written here.",
|
|
3440
|
+
),
|
|
3441
|
+
rename: list[str] | None = typer.Option(
|
|
3442
|
+
None,
|
|
3443
|
+
"--rename",
|
|
3444
|
+
help=(
|
|
3445
|
+
"Joint-name rename as 'PATTERN=>REPL' (regex re.sub). Repeatable — "
|
|
3446
|
+
"applied in order (so100/so101 take 6, gr1/h1 take 1). Defaults to "
|
|
3447
|
+
"the per-robot rule (openarm strips its 'openarm_' prefix)."
|
|
3448
|
+
),
|
|
3449
|
+
),
|
|
3450
|
+
raw_text: bool = typer.Option(
|
|
3451
|
+
False,
|
|
3452
|
+
"--raw-text/--no-raw-text",
|
|
3453
|
+
help=(
|
|
3454
|
+
"Copy an already-flat upstream URDF verbatim and apply --rename to "
|
|
3455
|
+
"the raw XML (no yourdfpy round-trip), preserving package:// mesh "
|
|
3456
|
+
"paths byte-for-byte (so100/so101/gr1/h1)."
|
|
3457
|
+
),
|
|
3458
|
+
),
|
|
3459
|
+
) -> None:
|
|
3460
|
+
"""Expand an upstream description to a flat, committed URDF."""
|
|
3461
|
+
from openral_cli.robot import vendor_urdf
|
|
3462
|
+
|
|
3463
|
+
rename_pairs: list[tuple[str, str]] | None = None
|
|
3464
|
+
if rename:
|
|
3465
|
+
rename_pairs = []
|
|
3466
|
+
for spec in rename:
|
|
3467
|
+
if "=>" not in spec:
|
|
3468
|
+
raise typer.BadParameter("--rename must be 'PATTERN=>REPL'", param_hint="--rename")
|
|
3469
|
+
pat, _, repl = spec.partition("=>")
|
|
3470
|
+
rename_pairs.append((pat, repl))
|
|
3471
|
+
written = vendor_urdf(
|
|
3472
|
+
robot_id, upstream=upstream, out_dir=out, rename=rename_pairs, raw_text=raw_text
|
|
3473
|
+
)
|
|
3474
|
+
typer.echo(f"Wrote {written}")
|
|
3475
|
+
|
|
3476
|
+
|
|
3477
|
+
# `openral prompt "do X"` publishes a one-shot PromptStamped
|
|
3478
|
+
# onto /openral/prompt_in/cli; the prompt_router_node fans it out to
|
|
3479
|
+
# /openral/prompt for the reasoner. rclpy import is deferred inside
|
|
3480
|
+
# the command body so `openral --help` stays sub-second.
|
|
3481
|
+
app.command(
|
|
3482
|
+
name="prompt",
|
|
3483
|
+
help=(
|
|
3484
|
+
"Publish a one-shot operator prompt to the prompt-router. Requires a sourced ROS 2 install."
|
|
3485
|
+
),
|
|
3486
|
+
)(prompt_command)
|
|
3487
|
+
|
|
3488
|
+
|
|
3489
|
+
# ── openral dashboard — live debugging UI over the OTel stream (issue #44) ──────
|
|
3490
|
+
|
|
3491
|
+
|
|
3492
|
+
@app.command(
|
|
3493
|
+
"dashboard",
|
|
3494
|
+
help=(
|
|
3495
|
+
"Serve a live debugging dashboard (read-only) at the given port. "
|
|
3496
|
+
"The same port also acts as an OTLP/HTTP receiver, so any "
|
|
3497
|
+
"`openral sim run` / `openral deploy run` pointed at "
|
|
3498
|
+
"OTEL_EXPORTER_OTLP_ENDPOINT=http://<host>:<port> + "
|
|
3499
|
+
"OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf streams in live. "
|
|
3500
|
+
"Works without Jaeger/Tempo running."
|
|
3501
|
+
),
|
|
3502
|
+
)
|
|
3503
|
+
def dashboard(
|
|
3504
|
+
host: str = typer.Option(
|
|
3505
|
+
"127.0.0.1",
|
|
3506
|
+
"--host",
|
|
3507
|
+
help="Bind address. Loopback by default; no auth.",
|
|
3508
|
+
),
|
|
3509
|
+
port: int = typer.Option(
|
|
3510
|
+
4318,
|
|
3511
|
+
"--port",
|
|
3512
|
+
help=(
|
|
3513
|
+
"HTTP port; serves UI, /api/state, /api/stream, and OTLP/HTTP "
|
|
3514
|
+
"receiver. Defaults to 4318 (the OTLP/HTTP standard port) "
|
|
3515
|
+
"rather than 8000 (issue #132) — `mkdocs serve` and most "
|
|
3516
|
+
"FastAPI demos already squat on 8000."
|
|
3517
|
+
),
|
|
3518
|
+
),
|
|
3519
|
+
log_level: str = typer.Option(
|
|
3520
|
+
"warning",
|
|
3521
|
+
"--log-level",
|
|
3522
|
+
help="uvicorn log level (debug | info | warning | error).",
|
|
3523
|
+
),
|
|
3524
|
+
inprocess: str | None = typer.Option(
|
|
3525
|
+
None,
|
|
3526
|
+
"--inprocess",
|
|
3527
|
+
help=(
|
|
3528
|
+
"Optional shell-quoted command to spawn as a child process with "
|
|
3529
|
+
"OTEL_EXPORTER_OTLP_ENDPOINT pointed at this dashboard. Pass the "
|
|
3530
|
+
"whole command as one string (shlex-tokenised), e.g. "
|
|
3531
|
+
"`--inprocess 'openral sim run --config scenes/benchmark/pusht.yaml"
|
|
3532
|
+
" --rskill diffusion-pusht'`."
|
|
3533
|
+
),
|
|
3534
|
+
),
|
|
3535
|
+
) -> None:
|
|
3536
|
+
"""Serve the OpenRAL live dashboard."""
|
|
3537
|
+
import shlex
|
|
3538
|
+
|
|
3539
|
+
from openral_observability.dashboard import run_dashboard
|
|
3540
|
+
|
|
3541
|
+
inprocess_cmd = shlex.split(inprocess) if inprocess else None
|
|
3542
|
+
run_dashboard(
|
|
3543
|
+
host=host,
|
|
3544
|
+
port=port,
|
|
3545
|
+
inprocess_cmd=inprocess_cmd,
|
|
3546
|
+
log_level=log_level,
|
|
3547
|
+
)
|
|
3548
|
+
|
|
3549
|
+
|
|
3550
|
+
# ── openral deploy {run, list} ───────────────────────────────────────────────────
|
|
3551
|
+
|
|
3552
|
+
deploy_app = typer.Typer(
|
|
3553
|
+
name="deploy",
|
|
3554
|
+
help=(
|
|
3555
|
+
"Hardware deploy — run the ROS graph against a real robot from a "
|
|
3556
|
+
"DeployScene YAML (`openral deploy run`) or list deploy scenes."
|
|
3557
|
+
),
|
|
3558
|
+
no_args_is_help=True,
|
|
3559
|
+
)
|
|
3560
|
+
app.add_typer(deploy_app, name="deploy")
|
|
3561
|
+
|
|
3562
|
+
deploy_app.command(
|
|
3563
|
+
"sim",
|
|
3564
|
+
help=(
|
|
3565
|
+
"Boot the full ROS graph (dashboard + safety_kernel + reasoner + "
|
|
3566
|
+
"prompt_router + runtime + HAL) against a digital-twin HAL, driven "
|
|
3567
|
+
"by a DeployScene YAML + rSkill. Sibling of ``deploy run``."
|
|
3568
|
+
),
|
|
3569
|
+
)(deploy_sim_command)
|
|
3570
|
+
|
|
3571
|
+
|
|
3572
|
+
@deploy_app.command("list")
|
|
3573
|
+
def deploy_list() -> None:
|
|
3574
|
+
"""List every deploy scene under `scenes/deploy/*.yaml`.
|
|
3575
|
+
|
|
3576
|
+
Each entry is a paste-able `--config` path for `openral deploy run` or `deploy sim`.
|
|
3577
|
+
No hardware touch, no GPU.
|
|
3578
|
+
"""
|
|
3579
|
+
from openral_rskill.loader import _find_repo_root_from
|
|
3580
|
+
|
|
3581
|
+
repo_root = _find_repo_root_from(Path(__file__))
|
|
3582
|
+
if repo_root is None:
|
|
3583
|
+
console.print("[red]Could not locate repo root.[/red]")
|
|
3584
|
+
raise typer.Exit(code=1)
|
|
3585
|
+
deploy_scenes = repo_root / "scenes" / "deploy"
|
|
3586
|
+
if not deploy_scenes.is_dir():
|
|
3587
|
+
print("<none>")
|
|
3588
|
+
return
|
|
3589
|
+
configs = sorted(deploy_scenes.rglob("*.yaml"))
|
|
3590
|
+
if not configs:
|
|
3591
|
+
print("<none>")
|
|
3592
|
+
return
|
|
3593
|
+
for cfg in configs:
|
|
3594
|
+
print(cfg.relative_to(repo_root))
|
|
3595
|
+
|
|
3596
|
+
|
|
3597
|
+
@deploy_app.command("run")
|
|
3598
|
+
def deploy_run(
|
|
3599
|
+
config: Path = typer.Option( # reason: typer Option idiom
|
|
3600
|
+
...,
|
|
3601
|
+
"--config",
|
|
3602
|
+
"-c",
|
|
3603
|
+
exists=True,
|
|
3604
|
+
readable=True,
|
|
3605
|
+
dir_okay=False,
|
|
3606
|
+
help="Path to a DeployScene YAML; its robot_id selects the real robot workcell.",
|
|
3607
|
+
),
|
|
3608
|
+
robot: str | None = typer.Option(
|
|
3609
|
+
None,
|
|
3610
|
+
"--robot",
|
|
3611
|
+
help="Override the robot_id resolved from --config.",
|
|
3612
|
+
),
|
|
3613
|
+
hal: list[str] | None = typer.Option(
|
|
3614
|
+
None,
|
|
3615
|
+
"--hal",
|
|
3616
|
+
help="Override HAL node params, key=value (repeatable), e.g. --hal port=/dev/ttyUSB1.",
|
|
3617
|
+
),
|
|
3618
|
+
dashboard: bool = typer.Option(
|
|
3619
|
+
True,
|
|
3620
|
+
"--dashboard/--no-dashboard",
|
|
3621
|
+
help="Spawn the live dashboard (default on).",
|
|
3622
|
+
),
|
|
3623
|
+
dashboard_port: int = typer.Option(
|
|
3624
|
+
4318,
|
|
3625
|
+
"--dashboard-port",
|
|
3626
|
+
help="Dashboard OTLP port.",
|
|
3627
|
+
),
|
|
3628
|
+
enable_reward_monitor: bool | None = typer.Option(
|
|
3629
|
+
None,
|
|
3630
|
+
"--enable-reward-monitor/--no-enable-reward-monitor",
|
|
3631
|
+
help=(
|
|
3632
|
+
"Bring up the Robometer reward monitor parallel to the "
|
|
3633
|
+
"VLA (same leg `deploy sim` exposes): it scores the robot's first RGB "
|
|
3634
|
+
"camera topic and serves /openral/perception/query_task_progress. The "
|
|
3635
|
+
"manifest is auto-paired from the VLA palette's reward_rskill_name; "
|
|
3636
|
+
"override with --reward-monitor-manifest. Unset = the "
|
|
3637
|
+
"scene's runtime.enable_reward_monitor, else off."
|
|
3638
|
+
),
|
|
3639
|
+
),
|
|
3640
|
+
reward_monitor_manifest: str | None = typer.Option(
|
|
3641
|
+
None,
|
|
3642
|
+
"--reward-monitor-manifest",
|
|
3643
|
+
help=(
|
|
3644
|
+
"Path to a kind:reward rSkill manifest. Empty auto-pairs "
|
|
3645
|
+
"from the VLA palette, falling back to rskills/robometer-4b. Ignored "
|
|
3646
|
+
"unless --enable-reward-monitor."
|
|
3647
|
+
),
|
|
3648
|
+
),
|
|
3649
|
+
dry_run: bool = typer.Option(
|
|
3650
|
+
False,
|
|
3651
|
+
"--dry-run",
|
|
3652
|
+
help="Print the resolved real-mode launch argv and exit without shelling out.",
|
|
3653
|
+
),
|
|
3654
|
+
) -> None:
|
|
3655
|
+
"""Run an rSkill on REAL hardware via the production ROS graph.
|
|
3656
|
+
|
|
3657
|
+
Unlike `openral deploy sim`, this drives the **real** hardware HAL: it
|
|
3658
|
+
resolves the robot from `--config` (a DeployScene) and shells the SAME
|
|
3659
|
+
`sim_e2e.launch.py` graph with `hal_mode:=real` — the HAL lifecycle node +
|
|
3660
|
+
C++ safety kernel + reasoner + world state (+ SLAM/Nav2 when the robot
|
|
3661
|
+
declares a lidar). The HAL's `connect()` fails loudly if no hardware is
|
|
3662
|
+
attached; a simulation-only robot raises ROSCapabilityMismatch (use
|
|
3663
|
+
`openral deploy sim`). Robot HAL defaults come from robot.yaml; `--hal` wins.
|
|
3664
|
+
"""
|
|
3665
|
+
from openral_core import DeployScene # reason: defer schema import
|
|
3666
|
+
from openral_core.exceptions import ROSCapabilityMismatch # reason: defer
|
|
3667
|
+
from pydantic import ValidationError # reason: defer CLI import
|
|
3668
|
+
|
|
3669
|
+
from openral_cli.deploy_sim import ( # reason: defer heavy CLI import
|
|
3670
|
+
_parse_hal_overrides,
|
|
3671
|
+
resolve_launch_invocation,
|
|
3672
|
+
run_launch_invocation,
|
|
3673
|
+
)
|
|
3674
|
+
|
|
3675
|
+
try:
|
|
3676
|
+
deploy_scene = DeployScene.from_yaml(str(config))
|
|
3677
|
+
except (FileNotFoundError, ROSConfigError, ValidationError) as exc:
|
|
3678
|
+
console.print(f"[red]config error:[/red] {exc}")
|
|
3679
|
+
raise typer.Exit(code=1) from exc
|
|
3680
|
+
|
|
3681
|
+
overrides = _parse_hal_overrides(hal)
|
|
3682
|
+
|
|
3683
|
+
# A committed, config-relative `calibration_dir` (deploy owns its calibration
|
|
3684
|
+
# instead of the ambient HF cache) is resolved against THIS config's
|
|
3685
|
+
# directory so it works regardless of the CWD `deploy run` is invoked from.
|
|
3686
|
+
cal_dir = overrides.get("calibration_dir")
|
|
3687
|
+
if isinstance(cal_dir, str) and cal_dir and not Path(cal_dir).is_absolute():
|
|
3688
|
+
overrides["calibration_dir"] = str((config.parent / cal_dir).resolve())
|
|
3689
|
+
|
|
3690
|
+
try:
|
|
3691
|
+
invocation = resolve_launch_invocation(
|
|
3692
|
+
config=config,
|
|
3693
|
+
robot_override=robot or deploy_scene.robot_id,
|
|
3694
|
+
dashboard_port=dashboard_port,
|
|
3695
|
+
reset_to_pose_service=None,
|
|
3696
|
+
deploy_config=config,
|
|
3697
|
+
hal_param_overrides=overrides,
|
|
3698
|
+
hal_mode="real",
|
|
3699
|
+
enable_dashboard=dashboard,
|
|
3700
|
+
enable_reward_monitor=enable_reward_monitor,
|
|
3701
|
+
reward_monitor_manifest=reward_monitor_manifest,
|
|
3702
|
+
)
|
|
3703
|
+
except (ROSConfigError, ROSCapabilityMismatch) as exc:
|
|
3704
|
+
console.print(f"[red]deploy run:[/red] {exc}")
|
|
3705
|
+
raise typer.Exit(code=1) from exc
|
|
3706
|
+
|
|
3707
|
+
console.print(
|
|
3708
|
+
f"[cyan]deploy run[/cyan] {invocation.robot_id} → real HAL "
|
|
3709
|
+
f"(hal_mode=real); the HAL's connect() requires the robot to be attached."
|
|
3710
|
+
)
|
|
3711
|
+
if dry_run:
|
|
3712
|
+
printed = [
|
|
3713
|
+
arg.replace("HAL_PARAMS_FILE_PLACEHOLDER", "<hal-params-tmp>")
|
|
3714
|
+
for arg in invocation.argv_template
|
|
3715
|
+
]
|
|
3716
|
+
console.print(f" hal_params: {invocation.hal_params}")
|
|
3717
|
+
console.print(f" argv: {shlex.join(printed)}")
|
|
3718
|
+
return
|
|
3719
|
+
|
|
3720
|
+
returncode = run_launch_invocation(invocation)
|
|
3721
|
+
raise typer.Exit(code=returncode)
|
|
3722
|
+
|
|
3723
|
+
|
|
3724
|
+
@deploy_app.command("validate")
|
|
3725
|
+
def deploy_validate(
|
|
3726
|
+
config: Path = typer.Option( # reason: typer Option idiom
|
|
3727
|
+
...,
|
|
3728
|
+
"--config",
|
|
3729
|
+
"-c",
|
|
3730
|
+
exists=True,
|
|
3731
|
+
readable=True,
|
|
3732
|
+
dir_okay=False,
|
|
3733
|
+
help="DeployScene YAML to check for real-run readiness.",
|
|
3734
|
+
),
|
|
3735
|
+
robot: str | None = typer.Option(
|
|
3736
|
+
None,
|
|
3737
|
+
"--robot",
|
|
3738
|
+
help="Override the robot_id resolved from --config.",
|
|
3739
|
+
),
|
|
3740
|
+
hal: list[str] | None = typer.Option(
|
|
3741
|
+
None,
|
|
3742
|
+
"--hal",
|
|
3743
|
+
help="HAL overrides applied before validation (same precedence as deploy run).",
|
|
3744
|
+
),
|
|
3745
|
+
) -> None:
|
|
3746
|
+
"""Pre-run readiness check for `openral deploy run` — no hardware, no ROS launch.
|
|
3747
|
+
|
|
3748
|
+
Validates the DeployScene + robot manifest resolve, then checks the
|
|
3749
|
+
runtime-required inputs a real run needs are present *before* the launch —
|
|
3750
|
+
the exact gaps that otherwise fail late at HAL configure / sensor leg:
|
|
3751
|
+
|
|
3752
|
+
* **HAL transport** — a serial `port` is declared, and its device exists now.
|
|
3753
|
+
* **Calibration** — a serial HAL with `calibrate_on_connect=false` has an
|
|
3754
|
+
`id` + `calibration_dir`, and the `<calibration_dir>/<id>.json` file exists
|
|
3755
|
+
(missing → "has no calibration registered" at every send_action).
|
|
3756
|
+
* **Camera bindings** — each scene sensor has a `deploy_binding` (else it is
|
|
3757
|
+
never published and a camera VLA gets an empty observation), and any
|
|
3758
|
+
`/dev/*` device path exists now.
|
|
3759
|
+
|
|
3760
|
+
Reports ERROR (missing committed data — exits non-zero) vs WARN (device just
|
|
3761
|
+
not attached right now). HAL param precedence matches `deploy run`
|
|
3762
|
+
(`--hal` > scene `hal` > `robot.yaml`).
|
|
3763
|
+
"""
|
|
3764
|
+
from openral_core import DeployScene # reason: defer schema import
|
|
3765
|
+
from openral_core.exceptions import ROSCapabilityMismatch # reason: defer
|
|
3766
|
+
from pydantic import ValidationError # reason: defer CLI import
|
|
3767
|
+
|
|
3768
|
+
from openral_cli.deploy_sim import ( # reason: defer heavy CLI import
|
|
3769
|
+
_parse_hal_overrides,
|
|
3770
|
+
resolve_launch_invocation,
|
|
3771
|
+
)
|
|
3772
|
+
|
|
3773
|
+
try:
|
|
3774
|
+
deploy_scene = DeployScene.from_yaml(str(config))
|
|
3775
|
+
except (FileNotFoundError, ROSConfigError, ValidationError) as exc:
|
|
3776
|
+
console.print(f"[red]✗ config:[/red] {exc}")
|
|
3777
|
+
raise typer.Exit(code=1) from exc
|
|
3778
|
+
|
|
3779
|
+
overrides = _parse_hal_overrides(hal)
|
|
3780
|
+
cal_dir_override = overrides.get("calibration_dir")
|
|
3781
|
+
if (
|
|
3782
|
+
isinstance(cal_dir_override, str)
|
|
3783
|
+
and cal_dir_override
|
|
3784
|
+
and not Path(cal_dir_override).is_absolute()
|
|
3785
|
+
):
|
|
3786
|
+
overrides["calibration_dir"] = str((config.parent / cal_dir_override).resolve())
|
|
3787
|
+
|
|
3788
|
+
# Reuse the deploy-run resolver: raises on sim-only robot, name mismatch,
|
|
3789
|
+
# unknown HAL, missing manifest — and produces the merged hal_params
|
|
3790
|
+
# (registry → scene hal → --hal) we then inspect for readiness.
|
|
3791
|
+
try:
|
|
3792
|
+
invocation = resolve_launch_invocation(
|
|
3793
|
+
config=config,
|
|
3794
|
+
robot_override=robot or deploy_scene.robot_id,
|
|
3795
|
+
dashboard_port=4318,
|
|
3796
|
+
reset_to_pose_service=None,
|
|
3797
|
+
deploy_config=config,
|
|
3798
|
+
hal_param_overrides=overrides,
|
|
3799
|
+
hal_mode="real",
|
|
3800
|
+
enable_dashboard=False,
|
|
3801
|
+
)
|
|
3802
|
+
except (ROSConfigError, ROSCapabilityMismatch) as exc:
|
|
3803
|
+
console.print(f"[red]✗ resolve:[/red] {exc}")
|
|
3804
|
+
raise typer.Exit(code=1) from exc
|
|
3805
|
+
|
|
3806
|
+
errors: list[str] = []
|
|
3807
|
+
warns: list[str] = []
|
|
3808
|
+
hp = invocation.hal_params
|
|
3809
|
+
|
|
3810
|
+
port = hp.get("port")
|
|
3811
|
+
if isinstance(port, str) and port and not Path(port).exists():
|
|
3812
|
+
warns.append(f"serial port {port!r} does not exist now (arm not attached?)")
|
|
3813
|
+
|
|
3814
|
+
if isinstance(port, str) and port and not bool(hp.get("calibrate_on_connect", False)):
|
|
3815
|
+
cal_id = hp.get("id")
|
|
3816
|
+
cal_dir = hp.get("calibration_dir")
|
|
3817
|
+
if not cal_id or not cal_dir:
|
|
3818
|
+
errors.append(
|
|
3819
|
+
"serial HAL with calibrate_on_connect=false but no id/calibration_dir "
|
|
3820
|
+
"→ every send_action/read_state raises 'has no calibration registered'. "
|
|
3821
|
+
"Add a scene `hal:` binding with id + calibration_dir."
|
|
3822
|
+
)
|
|
3823
|
+
else:
|
|
3824
|
+
cal_file = Path(str(cal_dir)) / f"{cal_id}.json"
|
|
3825
|
+
if not cal_file.exists():
|
|
3826
|
+
errors.append(f"calibration file {cal_file} does not exist (id={cal_id!r}).")
|
|
3827
|
+
|
|
3828
|
+
if not deploy_scene.sensors:
|
|
3829
|
+
warns.append("scene declares no sensors → a camera VLA will get an empty observation")
|
|
3830
|
+
for sensor in deploy_scene.sensors:
|
|
3831
|
+
binding = sensor.deploy_binding
|
|
3832
|
+
if binding is None:
|
|
3833
|
+
warns.append(
|
|
3834
|
+
f"sensor {sensor.name!r} has no deploy_binding → not published, VLA won't see it"
|
|
3835
|
+
)
|
|
3836
|
+
continue
|
|
3837
|
+
dev = binding.backend_params.get("device")
|
|
3838
|
+
if isinstance(dev, str) and dev.startswith("/dev/") and not Path(dev).exists():
|
|
3839
|
+
warns.append(f"sensor {sensor.name!r} device {dev!r} does not exist now")
|
|
3840
|
+
|
|
3841
|
+
console.print(f"[cyan]deploy validate[/cyan] {invocation.robot_id} ← {config}")
|
|
3842
|
+
for warn in warns:
|
|
3843
|
+
console.print(f" [yellow]⚠ {warn}[/yellow]")
|
|
3844
|
+
for err in errors:
|
|
3845
|
+
console.print(f" [red]✗ {err}[/red]")
|
|
3846
|
+
if errors:
|
|
3847
|
+
console.print(
|
|
3848
|
+
f"[red]{len(errors)} error(s), {len(warns)} warning(s) — "
|
|
3849
|
+
"not ready for `deploy run`.[/red]"
|
|
3850
|
+
)
|
|
3851
|
+
raise typer.Exit(code=1)
|
|
3852
|
+
console.print(f"[green]✓ ready for `deploy run` ({len(warns)} warning(s)).[/green]")
|
|
3853
|
+
|
|
3854
|
+
|
|
3855
|
+
# ── openral replay — bag↔OTel correlator ──────────────────────────
|
|
3856
|
+
|
|
3857
|
+
|
|
3858
|
+
def _resolve_frame_trace_id(frame_spec: str, dataset_root: Path) -> str:
|
|
3859
|
+
"""Resolve a ``<repo_id>/<episode>/<frame>`` spec to its stored trace_id.
|
|
3860
|
+
|
|
3861
|
+
``repo_id`` itself contains a slash (``org/name``); the episode and
|
|
3862
|
+
frame indices are the last two ``/``-separated fields, so we split
|
|
3863
|
+
from the right. Exits non-zero with a typed message on a malformed
|
|
3864
|
+
spec, a missing frame, or a frame that carries no trace.
|
|
3865
|
+
"""
|
|
3866
|
+
from openral_dataset import read_frame_trace
|
|
3867
|
+
|
|
3868
|
+
try:
|
|
3869
|
+
_repo_id, ep_str, frame_str = frame_spec.rsplit("/", 2)
|
|
3870
|
+
episode_idx = int(ep_str)
|
|
3871
|
+
frame_idx = int(frame_str)
|
|
3872
|
+
except ValueError:
|
|
3873
|
+
console.print(
|
|
3874
|
+
f"[red]openral replay:[/red] malformed --frame {frame_spec!r}; "
|
|
3875
|
+
"expected '<repo_id>/<episode>/<frame>' (e.g. 'openral/dataset-pick/0/12')"
|
|
3876
|
+
)
|
|
3877
|
+
raise typer.Exit(code=2) from None
|
|
3878
|
+
|
|
3879
|
+
try:
|
|
3880
|
+
trace_id, _span_id = read_frame_trace(
|
|
3881
|
+
root=dataset_root, episode_idx=episode_idx, frame_idx=frame_idx
|
|
3882
|
+
)
|
|
3883
|
+
except ROSConfigError as exc:
|
|
3884
|
+
console.print(f"[red]openral replay:[/red] {exc}")
|
|
3885
|
+
raise typer.Exit(code=2) from exc
|
|
3886
|
+
|
|
3887
|
+
if not trace_id:
|
|
3888
|
+
console.print(
|
|
3889
|
+
f"[red]openral replay:[/red] frame {frame_spec} carries no trace_id "
|
|
3890
|
+
"(its producing tick had no active OTel span); nothing to pivot to"
|
|
3891
|
+
)
|
|
3892
|
+
raise typer.Exit(code=2)
|
|
3893
|
+
return trace_id
|
|
3894
|
+
|
|
3895
|
+
|
|
3896
|
+
@app.command(
|
|
3897
|
+
"replay",
|
|
3898
|
+
help=(
|
|
3899
|
+
"Join a rosbag2/.mcap file with OTel spans from the live dashboard. "
|
|
3900
|
+
"Prints a chronological JSON timeline keyed by trace_id; "
|
|
3901
|
+
"writes to `--out` when given. `--dashboard` may be omitted for a "
|
|
3902
|
+
"bag-only timeline."
|
|
3903
|
+
),
|
|
3904
|
+
)
|
|
3905
|
+
def replay(
|
|
3906
|
+
bag: Path = typer.Argument( # reason: typer Argument idiom
|
|
3907
|
+
...,
|
|
3908
|
+
exists=True,
|
|
3909
|
+
readable=True,
|
|
3910
|
+
help="Path to a rosbag2 directory or a bare .mcap file.",
|
|
3911
|
+
),
|
|
3912
|
+
trace: str | None = typer.Option(
|
|
3913
|
+
None,
|
|
3914
|
+
"--trace",
|
|
3915
|
+
help="32-hex-char trace_id to filter on. Defaults to the busiest one in the bag.",
|
|
3916
|
+
),
|
|
3917
|
+
frame: str | None = typer.Option(
|
|
3918
|
+
None,
|
|
3919
|
+
"--frame",
|
|
3920
|
+
help=(
|
|
3921
|
+
"Pivot from a written LeRobotDataset frame: '<repo_id>/<episode>/<frame>' "
|
|
3922
|
+
"(e.g. 'openral/dataset-pick/0/12'). Reads that frame's trace_id and uses "
|
|
3923
|
+
"it as the join key. Requires --dataset-root; mutually exclusive with --trace."
|
|
3924
|
+
),
|
|
3925
|
+
),
|
|
3926
|
+
dataset_root: Path | None = typer.Option(
|
|
3927
|
+
None,
|
|
3928
|
+
"--dataset-root",
|
|
3929
|
+
help="Root directory of the LeRobotDataset that --frame refers to.",
|
|
3930
|
+
),
|
|
3931
|
+
dashboard: str | None = typer.Option(
|
|
3932
|
+
None,
|
|
3933
|
+
"--dashboard",
|
|
3934
|
+
help="Dashboard base URL (e.g. http://127.0.0.1:8000). Omit for bag-only.",
|
|
3935
|
+
),
|
|
3936
|
+
out: Path | None = typer.Option(
|
|
3937
|
+
None,
|
|
3938
|
+
"--out",
|
|
3939
|
+
"-o",
|
|
3940
|
+
help="Write the timeline JSON to this file; print to stdout when omitted.",
|
|
3941
|
+
),
|
|
3942
|
+
) -> None:
|
|
3943
|
+
"""Read a bag, join it with spans by trace_id, emit a JSON timeline."""
|
|
3944
|
+
from openral_observability.replay.cli import run_replay, write_timeline
|
|
3945
|
+
|
|
3946
|
+
# ISSUE-109 pivot — resolve --frame into a concrete trace_id off the
|
|
3947
|
+
# dataset before the join. Done here (not in run_replay) so the
|
|
3948
|
+
# openral_observability replay module stays free of the lerobot dep.
|
|
3949
|
+
if frame is not None:
|
|
3950
|
+
if trace is not None:
|
|
3951
|
+
console.print("[red]openral replay:[/red] --frame and --trace are mutually exclusive")
|
|
3952
|
+
raise typer.Exit(code=2)
|
|
3953
|
+
if dataset_root is None:
|
|
3954
|
+
console.print("[red]openral replay:[/red] --frame requires --dataset-root")
|
|
3955
|
+
raise typer.Exit(code=2)
|
|
3956
|
+
trace = _resolve_frame_trace_id(frame, dataset_root)
|
|
3957
|
+
|
|
3958
|
+
result = run_replay(bag_path=bag, trace_id=trace, dashboard_url=dashboard)
|
|
3959
|
+
if out is not None:
|
|
3960
|
+
write_timeline(result, out)
|
|
3961
|
+
console.print(
|
|
3962
|
+
f"[green]openral replay:[/green] wrote {len(result.timeline)} entries to {out}"
|
|
3963
|
+
)
|
|
3964
|
+
if result.trace_id:
|
|
3965
|
+
console.print(f"trace_id: {result.trace_id}")
|
|
3966
|
+
return
|
|
3967
|
+
print(_json.dumps(result.to_json(), indent=2, sort_keys=False))
|
|
3968
|
+
|
|
3969
|
+
|
|
3970
|
+
# ── openral record — wrap `ros2 bag record` with profile presets ──
|
|
3971
|
+
|
|
3972
|
+
|
|
3973
|
+
@app.command(
|
|
3974
|
+
"record",
|
|
3975
|
+
help=(
|
|
3976
|
+
"Spawn `ros2 bag record` for the OpenRAL ROS graph with a slim/full profile. "
|
|
3977
|
+
"Requires a sourced ROS 2 install. Use `--dry-run` to print the argv "
|
|
3978
|
+
"instead of executing."
|
|
3979
|
+
),
|
|
3980
|
+
)
|
|
3981
|
+
def record(
|
|
3982
|
+
out: Path = typer.Option( # reason: typer Option idiom
|
|
3983
|
+
...,
|
|
3984
|
+
"--out",
|
|
3985
|
+
"-o",
|
|
3986
|
+
help="Output directory passed to `ros2 bag record -o`.",
|
|
3987
|
+
),
|
|
3988
|
+
profile: str = typer.Option(
|
|
3989
|
+
"slim",
|
|
3990
|
+
"--profile",
|
|
3991
|
+
help="Recording profile: 'slim' (default) or 'full'.",
|
|
3992
|
+
),
|
|
3993
|
+
storage: str = typer.Option(
|
|
3994
|
+
"mcap",
|
|
3995
|
+
"--storage",
|
|
3996
|
+
help="rosbag2 storage backend; mcap is the openral default.",
|
|
3997
|
+
),
|
|
3998
|
+
extra_topic: list[str] = typer.Option(
|
|
3999
|
+
[],
|
|
4000
|
+
"--extra-topic",
|
|
4001
|
+
help="Additional topic to record verbatim. Repeatable.",
|
|
4002
|
+
),
|
|
4003
|
+
extra_regex: list[str] = typer.Option(
|
|
4004
|
+
[],
|
|
4005
|
+
"--extra-regex",
|
|
4006
|
+
help="Additional regex to OR into --regex. Repeatable.",
|
|
4007
|
+
),
|
|
4008
|
+
dry_run: bool = typer.Option(
|
|
4009
|
+
False,
|
|
4010
|
+
"--dry-run",
|
|
4011
|
+
help="Print the composed argv instead of executing.",
|
|
4012
|
+
),
|
|
4013
|
+
) -> None:
|
|
4014
|
+
"""Wrap `ros2 bag record` with slim/full topic presets."""
|
|
4015
|
+
from openral_observability.replay.cli import run_record
|
|
4016
|
+
|
|
4017
|
+
if profile not in {"slim", "full"}:
|
|
4018
|
+
console.print(f"[red]openral record:[/red] unknown profile {profile!r}; expected slim|full")
|
|
4019
|
+
raise typer.Exit(code=2)
|
|
4020
|
+
try:
|
|
4021
|
+
argv, completed = run_record(
|
|
4022
|
+
profile=profile, # type: ignore[arg-type] # reason: validated above against the literal set
|
|
4023
|
+
output_dir=out,
|
|
4024
|
+
storage=storage,
|
|
4025
|
+
extra_topics=extra_topic,
|
|
4026
|
+
extra_regex=extra_regex,
|
|
4027
|
+
dry_run=dry_run,
|
|
4028
|
+
)
|
|
4029
|
+
except FileNotFoundError as exc:
|
|
4030
|
+
console.print(f"[red]openral record:[/red] {exc}")
|
|
4031
|
+
raise typer.Exit(code=1) from exc
|
|
4032
|
+
if dry_run:
|
|
4033
|
+
print(" ".join(argv))
|
|
4034
|
+
return
|
|
4035
|
+
assert completed is not None
|
|
4036
|
+
if completed.returncode != 0:
|
|
4037
|
+
raise typer.Exit(code=completed.returncode)
|
|
4038
|
+
|
|
4039
|
+
|
|
4040
|
+
# ── openral profile session — LTTng opt-in profiling ──────────────
|
|
4041
|
+
|
|
4042
|
+
profile_app = typer.Typer(
|
|
4043
|
+
name="profile",
|
|
4044
|
+
help="Microsecond-accurate profiling via ros2_tracing / LTTng.",
|
|
4045
|
+
no_args_is_help=True,
|
|
4046
|
+
)
|
|
4047
|
+
app.add_typer(profile_app, name="profile")
|
|
4048
|
+
|
|
4049
|
+
|
|
4050
|
+
@profile_app.command(
|
|
4051
|
+
"session",
|
|
4052
|
+
help=(
|
|
4053
|
+
"Start, stop, or view an LTTng session for the realtime hot path. "
|
|
4054
|
+
"Requires lttng-tools on PATH. Set OPENRAL_ROS2_TRACING=1 on the "
|
|
4055
|
+
"agent process to emit tracepoints; the env var is the runtime gate."
|
|
4056
|
+
),
|
|
4057
|
+
)
|
|
4058
|
+
def profile_session(
|
|
4059
|
+
action: str = typer.Argument( # reason: typer Argument idiom
|
|
4060
|
+
...,
|
|
4061
|
+
help="One of: start | stop | view.",
|
|
4062
|
+
),
|
|
4063
|
+
output: Path = typer.Option( # reason: typer Option idiom
|
|
4064
|
+
Path("./lttng-traces"),
|
|
4065
|
+
"--output",
|
|
4066
|
+
"-o",
|
|
4067
|
+
help="LTTng output directory. Used by start (write here) and view (read from here).",
|
|
4068
|
+
),
|
|
4069
|
+
name: str = typer.Option(
|
|
4070
|
+
"openral",
|
|
4071
|
+
"--name",
|
|
4072
|
+
"-n",
|
|
4073
|
+
help="LTTng session name.",
|
|
4074
|
+
),
|
|
4075
|
+
) -> None:
|
|
4076
|
+
"""Drive an LTTng session — start, stop, view."""
|
|
4077
|
+
from openral_observability.tracing_lttng import (
|
|
4078
|
+
LttngSessionError,
|
|
4079
|
+
start_session,
|
|
4080
|
+
stop_session,
|
|
4081
|
+
view_session,
|
|
4082
|
+
)
|
|
4083
|
+
|
|
4084
|
+
try:
|
|
4085
|
+
if action == "start":
|
|
4086
|
+
session = start_session(name=name, output_dir=output)
|
|
4087
|
+
console.print(
|
|
4088
|
+
f"[green]openral profile session start:[/green] "
|
|
4089
|
+
f"{session.name} → {session.output_dir}"
|
|
4090
|
+
)
|
|
4091
|
+
console.print(
|
|
4092
|
+
"Run your workload with OPENRAL_ROS2_TRACING=1, then "
|
|
4093
|
+
"`openral profile session stop` to flush."
|
|
4094
|
+
)
|
|
4095
|
+
elif action == "stop":
|
|
4096
|
+
stop_session(name=name)
|
|
4097
|
+
console.print(f"[green]openral profile session stop:[/green] {name}")
|
|
4098
|
+
elif action == "view":
|
|
4099
|
+
view_session(output_dir=output)
|
|
4100
|
+
else:
|
|
4101
|
+
console.print(
|
|
4102
|
+
f"[red]openral profile session:[/red] unknown action {action!r}; "
|
|
4103
|
+
"expected start | stop | view"
|
|
4104
|
+
)
|
|
4105
|
+
raise typer.Exit(code=2)
|
|
4106
|
+
except LttngSessionError as exc:
|
|
4107
|
+
console.print(f"[red]openral profile session:[/red] {exc}")
|
|
4108
|
+
raise typer.Exit(code=1) from exc
|