Documentation

OpenFC is open hardware, open firmware and an open protocol. Nothing here is proprietary — you can build the board, build the firmware, and drive it from your own software.

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:

To run a match, use 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

pip install pyserial
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

npm install serialport
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.

requires a user gesture and a secure context
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.

python
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 repliesMeaningYour software does
ackPeriod started — or was already running under this token.Wait for timer_expired with this period_seq.
error period_completeThis period finished while you were away.Move on to the next period.
error timer_activeA different period is running (another host, or a stale token).Stop and surface it to the operator.
error wrong_deviceThis is not the board you started the match on.Stop — you have the wrong port.
python — full match, safe to restart at any point
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 disabled

Two 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 estop on 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_timer restores 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 error events. Most rejections are timer_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 last start_period as above.
  • Check the firmware before a real event. Compare fw_hash from hello with 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.

github.com/Elevation-Robotics/OpenFC