The AY-Robots SO-100 hub page, the starting point for setup, calibration, recording and training on the SO-100 arm
Feetech STS3215CalibrationSO-100LeRobotRobot hardware

Feetech STS3215 Calibration: What LeRobot Writes to the Servo

AY-Robots ResearchAugust 23, 202617 min read

Calibration on a Feetech STS3215 writes three integers per joint into servo EEPROM. What they mean, why a bad one silently ruins your episodes, and six checks that catch it.

Calibration on an SO-100 is not a vague tuning step. On a Feetech STS3215 it is three integers per joint, written into the servo's non-volatile memory and mirrored into a JSON file on your laptop. Get them wrong and every joint angle you record is wrong the same systematic way, so a policy trained on it learns a mapping that only holds for the arm as it was miscalibrated that day.

This page covers what those three numbers are, which registers they land in, where they come from in the source, what breaks downstream, and the checks that catch a bad calibration in two minutes. Everything here is read out of LeRobot on the main branch (0.6.2 in development; last release v0.6.1, 2026-08-03) and the Feetech STS3215 product specification, edition A/0, 2020-04-10.

What you need to know

  • An STS3215 has a 12-bit magnetic angle sensor: 4096 counts over 360 degrees, one count is 0.088 degrees.
  • Calibration writes three registers per motor: Homing_Offset (address 31), Min_Position_Limit (9), Max_Position_Limit (11).
  • Present_Position = Actual_Position - Homing_Offset. LeRobot picks the offset so your mid-pose reads 2047.
  • Episodes store normalized values derived from range_min and range_max, never raw counts, so a wrong range rescales the dataset.
  • wrist_roll is hardcoded to 0 and 4095 rather than recorded, because it has no hard stops.
  • Homing_Offset is sign-magnitude, sign bit at index 11. Tools assuming two's complement show nonsense.
  • Calibration is per arm and per assembly. Reprint a bracket and the numbers no longer describe the machine.

What an STS3215 actually reports

The STS3215 is a serial bus servo with an absolute 12-bit magnetic angle sensor. It does not report degrees. It reports a count, and the datasheet fixes the mapping: 360 degrees of output rotation over 0 to 4096, with 180 degrees at count 2048. Everything a LeRobot dataset contains about arm state starts as one of those integers, on every arm this platform supports except Koch v1.1, which uses Dynamixel servos.

PropertyValue for the 7.4 V STS3215
Angle sensor12-bit magnetic, 4096 counts over 360 degrees
Resolution0.088 degrees per count (360 / 4096)
Neutral position180 degrees, count 2048
Input voltage4 V to 7.4 V
Protection tripsabove 7.4 V or below 4 V, released once voltage returns to range
Stall torque19.5 kg.cm at 7.4 V, 16.5 kg.cm at 6 V; rated 5 kg.cm at 7.4 V
Gear ratio1:345 on the C001 part, which the SO-ARM100 BOM lists for all six SO-100 joints
Backlash (spec)0.5 degrees or less
Bushalf duplex asynchronous serial, 8 bit, 1 stop bit, no parity
Baud rate38400 bps to 1 Mbps, factory default 1 Mbps
ID range0 to 253
Not every STS3215 in a kit is the same part

The SO-ARM100 bill of materials specifies six identical 7.4 V servos with the 1:345 gearbox (part code C001) for the SO-100. The SO-101 leader is a mix: three 1:147 (C046), two 1:191 (C044), one 1:345 (C001), because an arm you push by hand wants less reduction. That changes nothing in the arithmetic, since the sensor spans 360 degrees over 0 to 4096 counts on all of them. It only changes how hard the joint is to backdrive during the sweep.

7.4 V means 7.4 V

The STS3215 exists in a 7.4 V and a 12 V variant, and they look identical. The 7.4 V part is what the SO-100, SO-101 and the LeKiwi arm use. Its datasheet input range is 4 V to 7.4 V, and it enters over-voltage protection above 7.4 V. A 12 V supply on a 7.4 V bus destroys servos, and it destroys every one on the daisy chain at once. Check the label on the barrel jack before the first power-up.

The three numbers, and the registers they land in

LeRobot models a calibrated motor with a five-field dataclass called MotorCalibration: id, drive_mode, homing_offset, range_min, range_max. Only three of those reach the hardware. FeetechMotorsBus.write_calibration writes Homing_Offset (on protocol 0, which is what the SO arms use), then Min_Position_Limit, then Max_Position_Limit, and nothing else. drive_mode is always 0 on SO arms, and id is bookkeeping.

JSON fieldServo registerAddressSizeWhat it does
homing_offsetHoming_Offset312 bytessubtracted from the raw count before the servo reports position
range_minMin_Position_Limit92 byteslower end of joint travel, in counts, after the offset
range_maxMax_Position_Limit112 bytesupper end, in counts, after the offset
drive_modenot written--0 on SO arms; flips the sign in the RANGE modes, ignored in DEGREES
idnot written by calibrate51 byteset once by lerobot-setup-motors

These registers live in EEPROM, so they survive a power cycle, which is why calibration happens once per physical arm. It is also why a used servo arrives carrying someone else's numbers, and why LeRobot compares file against motors on every connect instead of trusting either.

json
{
  "shoulder_pan":  { "id": 1, "drive_mode": 0, "homing_offset": 14,    "range_min": 1015, "range_max": 3128 },
  "shoulder_lift": { "id": 2, "drive_mode": 0, "homing_offset": -1732, "range_min": 812,  "range_max": 3260 },
  "elbow_flex":    { "id": 3, "drive_mode": 0, "homing_offset": 907,   "range_min": 985,  "range_max": 3061 },
  "wrist_flex":    { "id": 4, "drive_mode": 0, "homing_offset": -461,  "range_min": 900,  "range_max": 3190 },
  "wrist_roll":    { "id": 5, "drive_mode": 0, "homing_offset": 122,   "range_min": 0,    "range_max": 4095 },
  "gripper":       { "id": 6, "drive_mode": 0, "homing_offset": -1024, "range_min": 1975, "range_max": 3050 }
}
Shape of a follower calibration file. The integers are illustrative and yours will differ; the only values fixed by the code are wrist_roll's 0 and 4095.
Sign-magnitude, not two's complement

Homing_Offset on the STS series is sign-magnitude with the sign bit at index 11. LeRobot's encode_sign_magnitude puts the magnitude in bits 0 to 10 and the direction in bit 11, so the range is -2047 to +2047 and an offset of -1732 goes onto the wire as 2048 + 1732 = 3780. Read it with a tool that assumes a signed 16-bit integer and you get a number that looks broken but is not: one reporter saw 65353 and concluded the write had failed. That is lerobot issue 1342, closed as not planned with no maintainer explanation, so read it as evidence the encoding confuses people, not as a confirmed bug.

Where the numbers come from: the calibration run

The calibration routine for the SO follower and the SO leader is the same code, short enough to read in one sitting. It disables torque, forces every motor into position mode, asks for one pose, then asks for a sweep. Below is the whole sequence including the two prerequisites people skip, cross-checked against the upstream LeRobot SO-100 guide.

  1. 1
    Install LeRobot with the Feetech extra

    The Feetech SDK is not in the base install. The extra pulls in feetech-servo-sdk, pyserial and deepdiff.

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

    Plug the arm in, unplug when prompted, and it names the device node. Do leader and follower separately.

    bash
    lerobot-find-port
    # Finding all available ports for the MotorsBus.
    # Ports before disconnecting: ['/dev/ttyACM0', '/dev/ttyACM1']
    # Remove the USB cable from your MotorsBus and press Enter when done.
    # The port of this MotorsBus is '/dev/ttyACM0'
  3. 3
    Set motor IDs and baud rate, once per arm

    New servos all ship with ID 1. This walks the chain from gripper down to shoulder_pan, writing IDs 6 to 1 and the default baud rate of 1000000 into EEPROM. Nothing works until it is done.

    bash
    lerobot-setup-motors \
        --robot.type=so100_follower \
        --robot.port=/dev/ttyACM0
  4. 4
    Run the calibration on the follower

    The script connects with calibrate=False, then calls calibrate(). If a file exists for that id it offers to write it back to the motors instead of sweeping again.

    bash
    lerobot-calibrate \
        --robot.type=so100_follower \
        --robot.port=/dev/ttyACM0 \
        --robot.id=my_follower
  5. 5
    Pose the arm mid-range, then press Enter

    The homing step. LeRobot resets calibration (offset 0, limits 0 and 4095), reads every joint's raw position, and writes Homing_Offset = raw - 2047. The prompt interpolates the device, so it prints your id then the class name.

    text
    Move my_follower SOFollower to the middle of its range of motion and press ENTER....
  6. 6
    Sweep every joint to both hard stops, then press Enter

    Torque is off, so you move the arm by hand. The script polls Present_Position and keeps a running min and max. Take each joint to both mechanical limits at least once, and do not forget the gripper.

    text
    Move all joints except 'wrist_roll' sequentially through their
    entire ranges of motion.
    Recording positions. Press ENTER to stop...
  7. 7
    Repeat for the leader

    Same command, teleop side. Each arm gets its own file. The raw counts will not match and are not supposed to; the normalized value in the same pose has to.

    bash
    lerobot-calibrate \
        --teleop.type=so100_leader \
        --teleop.port=/dev/ttyACM1 \
        --teleop.id=my_leader

The result goes to disk and to the servos in the same breath. The path comes from the device class name and the id you passed, under the Hugging Face cache root.

bash
# follower
~/.cache/huggingface/lerobot/calibration/robots/so_follower/my_follower.json

# leader
~/.cache/huggingface/lerobot/calibration/teleoperators/so_leader/my_leader.json

# override the root if you want it inside your project
export HF_LEROBOT_CALIBRATION=/path/to/my/calibrations
Default calibration paths. The directory is named after the device class, which is so_follower since the upstream package rename, not so100_follower.
The AY-Robots recording tutorial, the step that follows calibration, showing how a LeRobot dataset is recorded from a teleoperation session
Recording is the step that consumes the calibration: everything the arm reports is encoded with those three numbers.

Why a bad calibration ruins episodes rather than just offsetting them

Here is the part most guides skip. A LeRobot episode does not store encoder counts. Every observation and action is a float per joint, named shoulder_pan.pos and so on, produced by the normalization step between the bus and the rest of the stack.

python
# lerobot/motors/motors_bus.py, MotorsBus._normalize (abridged)

bounded_val = min(max_, max(min_, val))          # clamp to [range_min, range_max]

if norm_mode is MotorNormMode.RANGE_M100_100:    # body joints, legacy mode
    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, current default
    mid = (min_ + max_) / 2
    max_res = self.model_resolution_table[model] - 1   # 4095 for an sts3215
    norm = (val - mid) * 360 / max_res           # note: val, not bounded_val
The three normalization modes. On the SO-100 the five body joints use DEGREES by default since lerobot v0.4.4, and the gripper always uses RANGE_0_100.

Two failure modes fall out of that. In the RANGE modes the value is clamped to the recorded range first, so anything beyond range_max is recorded as exactly 100, indistinguishable from a joint parked at its limit. DEGREES does not clamp, but its zero is the midpoint of your recorded range, so losing 200 counts on one side shifts every angle in the dataset by 8.8 degrees.

The trap that eats a day

The most common bad calibration is not a wrong number, it is a range that was never fully swept. You move each joint through the part of its travel you care about, press Enter, and the file looks plausible. Then the arm saturates at 100 for a third of every episode, the loss still falls nicely, and the policy stalls at the same angle every time. Nothing emits an error. See joint stops early and loss falls but the policy does nothing.

The second consequence is about leader-follower teleoperation. The leader's position is normalized with the leader's calibration, sent as a float, and unnormalized with the follower's before it reaches Goal_Position. The two arms never exchange raw counts, so the normalized space is the shared language: if either dictionary is wrong, the follower goes somewhere the leader did not ask for.

What you observeLikely calibration causeWhere to look
Constant offset on one joint in teleopmid-pose captured differently on the two armshoming_offset, both files
Joint hits its target in teleop, stops short under a policyrange narrower than real travel, clamped at 100joint stops early
Gripper no longer closes on objects it closed on in recordinggripper swept without loadgripper does not close
Arm jerks to a new pose the instant torque is enabledfile and EEPROM disagree, limits rewritten on connectarm twitches then sags
Policy works on its recording arm and nowhere elsearms centred differently, one float means two anglespolicy only works in one setup
ValueError: some motors have the same min and max valuesa joint was never moved during the sweeprecord_ranges_of_motion, before anything is written
ValueError: min and max are equal, hours latera hand-edited file with a collapsed range_normalize, on the first read after connect
The AY-Robots failure-mode index listing fixes for arm not detected, joint stops early, gripper does not close and other robot arm problems
Most entries on the failure-mode index have a calibration branch in their decision tree.

Six checks that a calibration is sane

None of these needs a policy, a dataset or a GPU. Run them after every calibration and after any mechanical work on the arm.

  1. range_min is strictly less than range_max on all six joints. record_ranges_of_motion refuses to finish when they are equal, but cannot catch a range that is merely too narrow.
  2. The span in counts matches the joint's real travel. Multiply by 0.088 for degrees: a joint swinging about 180 degrees should show roughly 2048 counts.
  3. wrist_roll is exactly 0 and 4095. Anything else was written by an older version or by hand.
  4. Every homing_offset is inside -2047 to +2047. Outside that, encode_sign_magnitude raises and the write never lands.
  5. The gripper reads near 0 fully closed and near 100 fully open, since it always uses RANGE_0_100.
  6. The file and the servos agree. robot.is_calibrated reads all three registers off the bus and compares them against the cached file.
python
import json, pathlib

RESOLUTION = 4096
path = pathlib.Path.home() / ".cache/huggingface/lerobot/calibration/robots/so_follower/my_follower.json"
cal = json.loads(path.read_text())

for joint, c in cal.items():
    span = c["range_max"] - c["range_min"]
    degrees = span * 360 / (RESOLUTION - 1)
    print(f"{joint:<14} id={c['id']} offset={c['homing_offset']:>6} "
          f"span={span:>5} counts = {degrees:6.1f} deg")

    assert c["range_min"] < c["range_max"], f"{joint}: empty range"
    assert abs(c["homing_offset"]) <= 2047, f"{joint}: offset outside sign-magnitude range"
    if joint == "wrist_roll":
        assert (c["range_min"], c["range_max"]) == (0, 4095), "wrist_roll should be a full turn"
    elif span < 200:
        print(f"  WARNING {joint}: only {span} counts ({degrees:.1f} deg), sweep it again")
Static check against the JSON. The 200-count threshold is a heuristic, not an upstream rule.
python
from lerobot.robots.so_follower import SO100Follower, SO100FollowerConfig

robot = SO100Follower(SO100FollowerConfig(port="/dev/ttyACM0", id="my_follower"))
robot.connect(calibrate=False)

print("file matches motors:", robot.is_calibrated)
for joint in robot.bus.motors:
    raw = robot.bus.read("Present_Position", joint, normalize=False)
    off = robot.bus.read("Homing_Offset", joint, normalize=False)
    lo = robot.bus.read("Min_Position_Limit", joint, normalize=False)
    hi = robot.bus.read("Max_Position_Limit", joint, normalize=False)
    print(f"{joint:<14} raw={raw:>5} offset={off:>6} limits=({lo}, {hi})")

robot.disconnect()
Live check against the servos. connect(calibrate=False) skips the interactive prompt so the script does not block.

The seventh check is behavioural. Run teleoperation and hold leader and follower in the same physical pose: the normalized values should line up. If one joint is consistently off, that joint's homing offset is the suspect, not the mechanics.

bash
lerobot-teleoperate \
    --robot.type=so100_follower \
    --robot.port=/dev/ttyACM0 \
    --robot.id=my_follower \
    --teleop.type=so100_leader \
    --teleop.port=/dev/ttyACM1 \
    --teleop.id=my_leader \
    --display_data=true
--display_data=true streams joint values to Rerun so you can watch leader and follower side by side.
LeRobot's calibration design
What it gets right
  • Three integers per joint, all in EEPROM, so an arm carries its calibration to any machine.
  • The same procedure across SO-100, SO-101, Koch and LeKiwi, which makes a policy portable between builds.
  • The bus verifies file against hardware on every connect, so a swapped servo is caught rather than absorbed.
  • Sweeping by hand with torque off needs no jig and captures the limits of your specific print.
What it costs you
  • The mid-pose is eyeballed. Two people calibrating one arm land tens of counts apart, unflagged.
  • An under-swept range produces a file that looks valid and fails silently, clamped at 100 in the RANGE modes.
  • No repeatability estimate comes out, so you never learn how much joint noise is the servo and how much is you.
  • Mechanical changes invalidate the file without invalidating anything that reads it.

Do it yourself, or let the platform handle what comes after

Everything above runs on your own laptop with the upstream repo. Calibration is a local operation on a USB serial bus; there is no cloud version of it.

bash
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=my_follower
lerobot-calibrate --teleop.type=so100_leader  --teleop.port=/dev/ttyACM1 --teleop.id=my_leader

lerobot-teleoperate \
    --robot.type=so100_follower --robot.port=/dev/ttyACM0 --robot.id=my_follower \
    --teleop.type=so100_leader  --teleop.port=/dev/ttyACM1 --teleop.id=my_leader
  • You own the whole chain and can read every line of it.
  • You also own dataset formatting, GPU rental, checkpoint storage and the serving loop.
  • Budget an evening for the first arm. Calibration takes five minutes; the port and the servo IDs do not.

How precise can this get, realistically

One count is 0.088 degrees, which sounds precise until you look at the mechanics in front of the sensor. The datasheet allows up to 0.5 degrees of backlash, about 5.7 counts. An independent bench test by Robonine put a dial indicator on an 86 mm lever, measured 1.3 mm of play at the tip, and worked that back to 0.0151 radians, roughly 0.87 degrees or ten counts. The same test put repeatability at 0.3 mm on a 10 cm lever, about 0.17 degrees, and reports a built-in dead zone of ten counts.

Read that bench test with the variant in mind

The Robonine unit was an STS3215 C018: same 1:345 gearbox, but the 12 V, 30 kg.cm part, not the 7.4 V part the SO-100 uses. Its torque figures do not transfer and are not quoted here. Backlash and repeatability come from the gear train and the sensor, which both variants share, so those are the best public numbers for an SO-100 joint. Still one sample on one bench.

That sets a floor. Chasing a homing offset to within a few counts is pointless when the joint has ten counts of slop, which is why the eyeballed mid-pose is fine. Being wrong by hundreds of counts is not, and that is what an under-swept range gives you.

A small habit that pays

Copy the calibration JSON into your project repo next to the recording scripts, and commit it. It is under a kilobyte, it is the only artefact tying a dataset to the geometry it was recorded on, and six months later it is the difference between reproducing a result and guessing. Pushing it back onto the servos is one bus.write_calibration(calibration_dict) call, which is what lerobot-calibrate offers when it finds an existing file for that id.

Version notes, because upstream moved

Calibration has been rewritten more than once, and older posts describe an API that no longer exists. If a command or path below does not match what you read elsewhere, check the version it was written for.

ThingOlder versionsCurrent, v0.4.4 through v0.6.1
Console script modulelerobot.calibrate, callable as python -m lerobot.calibrate (v0.3.3)lerobot.scripts.lerobot_calibrate since v0.4.0; the lerobot-calibrate command itself is unchanged
Package pathlerobot.robots.so100_follower, up to and including v0.4.2lerobot.robots.so_follower since v0.4.3, with SO100Follower and SO100FollowerConfig kept as aliases
Calibration directoryrobots/so100_follower/robots/so_follower/, named after the class attribute
Body joint normalizationuse_degrees defaulted to False, so RANGE_M100_100, up to v0.4.3use_degrees defaults to True, so DEGREES, since v0.4.4
Config type stringsso100_follower, so100_leaderunchanged, still registered under both names
Latest release-v0.6.1, published 2026-08-03; main is 0.6.2 in development

The normalization default matters more than it looks. RANGE_M100_100 and DEGREES encode the same motion as different numbers, and a checkpoint trained on one does not transfer without a conversion. Check use_degrees before concatenating a recording made under v0.4.3 or earlier with a new one. Two things on main are newer than most tutorials: a pygame range-slider GUI in lerobot/motors/calibration_gui.py, one row per motor for dragging range_min and range_max, and lerobot-find-joint-limits, which drives the follower from the leader and reports the limits it saw, plus end-effector bounds if you give it a URDF.

The AY-Robots teleoperator page showing an SO-100 robot arm that remote operators drive to record training data
Remote operators drive arms they did not calibrate, which is why calibration has to be right first.

When the arm does something you did not ask for

The failure-mode index walks the usual suspects one page at a time: arm not detected, servo not responding, joint stops early, gripper does not close, policy only works in one setup. Each page names the check to run.

Open the failure-mode index

Where to go next

With a calibration you trust, the next thing that decides whether training works is the data. The recording walkthrough covers the mechanics, and our notes on collecting high-quality VLA training data cover the judgement calls. If you are still assembling, the full SO-100 setup guide starts one step earlier, and SO-100 against SO-101 matters here because both use STS3215 servos and the same calibration procedure.

Do I have to recalibrate every time I power the arm on?

No. The three registers live in EEPROM and survive power cycles. LeRobot compares file against motors on every connect and only re-runs calibration if they disagree or the file is missing. If you are asked to recalibrate every run, the EEPROM write is not landing: disable_torque writes Lock = 0 to open EEPROM and enable_torque writes Lock = 1, so a servo left locked is the first suspect.

Why does wrist_roll get a hardcoded range of 0 to 4095?

It has no mechanical hard stops, so there is nothing to sweep against. LeRobot excludes it from record_ranges_of_motion and assigns the full 12-bit range directly. Anything other than 0 and 4095 was not produced by the current code.

Can I copy another person's calibration file for the same arm model?

No, and it will not fail loudly. Calibration encodes one specific assembly: print tolerances, horn screw positions, where each servo sat when you attached its bracket. An identically specced SO-100 lands tens to hundreds of counts away, giving you an arm that thinks it is somewhere it is not.

Does calibration affect inference latency?

No. Normalization is a handful of floating point operations per joint. Per-action-step inference on this platform runs from about 20 ms for ACT to about 485 ms for Pi0.5, and network round trips dominate. Calibration decides whether the numbers mean what you think, not how fast they arrive.

My homing_offset is negative in the JSON. Is that a bug?

No. The offset is the raw position at your mid-pose minus 2047, so it is negative whenever the joint sits below the encoder midpoint. Roughly half of a typical file is negative. It only becomes a problem when a tool reads the register assuming two's complement, because the STS series encodes it sign-magnitude with the sign bit at index 11. A value near 65353 is that mistake, not a failed write.

Ready for high-quality robotics data?

AY-Robots connects your robots to skilled operators worldwide.

Get Started