修改了菜单按键的逻辑,使菜单可以根据按键动态跳转,但是还没有实现对应的操作逻辑

This commit is contained in:
2026-03-12 18:01:57 +08:00
parent fb1a28df00
commit c2ce221691
13 changed files with 4241 additions and 457 deletions

358
README.md
View File

@@ -1,10 +1,18 @@
# DTU-HMI
PC 端 HMI 菜单逻辑模拟程序,纯 C 实现,支持菜单树、LCD 显示、TCP 远程显示RemoDispBus 协议)及 UTF-8 汉字库
PC 端 HMI 菜单逻辑模拟程序,纯 C 实现,用于在电脑上模拟现场 DTU 设备的人机界面HMI行为
- **菜单树**:按真实装置菜单还原,支持多级嵌套、首尾成环遍历
- **LCD 显示**:模拟 128×64 单色 LCD点阵缓冲区 + 绘制函数)
- **键盘输入**:用 PC 键盘按键映射嵌入式按键(上/下/左/右/确认/退出)
- **TCP 远程显示**:实现 RemoDispBus 协议,可把 LCD 显示通过网络推送到上位机
- **UTF8 汉字库**:内置 12×12 点阵汉字库,可扩展
---
## 快速开始
## 1. 快速开始(构建 & 运行)
### 1.1 构建可执行程序
```powershell
mkdir build
@@ -13,11 +21,24 @@ cmake ..
cmake --build .
```
生成可执行文件 `DTU-HMI.exe`Windows`DTU-HMI`Linux
生成可执行文件
---
- 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/
@@ -42,7 +63,7 @@ DTU-HMI/
---
## 环境要求
## 4. 环境要求
- **CMake** 3.10+
- **编译器**Windows 默认 MSVCLinux 需 GCC/Clang
@@ -50,9 +71,9 @@ DTU-HMI/
---
## 构建步骤
## 5. 构建步骤
### Windows
### 5.1 Windows
```powershell
mkdir build
@@ -61,7 +82,7 @@ cmake ..
cmake --build .
```
### Linux / macOS
### 5.2 Linux / macOS
```bash
mkdir build
@@ -72,21 +93,47 @@ cmake --build .
---
## TCP 通信
## 6. TCP 远程显示RemoDispBus
程序监听端口 **7003**RemoDispBus 默认)。使用 `remo_disp_server.py` 连接后可实时查看 LCD 显存画面。协议支持 `CMD_INIT``CMD_LCDMEM``CMD_KEY``CMD_KEEPLIVE`
程序内部通过 `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. 菜单系统
### 1. 概述
### 7.1 概述
菜单由**静态表** `g_tMenuModelTab` 定义,经 `Menu_Main_Creat_01` 构建为**可遍历树** `g_tMenuItem[]`每个结点有 `ptHigher/ptLower/ptBefore/ptBehind` 四个指针,同级首尾成环。
菜单由**静态表** `g_tMenuModelTab` 定义,经 `Menu_Main_Creat_01` 构建为**可遍历树** `g_tMenuItem[]`
运行时由 `menu.c` 负责:
### 2. 数据结构
- 把静态表解析成**树 + 循环双向链表**
- 根据按键事件更新当前指针 `g_tMenuCtrl.ptCurrent`
- 调用一系列“坐标计算函数”给每一级菜单计算矩形框位置
- 最终调用 `display.c` / `lcd.c` 完成绘制
#### 2.1 静态菜单定义tagMenuModel
### 7.2 数据结构
#### 7.2.1 静态菜单定义(`tagMenuModel`
```c
typedef struct
@@ -104,7 +151,7 @@ typedef struct
const tagMenuModel g_tMenuModelTab[] = { ... };
```
#### 2.2 菜单遍历树tagMenuItem
#### 7.2.2 菜单遍历树(`tagMenuItem`
```c
/* 每个菜单包含:一、上下前后等级关系;二、属性与内容;三、显示坐标 */
@@ -130,9 +177,9 @@ typedef struct _MENU_ITEM_
tagMenuItem g_tMenuItem[300]; /* 所有菜单存储于此数组 */
```
### 3. 菜单构建示例
### 7.3 菜单构建示例
#### 3.1 静态表g_tMenuModelTab
#### 7.3.1 静态表(`g_tMenuModelTab`
```c
const tagMenuModel g_tMenuModelTab[] =
@@ -201,7 +248,7 @@ const tagMenuModel g_tMenuModelTab[] =
};
```
#### 3.2 解析后的树形结构
#### 7.3.2 解析后的树形结构
根据上表,`Menu_Main_Creat_01` 解析后的菜单树为:
@@ -281,7 +328,7 @@ const tagMenuModel g_tMenuModelTab[] =
}
```
#### 3.3 单例解析示例:直流量
#### 7.3.3 单例解析示例:直流量
`{2,"直流量","查看遥测直流量",EN_MEA_DC,0x0000,EN_ANA_0,(FUNCPTR)MenuProc_See_YC}` 为例,构建后该菜单项的指针与属性为:
@@ -302,7 +349,7 @@ const tagMenuModel g_tMenuModelTab[] =
}
```
#### 3.4 同级首尾成环
#### 7.3.4 同级首尾成环
同级菜单中,首尾通过 `ptBefore/ptBehind` 相连形成环。例如 2 级子菜单:
@@ -322,11 +369,11 @@ const tagMenuModel g_tMenuModelTab[] =
- `2.虚拟遥信``ptBefore` 指向 `2.信号出口`(首的前一个是尾)
- `2.信号出口``ptBehind` 指向 `2.虚拟遥信`(尾的后一个是首)
### 4. 构建流程Menu_Main_Creat_01
### 7.4 构建流程(`Menu_Main_Creat_01`
`g_tMenuModelTab` 的定义顺序逐项处理,通过比较 `byCurClass``byNextClass` 设置每个菜单的层级指针。
#### 情况 1byCurClass < byNextClass下一项更深进入子菜单
#### 7.4.1 情况 1`byCurClass < byNextClass`(下一项更深,进入子菜单)
```text
ptCurrent ptNextNode
@@ -338,7 +385,7 @@ const tagMenuModel g_tMenuModelTab[] =
该级尾不变 新一级的 首=尾=ptNextNode
```
#### 情况 2byCurClass == byNextClass同级并列
#### 7.4.2 情况 2`byCurClass == byNextClass`(同级,并列)
```text
ptCurrent ─── ptBehind ──► ptNextNode
@@ -349,7 +396,7 @@ const tagMenuModel g_tMenuModelTab[] =
该级 尾 更新为 ptNextNode
```
#### 情况 3byCurClass > byNextClass下一项更浅回到上层
#### 7.4.3 情况 3`byCurClass > byNextClass`(下一项更浅,回到上层)
```text
... byCurClass 级 ... byNextClass 级 ...
@@ -363,7 +410,7 @@ const tagMenuModel g_tMenuModelTab[] =
ptLast[级]──►ptFirst[级]ptFirst[级]──►ptLast[级]
```
#### 最后:各级首尾成环
#### 7.4.4 最后:各级首尾成环
表遍历完后,从 **0 级到当前结点所在级**,把该级首尾连成环:
@@ -373,3 +420,260 @@ const tagMenuModel g_tMenuModelTab[] =
└──── 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/Ascii.c`ASCII/字母数字字符绘制
- `src/Drv/lcd.c`LCD 基础绘制接口
---
## 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` 等处打断点,单步查看菜单树和坐标计算过程