Browse Source
修复三项:stats_global_day 主键重建(统计快照原本会全部写入失败)、 mcp.ymaikj.com 的证书与反代(SSE 需关缓冲)、手签证书的续期 crontab。 记下一次已回滚的试错:照着「测试有值/正式为空」搬短信与微信凭据是错的, 两边是不同服务商不同账号(测试走阿里云、正式配的腾讯云),混搭后 实测报 SmsSdkAppIdVerifyFail。已恢复原状。教训:凭据搬迁前要先核对 非机密的配套字段(Provider/AppId/模板/签名)是否同一账号体系。 另记七项待决策发现,含 NPM 管理端口公网开放、两机 MySQL 大版本差异 (8.0.27 vs 26.7.0)、nats healthcheck 命令写错导致恒 unhealthy。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>main
2 changed files with 864 additions and 0 deletions
@ -0,0 +1,781 @@ |
|||||
|
# 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/<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 已经预埋好的两处 |
||||
|
|
||||
|
```dart |
||||
|
// 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_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/<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` 为准**, |
||||
|
协议文档那份当参考、不当依据。 |
||||
|
|
||||
|
**代码里必须做好标记**,真机测完再回来改。约定格式: |
||||
|
|
||||
|
```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 厂商目录**——有测试守着,但设计时就该按能力想问题 |
||||
Loading…
Reference in new issue