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:
- [ ] Confirm on a failing unit whether the host's line-coding request reaches the CDC at all (USB capture, or a QS record on the CDC callback) before anything in the application is suspected; the two units took the touch from the self-test build, which has the same core.
- [ ] If the request arrives and is ignored, look at what the delivery
build does differently around USB: the 1 kHz hardware-alarm tick and
the watchdog (
Bsp.cpp), and whether TinyUSB's task gets starved. - [ ] Either way, keep the
Bprotocol verb and the button procedure in the README as the documented routes into BOOTSEL. - [ ] 2026-10-14, Peter's laptop: board 9 (unit D) took the touch from the
delivery build
316f802-dirtyand flashed in 4.3 s; board 8 in 7.6 s; board 8 took a second touch (a pyserial 1200 baud open, DTR low) a minute later. So "ignores it every time" is wrong for board 9 — it is intermittent, and the host (laptop vs desk computer, hub) may matter. One board at a time: PlatformIO skips the touch when any RP2040 is already in BOOTSEL andpicotool loadtakes no address.
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)
- [x]
MJ_BOARD_REV=2insrc/BoardPins.hpp: the v1.0 map,hw=rev2,HV_LATCHon GP24 (MJ_HAS_LATCH_GPIO/MJ_LATCH_GPIOforced, overrides refused),~{HV_FAULT}on GP23, I2C0 on GP16/GP17 with the DAC addresses 0x48/0x49, the torque ADC on SPI1 (GP10/11/12, CONVST GP15),TQ_CAL_ENGP18,~{BRAKE_FAULT}GP13,BRAKE_ISENSEGP27,ANGLE_OUT_MONGP29, spares GP20/GP22. The SPI DAC constants do not exist for rev 2, so no SPI DAC code path compiles for it. - [x]
SafeState: drivesHV_LATCHlow (auto-retry, the reset default;powerUp()raises it after the rail is up),TQ_CAL_ENlow andTQ_CONVST_CShigh behind the open switch, leaves~{BRAKE_FAULT}, the SPI1 and I2C pins as inputs;setInstrumentationEnabled()is a no-op on rev 2 (Digital-50 has no enable). Reset-state reads:R607,R401,R507on GP23,R402/R403(I2C idle high),HV_LATCHinformational. - [x]
RP2040SelfTeston rev 2: 2 spare pads, the four straps above, the two monitor ADC channels at 0 V with the rail off; 20 checks by derivation from rev 1's 24 (−7 spares −1 buffer OE +2 I2C +2 ADC). - [x]
TMC5130Communication: v1.0 designators (U501,R501,R506,R508,R509; no LATCH strap).MotionAndHoming,AngleOutputandPyControlLinkQp/Bsp.cpp: the DAC transport follows the revision (rev 1 SPI1, rev 2 I2C0 onpins::kDacScl/kDacSda),MJ_DAC_I2Cis derived and a contradiction is a compile error; the v1.0 ÷2 output divider is in the printed BNC volts (1.650 V full scale). - [x] Protocol:
ProtocolBoard::Config::instr_switchable(false on rev 2, soINSTRis accepted andstateis always 1, per uart-protocol.md). - [x] Envs:
mouse_joystick_rev2,mouse_joystick_qp_rev2,mouse_joystick_qp_rev2_usb; the two*_rev1_i2cenvs removed (they paired rev 1 pins with an I2C DAC and matched no board).mouse_joystick_rev1is now documented as v0.3 only, not "HEAD". - [x] Host tests: 125 pass (a Guix
gcc-toolchainis needed on this machine; the conda compiler in the Pixi env fails at link against the system glibc). Target builds for every example and env: see the status line below once they have run.
Still owed, in order (software first, then the bench)
- [x] Regression of HEAD on v0.3 before the v1.0 boards (2026-10-05, board 4):
every stage console on
mouse_joystick_rev1and the QP session onmouse_joystick_qp_rev1_usb, no firmware regression; see Status. - [ ] Tag
mouse-joystick-pcbv1.0at the fabricated commit (646b8f4is the one whosefab/package is the order;d5ae4b0is the last board-file change). Then replace "no tag yet" inBoardPins.hpp,platformio.ini, the README anddocs/rp2040-bringup.md. - [ ] Rig plan Phase A1 cannot see the DACs or the ADS8689 on USB alone.
Both DACs run from
+3.3V_FILT, the ADC from+3.3V_SW/+5V_FILT, and every one of their bus lines is behind the bus switch. "I2C sees 0x48 and 0x49, SPI1 sees the ADS8689" is an A2/A3 check with the rail up and the bus closed. Corrected in the rig plan 2026-10-04; A1 is theRP2040SelfTestrun and nothing more. - [x] A1:
RP2040SelfTestonmouse_joystick_rev2, USB only (2026-10-05, unit AE4654C159F274534): 20 checks, 0 failed,HV_LATCHlow, monitors at 0 V; recorded in the PCB bring-up log. Both v1.0 units arrived with a wrong part (green, banded) at everyC2858031position (C1301on+3.3V_MCU,C1208on TMC5VOUT,C1501on+5V_SW); removingC1301brought the board up.C1208must be refitted with a real 2.2–4.7 µF before A2. - [x] A2 (2026-10-05, unit A):
powerUp()reports the direct method; the first attempt failed because latch-off was armed 10 ms after+3.3V_SWread live while the switch was still charging the bulk (PG lines up at 6–11 ms, trip at 37 ms). Fixed inPowerSequence: LATCH stays low untilHV_SNShas left the fault band for 20 ms (33–34 ms measured),powerDown()restores LATCH low,PowerUpReport::charge_msadded. Two clean passes, 5/0 register checks each; idle 36 V input current 43.6–51.3 mA. The LATCH probe's strapped half fails as designed. Numbers in the PCB bring-up log. Uncommitted. - [x] A3 (2026-10-05, unit A): the I2C DAC at 0x48 answers,
GAIN 0x0101written and read back matched,DEVID0x115,REF-ALARMclear, every transaction ACKed on the Digital-50 I2C taps.ANGLE_OUT_MONreadback added toAngleOutput(rev 2): midscale 0.8236 V, full scale 1.6415 V, floor 12 mV at the BNC node, within the RP2040 ADC's accuracy; a meter fit is still owed. I2C set to 400 kHz in both bench consoles (the QP build already had it); the live loop's worst iteration went 1082 → 732 µs. Uncommitted. - [x] A4 (2026-10-06, unit A):
examples/BrakeAndTorque(new; the console for A4 and A5 together, line-oriented commands,tools/board_console.pydrives it) configures the brakeDac80501at 0x49 withGAIN 0x0101read back, sweeps, steps and readsBRAKE_ISENSE/~{BRAKE_FAULT}. Brake chain linear, full scale 1.0065 V = 48.80 mA (design 1.006 V), ±0.07 % FS above half scale, the 12 V gate drive sufficient. Finding: a step above ~60 % overshoots — 62 mA (127 %) for ~5 ms at full scale — and~{BRAKE_FAULT}never asserts, although 62 mA is above the 56.4 mA ceiling. Board question filed in the PCB plan; firmware consequence:BRAKEramps its setpoint (≥ 6 ms to full scale) and checksBRAKE_ISENSEagainst it rather than trusting bit 9. Numbers in the PCB bring-up log;docs/brake-torque-bringup.mdhere. - [x] A5 (2026-10-06, unit A): ADS8689 driver written (
src/ADS8689*.hpp,src/ADS8689/, 22 host tests),RANGE_SEL0x0B written and read back, SDO proven by theDATA_VALloopback patterns before any conversion is trusted; the frames decoded on the Digital-50 taps. Torque at rest 1.5956 V (69 mV below the ideal zero: mechanism preload or offset, tare covers it), noise 95–112 µV rms at 1 kHz, 1000.1 Hz live loop at a 295 µs worst tick, no brake-to-torque coupling at 0.1 mV. The shunt-cal switch is inert (0.0 codes of a 0.744 V step), as the 2026-10-04 design review predicted;TQ calstays a verb and not a calibration. - [x] Brake and torque in the protocol (2026-10-06, both ends, run on unit
A).
src/BrakeTorqueBackend.hppis the seam (with the simulated backend the corpus runs against);src/BrakeTorqueHw.hppthe real one (brake DAC at 0x49 with a setpoint ramped at 8 ms to full scale,BRAKE_ISENSEsupervision for bit 10, the fault pin for bit 9, the ADS8689 sampled on the tick, bits 11–12).ProtocolBoard:capsbrake/torque,BRAKEacks then waits for the backend to settle before releasing the drive and emittingRELEASED(motion verbs busy meanwhile,STOP/DISARMcancel), the drive re-engages BEFORE the brake comes off on every way out ofRELEASED(3a; the old order was wrong and no board could show it),TQ zero|cal|get,torqueinSTATUS/events/TRIAL(t_touch,tq_peak,tq_out)/capture,TOUCHon the torque magnitude. Mirrored inboard_sim.py; corpus grammar gainedCAPS <tokens>and a liveTORQUEstimulus, 5 new cases; device classtorque_get()/torque_cal(). 154 Unity, 178 pytest. On unit A (mouse_joystick_qp_rev2_usb, driven byqp-arduino-example/tools/brake_torque_steps.py):RELEASED11.6 ms afterbrake 1.0and 15–17 ms afterbrake 3.4, torque −0.30 N·cm on the nominal zero then tared,NOMOVEat 3000.1 ms,TRIALwith torque fields,FETCHwith the torque column, cleanhv off. Decisions the spec left open are written up as uart-protocol.md V1.11. Numbers in the PCB bring-up log ("brake and torque in the protocol build"). - [ ] The 1 kHz sampler is decimated whenever the rail is up on v1.0, and
was already on 2026-10-05 (
last110–750 µs,max0.9–1.5 ms without the brake/torque poll; 0.26–1.2 ms and 2.1–2.5 ms with it, 1.2 ms inRELEASEDwith aWATCHarmed). The primitives are cheap (ADS8689 sample 37 µs,BRAKE_ISENSE4.8 µs, DAC write 110 µs, consoleM);delayMicroseconds()in the ADS8689 transport was the first suspect and was not it (busy wait now, no change). Attributed withPOLL_STAT(QS_USER+14, per second: each backend's worst poll, its calls, and its worst primitive), run…-0942-unitA-qp-brake-torque: with the rail up EVERY primitive shows 100–500 µs maxima — the X_ENC read 350–555 µs, the five-register snapshot 400–670, the ADS8689 sample 230–480, even theBRAKE_ISENSEanalogRead110–230 µs — against 30, 150, 37 and 4.8 µs on the bench console. A 5 µs ADC read cannot take 230 µs by itself: this is interrupt time landing inside whichever bus primitive is running. The bench builds carry the protocol over USB CDC, and the Arduino core services tinyusb from a 1 kHz interrupt (tud_task()at IRQ level), so the heartbeat and the replies are the prime suspect; the QS PIO UART's ISR is the other. Next: an A/B with the heartbeat slowed and the trace off, then the delivery build over UART0 with the pyboard (which has almost no USB traffic) — the tick budget must be judged on that build, not on_usb. Until then the brake ramp's steps are coarser than designed (three writes instead of eight for 3.4 N·cm) though still below the overshoot threshold, andlost=in a trial's capture will not be 0 under load. - [x]
SafeState::driveOutput()writes the level before the direction (SDK calls, nogpio_init()): the 34 µs~{BUS_SW_EN}boot glitch is gone, no edge on CH27 across a warm reboot (2026-10-06).measureHvCurrent()now waits for the rail to read off (and reports how long it took) instead of pausing 50 ms, so stage 2'smworks after a live rail; not yet re-run on hardware. - [x] A6 (2026-10-05, unit A): rail 3 ms / charged 34 ms, TMC 0x11,
vsense1, switch witnessed, homed, 5 of 5 at 2.0 counts, reversal dead band 89 µsteps as on board 4 (mechanism, not board),L1000.1 Hz but worst loop 1082 µs over the I2C DAC (v0.3 SPI: 386–527 µs). Numbers in the PCB bring-up log. - [x] After A6 (2026-10-05, unit A):
PyControlLinkQponmouse_joystick_qp_rev2_usb, phases a–e, no asserts; tick cost 305/357 µs holding with the angle output on (I2C), 15/52 idle, 116/1081 moving; host-loss 105.7 ms; 320 moves. Run with a scratch variant of the session script that remaps the Digital-50 channels to the 40-pin names the trace tools expect (CH1→CH17, CH0→CH16, CH2→CH8, CH9→CH5, CH8→CH2); the bench scripts still hard-code board 4,rev1and the 40-pin map and are owed a serial/env/channel-map parameterisation. - [x] Digital-50 observation through the LA5032 adapter v2.0 and the Molex 15020 FFC: straight mapping confirmed 2026-10-05, 26 of 32 channels exercised by 2026-10-06 (the UART pair last, with a pyboard).
- [x] First contact over the RJ-45 on v1.0 (2026-10-06, 09:59): the
delivery build
mouse_joystick_qp_rev2(protocol on UART0) answeredidfrom the pyboard's REPL on Breakout 1.2 port 4 (USART3) — the right joystick's port, which is the one unit A drives — with the full v1.0 identity and caps; ports 1 and 3 silent. Then the whole protocol over the real link with the rail up:hv on, torque live and tared,home,arm,brake 1.0→RELEASED,watch→NOMOVEat 1500.0 ms,TRIAL1 s later with torque,fetchwith the torque column; a 150 ms gap in the REPL script then tripped the 100 ms host-loss watchdog exactly as specified (FAULT,hv=0,brake=0.0,nak;faultedto the nextbrake,clrfaultrecovered). Decoded on CH2/CH3. PCB bring-up log, "first contact over the RJ-45". - [x] The tick budget, attributed and half fixed (2026-10-06). Over
UART0 the primitives drop to a third of the USB build's maxima (X_ENC
161 µs, snapshot 310, ADS8689 sample 103,
BRAKE_ISENSE57–137), so the USB protocol port's interrupt-level servicing was the big inflator and the_usbbuilds are not where the budget is judged. What remained was structural: both hardware backends polled at every scheduler stop (theRELEASEDring adds one per millisecond), and thehv ontransient latched the decimated mode untilhv off. Fixed: hardware polls reuse their cache within 500 µs (TmcMotion,BrakeTorqueHw; the simulated backends are unlimited so the corpus is untouched), and decimation ends after 500 handled ticks under half the budget (LinkAo). Measured on the Gate E run — see Status. - [x] The watchdog runs with the rail (2026-10-06, Peter's call after Gate
E).
hv onarms the host-loss watchdog,hv offor a fault disarms it,armrefreshes,disarmleaves it running while the rail is on (ProtocolBoard::cmdHv/cmdDisarm,board_sim._cmd_hv/_cmd_disarm,uart-protocol.mdV1.12 and its tables). Three new corpus cases; two older ones rewritten (event-time-is-64-bitadvances before the rail,home-timeout-above-the-maximum-is-clipped-not-refusedwidens the watchdog and heartbeats for its eight seconds), their unit-test mirrors in both languages likewise. Consequence: a host heartbeats fromhv on; the device class'shv()docstring says so. - [x] Tare before the watch (same day).
mouse_joystick_checksendstq zeroright beforewatchwhen the board hastorque, so the rest offset (−0.2 to −0.35 N·cm at rest afterRELEASED) stops trippingtouch=0.3. Bench knobsv.wdt_msandv.instradded for the tick attribution runs. - [x] The tick budget, round three, attributed and fixed (2026-10-06,
11:04–11:55). Measured on the LA5032 rather than with the CPU's own
clock (PCB bring-up log, "the tick budget attributed on the bus and on
the pin"; scripts
qp-arduino-example/tools/la/). The bus was never the problem: an X_ENC read is 27 µs on the wire and a snapshot 101 µs after the library changes, yet the firmware measured 240–430 and 400–570 µs for them, apingcost the active object 1.2–1.9 ms (LINK_WORK), and on the pin (MJ_TICK_LEVEL) theRELEASEDtick was 758 µs median for a few hundred instructions of classifier work. The trace off changed nothing; fewer host lines helped only the idle. The cause: every build ran from the W25Q128JVZPIQ through the generic single-lane 03h stage 2 (boot2_generic_03h_4, kept "until stage 1 passes" since the first article, never switched). On the quad-lane stage 2 matched to the part (tried asenv:mouse_joystick_qp_rev2_fastflash, then made the default of every env at 14:05,boot2_w25q128jvxq_4; boots and runs the task first time) theRELEASEDtick is 346 µs median / 450 p90 / 760 max, theARMEDtick 146 / 235 / 370 at 1000 handlers/s, aping0.5 ms, everyPOLL_STATmaximum 2–3× lower, and the sampler holds 1 kHz inARMED. The budget constant (400 µs) then decimated the trial on its own, so it is 700 µs (measured headroom inLinkAo.cpp); run 9 (…-1147-unitA-gate-e-fastflash/): 1 kHz for the whole run, trial included, never decimated, 79 overrun skips and 47 coalesced ticks in 50 s (from ≈1900 / ≈1500), handler max 798 µs inRELEASED, 371 inARMED. Peter made it the default at 14:05:env:mouse_joystick'sboot2_sourceis the quad-lane one, every env inherits it,env:mouse_joystick_safeflashkeeps the generic stage 2 for a first article, and the_fastflashenvs are gone. Also done on the way, all measured:TMC51X0working copy with one buffer transfer per datagram (two-buffer form on RP2040/Teensy: 17 → 12 µs per datagram, clocks back to back) and the pipelinedreadMany()(snapshot 10 → 6 datagrams), used byMotionSetup::snapshot();platformio.inipinsTMC51X0.git#4.2.0(the library is versioned and release-checked; Peter tags and pushes). Open: (1) make the quad-lane stage 2 the default for pcb v1.0 (flipenv:mouse_joystick'sboot2_source, or the rev2 envs'); every v1.0 board has the same flash; keep the generic one for a first article. (2) TheRELEASEDtick is still 2.4× theARMEDone for little work — code size and the 16 KB cache; a-Os/-O2and section-placement pass (__not_in_flash_funcon the tick path) is the next lever if ever needed. (3) Unit B and the pyboard heartbeat rate: 25/s costs 5 % of the ticks ~40 µs; fine. - [ ] The park error: lost motion on direction reversal, measured
(2026-10-06, 14:07–14:25). Every trial parked 0.99° short on the
encoder with the ramp generator satisfied. Probed with the bring-up
task's
parkknob (PCB bring-up log, "the park error measured"; evidence…-1407-unitA-park-error/): the release, the resync and the brake are not it (both frames equal and the brake off for 300 ms, the move still lands at −0.991); a chain of six moves loses 0.35–0.77° on every reversal and ≤ 0.07° on same-direction moves, independent of distance, not accumulating. Not the encoder, not the firmware. At IRUN 17 instead of 8 the after-positive state is −0.633 three times out of three (from −0.82…−0.99) and the hysteresis shrinks by a third: ~0.3° is the stepper's torque angle under friction at the bring-up current, 0.3–0.6° is drive-train lost motion. Options, in order of preference: (1) settle on the encoder afterTARGETwith one or two correction moves of the residual — needs aTARGETsemantics decision inuart-protocol.md; (2) same-direction approach (overshoot and return); (3)IRUNtoward 17 when the thermal survey allows. Until then both tasks home before every trial. Option (1) done 2026-10-07 (uart-protocol.md V1.13), measured on unit A (PCB bring-up log, "the encoder settle after a move"; evidence…-2026-10-07-0807-unitA-encoder-settle/):TmcMotioncommands moves relative to the encoder, waits afterposition_reachedforX_ENCto stand still (one count for 10 ms, bounded 100 ms), corrects a residual above one count up to twice (Conversions.hppkMoveSettle*), reportsMOVE_SETTLEin the QP trace; both simulators mirror it with the corpusSLIPstimulus and three cases (156 Unity, 188 pytest, all 15 example/env builds). The same probe chain twice: all 16TARGETs within 0.18° (before: eight of eight short by 0.22–0.99°), the park at 0.00 and +0.08; a reversal costs 150–260 ms more, an uncorrected move 10 ms. The rig task'shome_each_trialdefault is 0 now. - [ ] Settle tolerance, one count or two (2026-10-07).
MOVE_SETTLEshows the first correction removing most of a reversal's 0.6–1.0° and 1–2 counts remaining that a 13–25 microstep correction cannot move against static friction at IRUN 8 (the ramp frame advances, the rotor does not, and the next same-direction move overshoots by what was stuck: probe 3 arrived 0.36° past). A two-count tolerance would skip the ~100 ms second correction at the price of the last count; at IRUN 17 the torque per microstep doubles. Decide with the thermal survey; the constant iskMoveSettleToleranceMicrosteps. - [x] The rig's trial task, dry run (2026-10-07, 08:40):
joystick-rig/feat/mouse-joystick'shardware_definition_joystick.pyandtasks/rig1/joystick_trial.pywithhome_each_trial = 0, run from their checkout (tools/pycontrol_run.py --hwd-path/--task-dir, the rig'sopto_led.pysymlinked into pyControl'sdevices/) against unit A on the right joystick: eight trials, every present and park within one count, no fault (PCB bring-up log, "the rig's trial task, dry run"; evidence…-0840-unitA-rig-trial-dryrun/). The pyboard's flash was full of device files pyControl never deletes (pycontrol/README.md, "A fourth thing"). What remains for the rig session is the solenoid cable on port 5 and the researcher. - [x] The reward path and unit A's pull sign (2026-10-07, 08:53–08:59):
with the solenoid on port 5 a hand pull was first classified
PUSH(pull sign +1), the park against the held lever faultednot_following;BoardCalibration.hppgained{"E4654C159F274534", -1.0}, and the next pull wasPULL→ the task's 50 ms solenoid pulse →TRIAL outcome=PULL→ park at 0.0 (PCB bring-up log, "the reward path and the pull sign"; evidence…-0855-unitA-solenoid-pull/). Unit B's sign is to be measured the same way (a hand pull duringWATCH;PUSHmeans flip it). - [ ] A lever held through the park faults the board (2026-10-07).
Correct for the stall detector, fatal for a session:
TRIALcomes 1 s after the outcome, the task parks, a held lever stalls the drive at IRUN 8 →not_following→ rail off. Decide with the researcher (uart-protocol.mdV1.9): the board defers the move while the torque says the lever is held (anak;busyor a wait), or the task recovers (CLRFAULT→HV ON→HOME). The rig task has no recovery path yet. - [ ] One
badverb5 ms after aFAULT(2026-10-07,pull1_60s): the task's firstfaultsrequest was refusednak;326;badverb, the retry was acknowledged; the verb dispatches by exact match. Most likely a corrupted byte on the way in around the switched-domain collapse; no capture was running. Capture CH3 (board RX) at the next fault before changing anything. - [x] Unit B (board 7,
E4654C159F404534, the LEFT joystick), stages 1 through A5 (2026-10-07, 09:07–09:31) after Peter's capacitor rework: 20/0 self-test checks, two direct-LATCH power-ups with 5/0 register checks and 29.9 mA idle, the angle DAC withGAINread back (0.829/1.649 V mid/full on the monitor), the left joystick's switch at the positive end and active high with the encoder tracking (no config flip), homed, homing spread 9 counts (runs 3–5 within one), live loop 350 µs, brake sweep linear to 49.11 mA with the same silent 128 % full-scale overshoot as unit A, torque 103 µV rms at 1000.1 Hz. The second LA5032 (conn=1.67) is on its Digital-50, straight cable, map proven by beacon. PCB bring-up log, six new unit B sections; evidence…-0908-unitB-bringup/. Gate E passed on Breakout port 1 at 09:37 (the same sequence as unit A's to tens of ms), and the hand pull wasPUSHthenPULLonce{"E4654C159F404534", -1.0}was in the calibration table: both joysticks pull toward decreasing angle, the left torque sensor reads it negative. Both boards pass under one hardware definition on their own ports; a task driving both at once is the researcher's. - [x] The rig's files fit the pyboard the way the GUI uploads them
(2026-10-07, 10:30). Measured: 33.8 KB free after "Load framework",
the six drivers the rig definition pulls in take 22.3 KB, so the
hardware definition and task must fit in ~10 KB verbatim; the
originals (15.5 KB, 95 % comments; 10.2 KB, 47 %) failed on the
definition at 0 bytes free. On the rig branch the definition's prose
moved verbatim to
notes/hardware_definition_joystick_notes.md(the.pyis 2.1 KB, code AST unchanged), the task's long header tonotes/261007_mouse_joystick_researcher_guide.md(6.9 KB, plus aclear_fault()atrun_start); the GUI path then leaves 7.7 KB free. The researcher guide is the deliverable for the session. - [x] 400-trial soak (2026-10-07, 10:40–11:14): the rig task on the
right joystick unattended, 400
NOMOVE, 0nak, 0FAULT, every present and park within one count (park 0.0 × 399), period 5.08 ± 0.02 s with no drift, 53 190 link lines (PCB bring-up log, "400 trials unattended"; evidence…-1040-unitA-soak/). Not exercised: a lever in a hand, both boards at once, an hour or more; an overnight run is the next size up when the rig is idle. - [ ] Left joystick: the torque sensor rests 1.55 N·cm above its nominal zero (unit A: 0.07 below), forty times the zero-balance spec; a mechanical preload at the sensor or lever. The tare hides it; the mounting wants a look before absolute torque is trusted. Also a small brake-to-torque coupling (−0.006 N·cm at 49 mA) unit A does not show. Peter / the rig.
- [ ] The uploader's bootloader step fails on unit B three times out of
four ("The RP2040 device returned an error:
", error 157, after the 1200-baud touch has put it in BOOTSEL); unit A takes the same pixi run uploadevery time. Copying the UF2 to the mountedRPI-RP2drive works every time. Two RP2040s on the bus, a different hub port, or picotool's device pick — undiagnosed; the UF2 path is the workaround (tools/pio_task.pycould fall back to it). - [x] Gate E (2026-10-06), passed on v1.0 unit A:
tools/pycontrol_run.pyruns a task on the pyboard headlessly through pyControl's ownPycboardAPI (the GUI's path, no GUI);pixi run -e pycontrol pycontrol-run. Two things pyControl 2.1.1 taught us on the way: (1) its device-file discovery is a substring match of class names over file TEXT, comments included, recursing without a cycle check, soMouseJoystickbeing a prefix ofMouseJoystickProtocollooped it to aRecursionError— the codec is nowJoystickProtocoland the constraint is written in its header; (2) the pyboard's 95 KB flash had 36 KB free against 60 KB of uploads (our files were 60 % prose, and comment-only mentions ofMCP23017andPokepulled a 9 KB driver along), sotools/pycontrol_lean.pynow generates comment- and docstring-free copies intopycontrol/dist/(49 → 20 KB), which is whatpycontrol-linkpoints pyControl at and what the rig will ship;tests/test_lean.pyimports them. The bring-up taskmouse_joystick_checkspeaks protocol v1 withv.boardselecting the side, and keeps its ownv.phasebecause the task API has no current-state query (the third lesson; the first run on the board died onget_current_state()one step afterhv on, rail up, host watchdog not yet armed). Passed twice (evidenceqp-arduino-example/evidence/2026-10-06-1019-unitA-gate-e/,-1037-,-1043-): identify →hv on(180 ms from cold) →HOMED→arm→TARGET−5.041° →BRAKE 1.0→RELEASED23 ms later →TOUCH→NOMOVE→TRIAL→ park →done;run_endstops, disarms, drops the rail. Heartbeats 25/s, none lost. Written up inpycontrol/README.mdand the PCB bring-up log's "pyControl Integration". Two observations for the rig: the rest torque reading (−0.2 to −0.35 N·cm, the PCB plan's third finding) trips a 0.3 N·cmtouchwith nobody there — tare beforewatch; and the park after a trial ends ~1° short on the encoder (backlash on the direction reversal) — home first when the park position matters (fixed 2026-10-07, V1.13: the board settles on the encoder after every move).
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.
- [ ]
OBS-FW-1: define representative command/event/completion semantics; ready now, coordinate interface/fixture fields withOBS-IF/OBS-HOST. - [ ]
OBS-FW-2: prepare good/fault/gap baseline fixtures and expected outcomes. - [ ]
OBS-FW-3: implement bounded observation only after agreeing semantics and claiming affected files with the delivery/watchdog agent. - [ ]
OBS-FW-4: measure the baseline on a qualified board/capture path; hardware-gated, with measurements in the PCB bring-up log. - [x]
OBS-FW-5(first half, 2026-10-01): the QP comparison slice exists and ran.examples/PyControlLinkQpputsProtocolBoardand the motion backend, unchanged, inside one QP/C++ active object with a transmit-only QS trace onFW_TRIG1; the simulated-motion bench build ran the pilot's full protocol sequence on the rev 0 fixture, the delivery build (TmcMotion, UART0) builds and boots into the safe state. Evidence in../qp-arduino-example/evidence/2026-10-01-rp2040-joystick-link/and.../2026-10-01-rp2040-joystick-firm-qp/. Owner of the example files,platformio.ini's[qp_env_data]and the threemouse_joystick_qp*envs: this workstream. Still owed before any adopt/defer decision:TmcMotionbehind the AO with the rail up (the same first supervised runPyControlLinkowes), the UART0 path under QP with a pyControl host, theOBS-IFmarker overlay forFW_TRIG0/FW_TRIG1, and the v1.0 pin map. Record the decision here when it is made. - [ ]
OBS-FW-6: hand off correlated evidence for a supervised observation-only experiment pilot.
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:
- Inverting the ARMED and range checks in
MOVETOpassed every corpus case, so the corpus documented a safety-relevant ordering it did not enforce. Addedarmed-is-checked-before-the-range-check, which both implementations now pass. - A test asserting that malformed frames do not refresh the host-loss watchdog advanced a full extra timeout at the end, tripping the watchdog either way. It passed against a board that refreshed on garbage. Margins tightened.
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.
-
STATUShung the core.TmcMotion::angleDeg()/xactualDeg()read position over SPI andSTATUScalls both, so astatusbeforehv onreached for SPI0 beforebeginSpi()had run with the bus switch open. The board then stopped answering the link and stopped honouring the 1200 baud reset touch; recovery neededSW501through a power cycle with both USB and the RJ-45 unplugged. Fixed: position is a cache refreshed once perpoll()and nothing touches SPI unlessbus_up_. -
IDreported the corpus placeholder serial. The firmware never set it from the flash unique id, so every board would have reported0000000000000000and 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
- [x] Pixi + PlatformIO environment, mirroring the
TMC51X0layout - [x]
src/BoardPins.hppderived fromU501's pad-to-net map at PCB tagv0.1 - [x] Confirmed PCB tags
v0.1andv0.2have byte-identical RP2040 pad maps - [x]
SafeState— safe idle without energising anything - [x]
examples/RP2040SelfTest— clocks, flash, RAM, pads, pull resistors, ADC - [x] Boot signature and heartbeat on
FW_TRIG0/FW_TRIG1, so firmware execution is observable without USB - [x]
MJ_BOARD_REVso the spare-pad test matches the revision being built - [x] Compile clean on
mouse_joystick,mouse_joystick_safeflash(was_fastflash: the quad-lane stage 2 is the default since 2026-10-06),mouse_joystick_rev1; 7/7 host tests pass - [x] BOOTSEL enumerates (
RPI-RP2mass storage) — passed 2026-09-01 - [x] Firmware flashes, verifies, and runs — 27 checks, 0 failed, 2026-09-01
- [x] Results recorded in
../mouse-joystick-pcb/docs/bringup-log.md, 2026-09-01 - [x] LA5032 channel map confirmed against the boot signature, 2026-09-01, and recorded in the adapter's own bring-up log
- [x] Stage 2 written:
examples/TMC5130Communication. Builds clean, plus 8 host tests pinning the register decoding and the expected-IOIN claim. - [x] Stage 2 run on hardware 2026-09-01 — reached gate 2 and stopped there
- [x] 36 V power chain shown to work at least momentarily: rail up in 3 ms to 3.309 V, measured
- [x] 36 V rail collapse resolved 2026-09-02 —
R203straps the switch'sLATCHhigh, so it cannot auto-retry through the inrush. Worked around byPowerSequence::staircaseStart(); the real fix is a board change, tracked in../mouse-joystick-pcb/.agents/PLANS.md - [x] Stage 2 gates 4 and 5: bus switch closed, TMC5130A
IOINread, both boards, 2026-09-02 - [x] Stage 3: motion, encoder and homing, passed 2026-09-02 on board
E4654C159F4D1334, withvsense = 1set and read back before the output stage was ever enabled - [ ] Resolve whether the motor's 0.6 A rating is RMS or peak, and set the run current ceiling deliberately. Needs a coil-current measurement
- [ ] Run stage 3 on board
E4654C159F2C2534, whose empty-connectorREFL_STEP= 1 is still unexplained - [x] Stage 4 host environment, 2026-09-02.
pycontrolpixi environment (no-default-feature, so no PlatformIO),tools/pycontrol_task.py, andpycontrol/. Resolved and locked for linux-64, osx-64, osx-arm64 and win-64 — the rig PC is Windows 11, which is what ruled out a Guix-only environment. Verified: PyQt6 6.11.0 / pyqtgraph 0.14.0 / numpy 2.5.2 / pyserial 3.5 against pyControl v2.1.1; 6 environment tests pass; firmware env unchanged at 33/33 - [x] Stage 4 host half: framing codec, device class, simulator, 22-case corpus
- [x] Stage 4 firmware half:
src/Protocol.hpp+src/ProtocolBoard.hpp, held to the same corpus bytest/native/test_protocol/. 2026-09-03 - [x] Stage 4: protocol layer wired to a real UART and real motion —
MotionBackendseam,TmcMotion,PycUart,examples/PyControlLink. 2026-09-03, not yet run on hardware - [ ] Stage 4: two-stage homing over the protocol (single-stage today)
- [ ] Stage 4: pyControl UART protocol on hardware (Gate E)
- [x] Host half written and green, 2026-09-02. 128 tests.
pycontrol/devices/mouse_joystick_protocol.py(codec, runs on the board and under pytest),pycontrol/sim/board_sim.py(simulator with an injected clock),pycontrol/devices/mouse_joystick.py(device class, asserts onport.UARTand on duplicateevent_name),pycontrol/tests/fixtures/protocol_cases.txt(22-case shared corpus), plus a bring-up hardware definition and task - [x] Narrowed to rev-0 hardware, 2026-09-02.
ANGOUT/ANGSCALEremoved (analog outputs not populated);FREEWHEELand a compliant state added;STATUSnow reportsangle(encoder) andxactual(ramp generator) separately; encoder resync on leaving compliance only. Findings appended to../mouse-joystick-pcb/docs/uart-protocol.mdas open questions, without changing its command table - [ ] Protocol framer/parser in
src/, and a host-native test that reproducesprotocol_cases.txt. Until this exists Gate C is NOT passed: a simulator agreeing with a corpus only it has been checked against says nothing about the RP2040. The corpus is line-oriented so the C++ side isgetlineplus a prefix compare, no JSON library - [ ] Resolve the six protocol findings below before freezing the draft
- [ ] Gate E on the Breakout 1.2, then deliver to
joystick-rig
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.
- ~~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 —badframeexcepted, 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 becauseCAL/INSTR/HVare each a read or a write depending on their argument. Cache bounded at 2. NoteMOVEis 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 inpycontrol/; the corpus did not change. Owned by../mouse-joystick-pcb/docs/uart-protocol.md, open question 1. - 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.
- 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. - 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>.FREEWHEELis a rev-0 stand-in to be replaced. STATUS's "position" is two quantities once there is a compliant state - measured and commanded, disagreeing by the whole pull.- Resyncing
XACTUALfrom 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.
- What the joystick must do in a trial - whether
MOVETO/MOVE/ARMPULLis the right vocabulary, or whether a hold-against-force mode is needed - Whether move speed and motor current should be adjustable or fixed defaults. Current stays out of the protocol either way, per the draft's "deliberately absent" list; speed would be named profiles, not raw numbers
- Whether rev 0's UART-latency-only event path is acceptable for a pilot.
../mouse-joystick-pcb/docs/pycontrol-interface.md: "fine for exercising the interfaces, not fine for behavioural data" - On heartbeat loss mid-trial, whether latching a fault and requiring
CLRFAULTis the behaviour they want, given it ends the session - ~~Which Breakout 1.2 ports the two boards get~~ — assigned 2026-09-03:
ports 1 and 4. Requested of the researcher: move the reward solenoid from
port_4.POW_Atoport_5.POW_A. Awaiting confirmation that it is done — that is the only thing standing between this assignment and a working link. Port 3 stays withLaserLickPort, which needs the one port having both I2C and a freeDIO_C. Reasoning in../mouse-joystick-pcb/docs/pycontrol-port-pinout.md - Whether one
RSYNCoutput can drive both boards - still open in Gate A ofpycontrol-interface.md, and now sharper: Breakout 1.2 has two BNCs and both are already in use (camera trigger, opto gate), against six lines wanted at rev 1
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:
v0.1andv0.2RP2040 pad-to-net maps are byte-identical. The ambiguity over which was manufactured does not affect firmware.- HEAD differs on three pins only:
GP10,GP11,GP13. HEAD routes them to the rev 1DAC80501;v0.1leaves them unrouted. Handled byMJ_BOARD_REV. - Designators rotated between
v0.1and HEAD. The 36 V input block went fromU201/R20xtoU1901/R190x.R1901/R1902are 100 ohm pyControl series resistors atv0.1and 1 kohmHV_EN/HV_DIAG_ENseries resistors at HEAD. All designators in this repository arev0.1designators. v0.1has threeSN74LVC125Abuffers (U1601,U1701,U1801), all on+3.3V_MCU. HEAD has a fourth,U2101, for the DAC bus.- Every instrumentation buffer input is resistor-held, including the encoder
taps (
R805/R806100 kohm to GND), so enabling the port floats nothing. - USB is USB-C (
J301,CC1/CC2via 5.1 kohm), withU301USBLC6-2P6 on the data pair. MCU power chain:VBUS->D301Schottky ->+5V_MCU->U701TPS73733 ->+3.3V_MCU. pyControl's 5 V ORs in throughD1901. - BOOTSEL is the standard circuit:
SW501shorts~{USB_BOOT}to GND,R5051 kohm toQSPI_SS,R50410 kohm pull-up to+3.3V_MCU. - Flash is
U502W25Q128JVZPIQ, 16 MiB, matching the build configuration. - The TMC5130A's clock is an external 16 MHz oscillator
X1201, powered from+3.3V_SW. It is dead untilHV_ENis raised — a stage 2 consideration.
Confirmed On Hardware 2026-09-01
Board serial E4654C159F2C2534, 36 V absent, USB only.
- The
genericboard definition boots this board withboot2_generic_03h_4.mouse_joystick_fastflashis untried on hardware. (2026-10-06: tried, measured, and made the default; the generic one ismouse_joystick_safeflashnow.) - Flash is really 16 MiB. No aliasing at +2, +4 or +8 MiB, and
picotoolverified the whole image byte for byte. - Clocks are nominal:
clk_ref12000 kHz,clk_sys133000 kHz measured,clk_peri/clk_usb/clk_adc48000 kHz. - Crystal
Y501absolute error is below this method's noise floor — −18.7 ppm apparent over 9 s against hosttime.monotonic(), where USB CDC jitter alone is order 110 ppm. Consistent with a healthy +/-30 ppm part. - All four resistor-defined levels are correct:
R6017.87k holds the GPIO bus isolated at reset, the buffer OE pull-ups hold the instrument port silent,R403holds pyControl RX idle high,R205is present with no latched fault. - All 12 spare pads move both ways. No solder defects on any unrouted GPIO.
+3.3V_SWreads 0.034 V withHV_ENlow — the switched domain is genuinely off andR401/R402hold it there.HV_SNS0.016 V. Die 26.2 C.- The 1200-baud touch works, so reflashing needs no button and no physical access to the board.
Still Unconfirmed
- ~~The LA5032 channel mapping.~~ Confirmed: the rev 0/1 map through the
adapter v1.0 carried the stage 1 to 3 captures, and the v1.0 Digital-50 map
through the adapter v2.0 was confirmed straight (
CHnobservesDIGn) on 2026-10-05 (bring-up log, unitE4654C159F274534). env:mouse_joystick_fastflashon hardware. (Done 2026-10-06, see the tick budget item.)- Everything on the far side of
HV_EN: the switched rails, the TMC5130A, the 16 MHz oscillatorX1201, the motor, encoder, and limit switch.
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:
- Travel is ~4000 microsteps, 28 deg at the motor shaft: the limit switch bounds the positive end, a mechanical stop bounds the negative end. Every bring-up distance has to fit inside it.
- The limit switch is active high (
REFLrests low, goes high when hit), throw 256 microsteps, and it is at the positive end of travel. It is wired toREFL, which stops negative motion, soSW_MODE.swap_lris required or the hardware stop guards the wrong direction. - The encoder counts down as
XACTUALcounts up.ENC_CONSTresets to zero soX_ENCdoes not count until it is written, and the negation is −13 + 0.2, not −12 and 0.8, because the fraction is always added. Bench-confirmed: the other encoding gives a ratio of 0.875 instead of 1.000. - The switch's reporting delay is 8.74 ms, measured by taking two closing-edge latches at two approach velocities in one position frame. No instrument needed, and it is what justifies two-stage homing.
- Homing is two-stage: a fast approach latching the closing edge, then a slow creep off the switch latching the release edge, which is the datum. It buys accuracy, not precision -- the datum stops depending on approach velocity (89 usteps of offset at 0.200 rev/s versus 4.5 at the creep) and the primary approach can run four times faster. Repeatability is 2-5 encoder counts in both schemes across four series of five runs, and the rig cannot distinguish them at n = 5. An earlier claim that the second stage halved the scatter came from one lucky series and is retracted: a repeatable 8.74 ms delay is a systematic offset, and systematic offsets do not widen spread.
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.
RAMPMODE= hold does not stop the motor, it freezes the current velocity. TMC51X0'sController::initialize()runs duringsetupSpi()and leavesVelocityPositiveModewithVMAX= 10, so the chip is commanded to turn from the moment the bus closes.XACTUALcrept to 125 microsteps with the driver disabled, and the motor would have started the instant~{TMC_DRV_EN}went low. Bring the ramp to a real standstill — velocity mode,VMAX= 0, wait forVACTUAL= 0, then hold.- A real-unit ramp value can round to zero. One chip unit of acceleration at
16 MHz is 116.4 microsteps/s^2, so asking for the library's default of 10 in
real units gave
D1= 0. The datasheet forbids that in positioning mode and a TMC5130A withD1= 0 does not fault — it crawls at aboutVSTOP. Symptom:VMAX5368 written, 9.5 microsteps/s observed.conversions::nonZeroRampParameterguards it, with a host test. - A limit switch must only block the direction that drives into it. Checking both stop status bits refused the move that gets off the switch, in 3 ms. Check only the bit guarding the direction of travel, which is what the hardware does.
- A diagnostic that can be truncated must say so. The encoder round trip ran
into the switch, lost part of a leg, and reported the resulting 0.786 ratio as
a possible CPR error. Run it away from the switch, and report
[inconclusive]when a leg does not reach its target.
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
- Raw SPI, not the TMC51X0 library. Stage 2 asks one question: does this
board's SPI path reach the driver. Answering it with a 40-bit datagram nobody
has to trust beats answering it with a library that would then be a second
unknown. Bringing TMC51X0 up on this board is its own step and starts from a
bus already known to work. Byte order and register addresses are copied from
TMC51X0'sSpiProtocol.hppandRegisters.hppso the two cannot diverge. - No register writes at all. A TMC5130A read requires a correct address over MOSI, a clock on SCK, framing on CS, and an answer on MISO, so reads alone prove the whole bus. Writes would buy nothing and add a way to leave the chip misconfigured.
- The load-bearing check is
IOINbit 4,DRV_ENN. It reads back the level firmware drives onGP3through the bus switch. Nothing else in the test proves that path. ExpectedIOINon this board is0x11000010with bit 7 (SWCOMP_IN, a comparator output) free. - Ordering is the whole point of the bus switch. Power up:
HV_ENhigh, wait for+3.3V_SW, settle, then close the bus. Power down: open the bus, then dropHV_EN. Get it backwards and MCU pins drive into an unpowered TMC5130A.PowerSequenceenforces this andconnectBus()refuses while the rail is down. PowerSequenceis the only file that raisesHV_EN, and it refuses to start unless+3.3V_SWis measurably dead first.vsenseis reported, never written. Stage 3 owns setting it to 1 and reading it back before it lowers~{TMC_DRV_EN}.FW_TRIG1goes high for the duration of each register-read burst, so the LA5032 can trigger on ch 17 rising and captureDIG2-DIG5for thespidecoder.- Gotcha found:
Arduino.hdefinesbitSet()as a macro, so the helper is namedbitIsSet(). A function calledbitSetwill not compile.
Instrumentation Reach — A Standing Limitation
Worth stating once because it shaped this session. Firmware can see:
+3.3V_SW, divided, onGP26HV_SNSand~{HV_FAULT}, both only whileHV_DIAG_ENis high- the TMC5130A's registers, once the bus switch is closed
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.
- [ ] Enable the RP2040 hardware watchdog. A transient hang then self-recovers
unattended, in the rig, with no button and no cables — the
STATUShang would have auto-recovered. - [ ] Decide the petting point deliberately, and write down why. Petting from the main loop catches a wedged loop, which is the observed failure. Petting from a timer interrupt would have kept petting through that exact hang and bought nothing.
- [ ] Make the reset safe by construction, and verify it on hardware. The
board already argues it is:
HV_ENreleases intoR190410 kΩ pullingENlow at theTPS1HTC100so+36V_SWdrops, and~{TMC_DRV_EN}pulls up to+3.3V_SWthroughR110310 kΩ so the driver disables. Confirm that a watchdog reset mid-move actually lands there rather than leaving the motor energised. - [ ] Guard against a reset loop being worse than a stop. A watchdog reset
does not enter BOOTSEL, so firmware that hangs early gives a reset loop
that cannot be reflashed out of — the watchdog makes that case harder to
escape, and the
RUNswitch plus BOOTSEL is the only exit. Consider a scratch-register reboot counter so repeated watchdog resets land in a safe halt (rail off, driver disabled, link answering) rather than looping. - [ ] Record the watchdog reset in
STATUSso a reset is visible to the host and to the capture instead of looking like a clean boot.WATCHDOG_REASONdistinguishes it from a power-on reset. - [ ] Note the division of labour in
.agents/AGENTS.md: watchdog for "it wedged mid-session",RUN+ BOOTSEL for "I just flashed something broken".
Not A Priority
- OTA, filesystem, or EEPROM use.
board_build.filesystem_sizeis 0. - Anything that energises the 36 V rail, until stage 1 passes.