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 |
不需要:下行走 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 iOSparseMacFromManufacturer的 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.0maven 上没有坐标,只能 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 要做的两件事
- 给
classic_bluetooth新增connectDevice(mac)(Android + iOS),见 5.3 - 编排写在
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 行)踩出来的坑,杰理大概率同样适用,实现前先读一遍:
- OTA 期间可能要独占链路。恒玄是「多数固件同时只接受一条 SPP」,所以
start先断 app 层连接、留 2s 让设备拆完旧链路再让插件连。杰理 BLE 是否也需要 让路,要实测。 - 升级中的「已断开」是我们自己断的,不能当成 OTA 掉线——按掉线处理会把正常升级判成失败。
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 已导入) | — |
回归清单(每阶段结束都跑):
flutter test(两个守护测试必须绿)- 恒玄耳机:扫描 → 连接 → 电量 → OTA 读版本 → 按键唤醒 AI 对话
- EaiRec 支架:连接 → 录音(opus 链路)
- 从广场点 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) |
还剩的三块(按依赖顺序):
- 阶段 5 经典蓝牙——风险最高,要动共享插件
classic_bluetooth(恒玄 OTA / azure_speech 也在用它, 必须是纯新增 + 单独回归)。没有它就听不到 AI 回复(下行走 A2DP,见 7.0)。 - 阶段 6 音频上行——依赖 5。opus 解码器按 3.1 的方案 A 自带;Android 侧注意
ble_service已经带了com.jieli.audio:jl_audio_decode,别再 vendor 第二份。 - 命令表复核 + 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 厂商目录——有测试守着,但设计时就该按能力想问题