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.
 
 
 
 
 
 

471 lines
19 KiB

import 'dart:typed_data';
/// EaiCar(车载香薰,方案代号 **C2**)的 BLE 私有协议。
///
/// 纯函数 + 纯数据,不碰蓝牙栈,方便单测把每个字节钉死
/// (`test/devices/eaicar_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 EaiCarProtocol {
EaiCarProtocol._();
// ─────────────────────────── 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(eaicar-cmd) 的几条,**协议文档与 lumi 代码是冲突的**。
// 按 2026-09-10 的决定:一律以 lumi 的 BleConst.kt 为准(那是真机上跑着的),
// 协议文档只作参考。样机到手后逐条复核,改这里并同步更新
// docs/EaiCar车载香薰-接入设计与落地文档.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(eaicar-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 来宣告。
/// 所以它是握手的一部分([EaiCarSession._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;
/// 待机随机表情频次,载荷 1 字节 = **秒数原值**(不是档位索引),
/// 允许 [randomExpressionIntervalOptions] 里的 9 档(lumi `settings_view.dart:1517`)。
/// 0x15 回读 `[2]` 也是秒数原值。
static const int cmdRandomExpressionInterval = 0x13;
/// 电机(摇摆)开关,载荷 1 字节 0/1。lumi 常量名 MOTOR,UI 文案「摇摆开关」。
static const int cmdMotorSwitch = 0x14;
/// [cmdRandomExpressionInterval] 的合法取值(秒)
static const List<int> randomExpressionIntervalOptions = [
20, 30, 40, 50, 60, 90, 120, 150, 180,
];
/// [cmdControlEmotion] 的表情 id 范围:0x00 开心 / 01 生气 / 02 伤心 / 03 害怕 /
/// 04 疲惫 / 05 得意 / 06 无聊 / 07 疑惑 / 08 跳舞 / 09 唱歌
/// (lumi `chat_controller.dart:35-59`,与协议文档一致)
static const int emotionCount = 10;
/// 音量 / 亮度的上限。lumi 原生层把音量校验写成 0~16,但 UI 只发 0~10,
/// 协议文档也写 0~10,按 10 收。
static const int volumeMax = 10;
static const int brightnessMax = 10;
/// 同步参数汇总。应答 data = 14 字节,见 [EaiCarAllSettings]
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 EaiCarFrame? 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 EaiCarFrame(
header: header,
cmd: cmd,
payload: Uint8List.fromList(raw.sublist(3, 3 + len)),
);
}
// ──────────────────────── 广播解析 ────────────────────────
/// 解厂商自定义广播段(传完整 AD 结构:`[长度, 0xFF, CID低, CID高, ...]`)。
///
/// **真机实测布局(2026-09-10,EaiCar 样机,与 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(eaicar-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 EaiCarAdvertisement? 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 EaiCarAdvertisement(
cid: le16(2),
bid: 0,
pid: 0,
classicBtConnected: state == 0x0C,
macAddress: mac,
license: '',
deviceType: 0,
);
}
}
/// 一帧解好的数据
class EaiCarFrame {
const EaiCarFrame({
required this.header,
required this.cmd,
required this.payload,
});
final int header;
final int cmd;
final Uint8List payload;
bool get isResponse => header == EaiCarProtocol.headerResponse;
bool get isNotify => header == EaiCarProtocol.headerNotify;
/// 设备回的应答里,`len == 0` 表示这条命令**执行失败**
/// (协议就是这么设计的:成功才带数据)。
bool get success => payload.isNotEmpty;
@override
String toString() =>
'EaiCarFrame(0x${header.toRadixString(16)}, cmd=0x${cmd.toRadixString(16)}, '
'len=${payload.length})';
}
/// 广播里带的设备身份
class EaiCarAdvertisement {
const EaiCarAdvertisement({
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() => 'EaiCarAdv(pid=0x${pid.toRadixString(16)}, '
'mac=$macAddress, btConnected=$classicBtConnected)';
}
/// `0x15 同步参数汇总` 的 14 字节应答
class EaiCarAllSettings {
const EaiCarAllSettings({
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 EaiCarAllSettings? parse(List<int> d) {
if (d.length < 14) return null;
int u(int i) => d[i] & 0xFF;
return EaiCarAllSettings(
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,
);
}
EaiCarAllSettings copyWith({
int? volume,
int? randomExprFreq,
int? brightness,
bool? powerSwitch,
bool? gyroscope,
bool? motor,
}) =>
EaiCarAllSettings(
otaAbnormal: otaAbnormal,
volume: volume ?? this.volume,
randomExprFreq: randomExprFreq ?? this.randomExprFreq,
brightness: brightness ?? this.brightness,
battery: battery,
firmwareVersion: firmwareVersion,
powerSwitch: powerSwitch ?? this.powerSwitch,
musicHeadStyle: musicHeadStyle,
musicExpression: musicExpression,
gyroscope: gyroscope ?? this.gyroscope,
motor: motor ?? this.motor,
);
/// `DeviceFeatures.settingsQuery` 返回值 / `settings.changed` 事件的 data。
///
/// 键名是契约层定的通用语义(见 `DeviceFeatures.settings*`),不是 C2 字段名:
/// `gyroscope` 对外叫 `driving_sense`,`motor` 对外叫 `swing`。
Map<String, Object?> toSettingsMap() => {
'items': {
'power': {'on': powerSwitch},
'volume': {'value': volume, 'min': 0, 'max': EaiCarProtocol.volumeMax},
'brightness': {
'value': brightness,
'min': 0,
'max': EaiCarProtocol.brightnessMax,
},
'driving_sense': {'on': gyroscope},
'swing': {'on': motor},
'random_expression_interval': {
'value': randomExprFreq,
'options': EaiCarProtocol.randomExpressionIntervalOptions,
},
'play_random_expression': <String, Object?>{},
},
'battery': battery,
'firmware': firmwareVersion,
'otaAbnormal': otaAbnormal,
};
}