439 lines
14 KiB
TypeScript
439 lines
14 KiB
TypeScript
/**
|
|
* Duooomi BLE SDK - Brownfield API Layer
|
|
*
|
|
* 对外暴露的蓝牙 SDK 接口,通过 expo-brownfield 的消息机制与原生 App 通信。
|
|
* 原生 App 发送 action 消息 → SDK 执行蓝牙操作 → 通过事件/SharedState 返回结果。
|
|
*/
|
|
import Brownfield, {
|
|
addMessageListener,
|
|
sendMessage,
|
|
setSharedStateValue,
|
|
} from 'expo-brownfield'
|
|
import { Platform, PermissionsAndroid, Linking, Alert } from 'react-native'
|
|
import { ScanMode } from 'react-native-ble-plx'
|
|
|
|
import { BleClient } from '../core/BleClient'
|
|
import { type BleDevice, type BleError, ConnectionState } from '../core/types'
|
|
import { BLE_UUIDS, COMMAND_TYPES, EVENT_TYPES } from '../protocol/Constants'
|
|
import {
|
|
type BindingResponse,
|
|
type DeleteFileResponse,
|
|
type DeviceInfo,
|
|
type PrepareTransferResponse,
|
|
type UnBindResponse,
|
|
type VersionInfo,
|
|
} from '../protocol/types'
|
|
import { BleProtocolService } from '../services/BleProtocolService'
|
|
import { DeviceInfoService } from '../services/DeviceInfoService'
|
|
import { FileTransferService } from '../services/FileTransferService'
|
|
|
|
// ====== SharedState Keys ======
|
|
const STATE_KEYS = {
|
|
BT_STATE: 'btState', // 'idle' | 'scanning' | 'connecting' | 'connected' | 'disconnected'
|
|
CONNECTED_DEVICE: 'connectedDevice', // { id, name } | null
|
|
DEVICE_INFO: 'deviceInfo', // DeviceInfo | null
|
|
VERSION: 'version', // string
|
|
IS_ACTIVATED: 'isActivated', // boolean
|
|
TRANSFER_PROGRESS: 'transferProgress', // number 0-100
|
|
ERROR: 'error', // string | null
|
|
DISCOVERED_DEVICES: 'discoveredDevices', // Array<{ id, name, rssi }>
|
|
} as const
|
|
|
|
class BleSDK {
|
|
private bleClient = BleClient.getInstance()
|
|
private deviceInfoService = DeviceInfoService.getInstance()
|
|
private fileTransferService = FileTransferService.getInstance()
|
|
private protocolService = BleProtocolService.getInstance()
|
|
|
|
private connectedDeviceId: string | null = null
|
|
private pendingOperations = new Map<
|
|
string,
|
|
{
|
|
resolve: (value: any) => void
|
|
reject: (reason?: any) => void
|
|
timeoutId: ReturnType<typeof setTimeout>
|
|
}
|
|
>()
|
|
|
|
// 批量设备发现缓冲
|
|
private pendingDevices: Array<{ id: string; name: string | null; rssi: number | null }> = []
|
|
private flushTimer: ReturnType<typeof setTimeout> | null = null
|
|
|
|
initialize() {
|
|
this.setupBleListeners()
|
|
this.setupMessageHandler()
|
|
this.updateState(STATE_KEYS.BT_STATE, 'idle')
|
|
console.log('[BleSDK] Initialized')
|
|
}
|
|
|
|
// ====== 设置 BLE 事件监听 ======
|
|
private setupBleListeners() {
|
|
this.bleClient.addListener('connectionStateChange', ({ deviceId, state }: { deviceId: string; state: ConnectionState }) => {
|
|
const stateMap: Record<ConnectionState, string> = {
|
|
[ConnectionState.CONNECTING]: 'connecting',
|
|
[ConnectionState.CONNECTED]: 'connected',
|
|
[ConnectionState.DISCONNECTING]: 'disconnecting',
|
|
[ConnectionState.DISCONNECTED]: 'disconnected',
|
|
}
|
|
this.updateState(STATE_KEYS.BT_STATE, stateMap[state] || 'idle')
|
|
|
|
if (state === ConnectionState.DISCONNECTED) {
|
|
this.connectedDeviceId = null
|
|
this.updateState(STATE_KEYS.CONNECTED_DEVICE, null)
|
|
}
|
|
|
|
sendMessage({ event: 'connectionStateChange', deviceId, state })
|
|
})
|
|
|
|
this.bleClient.addListener('scanError', (error: BleError) => {
|
|
this.updateState(STATE_KEYS.ERROR, error.message)
|
|
sendMessage({ event: 'error', type: 'scanError', message: error.message })
|
|
})
|
|
|
|
// 设备信息响应
|
|
this.deviceInfoService.addListener(EVENT_TYPES.DEVICE_INFO.name, (info: DeviceInfo) => {
|
|
this.updateState(STATE_KEYS.DEVICE_INFO, info)
|
|
this.resolvePending('deviceInfo', info)
|
|
sendMessage({ event: 'deviceInfo', data: info })
|
|
})
|
|
|
|
this.deviceInfoService.addListener(EVENT_TYPES.VERSION_INFO.name, (info: VersionInfo) => {
|
|
this.updateState(STATE_KEYS.VERSION, info.version)
|
|
this.resolvePending('versionInfo', info)
|
|
sendMessage({ event: 'versionInfo', data: info })
|
|
})
|
|
|
|
this.deviceInfoService.addListener(EVENT_TYPES.BIND_DEVICE.name, (resp: BindingResponse) => {
|
|
this.updateState(STATE_KEYS.IS_ACTIVATED, resp.success === 1)
|
|
if (resp.success === 1) {
|
|
this.resolvePending('bindDevice', resp)
|
|
} else {
|
|
this.rejectPending('bindDevice', 'Device already bound to another user')
|
|
}
|
|
sendMessage({ event: 'bindDevice', data: resp })
|
|
})
|
|
|
|
this.deviceInfoService.addListener(EVENT_TYPES.UNBIND_DEVICE.name, (resp: UnBindResponse) => {
|
|
if (resp.success === 1) {
|
|
this.updateState(STATE_KEYS.IS_ACTIVATED, false)
|
|
this.resolvePending('unbindDevice', resp)
|
|
} else {
|
|
this.rejectPending('unbindDevice', 'Unbind failed')
|
|
}
|
|
sendMessage({ event: 'unbindDevice', data: resp })
|
|
})
|
|
|
|
this.deviceInfoService.addListener(EVENT_TYPES.DELETE_FILE.name, (resp: DeleteFileResponse) => {
|
|
if (resp.success === 0) {
|
|
this.resolvePending('deleteFile', resp)
|
|
} else {
|
|
this.rejectPending('deleteFile', resp.success === 2 ? 'File not found' : 'Delete failed')
|
|
}
|
|
sendMessage({ event: 'deleteFile', data: resp })
|
|
})
|
|
|
|
this.deviceInfoService.addListener(EVENT_TYPES.PREPARE_TRANSFER.name, (resp: PrepareTransferResponse) => {
|
|
const opId = `prepareTransfer_${resp.key}`
|
|
if (resp.status === 'ready') {
|
|
this.resolvePending(opId, resp)
|
|
} else {
|
|
this.rejectPending(opId, resp.status === 'no_space' ? 'No space on device' : 'File already exists')
|
|
}
|
|
sendMessage({ event: 'prepareTransfer', data: resp })
|
|
})
|
|
}
|
|
|
|
// ====== 接收原生 App 的消息指令 ======
|
|
private setupMessageHandler() {
|
|
addMessageListener((event: Record<string, any>) => {
|
|
const { action, ...params } = event
|
|
console.log(`[BleSDK] Received action: ${action}`, params)
|
|
|
|
switch (action) {
|
|
case 'scan':
|
|
this.startScan()
|
|
break
|
|
case 'stopScan':
|
|
this.stopScan()
|
|
break
|
|
case 'connect':
|
|
this.connect(params.deviceId)
|
|
break
|
|
case 'disconnect':
|
|
this.disconnect()
|
|
break
|
|
case 'getDeviceInfo':
|
|
this.getDeviceInfo()
|
|
break
|
|
case 'getVersion':
|
|
this.getVersion()
|
|
break
|
|
case 'bind':
|
|
this.bind(params.userId)
|
|
break
|
|
case 'unbind':
|
|
this.unbind(params.userId)
|
|
break
|
|
case 'deleteFile':
|
|
this.deleteFile(params.key)
|
|
break
|
|
case 'transferFile':
|
|
this.transferFile(params.fileUri, params.commandType)
|
|
break
|
|
default:
|
|
sendMessage({ event: 'error', type: 'unknownAction', message: `Unknown action: ${action}` })
|
|
}
|
|
})
|
|
}
|
|
|
|
// ====== 蓝牙操作实现 ======
|
|
|
|
private async startScan() {
|
|
try {
|
|
const hasPerms = await this.requestPermissions()
|
|
if (!hasPerms) {
|
|
sendMessage({ event: 'error', type: 'permission', message: 'Bluetooth permission denied' })
|
|
return
|
|
}
|
|
|
|
this.updateState(STATE_KEYS.BT_STATE, 'scanning')
|
|
this.updateState(STATE_KEYS.DISCOVERED_DEVICES, [])
|
|
this.pendingDevices = []
|
|
|
|
await this.bleClient.startScan(
|
|
[BLE_UUIDS.SERVICE],
|
|
{ scanMode: ScanMode.Balanced, allowDuplicates: false },
|
|
(result) => {
|
|
const device = result.device as BleDevice
|
|
const hasService = device.serviceUUIDs?.some(
|
|
(uuid) => uuid?.toLowerCase() === BLE_UUIDS.SERVICE.toLowerCase()
|
|
)
|
|
if (!hasService || !device?.id) return
|
|
|
|
this.queueDevice({
|
|
id: device.id,
|
|
name: device.name || device.localName || null,
|
|
rssi: result.rssi,
|
|
})
|
|
}
|
|
)
|
|
|
|
sendMessage({ event: 'scanStarted' })
|
|
} catch (e: any) {
|
|
this.updateState(STATE_KEYS.BT_STATE, 'idle')
|
|
this.updateState(STATE_KEYS.ERROR, e.message)
|
|
sendMessage({ event: 'error', type: 'scan', message: e.message })
|
|
}
|
|
}
|
|
|
|
private stopScan() {
|
|
this.bleClient.stopScan()
|
|
this.flushDevices()
|
|
this.updateState(STATE_KEYS.BT_STATE, this.connectedDeviceId ? 'connected' : 'idle')
|
|
sendMessage({ event: 'scanStopped' })
|
|
}
|
|
|
|
private async connect(deviceId: string) {
|
|
if (!deviceId) {
|
|
sendMessage({ event: 'error', type: 'connect', message: 'deviceId is required' })
|
|
return
|
|
}
|
|
|
|
this.stopScan()
|
|
|
|
try {
|
|
this.updateState(STATE_KEYS.BT_STATE, 'connecting')
|
|
const device = await this.bleClient.connect(deviceId)
|
|
|
|
this.connectedDeviceId = deviceId
|
|
await this.protocolService.initialize(deviceId)
|
|
|
|
this.updateState(STATE_KEYS.CONNECTED_DEVICE, {
|
|
id: device.id,
|
|
name: device.name || device.localName || null,
|
|
})
|
|
this.updateState(STATE_KEYS.BT_STATE, 'connected')
|
|
|
|
sendMessage({ event: 'connected', deviceId: device.id, name: device.name })
|
|
|
|
// 自动获取设备信息
|
|
this.getDeviceInfo().catch(() => {})
|
|
} catch (e: any) {
|
|
this.updateState(STATE_KEYS.BT_STATE, 'idle')
|
|
this.updateState(STATE_KEYS.ERROR, e.message)
|
|
sendMessage({ event: 'error', type: 'connect', message: e.message })
|
|
}
|
|
}
|
|
|
|
private async disconnect() {
|
|
try {
|
|
await this.bleClient.disconnect()
|
|
this.protocolService.disconnect()
|
|
this.connectedDeviceId = null
|
|
|
|
this.updateState(STATE_KEYS.CONNECTED_DEVICE, null)
|
|
this.updateState(STATE_KEYS.DEVICE_INFO, null)
|
|
this.updateState(STATE_KEYS.VERSION, '')
|
|
this.updateState(STATE_KEYS.IS_ACTIVATED, false)
|
|
this.updateState(STATE_KEYS.BT_STATE, 'disconnected')
|
|
|
|
sendMessage({ event: 'disconnected' })
|
|
} catch (e: any) {
|
|
sendMessage({ event: 'error', type: 'disconnect', message: e.message })
|
|
}
|
|
}
|
|
|
|
private async getDeviceInfo() {
|
|
if (!this.connectedDeviceId) return
|
|
try {
|
|
const promise = this.createWaitable<DeviceInfo>('deviceInfo')
|
|
await this.deviceInfoService.getDeviceInfo(this.connectedDeviceId)
|
|
return await promise
|
|
} catch (e: any) {
|
|
sendMessage({ event: 'error', type: 'deviceInfo', message: e.message })
|
|
}
|
|
}
|
|
|
|
private async getVersion() {
|
|
if (!this.connectedDeviceId) return
|
|
try {
|
|
const promise = this.createWaitable<VersionInfo>('versionInfo')
|
|
await this.deviceInfoService.getDeviceVersion(this.connectedDeviceId)
|
|
return await promise
|
|
} catch (e: any) {
|
|
sendMessage({ event: 'error', type: 'version', message: e.message })
|
|
}
|
|
}
|
|
|
|
private async bind(userId: string) {
|
|
if (!this.connectedDeviceId) return
|
|
try {
|
|
const promise = this.createWaitable<BindingResponse>('bindDevice')
|
|
await this.deviceInfoService.bindDevice(this.connectedDeviceId, userId)
|
|
return await promise
|
|
} catch (e: any) {
|
|
sendMessage({ event: 'error', type: 'bind', message: e.message })
|
|
}
|
|
}
|
|
|
|
private async unbind(userId: string) {
|
|
if (!this.connectedDeviceId) return
|
|
try {
|
|
const promise = this.createWaitable<UnBindResponse>('unbindDevice')
|
|
await this.deviceInfoService.unbindDevice(this.connectedDeviceId, userId)
|
|
return await promise
|
|
} catch (e: any) {
|
|
sendMessage({ event: 'error', type: 'unbind', message: e.message })
|
|
}
|
|
}
|
|
|
|
private async deleteFile(key: string) {
|
|
if (!this.connectedDeviceId) return
|
|
try {
|
|
const promise = this.createWaitable<DeleteFileResponse>('deleteFile')
|
|
await this.deviceInfoService.deleteFile(this.connectedDeviceId, key)
|
|
return await promise
|
|
} catch (e: any) {
|
|
sendMessage({ event: 'error', type: 'deleteFile', message: e.message })
|
|
}
|
|
}
|
|
|
|
private async transferFile(fileUri: string, commandType?: number) {
|
|
if (!this.connectedDeviceId) return
|
|
try {
|
|
this.updateState(STATE_KEYS.TRANSFER_PROGRESS, 0)
|
|
const type = commandType ?? COMMAND_TYPES.TRANSFER_ANI_VIDEO
|
|
|
|
await this.fileTransferService.transferFile(
|
|
this.connectedDeviceId,
|
|
fileUri,
|
|
type,
|
|
(progress) => {
|
|
const pct = Math.round(progress * 100)
|
|
this.updateState(STATE_KEYS.TRANSFER_PROGRESS, pct)
|
|
sendMessage({ event: 'transferProgress', progress: pct })
|
|
}
|
|
)
|
|
|
|
sendMessage({ event: 'transferComplete' })
|
|
} catch (e: any) {
|
|
this.updateState(STATE_KEYS.ERROR, e.message)
|
|
sendMessage({ event: 'error', type: 'transfer', message: e.message })
|
|
}
|
|
}
|
|
|
|
// ====== 工具方法 ======
|
|
|
|
private updateState(key: string, value: any) {
|
|
try {
|
|
setSharedStateValue(key, value)
|
|
} catch (e) {
|
|
console.warn(`[BleSDK] Failed to set shared state ${key}:`, e)
|
|
}
|
|
}
|
|
|
|
private queueDevice(device: { id: string; name: string | null; rssi: number | null }) {
|
|
if (this.pendingDevices.some((d) => d.id === device.id)) return
|
|
this.pendingDevices.push(device)
|
|
|
|
if (this.flushTimer) clearTimeout(this.flushTimer)
|
|
this.flushTimer = setTimeout(() => this.flushDevices(), 500)
|
|
}
|
|
|
|
private flushDevices() {
|
|
if (this.pendingDevices.length === 0) return
|
|
const devices = [...this.pendingDevices]
|
|
this.pendingDevices = []
|
|
this.updateState(STATE_KEYS.DISCOVERED_DEVICES, devices)
|
|
sendMessage({ event: 'devicesFound', devices })
|
|
}
|
|
|
|
private createWaitable<T>(opId: string, timeout = 10000): Promise<T> {
|
|
return new Promise<T>((resolve, reject) => {
|
|
const timeoutId = setTimeout(() => {
|
|
this.pendingOperations.delete(opId)
|
|
reject(new Error(`Operation ${opId} timeout`))
|
|
}, timeout)
|
|
this.pendingOperations.set(opId, { resolve, reject, timeoutId })
|
|
})
|
|
}
|
|
|
|
private resolvePending(opId: string, value: any) {
|
|
const pending = this.pendingOperations.get(opId)
|
|
if (pending) {
|
|
clearTimeout(pending.timeoutId)
|
|
this.pendingOperations.delete(opId)
|
|
pending.resolve(value)
|
|
}
|
|
}
|
|
|
|
private rejectPending(opId: string, error: string) {
|
|
const pending = this.pendingOperations.get(opId)
|
|
if (pending) {
|
|
clearTimeout(pending.timeoutId)
|
|
this.pendingOperations.delete(opId)
|
|
pending.reject(new Error(error))
|
|
}
|
|
}
|
|
|
|
private async requestPermissions(): Promise<boolean> {
|
|
if (Platform.OS !== 'android') return true
|
|
try {
|
|
const sdk = Number(Platform.Version) || 0
|
|
const perms: string[] = []
|
|
if (sdk >= 31) {
|
|
perms.push(PermissionsAndroid.PERMISSIONS.BLUETOOTH_SCAN)
|
|
perms.push(PermissionsAndroid.PERMISSIONS.BLUETOOTH_CONNECT)
|
|
} else {
|
|
perms.push(PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION)
|
|
}
|
|
const results = await PermissionsAndroid.requestMultiple(perms as any)
|
|
return Object.values(results).every((r) => r === PermissionsAndroid.RESULTS.GRANTED)
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
|
|
export const bleSDK = new BleSDK()
|