333 lines
10 KiB
TypeScript
333 lines
10 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)
|
||
}
|
||
}
|
||
|
||
public removeAllListeners() {
|
||
this.listeners.clear()
|
||
console.debug('All BLE event listeners cleared')
|
||
}
|
||
|
||
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)
|
||
}
|
||
}
|
||
|
||
async disconnect(): Promise<void> {
|
||
try {
|
||
if (this.connectedDevice) {
|
||
console.log(`Disconnecting from ${this.connectedDevice.id}`)
|
||
|
||
const deviceId = this.connectedDevice.id
|
||
|
||
// ✅ 修复:先触发断开状态,然后清理
|
||
this.emit('connectionStateChange', { deviceId, state: ConnectionState.DISCONNECTING })
|
||
|
||
try {
|
||
// ✅ 修复:安全断开,设置较短超时
|
||
await Promise.race([
|
||
this.connectedDevice.cancelConnection(),
|
||
new Promise((_, reject) => setTimeout(() => reject(new Error('Disconnect timeout')), 5000)),
|
||
])
|
||
} catch (disconnectError: any) {
|
||
// 忽略断开连接过程中的预期错误
|
||
const errorMsg = disconnectError?.message || String(disconnectError)
|
||
console.debug('Disconnect operation completed with:', errorMsg)
|
||
}
|
||
|
||
// ✅ 修复:无论断开是否成功,都清理状态
|
||
this.connectedDevice = null
|
||
this.emit('connectionStateChange', { deviceId, state: ConnectionState.DISCONNECTED })
|
||
|
||
console.log('Device disconnected successfully')
|
||
}
|
||
} catch (error: any) {
|
||
const errorMsg = error?.message || String(error)
|
||
console.error('Disconnect error:', errorMsg)
|
||
|
||
// 即使出现错误,也要清理状态
|
||
if (this.connectedDevice) {
|
||
const deviceId = this.connectedDevice.id
|
||
this.connectedDevice = null
|
||
this.emit('connectionStateChange', { deviceId, state: ConnectionState.DISCONNECTED })
|
||
}
|
||
|
||
// 只在严重错误时抛出异常
|
||
if (!this.isDisconnectionError(error)) {
|
||
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: -1, reason: 'platform_not_supported' }, null)
|
||
return {
|
||
remove: () => {},
|
||
}
|
||
}
|
||
|
||
try {
|
||
return this.manager.monitorCharacteristicForDevice(deviceId, serviceUUID, characteristicUUID, (error, char) => {
|
||
if (error) {
|
||
// ✅ 修复:安全处理错误,确保不传递 null/undefined 值
|
||
const normalizedError = this.normalizeError(error)
|
||
|
||
// 特别处理断开连接错误,避免崩溃
|
||
if (this.isDisconnectionError(error)) {
|
||
console.debug('Device disconnected during monitoring:', normalizedError.message)
|
||
}
|
||
|
||
listener(normalizedError, null)
|
||
} else {
|
||
listener(null, char?.value || null)
|
||
}
|
||
})
|
||
} catch (error: any) {
|
||
// ✅ 修复:捕获监听器设置时的异常
|
||
const normalizedError = this.normalizeError(error)
|
||
listener(normalizedError, null)
|
||
return {
|
||
remove: () => {},
|
||
}
|
||
}
|
||
}
|
||
|
||
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 {
|
||
// ✅ 修复:安全处理错误对象,确保所有字段都有有效值
|
||
const errorCode = typeof e?.errorCode === 'number' ? e.errorCode : -1
|
||
const message =
|
||
typeof e?.message === 'string' && e.message.trim()
|
||
? e.message
|
||
: typeof e === 'string' && e.trim()
|
||
? e
|
||
: 'Unknown BLE error'
|
||
const reason = typeof e?.reason === 'string' && e.reason.trim() ? e.reason : 'unknown'
|
||
|
||
return {
|
||
errorCode,
|
||
message,
|
||
reason,
|
||
}
|
||
}
|
||
|
||
private isDisconnectionError(error: any): boolean {
|
||
const message = error?.message || ''
|
||
const reason = error?.reason || ''
|
||
|
||
return (
|
||
message.toLowerCase().includes('disconnect') ||
|
||
message.toLowerCase().includes('gatt_conn_terminate') ||
|
||
reason.toLowerCase().includes('disconnect') ||
|
||
error?.errorCode === 19
|
||
) // GATT_CONN_TERMINATE_PEER_USER
|
||
}
|
||
}
|