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.
 
 
 
 
 
 

258 lines
9.4 KiB

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<int> resp(int cmd, List<int> 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<int> 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);
});
});
}