From bb483ec0089baf54371c6045cc27062e9a3c87e9 Mon Sep 17 00:00:00 2001 From: Rodger-Wang <1367893453@qq.com> Date: Sat, 12 Sep 2026 09:47:30 +0800 Subject: [PATCH] =?UTF-8?q?=E8=90=BD=E5=9C=B0=E6=96=87=E6=A1=A3=E8=A1=A5?= =?UTF-8?q?=E4=B8=8A=E7=BA=BF=E5=90=8E=E4=BD=93=E6=A3=80=EF=BC=9A=E4=BF=AE?= =?UTF-8?q?=E5=A5=BD=E7=BB=9F=E8=AE=A1=E4=B8=BB=E9=94=AE=E4=B8=8E=20MCP=20?= =?UTF-8?q?=E5=8F=8D=E4=BB=A3=EF=BC=8C=E8=AE=B0=E4=B8=8B=E4=B8=80=E6=AC=A1?= =?UTF-8?q?=E5=87=AD=E6=8D=AE=E6=90=AC=E8=BF=81=E7=9A=84=E8=AF=95=E9=94=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复三项: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) --- ...¾™测试服上线正式服-落地文档.md | 83 ++ ...½¦载香薰-接入设计与落地文档.md | 781 ++++++++++++++++++ 2 files changed, 864 insertions(+) create mode 100644 docs/Smartcar车载香薰-接入设计与落地文档.md diff --git a/docs/2026-09-11-阿龙测试服上线正式服-落地文档.md b/docs/2026-09-11-阿龙测试服上线正式服-落地文档.md index 81c5c977..7feffeca 100644 --- a/docs/2026-09-11-阿龙测试服上线正式服-落地文档.md +++ b/docs/2026-09-11-阿龙测试服上线正式服-落地文档.md @@ -736,6 +736,89 @@ DNS 已指向 47.116.104.181,MCP 服务本身也正常,但 NPM 里没有这 --- +## 14. 上线后全面体检(2026-09-11 夜 ~ 09-12) + +### 14.1 已修复 + +| # | 问题 | 处理 | +|---|---|---| +| 1 | `stats_global_day` 主键缺 `channel_id`,统计快照会全部写入失败 | 表 0 行、列已在,直接重建主键为 `(app_id, product_id, channel_id, region, stat_day)` + 建 `idx_stats_channel_day`,重启 console 后告警消失 | +| 2 | `mcp.ymaikj.com` 无反代无证书 | 用 NPM 内置 certbot 以**与 NPM 完全相同的参数**签发(`--webroot /data/letsencrypt-acme-challenge`,cert-name `npm-mcp`),手写 `proxy_host/mcp.conf` → `ym-a11:7300`。`nginx -t` 通过,外网 SSE 已可用 | +| 3 | 手签证书不会被自动续期(NPM 只续数据库里登记的,容器内外都没有 certbot 定时任务) | 宿主机 crontab 加一条只针对 `npm-mcp` 的续期(每日 03:17,带 `--deploy-hook "nginx -s reload"`),`certbot renew --dry-run` 演练通过 | + +⚠️ **`mcp.conf` 是手写的,NPM 界面里看不到它。** 长期更干净的做法是在 NPM 界面新建这条 proxy host, +然后删掉 `mcp.conf` 与那条 crontab(文件头和 crontab 注释里都写了这一点)。 +界面重建时**务必勾上 Websockets Support**——MCP 走 SSE 长连接,不勾会被缓冲, +表现为「连上了但事件一直不下来」,比连不上更难查。 + +### 14.2 ⚠️ 一次试错与回滚:两个环境的第三方账号根本不是同一套 + +体检发现正式服 `init sys.sms err`(测试服没有),即**短信发不出去 = 手机验证码发不了 = 用户注册登录全断**。 +追下去发现短信凭据配在业务库 `app_module_config` 表里,测试服有值、正式服为空。 + +**我据此把 5 项凭据搬了过去,结果是错的,已全部回滚。** 教训值得记下来: + +| | 测试服 | 正式服 | +|---|---|---| +| `sms.Provider` | **aliyun** | **tencent** | +| `sms.AppId` | 1400989775 | 1400971698 | +| `sms.Template1` | `SMS_337511278`(阿里格式) | `2397800`(腾讯格式) | +| `wechat_login.AppID` | `wx2feeafe084e7e6b3` | `wx4b5ec8957ad5e425` | + +两边是**不同服务商、不同账号**。搬过去只填了空的 `SecretId/SecretKey`(腾讯云字段), +与正式服原有的腾讯 `AppId` 凑成不匹配的一对,实测报 +`UnauthorizedOperation.SmsSdkAppIdVerifyFail`;微信同理,AppID 与 AppSecret 错配。 + +**已把 `sms.SecretId/SecretKey/AccessKeyId/AccessKeySecret` 与 `wechat_login.AppSecret` +全部清空、恢复到改动前状态,并重启 app 让运行态与配置一致**(否则会留下 +「`init sys.sms success` 但一发就失败」的迷惑状态,比一开始就报未配置更难查)。 + +> **教训(下次上线务必记住)**:「测试服有值、正式服为空」**不等于**「照搬过去就对」。 +> 凭据类配置要先核对**非机密的配套字段**(Provider / AppId / 模板 ID / 签名), +> 确认是同一个账号体系再搬。本文档 §7 的搬迁清单只验证了「条数相同」就判定 +> `app_module_config` 不用动,这是疏漏——**条数相同不代表有值,有值也不代表是同一套账号**。 + +**短信要真正可用,二选一,都需要你提供凭据:** + +- **A. 正式服继续用腾讯云**(保留现有 AppId/签名/模板):在后台「业务功能配置 → sms」填**正式服自己的** + 腾讯云 `SecretId`/`SecretKey`,要求该账号下存在 SmsSdkAppId `1400971698`。 +- **B. 改用阿里云**(与测试服同一套):把 `Provider` 改 `aliyun`,并整组替换 + `AppId`/`SignName`/`Template1`/`Template2` 与 `AccessKeyId`/`AccessKeySecret`。 + ⚠️ 这等于正式用户的验证码短信从测试账号扣费、用测试主体报备的签名发出去,**需要你确认是否可接受**。 + +### 14.3 发现但未改动(需要你决策) + +| # | 发现 | 影响 | 建议 | +|---|---|---|---| +| 1 | **NPM 管理后台(81 端口)对公网开放**,实测返回 200 | 可被爆破;拿到 NPM 就能把 `ym.ymaikj.com` 指向任意后端、签发任意证书 | 阿里云安全组移除 81,改用 SSH 隧道访问;或限制来源 IP。**我没动**——一改你就进不去管理界面了 | +| 2 | **两台机 MySQL 大版本差异巨大:测试 8.0.27 / 正式 26.7.0** | 测试环境验证过的行为未必适用于生产(26.7 已移除 `MD5()` 等函数) | 已确认业务代码没用 SQL 侧 MD5/SHA1,`ngram` 全文索引在正式服也建成功了,当前无实际故障。但长期应统一版本,否则「测试通过」的说服力打折 | +| 3 | `email.Password` **两边都空** | 邮箱验证码发不出(`user_verification` 的 `vtype=0` 分支实测报 `email 系统未初始化`) | 若 App 支持邮箱注册就必须配;只用手机号则无所谓 | +| 4 | 支付密钥(alipay/wechatpay/paypal/appleiap/googleiap)**两边都空** | 支付功能不可用 | 正式要收款必须配真实商户凭据 | +| 5 | 业务库 `config` 表那 41 条第三方密钥(Azure/火山/讯飞/COS/百炼)是**从测试服搬来的** | 正式用户会用测试环境的第三方账号,用量与计费混在一起 | 正式服原本是空的(从无到有,没覆盖任何东西),先能跑起来;但要投产应换成正式账号 | +| 6 | `nats` 容器 healthcheck 永远失败(显示 unhealthy 已 3 周) | **NATS 服务本身完全正常**(4222 可达、`/varz` 正常、console 已订阅到 STATS 流)。问题是 healthcheck 命令写错了:`nats-server --signal status` 不是合法信号(合法的是 quit/reload/reopen/ldm) | 危害是**真故障时无法分辨**。修法:healthcheck 改用 `wget -qO- http://127.0.0.1:8222/healthz`。需重建 nats 容器(几秒中断,业务不受影响——NATS 连不上不阻断业务)。测试机同样问题 | +| 7 | `channel_app` 只有一条 `app_name='Voitrans-Test'` 残留 | `user_getchannelapps` 返回 `code:0` 但 `data` 为空,游客登录入口显隐结论拿不到 | 正式发客户端前要为 `EAIMAR` 配渠道分发与版本控制;那条残留读不到、不影响功能,确认后可删 | + +### 14.4 体检通过项 + +- 资源:磁盘 7%、内存 1.9/7.3G、负载 0.04,无压力 +- 三容器均 `0.2.0`,app healthy、五进程齐全 +- 应用身份三处一致(`app_registry.name` = `app_name` = `.env` = `EAIMAR`),`app_service_config` 18 行已归属 `EAIMAR` +- 统计快照无拒收、无缺列告警 +- 鉴权正确:`user_getinfo`/`user_getcompute`/`memory_list`/`echomeet_getrecords` 未登录均返回 `code:18` +- 白名单正确:`user_getchannelapps`/`user_getchannelapp` 未登录可访问 +- `user_getappconfig` 的 `env` 已非空(41 条配置正常下发) +- MCP:SSE 端点正常,BaseURL 已是 `https://mcp.ymaikj.com` +- 会议纪要 `ngram` 全文索引在正式服已建立(title/summary/overview/remark 四列) +- 会议编排引用的 4 个服务在正式 `svc_config` 中都存在 +- 三张证书有效(console/ym 到 11-14,mcp 到 12-10),续期演练通过 +- **外网暴露面只有 22/80/81/443**——数据库端口虽绑 `0.0.0.0`,但被阿里云安全组挡在外面(从干净网络实测确认) + +> ⚠️ 排查端口暴露时踩过一个坑:**本机 `nc` 测出「数据库端口全部公网可连」是假阳性**—— +> 本机装了代理软件(fake-ip,DNS 返回 `198.18.x.x`),`nc` 被代理接管所以恒成功。 +> 必须从一台干净的公网主机(这里用测试机)去测才准。 + +--- + ## 附:本文档的事实来源 除标注为「建议」的部分,其余均为 2026-09-11 在两台服务器上实测: diff --git a/docs/Smartcar车载香薰-接入设计与落地文档.md b/docs/Smartcar车载香薰-接入设计与落地文档.md new file mode 100644 index 00000000..b3b7f82a --- /dev/null +++ b/docs/Smartcar车载香薰-接入设计与落地文档.md @@ -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//` + 对应原生插件 | 把私有协议翻译成契约语义 | +| 中枢 | `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 厂商目录**——有测试守着,但设计时就该按能力想问题