Bluetooth heart-rate lab: see every byte your strap sends
Whether a Bluetooth heart-rate device can give you HRV is written in its bytes: each Heart Rate Measurement (0x2A37) notification carries RR intervals only when bit 4 of its Flags octet is set, and without RR intervals there is no beat-to-beat timing to compute HRV from. This page connects a strap or a broadcasting watch over Web Bluetooth, decodes every notification against the Bluetooth SIG’s published structure, and tells you which kind your device is. Browsers without Web Bluetooth can replay a synthetic stream instead. The bytes never leave the tab.
Checking whether this browser exposes Web Bluetooth…
Nothing leaves your device: the bytes are decoded in this tab, held in memory, and gone when you close it. The page makes no request with them.
Decode any 0x2A37 value
Paste the bytes of one notification in hex, from nRF Connect, a log or a capture. The default is the value behind the Web Bluetooth specification’s own example.
FlagsHeart rate valueEnergy ExpendedRR-intervalnot described
| bit | value | the SIG’s description |
|---|---|---|
| 0 | 0 | Heart Rate Value Format: 0 = Heart Rate Value Format is set to uint8 1 = Heart Rate Value Format is set to uint16 |
| 1 | 1 | Sensor Contact detected |
| 2 | 1 | Sensor Contact Supported |
| 3 | 1 | Energy Expended present |
| 4 | 1 | RR-Interval present |
| 5–7 | 000 | Reserved for Future Use |
| field | type | why | offset | bytes | value |
|---|---|---|---|---|---|
| Heart Rate Measurement Value (8 bit resolution) | uint8 | present: Present if bit 0 of Flags field is set to 0 | 1 | 46 | 70 period (beats per minute) |
| Heart Rate Measurement Value (16 bit resolution) | uint16 | absent: Present if bit 0 of Flags field is set to 1 | — | — | — |
| Energy Expended | uint16 | present: Present if bit 3 of Flags field is set to 1 | 2 | EE 02 | 750 energy (joule) |
| RR-interval | uint16[n] | present: Present if bit 4 of Flags field is set to 1 | 4 | 7A 03 66 03 | 890, 870 × 1/1024 s = 869.1, 849.6 ms |
Sensor contact: detected
The structure it decodes against
The decoder has no packet layout of its own. It reads the Bluetooth SIG’s GATT Specification Supplement file for Heart Rate Measurement, gss/org.bluetooth.characteristic.heart_rate_measurement.yaml at commit 408f4a0 of the SIG’s public repository (read 2026-10-17), from this site’s generated copy, and builds the decoder from it. Every field after Flags is conditional:
| Field | Type | Size | Condition | Unit |
|---|---|---|---|---|
| Flags | boolean[8] | 1 | always | — |
| Heart Rate Measurement Value (8 bit resolution) | uint8 | 0 or 1 | Present if bit 0 of Flags field is set to 0 | period (beats per minute) |
| Heart Rate Measurement Value (16 bit resolution) | uint16 | 0 or 2 | Present if bit 0 of Flags field is set to 1 | period (beats per minute) |
| Energy Expended | uint16 | 0 or 2 | Present if bit 3 of Flags field is set to 1 | energy (joule) |
| RR-interval | uint16[n] | 0 or n*2 | Present if bit 4 of Flags field is set to 1 | 1/1024 s |
The Flags bits, as the SIG lists them: bit 0, Heart Rate Value Format:
; bit 1, Sensor Contact detected
; bit 2, Sensor Contact Supported
; bit 3, Energy Expended present
; bit 4, RR-Interval present
; bit 5–7, Reserved for Future Use
. Bits 2 and 1 carry no field of their own: the lab reads them as “contact not supported” when bit 2 is clear, and otherwise as detected or not detected. The full page for the characteristic, with the SIG’s notice, is Heart Rate Measurement (0x2A37); Body Sensor Location (0x2A38), which the lab reads once after connecting, is on the Bluetooth GATT reference.
Why RR intervals decide HRV
The SIG defines the field plainly: The RR-Interval value represents the time between two R-Wave detections.
and Each RR-Interval value is represented by a uint16 with 1/1024 second as the unit.
HRV is a statistic over exactly those beat-to-beat times. A heart-rate value is one number in beats per minute per notification; the timing of the individual beats is not in it, and no amount of processing on the app side puts it back. That is why the verdict above is a yes or a no: a device that never sets bit 4 gives you heart rate, and only heart rate.
A notification can carry several RR values or none. The SIG: The number of RR-Interval sub-fields present is determined by a combination of the overall length of the characteristic and whether or not the characteristic contains the Energy Expended field.
The lab therefore counts the octets left after the fields the flags announce, two per value, and reports an odd one left over instead of guessing at it.
The RMSSD the lab shows is the root mean square of successive differences between RR intervals, computed only within runs of notifications that all carried RR values. It is there so you can see the arithmetic work on your own data, labeled a demo, and it is not a medical or training measurement: there is no artifact or ectopic-beat correction, no minimum recording length and no baseline. What HRV is, and how the platforms store it, is covered in what is HRV, how to get HRV data from an API and Health Connect’s HeartRateVariabilityRmssdRecord.
Where the decoder reads past the SIG’s file
- Byte order. The structure gives sizes, not byte order. The lab reads multi-octet fields little-endian, as the Web Bluetooth specification’s heart-rate example does (
result.heartRate = data.getUint16(index, /*littleEndian=*/true);, read 2026-10-31). The default value in the paste box,1E 46 EE 02 7A 03 66 03, decodes to that example’s published result: 70 bpm, contact detected, Energy Expended 750, RR values 890 and 870. - Energy Expended’s unit. The SIG’s file gives
org.bluetooth.unit.energy.joule(energy (joule)), and the lab shows that. The same Web Bluetooth example comments the value asenergyExpended: 750, // Meaning 750kJ.. The two sources disagree by a factor of a thousand; we report the disagreement rather than pick one. - Battery Level (0x2A19). Outside this site’s Bluetooth dataset, so it was read on 2026-10-31 from the SIG’s own
gss/org.bluetooth.characteristic.battery_level.yamlat the same commit: oneuint8,Allowed range is 0 to 100.
The lab asks for the Battery service (0x180F) as an optional service and says so when a device does not expose it.
What the lab cannot tell you
- Whether the RR values are measured. The bytes say a device sent values in the RR field, not how it got them; the lab shows how many distinct values arrived so a suspiciously uniform stream is visible, and leaves the judgment to you.
- Accuracy. Nothing here compares a device with a reference; we publish no accuracy figure for any strap or watch.
- Anything in a browser without Web Bluetooth. The page detects the API and, where it is missing, offers the synthetic replay and the paste box. Which browsers ship it is set out in Web Bluetooth for fitness devices. Real Bluetooth cannot be exercised in an automated test either, which is why the decoder is tested on synthetic packets built from the SIG structure (all 32 combinations of the five defined Flags bits).
Building this into an app is the subject of connecting a Bluetooth heart rate monitor, with the radio choice in ANT+ vs Bluetooth, the watch case in Apple Watch live heart rate, and CI without a strap in testing BLE fitness devices. Every characteristic the SIG defines for fitness devices is in the Bluetooth GATT reference.
How this works, and what it sends
Nothing leaves your device. The browser shows its own device chooser; the page can talk only to the device you pick, and only to the Heart Rate service and, if present, the Battery service. Notifications are decoded in this tab and held in memory until you close it. This site has no server code behind the page, stores nothing, and logs no event from it beyond its ordinary page view. The CSV and the share card are made in the tab and go wherever you paste or save them, and nowhere else.
The synthetic replay is generated by the page, not recorded from a device: its heart rate and RR values are made up, and it switches between the uint8 and uint16 heart-rate formats, the three contact states, Energy Expended and a stretch with no RR intervals so that every part of the decoder runs.
The structure comes from the Bluetooth SIG’s public repository (https://bitbucket.org/bluetooth-SIG/public), read by this site’s generator on 2026-10-17. The SIG’s text is quoted with attribution: Copyright © Bluetooth SIG, Inc., which describes these files as proprietary to it. This site is not affiliated with Bluetooth SIG.
A free tool from AIFitnessAPI. More at free tools for health-app builders.
Next step
Turning this into a choice of API: