串口采集/测试去重:删 tools/serial_tool 与 test/host run_boot_test/verify_log 副本,引用改指 .dsh/skills 串口 skill
This commit is contained in:
+2
-2
@@ -16,10 +16,10 @@
|
||||
|
||||
```bash
|
||||
# @test 一键闭环(推荐)
|
||||
& '.venv\Scripts\python.exe' test/host/run_boot_test.py
|
||||
& '.venv\Scripts\python.exe' .dsh/skills/serial-boot-test/scripts/run_boot_test.py
|
||||
|
||||
# 单独校验日志
|
||||
& '.venv\Scripts\python.exe' test/host/verify_log.py <日志文件>
|
||||
& '.venv\Scripts\python.exe' .dsh/skills/serial-boot-test/scripts/verify_log.py <日志文件>
|
||||
|
||||
# 多套件回归编排(需真机;自动改开关 + 构建 + 烧录 + 采集,结束后还原)
|
||||
& '.venv\Scripts\python.exe' test/run_all_suites.py
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
1. 在 `test_config.h` 加开关(并入互斥 `#error` 检查)
|
||||
2. 新建 `<模块>_test_task.c/h`
|
||||
3. **注册进 Keil 工程**(`<FilePath>..\test\board\xxx.c</FilePath>`)
|
||||
4. 跑回归:`@test`(`test/host/run_boot_test.py`)
|
||||
4. 跑回归:`@test`(`.dsh/skills/serial-boot-test/scripts/run_boot_test.py`)
|
||||
|
||||
## 相关文档
|
||||
|
||||
|
||||
+2
-2
@@ -54,10 +54,10 @@
|
||||
|
||||
```bash
|
||||
# @test 一键闭环(推荐,需真机)
|
||||
& '.venv\Scripts\python.exe' test/host/run_boot_test.py
|
||||
& '.venv\Scripts\python.exe' .dsh/skills/serial-boot-test/scripts/run_boot_test.py
|
||||
|
||||
# 测试套件构建的回归:要求套件跑到汇总行且断言数 > 0(T-2)
|
||||
& '.venv\Scripts\python.exe' test/host/run_boot_test.py `
|
||||
& '.venv\Scripts\python.exe' .dsh/skills/serial-boot-test/scripts/run_boot_test.py `
|
||||
--required "=== Storage Tests:" --forbidden "ERROR;HardFault;[ERR]" `
|
||||
--require-suite-summary "Storage Tests"
|
||||
|
||||
|
||||
@@ -1,501 +0,0 @@
|
||||
#!/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)
|
||||
2.5 固件身份戳:从刚烧进去的 .hex 里抠出 `fw build: <DATE> <TIME>`,追加成 Required
|
||||
标记——芯片里跑的必须就是这份构建(--no-fw-check 关闭)
|
||||
3. wait-idle:RX 静默 >= settle-ms 收尾;首字节超过 --first-wait-ms 未到 → no_data
|
||||
(快速失败);整体 max-wait-ms 超时
|
||||
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 可对第一个锚点做原因白名单;
|
||||
--require-order 查标记先后顺序;--done-marker + --max-boot-ms 量开机耗时)
|
||||
6. (仅当 --require-selftest-pass)自检记录联合判定(S7):
|
||||
--selftest-log 提供本地副本则直接用它,否则从设备 FTP 抓取 /log/selftest.log
|
||||
(lftpd :21 匿名);判定:文件缺失 / 0 字节 = 无故障 → PASS,有 64B 记录 = FAIL
|
||||
|
||||
采集在烧录**之前**武装(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/host/run_boot_test.py
|
||||
[--port COM7] [--baud-rate 115200] [--settle-ms 8000] [--max-wait-ms 30000]
|
||||
[--first-wait-ms 12000]
|
||||
[--flash-cmd MDK-ARM\\download.bat] [--flash-timeout 180]
|
||||
[--required "App init OK;Network Init;net init: OK"]
|
||||
[--forbidden "FAIL;ERROR;HardFault;[ERR]"]
|
||||
[--require-suite-summary "Storage Tests"]
|
||||
[--require-selftest-pass] [--selftest-log <本地副本>]
|
||||
[--device-ip 192.168.1.100] [--out-dir <目录>] [--show-tail-lines 15]
|
||||
[--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 缺省取 .serialtest/config.json 的 portName(单一配置源)
|
||||
|
||||
退出码(与旧版一致):
|
||||
0 = PASS(采集成功 + Required 全中 + Forbidden 零命中)
|
||||
1 = FAIL(标记缺失/命中)
|
||||
2 = 采集或烧录错误(含烧录回读不一致 = 芯片内容与刚编的镜像不同,本次运行作废)
|
||||
3 = 用法/预检错误
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import ftplib
|
||||
import json
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
# 本脚本位于 test/host/,仓库根需上溯两级(2026-10 实机窗口修:原 .parent 只到 test/,
|
||||
# 导致 .serialtest/config.json 与 tools/serial_tool 路径解析错误)
|
||||
REPO_ROOT = SCRIPT_DIR.parent.parent
|
||||
SERIAL_TOOL = REPO_ROOT / "tools" / "serial_tool" / "serial_tool.py"
|
||||
CFG_FILE = REPO_ROOT / ".serialtest" / "config.json"
|
||||
DEFAULT_FLASH = REPO_ROOT / "MDK-ARM" / "download.bat"
|
||||
RESET_BAT = REPO_ROOT / "MDK-ARM" / "reset.bat"
|
||||
DEFAULT_HEX = REPO_ROOT / "MDK-ARM" / "STM32F407-Demo" / "STM32F407-Demo.hex"
|
||||
|
||||
REQUIRED_DEFAULT = "App init OK;Network Init;net init: OK"
|
||||
FORBIDDEN_DEFAULT = "FAIL;ERROR;HardFault;[ERR]"
|
||||
|
||||
# 固件身份戳(App/app/app_main.c 的 `DBG_INFO("fw build: " __DATE__ " " __TIME__)`)。
|
||||
# 编译期展开成一段连续 ASCII,故能直接从 .hex 里抠出来当 Required 标记用:
|
||||
# 芯片里跑的镜像 == 刚编出来的镜像,这才叫"烧进去了"。此前所有标记都是固定串,
|
||||
# 旧镜像照样全中——AGENTS.md 烧录铁律记的"反复重下载、芯片一直是旧镜像"在
|
||||
# 自动闭环里完全不可见(2026-10-10 加)。
|
||||
FW_MARKER = "fw build: "
|
||||
|
||||
|
||||
def _split_cmd(cmd_str):
|
||||
r"""把 `--flash-cmd` 这类命令串拆成 [exe, arg, ...](2026-10-10 实测坑)。
|
||||
|
||||
直接交给 subprocess、**不经 `cmd /c`**:CreateProcess 能直接跑 .bat,且 Python
|
||||
会给含空格的路径正确加引号。旧写法 `['cmd','/c', 串]` 在本仓库(目录名
|
||||
`DTU Code` 含空格)会被 cmd 的 /c 引号规则玩坏——实测报
|
||||
`'D:\Code\DTU' is not recognized`,回读校验被假判成"芯片内容不一致"。
|
||||
路径本身含空格时把该段用引号包住(`"D:\some dir\reset.bat" flash`)。
|
||||
posix=False 是必须的:posix=True 会吃掉 Windows 路径里的反斜杠。
|
||||
"""
|
||||
return [p.strip('"') for p in shlex.split(cmd_str, posix=False)]
|
||||
|
||||
|
||||
def _run_cmd(argv, timeout):
|
||||
"""跑一条外部命令,按字节取回输出并显式 UTF-8 解码(不受控制台代码页影响)。"""
|
||||
try:
|
||||
proc = subprocess.run(argv, capture_output=True, timeout=timeout)
|
||||
rc = proc.returncode
|
||||
raw = (proc.stdout or b"") + (proc.stderr or b"")
|
||||
except subprocess.TimeoutExpired as e:
|
||||
rc = -1
|
||||
raw = (e.stdout or b"") + (e.stderr or b"")
|
||||
return rc, raw.decode("utf-8", errors="replace")
|
||||
|
||||
|
||||
def extract_fw_stamp(hex_path):
|
||||
"""从 Intel HEX 里抠固件身份戳(只扫数据记录,不建整片镜像,内存 O(镜像体积))。
|
||||
|
||||
返回戳串(如 'Oct 10 2026 02:38:12');找不到(旧固件/未重编译)返回 None。
|
||||
"""
|
||||
data = bytearray()
|
||||
for line in Path(hex_path).read_text(errors="replace").splitlines():
|
||||
line = line.strip()
|
||||
if not line.startswith(":"):
|
||||
continue
|
||||
try:
|
||||
rec = bytes.fromhex(line[1:])
|
||||
except ValueError:
|
||||
continue
|
||||
if len(rec) > 4 and rec[3] == 0:
|
||||
data += rec[4:4 + rec[0]]
|
||||
i = data.find(FW_MARKER.encode("ascii"))
|
||||
if i < 0:
|
||||
return None
|
||||
j = i + len(FW_MARKER)
|
||||
end = j
|
||||
while end < len(data) and 0x20 <= data[end] < 0x7F:
|
||||
end += 1
|
||||
return data[j:end].decode("ascii", errors="replace")
|
||||
|
||||
|
||||
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 _ftp_quit(ftp):
|
||||
"""尽力关闭 FTP 控制连接(QUIT 失败不影响判定)。"""
|
||||
try:
|
||||
ftp.quit()
|
||||
except (OSError, ftplib.Error):
|
||||
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 匿名,根目录 = 卷根)。
|
||||
|
||||
成功返回本地路径字符串;失败(网络不可达 / FTP 错误 / 设备侧文件缺失)返回 ""
|
||||
(按 S7 语义视为无故障记录;此处打 WARN,调用方不判整体 FAIL)。
|
||||
|
||||
2026-10-02 台架修复:原实现只 `except OSError`,而 ftplib 的 error_temp /
|
||||
error_perm 继承自 Exception 而非 OSError —— 台账"仅故障才写",干净设备上
|
||||
/log/selftest.log 根本不存在,RETR 返回 450 直接抛出未捕获异常,令整个
|
||||
`@test --require-selftest-pass` 在最后一步崩掉(而它本该是 PASS 路径)。
|
||||
现把"设备侧无此文件"单列为正常路径(INFO),其余 FTP 错误保留 WARN。
|
||||
"""
|
||||
local = Path(out_dir) / "selftest_log_{}.log".format(stamp)
|
||||
ftp = ftplib.FTP()
|
||||
try:
|
||||
ftp.connect(device_ip, 21, timeout=10)
|
||||
ftp.login() # 匿名(lftpd 接受 USER anonymous / 空 PASS)
|
||||
except (OSError, ftplib.Error) as e:
|
||||
print("RUN_TEST_WARN: selftest fetch failed: {} "
|
||||
"(按 S7 语义视为无记录;设备网络/FTP 需另行排查)".format(e))
|
||||
return ""
|
||||
|
||||
buf = bytearray()
|
||||
try:
|
||||
ftp.retrbinary("RETR /log/selftest.log", buf.extend)
|
||||
except ftplib.all_errors as e:
|
||||
# 450/550 = 设备侧无此文件:干净台账,S7 判 PASS(正常路径,非故障)
|
||||
print("SELFTEST_FETCH: {} -> (ledger absent: {}) 按 S7 视为无故障记录"
|
||||
.format(device_ip, str(e).strip()))
|
||||
_ftp_quit(ftp)
|
||||
return ""
|
||||
|
||||
_ftp_quit(ftp)
|
||||
local.write_bytes(bytes(buf))
|
||||
print("SELFTEST_FETCH: {} -> {} ({} bytes)".format(device_ip, local, len(buf)))
|
||||
return str(local)
|
||||
|
||||
|
||||
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 .serialtest/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("--first-wait-ms", type=int, default=12000,
|
||||
help="give up as no_data if the board sent ZERO bytes this long "
|
||||
"after flash started the wait (0 = same as --max-wait-ms)")
|
||||
ap.add_argument("--flash-cmd", default="",
|
||||
help="flash command (default: MDK-ARM/download.bat; pass reset.bat for reset-only)")
|
||||
ap.add_argument("--flash-timeout", type=int, default=180,
|
||||
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("--require-suite-summary", default="",
|
||||
help="suite names that MUST appear as '=== <name>: N/M PASSED (failed=0) ===' "
|
||||
"with N>0, ';' separated (T-2). Empty = off")
|
||||
ap.add_argument("--require-selftest-pass", action="store_true",
|
||||
help="jointly judge /log/selftest.log (S7): missing/empty = PASS, "
|
||||
"any 64B record = FAIL")
|
||||
ap.add_argument("--selftest-log", default="",
|
||||
help="local copy of /log/selftest.log (skips device-side fetch when given)")
|
||||
ap.add_argument("--device-ip", default="192.168.1.100",
|
||||
help="device IP for fetching /log/selftest.log via FTP (lftpd anonymous)")
|
||||
ap.add_argument("--out-dir", default=str(Path(tempfile.gettempdir()) / "serial-boot-test"))
|
||||
ap.add_argument("--show-tail-lines", type=int, default=15)
|
||||
ap.add_argument("--fw-hex", default=str(DEFAULT_HEX),
|
||||
help="built image for the firmware-identity stamp and the readback "
|
||||
"compare (default: latest build hex)")
|
||||
ap.add_argument("--no-fw-check", action="store_true",
|
||||
help="do not require the 'fw build: <DATE> <TIME>' stamp in the boot log")
|
||||
ap.add_argument("--no-verify-flash", action="store_true",
|
||||
help="skip the ST-LINK -CmpFile readback (AGENTS.md makes it mandatory)")
|
||||
ap.add_argument("--readback-timeout", type=int, default=120,
|
||||
help="timeout for the readback compare, seconds")
|
||||
ap.add_argument("--max-boot-count", type=int, default=1,
|
||||
help="fail if the boot banner ('reset cause:') appears 0 times or more "
|
||||
"than N times (0 = check off)")
|
||||
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)
|
||||
py = sys.executable
|
||||
|
||||
# 端口缺省:单一配置源 .serialtest/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 允许带参数(如 "MDK-ARM\reset.bat flash"):拆成 argv,
|
||||
# 存在性只查 exe 段(整串当文件名必然 missing,2026-10-10 实测)
|
||||
flash_argv = _split_cmd(args.flash_cmd) if args.flash_cmd else [str(DEFAULT_FLASH)]
|
||||
flash_cmd = Path(flash_argv[0])
|
||||
|
||||
# 预检(对应旧版 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)
|
||||
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)
|
||||
|
||||
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(" ".join(flash_argv)))
|
||||
t0 = time.monotonic()
|
||||
try:
|
||||
proc = subprocess.run(flash_argv,
|
||||
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))
|
||||
|
||||
# 3.5 固件身份戳:从**刚烧进去的那份 .hex** 里抠出 `fw build: <DATE> <TIME>`,
|
||||
# 追加成 Required 标记 → 芯片里跑的必须就是这份构建。旧 hex 里没有该串
|
||||
# (未重编译)时只 WARN 跳过,不假判 FAIL。
|
||||
required = args.required
|
||||
fw_hex = Path(args.fw_hex)
|
||||
if args.no_fw_check:
|
||||
print("FW ID: skipped (--no-fw-check)")
|
||||
elif not fw_hex.is_file():
|
||||
print("RUN_TEST_WARN: fw hex not found {} -> identity + readback skipped".format(fw_hex))
|
||||
else:
|
||||
fw_stamp = extract_fw_stamp(fw_hex)
|
||||
if fw_stamp is None:
|
||||
print("RUN_TEST_WARN: no '{}' stamp in {} -> identity check skipped "
|
||||
"(old build: run @build first)".format(FW_MARKER, fw_hex.name))
|
||||
else:
|
||||
marker = FW_MARKER + fw_stamp
|
||||
required = (args.required + ";" + marker) if args.required else marker
|
||||
print("FW ID: expected stamp '{}' (from {})".format(marker, fw_hex.name))
|
||||
|
||||
# 4. wait-idle(RX 静默收尾或整体超时)
|
||||
# --first-timeout-ms:首字节门——烧录后板子全程不吐字节时按 no_data 快速失败
|
||||
# (旧版 daemon 常驻累计 rx_bytes>0,no_data 永不触发,只能靠后面"日志里没有
|
||||
# [RX] 行"兜底,报错信息误导成 device silent?)
|
||||
wait_timeout_s = args.max_wait_ms // 1000 + 20
|
||||
wait_args = ["--settle-ms", str(args.settle_ms),
|
||||
"--max-ms", str(args.max_wait_ms),
|
||||
"--timeout", str(wait_timeout_s)]
|
||||
if args.first_wait_ms:
|
||||
wait_args += ["--first-timeout-ms", str(args.first_wait_ms)]
|
||||
data, out = tool("wait-idle", *wait_args, timeout=wait_timeout_s + 15)
|
||||
if data is None:
|
||||
print("RUN_TEST_ERROR: wait-idle failed: {}".format(out))
|
||||
return 2
|
||||
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时间戳> " 前缀)
|
||||
# 默认给每行加 `[+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 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={} ts={})".format(
|
||||
boot_txt, len(rx), data.get("gen"), "raw" if args.raw_log else "+ms"))
|
||||
|
||||
# 5.6 烧录回读校验(AGENTS.md 铁律:`No difference found` 才算真写进去)。
|
||||
# 必须排在**采集之后**:实测 -CmpFile 连接时会复位目标(板子当场重打一遍开机
|
||||
# 日志),排在采集前会截断开机日志并让启动锚点计数误判成"中途复位"。
|
||||
if args.no_verify_flash:
|
||||
print("READBACK: skipped (--no-verify-flash)")
|
||||
elif not fw_hex.is_file():
|
||||
print("READBACK: skipped (no fw hex: {})".format(fw_hex))
|
||||
else:
|
||||
rb_rc, rb_text = _run_cmd(_split_cmd('{} cmp "{}"'.format(RESET_BAT, fw_hex)),
|
||||
args.readback_timeout)
|
||||
# 判定按**行首**匹配:进度条和 reset.bat 的帮助文本里都含 "No difference found"
|
||||
# 字样,只有 ST-LINK 自己那行才独占行首(实测 `...100%\r\nNo difference found.`)。
|
||||
rb_ok = any(ln.strip().lower().startswith("no difference found")
|
||||
for ln in rb_text.splitlines())
|
||||
if rb_ok:
|
||||
print("READBACK: OK (No difference found) rc={}".format(rb_rc))
|
||||
else:
|
||||
print("READBACK: FAIL -- device flash does NOT match {}".format(fw_hex.name))
|
||||
for line in rb_text.strip().splitlines()[-10:]:
|
||||
print(" | {}".format(line))
|
||||
print("RUN_TEST_ERROR: flash readback mismatch (chip content != built image)")
|
||||
return 2
|
||||
|
||||
# 5.5 自检记录抓取(S7):启用且未提供本地副本时才走 FTP
|
||||
selftest_local = args.selftest_log
|
||||
if args.require_selftest_pass and not selftest_local:
|
||||
selftest_local = _fetch_selftest_log(args.device_ip, out_dir, stamp)
|
||||
|
||||
# 6. 标记校验(含固件身份戳、启动锚点计数、可选的自检记录联合判定)
|
||||
from verify_log import verify
|
||||
rc = verify(str(boot_txt), required, args.forbidden, args.show_tail_lines,
|
||||
suite_summary=args.require_suite_summary,
|
||||
selftest_pass=args.require_selftest_pass,
|
||||
selftest_log=selftest_local,
|
||||
max_boot_count=args.max_boot_count,
|
||||
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
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,448 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""verify_log.py - 对抓取的 MCU 调试日志做标记校验(纯标准库,2026-09-20 替代 verify_log.ps1)。
|
||||
|
||||
用于 @test 闭环:烧录后从串口助手日志提取的开机日志交给本脚本 grep 标记,
|
||||
输出结构化判定(格式与旧 PowerShell 版逐字一致,下游文档/习惯不受影响)。
|
||||
|
||||
用法:
|
||||
& '.venv\\Scripts\\python.exe' test/host/verify_log.py --log-path <file>
|
||||
[--required 'marker1;marker2']
|
||||
[--forbidden 'FAIL;ERROR;HardFault;assert;[ERR]']
|
||||
[--require-suite-summary 'Storage Tests;GD5F2GQ5UE Tests']
|
||||
[--require-selftest-pass] [--selftest-log <selftest.log 本地副本>]
|
||||
[--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 零命中,汇总行要求满足)
|
||||
1 = FAIL(缺 Required / 命中 Forbidden / 汇总行要求不满足)
|
||||
2 = ERROR(日志文件不存在/不可读)
|
||||
|
||||
默认 Required 标记对应 STM32F4-Base 启动序列(见 App/app/app_main.c + App/task/net_task.c),
|
||||
按测试用例覆盖,例如:
|
||||
--required 'App init OK;net init: OK;PHY: 100M FULL'
|
||||
--forbidden 'FAIL;ERROR;HardFault;assert;[ERR]'
|
||||
|
||||
语义约定(与旧 PowerShell 版对齐):
|
||||
- Required:大小写敏感子串匹配(全文)
|
||||
- Forbidden:大小写**不敏感**子串匹配、逐行计数(旧版 -like 语义;
|
||||
如 Forbidden 'FAIL' 会命中 "write /log/run.log fail (7)" 这类行)
|
||||
- 固件错误级日志前缀是 `[ERR]`(`DBG_ERROR`,形如 `[时间][ERR][TAG] 消息`),
|
||||
与 `ERROR` 子串**不匹配**——默认 Forbidden 因此显式包含 `[ERR]`,驱动/模块
|
||||
错误行不再漏检(2026-09-21 修复)。测试套件中**预期**出现 `[ERR]` 的用例
|
||||
(如 GD5F DMA 守卫负路径、FatFs 非法名校验)须用 --forbidden 覆盖默认值。
|
||||
- 读取编码:先按 UTF-8,解码失败回退系统 locale 编码(旧版 Get-Content 行为)
|
||||
- 控制台输出重配置为 UTF-8(errors=replace):任何 Windows 控制台代码页
|
||||
都只影响终端显示,不会导致本脚本崩溃
|
||||
- 汇总行(2026-10-01 加,T-2):纯 Required 子串匹配有个盲区——套件启动横幅
|
||||
命中即判 PASS,**套件是否跑到汇总行、断言数是否为 0 完全不可见**(套件中途
|
||||
挂死/复位、或 Phase 宏全关时会被误判为通过)。`--require-suite-summary`
|
||||
要求日志中出现 `=== <名称>: N/M PASSED (failed=0) ===` 且 **N > 0**。
|
||||
- 自检记录联合判定(S7,2026-10 实机窗口):`--require-selftest-pass` 对 /log/selftest.log
|
||||
的本地副本(定长 64B 记录,格式见 App/log/sys_log.h)判定。自检仅故障写盘(v1 不记
|
||||
通过项):**文件缺失 / 0 字节 = 无故障 → PASS**;存在任何一条记录 = 有故障 → FAIL
|
||||
(退出码 1),并逐条打印时间戳 + 描述。抓取失败(设备不可达 / FTP 错误等)由调用方
|
||||
(run_boot_test.py)先打 WARN,本地文件按缺失处理 → 按上述规则不判整体 FAIL。
|
||||
- 启动锚点计数(2026-10-10):`--max-boot-count N`(run_boot_test.py 默认传 1)统计
|
||||
`reset cause:` 锚点出现次数——**0 次** = 采集没从复位开始(标记全中也可能只是上一轮
|
||||
的残留日志);**>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
|
||||
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 命中时才被发现,
|
||||
# "跑完 PASS 但中途重启过"完全不可见(2026-10-10 加)。
|
||||
BOOT_MARKER_DEFAULT = "reset cause:"
|
||||
|
||||
# 套件汇总行:`=== Storage Tests: 3/3 PASSED (failed=0) ===`
|
||||
# 名称可含空格;N/M 为十进制;failed 允许前导 0。宽松容忍前后空白与
|
||||
# 日志时间戳前缀(`[2026-10-01 12:00:00.000]`)——故用 search 而非 match。
|
||||
_SUMMARY_RE = re.compile(
|
||||
r"===\s*(?P<name>[^:=]+?)\s*:\s*(?P<passed>\d+)\s*/\s*(?P<total>\d+)\s+"
|
||||
r"PASSED\s*\(\s*failed\s*=\s*(?P<failed>\d+)\s*\)\s*==="
|
||||
)
|
||||
|
||||
|
||||
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 _reset_cause_of(line, marker):
|
||||
"""从 `reset cause: software (NVIC_SystemReset) (CSR=0x14000000)` 取原因文本。"""
|
||||
i = line.find(marker)
|
||||
if i < 0:
|
||||
return ""
|
||||
tail = line[i + len(marker):].strip()
|
||||
j = tail.find(" (CSR=")
|
||||
return tail[:j] if j > 0 else tail
|
||||
|
||||
|
||||
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="", require_order="", done_marker="",
|
||||
max_boot_ms=0, as_json=False):
|
||||
"""标记校验核心(可被 run_boot_test.py 复用)。返回退出码 0/1/2。
|
||||
|
||||
suite_summary: ';' 分隔的套件名列表。非空时,要求日志中每个套件都出现
|
||||
`=== <名称>: N/M PASSED (failed=0) ===` 且 **N > 0**。
|
||||
selftest_pass: True 时对 selftest_log(/log/selftest.log 本地副本)联合判定:
|
||||
文件缺失 / 0 字节 = 无故障(PASS);存在任何 64B 记录 = FAIL(S7)。
|
||||
selftest_log: 自检记录文件本地路径(空串 = 按文件缺失处理)。
|
||||
boot_marker / max_boot_count(2026-10-10):统计启动锚点出现次数。
|
||||
max_boot_count>0 时启用:0 次 = 采集没从复位开始;>N 次 = 板子
|
||||
在采集窗口内自己复位过(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 finish(2)
|
||||
try:
|
||||
lines = _read_lines(log_path)
|
||||
except OSError:
|
||||
out("VERIFY_ERROR: cannot read log file: {}".format(log_path))
|
||||
return finish(2)
|
||||
|
||||
full = "\n".join(lines)
|
||||
req_list = _split_list(required)
|
||||
forb_list = _split_list(forbidden)
|
||||
summary_list = _split_list(suite_summary)
|
||||
|
||||
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:
|
||||
if m in full:
|
||||
out("[OK] required marker found: {}".format(m))
|
||||
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()
|
||||
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))
|
||||
|
||||
# 启动锚点计数(2026-10-10):抓"跑完但中途复位过"和"采集没从复位开始"
|
||||
boot_issue = False
|
||||
if max_boot_count > 0:
|
||||
banner_lines = [ln for ln in lines if boot_marker in ln]
|
||||
n_boot = len(banner_lines)
|
||||
causes = [_reset_cause_of(ln, boot_marker) for ln in banner_lines]
|
||||
if n_boot == 0:
|
||||
out("[FAIL] no boot banner (marker '{}'): capture did not start at a reset".format(
|
||||
boot_marker))
|
||||
boot_issue = True
|
||||
elif n_boot > max_boot_count:
|
||||
out("[FAIL] boot banner x{} > allowed {} -> board reset itself during capture".format(
|
||||
n_boot, max_boot_count))
|
||||
for ln in banner_lines[:6]:
|
||||
out(" {}".format(ln.strip()))
|
||||
boot_issue = True
|
||||
else:
|
||||
out("[OK] boot banner x{} (marker '{}')".format(n_boot, boot_marker))
|
||||
if causes:
|
||||
out("[INFO] reset cause(s): {}".format("; ".join(causes)))
|
||||
exp = _split_list(expect_reset_cause)
|
||||
if exp:
|
||||
got = causes[0].lower()
|
||||
if any(e.lower() in got for e in exp):
|
||||
out("[OK] reset cause '{}' matches expected {}".format(
|
||||
causes[0], expect_reset_cause))
|
||||
else:
|
||||
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 = []
|
||||
if summary_list:
|
||||
parsed = {}
|
||||
for ln in lines:
|
||||
m = _SUMMARY_RE.search(ln)
|
||||
if m:
|
||||
parsed.setdefault(m.group("name").strip(), []).append(m)
|
||||
for name in summary_list:
|
||||
cands = parsed.get(name)
|
||||
if not cands:
|
||||
out("[MISS] suite summary NOT found: '=== {}: N/M PASSED (failed=0) ==='".format(name))
|
||||
bad_summary.append(name)
|
||||
continue
|
||||
ok = False
|
||||
for m in cands:
|
||||
passed = int(m.group("passed"))
|
||||
total = int(m.group("total"))
|
||||
failed = int(m.group("failed"))
|
||||
if failed != 0:
|
||||
out("[FAIL] suite summary reports failures: {} -> {}/{} PASSED (failed={})".format(
|
||||
name, passed, total, failed))
|
||||
continue
|
||||
if total == 0:
|
||||
out("[FAIL] suite summary total is 0 (no assertion ran): '{}'".format(name))
|
||||
continue
|
||||
if passed != total:
|
||||
out("[FAIL] suite summary inconsistent: {} -> {}/{} PASSED".format(
|
||||
name, passed, total))
|
||||
continue
|
||||
out("[OK] suite summary ok: {} -> {}/{} PASSED".format(name, passed, total))
|
||||
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
|
||||
selftest_fail = False
|
||||
if selftest_pass:
|
||||
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 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
|
||||
or order_issue or timing_issue):
|
||||
out("VERDICT: FAIL")
|
||||
return finish(1)
|
||||
out("VERDICT: PASS")
|
||||
return finish(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("--require-suite-summary", default="",
|
||||
help="suite names that MUST appear as '=== <name>: N/M PASSED (failed=0) ===' "
|
||||
"with N>0, ';' separated (T-2). Empty = off")
|
||||
ap.add_argument("--show-tail-lines", type=int, default=40,
|
||||
help="show last N log lines (0 = off)")
|
||||
ap.add_argument("--require-selftest-pass", action="store_true",
|
||||
help="judge /log/selftest.log local copy (S7): "
|
||||
"missing/empty = PASS, any 64B record = FAIL")
|
||||
ap.add_argument("--selftest-log", default="",
|
||||
help="local copy path of /log/selftest.log (with --require-selftest-pass)")
|
||||
ap.add_argument("--boot-marker", default=BOOT_MARKER_DEFAULT,
|
||||
help="per-boot banner substring (default '{}')".format(BOOT_MARKER_DEFAULT))
|
||||
ap.add_argument("--max-boot-count", type=int, default=0,
|
||||
help="fail if the boot banner appears 0 times or more than N times "
|
||||
"(0 = check off). run_boot_test.py passes 1 by default")
|
||||
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,
|
||||
args.show_tail_lines, suite_summary=args.require_suite_summary,
|
||||
selftest_pass=args.require_selftest_pass,
|
||||
selftest_log=args.selftest_log,
|
||||
boot_marker=args.boot_marker,
|
||||
max_boot_count=args.max_boot_count,
|
||||
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__":
|
||||
sys.exit(main())
|
||||
@@ -10,7 +10,7 @@
|
||||
1. 改 test_config.h 启用该套件(其它全注释)
|
||||
2. @build(0 错 0 警,否则跳过该套件)
|
||||
3. 烧录(MDK-ARM/download.bat,Reset & Run)
|
||||
4. @test 采集 + 判读(verify_log.py,含套件汇总行校验 T-2)
|
||||
4. @test 采集 + 判读(.dsh/skills/serial-boot-test 的 verify_log.py,含套件汇总行校验 T-2)
|
||||
结束后:
|
||||
5. **无条件还原 test_config.h**(try/finally,含异常/KeyboardInterrupt 路径)
|
||||
|
||||
@@ -55,7 +55,7 @@ REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
CONFIG = REPO_ROOT / "test" / "board" / "test_config.h"
|
||||
BUILD_BAT = REPO_ROOT / "MDK-ARM" / "build.bat"
|
||||
DOWNLOAD_BAT = REPO_ROOT / "MDK-ARM" / "download.bat"
|
||||
RUN_BOOT = REPO_ROOT / "test" / "host" / "run_boot_test.py"
|
||||
RUN_BOOT = REPO_ROOT / ".dsh" / "skills" / "serial-boot-test" / "scripts" / "run_boot_test.py"
|
||||
|
||||
# 套件 -> (宏名, verify_log 需要的汇总行名称, 该套件的 Required 标记, --forbidden 覆盖)
|
||||
# 汇总行名称与套件 `XXX_TEST_REPORT("...")` 里的字符串一致(见各模块 Test_Guide)。
|
||||
@@ -207,7 +207,7 @@ def main(argv=None):
|
||||
print("[WARN] 烧录 rc={}(继续:板子可能已复位)".format(frc))
|
||||
|
||||
# 3) 采集 + 判读(含套件汇总行校验 T-2)
|
||||
cmd = [py, str(RUN_BOOT)]
|
||||
cmd = [py, str(RUN_BOOT), "--flash-cmd", "MDK-ARM\\download.bat"]
|
||||
if required:
|
||||
cmd += ["--required", required]
|
||||
if summary_name:
|
||||
|
||||
Reference in New Issue
Block a user