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 GuideOverview
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:
- Fetch it immediately (
state) — get the last result right now - Wait for the next one (
wait_race) — block until a new race completes, then return it
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.
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:
| Value | Meaning |
|---|---|
"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.
GET /info
info
{
"protocol": "1.0",
"firmware": "1.2.0",
"lane_count": 6
}
state
The last completed race. Returns immediately.
GET /state
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.
GET /wait/race?after=<race_id>&lanes=<lanes>
wait_race after=<race_id> lanes=<lanes>
Both parameters are optional:
after— the lastrace_idthe client has seen. If the controller has a newer result, it returns immediately. If the IDs match (orafteris 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.
GET /gate
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.
GET /wait/gate
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.
GET /dbg
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).
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.
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.
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:
- Call
wait_racewith thelanesfor the current heat and theafterID from the previous race. - The Track Operator loads cars and releases the start gate. The controller detects the gate opening and starts timing.
- Cars cross the finish line. When all expected lanes have finished (or a timeout expires), the controller completes the race.
wait_racereturns with the new race ID and lane times. The Operator Console records the result and shows it on the audience display.- Call
wait_gateto wait for the Track Operator to reset the start gate. - 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.
times_ms, and the Operator can declare a re-run or enter a manual ranking.