import 'package:eaimar/devices/eaicar/eaicar_protocol.dart'; import 'package:flutter_test/flutter_test.dart'; /// EaiCar C2 协议的字节级金测试。 /// /// 协议是**冻结的**:同一个命令永远发同样的字节。这些用例就是那份契约—— /// 改动这里等于改协议,必须先在真机上验过。 void main() { group('CRC-8/MAXIM', () { test('空数据为 0', () { expect(EaiCarProtocol.crc8Maxim(const []), 0x00); }); // 与 lumi BleProtocol.calcCrc 同算法(poly 0x31 反射 0x8C / init 0 / xorOut 0)。 // 这几个值是标准 CRC-8/MAXIM 的公认结果,可离线核对。 test('标准向量', () { expect(EaiCarProtocol.crc8Maxim([0x00]), 0x00); // "123456789" 的 CRC-8/MAXIM 标准 check 值是 0xA1 expect( EaiCarProtocol.crc8Maxim( '123456789'.codeUnits), 0xA1, ); }); test('逐位实现与查表口径一致(对 0..255 单字节全覆盖)', () { for (var b = 0; b < 256; b++) { var crc = 0; crc ^= b; for (var i = 0; i < 8; i++) { crc = (crc & 1) != 0 ? (crc >> 1) ^ 0x8C : crc >> 1; } expect(EaiCarProtocol.crc8Maxim([b]), crc & 0xFF); } }); }); group('组帧', () { test('无参命令:获取版本号', () { final p = EaiCarProtocol.buildPacket(EaiCarProtocol.cmdGetVersion); expect(p[0], 0xAA); expect(p[1], 0x01); expect(p[2], 0x00); // len expect(p.length, 4); expect(p[3], EaiCarProtocol.crc8Maxim([0xAA, 0x01, 0x00])); }); test('带参命令:设音量', () { final p = EaiCarProtocol.buildPacket( EaiCarProtocol.cmdSetVolume, const [0x05]); expect(p.sublist(0, 4), [0xAA, 0x0B, 0x01, 0x05]); expect(p.length, 5); expect(p[4], EaiCarProtocol.crc8Maxim([0xAA, 0x0B, 0x01, 0x05])); }); test('组出来的帧能被自己解回去', () { final p = EaiCarProtocol.buildPacket(0x42, const [1, 2, 3]); // buildPacket 发的是 0xAA,parseFrame 也认这个头 final f = EaiCarProtocol.parseFrame(p); expect(f, isNotNull); expect(f!.cmd, 0x42); expect(f.payload, [1, 2, 3]); }); }); group('解帧', () { /// 造一帧设备应答 List resp(int cmd, List payload) { final head = [0xBB, cmd, payload.length, ...payload]; return [...head, EaiCarProtocol.crc8Maxim(head)]; } test('正常应答', () { final f = EaiCarProtocol.parseFrame(resp(0x04, const [0x64])); expect(f, isNotNull); expect(f!.isResponse, isTrue); expect(f.cmd, EaiCarProtocol.cmdGetBattery); expect(f.payload, [100]); expect(f.success, isTrue); }); test('len=0 表示命令执行失败', () { final f = EaiCarProtocol.parseFrame(resp(0x01, const [])); expect(f, isNotNull); expect(f!.success, isFalse); }); test('主动上报帧头 0xCC', () { final head = [0xCC, EaiCarProtocol.notifyAiWake, 0x00]; final f = EaiCarProtocol .parseFrame([...head, EaiCarProtocol.crc8Maxim(head)]); expect(f!.isNotify, isTrue); expect(f.cmd, 0x11); }); test('CRC 不符必须丢弃', () { final bad = resp(0x04, const [0x64]); bad[bad.length - 1] = (bad.last + 1) & 0xFF; expect(EaiCarProtocol.parseFrame(bad), isNull); }); test('帧头不认识 / 长度不够 一律 null', () { expect(EaiCarProtocol.parseFrame(const [0x12, 0x01, 0x00, 0x00]), isNull); expect(EaiCarProtocol.parseFrame(const [0xBB, 0x01]), isNull); // 声明 len=5 但实际只有 1 字节 payload expect( EaiCarProtocol.parseFrame(const [0xBB, 0x01, 0x05, 0x00, 0x00]), isNull); }); }); group('广播解析', () { /// 造一段 26 字节广播:CID 0x8505 / BID 0xA001 / PID 0x0001, /// 经典蓝牙已连,MAC 11:22:33:44:55:66(存的是 XOR 0x5A 之后的值) /// 真机实测(2026-09-10):CoreBluetooth 给的厂商数据 `05 85 a9 1a b3 21 e4 dd 00`, /// 链路层补回 `[长度, 0xFF]` 后交给解析器 List adv({int btState = 0x00}) => [ 10, 0xFF, 0x05, 0x85, // CID 小端 → 0x8505 0xA9, 0x1A, 0xB3, 0x21, 0xE4, 0xDD, // MAC 原样 btState, ]; test('真机广播:MAC 原样取 CID 之后 6 字节', () { final a = EaiCarProtocol.parseManufacturerData(adv())!; expect(a.cid, 0x8505); expect(a.macAddress, 'A9:1A:B3:21:E4:DD'); expect(a.classicBtConnected, isFalse); }); test('状态字节 0x0C 视为经典蓝牙已连', () { final a = EaiCarProtocol.parseManufacturerData(adv(btState: 0x0C))!; expect(a.classicBtConnected, isTrue); }); test('没有状态字节也能解出 MAC', () { final a = EaiCarProtocol.parseManufacturerData(adv().sublist(0, 10))!; expect(a.macAddress, 'A9:1A:B3:21:E4:DD'); }); test('长度不足返回 null,不能抛', () { expect(EaiCarProtocol.parseManufacturerData(null), isNull); expect(EaiCarProtocol.parseManufacturerData(List.filled(9, 0)), isNull); // Type 不是 0xFF 的段不是厂商数据 expect(EaiCarProtocol.parseManufacturerData([10, 0x09, ...List.filled(8, 0)]), isNull); }); }); group('0x15 参数汇总', () { test('14 字节逐项解对', () { final s = EaiCarAllSettings.parse(const [ 0x00, // OTA 正常 0x06, // 音量 6 0x1E, // 随机表情频次 30 0x08, // 亮度 8 0x50, // 电量 80 1, 2, 3, // 版本 1.2.3 0x01, // 启停开 0x03, // 摇头风格 3 0x01, // 音乐表情开 0x00, // 陀螺仪关 0x00, // 保留 0x01, // 电机开 ])!; expect(s.otaAbnormal, isFalse); expect(s.volume, 6); expect(s.randomExprFreq, 30); expect(s.brightness, 8); expect(s.battery, 80); expect(s.firmwareVersion, '1.2.3'); expect(s.powerSwitch, isTrue); expect(s.musicHeadStyle, 3); expect(s.musicExpression, isTrue); expect(s.gyroscope, isFalse); expect(s.motor, isTrue); }); test('短包返回 null', () { expect(EaiCarAllSettings.parse(List.filled(13, 0)), isNull); }); }); group('命令号(2026-09-11 按 lumi 运行时代码 + 协议文档定稿)', () { test('0x05 是音乐播放状态、0x07 是 AI 状态,别按 BleConst.kt 的旧名字改回去', () { // 判据:lumi `BleService.swift` 的 checkMusicPlaybackState() 实际发的是 // 0x05 [0x01]/[0x00](手机在放/停止放音乐),与协议文档一致; // "遥控电机"另有其号 0x0E,所以 0x07 不是控制动作。 expect(EaiCarProtocol.cmdMusicPlayState, 0x05); expect(EaiCarProtocol.cmdAiState, 0x07); expect(EaiCarProtocol.cmdRemoteMotor, 0x0E); }); test('0x11 双向共号:0xAA 是陀螺仪开关,0xCC 是 AI 唤醒', () { expect(EaiCarProtocol.cmdGyroscopeSwitch, 0x11); expect(EaiCarProtocol.notifyAiWake, 0x11); final wake = EaiCarProtocol.parseFrame( EaiCarProtocol.buildPacket(EaiCarProtocol.notifyAiWake, const [0xAA]) .toList() ..[0] = EaiCarProtocol.headerNotify); // 帧头被改过,CRC 对不上,解析必须失败——这条顺带守住"CRC 覆盖帧头" expect(wake, isNull); }); test('AI 状态取值与协议文档一致', () { expect(EaiCarProtocol.aiStateSpeakEnd, 0x00); expect(EaiCarProtocol.aiStateThinking, 0x01); expect(EaiCarProtocol.aiStateSpeakStart, 0x02); expect(EaiCarProtocol.aiStateClose, 0x03); expect(EaiCarProtocol.aiStateListening, 0x04); }); }); group('0x15 真机帧(2026-09-11 样机实测,金测试)', () { // 同一次连接里单发 0x04 得到 3c(60)、单发 0x01 得到 02 0c 0c(2.12.12), // 与这一帧的 [4] / [5..7] 完全一致 —— 偏移就是靠这两处交叉验证钉死的。 const real = [ 0x01, 0x00, 0x14, 0x0a, 0x3c, 0x02, 0x0c, 0x0c, 0x00, 0x02, 0x01, 0x01, 0x04, 0x01, ]; test('电量与版本与单发命令对得上', () { final s = EaiCarAllSettings.parse(real)!; expect(s.battery, 60, reason: '与 0x04 的应答 3c 一致'); expect(s.firmwareVersion, '2.12.12', reason: '与 0x01 的应答 02 0c 0c 一致'); }); test('其余字段按已钉死的偏移解出', () { final s = EaiCarAllSettings.parse(real)!; expect(s.brightness, 10); expect(s.randomExprFreq, 20); expect(s.volume, 0, reason: '实测就是 0(样机静音),改这条前先在真机上复核'); expect(s.otaAbnormal, isTrue); }); }); group('应答期望', () { test('这四条命令设备不回包,别等超时', () { for (final c in [ EaiCarProtocol.cmdMusicPlayState, EaiCarProtocol.cmdControlEmotion, EaiCarProtocol.cmdAiState, EaiCarProtocol.cmdControlMusicBeat, ]) { expect(EaiCarProtocol.expectsResponse(c), isFalse, reason: '0x${c.toRadixString(16)} 不该等应答'); } }); test('查询类命令要等应答', () { expect(EaiCarProtocol.expectsResponse(EaiCarProtocol.cmdGetVersion), isTrue); expect(EaiCarProtocol.expectsResponse(EaiCarProtocol.cmdGetBattery), isTrue); expect(EaiCarProtocol.expectsResponse(EaiCarProtocol.cmdSyncSummary), isTrue); }); }); }