f81bb38b64
- 故障录波设计说明 §10 新增「板级远程半轮记录」:真 FAT 落盘、触发 时序三样本、冷却恢复、轮转同号覆盖、PC 侧坏段拒收、104 并发 (参考主站 16/18 绿,两条 COV = 标定闭锁腿物理预期)六项 PASS; drop 400–640 点/窗入账为纯观察项(丢样本不丢采集实测面)。 - 新缺陷「回放页段表枚举恒空(板级)」登记登记簿 §2:五面判别钉死 唯 ui_task_wave_scan() 在 uiTask 拿不到目录条目;伴生纪律缺陷 f_opendir 失败仍钉 s_wave_scanned=1(修复方向同记录)。 - 新增 docs/测试说明/专项验证/故障录波板级到场轮执行手册.md:剩余 人到场判据拆成四幕,逐步写清谁出手/做什么/会看到什么,含到场 检查清单、现场记录表、两个用户决定点、收工还原清单与分诊; 索引挂行 docs/README.md 与 测试说明/README.md(专项 5→6 篇)。 - scripts/board_ui_nav.py 文件头补三条 7003 驱动纪律(ACK 与推流 同 TCP 须 drain 静默、home 首 e 是重开最近页捷径、级联光标记忆)。 - 自我更正入档:起草台架计划时带入的「≤15 s 判据」全仓无属主, 已溯源改指 WAVE_SEAL_TIMEOUT_MS 2 s 封存窗(真锚);「§1.2 两个 待台架核数值」为错引,改指 §4.2 兜底行与 wave_ring.h 冷却时长。 bench 锁已 release、设备未改烧;证据包在 ci-artifacts/wavebench/。 gate: doc-links --all --sections-strict rc=0(218 篇 3103 处 无法判定 0);mermaid 预检新发 0;py_compile 过;七文件 BOM/CR/U+FFFD 全零。
1278 lines
64 KiB
Python
1278 lines
64 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""
|
||
模块名称:板端 UI 有状态导航器(道E 台架判据执行器 v2)
|
||
模块功能:在 RemoDispBus(KEY 注键 + LCDMEM 取帧)上**靠画面反馈**决定下一个键,
|
||
走到指定页面并抓图;不再依赖"预置键序盲走"。
|
||
为什么要它(2026-10-25 大窗实测):
|
||
盲走(mega_ui.py 里那套 'dee' / 'dedde' 固定串)在板端不稳——级联列没收回时
|
||
同一串键落在不同的列上,落点整体错位;KEY 的 ACK 帧是空应答、不携带状态,
|
||
所以"发了 5 个键"不等于"到了第 5 层"。实测里深页盲走退化成"整屏只剩约 512 bit
|
||
有墨"的形态,却没有任何一帧告诉我们走错了。
|
||
结论:**导航必须闭环**——每步 grab 一帧、认出"当前在哪一屏/哪一列/哪一行",
|
||
再决定下一个键。模拟器侧已有的精确字形匹配器(同一份字体、同样的 3200 B I1
|
||
布局)可以直接用在板端帧上,本文件 = 那套匹配器 + 闭环状态机。
|
||
两种设备后端(同一套导航逻辑):
|
||
SimDevice = 无头模拟器(lvgl_sim.exe --keys <串> <out>),**不需要台架**,
|
||
--selftest 全部在它上面跑;
|
||
BoardDevice = RemoDispBus TCP 7003(真机),只在 --board 显式给出时才连,
|
||
且必须持 bench 锁(本文件自己不碰串口与烧录)。
|
||
用法: python scripts/board_ui_nav.py --selftest-quick # 认屏快检(提交钩子跑这档)
|
||
python scripts/board_ui_nav.py --selftest # 加闭环导航(需 lvgl_sim.exe)
|
||
python scripts/board_ui_nav.py --walk # 台架窗口:走全部绑定页并存帧
|
||
python scripts/board_ui_nav.py --board --to yx --grab /tmp/yx
|
||
可选:--trace 路径(步迹落盘) --dump-fail 目录(把认错的那一屏存下来)
|
||
可选:--backoff-keys esl(UNKNOWN 屏的回退键序;默认 esl 含 e——本机待机态
|
||
按 s/l 无效、要按 e 才进菜单,2026-10-26 task-24 台架实测)
|
||
--board / --walk 属真机动作:必须另加 --i-hold-bench-lock 显式确认已持 bench 锁,
|
||
否则直接拒绝(退出码 3,不碰网络)——台架是独占资源,脚本不替人判断有没有在用。
|
||
--walk 的存证目录默认在 ci-artifacts/uinav/walk(版本控制之外),--outdir 可改;
|
||
落在版本控制内会被直接拒绝——台架证据不进工作树,工作树才能保持干净可比对。
|
||
2026-10-31 板级远程半轮补三条(本文件当这三条的纪律属主;事实现场见
|
||
docs/子系统设计/故障录波设计说明.md §10「板级远程半轮记录」):
|
||
① KEY 的 ACK 与 LCDMEM 推流共用同一条 TCP:按键后排空要排到**帧流静默
|
||
≥900 ms** 再 grab,否则拿到的是 N−1 屏——半轮里多次差点据此误判「页面不动」;
|
||
② 本次上电开过页之后,home 上的 `e` 是**重开最近页**的捷径、不是进菜单;
|
||
backoff 序 'esl' 的 `e` 因此可能把刚关的坏页又打开——闭环必须先认屏:
|
||
落点是内容页就 `s` 关回 home、再按一次 `e` 才是菜单,盲连按走不动;
|
||
③ 级联列光标**开列时记忆上次停留行**、不归零——"进列后按 k 次下"的算式
|
||
只在光标已知位于列首时成立;稳妥做法 = 先上溢钳顶(`u` × 列高)再数 k。
|
||
退出码: 0 = 成功;1 = 分类或导航失败;2 = 环境错误(exe 缺失 / 板子连不上)
|
||
创建日期:2026-10-25(道E,Lead 大窗反馈后的判据脚本升级)
|
||
"""
|
||
|
||
import importlib.util
|
||
import json
|
||
import os
|
||
import socket
|
||
import subprocess
|
||
import sys
|
||
import time
|
||
|
||
try:
|
||
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
||
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
|
||
except Exception:
|
||
pass
|
||
|
||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||
# 仓库根:本文件入库后位于 <repo>/scripts/board_ui_nav.py(上跳一级即根)。
|
||
# 允许 DTU_REPO_ROOT 覆盖,便于从别的目录或工作树里跑同一份脚本取证。
|
||
REPO = os.environ.get("DTU_REPO_ROOT") or os.path.abspath(os.path.join(HERE, os.pardir))
|
||
HARNESS = os.path.join(REPO, "simulator", "tests", "check_content_pages.py")
|
||
SIM_EXE = os.path.join(REPO, "simulator", "build", "Debug", "lvgl_sim.exe")
|
||
MENU_TREE = os.path.join(REPO, "ui_menu_tree.txt")
|
||
# 真机留档帧目录:不在版本控制里(各次台架各自的产物),缺就跳过那一段并说明
|
||
WALK_DIR = os.environ.get("DSH_UINAV_WALK") or os.path.join(REPO, "ci-artifacts", "uiwalk")
|
||
# 轨迹/失败帧**默认不写进仓库**:只有 --trace / --dump-fail 显式给了路径才落盘
|
||
# (判据脚本自己在提交门禁里跑,顺手往 scripts/ 写 json 就是把工作树弄脏)
|
||
TRACE = None
|
||
FAIL_DIR = None
|
||
# UNKNOWN 屏回退键序(task-24,2026-10-26 台架实证):to_root 认不出屏时按此序**循环**消费,
|
||
# 绝不连按同一键。默认含 e——本机待机态按 s/l 无效、要按 e 才进菜单;旧逻辑 UNKNOWN 分支
|
||
# 的 l 条件永不可达(classify 只在 MENU 态填 cols,UNKNOWN 恒 cols=None),等于静默连按 s:
|
||
# 冷启动恰好落在待机态 = 闭环导航直接开不了工。--backoff-keys 可覆盖(如旧行为 "sl")。
|
||
BACKOFF_KEYS = "esl"
|
||
# ⚠ 但 e 在**内容页**上不是中性的:装置维护的 强制复归 / 清除记录 都是两段式确认——第一次
|
||
# ENTER 只把该行标成「确认?」,**第二次 ENTER 就真的执行**(复归全部保护动作位并写 SOE /
|
||
# 清 SOE·运行·操作记录)。而这两页恰恰最容易被判成 UNKNOWN(新字库缺字、白框、状态行变化
|
||
# ——正是我们想在 UI 窗口里抓的东西)。bench-runner 2026-10-26 报出这条叠险路径后加两条硬闸:
|
||
# ① 每次 to_root() 里 ENTER **最多按 BACKOFF_MAX_E 次**,还需要第二次就当场报错停机,
|
||
# 而不是把第二个 ENTER 投出去;
|
||
# ② 执行类页面默认**不进**(EXEC_PAGE_IDS);要看显示正确性去模拟器黄金参考,那里没有
|
||
# 真机副作用。通用口径:**导航类自动化脚本默认零 ENTER**,ENTER 只允许出现在明确
|
||
# 指定的、非执行类的用例步骤里。
|
||
BACKOFF_MAX_E = 1
|
||
# 执行类页面:进入即可能产生不可逆动作,导航器默认拒绝下钻(--allow-exec-pages 显式放行)。
|
||
EXEC_PAGE_IDS = frozenset(("clear", "reset"))
|
||
# 默认 False:执行类页面拒绝下钻。显式 --allow-exec-pages 才放行(放行 = 接受后果)。
|
||
ALLOW_EXEC_PAGES = False
|
||
|
||
W = H = 160
|
||
STRIDE = W // 8
|
||
FRAME_BYTES = STRIDE * H
|
||
|
||
# 键码(与 mega_ui.py 及板端 KEY 通道一致)
|
||
KEYS = {"u": 0x02, "d": 0x40, "l": 0x10, "r": 0x08, "e": 0x20, "s": 0x01}
|
||
|
||
# 菜单根框几何(与 check_menu_rows.py 同源:view_init 的 x=0,y=44,w=66,h=107)
|
||
ROOT_X0, ROOT_Y0, ROOT_W, ROOT_H = 0, 44, 66, 107
|
||
ROW_H = 15
|
||
BAND_SEL_MIN = 0.50 # 白底选带占比下限(同 check_menu_rows 口径)
|
||
COL_W = 66 # 一级子列宽(板端与模拟器同源)
|
||
BLANK_INK = 900 # "几乎空白"屏的墨量上限(待机/密码页形态,实测 512)
|
||
MAX_STEPS = 60 # 单次导航按键上限,到顶即失败而不是继续猜
|
||
|
||
# 直连真机的显式确认位:命令行里没有它,--board / --walk 一律不碰网络。
|
||
# 为什么需要(2026-10-25 实测踩过):--walk 在没有任何拦截的情况下就连上板子注入了按键
|
||
# ——跑的人只想“看一下默认存证目录打印成什么”,可台架是独占资源,别人可能正停在验收态。
|
||
# 文档里那句“必须持 bench 锁”防不住“我只是跑一下”,所以把确认做成命令行参数:要连真机,
|
||
# 就得在命令行里留下“我持锁”的字面证据。脚本不代取锁,也不接受“应该没人在用”的判断。
|
||
BENCH_CONFIRM = "--i-hold-bench-lock"
|
||
|
||
|
||
def load_harness():
|
||
"""载入库内黄金回归脚本,复用它的字体解析与字形渲染(不复制第二套规则)。"""
|
||
spec = importlib.util.spec_from_file_location("cc_harness", HARNESS)
|
||
m = importlib.util.module_from_spec(spec)
|
||
old = sys.argv
|
||
sys.argv = ["board_nav"]
|
||
try:
|
||
spec.loader.exec_module(m)
|
||
finally:
|
||
sys.argv = old
|
||
return m
|
||
|
||
|
||
CC = load_harness()
|
||
|
||
|
||
class Glyph(object):
|
||
"""一个字符串的精确字形占位描述(bbox + 每相对行的期望位模式)。"""
|
||
|
||
def __init__(self, s):
|
||
self.s = s
|
||
ink = CC._render(s)
|
||
if not ink:
|
||
self.empty = True
|
||
return
|
||
self.empty = False
|
||
xs = [p[0] for p in ink]
|
||
ys = [p[1] for p in ink]
|
||
self.bx1, self.bx2 = min(xs), max(xs)
|
||
self.by1, self.by2 = min(ys), max(ys)
|
||
self.wb = self.bx2 - self.bx1 + 1
|
||
rows = []
|
||
for iy in range(self.by1, self.by2 + 1):
|
||
mask = 0
|
||
for (x, y) in ink:
|
||
if y == iy:
|
||
mask |= 1 << (self.bx2 - x)
|
||
rows.append((iy, mask))
|
||
self.rows = rows
|
||
self.anchor = max(rows, key=lambda t: bin(t[1]).count("1"))
|
||
|
||
|
||
class Frame(object):
|
||
"""一帧 3200 B 的 I1 位图;每行存成一个 160 bit 整数(bit 159-x = 像素 x)。"""
|
||
|
||
def __init__(self, data, name=""):
|
||
if len(data) != FRAME_BYTES:
|
||
raise ValueError("帧长度 %d != %d(%s)" % (len(data), FRAME_BYTES, name))
|
||
self.name = name
|
||
self.data = data
|
||
self.rows = [int.from_bytes(data[y * STRIDE:(y + 1) * STRIDE], "big")
|
||
for y in range(H)]
|
||
self.ink = sum(bin(r).count("1") for r in self.rows)
|
||
|
||
@classmethod
|
||
def file(cls, path):
|
||
with open(path, "rb") as f:
|
||
return cls(f.read(), os.path.basename(path))
|
||
|
||
def span(self, y, ox, wb):
|
||
"""第 y 行从像素 ox 起、宽 wb 的一段(低位对齐成 wb 位整数)。"""
|
||
if y < 0 or y >= H or ox < 0 or ox + wb > W:
|
||
return None
|
||
shift = W - (ox + wb)
|
||
return (self.rows[y] >> shift) & ((1 << wb) - 1)
|
||
|
||
def match(self, g, band, x0=0, x1=W, invert=False, slack=6):
|
||
"""精确字形匹配,判定强度与 _match_text 相同(有墨处必须 1、bbox 内其余必须 0)。
|
||
|
||
快法:先用**锚行**一次整数比较筛出候选 ox,再逐行验全 bbox。原实现逐像素
|
||
双循环,闭环导航每步要判十几次、一屏几十秒等不起。
|
||
|
||
invert=True 认的是**反白行**(选中行是黑字白底:字形处必须 0、bbox 其余必须 1)。
|
||
这不是可选优化——菜单的选中行天然就是反白的,不认反白就永远读不出"当前选中的
|
||
是哪一项",闭环导航第一步就瞎了。
|
||
"""
|
||
if g.empty:
|
||
return []
|
||
aiy, amask0 = g.anchor
|
||
allw = (1 << g.wb) - 1
|
||
amask = amask0 ^ allw if invert else amask0
|
||
lo = max(0, x0 - g.bx1)
|
||
hi = min(W - g.bx2, x1 - g.bx1)
|
||
hits = []
|
||
for oy in range(band[0] - slack, band[1]):
|
||
ay = oy + aiy
|
||
for ox in range(lo, hi + 1):
|
||
if self.span(ay, ox + g.bx1, g.wb) != amask:
|
||
continue
|
||
ok = True
|
||
for (iy, mask0) in g.rows:
|
||
if iy != aiy:
|
||
mask = mask0 ^ allw if invert else mask0
|
||
if self.span(oy + iy, ox + g.bx1, g.wb) != mask:
|
||
ok = False
|
||
break
|
||
if ok:
|
||
hits.append((ox, oy))
|
||
return hits
|
||
|
||
def match_any(self, g, band, x0=0, x1=W, slack=6):
|
||
"""正拷/反白两种极性都试(选中行与未选中行都可能是目标)。"""
|
||
h = self.match(g, band, x0, x1, False, slack)
|
||
return h if h else self.match(g, band, x0, x1, True, slack)
|
||
|
||
def white_bars(self, min_w=26, y0=None, y1=None):
|
||
"""检出"白底横条"(反白选中行)→ [(x0, x1, ya, yb), ...] 按 x0 排序。
|
||
|
||
判据是"连续 >= min_w 列全白",再纵向合并成条(间隙 <=2 行算同一条)。
|
||
min_w 取 26:根列条宽约 64、子列条约 42,而汉字笔画里最宽的连续白只有
|
||
字间空隙(远小于 26),所以不会把普通文字行误判成选带。
|
||
"""
|
||
ya0 = ROOT_Y0 if y0 is None else y0
|
||
ya1 = (ROOT_Y0 + ROOT_H + 8) if y1 is None else y1
|
||
runs = []
|
||
for y in range(max(0, ya0), min(H, ya1)):
|
||
bits = self.rows[y]
|
||
x = 0
|
||
while x < W:
|
||
if (bits >> (W - 1 - x)) & 1:
|
||
x2 = x
|
||
while x2 + 1 < W and ((bits >> (W - 2 - x2)) & 1):
|
||
x2 += 1
|
||
if (x2 - x + 1) >= min_w:
|
||
runs.append((y, x, x2))
|
||
x = x2 + 1
|
||
else:
|
||
x += 1
|
||
out = []
|
||
for (y, a, b) in runs:
|
||
if out and (y - out[-1][3]) <= 2 and abs(a - out[-1][0]) <= 3 and abs(b - out[-1][1]) <= 3:
|
||
out[-1][3] = y
|
||
else:
|
||
out.append([a, b, y, y])
|
||
res = [(r[0], r[1], r[2], r[3]) for r in out]
|
||
res.sort(key=lambda t: (t[0], t[2]))
|
||
return res
|
||
|
||
def band_frac(self, y0, y1, x0, x1):
|
||
tot = (max(0, min(H, y1)) - max(0, y0)) * (x1 - x0)
|
||
if tot <= 0:
|
||
return 0.0
|
||
on = 0
|
||
for y in range(max(0, y0), min(H, y1)):
|
||
s = self.span(y, x0, x1 - x0)
|
||
if s is not None:
|
||
on += bin(s).count("1")
|
||
return float(on) / float(tot)
|
||
|
||
def sel_bands(self, x0, x1, y0, y1):
|
||
"""x 范围内"白底选带"的行段 [(ya, yb), ...](连续行合并,间隙 <=2 行算一段)。"""
|
||
rows = [y for y in range(max(0, y0), min(H, y1))
|
||
if self.band_frac(y, y + 1, x0, x1) >= BAND_SEL_MIN]
|
||
out = []
|
||
for y in rows:
|
||
if out and (y - out[-1][-1]) <= 2:
|
||
out[-1].append(y)
|
||
else:
|
||
out.append([y])
|
||
return [(g[0], g[-1]) for g in out]
|
||
|
||
|
||
def load_menu_tree():
|
||
"""解析 ui_menu_tree.txt → [(level, label, page)]。
|
||
|
||
注释行('#' 开头)与指令行('@' 开头,如 @password)都不是节点,必须跳过——
|
||
否则整块文件头注释会被当成 level 0 的"菜单项",导航器会去找一个不存在的标签。
|
||
"""
|
||
out = []
|
||
with open(MENU_TREE, encoding="utf-8-sig") as f:
|
||
for line in f:
|
||
t = line.strip()
|
||
if not t or t.startswith("#") or t.startswith("@"):
|
||
continue
|
||
lvl = (len(line) - len(line.lstrip(" "))) // 2
|
||
# 是否带密码注记要在剥掉之前记下来(剥掉后就无从得知了)
|
||
need_pwd = t.endswith("#P") or (" #P " in t)
|
||
page = None
|
||
if "#page=" in t:
|
||
t, page = t.split("#page=", 1)[0].strip(), t.split("#page=", 1)[1].strip()
|
||
# ` #P`(该叶要密码)是**注记不是名字**:屏上没有这几个字符,留着它字面
|
||
# 匹配永远打不中。它也可能写在 #page= 之前(网口设置那行就是
|
||
# "网口设置 #P #page=net_cfg"),所以必须放在 #page 拆分之后剥——第一版用
|
||
# endswith 判,对那一行永远不成立,网口设置就一直认不出来。
|
||
if t.endswith("#P"):
|
||
t = t[:-2].strip()
|
||
out.append((lvl, t, page, need_pwd))
|
||
return out
|
||
|
||
|
||
class Classifier(object):
|
||
"""画面 → 状态。页面看标题 + 黄金行文本;菜单看"网格对齐的白底选带 + 该带内的标签"。
|
||
|
||
菜单列模型(实测校正 2026-10-25):level 0(主界面)不是可选项而是根列的表头,
|
||
**可选行从 level 1 起**,列号 = level - 1。所以"根列选中的是 装置信息",'de' 之后
|
||
选中 实时数据 并展开子列——与黄金回归的键序 dee / dedde 完全对得上。
|
||
"""
|
||
|
||
def __init__(self):
|
||
self.tree = load_menu_tree()
|
||
self.title2harness = {}
|
||
for key, items in CC.TEXTS.items():
|
||
for (lab, txt, band, inv) in items:
|
||
if lab == "标题":
|
||
self.title2harness.setdefault(txt, []).append(key)
|
||
# 标题 → 规范页面 id(菜单 #page=):一个标题可能对应多个黄金态(如网口设置
|
||
# 的 4 个态、遥信量的两页),规范 id 取菜单树上真正绑定的那个
|
||
self.title2page = {}
|
||
self.pwd_pages = set()
|
||
for (lvl, label, page, need_pwd) in self.tree:
|
||
if page:
|
||
self.title2page.setdefault(label, page)
|
||
if need_pwd:
|
||
self.pwd_pages.add(page)
|
||
self.tglyph = {t: Glyph(t) for t in self.title2harness}
|
||
self.labelg = {}
|
||
for (lvl, label, page, _np) in self.tree:
|
||
if lvl >= 1:
|
||
self.labelg.setdefault(label, Glyph(label))
|
||
self.pages = {}
|
||
for key, items in CC.TEXTS.items():
|
||
sig = []
|
||
for (lab, txt, band, inv) in items:
|
||
if lab == "标题":
|
||
sig.append((self.tglyph[txt], band, 0, W))
|
||
elif str(lab).endswith("名"):
|
||
sig.append((Glyph(txt), band, 0, 101))
|
||
if len(sig) >= 3:
|
||
break
|
||
self.pages[key] = sig
|
||
# 密码页正签:屏上那行"用户密码"。整串匹配实测**打不中**(第四个字落点与逐字
|
||
# 渲染的推进量差约 1 px,精确匹配就废了),所以退成"四个字里中三个"的投票——
|
||
# 数字格随输入变不参与,而这四个字同时出现三个的地方只有密码页。
|
||
self.pwd_chars = [Glyph(c) for c in "用户密码"]
|
||
# 页码:"第 N 页,共 M 页"(1..9 全枚举精确字形匹配,不做正则与浮点解析)
|
||
self.footer = {}
|
||
for n in range(1, 10):
|
||
for m in range(1, 10):
|
||
self.footer[(n, m)] = Glyph("第 %d 页,共 %d 页" % (n, m))
|
||
|
||
def is_pwd(self, fr):
|
||
"""密码页判定(投票口径见上面的注释)。"""
|
||
hit = 0
|
||
for g in self.pwd_chars:
|
||
if fr.match(g, (24, 80)):
|
||
hit += 1
|
||
return hit >= 3
|
||
|
||
def page_of(self, fr):
|
||
"""标题命中 → (规范页面 id, 黄金态键列表)。多态共用标题时黄金态键不止一个。"""
|
||
for txt, keys in self.title2harness.items():
|
||
if fr.match(self.tglyph[txt], (0, 24)):
|
||
return self.title2page.get(txt, keys[0]), keys, txt
|
||
return None, [], None
|
||
|
||
def page_evidence(self, fr, keys):
|
||
"""除标题外还命中该页黄金文本的行数(第二道证据,防"同词不同屏")。"""
|
||
best = 0
|
||
for k in keys:
|
||
n = 0
|
||
for (g, band, x0, x1) in self.pages.get(k, []):
|
||
if fr.match_any(g, band, x0, x1):
|
||
n += 1
|
||
best = max(best, n)
|
||
return best
|
||
|
||
def page_no(self, fr):
|
||
n_m = None
|
||
for (n, m), g in self.footer.items():
|
||
if fr.match(g, (138, 160)):
|
||
n_m = (n, m)
|
||
break
|
||
return n_m
|
||
|
||
def menu_cols(self, fr):
|
||
"""逐列识别选中项 → [(标签, 条 x0, 条 y0), ...] 按 x 从左到右(列号 = 下标)。
|
||
|
||
这里**不用**"列宽 66 的固定网格":子菜单是**弹框**,它的左上角跟着父列选中行
|
||
的行顶(l2_geom:x0 = 父框右边、y0 = 根顶 + 1 + 选中行 × 15,宽约 42),根列一
|
||
动,子列整体上下平移——按固定网格找选带会整列错位(实测把 de 之后的第二列
|
||
整个看漏)。
|
||
改成找**白底横条**(反白文字一定在白底上,一条 = 一个打开列的选中行):
|
||
几何无关,且天然给出"打开了几列 / 最深列在哪"。
|
||
"""
|
||
# 认"每列当前选中的是哪一项":在**白底条覆盖的行范围**内、**整幅宽度**上找反白
|
||
# 标签。两个要点都是实测逼出来的:
|
||
# · x 不能限制在单条 run 内——根列选带与子列选带在同一行首尾相连,限宽会把
|
||
# 子列吞掉("找不到 SOE记录"就是这么来的);
|
||
# · 长标签优先 + 命中框被已接受者包住就丢短者——"参数定值"那一行里必然同时
|
||
# 落进子串"参数"与"定值"(它们是更深层的菜单项名),不去重就凭空多出两列。
|
||
bars = fr.white_bars()
|
||
if not bars:
|
||
return []
|
||
spans = sorted(set((by0, by1) for (_a, _b, by0, by1) in bars))
|
||
acc = []
|
||
for (label, g) in sorted(self.labelg.items(), key=lambda kv: -len(kv[0])):
|
||
for (by0, by1) in spans:
|
||
for (hx, hy) in fr.match(g, (by0 - 1, by1 + ROW_H), 0, W, True, 3):
|
||
x0, x1 = hx + g.bx1, hx + g.bx2
|
||
y0, y1 = hy + g.by1, hy + g.by2
|
||
inside = False
|
||
for (ax0, ax1, ay0, ay1, _lab, _hx, _hy) in acc:
|
||
if x0 >= ax0 - 2 and x1 <= ax1 + 2 and y0 >= ay0 - 3 and y1 <= ay1 + 3:
|
||
inside = True
|
||
break
|
||
if not inside:
|
||
acc.append((x0, x1, y0, y1, label, hx, hy))
|
||
acc.sort(key=lambda t: (t[5], t[6]))
|
||
return [(t[4], t[5], t[6]) for t in acc]
|
||
|
||
|
||
def classify(fr, clf):
|
||
"""整屏分类:kind = PAGE / MENU / BLANK / UNKNOWN。"""
|
||
st = {"kind": "UNKNOWN", "page": None, "states": [], "title": None, "evid": 0,
|
||
"ink": fr.ink, "no": None, "cols": None}
|
||
# 密码页先判:整屏只有"用户密码"四个字 + 三个数字格(实测 ink ≈ 512),既没有页
|
||
# 标题也没有选带,不先认就会被"空白/菜单"分支糊过去。Lead 大窗里"深页盲走退化成
|
||
# 512 bits"的那几帧正是这个态(--selftest 的留档帧表把它钉成断言)。
|
||
if clf.is_pwd(fr):
|
||
st["kind"] = "PWD"
|
||
return st
|
||
pid, keys, txt = clf.page_of(fr)
|
||
if pid is not None or keys:
|
||
st["kind"] = "PAGE"
|
||
st["page"] = pid
|
||
st["states"] = keys
|
||
st["title"] = txt
|
||
st["evid"] = clf.page_evidence(fr, keys)
|
||
st["no"] = clf.page_no(fr)
|
||
# 同一标题下的多态(如遥信量第 1 页与第 2 页)用页码区分;黄金态键里带 p2
|
||
# 的那个才是第 2 页——不靠"我记得键序"来判断,认屏只认屏上有的东西
|
||
if st["no"] and len(keys) > 1:
|
||
for k in keys:
|
||
if k.endswith("p2") and st["no"][0] >= 2:
|
||
st["states"] = [k]
|
||
return st
|
||
cols = clf.menu_cols(fr)
|
||
if cols and cols[0] is not None:
|
||
st["kind"] = "MENU"
|
||
st["cols"] = cols
|
||
return st
|
||
if fr.ink <= BLANK_INK:
|
||
st["kind"] = "BLANK"
|
||
return st
|
||
|
||
|
||
# ---------------------------------------------------------------- 设备后端
|
||
|
||
class SimDevice(object):
|
||
"""无头模拟器当设备用:状态 = 从开机起的按键串,每次按键后重放并截图。
|
||
|
||
不需要台架,因此闭环逻辑的正确性可以先在 PC 上证完,再上板换传输。
|
||
⚠ 一律在空临时目录里跑(沿用黄金回归的同一条纪律:目录里残留 sim_config.ini
|
||
会让网口设置页显示 ERR,把"工厂默认态"污染成另一个态)。
|
||
"""
|
||
|
||
def __init__(self):
|
||
import tempfile
|
||
self.seq = ""
|
||
self.dir = tempfile.mkdtemp(prefix="uinav_")
|
||
|
||
def key(self, ch):
|
||
if ch not in KEYS:
|
||
raise ValueError("未知键 " + repr(ch))
|
||
self.seq = self.seq + ch
|
||
return self.grab()
|
||
|
||
def boot(self):
|
||
self.seq = ""
|
||
return self.grab()
|
||
|
||
def grab(self):
|
||
import os as _os
|
||
out = _os.path.join(self.dir, "shot.bin")
|
||
CC.run_sim(self.seq, out, self.dir)
|
||
return Frame.file(out)
|
||
|
||
|
||
class BoardDevice(object):
|
||
"""RemoDispBus(TCP 7003):CMD_KEY 注键 + CMD_LCDMEM 取 3200 B 帧。
|
||
|
||
只在 --board 明确给出时构造;调用方(人)必须先持 bench 锁。本类刻意不含
|
||
任何串口 / 烧录 / 复位动作——那些是另外的票与另外的锁。
|
||
"""
|
||
|
||
TAG_C = 0xAA
|
||
CMD_KEY = 2
|
||
CMD_LCDMEM = 3
|
||
|
||
def __init__(self, ip="192.168.1.100", port=7003):
|
||
self.s = socket.create_connection((ip, port), timeout=5)
|
||
self.s.settimeout(3)
|
||
|
||
def close(self):
|
||
try:
|
||
self.s.close()
|
||
except Exception:
|
||
pass
|
||
|
||
@staticmethod
|
||
def _crc(d):
|
||
c = 0
|
||
for b in d:
|
||
c ^= b
|
||
return c
|
||
|
||
def _send(self, cmd, data=b""):
|
||
ln = len(data)
|
||
self.s.sendall(bytes([self.TAG_C, cmd, (ln >> 8) & 0xFF, ln & 0xFF])
|
||
+ data + bytes([self._crc(data)]))
|
||
|
||
def _recv_n(self, n, deadline):
|
||
buf = b""
|
||
while len(buf) < n:
|
||
if time.time() > deadline:
|
||
raise TimeoutError("recv timeout")
|
||
ch = self.s.recv(n - len(buf))
|
||
if not ch:
|
||
raise ConnectionError("peer closed")
|
||
buf += ch
|
||
return buf
|
||
|
||
def _read_frame(self, deadline):
|
||
hdr = self._recv_n(4, deadline)
|
||
n = (hdr[2] << 8) | hdr[3]
|
||
body = self._recv_n(n + 1, deadline)
|
||
return hdr[1], body[:n]
|
||
|
||
def key(self, ch):
|
||
self._send(self.CMD_KEY, bytes([KEYS[ch]]))
|
||
# ACK 是空应答:这里**不**试图从应答里读状态(读不到才是本次改造的起因)
|
||
try:
|
||
self._read_frame(time.time() + 2.0)
|
||
except (TimeoutError, ConnectionError):
|
||
pass
|
||
time.sleep(0.30)
|
||
return self.grab()
|
||
|
||
def boot(self):
|
||
return self.grab()
|
||
|
||
def grab(self):
|
||
self._send(self.CMD_LCDMEM, (0).to_bytes(4, "big"))
|
||
_cmd, data = self._read_frame(time.time() + 4.0)
|
||
bmp = data[4:4 + FRAME_BYTES]
|
||
if len(bmp) != FRAME_BYTES:
|
||
raise ValueError("LCDMEM 帧短:%d 字节" % len(bmp))
|
||
return Frame(bmp, "board")
|
||
|
||
|
||
# ---------------------------------------------------------------- 闭环导航
|
||
|
||
def page_path(clf, pid):
|
||
"""页面 id → 从根列到该项的标签序列(菜单树是唯一的真源)。"""
|
||
stack = []
|
||
for (lvl, label, page, _np) in clf.tree:
|
||
while stack and stack[-1][0] >= lvl:
|
||
stack.pop()
|
||
stack.append((lvl, label))
|
||
if page == pid:
|
||
# level 0(主界面)是根列表头、不是可选行,路径里必须剔掉
|
||
return [x[1] for x in stack if x[0] >= 1]
|
||
return None
|
||
|
||
|
||
class Navigator(object):
|
||
"""靠状态反馈走过去:每按一键都重新认屏,认不出就回收,绝不"我以为到了"。"""
|
||
|
||
def __init__(self, dev, clf, log=None):
|
||
self.dev = dev
|
||
self.clf = clf
|
||
self.log = log if log is not None else []
|
||
|
||
def snap(self):
|
||
fr = self.dev.grab()
|
||
st = classify(fr, self.clf)
|
||
st["ink"] = fr.ink
|
||
return fr, st
|
||
|
||
def sel_label(self, st, col):
|
||
cols = st.get("cols") or []
|
||
if col < len(cols):
|
||
return cols[col][0]
|
||
return None
|
||
|
||
def n_cols(self, st):
|
||
return len(st.get("cols") or [])
|
||
|
||
def _step(self, ch, why):
|
||
fr = self.dev.key(ch)
|
||
st = classify(fr, self.clf)
|
||
self.log.append({"key": ch, "why": why, "ink": fr.ink, "kind": st["kind"],
|
||
"page": st["page"], "cols": st["cols"]})
|
||
return fr, st
|
||
|
||
def _dump_unknown(self, fr, st):
|
||
"""认不出屏时的现场取证(task-24:不许静默反复按键)。
|
||
|
||
判定依据 = 现场复算 classify 的三个判据源、与它的判定顺序同口径——打印的是
|
||
"为什么没认出来",不是另立一套标准:密码页投票(4 字中 3 判 PWD)、标题命中
|
||
(page_of 返回的态键)、菜单选带条数(menu_cols)。另打 40x20 墨量预览(每字符
|
||
= 一个 4x4 块的 band_frac),"当前这一屏"在控制台直接可读;给了 --dump-fail 时
|
||
把原始帧一并落盘——台架窗口短,事后翻帧比现场重走便宜(同 go_page 的 fail_ 帧先例)。
|
||
"""
|
||
votes = 0
|
||
for g in self.clf.pwd_chars:
|
||
if fr.match(g, (24, 80)):
|
||
votes += 1
|
||
_pid, keys, txt = self.clf.page_of(fr)
|
||
cols = self.clf.menu_cols(fr)
|
||
print(" 依据:ink=%d 密码投票 %d/4 标题=%s%s 选带=%d 页码=%s"
|
||
% (fr.ink, votes, txt or "未命中",
|
||
"(态键 %s)" % ",".join(keys) if keys else "",
|
||
len(cols), str(st.get("no"))))
|
||
print(" 当前帧 40x20 预览(# 有墨 / . 空白):")
|
||
for y in range(0, H, 4):
|
||
row = "".join("#" if fr.band_frac(y, min(y + 4, H), x, min(x + 4, W)) > 0.0
|
||
else "." for x in range(0, W, 4))
|
||
print(" |%s|" % row)
|
||
if FAIL_DIR:
|
||
try:
|
||
if not os.path.isdir(FAIL_DIR):
|
||
os.makedirs(FAIL_DIR)
|
||
tag = "".join(c if c.isalnum() else "_" for c in (fr.name or "frame"))[:24]
|
||
fn = os.path.join(FAIL_DIR, "unknown_%s_%d.bin" % (tag, fr.ink))
|
||
with open(fn, "wb") as fp:
|
||
fp.write(fr.data)
|
||
print(" 原始帧已存:", fn)
|
||
except OSError as exc:
|
||
print(" 存原始帧失败(不影响判定):", exc)
|
||
|
||
def to_root(self, limit=14):
|
||
"""回到"只有一列打开"的根菜单。
|
||
|
||
两条实测纪律写在这里:
|
||
· 页面态用 s 退(s = 退出页);
|
||
· **级联列只能用 l 收**,菜单态按 s 不收列(Lead 大窗实测)——沿用盲走脚本
|
||
的 ssssssss 回根法,正是深页错位的来源之一。
|
||
"""
|
||
unk = 0
|
||
unk_e = 0 # 本次 to_root() 已投出去的 ENTER 次数(见 BACKOFF_MAX_E)
|
||
for _ in range(limit):
|
||
fr, st = self.snap()
|
||
if st["kind"] == "MENU" and self.n_cols(st) == 1:
|
||
# "只有一列"不等于"在根":子列弹框被屏幕裁掉时它的选带根本不在可扫窗口
|
||
# 里,n_cols 会漏数它——于是上一轮留在弹框里的选中项还在,直接按 e 就
|
||
# 钻进同胞页(实测 selftest / runlog 两次就是这么掉进去的)。
|
||
# 对策:认定已回根之后再按两次 l 兜底(根列上 l 不会破坏任何东西),
|
||
# 然后重新验一次列数——**多收比漏收安全**,这条纪律与"枚举后验终点"同源。
|
||
self._step("l", "回根后补收一次(防被裁弹框漏计)")
|
||
_fr2, st2 = self.snap()
|
||
if st2["kind"] == "MENU" and self.n_cols(st2) == 1:
|
||
return st2
|
||
continue
|
||
if st["kind"] in ("PAGE", "BLANK", "PWD"):
|
||
# PWD 也必须能退:板子上电时如果停在密码页(留档帧里就有三帧),
|
||
# 不退出来整个导航就再也开不了工——这一步用 s(密码页上 s = 放弃)
|
||
self._step("s", "退出页面 / 密码页 / 空白态回菜单")
|
||
continue
|
||
if st["kind"] == "UNKNOWN":
|
||
# 认不出的屏:按可配置键序**循环**退避(默认 esl,--backoff-keys 可改),
|
||
# 每轮先打印当前帧与判定依据再按键(task-24 两条台架实证教训都在这):
|
||
# · 旧版"看起来多列按 l 否则按 s"——UNKNOWN 态 cols 恒 None(classify 只在
|
||
# MENU 态填 cols),l 分支永不可达,实际是静默连按 s;本机待机态 s 无效、
|
||
# 要按 e 才进菜单,冷启动落在待机态 = 板端闭环回归不可复现;
|
||
# · 静默反复按同一键等于什么都没做,现场只剩耗时、不留证据。
|
||
keys = BACKOFF_KEYS or "esl"
|
||
ch = keys[unk % len(keys)]
|
||
unk += 1
|
||
if ch == "e":
|
||
# 第二条硬闸:未知屏上的 ENTER 可能就是执行类页面的第二段确认。
|
||
# 宁可停机让人看图,也不拿 ENTER 去试未知状态。
|
||
if unk_e >= BACKOFF_MAX_E:
|
||
raise RuntimeError(
|
||
"UNKNOWN 退避需要第 " + str(unk_e + 1) + " 次 ENTER,已拒绝("
|
||
+ "BACKOFF_MAX_E=" + str(BACKOFF_MAX_E) + ")。继续按 e 有真执行风险:"
|
||
+ "装置维护的 强制复归/清除记录 是两段式确认,第二次 ENTER 即执行。"
|
||
+ "请先看上面 dump 的帧判断这是哪一屏;确认不是执行类页面后,才用"
|
||
+ " --backoff-keys 改用无 ENTER 的键序(如 sl)重来。")
|
||
unk_e += 1
|
||
why = ("UNKNOWN 屏第 %d 次:按 '%s'(退避键序 %r,可 --backoff-keys 覆盖)"
|
||
% (unk, ch, keys))
|
||
print("[未知屏] %s" % why)
|
||
self._dump_unknown(fr, st)
|
||
self._step(ch, why)
|
||
continue
|
||
self._step("l", "菜单多列 → 收回一级")
|
||
raise RuntimeError("回根失败(%d 步内没见到单列根菜单;UNKNOWN 退避键序 %r,"
|
||
"可用 --backoff-keys 覆盖)" % (limit, BACKOFF_KEYS))
|
||
|
||
def seek_in_column(self, col, want, limit=12):
|
||
"""在当前列把选带移到标签 want:先下后上,走到边界就换向(不猜步数)。
|
||
|
||
读不到这一列时**分两种情况**(实测:选中行落在根列最后两行时,子列弹框会画到
|
||
屏幕外,选带根本不在可扫窗口里):
|
||
· 这一列本就不该存在(col >= 已检出列数)→ 认为父列刚展开、首项天然被选中,
|
||
直接交给上层的"按 e + 验到达页"闭环兜底——**到达与否只认页面身份**,
|
||
中途的假设一律写进 trace,不留成"我以为到了";
|
||
· 该列存在却认不出选中项 → 交上层重开(外部状态变了,别硬猜)。
|
||
"""
|
||
fr, st = self.snap()
|
||
if self.sel_label(st, col) == want:
|
||
return True
|
||
if col >= self.n_cols(st):
|
||
self.log.append({"key": "-", "why": "第 %d 列没检出(子列可能被屏幕裁掉),"
|
||
"按首项已选中处理,最终靠页面身份验收" % col,
|
||
"kind": st["kind"], "page": st["page"], "cols": st["cols"]})
|
||
return True
|
||
# 单调扫:先一路下移、再一路回上移,靠"选带标签变了没有"判断边界。
|
||
# 不猜"目标在第几行"——菜单内容会变,猜步数就等于回到盲走。
|
||
for ch in ("d" * limit) + ("u" * (limit * 2)):
|
||
_fr, st = self._step(ch, "在列 %d 里找 %s" % (col, want))
|
||
got = self.sel_label(st, col)
|
||
if got == want:
|
||
return True
|
||
if got is None:
|
||
return False # 这一列认不出选中项 = 状态被外部改变,交上层重开
|
||
return False
|
||
|
||
def _pwd_pass(self, modify):
|
||
"""过密码页:查看密码 = 出厂 000(直接 e 确认),修改密码 = 123(u,r,u,r,u,r,e)。
|
||
|
||
键序与 simulator/tests/check_content_pages.py 里 netcfg_ro / netcfg_edit 两个黄金态
|
||
**完全一致**:那是同一份 UI 源码的黄金回归,这里对齐它就不会出现"两套过法"。
|
||
⚠ 写路径(批 5)判据要区分权限:查看密码只给只读,修改密码才可写——导航器
|
||
默认走查看密码,**不因"能进去"就用修改密码**(那会把只读判据偷偷升格成可写)。
|
||
"""
|
||
if modify:
|
||
for ch in "uruuruuue":
|
||
self._step(ch, "过修改密码(123)")
|
||
else:
|
||
self._step("e", "过查看密码(出厂 000,直接确认)")
|
||
|
||
def _descend(self, path, blind_last, modify, pid):
|
||
"""沿路径下钻;blind_last = 在最后一列里先盲按几次下移(弹框被裁时的枚举)。"""
|
||
for (i, label) in enumerate(path):
|
||
assumed = (i >= self.n_cols(self.snap()[1]))
|
||
if assumed and i == len(path) - 1 and blind_last:
|
||
for _k in range(blind_last):
|
||
self._step("d", "末列盲移一次(弹框被屏幕裁掉,读不到选带)")
|
||
if not self.seek_in_column(i, label):
|
||
raise RuntimeError("第 %d 列找不到 %s" % (i, label))
|
||
_fr, st = self._step("e", "进入 " + label)
|
||
if st["kind"] == "PWD":
|
||
# 密码页只有两种正确解释:这一叶在菜单树上确实标了 #P(那就过一次),
|
||
# 或者它没标(那就是我们走到了意料之外的屏)——后者必须报错而不是顺手输密码
|
||
if pid not in self.clf.pwd_pages:
|
||
raise RuntimeError("停在密码页,但 " + pid + " 在菜单树上没有 #P 注记")
|
||
self._pwd_pass(modify)
|
||
return
|
||
if i < len(path) - 1 and st["kind"] != "MENU":
|
||
raise RuntimeError("进入 " + label + " 后不是菜单态而是 " + st["kind"])
|
||
|
||
def go_page(self, pid, modify=False, tries=5):
|
||
"""走到指定页面并用**页面身份**验收;到不了就报错,不返回"大概到了"。
|
||
|
||
末列弹框被屏幕裁掉时(根列选中落在最后两行)读不到选带,"首项已选中"是猜的。
|
||
对策是**枚举 + 验终点**:第 N 次尝试先在末列盲按 N 次下移再进,成败仍只由页面
|
||
身份裁定。这不算回到盲走——盲走的问题从来不是"按了 unknown 的键",而是
|
||
"按完就认为到了";这里每一次都验,验不过就退出来重走。
|
||
"""
|
||
path = page_path(self.clf, pid)
|
||
if not path:
|
||
raise RuntimeError("菜单树里没有页面 " + pid)
|
||
if pid in EXEC_PAGE_IDS and not ALLOW_EXEC_PAGES:
|
||
# 硬闸 ②:执行类页面默认不进(理由见文件头 BACKOFF_MAX_E 处那段说明)。
|
||
# 要看这两页的显示正确性,请走模拟器黄金参考——同一份 UI 源码、零真机副作用。
|
||
raise RuntimeError("刻意跳过执行类页面 " + pid + ":进入后第二次 ENTER 即真执行"
|
||
"(强制复归 = 复归保护位并写 SOE;清除记录 = 清 SOE/运行/操作记录)。"
|
||
"显示正确性请走模拟器黄金参考;确需真机下钻才加 --allow-exec-pages。")
|
||
last_err = None
|
||
for attempt in range(0, tries):
|
||
self.to_root()
|
||
try:
|
||
self._descend(path, attempt, modify, pid)
|
||
except RuntimeError as exc:
|
||
last_err = exc
|
||
continue
|
||
fr, st = self.snap()
|
||
if st["kind"] == "PAGE" and st["page"] == pid:
|
||
return fr, st
|
||
last_err = RuntimeError("第 %d 次尝试后停在 %s(目标 %s)"
|
||
% (attempt, st["kind"] + ":" + str(st["page"]), pid))
|
||
# 把认错/走歪那一屏存下来:台架窗口只有几分钟,事后能翻图比"现场再走一遍"
|
||
# 便宜得多,而且 UNKNOWN 到底长什么样只有图能回答
|
||
if FAIL_DIR:
|
||
try:
|
||
if not os.path.isdir(FAIL_DIR):
|
||
os.makedirs(FAIL_DIR)
|
||
with open(os.path.join(FAIL_DIR, "fail_%s_%d.bin" % (pid, attempt)), "wb") as fp:
|
||
fp.write(fr.data)
|
||
except OSError:
|
||
pass
|
||
self._step("s", "未到目标页,退出重走")
|
||
raise RuntimeError(str(last_err) + ";末列枚举 %d 次仍未到达" % tries)
|
||
|
||
|
||
|
||
# ---------------------------------------------------------------- 自检与入口
|
||
|
||
# Lead 大窗留档帧(ci-artifacts/uiwalk/)的身份。
|
||
# ⚠ 右边的期望值**不是照抄文件名**:本表按"认屏结果应当是什么"来写,实测证据是
|
||
# 文件名声称到达的 9 个态里只有 3 个真是那个态(00 根 / 01 装置信息 / 02 交流量),
|
||
# 其余 6 帧的墨量与内容说明它们停在别处——03 是**菜单级联**(ink 4961、没有页标题)、
|
||
# 04/05/06 是 ink ≈ 512 的**近空白态**(正是 Lead 说的 512 bits 形态)、
|
||
# 07 是网口设置页、08 是装置信息页。这正是"盲走不可信"的量化证据,也是本文件存在的
|
||
# 理由;把它写成断言而不是评论,是因为下次改 UI 后这条结论也要能被重新检查。
|
||
# 已声明的导航限度(不是"允许失败",是**记账**):这两页落在根列最后两行,其子菜单
|
||
# 弹框会被屏幕裁掉、选带不在可扫窗口内,"枚举 + 验终点"目前吃不到它们。
|
||
# 依据与现场帧见 docs/UI设计/UI_自动化测试流程.md §7.4;真机上弹框画在哪要等台架取证。
|
||
# ⚠ 对称设计:这两页哪天真走到了,本表就是**过期**——脚本会当成失败报出来,逼着同步删
|
||
# 条目与文档限度,免得限度清单变成永久遮羞布。
|
||
KNOWN_UNREACHABLE = {
|
||
"selftest": "根列末两行叶子的子列弹框被屏裁(见 §7.4 第 1 条)",
|
||
"runlog": "同上(与 自检记录 同一个被裁弹框)",
|
||
}
|
||
|
||
REF_TRUTH = {
|
||
"pwd": ("PWD",), "pwe": ("PWD",), "pwc": ("PWD",), "pwk": ("MENU",),
|
||
}
|
||
|
||
BOARD_TRUTH = {
|
||
"00_root.bin": ("MENU", None),
|
||
"01_info.bin": ("PAGE", "device_info"),
|
||
"02_ac.bin": ("PAGE", "ac"),
|
||
"03_dc.bin": ("MENU", None),
|
||
# 这三帧(ink 512/515/512)现在被认成 **PWD = 密码输入页**,比"近空白"更进一步,
|
||
# 而且与仓库黄金参考 shot_pwd/pwe.bin(ink 同为 512/512)逐字对得上:
|
||
# 也就是说"遥信量第 2 页 / SOE 记录"这两次深页盲走实际落点是**密码页**——
|
||
# 走到 记录查询 / 三遥设置 这类带 #P 注记的叶子时,第一屏是要密码,不是内容页。
|
||
# 这条结论值得留在断言里:批 5 的写路径判据必须先把"过密码"这一步做成显式状态。
|
||
"04_yx.bin": ("PWD", None),
|
||
"05_yxp2.bin": ("PWD", None),
|
||
"06_soe.bin": ("PWD", None),
|
||
"07_vparam.bin": ("PAGE", "net_cfg"),
|
||
"08_vset.bin": ("PAGE", "device_info"),
|
||
}
|
||
|
||
|
||
def selftest_safety_gates(clf):
|
||
"""两条执行风险硬闸的常驻回归用例(假设备、内存帧、零真机注键)。
|
||
|
||
为什么要常驻这两条:闸只在真出事的那一屏才起作用,平时没人碰,于是"改了个常量
|
||
就把闸拆掉"这种回退不会有任何人察觉。本用例不连 7003、不开串口、不碰台架——
|
||
设备是一帧内存里的空白位图,按键只记账、不投递。
|
||
覆盖的两条闸(2026-10-26 台架叠险 → ac9ca33f 收口,理由见文件头):
|
||
闸① UNKNOWN 退避里的 ENTER 次数上限(BACKOFF_MAX_E):需要第二次就当场停机;
|
||
以及显式改用无 ENTER 键序(--backoff-keys sl)时,一个 ENTER 都不许投出去;
|
||
闸② 执行类页面(EXEC_PAGE_IDS)默认拒绝下钻,且拒绝发生在**任何键投出去之前**;
|
||
显式放行(--allow-exec-pages)后不得再报"刻意跳过"——放行 = 接受后果。
|
||
返回 (用例数, 失败列表),与其它 selftest_* 同形,失败非 0 退出由调用方决定。
|
||
"""
|
||
g = globals()
|
||
saved = {"classify": g["classify"], "ALLOW_EXEC_PAGES": g["ALLOW_EXEC_PAGES"],
|
||
"BACKOFF_KEYS": g["BACKOFF_KEYS"], "BACKOFF_MAX_E": g["BACKOFF_MAX_E"]}
|
||
bad = []
|
||
n = 0
|
||
|
||
class FakeDevice(object):
|
||
"""假设备:grab 永远给同一帧内存位图,key 只记账不投递(不连板、不连模拟器)。"""
|
||
|
||
def __init__(self, fr):
|
||
self.fr = fr
|
||
self.keys = []
|
||
|
||
def grab(self):
|
||
return self.fr
|
||
|
||
def key(self, ch):
|
||
if ch not in KEYS:
|
||
raise ValueError("未知键 " + repr(ch))
|
||
self.keys.append(ch)
|
||
return self.fr
|
||
|
||
def stub(kind, extra=None):
|
||
def _f(fr, _clf):
|
||
st = {"kind": kind, "page": None, "cols": None, "evid": 0, "no": None}
|
||
if extra:
|
||
st.update(extra)
|
||
return st
|
||
return _f
|
||
|
||
def check(name, ok, detail):
|
||
print("%-5s %s %s" % ("[OK]" if ok else "[FAIL]", name, detail))
|
||
if not ok:
|
||
bad.append(name + ": " + detail)
|
||
|
||
blank = Frame(bytes(FRAME_BYTES), "fake_blank")
|
||
try:
|
||
# 政策钉:出厂默认必须是"拒绝下钻"。下面所有用例会临时改这两个常量,
|
||
# 所以默认值只能在进函数时抓一次(saved)——否则有人把默认翻成 True,
|
||
# 用例照样自己设 False 再测,全绿放行一个危险的默认(实测漏过一次)。
|
||
n += 1
|
||
check("闸② 出厂默认即拒绝(ALLOW_EXEC_PAGES 默认 False)",
|
||
saved["ALLOW_EXEC_PAGES"] is False,
|
||
"模块默认=%r(True 意味着任何人漏写 --allow-exec-pages 也会下钻执行类页面)"
|
||
% (saved["ALLOW_EXEC_PAGES"],))
|
||
# ---- 闸①:UNKNOWN 退避最多投 BACKOFF_MAX_E 次 ENTER,第二次当场停机 ----
|
||
g["classify"] = stub("UNKNOWN")
|
||
g["BACKOFF_KEYS"] = "esl"
|
||
# 故意**不**覆盖 BACKOFF_MAX_E:闸的强度本身要被钉住(有人把上限调到 2 就该红)
|
||
dev = FakeDevice(blank)
|
||
msg1 = ""
|
||
try:
|
||
Navigator(dev, clf).to_root(limit=12)
|
||
except RuntimeError as exc:
|
||
msg1 = str(exc)
|
||
n += 1
|
||
e_cnt = dev.keys.count("e")
|
||
check("闸① UNKNOWN 退避 ENTER 上限",
|
||
("BACKOFF_MAX_E" in msg1) and e_cnt == saved["BACKOFF_MAX_E"]
|
||
and saved["BACKOFF_MAX_E"] <= 1,
|
||
"停机消息=%s,投出 ENTER=%d 次(上限配置=%d,政策钉死 <=1),键迹=%s"
|
||
% (bool(msg1), e_cnt, saved["BACKOFF_MAX_E"], "".join(dev.keys)))
|
||
|
||
# ---- 闸①补:显式无 ENTER 键序时,一个 ENTER 都不许出现 ----
|
||
g["BACKOFF_KEYS"] = "sl"
|
||
dev2 = FakeDevice(blank)
|
||
try:
|
||
Navigator(dev2, clf).to_root(limit=6)
|
||
except RuntimeError:
|
||
pass
|
||
n += 1
|
||
check("闸①补 无 ENTER 键序(--backoff-keys sl)", "e" not in dev2.keys,
|
||
"键迹=%s(应不含 e)" % ("".join(dev2.keys) or "-"))
|
||
|
||
# ---- 闸②:执行类页面默认拒绝,且拒绝发生在投键之前 ----
|
||
g["BACKOFF_KEYS"] = "esl"
|
||
g["ALLOW_EXEC_PAGES"] = False
|
||
g["classify"] = stub("MENU", {"cols": [("根", 0)]})
|
||
# 清单被清空 = 闸被拆。这种情况要**报红**,不能"零条用例"悄悄过去,也不能崩
|
||
# (崩了只是噪声,红色才是判定;ng==0 只能兜住"一条都没跑",兜不住"清单空了")
|
||
n += 1
|
||
check("闸② 执行类清单完整(清空或缺项 = 拆闸)",
|
||
len(EXEC_PAGE_IDS) >= 2 and {"clear", "reset"} <= set(EXEC_PAGE_IDS),
|
||
"EXEC_PAGE_IDS=%s" % (sorted(EXEC_PAGE_IDS) or "空"))
|
||
for pid in sorted(set(EXEC_PAGE_IDS) | {"clear", "reset"}):
|
||
dev3 = FakeDevice(blank)
|
||
msg3 = ""
|
||
try:
|
||
Navigator(dev3, clf).go_page(pid)
|
||
except RuntimeError as exc:
|
||
msg3 = str(exc)
|
||
n += 1
|
||
check("闸② 执行类页面 %s 默认拒绝下钻" % pid,
|
||
("刻意跳过执行类页面" in msg3) and ("真执行" in msg3)
|
||
and ("e" not in dev3.keys),
|
||
"拒绝理由点名真执行=%s,投出的 ENTER=%d(消息:%s)"
|
||
% ("真执行" in msg3, dev3.keys.count("e"), msg3[:36] or "没抛错"))
|
||
|
||
# ---- 闸②反向:显式放行后不得再报"刻意跳过"(放行 = 接受后果)----
|
||
g["ALLOW_EXEC_PAGES"] = True
|
||
g["classify"] = stub("UNKNOWN") # 放行后走进导航,被闸①停住而不是被闸②拒绝
|
||
dev4 = FakeDevice(blank)
|
||
msg4 = ""
|
||
try:
|
||
Navigator(dev4, clf).go_page(sorted(set(EXEC_PAGE_IDS) | {"clear", "reset"})[0])
|
||
except RuntimeError as exc:
|
||
msg4 = str(exc)
|
||
n += 1
|
||
check("闸②反向 --allow-exec-pages 放行后不再拒绝",
|
||
("刻意跳过执行类页面" not in msg4) and bool(msg4),
|
||
"放行者=%s(消息:%s)" % ("刻意跳过执行类页面" not in msg4, msg4[:36] or "没抛错"))
|
||
return n, bad
|
||
finally:
|
||
for k, v in saved.items():
|
||
g[k] = v
|
||
|
||
|
||
def selftest_board_frames(clf):
|
||
"""真机留档帧上的分类自检:不需要台架也能证明认屏认得对。"""
|
||
bad = []
|
||
n = 0
|
||
for name, (kind, pid) in sorted(BOARD_TRUTH.items()):
|
||
p = os.path.join(WALK_DIR, name)
|
||
if not os.path.exists(p):
|
||
print(" 跳过(无留档)", name)
|
||
continue
|
||
n += 1
|
||
fr = Frame.file(p)
|
||
st = classify(fr, clf)
|
||
ok = (st["kind"] == kind) and (pid is None or st["page"] == pid)
|
||
print(" %-16s ink=%-5d 期望 %-5s %-11s 实得 %-5s %-11s %s"
|
||
% (name, fr.ink, kind, pid or "-", st["kind"], st["page"] or "-",
|
||
"OK" if ok else "MISMATCH"))
|
||
if not ok:
|
||
bad.append(name)
|
||
return n, bad
|
||
|
||
|
||
def selftest_refs(clf):
|
||
"""仓库黄金参考(66 张)逐帧分类自检:每张图的期望值 = 它的文件名。
|
||
|
||
这一组是**语义正确的基线**(模拟器与板端同一份 UI 源码),用来把"分类器错了"与
|
||
"留档帧本来就是走歪的"分开——两类失败含义完全不同,混在一起就只能靠猜。
|
||
"""
|
||
bad = []
|
||
n = 0
|
||
for (key, _seq) in CC.PAGES:
|
||
p = os.path.join(CC.REF_DIR, "shot_" + key + ".bin")
|
||
if not os.path.exists(p):
|
||
continue
|
||
n += 1
|
||
fr = Frame.file(p)
|
||
st = classify(fr, clf)
|
||
# 有些参考只有像素断言、没有文本断言(TEXTS 里没这一项),它们的标题就取不到,
|
||
# 这类帧只要求"认出是某个内容页",不硬猜是哪一页(认不准就说认不准)
|
||
exp = None
|
||
for (lab, txt, band, inv) in CC.TEXTS.get(key, []):
|
||
if lab == "标题":
|
||
exp = clf.title2page.get(txt, key)
|
||
break
|
||
if exp is None:
|
||
# 密码系列(pwd/pwe/pwc = 密码输入态、pwk = 密码通过后的级联菜单态)没有
|
||
# 页标题可核,期望值写死在下面这张表里,而不是"能认成什么都算过"
|
||
exp_kind = REF_TRUTH.get(key, ("PAGE",))[0]
|
||
ok = st["kind"] == exp_kind
|
||
print(" %-16s ink=%-5d 期望 (无标题可核) 实得 %-11s 页码 %-8s %s"
|
||
% (key, fr.ink, st["kind"], str(st["no"]), "OK" if ok else "MISMATCH"))
|
||
if not ok:
|
||
bad.append(key)
|
||
continue
|
||
ok = (st["kind"] == "PAGE") and (st["page"] == exp)
|
||
# 同一标题的多态(遥信量第 1 页 / 第 2 页)用页码再分一道
|
||
if ok and key.endswith("p2"):
|
||
ok = bool(st["no"]) and st["no"][0] >= 2
|
||
print(" %-16s ink=%-5d 期望 %-11s 实得 %-11s 页码 %-8s %s"
|
||
% (key, fr.ink, exp, st["page"], str(st["no"]), "OK" if ok else "MISMATCH"))
|
||
if not ok:
|
||
bad.append(key)
|
||
return n, bad
|
||
|
||
|
||
def known_unreachable_snapshot():
|
||
"""已声明限度里的页面,且**确实还在菜单树上**(页面被删掉时不该再报限度过期)。"""
|
||
return list(KNOWN_UNREACHABLE.keys())
|
||
|
||
|
||
def selftest_closed_loop(clf, want):
|
||
"""闭环导航自检:在模拟器上**只用状态反馈**走到目标页(不给键序)。"""
|
||
if not os.path.exists(SIM_EXE):
|
||
# 与 mem-numbers 缺 .map 是同一课:**新工作树没构建过就没有这个事实源**。
|
||
# 这里刻意不"跳过当通过":闭环是判据的核心,静默 skip 等于把闸门打开。
|
||
print(" [FAIL] 缺 lvgl_sim.exe(%s)——先跑 simulator/build_sim.bat," % SIM_EXE)
|
||
print(" 或改跑 --selftest-quick(只核认屏,不需要构建产物)")
|
||
return len(want), list(want), []
|
||
log = []
|
||
bad = []
|
||
known = []
|
||
for pid in want:
|
||
dev = SimDevice()
|
||
nav = Navigator(dev, clf, log=log)
|
||
t0 = time.time()
|
||
try:
|
||
fr, st = nav.go_page(pid)
|
||
print(" %-12s 到达 page=%-11s evid=%d 页码 %-6s 用键 %2d 次 %.1fs"
|
||
% (pid, st["page"], st["evid"], str(st["no"]), len(dev.seq), time.time() - t0))
|
||
except Exception as exc:
|
||
if pid in KNOWN_UNREACHABLE:
|
||
print(" %-12s 预期内失败(已声明限度):%s" % (pid, KNOWN_UNREACHABLE[pid]))
|
||
known.append(pid)
|
||
else:
|
||
print(" %-12s 失败:%s" % (pid, exc))
|
||
bad.append(pid)
|
||
# 反向检查:限度清单必须**恰好**命中——被声明的页真走到了,说明限度已解除,
|
||
# 那也是一次失败(过期清单比没有清单更害人:下一个人会照着它解释真实现象)
|
||
fixed = [p for p in known_unreachable_snapshot() if p not in known]
|
||
for p in fixed:
|
||
print(" %-12s 限度已解除:请删 KNOWN_UNREACHABLE 条目并更新文档 §7.4" % p)
|
||
bad.extend(fixed)
|
||
return len(want), bad, log
|
||
|
||
|
||
def bench_gate(argv, what):
|
||
"""真机动作前的闸:没给显式确认位就拒绝(退出码 3,且不碰网络)。"""
|
||
if BENCH_CONFIRM in argv:
|
||
return 0
|
||
print("[拒绝] %s 会直连真机(注 KEY + 取 LCDMEM 帧),台架是独占资源。" % what)
|
||
print(" 先取 bench 锁,再由人在同一命令行加 %s 明确确认已持锁:" % BENCH_CONFIRM)
|
||
print(" .venv/Scripts/python.exe .dsh/skills/task-worktree/scripts/task_worktree.py lock acquire bench --task <ID>")
|
||
print(" 本脚本不代取锁,也不接受“应该没人在用”——那种判断只有持锁的人能做。")
|
||
return 3
|
||
|
||
|
||
def _dump_trace(log):
|
||
"""轨迹只在 --trace 给了路径时落盘(默认不往仓库里写东西)。"""
|
||
if not TRACE:
|
||
return 0
|
||
try:
|
||
d = os.path.dirname(os.path.abspath(TRACE))
|
||
if d and not os.path.isdir(d):
|
||
os.makedirs(d)
|
||
with open(TRACE, "w", encoding="utf-8") as f:
|
||
json.dump(log, f, ensure_ascii=False, indent=1)
|
||
print("轨迹已写 %s(%d 步)" % (TRACE, len(log)))
|
||
except OSError as exc:
|
||
print("轨迹写盘失败(不影响判定):", exc)
|
||
return len(log)
|
||
|
||
|
||
def main(argv):
|
||
global TRACE, FAIL_DIR, BACKOFF_KEYS, ALLOW_EXEC_PAGES
|
||
if "--allow-exec-pages" in argv:
|
||
ALLOW_EXEC_PAGES = True
|
||
print("[警告] --allow-exec-pages 已开:导航器会下钻执行类页面 "
|
||
+ str(sorted(EXEC_PAGE_IDS)) + "。第二次 ENTER 即真执行,后果自负。")
|
||
if "--trace" in argv:
|
||
TRACE = argv[argv.index("--trace") + 1]
|
||
if "--dump-fail" in argv:
|
||
FAIL_DIR = argv[argv.index("--dump-fail") + 1]
|
||
if "--backoff-keys" in argv:
|
||
bk = argv[argv.index("--backoff-keys") + 1]
|
||
# 只收导航器真会注的键字符;空串或含别的字符一律当场拒——退避键序打错宁可现在报错,
|
||
# 也不要它变成"跑了半天都在按奇怪的东西"(同注释行注记写错必须当场失败的口径)
|
||
if not bk or any(c not in "ulrdes" for c in bk):
|
||
print("--backoff-keys 不能为空、只允许 u/l/r/d/e/s 的组合(收到 %r)" % bk)
|
||
return 3
|
||
BACKOFF_KEYS = bk
|
||
clf = Classifier()
|
||
if "--help" in argv:
|
||
print(__doc__)
|
||
return 0
|
||
if "--list" in argv:
|
||
# 只用 Classifier 真实暴露的接口拼清单:tree(菜单树 #page= 叶 = --to 合法页 id
|
||
# 与路径的唯一真源)、title2harness(标题→黄金态键)、pwd_pages(#P 叶)、
|
||
# page_path(id→标签路径)。旧版打的 clf.titles 根本不存在(AttributeError,
|
||
# 整条子命令不可用),且 clf.pages 的键是**黄金态键**不是页 id——即便 titles 存在
|
||
# 列出来的也不能喂给 --to。**不另造标题表**:手写第二真相下次菜单树改动必然漂移。
|
||
titles_by_page = {}
|
||
for (_lvl, label, page, _np) in clf.tree:
|
||
if page:
|
||
lst = titles_by_page.setdefault(page, [])
|
||
if label not in lst:
|
||
lst.append(label)
|
||
for pid in sorted(titles_by_page):
|
||
path = page_path(clf, pid)
|
||
states = sorted({k for t in titles_by_page[pid]
|
||
for k in clf.title2harness.get(t, [])})
|
||
print("%-12s 标题=%-14s 路径=%s%s 黄金态=%s"
|
||
% (pid, "、".join(titles_by_page[pid]),
|
||
">".join(path) if path else "-",
|
||
" [#P 需密码]" if pid in clf.pwd_pages else "",
|
||
",".join(states) or "(无文本断言,认屏只认标题)"))
|
||
return 0
|
||
if "--classify" in argv:
|
||
tgt = argv[argv.index("--classify") + 1]
|
||
files = []
|
||
if os.path.isdir(tgt):
|
||
files = [os.path.join(tgt, x) for x in sorted(os.listdir(tgt))
|
||
if x.endswith(".bin")]
|
||
else:
|
||
files = [tgt]
|
||
for p in files:
|
||
fr = Frame.file(p)
|
||
st = classify(fr, clf)
|
||
print(os.path.basename(p), st["kind"], st["page"] or "-",
|
||
"evid=%d" % st["evid"], "ink=%d" % fr.ink)
|
||
return 0
|
||
if "--selftest-quick" in argv:
|
||
# 提交门禁用的快检档:只核"认屏认得对",不需要模拟器构建产物、不需要台架
|
||
print("[1/3] 仓库黄金参考分类(模拟器与板端同一份 UI 源码 = 语义基线)")
|
||
nr, badr = selftest_refs(clf)
|
||
print("[2/3] 真机留档帧分类(目录缺则整段跳过,不算失败)")
|
||
n, bad1 = selftest_board_frames(clf)
|
||
print("[3/3] 执行风险硬闸(假设备、零真机注键,task-41)")
|
||
ng, badg = selftest_safety_gates(clf)
|
||
print("汇总(快检):黄金参考 %d 帧错 %d;留档帧 %d 帧错 %d;硬闸用例 %d 条错 %d"
|
||
% (nr, len(badr), n, len(bad1), ng, len(badg)))
|
||
print("闭环导航未跑(需 lvgl_sim.exe):完整自检用 --selftest")
|
||
# 硬闸用例一条没跑也算失败:静默跳过等于把闸拆了还显示绿(同 nr==0 的处理口径)
|
||
return 1 if (badr or bad1 or badg or nr == 0 or ng == 0) else 0
|
||
if "--selftest" in argv:
|
||
print("[1/3] 仓库黄金参考分类(模拟器与板端同一份 UI 源码 = 语义基线)")
|
||
nr, badr = selftest_refs(clf)
|
||
print("[2/3] 真机留档帧分类(%s)" % WALK_DIR)
|
||
n, bad1 = selftest_board_frames(clf)
|
||
print("[3/3] 模拟器闭环导航(只用状态反馈,不给键序)")
|
||
# 覆盖菜单树上**所有绑定了页面的叶子**(不是挑几个好看的):判据脚本自己也得
|
||
# 被同样的标准检查,否则"闭环可用"就只是一次演示
|
||
all_ids = [p for (_l, _n, p, _np) in clf.tree if p]
|
||
m, bad2, log = selftest_closed_loop(clf, all_ids)
|
||
_dump_trace(log)
|
||
print("[4/4] 执行风险硬闸(假设备、零真机注键,task-41)")
|
||
ng, badg = selftest_safety_gates(clf)
|
||
print("汇总:黄金参考 %d 帧错 %d;留档帧 %d 帧错 %d;闭环 %d 页失败 %d;硬闸用例 %d 条错 %d"
|
||
% (nr, len(badr), n, len(bad1), m, len(bad2), ng, len(badg)))
|
||
# 留档帧目录(ci-artifacts/uiwalk,不受版本控制)可以不存在 → 那一段不算失败;
|
||
# 但黄金参考与闭环必须真跑过:任一为 0 说明这次"自检"其实什么都没检查
|
||
return 1 if (badr or bad1 or bad2 or badg or nr == 0 or m == 0 or ng == 0) else 0
|
||
if "--walk" in argv:
|
||
rc = bench_gate(argv, "--walk")
|
||
if rc:
|
||
return rc
|
||
# 台架窗口用:一条命令走完菜单树上**所有绑定页**,每页存一帧 + 一份轨迹。
|
||
# 之所以要"一条命令",是因为 5 分钟窗口里没时间让人逐页手按;跑完只需把
|
||
# 存证目录(默认 ci-artifacts/uinav/walk/)与 --trace 落下的步迹交给 Lead,
|
||
# 判据失败在哪一屏、当时认出的是什么都在文件里,不用现场再猜。
|
||
pid = None
|
||
# 存证**必须落在版本控制之外**。第一版写 HERE/walk = <仓库根>/scripts/walk/,等于
|
||
# 把台架产物写进工作树:git status 立刻脏、done 的并集残留审计会把它当成任务遗留
|
||
# 物,下一个窗口还盖掉上一个窗口的证据。默认落 ci-artifacts/uinav/walk/(已
|
||
# gitignore,本仓台架产物一贯在此),--outdir 可改。
|
||
# 反向拒绝:落在版本控制内且不在 ci-artifacts 之下就**直接不跑**——规矩一旦依赖
|
||
# "跑完记得删",它就只防得住记得的人(这条道理同 bom-guard 的立项理由)。
|
||
outdir = argv[argv.index("--outdir") + 1] if "--outdir" in argv else \
|
||
os.path.join(REPO, "ci-artifacts", "uinav", "walk")
|
||
rp = os.path.abspath(outdir)
|
||
if rp.lower().startswith(os.path.abspath(REPO).lower() + os.sep) \
|
||
and ("ci-artifacts" not in rp.lower()):
|
||
print("[FAIL] --walk 的存证目录不能落在版本控制内:%s" % rp)
|
||
print(" 默认 ci-artifacts/uinav/walk/;换目录请用 --outdir <忽略区里的路径>")
|
||
return 3
|
||
print("⚠ --walk 直连真机:调用方必须已持 bench 锁;存证目录 %s" % rp)
|
||
dev = BoardDevice()
|
||
ok, bad = 0, []
|
||
try:
|
||
ids = [p for (_l, _n, p, _np) in clf.tree if p]
|
||
for pid in ids:
|
||
nav = Navigator(dev, clf)
|
||
try:
|
||
fr, st = nav.go_page(pid)
|
||
if not os.path.isdir(outdir):
|
||
os.makedirs(outdir)
|
||
with open(os.path.join(outdir, pid + ".bin"), "wb") as fp:
|
||
fp.write(fr.data)
|
||
print(" %-12s OK evid=%d 页码 %s ink=%d" % (pid, st["evid"], st["no"], fr.ink))
|
||
ok += 1
|
||
except Exception as exc:
|
||
print(" %-12s FAIL %s" % (pid, exc))
|
||
bad.append(pid)
|
||
_dump_trace(nav.log)
|
||
finally:
|
||
dev.close()
|
||
print("走完 %d 页:成功 %d,失败 %d;轨迹 %s" % (ok + len(bad), ok, len(bad), TRACE))
|
||
return 1 if bad else 0
|
||
if "--board" in argv:
|
||
rc = bench_gate(argv, "--board")
|
||
if rc:
|
||
return rc
|
||
if "--to" not in argv:
|
||
print("--board 需要 --to <页面 id>(见 --list)")
|
||
return 3
|
||
pid = argv[argv.index("--to") + 1]
|
||
print("⚠ 直连真机:调用方必须已持 bench 锁(本脚本不申请锁、不碰串口与烧录)")
|
||
dev = BoardDevice()
|
||
try:
|
||
nav = Navigator(dev, clf)
|
||
fr, st = nav.go_page(pid)
|
||
print("到达", pid, "kind=", st["kind"], "evid=", st["evid"], "ink=", fr.ink)
|
||
out = argv[argv.index("--grab") + 1] + "_" + pid + ".bin" if "--grab" in argv else None
|
||
if out:
|
||
with open(out, "wb") as f:
|
||
f.write(fr.data)
|
||
print("帧已存", out)
|
||
_dump_trace(nav.log)
|
||
finally:
|
||
dev.close()
|
||
return 0
|
||
print("没有要做的事:给 --selftest / --classify <文件或目录> / --board --to <页> / --list")
|
||
return 3
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main(sys.argv[1:]))
|