react-native-polar
v1.0.0
Published
React Native bridge for Polar sensors and watches on iOS and Android.
Maintainers
Readme
react-native-polar
React Native bridge around Polar's BLE SDK for live Polar sensor data.
See PARITY.md for the current SDK-to-bridge parity audit, including the small set of SDK hooks that remain intentionally internal or not locally provable from the iOS binary distribution.
Scope
- Connection lifecycle
- Device discovery
- Device time setup
- SDK mode toggling and status checks
- H10 exercise recording lifecycle and fetch/remove access
- Generic offline recording settings, status, trigger, list/fetch/remove, and start/stop access
- Passive HR broadcast listening
- Auto-connect and automatic reconnection controls
- FTU status/configuration and user device settings/configuration
- Activity and sleep data reads
- Direct device-administration helpers for charger state, connection mode, telemetry, sync, restart/reset, and data deletion
- Device utility reads for battery, RSSI, name, and disk space
- Connection wait, ATT MTU, device file access, feature-readiness checks, pairing-removal diagnostics, warehouse-sleep control, and device log-configuration read/write helpers
- Battery and heart-rate telemetry
- Online stream capability queries
- Hook-based JS API for:
HRECGACCPPGPPIGYROMAGNETOMETERPRESSURELOCATIONTEMPERATURESKIN_TEMPERATURE
The TypeScript surface and native bridge expose the stream and H10 exercise-recording APIs above. Automated JS, Android, and iOS example checks cover the shipped surface; real-device validation is still required for BLE behavior and stream fidelity.
Install
npm install react-native-polarExpo:
- Use this package with Expo development builds or prebuild, not Expo Go. Expo Go cannot load arbitrary native modules.
- Install the library in your Expo app, add the config plugin, then regenerate native projects.
- Set Android
minSdkVersionto33; Polar's Android SDK 8.x does not support lower API levels.
{
"expo": {
"plugins": [
[
"react-native-polar",
{
"iosBluetoothPermission": "Allow $(PRODUCT_NAME) to connect to nearby Polar sensors over Bluetooth."
}
],
[
"expo-build-properties",
{
"android": {
"minSdkVersion": 33
}
}
]
]
}
}npx expo install react-native-polar expo-dev-client expo-build-properties
npx expo prebuild
npx expo run:ios
npx expo run:androidThe config plugin adds the required Bluetooth usage descriptions on iOS, the Android 12+ BLE permissions (BLUETOOTH_SCAN with neverForLocation and BLUETOOTH_CONNECT), and sets android.enableJetifier=false in gradle.properties for Polar SDK 8 artifacts. If your app derives location from BLE scan results, override androidPermissions with a plain android.permission.BLUETOOTH_SCAN entry plus the location permissions you need.
iOS:
cd ios
pod installAndroid:
- Polar's current Android SDK line requires
minSdkVersion 33. - The current Android bridge path also expects a Kotlin 2.3-era Android toolchain.
- If the host app still enables Jetifier, disable it for Polar SDK 8 artifacts with
android.enableJetifier=false. - Ensure Bluetooth permissions are declared and requested at runtime.
- The library depends on Polar's Android BLE SDK through Gradle.
Usage
import React, { useEffect } from 'react';
import { Text, View } from 'react-native';
import { usePolarDevice } from 'react-native-polar';
export function PolarScreen() {
const deviceId = '8A1F2C4D';
const polar = usePolarDevice(deviceId);
useEffect(() => {
polar.onEcgData((event) => {
console.log('ECG samples', event.samples.length);
});
void polar.startEcg();
return () => {
void polar.stopEcg();
};
}, [polar]);
return (
<View>
<Text>{polar.connectionState}</Text>
<Text>{polar.heartRate ?? '--'} bpm</Text>
<Text>{polar.batteryLevel ?? '--'}%</Text>
</View>
);
}Connection ownership
Mounting usePolarDevice(deviceId) connects to the device and unmounting disconnects it, but connections are reference-counted across hook instances: the first mount for a given deviceId issues the native connect, later mounts adopt the existing connection state, and only the last unmount stops active streams and disconnects. Calling disconnect() explicitly always disconnects immediately, even if other hook instances still use the device. usePolarHrBroadcasts() shares the global broadcast listener the same way — it stops only when the last broadcast hook unmounts.
API
usePolarDevice(deviceId)usePolarH10(deviceId)compatibility aliasusePolarDiscovery()usePolarDiscovery().startScan(service?)usePolarHrBroadcasts(deviceIds?)startDeviceScan(service?)stopDeviceScan()autoConnectToDevice(options)setAutomaticReconnection(enabled)setPolarFilter(enabled)foregroundEntered()cleanup()shutDown()setApiLogger(listener)startBackgroundRecording(deviceId, sessionId, streams)stopBackgroundRecording(deviceId)getPendingRecordingChunks(sessionId?)readRecordingChunk(path)deleteRecordingChunk(path)getBackgroundRecordingStatus()isFtuDone(deviceId)getUserPhysicalConfiguration(deviceId)getUserDeviceSettings(deviceId)setUserDeviceSettings(deviceId, settings)setUserDeviceLocation(deviceId, location)doFirstTimeUse(deviceId, config)getAvailableOnlineStreamDataTypes(deviceId)getAvailableHRServiceDataTypes(deviceId)requestStreamSettings(deviceId, dataType)requestFullStreamSettings(deviceId, dataType)getSteps(deviceId, range)getActivitySampleData(deviceId, range)getDistance(deviceId, range)getActiveTime(deviceId, range)getCalories(deviceId, range, type)getDailySummaryData(deviceId, range)get247HrSamples(deviceId, range)get247PpiSamples(deviceId, range)getNightlyRecharge(deviceId, range)getSleep(deviceId, range)getSkinTemperature(deviceId, range)getSpo2TestData(deviceId, range)getSleepRecordingState(deviceId)stopSleepRecording(deviceId)getDeviceTime(deviceId)setDeviceTime(deviceId, iso8601)getChargerState(deviceId)getMultiBleConnectionMode(deviceId)setUsbConnectionMode(deviceId, enabled)setAutomaticTrainingDetectionSettings(deviceId, settings)setTelemetryEnabled(deviceId, enabled)setAutomaticOhrMeasurementEnabled(deviceId, enabled)setMultiBleConnectionMode(deviceId, enabled)setDaylightSavingTime(deviceId)doRestart(deviceId)doFactoryReset(deviceId, preservePairing?)turnDeviceOff(deviceId)sendInitializationAndStartSyncNotifications(deviceId)sendTerminateAndStopSyncNotifications(deviceId)listRestApiServices(deviceId)getRestApiDescription(deviceId, path)putNotification(deviceId, notification, path)receiveRestApiEvents(deviceId, listener)deleteStoredDeviceData(deviceId, dataType, date)deleteDeviceDateFolders(deviceId, range)deleteTelemetryData(deviceId)isSdkModeEnabled(deviceId)enableSdkMode(deviceId)disableSdkMode(deviceId)setLedConfig(deviceId, config)checkFirmwareUpdate(deviceId)updateFirmware(deviceId, version?)getWatchFaceConfig(deviceId)setWatchFaceConfig(deviceId, config)getTrainingSessionReferences(deviceId, range)deleteTrainingSession(deviceId, reference)getTrainingSession(deviceId, reference)getTrainingSessionWithProgress(deviceId, reference)startExercise(deviceId, sportProfile)pauseExercise(deviceId)resumeExercise(deviceId)stopExercise(deviceId)getExerciseStatus(deviceId)startExerciseRecording(deviceId, identifier, interval, sampleType)stopExerciseRecording(deviceId)getExerciseRecordingStatus(deviceId)listExercises(deviceId)fetchExercise(deviceId, entry)removeExercise(deviceId, entry)startOfflineExerciseV2(deviceId, sportProfile)stopOfflineExerciseV2(deviceId)getOfflineExerciseStatusV2(deviceId)listOfflineExercisesV2(deviceId, directoryPath?)fetchOfflineExerciseV2(deviceId, entry)removeOfflineExerciseV2(deviceId, entry)isOfflineExerciseV2Supported(deviceId)getAvailableOfflineRecordingDataTypes(deviceId)requestOfflineRecordingSettings(deviceId, dataType)requestFullOfflineRecordingSettings(deviceId, dataType)getOfflineRecordingStatus(deviceId)listOfflineRecordings(deviceId)getOfflineRecord(deviceId, entry, secret?)getOfflineRecordWithProgress(deviceId, entry, secret?)getSubRecordingCount(deviceId, entry)listSplitOfflineRecordings(deviceId)getSplitOfflineRecord(deviceId, entry, secret?)removeOfflineRecord(deviceId, entry)removeOfflineRecords(deviceId, entry)startOfflineRecording(deviceId, dataType, settings, secret?)stopOfflineRecording(deviceId, dataType)getOfflineRecordingTrigger(deviceId)setOfflineRecordingTrigger(deviceId, trigger, secret?)startHrBroadcastListening(deviceIds?)stopHrBroadcastListening()getBatteryLevel(deviceId)getRssiValue(deviceId)getDeviceName(deviceId)getDiskSpace(deviceId)waitForConnection(deviceId)readFile(deviceId, path)getFile(deviceId, path)writeFile(deviceId, path, data)deleteFileOrDirectory(deviceId, path)getFileList(deviceId, path, recursive)getLogConfig(deviceId)setLogConfig(deviceId, config)isFeatureReady(deviceId, feature)setWareHouseSleep(deviceId)checkIfDeviceDisconnectedDueRemovedPairing(deviceId)setMtu(mtu)
Platform Notes
getAvailableOnlineStreamDataTypes(deviceId)returns the data types reported by the connected device and current SDK mode. It is not a hard-coded capability list.startLocation()/stopLocation()andonLocationData()are Android-only in practice. Polar's Android SDK 8.x exposesstartLocationStreaming, while the current iOS SDK does not, so iOS rejects those calls.foregroundEntered()is Android-only in practice. The Polar iOS SDK does not expose a matching lifecycle hook, so iOS rejects this call.setMtu(mtu)is Android-only in practice. The Polar iOS SDK does not expose ATT MTU configuration, so iOS rejects this call.getSubRecordingCount(deviceId, entry)andremoveOfflineRecords(deviceId, entry)are iOS-only in practice. Polar's iOS SDK 8.x still exposes these deprecated split-record helpers, while Polar's Android SDK 8.x no longer does, so Android rejects these calls.startBackgroundRecording(deviceId, sessionId, streams)is a bridge-level convenience, not a Polar SDK feature. It spoolsHR,ECG,ACC, andPPIsamples to on-device JSONL chunk files natively, so recording survives JS reloads. UsegetPendingRecordingChunks(),readRecordingChunk(), anddeleteRecordingChunk()to drain the spool. Available on both iOS and Android; the spool stops when the device disconnects.
Hook fields:
connectionStateheartRaterrIntervalsbatteryLevelcontactStatuserrorsdkModeEnableddeviceTimeavailableStreamDataTypesreadyFeatures
Hook methods:
startStream(streamType)stopStream(streamType)refreshAvailableStreamDataTypes()disconnect()reconnect()getDeviceTime()setDeviceTime(iso8601)isSdkModeEnabled()enableSdkMode()disableSdkMode()checkFirmwareUpdate()updateFirmware(version?)getWatchFaceConfig()setWatchFaceConfig(config)waitForConnection()readFile(path)getFile(path)writeFile(path, data)deleteFileOrDirectory(path)getFileList(path, recursive)getLogConfig()setLogConfig(config)isFeatureReady(feature)setWareHouseSleep()checkIfDeviceDisconnectedDueRemovedPairing()getTrainingSessionReferences(range)deleteTrainingSession(reference)getTrainingSession(reference)getTrainingSessionWithProgress(reference)startExercise(sportProfile)pauseExercise()resumeExercise()stopExercise()getExerciseStatus()observeExerciseStatus()stopObservingExerciseStatus()startExerciseRecording(identifier, interval, sampleType)stopExerciseRecording()getExerciseRecordingStatus()listExercises()fetchExercise(entry)removeExercise(entry)getAvailableOfflineRecordingDataTypes()requestOfflineRecordingSettings(dataType)requestFullOfflineRecordingSettings(dataType)getOfflineRecordingStatus()listOfflineRecordings()getOfflineRecord(entry, secret?)removeOfflineRecord(entry)startOfflineRecording(dataType, settings, secret?)stopOfflineRecording(dataType)getOfflineRecordingTrigger()setOfflineRecordingTrigger(trigger, secret?)getBatteryLevel()getChargerState()getRssiValue()getDeviceName()getDiskSpace()getSteps(range)getActivitySampleData(range)getDistance(range)getActiveTime(range)getCalories(range, type)getDailySummaryData(range)get247HrSamples(range)get247PpiSamples(range)getNightlyRecharge(range)getSleep(range)getSleepRecordingState()stopSleepRecording()isFtuDone()getUserPhysicalConfiguration()getUserDeviceSettings()getMultiBleConnectionMode()setUserDeviceSettings(settings)setUserDeviceLocation(location)setUsbConnectionMode(enabled)setAutomaticTrainingDetectionSettings(settings)setTelemetryEnabled(enabled)setAutomaticOhrMeasurementEnabled(enabled)setMultiBleConnectionMode(enabled)setDaylightSavingTime()doFirstTimeUse(config)doRestart()doFactoryReset(preservePairing?)turnDeviceOff()sendInitializationAndStartSyncNotifications()sendTerminateAndStopSyncNotifications()deleteStoredDeviceData(dataType, date)deleteDeviceDateFolders(range)deleteTelemetryData()setLedConfig(config)
Auto-connect options:
rssiLimitservice?deviceType?
Configuration payloads:
PolarFirstTimeUseConfigPolarPhysicalConfigurationPolarUserDeviceSettingsPolarUserDeviceLocationPolarUserGenderPolarUserTypicalDay
Discovery hook fields:
isScanningdevicesstartScan()stopScan()clearDevices()
HR broadcast hook fields:
isListeningbroadcastsstartListening()stopListening()clearBroadcasts()
Connection helpers:
autoConnectToDevice({ rssiLimit, service?, deviceType? })setAutomaticReconnection(enabled)
Convenience methods:
startHr()/stopHr()startEcg()/stopEcg()startAcc()/stopAcc()startPpg()/stopPpg()startPpi()/stopPpi()startGyro()/stopGyro()startMagnetometer()/stopMagnetometer()startPressure()/stopPressure()startTemperature()/stopTemperature()startSkinTemperature()/stopSkinTemperature()
Stream listeners:
onEcgData()onAccelerometerData()onPpgData()onPpiData()onGyroData()onMagnetometerData()onPressureData()onTemperatureData()onSkinTemperatureData()onExerciseStatus()
Testing
npm run build
npm run lint
npm test -- --runInBandExample app verification:
cd example
npm run lint
npm test -- --runInBand
cd android
./gradlew assembleDebug
cd ios
xcodebuild -workspace PolarExample.xcworkspace -scheme PolarExample -configuration Debug -destination 'platform=iOS Simulator,name=iPhone 15,OS=18.4' test CODE_SIGNING_ALLOWED=NOSee TESTING.md for physical-device validation notes.
Example
See example/README.md for the test app scaffold.
