Skip to content
AF
Cookbook

Recipe: Parse a BLE Heart Rate Measurement (0x2A37)

Last verified November 5, 2026 · 6 min read

A dependency-free JavaScript parser for the Bluetooth Heart Rate Measurement characteristic (0x2A37) that turns a notification into heart rate, sensor contact, energy expended and RR intervals, and throws on a value the flags do not account for. Bit 0 of the Flags octet picks a uint8 or uint16 heart rate, bits 1 and 2 report contact, bit 3 announces a uint16 Energy Expended and bit 4 RR intervals in units of 1/1024 second, all little-endian. Its tests check those bit positions against the Bluetooth SIG structure the site generates and run the parser against the site's structure-driven decoder for every flag combination.

Every heart-rate strap and most watches broadcast the same characteristic, and almost every hand-written parser for it is wrong in one of three ways: it ignores bit 0 and reads a 16-bit heart rate as 8, it collapses sensor contact to a boolean, or it reads RR intervals as milliseconds. The site's heart-rate lab decodes 0x2A37 live in the browser from the Bluetooth SIG's structure; this recipe is a short, fixed-layout parser for your own app, held to that decoder by its tests.

The layout, from the SIG's structure#

OctetFieldPresent when
0Flagsalways
1 or 1-2Heart Rate Measurement Value, uint8 or uint16, beats per minutebit 0 is 0 (uint8) or 1 (uint16)
next 2Energy Expended, uint16, SIG unit joulebit 3 is 1
restRR-Interval, uint16 each, 1/1024 sbit 4 is 1

Bit 1 is "Sensor Contact detected", bit 2 "Sensor Contact Supported", bits 5-7 are reserved. The SIG's YAML does not state byte order; the Web Bluetooth specification's example reads every uint16 little-endian, and so does this parser. Each line is from the site's generated copy of the SIG's GATT Specification Supplement in the Bluetooth fitness reference.

What the recipe does#

  • Accepts what platforms hand you: a DataView from Web Bluetooth, a Buffer, a Uint8Array, an ArrayBuffer or a byte array.
  • Decodes every defined field and returns RR intervals raw and in milliseconds.
  • Keeps contact three-valued and reports reserved bits instead of dropping them.
  • Throws on a value the flags do not explain: too short for a flagged field, RR flagged with no RR octets, an odd RR tail, or octets left over.
  • Wires into Web Bluetooth with onHeartRate, which routes parse errors to a callback instead of crashing the listener.

The implementation#

The canonical file is cookbook/ble-heart-rate-parser.mjs in this site's repository, and cookbook/ble-heart-rate-parser.test.mjs runs against it on every CI run. The listing below is a verbatim copy of that file. It has no runtime dependencies and runs in Node 20 or newer or in any browser.

/**
 * ble-heart-rate-parser.mjs — decode a Bluetooth Heart Rate Measurement
 * (0x2A37) notification into beats per minute, sensor contact, energy and
 * RR intervals, refusing a malformed value instead of guessing.
 *
 * WHAT THIS IMPLEMENTS
 *   `parseHeartRateMeasurement(bytes)` for a Uint8Array, Buffer, DataView or
 *   ArrayBuffer — what Web Bluetooth's characteristicvaluechanged, a native
 *   BLE library or a captured log hands you. The layout is the Bluetooth
 *   SIG's GATT Specification Supplement structure, as the site's generated
 *   copy holds it (src/data/bluetoothGatt.ts):
 *     octet 0       Flags
 *       bit 0       0 = Heart Rate Value Format uint8, 1 = uint16
 *       bit 1       Sensor Contact detected
 *       bit 2       Sensor Contact Supported
 *       bit 3       Energy Expended present (uint16; SIG unit: joule)
 *       bit 4       RR-Interval present (uint16 each, "1/1024 second as the
 *                   unit"; as many as fit after the fields before them)
 *       bits 5-7    Reserved for Future Use
 *   Multi-octet fields are little-endian, as the Web Bluetooth
 *   specification's own heart-rate example reads them.
 *
 *   Sensor contact is three-valued on purpose: when bit 2 is clear the
 *   device does not report contact, which is not the same as "no contact".
 *   RR intervals come back raw and in milliseconds; HRV needs them, and a
 *   device that never sets bit 4 cannot give you HRV at all.
 *
 *   The Energy Expended value is returned raw with the SIG's unit (joule).
 *   The Web Bluetooth example's comment reads its 750 as "750kJ"; this file
 *   does not pick between the two, it reports what the structure says.
 *
 * PAGES THIS SUPPORTS
 *   https://aifitnessapi.com/tools/heart-rate-lab (the same structure, live)
 *   https://aifitnessapi.com/bluetooth-fitness
 *
 * No dependencies. Node 20+ or any browser.
 *
 * MIT — from aifitnessapi.com/cookbook
 */

export const HEART_RATE_MEASUREMENT_UUID = 0x2a37;
export const RR_UNITS_PER_SECOND = 1024;

export class HeartRateParseError extends Error {
  constructor(message, bytes) {
    super(message);
    this.name = "HeartRateParseError";
    this.bytes = bytes;
  }
}

function asBytes(input) {
  if (input instanceof Uint8Array) return input;
  if (input instanceof ArrayBuffer) return new Uint8Array(input);
  if (ArrayBuffer.isView(input)) return new Uint8Array(input.buffer, input.byteOffset, input.byteLength);
  if (Array.isArray(input)) return Uint8Array.from(input);
  throw new TypeError("expected bytes: Uint8Array, Buffer, DataView, ArrayBuffer or number[]");
}

const u16 = (b, i) => b[i] | (b[i + 1] << 8);

/**
 * @returns {{ heartRate: number, format: "uint8"|"uint16",
 *            sensorContact: "detected"|"not detected"|"not supported",
 *            energyExpended: number|null, energyUnit: "joule (SIG unit)"|null,
 *            rrIntervals: number[], rrMs: number[], rrPresent: boolean,
 *            reservedBits: number[] }}
 */
export function parseHeartRateMeasurement(input) {
  const b = asBytes(input);
  if (b.length < 2) throw new HeartRateParseError(`a 0x2A37 value is at least 2 octets; got ${b.length}`, b);
  const flags = b[0];
  const hr16 = (flags & 0x01) !== 0;
  const contactDetected = (flags & 0x02) !== 0;
  const contactSupported = (flags & 0x04) !== 0;
  const energyPresent = (flags & 0x08) !== 0;
  const rrPresent = (flags & 0x10) !== 0;
  const reservedBits = [5, 6, 7].filter((bit) => (flags >> bit) & 1);

  let off = 1;
  const need = (n, what) => {
    if (off + n > b.length) throw new HeartRateParseError(`${what} is flagged present but the value ends at octet ${b.length}`, b);
  };
  need(hr16 ? 2 : 1, "the heart-rate value");
  const heartRate = hr16 ? u16(b, off) : b[off];
  off += hr16 ? 2 : 1;

  let energyExpended = null;
  if (energyPresent) {
    need(2, "Energy Expended");
    energyExpended = u16(b, off);
    off += 2;
  }

  const rrIntervals = [];
  if (rrPresent) {
    const remaining = b.length - off;
    if (remaining < 2) throw new HeartRateParseError("RR-Interval is flagged present but no RR octets follow", b);
    if (remaining % 2 !== 0) throw new HeartRateParseError("an odd number of octets follows: RR intervals are uint16", b);
    for (; off < b.length; off += 2) rrIntervals.push(u16(b, off));
  } else if (off !== b.length) {
    throw new HeartRateParseError(`${b.length - off} octet(s) left over after the fields the flags announce`, b);
  }

  return {
    heartRate,
    format: hr16 ? "uint16" : "uint8",
    sensorContact: contactSupported ? (contactDetected ? "detected" : "not detected") : "not supported",
    energyExpended,
    energyUnit: energyPresent ? "joule (SIG unit)" : null,
    rrIntervals,
    rrMs: rrIntervals.map((v) => (v * 1000) / RR_UNITS_PER_SECOND),
    rrPresent,
    reservedBits,
  };
}

/** Wire it to Web Bluetooth: `characteristic.addEventListener("characteristicvaluechanged", onHeartRate(cb))`. */
export function onHeartRate(callback, onError = () => {}) {
  return (event) => {
    try {
      callback(parseHeartRateMeasurement(event.target.value));
    } catch (err) {
      onError(err);
    }
  };
}

How to adapt it#

Subscribe, then parse. In a browser: requestDevice({ filters: [{ services: ["heart_rate"] }] }), get the heart_rate_measurement characteristic, startNotifications(), and add onHeartRate(cb) as the characteristicvaluechanged listener.

Keep RR runs separate. RR intervals are consecutive only within one connection; compute HRV per uninterrupted run, never across a reconnect.

Do not treat energy as calories. Report it with its unit, or ignore it.

The contract the tests hold it to#

The hard-coded bit positions, the RR divisor of 1024 and the energy unit are checked against the SIG structure in src/data/bluetoothGatt.ts. The Web Bluetooth example (70 bpm, contact, 750, RR 890 and 870) decodes exactly. For 48 combinations of heart-rate format, contact state, energy and zero to three RR values, the parser agrees field for field with the site's structure-driven decoder. Contact is three-valued, uint16 is little-endian, reserved bits are reported, six kinds of malformed value throw, all input types parse, and the listener routes errors to its error callback.

What it deliberately does not do#

It does not connect to devices, compute HRV or filter artifacts, and it does not decode Body Sensor Location (0x2A38) or other characteristics; the GATT explorer covers those.

Frequently asked questions

Why is the sensor contact field in a heart rate measurement three-valued?

Because two bits encode it and they mean different things. In the Bluetooth SIG's structure, bit 2 of the Flags octet says Sensor Contact Supported and bit 1 says Sensor Contact detected. When bit 2 is clear the device does not report contact at all, which is not the same as reporting no contact, so a parser that returns a boolean turns not supported into false and makes a chest strap that never reports contact look permanently detached. The recipe returns detected, not detected or not supported, and treats bit 1 as meaningless whenever bit 2 is clear.

How do you convert BLE RR-interval values to milliseconds?

Multiply by 1000 and divide by 1024. The Bluetooth SIG's structure says each RR-Interval value is represented by a uint16 with 1/1024 second as the unit, so a raw 1024 is exactly one second and a raw 890 is about 869 milliseconds. A notification can carry several RR values, as many as fit after the fields before them, and a device that never sets bit 4 of the Flags octet sends none, in which case heart rate variability cannot be computed from that device at all. The recipe returns both the raw values and the milliseconds.

What unit is the Energy Expended field of a heart rate measurement in?

The Bluetooth SIG's structure gives it the unit org.bluetooth.unit.energy.joule, and the recipe reports the raw uint16 with that unit. The Web Bluetooth specification's own heart-rate example, which the site's heart-rate lab also quotes, comments its value of 750 as 750kJ. The two sources disagree on the scale, so the recipe does not convert: it returns the number and the SIG's unit, and you decide from your device's documentation. In practice most apps take energy from the platform's calorie fields rather than from this characteristic.

All 63 questions in Cookbook →

Keep reading

Next steps

Was this page useful?

Corrections that change a page are logged, and credited by name if you ask: contributors.

Cite this page

Title
Recipe: Parse a BLE Heart Rate Measurement (0x2A37)
Publisher
AIFitnessAPI
URL
https://aifitnessapi.com/cookbook/ble-heart-rate-parser
Last verified
Plain text
AIFitnessAPI. "Recipe: Parse a BLE Heart Rate Measurement (0x2A37)." Last verified 2026-11-05. https://aifitnessapi.com/cookbook/ble-heart-rate-parser
BibTeX
@misc{aifitnessapi-cookbook-ble-heart-rate-parser,
  author = {{AIFitnessAPI}},
  title = {Recipe: Parse a BLE Heart Rate Measurement (0x2A37)},
  year = {2026},
  url = {https://aifitnessapi.com/cookbook/ble-heart-rate-parser},
  urldate = {2026-11-05},
  note = {Last verified 2026-11-05}
}

Cite the page, not the site — every page carries its own verification date.

Independent comparison, last reviewed November 5, 2026. Pricing, rate limits, and feature availability change often — confirm current details in each provider’s official documentation before you commit. Product and company names are trademarks of their respective owners; AIFitnessAPI is not affiliated with, endorsed by, or sponsored by any product listed here.

← All cookbook · by AIFitnessAPI