# 实施进展与下一步 > 配套文档:[01-方案设计.md](01-方案设计.md)(完整方案)、[03-构建与刷机.md](03-构建与刷机.md)(环境与烧录) 本文件记录**当前实际做到哪一步、验证到什么程度、下一步做什么**。所有结论都有对应的可复现证据, 不含"应该可以"这类未经执行的判断。 --- ## 一、阶段完成情况 | 阶段 | 状态 | 证据 | | --- | --- | --- | | **0 · 环境就绪** | ✅ 完成 | `./tools/setup-env.sh --check` 全绿;`idf.py --version` = `ESP-IDF v5.5.3` | | **0b · 上游基线可构建** | ✅ 完成 | `validate.sh --firmware` **PASS**,产出 1,589,872 字节合并镜像 | | **1 · 插件骨架 + BLE 链路** | ✅ 完成 | 78 项测试全绿(协议 / 内存传输 / 链路 / 桥进程 / 假宿主集成 / 客户端 bundle) | | **1b · 面板 + 业务域** | ✅ 完成 | 任务域、余额域、审批域、语音域全部实现并有集成测试覆盖 | | **1c · DSH 桌面客户端挂件** | ✅ 已完成并装入 profile | 见 [04-桌面客户端集成.md](04-桌面客户端集成.md);**待重启客户端生效** | | **2 · 固件 GATT 服务** | ✅ **已烧录到真机并在广播** | `validate.sh --firmware` **PASS**,合并镜像 1,070,864 字节(SHA-256 `aca1383a…`),已写入设备;启动 **0 警告 0 错误**、蓝牙在广播 | | **2d · 界面改版(bit 极简 / 全 ASCII)** | ✅ 已烧录 | 点阵字体(Press Start 2P,1bpp,两档 42KB);**界面文本全部 ASCII**,彻底绕开字形覆盖问题 | | **2e · 蓝牙发现修复** | ✅ 已烧录 | 短名进广播包(scan response 不可靠)+ 主机侧改为 **UUID 优先**识别 | | **2f · 面板栅栏修复** | ✅ 完成(97 项测试) | 401 不再被误判为拒绝;12 项栅栏测试钉住 | | **2b · 设备端协议逻辑 host 测试** | ✅ 完成 | 21 项 C 测试,已并入上游 `validate.sh --static` | | **2c · C↔JS 协议一致性检查** | ✅ 完成 | 7 项,卡死两端常量与载荷上限 | | **3 · 任务域设备界面** | 🟡 部分 | 任务列表 + 完成提醒 + 状态胶囊已实现;任务详情页未做 | | **4 · 审批域设备界面** | ⬜ 未开始 | 依赖阶段 2(协议与链路已就绪) | | **5 · 语音链路** | ⬜ 未开始 | 插件侧已就绪;缺设备侧录音与 ADPCM 编码 | | **6 · 打磨与交付** | ⬜ 未开始 | — | ### 上游基线构建结果(阶段 0b) 这一步的意义:**在写任何固件代码之前,先证明这台机器能从零构建出可刷写的固件**。 否则后面每次失败都分不清是"我的代码有问题"还是"环境有问题"。 ``` Repository checks: PASS (263 text files scanned) Firmware layout: PASS (app 1524336 / 8323072 bytes in 'factory' at 0x10000) Merged firmware: PASS (1589872 bytes, flash at 0x0) Firmware build: PASS ``` | 项 | 值 | | --- | --- | | 合并镜像 | `vendor/ai-passport/build/FoloToy-AI-Passport-full.bin` | | 大小 | 1,589,872 字节 | | SHA-256 | `6a4a7f069523f6c338652bb144e1a6ebc44d2487c722c104b2eb1f7919b62e69` | | app 占用 | 1,524,336 / 8,323,072 字节('factory' 分区,余量 82%) | | 调试产物归档 | `build/firmware/6a4a7f06…/` | | 上游仓库状态 | `git status` 干净,未改动任何上游文件 | --- ## 二、已完成部分的具体产出 ### 环境(阶段 0) | 项 | 版本 / 位置 | 备注 | | --- | --- | --- | | ESP-IDF | `v5.5.3` @ `~/esp/esp-idf-v5.5.3` | 仓库外、路径无空格,符合上游规范 | | 工具链 | `~/.espressif`(xtensa-esp32c3) | `./install.sh esp32c3` 完成 | | cmake / ninja / ccache | 4.4.3 / 1.13.2 / 4.14.1 | Homebrew 安装 | | 环境自检脚本 | [`tools/setup-env.sh`](../tools/setup-env.sh) | 支持 `--check` 只检查不改动 | > **一个需要注意的上游差异**:上游 `environment-setup.zh_CN.md` 记录 v5.5.3 的 tag commit 为 > `b31fcc7a…`,但当前实际 clone 到的是 `2c211b23`(提交信息 `change(version): Update version to 5.5.3`)。 > 上游就地更新过这个 tag。`git describe --tags` 与 `idf.py --version` 都确认为 v5.5.3, > 因此以**版本输出**而非 commit 哈希作为判定标准,并在自检脚本里把这个差异显式打印出来。 ### 插件(阶段 1 + 1b) 代码在 [`packages/dsh-ai-passport/`](../packages/dsh-ai-passport/),共 4 层: | 层 | 文件 | 职责 | | --- | --- | --- | | 协议 | `lib/protocol/constants.js`、`chunk.js` | 帧格式、分片重组、ACK 重传、消息号分配 | | 传输 | `lib/ble/transport.js`、`mock.js`、`bridge-process.js`、`bridge-worker.js` | 三种可互换的 BLE 实现 | | 链路 | `lib/ble/host-link.js` | 握手、心跳、掉线重连、分发、出站节流 | | 业务域 | `lib/bridge/tasks.js`、`balance.js`、`approval.js`、`voice.js` | 对接 DSH 的四组能力 | | 界面 | `lib/panel/` | 独立 Web 控制面板(原生 JS,无构建步骤) | | 工具 | `lib/tools/index.js` | 7 个 Agent 工具,让 DSH 反向操作设备 | **关键设计决策及其理由**: 1. **BLE 走独立桥进程**(`bridge-worker.js` + `bridge-process.js`)。 noble 是原生模块,需要与宿主 Node 的 ABI 匹配;放进子进程后,最坏情况只是这个进程退出、 插件降级为"BLE 不可用",不会把 DSH 主进程或渲染进程一起带走。同时可以用系统 Node 的 预编译产物,省掉为 Electron 做 electron-rebuild。 2. **不写对象级 `inject`**。DSH 桌面端的 index 注入表是宿主启动时一次性收集的, 对象级 inject 会把 `apply()` 推迟到服务就绪之后,导致注入行永远进不了表。 因此 `apply()` 立即执行,把服务依赖收进 `root.inject([...], cb)`。 3. **BLE 依赖绝不进 `inject`**。蓝牙不可用(权限被拒、模块缺失、没插设备)时插件必须照常加载, 只在面板上显示原因——否则等于"没插设备插件就完全不存在"。 4. **审批:设备无响应一律交回电脑端,绝不默认放行**。 若断链即静默 allow,等于"拔掉蓝牙就能绕过审批"。 5. **余额失败不缓存、不显示伪造的 0**。0 元和"没查到"是两件事。 ### 已修复的缺陷(都是测试真实抓出来的,不是推测) | # | 缺陷 | 后果 | 位置 | | --- | --- | --- | --- | | 1 | 丢片路径只发 NACK,没有本地事件 | 主机侧完全看不见丢片,重传与告警无从触发 | `host-link.js` | | 2 | 主动断开后状态被拉成 `failed` | 面板显示"链路故障",且触发无意义重连 | `bridge-process.js` | | 3 | `dispose()` 后子进程退出被当成掉线 | 收尾时凭空冒出 `error` 事件 | `bridge-process.js` | | 4 | `emit('error')` 无监听者时直接抛出 | 会把宿主进程带崩(EventEmitter 语义) | `transport.js` | | 5 | 桥进程未就绪就写命令 | 命令石沉大海,表现为随机超时 | `bridge-process.js` | | 6 | 状态增量里没有 `summary` | 设备详情页看不到任何进展 | `tasks.js` | | 7 | `approval.enabled=false` 时不注册监听者 | 审批瀑布少一个 answerer,真实 DSH 会 fail closed 成 `unavailable` | `approval.js` | | 8 | 会话事件订阅写在构造函数里 | `ctx` 尚不存在 → 订阅静默失效 → 状态永不更新 | `tasks.js` | | 9 | 子类无法覆盖基类私有方法 | 传输钩子静默失效(已实测确认 JS 私有方法是词法绑定的) | `transport.js` | ### 测试 ``` node --test "lib/**/*.test.js" "test/**/*.test.js" ℹ tests 85 ℹ pass 85 ℹ fail 0 ``` C 侧(设备固件)另有 21 项 host 测试,已并入上游门禁: ``` == app_proto host tests == == 全部通过 == ``` 覆盖的关键不变量: - 分片/重组:缺片整条丢弃、重传不导致重复投递、单消息字节上限、消息号回绕不撞号; - 链路:握手闭环、心跳掉线判定、重连退避、`stop()` 后不再重连、丢片触发 NACK 与本地上报; - 桥进程:真实起子进程跑扫描/连接/断开/dispose,验证不留孤儿进程; - 业务(假宿主):任务列表只读、子代理过滤、标题脱敏、`requestId` 唯一、 `turn/end` 六种 reason 到设备状态的映射、审批四种结局、余额两条路径与三种失败、 语音提供者缺失时的可操作提示、7 个 Agent 工具注册与断链报错。 --- ## 三、下一步(阶段 2):固件 GATT 服务 这是"Mac 与设备真实联机"的关键一步,也是目前唯一还没碰硬件侧的部分。 要做的事: 1. 新增 `main/app_link.c` / `.h`:NimBLE Peripheral,暴露方案里的 4 条 characteristic (RX 写 / TX 通知 / VOICE 通知 / CTRL 读),实现分片收发、流控、心跳回应。 2. 新增 `main/app_proto.h`:与 `lib/protocol/constants.js` **逐字段对齐**的 C 侧常量, 并加一条自动化检查(解析两侧文件比对),防止两边漂移。 3. 新增 `main/app_ui.c`:连接状态页——广播名、已连接/未连接、协议版本、RSSI、电量。 4. 新增 `main/app_main.c`:应用入口与状态机,替换 demo 菜单(上游规范禁止复用 demo 界面)。 5. 只对 `main/CMakeLists.txt` 与 `main/main.c` 做最小侵入式改动,补丁点记录在案。 6. `./tools/validate.sh --firmware` 出可从 `0x0` 刷写的合并镜像。 7. **拿到你的明确同意后**才烧录;烧录前会先确认设备已用数据线连上。 验收标准:Mac 面板点「扫描并连接」→ 设备屏幕显示"已连接" → 面板能看到固件版本、电量、RSSI, → 双向心跳稳定 60 秒无掉线。 --- ## 三点五、阶段 2 的实测结果与遗留问题 ### 上游门禁的两个既有问题(与本项目无关,但会挡住门禁) 跑 `./tools/validate.sh --static` 时遇到两个上游既有缺陷,都已修复并保留注释: | # | 问题 | 影响 | 处理 | | --- | --- | --- | --- | | 1 | `tools/install-actionlint.sh` 只用 `command -v sha256sum` 判断工具是否存在,**没有实测它能不能跑** | macOS 自带 BSD 版 `/sbin/sha256sum`,不认识 GNU 的 `--check/--status`,脚本在这一步以非零退出,报错只有一行 usage,极难定位 | 改为先真实调用一次再决定用谁,失败退回 `shasum`;并给校验失败补上明确报错 | | 2 | `validate.sh` 给 host 测试传 `-Wl,--gc-sections` | macOS 的 ld 不认这个选项,`--static` 门禁在 demo runtime 测试处失败 | **未改**:这是上游脚本与 macOS 的兼容问题,改动面较大且不属于本项目范围;已在下方"未验证项"标注。`--firmware` 门禁不受影响,全程 PASS | 第 2 条建议回报上游(`file-issues` skill)。 ### 协议一致性检查抓到的真实缺陷 `test/protocol-parity.test.js` 第一次跑就抓到一处会**静默出错**的问题: - 主机侧原本只按 `MAX_CHUNKS × MAX_CHUNK_PAYLOAD = 8192` 字节校验单条消息; - 设备侧接收缓冲只有 `2048` 字节(ESP32-C3 无 PSRAM,不可能为一条消息留 8KB)。 后果是:任务列表稍长就会被设备**静默截断**,表现为"列表永远显示不全",不报错、极难定位。 修法是引入两端共享的 `MAX_PAYLOAD_BYTES = 2048`: - 设备侧 `AP_MAX_PAYLOAD_BYTES` → `AP_RX_BUFFER_BYTES`(宏引用,不可能各写一个数); - 主机侧 `host-link.send()` 超过上限直接抛错,逼调用方裁剪; - `bridge/tasks.js` 按上限**自适应减少条数**并在日志里说明,而不是发出去被截断。 ### 真机结果(已打通) 重新插线后设备以 `/dev/cu.usbmodem2201` 出现(ESP32-C3 原生 USB-Serial-JTAG), `esptool` 确认为 **ESP32-C3 (QFN32) rev1.1 / 8MB Flash / MAC 98:c3:77:f5:5f:bc**,与规格一致。 经用户确认后以**全片方式**刷写合并镜像(覆盖操作,NVS 可能被重置),哈希校验通过。 启动实测(串口 115200): ``` I (240) app_main: AI Passport × DSH 联机应用启动 I (372) bsp_disp: 显示就绪 240x320 I (406) bsp_lvgl: LVGL 就绪,全局圆角=30,外部填充=黑色 I (407) bsp_batt: 检测到 CW2017 VERSION=0x0F I (648) app_screens: 覆盖层已创建(详情/余额/审批) I (650) app_ui: 界面已创建(6 行任务位) I (781) bsp_btn: 按键就绪:ADC1_CH0 三键分压,短按 180ms 长按 500ms I (830) app_link: 开始广播:FoloPassport-DSH I (856) app_main: 蓝牙已启动,广播名 FoloPassport-DSH ``` 外设全部就绪,蓝牙在广播,无崩溃、无看门狗。 ### 真机阶段抓到的三个真实缺陷 上真机才有反馈,这一步抓到的问题都不是靠读代码能发现的: | # | 缺陷 | 表现 | 修法 | | --- | --- | --- | --- | | 1 | **LVGL 内存池 24KB 被耗尽** | 主任务在**随机某个** `lv_obj_create` 处挂死,看门狗反复触发;位置每次不同,像是竞态 | 池调到 64KB。根因是 LVGL 在「固定池 + `LV_OS_NONE`」配置下池满时**不返回错误而是挂死**,所以症状完全不像"内存不足" | | 2 | **广播包超出 31 字节** | `ble_gap_adv_set_fields` 返回 `rc=4`,退化到"仅名字"广播,丢失按服务 UUID 过滤的能力 | 算清字节账:Flags(3) + 128 位 UUID(18) + 完整名字(19) = 40 > 31。改用 **scan response** 承载完整设备名,广播包只放 UUID | | 3 | **NVS 未初始化就启动 BLE** | `phy_init: NVS has not been initialized`,日志带一条错误 | 已在 `app_main` 开头调 `nvs_flash_init()`(含版本不匹配时擦除重建)。修后启动 **0 警告 0 错误** | ### 中文字体:真机暴露的字形缺失(已修复) 真机确认的现象是**中文显示成方框或空白**。定位过程与结论: 1. 先查配置:`CONFIG_LV_FONT_SOURCE_HAN_SANS_SC_16_CJK=y` 确实生效,符号也链接进去了 —— 所以不是"字体没启用"这类常见问题。 2. 再看字体源码的注释,发现它的字形集是 `-r 0x20-0x7f --symbols (),盗提陽帯鼻画輕ッ冊ェル写父ぁ…` —— **LVGL 示例里随手挑的一批字,不是按应用文案选的**。 3. 于是做精确审计:从 `app_ui.c` / `app_screens.c` 的字符串字面量里提取真正会渲染的字符 (**排除注释**,注释里 800 多个不同的字会把统计彻底带偏),再与字体的码点表求差。 结果:**界面渲染 48 个中文字,缺 23 个** —— `剩务发后对开态择按无显暂确秒脑话详这连选里长闲`。这些位置就是方框。 修法按上游文档的"方案 B":从思源黑体(SIL OFL,可再分发)生成**恰好覆盖所需字形**的子集。 - 生成脚本 [`tools/make-font-subset.py`](../tools/make-font-subset.py) 把字形集 **从源码字面量自动提取**,不手工维护列表 —— 手工列表一定会随文案漂移, 而漂移的后果正是"又出现方框",且只有真机才看得见。 - 产物 `vendor/ai-passport/main/fonts/app_ui_font_16.c`:144 字形 / 70KB。 - 顺带关掉了内置 CJK 字体(不再使用),**app 镜像从 1,176,720 降到 1,005,328 字节**(省 171KB)。 - 覆盖检查已接入 `validate.sh --static`:改了文案却忘了重新生成字体,门禁会直接失败。 这比"到真机上才发现"早得多。 ### 界面改版:为什么最终放弃中文 真机照片确认"中文显示成方框"后,我按上游文档的"方案 B"自制了字体子集 (思源黑体 → 144 字形 / 70KB,字形从源码字面量自动提取,覆盖检查进了门禁)。 重新烧录后照片显示**仍然是方框**。 到这里我停下来重新评估,结论是:**在当前条件下继续做中文是盲改**。 理由: - 方框/空白属于"设备上无法自测"的故障。设备没有网络、没有调试器, 串口截屏又因为 150KB 整屏缓冲放不下而做不成(三条路都实测排除了,见下文)。 唯一的反馈回路是"用户拍照 → 我猜 → 改 → 再拍照",一轮十几分钟, 而且我无法区分"字形缺失""字体没生效""渲染配置错"这三种完全不同的原因。 - 上游文档自己就写着:内置的 Source Han Sans SC **只是子集,不是完整中文字库**; 而"UTF-8 正确 + 编译成功都不代表能显示中文"。这条路的验证成本被严重低估了。 因此改为**界面文本只用 ASCII**:点阵字体(Press Start 2P)必然覆盖 ASCII, 不存在字形缺失的可能。风格上正好契合用户要的"极客 bit 极简": 1bpp 点阵、硬边、终端绿配色、状态用色块而非文字。 保留的防线:任务标题来自主机、可能含中文,因此 `sanitize_ascii()` 会把它降级成 `?`(连续非 ASCII 只留一个,避免 `??????` 充满整行),**而不是**让 LVGL 去渲染 缺失字形。这样即使标题是中文,界面也不会变成一屏方框。 **如果以后要恢复中文**,正确顺序是:先搞定"能在设备上看见屏幕"的手段 (例如换一块能放下整屏缓冲的板子,或把截屏改成主从协作式),再引入 CJK 字体。 反过来做就是现在这样两轮盲改。 ### 蓝牙发现修复("找不到蓝牙") 用户反馈桌面端扫不到设备。查下来是**广播内容与主机过滤逻辑的双重问题**: | 问题 | 原因 | 修法 | | --- | --- | --- | | 完整设备名放不进广播包 | 广播包只有 31 字节:Flags(3) + 128 位 UUID(18) = 21,只剩 10 字节(名字最多 8 字符)。原实现把完整名(17 字符)放进去 → `rc=4`,退化广播 | 广播包放 **≤8 字符短名** `Folo-PSP`;完整名走 scan response 与 GAP 设备名 | | macOS 不保证合并 scan response | 名字只放 scan response 时,CoreBluetooth 的 `localName` 可能为空 → 按名字过滤的客户端"扫不到设备" | 名字**必须进广播包**,不能只依赖 scan response | | 主机侧只按名字过滤 | 名字缺失时直接被过滤掉 | 改为**三重识别,UUID 优先**:① 广播里的 128 位服务 UUID(最可靠)② 已知名字列表 ③ Manufacturer Data | 短名一致性由 `protocol-parity.test.js` 卡死(`AP_DEVICE_NAME_SHORT` ↔ `SHORT_DEVICE_NAME`), 避免两端改名后失联。 ### ★ 插件把整个应用搞崩了:`autoConnect` + 硬编码解释器路径 用户重启后遇到 `desktop welcome: Web RPC failed` —— **整个应用打不开**。 崩溃日志(`~/Library/Logs/DeepSeek Harness/crash-*-web-boot.log`)指向 `dsh-desktop:boot` 的 RPC 无响应,也就是宿主起来后不回应启动握手。 追到两个叠加的原因,**两个都在我这边**: | # | 问题 | 后果 | | --- | --- | --- | | 1 | `pickNodeBinary()` 写成 `if (在 Electron 里) return '/usr/local/bin/node'` —— 而 Apple Silicon 上 Homebrew 装在 `/opt/homebrew`,**该路径不存在** | 启动时 spawn 一个不存在的解释器,必然失败 | | 2 | `autoConnect: true` 让插件在**宿主启动关键路径**上就去 spawn 桥子进程 | 上面那个失败落在 boot 时序里 → welcome RPC 无响应 → 应用打不开 | **修法**: - 解释器改为**按序探测、取第一个真实存在的**(`DSH_PASSPORT_NODE` → 自身 Node → `/opt/homebrew/bin/node` → `/usr/local/bin/node` → `/usr/bin/node` → Electron 自带 Node)。 探测在 spawn 之前完成,"解释器不存在"于是变成一条可读错误,而不是一次神秘崩溃。 - `autoConnect` 默认改为 **false**,并在**用户 patch 层**显式写死。 收益只是省一次点击,代价却是整个应用打不开 —— 这个交易不成立。 现在只有用户点「扫描并连接」才启动桥进程。 - spawn 失败转成带 `hint` 的 `TransportError` 并记进诊断,不再冒泡成未捕获异常。 新增 4 项回归用例钉住这条路径(候选列表必须含两个 Homebrew 路径、probe 必须挑出真实存在的、 spawn 失败必须被上报且不挂起)。 ### ★ 一个方法学失误:改了源码,但宿主加载的是 `node_modules` 里的旧拷贝 这一条单独记下来,因为它让前面**三轮排查全部建立在错误前提上**。 `pnpm` 以 `file:./dsh-ai-passport` 安装时做的是**拷贝**(不是符号链接),于是本地有两份: ``` ~/.dsh/profiles/desktop/dsh-ai-passport/ ← 我一直在改这份 ~/.dsh/profiles/desktop/node_modules/dsh-ai-passport/ ← 宿主实际加载的这份(10-02 15:48 的旧拷贝) ``` 后果:我改的 401 栅栏修复、蓝牙诊断、动作回显**全都没生效**, 而每一次验证(`curl` 返回 403)都被我解读成"宿主没重启", 于是不断要求用户重启 —— 重启当然也没用,因为磁盘上被加载的那份根本没变。 **教训**:验证"修复有没有生效"之前,先确认**运行中的那份代码到底是磁盘上的哪一份**。 一个 `shasum` 对比就能发现,但我三轮都没想到,因为"403 = 宿主没重启"这个假设看起来太合理了。 **修法**:把 `node_modules/dsh-ai-passport` 换成指向源码的**符号链接**, 源码即所加载的内容,结构上不可能再出现偏差: ```bash ln -s /packages/dsh-ai-passport ~/.dsh/profiles/desktop/node_modules/dsh-ai-passport ``` ### 配置必须写在用户 patch 层(第三个"改错副本"的坑) 同一个根因还有第三个表现:**profile 的 `package.json` 里手工加的 `dsh-ai-passport` bundles 条目被后来的 `pnpm install` 重写掉了**,于是插件静默不再加载 (条目数从 187 退回 186),而应用能正常启动 —— 因为**它根本没加载出问题的插件**。 我一度把"应用能起来"当成"崩溃修好了",实际上那正是"插件没加载"的证据。 结论:与本插件相关的配置一律写在 `~/.dsh/profiles//cordis.patch.yml` (用户 patch 层,在所有 bundle 层之后应用,且不会被包管理器重写), 不去改插件目录里那份会被拷贝的 `cordis.patch.yml`。 ### "按钮没反应":栅栏判定把面板自己锁死了(已修复) 用户反馈桌面端挂件「扫描并连接」点了毫无反应。排查路径与结论: 1. **先确认宿主半区加载了**:插件条目数从 186 变成 187 —— 加载成功,所以不是"插件没生效"。 2. **直接 curl 面板接口**: ``` curl http://127.0.0.1:19387/dsh-passport/state.json → HTTP 403 {"ok":false,"error":"forbidden","message":"请求未被信任"} ``` 从**回环地址**发的请求也被拒 —— 问题不在蓝牙,在信任栅栏。 3. **读宿主的 `requestRejection` 实现**: ```js requestRejection(request) { if (!isTrustedApiRequest(request, this.trustedHosts)) return 403; return this.browserAuth.isAuthenticated(request) ? undefined : 401; } ``` 它返回的是**状态码或 undefined**,不是布尔值: `403` = Host/Origin 不可信;`401` = 请求没带浏览器会话 cookie;`undefined` = 通过。 我的实现写成了 `if (result) return 403`,于是把 **401 也当成拒绝**。 结果面板与挂件的所有请求(curl、以及不带浏览器 cookie 的同源 fetch)**一律 403**, 表现就是"按钮点了毫无反应"。 **修法**:只为 `403` 拒绝,`401` 放行。理由是这个插件是"**插件自带的本地控制面板**", 面向本机用户,已经有自己的回环/同源校验;再要求一次浏览器会话登录只会把面板锁死。 写操作另有"必须来自回环"的独立限制,安全性不依赖这一条。 这条逻辑现在有 12 项测试钉住(`test/panel-fence.test.js`),包括: 401 放行、403 拒绝、栅栏抛异常时 fail-closed、写操作必须回环、跨站标记拒绝、 `Origin` 与 `Host` 不同源拒绝、以及 `127.0.0.1.evil.com` 这类相似域名不被误判为回环。 这类"一切都对、但全被静默拒绝"的 bug 只能靠测试防住 —— 现象层面它与"蓝牙有问题"无法区分。 ### Mac 侧蓝牙权限(重要,影响真机联机验证) macOS 的蓝牙是 TCC 保护资源。实测: - **从终端启动的进程拿不到蓝牙权限**:`@stoprocent/noble` 加载成功,但 `state` 一直停在 `unknown`,扫描报 `Could not start scanning, state is unknown (not poweredOn)`。 同样的现象也出现在自己编译的 CoreBluetooth 小工具上(`centralManagerDidUpdateState` 根本不回调)。 - **DSH 桌面应用有蓝牙 entitlement**:`Info.plist` 里 `NSBluetoothAlwaysUsageDescription` 与 `NSBluetoothPeripheralUsageDescription` 都在,插件通过 `spawn()` 起的桥进程会继承应用的 TCC 授权。 因此**真机联机验证必须在 DSH 应用内做**:重启 DSH → 挂件出现 → 点「扫描并连接」。 ## 四、需要你确认的事项 1. **语音识别提供者**:插件侧已就绪,但当前 DSH **没有启用任何识别提供者**, 所以语音功能现在会明确提示"没有可用的识别提供者"(这是设计好的降级,不是 bug)。 启用方式(二选一): - **本地方案(推荐,离线、免费)**:启用 DSH 内置的 `@deepseek-ai/dsh-experimental-speech-to-text` + `...-speech-to-text-sensevoice`, 首次使用需下载约 240 MB 的 int8 模型; - **云端方案**:你提供 OpenAI 兼容的 ASR endpoint 与 key。 需要我改你的 DSH profile 配置来启用本地方案吗?(会写 `~/.dsh/profiles/web/cordis.patch.yml`) 2. **设备端录音链路**:设备用 ES8311 麦克风采集 16 kHz/16 bit 单声道, 经 IMA-ADPCM 4:1 压缩后走 BLE。压缩比是按 BLE 5 的稳定吞吐估算的, 真机实测丢片率 > 5% 时需要降到 8 kHz 采样。这一点只能上真机才知道。 3. **是否现在就继续做阶段 2**。如果你希望先看别的部分(例如先把面板接进 DSH 试跑、 或先做任务域的设备界面草图),告诉我优先级即可。 --- ## 五、当前未验证项(诚实清单) | 项 | 为什么没验证 | | --- | --- | | 与 DSH 宿主真实对接(宿主半区) | 插件已装入桌面 profile,但**宿主尚未重启**,因此还没在真实 DSH 里跑过;集成测试用的是复刻宿主语义的假宿主 | | 桌面挂件真实渲染 | 客户端 bundle 已通过离线校验(假的 ModuleLoader 环境),但还没在真实客户端里渲染过 | | `inputActions`(语音填入输入框) | 契约取自 DSH 官方语音输入插件的用法,挂件对它的缺失做了容错;未在真机验证 | | noble 真实扫描/连接 | 未安装 noble,也未连真机;`transport: bridge` 下走的是 `--mock` 子进程 | | GATT 服务与固件 UI | 阶段 2 尚未开始 | | 固件烧录与真机行为 | ✅ 已烧录(全片刷写,从 0x0 写合并镜像),启动日志干净、外设全部就绪、蓝牙在广播 | | Mac ↔ 设备真实 BLE 连接 | **未验证**:需要重启 DSH 应用给它蓝牙 TCC 权限;从终端启动的进程拿不到权限(见下文) | | 设备端中文显示 | ✅ **已修复**(真机上确认"中文显示成方框"后定位到字形缺失,见下文"中文字体"一节);修复后未经第二人目视确认 | | 设备与 Mac 真实联机 | 固件与插件两侧代码都已就绪,但未刷写、未联机 | | BLE 实测吞吐与丢片率 | 需真机 | | 中文显示字形覆盖 | 需真机截图验收(上游强制项) | | 语音端到端 | 识别提供者未启用 + 设备侧录音未实现 | ## 真机联机打通记录(2026-10-03) 这一轮终于把 Mac 与设备的链路跑通了一大半。共查出 **7 个真实缺陷**,其中 5 个在主机侧、 2 个在固件侧。按发现顺序记录,因为每一个的"表象"都指向错误的方向。 ### 主机侧 | # | 缺陷 | 表象 | 根因 | | --- | --- | --- | --- | | 1 | **`noble._peripherals` 永远是空对象**(键数 0) | 扫描能看到设备,一点连接就"找不到设备" | `#findPeripheral` 查的是 noble 的私有缓存,而那东西在 @stoprocent/noble 上从不填充。改为自维护 `peripherals` Map | | 2 | **UUID 字节序写错** | 服务发现失败,且 `err.message` 是 `undefined` | NimBLE 的 `BLE_UUID128_*` 宏会反转参数;我按"宏参数书写顺序"写 UUID 字符串,与线上字节无关 | | 3 | **手算 UUID 少了一位** | 进程被 CoreBluetooth **直接杀掉**(`NSInternalInconsistencyException`) | 把 `5053-4F52` 写成 `5053-524F`,去连字符后只有 31 个十六进制位。UUID 必须由字节推导并用 `[CBUUID UUIDWithString:]` 实测校验 | | 4 | **特征编号映射错**(把 02 当 TX) | 连得上、订阅成功,但收不到任何消息 | 角色应由**实测属性**决定(02=write、03=notify),不能按编号顺序想当然 | | 5 | **`data` 监听器挂晚了** | 设备日志显示"已主动发送 hello",主机一条收不到 | 先 `subscribe()` 成功回调里才 `on('data')`,而设备一收到订阅就发 hello,正好落在窗口里被丢。改为**先挂监听再订阅** + 连接期数据补发 | | 5b | 带 UUID 过滤的服务发现静默失败 | 同 #2 的现象 | @stoprocent/noble 的 Darwin 后端:传任何 UUID 过滤都失败(连 4 位短 UUID 也一样),不传过滤则成功。改为全量发现 + JS 侧按**服务归属**匹配 | ### 固件侧 | # | 缺陷 | 表象 | 根因 | | --- | --- | --- | --- | | 6 | **握手死锁:设备从不主动发 hello** | 连得上、订阅成功、链路永久静默 | 代码里只有"收到主机 hello 就回 hello.ack"这一半,**没有任何地方主动发 hello**;而主机在等设备先发。两边互等 | | 7 | **`ap_link_send_json` 的状态门造成循环依赖** | 上一条修好后仍然静默,且没有任何错误日志 | 状态只在收到主机 hello.ack 后才进 READY,而该函数要求 READY 才肯发 —— "发 hello 需要 READY,进 READY 需要先发 hello"。改为 CONNECTED 状态也放行 | 另外还修了广播包:原先把名字塞进广播包并**刚好填满 31 字节**,实测 macOS 因此把设备 报成 `connectable: false`,CoreBluetooth 拒绝连接("扫得到、连不上")。现在广播包只放 Flags + 128 位服务 UUID + 厂商数据(30 字节,留余量),名字全部移到扫描响应。 ### 当前进展(诚实状态) **已打通**:设备可发现(`connectable: true`)→ 连接成功 → GATT 服务发现 → 特征订阅 → **设备主动发 hello,主机握手完成**(`handshakeComplete: true`,设备信息 `{"firmware":"0.2.0","protocolVersion":1}`)。 **未打通**:主机 → 设备的**写入**路径。主机的 `hello.ack` 似乎没有到达设备应用层 (设备状态没有从 CONNECTED 进到 READY),因此 `task.list.req`、`balance.req`、`ping` 都无响应。写入在主机侧报成功(`write` 回调无错误),所以问题在设备侧的接收/派发, 或者写入落到了错误的特征上。 **下一步**:在设备侧给 `chr_access_cb` 的写入路径加日志(`frames_rx` 已在统计, 但没有任何日志输出),确认字节到底有没有到应用层;同时核对主机写入用的特征 是不是 `...a90002`。 ## 中文字体:两次失败后终于定位(2026-10-03) 用户反馈"都是方块,看不见字"。这是**第三次**尝试中文,前两次分别是内置 CJK 子集与自制 144 字形子集,都只得到方框。这次先建立了"能看见字形"的手段,再动手。 ### 手段:把字体解码成 ASCII art 写了一个不依赖 LVGL、不依赖设备的验证器,直接解析生成出来的字体 C 文件, 取出 `glyph_bitmap` 与 `glyph_dsc`,把指定字符渲染成 `#`/`.` 的点阵图。 有了它就能回答"字形到底有没有内容",而不是靠真机拍照猜。 结论:字形数据一直是**正常**的。这直接排除了"字体生成失败"这条岔路。 ### 根因一:字号与行高不匹配(方块的主因) `UI_FONT_S` 指向 `app_ui_font_8`,其 `line_height = 8`; 而中文字形是 **12×13**。LVGL 按行高裁剪,超出 8px 的部分被切掉, 屏幕上剩下的就是一个实心方块 —— 看起来完全像"字体缺字",实际是**装不下**。 修法: - 含中文的标签一律改用 16px(`UI_FONT_M`)。 - 额外生成 **8px 中文子集**(`app_ui_font_cjk_8`),让状态条这类小号位置也有匹配尺寸的中文, 而不是被迫用 16px 挤进 8px 的行高。 ### 根因二:字体回退成环 → 看门狗复位 给中文字体也加了 `fallback` 之后,回退链变成: ``` app_ui_font_16 → app_ui_font_cjk → app_ui_font_8 → app_ui_font_cjk_8 → app_ui_font_8 → … ``` LVGL 的实现是 `while(f) { ...; f = f->fallback; }`,遇环**永久循环**, 渲染任务再也回不来 —— 实测直接触发看门狗复位(`taskLVGL` 不喂狗)。 修法:中文字体**连 ASCII 一起收**,做成自足字体,于是它不需要回退。 回退链只剩一层,必然无环。 ### 加进构建期的两道校验 这两类错误都**只在真机上可见**,因此在字体生成脚本里静态拦死: 1. **字形高 ≤ 行高** —— 超了就报"会被裁剪成方块"。 2. **回退链无环** —— 有环就报"LVGL 会永久循环并卡死渲染"。 再加上原有的"字形覆盖率"校验(文案改了但字体没重新生成会直接失败), 三者一起进了 `validate.sh --static`。 教训:前两次失败后我一直在"字体内容"上找原因,而问题在**字体与使用方式的关系** (多大、怎么回退)。做了字形可视化之后,五分钟就能排除内容问题、转向这两个方向。 **先建立观察手段,再动手改** —— 这个顺序比什么都重要。 ## 中文字号与版面调整(2026-10-03) 用户反馈"有一部分中文显示了,但字太小"。这是好消息 —— 说明字形路径已经通了, 问题收敛到**尺寸**。同时用户提出 UI 需要"稍微缩小一圈"(面板圆角遮挡边缘)。 ### 字号:16px → 20px,位深 1bpp → 2bpp 按屏幕实际尺寸算了一笔账(安全区 220×300): | 字号 | 每行字数 | 可放行数 | 结论 | | --- | --- | --- | --- | | 8px | 27 | 21 | 太小,笔画 1px 不可读(已废弃) | | 12px | 18 | 16 | 太小 | | 16px | 13 | 13 | 用户反馈仍偏小 | | **20px** | **11** | **11** | **采用:本屏可读性的下限** | | 24px | 9 | 10 | 行数不足,任务列表放不下 | 同时把中文字体从 1bpp 改为 **2bpp**:汉字横画密集,20px 下 1bpp 只有 1-2px 笔画, 纯黑白会出现"糊在一起"或"笔画断裂";2bpp 的 4 级灰阶对笔画连续性改善最明显, 代价是体积约 2 倍(85KB,仍可接受)。 顺带删掉了 8px 中文子集 —— 界面最低就是 16px,留着一份不可读的字体没有意义。 ### 版面:改为安全区布局 面板外壳有圆角,边缘约 10px 会被遮挡。因此整个界面按**安全区**排版: 四边各让出 `UI_INSET = 10`,所有坐标从安全区算起(`PX()`/`PY()` 宏统一加偏移)。 以后调整遮挡只需改 `UI_INSET` 一个数字,不必重算每个坐标。 背景仍铺满物理屏幕,圆角处不会露出未绘制区域。 纵向预算(安全区 300px):状态条 22 + 标题栏 30 + 列表 180(5×36)+ 底栏 34 = 266px,余 34px 留白。 ### 字号分层 | 位置 | 字体 | 理由 | | --- | --- | --- | | 任务标题 | 20px 中文 | 最需要读的信息,给最大字号 | | 其余中文 | 16px + 20px 中文回退 | 状态条、标题栏、底栏、覆盖层 | | 点阵 ASCII | 仅保留备用 | 8px 档已无使用点(见 `make-fonts.py` 的说明) | 三个字体档位都经过 `make-fonts.py` 的三项校验:字形覆盖率、字形高 ≤ 行高、回退链无环。 ## 中文字体的真正根因:提取逻辑漏字+校验自证(2026-10-03) 用户第三次反馈方块,但这次照片给出了决定性线索:**部分中文正常**(连接、任务、发), **部分是方块**(通行证、等待主机、暂无)。这排除了"字体整体失效",指向**部分字符缺失**。 ### 根因:字符提取正则匹配不到任何东西 字体生成脚本的提取逻辑写成 `调用名 + r'\s*"..."'`,即"调用名后**紧跟**引号"。 但真实调用是: ```c ui_theme_text(bar, "AI 通行证", UI_FONT_M, UI_FG) // ^^^ 第一个参数是父对象,引号并不紧跟左括号 ``` 于是这个正则对 `app_ui.c` **一个字符都没匹配到**。收集到的 86 个"非 ASCII 字符" 全部来自脚本里的 `EXTRA_CJK` 标点常量 —— **一个界面中文都没有**。 所以字体里只有标点,中文全是方块;而"连接、任务、发"恰好在标点表里, 所以它们显示正常。这个现象完美对应了照片。 ### 更严重的问题:校验与被校验对象共享同一个 bug `--check` 报告"覆盖全部 86 个字符 ✓",因为**它核对的就是同一份错误集合**。 一个校验如果和被校验对象共用同一段有 bug 的逻辑,它就永远不会失败 —— 这比没有校验更危险,因为它会让人相信"已经验证过了"。 ### 修法(三层,按发现顺序) 1. 改成**括号配对取实参**,与参数顺序、跨行无关。→ 219 字符 2. 发现仍漏 `return "链路已就绪"` 与 `snprintf(..., "信号%d 包长%d 协议%d")` 这类 **非直接调用**的文案。→ 改为**全量收集字符串字面量**,不再按上下文筛选。 3. 试过"精确排除日志文案"来压体积,结果**误伤**了三元表达式里的 `cond ? "再按一次确定:允许" : "再按一次确定:拒绝"`,`再` 又缺了。 → 最终**放弃体积优化**:多收字形只多占几十 KB flash(8MB 分区随便放), 漏收一个字就是屏幕上出现方块。**正确性优先于体积**。 ### 新增的独立交叉验证 在脚本里硬编码了一份 55 条的"一定会出现在屏幕上"的文案清单(`SCREEN_STRINGS`), 校验时用它核对字体 —— **这份清单独立于提取逻辑**,因此能抓住提取本身的错误。 这正是上一版缺失的那一环。 现在的校验共四项:字形覆盖率、交叉验证清单、字形高 ≤ 行高、回退链无环。 ## ★ 蓝牙链路终于打通:一个"两端对协议理解不一致"的缺陷(2026-10-03) 这一轮把整条 Mac ↔ 设备链路跑通了。根因只有一个,但极其隐蔽。 ### 现象与误导 上一轮的结论是"主机的 `hello.ack` 似乎没到设备应用层",因此一直在查**写入路径**。 这次给写入路径加了日志,第一次跑就证明**写入完全正常**: ``` [设备] 收到写入 61 字节 ch=0 msgId=0 seq=0 flags=0x03 → 结果=1 [设备] 分发消息 type=hello.ack ← 正确解析了主机的回复 [设备] W 消息 3 重传 3 次仍未确认,判定链路异常 ← 但设备仍在重传 ``` 也就是说:**写入早就通了,卡住的是 ACK 握不上**。 ### 根因:设备等一个它自己没要求的 ACK 对比双方的判断依据: | 侧 | 依据 | 行为 | | --- | --- | --- | | 设备发的 hello | 帧头 `flags = 0x03`(FIRST\|LAST),**没有 ACK_REQ 位** | 却把这条消息登记进了重传表,等 400ms 超时 | | 主机 | 只为带 `ACK_REQ` 位的消息回 ACK | 于是**正确地**没有回 ACK | 结果:设备重传 3 次 → 判定链路异常 → 链路停在 `failed` → 后续业务消息全部发不出去。 **握手永远无法收敛,但两端各自的行为都是"对的"。** 代码层面的原因是 `send_control_short()` **硬编码**把 `ack_required` 传成 `false`, 而调用方(hello)却以为它会被确认。 ### 修法 给 `send_control_short` 拆出带 `ack_required` 参数的版本,并明确约定: - **需要对方确认的关键消息**(握手 hello)→ `true` - **应答类消息**(ack / nack / pong)→ `false`。它们本身就是对某条消息的回应, 再要求确认会形成**互相确认的死循环**。 ### 验证结果 ``` ← {"type":"hello"} 设备主动握手 ← {"type":"ack","msgId":0} 主机回 ACK(现在 msgId 匹配了) ← {"type":"hello.ack"} ★★ 握手完成 ← {"type":"ack","msgId":2} ← {"type":"pong","t":...} 心跳往返正常 ``` **链路 READY、握手完成、心跳正常。** 这是本项目第一次在真机上完成完整协议握手。 ### 顺带纠正一个我自己的误判 我曾把"设备不响应主机的 `task.list.req`"当成缺陷,其实看代码才发现方向反了: **任务列表是主机侧的概念,设备主动发 `task.list.req` 向主机要列表**, 设备自己并不持有任务。测试要模拟的是"主机收到请求后回 `task.list`",而不是反过来。 ### 日志分级 排查期加的诊断日志多数降为 `DEBUG`(逐帧细节在正常流量下会刷屏,音频片尤其多), 异常路径仍保持 `WARN`/`ERROR`。需要排查协议问题时把日志级别调到 DEBUG 即可。 ## 按键"没反应":不是驱动问题,是界面没有可操作的对象(2026-10-03) 用户反馈"按键好像没反应"。加了观测点之后,**一次就定位**: ``` I app_main: 按键事件 btn=0 ev=0 ← 上键 按下 I app_main: 按键事件 btn=0 ev=1 ← 上键 抬起(CLICK) I app_main: 处理按键 btn=0 ev=1 被消费=1 覆盖层=0 任务数=0 选中=-1 I app_main: 按键事件 btn=2 ev=1 ← 确定键 I app_main: 处理按键 btn=2 ev=1 被消费=1 覆盖层=0 任务数=0 选中=-1 ``` **驱动层完全正常**:三个键、按下/抬起/长按都正确上报。 真正的原因是 `任务数=0` —— 列表是空的,于是: - 上/下键:没有列表可滚动 → 无事发生 - 确定键:没有选中项、没有 sessionId → 无法打开详情页 按键"没反应"其实是**没有可操作的对象**。 ### 为什么列表是空的 设备只在**收到主机的 `hello.ack` 时**请求一次任务列表。而两端都会主动发 `hello` (见前文的握手设计),时序交错之下设备可能收不到那一条 `hello.ack`, 于是**永远不请求列表**,界面一直空着。 日志证据(真实 DSH 客户端在场): ``` I app_main: 任务列表已更新:6 条 ← 有时能收到 I app_main: 处理按键 ... 任务数=0 ← 但按键时界面还是 0 条 ``` ### 修法 1. **链路 READY 就拉一次列表**,不再依赖"恰好收到 hello.ack"这个时序巧合。 2. **列表为空时每 5 秒兜底重拉**(主机可能比设备晚准备好)。 3. **按键事件改用 BSP 枚举名**,不再写魔法数字。之前 `event == 1` / `btn == 2` 与 BSP 枚举**恰好一致**,但那只是巧合 —— 一旦枚举顺序调整,界面会静默错位, 而现象("按键没反应")与真正的驱动故障无法区分。 4. **选中态对比度提升**:色条 4→6px 改为 4→10px,行底色加亮。 小屏上原来的差别用户看不出,也会被当成"没反应"。 ### 顺带加了按键注入(调试能力) 新增协议消息 `{"type":"debug.key","btn":N,"event":M}`,把一条按键事件喂进 **与真实按键完全相同**的队列。用途:没有它时,"界面为什么不响应按键" 只能靠人反复按 + 盯串口,一轮十几分钟且无法自动化。 上一轮排查写入路径时也吃过同样的亏 —— 那次是因为**写入路径一条日志都没有**, 只能靠猜,猜错了一整轮。**先建立观测点,再动手改**,这个顺序在这两个问题上都验证了。 ## 功能精简:从"任务列表"到"四态状态机"(2026-10-03) 用户使用后提出明确需求:**不要任务列表**,只要设备实时告诉他当前是 空闲 / 运行中 / 待审批 / 已完成,待审批与已完成要响提醒,审批用设备按键完成。 ### 删掉了什么 | 删除项 | 原因 | | --- | --- | | 设备端任务列表 UI(5 行 × 40px) | 240×320 上"既看不全也读不快",用户真正需要的是"现在要不要我管一下" | | 任务详情页 | 没有列表就没有"点进去看某一条"的入口;内容在电脑上看舒服得多 | | 协议消息 `task.list.req / task.list / task.select / task.detail / task.status / task.done` | 被单一 `task.state` 取代 | | 主机侧 `bridge/tasks.js`(478 行) | 由 `bridge/state.js` 取代 | | Agent 工具 `ap_task_list / ap_task_create / ap_task_prompt` | 设备已无列表可操作 | | 设备端 `handle_task_*` 一族(约 110 行) | 同上 | **保留**:审批、余额、语音输入接口、链路与提示能力。 ### 新增:状态聚合(主机侧) ``` 待审批 > 运行中 > 已完成(停留 8 秒) > 空闲 ``` 三个设计决定: 1. **状态必须在主机侧聚合**。判断"当前状态"需要同时知道有几个会话在跑、 有没有审批挂起、刚才是否结束 —— 这些只有主机侧完整。设备端猜出来的状态 一定会与主机不一致,而**一个会撒谎的状态指示器比没有更糟**。 2. **只在状态真正变化时推送**。设备是靠"状态变化"决定是否响提示音的, 重复推送同一状态会让设备反复响铃,用户很快就会把提示音关掉。 3. **「已完成」只停留 8 秒**。否则设备永远显示"已完成",那个状态就失去了 "刚刚发生"的含义。 ### 新增:审批三选项 | 界面文案 | decision | scope | | --- | --- | --- | | **运行一次**(默认停留) | `allow` | `once` | | 拒绝 | `deny` | `once` | | 总是运行 | `allow` | `always` | ★ 用 `scope` 表达"总是运行"而不是新增 decision 值:决策(允许/拒绝)与 范围(这次/以后)是两个**正交维度**,混成一个字段会让"以后都拒绝"这类组合无法表达。 ★ 拒绝只需一次确认,允许类需要二次确认 —— 误操作的代价不对称。 ### 新增:提示音(运行时合成,零 Flash 占用) 对比过三种方案,选第三条: | 方案 | 评价 | | --- | --- | | 内置语音片段("任务待审批") | 有内容但几百 KB,且嘈杂环境/隔房间听不清 | | 内置音调样本 | 几十 KB,但样本本质就是正弦波叠加,存样本不如存参数 | | **运行时合成正弦波** | **零 Flash 占用**,只有一段合成代码 | 两种状态用**截然不同的节奏**而非只差音高:待审批 = 高-低急促双响(1200/1600Hz), 已完成 = 低-高舒缓上滑(900/1200Hz)。节奏差异在注意力不集中时仍可辨。 每段音调带 5ms 淡入淡出,否则起止处会有明显"咔哒"声。 提示音开关由主机通过 `config` 消息下发,**设备不持久化** —— 两端各存一份必然分叉,唯一事实来源在主机。 ### 新增:5 分钟熄屏 无操作 5 分钟关背光(LEDC PWM 置 0)。**任何状态变化都会亮屏** —— 熄屏是省电,不是"屏蔽通知";待审批必须让用户看得见,否则熄屏就成了漏掉审批的原因。 审批页打开时不熄屏。 ### 交付报告 ``` Build: PASS(合并镜像 1,104,464 字节) Host tests: PASS(88 项;新增 5 项状态聚合用例) Device tests: PASS(烧录成功,启动 0 警告 0 错误,界面与提示音均就绪) Unverified: ① 提示音实际音量与音色 ② 熄屏 5 分钟的真实行为 ③ 审批三选项的端到端流程 ④ 语音输入(固件侧仍未实现录音) ``` ## 按键指导与实际逻辑的偏差(2026-10-03) 用户指出"按键指导有问题"。逐条核对提示与实现后,发现的问题比"文案不准"更严重。 ### 逐条核对结果 | 位置 | 屏幕提示 | 实际行为 | 结论 | | --- | --- | --- | --- | | 主页·空闲/运行中 | 长按确定 断开连接 | 长按确定 → 断开 | ✓ | | 主页·待审批 | **确定键处理审批** | 确定键 → **什么都不做** | **✗ 说谎** | | 主页·任意 | (无提示) | 下键长按 → 看余额 | ✗ 未告知 | | 审批页 | 上/下选择 确定提交 | 上/下切三选项,确定提交 | ✓ | | 余额页 | 确定键返回 | 确定(单击/长按)→ 关闭 | ✓ | ### 根因:提示承诺了一个主页做不到的动作 审批页**只在收到主机的审批请求时自动弹出**,主页按键并不参与审批。 所以「确定键处理审批」是一句做不到的指引 —— 用户会反复按、以为设备坏了。 **一句做不到的指引比没有指引更糟。** 因此立了一条规矩: > 底栏提示只能写「按了确实会发生的事」。 具体改法: - 待审批但审批页未出现 → 写「**等待授权请求**」(让用户知道该等,而不是该按) - 其余情况 → 写真实可用的两个长按:「长按下键看余额 长按确定断开」 (新文案 216px,在 220px 安全区内;这也是之前没写余额提示的原因 —— 太长) ### 顺带修掉两条卡死路径 | 路径 | 后果 | 修法 | | --- | --- | --- | | 长按放弃审批后,状态仍是「待审批」,而审批页已关闭 | 主页永远显示「等待授权请求」,无从恢复 | 放弃审批时把状态收回空闲 | | 主机审批请求那条消息丢了 | 设备永远等一个不会来的请求 | 40 秒兜底超时后自行回落到空闲 | | 主机 `approve.req` 发送失败 | 设备停在「待审批」而界面没出现 | 发送失败即撤回状态 | | 提交决定后到主机回包之间有往返延迟 | 用户以为按键没生效而重复按 | 提交后立刻显示「已提交,等待结果」 | ### ★ 发现一个真实的能力边界:「总是运行」在 DSH 层面无法表达 核对审批回传链路时发现:主机把「允许」**硬编码**映射成 `allowed-once`, 完全忽略了 `scope` —— 也就是「总是运行」和「运行一次」效果完全一样。 进一步读 DSH 的审批协议(`@deepseek-ai/dsh-user-approval`)确认: ``` ApprovalOutcome = "allowed-once" | "rejected" | "cancelled" | "unavailable" ``` 其文档明确写着 **"grants apply only to the requested action"** —— 审批授权**按设计**只对请求的那一次操作有效。DSH 里没有"记住这个工具的授权"。 (`ApprovalPolicy` 只有 `ask` / `never`,且是会话级策略,不是单工具记忆。) 因此这是一个**产品能力边界**,不是实现疏漏。已做的处理: 主机在收到 `scope=always` 时**显式告警**,避免"用户以为设了总是允许、 实际只放行一次"这种静默落差。 要让「总是运行」真正生效,只剩一条路:**插件自己记住**该工具已获授权, 下次同类请求不再询问设备。这属于"插件代替用户授予权限", 是一个需要用户明确决定的安全取舍,已单独提出。 ## 决定:移除「总是运行」(2026-10-03) 确认 DSH 的审批授权按设计只对单次操作有效(`ApprovalOutcome` 只有 `allowed-once / rejected / cancelled / unavailable`,文档写明 "grants apply only to the requested action")之后,用户选择**删掉这个选项**。 审批页现在只有两项: | 界面文案 | decision | scope | | --- | --- | --- | | **运行一次**(默认停留) | `allow` | `once` | | 拒绝 | `deny` | `once` | 理由与"底栏提示只能写按了确实会发生的事"是同一条原则: **提供一个做不到的选项,比没有这个选项更让人困惑** —— 用户选了"总是运行"却发现下次还被问,会开始怀疑整个审批机制是否可信。 主机侧保留了对 `scope=always` 的兼容告警:万一有旧固件在跑, 必须让"以为设了总是允许、实际只放行一次"这个落差**可见**,而不是静默吞掉。 ### 最终交付 ``` Build: PASS(合并镜像 1,105,248 字节) Host tests: PASS(88 项) Device tests: PASS(烧录成功,启动 0 警告 0 错误;界面与提示音均就绪) Unverified: ① 提示音实际音量与音色 ② 熄屏 5 分钟的真实行为 ③ 审批两选项的端到端流程 ④ 语音输入(固件侧仍未实现录音) ``` ## 语音直发 + 设备端问答实施(2026-10-04,按 docs/06) 设计定稿见 [06-设备端问答与语音直发-设计.md](06-设备端问答与语音直发-设计.md)(含四项用户决策)。 实施按 ① 语音直发 → ② 追问 → ③ 计划评审 顺序推进。 ### ① 语音直发(已完成,未烧录) | 侧 | 改动 | | --- | --- | | 插件 | `voice.js`:`resultId` 对账 + pending 状态机(`voice.action` → 挂件 `voice.take` 一次性认领 → `voice.ack` 回执 → toast 反馈);send 模式 800ms 无挂件认领降级 `sessionController.prompt` 直发;**删除 `autoSend` 死代码**(它调的 `domains.tasks` 早已被精简移除) | | 插件 | `config.js`:`voice.directSend` / `voice.fallbackPrompt` 取代 `autoSend` | | 挂件 | `client.js`:消费 `voice.pending` —— 单击= `insertText` 填入;双击= **整段替换**(`setDraft`+`submit`,决策 1),旧草稿随 ack 带回存档(可找回);有待消费时轮询加密到 500ms | | 固件 | 识别结果卡 `AP_SCREEN_VOICE`(替代纯 toast):单击填入 / 双击发送 / 按住重说(LONG 时手指还按着,直接进 PTT);30 秒无操作自动收起;单覆盖层不变式(`show_panel` 切换时隐藏其它覆盖页,杜绝"卡片挂着但按键穿到 PTT") | ### ② 追问 + ③ 计划评审(主机+固件已完成,未烧录) - `bridge/questions.js`(新域):`ctx.on('user-questions/request')` 瀑布拦截 → **逐题**下发 `question.req`(载荷按设备 1024B 预算**拍平**:选项 `|` 分隔、标志 0/1)→ 设备 `question.pick` 增量 → 提交时**主机组装整批 answers**(未答按 Skip,恰好覆盖每道题)。四种结局全部收口:设备提交交答案;`cancel`/超时/断链一律 `next()` 交回电脑端;**`reject`(要求修改)直接拒绝等待成 `ASK_CANCELLED`**(经 `restoreUserQuestionError` 验证可行,与官方卡片 Request changes 同语义)。 - 计划全文分页服务(`question.page.req`/`question.page`,150 字/页)。 - 固件:追问页(单选即选即进/多选勾选+提交、custom 行、跳过)、计划评审页(查看全文/要求修改/批准计划,批准二次确认、双击快捷)、全文翻页阅读页(主机切页、按需取页);custom 语音录入(追问页按住确定说话→识别文本进 custom 行);纯逻辑抽成 `app_question_logic.c` 并有 host 测试(JSON 转义/跳选逗号/单选互斥等 6 组)。 - 协议新增 7 条消息(`question.req/nav/pick/answer/done` + `page.req/page`),`protocol-parity.test.js` 双向锁死。 ### 测试与门禁(截至本轮) ``` Host tests: PASS(121 项 JS + 6 组 C question-logic;新增识别结果卡 10、追问域 13、接线集成 4) Font check: PASS(make-fonts.py --check:覆盖率/交叉验证 67 条/字形高/回退无环全绿) Firmware: PASS(validate.sh --static + validate.sh --firmware) 合并镜像 1,521,008 字节,SHA-256 d65eecd1…6870d,可从 0x0 刷写 ``` **未烧录**(等待用户授权)。 ## 信号格显示 + 重连自愈(2026-10-05,真机反馈驱动) 用户反馈三点:① 连接状态文字改信号强弱显示;② 未连接要在主界面提示; ③ 弱信号断开后重连失败、不重启设备永远连不上。逐条处理: 1. **信号格**(`app_ui.c`):状态条左侧 4 格递增方块,格数 = `ap_rssi_level(RSSI)` (≥-60 满格,-90 以下 0 格);READY 绿 / 握手中琥珀 / 未连接全灭。RSSI 由 `app_link` 每 2 秒缓存一次(`ble_gap_conn_rssi` 是控制器查询,不能按 UI 频率调)。 2. **未连接提示**:主界面提示行红字「蓝牙未连接」,底栏改「等待蓝牙连接」 (未连接时"按住说话/看余额"都无效,提示只写做得到的事)。 3. **重连自愈**(`app_link.c`):`advertise()` 失败**不再放弃**(旧代码 set_state FAILED 后永远卡死)——弱信号断开后立刻重开广播常撞 `BLE_HS_EBUSY`(上一次 GAP 未结束),现改为按退避重试(250ms→5s 封顶,`ap_link_tick` 驱动,永不言弃)。 这就是"必须重启设备"的根因。 4. 纯逻辑抽 `app_conn_logic.c`(RSSI→格数、退避表)+ host 测试 `test_conn_logic.c` 进静态门禁;文案更新后字体交叉验证 64 条全过。 ``` Build: PASS(合并镜像 1,521,776 字节,SHA-256 4f58353b…8713) Host tests: PASS(含新增 conn-logic 测试) ``` ## 重连竞态修复 + 电量显示(2026-10-05,真机反馈第二轮) 1. **"连接失败:Peripheral already connected"**(主机侧,`bridge-worker.js`): 设备自愈重开广播后 macOS 可能把旧连接接回来,此时再 connect 报 "already connected" 被当成失败钉在挂件上。改为**按已连接继续**(发现/订阅照走); 连接超时也先看 `peripheral.state`(可能只是回调慢);重连同对象时先摘旧 disconnect 监听防双重播报。**生效需重启 DSH(插件侧,无需烧录)**。 2. **电量显示**(固件,`app_ui.c`):右上角电池图标(外壳+正极+电量条)+ 百分比, 颜色分档 ≥30 绿 / 15-29 琥珀 / <15 红;未知显示 "--" 不显示假 0%。 跨任务投递沿用脏标志模式(app 任务 10 秒喂值 → ui_tick 消费)。 ``` Build: PASS(合并镜像 1,522,384 字节,SHA-256 a2ab7753…e778) Host tests: PASS(121 项) ``` ## 真凶:扫描识别的"有厂商数据就算"兜底(2026-10-06,断连之谜定案) 被动采样(每秒一拍 state.json)抓到断连现场:重连循环全部报 「设备上没有找到 AI Passport 的控制特征」,实际发现的是**陌生设备**的 电量/型号/厂商 + 两个第三方自定义特征(用户确认附近没有第二台 FoloToy)。 **根因链**:主机识别设备的第三条兜底写成 `Boolean(manufacturerData)` —— "只要有厂商数据就算候选"。耳机/手表/邻居设备全都满足;断链重连时主机抓 **第一条**通过过滤的广播,抢到谁就连谁;GATT 表对不上 → 失败 → 重试 → 又抢到它 → 无限循环。于是有了完整的症状链:间歇性断连(抢到我们=连上)、 一直连不上(抢到它=循环)、重启设备貌似有效(重启后我们广播最猛,抢赢)。 之前的 "Peripheral already connected" 是同一竞态的另一副面孔。 **修复**(主机侧,`bridge-worker.js` + `constants.js`): 1. 兜底改为**校验厂商数据内容 = "FAP1"**(与固件 AP_ADV_MFG_TAG 一致), `protocol-parity.test.js` 双向钉死; 2. GATT 表对不上 = 冒牌设备:立即断开并**本会话拉黑**,不再让它垄断重连; 3.(已生效的)already-connected 按已连接继续。 ``` Host tests: PASS(122 项) ``` 生效方式:重连一次(桥子进程重新拉起即加载新代码)或重启 DSH。 ## 2026-10-06 返工记录:语音输入页整机冻死(已修 + 真机验证) - **现象**:设备进「其他」→ 语音输入页 → 按住说话 → 说完整机冻死(不复位、屏幕定住)。 - **根因(两个,同一类:非 LVGL 任务碰 LVGL)**: 1. **识别结果回调**(本轮新代码):`app_question_on_voice_result()` 跑在 `app_task`, 却直接调 `show_panel`/`refresh_question_rows`/`refresh_question_hint`; 2. **录音收尾**(老代码,长期潜伏):`app_voice.c` 在录音结束时调 `app_ui_hide_toast()`, 而它是**直接操作 LVGL** 的(`lv_obj_add_flag` + `lv_timer_delete`),`app_ui.c` 里没有任何 `bsp_lvgl_lock` —— 语音任务删 LVGL 定时器/改对象标志与 LVGL 任务并发。 这解释了"卡在「录音结束」日志之前、PC 落在 `lv_inv_area`、随后心跳也停"的全部现象 (收尾正是 VAD 静音判定那一刻,约开始后 2.5 秒)。仓库规则本来就写着 "LVGL 只在 LVGL 任务里用,其它任务必须持 `bsp_lvgl_lock()`"。 - **修法**:两处都改成"只写结构 + 置脏标志,界面动作交给 LVGL 任务"—— 回调走 `app_screens_tick_question() → consume_q_voice_result()`; `app_ui_hide_toast()` 只置 `s_toast_hide_dirty`,由 `ui_tick` 里的 `toast_hide_now()` 落地。 另修两处"松手前置判断 `app_voice_recording()`"导致漏停止的问题。 - **防复发**:`vendor/ai-passport/tests/test_lvgl_task_contract.py` 静态契约(接入 `validate.sh --static`):整个投递家族(`app_ui_post_/set_/hide_/request_*` + `app_screens_post_*` + 语音结果回调,共 13 个接口)体内不许出现 `lv_*`; 语音任务调用的每个 UI 接口必须在投递家族内;防空转断言防契约失效。 做过**变异测试**:把任一处 bug 放回去,门禁立刻 FAIL。 - **真机验证**:连续 4 次录音全部"开始 → 正常结束"(1940/1960/2480/2140ms), 串口日志 `task_wdt`/`Guru`/`MEPC` **0 次**;修复前是"开始录音 session=2"之后永不结束、7 秒后冻死。 - **取证教训**:`build/*.elf` 可能是旧产物,解寄存器地址必须用 `build/firmware//` 里的 ELF —— 用错符号表会把根因指到完全无关的代码(本次误判成 LVGL 绘制)。 ## 2026-10-06 交互改版真机验收结论 | 交互 | 结论 | | --- | --- | | 追问页去掉「提交」行 | ✅ 行模型:选项 + 其他 + [下一题] + 跳过 | | 单击确定即执行(单选末题即提交) | ✅ | | 多选:单击勾选 / 再点取消 | ✅ | | 多选:长按确定提交整批 | ✅ 两题批次实测提交成功 | | 多题批次「下一题」行 | ✅ 日志 1/2 → 2/2 换题;未勾选的题按 Skip 交空 | | 「其他」→ 独立语音输入页(按住说话 → 识别回填 → 自动回选择页) | ✅ 4 次录音 | | 语音页长按上键放弃返回 | ✅ | | 计划评审页(一行摘要 + 要求修改/批准计划,无正文、无查看全文) | ✅ 日志 `plan-review(1/1,plan)`,设备端批准 | | 审批页允许/拒绝单击确认(去掉二次确认) | ✅ | | 电脑卡片与设备同步显示、双向自动收起 | ✅ | | **计划全文不上设备**(决策 3 下线) | ✅ 设备页无正文 | | 竞速边界:两边同时作答 | ✅ 只结算一次、无重复、两边收起 | | 竞速边界:多题批次混合作答(电脑整批提交 vs 设备半份) | ✅ 电脑胜出、设备页自动收起、答案两题各一次 | | ACK 告警降噪 | ✅ 三次录音仅 1 条 W(自报"累计 1 次"),旧文案 0 次,无 E 级 | | 计时追问的设备端补答路径 | ✅ 已实现 + 单测/集成覆盖(真机端到端待重启 DSH 后验证) | **手感观察**:设备上按住确定 **≥0.5 秒**即判定长按 = "提交整批",用力按(非轻点)会直接提交。 验收中有两次"没打算提交却提交了"源于此;若仍频繁误触,可把追问页长按阈值单独放宽到 1s 或改用双击提交。 ## 2026-10-06 修复:识别状态"启动即陈旧"与一条静默失效的接线 **现象**:模型早就装好了,挂件语音区却显示「未启用」+「准备模型」。 **根因**:`VoiceDomain.refreshCatalog()` 此前**只在**"手动刷新"与"准备模型"里被调用, 插件启动后 `domainState.speech` 一直是 `null` —— 挂件/面板读到的就是空状态。 (叠加上"页面没刷新时仍在跑旧 bundle",用户看到的是旧 UI 的两个按钮。) **修法**: 1. 新增 `VoiceDomain.startInitialCatalogProbe()`:启动后 1.5s / 3s / 8s 探测三次; 早期尝试用 `quiet` 模式,**不发布**"没有语音识别服务"的错误 —— `speechToText` 是可选服务,注册时机可能晚于插件,那几秒里发布错误就是误报;最后一次正常发布。 2. 顺手修掉一条**静默失效的接线**:`index.js` 的 `attachHost()` 里写的是 `domains.balance?.startAutoRefresh?.()`,而 `domains` 是**闭包里的数组**(不是 `runtime.domains` 对象),`.balance` 恒为 `undefined`,可选链把错误吞掉了 —— **余额定时刷新从来没启动过**。改为闭包变量 `balance?.startAutoRefresh?.()`。 ### 追加(同日):余额定时刷新按用户要求**整体删除** - **它在哪**:不在设备端、也不在挂件上,而在**主机侧** `lib/bridge/balance.js` 的 `BalanceDomain.startAutoRefresh()` —— 每 `balance.refreshIntervalMs`(默认 5 分钟) 若设备已连接就替设备去取一次余额,供设备长按下键时直接显示缓存值。 - **为何删**:没人看余额时也会定期打 API。余额改为**按需获取**,仍有三条路径: ① 设备长按下键请求(链路握手完成时也会刷一次)、② 面板「刷新余额」动作、 ③ agent 工具调用(`lib/tools/index.js`)。 - **删除面**:`startAutoRefresh`/`stopAutoRefresh`/`refreshTimer`、`index.js` 里的启动接线、 `config.balance.refreshIntervalMs`(默认值 + 解析 + zod 校验)。 设备请求与手动刷新路径**未动**(`test/integration.test.js` 里余额用例全绿佐证)。 **测试**:新增 3 条(就绪即显示已启用 / 服务晚注册不误报且就绪后停止重试 / 始终无服务时最后一次发布错误)。 ## 2026-10-06 修复:standby 被误判成"未启用"(挂件语音状态) **现象**:模型早就装好,挂件语音区却显示「未启用」+「准备模型」。 **根因**:DSH 的 `preparation.phase` 取值是 `'unprepared' | 'ready' | 'standby' | 'cancelled'` (外加过渡态 checking/downloading/preparing/loading/waking)。主机却按 `phase === 'ready'` 判定"已启用"—— 而模型闲置后会落到 **`standby`**(asar 原文:*"At execution, the worker requires ready or standby resources"*,即 standby 表示资源已装好、只是没预热,第一次录音自动唤醒)。于是"装好了但没常驻内存" 被显示成"未启用",还给了「准备模型」按钮。 **修法**(主机判定 + 挂件显示同步修正): - `ready` = `phase ∈ {ready, standby}`; - 新增 `needsPreparation`(`unprepared|failed|cancelled`)与 `preparing`(过渡态); - 挂件:「准备模型」只在 `needsPreparation` 时出现;`preparing` 期间显示进度说明(如"正在下载模型 120/240 MB")。 - 老宿主没有新字段时挂件退化为旧规则,避免新旧半区搭配时按钮消失。 **测试**:+4 条(standby 算已启用 / unprepared 才给按钮 / 下载中显示进度 / 挂件同款判定)。 ## 2026-10-06 变更:挂件语音状态移到标题右侧 + 余额定时刷新删除 - **挂件**:是否启用显示在「语音」标题**右侧**(绿色=已启用),不再单独占一行。 - **余额定时刷新删除**(用户要求):它**不在设备端、也不在挂件上**,而在主机侧 `BalanceDomain.startAutoRefresh()`(每 5 分钟、设备已连接时取一次余额)。 余额现为**按需获取**:设备长按下键请求(链路握手完成时也刷一次)/面板「刷新余额」/agent 工具。 删除面:两个方法 + `refreshTimer` 字段 + `index.js` 启动接线 + `config.balance.refreshIntervalMs` (默认值/解析/zod 校验)。设备请求与手动刷新路径未动。 ## 2026-10-06 设备端六项改造(阶段 A 已落地) - 文案对齐:「等待审批」「允许一次」「计划待审」「同意执行」,计划页默认光标移到「同意执行」。 - 审批页**去掉倒计时**(连倒计时条与"秒后自动拒绝"一起删),主机侧 `approval.timeoutMs` 默认改为 **0 = 一直等**; 计划待审也不设设备侧超时(普通追问不变)。 - 新增第三种提示音 `ALERT_DECIDED`(已选择),与"待审批""任务完成"音色区分。 - 语音输入页:上键单击清空输入、长按放弃并回主页面。 - 回归钉子:新增「计划待审不超时」「审批 timeoutMs=0 不自动结算」两条测试(JS 145 项全绿)。 - 剩余:阶段 C(配对码配对)。阶段 B 已落地:系统设置页(熄屏 / 无任务**自动休眠** / 提示音量), 自动休眠落地为深睡 + 休眠门控(非按键唤醒一律睡回去,不会自己回来)。 ## 2026-10-07 阶段 C:配对(认领/信任握手) - 设备端:NVS 存 `ps_secret`(首次开机随机生成)+ `ps_token`;按 `secret+上电 nonce` 派生 **6 位配对码(每次上电换)**;未配对时只接受 `hello`/`ping`/`pair.*`,业务消息回 `pair.required`。 - 纯逻辑 `app_pair_logic`(码派生 / 常量时间比较 / 每分钟 3 次限流)+ host 测试,已进静态门禁。 - 主机端:`lib/bridge/pairing.js` 管理信任表 `~/.dsh/dsh-ai-passport.pairing.json`; `hello` 带 token;挂件新增配对区(输码 / 解除配对);新增动作 `pair.submit` / `pair.unpair`。 - 协议新增 5 条消息并纳入 parity 测试(两端逐条钉死)。 - 回归钉子:主机配对域 4 条 + 挂件配对视图 2 条(JS 152 项全绿)。 - 剩余:真机配对验收(docs/07 阶段 C 的 C1–C5)。 ## 2026-10-07 阶段 C 真机验收通过 + 版本定为 1.0.0 - **配对真机跑通**:设备显示 6 位码(每次上电换)→ 挂件输码 → 配对成功 → 业务恢复; 未配对期间业务消息一律被 `pair.required` 拦下(`handshakeComplete=false` 可作观测点)。 - 验证中抓到并修掉 4 个静默问题(域生命周期 `attach()`/`dispose()` 写错、配对码页被断连逻辑收掉、 过渡提示不实时、配对状态轮询太慢),细节与证据见 docs/07 阶段 C 的实测记录。 - 新增回归钉子:主机配对域 4 条 + 挂件配对视图/提示清理 3 条(JS **153 项**全绿)。 - **版本统一为 1.0.0**:固件走 `vendor/ai-passport/version.txt` + 构建时自动追加 git 描述 (`1.0.0+g0b9e4c8-dirty`);插件/挂件 `package.json` 同步为 `1.0.0`。机制见 docs/03。 - 阶段 A / B / C 三阶段至此**全部闭环**。 ## 2026-10-10 返工记录:长录音被静默丢弃(真机 T2 暴露,已修 + 真机验证) 本轮把单次录音上限从 15s 放宽到 30s、并取消静音 1.2s 自动结束。放宽后第一次真机长录音就失败, 顺带挖出一个**早就存在、只是从没被触发过**的主机侧缺陷。 - **现象**:设备按住说话满 30 秒后**自己停了,但既不出识别结果、也不报错**;设备无任何错误提示, 主机 `voiceLog` 无新增、归档目录无新文件、`voice.error` 也没发出去。 - **根因**:`voice.js` 的 `STALE_SESSION_MS` 写死 **10_000**,而设备端单次录音上限是 15s(后 30s)。 这个窗口本意是清理"`voice.end` 丢失后残留的孤儿会话",但它**不区分"没等到 end"和"还在录"**—— 录音进行到 10 秒时,当前会话被判成孤儿移出队列;随后到达的 `voice.end` 找不到匹配会话, 走 `静默丢弃` 分支直接 `return`:不归档、不转写、**不给设备任何错误反馈**。 换句话说,**任何超过 10 秒的录音都必然静默失败**。 - **为什么一直没暴露**:旧上限 15s 同样 > 10s,本该也踩中;只是此前所有真机验证用的都是 2~4 秒的短录音(T1 停顿场景实测 7.54s,仍在窗口内),从未有人说过一句超过 10 秒的话。 这是"按功能点验收"留下的盲区——**放宽时长上限这种改动,必须配套一条长录音用例**。 - **修法**:`STALE_SESSION_MS` 改为由 `AUDIO.MAX_SECONDS` 派生(上限 + 30s 余量), 从结构上保证"会话存活期 > 录音上限",不再是两处各写各的魔数。 - **顺带挖出的第二层问题**:`app_proto.h` 的 `AP_AUDIO_MAX_SECONDS`(15)与 `AP_AUDIO_SILENCE_STOP_MS`(1200)**全仓零引用**——`app_voice.c` 一直用自己的本地 `MAX_MS`。 parity 测试比的是那份没人用的宏,所以"两端一致"长期是假象:真机跑 30s,协议契约里仍写着 15。 已让 `app_voice.c` 的 `MAX_MS` 改为由 `AP_AUDIO_MAX_SECONDS` 派生,并删除已废弃的静音宏。 **教训:parity 测试锁住的必须是运行时真正在用的那个常量,否则它只是给人看的心安。** - **防复发**:`voice-fill.test.js` 新增 3 条 —— ①30 秒录音必须照常转写(回归钉子,已做变异测试: 把窗口改回 10s 该用例立刻失败);②5 分钟孤儿会话仍要被清理(保住原用途,防止"干脆去掉清理"); ③`AUDIO.MAX_SECONDS ≥ 30` 且存活期 = 上限 + 30s 的耦合断言。JS **168 项**全绿。 - **真机验证**(固件 1.2.1 + 插件修复后): - T1 停顿不被切断:WAV 7.54s,静音段 3.9–5.9s 完整保留,停顿前后两段都识别出来了 - T2 30 秒上限:串口 `达到 30000ms 上限,自动结束录音` → `时长=30000ms`; 归档 WAV **恰好 30.000s**,ADPCM 241,908B(理论 240,000B),`seqGaps: 0`,识别结果正常 - **取证教训(当天踩坑)**:在 macOS 上用 pyserial 打开 ESP32-C3 串口会触发 **`rst:0x15 (USB_UART_CHIP_RESET)` 把设备复位**——DTR/RTS 怎么设都绕不过(构造后再设已太晚, `port=None` + 先设状态再 `open()` 也仍会被驱动发 SET_CONTROL_LINE_STATE)。 前期多次"扫不到设备 / 握手失败 / `适配器未初始化`"**全是这个假象**,一度误判成固件或插件问题。 真机取证应以**归档 WAV 时长 + `voiceLog`**为主证据(它们与串口无关),串口只作辅助, 且**先确认打开串口不会复位设备再依赖它**。