mirror of
https://github.com/huggingface/lerobot.git
synced 2026-07-13 13:01:58 +00:00
293a8d9a77
* Add Isaac Teleop SO-101 leader-arm teleoperator
Add the NVIDIA Isaac Teleop teleoperator scaffolding and its first device:
SO101LeaderArm, a back-drivable SO-101 leader arm on Isaac Teleop's generic
joint-space device path. It reads the leader's joints from the so101_leader
plugin via JointStateSource and emits follower-ready {joint}.pos (rad2deg arm,
gripper -> RANGE_0_100) for direct 1:1 joint drive.
- IsaacTeleopTeleoperator base + IsaacTeleopConfig (shared session/CloudXR config)
- SO101LeaderArm / SO101LeaderArmConfig and leader_joints_to_robot_action
- examples/isaac_teleop_to_so101/teleoperate_leader.py example
- pure-numpy conversion tests
- isaac-teleop optional extra + NVIDIA PyPI index in pyproject
* Add Isaac Teleop XR controller teleoperator for SO-101
Add end-to-end XR (VR) controller teleoperation of an SO-101 follower arm via
the NVIDIA Isaac Teleop stack, layered on the Isaac Teleop scaffolding.
Teleoperator (src/lerobot/teleoperators/isaac_teleop/):
- XRController / XRControllerConfig: connect to the CloudXR runtime, auto-launch
the Isaac Teleop session, and expose get_action() emitting the raw base-frame
grip pose, squeeze, and trigger.
- MapXRControllerActionToRobotAction: stateless per-frame mapper from the XR
action to the IK input contract (absolute ee.x/y/z, ee.gripper_pos, wrist_roll).
- OverwriteWristRollFromAngle: post-IK step writing the operator wrist-roll [rad]
onto wrist_roll.pos [deg], recovering the under-determined roll DOF.
Example (examples/isaac_teleop_to_so101/):
- teleoperate.py: thin absolute-pose IK pipeline with an in-loop clutch (engage
latch + 1:1 delta rebase of position and orientation), EEBoundsAndSafety, and
InverseKinematicsEEToJoints; slews to a recorded home on startup.
- record_reset_pose.py / download_assets.py / webxr.env / .gitignore.
Also:
- Extend robot_kinematic_processor.py with EEBoundsAndSafety and
InverseKinematicsEEToJoints.
- Add XRControllerConfig + base_T_anchor to the Isaac Teleop config.
- Add docs/source/isaac_teleop.mdx and the _toctree entry.
- Add unit tests for the CloudXR launcher and the XR controller processor.
* Unify Isaac Teleop SO-101 scripts behind a mandatory device selector
Merge teleoperate.py (XR controller: clutch + soft-orientation IK) and
teleoperate_leader.py (SO-101 leader arm: 1:1 joint mirror) into a single
teleoperate.py driven by a `lerobot-teleoperate`-style draccus CLI: a follower
`--robot.*` and an input `--teleop.*`, where `--teleop.type` (xr_controller |
so101_leader) selects the Isaac device.
Uses a "dispatch, don't merge" shape: per-device setup_xr/setup_leader build a
Device bundle (compute / startup / cleanup / command); a shared slew() takes a
per-step target callable (XR a fixed reset pose, leader a live re-read so the
1:1 handoff stays continuous); one device-branchless outer loop runs both, with
compute() -> None meaning "hold at the measured pose" (XR disengaged or leader
stale). The entrypoint is @parser.wrap()'d over a TeleoperateConfig dataclass and
dispatches on the parsed config type; device knobs ride on --teleop.* (the leader
serial port is --teleop.port, forwarded to the plugin) and loop/launch knobs are
top-level (--launch_plugin=<path> collapses the old --launch-plugin/--plugin-bin
pair; --reset_to_origin/--align/--dry_run).
To let the Isaac devices claim the natural --teleop.type names without colliding
with the serial so101_leader of lerobot-teleoperate, give IsaacTeleopConfig its
own draccus choice registry (own _choice_registry, decoupled from the global
TeleoperatorConfig one) and register XRControllerConfig as "xr_controller" and
SO101LeaderArmConfig as "so101_leader" there; the example types its teleop field
as IsaacTeleopConfig so the choices resolve against that scoped registry. These
devices drive the bespoke clutch/IK/align loop and are not routed through
make_teleoperator_from_config, so dropping them from the global registry is inert.
YAGNI sweep of the commit train: delete the orphaned OverwriteWristRollFromAngle
(wrist_roll_processor.py) plus its export and tests -- no producer emits
wrist_roll; the live XR path uses orientation-weight IK on the 5-DOF arm by
design. Kept the load-bearing knobs (orientation_weight, raise_on_jump,
base_T_anchor) and the optional reset-pose recorder. Updated isaac_teleop.mdx
for the unified entrypoint and excised the stale roll-retargeter prose.
Net LOC down (two scripts 714 lines -> one), in-loop device branches reduced to
zero. Planned and reviewed via a 6-persona multi-agent panel (3-round planning
convergence + 2-round review). Verification (isaacteleop/placo not installable
here, so the device classes cannot connect, but their config dataclasses and the
script import fine via deferred imports): the teleoperators test suite passes
(45 passed, 2 skipped), draccus parsing of both target command lines yields the
right config subclass with scoped --teleop.type, --help renders the scoped
choices, the serial so101_leader stays in the global registry, and ruff
check/format are green.
Signed-off-by: Jiwen Cai <jiwenc@nvidia.com>
* Add Isaac Teleop SO-101 dataset recording script
record.py records a LeRobot dataset while driving the SO-101 follower
with either Isaac Teleop device (--teleop.type=xr_controller |
so101_leader), mirroring teleoperate.py's device dispatch.
* Extract shared Isaac Teleop SO-101 example infra into common.py
teleoperate.py and record.py both built the per-device pipeline and ran the
same read -> compute -> hold-when-idle -> sleep loop, with record.py importing
internals from teleoperate.py. Move the shared device/loop infrastructure
(Device, slew, Clutch, setup_xr/setup_leader + leader helpers, reset infra and
constants) into a new common.py, and add build_device() + hold_action() to
collapse the connect/dispatch/startup and idle-hold glue duplicated in both
entry points. The setup functions now type their config against a LoopConfig
Protocol, so common.py is decoupled from either CLI; both import from it.
Also rename record_reset_pose.py -> override_reset_pose.py so it is not confused
with record.py, and update the doc references.
* Add stdin keyboard backend so recording shortcuts work over SSH/headless
lerobot's init_keyboard_listener() uses pynput, which hooks GLOBAL key events
from the display server. Over SSH, under Wayland, or on a headless box with only a
TTY, keystrokes go to the terminal's stdin instead, so the listener never fires and
the Right/Left/Esc recording shortcuts silently do nothing.
Add a stdin (termios) keyboard backend to the example's common.py and an
init_keyboard_listener() that prefers it whenever stdin is an interactive TTY
(works over SSH / Wayland / headless-with-tty), falling back to lerobot's
pynput/headless listener for GUI launches with no controlling terminal. Selectable
via LEROBOT_KEYBOARD_BACKEND={auto,stdin,pynput,none}. The backend keeps ISIG so
Ctrl-C still works and always restores the terminal (on stop() and via atexit).
record.py now sources init_keyboard_listener from common; the Right/Left/Esc -> flag
mapping and the (listener, events) contract are unchanged.
Also convert record.py's loop_kwargs to a dict literal (ruff C408).
* Wait for the XR headset to connect before driving the arm
On the xr_controller path the example connected CloudXR and immediately ran the
reset slew + control loop, even if no headset was connected — the arm moved before
the operator was in VR, and get_action() just returned zeros so the clutch never
engaged.
Add an is_tracking property to XRController (set from the controller stream's
optional group, mirroring SO101LeaderArm) and a _wait_for_xr_controller() helper in
common.py that prints connection instructions (CloudXR web client URL + this
workstation's candidate IPv4s, with loopback/link-local and virtual/bridge/USB-gadget
interfaces filtered out) and polls until the controllers stream (indefinite, 15s
reminder, Ctrl-C to abort). setup_xr.startup() now connects, waits for the headset,
THEN runs the reset slew and seeds the clutch — so the arm only moves once the
operator is connected and watching. Mirrors the leader path's _wait_for_leader; both
record.py and teleoperate.py inherit it via the shared setup_xr.
* Address review feedback on the Isaac Teleop -> SO-101 example
Review-response and CI fixes for the Isaac Teleop -> SO-101 example.
- Move the XR Clutch into src/lerobot/teleoperators/isaac_teleop/clutch.py
(pure numpy + Rotation, no isaacteleop import), export it, and add
tests/teleoperators/test_clutch.py.
- Drop the vendored stdin keyboard listener; record.py uses a small terminal-
first wrapper over upstream's TerminalKeyListener (works over SSH even with a
local X display), falling back to upstream init_keyboard_listener otherwise.
- record.py: pass rgb_encoder/depth_encoder to LeRobotDataset create()/resume()
(upstream removed camera_encoder), fixing the AttributeError at record time.
- build_device: derive motor names from robot.action_features instead of
robot.bus (supports non-bus robots), and disconnect the follower if any step
after connect() fails so a failed setup never leaks the connection.
- Read leader joints by the group's declared names (_joints_group_to_rad)
instead of positionally, so a layout mismatch can't silently mirror the wrong
DOF onto the follower; add tests including a reversed-layout group.
- base.py: hoist `from pathlib import Path` to module scope; only the
isaacteleop CloudXRLauncher import stays lazy (optional dep).
- Trim the common.py module docstring and point to docs/source/isaac_teleop.mdx.
- default.env: correct the NV_DEVICE_PROFILE comment (auto-webrtc is the default;
this file overrides to Quest3, which works for both Quest 3 and Pico 4).
- download_assets.py: correct the RAW_BASE comment (tracks main, not pinned) and
add `# nosec B310` next to the existing `# noqa: S310` for the bandit hook.
- uv.lock: add the isaac-teleop extra's deps so `uv sync --locked` matches
pyproject; regenerated with uv 0.8.0 to keep lockfile revision 2 (CI's uv).
- isaac_teleop.mdx: prettier formatting.
* fix(.gitignore): removing .gitignore and using lerobot cache folder instead to store local user files
* chore(docstrings): reducing docstrings in default.env
* feat(URDF): cleaning up and simplifying the URDF download procedure
* feat(robot guard): adding a guard in case an unsupported robot type is provided (so-arms only)
* fix(imports): enforcing a python module structure to simplify imports
* feat(safe read): extending the motor bus safe read rationale to reset pose setting
* chore(trim): trimming lenghty comments and docstrings
* fix(deps): use isaacteleop [retargeters-lite] extra to unblock aarch64 (DGX Spark) (#3933)
* fix(deps): drop isaacteleop [retargeters] extra to unblock aarch64
The [retargeters] extra pulls dex-retargeting (pins numpy<2.0, conflicting
with lerobot's numpy>=2.0) and nlopt>=2.8 (no aarch64 wheels), making
lerobot[isaac-teleop] unresolvable on ARM (DGX Spark, Jetson Thor, GH200)
and over-constrained on numpy everywhere else.
The LeRobot teleoperators only import isaacteleop.retargeting_engine,
isaacteleop.cloudxr and isaacteleop.teleop_session_manager, all shipped in
the base wheel (requires only numpy>=1.23), so the extra is unused.
Verified on DGX Spark (aarch64, Python 3.12): resolves and installs with
isaacteleop 1.3.131 + numpy 2.2.6; all imported symbols load.
* fix(deps): use isaacteleop [retargeters-lite] extra for aarch64 support
Pin to isaacteleop ~=1.3.131 (the release that added ARM64/aarch64 support)
and swap the full [retargeters] extra for the new [retargeters-lite] one
(scipy-only). The full extra drags in dex-retargeting (pins numpy<2,
conflicting with lerobot's numpy>=2.0) and nlopt>=2.8 (no aarch64 wheels),
making lerobot[isaac-teleop] unresolvable on ARM hosts (DGX Spark, Jetson
Thor, GH200) and over-constrained on numpy everywhere else.
The LeRobot teleoperators only import isaacteleop.retargeting_engine,
isaacteleop.cloudxr and isaacteleop.teleop_session_manager — all covered
by the base wheel + retargeters-lite.
Verified on DGX Spark (aarch64, Python 3.12/3.13): resolves and installs
with isaacteleop 1.3.131 + numpy 2.2.6 + scipy 1.18.
* feat(deps): re-add full [retargeters] extra gated to x86_64
Keep the dex-retargeting/nlopt-based retargeters available on x86_64 (where
their wheels exist) via an environment marker, while ARM hosts (DGX Spark,
Jetson Thor, GH200) resolve with base + [retargeters-lite] only.
Verified: uv lock resolves on both platforms; on aarch64 the compile
excludes nlopt/dex-retargeting, on x86_64 they are included.
---------
Co-authored-by: Johnny Nunez <22727137+johnnynunez@users.noreply.github.com>
* chore(docstrings): trimming latest docstrings
* chore(teleop): move isaac-teleop to examples + update docs + add readme with installation notes
* chore(deps): restore uv.lock
* fix(example: isaac teleop parsing config
* fix(examples): isaac atomic-gripper controller
* feat(Examples): isaac-teleop holdlatch
* chore(examples): some other minor improvements for isaac-teleop
* chore(examples): top-level imports isaac-teleop
* chore(Examples): address ai review isaac-teleop
---------
Signed-off-by: Jiwen Cai <jiwenc@nvidia.com>
Co-authored-by: Jiwen Cai <jiwenc@nvidia.com>
Co-authored-by: Johnny <johnnync13@gmail.com>
Co-authored-by: Johnny Nunez <22727137+johnnynunez@users.noreply.github.com>
Co-authored-by: Steven Palma <steven.palma@huggingface.co>
Co-authored-by: Steven Palma <imstevenpmwork@ieee.org>
651 lines
28 KiB
Python
651 lines
28 KiB
Python
#!/usr/bin/env python
|
|
|
|
# Copyright 2026 NVIDIA Corporation and The HuggingFace Inc. team. All rights reserved.
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
|
|
"""Shared device + control-loop infrastructure for the Isaac Teleop -> SO-101 examples.
|
|
|
|
Consumed by ``teleoperate.py`` and ``record.py``, which both build a per-device
|
|
:class:`Device` bundle and run the same loop: read -> (maybe command) -> hold-when-idle ->
|
|
sleep. A :class:`Device` bundles three closures: ``compute(obs) -> RobotAction | None``
|
|
(``None`` = hold at the measured pose while idle), ``startup``, and ``cleanup``. The devices:
|
|
|
|
* ``xr_controller`` — a thin :class:`XRController` whose raw grip pose an in-loop
|
|
:class:`Clutch` turns into an EE target for LeRobot's Cartesian IK pipeline.
|
|
* ``so101_leader`` — a back-drivable leader arm mirrored 1:1 into the follower.
|
|
|
|
Requires the ``isaacteleop`` package and an OpenXR runtime (install instructions in this
|
|
folder's ``README.md``). User-facing guide: ``docs/source/isaac_teleop.mdx``.
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
import socket
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
from collections.abc import Callable
|
|
from contextlib import suppress
|
|
from dataclasses import dataclass
|
|
from importlib.resources import files
|
|
from pathlib import Path
|
|
from typing import Protocol
|
|
|
|
import numpy as np
|
|
|
|
from lerobot.model.kinematics import RobotKinematics
|
|
from lerobot.processor import (
|
|
RobotProcessorPipeline,
|
|
robot_action_observation_to_transition,
|
|
transition_to_robot_action,
|
|
)
|
|
from lerobot.robots import RobotConfig, make_robot_from_config
|
|
from lerobot.robots.so_follower import SOFollowerConfig # noqa: F401 (registers so101_follower)
|
|
from lerobot.robots.so_follower.robot_kinematic_processor import (
|
|
EEBoundsAndSafety,
|
|
InverseKinematicsEEToJoints,
|
|
)
|
|
from lerobot.types import RobotAction, RobotObservation
|
|
from lerobot.utils.constants import HF_LEROBOT_CALIBRATION, HF_LEROBOT_HOME, TELEOPERATORS
|
|
from lerobot.utils.robot_utils import precise_sleep
|
|
|
|
from .isaac_teleop import (
|
|
Clutch,
|
|
IsaacTeleopConfig,
|
|
MapXRControllerActionToRobotAction,
|
|
SO101LeaderArm,
|
|
SO101LeaderArmConfig,
|
|
XRController,
|
|
)
|
|
|
|
# Fixed rate [Hz] for the teleoperate loop and the pre-loop slews / connect-wait poll sleeps.
|
|
FPS = 30
|
|
|
|
# CloudXR device-profile env file passed to the launcher (see default.env in this package).
|
|
CLOUDXR_ENV_FILE = str(files(__package__) / "default.env")
|
|
|
|
|
|
class LoopConfig(Protocol):
|
|
"""Structural type for the loop/launch knobs ``build_device`` and the ``setup_*`` read.
|
|
|
|
Both ``TeleoperateConfig`` and ``RecordConfig`` satisfy it, keeping ``common`` decoupled
|
|
from either entry point's concrete config.
|
|
"""
|
|
|
|
teleop: IsaacTeleopConfig
|
|
robot: RobotConfig
|
|
launch_plugin: str | None
|
|
reset_to_origin: bool
|
|
reset_duration: float
|
|
align: bool
|
|
align_duration: float
|
|
|
|
|
|
# Per-device bundle consumed by the shared loop. ``compute`` returns None to mean
|
|
# "idle -> hold at the measured pose"; ``startup`` warms up; ``cleanup`` reaps/disconnects.
|
|
@dataclass(frozen=True)
|
|
class Device:
|
|
compute: Callable[[RobotObservation | None], RobotAction | None]
|
|
startup: Callable[[], None]
|
|
cleanup: Callable[[], None]
|
|
|
|
|
|
def hold_action(obs: RobotObservation, motor_names: list[str]) -> dict[str, float]:
|
|
"""Re-send the measured joints — the explicit hold when a device is idle."""
|
|
return {f"{name}.pos": float(obs[f"{name}.pos"]) for name in motor_names}
|
|
|
|
|
|
class HoldLatch:
|
|
"""Resolve the per-frame action, holding one LATCHED pose while the device is idle.
|
|
|
|
Re-sending the freshly measured joints on every idle frame would ratchet the arm
|
|
downward: under gravity the P-only servo settles below its goal by a steady-state
|
|
error, so each re-command of the measurement lowers the goal by that error again.
|
|
Latching the target once on the active->idle transition holds a fixed pose instead.
|
|
"""
|
|
|
|
def __init__(self, motor_names: list[str]):
|
|
self._motor_names = motor_names
|
|
self._held: dict[str, float] | None = None
|
|
|
|
def resolve(self, action: RobotAction | None, obs: RobotObservation) -> RobotAction:
|
|
"""Pass through an active action (clearing the latch); latch + hold when idle."""
|
|
if action is not None:
|
|
self._held = None
|
|
return action
|
|
if self._held is None:
|
|
self._held = hold_action(obs, self._motor_names)
|
|
return self._held
|
|
|
|
|
|
def slew(
|
|
robot,
|
|
motor_names: list[str],
|
|
target_fn: Callable[[], dict[str, float]],
|
|
duration_s: float,
|
|
) -> None:
|
|
"""Linearly slew all joints from their current measured pose toward a target.
|
|
|
|
``target_fn`` is called EACH step, so the leader can pass a live re-read (landing on its
|
|
current pose at ``alpha == 1`` for a continuous handoff) while XR passes a constant.
|
|
"""
|
|
obs = robot.get_observation()
|
|
start = {name: float(obs[f"{name}.pos"]) for name in motor_names}
|
|
n_steps = max(1, int(duration_s * FPS))
|
|
for step in range(1, n_steps + 1):
|
|
alpha = step / n_steps
|
|
target = target_fn()
|
|
action = {f"{name}.pos": start[name] + alpha * (target[name] - start[name]) for name in motor_names}
|
|
robot.send_action(action)
|
|
precise_sleep(1.0 / FPS)
|
|
|
|
|
|
# ============================================================================
|
|
# XR controller device
|
|
# ============================================================================
|
|
|
|
# Per-frame EE rate limit [m]. With raise_on_jump=False, EEBoundsAndSafety clamps an
|
|
# over-limit step instead of raising, absorbing a tracking glitch as one slow frame. At
|
|
# FPS=30, 0.1 m/frame caps EE speed at ~3 m/s. (end_effector_bounds clips the absolute target.)
|
|
MAX_EE_STEP_M = 0.1
|
|
|
|
# Soft-orientation IK weight: small but nonzero so the wrist follows the hand while position
|
|
# dominates (the 5-DOF SO-101 cannot realize an arbitrary orientation). 0.0 = position-only.
|
|
IK_ORIENTATION_WEIGHT = 0.01
|
|
|
|
|
|
def _ensure_so101_urdf() -> str:
|
|
"""Return the cached SO-101 URDF path, fetching the ``so101`` folder (URDF + meshes) from
|
|
the public ``lerobot/robot-urdfs`` HF bucket into the LeRobot cache on first use."""
|
|
dest_dir = HF_LEROBOT_HOME / "robot-urdfs" / "so101"
|
|
urdf_path = dest_dir / "so101_new_calib.urdf"
|
|
# Completeness marker written only after a FULL sync: the URDF file alone is not a
|
|
# completeness signal (an interrupted first sync can leave the meshes it references
|
|
# missing, which the URDF's mere existence would then hide forever). Re-syncing is
|
|
# idempotent and repairs a partial cache; delete the folder to force a re-download.
|
|
marker = dest_dir / ".sync_complete"
|
|
if not marker.exists():
|
|
from huggingface_hub import sync_bucket
|
|
|
|
sync_bucket("hf://buckets/lerobot/robot-urdfs/so101", str(dest_dir), quiet=True)
|
|
marker.touch()
|
|
return str(urdf_path)
|
|
|
|
|
|
# Default duration [s] for the startup reset-to-origin slew.
|
|
RESET_DURATION_S = 5.0
|
|
|
|
# Optional cached file written by override_reset_pose.py. When present it takes priority over RESET_ORIGIN_DEG.
|
|
RESET_POSE_FILE = str(HF_LEROBOT_HOME / "reset_poses" / "{robot_name}" / "{robot_id}.json")
|
|
|
|
# Reset target in each motor's native units (arm joints in degrees, gripper RANGE_0_100,
|
|
# 100 = open). An empirically comfortable pose (elbow/wrist bent) avoiding the singularity of
|
|
# a fully-extended arm; assumes standard calibration. Override per-arm via override_reset_pose.py.
|
|
RESET_ORIGIN_DEG: dict[str, float] = {
|
|
"shoulder_pan": -4.0,
|
|
"shoulder_lift": -103.0,
|
|
"elbow_flex": 97.0,
|
|
"wrist_flex": 78.0,
|
|
"wrist_roll": -65.0,
|
|
"gripper": 0.0,
|
|
}
|
|
|
|
|
|
def _load_reset_target(reset_pose_file: Path, motor_names: list[str]) -> dict[str, float]:
|
|
"""Return reset targets: the saved reset pose if present, else RESET_ORIGIN_DEG."""
|
|
if reset_pose_file.exists():
|
|
saved = json.loads(reset_pose_file.read_text())
|
|
# Fill any missing motors from the fallback dict.
|
|
return {name: float(saved.get(name, RESET_ORIGIN_DEG.get(name, 0.0))) for name in motor_names}
|
|
return {name: RESET_ORIGIN_DEG.get(name, 0.0) for name in motor_names}
|
|
|
|
|
|
# CloudXR web client URL opened in the headset (Isaac Teleop quick start, step 5).
|
|
_CLOUDXR_WEB_CLIENT_URL = "https://nvidia.github.io/IsaacTeleop/client"
|
|
# WSS-proxy / self-signed-cert port the operator accepts in-browser before connecting.
|
|
_CLOUDXR_WSS_PORT = 48322
|
|
# How often to re-print the connection hint while waiting for the headset [s].
|
|
_XR_CONNECT_REMINDER_S = 15.0
|
|
# Virtual / bridge / USB-gadget interfaces a headset can't reach over the network — skip
|
|
# by name prefix (``docker0``, compose ``br-*``, ``veth*``, libvirt ``virbr*``, and the
|
|
# Tegra USB device-mode bridge ``l4tbr0``).
|
|
_SKIP_IFACE_PREFIXES = ("docker", "br-", "veth", "virbr", "l4tbr")
|
|
|
|
|
|
def _primary_ipv4() -> str | None:
|
|
"""The workstation's primary outbound IPv4, via the UDP-socket trick (``connect()`` on a
|
|
datagram socket selects the egress interface without sending packets)."""
|
|
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
|
|
try:
|
|
s.connect(("8.8.8.8", 80))
|
|
return s.getsockname()[0]
|
|
except OSError:
|
|
return None
|
|
|
|
|
|
def _candidate_ipv4s() -> list[tuple[str, str]]:
|
|
"""Return ``[(interface, ipv4), ...]`` the headset might reach this workstation at.
|
|
|
|
Lists each interface's IPv4 via ``psutil`` (dropping loopback, link-local, and the
|
|
virtual/bridge interfaces in ``_SKIP_IFACE_PREFIXES``), primary outbound first. Falls
|
|
back to just the primary IP when ``psutil`` is unavailable.
|
|
"""
|
|
primary = _primary_ipv4()
|
|
found: list[tuple[str, str]] = []
|
|
try:
|
|
import psutil
|
|
|
|
for iface, addrs in psutil.net_if_addrs().items():
|
|
if iface.startswith(_SKIP_IFACE_PREFIXES):
|
|
continue
|
|
for addr in addrs:
|
|
if addr.family != socket.AF_INET:
|
|
continue
|
|
ip = addr.address
|
|
if ip.startswith("127.") or ip.startswith("169.254."):
|
|
continue
|
|
found.append((iface, ip))
|
|
except Exception:
|
|
if primary:
|
|
found.append(("default", primary))
|
|
found.sort(key=lambda t: t[1] != primary) # primary outbound interface first
|
|
return found
|
|
|
|
|
|
def _print_xr_connect_help() -> None:
|
|
"""Print how to connect the headset to this workstation over CloudXR."""
|
|
ips = _candidate_ipv4s()
|
|
print("\n" + "=" * 76)
|
|
print("Connect your XR headset to this workstation over NVIDIA CloudXR:")
|
|
print(f" 1. In the headset, open the CloudXR web client: {_CLOUDXR_WEB_CLIENT_URL}")
|
|
print(" 2. Enter this workstation's IP address:")
|
|
if ips:
|
|
for iface, ip in ips:
|
|
print(f" {ip:<15} ({iface})")
|
|
if len(ips) > 1:
|
|
print(" (use the address on the same network as your headset)")
|
|
else:
|
|
print(" <could not determine — check `hostname -I` / `ip addr`>")
|
|
print(f" 3. Accept the self-signed cert at https://<that-ip>:{_CLOUDXR_WSS_PORT}/ , then Connect.")
|
|
print("=" * 76 + "\n")
|
|
|
|
|
|
def _wait_for_xr_controller(teleop_device: XRController) -> None:
|
|
"""Block until the XR controller is tracked, polling ``get_action()`` and re-printing a
|
|
reminder every ``_XR_CONNECT_REMINDER_S``. User-paced; ``Ctrl-C`` aborts (no hard timeout).
|
|
"""
|
|
_print_xr_connect_help()
|
|
print("Waiting for the headset controllers to start streaming… (Ctrl-C to abort)")
|
|
last_reminder = time.time()
|
|
while True:
|
|
teleop_device.get_action() # steps the session; updates is_tracking
|
|
if teleop_device.is_tracking:
|
|
print("Headset connected — controllers are streaming.")
|
|
return
|
|
if time.time() - last_reminder >= _XR_CONNECT_REMINDER_S:
|
|
print("…still waiting for the headset to connect (Ctrl-C to abort).")
|
|
last_reminder = time.time()
|
|
time.sleep(1.0 / FPS)
|
|
|
|
|
|
def setup_xr(cfg: LoopConfig, robot, motor_names: list[str]) -> Device:
|
|
"""Build the XR controller device bundle (clutch + soft-orientation IK pipeline)."""
|
|
kinematics_solver = RobotKinematics(
|
|
urdf_path=_ensure_so101_urdf(),
|
|
target_frame_name="gripper_frame_link",
|
|
joint_names=motor_names,
|
|
)
|
|
|
|
teleop_config = cfg.teleop # XRControllerConfig (selected via --teleop.type=xr_controller)
|
|
teleop_device = XRController(teleop_config)
|
|
|
|
# The clutch (below) turns the raw grip pose into an absolute base-frame ee_pose; this
|
|
# pipeline maps it to joint targets: rename -> bounds/rate-limit -> IK.
|
|
xr_to_robot_joints_processor = RobotProcessorPipeline[tuple[RobotAction, RobotObservation], RobotAction](
|
|
steps=[
|
|
MapXRControllerActionToRobotAction(),
|
|
# raise_on_jump=False: an over-limit step (e.g. a tracking glitch) is clamped +
|
|
# warned instead of raised, since a crash mid-loop would leave the arm uncontrolled.
|
|
# z floor 0.0 keeps a stray target above the table; x/y stay at a loose [-1,1]m box.
|
|
EEBoundsAndSafety(
|
|
end_effector_bounds={"min": [-1.0, -1.0, 0.0], "max": [1.0, 1.0, 1.0]},
|
|
max_ee_step_m=MAX_EE_STEP_M,
|
|
raise_on_jump=False,
|
|
),
|
|
# initial_guess_current_joints=False: warm-start from the previous IK solution so
|
|
# the joint trajectory stays continuous frame-to-frame.
|
|
InverseKinematicsEEToJoints(
|
|
kinematics=kinematics_solver,
|
|
motor_names=motor_names,
|
|
initial_guess_current_joints=False,
|
|
orientation_weight=IK_ORIENTATION_WEIGHT,
|
|
),
|
|
],
|
|
to_transition=robot_action_observation_to_transition,
|
|
to_output=transition_to_robot_action,
|
|
)
|
|
|
|
# The clutch is built in startup() (after the optional reset slew, seeded from the
|
|
# post-slew MEASURED pose) and shared with compute() via nonlocal.
|
|
clutch: Clutch | None = None
|
|
prev_enabled = False
|
|
|
|
def startup() -> None:
|
|
nonlocal clutch
|
|
# Connect and wait for the operator to don the headset BEFORE moving the arm, so the
|
|
# reset slew happens while they are watching in VR.
|
|
teleop_device.connect()
|
|
if not teleop_device.is_connected:
|
|
raise ValueError("Teleop is not connected!")
|
|
_wait_for_xr_controller(teleop_device)
|
|
|
|
if cfg.reset_to_origin:
|
|
reset_pose_file = Path(RESET_POSE_FILE.format(robot_name=robot.name, robot_id=robot.id))
|
|
target = _load_reset_target(reset_pose_file, motor_names)
|
|
source = str(reset_pose_file) if reset_pose_file.exists() else "hardcoded defaults"
|
|
print(f"Reset target source: {source}")
|
|
print(f"Resetting to origin over {cfg.reset_duration:.1f} s…")
|
|
slew(robot, motor_names, lambda: target, cfg.reset_duration)
|
|
print("Reset complete.")
|
|
|
|
# Seed the clutch home from the arm's measured pose (FK of the current joints) so the
|
|
# first engage is jump-free, whether or not a reset slew ran.
|
|
obs0 = robot.get_observation()
|
|
q_measured_deg = np.array([float(obs0[f"{name}.pos"]) for name in motor_names], dtype=float)
|
|
home_base_T_ee = kinematics_solver.forward_kinematics(q_measured_deg) # noqa: N806
|
|
clutch = Clutch(home_base_T_ee)
|
|
|
|
print("Starting teleop loop. Squeeze and move the controller to teleoperate the robot...")
|
|
|
|
def compute(robot_obs: RobotObservation | None) -> RobotAction | None:
|
|
nonlocal prev_enabled
|
|
if clutch is None: # set in startup(), which runs before compute()
|
|
raise RuntimeError("compute() called before startup(); the clutch is not initialized")
|
|
xr_action = teleop_device.get_action()
|
|
grip_pos = np.asarray(xr_action["grip_pos"], dtype=float)
|
|
grip_quat = np.asarray(xr_action["grip_quat"], dtype=float)
|
|
squeeze = float(xr_action["squeeze"])
|
|
trigger = float(xr_action["trigger"])
|
|
enabled = squeeze > teleop_config.clutch_threshold
|
|
|
|
# On the engage edge, latch the clutch home at the arm's MEASURED EE pose (FK of
|
|
# the live joints) and the controller origin so the per-frame delta starts at zero.
|
|
# Latching the last commanded pose instead would snap the arm back to it at full
|
|
# servo speed if the arm moved while disengaged (gravity sag, external contact).
|
|
is_engage_frame = enabled and not prev_enabled
|
|
if is_engage_frame:
|
|
q_measured = np.array([float(robot_obs[f"{name}.pos"]) for name in motor_names], dtype=float)
|
|
measured_base_T_ee = kinematics_solver.forward_kinematics(q_measured) # noqa: N806
|
|
clutch.engage(grip_pos, grip_quat, measured_base_T_ee=measured_base_T_ee)
|
|
# Re-anchor the pipeline state at the measured pose as well: EEBoundsAndSafety's
|
|
# rate limiter and the IK warm start otherwise still reference the stale
|
|
# pre-disengage command and would fight the fresh home for several frames.
|
|
xr_to_robot_joints_processor.reset()
|
|
prev_enabled = enabled
|
|
|
|
# SAFETY GATE: command the robot ONLY while the clutch is engaged; otherwise return
|
|
# None so the loop holds the measured joints (releasing the clutch freezes the arm).
|
|
if not enabled:
|
|
return None
|
|
|
|
# Rebase the raw grip pose onto the EE, then run the pipeline. closedness = trigger.
|
|
ee_pos, ee_quat = clutch.rebase(grip_pos, grip_quat)
|
|
ee_action = {
|
|
"ee_pose": np.concatenate([ee_pos, ee_quat]).astype(np.float32),
|
|
"closedness": trigger,
|
|
}
|
|
return xr_to_robot_joints_processor((ee_action, robot_obs))
|
|
|
|
return Device(compute=compute, startup=startup, cleanup=teleop_device.disconnect)
|
|
|
|
|
|
# ============================================================================
|
|
# SO-101 leader arm device
|
|
# ============================================================================
|
|
|
|
# Default duration [s] for the startup alignment slew (follower current -> leader first pose).
|
|
ALIGN_DURATION_S = 3.0
|
|
|
|
# How long to wait for the leader plugin to start streaming before aligning / looping.
|
|
LEADER_WARMUP_TIMEOUT_S = 20.0
|
|
|
|
# The plugin converts the leader's servo ticks to radians, so it reuses the serial SO-101
|
|
# leader's calibration, stored by lerobot-calibrate under SO101Leader.name == "so_leader".
|
|
SO_LEADER_CALIBRATION_NAME = "so_leader"
|
|
|
|
|
|
def _leader_calibration_path(cfg: LoopConfig) -> Path | None:
|
|
"""Infer the calibration JSON the launched plugin should read, or None.
|
|
|
|
Path convention: ``HF_LEROBOT_CALIBRATION / teleoperators / so_leader / {--teleop.id}.json``
|
|
(or ``--teleop.calibration_dir`` if set). Returns None (plugin falls back to defaults) when
|
|
it does not exist, warning if an id was given, or when no ``--teleop.id`` is set.
|
|
"""
|
|
if not cfg.teleop.id:
|
|
return None
|
|
calib_dir = cfg.teleop.calibration_dir or (
|
|
HF_LEROBOT_CALIBRATION / TELEOPERATORS / SO_LEADER_CALIBRATION_NAME
|
|
)
|
|
calib_path = Path(calib_dir) / f"{cfg.teleop.id}.json"
|
|
if calib_path.is_file():
|
|
return calib_path
|
|
print(
|
|
f"WARNING: no leader calibration at {calib_path}; the plugin will use built-in defaults. "
|
|
f"Calibrate with the serial leader (`lerobot-calibrate --teleop.type=so101_leader "
|
|
f"--teleop.id={cfg.teleop.id}`) or the plugin's `calibrate` subcommand."
|
|
)
|
|
return None
|
|
|
|
|
|
def _wait_for_leader(teleop: SO101LeaderArm, timeout_s: float) -> dict[str, float]:
|
|
"""Poll the leader until it streams a live frame; return that frame's ``{joint}.pos``.
|
|
|
|
Raises ``SystemExit`` if no live frame arrives within ``timeout_s`` (plugin not pushing,
|
|
wrong ``--teleop.collection_id``, or CloudXR not up).
|
|
"""
|
|
print(f"Waiting up to {timeout_s:.0f}s for the so101_leader plugin to stream…")
|
|
deadline = time.time() + timeout_s
|
|
while time.time() < deadline:
|
|
action = teleop.get_action()
|
|
if teleop.is_tracking:
|
|
print("Leader is streaming.")
|
|
return action
|
|
time.sleep(1.0 / FPS)
|
|
raise SystemExit(
|
|
f"FAILED: leader did not stream within {timeout_s:.0f}s. Is the so101_leader plugin "
|
|
"running and pushing (check --teleop.collection_id)? Is CloudXR up?"
|
|
)
|
|
|
|
|
|
def _maybe_launch_plugin(cfg: LoopConfig) -> subprocess.Popen | None:
|
|
"""Spawn the so101_leader plugin if ``--launch_plugin <path>`` was given (after connect())."""
|
|
if cfg.launch_plugin is None:
|
|
return None
|
|
if not Path(cfg.launch_plugin).exists():
|
|
raise SystemExit(
|
|
f"plugin binary not found: {cfg.launch_plugin} (build it in the IsaacTeleop repo first)"
|
|
)
|
|
leader_port = cfg.teleop.port # SO101LeaderArmConfig.port, forwarded to the plugin
|
|
backend = f"leader on {leader_port}" if leader_port else "synthetic trajectory"
|
|
print(f"launching plugin: {cfg.launch_plugin} ({backend})")
|
|
# Positional args: [device_path] [collection_id] [calibration_file]. Empty device_path ->
|
|
# synthetic backend. Calibration (only real hardware needs it) is appended when a port is set.
|
|
argv = [cfg.launch_plugin, leader_port, cfg.teleop.collection_id]
|
|
if leader_port:
|
|
calib_path = _leader_calibration_path(cfg)
|
|
if calib_path is not None:
|
|
argv.append(str(calib_path))
|
|
print(f" leader calibration: {calib_path}")
|
|
# Spawned after connect() so it inherits the CloudXR runtime env (XR_RUNTIME_JSON, ...).
|
|
proc = subprocess.Popen(argv)
|
|
time.sleep(1.5) # let it create its OpenXR session and start pushing
|
|
return proc
|
|
|
|
|
|
def setup_leader(cfg: LoopConfig, robot, motor_names: list[str]) -> Device:
|
|
"""Build the SO-101 leader arm device bundle (1:1 joint mirror)."""
|
|
teleop_config = cfg.teleop # SO101LeaderArmConfig (selected via --teleop.type=so101_leader)
|
|
teleop = SO101LeaderArm(teleop_config)
|
|
|
|
plugin_proc: subprocess.Popen | None = None
|
|
|
|
def startup() -> None:
|
|
nonlocal plugin_proc
|
|
# connect() auto-launches CloudXR (unless opted out); spawn the plugin AFTER so it
|
|
# inherits the runtime env. The plugin is reaped in cleanup().
|
|
teleop.connect()
|
|
plugin_proc = _maybe_launch_plugin(cfg)
|
|
|
|
if not teleop.is_connected:
|
|
raise ValueError("Teleop is not connected!")
|
|
|
|
# Block until the leader streams a live frame (clear error if it never does).
|
|
_wait_for_leader(teleop, LEADER_WARMUP_TIMEOUT_S)
|
|
|
|
if cfg.align:
|
|
print(f"Aligning follower to leader over {cfg.align_duration:.1f}s…")
|
|
|
|
# Re-read the live leader pose once per step so alpha=1 lands on its current pose
|
|
# from a single coherent frame.
|
|
def _leader_target() -> dict[str, float]:
|
|
leader_now = teleop.get_action()
|
|
return {name: float(leader_now[f"{name}.pos"]) for name in motor_names}
|
|
|
|
slew(robot, motor_names, _leader_target, cfg.align_duration)
|
|
print("Alignment complete.")
|
|
|
|
print(
|
|
"Starting joint-mirror loop. Back-drive the leader to teleoperate the follower… (Ctrl-C to stop)"
|
|
)
|
|
|
|
def compute(robot_obs: RobotObservation | None) -> RobotAction | None:
|
|
leader_action = teleop.get_action()
|
|
# Hold the follower at its measured pose when the leader drops out (stale stream)
|
|
# rather than commanding a possibly-old target.
|
|
if not teleop.is_tracking:
|
|
return None
|
|
return leader_action
|
|
|
|
def cleanup() -> None:
|
|
# A plugin-reaping failure must not skip the session disconnect (and vice versa
|
|
# the disconnect runs after the plugin stops pushing on it).
|
|
try:
|
|
if plugin_proc is not None:
|
|
plugin_proc.terminate()
|
|
try:
|
|
plugin_proc.wait(timeout=5)
|
|
except subprocess.TimeoutExpired:
|
|
plugin_proc.kill()
|
|
finally:
|
|
teleop.disconnect()
|
|
|
|
return Device(compute=compute, startup=startup, cleanup=cleanup)
|
|
|
|
|
|
# ============================================================================
|
|
# Shared setup
|
|
# ============================================================================
|
|
|
|
|
|
def build_device(cfg: LoopConfig) -> tuple:
|
|
"""Connect the follower, build the selected Isaac device, and run its pre-loop startup.
|
|
|
|
Connects the follower FIRST (so the startup slew / clutch-home seed can read live joints),
|
|
dispatches on ``--teleop.type``, then runs ``device.startup()`` before returning. On any
|
|
failure after ``connect()`` the follower is disconnected so the connection never leaks.
|
|
|
|
Returns ``(robot, device, motor_names)``.
|
|
"""
|
|
# Default the CloudXR input profile to this example's default.env unless the user overrode
|
|
# it via --teleop.cloudxr_env_file.
|
|
if cfg.teleop.cloudxr_env_file is None:
|
|
cfg.teleop.cloudxr_env_file = CLOUDXR_ENV_FILE
|
|
|
|
# SO-101/SO-100 only (both share the SO-101 URDF), reject other followers.
|
|
supported_robots = {"so101_follower", "so100_follower"}
|
|
if cfg.robot.type not in supported_robots:
|
|
raise ValueError(
|
|
f"This example only supports SO-101/SO-100 followers ({sorted(supported_robots)}), "
|
|
f"but got --robot.type={cfg.robot.type}."
|
|
)
|
|
|
|
# The degree-based pipeline relies on --robot.use_degrees (default True).
|
|
robot = make_robot_from_config(cfg.robot)
|
|
# Connect FIRST so the startup slew and clutch-home seed can read live joints.
|
|
robot.connect()
|
|
# Everything after connect() can fail; this runs outside the callers' try/finally, so
|
|
# disconnect the follower on any failure to avoid leaking the connection.
|
|
device: Device | None = None
|
|
try:
|
|
# Joint names in action order, read from {name}.pos action features (robot-agnostic).
|
|
motor_names = [key.removesuffix(".pos") for key in robot.action_features if key.endswith(".pos")]
|
|
|
|
if isinstance(cfg.teleop, SO101LeaderArmConfig):
|
|
device = setup_leader(cfg, robot, motor_names)
|
|
else:
|
|
device = setup_xr(cfg, robot, motor_names)
|
|
|
|
device.startup()
|
|
except BaseException:
|
|
# Reap a partially-started device, then always disconnect the follower.
|
|
if device is not None:
|
|
with suppress(Exception):
|
|
device.cleanup()
|
|
robot.disconnect()
|
|
raise
|
|
|
|
return robot, device, motor_names
|
|
|
|
|
|
# ============================================================================
|
|
# Keyboard control
|
|
# ============================================================================
|
|
|
|
|
|
def init_keyboard_listener():
|
|
"""Recording shortcuts, terminal-first so they work over SSH.
|
|
|
|
Whenever stdin is a TTY we use the stdlib :class:`TerminalKeyListener` directly rather
|
|
than upstream's pynput-first :func:`init_keyboard_listener`, whose global listener would
|
|
capture the workstation console instead of this (often SSH) terminal. With no TTY we defer
|
|
to upstream (pynput on a GUI, else headless no-op).
|
|
"""
|
|
if not (sys.stdin is not None and sys.stdin.isatty()):
|
|
from lerobot.utils.keyboard_input import init_keyboard_listener as _upstream
|
|
|
|
return _upstream()
|
|
|
|
from lerobot.utils.keyboard_input import TerminalKeyListener, apply_recording_control
|
|
|
|
events = {"exit_early": False, "rerecord_episode": False, "stop_recording": False}
|
|
|
|
# n/r/q are the arrow/Esc equivalents that survive escape-sequence splitting over laggy
|
|
# SSH/VNC links. Case-insensitive so Shift+letter still works.
|
|
def on_key(name: str) -> None:
|
|
key = name.lower()
|
|
if key in ("right", "n"):
|
|
apply_recording_control("right", events)
|
|
elif key in ("left", "r"):
|
|
apply_recording_control("left", events)
|
|
elif key in ("esc", "q"):
|
|
apply_recording_control("esc", events)
|
|
|
|
listener = TerminalKeyListener(on_key)
|
|
listener.start()
|
|
logging.info(
|
|
"Keyboard control via terminal — keep this terminal focused: "
|
|
"Right/n = end episode early, Left/r = re-record, Esc/q = stop."
|
|
)
|
|
return listener, events
|