Where you are. Both arms are calibrated and their numbers mean something. Nothing has moved yet under software control. This lesson is the first command, and the discipline of reading what comes back.
Push it before you drive it
Power the follower off and push its forearm with one finger. It swings. The joints have friction and a gearbox behind them, so it is not floppy exactly, but you can put the arm anywhere you like and it stays there in a loose, agreeable way.
Now run a script that does nothing except connect to the arm and exit twenty seconds later. Nothing moves. No joint twitches. From across the room, nothing happened.
Push the forearm again while the script is still running.
It pushes back. Expect the arm to have gone from agreeable to stubborn without moving a millimetre and without you asking for anything. Confirm it with your hand, because that stiffness is the real first event, and everything the rest of this module does happens on the far side of it.
The idea in one paragraph
Your first motion is not a demo. It is a continuity test of a chain that runs from a Python dictionary, through a USB serial port, a control board, a one-megabaud daisy chain and a motor’s own controller, into a gearbox and a joint. Command one joint by five degrees, then read the joint back, and you have exercised every link. Two things make this worth doing deliberately rather than skipping to teleoperation: each hop fails with its own recognisable signature, and the number that comes back will not equal the number you sent - not because anything is broken, but for reasons that will shape every policy you train from here on.
Wider than the screen; scroll it sideways.
Finding the two ports
Each arm shows up as a serial device. There are two of them and they look identical, so LeRobot resolves it by elimination: run the tool, unplug the arm you are identifying when prompted, and it reports which device disappeared.
lerobot-find-port
On macOS you get something like /dev/tty.usbmodem58760431541; on Linux, /dev/ttyACM0. On Linux you may also need to grant yourself access to the device before anything will open it:
sudo chmod 666 /dev/ttyACM0
What connect() does before you ask for anything
That stiffness in the hook is not a side effect. Connecting is a configuration step, and it writes to your motors.
| What gets written | Value | To which motors |
|---|---|---|
| Operating mode | position | all six |
| P coefficient | 16 | all six |
| I coefficient | 0 | all six |
| D coefficient | 32 | all six |
| Max torque limit | 500 of 1000 | the gripper only |
| Protection current | 250 | the gripper only |
| Overload torque | 25 | the gripper only |
Torque is switched on too, which is the stiffness you felt, and this one is readable in the source rather than inferred: the whole configuration block runs inside a context manager called torque_disabled(), whose docstring says it “guarantees torque is re-enabled” on exit. It drops torque so the settings can be written, then puts it back. Your hand confirms it in two seconds, which is why the hook asks you to push.
The smallest useful command
The robot object gives you two calls that matter. get_observation() returns the current state as a dictionary; send_action() takes a dictionary of targets. Read, modify one entry, send, read again.
obs = robot.get_observation()
print(sorted(obs)) # confirm the key names on YOUR version first
key = "shoulder_pan.pos" # whatever the line above actually printed
start = obs[key]
robot.send_action({key: start + 5.0})
Print the keys rather than trusting any tutorial, including this one. Naming has changed across releases, and a script that discovers its own key names keeps working when a rename lands. code/first_motion.py is written that way, and it will tell you what it found.
Choose shoulder_pan for the first command. It rotates the whole arm about a vertical axis, so gravity does not act along its direction of travel; it is the joint where the least can surprise you. Choose five degrees, which is large enough to see and small enough to be boring.
Why the number that comes back is wrong
Command five degrees, wait a second, read the joint, and expect to find it somewhere short. Perhaps four point six. Nothing is broken.
Wider than the screen; scroll it sideways.
Four things put the gap there.
Finite gain with no integrator. The motor’s internal loop is proportional and derivative only; the integral coefficient is zero. A proportional controller produces effort in proportion to the error that remains, so against a constant load such as gravity it settles at whatever error generates just enough torque to balance it. This is exactly the steady-state offset you derived in Module 1, arriving in hardware.
Static friction, multiplied. A few-hundred-to-one gearbox has real friction. Near the target the commanded current falls below what is needed to break stiction, and the joint simply stops. The effect is an error band the controller cannot see its way out of.
Backlash. The clearance in the gear teeth means the last fraction of a degree turns the motor and not the joint, and it lands differently depending on which direction you approached from.
Gravity, on every joint but this one. shoulder_pan is the easy case precisely because gravity pulls sideways to its motion. Try the same five degrees on shoulder_lift with the arm held out and expect the gap to grow.
When nothing moves, look at the lights
Sooner or later a command produces silence, or a timeout naming some subset of ids. The instinct from software is to read the traceback. The correct first move is to look at the arm.
Every motor has an LED. Read the chain from the base outward before touching any code.
| What you see | What it means | What to do |
|---|---|---|
| All steady red, the whole chain | Wiring is fine | The problem is upstream: port, ids, or your code |
| One or more motors dark, chain stops partway | A break at or just before the first dark motor | Reseat the 3-pin cables, check the board’s power, make sure each connector is fully clicked in |
| One or more LEDs blinking | That motor is in an error state | Usually overload or the wrong supply voltage. Power-cycle after fixing the cause |
Wider than the screen; scroll it sideways.
One error message deserves early recognition because it looks alarming and often is not: Incorrect status packet!. Feetech buses occasionally return a corrupted reply, especially when several joints move at once. LeRobot retries reads twice by default for exactly this reason. An occasional one is bus noise; a constant stream of them is a wiring or power problem.
Without hardware
- Instead of the arm, run the same experiment in MuJoCo: set one joint’s
ctrlfive degrees away from where it is, step until it settles, and readqposback. - Measure this: the residual gap and the number of steps to settle. In simulation the gap comes from the position actuator’s gain and nothing else, so it is stable and repeatable.
- What you lose: every one of this lesson’s failure signatures. A wrong port, a wrong baud rate, an unpowered bus and a servo in an error state all produce distinct symptoms on hardware and none of them exist in a model. Read the signature table; you will need it on the day you do plug something in.
Check yourself
1. You run a script that only calls connect() and sleeps. Nothing moves. Has anything happened?
A great deal. Connecting writes the operating mode and the P, I and D coefficients to all six motors, writes torque, current and overload caps to the gripper, and enables torque, which is why the arm stiffens under your hand without moving. Some of those writes land in non-volatile memory on the motors. “Nothing moved” and “nothing happened” are different statements on hardware.
2. You command shoulder_pan by five degrees and it settles at four point six. Name two mechanisms, and say why raising the P coefficient is the wrong fix.
The internal loop is proportional and derivative with the integral coefficient at zero, so a constant load leaves a permanent offset; and gearbox stiction means the current commanded near the target is too small to break static friction. Raising P attacks both, at the cost of overshoot, buzzing and heat, and it changes the dynamics your demonstrations will encode. If the arm behaves one way while you record and another way while you deploy, you have manufactured a distribution shift.
3. Why is shoulder_pan the right joint for a first command, and what changes on shoulder_lift?
shoulder_pan rotates about a vertical axis, so gravity acts perpendicular to its motion and contributes nothing to the load. It is the cleanest test of the chain. shoulder_lift carries the weight of everything beyond it, so a constant gravitational load acts along the direction of travel, the proportional-only loop settles further from the target, and the gap depends on the arm’s pose.
4. A command times out on ids 4, 5 and 6 but succeeds on 1, 2 and 3. What is your first hypothesis, and where do you look?
A physical break in the daisy chain between motor 3 and motor 4. Ids 1 to 3 are electrically upstream of the fault and answer normally; everything downstream is unreachable. Look at the LEDs: expect the chain to be lit up to motor 3 and dark from 4 onward, which localises the fault to one connector. Reseat that cable before reading a single line of the traceback.
5. Your script prints an occasional Incorrect status packet! and carries on. Is that a bug you introduced?
Almost certainly not. Feetech buses sometimes return a corrupted status packet, especially when several joints are moving at once and drawing current. LeRobot retries reads by default because of it. Treat an occasional one as bus noise. Treat a steady stream as a real wiring, connector or power problem, since the retry budget is not a fix.
6. Why does code/first_motion.py print the observation keys instead of hardcoding them?
Because names move between LeRobot releases, and a hardcoded key fails with a KeyError that tells you nothing about which name replaced it. Discovering the keys at runtime makes the script self-describing: when a rename lands, the print tells you the new name immediately. It is the same reason you pin the version and note it: this is a fast-moving dependency, and scripts that assume otherwise rot.
Do this
About an hour with the follower on the bench. The follower alone is enough; the leader stays unplugged until the teleoperation lesson.
1. Find and label both ports. Run lerobot-find-port once per arm. Tape and label both USB cables. Add the ports to the same notes file that holds your calibration ids from the previous lesson.
2. Read the script before you run it.
python code/first_motion.py --dry-run
Dry-run needs no hardware. It prints exactly what it would connect to and what it would command, so nothing about the first real run is a surprise.
3. Feel the torque come on. Connect without commanding anything, and push a joint while it holds:
python code/first_motion.py --port /dev/ttyACM0 --id kp_follower \
--connect-only --settle 20
Push before you start it and push again while it runs. That difference is the hook, and it is also your first confirmation that the port and the id are right.
4. Take the first motion. Clear the desk around the arm first.
python code/first_motion.py --port /dev/ttyACM0 --id kp_follower \
--joint shoulder_pan --delta 5
Note the commanded value, the value it settles at, and the difference. That difference is your arm’s number, and no lesson can supply it for you.
5. Add gravity. Move the arm so the forearm is roughly horizontal, then repeat on shoulder_lift:
python code/first_motion.py --port /dev/ttyACM0 --id kp_follower \
--joint shoulder_lift --delta 5
Expect a larger gap than shoulder_pan gave you. Write both numbers down. You have just measured, on your own hardware, the steady-state error of a proportional controller under load.
6. Find out what the gap is made of. Repeat on one joint with deltas of 2, 5, 10 and 20 degrees. If the residual stays roughly constant regardless of step size, friction and deadband dominate. If it grows with the step, gain-related effects dominate. Then command +5 and -5 alternately a few times and see whether the arm lands in the same place both ways; the difference between the two is backlash you can measure with software.
7. Learn the failure signature on purpose. Power the arm down. Unplug one 3-pin cable in the middle of the chain. Power up and run the script again. Note which ids fail, what the LEDs look like, and what the error text says. Then put it back. Ten minutes here saves an evening later, because you will meet this signature by accident eventually and it will already be familiar.
What you can now do
You can identify your arms’ serial ports and keep them straight, connect to the follower knowing exactly what that writes to the motors, command a single joint by a known amount and read back what actually happened. You can explain why the reached position falls short and why raising the gain is the wrong response, and you can localise a dead command to one hop of the chain by reading six LEDs before you read any code.