Files
STM32F4-Base/scripts/board_ui_nav.py
T
wandering1 621421f3ca fix(判据脚本): --walk 存证不进工作树;真机动作需显式持锁确认
票 = task-26(自报缺陷,Lead 批准开树自修)。两处都是我上一笔(导航器入库)留下的:
一条会让工作树变脏,另一条更严重——动到了不属于我的台架。

一、--walk 的存证落点
- 症状:outdir = os.path.join(HERE, "walk"),HERE = <仓库根>/scripts → 真机跑完在
  scripts/walk/ 落 12 个帧:git status 立刻脏、done 的并集残留审计会把它当任务遗留物,
  下一个窗口还盖掉上一个窗口的证据。我自己刚立过「轨迹只在 --trace 给了路径才落盘」的
  规矩,这条路径上被自己破了。
- 改法:默认落 <仓库根>/ci-artifacts/uinav/walk/(已 gitignore,本仓台架产物一贯在此),
  --outdir 可改;并加**反向拒绝**——解析后的目录若落在版本控制内且不在 ci-artifacts 之下,
  直接 [FAIL] 退出 3、连目录都不建。「跑完记得删」只防得住记得的人(同 bom-guard 立项
  理由,也同今天 .map / 缺 lvgl_sim.exe 那一族:事实源不存在就大声失败)。

二、未持锁就注入了按键(实测踩到,已做成参数闸)
- 我给「默认落点会打印成什么」做验收时跑了 --walk,脚本在没有任何拦截的情况下连上 7003
  并开始注键;而那时台架属于验收窗的准备方,板子停在主干 mega 镜像上。**板端 UI 状态可能
  已被推到别处**:只注入了方向/进入/退出类按键,未烧录、未碰串口、未触发任何写操作
  (网口设置的保存行走不到——导航器从不在保存行按 ENTER,过密码默认也只走只读查看密码)。
- 根因不是手滑,是「必须持 bench 锁」只写在文档与 print 里:跑的人自认为在跑一次离线
  检查,脚本就真去连了。于是把确认做成参数 --i-hold-bench-lock:不给就拒绝(退出码 3,
  不碰网络);脚本不代取锁,也不替人判断「应该没人在用」。
- 给窗口方的处置建议:验收窗前先复位一次,或直接以 --walk 起步(导航器第一步就是回根,
  且回根后还补收一次 l 防被裁弹框漏计)——别把我注入后的残留状态当成验收现象。

验收(本机、无板,全部实跑)
- --walk --outdir <仓库内目录> → rc=3 + [FAIL] + 目录未被创建;
- --walk / --board 未给确认位 → rc=3 且**不触网**(瞬时返回,不再有任何连板可能);
- --selftest-quick rc=0(黄金参考 18 帧 0 错);check_doc_links 通过;
- 一笔提交,未用 --no-verify;工作树仅这 2 个文件改动,无多余产物。

文档同步:docs/UI设计/UI_自动化测试流程.md §7.3 补「存证不进工作树」「真机动作需显式
确认位」两条并把用法示例改对(同一事实只在一处,正文仍在那一节)。
2026-10-25 18:30:41 +08:00

1017 lines
48 KiB
Python
Raw 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 目录(把认错的那一屏存下来)
--board / --walk 属真机动作:必须另加 --i-hold-bench-lock 显式确认已持 bench 锁,
否则直接拒绝(退出码 3,不碰网络)——台架是独占资源,脚本不替人判断有没有在用。
--walk 的存证目录默认在 ci-artifacts/uinav/walk(版本控制之外),--outdir 可改;
落在版本控制内会被直接拒绝——台架证据不进工作树,工作树才能保持干净可比对。
退出码: 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
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 to_root(self, limit=14):
"""回到"只有一列打开"的根菜单。
两条实测纪律写在这里:
· 页面态用 s 退(s = 退出页);
· **级联列只能用 l 收**,菜单态按 s 不收列(Lead 大窗实测)——沿用盲走脚本
的 ssssssss 回根法,正是深页错位的来源之一。
"""
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":
# 认不出的屏:先按 l(收列不会造成破坏),再按 s 兜一次
if self.n_cols(st) > 1:
self._step("l", "UNKNOWN 且看起来有多列 → 收列")
else:
self._step("s", "UNKNOWN 且无列可收 → 退出")
continue
self._step("l", "菜单多列 → 收回一级")
raise RuntimeError("回根失败(14 步内没见到单列根菜单)")
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)
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_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):
"""仓库黄金参考(18 页)逐帧分类自检:每张图的期望值 = 它的文件名。
这一组是**语义正确的基线**(模拟器与板端同一份 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
if "--trace" in argv:
TRACE = argv[argv.index("--trace") + 1]
if "--dump-fail" in argv:
FAIL_DIR = argv[argv.index("--dump-fail") + 1]
clf = Classifier()
if "--help" in argv:
print(__doc__)
return 0
if "--list" in argv:
for k in sorted(clf.pages):
print(k, "标题=", [t for t, g in clf.titles.items() if g == k])
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/2] 仓库黄金参考分类(模拟器与板端同一份 UI 源码 = 语义基线)")
nr, badr = selftest_refs(clf)
print("[2/2] 真机留档帧分类(目录缺则整段跳过,不算失败)")
n, bad1 = selftest_board_frames(clf)
print("汇总(快检):黄金参考 %d 帧错 %d;留档帧 %d 帧错 %d" % (nr, len(badr), n, len(bad1)))
print("闭环导航未跑(需 lvgl_sim.exe):完整自检用 --selftest")
return 1 if (badr or bad1 or nr == 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("汇总:黄金参考 %d 帧错 %d;留档帧 %d 帧错 %d;闭环 %d 页失败 %d"
% (nr, len(badr), n, len(bad1), m, len(bad2)))
# 留档帧目录(ci-artifacts/uiwalk,不受版本控制)可以不存在 → 那一段不算失败;
# 但黄金参考与闭环必须真跑过:任一为 0 说明这次"自检"其实什么都没检查
return 1 if (badr or bad1 or bad2 or nr == 0 or m == 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:]))