Serial protocol

The board speaks newline-delimited JSON over USB serial. Anything that can open a serial port can drive it — no SDK required. Protocol revision 3, firmware 1.4.0.

Transport

SettingValue
Baud rate115200
Framing8 data bits, no parity, 1 stop bit (8N1)
Flow controlNone — do not enable hardware flow control
EncodingUTF-8, one JSON object per line, \n terminated
USB bridgeCH340C
USB VID:PID1A86:7523

Every line the board sends is a JSON object with an evt field. Every line you send is a JSON object with a cmd field. Lines that are not valid JSON are ignored, so bootloader chatter at reset will not desynchronise a reader.

Opening the port with RTS asserted but DTR de-asserted pulls the ESP32-S3 into reset via the CH340C auto-reset circuit. Assert both or neither. Chrome's Web Serial default is safe; most native serial libraries need this set explicitly.

Connection lifecycle

The board emits hello once at boot. A host that attaches to an already-running board has missed it, so the first thing to send is get_info, which re-emits it on demand. The standard opening sequence is three queries that fully restore host-side state:

resync on connect
{"cmd":"get_info"}    -> hello       (identity + firmware hash)
{"cmd":"get_state"}   -> state       (enabled? mode? timed period?)
{"cmd":"get_timer"}   -> timer_state (remaining_ms of any running period)

This is what makes reattaching mid-match safe: the board keeps running its countdown across a disconnect, and these three replies tell a reconnecting host exactly where the match stands.

Period tokens

Losing the host mid-match is normal, not exceptional — the page reloads, the operator reopens it, the cable is replugged. The problem that creates is that a reconnecting host cannot tell the board "this is the period you are already running" without the board hearing "start a new one". Protocol 3 fixes that with a token: match_id (opaque, up to 32 characters) plus period_seq, sent on start_period and carried by resync and end_period.

start_period arrivesBoard does
No period running, unused tokenStarts the period.
Period running, same tokenNothing. Acks and re-sends state. The countdown and the outputs are untouched — a replay is not a restart.
Period running, different tokenRejects with timer_active. A running match is never silently preempted.
Token of a finished period, or an earlier one of the same matchRejects with period_complete.
No token at allProtocol-2 behaviour, unchanged.
The board remembers completed tokens and will not run one again, including after an estop. Without that, a host that died just before expiry could reconnect, see no active period, replay its start_period and re-enable the robots after the match was over. Retirement is per match and covers every period_seq at or below the highest one completed, so finishing the driver period also closes the auto period before it. A new period always needs a new period_seq, and sequence numbers must increase within a match.

Binding to a board. Every OpenFC enumerates under the same USB name, so on a multi-field event an operator can select the wrong port — and a token cannot catch it, because the token is perfectly valid, just meant for another field. Send the board's device_id from hello alongside the token and a mismatch is rejected with wrong_device before anything moves. Binding is sticky: a period started with a device_id can only be resynced or ended by commands that also carry one. estop is never gated on any of this.

Correcting drift. resync adjusts the deadline of the running period and nothing else — it never enables, disables, changes mode or re-fires a start. Shortening is unrestricted; extending is capped at 2000 ms, because the board's clock is the more trustworthy of the two. millis() runs off the 40 MHz crystal and drifts on the order of 12 ms across a two-minute period, whereas a backgrounded browser tab has its timers throttled. A host asking for a large extension has a wrong clock, not a longer match.

Commands (host → board)

CommandFieldsEffect
pingKeepalive. Refreshes the comms watchdog; replies with ack.
get_infoRe-emit hello (identity and firmware hash).
get_stateEmit the current state event.
get_timerEmit timer_state.
setenable (bool), mode ("driver" | "auto")Manual enable/disable. Governed by the comms watchdog — you must keep pinging. Rejected during a timed period.
start_periodmode, duration_ms (1–600000), optional match_id, period_seq, device_idAtomically enable and start an on-board countdown. Survives host disconnect. Rejected if a different period is already running; replaying the token of the running one is a no-op.
resyncmatch_id, period_seq, remaining_ms, optional device_idCorrect the running period’s deadline. Never touches the outputs. Acks with the applied delta_ms.
end_periodmatch_id, period_seq, optional device_idEnd the running period early — an operator abort, reported distinguishably from a fault.
estopImmediately disable and cancel any running period. Always accepted — never gated on a token or device_id.
identifyVisual identification for 3 s. Does not affect outputs.
set_pinpin (int), value (bool)Diagnostics only: drive one GPIO directly, bypassing the state machine. Rejected during a timed period.
get_pinsEmit raw output pin levels.
set_pin bypasses the state machine and can enable robots while the reported state still reads DISABLED. It exists for hardware bring-up. Use set or start_period in any application.

Events (board → host)

EventFieldsWhen
helloproto, fw_version, hw_version, device_id, fw_hashAt boot, and in reply to get_info.
stateenable, mode, state, uptime_ms, and when a period is running timed, duration_ms, remaining_ms, plus match_id, period_seq, bound if it is tokenised; diag when set_pin owns the outputsAfter any state change, and in reply to get_state.
heartbeatuptime_msEvery 1000 ms. Absence indicates a dead link.
timer_stateactive; when active mode, duration_ms, remaining_ms, elapsed_ms and any match_id, period_seq, bound; when idle, last_match_id and last_period_seq — the match that most recently finished a period, and the highest period_seq retired within it (every seq at or below it is refused)In reply to get_timer.
timer_expiredmode, duration_ms, elapsed_ms, reason: timer | ended; plus the token if the period had oneA timed period finished — either by reaching its duration or via end_period.
estopsource: software | watchdog | timerOutputs were disabled. Always followed by a state event. Not emitted for end_period, which is not a fault.
pinspins[]In reply to get_pins.
ackcmd; delta_ms on a resyncCommand accepted.
errorreason, cmdCommand rejected.

Board states

StateMeaning
BOOTPowering up. Outputs safe.
DISABLEDRobots disabled. The resting state.
ENABLED_DRIVERRobots enabled in driver control.
ENABLED_AUTORobots enabled in autonomous.
ESTOP_WATCHDOGDisabled because the host stopped talking. Send set or start_period to leave.

Error reasons

reasonCause
timer_activeset, start_period or set_pin arrived while a timed period owns the outputs. Send estop first. A start_period replaying the running period's own token is exempt — that is a no-op, not a rejection.
wrong_deviceThe device_id is not this board's, or the period is bound and the command carried none.
token_mismatchresync or end_period did not name the period that is running — or nothing is running at all.
period_completestart_period reused the token of a period that already finished, or one at or below the match's highest completed period_seq. Use a new, higher period_seq.
invalid_tokenmatch_id and period_seq were not both present, or match_id was empty or over 32 characters.
resync_out_of_rangeresync tried to extend the period by more than 2000 ms.
invalid_modemode was not "driver" or "auto".
invalid_durationduration_ms was missing, not an integer, or outside 1–600000; or resync sent a remaining_ms that was missing or not a non-negative integer.
invalid_pinThe pin is missing, not an integer, or not one of the eight output GPIOs.
unknown_commandUnrecognised cmd value.

Safety model

There are two independent mechanisms, and which one applies depends on how the robots were enabled.

Manual (set / set_pin)Timed (start_period)
Comms watchdogActive — 600 msSuspended for the whole period
Host must keep pingingYesNo — the host may disconnect entirely
Ends whenHost stops talking, or estopduration_ms elapses, or end_period, or estop

A timed period is deliberately immune to the comms watchdog: a match must finish even if the laptop sleeps or the cable is pulled. The countdown is owned by the board, not the host. Backing that up, an on-chip task watchdog is armed to duration_ms + 10 s, so a firmware hang reboots the chip into the disabled state rather than leaving robots live.

Outside a timed period the opposite is true: anything that enables the robots requires the host to keep sending commands at least every 600 ms, or the board disables itself and reports {"evt":"estop","source":"watchdog"}. Send ping every 250 ms; any command resets the timer.

The board boots into DISABLED with the enable lines held at their safe level, so a power cycle or reset can never enable robots.

A complete match

host → board, board → host
>>> {"cmd":"get_info"}
<<< {"evt":"hello","proto":3,"fw_version":"1.4.0","hw_version":"1.1",
     "device_id":"openfc-494274","fw_hash":"0217fc0d..."}

>>> {"cmd":"start_period","mode":"auto","duration_ms":15000,
     "match_id":"qm-42","period_seq":1,"device_id":"openfc-494274"}
<<< {"evt":"state","enable":true,"mode":"auto","state":"ENABLED_AUTO",
     "timed":true,"duration_ms":15000,"remaining_ms":15000,
     "match_id":"qm-42","period_seq":1,"bound":true,"uptime_ms":41230}
<<< {"evt":"ack","cmd":"start_period"}

    ... the host page reloads mid-period. The board keeps counting. ...

>>> {"cmd":"get_timer"}
<<< {"evt":"timer_state","active":true,"mode":"auto","duration_ms":15000,
     "remaining_ms":6120,"elapsed_ms":8880,"match_id":"qm-42",
     "period_seq":1,"bound":true}

    Same token, so this re-establishes context instead of restarting:
>>> {"cmd":"start_period","mode":"auto","duration_ms":15000,
     "match_id":"qm-42","period_seq":1,"device_id":"openfc-494274"}
<<< {"evt":"ack","cmd":"start_period"}

    The server's clock says 6.4 s, the board says 6.1 s:
>>> {"cmd":"resync","match_id":"qm-42","period_seq":1,
     "remaining_ms":6400,"device_id":"openfc-494274"}
<<< {"evt":"ack","cmd":"resync","delta_ms":300}

<<< {"evt":"timer_expired","mode":"auto","duration_ms":15300,
     "elapsed_ms":15300,"reason":"timer","match_id":"qm-42","period_seq":1}
<<< {"evt":"estop","source":"timer","uptime_ms":56230}
<<< {"evt":"state","enable":false,"mode":"driver","state":"DISABLED",...}

    A new period needs a new period_seq; replaying seq 1 is refused:
>>> {"cmd":"start_period","mode":"driver","duration_ms":105000,
     "match_id":"qm-42","period_seq":2,"device_id":"openfc-494274"}
    ... driver control runs, then a referee stops the match early ...

>>> {"cmd":"end_period","match_id":"qm-42","period_seq":2,
     "device_id":"openfc-494274"}
<<< {"evt":"ack","cmd":"end_period"}
<<< {"evt":"timer_expired","mode":"driver","duration_ms":105000,
     "elapsed_ms":31400,"reason":"ended","match_id":"qm-42","period_seq":2}

Output pin map

All four ports are driven in unison through optocouplers. Each V5 Competition Switch port carries two signals. Polarity differs between them, which is why the safe level is not the same for both.

PortENABLE/DISABLE (ED)DRIVER/AUT (AD)
1IO7IO15
2IO16IO17
3IO18IO8
4IO19IO20
SignalHIGHLOW
ENABLE/DISABLEDisabled (safe)Enabled
DRIVER/AUTAutonomousDriver