projects/ janelia/ Mouse Joystick/ Sources/ mouse-joystick-firm/ .agents/ PLANS.md/

Archived page

Plans

.agents/PLANS.md of mouse-joystick-firm, rendered; the Markdown is beside it.

Current Goal

Delivered: protocol v1 on four pcb v1.0 boards across two rigs (Rig 1 2026-10-09, the desk rig 2026-10-12). v1.0.0 is tagged; the desk rig's two boards were reflashed from v1.0.0-2-g109169f (same code as the tag, docs only after it) on 2026-10-14 from Peter's laptop. Next: the same reflash on Rig 1 (procedure in the README, "Reprogramming a v1.0 board"; the Status entry for 2026-10-14 has what the laptop needs), then the researcher-facing documentation (orb-weaver-context/context/mouse-joystick-rig-documentation-plan.md, phase 2: architecture page, circuit-to-code map, diagrams).

Open Item — Two of four v1.0 units ignore the 1200 baud touch (opened 2026-10-13)

Units B and D (boards 7 and 9) take the bootloader touch at most once and then need the button under mouse_joystick_qp_rev2; units A and C take it every time with the same build. The board-side item is in mouse-joystick-pcb/.agents/PLANS.md. Firmware side:

Task: pcb v1.0 Bring-Up (opened 2026-10-04; boards arrive 2026-10-05)

The v1.0 boards (two ordered from the 2026-09-21 fabrication package) are the first boards with the brake sink, the torque bridge, I2C DACs, LATCH on a GPIO and the Digital-50 instrument port. The board file is unchanged from mouse-joystick-pcb commit d5ae4b0 through its fabrication package 646b8f4 and HEAD 6d5ffd3; no v1.0 tag exists yet — tagging the fabricated commit is Peter's call and the firmware comments say "no tag yet" until then. The pin map was read off footprint U601 with a script over the .kicad_pcb (the RP2040 is U601 at v1.0; U501 is now the 36 V switch), not from prose. Rig-level sequence: ../orb-weaver-context/context/mouse-joystick-rig-system-plan.md section 9, Phase A.

Done before the boards arrived (software only, 2026-10-04, uncommitted)

Still owed, in order (software first, then the bench)

Parallel Observability Workstream — Updated 2026-09-30

docs/observability-plan.md is the local OBS-FW plan. The shared experimental-system roadmap owns cross-repository dependencies and stages. This optional path runs alongside existing delivery, bring-up and hardware-watchdog work; it does not replace those tasks or commit the application to QP.

The QP plan now qualifies RP2040 first, using the Earle Philhower core family already used here, on a separate simple non-actuating fixture. Exact core/fixture versions and all RP2040 qualification gates remain open. Teensy hardware work is deferred and nonblocking; retained build regressions remain useful. STM32G0B1CEU6 is a later second target. Current joystick delivery remains non-QP.

Hardware intent is passive Digital-50 plus a separate optional debug/trace connector and reusable external RP2040 probe. Combined 60-pin wiring and an onboard second MCU are deferred. This does not allocate pins or change present board capabilities; OBS-IF and the selected PCB revision own those decisions.

Claim step, owner and exact paths before implementation; use the local plan's file-ownership and evidence checklist. Planning alone closes none of these items. Preserve selected manufactured-tag/serial context rather than assuming historical firmware wiring matches current PCB development.

Status

2026-10-14, 09:10, Peter's laptop: the desk rig's boards reflashed. Practice run for Rig 1. Laptop setup: pixi install fails on this machine until CONDA_OVERRIDE_GLIBC=2.41 is in the environment (the Guix-built pixi does not see Debian's glibc; every pixi run needs it too). First build of examples/PyControlLinkQp mouse_joystick_qp_rev2 2 min (toolchain and framework download); image v1.0.0-2-g109169f (HEAD; no code change since the tag, git diff v1.0.0 HEAD --stat -- src examples platformio.ini is empty). pixi run upload … /dev/serial/by-id/…<serial>-if00 on board 8 (E4654C159F504534): touch, load, verify, reboot, 7.6 s. The same on board 9 (E4654C159F234234): 4.3 s, touch honoured. Both re-enumerate as 2e8a:f00a with their by-id links; picotool info -a in BOOTSEL shows the flash id and no binary info, and the delivery build's USB banner cannot be caught (the CDC port appears ~1 s after reboot, after the if (Serial) check), so the only read of fw is id over the RJ-45. Done at 09:20 once Peter plugged the desk rig's pyboard into the laptop: id;1 from the pyboard's raw REPL (the desk-rig session's pyb_uart_probe.py, now tools/pyb_id_probe.py) over port 1 UART(4) and port 4 UART(3): both boards answer fw=v1.0.0-2-g109169f, boot=reboot, caps unchanged (evidence/2026-10-14-0910-deskrig-reflash/). Rig 1 plan: laptop, both boards' USB and the pyboard's USB plugged in, one upload per board, then the probe.

2026-10-06, later: Gate E passed on v1.0 unit A. pyControl 2.1.1 on the PYBV11 ran mouse_joystick_check against the board over the RJ-45 three times through the GUI's own Pycboard path (tools/pycontrol_run.py), the whole sequence from id to hv off (identify, rail, home, arm, present, BRAKE → RELEASED in 12 ms, TOUCH, NOMOVE, TRIAL, park). Getting there: the codec renamed JoystickProtocol (pyControl's substring device discovery), comment-free lean copies in pycontrol/dist/ (36 KB of pyboard flash), and the task keeping its own v.phase (no current-state query in the task API). The hardware polls are now gated to one per advance of the protocol clock, which removed the doubled bus reads and most of the overrun skips; the sampler still runs decimated (500 Hz) with the rail up because every bus primitive costs 2–4× its console cost in the link build, and attribution is the next firmware step (plan item "round three"). The board is flashed with this build (mouse_joystick_qp_rev2, 10:43), rail off. Host tests 154 Unity + 182 pytest; the two qp rev2 envs build.

2026-10-06, 14:30. TMC51X0 4.2.0 released and pinned; the quad-lane flash stage 2 is the default of every env (mouse_joystick_safeflash keeps the generic one); the park error is measured — lost motion on direction reversal, part torque angle at the bring-up current, part drive train, not the encoder or the firmware — with the encoder-settle fix as the next firmware item; the rig repository has branch feat/mouse-joystick with the device files, the hardware definition (ports 1/4, solenoid to port 5) and a first trial task. Board: unit A on the default build, rail off. Tests 156 Unity + 185 pytest; all 14 example/env builds pass.

2026-10-06, 12:00: the 1 kHz sampler is back, and the watchdog runs with the rail. The tick budget was never the bus: every build ran from flash through the generic single-lane 03h stage 2, and the quad-lane stage 2 matched to the W25Q128JV (now the default of every env; mouse_joystick_safeflash keeps the generic one) halves to thirds everything the CPU does (tick 758 → 346 µs median in RELEASED, 268 → 146 in ARMED, ping 1.35 → 0.5 ms). With the budget constant at the measured 700 µs, Gate E run 9 held 1 kHz through the whole trial. On the way: TMC51X0 working copy (buffer-transfer datagrams, pipelined readMany; unreleased, lib_deps via symlink:// until Peter tags it), one hardware poll per advance, the watchdog armed by hv on with blocked time credited (V1.12), the task taring before watch. Board: unit A on the fastflash build, rail off. Tests 156 Unity + 185 pytest + 53 library; all twelve example/env builds pass.

2026-10-06: v1.0 unit A stages A4 and A5 passed with the new examples/BrakeAndTorque (brake sink 0–48.80 mA linear; ADS8689 alive at 95–112 µV rms; both buses decoded on the Digital-50 taps), and the protocol build gained brake and torque the same day, run on unit A (ramped BRAKE → RELEASED in 11.6 / 15–17 ms, TQ, torque in every field, NOMOVE/TRIAL/FETCH with torque; uart-protocol.md V1.11 has the decisions). Two board findings — the brake step overshoot with a silent ~{BRAKE_FAULT}, and the inert TQ_CAL_EN switch — are filed in the PCB plan. Host tests 154 Unity + 178 pytest, every example/env build green. Open: the sampler's tick budget with the rail up (decimated since 2026-10-05, worse with the new poll; being attributed with POLL_STAT), and the pyboard was not on USB this session, so the UART0 path and Gate E remain owed. Bench state and next steps: .agents/handoff.local.md.

2026-10-05: HEAD (ec3b36d) regressed on board 4 (pcb v0.3) before the v1.0 boards arrive — no firmware regression. Every stage tool ran on mouse_joystick_rev1 and the QP session on mouse_joystick_qp_rev1_usb, non-interactively: stage 1 24 checks / 0 failed; stage 2 rail 133 ms, 5 / 0 registers, SPI taps decoded byte-for-byte; stage 3 switch witnessed, homed, 5 of 5 at a 2.0-count spread, live loop 1000.1 Hz; QP a–e with 320 moves, release/watch/fetch/stream, host-loss fault 124.7 ms, hv off. Host tests 125, pyControl tests 168, 15 target builds, qp-arduino resolved from the GitHub tag. Evidence ../qp-arduino-example/evidence/2026-10-05-1142-board4-regression/; numbers in the PCB bring-up log under board 4. Bench finding, settled: the reversal lost motion (reversals 6–7 counts at IRUN 8, 4–5 at IRUN 17; continuing moves 1.00; taps = X_ENC within 2 counts; same on a replacement joystick unit; board 2 had −4 counts in September) is the stepper's dead band against friction, not the TMC, not firmware, not a defect. t fails because its move away off the switch is ended by the bench console's follow check, which lacks TmcMotion's grace and second window, and is not checked. Nits, none applied: SafeState::driveOutput() drives ~{BUS_SW_EN} low for 34 µs at boot (direction set before level; seen on v1.0 unit A's Digital-50 tap); measureHvCurrent() 50 ms discharge wait (m fails after a live rail; from a cold rail it measured 12.9 mA), W sweeps with the driver disabled, the session scripts miss pre-dictionary DAC_STATE records. The UART0 path with a pyboard and Gate E remain owed; no pyboard was on the bench.

2026-10-02: protocol v1 (proto=2) implemented on both ends and run end to end on board 4 with the rail up in the QP bench build; watchdog, breadcrumbs, overrun guard, 4 MHz SPI, follow rule with grace and resync after enable are in. The rev 1 angle-output session (phases b–e) is the next bench step. Current local context, bench state and open items: .agents/handoff.local.md (top block). Cross-repository plan of record: ../orb-weaver-context/context/mouse-joystick-rig-system-plan.md.

Stage 1 passed on hardware, 2026-09-01. Board serial E4654C159F2C2534. 27 checks, 0 failed. Recorded in ../mouse-joystick-pcb/docs/bringup-log.md.

The LA5032 capture path and la5032-instrument-port-adapter v1.0 are both validated, same day, against the boot signature. TRIG0/TRIG1 on LA5032 channels 16/17; cable not mirrored. Recorded in ../la5032-instrument-port-adapter/docs/bringup-log.md.

Stage 2 passed 2026-09-02 on both boards, once the 36 V rail collapse was diagnosed as R203 strapping the switch's LATCH high and worked around with PowerSequence::staircaseStart(). See The 36 V Rail Collapse.

Stage 3 passed 2026-09-02 on board E4654C159F4D1334, the as-built unit. The motor turns, the encoder tracks it to 1.000, and homing repeats to 4 encoder counts. Written over TMC51X0 4.0.3, pinned in platformio.ini. Procedure and findings in docs/motion-bringup.md; measurements in ../mouse-joystick-pcb/docs/bringup-log.md.

Controller MicroPython upgraded 2026-09-03: 1.22.2 -> 1.26.1. This cleared a blocker that had not been recorded anywhere: devices/uart_handler.py asserts on MicroPython >= 1.26, added in pyControl 2.1.1, and MouseJoystick.__init__ constructs a UART_handler. The bench pyboard was on framework 2.1.1 over MicroPython 1.22.2, so the device class could not have been instantiated at all — every host test passes against stubs and none of them can see this. Standard PYBV11 build; the internal filesystem survived (firmware writes flash sector 0 and sectors 5+, the filesystem lives in sectors 1-4), verified rather than assumed. Backup of the board's filesystem taken first, at ~/pyboard-flash-backup-2026-09-03. UART.IRQ_RXIDLE reads 0x10, matching pycontrol/tests/stubs.py.

The rig's controller has not been checked. The board upgraded is a bench pyboard carrying a rotary-encoder test task, not the rig's hardware_definition_joystick.py. Expect to upgrade the rig's too.

Ports assigned 2026-09-03: 1 (UART4) and 4 (USART3). All three UART-capable ports were verified on the live controller — constructed at 115200 8N1 with an IRQ_RXIDLE handler accepted, achieved baud 115384 (+0.16%) on UART4 and USART3, 115226 (+0.02%) on USART1. Ports 2, 5 and 6 report UART = None. pycontrol/hardware_definitions/mouse_joystick_bringup.py now assigns ports 1 and 4.

This depends on one rig change that has been requested but not confirmed. Peter asked the researcher on 2026-09-03 to move the reward solenoid from port_4.POW_A to port_5.POW_A. Until that is reported done and joystick-rig's hardware_definition_joystick.py on source/main reads port_5.POW_A, port 4 is contested — a solenoid cable still in port 4 means the right board's connector is occupied, and the symptom is a link that never answers rather than an error. Verify before blaming the protocol.

Why port 3 was given up: it is the only Breakout 1.2 port with I2C on DIO_A/DIO_B and a free DIO_C, which is what LaserLickPort needs. That device was converted from UART to I2C (ATtiny 0x15 + MPR121 0x5A share I2C bus 1; ATtiny PB1 drives DIO_C/X5), and port 4's DIO_C is X6 = DAC_2 = the live rsync — so the lick port has exactly one home and cannot move. The solenoid needs only a driver line and all six ports have POW_A, so it is the thing that moves. Ports 1 and 4 also give both boards an identical achieved baud, both being on APB1.

On port 4, RJ-45 pin 5 must stay unconnected. It is DIO_C = X6 = the rsync driver. Rev 0 leaves it open, which is correct; driving or pulling it would fight the DAC and corrupt the alignment fingerprint the recording pipeline depends on. Full reasoning: ../mouse-joystick-pcb/docs/pycontrol-port-pinout.md.

Gate C's firmware half written 2026-09-03, and Gate C's corpus requirement is now met by two independent implementations. src/Protocol.hpp is the framing codec and src/ProtocolBoard.hpp the command layer and state machine; both are Arduino-free and allocation-free, which is what lets test/native/test_protocol/test_protocol.cpp run the shared corpus on the host. 54 native tests pass, up from 33. All three examples still build for mouse_joystick.

Mutation-testing the corpus was the part that paid. Four deliberate defects were injected to check the tests had teeth; two were not caught at first:

All four mutations are caught now. Neither gap was findable from one implementation plus its own fixture.

Standing requirement for firmware integration: event time must not be a raw 32-bit microsecond counter. ProtocolBoard keeps time in uint64_t because micros() wraps at 71.6 minutes, which is shorter than a session, and every event on this link carries t_us into a recorded dataset used for alignment. A wrap would not fail loudly — it would produce timestamps that go backwards. Whatever feeds this from micros() must accumulate the wrap.

Not yet done: none of this is wired to a UART or to real motion. Motion in ProtocolBoard is the simulator's linear model at v_max, and its defaults are the simulator's placeholders (travel +/-20 deg, 180 deg/s), not the measured bench values. Replacing them changes corpus timings and belongs in its own commit with the corpus updated alongside.

Protocol layer wired to a real UART and real motion, 2026-09-03. Four new firmware pieces, and one seam that made them possible:

File What it is
src/MotionBackend.hpp The seam. ProtocolBoard owns framing, the state machine, the watchdog and the cache, and owns no motion. SimulatedMotion (host, injected clock) runs the corpus; TmcMotion (firmware) drives the real thing.
src/TmcMotion.hpp Non-blocking motion over the TMC5130A, plus the 36 V rail and SPI bring-up. One snapshot() per poll, because RAMP_STAT's flags are read-and-clear.
src/PycUart.hpp UART0 on the RJ-45, and a 64-bit clock accumulated from micros().
examples/PyControlLink The two wired together. 61 native tests pass, all four examples build.

Why a seam rather than calling MotionSetup directly: MotionSetup::moveTo() and homeToSwitch() are blocking — they return a report once the motion is done. Right for a bench console, wrong here: the protocol must ack immediately and keep servicing the link for the whole move, and a 2.5 s blocking home would silently fail every heartbeat inside a 100 ms timeout. TmcMotion drives the same primitives in the same order without waiting.

The seam also grew two things real hardware needs and the corpus does not: consumeFault(), so a driver over-temperature or an unexpected end stop becomes a latched fault and a FAULT event with no host command in flight; and setHvEnabled(), because the 36 V domain exists to feed the driver and a rail that fails to come up is a fault rather than a nak. Both are defaulted, so the corpus behaviour is untouched, and both are tested with stub backends.

NOT YET RUN ON HARDWARE, and the first run should be supervised. Stage 3 validated the blocking implementations; this is a faithful rearrangement, which is an argument and not a measurement. Unproven: the non-blocking homing state machine, and the travel limits derived from bench geometry rather than exercised through the protocol.

Homing over the protocol is deliberately SINGLE-STAGE. The bench measured two-stage and single-stage repeatability as the same 2-5 encoder counts; the slow creep buys datum accuracy (89 microsteps of offset at 0.200 rev/s against 4.5 at the creep) and a faster primary approach, not precision. A single stage is a small amount of code whose failure modes are bounded by the chip's own reference-switch stop; a blind reimplementation of the validated two-stage sequence would not be. Follow-up: two-stage homing over the protocol, with the 89-microstep offset as the reason it matters.

First protocol contact on hardware, 2026-09-03: id passed with the 36 V supply off. Board E4654C159F591D34 through Breakout 1.2 port 1, peer driven from the pyboard REPL with machine.UART(4) directly -- no framework, no hardware definition, no device class, for the same reason stage 2 used raw SPI datagrams: first contact should not have the framework as a second unknown. The reply also closed a standing gap by announcing its own serial, so the left joystick is board E4654C159F591D34 and the right is E4654C159F4D1334. Measurements in ../mouse-joystick-pcb/docs/bringup-log.md under board 3.

Two firmware bugs found by that run, both fixed.

  1. STATUS hung the core. TmcMotion::angleDeg()/xactualDeg() read position over SPI and STATUS calls both, so a status before hv on reached for SPI0 before beginSpi() had run with the bus switch open. The board then stopped answering the link and stopped honouring the 1200 baud reset touch; recovery needed SW501 through a power cycle with both USB and the RJ-45 unplugged. Fixed: position is a cache refreshed once per poll() and nothing touches SPI unless bus_up_.

  2. ID reported the corpus placeholder serial. The firmware never set it from the flash unique id, so every board would have reported 0000000000000000 and a host confirming it was talking to the right board would have passed against the wrong one. Default is now unmistakable; regression test added.

THE LESSON, which is worth more than either bug. The corpus could not have caught the first one. status-is-safe-before-anything-is-configured passes on the host because SimulatedMotion just returns a number. A corpus held by two implementations pins protocol behaviour, not hardware preconditions -- one of the two has no hardware. A green corpus means "the two ends agree about the wire", not "the firmware is right". Anything the firmware does that the host model cannot express is unverified by construction, and the SPI-before-bus class is exactly that.

A latent hazard was closed at the same time: the USB mirror could block when a host connected and stopped reading, stalling the link, the motion poll and the watchdog together. It is now dropped rather than waited on -- losing a diagnostic line is free, losing the link is not.

Stage 4 ran the full motion path on hardware 2026-09-03: hv on, home, arm, moveto, disarm, clrfault, all over the RJ-45 with the motor moving. Homing repeats to about one encoder count; the commanded-move follow ratio is 0.991. ~150 byte-identical ping;9 were each answered and each refreshed the watchdog, which is open question 1's resolution demonstrated on hardware. Measurements in ../mouse-joystick-pcb/docs/bringup-log.md under board 3.

Eight firmware bugs were found by hardware in one session, none of which the shared corpus could have caught: the placeholder serial, STATUS hanging on SPI before the bus existed, a limit field nothing assigned, the missing encoder follow check, the homing driver-enable deadlock, a fault that dropped the flag but not the rail, the loop judging time before reading the link, and the datum offset below. That ratio is the point, not an accident -- see the corpus-limits note above.

A correction to the VALIDATED stage 3 code, not just the new firmware: ENC_LATCH is permanently 0 on rev 0. It latches on the encoder's N event and rev 0 leaves the index no-connect with ENCN terminated by R1201. So MoveReport::latch_encoder and HomeReport::coarse_encoder/fine_encoder -- all filled from that register -- are structurally zero on this hardware. Any stage 3 report showing one should be read as "no data".

RESOLVED: the position-frame offset. Peter's fix -- zero BOTH frames after the clearance move rather than at the switch edge -- removed it and did more besides. moveto -8.0 now reads back angle=-8.016 against xactual=-7.995, agreeing to 10 microsteps; ev;HOMED reports angle=0.0, which is what the shared corpus expects, so firmware and corpus stopped disagreeing on a pinned value; zero became a commandable position instead of one the travel limits exclude; and travel is now -10.125..0.0 deg. XACTUAL is still re-based to XLATCH first so the clearance is measured from the switch edge, which is velocity-independent where the stop position is not.

Also fixed: the approach keyed off status_latch_*, which is read-and-clear. A long approach reached the switch (limit=1) and then hung until the 8 s timeout. status_stop_* are plain status bits that survive polling, and the validated homeToSwitch() already keys off those with an explicit XLATCH fallback -- the non-blocking version had no fallback. And ev;HOMED was reporting the cache from before the datum writes.

WITHDRAWN: the "host must poll faster than the draft implies" measurement. The two observed host_timeout faults were the test harness, not the firmware: MicroPython's UART.read() blocks for its configured timeout on an empty buffer, so a timeout=50 made "20 ms pings" 72 ms and "40 ms pings" ~90 ms against a 100 ms watchdog. The loop-ordering fix stands on its own merits -- the watchdog was genuinely evaluated before the link was read -- but the margin is unmeasured. Set the UART read timeout deliberately before timing this link.

freewheel works on hardware 2026-09-03, and so do both exits. The compliant state entered from ARMED, status reported FREEWHEEL, and the encoder moved 0.133 deg while the ramp generator did not -- the divergence that makes angle and xactual two quantities, observed without touching the mechanism. moveto re-engages implicitly, stop takes the joystick back, disarm leaves it held, and after disarm both frames read identically.

Bug found: leaving FREEWHEEL never re-energised the driver, despite a comment claiming the move "re-engages implicitly". All three exits were separate omissions and now share one leaveCompliance(). Third time the encoder follow check has caught a state transition that forgot to energise what it was about to use -- the other two were homing and, indirectly, the same pattern at arm. That check has paid for itself repeatedly and should not be weakened.

OPEN: sub-degree discrepancies at the release/re-engage boundary. After freewheel -> moveto, xactual=-2.995 against angle=-3.691 (0.70 deg); after freewheel -> stop, 0.27 deg. Leading hypothesis is the rotor snapping when the driver re-energises -- a stepper's electrical angle repeats every four full steps, bounding it at ~+/-3.6 deg here, and both figures fit. The candidate fix is to re-enable BEFORE resyncing so the resync captures the post-snap truth, deliberately not done because energising against a stale XACTUAL would aim the snap at where the ramp generator thought the joystick was. Needs a measurement: park, freewheel, hand-move a known distance, read both frames before the re-engage, immediately after, and after the resync.

Stage 4's rev-0 protocol surface is fully exercised on hardware, 2026-09-03. PULL fires at the crossing (-8.016 against an armed -8.0, twice), and the raw encoder edges captured on the InstrumentPort corroborate the driver's X_ENC to 3-8 counts on a ~15 deg hand pull with zero invalid transitions. So every "anomalous" encoder reading earlier in the session was real motion, and the register can be trusted. Measurements in ../mouse-joystick-pcb/docs/bringup-log.md under board 3.

The identity beacon earned itself. The analyzers were power-cycled to clear a wedged capture and came back in opposite USB ports, silently inverting the topology-to-board mapping while the probe cables stayed put -- a capture was taken on the wrong instrument before Peter noticed the wrong analyzer blinking. The beacon re-established the mapping in one shot. examples/PyControlLink now emits it at boot, because the I command exists only in the bench consoles and the protocol firmware was the one case where identification was impossible without reflashing.

Bench consoles can wedge on USB CDC output. MotionAndHoming blocked its own loop on a Serial.print with no host draining, which enumerates fine but stops answering and stops honouring the 1200 baud reset -- recovery needed SW501. PyControlLink is guarded via usbMirror(); the three consoles are not. Worth the same guard if they are touched again.

Read .agents/handoff.local.md for bench state and the immediate next actions.

Milestones

Protocol Findings, 2026-09-02

Raised by implementing ../mouse-joystick-pcb/docs/uart-protocol.md (Draft v0.1). All six are now written up in that file under "Open Questions From The First Implementation", which is where they should be resolved - this repository implements the protocol and does not define it. Summarised here because they gate Stage 4.

  1. ~~The retransmission rule breaks the heartbeat.~~ RESOLVED 2026-09-03, and not by exempting PING. It was a layering error: the watchdog measures host liveness, so it is now reset on receipt of any well-formed command, before dispatch and regardless of a cache hit — badframe excepted, so a wedged serial driver cannot hold the safety timeout open. The cache is separately restricted to state-changing commands, classified on the parsed command because CAL/INSTR/HV are each a read or a write depending on their argument. Cache bounded at 2. Note MOVE is the only non-idempotent command in the set, so the cache guards one verb and could go entirely if the trial uses only absolute positioning. Implemented in pycontrol/; the corpus did not change. Owned by ../mouse-joystick-pcb/docs/uart-protocol.md, open question 1.
  2. The 100 ms host timeout is shorter than a long move. At 180 deg/s an 18 degree move consumes the whole timeout, and travel is 40 degrees wide. The host must ping during motion. Possibly fine, but it should be written down rather than discovered. Confirm the real velocity and the real timeout together.
  3. The command set does not match the trial. The draft models a pull as detection (ARMPULL threshold); the rig needs it as mechanical resistance - brake sets the force, motor gets out of the way. Trial shape is park, present, release, pull, present again.
  4. Brake-then-release cannot be two host commands. The brake must engage before drive is released or the joystick drops. Belongs in one firmware operation, BRAKE <torque>. FREEWHEEL is a rev-0 stand-in to be replaced.
  5. STATUS's "position" is two quantities once there is a compliant state - measured and commanded, disagreeing by the whole pull.
  6. Resyncing XACTUAL from the encoder deletes the stall detector. Resync only on leaving compliance. Stall detection is unimplemented: it needs a fault bit the draft does not have, and bit 4 is retired for a different comparison.

Update 2026-10-01: findings 3, 4 and 5 are closed by protocol v1 (proto=2, signed off and recorded in ../mouse-joystick-pcb/docs/uart-protocol.md, "Protocol v1 Proposal"): BRAKE <torque> replaces FREEWHEEL (release-only until a board has the brake), WATCH replaces ARMPULL with thresholds relative to the release angle and one TRIAL summary per arming, STATUS carries torque, brake and t, and CFG, chunked FETCH and STREAM are new. Implemented in src/, pycontrol/ and both test runners the same day, compiled into PyControlLink and PyControlLinkQp, not yet run on a board. Finding 6 stands.

Waiting On The Researcher

Not blocking Stage 4, but they change the command set when answered. Raised 2026-09-02; the researcher has not scoped the task yet.

Controller Decided 2026-09-02

Breakout Board 1.2. UART-capable ports 1 (UART4), 3 (USART1), 4 (USART3), from devices/breakout_1_2.py. Two boards fit with a port spare.

This closes the "Which breakout the lab actually deploys" unknown in Gate A of ../mouse-joystick-pcb/docs/pycontrol-interface.md, and it means no breakout_h7.py is needed. pyControl v2.1.1 declares no port.UART for any H7 port, so the H7 breakout would have required writing and upstreaming that driver first. It does not block anything now.

Revision Facts Established 2026-09-01

Read from git show <tag>:kicad/mouse-joystick-pcb.kicad_pcb, footprint U501:

Confirmed On Hardware 2026-09-01

Board serial E4654C159F2C2534, 36 V absent, USB only.

Still Unconfirmed

Known Documentation Conflict

../mouse-joystick-pcb/docs/power.md says HV_SNS and ~{HV_FAULT} "sit behind" the GPIO isolation bus switch and cannot be read until firmware enables it. The GPIO table in docs/interface-map.md marks both byp, and the board's pad-to-net map agrees at both v0.1 and HEAD: GP28/GP29 reach the 36 V switch directly, with no underscore-suffixed twin net. power.md is stale on this point. This firmware follows the netlist. Worth fixing in the PCB repository.

Build-System Trap, Fixed 2026-09-01

lib_deps = src/MouseJoystickBoard — a bare local path — made PlatformIO copy the directory into .pio/libdeps/ and never refresh it, so edits to the library's .cpp files were silently not compiled and builds reported success while testing stale code. Several "passing" builds during initial development were affected. Fixed by declaring symlink://src/MouseJoystickBoard and adding src/MouseJoystickBoard/library.json, which symlink:// requires. Verified with a #error canary.

TMC51X0 uses the same bare-path arrangement (lib_deps_local = src/TMC51X0) and so has the same trap. Worth fixing there.

The 36 V Rail Collapse (resolved, 2026-09-02)

The blocking issue. Full detail and raw data references in .agents/handoff.local.md; the durable summary:

Symptom. HV_EN high with 36 V on J201: +3.3V_SW reaches 3.309 V at t=3 ms, holds rock steady, collapses at t=56-57 ms and stays down. Repeatable to ±1 ms. Captures in ../mouse-joystick-pcb/docs/captures/2026-09-01/.

Ruled out. A resistive short — +36V_SW to GND measures 0.87 MΩ with a DMM, board unpowered. A too-low bench supply limit — the supply was not limited and can deliver 4 A. Note the DMM result does not rule out a voltage-dependent load.

Leading hypothesis. Capacitive-charging thermal shutdown in U201, which tps1htc100-q1.pdf explicitly warns about. Charging +36V_SW's ~208 µF to 36 V costs ½CV² = 135 mJ independent of the current limit, T_REL is 60 °C, and R203 ties LATCH high which disables the auto-retry TI says is acceptable for high-impedance loads. 0.87 MΩ is a high-impedance load.

Not yet distinguishable. Whether U201 turned off or U901/U1001 shut down. Only +3.3V_SW is instrumented; +36V_SW and +5V_SW reach neither the MCU nor the InstrumentPort. This cannot be resolved from firmware — it needs a scope directly on those two rails. Do not add more ADC reads hoping to close it.

Trap. HV_SNS at VSNSFH does not identify a fault. Per Table 7-2 that level is shared by overcurrent, overvoltage, short-to-ground and thermal shutdown, and with EN low it is also OFF-state open-load. An earlier diagnosis of "continuous 1.15 A load" came from misreading it and was wrong.

If confirmed, the fix is a board change, not firmware: reduce bulk capacitance, raise R206 to lower I_CL and spread the same energy over more time, or free LATCH for auto-retry. Raising I_CL does not help.

Stage 3, Passed 2026-09-02

Full procedure, measurements and the reasoning behind each gate are in docs/motion-bringup.md. The durable summary:

It uses TMC51X0. Stage 2 deliberately did not, because first contact with a bus should not also be first contact with a library. Stage 3 needs a ramp generator, an encoder scaler and homing, TMC51X0 has all three, and the bus was already proven. Pinned to tag 4.0.3 in platformio.ini and declared again in src/MouseJoystickBoard/library.json — lib_ldf_mode = off means PlatformIO will not find it from an #include.

src/MotionSetup.hpp is glue, not a driver. It holds only what the library cannot know: the 16 MHz external clock, the 0.15 ohm shunts, the motor's rating, the encoder's resolution, the rig's travel, and CHOPCONF.vsense.

The one bit that matters. TMC51X0's Registers::Chopconf has no VSENSE field — it follows the TMC5160 layout, where bit 17 does not exist. On a TMC5130A that bit is vsense, and with 0.15 ohm shunts it is a factor of 1.8 in motor current. IHOLD_IRUN is write-only, so the current scale can never be read back; CHOPCONF is the readable half of the decision. Firmware sets bit 17 with a raw register write, reads CHOPCONF back, and refuses to enable the output stage unless the read-back says 1. It does not survive a device reset or recoverFromDeviceReset() — re-assert and re-verify after any power cycle of the switched domain.

Measured facts about the rig, all of which were unknown before:

Every move is bounded and encoder-watched. MotionSetup::moveTo() targets a position rather than a stop condition, and compares X_ENC against XACTUAL — the ramp generator advances whether or not the shaft follows, so that comparison is what detects an end stop, a stall, a slipping coupling or a dead encoder. It found the negative end stop on the first attempt without straining anything.

Firmware Traps Found On Hardware, Stage 3

All fixed; do not reintroduce. All four were invisible to a compile and to the host tests, and three would have moved the motor when nothing asked.

Also: reading RAMP_STAT twice loses flags. Several bits are read-and-clear and TMC51X0's leftSwitchActive() / leftLatchActive() / leftStopEvent() each perform their own read, so read it once and decode all of it. MotionSnapshot does.

Stage 2 Design Notes

Instrumentation Reach — A Standing Limitation

Worth stating once because it shaped this session. Firmware can see:

Firmware cannot see +36V_SW, +5V_SW, +3.3V_MCU (it is the ADC reference), or +5V_MCU. InstrumentPort-40 carries no divided motor rail to any AIN by design. Any question about those rails needs a meter or a scope on the board, and there are no test points anywhere. Budget for that rather than trying to reason around it.

Task: Enable The RP2040 Hardware Watchdog (opened 2026-09-08)

Paired with a RUN reset switch added to mouse-joystick-pcb v1.0. Both were justified by the same incident and they cover different halves of it, so neither replaces the other.

The incident: ../mouse-joystick-pcb/docs/bringup-log.md → "STATUS hung the core", 2026-09-03. The board stopped answering the link and stopped honouring the 1200 baud reset touch, so it could not be reflashed. Recovery needed SW501 held through a power cycle with both USB and the RJ-45 unplugged — pulling USB alone leaves the board alive on pyControl's 5 V through the ORing diodes.

Not A Priority