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.
@@ -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)