expo-duooomi-app/ble/core/BleClient.ts

333 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}
}