增加网络配置界面
This commit is contained in:
@@ -7,7 +7,8 @@ project(lvgl_sim C)
|
||||
set(PROJECT_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/..")
|
||||
set(LVGL_DIR "${PROJECT_ROOT}/Lib/lvgl")
|
||||
set(UI_DIR "${PROJECT_ROOT}/Drivers/BSP/UI")
|
||||
set(SDL2_DIR "${CMAKE_CURRENT_SOURCE_DIR}/SDL2/SDL2-2.32.10")
|
||||
set(NETCFG_DIR "${PROJECT_ROOT}/Drivers/BSP/NETCFG")
|
||||
set(SDL2_DIR "${CMAKE_CURRENT_SOURCE_DIR}/SDL2/SDL2-2.32.10")
|
||||
|
||||
#=============================================================================
|
||||
# 编译标准
|
||||
@@ -47,6 +48,7 @@ include_directories(
|
||||
${LVGL_DIR} # lvgl.h
|
||||
${LVGL_DIR}/src # lvgl 内部头文件
|
||||
${UI_DIR} # ui_menu.h, ui_font_cn_12.h
|
||||
${NETCFG_DIR} # net_cfg.h(网络配置,双端共享核心)
|
||||
${PROJECT_ROOT}/Drivers/BSP # dbg_log.h(使用 simulator 的 dbg_cfg.h 配置)
|
||||
${SDL2_DIR}/include # SDL2 头文件
|
||||
)
|
||||
@@ -74,14 +76,22 @@ set(UI_SOURCES
|
||||
${UI_DIR}/ui_page_device_info.c
|
||||
${UI_DIR}/ui_page_ac.c
|
||||
${UI_DIR}/ui_page_dc.c
|
||||
${UI_DIR}/ui_page_password.c
|
||||
${UI_DIR}/ui_page_net_cfg.c
|
||||
${UI_DIR}/ui_menu_tab.c
|
||||
${UI_DIR}/ui_font_cn_12.c
|
||||
)
|
||||
|
||||
# 网络配置(双端共享核心 + 模拟器存储后端;MCU 端编 net_cfg_store_mcu.c,见 Keil 工程)
|
||||
set(NETCFG_SOURCES
|
||||
${NETCFG_DIR}/net_cfg.c
|
||||
${NETCFG_DIR}/net_cfg_store_sim.c
|
||||
)
|
||||
|
||||
#=============================================================================
|
||||
# 构建可执行文件
|
||||
#=============================================================================
|
||||
add_executable(lvgl_sim WIN32 ${SIM_SOURCES} ${UI_SOURCES})
|
||||
add_executable(lvgl_sim WIN32 ${SIM_SOURCES} ${UI_SOURCES} ${NETCFG_SOURCES})
|
||||
|
||||
target_compile_definitions(lvgl_sim PRIVATE
|
||||
LV_CONF_INCLUDE_SIMPLE # 让 lvgl.h 找到 lv_conf.h
|
||||
|
||||
+12
-9
@@ -16,10 +16,11 @@ simulator/
|
||||
├── sim_port.c / sim_port.h # 模拟器显示/输入端口(SDL 窗口 + 键盘),替代 MCU 端 ui_port.c
|
||||
├── lv_conf.h # 模拟器专用 LVGL 配置(与 MCU 端 Lib/lv_conf.h 相互独立)
|
||||
├── i1_to_png.py # 截图转 PNG:把 --screenshot 的 I1(1bpp) 原始位图转成 PNG(见 §3.5)
|
||||
├── tests/ # 无头回归脚本 + 黄金参考(见 §3.6,流程文档 docs/UI 设计/UI_自动化测试流程.md)
|
||||
├── tests/ # 无头回归脚本 + 本地黄金参考(见 §3.6,流程文档 docs/UI 设计/UI_自动化测试流程.md)
|
||||
│ ├── check_menu_rows.py # 菜单 8 状态像素回归 + 池基线门禁
|
||||
│ ├── check_content_pages.py # 内容页黄金参考回归 + 开页-关页往返池门禁(--update 刷参考)
|
||||
│ └── ref/ # 黄金参考截图 shot_{info,ac,dc}.bin(I1 原始位图,随仓库提交)
|
||||
│ ├── check_content_pages.py # 文本断言 + 内容页黄金参考回归 + 往返池门禁(--update 刷参考)
|
||||
│ ├── regen_ref_png.py # 由 ref/*.bin 批量重生成目检 PNG(--scale 6 --invert)
|
||||
│ └── ref/ # 黄金参考 shot_*.bin + 目检 *.png(**本地生成物,不入库**)
|
||||
├── dbg_log.h # 调试日志 stub:DBG_* 映射到 printf(覆盖 MCU 版头文件)
|
||||
├── SDL2/
|
||||
│ └── SDL2-2.32.10/ # SDL2 预编译 SDK(随仓库提供,无需安装)
|
||||
@@ -42,7 +43,7 @@ simulator/
|
||||
| `dbg_log.h` | MCU 版 `dbg_log.h` 依赖 `sys_clock.h`(RTC 时间戳),PC 无法编译。此 stub 把 `DBG_INFO/ERROR/DEBUG` 映射到 `printf`;因 `simulator/` 在 include 路径最前,**优先生效**覆盖 MCU 版 |
|
||||
| `CMakeLists.txt` | 收集 LVGL 源码(`../Lib/lvgl`)+ 共享 UI 源码(`../Drivers/BSP/UI`)+ 本目录端口代码,链接预编译 SDL2,产出 `lvgl_sim.exe` |
|
||||
| `i1_to_png.py` | 把 `--screenshot` 产出的 I1(1bpp) 原始位图转成 PNG,便于离线比对设计稿。详见 §3.5 |
|
||||
| `tests/` | 无头回归脚本(菜单 8 状态像素回归、内容页黄金参考回归)+ 入库黄金参考截图。详见 §3.6 |
|
||||
| `tests/` | 无头回归脚本(菜单 8 状态像素回归、内容页文本断言 + 黄金参考回归);`ref/` 为**本地生成物、不入库**(首次 `--update` 生成)。详见 §3.6 |
|
||||
|
||||
> **共享代码不复制**:`ui_menu.c` / `ui_font_cn_12.c` 直接引用 `Drivers/BSP/UI/` 下的原件,
|
||||
> MCU 与 PC 两端永远同步。**不要**在本目录内复制 UI 源码。
|
||||
@@ -202,16 +203,18 @@ UI 改动的自动化门禁脚本(完整流程见 `docs/UI 设计/UI_自动化
|
||||
```powershell
|
||||
# 仓库根目录执行(前置:本目录已构建出 build/Debug/lvgl_sim.exe)
|
||||
.venv\Scripts\python.exe simulator/tests/check_menu_rows.py # 菜单 8 状态像素回归 + 池基线门禁
|
||||
.venv\Scripts\python.exe simulator/tests/check_content_pages.py # 内容页黄金参考回归 + 开页-关页往返池门禁
|
||||
.venv\Scripts\python.exe simulator/tests/check_content_pages.py # 文本断言 + 内容页黄金参考回归 + 往返池门禁
|
||||
```
|
||||
|
||||
| 脚本 | 原理 | 失败输出 |
|
||||
|------|------|----------|
|
||||
| `check_menu_rows.py` | 键序注入(u/d/e/s)跑 8 个菜单状态,断言选中行白带、非选行黑底白字、框体边框、往返状态逐像素一致;S0 池水位须等于脚本内 `MENU_POOL_BASELINE` | 未过断言清单 + 保留临时截图 |
|
||||
| `check_content_pages.py` | 三内容页(info=`e` / ac=`dee` / dc=`dede`)无头截图与 `tests/ref/shot_*.bin` **逐字节**比对;另校验开页-关页往返池水位 = `POOL_AFTER_ROUNDTRIP` | 差异像素数 + 双方 ink runs 对照 |
|
||||
| `check_content_pages.py` | ① 文本断言(解析字体字形,在截图内精确匹配期望串,不依赖 `ref/`)② 11 内容页无头截图与 `tests/ref/shot_*.bin` **逐字节**比对;另校验开页-关页往返池水位 = `POOL_AFTER_ROUNDTRIP` | 文本未命中项 / 差异像素数 + 双方 ink runs 对照 |
|
||||
|
||||
- **黄金参考更新**(有意改视觉后):先肉眼确认新效果正确,再
|
||||
`.venv\Scripts\python.exe simulator/tests/check_content_pages.py --update` 刷新 `ref/`,随代码提交。
|
||||
- **黄金参考与目检 PNG**(`ref/` 不入库):首次/换机后先生成一次——
|
||||
`.venv\Scripts\python.exe simulator/tests/check_content_pages.py --update` 出 `ref/*.bin`,
|
||||
`.venv\Scripts\python.exe simulator/tests/regen_ref_png.py` 出目检 `ref/*.png`(默认 `--scale 6 --invert`)。
|
||||
有意改视觉时先肉眼确认新效果正确再 `--update`(会先跑文本断言防误刷)。
|
||||
- **池水位常量漂移**:菜单/页面结构变更导致门禁失败时,确认新值合理后再更新脚本内常量并登记流程文档变更记录;无法解释的漂移 = 内存回归 bug。
|
||||
- 退出码均为 0=PASS / 1=断言失败 / 2=环境错误(exe 缺失等)。
|
||||
|
||||
@@ -233,7 +236,7 @@ UI 改动的自动化门禁脚本(完整流程见 `docs/UI 设计/UI_自动化
|
||||
- `LV_USE_THEME_MONO = 1` 且 `sim_port_init` 中调用了 `lv_theme_mono_init`
|
||||
- 主循环调用了 `lv_refr_now(NULL)`(PARTIAL 模式保证首帧)
|
||||
- 没有重复设置 tick(SDL 驱动内部已 `lv_tick_set_cb`,**不要**再 `SDL_AddTimer` + `lv_tick_inc`)
|
||||
4. **中文显示空白**:字体 `ui_font_cn_12` 是子集字体(当前 97 汉字 + ASCII,共 193 字形),缺字渲染为空白。
|
||||
4. **中文显示空白**:字体 `ui_font_cn_12` 是子集字体(当前 111 汉字/符号 + ASCII,共 208 字形),缺字渲染为空白。
|
||||
补字流程见 `docs/UI 设计/UI_中文字体生成与配置.md`(`tools/font_gen/` 两步脚本)。
|
||||
5. **改了 UI 代码但模拟器没变化**:确认改的是 `Drivers/BSP/UI/ui_menu.c`(共享原件)而非本目录副本(本目录无副本),然后重新 `cmake --build`。
|
||||
6. **编码**:UI 源码为 UTF-8(带 BOM),MSVC 已加 `/utf-8`。**不要**用 PowerShell `Set-Content` 等重写含中文的文件(编码铁律,见根目录 `AGENTS.md`)。
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
#include "lvgl.h"
|
||||
#include "sim_port.h"
|
||||
#include "ui_menu.h"
|
||||
#include "net_cfg.h"
|
||||
|
||||
/* 屏幕尺寸(与 MCU 端一致) */
|
||||
#define SIM_SCREEN_W 160
|
||||
@@ -114,6 +115,10 @@ static int run_headless(const char * keys, const char * path)
|
||||
}
|
||||
#endif
|
||||
|
||||
/* 配置 RAM 副本:镜像 netTask 上电流程(net_cfg_load → 工厂默认),
|
||||
* 使网口设置内容页显示与真机一致的工厂默认值 */
|
||||
(void)net_cfg_load();
|
||||
|
||||
if (ui_menu_init() != 0) {
|
||||
return 1;
|
||||
}
|
||||
@@ -198,6 +203,9 @@ int main(int argc, char **argv)
|
||||
lv_sdl_window_set_title(disp, "STM32F4-Base UI Simulator (160x160 1bpp)");
|
||||
}
|
||||
|
||||
/* 配置 RAM 副本:镜像 netTask 上电流程(net_cfg_load → 工厂默认) */
|
||||
(void)net_cfg_load();
|
||||
|
||||
/* 构建菜单 UI(与 MCU 端共用 ui_menu.c) */
|
||||
if (ui_menu_init() != 0) {
|
||||
fprintf(stderr, "ui_menu_init failed\n");
|
||||
|
||||
@@ -1,21 +1,25 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
check_content_pages.py — 三个叶子内容页(装置信息/交流量/直流量)像素黄金参考回归。
|
||||
check_content_pages.py — 叶子内容页像素黄金参考回归 + 文本级断言 + 往返池水位门禁。
|
||||
|
||||
用法:
|
||||
python simulator/tests/check_content_pages.py # 与入库黄金参考逐字节比对
|
||||
python simulator/tests/check_content_pages.py --update # 有意改视觉后刷新黄金参考
|
||||
python simulator/tests/check_content_pages.py # 文本断言 + 与本地黄金参考逐字节比对
|
||||
python simulator/tests/check_content_pages.py --update # 有意改视觉后刷新本地黄金参考
|
||||
前置:simulator 已构建(build/Debug/lvgl_sim.exe 存在);纯标准库,任意 python3。
|
||||
ref/*.bin 为**本地生成物、不入库**(.gitignore):首次/换机后须先 `--update` 生成一次。
|
||||
|
||||
原理:每页用键序注入打开(info=e / ac=dee / dc=dede),无头截图 I1(160x160)
|
||||
与 ref/shot_<page>.bin 逐字节比对——任何像素变化即 FAIL(含字体、几何、颜色)。
|
||||
另做开页-关页往返池水位门禁:`es` 后 used 须等于 POOL_AFTER_ROUNDTRIP。
|
||||
原理:① 文本级断言——解析 ui_font_cn_12.c 字形,把期望字符串(标题/标签/值/页脚)在截图
|
||||
对应 y 带内做精确匹配;不依赖 ref/,先于像素比对运行,失败即中止(防误刷参考)。
|
||||
② 每页用键序注入打开(info=e / ac=dee / dc=dede / pwd=ddeee 等,见 PAGES),无头截图
|
||||
I1(160x160) 与 ref/shot_<page>.bin 逐字节比对——任何像素变化即 FAIL(含字体、几何、颜色)。
|
||||
另做开页-关页往返池水位门禁:`es`(info)与 `ddeeesll`(密码页,ESC 后 ←← 弹出路上压的
|
||||
2 个菜单框回根框,完全还原初始态)后 used 须等于 POOL_AFTER_ROUNDTRIP。
|
||||
|
||||
黄金参考更新流程(有意改视觉时):先肉眼看图确认新效果正确,再 --update 刷新,
|
||||
最后 git diff 检查 ref/ 变化符合预期。误报排查见 docs/UI 设计/UI_自动化测试流程.md。
|
||||
最后 regen_ref_png.py 重生成目检 PNG;误报排查见 docs/UI 设计/UI_自动化测试流程.md。
|
||||
|
||||
退出码:0 全 PASS;1 有页不一致或池水位漂移;2 运行/环境错误(exe 缺失、参考缺失)。
|
||||
退出码:0 全 PASS;1 文本断言失败/有页不一致/池水位漂移;2 运行/环境错误(exe 缺失、参考缺失)。
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
@@ -29,13 +33,72 @@ from pathlib import Path
|
||||
EXE = Path(__file__).resolve().parents[1] / "build" / "Debug" / "lvgl_sim.exe"
|
||||
REF_DIR = Path(__file__).resolve().parent / "ref"
|
||||
|
||||
# (页名, 打开键序):与 ui_menu.c menu_content_page_for() 的节点映射一致
|
||||
# (页名, 注入键序):与 ui_menu.c 节点映射/路由一致(u=上 d=下 l=左 r=右 e=回车 s=ESC);
|
||||
# 密码页 = 受保护叶子(参数定值/整定/参数 node 8,默认密码 123)
|
||||
PAGES = [
|
||||
("info", "e"), # 装置信息(node 1)
|
||||
("ac", "dee"), # 交流量(node 3,实时数据下第 1 项)
|
||||
("dc", "dede"), # 直流量(node 4,实时数据下第 2 项)
|
||||
("info", "e"), # 装置信息(node 1)
|
||||
("ac", "dee"), # 交流量(node 3,实时数据下第 1 项)
|
||||
("dc", "dede"), # 直流量(node 4,实时数据下第 2 项)
|
||||
("pwd", "ddeee"), # 密码页初始态:3 框 000、光标首框
|
||||
("pwe", "ddeee uru"), # 密码页调值+移光标:110、光标第 2 框
|
||||
("pwk", "ddeee uruuruuue"), # 密码页输 123:u / r / u,u / r / u,u,u / e → 关页进空白终点框(菜单屏)
|
||||
("pwc", "ddeee ue"), # 密码页 100 不一致 → 清空重输(回到 000 初始态像素)
|
||||
# 网口设置(node 30,通讯参数下第 2 项,需密码):
|
||||
# dddddd = 根框下移 5 至通讯参数;e 进框;d 选中网口设置;e 进密码页
|
||||
("netcfg", "ddddde de uruuruuue"), # 修改密码 123 → 可编辑态(工厂默认值)
|
||||
("netcfg_ro", "ddddde de e"), # 查看密码 000 → 只读态
|
||||
("netcfg_edit", "ddddde de uruuruuue e"), # 编辑模式:IP 首字节光标反显
|
||||
("netcfg_save", "ddddde de uruuruuue dddd e"), # 保存行 ENTER → 值列 OK
|
||||
]
|
||||
|
||||
# 文本级断言表:页名 -> [(名称, 期望串, y 带, 是否反显)]
|
||||
# 只断言确定的内容;标签与值分列断言(两列 x 不同,不能拼成一串)。
|
||||
# 覆盖本工程新增/易缺字的关键文案(网口设置/地址/掩码/逗号/保存/OK 等)。
|
||||
TEXTS = {
|
||||
"info": [
|
||||
("标题", "装置信息", (0, 24), False),
|
||||
("副标题", "馈线自动化终端", (22, 40), False),
|
||||
("r0名", "终端类型:", (40, 55), False), ("r0值", "F30", (40, 55), False),
|
||||
("r1名", "终端型号:", (55, 70), False), ("r1值", "F30", (55, 70), False),
|
||||
("r2名", "软件版本:", (70, 85), False), ("r2值", "SV0.010", (70, 85), False),
|
||||
("r3名", "硬件版本:", (85, 100), False), ("r3值", "HW0.010", (85, 100), False),
|
||||
("r4名", "软件校验:", (100, 115), False), ("r4值", "4454", (100, 115), False),
|
||||
("r5名", "程序日期:", (115, 130), False), ("r5值", "2024.08.27", (115, 130), False),
|
||||
("页脚", "第 10 页,共 1 页", (140, 160), False),
|
||||
],
|
||||
"netcfg": [
|
||||
("标题", "网口设置", (0, 24), False),
|
||||
("r0名", "IP地址", (24, 38), True), ("r0值", "192.168.001.100", (24, 38), True),
|
||||
("r1名", "子网掩码", (38, 52), False), ("r1值", "255.255.255.000", (38, 52), False),
|
||||
("r2名", "默认网关", (52, 66), False), ("r2值", "192.168.001.001", (52, 66), False),
|
||||
("r3名", "MAC地址", (66, 80), False), ("r3值", "000000000000", (66, 80), False),
|
||||
("r4名", "保存", (80, 95), False), ("r4值", "ENTER", (80, 95), False),
|
||||
("页脚", "第 1 页,共 1 页", (140, 160), False),
|
||||
],
|
||||
"netcfg_ro": [
|
||||
("标题", "网口设置", (0, 24), False),
|
||||
("r0名", "IP地址", (24, 38), True), ("r0值", "192.168.001.100", (24, 38), True),
|
||||
("r1名", "子网掩码", (38, 52), False), ("r1值", "255.255.255.000", (38, 52), False),
|
||||
("r2名", "默认网关", (52, 66), False), ("r2值", "192.168.001.001", (52, 66), False),
|
||||
("r3名", "MAC地址", (66, 80), False), ("r3值", "000000000000", (66, 80), False),
|
||||
("r4名", "保存", (80, 95), False), ("r4值", "ENTER", (80, 95), False),
|
||||
("页脚", "第 1 页,共 1 页", (140, 160), False),
|
||||
],
|
||||
"netcfg_edit": [
|
||||
("标题", "网口设置", (0, 24), False),
|
||||
("r0名", "IP地址", (24, 38), True),
|
||||
("r0值尾", "168.001.100", (24, 38), True), # 首字节 192 被光标反显覆盖
|
||||
("r1名", "子网掩码", (38, 52), False),
|
||||
("r4名", "保存", (80, 95), False),
|
||||
],
|
||||
"netcfg_save": [
|
||||
("标题", "网口设置", (0, 24), False),
|
||||
("r0名", "IP地址", (24, 38), False), ("r0值", "192.168.001.100", (24, 38), False),
|
||||
("r3名", "MAC地址", (66, 80), False),
|
||||
("r4名", "保存", (80, 95), True), ("r4值", "OK", (80, 95), True),
|
||||
],
|
||||
}
|
||||
|
||||
W = H = 160
|
||||
STRIDE = W // 8 # I1:20 字节/行
|
||||
|
||||
@@ -81,6 +144,112 @@ def diff_count(a, b):
|
||||
return n
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 文本级断言(字形模板匹配,独立于像素黄金参考)
|
||||
#
|
||||
# 像素 diff 能抓一切视觉变化,但代价是"任何改动都要刷参考";而"缺字显示空白 /
|
||||
# 标签写错"这类回归用文本断言直接对照期望字符串更稳,且不依赖 ref/。二者互补:
|
||||
# 文本断言先跑,失败即中止(不误刷像素参考)。
|
||||
# ---------------------------------------------------------------------------
|
||||
FONT_C = Path(__file__).resolve().parents[2] / "Drivers" / "BSP" / "UI" / "ui_font_cn_12.c"
|
||||
|
||||
_GLYPH = None
|
||||
|
||||
|
||||
def _parse_font():
|
||||
"""解析 ui_font_cn_12.c,返回 glyph(cp) -> (adv, box_w, box_h, ofs_x, ofs_y, rows)。"""
|
||||
src = FONT_C.read_text(encoding="utf-8-sig")
|
||||
ulists = {}
|
||||
for m in re.finditer(r"static const uint16_t (unicode_list_\d+)\[\] = \{(.*?)\};", src, re.S):
|
||||
ulists[m.group(1)] = [int(x, 16) for x in re.findall(r"0x[0-9a-fA-F]+", m.group(2))]
|
||||
cmaps = []
|
||||
for m in re.finditer(
|
||||
r"\.range_start = (\d+), \.range_length = (\d+), \.glyph_id_start = (\d+),\s*"
|
||||
r"\.unicode_list = ([^,]+), \.glyph_id_ofs_list = ([^,]+), \.list_length = (\d+), "
|
||||
r"\.type = (\w+)", src):
|
||||
cmaps.append((int(m.group(1)), int(m.group(2)), int(m.group(3)), m.group(4).strip(), m.group(7)))
|
||||
gm = re.search(r"glyph_dsc\[\]\s*=\s*\{", src)
|
||||
gend = src.find("\n};", gm.end())
|
||||
dscs = [(int(a), int(b), int(c), int(d), int(e), int(f)) for a, b, c, d, e, f in re.findall(
|
||||
r"\{\.bitmap_index = (\d+), \.adv_w = (\d+), \.box_w = (\d+), \.box_h = (\d+), "
|
||||
r"\.ofs_x = (-?\d+), \.ofs_y = (-?\d+)\}", src[gm.end():gend])]
|
||||
bm = re.search(r"glyph_bitmap\[\]\s*=\s*\{", src)
|
||||
bend = src.find("\n};", bm.end())
|
||||
body = re.sub(r"/\*.*?\*/", "", src[bm.end():bend], flags=re.S)
|
||||
bitmap = bytes(int(x, 16) for x in re.findall(r"0x[0-9a-fA-F]+", body))
|
||||
|
||||
def gid_of(cp):
|
||||
for rs, rl, gs, ul, typ in cmaps:
|
||||
if typ.endswith("SPARSE_TINY"):
|
||||
offs = ulists[ul]
|
||||
if (cp - rs) in offs:
|
||||
return gs + offs.index(cp - rs)
|
||||
elif rs <= cp < rs + rl:
|
||||
return gs + (cp - rs)
|
||||
return 0
|
||||
|
||||
def glyph(cp):
|
||||
ofs, adv, bw, bh, ox, oy = dscs[gid_of(cp)]
|
||||
rows = [[(bitmap[ofs + ((y * bw + x) >> 3)] >> (7 - ((y * bw + x) & 7))) & 1
|
||||
for x in range(bw)] for y in range(bh)]
|
||||
return adv, bw, bh, ox, oy, rows
|
||||
|
||||
return glyph
|
||||
|
||||
|
||||
def _render(s):
|
||||
"""把字符串渲染成相对原点的墨点集合(LVGL 基线对齐:字形顶 = baseline - box_h - ofs_y)。"""
|
||||
global _GLYPH
|
||||
if _GLYPH is None:
|
||||
_GLYPH = _parse_font()
|
||||
cur, ink = 0, set()
|
||||
for ch in s:
|
||||
adv, bw, bh, ox, oy, rows = _GLYPH(ord(ch))
|
||||
for y in range(bh):
|
||||
for x in range(bw):
|
||||
if rows[y][x]:
|
||||
ink.add((cur + ox + x, -bh - oy + y))
|
||||
cur += (adv + 8) >> 4
|
||||
return ink
|
||||
|
||||
|
||||
def _match_text(img, s, band, invert=False):
|
||||
"""在 img 的 y 带内搜索字符串 s 的精确字形渲染,返回命中位置列表。"""
|
||||
ink = _render(s)
|
||||
if not ink:
|
||||
return []
|
||||
xs = [p[0] for p in ink]
|
||||
ys = [p[1] for p in ink]
|
||||
bx1, bx2, by1, by2 = min(xs), max(xs), min(ys), max(ys)
|
||||
|
||||
def px(x, y):
|
||||
if x < 0 or x >= W or y < 0 or y >= H:
|
||||
return 0
|
||||
v = (img[y * STRIDE + (x >> 3)] >> (7 - (x & 7))) & 1
|
||||
return (1 - v) if invert else v
|
||||
|
||||
hits = []
|
||||
for oy in range(band[0] - 6, band[1]):
|
||||
for ox in range(0, W - (bx2 - bx1)):
|
||||
ok = True
|
||||
for ix, iy in ink:
|
||||
if px(ox + ix, oy + iy) != 1:
|
||||
ok = False
|
||||
break
|
||||
if not ok:
|
||||
continue
|
||||
for yy in range(by1, by2 + 1):
|
||||
for xx in range(bx1, bx2 + 1):
|
||||
if (xx, yy) not in ink and px(ox + xx, oy + yy) != 0:
|
||||
ok = False
|
||||
break
|
||||
if not ok:
|
||||
break
|
||||
if ok:
|
||||
hits.append((ox, oy))
|
||||
return hits
|
||||
|
||||
|
||||
def main():
|
||||
update = "--update" in sys.argv[1:]
|
||||
if not EXE.exists():
|
||||
@@ -89,11 +258,34 @@ def main():
|
||||
|
||||
tmp = tempfile.mkdtemp(prefix="dtu_ui_pages_")
|
||||
ok = True
|
||||
|
||||
# 清理手工运行残留的配置存储文件(模拟器工作目录),防"工厂默认值"像素参考被污染
|
||||
stale = Path.cwd() / "sim_netcfg.bin"
|
||||
if stale.exists():
|
||||
stale.unlink()
|
||||
|
||||
# 1) 跑全部页面 + 文本级断言预检(先于像素比对/刷新:失败即中止,避免误刷参考)
|
||||
shots = {}
|
||||
for name, seq in PAGES:
|
||||
ref = REF_DIR / ("shot_%s.bin" % name)
|
||||
shot = os.path.join(tmp, "shot_%s.bin" % name)
|
||||
run_sim(seq, shot)
|
||||
data = open(shot, "rb").read()
|
||||
shots[name] = open(shot, "rb").read()
|
||||
|
||||
for name, _seq in PAGES:
|
||||
for tname, text, band, inv in TEXTS.get(name, []):
|
||||
if not _match_text(shots[name], text, band, inv):
|
||||
ok = False
|
||||
print("[%s] FAIL 文本断言未命中:%s = %r(y 带 %s 反显=%s)"
|
||||
% (name, tname, text, band, inv))
|
||||
if not ok:
|
||||
print("FAIL: 文本级断言未通过(缺字/错标签);中止像素比对(现场保留在 %s)" % tmp)
|
||||
return 1
|
||||
print("文本级断言:%d 条全部命中" % sum(len(TEXTS.get(n, [])) for n, _ in PAGES))
|
||||
|
||||
# 2) 像素黄金参考比对 / 刷新
|
||||
for name, seq in PAGES:
|
||||
ref = REF_DIR / ("shot_%s.bin" % name)
|
||||
data = shots[name]
|
||||
|
||||
if update:
|
||||
REF_DIR.mkdir(exist_ok=True)
|
||||
@@ -102,7 +294,7 @@ def main():
|
||||
continue
|
||||
|
||||
if not ref.exists():
|
||||
print("FAIL: 缺黄金参考 %s(首次运行请加 --update 生成并入库)" % ref)
|
||||
print("FAIL: 缺黄金参考 %s(首次/换机后请加 --update 生成;ref/ 不入库)" % ref)
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
return 2
|
||||
refdata = ref.read_bytes()
|
||||
@@ -117,22 +309,23 @@ def main():
|
||||
print(" 黄金参考 ink runs: %s" % ink_runs(refdata))
|
||||
print(" 有意改视觉?确认新效果正确后运行 --update 刷新参考。")
|
||||
|
||||
# 开页-关页往返池水位门禁(info 页:e 打开、s 关闭回菜单)
|
||||
out = run_sim("es", os.path.join(tmp, "shot_rt.bin"))
|
||||
m = re.search(r"pool after keys: used=(\d+)", out.decode(errors="replace"))
|
||||
if not m:
|
||||
print("FAIL: 往返输出未找到 'pool after keys'")
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
return 2
|
||||
used = int(m.group(1))
|
||||
if update:
|
||||
print("[rt] 开页-关页往返池水位 %d B(--update 不校验,仅提示)" % used)
|
||||
elif used != POOL_AFTER_ROUNDTRIP:
|
||||
ok = False
|
||||
print("[rt] FAIL 往返池水位 %d != 预期 %d(页面结构变更?确认合理后更新常量)"
|
||||
% (used, POOL_AFTER_ROUNDTRIP))
|
||||
else:
|
||||
print("[rt] PASS 开页-关页往返池水位 %d B" % used)
|
||||
# 开页-关页往返池水位门禁(info 页 e/s;密码页 ddee e/s 后 ←← 弹框回根框)
|
||||
for rt_name, rt_seq in (("info", "es"), ("pwd", "ddeeesll")):
|
||||
out = run_sim(rt_seq, os.path.join(tmp, "shot_rt_%s.bin" % rt_name))
|
||||
m = re.search(r"pool after keys: used=(\d+)", out.decode(errors="replace"))
|
||||
if not m:
|
||||
print("FAIL: 往返输出未找到 'pool after keys'(%s %r)" % (rt_name, rt_seq))
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
return 2
|
||||
used = int(m.group(1))
|
||||
if update:
|
||||
print("[rt:%s] 开页-关页往返池水位 %d B(--update 不校验,仅提示)" % (rt_name, used))
|
||||
elif used != POOL_AFTER_ROUNDTRIP:
|
||||
ok = False
|
||||
print("[rt:%s] FAIL 往返池水位 %d != 预期 %d(页面结构变更?确认合理后更新常量)"
|
||||
% (rt_name, used, POOL_AFTER_ROUNDTRIP))
|
||||
else:
|
||||
print("[rt:%s] PASS 开页-关页往返池水位 %d B" % (rt_name, used))
|
||||
|
||||
if update:
|
||||
print("PASS: 黄金参考已刷新(%d 页)" % len(PAGES))
|
||||
@@ -140,7 +333,7 @@ def main():
|
||||
return 0
|
||||
|
||||
if ok:
|
||||
print("PASS: 3 内容页 + 往返池水位全部通过")
|
||||
print("PASS: %d 内容页 + 往返池水位全部通过" % len(PAGES))
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
return 0
|
||||
# 失败时保留临时截图便于排查
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
regen_ref_png.py — 由黄金参考 .bin 批量重生成目检用 PNG(simulator/tests/ref/*.png)。
|
||||
|
||||
背景:ref/*.png 是本地目检调试产物(.gitignore 忽略),**不会**随 .bin 参考自动更新。
|
||||
刷过 `check_content_pages.py --update` 或改了字体/视觉后,必须重跑本脚本,否则 PNG
|
||||
仍是旧渲染(缺字/错位,看似"乱码")。
|
||||
|
||||
用法(仓库根目录;PIL 依赖用 .venv,见 AGENTS.md「Python 环境」):
|
||||
.venv\\Scripts\\python.exe simulator/tests/regen_ref_png.py
|
||||
.venv\\Scripts\\python.exe simulator/tests/regen_ref_png.py --scale 4
|
||||
.venv\\Scripts\\python.exe simulator/tests/regen_ref_png.py --no-invert # 白字黑底(设备原样)
|
||||
|
||||
默认 `--scale 6 --invert`(深字白底,便于目检)。
|
||||
退出码:0 全部成功;1 无 .bin 参考 / 有文件转换失败。
|
||||
"""
|
||||
import argparse
|
||||
import importlib.util
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
HERE = Path(__file__).resolve().parent # simulator/tests/
|
||||
REF_DIR = HERE / "ref"
|
||||
I1_TO_PNG = HERE.parent / "i1_to_png.py" # simulator/i1_to_png.py
|
||||
|
||||
|
||||
def _load_i1_to_png():
|
||||
"""从 simulator/i1_to_png.py 载入转换模块(目录不同,不能直接 import)。"""
|
||||
spec = importlib.util.spec_from_file_location("i1_to_png", I1_TO_PNG)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="由 ref/*.bin 批量重生成目检 PNG")
|
||||
ap.add_argument("--scale", type=int, default=6, help="放大倍数(默认 6)")
|
||||
ap.add_argument("--invert", dest="invert", action="store_true", default=True,
|
||||
help="前景/背景互换:深字白底(默认开,便于目检)")
|
||||
ap.add_argument("--no-invert", dest="invert", action="store_false",
|
||||
help="关闭互换:白字黑底(与设备显示一致)")
|
||||
args = ap.parse_args()
|
||||
|
||||
if not I1_TO_PNG.exists():
|
||||
print("FAIL: 找不到 %s" % I1_TO_PNG)
|
||||
return 1
|
||||
|
||||
bins = sorted(REF_DIR.glob("*.bin"))
|
||||
if not bins:
|
||||
print("FAIL: %s 下没有 .bin 参考(先运行 check_content_pages.py --update)" % REF_DIR)
|
||||
return 1
|
||||
|
||||
i1_to_png = _load_i1_to_png()
|
||||
ok = 0
|
||||
for b in bins:
|
||||
try:
|
||||
out = i1_to_png.i1_to_png(str(b), scale=args.scale, invert=args.invert)
|
||||
print(" %-24s -> %s" % (b.name, Path(out).name))
|
||||
ok += 1
|
||||
except Exception as e: # 单文件失败不中断其余
|
||||
print(" %-24s FAIL: %s" % (b.name, e))
|
||||
|
||||
print("%s: %d/%d 张 PNG 已生成(scale=%d invert=%s)"
|
||||
% ("PASS" if ok == len(bins) else "FAIL", ok, len(bins), args.scale, args.invert))
|
||||
return 0 if ok == len(bins) else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user