修复串口助手6处采集缺陷:wait-idle no_data与首字节门、log先过滤后截尾、HTTP独占绑定修正、超时不再误拉daemon、配置BOM容错
This commit is contained in:
@@ -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 模拟器
|
||||
|
||||
+16
-6
@@ -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:<httpPort>`(默认 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 口独占绑定是否真生效 |
|
||||
|
||||
@@ -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 并存" |
|
||||
|
||||
@@ -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 口独占绑定修正(旧选项名/数值均无效,实际未生效) |
|
||||
|
||||
@@ -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] <ISO时间戳> " 前缀)
|
||||
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
|
||||
|
||||
@@ -17,9 +17,9 @@
|
||||
open / close 开(幂等)/ 关串口
|
||||
send <text> | --hex <hh> 发送 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()
|
||||
|
||||
Reference in New Issue
Block a user