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