The AY-Robots SO-100 hub page, the starting point for building, calibrating and recording with an SO-100 arm
SO-100HardwareCalibrationLeRobotTroubleshooting

SO-100 Build Mistakes That Cost a Weekend

AY-Robots ResearchAugust 23, 202619 min read

Servo ids, horn angle, cable routing and the 7.4 V rule: the SO-100 build errors nobody catches on the bench, and how each one turns into a dataset that trains a bad policy.

An SO-100 built wrong usually still works. That is the problem. The arm powers up, teleoperation feels smooth, the cameras appear in the recorder, and you spend a Saturday collecting fifty episodes. The build error surfaces four days later, when the policy you fine-tuned closes on air two centimetres left of the cube.

Four decisions on a SO-100 are invisible on the bench and expensive in the dataset: servo ids, horn angle, cable routing, and matching the supply to the servo variant. Checked against LeRobot main (release v0.6.1, 3 August 2026) and the SO-ARM100 repository as it stands today.

What you need to know

  • Every STS3215 ships with id 1. Daisy-chain before lerobot-setup-motors and nothing on the bus can be addressed.
  • lerobot gives the sts3215 4096 counts per turn and Waveshare's ST3215 page lists mechanical limited angle: no. The horn angle decides where the 0/4095 seam falls inside the joint's travel.
  • A seam inside the travel writes a silently wrong calibration file, and it does not raise. Under the default use_degrees=True every sample of that joint is offset. Only the gripper is clamped. See lerobot issues 3586 and 3587.
  • The SO-100 is a 7.4 V arm. The 12 V STS3215 is a different part with its own supply. Do not cross them.
  • The cheap fix is a 30 minute acceptance pass before recording.

What actually breaks, and when you find out

Ranked by cost, not likelihood. The second is the weekend-eater: it gives no signal until a training run has already finished.

Build mistakeFirst visible symptomWhen you noticeCost to undo
Ids never set, or set after daisy-chainingsetup-motors reports the motor was not foundBefore the first teleop session30 minutes, the cheapest failure available
Horn fitted at an arbitrary angleNone. Teleoperation feels correct.After training, as a constant offsetTeardown of that joint plus a re-record
Wrong supply for the servo variantMotors do not answer a ping, or drop outImmediately, or as a flaky fault laterNew servos
Cable crossing a rotation axisIntermittent 'no status packet' while recordingMid-recording, as frozen jointsOne joint apart, plus the lost episodes
No recalibration after a repairJoint stops early, or the arm snaps at connectFirst teleop after the repairDelete the calibration file, redo it

Mistake 1: servo ids, and the order they have to be set in

The six servos share one half-duplex serial bus, addressed by id, and every STS3215 leaves the factory as id 1. Until each has a distinct id, six devices answer to one address. The SO-100 assembly guide configures motors before assembly, because once the arm is together the connectors are unreachable.

bash
# install lerobot with the Feetech SDK
pip install -e ".[feetech]"

# find which serial port belongs to which arm
lerobot-find-port
# Finding all available ports for the MotorBus.
# ['/dev/tty.usbmodem575E0032081', '/dev/tty.usbmodem575E0031751']
# Remove the USB cable from your MotorsBus and press Enter when done.
lerobot-find-port asks you to unplug one arm so it can tell the two ports apart.

The id map is fixed in the source, not chosen by you. so_follower.py declares shoulder_pan on 1 through gripper on 6, and the leader uses the same names. The setup script walks the list in reverse, gripper first, shoulder_pan last.

JointMotor idHow its range is set during calibrationValue the policy sees
shoulder_pan1Swept by handDegrees (use_degrees defaults to True)
shoulder_lift2Swept by handDegrees
elbow_flex3Swept by handDegrees
wrist_flex4Swept by handDegrees
wrist_roll5Not swept. Forced to the full 0 to 4095 turn.Degrees
gripper6Swept open and closed0 to 100
  1. 1
    Connect exactly one motor

    Power the board, plug in USB, and run one 3-pin cable to the gripper motor only. It must not be chained to another motor at the far end.

    bash
    lerobot-setup-motors \
        --robot.type=so100_follower \
        --robot.port=/dev/tty.usbmodem585A0076841
  2. 2
    Answer the prompt

    Press Enter and the script writes the id and 1 Mbps baud rate into that motor's EEPROM, then moves on.

    text
    Connect the controller board to the 'gripper' motor only and press enter.
    'gripper' motor id set to 6
    Connect the controller board to the 'wrist_roll' motor only and press enter.
  3. 3
    Repeat down to shoulder_pan

    Move the board cable to the next motor each time. Finding a motor sweeps the STS/SMS baud table, 1000000 down to 19200, so a servo repurposed from another robot is still found.

  4. 4
    Do the leader with the teleop flags

    The leader takes --teleop.type and --teleop.port instead of --robot.*. Same procedure, same id order.

    bash
    lerobot-setup-motors \
        --teleop.type=so100_leader \
        --teleop.port=/dev/tty.usbmodem575E0031751
  5. 5
    Only now chain them

    Plug each motor into the next, and shoulder_pan (id 1) into the board. The ids live in EEPROM, so this never repeats.

One motor at a time means one motor at a time

If a second motor is still chained beyond the one you are programming, both answer as id 1 and the write lands where you did not intend. The error is RuntimeError: Motor 'gripper' (model 'sts3215') was not found. On a Waveshare board, check both jumpers sit on the B channel and the power barrel has not fallen out. See servo not responding.

The AY-Robots failure-mode index, listing pages such as arm not detected, servo not responding, joint stops early and gripper does not close
The two pages that callout points at live in the index, organised by what you observed.

Mistake 2: the horn angle, and why it only shows up after training

This is the expensive one. Waveshare's wiki page for the ST3215, the 12 V sibling of the SO-100's servo, lists a 360 degree magnetic encoder at 360/4096 resolution and, under mechanical limited angle, says: no. lerobot's model table gives the sts3215 the same 4096 counts. What stops your joint is the printed link bolted to the horn hitting another printed part, so the horn angle when you tighten the M3 screw decides where the 0/4095 seam falls inside that travel.

The LeRobot SO-100 page hints at it at step 6: install both horns, and try not to move the motor position while attaching them, especially on the leader arms where the gears were removed. That reads like a tidiness note. Here is what calibration does with the position you leave behind.

python
# lerobot/motors/motors_bus.py - set_half_turn_homings docstring
# "The function computes and writes a homing offset such that the present
#  position becomes exactly one half-turn (e.g. 2047 on a 12-bit encoder)."

# lerobot/motors/feetech/feetech.py - _get_half_turn_homings does the arithmetic
max_res = self.model_resolution_table[model] - 1   # 4096 - 1 = 4095 for sts3215
half_turn_homings[motor] = pos - int(max_res / 2)  # int(4095 / 2) = 2047

# lerobot/robots/so_follower/so_follower.py - calibrate()
input(f"Move {self} to the middle of its range of motion and press ENTER....")
homing_offsets = self.bus.set_half_turn_homings()

full_turn_motor = "wrist_roll"
unknown_range_motors = [m for m in self.bus.motors if m != full_turn_motor]
range_mins, range_maxes = self.bus.record_ranges_of_motion(unknown_range_motors)
range_mins[full_turn_motor] = 0
range_maxes[full_turn_motor] = 4095
Calibration centres each joint on count 2047 at the pose you hold, then records min and max by sweeping.

Read that order carefully. The homing offset is chosen before anything is known about the joint's real travel. If the pose you held is not the mechanical midpoint, the seam lands inside the travel, and the sweep that follows records the range with a plain min and max over a value that wraps modulo 4096.

python
# record_ranges_of_motion - the two lines that cannot see a wrap
mins  = {motor: min(positions[motor], min_) for motor, min_ in mins.items()}
maxes = {motor: max(positions[motor], max_) for motor, max_ in maxes.items()}

# _normalize - what the dataset and the policy actually get
bounded_val = min(max_, max(min_, val))

if norm_mode is MotorNormMode.RANGE_M100_100:      # body joints, use_degrees=False
    norm = (((bounded_val - min_) / (max_ - min_)) * 200) - 100
elif norm_mode is MotorNormMode.RANGE_0_100:       # the gripper, always
    norm = ((bounded_val - min_) / (max_ - min_)) * 100
elif norm_mode is MotorNormMode.DEGREES:           # body joints, the default
    mid = (min_ + max_) / 2
    max_res = self.model_resolution_table[self._id_to_model(id_)] - 1
    normalized_values[id_] = (val - mid) * 360 / max_res   # val, not bounded_val
A modular value passed through min and max loses the fact that a wrap happened. Two branches then clamp with bounded_val. The DEGREES branch, which is what an SO-100 uses by default, does not.

A bad range does not raise. Under the default use_degrees=True the five body joints take the DEGREES branch, where each sample is measured from the midpoint of the recorded range: a midpoint 200 counts off shifts that joint by about 18 degrees for the whole dataset, and a pose on the far side of the seam arrives as a jump of nearly a full turn. The gripper is the one column always clamped.

The failure is silent, which is why it costs a weekend

lerobot issue 3586 documents an SO-101 follower whose shoulder_lift recorded 2045 to 3919 while the seam sat at raw count 3374, inside the joint's real travel of roughly 1314 to 3657. Its rest pose at raw 3601 then reads as Present 228: outside the recorded band, physically reachable. The JSON looked normal, no warning. Companion issue 3587 proposes sweeping first and choosing the offset second. Both are open, so today this is your responsibility.

Issue 3585 is the dramatic version: when a present position sits outside the calibrated band and something writes a goal, the firmware clamps to the nearest limit, sets torque enable on its next PID tick even though the host disabled torque, and drives there. That is the arm that snaps when you start teleop. Start at arm twitches then sags.

The practical rule

Before tightening a horn screw, put that joint where the printed parts allow roughly equal travel both ways, and hold the servo body still. Then hold that same mid-travel pose on every joint at the first lerobot-calibrate prompt. wrist_roll is exempt: lerobot forces it to the full turn and never sweeps it.

The SO-101 adds a gear-ratio mistake the SO-100 does not have

On the SO-100 the bill of materials is one line item, twelve identical STS3215 servos, and the leader is made movable by removing the gears from its six. On the SO-101 nothing is removed: the follower stays on six 1/345 motors while the leader mixes three ratios in fixed positions, so a servo in the wrong socket is a new way to build the arm wrong.

SO-101 leader axisMotor idGear ratioPart code
Base / shoulder_pan11/191C044
shoulder_lift21/345C001
elbow_flex31/191C044
wrist_flex41/147C046
wrist_roll51/147C046
gripper61/147C046

Gear removal on the SO-100 is its own ordering trap: SO-ARM100 issue 31 is somebody who assembled the whole leader before finding the instruction. The maintainer's answer: not strictly necessary, but without it the leader cannot be driven one-handed for long. SO-100 against SO-101 has the rest.

Mistake 3: the 7.4 V rule

The platform lists the SO-100 and SO-101 as 7.4 V arms on Feetech STS3215 servos, at roughly 110 to 150 EUR and 130 to 170 EUR of parts. That voltage names the servo variant, and it matters because an identical-looking 12 V STS3215 is sold in the same shops.

PartStall torqueSupply it is paired withSource of the number
STS3215 7.4 V (C001, C044, C046)16.5 kg.cm at 6 V, less at 5 V5 V, per the bill of materialsSO-ARM100 README
STS3215 12 V30 kg.cm12 V at 5 A or moreSO-ARM100 README
ST3215 (Waveshare listing)up to 30 kg.cm at 12 V6 to 12.6 V inputWaveshare ST3215 wiki
Match the supply to the servo variant, and measure it

Feeding 12 V into 7.4 V STS3215 servos destroys them, and the SO-ARM100 README is explicit that the 12 V motors need a 12 V 5 A+ supply instead of the 5 V one. The mismatch runs both ways: a commenter on lerobot issue 1252 ran a kit follower built on 12 V servos off a 5 V supply, and only got a clean bus after switching. Over-voltage counts too: issue 3394 opens with six STS3215 that answer no ping at any baud rate, and a commenter with the same symptom measured a nominally 12 V adapter delivering about 16 V, which had tripped protection mode. A multimeter on the barrel jack is a two minute check.

The other supported arms have their own rails: Koch v1.1 runs Dynamixel XL330 and XL430 on 5 V and 12 V rails, and LeKiwi is a 7.4 V arm on a 12 V base. On a LeKiwi both supplies sit on one robot, the easiest place to plug the wrong barrel into the wrong board.

Mistake 4: cable routing, which becomes dropped frames

The LeRobot assembly notes say it in an aside: inserting cables beforehand is much easier than afterward. What they do not spell out is the later cost. A 3-pin cable crossing a rotation axis under tension is tugged every time that joint sweeps. It rarely disconnects outright. It intermittently corrupts a packet on a 1 Mbps bus.

text
Failed to sync read 'Present_Position' on ids=[2,3,4,6] after 1 tries.
[TxRxResult] There is no status packet!
lerobot issue 1252, reported first on a Koch arm and then by SO-101 owners on the Feetech bus. The community workaround was to raise num_retry by hand in the robot class.
Fixed upstream, and it tells you what to look for

Current lerobot ships num_read_retries: int = 2 on SOFollowerConfig, with a source comment that Feetech buses occasionally return a corrupted status packet when several joints move at once, which otherwise aborts the control loop. Retries are immediate and only happen on failure. It sits on the same config object as --robot.port. Raising it lets recording survive a marginal bus, but a run that only completes at a high retry count is telling you a cable is being pulled.

  • Insert both 3-pin cables before the motor goes into its printed holder. Retrofitting means taking the joint apart.
  • Loop the cable with slack around the rotation axis instead of stretching it across. The guide says which wire goes behind the shoulder holder and which routes upward.
  • Use the printed cable holders. The motor 3 to motor 4 cable gets its own screw for a reason.
  • With power off, sweep every joint to both limits and watch the cable. If it goes taut, it fails during recording, not now.
  • Rule the host out too: on issue 1252 an SO-101 owner found the error got much worse with two arms and two cameras on one cheap USB hub.
  • A dropped read is not a harmless retry. It is a repeated or missing frame in an episode, and it trains as a moment where the arm did not move.

Mistake 5: not recalibrating after you touch the hardware

Calibration is a file, and the file outlives the hardware it describes. lerobot writes one JSON per arm id under your Hugging Face cache and reads it at connect time. Swap a servo, re-seat a horn or reprint a link, and it describes an arm that no longer exists.

bash
# where the file lives (override with HF_LEROBOT_CALIBRATION)
ls ~/.cache/huggingface/lerobot/calibration/robots/so_follower/
# my_awesome_follower_arm.json

# per joint: id, drive_mode, homing_offset, range_min, range_max
cat ~/.cache/huggingface/lerobot/calibration/robots/so_follower/my_awesome_follower_arm.json

# after any mechanical change, delete it and calibrate from scratch
rm ~/.cache/huggingface/lerobot/calibration/robots/so_follower/my_awesome_follower_arm.json

lerobot-calibrate \
    --robot.type=so100_follower \
    --robot.port=/dev/tty.usbmodem58760431551 \
    --robot.id=my_awesome_follower_arm
lerobot-calibrate offers to reuse an existing file. Enter reuses it; type c and press Enter to actually recalibrate.

That reuse prompt is the trap: the default keeps the old numbers and writes them back to the motors, exactly wrong after a repair. Far enough out you get a hard error instead of silence, which is the good case. Issue 1296 is the reported version, Magnitude 2114 exceeds 2047 (max for sign_bit_index=11), raised because Homing_Offset uses sign bit index 11.

How a build error becomes bad training data

A LeRobot dataset stores joint states and actions as numeric columns. A mechanically wrong arm produces no error there, just plausible numbers describing something other than what happened, and imitation learning fits them faithfully.

Build errorWhat the dataset showsWhere to start
Two joints given the same idOne column missing or duplicated across jointsservo not responding
Encoder seam inside a joint's travelA jump of nearly 360 degrees when the pose crosses the seam, plus a constant offsetjoint stops early
Leader and follower on different mid-posesaction and observation.state differ by a constant offsetpolicy only works in one setup
Cable tug on the busRepeated frames and gaps in the timestamp columnpolicy freezes mid-motion
Gripper hitting its torque limitGripper column saturates short of closedgripper does not close
Camera re-enumerated between sessionsOne camera key showing two viewpointscamera not detected
The gripper limit is deliberate, not a fault

At connect time the follower writes Max_Torque_Limit = 500, Protection_Current = 250 and Overload_Torque = 25 to the gripper only, with a source comment saying this is 50 percent of max torque to avoid burnout. A gripper that will not crush an object works as designed. One that will not close at all is a calibration range or horn angle problem.

Running a hardware acceptance pass before you record
Advantages
  • Catches the two silent failures, wrong calibration range and bus dropouts, while the fix is still a screwdriver.
  • Costs 30 minutes against the 3 to 6 hours and 4 to 12 USD of a run on the A100 tier.
  • Leaves a known-good calibration file to diff against after every future repair.
  • Uses only upstream lerobot commands, so nothing goes stale when you change tooling.
Trade-offs
  • It says nothing about whether the task is well posed or the demonstrations consistent.
  • A clean bench test can hide a cable that only goes taut in a sweep you skipped.
  • No software check exists for a horn fitted 40 degrees off. You have to be honest about mid-travel.
  • Recalibrating writes new numbers into the motors, so datasets before and after use different conventions.

A 30 minute acceptance pass

Run this on a freshly built arm, and again after any repair, before going near recording a dataset.

  1. 1
    Recalibrate both arms from scratch

    Type c at the reuse prompt. Hold every joint at mid-travel for the first Enter, then sweep all but wrist_roll to both limits.

    bash
    lerobot-calibrate --robot.type=so100_follower \
      --robot.port=/dev/ttyACM0 --robot.id=follower_a
    
    lerobot-calibrate --teleop.type=so100_leader \
      --teleop.port=/dev/ttyACM1 --teleop.id=leader_a
  2. 2
    Read the calibration file, do not assume it succeeded

    A span near the full 4096 counts, or suspiciously narrow, is the signature of the wrap problem. wrist_roll is the only joint legitimately at 0 to 4095.

    python
    import json, pathlib
    
    f = pathlib.Path.home() / ".cache/huggingface/lerobot/calibration" \
        / "robots/so_follower/follower_a.json"
    cal = json.loads(f.read_text())
    
    for joint, c in cal.items():
        span = c["range_max"] - c["range_min"]
        flag = ""
        if joint != "wrist_roll" and span > 3600:
            flag = "  <-- suspiciously wide, check for a wrap"
        if span < 400:
            flag = "  <-- suspiciously narrow, joint may not have been swept"
        print(f"{joint:<14} ho={c['homing_offset']:>6}  "
              f"[{c['range_min']:>4}, {c['range_max']:>4}]  span={span:>4}{flag}")
  3. 3
    Teleoperate for five minutes with the data view on

    Drive every joint to both limits, gripper included. Watch for status packet errors, and for a channel that jumps or flattens while the joint moves smoothly.

    bash
    lerobot-teleoperate \
      --robot.type=so100_follower --robot.port=/dev/ttyACM0 --robot.id=follower_a \
      --teleop.type=so100_leader --teleop.port=/dev/ttyACM1 --teleop.id=leader_a \
      --display_data=true
  4. 4
    Add the cameras and repeat

    Cameras change control loop timing. A bus that was fine without them can start dropping reads once frames share the loop.

    bash
    lerobot-teleoperate \
      --robot.type=so100_follower --robot.port=/dev/ttyACM0 --robot.id=follower_a \
      --robot.cameras="{front: {type: opencv, index_or_path: 0, width: 640, height: 480, fps: 30}}" \
      --teleop.type=so100_leader --teleop.port=/dev/ttyACM1 --teleop.id=leader_a \
      --display_data=true
  5. 5
    Record two throwaway episodes and read the numbers

    Two, not fifty. Open the parquet and check every joint column. A jump of nearly a full turn, a gripper column that saturates, or a flat channel while the arm moved: that is the calibration fault.

What good looks like

Six calibration spans that differ from each other and sit well inside 0 to 4096, five minutes of teleoperation with cameras and no status packet errors, and two throwaway episodes in which all six joint columns move smoothly and none jumps a full turn. With that, the hardware is not why your policy is bad.

The AY-Robots recording tutorial page, the step that turns a built and calibrated SO-100 into a LeRobot dataset
Where a passing arm goes next, and where a build error stops being mechanical and becomes data.

Do it yourself, or do it on AY-Robots

Everything above runs locally on upstream lerobot. You own the arm, the calibration files and the serial port, which is the right trade while debugging hardware: every layer between you and the bus can hide the fault.

bash
git clone https://github.com/huggingface/lerobot
cd lerobot
pip install -e ".[feetech]"

lerobot-find-port
lerobot-setup-motors --robot.type=so100_follower --robot.port=/dev/ttyACM0
lerobot-calibrate  --robot.type=so100_follower --robot.port=/dev/ttyACM0 --robot.id=follower_a
lerobot-teleoperate --robot.type=so100_follower --robot.port=/dev/ttyACM0 --robot.id=follower_a \
                    --teleop.type=so100_leader --teleop.port=/dev/ttyACM1 --teleop.id=leader_a
  • You need the arm, a Feetech-capable serial adapter, and a supply matching the servo variant.
  • For servo-level debugging outside lerobot, the vendor tool is Feetech's Windows software; FT_SCServo_Debug_Qt is the Qt rebuild the SO-ARM100 README points Ubuntu users to.
  • Training and inference are then yours: 24 GB for ACT or SmolVLA, 80 GB for GR00T or Pi0.5.

Symptom first, cause second

Failure-mode pages for what actually goes wrong on an SO-100: arm not detected, servo not responding, joint stops early, gripper does not close. Each starts from what you observed.

Open the failure index

What none of this fixes

A perfect SO-100 does not give you a working vision-language-action model. It removes one class of explanation, which is worth a lot while debugging, and nothing else.

  • It does not fix too little data. ACT, GR00T N1.7, GR00T N1.5 and Pi0.5 each want at least 50 episodes here; only SmolVLA starts at 30.
  • It does not fix camera placement, lighting, or a task ambiguous from the frames. See collecting good VLA training data.
  • It does not fix inference latency: 20 ms per action step for ACT, 485 ms for Pi0.5, and internet round trips on top turn a working policy hesitant.
  • It does not detect a horn at the wrong angle, and no hosted pipeline can, from the dataset alone.
  • It does not make a v3.0 dataset loadable by GR00T. That is a conversion down to v2.1, with its own page.

With no hardware yet, the no-hardware starting points let you drive a real arm, compare models on the arena leaderboard across 85 VLA models and 332 benchmark results, or rent a GPU. If you would rather be paid to drive arms than build them, there is operator work.

The AY-Robots teleoperator page, headlined Become a Robot Operator from anywhere in the world, with a photo of the SO-100 arm operators drive
Driving a correctly built arm is also a job, at the other end of the same pipeline.

Where to go next

After the acceptance pass: SO-100 getting started, data collection, your first training run, running the policy. The complete SO-100 guide covers the same ground end to end, and the VLA overview the models on the other side of the dataset.

Do I really have to set the servo ids before assembling the SO-100?

On the SO-100, yes. The LeRobot guide says the motor connectors are not easily accessible once the arm is assembled, which is why configuration comes first. The SO-101 improved the wiring, but ids still have to be unique before the motors share a bus, because every STS3215 ships as id 1.

How do I tell whether my calibration is wrong or my policy is just bad?

Read the calibration JSON under ~/.cache/huggingface/lerobot/calibration/robots/. Every joint except wrist_roll should have a span clearly under 4096 counts and over a few hundred. Then record two throwaway episodes. Under the default use_degrees=True a body joint is not clamped, so the tell is a jump of nearly a full turn where the pose crossed the seam, or a constant offset; a flat line is the gripper, which is clamped.

What happens if I put a 12 V supply on 7.4 V STS3215 servos?

You destroy them. The SO-ARM100 bill of materials pairs the 7.4 V variant with a 5 V supply and says the 12 V variant needs a 12 V 5 A or larger one. Over-voltage counts too: one reported case had a nominally 12 V adapter measuring around 16 V, which tripped protection mode so none answered a ping.

Can I fix a badly fitted horn in software instead of taking the joint apart?

Partly, and it is not worth it. You can pick a calibration pose that pushes the seam out of the joint's travel, and issue 3587 proposes automating that by sweeping first. But today the offset is chosen before the sweep, so you are hand-tuning around a known bug. Refitting the horn at mid-travel is faster.

Does AY-Robots check my hardware for me?

No. It records datasets, rents GPUs, trains policies and serves them back, but it cannot see your arm's mechanics. What it gives you is a comparison: train on a public dataset from the directory, and if that policy behaves while yours does not, the difference is in your recording rather than the model.

Ready for high-quality robotics data?

AY-Robots connects your robots to skilled operators worldwide.

Get Started