A web page titled Ring control with a green dot beside COLMI R02_DEMO, 82 percent and recording every 5 min, above a Live reading card showing 78 bpm, a red line chart and Heart rate, Blood oxygen and Stop buttons

Mac toolSole authorDrawing 41 of 43

Health Ring
Interface

I wanted my smart ring's readings on my own computer, so I wrote a Mac program that talks to a COLMI R02 ring over Bluetooth and saves its heart rate, blood oxygen and steps to a local SQLite file. Every reading in the pictures on this page is made-up sample data.

Sheet 02 of 08/Why

Why I replaced the vendor app

The ring comes with a phone app called QRing, and I wanted the data in a file I control. I started on the Mac because it's a much cheaper place to figure out how the ring talks. The plan is to build an iPhone app in Swift later from the same notes.

It reads the battery, sets the ring's clock, changes how often it records heart rate, pulls the stored heart rate and step history, and takes live heart rate and blood oxygen readings.

A page titled Ring data with six number cards for readings, resting low, average bpm, peak bpm, steps and blood oxygen, above a red line chart of heart rate logged by the ring across seven days with one day missing
PL 02What cli.py report builds. It's one HTML file with the week's numbers on top and the ring's own heart rate log under them. Shown with sample data.

Sheet 03 of 08/Bluetooth

Getting the Mac to see the ring

I started with bleak, the usual Python Bluetooth library, and every connection dropped about 31 seconds in. The debug log showed it waiting on descriptor discovery for the ring's notify channel, and the ring never answers that request. So I dropped bleak and called Apple's CoreBluetooth directly through pyobjc, and a battery request came back in 1.5 seconds.

On macOS 26 an older pyobjc also claimed Bluetooth was turned off while it was on, so the project needs pyobjc 12 or newer.

The ring kept disappearing too. It stops advertising while it's in the charging case, while my phone holds the connection, and while macOS still holds the link from an earlier run. That last case looked exactly like a dead ring and cost me the most time, so now the program asks macOS for a ring it already holds before it scans.

A loose name match also connected to a Windows PC once, because the PC's name happened to contain R10. The ring's name is matched by its shape now.

A dark terminal window showing the help text of cli.py, listing the commands scan, info, set-time, blink, logging, live, sync, report, serve and raw with a one line description each
PL 03Every command the program has, straight from cli.py --help.
A dark terminal window running cli.py info, cli.py logging, cli.py live heart-rate with one timestamped value per line, then cli.py raw 03 and cli.py raw 15 with hex packets in orange
PL 04A few commands in a row. Battery and settings, a live heart rate reading about once a second, and two raw commands with the bytes that came back. Shown with sample data from a stand-in ring.

Sheet 04 of 08/Surprise

The ring is also a mouse

After the Mac connected to it, the pointer started moving and clicking on its own. The ring's firmware carries a Bluetooth mouse profile, and once macOS noticed the ring it hooked it up as a mouse, so my finger was driving the cursor.

The fix was to forget the ring in the Mac's Bluetooth settings. The program keeps working after that because reading the ring doesn't need pairing.

Sheet 05 of 08/Protocol

Writing the protocol down

Every message to and from the ring is 16 bytes, with a command byte first, 14 bytes of data and a checksum at the end. There's no pairing key and no encryption, so any program in range can read it. I wrote each command I confirmed on the ring into docs/protocol.md in plain language, so the iPhone app could be written from that file later.

I got the recording interval wrong at first. I set the ring to measure every minute and expected 1,440 heart rate readings a day, but the history still came back as 288 slots, one every 5 minutes. That setting only changes how often the ring measures. The stored grid stays at 5 minutes, so for anything finer I use a live reading, which sends about one value a second while the connection is open.

The ring also offers live blood pressure and blood sugar readings. Its hardware can't measure either one, so I don't trust those numbers.

A document page titled COLMI R02 protocol showing a strip of 16 numbered boxes for command, data and checksum, five example packets in hex, and a table of nine commands with their hex codes
PL 05The packet format and command list from my protocol notes. Every packet is 16 bytes, with a checksum in the last one.
A document page with three tables, the packet types for the 0x15 heart rate history, the byte layout of one 0x43 step block, and the reading kinds for 0x69 live readings
PL 06How the ring hands over a day of heart rate and a day of steps. A header of 15 00 18 05 means 24 packets on a 5 minute grid.
Two cards from the control page, one with Refresh, Set clock, Flash it and Disconnect buttons, and one titled Recording, stored in the ring itself with an interval box set to 5 minutes, Turn on and Turn off buttons and a paragraph about the 5 minute grid
PL 07The recording setting is stored in the ring itself. The note under it is there because a shorter interval doesn't add any detail to the stored history.

Sheet 06 of 08/Storage

My own database and report

A sync asks the ring for one day at a time and writes what comes back to SQLite. Every timestamp is stored in UTC, and each table is keyed on time, so syncing the same day twice doesn't add duplicate rows. The ring only holds about a week of history, so it needs syncing often.

cli.py report turns the database into one HTML file. The charts are inline SVG drawn straight from the rows, with no web server and no JavaScript library.

Two chart cards titled Heart rate, live readings and Blood oxygen, live readings, each with a few thin vertical blue strokes spread across a week
PL 08Live heart rate and blood oxygen in the report. Live readings come in short sessions, so across a week each one shows up as a thin stroke. Shown with sample data.
A card titled Steps per day with six blue bars labelled by date and a table listing day, steps, kcal and distance in kilometres
PL 09Steps per day as bars and as a table with calories and distance. Days the ring had nothing for are left out. Shown with sample data.
Control page cards titled Your database with Sync now, Build report and Open report buttons and row counts for heart rate, steps and live reading, above a table of timestamps, BPM values and the source log
PL 10The database part of the control page. Row counts for each table, then the latest 500 heart rate readings, newest first. Shown with sample data.
The same control page cards with the Steps tab selected, showing a table of 15 minute blocks with steps, kcal and metres columns
PL 11The Steps tab, one row per 15 minute block with calories and metres. Shown with sample data.

Sheet 07 of 08/Control page

A control page in the browser

cli.py serve opens a local page that does everything the command line does. One thread owns the Bluetooth connection and the page hands it jobs through a queue, so two requests never talk to the ring at the same time. Database reads skip that queue, so the page doesn't freeze while a sync runs.

Live readings stream to the page as they arrive, and there's a panel for sending any command by hand and watching the raw bytes come back. The server only listens on 127.0.0.1, so nothing leaves the Mac.

A card titled Send a command by hand with command, data and replies boxes and a Send button, above a log card listing timestamped lines for connecting, battery, a stopped live reading and tx and rx hex packets
PL 12Sending a command by hand. The log shows each 16 byte packet going out and the reply coming back. Shown with sample data from a stand-in ring.
The Ring control page on a near black background, showing 74 bpm with a pink line chart and the ring buttons below
PL 13The control page in dark mode during a live reading. Shown with sample data.
The Ring data report on a near black background, with six number cards and a pink heart rate line chart
PL 14The report in dark mode. Both pages follow the system setting. Shown with sample data.
A narrow view of the Ring control page showing a live reading of 98 percent with a red line chart and the ring and recording cards stacked below
PL 15The control page squeezed to phone width during a blood oxygen reading. It only runs on the Mac, so this is a narrow browser window. Shown with sample data.
A narrow view of the reading table with heart rate rows, followed by a card titled Keep it up to date by itself with Start and Stop buttons
PL 16The reading table at phone width, above the auto sync controls. Shown with sample data.
A narrow view of the Ring data report with number cards in two columns and small heart rate charts below
PL 17The report at phone width, with the number cards two across and the charts shrunk to fit. Shown with sample data.

Sheet 08 of 08/Next

What comes next

The ring also stores sleep, blood oxygen history, stress, HRV and temperature, and I haven't decoded any of those yet. The Gadgetbridge project has partly worked them out, so that's where I'd start. The iPhone app in Swift is milestone 6 in my notes, and I haven't started it yet.