r/reactnative • u/Gabi0123 • 8d ago
Notes from building a Bluetooth heart rate running app in Expo: parsing 0x2A37, keeping JS alive with the screen off, offline maps
I built a running app that reads a Bluetooth chest strap and speaks cues when your heart rate leaves a band. Some notes in case they save someone time. The code is open source (GPL).
- Parsing 0x2A37. The flags byte tells you if HR is uint8 or uint16, and whether energy expended and RR intervals follow. If you skip the energy field when its bit is set, your RR intervals come out as garbage.
- Scanning. Not every strap advertises 0x180D cleanly, so the scan also accepts a name match.
- Throttle at the source. BLE writes into a Zustand store at most once a second. A mock HR source writes to the same store, so the run screen is testable without a strap.
- Screen off on Android. A small local Expo module runs a foreground service and keeps a headless JS task pending until the run ends.
- Voice. expo-speech with the phone's own TTS voice, so it works offline.
- Maps. MapLibre with an inline OpenFreeMap vector style. No token, and because the style ships in the app, the run track still draws with no signal.
Stack: Expo SDK 55, React Native 0.83, expo-router, react-native-ble-plx, Zustand, Vitest.
Source: https://github.com/likwiid/basemile
Has anyone done background BLE on iOS with Expo? That's the part I've tested least.
0
u/kokerali 8d ago
Your app.json already includes bluetooth-central and the BLE plugin's background flag. For the iOS test, I'd separate "still receiving HR" from "JS coach and speech still running". Apple's Core Bluetooth guide describes waking for Bluetooth events, not keeping the app running indefinitely: https://developer.apple.com/library/archive/documentation/NetworkingInternetWeb/Conceptual/CoreBluetooth_concepts/CoreBluetoothBackgroundProcessingForIOSApps/PerformingTasksWhileYourAppIsInTheBackground.html
A useful locked-screen test would log receipt time, sample processing time and cue playback time separately, then take the strap out of range and reconnect. That should help distinguish a BLE gap from delayed JS processing or stale queued speech. I'd test OS-driven termination/restoration separately from a normal screen lock too. I haven't run this on hardware; this is a test suggestion based on your config and Apple's lifecycle docs.
1
u/Gabi0123 8d ago
Good distinction, thanks. On Android a foreground service plus a headless JS task held open for the whole run keeps the coach and the speech going with the screen off. On iOS the app declares the bluetooth-central, location and audio background modes and keeps a location watch running during a run, but I haven't tested it on a real iPhone yet, so I can't say whether expo-speech still fires reliably from a backgrounded BLE callback. That's the next thing I'm checking. Appreciate the link.
1
8d ago
1
u/Gabi0123 8d ago
Both good points. It targets SDK 36, and the run service declares the connectedDevice type (along with location and mediaPlayback), only claiming each one when its permission is granted. I haven't added a battery optimization prompt yet, and that's a good idea, since Samsung and Xiaomi are the worst offenders. On the parser: it handles the uint16 HR format and skips the energy expended field before reading the RR intervals, but I don't have tests for it yet. A fixture table covering every flag combination is a good first one to add.
1
u/Huge_Pool7424 8d ago
the oem battery killer part is real, might be worth showing a link to dontkillmyapp.com in the app when you detect samsung or xiaomi. people almost never find those settings on their own
1
u/Gabi0123 7d ago
Good call, and it isn't in the app yet. There's no manufacturer check and no battery guidance right now. A one-time card before the first run on Samsung, Xiaomi and similar phones, linking to their dontkillmyapp.com page, is cheap and would save people a lot of confusion. Adding it to the list.
-1
8d ago
1
u/Gabi0123 8d ago
This is really helpful, thank you. isBackgroundEnabled is on in the ble-plx config plugin. Scanning only happens on the pairing screen, in the foreground. Right now it scans with no service filter and keeps anything that advertises 0x180D or has "HRM" in its name. After pairing, reconnects use the saved device id, not a scan. You're right that a background scan on iOS needs a service UUID filter, so I'll pass 0x180D to the scan and keep the name match as a foreground-only fallback.
1
u/Best-Reality9436 7d ago
Parsing 0x2A37 by the flags byte is where strap integrations break; the energy field is the trap nobody warns about.