diff --git a/simulator/CMakeLists.txt b/simulator/CMakeLists.txt index 82d7a801..29f086ba 100644 --- a/simulator/CMakeLists.txt +++ b/simulator/CMakeLists.txt @@ -80,6 +80,7 @@ set(UI_SOURCES ${UI_DIR}/ui_page_net_cfg.c ${UI_DIR}/ui_menu_tab.c ${UI_DIR}/ui_font_cn_12.c + ${CMAKE_CURRENT_SOURCE_DIR}/ui_net_sink.c ) # 网络配置(双端共享核心 + 模拟器存储后端;MCU 端编 net_cfg_store_mcu.c,见 Keil 工程) @@ -96,6 +97,7 @@ 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 LV_LVGL_H_INCLUDE_SIMPLE # ui_font_cn_12.c 内部 include 简化 + WIN32_LEAN_AND_MEAN # 防止 Windows.h 拉入 winsock.h(与 winsock2.h 冲突) ) target_include_directories(lvgl_sim PRIVATE @@ -110,6 +112,7 @@ target_link_libraries(lvgl_sim lvgl SDL2 SDL2main + ws2_32 ) # 数学库(非 MSVC) diff --git a/simulator/README.md b/simulator/README.md index aeb11f05..deabe13b 100644 --- a/simulator/README.md +++ b/simulator/README.md @@ -14,11 +14,13 @@ simulator/ ├── CMakeLists.txt # 构建脚本(CMake + MSVC,x64) ├── main.c # 入口:SDL 初始化 → LVGL → 菜单 → 主循环 ├── sim_port.c / sim_port.h # 模拟器显示/输入端口(SDL 窗口 + 键盘),替代 MCU 端 ui_port.c +├── ui_net_sink.c # RemoDispBus 推流服务端(Winsock TCP 7003),替代 MCU 端同名文件 ├── 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) │ ├── check_menu_rows.py # 菜单 8 状态像素回归 + 池基线门禁 │ ├── check_content_pages.py # 文本断言 + 内容页黄金参考回归 + 往返池门禁(--update 刷参考) +│ ├── check_remo_sink.py # RemoDispBus 远端推流协议回归(起窗口模拟器 + TCP 取帧 + 参考比对) │ ├── regen_ref_png.py # 由 ref/*.bin 批量重生成目检 PNG(--scale 6 --invert) │ └── ref/ # 黄金参考 shot_*.bin + 目检 *.png(**本地生成物,不入库**) ├── dbg_log.h # 调试日志 stub:DBG_* 映射到 printf(覆盖 MCU 版头文件) @@ -38,7 +40,8 @@ simulator/ | 文件 | 说明 | |------|------| | `main.c` | 程序入口。`SDL_Init(TIMER\|EVENTS)` → `lv_init()` → `sim_port_init(160,160)` → `ui_menu_init()`(共享代码)→ 主循环(SDL 事件 + `lv_timer_handler` + `lv_refr_now`) | -| `sim_port.c` | 创建 SDL 窗口显示设备(`lv_sdl_window_create`)、初始化 **mono 主题**(`lv_theme_mono_init`,1bpp 必需)、创建键盘输入设备并挂默认输入组。**不创建鼠标**(纯键盘导航,与 MCU 端 keypad 一致) | +| `sim_port.c` | 创建 SDL 窗口显示设备(`lv_sdl_window_create`)、初始化 **mono 主题**(`lv_theme_mono_init`,1bpp 必需)、创建键盘输入设备并挂默认输入组。**不创建鼠标**(纯键盘导航,与 MCU 端 keypad 一致)。`sim_port_get_screen_mono()` 经 `SDL_RenderReadPixels` 读回屏幕像素转 1bpp,供推流取帧 | +| `ui_net_sink.c` | RemoDispBus 推流服务端(Winsock TCP 7003,与 MCU 端同名文件协议一致):连接后**不**主动上报 INIT,LCDMEM 取帧调 `sim_port_get_screen_mono` 转 1bpp 回传 | | `lv_conf.h` | 模拟器专用 LVGL 配置。与 MCU 端 `Lib/lv_conf.h` 的关键差异见 §3.3 | | `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` | @@ -198,18 +201,20 @@ cd simulator ### 3.6 无头回归测试(tests/) -UI 改动的自动化门禁脚本(完整流程见 `docs/UI 设计/UI_自动化测试流程.md`)。脚本纯标准库、任意 python3 均可,为环境统一一律用仓库 `.venv`: +UI 改动的自动化门禁脚本(完整流程见 `docs/UI 设计/UI_自动化测试流程.md`)。除 `check_remo_sink.py`(需 Pillow,`.venv` 已含)外均为纯标准库、任意 python3 均可;为环境统一,一律用仓库 `.venv` 运行: ```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_remo_sink.py # RemoDispBus 远端推流协议回归(起窗口模拟器 + TCP 取帧 + 参考比对) ``` | 脚本 | 原理 | 失败输出 | |------|------|----------| | `check_menu_rows.py` | 键序注入(u/d/e/s)跑 8 个菜单状态,断言选中行白带、非选行黑底白字、框体边框、往返状态逐像素一致;S0 池水位须等于脚本内 `MENU_POOL_BASELINE` | 未过断言清单 + 保留临时截图 | | `check_content_pages.py` | ① 文本断言(解析字体字形,在截图内精确匹配期望串,不依赖 `ref/`)② 11 内容页无头截图与 `tests/ref/shot_*.bin` **逐字节**比对;另校验开页-关页往返池水位 = `POOL_AFTER_ROUNDTRIP` | 文本未命中项 / 差异像素数 + 双方 ink runs 对照 | +| `check_remo_sink.py` | 启动**窗口**模拟器(非无头,`CREATE_NEW_PROCESS_GROUP` 防管道阻塞),经 TCP 7003 发 LCDMEM 取 1bpp 帧;断言首帧=LCDMEM(无 INIT 污染)/位图 3200 字节/非平凡;复用 `i1_to_png` 出目检 PNG;与无头参考(`lvgl_sim --screenshot`)逐位比对 <2% 判 MATCH。守护"INIT 污染 / 快照卡死"两个历史缺陷 | 各判据失败原因 + `sim_err.txt` 尾部 + 保留 PNG(默认不删,`--clean` 才删) | - **黄金参考与目检 PNG**(`ref/` 不入库):首次/换机后先生成一次—— `.venv\Scripts\python.exe simulator/tests/check_content_pages.py --update` 出 `ref/*.bin`, @@ -249,7 +254,8 @@ UI 改动的自动化门禁脚本(完整流程见 `docs/UI 设计/UI_自动化 ## 五、边界说明 -- 模拟器**只跑 UI**:不含 CH395F 网络、NAND 存储、ADC、RS-485 等硬件业务, - 也**不含** `ui_net_sink`(推流服务端)——远端推流链路只能在 MCU 上验证 +- 模拟器**只跑 UI**:不含 CH395F 网络、NAND 存储、ADC、RS-485 等硬件业务 +- 模拟器**含** `ui_net_sink`(Winsock 版 RemoDispBus 推流服务端,TCP 7003)——远端看图链路 + 可在模拟器上验证(`simulator/tests/check_remo_sink.py`);CH395F 真实网络栈仍只在 MCU 上验证 - `build/` 为生成物,可随时删除重建(已在 `.gitignore` 中忽略) - `Lib/lvgl/CMakeLists.txt` 仅被本模拟器引用,**不影响** Keil 的 MCU 编译 diff --git a/simulator/main.c b/simulator/main.c index 65b999fa..378b8131 100644 --- a/simulator/main.c +++ b/simulator/main.c @@ -25,6 +25,7 @@ #ifdef _MSC_VER #include + #include #else #include #endif @@ -35,6 +36,7 @@ #include "lvgl.h" #include "sim_port.h" #include "ui_menu.h" +#include "ui_net_sink.h" #include "net_cfg.h" /* 屏幕尺寸(与 MCU 端一致) */ @@ -181,6 +183,12 @@ int main(int argc, char **argv) } } + /* 初始化 Winsock(供 ui_net_sink 使用) */ +#ifdef _MSC_VER + WSADATA wsa; + WSAStartup(MAKEWORD(2, 2), &wsa); +#endif + /* 初始化 SDL2(仅 timer + events,视频由 lv_sdl_window 内部初始化) */ if (SDL_Init(SDL_INIT_TIMER | SDL_INIT_EVENTS) != 0) { fprintf(stderr, "SDL_Init failed: %s\n", SDL_GetError()); @@ -213,6 +221,13 @@ int main(int argc, char **argv) return 1; } + /* 启动 RemoDispBus TCP 监听(端口 7003,与 MCU 端一致) */ + if (ui_net_sink_start(7003) == 0) { + printf("RemoDispBus server listening on port 7003\n"); + } else { + fprintf(stderr, "ui_net_sink_start failed (port 7003)\n"); + } + printf("=== LVGL Simulator Started ===\n"); printf("Screen: %dx%d 1bpp\n", SIM_SCREEN_W, SIM_SCREEN_H); printf("Keyboard: ↑↓←→ navigate, Enter confirm, Escape back\n"); @@ -224,6 +239,9 @@ int main(int argc, char **argv) * 本循环不得再调 SDL_PollEvent,否则会先消费事件队列, * 导致键盘输入永远到不了 LVGL */ while (1) { + /* 轮询 RemoDispBus 协议(非阻塞收发) */ + ui_net_sink_poll(); + /* 驱动 LVGL 定时器与刷新 */ uint32_t sleep_ms = lv_timer_handler(); if (sleep_ms == LV_NO_TIMER_READY) { diff --git a/simulator/sim_port.c b/simulator/sim_port.c index 84119700..3bd765cf 100644 --- a/simulator/sim_port.c +++ b/simulator/sim_port.c @@ -69,3 +69,73 @@ void *sim_port_get_display(void) { return (void *)lv_display_get_default(); } + +/* + * 函数功能:读取当前屏幕内容并转换为 1bpp 单色位图(MSB-first) + * 入口参数:buf - 输出缓冲(调用方分配,≥ 宽*高/8 字节) + * buf_size - buf 容量(字节) + * 返回值:0 - 成功,-1 - 失败 + * 限定条件:sim_port_init() 已成功;须在 SDL 所属线程(主循环线程)调用 + * 函数说明:1. lv_sdl_window_get_renderer 取 SDL 渲染器 + * 2. SDL_RenderReadPixels 读回屏幕实际呈现的 ARGB8888 像素 + * 3. 按红通道阈值 128 判白(=1/墨)/黑(=0),逐行打包为 1bpp MSB-first + * 窗口模式为 I1+PARTIAL,LVGL 显示缓冲系分块且经 I1→ARGB8888 + * 转换,直接读 get_buf_active 不可靠;读回屏幕呈现像素最稳, + * 且完全避开快照离屏重渲染(SDL 驱动下会卡派发循环) + */ +int sim_port_get_screen_mono(uint8_t *buf, uint32_t buf_size) +{ + if (buf == NULL) { + return -1; + } + + lv_display_t *disp = lv_display_get_default(); + if (disp == NULL) { + return -1; + } + + int32_t w = lv_display_get_horizontal_resolution(disp); + int32_t h = lv_display_get_vertical_resolution(disp); + if (w <= 0 || h <= 0) { + return -1; + } + + uint32_t stride = (uint32_t)(w + 7) / 8u; + uint32_t need = stride * (uint32_t)h; + if (buf_size < need) { + return -1; + } + + SDL_Renderer *renderer = (SDL_Renderer *)lv_sdl_window_get_renderer(disp); + if (renderer == NULL) { + return -1; + } + + /* 读回屏幕实际呈现的像素(ARGB8888,4 字节/像素) */ + uint32_t pitch = (uint32_t)w * 4u; + uint8_t *px = (uint8_t *)SDL_malloc((size_t)w * (size_t)h * 4u); + if (px == NULL) { + return -1; + } + + int rc = SDL_RenderReadPixels(renderer, NULL, SDL_PIXELFORMAT_ARGB8888, px, (int)pitch); + if (rc != 0) { + SDL_free(px); + return -1; + } + + /* 先清零(黑像素=0),ARGB8888 内存序 [B,G,R,A],红通道在偏移 2 */ + lv_memset(buf, 0, need); + for (int32_t y = 0; y < h; y++) { + const uint8_t *row = px + (size_t)y * (size_t)pitch; + uint8_t *out = buf + (size_t)y * (size_t)stride; + for (int32_t x = 0; x < w; x++) { + if (row[(size_t)x * 4u + 2u] >= 128u) { + out[(uint32_t)x >> 3] |= (uint8_t)(0x80u >> (x & 7)); + } + } + } + + SDL_free(px); + return 0; +} diff --git a/simulator/sim_port.h b/simulator/sim_port.h index 98f81aea..3455938d 100644 --- a/simulator/sim_port.h +++ b/simulator/sim_port.h @@ -31,6 +31,18 @@ int sim_port_init(int32_t hor_res, int32_t ver_res); */ void *sim_port_get_display(void); +/* + * 函数功能:读取当前屏幕内容并转换为 1bpp 单色位图 + * 入口参数:buf - 输出缓冲(调用方分配,≥ 宽*高/8 字节) + * buf_size - buf 容量(字节) + * 返回值:0 - 成功,-1 - 失败 + * 限定条件:sim_port_init() 已成功;须在 SDL 所属线程(主循环线程)调用 + * 函数说明:经 SDL_RenderReadPixels 读回屏幕实际呈现的 ARGB8888 像素, + * 按红通道阈值 128 判白(=1/墨)/黑(=0),打包为 1bpp MSB-first, + * 与 MCU 端 ui_port_get_framebuffer 的 1bpp 布局一致 + */ +int sim_port_get_screen_mono(uint8_t *buf, uint32_t buf_size); + #ifdef __cplusplus } #endif diff --git a/simulator/tests/check_remo_sink.py b/simulator/tests/check_remo_sink.py new file mode 100644 index 00000000..93948d35 --- /dev/null +++ b/simulator/tests/check_remo_sink.py @@ -0,0 +1,266 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +check_remo_sink.py — 模拟器 RemoDispBus(远端看图)服务端协议回归。 + +验证 simulator 端 ui_net_sink(Winsock TCP 监听,默认 7003)按 RemoDispBus 协议 +正确提供当前 1bpp 屏幕图,供 DTU-RemoteLCD 等远端客户端取图。 + +守护两个历史缺陷(回归门禁,防止回退): + 1. INIT 污染 —— 连接后不得主动上报 INIT 帧,否则客户端首帧(LCDMEM 应答) + 读取整体错位 → 花屏/无图。判据 A 断言首帧必须是 LCDMEM。 + 2. 取帧卡死 —— LCDMEM 取帧不得走 lv_snapshot 离屏重渲染(SDL 窗口 I1+PARTIAL + 下会卡死 lv_draw_dispatch 派发循环 → 无应答超时),须走 SDL_RenderReadPixels + 读回屏幕实际呈现像素。判据 C 断言位图非平凡(有真实内容、非全 0/全 1)。 + +流程: + 1. 清理残留 lvgl_sim;以 CREATE_NEW_PROCESS_GROUP 启动窗口模拟器 + (stdout/stderr→临时文件,断开管道继承;父进程立即返回,不阻塞 shell)。 + 注意:不能用 DETACHED_PROCESS(无控制台会让 SDL 建窗异常,进程活着但 7003 不绑定)。 + 2. 等 7003 端口就绪。 + 3. 连接并发 LCDMEM(offset=0),读设备应答帧,断言 A/B/C(见上)。 + 4. 位图经 i1_to_png 渲染 PNG(默认 5x 最近邻)供目检。 + 5. (默认)另起无头参考(lvgl_sim --screenshot,同一初始主菜单)逐位比对, + <2% 差异判 MATCH —— 强证据:窗口取帧 == 已知正确渲染(连贯 UI,非噪声)。 + 6. 清理进程。 + +用法(仓库任意目录均可运行): + python simulator/tests/check_remo_sink.py # 全量(含参考比对) + python simulator/tests/check_remo_sink.py --no-compare # 跳过参考比对 + python simulator/tests/check_remo_sink.py --clean # 成功后删除临时产物 + python simulator/tests/check_remo_sink.py --port 7003 --scale 6 + +前置:simulator 已构建(build/Debug/lvgl_sim.exe 存在);Pillow 已装(.venv 已含)。 + 本测试依赖 Winsock,实际在 Windows 上运行。 +退出码:0 全 PASS;1 协议/比对断言失败;2 运行/环境错误(exe 缺失、端口未就绪等)。 +""" +import argparse +import os +import shutil +import socket +import subprocess +import sys +import tempfile +import time +from pathlib import Path + +# 本脚本位于 simulator/tests/;simulator 目录与 lvgl_sim.exe 据此定位(任意 cwd 可运行) +SIM_DIR = Path(__file__).resolve().parents[1] +EXE = SIM_DIR / "build" / "Debug" / "lvgl_sim.exe" + +# i1_to_png.py 与本脚本同在 simulator/ 树,加入搜索路径后复用其 1bpp->PNG 转换 +sys.path.insert(0, str(SIM_DIR)) +from i1_to_png import i1_to_png # noqa: E402 (模块级仅导入,PIL 在其内部按需加载) + +W = H = 160 +STRIDE = W // 8 # I1:20 字节/行 +FB = STRIDE * H # 3200 字节(160x160 1bpp) + +TAG_C, TAG_D = 0xAA, 0xBB +CMD_KEEPLIVE, CMD_INIT, CMD_KEY, CMD_LCDMEM = 0, 1, 2, 3 + +NEWGRP = 0x200 # Windows CREATE_NEW_PROCESS_GROUP +REF_DIFF_TOL = 2.0 # 参考比对差异率阈值(%) + + +def _ensure_utf8_console(): + """Windows 控制台默认 GBK,中文会乱码;统一切 UTF-8(不支持则静默跳过)。""" + for stream in (sys.stdout, sys.stderr): + try: + stream.reconfigure(encoding="utf-8") + except (AttributeError, ValueError): + pass + + +def _kill_name(name): + if sys.platform == "win32": + subprocess.run(["taskkill", "/F", "/IM", name + ".exe"], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + else: + subprocess.run(["pkill", "-f", name], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + + +def _start_sim(tmp): + """启动窗口模拟器(stdout/stderr→文件,NEWGRP 断开管道继承,非阻塞)。""" + out = open(os.path.join(tmp, "sim_out.txt"), "wb") + err = open(os.path.join(tmp, "sim_err.txt"), "wb") + kwargs = {} + if sys.platform == "win32": + kwargs["creationflags"] = NEWGRP + p = subprocess.Popen([str(EXE)], stdout=out, stderr=err, + stdin=subprocess.DEVNULL, cwd=str(SIM_DIR), **kwargs) + return p + + +def _wait_port(port, timeout=25.0): + t0 = time.time() + while time.time() - t0 < timeout: + try: + s = socket.create_connection(("127.0.0.1", port), 0.5) + s.close() + return True + except OSError: + time.sleep(0.4) + return False + + +def _crc(d): + c = 0 + for b in d: + c ^= b + return c + + +def _recv_exact(s, n, deadline): + buf = b"" + while len(buf) < n: + if time.time() > deadline: + raise TimeoutError("recv 超时") + chunk = s.recv(n - len(buf)) + if not chunk: + raise ConnectionError("连接被对端关闭") + buf += chunk + return buf + + +def _read_frame(s, deadline): + hdr = _recv_exact(s, 4, deadline) + tag, cmd = hdr[0], hdr[1] + n = (hdr[2] << 8) | hdr[3] + body = _recv_exact(s, n + 1, deadline) + data, crcb = body[:n], body[n] + ok = (tag == TAG_D) and (crcb == _crc(data)) + return cmd, data, ok + + +def _bits(data): + return sum(bin(b).count("1") for b in data) + + +def _dump_err(path, tag): + if os.path.exists(path): + txt = open(path, encoding="utf-8", errors="replace").read().strip() + if txt: + print(" --- sim_err.txt(%s)---" % tag) + for ln in txt.splitlines()[-15:]: + print(" ", ln) + + +def main(argv=None): + _ensure_utf8_console() + ap = argparse.ArgumentParser(description="模拟器 RemoDispBus 服务端协议回归") + ap.add_argument("--port", type=int, default=7003, help="RemoDispBus 监听端口") + ap.add_argument("--no-compare", action="store_true", help="跳过无头参考逐位比对") + ap.add_argument("--scale", type=int, default=5, help="目检 PNG 放大倍数(最近邻)") + ap.add_argument("--clean", action="store_true", help="成功后删除临时产物(默认保留 PNG 供目检)") + args = ap.parse_args(argv) + + if not EXE.exists(): + print("FAIL: 找不到 %s(先构建 simulator:cmake --build simulator/build --config Debug)" % EXE) + return 2 + + tmp = tempfile.mkdtemp(prefix="dtu_remo_") + sim = None + try: + _kill_name("lvgl_sim") + time.sleep(1.0) + sim = _start_sim(tmp) + print("[1] 已启动模拟器 PID=%d(stdout->文件,不阻塞 shell)" % sim.pid) + + if not _wait_port(args.port): + print("[2] FAIL: 端口 %d 未就绪" % args.port) + _dump_err(os.path.join(tmp, "sim_err.txt"), "port") + return 2 + print("[2] 端口 %d 就绪" % args.port) + + s = socket.create_connection(("127.0.0.1", args.port), 3) + s.settimeout(4) + deadline = time.time() + 6 + + # 客户端 -> 设备:LCDMEM(offset=0) + req = bytes([TAG_C, CMD_LCDMEM, 0, 4, 0, 0, 0, 0, _crc(b"\x00\x00\x00\x00")]) + s.sendall(req) + try: + cmd, data, ok = _read_frame(s, deadline) + except (TimeoutError, ConnectionError) as e: + print(" FAIL: 设备无应答(%s)——取帧卡死/读回失败" % e) + s.close() + _dump_err(os.path.join(tmp, "sim_err.txt"), "noresp") + return 1 + s.close() + + fails = [] + # 判据 A:首帧 = LCDMEM(INIT 污染已除) + if cmd != CMD_LCDMEM or not ok: + fails.append("首帧 cmd=0x%02X crc_ok=%s(期望 LCDMEM 0x%02X)" % (cmd, ok, CMD_LCDMEM)) + bitmap = data[4:4 + FB] + # 判据 B:位图 3200 字节 + if len(bitmap) != FB: + fails.append("位图长度 %d(期望 %d)" % (len(bitmap), FB)) + bitmap = bitmap.ljust(FB, b"\x00")[:FB] + # 判据 C:非平凡 + bits = _bits(bitmap) + total = W * H + if not (0 < bits < total): + fails.append("位图平凡(置位 %d/%d,全 0/全 1 或取帧失败)" % (bits, total)) + + if fails: + for m in fails: + print(" FAIL:", m) + _dump_err(os.path.join(tmp, "sim_err.txt"), "proto") + return 1 + + print(" 判据 A OK:首帧=LCDMEM(INIT 污染已除),CRC 通过") + print(" 判据 B OK:位图=%d 字节(addr=%s)" % (len(bitmap), data[:4].hex())) + print(" 判据 C OK:非平凡(置位 %d/%d,%.2f%%)" % (bits, total, bits / total * 100.0)) + + # 目检 PNG(复用 i1_to_png,最近邻放大;白=墨/黑=底) + bin_path = os.path.join(tmp, "proto_screen.bin") + png_path = os.path.join(tmp, "proto_screen.png") + with open(bin_path, "wb") as f: + f.write(bitmap) + i1_to_png(bin_path, output_path=png_path, width=W, height=H, scale=args.scale) + print(" PNG: %s" % png_path) + + # 参考比对:窗口取帧 vs 无头参考(同一初始主菜单,独立进程,互不干扰) + if not args.no_compare: + ref_path = os.path.join(tmp, "ref_headless.bin") + r = subprocess.run([str(EXE), "--screenshot", ref_path], + cwd=str(SIM_DIR), capture_output=True, timeout=90) + if r.returncode != 0 or not os.path.exists(ref_path): + print(" 参考比对:跳过(无头参考生成失败 rc=%d)" % r.returncode) + else: + ref = open(ref_path, "rb").read() + n = min(len(ref), len(bitmap)) + diff = sum(bin(ref[i] ^ bitmap[i]).count("1") for i in range(n)) + pct = diff / total * 100.0 + if pct < REF_DIFF_TOL: + print(" 参考比对 MATCH:与无头参考差异 %d/%d 位(%.3f%%)→ 连贯 UI 确认" + % (diff, total, pct)) + else: + fails.append("参考比对 MISMATCH:差异 %.2f%%(>=%.0f%%)" % (pct, REF_DIFF_TOL)) + print(" FAIL:", fails[-1]) + + if fails: + print("FAIL: %d 项未过" % len(fails)) + return 1 + + print("RESULT: PASS(协议 + 取帧 + 参考比对全过;请目检 PNG 确认)") + if args.clean: + shutil.rmtree(tmp, ignore_errors=True) + print("(--clean:已删除临时产物 %s)" % tmp) + else: + print("临时产物保留于:%s(PNG=%s)" % (tmp, png_path)) + return 0 + finally: + if sim is not None: + try: + sim.kill() + sim.wait(timeout=5) + except Exception: + _kill_name("lvgl_sim") + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/simulator/ui_net_sink.c b/simulator/ui_net_sink.c new file mode 100644 index 00000000..d477b146 --- /dev/null +++ b/simulator/ui_net_sink.c @@ -0,0 +1,274 @@ +/* + * 模块名称:UI Network Sink (RemoDispBus 服务端 — 模拟器 Winsock 版) + * 模块功能:与 MCU 端相同协议,用 Windows Winsock 实现 TCP 监听 + RemoDispBus 帧收发 + * 适用平台:Windows (Winsock2) + */ +#include "ui_net_sink.h" + +#include +#include +#include +#include "lvgl.h" +#include "sim_port.h" + +/* RemoDispBus 标签与命令(与 ui_net_sink.h / MCU 端一致) */ +#define TAG_CLIENT 0xAA +#define TAG_DEVICE 0xBB +#define CMD_KEEPLIVE 0 +#define CMD_INIT 1 +#define CMD_KEY 2 +#define CMD_LCDMEM 3 + +#define UI_W 160 +#define UI_H 160 +#define UI_MEMSIZE (UI_W * UI_H / 8) /* 3200 字节 */ + +/* 远端按键码(与 ui_port.h 一致) */ +#define UI_KEY_UP 0x02 +#define UI_KEY_DOWN 0x40 +#define UI_KEY_LEFT 0x10 +#define UI_KEY_RIGHT 0x08 +#define UI_KEY_ENTER 0x20 +#define UI_KEY_ESC 0x01 + +static uint8_t g_tx[4 + 4 + UI_MEMSIZE + 1]; +static uint8_t g_lcdmem_data[4 + UI_MEMSIZE]; +static uint8_t g_rx[4096]; + +static SOCKET g_listen = INVALID_SOCKET; +static SOCKET g_client = INVALID_SOCKET; +static int g_recv_off = 0; /* g_rx 已累积字节数 */ + +static uint8_t remo_crc(const uint8_t *d, int len) +{ + uint8_t c = 0; + for (int i = 0; i < len; i++) { + c ^= d[i]; + } + return c; +} + +static void put_u16(uint8_t *p, uint16_t v) +{ + p[0] = (uint8_t)(v >> 8); + p[1] = (uint8_t)(v & 0xFF); +} + +static void put_u32(uint8_t *p, uint32_t v) +{ + p[0] = (uint8_t)(v >> 24); + p[1] = (uint8_t)(v >> 16); + p[2] = (uint8_t)(v >> 8); + p[3] = (uint8_t)(v & 0xFF); +} + +static void remo_send(SOCKET s, uint8_t cmd, const uint8_t *data, uint16_t len) +{ + if (s == INVALID_SOCKET) { + return; + } + uint8_t *p = g_tx; + p[0] = TAG_DEVICE; + p[1] = cmd; + p[2] = (uint8_t)(len >> 8); + p[3] = (uint8_t)(len & 0xFF); + if (len && data) { + memcpy(p + 4, data, len); + } + p[4 + len] = remo_crc(data, len); + send(s, (const char *)p, 4 + len + 1, 0); +} + +static void remo_send_init(SOCKET s) +{ + uint8_t init[8]; + put_u16(init, UI_W); + put_u16(init + 2, UI_H); + put_u32(init + 4, UI_MEMSIZE); + remo_send(s, CMD_INIT, init, 6); +} + +static uint32_t sim_map_key(uint8_t remo_key) +{ + switch (remo_key) { + case UI_KEY_UP: return LV_KEY_UP; + case UI_KEY_DOWN: return LV_KEY_DOWN; + case UI_KEY_LEFT: return LV_KEY_LEFT; + case UI_KEY_RIGHT: return LV_KEY_RIGHT; + case UI_KEY_ENTER: return LV_KEY_ENTER; + case UI_KEY_ESC: return LV_KEY_ESC; + default: return 0; + } +} + +static void remo_handle(SOCKET s, const uint8_t *f, int n) +{ + if (n < 5) { + return; + } + uint8_t tag = f[0]; + uint8_t cmd = f[1]; + uint16_t len = ((uint16_t)f[2] << 8) | f[3]; + const uint8_t *data = f + 4; + if ((int)(4 + len + 1) > n) { + return; + } + uint8_t crc = f[4 + len]; + if (crc != remo_crc(data, len)) { + return; + } + if (tag != TAG_CLIENT) { + return; + } + + switch (cmd) { + case CMD_KEEPLIVE: + remo_send(s, CMD_KEEPLIVE, NULL, 0); + break; + case CMD_INIT: + remo_send_init(s); + break; + case CMD_KEY: + if (len >= 1) { + uint32_t key = sim_map_key(data[0]); + if (key != 0) { + lv_group_send_data(lv_group_get_default(), key); + } + } + remo_send(s, CMD_KEY, NULL, 0); + break; + case CMD_LCDMEM: { + if (len < 4) { + break; + } + uint32_t off = ((uint32_t)data[0] << 24) | + ((uint32_t)data[1] << 16) | + ((uint32_t)data[2] << 8) | + (uint32_t)data[3]; + if (off == 0) { + /* 窗口模式 I1+PARTIAL 显示缓冲系分块且经 I1→ARGB8888 转换, + * 不能直接读;读回屏幕实际呈现像素再转 1bpp(布局与 MCU 端一致) */ + if (sim_port_get_screen_mono(g_lcdmem_data + 4, UI_MEMSIZE) == 0) { + memcpy(g_lcdmem_data, data, 4); + remo_send(s, CMD_LCDMEM, g_lcdmem_data, 4 + UI_MEMSIZE); + } + /* 读回失败则静默丢弃(与 MCU 端一致) */ + } + break; + } + default: + break; + } +} + +/* 尝试从已接收数据中解析一帧并处理 */ +static void try_parse_frames(SOCKET s) +{ + for (;;) { + if (g_recv_off < 5) { + break; /* 不够最小帧长 */ + } + uint16_t payload_len = ((uint16_t)g_rx[2] << 8) | g_rx[3]; + int frame_total = 4 + payload_len + 1; + if (g_recv_off < frame_total) { + break; /* 帧不完整,等更多数据 */ + } + remo_handle(s, g_rx, frame_total); + /* 移除已处理帧(RemoDispBus 是请求-响应模式,不会有粘包;但以防万一保留移动逻辑) */ + int remain = g_recv_off - frame_total; + if (remain > 0) { + memmove(g_rx, g_rx + frame_total, remain); + } + g_recv_off = remain; + } +} + +int ui_net_sink_start(uint16_t port) +{ + g_listen = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); + if (g_listen == INVALID_SOCKET) { + return -1; + } + + int yes = 1; + setsockopt(g_listen, SOL_SOCKET, SO_REUSEADDR, (const char *)&yes, sizeof(yes)); + + struct sockaddr_in addr; + memset(&addr, 0, sizeof(addr)); + addr.sin_family = AF_INET; + addr.sin_port = htons(port); + addr.sin_addr.s_addr = INADDR_ANY; + + if (bind(g_listen, (struct sockaddr *)&addr, sizeof(addr)) == SOCKET_ERROR) { + closesocket(g_listen); + g_listen = INVALID_SOCKET; + return -1; + } + if (listen(g_listen, 1) == SOCKET_ERROR) { + closesocket(g_listen); + g_listen = INVALID_SOCKET; + return -1; + } + + /* 设为非阻塞 */ + u_long mode = 1; + ioctlsocket(g_listen, FIONBIO, &mode); + + g_client = INVALID_SOCKET; + g_recv_off = 0; + return 0; +} + +void ui_net_sink_stop(void) +{ + if (g_client != INVALID_SOCKET) { + closesocket(g_client); + g_client = INVALID_SOCKET; + } + if (g_listen != INVALID_SOCKET) { + closesocket(g_listen); + g_listen = INVALID_SOCKET; + } + g_recv_off = 0; +} + +void ui_net_sink_poll(void) +{ + if (g_listen == INVALID_SOCKET) { + return; + } + + /* 无客户端时尝试接受连接 */ + if (g_client == INVALID_SOCKET) { + struct sockaddr_in cli; + int cli_len = sizeof(cli); + SOCKET c = accept(g_listen, (struct sockaddr *)&cli, &cli_len); + if (c != INVALID_SOCKET) { + u_long mode = 1; + ioctlsocket(c, FIONBIO, &mode); + g_client = c; + g_recv_off = 0; + /* 不主动上报 INIT(与 MCU 端一致:避免污染客户端首帧读取,由客户端按需请求) */ + } + return; + } + + /* 有客户端时非阻塞收帧 */ + int n = recv(g_client, (char *)(g_rx + g_recv_off), + (int)(sizeof(g_rx) - g_recv_off), 0); + if (n > 0) { + g_recv_off += n; + try_parse_frames(g_client); + } else if (n == 0) { + closesocket(g_client); + g_client = INVALID_SOCKET; + g_recv_off = 0; + } else { + int err = WSAGetLastError(); + if (err != WSAEWOULDBLOCK) { + closesocket(g_client); + g_client = INVALID_SOCKET; + g_recv_off = 0; + } + } +}