HRBand-LSL

v0.2.0 · Rust · MIT · BLE → LSL

Heart rate, straight into LabStreamingLayer.

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.

What it is

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.

Data shape

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>

type
Markers
channels
1 × int32
rate
irregular
unit
bpm

57 bpm

RR <device>

type
Markers
channels
1 × int32
rate
irregular (per beat)
unit
ms

977 ms · ECG-derived

PP <device>

type
Markers
channels
1 × int32
rate
irregular (per beat)
unit
ms

1086 ms · PPG-derived (approx.)

Stream contract

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

Quick start

Run with Nix (one-shot)

$ nix run github:abcsds/HRBand-LSL

Build from source with Cargo

$ 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.

Receive in Python (pylsl)

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]}')

Hardware

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
RR vs PP — why the distinction matters. R-R intervals from ECG bands are direct observations of the cardiac cycle and are suitable inputs to clinical HRV measures (RMSSD, SDNN, pNN50, frequency-domain decomposition). Peak-to-peak intervals from PPG bands are derived from the optical pulse waveform and smooth out short cycles; downstream HRV metrics computed from PP are an approximation. The bridge keeps the two streams nominally separate so analysis pipelines can branch on provenance instead of treating the data as interchangeable.

Architecture

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]

Citing

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.