feat(scripts): board_ui_nav 接入 STATUS/GOTO 并落实排帧流规则①(#69)

- 每键后先读 KEY 回执,再等帧流静默 ≥900ms、双帧一致才判稳取帧:
  docstring 规则①此前代码只 sleep 300ms 单帧,本次补实(#38 调试
  复盘的主错源之一);判稳超上限记 unstable,禁止再基于此盲补键
- STATUS 客户端(字节契约以 ui_net_sink.h 为唯一真源,解析做防御):
  --where 固件身份 + 像素双口径报"现在在哪";--to 到位判定固件身份
  为主、像素为佐,不一致只记账不盲补(不一致=判据或固件有问题,
  先 dump 现场);UNKNOWN 恢复先问 STATUS 定分支——菜单态关列用 l
  不用 s(菜单不响应 s 的历史判据存疑,待台架复核后收口);
  STATUS depth=0 直接判回根,剪贴弹窗不再挡 to_root
- --goto 直达页(仅台架/模拟器固件,产品恒拒),主机侧同步拒绝
  执行类页 id;旧固件(无 STATUS 应答)自动退回纯像素路径,
  向后兼容
- WALK_DIR 与 --walk 默认目录并一(原 ci-artifacts/uiwalk 使
  BOARD_TRUTH 复核看不到新 walk 帧,10-31 记过)

无板端到端:窗口态 lvgl_sim(UI_REMO_ALLOW_GOTO)11 步全绿,含
待机→e 唤醒重开最近页的实锤复现。--selftest-quick 66/66、门 7/7。
This commit is contained in:
2026-11-02 22:22:10 +08:00
parent 493fa6ba0e
commit aaf75f7992
+277 -8
View File
@@ -37,6 +37,15 @@
落点是内容页就 `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 大窗反馈后的判据脚本升级)
"""
@@ -70,7 +79,9 @@ 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")
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
@@ -496,6 +507,28 @@ class SimDevice(object):
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 帧。
@@ -506,10 +539,17 @@ class BoardDevice(object):
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:
@@ -548,13 +588,13 @@ class BoardDevice(object):
def key(self, ch):
self._send(self.CMD_KEY, bytes([KEYS[ch]]))
# ACK 是空应答:这里**不**试图从应答里读状态(读不到才是本次改造的起因)
# ACK 是空应答:读掉它是不让空帧混进后面的排空窗口冒充"新帧"。#69 前这里是
# sleep(0.30)+单帧 grab——正是规则①点名的 N−1 屏坑,现在整套换成 settle(⑤)。
try:
self._read_frame(time.time() + 2.0)
except (TimeoutError, ConnectionError):
pass
time.sleep(0.30)
return self.grab()
return self.grab_settled()
def boot(self):
return self.grab()
@@ -567,6 +607,90 @@ class BoardDevice(object):
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
# ---------------------------------------------------------------- 闭环导航
@@ -591,9 +715,64 @@ class Navigator(object):
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 = classify(fr, self.clf)
st = self._merge_ident(fr, classify(fr, self.clf))
st["ink"] = fr.ink
return fr, st
@@ -608,9 +787,13 @@ class Navigator(object):
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"]})
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):
@@ -661,6 +844,16 @@ class Navigator(object):
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 st["kind"] == "MENU" and self.n_cols(st) == 1:
# "只有一列"不等于"在根":子列弹框被屏幕裁掉时它的选带根本不在可扫窗口
# 里,n_cols 会漏数它——于是上一轮留在弹框里的选中项还在,直接按 e 就
@@ -814,6 +1007,36 @@ class Navigator(object):
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
# ---------------------------------------------------------------- 自检与入口
@@ -1253,6 +1476,52 @@ def main(argv):
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: