Files
STM32F4-Base/.dsh/skills/serial-boot-test/scripts/run_boot_test.py
T
wandering1 0f7b5a87fb chore(编号): 存量编号全量转语义短语,门禁识别面与语料同批重打
登记簿、文档与代码注释里的存量字母加数字编号(N-x / S-x / T-x / E-x / A-x / 卡号)
一律改写为语义短语,净删 2,028 处引用;删除基线与待确认、并行分道方案、排期详述、
采集保护收尾 4 篇(内容已并入 docs/任务规划/README 与登记簿)。

门禁机器与三张账同批重打:check_ids.py 新增变更记录行豁免与第二根护栏
undecidable_total_ceiling;check_test_traceability.py 只加 stdout/stderr UTF-8
reconfigure(TC_RE 未动);check_cubemx_survival.py 的 --write 固定 newline=LF;
id_registry / id_aliases(+19 条 retired-to-phrase)/ id_known_backlog 重打。

test/ 56 个文件同步 TC 标签语义化,其中 18 个 TU 改了 CHECK / printf 里的标签文字,
是本批唯一改变构建产物的文件集合。

为什么合成一笔、不拆四笔(实测撞闸,非偏好):pre-commit 跑钩子前会 stash 未 staged
部分,拆开后中间提交必然是「新规则 + 旧语料」——实跑可判定 85.15% < 基线 86.00%、
不可判定 1,453 > 上限 1,166,编号门禁直接拦下。编号识别面与它描述的语料只能同码移动。

实跑基线(工作区全量):754 文件 / 8,230 处编号字面量 / 可判定 7,079 = 86.01%
(基线 86.0%,容差 0.1pp)/ 不可判定绝对数 1,151(上限 1,166)/ 校验 1-6 错误级 0 /
告警 = 悬空 2(App/algo/freq.c 的 S2,属 §4.4 同号不同义)+ 孤儿 50。

基线从 84.0 抬到 86.0 的理由见 docs/门禁说明/id-check.md §7.6:本批真删了 2,028 处
引用,可判定面随真实内容上升,不是把分母做小。同批新增排除 docs/硬件平台/ 语料,使
识别面少 264 处——芯片手册位号与网表料号不属编号空间(registry 有 excluded_paths_note)。

⚠ 余量只剩 0.11pp(约 10 处)与绝对数 15 处:把「可判定的引用」改成散文会立刻撞第二
根护栏。另遗留一项:id_aliases 里 retired 的 N / S / T / A 别名没有 scope(U1~U5 有),
今后有人新写一个 A-4,校验 3 不会响,只能靠人,建议下批补 scope。

固件不变证据:115 个 .c/.h 剥注释并归一字符串后与 HEAD 逐字节全等,残留代码差异 0;
App / Src / Drivers 的字符串字面量零改动。@build 0 错误 0 警告;40/40 主机套件、2,889
断言全通过;覆盖率 line 89.2% / func 92.6%;pre-commit 48 钩子 --all-files 全绿;
check_doc_links --all 通过(176 文档,章节引用无法判定 0);逐字节卫生核过:14 个带
BOM 文件与 HEAD 一致、0 个 CR、0 个 U+FFFD,字符串字面量纯 ASCII。

.cubemx_survival_baseline.json 的 watchdog-module 哈希 56bf27 变 2bb051 属合法:
Src/freertos.c 的 USER CODE 区内只有注释文字随编号改写,代码未变。

同批带上体积同码凭证与 RAM 台账行(@build 之后盖戳):RW_IRAM1 79,008 B / 60.3%,
RW_CCM 57,376 B / 87.9%,阈值 92%。
2026-10-26 12:28:14 +08:00

758 lines
39 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):默认**只判本次启动新增**
——烧录前先经 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` 命名、每次 @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 直接抛出未捕获异常,令整个
`@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)
# 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 = 设备侧无此文件:干净台账,S7 判 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 (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("--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("--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 自检台账基线(S7「只看本次新增」):**必须排在串口采集武装之前**。
# /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 自检记录判定(S7):默认只判「本次启动新增」,累计口径要显式指定。
# 三种抓取结果必须分开对待(旧实现把后两种都当"无记录 → 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)
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())