Table of Contents
The 2026 Australian GP is live. Russell and Leclerc are battling it out, and you want the data on your desk, not a browser tab, not your phone. Something physical that just sits there. You have an ESP32 CYD in the parts drawer: 320×240 touchscreen, built-in Wi-Fi, €10. OpenF1 is free and has real F1 timing data.
This sounds like a fun Sunday afternoon project. It wasn't.
The first problem hits immediately. OpenF1 doesn't return race state, it returns race history. Hit /position and you get thousands of chronological entries, one per position change, every driver, entire session. Hit /laps and you get the same for lap times. /stints for tyres. /intervals for gaps. Six endpoints, all flat arrays, none of them joined, none of them telling you what's happening right now.
The second problem is the ESP32 itself. 320KB of RAM. Fetching and aggregating six endpoints of session data on a microcontroller isn't a performance concern, it's a category error. Micro Python's urequests will run out of memory before it finishes parsing the response.
So the fun Sunday afternoon project becomes an exercise of a proper two-layer architecture: a Python aggregator on a local server that reconstructs race state from OpenF1's raw streams and exposes a single clean endpoint, and a Micro Python client that makes one HTTP request, gets back exactly what it needs, and renders it on a $10 screen sitting next to the keyboard.
Here's how it's built.
Full source for the API and CYD display:
One Endpoint, One Request
The first thing to get right is the boundary between server and device.
OpenF1 is the source: free, no API key, real timing data during sessions. Do note that access to live session data requires a paid API key, historical data access is free.
The catch is the shape of that data, the API is a log, not a state machine. It records everything that happened; figuring out what's happening now is your job. So we move that job off the device.
The fix is straightforward once you accept that the ESP32 is a display device, not a data processor.
You move the aggregation off the microcontroller entirely and onto anything with more headroom, like a Raspberry Pi, a spare machine, a Docker container, whatever is already running on your network. That server fetches all six OpenF1 endpoints, reconstructs current race state, and exposes a single endpoint: /api/race-status.
The ESP32 makes one HTTP request. It gets back one JSON response. That response contains exactly what the display needs, positions in order, driver codes, tyre compounds, tyre age in laps, gap to leader, fastest lap, and nothing else. No filtering, no aggregation, no memory pressure.
This is the core architectural decision of the whole build, and it applies well beyond OpenF1.

Any time you're hitting a complex external API from a microcontroller, like weather, sports scores, home automation, or any dashboard, the instinct is to do as much as possible on the device. Resist it.
Microcontrollers are good at talking to hardware; a Python server on your network is good at talking to APIs. Put each where it belongs and the problem gets simpler on both ends. The same aggregator can then serve other clients too: a second display, an e-ink board, a different form factor. One server, many endpoints, many devices.
The split also has a practical benefit: when OpenF1 changes an endpoint or returns unexpected data, you fix it in one file on the server. The display doesn't care where the data came from, only that it arrived in the right shape.
Part 1: The Aggregator
The server's job: turn six OpenF1 streams into one snapshot the display can consume without thinking. The aggregator lives in a race_status.py, in my case served as an API endpoint on a Raspberry Pi.
Positions
/position returns every position change for every driver, thousands of entries. You need the most recent entry per driver, not the last item in the array. Group by driver_number, take the entry with the highest date per group:
def _latest_position_per_driver(positions: list[dict]) -> dict[int, int]:
"""Map driver_number -> position using the most recent update per driver."""
by_driver: dict[int, list[dict]] = {}
for p in positions:
if p.get("date") is None:
continue
dn = p["driver_number"]
if dn not in by_driver:
by_driver[dn] = []
by_driver[dn].append(p)
out = {}
for dn, updates in by_driver.items():
if not updates:
continue
latest = max(updates, key=lambda x: x["date"])
out[dn] = latest["position"]
return out
That pattern, group by entity, take latest, is the same for positions, intervals, and stints. The result is a {driver_number: position} mapping you can sort and iterate.
Tyres
/stints gives compound and lap_start, not tyre age. Sort each driver's stints by lap_start descending, find the stint that covers the current lap (lap_start ≤ now and no lap_end or lap_end ≥ now), then tyre_age = current_lap - lap_start + 1.
Gaps
First choice: /intervals and its gap_to_leader, latest per driver as above. If the endpoint is empty (e.g. early in the session), fall back to lap-time delta: leader's last lap time minus driver's. When a driver is lapped, OpenF1 returns "+1 LAP" as a string, check the type and pass through as-is instead of formatting as seconds.
Formatting and total laps
Lap times and gaps are formatted on the server (e.g. ≥60s → M:SS.mmm, else +N.NNNs) so the display never does number formatting. OpenF1 doesn't expose scheduled race distance; the aggregator leaves total_laps as None and the display shows LAP 24 without a denominator.
The Response
All of that feeds into a single get_race_status() that returns this shape:
{
"session": {
"circuit_name": "Melbourne",
"country_iso3": "AUS"
},
"latest_lap_number": 58,
"total_laps": null,
"current_standings": [
{
"position": 1,
"driver_number": 63,
"name": "George RUSSELL",
"name_acronym": "RUS",
"team_colour": "00D7B6",
"tyre": "HARD",
"stint_duration_laps": 47,
"lap_time_seconds": 83.351,
"lap_time": "1:23.351",
"lap_time_or_gap": "1:23.351"
},
{
"position": 2,
"driver_number": 12,
"name": "Kimi ANTONELLI",
"name_acronym": "ANT",
"team_colour": "00D7B6",
"tyre": "HARD",
"stint_duration_laps": 47,
"lap_time_seconds": 82.653,
"lap_time": "1:22.653",
"lap_time_or_gap": "+2.974s"
},
{
"position": 3,
"driver_number": 16,
"name": "Charles LECLERC",
"name_acronym": "LEC",
"team_colour": "ED1131",
"tyre": "HARD",
"stint_duration_laps": 34,
"lap_time_seconds": 83.317,
"lap_time": "1:23.317",
"lap_time_or_gap": "+15.519s"
}
...
],
"fastest_lap": {
"driver_number": 3,
"lap_number": 42,
"duration_seconds": 82.091,
"duration_formatted": "1:22.091",
"driver_name": "Max VERSTAPPEN",
"driver_name_acronym": "VER",
"team_colour": "4781D7"
}
}
Flat. Ordered by position. Every field the display needs, nothing it doesn't. That's the contract, the ESP32 never sees a raw OpenF1 response.
Part 2: The Display
The part you actually look at. The CYD runs Micro Python with an ILI9341: fill_rectangle, fill_circle, draw_text8x8.
The font is fixed 8×8, no sizing, no wrapping. Every element is placed by hand with explicit coordinates. At 320×240, every pixel is intentional. That's the design challenge.
Layout
- Top bar (22px): circuit, lap, SC badge when active
- Bottom bar (20px): fastest lap in purple, lap number
- Rows (198px): 19px per row → 10 drivers on screen (top 10)
Column x-coordinates are constants; team colours from the API are hex, so convert once to RGB565 per driver and cache it, the ILI9341 needs 16-bit values.
# ── DISPLAY RENDERING ─────────────────────────────────────
# Uses ili9341: fill_rectangle, fill_circle, draw_text8x8 (8x8 built-in font)
ROW_H = 19
TOP_BAR = 22
BOT_BAR = 20
MAX_ROWS = 10 # (240 - 22 - 20) / 19 = 10 rows, order by position
# Column x positions (more spacing between position, driver #, strip, name, tyre, stint, lap time, gap)
COL_POS = 5
COL_DRIVER_NUM = 30
COL_STRIP = 50
COL_CODE = 60
COL_TYRE = 105
COL_STINT = 125
COL_LAP_TIME = 160
COL_GAP = 250
TOP_BAR_LAP_X = 265
Every element is placed by these constants, like position, driver number, team strip, code, tyre circle, stint laps, lap time and gap. Change one and you touch every draw_text8x8 and fill_rectangle that uses it.
Rendering
- Top bar: red strip, circuit left, lap right, yellow SC badge when the latest
/race_controlmessage says safety car. - Driver rows: alternating black/dark grey background; team colour on number and 3px strip; driver code; tyre as circle with compound initial (S/M/H/I); stint laps; lap time or gap (P1 gets their lap time, everyone else gets
lap_time_or_gapfrom the aggregator, including"+1 LAP"for lapped drivers). - Bottom bar: FL driver and time in purple, lap number in grey.
The 8×8 font caps line length (e.g. 9 chars for the gap column); the aggregator already sends formatted strings so the client never does number formatting.
Mapping the response: parse_pitwall
The renderer expects four dicts keyed by driver number, positions, drivers, laps, stints, because each draw function takes a driver key and looks up what it needs.
The API returns a single list of standings rows. parse_pitwall() is the bridge: one pass over current_standings, fan out each row into the four dicts, add session metadata and fastest-lap fields for the bars.
If the aggregator changes its response shape, you update this one function and the render calls stay unchanged.
def parse_pitwall(data):
"""Map pitwall JSON to (session, positions, drivers, laps, stints, fastest_driver, fastest_time, current_lap, total_laps, updated).
positions/drivers/laps/stints are dicts keyed by driver_number (str); order preserved via positions insertion order."""
if not data or "current_standings" not in data:
return None
sess = data.get("session") or {}
session = {
"circuit_short_name": sess.get("circuit_name", "???"),
"country_name": sess.get("country_iso3", "???"),
"total_laps": data.get("total_laps"),
}
current_lap = data.get("latest_lap_number") or 0
total_laps = data.get("total_laps")
standings = data.get("current_standings") or []
positions = {}
drivers = {}
laps = {}
stints = {}
for row in standings:
drv = str(row.get("driver_number", ""))
if not drv:
continue
positions[drv] = {
"position": row.get("position", 99),
"lap_time_or_gap": row.get("lap_time_or_gap"),
}
drivers[drv] = {
"name_acronym": row.get("name_acronym") or "???",
"team_name": "",
"team_colour": row.get("team_colour"),
}
laps[drv] = {"lap_duration": row.get("lap_time_seconds")} # seconds for format_laptime
tyre = row.get("tyre")
stints[drv] = {
"compound": tyre if tyre else "",
"stint_duration_laps": row.get("stint_duration_laps"),
}
fl = data.get("fastest_lap") or {}
fastest_driver = fl.get("driver_name_acronym") or ""
fastest_time = fl.get("duration_seconds")
fastest_lap_number = fl.get("lap_number")
updated = ""
return (session, positions, drivers, laps, stints, fastest_driver, fastest_time, fastest_lap_number, current_lap, total_laps, updated)
Part 3: The Main Loop
The main loop is the smallest part of the system, on purpose.
Setup: display and touch via the cyd helper, tap the screen and it sets a flag for an immediate refresh, no waiting for the next poll.
Wi-Fi next, show "Connecting..." on screen, then clear to black. If connection fails, there's nothing to show.
The loop is poll, parse, redraw only when needed:
# ── MAIN LOOP ─────────────────────────────────────────────
def main():
global touch_refresh
# Display + touch via cyd (touch handler sets touch_refresh for manual refresh)
display, backlight, touch = cyd.display_setup(320, 240, rotation=90, touch_handler=_on_touch)
cyd.set_backlight_brightness(backlight, 90)
cyd.display_loading(display, "Connecting...", RED)
if not cyd.connect_wifi(WIFI_SSID, WIFI_PASSWORD):
return
display.clear(BLACK)
time.sleep(1)
gc.collect()
last_lap = -1
while True:
data = get_pitwall()
parsed = parse_pitwall(data) if data else None
if parsed:
(session, positions, drivers, laps, stints,
fastest_driver, fastest_time, fastest_lap_number, current_lap, total_laps, updated) = parsed
if session.get("total_laps") is None and total_laps is not None:
session["total_laps"] = total_laps
if current_lap != last_lap or touch_refresh:
render_frame(display, session, positions, drivers, laps, stints, None,
current_lap=current_lap, fastest_driver=fastest_driver,
fastest_time=fastest_time, fastest_lap_number=fastest_lap_number)
last_lap = current_lap
touch_refresh = False
time.sleep(POLL_INTERVAL)
Triggers: new lap or touch. Same lap, failed request, or parse error → no redraw. Full-screen redraw over SPI is costly; do it only when something changed.
Memory. MicroPython doesn't run the garbage collector in the background like CPython.
urequests allocates a response buffer on every HTTP call; if you don't release it explicitly, that memory stays allocated. Over a two-hour race, dozens of poll cycles, the heap eventually runs out and the device crashes.
The discipline in get_pitwall(): collect before the request to maximise free heap; parse the response; close the response and delete the reference so the buffer can be reclaimed; collect again to actually free it.
def get_pitwall():
"""GET pitwall endpoint, return parsed JSON or None."""
print("Pitwall URL:", PITWALL_URL)
try:
gc.collect()
r = urequests.request("GET", PITWALL_URL, timeout=10)
data = ujson.loads(r.content)
r.close()
del r
gc.collect()
return data
except OSError as e:
print("Pitwall OSError:", e, type(e).__name__)
return None
except Exception as e:
print("Pitwall error:", e, type(e).__name__)
return None
Without that sequence, the device doesn't make it to the chequered flag.
Result
After the 2026 Australian GP, the display on the desk looks like this:

Ten drivers, tyre compounds, gaps, fastest lap in purple at the bottom. Norris's row is highlighted in gold because that's MY_DRIVER in the config. On a live session, a new lap crosses the line and within a few seconds the screen updates; tap the panel and it refreshes on demand.
What works well
- Lap-change trigger: Redrawing only on a new lap means no flicker mid-corner; the 30-second poll is short enough that you never miss an update. Touch-to-refresh covers the "just powered on mid-race" case.
- Team colours: OpenF1's colours are accurate, tyre circles and driver strips are readable at a glance; you can identify a row by colour before reading the name.
- Wrapper API in practice: The aggregator handled all OpenF1 complexity invisibly; the ESP32 never dropped a request or ran out of memory across a full two-hour session.
What doesn't
total_laps: OpenF1 doesn't expose scheduled race distance. The top bar showsLAP 24with no denominator. Workaround: hardcoded laps-per-circuit lookup bycircuit_short_name, inelegant but functional.- Gap accuracy early on: When
/intervalsis slow to populate (first few laps), the fallback to lap-time delta is rough, similar lap times can show near-zero gaps that don't reflect track position. It should correct itself after a couple of laps. - 8×8 font: Readable but unforgiving. Nine characters per column max; names are always three-letter codes. Fine for F1 (NOR, RUS, LEC); worth knowing if you adapt the layout for another sport.
What's Next
The display works. The important part: extending it doesn't mean rewiring the whole system, the aggregator-display boundary is the enabler. New behaviour is either a new endpoint on the server, a new view on the client, or both.
Three directions that fit that pattern:
Red flag and VSC detection — /race_control carries safety car, VSC, red flag, track clear. The code currently only checks for safety car. Add string checks for VSC (e.g. different badge) and red flag (full red overlay with RED FLAG in white; more useful than stale lap times when the race is paused).
Swipe between views — Touch currently triggers a refresh. Better: swipe left/right for standings vs championship points vs session schedule. Add /api/championship from OpenF1's /drivers and /team_results; the display is a second render function behind a view index the touch handler increments.
E-ink version — The CYD is right for live race (colour, refresh, lap-by-lap). Between weekends it stays off. An e-ink panel on another ESP32 on a small battery: always-on championship points, next race, countdown; power only when it updates (e.g. once a week). Same aggregator, different endpoint, simpler response shape.
Conclusion
It wasn't a fun Sunday afternoon, the build was harder than that. Not because the hardware was difficult, but because the data was.
OpenF1 is genuinely useful and leaves all the reconstruction work to the client. The aggregator-display split solved it: one Python server turns six raw endpoints into one clean API; the client does one request and renders.
That boundary is what makes expansion straightforward, new endpoints (championship, schedule), new clients (e-ink, second screen), or new views (swipe between standings and points) without tearing the system apart.
The CYD earned its place: cheap, capable, physically there next to the keyboard, updating every lap. Source on GitHub (link above).
The 2026 season is 24 races; plenty of time to add the extensions.
Follow me on Twitter: https://twitter.com/DevAsService
Follow me on Instagram: https://www.instagram.com/devasservice/
Follow me on TikTok: https://www.tiktok.com/@devasservice
Follow me on YouTube: https://www.youtube.com/@DevAsService
