502 lines
24 KiB
Python
502 lines
24 KiB
Python
#!/usr/bin/env python3
|
||
"""run_boot_test.py - 一键开机日志抓取 + 校验(纯标准库,2026-09-20 替代 run_boot_test.ps1)。
|
||
|
||
调用仓库内置串口助手(tools/serial_tool,常驻 daemon,子进程 CLI + JSON):
|
||
1. open 串口 + clear 日志(本次运行独立代际)
|
||
2. 烧录(默认 MDK-ARM\\download.bat:下载 + Reset & Run)
|
||
2.5 固件身份戳:从刚烧进去的 .hex 里抠出 `fw build: <DATE> <TIME>`,追加成 Required
|
||
标记——芯片里跑的必须就是这份构建(--no-fw-check 关闭)
|
||
3. wait-idle:RX 静默 >= settle-ms 收尾;首字节超过 --first-wait-ms 未到 → no_data
|
||
(快速失败);整体 max-wait-ms 超时
|
||
4. 从日志提取 [RX] 载荷行(剥掉 "[RX] <ISO时间戳> " 前缀),并给每行加 `[+Nms]` 相对
|
||
偏移(daemon 毫秒时基,本次第一行 = +0ms;--raw-log 出纯载荷)。MCU 侧时间戳只有
|
||
秒级、开机头几行还没时钟,量不了耗时——这个前缀就是耗时口径
|
||
4.5 烧录回读校验:MDK-ARM\\reset.bat cmp(= ST-LINK_CLI -CmpFile 全片逐字节比对,
|
||
`No difference found` 才算真写进去;--no-verify-flash 关闭)。
|
||
**必须排在采集之后**:实测 -CmpFile 连接时会复位目标(板子当场重打一遍开机日志)
|
||
5. verify_log.py 标记校验(含启动锚点计数:`reset cause:` 出现 0 次或 >N 次 = FAIL,
|
||
N 由 --max-boot-count 给,默认 1;--expect-reset-cause 可对第一个锚点做原因白名单;
|
||
--require-order 查标记先后顺序;--done-marker + --max-boot-ms 量开机耗时)
|
||
6. (仅当 --require-selftest-pass)自检记录联合判定(S7):
|
||
--selftest-log 提供本地副本则直接用它,否则从设备 FTP 抓取 /log/selftest.log
|
||
(lftpd :21 匿名);判定:文件缺失 / 0 字节 = 无故障 → PASS,有 64B 记录 = FAIL
|
||
|
||
采集在烧录**之前**武装(open + clear 先于烧录),daemon 常驻全量日志兜底,
|
||
无采集窗口/首字节等待仪式(与旧 PowerShell 版行为一致)。
|
||
|
||
相对旧 PowerShell 版的加固(本机 Windows 环境实测教训,2026-09-20):
|
||
- 子进程输出按**字节**读取、显式 UTF-8 解码 —— 不受控制台代码页(GBK)影响
|
||
- serial_config.json 按 UTF-8(容忍 BOM)解析 —— 无 PS 5.1 按 GBK 读 JSON
|
||
导致 "Unterminated string" 崩溃的问题
|
||
- 不经 PowerShell —— 无执行策略(scripts disabled)拦截
|
||
- 烧录带超时(默认 180s;旧版无超时,坏 SWD 连接会永久挂起)
|
||
|
||
用法:
|
||
& '.venv\\Scripts\\python.exe' test/host/run_boot_test.py
|
||
[--port COM7] [--baud-rate 115200] [--settle-ms 8000] [--max-wait-ms 30000]
|
||
[--first-wait-ms 12000]
|
||
[--flash-cmd MDK-ARM\\download.bat] [--flash-timeout 180]
|
||
[--required "App init OK;Network Init;net init: OK"]
|
||
[--forbidden "FAIL;ERROR;HardFault;[ERR]"]
|
||
[--require-suite-summary "Storage Tests"]
|
||
[--require-selftest-pass] [--selftest-log <本地副本>]
|
||
[--device-ip 192.168.1.100] [--out-dir <目录>] [--show-tail-lines 15]
|
||
[--fw-hex <构建产物 hex>] [--no-fw-check]
|
||
[--no-verify-flash] [--readback-timeout 120]
|
||
[--max-boot-count 1] [--expect-reset-cause 'software;power-on']
|
||
[--require-order 'App init OK>Network Init>net init: OK']
|
||
[--done-marker 'net init: OK'] [--max-boot-ms 0]
|
||
[--raw-log] [--keep-out 20]
|
||
|
||
--port 缺省取 .opencode/serial_config.json 的 portName(单一配置源)
|
||
|
||
退出码(与旧版一致):
|
||
0 = PASS(采集成功 + Required 全中 + Forbidden 零命中)
|
||
1 = FAIL(标记缺失/命中)
|
||
2 = 采集或烧录错误(含烧录回读不一致 = 芯片内容与刚编的镜像不同,本次运行作废)
|
||
3 = 用法/预检错误
|
||
"""
|
||
|
||
import argparse
|
||
import ftplib
|
||
import json
|
||
import shlex
|
||
import subprocess
|
||
import sys
|
||
import tempfile
|
||
import time
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
|
||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||
# 本脚本位于 test/host/,仓库根需上溯两级(2026-10 实机窗口修:原 .parent 只到 test/,
|
||
# 导致 .opencode/serial_config.json 与 tools/serial_tool 路径解析错误)
|
||
REPO_ROOT = SCRIPT_DIR.parent.parent
|
||
SERIAL_TOOL = REPO_ROOT / "tools" / "serial_tool" / "serial_tool.py"
|
||
CFG_FILE = REPO_ROOT / ".opencode" / "serial_config.json"
|
||
DEFAULT_FLASH = REPO_ROOT / "MDK-ARM" / "download.bat"
|
||
RESET_BAT = REPO_ROOT / "MDK-ARM" / "reset.bat"
|
||
DEFAULT_HEX = REPO_ROOT / "MDK-ARM" / "STM32F407-Demo" / "STM32F407-Demo.hex"
|
||
|
||
REQUIRED_DEFAULT = "App init OK;Network Init;net init: OK"
|
||
FORBIDDEN_DEFAULT = "FAIL;ERROR;HardFault;[ERR]"
|
||
|
||
# 固件身份戳(App/app/app_main.c 的 `DBG_INFO("fw build: " __DATE__ " " __TIME__)`)。
|
||
# 编译期展开成一段连续 ASCII,故能直接从 .hex 里抠出来当 Required 标记用:
|
||
# 芯片里跑的镜像 == 刚编出来的镜像,这才叫"烧进去了"。此前所有标记都是固定串,
|
||
# 旧镜像照样全中——AGENTS.md 烧录铁律记的"反复重下载、芯片一直是旧镜像"在
|
||
# 自动闭环里完全不可见(2026-10-10 加)。
|
||
FW_MARKER = "fw build: "
|
||
|
||
|
||
def _split_cmd(cmd_str):
|
||
r"""把 `--flash-cmd` 这类命令串拆成 [exe, arg, ...](2026-10-10 实测坑)。
|
||
|
||
直接交给 subprocess、**不经 `cmd /c`**:CreateProcess 能直接跑 .bat,且 Python
|
||
会给含空格的路径正确加引号。旧写法 `['cmd','/c', 串]` 在本仓库(目录名
|
||
`DTU Code` 含空格)会被 cmd 的 /c 引号规则玩坏——实测报
|
||
`'D:\Code\DTU' is not recognized`,回读校验被假判成"芯片内容不一致"。
|
||
路径本身含空格时把该段用引号包住(`"D:\some dir\reset.bat" flash`)。
|
||
posix=False 是必须的:posix=True 会吃掉 Windows 路径里的反斜杠。
|
||
"""
|
||
return [p.strip('"') for p in shlex.split(cmd_str, posix=False)]
|
||
|
||
|
||
def _run_cmd(argv, timeout):
|
||
"""跑一条外部命令,按字节取回输出并显式 UTF-8 解码(不受控制台代码页影响)。"""
|
||
try:
|
||
proc = subprocess.run(argv, capture_output=True, timeout=timeout)
|
||
rc = proc.returncode
|
||
raw = (proc.stdout or b"") + (proc.stderr or b"")
|
||
except subprocess.TimeoutExpired as e:
|
||
rc = -1
|
||
raw = (e.stdout or b"") + (e.stderr or b"")
|
||
return rc, raw.decode("utf-8", errors="replace")
|
||
|
||
|
||
def extract_fw_stamp(hex_path):
|
||
"""从 Intel HEX 里抠固件身份戳(只扫数据记录,不建整片镜像,内存 O(镜像体积))。
|
||
|
||
返回戳串(如 'Oct 10 2026 02:38:12');找不到(旧固件/未重编译)返回 None。
|
||
"""
|
||
data = bytearray()
|
||
for line in Path(hex_path).read_text(errors="replace").splitlines():
|
||
line = line.strip()
|
||
if not line.startswith(":"):
|
||
continue
|
||
try:
|
||
rec = bytes.fromhex(line[1:])
|
||
except ValueError:
|
||
continue
|
||
if len(rec) > 4 and rec[3] == 0:
|
||
data += rec[4:4 + rec[0]]
|
||
i = data.find(FW_MARKER.encode("ascii"))
|
||
if i < 0:
|
||
return None
|
||
j = i + len(FW_MARKER)
|
||
end = j
|
||
while end < len(data) and 0x20 <= data[end] < 0x7F:
|
||
end += 1
|
||
return data[j:end].decode("ascii", errors="replace")
|
||
|
||
|
||
def _cli(cmd_args, timeout):
|
||
"""调用串口助手 CLI,返回 (rc, stdout_text, parsed_json_or_None)。
|
||
|
||
全程字节级处理(capture_output 取 bytes 后显式 UTF-8 解码),
|
||
任何 Windows 控制台代码页都不参与数据路径。
|
||
"""
|
||
try:
|
||
proc = subprocess.run(cmd_args, capture_output=True, timeout=timeout)
|
||
except subprocess.TimeoutExpired:
|
||
return 2, "client timeout ({}s)".format(timeout), None
|
||
text = proc.stdout.decode("utf-8", errors="replace")
|
||
try:
|
||
return proc.returncode, text, json.loads(text)
|
||
except ValueError:
|
||
return proc.returncode, text, None
|
||
|
||
|
||
def _tool_cmd(py, cmd, *rest, timeout=15):
|
||
rc, out, data = _cli([py, str(SERIAL_TOOL), cmd, *rest], timeout)
|
||
if rc != 0 or data is None:
|
||
return None, out
|
||
return data, out
|
||
|
||
|
||
def _ftp_quit(ftp):
|
||
"""尽力关闭 FTP 控制连接(QUIT 失败不影响判定)。"""
|
||
try:
|
||
ftp.quit()
|
||
except (OSError, ftplib.Error):
|
||
pass
|
||
|
||
|
||
def _prune_out_dir(out_dir, keep):
|
||
"""只保留最近 N 份产物(`boot_log_*.txt` / `selftest_log_*.log`)。
|
||
|
||
这些文件按 `%Y%m%d_%H%M%S` 命名、每次 @test 各留一份,旧实现从不清理——
|
||
本机实测 out 目录已堆到 31 份 boot_log + 7 份 selftest_log(2026-10-10)。
|
||
文件名含时间戳 → 按名字排序即按时间排序。
|
||
"""
|
||
if keep <= 0:
|
||
return 0
|
||
removed = 0
|
||
for pat in ("boot_log_*.txt", "selftest_log_*.log"):
|
||
for p in sorted(out_dir.glob(pat), key=lambda q: q.name)[:-keep]:
|
||
try:
|
||
p.unlink()
|
||
removed += 1
|
||
except OSError:
|
||
pass
|
||
return removed
|
||
|
||
|
||
def _fetch_selftest_log(device_ip, out_dir, stamp):
|
||
"""从设备 FTP 抓取 /log/selftest.log(lftpd :21 匿名,根目录 = 卷根)。
|
||
|
||
成功返回本地路径字符串;失败(网络不可达 / FTP 错误 / 设备侧文件缺失)返回 ""
|
||
(按 S7 语义视为无故障记录;此处打 WARN,调用方不判整体 FAIL)。
|
||
|
||
2026-10-02 台架修复:原实现只 `except OSError`,而 ftplib 的 error_temp /
|
||
error_perm 继承自 Exception 而非 OSError —— 台账"仅故障才写",干净设备上
|
||
/log/selftest.log 根本不存在,RETR 返回 450 直接抛出未捕获异常,令整个
|
||
`@test --require-selftest-pass` 在最后一步崩掉(而它本该是 PASS 路径)。
|
||
现把"设备侧无此文件"单列为正常路径(INFO),其余 FTP 错误保留 WARN。
|
||
"""
|
||
local = Path(out_dir) / "selftest_log_{}.log".format(stamp)
|
||
ftp = ftplib.FTP()
|
||
try:
|
||
ftp.connect(device_ip, 21, timeout=10)
|
||
ftp.login() # 匿名(lftpd 接受 USER anonymous / 空 PASS)
|
||
except (OSError, ftplib.Error) as e:
|
||
print("RUN_TEST_WARN: selftest fetch failed: {} "
|
||
"(按 S7 语义视为无记录;设备网络/FTP 需另行排查)".format(e))
|
||
return ""
|
||
|
||
buf = bytearray()
|
||
try:
|
||
ftp.retrbinary("RETR /log/selftest.log", buf.extend)
|
||
except ftplib.all_errors as e:
|
||
# 450/550 = 设备侧无此文件:干净台账,S7 判 PASS(正常路径,非故障)
|
||
print("SELFTEST_FETCH: {} -> (ledger absent: {}) 按 S7 视为无故障记录"
|
||
.format(device_ip, str(e).strip()))
|
||
_ftp_quit(ftp)
|
||
return ""
|
||
|
||
_ftp_quit(ftp)
|
||
local.write_bytes(bytes(buf))
|
||
print("SELFTEST_FETCH: {} -> {} ({} bytes)".format(device_ip, local, len(buf)))
|
||
return str(local)
|
||
|
||
|
||
def main(argv=None):
|
||
# 控制台输出固定 UTF-8:Windows 代码页只影响显示,不会让脚本崩溃
|
||
for stream in (sys.stdout, sys.stderr):
|
||
try:
|
||
stream.reconfigure(encoding="utf-8", errors="replace")
|
||
except Exception:
|
||
pass
|
||
|
||
ap = argparse.ArgumentParser(description="boot log capture + verify (pure stdlib, no PowerShell)")
|
||
ap.add_argument("--port", default="",
|
||
help="serial port (default: portName from .opencode/serial_config.json)")
|
||
ap.add_argument("--baud-rate", type=int, default=115200)
|
||
ap.add_argument("--settle-ms", type=int, default=8000,
|
||
help="RX silence that ends the capture")
|
||
ap.add_argument("--max-wait-ms", type=int, default=30000,
|
||
help="overall wait-idle timeout")
|
||
ap.add_argument("--first-wait-ms", type=int, default=12000,
|
||
help="give up as no_data if the board sent ZERO bytes this long "
|
||
"after flash started the wait (0 = same as --max-wait-ms)")
|
||
ap.add_argument("--flash-cmd", default="",
|
||
help="flash command (default: MDK-ARM/download.bat; pass reset.bat for reset-only)")
|
||
ap.add_argument("--flash-timeout", type=int, default=180,
|
||
help="flash subprocess timeout in seconds")
|
||
ap.add_argument("--required", default=REQUIRED_DEFAULT,
|
||
help="required markers, ';' separated")
|
||
ap.add_argument("--forbidden", default=FORBIDDEN_DEFAULT,
|
||
help="forbidden patterns, ';' separated")
|
||
ap.add_argument("--require-suite-summary", default="",
|
||
help="suite names that MUST appear as '=== <name>: N/M PASSED (failed=0) ===' "
|
||
"with N>0, ';' separated (T-2). Empty = off")
|
||
ap.add_argument("--require-selftest-pass", action="store_true",
|
||
help="jointly judge /log/selftest.log (S7): missing/empty = PASS, "
|
||
"any 64B record = FAIL")
|
||
ap.add_argument("--selftest-log", default="",
|
||
help="local copy of /log/selftest.log (skips device-side fetch when given)")
|
||
ap.add_argument("--device-ip", default="192.168.1.100",
|
||
help="device IP for fetching /log/selftest.log via FTP (lftpd anonymous)")
|
||
ap.add_argument("--out-dir", default=str(Path(tempfile.gettempdir()) / "opencode"))
|
||
ap.add_argument("--show-tail-lines", type=int, default=15)
|
||
ap.add_argument("--fw-hex", default=str(DEFAULT_HEX),
|
||
help="built image for the firmware-identity stamp and the readback "
|
||
"compare (default: latest build hex)")
|
||
ap.add_argument("--no-fw-check", action="store_true",
|
||
help="do not require the 'fw build: <DATE> <TIME>' stamp in the boot log")
|
||
ap.add_argument("--no-verify-flash", action="store_true",
|
||
help="skip the ST-LINK -CmpFile readback (AGENTS.md makes it mandatory)")
|
||
ap.add_argument("--readback-timeout", type=int, default=120,
|
||
help="timeout for the readback compare, seconds")
|
||
ap.add_argument("--max-boot-count", type=int, default=1,
|
||
help="fail if the boot banner ('reset cause:') appears 0 times or more "
|
||
"than N times (0 = check off)")
|
||
ap.add_argument("--expect-reset-cause", default="",
|
||
help="';'-separated substrings the FIRST reset cause must match "
|
||
"(case-insensitive); empty = report only, no verdict")
|
||
ap.add_argument("--require-order", default="",
|
||
help="'>'-separated markers that must appear in that line order "
|
||
"(catches out-of-order boot steps); empty = off")
|
||
ap.add_argument("--done-marker", default="net init: OK",
|
||
help="marker whose line marks boot completion (for --max-boot-ms); "
|
||
"empty = don't measure")
|
||
ap.add_argument("--max-boot-ms", type=int, default=0,
|
||
help="fail if time from first line to --done-marker exceeds N ms "
|
||
"(0 = measure and report only)")
|
||
ap.add_argument("--raw-log", action="store_true",
|
||
help="write payload-only boot log (no '[+Nms]' relative-time prefix)")
|
||
ap.add_argument("--keep-out", type=int, default=20,
|
||
help="keep the newest N boot_log_*/selftest_log_* files in --out-dir "
|
||
"(0 = keep everything)")
|
||
args = ap.parse_args(argv)
|
||
|
||
# 串口助手 CLI 用当前解释器(AGENTS.md 统一入口 = 仓库 .venv python)
|
||
py = sys.executable
|
||
|
||
# 端口缺省:单一配置源 .opencode/serial_config.json(UTF-8,容忍 BOM)
|
||
port = args.port
|
||
if not port and CFG_FILE.is_file():
|
||
try:
|
||
cfg = json.loads(CFG_FILE.read_text(encoding="utf-8-sig"))
|
||
port = str(cfg.get("portName") or "")
|
||
except (OSError, ValueError) as e:
|
||
print("RUN_TEST_ERROR: cannot parse {}: {}".format(CFG_FILE, e))
|
||
return 3
|
||
if not port:
|
||
print("RUN_TEST_ERROR: no --port given and no portName in {}".format(CFG_FILE))
|
||
return 3
|
||
|
||
# --flash-cmd 允许带参数(如 "MDK-ARM\reset.bat flash"):拆成 argv,
|
||
# 存在性只查 exe 段(整串当文件名必然 missing,2026-10-10 实测)
|
||
flash_argv = _split_cmd(args.flash_cmd) if args.flash_cmd else [str(DEFAULT_FLASH)]
|
||
flash_cmd = Path(flash_argv[0])
|
||
|
||
# 预检(对应旧版 missing-file 检查)
|
||
for p in (SERIAL_TOOL, SCRIPT_DIR / "verify_log.py", flash_cmd):
|
||
if not p.is_file():
|
||
print("RUN_TEST_ERROR: missing {}".format(p))
|
||
return 3
|
||
|
||
out_dir = Path(args.out_dir)
|
||
out_dir.mkdir(parents=True, exist_ok=True)
|
||
pruned = _prune_out_dir(out_dir, args.keep_out)
|
||
if pruned:
|
||
print("OUT: pruned {} old artifact file(s) in {} (keep={})".format(
|
||
pruned, out_dir, args.keep_out))
|
||
|
||
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||
boot_txt = out_dir / "boot_log_{}.txt".format(stamp)
|
||
|
||
def tool(cmd, *rest, timeout=15):
|
||
return _tool_cmd(py, cmd, *rest, timeout=timeout)
|
||
|
||
print("=== Boot test {} ===".format(stamp))
|
||
print("Port: {} @ {} settle={}ms max={}ms".format(
|
||
port, args.baud_rate, args.settle_ms, args.max_wait_ms))
|
||
|
||
# 1. open(幂等;未运行则自动拉起常驻 daemon)
|
||
data, out = tool("open", "--port", port, "--baud", str(args.baud_rate), "--timeout", "15")
|
||
if data is None:
|
||
print("RUN_TEST_ERROR: open failed: {}".format(out))
|
||
return 2
|
||
|
||
# 2. clear(新代际;此后数据才属于本次运行)
|
||
data, out = tool("clear", "--timeout", "15")
|
||
if data is None:
|
||
print("RUN_TEST_ERROR: clear failed: {}".format(out))
|
||
return 2
|
||
|
||
# 3. flash(下载 + Reset & Run)。烧录失败不中断:板子可能已复位,
|
||
# 由采集 + 标记判定结果(与旧版一致)
|
||
print("FLASH_START: {}".format(" ".join(flash_argv)))
|
||
t0 = time.monotonic()
|
||
try:
|
||
proc = subprocess.run(flash_argv,
|
||
capture_output=True, timeout=args.flash_timeout)
|
||
flash_rc = proc.returncode
|
||
flash_bytes = (proc.stdout or b"") + (proc.stderr or b"")
|
||
except subprocess.TimeoutExpired as e:
|
||
flash_rc = -1
|
||
flash_bytes = (e.stdout or b"") + (e.stderr or b"")
|
||
elapsed_ms = int((time.monotonic() - t0) * 1000)
|
||
for line in flash_bytes.decode("utf-8", errors="replace").strip().splitlines()[-12:]:
|
||
print(line)
|
||
print("FLASH_RC: {} elapsed_ms={}".format(flash_rc, elapsed_ms))
|
||
if flash_rc != 0:
|
||
print("RUN_TEST_WARN: flash rc={} (continuing: board may still have reset)".format(flash_rc))
|
||
|
||
# 3.5 固件身份戳:从**刚烧进去的那份 .hex** 里抠出 `fw build: <DATE> <TIME>`,
|
||
# 追加成 Required 标记 → 芯片里跑的必须就是这份构建。旧 hex 里没有该串
|
||
# (未重编译)时只 WARN 跳过,不假判 FAIL。
|
||
required = args.required
|
||
fw_hex = Path(args.fw_hex)
|
||
if args.no_fw_check:
|
||
print("FW ID: skipped (--no-fw-check)")
|
||
elif not fw_hex.is_file():
|
||
print("RUN_TEST_WARN: fw hex not found {} -> identity + readback skipped".format(fw_hex))
|
||
else:
|
||
fw_stamp = extract_fw_stamp(fw_hex)
|
||
if fw_stamp is None:
|
||
print("RUN_TEST_WARN: no '{}' stamp in {} -> identity check skipped "
|
||
"(old build: run @build first)".format(FW_MARKER, fw_hex.name))
|
||
else:
|
||
marker = FW_MARKER + fw_stamp
|
||
required = (args.required + ";" + marker) if args.required else marker
|
||
print("FW ID: expected stamp '{}' (from {})".format(marker, fw_hex.name))
|
||
|
||
# 4. wait-idle(RX 静默收尾或整体超时)
|
||
# --first-timeout-ms:首字节门——烧录后板子全程不吐字节时按 no_data 快速失败
|
||
# (旧版 daemon 常驻累计 rx_bytes>0,no_data 永不触发,只能靠后面"日志里没有
|
||
# [RX] 行"兜底,报错信息误导成 device silent?)
|
||
wait_timeout_s = args.max_wait_ms // 1000 + 20
|
||
wait_args = ["--settle-ms", str(args.settle_ms),
|
||
"--max-ms", str(args.max_wait_ms),
|
||
"--timeout", str(wait_timeout_s)]
|
||
if args.first_wait_ms:
|
||
wait_args += ["--first-timeout-ms", str(args.first_wait_ms)]
|
||
data, out = tool("wait-idle", *wait_args, timeout=wait_timeout_s + 15)
|
||
if data is None:
|
||
print("RUN_TEST_ERROR: wait-idle failed: {}".format(out))
|
||
return 2
|
||
print("WAIT: reason={} rx_bytes={} waited_ms={}".format(
|
||
data.get("reason"), data.get("rx_bytes"), data.get("waited_ms")))
|
||
if data.get("reason") == "no_data":
|
||
print("RUN_TEST_ERROR: no RX data at all (check port / board power / flash)")
|
||
return 2
|
||
|
||
# 5. 提取 [RX] 载荷行(剥掉 "[RX] <ISO时间戳> " 前缀)
|
||
# 默认给每行加 `[+Nms]` 相对偏移(daemon 毫秒时基,本次采集第一行 = +0ms)。
|
||
# 为什么加:MCU 侧时间戳只有**秒级**,且开机头几行时钟还没起(`----/--/--`),
|
||
# 根本量不了耗时;有了它 verify_log 才能查"能开机但慢了几秒"(--max-boot-ms)。
|
||
# --raw-log 恢复旧格式(纯载荷)。
|
||
data, out = tool("log", "--tail", "0", "--timeout", "15")
|
||
if data is None:
|
||
print("RUN_TEST_ERROR: log read failed: {}".format(out))
|
||
return 2
|
||
rx = []
|
||
t0 = None
|
||
for l in data.get("lines", []):
|
||
if not (isinstance(l, str) and l.startswith("[RX] ")):
|
||
continue
|
||
rest = l[5:]
|
||
sp = rest.find(" ")
|
||
ts_txt = rest[:sp] if sp >= 0 else ""
|
||
payload = rest[sp + 1:] if sp >= 0 else rest
|
||
if args.raw_log:
|
||
rx.append(payload)
|
||
continue
|
||
ts = None
|
||
if ts_txt:
|
||
try:
|
||
ts = datetime.fromisoformat(ts_txt)
|
||
except ValueError:
|
||
ts = None
|
||
if ts is None:
|
||
rx.append(payload) # 时间戳读不出来就不加前缀(不猜)
|
||
continue
|
||
if t0 is None:
|
||
t0 = ts
|
||
rx.append("[+{}ms] {}".format(int((ts - t0).total_seconds() * 1000), payload))
|
||
if not rx:
|
||
print("RUN_TEST_ERROR: no [RX] lines in log (device silent?)")
|
||
return 2
|
||
boot_txt.write_bytes(("\r\n".join(rx) + "\r\n").encode("utf-8"))
|
||
print("Boot log: {} (lines={} gen={} ts={})".format(
|
||
boot_txt, len(rx), data.get("gen"), "raw" if args.raw_log else "+ms"))
|
||
|
||
# 5.6 烧录回读校验(AGENTS.md 铁律:`No difference found` 才算真写进去)。
|
||
# 必须排在**采集之后**:实测 -CmpFile 连接时会复位目标(板子当场重打一遍开机
|
||
# 日志),排在采集前会截断开机日志并让启动锚点计数误判成"中途复位"。
|
||
if args.no_verify_flash:
|
||
print("READBACK: skipped (--no-verify-flash)")
|
||
elif not fw_hex.is_file():
|
||
print("READBACK: skipped (no fw hex: {})".format(fw_hex))
|
||
else:
|
||
rb_rc, rb_text = _run_cmd(_split_cmd('{} cmp "{}"'.format(RESET_BAT, fw_hex)),
|
||
args.readback_timeout)
|
||
# 判定按**行首**匹配:进度条和 reset.bat 的帮助文本里都含 "No difference found"
|
||
# 字样,只有 ST-LINK 自己那行才独占行首(实测 `...100%\r\nNo difference found.`)。
|
||
rb_ok = any(ln.strip().lower().startswith("no difference found")
|
||
for ln in rb_text.splitlines())
|
||
if rb_ok:
|
||
print("READBACK: OK (No difference found) rc={}".format(rb_rc))
|
||
else:
|
||
print("READBACK: FAIL -- device flash does NOT match {}".format(fw_hex.name))
|
||
for line in rb_text.strip().splitlines()[-10:]:
|
||
print(" | {}".format(line))
|
||
print("RUN_TEST_ERROR: flash readback mismatch (chip content != built image)")
|
||
return 2
|
||
|
||
# 5.5 自检记录抓取(S7):启用且未提供本地副本时才走 FTP
|
||
selftest_local = args.selftest_log
|
||
if args.require_selftest_pass and not selftest_local:
|
||
selftest_local = _fetch_selftest_log(args.device_ip, out_dir, stamp)
|
||
|
||
# 6. 标记校验(含固件身份戳、启动锚点计数、可选的自检记录联合判定)
|
||
from verify_log import verify
|
||
rc = verify(str(boot_txt), required, args.forbidden, args.show_tail_lines,
|
||
suite_summary=args.require_suite_summary,
|
||
selftest_pass=args.require_selftest_pass,
|
||
selftest_log=selftest_local,
|
||
max_boot_count=args.max_boot_count,
|
||
expect_reset_cause=args.expect_reset_cause,
|
||
require_order=args.require_order,
|
||
done_marker=args.done_marker,
|
||
max_boot_ms=args.max_boot_ms)
|
||
print("RUN_TEST_RC={}".format(rc))
|
||
return rc
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|