Tools: Building a real-time F1 dashboard on macOS with free public APIs - 2025 Update

Tools: Building a real-time F1 dashboard on macOS with free public APIs - 2025 Update

What it looks like

Tech stack

The two APIs

OpenF1

Jolpica

Architecture: why a local Flask server?

The HLS stream headache

Übersicht-specific constraints

What I'd add next 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. During a live session the widget shows: Outside of race weekends it shows the championship standings, the full calendar and a countdown to the next event. OpenF1 is the standout discovery here. It exposes granular, real-time F1 telemetry for free with no authentication. The endpoints I use most: A few gotchas that cost me time: Jolpica is a drop-in replacement for the now-deprecated Ergast API. The JSON structure is identical: The one trap: it's Constructors[0] (plural, array) — not Constructor.name as you might expect from the docs. Übersicht widgets run a shell command on a timer. Shell commands can curl an API directly, but you lose: 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. 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: Writing JSX for Übersicht has a few rules that differ from a normal React project: The full source is on GitHub — pull requests welcome. Templates let you quickly answer FAQs or store snippets for re-use. Are you sure you want to ? It will become hidden in your post, but will still be visible via the comment's permalink. as well , this person and/or

Code Block

Copy

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 CODE_BLOCK: 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 CODE_BLOCK: 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 COMMAND_BLOCK: 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"] COMMAND_BLOCK: 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"] COMMAND_BLOCK: 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"] CODE_BLOCK: 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 CODE_BLOCK: 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 CODE_BLOCK: 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 COMMAND_BLOCK: 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 COMMAND_BLOCK: 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 COMMAND_BLOCK: 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 - 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 - 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) - 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 - No ${VAR} in the command string — the backtick template literal is evaluated by the shell, and ${} crashes the widget. Use $(subshell) for shell substitutions. - No child_process — use the built-in run() helper instead - No // comments inside the bash heredoc — they cause parse errors - refreshFrequency is in milliseconds - 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 - GitHub repo - OpenF1 API docs - Jolpica API