# Smartcar(车载香薰)接入 —— 设计与落地文档 > 2026-09-09 起草 → 2026-09-10 按客户端架构重构大幅修订 → 2026-09-10 **决策定稿**。 > 除命令表需真机复核外,实现方案已全部确定,可以开工。 > 对应产品:后台已登记的 `Smartcar`,客户端品类 `DeviceCategories.carAroma`,厂商键 `DeviceVendors.smartcar`。 > 参考源工程:`/Users/yunyanzhineng/work/src/lumi`(Lumi「智能车载AI精灵」App,同一颗方案)。 > **本文只做设计,未改任何代码。** --- ## 0. 一句话结论(本次修订后) 客户端刚做的设备层重构**把这件事的难度降了一大截**:`DeviceHub` + `DevicePlugin` 已经把 「接一种新芯片」标准化了,而且 `DeviceVendors.smartcar` 和 `AgentDefaults` 的 `smartcar → carAroma` 映射**已经预埋好了**。 所以现在的工作不再是"仿着恒玄再造一套服务",而是: > **实现一个 `DevicePlugin`(放 `lib/devices/smartcar/` + 一个原生插件), > 在 `device_bootstrap.dart` 里 `registerPlugin` 一行,声明好能力集,业务层零改动。** AI 侧同理:模块系统改成了自带路由/自带文案的 `AgentModuleDescriptor`, 新增一个 agent 不用改中心 switch、**不用动 41 个语言文件**。 仍然存在的三块硬骨头:**opus 解码器归属**(`jl_opus` 插件已被删)、 **`classic_bluetooth` 缺 `connectDevice`**(要动共享插件)、**C2 命令表文档与代码打架**。 --- ## 1. 新架构:接一种设备现在意味着什么 这是本次修订的核心,前一版文档基于旧架构写的实现方案已作废。 ### 1.1 三层结构 | 层 | 位置 | 职责 | |---|---|---| | 契约 | `local_plugins/device_plugin_interface/` | `DevicePlugin` / `DeviceSession` / `DeviceCapability` / `AudioRoute` / 各种事件 | | 厂商实现 | `lib/devices//` + 对应原生插件 | 把私有协议翻译成契约语义 | | 中枢 | `lib/devices/device_hub.dart` | **业务层唯一设备入口**,汇总会话/能力/电量 | ⚠️ **业务层(`lib/modules`、`lib/core`)只能 import `device_hub.dart` 和 `device_plugin_interface`**,不能碰 `lib/devices//` 里的任何东西 —— `test/devices/device_hub_boundary_test.dart` 守着这条线,越界会挂测试。 ### 1.2 已有两个厂商实现,可作模板 | 厂商 | 目录 | 链路 | 与 Smartcar 的相似度 | |---|---|---|---| | `bes` 恒玄 Echo-one | `lib/devices/bes/`(843+637+278 行) | 经典蓝牙 SPP / iOS GATT 桥 | 协议风格像(AA/BB 帧),链路不像 | | `holder` EaiRec 支架 | `lib/devices/holder/`(422+507 行) | **BLE + Opus** | **最像,优先照它抄** | `holder` 就是「BLE 扫描 + 私有帧 + Opus 上行」,和 Smartcar 是同一形态。 它的原生实现在 `local_plugins/recorder_holder/`(含 `lib/src/rec/` 协议层: `rec_client` / `rec_frame` / `rec_protocol` / `rec_transport` / `rec_native_transport`)。 ### 1.3 能力驱动,不写厂商分支 `DeviceCapability` 是个只增不删的枚举。**业务层禁止写 `if (vendor == 'xxx')`**, 一律问能力。Smartcar 预计要声明: | 能力 | 用途 | 备注 | |---|---|---| | `scan` / `connect` | 必备 | | | `battery` | 电量 | 走 C2 `0x04` | | `macAddress` | **服务端确权** | 见 5.2,这条 Smartcar 比恒玄强 | | `micUplink` | 设备麦上行(`openMic(ai)`) | Opus 解码后给 16k PCM | | ~~`speakerDownlink`~~ | ~~下行播 AI 回复~~ | **不需要**:下行走 A2DP,手机正常播放即可,见 7.0 | | `aiWake` | 按键/唤醒起会话 | **声明它就能白嫖 `DeviceAiSessionService`**,见 6.4 | | `customCommand` | 私有命令(情绪/亮度/电机…) | `invokeFeature` | | `ota` | 固件升级 | ✅ **本期要做**,见第 8 节 | ### 1.4 已经预埋好的两处 ```dart // lib/devices/device_vendors.dart static const String smartcar = 'smartcar'; // Smartcar 车载香薰(协议待接) // lib/data/services/agent_module_defaults.dart static const Map _vendorCategory = { DeviceVendors.bes: DeviceCategories.earphone, DeviceVendors.holder: DeviceCategories.phoneHolder, DeviceVendors.smartcar: DeviceCategories.carAroma, // ← 已经有了 }; ``` `AgentDefaults.bind(DeviceHub.to)` 挂在 `hub.sessions` 上,**谁上线就按厂商→品类铺一次默认功能**。 也就是说:只要 Smartcar 插件把会话推进 hub,默认编排会自动触发,不用另外写钩子。 --- ## 2. 设备与协议(来自 lumi,未变) 协议文档在 `lumi/docs/c2_ble_protocol.md`(自 `c2_ble_20251127.xlsx` 生成),设备代号 **C2**。 ### 2.1 GATT | 用途 | UUID | 属性 | |---|---|---| | 控制服务 | `0000ABC0-0000-1111-2222-123456789ABC` | PRIMARY | | 写 | `0000ABC1-...` | **WriteWithoutResponse** | | 通知 | `0000ABC2-...` | NOTIFY | | 广播 | `0000BB01-0000-1000-8000-00805f9b34fb` | 扫描过滤用 | ### 2.2 广播数据(25 字节)—— 绑定与经典蓝牙所需的一切都在这里 | 字节 | 含义 | 值 | |---|---|---| | 2-3 | CID 生产商 | `0x8505`(云衍) | | 4-5 | BID 服务商 | `0xA001` | | 6-7 | PID 产品 | 云衍分配 | | 8 | 经典蓝牙连接态 | `0x00` 未连 / `0x0C` 已连 | | 9-14 | **经典蓝牙 MAC** | **每字节 XOR `0x5A`** | > ⚠️ **上表是协议文档的写法,样机没有这样发**。2026-09-10 真机实测厂商数据只有 `05 85 a9 1a b3 21 e4 dd 00`:CID 之后紧跟 6 字节 MAC(**原样、不异或、不倒序**,= 设备真实地址 A9:1A:B3:21:E4:DD,`user_binddevice` 按它确权通过),再 1 字节状态(未连经典蓝牙时 0x00,按文档 0x0C=已连处理,待复核)。BID/PID/License 广播里没有。这和 lumi iOS `parseMacFromManufacturer` 的 0x0585 分支完全一致。**以代码 `SmartcarProtocol.parseManufacturerData` 为准。** | 15-24 | **License 授权码** | 10 字节 | | 25 | 设备类型 | 0x01 耳机 / 0x02 录音笔 | > ⚠️ 字节序**小端**。⚠️ MAC 要逐字节异或 `0x5A` 才是真地址,忘了这步会得到一串合法但错误的 > MAC,表现为绑定报「设备未登记」,而数据其实好好的。 ### 2.3 帧格式 | 方向 | 帧头 | 结构 | |---|---|---| | APP → 设备 | `0xAA` | sof + cmd(1) + len(1) + data(N) + CRC(1) | | 设备应答 | `0xBB` | 同上 | | 设备主动上报 | `0xCC` | 同上 | ### 2.4 命令表 —— ⚠️ 文档与代码对不上,以代码为准且必须真机复核 | 命令 | 协议文档说 | `BleConst.kt` 说 | |---|---|---| | `0x05` | 手机音乐播放状态 | `CMD_CLOSE_AI_DIALOG` | | `0x07` | AI状态 | `CMD_CONTROL_MOTION` | | `0x03` | 文档没有 | `CMD_GET_COLOR_ID` | | `0x08` | 文档没有 | `CMD_CONTROL_MUSIC_BEAT` | | `0x11` | 陀螺仪开关 | 同时也是**AI 对话触发**上报 | **按一等风险对待。** 恒玄耳机上栽过一模一样的跟头:厂商文档写的电量请求码三种解读全错, 实测才扫出真正的 `AA 09`,而且**换固件命令号还会变**。 - 实现以 `BleConst.kt` / `BleProtocol.kt` 为准(那是跑在真机上的代码) - 样机到手先做一轮命令核对,结论写回本文档并标注固件版本 - 别把这张表当稳定契约 --- ## 3. ⚠️ Opus 解码器:`jl_opus` 插件已被删除(本次修订新增的重大变化) 前一版文档写着"我们已经有 `jl_opus`,不用搬解码器"。**这个前提没了**: 本轮重构删掉了 5 个插件:`jl_opus`、`asr_tr_tts_manager`、`open_ai_service`、 `realtime`、`volcano_speech`。`pubspec.yaml` 里现在**没有任何 opus 依赖项**。 但 opus 能力并没有消失,只是**收进了各厂商插件内部**: | 端 | 支架(EaiRec)的 opus 解码在哪 | |---|---| | Android | `local_plugins/recorder_holder/.../BleService.kt` → 杰理 `jl_audio_decode` 的 `OpusManager` | | iOS | `local_plugins/recorder_holder/ios/.../SwiftOpusAudioProcessor.swift`,Package.swift 引用 `device_jieli` vendor 的 `JLAudioUnitKit` | > pubspec 里那条注释写得很清楚:`device_jieli` 之所以留着,是因为 > 「recorder_holder 的 iOS Package.swift 引用它 vendor 的 JLAudioUnitKit(支架 Opus 解码)」。 ### 3.1 ✅ 已定:方案 A(Smartcar 插件内自带一份) | | A. Smartcar 插件内自带一份 | B. 抽出共享 opus 模块 | C. 复用 recorder_holder | |---|---|---|---| | 做法 | 照 recorder_holder 的写法在新插件里再实现一遍 | 把解码抽成 `local_plugins/opus_codec` 供两家用 | Smartcar 直接依赖 recorder_holder | | 优点 | 边界最干净,改 Smartcar 不会碰支架 | 不重复 | 最省事 | | 缺点 | 两份解码代码 | **要动支架现有代码**,违反硬边界 | 把支架的协议层拖进来,耦合 | | 评价 | **推荐** | 本期不做 | 不推荐 | **已定方案 A**:重复一份解码胶水代码的成本,远低于动支架已上线代码的风险。 底层 SDK(`jl_audio_decode` jar / `JLAudioUnitKit`)本来就在树里,不用新引入依赖。 ⚠️ 落地时**不要 import `recorder_holder` 的任何东西**,照着它的写法在新插件里独立实现。 ⚠️ 无论选哪个,**目标格式是固定的**:`DeviceSession.openMic()` 要吐 **16kHz / 16bit / 单声道 PCM**(`holder_device_service` 的注释确认了这个口径), `DeviceAiSessionService` 直接把它转给百炼。 --- ## 4. 从 lumi 拉什么、不拉什么 ### 4.1 要拉 | 内容 | lumi 位置 | 用途 | |---|---|---| | C2 协议编解码 | `local_plugins/lumi_device/`(`BleProtocol.kt`/`.swift`) | 组帧/解帧/CRC | | 广播解析 | `BleConst.kt` 的字段布局 | 解 MAC / License / PID | | BLE 连接细节 | `sdk/lumi_ble/.../ble_service/BleService.{kt,swift}` 中 C2 相关部分 | GATT 连接、notify 订阅 | | **经典蓝牙自动连接时序** | `sdk/lumi_ble/lib/src/lumi_classic_manager.dart` | 见第 7 节 | | **杰理 OTA SDK** | `lumi_device/android/libs/jl_bt_ota-1.10.0.aar`、iOS `JL_OTALib.framework` | 见第 8 节 | ⚠️ **只搬协议与时序,不搬架构。** lumi 的 `LumiBle` 是个 1581 行的大单例, 直接搬会和 `DevicePlugin` 契约打架。正确做法是把协议逻辑塞进 `lib/devices/smartcar/` + 新原生插件,对外只暴露契约里的语义。 ### 4.2 不要拉 | 内容 | 为什么 | |---|---| | `bailian_multimodal` 插件 | 我们有 `BailianMultimodalService` | | 杰理 **Watch / BmpConvert** jar | 手表/表盘 SDK,与香薰无关,白撑包体(**OTA 要拉,见 4.1**) | | `MusicDetector` / `SilenceKeeper` | lumi 的产品功能,本期不做 | | `BootReceiver` / `MediaSessionNotificationListener` / `BleCompanionService` | 见 5.2,影响过审 | | `LumiBle` 大单例 | 见上,与新架构冲突 | --- ## 5. ⚠️ 四个坑(按新架构重新评估) ### 5.1 `ble_service` 仍是死代码,本次继续不碰 `apps/client/local_plugins/ble_service/`(5337 行 kt + 4343 行 swift)**依然没在 `pubspec.yaml` 里声明、`lib/` 下 0 处引用**。重构没有清理它。 它和 lumi 的同名插件已经分叉(lumi swift 5763 / dart 783,我们 4343 / 572)。 **不启用、不覆盖、不删——本次完全不碰。** 新插件另起炉灶。 ### 5.2 启用 lumi 的 `ble_service` 会引入常驻后台能力 里面带着 `BootReceiver`(开机自启)、`MediaSessionNotificationListener`(通知监听权限)、 `BleCompanionService`(前台服务)。通知监听在国内商店是**敏感权限**,与 Google Play 上架的合规压力冲突。新插件不要引入任何常驻组件。 ### 5.3 `classic_bluetooth` 缺 `connectDevice`(唯一要动共享插件的地方,仍未解决) 重构后复核,**依然只有两个方法**: | | 我们 | lumi | |---|---|---| | `getConnectedDevices()` | ✓ | ✓ | | `isBluetoothEnabled()` | ✓ | ✓ | | **`connectDevice(mac)`** | **✗** | ✓ | 而这个插件现在被 `azure_speech` 的 iOS Package.swift 引用(pubspec 注释里写着), 恒玄链路也在用。要接音乐就必须补这个方法。 ⚠️ **必须纯新增**:不改任何已有方法的签名与行为,改完回归恒玄 OTA 与 azure_speech。 ### 5.4 「只有恒玄、不考虑杰理」这条前提不成立 Smartcar 是杰理方案,opus 解码依赖 `jl_audio_decode` / `JLAudioUnitKit`。 `device_jieli` 插件之所以还留着就是为了支架的 opus。涉及香薰的评审别把杰理路径当死代码跳过。 ### 5.5 ⚠️ 固件要求先过杰理 RCSP 认证——纯 Dart 链路作废(2026-09-10 真机验证) 阶段 1 实施时曾改用 `flutter_blue_plus` 纯 Dart 实现(只订阅 `ABC0`),真机结果: **能扫到、能建 GATT,但设备屏幕始终显示 BLE 未连接,`0x15` 也不应答。** 根因在 lumi 两端代码里都写着:设备连上后要在杰理服务 `AE00/AE01/AE02` 上跑 **RCSP 认证握手**(iOS 走 `JL_BLEKit` 的 `JL_Assist.assistUpdateCharacteristic:Peripheral:Result:`, Android 走 `jl_rcsp` 的 `RcspAuth`),**认证通过固件才把手机当成已连接**。 Android 侧原话:「全新的 BLE 连接,没有走 JLWatchManager 认证,设备拒绝响应任何 RCSP 命令」。 认证算法在闭源 SDK 里,Dart 复刻不了。 由此定下的实现口径: | 项 | 做法 | |---|---| | 链路归属 | 新建原生插件 `local_plugins/device_smartcar`,**自己持有 GATT**,同一条连接上复用 `AE00`(认证)与 `ABC0`(业务)。这也是 lumi 自己写 1656 行 `BlePeripheral.kt` 的原因——SDK 持有连接就没法再订阅私有服务 | | 为什么不扩展 `device_jieli` | 它把连接交给 `JL_BLEMultiple` 持有,Android 上拿不到内部 `BluetoothGatt`,`ABC0` 接不上 | | iOS 框架共享 | `JL_BLEKit` 是**动态库**,第二份会「Class implemented in both」。`device_jieli` 的 `Package.swift` 新增 `jl-blekit` 产品,`device_smartcar` 按路径依赖(先例:支架复用 `jl-audio-unit-kit`)。我们那份 v1.13 的 `JL_Assist` 五个方法与 lumi 适配层一一对应,多出的 `assistDidReady` / `mAuthEnable` 已按头文件接上 | | Android aar | lumi 的认证在 `jl_rcsp V0.8`(`com.jieli.jl_rcsp.*`)+ `jl_watch` + `jl_bluetooth_connect`,与 `device_jieli` 已带的 `jl_bluetooth_rcsp V4.2`(`com.jieli.bluetooth.*`)**包名不重叠、可并存**;但 **`jl_audio_decode` 绝不能再放一份**(Duplicate class) | | `ready` 的定义 | = 杰理认证通过(对齐 lumi `passJlAuth`)。GATT 连上只是 `linkConnected`,业务层不能用 | | 组帧 | 仍在 Dart(`smartcar_protocol.dart`,18 条单测);原生只收发 `ABC1/ABC2` 裸字节 | | 先 iOS 后 Android | iPhone 上先验证「认证通过 → 设备显示已连接」;Android 暂为桩(返回明确的 UNIMPLEMENTED,不是 MissingPlugin) | ⚠️ 认证失败/超时都必须带原因报到 UI(「没有 AE00」「AE02 通知打不开」「固件拒绝握手」「SDK 15s 没回调」各不相同)—— 扫描与连接两次排查都栽在「失败不可见」上。 --- ### 5.6 ⚠️ 认证过了设备也不认——还要发 `0x10 蓝牙连接成功`(2026-09-10 真机定论) 原生插件上线后真机日志:`connecting → linkConnected → authenticating → ready`,`0x15` 回了电量 80%, 服务端 `user_binddevice` 也按 MAC 确权通过——**App 各层都说连上了,设备屏幕仍显示 BLE 未连接**。 原因:C2 协议里有一条 `0x10 蓝牙连接成功`(APP → 设备,载荷 `[设备类型, 车企编号]`, 设备类型 3 = iOS / 2 = 安卓),固件把「手机已连接」这个状态**交给 APP 宣告**。 在已认证的链路上发 `AA 10 02 03 00 `,设备回 `BB 10 01 64`。lumi 从没发过它(lumi 不是车载产品), 所以照搬 lumi 的连接流程永远不会碰到这条。 落地:`SmartcarSession._handshake` 第一步就发 0x10,无应答或 `len=0` 一律判握手失败断开—— "App 以为连上了、设备不认"就是这次排查的起点,不能再让它静默通过。feature key `smartcar.classic_bt_connected` 改名 `smartcar.app_connected`(重发宣告用)。 ### 5.7 握手最终序列与「单活动设备」(2026-09-10 真机定稿) 真机最终亮起 BLE 已连接的完整序列(`SmartcarSession._handshake`,全部保留,未二分,TODO(smartcar-handshake)): ``` GATT 连接 → 发现 AE00/ABC0 → JL_Assist 认证 (ready) → AA 10 02 03 00 APP 已连接(iOS=3 / 安卓=2) 设备回 BB 10 01 64 → RCSP:cmdSetSystemYear / cmdTargetFeature / cmdGetSystemInfo(COMMON,BT) status 全 0 → AA 17 同步手机时间 设备回 BB 17 01 64 → AA 15 FF 参数汇总(电量/版本/开关) ``` 0x10 单独发不亮;补上 RCSP 收尾 + 0x17 之后亮。 **单活动设备**:`DeviceHub._attach` 现在把新上线的会话插到最前(= 主设备,设备页图/名/电量随它), 并断开其它厂商的会话。用户从香薰切到耳机时香薰 BLE 断开、图片切换——之前两台并存、主设备是最早连上的那台。 被动回连(系统 ACL 把耳机连回来)同样会顶掉当前设备,这是刻意的。 ### 5.8 OTA(阶段 7,iOS 先行,从 lumi 搬) 原生 `SmartcarOta.swift` 对齐 lumi `PbMangerImp.startOta / handleOtaResult / scanAndConnectStage2`: `JL_OTAManager.cmdOTAData` → 0x08~0x0f 进度 → **0xfc** stage1 完成、设备重启进 stage2 → 扫描 3×60s(广播 MAC == `outputDeviceModel().bleAddr`,或名字含 `smartcar`/原名前缀)→ 连接 + 认证 → `noteEntityConnected` → `cmdTargetFeature` → 再 `cmdOTAData` → 0x00 成功。 OTA 期间 ABC1 写入被挡(和 SDK 抢 AE01 会卡死);stage2 窗口每 15s 发心跳喂上层 90s 看门狗。 `JL_OTALib` 同样由 `device_jieli` 以 `jl-otalib` 产品共享。Dart 侧 `SmartcarOtaPort` 接通用 `DeviceOtaController`(下载/看门狗/常亮都复用),会话声明 `DeviceCapability.ota` 后设备页自动出「固件升级」入口。 ⚠️ 没用 SDK 新接口 `cmdUpgrade:Option:`(内置回连):它要 SDK 自己持有连接,与我们自持 GATT 冲突。 ⚠️ **未经样机验证**:stage2 广播名、0xfc 是否真会出现、后台产品 `Smartcar` 的固件包地址是否已配。Android 仍是桩。 ### 5.9 Android 移植(2026-09-10) iOS 那套验证成立之后,Android 按同样的结构补齐,**方法/事件通道逐字对齐**,Dart 侧一份代码两端通用。 | iOS | Android | 说明 | |---|---|---| | `SmartcarBleCentral.swift` | `SmartcarBleCentral.kt` | 全量扫(不加 ScanFilter,理由同 iOS);厂商数据补回 2 字节小端公司 ID,让 Dart 解析器两端通用 | | `SmartcarPeripheral.swift` | `SmartcarGatt.kt` | 自持 GATT;**多一个操作队列**——Android 一次只允许一个在途 GATT 操作,没队列的表现是"偶尔丢一条命令"且与机型强相关 | | `JLAssistBridge.swift`(`JL_BLEKit`) | `SmartcarRcsp.kt`(`jl_rcsp` 的 `RcspAuth` + `RcspOpImpl`) | 认证算法同源(都调 `libjl_auth.so`) | | `SmartcarOta.swift`(`JL_OTALib`) | `SmartcarOta.kt`(`jl_bt_ota`) | 见下 | 认证触发点两端不同,都是照各自 SDK 的要求来的:iOS 在 `AE02` 通知打开的回调里起, Android 照 lumi 在 **MTU 协商完**之后起(并加了 6s 兜底定时器——个别机型 `onMtuChanged` 不回调, 不兜底就只会在 35s 后报「连接超时」,而那个提示指向的方向是错的)。 **依赖口径**(`local_plugins/device_smartcar/android/build.gradle` 顶部有同样的说明): - `jl_rcsp V0.8.0` maven 上没有坐标,**只能 vendor**(解包成 `libs/*.jar` + `jniLibs/*.so`; AGP 不允许 library 模块直接依赖本地 `.aar`)。包名 `com.jieli.jl_rcsp.*` / `jl_filebrowse.*`, 与工程里已有的 `com.jieli.jl_bt_ota.*` / `com.jieli.audio.*` 不重叠。 - ⚠️ **`jl_bt_ota` 必须走 maven**:`ble_service` 与 `recorder_holder` 已经依赖 `com.jieli.jl_bt_ota:jl_bt_ota`(解析到 **1.11.0**),再 vendor 一份 1.10.0 直接 Duplicate class 编不过。 1.11.0 的 `BluetoothOTAManager` 不再继承 `BluetoothDiscovery`,**没有 `startBLEScan` 可 override** (lumi 那个"拦住 SDK 扫描"的 override 也就不需要了),且 `BluetoothOTAConfigure` 的几个开关 只有 setter 没有 getter,Kotlin 属性赋值写法编不过,得调 `setXxx()`。 - ⚠️ 没有带 `JL_Watch`:lumi 的 `JLWatchManager` 继承 `WatchOpImpl`(手表专用),我们直接继承 `RcspOpImpl`,少一个 jar。 **Android 运行时权限**:这条链路不走 `flutter_blue_plus`(它自己会申请),所以 `SmartcarDevicePlugin.ensureReady()` 里自己申请 `BLUETOOTH_SCAN` / `BLUETOOTH_CONNECT`, 复用支架那套 48h 冷却闸(`PermissionUtil`)。少了这步的表现是**安卓上永远扫不到设备且不报错**。 **2026-09-11 与 iOS 二次对齐**(iOS 真机把链路跑通之后,把后续所有改动同步到安卓): | 项 | 两端状态 | |---|---| | 方法/事件通道 | 14 个方法、7 种事件**逐字一致**(脚本比对过) | | Ogg 拆包 + opus 解码 | 两端都有 `OggOpusExtractor`;`audioStats()` 五个键一致(含 `opusPackets`) | | 认证后 RCSP 收尾 | iOS `JLAssistBridge.runPostAuth` ↔ 安卓 `SmartcarRcsp.runPostAuth`:同步时间 → 设备信息 → 系统信息(通用/BT),**逐条对齐**。安卓用 `CommandBuilder.buildSyncTimeCmd / buildGetDeviceInfoCmdForAll / buildGetPublicSysInfoCmd / buildGetBtSysInfoCmd` + `sendRcspCommand`,8s 兜底 | | A2DP 用哪个 MAC | 见下 | | Dart 层(openMic 顺序、ai.state、quit、默认编排、两个 MAC) | 平台无关,自然同步 | **A2DP 用哪个 MAC**(`SmartcarSession.classicMacCandidate`):协议文档说广播里那 6 字节就是经典蓝牙 MAC, iOS 真机上服务端也按它确权通过了;但 lumi 安卓走的是「经典 MAC = BLE MAC 首字节 − 1」。 两种说法只能在安卓上分辨(安卓的 `deviceId` 就是 BLE MAC):广播 MAC ≠ BLE MAC 按文档原样用, == BLE MAC 则按 lumi 减一。⚠️ **未经安卓真机验证**(`TODO(smartcar-a2dp)`),首次真机看 `connectDevice(...)` 那行日志里三个地址的关系即可定论。 ⚠️ **Android 端整体仍未上真机**(手边没有安卓测试机连着)。首次真机要盯的四处:认证是否通过、 RCSP 收尾 + `0x10` + `0x17` 之后设备屏幕是否显示已连接、A2DP 用的 MAC 对不对、OTA 的 stage2 回连。 --- ### 5.10 阶段 5/6 落地:A2DP、opus 上行、设备唤醒(2026-09-11) #### 经典蓝牙 A2DP `classic_bluetooth` 新增 **`connectDevice(mac)` 与 `isA2dpConnected(mac)`,纯增量**—— 原有的 `getConnectedDevices` / `isBluetoothEnabled` / 状态监听一个字节没动, 所以依赖它的 `azure_speech`(iOS `MicrophoneCapture.swift` 用 `ClassicBluetoothHelper.shared` 选麦克风输入)行为不变。 | 平台 | `connectDevice` 实际做什么 | |---|---| | Android | `createBond()`。Android **没有**"直接连某个 profile"的公开 API,配对成功后系统自己接 A2DP。返回 `already_bonded` / `bonding_in_progress` / `bonding_started` | | iOS | **做不到**,恒返回 `unsupported_ios`。Apple 不提供发起经典蓝牙配对的接口,只能引导用户去「设置 → 蓝牙」手动配一次 | `isA2dpConnected` 两端都能用:Android 查 `BluetoothProfile.A2DP` 已连接列表, iOS 查 `AVAudioSession` 当前输出(`uid` 去掉 `-tacl` 后缀就是**经典蓝牙 MAC**—— 这是 iOS 上唯一能拿到设备经典 MAC 的地方)。 落地:BLE 连上后插件自动试一次 `connectClassicAudio()`(iOS 无效果),扫描页在 A2DP 没通时弹一次提示(文案 `smartcarA2dpTitle/Hint`,11 语种随模块自带)。 **失败一律不影响 BLE 会话**——BLE 管控制与上行,A2DP 只管出声。 #### opus 音频上行 设备收到唤醒后**自己开麦**(协议文档原话:「不需要 app 回复,设备端直接开麦,录音发 opus 数据给到 app」), 音频与 RCSP 命令**共用 AE02**,靠 7 字节头区分: ``` FE DC BA 80 01 00 8E | seq(1) | 04 | opus payload … | 尾字节(1,丢弃) ``` `seq` 0~255 循环用于去重(BLE 通知会重复投递,重复帧解出来是杂音)。 解码后统一是 **16k / 16bit / mono PCM**,两端解码库不同但输出口径一致: | 平台 | 解码器 | 来源 | |---|---|---| | iOS | libopus | `ble_service` 暴露的 `opus-binary` 产品(先例:recorder_holder)。⚠️ 自己再 vendor 一份 Xcode 会报「Multiple commands produce opus.framework」 | | Android | `com.jieli.jl_audio_decode.opus.OpusManager` | maven `com.jieli.audio:jl_audio_decode`,`ble_service`/`recorder_holder` 已在用同一坐标 | 排查口:`audioStats()` → `{running, framesIn, framesDup, pcmBytesOut}`。真机"没声音"时 先看这三个数,能立刻分清是**没收到帧** / **全被当重复丢了** / **解码没出字节**。 #### 设备唤醒接 Smartcar 助手 - 设备上报 `0xCC 0x11` → 会话声明了 `aiWake`,`SmartcarSession` 把它转成 `DeviceWakeEvent(ptt)`, `DeviceAiSessionService` 那整套编排直接白嫖。 - `DeviceFeatures.aiAccept` → `0x07 0x04`(聆听中);`aiExit` → `0x07 0x03`(关闭 AI 对话)。 设备靠 `0x07` 切表情动画。 - `openMic(ai)` → 上面那条 opus 流;`openSpeaker(ai)` → **手机扬声器** (`PcmStreamPlayer`,A2DP 连着时就从设备喇叭出来)。刻意**不声明** `speakerDownlink`: 下行根本不经 BLE。 - **接哪个助手**:`AgentModule.bailianAppId`(放在 **data 层基类**,不是 modules 层的 `AgentModuleDescriptor`——`DeviceAiSessionService` 在 data 层,**data 层不能反向依赖 modules 层**)。 `DeviceAiSessionService` 走「厂商 → 品类 → 默认编排第一个模块 → 该模块的 app_id」, 车载香薰因此接的是 Smartcar 车载助手(`mm_dd1eb7cb06484aafb94a0b6b2dff`)而不是 EMAI。 耳机/支架没声明就仍用全局默认,**行为不变**。 ### 5.11 ✅ 命令表复核(2026-09-11 样机实测) 两条长期冲突的命令**定了**,判据是 lumi 的**运行时代码**而不是常量名: | 命令 | 结论 | 判据 | |---|---|---| | `0x05` | **手机音乐播放状态**(`0x00`/`0x01`) | lumi `BleService.swift:1041` 在检测到手机放音乐时发的就是 `0x05 [0x01]`,停止发 `[0x00]`,与协议文档一致。`BleConst.kt` 里 `CMD_CLOSE_AI_DIALOG` 这个名字是没跟上的旧命名 | | `0x07` | **AI 状态**(0说话结束/1思考中/2说话开始/3关闭/4聆听中) | 协议文档明列;"遥控电机"另有其号 `0x0E`,所以 `BleConst.kt` 的 `CMD_CONTROL_MOTION` 也是错名 | | `0x11` | **双向共号不冲突**:`0xAA 0x11` 陀螺仪开关(APP→设备),`0xCC 0x11` AI 唤醒(设备→APP) | 协议文档里两条都在,靠帧头区分 | 样机实测应答(同一次连接内): | 命令 | 应答 | 说明 | |---|---|---| | `0x01` 版本 | `02 0c 0c` | = 2.12.12。**文档说 6 字节是错的**,实测 3 字节 | | `0x02` 设备ID | `01` | | | `0x03` | `03 00 00 00 3d 00 00 00 00 00 00 00 00 00` | **14 字节**,不像"颜色 ID"。含义仍未定,本项目没在用 | | `0x04` 电量 | `3c` = 60% | | | `0x15` 汇总 | `01 00 14 0a 3c 02 0c 0c 00 02 01 01 04 01` | 偏移被**两处独立交叉验证**钉死:`[4]=0x3c` 与 0x04 一致、`[5..7]` 与 0x01 一致 | 这一帧已写成金测试(`test/devices/smartcar_protocol_test.dart`)。 ⚠️ `[1]` 音量实测是 **0** —— 如果偏移没错,样机当前是静音的,A2DP 接上也可能听不见, 真机验声音前先确认这一项。 --- ## 6. AI 侧:新增 Smartcar agent(按新模块系统重写) 需求:**在广场新增一个 agent**(不复用 EMAI),同一百炼 workspace 下换应用 ID, 例如 `mm_dd1eb7cb06484aafb94a0b6b2dff`;连上设备后 AI 页默认只显示它。主功能是随时开聊。 ### 6.1 模块系统已经重构,工作量大幅下降 模块现在是 `AgentModuleDescriptor`(`lib/modules/module_descriptor.dart`),**自带一切**: ```dart AgentModuleDescriptor( id: 'smartcarChat', title: 'agentSmartcar', subtitle: 'agentSmartcarDesc', icon: ..., gradient: [...], requiredCapabilities: {DeviceCapability.aiWake}, // 能力声明,不写厂商判断 entryRoute: Routes.smartcarChat, // 或 onLaunch pages: [GetPage(name: Routes.smartcarChat, page: ..., binding: ...)], translations: {'zh_CN': {...}, 'en_US': {...}}, // ← 模块自带文案 registerServices: () { /* 需要常驻服务时在这里 put */ }, ) ``` 相比旧架构,**这三件事不用做了**: | 旧架构要做 | 新架构 | |---|---| | 改 `module_card.dart` 的中心 switch | ❌ 不用,`entryRoute`/`onLaunch` 自带 | | 改 41 个语言文件加文案 | ❌ 不用,`translations` 字段自带 | | 去 `AppPages` 注册路由 | ❌ 不用,`pages` 会被自动拼进总表 | 只需在 `lib/modules/smartcar/module.dart` 写一个描述符, 再到 `ModuleRegistry.all` 里加一行。`emai/module.dart` 是现成模板(只有 28 行)。 ⚠️ `test/module_registry_test.dart` 守着三条:**id 唯一、entryRoute 已注册、标题文案有落点**。 漏了会挂测试,不会静默。 ### 6.2 ⚠️ 百炼 app_id 仍是全局唯一值(这条没变) ```dart static const String _defaultAppId = '2813e297edb149b0956082641f3e7a53'; // EMAI 的应用 String get _appId => AppConfig.env('ALIBABA_BAILIAN_APP_ID') ?? _defaultAppId; ``` **不能改这个全局值或那个 env 键** —— 会把 EMAI 一起换掉。 **方案:给 `BailianMultimodalService` 加可选的实例级覆盖**(构造参数 `appId` / `workspaceId`, 默认 `null` → 完全走现有全局逻辑)。EMAI 与设备唤醒会话一行不改。 多实例这条路已被 `DeviceAiSessionService` 证明可行。 ### 6.3 ✅ 已定:方案 A(客户端内置) 服务端 `DBAgent` 预留了 `source`(0内置/1PolyChat/**2阿里云**/3火山)和 `serviceData`(JSON,各平台接入参数),但**客户端手写镜像里没有这两个字段**。 | | 方案 A:客户端内置(推荐) | 方案 B:后台下发 | |---|---|---| | 来源 | 常量或新 env 键 `ALIBABA_BAILIAN_APP_ID_SMARTCAR` | `DBAgent.serviceData` | | 运营能改 | 否 | 是 | | 风险 | 低 | 中:手写镜像要手动同步,**non-nullable 字段后端不下发就整个 `fromJson` 挂**,界面全空白 | **已定方案 A**:app_id 作为常量写在 Smartcar 模块里(或新 env 键 `ALIBABA_BAILIAN_APP_ID_SMARTCAR`)。 ⚠️ 代价是**换 app_id 要发版**。等运营真的提出「要后台改」再上 B。 ### 6.4 白嫖:声明 `aiWake` 就有设备唤醒会话 `DeviceAiSessionService` 已经**厂商无关**了:要求 `DeviceCapability.aiWake`, 通过 `openMic(ai)` 拿 16k PCM 推百炼,百炼回的 PCM 交 `openSpeaker(ai)`。 握手语义已抽象成 `wakeEvents(ptt)` → `ai.accept` → `ai.exit` → `wakeEvents(hangup)` (恒玄的 `BB 64` / `AA 6A` / `AA 65` / `BB E5` 只是它的一种实现)。 **Smartcar 只要在插件里把 C2 的 AI 触发(`0x11` 上报)对齐到这套语义, 整条设备唤醒对话链路不用重写。** ⚠️ 但这里有个**需要确认的设计问题**:`DeviceAiSessionService` 用的是全局 `BailianMultimodalService`(EMAI 的 app_id)。Smartcar 要用自己的 app_id, 就得让它支持按会话选实例 —— 这是 6.2 那个改造的一部分,**改动落在共享服务上, 必须保证恒玄唤醒行为零变化**(默认不传 = 原路径)。 ### 6.5 默认编排 `AgentModules.defaultsByCategory` 仍在: ```dart DeviceCategories.carAroma: [smartcarChat], // ← 加这一行 ``` 触发已自动(见 1.4,`AgentDefaults` 挂在 `hub.sessions` 上)。 ⚠️ **只在首次连接生效一次**(`AgentModuleService.applyDeviceDefaults` 记录)。 调试时改了看不到变化就是这个原因,要清应用数据或换账号。 ✅ **已定:就用默认编排,不加硬闸门。** 用户仍可去广场加别的功能,这是产品有意接受的。 ### 6.6 ✅ agent 展示信息(已定) | 项 | 值 | |---|---| | 模块 id | `smartcarChat` | | 名字 | **Smartcar** | | 副标题 | **您的车载助手** | | 欢迎语 | **欢迎回来** | | 百炼 app_id | `mm_dd1eb7cb06484aafb94a0b6b2dff`(workspace 同 EMAI) | ⚠️ **图标只能用 Material 图标,不能直接用产品图。** `AgentModule.icon` 的类型是 `IconData`,`module_card.dart` 里是 `Icon(module.icon, ...)` 套一个渐变色块,**不支持图片**。 想用 `Smartcar_icon.png` 就得给 `AgentModule` 加可选图片字段并改 `module_card` —— 那是**共享 UI**,会碰到耳机/支架的卡片,与硬边界冲突。 建议先用 `Icons.directions_car`(或 `Icons.air`,与 carAroma 品类图标一致), 配一组暖色渐变(如 `[Color(0xFFFFB74D), Color(0xFFE65100)]`,与品类卡同色系)。 真要用产品图,单独立项改卡片组件,别混在本次里。 ⚠️ **欢迎语放哪要注意**:走方案 A(客户端内置)时,「欢迎回来」是客户端常量; 但如果百炼那个应用自己也配了开场白,会**两句都出来**。接之前先确认 `mm_dd1eb...` 在百炼后台的开场白设置,二者留一个。 --- ## 7. 经典蓝牙与音乐播放 ### 7.0 ⚠️ 经典蓝牙不是可选项,是 AI 对话的前置条件 复核 lumi 代码后确认:**整个工程里没有任何 BLE 音频下行**(没有把 PCM 写回特征值的调用), `ble_manager.dart` 的注释写得很直白——经典蓝牙是「用于 A2DP/HFP 音频通路」。 所以这台设备的音频是**一上一下两条不同的路**: | 方向 | 通道 | 说明 | |---|---|---| | 上行(设备麦 → App) | **BLE Opus** | 解码成 16k PCM 喂百炼 | | 下行(AI 回复 → 设备喇叭) | **经典蓝牙 A2DP** | 手机当普通音频播放,系统自动路由到已连的 A2DP | **推论一**:不需要实现 `DeviceCapability.speakerDownlink` / `openSpeaker`, 手机照常播就行。这比恒玄那条(要把 PCM 按节奏回灌进耳机)简单得多。 **推论二(重要)**:**没有 A2DP 就没有 AI 回复的声音**。所以经典蓝牙**不能排在最后做**, 它和 AI 对话是同一条功能的两半。落地步骤已据此调整(见第 9 节)。 ### 7.1 lumi 的时序,照搬别自己发明 BLE 连上后**延迟 2500ms** 再发起经典蓝牙连接: ```dart await Future.delayed(const Duration(milliseconds: 2500)); if (BLE 已断开) return; // 延迟期间断了就放弃 await classic.connectDevice(bleMac); ``` ⚠️ 2500ms 不是随手写的:要等 BLE 栈完成服务发现/MTU/鉴权,否则 SDP 查询与 BLE 争抢射频, **系统弹出的配对对话框点确认也会失败**。"延迟期间可能已断开就放弃"同样要照搬。 ⚠️ 已 bonded 的不要重复 `createBond`,Android 会自己重连 A2DP/HFP;lumi 用 `_classicBondedBleMac` 短路。 ### 7.2 MAC 从哪来 广播厂商数据 CID 之后 6 字节原样(真机实测,见 4.x 的说明)。不需要额外查询命令。 ### 7.2b ✅ 音乐范围已定:只要 A2DP 出声 不做 App 内的上一曲/下一曲/音量控制,因此 **C2 的 `0x0B`(音量)、`0x05`(音乐状态)、 `0x08`(音乐节拍)、`0x0F`(音乐表情)这些命令本期都不用接**。 音乐由手机端播放器 + 系统 A2DP 路由完成,设备只当喇叭。 ⚠️ 顺带:lumi 的 `MusicDetector`(监听音乐让设备摇头)依赖 `0x1A` opus 通道与 `0x12` 摇头风格,本期不做,别顺手搬进来。 ### 7.3 要做的两件事 1. 给 `classic_bluetooth` **新增** `connectDevice(mac)`(Android + iOS),见 5.3 2. 编排写在 `lib/devices/smartcar/` 自己的插件里,**不要**把 lumi 的 `LumiClassicBluetoothManager` 整个搬进来(它耦合 lumi 的连接状态机) --- ## 8. OTA(✅ 本期要做,直接搬杰理那套) ### 8.1 新架构已经把 OTA 抽象好了 | 层 | 位置 | 职责 | |---|---|---| | 契约 | `device_plugin_interface/.../device_ota.dart` | `DeviceOtaPort`:`start(request)` / `cancel()` / `progressStream` / `close()` | | 厂商实现 | `lib/devices//_ota_port.dart` | 只管「怎么把固件推进这颗芯片」 | | 通用编排 | `OtaUpgradeController` / `device_ota_controller.dart` | 下载、看门狗、屏幕常亮、进度 UI | 请求类型有四种,`DeviceOtaFileRequest` / `BytesRequest` / `UrlRequest` / **`DeviceOtaVendorRequest`**(厂商私有参数走这个)。 **所以 Smartcar 的 OTA = 写一个 `SmartcarOtaPort implements DeviceOtaPort`, 再在能力集里声明 `DeviceCapability.ota`。** 下载、看门狗、UI 全都白拿。 ### 8.2 从 lumi 搬什么 | 内容 | 位置 | |---|---| | 杰理 OTA SDK(Android) | `lumi_device/android/libs/jl_bt_ota-1.10.0.aar` | | 杰理 OTA SDK(iOS) | `lumi_device/ios/Frameworks_backup_v1.12/JL_OTALib.framework` | | 升级流程编排 | `lumi_device` 里调用 OTA SDK 的那部分 | ⚠️ **只搬 OTA,别把 `JL_Watch` / `BmpConvert` / `jl_rcsp` 里用不着的一起拖进来**—— 那是手表表盘那套,白撑包体。 ### 8.3 ⚠️ 照 `bes_ota_port.dart` 抄那三条经验 恒玄那份(203 行)踩出来的坑,杰理大概率同样适用,**实现前先读一遍**: 1. **OTA 期间可能要独占链路**。恒玄是「多数固件同时只接受一条 SPP」,所以 `start` 先断 app 层连接、留 2s 让设备拆完旧链路再让插件连。杰理 BLE 是否也需要 让路,**要实测**。 2. **升级中的「已断开」是我们自己断的,不能当成 OTA 掉线**——按掉线处理会把正常升级判成失败。 3. **`close()`(退出升级页)要先停 OTA 再延时回连**(恒玄:成功 5s 等设备重启、其余 2s 等拆链路)。 另外通用控制器里那个**90 秒无进度即判失败的看门狗不能删**——原生侧存在「既不推进也不报错」 的情况,没有它 UI 会永久停在「升级中」。 ### 8.4 服务端:不用改 固件版本走 `user_getappconfig` 的 products(`version` / `updatepackageaddress` / `updatedescription` / `isforceupdate`),后台在产品管理 → 版本管理里传固件、设为当前版本即可。 Smartcar 已在后台登记,这条链路现成。 ⚠️ 版本比较用与服务端 `comm.CompareVersion` 同口径的实现(取每段前导数字), **别用 `int.parse` 逐段解析**——遇到 `1.0.0-beta` 会抛异常被外层吞成「已是最新」。 --- ## 9. 硬边界:不影响耳机 / 手机支架 好消息:**新架构本身就在强制这件事**,而且有测试守着。 | 风险点 | 保证方式 | 有无测试守 | |---|---|---| | 业务层碰厂商内部 | 只能 import `device_hub` + 契约 | ✅ `device_hub_boundary_test.dart` | | 模块注册漏项 | id 唯一 / 路由已注册 / 文案有落点 | ✅ `module_registry_test.dart` | | 百炼 app_id | 不动全局值,改用实例级覆盖(6.2) | ⚠️ 需人工回归 EMAI | | `DeviceAiSessionService` | 加可选参数,默认走原路径(6.4) | ⚠️ 需人工回归恒玄唤醒 | | `classic_bluetooth` | **唯一要动的共享插件**,只新增方法(5.3) | ⚠️ 需回归恒玄 OTA + azure_speech | | opus 解码 | 选方案 A(自带一份),不碰 recorder_holder(3.1) | — | | `ble_service` 死代码 | 完全不碰(5.1) | — | | 品类/默认编排 | 只加条目,不改 earphone / phoneHolder 现有行 | — | | 服务端 | 本期**零改动**(MAC 已导入) | — | **回归清单(每阶段结束都跑)**: 1. `flutter test`(两个守护测试必须绿) 2. 恒玄耳机:扫描 → 连接 → 电量 → OTA 读版本 → **按键唤醒 AI 对话** 3. EaiRec 支架:连接 → 录音(opus 链路) 4. 从广场点 EMAI → 正常对话 --- ## 10. 落地步骤 | 阶段 | 内容 | 状态(2026-09-10) | |---|---|---| | **0. 前置** | 样机到手;扫一轮命令表 | ✅ 已复核,`TODO(smartcar-cmd)` 全部消掉,见 5.11 | | **1. 原生插件** | `device_smartcar`:自持 GATT、**杰理 RCSP 认证(AE00)**、C2 通道(ABC0)裸字节收发 | ✅ iOS 真机通过(屏幕显示 BLE 已连接);✅ Android 已实现,**未上真机** | | **2. DevicePlugin** | `lib/devices/smartcar/` 实现契约,`device_bootstrap` 注册一行,声明能力集 | ✅ 完成(iOS 真机验过设备进 `DeviceHub.sessions`) | | **3. 绑定确权** | 广播 MAC → `user_binddevice`;声明 `macAddress` | ✅ 完成(真机 `A9:1A:B3:21:E4:DD` 确权通过,productid 45345) | | **4. agent** | `lib/modules/smartcar/module.dart` + `ModuleRegistry` 一行 + app_id 实例级覆盖 | ✅ 完成(用户已测) | | **5. 经典蓝牙** | `classic_bluetooth` 新增 `connectDevice` + 配对编排 | ✅ 代码完成(纯增量,见 5.10);⚠️ **A2DP 未真机验声**——样机当时没和 iPhone 配对经典蓝牙 | | **6. 音频上行** | opus → `openMic(ai)` 出 16k PCM;对齐 `aiWake` 握手语义 | ✅ 代码完成,能力集已含 `micUplink`/`aiWake`,真机上 opus 解码器起得来;⚠️ **未收到过真实音频帧**(还没在样机上触发过唤醒) | | **7. OTA** | `SmartcarOtaPort` + 杰理 OTA SDK,声明 `ota` 能力 | ⚠️ 两端代码已接(见 5.8 / 5.9),**都没跑过真机**;且后台还没给 Smartcar 挂固件包 | | **8. 收尾** | 扫描页、电量、把 `carAroma` 移出 `_scanNotReady` | ✅ 完成(`_scanNotReady` 已空;电量走 `0x15`/`0x04`) | **还剩的三块**(按依赖顺序): 1. **阶段 5 经典蓝牙**——风险最高,要动共享插件 `classic_bluetooth`(恒玄 OTA / azure_speech 也在用它, 必须是纯新增 + 单独回归)。没有它就听不到 AI 回复(下行走 A2DP,见 7.0)。 2. **阶段 6 音频上行**——依赖 5。opus 解码器按 3.1 的方案 A 自带;Android 侧注意 `ble_service` 已经带了 `com.jieli.audio:jl_audio_decode`,别再 vendor 第二份。 3. **命令表复核 + OTA 真机验证**——都要样机在手,见 11.2。 另有一处小尾巴:`smartcarWelcome`(欢迎语「欢迎回来」)**41 个语种的文案都写好了但没有接进会话**, 要先确认百炼那边的开场白由谁出(后台编排 vs 客户端首帧),别两边都说一遍。 --- ## 11. 决策记录与剩余待确认 ### 11.1 ✅ 已定稿(2026-09-10) | # | 问题 | 决定 | 落点 | |---|---|---|---| | 1 | opus 解码器归属 | **A:Smartcar 插件自带一份**,不碰 recorder_holder | 3.1 | | 2 | 百炼 app_id 放哪 | **A:客户端内置**(换 id 要发版;后台可配列为后续) | 6.3 | | 3 | 只显示这个 agent 的力度 | **默认编排**,不加硬闸门 | 6.5 | | 4 | agent 展示信息 | 名字 Smartcar / 副标题「您的车载助手」/ 欢迎语「欢迎回来」/ Material 图标 | 6.6 | | 5 | 音乐控制范围 | **只要 A2DP 出声**,不接音量与曲目命令 | 7.2b | | 6 | 下行音频通道 | **A2DP**(已查明 lumi 无 BLE 下行),不实现 `openSpeaker` | 7.0 | | 7 | OTA | **本期做**,直接搬杰理那套 | 第 8 节 | | 8 | 命令表冲突 | **先以代码为准**,真机后调整 | 11.2 | ### 11.2 ⏳ 剩余唯一待确认:命令表(等样机) 按决策 8:**实现时一律以 `BleConst.kt` / `BleProtocol.kt` 为准**, 协议文档那份当参考、不当依据。 **代码里必须做好标记**,真机测完再回来改。约定格式: ```dart // TODO(smartcar-cmd): 未经真机验证 —— 协议文档说「AI状态」,BleConst.kt 说 // CMD_CONTROL_MOTION,此处取后者。样机验证后更新本行与 // docs/Smartcar车载香薰-接入设计与落地文档.md 的 2.4 节。 static const int cmdControlMotion = 0x07; ``` **要打标记的至少这 5 条**(2.4 节列出的冲突项): `0x03` / `0x05` / `0x07` / `0x08` / `0x11`。 ⚠️ 验证时**只扫低位查询段**。lumi 的 `0x0C` 是恢复出厂、`0x0E` 是遥控电机、 `0x0D` 是启停开关——**这几个别乱发**。恒玄那次的教训是 `0x5x`/`0x6x` 会切工作模式, C2 同样要先看清命令含义再发。 ⚠️ 命令表**按固件版本标注**。恒玄上已经出现过「换固件命令号就变」,别当稳定契约。 --- ## 12. 附:不要重复踩的坑 - **别照文档写命令号**——恒玄电量那次文档三种解读全错,实测才扫出 `AA 09`;换固件还会变 - **别让客户端猜 pid**——2026-09-04 事故根因,服务端已改成按 MAC 全局反查 - **确权判不出来要放行**——断网/未登录一律放行,判严的代价是合法用户断网就用不了设备 - **Dart non-nullable 字段**——后端不下发就整个 `fromJson` 抛 TypeError、界面空白 - **AI 会话要独立实例**——共用单例会互相 `stop()`、下行音频串台 - **默认编排只生效一次**——调试时改了看不到变化是正常的 - **业务层别 import 厂商目录**——有测试守着,但设计时就该按能力想问题