You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
402 lines
16 KiB
402 lines
16 KiB
import 'dart:typed_data';
|
|
|
|
/// Smartcar(车载香薰,方案代号 **C2**)的 BLE 私有协议。
|
|
///
|
|
/// 纯函数 + 纯数据,不碰蓝牙栈,方便单测把每个字节钉死
|
|
/// (`test/devices/smartcar_protocol_test.dart`)。
|
|
///
|
|
/// 帧格式:
|
|
/// ```
|
|
/// APP → 设备 0xAA | cmd(1) | len(1) | data(len) | crc(1)
|
|
/// 设备 → APP 0xBB | cmd(1) | len(1) | data(len) | crc(1) 应答
|
|
/// 设备 → APP 0xCC | cmd(1) | len(1) | data(len) | crc(1) 主动上报
|
|
/// ```
|
|
/// CRC 覆盖**除自己以外的全部前导字节**,算法见 [crc8Maxim]。
|
|
///
|
|
/// ⚠️ 多字节字段一律**小端**。
|
|
class SmartcarProtocol {
|
|
SmartcarProtocol._();
|
|
|
|
// ─────────────────────────── GATT ───────────────────────────
|
|
|
|
/// 控制服务
|
|
static const String serviceUuid = '0000abc0-0000-1111-2222-123456789abc';
|
|
|
|
/// 写特征(WriteWithoutResponse)
|
|
static const String writeCharUuid = '0000abc1-0000-1111-2222-123456789abc';
|
|
|
|
/// 通知特征
|
|
static const String notifyCharUuid = '0000abc2-0000-1111-2222-123456789abc';
|
|
|
|
/// 广播里用于扫描过滤的服务 UUID
|
|
static const String broadcastServiceUuid =
|
|
'0000bb01-0000-1000-8000-00805f9b34fb';
|
|
|
|
/// 音频上行服务 / 特征(**本期不用**,阶段 6 接 Opus 时才需要)。
|
|
/// 注意它与控制服务是两个不同的 service,别混。
|
|
static const String audioServiceUuid =
|
|
'0000ae00-0000-1000-8000-00805f9b34fb';
|
|
static const String audioNotifyCharUuid =
|
|
'0000ae02-0000-1000-8000-00805f9b34fb';
|
|
|
|
// ─────────────────────────── 帧头 ───────────────────────────
|
|
|
|
static const int headerRequest = 0xAA;
|
|
static const int headerResponse = 0xBB;
|
|
static const int headerNotify = 0xCC;
|
|
|
|
static bool isFrameHeader(int b) =>
|
|
b == headerRequest || b == headerResponse || b == headerNotify;
|
|
|
|
// ───────────────────────── 命令号 ─────────────────────────
|
|
//
|
|
// ⚠️⚠️ 下面标了 TODO(smartcar-cmd) 的几条,**协议文档与 lumi 代码是冲突的**。
|
|
// 按 2026-09-10 的决定:一律以 lumi 的 BleConst.kt 为准(那是真机上跑着的),
|
|
// 协议文档只作参考。样机到手后逐条复核,改这里并同步更新
|
|
// docs/Smartcar车载香薰-接入设计与落地文档.md 的 2.4 节。
|
|
//
|
|
// ⚠️ 复核时**只扫查询类命令**。0x0C 恢复出厂 / 0x0D 启停开关 / 0x0E 遥控电机
|
|
// 会真的改设备状态,别拿来试探。
|
|
|
|
/// 获取设备版本号。
|
|
///
|
|
/// ✅ **2026-09-11 真机实测**(样机固件):应答是 **3 字节** `02 0c 0c` → `2.12.12`,
|
|
/// **不是**协议文档写的 6 字节(两组 major/minor/patch)。同一个值在 0x15 的
|
|
/// `[5..7]` 又出现了一次,两处对得上,所以 3 字节是可信的。
|
|
static const int cmdGetVersion = 0x01;
|
|
|
|
/// 获取设备 ID。✅ 真机实测应答 1 字节:`01`
|
|
static const int cmdGetProductId = 0x02;
|
|
|
|
/// ⚠️ **含义仍未定**。协议文档里没有这条;lumi `BleConst.kt` 叫 CMD_GET_COLOR_ID,
|
|
/// 但 2026-09-11 真机实测应答是 **14 字节**
|
|
/// `03 00 00 00 3d 00 00 00 00 00 00 00 00 00`,一个"颜色 ID"不该这么长。
|
|
/// 里面的 `3d`(61) 与当时电量 60 只差 1,也可能是另一份状态汇总。
|
|
/// **本项目没有任何地方在用它**,先留着名字不动,等厂商给准信。
|
|
static const int cmdGetColorId = 0x03;
|
|
|
|
/// 获取电量。应答 data[0] = 0~100。
|
|
/// ✅ 真机实测 `3c` = 60%,与同一时刻 0x15 的 `[4]` 完全一致。
|
|
static const int cmdGetBattery = 0x04;
|
|
|
|
/// 手机音乐播放状态:`0x00` 没在放 / `0x01` 在放。设备据此显示"听音乐"表情。
|
|
///
|
|
/// ✅ **2026-09-11 定论(改过一次)**:早先按 lumi `BleConst.kt` 的名字
|
|
/// `CMD_CLOSE_AI_DIALOG` 理解成"关闭 AI 对话",是错的。判据是 lumi 的**运行时代码**——
|
|
/// `BleService.swift` 的 `checkMusicPlaybackState()` 在检测到手机在放音乐时发的正是
|
|
/// `0x05 [0x01]`、停止时发 `0x05 [0x00]`,与协议文档「0x05 手机音乐播放状态」一致。
|
|
/// `BleConst.kt` 里那个名字是没跟上的旧命名,别再照它改回去。
|
|
static const int cmdMusicPlayState = 0x05;
|
|
|
|
/// 控制设备情绪表达
|
|
static const int cmdControlEmotion = 0x06;
|
|
|
|
/// AI 状态(APP → 设备),载荷 1 字节,取值见 [aiStateSpeakEnd] 等。
|
|
///
|
|
/// ✅ **2026-09-11 定论(改过一次)**:早先按 `BleConst.kt` 的 `CMD_CONTROL_MOTION`
|
|
/// 理解成"控制动作",是错的——协议文档里"遥控电机"另有其号([cmdRemoteMotor] `0x0E`),
|
|
/// 0x07 就是 AI 状态。设备靠它切换"思考中/说话中/聆听中"的表情动画。
|
|
static const int cmdAiState = 0x07;
|
|
|
|
/// [cmdAiState] 的取值
|
|
static const int aiStateSpeakEnd = 0x00;
|
|
static const int aiStateThinking = 0x01;
|
|
static const int aiStateSpeakStart = 0x02;
|
|
static const int aiStateClose = 0x03;
|
|
static const int aiStateListening = 0x04;
|
|
|
|
/// TODO(smartcar-cmd): 未经真机验证 —— 协议文档里没有这条,
|
|
/// BleConst.kt 说 CMD_CONTROL_MUSIC_BEAT,取后者。
|
|
static const int cmdControlMusicBeat = 0x08;
|
|
|
|
static const int cmdControlBrightness = 0x0A;
|
|
static const int cmdSetVolume = 0x0B;
|
|
|
|
/// ⚠️ 恢复出厂设置。复核命令表时**不要**发这条。
|
|
static const int cmdFactoryReset = 0x0C;
|
|
|
|
/// ⚠️ 启停开关。同上,会改设备状态。
|
|
static const int cmdAutoPowerSwitch = 0x0D;
|
|
|
|
/// ⚠️ 遥控电机。同上。
|
|
static const int cmdRemoteMotor = 0x0E;
|
|
|
|
static const int cmdSetMusicExpression = 0x0F;
|
|
|
|
/// 「蓝牙连接成功」——**APP 告诉设备"我连上了"**,不是经典蓝牙。
|
|
///
|
|
/// ⚠️ 2026-09-10 真机定论:GATT 连上 + 杰理认证通过 + 0x15 都能应答之后,
|
|
/// 设备屏幕**仍显示 BLE 未连接**;发了这条(`AA 10 02 03 00`)设备回 `BB 10 01 64`,
|
|
/// 屏幕才认。也就是说固件把"手机已连接"这个状态交给 APP 来宣告。
|
|
/// 所以它是握手的一部分([SmartcarSession._handshake]),不是可选功能。
|
|
///
|
|
/// 载荷 2 字节:`[设备类型, 车企编号]`,设备类型 0 车机 / 1 小程序 / 2 安卓 / 3 iOS / 4 鸿蒙,
|
|
/// 车企编号 0 默认 / 1 长城。lumi 没发过这条(它不是车载产品)。
|
|
static const int cmdAppConnected = 0x10;
|
|
|
|
/// [cmdAppConnected] 的「设备类型」取值
|
|
static const int appTypeAndroid = 0x02;
|
|
static const int appTypeIos = 0x03;
|
|
static const int appTypeHarmony = 0x04;
|
|
|
|
/// [cmdAppConnected] 的「车企编号」:0 默认
|
|
static const int carVendorDefault = 0x00;
|
|
|
|
/// 陀螺仪开关(APP → 设备,`0xAA 0x11`)。
|
|
///
|
|
/// ✅ **2026-09-11 定论**:与 [notifyAiWake] 号相同但**方向不同**,协议文档里两条都在
|
|
/// (「0x11 开启AI智能对话」是 `0xCC` 设备上报,「0x11 陀螺仪开关」是 `0xAA` APP 下发),
|
|
/// 靠帧头区分,不冲突。
|
|
static const int cmdGyroscopeSwitch = 0x11;
|
|
|
|
static const int cmdMusicHeadStyle = 0x12;
|
|
static const int cmdRandomExpressionInterval = 0x13;
|
|
static const int cmdMotorSwitch = 0x14;
|
|
|
|
/// 同步参数汇总。应答 data = 14 字节,见 [SmartcarAllSettings]
|
|
static const int cmdSyncSummary = 0x15;
|
|
|
|
/// 同步手机时间(协议文档没写,lumi 代码有,真机回 `BB 17 01 64`):
|
|
/// 载荷 `[年%100, 月, 日, 时, 分, 秒, 0x01]`
|
|
static const int cmdSyncTime = 0x17;
|
|
|
|
/// 设备主动上报:AI 对话唤醒(`0xCC 0x11 0x01 0xAA`)。
|
|
///
|
|
/// ⚠️ 协议文档原话:「**不需要 app 回复,设备端直接开麦,录音发 opus 数据给到 app**」。
|
|
/// 也就是说 App 收到它之后不用下"开麦"命令,只要准备好接 opus(见 `SmartcarAudio`),
|
|
/// 并用 [cmdAiState] 把会话状态回报给设备去驱动表情。
|
|
static const int notifyAiWake = 0x11;
|
|
|
|
/// 这几条设备**不回应答**,发完就完事。
|
|
///
|
|
/// ⚠️ 别给它们等回包——会白等到超时,把一次正常操作记成失败。
|
|
static const Set<int> _noResponse = {
|
|
cmdMusicPlayState,
|
|
cmdControlEmotion,
|
|
cmdAiState,
|
|
cmdControlMusicBeat,
|
|
};
|
|
|
|
static bool expectsResponse(int cmd) => !_noResponse.contains(cmd);
|
|
|
|
// ─────────────────────────── CRC ───────────────────────────
|
|
|
|
/// CRC-8/MAXIM(Dallas/Maxim)。
|
|
///
|
|
/// poly=0x31,反射后 0x8C;init=0x00,xorOut=0x00,refIn/refOut=true。
|
|
/// 与 lumi `BleProtocol.calcCrc` 逐位一致——**改这里等于改协议**,
|
|
/// 单测里有真实帧的金值守着。
|
|
static int crc8Maxim(List<int> data) {
|
|
var crc = 0x00;
|
|
for (final b in data) {
|
|
crc ^= b & 0xFF;
|
|
for (var i = 0; i < 8; i++) {
|
|
crc = (crc & 0x01) != 0 ? (crc >> 1) ^ 0x8C : crc >> 1;
|
|
}
|
|
}
|
|
return crc & 0xFF;
|
|
}
|
|
|
|
// ────────────────────────── 组帧 ──────────────────────────
|
|
|
|
/// 组一帧 APP → 设备的请求。
|
|
static Uint8List buildPacket(int cmd, [List<int> payload = const []]) {
|
|
final out = Uint8List(payload.length + 4);
|
|
out[0] = headerRequest;
|
|
out[1] = cmd & 0xFF;
|
|
out[2] = payload.length & 0xFF;
|
|
for (var i = 0; i < payload.length; i++) {
|
|
out[3 + i] = payload[i] & 0xFF;
|
|
}
|
|
out[out.length - 1] = crc8Maxim(out.sublist(0, out.length - 1));
|
|
return out;
|
|
}
|
|
|
|
/// 解一帧设备发来的数据。
|
|
///
|
|
/// 返回 null 表示这段不是合法帧(长度不够 / 帧头不认识 / CRC 不符)。
|
|
/// **CRC 不符一律丢弃**,不要「先用着再说」——错帧解出来的电量、版本号
|
|
/// 看着都像合法值,查起来极费劲(恒玄那次就是被错位解析出的垃圾值带偏的)。
|
|
static SmartcarFrame? parseFrame(List<int> raw) {
|
|
if (raw.length < 4) return null;
|
|
final header = raw[0] & 0xFF;
|
|
if (!isFrameHeader(header)) return null;
|
|
final cmd = raw[1] & 0xFF;
|
|
final len = raw[2] & 0xFF;
|
|
if (raw.length < len + 4) return null;
|
|
final expected = raw[len + 3] & 0xFF;
|
|
final calc = crc8Maxim(raw.sublist(0, len + 3));
|
|
if (expected != calc) return null;
|
|
return SmartcarFrame(
|
|
header: header,
|
|
cmd: cmd,
|
|
payload: Uint8List.fromList(raw.sublist(3, 3 + len)),
|
|
);
|
|
}
|
|
|
|
// ──────────────────────── 广播解析 ────────────────────────
|
|
|
|
/// 解厂商自定义广播段(传完整 AD 结构:`[长度, 0xFF, CID低, CID高, ...]`)。
|
|
///
|
|
/// **真机实测布局(2026-09-10,Smartcar 样机,与 lumi iOS `parseMacFromManufacturer`
|
|
/// 的 0x0585 分支一致)**:CID 之后只有 7 字节
|
|
/// ```
|
|
/// [0] 长度 [1] Type=0xFF
|
|
/// [2-3] CID 0x05 0x85 → 小端 0x8505
|
|
/// [4-9] MAC 原样、不异或、不倒序:a9 1a b3 21 e4 dd = A9:1A:B3:21:E4:DD
|
|
/// [10] 1 字节状态 TODO(smartcar-adv):未连经典蓝牙时实测为 0x00,
|
|
/// 按协议文档「0x0C = 经典蓝牙已连」处理,连上 A2DP 后要复核
|
|
/// ```
|
|
/// 实测原始厂商数据:`05 85 a9 1a b3 21 e4 dd 00`,解出的 MAC 与设备已知地址逐字节相同,
|
|
/// 服务端 `user_binddevice` 也按这个 MAC 确权通过。
|
|
///
|
|
/// ⚠️ 协议文档里那套「26 字节:BID/PID/MAC XOR 0x5A/License/设备类型」**这台固件没有发**,
|
|
/// 第一版照文档写的解析器要求 26 字节,于是永远解不出、MAC 恒为空。
|
|
/// BID/PID/License 现在从广播里拿不到,字段保留为 0 / 空串。
|
|
static SmartcarAdvertisement? parseManufacturerData(List<int>? raw) {
|
|
if (raw == null || raw.length < 10) return null;
|
|
if ((raw[1] & 0xFF) != 0xFF) return null;
|
|
int le16(int off) => (raw[off] & 0xFF) | ((raw[off + 1] & 0xFF) << 8);
|
|
final mac = raw
|
|
.sublist(4, 10)
|
|
.map((b) => (b & 0xFF).toRadixString(16).padLeft(2, '0').toUpperCase())
|
|
.join(':');
|
|
final state = raw.length > 10 ? raw[10] & 0xFF : 0;
|
|
return SmartcarAdvertisement(
|
|
cid: le16(2),
|
|
bid: 0,
|
|
pid: 0,
|
|
classicBtConnected: state == 0x0C,
|
|
macAddress: mac,
|
|
license: '',
|
|
deviceType: 0,
|
|
);
|
|
}
|
|
}
|
|
|
|
|
|
/// 一帧解好的数据
|
|
class SmartcarFrame {
|
|
const SmartcarFrame({
|
|
required this.header,
|
|
required this.cmd,
|
|
required this.payload,
|
|
});
|
|
|
|
final int header;
|
|
final int cmd;
|
|
final Uint8List payload;
|
|
|
|
bool get isResponse => header == SmartcarProtocol.headerResponse;
|
|
bool get isNotify => header == SmartcarProtocol.headerNotify;
|
|
|
|
/// 设备回的应答里,`len == 0` 表示这条命令**执行失败**
|
|
/// (协议就是这么设计的:成功才带数据)。
|
|
bool get success => payload.isNotEmpty;
|
|
|
|
@override
|
|
String toString() =>
|
|
'SmartcarFrame(0x${header.toRadixString(16)}, cmd=0x${cmd.toRadixString(16)}, '
|
|
'len=${payload.length})';
|
|
}
|
|
|
|
/// 广播里带的设备身份
|
|
class SmartcarAdvertisement {
|
|
const SmartcarAdvertisement({
|
|
required this.cid,
|
|
required this.bid,
|
|
required this.pid,
|
|
required this.classicBtConnected,
|
|
required this.macAddress,
|
|
required this.license,
|
|
required this.deviceType,
|
|
});
|
|
|
|
/// 生产商 ID(云衍 = 0x8505)
|
|
final int cid;
|
|
|
|
/// 服务商 ID
|
|
final int bid;
|
|
|
|
/// 产品 ID
|
|
final int pid;
|
|
|
|
/// 经典蓝牙当前连没连上(决定要不要发起 A2DP 配对)
|
|
final bool classicBtConnected;
|
|
|
|
/// **经典蓝牙** MAC,已还原 XOR,大写冒号分隔。
|
|
/// 服务端确权用的就是它(`user_binddevice`)。
|
|
final String macAddress;
|
|
|
|
/// 授权码,20 个十六进制字符
|
|
final String license;
|
|
|
|
final int deviceType;
|
|
|
|
@override
|
|
String toString() => 'SmartcarAdv(pid=0x${pid.toRadixString(16)}, '
|
|
'mac=$macAddress, btConnected=$classicBtConnected)';
|
|
}
|
|
|
|
/// `0x15 同步参数汇总` 的 14 字节应答
|
|
class SmartcarAllSettings {
|
|
const SmartcarAllSettings({
|
|
required this.otaAbnormal,
|
|
required this.volume,
|
|
required this.randomExprFreq,
|
|
required this.brightness,
|
|
required this.battery,
|
|
required this.firmwareVersion,
|
|
required this.powerSwitch,
|
|
required this.musicHeadStyle,
|
|
required this.musicExpression,
|
|
required this.gyroscope,
|
|
required this.motor,
|
|
});
|
|
|
|
final bool otaAbnormal;
|
|
final int volume;
|
|
final int randomExprFreq;
|
|
final int brightness;
|
|
|
|
/// 0~100
|
|
final int battery;
|
|
|
|
/// `major.minor.patch`
|
|
final String firmwareVersion;
|
|
|
|
final bool powerSwitch;
|
|
final int musicHeadStyle;
|
|
final bool musicExpression;
|
|
final bool gyroscope;
|
|
final bool motor;
|
|
|
|
/// 布局见 lumi `BleProtocol.AllSettings`:
|
|
/// `[0]OTA异常 [1]音量 [2]随机表情频次 [3]亮度 [4]电量 [5..7]版本
|
|
/// [8]启停 [9]摇头风格 [10]音乐表情 [11]陀螺仪 [12]保留 [13]电机`
|
|
///
|
|
/// ✅ **2026-09-11 真机实测帧**:`01 00 14 0a 3c 02 0c 0c 00 02 01 01 04 01`
|
|
/// —— 偏移由两处**独立交叉验证**钉死:`[4]=0x3c`(60) 与单发 0x04 的电量一字不差、
|
|
/// `[5..7]=02 0c 0c` 与单发 0x01 的版本一字不差。解出来:
|
|
/// 亮度 10(0-10 满格)、随机表情频次 20 秒、电量 60%、版本 2.12.12。
|
|
///
|
|
/// ⚠️ **`[1]` 音量实测是 0**。如果这个偏移没错,说明样机当前是静音的——
|
|
/// A2DP 接上后 AI 回复也可能听不见。真机验声音前先确认这一项。
|
|
static SmartcarAllSettings? parse(List<int> d) {
|
|
if (d.length < 14) return null;
|
|
int u(int i) => d[i] & 0xFF;
|
|
return SmartcarAllSettings(
|
|
otaAbnormal: u(0) != 0,
|
|
volume: u(1),
|
|
randomExprFreq: u(2),
|
|
brightness: u(3),
|
|
battery: u(4),
|
|
firmwareVersion: '${u(5)}.${u(6)}.${u(7)}',
|
|
powerSwitch: u(8) != 0,
|
|
musicHeadStyle: u(9),
|
|
musicExpression: u(10) != 0,
|
|
gyroscope: u(11) != 0,
|
|
motor: u(13) != 0,
|
|
);
|
|
}
|
|
}
|
|
|