串口助手补残行封顶与会话边界复位、截头按行对齐、open 保持 dtr/rts;判定加命中行/顺序/耗时/JSON 与产物清理
This commit is contained in:
@@ -16,7 +16,7 @@ STM32F407ZGTx @168MHz(HSE 8MHz → PLL M=4/N=168/P=2);**仅 Keil MDK-ARM v
|
||||
## 构建与验证
|
||||
|
||||
- `@build` = `& "MDK-ARM\build.bat"`(仓库根目录用**相对路径**);退出码 0 = 0 错误 0 警告,1 = 有警告或错误(查 `MDK-ARM/build_log.txt`),2 = `DTU_KEIL_UV4` 未设/无效(换机必设,见 `docs/环境与部署/本地工具路径环境变量方案.md`);UV4 路径经 `DTU_KEIL_UV4` 环境变量解析(**不再硬编码**;显式设置但无效会立即报错、不静默回退)
|
||||
- `@test` / `test/host/run_boot_test.py` 一键闭环(2026-09-20 起为纯 Python 标准库实现,替代 PowerShell 版;`& '.venv\Scripts\python.exe' test/host/run_boot_test.py`):开串口+清日志 → 烧录(`MDK-ARM/download.bat`,下载后 Reset&Run;快路径 `--flash-cmd "MDK-ARM\reset.bat flash"`)→ **固件身份戳**(从刚烧进去的 hex 里抠 `fw build: <DATE> <TIME>` 追加成 Required 标记)→ wait-idle(等 RX 静默 8s,超时 30s,首字节 12s 未到判 `no_data`)→ 提取日志 → **烧录回读校验**(`reset.bat cmp` = `-CmpFile`;不一致 = 本次运行作废 exit 2)→ `test/host/verify_log.py` 标记校验(含**启动锚点计数**:`reset cause:` 出现 0 次或 >1 次 = FAIL,`--max-boot-count` 调);退出码 0=PASS / 1=FAIL / 2=采集或烧录错误(含回读不一致)/ 3=用法/预检错误
|
||||
- `@test` / `test/host/run_boot_test.py` 一键闭环(2026-09-20 起为纯 Python 标准库实现,替代 PowerShell 版;`& '.venv\Scripts\python.exe' test/host/run_boot_test.py`):开串口+清日志 → 烧录(`MDK-ARM/download.bat`,下载后 Reset&Run;快路径 `--flash-cmd "MDK-ARM\reset.bat flash"`)→ **固件身份戳**(从刚烧进去的 hex 里抠 `fw build: <DATE> <TIME>` 追加成 Required 标记)→ wait-idle(等 RX 静默 8s,超时 30s,首字节 12s 未到判 `no_data`)→ 提取日志 → **烧录回读校验**(`reset.bat cmp` = `-CmpFile`;不一致 = 本次运行作废 exit 2)→ `test/host/verify_log.py` 标记校验(含**启动锚点计数**:`reset cause:` 出现 0 次或 >1 次 = FAIL,`--max-boot-count` 调;Forbidden 命中**逐行打印行号+内容**;boot_log 每行带 `[+Nms]` daemon 毫秒时基 → 可查标记顺序 `--require-order` 与开机耗时 `--done-marker`/`--max-boot-ms`,实测 `net init: OK` = **+537ms**);退出码 0=PASS / 1=FAIL / 2=采集或烧录错误(含回读不一致)/ 3=用法/预检错误
|
||||
- 默认校验标记:Required `App init OK` / `Network Init` / `net init: OK` + **自动追加的固件身份戳 `fw build: <__DATE__> <__TIME__>`**(`App/app/app_main.c` 开机打印,`run_boot_test.py` 从待烧 hex 里抠出该串当 Required——此前所有标记都是固定串,**旧镜像/错镜像照样全中**,"反复重下载、芯片一直是旧镜像"在自动闭环里不可见,2026-10-10 补;`--no-fw-check` 关闭);Forbidden `FAIL` / `ERROR` / `HardFault` / `[ERR]`(**四个 Fault 的崩溃日志都以 `[ERR]` 开头 → 一律判 FAIL**,这是刻意的:出现 Fault 即本次运行不通过);按模块定制标记(网络/存储/RTC/ADC/RS485/UI)见 `docs/测试说明/自动化测试流程.md`
|
||||
- ⚠️ 旧绝对路径 `D:\Code\DTU 程序\...` 出现即失效(仓库已搬迁),一律改用仓库相对路径
|
||||
- **下载/烧录铁律(2026-09-20 实测)**:走 `& "MDK-ARM\reset.bat" flash [文件]`(= `ST-LINK_CLI -c SWD -P <文件> -Rst -Run`),烧后 `-Rst -Run` **不可省略**——裸 `-P` 会报 `Programming Complete`,但目标核处于死循环/异常状态时写入并不落地(芯片内容不变;这就是 09/19~09/20 "反复重下载、芯片一直是旧镜像、二分法失效" 的根因);`.hex`/`.bin` 均可直烧(1MB ≈ 6.5s,快于 UV4 路径);只重启不烧录用 `reset.bat` 无参(= `-c SWD -Rst -Run`,本机 3 线接线唯一复位手段:nRST 未接、`-HardRst`/`RM=` 不可用)
|
||||
@@ -114,6 +114,7 @@ STM32F407ZGTx @168MHz(HSE 8MHz → PLL M=4/N=168/P=2);**仅 Keil MDK-ARM v
|
||||
- 命令/工作流/故障排查 `docs/测试说明/串口助手使用指南.md`;契约/daemon 行为/陷阱 `docs/测试说明/串口助手设计说明.md`
|
||||
- **抓开机日志铁律(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` 是本次增量而非累计
|
||||
- **采集卫生与查询面(2026-10-10 第三批)**:无换行残行 **64KB 封顶**(板子跑飞/烧录期的二进制垃圾旧实现无限攒内存,还会把后续每条正常行拼成一条读不出的长行);`open`/`close`/`clear` = **会话边界**,残行一并丢弃(旧实现里上一段被复位截断的半行会粘进新会话第一行,开机日志首行因此不可读);`cap_truncate` **按行边界**截头(旧实现按字节切 → 截出来的首行是半行,破坏"只含完整行"不变量);`open` 不带 `--dtr/--rts` = **保持当前线路状态**(旧实现把"没给"当 False,重开即静默拉低),显式给则现场应用并在 `note` 回显;`ports` 回 `configured_present` + `hint`(系统里只有一个候选才给,多候选不猜),`open` 的 `port not found` 也带 `[hint: ...]`;`status` 回 `rx_age_ms`
|
||||
- ⚠️ **看到两个 `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 启动时缓存配置)
|
||||
|
||||
|
||||
@@ -47,11 +47,11 @@
|
||||
|
||||
| 命令 | 作用 | 依赖 daemon |
|
||||
|---|---|---|
|
||||
| `ports` | 枚举 COM 口,标注配置目标口 | 否(本地执行) |
|
||||
| `open` | 开串口(**幂等**:同口已开 = 直接成功) | 是(未运行自动拉起) |
|
||||
| `ports` | 枚举 COM 口,标注配置目标口(`configured_present`;系统里只有**一个**候选时给 `hint`) | 否(本地执行) |
|
||||
| `open` | 开串口(**幂等**:同口已开 = 直接成功;不带 `--dtr/--rts` = **保持现状**,显式给则现场应用并在 `note` 里回显) | 是(未运行自动拉起) |
|
||||
| `close` | 关串口 | 是 |
|
||||
| `send` | 发 TX 字节(进日志) | 是 |
|
||||
| `status` | 全状态一条 JSON | 是 |
|
||||
| `status` | 全状态一条 JSON(含 `state`、`rx_age_ms` = 距最后一个 RX 字节的毫秒数,`null` = 这个 daemon 还没收过字节) | 是 |
|
||||
| `log` | 读日志文件(`--tail N` / `--grep P`) | **否**(直接读文件,daemon 死了也能用) |
|
||||
| `clear` | 清空日志 + 代际 +1 | 是 |
|
||||
| `wait-idle` | 等 RX 静默(@test 的收尾判据) | 是 |
|
||||
@@ -381,3 +381,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 口独占绑定是否真生效 |
|
||||
| 2026-10-10 | 第三批同步:① `ports` 加 `configured_present` + `hint`(换 USB 口/换机后 COM 号变了,系统里只有一个候选时直接给出口名;`open` 的 `port not found` 报错里也带 `[hint: ...]`);② `status` 加 `rx_age_ms`(判"板子是不是根本没在说话"不必自己减时间戳);③ `open` 幂等分支不再吞 `--dtr/--rts`——不带这俩参数时**保持当前 DTR/RTS**(旧实现把"没给"当 False,重开即静默拉低),显式给则现场应用并在 `note` 回显 `applied dtr=1` |
|
||||
|
||||
@@ -147,7 +147,7 @@ flowchart LR
|
||||
|---|---|
|
||||
| 方向标记 | `[RX]` 串口入 / `[TX]` Agent 发出 / `[SYS]` 工具事件(open/close/error/cleared/truncated) |
|
||||
| 时间戳 | 本地 ISO8601 含毫秒与偏移(`2026-09-15T10:30:03.445+08:00`);**行时间戳 = 该行首字节到达时刻** |
|
||||
| **完整行不变量** | 文件**只含完整行**:残行留 daemon 内存,`200ms` 无后续数据即冲刷成行(或 clear/close 时强制冲刷)→ Agent 读文件永不见半行 |
|
||||
| **完整行不变量** | 文件**只含完整行**:残行留 daemon 内存,`200ms` 无后续数据即冲刷成行(或 clear/close 时强制冲刷)→ Agent 读文件永不见半行。**两条补充(2026-10-10 第三批)**:① 无换行残行 **64KB 封顶**(`PARTIAL_MAX_BYTES`)——板子跑飞/烧录期吐二进制垃圾时旧实现会无限攒在内存里(长会话可到几十 MB),且后续每条正常行都被拼在这坨垃圾尾巴上变成不可读长行;超限即当一行落地(解码回退成 `HEX:`)并自动重新对齐换行。② `open` / `close` / `clear` 是**会话边界**,残行一并丢弃(`splitter_gen` 计数,串口线程下一轮循环生效)——旧实现残行跨会话留存,上一段末尾被复位截断的半行会粘进新会话第一行,开机日志首行因此不可读 |
|
||||
| 分行 | 按 `\n` 分(`\r` 剥除);纯二进制流(无换行)按 200ms 窗口成行 |
|
||||
| 解码回退 | UTF-8 → GBK → 仍失败则该行文本域写 `HEX:<原始字节 hex>`(MCU 流 = ASCII 为主 + UTF-8 中文,前两级足够) |
|
||||
| flush | 每行即 flush(串口速率低,无缓冲压力)→ 文件随时可读、内容即时可见 |
|
||||
@@ -159,7 +159,7 @@ flowchart LR
|
||||
|
||||
### 5.4 体积上限(20MB,用户确认)
|
||||
|
||||
- 每次写后检查:`size > 20MB` → **原地截头**:在同一写句柄上**读尾部 10MB → 截空 → 写回**(Windows 无"丢弃头部"单系统调用,`ftruncate` 只能截尾;单写者线程内执行,无并发风险)
|
||||
- 每次写后检查:`size > 20MB` → **原地截头**:在同一写句柄上**读尾部 10MB → 截空 → 写回**(Windows 无"丢弃头部"单系统调用,`ftruncate` 只能截尾;单写者线程内执行,无并发风险)。**按行边界对齐(2026-10-10)**:取到的尾部会丢掉首个换行之前的残片——旧实现按字节偏移切,截出来的**第一行是半行**,直接破坏"文件只含完整行"这条不变量(Grep / verify_log 读到的首行是垃圾)
|
||||
- **禁止** rename 轮转(Windows 下写句柄跟随被 rename 的文件)与重开文件/端口(CH340 释放后立即重开 → 读 0 字节 EOF 陷阱)
|
||||
- 截头后写 `[SYS] … log truncated to 10MB (cap=20MB)`;文件头内容可能随时消失 → **Agent 读日志应看尾部**(`log --tail` 或直接读文件末段)
|
||||
|
||||
@@ -271,3 +271,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 并存" |
|
||||
| 2026-10-10 | **第三批:采集卫生 + 查询面**(`selftest` 加 T8/T9/T10,23/23 PASS):① 无换行残行 **64KB 封顶**(旧实现二进制垃圾无限攒内存,且把后续正常行拼成一条读不出的长行);② `open`/`close`/`clear` 作为**会话边界**丢弃残行(`splitter_gen` 计数,串口线程下轮生效,避免跨线程直接改缓冲)——旧实现上一段会话被复位截断的半行会粘进新会话首行;③ `cap_truncate` **按行边界**截头(旧实现按字节切 → 截出来的首行是半行,破坏"只含完整行"不变量);④ `open` 幂等分支不再吞 `--dtr/--rts`:不带参数 = **保持当前 DTR/RTS**(旧实现把"没给"当 False,重开即静默拉低线路),显式给则现场应用并回显;⑤ `ports` 回 `configured_present`/`hint`(单一候选才给,多候选不猜),`open` 的 `port not found` 也带 `[hint: ...]`;⑥ `status` 回 `rx_age_ms` |
|
||||
|
||||
+16
-5
@@ -23,10 +23,10 @@
|
||||
| `@build` | `.opencode/command/build.md` | 仅编译验证(0 错误 0 警告) |
|
||||
| `@download` | `.opencode/command/download.md` | 编译并烧录(ST-Link) |
|
||||
| `@test` | `.opencode/command/test.md` | 完整闭环:build → capture+download → verify → iterate |
|
||||
| `test/host/run_boot_test.py` | `test/host/run_boot_test.py` | **一键闭环**:open + clear → 烧录 → 固件身份戳(从 hex 抠 `fw build:`)→ wait-idle(静默 8s/超时 30s/首字节 12s)→ 提取 `[RX]` → 回读校验(`reset.bat cmp`)→ verify,退出码 0=PASS/1=FAIL/2=采集·烧录·回读错误/3=用法错误 |
|
||||
| `test/host/run_boot_test.py` | `test/host/run_boot_test.py` | **一键闭环**:open + clear → 烧录 → 固件身份戳(从 hex 抠 `fw build:`)→ wait-idle(静默 8s/超时 30s/首字节 12s)→ 提取 `[RX]`(每行加 `[+Nms]` 时基)→ 回读校验(`reset.bat cmp`)→ verify;`--keep-out` 清旧产物;退出码 0=PASS/1=FAIL/2=采集·烧录·回读错误/3=用法错误 |
|
||||
| `MDK-ARM/reset.bat` | `MDK-ARM/reset.bat` | ST-LINK CLI 封装:`flash [hex]`(烧 + `-Rst -Run`)/ **`cmp [hex]`(回读比对,2026-10-10 加)**/ 无参复位 / `halt`/`run`/`list`/`hard` |
|
||||
| `tools/serial_tool/serial_tool.py` | `tools/serial_tool/` | Python 串口助手(常驻 daemon + 薄 CLI):ports/open/send/status/log/clear/wait-idle/selftest;全量日志 `state/serial.log`(20MB 自动截头) |
|
||||
| `test/host/verify_log.py` | `test/host/verify_log.py` | 对日志做标记校验(含**启动锚点计数** `--max-boot-count`、复位原因白名单 `--expect-reset-cause`),输出 PASS/FAIL 并返回退出码 |
|
||||
| `test/host/verify_log.py` | `test/host/verify_log.py` | 对日志做标记校验:Required/Forbidden(**命中逐行打印行号+内容**)、启动锚点计数 `--max-boot-count`、原因白名单 `--expect-reset-cause`、标记顺序 `--require-order`、开机耗时 `--done-marker`/`--max-boot-ms`、`--json` 结构化输出;退出码 0/1/2 |
|
||||
|
||||
## 判定标准
|
||||
|
||||
@@ -39,6 +39,13 @@
|
||||
- **固件身份戳(2026-10-10 加)**:`App/app/app_main.c` 开机打印 `fw build: <__DATE__> <__TIME__>`(编译期展开成一段**连续 ASCII**,`.hex` 里可原样搜到)。`run_boot_test.py` 从**待烧的 hex** 里抠出该串并追加成 Required 标记 → 芯片里跑的必须就是这份构建。反例实测:把 hex 里的戳改成 `Oct 9 2026 01:00:00` 再跑,其余标记全中但 `[MISS] fw build: Oct 9 2026 01:00:00` → FAIL——这正是过去"反复重下载、芯片一直是旧镜像"在闭环里**完全隐形**的场景。`--no-fw-check` 关闭;hex 里没有该串(旧构建)只 WARN 跳过、不假判。
|
||||
- **烧录回读(2026-10-10 加)**:采集结束后自动跑 `reset.bat cmp`(= `-CmpFile` 全片逐字节比对),判定按**行首** `No difference found`(进度条与帮助文本里也含该短语,故不能全文匹配);不一致 → `RUN_TEST_ERROR: flash readback mismatch` + 退出码 2(本次运行作废:日志是"某个未知镜像"跑出来的)。⚠ **顺序不能颠倒**——`-CmpFile` 连接时会复位目标(实测板子当场重打一遍开机日志)。`--no-verify-flash` 关闭。
|
||||
- **启动锚点计数(2026-10-10 加)**:`reset cause:` 在一次采集里出现 **0 次**(采集没从复位开始,标记全中也可能只是上一轮残留)或 **>1 次**(板子中途自己复位:IWDG 咬狗 / Fault 后 `NVIC_SystemReset` / 掉电重启)→ FAIL;`--max-boot-count N` 放宽(跑故意复位的套件时用),`--expect-reset-cause 'software;power-on'` 对第一个锚点做原因白名单(SWD 软复位实测为 `software (NVIC_SystemReset)`,CSR=0x14000000)。
|
||||
- **判定可读性 + 时序(2026-10-10 第三批)**:
|
||||
- **Forbidden 命中行**:命中时逐行打印 `L<行号>: <内容>`(最多 6 行,剩余报数)。旧实现只说 `forbidden pattern: [ERR] (3 line(s))`,还得人再 grep 一遍才知道是哪几行。
|
||||
- **`[+Nms]` 相对时基**:`run_boot_test.py` 给 boot_log 每行加行首 `[+Nms]`(daemon 毫秒时基,本次第一行 = +0ms;`--raw-log` 出纯载荷)。MCU 侧时间戳只有秒级、开机头几行还没时钟(`----/--/--`),量不了耗时。
|
||||
- **开机耗时**:`--done-marker 'net init: OK'`(默认)+ `--max-boot-ms N`。实测基线 **+537 ms**(2026-10-10 台架,`reset.bat flash` 后);默认 `--max-boot-ms 0` = 只报告不判,避免网络重试抖动造成假 FAIL。
|
||||
- **标记顺序**:`--require-order 'App init OK>Network Init>net init: OK'` 按首次出现的**行序**校验;标记全中但顺序颠倒(屏障失序 / 重试补跑)此前完全不可见。
|
||||
- **`--json`**:同一套判定以 JSON 输出(`required_missing` / `forbidden_hits[].lines` / `boot` / `timing` / `tail` / `verdict` / `exit`),退出码语义不变,供 CI 与上层脚本消费。
|
||||
- **产物保留**:`--keep-out N`(默认 20)自动清理 `--out-dir` 里的旧 `boot_log_*.txt` / `selftest_log_*.log`(本机曾堆到 31+7 份)。
|
||||
|
||||
### 自检结果判定(S7,`--require-selftest-pass`)
|
||||
|
||||
@@ -73,10 +80,13 @@
|
||||
| daemon 无响应 / 自动拉起失败 | 查 `tools/serial_tool/state/daemon.log`;`serial_tool.py restart` 重建 daemon;检查 COM 是否被其他程序占用 |
|
||||
| 抓到日志缺 `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 异常行 |
|
||||
| `[MISS] fw build: <戳>`(其余标记全中) | 芯片里跑的不是这份构建:烧录没落地 / 烧错文件 / hex 未重编译 | `reset.bat flash <hex>` 重烧并看 `READBACK: OK`;确认 `@build` 已重跑(戳取自编译时刻) |
|
||||
| `READBACK: FAIL` / `flash readback mismatch` | 芯片内容与 hex 逐字节不一致(写入未落地、选项字节/RDP 异常、烧错目标) | 按 `docs/测试说明/ST-LINK_CLI_复位使用说明.md` §10 排查;`reset.bat flash` 重烧后闭环会自动复核 |
|
||||
| `boot banner x2 > allowed 1` | 采集窗口内板子自己复位过(IWDG 咬狗 / Fault 后 `NVIC_SystemReset` / 掉电重启) | 看判定里逐条打印的 `reset cause:` 定位;跑**故意复位**的套件用 `--max-boot-count N` 放宽 |
|
||||
| `[MISS] fw build: <戳>`(其余标记全中) | 芯片里跑的不是这份构建:烧录没落地 / 烧错文件 / hex 未重编译。处置:`reset.bat flash <hex>` 重烧并看 `READBACK: OK`;确认 `@build` 已重跑(戳取自编译时刻) |
|
||||
| `READBACK: FAIL` / `flash readback mismatch` | 芯片内容与 hex 逐字节不一致(写入未落地、选项字节/RDP 异常、烧错目标)。处置:按 `docs/测试说明/ST-LINK_CLI_复位使用说明.md` §10 排查;`reset.bat flash` 重烧后闭环会自动复核 |
|
||||
| `boot banner x2 > allowed 1` | 采集窗口内板子自己复位过(IWDG 咬狗 / Fault 后 `NVIC_SystemReset` / 掉电重启)。处置:看判定里逐条打印的 `reset cause:` 定位;跑**故意复位**的套件用 `--max-boot-count N` 放宽 |
|
||||
| `marker order violated` | 标记全中但**先后顺序颠倒** = 某步在另一步之后补做(就绪屏障失序 / 失败重试补跑)。处置:看判定打印的各标记首次行号定位;`--require-order` 只在需要强约束时启用 |
|
||||
| `boot time N ms > limit` | 能开机但慢(mount 重试 / PHY 重协商 / 复位重试)。实测基线:`net init: OK` 在 **+537ms**(2026-10-10 台架);默认只报告不判(`--max-boot-ms 0`),要设门禁再给预算 |
|
||||
| 端口被占用(`port busy`) | 先 `serial_tool.py close` 或 `restart`;检查其他程序占用;Keil ST-Link 不占 COM |
|
||||
| 配置里的 COM 号失效(换口/换机) | `serial_tool.py ports` 现回 `configured_present` + `hint`(系统里只有一个候选时直接给出口名),`open` 的报错里也带 `[hint: ...]`;确认后只改 `.opencode/serial_config.json` |
|
||||
| 日志中无 UI 相关输出 | 见下节「UI 自动化」 |
|
||||
| 日志出现 `[ERR]` 行 | 固件错误级日志(`DBG_ERROR`),默认 Forbidden 会判 FAIL:按 TAG 查对应模块的 `docs/问题记录/*_Trap_Records.md`;若属测试套件的**预期**错误(守卫负路径、非法名校验等),用 `--forbidden` 放宽 |
|
||||
|
||||
@@ -111,3 +121,4 @@
|
||||
| 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 口独占绑定修正(旧选项名/数值均无效,实际未生效) |
|
||||
| 2026-10-10 | **第二批:闭环补上"烧进去的是哪份镜像"与"跑的过程中有没有偷偷复位"**:① 固件身份戳——`App/app/app_main.c` 开机打印 `fw build: <__DATE__> <__TIME__>`,`run_boot_test.py` 从待烧 hex 里抠出该串追加成 Required 标记(旧镜像/错镜像此前所有固定标记照样全中);② 烧录回读校验进闭环——`reset.bat` 新增 `cmp` 子命令(`-CmpFile` 全片比对),采集后自动跑,不一致 exit 2;实测 `-CmpFile` 连接时会复位目标,故必须排在采集之后;③ 启动锚点计数——`verify_log.py` 新增 `--max-boot-count`/`--boot-marker`/`--expect-reset-cause`,`reset cause:` 出现 0 次或 >1 次判 FAIL;④ 修 `--flash-cmd` 带参数时预检必 missing、以及 `cmd /c` 对含空格路径(本仓库目录名 `DTU Code`)的引号解析把回读假判成"芯片不一致"(改为直接 CreateProcess 跑 .bat)。真机验证:完整烧录闭环 PASS(含戳匹配 + `No difference found` + 锚点 x1);伪造戳 → 其余标记全中仍 FAIL |
|
||||
| 2026-10-10 | **第三批:判定可读性 + 时序 + 采集卫生**:① Forbidden 命中逐行打印行号+内容(旧只报条数);② boot_log 每行加 `[+Nms]` daemon 毫秒时基(`--raw-log` 关闭),据此量开机耗时(实测 `net init: OK` = **+537ms**;`--max-boot-ms` 默认 0 只报告);③ `--require-order` 标记先后顺序校验(全中但失序此前不可见);④ `--json` 结构化判定(退出码不变);⑤ `--keep-out`(默认 20)清理 out 目录旧产物(本机曾堆到 31+7 份);⑥ daemon 侧:无换行残行 64KB 封顶 + open/close/clear 会话边界丢弃残行 + 截头按行边界对齐(旧实现截出来的首行是半行)+ `open` 不带 `--dtr/--rts` 时保持现状、显式给则现场应用;⑦ `ports` 回 `configured_present`/`hint`,`status` 回 `rx_age_ms`。selftest 23/23、verify_log 离线回归 17/17、真机闭环 PASS |
|
||||
|
||||
@@ -8,12 +8,15 @@
|
||||
标记——芯片里跑的必须就是这份构建(--no-fw-check 关闭)
|
||||
3. wait-idle:RX 静默 >= settle-ms 收尾;首字节超过 --first-wait-ms 未到 → no_data
|
||||
(快速失败);整体 max-wait-ms 超时
|
||||
4. 从日志提取 [RX] 载荷行(剥掉 "[RX] <ISO时间戳> " 前缀)
|
||||
4. 从日志提取 [RX] 载荷行(剥掉 "[RX] <ISO时间戳> " 前缀),并给每行加 `[+Nms]` 相对
|
||||
偏移(daemon 毫秒时基,本次第一行 = +0ms;--raw-log 出纯载荷)。MCU 侧时间戳只有
|
||||
秒级、开机头几行还没时钟,量不了耗时——这个前缀就是耗时口径
|
||||
4.5 烧录回读校验:MDK-ARM\\reset.bat cmp(= ST-LINK_CLI -CmpFile 全片逐字节比对,
|
||||
`No difference found` 才算真写进去;--no-verify-flash 关闭)。
|
||||
**必须排在采集之后**:实测 -CmpFile 连接时会复位目标(板子当场重打一遍开机日志)
|
||||
5. verify_log.py 标记校验(含启动锚点计数:`reset cause:` 出现 0 次或 >N 次 = FAIL,
|
||||
N 由 --max-boot-count 给,默认 1;--expect-reset-cause 可对第一个锚点做原因白名单)
|
||||
N 由 --max-boot-count 给,默认 1;--expect-reset-cause 可对第一个锚点做原因白名单;
|
||||
--require-order 查标记先后顺序;--done-marker + --max-boot-ms 量开机耗时)
|
||||
6. (仅当 --require-selftest-pass)自检记录联合判定(S7):
|
||||
--selftest-log 提供本地副本则直接用它,否则从设备 FTP 抓取 /log/selftest.log
|
||||
(lftpd :21 匿名);判定:文件缺失 / 0 字节 = 无故障 → PASS,有 64B 记录 = FAIL
|
||||
@@ -41,6 +44,9 @@
|
||||
[--fw-hex <构建产物 hex>] [--no-fw-check]
|
||||
[--no-verify-flash] [--readback-timeout 120]
|
||||
[--max-boot-count 1] [--expect-reset-cause 'software;power-on']
|
||||
[--require-order 'App init OK>Network Init>net init: OK']
|
||||
[--done-marker 'net init: OK'] [--max-boot-ms 0]
|
||||
[--raw-log] [--keep-out 20]
|
||||
|
||||
--port 缺省取 .opencode/serial_config.json 的 portName(单一配置源)
|
||||
|
||||
@@ -166,6 +172,26 @@ def _ftp_quit(ftp):
|
||||
pass
|
||||
|
||||
|
||||
def _prune_out_dir(out_dir, keep):
|
||||
"""只保留最近 N 份产物(`boot_log_*.txt` / `selftest_log_*.log`)。
|
||||
|
||||
这些文件按 `%Y%m%d_%H%M%S` 命名、每次 @test 各留一份,旧实现从不清理——
|
||||
本机实测 out 目录已堆到 31 份 boot_log + 7 份 selftest_log(2026-10-10)。
|
||||
文件名含时间戳 → 按名字排序即按时间排序。
|
||||
"""
|
||||
if keep <= 0:
|
||||
return 0
|
||||
removed = 0
|
||||
for pat in ("boot_log_*.txt", "selftest_log_*.log"):
|
||||
for p in sorted(out_dir.glob(pat), key=lambda q: q.name)[:-keep]:
|
||||
try:
|
||||
p.unlink()
|
||||
removed += 1
|
||||
except OSError:
|
||||
pass
|
||||
return removed
|
||||
|
||||
|
||||
def _fetch_selftest_log(device_ip, out_dir, stamp):
|
||||
"""从设备 FTP 抓取 /log/selftest.log(lftpd :21 匿名,根目录 = 卷根)。
|
||||
|
||||
@@ -258,6 +284,20 @@ def main(argv=None):
|
||||
ap.add_argument("--expect-reset-cause", default="",
|
||||
help="';'-separated substrings the FIRST reset cause must match "
|
||||
"(case-insensitive); empty = report only, no verdict")
|
||||
ap.add_argument("--require-order", default="",
|
||||
help="'>'-separated markers that must appear in that line order "
|
||||
"(catches out-of-order boot steps); empty = off")
|
||||
ap.add_argument("--done-marker", default="net init: OK",
|
||||
help="marker whose line marks boot completion (for --max-boot-ms); "
|
||||
"empty = don't measure")
|
||||
ap.add_argument("--max-boot-ms", type=int, default=0,
|
||||
help="fail if time from first line to --done-marker exceeds N ms "
|
||||
"(0 = measure and report only)")
|
||||
ap.add_argument("--raw-log", action="store_true",
|
||||
help="write payload-only boot log (no '[+Nms]' relative-time prefix)")
|
||||
ap.add_argument("--keep-out", type=int, default=20,
|
||||
help="keep the newest N boot_log_*/selftest_log_* files in --out-dir "
|
||||
"(0 = keep everything)")
|
||||
args = ap.parse_args(argv)
|
||||
|
||||
# 串口助手 CLI 用当前解释器(AGENTS.md 统一入口 = 仓库 .venv python)
|
||||
@@ -289,6 +329,10 @@ def main(argv=None):
|
||||
|
||||
out_dir = Path(args.out_dir)
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
pruned = _prune_out_dir(out_dir, args.keep_out)
|
||||
if pruned:
|
||||
print("OUT: pruned {} old artifact file(s) in {} (keep={})".format(
|
||||
pruned, out_dir, args.keep_out))
|
||||
|
||||
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
boot_txt = out_dir / "boot_log_{}.txt".format(stamp)
|
||||
@@ -371,21 +415,44 @@ def main(argv=None):
|
||||
return 2
|
||||
|
||||
# 5. 提取 [RX] 载荷行(剥掉 "[RX] <ISO时间戳> " 前缀)
|
||||
# 默认给每行加 `[+Nms]` 相对偏移(daemon 毫秒时基,本次采集第一行 = +0ms)。
|
||||
# 为什么加:MCU 侧时间戳只有**秒级**,且开机头几行时钟还没起(`----/--/--`),
|
||||
# 根本量不了耗时;有了它 verify_log 才能查"能开机但慢了几秒"(--max-boot-ms)。
|
||||
# --raw-log 恢复旧格式(纯载荷)。
|
||||
data, out = tool("log", "--tail", "0", "--timeout", "15")
|
||||
if data is None:
|
||||
print("RUN_TEST_ERROR: log read failed: {}".format(out))
|
||||
return 2
|
||||
rx = []
|
||||
t0 = None
|
||||
for l in data.get("lines", []):
|
||||
if isinstance(l, str) and l.startswith("[RX] "):
|
||||
rest = l[5:]
|
||||
sp = rest.find(" ")
|
||||
rx.append(rest[sp + 1:] if sp >= 0 else rest)
|
||||
if not (isinstance(l, str) and l.startswith("[RX] ")):
|
||||
continue
|
||||
rest = l[5:]
|
||||
sp = rest.find(" ")
|
||||
ts_txt = rest[:sp] if sp >= 0 else ""
|
||||
payload = rest[sp + 1:] if sp >= 0 else rest
|
||||
if args.raw_log:
|
||||
rx.append(payload)
|
||||
continue
|
||||
ts = None
|
||||
if ts_txt:
|
||||
try:
|
||||
ts = datetime.fromisoformat(ts_txt)
|
||||
except ValueError:
|
||||
ts = None
|
||||
if ts is None:
|
||||
rx.append(payload) # 时间戳读不出来就不加前缀(不猜)
|
||||
continue
|
||||
if t0 is None:
|
||||
t0 = ts
|
||||
rx.append("[+{}ms] {}".format(int((ts - t0).total_seconds() * 1000), payload))
|
||||
if not rx:
|
||||
print("RUN_TEST_ERROR: no [RX] lines in log (device silent?)")
|
||||
return 2
|
||||
boot_txt.write_bytes(("\r\n".join(rx) + "\r\n").encode("utf-8"))
|
||||
print("Boot log: {} (lines={} gen={})".format(boot_txt, len(rx), data.get("gen")))
|
||||
print("Boot log: {} (lines={} gen={} ts={})".format(
|
||||
boot_txt, len(rx), data.get("gen"), "raw" if args.raw_log else "+ms"))
|
||||
|
||||
# 5.6 烧录回读校验(AGENTS.md 铁律:`No difference found` 才算真写进去)。
|
||||
# 必须排在**采集之后**:实测 -CmpFile 连接时会复位目标(板子当场重打一遍开机
|
||||
@@ -422,7 +489,10 @@ def main(argv=None):
|
||||
selftest_pass=args.require_selftest_pass,
|
||||
selftest_log=selftest_local,
|
||||
max_boot_count=args.max_boot_count,
|
||||
expect_reset_cause=args.expect_reset_cause)
|
||||
expect_reset_cause=args.expect_reset_cause,
|
||||
require_order=args.require_order,
|
||||
done_marker=args.done_marker,
|
||||
max_boot_ms=args.max_boot_ms)
|
||||
print("RUN_TEST_RC={}".format(rc))
|
||||
return rc
|
||||
|
||||
|
||||
+159
-15
@@ -13,6 +13,8 @@
|
||||
[--show-tail-lines 40]
|
||||
[--max-boot-count 1] [--boot-marker 'reset cause:']
|
||||
[--expect-reset-cause 'software;power-on']
|
||||
[--require-order 'App init OK>Network Init>net init: OK']
|
||||
[--done-marker 'net init: OK'] [--max-boot-ms 8000] [--json]
|
||||
|
||||
退出码(与旧版一致):
|
||||
0 = PASS(所有 Required 标记找到,Forbidden 零命中,汇总行要求满足)
|
||||
@@ -49,9 +51,20 @@
|
||||
的残留日志);**>N 次** = 板子在采集窗口内自己复位过(IWDG 咬狗 / Fault 后
|
||||
`NVIC_SystemReset` / 掉电重启),此前这类运行只要不命中 Forbidden 就判 PASS。
|
||||
`--expect-reset-cause` 对第一个锚点做原因白名单(大小写不敏感子串),空 = 只报告不判。
|
||||
- Forbidden 命中行(2026-10-10):命中时逐行打印**行号 + 内容**(最多 6 行,超出报剩余数)。
|
||||
旧实现只说 `forbidden pattern: [ERR] (3 line(s))`,人还得自己再 grep 一遍才知道是哪几行
|
||||
——而"是哪几行"恰恰是定位要的东西。
|
||||
- 标记顺序(`--require-order 'a>b>c'`):按**首次出现的行序**校验启动步骤先后。标记全中
|
||||
但顺序颠倒 = 某步在另一步之后补做(屏障失序、重试后补跑),旧实现完全看不见。
|
||||
- 开机耗时(`--done-marker` + `--max-boot-ms`):依赖行首 `[+Nms]` 前缀(run_boot_test.py
|
||||
2026-10-10 起按 daemon 毫秒时基给每行加,MCU 侧时间戳只有秒级且开机头几行根本没有)。
|
||||
量"第一行 → done-marker"的真实耗时,抓"能开机但慢了几秒"(mount 重试 / PHY 重协商)。
|
||||
日志里没有该前缀 → 只 WARN 跳过,不假判 FAIL。
|
||||
- `--json`:同一套判定以 JSON 输出(供 CI / 上层脚本消费;退出码语义不变)。
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import locale
|
||||
import re
|
||||
import sys
|
||||
@@ -60,6 +73,13 @@ from pathlib import Path
|
||||
REQUIRED_DEFAULT = "App init OK;Network Init;net init: OK"
|
||||
FORBIDDEN_DEFAULT = "FAIL;ERROR;HardFault;assert;[ERR]"
|
||||
|
||||
# Forbidden 命中时最多打印几条命中行(够定位又不刷屏;剩余条数会报出来)
|
||||
HIT_LINES_MAX = 6
|
||||
|
||||
# 行首 `[+1234ms] `:run_boot_test.py 按 daemon 毫秒时基给每行加的相对偏移
|
||||
# (MCU 侧时间戳只有秒级,且开机头几行时钟还没起 → 量不了耗时)
|
||||
_TS_OFF_RE = re.compile(r"^\[\+(\d+)ms\]\s")
|
||||
|
||||
# 每轮启动的锚点行(App/app/app_main.c:`reset cause: <原因> (CSR=0x…)`)。
|
||||
# 一次采集里出现 >1 次 = 板子在采集窗口内自己复位过(IWDG 咬狗 / Fault 后
|
||||
# NVIC_SystemReset / 掉电重启),此前只有 Forbidden 命中时才被发现,
|
||||
@@ -104,7 +124,8 @@ def verify(log_path, required=REQUIRED_DEFAULT, forbidden=FORBIDDEN_DEFAULT,
|
||||
show_tail=40, out=print, suite_summary="",
|
||||
selftest_pass=False, selftest_log="",
|
||||
boot_marker=BOOT_MARKER_DEFAULT, max_boot_count=0,
|
||||
expect_reset_cause=""):
|
||||
expect_reset_cause="", require_order="", done_marker="",
|
||||
max_boot_ms=0, as_json=False):
|
||||
"""标记校验核心(可被 run_boot_test.py 复用)。返回退出码 0/1/2。
|
||||
|
||||
suite_summary: ';' 分隔的套件名列表。非空时,要求日志中每个套件都出现
|
||||
@@ -117,16 +138,37 @@ def verify(log_path, required=REQUIRED_DEFAULT, forbidden=FORBIDDEN_DEFAULT,
|
||||
在采集窗口内自己复位过(IWDG / Fault 后 NVIC_SystemReset)。
|
||||
expect_reset_cause: ';' 分隔的原因子串白名单,只对**第一个**锚点生效(大小写不敏感)。
|
||||
空 = 只报告不判定。
|
||||
require_order: '>' 分隔的标记序列(2026-10-10),按**首次出现的行序**校验先后;
|
||||
顺序颠倒 = 某步在另一步之后补做(屏障失序 / 重试补跑)。空 = 不查。
|
||||
done_marker / max_boot_ms(2026-10-10):量"第一行 → done_marker"的毫秒耗时
|
||||
(靠行首 `[+Nms]` 前缀);超过 max_boot_ms 判 FAIL。
|
||||
日志无该前缀 → WARN 跳过,不假判。
|
||||
as_json: True = 不打印文本判定,最后输出一个 JSON 对象(退出码不变)。
|
||||
"""
|
||||
res = {"log_path": str(log_path), "lines": 0, "bytes": 0, "required_missing": [],
|
||||
"forbidden_hits": [], "boot": None, "suites_bad": [], "selftest": None,
|
||||
"order_bad": [], "timing": None, "tail": [], "verdict": None, "exit": None}
|
||||
emit = out
|
||||
|
||||
def finish(code):
|
||||
res["exit"] = code
|
||||
res["verdict"] = {0: "PASS", 1: "FAIL", 2: "ERROR"}[code]
|
||||
if as_json:
|
||||
emit(json.dumps(res, ensure_ascii=False, indent=2))
|
||||
return code
|
||||
|
||||
if as_json:
|
||||
out = lambda *a, **k: None # 文本判定静默,结果走 JSON
|
||||
|
||||
p = Path(log_path)
|
||||
if not p.is_file():
|
||||
out("VERIFY_ERROR: log file not found: {}".format(log_path))
|
||||
return 2
|
||||
return finish(2)
|
||||
try:
|
||||
lines = _read_lines(log_path)
|
||||
except OSError:
|
||||
out("VERIFY_ERROR: cannot read log file: {}".format(log_path))
|
||||
return 2
|
||||
return finish(2)
|
||||
|
||||
full = "\n".join(lines)
|
||||
req_list = _split_list(required)
|
||||
@@ -135,6 +177,8 @@ def verify(log_path, required=REQUIRED_DEFAULT, forbidden=FORBIDDEN_DEFAULT,
|
||||
|
||||
out("=== Log verdict: {} ===".format(log_path))
|
||||
out("Lines: {}, bytes: {}".format(len(lines), p.stat().st_size))
|
||||
res["lines"] = len(lines)
|
||||
res["bytes"] = p.stat().st_size
|
||||
|
||||
missing = []
|
||||
for m in req_list:
|
||||
@@ -143,14 +187,25 @@ def verify(log_path, required=REQUIRED_DEFAULT, forbidden=FORBIDDEN_DEFAULT,
|
||||
else:
|
||||
out("[MISS] required marker NOT found: {}".format(m))
|
||||
missing.append(m)
|
||||
res["required_missing"] = list(missing)
|
||||
|
||||
hits = []
|
||||
for f in forb_list:
|
||||
fl = f.lower()
|
||||
n = sum(1 for ln in lines if fl in ln.lower())
|
||||
if n > 0:
|
||||
out("[HIT] forbidden pattern: {} ({} line(s))".format(f, n))
|
||||
matched = [(i + 1, ln) for i, ln in enumerate(lines) if fl in ln.lower()]
|
||||
if matched:
|
||||
out("[HIT] forbidden pattern: {} ({} line(s))".format(f, len(matched)))
|
||||
# 命中行必须打出来(2026-10-10):旧实现只报 "(3 line(s))",人还得自己
|
||||
# 再 grep 一遍才知道是哪几行——而"哪几行"正是定位所需的那一步
|
||||
for ln_no, ln in matched[:HIT_LINES_MAX]:
|
||||
out(" L{}: {}".format(ln_no, ln.strip()[:200]))
|
||||
if len(matched) > HIT_LINES_MAX:
|
||||
out(" ... {} more".format(len(matched) - HIT_LINES_MAX))
|
||||
hits.append(f)
|
||||
res["forbidden_hits"].append({
|
||||
"pattern": f, "count": len(matched),
|
||||
"lines": [{"no": n, "text": t.strip()[:200]}
|
||||
for n, t in matched[:HIT_LINES_MAX]]})
|
||||
else:
|
||||
out("[OK] forbidden pattern absent: {}".format(f))
|
||||
|
||||
@@ -184,6 +239,73 @@ def verify(log_path, required=REQUIRED_DEFAULT, forbidden=FORBIDDEN_DEFAULT,
|
||||
out("[FAIL] reset cause '{}' not in expected: {}".format(
|
||||
causes[0], expect_reset_cause))
|
||||
boot_issue = True
|
||||
res["boot"] = {"marker": boot_marker, "count": n_boot, "allowed": max_boot_count,
|
||||
"causes": causes, "issue": boot_issue}
|
||||
|
||||
# 标记顺序(2026-10-10):标记全中但**顺序颠倒** = 某步在另一步之后补做
|
||||
# (屏障失序 / 失败重试补跑)。旧实现只看子串存在性,这类失序完全看不见。
|
||||
order_issue = False
|
||||
if require_order:
|
||||
seq = [s.strip() for s in require_order.split(">") if s.strip()]
|
||||
pos = []
|
||||
for m in seq:
|
||||
idx = next((i for i, ln in enumerate(lines) if m in ln), -1)
|
||||
pos.append((m, idx))
|
||||
absent = [m for m, i in pos if i < 0]
|
||||
if absent:
|
||||
out("[FAIL] require-order: marker(s) not found: {}".format("; ".join(absent)))
|
||||
order_issue = True
|
||||
else:
|
||||
bad = [(pos[k][0], pos[k + 1][0]) for k in range(len(pos) - 1)
|
||||
if pos[k][1] > pos[k + 1][1]]
|
||||
if bad:
|
||||
out("[FAIL] marker order violated (expected {})".format(require_order))
|
||||
for a, b in bad:
|
||||
out(" '{}' first appeared AFTER '{}'".format(a, b))
|
||||
for m, i in pos:
|
||||
out(" L{}: {}".format(i + 1, m))
|
||||
order_issue = True
|
||||
else:
|
||||
out("[OK] marker order ok: {}".format(require_order))
|
||||
res["order_bad"] = [m for m, i in pos if i < 0] + [
|
||||
"{}>{}".format(a, b) for a, b in
|
||||
[(pos[k][0], pos[k + 1][0]) for k in range(len(pos) - 1) if pos[k][1] > pos[k + 1][1]]]
|
||||
|
||||
# 开机耗时(2026-10-10):行首 [+Nms] = daemon 毫秒时基的相对偏移
|
||||
# (MCU 侧时间戳只有秒级,且开机头几行时钟还没起,量不出耗时)。
|
||||
timing = None
|
||||
timing_issue = False
|
||||
offs = []
|
||||
for i, ln in enumerate(lines):
|
||||
m = _TS_OFF_RE.match(ln)
|
||||
if m:
|
||||
offs.append((i, int(m.group(1))))
|
||||
if offs:
|
||||
timing = {"ts_lines": len(offs), "first_ms": offs[0][1], "last_ms": offs[-1][1],
|
||||
"span_ms": offs[-1][1] - offs[0][1]}
|
||||
out("[INFO] capture span: +{}ms .. +{}ms ({} timestamped line(s))".format(
|
||||
timing["first_ms"], timing["last_ms"], timing["ts_lines"]))
|
||||
if done_marker:
|
||||
di = next((i for i, ln in enumerate(lines) if done_marker in ln), -1)
|
||||
if di < 0:
|
||||
out("[WARN] done marker '{}' not found -> boot time not measured".format(
|
||||
done_marker))
|
||||
else:
|
||||
doff = next((off for i, off in offs if i >= di), None)
|
||||
if doff is None:
|
||||
out("[WARN] done marker line has no [+Nms] prefix -> not measured")
|
||||
else:
|
||||
boot_ms = doff - offs[0][1]
|
||||
timing["boot_ms"] = boot_ms
|
||||
timing["done_marker"] = done_marker
|
||||
out("[INFO] boot time to '{}': {} ms".format(done_marker, boot_ms))
|
||||
if max_boot_ms > 0 and boot_ms > max_boot_ms:
|
||||
out("[FAIL] boot time {} ms > limit {} ms".format(boot_ms, max_boot_ms))
|
||||
timing_issue = True
|
||||
elif done_marker or max_boot_ms > 0:
|
||||
out("[WARN] no [+Nms] line prefix in this log -> timing checks skipped "
|
||||
"(run_boot_test.py adds it; --raw-log turns it off)")
|
||||
res["timing"] = timing
|
||||
|
||||
# 套件汇总行解析(T-2):抓"没跑到汇总行 / 断言数为 0"的盲区
|
||||
bad_summary = []
|
||||
@@ -219,6 +341,7 @@ def verify(log_path, required=REQUIRED_DEFAULT, forbidden=FORBIDDEN_DEFAULT,
|
||||
ok = True
|
||||
if not ok:
|
||||
bad_summary.append(name)
|
||||
res["suites_bad"] = list(bad_summary)
|
||||
|
||||
# 自检记录联合判定(S7):/log/selftest.log 定长 64B 记录(格式见 App/log/sys_log.h),
|
||||
# 仅故障写盘:文件缺失 / 0 字节 = 无故障 → PASS;存在任何一条记录 → FAIL
|
||||
@@ -227,34 +350,41 @@ def verify(log_path, required=REQUIRED_DEFAULT, forbidden=FORBIDDEN_DEFAULT,
|
||||
sp = Path(selftest_log) if selftest_log else None
|
||||
if sp is None or not sp.is_file() or sp.stat().st_size == 0:
|
||||
out("[OK] selftest: no fault record (file missing or empty)")
|
||||
res["selftest"] = {"path": str(sp) if sp else None, "faults": 0, "records": []}
|
||||
else:
|
||||
raw = sp.read_bytes()
|
||||
n = len(raw) // 64
|
||||
if len(raw) % 64:
|
||||
out("[WARN] selftest log has {} trailing incomplete bytes (ignored)".format(len(raw) % 64))
|
||||
recs = []
|
||||
for i in range(n):
|
||||
rec = raw[i * 64:(i + 1) * 64]
|
||||
ts = rec[0:19].decode("ascii", errors="replace")
|
||||
desc = rec[31:59].split(b"\0", 1)[0].decode("utf-8", errors="replace")
|
||||
recs.append({"no": i, "ts": ts, "desc": desc})
|
||||
res["selftest"] = {"path": str(sp), "faults": n, "records": recs}
|
||||
if n > 0:
|
||||
selftest_fail = True
|
||||
out("[FAIL] selftest: {} fault record(s) in {}".format(n, selftest_log))
|
||||
for i in range(n):
|
||||
rec = raw[i * 64:(i + 1) * 64]
|
||||
ts = rec[0:19].decode("ascii", errors="replace")
|
||||
desc = rec[31:59].split(b"\0", 1)[0].decode("utf-8", errors="replace")
|
||||
out(" #{}: {} {}".format(i, ts, desc))
|
||||
for r in recs:
|
||||
out(" #{}: {} {}".format(r["no"], r["ts"], r["desc"]))
|
||||
else:
|
||||
out("[OK] selftest: no fault record (0 complete records)")
|
||||
|
||||
if show_tail > 0 and lines:
|
||||
res["tail"] = lines[-show_tail:]
|
||||
out("")
|
||||
out("=== Last {} line(s) of log ===".format(show_tail))
|
||||
for ln in lines[-show_tail:]:
|
||||
out(ln)
|
||||
|
||||
out("")
|
||||
if missing or hits or bad_summary or selftest_fail or boot_issue:
|
||||
if (missing or hits or bad_summary or selftest_fail or boot_issue
|
||||
or order_issue or timing_issue):
|
||||
out("VERDICT: FAIL")
|
||||
return 1
|
||||
return finish(1)
|
||||
out("VERDICT: PASS")
|
||||
return 0
|
||||
return finish(0)
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
@@ -289,6 +419,16 @@ def main(argv=None):
|
||||
ap.add_argument("--expect-reset-cause", default="",
|
||||
help="';'-separated substrings the FIRST reset cause must match "
|
||||
"(case-insensitive). Empty = report only, no verdict")
|
||||
ap.add_argument("--require-order", default="",
|
||||
help="'>'-separated markers that must appear in that line order "
|
||||
"(e.g. 'App init OK>Network Init>net init: OK'). Empty = off")
|
||||
ap.add_argument("--done-marker", default="",
|
||||
help="marker whose line marks boot completion, for --max-boot-ms")
|
||||
ap.add_argument("--max-boot-ms", type=int, default=0,
|
||||
help="fail if time from first line to --done-marker exceeds N ms "
|
||||
"(needs '[+Nms]' line prefix; 0 = off)")
|
||||
ap.add_argument("--json", action="store_true",
|
||||
help="emit one JSON object instead of the text verdict (exit code unchanged)")
|
||||
args = ap.parse_args(argv)
|
||||
|
||||
return verify(args.log_path, args.required, args.forbidden,
|
||||
@@ -297,7 +437,11 @@ def main(argv=None):
|
||||
selftest_log=args.selftest_log,
|
||||
boot_marker=args.boot_marker,
|
||||
max_boot_count=args.max_boot_count,
|
||||
expect_reset_cause=args.expect_reset_cause)
|
||||
expect_reset_cause=args.expect_reset_cause,
|
||||
require_order=args.require_order,
|
||||
done_marker=args.done_marker,
|
||||
max_boot_ms=args.max_boot_ms,
|
||||
as_json=args.json)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -16,18 +16,24 @@
|
||||
## 使用方式
|
||||
|
||||
```bash
|
||||
# 启动 daemon / 打开串口
|
||||
& '.venv\Scripts\python.exe' tools/serial_tool/serial_tool.py open COM6
|
||||
# 打开串口(daemon 自动拉起;端口缺省取 .opencode/serial_config.json,不必带参数)
|
||||
& '.venv\Scripts\python.exe' tools/serial_tool/serial_tool.py open
|
||||
|
||||
# 查询日志 / 发送命令
|
||||
& '.venv\Scripts\python.exe' tools/serial_tool/serial_tool.py tail
|
||||
# 看有哪些口 / 配置口是否还在位(单一候选时回 hint)
|
||||
& '.venv\Scripts\python.exe' tools/serial_tool/serial_tool.py ports
|
||||
|
||||
# 读日志(先过滤后截尾)/ 发送 / 等静默
|
||||
& '.venv\Scripts\python.exe' tools/serial_tool/serial_tool.py log --tail 50 --grep "App init OK"
|
||||
& '.venv\Scripts\python.exe' tools/serial_tool/serial_tool.py send "<命令>"
|
||||
& '.venv\Scripts\python.exe' tools/serial_tool/serial_tool.py wait-idle --settle-ms 8000 --max-ms 30000
|
||||
|
||||
# 关闭
|
||||
# 状态 / 关闭
|
||||
& '.venv\Scripts\python.exe' tools/serial_tool/serial_tool.py status
|
||||
& '.venv\Scripts\python.exe' tools/serial_tool/serial_tool.py close
|
||||
```
|
||||
|
||||
> 具体子命令以 `--help` 为准。
|
||||
> 全部命令输出 JSON、退出码 0/1/2/3;具体子命令以 `--help` 为准。
|
||||
> 抓开机日志的顺序是 `open` → `clear` → 烧录/复位 → `wait-idle` → 读日志(采集必须先于烧录)。
|
||||
|
||||
## ⚠️ 注意
|
||||
|
||||
|
||||
@@ -61,6 +61,7 @@ DEFAULT_HTTP_PORT = 18765
|
||||
CAP_BYTES = 20 * 1024 * 1024 # 日志体积上限
|
||||
KEEP_BYTES = 10 * 1024 * 1024 # 超限后保留的尾部
|
||||
PARTIAL_FLUSH_S = 0.2 # 残行冲刷窗口(秒)
|
||||
PARTIAL_MAX_BYTES = 64 * 1024 # 无换行残行的内存封顶(超限即当一行落地,见 LineSplitter.feed)
|
||||
REOPEN_SETTLE_S = 1.5 # 重开端口退避(CH340 释放后立即重开的 EOF 陷阱)
|
||||
DEFAULT_TIMEOUT_S = 5.0 # CLI→daemon 硬超时(秒;wait-idle 未显式给时按 --max-ms 推导)
|
||||
DAEMON_LOG_MAX = 1024 * 1024 # daemon.log 超此体积 → 拉起新实例前只保尾部
|
||||
@@ -103,6 +104,17 @@ def load_config():
|
||||
return cfg
|
||||
|
||||
|
||||
def port_hint(configured, names):
|
||||
"""配置里的端口不在位、而系统只有**一个**候选时,给出可照抄的建议。
|
||||
|
||||
换 USB 口 / 换机器 COM 号就变(本机 COM7→COM6 实测)。旧实现只报
|
||||
`port not found: COM7 (available: COM6)`,还得人自己判断改哪里;现单一候选
|
||||
直接给 hint(多候选不猜,避免把蓝牙/虚拟口当目标口)。
|
||||
"""
|
||||
cands = [n for n in sorted(set(names)) if n != configured]
|
||||
return cands[0] if len(cands) == 1 else None
|
||||
|
||||
|
||||
def load_meta():
|
||||
try:
|
||||
with open(META_FILE, "r", encoding="utf-8") as f:
|
||||
@@ -156,8 +168,23 @@ class LineSplitter:
|
||||
ts = self.buf_ts
|
||||
self.buf_ts = now if self.buf else 0.0
|
||||
out.append((ts, raw))
|
||||
# 残行封顶(2026-10-10):板子跑飞 / 烧录期吐二进制垃圾时,没有 \n 的字节会
|
||||
# 一直留在内存(旧实现无上限,长会话能涨到几十 MB),而且后续每一条正常行
|
||||
# 都被拼在这坨垃圾尾巴上,变成一条读不出来的超长行。超 PARTIAL_MAX_BYTES
|
||||
# 就当一行落地(decode_line 会转成 HEX:),内存封顶 + 自动重新对齐换行。
|
||||
if len(self.buf) >= PARTIAL_MAX_BYTES:
|
||||
out.extend(self.flush_partial())
|
||||
return out
|
||||
|
||||
def reset(self):
|
||||
"""丢弃残行:open / close / clear 的会话边界。
|
||||
|
||||
旧实现残行跨会话留存——上一段会话末尾的半行(复位瞬间被截断的那行)会在
|
||||
下次 open 后被拼进新会话的第一行,开机日志的头一行因此不可读
|
||||
(2026-10-10 修)。"""
|
||||
self.buf = b""
|
||||
self.buf_ts = 0.0
|
||||
|
||||
def flush_partial(self):
|
||||
if not self.buf:
|
||||
return []
|
||||
@@ -180,11 +207,19 @@ def decode_line(raw):
|
||||
def cap_truncate(f, size, keep=KEEP_BYTES, cap=CAP_BYTES):
|
||||
"""原地截头:保留尾部 keep 字节。
|
||||
Windows 无"丢弃头部"的单系统调用(ftruncate/SetEndOfFile 只截尾部),
|
||||
故在同一句柄上:读尾部 → 截空 → 写回。单写者线程内执行,无并发风险。"""
|
||||
故在同一句柄上:读尾部 → 截空 → 写回。单写者线程内执行,无并发风险。
|
||||
|
||||
按行边界对齐(2026-10-10):旧实现按字节偏移取尾,截出来的**第一行是半行**
|
||||
(前半截已被丢掉)——"日志文件只含完整行"的契约被破坏,Grep / verify_log
|
||||
读到的首行是垃圾,人读同样误导。现丢掉首个换行之前的残片(无换行则原样保留)。
|
||||
"""
|
||||
if size <= cap:
|
||||
return False
|
||||
f.seek(size - keep)
|
||||
tail = f.read(keep)
|
||||
i = tail.find(b"\n")
|
||||
if 0 <= i < len(tail) - 1:
|
||||
tail = tail[i + 1:]
|
||||
f.seek(0)
|
||||
os.ftruncate(f.fileno(), 0)
|
||||
f.write(tail)
|
||||
@@ -235,6 +270,10 @@ class Daemon:
|
||||
self.last_rx_line = None
|
||||
self.last_rx_at = 0.0
|
||||
self.gen = int(load_meta().get("gen", 0))
|
||||
self.splitter = LineSplitter()
|
||||
# 会话边界计数:open / close / clear 递增,串口线程下一轮循环丢弃残行
|
||||
# (跨线程只碰一个 int,避免直接改 splitter.buf 与 feed() 竞态)
|
||||
self.splitter_gen = 0
|
||||
self._stop = threading.Event()
|
||||
|
||||
# -- 日志(一律经写线程,保序 + 单写者) --
|
||||
@@ -294,8 +333,13 @@ class Daemon:
|
||||
# -- 串口线程(Serial 对象唯一持有者) --
|
||||
|
||||
def serial_loop(self):
|
||||
splitter = LineSplitter()
|
||||
splitter = self.splitter
|
||||
my_gen = self.splitter_gen
|
||||
while not self._stop.is_set():
|
||||
if self.splitter_gen != my_gen:
|
||||
# open / close / clear:上一段会话的半行不得粘进新会话首行
|
||||
splitter.reset()
|
||||
my_gen = self.splitter_gen
|
||||
ser = self.ser
|
||||
if ser is not None:
|
||||
try:
|
||||
@@ -361,6 +405,7 @@ class Daemon:
|
||||
self.state = "stopped"
|
||||
self.error = None
|
||||
self.last_released = time.time()
|
||||
self.splitter_gen += 1
|
||||
|
||||
# -- 命令执行(均在串口线程) --
|
||||
|
||||
@@ -387,14 +432,37 @@ class Daemon:
|
||||
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))
|
||||
rts = bool(p.get("rts", False))
|
||||
# dtr/rts:**未显式给 = 保持现状**。旧实现把"没给"当 False,于是每次不带
|
||||
# --dtr/--rts 的重开都会静默把 DTR/RTS 拉低(对走 DTR/RTS 复位电路的板子
|
||||
# 等于莫名多了一次复位脉冲,2026-10-10 修)
|
||||
dtr = bool(p["dtr"]) if p.get("dtr") is not None else self.dtr
|
||||
rts = bool(p["rts"]) if p.get("rts") is not None else self.rts
|
||||
|
||||
with self.lock:
|
||||
if self.ser is not None:
|
||||
if self.port_name == name and self.port_baud == baud:
|
||||
# 幂等重开。旧实现直接回 "already open",**显式给的 --dtr/--rts
|
||||
# 被静默忽略**(第二次起 `open --dtr 1` 毫无作用,2026-10-10 修):
|
||||
# 现按需现场应用并回显改了什么。
|
||||
note = "already open (idempotent)"
|
||||
applied = []
|
||||
for key, attr in (("dtr", "dtr"), ("rts", "rts")):
|
||||
if p.get(key) is None:
|
||||
continue
|
||||
want = bool(p[key])
|
||||
if want == getattr(self, attr):
|
||||
continue
|
||||
try:
|
||||
setattr(self.ser, attr, want)
|
||||
setattr(self, attr, want)
|
||||
applied.append("%s=%d" % (key, int(want)))
|
||||
except (serial.SerialException, OSError) as e:
|
||||
note += "; %s apply failed: %s" % (key, e)
|
||||
if applied:
|
||||
note += "; applied " + ", ".join(applied)
|
||||
cmd.finish({"ok": True, "port": name, "baud": baud, "gen": self.gen,
|
||||
"since": self.port_since, "note": "already open (idempotent)"})
|
||||
"dtr": self.dtr, "rts": self.rts,
|
||||
"since": self.port_since, "note": note})
|
||||
return
|
||||
self._close_port_locked()
|
||||
|
||||
@@ -422,6 +490,9 @@ class Daemon:
|
||||
# Windows 对不存在的 COM 口常报 Access Denied(而非 FileNotFound),
|
||||
# 故以端口枚举结果为准,避免把"不存在"误报成"被占用"
|
||||
err = "port not found: %s (available: %s)" % (name, ", ".join(names) or "(none)")
|
||||
hint = port_hint(name, names)
|
||||
if hint:
|
||||
err += " [hint: only candidate is %s -> set portName in %s]" % (hint, CONFIG_FILE)
|
||||
elif "Access is denied" in msg or "busy" in msg.lower():
|
||||
err = "port busy: %s (held by another process)" % name
|
||||
else:
|
||||
@@ -439,6 +510,7 @@ class Daemon:
|
||||
self.state = "active"
|
||||
self.error = None
|
||||
self.last_data_at = time.time()
|
||||
self.splitter_gen += 1 # 新会话:丢掉上一段会话遗留的残行
|
||||
self.emit(self.sys_line("port opened %s %d (gen=%d)" % (name, baud, self.gen)))
|
||||
self._save_meta()
|
||||
cmd.finish({"ok": True, "port": name, "baud": baud, "dtr": dtr, "rts": rts,
|
||||
@@ -514,6 +586,9 @@ class Daemon:
|
||||
"uptime_s": int(time.time() - self.born),
|
||||
"exclusive_bind": getattr(self.server, "exclusive_bound", False)},
|
||||
"port": port, "counters": counters, "last_rx": last,
|
||||
# rx_age_ms:距最后一个 RX 字节的毫秒数(null = 这个 daemon 还没收过
|
||||
# 任何字节)。判"板子是不是根本没在说话"不必再自己减时间戳(2026-10-10)
|
||||
"rx_age_ms": last["age_ms"] if last else None,
|
||||
"log": {"path": str(LOG_FILE), "size": size, "gen": gen},
|
||||
"state": state, "error": err,
|
||||
"config_error": self.cfg.get("configError") or None}
|
||||
@@ -675,6 +750,7 @@ class _Handler(BaseHTTPRequestHandler):
|
||||
params = {k: body[k] for k in ("text", "hex", "nl") if k in body}
|
||||
self._run_cmd(Cmd("send", **params))
|
||||
elif self.path == "/clear":
|
||||
d.splitter_gen += 1 # 清日志 = 会话边界:残行一并丢弃
|
||||
ev = threading.Event()
|
||||
d.emit_clear(ev)
|
||||
if not ev.wait(5):
|
||||
@@ -834,7 +910,15 @@ def cmd_ports(args):
|
||||
ports = [{"name": p.device, "description": p.description, "hwid": p.hwid}
|
||||
for p in serial.tools.list_ports.comports()]
|
||||
cfg = load_config()
|
||||
return {"ok": True, "ports": ports, "configured": cfg.get("portName", ""),
|
||||
configured = cfg.get("portName", "")
|
||||
names = [p["name"] for p in ports]
|
||||
hint = None
|
||||
if configured and configured not in names:
|
||||
hint = port_hint(configured, names) # 配置里的口不在位:唯一候选直接给
|
||||
elif not configured and len(names) == 1:
|
||||
hint = names[0] # 压根没配端口:唯一候选直接给
|
||||
return {"ok": True, "ports": ports, "configured": configured,
|
||||
"configured_present": configured in names, "hint": hint,
|
||||
"config_error": cfg.get("configError") or None}
|
||||
|
||||
|
||||
@@ -1130,6 +1214,52 @@ def run_selftest():
|
||||
check("T7b 拒连不当成超时(仍走自动拉起)",
|
||||
not _is_timeout_exc(urllib.error.URLError(ConnectionRefusedError("refused"))))
|
||||
|
||||
# T8 残行封顶 + 会话边界复位(2026-10-10):二进制垃圾不再无限占内存,
|
||||
# 也不再把自己拼进下一段会话的第一行
|
||||
sp8 = LineSplitter()
|
||||
junk = b"\x00\x01" * 40000 # 80 KB 无换行垃圾
|
||||
got = sp8.feed(junk, 2000.0)
|
||||
check("T8a 无换行垃圾封顶成一行(内存不无限涨)",
|
||||
len(got) == 1 and got[0][1] == junk and sp8.buf == b"",
|
||||
"lines=%d buf=%d" % (len(got), len(sp8.buf)))
|
||||
got2 = sp8.feed(b"App init OK\n", 2001.0)
|
||||
check("T8b 封顶后重新对齐换行", len(got2) == 1 and got2[0][1] == b"App init OK", repr(got2))
|
||||
|
||||
sp9 = LineSplitter()
|
||||
sp9.feed(b"half-a-li", 3000.0) # 复位瞬间被截断的半行
|
||||
sp9.reset()
|
||||
got3 = sp9.feed(b"ne boot: syslog init OK\n", 3001.0)
|
||||
check("T8c 会话边界丢弃残行(新会话首行不被污染)",
|
||||
len(got3) == 1 and got3[0][1] == b"ne boot: syslog init OK", repr(got3))
|
||||
|
||||
# T9 截头按行对齐(旧实现按字节切 → 截出来的首行是半行,Grep/verify_log 读到垃圾)
|
||||
fd9, path9 = tempfile.mkstemp(prefix="serial_tool_selftest_trunc_")
|
||||
os.close(fd9)
|
||||
try:
|
||||
with open(path9, "wb") as f:
|
||||
for i in range(2000):
|
||||
f.write(b"[RX] line %04d %s\n" % (i, b"x" * 40))
|
||||
size9 = os.path.getsize(path9)
|
||||
with open(path9, "r+b") as f:
|
||||
cap_truncate(f, size9, keep=8000, cap=1000)
|
||||
f.seek(0)
|
||||
data9 = f.read()
|
||||
first9 = data9.split(b"\n", 1)[0]
|
||||
check("T9 截头后首行完整(按行边界对齐)",
|
||||
first9.startswith(b"[RX] line ") and data9.endswith(b"\n"),
|
||||
"first=%r" % first9[:24])
|
||||
finally:
|
||||
try:
|
||||
os.remove(path9)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# T10 端口建议:换 USB 口后 COM 号变了,唯一候选直接给(多候选不猜)
|
||||
check("T10 port_hint 唯一候选才给建议",
|
||||
port_hint("COM7", ["COM6"]) == "COM6"
|
||||
and port_hint("COM7", ["COM6", "COM9"]) is None
|
||||
and port_hint("COM6", ["COM6"]) is None)
|
||||
|
||||
width = 46
|
||||
all_ok = True
|
||||
for name, ok, detail in results:
|
||||
|
||||
Reference in New Issue
Block a user