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) /org/bluez/hci1/dev_FA_1F_75_28_5B_2C/service000a 00001801-0000-1000-8000-00805f9b34fb Generic Attribute Profile [NEW] Characteristic (Handle 0x2674) /org/bluez/hci1/dev_FA_1F_75_28_5B_2C/service000a/char000b 00002a05-0000-1000-8000-00805f9b34fb Service Changed [NEW] Descriptor (Handle 0xe364) /org/bluez/hci1/dev_FA_1F_75_28_5B_2C/service000a/char000b/desc000d 00002902-0000-1000-8000-00805f9b34fb Client Characteristic Configuration [NEW] Primary Service (Handle 0x19bd) /org/bluez/hci1/dev_FA_1F_75_28_5B_2C/service000e 6a4e2401-667b-11e3-949a-0800200c9a66 Vendor specific [NEW] Characteristic (Handle 0x8c84) /org/bluez/hci1/dev_FA_1F_75_28_5B_2C/service000e/char000f 6a4ecd28-667b-11e3-949a-0800200c9a66 Vendor specific [NEW] Descriptor (Handle 0xe754) /org/bluez/hci1/dev_FA_1F_75_28_5B_2C/service000e/char000f/desc0011 00002902-0000-1000-8000-00805f9b34fb Client Characteristic Configuration [NEW] Characteristic (Handle 0xf584) /org/bluez/hci1/dev_FA_1F_75_28_5B_2C/service000e/char0012 6a4e4c80-667b-11e3-949a-0800200c9a66 Vendor specific [NEW] Primary Service (Handle 0x19bd) /org/bluez/hci1/dev_FA_1F_75_28_5B_2C/service0014 0000180a-0000-1000-8000-00805f9b34fb Device Information [NEW] Characteristic (Handle 0x44c4) /org/bluez/hci1/dev_FA_1F_75_28_5B_2C/service0014/char0015 00002a29-0000-1000-8000-00805f9b34fb Manufacturer Name String [NEW] Characteristic (Handle 0x50e4) /org/bluez/hci1/dev_FA_1F_75_28_5B_2C/service0014/char0017 00002a24-0000-1000-8000-00805f9b34fb Model Number String [NEW] Characteristic (Handle 0x5ab4) /org/bluez/hci1/dev_FA_1F_75_28_5B_2C/service0014/char0019 00002a25-0000-1000-8000-00805f9b34fb Serial Number String [NEW] Characteristic (Handle 0x6414) /org/bluez/hci1/dev_FA_1F_75_28_5B_2C/service0014/char001b 00002a27-0000-1000-8000-00805f9b34fb Hardware Revision String [NEW] Characteristic (Handle 0x6d24) /org/bluez/hci1/dev_FA_1F_75_28_5B_2C/service0014/char001d 00002a26-0000-1000-8000-00805f9b34fb Firmware Revision String [NEW] Characteristic (Handle 0x7684) /org/bluez/hci1/dev_FA_1F_75_28_5B_2C/service0014/char001f 00002a28-0000-1000-8000-00805f9b34fb Software Revision String [NEW] Primary Service (Handle 0x19bd) /org/bluez/hci1/dev_FA_1F_75_28_5B_2C/service0021 0000180f-0000-1000-8000-00805f9b34fb Battery Service [NEW] Characteristic (Handle 0x8674) /org/bluez/hci1/dev_FA_1F_75_28_5B_2C/service0021/char0022 00002a19-0000-1000-8000-00805f9b34fb Battery Level [NEW] Descriptor (Handle 0x9254) /org/bluez/hci1/dev_FA_1F_75_28_5B_2C/service0021/char0022/desc0024 00002902-0000-1000-8000-00805f9b34fb Client Characteristic Configuration [NEW] Primary Service (Handle 0x19bd) /org/bluez/hci1/dev_FA_1F_75_28_5B_2C/service0025 0000180d-0000-1000-8000-00805f9b34fb Heart Rate [NEW] Characteristic (Handle 0x9fa4) /org/bluez/hci1/dev_FA_1F_75_28_5B_2C/service0025/char0026 00002a37-0000-1000-8000-00805f9b34fb Heart Rate Measurement [NEW] Descriptor (Handle 0xab74) /org/bluez/hci1/dev_FA_1F_75_28_5B_2C/service0025/char0026/desc0028 00002902-0000-1000-8000-00805f9b34fb Client Characteristic Configuration [NEW] Characteristic (Handle 0xb204) /org/bluez/hci1/dev_FA_1F_75_28_5B_2C/service0025/char0029 00002a38-0000-1000-8000-00805f9b34fb 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:
Observables
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.
Conclusion
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.