GuidesModules

Bluetooth on the next client (preview)

@ovok/native/next/bluetooth: scan, pick, connect and save readings through the next Ovok client.

  • Stable unknown
  • Release candidate since 1.16.1-rc.4
  • Development since 1.16.1-dev.2129+1115230

@ovok/native/next/bluetooth finds supported Bluetooth devices, connects to them, collects their readings and saves each one through the next Ovok client. It replaces BTProvider and its hooks from @ovok/native/bt-management for apps on the next client. The Flutter SDK follows this contract, so its names match.

It is built in layers. Each layer works on its own, and each depends only on the layers below it:

LayerWhat it is
CoreBluetoothRuntime: scanning, connections, readings and saving, in plain TypeScript with no React and no BLE library.
PortsBluetoothScanner (with Peripheral and Link), BluetoothPermissions, ReadingSink and an OvokStorage: the BLE adapter, OS permissions, where readings go and where pairings persist.
DriversDeviceSupport, defineDevice and SIG_PROTOCOLS: which peripherals are supported and how to talk to them.
AdaptersbleManagerScanner, builtInDevices, osBluetoothPermissions, ovokReadingSink and createBluetoothRuntime: the ports and drivers on react-native-ble-plx, React Native and the Ovok client. In browsers, @ovok/native/next/bluetooth/web has the same runtime over Web Bluetooth.
HooksuseBluetooth for actions; useNearbyDevices, useMeasurements and the others for state.
UIBluetoothProvider and BluetoothDevicePicker: optional, built only from the public hooks.

Scan, pick, connect, save

import * as React from 'react';
import { Text } from 'react-native';
import { BleManager } from 'react-native-ble-plx';
import { BluetoothDevicePicker, BluetoothProvider, builtInDevices, createBluetoothRuntime, IntegratedDevices, useBluetooth, useMeasurements } from '@ovok/native/next/bluetooth';
import { ovok } from './ovok'; // the next Ovok client, signed in

const bluetooth = createBluetoothRuntime({ client: ovok, bleManager: new BleManager(), devices: [builtInDevices([IntegratedDevices.BP2])] });

export const App = () => <BluetoothProvider runtime={bluetooth}><Measure /></BluetoothProvider>;

const Measure = () => {
  const runtime = useBluetooth();
  const readings = useMeasurements();
  React.useEffect(() => { runtime.startScan().catch(() => undefined); }, [runtime]);
  return <><BluetoothDevicePicker visible={readings.length === 0} />{readings.map((r) => <Text key={r.id}>{r.save.status}</Text>)}</>;
};

startScan asks for the Bluetooth permission when it is not granted yet. Picking a device in the picker connects to it. Each reading appears in useMeasurements and is saved for the signed-in account with ovok.measurements.save. The reading's save.status moves from saving to saved, queued or failed.

The app owns the runtime: create it once and await bluetooth.dispose() when Bluetooth is no longer needed. dispose() stops scanning and resolves once every device has disconnected; after it, the snapshot no longer changes.

Actions and state

Every action is a method on the runtime, which useBluetooth() returns. Every hook only reads state, and re-renders when its slice changes.

Runtime methodWhat it does
startScan() / stopScan()Scans whenever the adapter is on. startScan asks for permission first.
connect(id) / disconnect(id)Connects for this session only.
pair(id) / forget(id)pair remembers the device in the client's secure storage and connects now, and again whenever the scan finds it. A device that keeps failing is retried with a growing delay.
requestPermission() / checkPermission() / openPermissionSettings()The Bluetooth permission.
retrySaves()Saves again the readings whose save failed, and flushes the readings the client queued offline.
dispose()Stops everything.
HookReturns
useBluetoothState()The adapter state: unknown, on, off, unauthorized or unsupported.
useBluetoothPermission()granted, denied, blocked or unknown. Re-checked whenever the app returns to the foreground, so a permission granted in the system settings shows up.
useScanning()Whether a scan is requested.
useNearbyDevices() / useNearbyDevice(id)The supported devices found, each with its connection state and last error.
usePairedDevices()The paired device IDs.
useMeasurements()The recent readings, oldest first. Readings waiting offline have save.status === 'queued'.

A device's connection is one of idle, connecting, connected, measuring, disconnected or failed. Every failure is a BluetoothError whose code says why, such as permission-denied, device-not-found, connection-failed or save-failed; the underlying error is its cause.

Build your own UI

BluetoothProvider and BluetoothDevicePicker are optional. The picker uses only useNearbyDevices and the runtime's connect, so any list can replace it:

import { Button } from 'react-native';
import { useBluetooth, useNearbyDevices } from '@ovok/native/next/bluetooth';

const DeviceList = () => {
  const runtime = useBluetooth();
  const devices = useNearbyDevices();

  return devices.map((device) => (
    <Button key={device.id} title={`${device.name}: ${device.connection}`} onPress={() => runtime.pair(device.id).catch(() => undefined)} />
  ));
};

Without React, use the runtime directly. subscribe calls back after every change and getSnapshot returns the current state:

const stop = bluetooth.subscribe(() => render(bluetooth.getSnapshot()));
await bluetooth.startScan();
await bluetooth.connect(deviceId);

Add a custom device

defineDevice adds a device without forking the SDK. Give it a key, how to recognise its advertisement and a protocol. The protocol talks to the device over a Link and yields readings:

import { MeasurementTypeKey } from '@ovok/core/next';
import { builtInDevices, createBluetoothRuntime, defineDevice, IntegratedDevices } from '@ovok/native/next/bluetooth';

const acmeThermometer = defineDevice({
  key: 'acme-thermometer',
  matches: ({ name }) => name.startsWith('ACME'),
  protocol: async function* (link) {
    for await (const bytes of link.subscribe('0000feed-0000-1000-8000-00805f9b34fb', '0000beef-0000-1000-8000-00805f9b34fb')) {
      yield { measurementTypeKey: MeasurementTypeKey.bodyTemperature, bodyTemperature: bytes[0] / 10 };
    }
  },
});

const bluetooth = createBluetoothRuntime({ client: ovok, bleManager, devices: [acmeThermometer, builtInDevices([IntegratedDevices.BP2])] });

Every reading this thermometer sends is kept, even two equal temperatures in a row. A device that stores readings and sends them again on the next connection should set each reading's recordedAt: a reading with a time is recognised when it comes again, and kept once.

The first entry of devices whose match recognises a peripheral handles it. The link connects with retries, ends its subscriptions when the device disconnects, and closes when the connection is cancelled. For full control, implement DeviceSupport yourself: match returns the device key and the Driver that runs the peripheral. A driver can name each reading with its own id.

A device declared with defineCustomDevice from @ovok/native/bt-management works too: pass it to builtInDevices.

After connecting and discovering services, the link reads Device Information (manufacturer name, model number and serial number). Each NearbyDevice then carries them as information, every DeviceReading.device repeats them, and ovokReadingSink saves them with the reading, together with the time the device took it (recordedAt) when it reported one. A custom driver sees the same values as link.deviceInformation and as a DeviceEvent of kind information.

A link.write waits until the notifications of the characteristics the link subscribed to are on, so a command is not sent before a device that requires indications is ready for it. When a read or a write fails because the device needs pairing, the link keeps the connection and tries the step again while the user finishes the system pairing dialog, for up to 30 seconds.

Which readings are kept

resultPolicy decides which readings of a connection are kept and saved. The default keeps every reading. firstReadingPerKind keeps the first of each measurement type, dailyTotalsOnly keeps activity totals once their day has closed, and policyByKind picks a policy per measurement type. acceptedMemorySlots([1]) keeps only the readings of those device users of a multi-user monitor or scale (the slot is the device's user index plus one, in memorySlot); a reading that names no user is kept. Combine it with policyByKind to apply it to some measurement types only.

Replace a port

createBluetoothRuntime wires the React Native defaults. To change one, build the runtime from its ports:

const bluetooth = new BluetoothRuntime({
  scanner: bleManagerScanner(bleManager),
  permissions: osBluetoothPermissions(),
  sink: ovokReadingSink(ovok), // or any ReadingSink, such as a local store
  devices: [builtInDevices([IntegratedDevices.BP2])],
  pairedStorage: ovok.storage,
});

A BluetoothScanner reports each Peripheral with an openLink that connects to it, so another BLE library, or a fake in tests, only has to implement that port.

Android foreground service

To keep connections alive while the app is in the background on Android, pass androidForegroundService to the provider. The service runs while the provider is mounted:

<BluetoothProvider runtime={bluetooth} androidForegroundService={{ notificationTitle: 'Measuring' }} onForegroundServiceError={report}>

Platform notes

  • Browsers: Chrome and Edge on desktop and Android, and Bluefy on iOS, run the same devices over Web Bluetooth. See Bluetooth in browsers.
  • Permissions: Android 12 and later asks for Bluetooth scan and connect; earlier Android asks for fine location, and also needs Location turned on to find devices. iOS asks for Bluetooth.
  • Bluetooth off: the scan waits and starts as soon as the adapter turns on. iOS cannot turn Bluetooth on for the user; show useBluetoothState() instead.
  • Reconnecting: a scan reports each peripheral once, so the runtime restarts the scan when a paired device disconnects, to find it again.

API reference

Every export, with signatures and types, is in the next/bluetooth API reference.

Last updated on

On this page