Drive ~7 min read
at the endYou know every rule the guard enforces, the incident that put it there, and the line of code that holds it.
The guard¶
Nothing writes to a servo except strands_arm/guard.py. This page lists what it refuses and why.
Why this page exists: a leader arm has no brakes, no gearbox worth the name and no mechanical stop on the head. It is light enough that a wrong goal does no damage to the room, and heavy enough to fall on its own camera. Every rule below was added after something moved that should not have. Read it before you turn the bench flag on.
The rules at a glance¶
Ten rules, what holds each one, and the incident that taught it.
| rule | held by | taught by |
|---|---|---|
| a servo goal is clamped, not refused, so the guard checks the EEPROM window first | check() with limits, read at boot by dashboard/arm_io.py |
tilt jump, 05:20Z |
| never release torque with the arm up | conduct, autocal returns to the fold first |
the drop, 02:50Z |
| joints 1 to 4: at most 10° per command, 30°/s | STEP_DEG, SPEED_DPS, _ramp() |
the drop, 02:50Z |
| pan and tilt: inside the head window, zero ±160° / ±80° (the yoke's clearance; neither axis has a stop). Falls back to ±60° of home when the window excludes the rest pose | head_window() ← head_tool.limits(), PAN_TILT_RANGE_DEG |
cable slack 02:05Z; opened by calibrate manual |
every arm joint: inside its hand-measured window (calib.json min_deg..max_deg), the metal's own range |
measured_windows(), rule 4c; the EEPROM windows of 1–4 were written from it |
calibrate manual, measuring the real range |
| no goal across the 0/360 tick wrap | WRAP_MARGIN_DEG |
home sat on the wrap, 05:20Z |
| torque off 2 s after the last command | IDLE_S, _idle_off() |
assembly 02:05Z |
| a reply must come from the id that was asked | feetech.read() id check, one-block telemetry |
pan incident, 06:40Z |
| under 6.5 V on the bus the arm may fold and hold, never rise; the head stays free | LIFT_MIN_V, rule 8, Hw._volts from the poller |
leader kit's 5 V supply: elbow stalled at 25 % load on the way up |
| motion verbs refuse without consent | teach.bench_ok, --i-know, STRANDS_ARM_I_KNOW=1 |
day one |
Every accepted and refused command appends one JSON line (time, route, body, result) to ~/.strands-arm/motion.log. That file is how two of the incidents below were reconstructed.
The incidents, in the order they happened¶
02:05Z, assembly: the first rules¶
The head went on the arm and motion was allowed for the first time, with four conditions written down before anything moved: pan and tilt stay within 60° of home because the Nicla's USB cable and antenna have finite slack; joints 1 to 4 move in 10° steps at 30°/s so a wrong goal is a small goal; torque drops when idle; every session returns to the folded home. All four are constants in guard.py; the 60° is the fallback behind the measured head window.
02:50Z, the drop: never release torque above the fold¶
The arm was walked from the fold to the LeRobot mid-range pose, all joints at 180°, through the guard. That part worked. Then torque was released while the arm stood upright. A leader arm holds nothing with torque off, so it fell onto its own fold stops. Nothing broke. The rule since: torque is released in the folded pose and nowhere else. autocal walks every joint back to the fold, settles, reads back within 2° of home, and only then turns torque off. Closed since: rule 7 (hold until folded, release() folds first) applies to every arm, leader included; STOP upright halts the ramp and holds. guard.torque(off) direct is the one unchecked path, kept as the emergency.
The same session showed a second thing: the elbow stalled at 202.9° because a killed autocal run had left TORQUE_LIMIT (RAM register 48) at 350 per mille. autocal now restores 1000 on every joint it touched before it exits, on success or abort.
05:20Z, the tilt jump: windows clamp, they do not refuse¶
The first sanctioned head test sent tilt (servo 6) to its home reading of 3.9°. Servo 6 still carried the gripper calibration from its LeRobot life: an EEPROM angle window of 177.4° to 290.2°. An STS3215 does not reject a goal outside its window. It clamps the goal to the nearest edge and drives there at full speed. Servo 6 swung about 173° to 177.4°. No damage, the head hung at roughly 326°.
Three rules came out of it. arm_io.py reads MIN_ANGLE and MAX_ANGLE (registers 9 and 11) from every servo at boot into state.joints; check() refuses any goal outside them with the message "it would clamp and jump". Because home for pan and tilt sat at 357.2° and 3.9°, right on the 0/4096 tick wrap, check() also refuses any goal within WRAP_MARGIN_DEG of the wrap: in position mode the servo drives the absolute tick, so 3.9° to 350° would be a 346° sweep the long way. And the fix that removed the root cause: servos 5 and 6 were re-indexed so the folded home reads 180° on both, off the wrap, and the windows of servos 2 and 3 were opened so the fold stops sit inside them. See Calibrate.
06:20Z, the dashboard drag: the guard held¶
motion.log shows 130 entries in one minute from the dashboard's joint drag: shoulder lift walked 84° to 180° in 10° steps, one refusal logged ("10.0 from current, max step 10"), then joints 3 and 4 re-commanded forty times each. Joint 3 never reached its 193.9° goal and stopped at 234.8°. Torque was released upright a second time and the arm settled back onto its fold stops. No rule was broken by the guard here; it is in this list because it is the reason the log exists and the reason the fold rule above says "conduct, not code".
06:40Z, the pan incident: trust the wire less¶
A pan calibration run aborted 60 ms in on a voltage reading of 4.9 V, yet the pan finished its 45° goal. Two faults at once. The broadcast torque-off is unacknowledged on the Feetech bus and this one was lost. And in the same session telemetry(6) returned servo 1's angle: a late reply from the previous transaction landed in the next read. A later abort message, "id 1: 47 °C", was really the voltage byte 47 read as temperature.
Fixes, all in feetech.py and autocal.py: a read or write rejects any reply whose id is not the one asked; telemetry is one 8-byte block read of registers 56 to 63 so nothing can land in the wrong register; a halt sets goal to present position, turns torque off and reads TORQUE_ENABLE back, three tries, and reports failure; a voltage abort needs two samples under 4.3 V because pan, at the end of the 5 V chain, idles at 4.6 V.
Consent¶
Reading the arm is always free: doctor, scan, pose, the dashboard telemetry. Anything that moves or releases the arm needs one of three things:
--i-knowon the command line, ori_know=trueon the tool callSTRANDS_ARM_I_KNOW=1in the environment{"ok": true}in~/.strands-arm/bench.json
The file is the "bench is clear" switch. It is fail-safe: missing, unreadable, not an object, or any value other than true means no. Write it by hand when you have looked at the arm:
{"ok": true, "at": "2026-09-05T16:00:00Z", "by": "cagatay", "note": "desk clear, head fitted"}
strands-arm doctor prints which of the three you are running under and, when none, the exact line to write.
What the guard does not do¶
- It does not know where the table is. Joint windows keep the servos inside their own travel, not out of furniture.
- It does not stop a fall. Torque off is torque off.
- It does not check the pose before releasing torque. You do, or
autocaldoes. - It does not run on the Nicla. The head firmware never commands a servo.
Next: Calibrate, where the home and the windows the guard trusts come from.