Skip to main content

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, pyqtgraph and PySide6 (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) or saturated (ADC at its limits). While the status is not ok the last good value is kept for about two seconds and then cleared. Analysis uses the last 8 s, so after a movement the status stays motion until the burst has left that window.
  • SpO2 is only computed while the heart-rate status is ok and the pulse amplitude is steady; the tile shows the ratio of ratios R it 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)

  1. 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).
  2. 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.
  3. 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.
  4. 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).
  5. SpO2 uses the ratio of ratios R = (AC_red/DC_red) / (AC_ir/DC_ir) with RMS amplitudes and the generic MAX3010x curve SpO2 = -45.060 R^2 + 30.354 R + 94.845, clamped to 70-100 %. The coefficients live in ProcessingConfig.spo2_coefficients; a device-specific calibration against a reference oximeter is required for meaningful absolute values.
  6. 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.
  7. 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-devices shows what the adapter sees; --device selects one explicitly.
  • "no PPG service": the connected firmware is not the PPG build. Flash the senswear_nrf54l15_cpuapp_ppg preset.
  • 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 noisy or motion: keep the arm still; the LED currents in the current firmware are low, so a loose fit gives a weak pulse. Try --hr-channel ir if 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 -v the 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; -v prints 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)

Source distribution for senswear-heartrate 0.1.0
File Size Uploaded
senswear_heartrate-0.1.0.tar.gz 42.8 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for senswear-heartrate 0.1.0
File Interpreter ABI Platform
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

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page