Skip to content

projects/ janelia/ Mouse Joystick/

mouse-joystick-firm

Firmware for mouse-joystick-pcb: an RP2040 driving one TMC5130A stepper controller, exposed to a pyControl host over one UART. One board, one joystick axis. Two of these boards serve the rig's two joysticks.

The board repository owns every electrical fact this firmware depends on, the UART protocol pyControl speaks to the board, and the bring-up log where every measurement is recorded per board serial. The driver library is TMC51X0.

Status, October 2026: four pcb v1.0 boards run this firmware on two rigs. Rig 1 at the lab bench (installed 2026-10-09) has units A and B, the right and left joysticks; the verification rig at Peter's desk (since 2026-10-12) has units C and D, where firmware and task changes run before they reach Rig 1. Every board passed the staged bench bring-up below and ran the rig's trial task end to end through pyControl; the delivery build is examples/PyControlLinkQp for the mouse_joystick_qp_rev2 environment. Each stage's procedure and findings are in docs/; the per-session record is evidence/. The delivered state is tagged v1.0.0 (2026-10-13). The desk rig's boards were reflashed on 2026-10-14 and report v1.0.0-2-g109169f (HEAD; the two commits after the tag change only documentation). Rig 1's boards report the commits they were built from (51322e7-dirty, 316f802-dirty, functionally the same code) until they are reflashed.

Read This First

If you Read
run sessions on the rig the researcher guide in the rig's repository, then the glossary for the words in the log
want to know how the firmware is organised and what talks to what Architecture, with the trial and power diagrams
need to know which code drives which circuit, connector or part Circuit-to-code map
write or change a pyControl task The pyControl device class and pycontrol/README.md
build, flash or bring up a board the rest of this README, then docs/rp2040-bringup.md, docs/motion-bringup.md, docs/brake-torque-bringup.md

What runs where

The v1.0 pin map (MJ_BOARD_REV=2) is read off footprint U601 at pcb tag v1.0. The rev 0 and rev 1 maps came from U501 at tags v0.1 and v0.3; tags v0.1 and v0.2 have byte-identical RP2040 pad-to-net maps. The Pin Map section below has all three.

Quick Start

Everything runs through pixi, which installs PlatformIO, Python and the RP2040 toolchain into the repository on Linux, macOS and Windows; nothing else has to be installed. The commands are the same on every platform apart from the serial port name (/dev/ttyACM0, /dev/cu.usbmodem… or COM3; pixi run ports lists them). On Linux, install PlatformIO's udev rules once (make udev-rules) so the port is usable without root.

pixi install                                    # resolve the firmware environment
pixi run build                                  # compile examples/RP2040SelfTest
pixi run test                                   # host-native tests, no board needed (Linux and macOS)

# with the board on USB and the 36 V input UNPLUGGED:
pixi run upload examples/RP2040SelfTest mouse_joystick_rev2 /dev/ttyACM0
pixi run monitor /dev/ttyACM0

pixi run build, upload, flash and rebuild take the example directory, the environment and, for uploads, the port as positional arguments; the defaults are examples/RP2040SelfTest and mouse_joystick, which is the pcb v0.1/v0.2 build. A v1.0 board is mouse_joystick_rev2 (see Environments). The Makefile is a thin wrapper over the same tasks for a shell with make: make upload EXAMPLE=… ENV=… PORT=….

Reprogramming a v1.0 board

The firmware the rig runs is examples/PyControlLinkQp built for mouse_joystick_qp_rev2:

pixi run upload examples/PyControlLinkQp mouse_joystick_qp_rev2 /dev/ttyACM0

A board already running this firmware reboots into the bootloader on the 1200 baud touch, so nothing has to be pressed. A blank or unresponsive board has to be put into BOOTSEL by hand, which on this board needs both USB and the pyControl cable unplugged first; docs/rp2040-bringup.md has the procedure. If picotool refuses to write, copy the built UF2, .pio/build/examples/PyControlLinkQp/mouse_joystick_qp_rev2/firmware.uf2, onto the RPI-RP2 drive the bootloader presents, or load it with picotool load -x firmware.uf2 --bus B --address N (the RP2040 ROM bootloader reports the same USB serial on every board, so with two boards in BOOTSEL the USB address is what selects one). Two of the four v1.0 boards (units B and D) have ignored the 1200 baud touch under this build on some hosts and needed the button; on 2026-10-14 unit D took it first time from a laptop, so try the touch first and reach for the button when the upload says no new device appeared. Do one board at a time: the uploader skips the touch when any RP2040 is already in BOOTSEL, and it loads without an address. The board announces its firmware version in the protocol's ID reply; the delivery build prints nothing on USB after boot, so ID over the RJ-45 is the only way to read it. With the pyboard also on USB and nothing else holding its port, tools/pyb_id_probe.py <pyboard tty> sends id;1 over Breakout ports 1 and 4 and prints both replies; expect fw= to name the build just flashed.

On USB a board enumerates as manufacturer Janelia Research Campus, product mouse-joystick-pcb, with the RP2040 flash unique id as its serial, so on Linux /dev/serial/by-id/usb-Janelia_Research_Campus_mouse-joystick-pcb_<serial>-if00 names a specific board regardless of plug order. Boards flashed before 2026-10-12 report the manufacturer as Orb Weaver until reflashed.

The pyControl host side

The pyControl host side is a separate pixi environment with no PlatformIO in it, so building firmware never downloads Qt and running the GUI never downloads a toolchain:

pixi install -e pycontrol                       # numpy, pyserial, pyqtgraph, PyQt6, pytest
pixi run -e pycontrol pycontrol-check           # verify pyControl's dependency gate
pixi run -e pycontrol pycontrol-test            # host-side tests, no board and no pyControl needed

export PYCONTROL_DIR=/path/to/pycontrol-code    # a pyControl 2.1.1 checkout
pixi run -e pycontrol pycontrol-link            # point that checkout at pycontrol/
pixi run -e pycontrol pycontrol-run             # run a task on the pyboard through pyControl's own board API, no GUI (Gate E)
pixi run -e pycontrol gui                       # launch the pyControl GUI

pycontrol-check exists because pyControl fails its own dependency check silently: pyControl_GUI.pyw catches the exception, writes ErrorLog.txt, and exits. The symptom is a GUI that does not open with nothing on the terminal. pycontrol/README.md covers the device class, what the rig's controller carries and how to set up another one.

tools/sim_task.py runs a pyControl task on the host against the board simulator, with a chosen reply dropped or corrupted, so a task's handling of a lost line can be exercised without a pyboard or a board:

pixi run -e pycontrol python tools/sim_task.py path/to/joystick_trial.py drop-trial 1

Bring-Up Is Staged

The board has two power domains with a bus switch between the MCU and everything else. The stages follow that boundary:

Stage What runs 36 V Bus switch Driver Status
1 RP2040 alone absent isolated disabled examples/RP2040SelfTest — passed
2 TMC5130A register reads applied closed disabled examples/TMC5130Communication — passed
3 Motion, encoder, homing applied closed enabled examples/MotionAndHoming — passed
4 pyControl UART protocol applied closed as needed examples/PyControlLink — passed on hardware 2026-09-03
5 Analog angle output applied closed untouched examples/AngleOutput — writes verified 2026-09-09; output not yet measured, needs a DMM
A4/A5 Brake sink, torque bridge (pcb v1.0) applied closed untouched examples/BrakeAndTorque — passed on v1.0 unit A 2026-10-06: brake 0–48.80 mA linear, ADS8689 reading at 95–112 µV rms; see docs/brake-torque-bringup.md

Stage 5 needs a v0.3 or later board and env:mouse_joystick_rev1. It also needs a meter: ANGLE_OUT reaches no RP2040 ADC, and over SPI the DAC cannot be read back either, so nothing on the board can see its own analog output.

Stage 1 has no way to raise HV_EN and no way to enable the bus switch. That is deliberate, not an omission: until the MCU is proven there is no reason to put 36 V near it, and an MCU pin driving into an unpowered TMC5130A back-powers a dead rail through its ESD clamps.

Stage 2 has no way to enable the driver. It reads registers with ~{TMC_DRV_EN} held high throughout and writes nothing, so no current can reach the motor. Enabling the driver depends on the current-sense decision — vsense = 1 with the 0.15 Ω shunts — which belongs to stage 3.

src/PowerSequence.hpp is the only file that raises HV_EN, and it enforces the one safe ordering: rail up before the bus closes, bus open before the rail drops.

What Stage 1 Actually Checks

Run examples/RP2040SelfTest, read the USB serial monitor. Roughly twenty checks, each reporting a number or a level:

  • Boot signature and heartbeat — a distinctive pattern on the instrument port triggers before Serial comes up, then a 1 Hz square wave forever after. This is the only proof that the RP2040 is running your code that does not depend on USB. On a v1.0 board watch LA5032 channels 0 and 1 (FW_TRIG0/FW_TRIG1 on DIG0/DIG1 of InstrumentPort-Digital-50); on rev 0/1, channels 16 and 17.
  • Identity — the flash unique id, which is this board's serial. Calibration belongs to the serial, not to a joystick position.
  • Clocks — every derived clock, plus clk_sys measured against the crystal. The crystal's own absolute proof is that USB enumerated at all; the k command measures it against your host clock if you want a number.
  • QSPI flash — a real 0x4B read proves the W25Q128 responds, and an address aliasing probe at +2, +4 and +8 MiB proves the part is really 16 MiB rather than trusting the BOM.
  • RAM — a four-pattern walk over a 16 KiB heap block.
  • Board pull resistors — GP9, GP24, GP1 and GP29 read with internal pulls off, so each level comes from a board resistor. A failure here is a missing or misplaced part, not firmware.
  • Spare pads — the nine unrouted GPIOs pulled both ways. A pad that will not move is shorted.
  • ADC — the switched-rail sense (expected ~0 V), the 36 V current sense (expected ~0 V), and the die temperature, which proves the ADC and ADC_AVDD.

Then an interactive menu: re-run, read the ADC, pulse the instrument port triggers, stream clock ticks, try a pyControl UART loopback, or reboot into BOOTSEL.

Things About This Board That Will Bite You

Collected here because none of them are guessable from the code:

  • There are no LEDs. Not one. USB CDC and the two FW_TRIG pins on the instrument port are the entire observable surface: InstrumentPort-Digital-50 (J1301, a 50-way FFC with 32 buffered taps, always enabled) on v1.0, InstrumentPort-40 (J1501, enabled by firmware) on rev 0/1. LED_BUILTIN resolves to GPIO 25, which here is FW_TRIG0.
  • There are no test points, anywhere. Reach a signal at a component pin or through the instrument port's taps.
  • No SWD header on any revision; rev 0 also has no RUN connection (v1.0 adds a reset switch). No debugger during bring-up. BOOTSEL recovery needs USB and pyControl unplugged, because the MCU domain is ORed from both.
  • Serial2 and Wire1 must never be called. The generic board definition maps them onto GPIO 8/9 and 26/27, which on this board are TMC_DIAG1, ~{BUS_SW_EN}, the rail sense ADC, and a spare pad.
  • The GPIO bus is isolated at reset and only a pull-up keeps it that way. R601 7.87 kΩ. All four SN74CB3Q3245 packages run from the always-on rail, so Ioff never engages — the enable pin is the only mechanism, not the inner of two.
  • HV_DIAG_EN must never idle high. Held high it leaks ~240 µA into +36V_SW through the switch's open-load detection; the rail creeps past the buck's minimum input in about three seconds and the switched domain hiccups instead of staying off.
  • vsense = 1 with the 0.15 Ω shunts. It resets to 0, which at 0.15 Ω drives roughly 1.8× the intended current. Stage 3 sets and reads it back before enabling the driver.
  • The encoder and limit switch never reach a GPIO, on any revision. Position comes from the driver's X_ENC/XACTUAL over SPI.

Stage 2: What It Checks

Run examples/TMC5130Communication with 36 V on J201. It is interactive and starts powered down — send s to begin, x to power down.

  1. Confirms +3.3V_SW is genuinely dead before raising anything, and refuses to continue if it is not.
  2. Raises HV_EN and measures how long +3.3V_SW takes to come up.
  3. Reads the 36 V switch's own diagnostics, raising HV_DIAG_EN only for the duration of the read and lowering it again.
  4. Then closes the GPIO isolation bus switch.
  5. Reads IOIN, GSTAT, GCONF, CHOPCONF, DRV_STATUS, XACTUAL and X_ENC, printing every datagram byte so the serial log can be compared against a logic analyser capture.

The check that matters most is IOIN bit 4, DRV_ENN: it reads back the level firmware drives on GP3 through the bus switch, and nothing else in the test proves that path. Expected IOIN on this board is 0x11000010, with bit 7 free because SWCOMP_IN is a comparator output.

Two failure signatures are called out explicitly rather than left as "SPI failed": all bytes 0x00 means CIPO is undriven, which on this board points at the bus switch enable, then +3.3V_SW at the driver's VCC_IO, then CS. All bytes 0xFF means CIPO is stuck high, which the board's 10 kΩ pull-down should prevent.

Pin Map

src/BoardPins.hpp is the single source of truth and carries the reasoning. Summary:

GPIO Net Function Isolated
0 PYC_UART_TX pyControl UART0 TX no
1 PYC_UART_RX pyControl UART0 RX, 10 kΩ pull-up no
2 ~{TMC_CS} driver chip select yes
3 ~{TMC_DRV_EN} driver enable, active low yes
4 TMC_SPI_CIPO SPI0 RX yes
5 TMC_DIAG0 driver diagnostic 0 yes
6 TMC_SPI_SCK SPI0 SCK yes
7 TMC_SPI_COPI SPI0 TX yes
8 TMC_DIAG1 driver diagnostic 1 yes
9 ~{BUS_SW_EN} GPIO isolation enable, active low —
10, 11, 13 DAC_SPI_*, ~{DAC_CS} rev 1 angle DAC, SPI1. Unrouted on rev 0 yes
14 HV_DIAG_EN 36 V switch diagnostics enable no
19 HV_EN 36 V input switch enable no
21, 25 FW_TRIG1, FW_TRIG0 firmware timing markers no
24 ~{INSTR_BUF_OE} instrumentation buffer enable no
26 SW_RAIL_SENSE_DIV_2 +3.3V_SW ÷2, ADC0 no
28 HV_SNS 36 V load current sense, ADC2 no
29 ~{HV_FAULT} 36 V switch fault, 10 kΩ pull-up no
12, 15, 16, 17, 18, 20, 22, 23, 27 — spare, routed nowhere —

Rev 0 and rev 1 share this allocation by design. No rev 0 pin moved. The only firmware-visible difference is GP10/GP11/GP13, unrouted on rev 0 and therefore folded into the spare-pad test — see MJ_BOARD_REV.

pcb v1.0 is MJ_BOARD_REV=2 and moves pins. Read off footprint U601 (the RP2040 moved into the combined MCU block; U501 is now the 36 V switch) in the board file, which is unchanged from commit d5ae4b0 through the fabrication package and HEAD. GP0–GP9, GP14, GP19, GP21, GP25, GP26 and GP28 are as above. The rest:

GPIO v1.0 net Function Isolated
10, 11, 12 TQ_SPI_SCK, TQ_SPI_COPI, TQ_SPI_CIPO torque ADC ADS8689, SPI1 yes
13 ~{BRAKE_FAULT} brake current-limiter indication, input yes
15 TQ_CONVST_CS torque ADC convert-start / chip select, no pull yes
16, 17 DAC_SDA, DAC_SCL I2C0 to both DACs: angle 0x48, brake 0x49; 4.7 kΩ pull-ups both sides of the switch yes
18 TQ_CAL_EN torque shunt-calibration switch, must idle low yes
23 ~{HV_FAULT} 36 V switch fault, 10 kΩ pull-up (R507) no
24 HV_LATCH 36 V switch latch-off select, via R503 10 kΩ; low = auto-retry no
27 BRAKE_ISENSE brake coil current monitor, 0–1.006 V, ADC1 no
29 ANGLE_OUT_MON angle output node, 0–1.650 V, ADC3 no
20, 22 — spare —

The instrument port is InstrumentPort-Digital-50 (J1301), always enabled, with FW_TRIG0/FW_TRIG1 on DIG0/DIG1; there is no ~{INSTR_BUF_OE}. Three traps follow directly: GP24 is an output that configures the 36 V switch, not a buffer enable; GP29 is an ADC input, so reading it as the fault line finds no fault, ever; and GP13 is driven by the brake block, so a rev 1 build that closes the bus and drives the old DAC chip select shorts two outputs. SafeState::apply() and PowerSequence::powerUp() handle HV_LATCH; nothing else may touch it.

Reference designators are not stable across revisions. Automatic annotation orders by sheet position, so inserting a part rotates everything after it. R1901/R1902 are 100 Ω pyControl series resistors at v0.1 and 1 kΩ HV_EN/HV_DIAG_EN series resistors at v0.3; at v1.0 the 36 V block is U501/R50x and U501 had been the RP2040. Designators quoted in this repository are v0.1 designators unless a revision is named; identify parts by value and net.

Layout

examples/RP2040SelfTest/       stage 1 program
examples/TMC5130Communication/ stage 2: raw SPI, no driver library
examples/MotionAndHoming/      stage 3: motion, encoder, homing, over TMC51X0
examples/PyControlLink/        stage 4: the pyControl UART protocol
examples/AngleOutput/          stage 5: the analog angle output. rev1 only
examples/BrakeAndTorque/       stages A4/A5: the brake current sink and the
                               torque bridge. pcb v1.0 (rev2) only
src/                           board support library
  BoardPins.hpp                GPIO allocation, single source of truth
  Conversions.hpp              ADC and motion scaling, Arduino-free so the
                               host can test it; also the fitted parts' facts
  DAC80501Registers.hpp        DACx0501 register map and output arithmetic.
                               Arduino-free, host tested
  DAC80501Device.hpp           DACx0501 device logic and the GAIN-before-data
                               ordering interlock. Arduino-free, host tested
                               against a fake transport
  DAC80501.hpp                 DACx0501 Arduino transports, SPI and I2C
  ADS8689Registers.hpp         ADS8689 register map, 32-bit framing and
                               transfer function. Arduino-free, host tested
  ADS8689Device.hpp            ADS8689 device logic: range written and read
                               back before any conversion is believed, the
                               two-frame register read. Host tested
  ADS8689.hpp, ADS8689/        the SPI transport with a plain-GPIO CONVST/CS,
                               a SEPARATE library like DAC80501
  DAC80501/                    a SEPARATE library, not board support: this part
                               is placed twice at pcb v1.0 and is intended to
                               be extracted to its own repository once a second
                               consumer exists
  BrakeTorqueBackend.hpp       the seam between the protocol and pcb v1.0's
                               brake sink and torque bridge, with the
                               simulated backend the corpus runs against
  BrakeTorqueHw.hpp            the real one: brake DAC at 0x49 with a ramped
                               setpoint, BRAKE_ISENSE supervision, the
                               ADS8689 sampled on the tick. Firmware only
  SafeState.hpp                safe idle state
  PowerSequence.hpp            the switched rail; the only thing that touches
                               HV_EN
  SelfTest.hpp                 the stage 1 checks
  TmcSpi.hpp                   stage 2's raw datagram layer
  MotionSetup.hpp              stage 3's TMC51X0 glue: this board's facts, and
                               CHOPCONF.vsense, which TMC51X0 cannot express
test/native/                   host tests for the Arduino-free arithmetic
tools/pio_task.py              example selection without editing platformio.ini
tools/pyb_id_probe.py          `id` over both RJ-45 links through the pyboard REPL
tools/circuit_code_map.py      writes docs/circuit-code-map.md from the rig's
                               component registry and the board's records
tools/pycontrol_api_doc.py     writes docs/pycontrol-api.md from the device class
tools/render_diagrams.py       renders docs/diagrams/*.dot to SVG and PNG; the three
                               run together as `make docs`
docs/architecture.md           how the firmware is organised, with the diagrams in
                               docs/diagrams/ (Graphviz sources and rendered SVG)
docs/circuit-code-map.md       generated: which code drives which circuit and part
docs/pycontrol-api.md          generated: the device class's methods and verbs
docs/glossary.md               the words the protocol, the task and the log use
docs/rp2040-bringup.md         stage 1: the procedure, and each failure's meaning
docs/motion-bringup.md         stage 3: the same, plus the rig's measured facts
docs/brake-torque-bringup.md   stages A4/A5: the brake sink and torque bridge
.agents/                       agent guidance and plans

The example to build is selected by PLATFORMIO_SRC_DIR, not by editing platformio.ini, and each example gets its own build directory so switching between them does not invalidate a cache. Same arrangement as TMC51X0.

Environments

Env Purpose
mouse_joystick MJ_BOARD_REV=0, for pcb v0.1 and v0.2 only. Quad-lane boot2_w25q128jvxq stage 2 since 2026-10-06 (every env inherits it); see mouse_joystick_safeflash for the generic one.
mouse_joystick_safeflash Same board on the universal single-lane boot2_generic_03h stage 2, which boots regardless of the flash's quad-enable bit: for a first-article board before its flash is proven. Slow XIP: on the link build it cost the 1 kHz sampler most of its budget (bring-up log, "the tick budget attributed"). Not for delivery.
mouse_joystick_rev1 MJ_BOARD_REV=1, for pcb v0.3 only, where GP10/GP11/GP13 carry the angle DAC on SPI1. This is not a future-only env — a v0.3 board needs it. Building rev 0 against v0.3 drives the spare-pad test onto the DAC's SPI bus and chip select. The check count is the tell: a v0.3 board reporting 27 checks in stage 1 was built with the wrong env; rev 1 reports 24. Not for v1.0, where GP13 is an input from the brake block.
mouse_joystick_rev2 MJ_BOARD_REV=2, for pcb v1.0 (board file d5ae4b0 through tag v1.0). I2C DACs on GP16/GP17, torque ADC on SPI1, ~{BRAKE_FAULT} GP13, HV_LATCH GP24, ~{HV_FAULT} GP23, two spare pads, 20 stage 1 checks. The transport is chosen by the revision; MJ_DAC_I2C is not needed. On hardware since 2026-10-05 (unit E4654C159F274534: stages 1, 2, A3, A6, and A4/A5 on 2026-10-06). Replaces mouse_joystick_rev1_i2c, which described a board that never existed.
mouse_joystick_qp examples/PyControlLinkQp only: the stage-4 protocol inside a QP/C++ active object with a QS trace on FW_TRIG1, rev 0, TmcMotion, pyControl on UART0. qp-arduino comes from its v7.3.4 tag on GitHub.
mouse_joystick_qp_rev1 Same for rev 1.
mouse_joystick_qp_rev2, mouse_joystick_qp_rev2_usb Same for pcb v1.0, delivery and bench (USB protocol, tick probe) builds. Since 2026-10-06 the brake sink and the torque bridge are driven through src/BrakeTorqueHw.hpp: caps reports brake and torque, BRAKE ramps the coil and releases the drive once the current has settled, TQ answers, torque rides in STATUS, the events, TRIAL and the capture.
mouse_joystick_qp_sim_usb Same application for a board with no 36 V and no host: simulated motion, protocol over USB CDC. How it was first run on the fixture.
native host tests.

Five Build-System Traps

  • The board support library is declared as symlink://src/MouseJoystickBoard, not as a bare path. A bare local path makes PlatformIO copy the directory into .pio/libdeps/ and it never refreshes the copy — not on a rebuild, and not on -t clean. Edits to src/MouseJoystickBoard/*.cpp then silently do not get compiled and the build succeeds while testing old code. symlink:// requires the library to carry a manifest, which is why src/MouseJoystickBoard/library.json exists. TMC51X0 has the same bare-path arrangement and therefore the same trap.
  • make test wipes the firmware build directories. The native test run uses the default build dir and prunes the per-example ones. Harmless, but do not read it as a failed build.
  • pixi run test deletes the firmware UF2s, so flash BEFORE you test. This is the same trap as the note above, but it bites harder than it reads: the artifact is simply gone, and picotool load then reports Could not open ...firmware.uf2 — which looks like a tooling failure rather than a missing file. Rebuild, then flash.
  • When picotool will not write, copy the UF2 to the bootloader drive instead. With the board in BOOTSEL, cp firmware.uf2 /media/$USER/RPI-RP2/ and sync works through the bootrom's mass storage and bypasses picotool entirely. On 2026-09-10 picotool failed six consecutive writes with The RP2040 device returned an error: <unknown> while lsblk showed the RPI-RP2 drive present at its correct 128 M — the copy succeeded first try. This is a more reliable recovery than retrying picotool.
  • lib_ldf_mode = off, so nothing is discovered by scanning includes. A library that is not named in lib_deps is simply not on the include path, even a built-in one. The symptom is a No such file or directory for a header the source plainly includes — Wire.h, when DAC80501 was added and only SPI was listed. Both SPI and Wire are named now, on every env, because the DAC driver supports either transport regardless of which one a given board wires up.

Recording Results

Measured behaviour goes in mouse-joystick-pcb/docs/bringup-log.md, under the board's serial, as numbers with units. Two boards in one rig are independently calibrated and independently suspect.

Verification Evidence

evidence/ holds the record of the v1.0 boards' verification: the console logs, protocol transcripts, pyControl session outputs, trace analyses and small logic-analyzer captures from every bench and rig run between the first v1.0 power-up on 2026-10-05 and the installed rig's first observed experiment on 2026-10-09, with the firmware commit and board serial of each. The numbers they support are written up in the board repository's bring-up log; the per-board calibration they produced is src/BoardCalibration.hpp.

Development

docs/development.md covers the sibling repositories this firmware was developed beside, the logic-analyzer tooling used to watch the board, and the observability workstream. Agent guidance is in AGENTS.md and .agents/.

License

3-clause BSD; see LICENSE.

Citation

CITATION.cff carries the citation metadata; GitHub renders it under "Cite this repository".