# 设备端验收用例 按阶段维护的真机验收用例。每条用例都写清**怎么触发**、**看什么**、**怎么判定通过**, 以及**失败时先看哪里**(本项目的排障原则:先建立观测点,再改代码)。 观测点速查: | 观测点 | 位置 | 用途 | | --- | --- | --- | | 设备串口日志 | `/dev/cu.usbmodem2201`(115200) | 按键/录音/覆盖页、`E (`/`W (` 计数 | | 插件状态快照 | `curl -s http://127.0.0.1:<端口>/dsh-passport/state.json` | `link`、`approvals`、`questions`、`speech`、`turnTrace`、`stateTrace` | | 插件日志 | `~/.dsh/dsh-ai-passport.log` | 面板动作与识别链路 | | 录音归档 | `<插件包>/audio/` | 每次录音一组 `.wav` + `.adpcm`(默认开启) | --- ## 阶段 A:文案 / 去倒计时 / 提示音 / 语音页按键 > 固件:`7c9257a9…c2b4`(阶段 A)。触发条件里"审批"用任何需要授权的命令即可; > "计划待审"需要在**计划模式**下发一句话;"语音输入页"用任意含「其他」行的追问。 ### A1-1 审批页文案 - **触发**:让我执行一条需要授权的命令(我会用 `danger-full-access` 跑一条无害命令,审批会同时出现在电脑卡片与设备上)。 - **步骤**:看设备屏幕。 - **预期**:标题**「等待审批」**;两个选项自上而下为**「允许一次」「拒绝」**;摘要行是工具名(高风险显红、普通显黄)。 - **判定**:标题与选项文字完全一致即通过(旧的「需要你的授权」「运行一次」不应再出现)。 - **失败先看**:串口日志是否有 `审批请求:<摘要>()`;`state.json` 的 `approvals[].summary`。 ### A1-2 计划待审页文案与默认光标 - **触发**:切到**计划模式**,让我给一个计划(我会调用 `exit_plan_mode`,DSH 抛出真实 plan-review)。 - **步骤**:看设备屏幕,先不要按键。 - **预期**:标题**「计划待审」**;两行**「要求修改 / 同意执行」**;**高亮停在「同意执行」**(默认光标已按用户要求移到这里)。 - **判定**:三点全对即通过。 - **失败先看**:串口日志 `追问显示:plan-review(1/1,plan)`(出现 `choice` 说明主机没带 plan intent);`state.json` 的 `questions`。 - **注意**:默认停在「同意执行」意味着**误按确定即批准**(用户明确要求),测试时别乱按。 ### A2-1 审批页不倒计时、不自动返回 - **触发**:同 A1-1(触发一次审批)。 - **步骤**:出现审批页后**什么都不按**,等 ≥2 分钟(旧实现的默认超时是 120 秒)。 - **预期**:页面一直停着,没有倒计时条、没有「N 秒后自动拒绝」;不会自己回到主页;主机的审批也**不会**被结算。 - **判定**:等待期间 `state.json` 的 `approvals` 里那条**始终在**;串口无「审批超时」日志。 - **失败先看**:`state.json` 的 `approvals` 是否被清空;插件日志里是否有 `[approval] 设备审批超时`。 - **回退方式**:配置 `approval.timeoutMs` 设为正数(≥5000)即恢复"超时交回电脑端"。 ### A2-2 计划待审不倒计时 - **触发**:同 A1-2。 - **步骤**:计划页出现后**什么都不按**,等 ≥2 分钟(旧的追问超时是 120 秒)。 - **预期**:页面一直停着;主机不会发 `question.done`。 - **判定**:串口无「追问显示」重发、无收页提示;`state.json` 的 `questions` 仍非 null。 - **失败先看**:`questionsTrace` 是否出现"设备分支结束(timeout)"。 ### A2-3 收页仍然有效(反向用例) - **触发**:任意一次审批或追问。 - **步骤**:设备上不动,**改在电脑卡片上作答**。 - **预期**:设备页**立即收起**(回到主页)。 - **判定**:这正是"无倒计时"下唯一还需要主动收页的路径,必须仍然工作。 - **失败先看**:串口是否收到 `approve.result` / `question.done`(`reason=answered-elsewhere`)。 ### A3-1 三种提示音互不相同 - **触发**:① 触发一次审批(听到**待审批**音)② 在设备上按确定提交决定(听到**已选择**音) ③ 跑一个任务让它自然完成(听到**任务完成**音)。 - **预期**: - 待审批 = 急促双响(1200→1600→1200Hz) - 已选择 = 上行双音(1200→1500Hz,短促) - 任务完成 = 舒缓单响 - **判定**:三者**听感明显不同**即通过;重点是 ② 与 ③ 不能是同一个音。 - **失败先看**:`app_alert.c` 的 `TONES_*` 是否被正确选择(日志无输出,属听感项);提示音总开关是否被关(设置里或 `alert_get_setting()`)。 - **补充**:取消/长按放弃**不应**播"已选择"音。 ### A4-1 识别结果卡:上键 = 删除并返回 - **触发**:主页按住确定说一句、松手 → 弹出「识别结果」卡。 - **步骤**:按上键(**单击与长按等价**,用户要求合并)。 - **预期**:卡片关闭回到主页面;这条结果被**真作废**(主机删文本)——此后误触填入只会提示「识别结果已过期」。 - **判定**:回到主页;串口日志 `识别结果卡:删除并返回(上键,已作废)`;插件日志 `[voice] 设备清除识别结果 `。 - **对照**:不按上键、让卡片 30 秒自动收起时**保留**文本(仍可在电脑端填入)。 ### A4-2 识别结果卡:三行按键提示 - **步骤**:弹出卡片后看正文下方。 - **预期**:正好三行、居中,文字为 `单击上键 删除/返回` / `单击确认 填入` / `长按确认 发送`。 - **判定**:三行齐全、无方块、无截断(正文区已为这三行让出高度)。 - **字号**:提示行用 **16px 中文子集**(`app_ui_font_cjk16`),正文仍 20px;底栏提示同样是 16px, 因此一行约能放 13 个汉字(原来 20px 只有 10 个,长提示会被截断)。 - **补充**:**双击确认仍然发送**(保留早先的"双击发送"决策,属提示之外的快捷方式); 原「长按确认 重说」已取消 —— 要重录就按上键删除返回,再在主页按住说话。 ### A4-3 识别结果卡:长文本滚动 - **说明**:上键单击被"清除"占用,滚动改到下键:**单击下键 = 向下翻**,**长按下键 = 向上翻**。 - **步骤**:用一段较长的识别文本,单击/长按下键各试一次。 - **预期**:文本可上下滚动,看全后再决定填入或发送。 ### A4-4 追问页语音输入页(保留同样按键) ### A4-1b 追问页语音输入页:单击上键清除输入 - **触发**:任意含「其他」行的追问(我可以发一道)。 - **步骤**:光标移到「其他」→ 按确定进入语音输入页 → 按住确定说一句话、松手 → 识别文本回填后 → **单击上键**。 - **预期**:提示「已清除输入内容」;「其他」行的文本被清空;**仍停留在语音输入页**(不返回)。 - **判定**:设备行文案清空 + 主机侧该题的 `custom` 被清空(可在电脑卡片上看到"其他"为空)。 - **失败先看**:串口日志有无 `question.pick` 发送失败;`state.json` 的 `questions`。 ### A4-2 语音输入页:长按上键放弃并回主页面 - **触发**:同 A4-1。 - **步骤**:进入语音输入页后**长按上键**(≥0.5 秒)。 - **预期**:提示「已放弃语音输入」;语音页与追问页**一起收起**,回到**主页面**。 - **判定**:屏幕回到主页即通过。 - **说明**:该追问在主机侧仍挂起(因为不再超时),可以继续在电脑卡片上回答 —— 这是预期行为,不是缺陷。 - **失败先看**:是否只退回了选择界面(说明跑的是旧固件)。 ### A4-3 语音页提示栏文案 - **步骤**:进入语音输入页,看底栏。 - **预期**:空闲时显示「上键清除 长按放弃」;按住确定时显示「松手结束」。 - **判定**:文字与预期一致(无方块、无截断)。 ### A5-1 录音时长上限:说满 30 秒自动结束并正常识别 > 固件 1.2.1 起上限为 30s(旧版 15s)。**不要跳过这条** —— 主机侧曾有一个 > `STALE_SESSION_MS=10s` 的缺陷,任何超过 10 秒的录音都会**静默失败** > (设备自己停了但没结果、无报错、无归档)。详见 docs/02 的 2026-10-10 返工记录。 - **步骤**:主页按住确定,**不间断说满 30 秒左右**(可念数字/长句),等设备自己停下来再松手。 - **预期**:约 30 秒时设备**自动结束**(不需要松手),弹出识别结果卡,识别文本完整。 - **判定**: - 串口(有则看):`达到 30000ms 上限,自动结束录音` → `录音结束 session=N 时长=30000ms` - 归档 WAV 时长 **≈ 30.000s**(`python3 -c "import wave;w=wave.open('x.wav');print(w.getnframes()/w.getframerate())"`) - `state.json` 的 `voiceLog` **新增一条**,`seqGaps: 0` - 任何一项缺失(尤其"没新增 voiceLog / 没归档")即为失败 —— 那是静默丢弃,不是"没听清" ### A5-2 说话中途停顿不被切断 - **步骤**:主页按住确定说「帮我看一下今天那个」,**停顿 2~3 秒**(不要松手、别出声), 接着说完「登录模块的鉴权层有没有问题」,再松手。 - **预期**:停顿不打断录音,识别结果包含停顿**前后两段**。 - **判定**:归档 WAV 里能看到一段连续静音,且其**后面仍有声音** (按 100ms 一格算 RMS,标出静音区间,确认末尾不是空的);识别文本含后半句。 - **对照**:旧固件有"静音 1.2s 自动结束",会在停顿中途切断,后半句整个丢失。 ### A5-3 短录音回归(确认长录音改动没弄坏常规路径) - **步骤**:正常说 2~4 秒,松手。 - **预期**:照常出结果卡与文本;`<500ms` 的误碰仍静默丢弃、不报错。 - **判定**:`voiceLog` 新增一条,`seqGaps: 0`;设备无「识别失败」。 --- ## 阶段 B:系统设置页(熄屏 / 自动休眠 / 提示音量) ### B1 入口与交互 - **触发**:主页**长按上键**(≥0.5 秒)。 - **预期**:出现「系统设置」页,三行:`熄屏:…` / `自动休眠:…` / `提示音量:…`;底栏「上/下选择 单击切换 长按退出」。 - **判定**:上/下能移动高亮;单击确定切换当前行档位;**长按确定或长按上键**都退回主页。 - **反向**:覆盖页(审批/追问/余额/结果卡)打开时长按上键应是"返回上一级",**不应**打开设置页。 ### B2 熄屏(默认常亮) - **步骤**:把「熄屏」切到 **1 分钟** → 等 1 分钟不碰设备;再把「熄屏」切回 **常亮** → 等 2 分钟。 - **预期**:1 分钟档会熄灭背光(任意键立刻点亮);常亮档**始终不熄**。 - **判定**:串口日志 `屏幕熄灭`;常亮档等满 2 分钟无该日志。 - **失败先看**:日志里设置加载行 `设置已加载:熄屏=…`(确认档位是否存对)。 ### B3 提示音量(静音 / 低 / 中 / 高) - **步骤**:光标停在「提示音量」,单击确定逐档切换。 - **预期**:每切一档**立刻试听一声**(上行双音),音量依次变大;切到**静音**时**没有声音**。 - **判定**:四档听感递减/递增可分;静音档后续审批/任务提示音都不响。 - **反向**:切回「中」,让一次任务跑完 → 应恢复"任务完成"提示音。 ### B4 自动休眠 + 按键唤醒(本阶段唯一硬件待验点) - **步骤**:把「自动休眠」设为 30 分钟,然后不碰设备、也不给设备派任务,等 30 分钟。 - **预期**:到点进入深度休眠(屏幕全灭、无广播);**按任意键应能唤醒**(设备重新启动,走正常开机流程)。 - **判定**:串口日志 `无任务空闲达到设定时长,进入深度休眠(按任意键唤醒)`;按上/下/确定任一键后设备重启。 - **风险与降级**:三个键共用一个 ADC 分压节点(GPIO0),其中第三键按下时约 1.9V,可能落在数字门限附近而**唤不醒**。 实测: - 若三个键都能唤醒 → 通过; - 若只有部分键能唤醒 → 记录哪几个键有效,并把"唤醒方式"写进说明书; - 若都唤不醒 → **降级为"息屏待机"**(背光关 + 停止广播 + 任意键唤醒),并在文档标注。 - **加速验证办法**:我可以临时构建一版把最短档改成 **1 分钟**(仅用于验证深睡与唤醒链路),验证完再烧回正式档位。 **实测记录(2026-10-07,验证版 `f9de5cc4…`,含 1 分钟档)** 串口证据(`/tmp/sleep-test2.log`): ``` 02:02:12 app_settings: 熄屏 已保存为 1 02:02:19 app_settings: 提示音量 已保存为 100 → 0 → 40 → 70(四档来回切,均持久化) 02:04:20 app_settings: 自动休眠 已保存为 1 / app_screens: 自动休眠 → 1 分钟(验) 02:05:23 app_ui: 屏幕熄灭(无操作到设置时长) 02:05:23 app_main: 无任务空闲达到设定时长,进入深度休眠(按任意键唤醒) 02:05:23 bsp_batt: CW2017 已进入休眠并通过寄存器校验 02:05:23 bsp_audio: ES8311 已进入低功耗状态并通过寄存器校验 (随后串口设备节点消失:OSError [Errno 6] Device not configured —— 深睡真正生效) (再后来 /dev/cu.usbmodem2201 重新出现 + 链路重连握手成功 —— 按键唤醒成功) ``` 结论:**熄屏、三档设置持久化、自动休眠、深睡、按键唤醒全部走通**;设置页交互与音量试听也按预期工作。 另有两条发现:① 深睡唤醒后**电量上报 0%**(根因见下,已修);② 深睡时 4 条 `E i2s_common: i2s_channel_disable ... has not been enabled yet`(音频未启用时释放通道的必然报错,无害但刺眼)。 **已修:深睡唤醒后电量 0%**(`components/bsp/src/bsp_battery.c`) - 根因:`cw_wait_soc_ready()` 只判断 `soc <= 100` —— 深睡唤醒后电量计重新起算、 寄存器先读 0,于是 0 被当成"就绪",hello 就把 0% 报给了主机(面板显示 0%,实际 98%)。 - 修法:**用电压判别** —— 电压正常(≥3.5V)却报 0% 说明还没算好,继续等; 电压确实低(<3.5V)才认为 0% 可信。最长仍等 5 秒。 - **待确认**:三个按键(上/下/确定)中**哪些能唤醒**(第三键分压约 1.9V,落在数字门限附近,是唯一风险点)。 **第二轮真机反馈与修复(2026-10-07)** **第三轮真机定位(2026-10-07,全部有串口实证)** 三个**根因**,逐条修掉后才闭环(`247b2bed…` 验证通过): 1. **USB 串口复位把设备拉起来**:日志 `rst:0x15 (USB_UART_CHIP_RESET)` —— 插着 USB 时,主机侧串口句柄/DTR 变化会触发**芯片复位**,于是"睡下去 1 秒又被拉起"。 修法:门控改用 `esp_reset_reason()` 判定 —— 只有**深睡唤醒 + 按键确实按着**才开机; USB/看门狗/软件复位一律睡回去;上电/外部复位照常开机(不把人关在外面)。 2. **背光引脚深睡期间悬空**:背光走 LEDC PWM(GPIO21),深睡时 LEDC 掉电、引脚悬空 → 屏幕发亮,看起来像"自己开机了"。修法:深睡前把该引脚拉低 + `gpio_hold_en` + `gpio_deep_sleep_hold_en`,开机早期释放 hold。 3. **唤醒引脚挂在 ADC 模式导致周期性误唤醒**(拔掉 USB 后仍"低频闪 + 自己重启"): GPIO0 平时是 ADC 输入,数字输入通路关闭;深睡的**低电平唤醒**把这种状态当成"一直按下" → 周期性自唤醒(每次点亮背光 = 低频闪),连续两次后触发防呆上限 = "自己重启"。 修法:深睡前 `gpio_reset_pin` + `gpio_set_direction(INPUT)` + `gpio_pullup_en`, 把引脚交回普通 GPIO 上拉(C3 的 RTC IO **不支持**方向/上下拉控制, `SOC_RTCIO_INPUT_OUTPUT_SUPPORTED=0`,所以走数字 IO)。 **最终实测(正式档位前最后一版验证)**:设 1 分钟档 → 1 分钟后干净休眠 → 串口节点消失后**不再回来**、无复位、无闪;按任意键正常开机 → **1、2 两项均通过**。 :休眠前写 NVS `pwr_off` + RTC 魔数; 开机早期 `power_off_gate()` 只在**按键唤醒**时真正开机,其它唤醒源(USB/JTAG、欠压、 看门狗等误唤醒)重新睡回去。冷启动(RTC 魔数丢失)照常开机,不会被误挡。 日志也会记录睡前唤醒引脚电平,便于定位误唤醒来源。 复验判据:到点后应一直保持黑屏/断开;**不碰它不应自己回来**;按任意键才开机。 2. **"在系统设置界面遇到状态变化应优先显示其他"** → `hide_all_panels()` 改为按屏枚举遍历 (原先硬编码列表漏了设置页)。复验判据:设置页打开时,审批/追问/余额/识别结果弹出应立即 盖住设置页;收起后回主页,不会把设置页留在上面。 3. **文案**:统一叫「关机 / 开机」(不再叫"深度休眠")—— 本板无软件可控电源锁存, "休眠"落地为深睡 + 不会自己回来。 ### B5 设置持久化(NVS) - **步骤**:把三项都改成非默认值(如:熄屏 2 分钟 / 自动休眠 1 小时 / 提示音量 低),然后拔电或复位。 - **预期**:重新开机后三项仍是改过的值。 - **判定**:串口启动日志 `设置已加载:熄屏=2 分钟…自动休眠=60…提示音量=40%`。 - **失败先看**:日志里 `读取 … 失败(用默认值)` / `设置命名空间不存在`。 ## 阶段 C:配对(认领 / 信任握手) ### C1 首次连接必须配对 - **前置**:设备 NVS 里没有 `ps_token`(新设备,或擦过 NVS:`erase_region 0x9000 0x6000`)。 - **步骤**:让挂件扫描并连接设备。 - **预期**:设备屏幕弹出**配对码页**(6 位数字 + 设备 ID);挂件配对区显示「未配对」+ 输入框 + 「配对」按钮; **此时任何业务功能都不工作**(设备对业务消息一律回 `pair.required`)。 - **判定**:串口日志 `未配对的主机接入,要求配对(设备 xxxxxx)`;挂件出现输入框。 - **失败先看**:日志 `设备 xxxxxx 配对状态:未配对(本次配对码 NNNNNN)`。 ### C2 输入配对码成功 - **步骤**:把设备屏幕上的 6 位码输进挂件 → 点「配对」(或回车)。 - **预期**:设备提示「配对成功」并收起配对码页;挂件配对区变「已配对(设备 xxxxxx)」; 业务功能(任务状态 / 审批 / 追问 / 语音)随之恢复。 - **判定**:串口 `配对完成,已回 pair.ok`;主机日志 `[pairing] 配对成功:设备 xxxxxx`; 信任表文件生成:`~/.dsh/dsh-ai-passport.pairing.json`(含 token)。 - **补充**:配对成功后主机立刻带 token 重新握手,设备日志出现 `token 校验通过`。 ### C3 错码与限流 - **步骤**:故意输错 6 位码,连输 4 次。 - **预期**:前 3 次提示「配对码不对,请照设备屏幕重输」,并显示剩余次数; 第 4 次起提示「尝试太频繁,请等一分钟再试」(`rate-limited`)。 - **判定**:串口 `配对码错误(本窗口还剩 N 次)` / `配对尝试过于频繁,已限流`。 ### C4 换主机 / 解除配对(可移植性) - **步骤 A(解除)**:挂件点「解除配对」。 - 预期:设备清 token 并**换新码**;挂件回到未配对;信任表文件被删除。 - 判定:串口 `已解除配对(新配对码 NNNNNN)`。 - **步骤 B(换电脑)**:在另一台电脑上跑 DSH + 挂件连接同一设备。 - 预期:新电脑没有信任表 → 设备回 `pair.required` → 用**设备屏幕上的新码**重新配对一次即可。 - **步骤 C(重启后免配)**:设备断电重启(RTC/NVS 保留 token)。 - 预期:主机直接连上即可用,**不需要重新配对**(码虽然换了,但 token 未变)。 ### C5 安全边界(写入说明书的一句话) 明确告知:配对码与 token 走**未加密**的 BLE 链路,本机制用于"认领/信任"(防连错设备、 防别人随手使用),**不是**抗主动窃听的加密认证。 **实测记录(2026-10-07,固件 `1.0.0+g0b9e4c8-dirty`)** | 用例 | 结果 | 证据 | | --- | --- | --- | | C1 首连必配 | **通过** | 设备日志 `设备 f55fbe 配对状态:未配对(本次配对码 016851)` → `未配对的主机接入,要求配对(设备 f55fbe)` → `未配对:弹出配对码页`;链路 `connected` 但 `handshakeComplete=false`(业务未放行) | | C2 输码配对 | **通过** | 用户实测"可以了";快照 `{paired:true, deviceId:"f55fbe", hostName:"sunlinxiaodeMac-mini.local"}`;设备提示配对成功并收起码页,业务功能恢复 | | C3 错码/限流 | **代码路径 + host 单测覆盖**(真机未逐次点满 4 次) | `app_pair_logic` 常量时间比较与每分钟 3 次限流有 4 组 host 测试;设备侧回 `pair.failed{reason,remaining}` | | C4 解除配对 / 换主机 | **部分通过** | "设备被擦 NVS + 主机留着旧 token" 这条**不匹配路径**实测通过(设备回 `pair.required{token}`,挂件自动变成待配对);`pair.reset` 双向清理由 host 单测覆盖 | | C5 安全边界 | **已写入** docs/06 §9.14 与本文档 | —— | **阶段 C 验证中抓到并修掉的 4 个问题(都属"静默失效"类,值得留档)** 1. **主机域生命周期写错**:本仓库的域按约定实现 `attach(runtime, ctx)` / `dispose()`, 我写成了 `start()` / `stop()` → 框架从不调用 → 配对消息处理器**根本没注册**、 token 也没塞给链路。现象:`state.pairing` 永远是 `{needed:false, deviceId:null}`(重启 DSH 也没用)。 2. **配对码页被"蓝牙断开"逻辑收掉**:`app_ui.c` 有一条"链路断开就把覆盖层收回主页"的保护; 未配对时链路永远不会进 READY,一被判成断开就把**配对码页收掉** → 用户看到的是普通主页 (原话"设备上没有确认配对码的地方")。已给 `AP_SCREEN_PAIR` 开例外:只在用户主动关闭或配对成功时收起。 3. **过渡提示不实时**:挂件把 `pair.submit` 的返回消息("已提交配对码,等待设备确认…")挂在面板上, 只有下一次点按钮才清 → 配对成功后还挂着。已改为**配对状态一变就清**,并加源码级回归钉子。 4. **配对状态刷新太慢**:只在常规轮询周期里更新 → 已改为 `pairing.needed` 为真时 **500ms 快轮询** (与"有待消费识别结果"同款做法)。 --- ## 阶段 D:运行计时(v2.0) > 固件 `b06de76a…`(v2.0 圆角 + 计时)。观测点:设备右上角计时标签 + > `state.json` 的 `taskState` / `turnTrace` / `stateTrace`。 > **桌面端 webServer 端口是动态的**,先查再 curl: > `lsof -nP -a -p -iTCP -sTCP:LISTEN`(10-31 这次是 64772)。 ### D0 已完成(2026-10-31 20:08 实测,留档) | 断言 | 结果 | 证据 | | --- | --- | --- | | 运行态显示纯 `MM:SS`,无 `~`/`≥` 前缀 | **通过** | 用户目视:右上角纯数字逐秒推进,未压徽章 | | `turn/start` 会到达并播种可靠起点 | **通过** | `20:08:15.310 status:run` → `.314 turn/start`(相隔 4ms),`elapsedMs=4 sinceReliable=True` | | 结束判定:`turn/end` 立即结束 | **通过** | `20:10:46.987 turn/end` → `.989 status:idle`(2ms 后配对到达) | | 结束后「已完成」停留 8 秒 | **通过** | `20:10:55.039` 回落 idle,即 8.05 秒 | | 离开运行中即清空计时标签 | **通过** | 状态回 idle 后标签消失 | ### D1 10 分钟长跑不漂移(待测) - **怎么触发**:跑一个持续 ≥10 分钟的任务。 - **看什么**:T+10 分时设备读数 vs 电脑秒表(或主机侧 `turnTrace` 的 `turn/start` 时刻)。 - **怎么判定**:误差 ≤ 1 秒。设备端是「基准 + `esp_timer` 经过时间」重算,不是累加, 理论上不会漂;要证实的是重算路径在长跑中没被别的地方打断。 - **失败先看**:串口日志里有没有重连/重播种记录;`state.json` 的 `messagesOut` 是否突增。 ### D2 熄屏 3 分钟唤醒不漂移(待测) - **怎么触发**:任务运行中,让屏幕按设置熄屏(默认常亮,需先把熄屏改成 30s/1min), 静置 3 分钟,再按键唤醒。 - **看什么**:唤醒瞬间的读数。 - **怎么判定**:与主机侧时间对得上(≤1s)。熄屏只关背光、不销毁界面、不停 LVGL 定时器, 所以这个用例实际验的是「熄屏期间 `ui_tick` 仍在跑」。 - **失败先看**:`screen_set` 的日志、`refresh_timer` 是否因 `s_run_last_sec` 被判等而跳过渲染。 ### D3 掉线重连不归零(待测) - **怎么触发**:任务运行中,用 esptool 复位设备(等效断电重连): `python -m esptool --chip esp32c3 -p /dev/cu.usbmodem2201 --after hard_reset chip_id` - **看什么**:重连后设备读数。 - **怎么判定**:回到「已运行时长」而不是 `00:00`。依赖主机在链路就绪时 `force` 重播种。 - **失败先看**:`state.json` 的 `link.stats.reconnects`;插件日志里 `onLinkReady` 是否触发。 ### D4 双任务并行取最早(待测) - **怎么触发**:会话 A 跑 2 分钟后,在会话 B 再起一个任务。 - **看什么**:设备计时是否**不重置**(仍从 A 的起点算),且标题变成「2 个任务执行中」。 - **怎么判定**:读数连续;`turnTrace` 里出现两条不同 sessionId 的 `turn/start`。 - **失败先看**:`#elapsed()` 取 `min(at)` 的逻辑;去重键是否把「1 → 2 个任务」这次推送吞了 (这也是个老 bug,已有单测 `任务数 1→2 必须重推` 钉住)。 ### D5 跨 1 小时切 `H:MM:SS`(待测) - **怎么触发**:让任务跑满 1 小时(不现实的话,可用主机侧临时把种子设成 `3_590_000` 逼近边界)。 - **看什么**:`59:59` → `1:00:00` 的切换;**字号与颜色保持不变**(10-31 起不再降字号)。 - **怎么判定**:不截断、不顶到别的内容。28px 计时字体下最宽形态 `10:23:45` 实宽 107.4px, 卡片右侧 192px 可用宽度仍宽裕;数字是 tabular(每字等宽),秒数跳动时不会左右抽动。 - **失败先看**:`refresh_timer` 是否还残留字号切换;`tools/verify-layout.py` 的 `状态卡·计时` 建模是否仍按 `10:23:45` 算宽度;`main/fonts/app_ui_font_timer.c` 是否漏了字形 (`python3 tools/make-fonts.py --check` 会报「运行计时(28px)缺字」)。 > 历史:D5 原来还要验「字号从 16px 降到 8px」,那是 16px **点阵**时代的权宜之计 > (等宽,`10:23:45` 要 128px,会与徽章相交 12px)。换思源 16px 后该降级已删除; > 10-31 又按要求把计时放大到 28px(另起一档字体,只含数字与冒号)。 ### D6 插件中途启动不显示计时(待测) - **怎么触发**:任务运行中重启 DSH(插件重载),让 `runStarts` 为空、只剩 `status(true)` 观察。 - **看什么**:设备右上角。 - **怎么判定**:**不显示任何数字**(而不是显示 `~00:0X`)。起点不可靠时主机不下发 `elapsedMs`,因为设备按设计稿只印纯格式,没有位置标注「近似」。 - **失败先看**:`state.json` 的 `taskState` 里有没有 `elapsedMs` 字段; 单测 `只有 api-session/status 时:起点不可靠,干脆不下发计时`。 ### D7 状态条时钟(10-31 新增) - **怎么触发**:设备连上主机并完成握手即可。 - **看什么**:状态条上、电量左边的 `HH:MM`。 - **怎么判定**: 1. 与电脑右上角时间**分秒级一致**(同一分钟即算过); 2. 等 3 分钟,分钟位跟着跳(设备自己走秒,不是靠主机反复推); 3. 空闲(没有任务在跑)时**时钟照常显示**,而右上角的**运行计时消失**; 4. 断开重连后仍在正确时间(重连会补推种子)。 - **失败先看**: - 显示 `--:--`:主机没下发 `hostEpochSec`/`hostTzOffsetMin`,查 `state.json` 的 `taskState` 里有没有这两个字段(老插件的推送不带)。 - 差整 8 小时:时区符号搞反了(应发 `-getTimezoneOffset()`,北京 = `+480`)。 - 分钟位不动:`refresh_clock` 的 `s_clock_last_key` 判定或 `ui_tick` 未调用。 **D7 实测(2026-10-31 20:40,固件 `6ffaf736…`)**:通过。设备状态条在电量左边显示 `HH:MM`, 与电脑一致;空闲(无任务)时右上角没有运行计时 —— 「时间常驻、计时只在运行中出现」两条都成立。 主机侧证据:`taskState` 里带 `hostEpochSec: 1793450387`、`hostTzOffsetMin: 480`。 --- ## 附录:配对掉了怎么快速恢复(10-31 加) **症状**:`state.json` 里 `pairing.needed=true`、`paired=false`、`deviceId=null`, 链路 `connected` 但 `handshakeComplete=false`;设备屏幕停在配对码页。 **为什么会掉**:主机信任表 `~/.dsh/dsh-ai-passport.pairing.json` 丢失(本机 10-31 实测:文件不存在)。 新进程 `hello` 没 token → 设备判「已配对但 token 不符」→ 按 `pairing.js` 的自动重置逻辑 解除配对、换新码 → 主机侧自然变成"未配对"。 **恢复步骤**(不用手抄屏幕上的码): ```bash # 1) 先开串口再触发,否则会错过设备打码的那一瞬间 # (设备在「解除配对」时会把新码写进日志:已解除配对(新配对码 690691)) python3 - <<'PY' & import os,termios,time,select,re fd=os.open('/dev/cu.usbmodem2201',os.O_RDWR|os.O_NONBLOCK|os.O_NOCTTY) a=termios.tcgetattr(fd); a[3]&=~(termios.ICANON|termios.ECHO); a[4]=a[5]=termios.B115200 termios.tcsetattr(fd,termios.TCSANOW,a); time.sleep(.2) end=time.time()+4; buf=b'' while time.time()/dsh-passport/action \ -H 'Content-Type: application/json' -d '{"action":"pair.unpair"}' # 3) 用第 1 步抓到的 6 位码提交 curl -s -X POST http://127.0.0.1:<端口>/dsh-passport/action \ -H 'Content-Type: application/json' -d '{"action":"pair.submit","payload":{"code":"690691"}}' ``` **几个坑**: - `pair.submit` 只接受**恰好 6 位**数字(`submit()` 里 `digits.length !== 6` 直接 `bad-input`,不发链路)。 少一位会收到 `bad-code` 并消耗限流名额(1 分钟 3 次),所以别猜,直接读串口。 - 配对码只在上电/解除配对时生成,**失败不会换码**,但设备重启会换。 - 成功后务必确认 `~/.dsh/dsh-ai-passport.pairing.json` 存在且 token 为 32 位; 这个文件缺失就是"重启 DSH 又要重新配对"的根因。