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
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);
|
|
});
|
|
});
|
|
}
|
|
|