蓝牙设备对接文档

私有协议 (PrivateProtocol)

基于 0xAB 帧格式的设备控制协议

概述

PrivateProtocol 是面向私有协议设备(isPrivate === 1)的 BLE GATT 通信协议。它使用 0xAB 作为控制帧头、0xBA 作为通知帧头,支持多电机独立控制和加热控制。

BLE UUID

特征UUID
Service0000ff00-0000-1000-8000-00805f9b34fb
Write0000ff02-0000-1000-8000-00805f9b34fb
Notify0000ff01-0000-1000-8000-00805f9b34fb
// PrivateProtocol.ts
public static readonly SERVICE_UUID = '0000ff00-0000-1000-8000-00805f9b34fb'
public static readonly WRITE_UUID   = '0000ff02-0000-1000-8000-00805f9b34fb'
public static readonly NOTIFY_UUID  = '0000ff01-0000-1000-8000-00805f9b34fb'

帧格式

控制帧(App -> 设备)

所有控制帧以 0xAB 开头,紧跟命令类型字节:

[0xAB] [命令类型] [数据...]

命令类型:
  0x00 = 认证
  0x01 = 电机控制
  0x02 = 加热控制
  0x04 = 特殊功能(如出油控制)

通知帧(设备 -> App)

所有设备通知帧以 0xBA 开头:

[0xBA] [通知类型] [数据...]

通知类型:
  0x00 = 认证通知
  0x01 = 状态通知

认证帧 (0x00)

详细的认证流程请参阅 设备认证

// 认证响应帧: 0xAB 0x00 [CRC8] 0xFF 0xFF
public static makeAuth(crc: number): Uint8Array {
  return new Uint8Array([
    PrivateProtocol.NEW_DATA0,  // 0xAB
    0x00,
    crc,
    0xFF,
    0xFF
  ])
}

电机控制帧 (0x01)

基本格式

字节 [0]: 0xAB  -- 帧头
字节 [1]: 0x01  -- 电机控制类型
字节 [2]: Motor1 强度 (0-10)
字节 [3]: Motor2 强度 (0-10)
字节 [4]: Motor3 强度 (0-10)

强度范围

每个电机的强度值范围为 0-10

  • 0 = 停止
  • 1-10 = 强度等级

固定三电机控制

makeMotorControlCombined 方法构造三电机控制帧。当 i2i3 为 0 时,会自动同步为 i1 的值:

public static makeMotorControlCombined(
  i1: number, i2: number, i3: number
): Uint8Array {
  if (i2 === 0 || i3 === 0) {
    i2 = i3 = i1
  }
  const b2 = (i1 < 0) ? 0x00 : Math.max(0, Math.min(10, Math.round(i1 % 10)))
  const b3 = (i2 < 0) ? 0x00 : Math.max(0, Math.min(10, Math.round(i2 % 10)))
  const b4 = (i3 < 0) ? 0x00 : Math.max(0, Math.min(10, Math.round(i3 % 10)))

  return new Uint8Array([
    PrivateProtocol.NEW_DATA0,  // 0xAB
    0x01,
    b2, b3, b4
  ])
}

示例

// 三个电机全部设为强度 5
AB 01 05 05 05

// 电机 1 强度 3,其他停止
AB 01 03 00 00

// 所有电机停止
AB 01 00 00 00

动态数组模式

makeMotorControlFromArray 支持动态长度的电机控制,适用于任意数量电机的设备:

/**
 * 根据指令序号数组创建电机控制组合帧(动态长度)
 * @param commandArray 指令序号数组,例如 [0, 1, 4] 或 [0, 1, 4, 2, 3]
 * @returns Uint8Array 蓝牙命令字节数组
 */
public static makeMotorControlFromArray(commandArray: number[]): Uint8Array {
  if (!commandArray || commandArray.length === 0) {
    return new Uint8Array([
      PrivateProtocol.NEW_DATA0,  // 0xAB
      0x01
    ])
  }

  const motorValues: number[] = []
  for (let i = 0; i < commandArray.length; i++) {
    const value = commandArray[i] ?? 0
    // 限制在 0x00-0xFF (0-255) 范围内
    const processedValue = (value < 0) ? 0x00 : Math.min(0xFF, Math.round(value))
    motorValues.push(processedValue)
  }

  return new Uint8Array([
    PrivateProtocol.NEW_DATA0,  // 0xAB
    0x01,
    ...motorValues
  ])
}

// 示例:
// 输入: [0, 1, 4]       => Uint8Array([0xAB, 0x01, 0x00, 0x01, 0x04])
// 输入: [0, 1, 4, 2, 3] => Uint8Array([0xAB, 0x01, 0x00, 0x01, 0x04, 0x02, 0x03])

位置映射规则

func 数据结构中,每个功能通过 sort 字段确定在命令数组中的位置:

func.sort - 1 = 数组位置索引
func 的 intensity = 该位置的电机强度值

示例:
  func = [{key: "thrust", sort: 1}, {key: "vibrate", sort: 2}, {key: "suction", sort: 3}]
  selectedIntensities = Map([["thrust", 5], ["suction", 3]])

  commandArray = [5, 0, 3]
               // ^sort=1  ^sort=2(未选中)  ^sort=3

加热控制帧 (0x02)

字节 [0]: 0xAB  -- 帧头
字节 [1]: 0x02  -- 加热控制类型
字节 [2]: 0x01 (开) / 0x00 (关)
字节 [3]: 0xFF  -- 填充
字节 [4]: 0xFF  -- 填充
public static makeHeatControl(on: boolean): Uint8Array {
  return new Uint8Array([
    PrivateProtocol.NEW_DATA0,  // 0xAB
    0x02,
    on ? 0x01 : 0x00,
    0xFF,
    0xFF
  ])
}

// 开启加热: AB 02 01 FF FF
// 关闭加热: AB 02 00 FF FF

直接命令

"AB" 开头的十六进制字符串命令会直接转换为 Uint8Array 发送,不经过 makeMotorControlFromArray 转换:

// 判断是否为直接命令
function isDirectCommand(command: string): boolean {
  return command?.toUpperCase().startsWith('AB')
}

// 直接命令示例:
// "AB0401FFFF" => Uint8Array([0xAB, 0x04, 0x01, 0xFF, 0xFF]) -- 出油开启
// "AB0400FFFF" => Uint8Array([0xAB, 0x04, 0x00, 0xFF, 0xFF]) -- 出油关闭
// "AB010909FF" => Uint8Array([0xAB, 0x01, 0x09, 0x09, 0xFF]) -- 一键暴击

设备通知

认证通知 (0xBA 0x00)

连接后设备首先发送认证帧,详见 设备认证

字节 [0]:    0xBA
字节 [1]:    0x00(认证类型)
字节 [2-3]:  客户编号 (ClientID)
字节 [4-5]:  硬件版本
字节 [6-11]: 软件版本
字节 [12]:   电池电量 (0-100)

状态通知 (0xBA 0x01)

设备定期上报运行状态:

字节 [0]: 0xBA
字节 [1]: 0x01(状态类型)
字节 [2]: 电池电量 (0-100)
字节 [3]: Motor1 强度 (0-10)
字节 [4]: Motor2 强度 (0-10)
字节 [5]: Motor3 强度 (0-10)

通知解析代码

public static parseDeviceNotification(data: Uint8Array): DeviceNotification | null {
  if (!data || data.length < 2) return null

  const frame = data[0] & 0xFF
  const type = data[1] & 0xFF

  // 认证通知: 0xBA 0x00
  if (frame === 0xBA && type === 0x00) {
    return this.parseAuthNotification(data)
  }

  // 状态通知: 0xBA 0x01
  if (frame === 0xBA && type === 0x01) {
    return this.parseStatusNotification(data)
  }

  return { type: 'unknown', frame, typeCode: type, rawData: data }
}

指令生成器

功能指令生成器 (function-command-generator)

用于从 func 数据结构生成蓝牙命令。输入为 Map<functionKey, intensity>

import { generateFunctionBluetoothCommands } from '@/device/function-command-generator'

// 设备功能列表
const deviceFunctions = [
  { key: "thrust", sort: 1, maxIntensity: 9, commands: [...] },
  { key: "vibrate", sort: 2, maxIntensity: 9, commands: [...] },
  { key: "suction", sort: 3, maxIntensity: 3, commands: [...] }
]

// 选中: 抽插强度5, 吮吸强度2
const selectedIntensities = new Map([["thrust", 5], ["suction", 2]])

const commands = generateFunctionBluetoothCommands(
  selectedIntensities,
  deviceFunctions,
  connectedDevice  // { deviceModel: { isPrivate: 1 } }
)

处理逻辑:

  1. 遍历选中的功能,查找对应的 command 字符串
  2. "AB" 开头的命令 -> 直接发送(directCommands
  3. 其他命令 -> 通过 generateFunctionSequenceArray 生成位置数组,再通过 makeMotorControlFromArray 转换
  4. 合并直接命令和转换后的命令

模式指令生成器 (mode-command-generator)

用于从 modes 数据结构生成蓝牙命令。输入为 Set<"modeIndex-cmdIndex">

import { generateBluetoothCommands } from '@/device/mode-command-generator'

// 设备模式列表
const deviceModes = [
  { name: "抽插", sort: 1, commands: [
    { sort: 0, command: "0", name: "停止" },
    { sort: 21, command: "21", name: "模式一" },
    { sort: 22, command: "22", name: "模式二" }
  ]},
  { name: "出油", sort: 3, commands: [
    { sort: 0, command: "AB0400FFFF", name: "关闭出油" },
    { sort: 1, command: "AB0401FFFF", name: "开始出油" }
  ]}
]

// 选中: 抽插模式一 (modeIndex=0, cmdIndex=0)
const selectedKeys = new Set(["0-0"])

const commands = generateBluetoothCommands(
  selectedKeys,
  deviceModes,
  connectedDevice,       // { deviceModel: { isPrivate: 1 } }
  previousSelectedKeys   // 上次选中(用于增量发送)
)

处理逻辑:

  1. 解析 "modeIndex-cmdIndex" 键,定位对应的 modecommand
  2. 过滤掉停止指令(sort === 0sort < 0),只处理 sort > 0 的命令
  3. "AB" 开头的命令 -> 直接发送
  4. 其他命令 -> 通过 generateCommandSequenceArray + makeMotorControlFromArray 转换
  5. 普通设备支持增量发送(只发送变化的模式命令)

命令类型判断

两个生成器共用相同的判断逻辑:

命令格式类型处理方式
"1", "9"数字命令通过 makeMotorControlFromArray 转换为私有协议格式
"21", "28"模式命令通过 makeMotorControlFromArray 转换为私有协议格式
"AB0401FFFF"直接命令直接转换为 Uint8Array 发送
"AB010909FF"直接命令直接转换为 Uint8Array 发送

发送命令

通过 UnifiedBluetoothManager.sendCommand 发送命令字符串:

public async sendCommand(command: string): Promise<boolean> {
  const connectedDevice = useDeviceStore.getState().connectedDevice
  const modelName = connectedDevice?.deviceModel?.modelName
  const isPrivate = connectedDevice?.deviceModel?.isPrivate

  // 自动处理命令转换
  const encoded = getBlePayloadCommandByCode(modelName, isPrivate, command)
  if (encoded.length === 0) return false

  return this.write(encoded, connectedDevice)
}

getBlePayloadCommandByCode 的转换逻辑:

  1. 如果 isPrivate,查找 bleCommandModes['default'] 中的命令映射
  2. 找到映射 -> 提取电机强度,调用 makeMotorControlCombined
  3. 未找到映射 -> 将命令字符串直接作为十六进制转换为 Uint8Array

完整示例

import { unifiedBluetoothManager } from '@/utils/bluetooth/UnifiedBluetoothManager'

// 1. 电机控制 -- 三电机同步强度 5
await unifiedBluetoothManager.sendCommand("5")

// 2. 预设模式 -- 抽插模式一
await unifiedBluetoothManager.sendCommand("21")

// 3. 特殊功能 -- 出油开启(直接命令)
await unifiedBluetoothManager.sendCommand("AB0401FFFF")

// 4. 特殊功能 -- 一键暴击(直接命令)
await unifiedBluetoothManager.sendCommand("AB010909FF")

// 5. 加热控制 -- 开启加热
await unifiedBluetoothManager.sendCommand("AB0201FFFF")

// 6. 停止所有
await unifiedBluetoothManager.sendCommand("0")

// 7. 使用功能指令生成器
import { generateFunctionBluetoothCommands } from '@/device/function-command-generator'

const commands = generateFunctionBluetoothCommands(
  new Map([["thrust", 5], ["suction", 2]]),
  device.func,
  connectedDevice
)
// commands: ["AB010500020000..."](根据设备功能转换)
for (const cmd of commands.filter(c => c)) {
  await unifiedBluetoothManager.sendCommand(cmd)
}