HR <device>
- type
- Markers
- channels
- 1 × int32
- rate
- irregular
- unit
- bpm
57 bpm
A small Rust bridge that connects to a Bluetooth Low Energy heart-rate band and pushes HR and beat-to-beat intervals (RR or PP) into Lab Streaming Layer as int32 marker streams. Built for psychophysiology, HRV, and cognitive-load experiments where you need a clean, time-aligned cardiac channel alongside EEG, GSR, eye-tracking, or task events.
HRBand-LSL scans for any BLE device exposing the
standard Heart Rate Service (0x180D), connects on
selection, parses the Heart Rate Measurement characteristic
(0x2A37), and publishes two LSL outlets per session: one
for instantaneous heart rate (bpm), one for the corresponding
beat-to-beat intervals in milliseconds.
Bands are split into two protocol paths. ECG-based bands
(Polar H10, Wahoo TICKR, Garmin HRM-Pro) advertise true R-R intervals
directly on 0x2A37 — the bridge labels these as
RR. PPG-based bands (Polar Verity Sense, OH1, OH1+) do
not, so the bridge falls through to Polar's vendor PMD service and
subscribes to peak-to-peak (PPI) frames; these are labelled as
PP. Receivers can therefore tell at a glance whether a
stream is suitable for clinical-grade HRV (RR) or only for an
approximation of it (PP).
The bridge is a single static binary, has no GUI, runs from the shell or from a Nix flake, and produces streams that are bit-compatible with the Android sibling (RRStreamer) and the historical Python reference. LabRecorder, pylsl receivers, and LabStreamingLayer-aware acquisition software pick up both outlets with no special configuration.
Each session produces exactly two LSL outlets. The interval outlet's
name reflects its physiological provenance — RR from
ECG, PP from PPG. Sample shape, channel count, and
format are identical so a downstream receiver written against the
Python or Android reference works without changes.
HR <device>
57 bpm
RR <device>
977 ms · ECG-derived
PP <device>
1086 ms · PPG-derived (approx.)
| Stream name | Type | Channels | Format | Source ID | Sample rate |
|---|---|---|---|---|---|
| HR <device> | Markers | 1 | int32 | HR_markers_<device> | irregular |
| RR <device> | Markers | 1 | int32 | RR_markers_<device> | irregular (per beat) |
| PP <device> | Markers | 1 | int32 | PP_markers_<device> | irregular (per beat) |
// Sample lines, as printed by the bridge during a Polar Verity Sense session
[ok] LSL outlets created (HR + PP)
PMD CP returned SUCCESS; PPI streaming committed
HR: 57 PP: [1051] ms
HR: 57 PP: [1034] ms
HR: 57 PP: [1070] ms
HR: 59 PP: [1009] ms
HR: 55 PP: [1083] ms
HR: 55 PP: [1094] ms
HR: 55 PP: [1080] ms
$ nix run github:abcsds/HRBand-LSL
$ git clone https://github.com/abcsds/HRBand-LSL
$ cd HRBand-LSL
$ nix develop --command cargo build --release
$ ./target/release/hrband-lsl
The application scans for 5 seconds, lists detected bands, and either
auto-selects (when exactly one Polar device is in range) or prompts.
Once subscribed it prints HR + interval samples to stdout and pushes
them to LSL until interrupted by Ctrl-C or
SIGTERM; on shutdown it writes STOP_PPI to
Polar PMD bands so the band's battery isn't drained by an orphaned
subscription.
import pylsl
# Resolve every stream, print sample values from each HR/RR/PP outlet.
streams = pylsl.resolve_streams(wait_time=5.0)
inlets = [pylsl.StreamInlet(s) for s in streams
if s.name().startswith(('HR ', 'RR ', 'PP '))]
while True:
for inlet in inlets:
sample, ts = inlet.pull_sample(timeout=0.0)
if sample is not None:
print(f'{ts:.3f} {inlet.info().name()}: {sample[0]}')
Anything advertising the BLE Heart Rate Service should be discoverable; the protocol path the bridge takes is decided automatically from the first Heart Rate Measurement frame and (for PPG bands) the presence of Polar's vendor PMD service.
| Band | Sensor type | Protocol path | Interval label | Status |
|---|---|---|---|---|
| Polar H10 | ECG (chest) | 0x2A37 (RR-bit) | RR | Tested · clinical-grade HRV source |
| Polar Verity Sense | PPG (optical) | PMD / PPI | PP | Tested · PMD/PPI path |
| Polar OH1 / OH1+ | PPG (optical) | PMD / PPI | PP | Same protocol path as Verity Sense |
| Wahoo TICKR, Garmin HRM-Pro, etc. | ECG (chest) | 0x2A37 (RR-bit) | RR | Expected to work via standard path |
| Generic HR-only band | any | 0x2A37 (HR only) | RR (intervals empty) | HR stream only; interval outlet stays silent |
Single-process Rust binary on top of btleplug (BLE) and
a thin direct FFI to liblsl. Protocol selection is a
small state machine; the LSL outlets are constructed lazily once the
first frame settles which kind it is.
+--------------+ +-----------+ +--------------+ +-------------+
| BLE band | --> | btleplug | --> | HeartRateClient| --> | LslOutlet |
| (HR / PMD) | | (Linux/ | | parse + state | | (HR + RR/PP)|
| | | macOS/ | | machine | | |
+--------------+ | Win) | +-------+--------+ +------+------+
+-----------+ | |
v v
+-----------------+ +----------------+
| parse_hr (RR) | | LSL multicast |
| polar_pmd (PP) | | --> LabRecorder|
+-----------------+ | pylsl |
| Lab.js etc. |
+----------------+
protocol selection (first 0x2A37 frame, then 3-frame decision window):
RR bit set ----> StandardRr
no RR bit, no PMD service ----> HrOnly
no RR bit, PMD present ----> PolarPpi [unsubscribe 0x2A37,
write START_PPI to PMD CP,
subscribe to PMD data]
If you use HRBand-LSL in published work, a citation back to the repository is appreciated. There is no associated paper; the preferred form is a software citation:
@software{hrband_lsl_2026,
author = {Barradas Chac{\'o}n, Luis Alberto},
title = {HRBand-LSL: BLE heart rate to Lab Streaming Layer},
year = {2026},
version = {0.2.0},
license = {MIT},
url = {https://github.com/abcsds/HRBand-LSL},
orcid = {0000-0001-6756-5485}
}
For mobile / Android-side acquisition with the same outlet contract, see the sibling project RRStreamer.