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.
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 ≈.
Pair a heart rate band.
Bands advertising the standard BLE Heart Rate service appear here. Tap to connect and start the LSL stream.
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.
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.
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 readsRRSTREAMER_LSL_JNILIBS, callssetSrcDirs(), and the source tree never gets mutated by the build. flake.nix · app/build.gradle.kts
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.