模拟器新增 RemoDispBus 推流服务端,修复远端取图

This commit is contained in:
2026-09-10 16:28:39 +08:00
parent 7fb0eec897
commit 6298beffc9
7 changed files with 653 additions and 4 deletions
+3
View File
@@ -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)
+10 -4
View File
@@ -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 编译
+18
View File
@@ -25,6 +25,7 @@
#ifdef _MSC_VER
#include <Windows.h>
#include <winsock2.h>
#else
#include <unistd.h>
#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) {
+70
View File
@@ -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;
}
+12
View File
@@ -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
+266
View File
@@ -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())
+274
View File
@@ -0,0 +1,274 @@
/*
* 模块名称:UI Network Sink (RemoDispBus 服务端 — 模拟器 Winsock 版)
* 模块功能:与 MCU 端相同协议,用 Windows Winsock 实现 TCP 监听 + RemoDispBus 帧收发
* 适用平台:Windows (Winsock2)
*/
#include "ui_net_sink.h"
#include <string.h>
#include <winsock2.h>
#include <ws2tcpip.h>
#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;
}
}
}