Hacking Bluetooth Sports Tech for Fun and Profit — Part 7: WHOOP 5 & MG — Same Handshake, New Walls

The newer straps speak almost the same language as the WHOOP 4 we researched in Parts 4–6. Almost. The handshake byte changed, the framing grew, and the one command that would have let me read the sensor live turns out to do nothing at all.


Parts 4-6 took apart the WHOOP 4.0, the strap whose internal codename is Harvard. We found its service, learned its packet types, pulled the overnight buffer, and computed HRV from the per-second records inside it. Then I moved to the newer hardware: the WHOOP 5.0 and the WHOOP MG. From the outside they look like the same protocol with a fresh coat of paint. From the inside they are the same protocol with the doors welded shut.

This post is the survey of those walls: what carried over from Harvard, what changed, and the finding that turns the sync buffer from one option into the only one.

Two straps, one stack

The newer line splits into two codenames. Goose is the WHOOP 5.0 standard strap. Maverick is the WHOOP MG, the medical-grade variant that adds a single-lead ECG and skin-temperature sensing on top of the optical sensor. The split between them is a sensor-fit distinction, not a firmware one: they share the entire BLE stack, the same service UUIDs, the same command enum, the same dispatch. The MG firmware is byte-identical to Goose's, with the product difference selected in software via config keys. Anything that works on Maverick at the BLE level works on Goose; only the ECG- and skin-temperature-specific paths are Maverick-only.

Both sit on the fd4b0001 service family, where Harvard sat on 61080001. That single UUID change is the cleanest way to tell which generation you are talking to before you have exchanged a byte.

On the hardware side the MG is a tidy stack: an Ambiq Apollo4 Blue MCU (Cortex-M4F with a BLE 5.0 radio), a Maxim MAX86176 optical front-end driving the photodiodes, a TDK ICM45686 six-axis IMU, and an ams AS6221 digital skin-temperature sensor. Names worth knowing, because every byte we decode later traces back to one of them.

What carried over

The frame grammar is the same one Part 4 documented. Commands still go out as type 0x23 and responses come back as 0x24. This matters because earlier community work, the whoomp project among it, listed Variant B as using 0x72/0x73. Part 4 established that those were never wire bytes, just a red herring carried forward from the earlier write-ups. Goose and Maverick put 0x23/0x24 on the wire exactly as Harvard does. Same 75-command enum, same connection handler underneath.

The historical-sync commands carried over too. The trio from Part 6 (0x16 to request history, 0x17 to acknowledge and advance the read pointer, 0x19 FORCE_TRIM to delete) all exist on the newer straps with the same meanings. If you have read Part 6, you already know not to send that last one until the strap has said it is finished.

What changed

Three things differ enough to cause issue if you assume the layouts match.

The handshake byte. On Harvard the opening "hello" was command 0x23, the same byte as a generic command frame. On current MG firmware the hello is its own command, 0x91. In the app's command enum, GET_HELLO for the newer straps carries the value 145, which is 0x91; the old GET_HELLO_HARVARD carries 35, which is 0x23. Send the Harvard hello to a Maverick and you are speaking last year's dialect.

The framing header. Harvard used a compact 4-byte header: a 0xAA start byte, a two-byte little-endian length, and a one-byte CRC8. Variant B widens this to eight bytes and drops the CRC8 entirely:

[0xAA] [0x01] [len LE 2B] [src] [dst] [b6] [b7] [Type] [Seq] [Cmd] [data...] [CRC32 LE 4B]

src and dst are routing bytes, (0x00, 0x01) phone-to-strap and (0x01, 0x00) the other way. b6 b7 is a two-byte field the app recomputes per length; the strap does not validate it, so zeroes work for arbitrary commands. The trailing CRC32 stayed; the CRC8 went. None of this is hard once you know the shape, and a Harvard parser pointed at a Maverick frame reads garbage from byte two onward.

The connection ritual. Two smaller differences will disrupt a session before you have sent a real command. First, the notification subscriptions have to be enabled in a fixed order: events, then memfault, then data, then the command channel. Subscribe to the command channel first and the strap quietly ignores your opening write. (Again, ask me how I know...) Second, the app fires a five-command prelude on connect (the 0x91 hello, an undecoded read, a data-range query, a haptics buzz, and an alarm-time read) before it does anything useful. The haptics command in the middle reads oddly the first time you watch it on a capture: the strap gives a little vibrate on connect, and the byte that triggers it is not the Harvard haptics command but a Maverick-specific one. Battery, meanwhile, is never polled during boot; the strap pushes it as a spontaneous event on the event channel.

The record codec. Harvard's buffer was 93-byte V24 records, one per second, the layout Part 6 mapped. Maverick stores something different: R20 records in two inner types. K=18 is the per-second metrics record at 109 bytes, with firmware heart rate at body[11]. K=26 is a raw-PPG burst at 73 bytes, carrying 24 photodiode samples per record at a 24 Hz wire rate (verified empirically across 2,332 bursts: every burst satisfies n_samples == duration_s × 24 exactly). Several polarities are inverted relative to Harvard, the rest-versus-motion gate among them, which is its own trap for anyone porting Harvard offsets across. Decoding these byte by byte is a post of its own; here it is enough to know the codec is not Harvard's.

The delivery channel

Here is the part that surprised me. I expected the per-second heart rate to arrive on the standard Bluetooth Heart Rate Service, the way a chest strap broadcasts it, with the raw optical bursts on some separate research channel, but neither are true.

Instead, everything is transmitted on one channel. Per-second metrics and 24 Hz raw-PPG bursts both arrive on the Variant B data characteristic (fd4b0005-...) as historical-replay packets of type 0x2F, with K=18 and K=26 as the inner record types. The standard Heart Rate Service is not used for this at all. There is no live feed to subscribe to: the strap hands you its recorded history, and history is where the heart rate lives alongside the optical data. The same 0x2F replay mechanism Part 6 used on Harvard is the carrier here, just wrapped around a different codec.

The blocker: there is no live optical

The newer straps expose a command called ENABLE_OPTICAL_DATA. Its value is 107, which is 0x6B. On paper this is the command you want: switch on the raw optical front-end and stream photoplethysmography live, no waiting for a buffer download. The app even contains the code to send it.

It does nothing, and appears to be a backwards-compatability piece.

I ran a series of experiments to determine whether 0x6B was a runtime gate (a feature flag switched off, ignored until enabled) or something more permanent. It is more permanent: on current MG firmware the command simply isn't implemented. It is not refused; it is not recognised. It falls through and is silently discarded. A later, separate investigation reached the same conclusion independently, with a high degree of confidence: the app can emit 0x6B when a server-side flag is set, and the strap drops it on the floor every time.
What matters is the consequence: on Harvard you could at least imagine a live path. On Maverick and Goose there is no live raw-optical stream to fall back on, because the command that would start one was never finished. That leaves exactly one route to the sensor: the sync buffer from Part 6. Everything else in this arc, the HRV, the blood oxygen, the skin temperature, the sleep scoring, comes out of that one buffer or not at all.

Where do we go from here?

So far, we've mapped the boundaries: the fd4b0001 family, the 0x91 hello, the eight-byte frame, the single 0x2F replay channel, and a live-optical command that exists in the app and not in the strap. What is still unclear is the buffer's contents. The K=18 and K=26 records are 109 and 73 bytes of mostly-undocumented territory, with heart rate at a known offset and almost everything else to be earned one verified byte at a time.

Decoding what is actually in those MG records, byte by byte, is where we go from here.


This is part 7 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.