SensWear Heart Rate Monitor
A desktop application that shows, in real time, what the SensWear PPG and temperature daughter boards measure:
| Panel | Content | Source |
|---|---|---|
| Raw PPG | Red, infrared and green MAX30101 channels (18-bit ADC counts, about 200 Hz each) | PPG service notifications |
| Arterial pulse waveform | Inverted, band-passed pulse (percent of the DC level) with detected beats | Derived on the host |
| Heart rate | Beats per minute from peak detection, cross-checked against the spectral peak | Derived on the host |
| SpO2 estimate | Ratio-of-ratios on the red/infrared channels (uncalibrated) | Derived on the host |
| Perfusion index | Infrared AC/DC in percent | Derived on the host |
| Body temperature | MAX30208 reading via the Health Thermometer Service, with a trend | Temperature indications |
It is built only on the public senswear Python SDK;
neither the firmware nor the SDK is modified.
The heart rate, SpO2 and perfusion numbers are signal-processing estimates from a wrist-worn optical sensor. They are not calibrated and not a medical measurement.
Requirements
- Python 3.10 or newer and a Bluetooth LE adapter (Windows, macOS and Linux
are supported by
bleak; the application has been exercised on Windows 11). - A SensWear device flashed with the PPG application preset
(
senswear_nrf54l15_cpuapp_ppg). That preset also enables the temperature shield because the PPG daughter board carries the MAX30208. numpy,scipy,pyqtgraphandPySide6(installed below).
Installation
From PyPI, with the dashboard:
python -m pip install "senswear-heartrate[gui]"
The base package without the gui extra is enough for the terminal mode
(--no-gui) and CSV recording. A virtual environment keeps the Qt
dependencies isolated:
python -m venv .venv
.venv\Scripts\activate # Windows
source .venv/bin/activate # macOS / Linux
python -m pip install "senswear-heartrate[gui]"
From a source checkout, run the same command in the project directory:
python -m pip install -e ".[gui,dev]"
Running
senswear-heartrate
python -m senswear_heartrate is equivalent. The dashboard connects to the
first advertising SensWear device, sets its clock, subscribes to the three PPG
channels and the temperature indications, restarts PPG acquisition so firmware
and sensor start in sync, and reconnects automatically if the link drops. On
exit it restores the PPG sampling state it found.
Try it without hardware first:
senswear-heartrate --simulate
To pick a specific device, scan first and pass its address or exact advertised name:
senswear-heartrate --list-devices
senswear-heartrate --device AA:BB:CC:DD:EE:FF
senswear-heartrate --device "Sens Wear (OOB)"
Useful options (--help lists all of them):
| Option | Effect |
|---|---|
--device ID |
Connect to a specific BLE address or exact advertised name |
--list-devices |
Scan and print SensWear devices, then exit |
--no-gui |
Print one line per second in the terminal instead of the dashboard |
--record [PREFIX] |
Write raw samples, temperature and metrics to PREFIX_*.csv (default recordings/senswear_<timestamp>) |
--window 15 |
Seconds of signal shown in the raw and waveform panels |
--hr-channel ir |
Channel used for heart rate (default green, the strongest pulse at the wrist) |
--waveform-channel green |
Channel shown as the arterial pulse waveform (default ir) |
--temperature-interval 2 |
Body-temperature cadence in whole minutes (default 1; the firmware only supports whole minutes) |
--simulate --sim-hr 95 --sim-spo2 93 --sim-artifacts 20 |
Synthetic signal with chosen targets and a motion burst every 20 s |
--duration 30 --screenshot shot.png |
Stop after 30 s and save a picture of the dashboard |
-v / -vv |
Verbose / debug logging |
The controls at the bottom of the window change the visible window, the channels used for heart rate and for the waveform, pause the display (data keeps flowing), start or stop CSV recording, and force a reconnect.
Reading the dashboard
- Heart rate shows four quality dots and a status:
ok,settling(less than three seconds of data),tracking(the rate jumped by more than 25 bpm; the new value is shown once it persists for about a second),no contact(nothing pulsates),motion(the pulsatile part is implausibly large or its amplitude changes several-fold from second to second),noisy(beat and spectral estimates disagree or the beats are irregular),dropouts(notifications were lost) orsaturated(ADC at its limits). While the status is notokthe last good value is kept for about two seconds and then cleared. Analysis uses the last 8 s, so after a movement the status staysmotionuntil the burst has left that window. - SpO2 is only computed while the heart-rate status is
okand the pulse amplitude is steady; the tile shows the ratio of ratiosRit came from and keeps its last value for a few seconds when a window is skipped. - Body temperature arrives once per measurement interval (one minute by default), so the first value can take up to a minute. The tile shows how old the reading is and the device's own timestamp.
- Device shows the firmware version, battery level and the delivered PPG notification rate (about 25 Hz with the current firmware, see below).
How it works
Data path
The SDK delivers one notification per channel per sample (three callbacks per
sample time, matched by timestamp_ms). The engine (engine.py) runs the BLE
session on its own asyncio loop in a background thread and appends every sample
to a lock-protected ring buffer (buffers.py). The GUI timer (20 Hz) takes a
snapshot of the last window seconds for drawing and, four times per second,
hands the snapshot to the processor. Nothing in the BLE callback blocks on
drawing or disk; CSV recording goes through a queue to a writer thread.
Signal processing (processing.py)
- The sample rate is estimated from the firmware timestamps and each channel is linearly resampled onto a uniform grid, which also tolerates lost notifications (the largest gap is reported).
- A zero-phase 4th-order Butterworth band-pass (0.5-4 Hz) separates the pulsatile AC part from the DC level. The signal is inverted so a beat is a positive peak: in reflectance PPG more blood absorbs more light.
- Beats are found with a prominence-based peak detector constrained to 40-200 bpm; the heart rate is 60 / median inter-beat interval over an 8 s window. A zero-padded FFT of the same window gives the spectral rate and how concentrated the band power is.
- Quality (0..1) combines the agreement of the two estimates, the regularity of the inter-beat intervals, the spectral purity and the second-to-second steadiness of the pulse amplitude; it is zero without contact (perfusion index outside 0.05-20 %), during motion bursts or with saturation. A heart-rate jump above 25 bpm is only accepted once it has persisted for four consecutive analyses (about one second).
- SpO2 uses the ratio of ratios
R = (AC_red/DC_red) / (AC_ir/DC_ir)with RMS amplitudes and the generic MAX3010x curveSpO2 = -45.060 R^2 + 30.354 R + 94.845, clamped to 70-100 %. The coefficients live inProcessingConfig.spo2_coefficients; a device-specific calibration against a reference oximeter is required for meaningful absolute values. - The arterial pulse waveform is the inverted 0.4-8 Hz band-pass of the selected channel divided by its DC level, which keeps the dicrotic notch visible; beats are marked on it. This trace uses a causal filter (about 0.1 s of lag, no edge artifact on the newest samples) whose warm-up runs off-screen, whereas the heart-rate analysis filters its window zero-phase.
- Heart rate and SpO2 are smoothed with an exponential moving average.
Firmware facts the application relies on
- PPG: MAX30101 in multi-LED mode (IR, red, green). The firmware asks for
3200 Hz with 16-sample averaging and documents 200 samples per second per
channel, but that rate is not allowed with three LED slots at the configured
pulse width, so the sensor falls back to 400 Hz and delivers about 25
samples per second per channel, in FIFO batches of 26 roughly once per
second (measured on firmware 0.1.0). The firmware still stamps each batch at
5 ms spacing, so the application re-spreads every batch between the real
interrupt times (
repair_batch_timestamps); the processing adapts to the measured rate. Values are raw 18-bit counts; LED current, pulse width and averaging cannot be changed over BLE, and the current LED currents are low (about 1.6 mA red/IR, 0.6 mA green), so the signal is small. - The out-of-the-box firmware reports PPG sampling enabled at boot, but on the tested device no samples were ever published until the interrupt cadence was switched to per-sample and back (a plain stop/start did not help). The engine therefore waits three seconds for the first notification and, if none arrives, performs that toggle automatically before warning.
- Temperature: Health Thermometer Service, indicate-only, Celsius, with the
device timestamp and type
BODY. The interval characteristic is in seconds but the firmware schedules whole minutes (the SDK rejects other values).
Project layout
senswear_heartrate/
app.py command line entry point, headless mode
buffers.py thread-safe ring buffers and snapshots
engine.py BLE session: connect, configure, stream, reconnect
events.py status/event types shared by the sources and the UI
gui.py pyqtgraph dashboard
processing.py DSP: filters, beats, spectral HR, SpO2, perfusion, waveform (pure)
recorder.py CSV recording on a writer thread
simulate.py synthetic PPG/temperature source for development and tests
tests/ pytest suite, runs without hardware (fake SDK client)
Run the tests from a source checkout with:
python -m pytest
Troubleshooting
- No device found: make sure the device is powered, advertising and not
connected to another host (phone app, nRF Connect).
--list-devicesshows what the adapter sees;--deviceselects one explicitly. - "no PPG service": the connected firmware is not the PPG build. Flash the
senswear_nrf54l15_cpuapp_ppgpreset. - Status stays
no contact: the sensor must sit flat on the skin with light pressure; the perfusion index tile shows how much pulsation it sees. - Status
noisyormotion: keep the arm still; the LED currents in the current firmware are low, so a loose fit gives a weak pulse. Try--hr-channel irif green is weaker on your skin. - "PPG: no notifications yet" stays on the device tile: the sensor did not
wake up even after the automatic cadence toggle. Power-cycle the device and
reconnect; with
-vthe log shows the first notification time and the per-channel rate every five seconds. - PPG rate far below 25 Hz: the host is dropping notifications. Move closer to the device and avoid other BLE traffic; the processing tolerates gaps but the quality score drops.
- Temperature never updates: the first indication arrives after one full
interval;
-vprints when the interval was applied and when readings come in. If the firmware reports interval 0 the measurements are disabled on the device.
Metadata
Release files for senswear-heartrate 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| senswear_heartrate-0.1.0.tar.gz | 42.8 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| senswear_heartrate-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 83.2 kB
Release files / senswear_heartrate-0.1.0.tar.gz
| Download URL | senswear_heartrate-0.1.0.tar.gz |
|---|---|
| Size | 42.8 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
34e71107ca27103e6ac25cdba675d4960423fd27b4e0916e562089de866f44ac
|
|
BLAKE2b-256 checksum How to use checksums |
5d846beecb3cfc8aefad2c9263ba86ae3d2bcf386c39fac4ea43f9b54e8cb288
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.0
|
Release files / senswear_heartrate-0.1.0-py3-none-any.whl
| Download URL | senswear_heartrate-0.1.0-py3-none-any.whl |
|---|---|
| Size | 40.3 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
3c141250e50adba249028131118c87f34ad02ffa9f0d224d347d10b617b88ac5
|
|
BLAKE2b-256 checksum How to use checksums |
c8d2e06004daf222baecebe5904e224edcc7dff041bbbc7aafb7a6b40484306c
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.0
|