UNPKG

react-native-ble-nitro

Version:

High-performance React Native BLE library built on Nitro Modules

729 lines (661 loc) 21.7 kB
import BleNitroNativeFactory, { NativeBleNitro } from './specs/NativeBleNitroFactory'; import { ScanFilter as NativeScanFilter, BLEDevice as NativeBLEDevice, BLEState as NativeBLEState, ScanCallback as NativeScanCallback, AndroidScanMode as NativeAndroidScanMode, } from './specs/NativeBleNitro'; export type ByteArray = number[]; export interface ScanFilter { serviceUUIDs?: string[]; rssiThreshold?: number; allowDuplicates?: boolean; androidScanMode?: AndroidScanMode; } export interface ManufacturerDataEntry { id: string; data: ByteArray; } export interface ManufacturerData { companyIdentifiers: ManufacturerDataEntry[]; } export interface BLEDevice { id: string; name: string; rssi: number; manufacturerData: ManufacturerData; serviceUUIDs: string[]; isConnectable: boolean; isConnected: boolean; } export type ScanCallback = (device: BLEDevice) => void; export type RestoreStateCallback = (connectedPeripherals: BLEDevice[]) => void; export type ConnectionCallback = ( success: boolean, deviceId: string, error: string ) => void; export type DisconnectEventCallback = ( deviceId: string, interrupted: boolean, error: string ) => void; export type OperationCallback = (success: boolean, error: string) => void; export type CharacteristicUpdateCallback = ( characteristicId: string, data: ByteArray ) => void; export type Subscription = { remove: () => void; }; export type AsyncSubscription = { remove: () => Promise<void>; } export enum BLEState { Unknown = 'Unknown', Resetting = 'Resetting', Unsupported = 'Unsupported', Unauthorized = 'Unauthorized', PoweredOff = 'PoweredOff', PoweredOn = 'PoweredOn', }; export enum AndroidScanMode { LowLatency = 'LowLatency', Balanced = 'Balanced', LowPower = 'LowPower', Opportunistic = 'Opportunistic', } export type BleNitroManagerOptions = { restoreIdentifier?: string; onRestoredState?: RestoreStateCallback; }; export function mapNativeBLEStateToBLEState(nativeState: NativeBLEState): BLEState { const map = { 0: BLEState.Unknown, 1: BLEState.Resetting, 2: BLEState.Unsupported, 3: BLEState.Unauthorized, 4: BLEState.PoweredOff, 5: BLEState.PoweredOn, }; return map[nativeState]; } export function mapAndroidScanModeToNativeAndroidScanMode(scanMode: AndroidScanMode): NativeAndroidScanMode { const map = { LowLatency: NativeAndroidScanMode.LowLatency, Balanced: NativeAndroidScanMode.Balanced, LowPower: NativeAndroidScanMode.LowPower, Opportunistic: NativeAndroidScanMode.Opportunistic, } return map[scanMode]; } export function convertNativeBleDeviceToBleDevice(nativeBleDevice: NativeBLEDevice): BLEDevice { return { ...nativeBleDevice, serviceUUIDs: BleNitroManager.normalizeGattUUIDs(nativeBleDevice.serviceUUIDs), manufacturerData: { companyIdentifiers: nativeBleDevice.manufacturerData.companyIdentifiers.map(entry => ({ id: entry.id, data: arrayBufferToByteArray(entry.data) })) } } } export function arrayBufferToByteArray(buffer: ArrayBuffer): ByteArray { return Array.from(new Uint8Array(buffer)); } export function byteArrayToArrayBuffer(data: ByteArray): ArrayBuffer { return new Uint8Array(data).buffer; } export class BleNitroManager { private _isScanning: boolean = false; private _connectedDevices: { [deviceId: string]: boolean } = {}; private _restoredStateCallback: RestoreStateCallback | null; private _restoredState: BLEDevice[] | null = null; private _restoreStateIdentifier: string | null = null; private Instance: NativeBleNitro; constructor(options?: BleNitroManagerOptions) { this._restoredStateCallback = options?.onRestoredState ?? null; this._restoreStateIdentifier = options?.restoreIdentifier ?? null; this.Instance = BleNitroNativeFactory.create(options?.restoreIdentifier, (peripherals: NativeBLEDevice[]) => this.onNativeRestoreStateCallback(peripherals)); } private onNativeRestoreStateCallback(peripherals: NativeBLEDevice[]) { if (!this._restoreStateIdentifier) return; const bleDevices = peripherals.map((peripheral) => convertNativeBleDeviceToBleDevice(peripheral)); bleDevices.forEach((device) => { this._connectedDevices[device.id] = device.isConnected; }); if (this._restoredStateCallback) { this._restoredStateCallback(bleDevices); } else { this._restoredState = bleDevices; } } /** * * Registers callback and returns restored peripheral state in it. Not working from 1.7.x upwards for singleton implementation! * @deprecated This method is deprecated and will be removed in 2.x, use onRestoredState option in BleNitroManageroptions instead! */ public onRestoredState(callback: RestoreStateCallback) { if (!this._restoreStateIdentifier) return; if (this._restoredState) { callback(this._restoredState); this._restoredState = null; } this._restoredStateCallback = callback; } /** * Converts a 16- oder 32-Bit UUID to a 128-Bit UUID * * @param uuid 16-, 32- or 128-Bit UUID as string * @returns Full 128-Bit UUID */ public static normalizeGattUUID(uuid: string): string { const cleanUuid = uuid.toLowerCase(); // 128-Bit UUID → normalisieren if (cleanUuid.length === 36 && cleanUuid.includes("-")) { return cleanUuid; } // GATT-Service UUIDs // 16- oder 32-Bit UUID → 128-Bit UUID const padded = cleanUuid.padStart(8, "0"); return `${padded}-0000-1000-8000-00805f9b34fb`; } public static normalizeGattUUIDs(uuids: string[]): string[] { return uuids.map((uuid) => BleNitroManager.normalizeGattUUID(uuid)); } /** * Start scanning for Bluetooth devices * @param filter Optional scan filter * @param callback Callback function called when a device is found * @returns void */ public startScan( filter: ScanFilter = {}, callback: ScanCallback, onError?: (error: string) => void, ): void { if (this._isScanning) { return; } // Create native scan filter with defaults const nativeFilter: NativeScanFilter = { serviceUUIDs: filter.serviceUUIDs || [], rssiThreshold: filter.rssiThreshold ?? -100, allowDuplicates: filter.allowDuplicates ?? false, androidScanMode: mapAndroidScanModeToNativeAndroidScanMode(filter.androidScanMode ?? AndroidScanMode.Balanced), }; // Create callback wrapper const scanCallback: NativeScanCallback = (device: NativeBLEDevice | null, error: string | null) => { if (error && !device) { this._isScanning = false; onError?.(error); return; } device = device!; // eslint-disable-line @typescript-eslint/no-non-null-assertion // Convert manufacturer data to Uint8Arrays const convertedDevice: BLEDevice = convertNativeBleDeviceToBleDevice(device); callback(convertedDevice); }; // Start scan this.Instance.startScan(nativeFilter, scanCallback); this._isScanning = true; } /** * Stop scanning for Bluetooth devices * @returns void */ public stopScan(): void { if (!this._isScanning) { return; } this.Instance.stopScan(); this._isScanning = false; } /** * Check if currently scanning for devices * @returns Boolean indicating if currently scanning */ public isScanning(): boolean { this._isScanning = this.Instance.isScanning(); return this._isScanning; } /** * Get all currently connected devices * @param services Optional list of service UUIDs to filter by * @returns Array of connected devices */ public getConnectedDevices(services?: string[]): BLEDevice[] { const devices = this.Instance.getConnectedDevices(services || []); // Normalize service UUIDs - manufacturer data already comes as ArrayBuffers return devices.map(device => convertNativeBleDeviceToBleDevice(device)); } /** * Connect to a Bluetooth device * @param deviceId ID of the device to connect to * @param onDisconnect Optional callback for disconnect events * @returns Promise resolving deviceId when connected */ public connect( deviceId: string, onDisconnect?: DisconnectEventCallback, autoConnectAndroid?: boolean, ): Promise<string> { return new Promise((resolve, reject) => { // Check if already connected if (this._connectedDevices[deviceId]) { resolve(deviceId); return; } this.Instance.connect( deviceId, (success: boolean, connectedDeviceId: string, error: string) => { if (success) { this._connectedDevices[deviceId] = true; resolve(connectedDeviceId); } else { reject(new Error(error)); } }, onDisconnect ? (deviceId: string, interrupted: boolean, error: string) => { // Remove from connected devices when disconnected delete this._connectedDevices[deviceId]; onDisconnect(deviceId, interrupted, error); } : undefined, autoConnectAndroid ?? false, ); }); } /** * Scans for a device and connects to it * @param deviceId ID of the device to connect to * @param scanTimeout Optional timeout for the scan in milliseconds (default: 5000ms) * @returns Promise resolving deviceId when connected */ public findAndConnect(deviceId: string, options?: { scanTimeout?: number, autoConnectAndroid?: boolean, onDisconnect?: DisconnectEventCallback }): Promise<string> { const isConnected = this.isConnected(deviceId); if (isConnected) { return Promise.resolve(deviceId); } if (this._isScanning) { this.stopScan(); } return new Promise((resolve, reject) => { const timeoutScan = setTimeout(() => { this.stopScan(); reject(new Error('Scan timed out')); }, options?.scanTimeout ?? 5000); this.startScan(undefined, (device) => { if (device.id === deviceId) { this.stopScan(); clearTimeout(timeoutScan); this.connect(deviceId, options?.onDisconnect, options?.autoConnectAndroid).then(async (connectedDeviceId) => { resolve(connectedDeviceId); }).catch((error) => { reject(error); }); } }); }); } /** * Disconnect from a Bluetooth device * @param deviceId ID of the device to disconnect from * @returns Promise resolving when disconnected */ public disconnect(deviceId: string): Promise<void> { return new Promise((resolve, reject) => { // Check if already disconnected if (!this._connectedDevices[deviceId]) { resolve(); return; } this.Instance.disconnect( deviceId, (success: boolean, error: string) => { if (success) { delete this._connectedDevices[deviceId]; resolve(); } else { reject(new Error(error)); } } ); }); } /** * Check if connected to a device * @param deviceId ID of the device to check * @returns Boolean indicating if device is connected */ public isConnected(deviceId: string): boolean { return this.Instance.isConnected(deviceId); } /** * Request a new MTU size * @param deviceId ID of the device * @param mtu New MTU size, min is 23, max is 517 * @returns On Android: new MTU size; on iOS: current MTU size as it is handled by iOS itself; on error: -1 */ public requestMTU(deviceId: string, mtu: number): number { mtu = parseInt(mtu.toString(), 10); const deviceMtu = this.Instance.requestMTU(deviceId, mtu); return deviceMtu; } /** * Read RSSI for a connected device * @param deviceId ID of the device * @returns Promise resolving to RSSI value */ public readRSSI(deviceId: string): Promise<number> { return new Promise((resolve, reject) => { // Check if connected first if (!this._connectedDevices[deviceId]) { reject(new Error('Device not connected')); return; } this.Instance.readRSSI( deviceId, (success: boolean, rssi: number, error: string) => { if (success) { resolve(rssi); } else { reject(new Error(error)); } } ); }); } /** * Discover services for a connected device * @param deviceId ID of the device * @returns Promise resolving when services are discovered */ public discoverServices(deviceId: string): Promise<boolean> { return new Promise((resolve, reject) => { // Check if connected first if (!this._connectedDevices[deviceId]) { reject(new Error('Device not connected')); return; } this.Instance.discoverServices( deviceId, (success: boolean, error: string) => { if (success) { resolve(true); } else { reject(new Error(error)); } } ); }); } /** * Get services for a connected device * @param deviceId ID of the device * @returns Promise resolving to array of service UUIDs */ public getServices(deviceId: string): Promise<string[]> { return new Promise(async (resolve, reject) => { // Check if connected first if (!this._connectedDevices[deviceId]) { reject(new Error('Device not connected')); return; } const success = await this.discoverServices(deviceId); if (!success) { reject(new Error('Failed to discover services')); return; } const services = this.Instance.getServices(deviceId); resolve(BleNitroManager.normalizeGattUUIDs(services)); }); } /** * Get characteristics for a service * @param deviceId ID of the device * @param serviceId ID of the service * @returns array of characteristic UUIDs */ public getCharacteristics( deviceId: string, serviceId: string ): string[] { if (!this._connectedDevices[deviceId]) { throw new Error('Device not connected'); } const characteristics = this.Instance.getCharacteristics( deviceId, BleNitroManager.normalizeGattUUID(serviceId), ); return BleNitroManager.normalizeGattUUIDs(characteristics); } /** * Get services and characteristics for a connected device * @param deviceId ID of the device * @returns Promise resolving to array of service and characteristic UUIDs * @see getServices * @see getCharacteristics */ public async getServicesWithCharacteristics(deviceId: string): Promise<{ uuid: string; characteristics: string[] }[]> { await this.discoverServices(deviceId); const services = await this.getServices(deviceId); return services.map((service) => { return { uuid: service, characteristics: this.getCharacteristics(deviceId, service), }; }); } /** * Read a characteristic value * @param deviceId ID of the device * @param serviceId ID of the service * @param characteristicId ID of the characteristic * @returns Promise resolving to the characteristic data as ByteArray */ public readCharacteristic( deviceId: string, serviceId: string, characteristicId: string ): Promise<ByteArray> { return new Promise((resolve, reject) => { // Check if connected first if (!this._connectedDevices[deviceId]) { reject(new Error('Device not connected')); return; } this.Instance.readCharacteristic( deviceId, BleNitroManager.normalizeGattUUID(serviceId), BleNitroManager.normalizeGattUUID(characteristicId), (success: boolean, data: ArrayBuffer, error: string) => { if (success) { resolve(arrayBufferToByteArray(data)); } else { reject(new Error(error)); } } ); }); } /** * Write a value to a characteristic * @param deviceId ID of the device * @param serviceId ID of the service * @param characteristicId ID of the characteristic * @param data Data to write as ByteArray (number[]) * @param withResponse Whether to wait for response * @returns Promise resolving with response data (empty ByteArray when withResponse=false) */ public writeCharacteristic( deviceId: string, serviceId: string, characteristicId: string, data: ByteArray, withResponse: boolean = true ): Promise<ByteArray> { return new Promise((resolve, reject) => { // Check if connected first if (!this._connectedDevices[deviceId]) { reject(new Error('Device not connected')); return; } this.Instance.writeCharacteristic( deviceId, BleNitroManager.normalizeGattUUID(serviceId), BleNitroManager.normalizeGattUUID(characteristicId), byteArrayToArrayBuffer(data), withResponse, (success: boolean, responseData: ArrayBuffer, error: string) => { if (success) { // Convert ArrayBuffer response to ByteArray const responseByteArray = arrayBufferToByteArray(responseData); resolve(responseByteArray); } else { reject(new Error(error)); } } ); }); } /** * Subscribe to characteristic notifications * @param deviceId ID of the device * @param serviceId ID of the service * @param characteristicId ID of the characteristic * @param callback Callback function called when notification is received * @returns Promise resolving to AsyncSubscription when subscription is established */ public async subscribeToCharacteristic( deviceId: string, serviceId: string, characteristicId: string, callback: CharacteristicUpdateCallback ): Promise<AsyncSubscription> { return new Promise((resolve, reject) => { // Check if connected first if (!this._connectedDevices[deviceId]) { reject(new Error('Device not connected')); return; } this.Instance.subscribeToCharacteristic( deviceId, BleNitroManager.normalizeGattUUID(serviceId), BleNitroManager.normalizeGattUUID(characteristicId), (charId: string, data: ArrayBuffer) => { callback(charId, arrayBufferToByteArray(data)); }, (success: boolean, error: string) => { if (!success) { reject(new Error(error || 'Failed to subscribe to characteristic')); return; } const sub: AsyncSubscription = { remove: async () => { await this.unsubscribeFromCharacteristic( deviceId, serviceId, characteristicId ).catch(() => {}); } }; resolve(sub); } ); }); } /** * Unsubscribe from characteristic notifications * @param deviceId ID of the device * @param serviceId ID of the service * @param characteristicId ID of the characteristic * @returns Promise resolving when unsubscription is complete */ public unsubscribeFromCharacteristic( deviceId: string, serviceId: string, characteristicId: string ): Promise<void> { return new Promise((resolve, reject) => { // Check if connected first if (!this._connectedDevices[deviceId]) { reject(new Error('Device not connected')); return; } this.Instance.unsubscribeFromCharacteristic( deviceId, BleNitroManager.normalizeGattUUID(serviceId), BleNitroManager.normalizeGattUUID(characteristicId), (success: boolean, error: string) => { if (success) { resolve(); } else { reject(new Error(error)); } } ); }); } /** * Check if Bluetooth is enabled * @returns returns Boolean according to Bluetooth state */ public isBluetoothEnabled(): boolean { return this.state() === BLEState.PoweredOn; } /** * Request to enable Bluetooth (Android only) * @returns Promise resolving when Bluetooth is enabled */ public requestBluetoothEnable(): Promise<boolean> { return new Promise((resolve, reject) => { this.Instance.requestBluetoothEnable( (success: boolean, error: string) => { if (success) { resolve(true); } else { reject(new Error(error)); } } ); }); } /** * Get the current Bluetooth state * @returns Bluetooth state * @see BLEState */ public state(): BLEState { return mapNativeBLEStateToBLEState(this.Instance.state()); } /** * Subscribe to Bluetooth state changes * @param callback Callback function called when state changes * @param emitInitial Whether to emit initial state callback * @returns Subscription * @see BLEState */ public subscribeToStateChange(callback: (state: BLEState) => void, emitInitial = false): Subscription { if (emitInitial) { const state = this.state(); callback(state); } this.Instance.subscribeToStateChange((nativeState: NativeBLEState) => { callback(mapNativeBLEStateToBLEState(nativeState)); }); return { remove: () => { this.Instance.unsubscribeFromStateChange(); }, }; } /** * Open Bluetooth settings * @returns Promise resolving when settings are opened */ public openSettings(): Promise<void> { return this.Instance.openSettings(); } }