projects/ janelia/ Mouse Joystick/ Sources/ mouse-joystick-firm/ .agents/ handoff-rig-system-plan-2026-10-01.md/

Archived page

Handoff — rig system plan → firmware work (2026-10-01)

.agents/handoff-rig-system-plan-2026-10-01.md of mouse-joystick-firm, rendered; the Markdown is beside it.

From: Peter, after an end-to-end review of the rig (pyControl task, pyControl device class, this firmware, the v1.0 board, the OneBox record). Authority: this file is a work brief. It does not override .agents/AGENTS.md, the board repository's electrical facts, or the protocol specification. Where it proposes protocol changes, the change is made in ../mouse-joystick-pcb/docs/uart-protocol.md first and implemented here second. Plan of record: ../orb-weaver-context/context/mouse-joystick-rig-system-plan.md (sections 3, 4, 7, 9). Read sections 3 and 4 before starting; they are short.

0. What the firmware is for, in one paragraph

Two of these boards sit under a pyControl task that owns the experiment. pyControl sends parameters and bounded primitives down; the board sends events and a per-trial summary up. The board owns everything that must be ordered or timed better than a few milliseconds: homing, moves, the brake-then-release sequence, classifying what the animal did, and sampling angle and torque at 1 kHz. The OneBox records the analog copies at 30 kHz; the UART is not a sample path. One trial, as the task will drive it:

MOVETO present → ev;TARGET → (pyControl cues) → BRAKE torque → ev;RELEASED
→ WATCH pull;push;timeout → exactly one of ev;PULL | ev;PUSH | ev;NOMOVE | ev;ABORT
→ MOVETO park → ev;TRIAL (summary) → ev;TARGET

Heartbeat is STATUS (not PING) at 10–25 Hz from the device class; any well-formed frame resets the host-loss watchdog (already implemented).

1. Where things stand (verified in the repos on 2026-10-01)

2. Bugs found in the review — fix regardless of anything else

# Where Problem Fix
1 src/ProtocolBoard.hpp beginEvent(name, static_cast<uint32_t>(now_us_)) Event t wraps at 71.6 min. The QP BSP already supplies 64-bit µs; the truncation is inside ProtocolBoard/Protocol. Carry 64-bit µs end to end; format as decimal. Update corpus EXPECT lines accordingly.
2 ProtocolBoard vs TmcMotion::expectedHomeDurationUs() home;<s>;<timeout_ms> faults immediately for any timeout under 8000 ms. Compare against the requested timeout, bounded by the firmware maximum.
3 PyControlLink setup instr on/off only sets a flag; the buffer is enabled unconditionally. Make the verb drive the enable, or drop the verb.
4 pycontrol/README.md Says the retransmit cache holds 16 entries; code has kRetransmitCacheSize = 2. Fix the doc (2 is the designed value).
5 ID Reports hard-coded fw=0.0.0, hw=rev0. Real build version and board revision from the build flags.
6 src/TmcMotion.hpp header Says "NOT YET RUN ON HARDWARE"; it has run (board 3, 2026-09-03). Update the comment.
7 pycontrol/devices/mouse_joystick.py UART(..., timeout=100) blocks the pyControl main loop when the buffer is empty (measured: a 20 ms poll became 72 ms). timeout=0, read only uart.any() bytes, accumulate partial lines.

3. Protocol v1 — the proposal to take to uart-protocol.md

Transcribe this into the spec (as a dated "v1 proposal" section) and get Peter's sign-off before changing the corpus. Framing, seq, ack/nak, the watchdog rule and the retransmission rule are unchanged.

3.1 New and changed verbs

Verb Args Semantics
BRAKE torque_Ncm Atomic in firmware: (1) set brake DAC to the calibrated code, (2) wait for coil current to reach setpoint or a fixed settle time (B5Z spec ~6 ms; confirm from ISENSE at bring-up), (3) release the driver, (4) ev;RELEASED;t;angle=…;torque=…;brake=…. State → RELEASED. Refused unless ARMED. On boards without a brake (caps lacks brake): accept, ignore torque, do (3)–(4) only. This is today's FREEWHEEL and replaces it; FREEWHEEL answers badverb after the change.
WATCH pull=deg;push=deg;timeout=ms[;hold=ms][;touch=Ncm][;maxdur=ms] One-shot classifier, valid only in RELEASED; nak;notreleased otherwise. Thresholds are relative to the angle at RELEASED. Sign convention: pull is positive displacement in the pull direction, push is positive in the push direction (document the mechanism's sign in the spec once; the device class hides it). Emits exactly one outcome event: PULL once angle ≥ pull continuously for hold ms (default 0); PUSH once angle ≥ push; NOMOVE if no first motion (angle change > a fixed deadband) before timeout; ABORT if first motion occurred but no outcome within maxdur (default: disabled). TOUCH (torque ≥ touch, default disabled) is informational, at most once, may precede the outcome. After the outcome the classifier is disarmed; a new WATCH re-arms. Replaces ARMPULL.
MOVETO deg[;profile] Unchanged semantics plus named profiles (present, retract, slow; constants in AppConfig.hpp first). Leaving RELEASED: re-engage driver, resync XACTUAL from X_ENC (exit-only, finding 5), emit ev;TRIAL, then release the brake.
MOVE — Candidate for deletion with the retransmit cache if the trial uses only absolute positions (finding 1). Decision: Peter.
CFG key=value[;…] / get Bounded runtime configuration. Keys for v1: wdt_ms (host-loss timeout, 50–500, default 100), deadband_deg (first-motion threshold for NOMOVE), stream_hz. Values are checked against firmware bounds; out of range → nak;outofrange.
FETCH — Returns the last WATCH capture: ack;s;n=N;t0=…;dt_us=1000;pre=P followed by N lines cap;k;angle;torque and cap;end. ASCII keeps the protocol line-oriented; 150 samples ≈ 3 kB ≈ 260 ms at 115200, acceptable in an ITI. Capture is a 1 kHz ring of angle+torque started at WATCH with pre ms of pre-trigger retained from the continuous sampler.
STREAM on;rate_hz / off Optional decimated s;t;angle;torque at ≤100 Hz. Off by default; display only.
TQ zero / cal;on|off / get Torque tare (store offset), shunt-cal switch (TQ_CAL_EN), raw read. v1.0 only; badverb when caps lacks torque.
ANGOUT, ANGSCALE as drafted Must work in the protocol build. GAIN=0x0101 written before the first data write; 0 V (or the agreed fault code) while unhomed/faulted/disabled.

3.2 Replies and events

3.3 State machine

BOOT → IDLE ─HOME→ HOMED ─ARM→ ARMED ─MOVETO→ ACTIVE → ARMED
                                 └─BRAKE→ RELEASED ─(WATCH outcome; stays RELEASED)
                                              └─MOVETO/STOP/DISARM→ re-engage → ARMED/HOMED
any fault → FAULTED ─CLRFAULT→ IDLE (homed cleared)

Rename the state string FREEWHEEL → RELEASED (kFreewheel in ProtocolBoard.hpp; FW_TRIG1 logic in PyControlLink compares that string).

3.4 Fault bits (bits 9+ are reserved for exactly this)

Bit Name Cause
9 brake_fault ~{BRAKE_FAULT} asserted
10 brake_current BRAKE_ISENSE outside tolerance of setpoint for > N ms
11 torque_adc ADS8689 not responding / conversion timeout
12 torque_range bridge saturated or excitation out of range
13 not_following XACTUAL vs X_ENC divergence while driving only (finding 5)

3.5 Safe state on host loss / fault (decision pending with the researcher)

Today: abort motion, HV off, motor coasts. With a brake fitted, propose: brake to a configurable holding level (CFG hold_Ncm), driver off, FAULTED latched. Implement behind a config so either behaviour is selectable; the default stays today's until the researcher answers.

4. QP structure — constraints the new behaviour must respect

5. What can be done now vs what waits for v1.0

Now, on v0.3 board 4 / v0.2 boards Needs v1.0
Bugs 1–7 BRAKE phases 1–2 (coil current), bits 9–10
RELEASED rename; BRAKE as release-only; WATCH classifier on angle only; TRIAL with torque fields empty Torque sampling, TQ, bits 11–12, torque in STATUS/TRIAL/capture
STATUS t and new fields; 64-bit t; CFG; MAX_LINE/rxbuf I2C DAC transport on the real board (MJ_DAC_I2C=1 is untestable on v0.3)
ANGOUT/ANGSCALE + 1 kHz DAC loop in the protocol build (board 4 has the SPI DAC) ÷2 output levels (board-side fact; firmware unchanged)
FETCH with angle only; STREAM —
QS records, sampler timing measurement, hardware watchdog —
Device class: non-blocking reads, named events, heartbeat timer, AnalogChannels, TRIAL → log()/print(), 3.0 names with 2.1.1 shim —

Do the "now" column first; it is most of the work and all of the risk.

6. Test contract — how to change the protocol without forking it

  1. Edit ../mouse-joystick-pcb/docs/uart-protocol.md (v1 proposal section).
  2. Add cases to pycontrol/tests/fixtures/protocol_cases.txt for every new verb, every nak reason, the RELEASED→outcome sequences (use ADVANCE and a simulated angle input; extend the corpus grammar if the simulator needs an ANGLE <deg> / TORQUE <Ncm> stimulus line, and document it in the header), and the 64-bit t formatting.
  3. Make pycontrol/sim/board_sim.py pass them (make pycontrol-test).
  4. Make the C++ half pass them (pio test -e native).
  5. Update pycontrol/devices/mouse_joystick.py and its tests in the same change set; the device class refuses unknown proto and gates on caps.
  6. Only then flash and measure. Record numbers, with units and board serial, in ../mouse-joystick-pcb/docs/bringup-log.md.

A change that only one side is checked against silently forks the contract (the corpus header says the same).

7. Evidence Peter needs from this work (feeds the researcher conversation)

8. Decisions reserved for Peter (ask, do not assume)

9. Do not