Documentation
Where to start
- Serial protocol — every command and event, and the safety model behind them.
- Firmware API — verify a board is running official, competition-certified firmware.
- Hardware — schematic, pin map and build notes.
- V1.3 dual-MCU board — the ESP32-S3 + RP2350 prototype: two-step flashing and the inter-MCU protocols.
- License — Apache-2.0 for software, CERN-OHL-P-2.0 for hardware; free to build, modify and sell.
How integration works
There is no daemon, no service and no SDK requirement. The board is a USB serial device that exchanges JSON lines at 115200 baud. Your application opens the port and writes commands.
The only rule that really matters:
start_period. The board owns the countdown, so the match completes even if your application crashes or the cable is unplugged. If you instead enable manually with set, you must send a command at least every 600 ms or the board disables itself.Python
import json, serial
port = serial.Serial('COM3', 115200, timeout=0.1)
port.dtr = port.rts = True # assert both, or neither — see the protocol page
def send(**kw):
port.write((json.dumps(kw) + '\n').encode())
def read():
line = port.readline().decode(errors='replace').strip()
return json.loads(line) if line.startswith('{') else None
send(cmd='get_info') # identity + fw_hash
send(cmd='start_period', mode='auto', duration_ms=15000)
while True:
evt = read()
if not evt:
continue
if evt.get('evt') == 'timer_expired':
print('autonomous complete')
break
if evt.get('evt') == 'error':
raise SystemExit(f"rejected: {evt['reason']}")Node.js
import { SerialPort } from 'serialport';
import { ReadlineParser } from '@serialport/parser-readline';
const port = new SerialPort({ path: '/dev/ttyUSB0', baudRate: 115200 });
const lines = port.pipe(new ReadlineParser({ delimiter: '\n' }));
const send = (obj) => port.write(JSON.stringify(obj) + '\n');
lines.on('data', (line) => {
let evt;
try { evt = JSON.parse(line); } catch { return; } // ignore boot chatter
if (evt.evt === 'hello') console.log('board', evt.device_id, evt.fw_version);
if (evt.evt === 'timer_expired') console.log('period over');
if (evt.evt === 'estop') console.log('disabled:', evt.source);
});
send({ cmd: 'get_info' });
send({ cmd: 'start_period', mode: 'driver', duration_ms: 105000 });Browser (Web Serial)
Chrome, Edge and Opera can talk to the board directly. This is how this site works — no installation, no native code.
const port = await navigator.serial.requestPort();
await port.open({ baudRate: 115200 }); // do not set flowControl
const encoder = new TextEncoderStream();
encoder.readable.pipeTo(port.writable);
const writer = encoder.writable.getWriter();
const send = (obj) => writer.write(JSON.stringify(obj) + '\n');
const decoder = new TextDecoderStream();
port.readable.pipeTo(decoder.writable);
const reader = decoder.readable.getReader();
await send({ cmd: 'start_period', mode: 'auto', duration_ms: 15000 });Chrome remembers the permission, so navigator.serial.getPorts() can reopen a previously approved board without prompting — which is what makes automatic reconnection mid-match possible.
Manual control with a keepalive
If you need free-running enable rather than a fixed period, you own the keepalive. Ping well inside the 600 ms window.
import threading
send(cmd='set', enable=True, mode='driver')
stop = threading.Event()
def keepalive():
while not stop.wait(0.25): # 250 ms, comfortably under the 600 ms limit
send(cmd='ping')
threading.Thread(target=keepalive, daemon=True).start()
# ... on shutdown, always disable explicitly rather than relying on the watchdog
stop.set()
send(cmd='estop')A complete match that survives a reconnect
The examples above start one period. A real match runs autonomous, a pause, then driver — and your software may crash, be restarted or lose the cable in the middle. Send a period token (match_id + period_seq) and the board's device_id with every start_period, and recovery is simply: reopen the port and send the same command again. The board sorts out what that means.
| Board replies | Meaning | Your software does |
|---|---|---|
ack | Period started — or was already running under this token. | Wait for timer_expired with this period_seq. |
error period_complete | This period finished while you were away. | Move on to the next period. |
error timer_active | A different period is running (another host, or a stale token). | Stop and surface it to the operator. |
error wrong_device | This is not the board you started the match on. | Stop — you have the wrong port. |
import json, time, uuid, serial
PORT = 'COM3'
MATCH_ID = uuid.uuid4().hex[:16] # any string up to 32 chars; persist it if
# your process may restart mid-match
PERIODS = [(1, 'auto', 15000), (2, 'driver', 105000)] # (period_seq, mode, ms)
GAP_S = 3
def open_port():
while True:
try:
p = serial.Serial(PORT, 115200, timeout=0.1)
p.dtr = p.rts = True
return p
except serial.SerialException:
time.sleep(0.5) # unplugged or re-enumerating; keep trying
port = open_port()
def send(**kw):
port.write((json.dumps(kw) + '\n').encode())
def events():
"""Yield parsed JSON lines; reopen the port if it drops."""
global port
while True:
try:
line = port.readline().decode(errors='replace').strip()
except serial.SerialException:
port.close(); port = open_port()
yield {'evt': '_reconnected'}
continue
if line.startswith('{'):
try: yield json.loads(line)
except ValueError: pass
def identify():
send(cmd='get_info')
for evt in events():
if evt.get('evt') == 'hello':
return evt['device_id']
device_id = identify()
def run_period(seq, mode, duration_ms):
"""Returns when this period is over, however many reconnects it takes."""
def start():
send(cmd='start_period', mode=mode, duration_ms=duration_ms,
match_id=MATCH_ID, period_seq=seq, device_id=device_id)
start()
for evt in events():
kind = evt.get('evt')
if kind == '_reconnected':
start() # replay: a no-op if still running
elif kind == 'timer_expired' and evt.get('period_seq') == seq:
return evt['reason'] # 'timer' or 'ended'
elif kind == 'error' and evt.get('cmd') == 'start_period':
if evt['reason'] == 'period_complete':
return 'complete' # finished while we were disconnected
raise SystemExit(f"{evt['reason']}: refusing to continue")
elif kind == 'estop' and evt.get('source') != 'timer':
raise SystemExit(f"e-stop ({evt['source']})")
try:
for i, (seq, mode, ms) in enumerate(PERIODS):
print(mode, run_period(seq, mode, ms))
if i < len(PERIODS) - 1:
time.sleep(GAP_S)
finally:
send(cmd='estop') # always leave the field disabledTwo details make this safe. The board refuses to run a retired token again, so a replay after the period ended can never re-enable the robots; and estop is never gated on tokens or binding, so the operator can always stop the field. If your process itself may be restarted mid-match, persist MATCH_ID and the current period_seq to disk, then on startup send get_timer — timer_state carries the running token, or last_match_id / last_period_seq if the period already finished.
Practical notes
- Always
estopon exit. The watchdog will catch a crash, but an explicit disable is immediate. - Ignore non-JSON lines. The ESP32 bootloader prints plain text at reset.
- Re-query after reconnecting.
get_info+get_state+get_timerrestores your view of a match already in progress. - Trust
remaining_ms, not your own clock. The board is authoritative; a host-side timer will drift. - Handle
errorevents. Most rejections aretimer_active— a period is already running. The full list of reasons is on the protocol page. - Expect the port to vanish. A replug re-enumerates the CH340; the path (COM number,
/dev/ttyUSB*) usually comes back the same but is not guaranteed. Retry the open in a loop, then replay your laststart_periodas above. - Check the firmware before a real event. Compare
fw_hashfromhellowith the published hashes and refuse to run if it is not a certified release.
Source
Firmware, web application, hardware design and this documentation are in one repository.