Merge branch 'v2-on-ui' into master
CI / L0 静态门禁 (pre-commit) (push) Has been cancelled
CI / L0.5 主机侧单测 (MSVC) (push) Has been cancelled

把 v2-on-ui 开发主线(250 个提交)合并进 master:涵盖 B0 产品定义、
B1 ADC 采集链路、1 kHz TIM6 采样节拍、L1-3/L1-4 语义通道映射、
@build/@download 收敛到 .dsh/skills/ 下的 skill(删除 build.bat /
download.bat / reset.bat)等。

master 侧 3 个提交(.gitignore 忽略项 + simulator/sim_config.ini)
已保留,无冲突。
This commit is contained in:
2026-10-10 16:46:28 +08:00
1275 changed files with 210605 additions and 7628 deletions
+50 -4
View File
@@ -7,7 +7,9 @@ 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(NET_DIR "${PROJECT_ROOT}/Drivers/BSP/NET")
set(NETCFG_DIR "${PROJECT_ROOT}/Drivers/BSP/NETCFG")
set(SDL2_DIR "${CMAKE_CURRENT_SOURCE_DIR}/SDL2/SDL2-2.32.10")
#=============================================================================
# 编译标准
@@ -43,10 +45,19 @@ add_subdirectory("${LVGL_DIR}" lvgl_build)
# 包含路径
#=============================================================================
include_directories(
${CMAKE_CURRENT_SOURCE_DIR} # sim_port.h, lvgl conf.h (LVGL 找 lv_conf.h), dbg_cfg.h
${CMAKE_CURRENT_SOURCE_DIR} # lv_conf.h (LVGL 找), dbg_log.h
${CMAKE_CURRENT_SOURCE_DIR}/sim # sim_port.h 等模拟器平台层
${LVGL_DIR} # lvgl.h
${LVGL_DIR}/src # lvgl 内部头文件
${UI_DIR} # ui_menu.h, ui_font_cn_12.h
${NET_DIR} # net_socket.h / net_types.h(net_* API 双端共享核心)
${NETCFG_DIR} # net_cfg.h(网络配置,双端共享核心)
${PROJECT_ROOT}/test/board # test_config.h / net_latency_test.h(共享核心
# ui_net_sink.c、net_socket.c 引含;探针默认关闭,
# 声明宿主安全;2026-09-21 修复模拟器构建断链;
# 2026-10-02 测试目录重构后头文件移入 test/board/)
${PROJECT_ROOT}/App/cfg # cfg_file.h / cfg_file_store.h(通用配置文件引擎,双端共享核心)
${PROJECT_ROOT}/App/log # sys_log.h(运行/自检记录引擎,双端共享核心)
${PROJECT_ROOT}/Drivers/BSP # dbg_log.h(使用 simulator 的 dbg_cfg.h 配置)
${SDL2_DIR}/include # SDL2 头文件
)
@@ -62,23 +73,57 @@ set(SDL2_LIB_X64 "${SDL2_DIR}/lib/x64")
#=============================================================================
set(SIM_SOURCES
main.c
sim_port.c
sim/sim_port.c
sim/sim_latency_stub.c # 探针符号宿主空实现(ui_net_sink.c 引用,见文件头注释)
)
# UI 源文件(与 MCU 端共用,直接编译)
set(UI_SOURCES
${UI_DIR}/ui_menu.c
${UI_DIR}/ui_menu_view.c
${UI_DIR}/ui_content_page.c
${UI_DIR}/ui_deco.c
${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_page_runlog.c
${UI_DIR}/ui_page_selftest.c
${UI_DIR}/ui_standby.c
${UI_DIR}/ui_menu_tab.c
${UI_DIR}/ui_font_cn_12.c
${UI_DIR}/ui_net_sink.c
)
# 运行/自检记录引擎(双端共享核心 + 平台存储后端;MCU 端编 sys_log_mcu.c,见 Keil 工程)
set(SYSLOG_SOURCES
sim/sim_syslog.c
)
# 网络配置(双端共享核心 + cfg_file 引擎 + 平台存储后端;MCU 端编 cfg_file_store_mcu.c,见 Keil 工程)
set(NETCFG_SOURCES
${NETCFG_DIR}/net_cfg.c
${PROJECT_ROOT}/App/cfg/cfg_file.c
sim/cfg_file_store_sim.c
)
# 网络后端(net_* 核心 API 的 Winsock 实现 + UI 推流平台后端;MCU 端另编 net_socket.c 与 _mcu 后端)
set(NET_SOURCES
sim/net_socket_sim.c
sim/ui_net_sink_backend_sim.c
)
#=============================================================================
# 构建可执行文件
#=============================================================================
add_executable(lvgl_sim WIN32 ${SIM_SOURCES} ${UI_SOURCES})
add_executable(lvgl_sim WIN32 ${SIM_SOURCES} ${UI_SOURCES} ${NETCFG_SOURCES} ${SYSLOG_SOURCES} ${NET_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 冲突)
NET_NO_OS # 剥离 net_socket.h 的 FreeRTOS 相关接口(纯 BSD Socket)
)
target_include_directories(lvgl_sim PRIVATE
@@ -93,6 +138,7 @@ target_link_libraries(lvgl_sim
lvgl
SDL2
SDL2main
ws2_32
)
# 数学库(非 MSVC)
+285
View File
@@ -0,0 +1,285 @@
# LVGL PC 模拟器(simulator/)
> 在 PC 上编译运行与 MCU **同一份** UI 源码(`Drivers/BSP/UI/ui_menu.c` + `ui_font_cn_12.c`),
> 实时预览 160×160 1bpp 单色菜单界面,**无需烧录 MCU**。
>
> 部署细节与完整 FAQ 见 `docs/UI设计/LVGL模拟器部署与使用说明.md`;本文聚焦**本目录的内容与架构**。
---
## 一、目录内容
```
simulator/
├── CMakeLists.txt # 构建脚本(CMake + MSVC,x64)
├── build_sim.bat / .ps1 # 一键构建入口(自动探测 VS2019/2022/2026 生成器,x64;.bat 绕过执行策略)
├── main.c # 入口:SDL 初始化 → LVGL → 菜单 → 主循环
├── sim/ # 平台层(PC 专用实现;MCU 端对应实现在各 BSP 目录)
│ ├── sim_port.c / .h # 显示/输入端口(SDL 窗口 + 键盘 + mono 主题),替代 MCU 端 ui_port.c
│ ├── net_socket_sim.c # 网络后端:Winsock 实现 net_* BSD API,替代 MCU 端 net_socket.c(CH395F)
│ ├── ui_net_sink_backend_sim.c # RemoDispBus 平台 backend:取帧/注键(配合共享 ui_net_sink.c)
│ └── cfg_file_store_sim.c # 配置文件存储后端:本地 sim_config.ini(旧档 sim_netcfg.bin 已废弃,不读不删)
├── 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 版头文件)
├── SDL2/
│ └── SDL2-2.32.10/ # SDL2 预编译 SDK(随仓库提供,无需安装)
│ ├── include/ # SDL2 头文件
│ │ └── SDL2/ # ★ 头文件副本(LVGL SDL 驱动写死 #include "SDL2/SDL.h")
│ └── lib/
│ ├── x64/ # SDL2.lib / SDL2main.lib / SDL2.dll(构建使用)
│ └── x86/ # 32 位库(当前未使用)
└── build/ # CMake 生成物(可整体删除重建,勿提交)
└── Debug/lvgl_sim.exe # 可执行文件(SDL2.dll 由 POST_BUILD 自动复制到同目录)
```
### 文件职责
| 文件 | 说明 |
|------|------|
| `main.c` | 程序入口。`SDL_Init(TIMER\|EVENTS)` → `lv_init()` → `sim_port_init(160,160)` → `ui_menu_init()`(共享代码)→ 主循环(SDL 事件 + `lv_timer_handler` + `lv_refr_now`) |
| `sim/sim_port.c` | 创建 SDL 窗口显示设备(`lv_sdl_window_create`)、初始化 **mono 主题**(`lv_theme_mono_init`,1bpp 必需)、创建键盘输入设备并挂默认输入组。**不创建鼠标**(纯键盘导航,与 MCU 端 keypad 一致)。`sim_port_get_screen_mono()` 经 `SDL_RenderReadPixels` 读回屏幕像素转 1bpp,供推流取帧 |
| `sim/net_socket_sim.c` | 网络后端:Winsock 实现 `net_*` BSD Socket API(`socket`/`bind`/`listen`/`accept`/`recv`/`send`/`close`),替代 MCU 端 `Drivers/BSP/NET/net_socket.c`(CH395F) |
| `sim/ui_net_sink_backend_sim.c` | RemoDispBus 平台 backend:`ui_net_sink_get_lcdmem`→`sim_port_get_screen_mono`、`ui_net_sink_inject_key`→`lv_group_send_data`;配合**共享** `Drivers/BSP/UI/ui_net_sink.c`(协议服务端,双端同一份,TCP 7003) |
| `sim/cfg_file_store_sim.c` | 通用配置文件存储后端:本地文件 `sim_config.ini`(`fopen`/`rename` 原子写),替代 MCU 端 `App/cfg/cfg_file_store_mcu.c`(FatFS);旧档 `sim_netcfg.bin` **已废弃**——不读、不删、不迁移(2026-10-10) |
| `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 版 |
| `build_sim.bat` / `build_sim.ps1` | 一键构建入口:vswhere 探测本机 VS 大版本(16=2019 / 17=2022 / 18=2026,取已装最高)→ 映射 CMake 生成器;`build/` 缓存生成器与本机不符(换机/升级 VS)时自动删除重建(带重试,抗文件锁)。**构建入口一律走它,文档/命令中勿写死生成器版本**;`.bat` 仅负责绕过 PowerShell 执行策略并转交 `.ps1` |
| `CMakeLists.txt` | 收集 LVGL 源码(`../Lib/lvgl`)+ 共享 UI 源码(`../Drivers/BSP/UI`)+ 通用配置文件引擎(`../App/cfg_file.c`、`../Drivers/BSP/NETCFG` 的 net_cfg;`net_cfg_legacy` 已于 2026-10-10 删除)+ `sim/` 平台层,链接预编译 SDL2,产出 `lvgl_sim.exe` |
| `i1_to_png.py` | 把 `--screenshot` 产出的 I1(1bpp) 原始位图转成 PNG,便于离线比对设计稿。详见 §3.5 |
| `tests/` | 无头回归脚本(菜单 8 状态像素回归、内容页文本断言 + 黄金参考回归);`ref/` 为**本地生成物、不入库**(首次 `--update` 生成)。详见 §3.6 |
> **共享代码不复制**:`ui_menu.c` / `ui_font_cn_12.c` / `ui_net_sink.c` 直接引用 `Drivers/BSP/UI/` 下的原件,
> MCU 与 PC 两端永远同步。**不要**在本目录内复制 UI 源码。
---
## 二、架构框架
### 2.1 运行架构
```
main.c
│
├─ SDL_Init(TIMER | EVENTS) # 仅初始化计时/事件,视频由 LVGL SDL 驱动内部接管
├─ lv_init() # LVGL v9.3.0 内核
├─ sim_port_init(160, 160)
│ ├─ lv_group_create + set_default # 默认输入组
│ ├─ lv_sdl_window_create(160,160) # SDL 窗口 = 显示设备(I1 单色)
│ ├─ lv_theme_mono_init(disp, true, # mono 主题(白字黑底),1bpp 下不初始化 = 黑屏
│ │ LV_FONT_DEFAULT)
│ └─ lv_sdl_keyboard_create # 键盘 → 默认输入组(方向键/Enter/Esc)
├─ ui_menu_init() # ★ 与 MCU 共用(Drivers/BSP/UI/ui_menu.c)
│ # 基元控件页面导航,节点表驱动
└─ 主循环
├─ SDL_PollEvent # SDL_QUIT → 退出
├─ lv_timer_handler() # 驱动 LVGL 定时器/重绘
├─ lv_refr_now(NULL) # 强制整屏刷新(PARTIAL 模式下保证首帧输出)
└─ Sleep(lv_timer_handler 返回值) # 自适应休眠
```
### 2.2 渲染链路(1bpp 单色)
```
LVGL 场景(基元控件页面/label,LV_COLOR_FORMAT_I1)
→ SW 渲染器(仅启用 I1,LV_DRAW_SW_SUPPORT_I1=1)
→ SDL 窗口 blit(LV_SDL_RENDER_MODE_PARTIAL,1bpp 强制要求)
→ 屏幕(160×160 物理像素,Windows 高 DPI 可能放大显示,属正常)
```
对照 MCU 端链路(`ui_port.c`):
```
LVGL 场景(同一份 ui_menu.c)
→ SW 渲染器(I1)
→ flush 回调 memcpy 到内存帧缓冲 g_ui_fbuf(3200 B)
→ RemoDispBus 协议(TCP 7003)按需推给远端客户端显示
```
两端**场景层完全相同**,只有"渲染结果去向"不同:模拟器进 SDL 窗口,MCU 进内存帧缓冲再走网络。
### 2.3 MCU 端 vs 模拟器 对照
| 项 | MCU(Keil MDK) | 模拟器(CMake + MSVC) |
|----|----------------|------------------------|
| LVGL 编译 | `.uvprojx` 工程添加 `Lib/lvgl` 源文件 | `Lib/lvgl/CMakeLists.txt`(最小 CMake,**仅模拟器用**,不影响 Keil 编译) |
| lv_conf.h | `Lib/lv_conf.h` | `simulator/lv_conf.h`(独立,互不影响) |
| 显示端口 | `ui_port.c`(离屏 framebuffer + flush 回调) | `sim_port.c`(SDL 窗口) |
| 输入 | 远端 keypad(`ui_port_queue_key`,网络回传键码) | SDL 键盘(`lv_sdl_keyboard_create`) |
| 主题 | 无主题(`LV_USE_THEME_* = 0`),控件用内置基础样式 | **mono 主题必须开启**(否则 1bpp 黑底黑字) |
| 日志 | `Drivers/BSP/dbg_log.h`(USART1 + RTC 时间戳) | `simulator/dbg_log.h`(printf stub) |
| 业务任务 | netTask / ftpTask / timeSyncTask 等 | 无(仅编译纯软件部分的配置文件引擎 net_cfg/cfg_file,可离线验证配置加载/保存) |
| 时钟 | `lv_tick_inc` 由 ui_task 手动喂(10ms 周期) | SDL 驱动内部 `lv_tick_set_cb(SDL_GetTicks)`,**不可再手动喂** |
### 2.4 关键设计决策
1. **SDL2**:LVGL 官方 PC 端口同款显示方案;使用仓库内预编译 SDK(`SDL2/SDL2-2.32.10/`),无需系统安装
2. **CMake + MSVC**:`build_sim.ps1` 按本机 VS 版本自动选生成器(16=2019 / 17=2022 / 18=2026;不同机器版本不同,**勿写死**),`-A x64`;MSVC 下加 `/utf-8` 消除中文注释的 C4819 警告
3. **共享 UI 源码**:`ui_menu.c` / `ui_font_cn_12.c` 直接编译 `Drivers/BSP/UI/` 原件,改一处两端同时生效
4. **纯键盘导航**:不创建鼠标/触摸设备,与 MCU 端 keypad 操作语义一致(↑↓←→ / Enter / Esc)
5. **UI 为主**:不含 CH395F 网络 / NAND 存储 / ADC 等硬件业务;通用配置文件引擎(`cfg_file`)与 `net_cfg` 核心为纯软件,配置加载/保存可在模拟器离线验证(`sim_config.ini`;旧档 `sim_netcfg.bin` 已废弃、不参与)
---
## 三、构建与运行
### 3.1 环境要求
| 依赖 | 版本 | 说明 |
|------|------|------|
| CMake | ≥ 3.15 | 本机验证 4.3.0-rc1 |
| MSVC | VS2019 / 2022 / 2026 任一 | `build_sim.ps1` 自动探测生成器(16=2019 / 17=2022 / 18=2026,取已装最高),Hostx64/x64 |
| SDL2 | 2.32.10 | 已内置于 `SDL2/`,无需安装 |
| 操作系统 | Windows | 已验证 win32 |
### 3.2 构建命令
```powershell
# 一键构建(仓库根目录执行;自动探测本机 VS2019/2022/2026 生成器,
# build/ 缓存生成器与本机不符时自动删除重建)
& "simulator\build_sim.bat"
# 运行
.\simulator\build\Debug\lvgl_sim.exe
# 或:cmake --build simulator\build --config Debug --target run
# 全量重建
& "simulator\build_sim.bat" -Clean
```
> 改 `CMakeLists.txt` 后再跑一次 `build_sim.bat` 即可(缓存不匹配会自动重建);遇到奇怪问题加 `-Clean` 全量重建。
> 手动 CMake 等效:`cd simulator; cmake -B build -G "Visual Studio <NN> <YYYY>" -A x64; cmake --build build --config Debug`,生成器号须与本机 VS 大版本一致(16=2019 / 17=2022 / 18=2026)。
### 3.3 `lv_conf.h` 与 MCU 端的关键差异
| 配置 | MCU 端(`Lib/lv_conf.h`) | 模拟器(本目录) | 原因 |
|------|--------------------------|------------------|------|
| `LV_USE_SDL` | 0 | **1** | SDL 显示/键盘驱动 |
| `LV_SDL_RENDER_MODE` | — | **`PARTIAL`** | 1bpp + SDL 非 PARTIAL 会 `#error` |
| `LV_SDL_INCLUDE_PATH` | — | `"SDL2/SDL.h"` | 对应 `include/SDL2/` 头文件副本 |
| `LV_USE_THEME_MONO` | 0 | **1** | 1bpp 无主题 = 黑底黑字不可见 |
| `LV_DRAW_SW_SUPPORT_I1` | 1 | 1 | 仅启用 I1 渲染,裁掉其余色深 |
| `LV_MEM_SIZE` | 40 KB | 40 KB | 保持一致(PC 内存充足,可放宽) |
| `LV_FONT_DEFAULT` | `&ui_font_cn_12` | 同 | 共用同一字体 |
| 其余控件/驱动 | 裁剪 | 基本一致(另开 `LV_USE_TEXTAREA/KEYBOARD/BUTTONMATRIX`) | 模拟器略宽松,便于调试 |
### 3.4 键盘操作
| 按键 | 功能 |
|------|------|
| ↑ / ↓ | 菜单项上下移动 |
| → | 进入子页 |
| Enter | 确认 / 进入子页 |
| ← / Escape | 返回上级 |
### 3.5 截图比对设计稿(离线出图)
窗口模式只适合人工目检。要把渲染结果存成 PNG 与设计参考(`docs/UI设计/mockups/` 下的原始参考图)做目视比对,用离屏截图 + 转 PNG:
```powershell
cd simulator
# 1) 离屏渲染首帧,输出 I1(1bpp) 原始位图(已剥离 8 字节调色板,纯像素数据)
.\build\Debug\lvgl_sim.exe --screenshot shot.bin
# 2) 转 PNG(默认 160x160;--scale 放大便于查看;python 统一用仓库 .venv,见 AGENTS.md「Python 环境」)
..\.venv\Scripts\python.exe i1_to_png.py shot.bin # -> shot.png
..\.venv\Scripts\python.exe i1_to_png.py shot.bin -o 预览.png --scale 6
```
**I1 位图格式**(`i1_to_png.py` 据此解析):1 位/像素,每字节 MSB 在左(bit7 = 该行最左像素),`bit=1` 前景 / `bit=0` 背景,每行 `宽/8` 字节、行内无填充。
`i1_to_png.py` 常用参数:
| 参数 | 说明 |
|------|------|
| `-o, --output` | 输出 PNG 路径(默认与输入同名 `.png`) |
| `-W, -H` | 宽高(默认 160/160,非默认分辨率时必填) |
| `--scale N` | 最近邻放大 N 倍,便于查看小图 |
| `--invert` | 前/背景互换 |
| `--fg / --bg` | 自定义前/背景色 `R,G,B`(默认黑白) |
| `--no-auto-palette` | 关闭调色板自动识别(按纯像素解析) |
> 依赖 Pillow(已含于仓库 `.venv`,见 `requirements.txt`)。`shot.bin`/`shot*.png` 均已列入 `.gitignore`,可随时删除重生成。
### 3.6 无头回归测试(tests/)
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`,
`.venv\Scripts\python.exe simulator/tests/regen_ref_png.py` 出目检 `ref/*.png`(默认 `--scale 6 --invert`)。
有意改视觉时先肉眼确认新效果正确再 `--update`(会先跑文本断言防误刷)。
- **池水位常量漂移**:菜单/页面结构变更导致门禁失败时,确认新值合理后再更新脚本内常量并登记流程文档变更记录;无法解释的漂移 = 内存回归 bug。
- 退出码均为 0=PASS / 1=断言失败 / 2=环境错误(exe 缺失等)。
### 3.7 配置文件自检(--cfgtest)
配置引擎"加载 → 轮询 → 重载 → 保存"的进程内闭环验证(无需窗口 / LVGL,退出码 0=PASS / 非 0=FAIL),在当前工作目录产生 `sim_config.ini`:
```powershell
.\build\Debug\lvgl_sim.exe --cfgtest
```
验证链:`net_cfg_load`(旧档迁移已于 2026-10-10 删除,只走 INI)→ 第 1 次 poll 建基线 → 伪造"外部编辑"(原子写,等价 FTP 上传/文本编辑器)→ 2 次 poll 沉降(对应真机 4s 沉降)→ 断言 RAM 副本已更新为新值 → 对已存在文件做外科式保存、回读校验注释/值无损。
空目录运行 = 验证出厂模板生成;超限文件(>4096 B)= 判损坏并重建出厂模板;超限文档保存 = 拒绝且不动原文件。窗口模式主循环以与 MCU netTask 相同的 2s 周期调用 `cfg_file_poll()`:外部文件变更沉降后自动重载 `[network]` 段(模拟器无网络栈,变更回调为空操作,仅日志可见)。
---
## 四、已知坑(构建/运行)
1. **`cannot open include file "SDL2/SDL.h"`**
LVGL SDL 驱动写死 `#include "SDL2/SDL.h"`,而原版 SDK 头文件平铺在 `include/` 下。
必须把头文件复制一份到 `include/SDL2/` 子目录(本仓库已做好):
```powershell
$inc = "simulator\SDL2\SDL2-2.32.10\include"
New-Item -ItemType Directory -Path "$inc\SDL2" -Force
Copy-Item "$inc\*.h" "$inc\SDL2\"
```
2. **`cannot open include file "lv_conf.h"`**
`LV_CONF_DIR` / `SDL2_INCLUDE_DIR` 两个 CMake 变量必须在 `add_subdirectory(lvgl)` **之前**定义(见 `CMakeLists.txt` 中的顺序注释),勿随意调整。
3. **运行后窗口全黑**,按序排查:
- `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` 是子集字体(当前 122 汉字/符号 + ASCII,共 217 字形),缺字渲染为空白。
补字流程见 `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`)。
7. **键盘输入无响应(方向键/Enter 无效)**:`main.c` 主循环**不得**调用 `SDL_PollEvent`。
LVGL SDL 驱动内部有 5ms 定时器(`lv_sdl_window.c` 的 `sdl_event_handler`)统一轮询分发 SDL 事件;
主循环若先轮询会消费掉事件队列,键盘输入永远到不了 LVGL(现象:画面正常但按键全部无效)。
关窗退出由驱动 `LV_SDL_DIRECT_EXIT=1` 自动 `exit(0)`,主循环无需处理 SDL_QUIT。
---
## 五、边界说明
- 模拟器**跑 UI + 配置文件引擎**:不含 CH395F 网络、NAND 存储、ADC、RS-485 等硬件业务;`cfg_file` 引擎与 `net_cfg`(纯软件部分)在模拟器编译运行,可离线验证配置加载/保存
- 模拟器**含** `ui_net_sink`(Winsock 版 RemoDispBus 推流服务端,TCP 7003)——远端看图链路
可在模拟器上验证(`simulator/tests/check_remo_sink.py`);CH395F 真实网络栈仍只在 MCU 上验证
- `build/` 为生成物,可随时删除重建(已在 `.gitignore` 中忽略)
- `Lib/lvgl/CMakeLists.txt` 仅被本模拟器引用,**不影响** Keil 的 MCU 编译
> **模拟器跑过 ≠ 真机跑过**:本目录与真机的**全部已知差异**(网络 pending 机制缺失、代次号恒 0、
> 日志/配置的 mtime 粒度与堆后端差异、IWDG/NAND/RTC 等硬件不可建模)统一登记在
> [`../docs/测试说明/模拟器差距清单.md`](../docs/测试说明/模拟器差距清单.md)——
> 改动触及 `sim/` 任何后端时,**必须同步在那里登记一行**;改动前先看该文档 §3 判断是否必须回真机。
+24
View File
@@ -0,0 +1,24 @@
@echo off
rem ============================================================
rem LVGL PC simulator one-shot build (VS2019/2022/2026 auto-detect, x64)
rem Usage (from repo root, any shell):
rem & "simulator\build_sim.bat" incremental build
rem & "simulator\build_sim.bat" -Clean full rebuild
rem & "simulator\build_sim.bat" -Config Release Release build
rem Logic lives in build_sim.ps1 (vswhere probe -> generator mapping);
rem this bat only bypasses the PowerShell execution policy and
rem forwards the arguments. Exit code = build result (0 ok / 1 fail / 3 env).
rem NOTE: keep this file PURE ASCII - cmd parses .bat in the ANSI
rem codepage; UTF-8 Chinese here corrupts the command lines.
rem ============================================================
setlocal
where pwsh >nul 2>nul
if %errorlevel%==0 (
pwsh -NoProfile -ExecutionPolicy Bypass -File "%~dp0build_sim.ps1" %*
goto :done
)
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0build_sim.ps1" %*
:done
exit /b %errorlevel%
+144
View File
@@ -0,0 +1,144 @@
#Requires -Version 5.1
<#
.SYNOPSIS
LVGL PC 模拟器一键构建(Visual Studio 版本自适应,x64)。
.DESCRIPTION
不同开发机安装的 VS 版本不同,CMake 生成器必须与本机 VS 大版本一致,
因此构建入口不写死生成器:本脚本用 vswhere 自动探测本机 Visual Studio
(VS / BuildTools 均可),按大版本映射 CMake 生成器,取已安装的最高版本:
16 = "Visual Studio 16 2019"
17 = "Visual Studio 17 2022"
18 = "Visual Studio 18 2026"
若 build/ 目录缓存的生成器与探测结果不一致(换机/升级 VS 后),
自动删除 build/ 重新配置,避免 MSBuild 报生成器不匹配。
退出码:0=成功;1=构建失败;3=环境预检失败(无 cmake / 无 VS / VS 版本过新)。
.PARAMETER Config
构建配置,默认 Debug(可选 Release)。
.PARAMETER Clean
强制删除 build/ 后全量重建。
.EXAMPLE
& 'simulator\build_sim.ps1' # 仓库根目录执行,日常增量构建
& 'simulator\build_sim.ps1' -Clean # 全量重建
& 'simulator\build_sim.ps1' -Config Release # Release 构建
#>
param(
[ValidateSet('Debug', 'Release')]
[string]$Config = 'Debug',
[switch]$Clean
)
$ErrorActionPreference = 'Stop'
# 本脚本位于 simulator/ 目录
$simDir = $PSScriptRoot
$buildDir = Join-Path $simDir 'build'
# --- 1. 环境预检 -----------------------------------------------------------
$cmake = Get-Command cmake -ErrorAction SilentlyContinue
if (-not $cmake) {
Write-Host '[build_sim] 预检失败:PATH 中找不到 cmake' -ForegroundColor Red
exit 3
}
# vswhere:DTU_VSWHERE 显式覆盖 > %ProgramFiles(x86)% 推导(不保留固定 C 盘值)
$vswhere = $env:DTU_VSWHERE
if ($vswhere) {
if (-not (Test-Path -LiteralPath $vswhere)) {
Write-Host "[build_sim] 预检失败:DTU_VSWHERE 已设置但文件不存在:$vswhere" -ForegroundColor Red
exit 3
}
} elseif (${env:ProgramFiles(x86)}) {
$vswhere = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio\Installer\vswhere.exe'
}
if (-not $vswhere -or -not (Test-Path -LiteralPath $vswhere)) {
Write-Host '[build_sim] 预检失败:未找到 vswhere(本机未安装 Visual Studio / BuildTools,或布局非标准;可用 DTU_VSWHERE 指向 vswhere.exe 完整路径)' -ForegroundColor Red
exit 3
}
# --- 2. 探测 VS 大版本 → 选择生成器 ----------------------------------------
$raw = & $vswhere -products * -format json
$instances = @($raw | ConvertFrom-Json) # 单实例时 PS5.1 返回裸对象,@() 统一成数组
$instances = $instances | Where-Object { $_ -and $_.installationVersion }
if (-not $instances) {
Write-Host '[build_sim] 预检失败:vswhere 未返回任何 VS 实例' -ForegroundColor Red
exit 3
}
$best = $instances |
Sort-Object { [version]$_.installationVersion } |
Select-Object -Last 1
$major = [version]$best.installationVersion
switch ($major.Major) {
16 { $generator = 'Visual Studio 16 2019' }
17 { $generator = 'Visual Studio 17 2022' }
18 { $generator = 'Visual Studio 18 2026' }
default {
Write-Host "[build_sim] 预检失败:不支持的 VS 大版本 $($major.Major)($($best.installationPath)),仅支持 16/17/18" -ForegroundColor Red
exit 3
}
}
Write-Host "[build_sim] 探测到 VS $($best.installationVersion) -> 生成器 '$generator'"
# 删除 build/ 目录(带重试:抗杀软瞬时锁/文件占用;彻底失败则提示用户)
function Remove-BuildDir([string]$Dir) {
for ($i = 1; $i -le 3; $i++) {
try {
Remove-Item $Dir -Recurse -Force -ErrorAction Stop
return $true
} catch {
if ($i -lt 3) {
Write-Host "[build_sim] build/ 删除失败(可能有文件被占用),重试 $i/3 ..."
Start-Sleep -Seconds 2
}
}
}
Write-Host '[build_sim] 无法删除 build/:若模拟器窗口仍开着,请先关闭再重试' -ForegroundColor Red
return $false
}
# --- 3. 生成器一致性检查(缓存不匹配则删 build/ 重建)-----------------------
if ($Clean -and (Test-Path $buildDir)) {
if (-not (Remove-BuildDir $buildDir)) { exit 3 }
Write-Host '[build_sim] -Clean:已删除 build/'
}
$cache = Join-Path $buildDir 'CMakeCache.txt'
if (Test-Path $cache) {
# CMake 缓存中 CMAKE_GENERATOR 的类型随版本为 STRING 或 INTERNAL,统一按行首匹配
$cached = (Select-String -Path $cache -Pattern '^CMAKE_GENERATOR:' |
Select-Object -First 1).Line -replace '^CMAKE_GENERATOR:[A-Z]+=', ''
if ($cached -cne $generator) {
Write-Host "[build_sim] build/ 由 '$cached' 配置,与本机 '$generator' 不符 -> 删除重建"
if (-not (Remove-BuildDir $buildDir)) { exit 3 }
}
}
# --- 4. 配置 + 构建 ---------------------------------------------------------
if (-not (Test-Path (Join-Path $buildDir 'CMakeCache.txt'))) {
Write-Host '[build_sim] CMake 配置 ...'
& cmake -S $simDir -B $buildDir -G $generator -A x64
if ($LASTEXITCODE -ne 0) {
Write-Host '[build_sim] CMake 配置失败' -ForegroundColor Red
exit $LASTEXITCODE
}
}
Write-Host "[build_sim] 构建($Config)..."
& cmake --build $buildDir --config $Config
if ($LASTEXITCODE -ne 0) {
Write-Host '[build_sim] 构建失败' -ForegroundColor Red
exit $LASTEXITCODE
}
$exe = Join-Path $buildDir (Join-Path $Config 'lvgl_sim.exe')
if (Test-Path $exe) {
Write-Host "[build_sim] OK:$exe" -ForegroundColor Green
exit 0
}
Write-Host "[build_sim] 构建退出码 0 但未找到 $exe" -ForegroundColor Red
exit 1

+189
View File
@@ -0,0 +1,189 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
i1_to_png.py — 将 LVGL 模拟器 `--screenshot` 产出的 I1(1bpp) 原始位图转换为 PNG。
输入格式(模拟器 main.c 的 --screenshot 模式输出):
- 1 位/像素,每字节 MSB 在左(bit7=该行最左像素),bit=1 为前景、bit=0 为背景
- 每行 stride = 宽 / 8 字节,行内无填充
- 模拟器当前已剥离 8 字节调色板,文件即纯像素数据(宽/8 × 高 字节)
- 若遇到仍含调色板的旧文件(像素数据 + 8 字节),--auto-palette 会自动识别并跳过
用法示例:
python i1_to_png.py shot.bin # -> shot.png(默认 160x160)
python i1_to_png.py shot.bin -o out.png # 指定输出
python i1_to_png.py shot.bin --scale 6 # 放大 6 倍便于查看(最近邻,保持像素感)
python i1_to_png.py shot.bin -W 320 -H 240 # 非默认分辨率
python i1_to_png.py shot.bin --fg 0,0,0 --bg 255,255,255 # 自定义前/背景色(RGB 模式)
python i1_to_png.py shot.bin --invert # 前景/背景互换
依赖:Pillow(pip install pillow)。本仓库环境已验证 PIL 12.2.0。
退出码:0 成功;1 参数/数据错误。
"""
import argparse
import os
import sys
# DTU 屏幕默认分辨率(160x160,1bpp)
DEFAULT_W = 160
DEFAULT_H = 160
# I1 调色板大小(2 色 × 4 字节)。模拟器新版已剥离,旧文件可能残留。
I1_PALETTE_BYTES = 2 * 4
def _decode_i1(data, width, height):
"""把原始 I1 数据解成 (height, width) 的 0/1 矩阵。MSB 在左。"""
stride = width // 8
if width % 8 != 0:
raise ValueError("宽度必须是 8 的整数倍(I1 按字节打包),当前 %d" % width)
need = stride * height
if len(data) < need:
raise ValueError("数据不足:%d 字节 < 需要 %d 字节(%dx%d I1)"
% (len(data), need, width, height))
px = []
for y in range(height):
base = y * stride
row = [0] * width
for x in range(width):
byte_i = x >> 3
bit_i = 7 - (x & 7)
row[x] = (data[base + byte_i] >> bit_i) & 1
px.append(row)
return px
def _pick_palette_offset(data, width, height, auto):
"""返回像素数据在文件中的起始偏移(跳过调色板)。"""
stride = width // 8
need = stride * height
sizes = [len(data)]
if auto:
# 像素数据前可能带 I1 调色板(8 字节)
if len(data) == need + I1_PALETTE_BYTES:
return I1_PALETTE_BYTES
if len(data) < need:
raise ValueError("数据不足:%d 字节 < 像素所需 %d 字节" % (len(data), need))
return 0
def i1_to_png(input_path, output_path=None, width=DEFAULT_W, height=DEFAULT_H,
scale=1, invert=False, auto_palette=True,
fg=None, bg=None):
"""核心转换函数,可被其他脚本直接 import 调用。
fg / bg 形如 (r,g,b);为 None 时用黑白(L 模式)。
返回输出文件路径。
"""
from PIL import Image
if not os.path.isfile(input_path):
raise FileNotFoundError(input_path)
with open(input_path, "rb") as f:
data = f.read()
off = _pick_palette_offset(data, width, height, auto_palette)
px = _decode_i1(data[off:], width, height)
nw, nh = width, height
if fg is None and bg is None:
# 灰度:bit=1 -> 白(255),bit=0 -> 黑(0)
img = Image.new("L", (nw, nh))
ipx = img.load()
for y in range(nh):
for x in range(nw):
bit = px[y][x]
if invert:
bit ^= 1
ipx[x, y] = 255 if bit else 0
else:
fgc = fg if fg is not None else (255, 255, 255)
bgc = bg if bg is not None else (0, 0, 0)
if invert:
fgc, bgc = bgc, fgc
img = Image.new("RGB", (nw, nh), bgc)
ipx = img.load()
for y in range(nh):
for x in range(nw):
bit = px[y][x]
ipx[x, y] = fgc if bit else bgc
# 放大(最近邻,保持 1bpp 像素感)
if scale and scale > 1:
img = img.resize((nw * scale, nh * scale), Image.NEAREST)
if output_path is None:
output_path = os.path.splitext(input_path)[0] + ".png"
img.save(output_path, "PNG")
return output_path
def _parse_rgb(s):
if s is None:
return None
parts = s.split(",")
if len(parts) != 3:
raise argparse.ArgumentTypeError("颜色格式应为 R,G,B,如 255,0,0")
try:
r, g, b = (int(p) for p in parts)
except ValueError:
raise argparse.ArgumentTypeError("颜色分量必须是整数")
for v in (r, g, b):
if not (0 <= v <= 255):
raise argparse.ArgumentTypeError("颜色分量需在 0~255")
return (r, g, b)
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 main(argv=None):
_ensure_utf8_console()
ap = argparse.ArgumentParser(
description="将模拟器 I1(1bpp) 原始截图转换为 PNG",
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
ap.add_argument("input", help="I1 原始位图文件(.bin)")
ap.add_argument("-o", "--output", default=None,
help="输出 PNG 路径(默认与输入同名 .png)")
ap.add_argument("-W", "--width", type=int, default=DEFAULT_W, help="图像宽度")
ap.add_argument("-H", "--height", type=int, default=DEFAULT_H, help="图像高度")
ap.add_argument("--scale", type=int, default=1,
help="放大倍数(最近邻,便于查看小图)")
ap.add_argument("--invert", action="store_true", help="前景/背景互换")
ap.add_argument("--no-auto-palette", action="store_true",
help="不做调色板自动识别(按纯像素数据解析)")
ap.add_argument("--fg", type=_parse_rgb, default=None, help="前景色 R,G,B(bit=1)")
ap.add_argument("--bg", type=_parse_rgb, default=None, help="背景色 R,G,B(bit=0)")
args = ap.parse_args(argv)
try:
out = i1_to_png(
args.input,
output_path=args.output,
width=args.width,
height=args.height,
scale=args.scale,
invert=args.invert,
auto_palette=not args.no_auto_palette,
fg=args.fg,
bg=args.bg,
)
except (ValueError, FileNotFoundError) as e:
print("错误:%s" % e, file=sys.stderr)
return 1
print("已生成:%s" % out)
return 0
if __name__ == "__main__":
sys.exit(main())
+7 -6
View File
@@ -49,11 +49,11 @@
#define LV_DRAW_BUF_STRIDE_ALIGN 1
#define LV_DRAW_BUF_ALIGN 4
/** 关闭复杂绘制,省代码 */
#define LV_DRAW_SW_COMPLEX 0
/** 复杂绘制:主界面顶部开口浅 V 装饰含斜线(lv_line 斜段依赖 LV_DRAW_SW_COMPLEX),须与 MCU 端一致保持开启,否则两腿不渲染 */
#define LV_DRAW_SW_COMPLEX 1
#define LV_DRAW_SW_COMPLEX_GRADIENTS 0
#define LV_DRAW_SW_SHADOW_CACHE_SIZE 0
#define LV_DRAW_SW_CIRCLE_CACHE_SIZE 0
#define LV_DRAW_SW_CIRCLE_CACHE_SIZE 4 /* LV_DRAW_SW_COMPLEX 依赖非零(零长数组 C2466);与 MCU 端默认一致 */
#define LV_DRAW_LAYER_SIMPLE_BUF_SIZE 0
#define LV_DRAW_LAYER_MAX_MEMORY 0
@@ -87,7 +87,8 @@
#define LV_USE_THORVG 0
#define LV_USE_VECTOR_GRAPHIC 0
#define LV_USE_MATRIX 0
#define LV_USE_SNAPSHOT 0
/* 离屏截图(--screenshot)用 lv_snapshot 取整屏,需要开启 */
#define LV_USE_SNAPSHOT 1
/*============================================================================
* INPUT DEVICES
@@ -229,10 +230,10 @@
#define LV_LABEL_LONG_TXT_HINT 0
#define LV_LABEL_WAIT_CHAR_COUNT 0
#define LV_USE_LED 0
#define LV_USE_LINE 0
#define LV_USE_LINE 1
#define LV_USE_LIST 0
#define LV_USE_LOTTIE 0
#define LV_USE_MENU 1
#define LV_USE_MENU 0
#define LV_USE_MSGBOX 0
#define LV_USE_QRCODE 0
#define LV_USE_RLOTTIE 0
+506 -15
View File
@@ -7,13 +7,43 @@
* ↑/↓/←/→ → 方向键导航
* Enter → 确认/进入
* Escape → 返回
*
* 截图模式(无窗口离屏渲染,导出像素供比对):
* lvgl_sim.exe --screenshot shot.bin
* 走与 MCU 端一致的离屏软件渲染(ARGB8888 缓冲 + mono 主题),
* 渲染首帧后将 160x160 像素按 BGRA 顺序写入 shot.bin(102400 字节),随即退出。
*
* 键序截图模式(无窗口 + 注入按键序列后截图,用于级联菜单无头回归):
* lvgl_sim.exe --keys <seq> shot.bin
* seq 字符:u=↑ d=↓ l=← r=→ e=Enter s=Esc(其余字符忽略)
* 每键经 lv_group_send_data 注入(与 keypad 输入设备同路径),驱动 5 帧后取快照
*
* 配置轮询自检模式(无窗口、进程内闭环、退出码 0=PASS / 非0=FAIL):
* lvgl_sim.exe --cfgtest
* 验证链:net_cfg_load(只读 INI;旧 NCFG 档迁移已于 2026-10-10 删除)
* → poll 建基线 → 伪造外部编辑
* (原子写新文档,等价 FTP 上传/文本编辑器)→ poll 两次(4s 沉降)
* → 断言 RAM 副本已按新文件重载
*
* 日志引擎自检模式(无窗口、进程内闭环、退出码 0=PASS / 非0=FAIL):
* lvgl_sim.exe --logtest
* 验证链:post N 条 → count/get 回读断言 + 宿主文件字节级校验
* (64B 定长、ISO 时间戳、epoch 字段、NUL 填充)
*
* 待机屏截图模式(无窗口,强制进入待机后截图):
* lvgl_sim.exe --standby shot.bin
*
* 窗口模式空闲超时覆盖:lvgl_sim.exe --idle <秒>(默认 60s)
*/
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <time.h>
#ifdef _MSC_VER
#include <Windows.h>
#include <winsock2.h>
#else
#include <unistd.h>
#endif
@@ -24,18 +54,455 @@
#include "lvgl.h"
#include "sim_port.h"
#include "ui_menu.h"
#include "ui_standby.h"
#include "ui_net_sink.h"
#include "net_cfg.h"
#include "cfg_file.h"
#include "cfg_file_store.h"
#include "sys_log.h"
/* 屏幕尺寸(与 MCU 端一致) */
#define SIM_SCREEN_W 160
#define SIM_SCREEN_H 160
/* 离屏截图缓冲:I1(1bpp,与 MCU/模拟器窗口一致;软件渲染器仅支持 I1),160x160/8 */
static uint8_t g_shot_buf[SIM_SCREEN_W * SIM_SCREEN_H / 8];
/*
* @brief 离屏截图用空 delay 回调
* @note 默认 lv_delay_ms 会忙等 tick 前进,但离屏渲染时 tick 不会在等待期间推进,
* 导致死循环挂死。window 模式经 SDL 设了真实 delay,离屏这里用空实现代替。
*/
static void shot_delay_cb(uint32_t ms)
{
(void)ms;
}
/*
* @brief 离屏截图 flush 回调(不写真实设备,直接标记就绪)
*/
static void shot_flush_cb(lv_display_t * disp, const lv_area_t * area, uint8_t * color_p)
{
(void)area;
(void)color_p;
lv_display_flush_ready(disp);
}
/*
* 键序列字符 → LVGL 键值(非键字符返回 0 = 忽略)
*/
static uint32_t sim_map_key_char(char c)
{
switch (c) {
case 'u': return LV_KEY_UP;
case 'd': return LV_KEY_DOWN;
case 'l': return LV_KEY_LEFT;
case 'r': return LV_KEY_RIGHT;
case 'e': return LV_KEY_ENTER;
case 's': return LV_KEY_ESC;
default: return 0;
}
}
/*
* @brief 待机屏底部时钟提供回调(平台差异:模拟器 = 宿主 time())
*/
static void sim_time_cb(char *buf, size_t len)
{
time_t t = time(NULL);
struct tm tmv;
#if defined(_MSC_VER)
localtime_s(&tmv, &t);
#else
localtime_r(&t, &tmv);
#endif
(void)snprintf(buf, len, "%04d-%02d-%02d %02d:%02d:%02d",
tmv.tm_year + 1900, tmv.tm_mon + 1, tmv.tm_mday,
tmv.tm_hour, tmv.tm_min, tmv.tm_sec);
}
/*
* @brief 无窗口离屏渲染(可注入键序列)并导出末帧像素
* @param keys 键序列(u=上 d=下 l=左 r=右 e=确认 s=返回;空 = 不注键)
* @param path 输出文件(写入 160x160 I1 原始像素)
* @param standby 非 0 = 注键后强制进入待机屏再截图(--standby 模式)
* @return 0 成功,非 0 失败
*/
static int run_headless(const char * keys, const char * path, int standby)
{
setvbuf(stdout, NULL, _IONBF, 0);
lv_init();
lv_delay_set_cb(shot_delay_cb);
lv_display_t *disp = lv_display_create(SIM_SCREEN_W, SIM_SCREEN_H);
if (disp == NULL) {
return 1;
}
lv_display_set_default(disp);
lv_display_set_color_format(disp, LV_COLOR_FORMAT_I1);
lv_display_set_buffers(disp, g_shot_buf, NULL, sizeof(g_shot_buf),
LV_DISPLAY_RENDER_MODE_FULL);
lv_display_set_flush_cb(disp, shot_flush_cb);
lv_group_t *g = lv_group_create();
if (g == NULL) {
return 1;
}
lv_group_set_default(g);
#if LV_USE_THEME_MONO
lv_theme_t *th = lv_theme_mono_init(disp, true, LV_FONT_DEFAULT);
if (th != NULL) {
lv_display_set_theme(disp, th);
}
#endif
/* 配置 RAM 副本:镜像 netTask 上电流程(net_cfg_load → 工厂默认),
* 使网口设置内容页显示与真机一致的工厂默认值 */
(void)net_cfg_load();
if (ui_menu_init() != 0) {
return 1;
}
/* 待机模块(无头模式不驱动 lv_timer_handler,空闲定时器不会触发;
* --standby 经 force_enter 测试钩子直接切入) */
(void)ui_standby_init(60000u, sim_time_cb);
/* 键注入:与 keypad 输入设备同路径(向焦点对象发 LV_EVENT_KEY)。
* 键处理是同步的(状态变更 + 场景更新当场完成),无需驱动帧;
* 无头模式严禁调 lv_timer_handler()/lv_refr_now()——I1+FULL 全屏刷新会进渲染死循环 */
for (const char *p = keys; *p != '\0'; p++) {
uint32_t key = sim_map_key_char(*p);
if (key == 0u) {
continue;
}
lv_group_send_data(g, key);
}
/* 执行待处理"保存网口配置"请求:无头模式不跑主循环(帧驱动被禁用),
* 此处同步完成保存交接,保持与窗口模式/真机一致的终态(WAIT → OK/ERR) */
while (net_cfg_save_state() == (int)NET_CFG_SAVE_ST_WAIT) {
net_cfg_save_run();
}
/* --standby:注键后强制进入待机屏(截图前) */
if (standby != 0) {
ui_standby_force_enter();
}
/* 注键后 LVGL 池占用(无头模式;内存回归用,对照 ui_menu_init 基线) */
lv_mem_monitor_t mon;
lv_mem_monitor(&mon);
printf("pool after keys: used=%u free=%u\n",
(unsigned)(mon.total_size - mon.free_size), (unsigned)mon.free_size);
/* 用 snapshot 把整屏渲进独立 I1 draw_buf(避开 I1+FULL 的渲染死循环) */
lv_draw_buf_t *snap = lv_snapshot_take(lv_screen_active(), LV_COLOR_FORMAT_I1);
if (snap == NULL) {
fprintf(stderr, "[shot] snapshot failed\n");
return 1;
}
uint32_t snap_w = snap->header.w;
uint32_t snap_h = snap->header.h;
/* I1 缓冲区布局:[palette 8B][pixel data],跳过 palette 只写像素 */
uint32_t pal_bytes = LV_COLOR_INDEXED_PALETTE_SIZE(LV_COLOR_FORMAT_I1) * (uint32_t)sizeof(lv_color32_t);
uint8_t * px = snap->data + pal_bytes;
uint32_t px_bytes = (uint32_t)snap->header.stride * (uint32_t)snap_h;
FILE *fp = fopen(path, "wb");
if (fp == NULL) {
lv_draw_buf_destroy(snap);
fprintf(stderr, "fopen failed: %s\n", path);
return 1;
}
size_t n = fwrite(px, 1, px_bytes, fp);
fclose(fp);
lv_draw_buf_destroy(snap);
printf("screenshot: %s (%zu bytes, %ux%u I1)\n", path, n, snap_w, snap_h);
return 0;
}
/* 伪造"外部编辑"文档(绕过引擎原子写 = 等价 FTP 上传/文本编辑器);
* 刻意短于出厂模板,保证 (size, mtime) 变更令牌必变 */
static const char s_cfgtest_doc[] =
"; cfgtest forged external edit\r\n"
"\r\n"
"[network]\r\n"
"ip=10.0.0.1\r\n"
"mask=255.255.255.0\r\n"
"gw=10.0.0.254\r\n"
"mac=00:00:00:00:00:00\r\n";
/* 伪造编辑后 RAM 副本应呈现的 IP */
static const uint8_t s_cfgtest_ip[4] = { 10u, 0u, 0u, 1u };
/*
* @brief 配置轮询自检(无窗口、无需 LVGL/SDL,进程内闭环)
* @note 验证链:net_cfg_load(只读 INI)→ poll 建基线 → 伪造外部编辑
* → poll 两次(沉降去抖)→ 重载并更新 RAM 副本
* @return 0 PASS,非 0 FAIL
*/
static int run_cfgtest(void)
{
int i;
const net_cfg_t *p_cfg;
setvbuf(stdout, NULL, _IONBF, 0);
(void)net_cfg_load(); /* 加载 INI(含 [network] 段注册;旧 NCFG 档不再读取) */
cfg_file_poll(); /* 第 1 次 poll:建立变更基线 */
/* 伪造外部编辑(原子写,与 P4 后 FTP STOR 同路径) */
if (cfg_store_write_atomic(cfg_store_config_path(),
(const uint8_t *)s_cfgtest_doc,
strlen(s_cfgtest_doc)) != 0) {
fprintf(stderr, "[cfgtest] forge write failed\n");
return 1;
}
cfg_file_poll(); /* 第 2 次 poll:检出变更,沉降计数 1 */
cfg_file_poll(); /* 第 3 次 poll:沉降完成 → 重载 + 变更回调 */
p_cfg = net_cfg_get();
for (i = 0; i < 4; i++) {
if (p_cfg->ip[i] != s_cfgtest_ip[i]) {
printf("[cfgtest] FAIL ip after reload = %u.%u.%u.%u (expect 10.0.0.1)\n",
(unsigned)p_cfg->ip[0], (unsigned)p_cfg->ip[1],
(unsigned)p_cfg->ip[2], (unsigned)p_cfg->ip[3]);
return 1;
}
}
printf("[cfgtest] OK ip after reload = 10.0.0.1\n");
/* 保存回环:外科手术式保存(目标文件已存在)→ 回读校验注释保留与值一致 */
if (net_cfg_save() != 0) {
printf("[cfgtest] FAIL net_cfg_save (existing file)\n");
return 1;
}
{
uint8_t buf[CFG_FILE_MAX_LEN + 1u];
char *p_doc;
size_t n = 0u;
if (cfg_store_read(cfg_store_config_path(), buf, sizeof(buf), &n) != 0) {
printf("[cfgtest] FAIL readback open\n");
return 1;
}
buf[n] = '\0';
p_doc = (char *)buf;
if (strstr(p_doc, "; cfgtest forged external edit") == NULL) {
printf("[cfgtest] FAIL comment lost after surgical save\n");
return 1;
}
if (strstr(p_doc, "ip=10.0.0.1") == NULL) {
printf("[cfgtest] FAIL value lost after surgical save\n");
return 1;
}
}
printf("[cfgtest] OK surgical save (comment kept, value intact)\n");
/* E-01 场景 1:伪造超长文件(> CFG_FILE_MAX_LEN)→ poll 必须判损坏
* (与 load/MCU 同一契约):RAM 回工厂默认 + 文件重建为出厂模板,
* 而非截断解析残留 10.0.0.1(修复前死码走的是截断分支) */
{
uint8_t big[CFG_FILE_MAX_LEN + 64u];
cfg_file_stat_t st_chk;
(void)memset(big, ';', sizeof(big)); /* 纯注释:内容无关,尺寸超即触发 */
if (cfg_store_write_atomic(cfg_store_config_path(), big, sizeof(big)) != 0) {
printf("[cfgtest] FAIL forge oversized write\n");
return 1;
}
cfg_file_poll(); /* 检出变更(沉降计数 1) */
cfg_file_poll(); /* 沉降完成 → 损坏分支:工厂默认 + 重建模板 */
if (cfg_store_stat(cfg_store_config_path(), &st_chk) != 0 ||
st_chk.size == 0u || st_chk.size > CFG_FILE_MAX_LEN) {
printf("[cfgtest] FAIL oversized file not rebuilt (size=%lu)\n",
(unsigned long)st_chk.size);
return 1;
}
p_cfg = net_cfg_get();
if (p_cfg->ip[0] != 192u || p_cfg->ip[1] != 168u ||
p_cfg->ip[2] != 1u || p_cfg->ip[3] != 100u) {
printf("[cfgtest] FAIL ip not factory after oversized: %u.%u.%u.%u\n",
(unsigned)p_cfg->ip[0], (unsigned)p_cfg->ip[1],
(unsigned)p_cfg->ip[2], (unsigned)p_cfg->ip[3]);
return 1;
}
}
printf("[cfgtest] OK oversized file rebuilt to factory template\n");
/* E-01 场景 2:save 拒写"雷"文件——伪造合法界内纯注释文件(4000B,无
* [network] 段),外科手术保存文末补写受管段 → 输出必然超
* CFG_FILE_MAX_LEN:必须拒绝(net_cfg_save 返 -1)且原文件不动;
* 修复前此处会成功写出 ~4200B 超长文件 = 下次开机被销毁的雷 */
{
uint8_t pad[4000u];
cfg_file_stat_t st_chk;
(void)memset(pad, ';', sizeof(pad));
if (cfg_store_write_atomic(cfg_store_config_path(), pad, sizeof(pad)) != 0) {
printf("[cfgtest] FAIL forge padding write\n");
return 1;
}
if (net_cfg_save() == 0) {
printf("[cfgtest] FAIL oversized document was written (landmine)\n");
return 1;
}
(void)cfg_store_stat(cfg_store_config_path(), &st_chk);
if (st_chk.size != 4000u) {
printf("[cfgtest] FAIL file modified by rejected save (size=%lu)\n",
(unsigned long)st_chk.size);
return 1;
}
}
printf("[cfgtest] OK save rejected oversized document (file untouched)\n");
printf("[cfgtest] PASS\n");
return 0;
}
/*
* @brief 日志引擎自检(无窗口、进程内闭环)
* @note 验证链:清理旧档 → post N 条 → count/get 回读断言 + 宿主文件字节级校验
* (64B 定长、ISO 时间戳格式、epoch 字段 ≈ now、NUL 填充)
* @return 0 PASS,非 0 FAIL
*/
static int run_logtest(void)
{
uint32_t ep;
char ts[SYSLOG_TS_LEN + 1u];
char desc[SYSLOG_DESC_MAX + 1u];
setvbuf(stdout, NULL, _IONBF, 0);
/* 确定性起点:清理旧档 */
(void)remove("sim_run.log");
(void)remove("sim_selftest.log");
syslog_post(SYSLOG_RUN, "power on");
syslog_post(SYSLOG_SELFTEST, "clock chip fault");
syslog_post(SYSLOG_SELFTEST, "NAND fault");
if (syslog_count(SYSLOG_RUN) != 1u) {
printf("[logtest] FAIL run count = %u (expect 1)\n", (unsigned)syslog_count(SYSLOG_RUN));
return 1;
}
if (syslog_count(SYSLOG_SELFTEST) != 2u) {
printf("[logtest] FAIL selftest count = %u (expect 2)\n", (unsigned)syslog_count(SYSLOG_SELFTEST));
return 1;
}
/* 回读断言:时间戳格式 + 描述内容 */
if (syslog_get_record(SYSLOG_RUN, 0u, ts, sizeof(ts), desc, sizeof(desc)) != 0) {
printf("[logtest] FAIL get_record(run, 0)\n");
return 1;
}
if (strlen(ts) != SYSLOG_TS_LEN || ts[4] != '-' || ts[7] != '-' || ts[10] != ' ') {
printf("[logtest] FAIL ts format = \"%s\"\n", ts);
return 1;
}
if (strcmp(desc, "power on") != 0) {
printf("[logtest] FAIL desc = \"%s\"\n", desc);
return 1;
}
if (syslog_get_record(SYSLOG_SELFTEST, 1u, ts, sizeof(ts), desc, sizeof(desc)) != 0) {
printf("[logtest] FAIL get_record(selftest, 1)\n");
return 1;
}
if (strcmp(desc, "NAND fault") != 0) {
printf("[logtest] FAIL desc[1] = \"%s\"\n", desc);
return 1;
}
/* epoch 字段 ≈ now(±5s) */
if (syslog_last_epoch(SYSLOG_RUN, &ep) != 0) {
printf("[logtest] FAIL last_epoch(run)\n");
return 1;
}
if ((uint32_t)abs((int)(ep - (uint32_t)time(NULL))) > 5u) {
printf("[logtest] FAIL epoch = %u (now = %lu)\n", (unsigned)ep, (unsigned long)time(NULL));
return 1;
}
/* 宿主文件字节级校验:64B 定长 + 字段布局 + NUL 填充 */
{
FILE *fp = fopen("sim_run.log", "rb");
uint8_t rec[SYSLOG_REC_SIZE];
if (fp == NULL) {
printf("[logtest] FAIL open sim_run.log\n");
return 1;
}
if (fread(rec, 1u, sizeof(rec), fp) != sizeof(rec)) {
fclose(fp);
printf("[logtest] FAIL record size != %u\n", (unsigned)SYSLOG_REC_SIZE);
return 1;
}
fclose(fp);
if (rec[SYSLOG_TS_LEN] != ' ' || rec[SYSLOG_DESC_OFFS - 1u] != ' ') {
printf("[logtest] FAIL field separators at [%u]/[%u]\n",
(unsigned)SYSLOG_TS_LEN, (unsigned)(SYSLOG_DESC_OFFS - 1u));
return 1;
}
for (uint32_t i = SYSLOG_DESC_OFFS + strlen("power on"); i < SYSLOG_REC_SIZE; i++) {
if (rec[i] != '\0') {
printf("[logtest] FAIL NUL padding at [%u]\n", (unsigned)i);
return 1;
}
}
}
printf("[logtest] OK count/get/epoch/bytes\n");
printf("[logtest] PASS\n");
return 0;
}
/**
* @brief 程序入口
*/
int main(int argc, char **argv)
{
(void)argc;
(void)argv;
/* 截图/自检模式:--screenshot / --keys / --standby / --cfgtest / --logtest */
for (int i = 1; i < argc; i++) {
if (strcmp(argv[i], "--screenshot") == 0 && (i + 1) < argc) {
return run_headless("", argv[i + 1], 0);
}
if (strcmp(argv[i], "--keys") == 0 && (i + 2) < argc) {
return run_headless(argv[i + 1], argv[i + 2], 0);
}
if (strcmp(argv[i], "--standby") == 0 && (i + 1) < argc) {
return run_headless("", argv[i + 1], 1);
}
if (strcmp(argv[i], "--cfgtest") == 0) {
return run_cfgtest();
}
if (strcmp(argv[i], "--logtest") == 0) {
return run_logtest();
}
}
/* 窗口模式空闲超时覆盖(--idle <秒>,默认 60) */
uint32_t idle_s = 60u;
for (int i = 1; i < argc; i++) {
if (strcmp(argv[i], "--idle") == 0 && (i + 1) < argc) {
int v = atoi(argv[i + 1]);
if (v > 0) {
idle_s = (uint32_t)v;
}
}
}
/* 初始化 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) {
@@ -59,6 +526,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");
@@ -66,25 +536,46 @@ int main(int argc, char **argv)
return 1;
}
/* 待机显示页(空闲超时 --idle <秒>,默认 60s;ENTER 返回) */
if (ui_standby_init(idle_s * 1000u, sim_time_cb) != 0) {
fprintf(stderr, "ui_standby_init failed\n");
} else {
printf("Standby: ready (idle=%us)\n", (unsigned)idle_s);
}
/* 启动 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");
printf("Press Ctrl+C or close window to exit\n\n");
/* 主循环:处理 SDL 事件 + 驱动 LVGL 刷新 */
/* 配置文件轮询起点(2s 周期,与 MCU netTask 一致) */
uint32_t cfg_last_poll = (uint32_t)SDL_GetTicks();
/* 主循环:驱动 LVGL 定时器与刷新
* 注意:SDL 事件(键盘/窗口/退出)由 LVGL SDL 驱动内部 5ms 定时器
* (lv_sdl_window.c 的 sdl_event_handler)统一轮询分发,
* 本循环不得再调 SDL_PollEvent,否则会先消费事件队列,
* 导致键盘输入永远到不了 LVGL */
while (1) {
/* 处理 SDL 事件(窗口关闭等) */
SDL_Event event;
while (SDL_PollEvent(&event)) {
if (event.type == SDL_QUIT) {
printf("SDL_QUIT received, exiting...\n");
SDL_Quit();
return 0;
}
if (event.type == SDL_KEYDOWN && event.key.keysym.sym == SDLK_ESCAPE) {
/* Escape 键:如果在子页面则返回上一级 */
/* LVGL keyboard 驱动已自动处理 LV_KEY_ESC */
}
/* 轮询 RemoDispBus 协议(非阻塞收发) */
ui_net_sink_poll();
/* 执行待处理"保存网口配置"请求(单线程主循环 = 配置引擎属主;
* 无请求 = 空操作);"立即应用"回调未注册 → 空操作 */
net_cfg_save_run();
/* 配置文件轮询(2s 周期:外部文件变更 4s 沉降后自动重载) */
uint32_t now_ms = (uint32_t)SDL_GetTicks();
if ((uint32_t)(now_ms - cfg_last_poll) >= 2000u) {
cfg_last_poll = now_ms;
cfg_file_poll();
}
/* 驱动 LVGL 定时器与刷新 */
+36
View File
@@ -0,0 +1,36 @@
# simulator/sim/ — 模拟器平台层
> PC 端 LVGL 模拟器的**平台适配层**:把 MCU 侧的硬件/RTOS 接口替换成 PC 实现,
> 使 **UI 源码(`Drivers/BSP/UI/`)与固件共用同一套代码**。
## 文件一览
| 文件 | 替换的 MCU 侧实现 | 作用 |
|---|---|---|
| `sim_port.c/h` | `ui_port.c` | **显示/输入移植层**:SDL 窗口 → LVGL 显示驱动 + 键鼠/触摸输入 |
| `net_socket_sim.c` | `net_socket.c` | 网络 Socket 的 PC 实现(宿主 BSD socket) |
| `cfg_file_store_sim.c` | `cfg_file_store_mcu.c` | 配置存储的 PC 后端(宿主堆) |
| `sim_syslog.c` | `sys_log_mcu.c` | 日志的 PC 实现(写文件而非 NAND) |
| `ui_net_sink_backend_sim.c` | `ui_net_sink_backend_mcu.c` | 推流后端的 PC 实现 |
| `sim_latency_stub.c` | — | 时延打桩(模拟链路延迟,供测试) |
## 设计模式
MCU 与模拟器**共用核心逻辑,仅后端不同**:
```
共享核心(无分配符号)
├── cfg_file.c(配置引擎)
└── ui_*(LVGL UI)
↓ 后端抽象接口
┌─────────┴─────────┐
MCU 后端(本工程 *_mcu.c) PC 后端(本目录 *_sim.c)
```
> 配置引擎后端:MCU = FreeRTOS 堆、模拟器 = 宿主堆,**共享核心零分配符号**。
## 相关文档
- 模拟器总说明:`../README.md`
- UI 架构与移植要点:`docs/UI设计/UI_设计架构.md`
- 模拟器部署:`docs/UI设计/LVGL模拟器部署与使用说明.md`
+179
View File
@@ -0,0 +1,179 @@
/*
* 模块名称:cfg_file_store (模拟器后端)
* 模块功能:cfg_file 存储后端——模拟器工作目录宿主文件 sim_config.ini;
* 原子写 = 临时文件 sim_config.ini.tmp + rename;mtime 变更令牌 =
* 宿主文件 mtime(秒级)
* 适用平台:PC 模拟器
* 作者:王建锋
* 创建日期:2026-09-14
* 修改记录:
* v1.0 2026-09-14 王建锋 创建初始版本
* v1.1 2026-09-18 王建锋 增 cfg_store_alloc / cfg_store_free / cfg_store_heap_free
* (宿主 CRT 堆实现,对齐 MCU 端 FreeRTOS 堆语义,P0-1)
*/
/* 头文件包含区 */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include "cfg_file_store.h"
/* 静态变量定义区 */
static const char s_path[] = "sim_config.ini";
static const char s_path_tmp[] = "sim_config.ini.tmp";
/* 函数定义区 */
/*
* 函数功能:读文件(最多 cap 字节)
* 入口参数:p_path - 文件路径 const char *
* p_buf - 接收缓冲(不可为 NULL) uint8_t *
* cap - 缓冲容量 size_t
* p_nread - 实际读字节数输出(不可为 NULL) size_t *
* 返回值:0 - 成功;-1 - 文件缺失/读失败 int
* 限定条件:无
*/
int cfg_store_read(const char *p_path, uint8_t *p_buf, size_t cap, size_t *p_nread) {
FILE *fp;
size_t n;
if (p_buf == NULL || p_nread == NULL) {
return -1;
}
fp = fopen(p_path, "rb");
if (fp == NULL) {
return -1;
}
n = fread(p_buf, 1u, cap, fp);
(void)fclose(fp);
*p_nread = n;
return 0;
}
/*
* 函数功能:原子写文件(先写 sim_config.ini.tmp,成功后 rename 覆盖目标)
* 入口参数:p_path - 目标文件路径 const char *
* p_buf - 待写数据(不可为 NULL) const uint8_t *
* len - 数据长度 size_t
* 返回值:0 - 成功;-1 - 写/改名失败(临时文件清理) int
* 限定条件:MSVC rename() 不覆盖已存在目标(C1117),须先 remove 再 rename
* (tmp 已完整写盘关闭后操作,中断窗口仅毫秒级;目标丢失时引擎
* 下次加载自动重建出厂模板,与 MCU 端 f_rename 直接替换等效)
*/
int cfg_store_write_atomic(const char *p_path, const uint8_t *p_buf, size_t len) {
FILE *fp;
size_t n;
if (p_buf == NULL) {
return -1;
}
fp = fopen(s_path_tmp, "wb");
if (fp == NULL) {
return -1;
}
n = fwrite(p_buf, 1u, len, fp);
(void)fclose(fp);
if (n != len) {
(void)remove(s_path_tmp);
return -1;
}
(void)remove(p_path); /* 目标可能不存在(首写):remove 失败忽略 */
if (rename(s_path_tmp, p_path) != 0) {
(void)remove(s_path_tmp);
return -1;
}
return 0;
}
/*
* 函数功能:文件状态(size + mtime 变更令牌)
* 入口参数:p_path - 文件路径 const char *
* p_st - 输出(不可为 NULL) cfg_file_stat_t *
* 返回值:0 - 成功;-1 - 文件缺失/读失败 int
* 限定条件:无
*/
int cfg_store_stat(const char *p_path, cfg_file_stat_t *p_st) {
struct stat st;
if (p_st == NULL) {
return -1;
}
if (stat(p_path, &st) != 0) {
return -1;
}
p_st->size = (uint32_t)st.st_size;
p_st->mtime = (uint32_t)st.st_mtime;
return 0;
}
/*
* 函数功能:删除文件
* 入口参数:p_path - 文件路径 const char *
* 返回值:0 - 成功;-1 - 文件缺失/删除失败 int
* 限定条件:无
*/
int cfg_store_remove(const char *p_path) {
if (remove(p_path) != 0) {
return -1;
}
return 0;
}
/*
* 函数功能:取配置文件路径
* 入口参数:无
* 返回值:"sim_config.ini"(模拟器工作目录) const char *
* 限定条件:无
*/
const char *cfg_store_config_path(void) {
return s_path;
}
/*
* 函数功能:取配置文件临时文件路径
* 入口参数:无
* 返回值:"sim_config.ini.tmp" const char *
* 限定条件:无
*/
const char *cfg_store_tmp_path(void) {
return s_path_tmp;
}
/*
* 旧版遗留档 getter(cfg_store_legacy_path)已于 2026-10-10 随旧档迁移
* net_cfg_legacy 一并删除:旧格式 sim_netcfg.bin 不再读取
*/
/*
* 函数功能:分配共享核心临时缓冲(宿主 CRT 堆)
* 入口参数:size - 字节数 size_t
* 返回值:缓冲指针(内容未清零);失败返回 NULL void *
* 限定条件:释放必须用 cfg_store_free
* 函数说明:模拟器进程堆以 MB 计,直接 malloc;与 MCU 端差异仅在本后端
*/
void *cfg_store_alloc(size_t size) {
return malloc(size);
}
/*
* 函数功能:释放 cfg_store_alloc 分配的临时缓冲
* 入口参数:p_buf - 缓冲指针(NULL 安全) void *
* 返回值:无
* 限定条件:p_buf 必须来自 cfg_store_alloc
*/
void cfg_store_free(void *p_buf) {
free(p_buf);
}
/*
* 函数功能:空闲堆字节数(诊断用)
* 入口参数:无
* 返回值:恒 0(宿主 CRT 不提供可移植的空闲堆查询) uint32_t
* 限定条件:无
*/
uint32_t cfg_store_heap_free(void) {
return 0u;
}
+786
View File
@@ -0,0 +1,786 @@
/*
* 模块名称:Network Socket API (Winsock 模拟器后端)
* 模块功能:以 Windows Winsock2 实现 net_socket.h 的 BSD Socket 核心 API,
* 使 PC 模拟器与 MCU 端可共用同一份上层网络代码(ui_net_sink.c 等)
* 适用平台:Windows (Winsock2);构建时须定义 NET_NO_OS(剥离 FreeRTOS 相关接口)
* 作者:王建锋
* 创建日期:2026-09-10
* 修改记录:
* 2026-09-10 王建锋 创建初始版本(RemoDispBus 双端共用 net_* 后端)
*
* 设计要点:
* 1. fd 采用与 MCU 端一致的"控制块索引"模型(0 ~ NET_MAX_SOCKETS-1)
* 2. 所有 Socket 创建即置为非阻塞(ioctlsocket FIONBIO),使 net_recv /
* net_accept 的 DONTWAIT 语义与 MCU 端一致(无数据返回 -1 + NET_ERR_WOULDBLOCK)
* 3. 代次号在模拟器上恒为 0(无"网络重建"概念),net_get_generation 恒返回 0
* 4. net_get_sock 以 in_use 判活,供上层判断"监听 Socket 是否随客户端一同失效"
* 5. Winsock 句柄存放于并行数组 s_ws[],不侵入共享的 net_sock_t 结构体
*/
/*
* 注意:net_types.h(经 net_socket.h)必须先于 winsock2.h 包含。
* MSVC 的 C 编译器在 winsock2.h 定义 Windows 宏之后再解析本项目的地址结构体
* (struct net_in_addr / net_sockaddr_in)会报 C2143 / C2079;ARMCC 无此限制。
* 故此处先引入 net 类型,再引入 Winsock(net_socket.h 本身不依赖 Winsock)。
*/
#include "net_socket.h"
#ifndef WIN32_LEAN_AND_MEAN
#define WIN32_LEAN_AND_MEAN
#endif
#include <winsock2.h>
#include <ws2tcpip.h>
#include <string.h>
#include <stdio.h>
/* WSA 版本号(MAKEWORD(2,2)) */
#define NET_SIM_WSA_VERSION 0x0202
/* 监听 backlog(单客户端) */
#define NET_SIM_BACKLOG 1
/* 控制块公开视图(in_use 判活,供 net_get_sock)与 Winsock 句柄并行数组 */
static net_sock_t s_socks[NET_MAX_SOCKETS];
static SOCKET s_ws[NET_MAX_SOCKETS];
static int s_net_errno = NET_OK; /* 最近一次操作错误码 */
static int s_generation = 0; /* 网络代次(模拟器恒为 0) */
/*
* 函数功能:确保 Winsock 已初始化(幂等)
* 入口参数:无
* 出口参数:无
* 返回值:0 - 就绪,-1 - WSAStartup 失败
* 限定条件:无
* 函数说明:任何 Socket 操作前先调用;已初始化则直接返回
*/
static int ensure_wsa(void)
{
static int s_done = 0;
if (s_done == 0) {
WSADATA wsa;
if (WSAStartup(NET_SIM_WSA_VERSION, &wsa) != 0) {
return -1;
}
s_done = 1;
}
return 0;
}
/*
* 函数功能:分配一个空闲 fd(控制块索引)
* 入口参数:无
* 出口参数:无
* 返回值:空闲索引(0 ~ NET_MAX_SOCKETS-1),-1 表示无空闲
* 限定条件:s_socks 已定义
* 函数说明:线性扫描,返回首个 in_use == 0 的控制块索引
*/
static int alloc_fd(void)
{
int i;
for (i = 0; i < NET_MAX_SOCKETS; i++) {
if (s_socks[i].in_use == 0) {
return i;
}
}
return -1;
}
/*
* 函数功能:net_sockaddr 拷贝为 Winsock sockaddr_in
* 入口参数:addr - 源地址(net_sockaddr,AF_INET 实为 net_sockaddr_in)
* out - 输出 Winsock 地址
* 出口参数:out - 填充
* 返回值:无
* 限定条件:两结构体布局一致(均 16 字节,字段偏移相同)
* 函数说明:字段偏移一致,按 sizeof 直接拷贝
*/
static void to_ws_addr(const struct net_sockaddr *addr, struct sockaddr_in *out)
{
memcpy(out, addr, sizeof(struct sockaddr_in));
}
/*
* 函数功能:Winsock sockaddr_in 拷贝为 net_sockaddr
* 入口参数:in - 源 Winsock 地址
* out - 输出 net_sockaddr
* 出口参数:out - 填充
* 返回值:无
* 限定条件:两结构体布局一致(均 16 字节,字段偏移相同)
* 函数说明:字段偏移一致,按 sizeof 直接拷贝
*/
static void from_ws_addr(const struct sockaddr_in *in, struct net_sockaddr *out)
{
memcpy(out, in, sizeof(struct sockaddr_in));
}
/*
* 函数功能:网络子系统初始化(模拟器:仅确保 Winsock 就绪)
* 入口参数:ip/mask/gateway/mac - 均忽略(模拟器无 CH395F 协议栈)
* 出口参数:无
* 返回值:0 - 成功,-1 - WSA 初始化失败
* 限定条件:无
* 函数说明:与 MCU 端 net_init 语义对齐,供上层统一调用
*/
int net_init(const char *ip, const char *mask, const char *gateway, const uint8_t *mac)
{
(void)ip;
(void)mask;
(void)gateway;
(void)mac;
if (ensure_wsa() != 0) {
s_net_errno = NET_ERR;
return -1;
}
s_net_errno = NET_OK;
return 0;
}
/*
* 函数功能:取网络代次号
* 入口参数:无
* 返回值:代次号(模拟器恒为 0)
* 限定条件:无
* 函数说明:模拟器无网络重建,代次恒 0,使上层代次自愈逻辑空转
*/
uint32_t net_get_generation(void)
{
return (uint32_t)s_generation;
}
/*
* 函数功能:递增网络代次号
* 入口参数:无
* 返回值:无
* 限定条件:无
* 函数说明:模拟器不会触发,保留接口以对齐 API
*/
void net_generation_inc(void)
{
s_generation++;
}
/*
* 函数功能:轮询所有 Socket 状态
* 入口参数:无
* 返回值:0(Winsock 非阻塞 I/O 直接反映事件,无需中断轮询)
* 限定条件:无
* 函数说明:模拟器无 CH395F 中断,空操作
*/
int net_poll(void)
{
return 0;
}
/*
* 函数功能:创建 socket
* 入口参数:domain - 地址族(仅 AF_INET)
* type - NET_SOCK_STREAM(TCP) 或 NET_SOCK_DGRAM(UDP)
* 出口参数:无
* 返回值:socket 描述符(0 ~ NET_MAX_SOCKETS-1),失败 -1
* 限定条件:Winsock 已就绪
* 函数说明:分配空闲控制块,创建 Winsock SOCKET 并置非阻塞
*/
int net_socket(int domain, int type)
{
int idx;
int is_udp;
SOCKET ws;
u_long nb = 1;
(void)domain;
if (ensure_wsa() != 0) {
s_net_errno = NET_ERR;
return -1;
}
idx = alloc_fd();
if (idx < 0) {
s_net_errno = NET_ERR;
return -1;
}
is_udp = (type == NET_SOCK_DGRAM);
ws = socket(AF_INET, is_udp ? SOCK_DGRAM : SOCK_STREAM,
is_udp ? IPPROTO_UDP : IPPROTO_TCP);
if (ws == INVALID_SOCKET) {
s_net_errno = NET_ERR;
return -1;
}
if (ioctlsocket(ws, FIONBIO, &nb) != 0) {
closesocket(ws);
s_net_errno = NET_ERR;
return -1;
}
memset(&s_socks[idx], 0, sizeof(net_sock_t));
s_socks[idx].in_use = 1;
s_socks[idx].type = (uint8_t)type;
s_socks[idx].state = NET_SOCK_STATE_CREATED;
s_socks[idx].ch395_sock = (uint8_t)idx;
s_ws[idx] = ws;
s_net_errno = NET_OK;
return idx;
}
/*
* 函数功能:绑定本地地址和端口
* 入口参数:sockfd - socket 描述符
* addr - 本地地址(net_sockaddr,AF_INET)
* addrlen - 地址结构体长度
* 出口参数:无
* 返回值:0 成功,-1 失败
* 限定条件:socket 已创建
* 函数说明:设置 SO_REUSEADDR 后 bind,便于断线后重监听复用端口
*/
int net_bind(int sockfd, const struct net_sockaddr *addr, int addrlen)
{
struct sockaddr_in wsaddr;
int yes = 1;
if (sockfd < 0 || sockfd >= NET_MAX_SOCKETS || s_socks[sockfd].in_use == 0) {
s_net_errno = NET_ERR_BADF;
return -1;
}
if (addr == NULL || addrlen < (int)sizeof(struct sockaddr_in)) {
s_net_errno = NET_ERR_INVAL;
return -1;
}
setsockopt(s_ws[sockfd], SOL_SOCKET, SO_REUSEADDR, (const char *)&yes, sizeof(yes));
to_ws_addr(addr, &wsaddr);
if (bind(s_ws[sockfd], (struct sockaddr *)&wsaddr, sizeof(wsaddr)) == SOCKET_ERROR) {
s_net_errno = NET_ERR;
return -1;
}
s_socks[sockfd].state = NET_SOCK_STATE_BOUND;
s_net_errno = NET_OK;
return 0;
}
/*
* 函数功能:TCP Server 监听
* 入口参数:sockfd - socket 描述符
* 出口参数:无
* 返回值:0 成功,-1 失败
* 限定条件:socket 已绑定
* 函数说明:backlog 为 1(单客户端),监听 Socket 保持非阻塞
*/
int net_listen(int sockfd)
{
if (sockfd < 0 || sockfd >= NET_MAX_SOCKETS || s_socks[sockfd].in_use == 0) {
s_net_errno = NET_ERR_BADF;
return -1;
}
if (listen(s_ws[sockfd], NET_SIM_BACKLOG) == SOCKET_ERROR) {
s_net_errno = NET_ERR;
return -1;
}
s_socks[sockfd].state = NET_SOCK_STATE_LISTENING;
s_net_errno = NET_OK;
return 0;
}
/*
* 函数功能:TCP Server 接受连接
* 入口参数:sockfd - 监听 socket 描述符
* addr - 输出:客户端地址(可为 NULL)
* addrlen - 输入输出:地址长度(addr 非空时有效)
* 出口参数:addr/addrlen - 填充客户端地址
* 返回值:新 socket 描述符,失败 -1
* 限定条件:socket 正在监听
* 函数说明:非阻塞监听下无新连接时返回 -1 并置 NET_ERR_WOULDBLOCK;
* 与 MCU 端不同,Winsock 的 accept 返回独立于监听 Socket 的新 fd
*/
int net_accept(int sockfd, struct net_sockaddr *addr, int *addrlen)
{
struct sockaddr_in wscli;
int wslen;
SOCKET cws;
int nidx;
u_long nb = 1;
if (sockfd < 0 || sockfd >= NET_MAX_SOCKETS || s_socks[sockfd].in_use == 0) {
s_net_errno = NET_ERR_BADF;
return -1;
}
wslen = sizeof(wscli);
cws = accept(s_ws[sockfd], (addr != NULL) ? (struct sockaddr *)&wscli : NULL,
(addr != NULL) ? &wslen : NULL);
if (cws == INVALID_SOCKET) {
int err = WSAGetLastError();
s_net_errno = (err == WSAEWOULDBLOCK) ? NET_ERR_WOULDBLOCK : NET_ERR;
return -1;
}
if (addr != NULL && addrlen != NULL) {
from_ws_addr(&wscli, addr);
*addrlen = (int)sizeof(struct net_sockaddr_in);
}
nidx = alloc_fd();
if (nidx < 0) {
closesocket(cws);
s_net_errno = NET_ERR;
return -1;
}
ioctlsocket(cws, FIONBIO, &nb);
memset(&s_socks[nidx], 0, sizeof(net_sock_t));
s_socks[nidx].in_use = 1;
s_socks[nidx].type = (uint8_t)NET_SOCK_STREAM;
s_socks[nidx].state = NET_SOCK_STATE_ESTABLISHED;
s_socks[nidx].ch395_sock = (uint8_t)nidx;
s_ws[nidx] = cws;
s_net_errno = NET_OK;
return nidx;
}
/*
* 函数功能:TCP Client 发起连接
* 入口参数:sockfd - socket 描述符
* addr - 服务器地址
* addrlen - 地址长度
* 出口参数:无
* 返回值:0 成功(或已发起),-1 失败
* 限定条件:socket 已创建
* 函数说明:非阻塞 Socket 上连接未完成时返回 -1 + NET_ERR_WOULDBLOCK
*/
int net_connect(int sockfd, const struct net_sockaddr *addr, int addrlen)
{
struct sockaddr_in wsaddr;
if (sockfd < 0 || sockfd >= NET_MAX_SOCKETS || s_socks[sockfd].in_use == 0) {
s_net_errno = NET_ERR_BADF;
return -1;
}
if (addr == NULL || addrlen < (int)sizeof(struct sockaddr_in)) {
s_net_errno = NET_ERR_INVAL;
return -1;
}
to_ws_addr(addr, &wsaddr);
if (connect(s_ws[sockfd], (struct sockaddr *)&wsaddr, sizeof(wsaddr)) == 0) {
s_socks[sockfd].state = NET_SOCK_STATE_ESTABLISHED;
s_net_errno = NET_OK;
return 0;
}
{
int err = WSAGetLastError();
s_net_errno = (err == WSAEWOULDBLOCK) ? NET_ERR_WOULDBLOCK : NET_ERR;
}
return -1;
}
/*
* 函数功能:发送数据(TCP)
* 入口参数:sockfd - socket 描述符
* buf - 数据缓冲区
* len - 数据长度
* flags - 通常为 0
* 出口参数:无
* 返回值:实际发送字节数,-1 错误;发送缓冲满时返回 0 + NET_ERR_WOULDBLOCK
* 限定条件:socket 已建立,buf 非空
* 函数说明:非阻塞单次发送(保持 UI 主循环不阻塞);协议帧小且对端主动
* 读取,实践中一次即发完
*/
int net_send(int sockfd, const void *buf, int len, int flags)
{
int r;
if (sockfd < 0 || sockfd >= NET_MAX_SOCKETS || s_socks[sockfd].in_use == 0) {
s_net_errno = NET_ERR_BADF;
return -1;
}
if (buf == NULL || len < 0) {
s_net_errno = NET_ERR_INVAL;
return -1;
}
r = send(s_ws[sockfd], (const char *)buf, len, flags);
if (r >= 0) {
s_socks[sockfd].send_bytes += (uint32_t)r;
s_net_errno = NET_OK;
return r;
}
{
int err = WSAGetLastError();
if (err == WSAEWOULDBLOCK) {
s_net_errno = NET_ERR_WOULDBLOCK;
return 0;
}
s_net_errno = NET_ERR;
}
return -1;
}
/*
* 函数功能:接收数据(TCP)
* 入口参数:sockfd - socket 描述符
* buf - 接收缓冲区
* len - 缓冲区大小
* flags - 通常为 0,可设 NET_MSG_DONTWAIT(Socket 本就非阻塞)
* 出口参数:buf - 填充接收数据
* 返回值:实际接收字节数;0 = 对端关闭;-1 = 无数据(NET_ERR_WOULDBLOCK)或错误
* 限定条件:socket 已建立
* 函数说明:与 MCU 端语义一致——>0 有数据,0 对端关闭,<0 且 errno 区分
* "暂无数据"与"真错误"
*/
int net_recv(int sockfd, void *buf, int len, int flags)
{
int r;
(void)flags;
if (sockfd < 0 || sockfd >= NET_MAX_SOCKETS || s_socks[sockfd].in_use == 0) {
s_net_errno = NET_ERR_BADF;
return -1;
}
if (buf == NULL || len < 0) {
s_net_errno = NET_ERR_INVAL;
return -1;
}
r = recv(s_ws[sockfd], (char *)buf, len, 0);
if (r > 0) {
s_socks[sockfd].recv_bytes += (uint32_t)r;
s_net_errno = NET_OK;
return r;
}
if (r == 0) {
s_net_errno = NET_OK;
return 0;
}
{
int err = WSAGetLastError();
s_net_errno = (err == WSAEWOULDBLOCK) ? NET_ERR_WOULDBLOCK : NET_ERR;
}
return -1;
}
/*
* 函数功能:发送 UDP 数据报(含目标地址)
* 入口参数:sockfd - socket 描述符
* buf - 数据缓冲区
* len - 数据长度
* flags - 通常为 0
* dest_addr - 目标地址
* addrlen - 地址长度
* 出口参数:无
* 返回值:实际发送字节数,-1 失败
* 限定条件:socket 已创建,buf/dest_addr 非空
*/
int net_sendto(int sockfd, const void *buf, int len, int flags,
const struct net_sockaddr *dest_addr, int addrlen)
{
struct sockaddr_in wsaddr;
int r;
if (sockfd < 0 || sockfd >= NET_MAX_SOCKETS || s_socks[sockfd].in_use == 0) {
s_net_errno = NET_ERR_BADF;
return -1;
}
if (buf == NULL || len < 0 || dest_addr == NULL ||
addrlen < (int)sizeof(struct sockaddr_in)) {
s_net_errno = NET_ERR_INVAL;
return -1;
}
to_ws_addr(dest_addr, &wsaddr);
r = sendto(s_ws[sockfd], (const char *)buf, len, flags,
(struct sockaddr *)&wsaddr, sizeof(wsaddr));
if (r >= 0) {
s_net_errno = NET_OK;
return r;
}
{
int err = WSAGetLastError();
s_net_errno = (err == WSAEWOULDBLOCK) ? NET_ERR_WOULDBLOCK : NET_ERR;
}
return -1;
}
/*
* 函数功能:接收 UDP 数据报(含源地址)
* 入口参数:sockfd - socket 描述符
* buf - 接收缓冲区
* len - 缓冲区大小
* flags - 通常为 0
* src_addr - 输出:发送方地址(可为 NULL)
* addrlen - 输入输出:源地址长度(可为 NULL)
* 出口参数:buf/src_addr/addrlen - 填充
* 返回值:实际接收字节数,-1 失败
* 限定条件:socket 已创建,buf 非空
*/
int net_recvfrom(int sockfd, void *buf, int len, int flags,
struct net_sockaddr *src_addr, int *addrlen)
{
struct sockaddr_in wsaddr;
int wslen;
int r;
(void)flags;
if (sockfd < 0 || sockfd >= NET_MAX_SOCKETS || s_socks[sockfd].in_use == 0) {
s_net_errno = NET_ERR_BADF;
return -1;
}
if (buf == NULL || len < 0) {
s_net_errno = NET_ERR_INVAL;
return -1;
}
wslen = sizeof(wsaddr);
r = recvfrom(s_ws[sockfd], (char *)buf, len, 0,
(struct sockaddr *)&wsaddr, &wslen);
if (r < 0) {
int err = WSAGetLastError();
s_net_errno = (err == WSAEWOULDBLOCK) ? NET_ERR_WOULDBLOCK : NET_ERR;
return -1;
}
if (src_addr != NULL && addrlen != NULL) {
from_ws_addr(&wsaddr, src_addr);
*addrlen = (int)sizeof(struct net_sockaddr_in);
}
s_net_errno = NET_OK;
return r;
}
/*
* 函数功能:关闭 socket
* 入口参数:sockfd - socket 描述符
* 出口参数:无
* 返回值:0 成功,-1 失败
* 限定条件:socket 已打开(in_use == 1)
* 函数说明:关闭 Winsock 句柄并释放控制块(in_use 清零,net_get_sock 转返回 NULL)
*/
int net_close(int sockfd)
{
if (sockfd < 0 || sockfd >= NET_MAX_SOCKETS) {
s_net_errno = NET_ERR_BADF;
return -1;
}
if (s_socks[sockfd].in_use == 0) {
s_net_errno = NET_ERR_BADF;
return -1;
}
closesocket(s_ws[sockfd]);
s_socks[sockfd].in_use = 0;
s_socks[sockfd].state = NET_SOCK_STATE_CLOSED;
s_ws[sockfd] = INVALID_SOCKET;
s_net_errno = NET_OK;
return 0;
}
/*
* 函数功能:注册事件回调
* 入口参数:sockfd - socket 描述符
* cb - 回调函数(NULL 取消注册)
* arg - 用户参数
* 出口参数:无
* 返回值:0 成功,-1 失败
* 限定条件:socket 已创建
* 函数说明:模拟器以非阻塞 I/O 直接感知事件,回调仅存储不主动触发
*/
int net_set_event_cb(int sockfd, net_event_cb_t cb, void *arg)
{
if (sockfd < 0 || sockfd >= NET_MAX_SOCKETS || s_socks[sockfd].in_use == 0) {
s_net_errno = NET_ERR_BADF;
return -1;
}
s_socks[sockfd].event_cb = cb;
s_socks[sockfd].event_arg = arg;
s_net_errno = NET_OK;
return 0;
}
/*
* 函数功能:获取 socket 最后错误码
* 入口参数:无
* 返回值:最近一次 net_* 操作设置的错误码(NET_OK / NET_ERR_xxx)
* 限定条件:无
*/
int net_get_errno(void)
{
return s_net_errno;
}
/*
* 函数功能:将点分十进制 IP 字符串转为网络字节序 32 位地址
* 入口参数:cp - 点分十进制字符串(如 "192.168.1.100")
* 出口参数:无
* 返回值:网络字节序 IP(参数无效返回 0)
* 限定条件:无
* 函数说明:逐段解析后按 NET_HTONL 转网络字节序(与 MCU 端实现一致)
*/
uint32_t net_inet_addr(const char *cp)
{
uint32_t addr = 0;
int i;
if (cp == NULL) {
return 0;
}
for (i = 0; i < 4; i++) {
uint32_t val = 0;
while (*cp >= '0' && *cp <= '9') {
val = val * 10 + (uint32_t)(*cp - '0');
cp++;
}
addr |= (val << (i * 8));
if (*cp == '.') {
cp++;
}
}
return NET_HTONL(addr);
}
/*
* 函数功能:将网络字节序 32 位 IP 转为点分十进制字符串
* 入口参数:addr - 网络字节序 IP
* buf - 输出缓冲区(至少 16 字节)
* 出口参数:buf - 填充字符串
* 返回值:buf 指针(buf 为 NULL 时返回 NULL)
* 限定条件:buf 非空且长度足够
*/
char *net_inet_ntoa(uint32_t addr, char *buf)
{
uint32_t naddr = NET_NTOHL(addr);
if (buf == NULL) {
return NULL;
}
snprintf(buf, 16, "%lu.%lu.%lu.%lu",
(unsigned long)(naddr & 0xFF),
(unsigned long)((naddr >> 8) & 0xFF),
(unsigned long)((naddr >> 16) & 0xFF),
(unsigned long)((naddr >> 24) & 0xFF));
return buf;
}
/*
* 函数功能:端口字节序转换(主机序 -> 网络序)
* 入口参数:hostshort - 主机序端口
* 返回值:网络序端口
* 限定条件:无
*/
uint16_t net_htons(uint16_t hostshort)
{
return NET_HTONS(hostshort);
}
/*
* 函数功能:端口字节序转换(网络序 -> 主机序)
* 入口参数:netshort - 网络序端口
* 返回值:主机序端口
* 限定条件:无
*/
uint16_t net_ntohs(uint16_t netshort)
{
return NET_NTOHS(netshort);
}
/*
* 函数功能:IP 地址字节序转换(主机序 -> 网络序)
* 入口参数:hostlong - 主机序 IP
* 返回值:网络序 IP
* 限定条件:无
*/
uint32_t net_htonl(uint32_t hostlong)
{
return NET_HTONL(hostlong);
}
/*
* 函数功能:IP 地址字节序转换(网络序 -> 主机序)
* 入口参数:netlong - 网络序 IP
* 返回值:主机序 IP
* 限定条件:无
*/
uint32_t net_ntohl(uint32_t netlong)
{
return NET_NTOHL(netlong);
}
/*
* 函数功能:获取 Socket 控制块指针
* 入口参数:sockfd - socket 描述符
* 返回值:控制块指针;描述符越界或已释放(in_use == 0)时返回 NULL
* 限定条件:无
* 函数说明:上层据此判断"监听 Socket 是否随客户端一同失效"(单连接模型)
*/
net_sock_t *net_get_sock(int sockfd)
{
if (sockfd < 0 || sockfd >= NET_MAX_SOCKETS) {
return NULL;
}
if (s_socks[sockfd].in_use == 0) {
return NULL;
}
return &s_socks[sockfd];
}
/*
* 函数功能:获取 Socket 本地地址(IP + Port)
* 入口参数:sockfd - socket 描述符
* addr - 输出:本地地址
* addrlen - 输入输出:地址结构体长度
* 出口参数:addr/addrlen - 填充
* 返回值:0 成功,-1 失败
* 限定条件:socket 已创建
* 函数说明:经 Winsock getsockname 读取实际绑定地址
*/
int net_getsockname(int sockfd, struct net_sockaddr *addr, int *addrlen)
{
struct sockaddr_in wsaddr;
int wslen;
if (sockfd < 0 || sockfd >= NET_MAX_SOCKETS || s_socks[sockfd].in_use == 0) {
s_net_errno = NET_ERR_BADF;
return -1;
}
if (addr == NULL || addrlen == NULL ||
*addrlen < (int)sizeof(struct net_sockaddr_in)) {
s_net_errno = NET_ERR_INVAL;
return -1;
}
wslen = sizeof(wsaddr);
if (getsockname(s_ws[sockfd], (struct sockaddr *)&wsaddr, &wslen) == SOCKET_ERROR) {
s_net_errno = NET_ERR;
return -1;
}
from_ws_addr(&wsaddr, addr);
*addrlen = (int)sizeof(struct net_sockaddr_in);
s_net_errno = NET_OK;
return 0;
}
+49
View File
@@ -0,0 +1,49 @@
/*
* 模块名称:sim_latency_stub
* 模块功能:链路时延探针(net_latency_test.h)的宿主空实现——探针打点站点位于
* 双端共享核心(ui_net_sink.c,TEST_LATENCY_PROBE 使能时编译),
* MCU 端符号由 test/board/net_latency_test.c 提供(DWT CYCCNT 时基,
* MCU 专属、宿主不可编译);模拟器构建在此以空操作满足链接,
* 不计时、不出报告(时延测量属台架能力,模拟器无意义)
* 适用平台:PC 模拟器(仅 simulator 构建编译本文件)
* 作者:王建锋
* 创建日期:2026-09-21
* 修改记录:
* 2026-09-21 王建锋 初版:修复模拟器链接断链(ui_net_sink.c 探针引用
* lat_mark/lat_mark_us/lat_probe_add_seg 无宿主定义)
*/
#include "net_latency_test.h"
/*
* 函数功能:打点标记(空操作,字段清零保证可辨识)
* 入口参数:p - 打点标记容器
* 返回值:无
*/
void lat_mark(lat_tmark_t *p) {
if (p != NULL) {
p->cyc = 0u;
p->tick = 0u;
}
}
/*
* 函数功能:打点差值(恒 0:模拟器不测时延)
* 入口参数:p - 打点标记容器
* 返回值:恒 0(微秒) uint32_t
*/
uint32_t lat_mark_us(const lat_tmark_t *p) {
(void)p;
return 0u;
}
/*
* 函数功能:按段名追加样本(空操作)
* 入口参数:seg - 段名 const char *
* us - 样本值(微秒) uint32_t
* 返回值:无
*/
void lat_probe_add_seg(const char *seg, uint32_t us) {
(void)seg;
(void)us;
}
+141
View File
@@ -0,0 +1,141 @@
/*
* 模块名称:Simulator Display Port (SDL2 模拟器端口)
* 模块功能:为 PC 模拟器提供 LVGL SDL2 显示与键盘输入端口
* 适用平台:Windows / Linux / macOS (SDL2)
* 说明:替代 MCU 端的 ui_port.c,用于 PC 上 UI 开发与调试
*/
#include "sim_port.h"
#include "lvgl.h"
/* SDL 驱动头文件(由 lv_conf.h 中 LV_SDL_INCLUDE_PATH 指定) */
#define SDL_MAIN_HANDLED
#include <SDL.h>
/* 键盘输入设备句柄(全局,供 main.c SDL 事件循环读取) */
static lv_indev_t *g_kbd_indev = NULL;
/*
* 函数功能:初始化 SDL2 显示窗口与键盘输入设备
* 入口参数:hor_res - 水平分辨率 int32_t
* ver_res - 垂直分辨率 int32_t
* 返回值:0 - 成功,-1 - 失败
* 限定条件:lv_init() 已调用
* 函数说明:1. 创建 SDL 窗口显示设备
* 2. 创建键盘输入设备及默认输入组
* 3. 不创建鼠标/触摸设备(模拟器仅用键盘导航)
*/
int sim_port_init(int32_t hor_res, int32_t ver_res)
{
/* 创建默认输入组 */
lv_group_t *g = lv_group_create();
if (g == NULL) {
return -1;
}
lv_group_set_default(g);
/* 创建 SDL 窗口显示设备 */
lv_display_t *disp = lv_sdl_window_create(hor_res, ver_res);
if (disp == NULL) {
return -1;
}
lv_display_set_default(disp);
/* 初始化 mono 主题(1bpp 单色屏:白字黑底),并应用默认字体 */
#if LV_USE_THEME_MONO
lv_theme_t *th = lv_theme_mono_init(disp, true, LV_FONT_DEFAULT);
if (th != NULL) {
lv_display_set_theme(disp, th);
}
#endif
/* 创建键盘输入设备(方向键/确认/返回 → LVGL keypad) */
g_kbd_indev = lv_sdl_keyboard_create();
if (g_kbd_indev == NULL) {
return -1;
}
lv_indev_set_group(g_kbd_indev, lv_group_get_default());
lv_indev_set_display(g_kbd_indev, disp);
return 0;
}
/*
* 函数功能:获取 SDL 显示设备句柄
* 入口参数:无
* 返回值:显示设备指针 lv_display_t * 可能为 NULL
* 限定条件:sim_port_init() 已成功调用
*/
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
+204
View File
@@ -0,0 +1,204 @@
/*
* 模块名称:System Log (模拟器后端) — 运行/自检记录落盘
* 模块功能:宿主文件 sim_run.log / sim_selftest.log(工作目录),与 MCU 端同格式
* (定长 64B 记录);单线程直写,无队列
* 适用平台:PC 模拟器
* 作者:王建锋
* 创建日期:2026-09-14
* 修改记录:
* v1.0 2026-09-14 王建锋 创建初始版本(设计见 docs/子系统设计/运行与自检记录设计说明.md §4)
*/
/* 头文件包含区 */
#include <stdio.h>
#include <string.h>
#include <time.h>
#include <sys/stat.h>
#include "sys_log.h"
/* 静态变量定义区 */
static const char s_paths[SYSLOG_COUNT][16] = { "sim_run.log", "sim_selftest.log" };
static const uint32_t s_caps[SYSLOG_COUNT] = { SYSLOG_RUN_MAX, SYSLOG_SELFTEST_MAX };
/* 函数定义区 */
/*
* 函数功能:取当前宿主本地时间(ISO 8601 空格变体 + epoch)
* 入口参数:p_ts - 输出缓冲(≥SYSLOG_TS_LEN+1 字节,不可为 NULL) char *
* p_epoch- 输出 epoch 秒(不可为 NULL) uint32_t *
* 返回值:无
*/
static void s_now(char *p_ts, uint32_t *p_epoch) {
time_t t = time(NULL);
struct tm tmv;
#if defined(_WIN32)
localtime_s(&tmv, &t);
#else
localtime_r(&t, &tmv);
#endif
(void)sprintf(p_ts, "%04d-%02d-%02d %02d:%02d:%02d",
tmv.tm_year + 1900, tmv.tm_mon + 1, tmv.tm_mday,
tmv.tm_hour, tmv.tm_min, tmv.tm_sec);
*p_epoch = (uint32_t)t;
}
/*
* 函数功能:追加一条记录(格式化 64B;容量满先删除重建)
* 入口参数:id - 日志 ID syslog_id_t
* desc - 描述串(UTF-8,超长截断至 SYSLOG_DESC_MAX) const char *
* 返回值:0 - 成功;-1 - 写失败 int
*/
static int s_append(syslog_id_t id, const char *desc) {
FILE *fp;
uint8_t rec[SYSLOG_REC_SIZE];
char ts[SYSLOG_TS_LEN + 1u];
uint32_t epoch = 0u;
size_t dl;
if ((uint32_t)id >= (uint32_t)SYSLOG_COUNT || desc == NULL) {
return -1;
}
if (syslog_count(id) >= s_caps[id]) {
(void)remove(s_paths[id]);
}
memset(rec, 0u, sizeof(rec));
s_now(ts, &epoch);
memcpy(rec, ts, SYSLOG_TS_LEN);
rec[SYSLOG_TS_LEN] = ' ';
(void)sprintf((char *)(rec + SYSLOG_EPOCH_OFFS), "%010u", (unsigned int)epoch);
rec[SYSLOG_DESC_OFFS - 1u] = ' ';
dl = strlen(desc);
if (dl > SYSLOG_DESC_MAX) {
dl = SYSLOG_DESC_MAX;
}
memcpy(rec + SYSLOG_DESC_OFFS, desc, dl);
fp = fopen(s_paths[id], "ab");
if (fp == NULL) {
return -1;
}
if (fwrite(rec, 1u, sizeof(rec), fp) != sizeof(rec)) {
(void)fclose(fp);
return -1;
}
(void)fclose(fp);
return 0;
}
/*
* 函数功能:读取第 idx 条原始记录(64B)
* 入口参数:id - 日志 ID syslog_id_t
* idx - 记录序号(0 起) uint32_t
* p_rec- 输出缓冲(SYSLOG_REC_SIZE 字节,不可为 NULL) uint8_t *
* 返回值:0 - 成功;-1 - 越界/读失败 int
*/
static int s_read_raw(syslog_id_t id, uint32_t idx, uint8_t *p_rec) {
FILE *fp;
if (p_rec == NULL || idx >= syslog_count(id)) {
return -1;
}
fp = fopen(s_paths[id], "rb");
if (fp == NULL) {
return -1;
}
if (fseek(fp, (long)idx * SYSLOG_REC_SIZE, SEEK_SET) != 0 ||
fread(p_rec, 1u, sizeof(uint8_t) * SYSLOG_REC_SIZE, fp) != SYSLOG_REC_SIZE) {
(void)fclose(fp);
return -1;
}
(void)fclose(fp);
return 0;
}
/*
* 函数功能:投递一条日志(单线程直写宿主文件)
* 入口参数:id - 日志 ID syslog_id_t
* desc - 描述串(UTF-8,超长截断) const char *
* 返回值:无
*/
void syslog_post(syslog_id_t id, const char *desc) {
(void)s_append(id, desc);
}
/*
* 函数功能:获取记录条数(= filesize / SYSLOG_REC_SIZE,O(1))
* 入口参数:id - 日志 ID syslog_id_t
* 返回值:条数;文件缺失返回 0 uint32_t
*/
uint32_t syslog_count(syslog_id_t id) {
struct stat st;
if ((uint32_t)id >= (uint32_t)SYSLOG_COUNT) {
return 0u;
}
if (stat(s_paths[id], &st) != 0) {
return 0u;
}
return (uint32_t)st.st_size / SYSLOG_REC_SIZE;
}
/*
* 函数功能:读取第 idx 条记录(时间戳 + 描述,均 NUL 结尾)
* 入口参数:id - 日志 ID syslog_id_t
* idx - 记录序号(0 起) uint32_t
* p_ts - 时间戳输出缓冲(≥SYSLOG_TS_LEN+1 字节,不可为 NULL) char *
* ts_len - p_ts 容量 size_t
* p_desc - 描述输出缓冲(≥SYSLOG_DESC_MAX+1 字节,不可为 NULL) char *
* desc_len - p_desc 容量 size_t
* 返回值:0 - 成功;-1 - 越界/读失败 int
*/
int syslog_get_record(syslog_id_t id, uint32_t idx, char *p_ts, size_t ts_len, char *p_desc, size_t desc_len) {
uint8_t rec[SYSLOG_REC_SIZE];
size_t dl = 0u;
if (p_ts == NULL || p_desc == NULL || ts_len <= SYSLOG_TS_LEN || desc_len <= SYSLOG_DESC_MAX) {
return -1;
}
if (s_read_raw(id, idx, rec) != 0) {
return -1;
}
memcpy(p_ts, rec, SYSLOG_TS_LEN);
p_ts[SYSLOG_TS_LEN] = '\0';
while (dl < SYSLOG_DESC_MAX && rec[SYSLOG_DESC_OFFS + dl] != '\0') {
dl++;
}
if (dl >= desc_len) {
dl = desc_len - 1u;
}
memcpy(p_desc, rec + SYSLOG_DESC_OFFS, dl);
p_desc[dl] = '\0';
return 0;
}
/*
* 函数功能:读取末条记录的 epoch 秒(上电/重启分类用)
* 入口参数:id - 日志 ID syslog_id_t
* p_epoch- 输出缓冲(不可为 NULL;无记录时置 0) uint32_t *
* 返回值:0 - 成功读到末条;-1 - 文件缺失/空 int
*/
int syslog_last_epoch(syslog_id_t id, uint32_t *p_epoch) {
uint8_t rec[SYSLOG_REC_SIZE];
uint32_t v = 0u;
uint32_t i;
if (p_epoch == NULL || syslog_count(id) == 0u) {
*p_epoch = 0u;
return -1;
}
if (s_read_raw(id, syslog_count(id) - 1u, rec) != 0) {
*p_epoch = 0u;
return -1;
}
for (i = 0u; i < SYSLOG_EPOCH_LEN; i++) {
char c = (char)rec[SYSLOG_EPOCH_OFFS + i];
if (c >= '0' && c <= '9') {
v = v * 10u + (uint32_t)(c - '0');
}
}
*p_epoch = v;
return 0;
}
+75
View File
@@ -0,0 +1,75 @@
/*
* 模块名称:UI Network Sink Backend (模拟器实现)
* 模块功能:RemoDispBus 服务端在 PC 模拟器端的平台相关操作——
* 读回 SDL 屏幕转 1bpp(LCDMEM 数据源)与经 LVGL 注入远端按键
* 适用平台:Windows / Linux / macOS (SDL2)
* 作者:王建锋
* 创建日期:2026-09-10
* 修改记录:
* 2026-09-10 王建锋 创建初始版本(双端共用 ui_net_sink 的平台后端)
*/
#include "ui_net_sink_backend.h"
#include "ui_port.h" /* 远端按键码 UI_KEY_* 常量 */
#include "sim_port.h" /* sim_port_get_screen_mono */
#include "lvgl.h" /* lv_group_send_data / LV_KEY_* */
/*
* 函数功能:将远端按键码映射为 LVGL 按键码
* 入口参数:remo_key - 远端按键码(UI_KEY_* 之一)
* 返回值:对应 LV_KEY_* 值;无法识别返回 0
* 限定条件:无
* 函数说明:远端按键码与 LVGL 按键码一一对应
*/
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;
}
}
/*
* 函数功能:读回 SDL 屏幕实际呈现像素并转为 1bpp 位图(LCDMEM 数据源)
* 入口参数:out - 输出缓冲(≥ UI_NET_SINK_MONO_SIZE 字节)
* out_size - out 容量(字节)
* 返回值:0 - 成功,-1 - 失败
* 限定条件:sim_port_init() 已成功;须在 SDL 所属(主循环)线程调用
* 函数说明:窗口模式显示缓冲经 I1→ARGB8888 转换,不能直读,
* 故读回屏幕像素再按红通道阈值打包 1bpp(布局与 MCU 端一致)
*/
int ui_net_sink_get_lcdmem(uint8_t *out, uint32_t out_size)
{
if (out == NULL || out_size < UI_NET_SINK_MONO_SIZE) {
return -1;
}
return sim_port_get_screen_mono(out, out_size);
}
/*
* 函数功能:经 LVGL 输入组注入一个远端按键
* 入口参数:remo_key - 远端按键码(UI_KEY_* 之一)
* 返回值:无
* 限定条件:默认输入组已建立
* 函数说明:映射为 LV_KEY_* 后经 lv_group_send_data 发向焦点对象
*/
void ui_net_sink_inject_key(uint8_t remo_key)
{
lv_group_t *g;
uint32_t key;
key = sim_map_key(remo_key);
if (key == 0) {
return;
}
g = lv_group_get_default();
if (g != NULL) {
lv_group_send_data(g, key);
}
}
-71
View File
@@ -1,71 +0,0 @@
/*
* 模块名称:Simulator Display Port (SDL2 模拟器端口)
* 模块功能:为 PC 模拟器提供 LVGL SDL2 显示与键盘输入端口
* 适用平台:Windows / Linux / macOS (SDL2)
* 说明:替代 MCU 端的 ui_port.c,用于 PC 上 UI 开发与调试
*/
#include "sim_port.h"
#include "lvgl.h"
/* SDL 驱动头文件(由 lv_conf.h 中 LV_SDL_INCLUDE_PATH 指定) */
#define SDL_MAIN_HANDLED
#include <SDL.h>
/* 键盘输入设备句柄(全局,供 main.c SDL 事件循环读取) */
static lv_indev_t *g_kbd_indev = NULL;
/*
* 函数功能:初始化 SDL2 显示窗口与键盘输入设备
* 入口参数:hor_res - 水平分辨率 int32_t
* ver_res - 垂直分辨率 int32_t
* 返回值:0 - 成功,-1 - 失败
* 限定条件:lv_init() 已调用
* 函数说明:1. 创建 SDL 窗口显示设备
* 2. 创建键盘输入设备及默认输入组
* 3. 不创建鼠标/触摸设备(模拟器仅用键盘导航)
*/
int sim_port_init(int32_t hor_res, int32_t ver_res)
{
/* 创建默认输入组 */
lv_group_t *g = lv_group_create();
if (g == NULL) {
return -1;
}
lv_group_set_default(g);
/* 创建 SDL 窗口显示设备 */
lv_display_t *disp = lv_sdl_window_create(hor_res, ver_res);
if (disp == NULL) {
return -1;
}
lv_display_set_default(disp);
/* 初始化 mono 主题(1bpp 单色屏:白字黑底),并应用默认字体 */
#if LV_USE_THEME_MONO
lv_theme_t *th = lv_theme_mono_init(disp, true, LV_FONT_DEFAULT);
if (th != NULL) {
lv_display_set_theme(disp, th);
}
#endif
/* 创建键盘输入设备(方向键/确认/返回 → LVGL keypad) */
g_kbd_indev = lv_sdl_keyboard_create();
if (g_kbd_indev == NULL) {
return -1;
}
lv_indev_set_group(g_kbd_indev, lv_group_get_default());
lv_indev_set_display(g_kbd_indev, disp);
return 0;
}
/*
* 函数功能:获取 SDL 显示设备句柄
* 入口参数:无
* 返回值:显示设备指针 lv_display_t * 可能为 NULL
* 限定条件:sim_port_init() 已成功调用
*/
void *sim_port_get_display(void)
{
return (void *)lv_display_get_default();
}
+36
View File
@@ -0,0 +1,36 @@
# simulator/tests/ — UI 模拟器无头回归测试
> PC 端**无头**(headless)UI 像素回归测试:在模拟器中渲染各页面,与**黄金参考图**比对,
> 检测 UI 改动是否引入像素级回归。属于 `ui-verify` skill 五步门禁的一部分。
## 文件一览
| 文件 | 作用 |
|---|---|
| `check_menu_rows.py` | **菜单像素回归**:渲染菜单行 → 与黄金参考比对 |
| `check_content_pages.py` | **内容页回归**:各内容页渲染比对 |
| `check_remo_sink.py` | **推流(RemoDispBus)回归**:接收端渲染比对 |
| `regen_ref_png.py` | **重建黄金参考**:UI 有意变更后,重新生成基准图 |
| `ref/` | 黄金参考图(基准 PNG) |
## 使用方式
```bash
# 跑回归(按 ui-verify skill 流程)
& '.venv\Scripts\python.exe' simulator/tests/check_menu_rows.py
& '.venv\Scripts\python.exe' simulator/tests/check_content_pages.py
# UI 有意变更、确认无误后重建基准
& '.venv\Scripts\python.exe' simulator/tests/regen_ref_png.py
```
## ⚠️ 注意
- **重建黄金参考前必须确认变更是有意的**——否则会把 bug 固化成"基准"
- UI 改动一律走 **`ui-verify` skill 五步门禁**(regression 是其中一步)
- 依赖模拟器已构建(`simulator/build_sim.bat`)
## 相关文档
- `docs/UI设计/UI_自动化测试流程.md` — ui-verify 五步门禁
- `docs/UI设计/LVGL模拟器部署与使用说明.md`
+348
View File
@@ -0,0 +1,348 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
check_content_pages.py — 叶子内容页像素黄金参考回归 + 文本级断言 + 往返池水位门禁。
用法:
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` 生成一次。
原理:① 文本级断言——解析 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 刷新,
最后 regen_ref_png.py 重生成目检 PNG;误报排查见 docs/UI设计/UI_自动化测试流程.md。
退出码:0 全 PASS;1 文本断言失败/有页不一致/池水位漂移;2 运行/环境错误(exe 缺失、参考缺失)。
"""
import os
import re
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
# lvgl_sim.exe 相对本脚本定位(simulator/tests/ -> simulator/build/Debug/)
EXE = Path(__file__).resolve().parents[1] / "build" / "Debug" / "lvgl_sim.exe"
REF_DIR = Path(__file__).resolve().parent / "ref"
# (页名, 注入键序):与 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 项)
("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值", "00:00:00:00:00:00", (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值", "00:00:00:00:00:00", (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 字节/行
# 开页-关页往返后的 LVGL 池水位(x64 模拟器)= 菜单基线 + 一次性残留 80 B。
# 页面结构变更导致漂移时,先确认新值合理再更新此常量(内存回归门禁)。
# 2026-09-14: 4864 → 5520(+656B)= run_headless 新增 ui_standby_init():待机模块
# 常驻屏 s_scr + 容器 s_cont 两个 lv_obj,随菜单基线同移(见 check_menu_rows.py)。
POOL_AFTER_ROUNDTRIP = 5520
def run_sim(seq, out):
r = subprocess.run([str(EXE), "--keys", seq, out], capture_output=True)
if r.returncode != 0:
raise RuntimeError("lvgl_sim --keys %r rc=%d: %s"
% (seq, r.returncode, r.stderr.decode(errors="replace")[:200]))
return r.stdout
def ink_runs(data):
"""I1 位图按行统计白像素,返回 [(y0,y1,count), ...](count>2 的连续段)。"""
px = []
for y in range(H):
row = data[y * STRIDE:(y + 1) * STRIDE]
cnt = sum((b >> (7 - x)) & 1 for b in row for x in range(8))
px.append(cnt)
runs, start = [], None
for y, c in enumerate(px):
if c > 2 and start is None:
start = y
elif c <= 2 and start is not None:
runs.append((start, y - 1))
start = None
if start is not None:
runs.append((start, H - 1))
return [(a, b, sum(px[a:b + 1])) for a, b in runs]
def diff_count(a, b):
n = 0
for y in range(H):
ra = a[y * STRIDE:(y + 1) * STRIDE]
rb = b[y * STRIDE:(y + 1) * STRIDE]
for x in range(STRIDE):
n += bin(ra[x] ^ rb[x]).count("1")
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():
print("FAIL: 找不到 %s(先构建 simulator)" % EXE)
return 2
tmp = tempfile.mkdtemp(prefix="dtu_ui_pages_")
ok = True
# 清理手工运行残留的配置存储文件(模拟器工作目录),防"工厂默认值"像素参考被污染
# (sim_netcfg.bin 是旧档时代的产物:旧档迁移已于 2026-10-10 删除,此处只做兼容清理)
stale = Path.cwd() / "sim_netcfg.bin"
if stale.exists():
stale.unlink()
# 1) 跑全部页面 + 文本级断言预检(先于像素比对/刷新:失败即中止,避免误刷参考)
shots = {}
for name, seq in PAGES:
shot = os.path.join(tmp, "shot_%s.bin" % name)
run_sim(seq, shot)
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)
ref.write_bytes(data)
print("[%s] 黄金参考已刷新 -> %s(键序 %r)" % (name, ref.name, seq))
continue
if not ref.exists():
print("FAIL: 缺黄金参考 %s(首次/换机后请加 --update 生成;ref/ 不入库)" % ref)
shutil.rmtree(tmp, ignore_errors=True)
return 2
refdata = ref.read_bytes()
if data == refdata:
print("[%s] PASS 与黄金参考一致(键序 %r)" % (name, seq))
continue
ok = False
n = diff_count(data, refdata)
print("[%s] FAIL 与黄金参考不一致:%d px 差异(键序 %r)" % (name, n, seq))
print(" 当前截图 ink runs: %s" % ink_runs(data))
print(" 黄金参考 ink runs: %s" % ink_runs(refdata))
print(" 有意改视觉?确认新效果正确后运行 --update 刷新参考。")
# 开页-关页往返池水位门禁(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))
shutil.rmtree(tmp, ignore_errors=True)
return 0
if ok:
print("PASS: %d 内容页 + 往返池水位全部通过" % len(PAGES))
shutil.rmtree(tmp, ignore_errors=True)
return 0
# 失败时保留临时截图便于排查
print("(现场截图保留在 %s)" % tmp)
return 1
if __name__ == "__main__":
sys.exit(main())
+228
View File
@@ -0,0 +1,228 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
check_menu_rows.py — 自绘菜单 8 状态像素回归(160x160 I1,MSB-first,1=白 0=黑)。
用法:
python simulator/tests/check_menu_rows.py # 在仓库任意目录均可运行
前置:simulator 已构建(build/Debug/lvgl_sim.exe 存在);纯标准库,任意 python3。
状态(键序注入,u=上 d=下 e=Enter s=Esc):
S0 "" 根框 7 行 sel=0
S1 "d" sel=1
S2 "dd" sel=2
S3 "ddu" 回退 → 须与 S1 全像素一致
S4 "de" 开 L2(实时数据 3 子项),根框不变
S5 "ded" L2 sel=1
S6 "des" 返回 → 须与 S1 全像素一致(销毁/重建一致性)
S7 "ddes" 根框 sel=2、L2 已毁 → 须与 S2 全像素一致
断言:选中行白带(白占比>0.50)+ 带内黑字;非选行黑底白字(0.03<占比<0.45);
框体 1px 白边框;一致性状态逐字节相同;S0 池水位 = MENU_POOL_BASELINE。
退出码:0 全 PASS;1 有断言失败;2 运行/环境错误(exe 缺失等)。
"""
import os
import re
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
# lvgl_sim.exe 相对本脚本定位(simulator/tests/ -> simulator/build/Debug/)
EXE = Path(__file__).resolve().parents[1] / "build" / "Debug" / "lvgl_sim.exe"
STATES = [
("S0", ""),
("S1", "d"),
("S2", "dd"),
("S3", "ddu"),
("S4", "de"),
("S5", "ded"),
("S6", "des"),
("S7", "ddes"),
]
W = H = 160
STRIDE = W // 8 # I1:20 字节/行
ROW_H = 15
BAND_MIN = 0.50 # 选中行白带:白像素占比下限
NBAND_LO = 0.03 # 非选行:白字占比下限(文本必须存在)
NBAND_HI = 0.45 # 非选行:白像素占比上限(背景须为黑)
# 根框几何(view_init):x=0,y=44,w=66,h=107
ROOT_X0, ROOT_Y0, ROOT_W, ROOT_H = 0, 44, 66, 107
# LVGL 池基线水位(menu build done,x64 模拟器):菜单结构/样式变更导致漂移时,
# 先确认新值合理再更新此常量(内存回归门禁,见 docs/UI设计/UI_自动化测试流程.md)
# 2026-09-14: 4784 → 5440(+656B)= run_headless 新增 ui_standby_init():待机模块
# 常驻屏 s_scr + 容器 s_cont 两个 lv_obj(先于池测量创建),属有意结构变更。
MENU_POOL_BASELINE = 5440
def load(path):
data = open(path, "rb").read()
if len(data) < STRIDE * H:
raise RuntimeError("shot 过短: %s (%d)" % (path, len(data)))
px = [[0] * W for _ in range(H)]
for y in range(H):
row = data[y * STRIDE:(y + 1) * STRIDE]
for x in range(W):
px[y][x] = (row[x // 8] >> (7 - x % 8)) & 1
return px
def frac(px, x1, x2, y1, y2):
tot = wh = 0
for y in range(y1, y2 + 1):
for x in range(x1, x2 + 1):
tot += 1
wh += px[y][x]
return wh / tot if tot else 0.0
def root_row_strip(i):
y1 = ROOT_Y0 + 1 + i * ROW_H
return (ROOT_X0 + 1, ROOT_X0 + ROOT_W - 2, y1, y1 + ROW_H - 1)
def l2_geom(root_sel, cnt):
x0 = ROOT_X0 + ROOT_W - 1 # 65(共享边框列)
y0 = ROOT_Y0 + 1 + root_sel * ROW_H # 顶对齐父框选中行行顶
w = 42 # 2+36+2 + 2 边框(3 汉字行)
h = cnt * ROW_H + 2
return x0, y0, w, h
def l2_row_strip(x0, y0, i, w):
y1 = y0 + 1 + i * ROW_H
return (x0 + 1, x0 + w - 2, y1, y1 + ROW_H - 1)
fails = []
def check(cond, msg):
if not cond:
fails.append(msg)
def raw(path):
return open(path, "rb").read()
def main():
if not EXE.exists():
print("FAIL: 找不到 %s(先构建 simulator)" % EXE)
return 2
tmp = tempfile.mkdtemp(prefix="dtu_ui_rows_")
paths = {}
s0_out = None
try:
for name, seq in STATES:
out = os.path.join(tmp, "rows_%s.bin" % name)
r = subprocess.run([str(EXE), "--keys", seq, out], capture_output=True)
if r.returncode != 0:
print("[%s] 运行失败 rc=%d: %s" % (name, r.returncode,
r.stderr.decode(errors="replace")[:200]))
return 2
paths[name] = out
if name == "S0":
s0_out = r.stdout
# S0(空键序)= 菜单基线:池水位门禁
m = re.search(r"pool after keys: used=(\d+)", s0_out.decode(errors="replace"))
if not m:
print("FAIL: S0 输出未找到 'pool after keys'")
return 2
used = int(m.group(1))
check(used == MENU_POOL_BASELINE,
"S0 池水位 %d != 基线 %d(菜单结构/样式变更?确认合理后更新常量)"
% (used, MENU_POOL_BASELINE))
shots = {n: load(p) for n, p in paths.items()}
S = shots
def row_check(px, x1, x2, y1, y2, band, tag):
f = frac(px, x1, x2, y1, y2)
if band:
check(f > BAND_MIN, "%s 选中行白占比 %.3f <= %.2f" % (tag, f, BAND_MIN))
# 带内须有黑字(文本落在白带上)
check(frac(px, x1, x2, y1, y2) < 0.999, "%s 带内无黑字" % tag)
else:
check(NBAND_LO < f < NBAND_HI, "%s 非选行白占比 %.3f 不在 (%.2f,%.2f)"
% (tag, f, NBAND_LO, NBAND_HI))
def border_check(px, x0, y0, w, h, tag):
for y in range(y0 + 1, y0 + h - 1):
check(px[y][x0] == 1 and px[y][x0 + w - 1] == 1,
"%s 左右边框 y=%d 非白" % (tag, y))
for x in range(x0 + 1, x0 + w - 1):
check(px[y0][x] == 1 and px[y0 + h - 1][x] == 1,
"%s 上下边框 x=%d 非白" % (tag, x))
# --- S0:根框 7 行,sel=0 ---
p = S["S0"]
border_check(p, ROOT_X0, ROOT_Y0, ROOT_W, ROOT_H, "S0根框")
for i in range(7):
row_check(p, *root_row_strip(i), band=(i == 0), tag="S0行%d" % i)
# --- S1/S2:sel 移位 ---
for name, sel in (("S1", 1), ("S2", 2)):
p = S[name]
border_check(p, ROOT_X0, ROOT_Y0, ROOT_W, ROOT_H, name + "根框")
for i in range(7):
row_check(p, *root_row_strip(i), band=(i == sel), tag=name + "行%d" % i)
# --- S4:L2 开于 sel=1 行右侧(3 子项、无箭头),根框区与 S1 相同 ---
p = S["S4"]
lx0, ly0, lw, lh = l2_geom(1, 3)
border_check(p, lx0, ly0, lw, lh, "S4-L2框")
for i in range(3):
row_check(p, *l2_row_strip(lx0, ly0, i, lw), band=(i == 0), tag="S4-L2行%d" % i)
# 根框区(x 0..65)须与 S1 逐像素一致
for y in range(H):
for x in range(0, ROOT_X0 + ROOT_W):
check(S["S1"][y][x] == p[y][x], "S4 根框区(x=%d,y=%d) 与 S1 不一致" % (x, y))
# L2 框下方区域须全黑(无残留对象)
f = frac(p, lx0 + 1, lx0 + lw - 2, ly0 + lh + 1, min(ly0 + lh + 14, H - 1))
check(f == 0.0, "S4 L2 框下方有像素(占比 %.3f)" % f)
# --- S5:L2 sel=1,根框区仍与 S1 同 ---
p = S["S5"]
lx0, ly0, lw, lh = l2_geom(1, 3)
for i in range(3):
row_check(p, *l2_row_strip(lx0, ly0, i, lw), band=(i == 1), tag="S5-L2行%d" % i)
for y in range(H):
for x in range(0, ROOT_X0 + ROOT_W):
check(S["S1"][y][x] == p[y][x], "S5 根框区(x=%d,y=%d) 与 S1 不一致" % (x, y))
# --- 一致性状态:逐字节全像素比对 ---
for a, b, tag in (("S3", "S1", "ddu==d"),
("S6", "S1", "des==d(往返)"),
("S7", "S2", "ddes==dd(往返)")):
if raw(paths[a]) == raw(paths[b]):
print("[%s] PASS 全像素一致 (%s)" % (a, tag))
else:
pa, pb = S[a], S[b]
diff = sum(1 for y in range(H) for x in range(W) if pa[y][x] != pb[y][x])
fails.append("%s 与 %s 不一致(%d px,%s)" % (a, b, diff, tag))
if fails:
print("FAIL: %d 项断言未过" % len(fails))
for msg in fails[:40]:
print(" -", msg)
if len(fails) > 40:
print(" ... 另 %d 项" % (len(fails) - 40))
return 1
print("PASS: 8 状态全部断言通过(%d 图,池基线 %d B)" % (len(paths), MENU_POOL_BASELINE))
return 0
finally:
# 清理临时截图(失败时保留现场便于排查:仅成功时删)
if not fails:
shutil.rmtree(tmp, ignore_errors=True)
if __name__ == "__main__":
sys.exit(main())
+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())
+70
View File
@@ -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())