Files
DTU-HMI/README.md

729 lines
30 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# DTU-HMI
PC 端 HMI 菜单逻辑模拟程序,纯 C 实现,用于在电脑上模拟现场 DTU 设备的人机界面HMI行为。
- **菜单树**:按真实装置菜单还原,支持多级嵌套、首尾成环遍历
- **LCD 显示**:模拟 128×64 单色 LCD点阵缓冲区 + 绘制函数)
- **键盘输入**:用 PC 键盘按键映射嵌入式按键(上/下/左/右/确认/退出)
- **TCP 远程显示**:实现 RemoDispBus 协议,可把 LCD 显示通过网络推送到上位机
- **UTF8 汉字库**:内置 12×12 点阵汉字库,可扩展
---
## 1. 快速开始(构建 & 运行)
### 1.1 构建可执行程序
```powershell
mkdir build
cd build
cmake ..
cmake --build .
```
生成可执行文件:
- Windows`build/DTU-HMI.exe`
- Linux/macOS`build/DTU-HMI`
### 1.2 直接在本机运行
`build` 目录中执行:
```powershell
./DTU-HMI.exe # Windows PowerShell
./DTU-HMI # Linux / macOS
```
终端中会出现类似“LCD 文本界面 + 菜单内容”的输出,使用键盘控制菜单。
## 3. 目录结构
```
DTU-HMI/
├── CMakeLists.txt
├── gen_utf8_hz12.py # 12×12 UTF-8 汉字库生成脚本
├── remo_disp_server.py # 远程显示 Python 服务端
├── include/
│ └── types.h
├── src/
│ ├── main.c
│ ├── thread_utils.c/h
│ ├── remoteDisplay.c/h
│ ├── TCP/tcp.c, tcp.h
│ └── Drv/
│ ├── menu.c/h
│ ├── display.c/h
│ ├── lcd/
│ │ ├── lcd.c/h
│ │ ├── lcd_draw.c/h
│ │ ├── lcd_text.c/h
│ │ ├── text_codec.c/h
│ │ └── ascii.c/h
│ └── utf8_hz12_data.c/h # 由脚本生成
└── build/
```
---
## 4. 环境要求
- **CMake** 3.10+
- **编译器**Windows 默认 MSVCLinux 需 GCC/Clang
- **编码**:源文件 UTF-8CMake 已配置 MSVC `/utf-8`
---
## 5. 构建步骤
### 5.1 Windows
```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. UTF8 汉字库12×12 点阵)
项目中通过 `utf8_hz12_data.c/h` 存储 12×12 点阵的汉字库,供 LCD 绘制函数使用。
- 当需要**增加新的汉字**时,可以:
1. 修改或扩展 `gen_utf8_hz12.py` 中的字符集/输入文本
2. 运行脚本,重新生成 `utf8_hz12_data.c/h`
3. 重新编译项目
- LCD 显示模块会在绘制字符串时,根据 UTF8 编码在该表中查找对应点阵。
具体使用方式可以参考:
- `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 --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 本机回环收发(集成)
### 11.2 质量门禁(建议)
- 提交前最低门禁:`ctest --test-dir build -C Debug --output-on-failure` 必须全绿。
- 新增或修改纯逻辑函数时,必须同时补充对应单元测试。
- 修复缺陷时,必须附带回归测试用例,避免问题再次出现。
### 11.3 覆盖率目标(阶段性)
- P0 核心模块(协议解析/字符解析)行覆盖率建议 >= 70%。
- P0 核心模块分支覆盖率建议 >= 60%。
- P1/P2 模块可按迭代逐步提升,不要求一次到位。