模拟器新增 RemoDispBus 推流服务端,修复远端取图
This commit is contained in:
@@ -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
@@ -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 编译
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user