capacitor-bp-monitor
v1.0.1
Published
A Capacitor plugin for the SP10A Bluetooth blood pressure monitor
Maintainers
Readme
@intervalhealth/capacitor-bp-monitor
A Capacitor plugin for the SP10A Bluetooth blood pressure monitor
Install
To use npm
npm install @intervalhealth/capacitor-bp-monitorTo use yarn
yarn add @intervalhealth/capacitor-bp-monitorSync native files
npx cap syncDevice Identification
The SP10A blood pressure monitor advertises over BLE with:
| Property | Value |
|---|---|
| Broadcast name | QN-Hem |
| Broadcast service UUID | 0xFFC0 ← use as scan filter |
| Notify characteristic UUID | 0xFFC1 (device → app) |
| Write characteristic UUID | 0xFFC2 (app → device) |
| Max packet size | 20 bytes |
The device also exposes its MAC address in the Device Information Service → System ID characteristic (bytes in reversed order).
Communication Workflow
Daily measurement flow
APP Device (SP10A)
| |
|-------- startScan() -----------> | (filter on 0xFFC0 or name "QN-Hem")
|<------- deviceFound event ------ |
|-------- connect(deviceId) ------> |
|<------- connectionState: ready -- | (CCCD enabled on 0xFFC1)
| |
|<------- deviceInfo (0x12) ------- | every ~150 ms
|-------- 0x13 sync (auto) -------> | time + settings (sent once on first 0x12)
|<------- 0x14 ACK ---------------- |
| |
|<------- measurement (0x10) ------ | after user takes reading
|-------- 0x1F ACK (auto) --------> |
| |
|-------- fetchHistory() ---------> | sends 0x22
|<------- historyRecord × N ------- | streams 0x23 packets
|-------- 0x24 ACK each (auto) ---> |
|<------- historyComplete ---------- | all records received
|-------- disconnect() -----------> |Note: The plugin automatically sends the 0x13 time-sync and 0x1F / 0x24 ACKs. Your app only needs to call the public API methods.
Blood Pressure Values
Units
| Unit | Precision | Plugin output |
|------|-----------|--------------|
| mmHg | Whole integers | e.g. 120 |
| kPa | 0.1 kPa precision (raw × 0.1) | e.g. 16.0 |
When unit === "kPa", the device sends systolic/diastolic as values in 0.1 kPa units (e.g. raw 160 → 16.0 kPa). The plugin converts this automatically — systolic and diastolic in the event payload are always in the final unit.
Hypertension stage classification
The hypertensionStage value (0–5) must be interpreted using the standard field from the deviceInfo event, which reflects the standard configured on the device.
| Stage | China (CN) | USA (US) | Europe (EU) | Japan (JP) | |-------|-----------|----------|-------------|------------| | 0 | — | Normal | Optimal | Normal | | 1 | — | Elevated | Normal | High-normal | | 2 | Normal | Stage 1 | High-normal | Elevated | | 3 | High-normal | Stage 2 | Stage 1 | Stage 1 | | 4 | Stage 1 | — | Stage 2 | Stage 2 | | 5 | Stage 2/3 | — | Stage 3 | Stage 3 |
Measurement error codes
When result === "error", the errorCode field contains one of:
| Code | Meaning | |------|---------| | 1 | Device uncalibrated | | 2 | Cuff detached | | 3 | Systolic measurement failed | | 4 | Diastolic measurement failed | | 5 | No pulse detected | | 6 | Measurement timeout | | 7 | Over-pressurized | | 8 | Air leak detected | | 9 | No cuff attached |
Platform Notes
iOS
Scan behaviour
iOS CoreBluetooth does not allow filtering by service UUID at the scan level without a special entitlement. The plugin scans for all BLE devices and filters in software, accepting a peripheral if:
- Its advertisement includes service UUID
0xFFC0, or - Its name (from
peripheral.nameorCBAdvertisementDataLocalNameKey) starts with"QN"
Both sources are checked because peripheral.name can be nil on the very first discovery — iOS may not populate the cached name until the advertisement local name field has been seen at least once.
Device IDs on iOS vs Android
| Platform | deviceId format | Stability |
|---|---|---|
| Android | BLE MAC address — e.g. "AA:BB:CC:DD:EE:FF" | Stable across installs |
| iOS | CoreBluetooth UUID — e.g. "3A2B1C4D-XXXX-XXXX-XXXX-XXXXXXXXXXXX" | Stable within one app install; changes on reinstall |
Because iOS assigns its own UUID to each peripheral per app install, deviceId values are not portable between iOS and Android, and will change if the app is reinstalled on iOS.
Reconnect without scan (iOS)
When startMonitoring() reconnects after a disconnect, it calls centralManager.retrievePeripherals(withIdentifiers:) using the last known UUID. This lets the app reconnect to a known device without running a fresh scan — as long as the app has not been reinstalled since it first saw the device.
Enabling Bluetooth
Unlike Android (which can directly launch a system dialog via ACTION_REQUEST_ENABLE), iOS shows a system-managed alert automatically when CoreBluetooth is first used while Bluetooth is off. The app has no control over this alert and cannot detect when the user dismisses it without enabling Bluetooth — it will only be notified via the bluetoothStateChange event when BT actually turns on.
Background scanning
The plugin currently supports foreground use only. When the app moves to the background, iOS throttles BLE scan results significantly. True background scanning requires:
- The service UUID declared explicitly in the
scanForPeripherals(withServices:)call bluetooth-centralinUIBackgroundModesinInfo.plist(already added)- The Core Bluetooth background entitlement approved by Apple for App Store distribution
Without these, scan results will be delayed or missed when the app is backgrounded. Connected peripherals and their notifications continue to work in the background.
Permissions
The plugin requests Bluetooth permission lazily — CBCentralManager is created on the first call to startScan() or startMonitoring(), which triggers the system permission dialog. Calling checkPermissions() or requestPermissions() before scanning will also initialise the manager and show the prompt.
Add the following key to your Info.plist:
<key>NSBluetoothAlwaysUsageDescription</key>
<string>This app uses Bluetooth to connect to your blood pressure monitor.</string>Android
Permissions
On Android 12+ (API 31+), the plugin automatically requests BLUETOOTH_SCAN and BLUETOOTH_CONNECT at runtime before scanning. On older versions these permissions are granted at install time and no runtime request is needed.
If the Bluetooth radio is off when startScan() or startMonitoring() is called, the plugin shows the system ACTION_REQUEST_ENABLE dialog to prompt the user to turn it on.
Foreground service
BLE connections on Android require a foreground service to survive when the app is backgrounded. The plugin runs BleConnectionService as a foreground service with notification type connectedDevice. The service starts automatically when scanning begins and stays alive as long as a connection or monitoring session is active.
Add the following to your AndroidManifest.xml if not already present (the plugin's manifest merger handles this automatically via AAR):
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />API
checkPermissions()requestPermissions()getBluetoothState()startScan()stopScan()startMonitoring(...)stopMonitoring()connect(...)disconnect()getConnectionState()syncSettings(...)fetchHistory()addListener('bluetoothStateChange', ...)addListener('deviceFound', ...)addListener('connectionState', ...)addListener('deviceInfo', ...)addListener('measurement', ...)addListener('historyRecord', ...)addListener('historyComplete', ...)removeAllListeners()- Interfaces
- Type Aliases
checkPermissions()
checkPermissions() => Promise<BpPermissionStatus>Returns: Promise<BpPermissionStatus>
requestPermissions()
requestPermissions() => Promise<BpPermissionStatus>Returns: Promise<BpPermissionStatus>
getBluetoothState()
getBluetoothState() => Promise<BluetoothState>Returns the current Bluetooth radio state.
Use this to check if BT is on before calling startScan().
Also listen to the bluetoothStateChange event for reactive updates.
Returns: Promise<BluetoothState>
startScan()
startScan() => Promise<void>stopScan()
stopScan() => Promise<void>startMonitoring(...)
startMonitoring(options?: MonitoringOptions | undefined) => Promise<void>Enter monitoring mode: automatically scans, connects, reconnects, and (optionally) fetches history — all without JS intervention. Fires the same events as manual flow.
| Param | Type |
| ------------- | --------------------------------------------------------------- |
| options | MonitoringOptions |
stopMonitoring()
stopMonitoring() => Promise<void>Exit monitoring mode and disconnect the current device.
connect(...)
connect(options: { deviceId: string; }) => Promise<void>| Param | Type |
| ------------- | ---------------------------------- |
| options | { deviceId: string; } |
disconnect()
disconnect() => Promise<void>getConnectionState()
getConnectionState() => Promise<{ state: ConnectionState; }>Returns: Promise<{ state: ConnectionState; }>
syncSettings(...)
syncSettings(options: DeviceSettings) => Promise<void>Sync time and settings to the device (sends 0x13, awaits 0x14 ack). Time is synced automatically using the current device clock.
| Param | Type |
| ------------- | --------------------------------------------------------- |
| options | DeviceSettings |
fetchHistory()
fetchHistory() => Promise<{ records: BpRecord[]; }>Fetch all stored history records from the device (0x22 → 0x23 stream → 0x24 end).
Returns: Promise<{ records: BpReading[]; }>
addListener('bluetoothStateChange', ...)
addListener(eventName: 'bluetoothStateChange', listenerFunc: (state: BluetoothState) => void) => Promise<{ remove: () => void; }>| Param | Type |
| ------------------ | ----------------------------------------------------------------------------- |
| eventName | 'bluetoothStateChange' |
| listenerFunc | (state: BluetoothState) => void |
Returns: Promise<{ remove: () => void; }>
addListener('deviceFound', ...)
addListener(eventName: 'deviceFound', listenerFunc: (device: BleDevice) => void) => Promise<{ remove: () => void; }>| Param | Type |
| ------------------ | -------------------------------------------------------------------- |
| eventName | 'deviceFound' |
| listenerFunc | (device: BleDevice) => void |
Returns: Promise<{ remove: () => void; }>
addListener('connectionState', ...)
addListener(eventName: 'connectionState', listenerFunc: (event: { state: ConnectionState; }) => void) => Promise<{ remove: () => void; }>| Param | Type |
| ------------------ | ------------------------------------------------------------------------------------------- |
| eventName | 'connectionState' |
| listenerFunc | (event: { state: ConnectionState; }) => void |
Returns: Promise<{ remove: () => void; }>
addListener('deviceInfo', ...)
addListener(eventName: 'deviceInfo', listenerFunc: (info: DeviceInfo) => void) => Promise<{ remove: () => void; }>| Param | Type |
| ------------------ | -------------------------------------------------------------------- |
| eventName | 'deviceInfo' |
| listenerFunc | (info: DeviceInfo) => void |
Returns: Promise<{ remove: () => void; }>
addListener('measurement', ...)
addListener(eventName: 'measurement', listenerFunc: (reading: BpReading) => void) => Promise<{ remove: () => void; }>| Param | Type |
| ------------------ | --------------------------------------------------------------------- |
| eventName | 'measurement' |
| listenerFunc | (reading: BpReading) => void |
Returns: Promise<{ remove: () => void; }>
addListener('historyRecord', ...)
addListener(eventName: 'historyRecord', listenerFunc: (record: BpRecord) => void) => Promise<{ remove: () => void; }>| Param | Type |
| ------------------ | -------------------------------------------------------------------- |
| eventName | 'historyRecord' |
| listenerFunc | (record: BpReading) => void |
Returns: Promise<{ remove: () => void; }>
addListener('historyComplete', ...)
addListener(eventName: 'historyComplete', listenerFunc: (event: { records: BpRecord[]; }) => void) => Promise<{ remove: () => void; }>| Param | Type |
| ------------------ | ---------------------------------------------------------- |
| eventName | 'historyComplete' |
| listenerFunc | (event: { records: BpReading[]; }) => void |
Returns: Promise<{ remove: () => void; }>
removeAllListeners()
removeAllListeners() => Promise<void>Interfaces
BpPermissionStatus
| Prop | Type |
| --------------- | ----------------------------------------------------------- |
| bluetooth | PermissionState |
BluetoothState
| Prop | Type | Description |
| ------------- | -------------------- | ------------------------------------------------- |
| enabled | boolean | Whether the Bluetooth radio is currently enabled. |
MonitoringOptions
| Prop | Type | Description |
| ------------------ | -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| fetchHistory | boolean | If true (default), history is fetched automatically after the initial settings sync. Set to false if you only want live measurements without pulling stored records. |
DeviceSettings
| Prop | Type | Description |
| -------------- | --------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| unit | BloodPressureUnit | |
| volume | 0 | 1 | 2 | 5 | 4 | 3 | 0 = silent, 5 = loudest |
| language | string | ISO 639-1 language code, e.g. "en" |
| timezone | string | IANA timezone string, e.g. "America/New_York" |
| standard | 'CN' | 'US' | 'EU' | 'JP' | Blood pressure classification standard. Affects hypertensionStage labels in measurements. Defaults to "US" if not specified. - CN — China - US — United States - EU — Europe - JP — Japan |
BpReading
| Prop | Type | Description |
| ----------------------- | --------------------------------------------------------------- | ------------------------------------ |
| systolic | number | |
| diastolic | number | |
| heartRate | number | |
| timestamp | string | ISO 8601 UTC |
| unit | BloodPressureUnit | |
| hypertensionStage | HypertensionStage | 0=normal, 1–5 hypertension stages |
| result | MeasurementResult | |
| errorCode | 1 | 2 | 9 | 8 | 7 | 6 | 5 | 4 | 3 | Only present when result === 'error' |
| userId | number | |
BleDevice
| Prop | Type |
| -------------- | --------------------------- |
| deviceId | string |
| name | string | null |
| rssi | number |
DeviceInfo
| Prop | Type | Description |
| --------------------- | --------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| mac | string | |
| historyCount | number | |
| charging | boolean | |
| unit | BloodPressureUnit | |
| language | string | ISO 639-1 language code reported by device, e.g. "en" or "zh" |
| volume | number | Current volume level 0–5 |
| standard | 'CN' | 'US' | 'EU' | 'JP' | Blood pressure standard active on the device. Determines how hypertensionStage values in measurements should be labelled. - CN — China standard - US — USA standard - EU — European standard - JP — Japan standard |
| firmwareVersion | number | |
| bleVersion | number | |
Type Aliases
PermissionState
'prompt' | 'prompt-with-rationale' | 'granted' | 'denied'
ConnectionState
'disconnected' | 'scanning' | 'connecting' | 'ready' | 'error'
BloodPressureUnit
'mmHg' | 'kPa'
BpRecord
BpReading
HypertensionStage
3-bit field from device (0–7). 0 = normal, 1–5 are the standard stages; 6–7 reserved by device.
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7
MeasurementResult
'normal' | 'error'
