Files
super_agent_bot be16669564
CI / 静态门禁 (pre-commit) (push) Failing after 4m56s
CI / 主机侧单测 (MSVC) (push) Successful in 47s
CI / 静态门禁 (pre-commit) (pull_request) Failing after 4m4s
CI / 主机侧单测 (MSVC) (pull_request) Failing after 42s
docs(ui/nav): 台架复核落地——收列键语义、回根按 depth、限度取证结论(#69)
台架实机会话 2026-11-02(bench 档 fw 22:25:12 / 产品档
22:29:56,均经 /serial-boot-test 闭环:身份戳匹配 + 回读
no-difference):

- 复核② 收列键语义:菜单态按 s 与按 l 同走 menu_back、均收
  一级且父列选中留在原地(depth 1→0、sel 不变)——旧断言
  「菜单态按 s 不收列」是把待机态 s 无效(task-24 实证)误
  读成菜单行为。to_root() 据此在 STATUS depth>0 时按 s 逐级
  收(弹框被屏裁时像素 n_cols 漏计、固件 depth 不漏),老固
  件像素路径保留 l 兜底仪式。§7.2 纪律与 board-ui-nav skill
  恢复配方同步改写。
- 复核① 被裁弹框两叶:菜单步进依旧吃不到(枚举落同胞页,
  落点由 STATUS 如实报出、不再谎称到达);GOTO 直开两叶到
  达且完整渲染(自检记录 ink 4073 / 运行记录 ink 3559)→
  KNOWN_UNREACHABLE 对 --to 保留,绕行路径 --goto。§7.4 改写。
- 产品档(未加宏)实测:STATUS 照常应答(不设闸)、GOTO 回
  status=4——生产/bench 行为差异真机钉死;§7.6 增实机验证记
  录段。回归:--selftest-quick 66/66+闸 7/7;--selftest 闭环
  失败集与 master 基线逐项一致(6 存量像素 + 2 执行类拒绝)。
2026-11-02 22:39:48 +08:00

1569 lines
79 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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。
2026-11-02 #69 身份回读补两条(根因与方案调研见 issue #69 评论区):
④ 板端 7003 新增 STATUS 身份查询(kind/depth/path/sel/page id,固件态直读,
与像素认屏同源互证):认屏先问板子、像素退为兜底;老固件不应答则自动改走
纯像素路径(只损失一次超时)。像素与 STATUS 打架时以 STATUS 为准并留痕。
--where 现场打印两侧结论;GOTO 直开页仅 bench 固件(DTU_BENCH / 模拟器编译
宏 UI_REMO_ALLOW_GOTO),生产固件恒拒 status=4——见 --goto;
⑤ key() 从"sleep(0.30)+单帧 grab"改为真正执行规则①:先排空到**帧流静默
≥900 ms**(上限 5 s 不死等),再连取两帧比对;两帧不等(时钟页秒针在走)
最多重试 8 次并标注 stable=False——拿 N−1 屏当现行的坑就此封住。
退出码: 0 = 成功;1 = 分类或导航失败;2 = 环境错误(exe 缺失 / 板子连不上)
创建日期:2026-10-25(道E,Lead 大窗反馈后的判据脚本升级)
"""
import importlib.util
import json
import os
import socket
import subprocess
import sys
import time
# 台架连接默认值单一真源在 test/host/bench_defaults.py(跨目录:补 sys.path 后 import)
_sys_path = os.path.join(os.path.dirname(os.path.abspath(__file__)),
"..", "test", "host")
if _sys_path not in sys.path:
sys.path.insert(0, _sys_path)
import bench_defaults # noqa: E402
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", "uinav", "walk")
# 2026-11-02 #69:原默认 ci-artifacts/uiwalk 与 --walk 的存证目录 uinav/walk 两处不一——
# 每次台架走新帧,BOARD_TRUTH 那段永远读不到(静默"跳过(无留档)"),两处并一。
# 轨迹/失败帧**默认不写进仓库**:只有 --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)
def parse_remo_status(data):
"""解 CMD_STATUS 应答(v1):[ver][kind][depth][n][id_len][path n B][sel n B][id ASCII]。
布局唯一权威是 ui_net_sink.h 的 UI_REMO_STATUS_VER 注释块(两端共享同一份头文件,
这里按同口径做防御式解析):版本/长度对不上一律 None——宁可退回像素,不可错认身份。
"""
if not data or len(data) < 5 or data[0] != 1:
return None
kind_b, depth, n, id_len = data[1], data[2], data[3], data[4]
if not (1 <= n <= 4) or len(data) < 5 + 2 * n + id_len:
return None
pid_b = data[5 + 2 * n:5 + 2 * n + id_len]
try:
pid = pid_b.decode("ascii") if id_len else None
except UnicodeDecodeError:
return None
kinds = {0: "MENU", 1: "PAGE", 2: "STANDBY"}
return {"ver": 1, "kind": kinds.get(kind_b, "?%d" % kind_b), "depth": depth,
"path": list(data[5:5 + n]), "sel": list(data[5 + n:5 + 2 * n]),
"page": pid}
class BoardDevice(object):
"""RemoDispBus(TCP 7003):CMD_KEY 注键 + CMD_LCDMEM 取 3200 B 帧。
只在 --board 明确给出时构造;调用方(人)必须先持 bench 锁。本类刻意不含
任何串口 / 烧录 / 复位动作——那些是另外的票与另外的锁。
"""
TAG_C = 0xAA
CMD_KEY = 2
CMD_LCDMEM = 3
CMD_STATUS = 4 # #69:身份查询(kind/depth/path/sel/page id,固件态直读)
CMD_GOTO = 5 # #69:直开页(仅 bench 固件/模拟器受理,生产固件恒拒)
QUIET_S = 0.90 # 规则①帧流静默下限(2026-10-31 板级实测:900 ms 以下拿到的是 N−1 屏)
DRAIN_CAP_S = 5.0 # 排空上限:板子疯狂推流也不能挂死在这
def __init__(self, ip=bench_defaults.DEVICE_IP, port=7003):
self.s = socket.create_connection((ip, port), timeout=5)
self.s.settimeout(3)
self._status_supported = True # 老固件不回 STATUS 时置 False:之后只走像素,不再白等
self.stable_last = None # 上次 grab_settled 双帧是否一致(时钟页恒在变→False)
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 是空应答:读掉它是不让空帧混进后面的排空窗口冒充"新帧"。#69 前这里是
# sleep(0.30)+单帧 grab——正是规则①点名的 N−1 屏坑,现在整套换成 settle(⑤)。
try:
self._read_frame(time.time() + 2.0)
except (TimeoutError, ConnectionError):
pass
return self.grab_settled()
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 _pump(self, want, deadline):
"""丢掉积压陈帧(KEY 空 ACK / LCDMEM 推送残留),直到 cmd==want 的应答。
不能"发一个读一个":推流期间 socket 里排着若干与本次请求无关的帧,先读到的
往往是旧 LCDMEM——错认应答比没应答更毒,所以只认命令号。
"""
while True:
cmd, payload = self._read_frame(deadline)
if cmd == want:
return payload
def status(self):
"""CMD_STATUS:固件直读当前身份。老固件不应答 → None 并记住"别问了"。"""
if self._status_supported is False:
return None
self._send(self.CMD_STATUS)
try:
data = self._pump(self.CMD_STATUS, time.time() + 3.0)
except (TimeoutError, ConnectionError, OSError):
data = None
s = parse_remo_status(data) if data is not None else None
if data is None:
self._status_supported = False
print("[STATUS] 板端未应答身份查询(老固件?)→ 后续只用像素认屏")
return s
def goto(self, pid):
"""CMD_GOTO:直开页。仅 bench 固件/模拟器受理;生产固件回 status=4,老固件不应答。"""
b = pid.encode("ascii")
if not (1 <= len(b) <= 15):
raise ValueError("页 id 需 1..15 个 ASCII 字符:%r" % pid)
self._send(self.CMD_GOTO, b)
try:
data = self._pump(self.CMD_GOTO, time.time() + 4.0)
except (TimeoutError, ConnectionError, OSError):
return None
if not data or len(data) < 2 or data[0] != 1:
return None
return {"status": data[1], "node": data[2] if len(data) >= 3 else 0xFF}
def _drain(self):
"""排空推流直到**帧流静默 >= QUIET_S**(规则①)。上限 DRAIN_CAP_S 不死等。
中途 socket 层异常按"流已静默"处理直接收工——宁可少排一会儿(后面双帧比对
还会兜住),也不要半帧丢弃把整个流读成错位。
"""
last = time.time()
cap = last + self.DRAIN_CAP_S
self.s.settimeout(0.20)
try:
while True:
try:
self._read_frame(time.time() + 0.25)
last = time.time()
except TimeoutError:
if time.time() - last >= self.QUIET_S:
return
except (ConnectionError, OSError):
return
if time.time() > cap:
print("[settle] %d s 内帧流未静默,停止排空(结果按不稳定处理)"
% self.DRAIN_CAP_S)
return
finally:
self.s.settimeout(3)
def grab_settled(self):
"""规则①执行体:排空(静默>=900 ms) + 连取两帧比对;不等再取,最多 8 次。
两帧相同才敢说"这是现行"。时钟页秒针永远在走,到顶返回最后一帧并标
stable_last=False,让 trace 里看得见"这帧没能双帧自证"。
"""
self._drain()
fr1 = self.grab()
for _ in range(8):
fr2 = self.grab()
if fr2.data == fr1.data:
self.stable_last = True
return fr2
time.sleep(0.25)
fr1 = fr2
self.stable_last = False
return fr1
# ---------------------------------------------------------------- 闭环导航
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 _ident(self):
"""向设备要固件身份;SimDevice 没有这个通道 → None,闭环自检零影响。"""
fn = getattr(self.dev, "status", None)
if fn is None:
return None
try:
return fn()
except Exception as exc:
self.log.append({"key": "-", "why": "STATUS 读取失败,本轮只用像素:%s" % exc,
"ink": 0, "kind": "-", "page": None, "cols": None})
return None
def _merge_ident(self, fr, st):
"""STATUS 为准、像素为佐:打架时按固件身份改写 kind/page,并在 trace 里留痕。
留痕不是形式主义:像素与固件两头对不上的那一帧,就是分类器或固件该修的现场。
密码页特判:固件里它是内容页 id=password,像素侧有专门的 PWD 态与 #P 过密码流程,
归一化回 PWD,不让这里的改写把 _descend 的密码闸绕过去。
"""
s = self._ident()
if not s:
return st
st["status"] = s
k = s["kind"]
if k == "STANDBY":
if st["kind"] not in ("BLANK", "UNKNOWN", "STANDBY"):
self.log.append({"key": "-", "why": "不一致:像素=%s 但 STATUS=待机,按待机"
% st["kind"], "ink": fr.ink, "kind": st["kind"],
"page": st["page"], "cols": None})
st["kind"] = "STANDBY"
st["page"] = None
elif k == "PAGE":
pid = s["page"]
if st["kind"] == "PAGE" and st["page"] and pid and st["page"] != pid:
self.log.append({"key": "-", "why": "像素与 STATUS 不一致:像素=%s 按 STATUS=%s"
% (st["page"], pid), "ink": fr.ink, "kind": st["kind"],
"page": st["page"], "cols": None})
elif st["kind"] in ("UNKNOWN", "BLANK", "PWD"):
self.log.append({"key": "-", "why": "像素=%s 没认出,按 STATUS=页 %s"
% (st["kind"], pid or "?"), "ink": fr.ink,
"kind": st["kind"], "page": st["page"], "cols": None})
st["kind"] = "PWD" if pid == "password" else "PAGE"
st["page"] = None if pid == "password" else pid
if pid == "password":
st["page"] = "password"
elif k == "MENU":
if st["kind"] in ("PAGE", "STANDBY"):
self.log.append({"key": "-", "why": "不一致:像素=%s 但 STATUS=菜单,按菜单"
% st["kind"], "ink": fr.ink, "kind": st["kind"],
"page": st["page"], "cols": None})
if st["kind"] != "MENU":
st["kind"] = "MENU"
st["page"] = None
return st
def snap(self):
fr = self.dev.grab()
st = self._merge_ident(fr, 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 = self._merge_ident(fr, classify(fr, self.clf))
ent = {"key": ch, "why": why, "ink": fr.ink, "kind": st["kind"],
"page": st["page"], "cols": st["cols"]}
stable = getattr(self.dev, "stable_last", None)
if stable is not None:
ent["stable"] = stable
self.log.append(ent)
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):
"""回到"只有一列打开"的根菜单。
两条实测纪律写在这里(第二条已于 2026-11-02 台架复核改写,#69):
· 页面态用 s 退(s = 退出页);
· **级联列收级看回读**:有 STATUS 时按 depth 收——菜单态 s 与 l 同走
menu_back、都收一级且父列选中留在原地(台架实测 depth 1→0、sel 不变);
旧断言"菜单态按 s 不收列(Lead 大窗实测)"是把待机态 s 无效(task-24)
误读成了菜单行为。盲按 ssssssss 回根依旧禁止:不可信的是"不看回读连按",
不是键本身。老固件无 STATUS 时仍用 l 收(下方像素分支保留原路)。
"""
unk = 0
unk_e = 0 # 本次 to_root() 已投出去的 ENTER 次数(见 BACKOFF_MAX_E)
for _ in range(limit):
fr, st = self.snap()
s = st.get("status")
if s and s["kind"] == "MENU" and s.get("depth") == 0:
# 固件直说只有根列打开:被裁弹框根本不存在,旧的"补按 l 兜底"仪式可免
# (那条路正是 selftest/runlog 两次掉进同胞页的现场:见 go_page 上方注释)
return st
if st["kind"] == "STANDBY":
# 待机态:s/l 无效、e 唤醒进菜单(2026-10-26 task-24 台架实测)。
# 以前它被像素判成 BLANK 走"s 关页"分支——在待机页上按 s 是空转。
self._step("e", "待机态(STATUS)→ e 唤醒进菜单")
continue
if s and s["kind"] == "MENU" and s.get("depth", 0) > 0:
# 有固件身份回读时按 depth 收级(2026-11-02 台架复核,#69):菜单态
# s = menu_back,收一级且父列选中留在原地。子列弹框被屏裁时像素
# n_cols 会漏数、固件 depth 不漏——这正替掉了旧"补按 l 兜底"仪式要
# 防的那类漏计。s 在根列上无效但无害(台架同轮实测 depth0 按 s 原地)。
self._step("s", "STATUS depth=%d 级联 → s 收一级(台架复核 2026-11-02)"
% s["depth"])
continue
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)
def jump_page(self, pid):
"""GOTO 直开页(bench 固件/模拟器):言简答复也要终点双验,与 go_page 同一验收口径。
存在的意义是把"进故障录波看波形"这类调试动作从 5 分钟级联步行降为一次往返;
生产固件恒拒 status=4(编译期闸),执行类页面(clear/reset)主机侧同样先拒。
"""
if pid in EXEC_PAGE_IDS and not ALLOW_EXEC_PAGES:
raise RuntimeError("GOTO 同样拒绝执行类页面 " + pid + ":直开即到达可执行态,"
"没有第二段确认的余地")
names = {0: "opened", 1: "unknown id", 2: "exec refused", 3: "open failed",
4: "unsupported(生产固件不带直开页)", 5: "standby active(先 e 唤醒)"}
if not hasattr(self.dev, "goto"):
raise RuntimeError("该设备后端没有 GOTO 通道(--goto 只对真机 bench 固件有意义)")
r = self.dev.goto(pid)
self.log.append({"key": "-", "why": "GOTO %s -> %s node=%s"
% (pid, names.get(r["status"], r["status"]) if r else "无应答",
r["node"] if r else "-"),
"ink": 0, "kind": "GOTO", "page": pid, "cols": None})
if r is None:
raise RuntimeError("GOTO 无应答:老固件不认识该命令(用 --to 步行导航)")
if r["status"] != 0:
raise RuntimeError("GOTO %s 被拒:%s" % (pid, names.get(r["status"], r["status"])))
fr = self.dev.grab_settled()
st = self._merge_ident(fr, classify(fr, self.clf))
st["ink"] = fr.ink
if not (st["kind"] == "PAGE" and st["page"] == pid):
raise RuntimeError("GOTO 回了 opened,但终点是 %s:%s(期望 %s)" %
(st["kind"], st["page"], pid))
return fr, st
# ---------------------------------------------------------------- 自检与入口
# 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。台架复核 2026-11-02(#69,
# bench 固件 + STATUS):菜单步进**依旧**吃不到这两叶(末列枚举落到同胞页 alarm /
# selftest,落点由 STATUS 如实报出、不再谎称到达);但 GOTO 直开两叶都能到达并完整
# 渲染(自检记录 ink=4073 / 运行记录 ink=3559,证据在 PR #70 台架段)。本限度对 --to
# 步进继续有效,解法是 --goto 绕开被裁弹框,不是继续修枚举。
# ⚠ 对称设计:这两页哪天真走到了,本表就是**过期**——脚本会当成失败报出来,逼着同步删
# 条目与文档限度,免得限度清单变成永久遮羞布。
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 "--where" in argv:
rc = bench_gate(argv, "--where")
if rc:
return rc
dev = BoardDevice()
try:
nav = Navigator(dev, clf)
fr, st = nav.snap()
s = st.get("status")
print("STATUS:", ("kind=%s depth=%s path=%s sel=%s page=%s"
% (s["kind"], s["depth"], s["path"], s["sel"], s["page"]))
if s else "无应答(老固件无身份查询)→ 下面仅为像素认屏")
print("像素+STATUS:", st["kind"], st["page"] or "-",
"cols=%s" % ([c[0] for c in st["cols"]] if st.get("cols") else None),
"ink=%d" % fr.ink)
if nav.log:
print("留痕:", nav.log[-1]["why"])
finally:
dev.close()
return 0
if "--goto" in argv:
rc = bench_gate(argv, "--goto")
if rc:
return rc
pid = argv[argv.index("--goto") + 1]
print("⚠ --goto 直连真机:调用方必须已持 bench 锁;直开页仅 bench 固件受理")
dev = BoardDevice()
try:
nav = Navigator(dev, clf)
try:
fr, st = nav.jump_page(pid)
print("GOTO 到达", pid, "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)
rc2 = 0
except Exception as exc:
print("[GOTO FAIL]", exc)
rc2 = 1
_dump_trace(nav.log)
return rc2
finally:
dev.close()
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:]))