feat: init duooomi BLE SDK with brownfield support
This commit is contained in:
438
ble/sdk/BleSDK.ts
Normal file
438
ble/sdk/BleSDK.ts
Normal file
@@ -0,0 +1,438 @@
|
||||
/**
|
||||
* 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()
|
||||
Reference in New Issue
Block a user