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.
 
 
 
 
 
 

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 那一行除解绑按钮外的整块区域可点。点开一个底部面板:

  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 <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 四个必须知道的坑

  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,加不改)

// 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。
  • 打开时:
    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,设备管理页只负责跳转。