OpenArm ROS 2 Guide (v1.0 bimanual, Jazzy, main branch)
Archived version: V3
Kept exactly as it was written. The current guide is V3.1.
Navigation
What changed in V3 (2026-10-09)
- New: Part III (§30–§43), a complete, beginner-level guide to running a trained policy with
lerobot-rollout: concepts, checking the model files, every flag, what happens when, a first run in small steps, tuning, evaluation, DAgger, troubleshooting. It replaces the old §24.4–§24.11, which were short. §24.5 “Which policy” is now §24.4.- Correction:
lerobot-rolloutrefuses recording datasets whose name doesn’t start withrollout_. The V2.2 examples (eval_…,hil_…) would have failed at start-up. Fixed in §37 and §38.- Correction:
lerobot-recordappends a date-time tag to the dataset name unless--dataset.no_stamp=true, so the V2.2 training command couldn’t find the dataset under the name it was recorded with. §24.1 now usesno_stamp.- New pitfall: zeus logs in to a Wayland session, where the LeRobot keyboard controls (episodic, DAgger) probably don’t work (§37.3).
What changed in V2.2 (2026-10-09)
- Correction: V2.1 said that with
interpolation_method: nonethe trajectory controller “ramps linearly” to the first point overtime_from_start. That was wrong. It holds the current position for the wholetime_from_startand then jumps to the target (confirmed in sim, see log 2026-10-09). On real motors that’s a full-speed step. Fixed in §4, §8.1, §12.3 and §16.- New: how to switch the bringup controllers to
interpolation_method: splines, and how to check it (§4). This is now a pre-flight item for the real robot (§12.1).- Correction: the §12.1 checklist said
can0= right arm. On zeus it’s the reverse (left =can0, §22).
A practical reference for the OpenArm ROS 2 stack as installed in the openarm container on zeus: how it is built, how to run it in simulation, how to command it, and how to bring up and command the physical OpenArm v1.0 over SocketCAN. Part II covers teleoperation, data collection and policy learning with Hugging Face LeRobot, installed on the host, and how it relates to the ROS 2 stack. Part III is a step-by-step guide to running a trained policy on the robot with LeRobot, written for someone doing it for the first time.
Versions this guide was written against (inside the container,
/root/ros2_ws/src):
Repo Commit Date enactic/openarm_ros2(main)b9d7a672026-10-06 enactic/openarm_descriptionc1030412026-10-02 enactic/openarm_canf340d4b(v1.4.0)2026-09-16 huggingface/lerobot(main, host, conda envlerobot)b863b42(v0.6.2)2026-10-08 ROS 2 Jazzy, ros2_control 4.x, MoveIt 2. Everything below was checked against these sources. Where behaviour could not be verified without hardware, it is marked ⚠ verify.
Table of contents
- Big picture
- The packages
- Robot model: joints, limits, frames
- ros2_control layer: hardware, controllers, interfaces
- Environment and container
- Launch files and arguments
- Running in simulation (mock hardware)
- Commanding the robot (sim and real)
- MoveIt 2
- The physical robot: CAN setup
- The physical robot: zero calibration
- The physical robot: bringing it up with ROS 2
- Safety notes specific to this code
- Introspection and debugging cheat-sheet
- Troubleshooting (problems already hit on zeus)
- Known issues and gaps in `main`
Part II: Teleoperation and data collection with Hugging Face LeRobot
- What LeRobot is and where it sits
- Teleop hardware options
- Installation on zeus (done)
- Conventions: LeRobot vs ROS 2
- Calibration and the shared motor zero
- Bus and port layout for the bimanual setup
- Step-by-step: from unboxing to teleoperation
- Recording, training and running policies
- Switching between LeRobot and ROS 2
- Teleop safety notes
- LeRobot troubleshooting
- Enactic’s own `openarm_teleop` (alternative)
- Quick reference card
Part III: Running a trained policy with LeRobot, step by step (new in V3)
- What running a policy means
- What you need before the first run
- The `lerobot-rollout` command, flag by flag
- What happens when you run it
- Pre-flight checklist (every policy run)
- Your first run on zeus, step by step
- Tuning how the policy moves
- Measuring how well it works: episodic evaluation
- Making it better: corrections with the Minis (DAgger)
- Running a single-arm policy
- Big VLAs: RTC and running the model on another machine
- Policy troubleshooting
- Policy safety notes
- Policy cheat-sheet
1. Big picture
┌─────────────────────────── your code / CLI / RViz ───────────────────────────┐
│ ros2 action send_goal … rclpy node MoveIt "Plan & Execute" ros2 topic │
└──────────────┬───────────────────────────────┬───────────────────────────────┘
│ FollowJointTrajectory action │ MoveGroup action
│ or JointTrajectory topic ▼
│ ┌────────────┐
│ │ move_group │ (plans with OMPL + KDL IK,
│ └─────┬──────┘ then sends trajectories)
▼ ▼
┌──────────────────────────── controller_manager (ros2_control_node) ──────────────────────────┐
│ left_joint_trajectory_controller right_joint_trajectory_controller │
│ left_gripper_controller right_gripper_controller joint_state_broadcaster │
│ │ position commands ▲ position/velocity/effort states │
│ ┌─────────────▼──────────────────────────────────────┴─────────────────────┐ │
│ │ hardware component "openarm_left_hardware_interface" (+ "…right…") │ │
│ │ sim: mock_components/GenericSystem (echoes commands back as state) │ │
│ │ real: openarm_hardware/OpenArmHW → openarm_can library → SocketCAN │ │
│ └──────────────────────────────────────────────────────────────────────────┘ │
└───────────────────────────────────────────────────────────────────────────────────────────────┘
│ CAN-FD 1 Mbit/s nominal / 5 Mbit/s data
can1 ──► left arm: 7× Damiao motors (IDs 1–7) + gripper (ID 8)
can0 ──► right arm: 7× Damiao motors (IDs 1–7) + gripper (ID 8)
robot_state_publisher: /robot_description + /joint_states → /tf → RViz
Key facts:
- One controller manager runs both arms. Each arm is its own ros2_control hardware component on its own CAN bus.
- Everything is joint-space position control. The trajectory controllers write position commands. On real hardware these become Damiao MIT-mode commands (
kp,kd,q_des,dq_des,tau_ff), so the motor itself runs the PD loop. - Sim and real use the same controllers, topics and actions. Only the hardware plugin changes (
use_fake_hardware:=true/false). Anything you develop against the mock works unchanged on the robot. The real robot just has physics, gravity and the ability to hurt someone.
2. The packages
In openarm_ros2 (this repo, main)
| Package | Purpose |
|---|---|
openarm | Metapackage. |
openarm_bringup | openarm.bimanual.launch.py plus controller YAMLs (config/controllers/). This is the “robot without MoveIt” entry point. |
openarm_hardware | The ros2_control plugin openarm_hardware/OpenArmHW that talks to the real motors through openarm_can. Hard-coded for the v1.0 motor layout (works for v2.0 arms too, which share the layout). |
openarm_bimanual_moveit_config | MoveIt 2 config: SRDF, kinematics, joint limits, MoveIt controller mapping, for both openarm_v1.0 and openarm_v2.0 (subfolders in config/). demo.launch.py starts the full stack plus MoveIt and RViz. |
There is no single-arm launch file on main (openarm.launch.py from the v1.0 docs is gone). The only bringup is bimanual.
Dependencies (cloned separately)
| Package | Purpose |
|---|---|
openarm_description | URDF/xacro, meshes, per-version config (joint limits, gains, inertials). Layout: assets/robot/openarm_v1.0/… and assets/robot/openarm_v2.0/…. Not listed in openarm.repos, so you must clone it yourself. |
openarm_can | C++ library (libopenarm_can) for Damiao motors over SocketCAN, plus CLI tools (openarm-can-cli, calibration scripts, demos). Pulled in by vcs import < openarm_ros2/openarm.repos. |
Tools from openarm_can built into the workspace (/root/ros2_ws/install/openarm_can/bin/):
| Tool | What it does |
|---|---|
openarm-can-cli | Configure CAN, discover motors, enable/disable, monitor, set zero, change IDs/baud, read/write motor params, diagnose. |
openarm-can-zero-position-calibration | Automatic zero calibration by bumping into mechanical stops. Python, needs the openarm_can Python module, which is not built in the container yet. |
openarm-can-calibration-in-cell | Same, for arms mounted in an OpenArm “cell” with a lifter. Not relevant for you. |
openarm-can-configure-socketcan-4-arms | Configures can0–can3 in one go. It fails if any of the four is missing, so for two arms use openarm-can-cli can_configure per interface instead. |
openarm-can-health, openarm-can-demo, openarm-can-motor-sampling-check | Diagnostics and demo programs. |
3. Robot model: joints, limits, frames
Selecting the model
The launch files pick the xacro only from arm_type:
arm_type value | Xacro loaded |
|---|---|
v1.0, v10, v1_0, openarm_v1.0, openarm_v10, openarm_v1_0 | openarm_description/assets/robot/openarm_v1.0/urdf/openarm_v10.urdf.xacro |
v2.0, v20, … (the default!) | …/openarm_v2.0/urdf/openarm_v20.urdf.xacro |
- The
description_filelaunch argument exists but is ignored. - Both launch files default to v2.0. Always pass
arm_type:=v10for your arms.
Verify what is loaded:
ros2 param get /robot_state_publisher robot_description | grep -o -m1 "openarm_v1.0/urdf/openarm_v10.urdf.xacro"Joint names (bimanual)
| Arm | Arm joints | Gripper joint |
|---|---|---|
Left (can1) | openarm_left_joint1 … openarm_left_joint7 | openarm_left_finger_joint1 |
Right (can0) | openarm_right_joint1 … openarm_right_joint7 | openarm_right_finger_joint1 |
There are no un-prefixed openarm_joint1…7 names in the bimanual setup. That’s why commands copied from single-arm docs hang with “Waiting for an action server…“.
v1.0 joint limits (openarm_description/assets/robot/openarm_v1.0/config/arm/joint_limits.yaml)
| Joint | Lower (rad) | Upper (rad) | Lower (°) | Upper (°) | Max vel (rad/s) | Max effort (Nm) | Motor |
|---|---|---|---|---|---|---|---|
| joint1 | −1.396 | 3.491 | −80 | 200 | 16.75 | 40 | DM8009 |
| joint2 | −1.745 | 1.745 | −100 | 100 | 16.75 | 40 | DM8009 |
| joint3 | −1.571 | 1.571 | −90 | 90 | 5.45 | 27 | DM4340 |
| joint4 | 0.0 | 2.443 | 0 | 140 | 5.45 | 27 | DM4340 |
| joint5 | −1.571 | 1.571 | −90 | 90 | 20.94 | 7 | DM4310 |
| joint6 | −0.785 | 0.785 | −45 | 45 | 20.94 | 7 | DM4310 |
| joint7 | −1.571 | 1.571 | −90 | 90 | 20.94 | 7 | DM4310 |
| finger_joint1 | 0.0 (closed) | 0.044 m (open) | DM4310 (gripper) |
The gripper joint is prismatic, in metres: 0.0 = closed, 0.044 = fully open. MoveIt’s named states: closed = 0, half closed/half_closed = 0.022.
Note joint4 lower limit is 0: the elbow only bends one way. Commanding a negative joint4 is outside limits.
Frames
world→body_link0(the torso/pillar) →openarm_left_link0/openarm_right_link0, mounted atxyz = 0 ±0.031 0.698withrpy = ∓1.5708 0 0.- The arms are mirrored, so the same joint angle moves the left and right arms in mirrored directions for some joints. Test each arm separately.
robot_state_publisherpublishes/tffrom/joint_states. If/joint_statesis missing (nojoint_state_broadcaster), RViz shows “No transform” for every moving link.
4. ros2_control layer
Hardware components
Two components are declared in openarm_v1.0/urdf/ros2_control/openarm.bimanual.ros2_control.xacro:
| Component | Plugin when use_fake_hardware:=true | Plugin when false | CAN |
|---|---|---|---|
openarm_left_hardware_interface | mock_components/GenericSystem | openarm_hardware/OpenArmHW | left_can_interface (default can1) |
openarm_right_hardware_interface | mock_components/GenericSystem | openarm_hardware/OpenArmHW | right_can_interface (default can0) |
Each joint exports command interfaces position, velocity, effort and state interfaces position, velocity, effort.
What OpenArmHW actually does (real hardware)
From openarm_hardware/src/openarm_simple_hardware.cpp:
| Lifecycle step | Behaviour |
|---|---|
on_init | Opens SocketCAN on can_interface (CAN-FD if can_fd). Registers motors: joints 1–7 = send IDs 0x01…0x07, receive IDs 0x11…0x17, types DM8009, DM8009, DM4340, DM4340, DM4310, DM4310, DM4310. Gripper = send 0x08 / receive 0x18, DM4310. |
on_configure | Asks every motor for its state (refresh_all), reads replies. |
on_activate | Enables all motors (torque on) and then drives every arm joint from wherever it is to 0 rad in a straight joint-space line over ~2 s (200 steps × 10 ms) with the full PD gains. The gripper is commanded too. The robot moves on activation. |
read (every cycle) | Requests and receives motor states. Arm joint state = motor position/velocity/torque directly (no gear or offset conversion). Gripper position is converted from motor radians to metres: joint = 0.044 × motor_rad / (−1.0472). Gripper velocity and effort are reported as 0. |
write (every cycle) | Sends MIT control to every arm motor: {kp_i, kd_i, q_cmd, dq_cmd, tau_cmd} from the position/velocity/effort command interfaces. Gripper: {kp_hand, kd_hand, motor_rad(q_cmd), 0, 0} with motor_rad = q_cmd / 0.044 × (−1.0472). |
on_deactivate | Disables all motors 3×. Torque off: the arms go limp and fall under gravity. |
MIT gains (v1.0 config/arm/control_gains.yaml; override by editing that file):
| j1 | j2 | j3 | j4 | j5 | j6 | j7 | hand | |
|---|---|---|---|---|---|---|---|---|
| kp | 70 | 70 | 70 | 60 | 10 | 10 | 10 | 5 |
| kd | 2.75 | 2.5 | 2.0 | 2.0 | 0.7 | 0.6 | 0.5 | 0.1 |
Consequences you need to know:
- No gravity compensation.
tau_ffis 0 unless a controller writes theeffortinterface, and none of the provided ones do. Expect steady-state sag, especially on joints 1–2 with the arm extended. - All three command interfaces feed one MIT command. Position-only controllers leave velocity and effort at 0, which is fine. A velocity-only controller does not give pure velocity control: the position term with its stiff
kpstill pulls toward the last position command, which starts at 0. See §16. - Joint zero = motor encoder zero. Zero calibration (§11) is what makes “0 rad” in ROS mean the right physical pose.
Controllers (openarm_bringup/config/controllers/openarm_bimanual_controllers.yaml)
| Controller | Type | Joints | Interfaces | Started by default? |
|---|---|---|---|---|
joint_state_broadcaster | JointStateBroadcaster | all | reads all states → /joint_states, /dynamic_joint_states | ✅ |
left_joint_trajectory_controller | JointTrajectoryController | left joint1–7 | cmd: position, state: position | ✅ (default robot_controller) |
right_joint_trajectory_controller | JointTrajectoryController | right joint1–7 | same | ✅ |
left_gripper_controller | JointTrajectoryController | openarm_left_finger_joint1 | position | ✅ |
right_gripper_controller | JointTrajectoryController | openarm_right_finger_joint1 | position | ✅ |
left/right_forward_position_controller | ForwardCommandController | joint1–7 | position | only with robot_controller:=forward_position_controller |
left/right_forward_velocity_controller | ForwardCommandController | joint1–7 | velocity | declared but never spawned |
Controller manager update rate:
- 750 Hz for
openarm_bringup(openarm_bimanual_controllers.yaml). - 100 Hz for the MoveIt demo (
openarm_bimanual_moveit_controllers.yaml).
Trajectory controller settings worth knowing:
interpolation_method: upstreammainsets it tonone. Withnone, there is no motion between points: see the next subsection. On zeus it is changed tosplines.allow_partial_joints_goal: false. Every goal must contain all 7 joints of that arm.- All
constraints(goal/trajectory tolerances,goal_time) are 0.0, which means “not checked”. A goal always reportsSUCCEEDEDwhen time runs out, even if the real arm didn’t get there (blocked, sagging). Verify with/joint_statesorcontroller_state, not just the action result.
Interpolation (what it does, and the zeus setup)
The trajectory controller runs 750 times a second. Each cycle it asks: “where should each joint be right now?” and writes that to the hardware. interpolation_method decides the answer between the points of your trajectory (source: joint_trajectory_controller/src/trajectory.cpp, Trajectory::sample(), ros2_controllers 4.42 for Jazzy):
Before the first point’s time_from_start | Between point k and point k+1 | |
|---|---|---|
none (upstream main) | holds the position it had when the goal arrived | jumps straight to point k+1 at the start of the segment, then waits there |
splines (ROS default, zeus) | moves smoothly from the current position to the first point | moves smoothly from point k to point k+1 |
So with none, a one-point goal {positions: [...], time_from_start: {sec: 5}} does nothing for 5 s and then steps to the target. time_from_start only delays the jump; it doesn’t slow it down. On real motors, with the stiff MIT gains, a step is a full-speed move. Upstream disabled interpolation for streaming use (the config comment says the controller “is expected to receive high-frequency commands”), where every command is a tiny step anyway.
What splines does depends on what each point contains:
| The points have… | Interpolation | Motion |
|---|---|---|
positions only | linear | constant speed; starts and stops abruptly (a velocity step, far milder than a position step) |
positions + velocities | cubic | eases in and out; use velocities: [0, 0, 0, 0, 0, 0, 0] on the last point for a smooth stop ⚠ verify |
positions + velocities + accelerations | quintic | smoothest |
The zeus change. interpolation_method is read-only at runtime (ros2 param set can’t change it), so it’s changed in the controller config and the bringup is relaunched. Inside the container, with nothing running:
ros2 pkg prefix openarm_bringup # must print /root/ros2_ws/install/openarm_bringup, i.e. the file below is the one used
cd /root/ros2_ws/src/openarm_ros2
git status --short # note anything already modified
sed -i 's/interpolation_method: "none"/interpolation_method: "splines"/' openarm_bringup/config/controllers/openarm_bimanual_controllers.yaml
git diff --stat # expect: 1 file changed, 4 insertions(+), 4 deletions(-)
ls -l /root/ros2_ws/install/openarm_bringup/share/openarm_bringup/config/controllers/openarm_bimanual_controllers.yamlIf the last line shows an arrow (-> /root/ros2_ws/src/...), the install is a symlink to the file you edited and nothing needs rebuilding. If it’s a plain file, rebuild that one package: cd /root/ros2_ws && colcon build --symlink-install --packages-select openarm_bringup.
Check after every launch (all four should print splines):
for c in left_joint_trajectory_controller right_joint_trajectory_controller left_gripper_controller right_gripper_controller; do ros2 param get /$c interpolation_method; doneTo undo: git checkout -- openarm_bringup/config/controllers/openarm_bimanual_controllers.yaml. Note that a git pull, git stash or re-clone of openarm_ros2 also undoes it. The MoveIt demo uses a different file (openarm_bimanual_moveit_controllers.yaml) that doesn’t set interpolation_method at all, so it already uses the default, splines.
5. Environment and container
Current container (openarm, image openarm-jazzy:built)
docker run -it --name openarm --network host --ipc host --gpus all \
-e NVIDIA_DRIVER_CAPABILITIES=all -e __NV_PRIME_RENDER_OFFLOAD=1 -e __GLX_VENDOR_LIBRARY_NAME=nvidia \
-e DISPLAY=$DISPLAY -v /tmp/.X11-unix:/tmp/.X11-unix openarm-jazzy:built(Your user is now in the docker group, so sudo is no longer needed.)
Daily use:
xhost +SI:localuser:root # on the HOST, once per login; lets the container open windows
docker start openarm # if it stopped (it stops when its main shell exits, e.g. at logout)
docker exec -it openarm bash # as many shells as you likeWhat each Docker command does (an image like openarm-jazzy:built is a frozen template; a container like openarm is one instance of it, with its own writable filesystem):
| Command | Acts on | Creates a container? | What it does |
|---|---|---|---|
docker run | image | Yes | Creates a new container from the image and starts its main process (here, a bash shell). All flags (--network, --gpus, -e, -v, …) are fixed at this moment and cannot be changed later. Run it only once: a second run with the same --name fails, and with a different name it would create a separate container without any of your changes. |
docker start | stopped container | No | Restarts an existing container that has stopped. Everything done inside it (installed packages, build output, .bashrc edits) is preserved, along with the original flags. A container stops when its main process exits, i.e. when the shell opened by docker run -it is closed. |
docker exec | running container | No | Starts an additional process (e.g. bash) inside a running container. Use it once per terminal you need. Exiting an exec shell does not stop the container. |
docker ps -a --filter name=openarm shows the container’s state: Up … means it is running, Exited … means it has stopped. docker rm openarm deletes the container and every change made inside it. To recover, recreate it from the image with docker run.
Inside every shell:
source /opt/ros/jazzy/setup.bash
source /root/ros2_ws/install/setup.bash(Add both lines to /root/.bashrc and remove the old humble lines.)
Why each flag matters:
| Flag | Why |
|---|---|
--network host | DDS discovery works across host and container. Also required for real hardware: SocketCAN interfaces (can0, can1) live in the host’s network namespace and are only visible in the container with host networking. |
--ipc host | Fast DDS shared-memory transport between processes. |
--gpus all + __NV_PRIME_RENDER_OFFLOAD=1 + __GLX_VENDOR_LIBRARY_NAME=nvidia | Hybrid-graphics laptop: forces RViz onto the NVIDIA GPU instead of failing on Mesa/iris. |
-e DISPLAY + X11 socket | GUI. Needs xhost +SI:localuser:root on the host. |
Extra flags to add for real hardware (see §12):
| Flag | Why |
|---|---|
--cap-add NET_ADMIN | Only if you want to configure can0/can1 from inside the container. Otherwise do it on the host. |
--cap-add SYS_NICE --ulimit rtprio=99 --ulimit memlock=-1 | Lets ros2_control_node use SCHED_FIFO real-time priority (removes the “Could not enable FIFO RT scheduling” warning). Strongly recommended at 750 Hz on real motors. |
Rebuilding the workspace
cd /root/ros2_ws
colcon build --symlink-install
source install/setup.bashDeprecation warnings from openarm_hardware (old on_init(HardwareInfo) signature, written for Humble) are expected and harmless on Jazzy.
6. Launch files and arguments
openarm_bringup/launch/openarm.bimanual.launch.py: robot only
Starts robot_state_publisher, ros2_control_node (controller manager), RViz (openarm_description/rviz/bimanual.rviz), and after 1 s the spawners for joint_state_broadcaster, both arm controllers and both gripper controllers.
| Argument | Default | Notes |
|---|---|---|
arm_type | openarm_v2.0 | Use v10. |
use_fake_hardware | true | false = real motors via CAN. |
robot_controller | joint_trajectory_controller | or forward_position_controller. |
right_can_interface | can0 | |
left_can_interface | can1 | |
can_fd | true | false = classic CAN 2.0 (must match how the motors are configured). |
arm_prefix | "" | Puts everything under a namespace and switches to openarm_bimanual_controllers_namespaced.yaml. That file doesn’t exist on main, so leave this empty. |
controllers_file | openarm_bimanual_controllers.yaml | |
runtime_config_package | openarm_bringup | |
description_package | openarm_description | |
description_file | v20.urdf.xacro | Ignored. |
openarm_bimanual_moveit_config/launch/demo.launch.py: robot + MoveIt
Starts everything above (its own robot_state_publisher and controller manager, at 100 Hz) plus move_group and RViz with the MotionPlanning panel. Same arguments (arm_type, use_fake_hardware, can_fd, CAN interfaces, robot_controller).
Run one or the other, never both at once. Each starts its own controller manager, and two
/controller_managernodes conflict.
Other launch files in the MoveIt package (move_group.launch.py, moveit_rviz.launch.py, spawn_controllers.launch.py, static_virtual_joint_tfs.launch.py, setup_assistant.launch.py) are MoveIt Setup Assistant boilerplate and not needed for normal use.
7. Running in simulation (mock hardware)
# A) Robot only + RViz
ros2 launch openarm_bringup openarm.bimanual.launch.py arm_type:=v10 use_fake_hardware:=true
# B) Robot + MoveIt + RViz MotionPlanning
ros2 launch openarm_bimanual_moveit_config demo.launch.py arm_type:=v10Healthy start (ros2 control list_controllers):
joint_state_broadcaster joint_state_broadcaster/JointStateBroadcaster active
left_joint_trajectory_controller joint_trajectory_controller/JointTrajectoryController active
right_joint_trajectory_controller joint_trajectory_controller/JointTrajectoryController active
left_gripper_controller joint_trajectory_controller/JointTrajectoryController active
right_gripper_controller joint_trajectory_controller/JointTrajectoryController active
mock_components/GenericSystem copies each position command straight into the position state. There is no dynamics, no gravity, no limits enforcement, and motion is exactly the commanded trajectory. Good for checking joint names, frames, planning and your own code, but not for tuning.
Harmless messages you can ignore:
- “unrealistic inertia” on finger links
- controller_manager overrun warnings
- “Could not enable FIFO RT scheduling” (in sim)
XDG_RUNTIME_DIRnot setgroups: cannot find name for group ID 992- MoveIt “No 3D sensor plugin(s) defined for octomap updates”
move_groupexiting with code −11 when you press Ctrl+C (a known MoveIt shutdown crash)
8. Commanding the robot (sim and real)
All of the methods below work the same way with mock and real hardware. On the real robot, start with small, slow motions (§12).
8.1 Trajectory action (recommended)
Action servers:
| Action | Type |
|---|---|
/left_joint_trajectory_controller/follow_joint_trajectory | control_msgs/action/FollowJointTrajectory |
/right_joint_trajectory_controller/follow_joint_trajectory | same |
/left_gripper_controller/follow_joint_trajectory | same |
/right_gripper_controller/follow_joint_trajectory | same |
Left arm, all joints to 0.15 rad in 3 s:
ros2 action send_goal /left_joint_trajectory_controller/follow_joint_trajectory \
control_msgs/action/FollowJointTrajectory \
'{trajectory: {joint_names: [openarm_left_joint1, openarm_left_joint2, openarm_left_joint3, openarm_left_joint4, openarm_left_joint5, openarm_left_joint6, openarm_left_joint7],
points: [{positions: [0.15, 0.15, 0.15, 0.15, 0.15, 0.15, 0.15], time_from_start: {sec: 3}}]}}'Right arm, multi-waypoint (out to the “hands up” pose, then home):
ros2 action send_goal /right_joint_trajectory_controller/follow_joint_trajectory \
control_msgs/action/FollowJointTrajectory \
'{trajectory: {joint_names: [openarm_right_joint1, openarm_right_joint2, openarm_right_joint3, openarm_right_joint4, openarm_right_joint5, openarm_right_joint6, openarm_right_joint7],
points: [
{positions: [0, 0, 0, 1.0, 0, 0, 0], time_from_start: {sec: 3}},
{positions: [0, 0, 0, 2.0, 0, 0, 0], time_from_start: {sec: 6}},
{positions: [0, 0, 0, 0, 0, 0, 0], time_from_start: {sec: 10}}]}}' --feedbackGripper (metres, 0 = closed, 0.044 = open):
# open
ros2 action send_goal /left_gripper_controller/follow_joint_trajectory control_msgs/action/FollowJointTrajectory \
'{trajectory: {joint_names: [openarm_left_finger_joint1], points: [{positions: [0.044], time_from_start: {sec: 1}}]}}'
# close
ros2 action send_goal /left_gripper_controller/follow_joint_trajectory control_msgs/action/FollowJointTrajectory \
'{trajectory: {joint_names: [openarm_left_finger_joint1], points: [{positions: [0.0], time_from_start: {sec: 1}}]}}'Rules:
- Include all 7 joints of the arm (
allow_partial_joints_goal: false). - Order of
joint_namesis free, butpositionsmust match it. - Check
interpolation_methodfirst (§4). Withsplines(zeus),time_from_startsets the speed: make it long enough, since a short time means a fast, violent move on the real robot. Withnone(upstream default), the joint holds still for the wholetime_from_startand then jumps to the target, whatever the time: never usenonefor single-point or sparse goals on hardware. - Cancel a running goal: Ctrl+C on
ros2 action send_goalcancels it. The controller then holds the current position (decelerate_on_cancel: false, so it stops immediately rather than ramping down). SUCCEEDEDonly means “time ran out” (tolerances are 0 = disabled). Check/joint_states.
8.2 Trajectory topic (fire-and-forget)
Each JTC also listens on /<controller>/joint_trajectory (trajectory_msgs/msg/JointTrajectory):
ros2 topic pub --once /left_joint_trajectory_controller/joint_trajectory trajectory_msgs/msg/JointTrajectory \
'{joint_names: [openarm_left_joint1, openarm_left_joint2, openarm_left_joint3, openarm_left_joint4, openarm_left_joint5, openarm_left_joint6, openarm_left_joint7],
points: [{positions: [0, 0, 0, 0, 0, 0, 0], time_from_start: {sec: 3}}]}'No feedback and no result. A new message replaces the running trajectory. Useful for streaming from a teleop node.
8.3 Python (rclpy) action client
Save as move_arm.py and run with python3 move_arm.py in a sourced shell:
import rclpy
from rclpy.node import Node
from rclpy.action import ActionClient
from control_msgs.action import FollowJointTrajectory
from trajectory_msgs.msg import JointTrajectoryPoint
from builtin_interfaces.msg import Duration
class ArmCommander(Node):
def __init__(self, side: str):
super().__init__(f"{side}_arm_commander")
self.side = side
self.joints = [f"openarm_{side}_joint{i}" for i in range(1, 8)]
self.client = ActionClient(
self, FollowJointTrajectory,
f"/{side}_joint_trajectory_controller/follow_joint_trajectory")
def move(self, waypoints, seconds_per_point=3.0):
"""waypoints: list of 7-element joint position lists (rad)."""
goal = FollowJointTrajectory.Goal()
goal.trajectory.joint_names = self.joints
for k, q in enumerate(waypoints, start=1):
t = seconds_per_point * k
goal.trajectory.points.append(JointTrajectoryPoint(
positions=list(q),
time_from_start=Duration(sec=int(t), nanosec=int((t % 1) * 1e9))))
self.client.wait_for_server()
send_future = self.client.send_goal_async(goal)
rclpy.spin_until_future_complete(self, send_future)
handle = send_future.result()
if not handle.accepted:
self.get_logger().error("Goal rejected")
return False
result_future = handle.get_result_async()
rclpy.spin_until_future_complete(self, result_future)
res = result_future.result().result
self.get_logger().info(f"error_code={res.error_code} {res.error_string}")
return res.error_code == 0
def main():
rclpy.init()
left = ArmCommander("left")
left.move([[0, 0, 0, 0.5, 0, 0, 0],
[0, 0, 0, 0.0, 0, 0, 0]])
left.destroy_node()
rclpy.shutdown()
if __name__ == "__main__":
main()To move both arms at the same time, create two clients and send both goals before waiting on either result.
8.4 Reading state
ros2 topic echo /joint_states # name/position/velocity/effort, all 16 joints
ros2 topic echo /left_joint_trajectory_controller/controller_state # reference vs feedback vs error
ros2 topic echo /dynamic_joint_states # every state interface
ros2 run tf2_ros tf2_echo world openarm_left_link7 # Cartesian pose of a linkOn the real robot, /joint_states effort for arm joints is real motor torque (Nm). For the gripper, velocity and effort are always 0.
8.5 Forward position controller (streaming, advanced)
Launch with robot_controller:=forward_position_controller, then:
ros2 topic pub --once /left_forward_position_controller/commands std_msgs/msg/Float64MultiArray \
'{data: [0, 0, 0, 0.2, 0, 0, 0]}'The motor setpoint jumps to the value instantly: there is no interpolation. With the stiff MIT gains this is a fast, violent step on the real robot. Only use it from your own node streaming small increments at a high, steady rate (e.g. ≥100 Hz, a few mrad per step). Never use it with ros2 topic pub on hardware.
8.6 Switching controllers at runtime
ros2 control list_controllers
ros2 control load_controller left_forward_position_controller --set-state inactive
ros2 control switch_controllers --deactivate left_joint_trajectory_controller --activate left_forward_position_controller
# and back
ros2 control switch_controllers --deactivate left_forward_position_controller --activate left_joint_trajectory_controllerTwo controllers can’t claim the same command interface at once. Always deactivate one and activate the other in a single switch_controllers call.
9. MoveIt 2
ros2 launch openarm_bimanual_moveit_config demo.launch.py arm_type:=v10 # sim
ros2 launch openarm_bimanual_moveit_config demo.launch.py arm_type:=v10 use_fake_hardware:=false # realPlanning groups (SRDF config/openarm_v1.0/openarm_bimanual.srdf):
| Group | Joints | Named states |
|---|---|---|
left_arm | openarm_left_joint1…7 | home (all 0), hands_up (joint4 = 2.0) |
right_arm | openarm_right_joint1…7 | home, hands_up |
left_gripper | openarm_left_finger_joint1 | closed (0), half closed (0.022) |
right_gripper | openarm_right_finger_joint1 | closed, half_closed |
- IK: KDL (
kdl_kinematics_plugin), 5 ms timeout. Planner: OMPL. - Execution goes to
left/right_joint_trajectory_controllerviafollow_joint_trajectory.
Using RViz:
- In the MotionPlanning panel → Planning tab, choose Planning Group (
left_arm/right_arm). - Drag the interactive marker (or choose a named Goal State, e.g.
hands_up). - Plan, check the ghost trajectory, then Execute (or Plan & Execute).
- In the Planning tab, lower Velocity Scaling and Acceleration Scaling (e.g. 0.1) before executing on the real robot.
Programmatic access:
- Action
/move_action(moveit_msgs/action/MoveGroup) to plan and execute. - Action
/execute_trajectoryto execute an already-planned trajectory. - From C++, use
moveit::planning_interface::MoveGroupInterface. From Python, usemoveit_py(packageros-jazzy-moveit-py, needs its own config/launch).
Gripper caveat: MoveIt is configured to drive the grippers as GripperCommand controllers on /left_gripper_controller/gripper_cmd, but the gripper controllers are JointTrajectoryControllers, which only serve follow_joint_trajectory. Arm planning works, but executing a plan for a gripper group from MoveIt will fail. Command the grippers as in §8.1, or change the gripper entries in config/openarm_v1.0/moveit_controllers.yaml to type: FollowJointTrajectory with action_ns: follow_joint_trajectory, then rebuild.
10. The physical robot: CAN setup
10.1 Hardware you need
- Two CAN-FD capable USB-CAN adapters supported by SocketCAN (OpenArm uses gs_usb-class adapters such as CANable 2.0 / candleLight-FD firmware), one per arm.
- Power supply for the arms (per Enactic docs), with an emergency stop / power cut you can reach instantly.
- Bus termination per the OpenArm hardware docs.
Default mapping used by the launch files:
| Arm | Interface | Motor IDs (send → receive) |
|---|---|---|
| Right | can0 | J1–J7: 0x01…0x07 → 0x11…0x17; gripper 0x08 → 0x18 |
| Left | can1 | same IDs on its own bus |
Both arms use the same IDs, which is why each arm must be on its own bus.
10.2 Install the CAN tools on the host
Interface setup has to happen on the host, or in a container with NET_ADMIN. The easiest way to get the tools on the host is Enactic’s PPA:
sudo add-apt-repository -y ppa:openarm/main
sudo apt update
sudo apt install -y libopenarm-can-dev openarm-can-utils can-utils(can-utils gives candump/cansend for low-level debugging.) Alternatively, run the container’s copy: /root/ros2_ws/install/openarm_can/bin/openarm-can-cli. It works from the container because of --network host, but can_configure needs --cap-add NET_ADMIN.
10.3 Identify and name the adapters
USB-CAN adapters enumerate in plug-in order, so can0 and can1 can swap between boots. Swapping them sends left-arm commands to the right arm. Plug in one adapter at a time, or check serials:
ip -details link show type can
udevadm info -a /sys/class/net/can0 | grep -m1 'ATTRS{serial}'Pin the names with a udev rule (on the host), e.g. /etc/udev/rules.d/80-openarm-can.rules:
SUBSYSTEM=="net", ACTION=="add", ATTRS{serial}=="<RIGHT_ADAPTER_SERIAL>", NAME="can0"
SUBSYSTEM=="net", ACTION=="add", ATTRS{serial}=="<LEFT_ADAPTER_SERIAL>", NAME="can1"
Then run sudo udevadm control --reload && sudo udevadm trigger and replug. Label the cables too.
10.4 Bring the interfaces up (CAN-FD, 1 Mbit/s / 5 Mbit/s)
openarm-can-cli -i can0 can_configure # defaults: 1 Mbit/s nominal, 5 Mbit/s data, FD on
openarm-can-cli -i can1 can_configureEquivalent raw command:
sudo ip link set can0 down
sudo ip link set can0 type can bitrate 1000000 dbitrate 5000000 fd on
sudo ip link set can0 upFor classic CAN 2.0 (only if your motors are configured that way):
openarm-can-cli -i can0 can_configure -d 1000000 --no-fd
# and launch with can_fd:=falseNotes:
- By default the interface stays down after a bus-off (
--rm 0) so you notice faults.--rm 100auto-restarts. - Interface config is lost on unplug or reboot. Rerun it, or add a systemd/networkd unit.
- Check it:
ip -details -statistics link show can0should showstate ERROR-ACTIVE,fd on, and no growing error counters.
10.5 Check the motors before ROS
openarm-can-cli -i can0 discover # should list IDs 1–8 (7 joints + gripper)
openarm-can-cli -i can0 monitor # live pos/vel/torque/temp for IDs 1–8 (6 s by default)
openarm-can-cli -i can0 monitor -d 60000 # watch for 60 s; move the arm by hand
openarm-can-cli -i can0 diagnose # status/error codes and temp/torque ranges
openarm-can-cli -i can0 clear_error # clear latched motor errors
candump can0 # raw trafficRepeat for can1. With monitor running, move each joint by hand (motors disabled = free to move) and check that:
- each joint changes the motor you expect (ID n = joint n, ID 8 = gripper);
- the sign makes sense against RViz;
- at the physical zero pose the positions read ≈ 0. If not, see §11.
Other CLI subcommands (use carefully, they change motor firmware settings):
| Command | Purpose |
|---|---|
enable / disable [--id …] | Torque on/off. disable makes the arm fall. |
set_zero [--id …] | Store the current position as the zero for those motors. |
show_param, write_param -c ID -r RID -v VAL [--save] | Read/write internal motor registers. |
change_id -c CUR -s NEW_SLAVE -m NEW_MASTER --save | Change CAN IDs (e.g. a replaced motor). |
change_baud -c ID -b BAUD --save | Change motor bus speed (e.g. to 5 Mbit/s for CAN-FD). |
11. The physical robot: zero calibration
ROS joint angles are raw motor encoder positions, so the motor zero must equal the URDF zero pose. If it doesn’t, RViz won’t match reality, the limits in §3 are wrong, and the 2-second “return to zero” at activation (§4) drives the arm somewhere unexpected and possibly into itself or the torso.
Check first: with motors disabled, put the arm in the URDF zero pose (compare against RViz with all joints at 0) and run openarm-can-cli -i canX monitor. If every joint reads ≈ 0 (within a degree or two), you don’t need to recalibrate.
Option A: automatic (mechanical stops), recommended by Enactic
openarm-can-zero-position-calibration drives each joint gently into its mechanical stop, computes the offset to the ideal zero from the known stop angles (MECH_LIM_V1), and writes the zero to the motors.
openarm-can-zero-position-calibration --robot-version v1 --arm-side right_arm --canport can0
openarm-can-zero-position-calibration --robot-version v1 --arm-side left_arm --canport can1--robot-versiondefaults tov2, so passv1for your arms.- The arm moves on its own. Clear the space and stand by the power cut. Ctrl+C disables the motors (the arm drops).
- It needs the
openarm_canPython module. That’s not built in the container. Either install the PPA’s Python package on the host, if available, or build the bindings:cd /root/ros2_ws/src/openarm_can/python && pip install .(in a venv, after the C++ lib is installed).
Option B: manual
- Motors disabled. Physically place the arm precisely in the zero pose (a jig helps).
openarm-can-cli -i can0 set_zerosets IDs 1–8 at once. Use--id 1,2,3for specific motors.- Verify with
monitor.
Manual zeroing is only as precise as your pose placement. Option A is more repeatable.
Follow the official calibration instructions at https://docs.openarm.dev for anything that differs from this summary. Calibration procedure details have changed between releases.
12. The physical robot: bringing it up with ROS 2
12.1 Pre-flight checklist
- Workspace clear of people and objects within the arms’ full reach (arms can reach ~0.6 m+ from the shoulder in every direction).
- E-stop / power cut reachable by the person at the keyboard.
- You know which bus is which arm. On zeus:
can0= left arm,can1= right arm (the reverse of the ROS defaults, §22), so the launch needsright_can_interface:=can1 left_can_interface:=can0. Both up in FD mode (§10.4).discovershows 8 motors on each. - Trajectory controllers use
interpolation_method: splines, checked in sim first with theros2 param getloop in §4, and a single-point goal visibly moves smoothly in RViz. - Zero verified (§11).
- Arms resting in a pose near zero. On activation they move to zero in ~2 s with stiff gains (§4), so the closer they are, the smaller that move.
- Someone ready to support the arms when you shut down (torque off → arms drop).
- Container started with
--network host(and ideally--cap-add SYS_NICE --ulimit rtprio=99 --ulimit memlock=-1).
12.2 Launch
zeus: the PCAN cables are in the opposite order to the ROS defaults (left arm on
can0, right arm oncan1, see §22). On zeus, always addright_can_interface:=can1 left_can_interface:=can0to every real-hardware launch below, or each arm gets the other arm’s commands.
Robot only:
ros2 launch openarm_bringup openarm.bimanual.launch.py \
arm_type:=v10 use_fake_hardware:=false \
right_can_interface:=can0 left_can_interface:=can1 can_fd:=trueRobot + MoveIt:
ros2 launch openarm_bimanual_moveit_config demo.launch.py \
arm_type:=v10 use_fake_hardware:=false \
right_can_interface:=can0 left_can_interface:=can1 can_fd:=trueExpected log lines from OpenArmHW for each arm:
Configuration: CAN=can0, arm_prefix=right_, hand=enabled, can_fd=enabled
Initializing OpenArm on can0 with CAN-FD enabled...
OpenArm V10 Simple HW initialized successfully
Activating OpenArm V10...
Returning to zero position...
Reached zero position
OpenArm V10 activated
Then check:
ros2 control list_hardware_components # both openarm_*_hardware_interface: active, plugin openarm_hardware/OpenArmHW
ros2 control list_controllers # 5 controllers active
ros2 topic echo /joint_states --once # values ≈ 0, efforts non-zero (holding against gravity)Bring up one arm at a time the first time. There’s no launch argument for that. Either power only one arm, so the other component fails to init and you can debug calmly, or temporarily edit the bimanual ros2_control xacro. Powering one arm is simplest.
12.3 First motions
-
Watch
/joint_statesin one terminal:ros2 topic echo /joint_states --field position. -
Move one joint, a small amount, slowly. Here
joint4(elbow, limit 0…2.44) goes to 0.3 rad over 5 s:ros2 action send_goal /right_joint_trajectory_controller/follow_joint_trajectory control_msgs/action/FollowJointTrajectory \ '{trajectory: {joint_names: [openarm_right_joint1, openarm_right_joint2, openarm_right_joint3, openarm_right_joint4, openarm_right_joint5, openarm_right_joint6, openarm_right_joint7], points: [{positions: [0, 0, 0, 0.3, 0, 0, 0], time_from_start: {sec: 5}}]}}' -
Compare the real arm with RViz. Same direction? Same magnitude?
-
Go back to zero the same way, then test the other joints and the other arm one by one.
-
Gripper:
0.022(half) first, then0.0/0.044. -
Only then use MoveIt, with velocity/acceleration scaling at 0.1 at first.
Rules of thumb on hardware:
- Use
time_from_start≥ 3–5 s per large move until you trust the setup. This only slows the move withinterpolation_method: splines; withnonethe joint steps to the target at full speed after that time (§4). Check it before the first motion. - Stay inside the joint limits in §3. The trajectory controller and mock don’t stop you. MoveIt respects limits, raw commands don’t.
- Remember goals report
SUCCEEDEDeven if the arm didn’t arrive (tolerances disabled). - Expect some sag from gravity (no gravity compensation). Don’t “fix” it by raising
kpwithout understanding the motor limits.
12.4 Stopping
-
Stop a motion: Ctrl+C the
send_goalcommand (cancel), or send a new goal to the current position. The arm holds position. -
Torque off but keep ROS running: deactivate the hardware component. Support the arm first, it will drop.
ros2 control set_hardware_component_state openarm_right_hardware_interface inactive ros2 control set_hardware_component_state openarm_right_hardware_interface active # re-enable (moves to zero again!) -
Shut down: support the arms, then Ctrl+C the launch. ⚠ verify how shutdown behaves on your hardware. Jazzy’s controller manager may finalize the hardware without calling
on_deactivate. In that case the motors keep their last MIT command until the motor’s own CAN timeout (if one is configured in the motor parameters) or until power is cut. Test this once with the arms supported, and find out which happens before relying on it. -
Emergency: cut motor power. Don’t rely on software.
12.5 Real-time tuning (recommended)
- Run the container with
--cap-add SYS_NICE --ulimit rtprio=99 --ulimit memlock=-1so the controller manager’s RT thread gets SCHED_FIFO priority 50. - Optional: a low-latency or PREEMPT_RT kernel on the host.
- Watch for overruns:
ros2 topic echo /controller_manager/statistics/fulland the launch log. Persistent overruns at 750 Hz mean late CAN frames and jerky motion. A laptop on battery with GPU-heavy RViz is the worst case, so consider plugging in and closing RViz during demanding runs.
13. Safety notes specific to this code
- Activation moves the robot: arms go from the current pose to all-zero in ~2 s with full gains (
return_to_zero()inon_activate). Re-activating a hardware component does it again. - Torque off = arms fall:
on_deactivate,openarm-can-cli disable, Ctrl+C in the calibration scripts, and power loss all drop the arms. - No tracking-error abort: JTC tolerances are all 0 (disabled), so a blocked or colliding arm doesn’t make the controller give up. The motor keeps pushing up to what
kp × errorallows. - No self-collision checking outside MoveIt. Raw trajectory commands can drive the arm into the torso or the other arm.
- Forward position controller steps instantly (§8.5).
- Velocity “controller” isn’t velocity control (§4, §16).
- Mixed-up
can0/can1sends the right arm’s commands to the left arm (§10.3). - Wrong
arm_type(default v2.0) loads the wrong kinematics. Always passarm_type:=v10. - Wrong zero makes “0 rad” a different physical pose. That’s dangerous at activation (§11).
14. Introspection and debugging cheat-sheet
# Controllers & hardware
ros2 param get /left_joint_trajectory_controller interpolation_method # must be 'splines' on zeus (§4)
ros2 control list_controllers
ros2 control list_hardware_components
ros2 control list_hardware_interfaces
ros2 control set_controller_state left_joint_trajectory_controller inactive|active
ros2 control view_controller_chains
# Interfaces
ros2 action list -t
ros2 topic list -t
ros2 action info -t /left_joint_trajectory_controller/follow_joint_trajectory
# State
ros2 topic echo /joint_states --field position
ros2 topic hz /joint_states
ros2 topic echo /left_joint_trajectory_controller/controller_state
ros2 topic echo /controller_manager/statistics/full
# Model
ros2 param get /robot_state_publisher robot_description > /tmp/openarm.urdf
check_urdf /tmp/openarm.urdf # from liburdfdom-tools
ros2 run tf2_tools view_frames # frames.pdf
# Spawner by hand (with timeouts so it doesn't hang)
ros2 run controller_manager spawner joint_state_broadcaster -c /controller_manager --controller-manager-timeout 30
# CAN (host or container with --network host)
ip -details -statistics link show can0
candump -td can0
openarm-can-cli -i can0 monitor
openarm-can-cli -i can0 diagnose
# Logs
ls ~/.ros/log/ # per-launch logs
ros2 launch … 2>&1 | tee ~/launch.log15. Troubleshooting
| Symptom | Cause / fix |
|---|---|
Only body_link0 / link0 render; “No transform to world” for arm links | No /joint_states, so joint_state_broadcaster isn’t running. Check ros2 control list_controllers. |
| Spawner: “Failed to acquire lock in 20 seconds” | Another spawner is holding /tmp/ros2-control-controller-spawner.lock because it’s stuck or left over. Ctrl+C everything, pkill -f controller_manager/spawner; pkill -f ros2_control_node, relaunch. On zeus this was stale state in the old container session and disappeared after the container restarted. (A leftover lock file on its own does no harm; only a live process holds the lock.) |
| ”Waiting for an action server to become available…” forever | Wrong action name, e.g. single-arm /joint_trajectory_controller/…. Use /left_… or /right_… (ros2 action list). |
| Goal rejected / aborted immediately | Joint names wrong or not all 7 included, or a position is outside the limits. |
RViz: Authorization required… could not connect to display :1 | On the host: xhost +SI:localuser:root (needed again after each login). |
| RViz: MESA/iris errors, black window | Missing PRIME env vars (__NV_PRIME_RENDER_OFFLOAD=1, __GLX_VENDOR_LIBRARY_NAME=nvidia, NVIDIA_DRIVER_CAPABILITIES=all, --gpus all). |
| Container gone after logout | Its main process was your shell. docker start openarm. |
| Arm model looks wrong / v2.0 meshes | Forgot arm_type:=v10. |
OpenArmHW init fails / socket error | can0/can1 doesn’t exist or is down in the host namespace; the container isn’t on --network host; or FD mismatch (interface FD on but can_fd:=false, or vice versa). |
| Motors don’t respond / timeouts | Wrong bitrate (check discover, which scans 1/5/8/10 Mbit/s), motor errors (diagnose, clear_error), missing termination, no motor power. |
| Interface went down by itself | Bus-off (errors). Check wiring/termination; ip -s -d link show can0; reconfigure; optionally can_configure --rm 100. |
| Arm jerks / buzzes | CM overruns (no RT priority, loaded laptop), or gains too high for the load. Add the RT flags (§12.5). |
| MoveIt gripper execution fails | GripperCommand vs JTC mismatch (§9). |
move_group dies with −11 on Ctrl+C | Known MoveIt shutdown crash; harmless. |
16. Known issues and gaps in main
openarm.reposonly listsopenarm_can.openarm_descriptionmust be cloned separately, and the repo’s.docker/Dockerfilenever runsvcs import(you patched both).description_filelaunch argument is declared but unused. Both launch files default toarm_type=openarm_v2.0.arm_prefixnamespacing refers toopenarm_bimanual_controllers_namespaced.yaml, which doesn’t exist.- MoveIt gripper controllers are
GripperCommandbut the bringup runs gripper JTCs (§9). openarm_hardwareuses the Humble-eraon_init(HardwareInfo)signature (deprecation warnings on Jazzy). It doesn’t implementon_shutdown/on_error.return_to_zero()sends the gripper0.044as a raw motor angle (no joint→motor conversion), so during activation the gripper goes to ≈ closed, not “open”. After activation, gripper commands are converted correctly.- Gripper joint↔motor mapping is a linear approximation (source comment: “the mappings are approximates”). Gripper velocity/effort states are always 0.
*_forward_velocity_controlleris declared but never spawned. Because MIT mode always includes thekp·(q_cmd − q)term andq_cmdkeeps its last value, using it alone doesn’t give true velocity control. Don’t use it on hardware without modifyingOpenArmHW.- No gravity compensation (
tau_ff = 0). - JTC tolerances all 0, so failures are never detected (§4).
- All four bringup JTCs set
interpolation_method: none, so single-point and sparse goals step to the target aftertime_from_startinstead of moving there. Changed tosplineson zeus (§4). - The Python calibration tools need the
openarm_canPython bindings, whichcolcon builddoesn’t produce.
Part II: Teleoperation and data collection with Hugging Face LeRobot
17. What LeRobot is and where it sits
LeRobot is Hugging Face’s Python framework for real-robot learning. One toolchain covers:
teleoperate → record datasets (with cameras) → share on the Hub → train policies (ACT, Diffusion, SmolVLA, Pi0, …) → run policies on the robot
It has first-class OpenArm support: an openarm_follower robot plus leader/teleoperator devices.
The important thing to understand is that LeRobot doesn’t use ROS 2 at all. It is a second, independent control stack that talks to the very same Damiao motors directly:
┌──────────────── Part I: ROS 2 stack ────────────────┐
your code / MoveIt → │ controllers → OpenArmHW → openarm_can (C++) │ ─┐
└──────────────────────────────────────────────────────┘ │ SocketCAN
├─► can0 / can1 ─► follower arms
┌──────────────── Part II: LeRobot stack ─────────────┐ │ (Damiao, MIT mode)
leader arm(s) ─────► │ Teleoperator.get_action() → Robot.send_action() │ ─┘
policy (NN) ──────► │ DamiaoMotorsBus (python-can) — MIT commands, deg │
cameras ───────────► │ → LeRobotDataset (parquet + mp4) → Hub / training │
└──────────────────────────────────────────────────────┘
Consequences:
- Only one stack may own a follower CAN bus at a time. Both send MIT commands at high rate to the same motor IDs. Running both means two masters fighting over the arm. See §25.
- What you build in Part I (ros2_control, MoveIt, RViz) and Part II (LeRobot teleop, datasets, learned policies) are parallel paths to the same hardware, not layers of one system. They share:
- LeRobot runs on the host in its own conda env (
lerobot). ROS 2 runs in theopenarmcontainer. Both reachcan0/can1because the container uses--network host.
When to use which:
| Task | Use |
|---|---|
| Teleoperation with a leader arm, demonstration recording, imitation learning, running learned policies | LeRobot |
| Motion planning, collision checking, Cartesian goals, scripted trajectories, integration with other ROS nodes (perception, navigation) | ROS 2 / MoveIt |
| Visualising the robot model, checking frames, simulating before hardware | ROS 2 (RViz, mock hardware) |
LeRobot’s own visualisation is Rerun (--display_data=true), which shows joint values and camera streams as time series and images. It doesn’t show a 3D robot model.
18. Teleop hardware options
zeus uses the bimanual OpenArm Mini (bi_openarm_mini): two small 3D-printed 7-DoF + gripper leader arms
with Feetech STS3215 servos, each on its own USB serial board (CH343, 1a86:55d3). The rest of Part II is
written for that kit.
For reference, LeRobot v0.6.2 has two OpenArm teleoperator families:
OpenArm Mini (openarm_mini / bi_openarm_mini), used on zeus | OpenArm Leader (openarm_leader / bi_openarm_leader) | |
|---|---|---|
| What it is | Small leader arm, Feetech STS3215 servos | A full-size OpenArm with Damiao motors, used passively (torque off) |
| Connection | USB serial → /dev/serial/by-id/… | CAN-FD (two more CAN channels) |
| Mapping to follower | In code: per-side sign flips (SIDE_MOTORS_TO_FLIP), joint 6 ↔ joint 7 swapped (JOINT_REMAP), gripper 0–100 % → 0 … −65° | 1:1 |
| Force feedback | None | None in LeRobot |
How the Mini maps to the follower matters when debugging (§27). These are the joints LeRobot negates, per side:
| Mini servo | side=left | side=right | Drives follower joint |
|---|---|---|---|
| joint_1, 3, 4, 5 | flipped | flipped | same |
| joint_2 | — | flipped | joint_2 |
| joint_6 | flipped | — | joint_7 |
| joint_7 | flipped | flipped | joint_6 |
So a Mini read with the wrong side (or plugged into the other arm’s port) inverts follower joints 2 and 7.
That’s exactly what happened on zeus before the ports were sorted out.
Enactic’s C++ openarm_teleop (force feedback, needs Damiao leaders) is covered in §28.
19. Installation on zeus
Already done on the host (not in the container):
| Item | Location / version |
|---|---|
LeRobot source (editable install, shallow clone of main) | ~/Documents/lerobot (commit b863b42, v0.6.2) |
| Conda env | lerobot, Python 3.12.15, from conda-forge, with ffmpeg |
| Extras | damiao (python-can 4.6.1), feetech (feetech-servo-sdk 1.0.0, pyserial), core_scripts (datasets, rerun, foxglove, pynput, av) |
| PyTorch | 2.11.0 + CUDA 13.0, torch.cuda.is_available() == True on the RTX 5050 |
| zeus tools | ~/Documents/openarm_lerobot/: see the table below |
| Install logs | ~/Documents/lerobot_pip_install.log, ~/Documents/lerobot_pip_install2.log |
| Already on host | openarm-can-utils 1.4.0 (openarm-can-cli, calibration scripts), can-utils |
Exactly what was run, so you can reproduce it:
git clone --depth 1 https://github.com/huggingface/lerobot.git ~/Documents/lerobot
conda create -y -n lerobot -c conda-forge --override-channels python=3.12 ffmpeg
conda activate lerobot
cd ~/Documents/lerobot
pip install -e ".[damiao,feetech,core_scripts]"Use it:
conda activate lerobot # every new terminal
lerobot-info # versions sanity checkYour user is in the dialout group (needed for the Mini serial ports; it only takes effect after logging out and back in).
CAN interface setup still needs sudo each time the adapter is (re)plugged; lerobot-setup-can --mode=setup calls sudo ip link … internally.
zeus tools (~/Documents/openarm_lerobot/, all run inside conda activate lerobot):
| Tool | What it does | Touches hardware? |
|---|---|---|
setup_openarm_lerobot.sh | Start here. One-shot config + calibration: checks env/ports, brings CAN up, tests all 16 motors, creates follower placeholders, checks/redoes Mini calibration + gripper range, prints a sanity table. Re-runnable. | CAN setup (sudo); motor test briefly enables/disables; Mini calibration writes the Minis only |
safe_teleop.py | Guarded teleop (§23.3). Use it instead of lerobot-teleoperate. | Yes: drives the followers, but only after the alignment gate |
compare_mini_follower.py | Live table: what each Mini would command vs. where each follower joint is. --once for a snapshot. | Read-only, no torque |
check_mini_calibration.py | Checks the Mini calibration files match the offsets stored in each Mini, and that the gripper range is valid | Read-only |
fix_mini_gripper_range.py | Records the gripper open/closed range from a live readout and patches the files (works around lerobot-calibrate saving min == max) | Files only |
make_follower_calibration.py | Creates the follower placeholder files so LeRobot never re-zeroes the follower motors (§21) | Files only |
mini_raw_monitor.py | Raw servo counts of one Mini, live | Read-only |
The zeus hardware layout (§22) is the default in every tool, so they need no port arguments.
Optional:
hf auth loginwith a write token, to push datasets/models to the Hub.pip install -e ".[training]"or policy-specific extras (e.g.".[smolvla]",".[pi]") when you start training. Checkpyproject.tomlfor the exact extra names.- Updating LeRobot later:
cd ~/Documents/lerobot && git pull && pip install -e ".[damiao,feetech,core_scripts]". The clone is shallow; usegit fetch --unshallowif you need history.
20. Conventions: LeRobot vs ROS 2
Both stacks read the same raw motor angles, but present them differently.
Names
| LeRobot single arm | LeRobot bimanual | ROS 2 joint |
|---|---|---|
joint_1.pos … joint_7.pos | left_joint_1.pos …, right_joint_1.pos … | openarm_left_joint1 …, openarm_right_joint1 … |
gripper.pos | left_gripper.pos, right_gripper.pos | openarm_left_finger_joint1, openarm_right_finger_joint1 |
Units
| Quantity | LeRobot | ROS 2 | Conversion |
|---|---|---|---|
| Arm joints | degrees (motor angle) | radians (motor angle) | rad = deg × π/180. Same zero, same sign, no flips: LeRobot’s follower and OpenArmHW both pass the raw motor angle through. |
| Gripper | degrees of the gripper motor, 0 = closed, ≈ −60 … −65 = open | metres of finger travel, 0 = closed, 0.044 = open | m = 0.044 × deg / (−60) (ROS uses motor −1.0472 rad = −60° ↔ 0.044 m) |
Control parameters (MIT mode in both)
| LeRobot follower | ROS 2 OpenArmHW | |
|---|---|---|
| kp (j1…j7, gripper) | 240, 240, 240, 240, 24, 31, 25, 25 | 70, 70, 70, 60, 10, 10, 10, 5 |
| kd | 5, 5, 3, 5, 0.3, 0.3, 0.3, 0.3 | 2.75, 2.5, 2.0, 2.0, 0.7, 0.6, 0.5, 0.1 |
| Command rate | --fps of the loop (60 Hz teleop default, 30 Hz record default) | 750 Hz (bringup) / 100 Hz (MoveIt demo) |
| Interpolation | none: each action is a new setpoint | JTC: splines on zeus (upstream main: none, i.e. steps; §4) |
| Gravity compensation | none | none |
LeRobot is ~3.4× stiffer on the big joints, which tracks the leader tightly but hits harder on contact. Override with --robot.position_kp='[…8 values…]' and --robot.position_kd='[…]' (single arm), or --robot.left_arm_config.position_kp=… (bimanual).
Joint limits (degrees)
LeRobot clips every commanded position to per-side limits from config_openarm_follower.py. These are only used when side is set. With side unset, the defaults are ±5° (gripper −5…0) and the arm barely moves.
| Joint | LeRobot side=left | LeRobot side=right | ROS v1.0 URDF (both arms) |
|---|---|---|---|
| joint_1 | −75 … 75 | −75 … 75 | −80 … 200 |
| joint_2 | −90 … 9 | −9 … 90 | −100 … 100 |
| joint_3 | −85 … 85 | −85 … 85 | −90 … 90 |
| joint_4 | 0 … 135 | 0 … 135 | 0 … 140 |
| joint_5 | −85 … 85 | −85 … 85 | −90 … 90 |
| joint_6 | −40 … 40 | −40 … 40 | −45 … 45 |
| joint_7 | −80 … 80 | −80 … 80 | −90 … 90 |
| gripper | −65 … 0 | −65 … 0 | 0 … 0.044 m |
The asymmetric joint_2 limits show the arms are mirrored: shoulder abduction away from the body is negative on the left and positive on the right. LeRobot’s limits are slightly tighter than the URDF, a sensible margin from the mechanical stops. Always pass the correct side. Swapping sides with these limits lets joint_2 swing into the torso.
Converting a LeRobot action to a ROS trajectory point
import math
def lerobot_to_ros(action: dict, side: str) -> tuple[list[str], list[float]]:
names = [f"openarm_{side}_joint{i}" for i in range(1, 8)]
q = [math.radians(action[f"joint_{i}.pos"]) for i in range(1, 8)]
gripper_m = 0.044 * action["gripper.pos"] / -60.0
return names + [f"openarm_{side}_finger_joint1"], q + [gripper_m]This is what you need to replay a LeRobot dataset in RViz/MoveIt (see §25).
21. Calibration and the shared motor zero
Read this before running any LeRobot command on the follower arms.
What LeRobot’s calibration does (source: robots/openarm_follower/openarm_follower.py, motors/damiao/damiao.py):
| Device | When calibration runs | What it does to the hardware |
|---|---|---|
Follower (openarm_follower) | lerobot-calibrate, or automatically on connect() if no calibration file exists for that --robot.id (e.g. the first lerobot-teleoperate) | Torque off → asks you to put the arm hanging straight down, gripper closed → sends the Damiao set-zero command (0xFE) to all 8 motors. This rewrites the motors’ zero, which persists. It then saves a JSON file with dummy ranges. |
Damiao leader (openarm_leader) | Same as follower, and additionally on every connect() once calibrated (if self.is_calibrated: self.bus.set_zero_position()) | Re-zeroes the leader motors every time you start teleop. The leader must be hanging/gripper-closed at every start. |
OpenArm Mini (openarm_mini) | Every connect() asks “ENTER to use existing calibration or c to recalibrate” | Writes Feetech homing offsets to the Mini’s own servos (half-turn homing) and records the gripper open/closed range. Only the Mini is affected. |
Why this matters for the ROS stack: the follower’s motor zero is the same zero ROS 2 uses (§11). LeRobot’s zero is a hand-placed “hanging straight down” pose, and that matches the URDF zero (in RViz with all joints at 0 the arms hang straight down). So the convention is compatible, but:
- a hand-placed zero is less precise than Enactic’s mechanical-stop calibration (
openarm-can-zero-position-calibration); - every time someone runs LeRobot calibration on the follower, the ROS zero moves too;
- running it with the arm not properly hanging (e.g. resting on the table) corrupts the zero for both stacks, and the next ROS activation will “return to zero” into the wrong pose.
Recommended procedure (do the zero once, with Enactic’s tool, and stop LeRobot from re-zeroing the followers):
-
Zero each follower arm once with the mechanical-stop script (§11, Option A), or verify the existing zero with
openarm-can-cli -i canX monitorwhile the arm hangs straight down. -
Pre-create LeRobot’s follower calibration files so LeRobot never runs its own zeroing:
conda activate lerobot python ~/Documents/openarm_lerobot/make_follower_calibration.py my_follower # single arm, --robot.id=my_follower python ~/Documents/openarm_lerobot/make_follower_calibration.py my_bimanual_follower --bimanual # --robot.id=my_bimanual_followerFiles go to
~/.cache/huggingface/lerobot/calibration/robots/openarm_follower/<id>.json(bimanual:<id>_left.json,<id>_right.json). They only need to exist: the Damiao bus keeps nothing else from them, and clipping uses thesidelimits. -
Always use the same
--robot.idafterwards. A new id means no file, which means LeRobot re-zeroes the motors. -
If
lerobot-calibrateasks “Press ENTER to use provided calibration file … or type ‘c’”, press ENTER. Typingcre-zeroes the motors. -
Leaders are separate hardware, so let LeRobot calibrate them normally. For the Damiao leader, remember it re-zeroes at every start: start it hanging, gripper closed.
22. Bus and port layout
zeus (as cabled now)
CAN adapter: a single PEAK PCAN-USB Pro FD (peak_usb driver) with two channels. It always creates can0
for channel 1 and can1 for channel 2, so the names are stable. The cables are in the opposite order to the
ROS 2 defaults:
| Physical arm | Follower bus | Mini port (stable path) |
|---|---|---|
| Left | can0 (PCAN Ch1) | /dev/serial/by-id/usb-1a86_USB_Single_Serial_5876043720-if00 |
| Right | can1 (PCAN Ch2) | /dev/serial/by-id/usb-1a86_USB_Single_Serial_5876043592-if00 |
Consequences:
- LeRobot / zeus tools: left =
can0, right =can1(already the defaults in~/Documents/openarm_lerobot). - ROS 2: every real-hardware launch needs
right_can_interface:=can1 left_can_interface:=can0(the defaults are the reverse). - If someone swaps the PCAN cables back to the ROS order (right on Ch1), change
LEFT_CAN/RIGHT_CANat the top ofsetup_openarm_lerobot.shand theDEFAULTSinsafe_teleop.py/compare_mini_follower.py, and drop the ROS arguments. - Never use
/dev/ttyACM0/1for the Minis: they’re numbered in plug order and swap. A swapped Mini inverts joints 2 and 7 (§18), and LeRobot writes the calibration offsets of one Mini into the other. - The CAN interfaces lose their configuration whenever the PCAN adapter is unplugged (they come back down:
“Network is down”). Re-run
setup_openarm_lerobot.sh(orlerobot-setup-can --mode=setup --interfaces=can0,can1).
Physically label both PCAN cables and both Minis left/right.
How to re-identify which is which
- Follower bus: power only one arm and run
lerobot-setup-can --mode=test --interfaces=can0,can1; only that arm’s interface reports motors. Or move one follower joint by hand whilecompare_mini_follower.pyruns: the matching column changes. - Mini: unplug one Mini’s USB and see which entry disappears from
ls /dev/serial/by-id/.
23. Step-by-step teleoperation
Everything below runs on the host. No ROS launch may be running on the follower buses (§25). The base must be clamped or screwed to the table (OpenArm safety guide). On zeus the whole robot tipped over once when an arm snapped.
23.1 Setup and calibration: setup_openarm_lerobot.sh
bash ~/Documents/openarm_lerobot/setup_openarm_lerobot.sh # normal run (re-runnable)
bash ~/Documents/openarm_lerobot/setup_openarm_lerobot.sh --recalibrate-minis # force a Mini recalibrationIt activates the lerobot env itself and stops at the first problem:
- Environment:
dialoutgroup active, both Minis present, no teleop/record/ROS control running. - CAN: brings
can0/can1up in CAN-FD (1 / 5 Mbit/s) if needed (sudo), then requires 16/16 follower motors to answer. - Follower placeholders: creates
my_bimanual_follower_left/right.jsonif missing. It never re-zeroes motors (§21). - Minis:
check_mini_calibration.py; if the files don’t match the Minis (or you ask), a guidedlerobot-calibrate --teleop.type=bi_openarm_mini …(left Mini first, then right), thenfix_mini_gripper_range.py, then the check again. - Sanity table: one no-torque
compare_mini_follower.py --once.
23.2 Calibrating the Minis correctly
LeRobot’s Mini calibration sets each servo’s zero to wherever the Mini is when you press Enter (half-turn homing). A Mini zeroed in the wrong pose makes the follower go to the wrong pose. This is the most likely cause of the zeus snap incident.
At the zero-pose prompt, for each Mini:
- arm hanging straight down, gripper closed;
- wrist joints 5 and 7 visually matching the follower’s hanging wrist;
- joint 5 in the middle of its travel, not against a stop. On zeus one Mini was once zeroed 90° off, against its stop;
- hold it still, then press Enter.
The gripper “close / open” prompts of lerobot-calibrate are unreliable: a buffered Enter makes it record
min == max (Invalid calibration for motor 'gripper': min and max are equal). fix_mini_gripper_range.py
re-measures the range from a live readout. Open only as far as your finger opens it in use, not to the servo’s end,
because the left Mini’s gripper wraps past 0/4095 there. The setup script runs it automatically.
Afterwards, at every teleop start, LeRobot asks “ENTER to use existing calibration … or type ‘c’”: press ENTER.
Verify before teleop (no torque; everything hanging, grippers closed → all values ≈ 0):
python ~/Documents/openarm_lerobot/compare_mini_follower.pyThen move one Mini joint and the same follower joint by hand in the same direction: both numbers must change with the same sign and by roughly the same amount. Hanging, the followers read within a few degrees of 0 (their motor zero is fine). A Mini reading far from 0 while hanging means that Mini needs recalibrating.
23.3 Teleop: safe_teleop.py (use this, not lerobot-teleoperate)
python ~/Documents/openarm_lerobot/safe_teleop.py --arms right --max-vel 30 # first sessions: one arm, slow
python ~/Documents/openarm_lerobot/safe_teleop.py # both armsWhy not stock lerobot-teleoperate: on connect it enables the followers at full stiffness (kp 240) and the first
command sends them straight to the Mini’s reading, with no check that it’s plausible. A motor that doesn’t reply is
silently treated as being at 0°. max_relative_target only limits each step relative to the current position
(≈ 150°/s at 30 fps), so a wrong target is still reached, just slightly slower. It also makes low-stiffness joints
“stick” (the wrist stalls when its steady-state error exceeds the clamp).
What safe_teleop.py does instead:
| Guard | Behaviour |
|---|---|
| Pre-flight | Refuses to start unless the Mini files match the Minis and all 16 follower motors answer. |
| Alignment gate | Follower torque stays OFF and a live table shows Mini vs follower per joint. Torque only comes on after every joint has been within 10° (gripper 15°) for 1 s. You move the Mini to the arm, never the reverse. |
| Soft start | Stiffness ramps 0 → 100 % over 2 s. |
| Speed limit | The command changes by at most --max-vel deg/s per joint (default 60, gripper ×2). Limited on the command, so joints don’t stick. |
| Hold on fault | Joint > 25° from its command for 0.5 s, a motor not answering, or a Mini reading jumping > 45° in one frame (persisting): that arm holds its current position (doesn’t go limp) and resumes once you re-align the Mini. |
| Exit | Ctrl+C → arms hold. Support/lower them, then Enter → torque off. A second Ctrl+C = torque off immediately (arms fall). |
Options: --arms left|right|both, --fps 30, --align-tol 10, --ramp-s 2, --max-vel 60, --track-err 25,
--follower-id my_bimanual_follower, --mini-id my_mini.
The control logic was checked in a simulation with fake arms (gate stays closed when misaligned, kp starts at 0, ≤ 2° per
cycle at 60°/s, blocked joint and bogus 90° Mini jump both → HOLD), not yet on the real robot. Do the first run with
one arm, low --max-vel, someone at the power switch.
23.4 Stock lerobot-teleoperate (for reference, not recommended)
If you do use it, use the zeus mapping, keep max_relative_target, disable Rerun (it blocked the loop for 12.8 s once,
causing a freeze and then a catch-up jerk), and align the Minis with compare_mini_follower.py first:
lerobot-teleoperate \
--robot.type=bi_openarm_follower \
--robot.left_arm_config.port=can0 --robot.left_arm_config.side=left --robot.left_arm_config.max_relative_target=5.0 \
--robot.right_arm_config.port=can1 --robot.right_arm_config.side=right --robot.right_arm_config.max_relative_target=5.0 \
--robot.id=my_bimanual_follower \
--teleop.type=bi_openarm_mini \
--teleop.left_arm_config.port=/dev/serial/by-id/usb-1a86_USB_Single_Serial_5876043720-if00 --teleop.left_arm_config.side=left \
--teleop.right_arm_config.port=/dev/serial/by-id/usb-1a86_USB_Single_Serial_5876043592-if00 --teleop.right_arm_config.side=right \
--teleop.id=my_mini \
--fps=30 --display_data=falseStop it with Ctrl+C (torque off, so the arms fall; lower the Minis first), never Ctrl+Z. Ctrl+Z only suspends it:
it keeps the buses open and resumes commanding on fg. If it happens, kill -9 <pid>.
23.5 Cameras
lerobot-find-cameras opencv # lists indices/paths and saves test frames to outputs/
lerobot-find-cameras realsense # needs: pip install -e ".[intelrealsense]"Add cameras to the robot (they become part of the observation and are recorded as videos):
--robot.cameras='{top: {type: opencv, index_or_path: /dev/video0, width: 640, height: 480, fps: 30},
left_wrist: {type: opencv, index_or_path: /dev/video2, width: 640, height: 480, fps: 30}}'For bimanual setups, --robot.cameras is opened by the left arm but keys stay unprefixed. Per-arm cameras can go in
--robot.left_arm_config.cameras / --robot.right_arm_config.cameras. Prefer /dev/v4l/by-id/… paths over indices.
23.6 Python API
See ~/Documents/openarm_lerobot/safe_teleop.py for a complete, guarded example built on LeRobot’s classes (OpenArmMini,
OpenArmFollower, robot.send_action(..., custom_kp=...)). The key points if you write your own loop:
OpenArmFollower.connect()enables torque immediately; to control the start, userobot.bus.connect(handshake=False)(the handshake sends ENABLE) and callrobot.bus.enable_torque()yourself after checking alignment.bus.sync_read_all_states()returns the last known state for motors that didn’t reply (initially 0).OpenArmFollowerConfig(use_velocity_and_torque=True)addsjoint_i.vel(deg/s) andjoint_i.torque(Nm) to observations.
24. Recording, training and running policies
24.1 Record a dataset
lerobot-record \
--robot.type=bi_openarm_follower \
--robot.left_arm_config.port=can0 --robot.left_arm_config.side=left --robot.left_arm_config.max_relative_target=5.0 \
--robot.right_arm_config.port=can1 --robot.right_arm_config.side=right --robot.right_arm_config.max_relative_target=5.0 \
--robot.id=my_bimanual_follower \
--robot.cameras='{top: {type: opencv, index_or_path: /dev/video0, width: 640, height: 480, fps: 30}}' \
--teleop.type=bi_openarm_mini \
--teleop.left_arm_config.port=/dev/serial/by-id/usb-1a86_USB_Single_Serial_5876043720-if00 --teleop.left_arm_config.side=left \
--teleop.right_arm_config.port=/dev/serial/by-id/usb-1a86_USB_Single_Serial_5876043592-if00 --teleop.right_arm_config.side=right \
--teleop.id=my_mini \
--dataset.repo_id=<hf_user>/openarm_pick_cube \
--dataset.single_task="Pick up the red cube and place it in the bowl" \
--dataset.num_episodes=20 \
--dataset.episode_time_s=60 --dataset.reset_time_s=20 \
--dataset.fps=30 \
--dataset.no_stamp=true \
--dataset.push_to_hub=false \
--display_data=falseDataset names (new in V3)
- Without
--dataset.no_stamp=true,lerobot-recordappends a date-time tag to the name:<hf_user>/openarm_pick_cubebecomes<hf_user>/openarm_pick_cube_20261010_143000. Every later command (lerobot-train,lerobot-dataset-viz,--resume) then needs that full name.ls ~/.cache/huggingface/lerobot/<hf_user>/shows the real names. Withno_stamp, the name stays as typed. To add episodes to it later, add--resume=true.- Names starting with
eval_are refused bylerobot-record(“reserved for policy evaluation”). Policy evaluation is done withlerobot-rolloutand needs names starting withrollout_(§37).
Recording uses stock LeRobot, so it has the start-up snap behaviour described in §23.3 (
safe_teleop.pydoesn’t record). Before everylerobot-record: runcompare_mini_follower.py, put the Minis in the followers’ pose (all diffs ≈ 0), keepmax_relative_target, and keep--display_data=false. A recording mode forsafe_teleop.py(gate first, then hand over to LeRobot’s dataset writer) is the obvious next improvement.
The docs page you pasted uses short flags (--repo-id, --num-episodes, --fps). The v0.6.2 CLI expects the --dataset.* names above.
- Data goes to
~/.cache/huggingface/lerobot/<repo_id>/(override with--dataset.root=…): parquet for joint data, mp4 per camera. - Keyboard during recording (needs an X session;
pynput): → ends the episode early, ← re-records it, Esc stops. --resume=trueappends to an existing dataset.--dataset.push_to_hub=trueuploads (needshf auth login).- Recorded observation = follower joint positions in degrees (+ images). Action = the (clipped) command sent to the follower, i.e. the leader’s pose.
24.2 Inspect / replay
lerobot-dataset-viz --repo-id <hf_user>/openarm_pick_cube --episode-index 0 # Rerun viewer
lerobot-replay --robot.type=bi_openarm_follower … --dataset.repo_id=<hf_user>/openarm_pick_cube --dataset.episode=0lerobot-replay drives the real follower through the recorded actions. Same safety rules as teleop: start pose matched, max_relative_target set.
24.3 Train a policy (on the RTX 5050 or a bigger machine)
lerobot-train \
--dataset.repo_id=<hf_user>/openarm_pick_cube \
--policy.type=act \
--output_dir=outputs/train/act_openarm_pick_cube \
--job_name=act_openarm_pick_cube \
--policy.device=cuda \
--policy.push_to_hub=falseACT is a good first policy for ~20–50 demos. Diffusion, SmolVLA and Pi0 need their extras and much more GPU memory. An 8 GB laptop GPU is tight for anything beyond ACT/Diffusion with small batch sizes.
The trained model ends up in outputs/train/act_openarm_pick_cube/checkpoints/last/pretrained_model/ (relative to
where you ran lerobot-train). That directory is what --policy.path points to in Part III (§31.2). It contains the
weights, the policy config (which inputs it expects) and the normalisation statistics of the training dataset.
--policy.push_to_hub=true --policy.repo_id=<hf_user>/act_openarm_pick_cube uploads it so you can use --policy.path=<hf_user>/act_openarm_pick_cube instead.
24.4 Which policy to train
| Policy | --policy.type | Extra to install | Inference on zeus (RTX 5050, 8 GB) | Notes |
|---|---|---|---|---|
| ACT | act | none | ✅ fast, --inference.type=sync | Best first choice: 20–50 demos of one task |
| Diffusion | diffusion | pip install -e ".[diffusion]" | ✅ slower per call | Smoother, multimodal behaviour; needs more demos |
| SmolVLA | smolvla | pip install -e ".[smolvla]" | ⚠ probably fits; use RTC | Language-conditioned (--task matters); fine-tune from lerobot/smolvla_base |
| Pi0 / Pi0.5 | pi0 / pi05 | pip install -e ".[pi]" | ❌ too big for 8 GB, use a bigger GPU + async inference (§40) | Strongest generalist VLAs |
Install extras inside the lerobot env, in ~/Documents/lerobot. The training command (§24.3) is the same apart from
--policy.type (VLAs: --policy.path=lerobot/smolvla_base to fine-tune instead of --policy.type).
24.5 Running the trained policy
Running a policy on the arms has its own part, written for a first-time user: Part III.
| You want to… | Go to |
|---|---|
| Understand what running a policy means, and the vocabulary | §30 |
| Find the trained model, check what it expects, find the training start pose | §31 |
Understand every flag of lerobot-rollout (+ a ready-made script) | §32 |
| Know what happens, and when the arms move | §33 |
| Run it the first time, safely | §34, §35 |
| Make it smoother or more reactive | §36 |
| Measure the success rate | §37 |
| Correct it with the Minis (DAgger) | §38 |
| Fix an error | §41 |
25. Switching between LeRobot and ROS 2
Never run both stacks on the same follower bus. There is no lock between them. Both would send MIT commands to motors 1–8 and the arm would follow whichever frame arrived last.
| Going from → to | Do this |
|---|---|
| ROS 2 → LeRobot | 1. Move the arms to the hanging pose (home / all zeros) with ROS. 2. Support the arms. 3. Ctrl+C the ROS launch (or set_hardware_component_state … inactive). 4. Check ps aux | grep ros2_control_node in the container shows nothing. 5. Start LeRobot. |
| LeRobot → ROS 2 | 1. Lower the leader so the follower hangs. 2. Ctrl+C LeRobot (torque off → arms limp; support them). 3. Launch ROS with use_fake_hardware:=false right_can_interface:=can1 left_can_interface:=can0 (zeus cabling, §22). On activation the arms go to zero in ~2 s; from hanging, that’s a tiny move. |
A quick “is the bus free?” check from the host:
candump -n 20 -T 500 can0 # no output within 0.5 s means no one is commanding the left arm (zeus: can0 = left)Ways to combine them (not built yet)
- Replay LeRobot demos in RViz/MoveIt: load a dataset (
LeRobotDataset) in thelerobotenv, convert each frame withlerobot_to_ros()(§20), and send it as aJointTrajectoryto the ROS mock’s/left|right_joint_trajectory_controller/joint_trajectorytopic. The two Python envs differ (conda vs ROS Jazzy), so use a file in between (e.g. export to CSV/NumPy) or runrclpyfrom the ROS container on an exported file. - Live view of LeRobot teleop in RViz: a small bridge that publishes the follower observation as
sensor_msgs/JointState(radians/metres) to arobot_state_publisherrunning without ros2_control. Read-only, so it doesn’t conflict on the bus as long as the bridge gets its data from LeRobot, not from CAN. - LeRobot policy through ROS: write a custom LeRobot
Robotclass whosesend_action()publishes to the ROS trajectory controllers andget_observation()subscribes to/joint_states. Then ROS owns the bus, and LeRobot benefits from ROS-side safety (limits, MoveIt collision checks). This is the cleanest long-term integration, but it’s real work.
26. Teleop safety notes
Everything in §13 still applies. Specific to LeRobot, most of it learned the hard way on zeus:
- Clamp the base. An arm snap tipped the whole robot over once.
- Start-up snap: stock teleop enables at full stiffness and goes straight to the Mini’s reading. Use
safe_teleop.py(§23.3); with stock tools, align first withcompare_mini_follower.py. - Mini zero = the pose at Enter. Calibrate hanging, gripper closed, wrists matching the follower, joint 5 mid-travel (§23.2).
- Swapped Minis or buses: wrong arm and inverted joints 2/7 (§18, §22). Use the by-id paths and the zeus mapping; never
/dev/ttyACM*. - Mismatched Mini calibration files are written into the Minis on connect.
check_mini_calibration.py(run by the setup script and bysafe_teleop.py) catches this. - Silent zeros: a follower motor that doesn’t answer reads as 0° in LeRobot.
safe_teleop.pychecks replies; stock tools don’t. sidemissing → ±5° limits (arm seems stuck);sidewrong → mirroredjoint_2limits allow motion into the torso.- Unintended re-zeroing of the followers: a new
--robot.id, a deleted cache directory, orcat the follower calibration prompt (§21). - Torque off = arms fall: stock Ctrl+C,
openarm-can-cli disable, anddiagnose(enables, then disables at the end).safe_teleop.pyholds until you press Enter. - Ctrl+Z is not stop. It suspends the process with the buses still open; it resumes commanding on
fg. Use Ctrl+C; clean up withkill -9 <pid>. - Rerun can stall the loop (12.8 s freeze, then a catch-up jerk on zeus). Use
--display_data=falsefor teleop/recording, andpkill -f "rerun --port=9876"for leftover viewers. - Stiff gains (kp 240), no force feedback: the operator can’t feel contacts. Start with light, open-space tasks.
- Two stacks on one bus (§25).
- Policies (
lerobot-rollout) start unramped: start every run from the training start pose, keepmax_relative_target, press Ctrl+C once (the arms return to the start pose, then torque off). See §33–§35 and §42.
27. LeRobot troubleshooting
| Symptom | Cause / fix |
|---|---|
lerobot-…: command not found | conda activate lerobot. |
Permission denied: '/dev/ttyACM0' | Session not in dialout yet: log out/in (the group is already added). |
lerobot-find-port: “No difference was found” | You didn’t unplug the Mini’s USB at the prompt (or unplugged the PCAN adapter instead, which takes the CAN interfaces down). Not needed on zeus: use the by-id paths (§22). |
Failed to connect to CAN bus: … Network is down | The PCAN adapter was replugged; the interfaces came back unconfigured. Re-run setup_openarm_lerobot.sh. |
| Motors “No response” / packet drops on all motors | Motor power off, wrong bitrate/FD mode, cabling, or ROS still owns the bus. openarm-can-cli -i canX diagnose shows per-motor status and link stats. |
| Mini: “motor check failed … Missing motor IDs 1–8” | The Mini’s USB board answers but its servos don’t: Mini servo power off/unplugged. |
Invalid calibration for motor 'gripper': min and max are equal | lerobot-calibrate recorded the gripper open/closed instantly (buffered Enter). Run fix_mini_gripper_range.py; don’t type c at the teleop prompt afterwards. |
| Follower joints 2 and 7 inverted | Mini read with the other side’s flips: Mini ports or CAN buses swapped (§18, §22). |
| One joint inverted on one arm only (e.g. left joint 4) | Not explained by a swap (both sides flip it). Check that Mini’s calibration first; if it persists, the servo is mounted mirrored compared with LeRobot’s flip table, so a per-joint flip is needed. |
| A Mini joint reads far from 0 while hanging (e.g. joint 5/7 by 10–90°) | That Mini was zeroed in a different pose (wrist twisted / joint 5 against its stop). Recalibrate it (§23.2). |
| Constant “Relative goal position magnitude had to be clamped” on one joint, and it doesn’t move | max_relative_target + low stiffness: the joint stalls 5° short. Usually a Mini offset on that joint; fix the calibration. safe_teleop.py limits the command instead, so this doesn’t happen. |
| Teleop freezes for seconds, then the arm jerks | Rerun blocked the loop (Sender has been blocked …). --display_data=false; kill leftover rerun processes. |
| ”Control loop is running slower than the target FPS” | --fps=30, no Rerun, fewer/smaller cameras. |
A teleop process exists but nothing happens (ps STAT T) | Suspended with Ctrl+Z. kill -9 <pid>; don’t fg. |
| Arm barely moves (≈ 5°) | side not set. |
| LeRobot asks to put the follower “hanging straight down” | No follower calibration file for that --robot.id. Ctrl+C and create it with make_follower_calibration.py (§21) unless you really want to re-zero the follower motors. Rewriting existing placeholders changes nothing (they’re identical by design). |
| Record keyboard shortcuts do nothing | pynput needs an X session; on Wayland, run from an X11 session or use the terminal prompts. |
torch.cuda.is_available() false | Run from the lerobot env; driver ≥ 580 for CUDA 13 wheels (zeus has 595). |
28. Enactic’s own openarm_teleop
~/Documents/openarm_teleop is a clone of enactic/openarm_teleop (commit eb2d493). It’s a separate C++ alternative to LeRobot teleop:
openarm_teleop (Enactic) | LeRobot | |
|---|---|---|
| Language | C++ (openarm_can + its own dynamics via the URDF) | Python |
| Leader hardware | Damiao leader arms (CAN) | OpenArm Mini (Feetech) or Damiao leader |
| Modes | Unilateral (leader → follower) and bilateral (force feedback to the leader); gravity compensation mode | Unilateral only |
| Gravity / friction compensation | Yes (config/*.yaml: Kp/Kd + tanh friction model) | No |
| Dataset recording / learning | No | Yes |
Default CAN layout (script/launch_*.sh) | right: leader can0, follower can2; left: leader can1, follower can3 | whatever you pass |
It doesn’t support the OpenArm Mini. Its scripts also expect ~/openarm_ros2_ws/src/openarm_description/urdf/robot/v10.urdf.xacro, a path from the old openarm_description layout that no longer exists in the current version (assets/robot/openarm_v1.0/urdf/openarm_v10.urdf.xacro). Using it requires adapting WS_DIR/XACRO_PATH in script/launch_*.sh and building the binaries. There’s an untracked Dockerfile in that directory, which seems to be your own.
Use it if you have Damiao leaders and want force feedback or gravity compensation. Use LeRobot for data collection and learning.
29. Quick reference card
# ---------- host, once per login ----------
xhost +SI:localuser:root
docker start openarm
docker exec -it openarm bash
# ---------- in container ----------
source /opt/ros/jazzy/setup.bash && source /root/ros2_ws/install/setup.bash
# sim
ros2 launch openarm_bringup openarm.bimanual.launch.py arm_type:=v10 use_fake_hardware:=true
ros2 launch openarm_bimanual_moveit_config demo.launch.py arm_type:=v10
# real (after CAN up + zero verified + area clear + E-stop in hand)
openarm-can-cli -i can0 can_configure && openarm-can-cli -i can1 can_configure # host
openarm-can-cli -i can0 discover && openarm-can-cli -i can1 discover
ros2 launch openarm_bringup openarm.bimanual.launch.py arm_type:=v10 use_fake_hardware:=false right_can_interface:=can1 left_can_interface:=can0
ros2 launch openarm_bimanual_moveit_config demo.launch.py arm_type:=v10 use_fake_hardware:=false right_can_interface:=can1 left_can_interface:=can0
# command
ros2 action send_goal /left_joint_trajectory_controller/follow_joint_trajectory control_msgs/action/FollowJointTrajectory \
'{trajectory: {joint_names: [openarm_left_joint1, openarm_left_joint2, openarm_left_joint3, openarm_left_joint4, openarm_left_joint5, openarm_left_joint6, openarm_left_joint7],
points: [{positions: [0, 0, 0, 0.3, 0, 0, 0], time_from_start: {sec: 5}}]}}'
ros2 action send_goal /left_gripper_controller/follow_joint_trajectory control_msgs/action/FollowJointTrajectory \
'{trajectory: {joint_names: [openarm_left_finger_joint1], points: [{positions: [0.022], time_from_start: {sec: 2}}]}}'
# observe
ros2 control list_controllers
ros2 topic echo /joint_states --field position
# ---------- LeRobot (host; ROS must NOT be running on the follower buses; base clamped) ----------
bash ~/Documents/openarm_lerobot/setup_openarm_lerobot.sh # config + calibration checks (re-runnable)
python ~/Documents/openarm_lerobot/compare_mini_follower.py # no-torque check: hanging -> all ~0
python ~/Documents/openarm_lerobot/safe_teleop.py --arms right --max-vel 30 # guarded teleop, one arm, slow
python ~/Documents/openarm_lerobot/safe_teleop.py # both arms
# Ctrl+C = hold, Enter = torque off. Never Ctrl+Z.
# run a trained policy (arms in the training start pose first; Ctrl+C ONCE to stop). Full walkthrough: Part III (§30-§43)
lerobot-rollout --strategy.type=base --policy.path=outputs/train/<job>/checkpoints/last/pretrained_model \
--robot.type=bi_openarm_follower --robot.id=my_bimanual_follower \
--robot.left_arm_config.port=can0 --robot.left_arm_config.side=left --robot.left_arm_config.max_relative_target=10.0 \
--robot.right_arm_config.port=can1 --robot.right_arm_config.side=right --robot.right_arm_config.max_relative_target=10.0 \
--robot.cameras='<same as recording>' --task="<task>" --duration=30 --device=cuda| Thing | Left | Right |
|---|---|---|
| CAN bus (zeus cabling, ROS default is the reverse) | can0 (PCAN Ch1) | can1 (PCAN Ch2) |
| Arm action | /left_joint_trajectory_controller/follow_joint_trajectory | /right_joint_trajectory_controller/follow_joint_trajectory |
| Gripper action | /left_gripper_controller/follow_joint_trajectory | /right_gripper_controller/follow_joint_trajectory |
| Arm joints | openarm_left_joint1…7 | openarm_right_joint1…7 |
| Gripper joint (m, 0 closed – 0.044 open) | openarm_left_finger_joint1 | openarm_right_finger_joint1 |
| MoveIt groups | left_arm, left_gripper | right_arm, right_gripper |
| HW component | openarm_left_hardware_interface | openarm_right_hardware_interface |
| LeRobot keys (bimanual) | left_joint_1.pos … left_gripper.pos (deg) | right_joint_1.pos … right_gripper.pos (deg) |
| LeRobot follower calibration file | …/robots/openarm_follower/<id>_left.json | …/robots/openarm_follower/<id>_right.json |
| OpenArm Mini (zeus) | …Serial_5876043720-if00 | …Serial_5876043592-if00 |
| Mini calibration file | …/teleoperators/openarm_mini/my_mini_left.json | …/my_mini_right.json |
Part III: Running a trained policy with LeRobot, step by step
Who this part is for
You have never run a learned policy on a robot before. You have (or will soon have) a policy trained with
lerobot-trainon demonstrations recorded with the Minis (§24), and you want to let it drive the two OpenArm followers on zeus.Part III starts from zero: what a policy is, what files you need, every flag of the command, what happens second by second, a first run broken into small safe steps, how to tune it, how to measure it, and what to do when it misbehaves. Read §30 to §34 once before the first run. After that, §34 and the cheat-sheet in §43 are enough day to day.
Status (2026-10-09)
None of Part III has been run on the zeus robot yet. No policy has been trained so far. Everything below was checked against the LeRobot source installed on zeus (
~/Documents/lerobot, commitb863b42, v0.6.2): flag names, defaults, error messages and log lines. Behaviour that can only be confirmed on the hardware is marked ⚠ verify. When something in this part turns out to be different on the real robot, write it in the day’s log and correct it in the next guide version.
Everything in Part III runs on the host, in the lerobot conda env, with no ROS launch running on the follower buses (§25). The safety rules of §26 all still apply.
30. What running a policy means
30.1 The idea in one paragraph
A policy is a neural network that has learned, from your demonstrations, “when the robot looks like this, move like that”. Running it (“rollout”, “inference” or “deployment”; all three words mean the same here) means a program repeats a short loop about 30 times per second: read the arms’ joint angles and the camera images, give them to the network, take the joint targets it answers with, and send those targets to the motors. Nobody holds the Minis. The network is the operator. In LeRobot this program is the command lerobot-rollout.
It is exactly the teleoperation loop of §23 with the Minis replaced by the network:
| Teleoperation (§23) | Policy rollout (Part III) | |
|---|---|---|
| Who decides the next joint targets | You, through the Minis | The trained network |
| Where the targets come from | teleop.get_action() (Mini angles) | policy.select_action(observation) |
| What the follower does with them | Same: clipped to the side limits and max_relative_target, then sent as MIT commands at kp 240 | Same |
| What it needs to see | Nothing (you look at the scene yourself) | Joint angles and camera images, exactly as during recording |
30.2 Words you will meet
| Word | Meaning on zeus |
|---|---|
| Policy | The trained network plus its settings. For a first policy this is ACT ([[#24.4 Which policy to train |
| Checkpoint | A snapshot of the policy saved during training, e.g. after 20 000 and 40 000 training steps. Each one is a folder. The newest is reachable through the last link. |
pretrained_model folder | The folder inside a checkpoint that holds everything needed to run the policy. This is what --policy.path points to ([[#31.2 Find and check the trained model |
| Observation | What the policy sees at one instant: 16 joint angles in degrees (7 joints + gripper, per arm), named left_joint_1.pos … right_gripper.pos, plus one image per camera. |
| Action | What the policy answers: 16 joint targets in degrees, same names and order. They are absolute positions (“go to 37°”), not changes (“move 2°”). |
| Action chunk | ACT doesn’t predict one action, it predicts the next 100 at once (chunk_size). The loop then plays them back one per tick. |
n_action_steps | How many actions of each chunk are actually played before the network is asked again. Default 100 for ACT, i.e. the network looks at the scene once every 100 / 30 ≈ 3.3 s and moves “blind” in between. See [[#36.1 How often the policy looks: n_action_steps |
| fps / tick | One pass of the loop is a tick. --fps=30 means 30 ticks per second, one action per tick. It must match the fps of the recorded dataset. |
| Normalisation stats | The mean and spread of every joint and camera channel in the training data. The network works on normalised numbers, so the stats are saved with the checkpoint and applied automatically. You never touch them, but they’re why a policy trained on one dataset can’t simply be reused on a differently set-up robot. |
| Task | A sentence describing what to do, e.g. “Pick up the red cube and place it in the bowl”. ACT and Diffusion ignore it (they only know the one task they were trained on). Language models (SmolVLA, Pi0) use it, so it must match the training text. |
| Start pose / initial position | The joint angles the arms have when lerobot-rollout connects. LeRobot records them, and when the run ends it moves the arms back there over 3 s before switching torque off. |
| Strategy | What the run does around the policy: base (just run it), episodic (run repeated evaluation episodes and record them), dagger (let you take over with the Minis and record corrections), highlight, sentry. |
| Inference engine | How the policy is called: sync (inline, the default, right for ACT) or rtc (for slow VLA models). |
| Device | Where the network runs: cuda = the RTX 5050 GPU (use this), cpu = processor (slow). |
| Episode | One attempt at the task, from a reset scene to the end. Datasets are made of episodes. |
30.3 The loop, drawn
lerobot-rollout
├─ 1. load the policy (pretrained_model/ → GPU) no hardware touched yet
├─ 2. connect the robot: open can0/can1, cameras, TORQUE ON
├─ 3. remember the current joint angles = "initial position"
│
├─ 4. loop, 30 times per second, until --duration or Ctrl+C:
│ observe ← 16 joint angles (deg) + camera images
│ policy → if its action queue is empty: run the network once,
│ get a chunk of 100 future actions, queue n_action_steps of them
│ → pop the next action (16 joint targets, deg)
│ safety → clip each target to the `side` joint limits (§20)
│ → clip each step to ±max_relative_target from where the joint is now
│ send → MIT command per motor (kp 240 on the big joints)
│
├─ 5. move back to the initial position in a straight line, over 3 s
└─ 6. TORQUE OFF (the arms go limp) and disconnect
30.4 Why the setup has to match the training
The network only knows the world it saw in the demonstrations. It has no idea what a “cube” is. It learned which pixels and joint angles came before which motions. So anything that makes today’s observation look different from the recorded ones makes the policy behave worse, sometimes wildly:
| Must be identical to the recording | Why | What happens if not |
|---|---|---|
Robot type (bi_openarm_follower), cabling (can0 = left, can1 = right), side | Joint i of the observation must be the same physical joint | Wrong arm or mirrored joints receive the commands |
Camera names (top, …) | The policy looks up each image by name | Start-up error (Visual feature mismatch) |
| Camera resolution and fps | The network’s input size is fixed | Start-up error or garbage |
| Camera position and angle | Pixels must mean the same thing | Policy “does nothing sensible” (most common cause of a policy that worked yesterday failing today) |
| Which physical camera has which name | top must still be the top camera | Silently wrong behaviour |
Loop --fps | Each action was learned as “1/30 s later” | Motions too fast or too slow |
use_velocity_and_torque (default off) | Changes the size of the observation | Start-up error |
| Task text (VLAs only) | Language-conditioned policies read it | Wrong or no behaviour |
| Lighting, background, objects | Same pixels | Degrades gradually; train with some variety to make it robust |
31. What you need before the first run
31.1 Inputs checklist
| You need | Where it comes from | Check |
|---|---|---|
| A trained policy | lerobot-train (§24.3) or a Hub model id | §31.2 |
| The dataset it was trained on (locally) | lerobot-record (§24.1) | Needed only to look up the start pose and camera settings (§31.3) |
The exact --robot.cameras string used when recording | Your recording command / shell history (history | grep lerobot-record) | Copy it, don’t retype it |
| The same physical scene | Table, cameras, lighting, objects | Compare against a recorded frame (step 5 of §35) |
| Working robot setup | setup_openarm_lerobot.sh (§23.1) | 16/16 motors answer |
| GPU | RTX 5050 in the lerobot env | python -c "import torch; print(torch.cuda.is_available())" prints True |
| A second person (first runs) | Hand on the motor power switch |
31.2 Find and check the trained model
lerobot-train writes into the --output_dir you gave it, relative to the folder you ran it from. With the command of §24.3 run from ~/Documents/lerobot:
~/Documents/lerobot/outputs/train/act_openarm_pick_cube/
├── checkpoints/
│ ├── 020000/ one folder per saved checkpoint (every --save_freq steps, default 20 000)
│ │ ├── pretrained_model/ ← what --policy.path points to
│ │ └── training_state/ optimiser state, only for resuming training
│ ├── 040000/
│ │ └── …
│ └── last -> 100000 link to the newest checkpoint
└── … training logs
and inside every pretrained_model/:
| File | What it is |
|---|---|
config.json | The policy’s settings: its type (act), the inputs it expects (input_features), what it outputs (output_features), chunk_size, n_action_steps, … |
model.safetensors | The trained weights (ACT: a few hundred MB) |
train_config.json | The full training command, including which dataset was used |
policy_preprocessor.json, policy_postprocessor.json (+ .safetensors files) | The normalisation steps and the stats of the training data |
Point
--policy.pathatpretrained_model, not at the checkpoint folderRight:
…/checkpoints/last/pretrained_model. Wrong:…/checkpoints/last. Use an absolute path ($HOME/Documents/lerobot/outputs/…): a relative one only works from the folder you trained in, and a path that doesn’t exist locally is treated as a Hugging Face Hub repo name, which fails with a confusing “repository not found” style error.
Check the folder and print what the policy expects:
conda activate lerobot
P=$HOME/Documents/lerobot/outputs/train/act_openarm_pick_cube/checkpoints/last/pretrained_model
ls -l "$P"
python - "$P" <<'EOF'
import json, sys, pathlib
p = pathlib.Path(sys.argv[1])
c = json.loads((p / "config.json").read_text())
print("policy type :", c.get("type"))
print("device :", c.get("device"))
for k, v in c["input_features"].items():
print("input ", k, v["type"], v["shape"])
for k, v in c["output_features"].items():
print("output ", k, v["type"], v["shape"])
for k in ("chunk_size", "n_action_steps", "n_obs_steps", "temporal_ensemble_coeff"):
if k in c:
print(f"{k:<24}", c[k])
t = json.loads((p / "train_config.json").read_text())
print("trained on :", t["dataset"]["repo_id"])
EOFWhat you should see for a bimanual ACT trained with one camera called top (illustrative):
policy type : act
device : cuda
input observation.state STATE [16]
input observation.images.top VISUAL [3, 480, 640]
output action ACTION [16]
chunk_size 100
n_action_steps 100
n_obs_steps 1
temporal_ensemble_coeff None
trained on : <hf_user>/openarm_pick_cube
How to read it:
observation.state [16]andaction [16]: 8 values per arm, so it’s a bimanual policy and needs--robot.type=bi_openarm_follower.[8]would be a single-arm policy (§39).- Every
observation.images.<name>line is a camera the robot must provide under exactly that<name>, at that resolution ([channels, height, width]). trained ontells you which dataset to look at in §31.3.
A policy uploaded to the Hub (--policy.push_to_hub=true during training) is used with --policy.path=<hf_user>/act_openarm_pick_cube instead of a folder. It’s downloaded on first use.
31.3 Find out how the training data started
Two things come from the training dataset: the start pose and the camera settings. Datasets live in ~/.cache/huggingface/lerobot/<repo_id>/ (the repo_id printed above).
The fps and cameras the data was recorded with:
D=$HOME/.cache/huggingface/lerobot/<hf_user>/openarm_pick_cube
python - "$D" <<'EOF'
import json, sys
info = json.load(open(f"{sys.argv[1]}/meta/info.json"))
print("fps:", info["fps"], "| episodes:", info["total_episodes"], "| robot:", info.get("robot_type"))
for k, v in info["features"].items():
if v["dtype"] in ("video", "image"):
print("camera", k, v["shape"])
EOFThe --fps of the rollout must equal that fps, and every camera listed must be given in --robot.cameras with the same name and resolution.
The start pose. The policy has only ever seen the arms begin the task from wherever your recordings began. The safest place to start a rollout is therefore the average first frame of the training episodes. This prints it, per joint:
python - "$D" <<'EOF'
import glob, json, sys
import numpy as np, pandas as pd
root = sys.argv[1]
names = json.load(open(f"{root}/meta/info.json"))["features"]["observation.state"]["names"]
files = sorted(glob.glob(f"{root}/data/*/*.parquet"))
df = pd.concat(pd.read_parquet(f, columns=["episode_index", "frame_index", "observation.state"]) for f in files)
first = np.stack(df[df.frame_index == 0].sort_values("episode_index")["observation.state"].to_numpy())
print(f"{len(first)} episodes. First-frame joint angles (degrees):")
print(f"{'joint':<22}{'mean':>8}{'min':>8}{'max':>8}")
for i, n in enumerate(names):
print(f"{n:<22}{first[:, i].mean():8.1f}{first[:, i].min():8.1f}{first[:, i].max():8.1f}")
EOFIf your recordings started with the arms hanging and the grippers closed, every mean is within a few degrees of 0. A wide min–max spread on a joint means the episodes started in different places. That’s fine, it makes the policy more tolerant, but stay inside that range when you position the arms.
Write the start pose down. It’s the target of step 6 in §35.
32. The lerobot-rollout command, flag by flag
32.1 The complete command for zeus
This is the full command for a bimanual policy with one camera, with nothing left to defaults that matters for safety. Every line is explained in §32.2.
lerobot-rollout \
--strategy.type=base \
--policy.path=$HOME/Documents/lerobot/outputs/train/act_openarm_pick_cube/checkpoints/last/pretrained_model \
--robot.type=bi_openarm_follower \
--robot.id=my_bimanual_follower \
--robot.left_arm_config.port=can0 \
--robot.left_arm_config.side=left \
--robot.left_arm_config.max_relative_target=10.0 \
--robot.right_arm_config.port=can1 \
--robot.right_arm_config.side=right \
--robot.right_arm_config.max_relative_target=10.0 \
--robot.cameras='{top: {type: opencv, index_or_path: /dev/video0, width: 640, height: 480, fps: 30}}' \
--task="Pick up the red cube and place it in the bowl" \
--fps=30 \
--duration=30 \
--device=cuda \
--display_data=false32.2 Every flag explained
| Flag | Value on zeus | What it does | If it’s wrong or missing |
|---|---|---|---|
--strategy.type | base | What the run does: base = run the policy, record nothing. Others in §37, §38. | Default is base. |
--policy.path | absolute path to …/pretrained_model, or a Hub id | Which trained policy to load (weights + settings + normalisation stats). | Missing: --policy.path is required for rollout. Wrong folder: Hub “not found” style error. |
--robot.type | bi_openarm_follower | The two-arm OpenArm follower. | Must match the policy’s 16-value state/action. |
--robot.id | my_bimanual_follower | Name of the follower’s calibration files (…/openarm_follower/my_bimanual_follower_left.json / _right.json). | Missing or different = no file = LeRobot asks you to put the arms “hanging straight down” and re-zeroes all 16 motors (§21). The default id for this robot type is bi_openarm_follower, which has no files on zeus. If you see that prompt, Ctrl+C. |
--robot.left_arm_config.port | can0 | CAN interface of the left arm (zeus cabling, §22). | Swapped with right: each arm gets the other arm’s commands. |
--robot.right_arm_config.port | can1 | CAN interface of the right arm. | As above. |
--robot.left_arm_config.side / right_…side | left / right | Selects the per-side joint limits every command is clipped to (§20). | Missing: ±5° limits, the arms barely move. Swapped: joint_2 may swing into the torso. |
--robot.*_arm_config.max_relative_target | 10.0 (degrees) | Each command may move a joint at most this far from where it is now. At 30 fps, 10° per tick ≈ 300°/s, so it’s a guard against a single wild jump, not a speed limit. Write it with a decimal point. | Missing: no step limit at all. Too small (≤ 5): low-stiffness wrist joints stall short of their target (§23.3). See §36.4. |
--robot.cameras | copied from the recording command | Cameras, by name. Becomes observation.images.<name>. | Name/resolution mismatch: start-up error. Swapped devices: silently wrong. |
--task | the training task sentence | Instruction text. Ignored by ACT/Diffusion, essential for SmolVLA/Pi0. | VLAs: wrong behaviour. |
--fps | the dataset’s fps (30) | Loop rate: one policy action per tick. | Mismatch: motions play too fast or too slow. |
--duration | 30 for first runs | Seconds of policy control, then the run ends by itself (§33.3). 0 = until Ctrl+C. | Default is 0 = forever. Always set it at first. |
--device | cuda | Run the network on the GPU. | Omitted: taken from the checkpoint’s config.json, else auto-picked. cpu works but is slow. |
--display_data | false | Live Rerun viewer of joints and images. | true costs loop time and once froze the loop for 12.8 s on zeus (§26 item 11). |
32.3 Flags you can usually leave alone
| Flag | Default | When to change it |
|---|---|---|
--interactive | false | true to load everything and wait for you to type /start before anything moves. Recommended for the first runs (§35). Only with base (or sentry). |
--return_to_initial_position | true | false leaves the arms where they are at the end; they then go limp there. Keep true. |
--interpolation_multiplier | 1 | 2–3 for smoother motor commands (§36.3). |
--policy.n_action_steps, --policy.temporal_ensemble_coeff | from the checkpoint | Change how often ACT re-plans (§36.1, §36.2). Any --policy.<setting> overrides the value in config.json for this run only. |
--robot.*_arm_config.position_kp / position_kd | [240,240,240,240,24,31,25,25] / [5,5,3,5,0.3,0.3,0.3,0.3] | Softer arms (§36.5). |
--inference.type | sync | rtc for slow VLA models (§40). |
--use_torch_compile | false | Faster inference after a warm-up; not needed for ACT. |
--rename_map | {} | When the camera names differ between training and today: --rename_map='{"observation.images.cam_a": "observation.images.top"}'. |
--play_sounds | true | LeRobot speaks events aloud through spd-say (“Recording episode 3”, “Reset the environment”). Useful when you’re watching the arms, not the terminal. false to silence. |
--display_mode, --display_ip, --display_port | rerun | Only with --display_data=true. foxglove is the alternative viewer. |
--robot.*_arm_config.use_velocity_and_torque | false | Must equal what was used for recording. |
--robot.*_arm_config.disable_torque_on_disconnect | true | Keep true (otherwise the motors stay energised after the program exits, holding the last command with nobody supervising). |
lerobot-rollout --help lists every flag the installed version accepts.
32.4 Shell pitfalls when typing long commands
- The backslash
\must be the very last character of the line. A space after it ends the command there. The rest of the lines are then run as separate (failing) commands, and the robot runs with defaults for everything after the break. - No comments between continued lines. A
# …line in the middle ends the command. - Quote the cameras dict in single quotes
'{…}', and the task in double quotes"…". Inside the dict, keep the spaces after:and,as shown. max_relative_target=10.0, not10: LeRobot checks for a decimal number when it applies the limit. ⚠ verify that an integer is accepted; the decimal form is certainly fine.- Paste long commands from a file, not retyped. The script in §32.5 removes this whole class of mistakes.
32.5 A reusable script (recommended)
Put the parts that never change in a script, and keep what changes (policy, task, cameras, duration) as variables at the top. Create ~/Documents/openarm_lerobot/run_policy.sh with:
#!/usr/bin/env bash
# Run a trained LeRobot policy on the zeus bimanual OpenArm.
# Usage: bash run_policy.sh [extra lerobot-rollout flags, e.g. --interactive=true --duration=10]
set -euo pipefail
# ---- edit these per policy -------------------------------------------------
POLICY="$HOME/Documents/lerobot/outputs/train/act_openarm_pick_cube/checkpoints/last/pretrained_model"
TASK="Pick up the red cube and place it in the bowl"
CAMERAS='{top: {type: opencv, index_or_path: /dev/video0, width: 640, height: 480, fps: 30}}'
FPS=30
DURATION=30
MAX_STEP=10.0
# ---------------------------------------------------------------------------
command -v lerobot-rollout >/dev/null || { echo "lerobot-rollout not found: run 'conda activate lerobot' first"; exit 1; }
[ -f "$POLICY/config.json" ] || { echo "No config.json in $POLICY"; exit 1; }
lerobot-rollout \
--strategy.type=base \
--policy.path="$POLICY" \
--robot.type=bi_openarm_follower \
--robot.id=my_bimanual_follower \
--robot.left_arm_config.port=can0 --robot.left_arm_config.side=left --robot.left_arm_config.max_relative_target="$MAX_STEP" \
--robot.right_arm_config.port=can1 --robot.right_arm_config.side=right --robot.right_arm_config.max_relative_target="$MAX_STEP" \
--robot.cameras="$CAMERAS" \
--task="$TASK" \
--fps="$FPS" \
--duration="$DURATION" \
--device=cuda \
--display_data=false \
"$@"Flags added on the command line come last and override the ones in the script (e.g. bash run_policy.sh --duration=10 --interactive=true). ⚠ verify that a repeated flag takes the last value; if it doesn’t, remove --duration from the script and always pass it.
33. What happens when you run it
33.1 Timeline with the log lines you should see
Knowing the order matters: everything before step 4 can fail without the arms moving. The lines below are the INFO messages printed by LeRobot v0.6.2 (prefixes and timestamps omitted).
| # | Log line(s) | What is happening | Arms |
|---|---|---|---|
| 1 | Loading policy from '…/pretrained_model'... → Policy loaded: type=act, device=cuda | Weights loaded onto the GPU. A wrong path or a broken checkpoint stops here. | Not touched |
| 2 | Connecting robot (bi_openarm_follower)... | Opens can0 and can1, checks motors, opens the cameras. | Not yet powered |
| — | (should NOT appear) Position the arm … hanging straight down | No calibration file for this --robot.id. Ctrl+C now (§21). | — |
| 3 | Robot connected: … → Captured initial robot position (16 keys) | Torque is switched on for all 16 motors. The current joint angles are stored as the “initial position”. | Energised ⚠ verify how firmly they hold before the first command |
| 4 | Creating inference engine (type=sync)... → Rollout strategy: base → Robot: bi_openarm_follower | FPS: 30 | Duration: 30.0s | Last checks. A camera-name mismatch fails here with Visual feature mismatch between policy and robot hardware. This check runs after torque is on, and the program then exits without its normal shutdown, so the motors may stay energised ⚠ verify. If they do: support the arms, then openarm-can-cli -i can0 disable and -i can1 disable. | Held |
| 5 | Rollout setup complete, starting rollout... → Base strategy control loop started | The policy is in control from this line on. | Moving |
| 6 | (possibly) Relative goal position magnitude had to be clamped … warnings | A target was more than max_relative_target away. Occasional ones are normal; a constant stream on one joint is not (§41). | Moving |
| 7 | Duration limit reached (30s) (or nothing, on Ctrl+C) → Base strategy control loop ended | Policy stops. | Hold their last target |
| 8 | Cadence summary block | How fast the loop really ran (§36.6). | Held |
| 9 | Stopping inference engine... → Returning robot to initial position before shutdown... | Straight-line move back to the pose of step 3, over 3 s. | Moving |
| 10 | Disconnecting robot... → Base strategy teardown complete → Rollout finished | Torque off. | Limp: they fall from wherever they are |
The first run also takes a few extra seconds at step 1 (CUDA initialisation).
33.2 When the arms move, and how
- At connect (step 3): torque on. No motion is commanded yet. ⚠ verify whether the arms hold firmly or sag a little between connect and the first command.
- First action (step 5): there is no soft start. The first target goes to the motors at full stiffness. If the arms are at the training start pose, the first target is close to where they are and nothing dramatic happens. If they are far from it, they snap toward it, limited only by
max_relative_targetper tick. Starting in the training start pose is the main safety measure. - During the run: each tick sends one target per joint at kp 240 (big joints). Targets are clipped to the
sidelimits and to ±max_relative_targetfrom the current position. There is no collision checking, no force limit and no gravity compensation. The policy can drive the arm into the table, the other arm or the torso. - Every
n_action_stepsticks (ACT default: every 3.3 s) a new chunk starts. A small visible “hitch” at chunk boundaries is normal (§36.2). - At the end (step 9): a straight joint-space line back to the start pose over 3 s, also without collision checking. If the gripper is holding something or an arm is under an object, the return drags through it. Clear the path, or stop with the power switch if it’s about to hit something.
- After the end (step 10): the arms go limp. Because they’re back at the start pose, starting from the hanging pose means they’re already hanging and barely move. That’s another reason to start there.
33.3 All the ways a run ends
| How | What the arms do |
|---|---|
--duration expires | Policy stops → return to start pose (3 s) → torque off |
| Ctrl+C once | Same as above. The normal way to stop early. |
| Ctrl+C twice | Forces the program to exit immediately. The return move may be cut short and the arms may drop from where they are. Don’t, unless the return itself is dangerous. |
/reset (interactive mode) | Policy stops → return to start pose → torque stays on, ready for /start |
/stop (interactive mode) | Return to start pose → torque off → program ends |
| Esc (episodic, DAgger) | Ends the session → return to start pose → torque off. Needs the keyboard listener (§37.3). |
| A Python error during the run | Teardown still runs (return + torque off) where it can ⚠ verify |
| Motor power switch | Motors unpowered instantly: arms fall wherever they are. LeRobot then reports motors not answering. The emergency stop. |
--return_to_initial_position=false + any of the above | No return move: the arms go limp in place |
| Ctrl+Z | Not a stop. Suspends the program with the buses open; it resumes commanding on fg. Clean up with kill -9 <pid> (§26 item 10). |
34. Pre-flight checklist (every policy run)
Do these every time, in order. The first run has extra steps (§35).
- Base clamped, area clear, a person at the motor power switch. A policy can do anything the arm can do, including things it never did in training.
- Nothing else owns the buses: no
safe_teleop.py,lerobot-teleoperate,lerobot-recordor ROS launch running (§25). Quick check:candump -n 20 -T 500 can0and… can1print nothing. bash ~/Documents/openarm_lerobot/setup_openarm_lerobot.sh: CAN up, 16/16 motors answer, follower calibration files present. A motor that doesn’t answer reads as 0° in LeRobot (§26 item 6), and that would also corrupt the stored “initial position”.- Scene as in training: cameras plugged in, at the same positions and angles, same lighting, the objects in a typical start position.
- Arms at the training start pose (§31.3), torque off. Check with the follower columns of
python ~/Documents/openarm_lerobot/compare_mini_follower.py --once. - Command checked:
--robot.id=my_bimanual_follower,can0= left /can1= right, bothsides,max_relative_targetset, the same--robot.camerasas recording,--durationset,--display_data=false. - Hands and heads out of the workspace. Run.
35. Your first run on zeus, step by step
The first run is broken into small steps so that each new thing is tested on its own: first the files, then the hardware without the policy moving, then a 10-second run, then longer runs. Don’t skip ahead. If a step doesn’t give the expected result, stop, look it up in §41, and note it in the day’s log.
35.1 Step 1: open a terminal and activate the environment
conda activate lerobot
cd ~/Documents/lerobot
lerobot-info | head -5 # shows the LeRobot version (0.6.2)
python -c "import torch; print('CUDA:', torch.cuda.is_available())"Expected: CUDA: True. If False, see §27 (last row).
35.2 Step 2: check the trained model
Run the two snippets of §31.2 and §31.3. Note down:
- the full
pretrained_modelpath, - the camera names and resolutions the policy expects,
- the dataset’s fps,
- the start pose (mean per joint).
35.3 Step 3: hardware setup
bash ~/Documents/openarm_lerobot/setup_openarm_lerobot.shExpected: it ends with the sanity table, 16/16 follower motors. It also checks the Minis, which is harmless even if you won’t use them.
35.4 Step 4: make sure nothing else is driving the arms
pgrep -af "lerobot-|safe_teleop|ros2_control_node" || echo "nothing running"
candump -n 20 -T 500 can0; candump -n 20 -T 500 can1 # no output = nobody is commanding the armsros2_control_node runs inside the container, but the container shares the host’s process list only partly. If you’ve used ROS today, also check inside it (§25).
35.5 Step 5: check the cameras
lerobot-find-cameras opencvIt lists every camera and saves one test image per camera to outputs/captured_images/. Open them:
- Find which
/dev/video…(or better,/dev/v4l/by-id/…) is which physical camera, and make sure it matches the name used in recording (e.g. the overhead camera must betop). - Compare each image with a frame from the training data:
lerobot-dataset-viz --repo-id <hf_user>/openarm_pick_cube --episode-index 0. Same framing, same angle? A camera that moved by a few centimetres can be enough to break the policy.
Camera numbers (/dev/video0, /dev/video2) can change when cameras are re-plugged. If you recorded with /dev/v4l/by-id/… paths, they don’t.
35.6 Step 6: put the arms in the start pose
Torque is off (setup leaves it off). By hand, move both arms to the start pose you noted in step 2, usually hanging straight down, grippers closed. Check:
python ~/Documents/openarm_lerobot/compare_mini_follower.py --onceLook only at the follower columns. Every joint should be within a few degrees of the start pose (or inside the min–max range from §31.3). Then put the objects of the task in a typical start position.
35.7 Step 7: dry run, everything loads but nothing moves (interactive mode)
This tests the policy files, the CAN connection, the cameras and the feature matching, without the policy ever moving the arms:
bash ~/Documents/openarm_lerobot/run_policy.sh --interactive=true(or the full command of §32.1 with --interactive=true added.)
What happens:
- The log of §33.1 up to step 4. Torque comes on at connect: the arms are energised from here.
- A banner: “Interactive rollout session — the robot will NOT move until you type /start.”, followed by the command list:
| Command | Effect |
|---|---|
/start | Start (or restart) the policy. --duration then limits this segment. |
/reset | Stop the policy, move back to the start pose, keep torque on, wait. |
/stop | End the session: back to the start pose, torque off, exit. |
/help | List the commands. |
/subtask <text>, /vqa <text>, /autosteer <goal> | Only for language/VLA policies with a text head. Not used with ACT. |
- Type
/stopand Enter. The arms “return” to where they already are, then go limp. Support them as torque goes off.
Expected: no error, and the arms didn’t move. You have now proven that the policy loads, both buses and all cameras open, and the camera names match. Note that routine log messages are muted during an interactive session: only errors and the cadence summaries are shown.
35.8 Step 8: the first 10 seconds of policy control
Re-check step 6 (arms at the start pose). The second person has a hand on the power switch. Then:
bash ~/Documents/openarm_lerobot/run_policy.sh --interactive=true --duration=10At the banner, type /start. Watch the arms, not the screen.
- The arms should start moving smoothly, roughly like the beginning of a demonstration.
- Abort with the power switch if: an arm jumps fast at the very start, moves toward the torso or the other arm, hits the table hard, or a joint shakes.
- After 10 s the segment ends by itself: “Rollout run ended on its own (duration reached). Robot is holding position.” The arms hold their last pose.
- Type
/resetto bring them back to the start pose, reset the scene, and/startagain for another 10 s. Repeat a few times. /stopwhen done (support the arms).
Write down what you saw (did it go for the object? how smooth? any snap?) in the day’s log.
35.9 Step 9: longer runs
Once 10 s segments look sane:
- Raise
--durationto the length of a typical demonstration plus some margin (e.g. 30–60 s). - Drop
--interactive=truewhen you don’t need the pause between attempts. Ctrl+C once stops early. - To measure how often it succeeds, switch to evaluation episodes (§37).
- Read the cadence summary printed at the end of each run (§36.6):
effective cadenceshould be close to 30 Hz.
35.10 After the run
- The arms are limp at the start pose. If you’re done for the day, lower them fully and switch the motor power off.
- Nothing is saved by the
basestrategy: no dataset, no video. If you want a record, useepisodic(§37) or film it. - Note in the log: which checkpoint, the command, how many attempts, what worked, what didn’t.
36. Tuning how the policy moves
Change one thing at a time, and always on short --duration runs first.
36.1 How often the policy looks: n_action_steps
ACT predicts 100 actions at once. With the default n_action_steps=100, the robot plays all 100 (3.3 s at 30 fps) without looking, then asks the network again. If the object moves or the grasp slips, the policy notices only at the next chunk.
Lower values make it re-plan more often:
bash run_policy.sh --policy.n_action_steps=30 # re-plan every 1 s- Pros: reacts faster to what the cameras see.
- Cons: more network calls (still cheap for ACT), and more chunk boundaries, i.e. more small hitches.
n_action_stepscan’t exceedchunk_size(100).
36.2 Smoother motion: temporal ensembling (ACT)
Instead of playing one chunk and then switching to the next, ACT can run the network every tick and average the overlapping predictions for the current tick. This removes the chunk-boundary hitches and usually gives the smoothest motion:
bash run_policy.sh --policy.n_action_steps=1 --policy.temporal_ensemble_coeff=0.01n_action_stepsmust be 1 with ensembling (LeRobot refuses otherwise).0.01is the value from the ACT paper. Larger values weight the oldest predictions more.- The network now runs 30 times per second. ⚠ verify on the RTX 5050 that the cadence summary still shows ~30 Hz (the
inferline).
36.3 Smoother commands: —interpolation_multiplier
bash run_policy.sh --interpolation_multiplier=2Sends 2 commands per policy action (60 Hz to the motors) by interpolating linearly between consecutive actions. The policy and any recording stay at 30 Hz. Helps with “stair-step” motion at kp 240. It doesn’t fix chunk-boundary jumps (use §36.2 for that). Each motor command also reads the current position when max_relative_target is set, so going above 2–3 can saturate the CAN loop. Check the cadence summary.
36.4 Step limit: max_relative_target
The guard against a single huge jump. For each command and joint: target = current + clamp(target − current, ±max_relative_target).
| Value | Effect |
|---|---|
| unset | No guard at all. Don’t. |
| 5 | Very cautious, but the low-stiffness wrist joints (kp 24–31) stall a few degrees short of their target and the log fills with “clamped” warnings (§23.3). |
| 10 | Recommended start: limits a wild jump to 10° per tick, rarely clamps a normal policy motion. |
| 20+ | Little protection. |
The value is per arm. A per-joint form also exists ({joint_1: 10.0, …, gripper: 10.0}, all 8 names required). ⚠ verify the command-line syntax before relying on it.
36.5 Stiffness: position_kp and position_kd
LeRobot’s follower is ~3.4× stiffer than the ROS defaults (§20). Softer gains make contacts gentler but the arm sags more and lags its targets:
bash run_policy.sh \
--robot.left_arm_config.position_kp='[120,120,120,120,24,31,25,25]' \
--robot.right_arm_config.position_kp='[120,120,120,120,24,31,25,25]'The policy was trained on observations recorded at kp 240. With softer gains, the joint angles it sees differ from training (more sag), so it may behave differently. Treat it as an experiment, not a default.
36.6 Loop rate: —fps
Keep --fps equal to the dataset’s fps (30). What you can control is whether the loop actually reaches it. Every run ends with a cadence summary, for example:
Cadence summary — whole run …
effective cadence: 29.84 Hz policy …
cycles over the 33.3 ms work budget: 12/1197 (1.0%) — work mean 18.7 ms, worst 48.1 ms
loop-body steps (share of measured work):
observe mean 3.13 ms …
infer mean 5.18 ms …
send mean 0.40 ms …
pacing headroom: 7.4 ms slept per tick on average …
effective cadencewell below 30 Hz → the motion is slower than in training.- High
observeshare → cameras (fewer or smaller cameras, no Rerun). - High
infershare → the network (no ensembling, smaller model, or RTC for VLAs). pacing headroomnear 0 → no margin; expect jerks.
36.7 Faster inference: —use_torch_compile
--use_torch_compile=true compiles the network on the first calls. The robot waits during the warm-up (a few seconds to minutes) and then inference is faster. Not needed for ACT. Worth trying for Diffusion or SmolVLA if infer dominates the cadence summary.
37. Measuring how well it works: episodic evaluation
37.1 What it does
--strategy.type=episodic runs the policy as a series of episodes with a reset phase between them, and records everything as a LeRobot dataset, like lerobot-record but with the policy instead of you. Use it to measure a success rate, compare checkpoints, or keep good rollouts as extra training data.
For each episode:
- Policy state is cleared; the policy runs for up to
--dataset.episode_time_sseconds (→ ends it early). - Episode saved.
- Reset phase of
--dataset.reset_time_sseconds: the arms go back to the start pose (in 1 s, faster than the 3 s at the end of a run) and hold there, torque on, while you reset the objects. With the Minis attached you teleoperate the reset instead (§38). - Next episode, until
--dataset.num_episodesare recorded or Esc.
37.2 The command
lerobot-rollout \
--strategy.type=episodic \
--policy.path=$HOME/Documents/lerobot/outputs/train/act_openarm_pick_cube/checkpoints/last/pretrained_model \
--robot.type=bi_openarm_follower \
--robot.id=my_bimanual_follower \
--robot.left_arm_config.port=can0 --robot.left_arm_config.side=left --robot.left_arm_config.max_relative_target=10.0 \
--robot.right_arm_config.port=can1 --robot.right_arm_config.side=right --robot.right_arm_config.max_relative_target=10.0 \
--robot.cameras='{top: {type: opencv, index_or_path: /dev/video0, width: 640, height: 480, fps: 30}}' \
--dataset.repo_id=<hf_user>/rollout_act_pick_cube_last \
--dataset.single_task="Pick up the red cube and place it in the bowl" \
--dataset.num_episodes=10 \
--dataset.episode_time_s=40 \
--dataset.reset_time_s=20 \
--dataset.fps=30 \
--dataset.push_to_hub=false \
--device=cudaRules for the dataset flags, all enforced by LeRobot v0.6.2:
| Rule | Why / error if broken |
|---|---|
The dataset name must start with rollout_ | Dataset names for rollout must start with 'rollout_'. (V2.2 of this guide used eval_… and hil_…, which are rejected.) |
A date-time tag is appended to the name, e.g. rollout_act_pick_cube_last_20261010_143000 | Each session gets a new dataset. Add --dataset.no_stamp=true to keep the exact name (a second run with the same name then needs --resume=true, or fails because the dataset exists ⚠ verify the exact error). |
Always set --dataset.push_to_hub=false | The default is true: at the end it tries to upload to the Hub and fails without hf auth login. |
Set num_episodes, episode_time_s, reset_time_s | Defaults are 50 episodes of 60 s with 60 s resets. |
--dataset.single_task | Becomes the task text (no separate --task needed). |
--dataset.* flags with --strategy.type=base | Rejected: base strategy does not record data. |
The recording goes to ~/.cache/huggingface/lerobot/<hf_user>/rollout_…/. View it with lerobot-dataset-viz --repo-id <the stamped name> --episode-index 0.
37.3 Keyboard controls and the Wayland problem on zeus
| Key | Effect |
|---|---|
| → | End the current episode (or reset phase) now |
| ← | Discard the current episode and record it again |
| Esc | Stop the session (return to start pose, torque off) |
These keys are read by pynput, which needs an X11 session. zeus currently logs in to a Wayland session (echo $XDG_SESSION_TYPE → wayland), where the listener most likely receives nothing ⚠ verify. Without the keys the session still works: episodes and resets simply run for their full time, and Ctrl+C once ends the session (an episode in progress at that moment may be lost). To get the keys: log out, click the gear icon on the login screen, choose “Ubuntu on Xorg”, log in again.
37.4 Scoring
LeRobot doesn’t know whether the task succeeded. You decide, per episode. Keep a tally in the day’s log:
| Checkpoint | Episode | Result | Notes |
|---|---|---|---|
last (100k) | 0 | ✅ | |
last (100k) | 1 | ❌ | missed the grasp by ~2 cm |
| … |
With 10 episodes per checkpoint you can compare checkpoints. Point --policy.path at …/checkpoints/040000/pretrained_model, …/060000/…, etc. The last checkpoint isn’t always the best (overfitting).
38. Making it better: corrections with the Minis (DAgger)
38.1 The idea
A policy fails in situations its demonstrations didn’t cover. DAgger collects exactly those: the policy runs, and when it’s about to fail you pause it, take over with the Minis, show the correction, and hand back. Every correction is saved as an episode, tagged intervention=True. Retraining on the original demos plus the corrections fixes the weak spots. LeRobot’s own guide for this (docs/source/hil_data_collection.mdx) uses exactly bi_openarm_follower + bi_openarm_mini.
Needs the keyboard (or a foot pedal)
Pausing and taking over are done with keys read by
pynput. On zeus’s current Wayland session they probably don’t work (§37.3). Switch to an X11 session first, or use a USB foot pedal (--strategy.input_device=pedal). Without either, you can’t take over, so don’t start a DAgger run.
38.2 Before you start
python ~/Documents/openarm_lerobot/check_mini_calibration.pymust pass. A wrong Mini calibration makes the takeover snap (§23.2).- Practise the key sequence (§38.4) once with
--strategy.num_episodes=1and a short task.
38.3 The command
lerobot-rollout \
--strategy.type=dagger \
--strategy.num_episodes=20 \
--policy.path=$HOME/Documents/lerobot/outputs/train/act_openarm_pick_cube/checkpoints/last/pretrained_model \
--robot.type=bi_openarm_follower \
--robot.id=my_bimanual_follower \
--robot.left_arm_config.port=can0 --robot.left_arm_config.side=left --robot.left_arm_config.max_relative_target=10.0 \
--robot.right_arm_config.port=can1 --robot.right_arm_config.side=right --robot.right_arm_config.max_relative_target=10.0 \
--robot.cameras='{top: {type: opencv, index_or_path: /dev/video0, width: 640, height: 480, fps: 30}}' \
--teleop.type=bi_openarm_mini \
--teleop.left_arm_config.port=/dev/serial/by-id/usb-1a86_USB_Single_Serial_5876043720-if00 --teleop.left_arm_config.side=left \
--teleop.right_arm_config.port=/dev/serial/by-id/usb-1a86_USB_Single_Serial_5876043592-if00 --teleop.right_arm_config.side=right \
--teleop.id=my_mini \
--dataset.repo_id=<hf_user>/rollout_hil_pick_cube \
--dataset.single_task="Pick up the red cube and place it in the bowl" \
--dataset.no_stamp=true \
--dataset.push_to_hub=false \
--device=cudaAt start the Minis ask “ENTER to use existing calibration”: press Enter. --strategy.num_episodes is the number of corrections to collect (falls back to --dataset.num_episodes when unset).
38.4 Controls
| Key (default) | Effect |
|---|---|
| Space | Pause / resume the policy. On pause the followers hold, and LeRobot powers the Minis and drives them to the followers’ pose over 2 s. Hands off the Minis while they move; take hold of them once they stop. |
| Tab | Start / stop a correction. On start the Minis go limp and the followers follow them: you demonstrate. On stop the Minis are powered again to hold. |
| Space (again, while paused) | Resume the policy; the Minis go limp. |
| Enter | Push the dataset to the Hub (only with push_to_hub=true). |
| Esc | End the session (followers return to the start pose, then torque off). |
Typical cycle: policy runs → it’s about to fail → Space (pause, wait for the Minis to stop moving, grab them) → Tab (correct the motion) → Tab (end correction) → Space (policy continues).
Other options: --strategy.record_autonomous=true also records the autonomous stretches (continuous, size-based episodes). --strategy.input_device=pedal with --strategy.pedal.device_path=/dev/input/by-id/… uses a USB foot pedal. --strategy.smooth_handover=false disables the powered Mini move. Don’t: the follower would then jump to wherever the Mini is when the correction starts.
38.5 Retrain on demos + corrections
Training on two datasets at once isn’t supported in v0.6.2 (MultiLeRobotDataset isn't supported for now), so merge them first, then train as in §24.3 with a new --output_dir / --job_name:
lerobot-edit-dataset \
--new_repo_id <hf_user>/openarm_pick_cube_plus_hil \
--operation.type merge \
--operation.repo_ids "['<hf_user>/openarm_pick_cube', '<hf_user>/rollout_hil_pick_cube']"
lerobot-train --dataset.repo_id=<hf_user>/openarm_pick_cube_plus_hil … (as §24.3, new --output_dir/--job_name)Use the real names of both datasets (ls ~/.cache/huggingface/lerobot/<hf_user>/). Without --dataset.no_stamp=true, both carry a date-time suffix. Repeat rollout → corrections → merge → train until the policy stops needing corrections.
38.6 Other recording strategies
| Strategy | What it records | When useful |
|---|---|---|
highlight | Keeps the last --strategy.ring_buffer_seconds (default 10 s) in memory. Press s to save that buffer and keep recording, s again to close the episode. h pushes to the Hub. | Long autonomous runs where you only want to keep the interesting moments (e.g. failures). Needs the keyboard. |
sentry | Everything, continuously, in size-based episodes, uploading every --strategy.upload_every_n_episodes (5). | Unattended long runs. Not for this setup yet: the robot must never run unattended. |
Both need --dataset.repo_id=<hf_user>/rollout_….
39. Running a single-arm policy
A policy trained on one arm (observation.state [8], recorded with --robot.type=openarm_follower) runs with the single-arm robot type. On zeus the right arm is can1 and the left arm can0. The single-arm calibration file is my_follower:
lerobot-rollout \
--strategy.type=base \
--policy.path=<path>/pretrained_model \
--robot.type=openarm_follower \
--robot.id=my_follower \
--robot.port=can1 \
--robot.side=right \
--robot.max_relative_target=10.0 \
--robot.cameras='<same as recording>' \
--task="<task>" --fps=30 --duration=30 --device=cuda --display_data=falseThe other arm isn’t connected: it stays unpowered and hangs. A bimanual policy can’t run on one arm, and the reverse doesn’t work either: the number of joints must match.
40. Big VLAs: RTC and running the model on another machine
For flow-matching VLAs (SmolVLA, Pi0, Pi0.5) one inference takes longer than one control tick. Real-Time Chunking (RTC) computes the next chunk in the background while the current one plays, and blends them so the motion doesn’t stall:
lerobot-rollout --strategy.type=base --inference.type=rtc \
--inference.rtc.execution_horizon=10 --inference.rtc.max_guidance_weight=10.0 \
--policy.path=<hf_user>/smolvla_openarm_pick_cube \
… same --robot.* flags as §32.1 … \
--task="Pick up the red cube and place it in the bowl" --device=cudaLeRobot refuses RTC for policies that don’t support it (ACT, Diffusion: RTC inference is not supported by policy type …); use the default sync for those.
If the model doesn’t fit on the laptop GPU, LeRobot’s async inference runs the policy on a GPU server and only the robot client on zeus (pip install -e ".[async]" on both machines):
# on the GPU server
python -m lerobot.async_inference.policy_server --host=0.0.0.0 --port=8080
# on zeus
python -m lerobot.async_inference.robot_client --server_address=<server_ip>:8080 \
--robot.type=bi_openarm_follower … --policy_type=pi05 --pretrained_name_or_path=<hf_user>/pi05_openarm \
--actions_per_chunk=50 --chunk_size_threshold=0.5 --task="…"⚠ The async client’s official robot list doesn’t include the OpenArm yet (SUPPORTED_ROBOTS in async_inference/constants.py). The check is commented out, so it should run, but it’s untested here. The async client is a separate program from lerobot-rollout: no strategies, no return-to-start-pose. See docs/source/async.mdx for the full flag list.
41. Policy troubleshooting
Start-up errors (the policy never takes control). Errors raised after Robot connected (camera or feature mismatches) end the program without the normal shutdown, so torque may stay on ⚠ verify. If the arms are still stiff afterwards: support them, then openarm-can-cli -i can0 disable and openarm-can-cli -i can1 disable (the arms go limp).
| Message / symptom | Cause / fix |
|---|---|
lerobot-rollout: command not found | conda activate lerobot. |
--policy.path is required for rollout | Flag missing, or the line with it was cut off by a broken \ (§32.4). |
| Hub “repository not found” / “repo id must be in the form …” for a local model | The path doesn’t exist, so LeRobot treated it as a Hub name. Check it with ls <path>/config.json; use an absolute path ending in pretrained_model. |
Visual feature mismatch between policy and robot hardware. Policy expects: {…} Robot provides: {…} | Camera names differ from training. Use the recording’s exact --robot.cameras, or --rename_map. |
| Error about missing / unexpected features, or a size mismatch (e.g. 16 vs 48) | use_velocity_and_torque or the robot type differs from training, or a bimanual policy on a single arm. |
observation.state order … is a permutation of the action dispatch order | Joint order of the robot doesn’t match the checkpoint. Shouldn’t happen with the standard follower; check the policy was trained on a bi_openarm_follower dataset. |
| LeRobot asks to put the follower “hanging straight down” | --robot.id missing or different from my_bimanual_follower. Ctrl+C; don’t press Enter (§21). |
Failed to connect to CAN bus: … Network is down | PCAN adapter replugged. Re-run setup_openarm_lerobot.sh. |
| Motors “No response” | Motor power off, or another program (ROS, teleop) owns the bus. §27. |
CUDA out of memory | Model too big for 8 GB: smaller policy, --device=cpu (slow), or async inference on a bigger GPU (§40). |
Dataset names for rollout must start with 'rollout_' | Recording strategies need --dataset.repo_id=<hf_user>/rollout_<name>. |
base strategy does not record data: drop the --dataset.* flags … | Remove the --dataset.* flags, or use episodic. |
episodic strategy requires --dataset.repo_id to be set (same for dagger, highlight, sentry) | Add --dataset.repo_id=<hf_user>/rollout_<name>. |
dagger strategy requires --teleop.type to be set | Add the --teleop.* flags of §38.3. |
--interactive=true supports --strategy.type=base or sentry | Interactive mode only works with those two strategies. |
RTC inference is not supported by policy type 'act' | Drop --inference.type=rtc. |
`n_action_steps` must be 1 when using temporal ensembling | Add --policy.n_action_steps=1. |
During the run:
| Symptom | Cause / fix |
|---|---|
| Arms snap at the start | Not in the training start pose (§34 step 5), or max_relative_target not set. |
| Policy moves but “does nothing sensible” | Cameras moved, lighting changed, cameras swapped, wrong task text (VLAs), or simply too few / inconsistent demos. Replay a training episode with lerobot-replay (§24.2) to check the setup itself. |
| Policy works at the start, then drifts or freezes | Situation not covered by the demos: collect DAgger corrections (§38) or more demos. Also try a lower n_action_steps (§36.1). |
| A small jerk every ~3 s | ACT chunk boundaries. Temporal ensembling (§36.2). |
| Jerky motion all the time | Loop slower than --fps (cadence summary), Rerun on, or a slow VLA without RTC. Try --interpolation_multiplier=2. |
| One joint creeps or stalls, constant “had to be clamped” warnings for it | max_relative_target too small for that low-gain joint; raise it a little. |
| Gripper doesn’t close fully | Gripper targets are clipped to −65…0°. The policy learned the Mini’s gripper range; check the Mini gripper calibration used during recording (§23.2). |
| The arms go limp at the end | Expected: torque off after the return to the start pose. Support them. |
| Nothing printed in interactive mode | Expected: routine logs are muted during an interactive session; only errors and cadence summaries appear. |
| The computer talks | --play_sounds (default true) reads events aloud. --play_sounds=false to silence. |
| → / ← / Esc / Space / Tab do nothing | Keyboard listener needs X11; zeus is on Wayland (§37.3). |
| DAgger: Minis move on their own when pausing | Intended: the smooth handover drives the Minis to the follower pose. Hands off until they stop. |
| DAgger: follower jumps when a correction starts | Mini calibration wrong or smooth_handover disabled. Run check_mini_calibration.py; recalibrate (§23.2). |
Training / dataset names:
| Symptom | Cause / fix |
|---|---|
lerobot-train can’t find <hf_user>/openarm_pick_cube | lerobot-record appended a date-time tag to the name (§24.1). ls ~/.cache/huggingface/lerobot/<hf_user>/ shows the real name. Use --dataset.no_stamp=true when recording to avoid this. |
Dataset names starting with 'eval_' are reserved for policy evaluation | lerobot-record refuses eval_… names. Evaluate with lerobot-rollout --strategy.type=episodic (§37). |
42. Policy safety notes
Everything in §13 and §26 applies. Specific to running policies:
- A policy is not a program you can read. It can do things it never did in training, especially in situations it hasn’t seen. First runs: short
--duration, interactive mode, a person at the power switch. - No soft start. The first action goes out at full stiffness. Start from the training start pose.
- No collision checking, in the policy or in the return move. Keep the workspace clear of anything that isn’t part of the task, and keep people out of reach.
- Silent zeros: a motor that doesn’t answer reads 0°. If it’s in the start pose capture, the return move drives that joint to 0. Always confirm 16/16 motors before running.
- Torque off at the end: the arms fall from the start pose. Support them, or start from hanging so there’s nowhere to fall.
- Ctrl+C once. Twice can cut the return move short. Never Ctrl+Z.
- The right
--robot.id, or LeRobot re-zeroes the follower motors (and the ROS zero with them). - Never unattended.
sentryand--duration=0exist, but this robot has no external safety system.
43. Policy cheat-sheet
# ---------- once per session ----------
conda activate lerobot
bash ~/Documents/openarm_lerobot/setup_openarm_lerobot.sh # 16/16 motors
candump -n 20 -T 500 can0; candump -n 20 -T 500 can1 # nothing = bus free
# ---------- every run ----------
python ~/Documents/openarm_lerobot/compare_mini_follower.py --once # arms at the training start pose?
bash ~/Documents/openarm_lerobot/run_policy.sh --interactive=true --duration=10 # /start, /reset, /stop
bash ~/Documents/openarm_lerobot/run_policy.sh # plain 30 s run, Ctrl+C ONCE to stop early
# ---------- tuning (one at a time) ----------
bash run_policy.sh --policy.n_action_steps=30 # re-plan every 1 s
bash run_policy.sh --policy.n_action_steps=1 --policy.temporal_ensemble_coeff=0.01 # smoothest (ACT)
bash run_policy.sh --interpolation_multiplier=2 # 60 Hz commands
# ---------- evaluation episodes (recorded) ----------
# --strategy.type=episodic --dataset.repo_id=<hf_user>/rollout_<name> --dataset.single_task="…"
# --dataset.num_episodes=10 --dataset.episode_time_s=40 --dataset.reset_time_s=20 --dataset.push_to_hub=false| Must match training | Value on zeus |
|---|---|
--robot.type | bi_openarm_follower (single arm: openarm_follower) |
--robot.id | my_bimanual_follower (single arm: my_follower) |
| Buses / sides | can0 = left, can1 = right |
--robot.cameras | copied from the recording command |
--fps | the dataset’s fps (30) |
--task | the recording’s --dataset.single_task |
Official docs: https://docs.openarm.dev · Discord: https://discord.gg/FsZaZ4z3We