diff --git a/AGENTS.md b/AGENTS.md index f8dc97ee..8857228a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -108,10 +108,12 @@ STM32F407ZGTx @168MHz(HSE 8MHz → PLL M=4/N=168/P=2);**仅 Keil MDK-ARM v - 入口:`& '.venv\Scripts\python.exe' tools/serial_tool/serial_tool.py <命令>`(daemon 自动拉起;全部命令 5s 超时、JSON 输出、exit 0/1/2/3) - 全量历史 = 日志文件 `tools/serial_tool/state/serial.log`(RX/TX/SYS 时间戳行);agent 直接 Read/Grep 文件,**无采集窗口仪式**;20MB 上限自动截头保尾 10MB -- 常用:`open`(幂等)/ `send <文本>`(或 `--hex 0D0A`)/ `log --tail N --grep P` / `clear`(代际+1)/ `wait-idle --settle-ms 8000 --max-ms 30000` / `status` / `close` +- 常用:`open`(幂等)/ `send <文本>`(或 `--hex 0D0A`)/ `log --tail N --grep P [-i] [--regex]`(**先过滤后截尾**)/ `clear`(代际+1)/ `wait-idle --settle-ms 8000 --max-ms 30000 [--first-timeout-ms 12000]` / `status` / `close` - 串口端口单一配置源:`.opencode/serial_config.json`(当前 COM6,2026-09-21 台架重插后由 COM7 变更);端口变化**只改这个文件** - 命令/工作流/故障排查 `docs/测试说明/串口助手使用指南.md`;契约/daemon 行为/陷阱 `docs/测试说明/串口助手设计说明.md` -- **抓开机日志铁律(2026-09-20 实测)**:① 顺序 `open` → `clear` → 烧录/复位 → `wait-idle` → 读日志校验标记(**采集必须先于烧录**,否则错过开机头几行);② `wait-idle` 必须带 `--timeout <秒>`(CLI→daemon 默认 5s 硬超时会掐断长等待:`wait-idle --timeout 90 --settle-ms 8000 --max-ms 45000`);③ 标记校验直接 Read/Grep/Select-String `state/serial.log` 文件最可靠(`log --grep` 子命令实测不可信,勿作判定依据) +- **抓开机日志铁律(2026-09-20 实测;②③ 已于 2026-10-10 修复)**:① 顺序 `open` → `clear` → 烧录/复位 → `wait-idle` → 读日志校验标记(**采集必须先于烧录**,否则错过开机头几行);② `wait-idle` 的 CLI 超时现按 `--max-ms` **自动推导**,不再需要手工带 `--timeout`(旧版固定 5s 会在 daemon 还在等时掐断连接、daemon.log 刷 traceback);③ `log --grep` 已改为**先过滤后截尾**——旧实现先 `tail`(默认 50)后 grep,开机 marker 必然查不到,这才是当年"`log --grep` 实测不可信"的真根因,现可直接作判定依据(Read/Grep 文件仍等效) +- **`wait-idle` 判据(2026-10-10 修)**:`no_data` = **本次等待期间零字节**(旧实现比的是 daemon 累计 `rx_bytes` 是否为 0,常驻 daemon 下永不触发 → 板子全程静默也误报 `idle:true`);**首字节到达前不启动静默计时**(`--first-timeout-ms` 兜底,`run_boot_test.py --first-wait-ms` 默认 12000);返回的 `rx_bytes` 是本次增量而非累计 +- ⚠️ **看到两个 `serial_tool.py --serve` 进程是正常的**(2026-10-10 实测):`.venv\Scripts\python.exe` 是 venv stub,会派生基解释器(本机 = `C:\ProgramData\anaconda3\python.exe`)子进程跑真 daemon,`status.daemon.pid` 报的是子进程;**别把这对父子当成"多 daemon 并存"去 taskkill**(HTTP 口独占绑定 `SO_EXCLUSIVEADDRUSE` 已生效,`status.daemon.exclusive_bind: true` 可验)。真要清的是 `status`/`meta.json` 里那个 pid 之外的**独立**残留实例 - **`open` 报"拒绝访问/端口被占用"时**:先查**残留旧 daemon**(上一会话遗留的 `serial_tool.py --serve` python 进程——口句柄已死但进程仍占着串口与 HTTP 口 18765)→ `taskkill /F /PID <旧PID>` → 重新 `open`(自动拉起新 daemon,系统自动释放口);改 `serial_config.json` 后同样必须先杀旧 daemon 再 `open`(daemon 启动时缓存配置) ## LVGL PC 模拟器 diff --git a/docs/测试说明/串口助手使用指南.md b/docs/测试说明/串口助手使用指南.md index 82ebdc99..c5ebccb6 100644 --- a/docs/测试说明/串口助手使用指南.md +++ b/docs/测试说明/串口助手使用指南.md @@ -151,12 +151,15 @@ ... serial_tool.py log # 末 50 行 ... serial_tool.py log --tail 200 # 末 200 行 ... serial_tool.py log --tail 0 # 全部行 -... serial_tool.py log --grep "App init OK" # 只留含子串的行 +... serial_tool.py log --grep "App init OK" # 只留含子串的行(全文件搜,再截尾) ... serial_tool.py log --grep "init" --tail 100 +... serial_tool.py log --grep "app init" -i # 忽略大小写 +... serial_tool.py log --regex "\[STACK\]|Network Init" --tail 5 # 正则 ``` - **直接读日志文件**实现——daemon 死机/被杀也不受影响;`gen` 取自 `meta.json`。 -- 返回 `{"ok":true,"gen":N,"lines":["[RX] …", …]}`。 +- **先过滤、后截尾**(2026-10-10 修):旧实现先 `--tail`(默认 50)后 grep,`log --grep "App init OK"` 只要开机后又刷了 50 行就必然查无此 marker——这就是旧文档"log --grep 实测不可信"的根因,现已修复,可直接作判定依据。 +- 返回 `{"ok":true,"gen":N,"lines":[…],"scanned":总行数,"matched":命中行数}`;`--tail` 截掉了部分命中时另带 `note` 说明。`--regex` 写错 → `ok:false` + 错误文本(exit 3)。 - 等价的手动读法(Agent 用 Read/Grep 工具、人用 PowerShell 均可): ```powershell @@ -178,6 +181,7 @@ Select-String -Path 'tools\serial_tool\state\serial.log' -Pattern 'App init OK' ```powershell ... serial_tool.py wait-idle # 静默 8s 或超时 30s ... serial_tool.py wait-idle --settle-ms 5000 --max-ms 60000 # 自定义 +... serial_tool.py wait-idle --first-timeout-ms 12000 # 首字节 12s 未到即判 no_data ``` ```json @@ -187,11 +191,14 @@ Select-String -Path 'tools\serial_tool\state\serial.log' -Pattern 'App init OK' | `reason` | 含义 | `idle` | |---|---|---| | `idle` | 收到过数据,且已连续静默 ≥ `--settle-ms`(**正常收尾**) | true | -| `no_data` | 全程一个字节都没收到(板子没输出/口不对/没烧进去) | false | +| `no_data` | **本次等待期间**一个字节都没收到(板子没输出/口不对/没烧进去) | false | | `timeout` | `--max-ms` 到了仍在持续吐数据 | false | - **`--settle-ms` 默认 8000 不是随便定的**:本工程 boot 任务间有 3~4s 自然静默间隙,判据小于它会把开机日志截断。 -- @test 流程(`run_boot_test.py`)用它作为"烧录后日志收齐"的判据。 +- **首字节门(2026-10-10 加)**:首字节到达**之前**不启动静默计时——旧实现从进入即计时,板子第一行比 `--settle-ms` 来得晚就提前收尾("开机日志缺尾巴"的另一半成因)。`--first-timeout-ms`(0 = 同 `--max-ms`)到点仍零字节 → `no_data` 快速失败。 +- **`rx_bytes` 是本次增量**(旧实现回的是 daemon 累计值,常驻 daemon 下动辄百万,无判读价值)。 +- **CLI 超时自动推导**:未显式带 `--timeout` 时按 `--max-ms/1000 + 5` 取值,不再需要手工加(旧版固定 5s 会在 daemon 还在等时掐断连接 → CLI exit 2 + daemon.log 刷 `ConnectionAbortedError`)。 +- @test 流程(`run_boot_test.py`)用它作为"烧录后日志收齐"的判据(`--first-wait-ms` 默认 12000)。 ### 3.9 `start` / `stop` / `restart` — daemon 生命周期 @@ -324,11 +331,13 @@ Get-Content 'tools\serial_tool\state\daemon.log' -Tail 30 # ② daemon 自身 | `port not found: COMx (available: …)` | 板子没接 / CH340 驱动没装 / 口号变了 | 对照 `available` 列表;装 CH340 驱动;改 `.opencode/serial_config.json` 的 `portName` | | `port busy: COMx (held by another process)` | 别的程序占着口 | `Get-Process` 找占用者关掉;或 `… serial_tool.py close` 释放本工具占的口 | | `daemon start timeout` / `daemon unreachable` | daemon 起不来(端口冲突/解释器问题) | 看 `state/daemon.log`;`… serial_tool.py restart`;确认 18765 未被占(改 `httpPort`) | +| `daemon did not answer within Ns (busy? raise --timeout)` | 命令耗时超过 `--timeout`——daemon 活着、只是还在等(典型:显式给 `wait-idle` 带了小 `--timeout`) | 调大 `--timeout`,或不显式给(`wait-idle` 按 `--max-ms` 自动推导)。2026-10-10 起这种情况**不再**顺手拉起第二个 daemon | | `wait-idle` 返回 `no_data` | 全程无 RX | 核对 COM 号与波特率;板子供电;烧录是否成功;`status` 看 `port.ready` | | 日志里出现 `HEX:…` 行 | 该段字节不是 UTF-8/GBK 文本(二进制流/乱码) | 正常回退行为,按 hex 对照协议解析 | | `state: error` + `error: read: …` | 读串口出错/拔线 | daemon 仍活着;重插后 `open` 即恢复(自动 1.5s 退避) | | 改了 `serial_tool.py` 行为没变 | 运行中的 daemon 还是旧代码 | `… serial_tool.py restart` | -| 开机日志"缺尾巴"(如缺 `Network Init`) | `--settle-ms` 太小,在 boot 自然静默间隙就收尾了 | 用默认 8000(`run_boot_test.py` 已内置) | +| 开机日志"缺尾巴"(如缺 `Network Init`) | ① `--settle-ms` 太小,在 boot 自然静默间隙就收尾;② 首字节晚于 `--settle-ms` 到达,静默计时提前触发 | 用默认 8000(`run_boot_test.py` 已内置);2026-10-10 起首字节到达前不启动静默计时,必要时调 `--first-timeout-ms` | +| `open` 报 `no port specified … [config: …]` | `serial_config.json` 缺失 / JSON 语法错 / `baudRate`·`httpPort` 非整数(现按 `utf-8-sig` 读并**显式回显**错误,不再静默回退成"没配端口") | 按 `config_error` 文本修文件;`status` 与 `ports` 也回显 `config_error` | | 旧日志"丢了" | 20MB 截头丢的是**文件头** | 查旧内容要看尾部;重要轮次开始前先 `clear` 分代 | ## 8. 常见问题 @@ -354,7 +363,7 @@ daemon 在 `127.0.0.1:`(默认 18765)提供 JSON API,与 CLI 一 | `POST /close` | — | 同 `close` | | `POST /send` | `{"text":"hi","nl":true}` 或 `{"hex":"0D0A"}` | 同 `send` | | `POST /clear` | — | 同 `clear` | -| `POST /wait-idle` | `{"settle_ms":8000,"max_ms":30000}` | **同步阻塞**至静默/超时才返回 | +| `POST /wait-idle` | `{"settle_ms":8000,"max_ms":30000,"first_timeout_ms":12000}`(均可省) | **同步阻塞**至静默/首字节超时/整体超时才返回 | | `POST /stop` | — | 关口后 daemon 退出(响应先于退出发出) | 响应码:`200` 成功 / `400` 领域错误(`ok:false`+`error`)/ `504` daemon 忙(命令 20s 未完成)/ `404` 未知端点。 @@ -371,3 +380,4 @@ Invoke-RestMethod -Method Post http://127.0.0.1:18765/send -Body '{"text":"ping" | 日期 | 变更 | |---|---| | 2026-09-15 | 初版:命令全参考(含实测输出)、日志规范、典型工作流、故障排查、FAQ、HTTP 附录;与 v1.1 实现(含 4 项 Windows 缺陷修复)对齐 | +| 2026-10-10 | 第一批可靠性修复同步:① `wait-idle` `no_data` 改按**本次增量**判(旧实现常驻 daemon 下永不触发)、加**首字节门** `--first-timeout-ms`、`rx_bytes` 改回本次增量;② `log` 改**先过滤后截尾**并加 `-i`/`--regex` + `scanned`/`matched` 计数(旧顺序即"log --grep 不可信"根因);③ `wait-idle` CLI 超时按 `--max-ms` 自动推导(不再需手工 `--timeout`),且超时不再被误判成"daemon 不可达"——旧实现会顺手拉起一个绑不上 HTTP 口的 daemon(`daemon.log` 里成串的 `listening`/`HTTP port busy` 即由此而来),现改为快速失败并提示调大 `--timeout`;④ 配置读取改 `utf-8-sig` 并显式回显 `config_error`;⑤ `status.daemon.exclusive_bind` 可验 HTTP 口独占绑定是否真生效 | diff --git a/docs/测试说明/串口助手设计说明.md b/docs/测试说明/串口助手设计说明.md index 7008e1c0..b2169264 100644 --- a/docs/测试说明/串口助手设计说明.md +++ b/docs/测试说明/串口助手设计说明.md @@ -83,7 +83,8 @@ flowchart LR | `close` | 关端口(flush 后释放) | — | `{"ok":true}` | | `send` | 发 TX 字节(进日志);默认**精确字节**不加换行 | 位置参数文本(UTF-8)或 `--hex 0D0A`;`--nl` 追加 CRLF | `{"ok":true,"bytes":12}` | | `status` | 全状态(见 §4.2) | — | 见 §4.2 | -| `log` | 读日志(文件尾) | `--tail N`(默认 50;`0` = 全部)`--grep P` | `{"ok":true,"gen":8,"lines":["…"]}` | +| `log` | 读日志(**先过滤后截尾**,2026-10-10) | `--tail N`(默认 50;`0` = 全部)`--grep P` `-i` `--regex` | `{"ok":true,"gen":8,"lines":["…"],"scanned":N,"matched":M}` | +| `wait-idle` | 等 RX 静默(@test 收尾判据) | `--settle-ms`(默认 8000)`--max-ms`(默认 30000)`--first-timeout-ms`(默认同 max) | `{"ok":true,"rx_bytes":本次增量,"idle":bool,"waited_ms":N,"reason":"idle\|no_data\|timeout"}` | | `clear` | 清空日志:原地截断 + 写 `[SYS] log cleared (gen=N+1)` | — | `{"ok":true,"gen":8}` | | `start` / `stop` / `restart` | daemon 生命周期 | — | `{"ok":true,"pid":…}` / stop 后 `{"ok":true,"stopped":true}` | | `selftest` | 无头自检(行拆分器/解码回退/截头算法 单测回环,无需硬件) | — | 0=PASS | @@ -166,7 +167,7 @@ flowchart LR | 项 | 规定 | |---|---| -| 单实例 | HTTP 端口(默认 18765,`serial_config.json` 的 `httpPort` 可改)绑定即锁;端口被占 = 已有实例 | +| 单实例 | HTTP 端口(默认 18765,`serial_config.json` 的 `httpPort` 可改)绑定即锁;端口被占 = 已有实例。Windows 上真独占靠 `SO_EXCLUSIVEADDRUSE`(**真名,值 -5**;2026-10-10 修:旧代码写成 `SO_EXCLUSIVEADDRPLACE` + 数值 `0x0040`,`setsockopt` 抛 `WinError 10042` 被静默吞掉,"独占绑定"从未生效),失败现打 WARN,`status.daemon.exclusive_bind` 可验。⚠️ 进程列表里出现**两个** `--serve` 是正常的:`.venv\Scripts\python.exe` 是 venv stub,会派生基解释器(本机 anaconda)子进程跑真 daemon(`status.daemon.pid` = 子进程),不是多实例 | | 拉起方式 | CLI detached 子进程(`CREATE_NO_WINDOW`),解释器 = `sys.executable`(保证同一 .venv) | | `open` 语义 | 幂等(同端口已开 = 成功返回,**不重开**);端口不存在 → ≤1s 报错并附可用端口列表;被占用 → ≤1s 报错(Windows 共享冲突即时返回)并提示持有者 | | DTR/RTS | 默认**不翻转**(`dtr=False, rts=False`,防 STM32 自动复位电路误复位目标板);`--dtr/--rts` 可显式置位 | @@ -174,6 +175,9 @@ flowchart LR | 重开退避 | 错误恢复路径重开端口前 ≥1.5s 退避(CH340 特性);正常流程不重开 | | `stop` | 关端口(flush 尾行)→ 写 `[SYS] port closed` → 退出 | | 进程被强杀 | Windows 进程退出自动释放 COM 端口;下次命令自动拉起新实例(`open` 幂等路径) | +| `wait-idle` 判据(2026-10-10 修) | `no_data` = **本次等待期间零字节**(旧实现比的是 daemon 累计 `rx_bytes` 是否为 0 → 常驻 daemon 下永不触发,板子全程静默也误报 `idle:true`,@test 的 no_data 拦截形同虚设);**首字节到达前不启动 settle 计时**(`first_timeout_ms` 兜底,默认 = `max_ms`);返回 `rx_bytes` = 本次增量 | +| CLI 超时与断连噪声(2026-10-10) | `wait-idle` 未显式带 `--timeout` 时按 `max_ms/1000 + 5` 自动推导(旧版固定 5s 会在 daemon 还在等时掐断连接);handler 侧 `ConnectionError`(客户端已断开)不再往 daemon.log 打 traceback(本机曾累积 16 次);`daemon.log` 超 1MB 在拉起新实例前原地保尾 256KB | +| 配置读取(2026-10-10) | 按 `utf-8-sig` 读(容忍 BOM,与 `run_boot_test.py` 同口径);文件缺失 / JSON 语法错 / `baudRate`·`httpPort` 非整数不再静默回退成"没配端口",错误文本进 `configError`,由 `status` / `ports` / `open` 显式回显 | ## 7. 集成与迁移(@test 切换) @@ -266,3 +270,4 @@ flowchart LR | 日期 | 变更 | |---|---| | 2026-09-15 | v1.1 实施完成(S1–S6)。实施中实测发现并修复 4 个设计文档未覆盖的 Windows 实现级缺陷:① `SO_REUSEADDR` 在 Windows 允许多个活跃 socket 绑同端口 → HTTP 服务改**独占绑定**(`SO_EXCLUSIVEADDRPLACE`,发行版未导出时用数值 0x40 兜底)+ restart 强制确认旧实例死亡,防"already_running"假成功留下旧代码;② 写句柄 `"ab"` 追加模式在 Windows **禁读** → 溢出截头的"读尾部"必崩,改 `"a+b"`(自测用独立句柄故未暴露,真实 daemon 溢出路径已 E2E 验证);③ Windows 对**不存在**的 COM 口常报 Access Denied → 误报"port busy",改以 `list_ports` 枚举结果为"不存在/占用"判定准绳;④ 静默判据默认 2s 会截断 boot(任务间 3~4s 自然静默间隙),`-SettleMs` 默认改 8000ms。验收状态:A1–A5/A8 PASS(本机无 CH340,用 COM1 替代口验证;A5 溢出用进程内驱动真实 writer_loop 验证);A6/A7 正路径待硬件接入补验 | +| 2026-10-10 | **第一批可靠性修复**(5 项,均实测复现后修,`selftest` 加 T5/T6 共 6 个回归用例,16/16 PASS):① `wait_idle` `no_data` 改按**本次增量**判——旧实现比 daemon 累计 `rx_bytes` 是否为 0,常驻 daemon(本机 rx_bytes=1.2MB)下板子全程静默也返回 `idle:true`,@test 的 no_data 拦截形同虚设;同时加**首字节门**(首字节到达前不启动 settle 计时,`--first-timeout-ms` 兜底)并把返回的 `rx_bytes` 改为本次增量;② `log` 改**先过滤后截尾**(旧顺序 + 默认 `--tail 50` = 开机 marker 必然查不到,即 AGENTS.md"log --grep 实测不可信"的真根因),并加 `-i` / `--regex` 与 `scanned`/`matched` 计数;③ `wait-idle` 的 CLI 超时按 `--max-ms` 自动推导(旧版固定 5s 掐断长等待 → daemon.log 累积 16 次 `ConnectionAbortedError` traceback,现 handler 侧静音 `ConnectionError`);同时 CLI 不再把**超时**误判成"daemon 不可达"——旧实现超时与拒连走同一分支,一次 1s 超时就会额外拉起一个 daemon(新实例绑不上 HTTP 口,打 `HTTP port busy` 后退出;daemon.log 里 36 条 `listening` 大半是这种抖动攒出来的),现超时快速失败并提示调大 `--timeout`,只有真·拒连才自动拉起(`selftest` T7 锁);④ **§13 09-15 那条"独占绑定"其实是坏的**:`SO_EXCLUSIVEADDRPLACE` 是拼错的名字(真名 `SO_EXCLUSIVEADDRUSE`,值 **-5**),数值兜底 `0x0040` 是无效选项 → `setsockopt` 抛 `WinError 10042` 被 `except` 吞掉,独占从未生效;现改真名 + 失败打 WARN + `status.daemon.exclusive_bind` 可验(实测 true);⑤ 配置读取改 `utf-8-sig`(旧版遇 BOM 静默回退成"没配端口")并把解析错误显式回显;另加 `daemon.log` 超 1MB 原地保尾 256KB。顺带纠正一条误诊:进程列表里两个 `--serve` 是 **venv stub + 基解释器父子对**,不是"多 daemon 并存" | diff --git a/docs/测试说明/自动化测试流程.md b/docs/测试说明/自动化测试流程.md index f2b5b8d4..c1430459 100644 --- a/docs/测试说明/自动化测试流程.md +++ b/docs/测试说明/自动化测试流程.md @@ -67,8 +67,8 @@ |---|---| | `selftest` FAIL / `ModuleNotFoundError: pyserial` | venv 未建或依赖缺失:按 AGENTS.md「Python 环境」重建 `.venv` + `pip install -r requirements.txt` | | daemon 无响应 / 自动拉起失败 | 查 `tools/serial_tool/state/daemon.log`;`serial_tool.py restart` 重建 daemon;检查 COM 是否被其他程序占用 | -| 抓到日志缺 `Network Init` | `-SettleMs` 太小(boot 任务间有 ~3-4s 自然静默间隙),用默认 8000ms | -| 烧录后无数据(`no_data`) | 开串口 + clear 必须在烧录**前**完成(`run_boot_test.py` 已保证);核对 COM 号、板子供电,看 `state/serial.log` 有无 SYS 异常行 | +| 抓到日志缺 `Network Init` | `-SettleMs` 太小(boot 任务间有 ~3-4s 自然静默间隙),用默认 8000ms;2026-10-10 起首字节到达前不启动静默计时(`--first-wait-ms` 默认 12000),首行晚到不再被提前收尾 | +| 烧录后无数据(`no_data`) | 2026-10-10 起 `no_data` = **本次等待期间零字节**(旧实现比 daemon 累计字节,常驻 daemon 下永不触发,只能靠"日志里没有 [RX] 行"兜底);开串口 + clear 必须在烧录**前**完成(`run_boot_test.py` 已保证);核对 COM 号、板子供电,看 `state/serial.log` 有无 SYS 异常行 | | 端口被占用(`port busy`) | 先 `serial_tool.py close` 或 `restart`;检查其他程序占用;Keil ST-Link 不占 COM | | 日志中无 UI 相关输出 | 见下节「UI 自动化」 | | 日志出现 `[ERR]` 行 | 固件错误级日志(`DBG_ERROR`),默认 Forbidden 会判 FAIL:按 TAG 查对应模块的 `docs/问题记录/*_Trap_Records.md`;若属测试套件的**预期**错误(守卫负路径、非法名校验等),用 `--forbidden` 放宽 | @@ -93,7 +93,7 @@ 负路径与 FTL 运行期坏块、FatFs 非法名校验等),与默认 Forbidden 冲突,须显式放宽: `--forbidden "FAIL;HardFault"`(`[FAIL]` 仍会拦截真实断言失败); 例:GD5F 只读 `--required "=== GD5F2GQ5UE Tests: 16/16 PASSED"`,破坏性 `68/68`。 -- **长时间采集**:daemon 全量持续写入 `state/serial.log`(20MB 上限自动截头保尾 10MB),直接读文件即可;或用 `log --tail 0 --grep <标记>` 过滤。 +- **长时间采集**:daemon 全量持续写入 `state/serial.log`(20MB 上限自动截头保尾 10MB),直接读文件即可;或用 `log --grep <标记>` 过滤(2026-10-10 起先过滤后截尾,不必再配 `--tail 0`)。 ## 变更记录 @@ -102,3 +102,4 @@ | 2026-09-13 | 「UI 屏幕自动化(当前缺口)」更新:UI 初始化 DBG 打印已实现(`ui_task.c`),屏幕内容已纳入模拟器像素回归;测试分层与门禁见 `测试开发标准流程.md` | | 2026-09-15 | 采集机制整体切换:serial-mcp-server(采集窗口/first-byte/EOF 重试)→ 仓库内置 Python 串口助手(daemon 常驻全量日志 + `wait-idle` 静默判据 `-SettleMs`/`-MaxWaitMs`);`serial_capture.ps1` 删除,流程/脚本清单/失败模式表同步 | | 2026-09-21 | 默认 Forbidden 增补 `[ERR]`:固件 `DBG_ERROR` 前缀是 `[ERR]`(≠ `ERROR` 子串),此前驱动/模块错误日志不会被自动闭环拦截;`verify_log.py` / `run_boot_test.py` 默认值同步,测试套件运行需显式放宽 | +| 2026-10-10 | 采集链可靠性修复(serial_tool 第一批):`wait-idle` 的 `no_data` 改按本次增量判(旧实现常驻 daemon 下永不触发,"烧录后无数据"这一失败模式实际测不出来)+ 首字节门 `--first-timeout-ms`(`run_boot_test.py` 新增 `--first-wait-ms`,默认 12000);`log --grep` 改先过滤后截尾(旧顺序导致 marker 必然查不到)并加 `-i`/`--regex`;`wait-idle` CLI 超时自动推导,不再需手工 `--timeout`;HTTP 口独占绑定修正(旧选项名/数值均无效,实际未生效) | diff --git a/test/host/run_boot_test.py b/test/host/run_boot_test.py index 2d5eaafc..e25d5470 100644 --- a/test/host/run_boot_test.py +++ b/test/host/run_boot_test.py @@ -4,7 +4,8 @@ 调用仓库内置串口助手(tools/serial_tool,常驻 daemon,子进程 CLI + JSON): 1. open 串口 + clear 日志(本次运行独立代际) 2. 烧录(默认 MDK-ARM\\download.bat:下载 + Reset & Run) - 3. wait-idle:RX 静默 >= settle-ms,或 max-wait-ms 超时 + 3. wait-idle:RX 静默 >= settle-ms 收尾;首字节超过 --first-wait-ms 未到 → no_data + (快速失败);整体 max-wait-ms 超时 4. 从日志提取 [RX] 载荷行(剥掉 "[RX] " 前缀) 5. verify_log.py 标记校验 6. (仅当 --require-selftest-pass)自检记录联合判定(S7): @@ -24,6 +25,7 @@ 用法: & '.venv\\Scripts\\python.exe' test/host/run_boot_test.py [--port COM7] [--baud-rate 115200] [--settle-ms 8000] [--max-wait-ms 30000] + [--first-wait-ms 12000] [--flash-cmd MDK-ARM\\download.bat] [--flash-timeout 180] [--required "App init OK;Network Init;net init: OK"] [--forbidden "FAIL;ERROR;HardFault;[ERR]"] @@ -148,6 +150,9 @@ def main(argv=None): help="RX silence that ends the capture") ap.add_argument("--max-wait-ms", type=int, default=30000, help="overall wait-idle timeout") + ap.add_argument("--first-wait-ms", type=int, default=12000, + help="give up as no_data if the board sent ZERO bytes this long " + "after flash started the wait (0 = same as --max-wait-ms)") ap.add_argument("--flash-cmd", default="", help="flash command (default: MDK-ARM/download.bat; pass reset.bat for reset-only)") ap.add_argument("--flash-timeout", type=int, default=180, @@ -239,12 +244,16 @@ def main(argv=None): print("RUN_TEST_WARN: flash rc={} (continuing: board may still have reset)".format(flash_rc)) # 4. wait-idle(RX 静默收尾或整体超时) + # --first-timeout-ms:首字节门——烧录后板子全程不吐字节时按 no_data 快速失败 + # (旧版 daemon 常驻累计 rx_bytes>0,no_data 永不触发,只能靠后面"日志里没有 + # [RX] 行"兜底,报错信息误导成 device silent?) wait_timeout_s = args.max_wait_ms // 1000 + 20 - data, out = tool("wait-idle", - "--settle-ms", str(args.settle_ms), - "--max-ms", str(args.max_wait_ms), - "--timeout", str(wait_timeout_s), - timeout=wait_timeout_s + 15) + wait_args = ["--settle-ms", str(args.settle_ms), + "--max-ms", str(args.max_wait_ms), + "--timeout", str(wait_timeout_s)] + if args.first_wait_ms: + wait_args += ["--first-timeout-ms", str(args.first_wait_ms)] + data, out = tool("wait-idle", *wait_args, timeout=wait_timeout_s + 15) if data is None: print("RUN_TEST_ERROR: wait-idle failed: {}".format(out)) return 2 diff --git a/tools/serial_tool/serial_tool.py b/tools/serial_tool/serial_tool.py index 74260ff5..b9594679 100644 --- a/tools/serial_tool/serial_tool.py +++ b/tools/serial_tool/serial_tool.py @@ -17,9 +17,9 @@ open / close 开(幂等)/ 关串口 send | --hex 发送 TX(进日志);--nl 追加 CRLF status 全状态(单条 JSON) - log --tail N --grep P 读日志(CLI 直接读文件,daemon 死了也能看历史) + log --tail N --grep P 读日志(先过滤后截尾;-i 忽略大小写 / --regex 正则) clear 清空日志(原子截断 + 代际递增) - wait-idle 等 RX 静默(@test 用;--settle-ms / --max-ms) + wait-idle 等 RX 静默(@test 用;--settle-ms / --max-ms / --first-timeout-ms) start / stop / restart daemon 生命周期(其余命令会自动拉起 daemon) selftest 无头自检(行拆分/解码回退/截头算法,无需硬件) @@ -29,7 +29,9 @@ import argparse import json import os import queue +import re import signal +import socket import subprocess import sys import tempfile @@ -60,21 +62,44 @@ CAP_BYTES = 20 * 1024 * 1024 # 日志体积上限 KEEP_BYTES = 10 * 1024 * 1024 # 超限后保留的尾部 PARTIAL_FLUSH_S = 0.2 # 残行冲刷窗口(秒) REOPEN_SETTLE_S = 1.5 # 重开端口退避(CH340 释放后立即重开的 EOF 陷阱) -DEFAULT_TIMEOUT_S = 5.0 # CLI→daemon 硬超时(秒) +DEFAULT_TIMEOUT_S = 5.0 # CLI→daemon 硬超时(秒;wait-idle 未显式给时按 --max-ms 推导) +DAEMON_LOG_MAX = 1024 * 1024 # daemon.log 超此体积 → 拉起新实例前只保尾部 +DAEMON_LOG_KEEP = 256 * 1024 # ---------------------------------------------------------------- 配置 / 元数据 def load_config(): - cfg = {"portName": "", "baudRate": 115200, "httpPort": DEFAULT_HTTP_PORT} + """读唯一配置源 .opencode/serial_config.json。 + + 编码用 utf-8-sig(与 run_boot_test.py 同口径):纯 utf-8 遇 BOM 会让 json.load + 抛错,旧实现再把它静默吞掉 → 端口/波特率全回退默认 → 报 "no port specified", + 把配置问题误导成端口问题(2026-10-09 修)。解析失败不再静默:错误文本放进 + configError,由 status / open / ports 显式回显。 + """ + cfg = {"portName": "", "baudRate": 115200, "httpPort": DEFAULT_HTTP_PORT, + "configError": ""} try: - with open(CONFIG_FILE, "r", encoding="utf-8") as f: + with open(CONFIG_FILE, "r", encoding="utf-8-sig") as f: raw = json.load(f) - cfg["portName"] = str(raw.get("portName", "")) + except FileNotFoundError: + cfg["configError"] = "config file not found: %s" % CONFIG_FILE + return cfg + except OSError as e: + cfg["configError"] = "cannot read config %s: %s" % (CONFIG_FILE, e) + return cfg + except ValueError as e: + cfg["configError"] = "bad JSON in %s: %s" % (CONFIG_FILE, e) + return cfg + if not isinstance(raw, dict): + cfg["configError"] = "config root is not an object: %s" % CONFIG_FILE + return cfg + cfg["portName"] = str(raw.get("portName", "")) + try: cfg["baudRate"] = int(raw.get("baudRate", 115200)) cfg["httpPort"] = int(raw.get("httpPort", DEFAULT_HTTP_PORT)) - except (OSError, ValueError): - pass + except (TypeError, ValueError) as e: + cfg["configError"] = "bad baudRate/httpPort in %s: %s" % (CONFIG_FILE, e) return cfg @@ -356,7 +381,10 @@ class Daemon: p = cmd.params name = p.get("port") or self.cfg.get("portName") or "" if not name: - cmd.finish({"ok": False, "error": "no port specified (and no portName in config)"}) + err = "no port specified (and no portName in config)" + if self.cfg.get("configError"): + err += " [config: %s]" % self.cfg["configError"] + cmd.finish({"ok": False, "error": err}) return baud = int(p.get("baud") or self.cfg.get("baudRate") or 115200) dtr = bool(p.get("dtr", False)) @@ -483,29 +511,49 @@ class Daemon: size = LOG_FILE.stat().st_size if LOG_FILE.exists() else 0 return {"ok": True, "daemon": {"pid": os.getpid(), "version": VERSION, - "uptime_s": int(time.time() - self.born)}, + "uptime_s": int(time.time() - self.born), + "exclusive_bind": getattr(self.server, "exclusive_bound", False)}, "port": port, "counters": counters, "last_rx": last, "log": {"path": str(LOG_FILE), "size": size, "gen": gen}, - "state": state, "error": err} + "state": state, "error": err, + "config_error": self.cfg.get("configError") or None} - def wait_idle(self, settle_s, max_s): + def wait_idle(self, settle_s, max_s, first_timeout_s=None): + """等 RX 静默,返回 reason ∈ {idle, no_data, timeout}。 + + 2026-10-09 修三处(旧实现会让 @test 在"板子全程没输出"时误判收尾): + 1. no_data 判据:旧实现比的是"进入函数时 daemon 累计 rx_bytes 是否为 0"。 + daemon 常驻(本机实测 rx_bytes=1.2MB)→ 永远 >0 → 板子一个字节都不发 + 也返回 idle:true,@test 的 no_data 拦截形同虚设。现按**本次增量**判。 + 2. 首字节门:旧实现从进入即开始计静默时间,板子第一行比 settle_ms 来得 + 晚就提前收尾("开机日志缺尾巴"的另一半成因)。现首字节到达前不启动 + settle 计时,改由 first_timeout_s 兜底(默认 = max_s)。 + 3. 返回的 rx_bytes 改为**本次等待期间的增量**(旧实现回的是 daemon 累计 + 值,调用方打出来的 WAIT: rx_bytes=1246761 毫无意义)。 + """ t0 = time.time() - last = self.rx_bytes - last_change = t0 - while time.time() - t0 < max_s: - time.sleep(0.1) + initial = self.rx_bytes + last = initial + last_change = 0.0 # 0.0 = 本次尚未收到任何字节 + first_deadline = t0 + (first_timeout_s if first_timeout_s is not None else max_s) + reason = "timeout" + while True: + now = time.time() if self.rx_bytes != last: last = self.rx_bytes - last_change = time.time() - elif time.time() - last_change >= settle_s: + last_change = now + if last_change: + if now - last_change >= settle_s: + reason = "idle" + break + elif now >= first_deadline: + reason = "no_data" break - if last == 0: - reason = "no_data" - elif time.time() - last_change >= settle_s: - reason = "idle" - else: - reason = "timeout" - return {"ok": True, "rx_bytes": last, "idle": reason == "idle", + if now - t0 >= max_s: + reason = "timeout" + break + time.sleep(0.1) + return {"ok": True, "rx_bytes": last - initial, "idle": reason == "idle", "waited_ms": int((time.time() - t0) * 1000), "reason": reason} # -- 生命周期 -- @@ -540,18 +588,32 @@ class _ExclusiveHTTPServer(ThreadingHTTPServer): socket 绑同一端口(Linux 不会),会致多 daemon 并存、请求随机分发,必须禁用。""" allow_reuse_address = False + exclusive_bound = False def server_bind(self): - import socket as _socket if os.name == "nt": - # SO_EXCLUSIVEADDRPLACE = 0x0040;部分发行版 socket 模块未导出,用数值兜底 - excl = getattr(_socket, "SO_EXCLUSIVEADDRPLACE", 0x0040) + # 真名是 SO_EXCLUSIVEADDRUSE(值 -5 = ~SO_REUSEADDR)。旧代码写成 + # "SO_EXCLUSIVEADDRPLACE" 并用数值 0x0040 兜底:属性不存在 → 取到 0x40 + # → setsockopt 抛 WinError 10042(无效选项)→ 被 except 静默吞掉 + # → 注释承诺的"独占绑定"从未真正生效(2026-10-09 实测复现)。 + excl = getattr(socket, "SO_EXCLUSIVEADDRUSE", -5) try: - self.socket.setsockopt(_socket.SOL_SOCKET, excl, 1) - except (OSError, AttributeError): - pass # 退回 allow_reuse_address=False(Windows 同样拒绝二次绑定) + self.socket.setsockopt(socket.SOL_SOCKET, excl, 1) + _ExclusiveHTTPServer.exclusive_bound = True + except OSError as e: + print("serial_tool: SO_EXCLUSIVEADDRUSE failed (%s); falling back " + "to allow_reuse_address=False" % e, file=sys.stderr, flush=True) super().server_bind() + def handle_error(self, request, client_address): + """CLI 硬超时先退出会掐断连接 → socketserver 默认往 daemon.log 打整段 + traceback(本机实测 16 次 ConnectionAbortedError)。客户端已断开不是 + daemon 故障,静音处理。""" + exc = sys.exc_info()[1] + if isinstance(exc, ConnectionError): + return + super().handle_error(request, client_address) + class _Handler(BaseHTTPRequestHandler): daemon = None # 由 Daemon.run 注入 @@ -623,7 +685,9 @@ class _Handler(BaseHTTPRequestHandler): body = self._body() settle = max(0.5, float(body.get("settle_ms", 8000)) / 1000.0) mx = max(1.0, float(body.get("max_ms", 30000)) / 1000.0) - self._send(200, d.wait_idle(settle, mx)) + ft = body.get("first_timeout_ms") + ft = None if ft is None else max(1.0, float(ft) / 1000.0) + self._send(200, d.wait_idle(settle, mx, ft)) elif self.path == "/stop": cmd = Cmd("close") d.cmd_q.put(cmd) @@ -664,8 +728,31 @@ def http_request(method, path, body=None, timeout=DEFAULT_TIMEOUT_S): raise ToolError("daemon HTTP %d (non-JSON body)" % e.code, 2) +def _rotate_daemon_log(): + """daemon.log 超 DAEMON_LOG_MAX → 原地只保尾部 DAEMON_LOG_KEEP。 + + 不 rename(Windows 下老 daemon 可能还持有句柄,rename 会让它继续往旧 inode 写); + 只在拉起新实例前做,写入口径不变。旧实现无上限:daemon 反复重启 + 断连 + traceback 会把它无限撑大。 + """ + try: + size = DAEMON_LOG.stat().st_size + if size <= DAEMON_LOG_MAX: + return + with open(DAEMON_LOG, "r+b") as f: + f.seek(size - DAEMON_LOG_KEEP) + tail = f.read() + f.seek(0) + os.ftruncate(f.fileno(), 0) + f.write(b"[daemon.log rotated: kept last %dKB]\n" % (DAEMON_LOG_KEEP // 1024)) + f.write(tail) + except OSError: + pass + + def spawn_daemon(): STATE_DIR.mkdir(parents=True, exist_ok=True) + _rotate_daemon_log() log_fh = open(DAEMON_LOG, "ab") kwargs = {} if os.name == "nt": @@ -695,12 +782,33 @@ def _try_kill(pid): return False +def _is_timeout_exc(exc): + """超时判定:urlopen 连接阶段超时会被包成 URLError(reason=socket.timeout), + 读响应阶段超时则直接抛 socket.timeout(3.10+ 即 TimeoutError)。""" + if isinstance(exc, (socket.timeout, TimeoutError)): + return True + return isinstance(getattr(exc, "reason", None), (socket.timeout, TimeoutError)) + + def call_daemon(args, method, path, body=None): - """调 daemon;连接失败时自动拉起并重试一次。""" + """调 daemon;**连接被拒/不可达**时自动拉起并重试一次。 + + 2026-10-10 修:超时不再当"daemon 不可达"。旧实现把超时和拒连塞进同一个 + except → 任何耗时超过 --timeout 的命令(典型:wait-idle)都会误判 daemon 已死、 + 额外拉起一个 daemon(新实例绑不上 HTTP 口 → 打 "HTTP port busy" 后退出; + daemon.log 里 36 条 listening 大半是这么攒出来的),再把原命令撞一次超时。 + 现在超时直接快速失败并提示调大 --timeout;只有真·不可达才走自动拉起。 + """ timeout = getattr(args, "timeout", DEFAULT_TIMEOUT_S) try: return http_request(method, path, body, timeout) + except (socket.timeout, TimeoutError) as e: + raise ToolError("daemon did not answer within %.1fs (busy? raise --timeout): %s" + % (timeout, e), 2) except (urllib.error.URLError, OSError) as e: + if _is_timeout_exc(e): + raise ToolError("daemon did not answer within %.1fs (busy? raise --timeout): %s" + % (timeout, getattr(e, "reason", e)), 2) if args.command == "stop": killed = False pid = load_meta().get("pid") @@ -725,7 +833,9 @@ def call_daemon(args, method, path, body=None): def cmd_ports(args): ports = [{"name": p.device, "description": p.description, "hwid": p.hwid} for p in serial.tools.list_ports.comports()] - return {"ok": True, "ports": ports, "configured": load_config().get("portName", "")} + cfg = load_config() + return {"ok": True, "ports": ports, "configured": cfg.get("portName", ""), + "config_error": cfg.get("configError") or None} def cmd_open(args): @@ -761,16 +871,39 @@ def cmd_status(args): def cmd_log(args): + """读日志:**先过滤、后截尾**。 + + 旧实现先 tail 后 grep → `log --grep "App init OK"`(--tail 默认 50)只在末 50 行 + 里找,开机标记必然查无此 marker——这就是 AGENTS.md 里"log --grep 实测不可信"的 + 根因(2026-10-09 实测复现并修复)。返回带 scanned/matched 计数,便于判定。 + """ gen = int(load_meta().get("gen", 0)) if not LOG_FILE.exists(): return {"ok": True, "gen": gen, "lines": [], "note": "log file not created yet"} with open(LOG_FILE, "r", encoding="utf-8", errors="replace") as f: lines = f.read().splitlines() + scanned = len(lines) + matched = scanned + if args.grep: + if args.regex: + try: + pat = re.compile(args.grep, re.IGNORECASE if args.ignore_case else 0) + except re.error as e: + raise ToolError("bad --grep regex %r: %s" % (args.grep, e), 3) + lines = [l for l in lines if pat.search(l)] + elif args.ignore_case: + low = args.grep.lower() + lines = [l for l in lines if low in l.lower()] + else: + lines = [l for l in lines if args.grep in l] + matched = len(lines) if args.tail: lines = lines[-args.tail:] - if args.grep: - lines = [l for l in lines if args.grep in l] - return {"ok": True, "gen": gen, "lines": lines} + result = {"ok": True, "gen": gen, "lines": lines, + "scanned": scanned, "matched": matched} + if args.grep and args.tail and matched > len(lines): + result["note"] = "grep matched %d line(s), showing last %d (tail)" % (matched, len(lines)) + return result def cmd_clear(args): @@ -778,8 +911,16 @@ def cmd_clear(args): def cmd_wait_idle(args): - return call_daemon(args, "POST", "/wait-idle", - {"settle_ms": args.settle_ms, "max_ms": args.max_ms}) + body = {"settle_ms": args.settle_ms, "max_ms": args.max_ms} + if args.first_timeout_ms: + body["first_timeout_ms"] = args.first_timeout_ms + if not args.timeout_explicit: + # wait-idle 是同步阻塞的长操作:CLI 超时按等待上限自动推导。旧实现固定 5s + # 硬超时会在 daemon 还在等的时候先掐断 → CLI 报 exit 2、daemon 侧 + # ConnectionAbortedError traceback(本机 daemon.log 实测 16 次)。 + # AGENTS.md 抓开机日志铁律②"必须带 --timeout"就是这个坑的手工补丁。 + args.timeout = args.max_ms / 1000.0 + 5.0 + return call_daemon(args, "POST", "/wait-idle", body) def cmd_start(args, force=False): @@ -903,6 +1044,92 @@ def run_selftest(): except OSError: pass + # T5 wait_idle:no_data 判据 / 首字节门 / 增量字节(2026-10-09 回归锁) + class _Cnt: + def __init__(self, n): + self.rx_bytes = n + + r = Daemon.wait_idle(_Cnt(500000), 0.2, 0.6, 0.3) # 常驻 daemon:累计值非 0 + check("T5a 零字节判 no_data", r["reason"] == "no_data" and r["rx_bytes"] == 0 + and not r["idle"], repr(r)) + + p5 = _Cnt(100) + threading.Thread(target=lambda: (time.sleep(0.35), setattr(p5, "rx_bytes", 200)), + daemon=True).start() + r = Daemon.wait_idle(p5, 0.2, 2.0, 1.0) # 第一行比 settle 晚到 + check("T5b 首字节门(晚到的第一行仍收全)", + r["reason"] == "idle" and r["rx_bytes"] == 100 and r["waited_ms"] >= 500, repr(r)) + + p6 = _Cnt(0) + stop6 = threading.Event() + + def _spam(): + while not stop6.is_set(): + p6.rx_bytes += 10 + time.sleep(0.05) + + threading.Thread(target=_spam, daemon=True).start() + r = Daemon.wait_idle(p6, 0.3, 0.8) + stop6.set() + check("T5c 持续吐数判 timeout + 返回增量字节", + r["reason"] == "timeout" and r["rx_bytes"] > 0, repr(r)) + + # T6 log 命令:先 grep 后 tail(旧顺序即 AGENTS.md"log --grep 不可信"的根因) + saved_log, saved_meta = LOG_FILE, load_meta + fd2, lpath2 = tempfile.mkstemp(prefix="serial_tool_selftest_log_", suffix=".log") + os.close(fd2) + try: + body = "[RX] 2026-10-09T00:00:00.000+08:00 App init OK\n" + "\n".join( + "[RX] 2026-10-09T00:00:01.000+08:00 noise %d" % i for i in range(60)) + Path(lpath2).write_text(body, encoding="utf-8") + globals()["LOG_FILE"] = Path(lpath2) + globals()["load_meta"] = lambda: {"gen": 7} + + class _G: + tail = 50 + grep = "App init OK" + regex = False + ignore_case = False + + r = cmd_log(_G()) + check("T6a grep 先于 tail(末 50 行也查到开机标记)", + r["matched"] == 1 and len(r["lines"]) == 1 and "App init OK" in r["lines"][0], + "matched=%s lines=%d" % (r.get("matched"), len(r["lines"]))) + + class _R: + tail = 0 + grep = r"App init|noise 5[0-9]" + regex = True + ignore_case = False + + r = cmd_log(_R()) + check("T6b --regex 交替匹配", r["matched"] == 11, "matched=%s" % r.get("matched")) + + class _I: + tail = 5 + grep = "NOISE" + regex = False + ignore_case = True + + r = cmd_log(_I()) + check("T6c -i 忽略大小写 + tail 在过滤之后", + r["scanned"] == 61 and r["matched"] == 60 and len(r["lines"]) == 5, + "scanned=%s matched=%s lines=%d" % (r.get("scanned"), r.get("matched"), + len(r["lines"]))) + finally: + globals()["LOG_FILE"], globals()["load_meta"] = saved_log, saved_meta + try: + os.remove(lpath2) + except OSError: + pass + + # T7 CLI 超时/拒连区分(旧实现把超时也当"daemon 不可达"→ 误拉起新实例) + check("T7a 超时识别(裸抛 + URLError 包裹)", + _is_timeout_exc(socket.timeout("t")) + and _is_timeout_exc(urllib.error.URLError(socket.timeout("t")))) + check("T7b 拒连不当成超时(仍走自动拉起)", + not _is_timeout_exc(urllib.error.URLError(ConnectionRefusedError("refused")))) + width = 46 all_ok = True for name, ok, detail in results: @@ -917,8 +1144,9 @@ def run_selftest(): def build_parser(): common = argparse.ArgumentParser(add_help=False) - common.add_argument("--timeout", type=float, default=DEFAULT_TIMEOUT_S, - help="CLI->daemon 硬超时秒数(默认 %.0f)" % DEFAULT_TIMEOUT_S) + common.add_argument("--timeout", type=float, default=None, + help="CLI->daemon 硬超时秒数(默认 %.0f;wait-idle 未显式给时按 --max-ms 自动推导)" + % DEFAULT_TIMEOUT_S) parser = argparse.ArgumentParser( prog="serial_tool", @@ -944,8 +1172,10 @@ def build_parser(): sub.add_parser("status", parents=[common], help="全状态 JSON") p = sub.add_parser("log", parents=[common], help="读日志(直接读文件,daemon 死也能用)") - p.add_argument("--tail", type=int, default=50, help="取末 N 行(默认 50;0 = 全部)") - p.add_argument("--grep", help="仅含子串的行") + p.add_argument("--tail", type=int, default=50, help="过滤后取末 N 行(默认 50;0 = 全部)") + p.add_argument("--grep", help="只留匹配行(默认子串匹配;先过滤后 tail)") + p.add_argument("-i", "--ignore-case", action="store_true", help="grep 忽略大小写") + p.add_argument("--regex", action="store_true", help="grep 按 Python re 正则") sub.add_parser("clear", parents=[common], help="清空日志(代际 +1)") @@ -953,6 +1183,8 @@ def build_parser(): p.add_argument("--settle-ms", type=int, default=8000, help="静默判据毫秒(默认 8000:boot 有 3~4s 自然静默间隙)") p.add_argument("--max-ms", type=int, default=30000, help="最长等待毫秒(默认 30000)") + p.add_argument("--first-timeout-ms", type=int, default=0, + help="首字节超时毫秒(0 = 同 --max-ms):期间零字节即判 no_data") sub.add_parser("start", parents=[common], help="拉起 daemon(已在跑则直接返回)") sub.add_parser("stop", parents=[common], help="停止 daemon(关端口后退出)") @@ -977,6 +1209,11 @@ def main(argv=None): parser.print_help() return 3 + # --timeout 缺省解析:显式与否要留给 cmd_wait_idle(它按 --max-ms 自动推导) + args.timeout_explicit = args.timeout is not None + if args.timeout is None: + args.timeout = DEFAULT_TIMEOUT_S + try: if args.command == "selftest": return run_selftest()