Hacking Bluetooth Sports Tech for Fun and Profit — Part 2: Raw ECG From an £80 Chest Strap

Reverse-engineering the Wahoo TRACKR HR — no documentation, no SDK, no authentication. Just a BLE connection and methodical curiosity.


The Wahoo TRACKR HR is a rechargeable chest strap heart rate monitor. It costs around £80, it supports Bluetooth and ANT+, and — as I discovered — it will happily stream its raw ECG waveform to anyone who connects and asks politely.

This wasn't documented anywhere. Wahoo doesn't publish a developer SDK for the TRACKR. There's no API reference, no protocol guide, no GitHub repo. Everything in this post was discovered through blind probing: connecting to the device, mapping its BLE surface, writing bytes to its proprietary characteristics, and observing what came back.

The GATT surface

A BLE device exposes its capabilities through GATT (Generic Attribute Profile) — a hierarchy of services and characteristics, each identified by a UUID. When you connect to the TRACKR, here's what you find:

Service UUID Status
Heart Rate 0x180D Standard, works immediately
Battery 0x180F Standard, readable
Device Information 0x180A Standard, readable
Proprietary a026ee01-0a7d-4ab3-97fa-f1500f9feb8b Three characteristics

The standard services are unremarkable. The Heart Rate service streams BPM at ~1 Hz, the Battery service reports charge level, and Device Information confirms the manufacturer ("Wahoo Fitness"), model, serial, and firmware version.

The interesting part is that fourth service. Our tool initially labelled it "FORM Goggles Service" — an early, wrong guess baked into our own UUID table. When we later found real FORM goggles, they turned out to use a completely different base (00010001-8589-...) and a protobuf-based protocol, with no relationship to the Wahoo's a026e services at all. The label was our mistake, not a shared lineage; the table now reads "Wahoo Proprietary (mislabelled FORM)".

The proprietary service has three characteristics:

Characteristic Properties Role
a026e002 write-without-response, notify Command/response channel (CMD)
a026e004 notify Data channel (unused)
a026e03b write-without-response, notify Sensor data channel (SEC)

Phase 1: command channel probing

The CMD characteristic (a026e002) accepts writes and can send notifications. The first step was to subscribe to notifications and then write every single byte from 0x00 to 0xFF, observing what came back.

The device responded to every command with a consistent 3-byte format:

01 XX SS

Where XX echoes the command byte and SS is a status code:

Status Meaning Prevalence
0x05 No session / not authenticated Most commands
0x01 Unknown command 0x60, 0x80
0x02 Not supported 0x47, 0x48

One command stood out: 0x20 returned a 16-byte response instead of 3:

01 20 01 3a 00 01 03 3a 00 ff ff 0a 05 01 03 04

Status 0x01 (not 0x05) — this command succeeds without authentication. It's a capability descriptor, likely describing the device's supported features and configuration. The 0x05 status on everything else means "we understand the command, but you haven't established a session." The device knows what you want; it just hasn't decided to trust you yet.

Commands 0x03 through 0x07 were also interesting — they accepted writes silently (no response on CMD), but as we'd discover later, they were producing output on a different channel entirely.

Phase 2: the SEC channel

The SEC characteristic (a026e03b) is where things got exciting. Writing 0x0a to it triggered a flood of notifications — hundreds of frames containing structured binary data.

The frame format:

type(1) ch(1) seq(1) payload(variable)
  • Type 0x04: data frame (20 bytes maximum — the BLE MTU constraint)
  • Type 0x05: end frame (variable length, marks the last frame in a block)
  • Type 0x0a: acknowledgement

Frames arrive in blocks, each beginning with a seq=0 header frame and ending with a type 0x05 end frame. The header carries metadata:

block_type(2 LE) block_id(2 LE) sample_counter(4 LE) hr_tag(1) data...

The block_type is always 0x0034 for sensor data. The block_id increments sequentially. And hr_tag? That's the instantaneous heart rate in BPM at the moment the block was captured. We confirmed this by comparing it against the standard HR stream — the values matched exactly.

Each 0x0034 block contains 131 to 135 bytes of sensor payload, depending on the length of the end frame.

A single 0x0a command dumps the device's entire circular buffer — roughly 260 blocks spanning 10 minutes of historical data. The device sends it all in one burst, taking about 60 seconds to transfer. It's not real-time streaming; it's a buffer dump of recent history.

Phase 3: the metadata blocks

Interspersed with the sensor blocks are 0x0056 metadata blocks that describe the data format:

version(1) config(1) num_channels(1) num_channels(1)
[sample_rate(2 LE)] × num_channels
[adc_range(2 LE)] × num_channels

The metadata told us:

  • Sample rate: 18 Hz per channel
  • ADC range: 4092 (12-bit resolution)
  • Channels: 2 (the channel count is a per-block metadata field; the ECG reconstruction below uses the two interleaved channels)

Phase 4: cracking the encoding

This was the hard part. The raw sensor bytes don't divide cleanly by 2 (ruling out int16 samples) and the byte distribution is heavily skewed toward small values: not what you'd expect from raw waveform data interpreted as int16.

The breakthrough came from noticing that block sizes of 131, 133, and 135 bytes are all odd numbers. Nothing divides evenly into pairs. But 135 divides by 3 exactly, and 12-bit ADC data packs two samples into every 3 bytes.

The encoding is packed 12-bit little-endian:

sample_0 = byte[0] | ((byte[1] & 0x0F) << 8)
sample_1 = (byte[1] >> 4) | (byte[2] << 4)

Each 3 bytes yield two 12-bit unsigned values in the range 0–4095, which is a perfect match for the 4092 ADC range declared in the metadata.

Phase 5: deinterleaving

The samples alternate between two channels. Deinterleaving within each block (not across blocks, as block sizes vary) produces two clean signals:

  • Channel 0 (even samples): a periodic waveform with sharp peaks — the ECG lead
  • Channel 1 (odd samples): mostly baseline with corresponding heartbeat spikes — a reference or impedance channel

The result

Here's 15 seconds of raw ECG captured from the Wahoo TRACKR HR, decoded and plotted:

Wahoo TRACKR HR — Raw ECG Waveform

Top panel: the full ECG waveform from channel 0, showing clear R-peaks at a steady rhythm. Second panel: the reference channel. Third panel: a 10-second zoom showing individual QRS complexes. Bottom panel: the instantaneous heart rate from block headers, oscillating between 58 and 60 BPM.

The R-peaks are unmistakable. At 18 Hz per channel, the resolution is too low for clinical-grade ECG morphology analysis (medical devices sample at 250–500 Hz), but it's more than sufficient for:

  • R-R interval extraction with finer precision than the 1 Hz HR updates
  • Beat-by-beat HRV (heart rate variability) metrics like RMSSD
  • Signal quality assessment
  • Visual confirmation that the strap has good skin contact

The protocol summary

For anyone who wants to replicate this:

  1. Connect to the TRACKR HR via BLE (no pairing required)
  2. Subscribe to notifications on a026e03b (SEC channel)
  3. Write 0x0a to a026e03b
  4. Receive ~260 blocks of sensor data over ~60 seconds
  5. Decode each 0x0034 block:
    • Skip the 9-byte header (block_type + block_id + sample_counter + hr_tag)
    • Decode remaining bytes as packed 12-bit LE (3 bytes → 2 samples)
    • Deinterleave: even indices = ECG, odd indices = reference
  6. The hr_tag byte in each block header is the instantaneous heart rate in BPM

No authentication, no bonding, no handshake: one can connect and write one byte.

What this means

The Wahoo TRACKR HR is, as far as I can tell, the most open proprietary BLE sensor data source among the consumer chest straps I've tested. The Polar H10 streams higher-quality ECG (130 Hz, int24 microvolts) through its documented PMD service, but even that requires knowledge of the correct start command. The Wahoo just needs 0x0a.

Whether Wahoo intended this level of openness is an interesting question. The 0x05 status on most CMD channel commands suggests there is a session establishment protocol — the device knows about authentication, it's just not enforcing it on the data dump. This could be a deliberate design choice (the Wahoo app doesn't need raw ECG, so why gate it?) or an oversight that a firmware update might close.

Either way, right now, there's an £80 chest strap that will hand you its raw ECG waveform to anyone within Bluetooth range who knows the magic byte. And now you know the magic byte.


The code for this project is available in the VESTIGATOR toolkit. The next post in this series will cover the Polar H10's PMD service and its simultaneous ECG + accelerometer streaming capability.