DEV Community

AlexDesign420
AlexDesign420

Posted on

Building a real-time F1 dashboard on macOS with free public APIs

I wanted to see live Formula 1 data on my desktop while watching the race — not buried in an app or a browser tab, but always visible in the corner of my screen. So I built a widget that pulls from two free public APIs and renders everything directly on the macOS desktop.

Here's what went into it and what I learned along the way.

What it looks like

During a live session the widget shows:

  • Live standings — position, driver code, gap to leader, last lap time, tyre compound and age, pit stop count
  • Race Control banner — Safety Car, Virtual Safety Car, Red Flag with colour-coded flashing overlay
  • Side panel — Team radio recordings (playable), all RC messages, track weather
  • Audio streams — ARD, BBC Radio 5, talkSPORT, ORF Sport Plus and more, played via mpv

Outside of race weekends it shows the championship standings, the full calendar and a countdown to the next event.

Tech stack

Layer Tool
Widget host Übersicht — renders JSX widgets directly on the macOS desktop
Frontend JSX (React-like syntax, no build step)
Backend Python 3 + Flask on 127.0.0.1:9877
Live F1 data OpenF1 API — completely free, no key needed
Calendar & standings Jolpica API — the Ergast successor
Audio mpv via Unix socket
TTS macOS say -v Anna

The two APIs

OpenF1

OpenF1 is the standout discovery here. It exposes granular, real-time F1 telemetry for free with no authentication. The endpoints I use most:

GET /v1/position?session_key=9158&date>2024-05-18T13:00:00
GET /v1/intervals?session_key=9158&date>2024-05-18T13:00:00
GET /v1/laps?session_key=9158
GET /v1/stints?session_key=9158
GET /v1/pit?session_key=9158
GET /v1/race_control?session_key=9158
GET /v1/team_radio?session_key=9158
GET /v1/weather?session_key=9158
Enter fullscreen mode Exit fullscreen mode

A few gotchas that cost me time:

  • Date filters must be ISO 8601, not Unix timestamps: date>2024-05-18T13:00:00 ✅, date>1716033600
  • rainfall is an integer (0 = dry, higher = wetter), not a boolean
  • gap_to_leader from /intervals is a float for normal gaps but a string like "+1 LAP" for lapped cars — always parseFloat() and guard before calling .toFixed()
  • compound from /stints is one of SOFT, MEDIUM, HARD, INTERMEDIATE, WET (uppercase strings)

Jolpica

Jolpica is a drop-in replacement for the now-deprecated Ergast API. The JSON structure is identical:

url = f"https://api.jolpi.ca/ergast/f1/{SEASON}/driverStandings.json"
data = requests.get(url).json()

lists = data["MRData"]["StandingsTable"]["StandingsLists"]
for ds in lists[0]["DriverStandings"]:
    team = ds["Constructors"][0]["name"]   # plural array, not Constructor.name
    driver_code = ds["Driver"]["code"]
    points = ds["points"]
Enter fullscreen mode Exit fullscreen mode

The one trap: it's Constructors[0] (plural, array) — not Constructor.name as you might expect from the docs.

Architecture: why a local Flask server?

Übersicht widgets run a shell command on a timer. Shell commands can curl an API directly, but you lose:

  • Response caching (hitting OpenF1 20 times per minute per endpoint is unnecessary)
  • Background stream health checks
  • mpv process management via Unix socket
  • State persistence between widget refresh cycles

The Flask server runs as a daemon and handles all of that. The widget shell command just calls engine.py (event detection, TTS triggers) and curls the local server for the rest.

f1.jsx  (refreshes every 3 seconds)
  └── shell: python3 ~/.f1/engine.py
  └── fetch: http://127.0.0.1:9877/api/session   ← live standings + RC
  └── fetch: http://127.0.0.1:9877/api/radio      ← team radio
  └── fetch: http://127.0.0.1:9877/api/schedule   ← calendar
Enter fullscreen mode Exit fullscreen mode

The HLS stream headache

Several ARD/ZDF/MDR backup renditions use -b/ path segments that return 404 in ffmpeg (which mpv uses under the hood). I wrote a resolver that parses the HLS master playlist and picks the first clean audio rendition:

def resolve_hls_audio(url):
    if ".m3u8" not in url.lower():
        return url
    resp = requests.get(url, timeout=6)
    text = resp.text

    # Prefer explicit audio renditions
    audio = re.findall(
        r'#EXT-X-MEDIA:TYPE=AUDIO[^\n]*?URI="([^"]+)"', text
    )
    for cand in audio:
        full = urljoin(url, cand)
        if "-b/" not in full and "-b." not in full:
            return full

    # Fall back to lowest-bandwidth video variant
    variants = []
    for i, line in enumerate(text.splitlines()):
        if line.startswith("#EXT-X-STREAM-INF"):
            bw = int(re.search(r'BANDWIDTH=(\d+)', line).group(1))
            variants.append((bw, urljoin(url, text.splitlines()[i+1])))
    if variants:
        return sorted(variants)[0][1]
    return url
Enter fullscreen mode Exit fullscreen mode

Übersicht-specific constraints

Writing JSX for Übersicht has a few rules that differ from a normal React project:

  1. No ${VAR} in the command string — the backtick template literal is evaluated by the shell, and ${} crashes the widget. Use $(subshell) for shell substitutions.
  2. No child_process — use the built-in run() helper instead
  3. No // comments inside the bash heredoc — they cause parse errors
  4. refreshFrequency is in milliseconds

What I'd add next

  • Sprint weekend detection (Sprint Shootout sessions have a different session name)
  • Driver headshot images from OpenF1's headshot_url field
  • Push notifications for race start / chequered flag via macOS Notification Center

The full source is on GitHub — pull requests welcome.

Links

Top comments (0)