import { Platform } from 'react-native' import { BleManager, type Characteristic, type Device, type ScanOptions } from 'react-native-ble-plx' import { BLE_UUIDS } from '../protocol/Constants' import { type BleDevice, type BleError, ConnectionState, type ScanResult } from './types' 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()) || '').toString() if (!state.toLowerCase().includes('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, timeout: number = 30000): Promise { if (!this.manager) throw new Error('BLE not supported on web') // 创建超时 Promise const timeoutPromise = new Promise((_, reject) => { setTimeout(() => { reject(new Error(`Connection timeout after ${timeout / 1000}s`)) }, timeout) }) try { this.emit('connectionStateChange', { deviceId, state: ConnectionState.CONNECTING }) // 使用 Promise.race 实现超时控制 const connectPromise = this.manager.connectToDevice(deviceId, { autoConnect: false }) let device = await Promise.race([connectPromise, timeoutPromise]) if (!device) { throw new Error('Failed to connect: device is null') } // 连上后再单独请求 MTU(容错处理) if (Platform.OS === 'android') { try { if (typeof (device as any).requestMTU === 'function') { const newDevice = await (device as any).requestMTU?.(BLE_UUIDS.REQUEST_MTU) if (newDevice && typeof newDevice.mtu === 'number') { device = newDevice } else { console.warn('requestMTU did not return a device with MTU info, but continuing...') } await new Promise((resolve) => setTimeout(resolve, 500)) } else { console.warn('requestMTU not supported on this platform/device, but continuing...') } } catch (mtuErr) { console.warn('requestMTU failed, continuing without MTU change', mtuErr) } } await device.discoverAllServicesAndCharacteristics() 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: any, disconnectedDevice?: Device) => { this.connectedDevice = null const id = disconnectedDevice?.id || device.id || deviceId this.emit('connectionStateChange', { deviceId: id, state: ConnectionState.DISCONNECTED, }) this.emit('disconnected', disconnectedDevice || { id }) }) 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, } } }