Display your Oura Ring health data on a TRMNL e-ink display.
The full layout shows four score tiles of equal weight — Readiness, Sleep, Activity, and Heart Rate — with a slim 24-hour BPM trend strip underneath.
Three additional layouts are included for TRMNL mashups:
half_horizontal.html(800×240) — four score tiles in one row (Readiness, Sleep, Activity, Heart Rate)half_vertical.html(400×480) — 2×2 grid of Readiness, Sleep, Activity, Heart Ratequadrant.html(400×240) — four compact score-only tiles in one row
- Go to cloud.ouraring.com/personal-access-tokens
- Create a new personal access token
- Copy the token
You need a Private Plugin with a Webhook strategy so this repo can POST merge variables to it. Follow these steps exactly:
- Sign in at usetrmnl.com and open the Plugins page.
- Click Add New → Private Plugin (sometimes labeled "Custom Plugin").
- Fill in the plugin details:
- Name:
Oura Stats(or whatever you like) - Strategy: select Webhook (so the plugin receives pushed data rather than polling a URL)
- Refresh rate:
15 minutes(or match your desired cadence — this repo's GitHub Action runs every 15 min) - Dark mode: optional; the templates are designed to work in both
- Name:
- Click Save. TRMNL generates a Webhook URL of the form:
Copy only the
https://usetrmnl.com/api/custom_plugins/{UUID}{UUID}portion (everything after/custom_plugins/) — that's what you'll store asTRMNL_PLUGIN_UUID. - Open the plugin's Edit Markup screen. You'll see four layout tabs: Full, Half Horizontal, Half Vertical, Quadrant.
- For each tab, paste the contents of the matching file from
markup/:markup/full.html→ Full tabmarkup/half_horizontal.html→ Half Horizontal tabmarkup/half_vertical.html→ Half Vertical tabmarkup/quadrant.html→ Quadrant tab
- Click Save on each tab. You can click Preview to see a render — it'll show placeholder values until the first real push arrives.
- (Optional) Upload the plugin icon. In the plugin settings, there's an Icon or Image upload field. Export
assets/icon.svgto a PNG (any small square size like 96×96 or 240×240 works) and upload it — you can do this with Inkscape, an online SVG→PNG converter, or by opening the SVG in a browser and screenshotting. - Back on the plugin detail page, Install the plugin to your TRMNL device and assign it to a playlist slot so it actually appears on the screen.
Note: Whenever you change anything in
markup/*.htmlin this repo, you must re-paste the updated markup into the corresponding TRMNL tab. TRMNL does not pull markup from GitHub automatically — the repo is source-of-truth for your own reference, but the plugin editor holds the live copy.
Payload limit: TRMNL rejects pushes where
merge_variablesJSON exceeds roughly 2 KB with an HTTP 422.main.pyalready contains a size guard that progressively shrinks the heart rate chart if the payload grows too large; don't add large free-text merge variables without accounting for the ceiling.
This runs the updater for you in the cloud every 15 minutes — no server, no laptop-left-on, no cron. If you've never used GitHub before, follow these steps exactly.
- Create a free GitHub account at github.com/signup if you don't have one.
- Fork this repository. On this repo's page, click the Fork button in the top-right corner. On the next screen, leave all defaults and click Create fork. You'll land on your own copy at
https://github.com/<your-username>/trmnl-oura. All further steps happen on your fork, not the original. - Add your two secrets. Secrets are encrypted values the workflow can read but nobody (including you, after saving) can see in plaintext.
- On your fork, click the Settings tab (in the top navbar of the repo, near "Insights"). If you don't see it, make sure you're on your fork and not the original.
- In the left sidebar, expand Secrets and variables and click Actions.
- Click the green New repository secret button.
- For Name, type exactly
OURA_TOKEN(all caps, underscore). For Secret, paste your Oura personal access token from Step 1. Click Add secret. - Click New repository secret again. For Name, type exactly
TRMNL_PLUGIN_UUID. For Secret, paste just the UUID portion of your TRMNL webhook URL (the part after/custom_plugins/). Click Add secret. - You should now see both
OURA_TOKENandTRMNL_PLUGIN_UUIDlisted under "Repository secrets".
- Enable GitHub Actions on your fork. Forks have Actions disabled by default as a safety measure.
- Click the Actions tab in the top navbar.
- You'll see a yellow banner: "Workflows aren't being run on this forked repository." Click the green I understand my workflows, go ahead and enable them button.
- In the list, you should now see a workflow named Update TRMNL Display. Click it.
- Run it once manually to verify it works.
- On the workflow page, click the Run workflow dropdown on the right side.
- Leave the branch as
mainand click the green Run workflow button. - Wait ~15 seconds, then refresh the page. You should see a new run appear. Click into it — if it shows a green checkmark, data has been pushed to TRMNL. If it shows a red X, click the failed job to see the error (most common causes: typo in the secret name, wrong token/UUID, or TRMNL plugin not saved yet).
- You're done. The workflow is scheduled via cron (
*/15 * * * *) and will now run every 15 minutes automatically. You can watch runs under the Actions tab at any time.
Note on GitHub's free tier: Public repos get unlimited Actions minutes. Private repos include 2,000 free minutes/month, and this workflow uses roughly 13 seconds × 96 runs/day × 30 days ≈ 625 minutes/month — well within the free allowance even if you make your fork private.
The workflow uses actions/cache@v4 to persist the last known data between runs, so sections that temporarily return empty (for example, activity data early in the day) fall back to their most recent values instead of displaying blanks.
cp .env.example .env
# Edit .env with your tokens
pip install -r requirements.txt
python main.pySet up a cron job to run periodically:
# Every 15 minutes
*/15 * * * * cd /path/to/trmnl-oura && /path/to/python main.pyThe local cache lives at .cache/last_data.json (ignored by git).
Each run fetches sleep, readiness, activity, and heart rate from the Oura API. For each section:
- If fresh data is returned, it's used and written to the cache
- If the fetch returns empty (Oura hasn't generated today's document yet), the last cached values are used
- Sleep, readiness, and activity all fall back to yesterday's date before giving up, since new daily documents become available at different times during the morning
The updated_at variable shows the timestamp of the most recent piece of real data (not the time the script ran), so you can tell at a glance how stale the display is.
The following variables are available in your TRMNL markup templates:
| Variable | Example |
|---|---|
readiness_score |
78 |
readiness_temperature_deviation |
+0.2° |
readiness_recovery_index |
90 |
readiness_sleep_balance |
85 |
readiness_resting_heart_rate |
88 (Oura's 0–100 contributor score) |
sleep_score |
85 |
sleep_total_sleep |
7h 42m |
sleep_deep_sleep |
1h 20m |
sleep_rem_sleep |
1h 50m |
sleep_light_sleep |
4h 30m |
sleep_efficiency |
92 |
sleep_restfulness |
78 |
readiness_hrv_balance |
86 (Oura's 0–100 contributor score) |
sleep_average_hrv |
42 (templates display this as "HRV" — raw average HRV in ms from the main sleep session) |
sleep_average_breath |
14.2 |
sleep_average_heart_rate |
63 (raw avg HR in bpm during the main sleep session) |
sleep_lowest_heart_rate |
58 (lowest HR in bpm during the main sleep session) |
activity_score |
72 |
activity_steps |
8,432 |
activity_total_calories |
2,100 |
activity_active_calories |
420 |
activity_high_activity_time |
45m |
activity_medium_activity_time |
1h 20m |
activity_low_activity_time |
3h 15m |
activity_sedentary_time |
6h 09m |
activity_equivalent_walking_distance |
10,144 (meters) |
spo2_average |
97.5% |
| Variable | Example |
|---|---|
hr_resting_hr |
58 (sourced from the main sleep session's average_heart_rate — Oura's integrated resting HR — falling back to a computed value from rest-source samples) |
hr_resting_hr_display |
58 bpm |
hr_avg_hr |
72 |
hr_avg_hr_display |
72 bpm |
hr_min_hr |
52 |
hr_max_hr |
145 |
hr_line_path |
SVG path d for the HR line |
hr_area_path |
SVG path d for the filled area under the line |
hr_chart_start |
10 pm (local-time label for the earliest reading on the chart) |
hr_chart_end |
9 pm (local-time label for the most recent reading on the chart) |
| Variable | Example |
|---|---|
updated_at |
Apr 11, 08:32 AM |
The chart is an inline <svg> in the template with a fixed viewBox="0 0 480 60". Two <path> elements get their d attribute populated from merge variables:
hr_line_path— the line itselfhr_area_path— the same line closed along the baseline for a light fill
Path data is pure numeric text (M, L, Z plus integer coordinates), so it renders correctly even if TRMNL HTML-escapes merge variable values. build_hr_line() in main.py resamples the raw Oura BPM readings into 40 evenly-spaced time buckets (shrinking further if needed to stay under TRMNL's 2 KB payload limit), scales them to the chart's coordinate space, and emits both paths.
main.py— orchestrator: fetch, merge with cache, build chart data, push to TRMNLoura_client.py— Oura API v2 client with yesterday fallbackstrmnl_client.py— TRMNL webhook clientcache.py— persistent per-section cache (.cache/last_data.json)markup/full.html— 800×480 full-screen layoutmarkup/half_horizontal.html— 800×240 layoutmarkup/half_vertical.html— 400×480 layoutmarkup/quadrant.html— 400×240 layout.github/workflows/update.yml— scheduled GitHub Action
MIT