From af5bad5609379fa71c36ffd325d4a06b939bab86 Mon Sep 17 00:00:00 2001 From: Wanderingss <1624155937@qq.com> Date: Thu, 22 Oct 2026 22:19:51 +0800 Subject: [PATCH] =?UTF-8?q?fix(=E6=B5=8B=E8=AF=95=E5=B7=A5=E5=85=B7):=20?= =?UTF-8?q?=E8=87=AA=E6=A3=80=E5=8F=B0=E8=B4=A6=E5=88=A4=E6=8D=AE=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=E5=8F=AA=E7=9C=8B=E6=9C=AC=E6=AC=A1=E5=90=AF=E5=8A=A8?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=EF=BC=88=E4=BF=AE=E5=81=87=E7=BB=BF=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 接上一笔(FTP 凭据)之后,`@test --require-selftest-pass` 仍不可用——它其实是**三个 bug 叠 在一起**,这次一并收口。 **① 抓取失败被当成"设备干净"**:`_fetch_selftest_log()` 把"设备侧无此文件"(正常路径, "仅故障才写"语义下干净设备本就没有该文件)和"连不上/登录被拒/FTP 故障"(结果**未知**) 都返回 `""`,调用方一律按"无记录 → PASS"处理。叠加上一笔修掉的匿名登录 530,这条门禁 **从写下那天起一次都没真正读到过那个文件**。现在 fetch 返回 `(路径, 状态)`,状态 ∈ {ok, absent, error},三者分开判。 **② 判据本身判不了"一次启动"**:`/log/selftest.log` 是 **512 条环形累计历史**(设计文档 §4.2 容量表),而旧判据是"文件非空即 FAIL"——在任何做过故障注入或长跑的设备上**永远不 可能 PASS**。本次实机抓到的 29 条记录,时间戳全是 2026-10-10 ~ 10-14,没有一条来自本次。 新判据 `--selftest-scope this-run`(默认):**烧录之前**先取基线,启动后再取一次,只把 "基线最后一条之后"的记录交给判定(差集落成临时副本,命名落在 `selftest_log_*.log` 里, 才会被 `--keep-out` 的保留规则收走)。 **③ 取基线本身会污染本次启动日志**(我这次实测踩到的):抓基线是一次真实 FTP 会话—— 设备把 `USER/PASS/RETR` 打到串口上,客户端退出时还多打一行 `[ERR] [FTP] error reading next command`。放在 `open/clear`(采集武装)之后,这些行就落进本次启动日志:Forbidden `[ERR]` 命中 → 本次 FAIL,开机耗时从 612 ms 被拉到 **7997 ms**。所以基线固定在 open 之前, 并把这条写进文档当规避口径。 **判定表**(`docs/测试说明/自动化测试流程.md` §自检结果判定整段重写):无此文件=PASS; 本次无新增=PASS;本次有新增=FAIL;连不上/登录被拒/FTP 故障=**FAIL**;既无 `device_ip` 又无 `--selftest-log`=**FAIL**(要求判定却没有数据源,不能再"视为无记录");基线最后一条 被 512 条环覆盖(更新量 ≥ 环容)= **FAIL**(不拿"看不到"当"没有")。后四种"判不了"由调用 方在 `verify()` 之后强制收口成 RC=1——否则标记全中就把"没判"读成"判过了、没问题"。 `--selftest-scope all` 保留旧的累计口径(审计 / 第一次建基线用);给了 `--selftest-log` (本地副本)就没有基线可比 → 自动降级为 all 并打印一行说明;FTP 凭据 `--ftp-user`/ `--ftp-pass`(config `ftp_user`/`ftp_pass`,默认出厂 admin/admin)。退出码 1 的含义、 `verify_log.py` 的语义条目、SKILL.md 参数表(+2 行)、config.example.json(+3 键)同步。 **真机三跑,三条路径各自验过**: · 默认 this-run + `--device-ip` → **PASS / RC=0**:`基线 29 条 → 台账不变`、boot banner ×1、 `net init: OK` **612 ms**(证明污染已消除)、Forbidden 零命中。 · `--selftest-scope all` → **FAIL / RC=1**:`29 fault record(s)`(累计口径仍可用)。 · 不给 `--device-ip` → **FAIL / RC=1**:`verify_log` 那侧显示 `no fault record → PASS`, 调用方收口为 `RUN_TEST_VERDICT: FAIL —— 自检台账判定未能执行`(正是要的行为)。 门禁:`py_compile`(run_boot_test + verify_log)通过;`check_doc_links.py --all --sections-strict` 146 文档 0 问题;`check_ids.py --strict` 增量拦截通过(可判定 82.82%, 在地板之上);6 个文件字节卫生 BOM 无 / CR 0 / U+FFFD 0 / 无行尾空格。 **仍待办(本笔未做)**:设备侧"FTP 客户端中途退出"按 ERROR 级打日志(要不要降级);lftpd 的 LIST 传完回 550 而非 226;设备 `/` 残留约 24 个旧探针文件;`MDK-ARM/` 下 4 个一次性 探针脚本仍写匿名。未推送。 --- .dsh/skills/serial-boot-test/SKILL.md | 4 +- .../serial-boot-test/config.example.json | 3 + .../serial-boot-test/scripts/run_boot_test.py | 164 +++++++++++++++--- .../serial-boot-test/scripts/verify_log.py | 14 +- docs/测试说明/自动化测试流程.md | 30 +++- 5 files changed, 185 insertions(+), 30 deletions(-) diff --git a/.dsh/skills/serial-boot-test/SKILL.md b/.dsh/skills/serial-boot-test/SKILL.md index 82de7a27..7913bf35 100644 --- a/.dsh/skills/serial-boot-test/SKILL.md +++ b/.dsh/skills/serial-boot-test/SKILL.md @@ -47,7 +47,9 @@ whenToUse: 需要向目标板烧录或复位后,用串口日志自动判定运 | `done_marker` + `max_boot_ms` | 开机耗时口径与上限 | 空/0 = 只报告不判 | | `settle_ms` / `max_wait_ms` / `first_wait_ms` | RX 静默判据 / 总超时 / 首字节超时 | 8000 / 30000 / 12000 | | `require_order` / `expect_reset_cause` / `require_suite_summary` | 顺序校验 / 复位原因白名单 / 套件汇总行 | 空 = 关闭 | -| `require_selftest_pass` + `device_ip` | 设备侧自检台账联合判定(FTP 抓取) | 关闭 | +| `selftest_scope` | 自检台账判定口径:`this-run`(烧录前取基线,只判新增)/ `all`(累计,任一记录即 FAIL) | `this-run` | +| `ftp_user` / `ftp_pass` | 抓 `/log/selftest.log` 用的 FTP 账号(设备无匿名通道) | `admin` / `admin` | +| `require_selftest_pass` + `device_ip` | 设备侧自检台账联合判定(FTP 抓取;**默认只判本次启动新增**,抓取失败/无数据源一律 FAIL) | 关闭 | **先跑项目的构建**(本仓库 = `keil-build` skill,其它 Keil 项目照该 skill 套; 非 Keil 项目用项目自己的构建入口),确认 0 错误 0 警告后再进闭环。 diff --git a/.dsh/skills/serial-boot-test/config.example.json b/.dsh/skills/serial-boot-test/config.example.json index 4f2e7cdb..47a5b89b 100644 --- a/.dsh/skills/serial-boot-test/config.example.json +++ b/.dsh/skills/serial-boot-test/config.example.json @@ -24,6 +24,9 @@ "require_selftest_pass": false, "device_ip": "", + "selftest_scope": "this-run", + "ftp_user": "admin", + "ftp_pass": "admin", "httpPort": 18765 } diff --git a/.dsh/skills/serial-boot-test/scripts/run_boot_test.py b/.dsh/skills/serial-boot-test/scripts/run_boot_test.py index bd85c0d6..7f269cc2 100644 --- a/.dsh/skills/serial-boot-test/scripts/run_boot_test.py +++ b/.dsh/skills/serial-boot-test/scripts/run_boot_test.py @@ -19,9 +19,12 @@ 5. verify_log.py 标记校验(含启动锚点计数 config "boot_marker" + --max-boot-count; --expect-reset-cause 原因白名单;--require-order 标记顺序; --done-marker + --max-boot-ms 开机耗时) - 6. (仅当 --require-selftest-pass)自检记录联合判定(S7): - --selftest-log 提供本地副本则直接用它,否则从设备 FTP 抓取 /log/selftest.log - (需 config "device_ip");判定:文件缺失 / 0 字节 = 无故障 → PASS + 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 常驻全量日志兜底, 无采集窗口/首字节等待仪式。 @@ -46,7 +49,7 @@ [--flash-cmd "<烧录命令>"] [--flash-timeout 180] [--required "m1;m2"] [--forbidden "FAIL;ERROR;HardFault"] [--require-suite-summary "Storage Tests"] - [--require-selftest-pass] [--selftest-log <本地副本>] + [--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] @@ -58,7 +61,7 @@ 退出码(与旧版一致): 0 = PASS(采集成功 + Required 全中 + Forbidden 零命中) - 1 = FAIL(标记缺失/命中) + 1 = FAIL(标记缺失/命中;或要求自检台账判定却判不了——无数据源/抓取失败/无法证明无新增) 2 = 采集或烧录错误(含烧录回读不一致 = 芯片内容与刚编的镜像不同,本次运行作废) 3 = 用法/预检错误 """ @@ -240,11 +243,53 @@ def _prune_out_dir(out_dir, keep): 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 需鉴权,根目录 = 卷根)。 - 成功返回本地路径字符串;失败(网络不可达 / FTP 错误 / 设备侧文件缺失)返回 "" - (按 S7 语义视为无故障记录;此处打 WARN,调用方不判整体 FAIL)。 + 返回 (本地路径, 状态),状态 ∈ {"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 —— 台账"仅故障才写",干净设备上 @@ -260,24 +305,24 @@ def _fetch_selftest_log(device_ip, out_dir, stamp, ftp_user="admin", ftp_pass="a # 设备没有匿名通道——匿名登录一律 530,所以这里必须带凭据。 ftp.login(ftp_user, ftp_pass) except (OSError, ftplib.Error) as e: - print("RUN_TEST_WARN: selftest fetch failed: {} " - "(按 S7 语义视为无记录;设备网络/FTP 需另行排查)".format(e)) - return "" + 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: {}) 按 S7 视为无故障记录" + print("SELFTEST_FETCH: {} -> (ledger absent: {}) 台账干净" .format(device_ip, str(e).strip())) _ftp_quit(ftp) - return "" + return "", "absent" _ftp_quit(ftp) local.write_bytes(bytes(buf)) print("SELFTEST_FETCH: {} -> {} ({} bytes)".format(device_ip, local, len(buf))) - return str(local) + return str(local), "ok" def main(argv=None): @@ -316,6 +361,11 @@ def main(argv=None): "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, @@ -390,6 +440,11 @@ def main(argv=None): 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 "") @@ -454,6 +509,34 @@ def main(argv=None): 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: @@ -598,14 +681,50 @@ def main(argv=None): print("RUN_TEST_ERROR: flash readback mismatch (chip content != built image)") return 2 - # 5.5 自检记录抓取(S7):启用且未提供本地副本时才走 FTP(需 config "device_ip") + # 5.5 自检记录判定(S7):默认只判「本次启动新增」,累计口径要显式指定。 + # 三种抓取结果必须分开对待(旧实现把后两种都当"无记录 → PASS",就是假绿): + # absent(设备侧无此文件) → 台账干净 → 无新增 → PASS + # error(连不上/登录被拒/故障)→ 未知 → FAIL(要求判定却没数据,不能当通过) + # 没有数据源(无 device_ip 又无 --selftest-log)→ FAIL(同上) selftest_local = args.selftest_log - if args.require_selftest_pass and not selftest_local and not args.device_ip: - print("RUN_TEST_WARN: require_selftest_pass but no device_ip/selftest_log " - "-> treated as no record (S7 PASS path)") - if args.require_selftest_pass and not selftest_local and args.device_ip: - selftest_local = _fetch_selftest_log(args.device_ip, out_dir, stamp, - args.ftp_user, args.ftp_pass) + 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:')未配置 = @@ -625,6 +744,11 @@ def main(argv=None): 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 diff --git a/.dsh/skills/serial-boot-test/scripts/verify_log.py b/.dsh/skills/serial-boot-test/scripts/verify_log.py index 39f35543..6f613a2a 100644 --- a/.dsh/skills/serial-boot-test/scripts/verify_log.py +++ b/.dsh/skills/serial-boot-test/scripts/verify_log.py @@ -41,11 +41,15 @@ 命中即判 PASS,**套件是否跑到汇总行、断言数是否为 0 完全不可见**(套件中途 挂死/复位、或 Phase 宏全关时会被误判为通过)。`--require-suite-summary` 要求日志中出现 `=== <名称>: 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。 + - 自检记录联合判定(S7,2026-10 实机窗口;判据 2026-10-22 改为"只看本次新增"): + `--require-selftest-pass` 对 /log/selftest.log 的本地副本(定长 64B 记录,格式见 + App/log/sys_log.h)判定。自检仅故障写盘(v1 不记通过项):**文件缺失 / 0 字节 = 无故障 + → PASS**;存在记录 = FAIL(退出码 1),逐条打印时间戳 + 描述。 + ⚠ 该文件是 512 条环形**累计历史**,所以本函数收到的"有记录"必须由调用方先做差集: + run_boot_test.py 在烧录前取基线、启动后只把"基线之后新增"的记录写成临时副本交给本 + 函数(`--selftest-scope this-run`,默认)。**抓取失败/无数据源/无法证明无新增一律由 + 调用方判 FAIL**——旧实现把这些都当"无记录 → PASS",是假绿(详见 + docs/测试说明/自动化测试流程.md 的自检结果判定节)。 - 启动锚点计数(2026-10-10):`--max-boot-count N`(run_boot_test.py 默认传 1)统计 `reset cause:` 锚点出现次数——**0 次** = 采集没从复位开始(标记全中也可能只是上一轮 的残留日志);**>N 次** = 板子在采集窗口内自己复位过(IWDG 咬狗 / Fault 后 diff --git a/docs/测试说明/自动化测试流程.md b/docs/测试说明/自动化测试流程.md index 4e3d1e71..fb6b2e01 100644 --- a/docs/测试说明/自动化测试流程.md +++ b/docs/测试说明/自动化测试流程.md @@ -51,10 +51,31 @@ ### 自检结果判定(S7,`--require-selftest-pass`) `run_boot_test.py` / `verify_log.py` 支持 `--require-selftest-pass`:除串口标记外,还判定设备侧自检结论 -(v1 只记故障、不记通过)——`selftest.log` **缺失 / 0 字节 → 无故障 → PASS**;任一 64B 定长记录 -(`filesize/64 > 0`)→ FAIL,并打印故障条目(时间戳 + 描述)便于定位。`run_boot_test.py` 未给本地副本时 -**自动经 FTP 抓取** `/log/selftest.log`(设备侧 450"文件不存在"按正常路径处理——"仅故障才写"语义下 -干净设备本就没有该文件)。判定对象(20 处自检描述串)见 `实机测试实施指南.md` §3.2。 +(v1 只记故障、不记通过)。判定对象(20 处自检描述串)见 `实机测试实施指南.md` §3.2。 + +**判据默认只看「本次启动新增」**(`--selftest-scope this-run`,2026-10-22 改)。原因: +`/log/selftest.log` 是 **512 条环形累计历史**,而旧判据是"文件非空即 FAIL"——用它判一次启动, +在任何做过故障注入或长跑的设备上**永远不可能 PASS**(本仓台架实测:抓到的 29 条记录时间戳 +全是 2026-10-10 ~ 10-14 留下的,没有一条来自本次)。新流程 = 烧录**前**先经 FTP 取一次基线、 +启动后再取一次,只把"基线最后一条之后"的记录交给判定: + +| 情形 | 含义 | 判定 | +|---|---|---| +| 设备侧无此文件(450/550) | 台账干净("仅故障才写"语义下本就可能不存在) | 无新增 → **PASS** | +| 抓到文件、本次无新增 | 本次启动没产生故障 | **PASS** | +| 抓到文件、本次有新增 | 本次启动有故障 | **FAIL**,只打印新增条目(落 `selftest_log_<时刻>_new.log`) | +| 连不上 / 登录被拒 / FTP 故障 | 结果**未知** | **FAIL**——旧行为是 WARN 后当"无记录"放过,那就是假绿 | +| 既没有 `device_ip` 也没有 `--selftest-log` | 要求判定却没有数据源 | **FAIL**(同上,不再 WARN 放行) | +| 基线最后一条在台账里找不到 | 512 条环已覆盖基线(更新量 ≥ 环容),无法证明 | **FAIL**(不拿"看不到"当"没有") | + +- ⚠ **采集窗口内不要跑 FTP**:设备会把每条 FTP 命令打到串口,客户端退出时还会多打一行 + `[ERR] [FTP] error reading next command` —— 默认 Forbidden 含 `[ERR]`,于是**任何**人边跑 `@test` + 边用 FileZilla 传文件,都会让本次莫名 FAIL(本次改造实测踩到:抓基线原先排在采集武装之后, + 开机耗时被自己的 FTP 会话拉到 ~8 s 并命中 `[ERR]`)。基线抓取因此固定在 open/clear **之前**。 +- `--selftest-scope all` = 保留旧的累计口径(任一记录即 FAIL),用于**审计**或第一次建基线。 +- 给了 `--selftest-log`(本地副本)就没有基线可比 → 自动降级为累计口径并打印一行说明。 +- 抓基线同样要 FTP 凭据:`--ftp-user` / `--ftp-pass`(config `ftp_user` / `ftp_pass`,默认出厂 + `admin`/`admin`);设备**没有匿名通道** → `FTP_Test_Guide.md` §1。 ### 默认开机标记(来自固件) @@ -125,3 +146,4 @@ | 2026-10-10 | **第二批:闭环补上"烧进去的是哪份镜像"与"跑的过程中有没有偷偷复位"**:① 固件身份戳——`App/app/app_main.c` 开机打印 `fw build: <__DATE__> <__TIME__>`,`run_boot_test.py` 从待烧 hex 里抠出该串追加成 Required 标记(旧镜像/错镜像此前所有固定标记照样全中);② 烧录回读校验进闭环——`reset.bat` 新增 `cmp` 子命令(`-CmpFile` 全片比对),采集后自动跑,不一致 exit 2;实测 `-CmpFile` 连接时会复位目标,故必须排在采集之后;③ 启动锚点计数——`verify_log.py` 新增 `--max-boot-count`/`--boot-marker`/`--expect-reset-cause`,`reset cause:` 出现 0 次或 >1 次判 FAIL;④ 修 `--flash-cmd` 带参数时预检必 missing、以及 `cmd /c` 对含空格路径(本仓库目录名 `DTU Code`)的引号解析把回读假判成"芯片不一致"(改为直接 CreateProcess 跑 .bat)。真机验证:完整烧录闭环 PASS(含戳匹配 + `No difference found` + 锚点 x1);伪造戳 → 其余标记全中仍 FAIL | | 2026-10-10 | **第三批:判定可读性 + 时序 + 采集卫生**:① Forbidden 命中逐行打印行号+内容(旧只报条数);② boot_log 每行加 `[+Nms]` daemon 毫秒时基(`--raw-log` 关闭),据此量开机耗时(实测 `net init: OK` = **+537ms**;`--max-boot-ms` 默认 0 只报告);③ `--require-order` 标记先后顺序校验(全中但失序此前不可见);④ `--json` 结构化判定(退出码不变);⑤ `--keep-out`(默认 20)清理 out 目录旧产物(本机曾堆到 31+7 份);⑥ daemon 侧:无换行残行 64KB 封顶 + open/close/clear 会话边界丢弃残行 + 截头按行边界对齐(旧实现截出来的首行是半行)+ `open` 不带 `--dtr/--rts` 时保持现状、显式给则现场应用;⑦ `ports` 回 `configured_present`/`hint`,`status` 回 `rx_age_ms`。selftest 23/23、verify_log 离线回归 17/17、真机闭环 PASS | | 2026-10-11 | **身份戳覆盖测试宏**(N-31 复测时发现):切 `test/board/test_config.h` 的套件宏后 `app_main.c` 未被重编,测试镜像与产品镜像顶着同一个 `fw build:` 戳 → 戳校验对"两种构建互换"失明(当时只有 `-CmpFile` 回读发现镜像不同)。修复:`app_main.c` `#include "test_config.h"`(头依赖 → 改宏必重编、戳必刷新)+ 戳串追加 ` suite= dstr=`(`FW_BUILD_VARIANT`,戳内容本身区分构建)。实测:`20:36:11 suite=NONE dstr=off` ↔ `20:36:40 suite=CH395F dstr=on`;`@test` 闭环对新格式 PASS(脚本无需改动,戳按"标记后连续可打印 ASCII"抠取)。失败模式表补一行:戳命中但 `suite=` 与要跑的套件不符 = 烧了产品镜像 | +| 2026-10-22 | **自检台账判据改为「只看本次启动新增」**(修两处叠出来的假绿):① 抓取用的 FTP 登录还写着匿名,而设备自鉴权落地起就没有匿名通道 → 抓取**必然** 530 失败,旧实现又把"抓取失败"和"设备侧无此文件"都返回空、一律按"无记录 → PASS"处理 → `--require-selftest-pass` 从写下那天起没真正看过文件;② 就算抓到了,`/log/selftest.log` 是 512 条环形累计历史,"非空即 FAIL"在任何做过故障注入/长跑的设备上永远不可能 PASS(本仓台架实测 29 条记录全是 2026-10-10 ~ 10-14 留下的)。现在:烧录前取基线、启动后只判新增;抓取失败 / 无数据源 / 基线被环覆盖 **一律 FAIL**;`--selftest-scope all` 保留累计口径用于审计与首次建基线;FTP 凭据 `--ftp-user/--ftp-pass`(默认出厂 admin/admin)。凭据与判据两处都过真机:`this-run` → PASS(无新增)、`all` → FAIL(累计 29 条)、无 `device_ip` → FAIL(无数据源)。 |