test:@test 闭环迁移 PowerShell→Python(run_boot_test/verify_log 纯标准库,文档同步)
This commit is contained in:
@@ -34,20 +34,20 @@ Interpretation (per AGENTS.md): exit code 0 = PASS (0 errors, 0 warnings). 1/2+
|
||||
|
||||
### Step 2 — Flash + capture boot log (one orchestrated call)
|
||||
|
||||
Use `test/run_boot_test.ps1` which opens the port + clears the log (arming),
|
||||
Use `test/run_boot_test.py` (pure-stdlib Python, 2026-09-20 起替代 PowerShell 版) which opens the port + clears the log (arming),
|
||||
flashes, waits for RX silence, extracts the boot log and verifies — all in one
|
||||
call. The serial daemon buffers the full stream in `tools/serial_tool/state/serial.log`,
|
||||
so nothing can be lost between steps:
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File "test\run_boot_test.ps1" `
|
||||
-SettleMs 8000 -MaxWaitMs 30000 `
|
||||
-FlashCmd "MDK-ARM\download.bat" `
|
||||
-Required "App init OK;Network Init;net init: OK" `
|
||||
-Forbidden "FAIL;ERROR;HardFault"
|
||||
& '.venv\Scripts\python.exe' test/run_boot_test.py `
|
||||
--settle-ms 8000 --max-wait-ms 30000 `
|
||||
--flash-cmd "MDK-ARM\download.bat" `
|
||||
--required "App init OK;Network Init;net init: OK" `
|
||||
--forbidden "FAIL;ERROR;HardFault"
|
||||
```
|
||||
|
||||
`-Port` defaults to `.opencode/serial_config.json`; override only if needed.
|
||||
`--port` defaults to `.opencode/serial_config.json`; override only if needed.
|
||||
Exit codes: **0 = PASS**, 1 = FAIL (marker miss / forbidden hit), 2 = capture/flash error, 3 = preflight/usage error.
|
||||
|
||||
### Step 3 — Iterate
|
||||
@@ -68,7 +68,7 @@ For interactive command/response (not boot-log capture), drive the serial assist
|
||||
|
||||
- **串口端口**:以 `.opencode/serial_config.json` 为准(本机为 COM6)。每次连接前先读它。
|
||||
- **全量历史 = 日志文件**:daemon 把 RX/TX/SYS 持续写入 `tools/serial_tool/state/serial.log`,直接 Read/Grep 即可,无需采集窗口仪式。
|
||||
- **⚠️ 静默判据**:boot 任务间有 ~3-4s 自然静默间隙,`-SettleMs` 必须 ≥ 8000(默认已如此),否则日志截断。
|
||||
- **⚠️ 静默判据**:boot 任务间有 ~3-4s 自然静默间隙,`--settle-ms` 必须 ≥ 8000(默认已如此),否则日志截断。
|
||||
- **daemon 生命周期**:任何命令自动拉起 daemon(无需显式 start);`restart` 保证加载最新代码;强杀 daemon 后下条命令自动恢复(串口需重新 `open`)。
|
||||
- UI (LVGL) task `ui_task` 已有 `UI port init: OK` / `UI menu init: OK` 打印,可用 `-Required "UI port init: OK;UI menu init: OK"` 自动回归 UI 初始化。
|
||||
- UI (LVGL) task `ui_task` 已有 `UI port init: OK` / `UI menu init: OK` 打印,可用 `--required "UI port init: OK;UI menu init: OK"` 自动回归 UI 初始化。
|
||||
- Full command contract / daemon behavior / known traps: `docs/测试说明/串口助手设计说明.md` and `docs/测试说明/自动化测试流程.md`.
|
||||
|
||||
@@ -35,5 +35,5 @@ cmake --build simulator/build --config Debug # 0 警告
|
||||
- **禁止**把源文件复制进 `simulator/`——两端共用 `Drivers/BSP/` 原件(UI_界面制作流程.md C7)
|
||||
- 只注册 `.c`(含 `.s`);`.h` 靠 include 路径找到,不加工程
|
||||
- 漏注册的典型症状:一端编译失败(undefined reference)或一端行为悄悄停留在旧逻辑
|
||||
- 注册完成后跑回归:UI 文件 → `ui-verify` skill;其他模块 → `@test`(`test/run_boot_test.ps1`)
|
||||
- 注册完成后跑回归:UI 文件 → `ui-verify` skill;其他模块 → `@test`(`test/run_boot_test.py`)
|
||||
- 改过 `CMakeLists.txt` 后需重新 `cmake -B build` 配置
|
||||
|
||||
@@ -15,7 +15,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`);`build.bat` 硬编码本机 Keil 路径 `C:\Keil_v5\UV4\UV4.exe`(换机需改)
|
||||
- `@test` / `test/run_boot_test.ps1` 一键闭环:开串口+清日志 → 烧录(`MDK-ARM/download.bat`,下载后 Reset&Run)→ wait-idle(等 RX 静默 8s,超时 30s)→ `test/verify_log.ps1` 标记校验;退出码 0=PASS / 1=FAIL / 2=采集或烧录错误 / 3=用法/预检错误
|
||||
- `@test` / `test/run_boot_test.py` 一键闭环(2026-09-20 起为纯 Python 标准库实现,替代 PowerShell 版;`& '.venv\Scripts\python.exe' test/run_boot_test.py`):开串口+清日志 → 烧录(`MDK-ARM/download.bat`,下载后 Reset&Run)→ wait-idle(等 RX 静默 8s,超时 30s)→ `test/verify_log.py` 标记校验;退出码 0=PASS / 1=FAIL / 2=采集或烧录错误 / 3=用法/预检错误
|
||||
- 默认校验标记:Required `App init OK` / `Network Init` / `net init: OK`;Forbidden `FAIL` / `ERROR` / `HardFault`;按模块定制标记(网络/存储/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=` 不可用)
|
||||
|
||||
@@ -138,8 +138,8 @@ STM32F4-Base/
|
||||
│ ├── download.bat # 烧录脚本
|
||||
│ └── startup_stm32f407xx.s # 启动文件
|
||||
├── test/ # 测试代码与脚本
|
||||
│ ├── run_boot_test.ps1 # 自动化启动测试
|
||||
│ ├── verify_log.ps1 # 日志校验脚本
|
||||
│ ├── run_boot_test.py # 自动化启动测试
|
||||
│ ├── verify_log.py # 日志校验脚本
|
||||
│ ├── test_config.c/h # 测试任务配置
|
||||
│ ├── ch395f_test_task.c/h # CH395F 硬件测试
|
||||
│ ├── gd5f_test_task.c/h # NAND Flash 测试
|
||||
@@ -266,9 +266,9 @@ UI 改动回归门禁(菜单像素回归 + 内容页黄金参考 + 池水位
|
||||
|
||||
### 启动测试
|
||||
|
||||
```bash
|
||||
# 一键测试:构建 → 串口捕获 → 烧录 → 日志校验
|
||||
powershell -ExecutionPolicy Bypass -File test\run_boot_test.ps1
|
||||
```powershell
|
||||
# 一键测试:串口捕获 → 烧录 → 日志校验(2026-09-20 起为纯 Python 标准库实现)
|
||||
& '.venv\Scripts\python.exe' test/run_boot_test.py
|
||||
```
|
||||
|
||||
退出码:0 = PASS | 1 = FAIL | 2 = 错误
|
||||
@@ -360,7 +360,7 @@ pre-commit run --all-files
|
||||
| `Drivers/BSP/GD5F2GQ5UE/gd5f2gq5ue.c` | NAND Flash 驱动 |
|
||||
| `Drivers/BSP/NET/net_socket.c` | BSD Socket API |
|
||||
| `MDK-ARM/build.bat` | 编译脚本 |
|
||||
| `test/run_boot_test.ps1` | 自动化测试脚本 |
|
||||
| `test/run_boot_test.py` | 自动化测试脚本 |
|
||||
|
||||
## 已知问题
|
||||
|
||||
|
||||
@@ -249,8 +249,8 @@ flowchart TD
|
||||
## 六、P3:MCU 验证
|
||||
|
||||
1. `@build` → **0 错误 0 警告**(AGENTS.md 强制;有警告看 `MDK-ARM/build_log.txt`)
|
||||
2. `@test`(一键 `test/run_boot_test.ps1`):
|
||||
- 采集窗口**先于烧录武装**(脚本 `-FlashCmd` 分支已保证顺序)
|
||||
2. `@test`(一键 `test/run_boot_test.py`):
|
||||
- 采集窗口**先于烧录武装**(脚本 `--flash-cmd` 已保证顺序)
|
||||
- Required:`UI port init: OK;UI menu init: OK` + 本界面定制标记(按屏增补,如页头标题字样)
|
||||
- Forbidden:`FAIL;ERROR;HardFault`
|
||||
3. **真机目检**(用户执行,DTU-RemoteLCD @ TCP 7003):极性 / 边框 / 文字 / 选中态 / 滚动
|
||||
|
||||
@@ -279,7 +279,7 @@ ISR 余量:该任务运行期间可能被中断打断的最深中断链(SPI/
|
||||
1. **RAM 预算台账**(本节模板落地为 `docs/内存管理/RAM预算台账.md`):记录每个 Region/缓冲/栈的计划值、实测值、责任人、变更历史;每次改内存相关代码必须更新
|
||||
2. **AGENTS.md 增补**:新增任务/缓冲/栈前必须先查台账余量(把现有"内存预算紧张"条目升级为可执行 checklist:先跑 `ram_report.ps1`,算总账,再动手)
|
||||
3. **pre-commit 门禁**(可选):钩子解析最新 map,RW_IRAM1 使用率 >92% 或 RW_IRAM2 超限 → 提交拦截并提示
|
||||
4. **@test 扩展**:`verify_log.ps1` 增加可选 Required 标记 `"[MEM] heap free"`,把堆余量下限纳入自动化回归
|
||||
4. **@test 扩展**:`verify_log.py` 增加可选 Required 标记 `"[MEM] heap free"`,把堆余量下限纳入自动化回归
|
||||
5. **map diff 常态化**:每次 `@build` 后跑 `ram_report.ps1`,ZI 增量 >1KB 的改动必须在提交说明中解释
|
||||
|
||||
## 5. 预期效果
|
||||
|
||||
@@ -173,7 +173,7 @@ ST-LINK_CLI -c SWD HOTPLUG -Dump 20000000 1000000 dump.bin :: 转储 SRAM
|
||||
Get-Content tools\serial_tool\state\serial.log -Tail 100
|
||||
```
|
||||
|
||||
顺序关键:**先开串口清日志,再复位**,否则错过开机头几行(与 `run_boot_test.ps1` 的"采集先于烧录"原则一致)。
|
||||
顺序关键:**先开串口清日志,再复位**,否则错过开机头几行(与 `run_boot_test.py` 的"采集先于烧录"原则一致)。
|
||||
ⓞ 实测补充三条:
|
||||
|
||||
1. **改过 `serial_config.json` 端口后必须重启 daemon**:daemon 在启动时缓存配置,改端口后 `close` + 杀旧进程再 `open`,否则一直按旧端口报 `port not found`;
|
||||
|
||||
@@ -191,7 +191,7 @@ Select-String -Path 'tools\serial_tool\state\serial.log' -Pattern 'App init OK'
|
||||
| `timeout` | `--max-ms` 到了仍在持续吐数据 | false |
|
||||
|
||||
- **`--settle-ms` 默认 8000 不是随便定的**:本工程 boot 任务间有 3~4s 自然静默间隙,判据小于它会把开机日志截断。
|
||||
- @test 流程(`run_boot_test.ps1`)用它作为"烧录后日志收齐"的判据。
|
||||
- @test 流程(`run_boot_test.py`)用它作为"烧录后日志收齐"的判据。
|
||||
|
||||
### 3.9 `start` / `stop` / `restart` — daemon 生命周期
|
||||
|
||||
@@ -274,7 +274,7 @@ Select-String -Path 'tools\serial_tool\state\serial.log' -Pattern 'App init OK'
|
||||
... serial_tool.py log --tail 0 # 取本轮全量 → 人眼/脚本检查标记
|
||||
```
|
||||
|
||||
自动化版本 = `test/run_boot_test.ps1`(同一序列 + 烧录 + 标记校验,见 `自动化测试流程.md`)。
|
||||
自动化版本 = `test/run_boot_test.py`(同一序列 + 烧录 + 标记校验,见 `自动化测试流程.md`)。
|
||||
|
||||
### 5.3 长时运行监控
|
||||
|
||||
@@ -326,7 +326,7 @@ Get-Content 'tools\serial_tool\state\daemon.log' -Tail 30 # ② daemon 自身
|
||||
| 日志里出现 `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.ps1` 已内置) |
|
||||
| 开机日志"缺尾巴"(如缺 `Network Init`) | `--settle-ms` 太小,在 boot 自然静默间隙就收尾了 | 用默认 8000(`run_boot_test.py` 已内置) |
|
||||
| 旧日志"丢了" | 20MB 截头丢的是**文件头** | 查旧内容要看尾部;重要轮次开始前先 `clear` 分代 |
|
||||
|
||||
## 8. 常见问题
|
||||
|
||||
@@ -45,7 +45,7 @@ Python 重写一并解决:依赖收敛到仓库 `.venv + pyserial`(任意机
|
||||
| R8 | 日志 20MB 上限,超限**原地截头**保留尾部 10MB(不重开端口/不 rename) | P0 | 补充(用户确认) |
|
||||
| R9 | 开端口不翻转 DTR/RTS(防 STM32 自动复位电路误复位),可配置 | P0 | 补充 |
|
||||
| R10 | 串口读错/拔线 → daemon 存活,state=error,可重开;daemon 被杀可自恢复 | P0 | 补充 |
|
||||
| R11 | @test 集成:`run_boot_test.ps1` 采集步改写 + `verify_log.ps1` 兼容 + 旧文档/配置迁移 | P0 | 用户场景 |
|
||||
| R11 | @test 集成:`run_boot_test.py` 采集步改写 + `verify_log.py` 兼容 + 旧文档/配置迁移 | P0 | 用户场景 |
|
||||
| R12 | `send`(TX 进日志)、`log --tail/--grep`、`start/stop/restart`、`selftest` 无头自检 | P1 | 补充 |
|
||||
|
||||
## 3. 总体方案
|
||||
@@ -175,7 +175,7 @@ flowchart LR
|
||||
|
||||
## 7. 集成与迁移(@test 切换)
|
||||
|
||||
### 7.1 新 @test 流程(`run_boot_test.ps1` 采集步改写,退出码约定不变:0/1/2/3)
|
||||
### 7.1 新 @test 流程(`run_boot_test.py` 采集步改写,退出码约定不变:0/1/2/3)
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
@@ -183,11 +183,11 @@ flowchart LR
|
||||
B --> C["MDK-ARM\download.bat(ST-Link 烧录+复位,端口全程打开)"]
|
||||
C --> D["wait-idle:rx 静默 8s(-SettleMs)或超时 30s(-MaxWaitMs)"]
|
||||
D --> E["提取本代际 [RX] 行,剥 [RX] 时间戳前缀 → boot txt"]
|
||||
E --> F["verify_log.ps1(标记匹配,脚本不动)"]
|
||||
E --> F["verify_log.py(标记匹配)"]
|
||||
```
|
||||
|
||||
- 对比旧流程:6 个采集窗口参数 + JSON 解析 + EOF 重试 **全部删除**;采集 = 端口常开 + 读文件
|
||||
- `verify_log.ps1` 无需修改(剥前缀后与现有纯文本契约一致)
|
||||
- `verify_log.py` 与纯文本日志契约一致(2026-09-20 随 @test 迁移 Python,判定语义不变)
|
||||
- 参数保留:`-Port` `-BaudRate` `-FlashCmd` `-Required` `-Forbidden` `-OutDir`;删除 `-DurationMs` 等窗口参数,新增 `-SettleMs`(静默判据,**默认 8000**——boot 任务间有 3~4s 自然静默间隙,2000 会截断)`-MaxWaitMs`(默认 30000)
|
||||
|
||||
### 7.2 配套变更清单
|
||||
@@ -235,7 +235,7 @@ flowchart LR
|
||||
| S2 | daemon 核心:HTTP 服务 + 串口读线程 + 行日志写(含 clear/截头/DTR 策略) | `tools/serial_tool/serial_tool.py`(daemon 部分) | `start→status→send→stop` 手动走通 ✅ |
|
||||
| S3 | CLI 全子命令 + 自动拉起 + 超时契约 + `selftest` | serial_tool.py(CLI 部分) | `selftest` 0=PASS + 命令走查 ✅ |
|
||||
| S4 | 验收 A1–A6、A8 | 测试通过记录 | A1–A5/A8 PASS ✅(本机无 CH340,A6 待硬件) |
|
||||
| S5 | @test 集成:`run_boot_test.ps1` 采集步改写 + 参数收敛 | run_boot_test.ps1 | 负路径(端口缺失→exit 2)+ 假烧录全链路 + verify 正/负判定 ✅;**A7 正路径待 CH340 接入** |
|
||||
| S5 | @test 集成:`run_boot_test.py` 采集步改写 + 参数收敛 | run_boot_test.py | 负路径(端口缺失→exit 2)+ 假烧录全链路 + verify 正/负判定 ✅;**A7 正路径待 CH340 接入** |
|
||||
| S6 | 文档/配置迁移(§7.2 全清单) | docs + 配置 | 全库 grep 无 serial-mcp-server 残留引用(本文 §12 工具历史除外) ✅ |
|
||||
|
||||
提交:全部完成后单提交(pre-commit 门禁;纯宿主侧 Python,不涉及 Keil 构建)。
|
||||
|
||||
@@ -185,15 +185,15 @@ static void phaseN_run(void) {
|
||||
| 工具 | 位置 | 作用 |
|
||||
|---|---|---|
|
||||
| Python 串口助手(serial_tool) | `tools/serial_tool/serial_tool.py`(.venv + pyserial) | 常驻 daemon + 薄 CLI:列端口 / 开关 / 收发 / 全量日志 / wait-idle |
|
||||
| `test/run_boot_test.ps1` | `test/` | 一键闭环:采集 → 烧录 → 提取日志 → 校验 |
|
||||
| `test/verify_log.ps1` | `test/` | 日志标记校验(Required / Forbidden → PASS / FAIL) |
|
||||
| `test/run_boot_test.py` | `test/` | 一键闭环:采集 → 烧录 → 提取日志 → 校验 |
|
||||
| `test/verify_log.py` | `test/` | 日志标记校验(Required / Forbidden → PASS / FAIL) |
|
||||
| `.opencode/serial_config.json` | 仓库 | **串口端口单一配置源**(当前 COM6 / 115200);换端口只改这个文件 |
|
||||
| `test/ftp_*.py`(4 个)/ `ch395f_socket_test.py` / `time_sync_server.py` | `test/` | PC 端交互测试工具(FTP 客户端 / socket 回显 / 对时推送) |
|
||||
| `simulator/tests/*.py`(4 个) | `simulator/tests/` | LVGL 模拟器回归脚本(见 §5) |
|
||||
|
||||
### 4.2 新工具约定
|
||||
|
||||
- **PowerShell 脚本必须纯 ASCII**(编码铁律:会读写中文源码的脚本不得含非 ASCII,见 `run_boot_test.ps1` 头注);
|
||||
- **test/ 自动化脚本为纯 Python 标准库**(2026-09-20 由 PowerShell 迁移:绕开控制台代码页/执行策略陷阱,全部 I/O 显式 UTF-8/字节级处理;`requirements.txt` 仍须纯 ASCII——pip 按 GBK 代码页解码);
|
||||
- **Python 统一用仓库 `.venv`**;第三方依赖必须进 `requirements.txt`(纯 ASCII);
|
||||
- 落位:板端交互工具 → `test/`;模拟器回归 → `simulator/tests/`;
|
||||
- 新工具引入新的"通过标记"时,必须登记到 §6.2 标记表,供 `@test` 闭环复用。
|
||||
@@ -241,7 +241,7 @@ cmake --build simulator/build --config Debug # 0) 构建模拟器(须 0 警
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[代码改完] --> B["G1 @build(0 错 0 警,否则修复重编)"]
|
||||
B --> C["run_boot_test.ps1:烧录前武装采集(等 first-byte)"]
|
||||
B --> C["run_boot_test.py:烧录前武装采集(daemon 全量日志兜底)"]
|
||||
C --> D["@download 烧录(Reset & Run 触发重启)"]
|
||||
D --> E[提取日志 + verify_log 标记校验]
|
||||
E -->|exit 0| F["PASS → G3(UI 改动)→ L3 上机目检 → 提交"]
|
||||
@@ -263,7 +263,7 @@ flowchart TD
|
||||
|
||||
### 6.3 三条铁律
|
||||
|
||||
1. **采集必须先于烧录武装** —— 开串口 + clear 在烧录前完成(daemon 常驻全量日志兜底,不会漏字节;`run_boot_test.ps1` 已保证顺序);
|
||||
1. **采集必须先于烧录武装** —— 开串口 + clear 在烧录前完成(daemon 常驻全量日志兜底,不会漏字节;`run_boot_test.py` 已保证顺序);
|
||||
2. **端口以 `.opencode/serial_config.json` 为准** —— 每次连接前先读它;
|
||||
3. **静默判据覆盖时间窗** —— `-SettleMs=8000`(覆盖 boot ~3-4s 自然静默间隙)、`-MaxWaitMs=30000`(整体超时兜底);
|
||||
|
||||
@@ -333,7 +333,7 @@ flowchart LR
|
||||
| `storage_test.h` / `storage_test_task.c` | STORAGE 套件:FTL + FatFS 集成(TC-STO-01~04)+ 恢复测试 |
|
||||
| `gd5f_test.h` / `gd5f_test_task.c` | GD5F 套件:NAND 驱动 10 阶段 |
|
||||
| `sd2506_test.h` / `sd2506_test_task.c` | RTC 套件:SD2506 RTC 2 阶段组 |
|
||||
| `run_boot_test.ps1` / `verify_log.ps1` + `serial_tool` | 自动化闭环三件套(ps1 纯 ASCII / Python 仓库内置) |
|
||||
| `run_boot_test.py` / `verify_log.py` + `serial_tool` | 自动化闭环三件套(纯 Python 标准库 / 仓库内置) |
|
||||
| `ftp_test.py` / `ftp_capture.py` / `ftp_up_dl_test.py` / `ftp_speed_test.py` | FTP PC 端测试工具 |
|
||||
| `ch395f_socket_test.py` | CH395F PC 端 socket 回显 / 大文件传输工具 |
|
||||
| `time_sync_server.py` | 对时推送 PC 端服务器 |
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
|
||||
| 项 | 说明 |
|
||||
|---|---|
|
||||
| `@test` 标记 | 套件结尾汇总行(如 `=== Storage Tests:`)或阶段标题行;**必须与串口实际输出逐字一致**(`verify_log.ps1` 子串匹配) |
|
||||
| `@test` 标记 | 套件结尾汇总行(如 `=== Storage Tests:`)或阶段标题行;**必须与串口实际输出逐字一致**(`verify_log.py` 子串匹配) |
|
||||
| 禁止模式 | 默认 `FAIL;ERROR;HardFault;assert`,可按模块增补 |
|
||||
| PC 端脚本 | `test/*.py` 工具清单与调用方式 |
|
||||
| 门禁层级 | L1(板端套件)/ L2(串口标记 + 模拟器回归)/ L3(人工目检),见 `测试开发标准流程.md` §1 |
|
||||
@@ -201,7 +201,7 @@ DP-{NN}
|
||||
|
||||
| 通道 | 判定方 | 适用 |
|
||||
|---|---|---|
|
||||
| 串口日志标记 | `@test` / `verify_log.ps1` 子串匹配 | 启动序列、阶段标题行、关键事件 |
|
||||
| 串口日志标记 | `@test` / `verify_log.py` 子串匹配 | 启动序列、阶段标题行、关键事件 |
|
||||
| 返回值 / 寄存器 / 数据 | 板端 `XXX_TEST_CHECK` 断言 | 驱动命令、协议交互、数据完整性 |
|
||||
| 像素参考 | LVGL 模拟器回归(`simulator/tests/`) | UI 渲染、字体、样式 |
|
||||
| 内存池水位 | 模拟器池基线 / 往返常量 | LVGL 对象泄漏、结构漂移 |
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
├─ Step 0 预检 .venv + serial_tool(selftest/ports 确认 COM)
|
||||
├─ Step 1 构建验证 MDK-ARM/build.bat (0 错误 0 警告才继续)
|
||||
├─ Step 2 烧录 + 采集(一条命令编排:开串口 + 清日志 → 烧录 → wait-idle → 提取)
|
||||
│ test/run_boot_test.ps1 (内置 serial_tool + download.bat + verify_log.ps1)
|
||||
│ test/run_boot_test.py (内置 serial_tool + download.bat + verify_log.py)
|
||||
├─ Step 3 判定:
|
||||
│ PASS → 报告,结束
|
||||
│ FAIL → 依据 [MISS]/[HIT] + 日志尾部,修改代码后回到 Step 1
|
||||
@@ -23,15 +23,15 @@
|
||||
| `@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/run_boot_test.ps1` | `test/run_boot_test.ps1` | **一键闭环**:open + clear → 烧录 → wait-idle(静默 8s/超时 30s)→ 提取 `[RX]` → verify,退出码 0=PASS/1=FAIL/2=采集或烧录错误/3=用法错误 |
|
||||
| `test/run_boot_test.py` | `test/run_boot_test.py` | **一键闭环**:open + clear → 烧录 → wait-idle(静默 8s/超时 30s)→ 提取 `[RX]` → verify,退出码 0=PASS/1=FAIL/2=采集或烧录错误/3=用法错误 |
|
||||
| `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/verify_log.ps1` | `test/verify_log.ps1` | 对日志做标记校验,输出 PASS/FAIL 并返回退出码 |
|
||||
| `test/verify_log.py` | `test/verify_log.py` | 对日志做标记校验,输出 PASS/FAIL 并返回退出码 |
|
||||
|
||||
## 判定标准
|
||||
|
||||
- **构建**:`@build` 退出码 0(0 错误 0 警告),否则不进入硬件环节。
|
||||
- **烧录**:`@download` 退出码 0(`run_boot_test.ps1` 内部调用)。
|
||||
- **日志**:`verify_log.ps1` 退出码
|
||||
- **烧录**:`@download` 退出码 0(`run_boot_test.py` 内部调用)。
|
||||
- **日志**:`verify_log.py` 退出码
|
||||
- `0` = PASS:所有 `-Required` 标记均出现,且无 `-Forbidden` 模式命中
|
||||
- `1` = FAIL:存在缺失标记(`[MISS]`)或违规模式(`[HIT]`)
|
||||
- `2` = 日志文件不可读
|
||||
@@ -54,7 +54,7 @@
|
||||
| `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.ps1` 已保证);核对 COM 号、板子供电,看 `state/serial.log` 有无 SYS 异常行 |
|
||||
| 烧录后无数据(`no_data`) | 开串口 + clear 必须在烧录**前**完成(`run_boot_test.py` 已保证);核对 COM 号、板子供电,看 `state/serial.log` 有无 SYS 异常行 |
|
||||
| 端口被占用(`port busy`) | 先 `serial_tool.py close` 或 `restart`;检查其他程序占用;Keil ST-Link 不占 COM |
|
||||
| 日志中无 UI 相关输出 | 见下节「UI 自动化」 |
|
||||
|
||||
|
||||
+2
-2
@@ -27,7 +27,7 @@
|
||||
### P0-1 配置引擎临时缓冲用 C 库 malloc(MicroLIB C 堆仅 512 B)→ 真机配置加载/保存必然失败 ✅ 已修复(2026-09-18)
|
||||
- **位置**:`App/cfg_file.c` 修复前 `:485`(读缓冲 4,097B)/ `:522`(模板输出 5,120B)/ `:1038`(外科保存 Pass1 副本 ≤4,097B)/ `:1050`(保存输出 5,120B);根因参数在 `MDK-ARM/startup_stm32f407xx.s:43` `Heap_Size EQU 0x200`
|
||||
- **证据**(静态三重,可独立复核):① `.map`(2026-09-16)`0x2001e878 - 0x00000200 Zero RW HEAP` 且 `__heap_limit = 0x2001ea78` → **C 库堆总量 = 0x200 = 512 B**;② 交叉引用 `cfg_file.o(i.s_load_file)` / `(i.s_write_template)` / `(i.cfg_file_save)` `refers to malloc.o(i.malloc)`,请求量 4,097 / 5,120 B,且全仓库无 `pvPortMalloc` retarget;③ `.lnp` 带 `--library_type=microlib`,MicroLIB 堆为固定区不可外扩。调用链:`Src/freertos.c:234 net_cfg_load()` → `Drivers/BSP/NETCFG/net_cfg.c:146 cfg_file_load()` → `s_load_file()` → `malloc`。
|
||||
- **影响**:真机上配置文件**读不进也写不出**——上电加载回落工厂默认(192.168.1.100/24)、首启出厂模板生成失败、UI 保存与外科保存全部失败;日志签名 `read: malloc 4097B FAIL` / `template: malloc 5120B FAIL` / `load: file read FAIL, factory defaults kept`。**PC 模拟器走宿主 CRT 堆(MB 级)完全正常** → 双端行为分裂,双端 0 错 0 警也照不出来(`--cfgtest` PASS 亦不能证伪)。`test/verify_log.ps1` Forbidden 含 `FAIL`,一次 `@test` 即可捕获。
|
||||
- **影响**:真机上配置文件**读不进也写不出**——上电加载回落工厂默认(192.168.1.100/24)、首启出厂模板生成失败、UI 保存与外科保存全部失败;日志签名 `read: malloc 4097B FAIL` / `template: malloc 5120B FAIL` / `load: file read FAIL, factory defaults kept`。**PC 模拟器走宿主 CRT 堆(MB 级)完全正常** → 双端行为分裂,双端 0 错 0 警也照不出来(`--cfgtest` PASS 亦不能证伪)。`test/verify_log.py` Forbidden 含 `FAIL`,一次 `@test` 即可捕获。
|
||||
- **修复**(2026-09-18 实际实施):① `cfg_file_store` 后端新增 `cfg_store_alloc` / `cfg_store_free` / `cfg_store_heap_free` 三件套——MCU 端 = `pvPortMalloc`/`vPortFree`(取 FreeRTOS 堆 49,152B,与 `lftpd_io.c` 既有模式一致),模拟器端 = `malloc`/`free`;共享核心 `cfg_file.c` 内 C 库分配符号清零,双端仍可同源编译;② 新增编译期堆峰值预算 `CFG_HEAP_BUDGET = 10,240B`(typedef 负数组实现,ARMCC 5 C99 无 `_Static_assert`)——放大 `CFG_FILE_MAX_LEN`/`CFG_OUT_MAX` 会先编译失败,而不是退化为真机运行期失败;③ 四条失败路径(加载读失败 / 模板写失败 / 保存分配失败 / 保存写盘失败)补 `syslog_post(SYSLOG_SELFTEST, …)`,失败进自检记录页而非只留一行串口;`load: OK` 行附带 `heap free` 实测值;④ 文件头"属主任务"注释 netTask → defaultTask(P1-3 遗留漂移)。
|
||||
- **未采用方案**:静态缓冲(4,097 + 5,120 = 9,217B,当时 SRAM1 仅余 4,488B 放不下,须先做 CCM 迁移);调大 MicroLIB `Heap_Size`/加 `--heap`(只是把"必然失败"变成"够大就侥幸",且该堆无碎片与水位观测)。**流式读写(免整文件驻留内存,+≈1.2KB 即可彻底摆脱暂态堆)列为后续改进项**,见《项目审查与优化建议报告》§4.4.1。
|
||||
- **验证**:MCU `@build` 0 错 0 警;map 复核 `malloc.o` 不再参与链接、`Removing startup_stm32f407xx.o(HEAP), (512 bytes)` → 静态区 126,584 → **126,064 B**(余 4,488 → **5,008 B**),并新增 `cfg_file.o → cfg_file_store_mcu.o(cfg_store_alloc) → heap_4.o(pvPortMalloc)`;模拟器重建 0 警 + `lvgl_sim.exe --cfgtest` **PASS**(加载 523B → 外部编辑沉降重载 → 外科保存注释/值无损)。**待补**:真机 `@test` 一次,确认 `load: OK` 出现且 Forbidden `FAIL` 零命中。
|
||||
@@ -161,7 +161,7 @@
|
||||
- `Drivers/BSP/UI/ui_net_sink.c`:代次号重建监听、逐字节失步重同步、`RM_MAX_RX_PAYLOAD` 长度防护齐全。
|
||||
- FatFS 卷归属:产品构建单卷拥有者 = defaultTask 的 `f_mount` 实例,lftpd 复用不另 mount;`FF_FS_REENTRANT=1` ✓。
|
||||
- CH395F trap 修复在位:Trap 01(listen 顺序)、Trap 10(xTaskNotify)、Trap 11(单连接 accept)、Trap 20(send 分块 `NET_SEND_CHUNK_MAX`)均核验存在。
|
||||
- 宿主侧:`test/verify_log.ps1`、`test/run_boot_test.ps1`、`test/time_sync_server.py`(伪 epoch 与时区处理注释完备)、`tools/menu_gen/gen_menu.py`(自带校验:拒 Tab/缩进非整倍数/节点 >255)、`tools/serial_tool/`(近期已验证)均健康。
|
||||
- 宿主侧:`test/verify_log.py`、`test/run_boot_test.py`、`test/time_sync_server.py`(伪 epoch 与时区处理注释完备)、`tools/menu_gen/gen_menu.py`(自带校验:拒 Tab/缩进非整倍数/节点 >255)、`tools/serial_tool/`(近期已验证)均健康。
|
||||
|
||||
## 待确认(需手册/硬件/产品定义,暂不直接改码)
|
||||
|
||||
|
||||
+2
-2
@@ -149,7 +149,7 @@ python tools/font_gen/extract_cn.py # 任意 python3:
|
||||
|---|---|---|
|
||||
| Python 串口助手 | `tools/serial_tool/serial_tool.py`(仓库内置,.venv + pyserial) | 常驻 daemon + 薄 CLI + 全量日志文件;无需单独安装,`pip install -r requirements.txt` 即就绪 |
|
||||
| 调试串口 | USART1(PA9 TX / PA10 RX),115200 8N1 | **端口单一配置源 `.opencode/serial_config.json`**(本机 COM6);换端口只改这个文件 |
|
||||
| 一键测试 | `test\run_boot_test.ps1`(即 `@test`) | 开串口 + 清日志 → 烧录 → wait-idle(静默 8s/超时 30s)→ 提取 → 标记校验;默认端口取 `serial_config.json` / 115200,退出码 0=PASS / 1=FAIL / 2=采集或烧录错误 / 3=用法错误 |
|
||||
| 一键测试 | `test\run_boot_test.py`(即 `@test`,`& '.venv\Scripts\python.exe' test/run_boot_test.py`) | 开串口 + 清日志 → 烧录 → wait-idle(静默 8s/超时 30s)→ 提取 → 标记校验;默认端口取 `serial_config.json` / 115200,退出码 0=PASS / 1=FAIL / 2=采集或烧录错误 / 3=用法错误 |
|
||||
|
||||
> 采集 = 端口常开 + daemon 全量日志(`tools/serial_tool/state/serial.log`),无采集窗口/first-byte/EOF 重试仪式。
|
||||
> 命令契约与陷阱见 `docs/测试说明/串口助手设计说明.md`、`docs/测试说明/自动化测试流程.md`。
|
||||
@@ -381,7 +381,7 @@ pre-commit run --all-files
|
||||
|---|---|
|
||||
| 下载方式 | `& "MDK-ARM\download.bat"`(Keil 命令行:烧写 + Reset & Run) |
|
||||
| 下载器 | 工程已配 `UL2CM3` 调试驱动 + `STM32F4xx_1024` Flash 算法;**物理下载器按现场选择**(本机为 ST-Link——不占 COM 口,可与串口调试并存);新机器在 Keil `Options for Target → Debug → Settings` 选择与硬件一致的驱动 |
|
||||
| 调试日志 | USART1 115200;抓开机日志必须**先武装采集再复位**(`test/run_boot_test.ps1 -FlashCmd` 已保证顺序) |
|
||||
| 调试日志 | USART1 115200;抓开机日志必须**先武装采集再复位**(`test/run_boot_test.py --flash-cmd` 已保证顺序) |
|
||||
|
||||
### 3.5 功能配置开关
|
||||
|
||||
|
||||
@@ -1,174 +0,0 @@
|
||||
<#
|
||||
run_boot_test.ps1 - Automated boot-log capture + verify in one call.
|
||||
|
||||
Uses the repo's Python serial assistant (tools/serial_tool/serial_tool.py,
|
||||
resident daemon on 127.0.0.1:18765, auto-started on first call):
|
||||
1. open COM port + clear log (fresh generation for this run)
|
||||
2. flash via -FlashCmd (download.bat: download + Reset & Run)
|
||||
3. wait-idle: until RX silence >= SettleMs, or MaxWaitMs timeout
|
||||
4. extract [RX] payload lines from the log file (prefix/timestamp stripped)
|
||||
5. verify_log.ps1 marker check
|
||||
|
||||
The capture is armed (port open, log cleared) BEFORE flashing, and the
|
||||
resident daemon buffers everything in tools/serial_tool/state/serial.log -
|
||||
no capture-window timing ritual, no first-byte wait, no EOF retries.
|
||||
|
||||
Usage:
|
||||
powershell -ExecutionPolicy Bypass -File run_boot_test.ps1 `
|
||||
[-Port COM6] [-BaudRate 115200] `
|
||||
[-SettleMs 8000] [-MaxWaitMs 30000] `
|
||||
[-FlashCmd "D:\...\download.bat"] `
|
||||
[-Required "App init OK;Network Init;net init: OK"] `
|
||||
[-Forbidden "FAIL;ERROR;HardFault"] `
|
||||
[-OutDir C:\temp] [-ShowTailLines 15]
|
||||
|
||||
-Port default: .opencode/serial_config.json "portName" entry.
|
||||
-SettleMs: RX silence that ends the capture (default 8000 - boot has a
|
||||
natural 3~4s silent gap between tasks; 2000 would truncate the log).
|
||||
|
||||
Exit codes:
|
||||
0 = PASS (captured + all required markers + no forbidden)
|
||||
1 = FAIL (marker miss / forbidden hit)
|
||||
2 = capture or flash error
|
||||
3 = usage / preflight error
|
||||
|
||||
NOTE: ASCII-only on purpose - project source files contain Chinese and must
|
||||
never be rewritten by encoding-sensitive PowerShell. Keep this script ASCII.
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$Port = "",
|
||||
[int]$BaudRate = 115200,
|
||||
[int]$SettleMs = 8000,
|
||||
[int]$MaxWaitMs = 30000,
|
||||
[string]$FlashCmd = "",
|
||||
[string]$Required = "App init OK;Network Init;net init: OK",
|
||||
[string]$Forbidden = "FAIL;ERROR;HardFault",
|
||||
[string]$OutDir = "$env:TEMP\opencode",
|
||||
[int]$ShowTailLines = 15
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$repoRoot = Split-Path -Parent $scriptDir
|
||||
$py = Join-Path $repoRoot ".venv\Scripts\python.exe"
|
||||
$tool = Join-Path $repoRoot "tools\serial_tool\serial_tool.py"
|
||||
$verifyScript = Join-Path $scriptDir "verify_log.ps1"
|
||||
$cfgFile = Join-Path $repoRoot ".opencode\serial_config.json"
|
||||
|
||||
# -Port default: the single source of truth is .opencode/serial_config.json
|
||||
if (-not $Port) {
|
||||
if (Test-Path -LiteralPath $cfgFile) {
|
||||
$Port = (Get-Content -LiteralPath $cfgFile -Raw -Encoding UTF8 | ConvertFrom-Json).portName
|
||||
}
|
||||
}
|
||||
if (-not $Port) {
|
||||
Write-Output "RUN_TEST_ERROR: no -Port given and no portName in $cfgFile"
|
||||
exit 3
|
||||
}
|
||||
|
||||
# Default flash command: resolve relative to the repo (no stale absolute paths)
|
||||
if (-not $FlashCmd) {
|
||||
$FlashCmd = Join-Path $repoRoot "MDK-ARM\download.bat"
|
||||
}
|
||||
|
||||
foreach ($p in @($py, $tool, $verifyScript)) {
|
||||
if (-not (Test-Path -LiteralPath $p)) {
|
||||
Write-Output "RUN_TEST_ERROR: missing $p"
|
||||
exit 3
|
||||
}
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $FlashCmd)) {
|
||||
Write-Output "RUN_TEST_ERROR: flash command not found: $FlashCmd"
|
||||
exit 3
|
||||
}
|
||||
|
||||
if (-not (Test-Path -LiteralPath $OutDir)) {
|
||||
New-Item -ItemType Directory -Path $OutDir -Force | Out-Null
|
||||
}
|
||||
|
||||
$stamp = Get-Date -Format "yyyyMMdd_HHmmss"
|
||||
$bootTxt = Join-Path $OutDir "boot_log_$stamp.txt"
|
||||
|
||||
function Invoke-Tool {
|
||||
param(
|
||||
[Parameter(Position = 0)][string]$Command,
|
||||
[Parameter(ValueFromRemainingArguments = $true)][string[]]$Rest
|
||||
)
|
||||
$script:toolOut = (& $py $tool $Command @Rest 2>$null) -join "`n"
|
||||
$script:toolRc = $LASTEXITCODE
|
||||
}
|
||||
|
||||
Write-Output "=== Boot test $stamp ==="
|
||||
Write-Output "Port: $Port @ ${BaudRate} settle=${SettleMs}ms max=${MaxWaitMs}ms"
|
||||
|
||||
# 1. open port (auto-starts the serial daemon if it is not running)
|
||||
Invoke-Tool "open" "--port" $Port "--baud" $BaudRate "--timeout" 15
|
||||
if ($script:toolRc -ne 0) {
|
||||
Write-Output "RUN_TEST_ERROR: open failed: $($script:toolOut)"
|
||||
exit 2
|
||||
}
|
||||
|
||||
# 2. clear log (fresh generation; only data after this belongs to the run)
|
||||
Invoke-Tool "clear" "--timeout" 15
|
||||
if ($script:toolRc -ne 0) {
|
||||
Write-Output "RUN_TEST_ERROR: clear failed: $($script:toolOut)"
|
||||
exit 2
|
||||
}
|
||||
|
||||
# 3. flash (download + Reset & Run). A failed flash does not abort: the
|
||||
# board may still have reset; capture + marker check will judge the result.
|
||||
Write-Output "FLASH_START: $FlashCmd"
|
||||
$sw = [System.Diagnostics.Stopwatch]::StartNew()
|
||||
& $FlashCmd
|
||||
$flashRc = $LASTEXITCODE
|
||||
$sw.Stop()
|
||||
Write-Output ("FLASH_RC: {0} elapsed_ms={1}" -f $flashRc, $sw.ElapsedMilliseconds)
|
||||
if ($flashRc -ne 0) {
|
||||
Write-Output "RUN_TEST_WARN: flash rc=$flashRc (continuing: board may still have reset)"
|
||||
}
|
||||
|
||||
# 4. wait for RX silence (settle) or the overall timeout
|
||||
$waitTimeoutS = [int]($MaxWaitMs / 1000) + 20
|
||||
Invoke-Tool "wait-idle" "--settle-ms" $SettleMs "--max-ms" $MaxWaitMs "--timeout" $waitTimeoutS
|
||||
if ($script:toolRc -ne 0) {
|
||||
Write-Output "RUN_TEST_ERROR: wait-idle failed: $($script:toolOut)"
|
||||
exit 2
|
||||
}
|
||||
$wait = $script:toolOut | ConvertFrom-Json
|
||||
Write-Output ("WAIT: reason={0} rx_bytes={1} waited_ms={2}" -f $wait.reason, $wait.rx_bytes, $wait.waited_ms)
|
||||
if ($wait.reason -eq "no_data") {
|
||||
Write-Output "RUN_TEST_ERROR: no RX data at all (check port / board power / flash)"
|
||||
exit 2
|
||||
}
|
||||
|
||||
# 5. extract [RX] payload lines (strip the "[RX] <iso-timestamp> " prefix)
|
||||
Invoke-Tool "log" "--tail" 0 "--timeout" 15
|
||||
if ($script:toolRc -ne 0) {
|
||||
Write-Output "RUN_TEST_ERROR: log read failed: $($script:toolOut)"
|
||||
exit 2
|
||||
}
|
||||
$logJson = $script:toolOut | ConvertFrom-Json
|
||||
$rx = New-Object System.Collections.Generic.List[string]
|
||||
foreach ($l in $logJson.lines) {
|
||||
if ($l -is [string] -and $l.StartsWith("[RX] ")) {
|
||||
$rest = $l.Substring(5)
|
||||
$sp = $rest.IndexOf(" ")
|
||||
if ($sp -ge 0) { $rx.Add($rest.Substring($sp + 1)) } else { $rx.Add($rest) }
|
||||
}
|
||||
}
|
||||
if ($rx.Count -eq 0) {
|
||||
Write-Output "RUN_TEST_ERROR: no [RX] lines in log (device silent?)"
|
||||
exit 2
|
||||
}
|
||||
[System.IO.File]::WriteAllLines($bootTxt, $rx, (New-Object System.Text.UTF8Encoding($false)))
|
||||
Write-Output "Boot log: $bootTxt (lines=$($rx.Count) gen=$($logJson.gen))"
|
||||
|
||||
# 6. marker verification
|
||||
& $verifyScript -LogPath $bootTxt -Required $Required -Forbidden $Forbidden `
|
||||
-ShowTailLines $ShowTailLines
|
||||
$verifyRc = $LASTEXITCODE
|
||||
Write-Output "RUN_TEST_RC=$verifyRc"
|
||||
exit $verifyRc
|
||||
@@ -0,0 +1,218 @@
|
||||
#!/usr/bin/env python3
|
||||
"""run_boot_test.py - 一键开机日志抓取 + 校验(纯标准库,2026-09-20 替代 run_boot_test.ps1)。
|
||||
|
||||
调用仓库内置串口助手(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 超时
|
||||
4. 从日志提取 [RX] 载荷行(剥掉 "[RX] <ISO时间戳> " 前缀)
|
||||
5. verify_log.py 标记校验
|
||||
|
||||
采集在烧录**之前**武装(open + clear 先于烧录),daemon 常驻全量日志兜底,
|
||||
无采集窗口/首字节等待仪式(与旧 PowerShell 版行为一致)。
|
||||
|
||||
相对旧 PowerShell 版的加固(本机 Windows 环境实测教训,2026-09-20):
|
||||
- 子进程输出按**字节**读取、显式 UTF-8 解码 —— 不受控制台代码页(GBK)影响
|
||||
- serial_config.json 按 UTF-8(容忍 BOM)解析 —— 无 PS 5.1 按 GBK 读 JSON
|
||||
导致 "Unterminated string" 崩溃的问题
|
||||
- 不经 PowerShell —— 无执行策略(scripts disabled)拦截
|
||||
- 烧录带超时(默认 180s;旧版无超时,坏 SWD 连接会永久挂起)
|
||||
|
||||
用法:
|
||||
& '.venv\\Scripts\\python.exe' test/run_boot_test.py
|
||||
[--port COM7] [--baud-rate 115200] [--settle-ms 8000] [--max-wait-ms 30000]
|
||||
[--flash-cmd MDK-ARM\\download.bat] [--flash-timeout 180]
|
||||
[--required "App init OK;Network Init;net init: OK"]
|
||||
[--forbidden "FAIL;ERROR;HardFault"]
|
||||
[--out-dir <目录>] [--show-tail-lines 15]
|
||||
|
||||
--port 缺省取 .opencode/serial_config.json 的 portName(单一配置源)
|
||||
|
||||
退出码(与旧版一致):
|
||||
0 = PASS(采集成功 + Required 全中 + Forbidden 零命中)
|
||||
1 = FAIL(标记缺失/命中)
|
||||
2 = 采集或烧录错误
|
||||
3 = 用法/预检错误
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
REPO_ROOT = SCRIPT_DIR.parent
|
||||
SERIAL_TOOL = REPO_ROOT / "tools" / "serial_tool" / "serial_tool.py"
|
||||
CFG_FILE = REPO_ROOT / ".opencode" / "serial_config.json"
|
||||
DEFAULT_FLASH = REPO_ROOT / "MDK-ARM" / "download.bat"
|
||||
|
||||
REQUIRED_DEFAULT = "App init OK;Network Init;net init: OK"
|
||||
FORBIDDEN_DEFAULT = "FAIL;ERROR;HardFault"
|
||||
|
||||
|
||||
def _cli(cmd_args, timeout):
|
||||
"""调用串口助手 CLI,返回 (rc, stdout_text, parsed_json_or_None)。
|
||||
|
||||
全程字节级处理(capture_output 取 bytes 后显式 UTF-8 解码),
|
||||
任何 Windows 控制台代码页都不参与数据路径。
|
||||
"""
|
||||
try:
|
||||
proc = subprocess.run(cmd_args, capture_output=True, timeout=timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
return 2, "client timeout ({}s)".format(timeout), None
|
||||
text = proc.stdout.decode("utf-8", errors="replace")
|
||||
try:
|
||||
return proc.returncode, text, json.loads(text)
|
||||
except ValueError:
|
||||
return proc.returncode, text, None
|
||||
|
||||
|
||||
def _tool_cmd(py, cmd, *rest, timeout=15):
|
||||
rc, out, data = _cli([py, str(SERIAL_TOOL), cmd, *rest], timeout)
|
||||
if rc != 0 or data is None:
|
||||
return None, out
|
||||
return data, out
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
# 控制台输出固定 UTF-8:Windows 代码页只影响显示,不会让脚本崩溃
|
||||
for stream in (sys.stdout, sys.stderr):
|
||||
try:
|
||||
stream.reconfigure(encoding="utf-8", errors="replace")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
ap = argparse.ArgumentParser(description="boot log capture + verify (pure stdlib, no PowerShell)")
|
||||
ap.add_argument("--port", default="",
|
||||
help="serial port (default: portName from .opencode/serial_config.json)")
|
||||
ap.add_argument("--baud-rate", type=int, default=115200)
|
||||
ap.add_argument("--settle-ms", type=int, default=8000,
|
||||
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("--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,
|
||||
help="flash subprocess timeout in seconds")
|
||||
ap.add_argument("--required", default=REQUIRED_DEFAULT,
|
||||
help="required markers, ';' separated")
|
||||
ap.add_argument("--forbidden", default=FORBIDDEN_DEFAULT,
|
||||
help="forbidden patterns, ';' separated")
|
||||
ap.add_argument("--out-dir", default=str(Path(tempfile.gettempdir()) / "opencode"))
|
||||
ap.add_argument("--show-tail-lines", type=int, default=15)
|
||||
args = ap.parse_args(argv)
|
||||
|
||||
# 串口助手 CLI 用当前解释器(AGENTS.md 统一入口 = 仓库 .venv python)
|
||||
py = sys.executable
|
||||
|
||||
# 端口缺省:单一配置源 .opencode/serial_config.json(UTF-8,容忍 BOM)
|
||||
port = args.port
|
||||
if not port and CFG_FILE.is_file():
|
||||
try:
|
||||
cfg = json.loads(CFG_FILE.read_text(encoding="utf-8-sig"))
|
||||
port = str(cfg.get("portName") or "")
|
||||
except (OSError, ValueError) as e:
|
||||
print("RUN_TEST_ERROR: cannot parse {}: {}".format(CFG_FILE, e))
|
||||
return 3
|
||||
if not port:
|
||||
print("RUN_TEST_ERROR: no --port given and no portName in {}".format(CFG_FILE))
|
||||
return 3
|
||||
|
||||
flash_cmd = Path(args.flash_cmd) if args.flash_cmd else DEFAULT_FLASH
|
||||
|
||||
# 预检(对应旧版 missing-file 检查)
|
||||
for p in (SERIAL_TOOL, SCRIPT_DIR / "verify_log.py", flash_cmd):
|
||||
if not p.is_file():
|
||||
print("RUN_TEST_ERROR: missing {}".format(p))
|
||||
return 3
|
||||
|
||||
out_dir = Path(args.out_dir)
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
boot_txt = out_dir / "boot_log_{}.txt".format(stamp)
|
||||
|
||||
def tool(cmd, *rest, timeout=15):
|
||||
return _tool_cmd(py, cmd, *rest, timeout=timeout)
|
||||
|
||||
print("=== Boot test {} ===".format(stamp))
|
||||
print("Port: {} @ {} settle={}ms max={}ms".format(
|
||||
port, args.baud_rate, args.settle_ms, args.max_wait_ms))
|
||||
|
||||
# 1. open(幂等;未运行则自动拉起常驻 daemon)
|
||||
data, out = tool("open", "--port", port, "--baud", str(args.baud_rate), "--timeout", "15")
|
||||
if data is None:
|
||||
print("RUN_TEST_ERROR: open failed: {}".format(out))
|
||||
return 2
|
||||
|
||||
# 2. clear(新代际;此后数据才属于本次运行)
|
||||
data, out = tool("clear", "--timeout", "15")
|
||||
if data is None:
|
||||
print("RUN_TEST_ERROR: clear failed: {}".format(out))
|
||||
return 2
|
||||
|
||||
# 3. flash(下载 + Reset & Run)。烧录失败不中断:板子可能已复位,
|
||||
# 由采集 + 标记判定结果(与旧版一致)
|
||||
print("FLASH_START: {}".format(flash_cmd))
|
||||
t0 = time.monotonic()
|
||||
try:
|
||||
proc = subprocess.run(["cmd", "/c", str(flash_cmd)],
|
||||
capture_output=True, timeout=args.flash_timeout)
|
||||
flash_rc = proc.returncode
|
||||
flash_bytes = (proc.stdout or b"") + (proc.stderr or b"")
|
||||
except subprocess.TimeoutExpired as e:
|
||||
flash_rc = -1
|
||||
flash_bytes = (e.stdout or b"") + (e.stderr or b"")
|
||||
elapsed_ms = int((time.monotonic() - t0) * 1000)
|
||||
for line in flash_bytes.decode("utf-8", errors="replace").strip().splitlines()[-12:]:
|
||||
print(line)
|
||||
print("FLASH_RC: {} elapsed_ms={}".format(flash_rc, elapsed_ms))
|
||||
if flash_rc != 0:
|
||||
print("RUN_TEST_WARN: flash rc={} (continuing: board may still have reset)".format(flash_rc))
|
||||
|
||||
# 4. wait-idle(RX 静默收尾或整体超时)
|
||||
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)
|
||||
if data is None:
|
||||
print("RUN_TEST_ERROR: wait-idle failed: {}".format(out))
|
||||
return 2
|
||||
print("WAIT: reason={} rx_bytes={} waited_ms={}".format(
|
||||
data.get("reason"), data.get("rx_bytes"), data.get("waited_ms")))
|
||||
if data.get("reason") == "no_data":
|
||||
print("RUN_TEST_ERROR: no RX data at all (check port / board power / flash)")
|
||||
return 2
|
||||
|
||||
# 5. 提取 [RX] 载荷行(剥掉 "[RX] <ISO时间戳> " 前缀)
|
||||
data, out = tool("log", "--tail", "0", "--timeout", "15")
|
||||
if data is None:
|
||||
print("RUN_TEST_ERROR: log read failed: {}".format(out))
|
||||
return 2
|
||||
rx = []
|
||||
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 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")))
|
||||
|
||||
# 6. 标记校验
|
||||
from verify_log import verify
|
||||
rc = verify(str(boot_txt), args.required, args.forbidden, args.show_tail_lines)
|
||||
print("RUN_TEST_RC={}".format(rc))
|
||||
return rc
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,102 +0,0 @@
|
||||
<#
|
||||
verify_log.ps1 - Check a captured MCU debug log against boot/test markers.
|
||||
|
||||
Used by the @test loop: after flashing, the fresh boot log is extracted from
|
||||
the serial assistant log (tools/serial_tool), then this script greps it and
|
||||
prints a structured verdict.
|
||||
|
||||
Usage:
|
||||
powershell -ExecutionPolicy Bypass -File verify_log.ps1 `
|
||||
-LogPath <file> `
|
||||
[-Required 'marker1;marker2'] `
|
||||
[-Forbidden 'FAIL;ERROR;HardFault'] `
|
||||
[-ShowTailLines 40]
|
||||
|
||||
Exit code:
|
||||
0 = PASS (every required marker found, no forbidden pattern matched)
|
||||
1 = FAIL (missing required marker or forbidden pattern present)
|
||||
2 = ERROR (log file not found / unreadable)
|
||||
|
||||
Default required markers match the STM32F4-Base boot sequence
|
||||
(see App/app_main.c + App/task/net_task.c). Override per test case, e.g.:
|
||||
-Required 'App init OK;net init: OK;PHY: 100M FULL'
|
||||
-Forbidden 'FAIL;ERROR;HardFault;assert'
|
||||
|
||||
NOTE: ASCII-only on purpose - project source files contain Chinese and must
|
||||
never be rewritten by encoding-sensitive PowerShell. Keep this script ASCII.
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$LogPath,
|
||||
|
||||
[string]$Required = 'App init OK;Network Init;net init: OK',
|
||||
[string]$Forbidden = 'FAIL;ERROR;HardFault;assert',
|
||||
[int]$ShowTailLines = 40
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
if (-not (Test-Path -LiteralPath $LogPath)) {
|
||||
Write-Output "VERIFY_ERROR: log file not found: $LogPath"
|
||||
exit 2
|
||||
}
|
||||
|
||||
try {
|
||||
$content = Get-Content -LiteralPath $LogPath -Encoding UTF8 -ErrorAction Stop
|
||||
} catch {
|
||||
# Fall back to default encoding if UTF-8 read fails
|
||||
try {
|
||||
$content = Get-Content -LiteralPath $LogPath -ErrorAction Stop
|
||||
} catch {
|
||||
Write-Output "VERIFY_ERROR: cannot read log file: $LogPath"
|
||||
exit 2
|
||||
}
|
||||
}
|
||||
|
||||
$all = $content -join "`n"
|
||||
|
||||
$requiredList = @($Required -split ';' | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne '' })
|
||||
$forbiddenList = @($Forbidden -split ';' | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne '' })
|
||||
|
||||
Write-Output "=== Log verdict: $LogPath ==="
|
||||
Write-Output ("Lines: {0}, bytes: {1}" -f $content.Count, (Get-Item -LiteralPath $LogPath).Length)
|
||||
|
||||
$missing = @()
|
||||
foreach ($m in $requiredList) {
|
||||
if ($all.Contains($m)) {
|
||||
Write-Output ("[OK] required marker found: {0}" -f $m)
|
||||
} else {
|
||||
Write-Output ("[MISS] required marker NOT found: {0}" -f $m)
|
||||
$missing += $m
|
||||
}
|
||||
}
|
||||
|
||||
$hits = @()
|
||||
foreach ($f in $forbiddenList) {
|
||||
$matches = @($content | Where-Object { $_ -like "*$f*" })
|
||||
if ($matches.Count -gt 0) {
|
||||
Write-Output ("[HIT] forbidden pattern: {0} ({1} line(s))" -f $f, $matches.Count)
|
||||
$hits += $f
|
||||
} else {
|
||||
Write-Output ("[OK] forbidden pattern absent: {0}" -f $f)
|
||||
}
|
||||
}
|
||||
|
||||
if ($ShowTailLines -gt 0 -and $content.Count -gt 0) {
|
||||
Write-Output ""
|
||||
Write-Output "=== Last $ShowTailLines line(s) of log ==="
|
||||
$start = [Math]::Max(0, $content.Count - $ShowTailLines)
|
||||
$content[$start..($content.Count - 1)] | ForEach-Object { Write-Output $_ }
|
||||
}
|
||||
|
||||
if ($missing.Count -gt 0 -or $hits.Count -gt 0) {
|
||||
Write-Output ""
|
||||
Write-Output "VERDICT: FAIL"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Output ""
|
||||
Write-Output "VERDICT: PASS"
|
||||
exit 0
|
||||
@@ -0,0 +1,130 @@
|
||||
#!/usr/bin/env python3
|
||||
"""verify_log.py - 对抓取的 MCU 调试日志做标记校验(纯标准库,2026-09-20 替代 verify_log.ps1)。
|
||||
|
||||
用于 @test 闭环:烧录后从串口助手日志提取的开机日志交给本脚本 grep 标记,
|
||||
输出结构化判定(格式与旧 PowerShell 版逐字一致,下游文档/习惯不受影响)。
|
||||
|
||||
用法:
|
||||
& '.venv\\Scripts\\python.exe' test/verify_log.py --log-path <file>
|
||||
[--required 'marker1;marker2']
|
||||
[--forbidden 'FAIL;ERROR;HardFault;assert']
|
||||
[--show-tail-lines 40]
|
||||
|
||||
退出码(与旧版一致):
|
||||
0 = PASS(所有 Required 标记找到,Forbidden 零命中)
|
||||
1 = FAIL(缺 Required 或命中 Forbidden)
|
||||
2 = ERROR(日志文件不存在/不可读)
|
||||
|
||||
默认 Required 标记对应 STM32F4-Base 启动序列(见 App/app_main.c + App/task/net_task.c),
|
||||
按测试用例覆盖,例如:
|
||||
--required 'App init OK;net init: OK;PHY: 100M FULL'
|
||||
--forbidden 'FAIL;ERROR;HardFault;assert'
|
||||
|
||||
语义约定(与旧 PowerShell 版对齐):
|
||||
- Required:大小写敏感子串匹配(全文)
|
||||
- Forbidden:大小写**不敏感**子串匹配、逐行计数(旧版 -like 语义;
|
||||
如 Forbidden 'FAIL' 会命中 "write /log/run.log fail (7)" 这类行)
|
||||
- 读取编码:先按 UTF-8,解码失败回退系统 locale 编码(旧版 Get-Content 行为)
|
||||
- 控制台输出重配置为 UTF-8(errors=replace):任何 Windows 控制台代码页
|
||||
都只影响终端显示,不会导致本脚本崩溃
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import locale
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REQUIRED_DEFAULT = "App init OK;Network Init;net init: OK"
|
||||
FORBIDDEN_DEFAULT = "FAIL;ERROR;HardFault;assert"
|
||||
|
||||
|
||||
def _split_list(spec):
|
||||
"""';' 分隔列表 → 去空白、去空项。"""
|
||||
return [p.strip() for p in spec.split(";") if p.strip()]
|
||||
|
||||
|
||||
def _read_lines(path):
|
||||
raw = Path(path).read_bytes()
|
||||
for enc in ("utf-8", locale.getpreferredencoding(False)):
|
||||
try:
|
||||
return raw.decode(enc).splitlines()
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
return raw.decode("utf-8", errors="replace").splitlines()
|
||||
|
||||
|
||||
def verify(log_path, required=REQUIRED_DEFAULT, forbidden=FORBIDDEN_DEFAULT,
|
||||
show_tail=40, out=print):
|
||||
"""标记校验核心(可被 run_boot_test.py 复用)。返回退出码 0/1/2。"""
|
||||
p = Path(log_path)
|
||||
if not p.is_file():
|
||||
out("VERIFY_ERROR: log file not found: {}".format(log_path))
|
||||
return 2
|
||||
try:
|
||||
lines = _read_lines(log_path)
|
||||
except OSError:
|
||||
out("VERIFY_ERROR: cannot read log file: {}".format(log_path))
|
||||
return 2
|
||||
|
||||
full = "\n".join(lines)
|
||||
req_list = _split_list(required)
|
||||
forb_list = _split_list(forbidden)
|
||||
|
||||
out("=== Log verdict: {} ===".format(log_path))
|
||||
out("Lines: {}, bytes: {}".format(len(lines), p.stat().st_size))
|
||||
|
||||
missing = []
|
||||
for m in req_list:
|
||||
if m in full:
|
||||
out("[OK] required marker found: {}".format(m))
|
||||
else:
|
||||
out("[MISS] required marker NOT found: {}".format(m))
|
||||
missing.append(m)
|
||||
|
||||
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))
|
||||
hits.append(f)
|
||||
else:
|
||||
out("[OK] forbidden pattern absent: {}".format(f))
|
||||
|
||||
if show_tail > 0 and lines:
|
||||
out("")
|
||||
out("=== Last {} line(s) of log ===".format(show_tail))
|
||||
for ln in lines[-show_tail:]:
|
||||
out(ln)
|
||||
|
||||
out("")
|
||||
if missing or hits:
|
||||
out("VERDICT: FAIL")
|
||||
return 1
|
||||
out("VERDICT: PASS")
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
# 控制台输出固定 UTF-8:Windows 代码页(GBK/UTF-8 终端)均不会让本脚本崩溃
|
||||
for stream in (sys.stdout, sys.stderr):
|
||||
try:
|
||||
stream.reconfigure(encoding="utf-8", errors="replace")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
ap = argparse.ArgumentParser(description="MCU debug log marker check (pure stdlib)")
|
||||
ap.add_argument("--log-path", required=True, help="boot log file to check")
|
||||
ap.add_argument("--required", default=REQUIRED_DEFAULT,
|
||||
help="required markers, ';' separated (case-sensitive)")
|
||||
ap.add_argument("--forbidden", default=FORBIDDEN_DEFAULT,
|
||||
help="forbidden patterns, ';' separated (case-insensitive)")
|
||||
ap.add_argument("--show-tail-lines", type=int, default=40,
|
||||
help="show last N log lines (0 = off)")
|
||||
args = ap.parse_args(argv)
|
||||
|
||||
return verify(args.log_path, args.required, args.forbidden, args.show_tail_lines)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user