OpenArm ROS 2 Guide (v1.0 bimanual, Jazzy, main branch)
Archived version: V1
Kept exactly as it was written. The current guide is V3.1.
Navigation
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):
Repo Commit Date enactic/openarm_ros2(main)b9d7a672026-10-06 enactic/openarm_descriptionc1030412026-10-02 enactic/openarm_canf340d4b(v1.4.0)2026-09-16 huggingface/lerobot(main, host, conda envlerobot)b863b42(v0.6.2)2026-10-08 ROS 2 Jazzy, ros2_control 4.x, MoveIt 2. Everything below was checked against these sources. Where behaviour could not be verified without hardware, it is marked ⚠ verify.
Table of contents
- Big picture
- The packages
- Robot model: joints, limits, frames
- ros2_control layer: hardware, controllers, interfaces
- Environment and container
- Launch files and arguments
- Running in simulation (mock hardware)
- Commanding the robot (sim and real)
- MoveIt 2
- The physical robot: CAN setup
- The physical robot: zero calibration
- The physical robot: bringing it up with ROS 2
- Safety notes specific to this code
- Introspection and debugging cheat-sheet
- Troubleshooting (problems already hit on zeus)
- Known issues and gaps in `main`
Part II: Teleoperation and data collection with Hugging Face LeRobot
- What LeRobot is and where it sits
- Teleop hardware options
- Installation on zeus (done)
- Conventions: LeRobot vs ROS 2
- Calibration and the shared motor zero
- Bus and port layout for the bimanual setup
- Step-by-step: from unboxing to teleoperation
- Recording, replaying, training, deploying
- Switching between LeRobot and ROS 2
- Teleop safety notes
- LeRobot troubleshooting
- Enactic’s own `openarm_teleop` (alternative)
- 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)
| Package | Purpose |
|---|---|
openarm | Metapackage. |
openarm_bringup | openarm.bimanual.launch.py plus controller YAMLs (config/controllers/). This is the “robot without MoveIt” entry point. |
openarm_hardware | The ros2_control plugin openarm_hardware/OpenArmHW that talks to the real motors through openarm_can. Hard-coded for the v1.0 motor layout (works for v2.0 arms too, which share the layout). |
openarm_bimanual_moveit_config | MoveIt 2 config: SRDF, kinematics, joint limits, MoveIt controller mapping, for both openarm_v1.0 and openarm_v2.0 (subfolders in config/). demo.launch.py starts the full stack plus MoveIt and RViz. |
There is no single-arm launch file on main (openarm.launch.py from the v1.0 docs is gone). The only bringup is bimanual.
Dependencies (cloned separately)
| Package | Purpose |
|---|---|
openarm_description | URDF/xacro, meshes, per-version config (joint limits, gains, inertials). Layout: assets/robot/openarm_v1.0/… and assets/robot/openarm_v2.0/…. Not listed in openarm.repos, so you must clone it yourself. |
openarm_can | C++ library (libopenarm_can) for Damiao motors over SocketCAN, plus CLI tools (openarm-can-cli, calibration scripts, demos). Pulled in by vcs import < openarm_ros2/openarm.repos. |
Tools from openarm_can built into the workspace (/root/ros2_ws/install/openarm_can/bin/):
| Tool | What it does |
|---|---|
openarm-can-cli | Configure CAN, discover motors, enable/disable, monitor, set zero, change IDs/baud, read/write motor params, diagnose. |
openarm-can-zero-position-calibration | Automatic zero calibration by bumping into mechanical stops. Python, needs the openarm_can Python module, which is not built in the container yet. |
openarm-can-calibration-in-cell | Same, for arms mounted in an OpenArm “cell” with a lifter. Not relevant for you. |
openarm-can-configure-socketcan-4-arms | Configures can0–can3 in one go. It fails if any of the four is missing, so for two arms use openarm-can-cli can_configure per interface instead. |
openarm-can-health, openarm-can-demo, openarm-can-motor-sampling-check | Diagnostics and demo programs. |
3. Robot model: joints, limits, frames
Selecting the model
The launch files pick the xacro only from arm_type:
arm_type value | Xacro loaded |
|---|---|
v1.0, v10, v1_0, openarm_v1.0, openarm_v10, openarm_v1_0 | openarm_description/assets/robot/openarm_v1.0/urdf/openarm_v10.urdf.xacro |
v2.0, v20, … (the default!) | …/openarm_v2.0/urdf/openarm_v20.urdf.xacro |
- The
description_filelaunch argument exists but is ignored. - Both launch files default to v2.0. Always pass
arm_type:=v10for your arms.
Verify what is loaded:
ros2 param get /robot_state_publisher robot_description | grep -o -m1 "openarm_v1.0/urdf/openarm_v10.urdf.xacro"Joint names (bimanual)
| Arm | Arm joints | Gripper joint |
|---|---|---|
Left (can1) | openarm_left_joint1 … openarm_left_joint7 | openarm_left_finger_joint1 |
Right (can0) | openarm_right_joint1 … openarm_right_joint7 | openarm_right_finger_joint1 |
There are no un-prefixed openarm_joint1…7 names in the bimanual setup. That’s why commands copied from single-arm docs hang with “Waiting for an action server…“.
v1.0 joint limits (openarm_description/assets/robot/openarm_v1.0/config/arm/joint_limits.yaml)
| Joint | Lower (rad) | Upper (rad) | Lower (°) | Upper (°) | Max vel (rad/s) | Max effort (Nm) | Motor |
|---|---|---|---|---|---|---|---|
| joint1 | −1.396 | 3.491 | −80 | 200 | 16.75 | 40 | DM8009 |
| joint2 | −1.745 | 1.745 | −100 | 100 | 16.75 | 40 | DM8009 |
| joint3 | −1.571 | 1.571 | −90 | 90 | 5.45 | 27 | DM4340 |
| joint4 | 0.0 | 2.443 | 0 | 140 | 5.45 | 27 | DM4340 |
| joint5 | −1.571 | 1.571 | −90 | 90 | 20.94 | 7 | DM4310 |
| joint6 | −0.785 | 0.785 | −45 | 45 | 20.94 | 7 | DM4310 |
| joint7 | −1.571 | 1.571 | −90 | 90 | 20.94 | 7 | DM4310 |
| finger_joint1 | 0.0 (closed) | 0.044 m (open) | DM4310 (gripper) |
The gripper joint is prismatic, in metres: 0.0 = closed, 0.044 = fully open. MoveIt’s named states: closed = 0, half closed/half_closed = 0.022.
Note joint4 lower limit is 0: the elbow only bends one way. Commanding a negative joint4 is outside limits.
Frames
world→body_link0(the torso/pillar) →openarm_left_link0/openarm_right_link0, mounted atxyz = 0 ±0.031 0.698withrpy = ∓1.5708 0 0.- The arms are mirrored, so the same joint angle moves the left and right arms in mirrored directions for some joints. Test each arm separately.
robot_state_publisherpublishes/tffrom/joint_states. If/joint_statesis missing (nojoint_state_broadcaster), RViz shows “No transform” for every moving link.
4. ros2_control layer
Hardware components
Two components are declared in openarm_v1.0/urdf/ros2_control/openarm.bimanual.ros2_control.xacro:
| Component | Plugin when use_fake_hardware:=true | Plugin when false | CAN |
|---|---|---|---|
openarm_left_hardware_interface | mock_components/GenericSystem | openarm_hardware/OpenArmHW | left_can_interface (default can1) |
openarm_right_hardware_interface | mock_components/GenericSystem | openarm_hardware/OpenArmHW | right_can_interface (default can0) |
Each joint exports command interfaces position, velocity, effort and state interfaces position, velocity, effort.
What OpenArmHW actually does (real hardware)
From openarm_hardware/src/openarm_simple_hardware.cpp:
| Lifecycle step | Behaviour |
|---|---|
on_init | Opens SocketCAN on can_interface (CAN-FD if can_fd). Registers motors: joints 1–7 = send IDs 0x01…0x07, receive IDs 0x11…0x17, types DM8009, DM8009, DM4340, DM4340, DM4310, DM4310, DM4310. Gripper = send 0x08 / receive 0x18, DM4310. |
on_configure | Asks every motor for its state (refresh_all), reads replies. |
on_activate | Enables all motors (torque on) and then drives every arm joint from wherever it is to 0 rad in a straight joint-space line over ~2 s (200 steps × 10 ms) with the full PD gains. The gripper is commanded too. The robot moves on activation. |
read (every cycle) | Requests and receives motor states. Arm joint state = motor position/velocity/torque directly (no gear or offset conversion). Gripper position is converted from motor radians to metres: joint = 0.044 × motor_rad / (−1.0472). Gripper velocity and effort are reported as 0. |
write (every cycle) | Sends MIT control to every arm motor: {kp_i, kd_i, q_cmd, dq_cmd, tau_cmd} from the position/velocity/effort command interfaces. Gripper: {kp_hand, kd_hand, motor_rad(q_cmd), 0, 0} with motor_rad = q_cmd / 0.044 × (−1.0472). |
on_deactivate | Disables all motors 3×. Torque off: the arms go limp and fall under gravity. |
MIT gains (v1.0 config/arm/control_gains.yaml; override by editing that file):
| j1 | j2 | j3 | j4 | j5 | j6 | j7 | hand | |
|---|---|---|---|---|---|---|---|---|
| kp | 70 | 70 | 70 | 60 | 10 | 10 | 10 | 5 |
| kd | 2.75 | 2.5 | 2.0 | 2.0 | 0.7 | 0.6 | 0.5 | 0.1 |
Consequences you need to know:
- No gravity compensation.
tau_ffis 0 unless a controller writes theeffortinterface, and none of the provided ones do. Expect steady-state sag, especially on joints 1–2 with the arm extended. - All three command interfaces feed one MIT command. Position-only controllers leave velocity and effort at 0, which is fine. A velocity-only controller does not give pure velocity control: the position term with its stiff
kpstill pulls toward the last position command, which starts at 0. See §16. - Joint zero = motor encoder zero. Zero calibration (§11) is what makes “0 rad” in ROS mean the right physical pose.
Controllers (openarm_bringup/config/controllers/openarm_bimanual_controllers.yaml)
| Controller | Type | Joints | Interfaces | Started by default? |
|---|---|---|---|---|
joint_state_broadcaster | JointStateBroadcaster | all | reads all states → /joint_states, /dynamic_joint_states | ✅ |
left_joint_trajectory_controller | JointTrajectoryController | left joint1–7 | cmd: position, state: position | ✅ (default robot_controller) |
right_joint_trajectory_controller | JointTrajectoryController | right joint1–7 | same | ✅ |
left_gripper_controller | JointTrajectoryController | openarm_left_finger_joint1 | position | ✅ |
right_gripper_controller | JointTrajectoryController | openarm_right_finger_joint1 | position | ✅ |
left/right_forward_position_controller | ForwardCommandController | joint1–7 | position | only with robot_controller:=forward_position_controller |
left/right_forward_velocity_controller | ForwardCommandController | joint1–7 | velocity | declared but never spawned |
Controller manager update rate:
- 750 Hz for
openarm_bringup(openarm_bimanual_controllers.yaml). - 100 Hz for the MoveIt demo (
openarm_bimanual_moveit_controllers.yaml).
Trajectory controller settings worth knowing:
interpolation_method: none. Points are not spline-interpolated between waypoints, so send dense trajectories or a single point with a sensibletime_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 reportsSUCCEEDEDwhen time runs out, even if the real arm didn’t get there (blocked, sagging). Verify with/joint_statesorcontroller_state, not just the action result.
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 likeInside every shell:
source /opt/ros/jazzy/setup.bash
source /root/ros2_ws/install/setup.bash(Add both lines to /root/.bashrc and remove the old humble lines.)
Why each flag matters:
| Flag | Why |
|---|---|
--network host | DDS discovery works across host and container. Also required for real hardware: SocketCAN interfaces (can0, can1) live in the host’s network namespace and are only visible in the container with host networking. |
--ipc host | Fast DDS shared-memory transport between processes. |
--gpus all + __NV_PRIME_RENDER_OFFLOAD=1 + __GLX_VENDOR_LIBRARY_NAME=nvidia | Hybrid-graphics laptop: forces RViz onto the NVIDIA GPU instead of failing on Mesa/iris. |
-e DISPLAY + X11 socket | GUI. Needs xhost +SI:localuser:root on the host. |
Extra flags to add for real hardware (see §12):
| Flag | Why |
|---|---|
--cap-add NET_ADMIN | Only if you want to configure can0/can1 from inside the container. Otherwise do it on the host. |
--cap-add SYS_NICE --ulimit rtprio=99 --ulimit memlock=-1 | Lets ros2_control_node use SCHED_FIFO real-time priority (removes the “Could not enable FIFO RT scheduling” warning). Strongly recommended at 750 Hz on real motors. |
Rebuilding the workspace
cd /root/ros2_ws
colcon build --symlink-install
source install/setup.bashDeprecation warnings from openarm_hardware (old on_init(HardwareInfo) signature, written for Humble) are expected and harmless on Jazzy.
6. Launch files and arguments
openarm_bringup/launch/openarm.bimanual.launch.py: robot only
Starts robot_state_publisher, ros2_control_node (controller manager), RViz (openarm_description/rviz/bimanual.rviz), and after 1 s the spawners for joint_state_broadcaster, both arm controllers and both gripper controllers.
| Argument | Default | Notes |
|---|---|---|
arm_type | openarm_v2.0 | Use v10. |
use_fake_hardware | true | false = real motors via CAN. |
robot_controller | joint_trajectory_controller | or forward_position_controller. |
right_can_interface | can0 | |
left_can_interface | can1 | |
can_fd | true | false = classic CAN 2.0 (must match how the motors are configured). |
arm_prefix | "" | Puts everything under a namespace and switches to openarm_bimanual_controllers_namespaced.yaml. That file doesn’t exist on main, so leave this empty. |
controllers_file | openarm_bimanual_controllers.yaml | |
runtime_config_package | openarm_bringup | |
description_package | openarm_description | |
description_file | v20.urdf.xacro | Ignored. |
openarm_bimanual_moveit_config/launch/demo.launch.py: robot + MoveIt
Starts everything above (its own robot_state_publisher and controller manager, at 100 Hz) plus move_group and RViz with the MotionPlanning panel. Same arguments (arm_type, use_fake_hardware, can_fd, CAN interfaces, robot_controller).
Run one or the other, never both at once. Each starts its own controller manager, and two
/controller_managernodes conflict.
Other launch files in the MoveIt package (move_group.launch.py, moveit_rviz.launch.py, spawn_controllers.launch.py, static_virtual_joint_tfs.launch.py, setup_assistant.launch.py) are MoveIt Setup Assistant boilerplate and not needed for normal use.
7. Running in simulation (mock hardware)
# A) Robot only + RViz
ros2 launch openarm_bringup openarm.bimanual.launch.py arm_type:=v10 use_fake_hardware:=true
# B) Robot + MoveIt + RViz MotionPlanning
ros2 launch openarm_bimanual_moveit_config demo.launch.py arm_type:=v10Healthy start (ros2 control list_controllers):
joint_state_broadcaster joint_state_broadcaster/JointStateBroadcaster active
left_joint_trajectory_controller joint_trajectory_controller/JointTrajectoryController active
right_joint_trajectory_controller joint_trajectory_controller/JointTrajectoryController active
left_gripper_controller joint_trajectory_controller/JointTrajectoryController active
right_gripper_controller joint_trajectory_controller/JointTrajectoryController active
mock_components/GenericSystem copies each position command straight into the position state. There is no dynamics, no gravity, no limits enforcement, and motion is exactly the commanded trajectory. Good for checking joint names, frames, planning and your own code, but not for tuning.
Harmless messages you can ignore:
- “unrealistic inertia” on finger links
- controller_manager overrun warnings
- “Could not enable FIFO RT scheduling” (in sim)
XDG_RUNTIME_DIRnot setgroups: cannot find name for group ID 992- MoveIt “No 3D sensor plugin(s) defined for octomap updates”
move_groupexiting with code −11 when you press Ctrl+C (a known MoveIt shutdown crash)
8. Commanding the robot (sim and real)
All of the methods below work the same way with mock and real hardware. On the real robot, start with small, slow motions (§12).
8.1 Trajectory action (recommended)
Action servers:
| Action | Type |
|---|---|
/left_joint_trajectory_controller/follow_joint_trajectory | control_msgs/action/FollowJointTrajectory |
/right_joint_trajectory_controller/follow_joint_trajectory | same |
/left_gripper_controller/follow_joint_trajectory | same |
/right_gripper_controller/follow_joint_trajectory | same |
Left arm, all joints to 0.15 rad in 3 s:
ros2 action send_goal /left_joint_trajectory_controller/follow_joint_trajectory \
control_msgs/action/FollowJointTrajectory \
'{trajectory: {joint_names: [openarm_left_joint1, openarm_left_joint2, openarm_left_joint3, openarm_left_joint4, openarm_left_joint5, openarm_left_joint6, openarm_left_joint7],
points: [{positions: [0.15, 0.15, 0.15, 0.15, 0.15, 0.15, 0.15], time_from_start: {sec: 3}}]}}'Right arm, multi-waypoint (out to the “hands up” pose, then home):
ros2 action send_goal /right_joint_trajectory_controller/follow_joint_trajectory \
control_msgs/action/FollowJointTrajectory \
'{trajectory: {joint_names: [openarm_right_joint1, openarm_right_joint2, openarm_right_joint3, openarm_right_joint4, openarm_right_joint5, openarm_right_joint6, openarm_right_joint7],
points: [
{positions: [0, 0, 0, 1.0, 0, 0, 0], time_from_start: {sec: 3}},
{positions: [0, 0, 0, 2.0, 0, 0, 0], time_from_start: {sec: 6}},
{positions: [0, 0, 0, 0, 0, 0, 0], time_from_start: {sec: 10}}]}}' --feedbackGripper (metres, 0 = closed, 0.044 = open):
# open
ros2 action send_goal /left_gripper_controller/follow_joint_trajectory control_msgs/action/FollowJointTrajectory \
'{trajectory: {joint_names: [openarm_left_finger_joint1], points: [{positions: [0.044], time_from_start: {sec: 1}}]}}'
# close
ros2 action send_goal /left_gripper_controller/follow_joint_trajectory control_msgs/action/FollowJointTrajectory \
'{trajectory: {joint_names: [openarm_left_finger_joint1], points: [{positions: [0.0], time_from_start: {sec: 1}}]}}'Rules:
- Include all 7 joints of the arm (
allow_partial_joints_goal: false). - Order of
joint_namesis free, butpositionsmust match it. - Make
time_from_startlong enough. Withinterpolation_method: nonethe 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_goalcancels it. The controller then holds the current position (decelerate_on_cancel: false, so it stops immediately rather than ramping down). SUCCEEDEDonly means “time ran out” (tolerances are 0 = disabled). Check/joint_states.
8.2 Trajectory topic (fire-and-forget)
Each JTC also listens on /<controller>/joint_trajectory (trajectory_msgs/msg/JointTrajectory):
ros2 topic pub --once /left_joint_trajectory_controller/joint_trajectory trajectory_msgs/msg/JointTrajectory \
'{joint_names: [openarm_left_joint1, openarm_left_joint2, openarm_left_joint3, openarm_left_joint4, openarm_left_joint5, openarm_left_joint6, openarm_left_joint7],
points: [{positions: [0, 0, 0, 0, 0, 0, 0], time_from_start: {sec: 3}}]}'No feedback and no result. A new message replaces the running trajectory. Useful for streaming from a teleop node.
8.3 Python (rclpy) action client
Save as move_arm.py and run with python3 move_arm.py in a sourced shell:
import rclpy
from rclpy.node import Node
from rclpy.action import ActionClient
from control_msgs.action import FollowJointTrajectory
from trajectory_msgs.msg import JointTrajectoryPoint
from builtin_interfaces.msg import Duration
class ArmCommander(Node):
def __init__(self, side: str):
super().__init__(f"{side}_arm_commander")
self.side = side
self.joints = [f"openarm_{side}_joint{i}" for i in range(1, 8)]
self.client = ActionClient(
self, FollowJointTrajectory,
f"/{side}_joint_trajectory_controller/follow_joint_trajectory")
def move(self, waypoints, seconds_per_point=3.0):
"""waypoints: list of 7-element joint position lists (rad)."""
goal = FollowJointTrajectory.Goal()
goal.trajectory.joint_names = self.joints
for k, q in enumerate(waypoints, start=1):
t = seconds_per_point * k
goal.trajectory.points.append(JointTrajectoryPoint(
positions=list(q),
time_from_start=Duration(sec=int(t), nanosec=int((t % 1) * 1e9))))
self.client.wait_for_server()
send_future = self.client.send_goal_async(goal)
rclpy.spin_until_future_complete(self, send_future)
handle = send_future.result()
if not handle.accepted:
self.get_logger().error("Goal rejected")
return False
result_future = handle.get_result_async()
rclpy.spin_until_future_complete(self, result_future)
res = result_future.result().result
self.get_logger().info(f"error_code={res.error_code} {res.error_string}")
return res.error_code == 0
def main():
rclpy.init()
left = ArmCommander("left")
left.move([[0, 0, 0, 0.5, 0, 0, 0],
[0, 0, 0, 0.0, 0, 0, 0]])
left.destroy_node()
rclpy.shutdown()
if __name__ == "__main__":
main()To move both arms at the same time, create two clients and send both goals before waiting on either result.
8.4 Reading state
ros2 topic echo /joint_states # name/position/velocity/effort, all 16 joints
ros2 topic echo /left_joint_trajectory_controller/controller_state # reference vs feedback vs error
ros2 topic echo /dynamic_joint_states # every state interface
ros2 run tf2_ros tf2_echo world openarm_left_link7 # Cartesian pose of a linkOn the real robot, /joint_states effort for arm joints is real motor torque (Nm). For the gripper, velocity and effort are always 0.
8.5 Forward position controller (streaming, advanced)
Launch with robot_controller:=forward_position_controller, then:
ros2 topic pub --once /left_forward_position_controller/commands std_msgs/msg/Float64MultiArray \
'{data: [0, 0, 0, 0.2, 0, 0, 0]}'The motor setpoint jumps to the value instantly: there is no interpolation. With the stiff MIT gains this is a fast, violent step on the real robot. Only use it from your own node streaming small increments at a high, steady rate (e.g. ≥100 Hz, a few mrad per step). Never use it with ros2 topic pub on hardware.
8.6 Switching controllers at runtime
ros2 control list_controllers
ros2 control load_controller left_forward_position_controller --set-state inactive
ros2 control switch_controllers --deactivate left_joint_trajectory_controller --activate left_forward_position_controller
# and back
ros2 control switch_controllers --deactivate left_forward_position_controller --activate left_joint_trajectory_controllerTwo controllers can’t claim the same command interface at once. Always deactivate one and activate the other in a single switch_controllers call.
9. MoveIt 2
ros2 launch openarm_bimanual_moveit_config demo.launch.py arm_type:=v10 # sim
ros2 launch openarm_bimanual_moveit_config demo.launch.py arm_type:=v10 use_fake_hardware:=false # realPlanning groups (SRDF config/openarm_v1.0/openarm_bimanual.srdf):
| Group | Joints | Named states |
|---|---|---|
left_arm | openarm_left_joint1…7 | home (all 0), hands_up (joint4 = 2.0) |
right_arm | openarm_right_joint1…7 | home, hands_up |
left_gripper | openarm_left_finger_joint1 | closed (0), half closed (0.022) |
right_gripper | openarm_right_finger_joint1 | closed, half_closed |
- IK: KDL (
kdl_kinematics_plugin), 5 ms timeout. Planner: OMPL. - Execution goes to
left/right_joint_trajectory_controllerviafollow_joint_trajectory.
Using RViz:
- In the MotionPlanning panel → Planning tab, choose Planning Group (
left_arm/right_arm). - Drag the interactive marker (or choose a named Goal State, e.g.
hands_up). - Plan, check the ghost trajectory, then Execute (or Plan & Execute).
- In the Planning tab, lower Velocity Scaling and Acceleration Scaling (e.g. 0.1) before executing on the real robot.
Programmatic access:
- Action
/move_action(moveit_msgs/action/MoveGroup) to plan and execute. - Action
/execute_trajectoryto execute an already-planned trajectory. - From C++, use
moveit::planning_interface::MoveGroupInterface. From Python, usemoveit_py(packageros-jazzy-moveit-py, needs its own config/launch).
Gripper caveat: MoveIt is configured to drive the grippers as GripperCommand controllers on /left_gripper_controller/gripper_cmd, but the gripper controllers are JointTrajectoryControllers, which only serve follow_joint_trajectory. Arm planning works, but executing a plan for a gripper group from MoveIt will fail. Command the grippers as in §8.1, or change the gripper entries in config/openarm_v1.0/moveit_controllers.yaml to type: FollowJointTrajectory with action_ns: follow_joint_trajectory, then rebuild.
10. The physical robot: CAN setup
10.1 Hardware you need
- Two CAN-FD capable USB-CAN adapters supported by SocketCAN (OpenArm uses gs_usb-class adapters such as CANable 2.0 / candleLight-FD firmware), one per arm.
- Power supply for the arms (per Enactic docs), with an emergency stop / power cut you can reach instantly.
- Bus termination per the OpenArm hardware docs.
Default mapping used by the launch files:
| Arm | Interface | Motor IDs (send → receive) |
|---|---|---|
| Right | can0 | J1–J7: 0x01…0x07 → 0x11…0x17; gripper 0x08 → 0x18 |
| Left | can1 | same IDs on its own bus |
Both arms use the same IDs, which is why each arm must be on its own bus.
10.2 Install the CAN tools on the host
Interface setup has to happen on the host, or in a container with NET_ADMIN. The easiest way to get the tools on the host is Enactic’s PPA:
sudo add-apt-repository -y ppa:openarm/main
sudo apt update
sudo apt install -y libopenarm-can-dev openarm-can-utils can-utils(can-utils gives candump/cansend for low-level debugging.) Alternatively, run the container’s copy: /root/ros2_ws/install/openarm_can/bin/openarm-can-cli. It works from the container because of --network host, but can_configure needs --cap-add NET_ADMIN.
10.3 Identify and name the adapters
USB-CAN adapters enumerate in plug-in order, so can0 and can1 can swap between boots. Swapping them sends left-arm commands to the right arm. Plug in one adapter at a time, or check serials:
ip -details link show type can
udevadm info -a /sys/class/net/can0 | grep -m1 'ATTRS{serial}'Pin the names with a udev rule (on the host), e.g. /etc/udev/rules.d/80-openarm-can.rules:
SUBSYSTEM=="net", ACTION=="add", ATTRS{serial}=="<RIGHT_ADAPTER_SERIAL>", NAME="can0"
SUBSYSTEM=="net", ACTION=="add", ATTRS{serial}=="<LEFT_ADAPTER_SERIAL>", NAME="can1"
Then run sudo udevadm control --reload && sudo udevadm trigger and replug. Label the cables too.
10.4 Bring the interfaces up (CAN-FD, 1 Mbit/s / 5 Mbit/s)
openarm-can-cli -i can0 can_configure # defaults: 1 Mbit/s nominal, 5 Mbit/s data, FD on
openarm-can-cli -i can1 can_configureEquivalent raw command:
sudo ip link set can0 down
sudo ip link set can0 type can bitrate 1000000 dbitrate 5000000 fd on
sudo ip link set can0 upFor classic CAN 2.0 (only if your motors are configured that way):
openarm-can-cli -i can0 can_configure -d 1000000 --no-fd
# and launch with can_fd:=falseNotes:
- By default the interface stays down after a bus-off (
--rm 0) so you notice faults.--rm 100auto-restarts. - Interface config is lost on unplug or reboot. Rerun it, or add a systemd/networkd unit.
- Check it:
ip -details -statistics link show can0should showstate ERROR-ACTIVE,fd on, and no growing error counters.
10.5 Check the motors before ROS
openarm-can-cli -i can0 discover # should list IDs 1–8 (7 joints + gripper)
openarm-can-cli -i can0 monitor # live pos/vel/torque/temp for IDs 1–8 (6 s by default)
openarm-can-cli -i can0 monitor -d 60000 # watch for 60 s; move the arm by hand
openarm-can-cli -i can0 diagnose # status/error codes and temp/torque ranges
openarm-can-cli -i can0 clear_error # clear latched motor errors
candump can0 # raw trafficRepeat for can1. With monitor running, move each joint by hand (motors disabled = free to move) and check that:
- each joint changes the motor you expect (ID n = joint n, ID 8 = gripper);
- the sign makes sense against RViz;
- at the physical zero pose the positions read ≈ 0. If not, see §11.
Other CLI subcommands (use carefully, they change motor firmware settings):
| Command | Purpose |
|---|---|
enable / disable [--id …] | Torque on/off. disable makes the arm fall. |
set_zero [--id …] | Store the current position as the zero for those motors. |
show_param, write_param -c ID -r RID -v VAL [--save] | Read/write internal motor registers. |
change_id -c CUR -s NEW_SLAVE -m NEW_MASTER --save | Change CAN IDs (e.g. a replaced motor). |
change_baud -c ID -b BAUD --save | Change motor bus speed (e.g. to 5 Mbit/s for CAN-FD). |
11. The physical robot: zero calibration
ROS joint angles are raw motor encoder positions, so the motor zero must equal the URDF zero pose. If it doesn’t, RViz won’t match reality, the limits in §3 are wrong, and the 2-second “return to zero” at activation (§4) drives the arm somewhere unexpected and possibly into itself or the torso.
Check first: with motors disabled, put the arm in the URDF zero pose (compare against RViz with all joints at 0) and run openarm-can-cli -i canX monitor. If every joint reads ≈ 0 (within a degree or two), you don’t need to recalibrate.
Option A: automatic (mechanical stops), recommended by Enactic
openarm-can-zero-position-calibration drives each joint gently into its mechanical stop, computes the offset to the ideal zero from the known stop angles (MECH_LIM_V1), and writes the zero to the motors.
openarm-can-zero-position-calibration --robot-version v1 --arm-side right_arm --canport can0
openarm-can-zero-position-calibration --robot-version v1 --arm-side left_arm --canport can1--robot-versiondefaults tov2, so passv1for your arms.- The arm moves on its own. Clear the space and stand by the power cut. Ctrl+C disables the motors (the arm drops).
- It needs the
openarm_canPython module. That’s not built in the container. Either install the PPA’s Python package on the host, if available, or build the bindings:cd /root/ros2_ws/src/openarm_can/python && pip install .(in a venv, after the C++ lib is installed).
Option B: manual
- Motors disabled. Physically place the arm precisely in the zero pose (a jig helps).
openarm-can-cli -i can0 set_zerosets IDs 1–8 at once. Use--id 1,2,3for specific motors.- Verify with
monitor.
Manual zeroing is only as precise as your pose placement. Option A is more repeatable.
Follow the official calibration instructions at https://docs.openarm.dev for anything that differs from this summary. Calibration procedure details have changed between releases.
12. The physical robot: bringing it up with ROS 2
12.1 Pre-flight checklist
- Workspace clear of people and objects within the arms’ full reach (arms can reach ~0.6 m+ from the shoulder in every direction).
- E-stop / power cut reachable by the person at the keyboard.
-
can0= right arm,can1= left arm, both up in FD mode (§10.4).discovershows 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
Robot only:
ros2 launch openarm_bringup openarm.bimanual.launch.py \
arm_type:=v10 use_fake_hardware:=false \
right_can_interface:=can0 left_can_interface:=can1 can_fd:=trueRobot + MoveIt:
ros2 launch openarm_bimanual_moveit_config demo.launch.py \
arm_type:=v10 use_fake_hardware:=false \
right_can_interface:=can0 left_can_interface:=can1 can_fd:=trueExpected log lines from OpenArmHW for each arm:
Configuration: CAN=can0, arm_prefix=right_, hand=enabled, can_fd=enabled
Initializing OpenArm on can0 with CAN-FD enabled...
OpenArm V10 Simple HW initialized successfully
Activating OpenArm V10...
Returning to zero position...
Reached zero position
OpenArm V10 activated
Then check:
ros2 control list_hardware_components # both openarm_*_hardware_interface: active, plugin openarm_hardware/OpenArmHW
ros2 control list_controllers # 5 controllers active
ros2 topic echo /joint_states --once # values ≈ 0, efforts non-zero (holding against gravity)Bring up one arm at a time the first time. There’s no launch argument for that. Either power only one arm, so the other component fails to init and you can debug calmly, or temporarily edit the bimanual ros2_control xacro. Powering one arm is simplest.
12.3 First motions
-
Watch
/joint_statesin one terminal:ros2 topic echo /joint_states --field position. -
Move one joint, a small amount, slowly. Here
joint4(elbow, limit 0…2.44) goes to 0.3 rad over 5 s:ros2 action send_goal /right_joint_trajectory_controller/follow_joint_trajectory control_msgs/action/FollowJointTrajectory \ '{trajectory: {joint_names: [openarm_right_joint1, openarm_right_joint2, openarm_right_joint3, openarm_right_joint4, openarm_right_joint5, openarm_right_joint6, openarm_right_joint7], points: [{positions: [0, 0, 0, 0.3, 0, 0, 0], time_from_start: {sec: 5}}]}}' -
Compare the real arm with RViz. Same direction? Same magnitude?
-
Go back to zero the same way, then test the other joints and the other arm one by one.
-
Gripper:
0.022(half) first, then0.0/0.044. -
Only then use MoveIt, with velocity/acceleration scaling at 0.1 at first.
Rules of thumb on hardware:
- Use
time_from_start≥ 3–5 s per large move until you trust the setup. - Stay inside the joint limits in §3. The trajectory controller and mock don’t stop you. MoveIt respects limits, raw commands don’t.
- Remember goals report
SUCCEEDEDeven if the arm didn’t arrive (tolerances disabled). - Expect some sag from gravity (no gravity compensation). Don’t “fix” it by raising
kpwithout understanding the motor limits.
12.4 Stopping
-
Stop a motion: Ctrl+C the
send_goalcommand (cancel), or send a new goal to the current position. The arm holds position. -
Torque off but keep ROS running: deactivate the hardware component. Support the arm first, it will drop.
ros2 control set_hardware_component_state openarm_right_hardware_interface inactive ros2 control set_hardware_component_state openarm_right_hardware_interface active # re-enable (moves to zero again!) -
Shut down: support the arms, then Ctrl+C the launch. ⚠ verify how shutdown behaves on your hardware. Jazzy’s controller manager may finalize the hardware without calling
on_deactivate. In that case the motors keep their last MIT command until the motor’s own CAN timeout (if one is configured in the motor parameters) or until power is cut. Test this once with the arms supported, and find out which happens before relying on it. -
Emergency: cut motor power. Don’t rely on software.
12.5 Real-time tuning (recommended)
- Run the container with
--cap-add SYS_NICE --ulimit rtprio=99 --ulimit memlock=-1so the controller manager’s RT thread gets SCHED_FIFO priority 50. - Optional: a low-latency or PREEMPT_RT kernel on the host.
- Watch for overruns:
ros2 topic echo /controller_manager/statistics/fulland the launch log. Persistent overruns at 750 Hz mean late CAN frames and jerky motion. A laptop on battery with GPU-heavy RViz is the worst case, so consider plugging in and closing RViz during demanding runs.
13. Safety notes specific to this code
- Activation moves the robot: arms go from the current pose to all-zero in ~2 s with full gains (
return_to_zero()inon_activate). Re-activating a hardware component does it again. - Torque off = arms fall:
on_deactivate,openarm-can-cli disable, Ctrl+C in the calibration scripts, and power loss all drop the arms. - No tracking-error abort: JTC tolerances are all 0 (disabled), so a blocked or colliding arm doesn’t make the controller give up. The motor keeps pushing up to what
kp × errorallows. - No self-collision checking outside MoveIt. Raw trajectory commands can drive the arm into the torso or the other arm.
- Forward position controller steps instantly (§8.5).
- Velocity “controller” isn’t velocity control (§4, §16).
- Mixed-up
can0/can1sends the right arm’s commands to the left arm (§10.3). - Wrong
arm_type(default v2.0) loads the wrong kinematics. Always passarm_type:=v10. - Wrong zero makes “0 rad” a different physical pose. That’s dangerous at activation (§11).
14. Introspection and debugging cheat-sheet
# Controllers & hardware
ros2 control list_controllers
ros2 control list_hardware_components
ros2 control list_hardware_interfaces
ros2 control set_controller_state left_joint_trajectory_controller inactive|active
ros2 control view_controller_chains
# Interfaces
ros2 action list -t
ros2 topic list -t
ros2 action info -t /left_joint_trajectory_controller/follow_joint_trajectory
# State
ros2 topic echo /joint_states --field position
ros2 topic hz /joint_states
ros2 topic echo /left_joint_trajectory_controller/controller_state
ros2 topic echo /controller_manager/statistics/full
# Model
ros2 param get /robot_state_publisher robot_description > /tmp/openarm.urdf
check_urdf /tmp/openarm.urdf # from liburdfdom-tools
ros2 run tf2_tools view_frames # frames.pdf
# Spawner by hand (with timeouts so it doesn't hang)
ros2 run controller_manager spawner joint_state_broadcaster -c /controller_manager --controller-manager-timeout 30
# CAN (host or container with --network host)
ip -details -statistics link show can0
candump -td can0
openarm-can-cli -i can0 monitor
openarm-can-cli -i can0 diagnose
# Logs
ls ~/.ros/log/ # per-launch logs
ros2 launch … 2>&1 | tee ~/launch.log15. Troubleshooting
| Symptom | Cause / fix |
|---|---|
Only body_link0 / link0 render; “No transform to world” for arm links | No /joint_states, so joint_state_broadcaster isn’t running. Check ros2 control list_controllers. |
| Spawner: “Failed to acquire lock in 20 seconds” | Another spawner is holding /tmp/ros2-control-controller-spawner.lock because it’s stuck or left over. Ctrl+C everything, pkill -f controller_manager/spawner; pkill -f ros2_control_node, relaunch. On zeus this was stale state in the old container session and disappeared after the container restarted. (A leftover lock file on its own does no harm; only a live process holds the lock.) |
| ”Waiting for an action server to become available…” forever | Wrong action name, e.g. single-arm /joint_trajectory_controller/…. Use /left_… or /right_… (ros2 action list). |
| Goal rejected / aborted immediately | Joint names wrong or not all 7 included, or a position is outside the limits. |
RViz: Authorization required… could not connect to display :1 | On the host: xhost +SI:localuser:root (needed again after each login). |
| RViz: MESA/iris errors, black window | Missing PRIME env vars (__NV_PRIME_RENDER_OFFLOAD=1, __GLX_VENDOR_LIBRARY_NAME=nvidia, NVIDIA_DRIVER_CAPABILITIES=all, --gpus all). |
| Container gone after logout | Its main process was your shell. docker start openarm. |
| Arm model looks wrong / v2.0 meshes | Forgot arm_type:=v10. |
OpenArmHW init fails / socket error | can0/can1 doesn’t exist or is down in the host namespace; the container isn’t on --network host; or FD mismatch (interface FD on but can_fd:=false, or vice versa). |
| Motors don’t respond / timeouts | Wrong bitrate (check discover, which scans 1/5/8/10 Mbit/s), motor errors (diagnose, clear_error), missing termination, no motor power. |
| Interface went down by itself | Bus-off (errors). Check wiring/termination; ip -s -d link show can0; reconfigure; optionally can_configure --rm 100. |
| Arm jerks / buzzes | CM overruns (no RT priority, loaded laptop), or gains too high for the load. Add the RT flags (§12.5). |
| MoveIt gripper execution fails | GripperCommand vs JTC mismatch (§9). |
move_group dies with −11 on Ctrl+C | Known MoveIt shutdown crash; harmless. |
16. Known issues and gaps in main
openarm.reposonly listsopenarm_can.openarm_descriptionmust be cloned separately, and the repo’s.docker/Dockerfilenever runsvcs import(you patched both).description_filelaunch argument is declared but unused. Both launch files default toarm_type=openarm_v2.0.arm_prefixnamespacing refers toopenarm_bimanual_controllers_namespaced.yaml, which doesn’t exist.- MoveIt gripper controllers are
GripperCommandbut the bringup runs gripper JTCs (§9). openarm_hardwareuses the Humble-eraon_init(HardwareInfo)signature (deprecation warnings on Jazzy). It doesn’t implementon_shutdown/on_error.return_to_zero()sends the gripper0.044as a raw motor angle (no joint→motor conversion), so during activation the gripper goes to ≈ closed, not “open”. After activation, gripper commands are converted correctly.- Gripper joint↔motor mapping is a linear approximation (source comment: “the mappings are approximates”). Gripper velocity/effort states are always 0.
*_forward_velocity_controlleris declared but never spawned. Because MIT mode always includes thekp·(q_cmd − q)term andq_cmdkeeps its last value, using it alone doesn’t give true velocity control. Don’t use it on hardware without modifyingOpenArmHW.- No gravity compensation (
tau_ff = 0). - JTC tolerances all 0, so failures are never detected (§4).
- The Python calibration tools need the
openarm_canPython bindings, whichcolcon builddoesn’t produce.
Part II: Teleoperation and data collection with Hugging Face LeRobot
17. What LeRobot is and where it sits
LeRobot is Hugging Face’s Python framework for real-robot learning. One toolchain covers:
teleoperate → record datasets (with cameras) → share on the Hub → train policies (ACT, Diffusion, SmolVLA, Pi0, …) → run policies on the robot
It has first-class OpenArm support: an openarm_follower robot plus leader/teleoperator devices.
The important thing to understand is that LeRobot doesn’t use ROS 2 at all. It is a second, independent control stack that talks to the very same Damiao motors directly:
┌──────────────── Part I: ROS 2 stack ────────────────┐
your code / MoveIt → │ controllers → OpenArmHW → openarm_can (C++) │ ─┐
└──────────────────────────────────────────────────────┘ │ SocketCAN
├─► can0 / can1 ─► follower arms
┌──────────────── Part II: LeRobot stack ─────────────┐ │ (Damiao, MIT mode)
leader arm(s) ─────► │ Teleoperator.get_action() → Robot.send_action() │ ─┘
policy (NN) ──────► │ DamiaoMotorsBus (python-can) — MIT commands, deg │
cameras ───────────► │ → LeRobotDataset (parquet + mp4) → Hub / training │
└──────────────────────────────────────────────────────┘
Consequences:
- Only one stack may own a follower CAN bus at a time. Both send MIT commands at high rate to the same motor IDs. Running both means two masters fighting over the arm. See §25.
- What you build in Part I (ros2_control, MoveIt, RViz) and Part II (LeRobot teleop, datasets, learned policies) are parallel paths to the same hardware, not layers of one system. They share:
- LeRobot runs on the host in its own conda env (
lerobot). ROS 2 runs in theopenarmcontainer. Both reachcan0/can1because the container uses--network host.
When to use which:
| Task | Use |
|---|---|
| Teleoperation with a leader arm, demonstration recording, imitation learning, running learned policies | LeRobot |
| Motion planning, collision checking, Cartesian goals, scripted trajectories, integration with other ROS nodes (perception, navigation) | ROS 2 / MoveIt |
| Visualising the robot model, checking frames, simulating before hardware | ROS 2 (RViz, mock hardware) |
LeRobot’s own visualisation is Rerun (--display_data=true), which shows joint values and camera streams as time series and images. It doesn’t show a 3D robot model.
18. Teleop hardware options
LeRobot (v0.6.2) ships two different OpenArm teleoperator families. Check which one your “Hugging Face teleop kit” is, because the setup differs:
OpenArm Mini (openarm_mini / bi_openarm_mini) | OpenArm Leader (openarm_leader / bi_openarm_leader) | |
|---|---|---|
| What it is | Small 3D-printed 7-DoF + gripper leader arm, Feetech STS3215 servos (same servos as the SO-101 kit) | A full-size OpenArm with Damiao motors, used passively (torque off) as a leader |
| Connection | USB serial (Feetech bus board) → /dev/ttyACM* | CAN-FD → can2 / can3 (needs two more USB-CAN adapters) |
| Python extra | lerobot[feetech] | lerobot[damiao] |
| Kinematic mapping to follower | Done in code: per-side sign flips (SIDE_MOTORS_TO_FLIP), joint 6 ↔ joint 7 swapped (JOINT_REMAP), gripper 0–100 % → 0 … −65° | 1:1 (same motors, same zero) |
| Force feedback | None (send_feedback writes goal positions, unused in teleop) | None in LeRobot (send_feedback not implemented) |
| Used in LeRobot’s HIL/rollout docs | ✅ (bi_openarm_follower + bi_openarm_mini) |
If your kit has small Feetech servos and a USB board, it’s the OpenArm Mini. If it’s a second pair of full OpenArms with CAN adapters, it’s the OpenArm Leader. Both are installed and documented below. Commands are given for both where they differ.
A third option, Enactic’s openarm_teleop (C++, bilateral with force feedback, needs Damiao leader arms), is covered in §28.
19. Installation on zeus
Already done on the host (not in the container):
| Item | Location / version |
|---|---|
LeRobot source (editable install, shallow clone of main) | ~/Documents/lerobot (commit b863b42, v0.6.2) |
| Conda env | lerobot, Python 3.12.15, from conda-forge, with ffmpeg |
| Extras | damiao (python-can 4.6.1), feetech (feetech-servo-sdk 1.0.0, pyserial), core_scripts (datasets, rerun, foxglove, pynput, av) |
| PyTorch | 2.11.0 + CUDA 13.0, torch.cuda.is_available() == True on the RTX 5050 |
| Helper | ~/Documents/openarm_lerobot/make_follower_calibration.py (§21) |
| Install logs | ~/Documents/lerobot_pip_install.log, ~/Documents/lerobot_pip_install2.log |
| Already on host | openarm-can-utils 1.4.0 (openarm-can-cli, calibration scripts), can-utils |
Exactly what was run, so you can reproduce it:
git clone --depth 1 https://github.com/huggingface/lerobot.git ~/Documents/lerobot
conda create -y -n lerobot -c conda-forge --override-channels python=3.12 ffmpeg
conda activate lerobot
cd ~/Documents/lerobot
pip install -e ".[damiao,feetech,core_scripts]"Use it:
conda activate lerobot # every new terminal
lerobot-info # versions sanity checkStill to do by you, since these need sudo:
sudo usermod -aG dialout $USER # access to /dev/ttyACM* (OpenArm Mini). Log out/in afterwards.CAN interface setup also needs sudo (lerobot-setup-can --mode=setup calls sudo ip link … internally). See §23.
Optional:
hf auth loginwith a write token, to push datasets/models to the Hub.pip install -e ".[training]"or policy-specific extras (e.g.".[smolvla]",".[pi]") when you start training. Checkpyproject.tomlfor the exact extra names.- Updating LeRobot later:
cd ~/Documents/lerobot && git pull && pip install -e ".[damiao,feetech,core_scripts]". The clone is shallow; usegit fetch --unshallowif you need history.
20. Conventions: LeRobot vs ROS 2
Both stacks read the same raw motor angles, but present them differently.
Names
| LeRobot single arm | LeRobot bimanual | ROS 2 joint |
|---|---|---|
joint_1.pos … joint_7.pos | left_joint_1.pos …, right_joint_1.pos … | openarm_left_joint1 …, openarm_right_joint1 … |
gripper.pos | left_gripper.pos, right_gripper.pos | openarm_left_finger_joint1, openarm_right_finger_joint1 |
Units
| Quantity | LeRobot | ROS 2 | Conversion |
|---|---|---|---|
| Arm joints | degrees (motor angle) | radians (motor angle) | rad = deg × π/180. Same zero, same sign, no flips: LeRobot’s follower and OpenArmHW both pass the raw motor angle through. |
| Gripper | degrees of the gripper motor, 0 = closed, ≈ −60 … −65 = open | metres of finger travel, 0 = closed, 0.044 = open | m = 0.044 × deg / (−60) (ROS uses motor −1.0472 rad = −60° ↔ 0.044 m) |
Control parameters (MIT mode in both)
| LeRobot follower | ROS 2 OpenArmHW | |
|---|---|---|
| kp (j1…j7, gripper) | 240, 240, 240, 240, 24, 31, 25, 25 | 70, 70, 70, 60, 10, 10, 10, 5 |
| kd | 5, 5, 3, 5, 0.3, 0.3, 0.3, 0.3 | 2.75, 2.5, 2.0, 2.0, 0.7, 0.6, 0.5, 0.1 |
| Command rate | --fps of the loop (60 Hz teleop default, 30 Hz record default) | 750 Hz (bringup) / 100 Hz (MoveIt demo) |
| Interpolation | none: each action is a new setpoint | JTC trajectory sampling |
| Gravity compensation | none | none |
LeRobot is ~3.4× stiffer on the big joints, which tracks the leader tightly but hits harder on contact. Override with --robot.position_kp='[…8 values…]' and --robot.position_kd='[…]' (single arm), or --robot.left_arm_config.position_kp=… (bimanual).
Joint limits (degrees)
LeRobot clips every commanded position to per-side limits from config_openarm_follower.py. These are only used when side is set. With side unset, the defaults are ±5° (gripper −5…0) and the arm barely moves.
| Joint | LeRobot side=left | LeRobot side=right | ROS v1.0 URDF (both arms) |
|---|---|---|---|
| joint_1 | −75 … 75 | −75 … 75 | −80 … 200 |
| joint_2 | −90 … 9 | −9 … 90 | −100 … 100 |
| joint_3 | −85 … 85 | −85 … 85 | −90 … 90 |
| joint_4 | 0 … 135 | 0 … 135 | 0 … 140 |
| joint_5 | −85 … 85 | −85 … 85 | −90 … 90 |
| joint_6 | −40 … 40 | −40 … 40 | −45 … 45 |
| joint_7 | −80 … 80 | −80 … 80 | −90 … 90 |
| gripper | −65 … 0 | −65 … 0 | 0 … 0.044 m |
The asymmetric joint_2 limits show the arms are mirrored: shoulder abduction away from the body is negative on the left and positive on the right. LeRobot’s limits are slightly tighter than the URDF, a sensible margin from the mechanical stops. Always pass the correct side. Swapping sides with these limits lets joint_2 swing into the torso.
Converting a LeRobot action to a ROS trajectory point
import math
def lerobot_to_ros(action: dict, side: str) -> tuple[list[str], list[float]]:
names = [f"openarm_{side}_joint{i}" for i in range(1, 8)]
q = [math.radians(action[f"joint_{i}.pos"]) for i in range(1, 8)]
gripper_m = 0.044 * action["gripper.pos"] / -60.0
return names + [f"openarm_{side}_finger_joint1"], q + [gripper_m]This is what you need to replay a LeRobot dataset in RViz/MoveIt (see §25).
21. Calibration and the shared motor zero
Read this before running any LeRobot command on the follower arms.
What LeRobot’s calibration does (source: robots/openarm_follower/openarm_follower.py, motors/damiao/damiao.py):
| Device | When calibration runs | What it does to the hardware |
|---|---|---|
Follower (openarm_follower) | lerobot-calibrate, or automatically on connect() if no calibration file exists for that --robot.id (e.g. the first lerobot-teleoperate) | Torque off → asks you to put the arm hanging straight down, gripper closed → sends the Damiao set-zero command (0xFE) to all 8 motors. This rewrites the motors’ zero, which persists. It then saves a JSON file with dummy ranges. |
Damiao leader (openarm_leader) | Same as follower, and additionally on every connect() once calibrated (if self.is_calibrated: self.bus.set_zero_position()) | Re-zeroes the leader motors every time you start teleop. The leader must be hanging/gripper-closed at every start. |
OpenArm Mini (openarm_mini) | Every connect() asks “ENTER to use existing calibration or c to recalibrate” | Writes Feetech homing offsets to the Mini’s own servos (half-turn homing) and records the gripper open/closed range. Only the Mini is affected. |
Why this matters for the ROS stack: the follower’s motor zero is the same zero ROS 2 uses (§11). LeRobot’s zero is a hand-placed “hanging straight down” pose, and that matches the URDF zero (in RViz with all joints at 0 the arms hang straight down). So the convention is compatible, but:
- a hand-placed zero is less precise than Enactic’s mechanical-stop calibration (
openarm-can-zero-position-calibration); - every time someone runs LeRobot calibration on the follower, the ROS zero moves too;
- running it with the arm not properly hanging (e.g. resting on the table) corrupts the zero for both stacks, and the next ROS activation will “return to zero” into the wrong pose.
Recommended procedure (do the zero once, with Enactic’s tool, and stop LeRobot from re-zeroing the followers):
-
Zero each follower arm once with the mechanical-stop script (§11, Option A), or verify the existing zero with
openarm-can-cli -i canX monitorwhile the arm hangs straight down. -
Pre-create LeRobot’s follower calibration files so LeRobot never runs its own zeroing:
conda activate lerobot python ~/Documents/openarm_lerobot/make_follower_calibration.py my_follower # single arm, --robot.id=my_follower python ~/Documents/openarm_lerobot/make_follower_calibration.py my_bimanual_follower --bimanual # --robot.id=my_bimanual_followerFiles go to
~/.cache/huggingface/lerobot/calibration/robots/openarm_follower/<id>.json(bimanual:<id>_left.json,<id>_right.json). They only need to exist: the Damiao bus keeps nothing else from them, and clipping uses thesidelimits. -
Always use the same
--robot.idafterwards. A new id means no file, which means LeRobot re-zeroes the motors. -
If
lerobot-calibrateasks “Press ENTER to use provided calibration file … or type ‘c’”, press ENTER. Typingcre-zeroes the motors. -
Leaders are separate hardware, so let LeRobot calibrate them normally. For the Damiao leader, remember it re-zeroes at every start: start it hanging, gripper closed.
22. Bus and port layout
Pick one mapping and use it everywhere. The ROS launch defaults and LeRobot’s docs example disagree:
| Source | Right follower | Left follower |
|---|---|---|
ROS 2 openarm.bimanual.launch.py defaults | can0 | can1 |
| LeRobot docs bimanual example | can1 | can0 |
Use the ROS convention:
| Device | Interface |
|---|---|
| Right follower | can0 |
| Left follower | can1 |
Right Damiao leader (if openarm_leader) | can2 |
Left Damiao leader (if openarm_leader) | can3 |
Left OpenArm Mini (if openarm_mini) | /dev/ttyACM? (find with lerobot-find-port) |
| Right OpenArm Mini | /dev/ttyACM? |
Pin the names so they survive reboots and replugging:
- CAN adapters: udev rules by serial number (§10.3); extend to
can2/can3for Damiao leaders. - Mini serial ports:
/dev/ttyACM0/1also swap with plug order. Use the stable symlinks in/dev/serial/by-id/…directly as--teleop.left_arm_config.port=/dev/serial/by-id/usb-…, or add a udevSYMLINK+="openarm_mini_left"rule.
Physically label every adapter and cable left/right, leader/follower.
23. Step-by-step teleoperation
Everything below runs on the host, in conda activate lerobot. No ROS launch may be running on the follower buses (§25).
23.1 CAN up and motors visible
lerobot-setup-can --mode=setup --interfaces=can0,can1 # FD 1M/5M; asks for sudo
# (+ can2,can3 if you use Damiao leaders)
lerobot-setup-can --mode=test --interfaces=can0,can1 # expects motors 0x01–0x08 "FOUND" on each--mode=test briefly enables then disables each motor to get a reply. With torque off afterwards, the arm is limp. Equivalent tools from Part I: openarm-can-cli -i can0 can_configure, openarm-can-cli -i can0 discover.
lerobot-setup-can --mode=speed --interfaces=can0 measures the round-trip rate, which is useful if teleop feels laggy.
23.2 Find the Mini’s serial ports (OpenArm Mini only)
lerobot-find-port # unplug/replug when prompted; prints the port
ls -l /dev/serial/by-id/23.3 Follower calibration files (once)
python ~/Documents/openarm_lerobot/make_follower_calibration.py my_follower # single arm
python ~/Documents/openarm_lerobot/make_follower_calibration.py my_bimanual_follower --bimanual # both arms(See §21 for why. If you deliberately want LeRobot’s own zeroing instead, run lerobot-calibrate --robot.type=openarm_follower --robot.port=can0 --robot.side=right --robot.id=my_follower with the arm hanging straight down and the gripper closed, and accept that ROS 2 then uses that zero too.)
23.4 Leader calibration
OpenArm Mini (each arm; follow the prompts: hang down + gripper closed, then close/open the gripper fully):
lerobot-calibrate --teleop.type=openarm_mini --teleop.port=/dev/ttyACM0 --teleop.side=left --teleop.id=mini_left
lerobot-calibrate --teleop.type=openarm_mini --teleop.port=/dev/ttyACM1 --teleop.side=right --teleop.id=mini_rightFor bimanual use, bi_openarm_mini with --teleop.id=my_mini stores my_mini_left / my_mini_right. Calibrate through the bimanual type so the ids match:
lerobot-calibrate --teleop.type=bi_openarm_mini \
--teleop.left_arm_config.port=/dev/ttyACM0 --teleop.left_arm_config.side=left \
--teleop.right_arm_config.port=/dev/ttyACM1 --teleop.right_arm_config.side=right \
--teleop.id=my_miniDamiao leader (arm hanging, gripper closed; re-zeroes the leader):
lerobot-calibrate --teleop.type=openarm_leader --teleop.port=can2 --teleop.id=my_leader_right23.5 First teleop: one arm, rate-limited
Before starting, put the leader and the follower in the same pose (both hanging straight down, grippers closed). The follower jumps to the leader’s pose on the first command (§26).
OpenArm Mini → right follower:
lerobot-teleoperate \
--robot.type=openarm_follower --robot.port=can0 --robot.side=right --robot.id=my_follower \
--robot.max_relative_target=5.0 \
--teleop.type=openarm_mini --teleop.port=/dev/ttyACM1 --teleop.side=right --teleop.id=mini_right \
--fps=60 --display_data=trueDamiao leader → right follower:
lerobot-teleoperate \
--robot.type=openarm_follower --robot.port=can0 --robot.side=right --robot.id=my_follower \
--robot.max_relative_target=5.0 \
--teleop.type=openarm_leader --teleop.port=can2 --teleop.id=my_leader_right \
--fps=60 --display_data=true--robot.max_relative_target=5.0caps each step to 5° from the current follower position. At 60 Hz that’s ≤ 300°/s, still fast, but it prevents single-frame jumps. Lower it (2–3) for the first sessions, then raise or remove it once you trust the setup.--display_data=trueopens Rerun with live joint plots.- Stop with Ctrl+C. On disconnect the follower disables torque and falls (
disable_torque_on_disconnect=True), so lower the leader to the hanging pose first so the follower is already hanging.
23.6 Bimanual teleop
OpenArm Mini kit:
lerobot-teleoperate \
--robot.type=bi_openarm_follower \
--robot.left_arm_config.port=can1 --robot.left_arm_config.side=left --robot.left_arm_config.max_relative_target=5.0 \
--robot.right_arm_config.port=can0 --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/ttyACM0 --teleop.left_arm_config.side=left \
--teleop.right_arm_config.port=/dev/ttyACM1 --teleop.right_arm_config.side=right \
--teleop.id=my_mini \
--fps=30 --display_data=falseDamiao leaders:
lerobot-teleoperate \
--robot.type=bi_openarm_follower \
--robot.left_arm_config.port=can1 --robot.left_arm_config.side=left --robot.left_arm_config.max_relative_target=5.0 \
--robot.right_arm_config.port=can0 --robot.right_arm_config.side=right --robot.right_arm_config.max_relative_target=5.0 \
--robot.id=my_bimanual_follower \
--teleop.type=bi_openarm_leader \
--teleop.left_arm_config.port=can3 --teleop.right_arm_config.port=can2 \
--teleop.id=my_bimanual_leader \
--fps=30 --display_data=false(Note: the bimanual follower config takes side per arm. Set it, or you get ±5° limits.)
23.7 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.8 Python API (for your own loops)
from lerobot.robots.openarm_follower import OpenArmFollower, OpenArmFollowerConfig
from lerobot.teleoperators.openarm_mini import OpenArmMini, OpenArmMiniConfig
import time
robot = OpenArmFollower(OpenArmFollowerConfig(port="can0", side="right", id="my_follower",
max_relative_target=5.0))
teleop = OpenArmMini(OpenArmMiniConfig(port="/dev/ttyACM1", side="right", id="mini_right"))
robot.connect() # file exists (§21) → no zeroing; torque ON
teleop.connect() # prompts: ENTER to keep calibration
try:
while True:
action = teleop.get_action() # {"joint_1.pos": deg, ..., "gripper.pos": deg}
sent = robot.send_action(action) # clipped to side limits + max_relative_target
obs = robot.get_observation() # {"joint_1.pos": deg, ...} (+ cameras)
time.sleep(1 / 60)
except KeyboardInterrupt:
pass
finally:
teleop.disconnect()
robot.disconnect() # torque OFF → arm falls; lower it firstOpenArmFollowerConfig(use_velocity_and_torque=True) adds joint_i.vel (deg/s) and joint_i.torque (Nm) to observations, and also to the dataset if you record.
24. Recording, replaying, training, deploying
Record a dataset
lerobot-record \
--robot.type=bi_openarm_follower \
--robot.left_arm_config.port=can1 --robot.left_arm_config.side=left \
--robot.right_arm_config.port=can0 --robot.right_arm_config.side=right \
--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/ttyACM0 --teleop.left_arm_config.side=left \
--teleop.right_arm_config.port=/dev/ttyACM1 --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=trueThe docs page you pasted uses short flags (--repo-id, --num-episodes, --fps). The v0.6.2 CLI expects the --dataset.* names above.
- Data goes to
~/.cache/huggingface/lerobot/<repo_id>/(override with--dataset.root=…): parquet for joint data, mp4 per camera. - Keyboard during recording (needs an X session;
pynput): → ends the episode early, ← re-records it, Esc stops. --resume=trueappends to an existing dataset.--dataset.push_to_hub=trueuploads (needshf auth login).- Recorded observation = follower joint positions in degrees (+ images). Action = the (clipped) command sent to the follower, i.e. the leader’s pose.
Inspect / replay
lerobot-dataset-viz --repo-id <hf_user>/openarm_pick_cube --episode-index 0 # Rerun viewer
lerobot-replay --robot.type=bi_openarm_follower … --dataset.repo_id=<hf_user>/openarm_pick_cube --dataset.episode=0lerobot-replay drives the real follower through the recorded actions. Same safety rules as teleop: start pose matched, max_relative_target set.
Train (on the RTX 5050 or a bigger machine)
lerobot-train \
--dataset.repo_id=<hf_user>/openarm_pick_cube \
--policy.type=act \
--output_dir=outputs/train/act_openarm_pick_cube \
--job_name=act_openarm_pick_cube \
--policy.device=cuda \
--policy.push_to_hub=falseACT is a good first policy for ~20–50 demos. Diffusion, SmolVLA and Pi0 need their extras and much more GPU memory. An 8 GB laptop GPU is tight for anything beyond ACT/Diffusion with small batch sizes.
Deploy a policy
lerobot-rollout runs a trained policy on the robot. With --strategy.type=dagger and a teleop device attached, a human can take over mid-episode (human-in-the-loop collection). See docs/source/hil_data_collection.mdx in the LeRobot repo, which uses exactly bi_openarm_follower + bi_openarm_mini.
25. Switching between LeRobot and ROS 2
Never run both stacks on the same follower bus. There is no lock between them. Both would send MIT commands to motors 1–8 and the arm would follow whichever frame arrived last.
| Going from → to | Do this |
|---|---|
| ROS 2 → LeRobot | 1. Move the arms to the hanging pose (home / all zeros) with ROS. 2. Support the arms. 3. Ctrl+C the ROS launch (or set_hardware_component_state … inactive). 4. Check ps aux | grep ros2_control_node in the container shows nothing. 5. Start LeRobot. |
| LeRobot → ROS 2 | 1. Lower the leader so the follower hangs. 2. Ctrl+C LeRobot (torque off → arms limp; support them). 3. Launch ROS with use_fake_hardware:=false. 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 right armWays to combine them (not built yet)
- Replay LeRobot demos in RViz/MoveIt: load a dataset (
LeRobotDataset) in thelerobotenv, convert each frame withlerobot_to_ros()(§20), and send it as aJointTrajectoryto the ROS mock’s/left|right_joint_trajectory_controller/joint_trajectorytopic. The two Python envs differ (conda vs ROS Jazzy), so use a file in between (e.g. export to CSV/NumPy) or runrclpyfrom the ROS container on an exported file. - Live view of LeRobot teleop in RViz: a small bridge that publishes the follower observation as
sensor_msgs/JointState(radians/metres) to arobot_state_publisherrunning without ros2_control. Read-only, so it doesn’t conflict on the bus as long as the bridge gets its data from LeRobot, not from CAN. - LeRobot policy through ROS: write a custom LeRobot
Robotclass whosesend_action()publishes to the ROS trajectory controllers andget_observation()subscribes to/joint_states. Then ROS owns the bus, and LeRobot benefits from ROS-side safety (limits, MoveIt collision checks). This is the cleanest long-term integration, but it’s real work.
26. Teleop safety notes
Everything in §13 still applies. These are specific to LeRobot:
- First-frame jump:
connect()enables torque, and the firstsend_action()commands the leader’s pose with kp = 240 and no ramp. Start with leader and follower in the same pose and always usemax_relative_targetuntil you trust the setup. - Missing
side: limits fall back to ±5°, so the arm seems stuck. Wrongside: mirroredjoint_2limits allow motion into the torso. - Unintended re-zeroing: a new
--robot.id, a deleted cache directory, or typingcat the calibration prompt re-zeroes the follower motors, for both stacks (§21). - Disconnect = torque off = arms fall. Lower the arms via the leader before Ctrl+C, or catch them.
- Stiff gains (kp 240) + no force feedback: the operator can’t feel contacts. Collisions are taken at full stiffness. Start with light, open-space tasks.
- Leader/follower swapped buses or ports: commands go to the wrong arm. Label everything; pin names (§22).
- Loop timing: if the loop can’t hold
--fps(cameras, Rerun, CPU load), commands arrive late and motion gets jerky. Watch the loop frequency LeRobot prints; reduce camera resolution or disable--display_dataif needed. - Two stacks on one bus (§25).
27. LeRobot troubleshooting
| Symptom | Cause / fix |
|---|---|
lerobot-…: command not found | conda activate lerobot. |
Permission denied: '/dev/ttyACM0' | Not in dialout: sudo usermod -aG dialout $USER, log out/in. |
lerobot-setup-can --mode=test: “Interface is not UP” | Run --mode=setup (needs sudo), or openarm-can-cli -i can0 can_configure. |
| Motors “No response” | Wrong bitrate/FD mode, motor power off, wiring/termination, or ROS still owns the bus. openarm-can-cli -i can0 discover scans bitrates. |
OSError: [Errno 19] No such device on connect | CAN interface doesn’t exist (adapter unplugged / renamed). ip link show type can. |
| Arm barely moves (≈ 5°) | side not set. |
| LeRobot asks to position the arm “hanging straight down” | No calibration file for that --robot.id. Stop (Ctrl+C) and create it with the helper (§21) unless you really want to re-zero. |
| Follower jerks / lags | Lower --fps, fewer/smaller cameras, --display_data=false; check lerobot-setup-can --mode=speed. |
| Mini joints move the wrong way | Wrong --teleop.side; recalibrate the Mini with the arm truly hanging and gripper closed. |
| Damiao leader readings jump at start | It re-zeroes at every connect; start it hanging, gripper closed. |
| Record keyboard shortcuts do nothing | pynput needs an X session; on Wayland, run from an X11 session or use the terminal prompts. |
torch.cuda.is_available() false | Run from the lerobot env; driver ≥ 580 for CUDA 13 wheels (zeus has 595). |
28. Enactic’s own openarm_teleop
~/Documents/openarm_teleop is a clone of enactic/openarm_teleop (commit eb2d493). It’s a separate C++ alternative to LeRobot teleop:
openarm_teleop (Enactic) | LeRobot | |
|---|---|---|
| Language | C++ (openarm_can + its own dynamics via the URDF) | Python |
| Leader hardware | Damiao leader arms (CAN) | OpenArm Mini (Feetech) or Damiao leader |
| Modes | Unilateral (leader → follower) and bilateral (force feedback to the leader); gravity compensation mode | Unilateral only |
| Gravity / friction compensation | Yes (config/*.yaml: Kp/Kd + tanh friction model) | No |
| Dataset recording / learning | No | Yes |
Default CAN layout (script/launch_*.sh) | right: leader can0, follower can2; left: leader can1, follower can3 | whatever you pass |
It doesn’t support the OpenArm Mini. Its scripts also expect ~/openarm_ros2_ws/src/openarm_description/urdf/robot/v10.urdf.xacro, a path from the old openarm_description layout that no longer exists in the current version (assets/robot/openarm_v1.0/urdf/openarm_v10.urdf.xacro). Using it requires adapting WS_DIR/XACRO_PATH in script/launch_*.sh and building the binaries. There’s an untracked Dockerfile in that directory, which seems to be your own.
Use it if you have Damiao leaders and want force feedback or gravity compensation. Use LeRobot for data collection and learning.
29. Quick reference card
# ---------- host, once per login ----------
xhost +SI:localuser:root
docker start openarm
docker exec -it openarm bash
# ---------- in container ----------
source /opt/ros/jazzy/setup.bash && source /root/ros2_ws/install/setup.bash
# sim
ros2 launch openarm_bringup openarm.bimanual.launch.py arm_type:=v10 use_fake_hardware:=true
ros2 launch openarm_bimanual_moveit_config demo.launch.py arm_type:=v10
# real (after CAN up + zero verified + area clear + E-stop in hand)
openarm-can-cli -i can0 can_configure && openarm-can-cli -i can1 can_configure # host
openarm-can-cli -i can0 discover && openarm-can-cli -i can1 discover
ros2 launch openarm_bringup openarm.bimanual.launch.py arm_type:=v10 use_fake_hardware:=false
ros2 launch openarm_bimanual_moveit_config demo.launch.py arm_type:=v10 use_fake_hardware:=false
# 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) ----------
conda activate lerobot
lerobot-setup-can --mode=setup --interfaces=can0,can1
lerobot-setup-can --mode=test --interfaces=can0,can1
python ~/Documents/openarm_lerobot/make_follower_calibration.py my_bimanual_follower --bimanual # once
lerobot-teleoperate \
--robot.type=bi_openarm_follower \
--robot.left_arm_config.port=can1 --robot.left_arm_config.side=left --robot.left_arm_config.max_relative_target=5.0 \
--robot.right_arm_config.port=can0 --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/ttyACM0 --teleop.left_arm_config.side=left \
--teleop.right_arm_config.port=/dev/ttyACM1 --teleop.right_arm_config.side=right \
--teleop.id=my_mini --display_data=true| Thing | Left | Right |
|---|---|---|
| CAN bus | can1 | can0 |
| Arm action | /left_joint_trajectory_controller/follow_joint_trajectory | /right_joint_trajectory_controller/follow_joint_trajectory |
| Gripper action | /left_gripper_controller/follow_joint_trajectory | /right_gripper_controller/follow_joint_trajectory |
| Arm joints | openarm_left_joint1…7 | openarm_right_joint1…7 |
| Gripper joint (m, 0 closed – 0.044 open) | openarm_left_finger_joint1 | openarm_right_finger_joint1 |
| MoveIt groups | left_arm, left_gripper | right_arm, right_gripper |
| HW component | openarm_left_hardware_interface | openarm_right_hardware_interface |
| LeRobot keys (bimanual) | left_joint_1.pos … left_gripper.pos (deg) | right_joint_1.pos … right_gripper.pos (deg) |
| LeRobot follower calibration file | …/robots/openarm_follower/<id>_left.json | …/robots/openarm_follower/<id>_right.json |
| Damiao leader bus (if used) | can3 | can2 |
Official docs: https://docs.openarm.dev · Discord: https://discord.gg/FsZaZ4z3We