A pair of SO-101 arms driven through LeRobot: one that moves, one a human backdrives. Overview covers the adapters and the two hosts. This page is the arm itself.

Runnable example

Both scripts, a camera check and a dataset export, ready to clone and run.

Hardware

You need two arms: a follower that moves, and a leader a person holds. They are the same design, built with different motors.

Telling the two arms apart

Both arms plug in through the same USB adapter, and those adapters carry no serial number. Linux cannot tell them apart by name, so it hands out /dev/ttyACM0 and /dev/ttyACM1 in whatever order it happened to find them. That order changes between reboots. What does not change is which socket each arm is plugged into, and Linux publishes that as a by-path name:
The right-hand name is the one to keep. It only changes if you physically move the cable, so it identifies an arm the way a label on the socket would. Resolve it to a device node when you build the config:
Never write /dev/ttyACM0 into a config. If the two swap, the follower opens the leader’s bus and loads a calibration that does not match the servos in front of it. The numbers look fine and the arm moves wrongly, and nothing downstream can catch it.
Note which arm is in which socket once, and confirm it: the follower runs on a 12 V supply and the leader on 5 V, so a multimeter on the two barrel jacks tells you which by-path name is which.

Calibration

Calibration is LeRobot’s, and its SO-101 guide covers assembly, motor setup and the calibration itself. The steps below are what that guide gives you and how the result plugs in here. Do this once per arm. You end up with two files and two ids that every later command refers to.
1

Calibrate the follower

The guide walks you through this: move the arm so every joint sits mid range, press enter, then sweep each joint end to end.
2

Calibrate the leader

Same again on the arm a person holds. Note the different flag prefix: --teleop. here, --robot. above.
3

Keep the two ids

my_follower and my_leader are now the names of those files, under ~/.cache/huggingface/lerobot/calibration/. Followers and leaders are filed separately, so the same name on both would still be two files.
4

Pass each id back in

Every script gives its arm the id it was calibrated with, and LeRobot loads the matching file:
Each machine reads only its own arm’s file, so there is nothing to copy between them. It is also what makes the two grippers line up, since they differ in grip travel by about 19%.
Give each arm its own id. If the follower loads the leader’s file the numbers still look reasonable and the arm moves wrongly, and nothing catches it, since both arms have the same joint names. Check a file against the servos in front of you with verify_against_bus().
Calibrating does not add position limits. limits() returns {} on these adapters, so slew is the only thing holding the arm back.
That is the hardware done. Both arms are wired, named and calibrated, and none of it has needed a network yet.Two scripts from here and they are on the internet, one driving the other from wherever you happen to be.

Usage

One script per machine. Pass each arm the id you calibrated it with.
follower.py
  • id="my_follower" is the id you passed to lerobot-calibrate. LeRobot loads that arm’s calibration by this name, so a typo means the wrong travel, or none at all.
  • FOURCC = "MJPG" is not optional with two cameras on one USB bus. Uncompressed video is about 18 MB per second each, and the second camera fails to open.
  • slew and watchdog_timeout_s are the two that matter on hardware. The first caps how fast a joint may move, the second holds the arm when commands stop arriving.
  • on_starvation=HOLD freezes the arm where it is. TORQUE_OFF is rejected here, since these adapters cannot cut power.
  • record="./sessions" writes the session to disk as you drive.
leader.py
  • id="my_leader" is this arm’s calibration id, filed separately from the follower’s. Giving the leader the follower’s id is the mistake that produces numbers that look right and an arm that moves wrongly.
  • arm.connect() is yours to call. The leader adapter has no connect() hook, so it never opens your bus for you.
  • Check what claim_control() returns. False means you do not have control, and everything after it moves nothing.
  • hold_deadman(True) is what starts commands flowing. Nothing moves before it, and releasing it holds the far arm where it is.
Add max_misalignment=20.0 to the adapter and call leader.arm_when_aligned() instead if you want it to refuse to arm until the two arms match.

Run it

One terminal per machine, both with the same room id.

Output

  • holding (starvation) is the starting state. The arm holds position and applies nothing until a leader takes control.
  • active means commands are arriving and reaching the motors.
  • rejected seq=398: stale is one command that arrived later than max_staleness_ms and was dropped rather than applied late.
  • holding (lease_lost) is the leader disconnecting. The arm holds where it is, it does not fall.
  • wrist_flex -24.31 on the leader is that joint sitting 24 units away from the follower’s. The far arm travels to meet it once armed.

Methods

The Follower and Leader pages list the rest, along with every constructor argument and event.

Units and telemetry

use_degrees on the LeRobot config decides the units you read, send and record. It defaults to True, and both machines must pass the same value.
Body and gripper do not share units, so one slew value means two things. Set it per joint:
In that example 4.0 on a body joint means 4° of travel per tick, which is 200°/s at 50 Hz. The same 4.0 on the gripper would be 4% of its grip, which is why the two are set apart. The limit is per tick, so a slower loop gives a slower arm. Run at 25 Hz instead of 50 and that same 4.0 allows 100°/s. Start low, then raise it until the arm keeps up with you and on_limited stops firing constantly.
use_degrees must match on both machines. If it does not, the leader refuses to command.
telemetry() reads load and temperature per servo, and it is slow: twelve round trips on a bus already busy with position reads and writes.
Leave telemetry_hz at 1.0 unless you have a reason to raise it. Reading these often enough competes with the joint reads that actually drive the arm, and a slow answer from one servo can stall it.
A bad reading can pass the checksum and still be nonsense, such as a shoulder at 150°C beside neighbours at 45 and 72. Those are dropped rather than recorded, so do not build a thermal cut-out on the dataset alone.

Torque

The two arms are powered differently, on purpose. The leader’s motors come up unpowered, so a person can move it by hand. The follower’s stay powered throughout, including after your script exits, because an arm holding a load falls the moment you cut power to it. That is why on_starvation stays on HOLD. When commands stop, the follower freezes where it is rather than going slack.

Overview

The two adapters, and the full two-machine setup.

SO-101 on ROS 2

The same arm behind your own ROS 2 driver instead.

Safety

The checks a command passes before it reaches the motors.

Troubleshooting

Fix an arm that will not move or arm, and reach support if it persists.