SimpleBLE

Heart Rate Monitor

An end-to-end walkthrough — scan for a heart rate monitor, subscribe to measurements, and parse them per the Bluetooth specification.

The Heart Rate Profile is the "hello world" of real BLE integrations: it's a standardized service implemented identically by chest straps, watches, and fitness equipment, and it exercises the full SimpleBLE workflow — scan, filter by advertised service, connect, subscribe, parse binary payloads, and handle disconnects.

The two UUIDs involved are assigned by the Bluetooth SIG:

Role16-bit IDFull UUID
Heart Rate service0x180D0000180d-0000-1000-8000-00805f9b34fb
Heart Rate Measurement characteristic0x2A3700002a37-0000-1000-8000-00805f9b34fb

The measurement format

The Heart Rate Measurement characteristic is notify-only (you cannot read it) and packs its payload as: one flags byte, then the heart rate value, then optional fields:

Flags bitMeaning
Bit 00 → heart rate is a uint8; 1 → heart rate is a uint16 (little-endian)
Bits 1–2Sensor contact status (bit 2 = feature supported, bit 1 = contact detected)
Bit 3Energy Expended field present (uint16, kJ)
Bit 4RR-Interval fields present (one or more uint16, units of 1/1024 s)

Implementation

#include <simpleble/SimpleBLE.h>

#include <chrono>
#include <condition_variable>
#include <cstdint>
#include <iostream>
#include <mutex>
#include <thread>

const std::string HR_SERVICE = "0000180d-0000-1000-8000-00805f9b34fb";
const std::string HR_MEASUREMENT = "00002a37-0000-1000-8000-00805f9b34fb";

struct HeartRateMeasurement {
    uint16_t bpm = 0;
    bool contact_supported = false;
    bool contact_detected = false;
};

HeartRateMeasurement parse_heart_rate(const SimpleBLE::ByteArray& payload) {
    HeartRateMeasurement hr;
    if (payload.size() < 2) return hr;

    uint8_t flags = static_cast<uint8_t>(payload[0]);

    if (flags & 0x01) {
        // 16-bit heart rate value, little-endian.
        hr.bpm = static_cast<uint8_t>(payload[1]) | (static_cast<uint8_t>(payload[2]) << 8);
    } else {
        // 8-bit heart rate value.
        hr.bpm = static_cast<uint8_t>(payload[1]);
    }

    hr.contact_supported = (flags & 0x04) != 0;
    hr.contact_detected = (flags & 0x02) != 0;
    return hr;
}

int main() {
    auto adapters = SimpleBLE::Adapter::get_adapters();
    if (adapters.empty()) {
        std::cerr << "No Bluetooth adapters found." << std::endl;
        return 1;
    }
    auto adapter = adapters.front();

    // 1. Scan and keep the first device advertising the Heart Rate service.
    SimpleBLE::Peripheral monitor;
    adapter.set_callback_on_scan_found([&](SimpleBLE::Peripheral peripheral) {
        for (auto& service : peripheral.services()) {
            if (service.uuid() == HR_SERVICE && !monitor.initialized()) {
                monitor = peripheral;
                std::cout << "Found monitor: " << peripheral.identifier() << std::endl;
            }
        }
    });
    adapter.scan_for(5000);

    if (!monitor.initialized()) {
        std::cerr << "No heart rate monitor found." << std::endl;
        return 1;
    }

    // 2. Watch for disconnects. Only signal from the callback; act elsewhere.
    std::mutex mutex;
    std::condition_variable cv;
    bool disconnected = false;
    monitor.set_callback_on_disconnected([&]() {
        std::lock_guard<std::mutex> lock(mutex);
        disconnected = true;
        cv.notify_all();
    });

    // 3. Connect and subscribe.
    monitor.connect();
    monitor.notify(HR_SERVICE, HR_MEASUREMENT, [](SimpleBLE::ByteArray payload) {
        HeartRateMeasurement hr = parse_heart_rate(payload);
        std::cout << "Heart rate: " << hr.bpm << " bpm";
        if (hr.contact_supported && !hr.contact_detected) {
            std::cout << " (no skin contact)";
        }
        std::cout << std::endl;
    });

    // 4. Stream for 30 seconds or until the device disconnects.
    {
        std::unique_lock<std::mutex> lock(mutex);
        cv.wait_for(lock, std::chrono::seconds(30), [&]() { return disconnected; });
    }

    if (disconnected) {
        std::cout << "Device disconnected." << std::endl;
    } else {
        monitor.unsubscribe(HR_SERVICE, HR_MEASUREMENT);
        monitor.disconnect();
    }

    return 0;
}
import threading

import simplepyble

HR_SERVICE = "0000180d-0000-1000-8000-00805f9b34fb"
HR_MEASUREMENT = "00002a37-0000-1000-8000-00805f9b34fb"


def parse_heart_rate(payload: bytes) -> dict:
    flags = payload[0]

    if flags & 0x01:
        # 16-bit heart rate value, little-endian.
        bpm = int.from_bytes(payload[1:3], "little")
        offset = 3
    else:
        # 8-bit heart rate value.
        bpm = payload[1]
        offset = 2

    result = {
        "bpm": bpm,
        "contact_supported": bool(flags & 0x04),
        "contact_detected": bool(flags & 0x02),
    }

    if flags & 0x08:  # Energy Expended present.
        result["energy_kj"] = int.from_bytes(payload[offset : offset + 2], "little")
        offset += 2

    if flags & 0x10:  # RR intervals present (1/1024 s units).
        rr = []
        while offset + 1 < len(payload):
            rr.append(int.from_bytes(payload[offset : offset + 2], "little") / 1024.0)
            offset += 2
        result["rr_intervals_s"] = rr

    return result


def main():
    adapter = simplepyble.Adapter.get_adapters()[0]

    # 1. Scan for a device advertising the Heart Rate service.
    adapter.scan_for(5000)
    monitor = None
    for peripheral in adapter.scan_get_results():
        service_uuids = [service.uuid() for service in peripheral.services()]
        if HR_SERVICE in service_uuids:
            monitor = peripheral
            print(f"Found monitor: {peripheral.identifier()}")
            break

    if monitor is None:
        print("No heart rate monitor found.")
        return

    # 2. Watch for disconnects. Only signal from the callback; act elsewhere.
    disconnected = threading.Event()
    monitor.set_callback_on_disconnected(disconnected.set)

    # 3. Connect and subscribe.
    monitor.connect()

    def on_measurement(payload):
        hr = parse_heart_rate(bytes(payload))
        contact = "" if hr.get("contact_detected", True) else " (no skin contact)"
        print(f"Heart rate: {hr['bpm']} bpm{contact}")

    monitor.notify(HR_SERVICE, HR_MEASUREMENT, on_measurement)

    # 4. Stream for 30 seconds or until the device disconnects.
    if disconnected.wait(timeout=30):
        print("Device disconnected.")
    else:
        monitor.unsubscribe(HR_SERVICE, HR_MEASUREMENT)
        monitor.disconnect()


if __name__ == "__main__":
    main()

Walkthrough

  1. Scan filtering. While not connected, peripheral.services() returns the service UUIDs found in the advertisement, which is how we detect 0x180D without connecting. Many monitors only start advertising when worn or when a session is active.
  2. Disconnect handling. The disconnect callback fires on SimpleBLE's internal event thread, so it only sets a flag/event that the main thread waits on. Chest straps disconnect aggressively when taken off — treat it as a normal exit path, not an error.
  3. Subscription. 0x2A37 supports notifications only; there is nothing to read(). After notify() returns, measurements arrive roughly once per second on the callback thread.
  4. Parsing. Always branch on bit 0 of the flags byte: most devices send uint8 heart rates, but a device may switch to the uint16 format (for example at high rates), so hardcoding one format eventually breaks.

Practical notes

  • On macOS/iOS, the peripheral you get back is identified by a host-local UUID, not a MAC address — filter by advertised service (as above) or name, not by address.
  • If you need this to survive drops for a long-running session, combine it with the reconnect loop pattern and re-subscribe after each reconnect.
  • The sensor-contact bits are worth surfacing in real products: a valid link with no skin contact produces readings that look plausible but are not meaningful.

On this page