蓝牙设备对接文档

帧编解码

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 -- 输入数据的 Uint8Array
  • offset -- 起始偏移量,默认为 0
  • length -- 计算长度,默认为从 offset 到数据末尾

算法步骤

  1. 初始化 CRC 为 0xFF
  2. 对每个输入字节:
    • 将 CRC 与当前字节异或
    • 进行 8 轮移位运算:
      • 如果最高位为 1,左移后与多项式 0x1D 异或
      • 如果最高位为 0,仅左移
    • 保持在 8-bit 范围内(& 0xFF
  3. 最终将 CRC 与 0xFF 异或
  4. 返回 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)
  }
}