Holiday Hacking - Tracking my heart rate while playing Call Of Duty

Over the holidays, I got a Polar OH1+ as a Christmas present. Its an optical heart rate monitor with similar tech to those found in smart watches (including my Garmin running watch), but much more accurate (at least for running) due to being smaller & lighter, as well as fitting against the fleshier upper arm:

Polar OH1

Like any modern gadget these days, it supports Bluetooth (specifically Bluetooth Low Energy, or BLE/BTLE) for talking to your phone and/or smart watch. Which got me wondering, will it pair with a computer?

It pairs

This piqued my curiosity. I had long been at least somewhat curious about tracking my heart rate while say playing video games, if nothing else for curiosity. So how easy is it to grab data from this thing? As with the last few years, I had spent a bit of December doing a bunch of Advent of Code in rust, only to forget and have to re-learn everything the next year. So figured I could maybe try my hand at a “real” project.

Some quick googling later, I found the promising-looking btleplug crate. Lets dump data from all nearby devices…

extern crate btleplug;

use std::thread;
use std::time::Duration;

#[cfg(target_os = "linux")]
use btleplug::bluez::{adapter::ConnectedAdapter, manager::Manager};
#[cfg(target_os = "windows")]
use btleplug::winrtble::{adapter::Adapter, manager::Manager};
#[cfg(target_os = "macos")]
use btleplug::corebluetooth::{adapter::Adapter, manager::Manager};
use btleplug::api::{UUID, Central, Peripheral};

#[cfg(any(target_os = "windows", target_os = "macos"))]
fn get_central(manager: &Manager) -> Adapter {
    let adapters = manager.adapters().unwrap();
    adapters.into_iter().nth(0).unwrap()
}

#[cfg(target_os = "linux")]
fn get_central(manager: &Manager) -> ConnectedAdapter {
    let adapters = manager.adapters().unwrap();
    let adapter = adapters.into_iter().nth(0).unwrap();
    adapter.connect().unwrap()
}

fn main() {
    let manager = Manager::new().unwrap();
    let central = get_central(&manager);

    central.start_scan().unwrap();
    thread::sleep(Duration::from_secs(2));

    for per in &central.peripherals() {
        println!("{:?}", per);
    }
}

(Note that though this part should work cross-platform, I couldn’t get bluetooth working from WSL, so this was all done natively on Windows).

Sure enough, among the shockingly large list of nearby devices is my new toy:

A0:9E:1A:XX:XX:XX properties: PeripheralProperties { address: A0:9E:1A:XX:XX:XX, address_type: Public, local_name: Some("Polar OH1 XXXXXXXX"), tx_power_level: Some(-64), manufacturer_data: Some([]), discovery_count: 6, has_scan_response: true }, characteristics: {}

Let’s see what characteristics it supports:

    let ohr = central.peripherals().into_iter().find(|p| {
        p.properties().local_name.map(|n| n.starts_with("Polar OH1"))
            .unwrap_or(false)
    }).unwrap();

    ohr.connect();
    println!("{:?}", ohr.discover_characteristics().unwrap());

Turns out..a lot?

[Characteristic { start_handle: 0, end_handle: 0, value_handle: 0, uuid: 00:00:2A:00:00:00:10:00:80:00:00:80:5F:9B:34:FB, properties: READ }, 
Characteristic { start_handle: 0, end_handle: 0, value_handle: 0, uuid: 00:00:2A:01:00:00:10:00:80:00:00:80:5F:9B:34:FB, properties: READ }, 
Characteristic { start_handle: 0, end_handle: 0, value_handle: 0, uuid: 00:00:2A:04:00:00:10:00:80:00:00:80:5F:9B:34:FB, properties: READ }, 
Characteristic { start_handle: 0, end_handle: 0, value_handle: 0, uuid: 00:00:2A:A6:00:00:10:00:80:00:00:80:5F:9B:34:FB, properties: READ }, 
Characteristic { start_handle: 0, end_handle: 0, value_handle: 0, uuid: 00:00:2A:05:00:00:10:00:80:00:00:80:5F:9B:34:FB, properties: INDICATE }, Characteristic { start_handle: 0, end_handle: 0, value_handle: 0, uuid: 00:00:2A:29:00:00:10:00:80:00:00:80:5F:9B:34:FB, properties: READ }, 
Characteristic { start_handle: 0, end_handle: 0, value_handle: 0, uuid: 00:00:2A:24:00:00:10:00:80:00:00:80:5F:9B:34:FB, properties: READ }, 
Characteristic { start_handle: 0, end_handle: 0, value_handle: 0, uuid: 00:00:2A:25:00:00:10:00:80:00:00:80:5F:9B:34:FB, properties: READ }, 
Characteristic { start_handle: 0, end_handle: 0, value_handle: 0, uuid: 00:00:2A:27:00:00:10:00:80:00:00:80:5F:9B:34:FB, properties: READ }, 
Characteristic { start_handle: 0, end_handle: 0, value_handle: 0, uuid: 00:00:2A:26:00:00:10:00:80:00:00:80:5F:9B:34:FB, properties: READ }, 
Characteristic { start_handle: 0, end_handle: 0, value_handle: 0, uuid: 00:00:2A:28:00:00:10:00:80:00:00:80:5F:9B:34:FB, properties: READ }, 
Characteristic { start_handle: 0, end_handle: 0, value_handle: 0, uuid: 00:00:2A:23:00:00:10:00:80:00:00:80:5F:9B:34:FB, properties: READ }, 
Characteristic { start_handle: 0, end_handle: 0, value_handle: 0, uuid: FB:00:5C:51:02:E7:F3:87:1C:AD:8A:CD:2D:8D:F0:C8, properties: WRITE_WITHOUT_RESPONSE | WRITE | NOTIFY },
Characteristic { start_handle: 0, end_handle: 0, value_handle: 0, uuid: FB:00:5C:52:02:E7:F3:87:1C:AD:8A:CD:2D:8D:F0:C8, properties: NOTIFY }, 
Characteristic { start_handle: 0, end_handle: 0, value_handle: 0, uuid: FB:00:5C:53:02:E7:F3:87:1C:AD:8A:CD:2D:8D:F0:C8, properties: WRITE_WITHOUT_RESPONSE | WRITE },
Characteristic { start_handle: 0, end_handle: 0, value_handle: 0, uuid: 00:00:2A:37:00:00:10:00:80:00:00:80:5F:9B:34:FB, properties: NOTIFY }, 
Characteristic { start_handle: 0, end_handle: 0, value_handle: 0, uuid: 00:00:2A:19:00:00:10:00:80:00:00:80:5F:9B:34:FB, properties: READ | NOTIFY }, 
Characteristic { start_handle: 0, end_handle: 0, value_handle: 0, uuid: FB:00:5C:21:02:E7:F3:87:1C:AD:8A:CD:2D:8D:F0:C8, properties: READ }, 
Characteristic { start_handle: 0, end_handle: 0, value_handle: 0, uuid: FB:00:5C:22:02:E7:F3:87:1C:AD:8A:CD:2D:8D:F0:C8, properties: WRITE | INDICATE }, Characteristic { start_handle: 0, end_handle: 0, value_handle: 0, uuid: FB:00:5C:26:02:E7:F3:87:1C:AD:8A:CD:2D:8D:F0:C8, properties: NOTIFY }, 
Characteristic { start_handle: 0, end_handle: 0, value_handle: 0, uuid: 62:17:FF:4C:C8:EC:B1:FB:13:80:3A:D9:86:70:8E:2D, properties: READ }, 
Characteristic { start_handle: 0, end_handle: 0, value_handle: 0, uuid: 62:17:FF:4D:91:BB:91:D0:7E:2A:7C:D3:BD:A8:A1:F3, properties: WRITE | INDICATE }, Characteristic { start_handle: 0, end_handle: 0, value_handle: 0, uuid: FB:00:5C:81:02:E7:F3:87:1C:AD:8A:CD:2D:8D:F0:C8, properties: READ | WRITE | INDICATE }, Characteristic { start_handle: 0, end_handle: 0, value_handle: 0, uuid: FB:00:5C:82:02:E7:F3:87:1C:AD:8A:CD:2D:8D:F0:C8, properties: NOTIFY }]

Which of these do I want? The Bluetooth UUID specifications lists a bunch of different potentially interesting IDs, but they are all 16 bits, whereas are 128 bit. Making matters more confusing, btleplug ‘s UUID definition seems to allow for either:

pub enum UUID {
    B16(u16),
    B128([u8; 16]),
}

However it seems like a lot of them seem to only differ in the 3rd and 4th bytes 1, and those to roughly correspond to GATT Characteristic Ids, and in there is a 2A:37, which represents Heart Rate Measurement - sounds promising. Lets see if we can listen & dump that data:

    let mut bytes: [u8; 16] = [0x00,0x00,0x2A,0x37,0x00,0x00,0x10,0x00,0x80,0x00,0x00,0x80,0x5F,0x9B,0x34,0xFB];
    bytes.reverse();
    let uuid = UUID::B128(bytes);
    let chars = ohr.discover_characteristics()
        .expect("Couldn't discover characteristics");
    let hr_char = chars.iter().find(|c| c.uuid == uuid)
        .expect("couldn't find HR characteristic");

    ohr.on_notification(Box::new(|not| {
        println!("{:?}", not.value);
    }));
    ohr.subscribe(hr_char).expect("Couldn't subscribe");
    loop {
    }

Endianness issues out of the way this….seems to be working?

C:\Users\jackson\Dev\hroverlay>cargo run
    Finished dev [unoptimized + debuginfo] target(s) in 0.14s
     Running `target\debug\hroverlay.exe`
[0, 59]
[0, 58]
[0, 58]
[0, 59]
[0, 60]
[0, 61]
[0, 62]

At least that second number sure looks like a heart rate reading. To confirm this, I hopped over to the technical specifications for the Heart Rate Service (downloadable here). Sure enough, byte 0 represents various flags and byte 1 is a heart rate measurement. The spec authors have even figured out support for heart rates >255 on off chance you ever slap one of these bad boys on a hummingbird:

3.1.1.2Heart Rate Measurement Value Field

The Heart Rate Measurement Value field shall be included in the Heart Rate Measurement characteristic. While most human applications require support for only 255 bpm or less, special applications (e.g. animals) may require support for higher bpm values. If the Heart Rate Measurement Value is less than or equal to 255 bpm a UINT8 format should be used for power savings. If the Heart Rate Measurement Value exceeds 255 bpm a UINT16 format shall be used. See 3.1.1.1.1for additional requirements on the Heart Rate Value format change.

Other potentially interesting bit flags include a way to indicate if the sensor thinks it has lost skin contact.

Displaying things

Now its time to figure out a UI. Faced with a large number of different options, I ended up settling on the not-even-listed-there native-windows-gui crate , throwing cross-platform support into the wind on the suspicion I would need easy access to the underlying win32 APIs, since it is largely just a series of nice convenience abstractions over those. Using the associated native-windows-derive macro crate, I had a basic UI setup working fairly quickly:

#[derive(Default, NwgUi)]
pub struct BasicApp {
    #[nwg_control(size: (300, 115), flags: "WINDOW|VISIBLE")]
    #[nwg_events(OnWindowClose: [BasicApp::exit])]
    window: nwg::Window,

    #[nwg_layout(parent: window)]
    grid: nwg::GridLayout,

    #[nwg_control(text: "--", readonly: true)]
    #[nwg_layout_item(layout: grid, row: 0, col: 0)]
    hr: nwg::TextInput,

    #[nwg_control(parent: window, interval: 500, stopped: false)]
    #[nwg_events(OnTimerTick: [BasicApp::draw_hr])]
    timer: nwg::Timer,
}

impl BasicApp {

    fn draw_hr(&self) {
        self.hr.set_text("??");
    }

    fn exit(&self) {
        nwg::stop_thread_dispatch();
    }

}

And then change the end of our main to set this up:

nwg::init().expect("Failed to init Native Windows GUI");
let _app = BasicApp::build_ui(Default::default()).expect("Failed to build UI");
nwg::dispatch_thread_events();

Now how do we actually update the value? This is where I encountered my first real rust fun time : the bluetooth notification handler, for reasons I am still not entirely sure of, requires a 'static lifetime (it isn’t specified in the type - is it because it is Sync? ), meaning it couldn’t cleanly reference the UI struct, and my attempts to cleanly solve this went nowhere. Eventually I gave up and did the dirty thing (true rustaceans avert your eyes):

static HR_COUNT: AtomicU8 = AtomicU8::new(0);

Now the various reads/writes become trivial, and - tada:

Basic UI

From here, further UI improvements required me throwing away the nice macros and replacing them with the equivalent generated bits. However that gives us access to the underlying win32 Window Style flags. In particular, setting WS_EX_TOPMOST renders the window on top of everything, WS_POPUP hides the menu bar, WS_EX_LAYERED gives us some transparency controls, etc. The code is far too nasty to display inline, but here is what this all looks like in action as of the time of writing:

In game

Note quite Gamer™ enough, but hey it works. All things considered, I was surprised how easy/successful implementing this prototype in rust was. And for those curious, my heart rate really just mostly stays in the 55-70 range while playing CoD, but now I know for sure :)

The hacky source is up on github here


1. My friend Matt, who has some acutal experience in this space, was able to solve this riddle for me - 00000000-0000-1000-8000-00805F9B34FB is the “base” 128 bit UUID