Bring-Up Log¶
Rev 0 scope: Rev 0 hardware carries no analog angle output, no pyControl TTL
event lines, and no encoder cross-check — the last of those is permanent, not
deferred. Sections below covering the first two record
rev 1 measurements. See docs/rev0-plan.md.
Rev 0 does carry InstrumentPort-40 (J1501), with 11 of 18 digital Strands
live and all three buffer packages fitted — U1601, U1701, U1801. There
are three SN74LVC125A packages on this board, not five. Record only the live
Strands.
No test points exist anywhere on the board. Fields below that name a test pad are marked struck; reach those signals at a component pin or through InstrumentPort-40, and record how you reached them.
Use this file to record measured behavior of real boards. Record numbers with units, not pass/fail marks.
If the rig holds two boards, keep one section set per board serial. Do not merge them — the whole point of recording a serial is that the two boards are independently calibrated and independently suspect.
Boards In This Rig¶
Nine units are now known, across three PCB revisions.
v0.1→v0.2 is layout-only — same schematic, byte-identical pad-to-net map — so
measurements transfer between those tags but not between the units. Keep them
apart; that is what the serials are for.
Board 4 is a v0.3 unit and is a different board. v0.3 adds the analog
angle output, so it is MJ_BOARD_REV=1 in firmware, its designators have
rotated wholesale, and nothing measured on boards 1-3 past HV_EN should be
assumed to hold on it. Record which revision a serial is, not just the serial.
| Unit | RP2040 flash unique ID | State |
|---|---|---|
| Board 1 | E4654C159F2C2534 |
Stage 1 + stage 2 passed. R203 DEPOPULATED 2026-09-02 — LATCH released, so a plain HV_EN assertion works on this unit and it no longer represents an as-built board. Not attached to the bench on 2026-09-03 |
| Board 2 | E4654C159F4D1334 |
R203 intact, i.e. as-built. Stages 2 and 3 passed 2026-09-02 — motor turns, encoder tracks, homing repeats to 4 encoder counts. Stage 1 has never been run on it. See Board 2 |
| Board 3 | E4654C159F591D34 |
Added to this log 2026-09-03. R203 intact, i.e. as-built. Drives the LEFT joystick (Breakout 1.2 port 1). First stage-4 result recorded 2026-09-03 — see Board 3, stage 4. No stage 1, 2 or 3 measurements exist for this unit; treat every motion number in this file as belonging to another board until it is measured here |
| Board 4 | E4654C159F171E34 |
First v0.3 unit, added 2026-09-09. LATCH strap intact — on this revision that is R1903, not R203. Drives the RIGHT joystick. The angle output is characterised as of 2026-09-10 — unity gain to 0.03%, 4.5 mV floor, VREFIO 2.500 V, no motor coupling, and a live 1 kHz encoder-to-analog loop measured at exactly 1000.0 Hz over 835 000 updates. Stages 1, 2 and 3 all passed 2026-09-09 — stage 1 on USB alone (24 checks, 0 failed), then stage 2 and stage 3 with everything connected: rail up in 132 ms, TMC5130A alive, encoder follow ratio 1.0000, homing repeatable to 2.0 encoder counts. See stage 1, stage 2, stage 3. Stage 4 not run on this unit |
| Board 5 | E465C0164B30612D |
Second v0.3 unit, MJ_BOARD_REV=1, named board 5 in the firmware handoff of 2026-10-02. Carries a mouse_joystick_qp_rev1_usb build from that morning; stages 1–3 have never run on it. Not on the bench 2026-10-05 |
| Board 6 (v1.0 unit A) | E4654C159F274534 |
First v1.0 unit, MJ_BOARD_REV=2. Arrived with a wrong green, banded part at every C2858031 position; dead until C1301 was removed on 2026-10-05, then C1208 refitted with a genuine 4.7 µF and C1501 removed. Stages 1, 2, A3, A6 and the QP protocol session passed 2026-10-05; stages A4 (brake sink) and A5 (torque bridge) passed 2026-10-06 — brake 0–48.80 mA linear, full-scale step overshoots to 62 mA with no ~{BRAKE_FAULT}; ADS8689 alive, 95–112 µV rms, shunt-cal switch inert. Drives the replacement joystick unit. See v1.0 unit A and A4/A5 |
| Board 7 (v1.0 unit B) | E4654C159F404534 |
Second v1.0 unit, MJ_BOARD_REV=2. Arrived with the same wrong part at C1301, C1208, C1501; reworked by Peter 2026-10-07 (C1208 refitted, C1301 and C1501 removed). Stage 1 passed 2026-10-07, 09:08: 20 checks, 0 failed. For the LEFT joystick of Rig 1 (Breakout port 1). See v1.0 unit B |
| Board 8 (v1.0 unit C) | E4654C159F504534 |
Third v1.0 unit, MJ_BOARD_REV=2, on Peter's desk rig since 2026-10-12: Breakout port 1, the LEFT mechanism. Stage 1 20 checks 0 failed, delivery build, one trial through pyControl and the pull sign (−1, torque negative on a pull) the same day. See "Desk rig" below. Reflashed 2026-10-14 from Peter's laptop to v1.0.0-2-g109169f (the code of firmware tag v1.0.0; the 1200 baud touch honoured, picotool verified, id over the RJ-45 confirms; mouse-joystick-firm/evidence/2026-10-14-0910-deskrig-reflash/). |
| Board 9 (v1.0 unit D) | E4654C159F234234 |
Fourth v1.0 unit, MJ_BOARD_REV=2, desk rig, Breakout port 4, the RIGHT mechanism. Stage 1 20 checks 0 failed, delivery build, one trial through pyControl and the pull sign (−1, torque positive on a pull) 2026-10-12. Reflashed 2026-10-14 from Peter's laptop to v1.0.0-2-g109169f (the code of firmware tag v1.0.0; the 1200 baud touch honoured, picotool verified, id over the RJ-45 confirms; mouse-joystick-firm/evidence/2026-10-14-0910-deskrig-reflash/). |
Which board drives which joystick — established 2026-09-03.
| Joystick | Breakout 1.2 port | Board serial |
|---|---|---|
| Left | 1 (UART4) |
E4654C159F591D34 |
| Right | — | E4654C159F171E34 (board 4, v0.3) — confirmed 2026-09-09 |
| ~~Right~~ | ~~4 (USART3)~~ |
~~E4654C159F4D1334~~ — displaced by board 4 |
Updated 2026-09-09: board 4 now drives the RIGHT joystick, so every stage 3
number recorded for board 4 belongs to the right mechanism. Board 2
(E4654C159F4D1334) has been displaced from that position; its own stage 3
numbers were taken on the same mechanism, which makes the two directly
comparable — board 4 homes to 2.0 encoder counts where board 2 homed to 4.
The breakout port board 4 sits on has not been recorded. Re-establish this
with the protocol ID reply after any recabling rather than trusting the
table.
This had been a standing gap: the mirrored harnesses make the boards
interchangeable, so nothing in the mechanism records which one is where, and a
per-board calibration is meaningless without it. It was closed by the protocol
ID reply — the board announced its own serial on the port it was wired to,
which is exactly what ID carries a serial for. Re-establish it the same way
after any recabling rather than trusting this table.
On board 3. It was found attached to the bench on 2026-09-03 and its serial
appears nowhere in any repository before that date. It is not board 1 with a
mis-transcribed id: board 1 has R203 depopulated and board 3 has it intact,
which is a physical difference rather than a bookkeeping one. Whether it is a
newly built unit or one that predates this log is not recorded; what matters
here is that it carries no measurement history, and that the two boards on
the bench as of 2026-09-03 are boards 2 and 3.
A consequence worth stating because it has already caused confusion: the console
harness examples in this file and in mouse-joystick-firm/docs/ that name
/dev/ttyACM0 were written when that was board 2. With boards 2 and 3 attached,
/dev/ttyACM0 is board 3. Address boards by /dev/serial/by-id/, which
carries the serial, and never by ttyACM number.
Everything from here to Board 2 is board 1 unless a line says otherwise.
v1.0 Boards — arriving 2026-10-05 (nothing measured yet)¶
Two v1.0 units ordered from the fabrication package committed in 646b8f4
(2026-09-21); the board file has been unchanged since d5ae4b0. No v1.0
tag exists at the time of writing; tag the fabricated commit before recording
the first measurement so this section can cite it. Add one row per unit to the
table above as each serial is read from RP2040SelfTest, and keep one section
set per serial below this one, as for boards 1–4.
v1.0 is MJ_BOARD_REV=2 in firmware (env:mouse_joystick_rev2,
mouse_joystick_qp_rev2*), written 2026-10-04 from the pad-to-net map of
footprint U601 — the RP2040 moved into the combined MCU block
(rp2040_with_gpio_isolation_sn74cb3q3245) and U501 is now the 36 V
switch. Every designator below is v1.0's, read off the .kicad_pcb; most of
them collide with v0.3 names that meant something else.
First power-up of the first v1.0 unit — 2026-10-05, 13:30: not alive on USB, instrument port silent¶
Bench: joystick, motor, encoder, limit switch, brake and torque sensor cabled,
USB connected, 36 V connected, Digital-50 to the LA5032 through the v2.0
adapter and a Molex 15020 FFC. Serial not yet known: the board did not
enumerate at all — no 2e8a:0003 RP2040 bootloader (a blank flash would
show one) and no CDC device, while the LA5032 on the same host enumerated
normally. A 5 s capture of all 32 channels at 100 kSa/s
(mouse-joystick-firm/evidence/2026-10-05-1142-board4-regression/v1/v1_quiet_all32.sr) read
every channel low with no edges. With +3.3V_MCU up the always-on
buffers would hold DIG3 (R401), DIG26 (R507) and DIG27 (R607) high
in either cable orientation (CH3/26/27 straight, CH28/5/4 reversed); none was.
Two independent symptoms with one common cause: the MCU domain is not
powered, or the FFC is not making contact and USB fails separately. The
netlist is not the reason: J201 has R201/R202 5.1 kΩ on CC1/CC2, D201
Schottky feeds +5V_MCU, U701 TPS73733 makes +3.3V_MCU. No test points
exist; probe C201 100 nF (VBUS), C701 1 µF (+5V_MCU) and the U701
output. Nothing was energised by firmware: HV_EN floats low with the MCU
unpowered, so the switched domain stayed off (PG_5V/PG_12V low).
13:36, USB cable swapped: unchanged — still no 2e8a device, all 32
channels still low (v1/v1_quiet2_all32.sr). Meter readings owed at C201
(VBUS), C701 (+5V_MCU) and C1301 (+3.3V_MCU).
13:42, second v1.0 unit swapped in: identical — no 2e8a device, all 32
channels low (v1/v1b_quiet_all32.sr). Two units failing the same way points
at the design, the assembly or the bench feed rather than one bad board.
Prime suspect, 13:50: the v1.0 fabrication package's placement rotations.
The v0.3 package carried the KiCad rotation unchanged for every footprint
and assembled correctly. The v1.0 package applied a kicad-libs rotation table
that adds +270° to footprints the two boards share and that were right at
v0.3 without any offset: the flash U602 (WSON-8), the bus switches
U603–U606 (RGY VQFN-20), the USB ESD array U201 (SOT-666), the 36 V
switch U501 (HTSSOP-14), plus the new U1301–U1304, U801/U1801,
U2002. At most one of the two conventions is right, and v0.3 works. A
SOT-666 USBLC6 placed off its pads shorts VBUS to GND, which removes
the host's 5 V and matches both symptoms on both units. Check the pin-1
marks of U201, U602 and U501 against the v0.3 board before anything
else, and keep the 36 V supply off until U501's orientation is confirmed.
U701, U601, D201, J201, SW601/SW602 and Y601 carry no offset,
and the USB-C receptacle, the tactile switch and the crystal footprints have the
same rotation on both orders, so they are not suspects. The table's rows are
"verified" in the sense that the corrected v1.0 CPL looked right in the JLCPCB
placement viewer before the order; the v0.3 CPL copied KiCad verbatim and those
boards nevertheless came out right, so one of the two orders has U201
(USBLC6-2P6, pin 5 = VBUS, pin 2 = GND) and the other rotated parts 270° off.
A rotated U201 or a rotated +3.3V_MCU load (U1301–U1304, U603–U606)
is the one fault that explains both symptoms on both units without a
second one: the host's 5 V is shorted, the always-on domain never rises, and
the port buffers stay dark. Decisive checks, in order: pin-1 mark of U201,
U602 and U1301 against the same parts on a v0.3 board; VBUS at C201 with
the board plugged in (≈0 V means the host port has folded back); sudo dmesg
for an over-current message.
14:05, by eye and thermal camera (Peter): no chip is rotated, and U701,
the always-on +5V_MCU → +3.3V_MCU TPS73733, runs very hot with no visible
pin short. That retires the placement-rotation suspect and says what the
fault is: the always-on 3.3 V rail is shorted or grossly overloaded, so the
regulator sits in current limit (up to ~5 W in a 3×3 mm VSON) and the rail
never rises — the RP2040's ROM never runs USB and the port buffers stay dark,
one cause for both symptoms on both units. U1001, the switched-domain twin,
cannot be the hot one with HV_EN low. Nothing in the netlist ties
+3.3V_MCU to GND except capacitors; the DRC saw no +3.3V_MCU-to-GND
proximity (its seven +3.3V_MCU clearance warnings are against signal tracks:
the buffer ~{OE} nets at U1302/U1304 pad 20, which tie to GND only
through 10 kΩ, and GPIO tracks at C607/C614). Owed measurements: +3.3V_MCU
at C1301 and +5V_MCU at C701 powered; resistance +3.3V_MCU to GND
unpowered (a healthy board reads kΩ); a slow thermal pass over the whole board
for a second hot spot (a cracked 0402 on the rail or a bridged QFN pad heats
at 1 A, a copper short does not). The BOM is clean for every part on
VBUS, +5V_MCU and +3.3V_MCU (right MLCCs, right resistor values, right
ICs), the DRC's closest copper on those rails is 0.100 mm to signal tracks and
none to GND, and U701 is the same LCSC part (C523768) that works on v0.3.
Two regulators share the description "5 V to 3.3 V": U701 (always-on, at
board (108.9, 97) mm beside the RP2040) and U1001 (switched domain, at
(123.4, 96.8) mm); only U701 can be live with HV_EN low. A healthy
always-on domain draws ~0.2 A, which already makes U701 the warmest part on
the board, so heat alone does not prove an overload: the voltage does.
Side note, not the cause: v1.0 has no pull-down on HV_EN (v0.3 had
R1904 10 kΩ); U501's EN relies on the RP2040 pad's default pull-down and
on the part's internal pull-down while the MCU is unpowered.
14:30, prime suspect found by eye (Peter): C1301 is not a ceramic
capacitor. With only USB connected and U701 hot, the part at C1301
(+3.3V_MCU to GND, board (151.4, 121) mm beside the Digital-50 buffers) has
a green body with a black band at the +3.3V_MCU end. The BOM position is
LCSC C2858031, Murata GRM155R61E475ME15D, 4.7 µF 25 V X5R 0402: a plain
grey ceramic with unmarked metal ends and no polarity. A banded green 0402 is
a polarised part, so the reel at that position was substituted or mis-picked.
The same reel feeds C1208 (TMC5130A 5VOUT to GND) and C1501
(+5V_SW to GND), both in the switched domain and not yet energised; on
v0.3 the same LCSC number sits at C1208 and C801 as the genuine Murata
part. A diode-like part across the always-on rail clamps it below the
RP2040's USB minimum, holds U701 in current limit (hot) and keeps the port
buffers dark: one cause, both units, every symptom. Plan: measure across
C1301 as mounted, remove it (the TPS737 is stable without it; C702,
C603 and fifteen 100 nF remain), retry USB, test the removed part in diode
mode, then inspect and replace C1208 and C1501 before the first HV_EN.
14:40, confirmed by eye (Peter): C1208 and C1501 carry the same green,
black-banded part on v1.0, and the C1208 on a v0.3 board, same LCSC
number, is a normal unmarked ceramic. So the v1.0 assembly run placed a
wrong reel at every C2858031 position, on both units. Rework for both
boards: remove all three; C1301 may stay empty for bring-up; C1208
must be refitted with a real 2.2–4.7 µF ceramic because it is the TMC5130A's
5VOUT regulator capacitor, which the driver needs for stability; C1501
(+5V_SW local bulk) may stay empty or take any 1–10 µF ≥ 10 V ceramic.
This is a quality claim against the assembler: the BOM line, the three
positions and a photo of a green part beside the v0.3 ceramic are the
evidence.
v1.0 unit A (board 6) — E4654C159F274534 — Stage 1 passed 2026-10-05, 15:04, after removing C1301¶
The dead-board fault was the wrong part at C1301. With it removed and
USB alone connected, the board enumerated as the RP2040 ROM bootloader
(2e8a:0003, blank flash), took RP2040SelfTest for mouse_joystick_rev2
by UF2 copy, and came back as 2e8a:f00a with the serial above. 20
checks, 0 failed — the count derived on 2026-10-04 is now observed.
Console log: mouse-joystick-firm/evidence/2026-10-05-1142-board4-regression/v1/s1_selftest_rev2_boardA.log.
C1208 and C1501 still carry the wrong part on this unit; the 36 V supply
stays off until they are replaced.
| Check | Result |
|---|---|
| reset reason | software (3) (the UF2 reboot) |
clk_sys against clk_ref |
133000 kHz; clk_ref 12000, clk_peri/clk_usb/clk_adc 48000 kHz |
| flash | unique id read, 16 MiB declared, no aliasing at +2/+4/+8 MiB |
| heap | 252224 total, 16 KiB allocation and pattern walk ok |
GP9 ~{BUS_SW_EN} |
high — R607 7.87 kΩ holds the bus isolated |
GP24 HV_LATCH |
low (informational: switch unpowered; low = auto-retry) |
GP16/GP17 DAC_SDA/DAC_SCL |
high — R403/R402 4.7 kΩ |
GP1 PYC_UART_RX |
high — R401 10 kΩ |
GP23 ~{HV_FAULT} |
high — R507 10 kΩ, no latched fault |
spare pads GP20, GP22 |
both move both ways |
GP26 SW_RAIL_SENSE_DIV_2 |
11 counts = 0.0089 V divided = 0.0177 V on +3.3V_SW, off |
GP28 HV_SNS |
9–10 counts = 0.0073–0.0081 V across R509 249 Ω |
GP27 BRAKE_ISENSE |
7–11 counts = 0.0056–0.0089 V (0–1.006 V is 0–48.78 mA with the rail up) |
GP29 ANGLE_OUT_MON |
12–14 counts = 0.0097–0.0113 V (0–1.650 V with the rail up) |
| die temperature | 30.9 °C at boot, 32.3 °C a minute later |
Digital-50 through the v2.0 adapter, 15:10 — straight mapping, markers and
beacon good. With the LA5032 back: R401/R507/R607 put DIG3/DIG26/
DIG27 on CH3/CH26/CH27 (a reversing cable would have given CH28/CH5/CH4),
so CHn observes DIGn; the four GND-tied strands and every switched-domain
tap read low as their biases predict with the rail off. Boot signature on a
commanded reboot: CH1 (FW_TRIG1) 10.135, 10.005, 10.001 ms; CH0 (FW_TRIG0)
8 × 1.001–1.008 ms, 1.18 s after the last heartbeat edge; heartbeat half-period
500.25 ms. The I beacon's three frames (50.0 ms start, 64 clocks) each decode
to E4654C159F274534, the flash id above. Captures
mouse-joystick-firm/evidence/2026-10-05-1142-board4-regression/v1/v1a_boot_all32.sr (all 32
channels, 100 kSa/s) and v1a_signature.sr (1 MSa/s). Owned by the adapter's
log; recorded there too. Not yet done on this unit: the k crystal check
against the host clock, the cold power-on reset reason.
Firmware finding from the same capture: ~{BUS_SW_EN} (CH27) drops low for
34 µs at every firmware boot, 182 µs before the first signature pulse.
SafeState::driveOutput() calls pinMode(OUTPUT) before digitalWrite(HIGH),
and the RP2040's output register is 0 after reset, so the pad drives low
until the second call: the bus switch closes for 34 µs at each boot. The MCU
pins are still inputs at that moment, so nothing drives into the switched
domain and today it is harmless; it is still the one thing the firmware
contract says must never happen. Fix is to set the level before the
direction. Not applied.
v1.0 unit A — Stage 2 passed 2026-10-05, 15:34, direct LATCH-GPIO power-up, after a firmware fix¶
C1208 refitted with a genuine 4.7 µF and C1501 removed (Peter), 36 V on,
everything cabled but the RJ-45. TMC5130Communication on
mouse_joystick_rev2, Digital-50 taps CH1/8–11/14/24/26/27/30/31 at 10 MSa/s
(mouse-joystick-firm/evidence/2026-10-05-1142-board4-regression/v1/v1a_s2_tmc.sr, v1a_s2b_tmc.sr).
First attempt, firmware ec3b36d: the rail came up and the firmware then
armed latch-off too early. The tap timeline of the failed s: HV_EN high
for 116 ms; PG_5V up at +6.6 ms, PG_12V at +10.9 ms — the bucks run
from a few volts of +36V_SW, so +3.3V_SW read live while the switch was
still charging 208 µF in current limit; powerUp() saw the rail up, waited
10 ms and drove HV_LATCH high; PG_12V fell at +36.7 ms and PG_5V at
+59.6 ms (the switch tripped on the remaining inrush with latch-off armed and
stayed off); the fault check at +114 ms read ~{HV_FAULT} low. The m
measurement a few seconds later, same code, survived by timing luck: rail
held 3.15 s, no fault, 51.3 mA of 36 V input current with the bus open.
The LATCH probe's first half (LATCH driven high on purpose) failed as
designed: came up, held 13 ms, peak 3.293 V, fault latched, started cold.
p then also collapsed because powerDown() left LATCH high after the probe;
its table shows HV_SNS at the ISNSFLT level (1.65 V) throughout the charge.
Fix (firmware, PowerSequence): with LATCH on a GPIO, keep it low until
HV_SNS has read below the fault level for 20 ms running (the bulk is charged
when the switch leaves current limit), then arm latch-off; give up after 1 s
and report a short; powerDown() returns LATCH to low; the report carries
charge_ms. Two passes after the fix, both clean:
| Quantity | run 1 | run 2 |
|---|---|---|
| method | LATCH held low, direct HV_EN |
same |
+3.3V_SW up |
4 ms, peak 3.2927 V | 3 ms, peak 3.2927 V |
| bulk charged, latch-off armed | 34 ms | 33 ms |
PG_5V / PG_12V after HV_EN (tap) |
+6.4 / +8.4 ms, never fell | +6.0 / +8.3 ms, never fell |
~{HV_FAULT} (tap, DIAG_EN high) |
one low edge at +4.2 ms during the inrush retry, then high | one at +3.1 ms |
HV_SNS right after power-up / later |
0.0395 V = 126.9 mA (charging tail) / 0.0193 V floor | 0.0193 V floor |
bus closed (~{BUS_SW_EN} low, tap) |
+140 ms | +139 ms |
IOIN |
0x1100001C: VERSION 0x11, DRV_ENN 1, SD_MODE 0, REFL_STEP 0, ENCA 1, ENCB 1 |
same |
GSTAT / CHOPCONF / DRV_STATUS / XACTUAL / X_ENC |
0x1 then 0 / 0 (vsense 0 at reset) / 0x80000000 / 0 / 0 |
same |
| register checks | 5, 0 failed | 5, 0 failed; r re-read 5, 0 failed |
m from a cold rail |
43.6 mA idle, rail live |
So on v1.0 the staircase is retired: a plain HV_EN with LATCH low gets
through the inrush by the part's own retry, and latch-off is armed 33 ms
later. ~{BUS_SW_EN} also shows the 34 µs boot glitch at every
disconnectBus() call (the driveOutput() order, above).
v1.0 unit A — Stage A3, the angle DAC over I2C, 2026-10-05, 15:38¶
AngleOutput on mouse_joystick_rev2, I2C taps DIG22/DIG23 on CH22/CH23 at
10 MSa/s (mouse-joystick-firm/evidence/2026-10-05-1142-board4-regression/v1/v1a_a3_dac.sr).
Rail up in 3 ms (direct method), bus closed, I2C0 on GP17/GP16 to 0x48.
Every transaction ACKed on the tap: soft reset (0x05 ← 0x000A), CONFIG
(0x03 ← 0x0000), GAIN (0x04 ← 0x0101), SYNC (0x02 ← 0x0000), then
GAIN read back 0x0101, matched — the first time this board family could
verify its DAC gain — and DEVID 0x115, REF-ALARM clear, twice. Codes
0x8000, 0xFFFF and 0 were commanded (0.825, 1.650, 0 V expected at the BNC
after the v1.0 ÷2). 13 writes and 5 reads to 0x48 in the capture, no NACK
except the terminal one of each read.
15:49, measured at the BNC node through ANGLE_OUT_MON (GP29, 16-sample
average), after adding the readback to AngleOutput and setting I2C to
400 kHz (v1/a3b_angleoutput_rev2_boardA.log). The RP2040 ADC against its
3.3 V supply is the reference here, so ±1 % is the instrument, not the board;
a meter on the BNC is still owed for the fit.
| DAC code | expected at the BNC | ANGLE_OUT_MON |
difference |
|---|---|---|---|
| 0 | 0 V (floor) | 0.0121–0.0129 V (15.3–15.7 counts) | the v1.0 output floor, ~12 mV after the ÷2 (v0.3 measured 4.5 mV before it) |
| 497 (25 mV at the DAC) | 0.0125 V | 0.0226 V | floor plus the step |
| 0x8000 | 0.8250 V | 0.8236 V (1021.9 counts) | −1.4 mV, −0.17 % |
| 0xFFFF | 1.6500 V | 1.6415 V (2036.8 counts) | −8.5 mV, −0.5 % |
DEVID 0x115 and REF-ALARM clear again after the sweep. The 12 mV floor
means the out-of-band "unhomed" level needs the same thought as on v0.3
(code 497 reads 22.6 mV here, only 10 mV above the floor).
v1.0 unit A — Stage A6 (stage 3 on v1.0) passed 2026-10-05, 15:41, replacement joystick unit¶
MotionAndHoming on mouse_joystick_rev2, taps CH1/14/15/24/26/28/29 at
1 MSa/s (mouse-joystick-firm/evidence/2026-10-05-1142-board4-regression/v1/v1a_a6_motion.sr).
b: rail up 3 ms (direct method), bulk charged 34 ms, TMC5130A 0x11, uv_cp
0, encoder scaled, DAC over I2C at 0x48 with GAIN 0x101. c: vsense read
back 1, IRUN 8 = 0.211 A RMS. e: enabled.
| Measurement | Result |
|---|---|
W switch found from where it rested |
engaged at 6271 µsteps (44.09°), released 6015, throw 256, active high |
g out −1280 / back +1280 |
encoder −1203 / +1100 (0.9398 / 0.8594), residual −103 µsteps (−8.0 counts) |
h approach 0.200 rev/s, creep 0.010 |
stopped by the switch (328 → 397, 1.21), closing 6343, release edge 5978, closing-to-release 365 µsteps, 1454 ms |
H 5 runs |
5 of 5, spread 25 µsteps = 2.0 counts (0.176°) |
| reversal dead band (IRUN 8) | continuing jogs 1.0102 / 1.0000 / 1.0195; every reversal 1191 of 1280 (0.9305), 89 µsteps = 7 counts; g residual −102 µsteps |
second h from 4667 µsteps away |
approach follow 0.9927, closing-to-release 702 µsteps, 2784 ms |
L live encoder → I2C DAC |
1000.1 Hz achieved, worst loop 1082 µs of 1000 at the 100 kHz I2C default (v0.3 over SPI: 386–527 µs), code 7190 static; 732 µs worst after setting I2C to 400 kHz (15:50, 10019 updates, 1000.1 Hz, v1/a6b_liveloop_rev2_boardA.log) |
p after |
DRV_STATUS 0x81020034, ot/otpw 0 |
The reversal dead band is the same 89–90 µsteps this joystick unit measured on
board 4 earlier today, so it travels with the mechanism and motor, not with the
board or the driver. Homing repeatability is unchanged at 2.0 counts. The
live loop's worst iteration exceeded the millisecond for the first time: the
bench consoles left Wire at its 100 kHz default (the QP build already ran
400 kHz), where one three-byte DAC write costs ~400 µs. At 400 kHz the worst
iteration is 732 µs, still above v0.3's SPI figure; the remaining cost is the
I2C transaction itself (~90 µs) plus the same polling as before.
v1.0 unit A — QP protocol session on mouse_joystick_qp_rev2_usb, 2026-10-05, 15:51–16:05¶
Phases a–e of the board-4 session on the v1.0 build (protocol over USB CDC,
QS trace on FW_TRIG1 = CH1, TICK level on FW_TRIG0 = CH0, tick probe on
the UART0 TX pad = CH2, TMC SPI on CH8–CH11), no stops, no meter, no hand.
Files: mouse-joystick-firm/evidence/2026-10-05-1142-board4-regression/v1/qp/. ID: hw=rev2,
caps motion+home+instr+hv+watch+fetch+stream+cfg+angout. No ASSERT in any
trace; trace buffer never full; line pool min free 3 of 8 (rev 1 had 1).
| Phase | Result |
|---|---|
| b | hv on acked (the direct power-up inside TmcMotion; DAC_STATE 1 then 2, 5.2 ms apart), HOMED, angscale, angout on, TARGET at −7.566 / −5.766 / −3.15 for −7.59 / −5.06 / −2.53, park −0.541, angout off, disarm |
| c | 20 of 20 moves, ack→TARGET p50 435.1 ms; brake 0 → RELEASED at −0.816°; watch with no hand: first motion at 713 ms (drift to −0.9°), TRIAL outcome=NONE on leaving RELEASED; FETCH 500 samples lost=0 in 153 ms; stream on 50 100 lines in 2 s; host-loss FAULT after 105.7 ms; clrfault |
| d | 300 of 300 moves, ack→TARGET p50 433.7 / max 473.7 ms, no naks |
| e | hv off → hv=0, DAC_STATE 0 |
Tick cost on v1.0 (SAMPLE_STAT, p50 last / p50 worst-per-second, µs):
holding with the angle output on 305 / 357 (rev 1 on v0.3: 158 / 379 — the
I2C DAC write at 400 kHz replaces the SPI one); idle with it off 15 / 52;
moving back to back 116 / 1081 (the command tick, one per moveto, as on
v0.3). TICK level on CH0 p50 106–117 µs, max 1.46–1.75 ms; tick period
1000.0 µs, none missed; 92–96 back-to-back levels per 90 s. TMC SPI 3.33 MHz,
2 datagrams per tick p50, 10 p99. Ping round trip over USB CDC p50 3.1–3.4 ms.
Rev 2 is now exercised end to end on hardware: stages 1, 2, A3, A6 and the QP protocol build, all on unit A; A4 and A5 followed on 2026-10-06 (next section). Still owed on v1.0: a meter fit of the angle output, the UART0 path with a pyboard (Gate E), the brake and torque paths in the protocol build, and unit B.
v1.0 unit A — Stages A4 and A5, brake sink and torque bridge, 2026-10-06, 08:48–08:55¶
Bench as left on 2026-10-05: replacement joystick, motor, encoder, limit
switch, brake and torque sensor cabled, 36 V supply on, RJ-45 unplugged,
Digital-50 to the LA5032 through the v2.0 adapter (straight, CHn = DIGn).
Firmware: the new examples/BrakeAndTorque on mouse_joystick_rev2
(mouse-joystick-firm HEAD 80fdf3b plus that day's uncommitted ADS8689
driver, console and SafeState fix). Driven non-interactively by
mouse-joystick-firm/evidence/bench-scripts/unitA_brake_torque.sh and
unitA_brake_steps.sh; console transcripts and captures in
mouse-joystick-firm/evidence/2026-10-06-0848-unitA-brake-torque/ and
…-0853-unitA-brake-steps/. The motor driver was never enabled. No meter:
every brake number is the RP2040's ADC against its 3.3 V supply (0.806 mV =
39 µA of coil current per count through the 20.625 V/A monitor) and every
torque number the ADS8689's own 4.096 V reference (78.125 µV per code on the
0–5.12 V range). The design values compared against are in
mouse-joystick-firm/src/Conversions.hpp (from kicad-libs
docs/brake-current-sink-design.md and docs/torque-sensor-interface-design.md).
Bring-up (b). Rail up in 4 ms, peak 3.2895 V, bulk charged and latch-off
armed at 36 ms; bus closed. I2C0 at 400 kHz: 0x48 ACK, 0x49 ACK (rig plan
A1b). Brake DAC U1801 at 0x49: soft reset, CONFIG 0, GAIN 0x0101
written and read back 0x0101, SYNC 0, DEVID 0x115 (DAC80501Z,
zero-scale reset), REF-ALARM clear — decoded byte for byte from CH22/CH23
(captures/p1_bringup.sr). BRAKE_ISENSE at code 0: 0.0113 V
(14.0–14.3 counts), the monitor amplifier's floor (design ~12.5 mV after the
÷2), reported as 0.55 mA-equivalent; ~{BRAKE_FAULT} high. SPI1 at 2 MHz,
mode 0, CONVST/CS on GP15: ADS8689 U2002 SDI_CTL 0, SDO_CTL 0,
DATAOUT_CTL 0, RANGE_SEL 0x000B written and read back 0x000B,
loopback DATA_VAL all-ones 0xFFFF, all-zeros 0x0000,
DEVICE_ID[15:0] 0x0010 (datasheet: reserved, reads 0), RST_PWRCTL 0 —
decoded from CH16–CH19 as 32-bit words (D0080000, D00C0000, D0100000,
D014000B, C8140000 → 000B0000, D0100005 → FFFF0000, …). The
conversion results shifted out during the configuration writes, before
RANGE_SEL took effect, were 0x909E–0x90B2 (37022–37042): in the reset
±12.288 V range (375 µV per code, 0 V at 0x8000) that is 1.595–1.603 V, the
same node the 0–5.12 V range then read at 1.5956 V, so the two ranges agree and
the datasheet's straight-binary coding of the bipolar ranges is confirmed on
the wire.
A4, brake sweep (s: every point approached from code 0, 100 ms settle,
64-sample average; captures/p2_brake.sr at 1 MSa/s):
| % | code | commanded | BRAKE_ISENSE |
measured | error, % of FS |
|---|---|---|---|---|---|
| 0 | 0 | 0.000 mA | 0.0113 V | 0.547 mA | +1.12 (the floor) |
| 10 | 6553 | 4.878 mA | 0.1096 V | 5.314 mA | +0.89 |
| 20 | 13107 | 9.756 mA | 0.2087 V | 10.120 mA | +0.75 |
| 30 | 19660 | 14.634 mA | 0.3103 V | 15.043 mA | +0.84 |
| 40 | 26214 | 19.512 mA | 0.4102 V | 19.888 mA | +0.77 |
| 50 | 32767 | 24.390 mA | 0.5029 V | 24.381 mA | −0.02 |
| 60 | 39321 | 29.268 mA | 0.6036 V | 29.265 mA | −0.01 |
| 70 | 45874 | 34.145 mA | 0.7035 V | 34.110 mA | −0.07 |
| 80 | 52428 | 39.024 mA | 0.8042 V | 38.994 mA | −0.06 |
| 90 | 58981 | 43.901 mA | 0.9050 V | 43.878 mA | −0.05 |
| 100 | 65535 | 48.780 mA | 1.0065 V | 48.801 mA | +0.04 |
Full scale reads 1.0065 V against the 1.006 V design: the pass device
reaches 48.78 mA and the 12 V gate drive is sufficient (firmware contract
item 7). Above 50 % the chain is within ±0.07 % of full scale; below it the
monitor floor adds its ~0.4–0.5 mA-equivalent to every reading, which is the
floor and not coil current (the RP2040 ADC's own ~1 % is on top of all of it).
~{BRAKE_FAULT} was never low: in the console and on CH21 for the whole
sweep and every step below.
Brake step response (S: 0 → code → 0, BRAKE_ISENSE at 5 kHz for 25 ms
each way; FW_TRIG1 on CH1 marks each step, 125.5 ms wide;
…-0853-unitA-brake-steps/consoles/p1_steps.log):
| target | rise t10 / t50 / t90 | peak | overshoot | fall t10 / t50 / t90 |
|---|---|---|---|---|
| 48.78 mA (100 %) | 1.4 / 3.0 / 6.0 ms (one of three runs 4.0 / 5.6 / 8.6 — a 3 ms late start, see below) | 61.5–62.1 mA at 11.8–14.4 ms | +26–27 % | 0.4 / 1.2 / 3.0 ms |
| 43.90 mA (90 %) | 1.4 / 2.8 / 5.2 ms | 55.2 mA at 9.0 ms | +26 % | 0.4 / 1.2 / 3.2 ms |
| 36.59 mA (75 %) | 1.6 / 2.8 / 4.4 ms | 42.1 mA at 6.0 ms | +15 % | 0.4 / 1.2 / 3.4 ms |
| 29.27 mA (60 %) | 1.8 / 2.8 / 4.2 ms | 29.7 mA | +1 % | 0.4 / 1.2 / 3.6 ms |
| 24.39 mA (50 %) | 2.0 / 3.0 / 4.6 ms | 24.6 mA | +1 % | 0.6 / 1.4 / 3.8 ms |
The first millisecond of every rise slews at 8–14 mA/ms regardless of the
target: the coil's inductance against the pass device's drain headroom sets
it (at full scale 36 V − 22.8 V ≈ 13 V across the coil's L di/dt, which
puts the B5Z's unspecified inductance near 1–1.5 H). The command filter
reaches 90 % in about 2 ms, so above ~60 % the current cannot follow it, the
loop integrator winds up, and the current overshoots — by 27 % at full
scale, for about 5 ms above the setpoint, settling by 16–17 ms. The
full-scale peak, 62 mA = 1.28 V at BRAKE_ISENSE, is above the 56.4 mA
ceiling, yet ~{BRAKE_FAULT} stayed high at 5 kHz in the console and on
CH21 at 1 MSa/s. One of three things is true: the ceiling amplifier's
integrator is slower than the excursion; it limits but LIMIT_OUT does not
fall below the SN74LVC2G17's threshold while the gate sits near the FET's
threshold; or the monitor path overshoots and the coil current does not. A
scope on V_SENSE (top of R1816) and on LIMIT_OUT during a full-scale
step settles it — owed, needs a hand and a scope. The firmware
consequence is independent of which: BRAKE must ramp the setpoint over at
least ~6 ms (or stage it below 60 % first) rather than step it, or the brake
applies up to 127 % of the commanded torque for ~5 ms at every release.
Falls are clean: 48.8 → 1.4 mA in 8 ms, then a 15 ms tail from 1.4 to 0.6 mA
that is the monitor floor settling, not current. The one late-starting
full-scale rise (3 ms at 0.5 mA before moving) was the first step after b
in that session and did not recur; unexplained, noted.
A5, torque bridge. At rest, the joystick where the 2026-10-05 session left
it: 20424–20448 codes = 1.5956–1.5975 V at AMP_VOUT, 0.798–0.799 V at
the BNC, i.e. 69–71 mV below the ideal 1.6667 V zero — −0.255 N·cm on the
nominal 0.2715 V/N·cm scale. That is 7× the sensor's ±1 % R.O. zero balance
(±9.6 mV at AMP_VOUT), so it includes the mechanism's rest torque through
the coupling (the joystick against its stop) and the front end's own
offsets; separating them needs the joystick freed by hand, or a meter on
VEXC and VMID. Noise: sd 1.22–1.43 codes = 95–112 µV rms at AMP_VOUT
= 0.35–0.41 mN·cm rms, span 8–11 codes over 1000 and 10000 samples at
1 kHz — the ADS8689's own LSB dominates, and it is 10× below the lightest
training load (1 gf ≈ 21 mV at the BNC = 540 codes). Shunt calibration
(k, TQ_CAL_EN low / high / low, 500 samples each; CH20 shows the 549 ms
high): step 0.0 codes = 0.00 mV against a design step of 0.744 V (9522
codes). The calibration switch does nothing on the as-built v1.0, as the
2026-10-04 design review predicted: the N-channel enhancement MOSFET's source
sits at ~5 V (SIG_N) against a 3.3 V gate. Board finding, filed in
.agents/PLANS.md; the firmware's TQ cal verb stays, and a calibration is
not a measurement until the switch is fixed. ALARM read 0x0041 the first
time (OVW_ALARM + TRP_VDD_H_FLAG, a tripped high-AVDD flag from the
+5V_FILT ramp) and 0x0000 the second: the tripped flags clear on read,
no active alarm. The other registers sat at their reset values with
RANGE_SEL 0x000B.
Live loop (L 10): 10000 ticks in 9999 ms = 1000.1 Hz, one ADS8689
conversion (CONVST pulse, 6 µs wait, one 32-bit NOP frame at 2 MHz: ~22 µs)
and one BRAKE_ISENSE read per tick, worst iteration 295 µs, 0 overruns,
torque mean within 1 code of the tare. Coupling (X): torque mean with
the brake at 0 / 24.38 / 48.80 / 0 mA = 20446.4 / 20447.7 / 20446.6 /
20446.4 codes: 1.3 codes (0.1 mV, 0.4 mN·cm) between the extremes, inside
the sd. No brake-current-to-torque coupling at the 0.1 mV level, electrical or
mechanical, with the joystick untouched.
The ~{BUS_SW_EN} boot glitch is fixed. With SafeState::driveOutput()
writing the level before the direction (SDK calls, no gpio_init()), CH27
showed no transition at all across a warm reboot (…-0853-…/captures/
p0_reboot.sr, 10 MSa/s; the boot signature on CH1 10.139 / 10.010 /
10.008 ms and CH0 8 × 1.01 ms places the boot at 2.54–2.62 s of the capture),
where 2026-10-05 saw a 34 µs low at every boot and every disconnectBus().
Power-down (x): ~{BUS_SW_EN} high at 39750.049 ms and HV_EN low at
39750.088 ms — the bus opened 39 µs before the rail dropped, the ordering the
power sequence requires.
v1.0 unit A — brake and torque in the protocol build, 2026-10-06, 09:20–09:35¶
examples/PyControlLinkQp on mouse_joystick_qp_rev2_usb with the day's
protocol work: caps gains brake and torque, BRAKE ramps the brake
DAC and releases the drive once the coil current has settled, TQ answers,
torque rides in every field. Driven by
qp-arduino-example/tools/brake_torque_steps.py (new) over USB CDC with a
40 ms heartbeat; Digital-50 CH0/CH1 and CH16–CH27 at 10 MSa/s. Three runs:
mouse-joystick-firm/evidence/2026-10-06-0920-… (the host script's
heartbeat starved itself and the board faulted host_timeout 100 ms after the
last ping, correctly), …-0925-… and …-0931-… (complete). Numbers below are
the 0931 run unless said; the 0925 run agrees within a few percent.
| Step | Result |
|---|---|
id |
caps=motion+home+instr+hv+watch+fetch+stream+cfg+angout+brake+torque, hw=rev2 |
hv on |
ack 181 ms (host round trip); the switched-domain peripherals then came up one tick each: DAC_STATE 1 (angle DAC reset) at +173 ms after the command, 2 at +4.4 ms, 3 (brake DAC reset) +1.0 ms, 4 (brake DAC GAIN 0x0101 read back) +3.7 ms, 5 (ADS8689 range read back, loopback) +3.4 ms |
| torque before/after the tare | torque=-0.301 on the nominal zero (as the bench console read, −0.26 to −0.31 N·cm); tq get → torque=-0.301;raw=-0.301;cal=0; after tq zero → torque=-0.001;raw=-0.302; tq cal;on → cal=1, reading unchanged (the inert switch) |
brake 1.0 |
ack; ev;RELEASED 11.6 ms after the command reached the board (LINK_RX → the event's t: 9119149 → 9130786 µs; 10.7 and 12.4 ms in the other runs) = the 8 ms-to-full-scale ramp (18809 codes, three 1 ms steps) plus the 6 ms settle; RELEASED carried torque=0.051;brake=1.0 |
brake 3.4 |
RELEASED 15.1–16.7 ms after arrival (63950 codes, eight steps, plus the settle); brake=3.4 in STATUS while held |
| the held joystick | STATUS in RELEASED at the two levels: angle 0.267 → 0.267 (1.0 N·cm) and 0.176 → 0.176 (3.4 N·cm) over 300 ms, torque −0.03 to +0.02 N·cm; after stop the drive re-engaged first, then the brake off: state=ARMED;brake=0.0, angle resynced |
watch without a hand |
pull=5;push=5;timeout=3000;touch=0.3 → ev;NOMOVE 3000.1 ms after the command, no TOUCH (torque stayed within ±0.08 N·cm), ev;TRIAL exactly 1 s later with tq_peak=-0.075;tq_out=-0.028;a_peak=0.0 |
fetch;0;4 |
n=4;k0=0;t0=13132801;dt_us=1000;pre=500;lost=0, lines cap;k;0.176;-0.028 — the torque in the capture's third field |
moveto 0.0, disarm, hv off |
TARGET, then HOMED, hv=0, torque=0.0 with the rail down (the dying bridge's last sample is discarded, fixed between the 0920 and 0925 runs), faults=0 — no spurious range fault from the collapse |
| trace | no ASSERT; BRAKE_STATE records at each engage, release and brake-off |
The brake DAC writes on the I2C tap (0925 run, CH22/CH23) show the ramp
with coarse steps: for 1.0 N·cm a single write of 18809, for 3.4 N·cm three
writes 25550 → 53541 → 63950 at 3.0 and 2.0 ms intervals, instead of eight
1 ms steps. The ramp is slew-limited per unit time (6.1 mA/ms), so a late
poll takes a proportionally larger step; the polls were late because the
1 kHz sampler was in its decimated mode the whole time the rail was up
(SAMPLE_STAT last 1.2 ms in RELEASED with a WATCH armed, 0.26–1.1 ms
otherwise, max 2.1–2.5 ms every second, decimated=1). Yesterday's run of
the same build without the brake and torque paths was already decimated with
the rail up (last 110–750 µs, max 0.9–1.5 ms), so this is the standing
tick-budget problem with ~50 µs more per poll on top (the console's M
measured the ADS8689 sample at 37 µs, BRAKE_ISENSE at 4.8 µs, a DAC write
at 110 µs); the first suspicion, delayMicroseconds() in the ADS8689
transport, was changed to a busy wait and made no difference (0925 → 0931).
A POLL_STAT trace record (per-backend poll cost, per primitive; run
…-0942-unitA-qp-brake-torque/) then showed every bus primitive inflated
to 100–500 µs maxima with the rail up — the TMC X_ENC read, the
five-register snapshot, the ADS8689 sample and even the 5 µs BRAKE_ISENSE
analogRead (110–230 µs) — which is interrupt time landing inside the
transactions, not the transactions themselves; the bench build's USB CDC
protocol port (serviced at interrupt level by the Arduino core) is the
suspect, and the budget is to be judged on the UART0 delivery build. The
finding and its follow-up live in the firmware plan. Even in that state
every protocol step above behaved as specified, and the ramp's largest single
step (19 mA) is below the ~60 % of full scale where the coil was seen to
overshoot.
v1.0 unit A — first contact over the RJ-45 and the delivery build's tick budget, 2026-10-06, 09:59–10:05¶
Peter plugged the Breakout 1.2 into the board's RJ-45 and the pyboard into
USB (PYBV11, MicroPython 1.26.1, serial 397734803233, pyControl framework
on its flash). The board runs examples/PyControlLinkQp on
mouse_joystick_qp_rev2, the delivery build: protocol on UART0, QS trace
on FW_TRIG1, USB CDC only a banner. Driven from the pyboard's REPL with
machine.UART directly (tools/pyboard_repl.py), as on 2026-09-03: first
contact should not have the framework as a second unknown. Digital-50
CH0–CH3, CH24, CH27 at 10 MSa/s. Evidence
mouse-joystick-firm/evidence/2026-10-06-0959-unitA-uart0-first-contact/
and …-1001-unitA-uart0-protocol/.
Which port. id on UART(4) (port 1) and UART(1) (port 3): nothing.
On UART(3) = Breakout 1.2 port 4: ack;2;hw=rev2;serial=E4654C159F274534;
fw=9b6d2ef;proto=2;axes=1;caps=motion+home+instr+hv+watch+fetch+stream+cfg+angout+brake+torque;rxbuf=200;boot=reboot;crumb=0.
Port 4 is the RIGHT joystick's port in mouse_joystick_bringup.py, which is
the joystick unit A drives. The same bytes decoded on the taps: id;2 on CH3
(PYC_UART_RX) and the 170-character reply on CH2 (PYC_UART_TX), so the
adapter's CH2 is verified for the first time and the UART wiring and polarity
on v1.0 are as on rev 0 (R401 10 kΩ holds RX idle high; DIO-A → RX,
TX → DIO-B).
The protocol over the real link, 36 V on (…-1001-…/repl/protocol_session.txt):
| Step | Over UART0 |
|---|---|
status cold |
state=IDLE;…;torque=0.0;brake=0.0;hv=0 |
hv on |
ack; then torque=-0.262 live, tq get −0.261, tq zero, tq get → torque=0.001;raw=-0.26 |
home |
HOMED within the 3 s read window |
arm, brake 1.0 |
ev;RELEASED;…;angle=0.084;torque=0.016;brake=1.0; STATUS in RELEASED with brake=1.0 under 60 ms pings |
watch pull=5;push=5;timeout=1500;touch=0.3 |
ev;NOMOVE 1500.0 ms after the command (205679784 − 204179… µs), ev;TRIAL exactly 1 s later with tq_peak=-0.009;tq_out=-0.007;a_peak=0.183 |
fetch;0;3 |
n=3;…;pre=500;lost=0, three cap;k;0.267;-0.007 lines, cap;end |
| then | ev;FAULT;…;code=0;detail=host_timeout: the REPL script waited 150 ms on the fetch with no command in between — the host's fault, and the board did what the spec says: hv=0, brake=0.0, FAULTED, brake;68;3.4 → nak;faulted, pings still answered, clrfault afterwards → IDLE |
The tick budget on the delivery build (SAMPLE_STAT/POLL_STAT from the
trace, compare the USB bench build earlier today):
| State | USB bench build | UART0 delivery build |
|---|---|---|
| rail down, idle: last tick | 35–60 µs | 50–79 µs |
rail up, ARMED/HOMED: last / max |
260–1080 / 2100–2500 µs | 273–344 / 533–795 µs |
rail up, RELEASED + WATCH: last / max |
1170–2260 / 2160–3410 µs | 1167–1440 / 2264–3414 µs |
| worst X_ENC read / five-register snapshot | 350–555 / 400–670 µs | 161–167 / 308–317 µs |
worst ADS8689 sample / BRAKE_ISENSE read |
230–480 / 110–230 µs | 103–107 / 57–137 µs |
decimated from hv on to hv off |
yes | yes |
So the USB protocol port was inflating every primitive two- to fourfold (its
servicing runs at interrupt level in the Arduino core), and over UART0 the
primitives sit at 3× their bench-console cost (interrupt time from the tick
alarm, the UART and the trace's PIO ISR). What is left is structural and the
same on both builds: ProtocolBoard polls both hardware backends at every
scheduler stop, the classifier's 1 kHz ring adds one stop per millisecond in
RELEASED, and the sampler's decimated mode — entered during the hv on
transient (one 3.9 ms tick plus the four peripheral phases) — is only left
when the rail goes down, so a decimated tick covers 2 ms of ring stops: three
polls of ~300 µs each is the 1.2 ms. Both are firmware fixes (a once-per-tick
rate limit on the hardware polls; hysteresis on the decimation) and are in
the firmware plan.
v1.0 unit A — Gate E through pyControl, and the tick budget after the poll fixes, 2026-10-06, 10:17–10:45¶
The Breakout 1.2 on the RJ-45, the pyboard on USB, 36 V on; pyControl 2.1.1
running the bring-up task mouse_joystick_check on the pyboard
(mouse-joystick-firm/tools/pycontrol_run.py, the GUI's Pycboard path).
Three runs of the same 50 s task against three builds of
mouse_joystick_qp_rev2, each under a Digital-50 capture (CH0–CH3, CH24,
CH27 at 10 MSa/s) with the QS trace decoded. Evidence
mouse-joystick-firm/evidence/2026-10-06-1019-unitA-gate-e/, -1037-,
-1043-. The pyControl side is in "pyControl Integration" below; this
section is the board's tick.
The sequence, from the board's clock (run 1043, booted 30 s earlier):
hv on acked in 185 ms; home → HOMED 836 ms; arm → ARMED 23 ms;
moveto -5 → TARGET 338 ms at −5.041°; brake 1.0 → RELEASED 12 ms
(the ramp and the 6 ms settle); watch 17 ms later; TOUCH 20 ms after
RELEASED at −0.349 N·cm (the rest offset, nobody touching); NOMOVE at
the 10 s timeout; TRIAL 1.000 s after; park → TARGET 338 ms at −0.991°;
stop, disarm, hv off from the task's run_end. Identical in all three
runs to the millisecond, and to run 1019's (rail already up from the
crashed run before it, so hv on acked in 6 ms there).
The tick, three builds (SAMPLE_STAT/POLL_STAT per second; last is
a typical handled tick, max the worst in that second; the sampler was
decimated — every other tick — from hv on to hv off in every run):
| Build (hardware-poll rate limit) | RELEASED+WATCH, 10 s: bus polls/s |
handled ticks/s | overrun skips/s | coalesced/s | handler last / max | ARMED idle: last / max |
|---|---|---|---|---|---|---|
| 1019: once per 500 µs of protocol time | 1000 | ≈380 | ≈310 | ≈65 | 1050–1750 / 2100–2450 µs | 284–625 / 785–898 µs |
| 1037: once per 500 µs of wall-clock time | 720–734 | ≈400 | ≈145 | ≈55 | 600–1460 / 1800–2800 µs | 310–630 / 760–960 µs |
| 1043: once per advance of the protocol clock, plus the 500 µs guard | 487–489 | ≈488 | ≈30 | ≈40 | 550–1040 / 1150–1270 µs | 290–670 / 820–950 µs |
Why three tries: ProtocolBoard::advance() services once per scheduled
stop, and in RELEASED the classifier's ring schedules a stop every
millisecond, so one late tick runs two or three services. Keyed to the
protocol clock the limit let every one of them read the bus (the stops are
1 ms apart on that clock). Keyed to the wall clock it still let the second
one through whenever the first poll had itself taken longer than 500 µs —
which, with the primitives inflated as below, it had. One read per advance
(MotionBackend::beginAdvance()) is what the design meant.
What one poll costs on this board, in the link build, rail up (worst
per second; the bench console measured the same primitives without the QP
link at 2026-10-06 09:00: X_ENC read ≈ 70 µs, five-register snapshot
≈ 340 µs, ADS8689 sample 37 µs, BRAKE_ISENSE analogRead 4.8 µs, DAC
write 110 µs):
| Primitive | RELEASED+WATCH (run 1043) |
ARMED idle (run 1043) |
|---|---|---|
| X_ENC read (2 datagrams, 4 MHz) | 240–345 µs | 277–308 µs |
| five-register snapshot (10 datagrams, every 10th poll) | 400–566 µs | 375–495 µs |
| motion poll (one of the two above plus the ramp logic) | 430–600 µs | 407–529 µs |
| ADS8689 sample (CONVST, wait, one 32-bit frame) | 150–270 µs | 160–263 µs |
BRAKE_ISENSE analogRead |
105–200 µs | 70–160 µs |
| brake/torque poll | 220–400 µs | 230–330 µs |
| handled tick, rail down (no bus) | — | 57–73 µs (max 144–319) |
Every primitive is 2–4× its console cost, the 5 µs analogRead 20–40×:
the inflation is time stolen from inside the transactions, not the
transactions. The candidates are interrupt time (the 1 kHz tick alarm with
its QS records, UART0 RX/TX for the host lines, the trace's PIO UART ISR,
TinyUSB's housekeeping with the CDC enumerated) and XIP cache misses on a
code path that is much larger in the link build than in the console. Not
attributed yet; the firmware plan has the experiments. As it stands the
sampler holds 500 Hz in RELEASED with ~6 % of its ticks lost (skips
plus coalesced), and 1 kHz is not reached with the rail up. The angle DAC
write is now skipped when the value is unchanged (−110 µs on a resting
tick), which did not move the typical ARMED tick: the inflation
dominates.
Two observations for the rig, both runs: (1) the torque reading at rest
after RELEASED is −0.2 to −0.35 N·cm (the third A4/A5 finding), so a
watch with touch=0.3 reports TOUCH 20 ms after release with nobody
there — tq zero before the trial, or a higher touch level; (2) parking at
0° after the trial ends with the encoder at −0.991° and the driver
satisfied, in all three runs — the lost degree is on the reversal from the
−5° presentation, so coupling backlash, and a home before a trial whose
park position matters.
v1.0 unit A — the tick budget attributed on the bus and on the pin, 2026-10-06, 11:04–11:45¶
Peter's call after Gate E: the sampler should hold 1 kHz with the rail up if bugs are what stop it. Measured with the LA5032 on the Digital-50 taps rather than with the firmware's own clock, so the numbers below are what the bus and the pin did, not what the stalled CPU thought.
The TMC5130 SPI bus (CH8 TMC_SPI_SCK, CH9 ~{TMC_CS}, CH10 COPI, CH11
CIPO at 50 MSa/s, 1 s in the task's ARMED idle; evidence
mouse-joystick-firm/evidence/2026-10-06-1104-unitA-tmc-spi-timing/):
| TMC51X0 library | per datagram (CS low) | between datagrams | X_ENC read (2 datagrams) | snapshot | polls/s |
|---|---|---|---|---|---|
4.1.0: five transfer(byte) calls, two datagrams per register |
17.1 µs (40 clocks at 3.85 MHz in 14.8 µs: four 1.5 µs byte gaps) | 11.6 µs | 60 µs | 10 datagrams, 269 µs | 511 (decimated) |
working copy: in-place transfer(buf, 5) (still a byte loop on this core), pipelined readMany |
17.3 µs | 3.5 µs | 38 µs | 6 datagrams, 167 µs | 513 |
working copy: two-buffer transfer(tx, rx, 5) (one SDK call, clocks back to back) |
12.0 µs (9.8 µs of clocks, no gaps) | 3.2 µs | 27 µs | 134 µs | 515 |
the same, trace off (instr off) |
17.3 | 3.5 | 38 | 166 | 515 |
the same, host heartbeat 2.5/s instead of 25/s (cfg wdt_ms=500) |
17.3 | 3.5 | 38 | 144 | 1004 — the sampler at 1 kHz |
The SCK is 3.85 MHz (the 4 MHz request through the PL022 divider), CS lead and trail 1.2 and 1.0 µs. The 4.1.0 library's datagrams were also stretched on the bus itself (CS low p90 31.6 µs against a 17.1 µs median, one at 201 µs): the CPU stalled mid-datagram between the byte transfers. With the single buffer transfer the p90 equals the median; the rare long stall remains (max 96–142 µs, one or two per second).
What the firmware thinks the same operations cost (POLL_STAT maxima
per second, rail up): X_ENC read 240–430 µs, snapshot 400–570 µs, ADS8689
sample 150–270 µs, BRAKE_ISENSE analogRead 100–200 µs — 2–8× the bus,
and unchanged by the 37 % bus-time cut. A LINK_WORK record per host
line says the same from the other side: a ping costs the active object
1.2–1.9 ms (advance ≈ 450 µs, parse-dispatch-reply ≈ 650 µs, queueing the
6-byte ack ≈ 300 µs). The trace off changes nothing; fewer host lines let
the sampler recover to 1 kHz in the ARMED idle but not in RELEASED.
The TICK handler on the pin (MJ_TICK_LEVEL build, FW_TRIG0 high for
the whole handler, CH0–CH3 at 2 MSa/s for 30 s, 14 303 handlers; evidence
…/2026-10-06-1132-unitA-tick-attribution/tick_level/):
| State | handlers/s | median | p90 | max |
|---|---|---|---|---|
RELEASED + WATCH (first 7 s) |
440 | 758 µs | 1090 µs | 1.5–2.3 ms |
ARMED idle |
488 | 268 µs | 465 µs | 0.7–0.9 ms |
| the tick within 3 ms after a host line (650 of 14 303) | — | 568 µs | 820 µs | 1.25 ms |
| every other tick | — | 279 µs | 764 µs | 2.27 ms |
So a host line adds ~300 µs to the tick that follows it, but at 24 lines/s
that is 5 % of the ticks; the other 95 % cost 268 µs in ARMED for ~70 µs
of bus work, and 758 µs in RELEASED for the same bus work plus the
classifier and the capture ring — a few hundred instructions. The time is
in executing code. The board runs its code from the W25Q128JVZPIQ through
the RP2040's 16 KB XIP cache, and every build so far has used the generic
single-lane 03h stage 2 (boot2_generic_03h_4, a command and address per
word), kept since the first article "until stage 1 passes"; the quad-lane
stage 2 matched to this flash (boot2_w25q128jvxq_4) was never tried on
hardware. That is the experiment that follows.
The quad-lane stage 2 (env:mouse_joystick_qp_rev2_fastflash, 11:40;
the board enumerated and ran the whole task sequence first time; the QE bit
the stage 2 sets is non-volatile, so the generic stage 2 still boots the
board afterwards). The same probe, same task, same host:
| generic 03h stage 2 | quad-lane stage 2 | |
|---|---|---|
TICK handler, RELEASED+WATCH: median / p90 / max (pin) |
758 / 1090 / 1500–2300 µs | 346 / 450 / 610–760 µs |
TICK handler, ARMED idle: median / p90 / max (pin) |
268 / 465 / 700–900 µs | 146 / 235 / 310–370 µs |
handlers per second, ARMED idle (pin) |
488 (decimated) | 1000 |
| the tick after a host line vs the rest (median) | 568 vs 279 µs | 186 vs 147 µs |
a ping, LINK_WORK: advance / feed / after (mean) |
450 / 650 / 300 µs | 183 / 236 / 94 µs |
POLL_STAT maxima: X_ENC / snapshot / ADS8689 / analogRead |
240–430 / 400–570 / 150–270 / 100–200 µs | 100–147 / 175–231 / 61–125 / 28–92 µs |
| on the bus: X_ENC read / snapshot (two-buffer transfer) | 27 / 134 µs | 27 / 101 µs (the gaps between datagrams halved too) |
trace run (run 8, …-1140-unitA-gate-e-fastflash/): sampler |
decimated from hv on to hv off |
1 kHz in ARMED (1003–1008 polls/s, max 330–370 µs); decimated in RELEASED only, 68 overrun skips and 45 coalesced ticks in the whole run (from ≈1900 and ≈1500) |
Everything the CPU does got two to three times faster; the bus did not
change, which is the proof that the inflation was instruction fetch. What
was left in run 8 is the budget constant: the RELEASED handler's p90 of
450 µs crosses the declared 400 µs three ticks in a row and decimates the
sampler for the trial; the firmware raises the budget to 700 µs (measured
headroom: the worst handler 760 µs, the idle loop keeps ≥ 240 µs of the
millisecond) and run 9 below measures the result. Evidence
…/2026-10-06-1132-unitA-tick-attribution/tick_level_fastflash/,
…-1104-unitA-tmc-spi-timing/captures/after_fastflash.sr.
Run 9, quad-lane stage 2 and the 700 µs budget
(…/2026-10-06-1147-unitA-gate-e-fastflash/, 11:47): the sampler held
1 kHz for the whole run — never decimated, not even through the hv on
transient (one 2.6 ms tick, no longer three in a row), 988–1013 bus polls
per second in RELEASED as in ARMED. Handler maxima 553–798 µs in
RELEASED (typical 290–460), 310–371 µs in ARMED (typical 140–160),
19–26 µs with the rail down. In 50 s: 79 overrun skips, 47 coalesced ticks,
almost all inside the rail-up, home and brake transients; the previous
morning's runs lost ≈1900 and ≈1500 respectively. POLL_STAT maxima: X_ENC
91–186 µs, snapshot 171–208, ADS8689 sample 70–132, analogRead 30–113. A
ping 485 µs of handler time. The task sequence itself is unchanged to the
millisecond (hv on 180 ms, HOMED +850 ms, RELEASED 12 ms after
brake, park 338 ms); the tare leaves the torque at +0.08 to +0.12 N·cm
with no TOUCH. This is the configuration the board was left in.
v1.0 unit A — the park error measured, 2026-10-06, 14:07–14:25¶
Every trial today parked at 0° with the ramp generator satisfied and the
encoder at −0.99°. Peter asked whether that could be the quadrature encoder
block or the firmware. Measured with the bring-up task's park knob
(mouse_joystick_check.py; evidence
mouse-joystick-firm/evidence/2026-10-06-1407-unitA-park-error/), on
the delivery build:
| Probe | encoder angle |
ramp generator xactual |
|---|---|---|
at RELEASED, 20 ms after the drive let go under the brake |
−4.774 | −4.999 (the rotor relaxed 0.27° when the drive let go; the ramp generator is frozen) |
stop instead of a move (re-engage, resync, brake off), 40 ms later |
−4.774 | −4.774 after the resync |
| 300 ms later, brake fully off | −4.866 / −4.774 (two runs) | −4.774 |
then moveto 0, 300 ms after the stop |
−0.991 | 0.0 |
So the release, the resync and the brake are not it: with both frames equal
and the brake off for 300 ms, the move still arrives 0.99° short. A chain of
six moves after the park, encoder at each TARGET (error = angle − target):
| move | direction | IRUN 8 (0.21 A RMS, the bring-up current) | IRUN 17 (0.42 A RMS, one probe build) |
|---|---|---|---|
| home → −5 | − | −0.04 | +0.05 |
| park → 0 | + (reversal) | −0.99 | −0.63 |
| → −5 | − (reversal) | −0.22 | −0.32 |
| → −2.5 | + (reversal) | −0.83 | −0.65 |
| → 0 | + (same direction) | −0.82 | −0.63 |
| → −2.5 | − (reversal) | −0.47 | −0.29 |
| → −5 | − (same direction) | −0.40 | −0.40 |
| → 0 | + (reversal) | −0.90 | −0.63 |
The loss happens only on a change of direction (0.35–0.77° per reversal at IRUN 8) and not at all on a move in the same direction (≤ 0.07°); it does not grow with distance (a 2.5° reversal loses as much as a 4.8° one) and does not accumulate. That rules out the encoder (a missed-edge or scaling error grows with distance and is not direction-symmetric) and the firmware (the frames agree before the move, and the ramp generator's count is what the driver executed). It is lost motion between the electrical angle and the joystick: at twice the run current the "after a positive move" state became −0.633° three times out of three (from −0.82…−0.99) and the hysteresis between directions shrank by about a third, so roughly 0.3° is the stepper's own torque angle under the mechanism's friction at the low bring-up current (the rotor settles short of its electrical position in the direction it came from), and the remaining 0.3–0.6° is in the drive train (coupling slack or torsional compliance against pivot friction) — not felt by hand, which is consistent with a few tenths of a degree at the lever.
What to do about it (rig plan): the bring-up task and the rig's trial
task home before every trial for now (0.9 s). Three real fixes, any of
which the firmware can own: approach every target from the same direction
(overshoot and return, the CNC backlash convention); raise IRUN toward
the 0.42 A conservative figure once the thermal survey allows (it halves the
torque-angle share and makes the error repeatable); or settle on the
encoder after TARGET with one or two correction moves of the residual,
which removes both shares and is what an encoder is for. The last is the
one to implement; it belongs with the TARGET semantics in
uart-protocol.md (does TARGET mean "ramp generator arrived" or "encoder
arrived"?).
v1.0 unit A — the encoder settle after a move (protocol V1.13), 2026-10-07, 08:07–08:12¶
The fix for yesterday's park error, measured. Firmware examples/PyControlLinkQp
on mouse_joystick_qp_rev2 (quad-lane stage 2, TMC51X0 4.2.0, IRUN 8), working
tree bd9bcbb-dirty: TmcMotion commands each move relative to the encoder
(XTARGET = XACTUAL + (target − X_ENC)), waits after position_reached for
X_ENC to stand still (within one count for 10 ms, bounded at 100 ms) and
corrects a residual above one count (13 microsteps, 0.09°) with a further move,
at most twice; TARGET then carries the encoder's angle (uart-protocol.md
V1.13). The same probe chain as yesterday (mouse_joystick_check, park=3:
present −5°, brake and release, 2 s watch, park to 0°, then −5, −2.5, 0, −2.5,
−5, 0), run twice, the second with the QS trace on the LA5032 (MOVE_SETTLE:
the residual between encoder and target when the ramp generator first
arrived and at TARGET, the corrections, the time between). Evidence
mouse-joystick-firm/evidence/2026-10-07-0807-unitA-encoder-settle/.
TARGET error (encoder angle − target), degrees:
| move | direction | 2026-10-06, before | run 1, 08:08 | run 2, 08:11 |
|---|---|---|---|---|
| home → −5 | − | −0.04 | +0.05 | +0.05 |
| park → 0 | + (reversal) | −0.99 | 0.00 | +0.08 |
| → −5 | − (reversal) | −0.22 | −0.04 | +0.05 |
| → −2.5 | + (reversal) | −0.83 | −0.02 | −0.12 |
| → 0 | + (same direction) | −0.82 | +0.18 | +0.18 |
| → −2.5 | − (reversal) | −0.47 | −0.12 | −0.02 |
| → −5 | − (same direction) | −0.40 | +0.05 | +0.05 |
| → 0 | + (reversal) | −0.90 | 0.00 | −0.09 |
Every one of sixteen moves within 0.18° (two encoder counts); eleven within one count. Before: eight of eight short by 0.22–0.99°.
MOVE_SETTLE, run 2 (residual = target − X_ENC in microsteps, 12.8 per
encoder count; settle = from the ramp generator's first position_reached to
TARGET; move = OP_START to OP_DONE):
| move | residual at first arrival | residual at TARGET |
corrections | settle ms | move ms |
|---|---|---|---|---|---|
| home → −5 | −7 (−0.05°) | −7 | 0 | 11 | 351 |
| park → 0 | +141 (+0.99°) | −12 (−0.08°) | 1 | 177 | 515 |
| → −5 | −96 (−0.68°) | −7 | 2 | 217 | 563 |
| → −2.5 | +119 (+0.84°) | +17 (+0.12°) | 2 | 258 | 496 |
| → 0 (same direction) | −51 (−0.36°, past the target) | −25 (−0.18°) | 2 | 218 | 472 |
| → −2.5 | +4 | +4 | 0 | 10 | 263 |
| → −5 (same direction) | −19 (−0.13°) | −7 | 1 | 79 | 326 |
| → 0 | +90 (+0.63°) | +13 (+0.09°) | 1 | 149 | 485 |
Reading it:
- The reversal error is yesterday's: +141 microsteps = 0.99° on the park, 0.63–0.84° on the probe reversals, with the ramp generator satisfied. One correction removes most of it (the park: 0.99° → 0.08° in one leg).
- What the budget leaves is one to two counts (−25…+17 microsteps). A correction of 13–25 microsteps advances the electrical angle by 5–10 % of a full step, a torque increment of a few per cent of the holding torque at IRUN 8, and that does not break the mechanism's static friction: the ramp generator moves, the rotor does not. The stuck correction then surfaces on the next same-direction move as an overshoot — probe 3 arrived 0.36° past its target after probe 2's two corrections had advanced the ramp frame without moving the rotor, and needed two corrections of its own. The mechanism is the same one the reversal shows, seen from the other side.
- Cost. A move without a correction gains only the 10 ms stand-still wait
(263 ms for 2.5°, 351 ms for 5°, within the 10 ms snapshot latency of the
old
TARGET); a correction leg costs 70–110 ms plus its wait; a reversal costs 150–260 ms more than before.settle_usof 10–11 ms on the uncorrected moves says the encoder was already still when the ramp generator reported arrival: the rotor does not go on moving afterposition_reachedat this current. - The settled park is at least as good as a home. Homing repeatability was 2–5 counts (2026-09-02); the park now lands within 0–1 count. The rig task's home-before-every-trial (0.9 s a trial) is no longer needed for the park position.
Open, for the firmware (.agents/PLANS.md): the tolerance. At IRUN 8 a
one- or two-count correction rarely moves the rotor, so the second correction
mostly spends ~100 ms and over-advances the ramp frame; a two-count tolerance
(26 microsteps, 0.18°) would skip it at the price of the last count. At
IRUN 17 the torque increment per microstep doubles and the question changes;
decide it with the thermal survey. Lost motion between the motor shaft the
encoder reads and the joystick lever (gear pair, coupling) is invisible to
the board either way.
v1.0 unit A — the rig's trial task, dry run without homing between trials, 2026-10-07, 08:40–08:45¶
The rig's own files, not the bring-up ones: joystick-rig branch
feat/mouse-joystick, hardware_definition_joystick.py (left on port 1,
unpopulated; right on port 4; solenoid on port 5; rsync on DAC_2; camera
trigger on BNC_1) and tasks/rig1/joystick_trial.py with
home_each_trial = 0, run headlessly through pyControl 2.1.1's board API
from the checkouts (mouse-joystick-firm/tools/pycontrol_run.py
--hwd-path/--task-dir, added today) against unit A on the right joystick,
nobody at the rig: n_trials=8, watch_timeout_ms=2000, iti_ms=1000,
the rest the task's defaults. Firmware as the previous section. Evidence
mouse-joystick-firm/evidence/2026-10-07-0840-unitA-rig-trial-dryrun/.
| trial | present TARGET (−5.0 commanded) |
park TARGET (0.0 commanded) |
|---|---|---|
| 1 | −4.95 | +0.084 |
| 2 | −5.041 | 0.0 |
| 3 | −4.95 | 0.0 |
| 4 | −4.95 | +0.084 |
| 5 | −4.95 | +0.084 |
| 6 | −4.95 | +0.084 |
| 7 | −4.95 | +0.084 |
| 8 | −4.95 | +0.084 |
One home at the start (1.05 s), then eight trials of identify-free
present → BRAKE 1.0 → RELEASED ~20 ms later → tare → WATCH → NOMOVE
at 2 s → TRIAL 1 s later → park → 1 s ITI, 5.1 s a trial, every
present and every park within one encoder count (0.09°) of its command, no
home between trials; a0 −4.95 in every TRIAL summary, the torque at
rest after the tare 0.03–0.06 N·cm, no TOUCH, no nak, no FAULT; the
40 ms heartbeat ran throughout (1072 link events in 41 s), the rsync pulses
and the 150 Hz camera trigger alongside. The present move takes 0.36 s when
the park landed within a count and 0.58 s after the one park that needed a
correction; the park 0.55 s.
pyControl finding, the pyboard's flash: the first attempt failed with
"Insufficient space on pyboard filesystem" — statvfs showed 0 blocks free.
pyControl never removes device files it has uploaded
(transfer_device_files runs with remove_files=False): MCP.py (9 KB) and
rotary_encoder.py (2.5 KB), dragged along by a comment on 2026-10-06's first
Gate E attempt, were still there, and the aborted transfer had left a
512-byte frame_trigger.py (the file is 1093 bytes) that would have failed
to import. Only "Load framework" wipes the folder. After removing the four
files, 16.9 KB were free; the rig's hardware definition is 15.5 KB of mostly
comments and its task 10.2 KB, so the comment-free copies went up (706 bytes
and 5.2 KB, tools/pycontrol_lean.py's function on the rig's files; the
copies are in the evidence directory). Through the GUI the files go up
verbatim: load the framework first, and the budget after the devices is
about 3 KB. Written up in mouse-joystick-firm/pycontrol/README.md.
v1.0 unit A — the reward path and the pull sign, 2026-10-07, 08:53–08:59¶
The solenoid cable moved to Breakout 1.2 port 5 (POW_A) by Peter at
~08:50, then the rig's trial task (as the dry run above) on the right
joystick with Peter pulling the lever by hand during the watch. Evidence
mouse-joystick-firm/evidence/2026-10-07-0855-unitA-solenoid-pull/.
| run | window | what the board saw | outcome |
|---|---|---|---|
pull2, 08:53 |
2 × 25 s | trial 1: TOUCH 0.31 N·cm 15 ms before the timeout, NOMOVE; trial 2: nothing |
the pull came after each window closed |
pull1_60s, 08:55 |
60 s | TOUCH 0.30 N·cm at 9.4 s, then the lever from −4.95 to −9.991 in 0.26 s: PUSH, TRIAL a_peak=-5.041 outcome=PUSH; the park then faulted not_following 0.48 s in (the hand still on the lever), rail off |
pull sign +1 was wrong for this board; no reward |
pull1_60s_signed, 08:59 |
60 s | TOUCH 0.32 N·cm at 7.9 s, the lever to −10.083 93 ms later: PULL, the task's 50 ms solenoid pulse, TRIAL a_peak=5.041 outcome=PULL dur_thr=0, park TARGET 0.0, session complete: pull 1 |
the reward path works end to end |
Findings:
- Unit A's pull is the decreasing-angle direction, away from the home
switch and further into the travel, like board 4's on 2026-09-02:
BoardCalibration.hppnow carries{"E4654C159F274534", -1.0}and the board was flashed with it at 08:58. A pull-positive excursion of 5.04° from the presented −4.95° reaches the travel's far end (−10.0°); thepullthreshold of 5° therefore means "all the way". - Two direct 50 ms pulses on
port_5.POW_Athrough the pyboard's REPL at 08:58 (hardware_definition.solenoid.on()/off()), before the signed run, for the ear. - A lever held through the park faults the board, correctly. The TRIAL
summary comes one second after the outcome and the task parks at once; at
IRUN 8 the drive cannot move a held lever, the ramp generator advances
while the encoder does not, and the follow check faults
not_following(bit 2) and drops the rail — the stall detector doing its job, and the end of a session if a mouse does it. The protocol or the task needs a rule for it: wait for the lever to be free (torque below a level, angle still) before the park, or recover withCLRFAULT→HV ON→HOME. Open in the firmware plan anduart-protocol.mdV1.9. - One refused command, unexplained: 5 ms after that
FAULTevent the task's firstfaultsrequest was answerednak;326;badverb; the task re-entered its faulted state and the secondfaultswas acknowledged (mask=4,over_travel: not_following). The verb is dispatched by exact match and was accepted a few milliseconds later, so a byte of the first line was most likely corrupted on its way in, around the switched domain collapsing; the LA5032 was not capturing. Next fault, capture CH3 (board RX). No othernakin 1 500 lines today.
v1.0 unit B (board 7) — E4654C159F404534 — Stage 1 passed 2026-10-07, 09:08¶
First power-on of the second v1.0 unit, after Peter's rework (the wrong
green banded part: C1208 next to the TMC5130A refitted, C1301 and
C1501 removed, as on unit A). USB alone: the board enumerated as the
RP2040 ROM bootloader (2e8a:0003, RPI-RP2), took RP2040SelfTest for
mouse_joystick_rev2 by UF2 copy and came back as 2e8a:f00a with the
serial above on /dev/ttyACM2, beside unit A. result: 20 checks, 0 failed. Console log:
mouse-joystick-firm/evidence/2026-10-07-0908-unitB-bringup/s1_selftest_rev2_boardB.log.
| Check | Result |
|---|---|
| reset reason | software (3) (the UF2 reboot) |
clk_sys against clk_ref |
133000 kHz; clk_ref 12000, clk_peri/clk_usb/clk_adc 48000 kHz |
| flash | unique id read, 16 MiB declared, no aliasing at +2/+4/+8 MiB |
| heap | 252224/96/252128 |
GP9 ~{BUS_SW_EN} |
high — R607 holds the bus isolated |
GP24 HV_LATCH |
low (informational: switch unpowered) |
GP16/GP17 DAC_SDA/DAC_SCL |
high — R403/R402 |
GP1 PYC_UART_RX |
high — R401 |
GP23 ~{HV_FAULT} |
high — R507, no latched fault |
spare pads GP20, GP22 |
both move both ways |
GP26 SW_RAIL_SENSE_DIV_2 |
17 counts = 0.0137 V divided = 0.0274 V on +3.3V_SW, off |
GP28 HV_SNS |
15 counts = 0.0121 V across R509 |
GP27 BRAKE_ISENSE |
13 counts = 0.0105 V |
GP29 ANGLE_OUT_MON |
18 counts = 0.0145 V |
| die temperature | 29.0 C at boot |
The floors are 4–8 counts above unit A's (9–14 counts there): ADC offset,
nothing more at the rail-off level. Not done on this unit: the Digital-50
check (the adapter is on unit A), the k crystal check, the cold power-on
reset reason. Next: 36 V with nothing else connected (stage 2,
TMC5130Communication), then the left joystick's cables.
v1.0 unit B — Stage 2 passed 2026-10-07, 09:12, direct LATCH-GPIO power-up¶
36 V connected to J501 by Peter (nothing else on the board: no joystick,
no RJ-45), TMC5130Communication on mouse_joystick_rev2 (firmware
51322e7-dirty) over USB, the timed key sequence v d s i d x s r x m x l x
(mouse-joystick-firm/evidence/2026-10-07-0908-unitB-bringup/s2_tmc_rev2_boardB.log).
| Step | Result |
|---|---|
v, d before |
+3.3V_SW 0.0274 V off; HV_SNS 14 counts, offset floor; ~{HV_FAULT} high |
s, first pass |
method LATCH held low, direct HV_EN assertion; +3.3V_SW up in 4 ms, peak 3.3008 V; bulk charged (HV_SNS below the fault level 20 ms running) at 34 ms, latch-off armed; live 3.3040 V; HV_SNS 51 counts = 132 mA during the charge; bus closed with ~{TMC_DRV_EN} high; VERSION 0x11, DRV_ENN 1, SD_MODE 0, IOIN invariants ok; register checks 5, 0 failed |
s, second pass after x |
identical, bulk at 30 ms, +3.3V_SW 3.3008 V; 5/0; r re-read 5/0 |
m |
zero 13.94 counts, live 25.50, delta 11.56 → 36 V input current 29.9 mA idle with the bus open (unit A read 43.6–51.3 mA on 2026-10-05; one count is 2.6 mA) |
l LATCH probe |
strapped half (GP24 driven high through R503): rail up, held 32 of 250 ms, peak 3.306 V, ~{HV_FAULT} LOW, fault latched — FAIL as designed, as on unit A; the direct method of the s passes is the GPIO half passing |
Same behaviour as unit A on 2026-10-05 to the millisecond; the refitted
C1208 (TMC 5VOUT) is doing its job since the chip answers. Not done: the
Digital-50 taps (the adapter is on unit A). Next: A3 (the I2C angle DAC and
ANGLE_OUT_MON) with the same connections, then the left joystick's cables.
v1.0 unit B — Stage A3, the angle DAC over I2C, 2026-10-07, 09:14¶
Same connections as stage 2 (36 V, USB, nothing else), AngleOutput on
mouse_joystick_rev2 (51322e7-dirty; the uploader's 1200-baud touch left
the board in BOOTSEL with "RP2040 device returned an error", the UF2 copy
finished it), keys b d q z m f z d x
(mouse-joystick-firm/evidence/2026-10-07-0908-unitB-bringup/a3_angleoutput_rev2_boardB.log).
| Step | Result |
|---|---|
b |
+3.3V_SW up in 3 ms, bus closed, I2C on GP17/GP16 to 0x48, GAIN 0x101 written and read back matched (2.5 V at the DAC, 1.650 V at the BNC) |
d |
DEVID 0x115, REF-ALARM clear, before and after the codes |
z code 0 |
ANGLE_OUT_MON 0.0161 / 0.0153 V (19–20 counts): the floor (unit A 12 mV) |
m code 32768 |
expected 0.8250 V at the BNC node, measured 0.8292 V (unit A 0.8236) |
f code 65535 |
expected 1.6500 V, measured 1.6488 V (unit A 1.6415) |
Within the RP2040 ADC's accuracy of the design values, as unit A; a meter fit at the BNC is still owed on both units. Next: the left joystick's cables (motor, encoder, limit switch, brake, torque sensor) for A6, A4 and A5.
v1.0 unit B — Stage A6 (stage 3 on v1.0) passed 2026-10-07, 09:22–09:25, the LEFT joystick¶
The left joystick's motor, encoder, limit switch, brake and torque sensor
cabled by Peter, the second LA5032 on the Digital-50 (straight cable, see
the adapter's log), MotionAndHoming on mouse_joystick_rev2
(51322e7-dirty) over USB; the key sequences b c e p W p and then
g h H k k j k j k g h L p x, as unit A's on 2026-10-05
(mouse-joystick-firm/evidence/2026-10-07-0908-unitB-bringup/a6a_gates_unitB.log,
a6b_motion_unitB.log, captures a6a_gates_unitB.sr, a6b_motion_unitB.sr
at 1 MSa/s on CH0/1, CH8–11, CH14, CH15, CH24, CH27, CH28/29).
| Gate | Result on unit B | Unit A, 2026-10-05 |
|---|---|---|
b |
+3.3V_SW 4 ms, bulk 33 ms, direct method; bus closed; VERSION 0x11; converter, driver, controller, encoder configured; GAIN 0x101 |
same |
c |
CHOPCONF 0x10030153, vsense read back 1 |
same |
e, p |
enabled; X_ENC 38 microsteps (0.27°) against XACTUAL 0: the enable snap |
same order |
W |
the switch found toward the POSITIVE end after 3583 microsteps (25.19°) of travel, throw 256 microsteps, active high, pol_stop 0; XACTUAL 3327 / X_ENC 3392 afterwards |
same end, same polarity |
g |
follow ratios 0.93 / 0.89, residual −52 microsteps (−4.1 counts) after the round trip, within 7 % | 0.94 / 0.86, −103 (−8 counts) |
h |
approach stopped by the switch (390 commanded, 422 measured); closing latch 3717, release edge 3326, closing-to-release 391 microsteps (2.75°); backed off to −1401; homed, 1741 ms | same structure |
H |
5 of 5; release ENC_LATCH 115, 102, 0, 12, 12 microsteps → spread 115 microsteps (0.81°, 9.0 counts); runs 3–5 agree within one count, runs 1–2 sit 100 microsteps away |
spread 25 microsteps (2 counts) |
jogs k k j k j k |
follow ratios 0.93–1.01 | same |
g again |
residual −77 microsteps (−6 counts), within 1 % | −102 |
h again |
homed | |
L |
live angle output at 1 kHz, worst loop 350 µs of the 1000 µs budget | 1082 µs (before the 400 kHz I2C) |
p after the back-off |
XACTUAL −1404 / X_ENC −1229 microsteps |
−1346 / −1229 |
Same geometry as the right joystick: the switch at the positive end, active
high, the encoder counting with the ramp generator, the motor direction as
configured — no o, m or y flip needed, so one AppConfig serves both
boards. The one difference is the homing repeatability: the left
joystick's first two counted homes landed 8 counts from the last three,
which agreed to one count. Whether that is the switch bedding in after a
day's rest or play in this joystick's mechanism, a second H series later
will say; the settled figure matters because the rig task no longer homes
every trial (V1.13 settles on the encoder instead).
v1.0 unit B — Stages A4 and A5, brake sink and torque bridge, 2026-10-07, 09:27–09:31, the LEFT joystick¶
BrakeAndTorque on mouse_joystick_rev2 (51322e7-dirty) over USB, the
left joystick's brake and torque sensor cabled, joystick untouched; the key
sequences b i r s S "S 32768" 0 i and t "T 1000" d k z t "L 10" X i x,
as unit A's on 2026-10-06; the second LA5032 on CH16–CH23 (torque SPI,
TQ_CAL_EN, ~{BRAKE_FAULT}, DAC I2C) at 10 MSa/s
(mouse-joystick-firm/evidence/2026-10-07-0908-unitB-bringup/a4_brake_unitB.log,
a5_torque_unitB.log, captures a4_brake_unitB.sr, a5_torque_unitB.sr).
A4, the brake sink:
| Step | Result on unit B | Unit A |
|---|---|---|
b |
both DACs ACK (0x48, 0x49); brake GAIN 0x101 read back, DEVID 0x115; ADS8689 RANGE_SEL 0xB read back, SDO loopback all-ones/all-zeros ok |
same |
| floor | BRAKE_ISENSE 0.0145 V (18 counts) = 0.70 mA at code 0, ~{BRAKE_FAULT} high |
|
s sweep 0–100 % |
linear: 5.51, 10.35, 15.28, 19.97, 24.62, 29.54, 34.42, 39.31, 44.23, 49.11 mA at full scale (1.0130 V), error 0.5–1.4 % FS, the floor included; no fault at any point | 48.80 mA, ±0.07 % FS above half scale |
S full-scale step |
rise to 49.1 mA, peak 62.67 mA (128.5 %) at 13 ms, settled by 16 ms; ~{BRAKE_FAULT} never asserted; fall to 0.7 mA in ~4 ms |
62 mA (127 %), the same silent ceiling |
S 32768 half-scale step |
peak 24.93 mA (51.1 %), settled by 7 ms, no overshoot | same |
A5, the torque bridge:
| Step | Result on unit B | Unit A |
|---|---|---|
t, T 1000 |
rest 26718 codes = 2.0873 V at AMP_VOUT, 1.0437 V at the BNC = +1.5495 N·cm on the nominal scale; sd 1.31 codes = 102.6 µV rms (0.38 mN·cm), span 8 codes |
1.5956 V rest, 95–112 µV rms |
d |
DEVICE_ID 0x0010, RANGE_SEL 0x000B, ALARM 0x0041 |
same |
k shunt-cal |
step −0.1 codes of the 9522 designed: the switch is inert on this unit too | inert |
z, t |
tared at code 26721; the nominal zero is 21333, 5388 codes = 420.9 mV off (the sensor's zero balance is ±1 % R.O. = ±9.6 mV); tared reading 0.0003 N·cm | 69 mV off |
L 10 |
1000.1 Hz, worst iteration 137 µs, 0 overruns; 114 µV rms over 10 000 samples | 295 µs worst |
X coupling |
torque mean 26725.9 → 26713.9 → 26703.9 → 26724.6 codes at brake 0 → 24.6 → 49.1 → 0 mA: −22 codes = −1.7 mV = −0.006 N·cm at full brake current, reversible | none at 0.1 mV |
Two things for the left joystick, both the mechanism's rather than the board's: the torque sensor rests 1.55 N·cm from its nominal zero, forty times the sensor's zero-balance tolerance, so the bridge is under a mechanical preload (the lever resting against something, or the sensor mounted under strain) — the tare hides it and the range still has 3 V of headroom above and 2 V below, but it is worth a look at the sensor's mounting before trusting absolute torque; and a small brake-to-torque coupling (−0.006 N·cm at 49 mA), fifty times below the 0.3 N·cm touch level and reversible, which unit A's joystick did not show. The rest reading's sign (+1.55 on the nominal scale) and the pull direction are to be put together once a hand pull has been classified through the protocol.
v1.0 unit B — Gate E through pyControl on Breakout port 1, 2026-10-07, 09:37¶
RJ-45 from unit B to Breakout 1.2 port 1 (UART(4)), the PYBV11 on
/dev/ttyACM1, unit A still on port 4; examples/PyControlLinkQp on
mouse_joystick_qp_rev2 (51322e7-dirty, UF2 copy) on unit B;
pixi run -e pycontrol pycontrol-run with mouse_joystick_bringup and
mouse_joystick_check --set board=left --set watch_timeout_ms=2000, nobody
at the joystick; the second LA5032 on CH0–CH3 at 2 MSa/s
(mouse-joystick-firm/evidence/2026-10-07-0908-unitB-bringup/pilot/gate_e_left.txt,
captures/gate_e_left.sr).
| Task time | What happened |
|---|---|
| 0.023 s | id: serial E4654C159F404534 fw 51322e7-dirty hw rev2 proto 2 caps …+angout+brake+torque |
| 0.193 s | hv on acked (170 ms) |
| 1.312 s | HOMED at 0.0° (1.12 s) |
| 1.697 s | TARGET −4.95° (358 ms after arm) |
| 1.719 s | brake 1.0 → RELEASED 21 ms later, torque 1.524 N·cm untared (the left sensor's rest offset, A5) |
| 1.741 s | status: angle −4.774, xactual −4.999 (the rotor relaxed 0.23° under the brake), torque 1.672 |
| 3.737 s | NOMOVE at the 2 s timeout, torque −0.118 after the tare, no TOUCH |
| 4.750 s | TRIAL outcome=NOMOVE a_peak=0.176 tq_peak=-0.211 |
| 5.278 s | park → TARGET −0.091° (one count) |
| 30 s | run_end: stop, disarm, rail off |
The same sequence as unit A's Gate E to within tens of milliseconds, over the other port and the other UART, with unit A idle on port 4 (nothing printed as "other board"). The park within one count is V1.13 at work on the second mechanism.
The left joystick's pull direction, 09:34–09:38 (pilot/pull_left_60s.txt,
pull_left_60s_signed.txt): a hand pull during a 60 s watch took the lever
from −5.041 to −10.174° and was classified PUSH with the default
pull sign +1 (TRIAL a_peak=-5.133), the lever released in time and the
park landed at 0.0. BoardCalibration.hpp gained {"E4654C159F404534", -1.0},
the board was flashed with it (UF2 copy, 09:36), and the same pull was
PULL at −10.083 (TRIAL a_peak=5.041 outcome=PULL), park 0.0, sequence
complete. So both joysticks pull toward decreasing angle, away from the
home switch. The left torque sensor reads the pull NEGATIVE (−0.55 N·cm
at the outcome, −0.33 at TOUCH) where the right one reads positive
(+0.49); TOUCH uses the magnitude, tq_peak carries the sign, so the
rig's analysis must not assume one torque sign for both joysticks. The
untared rest reading at RELEASED was 1.524–1.529 N·cm every run (A5's
preload), and the tare before watch removed it each time.
Both v1.0 boards now pass Gate E on their own Breakout ports (unit A on 4, unit B on 1) under one hardware definition, with the pull signs of both measured; a task that drives both in one session is the researcher's.
v1.0 unit A — both analog outputs on an oscilloscope with the joystick in the hand, 2026-10-07, 09:50–10:10¶
Peter's scope on the right board's ANGLE_OUT and TQ_AOUT BNCs; the
bring-up task's new hold knob (mouse_joystick_check: hold=1 angout=1
presents at −5°, sets the brake to brake_ncm 1.0, releases the drive and
stays RELEASED with no watch, printing status every 2 s until the run
ends; angscale ang_lo ang_hi and angout on are sent behind the present
move). Evidence
mouse-joystick-firm/evidence/2026-10-07-0950-unitA-scope-hold/pilot/.
- Run 1 (
hold.txt), angle scale −10.125..0°: the angle BNC stayed at 0 V — theangoutline, queued behindhomewithangscale, was lost to anak;;badframe(homeblocks the board ~130 ms; three lines behind it were one too many, see the firmware'spycontrol/README.md). The torque BNC moved with the hand regardless.statusshowed the hand taking the lever from −4.77° to −20.07° and back, so the −10.125° scale would have pinned the output anyway. The run endedFAULTED host_timeoutwhen its host process was killed: the pyboard stalls on USB backpressure and stops pinging — the board did what it is for (rail off within 100 ms). - Run 2 (
hold2.txt), the lines behind the move, scale −28.125..0°,clrfaultbeforeid: no refusal;angle output on,TARGET−5.041,RELEASEDwith the brake at 1.0 N·cm,holding. Peter: both traces follow the joystick — the angle BNC through the encoder at 1 kHz on the full-travel scale (−5° ≈ 1.36 V, −20° ≈ 0.49 V), the torque BNC from the bridge amplifier (pull positive on this joystick). Resistance felt is the brake's 1.0 N·cm (14 mA). The run ended cleanly at 600 s (run_end: stop, disarm, rail off), 299statussamples, nonak, no fault; over the ten minutes the hand took the encoder from −48.24° to +0.90° (torque −0.175 to +0.563 N·cm), so the mechanism's free swing on this joystick is about 49°, far past both the firmware's commandable travel (−10.125..0°) and the 28° "measured travel" constant, which the open issue below already calls a hard-stop setting rather than a mechanism property; the angle BNC pins at its floor beyond the scale's −28.125°.
Not measured: voltages against a meter, the angle output's step response or the filter group delay on the scope; this was a look, not a characterisation (A3 and the 2026-09-10 board-4 section have those).
v1.0 unit A — 400 trials unattended, the soak before the researcher session, 2026-10-07, 10:40–11:14¶
The rig's own joystick_trial.py (the slimmed file, uploaded verbatim as
the GUI would) on the right joystick through Breakout port 4, nobody at the
lever: n_trials=400, watch_timeout_ms=2000, iti_ms=1000,
home_each_trial=0, the 40 ms heartbeat, one home at the start. Firmware
51322e7-dirty mouse_joystick_qp_rev2 (unit A's 08:58 build). Evidence
mouse-joystick-firm/evidence/2026-10-07-1040-unitA-soak/pilot/soak400.txt.
| Quantity | Over 400 trials |
|---|---|
| outcome | 400 NOMOVE, 0 TOUCH, 0 nak, 0 FAULT, session complete at 33 min 53 s |
present TARGET (−5.0 commanded) |
−4.95 × 358, −5.041 × 42: always one of the two encoder counts beside the command; mean −4.960, sd 0.028 |
park TARGET (0.0 commanded) |
0.0 × 399, −0.091 × 1: within one count every time, no homing between trials |
| trial period | 5.081 s mean, sd 0.023 s, min 4.95, max 5.16; first ten 5.068, last ten 5.084 — no drift |
torque at RELEASED, untared |
0.132 N·cm mean, sd 0.026 (0.015–0.181); after the tare, tq_out 0.044 ± 0.025 |
| link | 53 190 lines in 2029 s (26/s: the heartbeat acks and the events), none lost |
So the delivery firmware, the device class and the trial loop hold for half
an hour and four hundred reversals without a park drifting, a tick budget
problem surfacing on the link, or a fault: the position stays on the
encoder count, the timing on the millisecond. What this does not exercise:
a hand or an animal on the lever (PULL/PUSH, the reward, the held-lever
case), the second board at the same time, or an hour-long session; a longer
run can be left overnight when the rig is otherwise idle.
What stage 1 (USB only, 36 V unplugged) touches on v1.0¶
| Net | v1.0 part | Value | Firmware expects at reset |
|---|---|---|---|
~{BUS_SW_EN} (GP9) |
R607 |
7.87 kΩ to +3.3V_MCU |
high — bus isolated |
PYC_UART_RX (GP1) |
R401 |
10 kΩ to +3.3V_MCU |
high |
~{HV_FAULT} (GP23, moved from GP29) |
R507 via R504 |
10 kΩ to +3.3V_MCU, 10 kΩ series |
high — no fault |
DAC_SDA_BYP / DAC_SCL_BYP (GP16/GP17, MCU side) |
R403 / R402 |
4.7 kΩ to +3.3V_MCU |
high |
HV_LATCH (GP24, was ~{INSTR_BUF_OE}) |
R503 |
10 kΩ series to U501 LATCH |
recorded, not judged: the switch is unpowered |
FW_TRIG0 / FW_TRIG1 (GP25/GP21) |
R404 / R405 |
10 kΩ to GND | driven by firmware; markers reach Digital-50 DIG0/DIG1 |
SW_RAIL_SENSE_DIV_2 (GP26) |
R408 / R409 |
1 kΩ / 1 kΩ | ≈ 0 V, rail off |
HV_SNS (GP28) |
R509 behind R505 |
249 Ω to GND, 1 kΩ series | ≈ 0 V (DIAG_EN low) |
BRAKE_ISENSE (GP27, new) |
R1819, R1818 |
249 Ω series, 110 kΩ to GND | ≈ 0 V, rail off |
ANGLE_OUT_MON (GP29, new) |
R810, R805 |
1 kΩ series, 100 kΩ to GND | ≈ 0 V, rail off |
| spare pads | — | GP20, GP22 only | pull both ways |
Expected result line: 20 checks (24 on v0.3, less seven spare pads and the buffer-enable strap, plus the two I2C straps and the two monitors). The count is derived, not yet observed; the first run settles it.
What stage 1 cannot see on v1.0, and why the rig plan's A1 was wrong¶
Both DAC80501Z parts (U801 angle at I2C 0x48 via R806; U1801 brake at
0x49 via R1807) run from +3.3V_FILT; the ADS8689 (U2002) runs from
+3.3V_SW and +5V_FILT; and every one of their bus lines — DAC_SDA/DAC_SCL,
TQ_SPI_*, TQ_CONVST_CS, TQ_CAL_EN, ~{BRAKE_FAULT} — is on the ISO side of
the bus switch. Nothing on I2C or SPI1 can answer on USB power alone. The
"I2C sees 0x48 and 0x49, SPI1 sees the ADS8689" checks belong to A2/A3 with the
rail up and the bus closed; the rig plan was corrected on 2026-10-04.
v1.0 facts firmware relies on (from the netlist, to be confirmed here)¶
LATCHis a GPIO for the first time.PowerSequence::powerUp()holds GP24 low through the ramp and raises it once+3.3V_SWis up; the latch probe inTMC5130Communicationshould PASS on this revision and the power-up method should read "LATCH held low, direct HV_EN assertion". Record both.- The DAC80501Z resets its data register to zero code, so the brake sink
commands 0 mA from the instant the switched domain comes up until firmware
writes it.
GAINmust be0x0101(REF-DIV 1, BUFF-GAIN 1) on both DACs; at 3.3 V supply a wrong GAIN forces the output to 0 V, not full scale. - Angle output: 2.5 V at the DAC → ×1.32 (
U802,R804/R803) → ÷2 (R807/R811) → 1.650 V full scale at the BNC (R809249 Ω) and atANGLE_OUT_MON(R8101 kΩ). - Brake monitor: 10 Ω shunt
R1816, ×4.125 (U1804,R1817/R1821), ÷2 (R1808/R1810) → 0–1.006 V for 0–48.78 mA atBRAKE_ISENSE; the ceilingV_LIMIT= 5 V × 6.34/56.24 ≈ 0.564 V across 10 Ω ≈ 56 mA, and~{BRAKE_FAULT}(U1803) is low only while limiting. - Torque:
VEXC10.000 V (U2004REF5010A,U2003,Q2002),VMID5.000 V (U2006), INA821 gain withR2001+R2002= 259 Ω, ÷3 (R2006–R2008), filterR201013 kΩ, bufferU2005; the ADS8689 seesAMP_VOUTthroughR2023249 Ω before the ÷2 (R2016/R2020) that feeds the BNC andTQ_AOUT_MON.R200375 kΩ is the shunt-cal resistor switched byTQ_CAL_EN. No pull-up onTQ_CONVST_CS(the design note claims one): firmware drives it high in the safe state. - The RUN pin is connected for the first time (
C615); SWD still is not.
Instrument port on v1.0¶
InstrumentPort-Digital-50 (J1301, FH12-50S), four SN74LV541A (U1301–U1304)
on +3.3V_MCU, always enabled. Mates the la5032-instrument-port-adapter
v2.0 through a Molex Premo-Flex 15020 FFC — both on hand, neither
continuity-checked nor permutation-checked as of 2026-10-04. The boot
signature is the first thing to look for: three 10 ms pulses on FW_TRIG1
(DIG1, CH1) then eight 1 ms pulses on FW_TRIG0 (DIG0, CH0); on CH30/CH31
instead if the cable reverses. Record the outcome in the adapter's bring-up
log, not here.
Desk rig — boards 8 and 9 (v1.0 units C and D) — 2026-10-12¶
Peter's desk rig, built for verifying firmware and task changes before they
reach Rig 1: two further v1.0 boards on a second Breakout 1.2 (pyboard USB
serial 3968348E3233), both mechanisms with motor, encoder, limit switch,
brake and torque sensor on 36 V, an LA5032 on each board's instrument port,
everything on USB to Peter's Linux computer. Evidence:
mouse-joystick-firm/evidence/2026-10-12-1355-deskrig-bringup/
(meta.txt names every file). The rig's cabling is in joystick-rig,
Hardware/rig-connections.md.
Arrival and stage 1, 13:55–14:05¶
Both boards had never been powered: each enumerated as the RP2040 ROM
bootloader (2e8a:0003) and picotool info reported no program. Flashed with
picotool load -x --bus 1 --address N (the two bootloaders share the ROM's
fixed USB serial, so the USB address is what tells them apart), then the
stage 1 self test (examples/RP2040SelfTest, mouse_joystick_rev2, firmware
316f802) re-run with r on the console, USB only:
| Board 8 (unit C) | Board 9 (unit D) | |
|---|---|---|
| USB path on the desk hub | 1-5.1.1 |
1-5.1.2 |
| flash unique id | E4654C159F504534 |
E4654C159F234234 |
| stage 1 | 20 checks, 0 failed | 20 checks, 0 failed |
clk_sys measured |
133001 kHz | see log |
| boot cause | watchdog (picotool's reboot after load) | same |
| log | s1_selftest_rev2_boardA_E4654C159F504534.log |
s1_selftest_rev2_boardB_E4654C159F234234.log |
Both enumerate as manufacturer Janelia Research Campus, product
mouse-joystick-pcb: the first boards flashed since that string replaced
Orb Weaver.
Delivery build, 14:08¶
examples/PyControlLinkQp, mouse_joystick_qp_rev2, 316f802. Board 8
took it through pixi run upload (the 1200 baud touch, then picotool) in
7.3 s. On board 9 the touch worked and picotool then failed with
The RP2040 device returned an error: <unknown>, the failure board 7 showed
on 2026-10-07; a direct picotool load -x --bus 1 --address 51 on the board
it had left in BOOTSEL succeeded first time. The delivery build is silent on
USB, so the check that it runs is the id reply over the RJ-45 below.
Which board is on which port, 14:15¶
id;1 sent from the pyboard's REPL over each port (uart_id_probe.log):
| Breakout port | UART | Board | Reply |
|---|---|---|---|
1 (the hardware definition's left) |
UART(4) |
8, E4654C159F504534 |
hw=rev2 fw=316f802 proto=2 caps=motion+home+instr+hv+watch+fetch+stream+cfg+angout+brake+torque boot=reboot |
4 (right) |
UART(3) |
9, E4654C159F234234 |
the same |
One trial each through pyControl, 14:20–14:25¶
The pyboard was found on MicroPython 1.22.2 with pyControl 2.1.1 and no
joystick device files; its filesystem was copied off through the REPL
(pyboard-flash-before/, 18 files, 71 309 bytes) and MicroPython 1.26.1
(PYBV11-20250911-v1.26.1.dfu) written with dfu-util. The ST bootloader
only jumped to the new image after a second download with -s :leave. The
files survived. The rig's headless runner (joystick-rig,
pycontrol/tools/run_task_headless.py, --load-framework on the first run)
then uploaded the framework, the rig's hardware definition, the lean device
files and joystick_trial.py as committed that day, and ran one trial per
board with watch_timeout_ms=2000, iti_ms=1000, nobody at the lever
(run_right_1trial.log/.json, run_left_1trial.log/.json):
| Board 9, right, port 4 | Board 8, left, port 1 | |
|---|---|---|
hv on ack → HOMED |
1.39 s | 1.32 s |
present move, ack → TARGET |
0.36 s to −5.041° | 0.36 s to −5.041° |
RELEASED after brake 1.0 |
20 ms; torque at release −1.786 N·cm | 21 ms; torque at release 0.397 N·cm |
angle at NOMOVE (2.0 s later) |
−4.774° | −4.866° |
torque after the tare, at NOMOVE |
0.018 N·cm | 0.014 N·cm |
TRIAL |
NOMOVE, a_peak 0.267, tq_peak 0.085 |
NOMOVE, a_peak 0.176, tq_peak 0.037 |
park, ack → TARGET |
0.54 s to −0.091° | 0.51 s to 0.0° |
| session | complete, 1 trial, 0 resends, 0 bad frames, 0 naks | same |
Both mechanisms homed and presented at the first attempt, which is also the
check that each harness is the right one for its mechanism: a swapped harness
faults the homing. The torque reading at release before the tare (−1.786 and
0.397 N·cm) is the sensors' rest offset, as on units A and B; the task tares
before every watch. The 0.18–0.27° relaxation between RELEASED and NOMOVE
is the rotor settling under the brake, as measured on unit A on 2026-10-06.
Pull sign, 14:40¶
One trial per board with a 60 s watch and Peter's hand on the lever, under the
firmware default pull_sign +1 (pull_right_60s.log, pull_left_60s.log):
| Board 9, right | Board 8, left | |
|---|---|---|
| released at | −4.95° | −5.041° |
TOUCH |
1.55 s after release, torque 0.309 N·cm | 1.78 s, −0.319 N·cm |
| outcome | PUSH at −9.991°, 123 ms after the touch |
PUSH at −10.083°, 62 ms after |
TRIAL |
a_peak −5.041, tq_peak 0.383 |
a_peak −5.041, tq_peak −0.392 |
| park | −0.091° | 0.0° |
A hand pull is the decreasing-angle direction on both, as on every board so
far, so both went into src/BoardCalibration.hpp as −1.0. The left sensor
reads the pull negative and the right positive, the same split as units B
and A on Rig 1.
Confirmed with the calibrated build, 15:05–15:15 (pull_right_60s_signed.log,
pull_left_60s_signed.log; the firmware reports itself 316f802-dirty
until the calibration commit is tagged):
| Board 9, right | Board 8, left | |
|---|---|---|
TOUCH |
1.67 s after release, 0.304 N·cm | 2.26 s, −0.314 N·cm |
| outcome | PULL at −10.083°, 114 ms after the touch |
PULL at −10.083°, 44 ms after |
TRIAL |
a_peak +5.041, tq_peak 0.359; reward pulse fired |
a_peak +5.041, tq_peak −0.418; reward pulse fired |
| park | −0.091° | +0.084° |
Two runs on the right before that ended NOMOVE because nobody pulled in
the window; they are kept as the earlier pull_right_60s_signed attempts
only in the metadata, the files hold the confirming run.
Link loss, 15:30: the task's reply timeouts on hardware¶
The lost-reply case the PR 9 review raised, provoked by pulling the port 4
RJ-45 on board 9 (linkloss_a_*, linkloss_b_*, linkloss_c_* in the
evidence directory):
| Case | What happened | Task |
|---|---|---|
| a: link already unplugged at start | id unanswered |
no ack in identifying, resend 1/2/3 at 0.50, 1.00, 1.51 s; no reply in identifying - ending the session at 2.01 s; framework stopped at 2.26 s |
b: cable pulled ~3 s into a 10 s watch (event_timeout_ms 5000) |
no NOMOVE ever arrived |
no reply in watching - ending the session at 16.85 s, 15.01 s after the watch began; stopped at 17.10 s |
| board after b, read over the restored link | faults mask 1 host_timeout, hv=0, state FAULTED, angle −4.591° (the lever relaxed from −5.041° once the rail dropped), homed=1 |
|
| c: next normal session | run_start's clrfault cleared it |
home 1.06 s, present, release, NOMOVE, TRIAL, park 0.0°, session complete |
So a cut link ends the session cleanly on both paths instead of hanging, the board makes itself safe on its own within its 100 ms host-loss timeout, and the following session recovers without anyone touching the board. Before 2026-10-12 case a and case b both waited forever.
Two things that bit, for the next session¶
- Board 9 ignores the 1200 baud touch under the delivery build. Board 8
takes it (
pixi run uploadsucceeded three times); board 9 entered BOOTSEL on the touch once, from the self test, and then never again from the QP build: PlatformIO's upload failedThe RP2040 device returned an error: <unknown>or left the board running, a pyserial open at 1200 baud returned a broken pipe on the DTR ioctl, a termios open did nothing, andpicotool reboot -ffound no reset interface. This is what unit B did on 2026-10-07. Manual BOOTSEL (RJ-45 and USB out, button, USB in) thenpicotool load -x --bus 1 --address Nworks first time; the two ROM bootloaders share one USB serial, so the address is what selects a board. Board-specific, two of four v1.0 units, worth a look at the USB path. - The second pyboard wedged after a probe exec timed out on
UART(3), streaming raw-REPLOKacknowledgements at about 400 bytes/s with no input and ignoring every control key; only the RST button brought it back. The REPL probe and pyControl's runner must never overlap on the pyboard's one USB port, and a probe that times out must be followed by a reset before the runner is used.
Not yet measured on these two boards: the angle and torque outputs on a scope, and which LA5032 sits on which board (the beacon).
Board 4 — E4654C159F171E34 — v0.3, Stage 1¶
First v0.3 unit, first power-on 2026-09-09. Stage 1 passed: 24 checks, 0
failed. USB power only — 36 V not connected, RJ-45 not connected, nothing on
the stepper, encoder or limit-switch connectors. HV_EN low and the GPIO
isolation bus open throughout.
PCB revision: tag v0.3 (a3032a6).
Firmware: mouse-joystick-firm, examples/RP2040SelfTest, env
mouse_joystick_rev1 (MJ_BOARD_REV=1), built Sep 9 2026 13:49:10.
v0.3 is MJ_BOARD_REV=1, and that is not obvious from the env name¶
platformio.ini describes env:mouse_joystick_rev1 as "the rev 1 board (pcb
HEAD)", which reads as if no fabricated board matches it. A v0.3 board
does. Read off the v0.3 PCB's own pad-to-net map for U501:
U501 pad |
GPIO | v0.3 net |
|---|---|---|
| 13 | GP10 |
/mcu/DAC_SPI_SCK_ |
| 14 | GP11 |
/mcu/DAC_SPI_COPI_ |
| 16 | GP13 |
/mcu/~{DAC_CS_} |
All three are routed to the angle-output DAC, so building env:mouse_joystick
(MJ_BOARD_REV=0) against a v0.3 board would drive internal pull-ups and
pull-downs onto a live SPI bus and a chip select in the spare-pad test. The
bus switch happens to isolate them at reset, so it would not have damaged
anything on this board — but that is luck, not the intent, and it is why the
spare-pad count is 9 on v0.3 against 12 on v0.2, and the total 24 checks
against 27.
U501 pads 24, 25 and 26 — SWCLK, SWD, RUN — are still no-connect on
v0.3. There is no debugger and no reset button on this revision either.
Designators on v0.3, for the parts stage 1 actually tests¶
The self test prints v0.1 designators in its own output text. It says
R205 10k present and R207 249R; on this board those parts are R1905 and
R1907. Read the net names, not the printed refdes. Mapping confirmed against
the v0.3 schematic, not inferred:
| Net | v0.1 |
v0.3 |
|---|---|---|
~{BUS_SW_EN} pull-up, 7.87 kΩ |
R601 |
R601 — unchanged |
PYC_UART_RX pull-up, 10 kΩ |
R403 |
R403 — unchanged |
SW_RAIL_SENSE_DIV_2 divider, 1 kΩ/1 kΩ |
R401/R402 |
R401/R402 — unchanged |
~{INSTR_BUF_OE} pull-ups, 10 kΩ |
R1601/R1701/R1801 |
R1601/R1701/R1801/R2101 |
~{HV_FAULT} pull-up, 10 kΩ |
R205 |
R1905 |
HV_SNS bottom leg, 249 Ω |
R207 |
R1907 |
HV_SNS top leg, 40 kΩ |
R206 |
R1906 |
EN pull-down, 10 kΩ |
R204 |
R1904 |
LATCH strap, 1 kΩ |
R203 |
R1903 |
| 36 V high-side switch | U201 |
U1901 |
R1903 is the part that was depopulated on board 1 to release LATCH. If
that modification is ever wanted on a v0.3 unit, R1903 is the resistor —
R203 does not exist on this board.
There are four SN74LVC125A buffer packages on v0.3, not three.
U1601, U1701, U1801 and U2101, each with its own 10 kΩ OE pull-up. The
"three packages on this board" note at the top of this file describes rev 0.
Clocks, and the crystal¶
| Clock | Reported |
|---|---|
clk_ref (xosc) |
12 000 kHz |
clk_sys |
133 000 kHz, measured 133 001 kHz against clk_ref |
clk_peri / clk_usb / clk_adc |
48 000 kHz |
Crystal Y501, absolute — three 10 s k runs, host-timestamped. Over the
9 s tick span: −1.3, +42.4 and +91.6 ppm; per-tick medians −14.6, +70.8 and
+0.8 ppm. USB CDC scheduling jitter is order 1 ms over 9 s, i.e. ~110 ppm, so
all three are below the noise floor of the method — consistent with a
healthy ±30 ppm part but not a measurement of it, exactly as on board 1.
Independently, USB full speed needs 48 MHz within 0.25 % derived from Y501,
which bounds it regardless.
Method trap, and it produced a confident wrong answer first. Timestamping
the tick lines with pyserial read(512) makes every read wait out the full
timeout — read(n) returns early only when it has n bytes, and one tick
line is ~45 — so each timestamp is displaced by the timeout. That yielded a
stable, repeatable, entirely fictitious −3400 ppm, self-consistent across
nine intervals, and it changed with the timeout value rather than with the
board. Use blocking readline(). A crystal 3400 ppm slow is 1.4× outside
what USB full speed tolerates, so "the board is enumerating and streaming CDC
fine" was already enough to reject the number before the method was found.
QSPI flash U502 (W25Q128JVZPIQ, 16 MiB)¶
Unique ID read back over QSPI (0x4B) plausible, so the part is present,
selected and responding. Declared size 16 MiB. Address-aliasing probe at +2, +4
and +8 MiB: none aliases offset 0, so the fitted part really is at least 16 MiB
and not a smaller part wrapping its address space. picotool additionally
verified the whole image byte-for-byte after each of two writes.
RAM¶
Heap total 252 228 B, used 100 B, free 252 128 B. 16 KiB allocation and a four-pattern walk over it both clean.
Resistor-defined levels¶
Captured in the first instruction of setup(), internal pulls disabled, before
anything drove a pin. Each maps to one part, so a failure here is an assembly
defect and not firmware.
| Pin | Net | Read | Held by (v0.3 refdes) |
Verdict |
|---|---|---|---|---|
GP9 |
~{BUS_SW_EN} |
high | R601 7.87 kΩ to +3.3V_MCU |
ok — GPIO bus isolated at reset, which every later stage depends on |
GP24 |
~{INSTR_BUF_OE} |
high | R1601/R1701/R1801/R2101 10 kΩ |
ok — instrument port silent before firmware, all four packages |
GP1 |
PYC_UART_RX |
high | R403 10 kΩ to +3.3V_MCU |
ok — RJ-45 unplugged, so this is R403 and not an idle far-end TX |
GP29 |
~{HV_FAULT} |
high | R1905 10 kΩ to +3.3V_MCU |
ok — pull-up present, on the right rail, no latched 36 V fault |
Spare pads¶
All nine unrouted GPIOs — GP12, GP15, GP16, GP17, GP18, GP20,
GP22, GP23, GP27 — moved correctly under both an internal pull-up and an
internal pull-down. No solder defects on any of them. GP10, GP11 and GP13
are excluded on this revision because they carry the DAC bus; on v0.2 they
were testable this way and they are not any more.
ADC¶
12-bit, reference is ADC_AVDD tied directly to +3.3V_MCU with no separate
reference and no filter, so absolute accuracy is bounded by that rail. Four
samples — the boot run plus three a reads.
| Channel | Net | Reading |
|---|---|---|
GP26 / ADC0 |
SW_RAIL_SENSE_DIV_2 |
11-13 counts = 0.0089-0.0105 V at the pin = 0.018-0.021 V on +3.3V_SW — switched domain genuinely off, R401/R402 holding it there |
GP28 / ADC2 |
HV_SNS |
10-12 counts = 0.008-0.010 V across R1907 249 Ω. Reads zero regardless while HV_DIAG_EN is low, which stage 1 keeps it, so this confirms the quiet state and not the sense chain |
| ADC4 | internal temperature | 28.1, 29.9, 28.5, 29.0 °C — proves the ADC and ADC_AVDD are alive |
Reset reason, and the 1200 baud touch¶
Reported software (3) with watchdog caused reboot: yes after each picotool
upload, which is correct rather than a fault: picotool reboots the chip
through the watchdog. A cold power-on would read power-on; not taken on this
unit, because it needs USB unplugged and replugged.
The 1200 baud touch reset works on this board. The second upload was done
over /dev/ttyACM1 with the application running — no SW501, no unplugging.
That is worth recording rather than assuming, because RUN is still
no-connect on v0.3, so the touch and the B command are the only ways into
BOOTSEL that do not involve the button.
Not covered by stage 1 on this board¶
Everything past HV_EN: the switched rails, the TMC5130A, X1201, the motor,
the encoder, the limit switch. The whole angle-output chain, which is the
headline reason v0.3 was ordered — the DAC80501 and OPA197 run from
+5V_SW, so none of it can be reached without 36 V. The pyControl link beyond
GP1's idle level. Boot latency, which needs the LA5032 on FW_TRIG0/FW_TRIG1.
Stage 2 passed on board 4 — 2026-09-09, 36 V on, nothing else connected¶
examples/TMC5130Communication, env mouse_joystick_rev1, command s.
36 V on J1901; motor, encoder and limit-switch connectors all empty, RJ-45
unplugged. Four runs, all consistent.
| Step | Result |
|---|---|
rail off before HV_EN |
0.0161 V |
HV_EN via firmware staircase (R1903 straps LATCH high) |
+3.3V_SW up in 132-133 ms, peak 3.293-3.296 V, settled 3.2911 V |
HV_SNS |
0.0097-0.0113 V across R1907 249 Ω (12-14 counts) — at the offset floor, no measurable load |
~{HV_FAULT} |
high, no latched fault |
| GPIO isolation bus | closed, ~{TMC_DRV_EN} still high |
TMC5130A, driver disabled throughout:
| Register | Value | Reading |
|---|---|---|
IOIN (0x04) |
0x11000011, status 0x79 |
VERSION = 0x11, the TMC5130A signature |
IOIN.DRV_ENN |
1 | driver disabled, and GP3 reaches the chip through the bus switch |
IOIN.SD_MODE |
0 | motion-controller mode, tied to GND |
IOIN inputs |
REFL_STEP=1, REFR_DIR/ENCA/ENCB/ENCN/SWCOMP=0 |
REFL_STEP = 1 is correct on v0.3 — see below |
GSTAT (0x01) |
0x00000001 |
reset=1 expected on the first read after power-up, drv_err=0, uv_cp=0 |
GCONF (0x00) |
0x00000000 |
reset default |
CHOPCONF (0x6C) |
0x00000000 |
vsense = 0 at reset. This board fits 0.15 Ω, so stage 3 must set vsense = 1 and read it back before lowering ~{TMC_DRV_EN} |
DRV_STATUS (0x6F) |
0x80000000 |
stst — standstill, as it must be with the driver disabled |
XACTUAL (0x21) / X_ENC (0x39) |
0 / 0 |
Register checks: 5, 1 failed — IOIN against the firmware's expected
0x11000010. That failure is the firmware's, not the board's.
REFL_STEP = 1 with the switch unplugged is correct on v0.3 — RESOLVED for this revision¶
Read from the v0.3 .kicad_pcb, footprint U1301 (MAX6816):
U1301 pad 2 IN -> /motor_encoder_switch/limit_switch_debounced_input_vertical/IN
(reaches only J1301 pad 2 and this pin -- no external pull)
U1301 pad 3 OUT -> /LIMIT_SW
U1301 pad 4 -> +3.3V_SW
The v0.3 limit-switch block contains only C1301, U1301 and J1301.
There is no R1405 and no LIMIT_SW pull-down anywhere on v0.3 — the only
10 kΩ parts on the motor_encoder_switch sheet are R1101/R1102/R1103, and
those are pull-ups to +3.3V_SW on TMC_DIAG0, TMC_DIAG1 and
~{TMC_DRV_EN}.
So with the connector empty the MAX6816 must supply its own pull-up on IN, it
drives OUT high, and nothing can override a driven output. REFL_STEP = 1
is the only reading v0.3 can give. This is exactly the netlist argument
written for v0.2 on 2026-09-02 and then doubted when board 2 disagreed — on
v0.3 it is confirmed against the copper.
Board 4 agrees with board 1 (0x11000011). Across three units:
| Board | PCB | IOIN |
Odd input |
|---|---|---|---|
E4654C159F2C2534 |
v0.1/v0.2 | 0x11000011 |
REFL_STEP = 1 |
E4654C159F4D1334 |
v0.1/v0.2 | 0x11000014 |
ENCB = 1, REFL_STEP = 0 |
E4654C159F171E34 |
v0.3 | 0x11000011 |
REFL_STEP = 1 |
Two of three read REFL_STEP = 1, and board 2 is now the sole outlier on that
bit. Board 2's own stage 3 showed REFL driving 0→1 and 1→0 with the switch
fitted, so U1301's output path works there — which leaves board 2's
empty-connector 0 still unexplained. Its ENCB = 1 remains unique to it.
The standing instruction in this file not to "fix" the expected IOIN by
flipping a bit still stands, and I have not. The firmware's explanatory text
is fixed — on rev 1 it no longer names R1405 — but the check still fails
deliberately. The real fix is to stop asserting on REFL_STEP, ENCA, ENCB,
ENCN and SWCOMP at all: they legitimately depend on what is plugged in, so
no fixed expectation can be right. Assert VERSION, DRV_ENN and SD_MODE;
report the rest. Not done — it needs a decision.
LATCH baseline — FAIL as expected, and the probe was capable of a false PASS¶
Cold, the l probe reports what the plan predicted: rail came up, held
52-73 ms of 250 ms, peak 3.293-3.296 V, ~{HV_FAULT} LOW, fault
latched → [FAIL] this board still needs the workaround. R1903 is fitted,
so v0.3 behaves exactly like v0.2 and the firmware staircase stays required.
That closes the LATCH baseline item in .agents/PLANS.md.
The first run of that probe reported [PASS], and it was wrong. Run
immediately after s — which deliberately leaves the rail up —
probeLatchBehaviour() did powerDown(); delay(50); and then asserted HV_EN
into an already-charged 208 µF. With no inrush to supply the switch is asked
nothing, never trips, and the probe concludes the LATCH fix is present. Same
board, same firmware, minutes apart: PASS hot, FAIL cold.
This is the worst place in the firmware for that bug. l exists for exactly one
purpose — telling a new revision whether it needs the workaround — and it could
answer PASS on a board that needs it. Had it gone unnoticed it would have
removed the staircase from v1.0 planning on false evidence.
Measured: +36V_SW takes 691 ms to bleed down to where +3.3V_SW reads
off, with only board leakage as a load. The 50 ms delay was short by ~14×.
Fixed in mouse-joystick-firm: probeLatchBehaviour() and
profilePowerUp() now call the already-existing waitForRailOff() helper
plus a 200 ms margin — the helper was written for precisely this hazard and
documented it, and these two callers simply did not use it. LatchProbe gained
started_cold and discharge_ms, fix_present now requires started_cold,
and the report refuses to interpret a run that could not discharge. Re-verified
on hardware: run straight after s, it waits 691 ms and correctly reports FAIL.
Bench note¶
/dev/ttyACM0 is the pyControl pyboard; board 4 is /dev/ttyACM1. Omitting
the port from pixi run upload lets PlatformIO default to ttyACM0 and send
the 1200 baud touch to the pyboard, which fails with "No accessible
RP2040/RP2350 devices in BOOTSEL mode" and looks like a flaky reset. Always
pass the port, resolved through /dev/serial/by-id/.
Stage 3 passed on board 4 — 2026-09-09, everything connected¶
examples/MotionAndHoming, env mouse_joystick_rev1. Motor, encoder,
limit switch, 36 V, pyControl RJ-45, LA5032 on J1501 and the angle BNC on a
scope all connected. Gates driven one at a time rather than through s, so a
failure could be caught at the gate that produced it.
| Gate | Result |
|---|---|
| rail and bus | +3.3V_SW up in 132 ms, peak 3.301 V, firmware staircase; bus closed, ~{TMC_DRV_EN} high |
| TMC51X0 attached | VERSION = 0x11, motion-controller mode, GSTAT drv_err=0 uv_cp=0; ENC_CONST = +12.8 µsteps per quadrature count, decimal mode |
CHOPCONF.vsense |
CHOPCONF = 0x10030153, vsense reads back 1, TOFF nonzero |
| current | IRUN = 8 → 0.211 A RMS, IHOLD = 2 → 0.070 A RMS. Note this is half the settled ceiling — the thermal survey settled IRUN 17 = 0.421 A, and the example still ships 8. n sets it |
| driver enable | accepted, gated on the vsense read-back |
The encoder path is good — follow ratio 1.0000 — and g's failure was a starting position¶
g failed, and the failure is not a defect:
outbound commanded -1280, encoder measured -704, ratio 0.5500
return commanded +1280, encoder measured +1177, ratio 0.9195
residual after the round trip: 473 usteps (37.0 counts)
g always jogs away from the switch first, which on this rig is toward the
negative hard stop, and the mechanism was resting near it. The outbound move
drove into the stop partway: the encoder stopped at 4.95° while the ramp
generator kept stepping, the return ran unobstructed, and the 473 µstep residual
is exactly the open-loop position the motor gained while the mechanism was
blocked.
Two plain jogs the other way settle it:
+1280 commanded, +1280 measured, ratio 1.0000
+1280 commanded, +1280 measured, ratio 1.0000
X_ENC - XACTUAL = 473 usteps, constant across both moves
Ratio 1.0000 twice, exactly. The CPR, the 470 pF RC corner, the coupling and
the gear mesh are all fine on this board, and X_ENC - XACTUAL holding at a
constant 473 confirms the offset was taken once, during the stall, rather than
accumulating.
g's failure text is misleading and should be changed. It offers "a clean
2.0 or 0.5 means the CPR is wrong" and otherwise points at lost counts, the RC
corner and coupling slip — but it never names the most likely cause given that
the command itself jogs into the negative stop first. A ratio near 0.5 with an
asymmetric return and a residual matching the shortfall is a hard stop, not a
CPR error. Either home before the check, or jog positive to establish clearance
first, or name the stop as candidate one. Not changed — it needs a decision.
Limit switch witnessed with the motor, and its polarity is now settled¶
resting REFL_STEP: 0
engaged at: 4096 usteps (28.800 deg)
released at: 3840 usteps (27.000 deg)
switch throw: 256 usteps (1.80 deg)
triggered level = 1 (active high) -> SW_MODE.pol_stop = 0, non-inverted
The switch is normally closed: with the connector fitted and the mechanism
at rest it holds IN low and REFL_STEP reads 0; actuation opens it and
REFL_STEP goes to 1. That matches board 2's stage 3 exactly, and the path
J1301 → U1301 → TMC REFL works on this board.
This closes the IOIN open issue. With the switch unplugged board 4 read
REFL_STEP = 1; plugged in and at rest it reads 0. Both readings are correct,
and the empty-connector value is not a defect on either board:
| Connector | IOIN |
REFL_STEP |
Why |
|---|---|---|---|
| empty | 0x11000011 |
1 | MAX6816 supplies its own pull-up on IN and drives OUT high; no LIMIT_SW pull-down exists on v0.3 |
| fitted, at rest | 0x11000018 |
0 | normally-closed switch holds IN low |
| fitted, actuated | — | 1 | switch opens |
ENCA/ENCB behave the same way: empty they are floating CMOS inputs, fitted
they read a real quadrature state (ENCA=1, ENCB=0 at rest here). So the
firmware's fixed expected IOIN is wrong by construction — five of those bits
depend on what is plugged in. Board 2's ENCB=1 and REFL_STEP=0 with empty
connectors needs no assembly-defect explanation after all; it was a floating
input and a switch that had been left connected. The recommendation stands:
assert VERSION, DRV_ENN and SD_MODE, report the rest.
A useful consequence, and it is fail-safe in the right direction: an
unplugged or broken limit-switch cable reads as REFL_STEP = 1, i.e. as a
permanent limit, which refuses motion rather than allowing it.
Homing¶
Approach 0.200 rev/s, creep 0.010 rev/s — the repeatability figure is meaningless without these, because the MAX6816's debounce delay scales the offset with approach speed.
approach stopped by the switch at 4199 usteps (29.524 deg)
stage 1 XLATCH closing edge: 4199 usteps, overshoot past the latch 0 usteps
stage 2 XLATCH release edge: 3817 usteps (26.838 deg), creep -384 usteps
closing to release: 382 usteps (2.686 deg)
backed off to -1282 usteps, switch released
total 1467 ms
Position 0 is the release edge, taken at creep velocity, with the encoder zeroed there.
Repeatability, 5 counted runs after one throwaway: 5 of 5 succeeded, spread
26 µsteps = 0.183° = 2.0 encoder counts, at the velocities above. Per-run
release ENC_LATCH: 25, 38, 38, 38, 51 µsteps; closing-to-release 721, 725,
726, 714, 713 µsteps; 2530-2531 ms each. That is better than board 2's 4 counts.
Switch reporting delay — 44.61 ms, and it validates the two-stage homing¶
Measured with t: one approach at 0.200 rev/s and one at 0.010 rev/s, both
latched by the chip at the closing edge in one position frame.
fast latch: 719 usteps at 10240 usteps/s
slow latch: 285 usteps at 512 usteps/s
difference: 434 usteps -> reporting delay = 44.61 ms
Debounce plus signal path. The MAX6816's own debounce is ~40 ms, so this is the part behaving as specified rather than anything board-specific.
That delay is the whole reason homing has two stages: it costs 457 µsteps of
overshoot at the approach velocity and only 22.8 µsteps at creep, a 20:1
improvement, which is why the release edge taken at creep is the datum and the
closing edge is not. kHomeCreepBoundMicrosteps is 2560, comfortably above
the 457 µstep approach overshoot it has to clear — 5.6× margin, so the creep
stage cannot run out of bound before it finds the release edge.
Record any future homing repeatability figure with its velocities, because this delay is what makes the offset velocity-dependent.
Angle output, reset state — NOT YET MEASURED. The 150 mV was the scope.¶
There is no DAC80501 driver in the firmware, so nothing writes the DAC. With
+5V_SW up and the DAC untouched the BNC shows the reset-state output, which is
a real and useful number: DAC80501Z has a zero-scale power-on reset
(datasheet confirms the Z suffix; M is midscale), so the DAC sits at code 0
and the BNC should show the OPA197's output floor. Design expectation is
~25 mV, with the valid angle range mapped to start at 50 mV so a driven 0 V
stays out-of-band.
A scope on the BNC read 150 mV, and that number is void. It read the same 150 mV with the BNC cable disconnected, so it is the instrument's own baseline — channel offset, or an attenuation/coupling setting — and says nothing whatever about the board. Recorded here only so nobody later finds "150 mV" in a note and treats it as a measured floor. The angle output floor on this board is still unmeasured.
Take it with a DMM on DC volts, centre to shell at the BNC: no attenuation setting, no coupling question, resolution to 0.1 mV. That is also the instrument the per-board gain/offset fit needs, so it is not a detour. A scope is the wrong tool for a DC level of tens of millivolts, and this is exactly how it goes wrong.
If a scope is used anyway, three settings all shift a reading like this by
roughly the factor in question, which is why the number was ambiguous before the
cable-off check settled it: probe attenuation (10× against a 1:1 BNC cable
multiplies by ten), AC coupling (removes the DC level entirely), and 50 Ω input
termination (divides by 5.98 against the 249 Ω series output resistor, and loads
the OPA197 besides).
Two other things produce 0 V here and must not be read as a legitimate end of
travel: an unpowered output section, held down by the 100 kΩ R2005, and a
reference alarm from a misconfigured REF-DIV.
Left energised deliberately¶
The board was left with the driver disabled and the rail up on purpose, so
+5V_SW keeps the DAC80501/OPA197 section powered for that measurement.
Stage 4 over the pyControl RJ-45 has not been run on this board.
Two stage 2/3 check defects fixed, 2026-09-09 — and a backlash figure¶
Both were checks that failed on a correctly working board, which is the failure mode that costs the most time: it trains you to ignore the check.
IOIN no longer asserts on connector-dependent bits¶
Stage 2's register check compared the whole IOIN low byte against a single
expected 0x11000010. Five of those eight bits are driven by whatever is
plugged into the motor, encoder and limit-switch connectors, so no fixed value
can be right, and the check failed on every board that was actually wired up.
The two v0.2 units "disagreeing" was chased as a possible pair of assembly
defects.
Now only the four board-invariant fields are asserted — VERSION, DRV_ENN,
SD_MODE and REFR_DIR (a hard GND tie, not a connector). The connector bits
are still printed, because that is how you watch a switch actuate or an encoder
turn; they are just not a verdict. Stage 2 on board 4 fully connected is now
5 checks, 0 failed.
The host tests were encoding the bug — they asserted that 0x11000011 and
0x11000018 must FAIL, and both are correct readings from real hardware. They
now assert those pass, alongside board 2's 0x11000014, plus a mask test that
every low-byte bit is either asserted or explicitly connector-dependent and
never both.
A live demonstration of why this had to change: across two runs minutes apart on
the same board, IOIN read 0x11000018 then 0x11000010 — ENCA went 1 to 0
purely because the shaft was at a different position.
g now separates the mechanism from the encoder¶
The round-trip check blamed the encoder for a hard stop. The legs_clean guard
did not catch it because driving into an end stop open-loop still reports
kTargetReached — the ramp generator only counts the steps it issued, so with
partial clearance the move "succeeds" and the encoder asymmetry is the only
witness.
The verdict now classifies the round trip first, through
classifyRoundTrip() in mouse-joystick-firm/src/Conversions.hpp. It lives
there rather than beside MoveReport because MotionSetup.hpp needs
Arduino.h and TMC51X0.hpp, and this arithmetic has to be reachable from the
host tests — which now pin it with the real measured numbers.
Measured backlash, which is new data. Reversal costs a fixed amount, and which leg pays it depends on which way the mechanism was last moving — homing ends with a back-off, so the first leg may already be taken up. That makes legitimate asymmetry one-sided, not symmetric:
| out / back (µsteps) | apart | mechanism |
|---|---|---|
| 1203 / 1190 | 1.1% | free |
| 1165 / 1177 | 1.0% | free |
| 1280 / 1191 | 7.0% | free — one backlash-worth, ~89 µsteps = 7 counts |
| 704 / 1177 | 40.2% | outbound against the far hard stop |
So the envelope is "up to ~7% is backlash, ~40% is a stop". The threshold is 20%, roughly the geometric middle. A first draft used 10%, which did not misclassify anything observed — 7.0% is inside it — but 1.4× margin over measured backlash is thin, and a slightly stiffer gear pair would cross it.
A genuine CPR error lowers both legs equally and therefore stays symmetric, which keeps that diagnosis reachable. That is deliberate.
g also now says up front that it jogs toward the far hard stop first, so run
it from mid-travel or straight after h.
Bench trap: picotool's flash write is intermittently flaky on this board¶
After many reflashes in one session, picotool began failing mid-write with
ERROR: The RP2040 device returned an error: <unknown>, and PlatformIO reported
it as a failed upload. It is not a bad flash sector and not a dead board:
picotool info -a -> RP2040, revision B2, flash size 16384K,
flash id 0xE4654C159F171E34
The bootrom reads the flash correctly, and the failures are size-independent —
in one pass a 183 KB image wrote, a 209 KB image failed, and a 251 KB image
wrote. So it is the USB transfer, not the part. lsblk also showed a stale
sdd at 0 B where RPI-RP2 had been, which is the host holding a dead
block device across re-enumerations and is a good tell.
Recovery: just retry the load, in a loop. Driving picotool load directly
with a retry and then picotool reboot is more reliable than
pio run -t upload, because it skips the 1200 baud touch once the board is
already in BOOTSEL. A physical re-plug was never needed. Note that a genuine MCU
power cycle needs both USB and the RJ-45 out, since the MCU domain is ORed
from both.
IRUN 17 run, 2026-09-09 — the 36 V transient burst, and a repeatability surprise¶
Everything above was taken at IRUN 8. This run set IRUN 17 = 0.421 A RMS
(n, then c to re-verify), the ceiling settled by the thermal survey, with
vsense read back as 1 before the driver was enabled.
Homing repeatability at IRUN 17 is 0 encoder counts¶
IRUN |
A RMS | runs | spread | closing-to-release |
|---|---|---|---|---|
| 8 | 0.211 | 5 of 5 | 26 µsteps = 2.0 counts | 713-726 µsteps |
| 17 | 0.421 | 5 of 5 | 0 µsteps = 0.0 counts | 620-628 µsteps |
Both at 0.200 rev/s approach, 0.010 rev/s creep. At IRUN 17 every one of the
five counted runs latched at exactly 12 µsteps — not a spread that rounded
to zero, an identical reading five times. The closing-to-release figure also
dropped by ~90 µsteps and tightened.
The likely reading is mechanism compliance during the creep: at twice the
holding torque the gear pair is taken up harder and the datum stops moving. It
is a reason to prefer 17 beyond the current-headroom argument, and it means
any repeatability figure has to be quoted with its IRUN as well as its
velocities.
No faults across the whole run: ot=0, otpw=0, drv_err=0 on every poll,
DRV_STATUS stst=1 with cs_actual=2 at standstill.
What was driven, for the scope capture¶
Probing +36V_SW. Transients generated at IRUN 17, in order:
- 6 single jogs alternating direction — 6 accel/decel cycles at jog velocity
- 1
ground trip — 2 more, with a reversal between them - 4 further jogs closing on the switch, one ending stopped by the switch
- 1
Wwitness sweep — 8 short 128 µstep steps onto the switch and off - 1
hplusH's throwaway and 5 counted runs = 7 approaches at 0.200 rev/s, each ending in a hard stop at the switch — the fastest decelerations available, and the ones that matter most for inductive kick
Trap: H and W both refuse or mislead from the wrong starting position¶
Two commands were silently ineffective before this was noticed, and both are the
same class of problem as g's false CPR verdict:
Hrefuses outright after a reflash —Run 'b', 'c' and 'w' first.The switch-polarity witness does not survive a reflash, and until it is re-runhandHboth decline. An earlierHin this session was issued and did nothing; the transient burst it was supposed to generate never happened, and the only symptom was the absence of output.Wfailed withREFL_STEP never changedand pointed atU1301's output joint — "the open question the two rig boards left behind". It was nothing of the kind. The sweep is bounded, it used 3456 µsteps (24.3°) without reaching the switch, and the mechanism had simply drifted ~40° away during the jog burst. Fourjjogs brought it into range andREFL_STEPasserted normally.
So W's failure text names a hardware suspect before it names its own
bound. Same shape as g: a command that fails on starting position while
blaming a component. Worth the same treatment if it bites again.
Confirmation the switch is fine, in a completely different position frame: engaged at 7068 µsteps, released at 6812, throw 256 µsteps — identical to the 256 µsteps measured earlier at a different frame offset.
36 V transient, 2026-09-09 — observed flat at 36 V, and theory agrees¶
Scope on +36V_SW through the whole IRUN 17 burst above. Observed: right on
36 V throughout, no dips or spikes seen.
The DC level reading 36 V is itself the useful half of that: it confirms the probe scaling, which after the BNC episode the same day was the thing most worth confirming before any peak number got recorded.
Theory says the motor cannot lift this rail, so a flat trace is the expected
result rather than a lucky one. The board carries C1213+C1214 = 200 µF
of bulk on +36V_SW:
bulk energy at 36 V = 0.5 * 200e-6 * 36^2 = 130 mJ
coil energy, both phases= 0.5 * 6e-3 * 0.6^2 * 2 = 2.2 mJ (L estimated)
worst case, all coil energy into the bulk with no loss:
V = sqrt(2 * 132.2e-3 / 200e-6) = 36.4 V -> a 0.4 V rise
The motor's inductance is not recorded anywhere in this repository — 6 mH per phase is a NEMA-8-class estimate, so treat the 2.2 mJ as an order of magnitude. It does not matter: the coil energy is ~1.7% of the bulk energy, so even a 3× error in L keeps the rise around a volt. 200 µF is simply an enormous stiffness against a NEMA 8's coils. The TMC5130A also recirculates most of that energy through the chopper rather than returning it to the rail.
What this does and does not establish¶
It does not yet bound the peak. A human watching a live trace cannot see a microsecond-scale event: a DSO in normal acquisition mode captures a small fraction of elapsed time between acquisitions, so a narrow spike falls in the dead time. "Watched it and saw nothing" is consistent with a flat rail and also with a 2 µs excursion every few hundred milliseconds.
One cheap step converts it into a bound: arm a single-shot trigger at 40 V, rising, then re-run the burst. If it never triggers, the peak was below 40 V for the whole armed window — 9 V of margin to the TMC5130A's 49 V inductive-load absolute maximum, and the TVS populate question is answered with evidence rather than impression. Peak-detect or min/max envelope mode does the same job, because it uses the acquisition hardware's envelope rather than the displayed samples.
Do not close the TVS item on the eyeball result. Note also that the RC
snubber footprint across the driver's VS/GND and the entry TVS position are
both already on the board, so either outcome is a populate decision, not a
respin.
And note the scope was on +36V_SW, downstream of the high-side switch. An
entry TVS also covers supply-side events this test does not produce at all —
hot-plugging the 36 V supply, and cable-inductance ringing on insertion. The
TMC5130A datasheet's own warning is about supply ringing from stray inductance,
which is an input-side phenomenon. The motor-driven case looks clear; the
supply-side case has not been tested.
DAC80501 driver written and exercised on hardware, 2026-09-09 evening¶
mouse-joystick-firm/src/DAC80501.hpp + src/DAC80501Registers.hpp +
src/DAC80501/, with examples/AngleOutput as its bring-up console. Every
register address, reset value, field position and I2C address came out of TI
SBAS794E rather than from memory; a local copy is at
kicad-libs/tmp/dac60501.pdf, which despite the filename is the family
datasheet and covers the 80501 throughout.
Supports both transports, decided later by the SPI2C strap: SpiTransport
and I2cTransport behind one Transport interface, injected into the device
class so the second DAC80501 instance at v1.0 (the brake current setpoint) can
share a bus on its own chip select.
Verified on board 4 over SPI1, rail up, bus closed¶
[1] rail +3.3V_SW up in 132 ms
[2] bus switch closed, GP10/GP11/GP13 now reach the DAC
[3] SPI1 SCK GP10, TX GP11, SYNC GP13
[4] DAC GAIN 0x101, full scale 2.5000 V at the DAC, 3.3000 V at the BNC
| Commanded | DAC | BNC expected |
|---|---|---|
| code 0 | 0.000000 V | 0.000000 V |
| code 0x8000 | 1.250000 V | 1.650000 V |
| code 0xFFFF | 2.499962 V | 3.299950 V |
Full-scale code lands one LSB short of full scale, as it must — the denominator
is 65536, not 65535. One LSB is 38.15 µV at the DAC and 50.35 µV at the BNC,
against the AD7616-P's 152.6 µV OneBox step, so the DAC resolves about 3×
finer than the OneBox can record. That is the number the span argument turns on.
Nothing here is a measured output voltage. ANGLE_OUT reaches no RP2040 ADC
on v0.3 — the three ADC pins are the rail sense, HV_SNS and a spare — and over
SPI the DAC cannot be read back either. So the whole column above is what the
board was asked for. The gain and offset fit still needs a DMM on the BNC,
and examples/AngleOutput has an s command that parks at 0, midscale and full
scale for 15 s each to make that a one-command job.
The ordering interlock, and it was checked rather than assumed¶
GAIN resets to 0x0001 — a 5.0 V full scale from the 2.5 V internal
reference — while this board wants 2.5 V. A board that writes DAC data before
writing GAIN therefore asks for double. Dac80501::begin() writes CONFIG,
then GAIN, then SYNC, and setCode()/setVolts() refuse until it has
succeeded, so the mistake is unrepresentable rather than merely documented.
Exercised on hardware: after r (soft reset) the driver reports
configured: no and a full-scale write comes back Run 'b' first. The Z
orderable saves this from being a live hazard at power-up — it resets the data
register to zero scale — but an M part would sit at midscale of a possibly
wrong span, so the interlock does not lean on that.
verifyConfiguration() reports honestly, which is the point¶
On this board it returns kNotSupported and says so in as many words:
there is no SDO pin, nothing was checked, and the only real check on GAIN is
measuring the output. The result is a four-way enum — verified / mismatch /
read-failed / not-supported — rather than a bool, because "could not check" and
"checked and correct" collapsing into one false/true is how a board ends up
trusted on no evidence.
Left for the morning¶
- A DMM on the BNC. Three-point fit, and the output floor. Expected floor is ~25 mV; the 150 mV scope reading earlier the same day was the instrument's own baseline and is void.
VREFIO= 2.500 V at the pin, also a DMM job.GAIN0x0101against0x0000. Both give a 2.5 V full scale by different routes and the host tests pin that they are equal in span.0x0101runs the output buffer at a gain of two, doubling that stage's offset and noise;0x0000keeps the DAC core at the full 2.5 V reference. With a meter on the output the quieter one is measurable, and it is a one-constant change inexamples/AngleOutput. The pcb plan specifies0x0101, so that is what is built.- The board was left powered down — DAC at zero scale, bus open,
HV_ENlow — rather than energised unattended overnight.bbrings it all back.
ANGLE OUTPUT CHARACTERISED — 2026-09-10, board 4. The fit is essentially unity.¶
Fluke 87 True RMS on DC volts, at the BNC, centre to shell. This is the
measurement v0.3 was ordered for. examples/AngleOutput over SPI1, GAIN
0x0101, 2.5 V DAC full scale into the OPA197's 1.32 gain.
| Code | Ideal at the BNC | Measured | Residual vs the model |
|---|---|---|---|
| 0 | 0.000 mV | 4.5 mV | clamped, see below |
| 993 | 50.002 mV | 50.2 mV | −0.00 mV |
| 32768 | 1650.000 mV | 1.650 V | −0.20 mV |
| 65535 | 3299.950 mV | 3.300 V | −0.15 mV |
One model fits all four points: unity gain, a +0.2 mV offset, and a hard clamp at 4.5 mV. Every residual is inside the meter's own resolution at that range.
- Gain error is under 0.03%, which is the display resolution rather than a measured error. Span, full minus midscale, is 1.650 V against 1.650 expected. There is no meaningful per-board gain to calibrate out.
- Offset is +0.2 mV. Visible only at code 993; at midscale and full scale it is below the fourth digit.
- The 4.5 mV floor is a CLAMP, not an offset. If it were additive, midscale
would read 1.6545 V. It reads 1.650. So the
OPA197simply cannot swing below 4.5 mV, and the transfer function is linear above that. - Linearity holds at the design's valid-range boundary. Code 993 is the 50 mV valid-range start, and it reads 0.4% high — the clamp's influence has decayed from 4.5 mV at code 0 to 0.2 mV by 50 mV. So starting the valid range at 50 mV is sound.
What this does to the plan¶
The Fit gain and offset per board serial item is answered, and it turned out
to be nearly a no-op. The plan warned that "every one of these endpoints halves
at v1.0 and the fit must be redone rather than rescaled" — true, but if the fit
is unity to the meter's resolution there is very little to redo. The only
per-board number worth carrying is the clamp.
It also sharpens measurement 1, the ÷2 divider decision. That question was whether halving the span costs anything beyond the predicted one bit. Half of it is now settled: there is no span error to preserve, so the divider costs exactly one bit and nothing more. The other half — whether the recorded trace is already OneBox-quantisation-limited — is a noise question and needs a recording, not a meter. The Fluke cannot answer it.
The floor is 5.6x smaller than designed, and it breaks the three-level scheme¶
docs/encoder-analog-output.md assumes "the OPA197's output stage sits about
25 mV off the negative rail at light load" and builds an out-of-band scheme on
it. Measured: 4.5 mV. The three levels the design wants:
| Level | Means | Intended | Actual |
|---|---|---|---|
| true 0 V | output section unpowered, or a reference alarm | 0 V | 0 V |
| the floor at code 0 | firmware-signalled fault | ~25 mV | 4.5 mV |
| >= 50 mV | valid angle | 50 mV | 50 mV |
The valid-range gap is fine — better than designed, 45.5 mV instead of 25. The
gap that broke is the other one: a firmware fault and a dead output section are
now 4.5 mV apart instead of 25 mV, and v1.0's ÷2 divider halves that to
2.25 mV. Both hardware failures read as a legitimate end of travel in a
researcher's dataset, and the doc's stated mitigation is that STATUS reports
the output invalid — which, over SPI, it cannot do for a reference alarm. The
analog level was the backup and it just got 5.6x weaker.
Recommended fix, firmware-only and free: stop inheriting the fault level from the op amp's swing limit and choose it. Drive code 497 = 25 mV as the fault level instead of code 0:
| Level | Volts | Code | Distance from 0 V |
|---|---|---|---|
| hardware dead | 0 V | — | — |
| firmware fault | 25 mV | 497 | 164 OneBox codes |
| lowest valid angle | 50 mV | 993 | 328 OneBox codes |
That restores the 25 mV separations deliberately rather than by luck, and
survives the v1.0 halving at 12.5 mV, still 82 OneBox codes. Not applied — it
changes what recorded data means, so it is Peter's call. The reasoning in
encoder-analog-output.md about the "~25 mV floor" needs correcting either way.
Earlier the same day, before the meter: what the scope could and could not do¶
First real measurements of ANGLE_OUT, board 4, examples/AngleOutput over
SPI1. Scope on the BNC directly, no probe.
| Code | DAC commanded | BNC expected | BNC measured |
|---|---|---|---|
0x0000 |
0.000000 V | the output floor | at 0 V to the display's resolution |
0x8000 |
1.250000 V | 1.650000 V | 1.66 V |
0xFFFF |
2.499962 V | 3.299950 V | 3.29 V |
The signal chain is proven. The DAC80501, the 2-pole filter, the OPA197
and the 249 Ω output resistor all work, GAIN 0x0101 really is producing a
2.5 V DAC full scale, and there is no gross error — no wrong gain field, no
missing stage, no factor of two. That was the safety question and it is answered.
It is NOT the gain and offset fit, and the scope cannot make it one¶
Midscale came in +10 mV and full scale −10 mV — opposite directions, so neither a fixed offset nor a clean gain error. The span, full minus midscale, reads 1.63 V against 1.650 expected, about 1.2% low.
That is inside the instrument. A DSO's vertical accuracy is typically ±1.5-3% of full scale, so ±66 mV at a setting spanning 3.3 V, and the readings are quoted to three digits which is ±5 mV each and ±10 mV on the difference. The board's own span tolerance is the same order: the gain-setting resistors are 1% parts and the DAC has its own reference and gain error, so a genuine ~1% span error is expected and is exactly what per-board calibration absorbs.
So the scope confirmed the span to about 1% and could not do better. A gain/offset fit for a 16-bit converter needs 0.01-0.1%. Superseded the same day by the Fluke 87 readings above, which put the true values at 1.650 and 3.300 — so the scope was off by +10 mV and -10 mV respectively, its own vertical accuracy, and the board was right both times. Do not read the scope table as a calibration; the Fluke table is the calibration.
The output floor: small, and that is the right direction¶
At code 0 the scope showed no difference between the BNC connected and disconnected, both sitting at 0 V to the display's resolution. The vertical setting was 500 mV/div, so a 25 mV floor is one twentieth of a division and a 50 mV floor is a tenth — neither is reliably visible. This bounds the floor below roughly 100 mV and no better.
That is enough to kill the 150 mV, and not enough to establish the criterion
that matters. The floor is the out-of-band fault level, and the valid angle
range is mapped to start at 50 mV above it, so what has to be shown is
floor < 50 mV with margin — and a 500 mV/div reading cannot show it. The
design's ~25 mV estimate is neither confirmed nor contradicted. Corrected
2026-09-10: an earlier version of this entry claimed a 50 mV bound, which the
vertical setting does not support.
Worth carrying into v1.0: the ÷2 divider halves the floor along with the span, so whatever the floor turns out to be, v1.0 sees half of it and the out-of-band margin halves with it.
RESOLVED: yesterday's 150 mV was the instrument, twice over¶
The 150 mV "output floor" recorded on 2026-09-09 does not reproduce on a correctly configured channel. Two separate instrument errors were in play, and both are worth recognising by their signature:
- The channel's own baseline. It read 150 mV with the BNC cable unplugged, which is what finally condemned it.
- A 50 Ω input termination. With the channel on 50 Ω, midscale read
270 mV where 1.650 V was expected. That is not a mystery — the 249 Ω
series output resistor (
R2007) forms a divider with the termination:1.650 × 50/(249+50) = 0.276 V, against 270 mV measured, agreeing to 2%. Switching to 1 MΩ moved it straight to 1.66 V.
The second one is the more useful lesson: a reading that is low by almost
exactly 5.98× on this output is a 50 Ω termination, not a board fault. The
agreement to 2% also independently confirmed that R2007 is fitted and close to
value.
The monitor tap, traced in copper¶
ANGLE_OUT_MON on J1501 pad 32 is wired on v0.3 — confirmed against the
v0.3 .kicad_pcb, not the docs. The topology around the op-amp output node:
| Part | From | To |
|---|---|---|
R2007 249 Ω |
AMP_VOUT |
ANGLE_OUT — the BNC |
R2008 1 kΩ |
AMP_VOUT |
ANGLE_OUT_MON — J1501 pad 32 |
R2005 100 kΩ |
AMP_VOUT |
GND — the output pull-down |
So the monitor taps the op-amp side of the series resistor, through 1 kΩ.
Into a high-impedance instrument that is a 0.1% divider, negligible. This closes
the topology half of the Confirm ANGLE_OUT_MON tracks ANGLE_OUT plan item;
the "tracks" half still wants a simultaneous measurement.
A consequence with practical value: the monitor reads the true op-amp output regardless of what loads the BNC. An instrument on pad 32 during the 50 Ω episode would have read 1.65 V while the BNC read 0.27 V, and the disagreement would have named the loading error immediately instead of it taking a calculation to spot.
MEASUREMENT 2 CLOSED — +36V_SW peak stayed below 37 V, 2026-09-10¶
Owed since August, and this is the bound rather than an impression. Scope on
+36V_SW, single-shot trigger armed at 37 V rising — only 1 V above the
nominal rail — through a full transient burst at IRUN 17 = 0.421 A RMS:
- 7 approaches at 0.200 rev/s, each ending in a hard stop at the limit switch — the fastest decelerations this rig can produce
- 6 direction reversals plus a
ground trip at jog velocity - the
HV_ENstaircase turn-on, twice
The scope never triggered. So the peak stayed under 37 V for the whole armed window.
| Limit | Value | Margin from 37 V |
|---|---|---|
| TMC5130A absolute max, inductive load | 49 V | 12 V |
| TMC5130A operating max | 46 V | 9 V |
| Brake pass device (future) | 60 V | 23 V |
The TVS is not needed for the motor-driven case. That was the populate question, and it is answered with evidence.
A bonus from the trigger sitting only 1 V above nominal: switching ripple on
+36V_SW is under 1 V, which is the 200 µF of C1213/C1214 doing its job.
This matches the energy argument recorded on 2026-09-09 rather than merely being consistent with it: 200 µF at 36 V holds 130 mJ against roughly 2.2 mJ in both motor phases, so even dumping every joule of coil energy into the bulk with no loss predicts a ~0.4 V rise. Prediction and measurement agree.
Two things this does NOT cover¶
- Supply-side events. The probe was on
+36V_SW, downstream of the high-side switch. Hot-plugging the 36 V supply and cable-inductance ringing on insertion are a different energy source, and the TMC5130A datasheet's own warning is about supply ringing from stray inductance — an input-side phenomenon. The entry TVS position covers that case and this test says nothing about it. Both the entry TVS and the driver'sVS/GNDRC snubber footprints exist, so either stays a populate decision rather than a respin. - Single-shot captures the FIRST excursion and stops. Nothing fired, so there was no first excursion and the bound holds — but had it triggered, the captured event would not necessarily have been the worst one.
VREFIO = 2.500 V, measured 2026-09-10¶
Fluke 87 at the DAC's VSSOP-10 pin 10. Exactly nominal, so the internal
reference is good and REF-DIV is not misconfigured. Over SPI this is the only
check on the reference that exists — STATUS.REF-ALARM is unreadable — which is
why it is worth taking with a meter rather than inferring.
Motor coupling into the angle channel: none detectable, 2026-09-10¶
Plan item 1c's real question — does motor switching get into the analog output —
tested with a static setpoint, which is the sensitive way to do it: the angle
output was parked at a fixed 1.650 V (code 0x8000) so the intended signal is
pure DC and anything AC is coupling. IRUN 17 = 0.421 A RMS throughout.
Scope on the BNC, AC coupled, 1 MΩ, 10 mV/div, 1 ms/div, peak-to-peak measurement.
| Condition | pk-pk |
|---|---|
| driver off, chopper stopped | 3.54 mV |
driver on, motor stationary (chopper running, cs_actual 2, stst 1) |
3.55 mV |
| 8 alternating jogs at jog velocity | no increase observed |
| 6 fast approaches at 0.200 rev/s, each into a hard stop | no increase observed |
| BNC cable disconnected | same value |
The 3.5 mV is the scope's own noise floor, not the board. Confirmed two ways: it is identical with the chopper running and stopped (a 10 µV difference, which is a thirtieth of one scope LSB), and identical with the cable disconnected. Recorded explicitly because the same trap produced a phantom 150 mV "output floor" the day before — a number that turned out to belong to the instrument.
No gross motor coupling exists. That is what 1c was for: the plan's own words are that a chopper-correlated artefact "would be far larger than one bit and makes the span question academic until fixed." Nothing of that size is present, at standstill or under the fastest motion this rig produces.
What is bounded, and what is not¶
A scope cannot see the noise this design actually cares about:
| predicted board noise | 19 µV RMS |
AD7616-P quantisation |
44 µV RMS |
| scope LSB at 10 mV/div | 312 µV |
| scope LSB at 1 mV/div | 31 µV |
Even at 1 mV/div the scope's quantisation alone exceeds the predicted board
noise, so no scope result can confirm the 19 µV figure. Taking the observed
floor as ~590 µV RMS and assuming a detectable change of a few percent, this
bounds board noise plus coupling at roughly < 200 µV RMS — about 4x the
AD7616-P's own quantisation. Useful, and not the same as the prediction.
So measurement 1's noise half remains open and needs the OneBox itself, or another 16-bit capture. The span half is settled (see the unity-gain fit), so what is left is specifically: is the recorded trace quantisation-limited with margin to spare? Only the recording instrument can answer that.
Chopper frequency was not swept¶
Plan item 1c also asks to "sweep the motor chopper frequency while recording the angle channel". Not done — this was at the driver's default chopper configuration only. The null result above therefore covers the operating point, not the whole sweep. Worth revisiting only if a chopper artefact ever appears.
Firmware change this needed¶
Neither example could run this test: examples/AngleOutput has the DAC and no
motion, examples/MotionAndHoming had the motion and no DAC. The DAC is now in
MotionAndHoming behind #if MJ_BOARD_REV != 0, on SPI1 with GP12 as the
unused RX, configured in the b gate after the rail and bus so the chip
select is never driven into an unpowered part. New commands A/Z/F park the
output at midscale, zero and full scale. x calls dac.invalidate(), because
cycling the rail resets GAIN to its 5.0 V full scale.
LIVE ANGLE OUTPUT WORKING — 1 kHz measured, 2026-09-10, board 4¶
The encoder now drives the analog output continuously. examples/MotionAndHoming
command L, DAC on SPI1, homed against the switch first so the angle has a
datum. Driver disabled so the motor freewheels and the joystick moves by
hand; X_ENC still counts because the TMC5130A reads the encoder independently
of its output stage.
Mapping: 0 to 90° of excursion onto 0.050 to 3.300 V, so one encoder count (0.09°) is 3.25 mV at the BNC. The 90° span is deliberately wider than the ~60° mechanism — the hard stops are movable and the travel is not settled, so a span fitted to one measured swing would silently misreport a re-adjusted stop.
The rate is measured, not assumed¶
docs/encoder-analog-output.md asked for the achieved rate as "a measured
claim, not a target". Two runs:
| Run | Updates | Elapsed | Achieved | Worst loop |
|---|---|---|---|---|
| 1 | 202 496 | 202.5 s | 1000.0 Hz | 473 µs |
| 2 | 632 485 | 632.5 s | 1000.0 Hz | 527 µs |
835 000 updates over 14 minutes with zero budget overruns. Worst iteration 527 µs against the 1000 µs budget, so 47% headroom on the worst case.
1 kHz is the right rate and 2 kHz is not available. A 527 µs worst case is already most of a 500 µs budget, and 4 kHz is out of reach. That vindicates the design doc's original "roughly 1 kHz of real angle bandwidth is what the loop can sustain alongside motor control" — and it corrects an estimate made earlier the same day in this session, which computed a 77 kHz ceiling from 10 µs of encoder read plus 3 µs of DAC write. Measured worst case is 36x that: raw bus time ignores the TMC51X0 library's overhead, SPI transaction setup, and USB servicing. Do not quote bus arithmetic as a loop rate.
A useful consequence for the SPI-vs-I2C decision: the loop is dominated by things that are not the DAC write, so I2C at 1 MHz would add ~33 µs to a 527 µs worst case — about 6%. The bandwidth objection to the I2C strap is now measured to be negligible, not merely argued away.
The fast-flick capture, and the staircase is real¶
At hand speed the output looks smooth. Under a deliberate fast flick, captured DC-coupled at 10 ms/div:
rise 2.130 V over 32.7 ms -> 59.0 deg at 1804 deg/s = 5.01 rev/s
-> 20 042 counts/s, one count every 50 us
staircase 74 mV per step, 1.07 ms apart
Three cross-checks, all independent:
- The 1.07 ms step interval is the 1 kHz update measured from the analog output itself, not from firmware reporting its own rate.
- 74 mV per step is 22.8 encoder counts, and the velocity independently predicts 20.0 counts per update — agreement to 14%, measured mid-flick where velocity peaks, so slightly steeper than the mean is correct.
- Local slope 69.2 V/s against a 65.1 V/s mean says the same thing.
A prediction made in this session was wrong and is corrected here. It was predicted that the 987 Hz filter would smooth consecutive updates into each other and leave no visible staircase. It does not: the filter needs ~2 ms to settle a step while updates arrive every 1 ms, so each step is roughly half-settled when the next arrives. Partial smoothing, not suppression. The staircase is visible, faintly, and that is the correct expectation.
What the staircase costs, and why it is cosmetic¶
| mV at the BNC | OneBox codes | |
|---|---|---|
| one encoder count | 3.25 | 21 |
| one update at 5 rev/s | 74 | 485 |
During fast motion the update rate is the dominant quantiser, 23x coarser than the encoder. At rest and at hand speed the encoder dominates; at 5 rev/s the 1 kHz sampling does.
Nothing is aliasing, though. A 32.7 ms half-swing is roughly a half-cycle of a 15 Hz component, so the motion's real bandwidth is tens of Hz against the 500 Hz Nyquist of a 1 kHz sampler. The information is present and the steps are visible quantisation of a signal that is itself smooth and slow.
Where it does bite is timing precision on a fast trajectory: the recorded
analog trace resolves a pull to 1 ms. Note this does not affect the PULL
event, which firmware detects from the encoder directly and which never goes
through the analog path. If finer rendering of fast pulls turns out to matter,
the options are a faster encoder read path or accepting 1 ms — not turning up
a constant, because 2 kHz does not fit in the measured loop budget.
Travel, as a by-product¶
The code range across both runs topped out at 2.1496 V, which is 58.1° of excursion from the switch release edge on the RIGHT joystick. Consistent with the ~60° estimate and with the left joystick's 64.174° full swing. This is not the deliberate hard-stop survey that plan item still wants — it is a measurement of where the right joystick's stop happens to sit today.
Regression of the HEAD firmware on board 4 — 2026-10-05, stages 1–3, AngleOutput, QP session¶
Firmware ec3b36d (mouse-joystick-firm HEAD after the 2026-10-04 v1.0
preparation refactor), every example built for mouse_joystick_rev1; the QP
build pulls qp-arduino from its GitHub tag v7.3.4. Purpose: run the tools
that v1.0 bring-up will use on known-good v0.3 hardware first, so a different
result on v1.0 is the board. Driven non-interactively over USB CDC: no meter,
no hand pull, no pyboard (none on the bench). 36 V supply on throughout, rail
switched by firmware. Evidence, captures and console logs:
mouse-joystick-firm/evidence/2026-10-05-1142-board4-regression/.
| Stage | Build | Result |
|---|---|---|
1 RP2040SelfTest |
mouse_joystick_rev1 |
24 checks, 0 failed (as 2026-09-09) |
2 TMC5130Communication |
mouse_joystick_rev1 |
rail up 133 ms, 5 register checks, 0 failed |
3 MotionAndHoming |
mouse_joystick_rev1 |
switch witnessed, homed, 5 of 5 repeats, spread 2.0 counts (as 2026-09-09); encoder follow 1.0000 continuing, 0.93–0.96 on every reversal: the stepper's dead band against friction, present in September too, same on a replacement joystick unit; see below |
5 AngleOutput |
mouse_joystick_rev1 |
rail, bus, SPI1, GAIN 0x101 written; commanded codes only, no meter |
QP PyControlLinkQp |
mouse_joystick_qp_rev1_usb |
see below |
Stage 1, USB only, 36 V connected but HV_EN low¶
Serial E4654C159F171E34, built Oct 5 2026 11:29:57, reset reason
software (3) (the picotool reboot after the flash). clk_sys 133000 kHz
measured against clk_ref; flash 16 MiB with no aliasing at +2/+4/+8 MiB;
heap 252228 bytes. Straps at reset: R601 bus isolated, GP24 OE high,
R403 RX high, R1905 ~{HV_FAULT} high. Nine spare pads move both ways.
| Reading | Value |
|---|---|
+3.3V_SW via GP26 |
12 counts = 0.0193 V, off |
HV_SNS via GP28 |
10 counts = 0.0081 V (13 counts on the a re-read) |
| die temperature | 28.5 °C |
k 10 s tick stream |
1000166 … 10000094 µs on the RP2040 clock (host timestamps not taken) |
LA5032 through the v1.0 adapter, capture captures/s1_selftest_boot.sr
(100 kSa/s, CH16/CH17, started before the reflash):
| Marker | Measured |
|---|---|
FW_TRIG1 boot signature |
3 pulses: 10.140, 10.010, 10.000 ms |
FW_TRIG0 boot signature |
8 pulses: 1.000–1.010 ms |
FW_TRIG0 heartbeat |
half-period 500.25 ms, period 1000.52 ms (56 pulses) |
t burst |
1001 pulses of 0.1 ms on CH16, 503 of 0.2 ms on CH17 |
I beacon |
3 frames, 50 ms start on CH17, 64 clocks each on CH16, 134.3 ms apart |
| reflash gap | 2.88 s from the last QP tick pulse of the old firmware to the signature |
Stage 2, 36 V on, driver disabled¶
s: +3.3V_SW 0.0177 V before; method firmware staircase (R1903 straps
LATCH high); up in 133 ms, peak 3.2944 V, live 3.2895 V; HV_SNS
0.0137 V (17 counts, offset floor); ~{HV_FAULT} high; bus closed with
~{TMC_DRV_EN} high. Registers: IOIN 0x11000018 (REFL_STEP 0, ENCA 1,
ENCB 0, VERSION 0x11, DRV_ENN 1, SD_MODE 0), GSTAT 0x1 then cleared,
GCONF 0, CHOPCONF 0 (vsense 0 at reset, reported not written),
DRV_STATUS 0x80000000, XACTUAL 0, X_ENC 0. 5 checks, 0 failed.
SPI taps CH2–CH5 at 10 MSa/s (captures/s2_tmc_spi.sr): 16 datagrams of
40 bits decoded, every MOSI/MISO byte equal to what the console printed
(04 00 00 00 00 → 39 11 00 00 18, …), SCK 1.00 MHz, the FW_TRIG1 burst
marker 14.30 ms high.
m (36 V input current) fails whenever the rail was live, and works from
a cold rail. PowerSequence::measureHvCurrent() calls powerDown(), waits
50 ms, then powerUp(), which refuses while +3.3V_SW still reads live; the
208 µF +36V_SW bulk bleeds down in ~700 ms, so straight after s it reports
rail: NOT UP with a 0.00 live count. Re-run after x and a 4 s wait it
measured zero 11.77 counts, live 16.76, delta 4.98 counts = 12.9 mA of
36 V input current with the bus open and the driver disabled (KSNS 800,
R1907 249 Ω, 2.6 mA per count). Firmware fix: wait for the rail to read
dead before powerUp(). The ordering is right: the bus opens before HV_EN
drops.
Stage 3, motor, encoder, limit switch, correct gate order b c e W g h H t L¶
b: rail up 133 ms, peak 3.298 V; TMC5130A 0x11; uv_cp 0; DAC GAIN 0x101.
c: CHOPCONF 0x10030153, vsense reads back 1, IRUN 8 = 0.211 A RMS,
IHOLD 2. e: driver enabled.
| Measurement | 2026-10-05 | 2026-09-09 |
|---|---|---|
W switch found from the negative hard stop |
engaged at 8319 µsteps (58.49°), released 8063, throw 256 µsteps, active high | engaged 28.8° from where it stood, throw 1.80° |
g out −1280 / back +1280 |
encoder −1203 / +1126, follow 0.9398 / 0.8797, residual −77 µsteps (−6.0 counts) | follow 1.0000 |
h approach 0.200 rev/s, creep 0.010 rev/s |
stopped by the switch at 8415 (follow 1.1278), release edge 8020, closing-to-release 395 µsteps, 1721 ms | — |
H 5 runs |
5 of 5, ENC_LATCH 51/38/51/64/38, spread 26 µsteps = 2.0 counts (0.183°), closing-to-release 708–746 µsteps |
2.0 counts |
t switch reporting delay |
FAIL: the slow approach ended without a latch | 8.74 ms |
L live encoder→DAC loop |
1000.1 Hz, 8009 updates in 8008.2 ms, worst loop 386 µs of 1000, code 4805 static | 1000.0 Hz, worst 527 µs |
p after |
DRV_STATUS 0x8102003B, ot/otpw 0, ola/olb 0 |
— |
The lost motion is a direction-reversal dead band of the stepper against
the mechanism's friction. It is in neither the TMC5130A nor the encoder
signals, it is not a defect of the joystick unit, and it was already there in
September. Settled the same afternoon with the encoder taps on the
instrument port (CH0/CH1), a jog series at two currents on the original unit
(consoles/s6_irun_rev1.log, captures/s6_irun.sr) and the same series on a
replacement joystick unit (consoles/s7_newjoy_rev1.log,
captures/s7_newjoy.sr):
- The raw quadrature on the taps agrees with the driver's
X_ENCto within 2 counts on every move (g: taps +94/−88 counts,X_ENC1203/1126 µsteps = 94.0/88.0 counts). The chip counts what the encoder sends. - A move continuing in the previous direction follows at 0.99–1.02. Every reversal loses a fixed amount: 77–90 µsteps (6–7 counts, 0.54–0.63°) at IRUN 8, 52–64 µsteps (4–5 counts) at IRUN 17; the round-trip residual is one reversal (−77/−90 at 8, −52/−77 at 17). The replacement unit measured the same: 90 µsteps on every reversal at IRUN 8.
- On the taps a reversal's first encoder count comes 52 ms later than a same-direction move's (63 vs 11 ms after the move marker), about 50 µsteps of ramp travel with the shaft still; the shaft stops 25–35 ms before the firmware declares the move done and does not creep afterwards, so the reading is of a settled shaft, not a premature one.
- Doubling the current shrinks the band by about a third, which a loose coupling or hub could not do. The lag each way, ~45 µsteps = 16° electrical at IRUN 8, says the static friction torque is about 0.27 of the holding torque at 0.211 A (sin 16°); at 0.421 A the same friction predicts a ~45 µstep band, measured 52–64.
- It is not new: board 2's stage 3 on 2026-09-02 left a round-trip residual of
−51 µsteps (−4 counts) at IRUN 8, recorded as "unexplained", and board
4's
greturn on 2026-09-09 read 0.9195; the 1.0000 figures were same-direction legs. Today's band is one to two counts wider.
| Move (1280 µsteps = 9°) | IRUN 8, original unit | IRUN 17, original unit | IRUN 8, replacement unit | IRUN 17, replacement unit |
|---|---|---|---|---|
| continuing in the previous direction | −1280, −1280 (1.0000) | −1280 (1.0000), +1292 | −1306 (1.0203), −1267 (0.9898) | −1293 (1.0102) |
| reversal | 1190 ×4 (0.9297), 1203 (0.9398) | 1216 ×2, 1228 ×2 (0.9500–0.9594) | 1190 ×5 (0.9297), 1177 (0.9195) | 1242, 1177, 1165, 1216 (0.91–0.97) |
g round-trip residual |
−77 (−6.0 counts) | −52 (−4.1) | −90 (−7.0) | −77 (−6.0) |
Motor and encoder both turn 1:1 with the joystick axis (ENC_CONST 12.8
µsteps per count at 4000 counts/rev), so 0.4–0.6° of commanded joystick
position is lost at each reversal. The encoder, the angle output and WATCH
read the true angle and are unaffected; the follow rule's 1° grace tolerates
it; homing re-zeroes at the switch, which is why the repeatability is 1–2
counts on either unit (replacement unit: 5 of 5, spread 13 µsteps = 1.0
count; switch found 21.65° from where it rested, throw 256 µsteps, active
high). IRUN 17 roughly halves the band if commanded positioning ever needs it.
MotionSetup.cpp is unchanged since September, so the firmware is not a
suspect.
t (switch reporting delay) fails on both units for a related reason.
After its fast approach stops hard on the switch, its 1280-µstep move away
produced only 14 counts on the taps and left the switch engaged (tap 77.44 to
92.47 s, LIMIT_SW high), so the slow approach was never attempted; the code
ignores the away move's report. A reversal straight off a full-speed switch
stop shows a wider dead band than a free reversal, and the blocking
MotionSetup::moveTo() judges its first 200-µstep window against 0.25 with
no grace distance and no second window, unlike TmcMotion's rule (142
µsteps of grace, two failing windows), so it ended the move. Firmware nits:
give the bench console the delivery firmware's follow rule, and have t
check the away report and jog clear before judging, as W learned to.
Tool notes from the first, mis-ordered pass (W before e): W ran the
ramp generator 21760 µsteps (153°) with the driver disabled and reported "the
sweep ran out of time"; the encoder tap confirms zero motion. W should
refuse, or enable the driver itself, when ~{TMC_DRV_EN} is high. g from
rest against the hard stop then failed exactly as its own text predicts.
Encoder and switch taps (captures/s3b_motion.sr, 1 MSa/s, CH0/CH1/CH11/CH17):
3184 A/B edges, 0 invalid quadrature transitions; raw count decreases for
positive motion, as documented; LIMIT_SW showed 18 transitions matching W,
h, the six H homes (throwaway plus five) and t's fast approach.
Stage 5 AngleOutput, no meter¶
b: rail 132 ms, bus closed, SPI1 on GP10/GP11/GP13, GAIN 0x101 written
(not readable over SPI). q: 2.5000 V full scale at the DAC, 3.3000 V at the
BNC, 38.15 µV/LSB. m, f, t (step on FW_TRIG1), z, x all ran. No
voltage was measured; the 2026-09-10 characterisation stands.
QP protocol session, PyControlLinkQp on mouse_joystick_qp_rev1_usb (protocol over USB CDC)¶
Phases a–e of mouse-joystick-firm/evidence/bench-scripts/board4_session.sh
run without stops, meter prompts unanswered, no hand pull. ID: hw=rev1,
fw=ec3b36d, proto=2, caps motion+home+instr+hv+watch+fetch+stream+cfg+angout,
boot=reboot;crumb=0. LIMITS: travel −10.125 … 0.0°, v_max 36.0 °/s,
a_max 180.0 °/s², i_max 0.421 A.
| Phase | Result |
|---|---|
| b | hv on acked (one tick of 1346 µs; DAC_STATE 1 then 2, 7.0 ms apart); HOMED angle=0.0; angscale −10.12 0.00; angout on; TARGET at −7.474 / −5.674 / −3.066 for −7.59 / −5.06 / −2.53 commanded, park at −0.541 for 0.00; angout off; disarm. The TARGET offsets of 0.1–0.6° are the lost motion below plus the rotor snap, as on 2026-10-02 |
| c | 20 of 20 moves, ack→TARGET p50 434.8 ms; cfg get wdt_ms=100;deadband_deg=0.5;stream_hz=10;pre_ms=500;post_ms=500; brake 0 → RELEASED angle=−0.816; watch pull=5 push=5 timeout=6000 hold=100: the released joystick drifted to +0.9° (first motion at 712 ms) so neither NOMOVE nor ABORT (maxdur off) could fire, per spec; moveto 0 left RELEASED with TRIAL outcome=NONE a0=−0.816 a_peak=0.9 a_end=0.9 and the resync (angle=−0.183 xactual=0.0); FETCH 500 samples in 16 chunks, 125 ms, lost=0; stream on 50 delivered 100 ev;S in 2 s; host-loss FAULT code=0 host_timeout 124.7 ms after the last heartbeat, rail dropped (hv=0, DAC_STATE 0); clrfault → IDLE |
| d | 300 of 300 moves, ack→TARGET p50 434.5 / max 473.9 ms, no naks |
| e | hv off acked, status hv=0, DAC_STATE 0 |
Tick cost (SAMPLE_STAT, 1 kHz sampler, 400 µs budget): holding with the
angle output on 158 µs light / 379 µs snapshot (p50 last / p50 worst per
second); holding without it 46 / 88 µs; during back-to-back moves the
command-handling tick costs 1081 µs p50, 1328 max, one per moveto, so
every second of phase d is over budget while the sampler itself is not; 738
of 80003 ticks coalesced, 0 dropped, 96 back-to-back TICK levels on CH16 out
of 90035; tick period on CH8 1000.0 µs, stdev 12.4 µs, none missed. TMC SPI
taps during d: 123948 datagrams, SCK 3.33 MHz, 2 per tick p50 (10 p99),
intra-tick gap 5.3 µs p50. Trace buffer high-water 548, never full; line pool
min free 1 of 8, queue 9. On 2026-10-02 the rev 1 build holding measured
201 / 387 µs and the rev 0 build moving p50 444 / p99 1138 / max 2065; today
moving is p50 146 / p99 542 / max 1490. Full tables in the evidence README.
Not run today: the UART0 path with a pyboard (none on the bench), the meter readings of phase b, the hand pull.
Board 3 — E4654C159F591D34 — Stage 4, Protocol¶
First protocol contact over the RJ-45, 2026-09-03. Passed, with the 36 V
supply off. Firmware examples/PyControlLink. Peer was a pyControl Breakout
1.2 on port 1, driven from the pyboard's REPL with machine.UART(4) directly --
no pyControl framework, no hardware definition, no device class. Deliberately:
first contact should not have the framework as a second unknown, which is the
same reason stage 2 proved the SPI bus with raw datagrams before using TMC51X0.
Sent id;11, received:
ack;11;hw=rev0;serial=E4654C159F591D34;fw=0.0.0;proto=1;axes=1;caps=motion+home+freewheel+instr+hv;rxbuf=120
What that one line establishes, none of which had been shown on hardware before:
- The UART wiring and polarity. Controller
DIO-A(pin 1) reaches this board'sUART0 RX, and this board's TX reachesDIO-B(pin 4). A crossed cable would have produced silence. - The baud rate. 115200 requested,
UART4achieving 115384 (+0.16%). A complete 108-character reply with no framing errors. - The framing codec on real hardware, byte-identical in shape to the corpus
case
id-reports-version-serial-and-axis-count. IDanswers before ARM and with the motor rail off, which the protocol requires and which is what makes it safe to use for discovery.- Port 1 =
UART4on the deployed breakout, asdevices/breakout_1_2.pysays.
The read-only surface, verified after the STATUS fix¶
Same board and port, 2026-09-03, still with the 36 V supply off. Ten commands, deliberately with the USB mirror not being drained, so the mirror's non-blocking guard was exercised at the same time.
| Sent | Received |
|---|---|
id;40 |
ack;40;hw=rev0;serial=E4654C159F591D34;fw=0.0.0;proto=1;axes=1;caps=motion+home+freewheel+instr+hv;rxbuf=120 |
status;41 |
ack;41;state=IDLE;angle=0.0;xactual=0.0;homed=0;limit=0;faults=0;hv=0 |
limits;42 |
ack;42;travel_lo=-19.125;travel_hi=-9.0;v_max=36.0;a_max=3600.0;i_max=0.421 |
ping;43 |
ack;43 |
hv;44;state |
ack;44;hv=0 |
ping (no seq) |
nak;?;badframe |
wibble;45 |
nak;45;badverb |
moveto;46;-12.0 |
nak;46;notarmed |
freewheel;47 |
nak;47;notarmed |
status;48 |
ack;48;state=IDLE;angle=0.0;xactual=0.0;homed=0;limit=0;faults=0;hv=0 |
Two of those carry more than they look like:
moveto;46;-12.0proves an ordering, not just a refusal. -12.0 is inside the travel this board just reported, sonotarmedrather thanoutofrangeshows ARMED is checked before the range check on real hardware. That is the safety-relevant ordering the shared corpus did not pin until mutation testing forced a case for it.status;48answering at all is the fix for the hang above, confirmed after nine prior commands and all the mirror traffic they generated.
hv on passed, 2026-09-03 — and it is board 3's first power-chain result¶
36 V on J201, supply on. hv;51;on answered ack;51, and hv;52;state
then reported hv=1.
That single ack covers a lot of code that had never executed, and it is more than
a protocol result: this is the first time the 36 V chain, the GPIO isolation
bus switch and TMC5130A communications have been exercised on board
E4654C159F591D34 at all. The board previously had no stage 1, 2 or 3 numbers.
An ack is only returned if every one of these succeeded:
PowerSequence::powerUp()brought+3.3V_SWup and the RP2040's own ADC measured it crossing threshold. SoR203is fitted on this unit as expected and the firmware staircase works here, exactly as it does on board 2.connectBus()closed the isolation bus switch.beginSpi()started SPI0 andIOIN.VERSIONread back 0x11, the TMC5130A signature.communicating()true, andstepAndDirectionMode()false — SD_MODE is tied to GND as designed, so SPI motion commands will actually do something.MotionSetup::configure()ran, which zeroesXACTUAL, the target andX_ENC.
Stability: status at t+0, t+3 s and t+10 s all read
state=IDLE;angle=0.0;xactual=0.0;homed=0;limit=0;faults=0;hv=1, and faults
reported mask=0. No spurious fault with the rail live and the bus closed.
The zeros are expected rather than suspicious — configure() zeroes all three
registers — but note they do not prove the position cache is live, because a
broken cache would also read zero. That is the next thing to check, and it can be
checked with the driver still disabled by moving the joystick by hand.
The encoder cache is live, and angle/xactual genuinely diverge¶
With the rail up and the driver still disabled, the joystick was moved by
hand and status read twice:
ack;80;state=IDLE;angle=-22.866;xactual=0.0;homed=0;limit=0;faults=0;hv=1
ack;81;state=IDLE;angle=-22.866;xactual=0.0;homed=0;limit=0;faults=0;hv=1
Four things at once, none previously shown on hardware:
- The position cache is live. It went from 0.0 to -22.866 with no commanded
motion, so
poll()really is refreshing it from the encoder every pass. The fix for theSTATUShang is correct, not merely safe -- a broken cache would have kept reading zero and looked identical to a working one at rest. - The encoder counts, and 22.866 deg = 3252 microsteps is a plausible fraction of the ~4000 microsteps of measured travel.
angleandxactualgenuinely diverge. The encoder moved and the ramp generator did not. This is the concrete case behind open question 5 inuart-protocol.md-- with a compliant state, "position" is two quantities -- demonstrated on hardware rather than argued.- No fault was raised, which is correct: divergence in a non-driving state is the joystick being moved, not a stall. That distinction is the rule the protocol layer must preserve.
Do not read -22.866 as an absolute position. The board was unhomed, so zero
was wherever configure() last zeroed the registers, not the switch. The value
is meaningful as a change and says nothing about the travel limits.
Two more firmware bugs of the same class, found and fixed 2026-09-03¶
Both were fields that could not fail loudly, which is why they needed hardware to find.
status'slimitwas a hardcoded 0.limit_asserted_was a member that nothing ever assigned. A host could have used it to decide homing was safe to start -- and it was about to be used that way during this very bring-up. It now comes fromRAMP_STAT.status_stop_r/l, chosen by which end the switch is on, taken from the snapshot already read rather than a second register access. Those bits are plain status, not read-and-clear, and reflect the pin whether or not the hardware stop is armed, which is what makes them usable for reporting.- The homing approach had no encoder follow check. The validated
homeToSwitch()runs its approach throughmoveTo(), which has one:kNotFollowingmaps toHomeOutcome::kMechanicalStopFirst. The non-blocking version commanded the target directly with no such check, making it strictly weaker than the implementation stage 3 proved -- and weaker exactly where it matters, since the approach commands 102400 microsteps (720 deg at the motor shaft) against a mechanism with 28 deg of travel, relying entirely on the chip's switch stop to end it. If the switch never asserts, nothing noticed the mechanism had stopped and the motor pushed against it until the 8 s timeout. The check is now in all four motion phases, with thresholds taken fromConversions.hppso it andmoveTo()cannot drift apart.
hv on was repeated after these fixes and answered ack again, with
limit=0 now a real reading.
The full motion path, under protocol control — 2026-09-03¶
hv on -> home -> arm -> moveto -> disarm, all over the RJ-45 from the
pyboard, with the motor moving. Representative sequence:
ack;190 hv on
ack;191 ev;HOMED;66447906;angle=-7.741 home
ack;200 arm
ack;201 ev;TARGET;94551056;angle=-14.766 moveto -16.0, ~100 ms later
ack;202;state=ARMED;angle=-14.766;xactual=-15.996;homed=1;limit=0;faults=0;hv=1
ack;203 disarm
Established:
- Homing repeats.
angleat the parked position over four homes: -7.741, -7.741, -7.65, -7.741. A 0.09 deg spread is about one encoder count, consistent with the 2-5 counts measured on board 2 in stage 3. - Commanded moves track. Between the two
statusreads above, XACTUAL moved -6.996 deg and the encoder -6.933 deg: a follow ratio of 0.991. The motor goes where it is told. - The heartbeat works, including the resolved retransmission rule. About 150
consecutive
ping;9-- the same sequence number every time, byte-identical -- were each answered and each refreshed the watchdog. Under the draft's original rule those would have been served from cache and the safety timeout would have expired while the host was receiving acks. This is open question 1 demonstrated on hardware. disarmstands the watchdog down cleanly, so a session can end without provoking a fault.- Fault recovery works.
clrfaultafter ahost_timeoutreturnedack, state went toIDLEwithhomed=0, and a freshhv on+homesucceeded.hv onbeing needed again confirms the fault really did drop the rail.
Measured: the host must poll faster than the draft implies¶
At 40 ms pings against the 100 ms timeout, the board faulted host_timeout
21 ms after a move completed. At 20 ms pings the identical sequence ran
clean, 140 iterations, no fault.
The cause was an ordering defect, now fixed: the watchdog was evaluated before the link was read, so a heartbeat that had already ARRIVED but was not yet parsed did not count -- the board could fault on a ping sitting in the UART's receive buffer. Reading first closes it.
Worth keeping even so: 40 ms was chosen as "comfortably inside 100 ms" and it was
not. tasks/mouse_joystick_check.py pings at 40 ms. Re-measure the margin
after the fix rather than assuming it is now fine.
CORRECTION to existing firmware: ENC_LATCH is always 0 on rev 0¶
Found while trying to fix the datum offset below, and it applies to the validated stage 3 code, not just the new protocol firmware.
MotionSetup::moveTo() fills report.latch_encoder from ENC_LATCH, and
homeToSwitch() fills report.coarse_encoder and report.fine_encoder from it.
ENC_LATCH latches X_ENC on the encoder's N (index) event, not on the
reference-switch event that fills XLATCH. Rev 0 leaves the encoder index
no-connect with ENCN terminated by R1201 10 kOhm
(docs/interface-map.md), so there is never an N event and
the register is permanently 0.
Every encoder-latch number those reports carry is therefore structurally zero, on
this revision, by construction. Any stage 3 report that shows one should be read
as "no data", not as a measurement. Same class as the limit field above: a
reported number that cannot carry information.
RESOLVED: the position-frame offset, by zeroing both frames at the park¶
STATUS reports angle (encoder) and xactual (ramp generator). After homing
they differed by a constant ~1.2 deg / 166 microsteps, so a host commanding
-16.0 read back -14.766 on a ~10 deg usable range.
Peter's fix: zero BOTH frames after the clearance move, not at the switch
edge. Two earlier attempts failed -- one because ENC_LATCH is permanently 0
on rev 0 (below), one whose arithmetic never closed -- and this is better than
either, because it fixes more than the offset:
| Before | After | |
|---|---|---|
moveto -8.0 reads back |
-6.8 (offset ~1.2) | -8.016 |
ev;HOMED reports |
angle=-7.741 |
angle=0.0 |
| Is zero commandable? | no, outside travel | yes, it is the park |
| Travel limits | -19.125 .. -9.0 | -10.125 .. 0.0 |
Measured after the change: moveto -8.0 -> angle=-8.016, xactual=-7.995;
the two frames agree to 10 microsteps, under one encoder count. A moveto 0.0
round trip returned without fault.
Why this is the better datum, beyond removing the offset:
- Zero means something. It is where homing leaves the joystick, so it is a
position a host can command rather than one the travel limits exclude, and the
"homing parks exactly on
travel_hi" edge noted earlier is gone. - The firmware and the shared corpus stop disagreeing. The corpus expects
ev;HOMED;...;angle=0.0and the hardware now reports exactly that. The previous datum made the two implementations differ on a corpus-pinned value. - What is kept from the old scheme:
XACTUALis still re-based toXLATCHfirst, so the clearance move is measured from the switch EDGE rather than from wherever the chip stopped. The stop position varies with approach velocity (89 microsteps at 0.200 rev/s against 4.5 at the creep); the latch does not.
Two further firmware bugs found by the same runs¶
The homing approach keyed off the wrong flag. An approach that started ~25
deg from the switch reached it -- status showed limit=1 -- and then sat until
the 8 s timeout, faulting home_timeout with the joystick resting on the switch.
Four earlier homes, all starting near the switch, had succeeded.
status_latch_* are READ-AND-CLEAR, so whether a poll catches them depends on
its timing against the edge; status_stop_* are plain status bits that survive
polling. The validated homeToSwitch() already knows this -- it keys off
kSwitchStopped and treats the latch as a preference, with an explicit fallback
to reading XLATCH later. The non-blocking version had no fallback, so a missed
flag was a hang. Now keyed off the stop bit, with XLATCH read afterwards, which
is safe because the chip writes it at the edge and reading does not clear it.
ev;HOMED reported a stale angle. The event carried the cached position from
before the datum writes -- observed as ev;HOMED;...;angle=8.459 immediately
followed by a status reading angle=0.0. The event is the thing a host
timestamps and records, so it is the one that must not be stale. The cache is now
refreshed as part of applying the datum.
CORRECTION: the host_timeout faults were the test harness, not the firmware¶
Earlier in this session two host_timeout faults were attributed to firmware --
a real ordering defect was found and fixed (the watchdog was evaluated before the
link was read, so a heartbeat already in the UART buffer did not count, and
reading first closes that race). But the faults that were actually observed
were caused by the test harness.
Instrumenting the loop period showed it running at 72 ms per iteration, not the
20 ms intended: MicroPython's UART.read() blocks for the configured timeout
when the buffer is empty, so a timeout=50 added 50 ms to every pass. The
"20 ms pings" were 72 ms and the "40 ms pings" were ~90 ms, both against a 100 ms
watchdog. With timeout=20 the same sequence runs clean.
So the recorded claim that the host must poll faster than the draft implies is
withdrawn as a measurement. The ordering fix stands on its own merits, and
the real margin is unmeasured. Set the UART read timeout deliberately before
timing anything on this link -- and note tasks/mouse_joystick_check.py pings at
40 ms, which is fine at a 100 ms timeout if the poller is actually prompt.
End state¶
disarm, then hv;off -> ack. Rail down, driver disabled, board left safe.
The final status froze mid-return-move at angle=-0.816;xactual=-0.211; a
0.6 deg gap there is ordinary dynamic lag of the encoder behind the ramp
generator, captured because the fault stopped the cache updating, and not a
datum error -- at rest the two agree to under one encoder count.
freewheel while armed, and both ways back out — 2026-09-03¶
The compliant state, which is the part the actual trial depends on. Run inside a ~35 ms ping loop so the host-loss watchdog stayed fed throughout.
| Step | Reply |
|---|---|
arm |
ack |
moveto -5.0 |
ack, then state=ARMED;angle=-5.041;xactual=-4.999 |
freewheel |
ack |
status |
state=FREEWHEEL;angle=-4.866;xactual=-4.999 |
moveto -3.0 |
ack, then state=ARMED;angle=-3.691;xactual=-2.995 |
freewheel |
ack |
stop |
ack, then state=ARMED;angle=-3.783;xactual=-4.05 |
disarm |
ack, then state=HOMED;angle=-3.783;xactual=-3.783 |
- The compliant state works and the divergence is real. In FREEWHEEL the
encoder moved 0.133 deg and the ramp generator did not -- the joystick settled
when the driver was released. That is the effect that makes
angleandxactualtwo quantities rather than one, observed without anyone touching the mechanism. - Both exits work:
movetore-engages implicitly and moves,stoptakes the joystick back and holds it,disarmleaves it held in HOMED. - The resync is exact when nothing follows it. After
disarmboth frames read -3.783. That isolates the resync from the discrepancies below.
Firmware bug: leaving FREEWHEEL never re-energised the driver¶
freewheel releases the driver, and every path out of FREEWHEEL resynced and set
ARMED without re-enabling it -- despite a comment in the code claiming the
move "re-engages implicitly". The ramp generator advanced with no torque, the
encoder did not follow, and the follow check faulted not_following (bit 2)
after ~1.4 deg of commanded travel.
Same class as the homing driver-enable deadlock, and the third time the follow
check has caught a state transition that forgot to energise what it was about to
use. All three exits were separate omissions; they now share one
leaveCompliance() that resyncs and re-enables as one operation, because a host
must not be able to be interrupted between the two.
OPEN: sub-degree discrepancies at the release/re-engage boundary¶
Two numbers above do not close. They are small but not noise:
freewheel->moveto -3.0:xactual=-2.995againstangle=-3.691, a 0.70 deg gap. The resync had set both to -4.866 immediately before, so the encoder moved 1.175 deg where the ramp generator commanded 1.866.freewheel->stop:xactual=-4.05againstangle=-3.783, 0.27 deg, when the resync should have left them equal and no motion was commanded.
Leading hypothesis: the rotor snapping when the driver re-energises. A stepper's electrical angle repeats every four full steps, so re-energising pulls the rotor to the nearest matching position -- bounded at roughly +/-3.6 deg at this motor's shaft however far the joystick was moved while compliant. Both figures sit inside that.
If so, the fix is an ordering change: re-enable first, let the rotor settle,
resync after -- so the resync captures the post-snap truth rather than a
position the snap then invalidates. Deliberately NOT done yet, because it has a
plausible failure of its own: energising against a stale XACTUAL derives the
driver's electrical angle from where the ramp generator thought the joystick
was, so the snap would be toward that rather than away from it.
Deciding it needs a measurement, not a preference: park, freewheel, move the
joystick a known distance by hand, and read both frames before the re-engage,
immediately after, and after the resync. Until then the effect is bounded,
sub-degree, and the encoder stays the trustworthy frame -- which is what STATUS
reports.
armpull argument handling verified; PULL implemented but NOT confirmed¶
armpull's validation is correct on hardware, tested from HOMED so no
watchdog was involved:
| Sent | Reply |
|---|---|
armpull;361;-5.0 |
ack;361 |
armpull;362;-50.0 |
nak;362;outofrange |
armpull;363 (no argument) |
nak;363;badargs |
armpull;364;abc |
nak;364;badargs |
armpull;365;0.0 |
ack — inclusive upper bound |
armpull;366;-10.125 |
ack — inclusive lower bound |
It was a validated no-op. Nothing checked the threshold, so PULL could
never fire; the Python simulator only emitted it from a test-injection hook. Both
ends now implement detection, and implementing it exposed that they had
different rules -- the fork the shared corpus exists to prevent, and one the
corpus could not catch because no corpus case can move an encoder.
The rule is now a crossing, on both ends: ARMPULL records which side of the
threshold the joystick is on, and PULL fires on crossing to the other side.
The simulator previously compared magnitudes, abs(angle) >= abs(threshold),
which agreed with the firmware only because this board's travel happens to be
one-signed -- on the simulator's own default travel of -20..20 a threshold of
-5.0 also fired at +5.0, and a task that presented past the threshold got a pull
for free. Recording the side invents no sign convention, which is what lets the
mirrored left and right joysticks share one firmware.
Gated to FREEWHEEL only: under commanded motion a crossing means the board did
it, and reporting that as a pull would put a fabricated event into a behavioural
dataset.
API consequence worth knowing: re-arming while the joystick still sits past the threshold arms from the far side, so the next pull would have to be in the other direction. A task must present before re-arming -- which is the trial's natural order anyway.
OPEN: an anomalous frozen encoder reading during the pull window¶
The hand-pull test did not confirm PULL, because the encoder reading was
already wrong before the joystick was touched.
Sequence: home to angle=0.0, arm, moveto -5.0, armpull -8.0,
freewheel, then a 17 s window sampling status every ~0.6 s while Peter pulled
the joystick. Every one of the twelve samples read identically:
state=FREEWHEEL;angle=-40.95;xactual=-4.999;homed=1;limit=0;faults=0;hv=1
What that rules out and what it leaves:
- -40.95 deg is impossible mechanically. Travel is ~28 deg at the motor shaft and the commanded position was -5.0. Corrected 2026-09-09: the premise is wrong. The hard stops are movable and both mechanisms have ~60 deg of range, so -40.95 deg is not out of reach. The rest of the case against this reading — frozen across the whole window, through a physical pull — is unaffected and still stands on its own. See The 28 deg travel figure is a hard-stop setting, not a mechanism property.
- It was frozen across the whole window, through a physical pull, so the
encoder was not tracking.
xactualbeing frozen is expected in FREEWHEEL -- nothing turns the ramp generator -- butangleshould have moved. - It is not a scaling error. Three commanded moves measured immediately afterwards tracked at ratios 0.947, 1.127 and 0.871 -- scattered but around 1.0, nowhere near the 8.19x that -40.95 against -5.0 implies.
- It is not a dead encoder or a dead bus.
hv onre-zeroed both registers and a subsequenthomeworked normally, reportingangle=0.0. - No
PULLfired, and that is consistent. If the cache already read -40.95 whenarmpull -8.0ran, detection armed from BELOW the threshold, so it was waiting for a crossing upward past -8.0 that never came. The rule behaved correctly on bad input.
So the fault is in how the encoder value got to -40.95 and stopped changing, and
it happened once. Candidates not yet separated: spurious counts on ENC_A/ENC_B
from motor commutation noise while the driver was energised; a poll() that
stopped refreshing for a reason not yet identified; or a stuck read of X_ENC.
This is exactly the question the InstrumentPort's encoder taps exist for.
docs/instrument-strategy.md: "There is no PIO cross-check in any revision, so
the TMC5130A's X_ENC register is the only count on the board and nothing in
firmware can contradict it. Raw edges plus SPI reads of that register, in one
capture, make the comparison by hand." ENC_A/ENC_B are LA5032 channels 0 and
1, FW_TRIG1 is channel 17, and the analyzer on this board is identified
(1-5.1.4). Capture a freewheel pull and compare the raw quadrature against
what STATUS reports -- that is the measurement, and it needs no new firmware.
Noted: the follow check is one-sided¶
It fires when the encoder moves LESS than commanded (|ratio| < 0.25) and
cannot fire when it moves more. An encoder over-reading by 8x sails through, as
this run demonstrates. That is the right bias -- under-following is the case that
means the mechanism is being pushed -- but it means over-counting has no
detector at all, and the earlier ~1.2 deg and ~0.3 deg discrepancies were all
found by reading numbers rather than by any check.
The encoder was RIGHT: -28.083 deg is the mechanical end of travel¶
A second pull attempt, this time with the joystick pulled only after it went limp, produced the trace that reframes the earlier "anomalous" readings:
| Sample | angle |
xactual |
|---|---|---|
| freewheel + 0.9 s | -5.941 | -4.999 |
| + 2.3 s | -5.941 | -4.999 |
| + 3.7 s | -6.033 | -4.999 |
| + 5.1 s | -28.083 | -4.999 |
| + 6.5 s ... + 19 s (11 samples) | -28.083 | -4.999 |
Read it as motion rather than as corruption and it is entirely coherent:
- -5.0 -> -5.941 when the driver released. The joystick settled 0.94 deg
under its own load the moment it went compliant.
xactualstayed at -4.999 throughout, which is the whole point of reporting both. - -5.941 -> -6.033 is the pull starting.
- -28.083 deg = 3994 microsteps, which is
conversions::kMeasuredTravelMicrosteps(4000) to within 6 microsteps. The joystick was pulled to its mechanical end of travel. - Frozen for the next 14 s because it was resting against the stop, which is the correct reading for a stationary joystick, not a stuck register.
So the encoder tracked the pull faithfully from beginning to end. The earlier -40.95 and -48.5/-51.2 readings are best explained the same way -- real displacement, in runs where the datum was not what I assumed -- and Peter's own suspicion that he pulled at the wrong moment (while the driver was still holding) fits: forcing a held motor back-drives it, and the encoder on the motor's rear shaft reports that honestly.
Downgrade the "anomalous frozen encoder" item above from a suspected fault to a misreading of correct data. What remains genuinely unexplained is nothing: there is no evidence of an encoder or reporting fault in any of it.
MEASURED: the travel limits are wrong by a factor of nearly three¶
This is the actionable finding. examples/PyControlLink sets
travel_lo = -10.125 deg, derived at a desk from kMeasuredTravelMicrosteps
less a clearance step at each end. The left joystick reached -28.083 deg below
the park under a hand pull, so the mechanism has roughly 28 deg of usable
travel below the park and the firmware permits 10.
Consequences:
- Two thirds of the mechanism's range is unreachable by
moveto. Any trial that wants to present the joystick further than 10 deg from the park cannot. kMeasuredTravelMicrosteps= 4000 was measured on board 2, the RIGHT joystick, from the switch. It does not describe this mechanism from the park, and the two joysticks may simply differ. Neither joystick's travel has been measured through the protocol.- The limits should come from a measurement on each joystick -- walk to each end under a bounded jog, record where the follow check or the switch stops it -- not from a constant carried across from another unit.
Harness bug: the PULL event may have fired and been discarded¶
PULL is still unconfirmed either way, and that is my test harness rather
than the firmware. The pyboard loop filtered replies with
if r and b'ack;9\n' not in r, which discards any read that contains a
heartbeat ack -- and an asynchronous event arriving in the same read as a ping
ack is exactly the common case. A crossing of the armed -8.0 threshold did occur
(-6.033 to -28.083), so if detection worked the event was emitted and thrown
away by the filter.
Fix the filter to b'ev;' in r or b'ack;9\n' not in r and re-run. Nothing about
the firmware is implicated.
Analyzer capture NOT obtained: both LA5032s stopped capturing¶
The instrumented capture could not be taken. Both analyzers enumerate and answer
--scan normally, reporting 34 channels, but every capture hangs -- including
a 2000-sample single-channel request, and including command shapes that worked
earlier in the same session for the identity beacon. sigrok-cli reports:
sr: kingst-la2016: Unexpected run state, want 0x85eX, got 0x0000.
0x0000 is the FPGA not running. No stray sigrok-cli process is holding the
devices. The likely cause is a capture killed mid-flight leaving the device
wedged in a way that firmware re-upload on --scan does not clear.
Recovery needs a USB power cycle of the analyzers. Worth adding to
instrument-control's notes: do not kill a Kingst capture with a signal; let it
reach its sample count or trigger timeout.
CONFIRMED: PULL fires, and the raw encoder edges corroborate X_ENC¶
Both remaining questions answered 2026-09-03, in one instrumented run.
PULL works. Armed at -8.0 with the joystick presented at -5.0, then
released and pulled by hand:
ev;PULL;131490670;angle=-8.016
It fired at -8.016 against a threshold of -8.0 -- one encoder count past the crossing -- exactly once, and did not repeat as the joystick continued to -20.166 and rested there. Reproduced in two consecutive runs. That is the whole trial mechanism end to end: park, present, arm, go compliant, pull, detect.
And the encoder is independently confirmed. LA5032 on the LEFT board
(topology 1-5.2.1, identified by beacon), capturing ENC_A/ENC_B at
200 kSa/s, bounded by FW_TRIG1 which this firmware now holds high for the whole
compliant window:
| Source | Displacement | In counts |
|---|---|---|
| Raw quadrature edges | 14.490 deg | 161 (163 transitions, 0 invalid) |
STATUS, from -4.999 |
15.167 deg | 168.5 |
STATUS, from -5.941 (first post-release sample) |
14.225 deg | 158 |
Agreement is 3 to 8 counts on a ~15 deg pull, 2-5%. Zero invalid transitions
means the capture was not under-sampled, so 161 is a value and not a floor. The
sign difference between the raw decode and STATUS is the decoder's arbitrary
A/B convention against this harness's crossed channels and a positive
ENC_CONST -- not a discrepancy.
This is the measurement the InstrumentPort's encoder taps were put there for.
ENC_A/ENC_B reach no RP2040 pin on any revision, so X_ENC is otherwise
unfalsifiable; counting the raw edges is the only thing in the system that can
contradict it, and it does not. Every "anomalous" encoder reading earlier in
this session was real motion, and the register can be trusted.
Practical note for future captures: sigrok-cli's default capture ratio put
roughly half the buffer BEFORE the trigger -- a 17.5 s capture yielded 8.8 s of
pre-trigger data and 9.0 s inside the window. Helpful here, since it also caught
the presenting moves, but size the post-trigger half deliberately rather than
assuming the whole capture follows the trigger.
Travel survey, LEFT joystick — 44 deg and no stop found¶
Method: temporarily widen travel_lo to -45 deg so moveto cannot be what ends
the walk, then step out from the park in 2 deg increments and let the encoder
follow check find the hard stop. That check is the only detector at that end --
there is no switch there -- and it trips within about 1.4 deg of commanded travel
that the encoder does not confirm.
It never tripped. All 22 steps reached target, and the walk ended only when the survey limit itself refused the next one:
| Commanded | angle |
xactual |
|---|---|---|
| -2.0 | -1.891 | -1.997 |
| -10.0 | -10.083 | -9.998 |
| -20.0 | -20.074 | -19.997 |
| -28.0 | -28.083 | -27.998 |
| -36.0 | -36.091 | -36.000 |
| -44.0 | -44.100 | -43.995 |
| -46.0 | nak;743;outofrange |
— |
The encoder tracked the ramp generator to within 0.15 deg at every step over
44 deg. Afterwards the mechanism homed back to 0.0 normally, so the switch was
found from -44 and the position was reachable in both directions.
So the firmware's limit of -10.125 deg was wrong by more than a factor of four,
not three. It was derived at a desk from kMeasuredTravelMicrosteps (4000 =
28.1 deg), a constant measured on board 2 from the switch, which does not
describe this mechanism from the park.
OPEN: 44 deg of WHAT? The encoder cannot answer this one¶
The measurement above is 44 deg of motor shaft rotation. It is not necessarily 44 deg of joystick travel, and nothing electrical on this board can tell the difference:
- The E4T sits on the motor's rear shaft while the gear train is driven from
the front (
docs/harness-pinouts.md), so the encoder measures the motor, not the mechanism. - The follow check compares
XACTUALagainstX_ENC, and both are motor-side. A coupling that slipped downstream of the encoder would leave them in perfect agreement -- which is exactly what was observed. .agents/AGENTS.mdlists "a slipping coupling" among the things theXACTUAL/X_ENCdivergence detects. On this geometry it does not: that detector only sees faults between the ramp generator and the motor shaft.
Evidence on both sides. For it being real travel: the mechanism homed successfully from -44, and homing requires the MECHANISM to move far enough to actuate the switch. Against: the recorded travel is 28 deg, an earlier hand pull stopped and sat at -28.083 for 14 s as though against a stop, and homing from a slipped position near the switch would also succeed quickly.
The homing duration would separate them -- 44 deg at the 0.200 rev/s approach is ~610 ms against ~100 ms from near the switch -- but the timing was not captured at the resolution needed.
This wants eyes on the mechanism, not another electrical measurement. Does the joystick handle actually swing through the full 44 deg, and is there a hard stop at the negative end at all? Until that is answered the 44 deg figure should NOT be written into the travel limits: setting them from a slipping coupling would let a task command positions the joystick cannot reach.
The shipped limit is left at the old conservative value. It is known wrong and known safe, which is the right way round while the question is open.
RESOLVED: 44 deg was real, the gearing is 1:1¶
Peter: the motor-to-joystick gear ratio is 1:1, and the full swing may be as
much as 60 deg. That settles the slipping-coupling question: motor shaft angle IS
joystick angle, the encoder sits on the motor shaft, so a blocked mechanism stops
the encoder while XACTUAL advances -- which is exactly what the follow check
tests. The survey simply had not gone far enough.
The negative hard stop, ONE valid measurement¶
Widening the survey limit to -75 deg and walking out in 2 deg steps, the follow check tripped:
state=FAULTED;angle=-55.174;xactual=-57.403;faults=4 (not_following)
The encoder stopped at -55.174 deg -- the mechanism against its stop -- while the ramp generator pushed 2.229 deg further before the check evaluated.
With the park sitting one 9.000 deg clearance step off the switch:
| deg | encoder counts | microsteps | |
|---|---|---|---|
| 1 encoder count | 0.0900 | 1 | 12.8 |
| switch -> park | 9.000 | 100 | 1280 |
| park -> hard stop | 55.174 | 613 | 7847 |
| FULL SWING | 64.174 | 713 | 9127 |
64.2 deg is consistent with Peter's estimate of up to 60 deg.
This is ONE measurement, not a repeatable figure. Two apparent repeats were retracted -- see below -- and later attempts gave inconsistent results. Treat 64.2 deg as a single observation of the right order, not a calibration.
RETRACTED: the "three identical measurements" claim¶
Three readings of angle=-55.174 were reported as independent repeats. They were
the same reading three times. clrfault was refusing with latched, so the
subsequent home, arm and moveto all naked, and the loop broke on the first
status still showing the stale fault. Byte-identical results across three runs
should have been read as suspicious rather than as precision.
Firmware bug: a motion fault could not be cleared¶
clrfault answered nak;latched forever after a not_following fault, because
backend faults were raised with cause_active = true. The remedy for being
against a stop is to move, and motion is refused while faulted -- so the only
exit was a reset, and it silently invalidated the measurements above.
Fixed: every fault this board raises is now clearable. The draft's "clears only faults whose cause has gone" is not implementable for causes the board cannot probe without retrying -- whether the mechanism is still stuck, whether the rail will come up, whether CHOPCONF reads back 1. The board clears and re-faults instead, which is recoverable and reports just as loudly. Raise this against the draft's CLRFAULT wording.
Firmware bug: small moves had NO stall detection at all¶
The more serious one. startMoveTo() re-seeded the follow-check window on every
move, and the window only evaluates once 200 microsteps (1.4 deg) of
commanded travel accumulate. So any move smaller than 1.4 deg reset the window
before it could ever evaluate, and was never follow-checked.
Observed directly: walking to the stop in 2 deg steps faulted correctly at -55.174, while the same walk in 0.25 deg steps ran to -58 and -60 deg with no fault at all, pushing the motor against the stop repeatedly with nothing noticing.
Small commanded moves are exactly what a trial does, so this was a stall
detector that switched itself off for the normal case. Fixed by letting the
window persist across moves and re-seeding it only where XACTUAL is written out
from under the check: the homing datum, the park datum, a compliance resync, and
bus bring-up.
OPEN: the mechanism may have been left jammed¶
After those unprotected walks, homing now faults immediately:
ack;582
ev;FAULT;35158468;code=3;detail=mechanical_stop_first
state=FAULTED;angle=0.267;xactual=1.413;homed=0;limit=0;faults=8;hv=0
The approach commanded 1.413 deg and the encoder returned 0.267 -- a ratio of 0.19, below the 0.25 threshold -- with the limit switch NOT asserting. So the mechanism is not following at the POSITIVE end, and it is not the switch stopping it.
Candidates, not separated: the mechanism is jammed against something after being driven into the negative stop without detection; the joystick is resting at a positive limit the switch does not cover; or the follow check is now tripping on acceleration lag at the 0.200 rev/s homing velocity, since the window is no longer re-seeded at the start of each move and evaluates at a different point in the ramp.
That last possibility is a real risk introduced by the fix and must be ruled
out before trusting it. The original blocking moveTo() re-seeds per move
precisely so "acceleration from standstill and the encoder's 12.8 microstep
quantum cannot trip it".
This wants eyes on the mechanism before any more motion. Does the joystick move freely by hand through its full swing, or is it caught? The board is left safe: rail off, driver disabled.
RIGHT joystick: not surveyed, and should not be until the left one is
understood. It also needs PyControlLink flashed over the bench console.. The motor has been
energised — note hv on brings up the rail and configures the driver but
leaves ~{TMC_DRV_EN} high; only arm and home enable it.
~~OPEN: the datum sits outside the permitted travel~~ — RESOLVED 2026-09-03¶
The first travel limits were travel_lo=-19.125, travel_hi=-9.0, with zero at
the switch edge. That made zero unreachable (driving into the switch is what the
switch exists to prevent) and parked homing exactly on the inclusive boundary.
Both went away with the park datum. Zero is now where homing leaves the
joystick, so it is a commandable position, and travel is -10.125 .. 0.0,
confirmed on hardware:
ack;211;travel_lo=-10.125;travel_hi=0.0;v_max=36.0;a_max=3600.0;i_max=0.421
Still not exercised: walking to each end and confirming moveto refuses one
step past. The limits remain constants derived from bench geometry rather than
measured through the protocol, and the margin at the negative end -- a hard
mechanical stop, which is not a sensor -- has never been approached deliberately.
STATUS hung the core — firmware bug, fixed 2026-09-03¶
The first status sent after id produced no reply, and the board then 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 — the ORing diodes mean pulling USB alone
leaves the board alive on pyControl's 5 V, exactly as
docs/interface-map.md warned.
Cause: TmcMotion::angleDeg() and xactualDeg() read position over SPI, and
STATUS calls both. STATUS is specified "safe at any time", so a status
before hv on reached for SPI0 before beginSpi() had ever run, with the GPIO
isolation bus switch still open.
The shared corpus could not have caught this, and that is the lesson worth
keeping. The corpus case status-is-safe-before-anything-is-configured passes
on the host because the host's motion backend just returns a number. A corpus
held by two implementations pins protocol behaviour; it says nothing about
hardware preconditions, because one of the two implementations has no
hardware. Do not read a green corpus as "the firmware is right", only as "the
two ends agree about the wire".
Fixed by making position a cache refreshed once per poll and forbidding any SPI
access unless the bus is up. Two side benefits: several bus transactions per
reply removed, and a pull in FREEWHEEL is now visible at all -- the encoder
moves there with no commanded motion, so a cache refreshed only during a move
would have reported a stale angle for the whole pull.
Also fixed: ID reported a placeholder serial¶
The first READY this firmware emitted carried
serial=0000000000000000 -- the corpus's placeholder, because the firmware never
set it from the flash unique id. Both boards would have reported the same value,
so a host confirming "am I talking to the board whose calibration matches this
joystick" would have passed against the wrong board. The default is now a
string that cannot be mistaken for a real id, so forgetting it fails loudly.
Instruments Attached To This Rig¶
Two LA5032s, one per board, established by identity beacon 2026-09-03.
| Analyzer, by USB topology | Board it is probing |
|---|---|
1-5.1.4 |
E4654C159F4D1334 (board 2, RIGHT joystick) |
1-5.2.1 |
E4654C159F591D34 (board 3, LEFT joystick) |
This table was WRONG for part of 2026-09-03 and the beacon is what caught it. The two analyzers were unplugged and power-cycled to clear a wedged capture, and came back in the opposite USB ports -- so the topology-to-board mapping inverted while the probe cables stayed put. A capture was taken on the wrong instrument before Peter noticed the wrong analyzer was blinking.
That is precisely the failure usb_topology.py warns about: the topology path
identifies the PORT, not the instrument. Re-run the identity beacon after any
replug rather than trusting this table -- it settled the question in one shot,
decoding E4654C159F4D1334 from 1-5.1.4. The mapping above is the state after
that re-identification.
Incidental hint, not a substitute for the beacon: sysfs shows a product string of
Kingst Logic Analyzer only on an analyzer that has had its firmware uploaded
this session, and nothing on a freshly power-cycled one. Useful for spotting
which one has been used, useless for identity.
Record the topology path, not the conn= spec. An LA5032 reports no USB
serial number at all, so libsigrok can only address it as
conn=<busnum>.<devnum> — and that changes when the firmware upload
re-enumerates the device. Two analyzers were seen moving from addresses 21,22 to
23,24 across one sigrok-cli --scan with nothing touched, and their relative
order inverted. The sysfs topology path is the hub port and is stable until a
cable moves.
How the mapping was established, and how to redo it after any recabling: each
board clocks its own flash unique id out of FW_TRIG0/FW_TRIG1 on command
(the I command, SelfTest::emitIdentityBeacon()), so each analyzer identifies
itself by what it recorded rather than by elimination. Both analyzers decoded
the serial of the board they were wired to in a single pass. Run
tools/identify_boards.py in instrument-control.
This needs no 36 V and no motion, which is the property that makes it usable
before anything is energised: U1801 runs from +3.3V_MCU and reaches J1501
through 33 Ω series resistors. Verified pad-to-net at tag v0.2 — U501
pad 37 (GP25) → FW_TRIG0 → U1801 pad 5 → pad 6 → R1803 → J1501
pin 26, and U501 pad 32 (GP21) → FW_TRIG1 → U1801 pad 9 → pad 8 →
R1804 → J1501 pin 28. Both boards' rails read off throughout: 0.0161 V and
0.0355 V.
Measured incidentally, and worth having: the beacon's frame-start pulse captured
at 50.0 ms (5005 samples at 100 kSa/s) against a firmware-specified 50 ms.
That is a check on the RP2040 timebase and on the whole buffer chain, taken
without energising anything. The frame contract is
board-interfaces/docs/instrument-port-identity-beacon.md.
Two analyzers means this rig is now in option 2 of
docs/instrument-strategy.md, not option 1. Nothing
cross-joystick is possible yet — that still needs the RSYNC fan-out question
answered.
Board 1 — E4654C159F2C2534¶
- Revision: rev 0, PCB git tag
v0.1. Confirmed from the.kicad_pcbat that tag: threeSN74LVC125Abuffers, no analog output block,GP10/GP11/GP13unrouted. Tagsv0.1andv0.2have byte-identical RP2040 pad-to-net maps, so which of the two was fabricated does not affect firmware. - Serial / unit:
E4654C159F2C2534— RP2040 flash unique ID, read over USB. - Designator scheme this board's silkscreen uses — see the warning below:
rev 0 / left column. Confirmed from the
v0.1netlist:J1101is the 4-position stepper terminal,J1102the 5-position encoder,J1301the 2-position limit switch. The 36 V input block isU201withR201-R207, notU1901/R1901-R1907as elsewhere in this file. Note the collision: atv0.1R1901/R1902are the 100 Ω pyControl series resistors, while at HEAD those numbers are the 1 kΩHV_EN/HV_DIAG_ENseries resistors.R19030.36 Ω is the pyControl 5 V jumper in both schemes. - Joystick this unit is calibrated for:
- Assembly date:
- Assembler:
- Firmware commit / image hash:
mouse-joystick-firm, uncommitted working tree on top ofa7995da,examples/TMC5130Communicationon envmouse_joystick, flashed bypicotool load -x2026-09-02. (Stage 1 measurements below were taken onexamples/RP2040SelfTest, builtSep 1 2026 13:31:32.) - Protocol version: n/a — no pyControl protocol implemented yet.
- Notes: Stage 1 (RP2040 subsystem) passed 2026-09-01: 27 checks, 0 failed.
Stage 2 (36 V rails + TMC5130A SPI) passed 2026-09-02 — but only with the
firmware staircase soft-start; a plain
HV_ENassertion still latches the 36 V switch off. Full record under Controller, Power — 36 V and Motor And Driver.
Designators Are Not Stable Across Revisions — Read Before Wiring A Harness¶
The project uses KiCad automatic annotation, which orders by sheet position, so inserting a part rotates every designator after it. A global re-annotation happened on 2026-08-27 when the analog output block gained its filter, and three connectors in the motor/encoder sheet rotated:
| Carries | Rev 0 silkscreen | Rev 1 onward |
|---|---|---|
| Analog angle out, BNC | not fitted | J1101 |
| Stepper coils, 4-pos 3.5 mm | J1101 |
J1102 |
| Encoder, 5-pos 3.5 mm | J1102 |
J1103 |
| Limit switch, 2-pos 3.5 mm | J1301 |
J1301 |
A rev-0 board in your hand is silkscreened with the left column. The rest of this file, and the rest of the repository, uses the right column. The 4-, 5- and 2-position housings are not interchangeable so a harness cannot physically go into the wrong terminal, but read the position count, not the number, when you are working from a board rather than from a document. Record which scheme this unit carries in the field above.
The analog output block's own designators rotated wholesale in the same pass
(R2001-R2010, C2001-C2009, and U2001/U2002 swapped between the DAC and
the op amp). Where this file names one, it names the post-2026-08-27 assignment.
Inspection¶
- Visual inspection:
- Connector orientation:
- Unit identifier legible on silkscreen:
- Sense resistor value populated at
R1203/R1204(expect 0.15 Ω, not thekicad-libsblock default 0.12 Ω — a re-placedtmc5130block reverts them): 0.15 Ω in thev0.1netlist for both positions, so the design is correct. Not visually confirmed on the physical board — check the part marking before stage 3 relies on it, because the shunt value and thevsensebit are one decision. - Solder issues:
- Rework:
R203(0402,+3.3V_MCU→LATCH, 1 kΩ) depopulated 2026-09-02 to releaseLATCHand select the switch's auto-retry. This is the fix for the 36 V turn-on fault and it should be carried into the next revision as a GPIO or a hard tie low — see Changes Recommended.
Power — sources and ORing¶
| Source powering the board | +3.3V_MCU |
+5V_SW |
+3.3V_SW |
Responds to ID |
Notes |
|---|---|---|---|---|---|
| USB only | not measured — ADC_AVDD is tied to it, so the RP2040 cannot measure its own reference |
0 V expected, not measured | 0.034 V measured via GP26 |
no protocol yet | board boots and runs; die temperature read a plausible 26.2 °C, which weakly bounds ADC_AVDD |
| pyControl only | 0 V expected | 0 V expected | measure port current too | ||
| 36 V only | 0 V expected | 0 V expected | 0 V expected | no expected | board deliberately does not boot |
36 V + USB, HV_EN low |
0 V expected | 0 V expected | the normal idle state | ||
36 V + USB, HV_EN high |
|||||
| All available |
- ORing diode part and measured
Vfat the actual logic current: - Current measured flowing back into the USB host (expect 0):
- Current measured flowing back into pyControl (expect 0):
- Current into the dead
+5Vrail under USB-only power (expect ~0): SW_RAIL_SENSE_DIV_2onGP26versus measured+3.3V_SW, rail up and rail down (R401/R4021 kΩ/1 kΩ; expect ~0 V down and ~1.65 V up): rail down: 21 counts = 0.0169 V at the pin = 0.034 V undivided (12-bit,ADC_AVDDreference), repeatable across reboots. Rail up not yet measured — 36 V not connected. No independent meter reading taken, so this confirms the divider reads something sane when off, not its gain.- Back-power:
+3.3V_MCUand+3.3V_SWmeasured with 36 V applied and both USB and pyControl unplugged (expect 0 V on both; theSN74LVC125Ahas noIoffand TI does not specify leakage atVCC= 0, so this is unspecified, not guaranteed): - Firmware refuses 5 V-domain access when
+5Vis absent: - Behavior when each source is plugged/unplugged while others are live:
- pyControl port 5 V current drawn by this board:
Power — 36 V¶
- Input voltage / current limit used: 36 V, not current-limited (supply
capable of 4 A), 2026-09-01 and again 2026-09-02. The single-shot turn-on
fault is diagnosed and worked around — see
The Rail Comes Up And Then Collapses.
Everything below marked 2026-09-02 was taken with the rail brought up by the
firmware staircase (
c), not by a plainHV_ENassertion. - Board boots with motor rail off,
HV_ENlow: yes — with USB only, the firmware's safe state drivesHV_ENlow and+3.3V_SWreads 0.034 V. Note the stage 1 firmware has no code path that can raiseHV_ENat all. - Regulator chain confirmed fed post-switch, and
+3.3V_MCUconfirmed fed from USB/pyControl only: +36V_SWmeasured with 36 V applied,GP14(HV_DIAG_EN) low andGP19(HV_EN) low (expect true 0 V, no upward creep). If it creeps at ~1.2 V/s and the 5 V rail hiccups every few seconds,DIAG_ENis high and the TPS1HTC100's OFF-state 150 kΩ open-load pull-up fromVStoVOUTis enabled. Record the voltage at t = 0, 10 s and 60 s, not a yes/no:+36V_SWnot measured directly — no test point and it does not reach the MCU. Measured instead on+3.3V_SWdownstream, sampled over 60 s with 36 V applied and bothHV_ENandHV_DIAG_ENlow: t=0 s 0.0338 V, t=10 s 0.0338 V, t=30 s 0.0306 V, t=60 s 0.0322 V. Range 0.0306-0.0338 V, drift −0.0016 V, which is ±1 ADC count. No creep, and the 5 V rail did not hiccup. Creep would have pushed+36V_SWpast the buck's 3.6 V minimum in ~3 s and shown here as the rail twitching. TheDIAG_ENleakage failure mode is ruled out on this board. A direct+36V_SWreading is still wanted.+36V_SWbulk capacitance measured or confirmed (design value ~208 µF,C1213+C1214100 µF 50 V each plus ceramics; no TVS populated):- Inrush: not measured directly — needs a current probe or a monitoring
supply. Bounded indirectly: the switch delivers
I_CLfor only 1.8-2.3 ms before tripping, so no attempt has ever delivered the full 7.5 mC that 208 µF at 36 V requires in one go. HV_ENassertion behavior, rail rise time: single shot fails.+3.3V_SWreaches 3.309 V in 3-4 ms, holds, then collapses at 38-68 ms and stays down. Six attempts, 2026-09-01 and 2026-09-02; table in the subsection below. With the firmware staircase (c) it comes up and stays up: rail live within the 60 ms train, 3.310-3.314 V, held for the full 1000 ms monitor window, 5 of 5 attempts, then soaked 40 s at 3.307-3.314 V, 2026-09-02.ILIMresistorR206value and measured current limit:R206= 40 kΩ in thev0.1netlist, design I_CL ≈ 1.15 A (K_CL= 46 A·kΩ at the bottom of the range). Not measured — and note 1.15 A sits near the bottom of the part's stated 1-5 A window, so it is the least-characterised corner of the current-limit spec.HV_SNSreading versus measured load current, withGP14(HV_DIAG_EN) raised —HV_SNSand~{HV_FAULT}report nothing whileDIAG_ENis low: not obtained. Every reading taken withDIAG_ENraised sat at 1.56-1.63 V, which isISNSFLT(5.2-6.4 mA intoR207249 Ω), i.e. the fault level rather than a load-current reading. WithK_SNS= 800 A/A andR207= 249 Ω a load reading would be 0.311 V/A, so a legitimate reading at the 1.15 A current limit would be 0.37 V. No valid load-current measurement exists yet. 2026-09-02, rail up via the staircase and no fault asserted:HV_SNS= 0.0153 V, 19 counts — but the quiet-state floor withHV_ENlow is 18-22 counts, so this is offset, not signal. The chain resolves 0.311 V/A → 2.6 mA of load current per ADC count, with a ~19-count offset floor. At the ~10-15 mA idle draw the sense chain cannot resolve anything; all it says is "far below the 1.15 A limit". It should become usable at motor currents: 0.2-0.7 A is 76-270 counts above the floor. Firmware now carriesK_SNS= 800 A/A (conversions::hvLoadAmps) and a fault-band guard, so it prints amps or "FAULT LEVEL", not raw volts. Resolved by averaging, 2026-09-02 (m,PowerSequence::measureHvCurrent, 2048 samples with a zero captured moments earlier atHV_ENlow): 36 V input current = 8.6 mA, rails up, driver disabled, nothing plugged into the motor / encoder / limit connectors. Zero 19.65-19.68 counts, live 22.98-23.03, delta 3.33-3.35 counts, three runs. Caveat:K_SNSis specified atI_OUT= 1 A and this is 100× below that — read it as "under 10 mA", not 8.6 ± 0.1 mA. The precision is real; the accuracy at this current is uncharacterised.- 36 V input current, rails up, driver disabled: 8.6 mA (310 mW), as above.
U201's ownI_Q,VSis 1.2-1.65 mA of that and is drawn upstream of the bulk, leaving ~260 mW on+36V_SW. Cross-checks the energy accounting in the subsection below, which assumed 0.2 W. - 36 V input current, driver enabled, motor at standstill hold:
- 36 V input current, motor moving:
HV_SNSreading atGP28during a provoked fault (expect 1.3-1.6 V fromISNSFLT5.2-6.4 mA intoR207249 Ω; RP2040VPINabsolute max is 3.8 V):- 5 V rail, measured: still not measured with a meter.
+5V_SWreaches neither the MCU nor InstrumentPort-40, and there is no test point. ProbeU901pad 4 or 9, orU1001pad 5 or 8 (pad→net map read from thev0.2.kicad_pcb, not from a document). Proven in spec indirectly, 2026-09-02: the TMC5130A answers over SPI withGSTAT.uv_cp = 0andDRV_STATUSreporting no fault, which needsVCC/VSAinside 4.6-5.5 V. - 3.3 V rail, measured:
+3.3V_SW= 3.307-3.314 V sustained over a 40 s soak with the rail up via the staircase, 2026-09-02 — 0.2-0.4% of nominal, read through theR401/R402divider onGP26at 12-bit resolution. (2026-09-01: 3.309 V during the 53 ms it was live.)+3.3V_MCUnot measured — it is the ADC reference, so the RP2040 cannot measure it. - RP2040 core rail, measured:
- Rail ripple, motor idle:
- Rail ripple, motor moving:
Transient headroom — the 36 V measurement that matters¶
Limits: 46 V operating, 49 V absolute with inductive load, 50 V bridge. No TVS is
populated, and C1213/C1214 are 100 µF 50 V parts on this rail, so record
the margin to 50 V as well — this capture is the only evidence for either.
| Condition | Peak VS at the TMC5130A pins |
Margin to 46 V | Margin to C1213/C1214 50 V |
|---|---|---|---|
| Motion start | |||
| Motion stop | |||
| Deliberate stall | |||
| Power removal under load |
- Bulk capacitance value and its position relative to the high-side switch:
- Snubber populated, and its values:
- Probe method and bandwidth used (a slow probe will not see this):
- If overshoot approaches 46 V, the fallback taken (snubber / lower supply / 24 V) and why:
Thermal¶
Case temperatures — TMC5130A idle and under motion, the 3.3 V LDO, the sense resistors and the motor — are recorded in Thermal Survey, so there is one place to look and one place to fill. The rows below are the non-thermal-camera half and stay here.
- Confirmed
VCCfed externally, internal regulator offloaded: - Sequencing at power-up:
- Behavior at power-down:
The Rail Comes Up And Then Collapses — RESOLVED, 2026-09-02¶
Cause: U201 trips its own thermal shutdown 1-2 ms into charging the 208 µF
bulk, having reached only ~10-12 V, and R203 makes it latch instead of
retrying. This is the failure tps1htc100-q1.pdf §7.3.2.1 warns about
verbatim. It is a design-margin problem in the 36 V input block, not a defect
in the regulator chain, and not a load fault.
FIXED IN HARDWARE, 2026-09-02: R203 depopulated. With LATCH released
the internal 0.7-1.3 MΩ pulldown selects auto-retry (t_RETRY = 2 ms) and
a plain HV_EN assertion now works. The full stage 2 sequence runs end to
end on one assertion: rail up in 4 ms, no latched fault at the 105 ms check,
bus closed, TMC5130A registers read. 4 of 4 attempts. Raw output in
captures/2026-09-02/latch-released/.
The retry sequence is visible directly in p's 1 ms sampling:
| t | +3.3V_SW |
HV_SNS |
|
|---|---|---|---|
| 0-1 ms | 0.031 V | 1.583 V | fault asserts — the switch trips on the first burst, same as before |
| 4 ms | 3.312 V | 1.567 V | +36V_SW has already cleared the buck's threshold |
| 10 ms | 3.315 V | 1.590 V | still retrying |
| 11-12 ms | 3.315 V | 0.437 V → 0.065 V | fault clears — charging finished, no more trips |
| 13-249 ms | 3.309-3.314 V | 0.020-0.028 V | settled, and now a genuine load reading |
Retry window 10-12 ms across four attempts. At I_CL ≈ 1.15 A into 208 µF each
burst adds 8-10 V, so 36 V needs 3-4 bursts; with t_RETRY = 2 ms plus ~1.8 ms
of conduction that predicts 11-15 ms. The model and the board agree.
This is the strongest confirmation of the diagnosis available: one 0402 resistor removed, nothing else touched, and the failure disappears. It also makes change 1 below a validated rework rather than a proposal.
Firmware workaround, still available and no longer needed: a staircase
soft-start.
mouse-joystick-firm PowerSequence::staircaseStart(), command c in
examples/TMC5130Communication. 30 bursts of 1200 µs on / 800 µs off, then
HV_EN held high. 5 of 5 attempts brought +3.3V_SW up and held it for the
full 1000 ms monitor window with ~{HV_FAULT} high, then soaked 40 s at
3.307-3.314 V. With the rail up, the TMC5130A answers over SPI — see
Motor And Driver. Raw output in
captures/2026-09-02/.
Why it works: the bulk keeps its charge between bursts. Once the buck drops
out the only load left on +36V_SW is the 0.87 MΩ of board leakage, so
τ ≈ 180 s and each burst starts where the last one stopped. Dropping HV_EN
clears the latch. Total FET energy is unchanged — it is ½CV² either way — but
delivered over ~60 ms instead of one 6.5 ms burst. t_ON_pw is 60-134 µs and
t_OFF_pw 82-175 µs, so 1200/800 µs is well inside the device's pulse rating.
The off-time is the parameter that matters. Give the buck long enough and it
bleeds the bulk back to its own dropout, every burst starts from the same
place, and the staircase never climbs. The w command's 250 ms off-time is
far too long and would have reported "not charging" — a false negative.
The Evidence, 2026-09-02¶
Five single-shot p attempts plus s, d and v, all on 36 V (not
current-limited, supply capable of 4 A).
- The fault is caused by turning on.
d—HV_ENlow,HV_DIAG_ENpulsed high — readsHV_SNS0.0153-0.0169 V and~{HV_FAULT}high, no fault, three times running. Underp, t = 0 reads 0.018 V (no fault) and t = 1 ms reads 1.597 V, in 5 of 5 runs. This corrects the 2026-09-01 entry below, which read the t = 1 ms assertion as an OFF-state open-load indication asserted "when firmware had just raisedDIAG_ENwithENstill low".PowerSequence.cppraisesDIAG_EN, waits 2 ms, and only then raisesENat t = 0 — so the flag appears 1 ms after the switch is enabled, during capacitive charging. The OFF-state open-load reading cannot happen this early anyway: with+36V_SWat 0 V the 150 kΩ pull-up needsV_S − V_OUT < V_OL_OFF(1.4-2.6 V), and at 240 µA into 208 µF the node rises 1.15 V/s. HV_SNSis never a load-current reading during the failure. It sits at 1.564-1.620 V acrossR207= 249 Ω for the whole window → 6.28-6.51 mA, i.e.I_SNSFLT(datasheet 5.2-6.4 mA), the fault current source.- The device latches.
sreads~{HV_FAULT}LOW at ~105 ms withHV_ENstill high. A downstream shutdown would look different: the TPSM365R6 hiccups and the TPS73733 has no latch, so both would retry. The rail instead sits flat at 0.047-0.053 V for 200 ms whileHV_ENis high. - Energy accounting rules out the rail ever reaching 36 V. At 36 V the
208 µF holds ½CV² = 135 mJ; against the ~0.2 W switched-domain load the buck
would hold 5 V for ~650 ms. Measured hold is 34-65 ms. This survives a
4× error in the load estimate — even at 0.8 W the "reached 36 V" case
predicts ≥ 170 ms. Inverting it,
½C(V_peak² − 5.5²) = P·t_holdgives V_peak ≈ 10-13 V, and atI_CL≈ 1.15 A into 208 µF (5.53 V/ms) that is 1.8-2.3 ms of conduction — matching the t = 1 ms fault flag.
p attempt |
rail up | leaves 3.30 V | hold | +36V_SW peak, inferred |
|---|---|---|---|---|
| first, bulk cold | 4 ms | 38 ms | 34 ms | ~10.7 V |
| back-to-back 1 | 3 ms | 51 ms | 47 ms | ~11.8 V |
| back-to-back 2 | 3 ms | 50 ms | 47 ms | ~11.8 V |
| back-to-back 3 | 3 ms | 50 ms | 47 ms | ~11.8 V |
| back-to-back 4 | 3 ms | 51 ms | 48 ms | ~11.9 V |
after a successful c + x |
3 ms | 68 ms | 65 ms | ~12.8 V |
±1 ms across the burst, and the cold attempt holding less. Both follow from the bulk retaining charge between attempts: a warm attempt starts pre-charged at ~5 V — where the buck dropped out — and gets ~1-2 V further before tripping. The last row is the same effect after a successful power-up.
Still wanted: a direct +36V_SW capture. The ~10-13 V peak is inferred,
not measured. It is a ~2 ms event, so it needs a scope at C1213 pad 1
(+36V_SW) against pad 2 (GND) — a DMM's max-hold will not catch it. The
inference is consistent across six attempts and two independent arguments, but
it is still an inference.
R203 is the wrong choice, and now there is evidence. It ties LATCH to
+3.3V_MCU, selecting latch-off over 2 ms auto-retry. The datasheet explicitly
sanctions retry-until-charged "with high enough load impedance"; 0.87 MΩ
qualifies, and the firmware staircase is a hand-rolled version of exactly that.
See Changes Recommended For The Next Revision.
Original 2026-09-01 Entry, Kept For The Raw Data¶
Symptom. 36 V on J201, not current-limited, supply capable of 4 A.
Firmware raises HV_EN with the GPIO isolation bus still open and the driver
disabled:
| t | +3.3V_SW |
HV_SNS (DIAG_EN high) |
|---|---|---|
| 0 ms | 0.034 V | 0.015 V — no fault |
| 1 ms | 0.032 V | 1.607 V — fault asserts, before the rail rises |
| 3 ms | 3.309 V | 1.608 V |
| 3-56 ms | steady 3.309 V | steady ~1.59 V |
| 57 ms | collapsing | ~1.59 V |
| 252 ms | 0.047 V | ~1.59 V |
Repeatable to ±1 ms across two runs. Raw 1 ms-resolution data in
captures/2026-09-01/hv-powerup-profile-{1,2}.txt.
What this proves works. U201 passes, U901 TPSM365R6 buck works, U1001
TPS73733 LDO works, and +3.3V_SW regulates to within 0.3% in 3 ms. The chain
is not broken; something shuts it down.
Ruled out.
- A resistive short.
+36V_SWtoGNDmeasures 0.87 MΩ with a DMM acrossC1213, board fully unpowered. Caveat: a DMM probes at 1-3 V, so this does not rule out a voltage-dependent load. 2026-09-02: nor was one needed — the 0.87 MΩ turned out to be the thing that makes the staircase work. - A too-low supply current limit. The supply was not limited.
Leading hypothesis at the time: capacitive-charging thermal shutdown in
U201. Confirmed 2026-09-02, with the correction that it trips at 1-2 ms
rather than after a full charge. Relevant numbers: charging +36V_SW's ~208 µF
to 36 V costs ½CV² = 135 mJ regardless of the current limit, T_REL = 60 °C
relative shutdown, t_RETRY = 2 ms, and I_CL ≈ 1.15 A from R206 40 kΩ.
Do not trust HV_SNS to identify the fault. Per Table 7-2, VSNSFH is
shared by overcurrent, overvoltage, short-to-ground and thermal shutdown, and
with EN low it also indicates OFF-state open-load. An earlier diagnosis of
"continuous 1.15 A load" was derived from misreading this and was wrong.
2026-09-02: the "before the rail rises" reading in the table above was also a
misreading — see the correction under The Evidence.
Controller¶
- RP2040 boot behavior: works. BOOTSEL (hold
SW501, apply USB) enumerates2e8a:0003 RP2 Bootand mountsRPI-RP2, bootromUF2 Bootloader v3.0,Board-ID: RPI-RP2. Firmware boots and runs. The 1200-baud touch works, so reflashing needs no button and no physical access —picotoolreported "Device rebooted into BOOTSEL mode successfully". - USB CDC console: works at 115200. Enumerates as
2e8a:f00a, manufacturerOrb Weaver, productmouse-joystick-pcb, USB serialE4654C159F2C2534./dev/ttyACM0. - ~~SWD access:~~ struck — no SWD header and no reset button in Rev 0, by
decision;
RUN,SWCLKandSWDIOare unconnected and NC-flagged: confirmed unconnected in thev0.1netlist. No debugger was used or needed. - Clock verified: yes.
clk_ref(XOSC) 12000 kHz,clk_sys133000 kHz measured by the RP2040's own frequency counter againstclk_ref,clk_peri/clk_usb/clk_adc48000 kHz. For the crystal's absolute accuracy see the note below — the on-chip counter measures everything againstY501and so cannot see an error in it. - ~~PIO encoder decode functional~~ struck — no PIO decode exists:
Stage 1 Self-Test, 2026-09-01¶
mouse-joystick-firm, examples/RP2040SelfTest, env mouse_joystick
(MJ_BOARD_REV=0). USB power only, 36 V not connected, HV_EN low, GPIO
isolation bus open throughout. 27 checks, 0 failed. Designators below are
v0.1 designators.
Crystal Y501, absolute. −18.7 ppm apparent over 9 s, comparing the RP2040's
micros() against the host's time.monotonic() across ten 1 Hz ticks. USB CDC
scheduling jitter is order 1 ms over that span, i.e. ~110 ppm, so this is below
the noise floor of the method — consistent with a healthy ±30 ppm part but not a
measurement of it. Independently, USB full-speed enumeration requires 48 MHz
within 0.25 % derived from Y501, which bounds it regardless.
QSPI flash U502 (W25Q128JVZPIQ, 16 MiB). Unique ID read back over QSPI
(0x4B) plausible, so the part is present and responding. Declared size 16 MiB.
Address-aliasing probe at +2, +4 and +8 MiB: none aliases offset 0, so the
fitted part really is at least 16 MiB rather than a smaller part wrapping its
address space. picotool additionally verified the whole 181 760-byte image
byte-for-byte after writing.
RAM. Heap total 252 228 B, free 252 128 B. Four-pattern walk over a 16 KiB heap block clean.
Resistor-defined levels, captured in the first instruction of setup() with
internal pulls disabled and before anything drove a pin. Each of these maps to
one part, so a failure here is an assembly defect rather than firmware:
| Pin | Net | Read | Held by | Verdict |
|---|---|---|---|---|
GP9 |
~{BUS_SW_EN} |
high | R601 7.87 kΩ to +3.3V_MCU |
ok — GPIO bus isolated at reset, which every later stage depends on |
GP24 |
~{INSTR_BUF_OE} |
high | R1601/R1701/R1801 10 kΩ |
ok — instrument port silent before firmware |
GP1 |
PYC_UART_RX |
high | R403 10 kΩ to +3.3V_MCU |
ok — cable unplugged, so this is R403 and not an idle far-end TX |
GP29 |
~{HV_FAULT} |
high | R205 10 kΩ to +3.3V_MCU |
ok — pull-up present, on the right rail, no latched fault |
Spare pads. All twelve unrouted GPIOs — GP10, GP11, GP12, GP13,
GP15, GP16, GP17, GP18, GP20, GP22, GP23, GP27 — moved correctly
under both an internal pull-up and an internal pull-down. No solder defects on
any of them. GP10/GP11/GP13 are testable this way only because rev 0 leaves
them unrouted.
ADC. 12-bit, reference is ADC_AVDD tied directly to +3.3V_MCU with no
separate reference and no filter, so absolute accuracy is bounded by that rail.
| Channel | Net | Reading |
|---|---|---|
GP26 / ADC0 |
SW_RAIL_SENSE_DIV_2 |
21 counts = 0.0169 V at the pin = 0.034 V on +3.3V_SW — switched domain genuinely off, R401/R402 holding it there |
GP28 / ADC2 |
HV_SNS |
18-20 counts = 0.015 V across R207 249 Ω. Reads zero regardless while HV_DIAG_EN is low, which stage 1 keeps it, so this confirms the quiet state and not the sense chain |
| ADC4 | internal temperature | 26.2 °C — proves the ADC and ADC_AVDD are alive |
Reset reason. Reported software with watchdog caused reboot: yes after a
picotool upload, which is correct rather than a fault: picotool reboots the
chip through the watchdog. A cold power-on reads power-on.
Boot latency. From the RP2040 releasing FW_TRIG0 at reset to the first
firmware-driven edge on FW_TRIG1: ~143 ms (measured on the logic analyser,
6.948 ms → 149.626 ms in the same capture). That is bootrom, boot2, XIP setup,
C runtime and setup(). Relevant because HV_EN is held low only by R204
during that window.
Not covered by stage 1. Anything past HV_EN: the switched rails, the
TMC5130A, the 16 MHz oscillator X1201 (itself on +3.3V_SW), the motor,
encoder and limit switch. The pyControl link beyond GP1's idle level.
pyControl Port¶
- Controller / breakout model and revision:
- Port designation used:
- Continuity on all eight
J1901pins, pin-to-pin: - Event lines landed on:
- BNC lines available versus needed across the whole rig:
- Cable type and length:
- UART levels measured, both directions:
- UART idle state, measured at the RP2040 pin with the cable unplugged (expect
PYC_UART_RXhigh onR40310 kΩ to+3.3V_MCU; a low idle meansR403is missing or on the wrong rail and every frame looks like a break): high, read atGP1with internal pulls disabled, cable unplugged, 2026-09-01.R403is present and on the correct rail. R1903populated as a jumper (0.36 Ω) or as a 1206 PPTC, and which:- pyControl port 5 V current measured through
R1903(shared 1.0 A port budget that also feeds the controller's own logic regulator; no test points either side): - Event line edge quality and levels,
PULL_EVENT: FAULTline behavior:RSYNCedge timing observed:- If two boards: both receive the same
RSYNCedges, method used: - Behavior with board powered first:
- Behavior with pyControl powered first:
- Behavior on live insertion:
- Behavior on live removal:
- Motor current observed on any port conductor:
Motor And Driver¶
Expected configuration: R1203/R1204 0.15 Ω, vsense = 1, IRUN ≤ 17
(0.421 A RMS), with IRUN 24-25 (0.600 A) the open alternative. A measured
current near 0.760 A means vsense is 0 — stop.
Corrected 2026-09-09. This paragraph read "IRUN = 28, target 0.679 A RMS…
near 1.23 A means vsense is 0", all of which predates the motor being
identified as a WO-208-13-01D. See IRUN = 28 predates the motor and is above
its rating, and settle the
number with Thermal Survey.
Stage 2 passed 2026-09-02, 5 of 6 register checks. Rail brought up with the
firmware staircase (c), bus closed with b, ~{TMC_DRV_EN} high throughout,
no motor / encoder / limit switch connected. Read over USB CDC; SPI datagrams
not yet cross-checked on DIG2-DIG5.
| Register | Read | Verdict |
|---|---|---|
IOIN (0x04) |
0x11000011, status 0x79 |
VERSION = 0x11, the TMC5130A signature. The right chip, alive, on all three rails |
IOIN.DRV_ENN |
1 | driver disabled, and GP3 reaches the chip through the bus switch |
IOIN.SD_MODE |
0 | motion-controller mode, SD_MODE tied to GND as designed |
IOIN.REFR_DIR / ENCA / ENCB / ENCN / SWCOMP |
all 0 | connectors empty; ENCN held by R1201, ENCA/ENCB by R805/R806 |
IOIN.REFL_STEP |
1 | firmware expected 0 and flagged it. Firmware is wrong, not the board — see Open Issues |
GSTAT (0x01) |
0x00000001 |
reset = 1 (expected on first read after power-up), drv_err = 0, uv_cp = 0. uv_cp = 0 means the charge pump is happy, which needs VS genuinely up |
GCONF (0x00) |
0x00000000 |
reset value |
CHOPCONF (0x6C) |
0x00000000 |
vsense = 0, the reset value. With 0.15 Ω fitted this is ~1.8× rated current. Stage 3 must set it to 1 and read it back before lowering ~{TMC_DRV_EN} |
DRV_STATUS (0x6F) |
0x80000000 |
stst (standstill) only. No ot, no otpw, no s2ga/s2gb |
XACTUAL (0x21) / X_ENC (0x39) |
0x00000000 |
as expected, nothing attached |
SPI status byte 0x79 on the first read: reset_flag, standstill,
velocity_reached, position_reached and status_stop_l set, driver_error
clear. status_stop_l follows from REFL_STEP = 1 and is not a fault —
SW_MODE defaults to stop_l_enable = 0, so the input is only being reported,
not enforced.
Measure these before a motor is attached, with the switched domain up and the bus switch still isolated:
~{TMC_DRV_EN}atU1201pin 29 (expect ≥2.31 V, the TMCV_INHIof 0.7 ×VCC_IO;R110310 kΩ to+3.3V_SWshould give ≥2.75 V worst case. Near 1 V means the pull-up is missing or on the wrong rail and the power stage can enable itself):~{TMC_CS}idle level, domain on and bus isolated (expect ≈3.4 V, deasserted):TMC_SPI_SCK/TMC_SPI_COPI/TMC_SPI_CIPOidle levels, domain on and bus isolated (expect ≈0.28 V each; ~1.9 V means 100 kΩ was fitted instead of the 10 kΩR1401-R1403and the taps straddle theSN74LVC125A2.0 VVIH):REFR_DIRatU1201pin 9 (expect a hard 0 V). Added 2026-08-27, now tied directly to GND — a 10 kΩ pull-down (R1104) was fitted first and replaced by the hard tie, which is the more robust treatment and what the datasheet asks for on an unused input. This pin is a plainDIwith no internal pull — unlikeSD_MODE/SPI_MODE(pu) andSWSEL(pd) — and it was floating before rev 1. Anything other than ~0 V means the tie is broken, and a floating right-stop input next to a 36 V bridge can trip a stop:DAC_SPI_SCK/DAC_SPI_COPIidle levels, domain on and bus isolated (expect ≈0 V fromR2010/R2009, the block's own 10 kΩ pull-downs), and~{DAC_CS}idle level (expect ≈3.3 V fromR2006to+3.3V_SW, deasserted — a low here would assertSYNCand let a floating bus clock data in):-
All of the above measured again with the switched domain off (expect 0 V on every one, including
~{DAC_CS}, because its pull-up references+3.3V_SWwhichR401/R402hold at 0 V): -
Motor part:
- Motor rated current per phase, and whether RMS or peak:
- Sense resistor populated, measured on the physical board:
vsenseread back from CHOPCONF (expect 1):IRUNread back (expect 17 unless a thermal run has moved it; the bench console'snrefuses anything above 17):- Coil current measured, RMS (expect 0.421 A at
IRUN17, 0.600 A at 24): - Driver config read back over SPI:
DIAG0behavior (R110110 kΩ pull-up to+3.3V_SW; open-drain, idles high):DIAG1behavior (R110210 kΩ pull-up to+3.3V_SW):- Sense resistor temperature, standstill hold at current — record it in
Thermal Survey
with the
IRUN,vsenseand shunt value it was taken at: - Motion smoothness / audible behavior:
- Microstep smoothness at the 101 mV peak sense signal (89 mV across the
0.15 Ω itself; the old 163 mV figure came from
IRUN28): - Step-rate ceiling achieved at 36 V, versus the same motor at 24 V if available:
Encoder¶
- Encoder part:
- Interface: single-ended / differential:
- Output stage: push-pull or open-collector. The board assumes push-pull —
R801/R802are 100 kΩ pull-downs. An open-collector part needs them changed to pull-ups: J1103pin order confirmed against the encoder's harness (board expects pin 1 =GND, pin 2 = reserved index NC, pin 3 =ENC_A_RAW, pin 4 =+5V_SW, pin 5 =ENC_B_RAW; supply sits between the two channels): confirmed 2026-09-02. Left and right harness tables are inharness-pinouts.md, which is also where the two-swap mirroring for the left joystick is specified.- Supply voltage and measured current (from
+5V_SW, so the encoder resets with the switched domain): - Counts per revolution:
- Maximum output frequency, versus the fitted RC corner of ~72 kHz
(
R803/R8044.7 kΩ ×C802/C803470 pF, τ = 2.2 µs): - TMC5130A
X_ENCversus quadrature edges captured onDIG0/DIG1: - ~~Index (
ENC_N) behavior:~~ struck — there is noENC_Nnet; TMCENCNis tied to GND throughR120110 kΩ andJ1103pin 2 is a no-connect: - Angle versus independent angular measurement:
- ~~Deliberate mismatch raises
ENCODER_MISMATCH~~ struck, fault retired:
Homing¶
- Limit switch part (at
J1301, debounced byU1301MAX6816 ontoLIMIT_SW→ TMCREFLpin 8): - Debounce behavior observed:
- Switch closure seen on InstrumentPort
DIG11, and the driver's reaction in the next SPI poll: - ~~
REFLtest pad jumpered to force a closure, and the driver reacts:~~ struck — there is no test pad, andGP9is~{BUS_SW_EN}rather than a stimulus. Force the closure at the switch itself and record how: - Homing repeatability over N runs:
- Homing time:
- Behavior when the switch is never reached:
- Hardware latch versus software position agreement:
Analog Angle Output¶
Expected configuration as placed 2026-08-27: DAC80501 (U2001) on its internal
2.5 V reference, GAIN register 4h = 0x0101, a 2-pole 987 Hz Sallen-Key
(R2001 20 kΩ, R2002 13 kΩ, C2003/C2007 10 nF C0G), OPA197 (U2002)
at DC gain 1.32, R2007 249 Ω, D2001 ESD, out to the BNC J1101. Full scale
3.300 V, LSB 50.4 µV.
- DAC part and
VDD: GAINregister read back as0x0101before any data write (reset value is0x0001, the 0-5 V range; the 3.3 V ceiling only holds after this write):VREFIOmeasures 2.500 V:- Level translation required, and part used (expect none —
DAC80501VIHis 1.62 V flat acrossVDD2.7-5.5 V): - Software
LDACsynchronous update confirmed (DAC_SYNC_ENplus theTRIGGERregister; there is no hardware~LDACor~CLRpin): - Output at commanded zero code (expect ~25 mV, not 0 V — the
OPA197light-load floor; the valid range should start at 50 mV or above): - Output at commanded full scale (expect 3.300 V, not 5 V):
- Linearity across range:
- Output at boot, before firmware runs (expect 0 V; the
Zsuffix resets to zero code): - Output when unhomed / disarmed / faulted:
- Update rate achieved:
- Behavior into the researcher's actual recording input:
- ~~
AUX_ANALOG_OUTpopulated, and what it carries:~~ struck 2026-08-27 — not populated.J1101is a single BNC and a second channel would be a second full block instance.
Filter — Measure It, Do Not Assume It¶
- Corner measured (expect −3 dB at ~827 Hz;
f0987 Hz,Q0.606): - DC group delay measured (expect 266 µs, near-flat in band):
- Full-scale settling to one OneBox LSB (expect ~2 ms):
C2003/C2007confirmed C0G, not X7R, on the assembled board:- Latency from motion to output voltage, measured from the InstrumentPort
capture (DAC bus, encoder, and analog output in one file). Report the
filter's 266 µs separately — it is not firmware latency, and every latency
measured through
AIN0or seen at the BNC includes it:
Chopper Alias Check — The Measurement That Justifies The Filter¶
The OneBox ADC is an AD7616-P at a fixed 30.3 kHz behind only a first-order
~39 kHz filter, and the TMC chopper runs at 16-30 kHz, so any chopper setting
folds into 0-15 kHz. Sweep the chopper frequency while recording the angle
channel:
- Artifact amplitude on the angle channel, at each chopper setting tried:
- Does a periodic artifact move with the chopper frequency? If it moves, it is aliasing, not joystick motion:
- Whether 987 Hz gave enough rejection, or the corner should be retuned (scale
R2001andR2002by the same factor; the ladder is in the block description):
If The Angle Channel Misbehaves, Check In This Order¶
- Hysteresis after large excursions, or motor-correlated noise: suspect
C2003/C2007dielectric first. They should be C0G; a class-2 substitution adds a dielectric-absorption tail and a piezoelectric path from the stepper. - A periodic artifact: run the chopper sweep above.
- A gain error near 0.025%: that is
R2007249 Ω into the OneBox's 1 MΩ input. Expected, and calibration absorbs it. - Output stuck at 0 V: distinguish an unpowered output section (held there by
R2005100 kΩ) from a reference alarm caused by a misconfiguredREF-DIV. Both read as a legitimate end of travel, soSTATUSmust report the output invalid in either case.
Protocol¶
- Simulator conformance result:
- Malformed input handling:
- Duplicate sequence handling:
- Unrecognized verb rejected:
IDreports board serial:- Heartbeat timeout, measured:
- Time from heartbeat loss to
HV_ENdrop: - Message rate observed:
- Each fault code provoked and observed:
Safety¶
- State of outputs at boot, measured before firmware runs:
- Motor rail confirmed off at boot:
- Motion refused outside
ARMED: - Travel limit enforcement, tested by attempting to exceed:
- Velocity and acceleration limit enforcement:
- Time from cable removal to motion stop:
- Time from heartbeat loss to motion stop:
EXT_FAULT_INfitted, and its behavior:- Emergency disable behavior:
- Recovery path from each fault:
InstrumentPort-40¶
- Adapter used:
la5032-instrument-port-adapterv1.0 with a Kingst LA5032, 2026-09-01. Host path:sigrok-cli0.7.2 / libsigrok 0.6.0 frominstrument-control's Guix manifest, driverkingst-la2016, FPGA bitstreamkingst-la5032a0-fpga.bitstreamextracted intoinstrument-nonfree. The device identifies as "Kingst LA5032 with 34 channels". This was also the adapter's own first bench validation — see that repository's bring-up log. J1501cable orientation confirmed by continuity, pin-1 to pin-1: not by continuity — confirmed functionally, which is stronger.FW_TRIG0andFW_TRIG1carry deliberately different patterns (1 Hz square wave versus three 10 ms pulses at boot), and each appeared on its expected channel:TRIG0on LA5032 ch 16,TRIG1on ch 17. The cable is not mirrored. A pin-1-to-pin-1 continuity check is still worth doing for the other 38 conductors.- Buffer
~OEstate at power-on, before firmware, confirmed high-Z (R1601/R1701/R1801, 10 kΩ each to+3.3V_MCU): yes —GP24read high at boot, sampled before firmware drove any pin. - Firmware enables buffers at startup: yes.
examples/RP2040SelfTestenables them to emit a boot signature and then a 1 Hz heartbeat onFW_TRIG0. On a board with no LEDs, no test points and no SWD header, that is the only evidence of firmware execution that does not depend on USB. Theicommand toggles it. - Buffer state reported in
ID/STATUS: n/a — no protocol yet. - All 11 Rev 0 Strands present and correctly mapped:
DIG0ENC_A_BUF,DIG1ENC_B_BUF,DIG2TMC_SPI_SCK_BUF,DIG3TMC_SPI_COPI_BUF,DIG4TMC_SPI_CIPO_BUF,DIG5~TMC_CS_BUF,DIG7PYC_UART_RX_BUF,DIG8PYC_UART_TX_BUF,DIG11LIMIT_SW_BUF,TRIG0FW_TRIG0_BUF,TRIG1FW_TRIG1_BUF: 2 of 11 verified.TRIG0(pin 26, ch 16) andTRIG1(pin 28, ch 17) both confirmed carrying the expected patterns, which also validatesU1801and the~{INSTR_BUF_OE}enable path. The other nine are untested;DIG2-DIG5get exercised by stage 2 andDIG7/DIG8by stage 4. Measured timing on the two that work, against firmware nominals:
| Strand | Nominal | Measured |
|---|---|---|
TRIG1 boot signature |
3 pulses, 10 ms half-period | 3 pulses; 10.135, 10.018, 10.005, 10.002, 10.001 ms |
TRIG0 boot signature |
8 pulses, 1 ms half-period | 8 pulses; 1.001-1.009 ms |
TRIG0 heartbeat |
1 Hz | 500.26 ms half-period, jitter ±20 µs |
The 0.26 ms on the heartbeat is loop() quantisation (a delay(10) poll), not
clock error. Captures: signature.vcd, heartbeat.vcd — session scratch, not
archived.
- DIG12 confirmed inactive, not a stuck low — U1801's spare output stops at
R1805 and J1501 pin 20 is a no-connect:
- pyControl serial decodes at 115200 8N1 on DIG7/DIG8, both directions:
- Decoded bytes on DIG7/DIG8 match what firmware reports receiving and
sending, confirming the parser rather than the wire:
- TMC SPI decodes on DIG2-DIG5, and TMC_SPI_CIPO goes quiet when
~{BUS_SW_EN} is deasserted:
- DIG2-DIG5 read their idle levels with the switched domain up and the bus
isolated (~TMC_CS high, the other three low, per R1401-R1404), and read 0 V
with the domain off — the taps are on the A / switched side of the bus switch:
- Encoder decode check. Quadrature edges counted on DIG0/DIG1 over a
known joystick sweep, against X_ENC read over SPI in the same capture.
Edge count, register delta, and any discrepancy:
- Direction reversal produces the expected X_ENC sign change:
- DIG0/DIG1 read a defined low with HV_EN off, confirming the 100 kΩ
pull-downs R805/R806 hold the switched-domain taps:
- Signal integrity at the instrument:
- AOUT* confirmed unpopulated, and AGND (pins 31, 34, 37, 40) confirmed tied to
GND rather than left as an isolated island:
- Gate D motion/timing check repeated with analyzer attached versus detached, any
difference:
- Session-length capture demonstrated:
- Physical cable arrangement after a realistic session:
- Captures archived at: captures/2026-09-01/ and captures/2026-09-02/ —
see the READMEs there.
pyControl Integration¶
First filled 2026-10-06 (Gate E on v1.0 unit A; details in
mouse-joystick-firm/pycontrol/README.md, "Gate E Passed On v1.0 Unit A").
- pyControl version: 2.1.1 (
pycontrol-codecheckout, GUI not used:tools/pycontrol_run.pydrives the samePycboardAPI headlessly), pyboard PYBV11, MicroPython 1.26.1, serial397734803233, Breakout 1.2 port 4 (machine.UART(3)) → the board's RJ-45. - Task file used:
mouse-joystick-firm/pycontrol/tasks/mouse_joystick_check.pywithhardware_definitions/mouse_joystick_bringup.py(right= port 4), both uploaded as the comment-free copiestools/pycontrol_lean.pygenerates (the pyboard's 95 KB flash had 36 KB free). - Events received and logged:
idreply,HOMED,TARGET(twice),RELEASED(23 ms afterbrake 1.0),TOUCH,NOMOVE,TRIAL, the heartbeat replies at 25/s (1236 lines in 50 s, none lost), through pyControl's data log asprintandinputevents; runsmouse-joystick-firm/evidence/2026-10-06-1019-unitA-gate-e/and-1037-. - Timestamp agreement after Rsync alignment, with uncertainty: not yet —
the bring-up task does not send
rsync; the board'st_usand the pyboard's ms clock were only compared by eye (sequence and spacing agree). - If two boards: not yet (unit B is not on the bench).
- Failure and recovery behavior from the task's perspective: two cases seen.
(1) A host that stops talking: the REPL session of 10:01 waited 150 ms
during a
fetchand the board faultedhost_timeout, dropped the rail and the brake, answered pings andclrfault— as specified. (2) A task that dies betweenhv onand its first heartbeat (run 10:17, aNameErrorin the task): the board's host watchdog was not armed yet, so the rail stayed up, idle, until the next run'shv on/hv off. Harmless here; a task should send its first heartbeat (or arm the watchdog) right afterhv on, andrun_endmust alwaysstop/disarm/hv off, whichmouse_joystick_checkdoes. - Observation for the rig: parking at 0° after a trial ended with the encoder at −0.9/−0.99° while the driver reported its target reached (both runs). The resync at the trial's close had made the two agree, so the degree was lost in the park move itself, a direction reversal after the move to −5°: coupling backlash, most likely. Home before a trial that needs the park position exact.
Two-Board Rig Notes¶
Only relevant once a second unit exists. This is where the form-factor decision gets its evidence.
- Serials of both units:
- Firmware hashes, confirmed matched:
- Sense resistor values, confirmed matched:
- Angle-output gain and offset, both units, and the difference:
- Homing repeatability, both units, and the difference:
- Coil current, both units, and the difference:
- Cross-joystick timing uncertainty after
RSYNCalignment: - Any systematic difference between units that would confound a two-joystick comparison:
Board 2 — E4654C159F4D1334¶
The as-built unit: R203 is intact, so this is what a v0.2 or v0.3 board
does out of the box. Arrived with blank flash (enumerated 2e8a:0003 RP2 Boot),
flashed examples/TMC5130Communication on env mouse_joystick by
picotool load -x.
Stage 1 has NOT been run on this unit. Everything below is stage 2 only.
- 36 V input: 36 V on
J201, not current-limited. Note: the firstpattempt on this board ran with no 36 V present and is archived as the reference signature for that condition — rail never moved andHV_SNSnever left its offset floor for the full 250 ms, because belowV_S,UVLOR(5.0-5.75 V) the switch does not operate and reports nothing. Contrast board 1, where the fault peggedHV_SNSat 1.58 V within 1 ms of every enable. - Single-shot
HV_ENfails here too, confirming this is the revision and not board 1: rail peaked 3.339 V, held 17 ms,~{HV_FAULT}LOW, latched. Board 1's cold first attempt held 34 ms. The shorter hold means board 2's switch trips a little earlier and so reaches a lower+36V_SWpeak — part-to-part variation inT_RELand die thermal impedance. - Stage 2 passes with the firmware staircase as the default power-up path:
rail up in 132 ms (60 ms train + settle), peak 3.3105 V,
~{HV_FAULT}high, bus closed,VERSION=0x11,GSTAT.drv_err= 0,uv_cp= 0,DRV_STATUS=0x80000000,CHOPCONF.vsense= 0. - 36 V input current, rails up, driver disabled: 13.7-13.8 mA, three runs,
delta 5.29-5.33 counts against a zero of 21.10-21.21. Board 1 read 8.6 mA
(delta 3.33-3.35). Do not read that as a real 5 mA difference — it is a
2-count difference on a sense ratio specified at
I_OUT= 1 A, which is 100× higher. Both boards are "well under 20 mA" and that is all this channel can currently support. IOIN=0x11000014, which differs from board 1 — see the retraction.- Captures:
captures/2026-09-02/board-E4654C159F4D1334/.
Still outstanding on this unit: the whole of stage 1, every meter measurement, transient headroom, and thermal.
Stage 3 — Motion, Encoder And Homing, 2026-09-02¶
Passed. First time anything downstream of the driver has been exercised on
either board. Run with mouse-joystick-firm examples/MotionAndHoming on env
mouse_joystick, over TMC51X0 4.0.3. Motor, encoder and limit switch all
connected, motor installed in the rig through the gear pair.
Parts, identified the same day: motor Lin Engineering WO-208-13-01D (NEMA
08, 1.8 deg/step, 4-lead bipolar, 0.6 A/phase, 6.5 ohm/phase, 3 oz-in, no
gearbox, rear shaft), encoder US Digital E4T-1000 single-ended (1000 CPR,
4000 quadrature counts/rev, 5 V push-pull). Datasheets in docs/datasheets/.
Rail and bus. +3.3V_SW up in 132 ms, peak 3.317 V, firmware
staircase. Bus closed after. Note that +3.3V_SW reads 3.3169 V immediately
after HV_EN drops and 0.033 V a few seconds later — that is +36V_SW's bulk
bleeding down through the buck, not a stuck rail.
Driver, over SPI.
| Reading | Value | Verdict |
|---|---|---|
IOIN.VERSION |
0x11 |
TMC5130A |
IOIN.SD_MODE |
0 | motion-controller mode |
GSTAT |
drv_err 0, uv_cp 0 |
charge pump happy, so VS is genuinely up |
CHOPCONF written and read back |
0x10430153 |
vsense = 1, TOFF 3, TBL 2, CHM 0 (SpreadCycle), HSTRT 5, HEND 2, MRES 0 (256 usteps), intpol 1, SYNC 4 |
IHOLD_IRUN written |
IRUN 8, IHOLD 2 |
0.211 A and 0.070 A RMS/coil. Write-only, so as-written, not read back |
DRV_STATUS standstill, driver off |
0x80020000 |
stst 1, cs_actual 2 |
DRV_STATUS standstill, driver on |
0xA0020036 |
ola 1 — expected false detection at standstill, per the datasheet |
intpol = 1 and SYNC = 4 were not chosen: TMC51X0 seeds its register mirror
with the datasheet's CHOPCONF reset default 0x10410150 and writes whole
registers, so those fields ride along. SYNC = 4 puts chopSync at
fCLK/(4*64) = 62.5 kHz; with TOFF = 3 the chopper cycle ends well before each
sync pulse, which is the arrangement the datasheet asks for. Benign, but it is
inherited rather than decided, and it fixes the chopper frequency — worth
knowing before the angle-channel alias sweep on rev 1.
Current. vsense = 1 read back before the output stage was ever enabled,
which is the whole gate. The firmware ceiling is IRUN 17 = 0.421 A RMS, from
reading the vendor's 0.6 A as a sine peak — the conservative of the two
possible readings, since Lin Engineering does not say which it is. The
equal-heating reading would allow 0.600 A RMS (IRUN 24-25). Unresolved, and
it needs a coil-current measurement and a case temperature to settle. The
IRUN = 28 figure that docs/rev0-plan.md and the firmware's AGENTS.md still
carry predates the motor being identified: it is 0.679 A RMS, above the rating
on either reading, and should be revised.
Update 2026-09-09: both have been. docs/rev0-plan.md was swept on
2026-09-08 and the firmware's AGENTS.md on 2026-09-02; each now carries IRUN
≤ 17 plus a note about what it used to say. The measurement that settles 17
versus 24-25 is still owed — see
Thermal Survey.
Motion. One jog step = 1280 usteps = 9.00 deg at the motor shaft.
VMAX5368 = 5119 usteps/s = 0.100 rev/s,AMAX=DMAX=D1= 219 = 0.5 rev/s^2.- Jog time 459-460 ms for 1280 usteps, repeatably. Matches the ramp.
- Encoder follow ratio in free travel: 1.0000 and 1.0102.
- Audible behaviour: not recorded. Coil current: not measured.
Encoder.
ENC_CONSTmust be written — it resets to zero, soX_ENCdoes not count at all until firmware sets it. Scaled to 12.8 usteps per quadrature count (51200/4000) in decimal mode, since 12.8 is exact there and recurring in binary.X_ENCthen reads in microsteps and compares directly withXACTUAL.- The encoder counts down as
XACTUALcounts up. A positive constant gave a follow ratio of −0.82; the negated constant gives +1.00. The negation is −13 integer + 0.2 fractional, not −12 and 0.8:ENC_CONSTis one signed fixed-point value and the fraction is always added. That encoding is now bench-confirmed rather than inferred. - Round trip ±1280 usteps: residual −51 usteps = −4 quadrature counts. That is about three encoder quanta, so backlash between the motor shaft and the encoder is at or below the measurement floor — expected, since the E4T is on the motor's own rear shaft and the gear pair is downstream of it.
- 1000 CPR confirmed against 51200 usteps/rev. A ratio near 2 or 0.5 would have meant the fitted part was not the CPR the firmware was told about.
- Maximum output frequency versus the fitted ~72 kHz RC corner: not stressed. At 0.1 rev/s the A/B channels run at 100 Hz, four orders below the corner.
X_ENCversus quadrature edges captured onDIG0/DIG1: not done.
Homing. See also the polarity finding under Open Issues.
- Resting
IOIN.REFL_STEP= 0, triggered = 1, so the switch is active high andSW_MODE.pol_stop= 0. Consistent with a normally-closed switch to GND through the MAX6816. - The
J1301→U1301→LIMIT_SW→ TMCREFLpath works on this board. Driven by the motor,REFL_STEPwent 0 → 1 on engage and 1 → 0 on release. - Switch throw: 512-630 usteps at the motor shaft between engage and release, from the stepped sweep and the homing creep respectively.
- The switch is at the POSITIVE end of travel. It is wired to
REFL, which on the TMC5130A stops negative motion, soSW_MODE.swap_lrhas to be set or the hardware stop guards the wrong direction entirely. Firmware sets it. - The switch's reporting delay is 8.74 ms — debounce plus signal path, measured by taking two closing-edge latches at two approach velocities in one position frame: 85 usteps apart at 10240 and 512 usteps/s. Substantially shorter than the tens of milliseconds a MAX6816 might have suggested, and it needed no instrument. This fills the "debounce behavior observed" field.
- Homing is two-stage, which is what the delay above justifies. Fast
approach at 0.200 rev/s latching the closing edge, then a slow creep off
the switch at 0.010 rev/s latching the releasing edge, and the release
position is the datum.
XLATCHis written by the chip at the edge, so neither stage is limited by firmware's poll interval. XLATCHequalled the settledXACTUALon the fast approach: overshoot 0 usteps, which is what a hard stop should give.- Closing edge to release edge: 691-728 usteps across runs. That is the approach's 89-ustep debounce overshoot plus the switch's own throw.
- Repeatability: 2 to 5 quadrature counts, in BOTH schemes. Four series of five runs: single-stage at 0.050 rev/s gave 51 usteps (4 counts); two-stage gave 25 usteps (2 counts), then 64 (5), then 64 (5) again with a throwaway home first. The rig cannot distinguish the two schemes at n = 5, and an earlier entry claiming the second stage halved the scatter was reading a single lucky series. Retracted.
- Why that is expected, not disappointing: the reporting delay is 8.74 ms and highly repeatable, so it is a systematic offset. It moves where zero is; it does not widen run-to-run spread. What the second stage buys is that the datum no longer depends on approach velocity (89 usteps of offset at 0.200 rev/s, 4.5 usteps at the creep), and that the primary approach can run four times faster than a single-stage home that has to be slow because it is the datum.
- The residual 2-5 counts is unexplained. 64 usteps is five encoder quanta,
so it is not quantisation. Most likely the switch's own mechanical
repeatability amplified through the gear pair — testable by homing against a
hand-held short at
J1301instead of the mechanism. Backlash between motor shaft and lever, and variation in the reporting delay, are the other candidates. - Record the velocities with any future figure — the datum moves with approach speed, so a repeatability number without a velocity is meaningless.
- Homing time: 2531 ms per two-stage run, of which the fast approach is 365 ms and most of the rest is the creep (768 usteps at 512 usteps/s). Raising the creep to 0.020 rev/s would roughly halve it and still keep the creep's share of the delay under one encoder count. Single-stage was ~1015 ms.
- Switch throw, engage to release: 512-630 usteps, 3.6-4.4 deg at the motor. (An earlier 256-ustep figure came from a coarser stepped sweep.)
- Behaviour when the switch is never reached: bounded. The approach targets a position two motor revolutions away, far beyond the rig's travel, so a switch that never asserts ends the move rather than running the mechanism.
- Hardware latch versus software position agreement:
XLATCHandXACTUALagree exactly.ENC_LATCHis latched in the same event.
Travel, measured — this was not known before.
- Positive end: the limit switch.
- Negative end: a mechanical stop, found by the firmware's encoder follow
check.
XACTUALreached −4330 usteps whileX_ENCstopped following at about −3276, i.e. the ramp kept going and the shaft did not. - Usable travel ≈ 4000 usteps ≈ 28 deg at the motor shaft, less at the joystick through the gear pair. Every bring-up distance has to fit inside this; the first encoder round trip was written at 5120 usteps and ran straight off the end of the mechanism. Corrected 2026-09-09, twice over. The hard stops are movable, so 28 deg is where they sat that day and not the mechanism's range, which is ~60 deg on both joysticks; and the gearing is 1:1, so joystick degrees equal motor degrees rather than being "less". See The 28 deg travel figure is a hard-stop setting, not a mechanism property.
- Nothing was strained finding it. At
IRUN8 the motor simply stalls, and the follow check ends the move within one 200-ustep window.
Not yet measured on this unit: coil current RMS, sense-resistor
temperature, DIAG0/DIAG1 behaviour, microstep smoothness, step-rate ceiling,
encoder supply current, and any of it at a current above IRUN 8.
Thermal Survey — Procedure And Results (opened 2026-09-09)¶
Taken with a thermal imaging camera on v0.2 hardware, deliberately, rather
than waiting for v0.3. The motor, driver, shunts and 36 V path are identical on
the two revisions — verified by diffing kicad/ at v0.2 against cab25b3 — so
waiting buys nothing on any row below.
What it settles. IRUN 17 (0.421 A RMS) versus 24-25 (0.600 A), the last
open question about the fitted motor and the one input the shunt decision needs.
That decision is a v1.0 layout matter: staying at 0.15 Ω keeps the present
land, and moving to 0.22 Ω 2512 changes it in both the library block and this
board, where BRA/BRB are routed tight. Take it before v1.0 placement, not
after.
It also fills four slots owed since before v0.2 shipped: TMC5130A case temperature idle and under motion, the 3.3 V LDO, and sense-resistor temperature under load.
Before anything is energised¶
- Hand-check the left joystick's swing. It may have been left jammed on
2026-09-03 and that is still open — see OPEN: the mechanism may have been left
jammed. If there is any doubt,
take the motion runs on the RIGHT joystick, board
E4654C159F4D1334, whose usable travel is measured at ≈4000 usteps ≈ 28 deg at the motor shaft. - Re-establish which board is on which port with the
IDreply or the InstrumentPort beacon. The harnesses are mirrored, and the table at the top of this file is only as good as the last recabling. - Record the board serial on every reading. Two interchangeable boards in one rig is the failure mode this log exists to prevent.
- Record ambient in the same frame as every part. The quantity that transfers is rise above ambient, not absolute case temperature.
Camera setup — this is what decides whether the numbers mean anything¶
- Emissivity dominates the error. Black plastic IC packages read acceptably at ~0.95. The motor's plated can, the shunts' metal element and bare copper read far too low at that setting. Put a matte black target — electrical tape or paint — on the motor can and beside the shunts, and read the target rather than the metal. Record the emissivity you set.
- Shiny surfaces reflect you and the room. If a hotspot moves when you move, it is a reflection, not a part.
- Spot size versus distance. A 1206 shunt is smaller than the detector spot at a typical working distance, so a reading there is an average of its neighbourhood. Get close, and mark which readings are spot averages.
- Let it settle. An 82 g NEMA 08 has a thermal time constant in the many-minutes range, so the 30-minute soaks below are not padding. A reading taken a minute in tells you nothing.
- Record camera model, emissivity, working distance, whether a target dot was used, and ambient — once per run.
The two runs, and one firmware constraint to know first¶
The bench console's n command refuses any run current above IRUN 17:
conv::kCurrentScaleCeiling is derived from the conservative 0.421 A reading, so
IRUN 24 needs a rebuild, not a keystroke. IHOLD is likewise fixed at
conv::kCurrentScaleHold = 2 (0.070 A RMS), so a standstill soak at run
current — the true worst case for the coils, and one that needs no motion at all
— also needs a firmware change. Record which firmware and which path each run
used.
- Run A — energised, standstill, 30 min. Baseline. Note whether the coils sat
at
IRUNor dropped toIHOLD; atIHOLD= 2 this is a near-idle number and must be labelled as one. - Run B — representative continuous motion, 30 min. Back and forth, well inside the measured travel. This is the number the duty-cycle question wants.
Repeat both at each IRUN under test. Predicted copper loss, both phases at
6.5 Ω/phase: 2.31 W at 0.421 A (IRUN 17), 4.68 W at 0.600 A (IRUN 24),
and 7.29 W at 0.749 A, the vsense = 1 hardware ceiling.
Results¶
Copy this block once per (board, run, IRUN). Numbers with units, not pass/fail.
- Board serial / run (A or B) /
IRUN/vsense/ shunt value: - Firmware and path used (bench console or protocol):
- Ambient, emissivity, camera and working distance:
| Part | Designator | Case temp | Rise above ambient | Notes |
|---|---|---|---|---|
| TMC5130A | U1201 |
|||
| Sense resistor A | R1203 |
|||
| Sense resistor B | R1204 |
|||
| 3.3 V LDO | U1001 |
|||
| 5 V buck | U901 |
|||
| High-side switch | U201 |
|||
| 36 V bulk | C1213/C1214 |
|||
| Motor can | — | |||
| Gear pair / mechanism | — | |||
| Hottest thing in frame, whatever it turns out to be |
DRV_STATUSotpw/otat the end of the run (free over SPI, and it is the chip's own thermal opinion):- Coil current measured, RMS, at this
IRUN: - Anything unexpected in the field of view:
Run B — board E4654C159F4D1334 (RIGHT), 2026-09-09¶
- Conditions:
IRUN17 = 0.421 A RMS,vsense= 1 read back,R1203/R12040.15 Ω,IHOLD2. Rail up via the firmware staircase, 133 ms, peak 3.317 V. - Firmware and path:
MotionAndHomingover the USB console, repeatedg(jog one step away from the switch and back, ±9 deg at the motor shaft) every 1.2 s. - Duration: 10:44 to 11:50, ~66 min, with a 50 s break at 11:14. About 1300 cycles, 2600 legs at 459 ms each.
- Duty: motion occupied 72% of wall time, but
DRV_STATUSpolledcs_actual= 15 withstst= 1 throughout, so the coils sat at essentially run current between cycles as well as during them. The firmware never writesTPOWERDOWN, so it holds the chip default (~164 ms) and the standstill gaps here are shorter than power-down plus ramp. Electrical duty is therefore near 100%: this is the continuous case, not a duty-cycled one. - Camera: emissivity 0.95. The "ambient" column below is the minimum pixel in frame, not room air — see the caveats. Room was about 25 °C.
| Clock | t | "Ambient" | TMC5130A U1201 |
Motor can |
|---|---|---|---|---|
| 10:47 | 3 min | 24.5 °C | 44.5 °C | 34.5 °C |
| 10:54 | 10 min | 24.3 °C | 45.3 °C | 38.7 °C |
| 11:04 | 20 min | 25.1 °C | 45.3 °C | 40.6 °C |
| 11:14 | 30 min | 23.3 °C | 46.9 °C | 42.6 °C |
| 11:27 | 43 min | 25.3 °C | 45.6 °C | 42.3 °C |
| 11:50 | 66 min | 25.2 °C | 45.5 °C | 43.3 °C |
Plateau: TMC5130A ≈ 45.5 °C, motor can ≈ 43.3 °C, against a room of ~25 °C, so roughly +20 °C and +18 °C of rise. The motor was still creeping ~1 °C per 20 min at the end; the TMC was flat from ten minutes on.
DRV_STATUS:ot= 0 andotpw= 0 on every poll across both runs.cs_actual= 15,sg_result70-78,stst= 1 between cycles.- No follow failures in ~1300 cycles, so the mechanism ran free throughout.
- Coil current, RMS: not measured — no clamp on the bench this session. The case temperature is the half that was taken.
R1203/R1204,U1001,U901,U201and a wide board frame: not measured. Worth one shot next time the rig is warm; the shunts' ±100 ppm/°C tempco is a current-accuracy term, so their temperature bounds it.
What it settles.
IRUNstays 17. 43.3 °C continuous leaves 16.7 °C to the motor's 60 °C case maximum in this room, and real use is far below continuous.IRUN24 is rejected without testing it. Rise scales as (0.600/0.421)² = 2.03, giving ~+37 °C, i.e. ~62 °C in this room — over the case maximum before any allowance for a warm day. That scaling is robust to the duty uncertainty, because whatever fraction of time the coils spend at run current applies equally to both currents and cancels.R1203/R1204stay 0.15 Ω withvsense= 1. Moving to 0.22 Ω 2512 neededIRUNsettled at 17 and a reduction above ~1.7:1. The gearing is 1:1 on both joysticks, so the second condition fails and the 2512 land change is off the table in both the library block and this board.
Three caveats that belong with the numbers.
- The motor can was read at ε = 0.95. If that frame was bare plated metal
rather than a matte target, every motor figure here is a floor, and the
IRUN24 projection gets worse rather than better. - The "ambient" column is the coldest pixel in frame, not room air. It wandered 23.3-25.3 °C non-monotonically, and at 11:27 it moved up 2.0 °C while both parts moved down — it tracks framing, not the room. Rises here are computed against ~25 °C rather than that column. Next run: read one fixed matte reference away from the rig, every frame.
- A plateau was called at 20 min and was wrong. A single-exponential fit to the first three points gave τ ≈ 3 min and +15.3 °C; the real curve has a second, slower constant — the joystick assembly and board copper soaking — and the motor gained another ~3 °C after that "plateau". Do not call steady state on a small-package fit when the part is bolted to something large.
Reading the result¶
- Run B bounds the design; it does not represent the experiment. Established 2026-09-09: real use is low duty — the researcher commands a motion, the motor depowers and waits, and there may be a rehome and another move, with cooling periods between. A back-to-back motion soak is therefore a worst case, which is the more useful thing to measure, but the margin it reports is not the margin a trial sees. The one thing that would make it representative is a hold-against-force mode, which is still open with the researcher — a sustained hold energises the coils continuously and puts the motor back on this curve.
- The motor is the binding part. Its datasheet gives a 60 °C case maximum against a 50 °C ambient maximum — a 10 °C rise budget at worst-case ambient — so compare rise, not absolute. That 60 °C figure is inconsistent with the Class B (130 °C) winding insulation quoted on the same datasheet page, and the inconsistency is worth a question to Lin Engineering whichever way the numbers land.
- Record the shunt value, the
vsensebit andIRUNin one place. They are one decision, and a temperature without all three is uninterpretable later. - If
IRUN17 sits comfortably inside the rise budget and 24 does not, 17 wins and the shunt stays 0.15 Ω. If 17 is already at the thermal limit, the motor-against-brake sweep has no overcurrent headroom to buy, which is the case where keeping the 23.4 mA step matters more than the 0.22 Ω 2512's extra sense signal. - The gear-teeth count is the other half of that decision and needs no power at all. Count both assemblies while the rig is open: above roughly 1.7:1 the motor at rated current reaches the brake's full 3.5 N·cm and the sweep needs no headroom either way.
What this survey cannot settle¶
The brake pass device. The BUK7D36-60EX has no linear-mode SOA derating
curve in its datasheet, which is precisely why its case temperature is not
optional — but that circuit does not exist until v1.0, and the 6 cm² of drain
copper it is conditioned on is a v1.0 layout obligation. Take it during v1.0
bring-up, with this camera and this procedure.
Open Issues Found During Bring-Up¶
The 28 deg travel figure is a hard-stop setting, not a mechanism property¶
Established 2026-09-09, from Peter: the hard stops on both joysticks are movable, and both mechanisms have roughly 60 deg of range. At 1:1 gearing that is 60 deg at the motor shaft too. 28 deg is not the travel of either joystick — it is where the stops happened to sit, or how far a hand pull got, on the day it was measured.
That number is load-bearing in at least five places in this file, and in firmware:
kMeasuredTravelMicrosteps= 4000 (≈28 deg), measured on board 2 (RIGHT) and used since as if it described the mechanism.- "Usable travel ≈ 4000 usteps ≈ 28 deg at the motor shaft" in board 2's stage 3 entry. The same sentence adds "less at the joystick through the gear pair", which is also wrong: the ratio is 1:1, so joystick degrees and motor degrees are the same number.
- "Travel is ~28 deg at the motor shaft" in the FREEWHEEL analysis, where it is the premise for calling -40.95 deg impossible mechanically. With ~60 deg of range that premise no longer holds. The reading may still be an artefact — it was frozen across the whole window, which is independent evidence — but "impossible" is not the reason. This file has already recorded one encoder reading dismissed as impossible that turned out to be real (see "The encoder was RIGHT: -28.083 deg is the mechanical end of travel"), so the same mistake has now been made twice from the same bad figure.
- The travel-limit finding and the limit-switch-end argument both quote it.
What follows: usable travel is a rig configuration to be set and recorded, not
a constant to inherit from a measurement. Set both mechanisms' stops to the
same range deliberately, record it per joystick with the date, and derive the
firmware limit from that rather than from kMeasuredTravelMicrosteps. The
earlier left/right disagreement (64.174 deg full swing on the left against 28 deg
on the right) is explained by movable stops rather than by the two assemblies
differing, which also retires the "different small-gear geometry is a scientific
confound" worry — the gearing is 1:1 on both.
docs/power.md is stale about HV_SNS and ~{HV_FAULT}¶
power.md says both "sit behind" the GPIO isolation bus switch and cannot be
read until firmware enables it. docs/interface-map.md marks both byp, and the
pad-to-net map agrees at v0.1 and at HEAD: GP28/GP29 reach the 36 V switch
directly, with no underscore-suffixed twin net. interface-map.md is right —
the switch sits upstream of the switched domain, so the bypass rule applies.
Firmware follows the netlist. Worth correcting in power.md.
Designator collision between v0.1 and HEAD is a live hazard¶
R1901/R1902 are the 100 Ω pyControl series resistors at v0.1 and the 1 kΩ
HV_EN/HV_DIAG_EN series resistors at HEAD. Anyone reading a HEAD-era document
while holding a rev 0 board can probe the wrong part and draw a confident wrong
conclusion. The existing warning in this file covers the three motor/encoder
connectors but not the 36 V input block, which moved wholesale from
U201/R201-R207 to U1901/R1901-R1907. Consider extending the table.
The two rig boards disagree on REFL_STEP and ENCB — HALF RESOLVED¶
Resolved for board E4654C159F4D1334 on 2026-09-02 by stage 3, with the
switch and encoder connected. Both of its "unexpected" readings were the
connectors being empty, not defects:
REFL_STEP. With the limit switch fitted the resting level is 0 and the triggered level is 1, and the motor drove it through both transitions. SoU1301's output path is intact on this board, and its empty-connector 0 was simplyR1405holdingLIMIT_SWlow with nothing driving it. The hand-short test proposed below was never needed.ENCB. With the E4T-1000 fitted,ENCA/ENCBread as a legitimate quadrature state and change as the shaft turns — observed going 1/1 → 1/0 → 0/1 → 0/0 across moves. Its empty-connector 1 was a floating CMOS input.
Still open for board E4654C159F2C2534, whose empty-connector REFL_STEP
read 1. That is the reading the netlist argument below predicts, and it is now
the odd one out: the same net reads 0 on the other board with the connector
empty. Nothing has been connected to board 1 yet, so run stage 3 on it before
drawing any conclusion — and note that board 1 has R203 depopulated, so it no
longer represents an as-built board for anything.
The original entry, kept because it is the reasoning that got here:
Written 2026-09-02, then contradicted the same day by the second board. The
subsection below argued from the netlist that REFL_STEP = 1 is correct because
the MAX6816 must supply its own pull-up. Board E4654C159F4D1334 reads
REFL_STEP = 0, so that inference does not hold. Keep the netlist reading —
it is ground truth — but treat the conclusion as open.
With all connectors empty on both boards, IOIN's low byte differs:
| Board | IOIN |
Unexpected inputs high | Firmware expects |
|---|---|---|---|
E4654C159F2C2534 |
0x11000011 |
REFL_STEP |
0x…10 |
E4654C159F4D1334 |
0x11000014 |
ENCB |
0x…10 |
Each board has exactly one unexpected input high, and it is a different
input. Board 2's reading is stable across three consecutive i reads with the
rail up for tens of seconds, so it is not a MAX6816 debounce-settling artifact.
Two candidate explanations, and the bench can separate them in a minute:
- Assembly defects, one per board. An open joint on
U1301'sOUTwould leaveLIMIT_SWheld low byR1405→ board 2'sREFL_STEP= 0. An open joint onU801's channel-B input would let a CMOS input float high → board 2'sENCB= 1. Board 1 would then have theU1301path intact and board 2 would have two defects. - Both nets are genuinely indeterminate with the connectors empty, in which case neither board is faulty and neither reading means anything.
Two decisive tests, both zero-risk, neither yet done:
REFL: shortJ1301pin 1 to pin 2 (a switch closure — pin 1 isGND, pin 2 isIN) and re-readIOIN. IfREFL_STEPchanges,U1301and its output path work. If it does not move on board 2,U1301or itsOUTjoint is the problem.ENCB: short the 5-position encoder terminal's pin 5 (ENC_B_RAW) to pin 1 (GND) and re-read. IfENCBgoes to 0, the path works. Do not touch pin 4 — it is+5V_SW.
Until then, do not treat either value as a known-good baseline, and do not
"fix" the firmware's expected IOIN by flipping a bit — there is no single
correct value while the two boards disagree.
The consequence for stage 3 is unchanged and still the most important part:
Original reasoning, kept because the netlist half of it is still ground truth¶
Stage 2's register check expects IOIN.REFL_STEP = 0 and blames R1405. Read
from the v0.2 .kicad_pcb rather than from a document, the chain is:
J1301 pad 2 ── IN ── U1301 MAX6816 pad 2 ┐
J1301 pad 1 ── GND ├─ pad 3 OUT ── LIMIT_SW ── U1201 pad 8 REFL
└─ pad 4 = +3.3V_SW
R1405 (0402) : LIMIT_SW ── GND
IN has no external pull of any kind — the net reaches only J1301 pad 2
and U1301 pad 2. A switch debouncer with a switch-to-ground topology and no
external pull-up must supply its own, so with the connector empty IN sits
high, OUT drives high, and REFL reads 1. R1405 cannot override a driven
CMOS output; its job is to define the level while +3.3V_SW is down, the
same role R805/R806 play for the encoder taps.
So R1405 cannot be what holds LIMIT_SW low when U1301 is driving, and the
firmware comment that blames it is wrong on that point. What does not follow —
and what board 2 disproves — is the conclusion that REFL_STEP = 1 is therefore
the correct reading. Report REFL_STEP rather than asserting it: with nothing
plugged in there is no correct value to assert.
The consequence for stage 3 is the real finding. The switch is active-low
at REFL, and SW_MODE defaults to pol_stop_l = 0, meaning active-high. So
the moment stage 3 sets stop_l_enable = 1 without also setting
pol_stop_l = 1, an un-triggered switch reads as "left stop active" and
motion is blocked. Confirm against the actual switch's normally-open /
normally-closed wiring before homing.
The USB CDC console can hang the sketch, and it did¶
2026-09-02, before any measurement: the board enumerated correctly —
2e8a:f00a, Orb Weaver, mouse-joystick-pcb, serial E4654C159F2C2534,
both interfaces bound to cdc_acm — but answered nothing over
/dev/ttyACM0. Zero bytes across repeated pokes over 10+ s, and
SET_CONTROL_LINE_STATE (asserting DTR) stalled with EPIPE.
The MCU was executing throughout: it was serving its own USB descriptors, including the serial string read from flash. So this is not a power fault.
The 1200-baud touch could not rescue it — that reset is triggered by exactly
the DTR request that was stalling. picotool could not either: this build
exposes only the two CDC interfaces, no vendor reset interface. A physical
USB replug was the only recovery, and it worked first time.
Likely mechanism: arduino-pico's SerialUSB::write() spins until the host
drains the endpoint, and stage 2's banner plus printMenu() is roughly 1 kB
against a 256-byte CDC FIFO. Booting with no terminal attached parks the sketch
inside setup(). Worth fixing in firmware — guard the banner on Serial
being connected, or bound the write — because on a board with no LEDs, no test
points and no SWD header, a wedged console means a wedged board.
Unrelated but found the same way: pixi run test clobbers .pio/build.
The build task scopes PLATFORMIO_BUILD_DIR per example; the test task does
not, so running tests deletes every example's build output including
firmware.uf2. Bit once mid-session, between a B into BOOTSEL and the flash
that was supposed to follow it.
The limit switch is at the positive end of travel, on the REFL input¶
Found 2026-09-02 on board E4654C159F4D1334. A firmware-level problem with
a hardware cause, so it is recorded here rather than only in the firmware
repository.
J1301 feeds TMC pin 8, REFL — the left reference switch, which the
TMC5130A uses to stop negative motion (datasheet rev 1.21 section 14.4: "a
stop occurs when the velocity sign matches the reference switch position, REFL
for negative velocities, REFR for positive velocities"). The switch is
physically at the positive end of the joystick's travel.
So stop_l_enable alone arms the stop against the direction that moves away
from the switch, and leaves the direction that drives into it unguarded. The
fix is SW_MODE.swap_lr, which routes the REFL pin to the right-stop logic;
firmware now sets it from a HomeEnd setting and homes positive.
Nothing to change on the board — swap_lr exists for exactly this, and
REFR is tied to GND so the swapped-in left stop reads a harmless 0. Worth
knowing before anyone wires the second joystick, or mouse-joysticks-pcb,
and assumes the switch end is a free choice. If a future revision wants the
hardware default to be the natural one, put the switch at the negative end of
travel or wire it to REFR.
IRUN = 28 predates the motor and is above its rating¶
docs/rev0-plan.md and mouse-joystick-firm's .agents/AGENTS.md both carry
"vsense = 1 with the 0.15 ohm shunts, IRUN = 28", against a target of
0.679 A RMS. That target was set before the motor was identified.
The fitted motor is a WO-208-13-01D rated 0.6 A per phase, so IRUN = 28 is
above the rating on either reading of that figure — 13% over the equal-heating
reading, 60% over the peak-of-sine reading. vsense = 1 is still correct and
still load-bearing; the current scale is what needs revising.
Firmware currently enforces IRUN <= 17 (0.421 A RMS, the peak-of-sine
reading) and runs bring-up at IRUN = 8 (0.211 A RMS). Settle it with a coil
current measurement and a case temperature, then change the number in one
place and record which reading won.
SETTLED 2026-09-09 by case temperature: IRUN 17 wins, and the peak-of-sine
reading is the one to keep. 66 minutes of continuous energisation at IRUN 17
plateaus the motor can at 43.3 °C in a ~25 °C room, 16.7 °C below its 60 °C
case maximum, with ot/otpw clear throughout. IRUN 24 scales the rise by
(0.600/0.421)² = 2.03 to about 62 °C in the same room, over the maximum
before any allowance for a warm day. Coil current was not measured — no clamp
on the bench — but the thermal half is decisive on its own, and the scaling is
insensitive to the duty question. Full data, method and caveats in
Thermal Survey.
No test points remains the binding constraint¶
Every stage 1 result above came from the RP2040's own ADC and GPIOs or from the
InstrumentPort. No independent meter reading was taken anywhere, so the ADC
figures confirm self-consistency rather than absolute accuracy — and ADC_AVDD is
tied straight to +3.3V_MCU, so the ADC cannot measure its own reference. A
single meter reading of +3.3V_MCU and +5V_MCU would close that gap.
Changes Recommended For The Next Revision¶
1. LATCH must not be strapped high — this is the one that matters¶
R203 (0402, +3.3V_MCU → LATCH) selects latch-off over the device's own
2 ms auto-retry, and that is what turns a survivable retry sequence into a hard
stop. The datasheet sanctions retry-until-charged "with high enough load
impedance"; +36V_SW's 0.87 MΩ qualifies, and the firmware staircase that now
works is a hand-rolled version of exactly that.
Put LATCH on a GPIO, so firmware chooses latch-off for real faults and
auto-retry for power-up, or tie it low. Either way keep a 1 kΩ RPROT per
Table 4-2. LATCH has a 0.7-1.3 MΩ internal pulldown, so on an assembled board
depopulating R203 alone selects auto-retry.
Done and validated on this board, 2026-09-02. R203 removed; a plain
HV_EN assertion brings the rail up in 4 ms after a 10-12 ms retry window, no
latched fault, 4 of 4, and the full stage 2 sequence passes. Carry this into
v0.4. The RPROT position should stay so the choice remains available; only
the far end changes from +3.3V_MCU to a GPIO or to GND.
One consequence to keep in mind: with LATCH low the device no longer latches
on a genuine fault either — it auto-retries every 2 ms indefinitely. Firmware
must therefore read ~{HV_FAULT} and decide, rather than relying on the part to
stay off. A GPIO on LATCH gets both behaviours: low for power-up, high once
the rail is established.
2. Halve the +36V_SW bulk, or justify it¶
C1213 + C1214 = 200 µF of EEEHAH101UAP plus ~8 µF of ceramic. 208 µF at
36 V is 135 mJ per turn-on into an HTSSOP-14, and the switch demonstrably cannot
deliver it in one burst. Depopulating C1214 halves it to 67 mJ. This finally
settles the question left open in .agents/handoff.local.md — 200 µF against
docs/power.md's "tens of µF, not hundreds" — with a measurement instead of an
argument.
3. Do not raise I_CL¶
The instinct is to increase R206's current limit. It does not help: the FET
absorbs ½CV² = 135 mJ regardless of the limit, just faster and into a smaller
transient thermal impedance. The levers are bulk capacitance and retry
behaviour. If anything, 1.15 A is already near the bottom of the part's 1-5 A
window, which is the least-characterised corner of the spec.
4. Test points, at last¶
+36V_SW, +5V_SW, +3.3V_SW, +3.3V_MCU, +5V_MCU, and both sides of
R1903. Every number in this file above came from the RP2040's own ADC, and the
one measurement that would have settled the 36 V question in five minutes — a
scope on +36V_SW — still has not been taken because it means probing the pads
of C1213. Usable stand-ins on v0.2/v0.3, read from the .kicad_pcb:
| Rail | Probe at |
|---|---|
+36V_SW |
C1213 pad 1, GND on C1213 pad 2 (D8×10.2 mm can, easy to clip) |
HV_IN |
C201 pad 1, or D201 pad 1 (cathode) |
HV_RAW |
J201 pad 2, or D201 pad 2 (anode) |
+5V_SW |
U901 pad 4 or 9, U1001 pad 5 or 8 |
+3.3V_SW |
U1001 pad 1 |
5. Firmware, not hardware, but record it here¶
The shipping power-up path should be the staircase, not a single HV_EN
assertion — PowerSequence::staircaseStart(). 30 bursts of 1200/800 µs costs
60 ms and has worked 5 of 5 times. Whether it needs 30 or 6 has not been
measured; the arithmetic says ~6 should do it, and knowing the real margin
would be worth one rebuild.