armnet-runtime 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.
- armnet_runtime/__init__.py +66 -0
- armnet_runtime/_rerun_pb2.py +32 -0
- armnet_runtime/cli.py +217 -0
- armnet_runtime/context.py +974 -0
- armnet_runtime/decorator.py +71 -0
- armnet_runtime/env.py +51 -0
- armnet_runtime/lerobot/__init__.py +8 -0
- armnet_runtime/lerobot/_remote_protocol.py +261 -0
- armnet_runtime/lerobot/import_swap.py +85 -0
- armnet_runtime/lerobot/remote_arx5.py +147 -0
- armnet_runtime/lerobot/remote_camera.py +145 -0
- armnet_runtime/lerobot/remote_feetech.py +160 -0
- armnet_runtime/markers.py +51 -0
- armnet_runtime/rerun.py +461 -0
- armnet_runtime-0.1.0.dist-info/METADATA +53 -0
- armnet_runtime-0.1.0.dist-info/RECORD +18 -0
- armnet_runtime-0.1.0.dist-info/WHEEL +4 -0
- armnet_runtime-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,974 @@
|
|
|
1
|
+
"""Job context surfaced to ``@main``-decorated functions.
|
|
2
|
+
|
|
3
|
+
The cell program injects job env vars plus a JSON-encoded cell config when it
|
|
4
|
+
starts the container; :func:`build_context` reads them and constructs the
|
|
5
|
+
:class:`Context` object that the ``armnet-runtime`` entrypoint passes to
|
|
6
|
+
the user's ``@main`` function.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
import time
|
|
14
|
+
import os
|
|
15
|
+
import shutil
|
|
16
|
+
import socket
|
|
17
|
+
import tempfile
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any, NamedTuple, Optional
|
|
21
|
+
from urllib.parse import urlparse
|
|
22
|
+
|
|
23
|
+
from armnet_core import Embodiment, RobotCellConfig, Task
|
|
24
|
+
|
|
25
|
+
from armnet_runtime import env as env_keys
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ResetTimeoutException(RuntimeError):
|
|
31
|
+
"""Raised by ``ctx.cell.reset()`` when a manual reset is not actioned in time.
|
|
32
|
+
|
|
33
|
+
The cell waits for an operator to confirm the reset (via the Fleet
|
|
34
|
+
Management System). If no confirmation arrives within the cell's reset
|
|
35
|
+
timeout, the cell trips its safety interlock (no further robot commands are
|
|
36
|
+
allowed, as with a safety violation) and this exception is raised into the
|
|
37
|
+
job code. Catch it to shut down gracefully — e.g. commit a dataset that was
|
|
38
|
+
being recorded — before letting the job fail::
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
ctx.cell.reset()
|
|
42
|
+
except ResetTimeoutException:
|
|
43
|
+
dataset.push_to_hub() # save what we collected
|
|
44
|
+
raise
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class CompletionStatus(NamedTuple):
|
|
49
|
+
"""Result of :meth:`Cell.is_complete`: whether the episode is over and how.
|
|
50
|
+
|
|
51
|
+
``complete`` is True once the current rollout/episode should end (the task
|
|
52
|
+
was scored complete by the automated monitor, or a human operator reported
|
|
53
|
+
an outcome). ``success`` is the verdict — for automated completion it equals
|
|
54
|
+
``complete`` (a task scored complete is a success); for a human-reported
|
|
55
|
+
outcome it is the operator's success/fail choice, so an operator can end a
|
|
56
|
+
rollout as a *failure* (e.g. the robot behaved dangerously).
|
|
57
|
+
|
|
58
|
+
Backwards-compatible truthiness: ``bool(status)`` is ``status.complete``, so
|
|
59
|
+
existing ``if ctx.cell.is_complete(): ...`` callers keep working, while new
|
|
60
|
+
code can unpack ``complete, success = ctx.cell.is_complete()``.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
complete: bool
|
|
64
|
+
success: bool
|
|
65
|
+
|
|
66
|
+
def __bool__(self) -> bool: # noqa: D401 - truthy iff the episode is complete
|
|
67
|
+
return self.complete
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# Bounded read timeout for polling the teleop register. Unlike reset (which
|
|
71
|
+
# waits, unbounded, for a human), a teleop read must never stall the control
|
|
72
|
+
# loop: the register is in-memory on the cell, so a response that doesn't come
|
|
73
|
+
# back well within this window means the channel is wedged and we should give
|
|
74
|
+
# up, reconnect next time, and hold position.
|
|
75
|
+
_TELEOP_READ_TIMEOUT_S = 1.0
|
|
76
|
+
# Throttle interval for teleop-channel error logs (they'd otherwise repeat at
|
|
77
|
+
# the control-loop rate while the channel is down).
|
|
78
|
+
_TELEOP_ERROR_LOG_INTERVAL_S = 5.0
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass
|
|
82
|
+
class Volume:
|
|
83
|
+
"""User volume mounted into the runtime container."""
|
|
84
|
+
|
|
85
|
+
root: Optional[Path] = None
|
|
86
|
+
|
|
87
|
+
def path(self, relative_path: str | Path) -> Path:
|
|
88
|
+
if self.root is None:
|
|
89
|
+
raise RuntimeError("armnet volume is not mounted in this context")
|
|
90
|
+
rel = Path(relative_path)
|
|
91
|
+
if rel.is_absolute() or ".." in rel.parts:
|
|
92
|
+
raise ValueError("volume path must be relative and must not contain '..'")
|
|
93
|
+
return self.root / rel
|
|
94
|
+
|
|
95
|
+
def read_bytes(self, relative_path: str | Path) -> bytes:
|
|
96
|
+
return self.path(relative_path).read_bytes()
|
|
97
|
+
|
|
98
|
+
def read_text(self, relative_path: str | Path) -> str:
|
|
99
|
+
return self.path(relative_path).read_text()
|
|
100
|
+
|
|
101
|
+
def write_bytes(self, relative_path: str | Path, data: bytes) -> Path:
|
|
102
|
+
path = self.path(relative_path)
|
|
103
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
104
|
+
path.write_bytes(data)
|
|
105
|
+
return path
|
|
106
|
+
|
|
107
|
+
def write_text(self, relative_path: str | Path, data: str) -> Path:
|
|
108
|
+
path = self.path(relative_path)
|
|
109
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
110
|
+
path.write_text(data)
|
|
111
|
+
return path
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@dataclass(frozen=True)
|
|
115
|
+
class RuntimeArm:
|
|
116
|
+
"""Runtime-facing handle for one named robot arm in a multi-arm cell."""
|
|
117
|
+
|
|
118
|
+
name: str
|
|
119
|
+
robot_port: str
|
|
120
|
+
robot_id: Optional[str] = None
|
|
121
|
+
calibration_dir: Optional[Path] = None
|
|
122
|
+
calibration_file_path: Optional[Path] = None
|
|
123
|
+
safety_limit: Optional[float] = None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@dataclass(frozen=True)
|
|
127
|
+
class BimanualCalibrationLayout:
|
|
128
|
+
"""Temporary calibration layout matching LeRobot's bimanual id convention."""
|
|
129
|
+
|
|
130
|
+
robot_id: str
|
|
131
|
+
calibration_dir: Path
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@dataclass
|
|
135
|
+
class Cell:
|
|
136
|
+
"""Handle to the physical cell the user code is running on.
|
|
137
|
+
|
|
138
|
+
M0.5 stub: there is no real cell yet, so ``robot_port`` is always ``None``
|
|
139
|
+
and :meth:`reset` is a no-op. The shape is fixed now so the spec
|
|
140
|
+
example compiles end-to-end and so M2/M3 can fill in the
|
|
141
|
+
implementation without touching customer-facing imports.
|
|
142
|
+
"""
|
|
143
|
+
|
|
144
|
+
robot_port: Optional[str] = None
|
|
145
|
+
"""Robot port value to pass into LeRobot robot configs.
|
|
146
|
+
|
|
147
|
+
In container-backed remote execution this is the connector endpoint, not
|
|
148
|
+
the host's physical serial path. The SDK's import-system swap routes that
|
|
149
|
+
endpoint through the cell-side connector, which then opens the real robot
|
|
150
|
+
port configured on the cell host.
|
|
151
|
+
"""
|
|
152
|
+
|
|
153
|
+
robot_id: Optional[str] = None
|
|
154
|
+
"""Stable robot id used by LeRobot to find calibration data."""
|
|
155
|
+
|
|
156
|
+
calibration_dir: Optional[Path] = None
|
|
157
|
+
"""Calibration store path visible inside the customer container."""
|
|
158
|
+
|
|
159
|
+
calibration_file_path: Optional[Path] = None
|
|
160
|
+
"""Exact LeRobot calibration file path visible inside the customer container."""
|
|
161
|
+
|
|
162
|
+
language_instruction: Optional[str] = None
|
|
163
|
+
"""Task instruction provided by the cell."""
|
|
164
|
+
|
|
165
|
+
local_control_endpoint: Optional[str] = None
|
|
166
|
+
"""Developer local-container control endpoint for keyboard-driven state."""
|
|
167
|
+
|
|
168
|
+
operator_call_endpoint: Optional[str] = None
|
|
169
|
+
"""Operator-call endpoint served by the cell program for human-in-the-loop
|
|
170
|
+
calls (manual reset confirmation). Distinct from ``robot_port``, which is the
|
|
171
|
+
robot/bus connector (potentially a headless edge device)."""
|
|
172
|
+
|
|
173
|
+
is_local_container: bool = False
|
|
174
|
+
"""True when running a Docker image locally for development."""
|
|
175
|
+
safety_limit: Optional[float] = None
|
|
176
|
+
"""Relative action safety limit exposed by the cell, if applicable."""
|
|
177
|
+
|
|
178
|
+
arms: dict[str, RuntimeArm] = field(default_factory=dict)
|
|
179
|
+
"""Named arms for bimanual/multi-arm cells."""
|
|
180
|
+
|
|
181
|
+
# Reused connection + log throttle for teleop polling (see get_teleop_action).
|
|
182
|
+
# Not part of the constructor or the public/comparable surface.
|
|
183
|
+
_teleop_conn: Any = field(default=None, init=False, repr=False, compare=False)
|
|
184
|
+
_teleop_last_error_log: float = field(default=0.0, init=False, repr=False, compare=False)
|
|
185
|
+
# Reused connection for polling the human-reported rollout outcome served by
|
|
186
|
+
# the cell program's operator-call endpoint (see is_complete).
|
|
187
|
+
_completion_conn: Any = field(default=None, init=False, repr=False, compare=False)
|
|
188
|
+
_completion_last_error_log: float = field(default=0.0, init=False, repr=False, compare=False)
|
|
189
|
+
|
|
190
|
+
@property
|
|
191
|
+
def is_bimanual(self) -> bool:
|
|
192
|
+
return {"left", "right"}.issubset(self.arms)
|
|
193
|
+
|
|
194
|
+
def arm(self, name: str) -> RuntimeArm:
|
|
195
|
+
try:
|
|
196
|
+
return self.arms[name]
|
|
197
|
+
except KeyError as exc:
|
|
198
|
+
raise KeyError(f"cell has no arm named {name!r}") from exc
|
|
199
|
+
|
|
200
|
+
def prepare_bimanual_calibration_dir(self) -> BimanualCalibrationLayout:
|
|
201
|
+
"""Create a temp calibration dir using LeRobot's `<base>_<arm>.json` names.
|
|
202
|
+
|
|
203
|
+
Each arm's source calibration is resolved (in order) from its own
|
|
204
|
+
``calibration_file_path``, its own ``calibration_dir`` keyed by the arm's
|
|
205
|
+
``robot_id``, or—when the arm declares neither—the **cell-level**
|
|
206
|
+
``calibration_dir`` keyed by the arm's ``robot_id`` (``<robot_id>.json``).
|
|
207
|
+
This mirrors how the cell's per-arm health check resolves calibration
|
|
208
|
+
(``arm.calibration_dir or cell.calibration_dir``), so a config that only
|
|
209
|
+
sets a top-level ``calibration_dir`` (per-arm ``robot_id`` only) works.
|
|
210
|
+
"""
|
|
211
|
+
|
|
212
|
+
if not self.is_bimanual:
|
|
213
|
+
raise RuntimeError("bimanual calibration requires left and right arms")
|
|
214
|
+
robot_id = self.robot_id
|
|
215
|
+
if not robot_id:
|
|
216
|
+
raise RuntimeError("bimanual calibration requires ctx.cell.robot_id")
|
|
217
|
+
calibration_dir = Path(tempfile.mkdtemp(prefix="armnet-bimanual-calibration-"))
|
|
218
|
+
for arm_name in ("left", "right"):
|
|
219
|
+
arm = self.arm(arm_name)
|
|
220
|
+
source = arm.calibration_file_path
|
|
221
|
+
if source is None:
|
|
222
|
+
cal_dir = arm.calibration_dir or self.calibration_dir
|
|
223
|
+
if cal_dir is not None and arm.robot_id:
|
|
224
|
+
source = Path(cal_dir) / f"{arm.robot_id}.json"
|
|
225
|
+
if source is None or not Path(source).is_file():
|
|
226
|
+
raise RuntimeError(
|
|
227
|
+
f"no calibration file found for {arm_name} arm "
|
|
228
|
+
f"(robot_id={arm.robot_id!r}); looked for "
|
|
229
|
+
f"{source if source is not None else '<unresolved>'}. Set the "
|
|
230
|
+
"cell-level calibration_dir (with per-arm robot_id) or each "
|
|
231
|
+
"arm's calibration_dir/calibration_file_path."
|
|
232
|
+
)
|
|
233
|
+
shutil.copy2(source, calibration_dir / f"{robot_id}_{arm_name}.json")
|
|
234
|
+
return BimanualCalibrationLayout(robot_id=robot_id, calibration_dir=calibration_dir)
|
|
235
|
+
|
|
236
|
+
def reset(self) -> None:
|
|
237
|
+
"""Return the robot to rest, then block until the operator confirms.
|
|
238
|
+
|
|
239
|
+
Two concerns, two endpoints:
|
|
240
|
+
|
|
241
|
+
1. Returning the arm to its rest position is a low-level bus operation,
|
|
242
|
+
so it is sent to the robot connector (``robot_port``), which may be a
|
|
243
|
+
headless edge device.
|
|
244
|
+
2. Operator confirmation is a human-in-the-loop concern, so it is sent
|
|
245
|
+
to the ``operator_call_endpoint`` served by the ``armnet-cell``
|
|
246
|
+
process, whose stdin is the operator's terminal.
|
|
247
|
+
|
|
248
|
+
The operator-facing prompt is owned by the cell, not by job code: job
|
|
249
|
+
code only signals *that* a reset point has been reached.
|
|
250
|
+
"""
|
|
251
|
+
|
|
252
|
+
self._report_progress("Waiting for workspace reset...")
|
|
253
|
+
|
|
254
|
+
# 1. Safety: return the arm to rest via the robot connector, if present.
|
|
255
|
+
if self.robot_port and _looks_like_connector_endpoint(self.robot_port):
|
|
256
|
+
response = _connector_request(self.robot_port, {"op": "return_to_rest"})
|
|
257
|
+
if not response.get("ok"):
|
|
258
|
+
raise RuntimeError(response.get("error", "robot return-to-rest failed"))
|
|
259
|
+
|
|
260
|
+
# 2. Operator confirmation on the cell-served operator-call endpoint
|
|
261
|
+
# (fallback to the dev local-control endpoint).
|
|
262
|
+
request = {"op": "reset", "request": {"kind": "manual"}}
|
|
263
|
+
operator_endpoint = self.operator_call_endpoint or self.local_control_endpoint
|
|
264
|
+
if operator_endpoint:
|
|
265
|
+
response = _connector_request(operator_endpoint, request)
|
|
266
|
+
if not response.get("ok"):
|
|
267
|
+
error = response.get("error", "operator reset confirmation failed")
|
|
268
|
+
if response.get("error_type") == "ResetTimeoutException":
|
|
269
|
+
raise ResetTimeoutException(error)
|
|
270
|
+
raise RuntimeError(error)
|
|
271
|
+
return
|
|
272
|
+
|
|
273
|
+
# No operator endpoint attached (degenerate in-process dev run): block on
|
|
274
|
+
# the local terminal with a standard prompt owned by the runtime.
|
|
275
|
+
input("Reset the cell workspace, then press Enter. ")
|
|
276
|
+
|
|
277
|
+
def is_complete(self, *, block: bool = False) -> CompletionStatus:
|
|
278
|
+
"""Return whether the current episode is complete, and its success.
|
|
279
|
+
|
|
280
|
+
Returns a :class:`CompletionStatus` ``(complete, success)``:
|
|
281
|
+
|
|
282
|
+
1. A human-reported outcome (an operator hitting success/fail in the
|
|
283
|
+
FMS during a live rollout) takes precedence and ends the episode
|
|
284
|
+
immediately, with ``success`` set to the operator's choice. This is
|
|
285
|
+
how an operator stops a dangerous rollout without stopping the job.
|
|
286
|
+
2. Otherwise the cell's automated completion monitor is consulted; a
|
|
287
|
+
task scored complete is reported as a success (``success ==
|
|
288
|
+
complete``). Pass ``block=True`` for a final episode check that waits
|
|
289
|
+
for the cell to score the latest cached frames before returning.
|
|
290
|
+
|
|
291
|
+
``bool(status)`` is ``status.complete`` for backwards compatibility.
|
|
292
|
+
"""
|
|
293
|
+
|
|
294
|
+
# 1. Human-reported outcome (operator/FMS) wins and ends the episode now.
|
|
295
|
+
reported = self._human_completion()
|
|
296
|
+
if reported is not None:
|
|
297
|
+
return CompletionStatus(complete=True, success=reported)
|
|
298
|
+
|
|
299
|
+
# 2. Automated completion scoring via the local-dev or cell connector.
|
|
300
|
+
request = {"op": "is_complete", "block": block}
|
|
301
|
+
if self.local_control_endpoint:
|
|
302
|
+
response = _connector_request(self.local_control_endpoint, request)
|
|
303
|
+
if not response.get("ok"):
|
|
304
|
+
raise RuntimeError(response.get("error", "local completion check failed"))
|
|
305
|
+
return _completion_from_response(response)
|
|
306
|
+
if self.robot_port and _looks_like_connector_endpoint(self.robot_port):
|
|
307
|
+
response = _connector_request(self.robot_port, request)
|
|
308
|
+
if not response.get("ok"):
|
|
309
|
+
raise RuntimeError(response.get("error", "cell completion check failed"))
|
|
310
|
+
return _completion_from_response(response)
|
|
311
|
+
return CompletionStatus(complete=False, success=False)
|
|
312
|
+
|
|
313
|
+
def _human_completion(self) -> Optional[bool]:
|
|
314
|
+
"""Return the operator's reported success/fail, or None if none pending.
|
|
315
|
+
|
|
316
|
+
Polls the cell program's operator-call endpoint (where FMS rollout
|
|
317
|
+
commands land). A wedged/slow channel must never stall the control
|
|
318
|
+
loop, so this mirrors get_teleop_action: bounded read, throttled error
|
|
319
|
+
logging, drop the connection on error, and treat failures as "no
|
|
320
|
+
outcome" so the episode simply continues.
|
|
321
|
+
"""
|
|
322
|
+
|
|
323
|
+
endpoint = self.operator_call_endpoint or self.local_control_endpoint
|
|
324
|
+
if not endpoint:
|
|
325
|
+
return None
|
|
326
|
+
|
|
327
|
+
if self._completion_conn is None or self._completion_conn.endpoint != endpoint:
|
|
328
|
+
if self._completion_conn is not None:
|
|
329
|
+
self._completion_conn.close()
|
|
330
|
+
self._completion_conn = _TeleopConnection(endpoint, timeout=_TELEOP_READ_TIMEOUT_S)
|
|
331
|
+
|
|
332
|
+
try:
|
|
333
|
+
response = self._completion_conn.request({"op": "get_completion"})
|
|
334
|
+
except Exception as exc: # noqa: BLE001
|
|
335
|
+
now = time.monotonic()
|
|
336
|
+
if now - self._completion_last_error_log >= _TELEOP_ERROR_LOG_INTERVAL_S:
|
|
337
|
+
self._completion_last_error_log = now
|
|
338
|
+
logger.warning(
|
|
339
|
+
"completion read from %s failed (treating as not complete): %r",
|
|
340
|
+
endpoint,
|
|
341
|
+
exc,
|
|
342
|
+
)
|
|
343
|
+
return None
|
|
344
|
+
|
|
345
|
+
if not response.get("ok") or not response.get("reported"):
|
|
346
|
+
return None
|
|
347
|
+
return bool(response.get("success", False))
|
|
348
|
+
|
|
349
|
+
def rollout_begin(
|
|
350
|
+
self,
|
|
351
|
+
*,
|
|
352
|
+
index: Optional[int] = None,
|
|
353
|
+
total: Optional[int] = None,
|
|
354
|
+
outcome_controls: bool = True,
|
|
355
|
+
) -> None:
|
|
356
|
+
"""Tell the platform a rollout/episode in this job's loop has started.
|
|
357
|
+
|
|
358
|
+
The cell publishes this to the FMS, which shows the loop progress
|
|
359
|
+
("rollout N / M") for the live job. Pass ``index`` (1-based) and, when
|
|
360
|
+
known, ``total`` so operators see how far along the loop is.
|
|
361
|
+
|
|
362
|
+
``outcome_controls`` controls whether the FMS also shows operator
|
|
363
|
+
success/fail buttons: keep the default ``True`` for policy evals; pass
|
|
364
|
+
``False`` for progress-only loops such as teleop data collection, where
|
|
365
|
+
a human verdict doesn't apply. Best-effort: a failed notification never
|
|
366
|
+
breaks the rollout. Pair with :meth:`rollout_end`.
|
|
367
|
+
"""
|
|
368
|
+
payload: dict[str, Any] = {"outcome_controls": bool(outcome_controls)}
|
|
369
|
+
if index is not None:
|
|
370
|
+
payload["index"] = int(index)
|
|
371
|
+
if total is not None:
|
|
372
|
+
payload["total"] = int(total)
|
|
373
|
+
self._rollout_signal("rollout_begin", **payload)
|
|
374
|
+
|
|
375
|
+
def rollout_end(self) -> None:
|
|
376
|
+
"""Tell the platform the current rollout has ended (hides FMS buttons)."""
|
|
377
|
+
self._rollout_signal("rollout_end")
|
|
378
|
+
|
|
379
|
+
def _rollout_signal(self, op: str, **payload: Any) -> None:
|
|
380
|
+
endpoint = self.operator_call_endpoint or self.local_control_endpoint
|
|
381
|
+
if not endpoint:
|
|
382
|
+
return
|
|
383
|
+
try:
|
|
384
|
+
_connector_request(endpoint, {"op": op, **payload})
|
|
385
|
+
except Exception: # noqa: BLE001 - rollout signalling is best-effort
|
|
386
|
+
logger.warning("rollout signal %s to %s failed", op, endpoint, exc_info=True)
|
|
387
|
+
|
|
388
|
+
def is_shutting_down(self) -> bool:
|
|
389
|
+
"""Return True once the cell has entered the job's post-timeout grace window.
|
|
390
|
+
|
|
391
|
+
When a job exceeds its ``timeout_seconds`` the cell does not kill the
|
|
392
|
+
container straight away: it trips the robot interlock (so any further
|
|
393
|
+
robot-bus calls fail) and opens a short *grace window* during which this
|
|
394
|
+
returns True, before force-killing the container. Poll it in your loop
|
|
395
|
+
and break out to finalize gracefully — e.g. save/push a dataset — instead
|
|
396
|
+
of being killed mid-write::
|
|
397
|
+
|
|
398
|
+
for episode in range(n):
|
|
399
|
+
if ctx.cell.is_shutting_down():
|
|
400
|
+
break # finalize below
|
|
401
|
+
...
|
|
402
|
+
|
|
403
|
+
Resilient by design: returns False when no cell/operator endpoint is
|
|
404
|
+
attached or the status can't be read, so it never stalls or crashes the
|
|
405
|
+
control loop.
|
|
406
|
+
"""
|
|
407
|
+
|
|
408
|
+
endpoint = self.operator_call_endpoint or self.local_control_endpoint
|
|
409
|
+
if not endpoint:
|
|
410
|
+
return False
|
|
411
|
+
try:
|
|
412
|
+
response = _connector_request(
|
|
413
|
+
endpoint, {"op": "shutdown_status"}, read_timeout=_TELEOP_READ_TIMEOUT_S
|
|
414
|
+
)
|
|
415
|
+
except Exception: # noqa: BLE001 - never let a status poll break the loop
|
|
416
|
+
return False
|
|
417
|
+
return bool(response.get("ok") and response.get("shutting_down"))
|
|
418
|
+
|
|
419
|
+
def should_stop(self) -> bool:
|
|
420
|
+
"""Return True when local/remote control asks user code to stop safely."""
|
|
421
|
+
|
|
422
|
+
request = {"op": "should_stop"}
|
|
423
|
+
if self.local_control_endpoint:
|
|
424
|
+
response = _connector_request(self.local_control_endpoint, request)
|
|
425
|
+
if not response.get("ok"):
|
|
426
|
+
raise RuntimeError(response.get("error", "local stop check failed"))
|
|
427
|
+
return bool(response.get("stop", False))
|
|
428
|
+
return False
|
|
429
|
+
|
|
430
|
+
def get_teleop_action(self) -> Optional[dict[str, float]]:
|
|
431
|
+
"""Return the freshest remote-teleoperation action for this job, or None.
|
|
432
|
+
|
|
433
|
+
The client samples a local leader arm and pushes actions to the cell,
|
|
434
|
+
which keeps only the most recent one (older messages are dropped). This
|
|
435
|
+
reads that most-recent-value register over the operator-call endpoint.
|
|
436
|
+
|
|
437
|
+
Returns ``None`` when no teleop has been received yet (or no operator
|
|
438
|
+
endpoint is attached), so a control loop can hold position until the
|
|
439
|
+
operator starts driving. The returned dict is keyed for LeRobot's
|
|
440
|
+
``send_action`` (e.g. ``{"shoulder_pan.pos": 12.3, ...}``).
|
|
441
|
+
"""
|
|
442
|
+
|
|
443
|
+
endpoint = self.operator_call_endpoint or self.local_control_endpoint
|
|
444
|
+
if not endpoint:
|
|
445
|
+
return None
|
|
446
|
+
|
|
447
|
+
if self._teleop_conn is None or self._teleop_conn.endpoint != endpoint:
|
|
448
|
+
if self._teleop_conn is not None:
|
|
449
|
+
self._teleop_conn.close()
|
|
450
|
+
self._teleop_conn = _TeleopConnection(endpoint, timeout=_TELEOP_READ_TIMEOUT_S)
|
|
451
|
+
|
|
452
|
+
try:
|
|
453
|
+
response = self._teleop_conn.request({"op": "get_teleop"})
|
|
454
|
+
except Exception as exc: # noqa: BLE001
|
|
455
|
+
# A wedged/slow teleop channel must not stall or crash the control
|
|
456
|
+
# loop: log (throttled) so a recurrence is diagnosable, drop the
|
|
457
|
+
# connection (already done in request()) so we reconnect next tick,
|
|
458
|
+
# and hold position by returning None.
|
|
459
|
+
now = time.monotonic()
|
|
460
|
+
if now - self._teleop_last_error_log >= _TELEOP_ERROR_LOG_INTERVAL_S:
|
|
461
|
+
self._teleop_last_error_log = now
|
|
462
|
+
logger.warning(
|
|
463
|
+
"teleop read from %s failed (holding position; will reconnect): %r",
|
|
464
|
+
endpoint,
|
|
465
|
+
exc,
|
|
466
|
+
)
|
|
467
|
+
return None
|
|
468
|
+
|
|
469
|
+
if not response.get("ok"):
|
|
470
|
+
logger.warning("teleop read returned error: %s", response.get("error"))
|
|
471
|
+
return None
|
|
472
|
+
action = response.get("action")
|
|
473
|
+
if not action:
|
|
474
|
+
return None
|
|
475
|
+
return {str(key): float(value) for key, value in action.items()}
|
|
476
|
+
|
|
477
|
+
def get_teleop_event(self) -> Optional[str]:
|
|
478
|
+
"""Return the next pending recording-control event, or None.
|
|
479
|
+
|
|
480
|
+
While teleoperating, the client can send discrete recording-control
|
|
481
|
+
events alongside the action stream — LeRobot's standard dataset
|
|
482
|
+
recording shortcuts: ``"next_episode"`` (Right Arrow: save the episode
|
|
483
|
+
and move on), ``"rerecord_episode"`` (Left Arrow: discard and redo) and
|
|
484
|
+
``"stop_recording"`` (Esc: end the session). The cell queues them in
|
|
485
|
+
arrival order; each call pops at most one.
|
|
486
|
+
|
|
487
|
+
Like :meth:`get_teleop_action`, a wedged channel never stalls the
|
|
488
|
+
control loop: errors log (throttled), drop the connection so the next
|
|
489
|
+
call reconnects, and return None.
|
|
490
|
+
"""
|
|
491
|
+
|
|
492
|
+
endpoint = self.operator_call_endpoint or self.local_control_endpoint
|
|
493
|
+
if not endpoint:
|
|
494
|
+
return None
|
|
495
|
+
|
|
496
|
+
if self._teleop_conn is None or self._teleop_conn.endpoint != endpoint:
|
|
497
|
+
if self._teleop_conn is not None:
|
|
498
|
+
self._teleop_conn.close()
|
|
499
|
+
self._teleop_conn = _TeleopConnection(endpoint, timeout=_TELEOP_READ_TIMEOUT_S)
|
|
500
|
+
|
|
501
|
+
try:
|
|
502
|
+
response = self._teleop_conn.request({"op": "get_teleop_event"})
|
|
503
|
+
except Exception as exc: # noqa: BLE001
|
|
504
|
+
now = time.monotonic()
|
|
505
|
+
if now - self._teleop_last_error_log >= _TELEOP_ERROR_LOG_INTERVAL_S:
|
|
506
|
+
self._teleop_last_error_log = now
|
|
507
|
+
logger.warning(
|
|
508
|
+
"teleop event read from %s failed (will reconnect): %r",
|
|
509
|
+
endpoint,
|
|
510
|
+
exc,
|
|
511
|
+
)
|
|
512
|
+
return None
|
|
513
|
+
|
|
514
|
+
if not response.get("ok"):
|
|
515
|
+
logger.warning("teleop event read returned error: %s", response.get("error"))
|
|
516
|
+
return None
|
|
517
|
+
event = response.get("event")
|
|
518
|
+
return str(event) if event else None
|
|
519
|
+
|
|
520
|
+
def _report_progress(self, message: str) -> None:
|
|
521
|
+
"""Surface a progress message back to the platform.
|
|
522
|
+
|
|
523
|
+
M0.5: prints to stdout with a discoverable marker so the cell's
|
|
524
|
+
captured stdout shows progress in order with other prints. M1+
|
|
525
|
+
will also publish a NATS message so the orchestrator can stream
|
|
526
|
+
progress back to the client without waiting for the job to
|
|
527
|
+
terminate.
|
|
528
|
+
"""
|
|
529
|
+
|
|
530
|
+
# Imported locally to avoid pulling markers into the public API
|
|
531
|
+
# surface of `Context`.
|
|
532
|
+
from armnet_runtime.markers import PROGRESS_MARKER
|
|
533
|
+
print(f"{PROGRESS_MARKER} {message}", flush=True)
|
|
534
|
+
time.sleep(0.01)
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def _completion_from_response(response: dict[str, Any]) -> CompletionStatus:
|
|
538
|
+
"""Build a CompletionStatus from a connector ``is_complete`` response.
|
|
539
|
+
|
|
540
|
+
Automated completion has no separate success signal, so ``success``
|
|
541
|
+
defaults to ``complete`` (a task scored complete is a success) unless the
|
|
542
|
+
connector explicitly reports one.
|
|
543
|
+
"""
|
|
544
|
+
complete = bool(response.get("complete", False))
|
|
545
|
+
success = bool(response.get("success", complete))
|
|
546
|
+
return CompletionStatus(complete=complete, success=success)
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def _looks_like_connector_endpoint(endpoint: str) -> bool:
|
|
550
|
+
if endpoint.startswith("tcp://"):
|
|
551
|
+
return True
|
|
552
|
+
path = Path(endpoint)
|
|
553
|
+
return path.exists() and path.is_socket()
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def _connector_request(
|
|
557
|
+
endpoint: str, payload: dict[str, Any], *, read_timeout: float | None = None
|
|
558
|
+
) -> dict[str, Any]:
|
|
559
|
+
sock = _connect_connector(endpoint)
|
|
560
|
+
with sock:
|
|
561
|
+
# ``read_timeout`` bounds the response wait (used by polling calls that
|
|
562
|
+
# must never stall a control loop). Operator-paced calls such as ``reset``
|
|
563
|
+
# leave it None so the wait stays unbounded after the connection succeeds.
|
|
564
|
+
sock.settimeout(read_timeout)
|
|
565
|
+
sock.sendall(json.dumps(payload).encode("utf-8") + b"\n")
|
|
566
|
+
data = _read_line(sock)
|
|
567
|
+
return json.loads(data.decode("utf-8"))
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def _connect_connector(endpoint: str) -> socket.socket:
|
|
571
|
+
if endpoint.startswith("tcp://"):
|
|
572
|
+
parsed = urlparse(endpoint)
|
|
573
|
+
if not parsed.hostname or not parsed.port:
|
|
574
|
+
raise RuntimeError(f"invalid TCP connector endpoint: {endpoint!r}")
|
|
575
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
576
|
+
sock.settimeout(10.0)
|
|
577
|
+
sock.connect((parsed.hostname, parsed.port))
|
|
578
|
+
return sock
|
|
579
|
+
|
|
580
|
+
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
581
|
+
sock.settimeout(10.0)
|
|
582
|
+
sock.connect(endpoint)
|
|
583
|
+
return sock
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def _read_line(sock: socket.socket) -> bytes:
|
|
587
|
+
chunks: list[bytes] = []
|
|
588
|
+
while True:
|
|
589
|
+
chunk = sock.recv(65536)
|
|
590
|
+
if not chunk:
|
|
591
|
+
break
|
|
592
|
+
chunks.append(chunk)
|
|
593
|
+
if b"\n" in chunk:
|
|
594
|
+
break
|
|
595
|
+
data = b"".join(chunks)
|
|
596
|
+
return data.split(b"\n", 1)[0]
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
class _TeleopConnection:
|
|
600
|
+
"""Reused, bounded-timeout line connection for polling teleop from the cell.
|
|
601
|
+
|
|
602
|
+
Distinct from :func:`_connector_request` (a fresh socket per call with an
|
|
603
|
+
*unbounded* read, which is correct for operator-paced ``reset`` but would
|
|
604
|
+
let a wedged response hang the control loop forever). This keeps one socket
|
|
605
|
+
open across control steps and uses a short read timeout; on any error the
|
|
606
|
+
socket is dropped so the next request transparently reconnects.
|
|
607
|
+
"""
|
|
608
|
+
|
|
609
|
+
def __init__(self, endpoint: str, *, timeout: float) -> None:
|
|
610
|
+
self.endpoint = endpoint
|
|
611
|
+
self._timeout = timeout
|
|
612
|
+
self._sock: Optional[socket.socket] = None
|
|
613
|
+
self._buf = b""
|
|
614
|
+
|
|
615
|
+
def request(self, payload: dict[str, Any]) -> dict[str, Any]:
|
|
616
|
+
sock = self._ensure()
|
|
617
|
+
try:
|
|
618
|
+
sock.settimeout(self._timeout)
|
|
619
|
+
sock.sendall(json.dumps(payload).encode("utf-8") + b"\n")
|
|
620
|
+
line = self._read_line(sock)
|
|
621
|
+
except Exception:
|
|
622
|
+
# Drop the (possibly half-open) socket so the next call reconnects.
|
|
623
|
+
self.close()
|
|
624
|
+
raise
|
|
625
|
+
return json.loads(line.decode("utf-8"))
|
|
626
|
+
|
|
627
|
+
def _ensure(self) -> socket.socket:
|
|
628
|
+
if self._sock is None:
|
|
629
|
+
self._sock = _connect_connector(self.endpoint)
|
|
630
|
+
self._buf = b""
|
|
631
|
+
return self._sock
|
|
632
|
+
|
|
633
|
+
def _read_line(self, sock: socket.socket) -> bytes:
|
|
634
|
+
while b"\n" not in self._buf:
|
|
635
|
+
chunk = sock.recv(65536)
|
|
636
|
+
if not chunk:
|
|
637
|
+
raise ConnectionError("teleop connection closed by the cell")
|
|
638
|
+
self._buf += chunk
|
|
639
|
+
line, _, self._buf = self._buf.partition(b"\n")
|
|
640
|
+
return line
|
|
641
|
+
|
|
642
|
+
def close(self) -> None:
|
|
643
|
+
if self._sock is not None:
|
|
644
|
+
try:
|
|
645
|
+
self._sock.close()
|
|
646
|
+
except Exception: # noqa: BLE001
|
|
647
|
+
pass
|
|
648
|
+
self._sock = None
|
|
649
|
+
self._buf = b""
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
@dataclass
|
|
653
|
+
class Context:
|
|
654
|
+
"""Everything a ``@main``-decorated function needs from the platform."""
|
|
655
|
+
|
|
656
|
+
job_id: str
|
|
657
|
+
embodiment: Embodiment
|
|
658
|
+
task: Task
|
|
659
|
+
args: dict[str, Any] = field(default_factory=dict)
|
|
660
|
+
cell: Cell = field(default_factory=Cell)
|
|
661
|
+
camera_configs: dict[str, Any] = field(default_factory=dict)
|
|
662
|
+
cache_home: Optional[Path] = None
|
|
663
|
+
volume: Volume = field(default_factory=Volume)
|
|
664
|
+
secrets: dict[str, str] = field(default_factory=dict)
|
|
665
|
+
timeout_seconds: Optional[int] = None
|
|
666
|
+
# Lazily created background Rerun streamer (see log_rerun_data). Not part of
|
|
667
|
+
# the constructor or the public/comparable surface.
|
|
668
|
+
_rerun_streamer: Any = field(default=None, init=False, repr=False, compare=False)
|
|
669
|
+
|
|
670
|
+
def report_progress(self, message: str) -> None:
|
|
671
|
+
"""Surface a progress message back to the platform.
|
|
672
|
+
|
|
673
|
+
M0.5: prints to stdout with a discoverable marker so the cell's
|
|
674
|
+
captured stdout shows progress in order with other prints. M1+
|
|
675
|
+
will also publish a NATS message so the orchestrator can stream
|
|
676
|
+
progress back to the client without waiting for the job to
|
|
677
|
+
terminate.
|
|
678
|
+
"""
|
|
679
|
+
|
|
680
|
+
# Imported locally to avoid pulling markers into the public API
|
|
681
|
+
# surface of `Context`.
|
|
682
|
+
from armnet_runtime.markers import PROGRESS_MARKER
|
|
683
|
+
print(f"{PROGRESS_MARKER} {message}", flush=True)
|
|
684
|
+
|
|
685
|
+
def is_shutting_down(self) -> bool:
|
|
686
|
+
"""Whether the cell has entered the job's post-timeout grace window.
|
|
687
|
+
|
|
688
|
+
Convenience delegate for :meth:`Cell.is_shutting_down`. Poll it in long
|
|
689
|
+
loops and break out to finalize gracefully before the cell kills the
|
|
690
|
+
container.
|
|
691
|
+
"""
|
|
692
|
+
return self.cell.is_shutting_down()
|
|
693
|
+
|
|
694
|
+
def log_rerun_data(
|
|
695
|
+
self,
|
|
696
|
+
observation: dict[str, Any] | None = None,
|
|
697
|
+
action: dict[str, Any] | None = None,
|
|
698
|
+
*,
|
|
699
|
+
compress_images: bool = True,
|
|
700
|
+
jpeg_quality: int = 75,
|
|
701
|
+
) -> None:
|
|
702
|
+
"""Stream observation/action data to a Rerun viewer on the client.
|
|
703
|
+
|
|
704
|
+
Mirrors LeRobot's ``log_rerun_data``: scalars are logged as Rerun
|
|
705
|
+
scalars, image-like arrays as images, and other arrays as per-element
|
|
706
|
+
scalars. Keys are namespaced with ``observation.`` / ``action.`` when
|
|
707
|
+
not already.
|
|
708
|
+
|
|
709
|
+
Unlike the LeRobot helper, this does not call ``rr.log`` in-process
|
|
710
|
+
(the cell container has no viewer). Instead it serializes a protobuf
|
|
711
|
+
packet and emits it on stdout behind a marker; the cell republishes it
|
|
712
|
+
on ``logs.<job_id>.rerun`` and the client's orchestrate script replays
|
|
713
|
+
it into the viewer it started with ``rr.init(...)``.
|
|
714
|
+
|
|
715
|
+
Images are JPEG-compressed by default to keep the NATS stream light;
|
|
716
|
+
set ``compress_images=False`` to send raw RGB. opencv is required for
|
|
717
|
+
compression and numpy for any array handling; both are imported lazily.
|
|
718
|
+
|
|
719
|
+
Non-blocking: the snapshot is handed to a background worker thread that
|
|
720
|
+
does the encoding and stdout write, so the calling control loop never
|
|
721
|
+
stalls on visualization. The worker's queue is bounded and drops the
|
|
722
|
+
oldest pending frame under backpressure (tune with
|
|
723
|
+
``ARMNET_RERUN_QUEUE_MAXSIZE``), so a slow consumer sheds frames
|
|
724
|
+
rather than slowing the robot loop.
|
|
725
|
+
"""
|
|
726
|
+
|
|
727
|
+
if not observation and not action:
|
|
728
|
+
return
|
|
729
|
+
|
|
730
|
+
from armnet_runtime.rerun import RerunStreamer
|
|
731
|
+
|
|
732
|
+
if self._rerun_streamer is None:
|
|
733
|
+
self._rerun_streamer = RerunStreamer(self.job_id)
|
|
734
|
+
self._rerun_streamer.start()
|
|
735
|
+
self._rerun_streamer.submit(
|
|
736
|
+
observation,
|
|
737
|
+
action,
|
|
738
|
+
compress_images=compress_images,
|
|
739
|
+
jpeg_quality=jpeg_quality,
|
|
740
|
+
)
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
SO101_EMBODIMENT = "lerobot/so-101"
|
|
744
|
+
BIMANUAL_SO101_EMBODIMENT = "lerobot/bimanual_so101"
|
|
745
|
+
|
|
746
|
+
|
|
747
|
+
def require_so101_embodiment(ctx: "Context", runtime_name: str) -> bool:
|
|
748
|
+
"""Validate the job's embodiment is a (single or bimanual) SO-101.
|
|
749
|
+
|
|
750
|
+
The embodiment is the source of truth for how many arms the robot has —
|
|
751
|
+
``lerobot/so-101`` is a single arm, ``lerobot/bimanual_so101`` is two — and
|
|
752
|
+
the orchestrator only routes a job to a cell of the matching embodiment.
|
|
753
|
+
Returns ``True`` for the bimanual embodiment (so the caller builds a two-arm
|
|
754
|
+
robot), ``False`` for single-arm.
|
|
755
|
+
|
|
756
|
+
Raises :class:`NotImplementedError` for any other embodiment, and
|
|
757
|
+
:class:`RuntimeError` if the embodiment's arm count disagrees with the cell's
|
|
758
|
+
actual wiring (``ctx.cell.is_bimanual``) — a misrouted or misconfigured cell.
|
|
759
|
+
"""
|
|
760
|
+
if ctx.embodiment not in (SO101_EMBODIMENT, BIMANUAL_SO101_EMBODIMENT):
|
|
761
|
+
raise NotImplementedError(
|
|
762
|
+
f"{runtime_name} supports {SO101_EMBODIMENT!r} and "
|
|
763
|
+
f"{BIMANUAL_SO101_EMBODIMENT!r}, got {ctx.embodiment!r}"
|
|
764
|
+
)
|
|
765
|
+
expect_bimanual = ctx.embodiment == BIMANUAL_SO101_EMBODIMENT
|
|
766
|
+
if expect_bimanual != ctx.cell.is_bimanual:
|
|
767
|
+
raise RuntimeError(
|
|
768
|
+
f"embodiment {ctx.embodiment!r} expects "
|
|
769
|
+
f"{'two arms (left+right)' if expect_bimanual else 'a single arm'}, "
|
|
770
|
+
f"but the cell exposes arms={sorted(ctx.cell.arms)}"
|
|
771
|
+
)
|
|
772
|
+
return expect_bimanual
|
|
773
|
+
|
|
774
|
+
|
|
775
|
+
def build_context() -> Context:
|
|
776
|
+
"""Read the cell-injected env vars and construct a :class:`Context`.
|
|
777
|
+
|
|
778
|
+
Called by the ``armnet-runtime`` entrypoint before invoking the
|
|
779
|
+
user's ``@main`` function. Raises :class:`RuntimeError` with a clear
|
|
780
|
+
message if any required env var is missing \u2014 that indicates the
|
|
781
|
+
program is being run outside a armnet cell.
|
|
782
|
+
"""
|
|
783
|
+
|
|
784
|
+
job_id = os.environ.get(env_keys.JOB_ID)
|
|
785
|
+
embodiment_raw = os.environ.get(env_keys.EMBODIMENT)
|
|
786
|
+
task_raw = os.environ.get(env_keys.TASK)
|
|
787
|
+
if not job_id or not embodiment_raw or not task_raw:
|
|
788
|
+
raise RuntimeError(
|
|
789
|
+
"armnet runtime env vars not set; this program is meant to "
|
|
790
|
+
"be executed inside a armnet cell container. Missing one of: "
|
|
791
|
+
f"{env_keys.JOB_ID}, {env_keys.EMBODIMENT}, {env_keys.TASK}."
|
|
792
|
+
)
|
|
793
|
+
|
|
794
|
+
timeout_raw = os.environ.get(env_keys.TIMEOUT_SECONDS)
|
|
795
|
+
timeout = int(timeout_raw) if timeout_raw and timeout_raw.isdigit() else None
|
|
796
|
+
|
|
797
|
+
args_raw = os.environ.get(env_keys.ARGS, "{}")
|
|
798
|
+
try:
|
|
799
|
+
args = json.loads(args_raw)
|
|
800
|
+
except json.JSONDecodeError as exc:
|
|
801
|
+
raise RuntimeError(
|
|
802
|
+
f"{env_keys.ARGS} env var is not valid JSON: {exc}"
|
|
803
|
+
) from exc
|
|
804
|
+
if not isinstance(args, dict):
|
|
805
|
+
raise RuntimeError(
|
|
806
|
+
f"{env_keys.ARGS} must decode to a JSON object (dict); got {type(args).__name__}."
|
|
807
|
+
)
|
|
808
|
+
|
|
809
|
+
cell_config = _load_cell_config()
|
|
810
|
+
return Context(
|
|
811
|
+
job_id=job_id,
|
|
812
|
+
embodiment=embodiment_raw,
|
|
813
|
+
task=task_raw,
|
|
814
|
+
args=args,
|
|
815
|
+
cell=Cell(
|
|
816
|
+
robot_port=(
|
|
817
|
+
os.environ.get(env_keys.CELL_SOCKET)
|
|
818
|
+
or (cell_config.robot_connector_endpoint if cell_config else None)
|
|
819
|
+
or (cell_config.connector_socket_path if cell_config else None)
|
|
820
|
+
or (cell_config.robot_port if cell_config else None)
|
|
821
|
+
),
|
|
822
|
+
operator_call_endpoint=(
|
|
823
|
+
os.environ.get(env_keys.OPERATOR_CALL_ENDPOINT)
|
|
824
|
+
or (cell_config.operator_call_endpoint if cell_config else None)
|
|
825
|
+
),
|
|
826
|
+
robot_id=os.environ.get(env_keys.ROBOT_ID)
|
|
827
|
+
or (cell_config.robot_id if cell_config else None),
|
|
828
|
+
calibration_dir=_path_from_env_or_config(
|
|
829
|
+
env_keys.CALIBRATION_DIR,
|
|
830
|
+
cell_config.calibration_dir if cell_config else None,
|
|
831
|
+
),
|
|
832
|
+
calibration_file_path=_path_from_env_or_config(
|
|
833
|
+
env_keys.CALIBRATION_FILE_PATH,
|
|
834
|
+
cell_config.calibration_file_path if cell_config else None,
|
|
835
|
+
),
|
|
836
|
+
language_instruction=cell_config.language_instruction if cell_config else None,
|
|
837
|
+
local_control_endpoint=os.environ.get(env_keys.LOCAL_CONTROL_ENDPOINT),
|
|
838
|
+
is_local_container=bool(os.environ.get(env_keys.LOCAL_CONTAINER)),
|
|
839
|
+
safety_limit=_cell_safety_limit(cell_config, embodiment_raw),
|
|
840
|
+
arms=_build_runtime_arms(cell_config),
|
|
841
|
+
),
|
|
842
|
+
camera_configs=_build_camera_configs(
|
|
843
|
+
cell_config.camera_configs if cell_config else {}
|
|
844
|
+
),
|
|
845
|
+
cache_home=_path_from_env_or_config(env_keys.CACHE_HOME, None),
|
|
846
|
+
volume=Volume(root=_path_from_env_or_config(env_keys.VOLUME_HOME, None)),
|
|
847
|
+
secrets=_load_secrets(),
|
|
848
|
+
timeout_seconds=timeout,
|
|
849
|
+
)
|
|
850
|
+
|
|
851
|
+
|
|
852
|
+
def _load_secrets() -> dict[str, str]:
|
|
853
|
+
raw = os.environ.get(env_keys.SECRETS, "{}")
|
|
854
|
+
try:
|
|
855
|
+
value = json.loads(raw)
|
|
856
|
+
except json.JSONDecodeError as exc:
|
|
857
|
+
raise RuntimeError(f"{env_keys.SECRETS} env var is not valid JSON: {exc}") from exc
|
|
858
|
+
if not isinstance(value, dict):
|
|
859
|
+
raise RuntimeError(f"{env_keys.SECRETS} must decode to a JSON object.")
|
|
860
|
+
return {str(key): str(item) for key, item in value.items()}
|
|
861
|
+
|
|
862
|
+
|
|
863
|
+
def _cell_safety_limit(cell_config: RobotCellConfig | None, embodiment: str) -> float | None:
|
|
864
|
+
"""Resolve the runtime max_relative_target (degrees) for a cell.
|
|
865
|
+
|
|
866
|
+
``safety_limit`` and ``safety_delta_degrees`` are the same concept (max
|
|
867
|
+
allowed relative joint motion in degrees) under two names: ``safety_limit``
|
|
868
|
+
is the runtime/LeRobot-facing name (``max_relative_target``), while configs
|
|
869
|
+
canonically declare ``robot.safety_delta_degrees`` (also pushed to the edge
|
|
870
|
+
connector for its own safety checks). An explicit top-level ``safety_limit``
|
|
871
|
+
wins; otherwise the SO-101 family (single-arm and bimanual) derives it from
|
|
872
|
+
the configured ``safety_delta_degrees``. Embodiments with connector-only
|
|
873
|
+
safety (e.g. ARX5) leave it unset.
|
|
874
|
+
"""
|
|
875
|
+
if cell_config and cell_config.safety_limit is not None:
|
|
876
|
+
return float(cell_config.safety_limit)
|
|
877
|
+
if embodiment.startswith("lerobot/so-") or embodiment == BIMANUAL_SO101_EMBODIMENT:
|
|
878
|
+
return float(cell_config.safety_delta_degrees) if cell_config else 30.0
|
|
879
|
+
return None
|
|
880
|
+
|
|
881
|
+
|
|
882
|
+
def _build_runtime_arms(cell_config: RobotCellConfig | None) -> dict[str, RuntimeArm]:
|
|
883
|
+
if cell_config is None or not cell_config.arms:
|
|
884
|
+
return {}
|
|
885
|
+
base_endpoint = cell_config.robot_connector_endpoint or cell_config.connector_socket_path
|
|
886
|
+
if not base_endpoint:
|
|
887
|
+
return {}
|
|
888
|
+
return {
|
|
889
|
+
name: RuntimeArm(
|
|
890
|
+
name=name,
|
|
891
|
+
robot_port=_arm_connector_endpoint(base_endpoint, name),
|
|
892
|
+
robot_id=arm.robot_id,
|
|
893
|
+
calibration_dir=Path(arm.calibration_dir) if arm.calibration_dir else None,
|
|
894
|
+
calibration_file_path=(
|
|
895
|
+
Path(arm.calibration_file_path) if arm.calibration_file_path else None
|
|
896
|
+
),
|
|
897
|
+
# Per-arm configs declare safety as ``safety_delta_degrees`` (same as
|
|
898
|
+
# the cell level); fall back to it so the arm's max_relative_target is
|
|
899
|
+
# applied. ``safety_limit`` is accepted too for symmetry.
|
|
900
|
+
safety_limit=(
|
|
901
|
+
arm.safety_limit if arm.safety_limit is not None else arm.safety_delta_degrees
|
|
902
|
+
),
|
|
903
|
+
)
|
|
904
|
+
for name, arm in cell_config.arms.items()
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
|
|
908
|
+
def _arm_connector_endpoint(base_endpoint: str, arm_name: str) -> str:
|
|
909
|
+
if not base_endpoint.startswith("tcp://"):
|
|
910
|
+
return base_endpoint
|
|
911
|
+
parsed = urlparse(base_endpoint)
|
|
912
|
+
query = f"arm={arm_name}"
|
|
913
|
+
if parsed.query:
|
|
914
|
+
query = f"{parsed.query}&{query}"
|
|
915
|
+
return parsed._replace(query=query).geturl()
|
|
916
|
+
|
|
917
|
+
|
|
918
|
+
def _load_cell_config() -> RobotCellConfig | None:
|
|
919
|
+
raw = os.environ.get(env_keys.CELL_CONFIG)
|
|
920
|
+
if not raw:
|
|
921
|
+
return None
|
|
922
|
+
try:
|
|
923
|
+
return RobotCellConfig.model_validate_json(raw)
|
|
924
|
+
except Exception as exc: # noqa: BLE001
|
|
925
|
+
raise RuntimeError(f"{env_keys.CELL_CONFIG} env var is not valid RobotCellConfig JSON: {exc}") from exc
|
|
926
|
+
|
|
927
|
+
|
|
928
|
+
def _path_from_env_or_config(env_key: str, configured: str | None) -> Path | None:
|
|
929
|
+
value = os.environ.get(env_key) or configured
|
|
930
|
+
return Path(value) if value else None
|
|
931
|
+
|
|
932
|
+
|
|
933
|
+
def _build_camera_configs(camera_configs: dict[str, Any] | None) -> dict[str, Any]:
|
|
934
|
+
if not camera_configs:
|
|
935
|
+
return {}
|
|
936
|
+
return {
|
|
937
|
+
name: _build_camera_config(config)
|
|
938
|
+
for name, config in camera_configs.items()
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
|
|
942
|
+
def _build_camera_config(config: Any) -> Any:
|
|
943
|
+
if not isinstance(config, dict):
|
|
944
|
+
return config
|
|
945
|
+
|
|
946
|
+
camera_type = str(config.get("type", "opencv")).lower()
|
|
947
|
+
# Strip keys that are not OpenCVCameraConfig params (e.g. arducam_mount is
|
|
948
|
+
# only used by the edge connector for camera mount actuation; id_path is the
|
|
949
|
+
# USB port used at provisioning time to create the /dev/video_<name> symlink).
|
|
950
|
+
_NON_CAMERA_KEYS = {"type", "arducam_mount", "id_path"}
|
|
951
|
+
kwargs = {key: value for key, value in config.items() if key not in _NON_CAMERA_KEYS}
|
|
952
|
+
if camera_type != "opencv":
|
|
953
|
+
return config
|
|
954
|
+
|
|
955
|
+
try:
|
|
956
|
+
from lerobot.cameras import ColorMode
|
|
957
|
+
from lerobot.cameras.opencv import OpenCVCameraConfig
|
|
958
|
+
except ImportError:
|
|
959
|
+
return config
|
|
960
|
+
|
|
961
|
+
if "color_mode" in kwargs and not isinstance(kwargs["color_mode"], ColorMode):
|
|
962
|
+
kwargs["color_mode"] = _coerce_enum(ColorMode, kwargs["color_mode"])
|
|
963
|
+
return OpenCVCameraConfig(**kwargs)
|
|
964
|
+
|
|
965
|
+
|
|
966
|
+
def _coerce_enum(enum_cls: Any, value: Any) -> Any:
|
|
967
|
+
if isinstance(value, enum_cls):
|
|
968
|
+
return value
|
|
969
|
+
if isinstance(value, str):
|
|
970
|
+
try:
|
|
971
|
+
return enum_cls[value.upper()]
|
|
972
|
+
except KeyError:
|
|
973
|
+
return enum_cls(value)
|
|
974
|
+
return enum_cls(value)
|