帧编解码
BLE 数据帧的转义、校验与编解码
概述
FrameCodec 是 PrivateProtocol 的数据帧编解码工具,负责对 BLE 传输的数据帧进行转义编码和反转义解码,以及提供 XOR 校验和 CRC-8 校验算法。
该工具确保数据在 BLE 传输过程中不会与特殊控制字节冲突,并提供完整性校验功能。
转义规则
转义标记字节
转义标记: 0x3D ('=')编码规则
发送数据时,如果数据字节等于转义标记 0x3D,需要进行转义处理:
原始字节 0x3D => 转义为 [0x3D, 0x3D XOR 0x3D] = [0x3D, 0x00]规则:遇到转义标记本身,输出 0x3D 后跟 原字节 XOR 0x3D。
解码规则
接收数据时,遇到 0x3D 字节需要反转义:
[0x3D, next_byte] => 还原为 (next_byte XOR 0x3D)转义示例
编码:
输入: [0xAB, 0x3D, 0x01]
输出: [0xAB, 0x3D, 0x00, 0x01]
说明: 0x3D 被转义为 [0x3D, 0x00]
解码:
输入: [0xAB, 0x3D, 0x00, 0x01]
输出: [0xAB, 0x3D, 0x01]
说明: [0x3D, 0x00] 被还原为 0x3D (0x00 XOR 0x3D = 0x3D)编码实现
encode 方法对数据进行转义处理后发送:
export class FrameCodec {
private static readonly ESC = 0x3D // '='
/**
* 编码数据(转义处理)
*/
public static encode(payload: Uint8Array): Uint8Array {
return this.escape(payload)
}
/**
* 转义处理
*/
private static escape(input: Uint8Array): Uint8Array {
const output: number[] = []
for (const b of input) {
const ub = b & 0xFF
if (ub === this.ESC) {
output.push(this.ESC)
output.push(ub ^ this.ESC) // 0x3D ^ 0x3D = 0x00
} else {
output.push(b)
}
}
return new Uint8Array(output)
}
}解码实现
decode 方法对接收的数据进行反转义还原:
/**
* 解码数据(反转义处理)
*/
public static decode(frame: Uint8Array): Uint8Array {
if (!frame || frame.length < 1) {
throw new Error('frame format error')
}
return this.unescape(frame)
}
/**
* 反转义处理
*/
private static unescape(input: Uint8Array): Uint8Array {
const output: number[] = []
for (let i = 0; i < input.length; i++) {
const ub = input[i] & 0xFF
if (ub === this.ESC) {
if (i + 1 >= input.length) {
throw new Error('bad escape')
}
const next = (input[++i] & 0xFF) ^ this.ESC
output.push(next & 0xFF)
} else {
output.push(ub)
}
}
return new Uint8Array(output)
}注意:如果转义标记出现在数据末尾且没有后续字节,decode 会抛出 'bad escape' 错误。
校验算法
XOR 校验
对所有字节进行异或运算,得到单字节校验值:
/**
* 计算异或校验
* 对 payload 所有字节进行异或
*/
public static calcXor(payload: Uint8Array): number {
let x = 0
for (const b of payload) {
x ^= (b & 0xFF)
}
return x & 0xFF
}XOR 校验用于发送时附加在 payload 之后,再整体做转义。
CRC-8
CRC-8 算法用于设备认证流程中的握手校验。
参数
| 参数 | 值 |
|---|---|
| 多项式 | 0x1D |
| 初始值 | 0xFF |
| 最终异或 | 0xFF |
| 位序 | MSB-first |
完整实现
/**
* CRC-8 计算,使用多项式 0x1D
* init=0xFF, polynomial=0x1D (MSB-first), final xor=0xFF
*/
public static crc8Poly1D(
data: Uint8Array,
offset: number = 0,
length?: number
): number {
const len = length ?? (data.length - offset)
let crc = 0xFF // 初始值
const poly = 0x1D // 多项式
for (let i = 0; i < len; i++) {
crc ^= (data[offset + i] & 0xFF)
for (let j = 0; j < 8; j++) {
if ((crc & 0x80) !== 0) {
crc = ((crc << 1) & 0xFF) ^ poly
} else {
crc = (crc << 1) & 0xFF
}
}
}
crc ^= 0xFF // 最终异或
return crc & 0xFF
}参数说明
data-- 输入数据的Uint8Arrayoffset-- 起始偏移量,默认为 0length-- 计算长度,默认为从 offset 到数据末尾
算法步骤
- 初始化 CRC 为
0xFF - 对每个输入字节:
- 将 CRC 与当前字节异或
- 进行 8 轮移位运算:
- 如果最高位为 1,左移后与多项式
0x1D异或 - 如果最高位为 0,仅左移
- 如果最高位为 1,左移后与多项式
- 保持在 8-bit 范围内(
& 0xFF)
- 最终将 CRC 与
0xFF异或 - 返回 8-bit 结果
辅助函数
Hex 字符串转换
/**
* 将字节数组转换为十六进制字符串(用于调试)
* 格式: "0xAB 0x01 0xFF"
*/
public static toHexString(data: Uint8Array): string {
return Array.from(data)
.map(b => `0x${b.toString(16).padStart(2, '0').toUpperCase()}`)
.join(' ')
}
/**
* 从十六进制字符串创建字节数组
* 支持 "0xAB 0x01" 和 "AB01" 两种格式
*/
public static fromHexString(hex: string): Uint8Array {
const bytes = hex
.replace(/0x/g, '')
.replace(/\s+/g, '')
.match(/.{1,2}/g)
?.map(byte => parseInt(byte, 16)) || []
return new Uint8Array(bytes)
}Hex 转 JSON
/**
* 将十六进制字符串转换为 JSON 字符串
* 用于解析设备上报的数据(如 VxMi 协议的 JSON 响应)
*/
public static hexTojson(hex: string): string {
const bytes = this.fromHexString(hex)
const decoder = new TextDecoder('utf-8')
return decoder.decode(bytes)
}完整收发流程
帧编码流程(发送)
构造原始帧(如 0xAB 0x01 0x05 0x05 0x05)
|
v
计算 XOR 校验
|
v
附加校验字节到帧尾部
|
v
FrameCodec.encode() -- 转义处理
|
v
BLE Write 发送帧解码流程(接收)
BLE Notify 收到原始数据
|
v
FrameCodec.decode() -- 反转义处理
|
v
提取 payload 和校验字节
|
v
验证 XOR 校验
|
v
解析帧内容(如 PrivateProtocol.parseDeviceNotification)发送示例
import { FrameCodec } from '@/utils/bluetooth/le/FrameCodec'
import { PrivateProtocol } from '@/utils/bluetooth/protocol/PrivateProtocol'
// 1. 构造控制帧
const frame = PrivateProtocol.makeMotorControlCombined(5, 5, 5)
// frame = Uint8Array([0xAB, 0x01, 0x05, 0x05, 0x05])
// 2. 计算 XOR 校验(可选,取决于设备是否要求)
const checksum = FrameCodec.calcXor(frame)
const withChecksum = new Uint8Array([...frame, checksum])
// 3. 转义编码
const encoded = FrameCodec.encode(withChecksum)
// 4. 发送
await unifiedBluetoothManager.write(encoded, connectedDevice)
// 调试输出
console.log('原始帧:', FrameCodec.toHexString(frame))
console.log('编码后:', FrameCodec.toHexString(encoded))接收示例
import { FrameCodec } from '@/utils/bluetooth/le/FrameCodec'
import { PrivateProtocol } from '@/utils/bluetooth/protocol/PrivateProtocol'
// 通知回调
function onNotification(rawData: Uint8Array): void {
// 1. 反转义解码
let decoded: Uint8Array
try {
decoded = FrameCodec.decode(rawData)
} catch (e) {
console.error('解码失败:', e)
// 解码失败,使用原始数据
decoded = rawData
}
// 2. 验证校验(可选)
if (decoded.length > 1) {
const payload = decoded.slice(0, -1)
const receivedChecksum = decoded[decoded.length - 1]
const calculatedChecksum = FrameCodec.calcXor(payload)
if (receivedChecksum !== calculatedChecksum) {
console.warn('校验不匹配')
}
}
// 3. 解析帧内容
const notification = PrivateProtocol.parseDeviceNotification(decoded)
if (notification) {
switch (notification.type) {
case 'auth':
// 认证处理:计算 CRC8 并回复
const crc = FrameCodec.crc8Poly1D(notification.rawData)
const authFrame = PrivateProtocol.makeAuth(crc)
const encodedAuth = FrameCodec.encode(authFrame)
// 发送认证响应...
break
case 'status':
// 状态更新
console.log('电量:', notification.battery)
console.log('电机:', notification.motors)
break
}
}
}完整 FrameCodec 源码
以下为 FrameCodec 类的完整实现,位于 bluetooth/le/FrameCodec.ts:
export class FrameCodec {
private static readonly ESC = 0x3D // '='
/** 计算异或校验 */
public static calcXor(payload: Uint8Array): number {
let x = 0
for (const b of payload) {
x ^= (b & 0xFF)
}
return x & 0xFF
}
/** CRC-8, poly=0x1D, init=0xFF, final xor=0xFF */
public static crc8Poly1D(
data: Uint8Array, offset: number = 0, length?: number
): number {
const len = length ?? (data.length - offset)
let crc = 0xFF
const poly = 0x1D
for (let i = 0; i < len; i++) {
crc ^= (data[offset + i] & 0xFF)
for (let j = 0; j < 8; j++) {
if ((crc & 0x80) !== 0) {
crc = ((crc << 1) & 0xFF) ^ poly
} else {
crc = (crc << 1) & 0xFF
}
}
}
crc ^= 0xFF
return crc & 0xFF
}
/** 编码(转义处理) */
public static encode(payload: Uint8Array): Uint8Array {
return this.escape(payload)
}
/** 解码(反转义处理) */
public static decode(frame: Uint8Array): Uint8Array {
if (!frame || frame.length < 1) {
throw new Error('frame format error')
}
return this.unescape(frame)
}
private static escape(input: Uint8Array): Uint8Array {
const output: number[] = []
for (const b of input) {
const ub = b & 0xFF
if (ub === this.ESC) {
output.push(this.ESC)
output.push(ub ^ this.ESC)
} else {
output.push(b)
}
}
return new Uint8Array(output)
}
private static unescape(input: Uint8Array): Uint8Array {
const output: number[] = []
for (let i = 0; i < input.length; i++) {
const ub = input[i] & 0xFF
if (ub === this.ESC) {
if (i + 1 >= input.length) throw new Error('bad escape')
const next = (input[++i] & 0xFF) ^ this.ESC
output.push(next & 0xFF)
} else {
output.push(ub)
}
}
return new Uint8Array(output)
}
/** Uint8Array -> "0xAB 0x01 0xFF" */
public static toHexString(data: Uint8Array): string {
return Array.from(data)
.map(b => `0x${b.toString(16).padStart(2, '0').toUpperCase()}`)
.join(' ')
}
/** "0xAB01FF" | "AB 01 FF" -> Uint8Array */
public static fromHexString(hex: string): Uint8Array {
const bytes = hex
.replace(/0x/g, '')
.replace(/\s+/g, '')
.match(/.{1,2}/g)
?.map(byte => parseInt(byte, 16)) || []
return new Uint8Array(bytes)
}
/** Hex -> UTF-8 -> JSON string */
public static hexTojson(hex: string): string {
const bytes = this.fromHexString(hex)
const decoder = new TextDecoder('utf-8')
return decoder.decode(bytes)
}
}