4a4ad6bc5f
现状口径:/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 全过;字节卫生零漂移。
777 lines
40 KiB
Python
777 lines
40 KiB
Python
#!/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())
|