diff --git a/.dsh/skills/board-ui-nav/SKILL.md b/.dsh/skills/board-ui-nav/SKILL.md new file mode 100644 index 00000000..bf011fdf --- /dev/null +++ b/.dsh/skills/board-ui-nav/SKILL.md @@ -0,0 +1,73 @@ +--- +name: board-ui-nav +description: 板端 UI 交互的唯一正确入口:先问固件"我在哪"(STATUS 身份查询),再决定按键;到页用闭环 --to 或 bench 直开 --goto。附 #69 实证的四类走错页根因与恢复配方。 +whenToUse: 需要上真机/台架进某页面看图(如故障录波、回放、定值)、在板端做 UI 取证、或从错误页面/待机态/未知屏恢复时。**写任何注键/取帧代码之前先读本技能**。 +--- + +# 板端 UI 导航(唯一入口 = scripts/board_ui_nav.py) + +一句话纪律:**一切"进页面看图"都走 `scripts/board_ui_nav.py`,禁止另写一次性注键脚本。** +2026-11-02 #38 调试里"总是进错误页面浪费大量时间"的直接原因,就是临时手搓 +`board_walk*.py` 盲按 `d*9`——而闭环导航器 2026-10-25 起就已存在(10-25 盲走事故 +9 个目标只有 3 个落对,`docs/UI设计/UI_自动化测试流程.md` §7.1)。 + +## 为什么不能自己搓(#69 复盘四类根因,全部有实证) + +1. **协议旧版零身份反馈**:KEY 的 ACK 是空帧,"发了 5 个键"≠"到了第 5 层"。(已修: + 7003 新增 STATUS 命令,固件直读 `s_depth/s_path/s_sel` + 当前内容页 id,见下) +2. **N−1 屏坑**:KEY ACK 与 LCDMEM 推流共用同一条 TCP,`sleep(0.30)` 就抓帧拿到的是 + 上一屏(规则①:排空到帧流静默 ≥900 ms;现在 `key()` 代码已真正执行排空+双帧比对)。 +3. **物理陷阱**:待机态按 `e` 是**重开最近页**不是进菜单;级联列光标记忆上次行不归零; + 根列最后两行的子列弹框被 160×160 屏裁掉(selftest/runlog 因此"读不到选带")。 +4. **认屏误判**:菜单子列标签与页标题同字(记录查询子列里的 事故记录/操作记录…), + 像素分类器会把级联菜单判成 PAGE——**有 STATUS 时以 STATUS 为准**,trace 留痕不一致。 + +## 命令(python 一律 .venv;真机动作必须已持 bench 锁并加 --i-hold-bench-lock) + +```powershell +# 我在哪?(固件身份 + 像素双口径,不一致会留痕打印) +.venv\Scripts\python.exe scripts\board_ui_nav.py --where --i-hold-bench-lock + +# 到页:闭环步行(像素态设备/老固件也能用;每键重认屏,绝不猜步数) +.venv\Scripts\python.exe scripts\board_ui_nav.py --board --to wave_play --grab C:\temp\wx --i-hold-bench-lock + +# 到页:bench 固件直开页(一次往返直达,不注键;见下"直开页的边界") +.venv\Scripts\python.exe scripts\board_ui_nav.py --goto wave_play --grab C:\temp\wx --i-hold-bench-lock + +# 提交门禁快检(无台架、无模拟器产物也要绿) +.venv\Scripts\python.exe scripts\board_ui_nav.py --selftest-quick +``` + +`--list` 列出全部合法页 id 与菜单路径;`--trace 路径` 落步迹 jsonl(排错先看它)。 +退出码:0 成功 / 1 分类或导航失败 / 2 环境错误 / 3 拒动(未持锁确认位等)。 + +## 身份回读与直开页(#69 新增,两端共享 ui_net_sink.h 契约) + +- **STATUS(命令 4)**:应答 `[ver][kind][depth][n][id_len][path][sel][page id]`; + kind = MENU/PAGE/STANDBY(待机优先判)。老固件不应答 → 主机打印一次后自动退回纯像素。 + 生产固件也支持 STATUS(只读、零画面副作用)。 +- **GOTO(命令 5)**:**编译期双闸** `DTU_BENCH`(临时 bench 固件)或模拟器宏 + `UI_REMO_ALLOW_GOTO`;生产固件恒拒 `status=4`。拒绝码:1 无此页 id / 2 执行类页 / + 3 开页失败 / 5 待机态(先按键唤醒再来)。直开按查看权限 `UI_PERM_VIEW` 进入, + **不弹密码**——所以它只用于调试取证,不能当作"过密码"的旁路写路径。 +- 执行类页面 `clear`/`reset`(第二次 ENTER 即真执行)主机与固件**双侧**默认拒绝, + `--allow-exec-pages` 才放行,放行 = 接受后果。 + +## 走错页 / 未知屏恢复配方(按序,不跳步) + +1. `--where` 先看 STATUS:`PAGE` → 按 `s` 关页(导航器 `--to` 自动做); + `STANDBY` → 按 `e` 唤醒(注意规则②:home 上 `e` 可能重开最近页); + `MENU depth>0` → 按 `s` 或 `l` 都收一级(台架复核 2026-11-02:菜单态 `s` 与 `l` 同走 + `menu_back`,父列选中留在原地;旧说法"`s` 不收列"系把待机态 `s` 无效误读成菜单行为)。 +2. STATUS 无应答(老固件)才吃退避键序(默认 `esl`,`--backoff-keys sl` 可去 ENTER); + ENTER 上限 1 次是硬闸(BACKOFF_MAX_E),报停机就 dump 帧人眼看,不许连按。 +3. 反复落错页且 trace 里出现"像素与 STATUS 不一致" → 分类器或固件有账要记, + 把留痕帧 `--dump-fail` 存下交 Lead,**不要**改判据迁就现状。 + +## 边界 + +- 本脚本不申请锁、不碰串口与烧录;锁的取还见 task-worktree 技能 + (`lock acquire bench --task `)。 +- 台架证据只进 `ci-artifacts/`(`--walk` 默认 `ci-artifacts/uinav/walk/`,与 BOARD_TRUTH + 读取目录已并一),不进工作树。 +- UI 源码改动的前后回归是 ui-verify 技能(5 门禁),本技能只管"人到板上去看"。 diff --git a/AGENTS.md b/AGENTS.md index 4fa5fe4e..838f6619 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -185,6 +185,7 @@ | 烧录与复位语义 | `docs/测试说明/测试工具/ST-LINK_CLI_复位使用说明.md` | | 串口助手 | **`serial-assistant` skill**(命令/判据/陷阱,用到自动加载)、`docs/测试说明/测试工具/串口助手使用指南.md`、`串口助手设计说明.md` | | UI 架构 / 模拟器 / UI 自动化回归 | **`lvgl-simulator` skill**(构建/自检/报错速查,用到自动加载)、`docs/UI设计/UI_设计架构.md`、`LVGL模拟器部署与使用说明.md`、`UI_自动化测试流程.md`;UI 改动回归走 **`ui-verify` skill** | +| 板端进页看图 / 注键 / 认屏 / 直开页(真机 UI 交互) | **`board-ui-nav` skill**(唯一入口,**写任何注键脚本之前先读**)、`scripts/board_ui_nav.py`(STATUS 身份回读 + `--where`/`--goto`),`UI_自动化测试流程.md` §7 | | 待办与审计发现登记簿 | `docs/任务规划/README.md` | | 多任务并行开发(开空间 / 合并回收 / 台架与构建排队);**DSH worktree 插件的仓内树怎么接上**(`.dsh-worktrees/` 缺 `.venv`、`plugin adopt`、锁归属 `pw:`) | **`task-worktree` skill**(`@task` 仅为文档别名非可执行命令,显式调用 `/task-worktree`,用到自动加载;插件桥 → 其 F 节)、`docs/环境与部署/并行任务工作树方案.md` §3.4 | | 换机 / 工具路径环境变量 | `docs/环境与部署/` | diff --git a/Drivers/BSP/UI/ui_content_page.c b/Drivers/BSP/UI/ui_content_page.c index 2a49f153..7bfc062a 100644 --- a/Drivers/BSP/UI/ui_content_page.c +++ b/Drivers/BSP/UI/ui_content_page.c @@ -34,6 +34,8 @@ * 描述符指针、s_open 变指针(NULL = 未打开);7 个页面头包含移交 * ui_page_registry.c;日志页身份改打描述符 id(ASCII 页名,比原 * id=%u 更可定位);绘制/按键/几何一律未动 + * 2026-11-02 DTU 项目组 新增 ui_content_page_current_desc(#69 板端导航身份查询: + * 页 id 从"只有串口开/关沿可见"升级为可按帧查询) */ /* 头文件包含区 */ @@ -599,3 +601,15 @@ uint16_t ui_content_page_selected_row(void) { } return s_sel; } + +/* + * 函数功能:查询当前打开页面的描述符(契约见 ui_content_page.h,Issue #69) + * 入口参数:无 + * 返回值:当前页面描述符指针(Flash 常量,只读);无页打开 = NULL + * 限定条件:同上下文(uiTask)读取才有一致性语义;本函数零副作用、不打日志 + * 函数说明:与 ui_content_page_is_open 的区别只在于顺带交出身份(desc->id), + * 推流 STATUS 用它把"开着哪一页"随帧带回,不再只有串口开/关沿可见 + */ +const content_page_def_t *ui_content_page_current_desc(void) { + return s_open; +} diff --git a/Drivers/BSP/UI/ui_content_page.h b/Drivers/BSP/UI/ui_content_page.h index 5a936200..6b566633 100644 --- a/Drivers/BSP/UI/ui_content_page.h +++ b/Drivers/BSP/UI/ui_content_page.h @@ -124,6 +124,15 @@ void ui_content_page_invalidate(void); */ bool ui_content_page_is_open(void); +/* + * 函数功能:查询当前打开页面的描述符(身份查询/调试消费,Issue #69) + * 入口参数:无 + * 返回值:当前页面描述符指针(Flash 常量,只读);无页打开 = NULL + * 限定条件:与框架同上下文(uiTask)读取才有一致性语义 + * 函数说明:消费方(推流 STATUS)据 desc->id 回答"开着哪一页";零副作用、不打日志 + */ +const content_page_def_t *ui_content_page_current_desc(void); + /* * 函数功能:查询当前打开的页面是否可编辑 * 入口参数:无 diff --git a/Drivers/BSP/UI/ui_menu.c b/Drivers/BSP/UI/ui_menu.c index 1d691431..776340e7 100644 --- a/Drivers/BSP/UI/ui_menu.c +++ b/Drivers/BSP/UI/ui_menu.c @@ -283,3 +283,23 @@ int ui_menu_init(void) { (unsigned)(mon.total_size - mon.free_size)); return 0; } + +/* + * 函数功能:读取菜单导航状态(契约见 ui_menu.h,Issue #69) + * 入口参数:p_depth / p_path / p_sel - 三个只读出参,任一可为 NULL 表示不取 + * 返回值:无 + * 限定条件:同上下文(uiTask)读取;本函数零副作用、不打日志(每帧查询不刷日志) + * 函数说明:直接交还静态数组指针(导航状态本身仅 17 B,拷贝反而是第二份要同步的 + * 事实);消费方按"深度+1"项数读取,越界由消费方的深度钳位负责 + */ +void ui_menu_get_nav_state(uint8_t *p_depth, const uint8_t **p_path, const uint8_t **p_sel) { + if (p_depth != NULL) { + *p_depth = s_depth; + } + if (p_path != NULL) { + *p_path = s_path; + } + if (p_sel != NULL) { + *p_sel = s_sel; + } +} diff --git a/Drivers/BSP/UI/ui_menu.h b/Drivers/BSP/UI/ui_menu.h index 3a4e5eb7..c730c289 100644 --- a/Drivers/BSP/UI/ui_menu.h +++ b/Drivers/BSP/UI/ui_menu.h @@ -11,10 +11,14 @@ * 2026-09-02 王建锋 弃用已废弃的 lv_menu 控件,改为基元控件页面导航 * 2026-09-04 王建锋 重构为"横向级联框"模型(菜单页面设计.md):拆出显示层 * ui_menu_view 与数据层 ui_menu_tab;本层只留导航状态机 + * 2026-11-02 DTU 项目组 新增 ui_menu_get_nav_state(#69 板端导航身份查询: + * 主机侧不再只靠像素认屏猜"在哪一列选中哪一项") */ #ifndef UI_MENU_H #define UI_MENU_H +#include + #ifdef __cplusplus extern "C" { #endif @@ -31,6 +35,20 @@ extern "C" { */ int ui_menu_init(void); +/* + * 函数功能:读取菜单导航状态(身份查询/调试消费的只读快照,Issue #69) + * 入口参数:p_depth - 输出当前深度(0 = 根框) uint8_t *,可为 NULL + * p_path - 输出路径数组指针(节点下标) const uint8_t **,可为 NULL + * p_sel - 输出各层选中下标数组指针 const uint8_t **,可为 NULL + * 返回值:无 + * 限定条件:与导航同上下文(uiTask/模拟器主循环)调用才有一致性语义; + * ui_menu_init() 前调用返回初始零值,不崩 + * 函数说明:输出指针指向本模块静态数组(共 17 B 导航状态,见设计 §7.2), + * 调用方只读、最多读"深度+1"项(上限 MENU_PAGE_MAX)、不得改写; + * 弹框被屏裁时像素认屏看不见选带,本接口是主机侧唯一的菜单真值出口 + */ +void ui_menu_get_nav_state(uint8_t *p_depth, const uint8_t **p_path, const uint8_t **p_sel); + #ifdef __cplusplus } #endif diff --git a/Drivers/BSP/UI/ui_net_sink.c b/Drivers/BSP/UI/ui_net_sink.c index c213035f..72ca5d33 100644 --- a/Drivers/BSP/UI/ui_net_sink.c +++ b/Drivers/BSP/UI/ui_net_sink.c @@ -39,6 +39,9 @@ * 在往返边界续命。钩子未注册 = 零行为变化(模拟器/黄金 * 回归不注册)。判读与设计口径见 * docs/UI设计/UI_设计架构.md §5.8 + * 2026-11-02 DTU 项目组 #69:新增 STATUS(4) 身份查询应答(kind+菜单路径/选中+页 id, + * v1 契约在 ui_net_sink.h)与 GOTO(5) 直开页(台架/模拟器构建, + * DTU_BENCH / UI_REMO_ALLOW_GOTO 门控;产品构建应答 NOSUPP) */ #include "ui_net_sink.h" @@ -50,6 +53,12 @@ #include "ui_tokens.h" /* 屏幕几何令牌(UI_SCREEN_W/H、UI_FB_SIZE 权威在 ui_port.h) */ #include "test_config.h" /* TEST_LATENCY_PROBE(链路时延探针编译开关) */ #include "net_latency_test.h" /* M3 探针:prod_cycle_mcu(LCDMEM 处理净成本) */ +#include "ui_menu.h" /* 菜单导航状态只读快照(STATUS 身份查询,#69) */ +#include "ui_menu_view.h" /* MENU_PAGE_MAX(STATUS 深度钳位上界) */ +#include "ui_content_page.h" /* 当前内容页描述符(STATUS 页 id,#69) */ +#include "ui_standby.h" /* 待机判定(STATUS)/ 待机拒开(GOTO) */ +#include "ui_page_registry.h" /* 叶节点→页注册表(GOTO 按 id 直开,#69) */ +#include "ui_pwd_def.h" /* UI_PERM_VIEW(GOTO 授只读权限,#69) */ /* (2026-10-02):发送短写/失败告警(MCU=控制台日志,模拟器=printf 桩) */ #define DBG_TAG "[UI_NET_SINK]" @@ -62,6 +71,8 @@ #define CMD_INIT UI_REMO_CMD_INIT #define CMD_KEY UI_REMO_CMD_KEY #define CMD_LCDMEM UI_REMO_CMD_LCDMEM +#define CMD_STATUS UI_REMO_CMD_STATUS +#define CMD_GOTO UI_REMO_CMD_GOTO /* 屏幕尺寸与帧大小取 ui_tokens.h / ui_port.h 的屏幕令牌(160x160 1bpp = 3200 字节), * 本模块不再自带同值字面量——显示几何与协议几何必须是同一个事实 */ @@ -71,6 +82,7 @@ static uint8_t g_tx[4 + 4 + UI_FB_SIZE + 1]; /* 发送组帧缓冲:[TAG][CMD][LEN][DATA][CRC] */ static uint8_t g_lcdmem_data[4 + UI_FB_SIZE]; /* LCDMEM 应答缓冲:4B 偏移 + 1bpp 位图 */ +static uint8_t g_status_data[32]; /* STATUS 应答缓冲:最大 28 B,见应答注释 */ static uint8_t g_rx[RM_RX_BUF]; /* 接收暂存区:整块 recv 后逐字节喂入状态机 */ static int g_listen = -1; /* 监听 Socket fd(未启动或已停止时为 -1) */ @@ -343,6 +355,122 @@ static void remo_handle(const uint8_t *f, int n) { } break; } + case CMD_STATUS: { + /* 身份查询 v1(#69,2026-11-02):一帧回答"现在在哪个画面"—— + * kind + 菜单路径/选中 + 页 id。本模块由 uiTask 轮询驱动(宿主契约 + * 见 ui_net_sink.h poll 文档),同上下文读静态导航状态,免锁、只读、 + * 零画面副作用。布局:[ver][kind][depth][n][id_len][path n B][sel n B] + * [页 id ASCII id_len B];上界 5 + 2n(≤8) + id_len(≤15) = 28 ≤ 32 */ + uint8_t depth = 0u; + const uint8_t *path = NULL; + const uint8_t *sel = NULL; + const content_page_def_t *desc; + uint8_t npath; /* 帧内 [n] 字段:path/sel 公共长度 */ + uint8_t id_len; + uint8_t kind; + uint16_t i; + uint16_t p; + + ui_menu_get_nav_state(&depth, &path, &sel); + if (depth >= MENU_PAGE_MAX) { + depth = (uint8_t)(MENU_PAGE_MAX - 1u); /* 防御钳位(正常不可达) */ + } + npath = (uint8_t)(depth + 1u); + desc = ui_content_page_current_desc(); + id_len = 0u; + if (ui_standby_is_active()) { + kind = UI_REMO_ST_STANDBY; /* 待机优先:path 仍为底层菜单态,页 id 不附 */ + } else if (desc != NULL) { + kind = UI_REMO_ST_PAGE; + if (desc->id != NULL) { + size_t l = strlen(desc->id); + id_len = (l > UI_REMO_MAX_ID) ? (uint8_t)UI_REMO_MAX_ID : (uint8_t)l; + } + } else { + kind = UI_REMO_ST_MENU; + } + p = 0u; + g_status_data[p++] = UI_REMO_STATUS_VER; + g_status_data[p++] = kind; + g_status_data[p++] = depth; + g_status_data[p++] = npath; + g_status_data[p++] = id_len; + for (i = 0u; i < npath; i++) { + g_status_data[p++] = path[i]; + } + for (i = 0u; i < npath; i++) { + g_status_data[p++] = sel[i]; + } + if (id_len > 0u) { + memcpy(g_status_data + p, desc->id, id_len); + } + (void)remo_send(CMD_STATUS, g_status_data, (uint16_t)(p + id_len)); + break; + } +#if defined(DTU_BENCH) || defined(UI_REMO_ALLOW_GOTO) + case CMD_GOTO: { + /* 直开页(#69,2026-11-02):按页 id 一步开内容页,替代多步按键盲走 + * (主机侧 scripts/board_ui_nav.py --goto)。仅台架构建(DTU_BENCH) + * 与模拟器(UI_REMO_ALLOW_GOTO)存在;产品构建走下方 NOSUPP 分支。 + * 契约:只读授权——与"过查看密码"的进入态同档;执行页(clear/reset) + * 拒(清单与主机 EXEC_PAGE_IDS 同一事实,动作前拒);待机中先拒 + * (STANDBY),主机按键唤醒后再来——不在此处双份判屏。 + * 应答 [ver][status][node],node = 目标叶节点下标(成功有意义,余 0xFF)。 */ + uint8_t rsp[3]; + char want[UI_REMO_MAX_ID + 1u]; + uint8_t hit; + uint8_t i; + + rsp[0] = UI_REMO_STATUS_VER; + rsp[2] = 0xFFu; + if ((len == 0u) || (len > UI_REMO_MAX_ID)) { + rsp[1] = UI_REMO_GOTO_NO_ID; + (void)remo_send(CMD_GOTO, rsp, 3u); + break; + } + memcpy(want, data, len); + want[len] = '\0'; + hit = 0xFFu; /* 哨兵 = 未命中 */ + for (i = 0u; i < (uint8_t)UI_PAGE_MAP_COUNT; i++) { + if (strcmp(g_ui_page_map[i].desc->id, want) == 0) { + hit = i; + break; + } + } + if (hit == 0xFFu) { + rsp[1] = UI_REMO_GOTO_NO_ID; + } else if ((strcmp(want, "clear") == 0) || (strcmp(want, "reset") == 0)) { + rsp[1] = UI_REMO_GOTO_EXEC; /* 执行页拒(清单与主机 EXEC_PAGE_IDS 一致) */ + } else if (ui_standby_is_active()) { + rsp[1] = UI_REMO_GOTO_STANDBY; /* 先按键唤醒再查(主机侧自动处理) */ + } else { + if (ui_content_page_is_open()) { + ui_content_page_close(); /* 先关已开页(open 守卫:同时只开一页) */ + } + if (ui_content_page_open(g_ui_page_map[hit].desc, (uint8_t)UI_PERM_VIEW)) { + rsp[1] = UI_REMO_GOTO_OK; + rsp[2] = g_ui_page_map[hit].node; + /* 身份留痕:开页本身有框架自带 "page open: id=" 边沿日志, + * 这里只补 goto 来源一条,便于台架日志对账 */ + DBG_INFO("ui jump ok id=%s node=%u", want, (unsigned)rsp[2]); + } else { + rsp[1] = UI_REMO_GOTO_FAIL; + } + } + (void)remo_send(CMD_GOTO, rsp, 3u); + break; + } +#else + case CMD_GOTO: { + /* 产品构建:GOTO 编译期剔除,应答 NOSUPP(主机侧据此回退按键走法) */ + uint8_t rsp[3]; + rsp[0] = UI_REMO_STATUS_VER; + rsp[1] = UI_REMO_GOTO_NOSUPP; + rsp[2] = 0xFFu; + (void)remo_send(CMD_GOTO, rsp, 3u); + break; + } +#endif default: /* 未知命令码:静默丢弃,保持连接等待后续帧 */ break; diff --git a/Drivers/BSP/UI/ui_net_sink.h b/Drivers/BSP/UI/ui_net_sink.h index 1b031d56..4520c738 100644 --- a/Drivers/BSP/UI/ui_net_sink.h +++ b/Drivers/BSP/UI/ui_net_sink.h @@ -9,6 +9,9 @@ * 2026-08-29 王建锋 修正监听端口为 7003,与 DTU-RemoteLCD 客户端默认端口对齐 * 2026-10-25 DTU 项目组 F3:新增 ui_net_sink_set_beat_cb()(看门狗心跳 * 回调注入,往返返回点喂位;未注册 = 零行为变化) + * 2026-11-02 DTU 项目组 #69:新增 STATUS(4) 身份查询与 GOTO(5) 直开页命令 + * (协议契约 UI_REMO_STATUS_VER / UI_REMO_ST_* / + * UI_REMO_GOTO_*,v1;主机侧解析器 = scripts/board_ui_nav.py) */ #ifndef UI_NET_SINK_H #define UI_NET_SINK_H @@ -26,6 +29,27 @@ extern "C" { #define UI_REMO_CMD_INIT 1 #define UI_REMO_CMD_KEY 2 #define UI_REMO_CMD_LCDMEM 3 +#define UI_REMO_CMD_STATUS 4 /* 身份查询:应答"现在在哪个画面"(v1,2026-11-02) */ +#define UI_REMO_CMD_GOTO 5 /* 直开页:按页 id 一步开内容页(台架/模拟器构建,2026-11-02) */ + +/* STATUS/GOTO 协议契约 v1(#69,2026-11-02;主机侧解析器 = scripts/board_ui_nav.py, + * 两端共用本组常量,禁止各自复制字面量) + * STATUS 应答布局:[ver][kind][depth][n][id_len][path n B][sel n B][页 id ASCII id_len B] + * kind 取值:MENU(菜单屏,path/sel 有效)/ PAGE(内容页开,页 id 尾附)/ STANDBY(待机屏) + * GOTO payload:页 id ASCII(≤ UI_REMO_MAX_ID 字节);应答 [ver][status][node] + * status 取值:OK(node=目标叶节点)/ NO_ID / EXEC(clear、reset 拒)/ FAIL / + * NOSUPP(产品构建,编译期剔除)/ STANDBY(先唤醒再查) */ +#define UI_REMO_STATUS_VER 1u +#define UI_REMO_MAX_ID 15u /* 页 id 字符串上限(现最长 wave_manual 10 字符,留余量) */ +#define UI_REMO_ST_MENU 0u +#define UI_REMO_ST_PAGE 1u +#define UI_REMO_ST_STANDBY 2u +#define UI_REMO_GOTO_OK 0u +#define UI_REMO_GOTO_NO_ID 1u +#define UI_REMO_GOTO_EXEC 2u +#define UI_REMO_GOTO_FAIL 3u +#define UI_REMO_GOTO_NOSUPP 4u +#define UI_REMO_GOTO_STANDBY 5u /* UI 推流监听端口(须与 DTU-RemoteLCD 客户端默认端口 7003 一致) */ #define UI_NET_SINK_PORT 7003 @@ -48,7 +72,7 @@ int ui_net_sink_start(uint16_t port); * 限定条件:ui_net_sink_start() 已成功调用 * 函数说明:1. 无客户端时先门控(net_listener_armed,纯 RAM 读)再尝试接受连接 * 2. 有客户端时先门控(net_can_read)再用 net_recv_nb 非阻塞收帧并解析 - * (KEEPLIVE/INIT/KEY/LCDMEM);门控判为无数据时仍每 32 轮兜底收一次 + * (KEEPLIVE/INIT/KEY/LCDMEM/STATUS/GOTO);门控判为无数据时仍每 32 轮兜底收一次 * (DONTWAIT 已转正为具名 API;uiTask 受 IWDG 监控, * 非阻塞是契约义务——见 net_socket.h net_recv 文档契约) * 3. 对端关闭或异常时复位客户端,等待下次重监听 diff --git a/docs/UI设计/UI_自动化测试流程.md b/docs/UI设计/UI_自动化测试流程.md index 6945c989..2836aee0 100644 --- a/docs/UI设计/UI_自动化测试流程.md +++ b/docs/UI设计/UI_自动化测试流程.md @@ -130,6 +130,12 @@ cmake --build simulator/build --config Debug # 0) 构建模拟器(须 0 警 ——写路径判据(批 5)因此必须把「过密码」当显式状态处理,口径见 §7.5。 - 结论:判据脚本只能信画面。脚本因此**每按一键重新认屏**再决定下一个键,且「到达」只由 **页面身份**裁定;中途任何假设都写进轨迹,绝不当成结论。 +- **2026-11-02 #69 复犯与收口**:修 #38(回放页枚举恒空)的会话里又走了弯路——当时没有走 + 本脚本,而是临时手搓 `board_walk*.py` 盲按,落错页后反复重来,浪费大量调试时间。四条根因 + (协议无身份回读 / `sleep(0.30)` 抓帧拿到 N−1 屏 / 待机·光标·被裁弹框三个物理陷阱 / 技能 + 路由缺位)与成熟项目对照(accessibility 树优先于截图、auto-wait 断言、可复用工作流)见 + issue #69 评论区;前两条已在代码里收口(§7.6 的 STATUS 与排空双帧),后两条靠 + **`board-ui-nav` skill** 的「唯一入口」纪律兜住。 ### 7.2 认屏的三条判据(精确字形匹配,复用同一份 `ui_font_cn_12`) @@ -141,7 +147,7 @@ cmake --build simulator/build --config Debug # 0) 构建模拟器(须 0 警 另两条实测纪律(理由写死在代码注释里,此处不重述): -- **收级联列按 `l`、不按 `s`**:菜单态按 `s` 不收列,用 `ssssssss` 回根正是深页错位的来源之一; +- **收级联列的键语义已台架复核定型(2026-11-02,#69)**:菜单态按 `s` 与 `l` 同走 `menu_back`,**都收一级**且父列选中留在原位(实测 depth 1→0、sel 不变)——旧断言「菜单态按 `s` 不收列」是把**待机态 `s` 无效**(task-24 实证)误读成了菜单行为;`ssssssss` 盲按回根不可信这个结论仍然成立,不可信的是"不看回读连按",不是键本身。有 STATUS 时按 depth 收(弹框被屏裁时像素 `n_cols` 会漏数、固件 depth 不会),老固件继续按 `l` 收 + 验终点; - 菜单树的 `#P` 注记可能写在 `#page=` **之前**(`网口设置 #P #page=net_cfg`),解析顺序错了就 剥不掉,那一叶的页面 id 也永远认不出来。 @@ -158,6 +164,10 @@ cmake --build simulator/build --config Debug # 0) 构建模拟器(须 0 警 # 只走一页并抓图;--trace 落步迹、--dump-fail 落「认错的那一屏」,--outdir 改存证目录 & '.venv\Scripts\python.exe' scripts/board_ui_nav.py --board --to yx --grab D:/tmp/yx \ --i-hold-bench-lock --trace D:/tmp/uinav_trace.json +# 我在哪(固件 STATUS 身份 + 像素双口径)/ bench 固件直开页(#69,见 §7.6) +& '.venv\Scripts\python.exe' scripts/board_ui_nav.py --where --i-hold-bench-lock +& '.venv\Scripts\python.exe' scripts/board_ui_nav.py --goto wave_play --grab D:/tmp/wx \ + --i-hold-bench-lock ``` - 提交钩子 `uinav-selftest` 跑的是**快检档**(`--selftest-quick`)。为什么不挂完整档:闭环 @@ -186,12 +196,25 @@ cmake --build simulator/build --config Debug # 0) 构建模拟器(须 0 警 身份裁定,所以它会大声报错而不是静默走错页。实测 `自检记录` / `运行记录` 两页在模拟器 上 5 次枚举内没吃到,现场帧见 [popup_clipped](../测试说明/测试数据/boot_log_data/20261025_uinav_popup_clipped.png); - **真机上那层弹框究竟画在哪,留台架窗口取证后再定修法**——这是取证题,不是编码题。 + **真机上弹框位置的取证已完成(2026-11-02 台架,#69)**:菜单步进**依旧**吃不到这两叶 + (末列枚举落到同胞页 `alarm` / `selftest`——但落点现在由 STATUS 如实报出,不再谎称到达); + GOTO 直开两叶都到达且完整渲染(自检记录 ink 4073 / 运行记录 ink 3559,证据在 PR #70 + 台架段)。故 `KNOWN_UNREACHABLE` 对 `--to` 步进**保留**(对称设计的销项条件"真走到" + 尚未满足),已验证的绕行路径是 `--goto`;真机(新固件)侧"回根"改认 STATUS `depth`: + `depth==0` 直接返根、`depth>0` 按 s 逐级收(弹框被裁时像素漏计、固件 depth 不漏), + 旧的"补按 `l` 兜底"仪式只留给无 STATUS 的老固件像素路径。 2. 同一标题的多个显示态(网口设置的 只读 / 编辑 / 保存)在分类里都是同一个页面 id `net_cfg`;要区分得看行内文本,写路径判据需要时再加。 3. 认屏复用黄金参考的文案:改了页面文案而没同步 `simulator/tests/` 的文本断言表,本节脚本 会一起红。那是**有意的耦合**(两处必须同时改),不是脚本脆弱。 4. 快检档只证「认屏认得对」,不证「导航走得对」;后者只有完整档的闭环那一段会响。 +5. **纯像素路径对"子列标签 ≡ 页标题"的级联会误判**:记录查询子列里的 事故记录 / 操作记录 / + 报警记录 / 启动记录 / 遥控记录 / 运行报告 等标签与对应页标题同字,开子列那一屏会被判成 + PAGE——`--selftest` 闭环因此对 记录查询 下 6 叶(五本记录 + 录波回放)恒报「进入 记录查询 + 后不是菜单态而是 PAGE」(2026-11-02 对 master 基线复跑确认为**存量**,非 #69 引入; + `clear`/`reset` 两页的"失败"是执行类硬闸的刻意拒绝,另计)。真机新固件侧 STATUS 会把它 + 纠成 MENU(trace 里留"像素与 STATUS 不一致"痕);模拟器侧无 STATUS,这 8 项维持红灯直至 + 像素判据补上级联上下文——**别把它们当 #69 的回归**。 ### 7.5 过密码的口径(与黄金回归同源,只有一套走法) @@ -200,3 +223,38 @@ cmake --build simulator/build --config Debug # 0) 构建模拟器(须 0 警 (两套走法迟早分叉,而分叉的那一边往往是真机)。导航器**默认只过查看密码**:判据需要写 路径权限时必须显式传 `modify=True`,**不因「能进去」就自动升格成可写**——只读判据偷偷拿到 修改权限,等于把判据的边界让给了实现。 + +### 7.6 身份回读 STATUS 与直开页 GOTO(#69,2026-11-02) + +旧协议四个命令(KEEP/INIT/KEY/LCDMEM)里没有一个是"我是谁",闭环只能靠像素反推——本节 +判据的全部不确定性都源于此。#69 在 7003(RemoDispBus)上补了两条,两端共享同一份契约头 +`Drivers/BSP/UI/ui_net_sink.h`(**布局以该文件注释为唯一权威**,此处只记语义): + +| 命令 | 语义 | 生产固件 | bench 固件 / 模拟器 | +|---|---|---|---| +| STATUS(4) | 直读固件导航态:kind(MENU/PAGE/STANDBY,待机先判)+ `depth/path/sel`(`ui_menu.c` 的 `s_*` 快照,经 `ui_menu_get_nav_state()`)+ 当前内容页 id(`ui_content_page_current_desc()`) | ✅ 支持(只读、零画面副作用) | ✅ | +| GOTO(5) | 按页 id 直开页:查 `g_ui_page_map`,以查看权限 `UI_PERM_VIEW` 开页并回节点号 | ❌ 编译期闸外,恒回 status=4(不支持) | ✅ 受理(闸 = `DTU_BENCH` 或 `UI_REMO_ALLOW_GOTO`) | + +- **主机侧优先问、像素兜底**:`Navigator` 每次认屏先 STATUS、后像素,两边打架以 STATUS 为准 + 并在 trace 留痕;老固件不回 STATUS 时打印一次并永久退回纯像素路径。`--where` 现场打印双 + 口径;`--goto <页 id>` 直开后仍走与 `--to` 相同的终点双验(STATUS + 像素),拒绝码 1 无此 + 页 / 2 执行类页 / 3 开页失败 / 5 待机态。 +- **KEY 后稳帧已代码化**(规则①不再是纸面纪律):`key()` = 读掉 ACK → 排空到帧流静默 + ≥900 ms(上限 5 s)→ 连取两帧比对,不等重试至多 8 次并在 trace 标 `stable=false` + (时钟页秒针恒在走,属预期)。 +- **GOTO 的边界**:只用于调试取证——它按查看权限直达,**不弹密码**,因此不是"过密码"的旁路 + 写路径;`clear`/`reset` 主机与固件双侧默认拒绝(直开即到达可执行态,没有第二段确认的 + 余地)。MCU 侧要拿到 bench 能力须临时给 `MDK-ARM/STM32F407-Demo.uvprojx` 加 `DTU_BENCH` + 宏烧录,测完**必须还原宏并重烧产品构建**(uvprojx 抖动不入库,同构建节律)。 +- **留档帧目录已并一**:`WALK_DIR` 与 `--walk` 存证目录同指 `ci-artifacts/uinav/walk/` + (#69 前指 `ci-artifacts/uiwalk/`,走的新帧 BOARD_TRUTH 段永远读不到,静默"跳过(无 + 留档)")。 +- **实机验证记录(2026-11-02 台架,两档实烧,输出全文贴在 PR #70 的台架证据评论)**: + bench 档(`fw build: Nov 2 2026 22:25:12`)上 STATUS 首答 `{kind=MENU depth=0 path=[0] + sel=[0]}`;GOTO `wave_play` 一次往返到达——#38 那趟"进故障录波看波形"的级联盲走就此降为 + 一条命令;`selftest` / `runlog` 直开渲染完整(见 §7.4 第 1 条取证结论);PAGE 上按 s 回根 + 由 STATUS 证实;级联收列按 s 逐级收回(§7.2 纪律据此改写);根列光标可经 STATUS `sel` + 对位(记录查询 = 根行 6,对齐一击命中再 ENTER)。换回**产品档**(`fw build: Nov 2 2026 + 22:29:56 suite=NONE`,同一提交、未加宏)复烧后:STATUS 照常应答(它不设编译期闸), + GOTO 回 **status=4(不支持)**——同一对命令在生产/bench 两侧的行为差异由真机钉死,不止 + 纸面推理。烧录两轮均过 `/serial-boot-test` 闭环(身份戳匹配 + 回读 `no difference`)。 diff --git a/docs/内存管理/RAM使用分析与优化方案.md b/docs/内存管理/RAM使用分析与优化方案.md index 384ca921..474e7877 100644 --- a/docs/内存管理/RAM使用分析与优化方案.md +++ b/docs/内存管理/RAM使用分析与优化方案.md @@ -47,10 +47,10 @@ dhara 失败类 +84;RO 另 +208,全是该失败类名表)→ `ER_IROM1` | 区域 | 用量 | 容量 | 占比 | |---|---|---|---| -| ER_IROM1 | 277,124 B (270.63 KB) | 1,048,576 B (1024 KB) | 26.4% | -| RW_IRAM1 | 94,336 B (92.12 KB) | 131,072 B (128 KB) | 72.0% | +| ER_IROM1 | 277,384 B (270.88 KB) | 1,048,576 B (1024 KB) | 26.5% | +| RW_IRAM1 | 94,368 B (92.16 KB) | 131,072 B (128 KB) | 72.0% | | RW_CCM | 57,376 B (56.03 KB) | 65,280 B (64 KB) | 87.9% | -| **Flash (ROM 合计)** | **279,176 B (272.6 KB)** | 1,048,576 B (1024 KB) | **26.6%** | +| **Flash (ROM 合计)** | **279,436 B (272.9 KB)** | 1,048,576 B (1024 KB) | **26.6%** | 各区的容量与常量来源: diff --git a/docs/内存管理/RAM预算台账.md b/docs/内存管理/RAM预算台账.md index 88460cfc..4a015430 100644 --- a/docs/内存管理/RAM预算台账.md +++ b/docs/内存管理/RAM预算台账.md @@ -15,16 +15,16 @@ | 区域 | 用量 | 容量 | 占比 | 门禁(≤92%) | |---|---|---|---|---| -| ER_IROM1 (Flash) | 277,124 B | 1,048,576 B | 26.4% | 参考 | +| ER_IROM1 (Flash) | 277,384 B | 1,048,576 B | 26.5% | 参考 | | RW_CCM | 57,376 B | 65,280 B | 87.9% | PASS | | UNINIT_CCM | 48 B | 256 B | 18.8% | 参考 | -| RW_IRAM1 | 94,336 B | 131,072 B | 72.0% | PASS | +| RW_IRAM1 | 94,368 B | 131,072 B | 72.0% | PASS | | 子系统(静态 RW+ZI;除 RTOS 堆在 CCM 外均在 SRAM1) | 现值 | 配额 | 状态 | |---|---|---|---| | 网络 | 13,702 B | 12 KB | ⚠ 超 1.4 KB | | 存储 | 7,072 B | 12 KB | OK | -| UI | 36,441 B | 16 KB | ⚠ 超 19.6 KB | +| UI | 36,473 B | 16 KB | ⚠ 超 19.6 KB | | RTOS 堆 | 57,376 B | 56 KB | 堆=配额 | | 应用 | 26,333 B | 8 KB | ⚠ 超 17.7 KB | | 其它 | 10,780 B | — | 参考 | diff --git a/scripts/board_ui_nav.py b/scripts/board_ui_nav.py index c2244d70..e99cd8f8 100644 --- a/scripts/board_ui_nav.py +++ b/scripts/board_ui_nav.py @@ -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): @@ -652,15 +835,36 @@ class Navigator(object): def to_root(self, limit=14): """回到"只有一列打开"的根菜单。 - 两条实测纪律写在这里: + 两条实测纪律写在这里(第二条已于 2026-11-02 台架复核改写,#69): · 页面态用 s 退(s = 退出页); - · **级联列只能用 l 收**,菜单态按 s 不收列(Lead 大窗实测)——沿用盲走脚本 - 的 ssssssss 回根法,正是深页错位的来源之一。 + · **级联列收级看回读**:有 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 就 @@ -814,6 +1018,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 + # ---------------------------------------------------------------- 自检与入口 @@ -827,7 +1061,11 @@ class Navigator(object): # 理由;把它写成断言而不是评论,是因为下次改 UI 后这条结论也要能被重新检查。 # 已声明的导航限度(不是"允许失败",是**记账**):这两页落在根列最后两行,其子菜单 # 弹框会被屏幕裁掉、选带不在可扫窗口内,"枚举 + 验终点"目前吃不到它们。 -# 依据与现场帧见 docs/UI设计/UI_自动化测试流程.md §7.4;真机上弹框画在哪要等台架取证。 +# 依据与现场帧见 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 = { @@ -1253,6 +1491,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: diff --git a/scripts/ram_baseline.tsv b/scripts/ram_baseline.tsv index 9a098a17..a2d7ecc3 100644 --- a/scripts/ram_baseline.tsv +++ b/scripts/ram_baseline.tsv @@ -14,4 +14,4 @@ date code ro rw zi er_irom1 er_pct iram1 iram1_pct ccm ccm_pct top_rwzi 2026-10-29 238936 29140 1932 141396 268076 25.6 85904 65.5 57376 87.9 heap_4.o=57376;lv_mem_core_builtin.o=16384;lftpd.o=7884;ui_net_sink.o=6770;ui_port.o=6420;nand_ftl.o=6216;sb_link.o=4364;ch395f.o=4162;console_log.o=4132;sys_log_mcu.o=4055 2026-10-29 243552 28784 1972 145388 272336 26.0 89936 68.6 57376 87.9 heap_4.o=57376;lv_mem_core_builtin.o=16384;lftpd.o=7884;ui_net_sink.o=6770;ui_port.o=6420;nand_ftl.o=6216;wave_ring.o=5296;ch395f.o=4162;console_log.o=4132;sys_log_mcu.o=4055 2026-10-30 246720 29836 2052 149708 276556 26.4 94336 72.0 57376 87.9 heap_4.o=57376;lv_mem_core_builtin.o=16384;lftpd.o=7884;ui_net_sink.o=6770;ui_port.o=6432;nand_ftl.o=6216;wave_ring.o=5296;sb_link.o=4376;ch395f.o=4162;console_log.o=4132 -2026-11-02 247288 29836 2052 149708 277124 26.4 94336 72.0 57376 87.9 heap_4.o=57376;lv_mem_core_builtin.o=16384;lftpd.o=7884;ui_net_sink.o=6770;ui_port.o=6432;nand_ftl.o=6216;wave_ring.o=5296;sb_link.o=4376;ch395f.o=4162;console_log.o=4132 +2026-11-02 247548 29836 2052 149740 277384 26.5 94368 72.0 57376 87.9 heap_4.o=57376;lv_mem_core_builtin.o=16384;lftpd.o=7884;ui_net_sink.o=6802;ui_port.o=6432;nand_ftl.o=6216;wave_ring.o=5296;sb_link.o=4376;ch395f.o=4162;console_log.o=4132 diff --git a/scripts/size_attestation.json b/scripts/size_attestation.json index 730deffa..0fcadeba 100644 --- a/scripts/size_attestation.json +++ b/scripts/size_attestation.json @@ -1,25 +1,25 @@ { - "built_at": "2026-11-02T18:21:27+08:00", - "fingerprint": "a4435f138bc6dc5fe18779977ec5a2515e15e72ceeecb9653ce2930b640e71c2", + "built_at": "2026-11-02T22:19:57+08:00", + "fingerprint": "19f80087a70f1c74afb1d71fb6d754b4b647321da90b312068144a8f31a9a935", "fingerprint_files": 1466, "fingerprint_source": "git-index", "gated_regions": [ "RW_IRAM1", "RW_CCM" ], - "git_head": "d6253aa974f4357779d0a94ff24ba66a58abf094", + "git_head": "b8e1de5f2e2515b528b0af21bcb8b423a406b8f2", "map": "MDK-ARM/STM32F407-Demo/STM32F407-Demo.map", - "map_mtime": 1793614877, + "map_mtime": 1793629184, "pct": { - "ER_IROM1": 26.43, + "ER_IROM1": 26.45, "RW_CCM": 87.89, - "RW_IRAM1": 71.97, + "RW_IRAM1": 72.0, "UNINIT_CCM": 18.75 }, "regions": { "ER_IROM1": { "max": 1048576, - "size": 277124 + "size": 277384 }, "RW_CCM": { "max": 65280, @@ -27,7 +27,7 @@ }, "RW_IRAM1": { "max": 131072, - "size": 94336 + "size": 94368 }, "UNINIT_CCM": { "max": 256, @@ -37,10 +37,10 @@ "schema": 2, "scope_note": "\u540c\u7801\u51ed\u8bc1\uff1a\u6307\u7eb9\u8986\u76d6\u56fa\u4ef6\u4f53\u79ef\u76f8\u5173\u6e90\u7801\uff0c\u53e3\u5f84\u89c1\u672c\u811a\u672c docstring", "sizes": { - "code": 247288, + "code": 247548, "ro_data": 29836, "rw_data": 2052, - "zi_data": 149708 + "zi_data": 149740 }, "threshold_pct": 92.0, "worktree_dirt_at_stamp": 0, diff --git a/simulator/CMakeLists.txt b/simulator/CMakeLists.txt index 873b3a0e..e226f970 100644 --- a/simulator/CMakeLists.txt +++ b/simulator/CMakeLists.txt @@ -179,6 +179,7 @@ target_compile_definitions(lvgl_sim PRIVATE LV_LVGL_H_INCLUDE_SIMPLE # ui_font_cn_12.c 内部 include 简化 WIN32_LEAN_AND_MEAN # 防止 Windows.h 拉入 winsock.h(与 winsock2.h 冲突) NET_NO_OS # 剥离 net_socket.h 的 FreeRTOS 相关接口(纯 BSD Socket) + UI_REMO_ALLOW_GOTO # 模拟器构建启用 GOTO(5) 直开页(#69,2026-11-02;台架 MCU 门控 DTU_BENCH,见 ui_net_sink.c 注释) ) target_include_directories(lvgl_sim PRIVATE diff --git a/test/host/ui_sink/stub/lvgl.h b/test/host/ui_sink/stub/lvgl.h new file mode 100644 index 00000000..8f9079c3 --- /dev/null +++ b/test/host/ui_sink/stub/lvgl.h @@ -0,0 +1,56 @@ +#ifndef TEST_UI_SINK_STUB_LVGL_H +#define TEST_UI_SINK_STUB_LVGL_H + +/* + * 测试存根 lvgl.h(#69,2026-11-02 新增) + * + * 为什么存在:ui_net_sink.c 新增 STATUS/GOTO 处理(#69 板端导航身份查询)后, + * 间接引用 ui_menu_view.h / ui_content_page.h,两者 include 真实 "lvgl.h"。 + * 本单测只覆盖成帧 / 心跳 / 状态机纯逻辑(见 run_unit_test.py 文件头), + * 运行期不触任何 LVGL,故此处仅提供上述头文件声明处用到的最小类型; + * 不模拟任何 LVGL 行为,也不参与 MCU 构建(stub 目录仅本测试使用)。 + * + * 维护约定:ui_content_page.h / ui_menu_view.h 的声明若新增 lv_* 类型依赖, + * 在此按需补最小 typedef;禁止在此写真实 LVGL 语义来"凑过"编译。 + */ + +#include + +typedef struct _lv_obj_t lv_obj_t; +typedef struct _lv_area_t lv_area_t; +typedef struct _lv_layer_t lv_layer_t; +typedef uint32_t lv_key_t; + +typedef struct { + uint8_t a; + uint8_t r; + uint8_t g; + uint8_t b; +} lv_color_t; + +/* ui_tokens.h 的 UI_COL_* 宏用到;本测试不断言其值,仅需可编译 */ +static inline lv_color_t lv_color_white(void) +{ + lv_color_t c; + c.a = 0xFFu; + c.r = 0xFFu; + c.g = 0xFFu; + c.b = 0xFFu; + return c; +} + +static inline lv_color_t lv_color_black(void) +{ + lv_color_t c; + c.a = 0xFFu; + c.r = 0x00u; + c.g = 0x00u; + c.b = 0x00u; + return c; +} + +/* ui_content_page.h 注释提及;为防后续声明引用,按真实 LVGL 键码给出 */ +#define LV_KEY_LEFT 0x14u +#define LV_KEY_ESC 0x1Bu + +#endif /* TEST_UI_SINK_STUB_LVGL_H */ diff --git a/test/host/ui_sink/ui_sink_test.c b/test/host/ui_sink/ui_sink_test.c index 6331a485..840be4b1 100644 --- a/test/host/ui_sink/ui_sink_test.c +++ b/test/host/ui_sink/ui_sink_test.c @@ -93,6 +93,38 @@ void ui_net_sink_inject_key(uint8_t k) { if (s_key_n < 16) { s_keys[s_key_n++] = k; } } +/* ============ 假件:导航侧(#69 STATUS/GOTO,2026-11-02 新增) ============ + * 本单测不发 STATUS/GOTO 帧,以下仅为链接占位:STATUS 恒为根菜单 + * depth=0、无页、非待机。若后续要断言 STATUS/GOTO 语义,把这些假件 + * 改成可控状态并新增用例,不要动生产分支。 */ +#include "ui_menu.h" +#include "ui_content_page.h" +#include "ui_page_registry.h" +#include "ui_standby.h" + +static const uint8_t s_nav_zero[4] = { 0u, 0u, 0u, 0u }; /* 根菜单 path/sel */ + +void ui_menu_get_nav_state(uint8_t *p_depth, const uint8_t **p_path, + const uint8_t **p_sel) +{ + if (p_depth != NULL) { *p_depth = 0u; } + if (p_path != NULL) { *p_path = s_nav_zero; } + if (p_sel != NULL) { *p_sel = s_nav_zero; } +} +const content_page_def_t *ui_content_page_current_desc(void) { return NULL; } +bool ui_content_page_open(const content_page_def_t *desc, uint8_t perm) +{ + (void)desc; (void)perm; return false; +} +void ui_content_page_close(void) { } +bool ui_standby_is_active(void) { return false; } + +/* 生成表占位:尺寸必须按 ui_page_registry.h 的固定条数(本头文件 extern + * 了 g_ui_page_map[UI_PAGE_MAP_COUNT])。静态零初始化 = 全表 {node=0, + * desc=NULL}:本单测从不发 GOTO 帧,生产的 desc 扫描不会被触达 */ +const ui_page_entry_t g_ui_page_map[UI_PAGE_MAP_COUNT] = { { 0u, + (const content_page_def_t *)0 } }; + /* ===================== 被测代码 ===================== */ #include "ui_net_sink.c"