Skip to content

feat: encoder linearity — evict the seam so a linear tick axis is finally true (#35)#40

Merged
OriNachum merged 15 commits into
mainfrom
feat/encoder-linearity
Jul 12, 2026
Merged

feat: encoder linearity — evict the seam so a linear tick axis is finally true (#35)#40
OriNachum merged 15 commits into
mainfrom
feat/encoder-linearity

Conversation

@OriNachum

Copy link
Copy Markdown
Contributor

Refs #35. Plan tasks t5, t7, t8, t9, t9b.

Merge order: this bumps to 0.21.0 because #39 already holds 0.20.x. Merge #39 first
and I'll rebase if the CHANGELOG conflicts.

The bug

elbow_flex and wrist_roll cross the 4095→0 encoder seam within their physical travel, so
their encoder value is not monotonic with joint angle. Everything that reasons about position —
arrival checks, clamp_goal, ReachMap's (min,max) ranges — assumes a linear tick axis, and for
these two joints that assumption is false. For elbow_flex the sorted endpoints of its measured
range describe exactly the unreachable region: the map is not imprecise, it is inverted.

The finding that split the fix in two

Issue #35 recommended re-zeroing both joints. That turns out to be impossible for wrist_roll,
even in principle — and the issue's own evidence proves it. wrist_roll was mapped free through its
entire range: it stopped only where the 0–4095 clamp stopped it, never at a wall. So its travel
covers the whole circle — and no choice of zero moves a seam out of a travel that includes
every angle.
Re-zero only relocates a seam; it cannot evict one.

joint travel fix
elbow_flex bounded (real walls) re-zeroed — the seam parks in its unreachable arc
wrist_roll continuous (whole circle) soft-limited to (100, 3995) — a dead arc that contains the seam

The general rule now lives in the code: ask whether a joint's travel is bounded or continuous.
A re-zero is only available to a bounded joint. wrist_roll's soft limit is a software bound —
never an EEPROM angle-limit write.

t5 — the spike (docs/spikes/sts3215-offset-register.md)

The register, triple-sourced against Feetech's SDK, Feetech's Python lib, and LeRobot: Ofs, addr
31, 2 bytes, sign-magnitude on bit 11
(not two's complement), range ±2047, EEPROM.
elbow_flex needs H = 1073, with 974 ticks of head-room.

Verdict: GO-WITH-CAVEAT. It is undocumented whether the servo reduces the corrected position
mod 4096 (seam moves → the fix works) or does a plain signed subtraction (seam stays pinned
the re-zero achieves nothing). Evidence favours the former but is circumstantial.

We did not assume. FakeBus models both readings via offset_wraps, and the verb is tested
against each. If hardware settles it the wrong way, the pessimistic behaviour is already built.

t7 — the primitives

write_offset's entire wire surface is torque-off → unlock → addr 31 → re-lock, pinned by an
exact allow-list so addrs 9/11 can never be touched. That guard is deliberate: LeRobot's
write_calibration does write them, and in servo mode Min/Max_Position_Limit clamp goals
they would narrow the very reachable set we are trying to recover.

t8 — arm rezero, and the proof

The bootstrap problem is the hard part. elbow_flex currently rests past its wrap at ~126, so
the natural procedure — "drive to mid-travel, then centre" — is exactly the one that must not run: a
goal of 3121 looks like a modest tick-space move and is in fact a rotation the long way round,
through the entire unreachable arc, into a wall. So the re-zero commands no motion at all: it
reads where the joint is, derives the offset from its measured unreachable arc, and writes it.
Pinned at the register level — position_writes == [], nothing at addr 42.

--verify is the step that settles the caveat, and reading the offset back is not a substitute
for it.
That proves the offset was applied; only a sweep proves the seam moved. Torque off, the
operator hand-moves the joint through its travel, and the verb asserts monotonicity.

Two traps it avoids, either of which would have produced a false pass:

  • The discontinuity threshold is 500, not the tempting 2048. If the firmware does a plain signed
    subtraction and read_position's & 0x0FFF folds the negative back into range, the seam jump is
    only ~1949 ticks — a 2048 threshold would call that a pass and tell the operator a broken fix
    works.
  • A short clean sweep is inconclusive, never a pass. Covering 200 of 2202 ticks and seeing no
    seam proves nothing. ≥80% travel coverage is required before "no discontinuity" is allowed to mean
    anything.

Under offset_wraps=False the verify fails loudly, exit 2, verdict seam-not-evicted, and says
"this is not a retry — take it back for a re-decision."

t9 / t9b — the soft limit, and making it bind

t9 added wrist_roll's soft limit; a review caught that nothing consumed it — every move still
resolved bounds from the servo's EEPROM (factory 0–4095), so arm flex wrist_roll --to 4090 would
have driven straight into the dead arc. t9b closes it: arm_spec.resolve_bounds() intersects the
EEPROM limits with the soft limit (intersection, not replacement — a genuinely narrower EEPROM bound
still wins), and every site that resolved bounds from EEPROM now routes through it.

Hardware

docs/hardware-rezero-procedure.md is the followable version — including the mandatory
power-cycle
(PR #21 exists because an EEPROM write read back fine and reverted overnight) and the
hand-sweep. A passing sweep also measures elbow_flex's far wall for the first time: nothing
could see across the seam before.

Gates

1134 tests pass (911 on main). black / isort / flake8 / bandit clean. teken cli doctor --strict
passes. markdownlint clean.

  • arm101-cli (Claude)

OriNachum and others added 10 commits July 12, 2026 12:22
wrist_roll's encoder wraps and no wall was ever found across its entire
measured [21, 4073] free range, so unlike elbow_flex it cannot be fixed by
an encoder re-zero: a re-zero only relocates the seam, and a joint whose
travel covers the whole circle has no arc to relocate it into. Instead,
arm_spec now carries a SOFTWARE-only SoftLimit for wrist_roll, (100, 3995),
that restricts its permitted travel to a genuine sub-turn arc and leaves a
200-tick dead arc straddling the 4095->0 seam. The servo's EEPROM
min_angle/max_angle stay untouched at the factory 0-4095 — this module
never imports the bus, so it has no way to write a register at all.

dead_arc_contains_seam() is the enforceable predicate behind the guarantee:
for any well-ordered (min_tick, max_tick) interval the excluded region is
always [TICK_MIN, min_tick) u (max_tick, TICK_MAX], which is non-empty (and
therefore contains the seam) iff min_tick > TICK_MIN or max_tick < TICK_MAX.
_require_dead_arc_contains_seam() runs this against SOFT_LIMITS at import
time, so a future edit that widens the range back to the full turn fails
loudly at import rather than silently reintroducing the bug — verified
manually and covered by
test_widening_the_soft_limit_to_the_full_range_fails_validation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018vrVNSJ9kzhCQqwpA2e8u6
Read-only verification spike for the elbow_flex encoder re-zero (issue #35).
No hardware was touched; no servo was written; no Python file was modified.

The mechanism EXISTS and is sufficient:

  Ofs / Homing_Offset — addr 31, 2 bytes, EEPROM (persistent),
  sign-magnitude with sign bit 11 (NOT two's complement), range -2047..+2047,
  and the servo applies it to the position it REPORTS:
  Present_Position = Actual_Position - Homing_Offset.

Triple-sourced: Feetech's own SMS_STS.h (addr 31 sits under the
"EEPROM (Read and Write)" heading; SRAM does not start until addr 40),
Feetech's official Python SDK, and LeRobot — which drives this exact arm.
A second mechanism also exists: writing 128 to Torque_Enable (addr 40) runs
the firmware's midpoint calibration (Feetech's CalibrationOfs).

Range arithmetic for elbow_flex: travel is 2020 -> 4095 -> seam -> 0 -> ~126,
i.e. >= 2202 ticks, leaving a 1894-tick unreachable arc spanning raw
(126, 2020). Evicting the seam into it needs H in that window; the midpoint
H = 1073 is well inside the +/-2047 limit (974 ticks of head-room) and maps
the travel to a monotonic [947, 3149]. Independently, LeRobot's half-turn rule
gives H = 1074 — the same answer within one tick.

CAVEAT (why not a clean GO): it is undocumented whether the corrected
Present_Position is reduced modulo 4096 — so the seam RELOCATES to raw == Ofs
— or reported as a plain signed subtraction, which would leave the
discontinuity pinned at raw 4095->0 and make the re-zero useless.
Present_Position really is sign-magnitude on bit 15, so negatives are
representable. All evidence points to the modular reading (LeRobot's shipped
SO-101 calibration depends on it), but it is circumstantial. The doc specifies
the exact hardware test, including the PR #21-style power-cycle check for
EEPROM persistence and a torque-off manual sweep that settles the wrap
semantics outright.

Also could not determine: whether Ofs applies to Goal_Position as well as
Present_Position, and elbow_flex's far wall (never measured — explore cannot
see across the seam), which makes the 2202-tick travel a lower bound.
…ow unreachable

Task t9 added SOFT_LIMITS, giving wrist_roll a soft travel limit of
(100, 3995) whose dead arc contains the 4095->0 encoder seam. Nothing
consumed it. Every move resolved its bounds from the servo's EEPROM
angle-limit registers, which are the factory 0-4095 on every joint of
this arm — so `arm flex wrist_roll --to 4090 --apply` drove the joint
straight into the dead arc and across the seam. A soft limit nobody
reads is inert data, and the spec's claim that no joint's usable travel
crosses the wrap was false.

Add arm_spec.resolve_bounds(joint, eeprom_min, eeprom_max), a pure
read-side resolver returning the INTERSECTION of the servo's EEPROM
range with the joint's soft limit — each end independently takes the
tighter of the two, so a genuinely narrower EEPROM bound still wins and
a joint with no soft limit is returned verbatim.

Route every EEPROM-to-move-bounds site through it:
- arm flex (arm.py _resolve_joint_bounds, both compliant and gentle)
- arm explore's grid (arm.py _build_grid_spec) — the engine takes every
  move bound from GridSpec.bounds, so soft-limiting the grid covers the
  flood-fill and the escape probes
- demo_sweep (demo.py _bounds_for)

The runtime package now has exactly two reads of min_angle/max_angle and
both go through the resolver; every other bounds consumer receives them
as parameters. The soft limit is never written to a servo: arm_spec
imports no bus, and a test asserts no motion path touches EEPROM
addresses 9/11.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018vrVNSJ9kzhCQqwpA2e8u6
… EEPROM Lock dance)

Adds the read/write primitives for the STS3215 Ofs / Homing_Offset register
(EEPROM addr 31, 2 bytes) — the mechanism issue #35 needs to re-zero
elbow_flex so its 4095->0 encoder seam falls in the arc the joint cannot
reach, making the linear-axis assumption true rather than assumed.

Implements docs/spikes/sts3215-offset-register.md.

- encode_offset / decode_offset: pure module-level codec for the register's
  SIGN-MAGNITUDE encoding (sign on bit 11 — NOT two's complement). Round-trip
  tested across the whole +/-2047 range; |offset| > 2047 raises
  CliError(EXIT_USER_ERROR) rather than truncating the magnitude into the sign
  bit (LeRobot #3193 is a real SO-101 hitting exactly this).
- FeetechBus.read_offset / write_offset. write_offset emits exactly
  (40,0) torque-off -> (55,0) unlock -> (31,wire) -> (55,1) re-lock, validates
  BEFORE any wire traffic, and best-effort re-locks on every failure path so a
  failed write never strands the motor at Lock=0 (PR #21). It writes addr 31
  and nothing else — NOT the position limits at 9/11 that LeRobot's
  write_calibration also writes, which in servo mode would clamp goals and
  narrow the reachable set the re-zero exists to recover. Pinned by an exact
  register allow-list test.
- FakeBus models the register, the Lock dance, and its effect on what the servo
  reports (Present = Actual - Ofs) through one funnel shared by read_position
  and read_info, so the two views of addr 56 can never disagree. The modulo-4096
  reduction — the spike's one unproven assumption — is a switch (offset_wraps),
  not a hard-coded belief. ServoModelBus converts goals back to the raw frame so
  goals and feedback never live in different frames.
- arm read surfaces the offset read-only (text column + --json field), signed.

Baseline 960 tests -> 1028 (68 new). No new runtime deps.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018vrVNSJ9kzhCQqwpA2e8u6
elbow_flex's encoder wraps INSIDE its physical travel (issue #35): driven far
enough it crosses the raw 4095->0 seam and reads back near zero, so its reported
position is not monotonic with joint angle and every position comparison in the
codebase is silently wrong for it. Shift the servo's encoder zero (Ofs /
Homing_Offset, EEPROM addr 31) so the seam falls in the 1894-tick arc the joint
physically cannot reach, and the linear-axis assumption becomes TRUE rather than
merely assumed.

`arm rezero <joint>` — the gated write:

- Three-mode consent (dry-run / TTY prompt / --apply). The dry-run opens NO bus
  and prints the exact register writes.
- COMMANDS NO MOTION, on any path. This is the load-bearing safety property:
  elbow_flex rests at raw ~126, PAST its wrap, so a linear goal rotates it the
  long way round through its whole travel and into a wall. The tool that makes
  the axis linear cannot rely on the axis being linear. It reads where the joint
  physically is, derives the offset from the joint's measured unreachable arc,
  and writes it. Pinned at the register level by tests.
- Clears a latched overload first (write_offset's own torque-off would otherwise
  raise on a joint that just fought a wall — which is how the arc was measured).
- Torque goes off for the write and STAYS off; the servo must not be holding
  while its frame of reference changes underneath it.
- Refuses wrist_roll WITH THE REASON, and it is a different reason from the other
  four: a re-zero only RELOCATES a seam, it can never EVICT one from a joint whose
  travel covers the whole circle. It has a soft limit instead. The other four
  simply do not wrap.
- Per-joint arcs live in arm_spec (REZERO_ARCS), mirroring SOFT_LIMITS. The offset
  is DERIVED from the arc (midpoint 1073 for elbow_flex), never typed, so
  correcting the arc corrects the offset. Import-time guards reject an arc with no
  interior or an unrepresentable offset (raw 2048).

`arm rezero <joint> --verify` — the seam-eviction proof:

Reading the offset back proves it was APPLIED. It does NOT prove the seam MOVED.
Only a sweep does. Torque off, the human hand-moves the joint through its entire
travel, and the verb asserts no discontinuity anywhere — a human arm being the
only actuator available that does not need a linear tick axis to work.

Four verdicts, because "did not fail" is not "proved it works": seam-evicted /
seam-not-evicted (STOP, exit 2) / seam-present-baseline / inconclusive. A short
clean sweep is INCONCLUSIVE, never a pass — claiming a pass from a sweep that
never went near the seam would close the open question with a lie.

The undocumented caveat (spike §4) is modelled, not papered over: FakeBus offers
both offset_wraps=True (seam relocates, fix works) and offset_wraps=False (plain
signed subtraction, seam stays pinned, re-zero does NOTHING), and every sweep
claim is asserted in BOTH worlds. Under offset_wraps=False --verify FAILS loudly
with exit 2 — that failure is the feature. The discontinuity threshold is 500
ticks, not the tempting 2048: a masked signed subtraction shows up as only a
~1949-tick jump, and 2048 would call it a pass.

Also: docs/hardware-rezero-procedure.md — the hand-run procedure for the follower
(/dev/ttyACM1, elbow_flex = motor id 3), including the POWER-CYCLE (PR #21 exists
because an EEPROM write looked fine until power-cycled) and the --verify sweep.

106 new tests (1028 -> 1134). rezero.py 99% / arm_spec.py 100% coverage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018vrVNSJ9kzhCQqwpA2e8u6
…ally TRUE (v0.21.0)

Refs #35. Plan tasks t5, t7, t8, t9, t9b.

elbow_flex and wrist_roll cross the 4095->0 encoder seam WITHIN their physical
travel, so their encoder value is not monotonic with joint angle. Everything in
the codebase that reasons about position — arrival checks, clamp_goal, ReachMap's
(min,max) ranges — assumes a linear tick axis, and for these two joints that
assumption is FALSE. For elbow_flex the sorted endpoints of its measured range
describe exactly the UNREACHABLE region: the map is not imprecise, it is
INVERTED.

THE FINDING THAT SPLIT THE FIX IN TWO. Issue #35 recommended re-zeroing both
joints. That is impossible for wrist_roll, even in principle: it was mapped free
through its entire range (it stopped only where the 0-4095 clamp stopped it, not
at a wall), so its travel covers the WHOLE CIRCLE — and no choice of zero moves a
seam out of a travel that includes every angle. Re-zero only RELOCATES a seam; it
cannot EVICT one. So:

  elbow_flex  -> RE-ZEROED. It has real walls, so the seam parks in its
                 unreachable arc.
  wrist_roll  -> SOFT-LIMITED to (100, 3995), a software bound whose dead arc
                 CONTAINS the seam. Never an EEPROM angle-limit write.

General rule now in the code: ask whether a joint's travel is BOUNDED or
CONTINUOUS. A re-zero is only available to a bounded joint.

t5 (spike, docs/spikes/) — the offset register, triple-sourced: Ofs at addr 31, 2
bytes, SIGN-MAGNITUDE on bit 11 (not two's complement), +/-2047, EEPROM. elbow_flex
needs H=1073, with 974 ticks of head-room. VERDICT: GO-WITH-CAVEAT — it is
UNDOCUMENTED whether the servo reduces the corrected position mod 4096 (seam
moves, fix works) or does a plain signed subtraction (seam stays pinned, re-zero
achieves NOTHING). We did not assume: FakeBus models BOTH via offset_wraps.

t7 — the primitives. write_offset's entire wire surface is torque-off -> unlock ->
addr 31 -> re-lock, pinned by an exact allow-list so addrs 9/11 (the position
limits) can never be touched: LeRobot's write_calibration writes them, and in
servo mode they CLAMP goals — they would narrow the very reachable set we are
recovering.

t8 — `arm rezero`, and its --verify seam-eviction proof. The bootstrap problem is
the hard part: elbow_flex RESTS past its wrap at ~126, so the natural procedure
("drive to mid-travel, then centre") is exactly the one that must not run — a goal
of 3121 looks like a modest move and is in fact a rotation the LONG WAY ROUND,
into a wall. So the re-zero commands NO motion at all: it reads where the joint
is, derives the offset from the joint's measured unreachable arc, and writes it.

--verify is the step that settles the caveat, and reading the offset back does NOT
substitute for it: that proves the offset was APPLIED, not that the seam MOVED.
Only a sweep proves that. Two traps it avoids:

  * The discontinuity threshold is 500, NOT the tempting 2048. If the firmware
    does a plain signed subtraction AND read_position's & 0x0FFF folds the
    negative back into range, the seam jump is only ~1949 ticks — a 2048 threshold
    would call that a PASS and tell the operator a broken fix works.
  * A short clean sweep is INCONCLUSIVE, never a pass. Covering 200 of 2202 ticks
    and seeing no seam proves nothing; >=80% travel coverage is required before
    "no discontinuity" is allowed to mean anything.

t9/t9b — the soft limit, and the enforcement it needs to be more than inert data.
arm_spec.resolve_bounds() INTERSECTS each joint's EEPROM limits with its soft
limit (intersection, not replacement — a genuinely narrower EEPROM bound still
wins), and every site that resolved move bounds from EEPROM now routes through it.

1134 tests pass (was 911 on main). teken rubric passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018vrVNSJ9kzhCQqwpA2e8u6
@OriNachum

Copy link
Copy Markdown
Contributor Author

/agentic_review

@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Evict encoder seam: elbow_flex rezero + wrist_roll soft-limit enforcement

🐞 Bug fix ✨ Enhancement 🧪 Tests 📝 Documentation ⚙️ Configuration changes 🕐 40+ Minutes

Grey Divider

AI Description

• Add arm rezero  to write STS3215 Homing_Offset (addr 31) with a no-motion safety contract.
• Add --verify hand-sweep to prove seam eviction (or fail loudly if firmware doesn’t wrap).
• Make wrist_roll linear by enforcing its software soft limit via EEPROM∩soft-limit bounds
 everywhere.
Diagram

graph TD
  CLI["CLI: arm.py"] --> RZ["rezero.py"] --> BUS["bus.py"] --> SERVO{{"STS3215 servo"}}
  CLI --> MOTION["flex/explore/demo"] --> SPEC["arm_spec.py"]
  MOTION --> BUS --> SERVO
  CLI --> READ["arm_read.py"] --> BUS
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Pure software unwrapping (wrap-aware comparisons everywhere)
  • ➕ Avoids EEPROM writes and persistence hazards
  • ➕ Works for continuous joints (e.g., wrist_roll full rotation) without dead arcs
  • ➕ Doesn’t depend on undocumented firmware modulo behavior
  • ➖ Requires pervasive changes to every place that compares/clamps ticks (arrival, clamp_goal, reachability ranges, etc.)
  • ➖ Higher risk of subtle regressions and inconsistent handling across modules
  • ➖ Harder to validate operationally vs. a single seam-eviction proof sweep
2. Use Feetech midpoint calibration command (write 128 to Torque_Enable)
  • ➕ One-byte command; firmware computes and commits offset
  • ➕ Avoids sign-magnitude encoding pitfalls in host code
  • ➖ Centers at a fixed midpoint (less control over seam placement)
  • ➖ Unclear interaction with Lock/persistence (still EEPROM)
  • ➖ Still relies on firmware semantics; may not solve seam eviction deterministically
3. Treat wrist_roll as a continuous joint with explicit modulo-domain planning
  • ➕ Preserves full rotation capability without a dead arc
  • ➕ Makes intent explicit for joints that are mechanically continuous
  • ➖ Requires new motion primitives and updated reachability modeling (not a simple patch)
  • ➖ More complex UX and more complex safety constraints for humans operating the arm

Recommendation: Current approach is appropriate for the two-joint reality discovered in #35: bounded joints can be fixed by re-zeroing to park the seam in an unreachable arc, while continuous joints need a software-bound permitted interval. The PR’s key strength is operational proof (--verify) plus pessimistic modeling (offset_wraps=False) so the team can decisively reject the re-zero strategy if hardware semantics don’t wrap modulo 4096. If future work expands to more wrapping joints or continuous motion planning, consider software unwrapping as a larger, dedicated refactor rather than mixing it into this safety-critical fix.

Files changed (21) +6940 / -68

Enhancement (3) +1475 / -24
arm_read.pyExpose signed Homing_Offset in joint readings +14/-0

Expose signed Homing_Offset in joint readings

• Adds an 'offset' field to 'JointReading' and includes it in the read mapping so 'arm read' surfaces the decoded, signed encoder offset. Ensures failed joints report 'offset=None' rather than a misleading default.

arm101/hardware/arm_read.py

bus.pyAdd Homing_Offset codec + read/write primitives and FakeBus offset modeling +549/-24

Add Homing_Offset codec + read/write primitives and FakeBus offset modeling

• Defines addr constants and implements 'encode_offset'/'decode_offset' for the sign-magnitude bit-11 format. Adds 'read_offset' and 'write_offset' with a pinned wire sequence (torque-off → unlock → write addr 31 → relock), and updates 'read_info' to return a signed 'homing_offset'; FakeBus now simulates offset effects and supports testing both modulo-wrapping and non-wrapping semantics.

arm101/hardware/bus.py

rezero.pyAdd rezero planning, apply, and sweep-based seam-eviction verification +912/-0

Add rezero planning, apply, and sweep-based seam-eviction verification

• Introduces the no-motion re-zero algorithm (derive offset from unreachable arc, write only Homing_Offset with safety/persistence constraints). Adds a torque-off hand-sweep verifier with discontinuity detection, coverage gating, and explicit “stop condition” failure when firmware semantics imply the seam did not move.

arm101/hardware/rezero.py

Bug fix (3) +1393 / -27
arm.pyIntroduce 'arm rezero' verb and route bounds through arm_spec resolver +662/-13

Introduce 'arm rezero' verb and route bounds through arm_spec resolver

• Adds a new gated 'arm rezero <joint>' command (plus '--verify') that plans/writes Homing_Offset and runs the hand-sweep proof without ever writing goal positions. Updates existing arm verb help/overview and ensures motion call sites resolve bounds via 'arm_spec.resolve_bounds()' so wrist_roll’s soft limit actually constrains commands.

arm101/cli/_commands/arm.py

arm_spec.pyAdd SoftLimit model, wrist_roll soft limit, and EEPROM∩soft bounds resolver +641/-0

Add SoftLimit model, wrist_roll soft limit, and EEPROM∩soft bounds resolver

• Introduces 12-bit tick bounds, a 'SoftLimit' dataclass, seam/dead-arc validation helpers, and a 'SOFT_LIMITS' table for wrist_roll. Adds 'resolve_bounds()' to intersect EEPROM limits with the soft limit (when present) and provides rezero eligibility/refusal helpers and offset derivation from the unreachable-arc table.

arm101/hardware/arm_spec.py

demo.pyMake demo sweep respect wrist_roll software soft limit +90/-14

Make demo sweep respect wrist_roll software soft limit

• Imports 'arm_spec.resolve_bounds()' and uses it to intersect EEPROM limits with software limits before computing sweep targets. Raises an environment error early if EEPROM bounds contradict the soft limit, preventing a sweep from driving through the seam.

arm101/hardware/demo.py

Documentation (6) +911 / -10
CHANGELOG.mdAdd 0.21.0 release notes for rezero and soft-limit enforcement +14/-0

Add 0.21.0 release notes for rezero and soft-limit enforcement

• Documents the new 'arm rezero' verb, the bus-level offset primitives, and the '--verify' seam-eviction sweep. Notes the wrist_roll soft limit and that bounds resolution now intersects EEPROM limits with software limits.

CHANGELOG.md

learn.pyUpdate CLI help surface to include 'arm rezero' +46/-3

Update CLI help surface to include 'arm rezero'

• Extends the learn/overview narrative and JSON payload to describe 'arm rezero', its no-motion contract, joint eligibility/refusals, and the '--verify' proof sweep.

arm101/cli/_commands/learn.py

overview.pyAdd 'arm rezero' to top-level CLI overview list +4/-0

Add 'arm rezero' to top-level CLI overview list

• Includes the new verb in the quick overview text, highlighting EEPROM addr 31, no-motion behavior, and verification sweep.

arm101/cli/_commands/overview.py

catalog.pyDocument new rezero workflow and offset visibility in explain catalog +151/-7

Document new rezero workflow and offset visibility in explain catalog

• Updates the catalog text to include 'arm rezero' and expands 'arm read' docs to include the signed Homing_Offset field and its purpose for issue #35.

arm101/explain/catalog.py

hardware-rezero-procedure.mdAdd step-by-step operator procedure for elbow_flex rezero +321/-0

Add step-by-step operator procedure for elbow_flex rezero

• Provides a followable hardware runbook including dry-run plan, apply, mandatory power-cycle, and the verification hand-sweep. Captures safety notes (limp joint sag) and explains why verification proves seam eviction better than read-back.

docs/hardware-rezero-procedure.md

sts3215-offset-register.mdDocument research on STS3215 Ofs/Homing_Offset register semantics +375/-0

Document research on STS3215 Ofs/Homing_Offset register semantics

• Triple-sources address/encoding/range/persistence details for the offset register and records the key remaining caveat (modulo vs signed subtraction behavior). Includes recommended hardware validation to settle the assumption safely.

docs/spikes/sts3215-offset-register.md

Other (9) +3161 / -7
pyproject.tomlBump project version to 0.21.0 +1/-1

Bump project version to 0.21.0

• Updates the package version to reflect the new rezero/soft-limit functionality and related surface changes.

pyproject.toml

_fakes.pyAlign servo-model fake with corrected (offset) position frame +21/-4

Align servo-model fake with corrected (offset) position frame

• Updates the servo simulation to treat goals as arriving in the corrected frame and to report positions consistently through the same offset transformation used elsewhere. Prevents tests from passing under an impossible “goals and feedback use different frames” model.

tests/_fakes.py

test_arm_read.pyTest signed offset visibility and read-only behavior in arm_read +56/-2

Test signed offset visibility and read-only behavior in arm_read

• Adds coverage ensuring offsets are exposed as signed values, failed reads yield 'offset=None', and surfacing the offset remains read-only (no register writes).

tests/test_arm_read.py

test_arm_read_flex.pyTest CLI 'arm read' renders/exports offset and stays read-only +43/-0

Test CLI 'arm read' renders/exports offset and stays read-only

• Validates JSON and text output include the 'offset' field/column and that 'arm read' does not write any registers even with offset surfaced.

tests/test_arm_read_flex.py

test_arm_rezero_cli.pyAdd comprehensive tests for 'arm rezero' CLI semantics +684/-0

Add comprehensive tests for 'arm rezero' CLI semantics

• Pins command registration, consent-mode behavior, idempotence, and the “no goal-position writes ever” safety contract. Exercises '--verify' behavior including the intended loud failure under 'offset_wraps=False'.

tests/test_arm_rezero_cli.py

test_arm_spec.pyAdd tests for SoftLimit validation and seam-containing dead arcs +297/-0

Add tests for SoftLimit validation and seam-containing dead arcs

• Covers tick bounds, SoftLimit structural validation, dead-arc sizing, 'dead_arc_contains_seam', and soft-limit lookup behavior to prevent regressions that reintroduce seam-crossing ranges.

tests/test_arm_spec.py

test_bus_offset.pyAdd tests for Homing_Offset codec and locked EEPROM write sequence +794/-0

Add tests for Homing_Offset codec and locked EEPROM write sequence

• TDD suite validating sign-magnitude encoding/decoding, the exact unlock/write/relock ordering, and explicit non-writing of angle-limit registers. Asserts both FeetechBus and FakeBus behaviors needed to safely support addr 31 writes.

tests/test_bus_offset.py

test_rezero.pyAdd rezero plan/apply/sweep tests under both firmware semantic models +897/-0

Add rezero plan/apply/sweep tests under both firmware semantic models

• Tests offset derivation from unreachable arcs, ensures no motion is commanded, and verifies sweep verdicts under both 'offset_wraps=True' (works) and 'False' (must fail). Includes a HandMovedBus to simulate the human-driven sweep actuator.

tests/test_rezero.py

test_soft_limit_enforcement.pyProve soft limits bind across all motion call sites +368/-0

Prove soft limits bind across all motion call sites

• Asserts 'resolve_bounds()' performs intersection (not replacement) and that flex/explore/demo paths actually use it by inspecting real goal writes. Guards the spec boundary that software limits are never written into EEPROM angle-limit registers.

tests/test_soft_limit_enforcement.py

…atenated f-string

Two SonarCloud findings on #40, both fair.

S1192 — '(auto-detect at apply)' written out three times. adding 'arm rezero' as
a third gated motion verb pushed it over the threshold. Hoisted to _PORT_UNRESOLVED
alongside the existing verb-label constants. (The same literal was hoisted on the
speed-profile branch; this branch predates that, so the two will converge on merge.)

S5799 — two adjacent f-strings inside a LIST literal, relying on implicit
concatenation. It was intentional line-wrapping, but Sonar is right that this is
exactly the shape a forgotten comma takes, and in a list of report lines a missing
comma silently merges two rows rather than failing. Merged into one f-string.

1134 tests still pass; no behaviour change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018vrVNSJ9kzhCQqwpA2e8u6
@qodo-code-review

qodo-code-review Bot commented Jul 12, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0) 📜 Skill insights (0)

Context used
✅ Compliance rules (platform): 8 rules
✅ Skills: cicd

Grey Divider


Action required

1. Rezero fails under overload ✓ Resolved 🐞 Bug ☼ Reliability
Description
arm rezero's write path calls rezero.plan_rezero() (which reads registers) before clearing a
latched overload, so an overloaded motor raises OverloadError on the initial reads and the command
aborts before it can call apply_rezero() (which would clear overload). This blocks rezeroing in
exactly the “just hit a wall / overload-latched” state the procedure expects to handle.
Code

arm101/cli/_commands/arm.py[R1513-1519]

+    plan = rezero.plan_rezero(bus, motor, joint)  # type: ignore[arg-type]
+
+    if plan.already_applied:
+        _emit_rezero_noop(role, port, plan, json_mode=json_mode)
+        return
+
+    read_back = rezero.apply_rezero(bus, motor, plan.target_offset)  # type: ignore[arg-type]
Relevance

⭐⭐⭐ High

Team repeatedly accepted overload-latched recovery fixes; wrapped pre-reads/clear_overload to avoid
aborts on OverloadError (PR #24).

PR-#24

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The CLI calls plan_rezero() before apply_rezero(). plan_rezero() performs reads
(read_offset/read_position), and the bus layer surfaces the overload status bit as an
OverloadError on any operation with that error byte. Since apply_rezero() (which clears
overload) is invoked only after planning, overload-latched motors fail during planning and never
reach the clear step.

arm101/cli/_commands/arm.py[1513-1519]
arm101/hardware/rezero.py[298-330]
arm101/hardware/rezero.py[430-432]
arm101/hardware/bus.py[812-846]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`_run_rezero_write()` calls `rezero.plan_rezero()` before clearing overload. Since `plan_rezero()` performs `bus.read_offset()` and `bus.read_position()`, an overload-latched motor will throw `OverloadError` during planning and the flow will never reach `rezero.apply_rezero()`, even though `apply_rezero()` starts with `bus.clear_overload()`.

## Issue Context
- `FeetechBus._status_error()` returns `OverloadError` when the overload bit is set, so overload state breaks reads as well as writes.
- The rezero write sequence already intends to tolerate overload by clearing it first (in `apply_rezero()`), but the CLI currently does reads first.

## Fix Focus Areas
- arm101/cli/_commands/arm.py[1501-1520]
- arm101/hardware/rezero.py[298-330]

### Suggested implementation approach
- In `_run_rezero_write()`, wrap the `rezero.plan_rezero(...)` call in `try/except OverloadError`.
- On `OverloadError`, call `bus.clear_overload(motor)` (best-effort if you want to preserve original exception on failure), then retry `rezero.plan_rezero(...)` once.
- Only do this recovery path when overload is actually detected, to avoid unexpectedly de-energising the joint on the common/no-op path (`plan.already_applied`).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Qodo Logo

Comment thread arm101/cli/_commands/arm.py Outdated
OriNachum and others added 3 commits July 12, 2026 13:36
…oad before planning

plan_rezero() is reads-only, but FeetechBus._read_register raises OverloadError
off the status byte regardless of read vs. write, so a motor latched in
overload aborted arm rezero before it ever reached apply_rezero (the only call
site that clears the latch). elbow_flex's arc is measured by driving the
joint into a wall — exactly how a Feetech servo latches an overload — so an
operator running the documented rezero procedure right after that measurement
hit this every time.

_run_rezero_write() now catches OverloadError around the plan_rezero() call,
clears the latch, and retries exactly once; a second failure propagates
rather than looping. The recovery only fires inside the except branch, never
ahead of it, so the already_applied no-op path and any healthy planning read
never call clear_overload and never silently de-energise a holding joint. The
operator is told on stderr that the latch was cleared and the joint went
limp.
Both branches added a gated verb to the same 1700-line arm.py — `arm profile`
on main, `arm rezero` here — and git's conflict regions did not align on
function boundaries, so a naive union spliced one verb's output list into the
middle of the other's function call. Resolved by taking main's arm.py whole
(profile intact) and replaying the encoder branch's delta onto it, then checking
mechanically that (merged - main) == (encoder - base) apart from the three verb
ENUMERATIONS, which a union gets wrong by construction and which must name BOTH
verbs:

  - the module docstring's first line
  - the "verbs": [...] JSON list
  - the "Verbs: ..." text line

Also carried the rest of the encoder delta that rides alongside rezero: `arm
read`'s signed `offset` column/field, and `_resolve_joint_bounds` (the soft-limit
intersection) at its two call sites.

1202 tests pass (main 974 + encoder 1139, no file dropped). teken cli doctor
--strict: 26/26. Both verbs resolve through explain/overview/learn.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018vrVNSJ9kzhCQqwpA2e8u6
@OriNachum

Copy link
Copy Markdown
Contributor Author

FIX — verified against the source, and it is worse than an edge case: this would have fired on the documented procedure, every time.

Confirmed the chain. plan_rezero() does two register READS (read_offset, read_position), and on a latched motor reads raise tooFeetechBus._read_register raises _status_error(...) on any non-zero status byte, and _status_error returns an OverloadError when the overload bit is set. So planning aborts before the flow ever reaches apply_rezero(), which is the only place that calls clear_overload().

What makes it the expected state rather than a corner: elbow_flex's unreachable arc — the very number the offset is derived from — was measured by driving the joint into a wall. That is exactly how a Feetech servo latches an overload. An operator following docs/hardware-rezero-procedure.md right after that measurement hits this on the first attempt. The verb refused to work in precisely the state it exists for.

Fixed in e6c5b6f (shipped in cfcc8a2): plan_rezero() is wrapped in try/except OverloadError; on catch we tell the operator (stderr) that the latch is being cleared and the joint will go limp, call clear_overload(motor), and retry planning once, unguarded — so a second OverloadError propagates rather than looping.

One thing worth stating explicitly, because it is the trap in this fix: the recovery is conditional, and must be. clear_overload de-energises the joint, so an unconditional pre-clear would silently drop torque on a healthy joint that was holding fine — including on the already_applied no-op path, where nothing is written at all. The recovery call therefore lives only inside the except block. Two tests pin that: the happy path issues no recovery clear_overload (spy bus counts calls), and the no-op path leaves torque_writes == [] and register_writes == [].

Five new tests, including the one that matters: a motor latched on its very first bus op is still re-zeroed successfully.

  • arm101-cli (Claude)

…D022)

My CHANGELOG conflict resolution joined the 0.21.0 and 0.20.1 sections without a
blank line. Also records the latched-motor rezero fix, which the merge commit
carried but the changelog had not yet named.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018vrVNSJ9kzhCQqwpA2e8u6
@sonarqubecloud

Copy link
Copy Markdown

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