Serial protocol
3, firmware 1.4.0.Transport
| Setting | Value |
|---|---|
| Baud rate | 115200 |
| Framing | 8 data bits, no parity, 1 stop bit (8N1) |
| Flow control | None — do not enable hardware flow control |
| Encoding | UTF-8, one JSON object per line, \n terminated |
| USB bridge | CH340C |
| USB VID:PID | 1A86: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.
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:
{"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 arrives | Board does |
|---|---|
| No period running, unused token | Starts the period. |
| Period running, same token | Nothing. Acks and re-sends state. The countdown and the outputs are untouched — a replay is not a restart. |
| Period running, different token | Rejects with timer_active. A running match is never silently preempted. |
| Token of a finished period, or an earlier one of the same match | Rejects with period_complete. |
| No token at all | Protocol-2 behaviour, unchanged. |
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)
| Command | Fields | Effect |
|---|---|---|
ping | — | Keepalive. Refreshes the comms watchdog; replies with ack. |
get_info | — | Re-emit hello (identity and firmware hash). |
get_state | — | Emit the current state event. |
get_timer | — | Emit timer_state. |
set | enable (bool), mode ("driver" | "auto") | Manual enable/disable. Governed by the comms watchdog — you must keep pinging. Rejected during a timed period. |
start_period | mode, duration_ms (1–600000), optional match_id, period_seq, device_id | Atomically 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. |
resync | match_id, period_seq, remaining_ms, optional device_id | Correct the running period’s deadline. Never touches the outputs. Acks with the applied delta_ms. |
end_period | match_id, period_seq, optional device_id | End the running period early — an operator abort, reported distinguishably from a fault. |
estop | — | Immediately disable and cancel any running period. Always accepted — never gated on a token or device_id. |
identify | — | Visual identification for 3 s. Does not affect outputs. |
set_pin | pin (int), value (bool) | Diagnostics only: drive one GPIO directly, bypassing the state machine. Rejected during a timed period. |
get_pins | — | Emit 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)
| Event | Fields | When |
|---|---|---|
hello | proto, fw_version, hw_version, device_id, fw_hash | At boot, and in reply to get_info. |
state | enable, 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 outputs | After any state change, and in reply to get_state. |
heartbeat | uptime_ms | Every 1000 ms. Absence indicates a dead link. |
timer_state | active; 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_expired | mode, duration_ms, elapsed_ms, reason: timer | ended; plus the token if the period had one | A timed period finished — either by reaching its duration or via end_period. |
estop | source: software | watchdog | timer | Outputs were disabled. Always followed by a state event. Not emitted for end_period, which is not a fault. |
pins | pins[] | In reply to get_pins. |
ack | cmd; delta_ms on a resync | Command accepted. |
error | reason, cmd | Command rejected. |
Board states
| State | Meaning |
|---|---|
BOOT | Powering up. Outputs safe. |
DISABLED | Robots disabled. The resting state. |
ENABLED_DRIVER | Robots enabled in driver control. |
ENABLED_AUTO | Robots enabled in autonomous. |
ESTOP_WATCHDOG | Disabled because the host stopped talking. Send set or start_period to leave. |
Error reasons
| reason | Cause |
|---|---|
timer_active | set, 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_device | The device_id is not this board's, or the period is bound and the command carried none. |
token_mismatch | resync or end_period did not name the period that is running — or nothing is running at all. |
period_complete | start_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_token | match_id and period_seq were not both present, or match_id was empty or over 32 characters. |
resync_out_of_range | resync tried to extend the period by more than 2000 ms. |
invalid_mode | mode was not "driver" or "auto". |
invalid_duration | duration_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_pin | The pin is missing, not an integer, or not one of the eight output GPIOs. |
unknown_command | Unrecognised 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 watchdog | Active — 600 ms | Suspended for the whole period |
| Host must keep pinging | Yes | No — the host may disconnect entirely |
| Ends when | Host stops talking, or estop | duration_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
>>> {"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.
| Port | ENABLE/DISABLE (ED) | DRIVER/AUT (AD) |
|---|---|---|
| 1 | IO7 | IO15 |
| 2 | IO16 | IO17 |
| 3 | IO18 | IO8 |
| 4 | IO19 | IO20 |
| Signal | HIGH | LOW |
|---|---|---|
| ENABLE/DISABLE | Disabled (safe) | Enabled |
| DRIVER/AUT | Autonomous | Driver |