Part I: The ROS 2 stack · §4 of 43

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; zeus can0)
openarm_right_hardware_interfacemock_components/GenericSystemopenarm_hardware/OpenArmHWright_can_interface (default can0; zeus can1)

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_activateUpstream: enables all motors (torque on), sends one full-stiffness command to 0 (a kick, before it even reads the positions), 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 sent straight to its position. The robot moves on activation. Patched on zeus: see below.
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.

The hardware plugin's lifecycle. Two steps move the arms without any command from you.

stateDiagram-v2
  direction TB
  [*] --> Unconfigured: on_init · open SocketCAN, register motors
  Unconfigured --> Inactive: on_configure · read every motor
  Inactive --> Active: on_activate · ⚠ torque on, joints → 0 rad in ~2 s
  Active --> Active: read() + write() every cycle
  Active --> Inactive: on_deactivate · ⚠ torque off, arms go limp

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.
  • Joints stop a little short of their targets. The torque is only kp × (target − position) (no integral term), so once the error is small, the torque is less than the gearbox friction and the joint stops. On zeus, at the hanging pose: wrist joints (kp 10) stop 0.01–0.02 rad short, and the reported efforts match kp × error (e.g. joint6: 10 × 0.022 ≈ 0.2 Nm). Shoulder and elbow moves that lift the arm stop further short.
  • The driver switches the torque on only once, at start-up. An arm powered after the launch stays limp while ROS believes it’s in control, with stored targets that may be far from the real pose. Power the arms before launching, never while ROS runs.

Start-up on zeus (patched driver)

OpenArmHW::return_to_zero() (called by on_activate) is patched on zeus by two patches in Prandium/openarm_patches/, applied to /root/ros2_ws/src/openarm_ros2 and built (log 2026-10-09):

Upstreamzeus (patches 0001 + 0002)
Reading the start positionfirst sends a full-stiffness command to 0state request only, no command
Move to zero2 s, linear10 s, eased (smoothstep: gentle start and stop), RETURN_TO_ZERO_SECONDS
Grippersent straight to its positionramped from where it is (same target)
Every motor replied?not checked (an unanswered motor reads 0.0)each arm motor and the gripper must report enabled, no error code, else refuse
Far from zero?not checkedany arm joint more than 0.5 rad (≈29°) from zero → refuse (MAX_START_OFFSET)
On refusal—logs which joint, disables the motors, activation fails
Logging—prints every joint’s start position
After the rampcommand buffers left as they wereset to the zero pose, so a re-activation can’t send stale targets

Log lines to expect, per arm (the right arm is activated first, then the left):

[OpenArmHW]: Activating OpenArm V10...
[OpenArmHW]: Returning to zero position...
[OpenArmHW]:   joint1 start: -0.009 rad          (x7, real non-zero numbers)
[OpenArmHW]: Reached zero position                (~10.4 s later)
[OpenArmHW]: OpenArm V10 activated

Refusals look like joint5: no reply, not enabled, or error (code 0x0) (arm not powered or not answering) or joint5 is +1.394 rad from zero (limit 0.50 rad) (wrong zero or badly placed arm), followed by Not moving: … Disabling motors.

Consequences:

  • Start-up takes ~21 s (two 10 s ramps in turn) and the controller spawners wait for it. Some spawners print Failed to acquire lock in 20 seconds. Attempt 1 of 5 failed. Retrying in 3 seconds..., which is normal: they retry. The controllers, and the arms in RViz, appear ~25–30 s after the launch.
  • Both arms must be powered. An unpowered arm now fails the check (by design), so “one arm on a virtual CAN bus” no longer works. To test one arm, power both and command only one.
  • Apply / undo: docker cp the patch into the container, git apply --check then git apply in /root/ros2_ws/src/openarm_ros2, colcon build --symlink-install --packages-select openarm_hardware. Undo: git checkout -- openarm_hardware, rebuild.

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: upstream main sets it to none. With none, there is no motion between points: see the next subsection. On zeus it is changed to splines.
  • 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.

Interpolation (what it does, and the zeus setup)

The trajectory controller runs 750 times a second. Each cycle it asks: “where should each joint be right now?” and writes that to the hardware. interpolation_method decides the answer between the points of your trajectory (source: joint_trajectory_controller/src/trajectory.cpp, Trajectory::sample(), ros2_controllers 4.42 for Jazzy):

Before the first point’s time_from_startBetween point k and point k+1
none (upstream main)holds the position it had when the goal arrivedjumps straight to point k+1 at the start of the segment, then waits there
splines (ROS default, zeus)moves smoothly from the current position to the first pointmoves smoothly from point k to point k+1

The same one-point goal (time_from_start = 5 s) under each setting. The dot traces the joint position; the arm shows the same motion.

interpolation_method: noneupstream main · holds for the whole time_from_start, then jumpsgoal senttime_from_startsplines, positions onlyzeus setting · linear, constant speedgoal senttime_from_startsplines, positions + velocitiescubic · eases in and out (⚠ verify on hardware)goal senttime_from_start

So with none, a one-point goal {positions: [...], time_from_start: {sec: 5}} does nothing for 5 s and then steps to the target. time_from_start only delays the jump; it doesn’t slow it down. On real motors, with the stiff MIT gains, a step is a full-speed move. Upstream disabled interpolation for streaming use (the config comment says the controller “is expected to receive high-frequency commands”), where every command is a tiny step anyway.

What splines does depends on what each point contains:

The points have…InterpolationMotion
positions onlylinearconstant speed; starts and stops abruptly (a velocity step, far milder than a position step)
positions + velocitiescubiceases in and out; use velocities: [0, 0, 0, 0, 0, 0, 0] on the last point for a smooth stop ⚠ verify
positions + velocities + accelerationsquinticsmoothest

The zeus change. interpolation_method is read-only at runtime (ros2 param set can’t change it), so it’s changed in the controller config and the bringup is relaunched. Inside the container, with nothing running:

ros2 pkg prefix openarm_bringup      # must print /root/ros2_ws/install/openarm_bringup, i.e. the file below is the one used
cd /root/ros2_ws/src/openarm_ros2
git status --short                   # note anything already modified
sed -i 's/interpolation_method: "none"/interpolation_method: "splines"/' openarm_bringup/config/controllers/openarm_bimanual_controllers.yaml
git diff --stat                      # expect: 1 file changed, 4 insertions(+), 4 deletions(-)
ls -l /root/ros2_ws/install/openarm_bringup/share/openarm_bringup/config/controllers/openarm_bimanual_controllers.yaml

If the last line shows an arrow (-> /root/ros2_ws/src/...), the install is a symlink to the file you edited and nothing needs rebuilding. If it’s a plain file, rebuild that one package: cd /root/ros2_ws && colcon build --symlink-install --packages-select openarm_bringup.

Check after every launch (all four should print splines):

for c in left_joint_trajectory_controller right_joint_trajectory_controller left_gripper_controller right_gripper_controller; do ros2 param get /$c interpolation_method; done

To undo: git checkout -- openarm_bringup/config/controllers/openarm_bimanual_controllers.yaml. Note that a git pull, git stash or re-clone of openarm_ros2 also undoes it. The MoveIt demo uses a different file (openarm_bimanual_moveit_controllers.yaml) that doesn’t set interpolation_method at all, so it already uses the default, splines.