← All writing.md

How we sync a smart ring into our own database, and how you can too

October 15, 2026 · 24 min read

We wear an Ultrahuman Ring AIR. It records sleep, heart rate, heart rate variability, skin temperature and steps, and the phone app shows a tidy score each morning. We wanted that data in our own database, next to everything else our agents can read, so a question like "how did I sleep the week of the hackathon?" has an answer that isn't locked in someone else's app.

The first version worked, with one flaw we only noticed later. The job that pulled the ring data ran on a Raspberry Pi we carry around, a small wearable computer we call the cyberdeck. The Pi pulled from the ring vendor's cloud every hour, kept a local copy for its own screen, and pushed each day's data up to our server. When the Pi was in a bag with its battery off, nothing pulled. The server's copy quietly stopped at the last day the Pi had been online. Our laptop app showed a night that was a day old, with only a small timestamp to give it away.

The fix was to turn the design around. The always-on server now calls the ring vendor's API itself and stores the result in the one database we treat as the master. The Pi becomes a reader of a copy; that half is built and tested and installs itself the next time the Pi is online. This post explains the pieces in the order we built them, with the code, so you can do the same with your own ring and your own server.

What does a smart ring actually give you?

The ring itself talks only to the phone app over Bluetooth. There is no public Bluetooth protocol for reading it directly. The app uploads to the vendor's cloud, and the cloud has a partner API (a web address you call with a personal token to get your own data back). For Ultrahuman, you generate the token in their developer portal, then call one endpoint:

GET https://partner.ultrahuman.com/api/v1/partner/daily_metrics?start_epoch=...&end_epoch=...
Authorization: <your token, with no "Bearer" prefix>

You can ask for one date or for a range of at most seven days as Unix epoch seconds (the number of seconds since 1970, the standard way programs write a moment in time). The answer is one JSON object per day, keyed by date, holding a list of metric objects: readings of heart rate, HRV, temperature and steps every few minutes, a handful of blood-oxygen points, single daily values such as recovery index and VO2 max, and a sleep object with the score, the minutes in each stage and the full hypnogram (the timeline of sleep stages through the night).

Exactly what comes back for one day

Each day is a list of typed objects. From a real response, these are the types and what each holds:

  • `hr`: heart rate readings every few minutes through the day, in beats per minute, plus the last reading.
  • `hrv`: heart rate variability readings (the variation in time between beats, in milliseconds) and the day's average, with a trend against the previous days.
  • `temp`: skin temperature readings in degrees Celsius.
  • `steps`: step counts over time, the day's total and the average per interval.
  • `spo2`: a few blood-oxygen saturation points, as a percentage.
  • `night_rhr`: resting heart rate during sleep, as a trend series over several nights.
  • `sleep`: one object for the night that ended that morning: bedtime start and end, the score, total sleep, time in bed, the minutes and percentage in deep, light, REM and awake, sleep efficiency, restorative sleep percentage, temperature deviation from your baseline, average oxygen saturation (which the vendor marks as a beta feature), tosses and turns, full sleep cycles, morning alertness in minutes, and four time series across the night: the hypnogram (which stage you were in, minute by minute), heart rate, HRV and skin temperature. A day with no sleep recorded has no score, and our parser treats the missing score as "no night".
  • Single daily values: sleep_rhr (resting heart rate in sleep), avg_sleep_hrv, recovery_index, movement_index, active_minutes, inactive_time, weekly_active_minutes, movements, morning_alertness and vo2_max (an estimate of aerobic fitness).

Three details of this API shaped everything that follows:

  • Days are keyed in your account's timezone, not in UTC. The night you sleep is filed under the day you wake up.
  • A day at the edge of a range comes back partial. If your range starts at noon, that day's readings before noon are missing. So a range must be padded and edge days dropped.
  • Recent days change. Last night's sleep appears only after the phone syncs, and some trend values are recomputed later. A sync has to re-fetch the last few days every time.

Why the Pi was the wrong place to pull from

The question to ask about any data pipeline is: which machine is the source of truth, the one copy every other copy is judged against? A device that travels, sleeps and runs on a battery is a poor source of truth, however convenient it is for display. An always-on server is a good one. Our server already held the master database for everything else, so the ring data joining it was the obvious move.

The analogy is a newspaper and a newsstand. The press prints every day whether or not any particular stand is open. A stand that was closed yesterday picks up both days' papers when it reopens. In our first design the newsstand was doing the printing, so a closed stand meant no paper for anyone. The analogy stops at one point: our "stand" also needs to work with no connection at all, so it keeps its own copy rather than fetching on demand.

After the change the roles are (the Pi's is the part still waiting for the Pi):

  • The server calls the vendor's API every hour and writes the result into Postgres. It is the only writer of the ring tables and the only machine that uses the API token.
  • The Pi will ask the server, every hour it is online, for every day that changed since its last visit, and keep the result in a small local SQLite file for its screen. Delete that file and the next pull rebuilds it.
  • The laptop app reads the server's database directly over a private network. It has no copy to go stale.

Step 1: one script, two storage backends

We wanted the server and the Pi to run the same code, so they can never disagree about what a day's data means. The script has one parser and one storage function, and the storage function works on either a Postgres connection or a SQLite one. The trick that makes the two copies comparable is to store the canonical bytes of each day's payload and a hash of them:

import hashlib, json

def canonical(items):
    """The stored payload text and its sha256. The same bytes on every machine."""
    text = json.dumps(items, sort_keys=True, separators=(",", ":"))
    return text, hashlib.sha256(text.encode()).hexdigest()

Sorting the keys and removing the whitespace means the same data always produces the same text, so the same hash. Storage then becomes an idempotent upsert: if the hash for that day is unchanged, touch a received_at timestamp and stop; if it changed or is new, replace the raw payload, the parsed daily row, the samples and the sleep stages for that day in one transaction, and move changed_at. Here is the SQLite half, trimmed to its shape:

def store_day(con, day, items, now, changed_at=None):
    payload, sha = canonical(items)
    row, samples, stages = parse_day(items)           # your parser: daily dict, (metric, ts, value), (start, end, stage)
    prev = con.execute("SELECT payload_sha256 FROM ring_raw WHERE day = ?", (day,)).fetchone()
    with con:
        if prev and prev[0] == sha:
            con.execute("UPDATE ring_raw SET fetched_at = ? WHERE day = ?", (now, day))
            return False
        con.execute("""INSERT INTO ring_raw (day, payload, payload_sha256, fetched_at, changed_at)
                       VALUES (?, ?, ?, ?, ?)
                       ON CONFLICT(day) DO UPDATE SET payload = excluded.payload,
                         payload_sha256 = excluded.payload_sha256,
                         fetched_at = excluded.fetched_at, changed_at = excluded.changed_at""",
                    (day, payload, sha, now, changed_at if changed_at is not None else now))
        con.execute("DELETE FROM ring_samples WHERE day = ?", (day,))
        con.executemany("INSERT OR REPLACE INTO ring_samples (metric, ts, day, value) VALUES (?, ?, ?, ?)",
                        [(m, ts, day, v) for m, ts, v in samples])
        con.execute("DELETE FROM ring_sleep_stages WHERE day = ?", (day,))
        con.executemany("INSERT OR REPLACE INTO ring_sleep_stages (day, start, end, stage) VALUES (?, ?, ?, ?)",
                        [(day, s, e, st) for s, e, st in stages])
    return True

changed_at matters more than it looks. On the server it is set to "now" whenever the hash moves. On the Pi it is copied from the server, so the Pi can later ask "give me every day whose changed_at is after the newest one I have". That one column is the whole synchronisation protocol.

The raw payload is kept word for word. The parsed tables are a convenience that can be rebuilt from it, so a parser bug never costs data.

Step 2: fetch in windows and re-fetch the last three days

Each run decides which days to fetch: from the newest stored day minus two (a three-day window, so a night that lands late is picked up) through today. An empty database starts from a fixed date, the first day the ring was worn. Days are grouped five at a time, and each group's time window is padded by twelve hours on both sides, which keeps every requested day whole even if the server's clock and the account's timezone disagree by up to twelve hours. Five days plus a day of padding is six days, under the API's seven-day cap.

from datetime import date, datetime, timedelta

CHUNK_DAYS, PAD_S = 5, 12 * 3600

def plan_days(last_stored, today, backfill_from, refetch=3, force_from=None):
    if force_from:
        start = date.fromisoformat(force_from)
    elif last_stored:
        start = min(date.fromisoformat(last_stored), today) - timedelta(days=refetch - 1)
    else:
        start = date.fromisoformat(backfill_from)
    return [start + timedelta(days=i) for i in range((today - start).days + 1)]

def chunk_windows(days):
    out = []
    for i in range(0, len(days), CHUNK_DAYS):
        group = days[i:i + CHUNK_DAYS]
        first = datetime.combine(group[0], datetime.min.time()).timestamp()
        last_end = datetime.combine(group[-1] + timedelta(days=1), datetime.min.time()).timestamp()
        out.append(({d.isoformat() for d in group}, int(first - PAD_S), int(last_end + PAD_S - 1)))
    return out

def sync(con, token, today, fetch):
    days = plan_days(max_day(con), today, backfill_from="2026-01-01")
    for wanted, start, end in chunk_windows(days):
        metrics = fetch(token, start, end)                 # {"YYYY-MM-DD": [metric objects]}
        for day in sorted(wanted & metrics.keys()):       # drop the clipped edge days
            store_day(con, day, metrics[day], now=int(time.time()))

The line wanted & metrics.keys() is the edge-day rule: only days we asked for are stored, so a day the API clipped at the window's edge is never written as if it were complete.

Step 3: the timezone trap

Our server runs on UTC, as most servers do. The ring vendor files each day in the account's timezone, which for us is Tokyo, nine hours ahead. At 23:30 UTC the server's calendar still says the 15th, but in Tokyo it is 08:30 on the 16th, and last night's sleep is already filed under the 16th. A planner that used the server's date.today() would not ask for the 16th until nine hours later.

The fix is one function, and a setting that names the account's timezone:

from datetime import datetime
from zoneinfo import ZoneInfo

def today_in(tz="Asia/Tokyo"):
    """The vendor files days in the account's timezone; the server's clock is UTC."""
    return datetime.now(ZoneInfo(tz)).date()

We found this one by reading the code before deploying, not by losing a night. The first run on the server, at 15:20 UTC on the 15th, fetched through the 16th, which is the confirmation.

Step 4: a read-only feed for the mirror

The Pi needs a way to ask the server for changed days without being able to change anything. We gave the server a tiny HTTP service, about a hundred lines of Python's standard library plus the Postgres driver, with two routes. /ring/health reports how many days are stored and when the last sync ran. /ring/days?since=<epoch> returns every day whose changed_at is after since, oldest change first, fifty per page, with the canonical payload text, its hash and its changed_at. It checks a bearer token (a shared secret sent in a header) and listens only on our private network address.

def feed(con, since, limit=50):
    with con, con.cursor() as cur:
        cur.execute("""SELECT day::text, payload::text, extract(epoch FROM changed_at)::float8
                       FROM ring_raw WHERE extract(epoch FROM changed_at)::float8 > %s
                       ORDER BY changed_at, day LIMIT %s""", (float(since), limit))
        out = []
        for day, payload, changed_at in cur.fetchall():
            text, sha = canonical(json.loads(payload))
            out.append({"day": day, "payload": text, "sha256": sha, "changed_at": changed_at})
    return out

One detail worth copying: changed_at is compared and returned as a floating-point epoch with the same rounding on both sides, and the comparison is strictly "after". The Pi stores the value it was given and sends it back next time, so the newest day is never served twice. Our first sketch used whole seconds and a "greater or equal" comparison, which would have re-sent the latest day every hour, harmlessly but pointlessly; we caught it before writing the code.

We chose an HTTP feed over giving the Pi a database login. A database role would have opened the whole database to a device that is carried around and could be lost. The feed exposes exactly one table, read-only.

Step 5: the mirror pulls

On the Pi, the same script runs with a --pull flag instead of the vendor token. It reads the newest changed_at in its local file, asks the feed for everything after it, verifies each payload's hash, and stores each day with the server's changed_at. It pages until a page comes back short.

def pull(con, url, token, get, now):
    since = mirror_since(con)                         # max(changed_at) in the local file, or 0.0
    while True:
        days = get(url, token, since).get("days") or []
        before = since
        for d in days:
            if hashlib.sha256(d["payload"].encode()).hexdigest() != d["sha256"]:
                raise ValueError(f"{d['day']}: sha256 mismatch from the server")
            store_day(con, d["day"], json.loads(d["payload"]), now=now, changed_at=float(d["changed_at"]))
            since = max(since, float(d["changed_at"]))
        if len(days) < 50 or since <= before:
            break

If the Pi is offline, the request fails, the failure is logged, and the screen keeps showing the last copy with its age. Nothing on the server notices or cares. The installer also removes the vendor token from the Pi's environment file: a reader has no business holding the key.

Step 6: two timers, ten minutes apart

Both machines run the job from a systemd timer (Linux's built-in scheduler, which also runs a missed job after a reboot when Persistent=true is set). The server syncs at seven minutes past each hour; the Pi pulls at seventeen past, so it always finds the fresh hour. The server's unit, trimmed and with our user and paths made generic:

[Unit]
Description=Ring sync into the master Postgres

[Service]
Type=oneshot
User=app
EnvironmentFile=/etc/ring-sync/env
ExecStart=/usr/bin/python3 /opt/ring-sync/ring_sync.py
[Timer]
OnBootSec=3min
OnCalendar=*-*-* *:07:00
RandomizedDelaySec=2min
Persistent=true

[Install]
WantedBy=timers.target

The environment file holds the vendor token, the feed's bearer token and the timezone, readable by root and the service's user only. The Pi's unit has the same shape: it runs ring_sync.py --pull as the device's own user, fires at *:17:00, and its environment file holds the feed's address and bearer token instead of the vendor token.

Each run also writes one row to a ring_sync_log table: when it ran, how many days it fetched, how many changed, and the error text if it failed. That row is what the health route reads, and it is what our morning stand-up will read next, so a revoked token shows up the same day instead of as a slowly ageing chart.

What the numbers mean, and how to read them

A ring is not a lab. Treat every value as a trend against your own baseline, not as a diagnosis. With that said, the numbers are far from meaningless, and reading them is mostly a matter of knowing which few to look at and in which direction they move.

  • Sleep score is the vendor's summary of the night. It is useful as a single line on a chart and useless as a thing to argue with. Look at what feeds it instead.
  • Total sleep and time in bed are the two honest numbers. Their ratio is sleep efficiency: how much of the time in bed you actually slept. A low efficiency with a long time in bed usually means a late, restless night rather than a short one.
  • Deep, light and REM minutes describe the night's shape. Deep sleep tends to come early in the night, REM later, so a night cut short at the end loses REM first. The hypnogram shows this directly, and it shows wake-ups you don't remember.
  • Resting heart rate in sleep is the night's average heart rate while asleep. For one person, a lower value than usual tends to go with being rested; a higher one with strain of some kind, whether a late meal, alcohol, illness or hard training.
  • HRV moves the other way: higher than your usual is the recovered direction, lower is the strained one. It varies a lot night to night, so read a rolling average over several nights, not one value.
  • Temperature deviation is the night's skin temperature against your baseline. We treat a clear rise as a reason to take the day easier, not as a diagnosis.
  • Recovery index and movement index are the vendor's composite scores for the night and for the day's activity. Keep them as summaries; look at the inputs when they surprise you.
  • Morning alertness is minutes, not a score: how long after waking the ring judged you to be fully up.
  • Steps, active minutes and VO2 max describe the day rather than the night. VO2 max from a wrist or finger sensor is an estimate; its direction over months is the useful part.

The rule that makes all of this usable: compare today with your own last month, never with a published average. That is exactly what owning the data lets you do: the vendor's app shows the recent past, and your database keeps everything.

How we visualise it

The ring's data has two natural scales, the night and the month, so we draw two things and little else. This is what our web app's ring page and the wearable's glasses panel draw today.

  • One night, in detail. A hypnogram: time across, stage down, each stage a horizontal band in one colour family, dark for deep and light for awake, with the stage names written on the rows so nobody has to read the colour. Under it, the in-bed heart rate as a thin line on the same time axis, so a spike in the line lands under the stage it happened in. Beside them, the night's handful of numbers in a small grid: score, total sleep, deep and REM, HRV, resting heart rate, recovery, temperature deviation, efficiency, oxygen.
  • The last fourteen to thirty nights. A bar chart of sleep scores, one bar per night, the selected night highlighted, so a bad week is a shape rather than a memory. Clicking a bar swaps the night above.

The chart we have not drawn yet, and want next: trend lines over a month for the three slow signals worth watching, resting heart rate, HRV and temperature deviation, each against its own rolling average.

Two rules we follow on every chart. First, never encode a sleep stage by colour alone: label the rows, because some readers will not see the hue difference and the glasses display we use shows only a few shades clearly. Second, the same chart twice, at two sizes: the laptop version has a crosshair and a full day table; the version on the wearable's glasses uses thirty-pixel text, inline SVG and no interaction except "more" to step back a night. If a chart cannot survive the small version, it was decoration.

What we deliberately don't draw: every five-minute heart rate reading of the day as one long line. It looks impressive and tells you nothing you can act on. The day's story is in the night and the trend.

What it's useful for, and how agents use it

The point of owning the rows is that a question can be asked in words and answered with a query, by you or by an agent. Our agents reach the database through a role that cannot write the ring tables, so a question never risks a write. Two that work today, and two we are wiring next:

  • "How did I sleep last week?" A query over the daily table for the last seven nights, returned as a short list with the two or three numbers that moved.
  • "Did the late nights at the hackathon cost me?" The same query over a date range, compared with the thirty days before it.
  • "Flag a bad night." (next) Our morning stand-up is written by an agent before we wake. The plan is for it to read the newest row and the sync log: if last night's efficiency or temperature deviation is off from the month's average, say so in one line; if the sync failed, say that instead of showing a stale number.
  • "Is this a pattern?" (next) A journal entry mentions feeling flat; an agent lines up the last ten nights next to the last ten entries and says whether the flat days follow the short nights. This is the case we built the whole thing for: body data next to written reflection, in one place, under your own control.

The query behind the first question, as an example you can paste into psql:

SELECT day, sleep_score, total_sleep_min, sleep_efficiency_pct, sleep_hrv, sleep_rhr, temp_deviation_c
FROM ring_daily
WHERE day >= current_date - 7 AND sleep_score IS NOT NULL
ORDER BY day;

And the instruction we give agents that may read it, in their shared rules file: the ring tables are records, read them with SQL, trust the database over any note that quotes a number from it, and never edit them; only their pipeline writes them. Our writing guide adds that health values never go into a public post.

How to build your own

You need a ring with a partner API (we use Ultrahuman; the same shape works for any vendor that returns per-day JSON), an always-on Linux box with Postgres, and optionally a small device for a screen.

  • Get a token from the vendor's developer portal and call the daily endpoint once by hand for yesterday. Save the response as a test fixture; every test below runs against it, so your tests never call the real API.
  • Write the parser against the fixture: one daily row, a list of (metric, timestamp, value) samples, a list of (start, end, stage) sleep blocks. Keep the raw payload too.
  • Write `store_day` with the canonical hash and the changed_at rule above. Test that storing the same day twice changes nothing and that a changed value moves changed_at.
  • Write the planner with the three-day re-fetch, five-day windows, twelve-hour padding and edge-day dropping. Test that every window is under the API's cap.
  • Decide today's date in the account's timezone. Read step 3 twice.
  • Run it on the server from a timer, as a user that owns the tables. Check the row counts after the first run and compare them with what the vendor's app shows.
  • Only if you need a screen: add the feed and the --pull mode. The same store_day serves both machines.

Don't give the reading device the vendor token or a database login. Give it a read-only feed and a shared secret. Keep every secret in an environment file the scheduler loads, never in the code or the repository.

A prompt for your coding agent

The change on our side started with one message to our agent, which we reproduce as it was written:

I'm seeing that the ring data is only synced from the Raspberry Pi. We should make it such that the server can pull the data and store it on the server, and the server being the master source of truth, master database. And so let's update the design such that we use the server, the ring, the ultra human ring API keys on the server to pull in the data into our database and that the Raspberry Pi would only be reading it.

The agent read the existing code and the database, found the data had stopped at the last day the Pi was online, wrote the new design, and then built and deployed it on a second message ("this looks good, let's build this and deploy it"). If you are starting from nothing, this prompt covers the whole job. Fill in the parts in angle brackets.

Build an hourly sync of my <vendor> smart ring into my own Postgres database.

Facts: the vendor's partner API is <endpoint>, authenticated with the token in the
environment variable <NAME>; it returns one JSON object per day keyed by date in my
account's timezone (<timezone>), accepts a date range of at most <N> days, and returns
partial data for days at the edge of a range. Recent days change after the phone syncs.

Design:
- One Python script, standard library plus psycopg2, with a Postgres backend on the
  server and a SQLite backend for a reader device, behind the same functions.
- Store each day's raw payload as canonical JSON (sorted keys, no whitespace) with its
  sha256, plus parsed tables: a daily row, samples (metric, ts, value) and sleep stages.
  Upserts are idempotent: an unchanged hash only touches a received_at timestamp; a
  changed hash replaces the day's rows in one transaction and moves changed_at.
- Each run fetches from the newest stored day minus 2 through today, in 5-day windows
  padded by 12 hours on each side, and stores only days it asked for. "Today" is
  computed in the account's timezone, never the server's clock.
- A ring_sync_log table gets one row per run: when, days fetched, days changed, error.
- A read-only HTTP feed on the server, bound to the private network only, with a bearer
  token: GET /ring/health and GET /ring/days?since=<epoch float> returning days whose
  changed_at is strictly after since, oldest first, 50 per page, each with its canonical
  payload, sha256 and changed_at.
- A --pull mode for the reader device that copies changed days from that feed into its
  SQLite file, verifying every sha256, and never holds the vendor token.
- systemd units: a oneshot service plus an hourly timer on the server (Persistent=true),
  and the same on the reader ten minutes later. Secrets live in an EnvironmentFile.

Tests first, with unittest, against a saved real API response: the parser, idempotent
storage, the planner's windows and edge days, the timezone rule, the feed's paging and
auth, and one end-to-end pull from the real feed into a SQLite file.

Then write an installer script that copies the files to both machines, writes the
environment files without printing any secret, enables the timers, runs the first sync
and prints the health route. Deploy it, show me the row counts, and write a dev log.

What's built and what's next

Built and running on the server: the sync and its timer, the feed and the log table. Built and tested, waiting for the Pi: the pull mode and the screen panel that shows "mirror of N days, as of ten minutes ago". The server has pulled about three weeks of data, roughly twenty thousand readings, and it is the only machine that calls the vendor with the token.

Next: the Pi was off the network the whole day we built this, so it still runs the old push script until we re-run the installer with it online; its pushes now get a polite 404. Our web app's ring page still reports "last update" from the newest row rather than from the sync log, a small change that will make a dead token visible on the page itself. And we want to compare the hashes of older days after a month of hourly runs, to learn how often the vendor revises them and whether a weekly deeper re-fetch is worth it.

The idea to keep is older than any of this code: the always-on machine is the source of truth, and everything that moves is a reader. If you build your own, follow along at https://myargoquest.com/blog and https://x.com/myargoquest.

Start your 750 words today

Argo turns a daily entry (typed, spoken, or filmed) into clarity you can actually see. Private, encrypted, and built entirely from your own life.

Download on the App Store