Stage 4 again, structured as a QP/C++ (v7.3.4, QV kernel) application: the
same ProtocolBoard and TmcMotion as examples/PyControlLink, unchanged,
inside one active object, with a QS trace leaving the board on FW_TRIG1 so a
logic analyser on the InstrumentPort can see what the firmware did, per
request, without USB and without touching the pyControl link.
It exists because the only thing QP buys this board is observability, and
that had to be measured before deciding anything about the delivery firmware.
The measurement is in qp-arduino-example/evidence/2026-10-01-rp2040-joystick-link/
(the pilot this application is derived from) and in the firmware run recorded
alongside it (see "Status"). examples/PyControlLink stays the non-QP
delivery sketch; nothing here changes the protocol or the pyControl side.
What runs where
| Piece | Here | Notes |
|---|---|---|
ProtocolBoard, TrialWatch, TmcMotion, SimulatedMotion |
src/ |
shared with PyControlLink; protocol v1 (proto=2) since 2026-10-01, the 48-case corpus holds in both halves |
LinkAo.cpp |
the active object | 64-bit clock, lines in, lines out: what PyControlLink.ino's loop() did, as run-to-completion steps with trace records |
Bsp.cpp |
board support | SafeState first, boot signature and identity beacon, 1 kHz hardware-alarm tick (the sampler) with watchdog, non-blocking line transport, transmit-only QS on a PIO UART at FW_TRIG1, the rev 1 angle DAC |
AppConfig.hpp |
configuration | the mechanism and protocol numbers of PyControlLink.ino; has_angout follows MJ_BOARD_REV; pull_sign comes from src/BoardCalibration.hpp by serial once the board is known (board 4 is −1, measured 2026-10-02; others +1, unmeasured) |
PyControlLinkQp.ino |
setup()/loop() only |
event pool (8 lines), queue (16 events), start |
The qp-arduino library comes from its released tag on GitHub
(https://github.com/QuantumLeaps/qp-arduino.git#v7.3.4) through
platformio.ini's [qp_env_data], since 2026-10-04; PlatformIO clones it
once into .pio/libdeps and refreshes it only when the pin changes. Before
the release it was the sibling checkout ../qp-arduino, linked with
symlink://.
Signals and ports
| Signal | Pin (from BoardPins.hpp) |
Behaviour |
|---|---|---|
| pyControl protocol | UART0 kPycUartTx/kPycUartRx, 115200 8N1 |
as before; MJ_LINK_USB=1 moves it to USB CDC for a bench with no host |
| QS trace | kFwTrig1 as a PIO UART TX, 115200 8N1, transmit only |
starts after the boot signature and the identity beacon, which still use the pin as GPIO; nothing on this line can command the board |
| operation marker | kFwTrig0 |
high from the start of a homing or move to its completion (OP_START/OP_DONE) |
| USB CDC | Serial |
a banner at boot; in the bench build, the protocol |
FW_TRIG1 therefore no longer carries the RELEASED level of PyControlLink:
that state is a LINK_STATE record. Which marker roles the v1.0 board's
InstrumentPort overlay finally assigns is an OBS-IF decision still owed
(mouse-joystick-pcb/docs/observability-plan.md); the two uses here are
one-line changes.
Trace records
| Record | Fields | Meaning |
|---|---|---|
LINK_RX |
seq, verb, length | a protocol line was dispatched to the AO |
LINK_TX |
sent flag, ready time, first 20 characters | a reply or event was queued for the port (0 = ring full, dropped) |
LINK_STATE |
name | ProtocolBoard's state changed |
OP_START / OP_DONE |
homing flag, angle | an operation started / completed; FW_TRIG0 follows |
LINK_WORK |
advance µs, feed µs, output µs | where a dispatched line's time went |
DAC_STATE |
phase (0 down, 1 angle DAC reset, 2 angle DAC up; pcb v1.0: 3 brake DAC reset, 4 brake DAC up, 5 torque ADC up), begin failures | the switched-domain peripherals' bring-up after hv=1, one phase per tick, and their release when the rail drops |
BRAKE_STATE |
engaging flag, commanded level N·cm, torque N·cm | the brake's commanded state changed (pcb v1.0): BRAKE accepted (engaging), the drive released (engaging clears, the level holds), the brake off |
BOOT_INFO |
watchdog timeout?, forced reboot?, last breadcrumb | once after boot: why the chip booted and where the previous run was when the hardware watchdog bit |
SAMPLE_STAT |
ticks, handled, coalesced, dropped, max µs, last µs, skipped (idle gap), skipped (decimated), decimated flag | the 1 kHz sampler: ticks the alarm posted, ticks the AO ran, samples lost to a tick still in flight or to a full queue, and the cost of the tick handler (poll, classifier, capture, DAC write), every second |
TRACE_STAT |
high-water, buffer-full count, size, ticks | QS buffer use, every second |
LINK_STAT |
lines, drops (pool, queue, ring), overlong, queue min free, pool min free, ring high-water | transport and budget use, every second |
TICK_LOST |
ticks, re-arms, catch-ups | the tick watchdog fired (never expected) |
Plus QP's own state transitions, entry/exit and assertions. The per-event
dispatch/post/get records are filtered out by default (LINK_QS_FULL=1 keeps
them, and saturates the line at a 1 kHz tick).
The sampler under the rail (2026-10-02)
Board 4's second attempt reached breadcrumb 9, hv on complete in 272 ms,
and then never idled again: with the bus up every 1 kHz tick polled the
TMC5130A, and the handler ran back to back until the watchdog bit. Four
things changed:
- Poll cost.
TmcMotion::pollreads the encoder alone (X_ENC, two 40-bit datagrams) on every tick and the full snapshot (RAMP_STAT, DRV_STATUS, XACTUAL, VACTUAL, X_ENC: ten datagrams) every tenth tick, where completion, the limit switch and driver faults are decided, so within 10 ms. The SPI clock iskTmcSpiClockHz, 4 MHz, inConversions.hpp(the library's 1 MHz default was chosen for a 1 MSa/s analyser; the analyser now needs 10 MSa/s to see the clock). - Overrun guard. If the previous TICK handler ended less than 200 µs
before this one starts, the AO is running back to back and the idle loop
has had no turn: the tick's work is skipped and counted, so idle always
gets one. If the handler exceeds the 400 µs budget three ticks running,
polling drops to every other tick until the rail goes down; counted and
flagged in
SAMPLE_STAT. - Tick level. In
MJ_TICK_LEVELbuilds (the three_usbbench builds)FW_TRIG0is high for the whole TICK handler: the analyser shows the handler's duration directly, back-to-back levels are an overrun, a level that never falls is a stuck poll. The operation marker is then a trace record only; breadcrumb trains still pulse the pin. - The static accounting behind this is in the evidence README of the run.
- 2026-10-02 12:45, board 4,
mouse_joystick_qp_rev0_usb(qp-arduino-example/evidence/2026-10-02-1245-board4-rail-up/):hv onacked in 272 ms, the full v1 trial on real motion, 300 of 300 moves, the watchdog never fired. The tick cost with the rail up is about 640 µs flat while holding and 444 µs p50 moving, which the datagram count does not explain, so the sampler ran decimated at 500 Hz for the whole rail-up period; the next capture includes the SPI lines. Findings and the poll-cost proposal are in that README.
Watchdog and breadcrumbs
Since 2026-10-02 the BSP enables the RP2040 hardware watchdog, 2 s, after
SafeState and the USB host wait, and feeds it in QV::onIdle() only: a
run-to-completion step that never returns reboots the chip, and the reset
drops HV_EN. Q_onError keeps its two-second FW_TRIG0 signature by
feeding the watchdog inside it. The reason for a boot and the last
breadcrumb are read from the watchdog block and its scratch registers before
anything else runs, printed in the banner (Boot: WATCHDOG TIMEOUT, last
breadcrumb N) and traced as BOOT_INFO.
BSP::crumb(n) is the breadcrumb: a train of n pulses, 25 µs high and
25 µs low, on FW_TRIG0, and n into watchdog scratch 0. An operation
marker pulse is 500 µs or longer, so the two are told apart on the analyzer.
A train during a running operation briefly lowers the marker; none of the
checkpoints below runs during a move.
| Count | Where |
|---|---|
| 1 | TmcMotion::setHvEnabled(true) entered, before powerUp |
| 2 | powerUp returned |
| 3 | connectBus done |
| 4 | beginSpi done (after its 10 ms delays) |
| 5 | TMC version 0x11 and link verified |
| 6 | SD_MODE check passed |
| 7 | configure done, bus up |
| 8 | position cache seeded, setHvEnabled returning true |
| 9 | the hv reply queued in the port ring (LINE handler) |
| 11 | DAC: claiming the bus (first tick after hv=1, rev 1) |
| 12 | DAC: bus up (SPI1 or Wire begun) |
| 13 | DAC: soft reset frame sent |
| 14 | DAC: writing CONFIG, GAIN, SYNC (2 ms later) |
| 15 | DAC: configured |
| 16 | DAC: releasing the bus (rail going down) |
| 17 | DAC: bus released |
The hook into TmcMotion is setCheckpointHook(); PyControlLink does not
install one and pulses nothing.
Builds
| Env | For |
|---|---|
mouse_joystick_qp |
rev 0 (pcb v0.1/v0.2): TmcMotion, UART0, trace on FW_TRIG1 |
mouse_joystick_qp_rev1 |
rev 1 (pcb v0.3 and later): angle DAC on SPI1 |
mouse_joystick_qp_rev2 |
pcb v1.0 (MJ_BOARD_REV=2): TmcMotion, UART0, trace on FW_TRIG1 (Digital-50 DIG1), the angle DAC on I2C0 GP16/GP17 at 0x48. The brake DAC (0x49) and the ADS8689 are not driven yet, so caps lacks brake and torque. Compiles; no v1.0 board had arrived when it was written (2026-10-04). Replaces mouse_joystick_qp_rev1_i2c, which paired rev 1 pins with an I2C DAC and matched no board |
mouse_joystick_qp_rev2_usb |
the same for the bench: protocol over USB CDC, tick probe on the UART0 TX pad, TICK handler level on FW_TRIG0; the first rail-up runs on a v1.0 board |
mouse_joystick_qp_rev1_usb |
board 4's rail-up bench build: TmcMotion, rev 1, DAC on SPI1, protocol over USB CDC, trace on FW_TRIG1, tick probe on the UART0 TX pad with the RJ-45 unplugged; driven by evidence/bench-scripts/board4_session.sh. Hung on the first hv on on board 4, 2026-10-02 (see Status) |
mouse_joystick_qp_rev0_usb |
the bisect build for that hang: the same, with the rev 0 pad map and therefore no DAC path at all; safe on a v0.3 board, whose rail-up pins are the same |
mouse_joystick_qp_sim_usb |
a board with no 36 V and no host: SimulatedMotion, protocol over USB CDC, tick probe on the unused UART0 TX pad (InstrumentPort DIG8). Accepts the bench stimulus sim;<seq>;angle;<deg> (sets the simulated encoder, as the corpus's ANGLE step does, so a WATCH outcome can be driven by hand; ack;<seq>), which exists in no other build and is not a protocol verb |
pixi run build examples/PyControlLinkQp mouse_joystick_qp
pixi run upload examples/PyControlLinkQp mouse_joystick_qp_sim_usb /dev/ttyACM0
Drive the bench build with qp-arduino-example/tools/link_pilot.py and
read the trace as evidence/bench-scripts/link_run.sh
does (sigrok uart decoder on the FW_TRIG1 channel, 17 through the rev 0/1
adapter the script was written for and 1 on a v1.0 board, bytes to qspy -k -f).
Status
- 2026-10-01: all three environments build; the 114 host tests still pass.
The bench build ran on the rev 0 fixture (board E4654C159F2C2534, 36 V
unplugged) under the pilot's full sequence: 21 of 21 steps, 1,003 lines
and 1,206 replies with none dropped, QS high-water 482 of 2,048 bytes with
zero buffer-full, queue and pool never below 15 of 16 and 6 of 8 free,
tick exact, host-loss fault 105 ms after the last heartbeat. The delivery
build was flashed and its boot sequence captured: signature, beacon, then
the trace with
READYand a statistics record every second, nothing on UART0. Results inqp-arduino-example/evidence/2026-10-01-rp2040-joystick-firm-qp/. - Not yet run:
TmcMotionbehind this AO with the rail up (as forPyControlLink, whose first supervised run it still owes), the UART0 protocol path under QP with a pyControl host, and anything on a v1.0 board, whose GPIO changes (GP24becomesHV_LATCH,~HV_FAULTmoves, the DAC goes to I2C) are not inBoardPins.hppyet. - The RP2040 hardware watchdog task of
.agents/PLANS.mdapplies here as it does toPyControlLink;Q_onErroralready drops toSafeStatebefore rebooting. - 2026-10-01, later: protocol v1 (
proto=2: BRAKE, WATCH, CFG, chunked FETCH, STREAM, TRIAL, 64-bit event times) in both halves with the corpus rewritten (46 cases, 120 native and 164 host tests), the tick raised to 1 kHz as the sampler with the tick coalesced (one TICK_SIG in flight, lost samples counted inSAMPLE_STATrather than overflowing the queue behind the rail's soft start), and the rev 1 angle output driven from the tick: bus claimed and GAIN 0x0101 written only afterhv=1, parked at the fault code whileangleOutputValid()is false, released when the rail drops. Compiled only, in all four environments; nothing was flashed. The sampler cost is instrumented (SAMPLE_STATmax/last,LINK_WORK), not measured: the 400 us budget is a declaration until a run on the fixture. - 2026-10-01, review: the capture became a ring (
TrialWatch): sampled fromRELEASEDon, frozenpost_msafter the outcome around the outcome sample (CFG pre_ms/post_ms, 500/500, sum ≤ 2047), so a pull late in a 10 s trial is captured where the old two-second linear buffer missed it. Corpus, simulator, firmware and device class changed together. - 2026-10-01, fixture run of the bench build at firmware 0109ca5
(
qp-arduino-example/evidence/2026-10-01-rp2040-joystick-ring-capture/): 31 of 31 pilot steps, the whole v1 trial shape including the 1,001-sample window fetched in 32 chunks and 100 stream lines in 2 s. Measured against the 400 µs budget: the sampler tick costs 20 to 60 µs idle, the alarm tick is exact (stdev 10 µs, none lost), but one protocol line costs 1.0 ms (ping) to 2.8 ms (status) to 15.9 ms (a 32-lineFETCHchunk), so one sample is coalesced per host line (2,183 of 86,024 ticks, 2.5 %, zero dropped, filled on the grid). The cost isformatNumber'ssnprintf("%.3f")with software double on the M0+, about 0.5 ms per number, pre-dating this example. Also: aFETCHchunk's 34LINK_TXrecords saturate the trace (QS high-water 2,048, 35 buffer-full events). Fixed the same day, below. - 2026-10-01, no newlib on the line path
(
qp-arduino-example/evidence/2026-10-01-rp2040-joystick-no-newlib/):Protocol.hppformats and parses in integer fixed point (exact on the binary value, ties to even, 124 native tests), the cache and queue copy withoutsnprintf, and the trace takes oneLINK_TXperFETCHchunk. Rerun on the fixture, 31 of 31 steps: feed costping330 µs (was 1,031),status519 (2,839),moveto818 (1,841), aFETCHchunk 1,201 (15,921); coalesced samples 237 of 85,999 (was 2,183), QS buffer-full 4 (was 35), worst tick 694 µs (was 2,379), the window in 217 ms (was 774). Still over one tick per line:moveto(1.13 ms total, the one that coalesces a sample per command),FETCHchunks (1.6 ms),id,watch,brake,stream. The floor of about 450 µs per line, with no number in it, is not attributed yet; splittingLINK_WORKinsidefeed()is the next measurement, and whether long replies alternate with the tick is a decision, not a default. Accepted as is on 2026-10-02: the floor fits the rig's message rate. - 2026-10-02, before the rail-up session:
FETCH's ack carrieslost=N, the window samples that were grid fills after a coalesced tick (spec, corpus, simulator, firmware, device class; a fill-path unit test on each side); theDAC_STATErecord; themouse_joystick_qp_rev1_usbbuild; the board 4 session script and procedure. Nothing flashed. - 2026-10-02, board 4, first rail-up attempt
(
qp-arduino-example/evidence/2026-10-02-board4-rail-up/): identity ok,limitsok (travel [−10.125, 0],v_max36), thenhv onnever answered: USB stayed enumerated, the tick probe kept running, the trace and the port went silent, no reset. The active object never returned from thehvLINE handler or the first TICK after it. Static reading found no unbounded wait that differs fromPyControlLink's working sequence (panic()is excluded by the live tick), so the firmware gained the hardware watchdog, the breadcrumbs and the rev 0 bisect build above; the reproduction is Peter's, from the bench.
Files
| Name | Size |
|---|---|
| ../ | |
| AppConfig.hpp | 4 kB |
| Bsp.cpp | 36 kB |
| Bsp.hpp | 4 kB |
| LinkAo.cpp | 17 kB |
| LinkAo.hpp | 2 kB |
| PyControlLinkQp.ino | 793 B |
| README.md (rendered above; raw) | 17 kB |