Skip to content

projects/ janelia/ Mouse Joystick/

pyControl Host Side (Stage 4)

The other end of the UART protocol in mouse-joystick-pcb/docs/uart-protocol.md. The RP2040 firmware in ../src speaks it over RJ-45 pins 1 and 4; the MicroPython device class here speaks it from the pyControl board.

They live in one repository because they are one contract. A protocol with sequence numbers, ack/nak, and a nine-bit fault mask cannot have its two ends version independently.

Target controller: Breakout Board 1.2. UART-capable ports are 1 (UART4), 3 (USART1), and 4 (USART3) — see mouse-joystick-pcb/docs/pycontrol-port-pinout.md. Two boards fit with a port to spare. The device class asserts port.UART is not None, so a non-UART port fails loudly rather than silently not receiving.

Controller Prerequisite: MicroPython >= 1.26

Check this before anything else. devices/uart_handler.py in pycontrol/code asserts on it:

assert (major > 1) or (major == 1 and minor >= 26), "UART_handler requires MicroPython version >= 1.26"

MouseJoystick.__init__ constructs a UART_handler and registers an IRQ_RXIDLE handler, so on an older controller the device class cannot be instantiated — it asserts before a single byte moves, and none of the 128 tests here can catch it because they run against stubs.

The pyControl framework version tells you nothing about this. The bench pyboard was found on 2026-09-03 carrying framework 2.1.1 on MicroPython 1.22.2, which cannot run UART_handler at all. Check the interpreter, not the framework:

>>> import sys; sys.implementation.version
(1, 26, 1, '')

The bench controller was upgraded 1.22.2 -> 1.26.1 on 2026-09-03 (standard PYBV11 build, not THREAD) and became Rig 1's controller on 2026-10-09 (the inventory below was read off it that morning). The desk rig's controller was found on 1.22.2 and upgraded the same way on 2026-10-12. Both controllers in service are on 1.26.1. Full account in mouse-joystick-pcb/docs/pycontrol-interface.md.

Setting Up Another Controller (and what the delivered one carries)

Read off the Rig 1 controller on 2026-10-09, the morning it left the bench, through the raw REPL. Nothing on it is unique: every file comes from a repository, so a replacement controller is built from the recipe below and needs nothing copied from this one.

Item Value
Hardware pyboard PYBv1.1 (STM32F405RG) on a pyControl Breakout 1.2; no modification to either
USB serial / machine.unique_id() 397734803233 / b'M\x00B\x00\x0bQ323459'
MicroPython v1.26.1 on 2025-09-11, build PYBV11 (standard, not THREAD); upgraded from 1.22.2 on 2026-09-03
pyControl framework 2.1.1 (pyControl/: __init__ 137, audio 3775, framework 7099, hardware 21922, state_machine 4357, timer 2691, utility 6061 bytes)
devices/ __init__ 276, breakout_1_2 927, frame_trigger 1093, uart_handler 871 (pyControl's own); mouse_joystick 7256, mouse_joystick_protocol 7666 (this repository's lean copies, as on the joystick-rig branch); opto_led 4207 (joystick-rig)
hardware_definition.py 2157 bytes: joystick-rig hardware_definition_joystick.py
task_file.py 6905 bytes: joystick-rig tasks/rig1/joystick_trial.py
boot.py, main.py stock (454 and 34 bytes, nothing enabled)
Flash 190 blocks of 512 bytes (95 KB), 15 free (7.5 KB) with everything above loaded

The researcher's GUI replaces the hardware definition, the task and the device files on first use (the guide in joystick-rig), so only the MicroPython version is state the GUI cannot restore.

Recipe for a new Breakout 1.2 controller

  1. Check the interpreter first, over the pyboard's USB REPL: import sys; sys.implementation.version must be (1, 26, …) or newer. pyControl 2.1.1's UART_handler asserts on it and the joystick device class cannot be instantiated below 1.26 (the section above).
  2. If it is older, flash MicroPython 1.26.1, standard PYBV11 build (micropython.org/download/PYBV11, the .dfu file). Back the filesystem up first (mpremote connect <port> fs cp -r :/flash <dir>, or mount the board as mass storage and copy). Put the board into DFU mode with machine.bootloader() at the REPL (or jumper BOOT0 to 3V3 and press reset), then dfu-util --alt 0 -D PYBV11-<date>-v1.26.1.dfu (or MicroPython's pydfu.py -u). The firmware writes flash sector 0 and sectors 5 and up; the filesystem lives in sectors 1–4 and survives, which was verified on the bench controller. The tool used on 2026-09-03 was not recorded; both of the above work.
  3. pyControl 2.1.1 on the host (the researcher's machine, or the pycontrol-code checkout here), then Config → Load framework. This wipes the device files and the hardware definition on the board, which is wanted: pyControl never removes a device file otherwise, and stale ones are what fill the flash.
  4. Device files into pyControl's devices/ folder: mouse_joystick.py and mouse_joystick_protocol.py (this repository's dist/devices/, identical to the copies on the joystick-rig branch) and the rig's opto_led.py. frame_trigger.py and breakout_1_2.py ship with pyControl.
  5. Load the hardware definition, joystick-rig's hardware_definition_joystick.py (left joystick port 1, right port 4, solenoid port 5, camera trigger BNC_1, Rsync DAC_2), then upload the task tasks/rig1/joystick_trial.py. Expect about 7.5 KB free afterwards. "Insufficient space on pyboard filesystem" means a stale device file: repeat from step 3.
  6. Verify with two trials and nobody at the joystick, as in instrument-control/docs/rig-day.md ("Rehearsal before the move"): tools/pycontrol_run.py --hwd-path … --task-dir … --task joystick_trial --set n_trials=2; each board answers its identity, homes, presents at −5°, reports NOMOVE, parks at 0°.

Layout

Path Runs on Delivered by
devices/ pyControl board (MicroPython) its lean copy in dist/devices/, symlinked into pyControl's own devices/
hardware_definitions/ pyControl board (MicroPython) its lean copy in dist/hardware_definitions/, a config/settings.json folder redirect
tasks/ pyControl board (MicroPython) its lean copy in dist/tasks/, a config/settings.json folder redirect
dist/ — generated, not committed: tools/pycontrol_lean.py strips comments and docstrings from the three folders above (49 → 20 KB on 2026-10-06); regenerated by pycontrol-link and by every pycontrol-run
sim/ host (CPython) not delivered; test infrastructure
tests/ host (CPython) not delivered; this is Gate C (and test_lean.py imports what dist/ holds)

devices/ and below is MicroPython that pixi never executes — pixi supplies the host GUI that uploads it. mouse_joystick_protocol.py is the exception: it runs in both places, which is why it has no pyControl imports.

Sibling imports inside devices/ must be package-qualified — from devices.mouse_joystick_protocol import ..., matching pyControl's own from devices.poke import _Poke. On the pyboard the uploaded files live inside the devices package and /flash/devices is not on sys.path, so a bare import raises ImportError there while passing every host test. tests/test_device_class.py asserts this against the source text, because no runtime test on a host can reproduce the board's import environment.

Two Delivery Mechanisms, Not One

mouse-joystick-pcb/docs/validation-plan.md Gate E requires that "an unforked upstream pyControl installation loads the device class through its normal supported mechanism". make pycontrol-link does that, and it needs two different mechanisms because pyControl treats the folders differently:

  • hardware_definitions and tasks are redirected through config/settings.json. pyControl resolves both through get_setting("folders", ...), and upstream's own .gitignore excludes them — they are declared user-owned. Redirecting them means this repository holds the only copy.

  • devices is not redirected, and must not be. source/communication/pycboard.py builds its device-class map by scanning exactly one folder (os.listdir(user_folder("devices"))). Point that at this repository and Breakout_1_2, UART_handler and every other stock driver disappear, so the hardware definition fails to import. The device file is symlinked into pyControl's devices/ instead, which is the upstream mechanism: any class in any .py there is auto-discovered with no registration step.

On Windows, where symlinks need developer mode, link copies and says so. A copy goes stale, so re-run make pycontrol-link after editing the device class.

Undo either with make pycontrol-unlink.

What Reaches The Pyboard Is The Lean Copy

The pyboard's internal flash is 95 KB and the pyControl framework takes 47 KB of it. The first Gate E upload (2026-10-06) failed with "Insufficient space on pyboard filesystem": 36 KB free, 60 KB to send, because these files are written to be read (60 % comments and docstrings) and pyControl uploads source files verbatim. The rig's pyboard has the same flash. So the files the board receives are generated: tools/pycontrol_lean.py parses each source, drops the docstrings, and unparses it -- comments are not in the syntax tree, so they go too -- into dist/. Same code, same names, same behaviour; a header line says where it came from. make pycontrol-link points pyControl at dist/, make pycontrol-run regenerates it before uploading, and tests/test_lean.py compiles every output and drives the lean codec. Edit the sources only; the rig delivery ships dist/ plus this README.

Three Things pyControl 2.1.1 Taught Us At Gate E (2026-10-06)

  1. Device-file discovery is a substring match over file text, with no cycle check. pycboard._get_used_device_files keeps every file whose class name appears anywhere in the referencing file's text -- comments included -- and recurses into each. MouseJoystick is a prefix of MouseJoystickProtocol, so the codec's own class name made its file "mention" the device class, the device class mentions the codec, and the GUI's path recursed until Python gave up. The codec is JoystickProtocol now, its header says why, and test_lean.py checks the lean device file names only what it uses: a comment that mentioned MCP23017 and Poke as examples of pyControl's own imports had been pulling a 9 KB driver onto the board.
  2. Flash is the budget, not RAM. See above.
  3. The task API has no "current state" query. pyControl.utility exports goto_state, the timers, print, publish_event, get_current_time and the random helpers; the framework's current_state lives in state_machine.py and never reaches the task. The bring-up task's first run on the board died on a get_current_state() one step after hv on -- with the rail up and nobody talking to the board, before its host watchdog was armed, so the rail stayed up until the next run's hv on/hv off. mouse_joystick_check keeps its own v.phase, set on every state's entry, and reads that wherever a reply or an event means different things in different states (TARGET in moving vs parking, the ack in powering vs arming).

A fourth thing, 2026-10-07: pyControl never deletes a device file

Pycboard.transfer_device_files() uploads with remove_files=False, so every device file ever pulled onto the pyboard stays until "Load framework" wipes the folder, and an upload that dies of "Insufficient space" leaves the file it was writing truncated (a 512-byte frame_trigger.py on 2026-10-07, which would have failed to import). The rig's hardware definition found 0 bytes free that morning: MCP.py and rotary_encoder.py from 2026-10-06's first attempt were still there. Check with os.statvfs('/flash') through Pycboard.exec(), remove with Pycboard.remove_file('devices/<name>.py'), and upload the rig's files lean (tools/pycontrol_lean.py's lean_source() works on any file; the rig's definition is 15 KB verbatim, 706 bytes lean). tools/pycontrol_run.py --hwd-path <file> --task-dir <dir> runs a hardware definition and task from another checkout — the rig's — with the device files still coming from pyControl's devices/ folder, where pycontrol-link points at dist/ and where the rig's opto_led.py had to be symlinked too.

A fifth thing, 2026-10-07: do not kill the host process to stop a run

The pyboard runs the task on its own, but it prints every event over USB CDC, and MicroPython's USB write blocks once the host stops draining it. So killing tools/pycontrol_run.py mid-run stalls the task within a few lines, the heartbeat stops with it, and the board trips its host-loss watchdog (host_timeout, rail off, FAULTED) about 100 ms later, which is what the board is for but not what was meant. To stop early, send pyControl's stop signal first (Pycboard.stop_framework(), a single \x03 on the pyboard's serial port): the framework runs run_end (stop, disarm, rail off) and exits; then end the host. mouse_joystick_check now sends clrfault before id so a board left FAULTED by a run that ended badly is usable by the next one (a clrfault on a clean board is a plain ack).

Also from the same morning: three lines queued behind home cost one of them a badframe — home blocks the board for ~130 ms (the handoff's hv on/home blocking note) and the task had sent angscale and angout right behind it. Sent behind the non-blocking present move instead they are fine. Keep configuration lines away from the two blocking verbs.

Gate E Passed On v1.0 Unit A (2026-10-06)

pixi run -e pycontrol pycontrol-run <pyboard port> mouse_joystick_bringup mouse_joystick_check 50 (the Pycboard path pyControl's GUI uses, no GUI) uploaded the lean device files, the hardware definition and the task to the PYBV11 (MicroPython 1.26.1, pyControl framework 2.1.1) and ran the sequence against unit A over the RJ-45 on Breakout 1.2 port 4, twice (runs evidence/2026-10-06-1019-unitA-gate-e/ and -1037-):

Task time What happened (run 1037, board freshly booted)
0.024 s id answered: serial E4654C159F274534 fw 9b6d2ef-dirty hw rev2 proto 2 caps motion+home+instr+hv+watch+fetch+stream+cfg+angout+brake+torque
0.204 s hv on acked (180 ms: the rail's soft start and the peripheral phases)
1.061 s HOMED at 0.0°
1.090 s arm acked; moveto -5
1.440 s TARGET at −5.041°
1.464 s brake 1.0 → RELEASED 23 ms later, torque=-0.206;brake=1.0
1.490 s TOUCH at −4.774°, torque −0.345 N·cm (nobody touched it: the rest offset of the torque reading and the brake's release are above the 0.3 N·cm touch level -- tare before watch, or raise touch)
11.483 s NOMOVE (10 s timeout, no hand on the joystick)
12.495 s TRIAL one second later: a_peak 0.267 tq_peak -0.345 tq_out -0.261 outcome NOMOVE
12.859 s park to 0° → TARGET at −0.991° (see below)
50 s run_end: stop, disarm, hv off; the board ends HOMED with the rail down

Heartbeats ran at 25/s throughout (joystick_R input events, 1236 lines in 50 s), none lost. Two things the runs showed that are not pyControl's: the trace's tick statistics (the firmware plan, "tick budget"), and the park after a trial ending ~1° short on the encoder with the driver reporting its target reached, in both runs -- a direction reversal after the move to −5°, so most likely the coupling's backlash; a trial that needs the park position exact should home first. (Measured and fixed the next day: lost motion on every direction reversal, and since 2026-10-07 the board settles on the encoder after each move before TARGET — uart-protocol.md V1.13, the PCB bring-up log "the encoder settle after a move"; the park lands within a count and home_each_trial is off in the rig task.)

Two follow-ups the same day, both asked for by Peter after the runs:

  • The watchdog runs with the rail (uart-protocol.md V1.12). hv;ON arms the board's host-loss watchdog, hv;OFF or a fault disarms it, arm refreshes it and disarm leaves it running while the rail is on. A task must therefore have its heartbeat running before hv(True); mouse_joystick_check starts its timer in run_start. Three corpus cases pin it (hv-on-arms-the-watchdog, hv-off-disarms-the-watchdog, disarm-keeps-the-watchdog-while-the-rail-is-on), and two older cases that idled with the rail on were rewritten to advance the clock before the rail or to keep the heartbeat.
  • Tare before the watch. The task sends tq;zero immediately before watch (both lines queued back to back; the board applies the zero first), so the rest offset no longer trips the touch level. The reply to tq;zero carries no data and no phase handles it, which is how every "fire and forget" command in this task is kept out of _handle's way.

Two bench knobs were added for the tick-budget attribution: v.wdt_ms (sent as cfg;wdt_ms= before the rail comes on, so heartbeat_interval can be slowed) and v.instr (0 sends instr;OFF, which also silences the QS trace). Both are sent from identifying, whose acks nothing handles.

The attribution they served ended the same day: the board's 1 kHz sampler was being throttled by instruction fetch from flash (the generic 03h flash stage 2), not by the bus or the host; on the quad-lane stage 2 (now the default of mouse_joystick_qp_rev2) run 9 held 1 kHz through the whole trial with the 25/s heartbeat (PCB bring-up log, "the tick budget attributed on the bus and on the pin"). A ping costs the board ~0.5 ms of handler time there, so the 40 ms heartbeat is fine as it is.

Editing Cycle, And The Trap In It

The collaborator's rig hardware definition (joystick-rig/pycontrol/hardware_definitions/hardware_definition_joystick.py) documents this from experience, and it applies here unchanged:

Editing this file on disk does NOT change the pyboard. To make a change take effect: edit → Config → Load hardware definition → re-Upload the task → restart the session.

And separately: Config → "Load framework" deletes hardware_definition.py from the pyboard. After reloading the framework you must reload the hardware definition, or task upload fails with ImportError: no module named 'hardware_definition'.

Gate C

mouse-joystick-pcb/docs/uart-protocol.md asks for "a host-side simulator or protocol fixture so both this firmware and the pyControl device class can be tested before real hardware and a real pyControl installation are both available." make pycontrol-test runs it: no board, no breakout, no pyControl checkout.

File What it is
devices/mouse_joystick_protocol.py The framing codec. Runs on the board and under pytest, which is why it has no pyControl imports.
devices/mouse_joystick.py The device class.
sim/board_sim.py Host simulator: state machine, faults, retransmission, watchdog. Injected clock, so every timestamp is deterministic.
tests/fixtures/protocol_cases.txt The shared corpus.
../src/Protocol.hpp The firmware framing codec. Arduino-free and allocation-free, which is what makes it testable on the host.
../src/ProtocolBoard.hpp The firmware command layer and state machine, with an injected clock.
../test/native/test_protocol/test_protocol.cpp The firmware half of the corpus check, plus codec unit tests.
../src/MotionBackend.hpp The seam. SimulatedMotion is the host model the corpus runs against; the protocol layer owns no motion of its own.
../src/TmcMotion.hpp Firmware only: non-blocking motion over the real TMC5130A. Not reachable from the native test, which is why the corpus stays host-runnable.
../src/PycUart.hpp Firmware only: UART0 on the RJ-45, and a 64-bit clock accumulated from micros().
../examples/PyControlLink The two ends wired together. Builds; never run.

Both halves now exist, as of 2026-09-03. The firmware side is src/Protocol.hpp (the framing codec) and src/ProtocolBoard.hpp (the command layer and state machine), held to the same corpus by test/native/test_protocol/test_protocol.cpp. The corpus is line-oriented rather than JSON specifically so that C++ test can parse it with getline and a prefix compare; it does exactly that. Run both:

make pycontrol-test                  # the Python half
pixi run -- pio test -e native       # the firmware half

A case changed in the corpus must be re-run against both, or the contract silently forks.

What holding two implementations to it actually bought. Mutation-testing the firmware against the corpus found that inverting the ARMED and range checks in MOVETO passed every case in the file — so the corpus documented a safety-relevant ordering it did not enforce. armed-is-checked-before-the-range-check was added to close it. That is the class of thing a single implementation and its own fixture cannot find, and it is the argument for the whole exercise.

And what it does not buy, learned on hardware 2026-09-03. A corpus held by two implementations pins protocol behaviour. It says nothing about hardware preconditions, because one of the two implementations has no hardware. status-is-safe-before-anything-is-configured passes here and hung the RP2040's core, because the firmware's STATUS read position over SPI before the bus existed — and SimulatedMotion just returns a number, so there was nothing for the corpus to disagree with.

Read a green corpus as "the two ends agree about the wire", never as "the firmware is right". Anything the firmware does that this side cannot express is unverified by construction. Account in mouse-joystick-pcb/docs/bringup-log.md under board 3, stage 4.

The simulator is not a model of the board. No TMC5130A, no encoder, no current control, no real motion profile. Position moves linearly at a configured velocity because the protocol is what is under test.

Scoped To Rev 0 Hardware

Narrowed 2026-09-02 to what the boards in hand can actually do. Writing firmware against absent hardware means the tests assert what was guessed.

Implemented Absent, and why
ID PING STATUS LIMITS FAULTS CLRFAULT HV INSTR CAL BRAKE / torque — no electronic brake, no torque sensor
HOME ARM DISARM MOVETO MOVE STOP ANGOUT ANGSCALE — analog outputs not populated
FREEWHEEL, ARMPULL (testable by moving the joystick by hand)

Unimplemented verbs answer badverb, never a silent ack — a stub that acked would let a host believe the analog output was live and record a trace of nothing.

ID reports caps=motion+home+freewheel+instr+hv. A host should gate on that rather than on a firmware version, so a rev-1 board simply reports more.

The trial shape, and what it changed

Park (HOME) → present (MOVETO) → release (FREEWHEEL) → the animal pulls → present again. Two consequences that are not in the draft:

Homing is no longer how position is recovered. The previous rig had no encoder, so every pull lost the stepper's position outright and every trial had to start with a home. Here the encoder keeps it. Homing is now for parking the joystick between trials — a choice, not a necessity.

FREEWHEEL is a rev-0 verb with a known expiry date. On a board with the brake it must not exist separately: the brake has to engage before motor drive is released, or the joystick drops as it goes compliant. Two host commands cannot guarantee that ordering — the host can die between them, or the second message can arrive late over a link whose whole point is that it has latency. It belongs inside one BRAKE <torque>. Expect this verb to be replaced, not extended.

Leaving compliance is likewise implicit: a MOVETO, MOVE, HOME or STOP reloads position from the encoder, re-engages, and proceeds — one command where an explicit "stop freewheeling" would be two, with a window in between.

The resync fights the stall detector

.agents/AGENTS.md: "XACTUAL advancing while X_ENC does not is how firmware detects an end stop, a stall, a slipping coupling or a dead encoder. Do not add a motion path that skips it."

That detector is the divergence between the two numbers — so reloading XACTUAL from the encoder deletes it. Doing it continuously, or on a timer, trades the board's only stall detection for not having to track which state it is in. The two cases are distinguishable by state and by nothing else:

State Divergence means Response
driving end stop, stall, slipping coupling, dead encoder fault
compliant the animal pulled resync, on exit only

STATUS therefore reports both angle (encoder — the joystick's real position) and xactual (ramp generator). The draft's single "position" field cannot express the difference, and after a pull they disagree by the whole pull.

Stall detection is not implemented. It needs a fault bit the draft does not have — bit 4 is retired and permanently reserved, and it was a different comparison anyway (driver encoder vs. an RP2040 PIO count that never existed). This is XACTUAL vs X_ENC, both inside the driver. Raised below rather than given a bit unilaterally.

What Implementing The Draft Surfaced

uart-protocol.md is marked "Draft v0.1. Not implemented, not frozen." Writing two ends of it turned up the following. Everything below is marked SPEC: at the point in the code where it was decided.

One real problem, now resolved; one still open

1. The retransmission rule broke the heartbeat — RESOLVED 2026-09-03. The draft said "a repeated seq with identical content is treated as a retransmission and re-acknowledged without re-executing." That rule exists so a retried relative MOVE does not move the joystick twice — it assumes re-execution is the harm. For PING the side effect was the command: it had no result other than resetting the host-loss watchdog, so a PING served from cache let the safety timeout expire while the host believed it was being answered.

The likely failure was not the 65536 wrap the draft reached for — a bounded cache makes that unreachable. It was the ordinary retry: reply lost, host retries, retry served from cache without resetting, repeat. On a degraded link no retry in the burst resets the watchdog, so the board faults exactly when the link is marginal, while the host is receiving acks.

Resolved as a layering fix, in mouse-joystick-pcb/docs/uart-protocol.md open question 1. Two changes, now implemented here:

  • The watchdog is reset on receipt of any well-formed command, in _handle_line before dispatch, cache hit or miss — not inside a handler. Liveness is a link property. badframe does not reset it, so a wedged serial driver cannot hold the safety timeout open. PING is no longer special and needs no exemption, and a STATUS poll now proves liveness.
  • The cache is restricted to state-changing commands, via JoystickProtocol.changes_state(), which classifies the parsed command because CAL, INSTR and HV are each a read or a write depending on their argument. Unrecognised commands default to cached: over-caching costs a stale ack, under-caching moves the joystick twice.

Worth carrying: MOVE is the only non-idempotent command in the set, so the cache protects exactly one verb. If the trial only ever uses absolute positioning, MOVE and the cache can both go. The cache is also now bounded at 2 entries rather than 16.

The corpus did not change — it pins PING framing, not retransmission or watchdog timing — so the contract the RP2040 half will be held to is unmoved.

2. The host timeout is shorter than a long move. The cited host timeout is 100 ms (pycontrol-port-pinout.md). At the simulator's default 180 °/s, an 18° move takes exactly that long — and the travel range is 40° wide. A task that issues a long move and then waits for TARGET gets faulted for heartbeat loss every time. This may be fine (the host simply must ping during motion, which tasks/mouse_joystick_check.py does at 40 ms) but it should be stated in the draft rather than discovered. Confirm the real velocity and the real timeout together, not separately.

Gaps that were filled to make the thing run

Item Draft says Decided here
Verb case Table is MOVETO, examples are moveto;417;-12.5 Decode either, emit lowercase
outofrange example state moveto;421;-999 → nak;421;outofrange, state unstated ARMED is checked before range, so a fresh board answers notarmed. Safety rule should not be reachable past an argument check
Ack data format One example, ack;419;enc_offset=1024;angle_zero=0.0 All data-carrying acks are key=value
ID/STATUS/LIMITS fields Prose lists Named keys, pinned byte-exact in the corpus
Max line length Only "rxbuf=130" as a hint MAX_LINE = 120, leaving room to answer badframe inside a 130-byte buffer
nak reasons Names 4: badverb, outofrange, badframe, latched Added badargs, notarmed, nothomed, faulted, busy, nohv
Retransmit cache size Unbounded 2 (kRetransmitCacheSize; the 2026-09-03 resolution of open question 1). Unbounded is a slow leak on a device with no way to report one
When the watchdog is armed Unstated At ARM, released at DISARM. Faulting an idle unarmed board before the host has connected would be absurd
HV as a motion precondition Unstated HOME and motion require it; new reason nohv
\r handling "A leading \r is tolerated" Stripped from both ends — a CRLF peer produces a trailing one, so the literal reading leaves the common case broken
Checksum Explicitly open Not implemented. Decide with a measured error rate on the real cable, as the draft says

Not Covered By Any Host Test

Interrupt timing, UART.IRQ_RXIDLE actually firing, rxbuf overrun, MicroPython's own UART driver, and every electrical property of the link. Those need the Breakout 1.2 and a board. A green make pycontrol-test is not a bring-up.