RRSTREAMER v0.2.0 Android · BLE · LSL ECG + PPG bands

Stream your heart
to the network.

An Android app for researchers. Pair any Bluetooth heart-rate band — ECG (Polar H10, Wahoo TICKR, Garmin) or optical (Polar Verity Sense, OH1) — and your phone publishes HR plus per-beat R-R or PP intervals as Lab Streaming Layer marker outlets. No laptop tether, no proprietary recording stack — just LSL inlets on the same Wi-Fi reading the values directly.

00:04 ● ● ● 5G 100%
RRSTREAMER · POLAR H10 · D6B5C724
Streaming · LSL Markers
HR AVG 69
71 BPM
−60s now
RR
832
ms · last beat
RMSSD
26
ms · last 30 beats
STREAMHR Polar H10 D…
UPTIME00:11
SAMPLES11
BUFFER11 / 100
RR· LAST 100 BEATS 11 / 100
MIN
781ms
MEAN
867ms
MAX
988ms
▢ Stop streaming
00What it does

One job: forward your pulse, faithfully.

RRStreamer is an Android sibling to the Python and Rust references at HRBand-LSL: same on-the-wire stream contract, but on a phone in the participant's pocket. It picks the right BLE protocol per band at connect time, so the same app covers two families:

  • ECG bands — Polar H10, Wahoo TICKR, Garmin HRM-Pro, Coospo, Decathlon Kalenji, Suunto Smart Sensor, and anything else advertising the standard BLE Heart Rate service (0x180D) with R-R intervals. Streams HR plus true R-R intervals.
  • Optical (PPG) bands — Polar Verity Sense, Polar OH1, Polar OH1+, and any other Polar PPG device that speaks the same Polar Measurement Data (PMD) service over GATT. Streams HR plus PP (peak-to-peak) intervals.

We've verified end-to-end with a Polar H10 (ECG path) and a Polar Verity Sense (PPG path) on Android 15. Other bands in either family use the same characteristics, so they should work without any code change — please report back if a particular band doesn't.

The contract below is load-bearing. Existing pylsl resolvers and LabRecorder configurations match on the literal stream name and source ID.

Outlet Stream Name Type Format Source ID
HR HR <devicename> Markers / 1ch / irregular int32 HR_markers_<devicename>
RR RR <devicename> (ECG bands) Markers / 1ch / irregular int32 RR_markers_<devicename>
PP PP <devicename> (PPG bands) Markers / 1ch / irregular int32 PP_markers_<devicename>

Both intervals are pushed as milliseconds (rounded int). ECG bands report R-R in 1/1024-s units per the BLE Heart Rate Measurement spec; PPG bands report PP directly in milliseconds via Polar PMD. Either way the conversion happens once at the parse boundary so the UI, the RMSSD computation, and the LSL outlet all speak the same units. The ms-rounding for R-R is a deliberate divergence from the Python and Rust references, which forward the raw value.

A note on RMSSD from PP: the algorithm is the same, but the PPG waveform smooths out short cycles and adds optical-path latency, so the value is an approximation. The UI flags it with a prefix and an "approx" qualifier whenever the source is PP. It's still useful for relative within-session comparisons; just don't treat it as ECG-grade.

01The two screens

Pair, then watch your pulse stream.

The whole app is two screens. Scan finds every band advertising the Heart Rate service. Streaming shows the live signal and forwards every sample to LSL — the screenshot below is the PPG path on a Polar Verity Sense, with the second outlet labelled PP and the RMSSD card flagged as approximate. The ECG path looks identical except the labels read RR and the RMSSD has no .

00:04 ● ● ● 5G 100%
RRSTREAMER

Pair a heart rate band.

Bands advertising the standard BLE Heart Rate service appear here. Tap to connect and start the LSL stream.

Scanning · 4s left
Rescan
Discovered · 2BLE 0x180D
Polar H10
C8:28:E6:77:9D:0D
Connect
Polar Sense C5363229
A0:9E:1A:C5:36:32
Connect
Scan · ECG and PPG bands side-by-side
00:04 ● ● ● 5G 100%
RRSTREAMER · POLAR SENSE · C5363229
Streaming · LSL Markers
HR AVG 64
65 BPM
−60snow
PP
916
ms · last beat
RMSSD ≈
37
ms · approx · last 30 beats
STREAMPP Polar Sense C…
UPTIME00:47
SAMPLES52
BUFFER52 / 100
PP· LAST 100 PEAKS · APPROX 52 / 100
MIN
849ms
MEAN
950ms
MAX
1146ms
▢ Stop streaming
Streaming (PPG path) · HR sparkline + PP-100 ms graph
02Architecture

Three units, one job each.

A Compose UI shell, a foreground service that owns the GATT connection and the LSL outlets together for the device's lifetime, and a small set of pure parsers. Each unit can be reasoned about — and tested — without the others.

BLE HR Band 0x2A37 / Polar PMD BleScanner callbackFlow HeartRateClient GATT subscribe HeartRateParser 0x2A37 / PPI → ms HeartRateService foreground · CONNECTED_DEVICE LslHeartRateStreamer edu.ucsd.sccn.LSL MulticastLock Wi-Fi · "rrstreamer-lsl" network pylsl LabRecorder … any LSL inlet BLE side LSL side discovery data path

The service is foreground-typed so the GATT link survives the screen turning off — the Polar H10 holds the connection exclusively, and any unbound GATT would die with the activity. The same service holds a non-reference-counted Wi-Fi multicast lock so a fresh pylsl resolve_streams() from another machine actually reaches the announcement.

03Build & run

Three commands. Nix carries the rest.

The flake provisions JDK 17, Gradle 8, the Android SDK 34 + NDK 26, adb, and a Python with pylsl installed. The vendored liblsl.so per-ABI is fetched from mvidaldp/liblsl-android-builder v1.16.2 and pinned by SHA-256.

# Drop into the toolchain shell
nix develop

# Build, install on the connected device, launch, tail logcat
nix run

# Just the APK (ends up under app/build/outputs/apk/release/)
nix run .#build

# Verify the LSL stream from another host on the same network
nix develop --command python3 scripts/verify_lsl.py

nix build works without flags but emits wrapper scripts rather than the APK itself — Gradle fetches Maven dependencies during the build, which a pure Nix derivation can't do without opting into the impure-derivations experimental feature. Running ./result/bin/rrstreamer-build after nix build drops the APK in the usual Gradle path.

The native libs come from a Nix store path. Gradle reads RRSTREAMER_LSL_JNILIBS, calls setSrcDirs(), and the source tree never gets mutated by the build. flake.nix · app/build.gradle.kts
04Verified setup

What we ran it against.

Every change was confirmed live on the device below, with a pylsl resolver on a separate host on the same Wi-Fi network reading the announced streams.

ECG Test Sensor
Polar H10
D6B5C724 · 0x2A37 RR path
PPG Test Sensor
Polar Sense
C5363229 · PMD/PPI path
Phone
Android 15
API 35 · ARM64
Toolchain
Kotlin 2.0.20
AGP 8.5 · JDK 17 · Gradle 8.7
liblsl
v1.16.2
arm64-v8a · armeabi-v7a · x86 · x86_64
Receiver
pylsl 1.18
resolve · pull_sample · same Wi-Fi
UI
Compose
Material 3 · BOM 2024.09 · Fira