import {BleManager, Device, Characteristic, BleError as PlxError, ScanOptions} from 'react-native-ble-plx'; import {Platform, PermissionsAndroid} from 'react-native'; import {BleDevice, ConnectionState, BleError, ScanResult} from './types'; import {BLE_UUIDS} from "@/ble"; export class BleClient { private static instance: BleClient; private manager: BleManager | null = null; private connectedDevice: Device | null = null; // Simple event system private listeners: Map> = new Map(); private constructor() { if (Platform.OS !== 'web') { this.manager = new BleManager(); } } public static getInstance(): BleClient { if (!BleClient.instance) { BleClient.instance = new BleClient(); } return BleClient.instance; } public addListener(event: string, callback: Function) { if (!this.listeners.has(event)) { this.listeners.set(event, new Set()); } this.listeners.get(event)!.add(callback); } public removeListener(event: string, callback: Function) { if (this.listeners.has(event)) { this.listeners.get(event)!.delete(callback); } } private emit(event: string, ...args: any[]) { if (this.listeners.has(event)) { this.listeners.get(event)!.forEach(cb => cb(...args)); } } public async startScan( serviceUUIDs: string[] | null = null, options: ScanOptions = {}, onDeviceFound: (result: ScanResult) => void ): Promise { if (!this.manager) { console.warn('BLE not supported on web'); return; } const state = await this.manager.state(); if (state !== 'PoweredOn') { throw new Error(`Bluetooth is not powered on. State: ${state}`); } this.manager.startDeviceScan(serviceUUIDs, options, (error, device) => { if (error) { this.emit('scanError', error); return; } if (device) { onDeviceFound({ device, rssi: device.rssi, localName: device.name }); } }); } public async getConnectedDevices(serviceUUIDs: string[]): Promise { if (!this.manager) return []; try { const devices = await this.manager.connectedDevices(serviceUUIDs); return devices as BleDevice[]; } catch (e) { throw this.normalizeError(e); } } public stopScan() { if (!this.manager) return; this.manager.stopDeviceScan(); } public async connect(deviceId: string): Promise { if (!this.manager) throw new Error('BLE not supported on web'); try { this.emit('connectionStateChange', {deviceId, state: ConnectionState.CONNECTING}); let device = await this.manager.connectToDevice(deviceId, { autoConnect: false, requestMTU: BLE_UUIDS.REQUEST_MTU }); if (device.mtu < BLE_UUIDS.REQUEST_MTU) { console.log("MTU not supported, requesting default to ", BLE_UUIDS.REQUEST_MTU); device = await device.requestMTU(BLE_UUIDS.REQUEST_MTU); // Give some time for the stack to stabilize after MTU change await new Promise(resolve => setTimeout(resolve, 500)); } console.log("Connected to device with MTU = ", device.mtu); this.connectedDevice = await device.discoverAllServicesAndCharacteristics(); this.emit('connectionStateChange', {deviceId, state: ConnectionState.CONNECTED}); // Handle disconnection monitoring device.onDisconnected((error, disconnectedDevice) => { this.connectedDevice = null; this.emit('connectionStateChange', { deviceId: disconnectedDevice.id, state: ConnectionState.DISCONNECTED }); this.emit('disconnected', disconnectedDevice); }); return this.connectedDevice; } catch (error: any) { this.emit('connectionStateChange', {deviceId, state: ConnectionState.DISCONNECTED}); throw this.normalizeError(error); } } public async disconnect(deviceId?: string): Promise { const id = deviceId || this.connectedDevice?.id; if (!id) return; if (!this.manager) return; try { this.emit('connectionStateChange', {deviceId: id, state: ConnectionState.DISCONNECTING}); await this.manager.cancelDeviceConnection(id); // onDisconnected callback will handle the state update } catch (error: any) { throw this.normalizeError(error); } } public async read( deviceId: string, serviceUUID: string, characteristicUUID: string ): Promise { if (!this.manager) throw new Error('BLE not supported on web'); try { const char = await this.manager.readCharacteristicForDevice( deviceId, serviceUUID, characteristicUUID ); return char.value || ''; // Base64 string } catch (e) { throw this.normalizeError(e); } } public async write( deviceId: string, serviceUUID: string, characteristicUUID: string, dataBase64: string, response: boolean = true ): Promise { if (!this.manager) throw new Error('BLE not supported on web'); let result: Characteristic | null; try { if (response) { result = await this.manager.writeCharacteristicWithResponseForDevice( deviceId, serviceUUID, characteristicUUID, dataBase64 ); } else { result = await this.manager.writeCharacteristicWithoutResponseForDevice( deviceId, serviceUUID, characteristicUUID, dataBase64 ); } return result } catch (e) { throw this.normalizeError(e); } } public async monitor( deviceId: string, serviceUUID: string, characteristicUUID: string, listener: (error: BleError | null, value: string | null) => void ) { if (!this.manager) { listener({message: 'BLE not supported on web', errorCode: 0, reason: null}, null); return { remove: () => { } }; } return this.manager.monitorCharacteristicForDevice( deviceId, serviceUUID, characteristicUUID, (error, char) => { if (error) { listener(this.normalizeError(error), null); } else { listener(null, char?.value || null); } } ); } public async requestMtu(deviceId: string, mtu: number): Promise { if (!this.manager) return 23; try { const device = await this.manager.requestMTUForDevice(deviceId, mtu); return device.mtu; } catch (e) { console.warn("MTU negotiation failed", e); // iOS doesn't allow explicit MTU request usually, so we might ignore or return default return 23; } } public getConnectedDevice(): Device | null { return this.connectedDevice; } private normalizeError(e: any): BleError { // wrapper to convert PlxError to our BleError return { errorCode: e.errorCode || 0, message: e.message || 'Unknown error', reason: e.reason }; } }