openral-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- openral_cli/__init__.py +6 -0
- openral_cli/_hf_publish.py +125 -0
- openral_cli/_rskill_doc_validator.py +562 -0
- openral_cli/_rskill_intel.py +334 -0
- openral_cli/_rskill_readme.py +217 -0
- openral_cli/_rskill_scaffolder.py +267 -0
- openral_cli/autodetect.py +464 -0
- openral_cli/check.py +397 -0
- openral_cli/collision.py +369 -0
- openral_cli/dataset.py +438 -0
- openral_cli/deploy_sim.py +2664 -0
- openral_cli/install.py +422 -0
- openral_cli/main.py +4108 -0
- openral_cli/prompt.py +128 -0
- openral_cli/py.typed +0 -0
- openral_cli/robot.py +301 -0
- openral_cli-0.1.0.dist-info/METADATA +33 -0
- openral_cli-0.1.0.dist-info/RECORD +20 -0
- openral_cli-0.1.0.dist-info/WHEEL +4 -0
- openral_cli-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,2664 @@
|
|
|
1
|
+
"""``openral deploy sim`` — boot the full ROS graph against a digital-twin HAL.
|
|
2
|
+
|
|
3
|
+
Sibling of ``openral deploy run``: where ``deploy run`` drives a tight Python
|
|
4
|
+
tick loop against a HAL + ``SafetyClient``, ``deploy sim`` shells
|
|
5
|
+
``ros2 launch openral_rskill_ros sim_e2e.launch.py`` so the operator gets
|
|
6
|
+
dashboard + C++ safety kernel + reasoner + prompt router + runtime
|
|
7
|
+
(world_state + skill_runner) + HAL in one command, running against the
|
|
8
|
+
HAL's digital-twin (MuJoCo viewer) mode.
|
|
9
|
+
|
|
10
|
+
The launch graph is robot-agnostic — one ``sim_e2e.launch.py`` for every
|
|
11
|
+
robot. The CLI's job is to resolve everything robot-specific:
|
|
12
|
+
|
|
13
|
+
* The robot manifest at ``robots/<robot_id>/robot.yaml``.
|
|
14
|
+
* The HAL package + executable + node name + per-robot default
|
|
15
|
+
parameter dict, looked up by ``robot_id`` in ``_ROBOT_HAL_REGISTRY``.
|
|
16
|
+
The lookup asserts the HAL's declared ``supported_robot_names`` matches
|
|
17
|
+
the manifest's ``name`` field — a mismatch (someone wires
|
|
18
|
+
``openarm`` to the so100 HAL by accident) fails loud.
|
|
19
|
+
|
|
20
|
+
No envelope YAML file is involved on either side:
|
|
21
|
+
|
|
22
|
+
* The robot manifest is the single source of truth for the safety
|
|
23
|
+
kernel envelope. ``sim_e2e.launch.py``'s ``compose_runtime_graph``
|
|
24
|
+
callback loads ``robot.yaml`` via Pydantic at launch time, calls
|
|
25
|
+
``openral_safety.envelope_loader.compute_intersection(robot, skill=None)``
|
|
26
|
+
+ ``kernel_params_from_envelope(...)``, and forwards each field of
|
|
27
|
+
the resulting :class:`EnvelopeIntersection` as a ROS parameter on
|
|
28
|
+
the kernel node (see ``cpp/openral_safety_kernel/src/envelope.cpp``
|
|
29
|
+
— `n_dof`, `joint_position_min/max`, `joint_velocity_max`,
|
|
30
|
+
`joint_torque_max`, scalar caps, deadman flag). The legacy
|
|
31
|
+
``envelope_file:=PATH`` path was removed.
|
|
32
|
+
|
|
33
|
+
The reasoner is NOT preselected: it walks the in-tree ``rskills/`` and
|
|
34
|
+
filters by the robot's capabilities at on_configure. ``openral deploy sim``
|
|
35
|
+
intentionally does not accept ``--rskill`` because the reasoner picks
|
|
36
|
+
the active rSkill dynamically and switching skills is its job, not the
|
|
37
|
+
operator's bring-up command.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
from __future__ import annotations
|
|
41
|
+
|
|
42
|
+
import contextlib
|
|
43
|
+
import importlib
|
|
44
|
+
import json
|
|
45
|
+
import os
|
|
46
|
+
import shlex
|
|
47
|
+
import shutil
|
|
48
|
+
import signal
|
|
49
|
+
import subprocess
|
|
50
|
+
import sys
|
|
51
|
+
import sysconfig
|
|
52
|
+
import tempfile
|
|
53
|
+
from collections.abc import Callable, Iterable
|
|
54
|
+
from dataclasses import dataclass, field
|
|
55
|
+
from pathlib import Path
|
|
56
|
+
from typing import TYPE_CHECKING
|
|
57
|
+
|
|
58
|
+
import typer
|
|
59
|
+
import yaml
|
|
60
|
+
from openral_core.exceptions import ROSCapabilityMismatch, ROSConfigError
|
|
61
|
+
from rich.console import Console
|
|
62
|
+
|
|
63
|
+
if TYPE_CHECKING:
|
|
64
|
+
from openral_core import RobotDescription, RSkillManifest
|
|
65
|
+
|
|
66
|
+
__all__ = [
|
|
67
|
+
"LaunchInvocation",
|
|
68
|
+
"assert_ros2_packages_discoverable",
|
|
69
|
+
"deploy_sim_command",
|
|
70
|
+
"resolve_launch_invocation",
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
_console = Console(soft_wrap=True)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass(frozen=True)
|
|
77
|
+
class _HalSpec:
|
|
78
|
+
"""Per-robot HAL spawn descriptor.
|
|
79
|
+
|
|
80
|
+
``package`` / ``executable`` / ``node_name`` parameterise the HAL
|
|
81
|
+
``LifecycleNode`` in ``sim_e2e.launch.py``. ``supported_robot_names``
|
|
82
|
+
is the set of ``RobotDescription.name`` values this HAL is willing
|
|
83
|
+
to drive — the CLI asserts the loaded manifest's name is in this
|
|
84
|
+
set so a mis-paired registry entry (openarm HAL routed at an so100
|
|
85
|
+
manifest) fails loud at resolution time, not at the HAL's first
|
|
86
|
+
actuation tick. ``default_params`` is the parameter dict the HAL
|
|
87
|
+
accepts via its ROS parameter interface; operator-supplied
|
|
88
|
+
``--hal`` overrides win.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
package: str
|
|
92
|
+
executable: str
|
|
93
|
+
node_name: str
|
|
94
|
+
supported_robot_names: frozenset[str]
|
|
95
|
+
default_params: dict[str, object] = field(default_factory=dict)
|
|
96
|
+
# HAL nodes that declare a `sim_env_yaml` ROS
|
|
97
|
+
# parameter (today: openral_hal_panda_mobile) opt in via this
|
|
98
|
+
# flag. When True, `openral deploy sim --config <yaml>` injects the
|
|
99
|
+
# resolved config path into hal_params so the HAL builds a live
|
|
100
|
+
# `openral_sim.SimRollout` env in-process. Other HALs leave it
|
|
101
|
+
# False so rclpy doesn't reject the unknown parameter at startup
|
|
102
|
+
# (`automatically_declare_parameters_from_overrides=False` is the
|
|
103
|
+
# default).
|
|
104
|
+
supports_sim_env_yaml: bool = False
|
|
105
|
+
# HAL nodes built via `make_lifecycle_main_from_manifest`
|
|
106
|
+
# (franka / ur5e / ur10e / aloha / g1 / h1 / rizon4 / so100 / so101) declare
|
|
107
|
+
# `robot_yaml` + `hal_mode` params and construct their HAL through
|
|
108
|
+
# `build_hal(mode=...)`. When True, `openral deploy sim` injects the resolved
|
|
109
|
+
# manifest path + `hal_mode="sim"`; `openral deploy run` injects
|
|
110
|
+
# `hal_mode="real"`.
|
|
111
|
+
manifest_driven: bool = False
|
|
112
|
+
# issue #191 Phase 2 — a manifest-driven arm that builds a *bare* MuJoCo
|
|
113
|
+
# twin (`MujocoArmHAL.from_description`) in sim rather than scene-attaching:
|
|
114
|
+
# A manifest-driven arm that builds its OWN sim MJCF rather than
|
|
115
|
+
# scene-attaching: so100 / so101 derive a bare `MujocoArmHAL` twin from the
|
|
116
|
+
# manifest's `sim:` block; openarm composes a tabletop MJCF from
|
|
117
|
+
# `scene_defaults.composition` (issue #191 Phase 3b). When True, the
|
|
118
|
+
# manifest-driven injection below skips `sim_env_yaml` so the node builds the
|
|
119
|
+
# explicit `hal.sim` HAL (with the composed mjcf threaded in) instead of a
|
|
120
|
+
# scene-attached `SimAttachedHAL`. Other manifest arms leave it False and
|
|
121
|
+
# scene-attach.
|
|
122
|
+
bare_twin_sim: bool = False
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
_ROBOT_HAL_REGISTRY: dict[str, _HalSpec] = {
|
|
126
|
+
# Keys match the directory name under ``robots/<robot_id>/``; the
|
|
127
|
+
# CLI looks up ``robots/<robot_id>/robot.yaml`` against this key. To
|
|
128
|
+
# add a new robot to ``openral deploy sim``: ship a ``openral_hal_<X>``
|
|
129
|
+
# ROS package with a ``lifecycle_node.py`` executable, register it
|
|
130
|
+
# in the ros2-build Justfile target, and add an entry here. The
|
|
131
|
+
# Python HAL adapters under ``python/hal/src/openral_hal/`` are a
|
|
132
|
+
# different layer (used by ``openral deploy run`` / ``openral sim run``);
|
|
133
|
+
# they do not provide ROS lifecycle nodes by themselves.
|
|
134
|
+
"openarm": _HalSpec(
|
|
135
|
+
package="openral_hal_openarm",
|
|
136
|
+
executable="lifecycle_node.py",
|
|
137
|
+
node_name="openral_hal_openarm",
|
|
138
|
+
supported_robot_names=frozenset({"openarm_v2", "openarm"}),
|
|
139
|
+
# issue #191 Phase 3b — migrated onto the manifest-driven node. Scene
|
|
140
|
+
# composition (robot_lift_z / robot_forward_x / white_background) moved to
|
|
141
|
+
# the manifest's `scene_defaults.composition`; HAL kwargs (settle_steps /
|
|
142
|
+
# gravity_enabled / staleness_limit_s) to `hal.parameters`; cameras via
|
|
143
|
+
# SimSensorBridge + OpenArmMujocoHAL.read_images. Only the viewer toggle
|
|
144
|
+
# remains a node ROS param.
|
|
145
|
+
default_params={
|
|
146
|
+
"viewer_enabled": True,
|
|
147
|
+
},
|
|
148
|
+
manifest_driven=True,
|
|
149
|
+
# openarm builds its own MJCF via scene COMPOSITION (the manifest's
|
|
150
|
+
# `scene_defaults.composition`), not scene-attach — so suppress the
|
|
151
|
+
# `sim_env_yaml` injection (see `bare_twin_sim`).
|
|
152
|
+
bare_twin_sim=True,
|
|
153
|
+
),
|
|
154
|
+
"so100_follower": _HalSpec(
|
|
155
|
+
# issue #191 Phase 2 — migrated onto the manifest-driven node
|
|
156
|
+
# (`make_lifecycle_main_from_manifest`). `hal_mode="sim"` derives a bare
|
|
157
|
+
# `MujocoArmHAL` twin; `hal_mode="real"` builds `SO100FollowerHAL` with
|
|
158
|
+
# `port` / `calibrate_on_connect` from the manifest's `hal.parameters`
|
|
159
|
+
# (so no `port` ROS param — the node would reject the unknown override).
|
|
160
|
+
package="openral_hal_so100",
|
|
161
|
+
executable="lifecycle_node.py",
|
|
162
|
+
node_name="openral_hal_so100",
|
|
163
|
+
supported_robot_names=frozenset({"so100_follower"}),
|
|
164
|
+
manifest_driven=True,
|
|
165
|
+
bare_twin_sim=True,
|
|
166
|
+
),
|
|
167
|
+
"so101_follower": _HalSpec(
|
|
168
|
+
# The SO-101 is a hardware revision of the SO-100: identical 6-DoF
|
|
169
|
+
# kinematic chain driven by the same lerobot Feetech STS3215 serial
|
|
170
|
+
# backend, so it reuses the ``openral_hal_so100`` ROS lifecycle node
|
|
171
|
+
# (now manifest-driven) verbatim. ``openral deploy sim`` injects this
|
|
172
|
+
# robot's manifest + ``hal_mode="sim"`` and the node builds a bare
|
|
173
|
+
# ``MujocoArmHAL.from_description`` from ``robots/so101_follower/
|
|
174
|
+
# robot.yaml`` (``assets.mjcf`` → ``so101_new_calib``). The SAME node
|
|
175
|
+
# serves so100 (``so_arm100``) and so101 (``so101_new_calib``) from
|
|
176
|
+
# their own MJCF, so no dedicated ``openral_hal_so101`` package exists
|
|
177
|
+
# or is needed (CLAUDE.md §1.13). The robot-name guard below keeps this
|
|
178
|
+
# entry bound to the so101 manifest.
|
|
179
|
+
package="openral_hal_so100",
|
|
180
|
+
executable="lifecycle_node.py",
|
|
181
|
+
node_name="openral_hal_so100",
|
|
182
|
+
supported_robot_names=frozenset({"so101_follower"}),
|
|
183
|
+
manifest_driven=True,
|
|
184
|
+
bare_twin_sim=True,
|
|
185
|
+
),
|
|
186
|
+
"panda_mobile": _HalSpec(
|
|
187
|
+
# panda_mobile publishes /joint_states + /odom +
|
|
188
|
+
# /scan and broadcasts the odom -> base_link TF that slam_toolbox + Nav2
|
|
189
|
+
# both need. issue #191 Phase 3 migrated it onto the manifest-driven node:
|
|
190
|
+
# MobileBaseBridge owns /odom + TF + /cmd_vel (gated on the manifest's
|
|
191
|
+
# `base_joints`), SimSensorBridge owns /scan + cameras + depth + viewer.
|
|
192
|
+
package="openral_hal_panda_mobile",
|
|
193
|
+
executable="lifecycle_node.py",
|
|
194
|
+
node_name="openral_hal_panda_mobile",
|
|
195
|
+
supported_robot_names=frozenset({"panda_mobile"}),
|
|
196
|
+
default_params={
|
|
197
|
+
"odom_publish_rate_hz": 20.0,
|
|
198
|
+
# The /scan envelope (rate / beam count / range) is NOT hardcoded
|
|
199
|
+
# here: it's derived from the robot.yaml lidar_2d sensor at resolve
|
|
200
|
+
# time (see _resolve_deploy_target) so robot.yaml stays the single
|
|
201
|
+
# source of truth. viewer_enabled opens a non-blocking
|
|
202
|
+
# mujoco.viewer.launch_passive window (no-op headless).
|
|
203
|
+
"viewer_enabled": True,
|
|
204
|
+
},
|
|
205
|
+
# Scene-attach (SimAttachedHAL via sim_env_yaml) when a scene config is
|
|
206
|
+
# given; bare PandaMobileHAL digital twin otherwise.
|
|
207
|
+
manifest_driven=True,
|
|
208
|
+
supports_sim_env_yaml=True,
|
|
209
|
+
),
|
|
210
|
+
"franka_panda": _HalSpec(
|
|
211
|
+
package="openral_hal_franka",
|
|
212
|
+
executable="lifecycle_node.py",
|
|
213
|
+
node_name="openral_hal_franka",
|
|
214
|
+
supported_robot_names=frozenset({"franka_panda"}),
|
|
215
|
+
default_params={},
|
|
216
|
+
manifest_driven=True,
|
|
217
|
+
),
|
|
218
|
+
"ur5e": _HalSpec(
|
|
219
|
+
package="openral_hal_ur5e",
|
|
220
|
+
executable="lifecycle_node.py",
|
|
221
|
+
node_name="openral_hal_ur5e",
|
|
222
|
+
supported_robot_names=frozenset({"ur5e"}),
|
|
223
|
+
default_params={},
|
|
224
|
+
manifest_driven=True,
|
|
225
|
+
),
|
|
226
|
+
"ur10e": _HalSpec(
|
|
227
|
+
package="openral_hal_ur10e",
|
|
228
|
+
executable="lifecycle_node.py",
|
|
229
|
+
node_name="openral_hal_ur10e",
|
|
230
|
+
supported_robot_names=frozenset({"ur10e"}),
|
|
231
|
+
default_params={},
|
|
232
|
+
manifest_driven=True,
|
|
233
|
+
),
|
|
234
|
+
"aloha_bimanual": _HalSpec(
|
|
235
|
+
package="openral_hal_aloha",
|
|
236
|
+
executable="lifecycle_node.py",
|
|
237
|
+
node_name="openral_hal_aloha",
|
|
238
|
+
supported_robot_names=frozenset({"aloha_bimanual"}),
|
|
239
|
+
default_params={},
|
|
240
|
+
manifest_driven=True,
|
|
241
|
+
),
|
|
242
|
+
"aloha_agilex": _HalSpec(
|
|
243
|
+
# RoboTwin owns the SAPIEN robot; deploy-sim only needs a ROS lifecycle
|
|
244
|
+
# host for SimAttachedHAL. This package is intentionally sim-only and
|
|
245
|
+
# generic, so it never claims a real AgileX hardware transport.
|
|
246
|
+
package="openral_hal_scene_attached",
|
|
247
|
+
executable="lifecycle_node.py",
|
|
248
|
+
node_name="openral_hal_scene_attached",
|
|
249
|
+
supported_robot_names=frozenset({"aloha_agilex"}),
|
|
250
|
+
default_params={},
|
|
251
|
+
manifest_driven=True,
|
|
252
|
+
supports_sim_env_yaml=True,
|
|
253
|
+
),
|
|
254
|
+
"widowx": _HalSpec(
|
|
255
|
+
# SimplerEnv owns the SAPIEN WidowX twin; OpenRAL has no real WidowX HAL.
|
|
256
|
+
# The generic scene-attached node is valid for deploy-sim only because
|
|
257
|
+
# build_hal(mode="sim", sim_env_yaml=...) bypasses hal.sim entirely.
|
|
258
|
+
package="openral_hal_scene_attached",
|
|
259
|
+
executable="lifecycle_node.py",
|
|
260
|
+
node_name="openral_hal_scene_attached",
|
|
261
|
+
supported_robot_names=frozenset({"widowx"}),
|
|
262
|
+
default_params={},
|
|
263
|
+
manifest_driven=True,
|
|
264
|
+
supports_sim_env_yaml=True,
|
|
265
|
+
),
|
|
266
|
+
"g1": _HalSpec(
|
|
267
|
+
package="openral_hal_g1",
|
|
268
|
+
executable="lifecycle_node.py",
|
|
269
|
+
node_name="openral_hal_g1",
|
|
270
|
+
supported_robot_names=frozenset({"g1"}),
|
|
271
|
+
default_params={},
|
|
272
|
+
manifest_driven=True,
|
|
273
|
+
),
|
|
274
|
+
"h1": _HalSpec(
|
|
275
|
+
package="openral_hal_h1",
|
|
276
|
+
executable="lifecycle_node.py",
|
|
277
|
+
node_name="openral_hal_h1",
|
|
278
|
+
supported_robot_names=frozenset({"h1"}),
|
|
279
|
+
default_params={},
|
|
280
|
+
manifest_driven=True,
|
|
281
|
+
),
|
|
282
|
+
"rizon4": _HalSpec(
|
|
283
|
+
package="openral_hal_rizon4",
|
|
284
|
+
executable="lifecycle_node.py",
|
|
285
|
+
node_name="openral_hal_rizon4",
|
|
286
|
+
supported_robot_names=frozenset({"rizon4"}),
|
|
287
|
+
default_params={},
|
|
288
|
+
manifest_driven=True,
|
|
289
|
+
),
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
@dataclass(frozen=True)
|
|
294
|
+
class LaunchInvocation:
|
|
295
|
+
"""Resolved ``ros2 launch`` argv + the metadata that built it.
|
|
296
|
+
|
|
297
|
+
Returned by :func:`resolve_launch_invocation` so the dispatcher can
|
|
298
|
+
pretty-print under ``--dry-run`` and the unit tests can assert on
|
|
299
|
+
the resolved fields without touching ``subprocess``.
|
|
300
|
+
"""
|
|
301
|
+
|
|
302
|
+
robot_id: str
|
|
303
|
+
robot_yaml: Path
|
|
304
|
+
robot_manifest_name: str
|
|
305
|
+
hal: _HalSpec
|
|
306
|
+
hal_params: dict[str, object]
|
|
307
|
+
hal_mode: str
|
|
308
|
+
"""``"sim"`` (``openral deploy sim``) or ``"real"``
|
|
309
|
+
(``openral deploy run``). Forwarded into the launch as ``hal_mode:=…`` so
|
|
310
|
+
the reasoner's action-mode palette gate matches the HAL this graph
|
|
311
|
+
brings up (sim admits cartesian/OSC skills the scene's robosuite OSC
|
|
312
|
+
controller can execute; real admits only the robot's declared
|
|
313
|
+
``supported_control_modes``)."""
|
|
314
|
+
reset_to_pose_service: str
|
|
315
|
+
approach_skill_id: str
|
|
316
|
+
"""MoveIt approach rSkill URI (e.g. ``rskills/rskill-moveit-joints``)
|
|
317
|
+
forwarded into the launch as ``approach_skill_id:=…`` so the skill_runner
|
|
318
|
+
plans a collision-free MoveGroup motion to the next skill's ``starting_pose``
|
|
319
|
+
instead of the teleport snap. Empty (the default) keeps the legacy
|
|
320
|
+
best-effort ``ResetToPose`` snap — opt in with ``--approach-skill-id`` once a
|
|
321
|
+
``move_group`` is in the graph."""
|
|
322
|
+
enable_slam: bool
|
|
323
|
+
"""Opt-in. Set by ``openral deploy sim --enable-slam``;
|
|
324
|
+
forwarded into the launch as ``enable_slam:=true``."""
|
|
325
|
+
slam_backend: str
|
|
326
|
+
"""Which SLAM backend the launch composes when
|
|
327
|
+
``enable_slam`` is true: ``"lidar"`` (slam_toolbox, needs ``/scan``),
|
|
328
|
+
``"visual"`` (cuVSLAM + nvblox, camera-based, for lidar-less robots),
|
|
329
|
+
or ``"none"`` (no SLAM). Resolved from capabilities — ``has_lidar``
|
|
330
|
+
selects ``lidar`` (it wins when both flags are set, needing no AI depth
|
|
331
|
+
model); else ``has_vision_slam`` selects ``visual``. Forwarded as
|
|
332
|
+
``slam_backend:=…``."""
|
|
333
|
+
enable_nav2: bool
|
|
334
|
+
"""Opt-in for the Nav2 navigation stack. Set by
|
|
335
|
+
``openral deploy sim --enable-nav2``; forwarded into the launch as
|
|
336
|
+
``enable_nav2:=true``. Defaults to ``has_lidar`` — every robot
|
|
337
|
+
that runs slam_toolbox needs a planner to consume the resulting
|
|
338
|
+
map, so the two are auto-co-enabled."""
|
|
339
|
+
enable_octomap: bool
|
|
340
|
+
"""Opt-in for the world-collision perception leg
|
|
341
|
+
(octomap_server + openral_octomap_bridge + the kernel's
|
|
342
|
+
capsule-vs-voxel check). Set by ``openral deploy sim --enable-octomap``;
|
|
343
|
+
forwarded as ``enable_octomap:=true``. Defaults to "auto" = the robot
|
|
344
|
+
manifest declares a depth SensorSpec (nothing to map otherwise)."""
|
|
345
|
+
clock_origin: str
|
|
346
|
+
"""ClockAuthority origin forwarded as ``clock_origin:=…``. Derived from
|
|
347
|
+
the deployment: simulator-owned elapsed time for sim backends that expose
|
|
348
|
+
``sim_time_ns``; host wall time for real deployments or clock-less scenes.
|
|
349
|
+
Operators do not choose ROS ``use_sim_time`` directly."""
|
|
350
|
+
enable_object_detector: bool
|
|
351
|
+
"""Object-detection perception leg
|
|
352
|
+
(ros_image_detector_node → /openral/perception/objects → world-state
|
|
353
|
+
object-lift → /openral/world_voxels). **On by default**; disabled with
|
|
354
|
+
``openral deploy sim --no-object-detector``. Forwarded as
|
|
355
|
+
``enable_object_detector:=true|false``. Auto-downgrades to ``false`` when no
|
|
356
|
+
backend is available (omdet deps absent *and* the RT-DETR ONNX missing)."""
|
|
357
|
+
object_detector_onnx: Path
|
|
358
|
+
"""Absolute path to the RT-DETR ONNX weights used by the legacy /
|
|
359
|
+
fallback detector path. Forwarded as ``object_detector_onnx:=<path>``.
|
|
360
|
+
Defaults to the in-tree ``rskills/rtdetr-coco-r18/model.onnx``; passing it
|
|
361
|
+
explicitly selects the fixed-label RT-DETR path over the omdet default."""
|
|
362
|
+
object_detector_manifest: str
|
|
363
|
+
"""Path to a kind:detector rSkill manifest. When set,
|
|
364
|
+
the detector node builds its backend from the manifest (runtime:pytorch →
|
|
365
|
+
the open-vocab LocateAnything VLM sidecar; runtime:onnx → RT-DETR ONNX).
|
|
366
|
+
Forwarded as ``object_detector_manifest:=<path>``. Empty = the RT-DETR ONNX
|
|
367
|
+
fallback. By default (no explicit override) this resolves to the
|
|
368
|
+
``omdet-turbo-indoor`` manifest when the omdet deps are importable."""
|
|
369
|
+
object_detector_query: str
|
|
370
|
+
"""Initial open-vocabulary query for a VLM detector
|
|
371
|
+
(e.g. 'red mug'). Forwarded as ``object_detector_query:=<text>``. Empty =
|
|
372
|
+
the manifest's ``detector.labels`` default. Ignored by ONNX detectors."""
|
|
373
|
+
object_detector_locators: tuple[str, ...]
|
|
374
|
+
"""Resolved manifest paths of the ``mode: on_demand`` open-vocab
|
|
375
|
+
locators to bring up alongside the continuous detector. The launch builds one
|
|
376
|
+
namespaced lifecycle node per entry (``/openral/perception/<alias>/locate_in_view``)
|
|
377
|
+
so the reasoner can choose a model via ``LocateInViewTool.detector``. Forwarded
|
|
378
|
+
as ``object_detector_locators:=<comma-joined paths>`` only when non-empty.
|
|
379
|
+
Defaults to the omdet-turbo-locator manifest when the detector is on and the
|
|
380
|
+
omdet deps are importable (LocateAnything is opt-in via an explicit path)."""
|
|
381
|
+
spatial_memory_ingest: bool
|
|
382
|
+
"""Opt-in. Set by ``openral deploy sim --spatial-memory-ingest``;
|
|
383
|
+
forwarded as ``spatial_memory_ingest:=true``. The reasoner then accumulates
|
|
384
|
+
a durable SpatialMemory from the object-lift producer's
|
|
385
|
+
``WorldState.detected_objects`` so ``recall_object`` recalls what the robot
|
|
386
|
+
has seen. Defaults to "auto" = enabled when the object detector is."""
|
|
387
|
+
enable_foxglove: bool
|
|
388
|
+
"""Opt-in. Off by default. Set by ``openral deploy sim --foxglove``;
|
|
389
|
+
forwarded as ``enable_foxglove:=true``. Spawns the read-only
|
|
390
|
+
``foxglove_bridge`` as part of the deploy-sim runtime graph so operators
|
|
391
|
+
can view the live scene (cameras, /tf, joint states, nav map) in
|
|
392
|
+
Foxglove Studio without an extra bring-up step. Cannot actuate the robot
|
|
393
|
+
(view-only; ``clientPublish``/``services`` capabilities omitted)."""
|
|
394
|
+
foxglove_port: int
|
|
395
|
+
"""Foxglove WebSocket port. Forwarded as ``foxglove_port:=…``.
|
|
396
|
+
Default 8765 (the ``foxglove_bridge`` upstream default)."""
|
|
397
|
+
initial_task_prompt: str
|
|
398
|
+
"""Operator goal delivered to the reasoner at startup (cli priority).
|
|
399
|
+
|
|
400
|
+
Sourced only from ``--initial-task`` (or a later live ``/openral/prompt``);
|
|
401
|
+
deploy never derives it from scene tasks. Forwarded as
|
|
402
|
+
``initial_task_prompt:=<text>`` to the launch file. Empty = the reasoner
|
|
403
|
+
idles until an operator prompt arrives."""
|
|
404
|
+
enable_reward_monitor: bool
|
|
405
|
+
"""Whether the Robometer reward monitor is brought up
|
|
406
|
+
co-active with the VLA. When true the deploy preflight checks the VLA↔reward
|
|
407
|
+
VRAM pairing (:func:`_preflight_reward_vram_fit`) before bringing up ROS."""
|
|
408
|
+
reward_monitor_manifest: str
|
|
409
|
+
"""The RESOLVED reward-monitor manifest path. Defaults from the
|
|
410
|
+
capability-matched VLA palette's ``reward_rskill_name`` (the pairing the
|
|
411
|
+
reasoner will honour) when ``--reward-monitor-manifest`` is not given; empty
|
|
412
|
+
when no reward monitor is active. Forwarded as ``reward_monitor_manifest:=…``."""
|
|
413
|
+
argv_template: list[str]
|
|
414
|
+
"""``argv_template`` carries ``HAL_PARAMS_FILE_PLACEHOLDER`` where
|
|
415
|
+
the temp HAL-params YAML path goes. The dispatcher substitutes it
|
|
416
|
+
once the file exists."""
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _repo_root_from(start: Path) -> Path:
|
|
420
|
+
"""Walk up from ``start`` until a directory with ``robots/`` and ``rskills/``."""
|
|
421
|
+
here = start.resolve()
|
|
422
|
+
for ancestor in (here, *here.parents):
|
|
423
|
+
if (ancestor / "robots").is_dir() and (ancestor / "rskills").is_dir():
|
|
424
|
+
return ancestor
|
|
425
|
+
raise ROSConfigError(
|
|
426
|
+
f"Could not locate OpenRAL repo root above {start}; "
|
|
427
|
+
"expected a parent containing both robots/ and rskills/."
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def _load_scene_robot_id(config: Path) -> str | None:
|
|
432
|
+
"""Return the ``robot_id`` declared in a DeployScene YAML, or None.
|
|
433
|
+
|
|
434
|
+
Strict DeployScene loading: ``openral deploy sim --config``
|
|
435
|
+
accepts a DeployScene YAML only (scene + optional robot, no task).
|
|
436
|
+
SimScene / BenchmarkScene YAMLs are rejected with a redirect message.
|
|
437
|
+
|
|
438
|
+
Two paths to discover the robot:
|
|
439
|
+
|
|
440
|
+
1. ``robot_id:`` declared explicitly in the YAML → use it.
|
|
441
|
+
2. The scene id is registered with a ``fixed_robot=`` in
|
|
442
|
+
``openral_sim.SCENES`` (every robocasa kitchen / LIBERO / ALOHA
|
|
443
|
+
/ MetaWorld / PushT scene) → look it up. Lets robocasa-shaped
|
|
444
|
+
YAMLs (which forbid ``robot_id:`` per the schema's free-axis
|
|
445
|
+
guard) still resolve into ``openral deploy sim`` without the
|
|
446
|
+
operator passing ``--robot`` redundantly.
|
|
447
|
+
"""
|
|
448
|
+
from openral_core import DeployScene, load_scene_strict # reason: defer schema import
|
|
449
|
+
|
|
450
|
+
try:
|
|
451
|
+
env = load_scene_strict(str(config), DeployScene)
|
|
452
|
+
except (ROSConfigError, FileNotFoundError) as exc:
|
|
453
|
+
raise ROSConfigError(f"failed to load --config {config}: {exc}") from exc
|
|
454
|
+
if env.robot_id is not None:
|
|
455
|
+
return env.robot_id
|
|
456
|
+
# Fixed-robot scene fallback. The sim registry is the source of
|
|
457
|
+
# truth for which scene ids hard-fix a robot.
|
|
458
|
+
try:
|
|
459
|
+
from openral_sim import SCENES # reason: defer optional dep
|
|
460
|
+
except ImportError:
|
|
461
|
+
return None
|
|
462
|
+
if env.scene.id in SCENES:
|
|
463
|
+
return SCENES.fixed_robot(env.scene.id)
|
|
464
|
+
return None
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def _scan_params_from_description(description: RobotDescription) -> dict[str, object]:
|
|
468
|
+
"""Map a robot's ``lidar_2d`` sensor to HAL ``scan_*`` ROS params.
|
|
469
|
+
|
|
470
|
+
Single source of truth — ``openral deploy sim`` forwards these
|
|
471
|
+
to the HAL instead of hardcoding a per-robot scan envelope. Returns
|
|
472
|
+
an empty dict when the robot declares no LiDAR (non-mobile robots,
|
|
473
|
+
no scan synthesis), so the call site is a no-op for them.
|
|
474
|
+
"""
|
|
475
|
+
lidar = description.lidar_sensor
|
|
476
|
+
if lidar is None:
|
|
477
|
+
return {}
|
|
478
|
+
params: dict[str, object] = {}
|
|
479
|
+
if lidar.rate_hz:
|
|
480
|
+
params["scan_publish_rate_hz"] = lidar.rate_hz
|
|
481
|
+
if lidar.n_channels is not None:
|
|
482
|
+
params["scan_n_beams"] = lidar.n_channels
|
|
483
|
+
if lidar.range_max_m is not None:
|
|
484
|
+
params["scan_max_range_m"] = lidar.range_max_m
|
|
485
|
+
if lidar.range_min_m is not None:
|
|
486
|
+
params["scan_min_range_m"] = lidar.range_min_m
|
|
487
|
+
return params
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def _scene_backend_has_sim_clock(config: Path | None) -> bool:
|
|
491
|
+
"""Return whether a DeployScene backend can be the OpenRAL simulation clock authority."""
|
|
492
|
+
if config is None:
|
|
493
|
+
return False
|
|
494
|
+
from openral_core import DeployScene, PhysicsBackend, load_scene_strict
|
|
495
|
+
|
|
496
|
+
scene = load_scene_strict(str(config), DeployScene)
|
|
497
|
+
return scene.scene.backend in {
|
|
498
|
+
PhysicsBackend.MUJOCO,
|
|
499
|
+
PhysicsBackend.MUJOCO_MJX,
|
|
500
|
+
PhysicsBackend.ISAACSIM,
|
|
501
|
+
PhysicsBackend.SAPIEN,
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def _resolve_clock_origin(*, hal_mode: str, config: Path | None) -> str:
|
|
506
|
+
"""Resolve the OpenRAL clock authority origin for the launch graph.
|
|
507
|
+
|
|
508
|
+
Real deployments use host wall time. Sim deployments use simulator elapsed
|
|
509
|
+
time when the deploy scene backend exposes a sim clock. Scene-attached HALs
|
|
510
|
+
and bare MuJoCo twins both expose ``sim_time_ns``; clock-less scenes stay in
|
|
511
|
+
host-wall time so ROS node clocks never pin at zero.
|
|
512
|
+
"""
|
|
513
|
+
if hal_mode != "sim":
|
|
514
|
+
return "host_wall"
|
|
515
|
+
return "simulation" if _scene_backend_has_sim_clock(config) else "host_wall"
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def _omdet_runtime_available() -> bool:
|
|
519
|
+
"""True when the OmDet-Turbo continuous detector's runtime deps are importable.
|
|
520
|
+
|
|
521
|
+
The default object detector is omdet-turbo-indoor (open-vocabulary, grounds
|
|
522
|
+
arbitrary indoor/kitchen objects rather than the fixed COCO-80 of RT-DETR).
|
|
523
|
+
Its in-process zero-shot backend
|
|
524
|
+
(``openral_runner.backends.gstreamer.omdet_turbo_detector.OmDetTurboDetector``)
|
|
525
|
+
needs ``transformers`` + ``timm`` — the ``omdet`` dependency group. When they
|
|
526
|
+
are absent (a checkout that only synced the base group),
|
|
527
|
+
:func:`resolve_launch_invocation` gracefully falls back to the in-tree
|
|
528
|
+
RT-DETR COCO ONNX so ``deploy sim`` still brings up a detector instead of the
|
|
529
|
+
node hard-failing at backend build.
|
|
530
|
+
|
|
531
|
+
Patched in tests to exercise both branches without touching the environment.
|
|
532
|
+
"""
|
|
533
|
+
# Local import: the probe is cheap and scoped to this one decision.
|
|
534
|
+
import importlib.util
|
|
535
|
+
|
|
536
|
+
return all(importlib.util.find_spec(mod) is not None for mod in ("transformers", "timm"))
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def _object_detector_onnx_present(path: Path) -> bool:
|
|
540
|
+
"""True when the fallback RT-DETR COCO ONNX weights are on disk.
|
|
541
|
+
|
|
542
|
+
The weights (``rskills/rtdetr-coco-r18/model.onnx``, ~2 MB) are gitignored, so
|
|
543
|
+
they are present on a weights-fetched dev host but absent in a bare CI
|
|
544
|
+
checkout. :func:`resolve_launch_invocation` downgrades the detector leg off
|
|
545
|
+
when neither omdet deps nor these weights can build a backend. Factored out so
|
|
546
|
+
tests can exercise the fallback-selection logic without the gitignored binary.
|
|
547
|
+
"""
|
|
548
|
+
return path.is_file()
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def _resolve_slam_backend(*, has_lidar: bool, has_vision_slam: bool, enable_slam: bool) -> str:
|
|
552
|
+
"""Pick the SLAM backend the launch composes.
|
|
553
|
+
|
|
554
|
+
Returns one of ``"lidar"`` (slam_toolbox; needs ``/scan``), ``"visual"``
|
|
555
|
+
(cuVSLAM + nvblox; camera-based, for lidar-less robots), or ``"none"``.
|
|
556
|
+
|
|
557
|
+
``has_lidar`` wins when both flags are set: the 2D-lidar leg is the
|
|
558
|
+
cheaper, proven path and needs no AI depth model. A resolved
|
|
559
|
+
``enable_slam`` of ``False`` (an explicit ``--no-enable-slam``) forces
|
|
560
|
+
``"none"`` so the forwarded ``slam_backend:=`` arg never contradicts the
|
|
561
|
+
``enable_slam:=false`` arg.
|
|
562
|
+
|
|
563
|
+
Args:
|
|
564
|
+
has_lidar: ``RobotCapabilities.has_lidar``.
|
|
565
|
+
has_vision_slam: ``RobotCapabilities.has_vision_slam``.
|
|
566
|
+
enable_slam: The resolved SLAM-on decision (manifest auto or flag).
|
|
567
|
+
|
|
568
|
+
Returns:
|
|
569
|
+
The backend identifier forwarded as the ``slam_backend`` launch arg.
|
|
570
|
+
|
|
571
|
+
Example:
|
|
572
|
+
>>> _resolve_slam_backend(has_lidar=False, has_vision_slam=True, enable_slam=True)
|
|
573
|
+
'visual'
|
|
574
|
+
>>> _resolve_slam_backend(has_lidar=True, has_vision_slam=True, enable_slam=True)
|
|
575
|
+
'lidar'
|
|
576
|
+
>>> _resolve_slam_backend(has_lidar=True, has_vision_slam=False, enable_slam=False)
|
|
577
|
+
'none'
|
|
578
|
+
"""
|
|
579
|
+
if not enable_slam:
|
|
580
|
+
return "none"
|
|
581
|
+
if has_lidar:
|
|
582
|
+
return "lidar"
|
|
583
|
+
if has_vision_slam:
|
|
584
|
+
return "visual"
|
|
585
|
+
return "none"
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def _memory_bundle_launch_args(memory_dir: str) -> list[str]:
|
|
589
|
+
"""Derive the sim_e2e.launch.py bundle args from a deploy memory-bundle dir.
|
|
590
|
+
|
|
591
|
+
The bundle is a directory holding any of ``MEMORY.md`` (semantic memory),
|
|
592
|
+
``scene_graph.json`` (3D world-state graph), and ``map.yaml`` (2D occupancy grid).
|
|
593
|
+
Each artifact is forwarded to its own consumer's launch arg. The dir must exist
|
|
594
|
+
(the robot writes ``MEMORY.md`` into it); ``scene_graph.json`` / ``map.yaml`` are
|
|
595
|
+
forwarded only when present, so a fresh bundle (empty dir) just starts the reasoner
|
|
596
|
+
with empty memory and no preloaded scene/map.
|
|
597
|
+
"""
|
|
598
|
+
from openral_core.exceptions import ROSConfigError
|
|
599
|
+
|
|
600
|
+
d = Path(memory_dir).expanduser()
|
|
601
|
+
if not d.is_dir():
|
|
602
|
+
raise ROSConfigError(
|
|
603
|
+
f"--memory-dir {memory_dir!r} is not an existing directory. Create the bundle "
|
|
604
|
+
"dir first (the robot writes MEMORY.md into it; place scene_graph.json / map.yaml "
|
|
605
|
+
"there to preload the scene graph + occupancy grid)."
|
|
606
|
+
)
|
|
607
|
+
# memory_md_path may not exist yet — the reasoner creates it on the first memory_write.
|
|
608
|
+
args = [f"memory_md_path:={d / 'MEMORY.md'}"]
|
|
609
|
+
scene_graph = d / "scene_graph.json"
|
|
610
|
+
if scene_graph.is_file():
|
|
611
|
+
args.append(f"spatial_memory_path:={scene_graph}")
|
|
612
|
+
map_yaml = d / "map.yaml"
|
|
613
|
+
if map_yaml.is_file():
|
|
614
|
+
args.append(f"map_path:={map_yaml}")
|
|
615
|
+
return args
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
# the in-tree directory of the default reward/progress-monitor rSkill
|
|
619
|
+
# the deploy pairs with a VLA when nothing names one. Mirrors the reasoner's
|
|
620
|
+
# launch default (``rskills/robometer-4b/rskill.yaml``, sim_e2e.launch.py).
|
|
621
|
+
_DEFAULT_REWARD_RSKILL_DIR = "robometer-4b"
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def _detect_gpu_vram_gb(field: str) -> float:
|
|
625
|
+
"""VRAM (GB) of GPU 0 for an ``nvidia-smi`` field, or ``0.0`` when unavailable.
|
|
626
|
+
|
|
627
|
+
Torch-free probe (the CLI must not import torch just to size the GPU). Any
|
|
628
|
+
failure (no nvidia-smi, no GPU, parse error) returns ``0.0`` → the caller
|
|
629
|
+
skips the pair check rather than blocking a launch on a host where the budget
|
|
630
|
+
cannot be read. ``field`` is a ``--query-gpu`` column, e.g. ``memory.total`` or
|
|
631
|
+
``memory.free``.
|
|
632
|
+
"""
|
|
633
|
+
try:
|
|
634
|
+
out = subprocess.run(
|
|
635
|
+
["nvidia-smi", f"--query-gpu={field}", "--format=csv,noheader,nounits"],
|
|
636
|
+
capture_output=True,
|
|
637
|
+
text=True,
|
|
638
|
+
timeout=5.0,
|
|
639
|
+
check=True,
|
|
640
|
+
)
|
|
641
|
+
except (OSError, subprocess.SubprocessError):
|
|
642
|
+
return 0.0
|
|
643
|
+
lines = out.stdout.strip().splitlines()
|
|
644
|
+
if not lines:
|
|
645
|
+
return 0.0
|
|
646
|
+
try:
|
|
647
|
+
return float(lines[0].strip()) / 1024.0 # MiB → GiB
|
|
648
|
+
except ValueError:
|
|
649
|
+
return 0.0
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def _detect_gpu_free_vram_gb() -> float:
|
|
653
|
+
"""Free VRAM (GB) of GPU 0 at launch — the real pre-load budget for the VLA+reward pair.
|
|
654
|
+
|
|
655
|
+
The launch preflight runs before any OpenRAL model is loaded, so *free* VRAM
|
|
656
|
+
(not total) is the honest headroom the VLA + reward pair must fit into. On a
|
|
657
|
+
shared dev box a desktop compositor or a sibling worktree's process can hold
|
|
658
|
+
GBs the pair will never see; budgeting against total would greenlight a pair
|
|
659
|
+
that OOMs the moment both models load (the failure mode `--no-enable-reward-monitor`
|
|
660
|
+
masks by dropping the reward model).
|
|
661
|
+
"""
|
|
662
|
+
return _detect_gpu_vram_gb("memory.free")
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def _capability_matched_manifests(
|
|
666
|
+
repo_root: Path,
|
|
667
|
+
description: RobotDescription,
|
|
668
|
+
*,
|
|
669
|
+
commercial_deployment: bool = False,
|
|
670
|
+
) -> list[RSkillManifest]:
|
|
671
|
+
"""In-tree rSkill manifests that match this robot's reasoner palette.
|
|
672
|
+
|
|
673
|
+
Loads every ``rskills/*/rskill.yaml`` and runs the same
|
|
674
|
+
capability/role/license filter the reasoner seeds at ``on_configure``
|
|
675
|
+
(:func:`openral_reasoner.palette.build_tool_palette`), returning the matched
|
|
676
|
+
manifests. ``openral deploy sim`` does not preselect a VLA — the reasoner picks
|
|
677
|
+
one at runtime from exactly this set — so reward resolution + the VRAM
|
|
678
|
+
preflight both reason over it (the "VLA known at launch" is the *palette*, not a
|
|
679
|
+
single policy). Unloadable manifests are skipped (the reasoner skips them too).
|
|
680
|
+
"""
|
|
681
|
+
from openral_core import RSkillManifest
|
|
682
|
+
from openral_reasoner.palette import build_tool_palette
|
|
683
|
+
|
|
684
|
+
manifests: list[RSkillManifest] = []
|
|
685
|
+
for path in sorted((repo_root / "rskills").glob("*/rskill.yaml")):
|
|
686
|
+
try:
|
|
687
|
+
manifests.append(RSkillManifest.from_yaml(str(path)))
|
|
688
|
+
except (OSError, ValueError):
|
|
689
|
+
continue
|
|
690
|
+
if not manifests:
|
|
691
|
+
return []
|
|
692
|
+
palette = build_tool_palette(
|
|
693
|
+
installed_skills=manifests,
|
|
694
|
+
robot_capabilities=description.capabilities,
|
|
695
|
+
commercial_deployment=commercial_deployment,
|
|
696
|
+
)
|
|
697
|
+
matched = set(palette.execute_rskill_ids)
|
|
698
|
+
return [m for m in manifests if m.name in matched]
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
def _resolve_reward_monitor_manifest(
|
|
702
|
+
*,
|
|
703
|
+
repo_root: Path,
|
|
704
|
+
description: RobotDescription,
|
|
705
|
+
explicit_manifest: str | None,
|
|
706
|
+
) -> str:
|
|
707
|
+
"""Resolve the reward-monitor manifest, defaulting from the VLA pairing.
|
|
708
|
+
|
|
709
|
+
The pairing used to be implicit: the reward model was chosen by a flag
|
|
710
|
+
(``--reward-monitor-manifest``) wholly decoupled from the VLA the reasoner
|
|
711
|
+
picks. The pairing is recorded on the VLA manifest
|
|
712
|
+
(``reward_rskill_name``); this honours it at launch. Because ``deploy sim``
|
|
713
|
+
does not preselect a single VLA, we read the pairing across the
|
|
714
|
+
capability-matched VLA palette:
|
|
715
|
+
|
|
716
|
+
* An explicit ``--reward-monitor-manifest`` always wins (operator override).
|
|
717
|
+
* Else, if the palette's VLAs agree on a single ``reward_rskill_name``, resolve
|
|
718
|
+
that rSkill ``name`` to its in-tree manifest path.
|
|
719
|
+
* Else (no VLA names a reward model, the named model is not in-tree, or the
|
|
720
|
+
palette VLAs disagree) fall back to the deployment default
|
|
721
|
+
(``robometer-4b``). A disagreement is warned — the reasoner additionally
|
|
722
|
+
warns per-VLA at dispatch when a VLA's ``reward_rskill_name`` differs from
|
|
723
|
+
the loaded reward model.
|
|
724
|
+
|
|
725
|
+
Returns the resolved manifest path as a string (empty only when even the
|
|
726
|
+
default is missing from the tree).
|
|
727
|
+
"""
|
|
728
|
+
if explicit_manifest:
|
|
729
|
+
return explicit_manifest
|
|
730
|
+
|
|
731
|
+
from openral_core import RSkillManifest
|
|
732
|
+
|
|
733
|
+
default_path = repo_root / "rskills" / _DEFAULT_REWARD_RSKILL_DIR / "rskill.yaml"
|
|
734
|
+
default = str(default_path.resolve()) if default_path.is_file() else ""
|
|
735
|
+
|
|
736
|
+
# name → manifest path for every in-tree kind:reward rSkill.
|
|
737
|
+
reward_index: dict[str, Path] = {}
|
|
738
|
+
for path in sorted((repo_root / "rskills").glob("*/rskill.yaml")):
|
|
739
|
+
try:
|
|
740
|
+
man = RSkillManifest.from_yaml(str(path))
|
|
741
|
+
except (OSError, ValueError):
|
|
742
|
+
continue
|
|
743
|
+
if man.kind == "reward":
|
|
744
|
+
reward_index[man.name] = path
|
|
745
|
+
|
|
746
|
+
named = {
|
|
747
|
+
m.reward_rskill_name
|
|
748
|
+
for m in _capability_matched_manifests(repo_root, description)
|
|
749
|
+
if m.kind == "vla" and m.reward_rskill_name
|
|
750
|
+
}
|
|
751
|
+
if not named:
|
|
752
|
+
return default
|
|
753
|
+
if len(named) > 1:
|
|
754
|
+
_console.print(
|
|
755
|
+
"[yellow]warning:[/yellow] capability-matched VLAs name different reward "
|
|
756
|
+
f"models {sorted(named)!r}; defaulting the reward monitor to "
|
|
757
|
+
f"{_DEFAULT_REWARD_RSKILL_DIR!r}. The reasoner re-checks each VLA's pairing "
|
|
758
|
+
"at dispatch."
|
|
759
|
+
)
|
|
760
|
+
return default
|
|
761
|
+
(target_name,) = tuple(named)
|
|
762
|
+
target_path = reward_index.get(target_name)
|
|
763
|
+
if target_path is None:
|
|
764
|
+
_console.print(
|
|
765
|
+
f"[yellow]warning:[/yellow] VLA(s) pair with reward model {target_name!r} "
|
|
766
|
+
" but no in-tree kind:reward rSkill declares that name; "
|
|
767
|
+
f"defaulting the reward monitor to {_DEFAULT_REWARD_RSKILL_DIR!r}."
|
|
768
|
+
)
|
|
769
|
+
return default
|
|
770
|
+
return str(target_path.resolve())
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
def _preflight_reward_vram_fit( # noqa: PLR0912 # reason: linear per-VLA classification (fit / oom / undeclared) + the three notify branches read clearest inline
|
|
774
|
+
*,
|
|
775
|
+
repo_root: Path,
|
|
776
|
+
description: RobotDescription,
|
|
777
|
+
reward_manifest_path: str,
|
|
778
|
+
gpu_budget_gb: float,
|
|
779
|
+
commercial_deployment: bool = False,
|
|
780
|
+
) -> None:
|
|
781
|
+
"""Fail fast before launch when no VLA can co-reside with the reward model.
|
|
782
|
+
|
|
783
|
+
A VLA emits no success signal of its own, so it must run with its reward model
|
|
784
|
+
resident alongside it. The reasoner enforces this per-VLA at
|
|
785
|
+
dispatch (``_refuse_unfittable_vla``) — but only *after* ROS is up. This is the
|
|
786
|
+
pre-LAUNCH gate: build the same capability-matched VLA palette the reasoner
|
|
787
|
+
will, and run :func:`openral_core.schemas.assert_vla_reward_fits` for each VLA
|
|
788
|
+
against the reward model + the GPU budget.
|
|
789
|
+
|
|
790
|
+
The contract mirrors :func:`_preflight_palette_deps`: it is advisory per-VLA
|
|
791
|
+
(the reasoner drops a non-fitting VLA from dispatch anyway) and a HARD gate only
|
|
792
|
+
when the palette would be empty of *runnable* policies — i.e. **no** matched VLA
|
|
793
|
+
can dispatch with the reward model resident. In that case the deploy could
|
|
794
|
+
actuate nothing, so we notify and ``typer.Exit(1)`` before bringing up ROS
|
|
795
|
+
instead of booting a graph that dispatches a VLA blind or OOMs mid-run.
|
|
796
|
+
|
|
797
|
+
``gpu_budget_gb`` is *free* VRAM at launch (nothing of ours is loaded yet), not
|
|
798
|
+
total — so a desktop compositor or a sibling worktree's process holding GBs is
|
|
799
|
+
counted against the pair, which is the whole point (it's the difference between
|
|
800
|
+
a preflight that greenlights an OOM and one that catches it).
|
|
801
|
+
|
|
802
|
+
Skipped (returns) when ``gpu_budget_gb <= 0.0`` (budget unreadable — defer to the
|
|
803
|
+
reasoner's runtime check), when no reward model is active, or when the robot has
|
|
804
|
+
no capability-matched VLA palette to check.
|
|
805
|
+
"""
|
|
806
|
+
if gpu_budget_gb <= 0.0 or not reward_manifest_path:
|
|
807
|
+
return
|
|
808
|
+
from openral_core import RSkillManifest
|
|
809
|
+
from openral_core.exceptions import ROSGPUMemoryError
|
|
810
|
+
from openral_core.schemas import assert_vla_reward_fits
|
|
811
|
+
|
|
812
|
+
try:
|
|
813
|
+
reward = RSkillManifest.from_yaml(reward_manifest_path)
|
|
814
|
+
except (OSError, ValueError) as exc:
|
|
815
|
+
_console.print(
|
|
816
|
+
f"[red]config error:[/red] reward monitor manifest {reward_manifest_path!r} "
|
|
817
|
+
f"failed to load (VLA+reward VRAM preflight): {exc}"
|
|
818
|
+
)
|
|
819
|
+
raise typer.Exit(code=1) from exc
|
|
820
|
+
|
|
821
|
+
vlas = [
|
|
822
|
+
m
|
|
823
|
+
for m in _capability_matched_manifests(
|
|
824
|
+
repo_root, description, commercial_deployment=commercial_deployment
|
|
825
|
+
)
|
|
826
|
+
if m.kind == "vla"
|
|
827
|
+
]
|
|
828
|
+
if not vlas:
|
|
829
|
+
return
|
|
830
|
+
|
|
831
|
+
fits: list[str] = []
|
|
832
|
+
oom: list[str] = []
|
|
833
|
+
undeclared: list[str] = []
|
|
834
|
+
for vla in vlas:
|
|
835
|
+
try:
|
|
836
|
+
combined = assert_vla_reward_fits(vla, reward, gpu_budget_gb)
|
|
837
|
+
except ROSGPUMemoryError as exc:
|
|
838
|
+
oom.append(f"{vla.name}: {exc}")
|
|
839
|
+
except ROSConfigError:
|
|
840
|
+
# min_vram_gb undeclared for the active dtype — the pair cannot be
|
|
841
|
+
# verified, so the reasoner will refuse this VLA at dispatch too.
|
|
842
|
+
undeclared.append(vla.name)
|
|
843
|
+
else:
|
|
844
|
+
fits.append(f"{vla.name} ({combined:.2f} GB)")
|
|
845
|
+
|
|
846
|
+
if not fits:
|
|
847
|
+
_console.print()
|
|
848
|
+
_console.print(
|
|
849
|
+
"[red]preflight failed:[/red] no capability-matched VLA can co-reside with "
|
|
850
|
+
f"the reward model {reward.name!r} on this GPU "
|
|
851
|
+
f"({gpu_budget_gb:.2f} GB free at launch) — every paired policy would be "
|
|
852
|
+
"refused at dispatch, so the deploy could actuate nothing."
|
|
853
|
+
)
|
|
854
|
+
for line in oom:
|
|
855
|
+
_console.print(f" • too large: {line}")
|
|
856
|
+
if undeclared:
|
|
857
|
+
_console.print(
|
|
858
|
+
" • undeclared min_vram_gb (cannot verify the required co-residency): "
|
|
859
|
+
f"{undeclared!r}"
|
|
860
|
+
)
|
|
861
|
+
_console.print(
|
|
862
|
+
" Remedies: use a smaller-footprint VLA/reward pair or a larger GPU; "
|
|
863
|
+
"declare min_vram_gb on the VLA manifest(s); or run with "
|
|
864
|
+
"--no-enable-reward-monitor (accepting the VLA runs without a live reward "
|
|
865
|
+
"signal)."
|
|
866
|
+
)
|
|
867
|
+
raise typer.Exit(code=1)
|
|
868
|
+
|
|
869
|
+
if oom:
|
|
870
|
+
_console.print(
|
|
871
|
+
f"[yellow]preflight:[/yellow] {len(oom)} VLA(s) cannot fit beside the reward "
|
|
872
|
+
f"model {reward.name!r} in {gpu_budget_gb:.2f} GB free and will be refused at "
|
|
873
|
+
"dispatch:"
|
|
874
|
+
)
|
|
875
|
+
for line in oom:
|
|
876
|
+
_console.print(f" • {line}")
|
|
877
|
+
if undeclared:
|
|
878
|
+
_console.print(
|
|
879
|
+
f"[yellow]preflight:[/yellow] {len(undeclared)} VLA(s) do not declare "
|
|
880
|
+
"min_vram_gb for their active dtype, so the reward pairing cannot be "
|
|
881
|
+
f"verified and the reasoner will refuse them while a reward model is active "
|
|
882
|
+
f": {undeclared!r}"
|
|
883
|
+
)
|
|
884
|
+
_console.print(
|
|
885
|
+
f"[green]preflight:[/green] {len(fits)} VLA(s) fit beside reward "
|
|
886
|
+
f"{reward.name!r} in {gpu_budget_gb:.2f} GB free: {fits!r}"
|
|
887
|
+
)
|
|
888
|
+
|
|
889
|
+
|
|
890
|
+
def resolve_launch_invocation( # noqa: PLR0912, PLR0915 # reason: a flat resolve sequence (robot_id → manifest → per-feature slam/nav2/octomap + sim/real hal_mode gating); splitting hurts readability
|
|
891
|
+
*,
|
|
892
|
+
config: Path | None = None,
|
|
893
|
+
robot_override: str | None,
|
|
894
|
+
dashboard_port: int,
|
|
895
|
+
reset_to_pose_service: str | None,
|
|
896
|
+
approach_skill_id: str | None = None,
|
|
897
|
+
dataset_out: str | None = None,
|
|
898
|
+
dataset_repo_id: str | None = None,
|
|
899
|
+
dataset_license: str | None = None,
|
|
900
|
+
deploy_config: Path | None = None,
|
|
901
|
+
hal_param_overrides: dict[str, object] | None = None,
|
|
902
|
+
hal_mode: str = "sim",
|
|
903
|
+
enable_slam: bool | None = None,
|
|
904
|
+
enable_nav2: bool | None = None,
|
|
905
|
+
enable_octomap: bool | None = None,
|
|
906
|
+
enable_octomap_kernel_check: bool | None = None,
|
|
907
|
+
enable_object_detector: bool | None = None,
|
|
908
|
+
object_detector_onnx: Path | None = None,
|
|
909
|
+
object_detector_manifest: str | None = None,
|
|
910
|
+
object_detector_query: str | None = None,
|
|
911
|
+
enable_reward_monitor: bool | None = None,
|
|
912
|
+
reward_monitor_manifest: str | None = None,
|
|
913
|
+
reward_monitor_task: str | None = None,
|
|
914
|
+
enable_critic: bool | None = None,
|
|
915
|
+
object_detector_locators: list[str] | None = None,
|
|
916
|
+
spatial_memory_ingest: bool | None = None,
|
|
917
|
+
memory_dir: str | None = None,
|
|
918
|
+
enable_dashboard: bool = True,
|
|
919
|
+
enable_foxglove: bool = False,
|
|
920
|
+
foxglove_port: int = 8765,
|
|
921
|
+
initial_task_prompt: str | None = None,
|
|
922
|
+
) -> LaunchInvocation:
|
|
923
|
+
"""Resolve every input into the ``ros2 launch`` argv to execute.
|
|
924
|
+
|
|
925
|
+
Shared by ``openral deploy sim`` (``hal_mode="sim"``) and ``openral deploy
|
|
926
|
+
run`` (``hal_mode="real"``) from the same ``DeployScene`` config. In real
|
|
927
|
+
mode the sim-twin / scene-attach injections are skipped so the HAL node
|
|
928
|
+
builds the real hardware HAL via ``build_hal(mode="real")``.
|
|
929
|
+
|
|
930
|
+
Returned ``argv_template`` carries a ``HAL_PARAMS_FILE_PLACEHOLDER``
|
|
931
|
+
sentinel the caller substitutes after writing the ephemeral HAL params
|
|
932
|
+
YAML. No envelope file is ever written — the launch reads ``robot_yaml``
|
|
933
|
+
and feeds the kernel via ROS params.
|
|
934
|
+
"""
|
|
935
|
+
from openral_core import DeployScene, RobotDescription # reason: defer schema import
|
|
936
|
+
|
|
937
|
+
if hal_mode not in ("sim", "real"):
|
|
938
|
+
raise ROSConfigError(f"hal_mode must be 'sim' or 'real', got {hal_mode!r}.")
|
|
939
|
+
|
|
940
|
+
deploy_scene = DeployScene.from_yaml(str(config)) if config is not None else None
|
|
941
|
+
scene_robot_id = (
|
|
942
|
+
deploy_scene.robot_id
|
|
943
|
+
if deploy_scene is not None and deploy_scene.robot_id is not None
|
|
944
|
+
else _load_scene_robot_id(config)
|
|
945
|
+
if config is not None
|
|
946
|
+
else None
|
|
947
|
+
)
|
|
948
|
+
robot_id = robot_override or scene_robot_id
|
|
949
|
+
if not robot_id:
|
|
950
|
+
raise ROSConfigError(
|
|
951
|
+
"robot_id is undefined: pass ``--robot <id>`` or (for ``deploy sim``) "
|
|
952
|
+
"set ``robot_id:`` in the DeployScene YAML / use a fixed-robot scene."
|
|
953
|
+
)
|
|
954
|
+
|
|
955
|
+
# The deploy startup prompt comes ONLY from the operator (--initial-task /
|
|
956
|
+
# a live /openral/prompt). Deploy never reads sim-predefined scene tasks —
|
|
957
|
+
# that is `sim run`'s job (deploy ≠ benchmark).
|
|
958
|
+
_resolved_initial_prompt: str = initial_task_prompt or ""
|
|
959
|
+
|
|
960
|
+
# DeployScene.runtime — the committed deploy posture. Field-by-field
|
|
961
|
+
# precedence: explicit CLI flag > scene runtime > auto/built-in default
|
|
962
|
+
# (the per-feature autos below). None on both = auto, as before.
|
|
963
|
+
rt = deploy_scene.runtime if deploy_scene is not None else None
|
|
964
|
+
if rt is not None:
|
|
965
|
+
scene_dir = config.parent if config is not None else None
|
|
966
|
+
|
|
967
|
+
def _scene_path(value: str | None) -> str | None:
|
|
968
|
+
# A relative path that exists next to the scene YAML resolves
|
|
969
|
+
# against it (CWD-independent committed workcells); anything else
|
|
970
|
+
# (alias, repo-relative default, hf:// URI) passes through verbatim.
|
|
971
|
+
if value and scene_dir is not None and not Path(value).is_absolute():
|
|
972
|
+
cand = (scene_dir / value).resolve()
|
|
973
|
+
if cand.exists():
|
|
974
|
+
return str(cand)
|
|
975
|
+
return value
|
|
976
|
+
|
|
977
|
+
enable_slam = enable_slam if enable_slam is not None else rt.enable_slam
|
|
978
|
+
enable_nav2 = enable_nav2 if enable_nav2 is not None else rt.enable_nav2
|
|
979
|
+
enable_octomap = enable_octomap if enable_octomap is not None else rt.enable_octomap
|
|
980
|
+
if enable_octomap_kernel_check is None:
|
|
981
|
+
enable_octomap_kernel_check = rt.enable_octomap_kernel_check
|
|
982
|
+
if enable_object_detector is None:
|
|
983
|
+
enable_object_detector = rt.enable_object_detector
|
|
984
|
+
if object_detector_onnx is None and rt.object_detector_onnx:
|
|
985
|
+
object_detector_onnx = Path(_scene_path(rt.object_detector_onnx) or "")
|
|
986
|
+
object_detector_manifest = object_detector_manifest or _scene_path(
|
|
987
|
+
rt.object_detector_manifest
|
|
988
|
+
)
|
|
989
|
+
object_detector_query = object_detector_query or rt.object_detector_query
|
|
990
|
+
if object_detector_locators is None:
|
|
991
|
+
object_detector_locators = rt.object_detector_locators
|
|
992
|
+
if enable_reward_monitor is None:
|
|
993
|
+
enable_reward_monitor = rt.enable_reward_monitor
|
|
994
|
+
reward_monitor_manifest = reward_monitor_manifest or _scene_path(rt.reward_monitor_manifest)
|
|
995
|
+
reward_monitor_task = reward_monitor_task or rt.reward_monitor_task
|
|
996
|
+
if enable_critic is None:
|
|
997
|
+
enable_critic = rt.enable_critic
|
|
998
|
+
if spatial_memory_ingest is None:
|
|
999
|
+
spatial_memory_ingest = rt.spatial_memory_ingest
|
|
1000
|
+
approach_skill_id = approach_skill_id or rt.approach_skill_id
|
|
1001
|
+
# Built-in defaults for the tri-state flags nothing pinned.
|
|
1002
|
+
if enable_octomap_kernel_check is None:
|
|
1003
|
+
enable_octomap_kernel_check = True
|
|
1004
|
+
if enable_reward_monitor is None:
|
|
1005
|
+
enable_reward_monitor = False
|
|
1006
|
+
if enable_critic is None:
|
|
1007
|
+
enable_critic = False
|
|
1008
|
+
|
|
1009
|
+
# a --robot override that differs from the scene's declared robot
|
|
1010
|
+
# composes a different arm than the scene was authored for. The scene's cameras
|
|
1011
|
+
# + asset mounts (e.g. tabletop_push's wrist_camera_mount_body="gripper") are
|
|
1012
|
+
# tuned for the declared robot, so on the override they may be mis-mounted or
|
|
1013
|
+
# unmatched — some /openral/cameras/* topics will be empty. Warn loudly.
|
|
1014
|
+
if robot_override and scene_robot_id and robot_override != scene_robot_id:
|
|
1015
|
+
_console.print(
|
|
1016
|
+
f"[yellow]warning:[/yellow] --robot {robot_override!r} overrides the scene's "
|
|
1017
|
+
f"declared robot_id {scene_robot_id!r}; the scene's cameras + asset mounts are "
|
|
1018
|
+
f"authored for {scene_robot_id!r} and may not match {robot_override!r} (expect "
|
|
1019
|
+
"empty /openral/cameras/* for non-matching sensors). Override only with a "
|
|
1020
|
+
"kinematically-compatible arm on a free-axis scene."
|
|
1021
|
+
)
|
|
1022
|
+
|
|
1023
|
+
hal = _ROBOT_HAL_REGISTRY.get(robot_id)
|
|
1024
|
+
if hal is None:
|
|
1025
|
+
supported = ", ".join(sorted(_ROBOT_HAL_REGISTRY))
|
|
1026
|
+
raise ROSConfigError(
|
|
1027
|
+
f"robot {robot_id!r} has no HAL entry in _ROBOT_HAL_REGISTRY. "
|
|
1028
|
+
f"Supported: {supported}. The registry key matches the "
|
|
1029
|
+
"``robots/<robot_id>/`` directory name — e.g. ``--robot "
|
|
1030
|
+
"franka_panda`` (not ``--robot franka``). To add a new robot, "
|
|
1031
|
+
"ship a ROS ``openral_hal_<X>`` package with a "
|
|
1032
|
+
"``lifecycle_node.py`` executable, add it to the ros2-build "
|
|
1033
|
+
"Justfile target, and register it here."
|
|
1034
|
+
)
|
|
1035
|
+
|
|
1036
|
+
repo_root = _repo_root_from(Path(__file__))
|
|
1037
|
+
robot_yaml = repo_root / "robots" / robot_id / "robot.yaml"
|
|
1038
|
+
if not robot_yaml.is_file():
|
|
1039
|
+
raise ROSConfigError(
|
|
1040
|
+
f"robot manifest not found at {robot_yaml}; expected robots/{robot_id}/robot.yaml."
|
|
1041
|
+
)
|
|
1042
|
+
|
|
1043
|
+
# Validate the manifest carries the e2e fields (joint position /
|
|
1044
|
+
# velocity / effort limits) and assert the manifest's declared name
|
|
1045
|
+
# is one the registered HAL accepts — protects against a typo
|
|
1046
|
+
# routing the wrong HAL at a robot.
|
|
1047
|
+
description = RobotDescription.from_yaml(str(robot_yaml))
|
|
1048
|
+
description.validate_for_e2e_pipeline()
|
|
1049
|
+
|
|
1050
|
+
# fail fast before shelling the launch if real mode is asked of
|
|
1051
|
+
# a simulation-only robot (better UX than a graph that dies at HAL
|
|
1052
|
+
# configure with the same ROSCapabilityMismatch).
|
|
1053
|
+
if hal_mode == "real" and description.hal.real is None:
|
|
1054
|
+
raise ROSCapabilityMismatch(
|
|
1055
|
+
f"robot {robot_id!r} has no real-hardware HAL (hal.real is null); it is "
|
|
1056
|
+
"simulation-only. Use `openral deploy sim` instead of `openral deploy run`."
|
|
1057
|
+
)
|
|
1058
|
+
|
|
1059
|
+
# SLAM is ON BY DEFAULT for every robot that *can* run it:
|
|
1060
|
+
# i.e. one that declares a lidar (the scan source slam_toolbox needs). This
|
|
1061
|
+
# is the firm default — a SLAM-capable robot always brings up the `map` frame
|
|
1062
|
+
# the object lift / spatial-memory ingest depend on, unless the operator
|
|
1063
|
+
# opts out with `--no-enable-slam`. Fixed-base arms (no mobile base, no lidar)
|
|
1064
|
+
# correctly stay off — there is no base to localise and nothing to map.
|
|
1065
|
+
# `enable_slam is None` means "auto": honour the manifest; an explicit flag wins.
|
|
1066
|
+
# SLAM is on for any robot that can localise/map: a lidar
|
|
1067
|
+
# (slam_toolbox) OR camera-based visual SLAM (cuVSLAM+nvblox, for
|
|
1068
|
+
# lidar-less robots). Fixed-base arms with neither correctly stay off.
|
|
1069
|
+
if enable_slam is None:
|
|
1070
|
+
enable_slam = bool(
|
|
1071
|
+
description.capabilities.has_lidar or description.capabilities.has_vision_slam
|
|
1072
|
+
)
|
|
1073
|
+
# backend selection (pure helper, unit-tested directly).
|
|
1074
|
+
slam_backend = _resolve_slam_backend(
|
|
1075
|
+
has_lidar=bool(description.capabilities.has_lidar),
|
|
1076
|
+
has_vision_slam=bool(description.capabilities.has_vision_slam),
|
|
1077
|
+
enable_slam=enable_slam,
|
|
1078
|
+
)
|
|
1079
|
+
# Nav2 auto-enables alongside slam_toolbox: every
|
|
1080
|
+
# lidar-equipped mobile robot needs a planner to consume the map.
|
|
1081
|
+
# Operators that want the map alone (recording / inspection) pass
|
|
1082
|
+
# ``--no-enable-nav2``.
|
|
1083
|
+
if enable_nav2 is None:
|
|
1084
|
+
enable_nav2 = enable_slam
|
|
1085
|
+
# the octomap world-collision leg auto-enables when the
|
|
1086
|
+
# robot manifest declares a usable depth SensorSpec (a camera the HAL
|
|
1087
|
+
# can ray-cast a PointCloud2 from); there is nothing to map otherwise.
|
|
1088
|
+
# ``--enable-octomap`` / ``--no-enable-octomap`` overrides.
|
|
1089
|
+
if enable_octomap is None:
|
|
1090
|
+
enable_octomap = any(
|
|
1091
|
+
s.modality in ("depth", "point_cloud") and s.intrinsics is not None
|
|
1092
|
+
for s in description.sensors
|
|
1093
|
+
)
|
|
1094
|
+
clock_origin = _resolve_clock_origin(hal_mode=hal_mode, config=config)
|
|
1095
|
+
|
|
1096
|
+
# The object-detection leg is ON by default (deploy sim is a
|
|
1097
|
+
# perception-driven stack; ``--no-object-detector`` turns it off). The default
|
|
1098
|
+
# backend is the open-vocabulary ``omdet-turbo-indoor`` continuous detector,
|
|
1099
|
+
# which grounds arbitrary indoor/kitchen objects instead of the fixed COCO-80
|
|
1100
|
+
# of RT-DETR; when its runtime deps (transformers/timm — the ``omdet`` group)
|
|
1101
|
+
# are not importable we gracefully fall back to the in-tree RT-DETR COCO ONNX
|
|
1102
|
+
# so the graph still comes up. Explicit ``--object-detector-manifest`` /
|
|
1103
|
+
# ``--object-detector-onnx`` override the default selection.
|
|
1104
|
+
default_rtdetr_onnx = repo_root / "rskills" / "rtdetr-coco-r18" / "model.onnx"
|
|
1105
|
+
default_omdet_manifest = repo_root / "rskills" / "omdet-turbo-indoor" / "rskill.yaml"
|
|
1106
|
+
if object_detector_manifest:
|
|
1107
|
+
resolved_object_detector_manifest = str(Path(object_detector_manifest).resolve())
|
|
1108
|
+
resolved_object_detector_onnx = (
|
|
1109
|
+
object_detector_onnx.resolve()
|
|
1110
|
+
if object_detector_onnx is not None
|
|
1111
|
+
else default_rtdetr_onnx
|
|
1112
|
+
)
|
|
1113
|
+
elif object_detector_onnx is not None:
|
|
1114
|
+
# Explicit ONNX → the legacy fixed-label RT-DETR path (no manifest).
|
|
1115
|
+
resolved_object_detector_manifest = ""
|
|
1116
|
+
resolved_object_detector_onnx = object_detector_onnx.resolve()
|
|
1117
|
+
elif _omdet_runtime_available():
|
|
1118
|
+
resolved_object_detector_manifest = str(default_omdet_manifest.resolve())
|
|
1119
|
+
resolved_object_detector_onnx = default_rtdetr_onnx
|
|
1120
|
+
else:
|
|
1121
|
+
# Graceful fallback: omdet deps absent → in-tree RT-DETR COCO-80 ONNX.
|
|
1122
|
+
resolved_object_detector_manifest = ""
|
|
1123
|
+
resolved_object_detector_onnx = default_rtdetr_onnx
|
|
1124
|
+
|
|
1125
|
+
if enable_object_detector is None:
|
|
1126
|
+
enable_object_detector = True
|
|
1127
|
+
# Downgrade to off (rather than let the node hard-fail at backend build) when
|
|
1128
|
+
# the detector is requested but no usable backend is present — a checkout that
|
|
1129
|
+
# has neither the omdet deps nor the gitignored RT-DETR ONNX weights.
|
|
1130
|
+
if (
|
|
1131
|
+
enable_object_detector
|
|
1132
|
+
and not resolved_object_detector_manifest
|
|
1133
|
+
and not _object_detector_onnx_present(resolved_object_detector_onnx)
|
|
1134
|
+
):
|
|
1135
|
+
_console.print(
|
|
1136
|
+
"[yellow]object detector requested but no backend is available[/yellow] "
|
|
1137
|
+
"(omdet deps not importable and RT-DETR ONNX missing at "
|
|
1138
|
+
f"{resolved_object_detector_onnx}); disabling the detector leg. Run "
|
|
1139
|
+
"`just sync --group omdet --inexact` for the open-vocab default, fetch "
|
|
1140
|
+
"the RT-DETR weights, or pass --no-object-detector to silence."
|
|
1141
|
+
)
|
|
1142
|
+
enable_object_detector = False
|
|
1143
|
+
|
|
1144
|
+
# When the leg is off (explicit --no-object-detector or the downgrade above),
|
|
1145
|
+
# do not forward a continuous-detector manifest: ros2 launch would otherwise
|
|
1146
|
+
# carry a manifest for a node that never starts (and, with omdet deps present,
|
|
1147
|
+
# a non-empty manifest:= for a disabled leg is simply wrong).
|
|
1148
|
+
if not enable_object_detector:
|
|
1149
|
+
resolved_object_detector_manifest = ""
|
|
1150
|
+
|
|
1151
|
+
# on-demand open-vocab locators co-resident alongside the
|
|
1152
|
+
# continuous detector. Each token is a manifest path (``…/rskill.yaml``) or a
|
|
1153
|
+
# short alias resolved to ``rskills/<alias>/rskill.yaml``; the launch builds one
|
|
1154
|
+
# namespaced locate_in_view node per entry so the reasoner can pick a model.
|
|
1155
|
+
# Default = omdet-turbo-locator when the detector is on and the omdet deps are
|
|
1156
|
+
# importable (LocateAnything is opt-in — NVIDIA non-commercial, 5 GB VRAM).
|
|
1157
|
+
resolved_object_detector_locators: list[str] = []
|
|
1158
|
+
# An explicit ``--object-detector-locator`` is an independent grounding source —
|
|
1159
|
+
# honour it even with ``--no-object-detector`` (a lean deploy that grounds via
|
|
1160
|
+
# the on-demand locator alone, no always-on detector holding VRAM). The implicit
|
|
1161
|
+
# omdet-turbo-locator default only applies when the continuous detector is on.
|
|
1162
|
+
if object_detector_locators is not None:
|
|
1163
|
+
locator_tokens = object_detector_locators
|
|
1164
|
+
elif enable_object_detector and _omdet_runtime_available():
|
|
1165
|
+
locator_tokens = ["omdet-turbo-locator"]
|
|
1166
|
+
else:
|
|
1167
|
+
locator_tokens = []
|
|
1168
|
+
for token in locator_tokens:
|
|
1169
|
+
candidate = Path(token)
|
|
1170
|
+
manifest_path = (
|
|
1171
|
+
candidate
|
|
1172
|
+
if candidate.suffix == ".yaml"
|
|
1173
|
+
else repo_root / "rskills" / token / "rskill.yaml"
|
|
1174
|
+
)
|
|
1175
|
+
resolved_object_detector_locators.append(str(manifest_path.resolve()))
|
|
1176
|
+
|
|
1177
|
+
# auto-enable durable spatial-memory ingest whenever the object
|
|
1178
|
+
# detector runs (the producer that feeds it); an explicit flag overrides.
|
|
1179
|
+
if spatial_memory_ingest is None:
|
|
1180
|
+
spatial_memory_ingest = enable_object_detector
|
|
1181
|
+
|
|
1182
|
+
if description.name not in hal.supported_robot_names:
|
|
1183
|
+
raise ROSConfigError(
|
|
1184
|
+
f"HAL/robot mismatch: robot_id={robot_id!r} (registry → "
|
|
1185
|
+
f"{hal.package!r}) declares supported_robot_names="
|
|
1186
|
+
f"{sorted(hal.supported_robot_names)}, but "
|
|
1187
|
+
f"{robot_yaml} has name={description.name!r}. Either fix the "
|
|
1188
|
+
"manifest's `name:` field or register the right HAL for this "
|
|
1189
|
+
"robot in openral_cli.deploy_sim._ROBOT_HAL_REGISTRY."
|
|
1190
|
+
)
|
|
1191
|
+
|
|
1192
|
+
hal_params: dict[str, object] = {**hal.default_params}
|
|
1193
|
+
# a DeployScene ``hal:`` binding carries the workcell's
|
|
1194
|
+
# host-specific HAL defaults (serial ``port`` + lerobot ``id`` /
|
|
1195
|
+
# ``calibration_dir`` + ``calibrate_on_connect``), the HAL analogue of a
|
|
1196
|
+
# sensor ``deploy_binding``, so ``deploy run --config <scene>`` is
|
|
1197
|
+
# self-contained (no ``--hal`` needed). Merged above the robot-manifest
|
|
1198
|
+
# defaults (which the HAL node reads from ``robot.yaml``) but below any
|
|
1199
|
+
# explicit ``--hal`` override. A relative ``calibration_dir`` resolves
|
|
1200
|
+
# against the scene file's dir — mirrors the ``--hal calibration_dir=``
|
|
1201
|
+
# handling in ``main.deploy_run`` so a committed calibration works from any CWD.
|
|
1202
|
+
if deploy_scene is not None and deploy_scene.hal is not None and config is not None:
|
|
1203
|
+
scene_hal = dict(deploy_scene.hal.defaults)
|
|
1204
|
+
_scene_cal_dir = scene_hal.get("calibration_dir")
|
|
1205
|
+
if (
|
|
1206
|
+
isinstance(_scene_cal_dir, str)
|
|
1207
|
+
and _scene_cal_dir
|
|
1208
|
+
and not Path(_scene_cal_dir).is_absolute()
|
|
1209
|
+
):
|
|
1210
|
+
scene_hal["calibration_dir"] = str((config.parent / _scene_cal_dir).resolve())
|
|
1211
|
+
hal_params.update(scene_hal)
|
|
1212
|
+
if hal_param_overrides:
|
|
1213
|
+
hal_params.update(hal_param_overrides)
|
|
1214
|
+
|
|
1215
|
+
# derive the /scan envelope from robot.yaml's lidar_2d
|
|
1216
|
+
# sensor (single source of truth) instead of hardcoding it in the
|
|
1217
|
+
# HAL registry. ``setdefault`` so an explicit ``--hal scan_*=…``
|
|
1218
|
+
# operator override still wins.
|
|
1219
|
+
for _scan_key, _scan_value in _scan_params_from_description(description).items():
|
|
1220
|
+
hal_params.setdefault(_scan_key, _scan_value)
|
|
1221
|
+
|
|
1222
|
+
# forward the sim config to HALs that declare
|
|
1223
|
+
# `sim_env_yaml` support. Gated on the per-HAL opt-in flag because
|
|
1224
|
+
# rclpy rejects unknown parameters at startup
|
|
1225
|
+
# (`automatically_declare_parameters_from_overrides=False` is the
|
|
1226
|
+
# default), so blanket-forwarding would break every other HAL.
|
|
1227
|
+
if hal.supports_sim_env_yaml and hal_mode == "sim" and config is not None:
|
|
1228
|
+
hal_params.setdefault("sim_env_yaml", str(config.resolve()))
|
|
1229
|
+
|
|
1230
|
+
# manifest-driven nodes build their HAL via build_hal(mode).
|
|
1231
|
+
# `deploy sim` → hal_mode="sim"; `deploy run` → hal_mode="real". The node
|
|
1232
|
+
# raises ROSCapabilityMismatch for a sim-only-vs-real mismatch.
|
|
1233
|
+
if hal.manifest_driven:
|
|
1234
|
+
hal_params.setdefault("robot_yaml", str(robot_yaml))
|
|
1235
|
+
hal_params.setdefault("hal_mode", hal_mode)
|
|
1236
|
+
# deploy sim is inherently a scene; forward the resolved
|
|
1237
|
+
# config so the manifest-driven node scene-attaches (SimAttachedHAL)
|
|
1238
|
+
# instead of building a bare twin. Sim mode only; real never attaches.
|
|
1239
|
+
# `bare_twin_sim` arms (so100 / so101) opt out: they build a bare
|
|
1240
|
+
# `MujocoArmHAL` twin from their `sim:` block (issue #191 Phase 2),
|
|
1241
|
+
# preserving the pre-migration `supports_sim_robot_yaml` behaviour.
|
|
1242
|
+
if hal_mode == "sim" and config is not None and not hal.bare_twin_sim:
|
|
1243
|
+
hal_params.setdefault("sim_env_yaml", str(config.resolve()))
|
|
1244
|
+
# forward the DeployScene's own MJCF composition (its arena)
|
|
1245
|
+
# to the manifest-driven node so the SCENE owns its environment instead
|
|
1246
|
+
# of the robot manifest. Sim-mode bare-twin robots only (scene-attach
|
|
1247
|
+
# robots build the scene's SimRollout directly via sim_env_yaml).
|
|
1248
|
+
if hal_mode == "sim" and deploy_scene is not None and hal.bare_twin_sim:
|
|
1249
|
+
scene_composition = deploy_scene.composition
|
|
1250
|
+
if scene_composition is not None:
|
|
1251
|
+
hal_params.setdefault("scene_composition_json", scene_composition.model_dump_json())
|
|
1252
|
+
|
|
1253
|
+
service = reset_to_pose_service or f"/openral/{robot_id}/reset_to_pose"
|
|
1254
|
+
# Empty by default — the legacy ResetToPose snap stays until a move_group is
|
|
1255
|
+
# wired into the graph; opt in with --approach-skill-id.
|
|
1256
|
+
approach_skill = approach_skill_id or ""
|
|
1257
|
+
|
|
1258
|
+
argv_template: list[str] = [
|
|
1259
|
+
"ros2",
|
|
1260
|
+
"launch",
|
|
1261
|
+
"openral_rskill_ros",
|
|
1262
|
+
"sim_e2e.launch.py",
|
|
1263
|
+
f"robot_yaml:={robot_yaml}",
|
|
1264
|
+
f"hal_package:={hal.package}",
|
|
1265
|
+
f"hal_executable:={hal.executable}",
|
|
1266
|
+
f"hal_node_name:={hal.node_name}",
|
|
1267
|
+
"hal_params_file:=HAL_PARAMS_FILE_PLACEHOLDER",
|
|
1268
|
+
f"reset_to_pose_service:={service}",
|
|
1269
|
+
f"dashboard_port:={dashboard_port}",
|
|
1270
|
+
# forward the deploy path so the reasoner's action-mode
|
|
1271
|
+
# palette gate matches the HAL this graph brings up. ``deploy sim``
|
|
1272
|
+
# → ``hal_mode="sim"`` (default; the scene's robosuite OSC controller
|
|
1273
|
+
# synthesises cartesian/OSC modes); ``deploy run`` → ``"real"``.
|
|
1274
|
+
f"hal_mode:={hal_mode}",
|
|
1275
|
+
f"enable_slam:={'true' if enable_slam else 'false'}",
|
|
1276
|
+
f"slam_backend:={slam_backend}",
|
|
1277
|
+
f"enable_nav2:={'true' if enable_nav2 else 'false'}",
|
|
1278
|
+
f"enable_octomap:={'true' if enable_octomap else 'false'}",
|
|
1279
|
+
f"enable_octomap_kernel_check:={'true' if enable_octomap_kernel_check else 'false'}",
|
|
1280
|
+
# OpenRAL clock authority. The launch maps this to ROS
|
|
1281
|
+
# use_sim_time internally: simulation → use_sim_time=true + HAL /clock;
|
|
1282
|
+
# host_wall → system time and no OpenRAL /clock publisher.
|
|
1283
|
+
f"clock_origin:={clock_origin}",
|
|
1284
|
+
f"enable_object_detector:={'true' if enable_object_detector else 'false'}",
|
|
1285
|
+
f"object_detector_onnx:={resolved_object_detector_onnx}",
|
|
1286
|
+
# reward monitor co-active with the VLA; the reasoner polls
|
|
1287
|
+
# /openral/perception/query_task_progress when task_progress_available.
|
|
1288
|
+
f"enable_reward_monitor:={'true' if enable_reward_monitor else 'false'}",
|
|
1289
|
+
# Tier-C critic producer; emits FailureTrigger on
|
|
1290
|
+
# /openral/failure/critic when a reward model's score stalls.
|
|
1291
|
+
f"enable_critic:={'true' if enable_critic else 'false'}",
|
|
1292
|
+
f"spatial_memory_ingest:={'true' if spatial_memory_ingest else 'false'}",
|
|
1293
|
+
f"enable_dashboard:={'true' if enable_dashboard else 'false'}",
|
|
1294
|
+
# read-only Foxglove live-scene bridge. Off by default;
|
|
1295
|
+
# ``--foxglove`` opts in. The bridge starts after the topic producers
|
|
1296
|
+
# (HAL, SLAM, octomap, robot_state_publisher) via a TimerAction in the
|
|
1297
|
+
# launch to avoid the foxglove-sdk-cpp v0.18.0 stale-bridge bug.
|
|
1298
|
+
f"enable_foxglove:={'true' if enable_foxglove else 'false'}",
|
|
1299
|
+
f"foxglove_port:={foxglove_port}",
|
|
1300
|
+
]
|
|
1301
|
+
# ``ros2 launch`` rejects an empty ``name:=`` argument, so only forward the
|
|
1302
|
+
# optional detector overrides when set; the launch file defaults both to "".
|
|
1303
|
+
if resolved_object_detector_manifest:
|
|
1304
|
+
argv_template.append(f"object_detector_manifest:={resolved_object_detector_manifest}")
|
|
1305
|
+
if object_detector_query:
|
|
1306
|
+
argv_template.append(f"object_detector_query:={object_detector_query}")
|
|
1307
|
+
# resolve the reward-monitor manifest from the VLA pairing when
|
|
1308
|
+
# the operator did not pin one. ``deploy sim`` does not preselect a VLA, so the
|
|
1309
|
+
# default is derived from the capability-matched VLA palette's
|
|
1310
|
+
# ``reward_rskill_name`` (the pairing the reasoner will honour) instead of an
|
|
1311
|
+
# independent flag. Only when the reward monitor is active; otherwise empty.
|
|
1312
|
+
resolved_reward_monitor_manifest = (
|
|
1313
|
+
_resolve_reward_monitor_manifest(
|
|
1314
|
+
repo_root=repo_root,
|
|
1315
|
+
description=description,
|
|
1316
|
+
explicit_manifest=reward_monitor_manifest,
|
|
1317
|
+
)
|
|
1318
|
+
if enable_reward_monitor
|
|
1319
|
+
else ""
|
|
1320
|
+
)
|
|
1321
|
+
# Forward the resolved reward manifest (ros2 launch rejects an empty ``name:=``
|
|
1322
|
+
# value; the launch file defaults it to "" and the reasoner/monitor fall back
|
|
1323
|
+
# to the robometer default when unset).
|
|
1324
|
+
if resolved_reward_monitor_manifest:
|
|
1325
|
+
argv_template.append(f"reward_monitor_manifest:={resolved_reward_monitor_manifest}")
|
|
1326
|
+
# The reward monitor's always-on critic_score path scores against its
|
|
1327
|
+
# `task` param; an empty task makes `_publish_critic_score` silently skip
|
|
1328
|
+
# every tick. Default
|
|
1329
|
+
# it to the operator goal so a deploy with `--initial-task` gets a
|
|
1330
|
+
# background progress signal out of the box (an explicit
|
|
1331
|
+
# `--reward-monitor-task` still wins; the reasoner's `query_task_progress`
|
|
1332
|
+
# polls already carry the live subtask when it dispatches with a deadline).
|
|
1333
|
+
effective_reward_task = reward_monitor_task
|
|
1334
|
+
if not effective_reward_task and _resolved_initial_prompt:
|
|
1335
|
+
effective_reward_task = _resolved_initial_prompt.strip()
|
|
1336
|
+
if effective_reward_task:
|
|
1337
|
+
argv_template.append(f"reward_monitor_task:={effective_reward_task}")
|
|
1338
|
+
# only forward the locator list when non-empty (ros2 launch rejects
|
|
1339
|
+
# an empty ``name:=`` value; the launch file defaults it to "").
|
|
1340
|
+
if resolved_object_detector_locators:
|
|
1341
|
+
argv_template.append(
|
|
1342
|
+
"object_detector_locators:=" + ",".join(resolved_object_detector_locators)
|
|
1343
|
+
)
|
|
1344
|
+
# only forward the approach skill when opted in (empty default;
|
|
1345
|
+
# ros2 launch rejects an empty ``name:=`` value, and the launch file
|
|
1346
|
+
# defaults ``approach_skill_id`` to "").
|
|
1347
|
+
if approach_skill:
|
|
1348
|
+
argv_template.append(f"approach_skill_id:={approach_skill}")
|
|
1349
|
+
|
|
1350
|
+
# only forward the dataset args when recording is opted in
|
|
1351
|
+
# (empty defaults; ros2 launch rejects an empty ``name:=`` value, and the
|
|
1352
|
+
# launch file defaults all three so omitting them disables recording).
|
|
1353
|
+
if dataset_out:
|
|
1354
|
+
argv_template.append(f"dataset_out:={dataset_out}")
|
|
1355
|
+
if dataset_repo_id:
|
|
1356
|
+
argv_template.append(f"dataset_repo_id:={dataset_repo_id}")
|
|
1357
|
+
if dataset_license:
|
|
1358
|
+
argv_template.append(f"dataset_license:={dataset_license}")
|
|
1359
|
+
|
|
1360
|
+
if deploy_scene is not None and (
|
|
1361
|
+
deploy_scene.safety is not None or deploy_scene.extra_allowed_collision_pairs
|
|
1362
|
+
):
|
|
1363
|
+
argv_template.append(f"workcell_json:={deploy_scene.model_dump_json(exclude_unset=True)}")
|
|
1364
|
+
|
|
1365
|
+
# The deploy memory bundle. ``--memory-dir`` (CLI) wins;
|
|
1366
|
+
# otherwise the DeployScene's own ``memory_dir`` field. Derive the per-modality
|
|
1367
|
+
# launch paths by convention and forward them (each to its consumer's arg).
|
|
1368
|
+
effective_memory_dir = memory_dir
|
|
1369
|
+
if effective_memory_dir is None and deploy_scene is not None:
|
|
1370
|
+
effective_memory_dir = deploy_scene.memory_dir
|
|
1371
|
+
if effective_memory_dir:
|
|
1372
|
+
argv_template.extend(_memory_bundle_launch_args(effective_memory_dir))
|
|
1373
|
+
|
|
1374
|
+
# Forward the startup prompt only when non-empty. The launch
|
|
1375
|
+
# file defaults ``initial_task_prompt`` to "" (no prompt), so omitting it
|
|
1376
|
+
# leaves the reasoner in idle mode until an operator prompt arrives.
|
|
1377
|
+
if _resolved_initial_prompt:
|
|
1378
|
+
argv_template.append(f"initial_task_prompt:={_resolved_initial_prompt}")
|
|
1379
|
+
|
|
1380
|
+
# Real deploys — forward the DeployScene YAML so the runtime node
|
|
1381
|
+
# opens every deploy-bound sensor (robot manifest + scene `sensors:`)
|
|
1382
|
+
# and publishes the physical cameras onto
|
|
1383
|
+
# /openral/cameras/<name>/image (sim keeps the HAL bridge as the
|
|
1384
|
+
# only camera source; empty default in the launch file).
|
|
1385
|
+
if deploy_config is not None and hal_mode == "real":
|
|
1386
|
+
argv_template.append(f"deploy_config:={Path(deploy_config).resolve()}")
|
|
1387
|
+
|
|
1388
|
+
return LaunchInvocation(
|
|
1389
|
+
robot_id=robot_id,
|
|
1390
|
+
robot_yaml=robot_yaml,
|
|
1391
|
+
robot_manifest_name=description.name,
|
|
1392
|
+
hal=hal,
|
|
1393
|
+
enable_slam=enable_slam,
|
|
1394
|
+
slam_backend=slam_backend,
|
|
1395
|
+
enable_nav2=enable_nav2,
|
|
1396
|
+
enable_octomap=enable_octomap,
|
|
1397
|
+
clock_origin=clock_origin,
|
|
1398
|
+
enable_object_detector=enable_object_detector,
|
|
1399
|
+
object_detector_onnx=resolved_object_detector_onnx,
|
|
1400
|
+
object_detector_manifest=resolved_object_detector_manifest,
|
|
1401
|
+
object_detector_query=object_detector_query or "",
|
|
1402
|
+
object_detector_locators=tuple(resolved_object_detector_locators),
|
|
1403
|
+
spatial_memory_ingest=spatial_memory_ingest,
|
|
1404
|
+
hal_params=hal_params,
|
|
1405
|
+
hal_mode=hal_mode,
|
|
1406
|
+
reset_to_pose_service=service,
|
|
1407
|
+
approach_skill_id=approach_skill,
|
|
1408
|
+
enable_foxglove=enable_foxglove,
|
|
1409
|
+
foxglove_port=foxglove_port,
|
|
1410
|
+
initial_task_prompt=_resolved_initial_prompt,
|
|
1411
|
+
enable_reward_monitor=enable_reward_monitor,
|
|
1412
|
+
reward_monitor_manifest=resolved_reward_monitor_manifest,
|
|
1413
|
+
argv_template=argv_template,
|
|
1414
|
+
)
|
|
1415
|
+
|
|
1416
|
+
|
|
1417
|
+
def _prepare_launch_env() -> dict[str, str]:
|
|
1418
|
+
"""Build the environment for the ``ros2 launch`` subprocess (deploy sim + deploy run).
|
|
1419
|
+
|
|
1420
|
+
Shared by both shelling paths so the wiring stays identical:
|
|
1421
|
+
|
|
1422
|
+
* Export ``OPENRAL_VENV_SITE`` + prepend the venv site / bin so the launch
|
|
1423
|
+
parser and every spawned node import ``openral_core`` from the workspace
|
|
1424
|
+
venv (the editable ``.pth`` files are processed via ``site.py``).
|
|
1425
|
+
* Default the **expandable-segments CUDA allocator**. The
|
|
1426
|
+
``runtime_node`` loads VLA weights (pi05 / molmoact2 …) onto the GPU; on a
|
|
1427
|
+
tight 8 GiB card the default allocator fragments and OOMs at the forward
|
|
1428
|
+
pass even for an NF4 model that otherwise fits (molmoact2-libero-nf4 peaks
|
|
1429
|
+
~7.6 GiB; without this it dies on a small alloc with ~165 MiB stuck in
|
|
1430
|
+
reserved-but-unallocated). ``setdefault`` so an operator override wins;
|
|
1431
|
+
both env-var spellings are set because the name was renamed across torch
|
|
1432
|
+
releases (``PYTORCH_CUDA_ALLOC_CONF`` → ``PYTORCH_ALLOC_CONF``).
|
|
1433
|
+
* Clean stale Fast-DDS SHM (``_apply_rmw_default``).
|
|
1434
|
+
"""
|
|
1435
|
+
env = os.environ.copy()
|
|
1436
|
+
venv_site = sysconfig.get_paths()["purelib"]
|
|
1437
|
+
env["OPENRAL_VENV_SITE"] = venv_site
|
|
1438
|
+
existing = env.get("PYTHONPATH", "")
|
|
1439
|
+
env["PYTHONPATH"] = f"{venv_site}{os.pathsep}{existing}" if existing else venv_site
|
|
1440
|
+
venv_bin = os.path.dirname(sys.executable)
|
|
1441
|
+
existing_path = env.get("PATH", "")
|
|
1442
|
+
env["PATH"] = f"{venv_bin}{os.pathsep}{existing_path}" if existing_path else venv_bin
|
|
1443
|
+
env.setdefault("PYTORCH_ALLOC_CONF", "expandable_segments:True")
|
|
1444
|
+
env.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
|
|
1445
|
+
_apply_rmw_default(env)
|
|
1446
|
+
return env
|
|
1447
|
+
|
|
1448
|
+
|
|
1449
|
+
def run_launch_invocation(invocation: LaunchInvocation, *, run_preflight: bool = True) -> int:
|
|
1450
|
+
"""Write the HAL params YAML, set the venv env, and shell ``ros2 launch``.
|
|
1451
|
+
|
|
1452
|
+
Returns the launch exit code. The shared shelling path used by both
|
|
1453
|
+
``openral deploy sim`` and ``openral deploy run``
|
|
1454
|
+
. Exports ``OPENRAL_VENV_SITE`` + prepends the venv bin/PATH so
|
|
1455
|
+
the launch parser and spawned nodes import ``openral_core`` from the
|
|
1456
|
+
workspace venv (the editable ``.pth`` files are processed via ``site.py``).
|
|
1457
|
+
``run_preflight`` probes the rSkill palette extras.
|
|
1458
|
+
"""
|
|
1459
|
+
if run_preflight:
|
|
1460
|
+
repo_root = _repo_root_from(Path(__file__))
|
|
1461
|
+
_preflight_palette_deps(
|
|
1462
|
+
repo_root=repo_root,
|
|
1463
|
+
robot_yaml=Path(invocation.robot_yaml),
|
|
1464
|
+
)
|
|
1465
|
+
# VLA↔reward VRAM pair preflight (deploy run path). No-op
|
|
1466
|
+
# unless a reward monitor is active and the GPU budget is readable.
|
|
1467
|
+
if invocation.enable_reward_monitor and invocation.reward_monitor_manifest:
|
|
1468
|
+
from openral_core import RobotDescription
|
|
1469
|
+
|
|
1470
|
+
_preflight_reward_vram_fit(
|
|
1471
|
+
repo_root=repo_root,
|
|
1472
|
+
description=RobotDescription.from_yaml(str(invocation.robot_yaml)),
|
|
1473
|
+
reward_manifest_path=invocation.reward_monitor_manifest,
|
|
1474
|
+
gpu_budget_gb=_detect_gpu_free_vram_gb(),
|
|
1475
|
+
)
|
|
1476
|
+
hal_params_tmp = tempfile.NamedTemporaryFile( # noqa: SIM115 # reason: HAL reads after this scope
|
|
1477
|
+
mode="w",
|
|
1478
|
+
prefix=f"openral-hal-params-{invocation.robot_id}-",
|
|
1479
|
+
suffix=".yaml",
|
|
1480
|
+
delete=False,
|
|
1481
|
+
encoding="utf-8",
|
|
1482
|
+
)
|
|
1483
|
+
try:
|
|
1484
|
+
yaml.safe_dump(
|
|
1485
|
+
{"/**": {"ros__parameters": invocation.hal_params}},
|
|
1486
|
+
hal_params_tmp,
|
|
1487
|
+
sort_keys=False,
|
|
1488
|
+
)
|
|
1489
|
+
hal_params_tmp.close()
|
|
1490
|
+
argv = [
|
|
1491
|
+
arg.replace("HAL_PARAMS_FILE_PLACEHOLDER", hal_params_tmp.name)
|
|
1492
|
+
for arg in invocation.argv_template
|
|
1493
|
+
]
|
|
1494
|
+
_console.print(f" hal_params_tmp:{hal_params_tmp.name}")
|
|
1495
|
+
_console.print(f" argv: {shlex.join(argv)}")
|
|
1496
|
+
venv_env = _prepare_launch_env()
|
|
1497
|
+
return _run_launch(argv, venv_env)
|
|
1498
|
+
finally:
|
|
1499
|
+
with contextlib.suppress(OSError):
|
|
1500
|
+
Path(hal_params_tmp.name).unlink(missing_ok=True)
|
|
1501
|
+
|
|
1502
|
+
|
|
1503
|
+
def _parse_hal_overrides(raw: list[str] | None) -> dict[str, object]:
|
|
1504
|
+
"""Parse ``--hal key=value`` flags into a typed override dict.
|
|
1505
|
+
|
|
1506
|
+
Accepts bool / int / float / string. JSON-encoded values are tried
|
|
1507
|
+
first so ``--hal cameras='["top"]'`` works.
|
|
1508
|
+
"""
|
|
1509
|
+
out: dict[str, object] = {}
|
|
1510
|
+
for entry in raw or []:
|
|
1511
|
+
if "=" not in entry:
|
|
1512
|
+
raise ROSConfigError(f"--hal {entry!r} is malformed; expected ``key=value``.")
|
|
1513
|
+
key, value = entry.split("=", 1)
|
|
1514
|
+
key = key.strip()
|
|
1515
|
+
value = value.strip()
|
|
1516
|
+
try:
|
|
1517
|
+
out[key] = json.loads(value)
|
|
1518
|
+
except json.JSONDecodeError:
|
|
1519
|
+
out[key] = value
|
|
1520
|
+
return out
|
|
1521
|
+
|
|
1522
|
+
|
|
1523
|
+
def _ros2_pkg_prefix(pkg: str) -> str | None:
|
|
1524
|
+
"""Return the install prefix for ``pkg`` per ``ros2 pkg prefix``, or None.
|
|
1525
|
+
|
|
1526
|
+
Uses ``ros2 pkg prefix`` because it is the same lookup ``ros2 launch``
|
|
1527
|
+
performs internally. A package is "discoverable" iff this exits 0
|
|
1528
|
+
and prints a path.
|
|
1529
|
+
"""
|
|
1530
|
+
try:
|
|
1531
|
+
completed = subprocess.run(
|
|
1532
|
+
["ros2", "pkg", "prefix", pkg],
|
|
1533
|
+
check=False,
|
|
1534
|
+
capture_output=True,
|
|
1535
|
+
text=True,
|
|
1536
|
+
timeout=10,
|
|
1537
|
+
)
|
|
1538
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
1539
|
+
return None
|
|
1540
|
+
if completed.returncode != 0:
|
|
1541
|
+
return None
|
|
1542
|
+
out = completed.stdout.strip()
|
|
1543
|
+
return out or None
|
|
1544
|
+
|
|
1545
|
+
|
|
1546
|
+
def _reap_orphans_with_log() -> None:
|
|
1547
|
+
"""Kill orphan openral-graph processes and log the count.
|
|
1548
|
+
|
|
1549
|
+
Reap orphan graph processes from a prior crashed/Ctrl-C'd run.
|
|
1550
|
+
Without this, a stale HAL still holds the robocasa MJCF env +
|
|
1551
|
+
``/dev/shm/fastrtps_*`` lockfiles, and the new launch surfaces
|
|
1552
|
+
as ``[RTPS_TRANSPORT_SHM Error] Failed init_port
|
|
1553
|
+
fastrtps_port7000`` on the safety_kernel, slam_toolbox,
|
|
1554
|
+
prompt_router, etc.
|
|
1555
|
+
"""
|
|
1556
|
+
killed = _kill_orphan_openral_graph_processes()
|
|
1557
|
+
if killed:
|
|
1558
|
+
_console.print(
|
|
1559
|
+
f"[yellow]reaped {killed} orphan openral-graph process(es) from a prior run[/yellow]"
|
|
1560
|
+
)
|
|
1561
|
+
|
|
1562
|
+
|
|
1563
|
+
# Argv substrings that identify a process as belonging to the openral
|
|
1564
|
+
# deploy graph. Each entry must be specific enough that a coincidental
|
|
1565
|
+
# unrelated invocation never matches — the reaper additionally scopes to
|
|
1566
|
+
# the calling user's PIDs (the ``st_uid`` guard) so a shared host is safe.
|
|
1567
|
+
_ORPHAN_GRAPH_NEEDLES: tuple[str, ...] = (
|
|
1568
|
+
"sim_e2e.launch.py",
|
|
1569
|
+
"openral_rskill_ros/runtime_node",
|
|
1570
|
+
"install/lib/openral_hal_",
|
|
1571
|
+
"openral_reasoner_ros/reasoner_node.py",
|
|
1572
|
+
"openral_prompt_router/prompt_router_node.py",
|
|
1573
|
+
"openral_safety_kernel/safety_kernel_node",
|
|
1574
|
+
"openral dashboard",
|
|
1575
|
+
"async_slam_toolbox_node",
|
|
1576
|
+
# Nav2 sub-nodes spawned by openral_nav2_bringup's
|
|
1577
|
+
# IncludeLaunchDescription. The upstream binaries live under
|
|
1578
|
+
# ``/opt/ros/<distro>/lib/nav2_*``; matching the path keeps the
|
|
1579
|
+
# predicate specific (won't catch unrelated ``nav2_*`` python
|
|
1580
|
+
# imports). Without these, a previously-crashed Nav2 graph leaves
|
|
1581
|
+
# zombie controller_server / planner_server / collision_monitor /
|
|
1582
|
+
# bt_navigator / opennav_docking processes alive, and the next
|
|
1583
|
+
# ``openral deploy sim`` hangs in ``Configuring controller_server``
|
|
1584
|
+
# while multiple lifecycle_managers fight over the same nodes.
|
|
1585
|
+
"/lib/nav2_controller/",
|
|
1586
|
+
"/lib/nav2_smoother/",
|
|
1587
|
+
"/lib/nav2_planner/",
|
|
1588
|
+
"/lib/nav2_route/",
|
|
1589
|
+
"/lib/nav2_behaviors/",
|
|
1590
|
+
"/lib/nav2_bt_navigator/",
|
|
1591
|
+
"/lib/nav2_waypoint_follower/",
|
|
1592
|
+
"/lib/nav2_velocity_smoother/",
|
|
1593
|
+
"/lib/nav2_collision_monitor/",
|
|
1594
|
+
"/lib/opennav_docking/",
|
|
1595
|
+
"/lib/nav2_lifecycle_manager/",
|
|
1596
|
+
# TF chain spawned by ``sim_e2e.launch.py``. These were the
|
|
1597
|
+
# silent gap that caused the rldx-rc365 "arm reaches 40 cm high" bug:
|
|
1598
|
+
# a ``static_transform_publisher`` orphaned from a run *before* the
|
|
1599
|
+
# URDF mount-z was zeroed kept publishing the stale ``base_link →
|
|
1600
|
+
# panda_link0 z=0.4`` on the TRANSIENT_LOCAL ``/tf_static`` topic, and
|
|
1601
|
+
# the next launch's correct ``z=0.0`` publisher couldn't override it
|
|
1602
|
+
# (tf2 picks non-deterministically among same-name static frames).
|
|
1603
|
+
# Reaping the renamed static publisher (``static_<base>_to_<root>``)
|
|
1604
|
+
# and the URDF ``robot_state_publisher`` closes that hole. Scoped to
|
|
1605
|
+
# the ``tf2_ros`` / ``robot_state_publisher`` executables under the
|
|
1606
|
+
# calling user so we never touch an unrelated TF graph.
|
|
1607
|
+
"/lib/tf2_ros/static_transform_publisher",
|
|
1608
|
+
"/lib/robot_state_publisher/robot_state_publisher",
|
|
1609
|
+
# rldx out-of-process sidecar (ADR auto-spawn). It runs in its OWN
|
|
1610
|
+
# session (``start_new_session=True`` in ``openral_sim.policies.rldx``)
|
|
1611
|
+
# so the launch group's SIGINT never reaches it; if the runtime node
|
|
1612
|
+
# dies before its adapter ``close()`` runs, the sidecar keeps the
|
|
1613
|
+
# GR00T/RLDX weights resident and starves the GPU (~6.5 GiB) of the
|
|
1614
|
+
# next run. The cache dir is openral-specific, so this is unambiguous.
|
|
1615
|
+
"/.cache/openral/rldx-sidecar/",
|
|
1616
|
+
# Perception / critic graph nodes spawned by ``sim_e2e.launch.py``. These
|
|
1617
|
+
# were absent from the sweep, so under a heavy graph whose graceful
|
|
1618
|
+
# shutdown doesn't finish within ``grace_s`` they orphaned (the reward
|
|
1619
|
+
# monitor holds the sidecar; the detector holds its model). Scoped to the
|
|
1620
|
+
# in-tree node entry points so we never touch an unrelated process.
|
|
1621
|
+
"openral_perception_ros/reward_monitor_node.py",
|
|
1622
|
+
"openral_perception_ros/ros_image_detector_node.py",
|
|
1623
|
+
"openral_reasoner_ros/critic_producer_node.py",
|
|
1624
|
+
)
|
|
1625
|
+
|
|
1626
|
+
|
|
1627
|
+
def _cmdline_is_openral_graph_process(cmdline: str) -> bool:
|
|
1628
|
+
"""Return True when ``cmdline`` matches an openral deploy-graph process.
|
|
1629
|
+
|
|
1630
|
+
Pure predicate over a space-joined ``/proc/<pid>/cmdline`` string so
|
|
1631
|
+
the needle set (:data:`_ORPHAN_GRAPH_NEEDLES`) is unit-testable
|
|
1632
|
+
without spawning real processes.
|
|
1633
|
+
"""
|
|
1634
|
+
return any(needle in cmdline for needle in _ORPHAN_GRAPH_NEEDLES)
|
|
1635
|
+
|
|
1636
|
+
|
|
1637
|
+
def _kill_orphan_openral_graph_processes() -> int:
|
|
1638
|
+
"""SIGKILL orphaned openral-graph processes from a prior ``openral deploy sim``.
|
|
1639
|
+
|
|
1640
|
+
A graceful ``Ctrl-C`` doesn't always propagate through ``ros2
|
|
1641
|
+
launch`` to every child — under load, the launch dispatcher
|
|
1642
|
+
exits before the HAL has finished tearing down its in-process
|
|
1643
|
+
robocasa env, and the HAL python process keeps running. On the
|
|
1644
|
+
next ``openral deploy sim``:
|
|
1645
|
+
|
|
1646
|
+
* the prior dashboard still holds ``127.0.0.1:4318`` →
|
|
1647
|
+
``[Errno 98] address already in use`` on the new dashboard;
|
|
1648
|
+
* the prior HAL still holds ``/dev/shm/fastrtps_*`` lockfiles →
|
|
1649
|
+
``[RTPS_TRANSPORT_SHM Error] Failed init_port fastrtps_port7000``
|
|
1650
|
+
on the new safety_kernel/slam_toolbox/prompt_router;
|
|
1651
|
+
* the prior HAL still holds the MJCF env handle → the new HAL's
|
|
1652
|
+
configure step hangs waiting for the robocasa loader.
|
|
1653
|
+
|
|
1654
|
+
Match orphans by argv signature so we never reach into other
|
|
1655
|
+
users' processes or unrelated python invocations. Best-effort:
|
|
1656
|
+
SIGKILL failures (permission denied, race) are silently
|
|
1657
|
+
skipped. Returns the count of processes killed for logging.
|
|
1658
|
+
"""
|
|
1659
|
+
proc_root = Path("/proc")
|
|
1660
|
+
if not proc_root.is_dir():
|
|
1661
|
+
# No procfs (macOS dev hosts) — nothing to sweep. Deployment
|
|
1662
|
+
# targets are Linux; on Darwin the reap is a no-op rather than a
|
|
1663
|
+
# FileNotFoundError crash inside _run_launch.
|
|
1664
|
+
return 0
|
|
1665
|
+
me = os.getuid()
|
|
1666
|
+
killed = 0
|
|
1667
|
+
for entry in proc_root.iterdir():
|
|
1668
|
+
if not entry.name.isdigit():
|
|
1669
|
+
continue
|
|
1670
|
+
pid = int(entry.name)
|
|
1671
|
+
if pid == os.getpid():
|
|
1672
|
+
continue
|
|
1673
|
+
try:
|
|
1674
|
+
st = entry.stat()
|
|
1675
|
+
except (FileNotFoundError, PermissionError):
|
|
1676
|
+
continue
|
|
1677
|
+
if st.st_uid != me:
|
|
1678
|
+
continue # other users' processes are not ours to kill
|
|
1679
|
+
try:
|
|
1680
|
+
cmdline = (
|
|
1681
|
+
(entry / "cmdline")
|
|
1682
|
+
.read_bytes()
|
|
1683
|
+
.replace(b"\x00", b" ")
|
|
1684
|
+
.decode(
|
|
1685
|
+
errors="replace",
|
|
1686
|
+
)
|
|
1687
|
+
)
|
|
1688
|
+
except (FileNotFoundError, PermissionError):
|
|
1689
|
+
continue
|
|
1690
|
+
if not _cmdline_is_openral_graph_process(cmdline):
|
|
1691
|
+
continue
|
|
1692
|
+
with contextlib.suppress(ProcessLookupError, PermissionError, OSError):
|
|
1693
|
+
os.kill(pid, signal.SIGKILL)
|
|
1694
|
+
killed += 1
|
|
1695
|
+
return killed
|
|
1696
|
+
|
|
1697
|
+
|
|
1698
|
+
def _terminate_launch_group(proc: subprocess.Popen[bytes], *, grace_s: float = 12.0) -> None:
|
|
1699
|
+
"""SIGINT the launch's session, then SIGKILL any straggler after a grace.
|
|
1700
|
+
|
|
1701
|
+
``proc`` was spawned with ``start_new_session=True`` so its PGID
|
|
1702
|
+
equals its PID and names the whole launch tree. SIGINT (not SIGTERM)
|
|
1703
|
+
is what ``ros2 launch`` translates into a graceful lifecycle
|
|
1704
|
+
shutdown; we give it ``grace_s`` to drain, then SIGKILL the group so
|
|
1705
|
+
no node — or the launch-spawned static_transform_publisher /
|
|
1706
|
+
robot_state_publisher — survives to orphan and poison the next run.
|
|
1707
|
+
"""
|
|
1708
|
+
if proc.poll() is not None:
|
|
1709
|
+
return
|
|
1710
|
+
with contextlib.suppress(ProcessLookupError, OSError):
|
|
1711
|
+
os.killpg(proc.pid, signal.SIGINT)
|
|
1712
|
+
with contextlib.suppress(subprocess.TimeoutExpired):
|
|
1713
|
+
proc.wait(timeout=grace_s)
|
|
1714
|
+
return
|
|
1715
|
+
with contextlib.suppress(ProcessLookupError, OSError):
|
|
1716
|
+
os.killpg(proc.pid, signal.SIGKILL)
|
|
1717
|
+
with contextlib.suppress(subprocess.TimeoutExpired):
|
|
1718
|
+
proc.wait(timeout=5.0)
|
|
1719
|
+
|
|
1720
|
+
|
|
1721
|
+
def _run_launch(argv: list[str], env: dict[str, str], *, grace_s: float = 12.0) -> int:
|
|
1722
|
+
"""Run ``ros2 launch`` in its own session, reaping the whole tree on exit.
|
|
1723
|
+
|
|
1724
|
+
The legacy ``subprocess.run(argv)`` left the launch a sibling in the
|
|
1725
|
+
CLI's process group: a ``kill -INT`` of this CLI (or any non-terminal
|
|
1726
|
+
exit) signalled only the CLI, and ``ros2 launch`` plus every node it
|
|
1727
|
+
spawned — the HAL (holding the MuJoCo render context, ~1.2 GiB GPU),
|
|
1728
|
+
robot_state_publisher, the ``static_<base>_to_<root>`` static TF
|
|
1729
|
+
publisher — were reparented to init and kept running. Those orphans
|
|
1730
|
+
accumulated across dev iterations and poisoned the TRANSIENT_LOCAL
|
|
1731
|
+
``/tf_static`` topic (see ``_kill_orphan_openral_graph_processes``).
|
|
1732
|
+
|
|
1733
|
+
Teardown runs in three escalating stages so nothing survives,
|
|
1734
|
+
whatever the process-group topology:
|
|
1735
|
+
|
|
1736
|
+
1. Forward SIGINT/SIGTERM to the launch's session so ``ros2 launch``
|
|
1737
|
+
runs its graceful shutdown (each node's ``on_shutdown`` fires; the
|
|
1738
|
+
HAL releases its MJCF env; the skill adapter's ``close()``
|
|
1739
|
+
terminates the rldx sidecar).
|
|
1740
|
+
2. After ``grace_s`` escalate to SIGKILL on the launch's process
|
|
1741
|
+
group (``_terminate_launch_group``).
|
|
1742
|
+
3. Sweep by argv signature (``_kill_orphan_openral_graph_processes``).
|
|
1743
|
+
This is the bulletproof backstop: ``ros2 launch`` spawns its nodes
|
|
1744
|
+
in their OWN process groups (and the rldx sidecar in its own
|
|
1745
|
+
session), so neither ``killpg`` reaches them directly — under a
|
|
1746
|
+
heavy graph the graceful shutdown often doesn't finish before the
|
|
1747
|
+
launch exits, and stage 1+2 alone leak nodes. The signature sweep
|
|
1748
|
+
matches every graph process regardless of parentage.
|
|
1749
|
+
|
|
1750
|
+
Returns the launch process's exit code (0 if it exited via signal
|
|
1751
|
+
with no recorded returncode).
|
|
1752
|
+
"""
|
|
1753
|
+
proc = subprocess.Popen(argv, env=env, start_new_session=True)
|
|
1754
|
+
|
|
1755
|
+
def _forward(_signum: int, _frame: object) -> None:
|
|
1756
|
+
with contextlib.suppress(ProcessLookupError, OSError):
|
|
1757
|
+
os.killpg(proc.pid, signal.SIGINT)
|
|
1758
|
+
|
|
1759
|
+
prev_int = signal.signal(signal.SIGINT, _forward)
|
|
1760
|
+
prev_term = signal.signal(signal.SIGTERM, _forward)
|
|
1761
|
+
try:
|
|
1762
|
+
proc.wait()
|
|
1763
|
+
finally:
|
|
1764
|
+
signal.signal(signal.SIGINT, prev_int)
|
|
1765
|
+
signal.signal(signal.SIGTERM, prev_term)
|
|
1766
|
+
_terminate_launch_group(proc, grace_s=grace_s)
|
|
1767
|
+
# ``ros2 launch`` spawns its nodes in their OWN process groups, so
|
|
1768
|
+
# ``killpg`` on the launch's group only reaps them via ros2
|
|
1769
|
+
# launch's *graceful* shutdown — which, under a heavy graph
|
|
1770
|
+
# (nav2 + slam + sidecar), routinely does not finish before the
|
|
1771
|
+
# launch process itself exits, leaving the nodes (and the
|
|
1772
|
+
# own-session rldx sidecar) orphaned. Sweep by argv signature as
|
|
1773
|
+
# the bulletproof backstop: it matches every graph process
|
|
1774
|
+
# regardless of parentage or process group. Safe because two
|
|
1775
|
+
# deploy graphs can't coexist (they collide on DDS/ports), so on
|
|
1776
|
+
# exit the only matching processes are this run's survivors.
|
|
1777
|
+
_kill_orphan_openral_graph_processes()
|
|
1778
|
+
return proc.returncode if proc.returncode is not None else 0
|
|
1779
|
+
|
|
1780
|
+
|
|
1781
|
+
def _apply_rmw_default(env: dict[str, str]) -> None:
|
|
1782
|
+
"""Clean stale Fast-DDS SHM lockfiles before spawning ``ros2 launch``.
|
|
1783
|
+
|
|
1784
|
+
ROS 2 Jazzy defaults to Fast-DDS, whose per-participant SHM
|
|
1785
|
+
files live under ``/dev/shm/fastrtps_*``. When a prior launch
|
|
1786
|
+
exited uncleanly (Ctrl-C, OOM kill, ``pkill -9``), those
|
|
1787
|
+
lockfiles persist, and the next Fast-DDS participant fails to
|
|
1788
|
+
bind with ``[RTPS_TRANSPORT_SHM Error] Failed init_port
|
|
1789
|
+
fastrtps_port7000: open_and_lock_file failed`` — breaking every
|
|
1790
|
+
subsequent ``openral deploy sim`` until the operator manually
|
|
1791
|
+
cleans them. CLAUDE.md §2 names Cyclone as the OpenRAL default,
|
|
1792
|
+
but the launch_ros lifecycle_event_manager in Jazzy has a
|
|
1793
|
+
sharp edge with Cyclone (deserialisation race on the
|
|
1794
|
+
auto-CONFIGURE → ACTIVATE chain) that takes down the
|
|
1795
|
+
prompt_router; until that's resolved we stay on Fast-DDS and
|
|
1796
|
+
aggressively clean its stale state.
|
|
1797
|
+
|
|
1798
|
+
Best-effort: only files owned by the calling user are
|
|
1799
|
+
unlinked — other users' SHM segments are silently skipped.
|
|
1800
|
+
Operators that explicitly opt into Cyclone or Zenoh via
|
|
1801
|
+
``RMW_IMPLEMENTATION`` keep theirs untouched.
|
|
1802
|
+
"""
|
|
1803
|
+
if "rmw_cyclonedds" in env.get("RMW_IMPLEMENTATION", ""):
|
|
1804
|
+
return # operator opted into Cyclone; nothing to clean
|
|
1805
|
+
if "rmw_zenoh" in env.get("RMW_IMPLEMENTATION", ""):
|
|
1806
|
+
return
|
|
1807
|
+
_clean_stale_fastrtps_shm()
|
|
1808
|
+
|
|
1809
|
+
|
|
1810
|
+
def _clean_stale_fastrtps_shm() -> None:
|
|
1811
|
+
"""Best-effort: remove stale Fast-DDS SHM lock files in ``/dev/shm``.
|
|
1812
|
+
|
|
1813
|
+
Fast-DDS lock files are owned by the user that created them — we
|
|
1814
|
+
silently skip anything we can't unlink (another user's file) so
|
|
1815
|
+
this never escalates to ``sudo``-required cleanup. Cyclone-DDS
|
|
1816
|
+
deployments never call this path. Only fires when the operator
|
|
1817
|
+
explicitly opts back into Fast-DDS via ``RMW_IMPLEMENTATION``.
|
|
1818
|
+
"""
|
|
1819
|
+
shm = Path("/dev/shm")
|
|
1820
|
+
if not shm.is_dir():
|
|
1821
|
+
return
|
|
1822
|
+
for entry in shm.iterdir():
|
|
1823
|
+
if not entry.name.startswith("fastrtps_"):
|
|
1824
|
+
continue
|
|
1825
|
+
with contextlib.suppress(OSError, PermissionError):
|
|
1826
|
+
entry.unlink()
|
|
1827
|
+
|
|
1828
|
+
|
|
1829
|
+
def _required_ros2_packages(invocation: LaunchInvocation) -> list[str]:
|
|
1830
|
+
"""Build the package-list the preflight discovery check must validate.
|
|
1831
|
+
|
|
1832
|
+
Pulled out of :func:`deploy_sim_command` for line-count hygiene
|
|
1833
|
+
and so future opt-in bringup wrappers extend a single list.
|
|
1834
|
+
"""
|
|
1835
|
+
pkgs = ["openral_rskill_ros", invocation.hal.package]
|
|
1836
|
+
if invocation.enable_slam:
|
|
1837
|
+
pkgs.append("openral_slam_bringup")
|
|
1838
|
+
if invocation.enable_nav2:
|
|
1839
|
+
pkgs.append("openral_nav2_bringup")
|
|
1840
|
+
return pkgs
|
|
1841
|
+
|
|
1842
|
+
|
|
1843
|
+
def assert_ros2_packages_discoverable(
|
|
1844
|
+
packages: Iterable[str],
|
|
1845
|
+
*,
|
|
1846
|
+
prefix_lookup: Callable[[str], str | None] = _ros2_pkg_prefix,
|
|
1847
|
+
) -> None:
|
|
1848
|
+
"""Raise ``ROSConfigError`` listing every ``pkg`` ``ros2`` cannot find.
|
|
1849
|
+
|
|
1850
|
+
Catches the most common operator failure for ``openral deploy sim``:
|
|
1851
|
+
``ros2`` itself is on PATH (so the system overlay is sourced) but
|
|
1852
|
+
the OpenRAL workspace overlay (``install/setup.bash``) is not, so
|
|
1853
|
+
``ros2 launch`` can't find ``openral_rskill_ros`` or the per-robot
|
|
1854
|
+
``openral_hal_<X>`` package. The same error fires for a stale build
|
|
1855
|
+
that simply hasn't included the requested HAL package yet.
|
|
1856
|
+
|
|
1857
|
+
``prefix_lookup`` is injectable so unit tests can drive the path
|
|
1858
|
+
with a deterministic fake without shelling out (CLAUDE.md §1.11
|
|
1859
|
+
process-boundary fake).
|
|
1860
|
+
"""
|
|
1861
|
+
missing = [pkg for pkg in packages if prefix_lookup(pkg) is None]
|
|
1862
|
+
if not missing:
|
|
1863
|
+
return
|
|
1864
|
+
quoted = ", ".join(repr(p) for p in missing)
|
|
1865
|
+
raise ROSConfigError(
|
|
1866
|
+
f"ros2 cannot find ROS package(s): {quoted}. The OpenRAL workspace "
|
|
1867
|
+
"overlay is not sourced (or the build is stale). From the repo root, "
|
|
1868
|
+
"run:\n"
|
|
1869
|
+
" just ros2-build && source install/setup.bash\n"
|
|
1870
|
+
"then re-run ``openral deploy sim``. Note: the package is named "
|
|
1871
|
+
"``openral_rskill_ros`` (not ``rskill``) — the Python sub-package "
|
|
1872
|
+
"under ``python/rskill/`` is a different layer."
|
|
1873
|
+
)
|
|
1874
|
+
|
|
1875
|
+
|
|
1876
|
+
def _preflight_palette_deps( # noqa: PLR0912, PLR0915 # reason: linear flow — split would obscure the prompt → install → re-probe contract
|
|
1877
|
+
*,
|
|
1878
|
+
repo_root: Path,
|
|
1879
|
+
robot_yaml: Path,
|
|
1880
|
+
commercial_deployment: bool = False,
|
|
1881
|
+
) -> None:
|
|
1882
|
+
"""Prompt to install missing extras before the reasoner palette empties.
|
|
1883
|
+
|
|
1884
|
+
Mirrors :meth:`ReasonerNode._maybe_seed_palette_from_search_paths`:
|
|
1885
|
+
loads ``<repo_root>/rskills/*/rskill.yaml``, builds the
|
|
1886
|
+
capability-filtered :class:`~openral_reasoner.palette.ToolPalette`
|
|
1887
|
+
against the robot's :class:`~openral_core.RobotCapabilities`, then
|
|
1888
|
+
probes each capability-matching manifest's ``model_family`` for
|
|
1889
|
+
importability. Surfaces missing extras *before* the launch
|
|
1890
|
+
instead of letting the reasoner silently drop them at
|
|
1891
|
+
``on_configure`` time (the operator only finds out via
|
|
1892
|
+
``palette_empty`` ticks once everything is up).
|
|
1893
|
+
|
|
1894
|
+
This is ADVISORY, not a gate. The reasoner ALREADY drops
|
|
1895
|
+
unimportable rSkills at ``on_configure``
|
|
1896
|
+
(:func:`openral_sim.policy_deps.filter_importable_manifests`) and
|
|
1897
|
+
runs the importable remainder. The palette is robot-WIDE — a single
|
|
1898
|
+
franka config matches six model families (act / molmoact2 / pi05 /
|
|
1899
|
+
rldx / smolvla / xvla), so a partially-installed venv is the common
|
|
1900
|
+
case and demanding every family's extras to run ONE skill is the
|
|
1901
|
+
wrong contract. So we warn-and-drop, and hard-fail only when the
|
|
1902
|
+
palette would be left empty (nothing dispatchable).
|
|
1903
|
+
|
|
1904
|
+
Behaviour (when ≥1 matching skill is blocked on missing extras):
|
|
1905
|
+
|
|
1906
|
+
* default / ``OPENRAL_AUTO_INSTALL_DEPS=1`` → install the union of
|
|
1907
|
+
missing groups via ``just sync --all-packages --group …``
|
|
1908
|
+
(cwd=repo_root), re-probe, and continue. Same env var honoured by
|
|
1909
|
+
:mod:`openral_sim._assets` / :mod:`openral_sim._deps`. A non-zero
|
|
1910
|
+
``just sync`` is a real failure → ``typer.Exit``.
|
|
1911
|
+
* ``OPENRAL_AUTO_INSTALL_DEPS=0`` on a TTY → ``typer.confirm`` the
|
|
1912
|
+
same install; on yes install+re-probe; on no → drop blocked skills.
|
|
1913
|
+
* ``OPENRAL_AUTO_INSTALL_DEPS=0`` non-TTY → drop the blocked skills
|
|
1914
|
+
and proceed, printing the install command as a hint.
|
|
1915
|
+
* In every "proceed" path above: if EVERY matching skill is blocked
|
|
1916
|
+
(palette would be empty) → print the install command and
|
|
1917
|
+
``typer.Exit(1)`` instead, since the graph could dispatch nothing.
|
|
1918
|
+
* No capability-matching skills are blocked → silent return.
|
|
1919
|
+
"""
|
|
1920
|
+
from openral_core import RobotDescription, RSkillManifest
|
|
1921
|
+
from openral_reasoner.palette import build_tool_palette
|
|
1922
|
+
from openral_sim.policy_deps import (
|
|
1923
|
+
can_import_policy_family,
|
|
1924
|
+
model_family_install_groups,
|
|
1925
|
+
model_family_install_hint,
|
|
1926
|
+
)
|
|
1927
|
+
|
|
1928
|
+
rskills_dir = repo_root / "rskills"
|
|
1929
|
+
manifest_paths = sorted(rskills_dir.glob("*/rskill.yaml"))
|
|
1930
|
+
if not manifest_paths:
|
|
1931
|
+
return
|
|
1932
|
+
|
|
1933
|
+
try:
|
|
1934
|
+
description = RobotDescription.from_yaml(str(robot_yaml))
|
|
1935
|
+
except (OSError, ValueError):
|
|
1936
|
+
# Robot.yaml will fail loudly downstream — don't double-report here.
|
|
1937
|
+
return
|
|
1938
|
+
|
|
1939
|
+
manifests: list[RSkillManifest] = []
|
|
1940
|
+
for path in manifest_paths:
|
|
1941
|
+
try:
|
|
1942
|
+
manifests.append(RSkillManifest.from_yaml(str(path)))
|
|
1943
|
+
except (OSError, ValueError):
|
|
1944
|
+
# Same "skip unloadable" behaviour as the reasoner seed.
|
|
1945
|
+
continue
|
|
1946
|
+
|
|
1947
|
+
matching = build_tool_palette(
|
|
1948
|
+
installed_skills=manifests,
|
|
1949
|
+
robot_capabilities=description.capabilities,
|
|
1950
|
+
commercial_deployment=commercial_deployment,
|
|
1951
|
+
)
|
|
1952
|
+
matching_ids = matching.execute_rskill_ids
|
|
1953
|
+
if not matching_ids:
|
|
1954
|
+
# Palette would be empty for capability / role / license reasons,
|
|
1955
|
+
# not deps. Reasoner already logs this clearly at on_configure.
|
|
1956
|
+
return
|
|
1957
|
+
|
|
1958
|
+
blocked: list[tuple[str, str]] = [] # (manifest_name, model_family)
|
|
1959
|
+
install_groups: set[str] = set()
|
|
1960
|
+
for m in manifests:
|
|
1961
|
+
if m.name not in matching_ids:
|
|
1962
|
+
continue
|
|
1963
|
+
family = getattr(m, "model_family", None) or ""
|
|
1964
|
+
ok, _ = can_import_policy_family(family)
|
|
1965
|
+
if ok:
|
|
1966
|
+
continue
|
|
1967
|
+
blocked.append((m.name, family))
|
|
1968
|
+
install_groups.update(model_family_install_groups(family))
|
|
1969
|
+
|
|
1970
|
+
if not blocked:
|
|
1971
|
+
return
|
|
1972
|
+
|
|
1973
|
+
_console.print()
|
|
1974
|
+
_console.print(
|
|
1975
|
+
f"[yellow]preflight:[/yellow] {len(blocked)} of "
|
|
1976
|
+
f"{len(matching_ids)} capability-matched rSkill(s) are missing "
|
|
1977
|
+
"Python extras:"
|
|
1978
|
+
)
|
|
1979
|
+
for name, family in blocked:
|
|
1980
|
+
_console.print(f" • {name} (model_family={family!r})")
|
|
1981
|
+
_console.print(f" {model_family_install_hint(family)}")
|
|
1982
|
+
|
|
1983
|
+
install_cmd: list[str] | None = None
|
|
1984
|
+
if install_groups:
|
|
1985
|
+
groups_argv: list[str] = []
|
|
1986
|
+
for g in sorted(install_groups):
|
|
1987
|
+
groups_argv.extend(["--group", g])
|
|
1988
|
+
# Route through ``just sync`` (not bare ``uv sync``) for two
|
|
1989
|
+
# reasons:
|
|
1990
|
+
# 1. ``--all-packages`` is REQUIRED so the workspace members
|
|
1991
|
+
# (openral-core, openral-cli, ...) survive the install.
|
|
1992
|
+
# ``uv sync --group <X>`` without ``--all-packages``
|
|
1993
|
+
# uninstalls every workspace member — the next ROS launch
|
|
1994
|
+
# then fails with ``No module named 'openral_core'``
|
|
1995
|
+
# (the exact symptom the preflight is meant to prevent).
|
|
1996
|
+
# 2. The libero/robocasa groups pull in ``hf-libero==0.1.3``,
|
|
1997
|
+
# whose sdist installs both modern and legacy uninstall
|
|
1998
|
+
# metadata. The ``just sync`` recipe repairs that
|
|
1999
|
+
# before+after via ``scripts/repair_hf_libero_install.py``
|
|
2000
|
+
# so the next ``uv sync --all-packages`` doesn't bail out
|
|
2001
|
+
# with ``Unable to uninstall hf-libero==0.1.3``.
|
|
2002
|
+
# 3. ``--inexact`` makes the install ADDITIVE. Without it, ``uv
|
|
2003
|
+
# sync --group <X>`` is exact-match on the dependency-group set
|
|
2004
|
+
# and uninstalls every package not in group ``X`` — including
|
|
2005
|
+
# run-critical packages from sibling groups: the OmDet-Turbo
|
|
2006
|
+
# detector's ``timm`` (group ``omdet``), ``robosuite`` (group
|
|
2007
|
+
# ``robocasa``), rldx's ``pyzmq``/``msgpack``. The observed
|
|
2008
|
+
# failure: installing the ``rldx`` palette extras wiped ``timm``,
|
|
2009
|
+
# so the detector ImportError'd on every frame and
|
|
2010
|
+
# ``/openral/perception/objects`` stayed empty (issue #12). The
|
|
2011
|
+
# robocasa AUTO_INSTALL plan already uses ``--inexact`` for this
|
|
2012
|
+
# exact reason (openral_sim._deps._robocasa_kitchen_plan).
|
|
2013
|
+
install_cmd = ["just", "sync", "--all-packages", "--inexact", *groups_argv]
|
|
2014
|
+
|
|
2015
|
+
# Install by default; set OPENRAL_AUTO_INSTALL_DEPS=0 to prompt on a
|
|
2016
|
+
# TTY or skip on non-TTY (honoured by openral_sim._assets / _deps).
|
|
2017
|
+
auto_install = os.environ.get("OPENRAL_AUTO_INSTALL_DEPS", "1") == "1"
|
|
2018
|
+
is_interactive = sys.stdin.isatty() and sys.stdout.isatty()
|
|
2019
|
+
attempt_install = install_cmd is not None and (
|
|
2020
|
+
auto_install
|
|
2021
|
+
or (
|
|
2022
|
+
is_interactive
|
|
2023
|
+
and typer.confirm(
|
|
2024
|
+
f"Install missing extras now with `{shlex.join(install_cmd)}`?",
|
|
2025
|
+
default=True,
|
|
2026
|
+
)
|
|
2027
|
+
)
|
|
2028
|
+
)
|
|
2029
|
+
|
|
2030
|
+
if attempt_install:
|
|
2031
|
+
assert install_cmd is not None # narrowed by attempt_install guard
|
|
2032
|
+
# Propagate the operator's install-consent to the launched graph. The
|
|
2033
|
+
# scene backend's on_configure asset/dep install (openral_sim._assets,
|
|
2034
|
+
# gated on OPENRAL_AUTO_INSTALL_DEPS) must then proceed WITHOUT a second
|
|
2035
|
+
# prompt — otherwise the graph blocks before the MuJoCo viewer opens and
|
|
2036
|
+
# the operator is forced to set the env var by hand. run_launch_invocation
|
|
2037
|
+
# builds the launch env via os.environ.copy(), so setting it here carries
|
|
2038
|
+
# the single "yes, install" answer to every downstream group/asset install,
|
|
2039
|
+
# not just this rSkill-extras step.
|
|
2040
|
+
os.environ["OPENRAL_AUTO_INSTALL_DEPS"] = "1"
|
|
2041
|
+
_console.print(f"[cyan]running:[/cyan] {shlex.join(install_cmd)}")
|
|
2042
|
+
# cwd=repo_root so ``just`` resolves the workspace ``Justfile``
|
|
2043
|
+
# regardless of where the user invoked ``openral deploy sim`` from.
|
|
2044
|
+
completed = subprocess.run(install_cmd, check=False, cwd=str(repo_root))
|
|
2045
|
+
if completed.returncode != 0:
|
|
2046
|
+
# The operator explicitly asked to install (env var / confirm)
|
|
2047
|
+
# and it failed — surface it, don't silently drop and boot a
|
|
2048
|
+
# degraded graph.
|
|
2049
|
+
_console.print(
|
|
2050
|
+
f"[red]uv sync failed (exit {completed.returncode}).[/red] "
|
|
2051
|
+
"Fix the install and re-run ``openral deploy sim``."
|
|
2052
|
+
)
|
|
2053
|
+
raise typer.Exit(code=completed.returncode)
|
|
2054
|
+
# Flush importer caches so the freshly-installed packages are
|
|
2055
|
+
# discoverable on the re-probe (the .venv is the same one this
|
|
2056
|
+
# process runs from; new files on disk need an invalidate to
|
|
2057
|
+
# show up via PathFinder), then recompute the blocked set.
|
|
2058
|
+
importlib.invalidate_caches()
|
|
2059
|
+
blocked = [(n, f) for n, f in blocked if not can_import_policy_family(f)[0]]
|
|
2060
|
+
if not blocked:
|
|
2061
|
+
_console.print("[green]preflight:[/green] extras installed; continuing launch.")
|
|
2062
|
+
return
|
|
2063
|
+
# Partial success — fall through to the drop/empty decision with
|
|
2064
|
+
# the reduced ``blocked`` set.
|
|
2065
|
+
|
|
2066
|
+
# We did NOT fully resolve the missing extras (declined / non-TTY /
|
|
2067
|
+
# no install command / partial install). Preflight is advisory: drop
|
|
2068
|
+
# the blocked skills and proceed so the reasoner runs the importable
|
|
2069
|
+
# remainder — UNLESS that remainder is empty, in which case the graph
|
|
2070
|
+
# could dispatch nothing and we fail fast with the install command.
|
|
2071
|
+
kept_count = len(matching_ids) - len(blocked)
|
|
2072
|
+
if kept_count <= 0:
|
|
2073
|
+
_console.print()
|
|
2074
|
+
if install_cmd is not None:
|
|
2075
|
+
_console.print(
|
|
2076
|
+
"[red]preflight failed:[/red] every capability-matched rSkill is "
|
|
2077
|
+
"blocked on missing extras — the reasoner palette would be empty. "
|
|
2078
|
+
f"Install them:\n {shlex.join(install_cmd)}"
|
|
2079
|
+
)
|
|
2080
|
+
else:
|
|
2081
|
+
_console.print(
|
|
2082
|
+
"[red]preflight failed:[/red] every capability-matched rSkill is "
|
|
2083
|
+
"blocked — see per-skill hints above."
|
|
2084
|
+
)
|
|
2085
|
+
raise typer.Exit(code=1)
|
|
2086
|
+
|
|
2087
|
+
_console.print()
|
|
2088
|
+
_console.print(
|
|
2089
|
+
f"[yellow]preflight:[/yellow] proceeding — {len(blocked)} skill(s) will be "
|
|
2090
|
+
f"dropped from the reasoner palette; {kept_count} remain dispatchable."
|
|
2091
|
+
)
|
|
2092
|
+
if install_cmd is not None:
|
|
2093
|
+
_console.print(f" to enable the dropped skill(s): {shlex.join(install_cmd)}")
|
|
2094
|
+
|
|
2095
|
+
|
|
2096
|
+
def deploy_sim_command(
|
|
2097
|
+
config: Path = typer.Option( # reason: typer Option idiom
|
|
2098
|
+
...,
|
|
2099
|
+
"--config",
|
|
2100
|
+
"-c",
|
|
2101
|
+
exists=True,
|
|
2102
|
+
readable=True,
|
|
2103
|
+
dir_okay=False,
|
|
2104
|
+
help=(
|
|
2105
|
+
"Path to a DeployScene YAML (scenes/deploy/, scene + optional "
|
|
2106
|
+
"robot, no task). Strict: SimScene / BenchmarkScene YAMLs are "
|
|
2107
|
+
"rejected with a redirect to `openral sim run` / "
|
|
2108
|
+
"`openral benchmark scene`."
|
|
2109
|
+
),
|
|
2110
|
+
),
|
|
2111
|
+
robot: str | None = typer.Option(
|
|
2112
|
+
None,
|
|
2113
|
+
"--robot",
|
|
2114
|
+
help=(
|
|
2115
|
+
"Override the DeployScene's ``robot_id``. Required when the YAML omits ``robot_id``."
|
|
2116
|
+
),
|
|
2117
|
+
),
|
|
2118
|
+
dashboard_port: int = typer.Option(
|
|
2119
|
+
4318,
|
|
2120
|
+
"--dashboard-port",
|
|
2121
|
+
help="OTLP/HTTP port passed through to the launch's dashboard child.",
|
|
2122
|
+
),
|
|
2123
|
+
reset_to_pose_service: str | None = typer.Option(
|
|
2124
|
+
None,
|
|
2125
|
+
"--reset-to-pose-service",
|
|
2126
|
+
help=(
|
|
2127
|
+
"Override the HAL ``reset_to_pose`` service path. Defaults to "
|
|
2128
|
+
"``/openral/<robot_id>/reset_to_pose``."
|
|
2129
|
+
),
|
|
2130
|
+
),
|
|
2131
|
+
approach_skill_id: str | None = typer.Option(
|
|
2132
|
+
None,
|
|
2133
|
+
"--approach-skill-id",
|
|
2134
|
+
help=(
|
|
2135
|
+
"MoveIt approach rSkill URI (e.g. "
|
|
2136
|
+
"``rskills/rskill-moveit-joints``). When set, the runner plans a "
|
|
2137
|
+
"collision-free MoveGroup motion to each skill's starting_pose "
|
|
2138
|
+
"instead of the teleport snap (needs a running move_group). Empty "
|
|
2139
|
+
"keeps the legacy ResetToPose snap."
|
|
2140
|
+
),
|
|
2141
|
+
),
|
|
2142
|
+
dataset_out: str | None = typer.Option(
|
|
2143
|
+
None,
|
|
2144
|
+
"--dataset-out",
|
|
2145
|
+
help=(
|
|
2146
|
+
"record the deploy session (proprio + action + camera "
|
|
2147
|
+
"frames + episode markers) to this rosbag2 mcap path. Convert to a "
|
|
2148
|
+
"LeRobotDataset v3 offline with `openral dataset from-bag`. Empty "
|
|
2149
|
+
"disables recording."
|
|
2150
|
+
),
|
|
2151
|
+
),
|
|
2152
|
+
dataset_repo_id: str | None = typer.Option(
|
|
2153
|
+
None,
|
|
2154
|
+
"--dataset-repo-id",
|
|
2155
|
+
help="repo_id for the recorded dataset (default openral/dataset-<robot>).",
|
|
2156
|
+
),
|
|
2157
|
+
dataset_license: str | None = typer.Option(
|
|
2158
|
+
None,
|
|
2159
|
+
"--dataset-license",
|
|
2160
|
+
help="SPDX license carried into `openral dataset from-bag` (default CC-BY-4.0).",
|
|
2161
|
+
),
|
|
2162
|
+
hal: list[str] = typer.Option( # reason: typer Option idiom
|
|
2163
|
+
None,
|
|
2164
|
+
"--hal",
|
|
2165
|
+
help=(
|
|
2166
|
+
"Per-robot HAL parameter override, ``key=value`` (repeatable). "
|
|
2167
|
+
"Value is parsed as JSON when possible (so ``--hal "
|
|
2168
|
+
"viewer_enabled=false`` works); otherwise treated as a string. "
|
|
2169
|
+
"Overrides the per-robot defaults in ``_ROBOT_HAL_REGISTRY``."
|
|
2170
|
+
),
|
|
2171
|
+
),
|
|
2172
|
+
enable_slam: bool | None = typer.Option(
|
|
2173
|
+
None,
|
|
2174
|
+
"--enable-slam/--no-enable-slam",
|
|
2175
|
+
help=(
|
|
2176
|
+
"bring up slam_toolbox as a Reasoner-managed "
|
|
2177
|
+
"background service. **Auto by default**: enabled when the "
|
|
2178
|
+
"robot's manifest declares ``capabilities.has_lidar: true``. "
|
|
2179
|
+
"Pass ``--enable-slam`` / ``--no-enable-slam`` to override the "
|
|
2180
|
+
"manifest. The launcher auto-transitions slam_toolbox to "
|
|
2181
|
+
"INACTIVE; the Reasoner promotes to ACTIVE via "
|
|
2182
|
+
'LifecycleTransitionTool(node="/openral_slam_toolbox"). '
|
|
2183
|
+
"Requires ros-${ROS_DISTRO}-slam-toolbox apt-installed and "
|
|
2184
|
+
"the openral_slam_bringup package colcon-built in the "
|
|
2185
|
+
"workspace."
|
|
2186
|
+
),
|
|
2187
|
+
),
|
|
2188
|
+
enable_nav2: bool | None = typer.Option(
|
|
2189
|
+
None,
|
|
2190
|
+
"--enable-nav2/--no-enable-nav2",
|
|
2191
|
+
help=(
|
|
2192
|
+
"bring up the Nav2 navigation stack so the "
|
|
2193
|
+
"``OpenRAL/rskill-nav2-navigate-to-pose`` wrapped-action "
|
|
2194
|
+
"rSkill has a ``/navigate_to_pose`` server to dispatch to. "
|
|
2195
|
+
"**Auto by default**: tracks ``--enable-slam`` (lidar-"
|
|
2196
|
+
"equipped robots need a planner to consume the map). "
|
|
2197
|
+
"Requires ros-${ROS_DISTRO}-nav2-bringup apt-installed "
|
|
2198
|
+
"and the openral_nav2_bringup package colcon-built."
|
|
2199
|
+
),
|
|
2200
|
+
),
|
|
2201
|
+
object_detector_locator: list[str] | None = typer.Option(
|
|
2202
|
+
None,
|
|
2203
|
+
"--object-detector-locator",
|
|
2204
|
+
help=(
|
|
2205
|
+
"on-demand open-vocab locator to bring up alongside the "
|
|
2206
|
+
"continuous detector (repeatable). A manifest path or a short alias "
|
|
2207
|
+
"(e.g. 'omdet-turbo-locator', 'locateanything-3b-nf4'). Each becomes a "
|
|
2208
|
+
"namespaced locate_in_view node the reasoner picks via the tool's "
|
|
2209
|
+
"'detector' field. Default = omdet-turbo-locator when the detector is "
|
|
2210
|
+
"on and the omdet deps are present; LocateAnything is opt-in (NVIDIA "
|
|
2211
|
+
"non-commercial, 5 GB VRAM, needs the sidecar venv)."
|
|
2212
|
+
),
|
|
2213
|
+
),
|
|
2214
|
+
enable_octomap: bool | None = typer.Option(
|
|
2215
|
+
None,
|
|
2216
|
+
"--enable-octomap/--no-enable-octomap",
|
|
2217
|
+
help=(
|
|
2218
|
+
"bring up the world-collision perception leg: "
|
|
2219
|
+
"octomap_server (3-D OcTree from the HAL's depth PointCloud2) "
|
|
2220
|
+
"+ openral_octomap_bridge (octree → /openral/world_voxels) + "
|
|
2221
|
+
"the C++ safety kernel's capsule-vs-voxel check. **Auto by "
|
|
2222
|
+
"default**: enabled when the robot manifest declares a depth "
|
|
2223
|
+
"SensorSpec. Requires ros-${ROS_DISTRO}-octomap-server "
|
|
2224
|
+
"apt-installed and the openral_octomap_bridge package "
|
|
2225
|
+
"colcon-built."
|
|
2226
|
+
),
|
|
2227
|
+
),
|
|
2228
|
+
enable_octomap_kernel_check: bool | None = typer.Option(
|
|
2229
|
+
None,
|
|
2230
|
+
"--enable-octomap-kernel-check/--no-enable-octomap-kernel-check",
|
|
2231
|
+
help=(
|
|
2232
|
+
"When --no-enable-octomap-kernel-check, the octomap "
|
|
2233
|
+
"perception leg still publishes /openral/world_voxels (so the "
|
|
2234
|
+
"world-state object-lift works) but the C++ safety kernel's "
|
|
2235
|
+
"capsule-vs-voxel check stays OFF (its --no-enable-octomap posture: "
|
|
2236
|
+
"envelope + self-collision only). Use with --enable-octomap to let "
|
|
2237
|
+
"perception use the world map without the dense-scene false-positive "
|
|
2238
|
+
"E-stop. Unset = the scene's runtime block, else on (bundled "
|
|
2239
|
+
"world-collision-leg behaviour)."
|
|
2240
|
+
),
|
|
2241
|
+
),
|
|
2242
|
+
enable_object_detector: bool | None = typer.Option(
|
|
2243
|
+
None,
|
|
2244
|
+
"--object-detector/--no-object-detector",
|
|
2245
|
+
help=(
|
|
2246
|
+
"bring up the ROS-Image object detector "
|
|
2247
|
+
"(openral_perception_ros/ros_image_detector_node): publishes "
|
|
2248
|
+
"ObjectsMetadata to /openral/perception/objects, which the "
|
|
2249
|
+
"world-state node's object-lift raises into /openral/world_voxels. "
|
|
2250
|
+
"**On by default** (unset = the scene's runtime block, else on). "
|
|
2251
|
+
"The default backend is the open-vocabulary "
|
|
2252
|
+
"omdet-turbo-indoor continuous detector (falls back to the in-tree "
|
|
2253
|
+
"RT-DETR COCO ONNX when the omdet deps are absent). Pass "
|
|
2254
|
+
"--no-object-detector to turn the leg off. Requires the "
|
|
2255
|
+
"openral_perception_ros package colcon-built."
|
|
2256
|
+
),
|
|
2257
|
+
),
|
|
2258
|
+
object_detector_onnx: Path | None = typer.Option(
|
|
2259
|
+
None,
|
|
2260
|
+
"--object-detector-onnx",
|
|
2261
|
+
help=(
|
|
2262
|
+
"path to the RT-DETR ONNX weights for the legacy / "
|
|
2263
|
+
"fallback detector path. Defaults to the in-tree "
|
|
2264
|
+
"rskills/rtdetr-coco-r18/model.onnx. Passing a path explicitly "
|
|
2265
|
+
"selects the fixed-label RT-DETR backend over the omdet default."
|
|
2266
|
+
),
|
|
2267
|
+
),
|
|
2268
|
+
object_detector_manifest: str | None = typer.Option(
|
|
2269
|
+
None,
|
|
2270
|
+
"--object-detector-manifest",
|
|
2271
|
+
help=(
|
|
2272
|
+
"path to a kind:detector rSkill manifest "
|
|
2273
|
+
"(e.g. rskills/locateanything-3b-nf4/rskill.yaml). When set, the "
|
|
2274
|
+
"detector node is manifest-driven: runtime:pytorch brings up the "
|
|
2275
|
+
"open-vocabulary LocateAnything VLM sidecar; runtime:onnx uses "
|
|
2276
|
+
"RT-DETR. A manifest path auto-enables the detector leg (no ONNX "
|
|
2277
|
+
"file needed). The VLM sidecar needs an isolated transformers==4.57.1 "
|
|
2278
|
+
"venv (OPENRAL_LOCATEANYTHING_SIDECAR_VENV) + OPENRAL_ALLOW_NONCOMMERCIAL=1."
|
|
2279
|
+
),
|
|
2280
|
+
),
|
|
2281
|
+
object_detector_query: str | None = typer.Option(
|
|
2282
|
+
None,
|
|
2283
|
+
"--object-detector-query",
|
|
2284
|
+
help=(
|
|
2285
|
+
"initial open-vocabulary query for a VLM "
|
|
2286
|
+
"detector (e.g. 'red mug'). Empty = the manifest's detector.labels "
|
|
2287
|
+
"default. Retarget live by publishing a std_msgs/String to "
|
|
2288
|
+
"/openral/perception/detector_query."
|
|
2289
|
+
),
|
|
2290
|
+
),
|
|
2291
|
+
enable_reward_monitor: bool | None = typer.Option(
|
|
2292
|
+
None,
|
|
2293
|
+
"--enable-reward-monitor/--no-enable-reward-monitor",
|
|
2294
|
+
help=(
|
|
2295
|
+
"bring up the Robometer reward monitor "
|
|
2296
|
+
"(openral_perception_ros/reward_monitor_node) PARALLEL to the VLA: it "
|
|
2297
|
+
"buffers the agentview RGB stream and serves "
|
|
2298
|
+
"/openral/perception/query_task_progress, and the reasoner is told "
|
|
2299
|
+
"task_progress_available=True so its LLM may poll per-frame "
|
|
2300
|
+
"progress/success whenever it sees fit. Advisory-only. Unset = the "
|
|
2301
|
+
"scene's runtime block, else off. "
|
|
2302
|
+
"Needs the openral_perception_ros package colcon-built and "
|
|
2303
|
+
"Robometer/TOPReward deps in the current env; "
|
|
2304
|
+
"co-resident with a VLA wants a small NF4 VLA on an 8 GB GPU (~3.3 GB)."
|
|
2305
|
+
),
|
|
2306
|
+
),
|
|
2307
|
+
enable_critic: bool | None = typer.Option(
|
|
2308
|
+
None,
|
|
2309
|
+
"--enable-critic/--no-enable-critic",
|
|
2310
|
+
help=(
|
|
2311
|
+
"bring up the Tier-C critic producer "
|
|
2312
|
+
"(openral_reasoner_ros/critic_producer_node). It watches the generic "
|
|
2313
|
+
"/openral/critic/score topic that reward models publish (Robometer, a "
|
|
2314
|
+
"future SARM, success classifiers) and emits a Tier-C FailureTrigger on "
|
|
2315
|
+
"/openral/failure/critic when a critic stalls — the reasoner already maps "
|
|
2316
|
+
"that to a forced Tier-C tick (replanning). Advisory-only. Unset = "
|
|
2317
|
+
"the scene's runtime block, else off."
|
|
2318
|
+
),
|
|
2319
|
+
),
|
|
2320
|
+
reward_monitor_manifest: str | None = typer.Option(
|
|
2321
|
+
None,
|
|
2322
|
+
"--reward-monitor-manifest",
|
|
2323
|
+
help=(
|
|
2324
|
+
"path to a kind:reward rSkill manifest. Empty defaults to "
|
|
2325
|
+
"the in-tree rskills/robometer-4b/rskill.yaml. weights_uri may be "
|
|
2326
|
+
"hf://org/repo or local:///abs/path (a pre-quantized NF4 checkpoint "
|
|
2327
|
+
"loaded directly as 4-bit). Ignored unless --enable-reward-monitor."
|
|
2328
|
+
),
|
|
2329
|
+
),
|
|
2330
|
+
reward_monitor_task: str | None = typer.Option(
|
|
2331
|
+
None,
|
|
2332
|
+
"--reward-monitor-task",
|
|
2333
|
+
help=(
|
|
2334
|
+
"default task instruction the reward monitor scores when a "
|
|
2335
|
+
"query leaves task empty. The reasoner normally passes the active task "
|
|
2336
|
+
"per query. Ignored unless --enable-reward-monitor."
|
|
2337
|
+
),
|
|
2338
|
+
),
|
|
2339
|
+
spatial_memory_ingest: bool | None = typer.Option(
|
|
2340
|
+
None,
|
|
2341
|
+
"--spatial-memory-ingest/--no-spatial-memory-ingest",
|
|
2342
|
+
help=(
|
|
2343
|
+
"have the reasoner accumulate a durable "
|
|
2344
|
+
"SpatialMemory from the object-lift producer's "
|
|
2345
|
+
"WorldState.detected_objects so recall_object recalls what the robot "
|
|
2346
|
+
"has seen, and the dashboard shows a scene-objects card + SLAM-map "
|
|
2347
|
+
"markers. **Auto by default**: enabled whenever the object "
|
|
2348
|
+
"detector is."
|
|
2349
|
+
),
|
|
2350
|
+
),
|
|
2351
|
+
memory_dir: str | None = typer.Option(
|
|
2352
|
+
None,
|
|
2353
|
+
"--memory-dir",
|
|
2354
|
+
help=(
|
|
2355
|
+
"path to a deploy memory bundle directory. The reasoner "
|
|
2356
|
+
"loads MEMORY.md (semantic memory + memory_write/search tools) from it; "
|
|
2357
|
+
"if the dir also holds scene_graph.json it preloads the 3D world-state "
|
|
2358
|
+
"graph (recall_object), and if it holds map.yaml a nav2 map_server seeds "
|
|
2359
|
+
"the 2D occupancy grid. The dir must exist (the robot writes MEMORY.md "
|
|
2360
|
+
"into it). Overrides the DeployScene's own memory_dir."
|
|
2361
|
+
),
|
|
2362
|
+
),
|
|
2363
|
+
dashboard: bool = typer.Option(
|
|
2364
|
+
True,
|
|
2365
|
+
"--dashboard/--no-dashboard",
|
|
2366
|
+
help=(
|
|
2367
|
+
"Auto-spawn the live observability dashboard alongside "
|
|
2368
|
+
"``ros2 launch`` and point OTLP exporters at it. Default: "
|
|
2369
|
+
"on. The dashboard binds to ``--dashboard-port`` (default "
|
|
2370
|
+
"4318) and serves the OpenRAL UI + OTLP/HTTP receiver. "
|
|
2371
|
+
"``--no-dashboard`` is a true headless mode: the dashboard "
|
|
2372
|
+
"child is skipped AND ``OTEL_EXPORTER_OTLP_ENDPOINT`` is "
|
|
2373
|
+
"omitted from every node's env, so the OTel SDK short-"
|
|
2374
|
+
"circuits to no-op (no BatchSpanProcessor → no shutdown "
|
|
2375
|
+
"stall on dead-port retries). Set ``OTEL_EXPORTER_OTLP_"
|
|
2376
|
+
"ENDPOINT`` in the parent shell to forward to an external "
|
|
2377
|
+
"collector instead."
|
|
2378
|
+
),
|
|
2379
|
+
),
|
|
2380
|
+
foxglove: bool = typer.Option(
|
|
2381
|
+
False,
|
|
2382
|
+
"--foxglove/--no-foxglove",
|
|
2383
|
+
help=(
|
|
2384
|
+
"spawn the read-only Foxglove WebSocket bridge "
|
|
2385
|
+
"as part of the deploy-sim runtime graph. Default: off. "
|
|
2386
|
+
"When enabled, open Foxglove Studio and connect via "
|
|
2387
|
+
"``ws://127.0.0.1:<foxglove-port>`` to see live cameras, "
|
|
2388
|
+
"joint states, /tf, and the navigation map. **View-only** "
|
|
2389
|
+
"(cannot actuate the robot): ``clientPublish``, "
|
|
2390
|
+
"``services``, and ``parameters`` capabilities are omitted "
|
|
2391
|
+
"from the bridge. Only Bucket-1 topics are exposed "
|
|
2392
|
+
"(safety/e-stop/action topics are never forwarded). "
|
|
2393
|
+
"Requires ``foxglove_bridge`` installed in the workspace "
|
|
2394
|
+
"(ros-${ROS_DISTRO}-foxglove-bridge)."
|
|
2395
|
+
),
|
|
2396
|
+
),
|
|
2397
|
+
foxglove_port: int = typer.Option(
|
|
2398
|
+
8765,
|
|
2399
|
+
"--foxglove-port",
|
|
2400
|
+
help=(
|
|
2401
|
+
"Foxglove WebSocket port (ws://127.0.0.1:<port>). "
|
|
2402
|
+
"Default 8765 (the foxglove_bridge upstream default). "
|
|
2403
|
+
"Ignored unless ``--foxglove`` is set."
|
|
2404
|
+
),
|
|
2405
|
+
),
|
|
2406
|
+
initial_task: str | None = typer.Option(
|
|
2407
|
+
None,
|
|
2408
|
+
"--initial-task",
|
|
2409
|
+
help=(
|
|
2410
|
+
"Single natural-language goal the reasoner decomposes into ordered subtasks "
|
|
2411
|
+
"via ``decompose_mission``, e.g. ``--initial-task 'pick the bowl and place "
|
|
2412
|
+
"it on the plate, then push the mug back'``. Passed as "
|
|
2413
|
+
"``initial_task_prompt`` to the launch. When omitted, no startup prompt is "
|
|
2414
|
+
"set and the reasoner idles until a manual ``openral prompt`` or dashboard "
|
|
2415
|
+
"prompt arrives."
|
|
2416
|
+
),
|
|
2417
|
+
),
|
|
2418
|
+
dry_run: bool = typer.Option(
|
|
2419
|
+
False,
|
|
2420
|
+
"--dry-run",
|
|
2421
|
+
help=(
|
|
2422
|
+
"Print the resolved ``ros2 launch`` argv + HAL params and exit "
|
|
2423
|
+
"without writing the HAL params temp file or shelling out."
|
|
2424
|
+
),
|
|
2425
|
+
),
|
|
2426
|
+
) -> None:
|
|
2427
|
+
r"""Boot the full ROS graph against a robot's digital-twin HAL.
|
|
2428
|
+
|
|
2429
|
+
Example::
|
|
2430
|
+
|
|
2431
|
+
openral deploy sim --config scenes/deploy/openarm_tabletop.yaml
|
|
2432
|
+
openral deploy sim --config scenes/deploy/openarm_tabletop.yaml \
|
|
2433
|
+
--hal viewer_enabled=false
|
|
2434
|
+
"""
|
|
2435
|
+
try:
|
|
2436
|
+
overrides = _parse_hal_overrides(hal)
|
|
2437
|
+
invocation = resolve_launch_invocation(
|
|
2438
|
+
config=config,
|
|
2439
|
+
robot_override=robot,
|
|
2440
|
+
dashboard_port=dashboard_port,
|
|
2441
|
+
reset_to_pose_service=reset_to_pose_service,
|
|
2442
|
+
approach_skill_id=approach_skill_id,
|
|
2443
|
+
dataset_out=dataset_out,
|
|
2444
|
+
dataset_repo_id=dataset_repo_id,
|
|
2445
|
+
dataset_license=dataset_license,
|
|
2446
|
+
hal_param_overrides=overrides,
|
|
2447
|
+
enable_slam=enable_slam,
|
|
2448
|
+
enable_nav2=enable_nav2,
|
|
2449
|
+
enable_octomap=enable_octomap,
|
|
2450
|
+
enable_octomap_kernel_check=enable_octomap_kernel_check,
|
|
2451
|
+
enable_object_detector=enable_object_detector,
|
|
2452
|
+
object_detector_onnx=object_detector_onnx,
|
|
2453
|
+
object_detector_manifest=object_detector_manifest,
|
|
2454
|
+
object_detector_query=object_detector_query,
|
|
2455
|
+
enable_reward_monitor=enable_reward_monitor,
|
|
2456
|
+
reward_monitor_manifest=reward_monitor_manifest,
|
|
2457
|
+
reward_monitor_task=reward_monitor_task,
|
|
2458
|
+
enable_critic=enable_critic,
|
|
2459
|
+
object_detector_locators=object_detector_locator,
|
|
2460
|
+
spatial_memory_ingest=spatial_memory_ingest,
|
|
2461
|
+
memory_dir=memory_dir,
|
|
2462
|
+
enable_dashboard=dashboard,
|
|
2463
|
+
enable_foxglove=foxglove,
|
|
2464
|
+
foxglove_port=foxglove_port,
|
|
2465
|
+
initial_task_prompt=initial_task,
|
|
2466
|
+
)
|
|
2467
|
+
except ROSConfigError as exc:
|
|
2468
|
+
_console.print(f"[red]config error:[/red] {exc}")
|
|
2469
|
+
raise typer.Exit(code=1) from exc
|
|
2470
|
+
|
|
2471
|
+
_console.print(
|
|
2472
|
+
f"[cyan]deploy sim[/cyan] → robot=[bold]{invocation.robot_id}[/bold] "
|
|
2473
|
+
f"(manifest.name=[bold]{invocation.robot_manifest_name}[/bold]) "
|
|
2474
|
+
f"hal_package=[bold]{invocation.hal.package}[/bold] "
|
|
2475
|
+
f"hal_node_name=[bold]{invocation.hal.node_name}[/bold]"
|
|
2476
|
+
)
|
|
2477
|
+
_console.print(f" robot_yaml: {invocation.robot_yaml}")
|
|
2478
|
+
_console.print(f" reset_service: {invocation.reset_to_pose_service}")
|
|
2479
|
+
_console.print(f" approach_skill: {invocation.approach_skill_id or '(snap)'}")
|
|
2480
|
+
_console.print(f" hal_params: {invocation.hal_params}")
|
|
2481
|
+
_console.print(
|
|
2482
|
+
" slam: "
|
|
2483
|
+
+ (
|
|
2484
|
+
"[green]enabled[/green] (auto from robot.capabilities.has_lidar — "
|
|
2485
|
+
"Reasoner drives → ACTIVE via LifecycleTransitionTool)"
|
|
2486
|
+
if invocation.enable_slam
|
|
2487
|
+
else "[dim]disabled[/dim] (robot.capabilities.has_lidar=false; "
|
|
2488
|
+
"pass --enable-slam to force on)"
|
|
2489
|
+
)
|
|
2490
|
+
)
|
|
2491
|
+
_console.print(
|
|
2492
|
+
" nav2: "
|
|
2493
|
+
+ (
|
|
2494
|
+
"[green]enabled[/green] (Nav2 advertises /navigate_to_pose; "
|
|
2495
|
+
"Reasoner dispatches OpenRAL/rskill-nav2-navigate-to-pose)"
|
|
2496
|
+
if invocation.enable_nav2
|
|
2497
|
+
else "[dim]disabled[/dim] (tracks --enable-slam; pass --enable-nav2 to force on)"
|
|
2498
|
+
)
|
|
2499
|
+
)
|
|
2500
|
+
_console.print(
|
|
2501
|
+
" octomap: "
|
|
2502
|
+
+ (
|
|
2503
|
+
"[green]enabled[/green] (octomap_server + bridge → "
|
|
2504
|
+
"/openral/world_voxels; kernel voxel check on when the robot "
|
|
2505
|
+
"has collision capsules)"
|
|
2506
|
+
if invocation.enable_octomap
|
|
2507
|
+
else "[dim]disabled[/dim] (no depth SensorSpec; pass --enable-octomap to force on)"
|
|
2508
|
+
)
|
|
2509
|
+
)
|
|
2510
|
+
_console.print(
|
|
2511
|
+
" detector: "
|
|
2512
|
+
+ (
|
|
2513
|
+
f"[green]enabled[/green] (ros_image_detector_node → "
|
|
2514
|
+
f"/openral/perception/objects → object-lift; onnx="
|
|
2515
|
+
f"{invocation.object_detector_onnx})"
|
|
2516
|
+
if invocation.enable_object_detector
|
|
2517
|
+
else "[dim]disabled[/dim] (onnx weights not found at "
|
|
2518
|
+
f"{invocation.object_detector_onnx}; pass --enable-object-detector "
|
|
2519
|
+
"to force on)"
|
|
2520
|
+
)
|
|
2521
|
+
)
|
|
2522
|
+
_console.print(
|
|
2523
|
+
" dashboard: "
|
|
2524
|
+
+ (
|
|
2525
|
+
f"[green]auto-spawn[/green] at http://127.0.0.1:{dashboard_port}/"
|
|
2526
|
+
if dashboard
|
|
2527
|
+
else "[dim]disabled[/dim] (pass --dashboard to auto-spawn)"
|
|
2528
|
+
)
|
|
2529
|
+
)
|
|
2530
|
+
_console.print(
|
|
2531
|
+
" foxglove: "
|
|
2532
|
+
+ (
|
|
2533
|
+
f"[green]enabled[/green] (view-only, cannot actuate) at ws://127.0.0.1:{foxglove_port}"
|
|
2534
|
+
if foxglove
|
|
2535
|
+
else "[dim]disabled[/dim] (pass --foxglove to enable read-only live scene view)"
|
|
2536
|
+
)
|
|
2537
|
+
)
|
|
2538
|
+
_console.print(" envelope: synthesised at launch time from robot.yaml (no envelope file)")
|
|
2539
|
+
_console.print(
|
|
2540
|
+
" startup_prompt: "
|
|
2541
|
+
+ (
|
|
2542
|
+
f"[green]{invocation.initial_task_prompt!r}[/green] "
|
|
2543
|
+
"(from --initial-task; delivered to reasoner at activate)"
|
|
2544
|
+
if invocation.initial_task_prompt
|
|
2545
|
+
else "[dim](none — reasoner idles until openral prompt or dashboard)[/dim]"
|
|
2546
|
+
)
|
|
2547
|
+
)
|
|
2548
|
+
|
|
2549
|
+
if dry_run:
|
|
2550
|
+
printed = [
|
|
2551
|
+
arg.replace("HAL_PARAMS_FILE_PLACEHOLDER", "<hal-params-tmp>")
|
|
2552
|
+
for arg in invocation.argv_template
|
|
2553
|
+
]
|
|
2554
|
+
_console.print(f" argv: {shlex.join(printed)}")
|
|
2555
|
+
return
|
|
2556
|
+
|
|
2557
|
+
if shutil.which("ros2") is None:
|
|
2558
|
+
_console.print(
|
|
2559
|
+
"[red]ros2 not found on PATH.[/red] Source your ROS 2 install "
|
|
2560
|
+
"(e.g. ``source /opt/ros/jazzy/setup.bash``) and the OpenRAL "
|
|
2561
|
+
"workspace overlay (``source install/setup.bash``) before "
|
|
2562
|
+
"``openral deploy sim``."
|
|
2563
|
+
)
|
|
2564
|
+
raise typer.Exit(code=1)
|
|
2565
|
+
|
|
2566
|
+
# Pre-flight: ``ros2`` is on PATH, but is the OpenRAL workspace
|
|
2567
|
+
# overlay sourced and current? ``ros2 launch`` would otherwise fail
|
|
2568
|
+
# with a terse "Package 'openral_rskill_ros' not found, searching:
|
|
2569
|
+
# ['/opt/ros/jazzy']" — the same wording for missing overlay AND
|
|
2570
|
+
# for a stale build that omits the HAL. Catch both up front.
|
|
2571
|
+
try:
|
|
2572
|
+
assert_ros2_packages_discoverable(_required_ros2_packages(invocation))
|
|
2573
|
+
except ROSConfigError as exc:
|
|
2574
|
+
_console.print(f"[red]config error:[/red] {exc}")
|
|
2575
|
+
raise typer.Exit(code=1) from exc
|
|
2576
|
+
|
|
2577
|
+
_reap_orphans_with_log()
|
|
2578
|
+
|
|
2579
|
+
# Probe the in-tree rSkill registry against this robot's
|
|
2580
|
+
# capabilities. If every capability-matching skill is blocked by a
|
|
2581
|
+
# missing extras group, prompt-and-install on a TTY, or fail with
|
|
2582
|
+
# the install command non-interactively. Without this the reasoner
|
|
2583
|
+
# silently degrades to ``palette_empty`` ticks once everything is
|
|
2584
|
+
# up — the user gets a running but useless deploy.
|
|
2585
|
+
_preflight_palette_deps(
|
|
2586
|
+
repo_root=_repo_root_from(Path(__file__)),
|
|
2587
|
+
robot_yaml=Path(invocation.robot_yaml),
|
|
2588
|
+
)
|
|
2589
|
+
|
|
2590
|
+
# VLA↔reward VRAM pair preflight. A VLA must run with its reward
|
|
2591
|
+
# model resident; verify the pair fits the GPU BEFORE bringing up
|
|
2592
|
+
# ROS. No-op unless the reward monitor is active and the GPU budget is readable;
|
|
2593
|
+
# hard-exits (before launch) when no capability-matched VLA can co-reside with
|
|
2594
|
+
# the reward model.
|
|
2595
|
+
if invocation.enable_reward_monitor and invocation.reward_monitor_manifest:
|
|
2596
|
+
from openral_core import RobotDescription
|
|
2597
|
+
|
|
2598
|
+
_preflight_reward_vram_fit(
|
|
2599
|
+
repo_root=_repo_root_from(Path(__file__)),
|
|
2600
|
+
description=RobotDescription.from_yaml(str(invocation.robot_yaml)),
|
|
2601
|
+
reward_manifest_path=invocation.reward_monitor_manifest,
|
|
2602
|
+
gpu_budget_gb=_detect_gpu_free_vram_gb(),
|
|
2603
|
+
)
|
|
2604
|
+
|
|
2605
|
+
# Write the ephemeral HAL params YAML (lifetime = subprocess) and
|
|
2606
|
+
# substitute its path into argv. ROS 2 parameter YAML uses the
|
|
2607
|
+
# ``/**`` wildcard so the file binds against the HAL's node name
|
|
2608
|
+
# regardless of robot. SIM115's "use a context manager" doesn't
|
|
2609
|
+
# fit — the file must outlive the Python ``with`` block so the
|
|
2610
|
+
# spawned HAL process can open it.
|
|
2611
|
+
hal_params_tmp = tempfile.NamedTemporaryFile( # noqa: SIM115 # reason: HAL reads after this scope
|
|
2612
|
+
mode="w",
|
|
2613
|
+
prefix=f"openral-hal-params-{invocation.robot_id}-",
|
|
2614
|
+
suffix=".yaml",
|
|
2615
|
+
delete=False,
|
|
2616
|
+
encoding="utf-8",
|
|
2617
|
+
)
|
|
2618
|
+
try:
|
|
2619
|
+
yaml.safe_dump(
|
|
2620
|
+
{"/**": {"ros__parameters": invocation.hal_params}},
|
|
2621
|
+
hal_params_tmp,
|
|
2622
|
+
sort_keys=False,
|
|
2623
|
+
)
|
|
2624
|
+
hal_params_tmp.close()
|
|
2625
|
+
|
|
2626
|
+
argv = [
|
|
2627
|
+
arg.replace("HAL_PARAMS_FILE_PLACEHOLDER", hal_params_tmp.name)
|
|
2628
|
+
for arg in invocation.argv_template
|
|
2629
|
+
]
|
|
2630
|
+
_console.print(f" hal_params_tmp:{hal_params_tmp.name}")
|
|
2631
|
+
_console.print(f" argv: {shlex.join(argv)}")
|
|
2632
|
+
|
|
2633
|
+
# `ros2 launch` runs under the system Python by default; the
|
|
2634
|
+
# launch's deferred imports (openral_core, openral_safety) live
|
|
2635
|
+
# in the workspace venv (the one `uv run` is using right now).
|
|
2636
|
+
# The launch file processes editable-install ``.pth`` files via
|
|
2637
|
+
# ``site.addsitedir`` keyed on ``OPENRAL_VENV_SITE``; export
|
|
2638
|
+
# that env var alongside PYTHONPATH so both the launch parser
|
|
2639
|
+
# and the spawned Python nodes import openral_core correctly.
|
|
2640
|
+
# Also prepend the venv's bin dir to PATH so ``#!/usr/bin/env
|
|
2641
|
+
# python3`` shebangs on spawned node executables resolve to the
|
|
2642
|
+
# venv interpreter — that's the only interpreter that processes
|
|
2643
|
+
# the editable ``.pth`` files via site.py at startup. PYTHONPATH
|
|
2644
|
+
# alone is not enough: .pth files in PYTHONPATH directories are
|
|
2645
|
+
# never processed by Python's site module.
|
|
2646
|
+
venv_env = _prepare_launch_env()
|
|
2647
|
+
|
|
2648
|
+
# The dashboard child is spawned by ``sim_e2e.launch.py`` itself
|
|
2649
|
+
# (gated on ``enable_dashboard:=true`` forwarded from ``dashboard``
|
|
2650
|
+
# above), so this wrapper just runs the launch. The earlier
|
|
2651
|
+
# design wrapped this in ``attached_dashboard(enabled=dashboard,
|
|
2652
|
+
# ...)``, but that would double-spawn the dashboard and trip
|
|
2653
|
+
# ``[Errno 98] address already in use``.
|
|
2654
|
+
#
|
|
2655
|
+
# ``_run_launch`` (not a bare ``subprocess.run``) puts the launch
|
|
2656
|
+
# in its own session and forwards SIGINT/SIGTERM to the group so
|
|
2657
|
+
# every node — and the static_transform_publisher /
|
|
2658
|
+
# robot_state_publisher / HAL it spawns — is reaped on shutdown
|
|
2659
|
+
# instead of orphaning onto ``/tf_static`` + the GPU.
|
|
2660
|
+
returncode = _run_launch(argv, venv_env)
|
|
2661
|
+
raise typer.Exit(code=returncode)
|
|
2662
|
+
finally:
|
|
2663
|
+
with contextlib.suppress(OSError):
|
|
2664
|
+
Path(hal_params_tmp.name).unlink(missing_ok=True)
|