Skip to content

Virtual leader teleop + episode recording for the SO-101 - #94

Merged
MJohnson459 merged 22 commits into
mainfrom
arm/virtual-leader-teleop
Sep 3, 2026
Merged

Virtual leader teleop + episode recording for the SO-101#94
MJohnson459 merged 22 commits into
mainfrom
arm/virtual-leader-teleop

Conversation

@MJohnson459

Copy link
Copy Markdown
Contributor

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 leader
pose and publishes leader/joint_states; arm_mirror turns it into arm/goal.
The frontend is deliberately replaceable — 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 change. 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 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_record samples joint_states (observation), arm/goal
(action — the mirror's output, because a policy replaces whatever produces goals)
and /image_raw/compressed at 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.py converts a capture
to a real LeRobotDataset off-board in a new linux-64 lerobot pixi env, through
LeRobot'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_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.

Verified (all headless, against mock_arm — the driver's exact interface with
no bus, plus a synthetic camera encoded with zlib and struct):

  • pixi run arm-teleop-test runs 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.
  • Export produced a v3.0 dataset (aggregated parquet + MP4 shards), which loads
    back through LeRobotDataset with the right shapes and reads in LeRobot's own
    lerobot-dataset-viz (wrote a 780 KB .rrd headless).
  • The state-only path forced by the camera/arm clash (GitHub The camera doesn't fit when the SO-101 arm is attached #2) exports and
    verifies identically.
  • 21 new tests over the safety rules and the capture format; 447 tests pass
    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 a
report — 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/goal fight
(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 the
process — cli.shutdown now joins the spin thread first. cli.parse also cuts
the --ros-args block out and parses strictly, because parse_known_args
silently discards a mistyped --max-travel.

Incidental: lag supervision moved to motion.py, shared with arm-pose go
rather than reimplemented; poses.py dropped its duplicate of mote_home.

Follow-up filed as #259 (move jog/arm_pose onto the same shutdown/parsing,
which wants a bench re-run of BENCH.md steps 5-7).

@MJohnson459
MJohnson459 force-pushed the arm/virtual-leader-teleop branch from 5cd326b to 3b52c7e Compare August 5, 2026 10:43
Comment thread mote_arm/mote_arm/mock_arm.py Outdated
Comment on lines +208 to +224
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()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changed in 0fa5c1bmock_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.

Comment on lines +26 to +34
PIDS=()
cleanup() {
for pid in "${PIDS[@]:-}"; do
kill "$pid" 2>/dev/null || true
done
wait 2>/dev/null || true
}
trap cleanup EXIT

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 run is a wrapper that Popens the real executable and handles no SIGTERM (only KeyboardInterrupt, assuming a terminal Ctrl-C reached the whole group), so proc.terminate() on it kills the wrapper and hands the node to init — once per run, on the success path... Every ros2 run spawn 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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +56 to +64
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}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
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()
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

MJohnson459 and others added 2 commits September 2, 2026 15:46
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
@MJohnson459
MJohnson459 force-pushed the arm/virtual-leader-teleop branch from 3b52c7e to 0fa5c1b Compare September 2, 2026 14:59
MJohnson459 and others added 20 commits September 2, 2026 16:16
…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
@MJohnson459
MJohnson459 merged commit aeb57d7 into main Sep 3, 2026
5 checks passed
@MJohnson459
MJohnson459 deleted the arm/virtual-leader-teleop branch September 3, 2026 16:30
MJohnson459 added a commit that referenced this pull request Sep 11, 2026
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant