S7:@test闭环增加自检结论联合判定(selftest.log缺失或空=PASS、有64B记录=FAIL;FTP匿名抓取+本地副本两种来源)

This commit is contained in:
2026-10-02 15:06:36 +08:00
parent b0851b2f70
commit 4a4cd29342
2 changed files with 91 additions and 6 deletions
+48 -3
View File
@@ -7,6 +7,9 @@
3. wait-idle:RX 静默 >= settle-ms,或 max-wait-ms 超时 3. wait-idle:RX 静默 >= settle-ms,或 max-wait-ms 超时
4. 从日志提取 [RX] 载荷行(剥掉 "[RX] <ISO时间戳> " 前缀) 4. 从日志提取 [RX] 载荷行(剥掉 "[RX] <ISO时间戳> " 前缀)
5. verify_log.py 标记校验 5. verify_log.py 标记校验
6. (仅当 --require-selftest-pass)自检记录联合判定(S7):
--selftest-log 提供本地副本则直接用它,否则从设备 FTP 抓取 /log/selftest.log
(lftpd :21 匿名);判定:文件缺失 / 0 字节 = 无故障 → PASS,有 64B 记录 = FAIL
采集在烧录**之前**武装(open + clear 先于烧录),daemon 常驻全量日志兜底, 采集在烧录**之前**武装(open + clear 先于烧录),daemon 常驻全量日志兜底,
无采集窗口/首字节等待仪式(与旧 PowerShell 版行为一致)。 无采集窗口/首字节等待仪式(与旧 PowerShell 版行为一致)。
@@ -25,7 +28,8 @@
[--required "App init OK;Network Init;net init: OK"] [--required "App init OK;Network Init;net init: OK"]
[--forbidden "FAIL;ERROR;HardFault;[ERR]"] [--forbidden "FAIL;ERROR;HardFault;[ERR]"]
[--require-suite-summary "Storage Tests"] [--require-suite-summary "Storage Tests"]
[--out-dir <目录>] [--show-tail-lines 15] [--require-selftest-pass] [--selftest-log <本地副本>]
[--device-ip 192.168.1.100] [--out-dir <目录>] [--show-tail-lines 15]
--port 缺省取 .opencode/serial_config.json 的 portName(单一配置源) --port 缺省取 .opencode/serial_config.json 的 portName(单一配置源)
@@ -37,6 +41,7 @@
""" """
import argparse import argparse
import ftplib
import json import json
import subprocess import subprocess
import sys import sys
@@ -79,6 +84,32 @@ def _tool_cmd(py, cmd, *rest, timeout=15):
return data, out return data, out
def _fetch_selftest_log(device_ip, out_dir, stamp):
"""从设备 FTP 抓取 /log/selftest.log(lftpd :21 匿名,根目录 = 卷根)。
成功返回本地路径字符串;失败(网络不可达 / FTP 错误 / 设备侧文件缺失)返回 ""
(按 S7 语义视为无故障记录;此处打 WARN,调用方不判整体 FAIL)。
"""
local = Path(out_dir) / "selftest_log_{}.log".format(stamp)
try:
ftp = ftplib.FTP()
ftp.connect(device_ip, 21, timeout=10)
ftp.login() # 匿名(lftpd 接受 USER anonymous / 空 PASS)
buf = bytearray()
ftp.retrbinary("RETR /log/selftest.log", buf.extend)
try:
ftp.quit()
except OSError:
pass
local.write_bytes(bytes(buf))
print("SELFTEST_FETCH: {} -> {} ({} bytes)".format(device_ip, local, len(buf)))
return str(local)
except OSError as e:
print("RUN_TEST_WARN: selftest fetch failed: {} "
"(按 S7 语义视为无记录;设备网络/FTP 需另行排查)".format(e))
return ""
def main(argv=None): def main(argv=None):
# 控制台输出固定 UTF-8:Windows 代码页只影响显示,不会让脚本崩溃 # 控制台输出固定 UTF-8:Windows 代码页只影响显示,不会让脚本崩溃
for stream in (sys.stdout, sys.stderr): for stream in (sys.stdout, sys.stderr):
@@ -106,6 +137,13 @@ def main(argv=None):
ap.add_argument("--require-suite-summary", default="", ap.add_argument("--require-suite-summary", default="",
help="suite names that MUST appear as '=== <name>: N/M PASSED (failed=0) ===' " help="suite names that MUST appear as '=== <name>: N/M PASSED (failed=0) ===' "
"with N>0, ';' separated (T-2). Empty = off") "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("--out-dir", default=str(Path(tempfile.gettempdir()) / "opencode"))
ap.add_argument("--show-tail-lines", type=int, default=15) ap.add_argument("--show-tail-lines", type=int, default=15)
args = ap.parse_args(argv) args = ap.parse_args(argv)
@@ -211,10 +249,17 @@ def main(argv=None):
boot_txt.write_bytes(("\r\n".join(rx) + "\r\n").encode("utf-8")) boot_txt.write_bytes(("\r\n".join(rx) + "\r\n").encode("utf-8"))
print("Boot log: {} (lines={} gen={})".format(boot_txt, len(rx), data.get("gen"))) print("Boot log: {} (lines={} gen={})".format(boot_txt, len(rx), data.get("gen")))
# 6. 标记校验 # 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 from verify_log import verify
rc = verify(str(boot_txt), args.required, args.forbidden, args.show_tail_lines, rc = verify(str(boot_txt), args.required, args.forbidden, args.show_tail_lines,
suite_summary=args.require_suite_summary) suite_summary=args.require_suite_summary,
selftest_pass=args.require_selftest_pass,
selftest_log=selftest_local)
print("RUN_TEST_RC={}".format(rc)) print("RUN_TEST_RC={}".format(rc))
return rc return rc
+43 -3
View File
@@ -9,6 +9,7 @@
[--required 'marker1;marker2'] [--required 'marker1;marker2']
[--forbidden 'FAIL;ERROR;HardFault;assert;[ERR]'] [--forbidden 'FAIL;ERROR;HardFault;assert;[ERR]']
[--require-suite-summary 'Storage Tests;GD5F2GQ5UE Tests'] [--require-suite-summary 'Storage Tests;GD5F2GQ5UE Tests']
[--require-selftest-pass] [--selftest-log <selftest.log 本地副本>]
[--show-tail-lines 40] [--show-tail-lines 40]
退出码(与旧版一致): 退出码(与旧版一致):
@@ -36,6 +37,11 @@
命中即判 PASS,**套件是否跑到汇总行、断言数是否为 0 完全不可见**(套件中途 命中即判 PASS,**套件是否跑到汇总行、断言数是否为 0 完全不可见**(套件中途
挂死/复位、或 Phase 宏全关时会被误判为通过)。`--require-suite-summary` 挂死/复位、或 Phase 宏全关时会被误判为通过)。`--require-suite-summary`
要求日志中出现 `=== <名称>: N/M PASSED (failed=0) ===` 且 **N > 0**。 要求日志中出现 `=== <名称>: N/M PASSED (failed=0) ===` 且 **N > 0**。
- 自检记录联合判定(S7,2026-10 实机窗口):`--require-selftest-pass` 对 /log/selftest.log
的本地副本(定长 64B 记录,格式见 App/log/sys_log.h)判定。自检仅故障写盘(v1 不记
通过项):**文件缺失 / 0 字节 = 无故障 → PASS**;存在任何一条记录 = 有故障 → FAIL
(退出码 1),并逐条打印时间戳 + 描述。抓取失败(设备不可达 / FTP 错误等)由调用方
(run_boot_test.py)先打 WARN,本地文件按缺失处理 → 按上述规则不判整体 FAIL。
""" """
import argparse import argparse
@@ -72,11 +78,15 @@ def _read_lines(path):
def verify(log_path, required=REQUIRED_DEFAULT, forbidden=FORBIDDEN_DEFAULT, def verify(log_path, required=REQUIRED_DEFAULT, forbidden=FORBIDDEN_DEFAULT,
show_tail=40, out=print, suite_summary=""): show_tail=40, out=print, suite_summary="",
selftest_pass=False, selftest_log=""):
"""标记校验核心(可被 run_boot_test.py 复用)。返回退出码 0/1/2。 """标记校验核心(可被 run_boot_test.py 复用)。返回退出码 0/1/2。
suite_summary: ';' 分隔的套件名列表。非空时,要求日志中每个套件都出现 suite_summary: ';' 分隔的套件名列表。非空时,要求日志中每个套件都出现
`=== <名称>: N/M PASSED (failed=0) ===` 且 **N > 0**。 `=== <名称>: N/M PASSED (failed=0) ===` 且 **N > 0**。
selftest_pass: True 时对 selftest_log(/log/selftest.log 本地副本)联合判定:
文件缺失 / 0 字节 = 无故障(PASS);存在任何 64B 记录 = FAIL(S7)。
selftest_log: 自检记录文件本地路径(空串 = 按文件缺失处理)。
""" """
p = Path(log_path) p = Path(log_path)
if not p.is_file(): if not p.is_file():
@@ -149,6 +159,29 @@ def verify(log_path, required=REQUIRED_DEFAULT, forbidden=FORBIDDEN_DEFAULT,
if not ok: if not ok:
bad_summary.append(name) bad_summary.append(name)
# 自检记录联合判定(S7):/log/selftest.log 定长 64B 记录(格式见 App/log/sys_log.h),
# 仅故障写盘:文件缺失 / 0 字节 = 无故障 → PASS;存在任何一条记录 → FAIL
selftest_fail = False
if selftest_pass:
sp = Path(selftest_log) if selftest_log else None
if sp is None or not sp.is_file() or sp.stat().st_size == 0:
out("[OK] selftest: no fault record (file missing or empty)")
else:
raw = sp.read_bytes()
n = len(raw) // 64
if len(raw) % 64:
out("[WARN] selftest log has {} trailing incomplete bytes (ignored)".format(len(raw) % 64))
if n > 0:
selftest_fail = True
out("[FAIL] selftest: {} fault record(s) in {}".format(n, selftest_log))
for i in range(n):
rec = raw[i * 64:(i + 1) * 64]
ts = rec[0:19].decode("ascii", errors="replace")
desc = rec[31:59].split(b"\0", 1)[0].decode("utf-8", errors="replace")
out(" #{}: {} {}".format(i, ts, desc))
else:
out("[OK] selftest: no fault record (0 complete records)")
if show_tail > 0 and lines: if show_tail > 0 and lines:
out("") out("")
out("=== Last {} line(s) of log ===".format(show_tail)) out("=== Last {} line(s) of log ===".format(show_tail))
@@ -156,7 +189,7 @@ def verify(log_path, required=REQUIRED_DEFAULT, forbidden=FORBIDDEN_DEFAULT,
out(ln) out(ln)
out("") out("")
if missing or hits or bad_summary: if missing or hits or bad_summary or selftest_fail:
out("VERDICT: FAIL") out("VERDICT: FAIL")
return 1 return 1
out("VERDICT: PASS") out("VERDICT: PASS")
@@ -182,10 +215,17 @@ def main(argv=None):
"with N>0, ';' separated (T-2). Empty = off") "with N>0, ';' separated (T-2). Empty = off")
ap.add_argument("--show-tail-lines", type=int, default=40, ap.add_argument("--show-tail-lines", type=int, default=40,
help="show last N log lines (0 = off)") help="show last N log lines (0 = off)")
ap.add_argument("--require-selftest-pass", action="store_true",
help="judge /log/selftest.log local copy (S7): "
"missing/empty = PASS, any 64B record = FAIL")
ap.add_argument("--selftest-log", default="",
help="local copy path of /log/selftest.log (with --require-selftest-pass)")
args = ap.parse_args(argv) args = ap.parse_args(argv)
return verify(args.log_path, args.required, args.forbidden, return verify(args.log_path, args.required, args.forbidden,
args.show_tail_lines, suite_summary=args.require_suite_summary) args.show_tail_lines, suite_summary=args.require_suite_summary,
selftest_pass=args.require_selftest_pass,
selftest_log=args.selftest_log)
if __name__ == "__main__": if __name__ == "__main__":