Hacker News

My Homepage Has a Pulse

Comments

There is a small red heart on my homepage now that beats. The number next to it is my actual heart rate, and the heart pulses at that frequency: 48 bpm means one thump every 1.25 seconds, 80 bpm means one every 0.75 seconds. I wrote this up because I wanted to, and because the design ended up containing more interesting decisions than I expected.

The watch I wear is a Garmin Descent Mk3i, 43 mm, carbon gray DLC titanium. I bought it as my primary dive computer, alongside my Shearwater Perdix 2, because you need redundancy in technical diving and overhead environments like caves and wrecks. If a computer dies at 75 m inside a cave, you can’t just surface and charge it. Cave divers have backups for their backups. Since April 2025 it has been on 400+ dives with me, including to 100 m. I don’t take it off unless I’m charging it or showering.

Between dives it tracks my heart rate, sleep score, HRV, steps, calories, and a dozen other vitals around the clock, and syncs everything to the Garmin Connect app on my Pixel 9 Pro. My gym and streetlifting sessions get logged on it too. Which means Garmin already holds a continuous, granular physiological record of my life. I just needed the data out of Garmin and onto my homepage.

A more personal "online" status indicator

Old messaging apps had the green dot. Facebook Messenger, Gchat before it: a small indicator that said "this person is here right now." I remember seeing it in HexChat in 2012, before I moved to Irssi. I wanted that for this site, except the green dot always felt like a statement about a socket instead of a person. A heartbeat is the least abstract "online" there is. It’s the difference between "his browser has a connection open" and "he is alive, and here is the proof."

In the end it’s just a number on a page, I get that. But it is my number, from my chest, and that makes it the most personal thing on the site. It might be a little cringe to a lot of you, but I like the idea :P.

The longer-term goal, when I get around to it, is a section of this site that shows a granular ledger of my life made public: workouts, sleep, HRV, calories burned, steps walked, etc. I understand what that leaks. All-day heart rate reveals when I sleep and wake. Gaps and timezone shifts reveal travel. A resting heart rate that jumps 8 bpm flags illness, stress, or last night’s drinks. HRV trends are a decent proxy for mental state, and a regular gym schedule advertises exactly when I am not home. I have sat with all of that and decided I am fine with it. I consent to it, and if you have a problem with it, that is your problem, not mine ¯(ツ)/¯.

Surely someone has done this

Yup, they have. My first thought was that this must already exist, and I was right. Twitch streamers have been putting live heart rate on screen for years through services like Pulsoid and HypeRate, and both support Garmin watches. They work by putting the wearable into its broadcast heart rate mode, where it streams over BLE/ANT+ to a phone app that relays the number to their servers. Seemed promising at first, but it was a dead end for me.

Garmin’s manuals warn that broadcasting decreases battery life, and this watch is my dive computer. I’m not going to trade dive-day battery for a website widget. And broadcast is a mode you switch on and stay in range of. Seems like too much of a chore to me. Plus, I like that I charge the watch once a week and it lasts for 10 days. I don’t want to charge it every night just to keep a widget alive.

The browser itself can speak Bluetooth, which briefly seemed promising. The Web Bluetooth API can read the standard heart rate GATT service directly from a nearby device. Still a no-go because it connects the visitor’s browser to a device near the visitor. It would only ever work on my own machine, in a Chromium browser, with the watch in broadcast mode.

Surely then there is a real API. There is: Garmin’s Health API serves this data, BUT access is gated behind a business application and review. It is built for companies integrating wearables into products. What about a guy who wants a heart on his homepage? Fahhhh (╯’□’)╯︵ ┻━┻.

At this point I did what I should have done first and searched GitHub and Reddit. Two projects kept surfacing in Reddit threads:

  • GarminDB downloads your entire Garmin history into a local SQLite database and gives you analysis and plots on top. Cool, but wrong for this. An archive with a schema seems like overkill… I probably just want one day of heart rate as JSON every morning.
  • python-garminconnect is the right tool because it is a Python client that speaks the same private API the Garmin Connect app uses. It lets you log in the way the app does and calls the same endpoints as the app. It exposes essentially everything I can see on my phone, which incidentally solves the someday-in-the-future-project problem, because workouts, sleep, and HRV are all reachable from the same client. Nice, thank you @cyberjunky!

Why not live?

Live was never actually on the table, but I didn’t know that at the start. The watch does not stream anywhere by default: all-day heart rate accumulates on the wrist and reaches Garmin’s servers in batches, whenever the watch↔phone sync happens. Without broadcast mode there is no feed to subscribe to. And my site is static, Astro built once and served as files from GitHub Pages. There’s no server to hold a websocket open, so even if the watch did stream, I didn’t figure out a way to relay it to visitors.

The only option is to fetch the data once a day and replay it. So the compromise is that the widget replays yesterday. When you load my homepage at 14:32 your time, you see my heart rate at 14:32 yesterday, interpolated from real samples. The "live" label is a white lie, but it’s mine to tell. But at least the lie is precise: the offset is exactly 24 hours, the data is real, and nothing is made up beyond drawing straight lines between measurements.

A one-time login (for a year)

python-garminconnect logs in with an email and password, but what it hands back is a pair of OAuth tokens. The OAuth1 token is the long-lived one, good for about a year. The OAuth2 token is what actually authorizes API calls, and it expires after a few hours. The library signs every request with the OAuth2 token, and whenever that one expires, it uses the OAuth1 token to generate a fresh one behind the scenes (my limited understanding). So my creds get typed once on my laptop, and never go anywhere near CI. CI only holds tokens which refresh themselves for a year.

The one-time part looks like this:

python3 -m venv .venv
source .venv/bin/activate.fish
pip3 install garminconnect
python3 scripts/garmin_login.py

garmin_login.py is a short script that prompts for credentials and handles the token plumbing. The interesting part:

garmin = Garmin(email=email, password=password, return_on_mfa=True)
status, client_state = garmin.login()
if status == "needs_mfa":
    mfa = input("MFA code: ")
    garmin.resume_login(client_state, mfa)
garmin.client.dump(str(tokendir))  # token files -> ~/.garminconnect
b64_path.write_text(garmin.client.dumps())  # same tokens, one base64 string

return_on_mfa=True makes the library pause and hand control back instead of dying when Garmin asks for a two-factor code, and resume_login finishes the handshake with whatever I type. At the end the tokens land in two forms: as files in ~/.garminconnect for local runs, and as a single base64 blob (client.dumps() serializes the whole token store into one string) that becomes a GitHub Actions secret:

gh secret set GARMINTOKENS_BASE64 --repo snehankekre/snehankekre.github.io
if (t > last) t -= DAY; // just past the data day (pre-refresh); replay it
if (t < last - GAP) return null;
let prev = null;
let next = null;
for (const p of pts) {
  if (p[0] < t) prev = p;
  else { next = p; break; }
}
if (!prev || !next) return null;
if (t - prev[0] > GAP || next[0] - t > GAP) return null;
return t - prev[0] < next[0] - t ? prev[1] : next[1];
if (t > last) t -= DAY; // just past the data? replay the same time, one day earlier

A visitor at 1:30 a.m. briefly gets 1:30 a.m. from two days ago instead of nothing, which for a widget whose entire premise is a replay seems like a fair trade. The part I like is that the same line is the staleness guard, because it rewinds at most once. If the pipeline dies and the file goes stale for days, even the rewound target misses the data and the widget hides. The wraparound grants at most 24 hours of grace and can never dress up week-old data as current.

I decided to hide the widget on failure rather than show a stale number. The widget’s markup ships with the hidden attribute already on it, and only a successful fetch plus a valid sample removes it. Failed fetch, empty file, stale data, hole in the samples all result in the widget staying hidden.

Making it beat

The pulse is a CSS animation that scales the heart glyph up and down at the same frequency as the data. Every tick, the script writes the reading into the DOM and sets a CSS custom property to the beat interval, 60/bpm seconds:

bpmEl.textContent = String(bpm);
heartEl.style.setProperty('--beat', `${(60 / bpm).toFixed(3)}s`);

The animation just reads that variable, so the heart on the page beats at whatever rate the data says. 48 bpm visibly beats slower than 80:

.hr-heart {
  display: inline-block;
  color: var(--accent);
  transform-origin: 50% 60%;
  animation: hr-beat var(--beat, 1s) ease-out infinite;
}

@keyframes hr-beat {
  0% { transform: scale(1); }
  14% { transform: scale(1.32); }
  28% { transform: scale(1); }
  42% { transform: scale(1.18); }
  56%, 100% { transform: scale(1); }
}

The keyframes mirror the systolic/diastolic cycle where you see a big bump, then a smaller one, then rest for the remainder of the cycle, because a single symmetric scale pulse looked like a notification badge rather than a heart. transform-origin: 50% 60% makes the glyph swell from its visual center instead of its bounding-box center, which for ♥ are not the same place. And prefers-reduced-motion disables the animation entirely; the number still updates for anyone who has asked their OS for less motion.

What does this cost me?

Nothing, really. There’s no server (besides GitHub Pages), no third-party relay, and no hit to the watch battery, since it syncs exactly like it did before. I wear the watch, it syncs to my phone, and the phone syncs to Garmin’s servers. The added difference is that CI fetches the data from Garmin’s servers once a day and ships it to the site.

The whole thing is one Python script, two cron lines, and some JS. The only upkeep I expect is the tokens expiring in about a year, at which point the heart will quietly vanish from my homepage and I’ll rerun the login script. I’ve asked Google Assistant to remind me in 11 months :D.

Someday, when I get around to it, I’ll add the workouts and the rest of the ledger. The same client that fetches my heart rate can fetch every gym and streetlifting session I’ve ever logged, so most of the plumbing here carries over. And because activities come down as FIT files with 1 second samples, the workouts section could be far more granular than the all-day series ever was.

Until then, you get my heartbeat exactly 24 hours late.

Comments

No comments yet. Start the discussion.