
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.
| Property | Value for the 7.4 V STS3215 |
|---|---|
| Angle sensor | 12-bit magnetic, 4096 counts over 360 degrees |
| Resolution | 0.088 degrees per count (360 / 4096) |
| Neutral position | 180 degrees, count 2048 |
| Input voltage | 4 V to 7.4 V |
| Protection trips | above 7.4 V or below 4 V, released once voltage returns to range |
| Stall torque | 19.5 kg.cm at 7.4 V, 16.5 kg.cm at 6 V; rated 5 kg.cm at 7.4 V |
| Gear ratio | 1:345 on the C001 part, which the SO-ARM100 BOM lists for all six SO-100 joints |
| Backlash (spec) | 0.5 degrees or less |
| Bus | half duplex asynchronous serial, 8 bit, 1 stop bit, no parity |
| Baud rate | 38400 bps to 1 Mbps, factory default 1 Mbps |
| ID range | 0 to 253 |
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.
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 field | Servo register | Address | Size | What it does |
|---|---|---|---|---|
| homing_offset | Homing_Offset | 31 | 2 bytes | subtracted from the raw count before the servo reports position |
| range_min | Min_Position_Limit | 9 | 2 bytes | lower end of joint travel, in counts, after the offset |
| range_max | Max_Position_Limit | 11 | 2 bytes | upper end, in counts, after the offset |
| drive_mode | not written | - | - | 0 on SO arms; flips the sign in the RANGE modes, ignored in DEGREES |
| id | not written by calibrate | 5 | 1 byte | set 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.
{
"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 }
}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.
- 1Install LeRobot with the Feetech extra
The Feetech SDK is not in the base install. The extra pulls in feetech-servo-sdk, pyserial and deepdiff.
bashgit clone https://github.com/huggingface/lerobot.git cd lerobot pip install -e ".[feetech]" - 2Find the serial port
Plug the arm in, unplug when prompted, and it names the device node. Do leader and follower separately.
bashlerobot-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' - 3Set 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.
bashlerobot-setup-motors \ --robot.type=so100_follower \ --robot.port=/dev/ttyACM0 - 4Run 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.
bashlerobot-calibrate \ --robot.type=so100_follower \ --robot.port=/dev/ttyACM0 \ --robot.id=my_follower - 5Pose 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.
textMove my_follower SOFollower to the middle of its range of motion and press ENTER.... - 6Sweep 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.
textMove all joints except 'wrist_roll' sequentially through their entire ranges of motion. Recording positions. Press ENTER to stop... - 7Repeat 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.
bashlerobot-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.
# 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
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.
# 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_valTwo 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 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 observe | Likely calibration cause | Where to look |
|---|---|---|
| Constant offset on one joint in teleop | mid-pose captured differently on the two arms | homing_offset, both files |
| Joint hits its target in teleop, stops short under a policy | range narrower than real travel, clamped at 100 | joint stops early |
| Gripper no longer closes on objects it closed on in recording | gripper swept without load | gripper does not close |
| Arm jerks to a new pose the instant torque is enabled | file and EEPROM disagree, limits rewritten on connect | arm twitches then sags |
| Policy works on its recording arm and nowhere else | arms centred differently, one float means two angles | policy only works in one setup |
| ValueError: some motors have the same min and max values | a joint was never moved during the sweep | record_ranges_of_motion, before anything is written |
| ValueError: min and max are equal, hours later | a hand-edited file with a collapsed range | _normalize, on the first read after connect |

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.
- 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.
- 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.
- wrist_roll is exactly 0 and 4095. Anything else was written by an older version or by hand.
- Every homing_offset is inside -2047 to +2047. Outside that, encode_sign_magnitude raises and the write never lands.
- The gripper reads near 0 fully closed and near 100 fully open, since it always uses RANGE_0_100.
- The file and the servos agree. robot.is_calibrated reads all three registers off the bus and compares them against the cached file.
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")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()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.
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- 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.
- 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.
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.
Be clear about the boundary: this platform does not calibrate your servos. You run lerobot-calibrate yourself, exactly as above. What it replaces is everything downstream of a calibrated arm.
- The desktop client records LeRobot-format datasets from a teleop session, so the calibration your arm has is the one in the episodes.
- Public datasets show what a well-swept range looks like before you commit to 50 episodes of your own.
- Training picks model and dataset in a form, rents a GPU by required VRAM and stores checkpoints; the five models are on the policies page.
- Live teleop puts you on a real, already-calibrated SO-100 with no signup.
- The CLI and the MCP server expose the same operations to a terminal and to an AI agent.
A dataset recorded from a badly calibrated arm is a bad dataset here too. Nothing in the training pipeline inspects your homing offsets, and no amount of GPU time recovers a joint clamped at 100 for a third of every episode. The minimum episode counts are 30 for SmolVLA and 50 for ACT, Pi0.5, GR00T N1.5 and GR00T N1.7, and they assume the episodes are informative.
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.
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.
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.
| Thing | Older versions | Current, v0.4.4 through v0.6.1 |
|---|---|---|
| Console script module | lerobot.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 path | lerobot.robots.so100_follower, up to and including v0.4.2 | lerobot.robots.so_follower since v0.4.3, with SO100Follower and SO100FollowerConfig kept as aliases |
| Calibration directory | robots/so100_follower/ | robots/so_follower/, named after the class attribute |
| Body joint normalization | use_degrees defaulted to False, so RANGE_M100_100, up to v0.4.3 | use_degrees defaults to True, so DEGREES, since v0.4.4 |
| Config type strings | so100_follower, so100_leader | unchanged, 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.

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 indexWhere 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.
Sources
- LeRobot: Feetech STS/SMS control table, resolutions and sign encodings
- LeRobot: MotorsBus, MotorCalibration, normalization and range recording
- LeRobot: SOFollower.calibrate() for SO-100 and SO-101
- LeRobot: sign-magnitude and two's complement encoding helpers
- LeRobot: SO follower configuration, use_degrees and PID defaults
- LeRobot: SO leader teleoperator and its calibration path
- Hugging Face LeRobot docs: SO-100 assembly, motor setup and calibration
- Hugging Face LeRobot docs: SO-101, including the calibration video
- Feetech STS3215 product specification, edition A/0, 2020-04-10 (translated)
- Feetech FT-SMS-STS series e-manual (official control table reference)
- TheRobotStudio SO-ARM100: bill of materials and servo variants
- Independent bench test of STS3215 backlash, repeatability and torque
- LeRobot release v0.6.1, published 2026-08-03
- LeRobot issue 1342: calibration parameters and negative homing offsets
- LeRobot: lerobot-find-joint-limits for joint and end-effector bounds
Sources
- LeRobot: Feetech STS/SMS control table, resolutions and sign encodings
- LeRobot: MotorsBus, MotorCalibration, normalization and range recording
- LeRobot: SOFollower.calibrate() for SO-100 and SO-101
- LeRobot: sign-magnitude and two's complement encoding helpers
- LeRobot: SO follower configuration, use_degrees and PID defaults
- LeRobot: SO leader teleoperator and its calibration path
- Hugging Face LeRobot docs: SO-100 assembly, motor setup and calibration
- Hugging Face LeRobot docs: SO-101, including the calibration video
- Feetech STS3215 product specification, edition A/0, 2020-04-10 (translated)
- Feetech FT-SMS-STS series e-manual (official control table reference)
- TheRobotStudio SO-ARM100: bill of materials and servo variants
- Independent bench test of STS3215 backlash, repeatability and torque
- LeRobot release v0.6.1, published 2026-08-03
- LeRobot issue 1342: calibration parameters and negative homing offsets
- LeRobot: lerobot-find-joint-limits for joint and end-effector bounds
Ready for high-quality robotics data?
AY-Robots connects your robots to skilled operators worldwide.
Get Started