Bluetooth
Add Bluetooth LE devices, collect readings, and build a reliable connection flow.
- Stable unknown
- Release candidate since 1.16.1-rc.3
- Development since 1.16.1-dev.2111+9796905
Use the Bluetooth module to discover supported BLE devices, connect to them, and receive normalized measurements. Your app owns the Bluetooth manager lifetime, the accepted device list, the user experience around measurement, and what happens to each result.
Before you start
- Follow Installation and native setup to install native peers and configure platform permissions.
- Test on a physical iOS or Android device. Simulators do not reproduce the full BLE scan, permission, and pairing path.
- Decide which device models the app will support. Review all supported Bluetooth devices and the supported device catalog; use custom device declarations only when the SDK does not already implement the protocol.
1. Create a long-lived BLE manager
Create one BleManager for the app or Bluetooth feature. Keep it outside a component render, or in a stable app-level ref, so a render does not replace the native manager.
import { BleManager } from "react-native-ble-plx";
import {
BTProvider,
IntegratedDevices,
} from "@ovok/native/bt-management";
const bleManager = new BleManager();
const acceptedDevices = [
IntegratedDevices.BP2,
IntegratedDevices.F4,
] as const;acceptedDevices is a typed allowlist. The SDK only creates handlers for entries in that list. Keep the array identity stable for the lifetime of the provider.
2. Mount BTProvider around the measurement flow
BTProvider requests Bluetooth access, creates the managed device runtime, starts discovery, and tears it down when it unmounts. Keep it mounted for as long as the flow should own the scan and connections.
export function MeasurementFlow() {
return (
<BTProvider
bleManager={bleManager}
acceptedDevices={acceptedDevices}
onDeviceFound={(device) => device.connect()}
onDeviceStatusChanged={({ deviceData, status }) => {
console.log(deviceData.name, status);
}}
onResult={(result) => {
const idempotencyKey = result.id;
const measurement = result.data;
void saveMeasurement({ idempotencyKey, measurement });
}}
onError={({ deviceData, error, code }) => {
reportBluetoothError(deviceData?.name, error, code);
}}
>
<MeasurementScreen />
</BTProvider>
);
}By default, the permission request runs when the provider mounts. If access is denied, the provider renders its default settings prompt; supply permissionFallback to render your own. To wait until a patient chooses to pair, set requestPermissionOnMount={false} and call useBluetoothState().requestPermission() from a child action. The provider stays mounted, but discovery starts only after that request succeeds. See BTProvider permission behavior for an example.
3. Handle readings and connection state
A result contains deviceData and normalized measurement data. It also has a stable id for deduplication. The SDK defines id as an enumerable property that is non-writable at runtime, so object spreads and JSON serialization include it. Pass it as an idempotency key when the app may retry uploads.
A stored record keeps its id whenever the device sends it again, in any time zone. A reading without a time of its own is a new measurement each time, so its id includes when it arrived. legacyId is the id SDKs before 1.16.1 gave the same reading: look it up to recognise readings an earlier version already saved.
Use onDeviceStatusChanged for the compact status values: Connected, Disconnected, Measuring, and LowBattery. Use onConnectionEvent for pairing prompts, bonding, battery values, and history transfer progress. A transfer's progress.total can be missing; show a count rather than a percentage unless a total is present.
The provider callbacks are app-owned boundaries. Persist, associate with a patient, validate, and upload measurements in your app. The SDK does not choose a patient or submit results to your backend.
4. Add a device selection and pairing experience
For apps that connect to whichever accepted device is nearby, enable autoConnect. One discovered device connects automatically after the settle delay; when multiple devices are found, handle onDeviceSelectionRequired and present a chooser such as BluetoothDevicePicker.
For a discover-and-pair screen, use PairingFlow. It reads nearby devices from the provider runtime and stores remembered device IDs through the storage adapter you supply. See Bluetooth state notice to give users a clear route to enable Bluetooth or permissions.
Keep device pairing secrets
Some devices hand the phone a secret once, at pairing, and expect it back on every later connection. Transtek's white-label blood-pressure monitors, scales and activity tracker do: the device sends a 32-bit password while it is in pairing mode, and every later session proves the phone holds it. The SDK keeps these secrets in memory for the app session. Give it durable, private storage so a paired device keeps working after the app restarts:
import * as SecureStore from "expo-secure-store";
import { setDeviceSecretStorage } from "@ovok/native";
setDeviceSecretStorage({
getItem: (key) => SecureStore.getItemAsync(key.replace(/[^A-Za-z0-9._-]/g, "_")),
setItem: (key, value) =>
SecureStore.setItemAsync(key.replace(/[^A-Za-z0-9._-]/g, "_"), value),
});Call it once at startup, before connecting. A device without a stored secret reports a bluetooth.pairing-required error: put it in pairing mode (on most Transtek devices, hold the Bluetooth or memory button until the display flashes) and connect again.
5. Add device UI where it helps
The package has two device catalog approaches:
- SupportedDevicesCatalogList shows the richer catalog with measurement types, manual links, artwork, and an optional selection callback.
- BTDeviceList renders the SDK's device-status list. Pair it with DeviceListProvider and optionally use BTDeviceInstruction for an available manual.
Catalog entries are metadata. Check each entry's acceptedDevices before offering a connect action; entries with no accepted device declaration cannot be passed to BTProvider.
For ECG readings, prepare the waveform samples and sampling rate from the result data in your app, then render them with EcgStripViewer. The viewer presents a trace; it does not parse a device result or interpret a rhythm.
Lifecycle and operational choices
- Mount one provider for one active scanning scope. Unmounting it stops scanning, detaches subscriptions, and destroys its managed
BTManager. - Use
useScanControlto pause and resume scanning without unmounting the provider. - Configure background delivery separately. It requires durable storage and native background configuration; it is opt-in.
- Tune result filtering only when the product requires it.
resultPoliciescan selectall,daily-totals-only,first-reading-per-kind-per-connection, orseriesat the device or measurement level. A device's entry can also listacceptedMemorySlots, so that only the readings of those device users are reported.
Streams as series
A device that streams, such as a heart-rate strap, a live oximeter or the Lepod, sends a value every second or so. With a series result policy (LIVE_RESULT_POLICY sets it for live devices), the manager keeps every live value in a series of one-second slots, one per device and kind of measurement, with null for a second nothing arrived in. A series closes when its device disconnects, after an hour, or after 15 minutes without a value, and is reported as one result: result.series holds every value ({ measurementTypeKey, start, periodMs, values }), and result.data each property's mean. Save it with @ovok/core's seriesObservations, which writes the values as valueSampledData.
- A disconnect ends the device's series, so it's saved straight away; reconnecting starts a new one.
- Pass
seriesStorage(the store your results queue uses;BTProviderpasses its background-sync storage) so a series survives the app closing. Without it, series live in memory. - A reading dated more than two minutes from now is a stored record, not a live value, and is reported on its own. So are the single measurements
LIVE_RESULT_POLICYkeeps asall: blood pressure, glucose and ECG recordings. - To show values before they are stored, read
manager.getOpenSeries()(each with itslatestvalues) andsubscribeOpenSeries, oruseOpenSeries(manager). Readings still waiting in aBackgroundSyncCoordinatorcome fromgetQueued()andsubscribe, oruseQueuedResults(coordinator). - A Viatom O2Ring, Oxyfit, KidsO2 or BabyO2 on the legacy O2 protocol, kept as a series, also streams its pulse wave when the ring serves one: raw 125 Hz samples (unitless, inverted, so systolic peaks are minima), handed over in results of up to five minutes and when the device disconnects.
result.seriesis{ measurementTypeKey: bloodOxygenPulseRate, start, periodMs: 8, values: { pulseWave } }, withnullwhere the sensor lost contact or samples never arrived; the id startswave:, andresult.dataholds onlymeasurementTypeKeyandrecordedAt. Its saturation and pulse rate still go into the one-second series. A ring that answers without a wave is polled for saturation and pulse rate as before. The five minutes being recorded are kept in memory only, so closing the app loses them. - Keep native permission and Bluetooth-on state distinct. Permission being granted does not guarantee that the radio is powered on.
Glucose meters
A Bluetooth SIG glucose meter (standardGlucoseDevice) keeps its readings until the app asks for them. After connecting, the SDK writes a request to the Record Access Control Point: the records after the newest one it has read, or every record when it has no store or nothing stored yet.
- Pass
recordCursorStoragetoBTManager(the store your results queue uses;BTProviderpasses its background-sync storage) so the next connection asks only for newer records. A meter that refuses the sequence-number filter is asked for every record instead. The SDK stores each meter's newest sequence number under@ovok/native/glucose-sequence/<device id>, counting a record it withholds too. - A meter that was reset starts numbering again, so its new records are skipped until it passes the stored number. Overwrite that entry with an empty string to download every record again.
result.data.bloodGlucoseis in mg/dL with one decimal, so a 5.5 mmol/L record reads 99.1 and converts back to 5.5.reportedUnitsays which unit the meter showed.- Each reading also carries
sequenceNumber,specimenandsampleLocation(seeGlucoseRecordDetails). - When the meter flags a result above or below what it can measure, the reading has
outOfRange: "high"or"low": itsbloodGlucoseis the edge of the meter's range, so do not store it as a measurement. A low battery, a hot or cold sensor or a time fault leaves the value usable and is listed insensorStatus. A record flagged as a failed test (sensor fault, too little blood, strip error, wrong strip, interrupted read, device fault) is withheld. - The Glucose Measurement Context characteristic (meal, tester, health, exercise and medication) is not read yet.
Viatom ER1 and BP2 history
An ER1 or BP2 keeps its recordings until the app asks: connecting only lists them, and device.syncHistory() (web: BluetoothRuntime.syncHistory(id)) reads them over the open connection.
- Pass
recordCursorStoragetoBTManager(web:builtInDevices(accepted, { recordCursorStorage })) and return a promise fromonResultthat resolves once the reading is queued or saved, such asreturn coordinator.enqueue(result). A later sync then opens only the files whose readings were not taken. The web runtime takes a reading once its sink saved or queued it. - A reading whose promise rejects, or whose callback returns nothing, leaves its file to be read again on the next sync.
- The SDK stores each device's file names under
@ovok/native/lepu-delivered-files/<device id>, only those the device still lists. Overwrite that entry with an empty string to read every file again.
Related references
- BTProvider and its callbacks
- Supported devices
- Custom devices
- Bluetooth components
- PairingFlow
- Background sync
API reference
Every export of @ovok/native/bt-management, with signatures, props and types, is in the bt-management API reference.
Last updated on