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:
| Role | 16-bit ID | Full UUID |
|---|---|---|
| Heart Rate service | 0x180D | 0000180d-0000-1000-8000-00805f9b34fb |
| Heart Rate Measurement characteristic | 0x2A37 | 00002a37-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 bit | Meaning |
|---|---|
| Bit 0 | 0 → heart rate is a uint8; 1 → heart rate is a uint16 (little-endian) |
| Bits 1–2 | Sensor contact status (bit 2 = feature supported, bit 1 = contact detected) |
| Bit 3 | Energy Expended field present (uint16, kJ) |
| Bit 4 | RR-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
- Scan filtering. While not connected,
peripheral.services()returns the service UUIDs found in the advertisement, which is how we detect0x180Dwithout connecting. Many monitors only start advertising when worn or when a session is active. - 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.
- Subscription.
0x2A37supports notifications only; there is nothing toread(). Afternotify()returns, measurements arrive roughly once per second on the callback thread. - Parsing. Always branch on bit 0 of the flags byte: most devices send
uint8heart rates, but a device may switch to theuint16format (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.
