Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
96fc346
feat(t9): wrist_roll soft limit whose dead arc contains the encoder seam
OriNachum Jul 12, 2026
7bd6641
spike(t5): STS3215 offset register — GO-WITH-CAVEAT
OriNachum Jul 12, 2026
d400d62
feat(t9b): enforce soft limits in the motion path — the dead arc is n…
OriNachum Jul 12, 2026
1ff6700
merge t9: wrist_roll soft limit whose dead arc contains the encoder seam
OriNachum Jul 12, 2026
3028368
merge t9b: enforce soft limits in the motion path — the dead arc is n…
OriNachum Jul 12, 2026
3fa6dd4
feat(t7): STS3215 encoder-offset primitives (addr 31, sign-magnitude,…
OriNachum Jul 12, 2026
fc53097
merge t7: STS3215 encoder-offset primitives (addr 31, sign-magnitude,…
OriNachum Jul 12, 2026
e7746e0
feat(t8): arm rezero — evict the encoder seam from elbow_flex's travel
OriNachum Jul 12, 2026
41ce064
merge t8: arm rezero — evict the encoder seam from elbow_flex's travel
OriNachum Jul 12, 2026
d3e3b30
feat: encoder linearity — evict the seam so a linear tick axis is fin…
OriNachum Jul 12, 2026
d19ad71
refactor(sonar): hoist the port placeholder; merge an implicitly conc…
OriNachum Jul 12, 2026
e4a171f
fix(rezero): a latched motor can still be re-zeroed — clear the overl…
OriNachum Jul 12, 2026
e6c5b6f
merge: a latched motor can still be re-zeroed (qodo #40)
OriNachum Jul 12, 2026
cfcc8a2
merge main (v0.20.1, arm profile) into encoder linearity
OriNachum Jul 12, 2026
b124741
fix(changelog): blank lines around the merged 0.21.0 section (MD032/M…
OriNachum Jul 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .eidetic/memory/arm101-cli__public.jsonl
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,5 @@
{"id": "wrist-roll-cannot-be-re-zeroed", "hash": "5cdf5d348b52a7306604d48e39851288b73411c86bbda7a4021299be9dfe1079", "content": "DESIGN FINDING (2026-07-12, /think spec for issues #33/#34/#35): RE-ZERO CANNOT FIX wrist_roll, EVEN IN PRINCIPLE. Issue #35 recommended re-zeroing BOTH wrapping joints (elbow_flex, wrist_roll) so the 4095->0 encoder seam falls outside physical travel. That works for elbow_flex \u2014 it has real walls (measured span 2020), so the seam can be parked in its UNREACHABLE arc. It is IMPOSSIBLE for wrist_roll: wrist_roll was mapped as free through its whole range (measured [21,4073], no contact anywhere \u2014 it stopped only where the 0-4095 clamp stopped it, not at a wall), i.e. its travel covers the WHOLE CIRCLE. No choice of zero can move a seam out of a travel that includes every angle \u2014 re-zero only relocates the seam, it cannot evict it. USER DECISION: elbow_flex is RE-ZEROED; wrist_roll is SOFT-LIMITED instead (a software bound in arm_spec/the map, NOT an EEPROM angle-limit write) to under a full turn, creating a DEAD ARC that must CONTAIN the seam. Acceptance test = sweep wrist_roll end-to-end of its soft-limited range and assert present_position is MONOTONIC (no 4095->0 jump inside permitted travel). GENERAL RULE: for any joint, ask first whether its travel is bounded or continuous \u2014 a re-zero is only available to a BOUNDED joint. SECOND FINDING from the same session: bus.py implements NO offset/homing register today (grep: only ADDR 5 id, 6 baud, 40 torque, 41 accel, 42 goal_pos, 46 speed, 48 torque_limit, 55 lock, 56 present_pos) and the STS3215 offset mechanism is UNVERIFIED \u2014 so the re-zero wave is gated on a READ-ONLY spike (identify register address/width/EEPROM-vs-RAM/range, prove it survives a power cycle) before any EEPROM write. THIRD: a re-zero INVALIDATES every tick ever recorded for that joint (maps, thresholds, run-logs, arm_spec bounds), so reachability maps must carry a CALIBRATION IDENTITY and explore must REFUSE a map measured under a different calibration rather than silently misreading it.", "scope": {"name": "arm101-cli", "visibility": "public"}, "metadata": {"type": "note", "record_metadata": {"source": "think-spec", "area": "arm101/hardware/arm_spec.py", "issue": "35", "status": "spec-converged", "spec": "docs/specs/2026-07-12-arm-explore-now-maps-the-arm-s-real-joint-space-it.md"}, "created": "2026-07-12T08:07:28.070943+00:00", "last_recall": null, "recall_count": 0, "links": ["elbow-flex-encoder-wraps-within-travel", "arm-explore-defaults-unusable-at-real-speeds", "motion-verb-crash-leaves-motors-energized"], "supersedes": null, "lifecycle": "active", "added_by": null}}
{"id": "torque-release-must-use-clear-overload", "hash": "95dddbbd5ff09773f25ba98eced1dee2798d20484b726965e71e7251a815ca4f", "content": "BUILD FINDING (2026-07-12, #33 safety wave, PR #38 v0.19.0): THE OBVIOUS TORQUE-RELEASE CALL IS THE WRONG ONE. To de-energize a motor on an abnormal exit, route through bus.clear_overload(motor), NOT bus.enable_torque(motor, False). Both are the IDENTICAL wire write (Torque_Enable=0, addr 40) and differ only in how they read the RESPONSE: a servo latched in overload tags EVERY packet response with the overload bit (0x20) \u2014 including the response to the very write that clears the latch \u2014 so enable_torque() raises OverloadError on exactly the motor that most needs de-energizing. clear_overload() masks that bit and treats it as success (bus.py:1136). A crashed motion verb is a prime way to leave a motor latched, so this is the common case, not the exotic one.\n\nSECOND FINDING (same wave): FakeBus.clear_overload used to record its write SEPARATELY instead of routing through FakeBus.enable_torque. Since the two are the same byte on the wire, a test subclass could intercept enable_torque and SILENTLY MISS the identical byte written by clear_overload \u2014 i.e. the fake modelled a bus that cannot exist, and would let a test PROVE a torque-disable never happened when on real hardware it did. Fixed: FakeBus.clear_overload now calls self.enable_torque and tolerates OverloadError. LESSON: when two bus methods are the same wire op, the fake MUST funnel them through one overridable method or its subclass seams lie.\n\nTHIRD: torque_guard semantics = HOLD ON SUCCESS, RELEASE ON ABNORMAL (user decision). Clean exit issues ZERO release writes so gentle_move stop-and-hold survives (a closed gripper must not drop its object); abnormal exit (any BaseException, so SIGINT included) releases. TorqueGuard.__exit__ is typed -> None (not -> bool) \u2014 Sonar S3516 BLOCKER on a constant `return False`; None is falsy so behavior is identical and the signature states the never-suppress guarantee.\n\nFOURTH: TorqueGuard.disown() exists for arm setup ONLY \u2014 writing a new servo id to EEPROM addr 5 MOVES the servo bus address, so a guard still owning the old id would aim its release at a servo that no longer exists and falsely report a motor as possibly-energized.", "scope": {"name": "arm101-cli", "visibility": "public"}, "metadata": {"type": "note", "record_metadata": {"source": "build", "area": "arm101/hardware/safety.py", "issue": "33", "pr": "38", "version": "0.19.0"}, "created": "2026-07-12T08:50:35.892215+00:00", "last_recall": null, "recall_count": 0, "links": ["motion-verb-crash-leaves-motors-energized", "present-load-saturates-at-torque-limit"], "supersedes": null, "lifecycle": "active", "added_by": null}}
{"id": "usb-yank-cannot-be-fixed-in-software", "hash": "a6744430cda0c6d8dc7d991854d125b9f3636a6779ef710a3d85c93af009da05", "content": "HARD LIMIT (2026-07-12, found while drafting t4 hardware acceptance for #33): A PHYSICAL USB YANK CANNOT BE RECOVERED IN SOFTWARE, and I wrongly wrote it into the spec as an achievable acceptance criterion (honesty conditions h1/h25) before catching it. The SO-101 servos are powered from the 12V SUPPLY; USB carries only DATA. Unplug the cable and /dev/ttyACM* disappears \u2014 the host has NO channel to the arm, every torque-release write fails, and the motors keep holding their last commanded goal. No torque_guard, no finally, no context manager can release torque over a bus that no longer exists.\n\nWHAT THE GUARD *CAN* COVER (all real, all fixed by PR #38): every abnormal exit where the PROCESS STILL HAS A BUS \u2014 SIGINT/Ctrl-C, any unhandled Python exception, and the ACTUAL #33 incident (a second process opening the port; the error was `device reports readiness to read but returned no data (multiple access on port?)` \u2014 the port is CONTENDED but still ours, so the release lands).\n\nThe only thing that could close the yanked-cable hole is a SERVO-SIDE watchdog (the STS3215 dropping torque by itself on comms loss). Whether one exists is UNKNOWN and is filed as issue #37 \u2014 it pairs with the offset-register spike (plan task t5) since both interrogate the same memory table. CAVEAT for that investigation: a watchdog that drops torque during a LEGITIMATE hold would break gentle_move`s stop-and-hold gripper contract, so any timeout must exceed any real hold or it is unusable.\n\nGENERAL LESSON: before writing a testable acceptance criterion, check it is PHYSICALLY achievable. I asserted this one and the user confirmed it on my word; it would have cost a hardware session to discover.", "scope": {"name": "arm101-cli", "visibility": "public"}, "metadata": {"type": "note", "record_metadata": {"source": "spec-review", "area": "arm101/hardware/safety.py", "issue": "37", "severity": "known-limit"}, "created": "2026-07-12T08:50:35.973210+00:00", "last_recall": null, "recall_count": 0, "links": ["torque-release-must-use-clear-overload", "motion-verb-crash-leaves-motors-energized"], "supersedes": null, "lifecycle": "active", "added_by": null}}
{"id": "sts3215-offset-register-facts", "hash": "6cc56871bfa8a793c4ac8db344448990cfcacad772a1faa065b3c5ecf0e14bc8", "content": "STS3215 ENCODER-OFFSET REGISTER \u2014 verified facts (t5 spike, 2026-07-12, triple-sourced: Feetech SMS_STS.h/.cpp, Feetech FTServo_Python, LeRobot tables.py/feetech.py/encoding_utils.py). Register `Ofs` (LeRobot: Homing_Offset): ADDRESS 31 (OFS_L; 32 = OFS_H), 2 BYTES, encoding SIGN-MAGNITUDE WITH SIGN ON BIT 11 \u2014 NOT twos complement (wire = (sign<<11)|magnitude; -1073 -> 3121, +1073 -> 1073). RANGE +/-2047 (max magnitude (1<<11)-1). EEPROM (persistent) \u2014 sits under //---EEPROM--- in SMS_STS.h; SRAM starts at addr 40. Therefore needs the addr-55 Lock unlock->write->relock dance (same region as ID/Baud \u2014 the exact registers PR #21 proved silently revert without it). EFFECT: Present_Position = Actual_Position - Homing_Offset.\n\nRANGE MATH for elbow_flex: its unreachable arc spans raw (126, 2020); the offset must land INSIDE that arc; midpoint H = 1073, |1073| <= 2047 with 974 ticks headroom. KEY INSIGHT: the required offset is bounded by WHERE THE UNREACHABLE ARC SITS, not by the travel length \u2014 which is why +/-2047 clears a ~2020-tick travel comfortably. LeRobot half-turn rule independently gives 1074.\n\nSECOND MECHANISM: writing 128 to Torque_Enable (addr 40) triggers Feetech firmware midpoint calibration (CalibrationOfs). Needs no wrap-aware move \u2014 human hand-parks the joint, torque off, one byte \u2014 sidestepping the chicken-and-egg that you cannot COMMAND a move across a seam you have not fixed yet.\n\nTRAP FOR ANY IMPLEMENTER: do NOT copy LeRobot write_calibration wholesale \u2014 it ALSO writes Min/Max_Position_Limit (addrs 9/11), which in servo mode CLAMP goals and would narrow the very reachable set you are recovering. Write ONLY addr 31.\n\nUNRESOLVED CAVEAT (the whole ballgame): it is UNDOCUMENTED whether the firmware computes Present = (raw - Ofs) MOD 4096 (seam RELOCATES -> re-zero works) or plain signed subtraction (seam STAYS PINNED at the physical angle where the magnet rolls over -> re-zero achieves NOTHING). Present_Position is itself sign-magnitude on bit 15 in both Feetech and LeRobot, so negatives ARE representable. Evidence favours modular (LeRobot SO-101 calibration = literally this fix; community says centring PREVENTS overflow) but is circumstantial. SETTLED ONLY BY A HAND-SWEEP with torque off, checking monotonicity \u2014 reading the offset back proves it was APPLIED, NOT that the seam MOVED.", "scope": {"name": "arm101-cli", "visibility": "public"}, "metadata": {"type": "reference", "record_metadata": {"source": "spike", "area": "arm101/hardware/bus.py", "issue": "35", "pr": "40", "spike": "docs/spikes/sts3215-offset-register.md"}, "created": "2026-07-12T10:24:51.133820+00:00", "last_recall": null, "recall_count": 0, "links": ["encoder-wrap-two-joints", "wrist-roll-cannot-be-re-zeroed", "arm-alive-eeprom-lock-fix"], "supersedes": null, "lifecycle": "active", "added_by": null}}
{"id": "seam-verification-false-pass-traps", "hash": "fb45432f219a4ecc4067c26ada3e28d37e262c1043db183481fda358c28356a6", "content": "TWO TRAPS THAT WOULD PRODUCE A FALSE PASS when verifying an encoder re-zero (found building t8, PR #40, 2026-07-12). Both matter because the verification is the ONLY thing standing between us and shipping a re-zero that silently did nothing.\n\nTRAP 1 \u2014 THE DISCONTINUITY THRESHOLD MUST BE ~500, NOT THE OBVIOUS 2048. The intuition is `a seam jump is ~4096 ticks, so anything over half that (2048) is a wrap`. WRONG: if the firmware does a plain signed subtraction AND read_position`s `& 0x0FFF` mask folds the resulting negative back into range, the observed seam jump is only ~1949 ticks. A 2048 threshold sails straight past it and reports PASS on a broken fix. Use 500 (sensor noise is a few ticks; there is a huge gap between noise and any real discontinuity).\n\nTRAP 2 \u2014 A SHORT CLEAN SWEEP IS `INCONCLUSIVE`, NEVER A PASS. A hand-sweep covering 200 of 2202 ticks that saw no discontinuity has proven NOTHING \u2014 the seam simply was not in the swept region. Claiming a pass from it closes an open question with a lie. Require >=80% travel coverage before `no discontinuity found` is allowed to mean anything.\n\nGENERAL LESSON: when a verification exists to settle an unproven premise, enumerate how it could report SUCCESS while the premise is false. Both traps here are of that shape.\n\nRELATED DESIGN CALL THAT PAID OFF: rather than assume the favourable semantics, FakeBus models BOTH via `offset_wraps` (True = seam relocates, the premise; False = seam stays pinned, the pessimistic reading). The verb is tested against each, so if hardware settles it the wrong way the pessimistic behaviour is already built and tested, not discovered in production.", "scope": {"name": "arm101-cli", "visibility": "public"}, "metadata": {"type": "note", "record_metadata": {"source": "build", "area": "arm101/hardware/rezero.py", "issue": "35", "pr": "40"}, "created": "2026-07-12T10:24:51.190254+00:00", "last_recall": null, "recall_count": 0, "links": ["sts3215-offset-register-facts", "wrist-roll-cannot-be-re-zeroed"], "supersedes": null, "lifecycle": "active", "added_by": null}}
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,32 @@ All notable changes to this project will be documented in this file.
Format follows [Keep a Changelog](https://keepachangelog.com/). This project
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.21.0] - 2026-07-12

### Added

- `arm rezero <joint>` — a gated EEPROM write that evicts the encoder seam from a wrapping joint travel. Writes only the STS3215 `Ofs` register (addr 31, sign-magnitude on bit 11, range +/-2047, EEPROM) via the unlock -> write -> relock dance PR #21 established. The offset is DERIVED from the joint measured unreachable arc, never typed.
- `arm rezero <joint> --verify` — the seam-eviction proof. Torque off; the operator hand-moves the joint through its travel while the verb polls position and asserts MONOTONICITY. Reading the offset back only proves it was APPLIED; only the sweep proves the seam MOVED.
- `arm101/hardware/bus.py`: `read_offset` / `write_offset` / `encode_offset` / `decode_offset`, plus a `FakeBus` that models the offset effect on reported position. `arm read` now shows the signed offset (read-only).
- `docs/spikes/sts3215-offset-register.md` — the triple-sourced research behind the register, including the range arithmetic and the one semantic that is still unproven.
- `docs/hardware-rezero-procedure.md` — the followable procedure, including the mandatory power-cycle.

### Changed

- `wrist_roll` gains a software soft limit (100, 3995) whose dead arc CONTAINS the 4095->0 seam,
and `arm_spec.resolve_bounds()` now INTERSECTS each joint's EEPROM limits with its soft limit.
Every site that resolved move bounds from EEPROM (`arm flex`, `arm explore`'s grid, the demo
sweep) routes through it, so the dead arc is genuinely unreachable rather than merely declared.

### Fixed

- `arm rezero` tolerates a motor latched in overload. Planning does register READS, and on a
latched servo reads raise too — so the verb aborted before reaching the only code that clears
the latch. Since `elbow_flex`'s unreachable arc is measured by driving the joint into a wall
(exactly how a servo latches), this fired on the documented procedure every time. The latch is
now cleared and planning retried once; the recovery is conditional, so a healthy holding joint
is never silently de-energised. Found by qodo on PR #40.

## [0.20.1] - 2026-07-12

### Fixed
Expand Down
Loading
Loading