GuidesModules

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

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:

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 useScanControl to 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. resultPolicies can select all, daily-totals-only, first-reading-per-kind-per-connection, or series at the device or measurement level. A device's entry can also list acceptedMemorySlots, 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; BTProvider passes 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_POLICY keeps as all: blood pressure, glucose and ECG recordings.
  • To show values before they are stored, read manager.getOpenSeries() (each with its latest values) and subscribeOpenSeries, or useOpenSeries(manager). Readings still waiting in a BackgroundSyncCoordinator come from getQueued() and subscribe, or useQueuedResults(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.series is { measurementTypeKey: bloodOxygenPulseRate, start, periodMs: 8, values: { pulseWave } }, with null where the sensor lost contact or samples never arrived; the id starts wave:, and result.data holds only measurementTypeKey and recordedAt. 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 recordCursorStorage to BTManager (the store your results queue uses; BTProvider passes 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.bloodGlucose is in mg/dL with one decimal, so a 5.5 mmol/L record reads 99.1 and converts back to 5.5. reportedUnit says which unit the meter showed.
  • Each reading also carries sequenceNumber, specimen and sampleLocation (see GlucoseRecordDetails).
  • When the meter flags a result above or below what it can measure, the reading has outOfRange: "high" or "low": its bloodGlucose is 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 in sensorStatus. 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 recordCursorStorage to BTManager (web: builtInDevices(accepted, { recordCursorStorage })) and return a promise from onResult that resolves once the reading is queued or saved, such as return 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.

API reference

Every export of @ovok/native/bt-management, with signatures, props and types, is in the bt-management API reference.

Last updated on

On this page