(see below for the working python-bleak GTK HRM app)
I am working on getting this compiled, some QT6 errors right now on my Librem5-Purism crimson but it is working on my Ubuntu’ish laptop. I used my old Polar H1 with my N900, I find it is the best way to set my workout cruise control especially choosing gears when cycling. The Polar H10 uses BTle and is powered by a cr2025 cell which is better than the lithium cell which lasted over 10 years in the H1 before becoming unusable.
https://github.com/RGreinacher/polar-h10-ecg-viewer
It is nice, more than just a rate number, and looks like it will work well on the phone UI. I can record as well as keep an eye on my own ECG trace(You can get a lot of cardiology information from analysis of the trace even if it is just two pads) which is way more high tech than my old H1. I suppose if I wear the HRM band over my right shoulder and under my left armpit to approximate ECG lead II((white–>red wires) axial electrical propagation path lets you best visualize the P wave) vs the less useful lead I(white–> black wires) across the chest which is the normal band wear. If you want to geek out on electrocardiology like I do start with this:
https://geekymedics.com/how-to-read-an-ecg/
You could also sell software add-ons. For an extra cost, they can have a BP of 120/80 and a perfect EKG every time…. features that they can’t buy anywhere else. ![]()
As I had hoped the libs are out there to make a nice little gtk python script try it out and see if you like it includes HRM, batt%, and stay awake button.
I was working on getting the ECG display to show a waveform at the bottom of the GUI so you can see if your heart is getting or is already messed up, The Polar H10 git sample works on desktop but is a bit more difficult for mobile and polar-python requires pip and setting an env to work as well as making the phone hot so I cant even guess the CPU % involved. ECG is getting problematic and HRM is all I need on a phone so abandoning phone ECG.
The heart rate monitoring is for keeping my workout level in a good aerobic body stress band and giving me a nice reasonably accurate and measurable metric so I can see that I am not slacking off or pushing too hard.
legal: not licensed this is now freeware in the public domain, no warranty of any kind, enjoy! always consult your cardiologist not an app,this script, or randos on purism forum
#!/usr/bin/env python3
import asyncio
import threading
import gi
gi.require_version("Gtk", "4.0")
from gi.repository import Gtk, GLib
from bleak import BleakClient, BleakScanner
HEART_RATE_UUID = "00002a37-0000-1000-8000-00805f9b34fb"
BATTERY_LEVEL_UUID = "00002a19-0000-1000-8000-00805f9b34fb"
def parse_bpm(data):
"""Read BPM from a Bluetooth Heart Rate Measurement packet."""
if not data:
raise ValueError("Empty heart-rate packet")
flags = data[0]
# Flag bit 0: 0 means 8-bit BPM; 1 means 16-bit BPM.
if flags & 0x01:
if len(data) < 3:
raise ValueError("Short 16-bit heart-rate packet")
return int.from_bytes(data[1:3], byteorder="little")
if len(data) < 2:
raise ValueError("Short heart-rate packet")
return data[1]
class HrmWindow(Gtk.ApplicationWindow):
def __init__(self, app):
super().__init__(application=app, title="Polar H10")
self.set_default_size(360, 420)
self.client = None
self.inhibit_cookie = 0
self.async_loop = asyncio.new_event_loop()
threading.Thread(
target=self.run_asyncio,
daemon=True,
).start()
outer = Gtk.Box(
orientation=Gtk.Orientation.VERTICAL,
spacing=18,
)
outer.set_margin_top(24)
outer.set_margin_bottom(24)
outer.set_margin_start(20)
outer.set_margin_end(20)
heading = Gtk.Label(label="Polar H10")
heading.add_css_class("title-2")
outer.append(heading)
self.bpm_label = Gtk.Label(label="—")
self.bpm_label.set_name("bpm")
self.bpm_label.add_css_class("numeric")
outer.append(self.bpm_label)
# Make the BPM number large while keeping it as ordinary label text.
provider = Gtk.CssProvider()
provider.load_from_data(
b"#bpm { font-size: 160px; font-weight: bold; }"
)
Gtk.StyleContext.add_provider_for_display(
self.get_display(),
provider,
Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION,
)
outer.append(Gtk.Label(label="beats per minute"))
self.battery_label = Gtk.Label(label="Battery: —")
outer.append(self.battery_label)
self.status_label = Gtk.Label(label="Ready")
self.status_label.set_wrap(True)
outer.append(self.status_label)
self.button = Gtk.Button(label="Connect to Polar H10")
self.button.connect("clicked", self.on_connect_clicked)
outer.append(self.button)
self.caffeine_button = Gtk.ToggleButton(
label="Keep screen awake"
)
self.caffeine_button.connect(
"toggled",
self.on_caffeine_toggled,
)
outer.append(self.caffeine_button)
self.set_child(outer)
def run_asyncio(self):
asyncio.set_event_loop(self.async_loop)
self.async_loop.run_forever()
def on_connect_clicked(self, _button):
self.button.set_sensitive(False)
self.status_label.set_text("Searching for Polar H10…")
asyncio.run_coroutine_threadsafe(
self.connect_to_h10(),
self.async_loop,
)
def on_caffeine_toggled(self, button):
if button.get_active():
flags = (
Gtk.ApplicationInhibitFlags.IDLE
| Gtk.ApplicationInhibitFlags.SUSPEND
)
self.inhibit_cookie = self.get_application().inhibit(
self,
flags,
"Polar H10 heart-rate monitor is running",
)
if self.inhibit_cookie:
button.set_label("Allow sleep")
self.status_label.set_text(
"Sleep is inhibited while this is enabled"
)
else:
button.set_active(False)
self.status_label.set_text(
"Could not inhibit sleep"
)
else:
if self.inhibit_cookie:
self.get_application().uninhibit(self.inhibit_cookie)
self.inhibit_cookie = 0
button.set_label("Keep screen awake")
self.status_label.set_text("Sleep inhibition disabled")
async def connect_to_h10(self):
try:
device = await BleakScanner.find_device_by_filter(
lambda dev, adv: (
(dev.name or adv.local_name or "").startswith("Polar H10")
),
timeout=12,
)
if device is None:
GLib.idle_add(
self.connection_failed,
"H10 not found. Wear the strap and try again.",
)
return
client = BleakClient(device)
await client.connect()
self.client = client
await client.start_notify(
HEART_RATE_UUID,
self.on_heart_rate,
)
GLib.idle_add(
self.set_status,
f"Connected to {device.name or 'Polar H10'}",
)
# Battery reading is optional: failure must not interrupt BPM.
asyncio.create_task(self.update_battery_periodically(client))
except Exception as exc:
GLib.idle_add(
self.connection_failed,
f"Connection failed: {exc}",
)
async def update_battery_periodically(self, client):
while client.is_connected:
try:
data = await client.read_gatt_char(BATTERY_LEVEL_UUID)
if data:
percent = int(data[0])
GLib.idle_add(
self.battery_label.set_text,
f"Battery: {percent}%",
)
else:
GLib.idle_add(
self.battery_label.set_text,
"Battery: unavailable",
)
except Exception as exc:
print(f"Battery reading unavailable: {exc}", flush=True)
GLib.idle_add(
self.battery_label.set_text,
"Battery: unavailable",
)
# Battery level changes slowly; read it about once a minute.
await asyncio.sleep(60)
def on_heart_rate(self, _sender, data):
try:
bpm = parse_bpm(data)
GLib.idle_add(self.bpm_label.set_text, str(bpm))
except (IndexError, ValueError) as exc:
print(f"Bad heart-rate packet: {exc}", flush=True)
def set_status(self, message):
self.status_label.set_text(message)
return False
def connection_failed(self, message):
self.status_label.set_text(message)
self.button.set_sensitive(True)
return False
class HrmApp(Gtk.Application):
def __init__(self):
super().__init__(application_id="org.example.PolarHrm")
def do_activate(self):
window = self.props.active_window
if window is None:
window = HrmWindow(self)
window.present()
if __name__ == "__main__":
HrmApp().run()
here are some icons