Skip to main content

BTProvider

BTProvider is the entry point for all Bluetooth (BLE) functionality in @ovok/native. It owns the scan lifecycle, parses incoming characteristics from supported medical devices, and surfaces every signal as a typed React callback.

This page is the authoritative reference for every prop the provider accepts. Earlier drafts of this doc listed invented callbacks (onScanError, onDeviceErrors, onMeasurementUpdated, onDeviceStatusUpdated, onStreamData, useFocusEffect). None of those exist in the SDK source. If you see them in legacy code, replace with the canonical names below.

Overview

BTProvider does five things:

  1. Wraps an external BleManager (from react-native-ble-plx) so the SDK owns scan start/stop.
  2. Filters scan results against your acceptedDevices tuple so unrelated peripherals (headphones, other phones) are ignored.
  3. Pairs + connects via BTManagedDevice.connect() (which you call from onDeviceFound). connect() internally calls subscribeToAllCharacteristics() — there is no separate call you need to make.
  4. Parses GATT characteristics into typed measurement objects per device kind.
  5. Emits status, error, and result callbacks every time the device's state advances.

The provider integrates with @react-navigation/native internally via useFocusEffect — scanning pauses automatically when the screen leaves focus. You do not pass a useFocusEffect prop; the integration is automatic.

Source of Truth

This page reflects the types at:

  • src/modules/bt-management/types/btprovider-props.ts
  • src/modules/bt-management/types/btprovider-children-props.ts
  • src/modules/bt-management/types/device-status.ts
  • src/modules/bt-management/types/error-callback.ts
  • src/modules/bt-management/types/result-callback.ts
  • src/modules/bt-management/types/device-status-change-callback.ts

Whenever the source-of-truth changes, this page changes — never the other way around.

Props

PropTypeRequiredDescription
bleManagerBleManagerBLE manager instance from react-native-ble-plx. Construct once at module top with new BleManager() and pass the same instance every render.
acceptedDevicesreadonly IntegratedDevices[]Tuple of device kinds to match while scanning, e.g. [IntegratedDevices.BP2] as const. The narrower the filter, the lower the BLE scan-slot pressure.
onDeviceFound(device: BTManagedDevice<T[number]>, manager: BTManager<T>) => Promise<void>Fires when scanning + filter matches a known integrated device. Async — your handler should await device.connect(). Connect handles characteristic subscription internally; you don't subscribe yourself. See on-device-found.
onError(data: ErrorCallback) => voidFires on BLE failures (permission denied mid-scan, GATT layer failures, BLE adapter off, parse errors). data = { error: BleError | Error | string; deviceData?: DeviceData }. See on-error.
onResult(data: ResultCallback<T[number]>) => voidFires after the SDK's internal characteristic monitor parses a complete measurement. data = { deviceData: DeviceData; data: Omit<MeasurementTypeForDevices<[T]>, 'caseType'> }. See on-result.
onDeviceStatusChanged(data: DeviceStatusChangeCallback) => voidFires on every device-status transition. data = { deviceData: DeviceData; status: DeviceStatus; measurementTypeKey?: MeasurementTypeKey }. See on-device-status-changed.
onAccessPermissionChanged(accessPermission: boolean) => voidFires when the OS bluetooth/location permission grant state flips (initial grant, Settings toggle, denial). Use this to drive a permission-fallback UI. See on-access-permission-changed.
permissionFallback() => React.ReactNodeRender-prop returning UI to show when bluetooth permissions are NOT granted. Typically a card with an "Open Settings" button.
childrenReactNodeThe tree that consumes BT context. Mount BTProvider inside <NavigationContainer> so its internal focus-based scan control works.

DeviceStatus enum — canonical 4 values

export enum DeviceStatus {
CONNECTED = "Connected",
DISCONNECTED = "Disconnected",
MEASURING = "Measuring",
LOW_BATTERY = "LowBattery",
}

That's the entire enum. There is no Connecting, Pairing, Ready, Syncing, Complete, Error, or Idle member — those are richer UX labels your app must derive from local state combined with the 4-value SDK signal:

App UI labelWhen to show it
"Idle"no callback has fired yet
"Connecting" / "Pairing"onDeviceFound fired but no Connected yet
"Connected"data.status === 'Connected'
"Measuring" / "Syncing"data.status === 'Measuring'
"Complete"Connected after onResult resolved (track in local state)
"Disconnected"data.status === 'Disconnected'
"Low Battery"data.status === 'LowBattery' (alongside connection state)
"Error"onError fired (NOT a DeviceStatus value)

Quick Example

import React, { useCallback } from "react";
import { BleManager } from "react-native-ble-plx";
import { BTProvider, DeviceStatus, IntegratedDevices } from "@ovok/native";

// Construct ONCE at module top — re-creating per render leaks BLE scanner instances.
const bleManager = new BleManager();
const acceptedDevices = [IntegratedDevices.BP2] as const;

function App() {
// Memoize EVERY callback. BTProvider re-registers them on each prop
// identity change; un-memoized handlers produce an infinite re-render
// loop that visibly thrashes the logs ("Setting onDeviceFound callback
// {}" repeating dozens of times) and prevents state transitions.
const handleDeviceFound = useCallback(async (device) => {
await device.connect();
// The SDK subscribes to the device's characteristics automatically as
// part of `connect()` — you do not need to subscribe manually.
}, []);

const handleError = useCallback((data) => {
console.error("BLE error:", data.error, "device:", data.deviceData);
}, []);

const handleResult = useCallback((data) => {
// data = { deviceData, data: { systolic, diastolic, heartRate, ... } }
saveMeasurement(data.data, data.deviceData);
}, []);

const handleDeviceStatusChanged = useCallback((data) => {
// data.status is one of the 4 DeviceStatus values
console.log(`Device ${data.deviceData.name}${data.status}`);
}, []);

const handleAccessPermissionChanged = useCallback((granted) => {
if (!granted) console.warn("BLE permission denied");
}, []);

return (
<BTProvider
bleManager={bleManager}
acceptedDevices={acceptedDevices}
onDeviceFound={handleDeviceFound}
onError={handleError}
onResult={handleResult}
onDeviceStatusChanged={handleDeviceStatusChanged}
onAccessPermissionChanged={handleAccessPermissionChanged}
permissionFallback={() => <PermissionDeniedCard />}
>
<YourAppComponents />
</BTProvider>
);
}

Healthcare Application Integration

A realistic flow that persists measurements to your app's data layer and switches UI based on the canonical 4-value enum:

import React, { useCallback, useState } from "react";
import { Alert } from "react-native";
import { BleManager } from "react-native-ble-plx";
import {
BTProvider,
DeviceStatus,
IntegratedDevices,
} from "@ovok/native";

const bleManager = new BleManager();
const acceptedDevices = [IntegratedDevices.BP2] as const;

function HealthcareApp() {
const [connectedNames, setConnectedNames] = useState(new Set<string>());

const handleDeviceFound = useCallback(async (device) => {
try {
await device.connect();
// SDK auto-subscribes inside connect(); do NOT call device.subscribeToAllCharacteristics() (private method).
setConnectedNames((prev) => new Set(prev).add(device.deviceData.name));
} catch (error) {
console.error("Connection failed:", error);
}
}, []);

const handleResult = useCallback(async (data) => {
// data.data is the parsed measurement, e.g. for BP2:
// { systolic, diastolic, pulseRate, irregularPulse, ... }
await saveMeasurement(data.data, data.deviceData);
}, []);

const handleDeviceStatusChanged = useCallback((data) => {
switch (data.status) {
case DeviceStatus.CONNECTED:
// Reached after successful pair + characteristic subscription.
break;
case DeviceStatus.MEASURING:
// data.measurementTypeKey tells you WHICH measurement is starting.
break;
case DeviceStatus.DISCONNECTED:
setConnectedNames((prev) => {
const next = new Set(prev);
next.delete(data.deviceData.name);
return next;
});
break;
case DeviceStatus.LOW_BATTERY:
Alert.alert(
"Low Battery",
`${data.deviceData.name} has low battery. Please charge it.`,
);
break;
}
}, []);

const handleError = useCallback((data) => {
const msg = typeof data.error === "string" ? data.error : data.error.message;
if (msg.includes("permission") || msg.includes("Permission")) {
Alert.alert("Permission Error", "Please grant Bluetooth permissions.");
} else {
Alert.alert("BLE Error", msg);
}
}, []);

return (
<BTProvider
bleManager={bleManager}
acceptedDevices={acceptedDevices}
onDeviceFound={handleDeviceFound}
onResult={handleResult}
onDeviceStatusChanged={handleDeviceStatusChanged}
onError={handleError}
>
<PatientDashboard connectedDevices={connectedNames} />
</BTProvider>
);
}

Multi-Device Management

Tracking richer per-device UI labels on top of the 4-value SDK signal:

import React, { useCallback, useState } from "react";
import { BleManager } from "react-native-ble-plx";
import {
BTProvider,
DeviceStatus,
IntegratedDevices,
} from "@ovok/native";

type UILabel =
| "idle"
| "connecting"
| "connected"
| "measuring"
| "complete"
| "disconnected"
| "low-battery"
| "error";

const bleManager = new BleManager();
const acceptedDevices = [
IntegratedDevices.BP2,
IntegratedDevices.SPO2,
] as const;

function MultiDeviceManager() {
const [labels, setLabels] = useState<Record<string, UILabel>>({});
const setLabel = useCallback((name: string, label: UILabel) => {
setLabels((prev) => ({ ...prev, [name]: label }));
}, []);

const handleDeviceFound = useCallback(async (device) => {
setLabel(device.deviceData.name, "connecting");
try {
await device.connect();
// SDK auto-subscribes inside connect(); do NOT call device.subscribeToAllCharacteristics() (private method).
} catch {
setLabel(device.deviceData.name, "error");
}
}, [setLabel]);

const handleDeviceStatusChanged = useCallback((data) => {
const name = data.deviceData.name;
switch (data.status) {
case DeviceStatus.CONNECTED: setLabel(name, "connected"); break;
case DeviceStatus.MEASURING: setLabel(name, "measuring"); break;
case DeviceStatus.DISCONNECTED: setLabel(name, "disconnected"); break;
case DeviceStatus.LOW_BATTERY: setLabel(name, "low-battery"); break;
}
}, [setLabel]);

const handleResult = useCallback((data) => {
// After a complete measurement parse, downgrade the UI from "measuring"
// back to "complete". The SDK does NOT emit a "Complete" enum value —
// you derive it from the (Measuring → onResult → Connected) sequence.
setLabel(data.deviceData.name, "complete");
}, [setLabel]);

const handleError = useCallback((data) => {
if (data.deviceData) setLabel(data.deviceData.name, "error");
}, [setLabel]);

return (
<BTProvider
bleManager={bleManager}
acceptedDevices={acceptedDevices}
onDeviceFound={handleDeviceFound}
onDeviceStatusChanged={handleDeviceStatusChanged}
onResult={handleResult}
onError={handleError}
>
<DeviceGrid labels={labels} />
</BTProvider>
);
}

Lifecycle and Scanning

The provider drives scanning off React Navigation's focus state internally. The shape, at a high level:

  1. Mount + screen gains focus → call BleManager.startDeviceScan(), filter against acceptedDevices.
  2. Matched advertisement → construct a BTManagedDevice<T[number]> and invoke onDeviceFound.
  3. Inside onDeviceFound, your handler calls await device.connect(). connect() internally subscribes to every characteristic the device exposes — you don't subscribe manually.
  4. As characteristics fire, the parser routes them to onResult (complete measurement), onDeviceStatusChanged (state transition), or onError (parse / GATT failures).
  5. Screen loses focus or unmount → scan stops, characteristic monitors removed, BTManagedDevice references dropped.

You do not control any of step 5 yourself; the provider's effect cleanup handles it. If you device.connect() manually outside onDeviceFound, you bypass this lifecycle and leak BLE connections — don't.

Permission Handling

onAccessPermissionChanged is the canonical permission signal. It fires:

  • Once on mount with the current grant state.
  • Whenever the user toggles Bluetooth/location permission in Settings while the app is alive.

When accessPermission === false, the provider renders permissionFallback() in place of children (so children that depend on BT context are never mounted without grants). See on-access-permission-changed for the full pattern.

Performance Considerations

  • useCallback every handler. Every prop identity change re-registers BTProvider's internal listener. Un-memoized handlers cause an infinite-update loop.
  • Construct BleManager once at module top. new BleManager() inside the component body leaks a scanner per render.
  • Narrow acceptedDevices. Each entry adds one parsing branch on every advertisement frame. Pass only the devices you actually pair with.
  • Mount inside <NavigationContainer>. Without it, the internal useFocusEffect throws.

Testing

import { render } from "@testing-library/react-native";
import { BleManager } from "react-native-ble-plx";
import { BTProvider, IntegratedDevices } from "@ovok/native";

const mockBleManager = {
state: jest.fn().mockResolvedValue("PoweredOn"),
startDeviceScan: jest.fn(),
stopDeviceScan: jest.fn(),
} as unknown as BleManager;

describe("BTProvider", () => {
it("starts a scan once mounted under NavigationContainer", () => {
const onDeviceFound = jest.fn();
render(
<NavigationContainer>
<BTProvider
bleManager={mockBleManager}
acceptedDevices={[IntegratedDevices.BP2] as const}
onDeviceFound={onDeviceFound}
>
<TestComponent />
</BTProvider>
</NavigationContainer>,
);
expect(mockBleManager.startDeviceScan).toHaveBeenCalled();
});
});

Troubleshooting

  • Scan never starts → BTProvider is mounted outside <NavigationContainer>, or the screen hasn't been focused yet. The provider's useFocusEffect cannot run otherwise.
  • onDeviceFound never firesacceptedDevices doesn't include the device kind being advertised, OR the platform permission grant is missing (onAccessPermissionChanged would have reported false).
  • Connecting shows forever → you switched on an invented enum value like 'Connecting' or 'Searching'. The SDK emits only the 4 canonical values; replace your switch.
  • Infinite re-renders + log spam → BTProvider callbacks aren't memoized. Wrap each with useCallback.
  • Permission UI never appears → you passed children that crash when BT context is absent; mount permissionFallback instead and put any BT-dependent UI inside children only.