Heart Rate Monitor with Python

Recently I've talked to somebody who has an interest in psychology, sports and data science. His idea was to measure the level of relaxation using brain waves and then light the room in a specific color to give the meditating person a direct feedback on their progress. They could then train to reach certain meditative states using the external feedback.

I don't know how hard it would be to measure brain waves, and also have no idea how to interpret them. But I recently got a Garmin HRM-Dual, a heart rate monitor (HRM) chest strap. It works with the Strava app on my smartphone via Bluetooth, so there must be some way to get the data.

We had discussed this a bit further, and I wanted to give it a try. The light in the room would ideally be realized using a computer controllable RGB light, but I don't have one of them. I could just use a solid color with the computer or a TV screen.

Connecting the HRM to my laptop

At first I would have to see whether I could get my HRM connected to my laptop. In the long run it might be nice to have it as an Android app, but I am not an Android developer. So I use the tools that I have, Linux and Python.

Finding the HRM

My laptop has an old built-in Bluetooth chip, and I have attached an external USB one with Bluetooth 5.0, which is automatically recognized as default:

❯ bluetoothctl list
Controller 44:01:BB:9F:B3:34 mu-x220 #2 [default]
Controller 40:2C:F4:B8:C1:30 mu-x220

I can scan for devices and it finds the HRM:

❯ bluetoothctl scan on
Discovery started
[CHG] Controller 44:01:BB:9F:B3:34 Discovering: yes
[NEW] Device FA:1F:75:28:5B:2C HRM-Dual:449192

Then I can pair it. This step also tells us the various services that the device offers.

❯ bluetoothctl pair FA:1F:75:28:5B:2C
Attempting to pair with FA:1F:75:28:5B:2C
[CHG] Device FA:1F:75:28:5B:2C Connected: yes
[CHG] Device FA:1F:75:28:5B:2C UUIDs: 00001800-0000-1000-8000-00805f9b34fb
[CHG] Device FA:1F:75:28:5B:2C UUIDs: 00001801-0000-1000-8000-00805f9b34fb
[CHG] Device FA:1F:75:28:5B:2C UUIDs: 0000180a-0000-1000-8000-00805f9b34fb
[CHG] Device FA:1F:75:28:5B:2C UUIDs: 0000180d-0000-1000-8000-00805f9b34fb
[CHG] Device FA:1F:75:28:5B:2C UUIDs: 0000180f-0000-1000-8000-00805f9b34fb
[CHG] Device FA:1F:75:28:5B:2C UUIDs: 6a4e2401-667b-11e3-949a-0800200c9a66
[CHG] Device FA:1F:75:28:5B:2C ServicesResolved: yes
[CHG] Device FA:1F:75:28:5B:2C Paired: yes
[NEW] Primary Service (Handle 0x19bd)
        Generic Attribute Profile
[NEW] Characteristic (Handle 0x2674)
        Service Changed
[NEW] Descriptor (Handle 0xe364)
        Client Characteristic Configuration
[NEW] Primary Service (Handle 0x19bd)
        Vendor specific
[NEW] Characteristic (Handle 0x8c84)
        Vendor specific
[NEW] Descriptor (Handle 0xe754)
        Client Characteristic Configuration
[NEW] Characteristic (Handle 0xf584)
        Vendor specific
[NEW] Primary Service (Handle 0x19bd)
        Device Information
[NEW] Characteristic (Handle 0x44c4)
        Manufacturer Name String
[NEW] Characteristic (Handle 0x50e4)
        Model Number String
[NEW] Characteristic (Handle 0x5ab4)
        Serial Number String
[NEW] Characteristic (Handle 0x6414)
        Hardware Revision String
[NEW] Characteristic (Handle 0x6d24)
        Firmware Revision String
[NEW] Characteristic (Handle 0x7684)
        Software Revision String
[NEW] Primary Service (Handle 0x19bd)
        Battery Service
[NEW] Characteristic (Handle 0x8674)
        Battery Level
[NEW] Descriptor (Handle 0x9254)
        Client Characteristic Configuration
[NEW] Primary Service (Handle 0x19bd)
        Heart Rate
[NEW] Characteristic (Handle 0x9fa4)
        Heart Rate Measurement
[NEW] Descriptor (Handle 0xab74)
        Client Characteristic Configuration
[NEW] Characteristic (Handle 0xb204)
        Body Sensor Location
Pairing successful

We can already see that there is a characteristic “Heart Rate Measurement” with UID 00002a37 and a primary service “Heart Rate” with UID 0000180d. We also have “Battery Level” at 00002a19.

Interactive querying

I have found a logger Python script, which uses hcitool and gatttool to gather the data. It also uses setcap to let all users query the devices. I have followed the Python script and manually entered the commands to see what it does.

First one connects to the device.

❯ gatttool -b FA:1F:75:28:5B:2C -t random --interactive
[FA:1F:75:28:5B:2C][LE]> connect
Attempting to connect to FA:1F:75:28:5B:2C
Connection successful

This might fail if it is connected already. Then one has to disconnect and try again.

Once that has finished, one can read from the UID of the battery level:

[FA:1F:75:28:5B:2C][LE]> char-read-uchar-read-uuid 00002a19-0000-1000-8000-00805f9b34fb
handle: 0x0023   value: 55 

This is a hexadecimal value, and that translates to 85, meaning 85 % battery level.

We can let it describe all available characteristics. They are emitted with their UIDs, so it doesn't really help up that much if we wouldn't have other bloggers describing them, or looking at the list during pairing.

[FA:1F:75:28:5B:2C][LE]> char-desc
handle: 0x0001, uuid: 00002800-0000-1000-8000-00805f9b34fb
handle: 0x0002, uuid: 00002803-0000-1000-8000-00805f9b34fb
handle: 0x0003, uuid: 00002a00-0000-1000-8000-00805f9b34fb
handle: 0x0004, uuid: 00002803-0000-1000-8000-00805f9b34fb
handle: 0x0005, uuid: 00002a01-0000-1000-8000-00805f9b34fb
handle: 0x0006, uuid: 00002803-0000-1000-8000-00805f9b34fb
handle: 0x0007, uuid: 00002a04-0000-1000-8000-00805f9b34fb
handle: 0x0008, uuid: 00002803-0000-1000-8000-00805f9b34fb
handle: 0x0009, uuid: 00002aa6-0000-1000-8000-00805f9b34fb
handle: 0x000a, uuid: 00002800-0000-1000-8000-00805f9b34fb
handle: 0x000b, uuid: 00002803-0000-1000-8000-00805f9b34fb
handle: 0x000c, uuid: 00002a05-0000-1000-8000-00805f9b34fb
handle: 0x000d, uuid: 00002902-0000-1000-8000-00805f9b34fb
handle: 0x000e, uuid: 00002800-0000-1000-8000-00805f9b34fb
handle: 0x000f, uuid: 00002803-0000-1000-8000-00805f9b34fb
handle: 0x0010, uuid: 6a4ecd28-667b-11e3-949a-0800200c9a66
handle: 0x0011, uuid: 00002902-0000-1000-8000-00805f9b34fb
handle: 0x0012, uuid: 00002803-0000-1000-8000-00805f9b34fb
handle: 0x0013, uuid: 6a4e4c80-667b-11e3-949a-0800200c9a66
handle: 0x0014, uuid: 00002800-0000-1000-8000-00805f9b34fb
handle: 0x0015, uuid: 00002803-0000-1000-8000-00805f9b34fb
handle: 0x0016, uuid: 00002a29-0000-1000-8000-00805f9b34fb
handle: 0x0017, uuid: 00002803-0000-1000-8000-00805f9b34fb
handle: 0x0018, uuid: 00002a24-0000-1000-8000-00805f9b34fb
handle: 0x0019, uuid: 00002803-0000-1000-8000-00805f9b34fb
handle: 0x001a, uuid: 00002a25-0000-1000-8000-00805f9b34fb
handle: 0x001b, uuid: 00002803-0000-1000-8000-00805f9b34fb
handle: 0x001c, uuid: 00002a27-0000-1000-8000-00805f9b34fb
handle: 0x001d, uuid: 00002803-0000-1000-8000-00805f9b34fb
handle: 0x001e, uuid: 00002a26-0000-1000-8000-00805f9b34fb
handle: 0x001f, uuid: 00002803-0000-1000-8000-00805f9b34fb
handle: 0x0020, uuid: 00002a28-0000-1000-8000-00805f9b34fb
handle: 0x0021, uuid: 00002800-0000-1000-8000-00805f9b34fb
handle: 0x0022, uuid: 00002803-0000-1000-8000-00805f9b34fb
handle: 0x0023, uuid: 00002a19-0000-1000-8000-00805f9b34fb
handle: 0x0024, uuid: 00002902-0000-1000-8000-00805f9b34fb
handle: 0x0025, uuid: 00002800-0000-1000-8000-00805f9b34fb
handle: 0x0026, uuid: 00002803-0000-1000-8000-00805f9b34fb
handle: 0x0027, uuid: 00002a37-0000-1000-8000-00805f9b34fb
handle: 0x0028, uuid: 00002902-0000-1000-8000-00805f9b34fb
handle: 0x0029, uuid: 00002803-0000-1000-8000-00805f9b34fb
handle: 0x002a, uuid: 00002a38-0000-1000-8000-00805f9b34fb

One can see that the 00002902 appears a lot, it is the “Client Characteristic Configuration” which is associated with the element above it. These are to control something else. And we can find the 00002a37 from the heart rate as the fourth last one, at handle 0x0027. And its control is one below, at handle 0x0028.

One is supposed to write an 0x0100 into there, and then is subscribed to the measurements, which are supposed to just come in there as a stream.

[FA:1F:75:28:5B:2C][LE]> char-write-req 0x0028 0100
Characteristic value was written successfully

But in my case, nothing comes. It just stays there, and gives me another prompt.

There is a blog post which describes how one can uses the gatttool to query the data from a Polar HRM. It follows pretty much the steps in the above linked Python script, it just obtains the handles a bit differently. There we first query the primaries:

[FA:1F:75:28:5B:2C][LE]> primary
attr handle: 0x0001, end grp handle: 0x0009 uuid: 00001800-0000-1000-8000-00805f9b34fb
attr handle: 0x000a, end grp handle: 0x000d uuid: 00001801-0000-1000-8000-00805f9b34fb
attr handle: 0x000e, end grp handle: 0x0013 uuid: 6a4e2401-667b-11e3-949a-0800200c9a66
attr handle: 0x0014, end grp handle: 0x0020 uuid: 0000180a-0000-1000-8000-00805f9b34fb
attr handle: 0x0021, end grp handle: 0x0024 uuid: 0000180f-0000-1000-8000-00805f9b34fb
attr handle: 0x0025, end grp handle: 0xffff uuid: 0000180d-0000-1000-8000-00805f9b34fb

Then we take a look at the characteristic descriptions for that range, and find the value to read:

[FA:1F:75:28:5B:2C][LE]> char-desc 0x0021 0x0024
handle: 0x0021, uuid: 00002800-0000-1000-8000-00805f9b34fb
handle: 0x0022, uuid: 00002803-0000-1000-8000-00805f9b34fb
handle: 0x0023, uuid: 00002a19-0000-1000-8000-00805f9b34fb
handle: 0x0024, uuid: 00002902-0000-1000-8000-00805f9b34fb

We can then query that, and retrieve the same battery level.

[FA:1F:75:28:5B:2C][LE]> char-read-hnd 0x0023
Characteristic value/descriptor: 55 

For the heart rate, we do the same query:

[FA:1F:75:28:5B:2C][LE]> char-desc 0x0025 0xffff
handle: 0x0025, uuid: 00002800-0000-1000-8000-00805f9b34fb
handle: 0x0026, uuid: 00002803-0000-1000-8000-00805f9b34fb
handle: 0x0027, uuid: 00002a37-0000-1000-8000-00805f9b34fb
handle: 0x0028, uuid: 00002902-0000-1000-8000-00805f9b34fb
handle: 0x0029, uuid: 00002803-0000-1000-8000-00805f9b34fb
handle: 0x002a, uuid: 00002a38-0000-1000-8000-00805f9b34fb

And then we also have to subscript to the notifications:

[FA:1F:75:28:5B:2C][LE]> char-write-req 0x0028 0100
Characteristic value was written successfully

Again, nothing happens.

I have tried to directly read out the value, but apparently there is no value to retrieve.

[FA:1F:75:28:5B:2C][LE]> char-read-hnd 0x0027
Characteristic value/descriptor: 00 00 

This has already been asked on Stack Overflow, where somebody used a no-name HRM successfully, but failed to use the Garmin HRM-Dual with this method.

Further attempts

I have then tried to write 0x0200 and 0x0300 to that location, but that didn't do anything either. I have written 0x0100 to the other control handles 0x000d, 0x0011 and 0x0024. This didn't provide any data either.

Manual reading

But just like with the battery reading, one can also try to manually read the value from the HRM:

[FA:1F:75:28:5B:2C][LE]> char-read-hnd 0x0027
Characteristic value/descriptor: 10 53 41 03

Having the Strava app open in parallel shows me that my heart rate was at 83/min in that moment. And according to the blog post with the Polar HRM the second octet is the heart rate. Converting 0x53 gives 83, so that seems to be a perfect fit. I have no idea what the other numbers mean, but I don't care.

Python implementation

Now that I have tested how it could work, I need to implement it. I start with the HRM, of course.

HRM implementation

For this I take some parts from the BSD licensed BLEHeartRateLogger.py and use the pexpect library to work with the gatttool.

As usual, I specify an interface and implement that with the specific class.

This implementation requires me to wear the chest strap and have it connected via Bluetooth to my computer. This is slightly annoying, and for testing I want to have a data source which just makes up data. I picture that a simple sine curve with a bit of noise would be good. This is my fake data source:

class FakeHeartRateMonitor(HeartRateMonitor):
    def __init__(self):
        self.step = 0

    def get_heart_rate(self) -> int:
        self.step += 1
        base = 80
        variable = 20 * math.cos(self.step * 2 * math.pi / 30)
        noise = random.gauss(0, 5)
        return int(base + variable + noise)

With that I now have two implementations of the HRM:


We want to display something to the user. So for the start a simple exponential moving average is a start. We'd like to add a derivative later on. I therefore extend the architecture with arbitrary observables:

Using my fake HRM and letting it run for 10 steps, I can generate this output:

❯ poetry run python -m hrm_meditation
Step   0: HR  93/min, HR EMA:  93.0/min. 🟡
Step   1: HR  92/min, HR EMA:  92.9/min. 🟡
Step   2: HR  95/min, HR EMA:  93.1/min. 🟡
Step   3: HR  86/min, HR EMA:  92.4/min. 🟢
Step   4: HR  93/min, HR EMA:  92.5/min. 🟡
Step   5: HR  82/min, HR EMA:  91.4/min. 🟢
Step   6: HR  88/min, HR EMA:  91.1/min. 🟢
Step   7: HR  75/min, HR EMA:  89.5/min. 🟢
Step   8: HR  79/min, HR EMA:  88.4/min. 🟢
Step   9: HR  73/min, HR EMA:  86.9/min. 🟢

That is minimalistic at this moment. But adding a derivative to it is now straightforward. I have already added a color emoji which indicates whether the heart rate is within 2 to the moving average (yellow), or above (red) or below (green). This is somewhat what we want to show to the user in the end, just with ambient lights that fill the whole room.

Simple graph

Another way of visualizing the heart rate is with a simple graph. In order to stick with the console text interface, one can make a horizontal graph, like with a plotter. Such a graph looks like this: It has the heart rate on the horizontal axis, the time on the vertical axis. It shows the current heart rate with a #, and the exponential moving average with an o. The number in front is the exact heart rate.

102 [                                #                                                 ]
 83 [                      #        o                                                  ]
 91 [                          #   o                                                   ]
 96 [                             #o                                                   ]
 87 [                        #    o                                                    ]
 93 [                           # o                                                    ]
 84 [                       #    o                                                     ]
 82 [                      #     o                                                     ]
 74 [                  #        o                                                      ]
 62 [            #            o                                                        ]

This also gives some feedback to the user, although it is not quite as beautiful. One could interpret it as the developer representation.


One could now use a library like PyGame to add a graphical output on top of that. All the functionality is there. I am not sure how useful it would be in practise, one would have to try it out with actual meditation.