feat: init duooomi BLE SDK with brownfield support
This commit is contained in:
23
app.json
23
app.json
@@ -34,6 +34,29 @@
|
||||
"imageWidth": 76
|
||||
}
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-brownfield",
|
||||
{
|
||||
"ios": {
|
||||
"targetName": "DuooomiBleSDK",
|
||||
"bundleIdentifier": "com.duooomi.ble.sdk"
|
||||
},
|
||||
"android": {
|
||||
"libraryName": "duooomi-ble-sdk",
|
||||
"group": "com.duooomi",
|
||||
"package": "com.duooomi.ble.sdk",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
}
|
||||
],
|
||||
[
|
||||
"react-native-ble-plx",
|
||||
{
|
||||
"isBackgroundEnabled": true,
|
||||
"modes": ["peripheral", "central"],
|
||||
"bluetoothAlwaysPermission": "This app needs Bluetooth to communicate with your device"
|
||||
}
|
||||
]
|
||||
],
|
||||
"experiments": {
|
||||
|
||||
334
ble/core/BleClient.ts
Normal file
334
ble/core/BleClient.ts
Normal file
@@ -0,0 +1,334 @@
|
||||
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 {
|
||||
console.log('Normalizing BLE error:', e)
|
||||
|
||||
// ✅ 修复:安全处理错误对象,确保所有字段都有有效值
|
||||
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
|
||||
}
|
||||
}
|
||||
34
ble/core/types.ts
Normal file
34
ble/core/types.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { type Device } from 'react-native-ble-plx'
|
||||
|
||||
export interface BleScanInfo {
|
||||
rawData?: ArrayBuffer
|
||||
rssi: number
|
||||
isEnableConnect: boolean
|
||||
}
|
||||
|
||||
export type BleDevice = Device & {
|
||||
scanInfo?: BleScanInfo
|
||||
connected?: boolean
|
||||
}
|
||||
|
||||
export enum ConnectionState {
|
||||
DISCONNECTED = 'disconnected',
|
||||
CONNECTING = 'connecting',
|
||||
CONNECTED = 'connected',
|
||||
DISCONNECTING = 'disconnecting',
|
||||
}
|
||||
|
||||
export interface BleError {
|
||||
errorCode: number
|
||||
message: string
|
||||
attErrorCode?: number | null
|
||||
iosErrorCode?: number | null
|
||||
androidErrorCode?: number | null
|
||||
reason?: string | null
|
||||
}
|
||||
|
||||
export interface ScanResult {
|
||||
device: BleDevice
|
||||
rssi: number | null
|
||||
localName: string | null
|
||||
}
|
||||
9
ble/index.ts
Normal file
9
ble/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export * from './core/BleClient'
|
||||
export * from './core/types'
|
||||
export * from './protocol/Constants'
|
||||
export * from './protocol/ProtocolManager'
|
||||
export * from './protocol/types'
|
||||
export * from './services/BleProtocolService'
|
||||
export * from './services/DeviceInfoService'
|
||||
export * from './services/FileTransferService'
|
||||
export { bleSDK } from './sdk/BleSDK'
|
||||
77
ble/protocol/Constants.ts
Normal file
77
ble/protocol/Constants.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
export const PROTOCOL_VERSION = '1.0.0'
|
||||
|
||||
export const BLE_UUIDS = {
|
||||
SERVICE: '000002c4-0000-1000-8000-00805f9b34fb',
|
||||
BROADCAST_CHARACTERISTIC: '000002c1-0000-1000-8000-00805f9b34fb',
|
||||
WRITE_CHARACTERISTIC: '000002c5-0000-1000-8000-00805f9b34fb',
|
||||
READ_CHARACTERISTIC: '000002c6-0000-1000-8000-00805f9b34fb',
|
||||
REQUEST_MTU: 512,
|
||||
SCREEN_SIZE: 360,
|
||||
} as const
|
||||
|
||||
export const FRAME_CONSTANTS = {
|
||||
HEAD_DEVICE_TO_APP: 0xb0,
|
||||
HEAD_APP_TO_DEVICE: 0xc7,
|
||||
// HEAD_APP_TO_DEVICE: 0xb1,
|
||||
|
||||
MAX_DATA_SIZE: 496,
|
||||
HEADER_SIZE: 8,
|
||||
FOOTER_SIZE: 1,
|
||||
FRAME_INTERVAL: 35, // package transfer idle interval in ms, set 35 ms for ble device have enough time to process data
|
||||
} as const
|
||||
|
||||
export type FRAME_HEAD = typeof FRAME_CONSTANTS.HEAD_DEVICE_TO_APP | typeof FRAME_CONSTANTS.HEAD_APP_TO_DEVICE
|
||||
|
||||
export const COMMAND_TYPES = {
|
||||
OTA_PACKAGE: 0x02,
|
||||
TRANSFER_BOOT_ANIMATION: 0x03,
|
||||
TRANSFER_ANI_VIDEO: 0x05,
|
||||
TRANSFER_JPEG_IMAGE: 0x06,
|
||||
GET_DEVICE_VERSION: 0x07,
|
||||
GET_DEVICE_INFO: 0x0d,
|
||||
BIND_DEVICE: 0x0f,
|
||||
UNBIND_DEVICE: 0x12,
|
||||
DELETE_FILE: 0x13,
|
||||
PREPARE_TRANSFER: 0x14,
|
||||
} as const
|
||||
|
||||
export type APP_COMMAND_TYPES = (typeof COMMAND_TYPES)[keyof typeof COMMAND_TYPES] | number
|
||||
|
||||
export const EVENT_TYPES = {
|
||||
TRANSFER_OTA_PACKAGE: {
|
||||
name: 'onTransferOtaPackage',
|
||||
code: 0x02, // 2
|
||||
},
|
||||
TRANSFER_BOOT_ANIMATION: {
|
||||
name: 'onTransferBootAnimation',
|
||||
code: 0x03, // 3
|
||||
},
|
||||
TRANSFER_ANI_VIDEO: {
|
||||
name: 'onTransferAniVideo',
|
||||
code: 0x05, // 5
|
||||
},
|
||||
VERSION_INFO: {
|
||||
name: 'onVersionInfo',
|
||||
code: 0x07, // 7
|
||||
},
|
||||
DEVICE_INFO: {
|
||||
name: 'onDeviceInfo',
|
||||
code: 0x0d, // 13
|
||||
},
|
||||
BIND_DEVICE: {
|
||||
name: 'onBindDevice',
|
||||
code: 0x0f, // 15
|
||||
},
|
||||
UNBIND_DEVICE: {
|
||||
name: 'onUnbindDevice',
|
||||
code: 0x12, // 18
|
||||
},
|
||||
DELETE_FILE: {
|
||||
name: 'onDeleteFile',
|
||||
code: 0x13, // 19
|
||||
},
|
||||
PREPARE_TRANSFER: {
|
||||
name: 'onPrepareTransfer',
|
||||
code: 0x14, // 20
|
||||
},
|
||||
}
|
||||
181
ble/protocol/ProtocolManager.ts
Normal file
181
ble/protocol/ProtocolManager.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import { type APP_COMMAND_TYPES, FRAME_CONSTANTS, type FRAME_HEAD } from './Constants'
|
||||
import { type ProtocolFrame } from './types'
|
||||
|
||||
export class ProtocolManager {
|
||||
static calculateChecksum(frameData: Uint8Array): number {
|
||||
let sum = 0
|
||||
// console.debug(`[ProtocolManager] Calculating checksum for frame count: ${frameData.length}`);
|
||||
// Checksum is calculated on all bytes except the last one (which is the checksum itself)
|
||||
// Example: 0xA0 03 00 01 01 5B
|
||||
// 0xA0 + 0x03 + 0x00 + 0x01 + 0x01 = 0xA5
|
||||
// 0 - 0xA5 = 0x5B
|
||||
for (let i = 0; i < frameData.length; i++) {
|
||||
sum += frameData[i]
|
||||
}
|
||||
const checksum = (~sum + 1) & 0xff
|
||||
// console.debug(`[ProtocolManager] Checksum calculated: 0 - ${sum} = ${checksum.toString(16).padStart(2, '0')}`);
|
||||
return checksum
|
||||
}
|
||||
|
||||
static verifyChecksum(frameData: Uint8Array, expectedChecksum: number): boolean {
|
||||
const calculatedChecksum = this.calculateChecksum(frameData)
|
||||
const isValid = calculatedChecksum === expectedChecksum
|
||||
|
||||
if (!isValid) {
|
||||
console.warn(
|
||||
`[ProtocolManager] Checksum mismatch: calculated=0x${calculatedChecksum.toString(16)}, expected=0x${expectedChecksum.toString(16)}`,
|
||||
)
|
||||
}
|
||||
|
||||
// console.log('verifyChecksum-----------------', isValid)
|
||||
|
||||
return isValid
|
||||
}
|
||||
|
||||
static createFrame(
|
||||
type: APP_COMMAND_TYPES,
|
||||
data: Uint8Array,
|
||||
head: FRAME_HEAD = FRAME_CONSTANTS.HEAD_APP_TO_DEVICE,
|
||||
maxDataSize: number = FRAME_CONSTANTS.MAX_DATA_SIZE,
|
||||
): Uint8Array[] {
|
||||
// Max pages index is 4 bytes, so we can fit up to 65535 pages of data
|
||||
const maxTotalSize = 0xffff * (FRAME_CONSTANTS.HEADER_SIZE + maxDataSize + FRAME_CONSTANTS.FOOTER_SIZE)
|
||||
if (data.length > maxTotalSize) {
|
||||
throw new Error(`Data size ${data.length} exceeds max size ${maxTotalSize}`)
|
||||
}
|
||||
if (data.length <= maxDataSize) {
|
||||
console.debug(`[ProtocolManager] Frame size ${data.length} is less than max size ${maxDataSize}, not fragmented`)
|
||||
return [this.createSingleFrame(type, data, head)]
|
||||
} else {
|
||||
console.debug(`[ProtocolManager] Frame size ${data.length} is greater than max size ${maxDataSize}, fragmented`)
|
||||
return this.createFragmentedFrames(type, data, head, maxDataSize)
|
||||
}
|
||||
}
|
||||
|
||||
private static createSingleFrame(type: APP_COMMAND_TYPES, data: Uint8Array, head: FRAME_HEAD): Uint8Array {
|
||||
const buffer = new Uint8Array(FRAME_CONSTANTS.HEADER_SIZE + data.length + FRAME_CONSTANTS.FOOTER_SIZE)
|
||||
let offset = 0
|
||||
|
||||
buffer[offset++] = head
|
||||
buffer[offset++] = type
|
||||
|
||||
// subpageTotal = 0
|
||||
buffer[offset++] = 0
|
||||
buffer[offset++] = 0
|
||||
|
||||
// curPage = 0
|
||||
buffer[offset++] = 0
|
||||
buffer[offset++] = 0
|
||||
|
||||
// dataLen
|
||||
buffer[offset++] = (data.length >> 8) & 0xff
|
||||
buffer[offset++] = data.length & 0xff
|
||||
const hexHeader = Array.from(buffer.slice(0, offset))
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join(' ')
|
||||
// console.debug(`chunk length = ${data.length}, buffer 8 header = ${hexHeader}`)
|
||||
|
||||
// data
|
||||
buffer.set(data, offset)
|
||||
offset += data.length
|
||||
|
||||
// checksum
|
||||
// Logic from ProtocolUtilsV2: calculate sum of everything before checksum byte
|
||||
const checksum = this.calculateChecksum(buffer.slice(0, offset))
|
||||
buffer[offset] = checksum
|
||||
|
||||
return buffer
|
||||
}
|
||||
|
||||
private static createFragmentedFrames(
|
||||
type: APP_COMMAND_TYPES,
|
||||
data: Uint8Array,
|
||||
head: FRAME_HEAD,
|
||||
maxDataSize: number,
|
||||
): Uint8Array[] {
|
||||
const frames: Uint8Array[] = []
|
||||
const totalSize = data.length
|
||||
const totalPages = Math.ceil(totalSize / maxDataSize)
|
||||
|
||||
for (let i = 0; i < totalPages; i++) {
|
||||
const start = i * maxDataSize
|
||||
const end = Math.min(start + maxDataSize, totalSize)
|
||||
const chunk = data.slice(start, end)
|
||||
|
||||
const buffer = new Uint8Array(FRAME_CONSTANTS.HEADER_SIZE + chunk.length + FRAME_CONSTANTS.FOOTER_SIZE)
|
||||
let offset = 0
|
||||
|
||||
buffer[offset++] = head
|
||||
buffer[offset++] = type
|
||||
|
||||
// subpageTotal
|
||||
buffer[offset++] = (totalPages >> 8) & 0xff
|
||||
buffer[offset++] = totalPages & 0xff
|
||||
|
||||
// Protocol specifies: page numbers count down from highest to 0
|
||||
const curPageVal = totalPages - 1 - i
|
||||
|
||||
buffer[offset++] = (curPageVal >> 8) & 0xff
|
||||
buffer[offset++] = curPageVal & 0xff
|
||||
|
||||
// dataLen
|
||||
buffer[offset++] = (chunk.length >> 8) & 0xff
|
||||
buffer[offset++] = chunk.length & 0xff
|
||||
// const hexHeader = Array.from(buffer.slice(0, offset)).map(b => b.toString(16).padStart(2, '0')).join(' ');
|
||||
// console.debug(`chunk length = ${chunk.length}, buffer 8 header = ${hexHeader}`)
|
||||
|
||||
// data
|
||||
buffer.set(chunk, offset)
|
||||
offset += chunk.length
|
||||
|
||||
buffer[offset] = this.calculateChecksum(buffer.slice(0, offset))
|
||||
|
||||
frames.push(buffer)
|
||||
}
|
||||
|
||||
return frames
|
||||
}
|
||||
|
||||
static parseFrame(data: ArrayBufferLike): ProtocolFrame | null {
|
||||
const bytes = new Uint8Array(data)
|
||||
if (bytes.length < FRAME_CONSTANTS.HEADER_SIZE + FRAME_CONSTANTS.FOOTER_SIZE) {
|
||||
return null
|
||||
}
|
||||
|
||||
const head = bytes[0]
|
||||
if (head !== FRAME_CONSTANTS.HEAD_DEVICE_TO_APP && head !== FRAME_CONSTANTS.HEAD_APP_TO_DEVICE) {
|
||||
console.warn(`[ProtocolManager] Invalid frame header: 0x${head.toString(16)}`)
|
||||
return null
|
||||
}
|
||||
|
||||
const type = bytes[1]
|
||||
const subpageTotal = (bytes[2] << 8) | bytes[3]
|
||||
const curPage = (bytes[4] << 8) | bytes[5]
|
||||
const dataLen = (bytes[6] << 8) | bytes[7]
|
||||
|
||||
if (bytes.length < FRAME_CONSTANTS.HEADER_SIZE + dataLen + FRAME_CONSTANTS.FOOTER_SIZE) {
|
||||
// Incomplete
|
||||
return null
|
||||
}
|
||||
|
||||
const frameData = bytes.slice(FRAME_CONSTANTS.HEADER_SIZE, FRAME_CONSTANTS.HEADER_SIZE + dataLen)
|
||||
const checksum = bytes[FRAME_CONSTANTS.HEADER_SIZE + dataLen]
|
||||
|
||||
// Verify checksum
|
||||
const dataToCheck = bytes.slice(0, FRAME_CONSTANTS.HEADER_SIZE + dataLen)
|
||||
if (!this.verifyChecksum(dataToCheck, checksum)) {
|
||||
console.warn('Checksum mismatch')
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
head,
|
||||
type,
|
||||
subpageTotal,
|
||||
curPage,
|
||||
dataLen,
|
||||
data: frameData.buffer as ArrayBuffer,
|
||||
checksum,
|
||||
}
|
||||
}
|
||||
}
|
||||
123
ble/protocol/types.ts
Normal file
123
ble/protocol/types.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const DeviceInfoZod = z.object({
|
||||
allspace: z.bigint(),
|
||||
freespace: z.bigint(),
|
||||
name: z.string().max(128, 'Device Name too long'),
|
||||
size: z.string().describe('设备分辨率, 以x作为分隔符, 宽x高, 例如 360x360'),
|
||||
brand: z.string().max(128, 'Brand name too long'),
|
||||
powerlevel: z
|
||||
.number()
|
||||
.min(0, 'Power level could not be less then 0')
|
||||
.max(100, 'Power level could not be more then 100'),
|
||||
})
|
||||
|
||||
export type DeviceInfo = z.infer<typeof DeviceInfoZod>
|
||||
|
||||
export const ProtocolFrameZod = z.object({
|
||||
head: z.number().describe('固定表示传输方向 APP -> BLE = 0xb1, BLE -> APP = 0xb0'),
|
||||
type: z.number().describe('表示传输数据对应哪个Event, 与EVENT_TYPES.xxx.code对应'),
|
||||
subpageTotal: z.number().describe('代表总的分包大小'),
|
||||
curPage: z.number().describe('代表当前分包序号'),
|
||||
dataLen: z.number().describe('代表当前数据包字节长度 bytes'),
|
||||
data: z.instanceof(ArrayBuffer).describe('数据包内实际二进制字节内容'),
|
||||
checksum: z.number().describe('数据包校验字节, 整个数据包中除该字节以外的所有字节数据的单字节和 = (0 - 该字节)'),
|
||||
})
|
||||
|
||||
export type ProtocolFrame = z.infer<typeof ProtocolFrameZod>
|
||||
|
||||
export const BondingResponseZod = z.object({
|
||||
type: z.number().describe('绑定命令类型 0x0f'),
|
||||
sn: z
|
||||
.string()
|
||||
.length(14)
|
||||
.describe(
|
||||
'设备sn码, 第1位A量产,D售后,E试产,F测试环境,后六位000000-999999,234568位不变,第7位可能根据配置不同变化',
|
||||
),
|
||||
success: z
|
||||
.number()
|
||||
.describe('绑定成功/失败 0 = 失败,1 = 成功,如传入的userId与已绑定的userId一致,直接返回成功代表已绑定'),
|
||||
contents: z
|
||||
.array(z.string())
|
||||
.describe('绑定成功时才返回设备内已存在的原始内容,绑定失败时返回空数组;数组按照id排序'),
|
||||
})
|
||||
|
||||
export type BindingResponse = z.infer<typeof BondingResponseZod>
|
||||
|
||||
export const UnBindResponseZod = z.object({
|
||||
success: z.number().describe('解绑成功 = 1, 失败 = 0'),
|
||||
})
|
||||
|
||||
export type UnBindResponse = z.infer<typeof UnBindResponseZod>
|
||||
|
||||
export const VersionInfoZod = z.object({
|
||||
version: z.string().max(128, 'Version string too long').describe('设备版本号'),
|
||||
type: z.string().describe('方法type 0x07'), // e.g., "duooomi_v1"
|
||||
})
|
||||
|
||||
export type VersionInfo = z.infer<typeof VersionInfoZod>
|
||||
|
||||
export const FileTransferDataZod = z.object({
|
||||
data: z.instanceof(ArrayBuffer),
|
||||
})
|
||||
|
||||
export type FileTransferData = z.infer<typeof FileTransferDataZod>
|
||||
|
||||
export const BindingRequestZod = z.object({
|
||||
type: z.number().describe('绑定命令类型 0x0f'),
|
||||
userId: z.string().length(32).describe('32位长度的user Id'),
|
||||
})
|
||||
|
||||
export type BindingRequest = z.infer<typeof BindingRequestZod>
|
||||
|
||||
// 删除文件协议响应状态
|
||||
export const DELETE_FILE_STATUS = {
|
||||
SUCCESS: 0, // 删除成功
|
||||
FAILED: 1, // 删除失败
|
||||
NOT_EXISTS: 2, // 文件不存在
|
||||
} as const
|
||||
|
||||
export type DeleteFileStatus = (typeof DELETE_FILE_STATUS)[keyof typeof DELETE_FILE_STATUS]
|
||||
|
||||
// 删除文件请求接口
|
||||
export const DeleteFileRequestZod = z.object({
|
||||
type: z.number().describe('删除文件命令类型 0x13'),
|
||||
key: z.string().describe('要删除内容对应的key'),
|
||||
})
|
||||
|
||||
export type DeleteFileRequest = z.infer<typeof DeleteFileRequestZod>
|
||||
|
||||
// 删除文件响应接口
|
||||
export const DeleteFileResponseZod = z.object({
|
||||
type: z.number().describe('删除文件命令类型 0x13'),
|
||||
success: z.number().min(0).max(2).describe('删除结果状态: 0=成功, 1=失败, 2=文件不存在'),
|
||||
})
|
||||
|
||||
export type DeleteFileResponse = z.infer<typeof DeleteFileResponseZod>
|
||||
|
||||
// 预先发送协议状态
|
||||
export const PREPARE_TRANSFER_STATUS = {
|
||||
READY: 'ready', // 准备好接收
|
||||
NO_SPACE: 'no_space', // 存储空间不足
|
||||
DUPLICATED: 'duplicated', // 已存在相同key的资源
|
||||
} as const
|
||||
|
||||
export type PrepareTransferStatus = (typeof PREPARE_TRANSFER_STATUS)[keyof typeof PREPARE_TRANSFER_STATUS]
|
||||
|
||||
// 预先发送请求接口
|
||||
export const PrepareTransferRequestZod = z.object({
|
||||
type: z.number().describe('预先发送命令类型 0x14'),
|
||||
key: z.string().describe('要传输内容对应的key'),
|
||||
size: z.number().describe('ani文件大小信息,单位为bytes'),
|
||||
})
|
||||
|
||||
export type PrepareTransferRequest = z.infer<typeof PrepareTransferRequestZod>
|
||||
|
||||
// 预先发送响应接口
|
||||
export const PrepareTransferResponseZod = z.object({
|
||||
type: z.number().describe('预先发送命令类型 0x14'),
|
||||
key: z.string().describe('要传输内容对应的key'),
|
||||
status: z.enum(['ready', 'no_space', 'duplicated']).describe('告知APP是否可继续发送'),
|
||||
})
|
||||
|
||||
export type PrepareTransferResponse = z.infer<typeof PrepareTransferResponseZod>
|
||||
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()
|
||||
214
ble/services/BleProtocolService.ts
Normal file
214
ble/services/BleProtocolService.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
import { Buffer } from 'buffer'
|
||||
import { type Subscription } from 'react-native-ble-plx'
|
||||
|
||||
import { BleClient } from '../core/BleClient'
|
||||
import { type APP_COMMAND_TYPES, BLE_UUIDS, FRAME_CONSTANTS } from '../protocol/Constants'
|
||||
import { ProtocolManager } from '../protocol/ProtocolManager'
|
||||
import { type ProtocolFrame } from '../protocol/types'
|
||||
|
||||
export class BleProtocolService {
|
||||
private static instance: BleProtocolService
|
||||
private client = BleClient.getInstance()
|
||||
private listeners: Map<number, Set<(data: ArrayBuffer, deviceId: string) => void>> = new Map()
|
||||
private subscription: Subscription | null = null
|
||||
|
||||
// deviceId_type -> { total: number, frames: ArrayBuffer[] }
|
||||
private fragments: Map<string, { total: number; frames: (ArrayBuffer | null)[] }> = new Map()
|
||||
|
||||
private constructor() {}
|
||||
|
||||
public static getInstance(): BleProtocolService {
|
||||
if (!BleProtocolService.instance) {
|
||||
BleProtocolService.instance = new BleProtocolService()
|
||||
}
|
||||
return BleProtocolService.instance
|
||||
}
|
||||
|
||||
public addListener(type: number, callback: (data: ArrayBuffer, deviceId: string) => void) {
|
||||
if (!this.listeners.has(type)) {
|
||||
this.listeners.set(type, new Set())
|
||||
}
|
||||
this.listeners.get(type)!.add(callback)
|
||||
}
|
||||
|
||||
public removeListener(type: number, callback: (data: ArrayBuffer, deviceId: string) => void) {
|
||||
if (this.listeners.has(type)) {
|
||||
this.listeners.get(type)!.delete(callback)
|
||||
}
|
||||
}
|
||||
|
||||
private emit(type: number, data: ArrayBuffer, deviceId: string) {
|
||||
if (this.listeners.has(type)) {
|
||||
this.listeners.get(type)!.forEach((cb) => cb(data, deviceId))
|
||||
}
|
||||
}
|
||||
|
||||
public async initialize(deviceId: string) {
|
||||
// Clean up previous subscription
|
||||
this.disconnect()
|
||||
|
||||
// Clear fragments for this device
|
||||
this.clearFragments(deviceId)
|
||||
|
||||
try {
|
||||
this.subscription = await this.client.monitor(
|
||||
deviceId,
|
||||
BLE_UUIDS.SERVICE,
|
||||
BLE_UUIDS.READ_CHARACTERISTIC,
|
||||
(error, value) => {
|
||||
if (error) {
|
||||
// Check for known native crash error and ignore/log as debug
|
||||
if (
|
||||
error.errorCode === 0 &&
|
||||
error.message.includes('Unknown error') &&
|
||||
error.reason?.includes('PromiseImpl.reject')
|
||||
) {
|
||||
console.debug('Ignored native monitor error', error)
|
||||
return
|
||||
}
|
||||
console.warn('Monitor error', error)
|
||||
return
|
||||
}
|
||||
if (value) {
|
||||
const buffer = Buffer.from(value, 'base64')
|
||||
const hexString =
|
||||
buffer
|
||||
.toString('hex')
|
||||
.match(/.{1,2}/g)
|
||||
?.join(' ')
|
||||
.toUpperCase() || ''
|
||||
|
||||
console.log(`[BleProtocol] Received ${buffer.byteLength} bytes:`, hexString)
|
||||
this.handleRawData(deviceId, buffer)
|
||||
}
|
||||
},
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize protocol service:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
public disconnect() {
|
||||
if (this.subscription) {
|
||||
try {
|
||||
this.subscription.remove()
|
||||
} catch (e) {
|
||||
console.warn('Failed to remove subscription', e)
|
||||
}
|
||||
this.subscription = null
|
||||
}
|
||||
}
|
||||
|
||||
private handleRawData(deviceId: string, data: Buffer) {
|
||||
const frame = ProtocolManager.parseFrame(data.buffer)
|
||||
if (!frame) return
|
||||
|
||||
if (frame.subpageTotal > 0) {
|
||||
this.handleFragment(deviceId, frame)
|
||||
} else {
|
||||
this.emit(frame.type, frame.data, deviceId)
|
||||
}
|
||||
}
|
||||
|
||||
private handleFragment(deviceId: string, frame: ProtocolFrame) {
|
||||
const key = `${deviceId}_${frame.type}`
|
||||
|
||||
if (!this.fragments.has(key)) {
|
||||
this.fragments.set(key, {
|
||||
total: frame.subpageTotal,
|
||||
frames: new Array(frame.subpageTotal).fill(null),
|
||||
})
|
||||
}
|
||||
|
||||
const session = this.fragments.get(key)!
|
||||
// Basic validation
|
||||
if (frame.curPage >= session.total) return
|
||||
|
||||
session.frames[frame.curPage] = frame.data
|
||||
|
||||
// Check if complete
|
||||
if (session.frames.every((f) => f !== null)) {
|
||||
const combinedLength = session.frames.reduce((acc, val) => acc + (val ? val.byteLength : 0), 0)
|
||||
const combined = new Uint8Array(combinedLength)
|
||||
let offset = 0
|
||||
|
||||
// Reassemble from High to Low pages
|
||||
for (let i = session.total - 1; i >= 0; i--) {
|
||||
const part = session.frames[i]
|
||||
if (part) {
|
||||
combined.set(new Uint8Array(part), offset)
|
||||
offset += part.byteLength
|
||||
}
|
||||
}
|
||||
|
||||
this.fragments.delete(key)
|
||||
this.emit(frame.type, combined.buffer as ArrayBuffer, deviceId)
|
||||
}
|
||||
}
|
||||
|
||||
private clearFragments(deviceId: string) {
|
||||
for (const key of this.fragments.keys()) {
|
||||
if (key.startsWith(deviceId)) {
|
||||
this.fragments.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async send(
|
||||
deviceId: string,
|
||||
type: APP_COMMAND_TYPES,
|
||||
data: object | ArrayBuffer | Uint8Array,
|
||||
onProgress?: (progress: number) => void,
|
||||
): Promise<void> {
|
||||
let payload: Uint8Array
|
||||
if (data instanceof ArrayBuffer) {
|
||||
console.debug('[BleProtocolService] Sending ArrayBuffer')
|
||||
payload = new Uint8Array(data)
|
||||
} else if (data instanceof Uint8Array) {
|
||||
console.debug('[BleProtocolService] Sending Uint8Array')
|
||||
payload = data
|
||||
} else {
|
||||
console.debug('[BleProtocolService] Sending JSON payload')
|
||||
const jsonStr = JSON.stringify(data)
|
||||
payload = new Uint8Array(Buffer.from(jsonStr))
|
||||
}
|
||||
|
||||
const device = this.client.getConnectedDevice()
|
||||
console.log('send-------------device', device)
|
||||
|
||||
const mtu = device?.mtu || 23
|
||||
// MTU - 3 bytes (ATT overhead) - Protocol Header - Protocol Footer
|
||||
const maxPayloadSize = mtu - 3 - FRAME_CONSTANTS.HEADER_SIZE - FRAME_CONSTANTS.FOOTER_SIZE
|
||||
// Ensure reasonable bounds (at least 1 byte, max constrained by protocol constant)
|
||||
const safeMaxDataSize = Math.max(1, Math.min(maxPayloadSize, FRAME_CONSTANTS.MAX_DATA_SIZE))
|
||||
|
||||
console.debug(`[BleProtocolService] Sending with MTU=${mtu}, maxDataSize=${safeMaxDataSize}`)
|
||||
const rawPayloadHex = payload.reduce((acc, val) => acc + val.toString(16).padStart(2, '0') + ' ', '')
|
||||
const formattedRawPayloadHex =
|
||||
rawPayloadHex.substring(0, 512 * 2) + '\n......\n' + rawPayloadHex.substring(rawPayloadHex.length - 512 * 2)
|
||||
console.debug(
|
||||
`[BleProtocolService] Sending payload size=${payload.byteLength}, raw payload hex=\n${formattedRawPayloadHex}`,
|
||||
)
|
||||
const frames = ProtocolManager.createFrame(type, payload, FRAME_CONSTANTS.HEAD_APP_TO_DEVICE, safeMaxDataSize)
|
||||
const total = frames.length
|
||||
console.debug(`Sending ${total} frames`)
|
||||
for (let i = 0; i < total; i++) {
|
||||
const frame = frames[i]
|
||||
if (i < 3) {
|
||||
const rawFrame = Array.from(frame)
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join(' ')
|
||||
console.debug(`raw ${i + 1} frame \n ${rawFrame}`)
|
||||
}
|
||||
// console.debug(`Writing frame ${i + 1}/${total}, length = ${frame.length}`)
|
||||
const base64 = Buffer.from(frame).toString('base64')
|
||||
const result = await this.client.write(deviceId, BLE_UUIDS.SERVICE, BLE_UUIDS.WRITE_CHARACTERISTIC, base64, false)
|
||||
await new Promise((resolve) => setTimeout(resolve, FRAME_CONSTANTS.FRAME_INTERVAL))
|
||||
if (onProgress) {
|
||||
onProgress((i + 1) / total)
|
||||
}
|
||||
// console.debug("Wrote frame", result);
|
||||
}
|
||||
}
|
||||
}
|
||||
148
ble/services/DeviceInfoService.ts
Normal file
148
ble/services/DeviceInfoService.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { Buffer } from 'buffer'
|
||||
|
||||
import { 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 './BleProtocolService'
|
||||
|
||||
export class DeviceInfoService {
|
||||
private protocol = BleProtocolService.getInstance()
|
||||
private static instance: DeviceInfoService
|
||||
private listeners: Map<string, Set<Function>> = new Map()
|
||||
|
||||
private constructor() {
|
||||
this.protocol.addListener(EVENT_TYPES.DEVICE_INFO.code, this.onDeviceInfo)
|
||||
this.protocol.addListener(EVENT_TYPES.VERSION_INFO.code, this.onVersionInfo)
|
||||
this.protocol.addListener(EVENT_TYPES.BIND_DEVICE.code, this.onBindDevice)
|
||||
this.protocol.addListener(EVENT_TYPES.UNBIND_DEVICE.code, this.onUnBindDevice)
|
||||
this.protocol.addListener(EVENT_TYPES.DELETE_FILE.code, this.onDeleteFile)
|
||||
this.protocol.addListener(EVENT_TYPES.PREPARE_TRANSFER.code, this.onPrepareTransfer)
|
||||
}
|
||||
|
||||
private onDeviceInfo = (data: ArrayBuffer, deviceId: string) => {
|
||||
try {
|
||||
const json = this.prase<DeviceInfo>(data)
|
||||
this.emit(EVENT_TYPES.DEVICE_INFO.name, json)
|
||||
} catch (e) {
|
||||
console.error('Failed to parse device info', e)
|
||||
}
|
||||
}
|
||||
|
||||
private onVersionInfo = (data: ArrayBuffer, deviceId: string) => {
|
||||
try {
|
||||
const json = this.prase<VersionInfo>(data)
|
||||
this.emit(EVENT_TYPES.VERSION_INFO.name, json)
|
||||
} catch (e) {
|
||||
console.error('Failed to parse activation status', e)
|
||||
}
|
||||
}
|
||||
|
||||
private onBindDevice = (data: ArrayBuffer, deviceId: string) => {
|
||||
try {
|
||||
const json = this.prase<BindingResponse>(data)
|
||||
this.emit(EVENT_TYPES.BIND_DEVICE.name, json)
|
||||
} catch (e) {
|
||||
console.error('Failed to parse activation status', e)
|
||||
}
|
||||
}
|
||||
|
||||
private onUnBindDevice = (data: ArrayBuffer, deviceId: string) => {
|
||||
try {
|
||||
const json = this.prase<UnBindResponse>(data)
|
||||
this.emit(EVENT_TYPES.UNBIND_DEVICE.name, json)
|
||||
} catch (e) {
|
||||
console.error('Failed to parse activation status', e)
|
||||
}
|
||||
}
|
||||
|
||||
private onDeleteFile = (data: ArrayBuffer, deviceId: string) => {
|
||||
try {
|
||||
const json = this.prase<DeleteFileResponse>(data)
|
||||
this.emit(EVENT_TYPES.DELETE_FILE.name, json)
|
||||
} catch (e) {
|
||||
console.error('Failed to parse delete file response', e)
|
||||
}
|
||||
}
|
||||
|
||||
private onPrepareTransfer = (data: ArrayBuffer, deviceId: string) => {
|
||||
try {
|
||||
const json = this.prase<PrepareTransferResponse>(data)
|
||||
this.emit(EVENT_TYPES.PREPARE_TRANSFER.name, json)
|
||||
} catch (e) {
|
||||
console.error('Failed to parse prepare transfer response', e)
|
||||
}
|
||||
}
|
||||
|
||||
public static getInstance(): DeviceInfoService {
|
||||
if (!DeviceInfoService.instance) {
|
||||
DeviceInfoService.instance = new DeviceInfoService()
|
||||
}
|
||||
return DeviceInfoService.instance
|
||||
}
|
||||
|
||||
private prase<T>(data: ArrayBuffer): T {
|
||||
const str = Buffer.from(data).toString('utf-8')
|
||||
const cleanStr = str.replace(/\0/g, '')
|
||||
return JSON.parse(cleanStr) as T
|
||||
}
|
||||
|
||||
public addListener(event: string, cb: Function) {
|
||||
if (!this.listeners.has(event)) this.listeners.set(event, new Set())
|
||||
this.listeners.get(event)!.add(cb)
|
||||
}
|
||||
|
||||
public removeListener(event: string, cb: Function) {
|
||||
if (this.listeners.has(event)) {
|
||||
this.listeners.get(event)!.delete(cb)
|
||||
}
|
||||
}
|
||||
|
||||
public removeAllListeners() {
|
||||
this.listeners.clear()
|
||||
console.debug('All DeviceInfoService listeners cleared')
|
||||
}
|
||||
|
||||
private emit(event: string, data: any) {
|
||||
this.listeners.get(event)?.forEach((cb) => cb(data))
|
||||
}
|
||||
|
||||
public async getDeviceInfo(deviceId: string) {
|
||||
await this.protocol.send(deviceId, COMMAND_TYPES.GET_DEVICE_INFO, { type: COMMAND_TYPES.GET_DEVICE_INFO })
|
||||
}
|
||||
|
||||
public async getDeviceVersion(deviceId: string) {
|
||||
await this.protocol.send(deviceId, COMMAND_TYPES.GET_DEVICE_VERSION, { type: COMMAND_TYPES.GET_DEVICE_VERSION })
|
||||
}
|
||||
|
||||
public async bindDevice(deviceId: string, userId: string) {
|
||||
await this.protocol.send(deviceId, COMMAND_TYPES.BIND_DEVICE, { type: COMMAND_TYPES.BIND_DEVICE, userId: userId })
|
||||
}
|
||||
|
||||
public async unbindDevice(deviceId: string, userId: string) {
|
||||
await this.protocol.send(deviceId, COMMAND_TYPES.UNBIND_DEVICE, {
|
||||
type: COMMAND_TYPES.UNBIND_DEVICE,
|
||||
userId: userId,
|
||||
})
|
||||
}
|
||||
|
||||
public async deleteFile(deviceId: string, key: string) {
|
||||
await this.protocol.send(deviceId, COMMAND_TYPES.DELETE_FILE, {
|
||||
type: COMMAND_TYPES.DELETE_FILE,
|
||||
key: key,
|
||||
})
|
||||
}
|
||||
|
||||
public async prepareTransfer(deviceId: string, key: string, size: number) {
|
||||
await this.protocol.send(deviceId, COMMAND_TYPES.PREPARE_TRANSFER, {
|
||||
type: COMMAND_TYPES.PREPARE_TRANSFER,
|
||||
key: key,
|
||||
size: size,
|
||||
})
|
||||
}
|
||||
}
|
||||
49
ble/services/FileTransferService.ts
Normal file
49
ble/services/FileTransferService.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { type APP_COMMAND_TYPES } from '../protocol/Constants'
|
||||
import { BleProtocolService } from './BleProtocolService'
|
||||
|
||||
export class FileTransferService {
|
||||
private static instance: FileTransferService
|
||||
private protocol = BleProtocolService.getInstance()
|
||||
|
||||
private constructor() {}
|
||||
|
||||
public static getInstance(): FileTransferService {
|
||||
if (!FileTransferService.instance) {
|
||||
FileTransferService.instance = new FileTransferService()
|
||||
}
|
||||
return FileTransferService.instance
|
||||
}
|
||||
|
||||
public async transferFile(
|
||||
deviceId: string,
|
||||
file: string | ArrayBuffer,
|
||||
type: APP_COMMAND_TYPES,
|
||||
onProgress?: (progress: number) => void,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const startAt = Date.now()
|
||||
let arrayBuffer: ArrayBuffer
|
||||
if (file instanceof ArrayBuffer) {
|
||||
arrayBuffer = file
|
||||
} else {
|
||||
const response = await fetch(file)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load file: ${response.statusText}`)
|
||||
}
|
||||
const blob = await response.blob()
|
||||
const reader = new FileReader()
|
||||
arrayBuffer = await new Promise<ArrayBuffer>((resolve, reject) => {
|
||||
reader.onload = () => resolve(reader.result as ArrayBuffer)
|
||||
reader.onerror = reject
|
||||
reader.readAsArrayBuffer(blob)
|
||||
})
|
||||
}
|
||||
await this.protocol.send(deviceId, type, arrayBuffer, onProgress)
|
||||
const transferredAt = Date.now()
|
||||
console.debug(`File transferred in ${(transferredAt - startAt) / 1000} s`)
|
||||
} catch (e) {
|
||||
console.error('File transfer failed', e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
1732
package-lock.json
generated
1732
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -14,7 +14,9 @@
|
||||
"@react-navigation/bottom-tabs": "^7.15.5",
|
||||
"@react-navigation/elements": "^2.9.10",
|
||||
"@react-navigation/native": "^7.1.33",
|
||||
"buffer": "^6.0.3",
|
||||
"expo": "~55.0.9",
|
||||
"expo-brownfield": "~55.0.16",
|
||||
"expo-constants": "~55.0.9",
|
||||
"expo-device": "~55.0.10",
|
||||
"expo-font": "~55.0.4",
|
||||
@@ -30,13 +32,14 @@
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0",
|
||||
"react-native": "0.83.4",
|
||||
"react-native-ble-plx": "^3.5.1",
|
||||
"react-native-gesture-handler": "~2.30.0",
|
||||
"react-native-worklets": "0.7.2",
|
||||
"react-native-reanimated": "4.2.1",
|
||||
"react-native-safe-area-context": "~5.6.2",
|
||||
"react-native-screens": "~4.23.0",
|
||||
"react-native-web": "~0.21.0",
|
||||
"expo-brownfield": "~55.0.16"
|
||||
"react-native-worklets": "0.7.2",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "~19.2.2",
|
||||
|
||||
@@ -1,98 +1,32 @@
|
||||
import * as Device from 'expo-device';
|
||||
import { Platform, StyleSheet } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useEffect } from 'react'
|
||||
import { View, Text, StyleSheet } from 'react-native'
|
||||
|
||||
import { AnimatedIcon } from '@/components/animated-icon';
|
||||
import { HintRow } from '@/components/hint-row';
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import { ThemedView } from '@/components/themed-view';
|
||||
import { WebBadge } from '@/components/web-badge';
|
||||
import { BottomTabInset, MaxContentWidth, Spacing } from '@/constants/theme';
|
||||
import { bleSDK } from '../../ble/sdk/BleSDK'
|
||||
|
||||
function getDevMenuHint() {
|
||||
if (Platform.OS === 'web') {
|
||||
return <ThemedText type="small">use browser devtools</ThemedText>;
|
||||
}
|
||||
if (Device.isDevice) {
|
||||
return (
|
||||
<ThemedText type="small">
|
||||
shake device or press <ThemedText type="code">m</ThemedText> in terminal
|
||||
</ThemedText>
|
||||
);
|
||||
}
|
||||
const shortcut = Platform.OS === 'android' ? 'cmd+m (or ctrl+m)' : 'cmd+d';
|
||||
export default function SDKEntry() {
|
||||
useEffect(() => {
|
||||
bleSDK.initialize()
|
||||
console.log('[DuooomiBleSDK] Ready')
|
||||
}, [])
|
||||
|
||||
// Brownfield 模式下这个 UI 通常不可见,
|
||||
// 但可以用于调试或显示 SDK 状态
|
||||
return (
|
||||
<ThemedText type="small">
|
||||
press <ThemedText type="code">{shortcut}</ThemedText>
|
||||
</ThemedText>
|
||||
);
|
||||
}
|
||||
|
||||
export default function HomeScreen() {
|
||||
return (
|
||||
<ThemedView style={styles.container}>
|
||||
<SafeAreaView style={styles.safeArea}>
|
||||
<ThemedView style={styles.heroSection}>
|
||||
<AnimatedIcon />
|
||||
<ThemedText type="title" style={styles.title}>
|
||||
Welcome to Expo
|
||||
</ThemedText>
|
||||
</ThemedView>
|
||||
|
||||
<ThemedText type="code" style={styles.code}>
|
||||
get started
|
||||
</ThemedText>
|
||||
|
||||
<ThemedView type="backgroundElement" style={styles.stepContainer}>
|
||||
<HintRow
|
||||
title="Try editing"
|
||||
hint={<ThemedText type="code">src/app/index.tsx</ThemedText>}
|
||||
/>
|
||||
<HintRow title="Dev tools" hint={getDevMenuHint()} />
|
||||
<HintRow
|
||||
title="Fresh start"
|
||||
hint={<ThemedText type="code">npm run reset-project</ThemedText>}
|
||||
/>
|
||||
</ThemedView>
|
||||
|
||||
{Platform.OS === 'web' && <WebBadge />}
|
||||
</SafeAreaView>
|
||||
</ThemedView>
|
||||
);
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.text}>Duooomi BLE SDK Running</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
flexDirection: 'row',
|
||||
},
|
||||
safeArea: {
|
||||
flex: 1,
|
||||
paddingHorizontal: Spacing.four,
|
||||
alignItems: 'center',
|
||||
gap: Spacing.three,
|
||||
paddingBottom: BottomTabInset + Spacing.three,
|
||||
maxWidth: MaxContentWidth,
|
||||
backgroundColor: '#000',
|
||||
},
|
||||
heroSection: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flex: 1,
|
||||
paddingHorizontal: Spacing.four,
|
||||
gap: Spacing.four,
|
||||
text: {
|
||||
color: '#fff',
|
||||
fontSize: 16,
|
||||
},
|
||||
title: {
|
||||
textAlign: 'center',
|
||||
},
|
||||
code: {
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
stepContainer: {
|
||||
gap: Spacing.three,
|
||||
alignSelf: 'stretch',
|
||||
paddingHorizontal: Spacing.three,
|
||||
paddingVertical: Spacing.four,
|
||||
borderRadius: Spacing.four,
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user