257 lines
7.8 KiB
TypeScript
257 lines
7.8 KiB
TypeScript
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<string, Set<Function>> = 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<void> {
|
||
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<BleDevice[]> {
|
||
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<BleDevice> {
|
||
if (!this.manager) throw new Error('BLE not supported on web')
|
||
|
||
// 创建超时 Promise
|
||
const timeoutPromise = new Promise<never>((_, 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<void> {
|
||
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<string> {
|
||
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<Characteristic> {
|
||
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<number> {
|
||
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,
|
||
}
|
||
}
|
||
}
|