Skip to content

Optional 1-DoF jaw on the hardware gripper - #40

Open
Nick Hehr (HipsterBrown) wants to merge 21 commits into
gripper-inputsfrom
hardware-articulated-jaw
Open

Optional 1-DoF jaw on the hardware gripper#40
Nick Hehr (HipsterBrown) wants to merge 21 commits into
gripper-inputsfrom
hardware-articulated-jaw

Conversation

@HipsterBrown

Copy link
Copy Markdown
Contributor

Stacked on #39. Review that one first — this targets gripper-inputs, so GitHub shows only the hardware commits and will retarget to main when #39 merges.

Why

#39 made the simulated gripper's jaw a real degree of freedom and measured what the motion planner does with it: nothing on an unobstructed plan, and 4.3%-19.9% of the jaw's range under obstruction, in both directions.

On a simulated gripper that's a curiosity. On hardware holding a part, it's the grip loosening mid-trajectory. This adds the same articulated_jaw attribute to devrel:so101:gripper (default false), plus the three things hardware forces that simulation didn't.

The three decisions, and why they went this way

Refusing planner jaw commands while holding — but only ones that move the jaw. The obvious version of this guard breaks pick-and-place. builtin.go:687-689 skips a component in a trajectory step only when len(inputs) == 0, never when the inputs are unchanged, so a 1-DoF gripper appears in every step of every plan carrying its current value. An unconditional refusal fails every arm move made while carrying a part. So a batch whose steps are all within 0.01 rad of current returns early without commanding the servo, and only a genuine jaw command is refused.

A 50 ms position cache. Every CurrentInputs is a DoCommand round trip plus a serial read on the bus the five arm servos share — and the 3D viewer polls it continuously to animate the jaw. Measured: 200 polls inside the TTL cost 0 bus reads. Invalidated at the top of servoDo (a deny-list, so a future servo command defaults to invalidating), with a generation counter because the lock is released across the read.

Last-known-good on a failed read. framesystem.CurrentInputs hard-errors the entire machine's frame system if a DoF-bearing component errors, so one dropped serial frame would abort every arm move. A failed read serves the last good value and warns; it only errors if no read has ever succeeded.

Also implements IsHoldingSomething, which was a stub that always answered "not holding" — the guard above reads from it.

Where to focus review

  • GoToInputs's ordering — validate everything, detect a no-op, then check holding. The sequence is the safety property, not an implementation detail.
  • Locking. The cache has its own jawMu because Open/Grab hold g.mu across moveToPercent and sync.Mutex isn't reentrant. jawMu is never held across a DoCommand, so a viewer poll can't block behind a 2000 ms servo_wait_stop. The only order is g.mu -> jawMu.
  • The default path. articulated_jaw: false must behave exactly as before; a test pins that construction issues only the capabilities probe.

Testing

go test ./... green, -race clean, go vet clean. No hardware required — the gripper's only hardware path is arm.DoCommand, so the existing fake-arm harness covers all of it.

Every guard was mutation-tested: broken deliberately, confirmed to fail with the intended diagnostic, reverted. Two worth noting, because both passed for the wrong reason until fixed:

  • The grasp threshold was only ever exercised with closedPosition = 0, so dropping the subtraction entirely left the suite green. There's now a calibrated-offset case.
  • Review caught a TOCTOU: the no-op and holding decisions were computed before taking g.mu, so an Open holding that lock for up to 2000 ms meant both were decided against a stale jaw. Both now happen inside the critical section.

Verified separately and committed as tests: 10 arm moves x 40 trajectory steps while holding all succeed with zero bus writes, while the same fixture still refuses a genuine jaw command.

Known limitations, documented not fixed

  • Geometries() still reads the bus uncached and falls back to "closed" on failure. Out of scope here; worth a follow-up since it's the weaker policy of the two.
  • GoToInputs isn't constrained to the calibrated openPosition/closedPosition, so the planner can drive the jaw further open than Open() will. Matches the simulated gripper. Flagging for a decision rather than changing it unilaterally.
  • A Stop arriving during the pre-move phase isn't latched. Fixing it would suppress legitimate cache stores; commented in place.

🤖 Generated with Claude Code

Nick Hehr (HipsterBrown) and others added 21 commits August 24, 2026 21:35
- The fake arm invoked hooks while holding f.mu, so TestGoToInputsAbortsOnStop
  (whose hook calls Stop) deadlocked. Hooks now run after unlocking.
- Constructor priming is gated on articulatedJaw; unconditional, it prepended
  a servo_position to every command list and broke two existing tests.
- TestStopDuringReadDoesNotStampStaleValue must invalidate first, or the
  primed cache makes the first call a hit and the hook never fires.

Also: Stop's latch must precede its existing isMoving clear or it can never
latch; stopped is an atomic (the plan said both bool-under-mutex and atomic);
prime goes after the struct literal, not after probeServoSupport.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both grippers will need the ULP-overshoot tolerance for GoToInputs input
validation; a divergent copy per gripper is the bad outcome. Pure move,
no behaviour change.
newTestGripperWithConfig lets a test supply a non-default gripper config.
onRead/onMove on fakeServoArm let a test hook a command; both fire after
f.mu is released so a hook that issues another command (e.g. calling
Stop from within an onMove) doesn't deadlock on the non-reentrant mutex.
setErr and countCommands are seams for upcoming tests.
Validates every step, then short-circuits on a no-op batch (the common
case: the gripper appears in every trajectory step of every plan
carrying its unchanged value), and only then checks IsHoldingSomething
-- in that order, so ordinary arm moves made while holding a part are
never rejected. Adds an atomic stopped latch so Stop can abort an
in-flight GoToInputs without taking g.mu.
calibrate_positions wrote g.openPosition/g.closedPosition without taking
g.mu, while IsHoldingSomething read closedPosition unguarded -- now from
the planner's goroutine on every jaw-moving GoToInputs batch. Take g.mu
around the calibrate_positions writes, and give IsHoldingSomething its
own g.mu-guarded read of closedPosition (safe here: this is the
standalone public-API path, not called from within GoToInputs's own
g.mu hold). Factor the threshold check into isHoldingAt so Grab shares
the same policy instead of re-deriving it, and note why Grab's read
failure returns true optimistically while IsHoldingSomething returns
the error.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jMMQDWSEV2hC9ue1yPfYs
GoToInputs read the jaw position and classified the batch (no-op vs.
holding-guard) before taking g.mu, then acquired the lock only for the
move itself. A concurrent Open can hold g.mu for up to 2000ms inside
servo_wait_stop, so the classification could be computed against a
pre-Open jaw and then the move executed against a world that had
already changed underneath it -- a TOCTOU. Move the lock up so the
fresh read, the no-op check, the holding guard, and the move all happen
as one critical section; execute-time means after the lock, not before
it.

g.mu is now held across the holding-guard check, so it can no longer
call the public IsHoldingSomething (which itself takes g.mu to read
closedPosition safely) without self-deadlocking. Read g.closedPosition
directly instead, against the pct already fetched under the same lock.

Accepted limitation, called out in comment: a Stop arriving during the
read/decide phase isn't latched, since isMoving is still false there;
setting it earlier would suppress legitimate cache stores during the
read, so this is accepted rather than fixed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jMMQDWSEV2hC9ue1yPfYs
- Prove the closedPosition threshold is actually measured from
  closedPosition, not hardcoded to 0: every existing fixture leaves it
  at its zero default, so "pct - g.closedPosition > threshold" and a
  buggy "pct > threshold" both pass today. Calibrate a non-zero
  closed_position and assert a jaw 10pp past it (under the 15pp
  threshold) reads as not holding. Mutation-checked by hand: dropping
  "-closedPct" from isHoldingAt fails the new case and nothing else;
  reverted after confirming.
- Pin "default is static" to assert the constructor issues exactly the
  capabilities probe and nothing else, guarding the flag-off shipping
  default against an accidental jaw-cache prime.
- Add a concurrent CurrentInputs-polling-during-Open/Grab test, race-
  clean, landing the ad hoc exercise used to validate the locking
  fixes as a real regression guard.
- Drive TestStopDuringReadDoesNotStampStaleValue's hook through the
  real g.Stop path instead of calling invalidateJawCache directly, so
  it also proves Stop's servoDo invalidates.
- Delete a dead onRead reset in TestGoToInputsAbortsOnStop (already
  nil on a fresh fake).
- Pin the clock in TestJawCacheBoundsBusReads so it no longer depends
  on 20 calls completing inside a real 50ms window.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jMMQDWSEV2hC9ue1yPfYs
- positionPercentCached's moving-guard comment claimed it covers the
  servo_wait_stop timeout case; it doesn't -- moveToPercent returning
  on timeout doesn't stamp anything, the caller's own isMoving defer
  fires microseconds later, and a read landing after that isn't
  covered here at all (it can stamp a mid-flight value, bounded by the
  TTL to 50ms). What the guard actually covers is the narrower window
  between a move's last servoDo and that defer.
- docs/gripper.md: "cached and may be up to 50ms stale" doesn't hold
  when the bus is down, since last-known-good can be arbitrarily
  stale; and a no-op batch "never touches the bus" should say "never
  commands the servo" -- it may still issue a servo_position read on a
  cache miss.
- CLAUDE.md: jawLimitEpsilon was renamed to geometry.JawLimitEpsilon.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jMMQDWSEV2hC9ue1yPfYs
Sibling of the IsHoldingSomething fix: calibrate_positions writes these
fields under g.mu, so every reader must take it too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…/geometry

components/gripper and components/simulated each re-implemented the same
GoToInputs batch check (DoF arity, per-step arity, limit check) and had
already drifted cosmetically (%.4f vs %.6f, differing wording). Share it as
geometry.ValidateJawSteps, folding the empty-batch short-circuit to the front
so it no longer runs after the DoF probe it doesn't need.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jMMQDWSEV2hC9ue1yPfYs
jawAngle called raw positionPercent, so Geometries() -- the only caller,
polled continuously by the 3D viewer -- issued an unconditional bus read
per poll, defeating the cache even on the default non-articulated config.
A dropped read now serves last-known-good instead of snapping the rendered
jaw shut; servoDo's invalidation keeps it correct after a move.

Document on positionPercent which callers must stay on the raw read (Grab,
get_position) because they need to tell a failed read from a stale one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jMMQDWSEV2hC9ue1yPfYs
positionPercentCached's freshness test had a redundant conjunct
(jawReadAt only ever goes non-zero alongside jawHaveRead), and its hit
path hand-rolled Unlock() instead of the defer everything else uses,
a shape a future early-return could silently break. Collapse the three
lock/unlock pairs into one locked prologue that snapshots gen, havePrev,
prev, and fresh, unlocks once, and returns early on a hit. No behavior
change -- the generation-checked store keeps its defer as-is.

Document jawPct/jawReadAt/jawHaveRead's split responsibilities, which the
compound condition was hiding: jawHaveRead survives invalidation so
last-known-good can still serve, and 0 is itself a legal reading.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jMMQDWSEV2hC9ue1yPfYs
- Grab: inline positionDifference into the Debugf call instead of computing
  it just to log it, duplicating what isHoldingAt computes on the next line.
- GoToInputs's holding-refusal error pointed only at disabling
  articulated_jaw, which throws the feature away. Point it at the real fix:
  add the held object to the motion request's WorldState.
- gripper_test.go: TestArticulatedJawConfigHardware's assert.Len read the
  fake's mutex-guarded commands slice directly instead of the issued()
  helper every other test uses (which filters the capabilities probe);
  switch to assert.Empty(t, fa.issued(), ...).
- Add a newJawGripper(t, pct) helper for the common
  newFakeServoArm()+fa.percent=X+newTestGripperWithConfig(...ArticulatedJaw)
  sequence, and use it at the call sites that don't need the fake arm
  afterward.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jMMQDWSEV2hC9ue1yPfYs
…losed stop

isHoldingAt compared the jaw's reading against closedPosition, so any merely
open jaw looked "held" (open - closed always clears the threshold). On real
hardware this latched the gripper open: once the planner moved the jaw past
~15%, IsHoldingSomething reported holding forever after, and GoToInputs's
safety guard refused every subsequent jaw command, including one to close it
back up.

Grab's own use was valid -- it commands closed immediately before reading, so
"stopped short" is real evidence. That inference doesn't hold for a standing
query taken at an arbitrary moment.

Track the percent moveToPercent last commanded (guarded by g.mu, the choke
point every percent-valued command already holds it through) and compare the
actual reading against that instead: a jaw that failed to reach where it was
told to go, in either direction, is obstructed. With nothing ever commanded,
report not holding rather than guessing -- the live failure showed guessing
"holding" is the more damaging error. A raw servo_position command (opaque
ticks) clears the commanded target instead of leaving a stale percent-valued
one behind.

TestGoToInputsDoesNotLatchOpenAsHolding reproduces the live sequence and
fails against the pre-fix isHoldingAt; verified by temporarily reverting the
predicate and the two call sites back to their literal old form.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jMMQDWSEV2hC9ue1yPfYs
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