The short version (30 seconds)
- I bought an inexpensive, screenless fitness band called the H59 Max and read its data over Bluetooth without the manufacturer's app.
- Claude Code, an AI coding agent, wrote and ran the scripts. I handled the band, approved anything that changed it, and kept it away from my WHOOP.
- After one night, the cheap band matched WHOOP on bedtime and resting heart rate. It missed most of the time I spent awake.
- Its HRV, stress, and blood oxygen numbers sat inside ranges its firmware is documented to fill with random values when a measurement fails, so I don't trust them yet.
Every fitness tracker shows you numbers. Heart rate, sleep stages, blood oxygen, a "stress" score. They all look equally confident on a phone screen. What I wanted to know is whether a cheap band is actually measuring those things, or just producing numbers that look convincing.
So I bought one, pulled its data off it myself, and lined up a night of it against the WHOOP I already wear. Some of its numbers held up surprisingly well. Others, it turned out, come from firmware that's documented to fill in a believable value when a measurement fails. Getting to that point took a few detours, so let's start at the beginning.
Why buy a cheap band when I already wear a WHOOP?
I want to build my own fitness app. Not a startup, just a companion app that keeps my health data on my own devices, in the spirit of NOOP, an open-source, offline app for WHOOP straps.
The WHOOP is the device I rely on every day, so it's the last thing I want to experiment on. A cheap band is the opposite: if I break something, I'm out the price of a cheap band. I picked up an H59 Max, a no-name band that measures heart rate, steps, and sleep and normally syncs to a phone app called QWatch Pro.
Before writing a single screen of an app, I needed answers to two questions. Can I get the data out without the manufacturer's app? And is the data any good?
How the work was split
I did this with Claude Code, an AI agent that can run commands on my Mac. "Agent" here means it doesn't just suggest code. It writes a script, runs it, reads the output, and decides what to try next.
The split looked like this. Claude Code wrote and ran every script in this post, and it did most of the protocol reading. I pressed the band's button when it needed waking up (more on that soon), wore both devices, and approved anything that would change the band's settings. When something failed, we debugged it together, and some of those failures were the agent's own bugs.
I also set one rule early: hands off my WHOOP. No connecting to it, no probing it, no disconnecting it from my phone. I made one narrow exception later, and it's covered below.
Finding the band, and why nothing showed up
Fitness bands talk to phones over Bluetooth Low Energy (BLE), a low-power version of Bluetooth built for small devices. A BLE device announces itself every so often with a short broadcast called an advertisement, and anything nearby can listen for it. So step one was simple: listen, and see what's in the room.
The first scan found nothing at all. Not the band, not even my iPhone sitting next to the Mac. The scan wasn't coming back empty. macOS was killing the process (exit code 134) before it could start. macOS asks for permission, app by app, before anything can use Bluetooth, and the shell Claude Code runs commands in belongs to a helper program that never got that permission. Granting Bluetooth to the Claude app didn't help, because the helper was the one asking.
The fix was to run the same script from the app's built-in terminal panel, which runs under a different parent process. It worked on the first try.
The first WHOOP device it found wasn't my strap. It was the WHOOP Wireless PowerPack, the clip-on battery that charges the strap. It answered a standard "device information" request with its model, its firmware version, and an 82% charge. That was the moment I set the hands-off rule. If the agent could stumble onto my WHOOP gear that easily, it needed a clear line.
The H59 showed up in a later scan, under a name like H59MAX_xxxx. It has no screen. Tapping it does nothing. Pressing its button makes a red light blink and wakes its Bluetooth for a short while. Most of my "it's not found" moments over the next day turned out to be "I didn't press the button."
Learning the band's language
Finding the band is one thing. Getting health data out of it means speaking its language.
Once you connect to a BLE device, it organizes what it offers into services, which are categories, and characteristics, which are individual slots you can read from, write to, or subscribe to for updates. Some services are standardized, like battery level or heart rate. Others are custom, and that's where budget bands usually keep everything interesting.
What happens behind those custom slots is decided by the band's firmware, the small program running on the band itself. To read the H59's data, I needed to know what its firmware expects to receive and what it sends back. Working that out from scratch would have taken weeks.
Luckily, other people had already done the hard part. The H59 speaks the same protocol as a family of budget bands, and two projects document it well. OpenWatch's protocol spec was written from a static analysis of the official app, and FiBand is a Python dashboard for this exact band. Neither has a license, so we used them as documentation and wrote our own code.
The band has two channels, one for small commands and one for bulk data:
| Channel | Used for | Format |
|---|---|---|
| Commands | Battery, settings, live measurements, most history | Fixed 16-byte frames: an opcode, a payload, and a last byte that is the sum of the others |
| Bulk | Sleep stages, hourly SpO2, firmware updates | Variable-length frames that start with 0xBC, with a CRC16 checksum |
A few terms in that table are worth unpacking. Every message is a short run of bytes, usually written in hexadecimal (base 16), so a byte looks like 5e instead of 94. The first byte is the opcode, a number that says which command this is: 03 means "battery level," and 69 means "start a heart rate measurement." The last byte is a checksum, a number computed from the other bytes so the receiver can tell whether the message arrived intact. The bulk channel uses a sturdier version called CRC16.
The first two commands were the real test. Battery came back at 94%. Heart rate took longer. The band streams an update every half second, and for the first 25 seconds it's only warming up its sensor. Then the beats per minute appear, and this reading came out at 48 bpm.
One reading takes about 28 seconds in total. That detail matters later, when it explains why this band doesn't watch your heart continuously.
For the curious: what those messages look like
Asking for the battery level, and the reply:
-> 03 00 00 00 00 00 00 00 00 00 00 00 00 00 00 03
<- 03 5e 00 00 00 00 00 00 00 00 00 00 00 00 00 61 0x5e = 94%Every command frame is 16 bytes. The first byte is the opcode (03 = battery), and the last is the sum of the others: 03 + 5e = 61.
A heart rate measurement streams frames like these. Bytes 6 and 7 change while the sensor warms up, then byte 3 carries the beats per minute:
<- 69 01 00 00 00 00 e5 04 00 00 00 00 00 00 00 53 warming up
<- 69 01 00 30 00 00 e5 04 00 00 00 00 00 00 00 83 0x30 = 48 bpmTakeaway: the band can be read without its app, and the first two commands worked exactly as documented.
An empty history, and a clock nobody set
With live readings working, the next step was history: everything the band records while you're not looking. Claude Code wrote a read-only script that asked the band for all of it: heart rate, steps, stress, HRV, sleep, and SpO2. Every command got a valid reply. Every reply said the same thing: no data.
The clue was in the settings. Automatic heart rate measurement every 5 minutes was switched on, but the daily goals were still factory defaults, which suggested no phone had ever set the band up. On this band, nothing gets recorded until something sets its clock, and the official app normally does that the first time it connects.
Setting the clock was the first command that would change the band, so I approved it explicitly. One command, with the date and time packed into the message, and the band answered with a list of the features it supports. Twelve minutes later, its history had heart rate samples at 18:10 and 18:15.
Takeaway: an empty history doesn't always mean a broken band. Sometimes it just doesn't know what time it is.
The live comparison that never happened
My original plan was more ambitious than comparing one night. I wanted both bands' heart rates on screen, side by side, updating every few seconds.
WHOOP has a Broadcast Heart Rate setting, meant for gym equipment. It makes the strap share its heart rate using the standard Bluetooth heart rate service, the same one any chest strap uses. Reading that broadcast was the one exception to my hands-off rule: listen only, send nothing, touch none of WHOOP's own services.
It never worked. Over several minutes of scanning, the strap showed up by name only a couple of times. Once I turned off my phone's Bluetooth, the Mac could connect, and the strap did offer the heart rate service. It just never sent a single reading, not in 20 seconds and not in 60. My best guess is that the broadcast setting reaches the strap through the phone, so with the phone's Bluetooth off, it never arrived. I couldn't confirm that.
One failure along the way was ours, not WHOOP's. A shared helper script read the band's name from the command line when it was imported. The side-by-side script used its first command-line argument for something else, a duration in seconds. So for three runs, it was patiently scanning for a device whose name started with 300. The fix was one line. The lesson is an old one that developers keep relearning: parse arguments in main(), not at import time.
In the end, we switched to WHOOP's data export, a spreadsheet you can request from the app. It doesn't touch the strap at all. A small script copies the band's history into a local SQLite database (a single file on my Mac), and another reads WHOOP's physiological_cycles.csv and lines the two up by night.
The surprise: numbers that fill in the blanks
Before getting to the night itself, here's the part that changed how I think about cheap bands. It's worth understanding two of the measurements first.
HRV, or heart rate variability, is how much the time between your heartbeats varies. A healthy, rested body tends to vary more, which is why recovery-focused trackers like WHOOP care about it so much. Real HRV needs the exact timing of each heartbeat and is usually reported in milliseconds. SpO2 is your blood oxygen saturation, the percentage of your blood's oxygen-carrying capacity that's in use. For most healthy people it sits in the mid to high 90s.
OpenWatch's spec describes what this firmware does when a measurement fails. It doesn't record "no reading." Instead:
| Measurement | What the firmware stores when a reading fails |
|---|---|
| HRV | A random value from 30 to 49. HRV is a single "score" byte here, not milliseconds |
| Stress | A random value from 30 to 49 |
| SpO2 | A random value from 93 to 99 |
| Blood pressure | Always synthetic. The stored "history" is really one heart rate byte |
That's documented behavior, from someone else's analysis of the firmware. What I saw on my own band is consistent with it. All 11 HRV scores and 17 of 21 stress values from my night landed inside the 30 to 49 range. Every hourly SpO2 reading was between 96 and 99, and the low and high values for each hour were identical, while WHOOP averaged 94.
To be fair to the band, that doesn't prove any particular reading was made up. A real HRV score can land between 30 and 49 too. The problem is that I can't tell the real readings from the filler, and a number I can't verify isn't one I want to chart. (I didn't test blood pressure at all. That row is from the spec alone.)
Takeaway: a number on a screen tells you nothing about whether a sensor actually measured it. If a band's values always land in a suspiciously tidy range, find out why.
Night one: the cheap band vs WHOOP
There was one more twist before the comparison: daylight saving time ended at 2 a.m. that very night.
When we set the band's clock on Saturday evening, it was 18:07 Central Daylight Time, which is UTC−5. The band stores a plain date and time with no time zone, and it doesn't adjust for DST. So when my phone and WHOOP fell back to Central Standard Time (UTC−6), the band kept counting on UTC−5. By morning, its clock was exactly one hour ahead.
WHOOP's export records each row's UTC offset (UTC-06:00 for this night), so the comparison script converts WHOOP's times onto the band's fixed UTC−5 clock before lining them up. For example, WHOOP's wake time of 07:46 becomes 08:46 on the band's clock, and the band's own wake time of 09:17 is 08:17 local. The table below shows everything back in local time.
A quick guide to the sleep rows. Trackers estimate sleep stages from heart rate and movement. Light sleep is most of the night. Deep sleep is the restorative stage. REM is when most dreaming happens. Awake is time in bed when you're not asleep. None of these devices measure brain activity, which is what a sleep lab uses, so every stage below is an estimate.
| Night ending Sunday | WHOOP | H59 | |
|---|---|---|---|
| Fell asleep | 21:06 | 21:03 | Within 3 minutes |
| Woke up | 07:46 | 08:17 | H59 31 minutes late |
| Asleep | 9h09 | 10h46 | H59 +1h37 |
| Light | 5h07 | 7h11 | Much higher |
| Deep | 1h44 | 2h00 | Close |
| REM | 2h18 | 1h35 | Lower |
| Awake | 1h31 | 0h28 | Missed most of it |
| Resting heart rate | 58 | 58 | Exact |
| HRV | 55 ms | 41 (score) | Not comparable |
| SpO2 | 94% | 98% | Suspect |
- Light
- Deep
- REM
- Awake
Light 5h07 · Deep 1h44 · REM 2h18 · Awake 1h31
Light 7h11 · Deep 2h00 · REM 1h35 · Awake 0h28
One important caveat before reading too much into it: WHOOP is my reference here, not the truth. It's a consumer wearable too, not a medical device. When the two disagree, all I know for certain is that they disagree.
What the cheap band got right
Bedtime matched within three minutes, and deep sleep came out close (2h00 against 1h44).
Resting heart rate matched exactly, though it took one correction. The first version of our script averaged the band's three lowest readings and got 51. That turned out to be the wrong question. The band's readings are noisy: between one 5-minute reading and the next, 19 of 127 changes were jumps of more than 15 bpm. So the lowest readings are mostly noise. WHOOP takes resting heart rate from deep sleep. When the script averaged the band's readings during the deep sleep periods the band reported itself, it got 58, the same as WHOOP.
I should be honest about what that match means. I changed the calculation after seeing WHOOP's number, and the second method happened to agree. The new method is a fairer comparison, but picking it after the fact makes the match look stronger than one night can support. It's a promising sign, not evidence that the band's resting heart rate is reliably accurate.
What it got wrong
Time awake is where the band fell down. It counted 28 minutes awake against WHOOP's 1h31, and most of that difference became "light sleep." It also thinks I got up 31 minutes later than WHOOP does. That's typical of bands that judge sleep mostly from movement. Lying still awake looks a lot like sleeping.
HRV isn't comparable at all. WHOOP reports 55 milliseconds, while the band reports a score of 41 on its own scale, and that score sits inside the filler range from the previous section.
SpO2 read 98% against WHOOP's 94%, with the same tidy-range problem.
And all of this is one night. Sleep tracking varies a lot from night to night, so a single night can hint at patterns but can't establish accuracy.
Why the hardware matters more than the app
The biggest difference between these two devices isn't the app. It's how often they look.
WHOOP uses frequent optical measurements and processing to estimate heart rate and HRV. The H59, by comparison, records periodic heart rate measurements, one every 5 minutes, and as we saw, each takes about 28 seconds. In between, it isn't watching. My guess is that this is mostly about battery life. The band also doesn't expose the beat-to-beat timing needed to calculate conventional HRV, so no app, including mine, can compute it.
That leaves a fairly clear line. Bedtime, resting heart rate trends, and steps are fair game. HRV, stress, and SpO2 should be hidden or clearly labeled as unverified.
What I learned, and what's next
So, can a budget band give you genuinely useful health information? Partly. Its timing and resting heart rate held up against WHOOP on night one. Its sleep stages didn't, especially the time I spent awake. And three of its headline measurements, HRV, stress, and SpO2, sat inside ranges that the firmware is documented to fill with random values when a reading fails. They might be real. I just can't tell.
That was the biggest surprise. Going in, I expected a cheap sensor to be noisy. I didn't expect firmware that's documented to invent a plausible number rather than admit it missed one.
Three things I'd pass on to anyone trying this:
- Check whether a number was measured before you trust it. A tidy range is a reason to dig, not to relax.
- Decide how you'll compare before you look at the reference. My resting heart rate match is encouraging, but I picked the method after seeing WHOOP's answer.
- Give the agent a line it can't cross. Claude Code did the scanning, scripting, and decoding much faster than I would have by hand, but the judgment calls stayed with me, and "hands off the WHOOP" kept a day of Bluetooth poking away from the device I rely on.
All of this shapes the app I'm building. It will show bedtime, steps, and resting heart rate trends, and either hide the band's HRV, stress, and SpO2 or clearly mark them as unverified. It will also be built so a better sensor, one that shares beat-to-beat timing, can slot in later. First, though, a week of nights instead of one, with the comparison method fixed in advance. I want to know whether night one was typical, or just the band on a good night.
WHOOP is a trademark of WHOOP, Inc. This post isn't affiliated with or endorsed by WHOOP or the H59's maker. It reads only my own devices and data.
Glossary
- BLE (Bluetooth Low Energy): the low-power Bluetooth that wearables use. Devices advertise their presence in short bursts, and a phone or computer connects to read data.
- Advertisement: the short broadcast a BLE device sends so nearby devices know it's there.
- Service / characteristic: a BLE device groups its data into services, and each service has characteristics you can read, write, or subscribe to for updates.
- Firmware: the small program that runs on the band itself and decides what it measures and sends.
- Hexadecimal: base-16 numbers, where each byte is written as two characters from
00toff. - Opcode: the first byte of a command, which tells the band what you're asking for.
- Checksum / CRC16: a number computed from a message's bytes so the receiver can tell if the message arrived intact. CRC16 is a sturdier kind.
- HRV (heart rate variability): how much the time between heartbeats varies. Real HRV needs beat-to-beat timing, usually reported in milliseconds.
- Beat-to-beat (R-R) intervals: the time between each pair of heartbeats. WHOOP-style recovery scores are built on these.
- SpO2: blood oxygen saturation, as a percentage.
- Sleep stages: light, deep, and REM sleep, which wearables estimate from heart rate and movement.
- DST: daylight saving time.
- SQLite: a database stored in a single local file.