Files
STM32F4-Base/.dsh/skills/serial-boot-test/scripts/run_boot_test.py
T
wandering1 5009a844a9
CI / L0 静态门禁 (pre-commit) (push) Has been cancelled
CI / L0.5 主机侧单测 (MSVC) (push) Has been cancelled
收敛 reset.bat 到 stlink-flash skill 并删除该批处理
stlink_flash.py 新增 hard 子命令(-c SWD RM=Hrst -HardRst PULSE=100),
RM= 被 CLI 拒绝时自动降级为系统复位并打印 RESET_NOTE;至此 reset.bat 的
全部子命令(复位/hard/halt/run/list/flash/cmp)均已由 skill 覆盖。

删除 MDK-ARM/reset.bat(git rm)。@build/@download 两条旧批处理入口至此
全部收敛到 .dsh/skills/ 下的 skill,一个能力一个实现。

同步更新引用:AGENTS.md、README、MDK-ARM/README、7 篇 docs、4 个 skill
(stlink-flash/serial-boot-test/new-machine-setup 及 config.example.json)。

真机验证:hard 子命令 RM= 被拒 → RESET_NOTE → 回退系统复位 → STLINK_RC=0。
2026-10-10 16:41:46 +08:00

625 lines
31 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)自检记录联合判定(S7):
--selftest-log 提供本地副本则直接用它,否则从设备 FTP 抓取 /log/selftest.log
(需 config "device_ip");判定:文件缺失 / 0 字节 = 无故障 → PASS
采集在烧录**之前**武装(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-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` 命名、每次 @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=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 (T-2). Empty = off")
ap.add_argument("--require-selftest-pass", action="store_true", default=None,
help="jointly judge /log/selftest.log (S7): 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("--device-ip", default=None,
help="device IP for fetching /log/selftest.log via FTP (anonymous)")
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("--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.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.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))
# 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 自检记录抓取(S7):启用且未提供本地副本时才走 FTP(需 config "device_ip")
selftest_local = args.selftest_log
if args.require_selftest_pass and not selftest_local and not args.device_ip:
print("RUN_TEST_WARN: require_selftest_pass but no device_ip/selftest_log "
"-> treated as no record (S7 PASS path)")
if args.require_selftest_pass and not selftest_local and args.device_ip:
selftest_local = _fetch_selftest_log(args.device_ip, out_dir, stamp)
# 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)
print("RUN_TEST_RC={}".format(rc))
return rc
if __name__ == "__main__":
sys.exit(main())