> ## Documentation Index
> Fetch the complete documentation index at: https://docs.zeroruntime.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# SO-101

> SO-101 arms driven through LeRobot: calibration, units, torque and the servo bus.

A pair of SO-101 arms driven through LeRobot: one that moves, one a human
backdrives.

[Overview](/robotics/integrations/lerobot) covers the adapters and the two hosts. This
page is the arm itself.

<Card title="Runnable example" icon="github" href="https://github.com/ZeroRuntimeAI/zrt-teleops-examples/tree/main/so101" horizontal>
  Both scripts, ready to clone and run.
</Card>

## What you need

* Two SO-101 arms, one you hold and one that moves
* `pip install "zeroruntime-teleops[lerobot]"` on each machine. This extra needs
  Python 3.12 or later, which is stricter than the base package
* [LeRobot](https://huggingface.co/docs/lerobot/installation) installed, for
  calibrating the arms
* A [access token](https://app.videosdk.live/signup), and one room id both
  machines join

`MEETING_ID` in the scripts below is that room id, read from the environment
along with the token. Both machines must use the same one. See
[Run it](#run-it).

## Hardware

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

| Item | Detail |
| - | - |
| Joints | six per arm: `shoulder_pan`, `shoulder_lift`, `elbow_flex`, `wrist_flex`, `wrist_roll`, `gripper`, ids 1 to 6 |
| Servos | Feetech STS3215, one per joint |
| Follower gearing | 1/345 throughout, so it can hold a load |
| Leader gearing | mixed, 1/191 to 1/147, so a person can move it without force |
| Bus | half duplex serial, 1 Mbaud by default, one port per arm |
| Resolution | 4096 ticks per revolution |
| Parts | bill of materials and printable files at [SO-ARM100](https://github.com/TheRobotStudio/SO-ARM100) |

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

```bash theme={null}
python -m zeroruntime.teleops.devices
```

```text theme={null}
serial (2)
  /dev/ttyACM0   platform-3610000.usb-usb-0:2.1:1.0
  /dev/ttyACM1   platform-3610000.usb-usb-0:2.3:1.0
```

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:

```python theme={null}
port = teleop.resolve_serial("platform-3610000.usb-usb-0:2.1:1.0")
```

<Warning>
  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.
</Warning>

<Tip>
  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.
</Tip>

## Calibration

Calibration is LeRobot's, and its
[SO-101 guide](https://huggingface.co/docs/lerobot/so101) 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.

<Steps>
  <Step title="Calibrate the follower">
    ```bash theme={null}
    lerobot-calibrate --robot.type=so101_follower \
        --robot.port=$FOLLOWER --robot.id=my_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.
  </Step>

  <Step title="Calibrate the leader">
    ```bash theme={null}
    lerobot-calibrate --teleop.type=so101_leader \
        --teleop.port=$LEADER --teleop.id=my_leader
    ```

    Same again on the arm a person holds. Note the different flag prefix:
    `--teleop.` here, `--robot.` above.
  </Step>

  <Step title="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.
  </Step>

  <Step title="Pass each id back in">
    Every script gives its arm the id it was calibrated with, and LeRobot loads
    the matching file:

    ```python theme={null}
    SO101FollowerConfig(id="my_follower", port=FOLLOWER)
    SO101LeaderConfig(id="my_leader", port=LEADER)
    ```
  </Step>
</Steps>

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

<Warning>
  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()`](/robotics/api-reference/python/adapters/lerobot#calibration).
</Warning>

<Note>
  Calibrating does not add position limits. `limits()` returns `{}` on these
  adapters, so `slew` is the only thing holding the arm back.
</Note>

<Check>
  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.
</Check>

## Usage

One script per machine. Pass each arm the `id` you calibrated it with.

```python follower.py highlight={8,17,21-22,25-26} theme={null}
import zeroruntime.teleops as teleop
from zeroruntime.teleops import SafetyConfig, FailsafeAction
from zeroruntime.teleops.adapters.lerobot import SO101FollowerAdapter
from lerobot.robots.so_follower import SO101Follower, SO101FollowerConfig
from lerobot.cameras.opencv import OpenCVCameraConfig

CAMERAS = {"wrist": 0, "front": 2}
FOURCC = "MJPG"


def camera(index):
    return OpenCVCameraConfig(index_or_path=index, fps=30,
                              width=640, height=480, fourcc=FOURCC)


arm = SO101Follower(SO101FollowerConfig(
    id="my_follower",
    port=teleop.resolve_serial("platform-3610000.usb-usb-0:2.1:1.0"),
    cameras={n: camera(i) for n, i in CAMERAS.items()}))

safety = SafetyConfig(slew=12.0, watchdog_timeout_s=0.5,
                      on_starvation=FailsafeAction.HOLD)

follower = SO101FollowerAdapter(
    MEETING_ID, robot=arm, safety=safety,
    record="./sessions", cloud_recording=True)


@follower.on_safe_state
def _(state, reason):
    print("safety:", state.value, reason.value if reason else "")


@follower.on_limited
def _(e):
    print(f"held back {e.joint}: asked {e.requested:.1f}, wrote {e.applied:.1f}")


follower.run(hz=30)
```

* **`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"` with `cloud_recording=True`** records as you drive,
  to the cloud (billed). A local copy is kept in `./sessions`, which
  is also where everything goes if the cloud isn't up within 90 s.

```python leader.py highlight={6,15,17-18,20} theme={null}
import zeroruntime.teleops as teleop
from zeroruntime.teleops.adapters.lerobot import SO101LeaderAdapter
from lerobot.teleoperators.so_leader import SO101Leader, SO101LeaderConfig

arm = SO101Leader(SO101LeaderConfig(
    id="my_leader",
    port=teleop.resolve_serial("platform-3610000.usb-usb-0:2.3:1.0")))

leader = SO101LeaderAdapter(MEETING_ID, teleop=arm, sync_buffer_ms=120.0)

@leader.on_control_denied
def _(reason):
    print("denied:", reason)

arm.connect()
leader.start()
if not leader.claim_control(timeout_s=10):
    raise SystemExit("no control: another leader may already be connected")

leader.hold_deadman(True)

for i in leader.ticks(hz=30):
    obs = leader.observation()
```

* **`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.

<Note>
  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.
</Note>

## Run it

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

```bash theme={null}
export ZERORUNTIME_AUTH_TOKEN="your-token-here"
export ZERORUNTIME_MEETING_ID="abcd-efgh-ijkl"

python follower.py     # on the robot machine
python leader.py       # on the operator machine
```

## Output

<CodeGroup>
  ```text follower theme={null}
  robot ready in abcd-efgh-ijkl
    joints  ['elbow_flex', 'gripper', 'shoulder_lift', 'shoulder_pan', 'wrist_flex', 'wrist_roll']
    cameras ['wrist', 'front']
    safety: holding (starvation)
    safety: active
    rejected seq=398: stale
    safety: holding (lease_lost)
    safety: active
    safety: holding (starvation)
  ```

  ```text leader theme={null}
  control granted, schema 42f4c231
    WARNING: 1 joint(s) differ from the follower:
      wrist_flex         -24.31
    the far arm will travel to meet you. check your calibration.
  armed. the far arm is now tracking your leader. ctrl-c to stop.
  ```
</CodeGroup>

* **`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

| On the follower | What it does |
| - | - |
| `estop()` | stop the arm now. It stays stopped until `clear_estop()` |
| `telemetry()` | load and temperature, per servo |
| `stats()` | jitter and overruns, worth watching on a 50 Hz loop |
| `start_episode(task)` | begin recording one demonstration |
| `end_episode(success)` | close it, and say whether it worked |

| On the leader | What it does |
| - | - |
| `claim_control()` | take control. `False` means you did not get it |
| `hold_deadman(True)` | start sending. Let go and the arm holds where it is |
| `arm_when_aligned()` | wait for the two arms to match before sending |
| `observation()` | joints and camera frames from one instant |

The [Follower](/robotics/api-reference/python/follower) and
[Leader](/robotics/api-reference/python/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.

```python theme={null}
SO101FollowerConfig(id="my_follower", port=PORT, use_degrees=True)
SO101LeaderConfig(id="my_leader", port=PORT, use_degrees=True)
```

| Mode | Body joints | Gripper |
| - | - | - |
| `use_degrees=True` *(LeRobot's default)* | degrees, from the middle of each joint's range | `0…100` |
| `use_degrees=False` | `−100…100` across the joint's travel | `0…100` |

Body and gripper do not share units, so one `slew` value means two things. Set
it per joint:

```python theme={null}
safety = teleop.SafetyConfig(slew={"shoulder_pan": 4.0, "gripper": 8.0})
```

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.

<Warning>
  Both machines must pass the same `use_degrees`, and nothing checks it for you.
  Each side declares the same joints either way, so a mismatch reaches the
  follower as a command on the wrong scale. Print the first `read_joints()` on
  each machine and confirm the numbers are the same size before you arm.
</Warning>

`telemetry()` reads load and temperature per servo, and it is slow: twelve round
trips on a bus already busy with position reads and writes.

```python theme={null}
follower = SO101FollowerAdapter(MEETING_ID, robot=arm, telemetry_hz=1.0)

@follower.on_state
def _(state):
    print(state.load, state.temp_c)      # {} until the first read
```

<Warning>
  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.
</Warning>

<Note>
  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.
</Note>

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

## Related

<CardGroup cols={2}>
  <Card title="Overview" icon="box" href="/robotics/integrations/lerobot">
    The two adapters, and the full two-machine setup.
  </Card>

  <Card title="SO-101 on ROS 2" icon="gears" href="/robotics/integrations/ros2/so101">
    The same arm behind your own ROS 2 driver instead.
  </Card>

  <Card title="Safety" icon="shield-halved" href="/robotics/concepts/safety">
    The checks a command passes before it reaches the motors.
  </Card>

  <Card title="Troubleshooting" icon="wrench" href="/robotics/guides/troubleshooting">
    Fix an arm that will not move or arm, and reach support if it persists.
  </Card>
</CardGroup>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.