Virtual leader teleop + episode recording for the SO-101 - #94
Conversation
5cd326b to
3b52c7e
Compare
| args = cli.parse(parser) | ||
|
|
||
| rclpy.init() | ||
| node = None | ||
| try: | ||
| node = MockArm(args) | ||
| rclpy.spin(node) | ||
| except (KeyboardInterrupt, ExternalShutdownException): | ||
| pass | ||
| except Exception: # noqa: BLE001 - the context is gone, see cli.spin_background | ||
| if rclpy.ok(): | ||
| raise | ||
| finally: | ||
| if node is not None: | ||
| node.destroy_node() | ||
| if rclpy.ok(): | ||
| rclpy.shutdown() |
There was a problem hiding this comment.
CLAUDE.md compliance: main() hand-rolls its own rclpy.init()/spin()/destroy_node()/shutdown() teardown instead of going through cli.py, even though it already uses cli.parse(parser) above.
Per CLAUDE.md's mote_arm section:
"Every arm CLI exits and parses through
cli.py, because both properties fail silently when hand-rolled.cli.shutdown(node, spinner)shuts the context down, joins the spin thread, and only then destroys the node... This is not a rare race, so a new arm CLI must not hand-roll the teardown."
Every other new CLI in this PR (virtual_leader.py, mirror.py, episode_record.py, episode_replay.py) uses cli.spin_background(node) + cli.shutdown(node, spinner). This file diverges — and the # noqa: BLE001 - the context is gone, see cli.spin_background comment on line 217 even references the function this file doesn't call, suggesting the exception handling was copied from a spin_background-based file without adopting the pattern itself.
Suggest switching to spinner = cli.spin_background(node) / cli.shutdown(node, spinner) for consistency with the rest of the PR and the documented rule.
There was a problem hiding this comment.
Changed in 0fa5c1b — mock_arm now spins on a worker thread and exits through cli.shutdown. The stale # see cli.spin_background comment pointing at a function the file never called is gone with it; that was the real defect here, and it is exactly the reading you drew from it.
One correction to the severity, because it changes what the rule is buying. This was not a latent abort. I measured both forms, SIGINT after 4 s, three runs each:
| exit | terminate called |
|
|---|---|---|
| before | 0, 0, 0 | 0 |
| after | 0, 0, 0 | 0 |
The reason is that mock_arm spun in the main thread, so spin() has already returned by the time the finally block destroys the node — there is no executor still holding it, which is the thing cli.shutdown exists to prevent. Task 259 measured the opposite for jog and arm-pose list (exit 134, 3/3), and those two spin on a background thread. So the documented "not a rare race" is about the background-spin pattern specifically.
Worth having the change anyway: CLAUDE.md's rule is stated for every arm CLI without qualification, one teardown is better than two, and the main thread now has somewhere for SIGINT to land rather than relying on where spin() happened to be running.
| PIDS=() | ||
| cleanup() { | ||
| for pid in "${PIDS[@]:-}"; do | ||
| kill "$pid" 2>/dev/null || true | ||
| done | ||
| wait 2>/dev/null || true | ||
| } | ||
| trap cleanup EXIT | ||
|
|
There was a problem hiding this comment.
Orphaned process leak: cleanup()/stop() (line 53) both do a plain kill "$pid" on the PID of a backgrounded ros2 run ... invocation (see the call sites at lines 62-63/67).
Per CLAUDE.md's "Stray ROS processes" section:
"
ros2 runis a wrapper thatPopens the real executable and handles no SIGTERM (onlyKeyboardInterrupt, assuming a terminal Ctrl-C reached the whole group), soproc.terminate()on it kills the wrapper and hands the node to init — once per run, on the success path... Everyros2 runspawn goes through it [spawn_reapable/reap_group]."
This script has no setsid, no process-group kill (kill -- -$pid), and no equivalent of spawn_reapable/reap_group — just a bare kill "$pid" on the wrapper's PID. Since this is pixi run arm-teleop-test, meant to be run repeatedly as a pre-bench gate, each run risks leaking a mock_arm, arm_mirror, and virtual_leader process — the exact class of leak pixi run sweep exists to catch.
There was a problem hiding this comment.
Fixed in 0fa5c1b, and confirmed by measurement first: arm_mirror and mock_arm both survived a run that exited 0.
Every job is now setsid-ed into a session of its own and torn down by process group and then by session id — the sim smoke test's scoping (run_sim_smoke.sh), and the shell equivalent of spawn_reapable/reap_group. The session id is read back with ps -o sid= and dropped if it matches the script's own, since a setsid that did not detach would otherwise have the teardown kill the script and its caller.
Same run afterwards: 0 surviving nodes, loop still passes.
| if spec.camera is not None: | ||
| missing = [ | ||
| f for f in frames if not f.image or not (path / f.image).exists() | ||
| ] | ||
| if missing: | ||
| problems.append( | ||
| f"{name}: {len(missing)}/{len(frames)} frames have no image" | ||
| ) | ||
| sizes = {(path / f.image).stat().st_size for f in frames if f.image} |
There was a problem hiding this comment.
Bug: when a frame declares an image filename that's missing on disk, this crashes with an unhandled FileNotFoundError instead of reporting the diagnostic it just computed.
missing (lines 57-59) correctly detects frames with an absent image file and records a problem for it, but sizes (line 64) filters only on f.image being truthy — not on the file actually existing — so it re-.stat()s the very same missing file and raises, skipping the problems report entirely (and any problems for later episodes).
| if spec.camera is not None: | |
| missing = [ | |
| f for f in frames if not f.image or not (path / f.image).exists() | |
| ] | |
| if missing: | |
| problems.append( | |
| f"{name}: {len(missing)}/{len(frames)} frames have no image" | |
| ) | |
| sizes = {(path / f.image).stat().st_size for f in frames if f.image} | |
| sizes = { | |
| (path / f.image).stat().st_size | |
| for f in frames | |
| if f.image and (path / f.image).exists() | |
| } |
There was a problem hiding this comment.
Fixed in 0fa5c1b. Reproduced first with a capture whose last frame names a file that never landed: FileNotFoundError before, PROBLEM episode_000: 1/30 frames have no image after.
Taken slightly differently from the suggestion — the fix is one reading of "this frame has an image", used by both checks, rather than the same predicate written twice:
present = [f for f in frames if f.image and (path / f.image).exists()]
if len(present) < len(frames):
problems.append(f"{name}: {len(frames) - len(present)}/{len(frames)} frames have no image")
sizes = {(path / f.image).stat().st_size for f in present}
if present and len(sizes) < 2:
problems.append(f"{name}: every camera frame is byte-identical")Two copies of the predicate is how the two drifted apart in the first place. The present and guard is the other half of it: with no images at all, sizes is empty and the byte-identical check would otherwise fire as a second, confusing complaint about frames that are already reported missing.
Teleoperation with no leader arm, and the recordings that make it worth doing. Design and workflow in mote_arm/TELEOP.md. The leader is a pose held in software, moved by the keyboard (virtual_leader), published on leader/joint_states, and turned into arm_controller trajectories by arm_mirror — through mote_arm.control, like every other command client, so the arm keeps exactly one command path. Of the three candidate shapes this is the second: LeRobot's own keyboard teleop would have been cheapest, but it means running LeRobot's robot class and bus driver on the Pi, which is the thing the bring-up decision exists to avoid. LeRobot is where the dataset goes, not where the arm is driven from — and that split is the design. IK jog was out for v1 and stays out. The frontend is deliberately the replaceable part: the mirror's whole contract is leader/joint_states plus a latched teleop/estop, so a slider GUI or a gamepad is a drop-in with no code here. Every safety rule is decided in teleop.py and nowhere else, so all of it is unit-tested without a bus: soft-limit clamping, a 0.5 rad/s rate limit (a leader that jumps becomes a ramp, never a lunge), the deadman, the panic latch, and re-seeding from the measured pose on every resume so a pause cannot bank up motion and pay it out later. The deadman is the leader's *liveness*: a frontend publishes only while it is being driven, so a released key, a closed window and a dropped SSH session all arrive as the same thing. On that transition the mirror issues one goal at the arm's present position — stopping it there rather than letting it coast to the setpoint it was still travelling towards — and then sends nothing, because an absent goal is a hold. Panic is controller deactivation, since torque *is* controller activation now. One structural consequence: the mirror ticks on its own thread rather than on a ROS timer. Taking hold of the arm is a switch_controller call, and a service call made from inside an executor callback can never complete — the future is resolved by the executor the callback is blocking. arm-jog avoids this by driving from its REPL thread; the mirror does the same with a plain loop while cli.spin_background spins the node. Episodes: episode_record samples joint_states (observation), arm_controller/joint_trajectory (action) and /image_raw/compressed at 20 Hz. The action is the mirror's output, not the leader's pose, because a policy replaces whatever produces goals — and it is read off the trajectory topic rather than from the mirror, so an arm-jog session records just as well. It writes a *capture* under $MOTE_HOME/episodes — JSON lines plus the compressed frames stored byte-for-byte, standard library only, since the Pi carries no parquet or ffmpeg and should not have to. tools/lerobot_export.py converts a capture to a real LeRobotDataset off-board, in its own linux-64 pixi env, through LeRobot's own API rather than emitting the files: the format already moved once (v2.1 -> v3.0) and a hand-rolled writer would be wrong the next time it moved. It resamples onto the exact 1/fps grid first — LeRobot derives timestamps from the frame index, so a slipped capture would otherwise export as if its timing had been perfect — and loads the result back to verify. episode_replay reads the capture, not the dataset, so replay needs nothing off-board; it approaches the first pose, replays at a quarter speed, and stops on sustained lag. mock_arm presents the control stack's exact surface — the trajectory topic and switch_controller — with nothing behind it, plus a synthetic camera encoded with zlib and struct, and starts limp as the real stack does. So the whole loop runs on a workstation: pixi run arm-teleop-test drives leader -> mirror -> arm_controller -> arm -> record -> replay -> export plan headless and is the gate before the bench. pixi run arm-bench-teleop is the guided hardware session (BENCH.md step 8); it prompts for the three observations no script can make and writes a report. Verified against the mock control stack: 220 frames over 10.9s with no dropped ticks, replay finishing within 0.0000 rad of the last action at 0.010 rad of steady lag, and an exported v3.0 dataset (aggregated parquet + MP4 shards) that loads back through LeRobot with the right shapes and reads in LeRobot's own lerobot-dataset-viz. The state-only path forced by the camera/arm clash (GitHub #2) exports and verifies the same way. Not verified: anything on the arm itself — that is BENCH.md step 8, and it needs a human. Two things found by running it. Two publishers on one arm fight, which the stall guard caught before the loop script learned to stop the leader first. And mote_arm keeps its own reading of MOTE_HOME rather than importing mote_bringup's: the dependency runs mote_bringup -> mote_arm (the base launch resolves this arm's calibration into the URDF) and colcon cannot order the packages if it runs back — episodes_root therefore goes through mote_arm.poses.mote_home, as arm_gains and calibrate already do. Incidental: the lag-supervision rule moves to motion.py, shared with arm-pose go rather than reimplemented. TELEOP.md is mounted into the docs site (#93) beside the arm's README and bench runbook, so the three cross-link as pages rather than as repo paths. 1005 tests pass, lint clean, `mkdocs build --strict` clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017hfWyNtkVh16hyvCK8K9cd
… crashing Three findings from the PR review, all confirmed by measurement before fixing. run_teleop_loop.sh leaked two nodes per run. `ros2 run` is a wrapper that Popens the real executable and handles no SIGTERM, so `kill $!` killed the wrapper and handed the node to init -- the class of straggler `pixi run sweep` exists to find, in a script meant to be run repeatedly as a pre-bench gate. Measured on this branch: arm_mirror and mock_arm both survived a run that exited 0. Every job is now setsid-ed into its own session and torn down by process group and then by session id, which is the sim smoke test's scoping and the shell equivalent of sweep_orphans.spawn_reapable/reap_group. Same run afterwards: 0 surviving nodes, loop still passes. check_capture.py crashed on a frame whose image file is missing. It computed exactly that diagnostic, then re-stat()ed the same absent file to compare frame sizes -- so the report it had just built was never printed, and neither were any problems for later episodes. There is now one reading of "this frame has an image", used by both checks. Against a capture whose last frame names a file that never landed: FileNotFoundError before, "1/30 frames have no image" after. mock_arm.py hand-rolled its rclpy teardown while every other CLI here goes through cli.py, and carried a `see cli.spin_background` comment pointing at a function it did not call. It now spins on a worker thread and exits through cli.shutdown. To be clear about what this is: it is compliance with the rule in CLAUDE.md and the removal of a misleading comment, not a crash fix -- the old form was measured too and also exited 0 with no abort 3/3, because a main-thread spin() has already returned by the time the finally block destroys the node. The new form is 0/3 aborts as well. 1144 tests pass, lint clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017hfWyNtkVh16hyvCK8K9cd
3b52c7e to
0fa5c1b
Compare
…it can check Found by an operator following the script on mote-01, and every one of these is the script's fault rather than theirs. It printed five instructions "in terminal B", then fired five y/N prompts that only terminal C can accept — so by the time you had switched windows, done the thing and come back, nothing said which of the five was being asked. Worse, the answers collide with live teleop keys: in the leader, 'y' drives joint 6 and 'z' clears the panic latch, so an answer aimed at the wrong window moves the arm instead of being read. Each action is now printed immediately before its own question, every prompt is marked [answer HERE], and the collision is stated rather than left to be discovered. Terminals are named by what runs in them. "Terminal B" means nothing to someone who did not lay the windows out in that order; "the TELEOP terminal" is self-describing. The header also had the count wrong — recording with the camera needs `pixi run launch` and `pixi run arm-mirror` in separate windows, so it is four terminals, not three. Two preconditions were missing, both of which let the operator get a long way in before anything looked wrong: - No check that the virtual leader is running. Every check in step 2 asks you to drive the arm from a window that, with only launch + arm-mirror + this script up, does not exist — and the script announced PASS on its preconditions first. - Step 6 *asked* whether the leader had been stopped and believed the answer. An operator who is wrong gets a replay that loses to the mirror and reports a stall, which reads exactly like the arm failing and is the one failure here that is not about the arm. It now watches for the node to exit instead. Verified against the mock (mock_arm --camera + arm_mirror + virtual_leader --demo): preconditions pass including the new leader check, the interleaved checks read one at a time, an episode records, and the replay runs 200/200 setpoints at 0.010 rad of lag once the leader has actually gone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017hfWyNtkVh16hyvCK8K9cd
Three files told the reader the camera cannot be attached with the arm on, and sent them to record episodes with --no-camera. That has not been true since the arm was re-mounted rotated 180 degrees -- option 1 of GitHub #2, where the camera clears the arm and the price is reach in the forward direction. Recording with the camera works; --no-camera is now what you use on a robot whose camera is off or fouled, not the standing advice. The re-mount left something behind, recorded in the same places rather than fixed here because this branch is teleop and teleop is joint-space: arm_mount_joint in mote.urdf.xacro is still rpy="0 0 0" and so describes the orientation the arm no longer has. Jog, taught poses, teleop and replay are unaffected -- none of them asks where the gripper is in the base frame -- but TF and RViz draw the arm facing the wrong way, and anything reasoning in base coordinates (a fetch standoff, an IK stack) would be 180 degrees out. mkdocs build --strict clean; TELEOP.md is mounted at arm/teleop.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017hfWyNtkVh16hyvCK8K9cd
"Hold the key past the soft limit and watch it stop" was not something the operator could judge. The help printed each joint's limits but never its position, `p` printed positions but only once and was not in the help, and nothing at all was printed while a key was held — so an arm that had stopped at its limit looked exactly like an arm that had stalled, lost its link, or given up. While a key repeats, one line is now rewritten in place for the joints being driven: where the joint is, where it is being sent, and the band it is in. When the leader's pose is clamped it says so outright: shoulder_pan +0.224 AT LIMIT (max +0.229) That is the check turned into a reading rather than an inference, and it doubles as the diagnostic for the opposite case: a target that keeps advancing while the measured value sticks is the arm failing to follow, which is a different fault from reaching a limit and looks identical without the numbers. `_out` clears the live line before writing, so ordinary messages never land on top of it, and it is cleared when the leader goes idle or quits rather than being left on screen. `p` is now in the help. Verified through a pty against the mock arm: 'q' held for 3 s drove shoulder_pan from +0.010 to its +0.229 limit and the line switched to AT LIMIT, then cleared on quit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017hfWyNtkVh16hyvCK8K9cd
Teleop on the arm is stuttery and falls short of the joint's range, and three
candidate causes were indistinguishable from outside the process: the mirror not
ticking at the rate it claims, leader poses arriving in gaps, or the arm not
achieving the velocity it is asked for. Two rounds of hypothesis got nowhere --
raising the leader's key timeout changed nothing, and the trajectory topic
measured a clean 20 Hz -- so each candidate is now a number instead.
pixi run arm-mirror --ros-args -p diagnose:=true
prints, twice a second, for whichever joint moved most in the window:
diag tick 19.7Hz worst 51.9ms | leader 19.7Hz worst gap 51.4ms |
shoulder_pan cmd 0.296 arm 0.148 rad/s lag +0.090 rad | tracking
Reading it: cmd at the rate limit with arm well below it, and a lag that grows,
is the arm failing to follow. Both low is the mirror. A leader gap over the
deadman is the input path -- which the deadman then reports as HOLDING, so the
state on the end of the line says whether it fired.
Validated against the mock follower capped at 0.15 rad/s while the mirror was
allowed 0.5: the line reported cmd 0.296, arm 0.148, lag growing to 0.090 rad,
which is the signature the real arm is showing. So the instrument reproduces the
symptom on demand, on a workstation, with no arm attached.
LeaderMirror gained read-only `commanded` and `measured` properties for this;
nothing else reaches into its state.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017hfWyNtkVh16hyvCK8K9cd
Found at the bench, and it is not a stall. shoulder_lift capped at -0.865 rad
every time, in one direction only, while the same joint drove to +1.787 against
its +1.775 limit -- so it was not gravity and not load, which is what the
operator's "same angle every time, and I can move it further the other way"
established.
A goal is written to a 12-bit register, and mote_hardware clamps it:
return std::max(0, std::min(ARM_COUNTS_PER_REV - 1, counts));
At 2*pi/4096 rad per count, -0.865 rad is 564 counts. A joint whose zero sits
564 counts up therefore has count 0 as its floor: the soft band says -1.775,
the register cannot express past -0.865, and the goal silently saturates there.
Both copies of that clamp already carried a comment about saturating at 0 or
4095 -- nothing checked for it, so it was a comment about a defect rather than a
guard against one.
JointSpec now computes the band the register can actually address
(`reachable_min`/`reachable_max`) and names the discrepancy (`unreachable`);
ArmConfig.problems collects them. Reported rather than raised, because a robot
with one over-wide band should still come up and drive its other five joints.
The virtual leader's help now lists the *reachable* limits, says what they were
narrowed from, and prints the warning; arm_mirror logs it at startup. The remedy
is `pixi run arm-calibrate`, which re-centres the zero by writing the servo's
homing offset -- which is what moves the addressable window.
Second defect, from the same session and visible in the diagnostics: the mirror
commanded a stationary arm at 0.25 rad/s for four seconds and let the lag grow
without bound, 0.087 -> 0.812 rad, straining the servo against a target ever
further ahead and lurching on every re-seed. arm-pose go has had a stall guard
since it was written; the mirror had none. The commanded pose may now never lead
the measured one by more than `max_lag` (0.15 rad, well above the 0.01-0.03 of
ordinary droop), so it waits for the arm instead of running away, and reports
which joint is not following. Reproduced against a mock follower pinned at zero
speed: lag pinned at 0.150 rad instead of growing, with the joint named.
222 tests pass, including three that pin the register arithmetic for plain and
inverted joints and three for the stall clamp.
Not fixed here: the same clamp in mote_hardware/include/mote_hardware/arm_joint.hpp
is still silent, and it is the authoritative one. Filed separately.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017hfWyNtkVh16hyvCK8K9cd
Diagnostic tooling the bench run needed, and the only piece of arm state nothing here has ever looked at. shoulder_lift on mote-01 stops dead at -0.865 rad while reaching +1.787 in the other direction. Everything above the servo is ruled out by numbers: the URDF limit is +-3.14, MoteHardware clamps to arm.yaml's +-1.7754, and rad_to_counts' register clamp does not bite with zero=2048. arm-jog -- one trajectory per keypress, hundreds of milliseconds long -- hits the same wall as teleop's 20 Hz stream, so it is not the command pattern either. That leaves the servo. STS registers 9-12 hold Min/Max_Angle_Limit, and in position mode the servo refuses a goal outside them: silently, in one direction, at any load. Which is indistinguishable from running out of torque, and invisible in robot.yaml, arm.yaml, the URDF and every tool in this package, because none of them read those registers. The bus layer knew torque, mode, gains, homing offset, position, load, voltage and temperature -- not this. `pixi run arm-check` now prints, per joint, the band the servo will accept in both counts and radians beside the configured band, and flags any joint whose configuration asks for more than the servo allows. Read twice and trusted only on agreement, the same rule read_gains follows, because a single read of EEPROM on this bus has been seen to come back garbled. It is a read, not a write: nothing here changes a limit. Whether these should be managed the way arm.yaml manages zeros and gains is a separate question, and this is what makes it answerable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017hfWyNtkVh16hyvCK8K9cd
`shoulder_lift` stopped dead at -0.865 rad against a configured -1.7785, at 0%
load, with the commanded position running 0.8 rad past it. It was not the
teleop path: `arm-jog`, one trajectory per keypress, hit the same wall.
EEPROM registers 9 and 11 (`Min_Angle_Limit`/`Max_Angle_Limit`) fence which goal
positions a servo will accept and refuse the rest in silence -- no error, no
status bit, no log line. Read off mote-01's arm:
joint id min max accepts (rad) configured
shoulder_pan 1 1123 3079 -1.419 .. +1.582 -2.033 .. +2.033
shoulder_lift 2 1478 3859 -0.874 .. +2.778 -1.778 .. +1.778
elbow_flex 3 716 2941 -2.043 .. +1.370 -1.646 .. +1.646
wrist_flex 4 709 3075 -2.054 .. +1.575 -1.761 .. +1.761
wrist_roll 5 0 4095 -3.142 .. +3.140 -2.880 .. +2.880
gripper 6 2047 3510 -0.002 .. +2.243 -1.073 .. +1.073
1478 counts is -0.874 rad about a zero of 2048, which is where the joint
stopped. Five of six joints are fenced inside their own travel, which is the
rest of "not going its full range".
This retracts e741c44's root cause. That commit blamed the 12-bit goal
register saturating at 0, which requires a zero 564 counts up; every joint on
this arm is centred on 2048, so nothing there was ever clamped. Its two guards
stand -- the mirror's stall clamp is what made this cap legible in the first
place -- but they did not explain it.
Two properties hid the fence. It binds only under torque, so `arm-calibrate`
sweeps a limp joint straight through it and measures travel the arm afterwards
refuses to make -- the calibration and the arm disagree and only the arm is
wrong. And the band is compared against the corrected goal, so moving a zero
moves what it fences without changing a number anyone can read. (Measured: the
cap appeared at -0.865, not at the +0.824 a raw-frame fence would give.)
So `arm-calibrate` clears the fence in phase 2 before it writes an offset, and
`pixi run arm-limits show|clear|restore` is the standalone path. The as-found
bands go to `$MOTE_HOME/arm_limits_backup.yaml` before the first write: like the
offset register, they exist nowhere else, and they are the only record of how
these servos shipped. `arm-check` reports the band beside the configured one.
Cleared, not narrowed to match. The guard is the soft limit in
`$MOTE_HOME/arm.yaml`, enforced by `MoteHardware::clamp_rad` and by `teleop.py`,
where it is versioned and printed by three commands; a second copy in EEPROM
adds nothing until the two disagree, and then it wins invisibly. Hence no
`arm-limits set` -- a narrower envelope belongs in arm.yaml.
238 tests pass. Nothing was written to the arm: the diagnosis is a register
read, and clearing the fence is a persistent hardware change for an operator to
make with the arm in front of them.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017hfWyNtkVh16hyvCK8K9cd
A LeRobot calibration, before any of the Mote arm work, and the arithmetic says so rather than the guess doing. The fence spans match the travel `arm-calibrate` sweeps to within 1, 3, 5 and 14 counts on gripper, shoulder_lift, wrist_flex and elbow_flex -- a recorded range of motion of this arm, not a factory default. The shift between the fence and that sweep equals the change in each servo's homing offset since the arm arrived (626 vs 616, -226 vs -215, -157 vs -152, +740 vs +726), so it was recorded while the servos still carried the offsets they came with, i.e. before 2026-07-28. The two odd joints identify the tool, and both are already written up in this README as things LeRobot does. wrist_roll is the one unfenced joint, and it is the one joint LeRobot hard-codes as full-turn and skips. shoulder_pan's band is 760 counts short, which is what an unwrapped min/max `record_ranges_of_motion` yields for a joint whose travel crosses 0/4095 -- and shoulder_pan is one of the two that do. LeRobot also demonstrably writes these registers here: the cache at ~/.cache/huggingface/lerobot/ lists the drive wheels at 0-4095, and the wheels read 0-4095. Inferred, not measured: there is no arm entry in that cache, so the arm's run happened on another machine or before the arm shipped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017hfWyNtkVh16hyvCK8K9cd
Answers "should we be saving the limits as part of calibration too?" -- yes, and the LeRobot file found on the workstation is why. `so101_follower.json` (2026-05-12) holds exactly the six bands read out of the servos tonight, beside the homing offsets the arm arrived with. LeRobot wrote fence and offset in one run and they agreed. What broke is that `arm-calibrate` moved the offsets on 2026-07-28 and left the fence behind: a band is compared against the *corrected* goal, so it goes on refusing the same counts, which now name different angles. Five of six joints were capped for four months at 0% load. So the fix is not to leave the arm unfenced, it is to stop splitting the pair. Phase 2 now unfences every joint, moves the zeros, and fences each joint at the stops it just measured -- in that order, so a run that dies in between leaves an unfenced arm, which is recoverable, rather than one fenced in a frame nothing uses. Both as-found sets are snapshotted before the first write. One confirmation covers both, since phase 2 already had one. The band written is `calibrate.fence_counts`: the *measured travel*, not the soft limits -- wider by --margin at each end. That is what makes it a backstop rather than a second opinion. arm.yaml always binds first, so the fence can never be what stops the arm in ordinary use, and `arm-limits show` reporting a band narrower than the configured one now means something is wrong rather than meaning Tuesday. What it catches is a soft limit that has gone wrong: a hand-edited arm.yaml, a URDF that never received one, a servo swapped under a stale calibration. --skip-homing promises to write nothing to the servos, so it reports a cutting fence instead of correcting it. Two of LeRobot's six bands were wrong when written, which is worth knowing before trusting one: wrist_roll is unfenced because LeRobot hard-codes the SO-101's wrist_roll as full-turn and skips its range, and shoulder_pan's band is 760 counts short because an unwrapped min/max `record_ranges_of_motion` mis-records a joint whose sweep crosses 0/4095 -- which shoulder_pan does. 251 tests pass. test_calibrate_phase2.py drives the whole phase against a fake bus whose position reading follows its offset register, so an ordering mistake -- fencing before the zeros move, backing up after the first write -- fails there rather than on hardware. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017hfWyNtkVh16hyvCK8K9cd
Clearing the fence before moving the zeros cost an EEPROM write per joint and manufactured the very gap it was meant to avoid: every run passed through a state with no fence at all, and a run that died there left one. Nothing commands the arm during calibration, so a stale fence obstructs nothing while the offsets are written -- there was never anything for the clear to protect. Each joint's fence now goes on immediately after its own offset. After, because the band is compared against the corrected goal and means the wrong angles until the frame has moved. Immediately, because the pair is what has to agree: a joint holds either its old band or its new one, and the window where the two disagree is one bus transaction wide. `_clear_fences` and `_write_fences` are gone with it, and so is arm_calibrate's FULL_RANGE -- clearing is `arm-limits clear`'s job, for diagnosis. The fence table now shows what each servo accepts against what it will be fenced at, rather than against the whole register, which is the comparison that matters once a fence is expected rather than exceptional. _abort_partial names `arm-limits restore` beside `arm-offsets restore`. 247 tests pass. test_calibrate_phase2.py pins the log as offset/fence/offset/fence and asserts 0..4095 is never written. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017hfWyNtkVh16hyvCK8K9cd
…off the arm Two defects found in one bench sitting, both from numbers that predate the arm being calibrated. `save` stored what /joint_states reported, raw. Posing by hand is posing a limp arm against its mechanical stops, and the soft limits sit --margin (0.05 rad) inside those, so a captured position is routinely a fraction outside the band: measured at the bench, elbow_flex 0.012 rad past and gripper 0.042 rad past. Such a pose can never be replayed -- every `go` clamps it and says CLAMPED, which is a warning the operator gets minutes later and cannot act on. `save` now clamps into the band and names the joints it held there, so a stored pose is reachable by construction and differs from what was posed by at most the margin. A joint further out than the margin is reported separately: that means the arm and arm.yaml disagree about its limits, not that someone leaned on a stop. `--max-travel` defaulted to 0.35 rad, chosen when the packaged limits were the old `arm-pose limits` envelope whose bands were ~0.2 rad -- wider than a whole joint's configured range, so it fired on nothing. Calibration gave the joints their real ~3.5 rad bands and left the guard refusing the ordinary case: teach a pose, let go, watch the limp arm fall to rest, replay. It now defaults to the widest travel any joint on this arm has, so it refuses an impossible move rather than a merely large one, and the flag stays for a deliberately tighter bench run. What keeps a `go` safe was never that number: setpoints are streamed at --speed so the arm moves continuously rather than lurching, --max-lag stops it if the arm falls behind, and every move is confirmed unless --yes. Also documented, because it is what made the guard fire and reads as a fault: the arm is limp whenever no controller holds it, so it falls to rest the moment you let go. A `go` straight after a `save` starts from the rest position, not from the pose just taught. Observed as elbow_flex reading -1.598 at save and +1.684 at go, with pan, wrist_roll and gripper unchanged -- the three joints that moved are exactly the pitch joints, and they landed on counts 862 and 3146, which is where every prior read of this arm at rest has found them. 254 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017hfWyNtkVh16hyvCK8K9cd
The previous commit sized --max-travel off the arm so it would fire only on an impossible move. That is a flag, a help string, a test and a paragraph of documentation that all describe nothing, which is worse than the fossil they replaced. Removed. Distance is not what makes a move risky once setpoints are streamed. The arm advances at --speed whatever the distance, so a long move is a slow move and not a violent one; --max-lag stops it if the arm falls behind; the soft limits bound the destination; and every move is confirmed unless --yes. The distance limit was a proxy for the lurch that streaming removed, and nobody removed the proxy. 0.35 rad then survived calibration widening the joints from the ~0.2 rad pose-envelope bands to their real ~3.5 rad ones, at which point it refused the ordinary case: teach a pose, let go, watch the limp arm fall to rest, replay. `go` still prints the travel each joint will make and still asks. episode-replay keeps its own --max-travel, and its help now says why: there a long approach means the arm is not where the recording started, so the replay will not reproduce it. That is a check on the episode, not on the motion, and "move it closer" only makes sense read that way. arm-pose go has no expectation about where the arm starts, so it has nothing to check. test_cli.py pinned cli.parse's strictness against `go --max-travel`; it now uses `--max-lag`, which is the same kind of flag and still exists. 250 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017hfWyNtkVh16hyvCK8K9cd
Two things, both found on the second `arm-pose go` of a bench session.
ArmControl assumed `holding = False` at construction, which is a fact about the
graph being guessed by a process that has just started. `go` leaves
arm_controller active on purpose -- that is what holding the pose means -- so
the next command client starts against an already-held arm, asks for a STRICT
switch the controller manager refuses, gets False back from set_holding, and
retries once per streamed setpoint:
Controller with name 'arm_controller' is already active.
Aborting, no controller is switched! (::STRICT switch)
It now reads list_controllers before its first switch, and re-reads after a
refusal rather than reporting failure: a STRICT switch refuses a controller
already in the state asked for, which is the caller's success. The same
assumption made `arm-jog`'s documented limp-on-exit silently do nothing when
something else had left the arm holding -- `set_holding(False)` matched the
assumed False and returned early. `mock_arm` answers list_controllers for the
same reason it answers switch_controller, or every read waits out a 5 s timeout.
And `go` no longer asks y/N. The move is bounded by --speed and supervised by
--max-lag, the destination is a pose the operator taught and `save` has already
clamped into the soft limits, and the travel ceiling that used to justify a
second look is gone. A prompt on every bench move buys none of that, so `--yes`
goes with it. episode-replay keeps its confirmation: that one starts an
unattended replay of a whole recorded episode.
260 tests pass. test_control_holding.py drives ArmControl against fake service
clients, so the state-assumption bug fails there rather than on the second `go`
of a session.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017hfWyNtkVh16hyvCK8K9cd
… a camera `arm_launch.py` had a `mirror:=` argument and `mote_launch.py` did not, which is why the bench session asked for four terminals: a run needing the camera has to be `pixi run launch`, and the mirror then had a window of its own purely because the two launch files disagreed. `pixi run launch mirror:=true` now folds it in exactly as `pixi run arm mirror:=true` does, and the bench is three terminals -- the robot, the leader you drive, and the script that asks. The declaration and the node live once in `launch_utils` (`declare_mirror_arg`, `arm_mirror_node`) rather than being copied into the second file, since the whole defect was two copies of one idea drifting. Still off by default in both: `arm-jog`, `arm-pose` and episode replay all command `arm_controller`, and none of them wants a second thing driving the arm in the same graph. Standalone `pixi run arm-mirror` stays, and TELEOP.md now says what it is for: beside a running mission, where `pixi run robot`/`mapping` already owns the bus and takes no such switch. 277 tests pass; both launch files were generated to confirm the argument and the node land exactly once in each. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017hfWyNtkVh16hyvCK8K9cd
Pressing 'q' at the recorder's first prompt leaves no dataset.json, and `check_capture.py` read one before it reached its own "no episodes recorded" check -- so quitting the step produced a FileNotFoundError traceback under a FAIL line, which says that the check crashed and not that nothing was recorded. It now looks for the capture first and says what to do about it. The bench script then skips steps 5 and 6 rather than asking the operator to export and replay an episode that does not exist: two more FAILs would bury the one thing that actually went wrong. And step 3 now says which key starts a recording, since "ENTER to record episode, 'q' to finish" reads as a choice between two ways of proceeding. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017hfWyNtkVh16hyvCK8K9cd
arm-check, arm-calibrate, arm-gains, arm-offsets and arm-limits were five
commands, and being five hid what they have in common: each opens
/dev/mote_servos directly so the control stack has to be stopped first, and each
writes servo EEPROM, which is per-robot hardware state with no copy in the repo.
They are also all once-off except `check` -- run when the arm is built, a servo
is swapped, or something is wrong -- which nothing in their names said. The arm
is *driven* by arm-teleop, arm-pose and arm-record, which are arm_controller
clients and never touch the bus.
pixi run arm-setup check # read-only
pixi run arm-setup calibrate # the once-off, needs a human
pixi run arm-setup gains show|apply|sweep
pixi run arm-setup offsets show|backup|restore|set
pixi run arm-setup limits show|clear|restore
Four things fall out of the merge rather than being the point of it.
`bus.open_bus` replaces four byte-identical copies of the port guard -- three
chances for one of them to grow a different idea of what "the base is running"
means -- and it now names the port it is refusing to share.
`--yes` and `--robot-yaml` are declared once, where they were on three of the
five tools in two different places (arm_gains had --yes per subcommand).
Every path now goes through `cli.parse`, which none of the five did: `ros2 run`
hands a tool ROS's arguments too, and a mistyped `--jiont` was previously either
fatal or silently dropped depending on which tool you were in.
And the `--` separator is gone from the docs. It was needed because the first
extra argument was a flag; now it is a subcommand.
The five modules keep their names, their internals and their tests; only their
`main()` becomes an `add_subparser`. 282 mote_arm tests and 17 mote_bringup
tests pass, including a new table that pins every subcommand to the handler it
used to reach, so the merge cannot quietly drop one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017hfWyNtkVh16hyvCK8K9cd
`virtual_leader` and `arm_mirror` were two nodes with a `leader/joint_states` topic between them, on the theory that a gamepad or a slider GUI would one day publish that topic instead. Nothing ever did. Nothing off the robot could have: DDS here is loopback-only by design, so the control surface for a remote arm would not be a ROS topic in the first place. And the seam that actually makes a frontend replaceable is `teleop.py` -- every safety rule as a library with no ROS in it, which any frontend imports. What the split bought in practice was a second terminal and a second thing to remember to start. So: one node, `arm_teleop`, holding the keyboard, the safety rules and the arm. `arm-mirror` is gone as an entry point; driving the arm beside a running mission is now just `pixi run arm-teleop`, since it was only ever an arm_controller client. `virtual_leader.py` -> `arm_teleop.py` and `mirror.py` -> a `diagnostics.py` holding the one class worth keeping, both as renames so the history follows. The latched `teleop/estop` topic goes with it. Its stated purpose was that a mirror restarted mid-panic must come up panicked -- and with one process the thing that latches the panic is the thing holding the arm, so exiting drops torque anyway. Two loops survive inside the process, and that part is not incidental: the keyboard reads on the main thread and the safety rules tick on their own, because taking hold of the arm is a `switch_controller` call and a service call made from inside an executor callback can never complete. This also reverts most of 1869458, the `mirror:=` launch argument added earlier today. With no separate mirror node there is nothing for a launch file to start, so `declare_mirror_arg`, `arm_mirror_node` and both call sites go. That argument existed to stop teleop costing a terminal for wanting a camera; merging the processes solves the same problem one level down. 286 tests pass. test_teleop_node.py drives the merged node the way `main` runs it -- executor on a worker thread, tick from this one -- and still holds every safety behaviour: follows, takes hold of a limp arm, rate-limits a jump, halts on release, clamps to the soft limits, latches panic, resumes on clear. Both launch files were generated to confirm the argument and the node are gone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017hfWyNtkVh16hyvCK8K9cd
…ules `arm-jog` was a second keyboard path to the arm and it had none of `teleop.py`'s rules — no rate limit, no deadman, no panic latch, only the soft-limit clamp. It existed for one thing teleop could not do: move a joint by a measured increment rather than for as long as a key is held. So teleop does that now. `m` toggles hold mode and step mode; in step mode each press advances one joint by `--step` (0.05 rad). A held key auto-repeats and a terminal cannot tell a repeat from a fresh press, so a repeat inside `key_timeout` is ignored: holding steps once, and stepping again means releasing and pressing again. That is what jog's typed step and Enter gave, on the one path that is rate-limited, deadman-guarded and panic-latched. A step is offered until the arm has had time to walk it (`settle_time`, the increment over `max_velocity`) rather than once, because the deadman would otherwise fire mid-travel and stop the arm short of the increment asked for. Two things go with jog and are worth naming rather than discovering: a per-joint "drive to 0 rad" command, and `torque on|off`, which SPACE and z replace. The BENCH.md step that jogged each joint through a small range now uses step mode. One latent bug fell out: `h` was listed as a help key, but `h` also drives joint 6 down and the joint keys are matched first, so help there could never fire. `?` alone now. 286 tests pass, six of them new: a press steps exactly once, a repeat does not, releasing and re-pressing does, a step clamps like any other command, the settle window is the step over the rate limit, and stepping the mock arm moves it by about the step. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017hfWyNtkVh16hyvCK8K9cd
"Virtual-leader teleop" is LeRobot's vocabulary: there, an operator puppets a physical *leader* arm and the follower mirrors it. There is no leader arm here and no plan to buy one, so the phrase names a piece of hardware that does not exist, in order to describe a keyboard. It is keyboard teleop. The prose says so, and so do the internals that carried the framing furthest: `LeaderMirror` -> `PoseFollower`, `MirrorLimits` -> `FollowLimits`, `on_leader` -> `on_command`. What the module does is take a commanded pose, apply the safety rules and emit goals; none of that needs a leader to describe. The word survives in exactly two places, both deliberate: the commit trail, and the paragraphs in TELEOP.md and CLAUDE.md explaining what `virtual_leader` and `arm_mirror` were and why they are one node now. 286 tests pass; the rename is mechanical and the behaviour is unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017hfWyNtkVh16hyvCK8K9cd
`arm-bench-teleop` was a test rig for one ticket: 180 lines of bash that prompted for six observations and wrote a report. It never ran to completion, and two of its own defects were fixed tonight rather than any of the arm's -- the terminal confusion, and a traceback when the recorder was quit before its first episode. A one-off rig accruing maintenance is the worst kind. What it was for survives, because it was never the script: three of the checks are observations no test can make -- the arm stopping at a limit, halting on a released key, going limp on panic -- and those are now a numbered list in BENCH.md step 8, beside every other bench step, with the record/check/replay commands under them. The one thing lost is the pasteable report, which mattered once. Step 8 also still said `pixi run arm mirror:=true`, three commits after that argument stopped existing, and now says what to run: two terminals, `arm` and `arm-teleop`. It gained the step-mode check, which nothing covered. `check_capture.py` stays -- `arm-teleop-test` uses it, and step 8 now calls it directly. 282 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017hfWyNtkVh16hyvCK8K9cd
Main brought #115 (GET /v1/robots/<id> with retained state), #117 (the zone split collapsed) and #94 (arm teleop). #115 is the one that met the gate. - fleet_server.py: main still dispatched through the if/elif chain and gave `_robot` its own operator check and path parsing. The route table keeps dispatching; main's `_roster` and `_robot` replace this branch's thin ones, with `_robot` taking `robot_id` from the table and its auth from the gate. - fleetctl.py: main's `robots [<id>]` kept, the roster read now sends the token. - fleet-api.md, CLAUDE.md, README.md: main's "the roster is anonymous until M7" prose rewritten, since the roster is behind the gate here. - Tests: main's two 401 tests and two e2e roster reads pass `token=""` / the operator token, since the harness now sends one by default. Verified: mote_fleet/test 338 passed with a real mosquitto on PATH, none skipped; pre-commit clean; ui_check.py 49/49. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UcGpBYdT7QUHoy8hRH5EzZ
Teleoperation with no leader arm, and the recordings that make it worth doing.
Branch
arm/virtual-leader-teleop(committed, not pushed). Design and workflow:mote_arm/TELEOP.md.What was chosen and why. Of the three candidate shapes, this is the second: a
virtual leader publishing joint targets. LeRobot's own keyboard teleop was the
cheapest option on paper but means running LeRobot's robot class and bus driver
on the Pi, which is exactly what task 163's stack decision exists to avoid.
LeRobot is where the dataset goes, not where the arm is driven from — that
split is the design. IK jog was out for v1 and stays out.
Teleop.
virtual_leader(keyboard,pixi run arm-teleop) holds a leaderpose and publishes
leader/joint_states;arm_mirrorturns it intoarm/goal.The frontend is deliberately replaceable — the mirror's whole contract is
leader/joint_statesplus a latchedteleop/estop, so a slider GUI or a gamepadis a drop-in with no code change. Every safety rule is decided in
teleop.pyandnowhere else, so all of it is unit-tested without a bus: soft-limit clamping, a
0.5 rad/s rate limit (a leader that jumps becomes a ramp, never a lunge), the
deadman, the panic latch, and re-seeding from the measured pose on resume so a
pause cannot bank up motion. The deadman is the leader's liveness — a released
key, a closed window and a dropped SSH session all arrive as "no fresh pose" —
and on that transition the mirror issues one goal at the arm's present position,
stopping it there rather than letting it coast to the setpoint it was still
travelling towards.
Episodes.
episode_recordsamplesjoint_states(observation),arm/goal(action — the mirror's output, because a policy replaces whatever produces goals)
and
/image_raw/compressedat 20 Hz into a capture under$MOTE_HOME/episodes:JSON lines plus the compressed frames byte-for-byte, standard library only, since
the Pi carries no parquet or ffmpeg.
tools/lerobot_export.pyconverts a captureto a real LeRobotDataset off-board in a new linux-64
lerobotpixi env, throughLeRobot's own API rather than emitting files — the format already moved once
(v2.1 -> v3.0) and a hand-rolled writer would be wrong the next time. It resamples
onto the exact 1/fps grid first (LeRobot derives timestamps from the frame index,
so a slipped capture would export as if its timing had been perfect) and loads
the result back to verify.
episode_replayreads the capture, not the dataset,so replay needs nothing off-board; it approaches the first pose, replays at a
quarter speed, and stops on sustained lag.
Verified (all headless, against
mock_arm— the driver's exact interface withno bus, plus a synthetic camera encoded with zlib and struct):
pixi run arm-teleop-testruns leader -> mirror -> follower -> record -> replay-> export plan end to end. 220 frames over 11.0 s, 0 dropped ticks; replay
finished within 0.0000 rad of the last action at 0.010 rad steady lag.
back through
LeRobotDatasetwith the right shapes and reads in LeRobot's ownlerobot-dataset-viz(wrote a 780 KB .rrd headless).verifies identically.
overall, lint clean.
Not verified: anything on the arm itself. The three safety observations (the
arm stopping at a limit, halting on a released key, going limp on panic) and a
real arm retracing an episode need a human at the bench. That is BENCH.md step 8
and
pixi run arm-bench-teleop, which prompts for those observations and writes areport — nothing is recorded as passing that the operator did not say they saw.
Two bugs found by running it, both fixed: two publishers on
arm/goalfight(caught by the stall guard before the loop script learned to stop the leader
first), and destroying a node while
rclpy.spin()still holds it aborts theprocess —
cli.shutdownnow joins the spin thread first.cli.parsealso cutsthe
--ros-argsblock out and parses strictly, becauseparse_known_argssilently discards a mistyped
--max-travel.Incidental: lag supervision moved to
motion.py, shared witharm-pose gorather than reimplemented;
poses.pydropped its duplicate ofmote_home.Follow-up filed as #259 (move
jog/arm_poseonto the same shutdown/parsing,which wants a bench re-run of BENCH.md steps 5-7).