Files
STM32F4-Base/.dsh/skills/serial-boot-test/scripts/run_boot_test.py
T
wandering1 4a4ad6bc5f chore(全仓): @build/@test 旧文档别名清退为技能调用名
现状口径:/keil-build 与 /serial-boot-test 是 DSH 会话的技能调用名,
不是 shell 命令;「名 + 命令行参数」的命令形态改为可执行体
run_boot_test.py 的真实调用。

- AGENTS.md:别名说明重写为「技能调用名不是 shell 命令」,两条目改
  「编译(/keil-build)」「串口回归(/serial-boot-test)」
- keil-build / serial-boot-test 两 skill 调用名与示例同步;task-worktree
  方案文档去掉 @build/@test 惯例从句;@task 保留(不在点名范围)
- 123 文件现状陈述全改;台架捕获、变更记录/时间线、rename 映射输入
  等 41 行历史证据保留旧名
- gen_memory_svg.py 6 处措辞随改,3 张 SVG 产物同批重跑
- 顺手补并行方案同行漏网计数 51→52 钩子

验证:提交期快钩子对 123 文件实跑全 Passed;check_doc_links --all
rc=0(3038 处、无法判定 0);check_mermaid --all rc=0;YAML 解析 52
钩子不变;skill-size rc=0;py_compile 全过;字节卫生零漂移。
2026-10-30 13:03:12 +08:00

777 lines
40 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""run_boot_test.py - 一键开机日志抓取 + 校验(纯标准库;可移植 skill 版)。
调用串口助手(采集层 = 兄弟 skill serial-assistant 的 serial_tool.py,常驻 daemon,
子进程 CLI + JSON;解析顺序见 find_serial_tool):
1. open 串口 + clear 日志(本次运行独立代际)
2. 烧录(config "flash_cmd";未配置 = 跳过烧录,等人工复位板子)
2.5 固件身份戳:从待烧 hex(config "hex")里抠出固件戳(config "fw_marker",
默认 `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 出纯载荷)。固件侧时间戳往往
只有秒级、开机头几行还没时钟,量不了耗时——这个前缀就是耗时口径
4.5 烧录回读校验:config "readback_cmd" 模板({hex} = 镜像路径),成功判据 =
某行以 "readback_ok"(默认 No difference found)开头;未配置 = 跳过。
**必须排在采集之后**:ST-LINK -CmpFile 实测连接时会复位目标
5. verify_log.py 标记校验(含启动锚点计数 config "boot_marker" + --max-boot-count;
--expect-reset-cause 原因白名单;--require-order 标记顺序;
--done-marker + --max-boot-ms 开机耗时)
6. (仅当 --require-selftest-pass)自检台账联合判定:默认**只判本次启动新增**
——烧录前先经 FTP 取 /log/selftest.log 基线,启动后再取一次,只把"基线之后新增"的
记录交给判定(该文件是 512 条环形累计历史,"非空即 FAIL"判一次启动永远不可能过)。
设备侧无此文件 = 台账干净 = PASS;**抓取失败 / 没有数据源 / 基线被环覆盖 → FAIL**,
不再"视为无记录"放过。累计口径用 --selftest-scope all;--selftest-log 走本地副本
(无基线可比,自动降级为 all)。FTP 凭据 --ftp-user/--ftp-pass(默认出厂 admin)。
采集在烧录**之前**武装(open + clear 先于烧录),daemon 常驻全量日志兜底,
无采集窗口/首字节等待仪式。
项目差异全部收在 <cwd>/.serialtest/config.json(见 config.example.json):
portName/baudRate/flash_cmd/hex/fw_marker/readback_cmd/readback_ok/
required/forbidden/boot_marker/done_marker/settle_ms/...
解析顺序:$DSH_SERIAL_CONFIG > <cwd>/.serialtest/config.json >
<cwd>/.opencode/serial_config.json(旧位置,仅串口字段)。
优先级:命令行 > 档案 > 内置通用默认。
加固口径(Windows 实测教训):
- 子进程输出按**字节**读取、显式 UTF-8 解码 —— 不受控制台代码页(GBK)影响
- 配置 JSON 按 UTF-8(容忍 BOM)解析
- 不经 PowerShell —— 无执行策略拦截
- 烧录/回读带超时 —— 坏连接不会永久挂起
用法(**项目根目录**下运行,python 需装有 pyserial):
python <skill>/scripts/run_boot_test.py
[--port COM7] [--baud-rate 115200] [--settle-ms 8000] [--max-wait-ms 30000]
[--first-wait-ms 12000]
[--flash-cmd "<烧录命令>"] [--flash-timeout 180]
[--required "m1;m2"] [--forbidden "FAIL;ERROR;HardFault"]
[--require-suite-summary "Storage Tests"]
[--require-selftest-pass [--selftest-scope this-run|all]] [--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 'a>b>c'] [--done-marker 'ok'] [--max-boot-ms 0]
[--raw-log] [--keep-out 20]
未显式给的参数取 <cwd>/.serialtest/config.json(见 config.example.json)
退出码(与旧版一致):
0 = PASS(采集成功 + Required 全中 + Forbidden 零命中)
1 = FAIL(标记缺失/命中;或要求自检台账判定却判不了——无数据源/抓取失败/无法证明无新增)
2 = 采集或烧录错误(含烧录回读不一致 = 芯片内容与刚编的镜像不同,本次运行作废)
3 = 用法/预检错误
"""
import argparse
import ftplib
import json
import os
import shlex
import subprocess
import sys
import tempfile
import time
from datetime import datetime
from pathlib import Path
SCRIPT_DIR = Path(__file__).resolve().parent
VERIFY_LOG = SCRIPT_DIR / "verify_log.py"
def find_serial_tool():
"""串口助手脚本解析顺序(分层 skill:采集层 = 兄弟 skill serial-assistant):
1. $DSH_SERIAL_TOOL 环境变量(显式指定)
2. 与本脚本同目录(兼容单独整包拷贝的场景)
3. 兄弟 skill:<skills 根>/serial-assistant/scripts/serial_tool.py
都不存在时返回第 3 候选(错误信息照它报)。"""
env = os.environ.get("DSH_SERIAL_TOOL")
if env:
return Path(env)
candidates = [SCRIPT_DIR / "serial_tool.py",
SCRIPT_DIR.parent.parent / "serial-assistant" / "scripts" / "serial_tool.py"]
for c in candidates:
if c.is_file():
return c
return candidates[1]
SERIAL_TOOL = find_serial_tool()
def find_config_file():
"""项目测试档案解析顺序(cwd = 项目根目录):
1. $DSH_SERIAL_CONFIG 环境变量(显式文件)
2. <cwd>/.serialtest/config.json (本 skill 约定)
3. <cwd>/.opencode/serial_config.json (旧位置,仅串口字段)
都不存在时返回第 1 候选(错误信息照它报)。"""
env = os.environ.get("DSH_SERIAL_CONFIG")
if env:
return Path(env)
cwd = Path.cwd()
candidates = [cwd / ".serialtest" / "config.json",
cwd / ".opencode" / "serial_config.json"]
for c in candidates:
if c.is_file():
return c
return candidates[0]
CFG_FILE = find_config_file()
# 通用默认(与具体项目无关;项目差异全部进 .serialtest/config.json)
FORBIDDEN_DEFAULT = "FAIL;ERROR;HardFault"
# 固件身份戳:固件里打印 `"<fw_marker>" __DATE__ " " __TIME__`(编译期展开成连续
# ASCII),脚本从待烧 hex 里抠出该串追加成 Required 标记——芯片里跑的镜像必须
# 就是刚编出来的这份。旧镜像/错镜像在纯固定串标记下不可见。
# 固件没有这类戳 → config 里 "fw_marker": "" 关闭(只 WARN 跳过,不假判)。
FW_MARKER_DEFAULT = "fw build: "
# 烧录回读校验的成功判据(按**行首**匹配,大小写不敏感):ST-LINK_CLI -CmpFile
# 输出 `No difference found`。其他烧录工具按其成功行改 config 里的 "readback_ok"。
READBACK_OK_DEFAULT = "no difference found"
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\flash_tool.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 解码(不受控制台代码页影响)。
stdin=DEVNULL 必须:烧录/回读走 ST-LINK_CLI,它编程完成后会停在
"run application to exit" 等按键——stdin 若是未关闭的管道会永久挂起
(2026-10-10 台架实测:编程成功但 CLI 180s 不退 → 误判 flash rc=-1)。
"""
try:
proc = subprocess.run(argv, capture_output=True, timeout=timeout,
stdin=subprocess.DEVNULL)
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, marker):
"""从 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(marker.encode("ascii"))
if i < 0:
return None
j = i + len(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` 命名、每次 /serial-boot-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 _selftest_records(path):
"""把 selftest.log 的本地副本切成 64 B 定长记录(尾部残块不算记录)。
返回 [] = 台账为空(干净设备);返回 None = **未知**(文件不可读等)。
两者绝不能混——把"看不见"当成"没有"正是假绿的成因。
"""
if not path:
return []
try:
raw = Path(path).read_bytes()
except OSError as exc:
print("RUN_TEST_WARN: 自检台账本地副本不可读: {}".format(exc))
return None
n = len(raw) - (len(raw) % 64)
return [raw[i:i + 64] for i in range(0, n, 64)]
def _selftest_new_records(baseline, after):
"""基线对比 → 本次运行新增的记录,返回 (新增列表, 能否证明)。
· baseline is None → 基线未知 → 无法证明
· baseline == [] → 设备原本无台账 → after 全部算本次新增
· 基线最后一条在 after 里找得到 → 它之后的就是新增
· 找不到 → 512 条环已覆盖基线(更新量 ≥ 环容)→ 无法证明;
宁可 FAIL,也不拿"看不到"当"没有"
"""
if baseline is None or after is None:
return [], False
if not baseline:
return list(after), True
last = baseline[-1]
for idx in range(len(after) - 1, -1, -1):
if after[idx] == last:
return list(after[idx + 1:]), True
return [], False
def _fetch_selftest_log(device_ip, out_dir, stamp, ftp_user="admin", ftp_pass="admin"):
"""从设备 FTP 抓取 /log/selftest.log(lftpd :21 需鉴权,根目录 = 卷根)。
返回 (本地路径, 状态),状态 ∈ {"ok", "absent", "error"}:
ok = 抓到文件(路径有效)
absent = 设备侧确实没有这个文件 = 台账干净(正常路径,不是故障)
error = 连不上 / 登录被拒 / FTP 故障 → 结果**未知**,调用方必须判 FAIL
⚠ 2026-10-22 关键更正:旧实现把 absent 与 error 都返回 "",调用方一律"视为无
记录 → PASS"。于是抓取失败会被读成"设备很干净"——这就是那条假绿。三种结果
必须分开。
2026-10-02 台架修复:原实现只 `except OSError`,而 ftplib 的 error_temp /
error_perm 继承自 Exception 而非 OSError —— 台账"仅故障才写",干净设备上
/log/selftest.log 根本不存在,RETR 返回 450 直接抛出未捕获异常,令整个
`run_boot_test.py --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)
# lftpd 按 /config.ini 的 [ftp] 校验账号(user / pwd_hash),出厂 admin/admin;
# 设备没有匿名通道——匿名登录一律 530,所以这里必须带凭据。
ftp.login(ftp_user, ftp_pass)
except (OSError, ftplib.Error) as e:
print("SELFTEST_FETCH: {} -> ERROR ({}) 连不上/登录被拒/FTP 故障 → 状态未知"
.format(device_ip, e))
return "", "error"
buf = bytearray()
try:
ftp.retrbinary("RETR /log/selftest.log", buf.extend)
except ftplib.all_errors as e:
# 450/550 = 设备侧无此文件:干净台账 → 判定 PASS(正常路径,非故障)
print("SELFTEST_FETCH: {} -> (ledger absent: {}) 台账干净"
.format(device_ip, str(e).strip()))
_ftp_quit(ftp)
return "", "absent"
_ftp_quit(ftp)
local.write_bytes(bytes(buf))
print("SELFTEST_FETCH: {} -> {} ({} bytes)".format(device_ip, local, len(buf)))
return str(local), "ok"
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=None,
help="serial port (default: portName from project config)")
ap.add_argument("--baud-rate", type=int, default=None)
ap.add_argument("--settle-ms", type=int, default=None,
help="RX silence that ends the capture")
ap.add_argument("--max-wait-ms", type=int, default=None,
help="overall wait-idle timeout")
ap.add_argument("--first-wait-ms", type=int, default=None,
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=None,
help="flash command (from config 'flash_cmd'; empty = skip flashing, "
"reset the board manually right after the run starts)")
ap.add_argument("--flash-timeout", type=int, default=None,
help="flash subprocess timeout in seconds")
ap.add_argument("--required", default=None,
help="required markers, ';' separated (config 'required')")
ap.add_argument("--forbidden", default=None,
help="forbidden patterns, ';' separated (config 'forbidden')")
ap.add_argument("--require-suite-summary", default=None,
help="suite names that MUST appear as '=== <name>: N/M PASSED (failed=0) ===' "
"with N>0, ';' separated. Empty = off")
ap.add_argument("--require-selftest-pass", action="store_true", default=None,
help="jointly judge /log/selftest.log: missing/empty = PASS, "
"any 64B record = FAIL")
ap.add_argument("--selftest-log", default=None,
help="local copy of /log/selftest.log (skips device-side fetch when given)")
ap.add_argument("--selftest-scope", choices=("this-run", "all"), default=None,
help="this-run (default): only records added by THIS run count as "
"faults -- needs a pre-flash baseline over FTP, so device_ip is "
"required. all: legacy cumulative rule (any record = FAIL); use it "
"for audits or a first baseline. Unproveable cases FAIL, never PASS.")
ap.add_argument("--device-ip", default=None,
help="device IP for fetching /log/selftest.log via FTP")
ap.add_argument("--ftp-user", default=None,
help="FTP user for the selftest fetch (config 'ftp_user'; default admin)")
ap.add_argument("--ftp-pass", default=None,
help="FTP password for the selftest fetch (config 'ftp_pass'; default admin)")
ap.add_argument("--out-dir", default=None)
ap.add_argument("--show-tail-lines", type=int, default=None)
ap.add_argument("--fw-hex", default=None,
help="built image for the firmware-identity stamp and the readback "
"compare (config 'hex')")
ap.add_argument("--no-fw-check", action="store_true", default=None,
help="do not require the firmware build stamp in the boot log")
ap.add_argument("--no-verify-flash", action="store_true", default=None,
help="skip the readback compare (config 'readback_cmd')")
ap.add_argument("--readback-timeout", type=int, default=None,
help="timeout for the readback compare, seconds")
ap.add_argument("--max-boot-count", type=int, default=None,
help="fail if the boot banner (config 'boot_marker') appears 0 times or "
"more than N times (0 = check off)")
ap.add_argument("--expect-reset-cause", default=None,
help="';'-separated substrings the FIRST reset cause must match "
"(case-insensitive); empty = report only, no verdict")
ap.add_argument("--require-order", default=None,
help="'>'-separated markers that must appear in that line order "
"(catches out-of-order boot steps); empty = off")
ap.add_argument("--exact", action="append", default=[],
help="repeatable regex that must match a WHOLE firmware line "
"(forwarded to verify_log.py --exact). Use it instead of --required "
"for any marker whose line could lose its head or gain spliced "
"bytes -- substring matching cannot see that class of damage. "
"Suffix '@@LEN=<n>' pins byte length: opt-in only, lengths belong "
"to one image")
ap.add_argument("--pair", action="append", nargs=2, metavar=("FIRST", "THEN"), default=[],
help="repeatable cross-reset pairing (exactly one FIRST, then exactly "
"one THEN after it), e.g. --pair MEMFAULT 'reset cause:'")
ap.add_argument("--allow-residual", type=int, default=0,
help="accept up to N stray bytes before the line head on a --exact hit "
"(crash-dump legs: 1 leftover from a reset mid-byte is known; "
"recorded, not a failure)")
ap.add_argument("--head-char", default="",
help="every --exact hit must start with this char after the optional "
"residual prefix (crash-dump legs: '[')")
ap.add_argument("--done-marker", default=None,
help="marker whose line marks boot completion (for --max-boot-ms); "
"empty = don't measure")
ap.add_argument("--max-boot-ms", type=int, default=None,
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", default=None,
help="write payload-only boot log (no '[+Nms]' relative-time prefix)")
ap.add_argument("--keep-out", type=int, default=None,
help="keep the newest N boot_log_*/selftest_log_* files in --out-dir "
"(0 = keep everything)")
args = ap.parse_args(argv)
# 串口助手 CLI 用当前解释器(需装有 pyserial)
py = sys.executable
# 项目测试档案(.serialtest/config.json,UTF-8 容忍 BOM):
# 优先级 = 命令行 > 档案 > 内置通用默认。档案缺失不是错误(纯命令行也能跑)。
cfg = {}
if CFG_FILE.is_file():
try:
raw = json.loads(CFG_FILE.read_text(encoding="utf-8-sig"))
except (OSError, ValueError) as e:
print("RUN_TEST_ERROR: cannot parse {}: {}".format(CFG_FILE, e))
return 3
if not isinstance(raw, dict):
print("RUN_TEST_ERROR: config root is not an object: {}".format(CFG_FILE))
return 3
cfg = raw
print("CONFIG: {}".format(CFG_FILE if cfg else "(none, CLI-only)"))
def _pick(cli, key, default=None):
if cli is not None:
return cli
v = cfg.get(key)
return default if v is None else v
port = str(_pick(args.port, "portName", default="") or "")
args.baud_rate = int(_pick(args.baud_rate, "baudRate", default=115200))
args.settle_ms = int(_pick(args.settle_ms, "settle_ms", default=8000))
args.max_wait_ms = int(_pick(args.max_wait_ms, "max_wait_ms", default=30000))
args.first_wait_ms = int(_pick(args.first_wait_ms, "first_wait_ms", default=12000))
args.flash_cmd = str(_pick(args.flash_cmd, "flash_cmd", default="") or "")
args.flash_timeout = int(_pick(args.flash_timeout, "flash_timeout", default=180))
args.required = str(_pick(args.required, "required", default="") or "")
args.forbidden = str(_pick(args.forbidden, "forbidden", default=FORBIDDEN_DEFAULT))
args.require_suite_summary = str(_pick(args.require_suite_summary,
"require_suite_summary", default="") or "")
args.selftest_scope = str(_pick(args.selftest_scope, "selftest_scope",
default="this-run") or "this-run")
if args.selftest_scope not in ("this-run", "all"):
print("RUN_TEST_USAGE_ERROR: selftest_scope must be this-run or all")
return 3
args.require_selftest_pass = bool(_pick(args.require_selftest_pass,
"require_selftest_pass", default=False))
args.selftest_log = str(_pick(args.selftest_log, "selftest_log", default="") or "")
args.device_ip = str(_pick(args.device_ip, "device_ip", default="") or "")
args.ftp_user = str(_pick(args.ftp_user, "ftp_user", default="admin") or "admin")
args.ftp_pass = str(_pick(args.ftp_pass, "ftp_pass", default="admin") or "admin")
args.out_dir = str(_pick(args.out_dir, "out_dir",
default=str(Path(tempfile.gettempdir()) / "serial-boot-test")))
args.show_tail_lines = int(_pick(args.show_tail_lines, "show_tail_lines", default=15))
args.fw_hex = str(_pick(args.fw_hex, "hex", default="") or "")
args.no_fw_check = bool(_pick(args.no_fw_check, "no_fw_check", default=False))
args.fw_marker = str(cfg.get("fw_marker", FW_MARKER_DEFAULT) or "")
args.no_verify_flash = bool(_pick(args.no_verify_flash, "no_verify_flash", default=False))
args.readback_cmd = str(cfg.get("readback_cmd", "") or "")
args.readback_ok = str(cfg.get("readback_ok", READBACK_OK_DEFAULT) or READBACK_OK_DEFAULT)
args.readback_timeout = int(_pick(args.readback_timeout, "readback_timeout", default=120))
args.max_boot_count = int(_pick(args.max_boot_count, "max_boot_count", default=1))
args.boot_marker = str(cfg.get("boot_marker", "") or "")
args.expect_reset_cause = str(_pick(args.expect_reset_cause,
"expect_reset_cause", default="") or "")
args.require_order = str(_pick(args.require_order, "require_order", default="") or "")
args.done_marker = str(_pick(args.done_marker, "done_marker", default="") or "")
args.max_boot_ms = int(_pick(args.max_boot_ms, "max_boot_ms", default=0))
args.raw_log = bool(_pick(args.raw_log, "raw_log", default=False))
args.keep_out = int(_pick(args.keep_out, "keep_out", default=20))
if not port:
print("RUN_TEST_ERROR: no --port given and no portName in {} "
"(create .serialtest/config.json, see config.example.json)".format(CFG_FILE))
return 3
if not args.required:
print("RUN_TEST_ERROR: no required markers (--required or 'required' in {})".format(CFG_FILE))
return 3
# --flash-cmd 允许带参数(如 "python flash_tool.py flash"):拆成 argv,
# 存在性只查 exe 段(整串当文件名必然 missing,2026-10-10 实测)
flash_argv = _split_cmd(args.flash_cmd) if args.flash_cmd else None
# 预检(对应旧版 missing-file 检查)
preflight = [SERIAL_TOOL, VERIFY_LOG]
if flash_argv is not None:
preflight.append(Path(flash_argv[0]))
for p in preflight:
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))
# 0.6 自检台账基线(只看本次新增):**必须排在串口采集武装之前**。
# /log/selftest.log 是 512 条环形累计历史,用"文件非空即 FAIL"去判一次启动,在任何
# 做过故障注入或长跑的设备上永远不可能 PASS(本仓台架实测:抓到的 29 条记录时间戳
# 全是 2026-10-10 ~ 10-14 留下的,与本次启动无关)。这里记下基线的最后一条记录,
# 启动后只判"它之后新增了几条";抓不到基线就判不了本次 → 退出码 2,不降级成"视为
# 无记录"。
# ⚠ 为什么排在 open/clear 之前(2026-10-22 实测踩到):抓基线是一次真实 FTP 会话,
# 设备会把 `USER/PASS/RETR` 打到串口上,客户端退出时还会多打一行 `[ERR] [FTP] error
# reading next command`——放在采集武装之后,这些行就落进本次启动日志:既让 Forbidden
# `[ERR]` 命中(本次判 FAIL),又把开机耗时从 ~0.6 s 拉到 ~8 s(假指标)。
st_baseline = None
if args.require_selftest_pass and args.selftest_scope == "this-run":
if args.selftest_log:
print("SELFTEST_BASELINE: 用的是本地副本,无基线可比 → 本次按累计口径判定")
args.selftest_scope = "all"
elif not args.device_ip:
print("SELFTEST_BASELINE: 没有 device_ip 就取不到基线 → 本次按累计口径判定")
args.selftest_scope = "all"
else:
base_local, base_status = _fetch_selftest_log(
args.device_ip, out_dir, stamp + "_base", args.ftp_user, args.ftp_pass)
if base_status == "error":
print("RUN_TEST_ERROR: 自检台账基线抓取失败 → 无法证明本次无新增故障"
"(不把抓取失败当成设备干净)")
return 2
st_baseline = _selftest_records(base_local)
print("SELFTEST_BASELINE: 既有记录 {} 条(status={})"
.format(len(st_baseline or []), base_status))
# 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)。烧录失败不中断:板子可能已复位,
# 由采集 + 标记判定结果(与旧版一致)。flash_cmd 为空 = 不烧录,
# 采集已武装,等人工复位/上电目标板(纯串口验证项目常用)。
if flash_argv is None:
print("FLASH: skipped (no flash_cmd) -- reset/power-cycle the board NOW")
else:
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** 里抠出固件戳(config "fw_marker",
# 默认 'fw build: '),追加成 Required 标记 → 芯片里跑的必须就是这份构建。
# hex 里没有该串(旧构建/无此机制)时只 WARN 跳过,不假判 FAIL。
required = args.required
fw_hex = Path(args.fw_hex) if args.fw_hex else None
if args.no_fw_check:
print("FW ID: skipped (--no-fw-check)")
elif not args.fw_marker:
print("FW ID: skipped (fw_marker empty)")
elif fw_hex is None:
print("RUN_TEST_WARN: no 'hex' configured -> identity + readback skipped")
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, args.fw_marker)
if fw_stamp is None:
print("RUN_TEST_WARN: no '{}' stamp in {} -> identity check skipped "
"(stale image: rebuild first)".format(args.fw_marker, fw_hex.name))
else:
marker = args.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 烧录回读校验(config "readback_cmd" 模板,{hex} = 待比对镜像路径;
# 成功判据 = 某行以 "readback_ok"(默认 No difference found)开头)。
# 必须排在**采集之后**:实测 ST-LINK -CmpFile 连接时会复位目标(板子当场
# 重打一遍开机日志),排在采集前会截断开机日志并让启动锚点计数误判。
if args.no_verify_flash:
print("READBACK: skipped (--no-verify-flash)")
elif not args.readback_cmd:
print("READBACK: skipped (no 'readback_cmd' in config)")
elif fw_hex is None or not fw_hex.is_file():
print("READBACK: skipped (no fw hex: {})".format(args.fw_hex or "(unset)"))
else:
rb_cmd = args.readback_cmd.replace("{hex}", '"{}"'.format(fw_hex))
rb_rc, rb_text = _run_cmd(_split_cmd(rb_cmd), args.readback_timeout)
# 判定按**行首**匹配:进度条和帮助文本里也可能含成功字样,
# 只有工具自己那行才独占行首(实测 `...100%\r\nNo difference found.`)
rb_ok = any(ln.strip().lower().startswith(args.readback_ok.lower())
for ln in rb_text.splitlines())
if rb_ok:
print("READBACK: OK ({}) rc={}".format(args.readback_ok, 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 自检记录判定:默认只判「本次启动新增」,累计口径要显式指定。
# 三种抓取结果必须分开对待(旧实现把后两种都当"无记录 → PASS",就是假绿):
# absent(设备侧无此文件) → 台账干净 → 无新增 → PASS
# error(连不上/登录被拒/故障)→ 未知 → FAIL(要求判定却没数据,不能当通过)
# 没有数据源(无 device_ip 又无 --selftest-log)→ FAIL(同上)
selftest_local = args.selftest_log
selftest_forced_fail = False
if args.require_selftest_pass:
if selftest_local:
pass # 本地副本:按它自己的口径判(基线已在 2.5 降级)
elif not args.device_ip:
print("RUN_TEST_FAIL: 要求自检台账判定,但既没有 device_ip 也没有 --selftest-log"
" → 无数据源,判 FAIL(旧行为是 WARN 后当无记录放过)")
selftest_forced_fail = True
else:
after_local, after_status = _fetch_selftest_log(
args.device_ip, out_dir, stamp, args.ftp_user, args.ftp_pass)
if after_status == "error":
print("RUN_TEST_FAIL: 自检台账抓取失败(设备不可达 / FTP 故障)→ 判 FAIL"
"(不把抓取失败当成设备干净)")
selftest_forced_fail = True
elif after_status == "absent":
selftest_local = None # 空台账 = 确无故障
elif args.selftest_scope == "this-run":
new_recs, provable = _selftest_new_records(
st_baseline, _selftest_records(after_local))
if not provable:
print("SELFTEST_JUDGE: FAIL —— 无法证明本次无新增"
"(基线最后一条已被 512 条环覆盖,或基线/台账不可读)")
selftest_forced_fail = True
elif not new_recs:
print("SELFTEST_JUDGE: 本次启动无新增记录(基线 {} 条 → 台账不变)"
.format(len(st_baseline or [])))
selftest_local = None
else:
# 文件名必须落在 selftest_log_*.log 里,才会被 --keep-out 的保留规则收走
delta = out_dir / "selftest_log_{}_new.log".format(stamp)
delta.write_bytes(b"".join(new_recs))
print("SELFTEST_JUDGE: 本次启动新增 {} 条 → 只把新增部分交给判定"
.format(len(new_recs)))
selftest_local = str(delta)
else:
selftest_local = after_local # --selftest-scope all:累计口径
# 6. 标记校验(含固件身份戳、启动锚点计数、可选的自检记录联合判定)
# 启动锚点(config "boot_marker",如本仓库的 'reset cause:')未配置 =
# 锚点计数关闭——不同固件的"一轮启动"锚点各不相同,宁关勿假判。
from verify_log import verify
boot_count = args.max_boot_count if args.boot_marker else 0
if not args.boot_marker:
print("BOOT ANCHOR: off (set 'boot_marker' in config to catch "
"mid-capture resets / stale captures)")
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,
boot_marker=args.boot_marker or "reset cause:",
max_boot_count=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,
exact=args.exact, pairs=args.pair,
allow_residual=args.allow_residual, head_char=args.head_char)
if selftest_forced_fail and rc == 0:
# 标记校验全过,但自检台账判定根本没能执行(无数据源 / 抓取失败 / 无法证明无新增)
# —— 这类"判不了"必须以 FAIL 收口,否则就又回到假绿。
print("RUN_TEST_VERDICT: FAIL —— 自检台账判定未能执行(见上方 SELFTEST 行)")
rc = 1
print("RUN_TEST_RC={}".format(rc))
return rc
if __name__ == "__main__":
sys.exit(main())