OpenArm ROS 2 Guide (v1.0 bimanual, Jazzy, main branch)

Archived version: V2.1

Kept exactly as it was written. The current guide is V3.1.

Navigation

Guide versions: V1 · V2 · V2.1 (this file) · V2.2 · V3 · V3.1 (V3.1 is the current one)
Work logs (what was tested, issues found, pitfalls): Log Index

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.

Versions this guide was written against (inside the container, /root/ros2_ws/src):

RepoCommitDate
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 env lerobot)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

  1. Big picture
  2. The packages
  3. Robot model: joints, limits, frames
  4. ros2_control layer: hardware, controllers, interfaces
  5. Environment and container
  6. Launch files and arguments
  7. Running in simulation (mock hardware)
  8. Commanding the robot (sim and real)
  9. MoveIt 2
  10. The physical robot: CAN setup
  11. The physical robot: zero calibration
  12. The physical robot: bringing it up with ROS 2
  13. Safety notes specific to this code
  14. Introspection and debugging cheat-sheet
  15. Troubleshooting (problems already hit on zeus)
  16. Known issues and gaps in `main`

Part II: Teleoperation and data collection with Hugging Face LeRobot

  1. What LeRobot is and where it sits
  2. Teleop hardware options
  3. Installation on zeus (done)
  4. Conventions: LeRobot vs ROS 2
  5. Calibration and the shared motor zero
  6. Bus and port layout for the bimanual setup
  7. Step-by-step: from unboxing to teleoperation
  8. Recording, training and running policies
  9. Switching between LeRobot and ROS 2
  10. Teleop safety notes
  11. LeRobot troubleshooting
  12. Enactic’s own `openarm_teleop` (alternative)
  13. Quick reference card

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)

PackagePurpose
openarmMetapackage.
openarm_bringupopenarm.bimanual.launch.py plus controller YAMLs (config/controllers/). This is the “robot without MoveIt” entry point.
openarm_hardwareThe 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_configMoveIt 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)

PackagePurpose
openarm_descriptionURDF/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_canC++ 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/):

ToolWhat it does
openarm-can-cliConfigure CAN, discover motors, enable/disable, monitor, set zero, change IDs/baud, read/write motor params, diagnose.
openarm-can-zero-position-calibrationAutomatic 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-cellSame, for arms mounted in an OpenArm “cell” with a lifter. Not relevant for you.
openarm-can-configure-socketcan-4-armsConfigures 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-checkDiagnostics and demo programs.

3. Robot model: joints, limits, frames

Selecting the model

The launch files pick the xacro only from arm_type:

arm_type valueXacro loaded
v1.0, v10, v1_0, openarm_v1.0, openarm_v10, openarm_v1_0openarm_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_file launch argument exists but is ignored.
  • Both launch files default to v2.0. Always pass arm_type:=v10 for 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)

ArmArm jointsGripper joint
Left (can1)openarm_left_joint1 … openarm_left_joint7openarm_left_finger_joint1
Right (can0)openarm_right_joint1 … openarm_right_joint7openarm_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)

JointLower (rad)Upper (rad)Lower (°)Upper (°)Max vel (rad/s)Max effort (Nm)Motor
joint1−1.3963.491−8020016.7540DM8009
joint2−1.7451.745−10010016.7540DM8009
joint3−1.5711.571−90905.4527DM4340
joint40.02.44301405.4527DM4340
joint5−1.5711.571−909020.947DM4310
joint6−0.7850.785−454520.947DM4310
joint7−1.5711.571−909020.947DM4310
finger_joint10.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 at xyz = 0 ±0.031 0.698 with rpy = ∓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_publisher publishes /tf from /joint_states. If /joint_states is missing (no joint_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:

ComponentPlugin when use_fake_hardware:=truePlugin when falseCAN
openarm_left_hardware_interfacemock_components/GenericSystemopenarm_hardware/OpenArmHWleft_can_interface (default can1)
openarm_right_hardware_interfacemock_components/GenericSystemopenarm_hardware/OpenArmHWright_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 stepBehaviour
on_initOpens 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_configureAsks every motor for its state (refresh_all), reads replies.
on_activateEnables 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_deactivateDisables 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):

j1j2j3j4j5j6j7hand
kp707070601010105
kd2.752.52.02.00.70.60.50.1

Consequences you need to know:

  • No gravity compensation. tau_ff is 0 unless a controller writes the effort interface, 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 kp still 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)

ControllerTypeJointsInterfacesStarted by default?
joint_state_broadcasterJointStateBroadcasterallreads all states → /joint_states, /dynamic_joint_states✅
left_joint_trajectory_controllerJointTrajectoryControllerleft joint1–7cmd: position, state: position✅ (default robot_controller)
right_joint_trajectory_controllerJointTrajectoryControllerright joint1–7same✅
left_gripper_controllerJointTrajectoryControlleropenarm_left_finger_joint1position✅
right_gripper_controllerJointTrajectoryControlleropenarm_right_finger_joint1position✅
left/right_forward_position_controllerForwardCommandControllerjoint1–7positiononly with robot_controller:=forward_position_controller
left/right_forward_velocity_controllerForwardCommandControllerjoint1–7velocitydeclared 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: none. Points are not spline-interpolated between waypoints, so send dense trajectories or a single point with a sensible time_from_start.
  • 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 reports SUCCEEDED when time runs out, even if the real arm didn’t get there (blocked, sagging). Verify with /joint_states or controller_state, not just the action result.

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 like

What 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):

CommandActs onCreates a container?What it does
docker runimageYesCreates 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 startstopped containerNoRestarts 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 execrunning containerNoStarts 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:

FlagWhy
--network hostDDS 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 hostFast DDS shared-memory transport between processes.
--gpus all + __NV_PRIME_RENDER_OFFLOAD=1 + __GLX_VENDOR_LIBRARY_NAME=nvidiaHybrid-graphics laptop: forces RViz onto the NVIDIA GPU instead of failing on Mesa/iris.
-e DISPLAY + X11 socketGUI. Needs xhost +SI:localuser:root on the host.

Extra flags to add for real hardware (see §12):

FlagWhy
--cap-add NET_ADMINOnly 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=-1Lets 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.bash

Deprecation 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.

ArgumentDefaultNotes
arm_typeopenarm_v2.0Use v10.
use_fake_hardwaretruefalse = real motors via CAN.
robot_controllerjoint_trajectory_controlleror forward_position_controller.
right_can_interfacecan0
left_can_interfacecan1
can_fdtruefalse = 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_fileopenarm_bimanual_controllers.yaml
runtime_config_packageopenarm_bringup
description_packageopenarm_description
description_filev20.urdf.xacroIgnored.

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_manager nodes 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:=v10

Healthy 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_DIR not set
  • groups: cannot find name for group ID 992
  • MoveIt “No 3D sensor plugin(s) defined for octomap updates”
  • move_group exiting 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).

Action servers:

ActionType
/left_joint_trajectory_controller/follow_joint_trajectorycontrol_msgs/action/FollowJointTrajectory
/right_joint_trajectory_controller/follow_joint_trajectorysame
/left_gripper_controller/follow_joint_trajectorysame
/right_gripper_controller/follow_joint_trajectorysame

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}}]}}' --feedback

Gripper (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_names is free, but positions must match it.
  • Make time_from_start long enough. With interpolation_method: none the controller ramps linearly from the current state to the first point over that time, so a short time means a fast, violent move on the real robot.
  • Cancel a running goal: Ctrl+C on ros2 action send_goal cancels it. The controller then holds the current position (decelerate_on_cancel: false, so it stops immediately rather than ramping down).
  • SUCCEEDED only 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 link

On 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_controller

Two 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 # real

Planning groups (SRDF config/openarm_v1.0/openarm_bimanual.srdf):

GroupJointsNamed states
left_armopenarm_left_joint1…7home (all 0), hands_up (joint4 = 2.0)
right_armopenarm_right_joint1…7home, hands_up
left_gripperopenarm_left_finger_joint1closed (0), half closed (0.022)
right_gripperopenarm_right_finger_joint1closed, half_closed
  • IK: KDL (kdl_kinematics_plugin), 5 ms timeout. Planner: OMPL.
  • Execution goes to left/right_joint_trajectory_controller via follow_joint_trajectory.

Using RViz:

  1. In the MotionPlanning panel → Planning tab, choose Planning Group (left_arm / right_arm).
  2. Drag the interactive marker (or choose a named Goal State, e.g. hands_up).
  3. Plan, check the ghost trajectory, then Execute (or Plan & Execute).
  4. 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_trajectory to execute an already-planned trajectory.
  • From C++, use moveit::planning_interface::MoveGroupInterface. From Python, use moveit_py (package ros-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:

ArmInterfaceMotor IDs (send → receive)
Rightcan0J1–J7: 0x01…0x07 → 0x11…0x17; gripper 0x08 → 0x18
Leftcan1same 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_configure

Equivalent 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 up

For 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:=false

Notes:

  • By default the interface stays down after a bus-off (--rm 0) so you notice faults. --rm 100 auto-restarts.
  • Interface config is lost on unplug or reboot. Rerun it, or add a systemd/networkd unit.
  • Check it: ip -details -statistics link show can0 should show state 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 traffic

Repeat 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):

CommandPurpose
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 --saveChange CAN IDs (e.g. a replaced motor).
change_baud -c ID -b BAUD --saveChange 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.

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-version defaults to v2, so pass v1 for 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_can Python 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

  1. Motors disabled. Physically place the arm precisely in the zero pose (a jig helps).
  2. openarm-can-cli -i can0 set_zero sets IDs 1–8 at once. Use --id 1,2,3 for specific motors.
  3. 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.
  • can0 = right arm, can1 = left arm, both up in FD mode (§10.4). discover shows 8 motors on each.
  • 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 on can1, see §22). On zeus, always add right_can_interface:=can1 left_can_interface:=can0 to 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:=true

Robot + 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:=true

Expected 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

  1. Watch /joint_states in one terminal: ros2 topic echo /joint_states --field position.

  2. 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}}]}}'
  3. Compare the real arm with RViz. Same direction? Same magnitude?

  4. Go back to zero the same way, then test the other joints and the other arm one by one.

  5. Gripper: 0.022 (half) first, then 0.0 / 0.044.

  6. 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.
  • 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 SUCCEEDED even if the arm didn’t arrive (tolerances disabled).
  • Expect some sag from gravity (no gravity compensation). Don’t “fix” it by raising kp without understanding the motor limits.

12.4 Stopping

  • Stop a motion: Ctrl+C the send_goal command (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.

  • Run the container with --cap-add SYS_NICE --ulimit rtprio=99 --ulimit memlock=-1 so 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/full and 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

  1. Activation moves the robot: arms go from the current pose to all-zero in ~2 s with full gains (return_to_zero() in on_activate). Re-activating a hardware component does it again.
  2. Torque off = arms fall: on_deactivate, openarm-can-cli disable, Ctrl+C in the calibration scripts, and power loss all drop the arms.
  3. 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 × error allows.
  4. No self-collision checking outside MoveIt. Raw trajectory commands can drive the arm into the torso or the other arm.
  5. Forward position controller steps instantly (§8.5).
  6. Velocity “controller” isn’t velocity control (§4, §16).
  7. Mixed-up can0/can1 sends the right arm’s commands to the left arm (§10.3).
  8. Wrong arm_type (default v2.0) loads the wrong kinematics. Always pass arm_type:=v10.
  9. Wrong zero makes “0 rad” a different physical pose. That’s dangerous at activation (§11).

14. Introspection and debugging cheat-sheet

# Controllers & hardware
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.log

15. Troubleshooting

SymptomCause / fix
Only body_link0 / link0 render; “No transform to world” for arm linksNo /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…” foreverWrong action name, e.g. single-arm /joint_trajectory_controller/…. Use /left_… or /right_… (ros2 action list).
Goal rejected / aborted immediatelyJoint names wrong or not all 7 included, or a position is outside the limits.
RViz: Authorization required… could not connect to display :1On the host: xhost +SI:localuser:root (needed again after each login).
RViz: MESA/iris errors, black windowMissing PRIME env vars (__NV_PRIME_RENDER_OFFLOAD=1, __GLX_VENDOR_LIBRARY_NAME=nvidia, NVIDIA_DRIVER_CAPABILITIES=all, --gpus all).
Container gone after logoutIts main process was your shell. docker start openarm.
Arm model looks wrong / v2.0 meshesForgot arm_type:=v10.
OpenArmHW init fails / socket errorcan0/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 / timeoutsWrong 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 itselfBus-off (errors). Check wiring/termination; ip -s -d link show can0; reconfigure; optionally can_configure --rm 100.
Arm jerks / buzzesCM overruns (no RT priority, loaded laptop), or gains too high for the load. Add the RT flags (§12.5).
MoveIt gripper execution failsGripperCommand vs JTC mismatch (§9).
move_group dies with −11 on Ctrl+CKnown MoveIt shutdown crash; harmless.

16. Known issues and gaps in main

  • openarm.repos only lists openarm_can. openarm_description must be cloned separately, and the repo’s .docker/Dockerfile never runs vcs import (you patched both).
  • description_file launch argument is declared but unused. Both launch files default to arm_type=openarm_v2.0.
  • arm_prefix namespacing refers to openarm_bimanual_controllers_namespaced.yaml, which doesn’t exist.
  • MoveIt gripper controllers are GripperCommand but the bringup runs gripper JTCs (§9).
  • openarm_hardware uses the Humble-era on_init(HardwareInfo) signature (deprecation warnings on Jazzy). It doesn’t implement on_shutdown/on_error.
  • return_to_zero() sends the gripper 0.044 as 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_controller is declared but never spawned. Because MIT mode always includes the kp·(q_cmd − q) term and q_cmd keeps its last value, using it alone doesn’t give true velocity control. Don’t use it on hardware without modifying OpenArmHW.
  • No gravity compensation (tau_ff = 0).
  • JTC tolerances all 0, so failures are never detected (§4).
  • The Python calibration tools need the openarm_can Python bindings, which colcon build doesn’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:
    • the CAN interface setup (§10.4),
    • the motor IDs (1–8 send, 0x11–0x18 receive, identical in both),
    • and most importantly the motor zero position (§21).
  • LeRobot runs on the host in its own conda env (lerobot). ROS 2 runs in the openarm container. Both reach can0/can1 because the container uses --network host.

When to use which:

TaskUse
Teleoperation with a leader arm, demonstration recording, imitation learning, running learned policiesLeRobot
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 hardwareROS 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 zeusOpenArm Leader (openarm_leader / bi_openarm_leader)
What it isSmall leader arm, Feetech STS3215 servosA full-size OpenArm with Damiao motors, used passively (torque off)
ConnectionUSB serial → /dev/serial/by-id/…CAN-FD (two more CAN channels)
Mapping to followerIn code: per-side sign flips (SIDE_MOTORS_TO_FLIP), joint 6 ↔ joint 7 swapped (JOINT_REMAP), gripper 0–100 % → 0 … −65°1:1
Force feedbackNoneNone in LeRobot

How the Mini maps to the follower matters when debugging (§27). These are the joints LeRobot negates, per side:

Mini servoside=leftside=rightDrives follower joint
joint_1, 3, 4, 5flippedflippedsame
joint_2—flippedjoint_2
joint_6flipped—joint_7
joint_7flippedflippedjoint_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):

ItemLocation / version
LeRobot source (editable install, shallow clone of main)~/Documents/lerobot (commit b863b42, v0.6.2)
Conda envlerobot, Python 3.12.15, from conda-forge, with ffmpeg
Extrasdamiao (python-can 4.6.1), feetech (feetech-servo-sdk 1.0.0, pyserial), core_scripts (datasets, rerun, foxglove, pynput, av)
PyTorch2.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 hostopenarm-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 check

Your 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):

ToolWhat it doesTouches hardware?
setup_openarm_lerobot.shStart 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.pyGuarded teleop (§23.3). Use it instead of lerobot-teleoperate.Yes: drives the followers, but only after the alignment gate
compare_mini_follower.pyLive table: what each Mini would command vs. where each follower joint is. --once for a snapshot.Read-only, no torque
check_mini_calibration.pyChecks the Mini calibration files match the offsets stored in each Mini, and that the gripper range is validRead-only
fix_mini_gripper_range.pyRecords 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.pyCreates the follower placeholder files so LeRobot never re-zeroes the follower motors (§21)Files only
mini_raw_monitor.pyRaw servo counts of one Mini, liveRead-only

The zeus hardware layout (§22) is the default in every tool, so they need no port arguments.

Optional:

  • hf auth login with 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. Check pyproject.toml for the exact extra names.
  • Updating LeRobot later: cd ~/Documents/lerobot && git pull && pip install -e ".[damiao,feetech,core_scripts]". The clone is shallow; use git fetch --unshallow if you need history.

20. Conventions: LeRobot vs ROS 2

Both stacks read the same raw motor angles, but present them differently.

Names

LeRobot single armLeRobot bimanualROS 2 joint
joint_1.pos … joint_7.posleft_joint_1.pos …, right_joint_1.pos …openarm_left_joint1 …, openarm_right_joint1 …
gripper.posleft_gripper.pos, right_gripper.posopenarm_left_finger_joint1, openarm_right_finger_joint1

Units

QuantityLeRobotROS 2Conversion
Arm jointsdegrees (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.
Gripperdegrees of the gripper motor, 0 = closed, ≈ −60 … −65 = openmetres of finger travel, 0 = closed, 0.044 = openm = 0.044 × deg / (−60) (ROS uses motor −1.0472 rad = −60° ↔ 0.044 m)

Control parameters (MIT mode in both)

LeRobot followerROS 2 OpenArmHW
kp (j1…j7, gripper)240, 240, 240, 240, 24, 31, 25, 2570, 70, 70, 60, 10, 10, 10, 5
kd5, 5, 3, 5, 0.3, 0.3, 0.3, 0.32.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)
Interpolationnone: each action is a new setpointJTC trajectory sampling
Gravity compensationnonenone

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.

JointLeRobot side=leftLeRobot side=rightROS 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_40 … 1350 … 1350 … 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 … 00 … 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):

DeviceWhen calibration runsWhat 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):

  1. Zero each follower arm once with the mechanical-stop script (§11, Option A), or verify the existing zero with openarm-can-cli -i canX monitor while the arm hangs straight down.

  2. 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_follower

    Files 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 the side limits.

  3. Always use the same --robot.id afterwards. A new id means no file, which means LeRobot re-zeroes the motors.

  4. If lerobot-calibrate asks “Press ENTER to use provided calibration file … or type ‘c’”, press ENTER. Typing c re-zeroes the motors.

  5. 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 armFollower busMini port (stable path)
Leftcan0 (PCAN Ch1)/dev/serial/by-id/usb-1a86_USB_Single_Serial_5876043720-if00
Rightcan1 (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_CAN at the top of setup_openarm_lerobot.sh and the DEFAULTS in safe_teleop.py / compare_mini_follower.py, and drop the ROS arguments.
  • Never use /dev/ttyACM0/1 for 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 (or lerobot-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 while compare_mini_follower.py runs: 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 recalibration

It activates the lerobot env itself and stops at the first problem:

  1. Environment: dialout group active, both Minis present, no teleop/record/ROS control running.
  2. CAN: brings can0/can1 up in CAN-FD (1 / 5 Mbit/s) if needed (sudo), then requires 16/16 follower motors to answer.
  3. Follower placeholders: creates my_bimanual_follower_left/right.json if missing. It never re-zeroes motors (§21).
  4. Minis: check_mini_calibration.py; if the files don’t match the Minis (or you ask), a guided lerobot-calibrate --teleop.type=bi_openarm_mini … (left Mini first, then right), then fix_mini_gripper_range.py, then the check again.
  5. 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.py

Then 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 arms

Why 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:

GuardBehaviour
Pre-flightRefuses to start unless the Mini files match the Minis and all 16 follower motors answer.
Alignment gateFollower 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 startStiffness ramps 0 → 100 % over 2 s.
Speed limitThe 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 faultJoint > 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.
ExitCtrl+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.

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=false

Stop 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, use robot.bus.connect(handshake=False) (the handshake sends ENABLE) and call robot.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) adds joint_i.vel (deg/s) and joint_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.push_to_hub=false \
  --display_data=false

Recording uses stock LeRobot, so it has the start-up snap behaviour described in §23.3 (safe_teleop.py doesn’t record). Before every lerobot-record: run compare_mini_follower.py, put the Minis in the followers’ pose (all diffs ≈ 0), keep max_relative_target, and keep --display_data=false. A recording mode for safe_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=true appends to an existing dataset. --dataset.push_to_hub=true uploads (needs hf 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=0

lerobot-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=false

ACT 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 §24.4 onwards. 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 Running a policy: how it works

lerobot-rollout is LeRobot’s (v0.6.2) single command for running a trained policy on the real robot. lerobot-eval is for simulation benchmarks only. Each run does:

load policy (+ its normalisation stats) ──► robot.connect()  ── torque ON, full stiffness (kp 240)
                                           capture start pose ("initial position")
loop at --fps (default 30 Hz):
   observation  = follower joint positions (deg) + camera images      (exactly like recording)
   policy       → action chunk (absolute follower joint targets, deg, incl. grippers)
   robot.send_action(action)   ── clipped to the `side` joint limits and `max_relative_target`
until --duration expires or Ctrl+C:
   return to start pose (linear, 3 s) ──► torque OFF (arms go limp)

What follows from that (all checked in the LeRobot source):

  • The policy only works on the setup it was trained on. Same robot type (bi_openarm_follower), same cabling/side (§22), same camera names, resolutions and fps, same --fps, same use_velocity_and_torque setting, and for language-conditioned policies the same task text. A mismatch is either an error at start-up (missing feature) or, worse, silently wrong behaviour (e.g. cameras swapped). --rename_map='{"observation.images.cam_a": "observation.images.top"}' maps differently named cameras.
  • Use the same --robot.id as for teleop (my_bimanual_follower). A new id has no calibration file, so LeRobot would ask to re-zero the follower motors (§21).
  • There is no soft start. The first policy action goes to the arms unramped (ActionInterpolator only blends from the second action on). If the arms are not where the training episodes started, they snap to the policy’s first target, the same failure mode as the teleop incident. max_relative_target limits each step (but see §23.3 for its side effects); the real protection is starting from the training start pose.
  • One Ctrl+C = graceful stop: the policy stops, the arms move back to the pose they had at start-up over 3 s, then torque goes off. Don’t press Ctrl+C twice: the second one force-exits and can cut the return motion short. Never Ctrl+Z. --return_to_initial_position=false skips the return (the arms drop where they are).
  • The policy runs on the zeus GPU (--device=cuda). Action chunking (ACT: chunk_size=100, n_action_steps=100 by default) means the policy is queried only occasionally; the loop just plays back the chunk.

24.5 Which policy

Policy--policy.typeExtra to installInference on zeus (RTX 5050, 8 GB)Notes
ACTactnone✅ fast, --inference.type=syncBest first choice: 20–50 demos of one task
Diffusiondiffusionpip install -e ".[diffusion]"✅ slower per callSmoother, multimodal behaviour; needs more demos
SmolVLAsmolvlapip install -e ".[smolvla]"⚠ probably fits; use RTCLanguage-conditioned (--task matters); fine-tune from lerobot/smolvla_base
Pi0 / Pi0.5pi0 / pi05pip install -e ".[pi]"❌ too big for 8 GB, use a bigger GPU + async inference (§24.10)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.6 Pre-flight checklist (every policy run)

  1. Base clamped, area clear, someone at the motor power switch. A policy can do anything the arm can do.
  2. bash ~/Documents/openarm_lerobot/setup_openarm_lerobot.sh: CAN up, all 16 motors answering, follower placeholders present. (Its Mini steps are harmless even if you don’t use the Minis.)
  3. Nothing else on the buses: no safe_teleop.py, lerobot-teleoperate/-record, or ROS launch (§25).
  4. Put the arms in the start pose of the training episodes (normally: hanging straight down, grippers closed, like your recordings started), with torque off. Check it with the follower columns of python ~/Documents/openarm_lerobot/compare_mini_follower.py --once (or openarm-can-cli -i can0 monitor / -i can1 monitor).
  5. Cameras plugged in and at the same positions and angles as during recording. Moved cameras are the most common reason a policy that worked yesterday fails today.
  6. First runs: short --duration (20–30 s), max_relative_target set, --display_data=false.

24.7 Autonomous run (no recording): --strategy.type=base

conda activate lerobot
cd ~/Documents/lerobot          # or wherever outputs/ is
lerobot-rollout \
  --strategy.type=base \
  --policy.path=outputs/train/act_openarm_pick_cube/checkpoints/last/pretrained_model \
  --robot.type=bi_openarm_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.id=my_bimanual_follower \
  --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=false
  • --robot.cameras must be identical to the recording command (§24.1).
  • --duration=0 runs until Ctrl+C.
  • --interactive=true: everything connects and warms up, but the robot doesn’t move until you type /start. /stop ends a segment, /reset returns to the start pose, and --duration then limits each /start segment. Useful to check that everything loaded before anything moves.
  • --interpolation_multiplier=2 sends 2 interpolated commands per policy action (60 Hz to the motors, policy still at 30 Hz) for smoother motion.
  • --use_torch_compile=true speeds up inference after a warm-up; not needed for ACT.

24.8 Evaluation episodes with recording: --strategy.type=episodic

Records --dataset.num_episodes policy episodes like lerobot-record does (→ ends an episode, ← redoes it, Esc stops), with a reset phase between episodes, for measuring success rate or adding data:

lerobot-rollout \
  --strategy.type=episodic \
  --policy.path=outputs/train/act_openarm_pick_cube/checkpoints/last/pretrained_model \
  --robot.type=bi_openarm_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.id=my_bimanual_follower \
  --robot.cameras='{top: {type: opencv, index_or_path: /dev/video0, width: 640, height: 480, fps: 30}}' \
  --dataset.repo_id=<hf_user>/eval_act_openarm_pick_cube \
  --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.push_to_hub=false \
  --device=cuda

Without a teleop, the arms are held at their start pose during the reset phase (--strategy.reset_to_initial_position=true, the default) while you reset the scene. With the Minis attached (add the --teleop.* flags from §24.9), you reset the robot by teleoperating it instead, and LeRobot powers the Minis to hand over smoothly (see §24.9 for what that means).

24.9 Human-in-the-loop (DAgger) with the Minis: --strategy.type=dagger

The policy runs; when it is about to fail you pause, take over with the Minis, demonstrate the correction, and hand back. Each correction is saved as an episode (flagged intervention=True); training on them fixes exactly the situations where the policy fails. LeRobot’s own HIL guide (docs/source/hil_data_collection.mdx) uses exactly bi_openarm_follower + bi_openarm_mini.

Run check_mini_calibration.py first: a wrong Mini calibration makes a takeover snap (§23.2).

python ~/Documents/openarm_lerobot/check_mini_calibration.py
lerobot-rollout \
  --strategy.type=dagger \
  --strategy.num_episodes=20 \
  --policy.path=outputs/train/act_openarm_pick_cube/checkpoints/last/pretrained_model \
  --robot.type=bi_openarm_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.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>/hil_openarm_pick_cube \
  --dataset.single_task="Pick up the red cube and place it in the bowl" \
  --dataset.push_to_hub=false \
  --device=cuda

At start the Minis ask “ENTER to use existing calibration”: press Enter.

Key (default)Effect
SpacePause / resume the policy. On pause, the arms hold, and the Minis are powered and driven by LeRobot to the follower pose over 2 s (teleop_smooth_move_to). Hands off the Minis while they move; take hold of them once they stop.
TabStart / stop a correction. On start, the Minis go limp (torque off) 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.
EnterPush the dataset to the Hub (only with push_to_hub).
EscEnd the session (arms return to the start pose, then torque off).

--strategy.record_autonomous=true records the autonomous stretches too (continuous, size-based episodes). --strategy.input_device=pedal uses a USB foot pedal instead of the keyboard (--strategy.pedal.device_path=…). --strategy.smooth_handover=false disables the powered Mini move. Don’t, unless you know why: without it the follower jumps to wherever the Mini is when the correction starts.

The keyboard listener (pynput) needs an X11 session, as for recording.

Then merge the original and the correction dataset and train on the merged one (training on several datasets at once is disabled in v0.6.2: MultiLeRobotDataset isn't supported for now):

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>/hil_openarm_pick_cube']"
lerobot-train --dataset.repo_id=<hf_user>/openarm_pick_cube_plus_hil … (as §24.3, new --output_dir/--job_name)

Repeat rollout → corrections → merge → train until the policy stops needing corrections.

24.10 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 keeps the robot moving smoothly while the next chunk is computed:

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 §24.7 … \
  --task="Pick up the red cube and place it in the bowl" --device=cuda

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). See docs/source/async.mdx for the full flag list and tuning.

24.11 Policy troubleshooting

SymptomCause / fix
Arms snap at startNot in the training start pose (§24.6 step 4). Also check max_relative_target is set.
Error about missing / unexpected features at startCamera names/resolution or use_velocity_and_torque differ from training; use the recording’s exact --robot.cameras, or --rename_map.
LeRobot asks to put the follower “hanging straight down”Different --robot.id from teleop; Ctrl+C and use my_bimanual_follower (§21).
Policy moves but “does nothing sensible”Cameras moved, lighting changed, wrong task text (VLAs), left/right cameras swapped, or simply too few / inconsistent demos. Replay a training episode (lerobot-replay) to check the setup itself.
One joint creeps or stallsmax_relative_target too small for that low-gain joint (see §23.3); raise it a little once the start is safe.
Jerky motionLoop slower than --fps (check the loop-time log), Rerun on, or a slow VLA without RTC. Try --interpolation_multiplier=2.
CUDA out of memoryModel too big for 8 GB: smaller policy, --device=cpu (slow), or async inference on a bigger GPU (§24.10).
DAgger: Minis move on their own when pausingIntended: the smooth handover drives the Minis to the follower pose. Hands off until they stop.
DAgger: follower jumps when a correction startsMini calibration wrong or smooth_handover disabled. Run check_mini_calibration.py; recalibrate (§23.2).

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 → toDo this
ROS 2 → LeRobot1. 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 21. 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 the lerobot env, convert each frame with lerobot_to_ros() (§20), and send it as a JointTrajectory to the ROS mock’s /left|right_joint_trajectory_controller/joint_trajectory topic. The two Python envs differ (conda vs ROS Jazzy), so use a file in between (e.g. export to CSV/NumPy) or run rclpy from 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 a robot_state_publisher running 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 Robot class whose send_action() publishes to the ROS trajectory controllers and get_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:

  1. Clamp the base. An arm snap tipped the whole robot over once.
  2. 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 with compare_mini_follower.py.
  3. Mini zero = the pose at Enter. Calibrate hanging, gripper closed, wrists matching the follower, joint 5 mid-travel (§23.2).
  4. 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*.
  5. Mismatched Mini calibration files are written into the Minis on connect. check_mini_calibration.py (run by the setup script and by safe_teleop.py) catches this.
  6. Silent zeros: a follower motor that doesn’t answer reads as 0° in LeRobot. safe_teleop.py checks replies; stock tools don’t.
  7. side missing → ±5° limits (arm seems stuck); side wrong → mirrored joint_2 limits allow motion into the torso.
  8. Unintended re-zeroing of the followers: a new --robot.id, a deleted cache directory, or c at the follower calibration prompt (§21).
  9. Torque off = arms fall: stock Ctrl+C, openarm-can-cli disable, and diagnose (enables, then disables at the end). safe_teleop.py holds until you press Enter.
  10. Ctrl+Z is not stop. It suspends the process with the buses still open; it resumes commanding on fg. Use Ctrl+C; clean up with kill -9 <pid>.
  11. Rerun can stall the loop (12.8 s freeze, then a catch-up jerk on zeus). Use --display_data=false for teleop/recording, and pkill -f "rerun --port=9876" for leftover viewers.
  12. Stiff gains (kp 240), no force feedback: the operator can’t feel contacts. Start with light, open-space tasks.
  13. Two stacks on one bus (§25).
  14. Policies (lerobot-rollout) start unramped: start every run from the training start pose, keep max_relative_target, press Ctrl+C once (the arms return to the start pose, then torque off). See §24.4–§24.6.

27. LeRobot troubleshooting

SymptomCause / fix
lerobot-…: command not foundconda 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 downThe PCAN adapter was replugged; the interfaces came back unconfigured. Re-run setup_openarm_lerobot.sh.
Motors “No response” / packet drops on all motorsMotor 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 equallerobot-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 invertedMini 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 movemax_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 jerksRerun 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 nothingpynput needs an X session; on Wayland, run from an X11 session or use the terminal prompts.
torch.cuda.is_available() falseRun 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
LanguageC++ (openarm_can + its own dynamics via the URDF)Python
Leader hardwareDamiao leader arms (CAN)OpenArm Mini (Feetech) or Damiao leader
ModesUnilateral (leader → follower) and bilateral (force feedback to the leader); gravity compensation modeUnilateral only
Gravity / friction compensationYes (config/*.yaml: Kp/Kd + tanh friction model)No
Dataset recording / learningNoYes
Default CAN layout (script/launch_*.sh)right: leader can0, follower can2; left: leader can1, follower can3whatever 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):
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
ThingLeftRight
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 jointsopenarm_left_joint1…7openarm_right_joint1…7
Gripper joint (m, 0 closed – 0.044 open)openarm_left_finger_joint1openarm_right_finger_joint1
MoveIt groupsleft_arm, left_gripperright_arm, right_gripper
HW componentopenarm_left_hardware_interfaceopenarm_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

Official docs: https://docs.openarm.dev · Discord: https://discord.gg/FsZaZ4z3We