# 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](Smartcar车载香薰-接入设计与落地文档.md),本文只讲"控制面板"这一件事。 ## 1. 要做什么 「设备 → 设备管理」列表里,EaiCar 那一行**除解绑按钮外的整块区域**可点。点开一个底部面板: 1. 先向设备拉一次当前状态(`0x15` 汇总帧),显示出来; 2. 面板上可以直接改:**音量、亮度、随机表情、启停开关、行车感应开关、摇摆开关**; 3. 改完立刻反映在面板上,失败回滚并提示。 其它设备(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 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 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 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 四个必须知道的坑 1. **六项都没有独立的"读"命令,也没有主动上报**。当前值只能靠 `0x15`;设置成功的应答只回 `0x64` 成功码,**不回显新值**。所以面板的即时反馈只能是"乐观回填",要确认就再发一次 `0x15`。 2. **"随机表情"在 lumi 里是频次设置,不是触发**。设备没有"现在随机播一个"的命令;要立刻播只能 App 自己随机挑一个 0x00~0x09 走 `0x06`(单向、无应答、无法知道设备是否真播了)。见 §7 待确认第 1 条。 3. **`0x11` 双向语义不同**:`AA 11` 是行车感应开关;`CC 11 01 AA` 是设备上报「AI 唤醒」。本工程 `eaicar_device_plugin.dart:544` 已按帧头分发,新增发送方向不影响它,但**别把 `0xCC 0x11` 当成开关状态回报**。 4. **命令必须在 RCSP 认证通过后发**(lumi Android 硬拦 `isAuthPassed`;iOS 只查连接,两端不一致)。本工程 `EaiCarLink.send` 以 `ready`(= 认证通过)为门槛,两端一致,比 lumi 更严,保持。 5. 文档 `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. 分层约束(不能违反的两条) 1. **业务层不认厂商**:`lib/modules|core|data` 不能 import `lib/devices/eaicar/`,不能出现厂商类名 (`test/devices/device_hub_boundary_test.dart` 守着)。所以面板只能通过 `DeviceHub` 拿 `DeviceSession`, 用 `invokeFeature` + `eventStream` 说话。 2. **同一 featureKey 永远发同样字节**,由字节级金测试守着(`device_plugin_interface` 铁律)。 由此得出的设计:**面板是厂商无关的"设备设置面板",靠能力位出现,靠设备声明的设置项渲染;EaiCar 插件负责把通用设置键映射成字节。** 将来支架/耳机要露设置项,只需在各自插件里声明,面板零改动。 ## 5. 方案 ### 5.1 契约层 `device_plugin_interface`(纯 Dart,加不改) ```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): ```dart { '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`。 - 打开时: 1. 用行的 MAC 找会话:遍历 `DeviceHub.to.sessions`,`info.macAddress`/`info.id`/`metadata['localId']` 与 `device.devicemac` 相等(与 `MyDevicesController.isConnected` 同一套判定,抽成公共函数)且 `capabilities.contains(settingsPanel)`。 2. 找不到 → 面板显示「设备未连接」+「去连接」(跳 `Routes.eaicarScan`,即品类扫描页),控件全部禁用。 3. 找到 → 显示骨架屏,`invokeFeature(DeviceFeatures.settingsQuery)`,3 s 超时;成功按 `items` 渲染,失败显示「读取失败,重试」。 - 渲染规则:`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 真机验证清单(按风险排) 1. `0x0D` 启停:常量注释标着「会真改设备状态、别试探」——**第一次发之前确认 0 是"关闭自动开关机"不是"关机"**。先在 `0x15` 里看当前值,再发同值,确认应答 `0x64`,再翻转。 2. `0x11` 开关翻转后 `0x15` byte[11] 是否跟着变;同时验证翻转期间 `CC 11` 唤醒上报不受影响。 3. `0x14` 摇摆开关翻转后设备有无实际动作;与 `0x0E` 遥控电机、`0x12` 摇头风格无冲突。 4. `0x13` 发非 9 档值(如 45)设备回什么,决定要不要在插件层强校验(设计上已校验)。 5. 音量发 11~16 设备回什么,确认 0~10 上限。 6. `0x06` 随机表情:确认播放时设备状态(是否打断当前动作、AI 会话中是否忽略)。 7. 面板打开时机:连上后 `_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. 待你拍板的问题 1. **「随机表情」到底要哪个**: - A. 频次滑块(设备原生能力,20~180 秒,与 lumi 一致); - B. 「随机来一个」按钮(App 随机挑一个表情发 `0x06`,设备无此原生命令,发出去也没应答); - C. 两个都要。 本文按 **C** 写,默认按钮放在频次滑块下方。 2. **「启停开关」的语义**:lumi 常量叫"自动开关机",UI 文案叫"启停开关"。面板文案照 lumi 用「启停开关」,副标题写「自动开关机」,还是要另一种说法? 3. **未连接时的行为**:按本文是"能点进去、看到未连接、给去连接按钮"。另一种是未连接的行干脆不可点。 4. **要不要顺带露出 `0x12` 音乐摇头风格、`0x0E` 遥控电机**:lumi 设置页有,本文没放(你没列)。放的话多两行,插件多两个键。 5. **面板归属**:本文放在 `my_devices` 模块(厂商无关)。如果你更希望它是 EaiCar 模块自己的页面(走路由、全屏),改成 `lib/modules/eaicar/views/eaicar_settings_view.dart`,设备管理页只负责跳转。