18 KiB
EaiCar 设备控制面板 — 落地文档
状态:已按 §5 落地(2026-09-22,
along),待真机验证(§5.7)。§7 的 5 个待定项按文中默认值实现: 随机表情"频次 + 随机来一个"都做;启停文案照 lumi;未连接的行可点;未露出 0x12/0x0E;面板在my_devices。 参考源工程:/Users/yunyanzhineng/work/liangchuang/lumi(插件目录local_plugins/voibots_device、local_plugins/ble_service)。 本工程侧:apps/client/lib/devices/eaicar/、lib/modules/my_devices/。 接入设计与协议总览见 Smartcar车载香薰-接入设计与落地文档.md,本文只讲"控制面板"这一件事。
1. 要做什么
「设备 → 设备管理」列表里,EaiCar 那一行除解绑按钮外的整块区域可点。点开一个底部面板:
- 先向设备拉一次当前状态(
0x15汇总帧),显示出来; - 面板上可以直接改:音量、亮度、随机表情、启停开关、行车感应开关、摇摆开关;
- 改完立刻反映在面板上,失败回滚并提示。
其它设备(Echo-one 耳机、EaiRec 支架)那一行行为不变。
2. 六项指令(以 lumi 真正发出的字节为准)
帧格式两边一致:AA | cmd | len | payload | crc,CRC-8/MAXIM(poly 0x31,反射 0x8C,init 0)。
本工程 eaicar_protocol.dart 的 buildPacket / crc8Maxim 与 lumi 逐字节相同,协议层不用改。
所有 payload 都是单字节标量,没有字节序问题。
| 功能 | 请求帧 | 取值 | 应答 | 当前值来源 | lumi 代码 |
|---|---|---|---|---|---|
| 音量 | AA 0B 01 <v> crc |
0~10(原生校验写的 0~16 是宽松校验,UI 只发 0~10) | BB 0B 01 64 成功 / BB 0B 00 失败 |
0x15 byte[1] |
BleService.kt:1105,UI settings_view.dart:383 Slider(divisions 10) |
| 亮度 | AA 0A 01 <v> crc |
0~10 | BB 0A 01 64 / BB 0A 00 |
0x15 byte[3] |
BleService.kt:1092,UI settings_view.dart:493 |
| 随机表情频次 | AA 13 01 <秒> crc |
秒数原值:20/30/40/50/60/90/120/150/180 | BB 13 01 64 / BB 13 00 |
0x15 byte[2] |
BleService.kt:1259,UI settings_view.dart:1423 Slider 吸附到档位 |
| 立即播一个表情 | AA 06 01 <id> crc |
0x00 开心 / 01 生气 / 02 伤心 / 03 害怕 / 04 疲惫 / 05 得意 / 06 无聊 / 07 疑惑 / 08 跳舞 / 09 唱歌 | 无应答(单向) | 无状态 | ble_manager.dart:1471,枚举 chat_controller.dart:35-59 |
| 启停开关 | AA 0D 01 01/00 crc |
1 开 / 0 关 | BB 0D 01 64 / BB 0D 00 |
0x15 byte[8] |
BleService.kt:1207,UI settings_view.dart:697(文案「启停开关」,常量名 AUTO_POWER_SWITCH) |
| 行车感应开关 | AA 11 01 01/00 crc |
1 开 / 0 关 | BB 11 01 64 / BB 11 00 |
0x15 byte[11] |
BleService.kt:1216,UI settings_view.dart:756(代码叫「陀螺仪」,文案「行车感应」) |
| 摇摆开关 | AA 14 01 01/00 crc |
1 开 / 0 关 | BB 14 01 64 / BB 14 00 |
0x15 byte[13] |
BleService.kt:1250,UI settings_view.dart:815(代码叫「电机」,文案「摇摆」) |
| 状态查询 | AA 15 01 FF crc |
固定 0xFF |
BB 15 0E <14B> / BB 15 00 |
自身 | BleService.kt:1085;lumi 连上后 1.2 s 自动发一次 |
2.1 0x15 汇总帧 14 字节偏移(本工程 EaiCarAllSettings.parse 已按此实现,金测试已钉死)
[0] OTA 异常 [1] 音量 0-10 [2] 随机表情频次(秒) [3] 亮度 0-10
[4] 电量 0-100 [5..7] 固件 major.minor.patch
[8] 启停开关 [9] 音乐摇头风格 [10] 音乐表情开关 [11] 行车感应(陀螺仪)
[12] 保留 [13] 摇摆(电机)
2.2 四个必须知道的坑
- 六项都没有独立的"读"命令,也没有主动上报。当前值只能靠
0x15;设置成功的应答只回0x64成功码,不回显新值。所以面板的即时反馈只能是"乐观回填",要确认就再发一次0x15。 - "随机表情"在 lumi 里是频次设置,不是触发。设备没有"现在随机播一个"的命令;要立刻播只能 App 自己随机挑一个 0x00~0x09 走
0x06(单向、无应答、无法知道设备是否真播了)。见 §7 待确认第 1 条。 0x11双向语义不同:AA 11是行车感应开关;CC 11 01 AA是设备上报「AI 唤醒」。本工程eaicar_device_plugin.dart:544已按帧头分发,新增发送方向不影响它,但别把0xCC 0x11当成开关状态回报。- 命令必须在 RCSP 认证通过后发(lumi Android 硬拦
isAuthPassed;iOS 只查连接,两端不一致)。本工程EaiCarLink.send以ready(= 认证通过)为门槛,两端一致,比 lumi 更严,保持。 - 文档
c2_ble_protocol.md有两处错的:CRC 写成"异或"(实为 CRC-8/MAXIM);0x15应答只列了 5 个字段(实为 14 字节)。以代码为准。
3. 本工程现状:已有 / 缺失
| 功能 | 命令常量 | invokeFeature 键 |
0x15 解析 |
状态透出到业务层 | UI |
|---|---|---|---|---|---|
| 音量 | ✅ cmdSetVolume 0x0B |
✅ eaicar.volume(args['value']) |
✅ | ❌ | ❌ |
| 亮度 | ✅ cmdControlBrightness 0x0A |
✅ eaicar.brightness |
✅ | ❌ | ❌ |
| 随机表情频次 | ✅ cmdRandomExpressionInterval 0x13(仅常量) |
❌ | ✅ | ❌ | ❌ |
| 播一个表情 | ✅ cmdControlEmotion 0x06 |
✅ eaicar.emotion |
— | — | ❌ |
| 启停开关 | ✅ cmdAutoPowerSwitch 0x0D(仅常量) |
❌ | ✅ | ❌ | ❌ |
| 行车感应 | ✅ cmdGyroscopeSwitch 0x11(仅常量) |
❌ | ✅ | ❌ | ❌ |
| 摇摆开关 | ✅ cmdMotorSwitch 0x14(仅常量) |
❌ | ✅ | ❌ | ❌ |
断点只有一处:eaicar_device_plugin.dart:525 _applySummary。0x15 解出的 EaiCarAllSettings 里,
它只把固件版本和电量写进 DeviceInfo,其余全部丢弃。所以"状态回读"这条链 90% 是现成的,
补一条透出的路即可。
设备管理页现状(my_devices_view.dart:331-516 _buildDeviceCard):整行没有 onTap,只有解绑按钮可点;
行的品类枚举 _MyDeviceCategory 没有车载香薰这一档(devicetype 服务端已恒为 0,不能用来判断)。
4. 分层约束(不能违反的两条)
- 业务层不认厂商:
lib/modules|core|data不能 importlib/devices/eaicar/,不能出现厂商类名 (test/devices/device_hub_boundary_test.dart守着)。所以面板只能通过DeviceHub拿DeviceSession, 用invokeFeature+eventStream说话。 - 同一 featureKey 永远发同样字节,由字节级金测试守着(
device_plugin_interface铁律)。
由此得出的设计:面板是厂商无关的"设备设置面板",靠能力位出现,靠设备声明的设置项渲染;EaiCar 插件负责把通用设置键映射成字节。 将来支架/耳机要露设置项,只需在各自插件里声明,面板零改动。
5. 方案
5.1 契约层 device_plugin_interface(纯 Dart,加不改)
// device_capability.dart
enum DeviceCapability { ..., settingsPanel } // 新增:设备有可读可写的设置项
// device_session.dart · DeviceFeatures 新增通用键(app → 设备)
static const settingsQuery = 'settings.query'; // 拉一次全部设置,返回 Map
static const settingsVolume = 'settings.volume'; // {value:int}
static const settingsBrightness = 'settings.brightness'; // {value:int}
static const settingsPower = 'settings.power'; // {on:bool}
static const settingsDrivingSense = 'settings.driving_sense'; // {on:bool}
static const settingsSwing = 'settings.swing'; // {on:bool}
static const settingsRandomExpressionInterval = 'settings.random_expression_interval'; // {seconds:int}
static const settingsPlayRandomExpression = 'settings.play_random_expression'; // 无参,见 §7-1
// DeviceFeatureKeys 新增(设备 → app 事件)
static const settingsChanged = 'settings.changed'; // data = 同 settingsQuery 的返回 Map
settings.query 返回值形状(也是 settings.changed 事件的 data):
{
'items': { // 只列设备支持的项,面板按 key 渲染,缺的不画
'volume': {'value': 6, 'min': 0, 'max': 10},
'brightness': {'value': 8, 'min': 0, 'max': 10},
'random_expression_interval': {'value': 30, 'options': [20,30,40,50,60,90,120,150,180]},
'power': {'on': true},
'driving_sense':{'on': false},
'swing': {'on': true},
'play_random_expression': {}, // 只表示"支持这个动作"
},
'battery': 80, 'firmware': '1.2.3', 'otaAbnormal': false,
}
现有的 eaicar.volume / eaicar.brightness / eaicar.emotion 三个键保留不动(冻结),新键与它们发同样的字节。
5.2 EaiCar 插件 lib/devices/eaicar/
| 改动 | 位置 | 内容 |
|---|---|---|
| 声明能力 | eaicar_device_plugin.dart:369 capabilities |
加 DeviceCapability.settingsPanel |
| 透出状态 | :525 _applySummary |
把整个 EaiCarAllSettings 存进 _settings,_emitFeature(DeviceFeatureKeys.settingsChanged, _settingsMap())。固件/电量逻辑不动 |
| 新特性键 | :762 invokeFeature switch |
settings.query → send(0x15,[0xFF]) → 解析 → 返回 Map;settings.volume → send(0x0B,[v]),v 夹到 0~10;settings.brightness → send(0x0A,[v]);settings.power → send(0x0D,[on?1:0]);settings.driving_sense → send(0x11,[on?1:0]);settings.swing → send(0x14,[on?1:0]);settings.random_expression_interval → send(0x13,[s]),s 必须在 9 档里,否则 invalidArgument;settings.play_random_expression → send(0x06,[random 0..9], expectResponse:false) |
| 应答判定 | 同上 | 0x0A/0B/0D/11/13/14 的应答 len==0 视为设备拒绝 → 抛 DeviceException(operationFailed);成功后本地乐观更新 _settings 并发一次 settings.changed(设备不回显值,见 §2.2-1) |
| 协议常量 | eaicar_protocol.dart |
不加新命令号(都有了);加 kRandomExpressionIntervalOptions 常量与表情 id 枚举注释 |
EaiCarLink.send 按 cmd 号配对应答、同 cmd 并发时复用同一个 Completer(eaicar_link.dart:389)——
滑块连续拖动时靠 UI 侧 300 ms 防抖即可,插件不需要排队。
5.3 面板 lib/modules/my_devices/views/widgets/device_settings_sheet.dart(新,厂商无关)
showModalBottomSheet,写法照lib/modules/meeting/views/bottomSheet/more_bottom_sheet.dart;开关行样式照settings_view.dart:890 _buildToggleSetting。- 打开时:
- 用行的 MAC 找会话:遍历
DeviceHub.to.sessions,info.macAddress/info.id/metadata['localId']与device.devicemac相等(与MyDevicesController.isConnected同一套判定,抽成公共函数)且capabilities.contains(settingsPanel)。 - 找不到 → 面板显示「设备未连接」+「去连接」(跳
Routes.eaicarScan,即品类扫描页),控件全部禁用。 - 找到 → 显示骨架屏,
invokeFeature(DeviceFeatures.settingsQuery),3 s 超时;成功按items渲染,失败显示「读取失败,重试」。
- 用行的 MAC 找会话:遍历
- 渲染规则:
items里有哪个 key 画哪个控件;顺序固定:启停 → 音量 → 亮度 → 行车感应 → 摇摆 → 随机表情频次 → 「随机来一个表情」按钮。 - 交互:
- 开关:点击立刻翻转(乐观),
invokeFeature失败翻回并Get.snackbar。 - 滑块:
onChanged只改本地值,300 ms 防抖后发;onChangeEnd立即发最后一个值。 - 频次滑块:
min 20 max 180 divisions 8,吸附到 9 档之一(照 lumi)。 - 表情按钮:点一次发一次,按钮 1 s 内防连点;不做状态。
- 开关:点击立刻翻转(乐观),
- 订阅
session.eventStream,feature事件 key ==settings.changed时整体刷新(覆盖乐观值);connectionStateChanged变为断开时切到「设备未连接」态。 - 顶部显示设备名、电量、固件版本(都在
settings.query返回里,顺手)。
5.4 设备管理页 my_devices_view.dart
_buildDeviceCard外包一层InkWell/GestureDetector:onTap→ 若该行"有设置面板"则打开面板,否则无动作。 解绑按钮自身的GestureDetector在子树里先消费事件,不会冒泡到整行(现有结构就是这样,不用改按钮)。- "该行有设置面板"的判定:按能力不按厂商——找到 MAC 匹配的会话且含
settingsPanel即可点; 未连接时也允许点(进面板看到「未连接 / 去连接」),判定退化为DeviceCategories.matchName(device.devicename) == carAroma(device_category.dart:109的nameKeywords,含eaicar/smartcar/车载/香薰)。 这一条是唯一按品类判断的地方,写在 data 层DeviceCategory上,不违反分层。 - 行右侧加一个 chevron 图标提示可点(仅对可点的行)。
- 顺手:
_MyDeviceCategory加carAroma一档用于图标兜底(现在落到unknown)。
5.5 文案
走模块自带 translations(lib/modules/eaicar/module.dart:48 那张表,11 个语种),不动 41 个语言文件。
新增键:deviceSettingsTitle / settingPower / settingVolume / settingBrightness / settingDrivingSense / settingSwing / settingRandomExpressionInterval / settingPlayRandomExpression / settingReadFailed / settingApplyFailed / deviceNotConnected / goConnect,单位「秒」用 @n 占位。
⚠️ 面板本身在 my_devices 模块,但文案挂在 eaicar 模块下会被 mergeTranslations 合进全局表,可用;
若将来第二个厂商也露面板,把这批键挪到全局表。
5.6 测试
| 测试 | 内容 |
|---|---|
test/devices/eaicar_protocol_test.dart |
新增金样本:AA 0D 01 01、AA 0D 01 00、AA 11 01 01、AA 14 01 00、AA 13 01 3C(60 秒)、AA 15 01 FF;随机表情 AA 06 01 xx 的 xx 必在 0x00~0x09 |
test/devices/eaicar_session_features_test.dart(新,照 bes_session_commands_test.dart) |
每个 settings.* 键 → 字节逐条断言;settings.random_expression_interval 传 45 抛 invalidArgument;settings.query 返回 Map 的形状 |
test/devices/eaicar_protocol_test.dart 0x15 段 |
已有,补一条"_applySummary 后 settings.changed 事件 data 与 14 字节一一对应" |
test/devices/device_hub_boundary_test.dart |
现有,面板与 my_devices 改动不能让它红 |
test/module_registry_test.dart |
新文案键至少有 zh_CN / en_US |
5.7 真机验证清单(按风险排)
0x0D启停:常量注释标着「会真改设备状态、别试探」——第一次发之前确认 0 是"关闭自动开关机"不是"关机"。先在0x15里看当前值,再发同值,确认应答0x64,再翻转。0x11开关翻转后0x15byte[11] 是否跟着变;同时验证翻转期间CC 11唤醒上报不受影响。0x14摇摆开关翻转后设备有无实际动作;与0x0E遥控电机、0x12摇头风格无冲突。0x13发非 9 档值(如 45)设备回什么,决定要不要在插件层强校验(设计上已校验)。- 音量发 11~16 设备回什么,确认 0~10 上限。
0x06随机表情:确认播放时设备状态(是否打断当前动作、AI 会话中是否忽略)。- 面板打开时机:连上后
_handshake已发过一次0x15,面板再发一次,确认同 cmd 并发下 Completer 复用不会把第一次的结果错配给第二次。
6. 改动清单(预估)
| 层 | 文件 | 改动 |
|---|---|---|
| 契约 | local_plugins/device_plugin_interface/lib/src/device_capability.dart、device_session.dart |
+1 能力位,+8 特性键,+1 事件键 |
| 插件 | lib/devices/eaicar/eaicar_device_plugin.dart |
_applySummary 透出、invokeFeature +8 分支、乐观更新 |
| 插件 | lib/devices/eaicar/eaicar_protocol.dart |
+档位常量、表情枚举注释 |
| 业务 | lib/modules/my_devices/views/widgets/device_settings_sheet.dart |
新文件,约 300 行 |
| 业务 | lib/modules/my_devices/views/my_devices_view.dart |
行 onTap、chevron、品类档 |
| 业务 | lib/modules/my_devices/controllers/my_devices_controller.dart |
sessionFor(device) 公共函数(从 isConnected 抽出) |
| 数据 | lib/data/models/device_category.dart |
matchName(String) 小函数(复用 nameKeywords) |
| 文案 | lib/modules/eaicar/module.dart |
+12 键 × 11 语种 |
| 测试 | test/devices/* |
见 §5.6 |
| 文档 | CLAUDE.md |
加一小节:设置面板按能力出现、0x11 双向语义、0x0D 验证口径 |
不动的:原生插件 device_smartcar(字节由 Dart 组,原生只透传 writeCustom)、EaiCarLink、协议帧格式、iOS/Android 原生代码。
7. 待你拍板的问题
- 「随机表情」到底要哪个:
- A. 频次滑块(设备原生能力,20~180 秒,与 lumi 一致);
- B. 「随机来一个」按钮(App 随机挑一个表情发
0x06,设备无此原生命令,发出去也没应答); - C. 两个都要。 本文按 C 写,默认按钮放在频次滑块下方。
- 「启停开关」的语义:lumi 常量叫"自动开关机",UI 文案叫"启停开关"。面板文案照 lumi 用「启停开关」,副标题写「自动开关机」,还是要另一种说法?
- 未连接时的行为:按本文是"能点进去、看到未连接、给去连接按钮"。另一种是未连接的行干脆不可点。
- 要不要顺带露出
0x12音乐摇头风格、0x0E遥控电机:lumi 设置页有,本文没放(你没列)。放的话多两行,插件多两个键。 - 面板归属:本文放在
my_devices模块(厂商无关)。如果你更希望它是 EaiCar 模块自己的页面(走路由、全屏),改成lib/modules/eaicar/views/eaicar_settings_view.dart,设备管理页只负责跳转。