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
A few gotchas that cost me time:
-
Date filters must be ISO 8601, not Unix timestamps:
date>2024-05-18T13:00:00✅,date>1716033600❌ -
rainfallis an integer (0 = dry, higher = wetter), not a boolean -
gap_to_leaderfrom/intervalsis a float for normal gaps but a string like"+1 LAP"for lapped cars — alwaysparseFloat()and guard before calling.toFixed() -
compoundfrom/stintsis one ofSOFT,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"]
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
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
Übersicht-specific constraints
Writing JSX for Übersicht has a few rules that differ from a normal React project:
-
No
${VAR}in thecommandstring — the backtick template literal is evaluated by the shell, and${}crashes the widget. Use$(subshell)for shell substitutions. -
No
child_process— use the built-inrun()helper instead -
No
//comments inside the bash heredoc — they cause parse errors refreshFrequencyis 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_urlfield - Push notifications for race start / chequered flag via macOS Notification Center
The full source is on GitHub — pull requests welcome.
Top comments (0)