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.
 
 
 
 
 
 

45 KiB

Smartcar(车载香薰)接入 —— 设计与落地文档

2026-09-09 起草 → 2026-09-10 按客户端架构重构大幅修订 → 2026-09-10 决策定稿。 除命令表需真机复核外,实现方案已全部确定,可以开工。 对应产品:后台已登记的 Smartcar,客户端品类 DeviceCategories.carAroma,厂商键 DeviceVendors.smartcar。 参考源工程:/Users/yunyanzhineng/work/liangchuang/lumi(Lumi「智能车载AI精灵」App,同一颗方案; 2026-09-22 起改用这个路径,原 ~/work/src/lumi 已不存在)。 ⚠️ 那边的插件已改名:本文写的 local_plugins/lumi_device 现在是 local_plugins/voibots_device (工程 pubspec name 也变成 voibots),BleProtocol.kt/.swift、BleConst.kt 仍在,按新目录找。 本文只做设计,未改任何代码。


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/<vendor>/ + 对应原生插件 把私有协议翻译成契约语义
中枢 lib/devices/device_hub.dart 业务层唯一设备入口,汇总会话/能力/电量

⚠️ 业务层(lib/modules、lib/core)只能 import device_hub.dart 和 device_plugin_interface,不能碰 lib/devices/<vendor>/ 里的任何东西 —— 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 已经预埋好的两处

// lib/devices/device_vendors.dart
static const String smartcar = 'smartcar';   // Smartcar 车载香薰(协议待接)

// lib/data/services/agent_module_defaults.dart
static const Map<String, String> _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 <crc>,设备回 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 <yy MM dd HH mm ss 01>   同步手机时间                设备回 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_127ddb82ba1244a99d2feaef0c1f)而不是 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_127ddb82ba1244a99d2feaef0c1f;连上设备后 AI 页默认只显示它。主功能是随时开聊。

6.1 模块系统已经重构,工作量大幅下降

模块现在是 AgentModuleDescriptor(lib/modules/module_descriptor.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 仍是全局唯一值(这条没变)

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 仍在:

DeviceCategories.carAroma: [smartcarChat],   // ← 加这一行

触发已自动(见 1.4,AgentDefaults 挂在 hub.sessions 上)。

⚠️ 只在首次连接生效一次(AgentModuleService.applyDeviceDefaults 记录)。 调试时改了看不到变化就是这个原因,要清应用数据或换账号。 ✅ 已定:就用默认编排,不加硬闸门。 用户仍可去广场加别的功能,这是产品有意接受的。

6.6 ✅ agent 展示信息(已定)

项 值
模块 id smartcarChat
名字 Smartcar
副标题 您的车载助手
欢迎语 欢迎回来
百炼 app_id mm_127ddb82ba1244a99d2feaef0c1f(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 再发起经典蓝牙连接:

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/<vendor>/<vendor>_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 为准, 协议文档那份当参考、不当依据。

代码里必须做好标记,真机测完再回来改。约定格式:

// 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 厂商目录——有测试守着,但设计时就该按能力想问题