feat: init duooomi BLE SDK with brownfield support
This commit is contained in:
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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user