Track Controller Protocol

How the browser communicates with the Raspberry Pi Pico W track controller. The same protocol works over USB serial and WiFi — identical commands, identical responses.

Setting up the hardware → Getting Started Guide

Overview

The track controller is a simple device. It does one thing: detect when cars cross the finish line and report their times. The browser (Operator Console) asks for results, and the controller responds with JSON.

The core concept is last completed race. The controller always holds the result of the most recently completed race. Clients can:

Each completed race gets a unique ID (UUID) generated by the controller. This lets the browser tell whether it's already seen a result or if it's new.

All responses are JSON objects, pretty-printed for readability. All times are in milliseconds since race start — the protocol doesn't use wall-clock timestamps.


Two Transports, Same Semantics

The controller supports two connection methods. Both accept the same commands and return the same responses.

USB Serial

The Pico W exposes a USB CDC serial port. Commands are plain text lines terminated by a newline. Baud rate is 115200. This is the most reliable connection — no network to worry about.

One important rule: while the controller is blocked in a wait command, any incoming line cancels the wait and is treated as the next command. This means you never get stuck — just send a new command to interrupt.

WiFi (HTTP)

When configured with WiFi credentials, the controller runs an HTTP server. All endpoints use GET requests. This is useful when the computer running the Operator Console can't be physically near the track.

The controller advertises itself on the local network using mDNS (Bonjour), so you can connect by hostname instead of IP address. By default, the hostname is derived from the device's MAC address (e.g., rallylab-a1b2c3.local). You can set a custom hostname with the hostname_set command — for example, hostname_set pack42 makes the controller reachable at pack42.local.

Which should I use? USB serial for reliability, WiFi for convenience. You can switch between them at any time — the Operator Console handles either transparently.

Data Types

Lane Times (times_ms)

Race results are reported as a JSON object mapping lane numbers to finish times in milliseconds. Lanes that weren't used in a race are simply absent — no nulls.

{
  "1": 2150,
  "2": 2320,
  "4": 3010,
  "5": 2875,
  "6": 2601
}

In this example, five cars raced on lanes 1, 2, 4, 5, and 6. Lane 3 was not in use (perhaps the car was removed from the section, or it's a Scout Trucks race using alternate lanes).

Lane Selection (lanes)

Some commands accept a lanes parameter — a compact string of lane numbers that tells the controller which lanes to expect. For example:

ValueMeaning
"123456"All six lanes
"135"Lanes 1, 3, 5 only (Scout Trucks)
"13456"All lanes except 2 (lane 2 disabled)

Errors

When something goes wrong, the controller returns a JSON object with an error field:

{
  "error": "human readable message"
}

Commands

info

Device capabilities. Called once when the Operator Console connects.

HTTP GET /info
Serial info
{
  "protocol": "1.0",
  "firmware": "1.2.0",
  "lane_count": 6
}

state

The last completed race. Returns immediately.

HTTP GET /state
Serial state
{
  "race_id": "550e8400-e29b-41d4-a716-446655440000",
  "times_ms": {
    "1": 2150,
    "2": 2320,
    "4": 3010,
    "5": 2875,
    "6": 2601
  }
}

Returns null if no race has completed since the controller booted.

wait_race

Block until the next race completes, then return the result. This is how the Operator Console receives finish times automatically.

HTTP GET /wait/race?after=<race_id>&lanes=<lanes>
Serial wait_race after=<race_id> lanes=<lanes>

Both parameters are optional:

  • after — the last race_id the client has seen. If the controller has a newer result, it returns immediately. If the IDs match (or after is omitted), the call blocks until the next race completes.
  • lanes — declares which lanes to expect for the next race. Only takes effect if the controller is currently idle. Omit to use all lanes.
{
  "race_id": "550e8400-e29b-41d4-a716-446655440002",
  "times_ms": {
    "3": 2401,
    "6": 2603
  }
}

gate

Check whether the start gate is in the ready (closed) position. Returns immediately.

HTTP GET /gate
Serial gate
{
  "gate_ready": true
}

wait_gate

Block until the start gate is reset (returned to the ready position). If the gate is already ready, returns immediately. The Operator Console uses this to auto-advance to the next heat after the Track Operator resets the gate.

HTTP GET /wait/gate
Serial wait_gate
{
  "gate_ready": true
}

dbg

Debug snapshot. Shows sensor states, WiFi status, and the engine state machine. Useful for diagnosing wiring or connection issues.

HTTP GET /dbg
Serial dbg
{
  "controller": {
    "protocol": "1.0",
    "firmware": "1.2.0",
    "uptime_ms": 123456
  },
  "io": {
    "start_gate": { "raw": 1, "debounced": 1, "invert": true, "pull": "up", "last_edge_ms": 0 },
    "lanes": {
      "1": { "raw": 0, "debounced": 0, "invert": false, "pull": "up", "last_edge_ms": 0 },
      "2": { "raw": 0, "debounced": 0, "invert": false, "pull": "up", "last_edge_ms": 0 }
    },
    "debounce_ms": 10
  },
  "engine": {
    "phase": "IDLE",
    "race_id": null,
    "active_lanes": null,
    "gate_ready": true
  },
  "wifi": {
    "mode": "sta",
    "connected": true,
    "hostname": "rallylab-a1b2c3.local",
    "ssid": "MyNetwork",
    "ip": "192.168.1.50",
    "rssi": -61
  }
}

The wifi section only appears when WiFi is available (Pico W with credentials configured).

dbg_watch

Live edge monitor. Streams a JSON line for every sensor edge as it happens. Use this to verify wiring — trip each lane sensor one at a time and confirm the correct label appears. Serial only (not available over HTTP).

Serial dbg_watch

Immediately prints { "watching": true, "gpio_range": "GP0-GP22" }, then one line per edge:

{ "gpio": 8, "pin": "gate", "edge": "opened", "ms": 4523 }
{ "gpio": 8, "pin": "gate", "edge": "closed", "ms": 4801 }
{ "gpio": 17, "pin": "lane", "lane": 3, "edge": "triggered", "ms": 5190 }

Each edge includes the gpio pin number for diagnosis. Configured pins (lanes and gate) include a pin label; unconfigured pins in the GP0–GP22 range report raw "fall" or "rise" edges.

Type any command to stop the watch. The cancel rule applies — the typed line stops the watch and is dispatched as the next command.

hostname_set

Set a custom mDNS hostname. The name is persisted and survives reboots. Serial only.

Serial hostname_set <name>

The name must be lowercase alphanumeric with optional hyphens, max 32 characters. After setting, the controller is reachable at <name>.local on the local network.

hostname_set pack42

{
  "hostname": "pack42.local"
}

The hostname can also be configured from the Operator Console's Track Manager dialog when connected via USB.

hostname_clear

Remove the custom hostname and revert to the default MAC-based name (e.g., rallylab-a1b2c3). Serial only.

Serial hostname_clear
{
  "hostname": "rallylab-a1b2c3.local"
}

How the Race Flow Works

Putting it all together, here's what happens during normal race-day operation. The Operator Console runs a simple loop:

  1. Call wait_race with the lanes for the current heat and the after ID from the previous race.
  2. The Track Operator loads cars and releases the start gate. The controller detects the gate opening and starts timing.
  3. Cars cross the finish line. When all expected lanes have finished (or a timeout expires), the controller completes the race.
  4. wait_race returns with the new race ID and lane times. The Operator Console records the result and shows it on the audience display.
  5. Call wait_gate to wait for the Track Operator to reset the start gate.
  6. When the gate is reset, stage the next heat and go back to step 1.

The Operator at the computer doesn't click anything during normal progression. The entire flow is driven by two physical actions: releasing the gate and resetting it.

Timeouts: If a lane sensor fails to trigger, the controller completes the race after a configurable timeout (default 15 seconds). The missing lane simply won't appear in times_ms, and the Operator can declare a re-run or enter a manual ranking.

Related