Hacking Bluetooth Sports Tech for Fun and Profit — Part 9: Decoding the Record, Byte by Byte
Part 7 left the MG's sync buffer wide open and its contents shut: 109-byte per-second records and 73-byte raw-PPG bursts, with heart rate at one known offset and everything else a wall of bytes. This is the slow work of turning that wall into a field map, and the traps that made it slow.
Part 6 mapped the WHOOP 4.0's 93-byte overnight record in an afternoon, because on Harvard the records came back in a form the phone could read directly: the offsets were there for the taking. Part 7 found the WHOOP MG (codename Maverick) storing something else entirely: R20 records in two inner types, K=18 at 109 bytes per second and K=26 at 73 bytes for the raw optical bursts. The MG gave up nothing so easily. The phone collects the K=18 records, reads one byte to decide whether the strap had a sensor lock, and forwards an opaque blob to WHOOP's cloud, which does the actual parsing. With nothing readable in transit, every offset had to be earned from the captures themselves.
What follows is the earned map, every offset pinned against the decoder that is now the single source of truth, with a few cautionary tales about offsets that looked obvious and were wrong.
The shared header
Both record types open the same way, which is the one piece of luck. The first ten bytes of the body are common to K=18 and K=26:
body[0:4] counter u32 LE monotonic +1 per record, resets on reboot
body[4:8] ts_unix u32 LE Unix seconds, +1/s
body[8:10] session_marker u16 LE stable within a boot (see the naming trap below)
Past the header the two diverge completely. K=18 is a metrics record: one heart rate, one motion reading, a clutch of sensor-fusion state, and a sleep flag, all per second. K=26 is a sample carrier: 24 raw photodiode readings and almost nothing else.
K=18, the per-second metrics record
Heart rate sits at body[11], a single unsigned byte of bpm, zero when the strap has no optical lock. This is the strap's own firmware estimate, not ours: a non-zero value means the strap had a lock, zero means it didn't. It reads as a clean physiological HR when it is non-zero, and the off-wrist gaps line up where you would expect them. That dual role, value when locked and validity flag when not, is what makes it the obvious anchor for everything else.
Motion lives a long way off at body[104], and here is the first polarity trap that Part 7 warned about: the byte runs inverse to Harvard. High means clean and still; low means movement. Port the Harvard rest gate (motion ≤ 0x40 is rest) straight across and you will gate on the wrong records.
The sleep flag is the headline of the extended decode. body[70] is a single packed state byte; bits 5–4 carry an on-wire sleep state, the SLEEPFLAG that the strap's own state machine maintains. It decodes to a four-value enum, {0: STILL, 1: WAKE, 2: SLEEP, 3: UP}, though that mapping is provisional: the STILL/SLEEP boundary is decisive from the data, but WAKE versus UP cannot be told apart from passive captures alone, so the decoder declines to assert it. The same byte packs an AGC gain mode in bits 3–2 and a low bit whose "AFE saturated" label was later refuted by a corpus audit; more on that habit below.
SpO2 at body[71] deserves its own post and gets one later in this series, because it is not a simple percentage. It is a tri-mode encoding: real percentages in one range, a family of bit-7-set saturation sentinels, and a family of low-bit diagnostic codes, far more than the four states a naïve parser assumes. The dedicated SpO2 post later in the series breaks the families down. An early parser that masked on bit 7 read the diagnostic code 0x08 as "SpO2 = 8 %" on hundreds of records. The lesson recurs: a byte that holds a number most of the time does not only hold numbers.
Skin temperature is an int16 LE at body[62:64], scaled by 0.01 to give degrees Celsius, traceable to the ams AS6221 sensor named in Part 7. An AFE status word sits at body[91:93] as a u16 LE, with bit 15 flagging unreadable SpO2, though that word was retired by a firmware update mid-research and now reads zero. There is a secondary heart-rate byte at body[26], which tracks the primary at correlation +0.94 and is held to zero whenever body[11] is zero.
The body[96] cautionary tale
For a while body[96] looked like a second heart rate. It sits in a plausible numeric range and changes smoothly, second to second, the way a heart rate should. The label "secondary HR" went onto it early, and stuck for a while.
It is not heart rate. Across tens of thousands of paired samples its correlation with the real HR at body[11] is approximately zero, slightly negative. Over one night of sleep it never dropped below 139 while the genuine HR sat at 40–53 bpm. What it actually does is drift like an automatic-gain-control readback, 181 down to 127 over three minutes during a controlled test, which is the signature of the optical front-end adjusting its gain, not of a pulse. The field was renamed to an AGC-readout label to stop it misleading anyone.
This is the whole discipline in one byte. A plausible label is not a verified one. The fix was not cleverness; it was the boring step of correlating the candidate against the known-good value across a large corpus and watching the relationship fail to appear.
K=26, the raw-PPG burst
The burst record is almost all payload. After the same ten-byte header and a two-byte per-burst index at body[10:12], the photodiode samples run from body[16:64]: 24 int16 LE values per record. Records arrive once a second within a burst, so the effective wire rate is 24 Hz, confirmed empirically across 2,332 bursts where the sample count equalled duration in seconds times 24 with no exceptions.
That 24 Hz is low for PPG, and it sets a hard floor on what HRV you can recover: a 41.7 ms sample period means beat timing is quantised to roughly ±21 ms, which alone puts an RMSSD floor around 30 ms even with flawless peak detection. Upsampling recovers phase but not information. That floor is a constraint the next stage of this work has to design around, not a bug to fix.
The byte signature is what pinned the sample window. Even bytes across body[16:64] take the full 256-value range you expect of int16 low bytes; odd bytes are narrow, as MSBs should be. A window shifted even two bytes earlier breaks that pattern, and a misleading single-burst autocorrelation test once favoured the wrong window before the byte-signature check overruled it. Structure in the bytes themselves beat a clever statistic.
The off-by-eleven trap
The nastiest trap is not in any single field; it is in which buffer you are counting from. An early field map had the SpO2 byte off by eleven, and the error was a quiet one because the number it produced still looked like a plausible offset.
The cause is the framing. Each record arrives inside a frame that carries an 8-byte MAVERICK header plus a 3-byte inner prefix before the body begins, so a position measured from the start of the frame is eleven bytes ahead of the same position measured from the start of the body. Take the SpO2 field at frame offset 0x52, read it as body[0x52], and you land on the wrong field; subtract eleven and you land on body[71], the SpO2 byte we already had from empirical work. The first map got this wrong, placing SpO2 on what is a skin-temperature byte, and the correction only stuck once it was confirmed against multi-byte empirical anchors: the counter u32 at body[0:4], the constant zero at body[10] across millions of records. The rule that came out of it: never trust a single-byte offset until a multi-byte anchor confirms which buffer you are counting from.
A trap that hides as a feature: byte order
body[29:45] is a sixteen-byte block of sensor-fusion state, and for a long time it was filed as "four u32 little-endian values, opaque, decodes to noise." Read that way it is noise, and the noise looked like proof the block was cloud-only payload.
It was a byte-order error. Three of the four channels at body[33:45] are float32 big-endian. Read as >f they land cleanly in physical ranges and the first channel tracks heart rate at correlation +0.42; read little-endian the same bytes are random rubbish at +0.03. The first group at body[29:33] is a different type again, a motion-coupled integer. These are internal AGC and quality channels, correlations in the 0.4–0.6 band, so they do not replace the canonical HR or motion bytes, but they are signal, not noise. The adjudicating measurement was correlation against the known HR under each byte-order hypothesis: one reading made physical sense, the other did not.
The forensic-raw rule
None of this re-analysis would have been possible if the capture path had been clever. The design rule, carried from Part 7's tooling through to the iOS capture app, is that the strap's bytes are stored as they arrive: untouched, unfiltered, in full. Every interpretation in this post was run after the fact against stored raw bytes: the byte-order correction, the body[96] refutation, the SpO2 sentinel families, the reserved-region closure that proved eighteen bytes are always zero across nearly two million records. Filtering happens at read time, never at write time. Decide what a byte means at capture and you can never revisit it; store the raw byte and every future correction is still on the table. Given how many of the "obvious" readings here turned out wrong, that is the only safe way to build.
Where this goes next
The map is enough to act on now: HR at body[11], motion (inverted) at body[104], sleep state at body[70], skin temperature at body[62:64], SpO2's tri-mode byte at body[71], and the 24 Hz raw-PPG stream in the K=26 bursts. Some fields are HIGH-confidence and load-bearing; others are provisional and labelled as such, which is the honest state of a map built entirely from the outside.
The byte that matters most for what comes next is the raw-PPG stream. Twenty-four photodiode samples a second is a coarse signal with a real quantisation floor, and turning it into a heart-rate-variability number you would actually trust against a reference is a problem of its own. That is the next piece of work.
This is part 9 of "Hacking Bluetooth Sports Tech for Fun and Profit" — a series documenting BLE reverse-engineering of fitness wearables. Tools: VESTIGATOR (Python + bleak), Pi Lab (FastAPI + BlueZ D-Bus). All research conducted on personally-owned devices.
Comments ()