UART Application Protocol¶
Rev 0 scope: the protocol is unchanged, with one hardware-imposed gap. The
ENCODER_MISMATCH fault compares the TMC5130A's encoder count against an
independent RP2040 PIO count. As of 2026-08-16 that second count is never
coming — the encoder reaches the driver only, in every revision, and
GP14-GP16 have been returned to the spare pool. Retire the fault code rather
than leaving it reserved; see docs/interface-map.md, "The Encoder And Limit
Switch Do Not Reach The MCU". Encoder integrity rests on the driver's count and the
index pulse.
Status: Protocol v1, proto=2 on the wire: implemented at both ends in
mouse-joystick-firm and in service on the four pcb v1.0 boards of Rig 1
and the desk rig since 2026-10-12. The specification of record is the
Protocol v1 Proposal section below,
whose last recorded decision is V1.13 (2026-10-07); the sections before it
are the v0.1 draft it amends and the questions the first implementation
raised. The "Rev 0 scope" paragraph above is history: ENCODER_MISMATCH
was retired.
Date: 2026-08-12; status updated 2026-10-13
Derived from: mouse-joysticks-pcb/docs/uart-protocol.md v0.3, with the
axis argument removed.
Ownership: This project owns the application protocol. board-interfaces
owns the physical port profile.
Transport: 115200 8N1, newline-terminated, over RJ-45 pins 1 and 4. See
docs/pycontrol-port-pinout.md.
Align With The Lab's Existing Idiom¶
The syringe pump at syringe-pump is a working pyControl
peripheral in this lab, documented against pyControl v2.1.1. This draft adopts
its framing conventions rather than inventing a parallel style:
| Convention | Syringe pump | Adopted here |
|---|---|---|
| Field delimiter | ; |
; |
| Terminator | \n |
\n |
| Baud | 115200 8N1 | 115200 8N1 |
| Verb position | first field | first field |
| Device parse | msg.split(";") |
same |
| Host receive | UART_handler + UART.IRQ_RXIDLE |
same |
Where this draft deliberately differs: the syringe pump has no sequence
numbers and no acknowledgements, which is reasonable for a request/response
dispenser. This board drives a motor against an animal under a safety heartbeat,
so it keeps sequence numbers, explicit ack/nak, and a fault model. The
framing is shared; the reliability model is stricter.
No Axis Argument¶
The twin-axis protocol carries an axis field — L, R, or B — in every
command, and a matching field in every event. This board has one axis, so that
field is absent, not defaulted.
Keep the field order such that adding it back is a clean insertion after the verb, in case this protocol is ever carried to the twin board:
this board: VERB;seq[;arg...]
twin board: VERB;axis;seq[;arg...]
A device class written for one will not silently work with the other, which is
correct. ID reports a protocol version and an axis count so a host can tell
which it is talking to and refuse rather than guess.
Framing¶
command: VERB;seq[;arg...] \n
reply: ack;seq[;data...] \n
nak;seq;reason \n
event: ev;EVENT;t_us[;key=value...] \n
Concrete examples:
moveto;417;-12.5 -> ack;417
home;418 -> ack;418
cal;419;get -> ack;419;enc_offset=1024;angle_zero=0.0
movetoo;420;-12.5 -> nak;420;badverb
moveto;421;-999 -> nak;421;outofrange
ev;PULL;18420102;angle=-3.1
ev;FAULT;18599112;code=2;detail=over_travel
seq— decimal, wraps at 65535. The board echoes it. A repeatedseqwith identical content and a state-changing verb is treated as a retransmission and re-acknowledged without re-executing. Reads and heartbeats are always re-executed. Resolved 2026-09-03 — see 1. The retransmission rule and the heartbeat.- The retransmission cache holds the last 2 commands, not an unbounded
seq-keyed history. The case it exists for is "the host retried immediately because a reply was lost"; a deep window buys nothing and is the only thing that makesseqreuse across a wrap reachable. - Fields are
;-separated. No embedded;or newline in values. - Malformed or over-length lines produce
nak;<seq or ?>;badframeand are otherwise discarded. - Line ending is
\n. A leading\ris tolerated and ignored. - Keep the device's receive buffer at least as large as the longest command plus
margin. The syringe pump's host side uses
rxbuf=130; size the device side to match or exceed the longest line this protocol can produce.
Open decision: whether to add a checksum. This cable runs next to motor leads. Recommendation is a two-hex-digit CRC-8 as a final field, with the host allowed to omit it during manual terminal debugging. The syringe pump has no checksum, so adding one is a deliberate divergence — justify it with a measured error rate on the real cable rather than by assumption.
Design Rules¶
- Newline-terminated ASCII. Human-readable at a serial terminal, greppable in a log, and decodable from a logic-analyzer capture without a custom plugin.
- Commands are bounded. There is no "set PWM" or "step N times" verb. Anything that could produce unbounded motion is not in the command set.
- Commands are acknowledged with the sequence number they carried, so a lost reply is detectable.
- Events are asynchronous and carry a local timestamp.
- Latency-critical events also assert a TTL line. The UART message is the description; the line is the timestamp.
- The host-loss watchdog is reset by the arrival of any well-formed command,
not by executing one. Liveness is a property of the link, so it is decided
where the frame is received, before dispatch and regardless of whether a
retransmission cache serves the reply. A frame that fails to parse
(
badframe) does not reset it: a host with a wedged serial driver emitting garbage is not usefully alive and must not hold the safety timeout open. - The watchdog runs for as long as the motor rail is on:
HV ONarms it,HV OFFor a fault disarms it,ARMrefreshes it andDISARMleaves it running (2026-10-06; until thenARMarmed it andDISARMcleared it, and a host that died betweenHV ONandARMleft the rail up). A host must heartbeat from the moment it asks for the rail. - The board never requires a message in order to become safe.
Message Rate¶
One axis roughly halves the message rate versus the twin board, which relieves the pyControl constraint that task code runs on the MicroPython board and should not poll at high rates. Two boards on two ports restores the total, but spreads it across two links and two handlers.
Still:
- Do not stream per-sample angle over this link. That is what the board's own
high-rate stream and
RSYNCalignment are for. - Keep the heartbeat rate as low as the safety timeout allows.
- Measure the achieved round-trip and event-message latency during Gate C rather than assuming the link is comfortable.
Commands¶
| Verb | Args | Reply | Notes |
|---|---|---|---|
ID |
— | hw rev, board serial, fw version, protocol version, axis count, capabilities, buffer state, and since 2026-10-02 boot=por\|wdt\|reboot;crumb=N: why the board last booted (power-on, the 2 s hardware watchdog, a forced reboot) and the last bring-up breadcrumb before a watchdog reboot, both captured once at boot |
Must work before ARM and must never fail |
PING |
— | ack |
Heartbeat. Carries no data and has no side effect of its own — any well-formed command resets the host-loss watchdog, so this is simply the command to send when there is nothing else to say |
STATUS |
— | state, angle, position, homed flag, limit state, fault mask | Safe at any time |
LIMITS |
— | configured travel, velocity, acceleration, current bounds | Reports what the board will enforce. v_max and a_max are the values programmed into the ramp generator (VMAX and AMAX, in degrees), not independent limits: on the rig firmware a_max is 180 deg/s² from its 0.5 rev/s² ramp (2026-10-02); the simulator reports its own programmed numbers |
HOME |
[timeout_ms] |
ack then !HOMED |
Bounded travel and timeout, enforced locally |
ARM |
— | ack |
Required before motion; refused if not homed or if faulted |
DISARM |
— | ack |
Always permitted; the host-loss watchdog keeps running while the rail is on |
MOVETO |
deg [profile] |
ack then !TARGET |
Absolute; rejected outside travel limits |
MOVE |
deg [profile] |
ack then !TARGET |
Relative; rejected if the result is outside limits |
STOP |
— | ack |
Controlled stop; always permitted |
ARMPULL |
threshold |
ack |
Arms pull detection; !PULL and the TTL line follow |
ANGOUT |
ON/OFF |
ack |
Enables the 0-3.3 V analog output |
ANGSCALE |
deg_lo deg_hi |
ack |
Maps the angle range onto 0.05-3.300 V; see below |
FAULTS |
— | fault mask with per-bit detail | Readable while faulted |
CLRFAULT |
— | ack or nak;latched |
Clears only clearable faults whose cause has gone |
CAL |
get / set k v |
calibration values | Encoder offset, angle zero, travel limits |
INSTR |
ON/OFF/STATE |
ack / state |
Instrumentation buffer output enable |
HV |
ON/OFF/STATE |
ack / state |
Motor rail high-side switch, see docs/power.md. ON arms the host-loss watchdog, OFF disarms it (2026-10-06) |
Deliberately absent, and to stay absent: any verb that sets raw driver current, raw PWM, a step count, or a velocity without a bounded destination.
ID reports the board serial. With two interchangeable single-axis boards in
a rig, the serial is how a task confirms it is talking to the board whose
calibration matches the joystick in front of it. Do not omit it.
HV exists because the 36 V input block's ENABLE is pulled low and the rail is
genuinely off at boot. It is a state the host can observe and should not have to
infer.
ANGSCALE maps onto 0.05-3.300 V, not 0-3.300 V, and both ends of that are
deliberate. Updated 2026-08-26; the range used to read 0-5 V, changed to
0-3.3 V by the OneBox acquisition contract in
docs/onebox-analog-interface.md.
- The top is a hardware limit: the DAC cannot exceed its own 2.5 V reference, so the gain stage cannot exceed 3.300 V. Firmware cannot overshoot it.
- The bottom is a hardware limit too, in the other direction: the output amplifier sits about 25 mV off ground at light load, so a commanded 0 V is not achievable. Starting the valid range at 50 mV keeps 0 V as an out-of-band level meaning unhomed, disarmed, faulted, or unpowered.
ANGOUT OFF, a fault, and a dead switched rail all produce the same 0 V.
That is intended — one out-of-band level, not three — but it means the analog
signal alone cannot say which. STATUS must report the output as invalid in
every one of those cases, because the recorded trace will not.
Firmware ordering, not a protocol matter but easy to get wrong here: the
DAC's GAIN register must be written to 0x0101 before the first data write.
Its reset value selects a 0-5 V span, and until that write lands a full-scale
code drives roughly 4.85 V into whatever is on the BNC. ANGOUT ON is the
natural place to enforce it.
Events¶
| Event | Keys | TTL line | Notes |
|---|---|---|---|
READY |
fw, proto, serial |
— | Once after boot; host should reconnect and re-ID |
HOMED |
t, angle |
— | |
TARGET |
t, angle |
— | UART only; motion completion is not latency-critical. angle is the encoder's, within one count of the commanded target (V1.13, 2026-10-07): the ramp generator arriving is not the joystick arriving, and the board corrects the residual before it reports |
PULL |
t, angle |
PULL_EVENT |
|
LIMIT |
t |
— | Limit asserted outside homing |
FAULT |
t, code, detail |
FAULT |
Latched; motion already stopped before this is sent |
RSYNC |
t, n |
— | Local timestamp and sample index of an RSYNC edge |
t is the board's local microsecond counter. It is not pyControl time;
alignment happens in post-processing via RSYNC.
With two boards there are two local clocks. Each board's stream is
internally coherent; cross-joystick ordering is a post-processing question
answered through the RSYNC edges both boards record. Record the alignment
uncertainty rather than assuming it is negligible — this is the cost the twin
board's shared timebase avoids.
State Machine¶
BOOT ──> IDLE ──HOME──> HOMED ──ARM──> ARMED ──motion──> ACTIVE
^ |
+─────completed────+
fault/STOP ──> FAULTED ──CLRFAULT──> IDLE
heartbeat loss, host reset, cable removal ──> FAULTED
BOOTleaves the motor rail off (HV_ENlow), the driver disabled, and emitsREADY.- Motion verbs are refused outside
ARMED. FAULTEDis entered without host cooperation and exited only throughCLRFAULT, and only for faults whose cause has cleared.
Fault Codes¶
A bitmask, so several faults can be present at once.
| Bit | Code | Cause |
|---|---|---|
| 0 | HOST_TIMEOUT |
Heartbeat not received within the timeout while the motor rail is on |
| 1 | DRIVER_FAULT |
TMC5130A DIAG0 or DIAG1 asserted |
| 2 | OVER_TRAVEL |
Commanded or measured position outside travel limits |
| 3 | HOME_TIMEOUT |
Homing exceeded its bound |
| 4 | ENCODER_MISMATCH |
Retired 2026-08-16 — there is no second count. Leave the bit permanently reserved so no later fault reuses it |
| 5 | LIMIT_UNEXPECTED |
Limit switch asserted during normal motion |
| 6 | SUPPLY_OUT_OF_RANGE |
Motor supply outside the accepted window, from HV_SNS, or ~{HV_FAULT} asserted by the input switch — overload, short circuit, or its thermal shutdown |
| 7 | INTERNAL |
Firmware assertion or self-test failure, including CHOPCONF vsense != 1 |
| 8 | EXT_STOP |
External fault input asserted, if the cross-board fault line is fitted |
Bit 7 is where the vsense = 1 read-back check reports. See docs/rev0-plan.md
— with 0.15 Ω installed, vsense = 0 drives ~1.8× the motor's rated current, and
vsense = 0 is the chip's reset default.
Bit 8 is reserved whether or not the cross-board FAULT interconnect is fitted;
see docs/pycontrol-interface.md.
Reserve bits 9 onward. If this design is ever extended with a brake and a torque sensor, their faults go there.
Versioning¶
ID reports a protocol version and an axis count. The pyControl device class
must refuse to operate against an incompatible major version rather than
guessing. The axis count is what distinguishes this board from a twin-axis board
on the same link — do not let the class infer it from the product name.
Host Simulator¶
Provide 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.
Gate C must cover malformed, duplicated, delayed, dropped, and out-of-order input, and must verify that timeouts and faults recover only through declared paths. Add:
- an unrecognized verb is rejected, never partially executed;
- a repeated
seqwith identical content is re-acknowledged without re-executing; - two simulator instances on two links do not interfere, which is the two-board case.
Open Questions From The First Implementation¶
Added 2026-09-02. Raised by implementing both ends of this draft in
mouse-joystick-firm/pycontrol/ — the codec, a host simulator, a pyControl
device class and a 22-case conformance corpus, 128 host tests passing. Nothing
above this line has been changed. This section records what the draft does not
answer, so the command table can be revised deliberately rather than by whoever
implements it next.
Full detail, and the eleven smaller gaps that were filled to make the thing run,
in mouse-joystick-firm/pycontrol/README.md.
1. The retransmission rule and the heartbeat — RESOLVED 2026-09-03¶
The problem¶
"A repeated seq with identical content is treated as a retransmission and
re-acknowledged without re-executing" assumes re-execution is the harm. That is
true of MOVE, which would move the joystick twice. It was false of PING,
whose side effect was the command — under the original draft it had no result
other than resetting the host-loss watchdog, so serving it from cache let the
safety timeout expire while the host believed it was being answered.
The failure was likelier than the draft argued. The draft reached for a
65536 seq wrap, which a bounded cache makes unreachable. The ordinary case is
worse: the host sends PING, the board executes and resets, the reply is
lost, the host retries, and the retry is served from cache without resetting.
Repeat — which is exactly what a degraded link produces — and no retry in the
burst resets the watchdog. The watchdog then stops measuring host liveness and
starts measuring "did the first ping of a retry burst get through", diverging
most when the link is worst. The board faults while the host is receiving acks
and believes all is well, and it presents as a hardware fault.
The decision¶
This was a layering error, not a caching policy problem. The watchdog
measures host liveness, and liveness is proven by a frame arriving, not by a
frame executing. Putting the reset inside PING's handler placed a link-layer
fact behind an application-layer decision, after which any rule about whether
handlers run became a safety rule by accident.
Two changes, in order of importance:
-
The watchdog is reset on receipt of any well-formed command, before dispatch, cache hit or miss. Recorded as a design rule above.
PINGis no longer special, and the retransmission rule needs no exceptions. ASTATUSpoll now proves liveness, which it did not before.badframedoes not reset it. -
The cache is restricted to state-changing commands. Not because
PINGforced it, but because re-execution of a read is better than replay of one: a retriedSTATUSreturning fresh state beats a stale one; a retriedCLRFAULTre-evaluates whether the cause has gone, where a cached "cleared" could mask a fault latched since; a retriedSTOPis safer executed than acknowledged.
Deliberately not chosen: exempting PING by name. It leaves the category
error in place and invites a table of exceptions, one per verb, each of which is
a safety decision in disguise.
Classifying a command¶
Classify the parsed command, not the verb name. Three verbs are dual-mode and a verb-name table gets all three wrong:
| Read — re-executed, never cached | State-changing — cached |
|---|---|
ID, PING, STATUS, LIMITS, FAULTS |
HOME, ARM, DISARM, MOVETO, MOVE, STOP, ARMPULL, ANGOUT, ANGSCALE, CLRFAULT |
CAL get |
CAL set k v |
INSTR STATE |
INSTR ON / INSTR OFF |
HV STATE |
HV ON / HV OFF |
An unrecognised or unclassified command must default to cached. Over-caching costs a stale ack. Under-caching moves the joystick twice. A verb added carelessly should be merely suboptimal, not dangerous.
What the cache is actually for¶
MOVE is the only non-idempotent command in the set. Everything else names
a destination or a target state, so re-executing it is harmless:
| Command | Re-executed |
|---|---|
MOVETO deg |
absolute — lands in the same place |
HOME |
re-parks; costs a homing cycle, no hazard |
ARM / DISARM / STOP / CLRFAULT |
naturally idempotent |
ARMPULL thr, ANGSCALE lo hi, CAL set k v, ANGOUT/INSTR/HV ON/OFF |
set a value, not a delta |
MOVE deg |
relative — doubles the movement |
So the cache protects exactly one verb. This is written down rather than left
implicit for two reasons: an implementer should know the mechanism guards a
specific hazard rather than being general correctness machinery, and if the
trial only ever uses absolute positioning — park, present, release, pull,
present, which is the shape described in
docs/pycontrol-interface.md — then MOVE and the
cache can both be deleted. Revisit this when the researcher settles the
command set.
Consequences elsewhere¶
- Open question 2 below is partly defused. With the reset on any frame, a host
polling
STATUSduring a long move keeps the link alive, so a 100 ms timeout shorter than a move is no longer a guaranteed spurious fault. The real velocity and the real timeout still need measuring together. - The 22-case conformance corpus is unaffected: it pins
PINGframing, not retransmission or watchdog timing. So the contract the RP2040 half will be held to does not move, and this decision was made before that half was written rather than after.
2. The host timeout is shorter than a long move — QUANTIFIED 2026-09-03¶
The cited host timeout is 100 ms (docs/pycontrol-port-pinout.md). The draft
asked for the real velocity and the real timeout to be confirmed together
rather than separately. With the firmware protocol layer now wired to real
motion, here are both:
| Quantity | Value | Source |
|---|---|---|
| Host timeout | 100 ms | docs/pycontrol-port-pinout.md |
| Commanded velocity | 0.100 rev/s = 36 deg/s | bench jog velocity, board E4654C159F4D1334, 2026-09-02 |
| Usable travel after homing | ~10 deg | ~28 deg measured, less a jog step of margin at each end |
| Full-travel move | ~280 ms | 10 deg at 36 deg/s |
| Measured homing | ~2530 ms | 5 of 5 runs, 2026-09-02 |
So a full-travel move spans about three host timeouts, and a home spans about twenty-five. The host must send something during motion; this is not a corner case, it is every move and every home.
It is no longer a design contradiction, because of the resolution to open
question 1. The watchdog is reset by the arrival of any well-formed command,
so a task polling STATUS during a move keeps the link alive and gets useful
data for doing so. Under the original draft only PING counted, which made a
long move a guaranteed spurious fault.
What is still a decision, not a measurement: 36 deg/s is the bring-up jog velocity, chosen to be slow enough to watch. Presenting the joystick to an animal may want to be faster, and a faster profile shortens every number above. That belongs with the researcher's answer about whether move speed should be adjustable — see the profile question in the command table — and not with this finding.
3. The command set does not match the trial¶
The draft models a pull as a detection problem: ARMPULL threshold, watch,
report. The rig needs it as a mechanical resistance problem — the brake sets
the force, the motor gets out of the way, the animal moves the joystick, and
detection is just watching the encoder. ARMPULL survives as "tell me when the
encoder passes X while compliant", but it stops being what defines a pull.
The trial shape is: park (HOME) → present (MOVETO) → set brake and release
drive → animal pulls → present again.
3a. Brake-then-release cannot be two host commands¶
The brake must engage before motor drive is released, or the joystick drops as it goes compliant; and drive must be restored before the brake releases, or the motor fights it. Split across two commands, the host can die between them, or the second can arrive late over a link whose entire premise is that it has latency.
Proposed: one verb, BRAKE <torque>, with the ordering enforced in firmware
where nothing can interrupt it. This follows the existing rule that the board
never requires a message in order to become safe — and extends it: the board
should not require two messages in order to become correct.
FREEWHEEL exists in the rev-0 implementation only because there is no brake to
attach it to. It is a bring-up primitive and should be replaced by BRAKE,
not kept alongside it.
3b. Leaving compliance should be implicit¶
A MOVETO, MOVE, HOME or STOP arriving while compliant re-engages the
driver as part of the same operation. An explicit "stop freewheeling" verb would
make it two commands with a window in between, for no gain.
4. STATUS's "position" is now two quantities¶
The draft lists "state, angle, position, homed flag, limit state, fault mask" without saying whether angle and position are one quantity in two units or two quantities. With a compliant state they are genuinely two — measured and commanded — and after a pull they disagree by the whole pull.
Proposed: report both, named unambiguously. The implementation uses angle
(encoder; the joystick's real position) and xactual (ramp generator).
5. Resyncing position from the encoder deletes the stall detector¶
The board's only detector for an end stop, a stall, a slipping coupling or a dead
encoder is XACTUAL advancing while X_ENC does not. Reloading XACTUAL from
the encoder after a pull destroys exactly that signal.
The two cases are distinguishable by state, and by nothing else:
| State | Divergence means | Response |
|---|---|---|
| driving | something is broken | fault |
| compliant | the animal pulled | resync, on exit only |
Proposed: resync only on leaving a compliant state. Never during commanded motion, never on a timer.
And this needs a fault code the draft does not have. Bit 4 is retired and
permanently reserved, and it was a different comparison anyway — the driver's
encoder against an RP2040 PIO count that never existed. This is XACTUAL against
X_ENC, both inside the driver. Bits 9 onward are reserved in this document "if
this design is ever extended with a brake and a torque sensor", so allocating one
here is a decision for this document rather than for firmware. Stall detection is
not implemented pending it.
6. Which parameters should the host be able to set¶
Open, pending what the researcher needs. The draft already answers it in
principle and that answer looks right: MOVETO deg [profile] takes a named
profile, and raw driver current, raw PWM, step counts and unbounded velocity
are on the "deliberately absent, and to stay absent" list. Named profiles let the
answer change without a firmware change and without ever exposing raw motor
power.
What Rev 0 Hardware Can Test¶
The boards in hand have no electronic brake, no torque sensor and no populated analog outputs. Implemented and testable now:
| Testable | Absent |
|---|---|
ID PING STATUS LIMITS FAULTS CLRFAULT HV INSTR CAL |
BRAKE, torque reporting |
HOME ARM DISARM MOVETO MOVE STOP |
ANGOUT ANGSCALE, and the second analog channel for torque |
FREEWHEEL, ARMPULL by moving the joystick by hand |
Unimplemented verbs answer badverb rather than acking, so a host cannot believe
the analog output is live and record a trace of nothing.
Protocol v1 Proposal — 2026-10-01¶
Status: signed off 2026-10-01 for implementation, with four changes from
the first draft (chunked FETCH, ev;S routing, TRIAL once per WATCH
including outcome=NONE, stream_hz ≤ 50 and the a_peak definition) and
the draft's eleven decisions accepted; a fifth change from the review of the
implementation the same day: the capture is a ring around the outcome
(CFG pre_ms/post_ms, FETCH below), not a linear buffer from WATCH. Transcribed from the firmware work
brief of 2026-10-01 (mouse-joystick-firm/.agents/handoff-rig-system-plan-2026-10-01.md,
section 3) and the rig system plan of record
(mouse-joystick-rig-system-plan.md, section 4, in the shared planning repository named in docs/development.md).
Nothing above this line changes until this section is signed off; the corpus
(mouse-joystick-firm/pycontrol/tests/fixtures/protocol_cases.txt)
changes only after that, and both implementations after the corpus.
Points the brief left open are marked DECISION with the value this draft
proposes; the reserved decisions of the brief's section 8 are listed at the
end rather than decided here.
Unchanged: framing, seq, ack/nak, the watchdog rule (any well-formed
frame resets it, before dispatch) and the retransmission rule (state-changing
commands cached, reads re-executed, two entries).
V1.1 What the protocol is for¶
Two boards sit under a pyControl task that owns the experiment. pyControl sends parameters and bounded primitives down; the board sends events and one 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:
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
The heartbeat is STATUS, not PING, at 10–25 Hz from the device class.
V1.2 Protocol version and capabilities¶
- DECISION:
IDreportsproto=2.FREEWHEELandARMPULLare removed (not aliased), so aproto=1host would break; the device class refuses a major version it does not know, which is what the Versioning section asks for. The alternative, keepingproto=1and the two old verbs as aliases, carries the bring-up primitives into the experiment vocabulary for no benefit. capsgains tokens as implemented, and the device class gates on them, never onfw:brake,torqueandangoutare hardware facts of the board revision; DECISION:watch,fetch,streamandcfgare firmware facts and are present in everyproto=2firmware, so a host may rely on them onceproto=2is confirmed.freewheelleavescaps.fwis the firmware build'sgit describestring andhwthe board revision from the build (rev0,rev1, …); neither is a placeholder any more (fixed 2026-10-01).
V1.3 New and changed verbs¶
| Verb | Args | Semantics |
|---|---|---|
BRAKE |
torque_Ncm |
Atomic in firmware: (1) set the brake DAC to the calibrated code, (2) wait for coil current to reach the setpoint or a fixed settle time (B5Z specification about 6 ms; to be confirmed from BRAKE_ISENSE at v1.0 bring-up), (3) release the driver, (4) ev;RELEASED;t;angle=…;torque=…;brake=…. State → RELEASED. Refused unless ARMED (nak;notarmed). On boards whose caps lacks brake: accepted, torque ignored, steps (3)–(4) only. This is today's FREEWHEEL and replaces it; FREEWHEEL answers badverb. |
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; pull is positive displacement in the pull direction, push positive in the push direction. Emits exactly one outcome event: PULL once the angle has been at or past pull continuously for hold ms (default 0); PUSH once the angle is at or past push; NOMOVE if no first motion (angle change above the deadband, V1.6) before timeout; ABORT if first motion occurred but no outcome within maxdur (default disabled; DECISION: measured from the WATCH command, like timeout). TOUCH (torque at or above touch, default disabled) is informational, at most once, and may precede the outcome. The classifier evaluates on every sample (the firmware's 1 kHz tick; the simulator's every service step). After the outcome the classifier is disarmed; a new WATCH re-arms. Replaces ARMPULL, which answers badverb. |
MOVETO |
deg[;profile] |
Unchanged, plus named profiles present, retract, slow (constants in the firmware's AppConfig.hpp first; nak;badargs for an unknown name; the simulator accepts the names and moves every profile at its one v_max, so corpus timings do not depend on them). Leaving RELEASED: re-engage the driver, resync XACTUAL from X_ENC (exit only, finding 5), emit ev;TRIAL, then release the brake. |
MOVE |
deg[;profile] |
Unchanged for now. Candidate for deletion together with the retransmission cache (finding 1); reserved decision. |
CFG |
key=value[;key=value…] / get |
Bounded runtime configuration. v1 keys: wdt_ms (host-loss timeout, 50–500, default 100; a change applies from the next watchdog refresh), deadband_deg (first-motion threshold for NOMOVE, 0.05–10, default 0.5), stream_hz (default STREAM rate, 1–50, default 10), pre_ms and post_ms (the capture window around the WATCH outcome, see FETCH: each 0–2047 and pre_ms + post_ms ≤ 2047 so that the window fits the 2048-sample ring; defaults 500 and 500; a change applies to the next window that freezes). A value outside its bounds → nak;outofrange and nothing in the line is applied; an unknown key → nak;badargs. CFG get → ack;s;wdt_ms=…;deadband_deg=…;stream_hz=…;pre_ms=…;post_ms=…. |
FETCH |
[k0;n] |
Returns the last WATCH capture in host-paced chunks: ack;s;n=N;k0=K;t0=T;dt_us=1000;pre=P;lost=L, then N lines cap;k;angle;torque for k from K, then cap;end. n is capped at 32 lines per request (FETCH alone means k0=0;n=32); the host asks again with the next k0 until a reply carries fewer lines than it asked for. Reason: a 150-line burst at 115200 lasts about 330 ms, IRQ_RXIDLE may not fire until it ends, and a 200-byte receive buffer overflows; the device class also raises its rxbuf to 2048. T is the board time of sample K; sample k is at T + (k−K)·dt_us. The capture is a ring (review of 2026-10-01; it replaced a linear buffer that started at WATCH and stopped after two seconds, which missed any outcome later than that in a trial whose timeout may be 10 s): the angle (and torque) is sampled on the 1 ms grid from RELEASED on into a ring of 2048 samples that keeps running however long the trial takes. When the outcome is decided, the window freezes post_ms samples later and holds the last pre_ms samples before the outcome sample, the outcome sample itself and the post_ms samples after it: pre + 1 + post lines, and k = pre is the outcome sample. If the host leaves RELEASED first the window freezes at the exit with what exists: fewer post samples, or, with no outcome yet (TRIAL outcome=NONE), only the samples before the exit and no outcome sample, so the line count equals pre. pre reports the samples the ring actually holds, which is fewer than pre_ms when RELEASED (or a WATCH re-armed after a frozen window, which restarts the ring) is younger than pre_ms; history from before the WATCH counts. lost (2026-10-02) is the number of samples in the whole window that the firmware did not take on time: when its 1 kHz tick is coalesced behind a longer step (a protocol line, the rail's soft start), the missed grid points are filled with the angle at the next tick so sample k stays at T + k·dt_us; lost says how many of the window's samples are such fills (the host-side simulator always reports 0). nak;busy while a WATCH is armed or the post window is still filling. A new BRAKE restarts the ring and discards the previous window, so the host fetches between trials; a RELEASED period in which no WATCH was armed leaves no window; no window → n=0;k0=0;t0=0;pre=0 and cap;end. DECISION: the window bounds are CFG pre_ms/post_ms, each 0–2047 with pre_ms + post_ms ≤ 2047, defaults 500/500; the 2048-sample bound stays. DECISION: on boards whose caps lacks torque the torque field is empty (cap;k;angle;), so the line has the same field count everywhere. |
STREAM |
on[;rate_hz] / off |
Optional decimated telemetry at 1 to 50 Hz at 115200 baud (raise the bound only with the baud), for display only, off by default and off again after a fault or DISARM; rate_hz outside the bound → nak;outofrange, omitted → CFG stream_hz. Sample lines are ev;S;t;angle=…;torque=…, an event in framing, so the device class has no fourth message kind to decode; the device class routes ev;S lines to its AnalogChannels and never publishes them as framework events. |
TQ |
zero / cal;on / cal;off / get |
Torque tare (store the offset), shunt-calibration switch (TQ_CAL_EN), raw read. v1.0 boards only; badverb when caps lacks torque. |
ANGOUT, ANGSCALE |
as in the command table | Must work in the protocol firmware, not only in the bench console. GAIN = 0x0101 written before the first data write; the output is 0 V, or the agreed fault code, while unhomed, faulted or disabled (reserved decision: code 0 or code 497). |
INSTR |
unchanged | Found 2026-10-01: the firmware enables the InstrumentPort buffers at boot for its proof of life, so INSTR STATE answers instr=1 on a fresh board; the simulator answered 0. The boot default is on. The verb now really drives the buffers (fixed 2026-10-01). On the QP build the QS trace leaves on FW_TRIG1 through the same buffers, so INSTR OFF also silences the trace. On a v1.0 board the Digital-50 port has no enable at all; there the verb is accepted and STATE always reports 1. |
DECISION: the retransmission classification (finding 1) extends as: cached
(state-changing) BRAKE, WATCH, CFG k=v, TQ zero, TQ cal, STREAM;
re-executed (reads) FETCH, CFG get, TQ get.
DECISION: a WATCH while one is already armed replaces it (one ack, no
event for the superseded one). A BRAKE while already RELEASED is refused
(nak;notarmed) as the brief states; whether the brake level should be
adjustable mid-trial is a question for the researcher.
V1.4 Replies and events¶
STATUS→ack;s;state=…;angle=…;xactual=…;torque=…;brake=…;homed=…;limit=…;faults=…;hv=…;t=….tis the board clock in microseconds, 64-bit, decimal. The device class pairs it with pyControl's millisecond clock on every heartbeat; nothing else is needed for alignment. DECISION:torqueandbrakeare always present and empty on boards without the capability, so field positions never move.- All event timestamps are the same 64-bit clock (fixed 2026-10-01; the
previous 32-bit
twrapped at 71.6 minutes). - New events:
RELEASED;t;angle=…;torque=…;brake=…,TOUCH;t;angle=…;torque=…,ABORT;t;angle=…;torque=…, and the per-trial summary:
text
ev;TRIAL;t;outcome=PULL;t_rel=…;t_touch=…;t_move=…;t_out=…;a0=…;a_peak=…;a_end=…;tq_peak=…;tq_out=…;dur_thr=…
outcome is one of PULL, PUSH, NOMOVE, ABORT; t_* are board
microseconds, t_touch/t_move empty if they did not occur; a0 the angle
at release; a_peak, tq_peak the signed extrema in the window
(pull-positive); a_end, tq_out the values at the outcome; dur_thr the
whole milliseconds spent at or past the pull threshold between WATCH and
the outcome. a_peak is the signed excursion of largest magnitude from
a0, pull-positive; a_end is likewise the excursion at the outcome (or at
the exit for outcome=NONE), not an absolute angle. TRIAL is emitted
exactly once per WATCH: when the board leaves RELEASED (MOVETO,
STOP, DISARM, fault), or 1 s after the outcome if the host has not left
RELEASED by then, whichever comes first; when the host leaves RELEASED
before any outcome it carries outcome=NONE with t_out empty. A
RELEASED period with no WATCH produces no TRIAL. On the wire the
TRIAL event precedes the ack of the command that left RELEASED, as
every event produced inside a handler does.
- PULL, PUSH, NOMOVE and ABORT carry t;angle=…;torque=… like
RELEASED, where angle is the absolute angle at the event.
- BRAKE answers ack first and ev;RELEASED when the release has
happened (the same service step on a board without a brake; after the
settle on a board with one).
- Line length: a full STATUS is about 125 bytes. MAX_LINE rises from 120
to 200, ID reports rxbuf=200, and the device class's receive buffer
becomes 2048 so a 32-line FETCH chunk cannot overflow it.
- caps tokens appear in a fixed order with absent ones omitted:
motion+home+instr+hv+watch+fetch+stream+cfg+angout+brake+torque.
V1.5 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)
The state string FREEWHEEL becomes RELEASED everywhere it is reported.
The drawing is docs/diagrams/protocol-states.dot, rendered with Graphviz;
edit the .dot and re-render.
V1.6 Configuration defaults and the deadband¶
deadband_deg defaults to 0.5° until the bench measures the
encoder's noise and the mechanism's play on a released joystick; wdt_ms
defaults to 100 (today's value); stream_hz defaults to 10 for STREAM on
without a rate. The mechanism's sign (which encoder direction is "pull") is a
board-and-harness fact to be recorded once in bringup-log.md per board; the
firmware exposes pull-positive numbers and the device class hides nothing.
V1.7 Fault bits (bits 9 and up are reserved for exactly this)¶
| Bit | Name | Cause |
|---|---|---|
| 9 | brake_fault |
~{BRAKE_FAULT} asserted |
| 10 | brake_current |
BRAKE_ISENSE outside tolerance of the setpoint for more than N ms (N from bring-up) |
| 11 | torque_adc |
ADS8689 not responding or conversion timeout |
| 12 | torque_range |
bridge saturated or excitation out of range |
| 13 | not_following |
XACTUAL against X_ENC divergence while driving only (finding 5); never in RELEASED |
Bit 13 is what finding 5 asked for: stall detection becomes implementable.
V1.8 Safe state on host loss or fault¶
Today: abort motion, HV off, motor coasts. With a brake fitted the proposal
is: brake to a configurable holding level (CFG hold_Ncm), driver off,
FAULTED latched. The behaviour is selectable by configuration and the
default stays today's until the researcher decides (reserved decision).
V1.9 Reserved decisions (brief section 8; ask, do not assume)¶
- Delete
MOVEand the retransmission cache, or keep them. - The
TRIALfield list and names. FETCHas ASCII lines (this draft) or a binary block.- The fault level on
ANGLE_OUT: code 0 or code 497 (25 mV);GAIN0x0101 or 0x0000. - Whether
WATCHneeds outcomes beyond the five above before the researcher answers the trial-vocabulary question. - The default safe state on host loss once the brake exists (V1.8).
- A lever held when the host leaves
RELEASED(2026-10-07). The park move against a held lever faultsnot_followingand drops the rail (the stall detector, correctly; seen on unit A with a hand on the lever through theTRIALsummary). Whether the board should refuse or defer the move while the torque reading says the lever is held, or the task should recover withCLRFAULT→HV ON→HOME, is the researcher's call.
V1.10 What changes where, once signed off¶
Spec (this section merged into the tables above) → corpus (new cases for every
verb, every nak reason, the RELEASED→outcome sequences with an ANGLE
and TORQUE stimulus line added to the corpus grammar, the 64-bit t) →
board_sim.py → the firmware → the device class in the same change set →
hardware last, with numbers in bringup-log.md.
V1.12 The watchdog runs with the rail — 2026-10-06¶
Adopted into the tables above the same day. Gate E's first run on a v1.0
board had the pyControl task die one step after HV ON (a NameError in
the task), before its first heartbeat; the watchdog was not armed until
ARM, so the rail stayed up, idle, until the next run two minutes later.
Now HV ON arms the watchdog, HV OFF (or any fault) disarms it, ARM
refreshes it and DISARM leaves it running while the rail is on. The
corpus case hv-on-arms-the-watchdog pins it, and two older cases that
idled with the rail on for longer than the timeout were rewritten to turn
the rail off first or to keep the heartbeat (event-time-is-64-bit,
home-timeout-above-the-maximum-is-clipped-not-refused). Consequence for
hosts: send the first heartbeat right after HV ON; the device class's
heartbeat timer already runs from the start of a task.
One firmware detail the rule exposed: HV ON blocks in the rail's soft start
(185 ms on pcb v1.0, longer than the 100 ms timeout) while the board's
protocol clock stands at the line's arrival, so a deadline armed after the
call read as already expired on the first advance (the first run after the
change faulted host_timeout at the ack), and a home that blocked
~130 ms had the heartbeats that arrived meanwhile read only after the
deadline check (the second run). The firmware now moves its protocol clock
to the wall clock after every dispatched line and credits the time it spent
blocked to the deadlines then running (the host watchdog, the home bound):
the host could not be heard during it. So the timeout counts from the moment
a blocking command returns — which is also when its ack leaves. The
simulator has no such delays and the corpus timings are unchanged.
V1.11 Implementation notes from the first brake and torque firmware — 2026-10-06¶
Written by the implementation (mouse-joystick-firm, both the RP2040 side and
pycontrol/sim/board_sim.py, held to the corpus by five new cases under a
CAPS brake torque directive) where the tables above left a choice open. Each
is a decision this document should either adopt into the tables or overrule;
none changes a line already specified.
BRAKEis not a step, and the state during the engage isARMED. The v1.0 bench (bring-up log, A4, 2026-10-06) found a code step to full scale overshoots the coil current to 127 % for ~5 ms, so the firmware ramps the setpoint (8 ms to full scale) and waits for the current to settle before phase (3), the release.BRAKEanswersackat once; between the ack andev;RELEASEDthe state readsARMEDwithbrake=<level>,MOVETO,MOVE,HOMEand a secondBRAKEanswernak;busy, andSTOPorDISARMcancel the engage (brake back to 0, noRELEASEDever emitted). The simulator settles in a fixed 10 000 µs; the firmware's backend decides fromBRAKE_ISENSE.RELEASEDis dated at the release, not at the ack.- The
brakefield is a number whenevercapshasbrake: the commanded level while the brake engages or holds inRELEASED,0.0otherwise. (V1.4 says "empty on boards without the capability"; this reads the complement literally so a host never has to guess whether an empty field is a missing brake or a brake at zero.)torquelikewise: a number whenevercapshastorque. TQ get→ack;s;torque=<Ncm>;raw=<Ncm>;cal=0|1: the tared reading, the untared one on the nominal scale about the nominal zero (what a tare removed), and the shunt-calibration switch's state.TQ zeroandTQ cal;on|offanswerack; anything elsenak;badargs; the verb isbadverbwithout the capability. On v1.0 as built the cal switch is inert (bring-up log, A5), soTQ calis a verb and not a calibration there.TOUCHfires on the magnitude of the torque (|torque| ≥ touch), at most once perWATCH, before the outcome when both fall on one sample. Which sign a pull produces is a harness fact for the calibration;tq_peakinTRIALis the signed value of largest magnitude,tq_outthe torque at the outcome (or at the exit foroutcome=NONE),t_touchtheTOUCHinstant.- The capture's third field is the torque sample taken on the same tick
as the angle (
cap;k;angle;torque), as V1.3 says. - Leaving
RELEASEDre-engages the drive before the brake comes off, in that order, as 3a requires:MOVETO/STOP/DISARMfromRELEASEDandHOMEfromRELEASEDall enable the driver first. A fault releases the brake with everything else (V1.8's default stands). - Fault bits 9–12 exist in the firmware with the names in V1.7:
brake_fault(~{BRAKE_FAULT}low for 5 ms while engaged, or no brake to drive whenBRAKEwas accepted),brake_current(BRAKE_ISENSEmore than 15 % of full scale from the setpoint for 50 ms once settled),torque_adc(the ADS8689 did not read back its range at rail-up),torque_range(the ADC within 64 codes of a rail). The 50 ms and 15 % are bring-up values (V1.7's "N from bring-up"), chosen so the sweep's ±0.5 mA agreement never trips them and an open coil does. Bit 9's indication is suspect on v1.0: the pin stayed high through a 62 mA overshoot (bring-up log). - Corpus grammar:
CAPS <tokens>right afterCASEboots the board with those hardware facts, and theTORQUE <Ncm>stimulus is live.
V1.13 TARGET means the encoder arrived — 2026-10-07¶
Decided from the measurement of 2026-10-06 (bringup-log.md, "the park
error measured", pcb v1.0 unit A): with the ramp generator at its target the
encoder landed 0.35–0.77° short on every direction reversal and within 0.07°
on a same-direction move, independent of distance, not accumulating — the
rotor's torque angle under the mechanism's friction at the bring-up current
plus lost motion in the drive train, not the encoder and not a step loss.
Every trial parked 0.99° short, and both tasks homed before every trial to
hide it (0.9 s a trial).
- DECISION:
TARGETpromises the encoder, not the ramp generator. The board emits it when the encoder stands within one count (12.8 microsteps, 0.09°) of the commanded target, or when it has spent its correction budget;angleis the encoder's reading either way, so a host that cares comparesanglewith what it asked for. - How the firmware does it. The move is commanded relative to the
encoder (
XTARGET = XACTUAL + (target − X_ENC)), so a frame difference left by the previous move does not enter the next one and a small move cannot run the wrong way. When the ramp generator reportsposition_reachedthe firmware waits forX_ENCto stand still (within one count for 10 ms, bounded at 100 ms), then, if the residual exceeds one count, commands a correction move of the residual and waits again; at most two corrections.XACTUALis never rewritten for this (finding 5: a resync deletes the stall detector); the follow check keeps judging commanded against measured travel through the corrections. Constants in the firmware'sConversions.hpp(kMoveSettle*). - What a host can see.
TARGETcomes later than the ramp generator's arrival by the settle wait and any correction — measured 2026-10-07 on unit A (bringup-log.md, "the encoder settle after a move"): 10 ms on a move that needs no correction, 150–260 ms more on a direction reversal; everyTARGETwithin 0.18° of its target where the reversal used to leave 0.99°, one to two counts remaining that a correction at IRUN 8 cannot move against static friction; the state isACTIVEthroughout. After a settled moveSTATUS'sxactualstands ahead ofangleby the lost motion the correction absorbed; that is the ramp generator's frame against the encoder's and it is expected, not a divergence.HOMEDis unchanged: homing writes both frames.STOPduring a correction holds where the encoder is and resyncs, as during any move. - Simulators. The corpus gained a
SLIP <deg>stimulus (the next move lands that far short on the encoder, in its direction of travel) and three cases (target-waits-for-the-encoder-after-lost-motion,a-residual-within-one-count-is-not-corrected,stop-during-a-correction-resyncs-the-frames). Both simulators judge the residual at the instant of arrival, with no settle wait, so every existingTARGETtime is unchanged. The QP build reports each settle in aMOVE_SETTLEtrace record (residual at first arrival and atTARGET, corrections, duration). - What this does not fix. Lost motion between the motor shaft the encoder
reads and the joystick lever (the gear pair, the coupling) is invisible to
the board; the torque sensor and the camera are the only witnesses to it.
The rig task's home-before-every-trial workaround (
v.home_each_trial) went the same day: homing repeats to 2–5 counts, the settled park lands within 0–1. Until the thermal survey allowsIRUNto rise, the torque-angle share stays and is what the correction mostly absorbs; whether the tolerance should be two counts rather than one at IRUN 8 is an open question in the firmware plan.