From e3435cd3dd9d39de1d2e7ba428aa27f810e6f29a Mon Sep 17 00:00:00 2001 From: Wanderingss <1624155937@qq.com> Date: Wed, 1 Apr 2026 19:54:17 +0800 Subject: [PATCH] =?UTF-8?q?=E9=92=88=E5=AF=B9=E6=96=B0=E9=A1=B9=E7=9B=AE?= =?UTF-8?q?=EF=BC=8C=E6=9B=B4=E6=96=B0Readme?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 991 +++++++++++++-------------------------- src/Drv/pages/menu/def.h | 8 +- tests/test_p1_menu.c | 4 +- 3 files changed, 320 insertions(+), 683 deletions(-) diff --git a/README.md b/README.md index 4e12bd0..a6a1004 100644 --- a/README.md +++ b/README.md @@ -1,728 +1,365 @@ # DTU-HMI -PC 端 HMI 菜单逻辑模拟程序,纯 C 实现,用于在电脑上模拟现场 DTU 设备的人机界面(HMI)行为。 +`DTU-HMI` 是一个纯 C 实现的 PC 端 DTU 人机界面模拟工程,用于在电脑上复现现场装置的菜单结构、按键交互、LCD 绘制与远程显示链路。当前代码已经从早期的过程式菜单实现演进为“页面管理器 + 菜单页 MVP 分层”结构,更适合作为后续页面扩展、联调和测试的基础。 -- **菜单树**:按真实装置菜单还原,支持多级嵌套、首尾成环遍历 -- **LCD 显示**:模拟 128×64 单色 LCD(点阵缓冲区 + 绘制函数) -- **键盘输入**:用 PC 键盘按键映射嵌入式按键(上/下/左/右/确认/退出) -- **TCP 远程显示**:实现 RemoDispBus 协议,可把 LCD 显示通过网络推送到上位机 -- **UTF‑8 汉字库**:内置 12×12 点阵汉字库,可扩展 +## 项目特性 + +- 多级菜单树,支持父子层级跳转与同层循环导航 +- 160×160 单色 LCD 模拟,支持边框、文字、反显等基础绘制 +- 按键输入驱动菜单导航,支持确认、返回、上下左右等操作 +- 基于 TCP 的 RemoDispBus 远程显示链路,默认端口 `7003` +- UTF-8 文本显示与菜单宽度自适应计算 +- 基于 CTest 的单元测试体系,已覆盖菜单、页面管理、按键、LCD、TCP 回环等模块 --- -## 1. 快速开始(构建 & 运行) +## 快速开始 -### 1.1 构建可执行程序 +### 构建 + +在仓库根目录执行: ```powershell -mkdir build -cd build -cmake .. -cmake --build . +cmake -S . -B build +cmake --build build ``` -生成可执行文件: +生成产物: -- Windows:`build/DTU-HMI.exe` -- Linux/macOS:`build/DTU-HMI` +- Windows: `build/DTU-HMI.exe` -### 1.2 直接在本机运行 - -在 `build` 目录中执行: +### 运行 ```powershell -./DTU-HMI.exe # Windows PowerShell -./DTU-HMI # Linux / macOS +.\build\DTU-HMI.exe ``` -终端中会出现类似“LCD 文本界面 + 菜单内容”的输出,使用键盘控制菜单。 +程序启动后会: +- 初始化页面管理器、LCD 和菜单页 +- 启动 TCP 服务线程,监听 `7003` +- 进入主循环,周期性读取按键并刷新当前页面 -## 3. 目录结构 +### 运行测试 +```powershell +cmake -S . -B build +cmake --build build +ctest --test-dir build -C Debug --output-on-failure ``` + +补充说明: + +- 单配置生成器下即使没有单独的 `Debug` 目录,`ctest` 也可直接运行 +- 多配置生成器下可先显式构建:`cmake --build build --config Debug` + +--- + +## 环境要求 + +- CMake `3.10+` +- C99 编译器 +- Windows 下默认适配 MSVC,并自动启用 `/utf-8` +- 非 Windows 平台默认链接 `pthread` + +--- + +## 目录结构 + +```text DTU-HMI/ ├── CMakeLists.txt -├── gen_utf8_hz12.py # 12×12 UTF-8 汉字库生成脚本 -├── remo_disp_server.py # 远程显示 Python 服务端 +├── README.md ├── include/ │ └── types.h ├── src/ │ ├── main.c -│ ├── thread_utils.c/h +│ ├── common/ +│ │ └── utf8.c/h │ ├── remoteDisplay.c/h -│ ├── TCP/tcp.c, tcp.h +│ ├── thread_utils.c/h +│ ├── TCP/ +│ │ └── tcp.c/h │ └── Drv/ -│ ├── menu.c/h -│ ├── display.c/h +│ ├── key.c/h +│ ├── menu/app/ +│ │ └── menu.c/h │ ├── lcd/ +│ │ ├── ascii.c/h │ │ ├── lcd.c/h │ │ ├── lcd_draw.c/h -│ │ ├── lcd_text.c/h -│ │ ├── text_codec.c/h -│ │ └── ascii.c/h -│ └── utf8_hz12_data.c/h # 由脚本生成 -└── build/ +│ │ └── lcd_text.c/h +│ └── pages/ +│ ├── page.h +│ ├── page_manager.c/h +│ ├── global/ +│ │ ├── global_state.c/h +│ │ └── renderer_lcd.c/h +│ └── menu/ +│ ├── def.h +│ ├── model.c/h +│ ├── presenter.c/h +│ ├── view.c/h +│ └── page.c/h +└── tests/ + ├── CMakeLists.txt + ├── test_p0_remote_display.c + ├── test_p0_utf8_hz12_get.c + ├── test_p1_key.c + ├── test_p1_lcd_basic.c + ├── test_p1_menu.c + ├── test_p1_page_manager.c + └── test_p2_tcp_loopback.c ``` --- -## 4. 环境要求 +## 当前架构 -- **CMake** 3.10+ -- **编译器**:Windows 默认 MSVC;Linux 需 GCC/Clang -- **编码**:源文件 UTF-8,CMake 已配置 MSVC `/utf-8` +### 总体运行流 + +当前主程序入口在 `src/main.c`,主流程可以概括为: + +```text +main + ├─ PageManager_Init() + ├─ Lcd_Init() + ├─ PageManager_Register(MenuPage_GetInstance()) + ├─ PageManager_Navigate(PAGE_ID_MENU) + ├─ StartTcpServerThread(... port 7003 ...) + └─ while (1) + ├─ Key_Read() + ├─ PageManager_DispatchEvent(...) + └─ PageManager_Loop() +``` + +因此,当前项目的交互入口已经不是旧版 `menu.c` 直接驱动,而是统一通过页面系统调度。 + +### 页面管理器 + +`src/Drv/pages/page_manager.c` 负责维护“页面注册表 + 页面栈”两套状态,提供: + +- `PageManager_Register()`:注册页面 +- `PageManager_Navigate()`:按 `page_id` 导航 +- `PageManager_Push()` / `PageManager_Pop()`:管理页面栈 +- `PageManager_DispatchEvent()`:把事件分发给当前栈顶页面 +- `PageManager_Loop()`:周期驱动当前页面 + +当前 `page_t` 生命周期约定: + +- 首次进入页面:`on_create -> on_enter` +- 切页离开:`on_exit` +- 非缓存页出栈:`on_exit -> on_destroy` +- 周期调度:`on_loop` + +这使得项目后续可以继续扩展成多页面系统,而不是只服务于一个菜单模块。 + +### 菜单页 MVP 分层 + +当前菜单页位于 `src/Drv/pages/menu/`,职责拆分如下: + +- `model.c`:菜单静态表、树构建、显示名修饰、层级索引与布局计算 +- `presenter.c`:按键路由、路径重建、刷新策略判定 +- `view.c`:边框、文字、高亮、整页刷新与局部刷新 +- `page.c`:把菜单页接入 `PageManager` 生命周期 + +数据流可以理解为: + +```text +按键输入 + -> MenuPage_OnEvent + -> MenuPresenter_HandleInput + -> MenuNavigator_ProcessKey + -> 更新 tagMenuCtrl + -> MenuPresenter_Refresh + -> MenuView_* 渲染接口 + -> LCD 渲染端口 +``` --- -## 5. 构建步骤 +## 菜单数据模型 -### 5.1 Windows +### 静态定义 + +菜单原始数据定义在 `src/Drv/pages/menu/model.c` 的 `menuTab[]` 中,每一项使用 `tagMenuModel` 描述: + +- `byClass`:层级,`0` 表示顶层 +- `byName`:菜单显示名 +- `byTip`:提示文本 +- `byAttrib`:业务属性 +- `wPassword`:权限或密码 +- `wPara`:动作参数 +- `pfnWinProc`:叶子节点动作入口 + +### 运行时节点 + +运行时菜单节点类型为 `MenuItem`,由三部分组成: + +- `links`:父、子、前、后四向链路 +- `menuDef`:静态业务定义副本 +- `rect`:菜单框位置、子项数量、当前项序号 + +关键字段含义: + +- `links.higher`:父节点 +- `links.lower`:第一个子节点 +- `links.before` / `links.behind`:同层前后节点 +- `rect.wPos`:当前节点在本层中的 `0` 基序号 +- `rect.wNum`:当前节点的直接子节点数量 +- `rect.wSPosX/Y`、`rect.wEPosX/Y`:子菜单框坐标 + +### 初始化流程 + +`MenuModel_Init()` 的核心步骤如下: + +1. 统计 0 级菜单数量 +2. 将 `menuTab[]` 构建成运行时树 +3. 为存在子菜单的项追加显示箭头 `\x10` +4. 计算每个节点的层内索引 `wPos` 和子节点数量 `wNum` +5. 计算各层菜单框的初始坐标 + +### 结构特点 + +- 同层链表最终会闭环,便于上下循环导航 +- 子菜单框 X 坐标从父菜单右侧展开 +- 子菜单 Y 坐标会根据当前项位置和屏幕边界做自适应修正 +- 视图层只读取 `rect` 结果,不参与复杂布局推导 + +--- + +## 菜单导航与渲染 + +### 导航规则 + +导航逻辑集中在 `src/Drv/pages/menu/presenter.c`,主要按键语义如下: + +- `KEY_U`:移动到同层前一个菜单 +- `KEY_D`:移动到同层后一个菜单 +- `KEY_R` / `KEY_ENT`:进入子菜单,或执行叶子节点动作 +- `KEY_L` / `KEY_ESC`:返回上一级 + +Presenter 维护的核心状态包括: + +- `ptCurrent`:当前选中项 +- `ptRoute[]`:当前路径上的各层节点 +- `ptCurBak`:上一次选中项 +- `pt0Level`:当前顶层上下文 + +### 刷新策略 + +当前渲染不是每次都整页重画,而是根据状态差异选择不同路径: + +- 首帧或跨顶层切换:`full_refresh` +- 同层同父切换:`update_selection_same_level` +- 进入更深层:`update_selection_new_level` + +这种方式可以减少闪烁,也让 Presenter 和 View 的职责更清晰。 + +### 顶部状态栏 + +`src/Drv/pages/menu/def.h` 中定义了 `MenuMode`,用于顶部状态栏文案: + +- `MODE_NONE` +- `MODE_OVERFLOW_PROTECTION` +- `MODE_LOCAL_FEEDER_SEGMENT` +- `MODE_LOCAL_FEEDER_CONTACT` + +当前 `view.c` 已支持这些模式的显示,默认使用 `MODE_NONE`。 + +--- + +## 远程显示与网络 + +项目通过 `src/remoteDisplay.c` 与 `src/TCP/tcp.c` 提供远程显示基础能力。 + +当前主程序会在独立线程中启动 TCP 服务: + +- 默认监听端口:`7003` +- 用途:同步 LCD 内容,并为远程显示或上位机交互预留链路 + +如果需要联调网络链路,建议重点查看: + +- `src/main.c` +- `src/remoteDisplay.c` +- `src/TCP/tcp.c` + +--- + +## UTF-8 与 LCD 显示 + +项目中的文本显示链路大致如下: + +```text +UTF-8 文本 + -> utf8.c 解析字符 + -> lcd_text / ascii 绘制字符 + -> lcd / lcd_draw 写入像素缓冲 + -> renderer_lcd 输出到页面渲染端口 +``` + +菜单宽度计算不是按字节长度,而是按显示宽度计算: + +- ASCII 字符记宽度 `1` +- 多字节 UTF-8 字符记宽度 `2` + +对应实现主要在: + +- `MenuModel_Utf8LenCal()` +- `MenuModel_GetMenuMaxDisplayLen()` + +这也是菜单框宽度能够同时适配中文和英文标题的基础。 + +--- + +## 调试 + +可通过 CMake 选项打开 `DEBUG` 宏: ```powershell -mkdir build -cd build -cmake .. -cmake --build . -``` - -### 5.2 Linux / macOS - -```bash -mkdir build -cd build -cmake .. -cmake --build . -``` - ---- - -## 6. TCP 远程显示(RemoDispBus) - -程序内部通过 `remoteDisplay.c` + `tcp.c` 实现 **RemoDispBus** 协议通讯,可把 LCD 显示内容实时推送给远程上位机。 - -- 默认监听 **端口:7003** -- 协议命令: - - `CMD_INIT`:初始化/握手 - - `CMD_LCDMEM`:下发/上传 LCD 显存缓冲区 - - `CMD_KEY`:上位机模拟按键下发给设备 - - `CMD_KEEPLIVE`:心跳保持连接 - -### 6.1 使用 Python 远程显示服务端 - -在 PC 上运行模拟程序的同时,可以用仓库中的 `remo_disp_server.py` 来查看 LCD 的显示图像: - -```bash -python remo_disp_server.py # 需要 Python 3 + 标准库,具体依赖参考脚本头部说明 -``` - -脚本会作为 **“远程显示上位机”**: - -- 主动连接 DTU-HMI 程序监听的端口(默认 7003) -- 接收并解析 `CMD_LCDMEM`,在一个窗口中实时绘制 LCD 内容 -- 也可以把 GUI 上的按键事件转换为 `CMD_KEY` 下发给 DTU-HMI 程序 ---- - -## 7. 菜单系统 - -### 7.1 概述 - -菜单由**静态表** `g_tMenuModelTab` 定义,经 `Menu_Main_Creat_01` 构建为**可遍历树** `g_tMenuItem[]`。 -运行时由 `menu.c` 负责: - -- 把静态表解析成**树 + 循环双向链表** -- 根据按键事件更新当前指针 `g_tMenuCtrl.ptCurrent` -- 调用一系列“坐标计算函数”给每一级菜单计算矩形框位置 -- 最终调用 `display.c` / `lcd.c` 完成绘制 - -### 7.2 数据结构 - -#### 7.2.1 静态菜单定义(`tagMenuModel`) - -```c -typedef struct -{ - uint8_t byClass; // 菜单分级标志 0/1/2/3 - uint8_t byName[50]; // 菜单字符串 - uint8_t byTip[50]; // 菜单提示文本 - uint8_t byAttrib; // 菜单属性 - uint16_t wPassword; // 访问密码,0x0000 表示无密码 - uint16_t wPara; // 菜单执行函数参数 - FUNCPTR pfnWinProc; // 界面执行函数指针 -} tagMenuModel, *tagPMenuModel; - -/* 注意:表定义时顺序不能乱,需从 0 级开始,一级一级按顺序写入 */ -const tagMenuModel g_tMenuModelTab[] = { ... }; -``` - -#### 7.2.2 菜单遍历树(`tagMenuItem`) - -```c -/* 每个菜单包含:一、上下前后等级关系;二、属性与内容;三、显示坐标 */ -typedef struct _MENU_ITEM_ -{ - struct _MENU_ITEM_ *ptHigher; // 上级菜单指针 - struct _MENU_ITEM_ *ptLower; // 下级菜单指针 - struct _MENU_ITEM_ *ptBefore; // 同级上方指针 - struct _MENU_ITEM_ *ptBehind; // 同级下方指针 - - uint8_t byClass; - uint8_t byName[50]; - uint8_t byTip[50]; - uint8_t byAttrib; - uint16_t wPassword; - uint16_t wPara; - FUNCPTR pfnWinProc; - - uint16_t wPos, wNum; - uint16_t wSPosX, wSPosY, wEPosX, wEPosY; -} tagMenuItem, *tagPMenuItem; - -tagMenuItem g_tMenuItem[300]; /* 所有菜单存储于此数组 */ -``` - -### 7.3 菜单构建示例 - -#### 7.3.1 静态表(`g_tMenuModelTab`) - -```c -const tagMenuModel g_tMenuModelTab[] = -{ - { 0, " ", "", 0, 0x0000, 0, (FUNCPTR)Menu_NonPfunc }, - { 1, "装置信息", "查看装置信息", 0, 0x0000, EN_ANA_0, (FUNCPTR)MenuProc_See_AppInfo }, - { 1, "实时数据", "装置实时数据", 0, 0x0000, 0, (FUNCPTR)Menu_NonPfunc }, - { 2, "交流量", "查看遥测交流量", EN_MEA_AC, 0x0000, EN_ANA_0, (FUNCPTR)MenuProc_See_YC }, - { 2, "直流量", "查看遥测直流量", EN_MEA_DC, 0x0000, EN_ANA_0, (FUNCPTR)MenuProc_See_YC }, - { 2, "遥信量", "查看遥信开入量", EN_INPUT_RLY_ALL, 0x0000, EN_INPUT_0, (FUNCPTR)MenuProc_See_Input }, - { 1, "参数定值", "保护参数查看与修改", 0, 0x0000, 0, (FUNCPTR)Menu_NonPfunc }, - { 2, "整定", "整定装置保护参数", 0, 0x0000, 0, (FUNCPTR)Menu_NonPfunc }, - { 3, "参数", "查看设备参数定值", EN_FIGURE_SET, CN_USER_PWD, EN_SIDE_BASIC, (FUNCPTR)MenuProc_Set_Value }, - { 3, "定值", "设置装置数值定值", EN_FIGURE_SET, CN_USER_PWD, EN_SIDE_DEF, (FUNCPTR)MenuProc_Set_Value }, - { 3, "控制字", "设置装置控制字", EN_SOFT_SET, CN_USER_PWD, EN_SIDE_DEF, (FUNCPTR)MenuProc_Set_Value }, - { 3, "软压板", "设置软压板", 0, CN_USER_PWD, EN_SOFT_PRO, (FUNCPTR)MenuProc_Set_Soft }, - { 2, "查看", "查看装置保护参数", 0, 0x0000, 0, (FUNCPTR)Menu_NonPfunc }, - { 3, "参数", "设置设备参数定值", EN_FIGURE_SET, 0x0000, EN_SIDE_BASIC, (FUNCPTR)MenuProc_See_Set }, - { 3, "定值", "查看数值型定值", EN_FIGURE_SET, 0x0000, EN_SIDE_DEF, (FUNCPTR)MenuProc_See_Set }, - { 3, "控制字", "查看控制字定值", EN_SOFT_SET, 0x0000, EN_SIDE_DEF, (FUNCPTR)MenuProc_See_Set }, - { 3, "软压板", "查看软压板", 0, 0x0000, EN_SOFT_PRO, (FUNCPTR)MenuProc_See_Soft }, - { 1, "三遥设置", "", 0, 0x0000, 0, (FUNCPTR)Menu_NonPfunc }, - { 2, "遥测死区", "设置遥测量死区门槛", 0, CN_USER_PWD, 0, (FUNCPTR)MenuProc_YC_SetSqValue }, - { 2, "遥测系数", "设置遥测量微调系数", EN_MEA_ADJ, CN_USER_PWD, 0, (FUNCPTR)MenuProc_YC_SetAdjCoe }, - { 2, "遥信类型", "设置遥信类型", 0, CN_USER_PWD, 0, (FUNCPTR)MenuProc_YX_SetCommType }, - { 2, "遥信防抖", "设置遥信防抖时间", 0, CN_USER_PWD, 0, (FUNCPTR)MenuProc_YX_SetWidth }, - { 2, "双点遥信", "设置双点遥信虚端子", 0, CN_USER_PWD, 0, (FUNCPTR)MenuProc_YX_SetTwin }, - { 1, "装置维护", "", 0, 0x0000, 0, (FUNCPTR)Menu_NonPfunc }, - { 2, "时钟设置", "设置系统时钟", 0, CN_USER_PWD, 0, (FUNCPTR)MenuProc_Cfg_Time }, - { 2, "强制复归", "可复归未返回事件", EN_REV_FORCE, CN_USER_PWD, 0, (FUNCPTR)MenuProc_Cfg_RevEvent }, - { 2, "手动录波", "启动手动录波", 0, CN_USER_PWD, 0, (FUNCPTR)MenuProc_Cfg_ManualWave }, - { 2, "清除记录", "", 0, CN_USER_PWD, 0, (FUNCPTR)MenuProc_Cfg_ClrRec }, - { 1, "通讯参数", "", 0, 0x0000, 0, (FUNCPTR)Menu_NonPfunc }, - { 2, "通讯设置", "外部通讯设置", 0, CN_USER_PWD, 0, (FUNCPTR)MenuProc_Cfg_ComPara }, - { 2, "网口设置", "", 0, CN_USER_PWD, 0, (FUNCPTR)MenuProc_Cfg_EditIP }, - { 2, "SNTP设置", "", 0, CN_USER_PWD, 0, (FUNCPTR)MenuProc_Cfg_EditSntp }, - { 1, "记录查询", "查看各种装置记录", 0, 0x0000, 0, (FUNCPTR)Menu_NonPfunc }, - { 2, "SOE记录", "", 0, 0x0000, 0, (FUNCPTR)MenuProc_See_RecSOE }, - { 2, "事故记录", "", 0, 0x0000, 0, (FUNCPTR)MenuProc_See_RecAct }, - { 2, "操作记录", "", 0, 0x0000, 0, (FUNCPTR)MenuProc_See_RecOpt }, - { 2, "保护告警", "", 0, 0x0000, 0, (FUNCPTR)MenuProc_See_RecAlm }, - { 2, "保护启动", "", 0, 0x0000, 0, (FUNCPTR)MenuProc_See_RecStart }, - { 2, "遥控记录", "", 0, 0x0000, 0, (FUNCPTR)MenuProc_See_RecYK }, - { 2, "自检记录", "", 0, 0x0000, 0, (FUNCPTR)MenuProc_See_RecChk }, - { 2, "运行记录", "", 0, 0x0000, 0, (FUNCPTR)MenuProc_See_RecRun }, - { 2, "运行报告", "", 0, 0x0000, 0, (FUNCPTR)MenuProc_See_RecFault }, - { 0, "厂家设置", "设置装置相关参数", 0, CN_COP_PWD, 0, (FUNCPTR)Menu_NonPfunc }, - { 1, "元件配置", "配置元件配置", 0, CN_COP_PWD, EN_FACTORY_PASSWORD,(FUNCPTR)MenuProc_Cfg_CellConf }, - { 1, "恢复默认", "恢复默认元件定值参数", 0, CN_COP_PWD, 0, (FUNCPTR)Menu_NonPfunc }, - { 2, "全部恢复", "全部参数恢复默认", 0, CN_COP_PWD, EN_NO_USER_PWD, (FUNCPTR)MenuProc_AllInf_Default }, - { 2, "默认参数", "当前参数恢复默认", 0, CN_COP_PWD, EN_NO_USER_PWD, (FUNCPTR)MenuProc_Para_Default }, - { 2, "默认定值", "当前定值区恢复默认", 0, CN_COP_PWD, EN_NO_USER_PWD, (FUNCPTR)MenuProc_Set_Default }, - { 2, "软压板", "当前软压板恢复默认", 0, CN_COP_PWD, EN_NO_USER_PWD, (FUNCPTR)MenuProc_Resume_Soft }, - { 2, "元件配置", "元件配置恢复默认", 0, CN_COP_PWD, EN_NO_USER_PWD, (FUNCPTR)MenuProc_Cfg_CellDef }, - { 1, "交流显示", "交流显示方式设置", 0, CN_COP_PWD, 0, (FUNCPTR)MenuProc_Cfg_ShowAnaType }, - { 1, "装置调试", "调试装置", 0, 0x0000, 0, (FUNCPTR)Menu_NonPfunc }, - { 2, "虚拟遥信", "设置虚拟遥信值", 0, CN_COP_PWD, 0, (FUNCPTR)MenuProc_Dbg_XuYX }, - { 2, "交流虚遥测", "设置虚拟交流遥测值", EN_MEA_AC, CN_COP_PWD, EN_ANA_0, (FUNCPTR)MenuProc_Dbg_XuYC }, - { 2, "直流虚遥测", "设置虚拟直流遥测值", EN_MEA_DC, CN_COP_PWD, EN_ANA_0, (FUNCPTR)MenuProc_Dbg_XuYC }, - { 2, "电度虚遥测", "设置虚拟电度遥测值", EN_MEA_POWER, CN_COP_PWD, EN_ANA_0, (FUNCPTR)MenuProc_Dbg_XuYC }, - { 2, "动作虚事件", "设置虚拟动作事件", EN_ACT_REC, CN_COP_PWD, 0, (FUNCPTR)MenuProc_Dbg_XuEvent }, - { 2, "告警虚事件", "设置虚拟告警事件", EN_ALM_REC, CN_COP_PWD, 0, (FUNCPTR)MenuProc_Dbg_XuEvent }, - { 2, "动作出口", "进入此菜单保护退出", EN_OUTPUT_TRIP, CN_COP_PWD, EN_INPUT_0, (FUNCPTR)MenuProc_Dbg_Relay }, - { 2, "信号出口", "进入此菜单保护退出", EN_OUTPUT_SIGN, CN_COP_PWD, 0, (FUNCPTR)MenuProc_Dbg_Relay }, - { 1, "版本信息", "查看板件版本信息", 0, CN_COP_PWD, 0, (FUNCPTR)MenuProc_See_VersionBoard }, -}; -``` - -#### 7.3.2 解析后的树形结构 - -根据上表,`Menu_Main_Creat_01` 解析后的菜单树为: - -```text -0 级目录 { - 1.装置信息 - 1.实时数据 { - 2.交流量 - 2.直流量 - 2.遥信量 - } - 1.参数定值 { - 2.整定 { - 3.参数 - 3.定值 - 3.控制字 - 3.软压板 - } - 2.查看 { - 3.参数 - 3.定值 - 3.控制字 - 3.软压板 - } - } - 1.三遥设置 { - 2.遥测死区 - 2.遥测系数 - 2.遥信类型 - 2.遥信防抖 - 2.双点遥信 - } - 1.装置维护 { - 2.时钟设置 - 2.强制复归 - 2.手动录波 - 2.清除记录 - 2.通讯参数 - 2.通讯设置 - 2.网口设置 - 2.SNTP设置 - } - 1.记录查询 { - 2.SOE记录 - 2.事故记录 - 2.操作记录 - 2.保护告警 - 2.保护启动 - 2.遥控记录 - 2.自检记录 - 2.运行记录 - 2.运行报告 - } -} - -0.厂家设置 { - 1.元件配置 - 1.恢复默认 { - 2.全部恢复 - 2.默认参数 - 2.默认定值 - 2.软压板 - 2.元件配置 - } - 1.交流显示 - 1.装置调试 { - 2.虚拟遥信 - 2.交流虚遥测 - 2.直流虚遥测 - 2.电度虚遥测 - 2.动作虚事件 - 2.告警虚事件 - 2.动作出口 - 2.信号出口 - } - 1.版本信息 -} -``` - -#### 7.3.3 单例解析示例:直流量 - -以 `{2,"直流量","查看遥测直流量",EN_MEA_DC,0x0000,EN_ANA_0,(FUNCPTR)MenuProc_See_YC}` 为例,构建后该菜单项的指针与属性为: - -```text -{ - ptHigher = 1.实时数据; - ptLower = NULL; - ptBefore = 2.交流量; - ptBehind = 2.遥信量; - - byClass = 2; - byName = 直流量; - byTip = 查看遥测直流量; - byAttrib = EN_MEA_DC; - wPassword = 0x0000; - wPara = 0; - pfnWinProc = MenuProc_See_YC; -} -``` - -#### 7.3.4 同级首尾成环 - -同级菜单中,首尾通过 `ptBefore/ptBehind` 相连形成环。例如 2 级子菜单: - -```text -2.虚拟遥信 -2.交流虚遥测 -2.直流虚遥测 -2.电度虚遥测 -2.动作虚事件 -2.告警虚事件 -2.动作出口 -2.信号出口 -``` - -其中: - -- `2.虚拟遥信` 的 `ptBefore` 指向 `2.信号出口`(首的前一个是尾) -- `2.信号出口` 的 `ptBehind` 指向 `2.虚拟遥信`(尾的后一个是首) - -### 7.4 构建流程(`Menu_Main_Creat_01`) - -按 `g_tMenuModelTab` 的定义顺序逐项处理,通过比较 `byCurClass` 与 `byNextClass` 设置每个菜单的层级指针。 - -#### 7.4.1 情况 1:`byCurClass < byNextClass`(下一项更深,进入子菜单) - -```text - ptCurrent ptNextNode - (当前) (下一项,更深一级) - │ │ - │ ptLower ──────────────────►│ - │◄───────────────── ptHigher │ - │ │ - 该级尾不变 新一级的 首=尾=ptNextNode -``` - -#### 7.4.2 情况 2:`byCurClass == byNextClass`(同级,并列) - -```text - ptCurrent ─── ptBehind ──► ptNextNode - │ │ - │ ptBefore ◄───────────┤ - │ │ - └──── ptHigher (同) ────────┘ - 该级 尾 更新为 ptNextNode -``` - -#### 7.4.3 情况 3:`byCurClass > byNextClass`(下一项更浅,回到上层) - -```text - ... byCurClass 级 ... byNextClass 级 ... - ptLast[byNextClass] 已存在 - │ - │ ptBehind ──► ptNextNode(新) - │ │ - │◄── ptBefore ────┤ - │ ptHigher = 该级尾的 ptHigher - 同时:从 byCurClass 到 byNextClass+1 各级首尾成环 - ptLast[级]──►ptFirst[级],ptFirst[级]──►ptLast[级] -``` - -#### 7.4.4 最后:各级首尾成环 - -表遍历完后,从 **0 级到当前结点所在级**,把该级首尾连成环: - -```text - ptFirst[级] ◄──────────────► ptLast[级] - │ │ - └──── ptBehind ───────────────┘ - ◄──────── ptBefore ────────────┘ -``` - -### 7.5 `menu.c` 运行时整体逻辑 - -`menu.c` 在运行时主要做三件事: - -1. **构建菜单树**:`Menu_Main_Creat` / `Menu_0LevelNumCal` -2. **根据按键路由当前菜单**:`Menu_Route` -3. **计算坐标并显示**:`Menu_Show_Proc` + 一系列 `Menu_*PosCal` 函数 - -可以把主循环理解为下面这个“数据流”: - -```text -键盘按键 (key.c) ──► 菜单路由 (Menu_Route) - │ - ▼ - 当前菜单指针 g_tMenuCtrl.ptCurrent - │ - ▼ - 坐标计算 (Menu_Sub1PosCal / Menu_PosCal_0Level ...) - │ - ▼ - 显示绘制 (Menu_Show_Proc + display.c/lcd.c) -``` - -#### 7.5.1 主循环 & 菜单刷新时序 - -在 `main.c` 的主循环中,大致时序可抽象成: - -```text -while (1) { - // 1. 采集按键状态(key.c) - Key_Scan(); - - // 2. 依据当前按键更新菜单当前位置(menu.c) - Menu_Route(); - - // 3. 根据当前位置和菜单树,计算各级菜单的坐标 - Menu_Show_Proc(); - - // 4. 刷新 LCD 显示(lcd.c / display.c) - LCD_Refresh(); -} -``` - -你可以把 **`Menu_Route` 看成“控制层”**,把 **`Menu_Show_Proc` 看成“视图层布局计算”**。 - -#### 7.5.2 按键如何驱动菜单移动(`Menu_Route`) - -`Menu_Route` 的核心思想是: -**根据按键,沿着当前层的循环链表移动 `ptCurrent`,或在父/子指针之间跳转。** - -简化后的逻辑可以画成下面这样的**状态机图**: - -```text - ┌─────────────┐ - │ 当前菜单项 │ (g_tMenuCtrl.ptCurrent) - └─────┬───────┘ - ▲ 上键/W │ 下键/S ▼ - │ │ - │ │ - ptBefore ptBehind - (同级上一个) (同级下一个) - - - 左键/A 或 退出键/Esc 右键/D 或 Enter - │ │ - ▼ ▼ - ptHigher(父菜单) ptLower(子菜单) -``` - -更具体地: - -- **上键 / `W`**: - - `ptCurrent = ptCurrent->ptBefore;` - - 由于同级首尾成环,**从首再往上就会回到尾** -- **下键 / `S`**: - - `ptCurrent = ptCurrent->ptBehind;` - - 从尾再往下会回到首,实现循环菜单 -- **右键 / `D` 或 `Enter`**: - - 若当前有子菜单:`ptCurrent = ptCurrent->ptLower;` - - 同时更新 `g_tMenuCtrl.ptRoute[层级]`,记录层级路径 -- **左键 / `A` 或 `Esc`**: - - 若存在上级:`ptCurrent = ptCurrent->ptHigher;` - - 层级回退,路径栈 `ptRoute[]` 随之更新 - -伪代码示意(省略防抖、长按等细节): - -```c -void Menu_Route(void) -{ - tagPMenuItem ptCur = g_tMenuCtrl.ptCurrent; - - if (KEY_UP_PRESSED()) { - ptCur = ptCur->ptBefore; - } else if (KEY_DOWN_PRESSED()) { - ptCur = ptCur->ptBehind; - } else if (KEY_RIGHT_PRESSED() || KEY_ENTER_PRESSED()) { - if (ptCur->ptLower != NULL) { - ptCur = ptCur->ptLower; - } - } else if (KEY_LEFT_PRESSED() || KEY_ESC_PRESSED()) { - if (ptCur->ptHigher != NULL) { - ptCur = ptCur->ptHigher; - } - } - - g_tMenuCtrl.ptCurrent = ptCur; -} -``` - -实际代码里还会配合 `ptCurBak` 判断“是否移动了”,以决定是否需要重算坐标和刷新显示。 - -#### 7.5.3 坐标字段含义(`wSPosX/Y`、`wEPosX/Y`) - -`tagMenuItem` 中与坐标相关的字段: - -- `wSPosX, wSPosY`:**本菜单的下一级子菜单框的左上角坐标** -- `wEPosX, wEPosY`:**本菜单的下一级子菜单框的右下角坐标** - -可以类比成: - -```text -┌──────────────── LCD 屏幕 ────────────────┐ -│ │ -│ ┌───── 本级菜单框 (父) ─────┐ │ -│ │ │ │ -│ │ ● 当前高亮菜单项 │ │ -│ │ │ │ -│ └───────────────────────────┘ │ -│ ▲ wSPosY │ -│ │ │ -│ ▼ wEPosY │ -│ ┌───── 子菜单框 (ptLower) ─────┐ │ -│ │ ← wSPosX → wEPosX │ │ -│ │ 子菜单项1 │ │ -│ │ 子菜单项2 │ │ -│ │ ... │ │ -│ └──────────────────────────────┘ │ -└──────────────────────────────────────────┘ -``` - -这样设计的好处是: -**每个菜单项都“携带”了自己的子菜单应该出现在哪一块区域的信息**,绘制时只需要看当前指针和这些坐标就能把所有框画出来。 - -#### 7.5.4 菜单位置如何计算(以 `Menu_Sub1PosCal` 为例) - -用于计算二级菜单(以及更深层子菜单)矩形框位置的典型函数是 `Menu_Sub1PosCal`,高度概括为: - -1. 统计当前菜单的**同级菜单数量**和**下一级菜单的最大标题长度** -2. 根据父菜单的框位置 + 当前菜单在本层中的序号,确定 Y 起点 -3. 根据子菜单项数和行高 `LINE_HEIGHT`,确定 Y 终点,并做“是否超屏”判断 -4. 若超出底部,则整体向上平移一段,避免越界 - -简化后的“计算流程图”: - -```text -输入: - - bylevel 当前层级(例如 1 表示一级菜单) - - ptFirst[bylevel] 本级菜单首节点 - - ptIndex[bylevel] 遍历用当前指针 - -步骤: -1) 统计下一级菜单: - byMaxLen = Menu_charLenCal(...) - → 得到最长标题字符数 byMaxLen - → 得到各层级菜单项数 byMenuNum[] - -2) 计算 X 方向: - wSPosX = 父菜单 wEPosX - wEPosX = wSPosX + byMaxLen * MENU_WITDTH + MENU_XADD - -3) 计算 Y 起点: - byMenuPos = 当前菜单在本级中的位置 (wPos) - byItemNum = 本级菜单总项数 (byMenuNum[bylevel]) - 如本级菜单超过一屏,修正 byMenuPos 做翻页映射 - wSPosY = 父菜单 wSPosY + (byMenuPos - 1) * LINE_HEIGHT - -4) 计算 Y 终点: - byItemNum = 子菜单项数 (byMenuNum[bylevel + 1]) - wEPosY = wSPosY + byItemNum * LINE_HEIGHT + MENU_YADD - -5) 越界修正: - 如果 wEPosY > MENU_YMAX: - a) 尝试从当前项往上展开,使尾部对齐当前项 - b) 若仍越界,则限制在 [0, MENU_YMAX] 范围内 - -6) 写回结果: - ptIndex[bylevel]->wSPosX/Y = wSPosX/Y - ptIndex[bylevel]->wEPosX/Y = wEPosX/Y -``` - -可以把它想象成: -**“拿到父菜单的框,把子菜单框贴在它右侧,再根据当前高亮位置往上或往下展开一串子项,同时保证整块框不会跑出屏幕。”** - -#### 7.5.5 多级菜单同时显示时的布局 - -当当前指针在三级菜单时,屏幕上通常会同时显示: - -```text -┌──────── 0 级菜单框 ────────┐ -│ ... │ -└───────────────────────────┘ - │ - ▼ -┌──────── 1 级菜单框 ────────┐ -│ ... 当前所在的一级菜单 │ -└───────────────────────────┘ - │ - ▼ - ┌── 2 级菜单框 ──┐ - │ 子项1 │ - │ 子项2(高亮) │ ← g_tMenuCtrl.ptCurrent - │ 子项3 │ - └──────────────┘ -``` - -`Menu_Show_Proc` 会依次遍历 `g_tMenuCtrl.ptRoute[]` 中记录的每一级菜单指针: - -1. 对每一级调用对应的“位置计算函数”(0 级用 `Menu_0LevelPosCal`,1/2/3 级用 `Menu_Sub1PosCal` 等) -2. 根据计算出的矩形框坐标,在 `display.c` 中画出边框和文字内容 -3. 对当前高亮项增加反显/反色效果 - -这样即可实现类似真实 HMI 上的“多级弹出菜单”效果。 ---- - -## 9. UTF‑8 汉字库(12×12 点阵) - -项目中通过 `utf8_hz12_data.c/h` 存储 12×12 点阵的汉字库,供 LCD 绘制函数使用。 - -- 当需要**增加新的汉字**时,可以: - 1. 修改或扩展 `gen_utf8_hz12.py` 中的字符集/输入文本 - 2. 运行脚本,重新生成 `utf8_hz12_data.c/h` - 3. 重新编译项目 -- LCD 显示模块会在绘制字符串时,根据 UTF‑8 编码在该表中查找对应点阵。 - -具体使用方式可以参考: - -- `src/Drv/lcd/ascii.c`:ASCII/字母数字字符绘制 -- `src/Drv/lcd/lcd.c`:LCD 核心上下文与像素接口 -- `src/Drv/lcd/lcd_draw.c`:图元绘制接口 -- `src/Drv/lcd/lcd_text.c`:文本渲染接口 -- `src/Drv/lcd/text_codec.c`:UTF-8 解码接口 - ---- - -## 10. 调试与日志 - -- CMake 中提供了一个 `ENABLE_DEBUG` 选项,用于开启调试输出: - - ```bash - cmake -DENABLE_DEBUG=ON .. - cmake --build . - ``` - -- 打开后会自动定义 `DEBUG` 宏,部分代码(例如 `Menu_Sub1PosCal` 等)会通过 `printf` 输出调试信息,方便观察菜单坐标计算、页滚动等细节。 - -如果你希望在调试时看到更多状态信息,可以: - -- 在关键流程(如 `Menu_Route`、`Menu_Show_Proc`、`remoteDisplay` 收发函数)增加 `#ifdef DEBUG` 包裹的日志 -- 使用 GDB / VS 调试器在 `main.c`/`menu.c` 等处打断点,单步查看菜单树和坐标计算过程 - ---- - -## 11. 单元测试与门禁 - -### 11.1 运行单元测试 - -```powershell -# 1. 配置:生成构建文件(如 Makefile)到 build 目录 -cmake -S . -B build -# 2. 编译:真正编译代码 +cmake -S . -B build -DENABLE_DEBUG=ON cmake --build build -# 3. 进行测试 -# ctest 用 CTest 测试运行器(CMake 自带) -# --test-dir build 指定测试目录是 build。 -# -C Debug 指定运行 Debug 配置下编译出的测试程序。 -# --output-on-failure 只有测试失败时,才输出该测试的 stdout/stderr 详细信息 -ctest --test-dir build -C Debug --output-on-failure ``` -当前已落地测试集合: +建议调试入口: -- `test_smoke`:测试框架烟雾验证 -- `test_p0_remote_display`:`calc_crc`、`parse_frame` 协议解析核心 -- `test_p0_utf8_next`:UTF-8 解码边界场景 -- `test_p0_utf8_hz12_get`:汉字字库二分查找命中/未命中 -- `test_p1_key`:按键状态消费语义 -- `test_p1_lcd_basic`:像素读写、填充、反显 -- `test_p1_menu`:菜单长度计算、层级初始化 -- `test_p2_tcp_loopback`:TCP 本机回环收发(集成) +- 菜单树构建问题:看 `src/Drv/pages/menu/model.c` +- 页面切换与事件分发问题:看 `src/Drv/pages/page_manager.c` +- 高亮与刷新异常:看 `src/Drv/pages/menu/presenter.c`、`src/Drv/pages/menu/view.c` +- TCP 联调问题:看 `src/main.c`、`src/remoteDisplay.c`、`src/TCP/tcp.c` -### 11.2 质量门禁(建议) +--- -- 提交前最低门禁:`ctest --test-dir build -C Debug --output-on-failure` 必须全绿。 -- 新增或修改纯逻辑函数时,必须同时补充对应单元测试。 -- 修复缺陷时,必须附带回归测试用例,避免问题再次出现。 +## 测试说明 -### 11.3 覆盖率目标(阶段性) +当前 `tests/CMakeLists.txt` 已注册以下测试: -- P0 核心模块(协议解析/字符解析)行覆盖率建议 >= 70%。 -- P0 核心模块分支覆盖率建议 >= 60%。 -- P1/P2 模块可按迭代逐步提升,不要求一次到位。 +- `test_smoke`:测试框架可用性 +- `test_p0_remote_display`:远程显示协议/链路核心逻辑 +- `test_p0_utf8_hz12_get`:字库查找 +- `test_p1_key`:按键模块 +- `test_p1_lcd_basic`:LCD 像素与基础绘制 +- `test_p1_page_manager`:页面管理器生命周期、导航与事件分发 +- `test_p1_menu`:菜单模型、视图初始化、页面入口绑定 +- `test_p2_tcp_loopback`:TCP 本机回环集成测试 + +其中比较值得关注的是: + +- `test_p1_menu.c`:覆盖菜单树构建、显示名修饰、`wPos/wNum` 统计、菜单页实例绑定 +- `test_p1_page_manager.c`:覆盖注册、导航、`ESC` 兜底回退、缓存页与非缓存页行为 + +建议门禁: + +- 提交前至少执行一次 `ctest --test-dir build -C Debug --output-on-failure` +- 修改纯逻辑代码时优先补充或更新对应单元测试 + +--- + +## 后续扩展建议 + +从当前代码结构看,比较自然的演进方向包括: + +- 在 `pages/` 下继续新增业务页面,而不是把所有交互都塞进菜单页 +- 为 `MenuMode` 和顶部状态栏接入真实业务状态源 +- 补充 RemoDispBus 交互说明和上位机联调文档 +- 继续加强 `Presenter` 和 `View` 的细粒度测试覆盖 diff --git a/src/Drv/pages/menu/def.h b/src/Drv/pages/menu/def.h index 6c04b9d..83b78b7 100644 --- a/src/Drv/pages/menu/def.h +++ b/src/Drv/pages/menu/def.h @@ -87,8 +87,8 @@ typedef struct * 菜单项/菜单框的布局计算结果,供 View 层绘制与反显定位使用。 * * 字段说明: - * wPos - 当前项在本层中的行号(从 0 开始) - * wNum - 当前层可见项数量 + * wPos - 当前项在本层中的 0 基行号 + * wNum - 当前节点拥有的直接子节点总数 * wSPosX - 绘制区域左上角 X * wSPosY - 绘制区域左上角 Y * wEPosX - 绘制区域右下角 X @@ -96,8 +96,8 @@ typedef struct * ------------------------------------------------------------------------- */ typedef struct { - uint16_t wPos; /* 当前项在本层中的行号(从 0 开始) */ - uint16_t wNum; + uint16_t wPos; /* 当前项在本层中的 0 基行号 */ + uint16_t wNum; /* 当前节点拥有的直接子节点总数 */ uint16_t wSPosX; uint16_t wSPosY; uint16_t wEPosX; diff --git a/tests/test_p1_menu.c b/tests/test_p1_menu.c index 7420d80..3f66134 100644 --- a/tests/test_p1_menu.c +++ b/tests/test_p1_menu.c @@ -78,7 +78,7 @@ int main(void) /* --------------------------------------------------------------------- * Case 3: * 测试 MenuModel_IndexMenuItems / MenuModel_GetMenuMaxDisplayLen - * - 使用真实菜单模型验证序号初始化、子节点数量统计与子菜单宽度统计 + * - 使用真实菜单模型验证 0 基序号初始化、子节点数量统计与子菜单宽度统计 * --------------------------------------------------------------------- */ first[0] = &menuModel.menuItems[0]; index[0] = &menuModel.menuItems[0]; @@ -134,7 +134,7 @@ int main(void) /* --------------------------------------------------------------------- * Case 5.1: * 强化测试 MenuModel_IndexMenuItems - * - 覆盖多顶层、多兄弟、跨层回退后的 wPos / wNum 统计 + * - 覆盖多顶层、多兄弟、跨层回退后的 0 基 wPos / wNum 统计 * --------------------------------------------------------------------- */ memset(customItems2, 0, sizeof(customItems2)); MenuModel_BuildTree(customItems2, customModelTab2, 7);