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 _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 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 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 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? 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 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, ); } }