Where you are. You have printed parts that fit and an arm you know not to finish yet. This lesson is the gate you stopped at: giving each motor a name, on a wire that has no idea how many devices are attached to it.
Two voices, one wire
Wire up two brand new motors, power the board, and ask a question: motor 1, where are you?
Both answer. They answer at the same instant, on the same wire, at a million bits per second, driving it in opposite directions wherever their bits differ. What comes back is not two replies. It is one damaged reply, or nothing at all, and the software’s best description of the event is that it waited and gave up.
Every STS3215 leaves the factory with the same identity. Twelve motors, twelve copies of the number 1.
Nothing here is broken. The bus is behaving exactly as designed. You just have not yet done the one thing the design assumes you did.
The idea in one paragraph
The six motors in an arm are not six devices with six cables. They are six devices on one shared wire, chained motor to motor from the control board out to the gripper. That wire is half duplex: the same conductor carries commands out and answers back, so exactly one device may talk at a time and the rule is enforced by convention rather than by hardware. Every transaction is therefore a question addressed to a number, and an answer from whoever owns that number. Nothing assigns the numbers for you. You assign them by hand, one motor at a time, physically alone on the bus, and the number is written into the motor’s own non-volatile memory so it survives power-off, disassembly and every reinstall of the software. Once that is done, the arm is addressable, and everything afterwards - calibration, teleoperation, recording, deployment - is reads and writes to numbered registers on numbered devices.
Wider than the screen; scroll it sideways.
What the wire actually carries
The defaults matter because you will see them in error messages long before you see them in documentation.
| Property | Value |
|---|---|
| Link | half-duplex TTL serial, three-pin connectors, daisy chained |
| Baud rate | 1,000,000 by default |
| Protocol | version 0, which supports a broadcast ping |
| Read timeout | 1000 ms |
| Encoder resolution | 4096 counts per turn |
| Scanned baud rates | 4800 through 1,000,000, in the usual eleven steps |
Two of these are worth a second look. The broadcast ping is what lets a tool ask “everyone, identify yourselves” in one transaction, which is how discovery works and also exactly what a duplicate ID ruins. And 4096 counts per turn is the 12-bit absolute magnetic encoder Module 0 described, reporting roughly 0.088 degrees per count, absolute from the moment it powers up, which is why this arm never needs to home itself against a switch.
The names are not yours to choose
LeRobot hardcodes the mapping from joint name to ID. This is the table the rest of the module runs on.
| Joint | ID | Reported as |
|---|---|---|
shoulder_pan | 1 | degrees |
shoulder_lift | 2 | degrees |
elbow_flex | 3 | degrees |
wrist_flex | 4 | degrees |
wrist_roll | 5 | degrees |
gripper | 6 | 0 to 100 |
The gripper is the odd one. Every other joint reports an angle; the gripper reports a percentage of open, always, whatever units the rest of the arm is configured to use. That is a deliberate interface choice and it will show up again in every dataset you record.
One at a time, gripper first
Wider than the screen; scroll it sideways.
The procedure is dictated by the collision. With one motor on the bus there is no ambiguity, so the tool can address the factory default, write the real ID and baud rate into the motor, and move on. Connect two and there is no safe question to ask.
lerobot-setup-motors --robot.type=so101_follower --robot.port=<FOLLOWER_PORT>
lerobot-setup-motors --teleop.type=so101_leader --teleop.port=<LEADER_PORT>
The tool prompts you through the six motors in reverse order: gripper first as ID 6, then wrist roll, wrist flex, elbow, shoulder lift, and shoulder pan last as ID 1. That order is fixed in the code - it literally iterates reversed(motors) - rather than being a suggestion. The reason is the one the previous lesson guessed at, and the assembly guide states it while walking you through the steps: when you move on from a motor you unplug the cable at the board end and leave it attached to the motor, “as it will already be in the right place”. The chain assembles itself behind you as you go.
What lives inside the motor
Module 0 opened a servo and found four things in one housing: a motor, a gearbox, an encoder, and a small controller you did not write, closing a position loop far faster than your code ever will. Here is that controller’s register map, or the part of it worth knowing.
Wider than the screen; scroll it sideways.
| Register | Address | Where | What it is |
|---|---|---|---|
ID | 5 | EEPROM | the address on the bus |
Baud_Rate | 6 | EEPROM | 1,000,000 by default |
Min/Max_Position_Limit | 9 / 11 | EEPROM | travel limits |
Max_Temperature_Limit | 13 | EEPROM | thermal cutout |
Max_Torque_Limit | 16 | EEPROM | how hard it may ever push |
P / D / I_Coefficient | 21 / 22 / 23 | EEPROM | gains of the loop inside the box |
Protection_Current | 28 | EEPROM | current cap |
Homing_Offset | 31 | EEPROM | where zero sits |
Operating_Mode | 33 | EEPROM | position mode, for this arm |
Overload_Torque | 36 | EEPROM | what it drops to once overloaded |
Torque_Enable | 40 | SRAM | powered or limp |
Goal_Position | 42 | SRAM | the command |
Present_Position | 56 | SRAM | the measurement |
Present_Load | 60 | SRAM | how hard it is working |
Present_Temperature | 63 | SRAM | degrees, readable live |
Present_Current | 69 | SRAM | current draw |
The split is the interesting part. EEPROM is persisted configuration: identity, limits, gains, the offset that defines zero. Written rarely, survives power loss, and is exactly the kind of state you forget exists until it disagrees with a file on your laptop. SRAM is the live process: the current command, the current measurement, gone the moment power drops.
Present_Temperature being readable is a small gift. A motor you can ask about its own temperature is a motor you can watch during a long teleoperation session, and watching it is much more informative than touching it afterwards.
Read the LEDs before you read the logs
Each motor has an LED. Powered up and healthy, they are steady red, all the way down the chain. That gives you a three-way triage that costs nothing and resolves most first-day failures.
| What you see | What it means |
|---|---|
| Steady red all the way from gripper to base | wiring is fine, look at software |
| One or more dark, or the chain stops part way | wiring: reseat the three-pin cables, check the board’s supply, make sure each connector is fully clicked in |
| Blinking | the motor is in an error state, usually overload or the wrong supply voltage |
The LeRobot guide puts it in one sentence that deserves to be on a sticky note: most “timeout” errors are physical, not code.
There is one more source of confusing errors that is neither of those. LeRobot configures two read retries on these buses, and the source comment explains why: Feetech buses occasionally return a corrupted status packet, reported as Incorrect status packet!, especially when several joints move at once. A single such message is normal noise on a shared wire. A stream of them is a real problem.
The two ways to actually destroy one
Almost everything on this arm is recoverable. Two things are not.
Without hardware
- Instead of the bus, MuJoCo addresses joints by name out of the MJCF. Look one up with
mj_name2idand give it a name that does not exist. - Measure this: the difference between the two namespaces. A model file raises on a name collision or a missing name. A serial bus with two motors at id 1 raises nothing at all and answers with whichever replied first.
- That is the whole lesson in one sentence, and it generalises: the failure modes that cost you an afternoon are the ones with no error path. Sim gives you an error path for free, which is exactly why it cannot teach you this one.
Check yourself
1. Every motor ships with ID set to 1. Why is that a sensible default rather than a manufacturing oversight?
Because the manufacturer cannot know your topology, and the ID is configuration rather than identity. One motor on a bus works out of the box with the default, which is the common case for anyone buying a single servo. Any multi-device bus requires an integration step where somebody decides who is who, and the only place that decision can be made is where the devices are assembled. The default is chosen so a single device is usable, not so twelve are.
2. Two motors share an ID. Why does the software report a timeout instead of “duplicate ID detected”?
Because nothing on the wire can detect it. The bus is half duplex with one conductor and no arbitration: when both motors answer the same question, both drive the line at once and the resulting waveform is neither reply. What arrives is a malformed packet or nothing, and the only observation the driver can make is that a well-formed answer did not appear before the timeout. A duplicate key is invisible to a store with no uniqueness constraint; you find it downstream, as corruption.
3. You set all six IDs, then unplug everything and rebuild the arm a week later. What survives, and what does not?
The IDs survive, along with baud rate, position limits, gains and the homing offset, because those are EEPROM: non-volatile configuration inside each motor. What does not survive is everything in SRAM, which is the live process state: Torque_Enable, the current Goal_Position, and the present measurements. Practically, the arm comes back addressable and configured, and comes back limp and with no commanded target, which is exactly the behaviour you want after a power cut.
4. LeRobot writes P = 16, I = 0, D = 32 into every motor on connect. Which loop are these the gains of, and why can your Python code not see its output?
The position loop inside the servo, the one Module 0 described as the controller you did not write. It compares the encoder reading against Goal_Position and drives current into the motor until the difference disappears, running far faster than your control loop. Your code sits outside the box: it writes an angle and reads an angle, and the current that got the arm there never crosses the boundary. Setting these gains is the only influence you have over how that inner loop behaves.
5. Failed to sync read 'Present_Position' on ids=[1,2,3,4,5,6]. What do you check, in order?
LEDs first, because most timeouts are physical. Dark motors or a chain that stops part way means cabling: reseat the three-pin connectors and check the board’s power lead, which is exactly the one that works loose. Blinking means an error state, usually overload or wrong supply voltage. Only with all six steady red do you move to software: the right serial port, the jumpers on the B channel if the board is a Waveshare, and duplicate or missing IDs from an interrupted setup. Note that an occasional Incorrect status packet! is normal noise on this bus and is what the two configured read retries exist to absorb.
6. Why does the gripper ship with its torque and current permanently capped at half?
Because it is the motor that burns out. A gripper closes on objects, and closing on something it cannot compress is a stall: full current, zero speed, all of the electrical power turning into heat in the windings, which is the failure mode Module 0 described. The other five joints stall occasionally; the gripper stalls as part of doing its job. The library caps it rather than trusting the operator, which tells you how routinely operators cook them.
Do this
Hardware procedure. Have both arms’ motors to hand, unconfigured, and nothing installed in the printed parts yet.
1. Install LeRobot and check what you got.
conda create -y -n lerobot python=3.12 && conda activate lerobot
conda install ffmpeg -c conda-forge
pip install 'lerobot[feetech,core_scripts,training]'
2. Find the ports, one arm at a time.
lerobot-find-port
It asks you to unplug a device so it can spot which port disappeared. Run it once per control board. Expect /dev/tty.usbmodem... on macOS and /dev/ttyACM0 on Linux, and on Linux expect to need sudo chmod 666 /dev/ttyACM0 before anything can open it.
3. Capture the state before you change anything.
cd module-04-hardware/code
python bus_preflight.py --follower-port /dev/ttyACM0 --leader-port /dev/ttyACM1
It lists the serial ports it can see, prints the ID plan, and prints the exact setup commands for your ports so you are pasting rather than typing. It does not touch the motors. It has not been run against a physical arm by the author, so read it before you trust it.
4. Set the follower’s IDs. Run the follower command from step 3, and follow the prompts with one motor connected at a time, gripper first. Check the cabling before every press of Enter. Tick each motor off on the build sheet as it is written.
5. Set the leader’s IDs the same way, with the leader command.
6. Acceptance test. Chain all six motors of one arm, power the board, and look at the LEDs: steady red the whole way from gripper to base. That is the state the next lesson assumes.
7. Now go back and build. With every motor configured, the assembly gate from the previous lesson is open.
What you can now do
You can explain why six motors on one wire need hand-assigned addresses, why the assignment happens one motor at a time in reverse order, and why a duplicate ID surfaces as a timeout rather than an error. You can read the register map of the controller Module 0 left as a black box, say which registers survive a power cut and which do not, and point at the exact numbers that admit the gripper is the motor most likely to die. And you can triage a silent bus from three LED states before opening a log file.