| @@ -55,10 +55,11 @@ tmp/ | |||||
| Thumbs.db | Thumbs.db | ||||
| Desktop.ini | Desktop.ini | ||||
| /scripts | |||||
| /scripts/* | |||||
| !/scripts/run_qt_tests.ps1 | |||||
| /docs/images | /docs/images | ||||
| /docs/类图Mermaid代码.md | /docs/类图Mermaid代码.md | ||||
| /other | /other | ||||
| 设计方案书.doc | 设计方案书.doc | ||||
| /学习 | /学习 | ||||
| /json | |||||
| /json | |||||
| @@ -1,57 +1,73 @@ | |||||
| # 项目长期约定 | # 项目长期约定 | ||||
| - 项目作者:`suyu`。 | |||||
| - 项目作者:`suyu` | |||||
| - 文件、终端输出和源代码统一使用 UTF-8 | |||||
| - 答复我的文本要说人话,口语化答复我 | |||||
| ## 新会话阅读顺序 | |||||
| 1. 先读 `docs/ai/handoff.md`,确认当前状态、未提交改动、验证结果和下一步 | |||||
| 2. 涉及模块边界、数据流、运行模式或持久化时,再读 `docs/architecture.md` | |||||
| 3. 按任务读取下方索引中的业务文档;Markdown 没有答案时再查 `docs/pdf/` | |||||
| 三个入口文档各自只负责一类信息: | |||||
| - `AGENTS.md`:长期有效的开发、构建和安全约束 | |||||
| - `docs/architecture.md`:稳定的分层、模块边界、核心模型和数据流 | |||||
| - `docs/ai/handoff.md`:当前进度、工作区状态、最近验证和下一步;更新时替换旧状态,不追加开发流水账 | |||||
| ## 关键文件索引 | ## 关键文件索引 | ||||
| - C++ 代码规范:`docs/C++代码规范.md`。 | |||||
| - 需求参考文档:`docs/0_综合平台编程器_修改.md`。 | |||||
| - 推荐开发顺序:`docs/开发顺序.md`。 | |||||
| - Qt 应用源码根目录:`app/`。 | |||||
| - 本机构建输出目录:`build/`(不纳入 Git)。 | |||||
| - Qt 一键构建运行脚本:`scripts/build_and_run_qt.ps1`。 | |||||
| - XDH-60T4-E 硬件与接线要点:`docs/XDH-60T4-E硬件与接线要点.md`。 | |||||
| - XDH-60T4-E 指令与 Modbus 要点:`docs/XDH-60T4-E指令与Modbus要点.md`。 | |||||
| - PLC 官方手册:`docs/pdf/`。 | |||||
| - 当前开发进度和未完成事项:`docs/ai/handoff.md`(创建后每次新会话优先阅读;不要把动态进度写入本文件)。 | |||||
| - 架构、模块边界和数据流:`docs/architecture.md`(创建后优先阅读)。 | |||||
| - 查阅资料时,先读取 UTF-8 编码的 Markdown 文档;只有 Markdown 中找不到所需信息时,才查阅 `docs/pdf/` 内的 PDF 手册。 | |||||
| - 当前交接:`docs/ai/handoff.md` | |||||
| - 架构与数据流:`docs/architecture.md` | |||||
| - C++ 规范:`docs/C++代码规范.md` | |||||
| - 原始需求:`docs/0_综合平台编程器_修改.md` | |||||
| - 数量边界最终规则:`docs/用户使用/数量边界确认方案.md` | |||||
| - 工程 JSON 格式:`docs/工程格式说明.md` | |||||
| - 开发阶段顺序:`docs/开发顺序.md`(阶段参考,当前状态以 handoff 为准) | |||||
| - 测试约定:`docs/测试约定.md` | |||||
| - XDH-60T4-E 硬件接线:`docs/XDH-60T4-E硬件与接线要点.md` | |||||
| - XDH-60T4-E 指令与 Modbus:`docs/XDH-60T4-E指令与Modbus要点.md` | |||||
| - PLC 官方手册:`docs/pdf/` | |||||
| - Qt 源码:`app/` | |||||
| - 构建输出:`build/`(不纳入 Git) | |||||
| ## 技术栈与构建 | ## 技术栈与构建 | ||||
| - 使用 C++、Qt 5.15.2 和 Qt Widgets;UI 默认使用 Qt Designer 的 `.ui` 文件。 | |||||
| - 使用 qmake,不使用 CMake。 | |||||
| - Qt MinGW 套件根目录:`D:\Qt5.15.2\5.15.2\mingw81_64`。 | |||||
| - 构建前在 PowerShell 7 中设置 Qt 工具链路径;MinGW 工具目录为常见安装位置,如实际安装位置不同,以本机路径为准: | |||||
| ```powershell | |||||
| $env:QTDIR = 'D:\Qt5.15.2\5.15.2\mingw81_64' | |||||
| $env:Path = "$env:QTDIR\bin;D:\Qt5.15.2\Tools\mingw810_64\bin;$env:Path" | |||||
| qmake <项目文件>.pro | |||||
| mingw32-make -j2 | |||||
| ``` | |||||
| - 优先在独立构建目录执行 qmake 和 `mingw32-make`,不要将生成物混入源代码目录。 | |||||
| - 文件读写、终端查看和源代码统一使用 UTF-8。 | |||||
| - 一键构建并运行 Debug 程序:`pwsh -NoLogo -NoProfile -File .\scripts\build_and_run_qt.ps1`。 | |||||
| - 一键构建并运行 Release 程序:`pwsh -NoLogo -NoProfile -File .\scripts\build_and_run_qt.ps1 -Configuration Release`。 | |||||
| - 打开 ui 文件:` & 'D:\Qt5.15.2\5.15.2\mingw81_64\bin\designer.exe' '.\app\src\ui\main_window.ui'` | |||||
| ## Git 提交约定 | |||||
| - 每次提交只包含一个逻辑完整的改动;提交前必须完成对应的构建或测试验证。 | |||||
| - 提交信息使用 `类型: 简短说明` 格式,例如 `feat: 建立 Qt 工程基线`。 | |||||
| - 类型使用 `feat`、`fix`、`docs`、`test`、`refactor` 或 `chore`。 | |||||
| ## 架构与运行模式 | |||||
| - 保持 UI、领域、服务和基础设施分层;业务逻辑不得堆入 `MainWindow` 或 Qt 槽函数。 | |||||
| - HMI 只绑定 M/D 寄存器仓库,不得直接读写串口;串口通信必须异步,不能阻塞 UI。 | |||||
| - M 区和 D 区的项目可用地址均为 `0~4000`;Modbus 代码使用从 `0` 开始的原始地址。 | |||||
| - 离线模式使用虚拟 M/D,并运行软件逻辑执行器。 | |||||
| - 真机模式使用 PLC 的真实 M/D,软件逻辑执行器必须停止;切换时先读取 PLC 数据,不自动写入离线虚拟值。 | |||||
| - 真机联动设备时 PLC 必须处于 RUN;STOP 仅用于安全通信验证。 | |||||
| ## 注意 | |||||
| - 生成注释的文本结尾别加逗号 `。`。 | |||||
| - 你写完代码需要测试真机PLC可以直接连 COM3,9600 8e1配置。 | |||||
| - 使用 C++17、Qt 5.15.2、Qt Widgets 和 qmake,不使用 CMake | |||||
| - UI 默认使用 Qt Designer `.ui` 文件;不要把静态表单重新堆回 C++ | |||||
| - Qt MinGW 套件:`D:\Qt5.15.2\5.15.2\mingw81_64` | |||||
| - Windows 命令优先使用 PowerShell 7;显式调用使用 `pwsh -NoLogo -NoProfile -Command`,脚本使用 `pwsh -NoLogo -NoProfile -File` | |||||
| - 复杂 PowerShell 逻辑写入 `.ps1` 后执行,避免嵌套引号;排查兼容问题时先确认 `$PSVersionTable.PSVersion` 和 `$PSVersionTable.PSEdition` | |||||
| - 在独立构建目录运行 qmake 和 `mingw32-make`,不要把生成物写进 `app/` | |||||
| - Debug 构建运行:`pwsh -NoLogo -NoProfile -File .\scripts\build_and_run_qt.ps1` | |||||
| - Release 构建运行:`pwsh -NoLogo -NoProfile -File .\scripts\build_and_run_qt.ps1 -Configuration Release` | |||||
| - 打开主界面:`& 'D:\Qt5.15.2\5.15.2\mingw81_64\bin\designer.exe' '.\app\src\ui\main_window.ui'` | |||||
| ## 代码与架构约束 | |||||
| - 保持 `UI -> Services -> Domain` 依赖方向,基础设施实现服务契约;`domain` 不依赖 Qt、串口或文件系统 | |||||
| - 业务规则放领域或服务层,不得堆入 `MainWindow`、Qt 槽函数或图元绘制代码 | |||||
| - HMI 和运行界面只访问寄存器仓库,不直接访问串口;PLC 通信必须异步,不能阻塞 UI | |||||
| - 编辑操作通过服务完成并保持原子性,失败时不得留下部分修改 | |||||
| - 数量边界统一定义在 `app/src/domain/project_limits.h`,修改时同步更新数量边界文档和边界测试 | |||||
| - M/D 项目地址范围固定为 `0~4000`;Modbus 使用从 `0` 开始的原始地址 | |||||
| - 离线模式使用虚拟 M/D 并运行软件逻辑执行器 | |||||
| - 真机模式使用 PLC 读回缓存并停止软件逻辑执行器;切换时先读 PLC,不复制离线值 | |||||
| - 本项目不生成、编译或下载 PLC 程序,不得把本地梯形图轨迹当成 PLC 内部程序轨迹 | |||||
| - 代码注释只解释不明显的约束,末尾不要加句号 `。` | |||||
| ## 验证与安全 | |||||
| - 代码改动必须构建相关测试;共享模型、持久化、运行模式或 UI 工作流改动还要扩大回归范围 | |||||
| - 提交前至少完成对应自动化测试、Release 构建和 `git diff --check` | |||||
| - 涉及 PLC 通信或真机运行模式的代码,使用 `COM3 / 9600 / 8E1 / 站号 1` 做真实读写验证;先读取原值,测试后恢复并读回确认 | |||||
| - STOP 用于安全通信验证;带真实设备的联动测试必须在确认接线和地址后切到 RUN | |||||
| - 纯文档、领域校验或离线执行器改动不要求连接 PLC,但要在 handoff 中说明未做真机测试的原因 | |||||
| - 离线仿真不会向 PLC 下载程序 | |||||
| ## Git 提交 | |||||
| - 不覆盖或回退工作区中来源不明的改动,开始前先检查 `git status` 和相关 diff | |||||
| - 每次提交只包含一个逻辑完整改动,提交前完成对应验证 | |||||
| - 提交信息使用 `类型: 简短说明`,类型限 `feat`、`fix`、`docs`、`test`、`refactor`、`chore` | |||||
| @@ -17,18 +17,30 @@ INCLUDEPATH += src | |||||
| SOURCES += \ | SOURCES += \ | ||||
| src/main.cpp \ | src/main.cpp \ | ||||
| src/ui/main_window.cpp \ | src/ui/main_window.cpp \ | ||||
| src/ui/logic_instruction_dialog.cpp \ | |||||
| src/ui/alarm_configuration_dialog.cpp \ | |||||
| src/ui/register_comment_dialog.cpp \ | |||||
| src/ui/plc_connection_dialog.cpp \ | src/ui/plc_connection_dialog.cpp \ | ||||
| src/ui/free_monitor_widget.cpp \ | src/ui/free_monitor_widget.cpp \ | ||||
| src/ui/runtime_monitor_window.cpp \ | |||||
| src/ui/runtime_monitor_widget.cpp \ | src/ui/runtime_monitor_widget.cpp \ | ||||
| src/ui/toolbar_icon_factory.cpp \ | |||||
| src/ui/project_workspace_controller.cpp \ | |||||
| src/ui/property_panel_controller.cpp \ | |||||
| src/ui/runtime_panel_controller.cpp \ | |||||
| src/domain/register_address.cpp \ | src/domain/register_address.cpp \ | ||||
| src/domain/register_repository.cpp \ | src/domain/register_repository.cpp \ | ||||
| src/domain/active_register_repository.cpp \ | src/domain/active_register_repository.cpp \ | ||||
| src/domain/register_monitor_model.cpp \ | src/domain/register_monitor_model.cpp \ | ||||
| src/domain/alarm_model.cpp \ | |||||
| src/domain/hmi_model.cpp \ | src/domain/hmi_model.cpp \ | ||||
| src/domain/hmi_control_registry.cpp \ | |||||
| src/domain/control_logic_model.cpp \ | src/domain/control_logic_model.cpp \ | ||||
| src/domain/project_model.cpp \ | src/domain/project_model.cpp \ | ||||
| src/domain/runtime_state.cpp \ | src/domain/runtime_state.cpp \ | ||||
| src/services/project_service.cpp \ | src/services/project_service.cpp \ | ||||
| src/services/alarm_editor_service.cpp \ | |||||
| src/services/alarm_service.cpp \ | |||||
| src/services/hmi_editor_service.cpp \ | src/services/hmi_editor_service.cpp \ | ||||
| src/services/hmi_navigation_service.cpp \ | src/services/hmi_navigation_service.cpp \ | ||||
| src/services/logic_editor_service.cpp \ | src/services/logic_editor_service.cpp \ | ||||
| @@ -37,6 +49,7 @@ SOURCES += \ | |||||
| src/services/offline_simulation_service.cpp \ | src/services/offline_simulation_service.cpp \ | ||||
| src/services/runtime_mode_service.cpp \ | src/services/runtime_mode_service.cpp \ | ||||
| src/services/register_monitor_service.cpp \ | src/services/register_monitor_service.cpp \ | ||||
| src/services/register_comment_service.cpp \ | |||||
| src/infrastructure/plc_register_repository.cpp \ | src/infrastructure/plc_register_repository.cpp \ | ||||
| src/infrastructure/plc_communication_error_classifier.cpp \ | src/infrastructure/plc_communication_error_classifier.cpp \ | ||||
| src/infrastructure/plc_communication_service.cpp \ | src/infrastructure/plc_communication_service.cpp \ | ||||
| @@ -46,19 +59,33 @@ SOURCES += \ | |||||
| HEADERS += \ | HEADERS += \ | ||||
| src/ui/main_window.h \ | src/ui/main_window.h \ | ||||
| src/ui/logic_instruction_dialog.h \ | |||||
| src/ui/alarm_configuration_dialog.h \ | |||||
| src/ui/register_comment_dialog.h \ | |||||
| src/ui/plc_connection_dialog.h \ | src/ui/plc_connection_dialog.h \ | ||||
| src/ui/free_monitor_widget.h \ | src/ui/free_monitor_widget.h \ | ||||
| src/ui/runtime_monitor_window.h \ | |||||
| src/ui/runtime_monitor_widget.h \ | src/ui/runtime_monitor_widget.h \ | ||||
| src/ui/toolbar_icon_factory.h \ | |||||
| src/ui/project_workspace_controller.h \ | |||||
| src/ui/property_panel_controller.h \ | |||||
| src/ui/runtime_panel_controller.h \ | |||||
| src/domain/register_address.h \ | src/domain/register_address.h \ | ||||
| src/domain/register_repository.h \ | src/domain/register_repository.h \ | ||||
| src/domain/active_register_repository.h \ | src/domain/active_register_repository.h \ | ||||
| src/domain/register_monitor_model.h \ | src/domain/register_monitor_model.h \ | ||||
| src/domain/alarm_model.h \ | |||||
| src/domain/hmi_model.h \ | src/domain/hmi_model.h \ | ||||
| src/domain/hmi_control_registry.h \ | |||||
| src/domain/control_logic_model.h \ | src/domain/control_logic_model.h \ | ||||
| src/domain/project_limits.h \ | |||||
| src/domain/project_model.h \ | src/domain/project_model.h \ | ||||
| src/domain/runtime_state.h \ | src/domain/runtime_state.h \ | ||||
| src/domain/project_storage.h \ | src/domain/project_storage.h \ | ||||
| src/services/project_service.h \ | src/services/project_service.h \ | ||||
| src/services/editor_history.h \ | |||||
| src/services/alarm_editor_service.h \ | |||||
| src/services/alarm_service.h \ | |||||
| src/services/hmi_editor_service.h \ | src/services/hmi_editor_service.h \ | ||||
| src/services/hmi_navigation_service.h \ | src/services/hmi_navigation_service.h \ | ||||
| src/services/logic_editor_service.h \ | src/services/logic_editor_service.h \ | ||||
| @@ -67,6 +94,7 @@ HEADERS += \ | |||||
| src/services/offline_simulation_service.h \ | src/services/offline_simulation_service.h \ | ||||
| src/services/runtime_mode_service.h \ | src/services/runtime_mode_service.h \ | ||||
| src/services/register_monitor_service.h \ | src/services/register_monitor_service.h \ | ||||
| src/services/register_comment_service.h \ | |||||
| src/services/plc_communication_gateway.h \ | src/services/plc_communication_gateway.h \ | ||||
| src/infrastructure/plc_register_repository.h \ | src/infrastructure/plc_register_repository.h \ | ||||
| src/infrastructure/plc_communication_error_classifier.h \ | src/infrastructure/plc_communication_error_classifier.h \ | ||||
| @@ -77,6 +105,10 @@ HEADERS += \ | |||||
| FORMS += \ | FORMS += \ | ||||
| src/ui/main_window.ui \ | src/ui/main_window.ui \ | ||||
| src/ui/logic_instruction_dialog.ui \ | |||||
| src/ui/alarm_configuration_dialog.ui \ | |||||
| src/ui/register_comment_dialog.ui \ | |||||
| src/ui/plc_connection_dialog.ui \ | src/ui/plc_connection_dialog.ui \ | ||||
| src/ui/free_monitor_widget.ui \ | src/ui/free_monitor_widget.ui \ | ||||
| src/ui/runtime_monitor_window.ui \ | |||||
| src/ui/runtime_monitor_widget.ui | src/ui/runtime_monitor_widget.ui | ||||
| @@ -0,0 +1,65 @@ | |||||
| #include "alarm_model.h" | |||||
| #include "project_limits.h" | |||||
| namespace { | |||||
| void setError(std::string *error, const std::string &message) | |||||
| { | |||||
| if (error != nullptr) | |||||
| { | |||||
| *error = message; | |||||
| } | |||||
| } | |||||
| } // namespace | |||||
| bool AlarmDefinition::validate(std::string *error) const | |||||
| { | |||||
| if (id.empty()) | |||||
| { | |||||
| setError(error, "报警定义 ID 不能为空"); | |||||
| return false; | |||||
| } | |||||
| if (id.size() > ProjectLimits::kMaximumIdBytes) | |||||
| { | |||||
| setError(error, "报警定义 ID 不能超过 128 个 UTF-8 字节"); | |||||
| return false; | |||||
| } | |||||
| if (message.empty()) | |||||
| { | |||||
| setError(error, "报警文本不能为空"); | |||||
| return false; | |||||
| } | |||||
| if (message.size() > ProjectLimits::kMaximumTextBytes) | |||||
| { | |||||
| setError(error, "报警文本不能超过 4096 个 UTF-8 字节"); | |||||
| return false; | |||||
| } | |||||
| if (!address.isValid()) | |||||
| { | |||||
| setError(error, "报警定义使用了无效地址"); | |||||
| return false; | |||||
| } | |||||
| if (condition == AlarmCondition::MOn) | |||||
| { | |||||
| if (address.area() != RegisterArea::M) | |||||
| { | |||||
| setError(error, "M ON 报警必须使用 M 地址"); | |||||
| return false; | |||||
| } | |||||
| return true; | |||||
| } | |||||
| if (condition == AlarmCondition::DHigh | |||||
| || condition == AlarmCondition::DLow) | |||||
| { | |||||
| if (address.area() != RegisterArea::D) | |||||
| { | |||||
| setError(error, "D 高限和低限报警必须使用 D 地址"); | |||||
| return false; | |||||
| } | |||||
| return true; | |||||
| } | |||||
| setError(error, "不支持的报警触发条件"); | |||||
| return false; | |||||
| } | |||||
| @@ -0,0 +1,24 @@ | |||||
| #pragma once | |||||
| #include "register_address.h" | |||||
| #include <cstdint> | |||||
| #include <string> | |||||
| enum class AlarmCondition | |||||
| { | |||||
| MOn, | |||||
| DHigh, | |||||
| DLow | |||||
| }; | |||||
| struct AlarmDefinition | |||||
| { | |||||
| std::string id; | |||||
| RegisterAddress address{RegisterArea::M, 0}; | |||||
| AlarmCondition condition = AlarmCondition::MOn; | |||||
| std::int16_t threshold = 0; | |||||
| std::string message; | |||||
| bool validate(std::string *error = nullptr) const; | |||||
| }; | |||||
| @@ -1,6 +1,10 @@ | |||||
| #include "control_logic_model.h" | #include "control_logic_model.h" | ||||
| #include "project_limits.h" | |||||
| #include <algorithm> | #include <algorithm> | ||||
| #include <map> | |||||
| #include <type_traits> | |||||
| #include <utility> | #include <utility> | ||||
| namespace { | namespace { | ||||
| @@ -13,6 +17,12 @@ void setError(std::string *error, const std::string &message) | |||||
| } | } | ||||
| } | } | ||||
| bool containsLineBreak(const std::string &value) | |||||
| { | |||||
| return value.find('\r') != std::string::npos | |||||
| || value.find('\n') != std::string::npos; | |||||
| } | |||||
| bool validateConfig(const ContactNodeConfig &config, std::string *error) | bool validateConfig(const ContactNodeConfig &config, std::string *error) | ||||
| { | { | ||||
| if (!config.address.isValid() || config.address.area() != RegisterArea::M) | if (!config.address.isValid() || config.address.area() != RegisterArea::M) | ||||
| @@ -29,6 +39,50 @@ bool validateConfig(const ContactNodeConfig &config, std::string *error) | |||||
| return true; | return true; | ||||
| } | } | ||||
| bool validateWordAddress( | |||||
| const RegisterAddress &address, | |||||
| const std::string &description, | |||||
| std::string *error) | |||||
| { | |||||
| if (!address.isValid() || address.area() != RegisterArea::D) | |||||
| { | |||||
| setError(error, description + "必须使用有效的 D 区地址"); | |||||
| return false; | |||||
| } | |||||
| return true; | |||||
| } | |||||
| bool validateConfig(const CounterContactNodeConfig &config, std::string *error) | |||||
| { | |||||
| if (!config.address.isValid()) | |||||
| { | |||||
| setError(error, "计数器触点必须使用有效的 C 地址"); | |||||
| return false; | |||||
| } | |||||
| if (config.mode != ContactMode::NormallyOpen | |||||
| && config.mode != ContactMode::NormallyClosed) | |||||
| { | |||||
| setError(error, "计数器触点使用了不支持的模式"); | |||||
| return false; | |||||
| } | |||||
| return true; | |||||
| } | |||||
| bool validateConfig(const EdgeContactNodeConfig &config, std::string *error) | |||||
| { | |||||
| if (!config.address.isValid() || config.address.area() != RegisterArea::M) | |||||
| { | |||||
| setError(error, "边沿触点必须使用有效的 M 区地址"); | |||||
| return false; | |||||
| } | |||||
| if (config.mode != EdgeMode::Rising && config.mode != EdgeMode::Falling) | |||||
| { | |||||
| setError(error, "边沿触点使用了不支持的模式"); | |||||
| return false; | |||||
| } | |||||
| return true; | |||||
| } | |||||
| bool validateConfig(const CoilNodeConfig &config, std::string *error) | bool validateConfig(const CoilNodeConfig &config, std::string *error) | ||||
| { | { | ||||
| if (!config.address.isValid() || config.address.area() != RegisterArea::M) | if (!config.address.isValid() || config.address.area() != RegisterArea::M) | ||||
| @@ -66,6 +120,94 @@ bool validateConfig(const CompareNodeConfig &config, std::string *error) | |||||
| return true; | return true; | ||||
| } | } | ||||
| bool validateConfig(const TimerContactNodeConfig &config, std::string *error) | |||||
| { | |||||
| if (!config.address.isValid()) | |||||
| { | |||||
| setError(error, "定时器触点必须使用有效的 T 地址"); | |||||
| return false; | |||||
| } | |||||
| if (config.mode != ContactMode::NormallyOpen | |||||
| && config.mode != ContactMode::NormallyClosed) | |||||
| { | |||||
| setError(error, "定时器触点使用了不支持的模式"); | |||||
| return false; | |||||
| } | |||||
| return true; | |||||
| } | |||||
| bool validateConfig(const TonNodeConfig &config, std::string *error) | |||||
| { | |||||
| if (!config.address.isValid()) | |||||
| { | |||||
| setError(error, "TON 必须使用有效的 T 地址"); | |||||
| return false; | |||||
| } | |||||
| if (config.presetMs < TonNodeConfig::kMinimumPresetMs | |||||
| || config.presetMs > TonNodeConfig::kMaximumPresetMs) | |||||
| { | |||||
| setError(error, "TON 预设时间必须在 1~86400000 ms 范围内"); | |||||
| return false; | |||||
| } | |||||
| return true; | |||||
| } | |||||
| bool validateConfig(const CounterNodeConfig &config, std::string *error) | |||||
| { | |||||
| if (!config.address.isValid()) | |||||
| { | |||||
| setError(error, "计数器指令必须使用有效的 C 地址"); | |||||
| return false; | |||||
| } | |||||
| if (config.mode != CounterMode::Up && config.mode != CounterMode::Down) | |||||
| { | |||||
| setError(error, "计数器指令使用了不支持的方向"); | |||||
| return false; | |||||
| } | |||||
| if (!validateWordAddress(config.currentValueAddress, "计数当前值", error) | |||||
| || !config.preset.validate(error)) | |||||
| { | |||||
| return false; | |||||
| } | |||||
| if (config.preset.kind == WordOperandKind::Constant | |||||
| && config.preset.constant < 0) | |||||
| { | |||||
| setError(error, "计数器常量预设值必须在 0~32767 范围内"); | |||||
| return false; | |||||
| } | |||||
| if (!config.resetAddress.isValid() | |||||
| || config.resetAddress.area() != RegisterArea::M) | |||||
| { | |||||
| setError(error, "计数器复位输入必须使用有效的 M 区地址"); | |||||
| return false; | |||||
| } | |||||
| return true; | |||||
| } | |||||
| bool validateConfig(const MoveNodeConfig &config, std::string *error) | |||||
| { | |||||
| if (!config.source.validate(error)) | |||||
| { | |||||
| return false; | |||||
| } | |||||
| return validateWordAddress(config.destination, "MOVE 目标", error); | |||||
| } | |||||
| bool validateConfig(const ArithmeticNodeConfig &config, std::string *error) | |||||
| { | |||||
| if (config.operation != ArithmeticOperation::Add | |||||
| && config.operation != ArithmeticOperation::Subtract) | |||||
| { | |||||
| setError(error, "算术指令使用了不支持的运算"); | |||||
| return false; | |||||
| } | |||||
| if (!config.left.validate(error) || !config.right.validate(error)) | |||||
| { | |||||
| return false; | |||||
| } | |||||
| return validateWordAddress(config.destination, "算术指令目标", error); | |||||
| } | |||||
| template<typename TItem> | template<typename TItem> | ||||
| bool hasDuplicateId(const std::vector<TItem> &items) | bool hasDuplicateId(const std::vector<TItem> &items) | ||||
| { | { | ||||
| @@ -101,7 +243,9 @@ bool validateUniqueExpressionIds( | |||||
| void normalizeExpression(ConditionExpression *expression) | void normalizeExpression(ConditionExpression *expression) | ||||
| { | { | ||||
| if (expression == nullptr || expression->kind == ConditionExpressionKind::Node) | |||||
| if (expression == nullptr | |||||
| || expression->kind == ConditionExpressionKind::Node | |||||
| || expression->kind == ConditionExpressionKind::Wire) | |||||
| { | { | ||||
| return; | return; | ||||
| } | } | ||||
| @@ -113,6 +257,12 @@ void normalizeExpression(ConditionExpression *expression) | |||||
| std::vector<ConditionExpression> flattened; | std::vector<ConditionExpression> flattened; | ||||
| for (ConditionExpression &child : expression->children) | for (ConditionExpression &child : expression->children) | ||||
| { | { | ||||
| if ((child.kind == ConditionExpressionKind::Series | |||||
| || child.kind == ConditionExpressionKind::Parallel) | |||||
| && child.children.empty()) | |||||
| { | |||||
| continue; | |||||
| } | |||||
| if (child.kind == expression->kind) | if (child.kind == expression->kind) | ||||
| { | { | ||||
| for (ConditionExpression &grandchild : child.children) | for (ConditionExpression &grandchild : child.children) | ||||
| @@ -132,8 +282,306 @@ void normalizeExpression(ConditionExpression *expression) | |||||
| } | } | ||||
| } | } | ||||
| void appendValidAddress( | |||||
| std::vector<RegisterAddress> *addresses, | |||||
| const RegisterAddress &address) | |||||
| { | |||||
| if (address.isValid()) | |||||
| { | |||||
| addresses->push_back(address); | |||||
| } | |||||
| } | |||||
| bool validateConditionExpression( | |||||
| const ConditionExpression &expression, | |||||
| std::size_t depth, | |||||
| std::size_t *node_count, | |||||
| int *columns, | |||||
| int *rows, | |||||
| std::string *error) | |||||
| { | |||||
| if (depth > ProjectLimits::kMaximumExpressionDepth) | |||||
| { | |||||
| setError(error, "条件表达式最多嵌套 20 层"); | |||||
| return false; | |||||
| } | |||||
| ++*node_count; | |||||
| if (*node_count > ProjectLimits::kMaximumExpressionNodesPerRung) | |||||
| { | |||||
| setError(error, "单个网络最多包含 4096 个条件表达式节点和叶子"); | |||||
| return false; | |||||
| } | |||||
| if (expression.id.empty()) | |||||
| { | |||||
| setError(error, "条件表达式 ID 不能为空"); | |||||
| return false; | |||||
| } | |||||
| if (expression.id.size() > ProjectLimits::kMaximumIdBytes) | |||||
| { | |||||
| setError(error, "条件表达式 ID 不能超过 128 个 UTF-8 字节"); | |||||
| return false; | |||||
| } | |||||
| if (expression.kind == ConditionExpressionKind::Node) | |||||
| { | |||||
| if (!expression.node.has_value() || expression.wire.has_value() | |||||
| || !expression.children.empty() || !expression.node->isCondition()) | |||||
| { | |||||
| setError(error, "条件叶节点必须包含一个条件节点且不能包含子表达式"); | |||||
| return false; | |||||
| } | |||||
| *columns = 1; | |||||
| *rows = 1; | |||||
| return expression.node->validate(error); | |||||
| } | |||||
| if (expression.kind == ConditionExpressionKind::Wire) | |||||
| { | |||||
| if (expression.node.has_value() || !expression.wire.has_value() | |||||
| || !expression.children.empty()) | |||||
| { | |||||
| setError(error, "横线叶节点必须包含横线配置且不能包含逻辑节点或子表达式"); | |||||
| return false; | |||||
| } | |||||
| if (!expression.wire->validate(error)) | |||||
| { | |||||
| return false; | |||||
| } | |||||
| *columns = expression.wire->columnSpan; | |||||
| *rows = 1; | |||||
| return true; | |||||
| } | |||||
| if (expression.kind != ConditionExpressionKind::Series | |||||
| && expression.kind != ConditionExpressionKind::Parallel) | |||||
| { | |||||
| setError(error, "条件表达式使用了不支持的类型"); | |||||
| return false; | |||||
| } | |||||
| if (expression.node.has_value() || expression.wire.has_value() | |||||
| || expression.children.size() < 2U) | |||||
| { | |||||
| setError(error, "串联和并联表达式至少需要两个子表达式"); | |||||
| return false; | |||||
| } | |||||
| if (expression.children.size() > ProjectLimits::kMaximumExpressionChildren) | |||||
| { | |||||
| setError(error, "单个串联或并联容器最多包含 256 个子表达式"); | |||||
| return false; | |||||
| } | |||||
| int total_columns = expression.kind == ConditionExpressionKind::Series ? 0 : 1; | |||||
| int total_rows = expression.kind == ConditionExpressionKind::Parallel ? 0 : 1; | |||||
| for (const ConditionExpression &child : expression.children) | |||||
| { | |||||
| if (child.kind == expression.kind) | |||||
| { | |||||
| setError(error, "同类型嵌套表达式必须先完成归一化"); | |||||
| return false; | |||||
| } | |||||
| int child_columns = 0; | |||||
| int child_rows = 0; | |||||
| if (!validateConditionExpression( | |||||
| child, depth + 1U, node_count, | |||||
| &child_columns, &child_rows, error)) | |||||
| { | |||||
| return false; | |||||
| } | |||||
| if (expression.kind == ConditionExpressionKind::Series) | |||||
| { | |||||
| if (total_columns | |||||
| > ProjectLimits::kMaximumConditionColumns - child_columns) | |||||
| { | |||||
| setError(error, "单个网络的条件区最多为 10 列,第 11 列固定用于输出指令"); | |||||
| return false; | |||||
| } | |||||
| total_columns += child_columns; | |||||
| total_rows = std::max(total_rows, child_rows); | |||||
| } | |||||
| else | |||||
| { | |||||
| total_columns = std::max(total_columns, child_columns); | |||||
| if (total_rows > ProjectLimits::kMaximumLogicRows - child_rows) | |||||
| { | |||||
| setError(error, "单个网络的逻辑总行数最多为 256 行"); | |||||
| return false; | |||||
| } | |||||
| total_rows += child_rows; | |||||
| } | |||||
| } | |||||
| if (total_columns > ProjectLimits::kMaximumConditionColumns | |||||
| || total_rows > ProjectLimits::kMaximumLogicRows) | |||||
| { | |||||
| setError(error, "单个网络最多使用 10 列条件、1 列输出和 256 行逻辑网格"); | |||||
| return false; | |||||
| } | |||||
| *columns = total_columns; | |||||
| *rows = total_rows; | |||||
| return true; | |||||
| } | |||||
| } // namespace | } // namespace | ||||
| TimerAddress::TimerAddress(int index) | |||||
| : index_(index) | |||||
| { | |||||
| } | |||||
| bool WordOperand::validate(std::string *error) const | |||||
| { | |||||
| if (kind == WordOperandKind::Constant) | |||||
| { | |||||
| return true; | |||||
| } | |||||
| if (kind != WordOperandKind::Register) | |||||
| { | |||||
| setError(error, "字操作数使用了不支持的类型"); | |||||
| return false; | |||||
| } | |||||
| if (!address.isValid() || address.area() != RegisterArea::D) | |||||
| { | |||||
| setError(error, "字操作数必须使用有效的 D 区地址"); | |||||
| return false; | |||||
| } | |||||
| return true; | |||||
| } | |||||
| CounterAddress::CounterAddress(int index) | |||||
| : index_(index) | |||||
| { | |||||
| } | |||||
| int CounterAddress::index() const | |||||
| { | |||||
| return index_; | |||||
| } | |||||
| bool CounterAddress::isValid() const | |||||
| { | |||||
| return index_ >= kMinimumIndex && index_ <= kMaximumIndex; | |||||
| } | |||||
| std::string CounterAddress::toString() const | |||||
| { | |||||
| return isValid() ? "C" + std::to_string(index_) : "InvalidCounterAddress"; | |||||
| } | |||||
| bool CounterAddress::operator==(const CounterAddress &other) const | |||||
| { | |||||
| return index_ == other.index_; | |||||
| } | |||||
| bool CounterAddress::operator!=(const CounterAddress &other) const | |||||
| { | |||||
| return !(*this == other); | |||||
| } | |||||
| int TimerAddress::index() const | |||||
| { | |||||
| return index_; | |||||
| } | |||||
| bool TimerAddress::isValid() const | |||||
| { | |||||
| return index_ >= kMinimumIndex && index_ <= kMaximumIndex; | |||||
| } | |||||
| std::string TimerAddress::toString() const | |||||
| { | |||||
| return isValid() ? "T" + std::to_string(index_) : "InvalidTimerAddress"; | |||||
| } | |||||
| bool TimerAddress::operator==(const TimerAddress &other) const | |||||
| { | |||||
| return index_ == other.index_; | |||||
| } | |||||
| bool TimerAddress::operator!=(const TimerAddress &other) const | |||||
| { | |||||
| return !(*this == other); | |||||
| } | |||||
| std::optional<RegisterAddress> registerAddressForLogicNode( | |||||
| const LogicNodeConfig &config) | |||||
| { | |||||
| return std::visit( | |||||
| [](const auto &value) -> std::optional<RegisterAddress> | |||||
| { | |||||
| using Config = std::decay_t<decltype(value)>; | |||||
| if constexpr (std::is_same_v<Config, ContactNodeConfig> | |||||
| || std::is_same_v<Config, EdgeContactNodeConfig> | |||||
| || std::is_same_v<Config, CoilNodeConfig> | |||||
| || std::is_same_v<Config, CompareNodeConfig>) | |||||
| { | |||||
| return value.address; | |||||
| } | |||||
| else if constexpr (std::is_same_v<Config, CounterNodeConfig>) | |||||
| { | |||||
| return value.currentValueAddress; | |||||
| } | |||||
| else if constexpr (std::is_same_v<Config, MoveNodeConfig> | |||||
| || std::is_same_v<Config, ArithmeticNodeConfig>) | |||||
| { | |||||
| return value.destination; | |||||
| } | |||||
| return std::nullopt; | |||||
| }, | |||||
| config); | |||||
| } | |||||
| void collectRegisterAddressesForLogicNode( | |||||
| const LogicNodeConfig &config, | |||||
| std::vector<RegisterAddress> *addresses) | |||||
| { | |||||
| if (addresses == nullptr) | |||||
| { | |||||
| return; | |||||
| } | |||||
| std::visit( | |||||
| [addresses](const auto &value) | |||||
| { | |||||
| using Config = std::decay_t<decltype(value)>; | |||||
| if constexpr (std::is_same_v<Config, ContactNodeConfig> | |||||
| || std::is_same_v<Config, EdgeContactNodeConfig> | |||||
| || std::is_same_v<Config, CoilNodeConfig> | |||||
| || std::is_same_v<Config, CompareNodeConfig>) | |||||
| { | |||||
| appendValidAddress(addresses, value.address); | |||||
| } | |||||
| else if constexpr (std::is_same_v<Config, CounterNodeConfig>) | |||||
| { | |||||
| appendValidAddress(addresses, value.currentValueAddress); | |||||
| appendValidAddress(addresses, value.resetAddress); | |||||
| if (value.preset.kind == WordOperandKind::Register) | |||||
| { | |||||
| appendValidAddress(addresses, value.preset.address); | |||||
| } | |||||
| } | |||||
| else if constexpr (std::is_same_v<Config, MoveNodeConfig>) | |||||
| { | |||||
| appendValidAddress(addresses, value.destination); | |||||
| if (value.source.kind == WordOperandKind::Register) | |||||
| { | |||||
| appendValidAddress(addresses, value.source.address); | |||||
| } | |||||
| } | |||||
| else if constexpr (std::is_same_v<Config, ArithmeticNodeConfig>) | |||||
| { | |||||
| appendValidAddress(addresses, value.destination); | |||||
| if (value.left.kind == WordOperandKind::Register) | |||||
| { | |||||
| appendValidAddress(addresses, value.left.address); | |||||
| } | |||||
| if (value.right.kind == WordOperandKind::Register) | |||||
| { | |||||
| appendValidAddress(addresses, value.right.address); | |||||
| } | |||||
| } | |||||
| else if constexpr (std::is_same_v<Config, CounterContactNodeConfig>) | |||||
| { | |||||
| return; | |||||
| } | |||||
| }, | |||||
| config); | |||||
| } | |||||
| bool LogicNode::validate(std::string *error) const | bool LogicNode::validate(std::string *error) const | ||||
| { | { | ||||
| if (id.empty()) | if (id.empty()) | ||||
| @@ -141,6 +589,11 @@ bool LogicNode::validate(std::string *error) const | |||||
| setError(error, "逻辑节点 ID 不能为空"); | setError(error, "逻辑节点 ID 不能为空"); | ||||
| return false; | return false; | ||||
| } | } | ||||
| if (id.size() > ProjectLimits::kMaximumIdBytes) | |||||
| { | |||||
| setError(error, "逻辑节点 ID 不能超过 128 个 UTF-8 字节"); | |||||
| return false; | |||||
| } | |||||
| return std::visit( | return std::visit( | ||||
| [error](const auto &config) { return validateConfig(config, error); }, | [error](const auto &config) { return validateConfig(config, error); }, | ||||
| config); | config); | ||||
| @@ -153,12 +606,20 @@ bool LogicNode::isConfigured() const | |||||
| bool LogicNode::isCondition() const | bool LogicNode::isCondition() const | ||||
| { | { | ||||
| return !std::holds_alternative<CoilNodeConfig>(config); | |||||
| return std::holds_alternative<ContactNodeConfig>(config) | |||||
| || std::holds_alternative<EdgeContactNodeConfig>(config) | |||||
| || std::holds_alternative<TimerContactNodeConfig>(config) | |||||
| || std::holds_alternative<CounterContactNodeConfig>(config) | |||||
| || std::holds_alternative<CompareNodeConfig>(config); | |||||
| } | } | ||||
| bool LogicNode::isOutput() const | bool LogicNode::isOutput() const | ||||
| { | { | ||||
| return std::holds_alternative<CoilNodeConfig>(config); | |||||
| return std::holds_alternative<CoilNodeConfig>(config) | |||||
| || std::holds_alternative<TonNodeConfig>(config) | |||||
| || std::holds_alternative<CounterNodeConfig>(config) | |||||
| || std::holds_alternative<MoveNodeConfig>(config) | |||||
| || std::holds_alternative<ArithmeticNodeConfig>(config); | |||||
| } | } | ||||
| ConditionExpression ConditionExpression::fromNode(LogicNode logic_node) | ConditionExpression ConditionExpression::fromNode(LogicNode logic_node) | ||||
| @@ -170,42 +631,35 @@ ConditionExpression ConditionExpression::fromNode(LogicNode logic_node) | |||||
| return expression; | return expression; | ||||
| } | } | ||||
| bool ConditionExpression::validate(std::string *error) const | |||||
| bool WireSegment::validate(std::string *error) const | |||||
| { | { | ||||
| if (id.empty()) | |||||
| if (columnSpan < kMinimumColumnSpan || columnSpan > kMaximumColumnSpan) | |||||
| { | { | ||||
| setError(error, "条件表达式 ID 不能为空"); | |||||
| setError(error, "横线跨度必须在 1~10 列范围内"); | |||||
| return false; | return false; | ||||
| } | } | ||||
| if (kind == ConditionExpressionKind::Node) | |||||
| { | |||||
| if (!node.has_value() || !children.empty() || !node->isCondition()) | |||||
| { | |||||
| setError(error, "条件叶节点必须包含一个条件节点且不能包含子表达式"); | |||||
| return false; | |||||
| } | |||||
| return node->validate(error); | |||||
| } | |||||
| if (node.has_value() || children.size() < 2U) | |||||
| { | |||||
| setError(error, "串联和并联表达式至少需要两个子表达式"); | |||||
| return false; | |||||
| } | |||||
| for (const ConditionExpression &child : children) | |||||
| { | |||||
| if (child.kind == kind) | |||||
| { | |||||
| setError(error, "同类型嵌套表达式必须先完成归一化"); | |||||
| return false; | |||||
| } | |||||
| if (!child.validate(error)) | |||||
| { | |||||
| return false; | |||||
| } | |||||
| } | |||||
| return true; | return true; | ||||
| } | } | ||||
| ConditionExpression ConditionExpression::fromWire( | |||||
| std::string expression_id, int column_span) | |||||
| { | |||||
| ConditionExpression expression; | |||||
| expression.id = std::move(expression_id); | |||||
| expression.kind = ConditionExpressionKind::Wire; | |||||
| expression.wire = WireSegment{column_span}; | |||||
| return expression; | |||||
| } | |||||
| bool ConditionExpression::validate(std::string *error) const | |||||
| { | |||||
| std::size_t node_count = 0U; | |||||
| int columns = 0; | |||||
| int rows = 0; | |||||
| return validateConditionExpression( | |||||
| *this, 1U, &node_count, &columns, &rows, error); | |||||
| } | |||||
| bool ConditionExpression::validateForRunning(std::string *error) const | bool ConditionExpression::validateForRunning(std::string *error) const | ||||
| { | { | ||||
| if (!validate(error)) | if (!validate(error)) | ||||
| @@ -221,6 +675,10 @@ bool ConditionExpression::validateForRunning(std::string *error) const | |||||
| } | } | ||||
| return true; | return true; | ||||
| } | } | ||||
| if (kind == ConditionExpressionKind::Wire) | |||||
| { | |||||
| return true; | |||||
| } | |||||
| for (const ConditionExpression &child : children) | for (const ConditionExpression &child : children) | ||||
| { | { | ||||
| if (!child.validateForRunning(error)) | if (!child.validateForRunning(error)) | ||||
| @@ -239,6 +697,7 @@ void normalizeConditionExpression(std::optional<ConditionExpression> *expression | |||||
| } | } | ||||
| normalizeExpression(&expression->value()); | normalizeExpression(&expression->value()); | ||||
| if (expression->value().kind != ConditionExpressionKind::Node | if (expression->value().kind != ConditionExpressionKind::Node | ||||
| && expression->value().kind != ConditionExpressionKind::Wire | |||||
| && expression->value().children.empty()) | && expression->value().children.empty()) | ||||
| { | { | ||||
| expression->reset(); | expression->reset(); | ||||
| @@ -248,6 +707,10 @@ void normalizeConditionExpression(std::optional<ConditionExpression> *expression | |||||
| const LogicNode *findConditionNode( | const LogicNode *findConditionNode( | ||||
| const ConditionExpression &expression, const std::string &node_id) | const ConditionExpression &expression, const std::string &node_id) | ||||
| { | { | ||||
| if (expression.kind == ConditionExpressionKind::Wire) | |||||
| { | |||||
| return nullptr; | |||||
| } | |||||
| if (expression.kind == ConditionExpressionKind::Node) | if (expression.kind == ConditionExpressionKind::Node) | ||||
| { | { | ||||
| return expression.node->id == node_id ? &*expression.node : nullptr; | return expression.node->id == node_id ? &*expression.node : nullptr; | ||||
| @@ -306,6 +769,10 @@ void collectConditionNodes( | |||||
| nodes->push_back(&*expression.node); | nodes->push_back(&*expression.node); | ||||
| return; | return; | ||||
| } | } | ||||
| if (expression.kind == ConditionExpressionKind::Wire) | |||||
| { | |||||
| return; | |||||
| } | |||||
| for (const ConditionExpression &child : expression.children) | for (const ConditionExpression &child : expression.children) | ||||
| { | { | ||||
| collectConditionNodes(child, nodes); | collectConditionNodes(child, nodes); | ||||
| @@ -341,18 +808,18 @@ bool LadderRung::validateForRunning(std::string *error) const | |||||
| { | { | ||||
| return true; | return true; | ||||
| } | } | ||||
| if (!condition.has_value() || !output.has_value()) | |||||
| if (!output.has_value()) | |||||
| { | { | ||||
| setError(error, "未完成的梯形图网络必须同时包含条件和输出线圈"); | |||||
| setError(error, "梯形图网络必须包含输出指令"); | |||||
| return false; | return false; | ||||
| } | } | ||||
| if (!condition->validateForRunning(error)) | |||||
| if (condition.has_value() && !condition->validateForRunning(error)) | |||||
| { | { | ||||
| return false; | return false; | ||||
| } | } | ||||
| if (!output->isConfigured()) | if (!output->isConfigured()) | ||||
| { | { | ||||
| setError(error, "输出线圈 " + output->id + " 尚未配置"); | |||||
| setError(error, "输出节点 " + output->id + " 尚未配置"); | |||||
| return false; | return false; | ||||
| } | } | ||||
| return true; | return true; | ||||
| @@ -365,6 +832,26 @@ bool LadderRung::validateStructure(std::string *error) const | |||||
| setError(error, "梯形图网络 ID 和名称不能为空"); | setError(error, "梯形图网络 ID 和名称不能为空"); | ||||
| return false; | return false; | ||||
| } | } | ||||
| if (id.size() > ProjectLimits::kMaximumIdBytes) | |||||
| { | |||||
| setError(error, "梯形图网络 ID 不能超过 128 个 UTF-8 字节"); | |||||
| return false; | |||||
| } | |||||
| if (name.size() > ProjectLimits::kMaximumTextBytes) | |||||
| { | |||||
| setError(error, "梯形图网络名称不能超过 4096 个 UTF-8 字节"); | |||||
| return false; | |||||
| } | |||||
| if (containsLineBreak(comment)) | |||||
| { | |||||
| setError(error, "梯形图网络注释只能使用单行文本"); | |||||
| return false; | |||||
| } | |||||
| if (comment.size() > ProjectLimits::kMaximumRungCommentBytes) | |||||
| { | |||||
| setError(error, "梯形图网络注释不能超过 128 个 UTF-8 字节"); | |||||
| return false; | |||||
| } | |||||
| std::vector<std::string> node_ids; | std::vector<std::string> node_ids; | ||||
| if (condition.has_value()) | if (condition.has_value()) | ||||
| { | { | ||||
| @@ -383,7 +870,7 @@ bool LadderRung::validateStructure(std::string *error) const | |||||
| { | { | ||||
| if (!output->validate(error) || !output->isOutput()) | if (!output->validate(error) || !output->isOutput()) | ||||
| { | { | ||||
| setError(error, "梯形图网络输出必须是线圈节点"); | |||||
| setError(error, "梯形图网络输出必须是有效的输出指令"); | |||||
| return false; | return false; | ||||
| } | } | ||||
| node_ids.push_back(output->id); | node_ids.push_back(output->id); | ||||
| @@ -420,6 +907,21 @@ bool ControlLogic::validateStructure(std::string *error) const | |||||
| setError(error, "控制逻辑 ID 和名称不能为空"); | setError(error, "控制逻辑 ID 和名称不能为空"); | ||||
| return false; | return false; | ||||
| } | } | ||||
| if (id.size() > ProjectLimits::kMaximumIdBytes) | |||||
| { | |||||
| setError(error, "控制逻辑 ID 不能超过 128 个 UTF-8 字节"); | |||||
| return false; | |||||
| } | |||||
| if (name.size() > ProjectLimits::kMaximumTextBytes) | |||||
| { | |||||
| setError(error, "控制逻辑名称不能超过 4096 个 UTF-8 字节"); | |||||
| return false; | |||||
| } | |||||
| if (rungs.size() > ProjectLimits::kMaximumRungsPerLogic) | |||||
| { | |||||
| setError(error, "单组控制逻辑最多包含 1024 个网络"); | |||||
| return false; | |||||
| } | |||||
| if (hasDuplicateId(rungs)) | if (hasDuplicateId(rungs)) | ||||
| { | { | ||||
| setError(error, "同一控制逻辑内的网络 ID 必须唯一"); | setError(error, "同一控制逻辑内的网络 ID 必须唯一"); | ||||
| @@ -479,3 +981,148 @@ bool ControlLogic::validateForRunning(std::string *error) const | |||||
| } | } | ||||
| return true; | return true; | ||||
| } | } | ||||
| bool validateTimerReferencesForRunning( | |||||
| const std::vector<ControlLogic> &logics, | |||||
| std::string *error) | |||||
| { | |||||
| std::map<int, std::string> timer_outputs; | |||||
| for (const ControlLogic &logic : logics) | |||||
| { | |||||
| if (!logic.enabled) | |||||
| { | |||||
| continue; | |||||
| } | |||||
| for (const LadderRung &rung : logic.rungs) | |||||
| { | |||||
| if (!rung.output.has_value()) | |||||
| { | |||||
| continue; | |||||
| } | |||||
| const auto *ton = std::get_if<TonNodeConfig>(&rung.output->config); | |||||
| if (ton == nullptr) | |||||
| { | |||||
| continue; | |||||
| } | |||||
| const auto inserted = timer_outputs.emplace( | |||||
| ton->address.index(), rung.output->id); | |||||
| if (!inserted.second) | |||||
| { | |||||
| setError( | |||||
| error, | |||||
| "定时器 " + ton->address.toString() | |||||
| + " 只能由一个已启用 TON 指令驱动"); | |||||
| return false; | |||||
| } | |||||
| } | |||||
| } | |||||
| for (const ControlLogic &logic : logics) | |||||
| { | |||||
| if (!logic.enabled) | |||||
| { | |||||
| continue; | |||||
| } | |||||
| for (const LadderRung &rung : logic.rungs) | |||||
| { | |||||
| if (!rung.condition.has_value()) | |||||
| { | |||||
| continue; | |||||
| } | |||||
| std::vector<const LogicNode *> nodes; | |||||
| collectConditionNodes(*rung.condition, &nodes); | |||||
| for (const LogicNode *node : nodes) | |||||
| { | |||||
| const auto *contact = std::get_if<TimerContactNodeConfig>( | |||||
| &node->config); | |||||
| if (contact != nullptr | |||||
| && timer_outputs.count(contact->address.index()) == 0U) | |||||
| { | |||||
| setError( | |||||
| error, | |||||
| "定时器触点 " + contact->address.toString() | |||||
| + " 没有对应的已启用 TON 指令"); | |||||
| return false; | |||||
| } | |||||
| } | |||||
| } | |||||
| } | |||||
| return true; | |||||
| } | |||||
| bool validateCounterReferencesForRunning( | |||||
| const std::vector<ControlLogic> &logics, | |||||
| std::string *error) | |||||
| { | |||||
| std::map<int, std::string> counter_outputs; | |||||
| for (const ControlLogic &logic : logics) | |||||
| { | |||||
| if (!logic.enabled) | |||||
| { | |||||
| continue; | |||||
| } | |||||
| for (const LadderRung &rung : logic.rungs) | |||||
| { | |||||
| if (!rung.output.has_value()) | |||||
| { | |||||
| continue; | |||||
| } | |||||
| const auto *counter = std::get_if<CounterNodeConfig>( | |||||
| &rung.output->config); | |||||
| if (counter == nullptr) | |||||
| { | |||||
| continue; | |||||
| } | |||||
| const auto inserted = counter_outputs.emplace( | |||||
| counter->address.index(), rung.output->id); | |||||
| if (!inserted.second) | |||||
| { | |||||
| setError( | |||||
| error, | |||||
| "计数器 " + counter->address.toString() | |||||
| + " 只能由一个已启用计数指令驱动"); | |||||
| return false; | |||||
| } | |||||
| } | |||||
| } | |||||
| for (const ControlLogic &logic : logics) | |||||
| { | |||||
| if (!logic.enabled) | |||||
| { | |||||
| continue; | |||||
| } | |||||
| for (const LadderRung &rung : logic.rungs) | |||||
| { | |||||
| if (!rung.condition.has_value()) | |||||
| { | |||||
| continue; | |||||
| } | |||||
| std::vector<const LogicNode *> nodes; | |||||
| collectConditionNodes(*rung.condition, &nodes); | |||||
| for (const LogicNode *node : nodes) | |||||
| { | |||||
| const auto *contact = std::get_if<CounterContactNodeConfig>( | |||||
| &node->config); | |||||
| if (contact != nullptr | |||||
| && counter_outputs.count(contact->address.index()) == 0U) | |||||
| { | |||||
| setError( | |||||
| error, | |||||
| "计数器触点 " + contact->address.toString() | |||||
| + " 没有对应的已启用计数指令"); | |||||
| return false; | |||||
| } | |||||
| } | |||||
| } | |||||
| } | |||||
| return true; | |||||
| } | |||||
| bool validateLogicResourceReferencesForRunning( | |||||
| const std::vector<ControlLogic> &logics, | |||||
| std::string *error) | |||||
| { | |||||
| return validateTimerReferencesForRunning(logics, error) | |||||
| && validateCounterReferencesForRunning(logics, error); | |||||
| } | |||||
| @@ -1,5 +1,6 @@ | |||||
| #pragma once | #pragma once | ||||
| #include "project_limits.h" | |||||
| #include "register_address.h" | #include "register_address.h" | ||||
| #include <cstdint> | #include <cstdint> | ||||
| @@ -21,6 +22,12 @@ enum class CoilMode | |||||
| Reset | Reset | ||||
| }; | }; | ||||
| enum class EdgeMode | |||||
| { | |||||
| Rising, | |||||
| Falling | |||||
| }; | |||||
| enum class ComparisonOperator | enum class ComparisonOperator | ||||
| { | { | ||||
| Equal, | Equal, | ||||
| @@ -31,12 +38,33 @@ enum class ComparisonOperator | |||||
| GreaterThanOrEqual | GreaterThanOrEqual | ||||
| }; | }; | ||||
| enum class WordOperandKind | |||||
| { | |||||
| Constant, | |||||
| Register | |||||
| }; | |||||
| struct WordOperand | |||||
| { | |||||
| WordOperandKind kind = WordOperandKind::Constant; | |||||
| RegisterAddress address{RegisterArea::D, 0}; | |||||
| std::int16_t constant = 0; | |||||
| bool validate(std::string *error = nullptr) const; | |||||
| }; | |||||
| struct ContactNodeConfig | struct ContactNodeConfig | ||||
| { | { | ||||
| RegisterAddress address{RegisterArea::M, 0}; | RegisterAddress address{RegisterArea::M, 0}; | ||||
| ContactMode mode = ContactMode::NormallyOpen; | ContactMode mode = ContactMode::NormallyOpen; | ||||
| }; | }; | ||||
| struct EdgeContactNodeConfig | |||||
| { | |||||
| RegisterAddress address{RegisterArea::M, 0}; | |||||
| EdgeMode mode = EdgeMode::Rising; | |||||
| }; | |||||
| struct CoilNodeConfig | struct CoilNodeConfig | ||||
| { | { | ||||
| RegisterAddress address{RegisterArea::M, 0}; | RegisterAddress address{RegisterArea::M, 0}; | ||||
| @@ -50,7 +78,117 @@ struct CompareNodeConfig | |||||
| std::int16_t value = 0; | std::int16_t value = 0; | ||||
| }; | }; | ||||
| using LogicNodeConfig = std::variant<ContactNodeConfig, CoilNodeConfig, CompareNodeConfig>; | |||||
| class TimerAddress | |||||
| { | |||||
| public: | |||||
| static constexpr int kMinimumIndex = 0; | |||||
| static constexpr int kMaximumIndex = 4000; | |||||
| explicit TimerAddress(int index = 0); | |||||
| int index() const; | |||||
| bool isValid() const; | |||||
| std::string toString() const; | |||||
| bool operator==(const TimerAddress &other) const; | |||||
| bool operator!=(const TimerAddress &other) const; | |||||
| private: | |||||
| int index_ = 0; | |||||
| }; | |||||
| struct TimerContactNodeConfig | |||||
| { | |||||
| TimerAddress address; | |||||
| ContactMode mode = ContactMode::NormallyOpen; | |||||
| }; | |||||
| class CounterAddress | |||||
| { | |||||
| public: | |||||
| static constexpr int kMinimumIndex = 0; | |||||
| static constexpr int kMaximumIndex = 4000; | |||||
| explicit CounterAddress(int index = 0); | |||||
| int index() const; | |||||
| bool isValid() const; | |||||
| std::string toString() const; | |||||
| bool operator==(const CounterAddress &other) const; | |||||
| bool operator!=(const CounterAddress &other) const; | |||||
| private: | |||||
| int index_ = 0; | |||||
| }; | |||||
| enum class CounterMode | |||||
| { | |||||
| Up, | |||||
| Down | |||||
| }; | |||||
| struct CounterContactNodeConfig | |||||
| { | |||||
| CounterAddress address; | |||||
| ContactMode mode = ContactMode::NormallyOpen; | |||||
| }; | |||||
| struct TonNodeConfig | |||||
| { | |||||
| static constexpr int kMinimumPresetMs = 1; | |||||
| static constexpr int kMaximumPresetMs = 86400000; | |||||
| TimerAddress address; | |||||
| int presetMs = 1000; | |||||
| }; | |||||
| struct CounterNodeConfig | |||||
| { | |||||
| CounterAddress address; | |||||
| CounterMode mode = CounterMode::Up; | |||||
| RegisterAddress currentValueAddress{RegisterArea::D, 0}; | |||||
| WordOperand preset; | |||||
| RegisterAddress resetAddress{RegisterArea::M, 0}; | |||||
| }; | |||||
| struct MoveNodeConfig | |||||
| { | |||||
| WordOperand source; | |||||
| RegisterAddress destination{RegisterArea::D, 0}; | |||||
| }; | |||||
| enum class ArithmeticOperation | |||||
| { | |||||
| Add, | |||||
| Subtract | |||||
| }; | |||||
| struct ArithmeticNodeConfig | |||||
| { | |||||
| ArithmeticOperation operation = ArithmeticOperation::Add; | |||||
| WordOperand left; | |||||
| WordOperand right; | |||||
| RegisterAddress destination{RegisterArea::D, 0}; | |||||
| }; | |||||
| using LogicNodeConfig = std::variant< | |||||
| ContactNodeConfig, | |||||
| EdgeContactNodeConfig, | |||||
| TimerContactNodeConfig, | |||||
| CounterContactNodeConfig, | |||||
| CoilNodeConfig, | |||||
| CompareNodeConfig, | |||||
| TonNodeConfig, | |||||
| CounterNodeConfig, | |||||
| MoveNodeConfig, | |||||
| ArithmeticNodeConfig>; | |||||
| std::optional<RegisterAddress> registerAddressForLogicNode( | |||||
| const LogicNodeConfig &config); | |||||
| void collectRegisterAddressesForLogicNode( | |||||
| const LogicNodeConfig &config, | |||||
| std::vector<RegisterAddress> *addresses); | |||||
| struct LogicNode | struct LogicNode | ||||
| { | { | ||||
| @@ -67,19 +205,33 @@ struct LogicNode | |||||
| enum class ConditionExpressionKind | enum class ConditionExpressionKind | ||||
| { | { | ||||
| Node, | Node, | ||||
| Wire, | |||||
| Series, | Series, | ||||
| Parallel | Parallel | ||||
| }; | }; | ||||
| struct WireSegment | |||||
| { | |||||
| static constexpr int kMinimumColumnSpan = 1; | |||||
| static constexpr int kMaximumColumnSpan = | |||||
| ProjectLimits::kMaximumConditionColumns; | |||||
| int columnSpan = 1; | |||||
| bool validate(std::string *error = nullptr) const; | |||||
| }; | |||||
| // 结构化表达式只允许合法的串并联拓扑,不保存可产生悬空线或环路的像素连接 | // 结构化表达式只允许合法的串并联拓扑,不保存可产生悬空线或环路的像素连接 | ||||
| struct ConditionExpression | struct ConditionExpression | ||||
| { | { | ||||
| std::string id; | std::string id; | ||||
| ConditionExpressionKind kind = ConditionExpressionKind::Node; | ConditionExpressionKind kind = ConditionExpressionKind::Node; | ||||
| std::optional<LogicNode> node; | std::optional<LogicNode> node; | ||||
| std::optional<WireSegment> wire; | |||||
| std::vector<ConditionExpression> children; | std::vector<ConditionExpression> children; | ||||
| static ConditionExpression fromNode(LogicNode node); | static ConditionExpression fromNode(LogicNode node); | ||||
| static ConditionExpression fromWire(std::string id, int column_span = 1); | |||||
| bool validate(std::string *error = nullptr) const; | bool validate(std::string *error = nullptr) const; | ||||
| bool validateForRunning(std::string *error = nullptr) const; | bool validateForRunning(std::string *error = nullptr) const; | ||||
| }; | }; | ||||
| @@ -99,11 +251,12 @@ void collectConditionNodes( | |||||
| void collectConditionExpressionIds( | void collectConditionExpressionIds( | ||||
| const ConditionExpression &expression, std::vector<std::string> *ids); | const ConditionExpression &expression, std::vector<std::string> *ids); | ||||
| // 一个网络包含一棵结构化条件表达式,输出线圈固定在最右侧 | |||||
| // 条件表达式为空时表示恒真网络,输出指令固定在最右侧 | |||||
| struct LadderRung | struct LadderRung | ||||
| { | { | ||||
| std::string id; | std::string id; | ||||
| std::string name; | std::string name; | ||||
| std::string comment; | |||||
| std::optional<ConditionExpression> condition; | std::optional<ConditionExpression> condition; | ||||
| std::optional<LogicNode> output; | std::optional<LogicNode> output; | ||||
| @@ -123,3 +276,13 @@ struct ControlLogic | |||||
| bool validateStructure(std::string *error = nullptr) const; | bool validateStructure(std::string *error = nullptr) const; | ||||
| bool validateForRunning(std::string *error = nullptr) const; | bool validateForRunning(std::string *error = nullptr) const; | ||||
| }; | }; | ||||
| bool validateTimerReferencesForRunning( | |||||
| const std::vector<ControlLogic> &logics, | |||||
| std::string *error = nullptr); | |||||
| bool validateCounterReferencesForRunning( | |||||
| const std::vector<ControlLogic> &logics, | |||||
| std::string *error = nullptr); | |||||
| bool validateLogicResourceReferencesForRunning( | |||||
| const std::vector<ControlLogic> &logics, | |||||
| std::string *error = nullptr); | |||||
| @@ -0,0 +1,134 @@ | |||||
| #include "hmi_control_registry.h" | |||||
| #include <algorithm> | |||||
| #include <array> | |||||
| #include <cstddef> | |||||
| namespace { | |||||
| constexpr std::array kControlDescriptors = { | |||||
| HmiControlDescriptor{HmiControlType::Button, | |||||
| "button", | |||||
| "按钮", | |||||
| "button", | |||||
| "按钮", | |||||
| {0, 0, 120, 40}, | |||||
| HmiBindingKind::Bit, | |||||
| HmiRuntimeValueKind::Bit, | |||||
| true}, | |||||
| HmiControlDescriptor{HmiControlType::Indicator, | |||||
| "indicator", | |||||
| "指示灯", | |||||
| "indicator", | |||||
| "指示灯", | |||||
| {0, 0, 64, 64}, | |||||
| HmiBindingKind::Bit, | |||||
| HmiRuntimeValueKind::Bit, | |||||
| true}, | |||||
| HmiControlDescriptor{HmiControlType::NumericDisplay, | |||||
| "numericDisplay", | |||||
| "数值显示", | |||||
| "numeric-display", | |||||
| "数值显示", | |||||
| {0, 0, 120, 40}, | |||||
| HmiBindingKind::Word, | |||||
| HmiRuntimeValueKind::Word, | |||||
| true}, | |||||
| HmiControlDescriptor{HmiControlType::NumericInput, | |||||
| "numericInput", | |||||
| "数值输入", | |||||
| "numeric-input", | |||||
| "数值输入", | |||||
| {0, 0, 120, 40}, | |||||
| HmiBindingKind::Word, | |||||
| HmiRuntimeValueKind::Word, | |||||
| true}, | |||||
| HmiControlDescriptor{HmiControlType::Label, | |||||
| "label", | |||||
| "文本", | |||||
| "label", | |||||
| "文本", | |||||
| {0, 0, 120, 40}, | |||||
| HmiBindingKind::None, | |||||
| HmiRuntimeValueKind::None, | |||||
| false}, | |||||
| HmiControlDescriptor{HmiControlType::PageJump, | |||||
| "pageJump", | |||||
| "页面跳转", | |||||
| "page-jump", | |||||
| "页面跳转", | |||||
| {0, 0, 120, 40}, | |||||
| HmiBindingKind::None, | |||||
| HmiRuntimeValueKind::None, | |||||
| false}, | |||||
| HmiControlDescriptor{HmiControlType::AlarmList, | |||||
| "alarmList", | |||||
| "报警列表", | |||||
| "alarm-list", | |||||
| "报警信息", | |||||
| {0, 0, 360, 136}, | |||||
| HmiBindingKind::None, | |||||
| HmiRuntimeValueKind::None, | |||||
| false}, | |||||
| HmiControlDescriptor{HmiControlType::ProgressBar, | |||||
| "progressBar", | |||||
| "进度条", | |||||
| "progress-bar", | |||||
| "进度", | |||||
| {0, 0, 240, 36}, | |||||
| HmiBindingKind::Word, | |||||
| HmiRuntimeValueKind::Word, | |||||
| true} | |||||
| }; | |||||
| static_assert( | |||||
| kControlDescriptors.size() | |||||
| == static_cast<std::size_t>(HmiControlType::Count), | |||||
| "每个 HMI 控件类型都必须注册描述"); | |||||
| } // namespace | |||||
| const HmiControlDescriptor *findHmiControlDescriptor(HmiControlType type) | |||||
| { | |||||
| const auto descriptor = std::find_if( | |||||
| kControlDescriptors.cbegin(), | |||||
| kControlDescriptors.cend(), | |||||
| [type](const HmiControlDescriptor &candidate) | |||||
| { | |||||
| return candidate.type == type; | |||||
| }); | |||||
| return descriptor == kControlDescriptors.cend() ? nullptr : &*descriptor; | |||||
| } | |||||
| const HmiControlDescriptor *findHmiControlDescriptor( | |||||
| std::string_view storage_name) | |||||
| { | |||||
| const auto descriptor = std::find_if( | |||||
| kControlDescriptors.cbegin(), | |||||
| kControlDescriptors.cend(), | |||||
| [storage_name](const HmiControlDescriptor &candidate) | |||||
| { | |||||
| return storage_name == candidate.storageName; | |||||
| }); | |||||
| return descriptor == kControlDescriptors.cend() ? nullptr : &*descriptor; | |||||
| } | |||||
| std::optional<RegisterArea> hmiBindingArea(HmiBindingKind binding_kind) | |||||
| { | |||||
| switch (binding_kind) | |||||
| { | |||||
| case HmiBindingKind::Bit: | |||||
| { | |||||
| return RegisterArea::M; | |||||
| } | |||||
| case HmiBindingKind::Word: | |||||
| { | |||||
| return RegisterArea::D; | |||||
| } | |||||
| case HmiBindingKind::None: | |||||
| default: | |||||
| { | |||||
| return std::nullopt; | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,68 @@ | |||||
| /** | |||||
| * @file hmi_control_registry.h | |||||
| * @brief 定义 HMI 控件公共描述及查询接口 | |||||
| * @author suyu | |||||
| * @date 2026-08-17 | |||||
| */ | |||||
| #pragma once | |||||
| #include "hmi_model.h" | |||||
| #include <optional> | |||||
| #include <string_view> | |||||
| /** | |||||
| * @brief 控件允许绑定的寄存器数据类型 | |||||
| */ | |||||
| enum class HmiBindingKind | |||||
| { | |||||
| None, | |||||
| Bit, | |||||
| Word | |||||
| }; | |||||
| /** | |||||
| * @brief 控件运行态读取的值类型 | |||||
| */ | |||||
| enum class HmiRuntimeValueKind | |||||
| { | |||||
| None, | |||||
| Bit, | |||||
| Word | |||||
| }; | |||||
| /** | |||||
| * @brief 保存一种 HMI 控件的稳定名称、默认值和寄存器能力 | |||||
| */ | |||||
| struct HmiControlDescriptor | |||||
| { | |||||
| HmiControlType type; | |||||
| const char *storageName; | |||||
| const char *displayName; | |||||
| const char *idPrefix; | |||||
| const char *defaultText; | |||||
| HmiRect defaultBounds; | |||||
| HmiBindingKind bindingKind; | |||||
| HmiRuntimeValueKind runtimeValueKind; | |||||
| bool requiresBindingForRunning; | |||||
| }; | |||||
| /** | |||||
| * @brief 按领域类型查找控件描述 | |||||
| * @return 找到时返回描述指针,不支持的类型返回 nullptr | |||||
| */ | |||||
| const HmiControlDescriptor *findHmiControlDescriptor(HmiControlType type); | |||||
| /** | |||||
| * @brief 按工程文件中的稳定类型名查找控件描述 | |||||
| * @return 找到时返回描述指针,未知名称返回 nullptr | |||||
| */ | |||||
| const HmiControlDescriptor *findHmiControlDescriptor( | |||||
| std::string_view storage_name); | |||||
| /** | |||||
| * @brief 返回绑定类型要求的寄存器区域 | |||||
| * @return 不支持寄存器绑定时返回空值 | |||||
| */ | |||||
| std::optional<RegisterArea> hmiBindingArea(HmiBindingKind binding_kind); | |||||
| @@ -1,6 +1,11 @@ | |||||
| #include "hmi_model.h" | #include "hmi_model.h" | ||||
| #include "hmi_control_registry.h" | |||||
| #include "project_limits.h" | |||||
| #include <algorithm> | #include <algorithm> | ||||
| #include <cctype> | |||||
| #include <charconv> | |||||
| namespace { | namespace { | ||||
| @@ -13,43 +18,98 @@ void setError(std::string *error, const std::string &message) | |||||
| } | } | ||||
| } | } | ||||
| // 判断控件是否必须绑定 M 区位地址 | |||||
| bool requiresBitBinding(HmiControlType type) | |||||
| bool isHexDigit(char value) | |||||
| { | { | ||||
| return type == HmiControlType::Button || type == HmiControlType::Indicator; | |||||
| return std::isxdigit(static_cast<unsigned char>(value)) != 0; | |||||
| } | } | ||||
| // 判断控件是否必须绑定 D 区字地址 | |||||
| bool requiresWordBinding(HmiControlType type) | |||||
| bool isValidTextColor(const std::string &value) | |||||
| { | { | ||||
| return type == HmiControlType::NumericDisplay || type == HmiControlType::NumericInput; | |||||
| return value.size() == 7U | |||||
| && value.front() == '#' | |||||
| && std::all_of(value.cbegin() + 1, value.cend(), isHexDigit); | |||||
| } | } | ||||
| bool supportsRegisterBinding(HmiControlType type) | |||||
| bool parseInteger(const std::string &value, int *result) | |||||
| { | { | ||||
| return requiresBitBinding(type) || requiresWordBinding(type); | |||||
| if (value.empty() || result == nullptr) | |||||
| { | |||||
| return false; | |||||
| } | |||||
| const char *begin = value.data(); | |||||
| const char *end = begin + value.size(); | |||||
| const auto parsed = std::from_chars(begin, end, *result); | |||||
| return parsed.ec == std::errc{} && parsed.ptr == end; | |||||
| } | } | ||||
| bool isSupportedControlType(HmiControlType type) | |||||
| bool isBooleanValue(const std::string &value) | |||||
| { | { | ||||
| switch (type) | |||||
| { | |||||
| case HmiControlType::Button: | |||||
| case HmiControlType::Indicator: | |||||
| case HmiControlType::NumericDisplay: | |||||
| case HmiControlType::NumericInput: | |||||
| case HmiControlType::Label: | |||||
| case HmiControlType::PageJump: | |||||
| return true; | |||||
| return value == "true" || value == "false"; | |||||
| } | |||||
| bool validateAppearanceProperty( | |||||
| const std::string &key, const std::string &value, std::string *error) | |||||
| { | |||||
| if (key == HmiAppearanceProperty::kTextColor) | |||||
| { | |||||
| if (!isValidTextColor(value)) | |||||
| { | |||||
| setError(error, "HMI 字体颜色必须是 #RRGGBB 格式"); | |||||
| return false; | |||||
| } | |||||
| } | |||||
| else if (key == HmiAppearanceProperty::kFontSize) | |||||
| { | |||||
| int point_size = 0; | |||||
| if (!parseInteger(value, &point_size) | |||||
| || point_size < ProjectLimits::kMinimumHmiFontPointSize | |||||
| || point_size > ProjectLimits::kMaximumHmiFontPointSize) | |||||
| { | |||||
| setError( | |||||
| error, | |||||
| "HMI 字号必须在 " | |||||
| + std::to_string(ProjectLimits::kMinimumHmiFontPointSize) | |||||
| + "~" | |||||
| + std::to_string(ProjectLimits::kMaximumHmiFontPointSize) | |||||
| + " 范围内"); | |||||
| return false; | |||||
| } | |||||
| } | |||||
| else if (key == HmiAppearanceProperty::kFontBold | |||||
| || key == HmiAppearanceProperty::kFontItalic) | |||||
| { | |||||
| if (!isBooleanValue(value)) | |||||
| { | |||||
| setError(error, "HMI 粗体和斜体属性必须是 true 或 false"); | |||||
| return false; | |||||
| } | |||||
| } | } | ||||
| return false; | |||||
| return true; | |||||
| } | } | ||||
| } // namespace | } // namespace | ||||
| bool HmiProgressBarConfig::isValid() const | |||||
| { | |||||
| return minimumValue < maximumValue; | |||||
| } | |||||
| int HmiProgressBarConfig::percentageForValue(std::int16_t value) const | |||||
| { | |||||
| if (!isValid()) | |||||
| { | |||||
| return 0; | |||||
| } | |||||
| const int minimum = minimumValue; | |||||
| const int maximum = maximumValue; | |||||
| const int bounded_value = std::clamp(static_cast<int>(value), minimum, maximum); | |||||
| return (bounded_value - minimum) * 100 / (maximum - minimum); | |||||
| } | |||||
| bool HmiControl::validate(std::string *error) const | bool HmiControl::validate(std::string *error) const | ||||
| { | { | ||||
| if (!isSupportedControlType(type)) | |||||
| const HmiControlDescriptor *descriptor = findHmiControlDescriptor(type); | |||||
| if (descriptor == nullptr) | |||||
| { | { | ||||
| setError(error, "不支持的 HMI 控件类型"); | setError(error, "不支持的 HMI 控件类型"); | ||||
| return false; | return false; | ||||
| @@ -59,9 +119,26 @@ bool HmiControl::validate(std::string *error) const | |||||
| setError(error, "HMI 控件 ID 不能为空"); | setError(error, "HMI 控件 ID 不能为空"); | ||||
| return false; | return false; | ||||
| } | } | ||||
| if (bounds.width <= 0 || bounds.height <= 0) | |||||
| if (id.size() > ProjectLimits::kMaximumIdBytes) | |||||
| { | { | ||||
| setError(error, "HMI 控件的宽度和高度必须大于 0"); | |||||
| setError(error, "HMI 控件 ID 不能超过 128 个 UTF-8 字节"); | |||||
| return false; | |||||
| } | |||||
| if (text.size() > ProjectLimits::kMaximumTextBytes) | |||||
| { | |||||
| setError(error, "HMI 控件文本不能超过 4096 个 UTF-8 字节"); | |||||
| return false; | |||||
| } | |||||
| if (bounds.width <= 0 || bounds.height <= 0 | |||||
| || bounds.width > ProjectLimits::kMaximumHmiControlWidth | |||||
| || bounds.height > ProjectLimits::kMaximumHmiControlHeight) | |||||
| { | |||||
| setError(error, "HMI 控件宽度和高度必须在 1~8192 范围内"); | |||||
| return false; | |||||
| } | |||||
| if (properties.size() > ProjectLimits::kMaximumHmiProperties) | |||||
| { | |||||
| setError(error, "单个 HMI 控件最多保存 64 对扩展属性"); | |||||
| return false; | return false; | ||||
| } | } | ||||
| for (const auto &property : properties) | for (const auto &property : properties) | ||||
| @@ -71,31 +148,44 @@ bool HmiControl::validate(std::string *error) const | |||||
| setError(error, "HMI 控件属性名称不能为空"); | setError(error, "HMI 控件属性名称不能为空"); | ||||
| return false; | return false; | ||||
| } | } | ||||
| } | |||||
| if (requiresBitBinding(type)) | |||||
| { | |||||
| if (binding.has_value() && binding->area() != RegisterArea::M) | |||||
| if (property.first.size() > ProjectLimits::kMaximumPropertyKeyBytes) | |||||
| { | { | ||||
| setError(error, "按钮和指示灯必须绑定 M 区地址"); | |||||
| setError(error, "HMI 控件属性名称不能超过 128 个 UTF-8 字节"); | |||||
| return false; | return false; | ||||
| } | } | ||||
| } | |||||
| if (requiresWordBinding(type)) | |||||
| { | |||||
| if (binding.has_value() && binding->area() != RegisterArea::D) | |||||
| if (property.second.size() > ProjectLimits::kMaximumPropertyValueBytes) | |||||
| { | |||||
| setError(error, "HMI 控件属性值不能超过 4096 个 UTF-8 字节"); | |||||
| return false; | |||||
| } | |||||
| if (!validateAppearanceProperty(property.first, property.second, error)) | |||||
| { | { | ||||
| setError(error, "数值控件必须绑定 D 区地址"); | |||||
| return false; | return false; | ||||
| } | } | ||||
| } | } | ||||
| if (binding.has_value() && !binding->isValid()) | |||||
| const std::optional<RegisterArea> binding_area = | |||||
| hmiBindingArea(descriptor->bindingKind); | |||||
| if (binding.has_value() && !binding_area.has_value()) | |||||
| { | { | ||||
| setError(error, "HMI 控件绑定了无效地址"); | |||||
| setError(error, "该 HMI 控件不能绑定寄存器"); | |||||
| return false; | |||||
| } | |||||
| if (binding.has_value() && binding_area.has_value() | |||||
| && binding->area() != *binding_area) | |||||
| { | |||||
| if (descriptor->bindingKind == HmiBindingKind::Bit) | |||||
| { | |||||
| setError(error, "HMI 位控件必须绑定 M 区地址"); | |||||
| } | |||||
| else | |||||
| { | |||||
| setError(error, "HMI 字控件必须绑定 D 区地址"); | |||||
| } | |||||
| return false; | return false; | ||||
| } | } | ||||
| if (!supportsRegisterBinding(type) && binding.has_value()) | |||||
| if (binding.has_value() && !binding->isValid()) | |||||
| { | { | ||||
| setError(error, "文本和页面跳转控件不能绑定寄存器"); | |||||
| setError(error, "HMI 控件绑定了无效地址"); | |||||
| return false; | return false; | ||||
| } | } | ||||
| if (type == HmiControlType::PageJump) | if (type == HmiControlType::PageJump) | ||||
| @@ -105,18 +195,47 @@ bool HmiControl::validate(std::string *error) const | |||||
| setError(error, "页面跳转控件缺少跳转配置"); | setError(error, "页面跳转控件缺少跳转配置"); | ||||
| return false; | return false; | ||||
| } | } | ||||
| if (pageJump->targetPageId.size() > ProjectLimits::kMaximumIdBytes) | |||||
| { | |||||
| setError(error, "页面跳转目标 ID 不能超过 128 个 UTF-8 字节"); | |||||
| return false; | |||||
| } | |||||
| } | } | ||||
| else if (pageJump.has_value()) | else if (pageJump.has_value()) | ||||
| { | { | ||||
| setError(error, "非页面跳转控件不能包含跳转配置"); | setError(error, "非页面跳转控件不能包含跳转配置"); | ||||
| return false; | return false; | ||||
| } | } | ||||
| if (type == HmiControlType::ProgressBar) | |||||
| { | |||||
| if (!progressBar.has_value() || !progressBar->isValid()) | |||||
| { | |||||
| setError(error, "进度条最小值必须小于最大值"); | |||||
| return false; | |||||
| } | |||||
| } | |||||
| else if (progressBar.has_value()) | |||||
| { | |||||
| setError(error, "非进度条控件不能包含进度条配置"); | |||||
| return false; | |||||
| } | |||||
| return true; | return true; | ||||
| } | } | ||||
| bool HmiControl::isConfigured() const | bool HmiControl::isConfigured() const | ||||
| { | { | ||||
| if (type == HmiControlType::Label) | |||||
| const HmiControlDescriptor *descriptor = findHmiControlDescriptor(type); | |||||
| if (descriptor == nullptr) | |||||
| { | |||||
| return false; | |||||
| } | |||||
| if (type == HmiControlType::ProgressBar | |||||
| && (!progressBar.has_value() || !progressBar->isValid())) | |||||
| { | |||||
| return false; | |||||
| } | |||||
| if (!descriptor->requiresBindingForRunning | |||||
| && type != HmiControlType::PageJump) | |||||
| { | { | ||||
| return true; | return true; | ||||
| } | } | ||||
| @@ -124,15 +243,15 @@ bool HmiControl::isConfigured() const | |||||
| { | { | ||||
| return pageJump.has_value() && !pageJump->targetPageId.empty(); | return pageJump.has_value() && !pageJump->targetPageId.empty(); | ||||
| } | } | ||||
| if (!binding.has_value() || !binding->isValid()) | |||||
| if (!descriptor->requiresBindingForRunning | |||||
| || !binding.has_value() | |||||
| || !binding->isValid()) | |||||
| { | { | ||||
| return false; | return false; | ||||
| } | } | ||||
| if (requiresBitBinding(type)) | |||||
| { | |||||
| return binding->area() == RegisterArea::M; | |||||
| } | |||||
| return !requiresWordBinding(type) || binding->area() == RegisterArea::D; | |||||
| const std::optional<RegisterArea> binding_area = | |||||
| hmiBindingArea(descriptor->bindingKind); | |||||
| return binding_area.has_value() && binding->area() == *binding_area; | |||||
| } | } | ||||
| bool HmiPage::validate(std::string *error) const | bool HmiPage::validate(std::string *error) const | ||||
| @@ -142,9 +261,26 @@ bool HmiPage::validate(std::string *error) const | |||||
| setError(error, "HMI 页面 ID 和名称不能为空"); | setError(error, "HMI 页面 ID 和名称不能为空"); | ||||
| return false; | return false; | ||||
| } | } | ||||
| if (width <= 0 || height <= 0) | |||||
| if (id.size() > ProjectLimits::kMaximumIdBytes) | |||||
| { | |||||
| setError(error, "HMI 页面 ID 不能超过 128 个 UTF-8 字节"); | |||||
| return false; | |||||
| } | |||||
| if (name.size() > ProjectLimits::kMaximumTextBytes) | |||||
| { | |||||
| setError(error, "HMI 页面名称不能超过 4096 个 UTF-8 字节"); | |||||
| return false; | |||||
| } | |||||
| if (width <= 0 || height <= 0 | |||||
| || width > ProjectLimits::kMaximumHmiPageWidth | |||||
| || height > ProjectLimits::kMaximumHmiPageHeight) | |||||
| { | |||||
| setError(error, "HMI 页面宽度和高度必须在 1~8192 范围内"); | |||||
| return false; | |||||
| } | |||||
| if (controls.size() > ProjectLimits::kMaximumHmiControlsPerPage) | |||||
| { | { | ||||
| setError(error, "HMI 页面尺寸必须大于 0"); | |||||
| setError(error, "单个 HMI 页面最多包含 512 个控件"); | |||||
| return false; | return false; | ||||
| } | } | ||||
| @@ -8,12 +8,24 @@ | |||||
| #pragma once | #pragma once | ||||
| #include "register_address.h" | #include "register_address.h" | ||||
| #include "project_limits.h" | |||||
| #include <cstdint> | |||||
| #include <map> | #include <map> | ||||
| #include <optional> | #include <optional> | ||||
| #include <string> | #include <string> | ||||
| #include <vector> | #include <vector> | ||||
| namespace HmiAppearanceProperty | |||||
| { | |||||
| inline constexpr const char kTextColor[] = "textColor"; | |||||
| inline constexpr const char kFontSize[] = "fontSize"; | |||||
| inline constexpr const char kFontBold[] = "fontBold"; | |||||
| inline constexpr const char kFontItalic[] = "fontItalic"; | |||||
| } // namespace HmiAppearanceProperty | |||||
| /** | /** | ||||
| * @brief 描述 HMI 控件在页面坐标系中的位置和尺寸 | * @brief 描述 HMI 控件在页面坐标系中的位置和尺寸 | ||||
| * | * | ||||
| @@ -42,7 +54,10 @@ enum class HmiControlType | |||||
| NumericDisplay, // 数值显示 | NumericDisplay, // 数值显示 | ||||
| NumericInput, // 数值输入 | NumericInput, // 数值输入 | ||||
| Label, // 标签 | Label, // 标签 | ||||
| PageJump // 页面跳转 | |||||
| PageJump, // 页面跳转 | |||||
| AlarmList, // 报警列表 | |||||
| ProgressBar, // 进度条 | |||||
| Count // 已注册控件类型数量,不作为实际控件使用 | |||||
| }; | }; | ||||
| /** | /** | ||||
| @@ -61,6 +76,19 @@ struct HmiPageJumpConfig | |||||
| std::string targetPageId; | std::string targetPageId; | ||||
| }; | }; | ||||
| /** | |||||
| * @brief 进度条的有效值范围和文字显示方式 | |||||
| */ | |||||
| struct HmiProgressBarConfig | |||||
| { | |||||
| std::int16_t minimumValue = 0; | |||||
| std::int16_t maximumValue = 100; | |||||
| bool showValue = true; | |||||
| bool isValid() const; | |||||
| int percentageForValue(std::int16_t value) const; | |||||
| }; | |||||
| /** | /** | ||||
| * @brief 描述一个可保存的 HMI 控件及其显示和寄存器配置 | * @brief 描述一个可保存的 HMI 控件及其显示和寄存器配置 | ||||
| * | * | ||||
| @@ -76,13 +104,14 @@ struct HmiControl | |||||
| std::map<std::string, std::string> properties; | std::map<std::string, std::string> properties; | ||||
| HmiButtonOperation buttonOperation = HmiButtonOperation::MomentaryOn; | HmiButtonOperation buttonOperation = HmiButtonOperation::MomentaryOn; | ||||
| std::optional<HmiPageJumpConfig> pageJump; | std::optional<HmiPageJumpConfig> pageJump; | ||||
| std::optional<HmiProgressBarConfig> progressBar; | |||||
| /** | /** | ||||
| * @brief 校验控件的标识、尺寸、扩展属性和寄存器绑定 | * @brief 校验控件的标识、尺寸、扩展属性和寄存器绑定 | ||||
| * @param error 可选错误输出,失败时写入首个校验原因 | * @param error 可选错误输出,失败时写入首个校验原因 | ||||
| * @return 配置完整且满足控件类型绑定规则时返回 true | * @return 配置完整且满足控件类型绑定规则时返回 true | ||||
| * | * | ||||
| * 按钮和指示灯必须绑定有效 M 地址,数值显示和数值输入必须绑定有效 D 地址 | |||||
| * 位控件必须绑定有效 M 地址,字控件必须绑定有效 D 地址 | |||||
| */ | */ | ||||
| bool validate(std::string *error = nullptr) const; | bool validate(std::string *error = nullptr) const; | ||||
| bool isConfigured() const; | bool isConfigured() const; | ||||
| @@ -97,8 +126,8 @@ struct HmiPage | |||||
| { | { | ||||
| std::string id; | std::string id; | ||||
| std::string name; | std::string name; | ||||
| int width = 800; | |||||
| int height = 480; | |||||
| int width = ProjectLimits::kDefaultHmiPageWidth; | |||||
| int height = ProjectLimits::kDefaultHmiPageHeight; | |||||
| std::vector<HmiControl> controls; | std::vector<HmiControl> controls; | ||||
| /** | /** | ||||
| @@ -0,0 +1,65 @@ | |||||
| #pragma once | |||||
| #include <cstddef> | |||||
| namespace ProjectLimits { | |||||
| constexpr std::size_t kMaximumProjectFileBytes = 16U * 1024U * 1024U; | |||||
| constexpr std::size_t kMaximumHmiPages = 128U; | |||||
| constexpr std::size_t kMaximumHmiControlsPerPage = 512U; | |||||
| constexpr std::size_t kMaximumAlarmDefinitions = 512U; | |||||
| constexpr std::size_t kMaximumRegisterComments = 8002U; | |||||
| constexpr std::size_t kMaximumControlLogics = 128U; | |||||
| constexpr std::size_t kMaximumRungsPerLogic = 1024U; | |||||
| constexpr std::size_t kMaximumExpressionNodesPerRung = 4096U; | |||||
| constexpr std::size_t kMaximumExpressionDepth = 20U; | |||||
| constexpr std::size_t kMaximumExpressionChildren = 256U; | |||||
| constexpr int kMaximumConditionColumns = 10; | |||||
| constexpr int kMaximumLadderColumns = 11; | |||||
| constexpr int kMaximumLogicRows = 256; | |||||
| static_assert(kMaximumLadderColumns == kMaximumConditionColumns + 1); | |||||
| constexpr std::size_t kMaximumHmiProperties = 64U; | |||||
| constexpr std::size_t kMaximumIdBytes = 128U; | |||||
| constexpr std::size_t kMaximumTextBytes = 4096U; | |||||
| constexpr std::size_t kMaximumHmiControlTextCharacters = 12U; | |||||
| constexpr std::size_t kMaximumAlarmTitleCharacters = 12U; | |||||
| constexpr std::size_t kMaximumAlarmMessageCharacters = 20U; | |||||
| constexpr std::size_t kMaximumPropertyKeyBytes = 128U; | |||||
| constexpr std::size_t kMaximumPropertyValueBytes = 4096U; | |||||
| constexpr std::size_t kMaximumRegisterCommentBytes = 64U; | |||||
| constexpr std::size_t kMaximumRungCommentBytes = 128U; | |||||
| constexpr int kMinimumHmiPageWidth = 320; | |||||
| constexpr int kMaximumHmiPageWidth = 1600; | |||||
| constexpr int kMinimumHmiPageHeight = 200; | |||||
| constexpr int kMaximumHmiPageHeight = 800; | |||||
| constexpr int kDefaultHmiPageWidth = 800; | |||||
| constexpr int kDefaultHmiPageHeight = 400; | |||||
| constexpr int kMaximumHmiControlWidth = 8192; | |||||
| constexpr int kMaximumHmiControlHeight = 8192; | |||||
| constexpr int kMinimumHmiFontPointSize = 6; | |||||
| constexpr int kMaximumHmiFontPointSize = 72; | |||||
| constexpr int kAlarmDefaultFontPointReduction = 2; | |||||
| constexpr int kMaximumVisibleAlarmRows = 5; | |||||
| constexpr std::size_t kMaximumPollAddresses = 1024U; | |||||
| constexpr std::size_t kMaximumPollBlocks = 64U; | |||||
| constexpr int kMaximumModbusReadCount = 120; | |||||
| constexpr int kMinimumPlcServerAddress = 1; | |||||
| constexpr int kMaximumPlcServerAddress = 247; | |||||
| constexpr int kMinimumResponseTimeoutMs = 100; | |||||
| constexpr int kMaximumResponseTimeoutMs = 30000; | |||||
| constexpr int kMinimumRetries = 0; | |||||
| constexpr int kMaximumRetries = 5; | |||||
| constexpr int kMinimumPollIntervalMs = 50; | |||||
| constexpr int kMaximumPollIntervalMs = 10000; | |||||
| constexpr std::size_t kMaximumPendingWrites = 1U; | |||||
| constexpr int kMaximumOutputMessages = 1000; | |||||
| } // namespace ProjectLimits | |||||
| @@ -1,6 +1,9 @@ | |||||
| #include "project_model.h" | #include "project_model.h" | ||||
| #include "project_limits.h" | |||||
| #include <algorithm> | #include <algorithm> | ||||
| #include <cctype> | |||||
| namespace { | namespace { | ||||
| @@ -53,8 +56,59 @@ bool containsDuplicateName(const std::vector<TItem> &items) | |||||
| return false; | return false; | ||||
| } | } | ||||
| bool isBlank(const std::string &value) | |||||
| { | |||||
| return value.empty() | |||||
| || std::all_of( | |||||
| value.cbegin(), value.cend(), | |||||
| [](unsigned char character) { return std::isspace(character) != 0; }); | |||||
| } | |||||
| bool containsLineBreak(const std::string &value) | |||||
| { | |||||
| return value.find('\r') != std::string::npos | |||||
| || value.find('\n') != std::string::npos; | |||||
| } | |||||
| } // namespace | } // namespace | ||||
| bool RegisterComment::validate(std::string *error) const | |||||
| { | |||||
| if (!address.isValid()) | |||||
| { | |||||
| setError(error, "软元件注释必须使用有效的 M/D 地址"); | |||||
| return false; | |||||
| } | |||||
| if (isBlank(text)) | |||||
| { | |||||
| setError(error, "软元件注释内容不能为空"); | |||||
| return false; | |||||
| } | |||||
| if (containsLineBreak(text)) | |||||
| { | |||||
| setError(error, "软元件注释只能使用单行文本"); | |||||
| return false; | |||||
| } | |||||
| if (text.size() > ProjectLimits::kMaximumRegisterCommentBytes) | |||||
| { | |||||
| setError(error, "软元件注释不能超过 64 个 UTF-8 字节"); | |||||
| return false; | |||||
| } | |||||
| return true; | |||||
| } | |||||
| const RegisterComment *Project::findRegisterComment( | |||||
| const RegisterAddress &address) const | |||||
| { | |||||
| const auto comment = std::find_if( | |||||
| registerComments.cbegin(), registerComments.cend(), | |||||
| [&address](const RegisterComment &candidate) | |||||
| { | |||||
| return candidate.address == address; | |||||
| }); | |||||
| return comment == registerComments.cend() ? nullptr : &*comment; | |||||
| } | |||||
| bool Project::validate(std::string *error) const | bool Project::validate(std::string *error) const | ||||
| { | { | ||||
| if (metadata.id.empty() || metadata.name.empty() || metadata.formatVersion.empty()) | if (metadata.id.empty() || metadata.name.empty() || metadata.formatVersion.empty()) | ||||
| @@ -62,6 +116,42 @@ bool Project::validate(std::string *error) const | |||||
| setError(error, "工程 ID、名称和格式版本不能为空"); | setError(error, "工程 ID、名称和格式版本不能为空"); | ||||
| return false; | return false; | ||||
| } | } | ||||
| if (metadata.id.size() > ProjectLimits::kMaximumIdBytes | |||||
| || metadata.formatVersion.size() > ProjectLimits::kMaximumIdBytes) | |||||
| { | |||||
| setError(error, "工程 ID 和格式版本不能超过 128 个 UTF-8 字节"); | |||||
| return false; | |||||
| } | |||||
| if (metadata.name.size() > ProjectLimits::kMaximumTextBytes) | |||||
| { | |||||
| setError(error, "工程名称不能超过 4096 个 UTF-8 字节"); | |||||
| return false; | |||||
| } | |||||
| if (initialHmiPageId.size() > ProjectLimits::kMaximumIdBytes) | |||||
| { | |||||
| setError(error, "初始 HMI 页面 ID 不能超过 128 个 UTF-8 字节"); | |||||
| return false; | |||||
| } | |||||
| if (hmiPages.size() > ProjectLimits::kMaximumHmiPages) | |||||
| { | |||||
| setError(error, "单个工程最多包含 128 个 HMI 页面"); | |||||
| return false; | |||||
| } | |||||
| if (alarmDefinitions.size() > ProjectLimits::kMaximumAlarmDefinitions) | |||||
| { | |||||
| setError(error, "单个工程最多包含 512 条报警定义"); | |||||
| return false; | |||||
| } | |||||
| if (registerComments.size() > ProjectLimits::kMaximumRegisterComments) | |||||
| { | |||||
| setError(error, "单个工程最多包含 8002 条软元件注释"); | |||||
| return false; | |||||
| } | |||||
| if (controlLogics.size() > ProjectLimits::kMaximumControlLogics) | |||||
| { | |||||
| setError(error, "单个工程最多包含 128 组控制逻辑"); | |||||
| return false; | |||||
| } | |||||
| if (containsDuplicateId(hmiPages)) | if (containsDuplicateId(hmiPages)) | ||||
| { | { | ||||
| setError(error, "工程内的 HMI 页面 ID 必须唯一"); | setError(error, "工程内的 HMI 页面 ID 必须唯一"); | ||||
| @@ -101,6 +191,37 @@ bool Project::validate(std::string *error) const | |||||
| setError(error, "工程内的控制逻辑名称必须唯一"); | setError(error, "工程内的控制逻辑名称必须唯一"); | ||||
| return false; | return false; | ||||
| } | } | ||||
| if (containsDuplicateId(alarmDefinitions)) | |||||
| { | |||||
| setError(error, "工程内的报警定义 ID 必须唯一"); | |||||
| return false; | |||||
| } | |||||
| for (const AlarmDefinition &definition : alarmDefinitions) | |||||
| { | |||||
| if (!definition.validate(error)) | |||||
| { | |||||
| return false; | |||||
| } | |||||
| } | |||||
| for (auto current = registerComments.cbegin(); current != registerComments.cend(); ++current) | |||||
| { | |||||
| if (!current->validate(error)) | |||||
| { | |||||
| return false; | |||||
| } | |||||
| const auto duplicate = std::find_if( | |||||
| current + 1, | |||||
| registerComments.cend(), | |||||
| [¤t](const RegisterComment &candidate) | |||||
| { | |||||
| return candidate.address == current->address; | |||||
| }); | |||||
| if (duplicate != registerComments.cend()) | |||||
| { | |||||
| setError(error, "同一 M/D 地址只能保存一条软元件注释"); | |||||
| return false; | |||||
| } | |||||
| } | |||||
| for (const HmiPage &page : hmiPages) | for (const HmiPage &page : hmiPages) | ||||
| { | { | ||||
| // 工程聚合校验会向下委托页面和控件的完整规则 | // 工程聚合校验会向下委托页面和控件的完整规则 | ||||
| @@ -136,6 +257,57 @@ bool Project::validate(std::string *error) const | |||||
| return false; | return false; | ||||
| } | } | ||||
| } | } | ||||
| std::vector<RegisterAddress> poll_addresses; | |||||
| for (const HmiPage &page : hmiPages) | |||||
| { | |||||
| for (const HmiControl &control : page.controls) | |||||
| { | |||||
| if (control.binding.has_value()) | |||||
| { | |||||
| poll_addresses.push_back(*control.binding); | |||||
| } | |||||
| } | |||||
| } | |||||
| for (const AlarmDefinition &definition : alarmDefinitions) | |||||
| { | |||||
| poll_addresses.push_back(definition.address); | |||||
| } | |||||
| for (const ControlLogic &logic : controlLogics) | |||||
| { | |||||
| for (const LadderRung &rung : logic.rungs) | |||||
| { | |||||
| std::vector<const LogicNode *> nodes; | |||||
| if (rung.condition.has_value()) | |||||
| { | |||||
| collectConditionNodes(*rung.condition, &nodes); | |||||
| } | |||||
| if (rung.output.has_value()) | |||||
| { | |||||
| nodes.push_back(&*rung.output); | |||||
| } | |||||
| for (const LogicNode *node : nodes) | |||||
| { | |||||
| collectRegisterAddressesForLogicNode(node->config, &poll_addresses); | |||||
| } | |||||
| } | |||||
| } | |||||
| std::sort( | |||||
| poll_addresses.begin(), poll_addresses.end(), | |||||
| [](const RegisterAddress &left, const RegisterAddress &right) | |||||
| { | |||||
| return left.area() == right.area() | |||||
| ? left.index() < right.index() | |||||
| : left.area() == RegisterArea::M; | |||||
| }); | |||||
| poll_addresses.erase( | |||||
| std::unique(poll_addresses.begin(), poll_addresses.end()), | |||||
| poll_addresses.end()); | |||||
| if (poll_addresses.size() > ProjectLimits::kMaximumPollAddresses) | |||||
| { | |||||
| setError(error, "工程中需要 PLC 轮询的去重 M/D 地址最多为 1024 个"); | |||||
| return false; | |||||
| } | |||||
| return true; | return true; | ||||
| } | } | ||||
| @@ -159,5 +331,9 @@ bool Project::validateForRunning(std::string *error) const | |||||
| return false; | return false; | ||||
| } | } | ||||
| } | } | ||||
| if (!validateLogicResourceReferencesForRunning(controlLogics, error)) | |||||
| { | |||||
| return false; | |||||
| } | |||||
| return true; | return true; | ||||
| } | } | ||||
| @@ -1,11 +1,20 @@ | |||||
| #pragma once | #pragma once | ||||
| #include "alarm_model.h" | |||||
| #include "control_logic_model.h" | #include "control_logic_model.h" | ||||
| #include "hmi_model.h" | #include "hmi_model.h" | ||||
| #include <string> | #include <string> | ||||
| #include <vector> | #include <vector> | ||||
| struct RegisterComment | |||||
| { | |||||
| RegisterAddress address{RegisterArea::M, 0}; | |||||
| std::string text; | |||||
| bool validate(std::string *error = nullptr) const; | |||||
| }; | |||||
| // 描述工程的基本身份和文件格式信息 | // 描述工程的基本身份和文件格式信息 | ||||
| struct ProjectMetadata | struct ProjectMetadata | ||||
| { | { | ||||
| @@ -26,9 +35,14 @@ struct Project | |||||
| std::vector<HmiPage> hmiPages; | std::vector<HmiPage> hmiPages; | ||||
| // 进入运行态时显示的初始 HMI 页面 | // 进入运行态时显示的初始 HMI 页面 | ||||
| std::string initialHmiPageId; | std::string initialHmiPageId; | ||||
| // 工程统一管理的报警定义 | |||||
| std::vector<AlarmDefinition> alarmDefinitions; | |||||
| // 工程级 M/D 地址注释,不包含当前值和引用位置 | |||||
| std::vector<RegisterComment> registerComments; | |||||
| // 工程包含的控制逻辑集合 | // 工程包含的控制逻辑集合 | ||||
| std::vector<ControlLogic> controlLogics; | std::vector<ControlLogic> controlLogics; | ||||
| const RegisterComment *findRegisterComment(const RegisterAddress &address) const; | |||||
| // 校验工程配置并通过 error 返回失败原因 | // 校验工程配置并通过 error 返回失败原因 | ||||
| bool validate(std::string *error = nullptr) const; | bool validate(std::string *error = nullptr) const; | ||||
| bool validateForRunning(std::string *error = nullptr) const; | bool validateForRunning(std::string *error = nullptr) const; | ||||
| @@ -15,7 +15,6 @@ | |||||
| namespace { | namespace { | ||||
| constexpr int kMaximumReadCount = 120; | |||||
| constexpr int kRecoveryProbeIntervalMs = 2000; | constexpr int kRecoveryProbeIntervalMs = 2000; | ||||
| std::string toUtf8(const QString &value) | std::string toUtf8(const QString &value) | ||||
| @@ -42,6 +41,67 @@ bool isReadingState(PlcConnectionState state) | |||||
| || state == PlcConnectionState::Recovering; | || state == PlcConnectionState::Recovering; | ||||
| } | } | ||||
| bool normalizePollAddresses( | |||||
| const std::vector<RegisterAddress> &addresses, | |||||
| std::vector<RegisterAddress> *normalized, | |||||
| std::string *error) | |||||
| { | |||||
| *normalized = addresses; | |||||
| if (std::any_of( | |||||
| normalized->cbegin(), normalized->cend(), | |||||
| [](const RegisterAddress &address) { return !address.isValid(); })) | |||||
| { | |||||
| *error = "PLC 轮询地址中包含无效的 M/D 地址"; | |||||
| return false; | |||||
| } | |||||
| std::sort( | |||||
| normalized->begin(), normalized->end(), | |||||
| [](const RegisterAddress &left, const RegisterAddress &right) | |||||
| { | |||||
| return left.area() == right.area() | |||||
| ? left.index() < right.index() | |||||
| : left.area() == RegisterArea::M; | |||||
| }); | |||||
| normalized->erase( | |||||
| std::unique(normalized->begin(), normalized->end()), normalized->end()); | |||||
| if (normalized->size() > ProjectLimits::kMaximumPollAddresses) | |||||
| { | |||||
| *error = "PLC 轮询的去重 M/D 地址最多为 1024 个"; | |||||
| return false; | |||||
| } | |||||
| return true; | |||||
| } | |||||
| std::size_t pollBlockCount(const std::vector<RegisterAddress> &addresses) | |||||
| { | |||||
| if (addresses.empty()) | |||||
| { | |||||
| return 2U; | |||||
| } | |||||
| std::size_t blocks = 0U; | |||||
| RegisterArea current_area = RegisterArea::M; | |||||
| int start_address = 0; | |||||
| int count = 0; | |||||
| for (const RegisterAddress &address : addresses) | |||||
| { | |||||
| if (count == 0 | |||||
| || address.area() != current_area | |||||
| || address.index() > start_address + count | |||||
| || count >= ProjectLimits::kMaximumModbusReadCount) | |||||
| { | |||||
| ++blocks; | |||||
| current_area = address.area(); | |||||
| start_address = address.index(); | |||||
| count = 1; | |||||
| } | |||||
| else | |||||
| { | |||||
| count = address.index() - start_address + 1; | |||||
| } | |||||
| } | |||||
| return blocks; | |||||
| } | |||||
| } // namespace | } // namespace | ||||
| PlcCommunicationService::PlcCommunicationService( | PlcCommunicationService::PlcCommunicationService( | ||||
| @@ -115,10 +175,11 @@ PlcCommunicationService::~PlcCommunicationService() = default; | |||||
| PlcCommunicationResult PlcCommunicationService::connectDevice( | PlcCommunicationResult PlcCommunicationService::connectDevice( | ||||
| const PlcSerialConfiguration &configuration) | const PlcSerialConfiguration &configuration) | ||||
| { | { | ||||
| if (QString::fromStdString(configuration.portName).trimmed().isEmpty() | |||||
| || configuration.serverAddress < 1 || configuration.serverAddress > 247) | |||||
| const PlcCommunicationResult validation = | |||||
| validatePlcSerialConfiguration(configuration); | |||||
| if (!validation.succeeded) | |||||
| { | { | ||||
| return {false, "必须填写串口端口,并将 PLC 站号设置为 1~247"}; | |||||
| return validation; | |||||
| } | } | ||||
| if (state_ == PlcConnectionState::Disconnected | if (state_ == PlcConnectionState::Disconnected | ||||
| && master_->state() != QModbusDevice::UnconnectedState) | && master_->state() != QModbusDevice::UnconnectedState) | ||||
| @@ -179,16 +240,27 @@ void PlcCommunicationService::disconnectDevice() | |||||
| setState(PlcConnectionState::Disconnected); | setState(PlcConnectionState::Disconnected); | ||||
| } | } | ||||
| void PlcCommunicationService::setPollAddresses( | |||||
| PlcCommunicationResult PlcCommunicationService::setPollAddresses( | |||||
| const std::vector<RegisterAddress> &addresses) | const std::vector<RegisterAddress> &addresses) | ||||
| { | { | ||||
| std::vector<RegisterAddress> normalized; | |||||
| std::string error; | |||||
| if (!normalizePollAddresses(addresses, &normalized, &error)) | |||||
| { | |||||
| return {false, error}; | |||||
| } | |||||
| if (pollBlockCount(normalized) > ProjectLimits::kMaximumPollBlocks) | |||||
| { | |||||
| return {false, "PLC 轮询地址拆分后最多允许 64 个读块"}; | |||||
| } | |||||
| if (pending_reply_ != nullptr) | if (pending_reply_ != nullptr) | ||||
| { | { | ||||
| pending_poll_addresses_ = addresses; | |||||
| pending_poll_addresses_ = std::move(normalized); | |||||
| poll_update_pending_ = true; | poll_update_pending_ = true; | ||||
| return; | |||||
| return {true, {}}; | |||||
| } | } | ||||
| applyPollAddresses(addresses); | |||||
| applyPollAddresses(normalized); | |||||
| return {true, {}}; | |||||
| } | } | ||||
| PlcConnectionState PlcCommunicationService::state() const | PlcConnectionState PlcCommunicationService::state() const | ||||
| @@ -259,7 +331,8 @@ void PlcCommunicationService::rebuildPollBlocks() | |||||
| || poll_blocks_.back().area != address.area() | || poll_blocks_.back().area != address.area() | ||||
| || address.index() > poll_blocks_.back().startAddress | || address.index() > poll_blocks_.back().startAddress | ||||
| + poll_blocks_.back().count | + poll_blocks_.back().count | ||||
| || poll_blocks_.back().count >= kMaximumReadCount) | |||||
| || poll_blocks_.back().count | |||||
| >= ProjectLimits::kMaximumModbusReadCount) | |||||
| { | { | ||||
| poll_blocks_.push_back({address.area(), address.index(), 1}); | poll_blocks_.push_back({address.area(), address.index(), 1}); | ||||
| } | } | ||||
| @@ -491,6 +564,10 @@ RegisterWriteResult PlcCommunicationService::sendBitWrite( | |||||
| { | { | ||||
| return {false, RegisterError::Unavailable}; | return {false, RegisterError::Unavailable}; | ||||
| } | } | ||||
| if (pending_write_reply_ != nullptr) | |||||
| { | |||||
| return {false, RegisterError::WriteRejected}; | |||||
| } | |||||
| QModbusDataUnit unit(QModbusDataUnit::Coils, address.index(), 1); | QModbusDataUnit unit(QModbusDataUnit::Coils, address.index(), 1); | ||||
| unit.setValue(0, value ? 1U : 0U); | unit.setValue(0, value ? 1U : 0U); | ||||
| QModbusReply *reply = master_->sendWriteRequest(unit, configuration_.serverAddress); | QModbusReply *reply = master_->sendWriteRequest(unit, configuration_.serverAddress); | ||||
| @@ -499,10 +576,15 @@ RegisterWriteResult PlcCommunicationService::sendBitWrite( | |||||
| handleModbusError(master_->error()); | handleModbusError(master_->error()); | ||||
| return {false, RegisterError::WriteRejected}; | return {false, RegisterError::WriteRejected}; | ||||
| } | } | ||||
| pending_write_reply_ = reply; | |||||
| connect(reply, &QModbusReply::finished, | connect(reply, &QModbusReply::finished, | ||||
| this, | this, | ||||
| [this, reply, generation = connection_generation_] | [this, reply, generation = connection_generation_] | ||||
| { | { | ||||
| if (pending_write_reply_ == reply) | |||||
| { | |||||
| pending_write_reply_ = nullptr; | |||||
| } | |||||
| if (generation != connection_generation_) | if (generation != connection_generation_) | ||||
| { | { | ||||
| reply->deleteLater(); | reply->deleteLater(); | ||||
| @@ -524,6 +606,10 @@ RegisterWriteResult PlcCommunicationService::sendWordWrite( | |||||
| { | { | ||||
| return {false, RegisterError::Unavailable}; | return {false, RegisterError::Unavailable}; | ||||
| } | } | ||||
| if (pending_write_reply_ != nullptr) | |||||
| { | |||||
| return {false, RegisterError::WriteRejected}; | |||||
| } | |||||
| QModbusDataUnit unit(QModbusDataUnit::HoldingRegisters, address.index(), 1); | QModbusDataUnit unit(QModbusDataUnit::HoldingRegisters, address.index(), 1); | ||||
| unit.setValue(0, static_cast<quint16>(value)); | unit.setValue(0, static_cast<quint16>(value)); | ||||
| QModbusReply *reply = master_->sendWriteRequest(unit, configuration_.serverAddress); | QModbusReply *reply = master_->sendWriteRequest(unit, configuration_.serverAddress); | ||||
| @@ -532,10 +618,15 @@ RegisterWriteResult PlcCommunicationService::sendWordWrite( | |||||
| handleModbusError(master_->error()); | handleModbusError(master_->error()); | ||||
| return {false, RegisterError::WriteRejected}; | return {false, RegisterError::WriteRejected}; | ||||
| } | } | ||||
| pending_write_reply_ = reply; | |||||
| connect(reply, &QModbusReply::finished, | connect(reply, &QModbusReply::finished, | ||||
| this, | this, | ||||
| [this, reply, generation = connection_generation_] | [this, reply, generation = connection_generation_] | ||||
| { | { | ||||
| if (pending_write_reply_ == reply) | |||||
| { | |||||
| pending_write_reply_ = nullptr; | |||||
| } | |||||
| if (generation != connection_generation_) | if (generation != connection_generation_) | ||||
| { | { | ||||
| reply->deleteLater(); | reply->deleteLater(); | ||||
| @@ -601,6 +692,7 @@ void PlcCommunicationService::closeSerialSession() | |||||
| poll_timer_.stop(); | poll_timer_.stop(); | ||||
| recovery_timer_.stop(); | recovery_timer_.stop(); | ||||
| pending_reply_ = nullptr; | pending_reply_ = nullptr; | ||||
| pending_write_reply_ = nullptr; | |||||
| poll_update_pending_ = false; | poll_update_pending_ = false; | ||||
| pending_poll_addresses_.clear(); | pending_poll_addresses_.clear(); | ||||
| if (master_->state() != QModbusDevice::UnconnectedState) | if (master_->state() != QModbusDevice::UnconnectedState) | ||||
| @@ -30,7 +30,8 @@ public: | |||||
| PlcCommunicationResult connectDevice( | PlcCommunicationResult connectDevice( | ||||
| const PlcSerialConfiguration &configuration) override; | const PlcSerialConfiguration &configuration) override; | ||||
| void disconnectDevice() override; | void disconnectDevice() override; | ||||
| void setPollAddresses(const std::vector<RegisterAddress> &addresses) override; | |||||
| PlcCommunicationResult setPollAddresses( | |||||
| const std::vector<RegisterAddress> &addresses) override; | |||||
| PlcConnectionState state() const override; | PlcConnectionState state() const override; | ||||
| bool initialReadCompleted() const override; | bool initialReadCompleted() const override; | ||||
| @@ -84,6 +85,7 @@ private: | |||||
| std::vector<PollBlock> poll_blocks_; | std::vector<PollBlock> poll_blocks_; | ||||
| std::size_t next_poll_block_ = 0; | std::size_t next_poll_block_ = 0; | ||||
| QModbusReply *pending_reply_ = nullptr; | QModbusReply *pending_reply_ = nullptr; | ||||
| QModbusReply *pending_write_reply_ = nullptr; | |||||
| bool poll_update_pending_ = false; | bool poll_update_pending_ = false; | ||||
| bool disconnecting_ = false; | bool disconnecting_ = false; | ||||
| bool serial_session_opened_ = false; | bool serial_session_opened_ = false; | ||||
| @@ -14,12 +14,15 @@ | |||||
| #include "domain/active_register_repository.h" | #include "domain/active_register_repository.h" | ||||
| #include "services/hmi_editor_service.h" | #include "services/hmi_editor_service.h" | ||||
| #include "services/hmi_runtime_service.h" | #include "services/hmi_runtime_service.h" | ||||
| #include "services/alarm_editor_service.h" | |||||
| #include "services/alarm_service.h" | |||||
| #include "services/hmi_navigation_service.h" | #include "services/hmi_navigation_service.h" | ||||
| #include "services/logic_editor_service.h" | #include "services/logic_editor_service.h" | ||||
| #include "services/offline_simulation_service.h" | #include "services/offline_simulation_service.h" | ||||
| #include "services/project_service.h" | #include "services/project_service.h" | ||||
| #include "services/runtime_mode_service.h" | #include "services/runtime_mode_service.h" | ||||
| #include "services/register_monitor_service.h" | #include "services/register_monitor_service.h" | ||||
| #include "services/register_comment_service.h" | |||||
| #include "ui/main_window.h" | #include "ui/main_window.h" | ||||
| int main(int argc, char *argv[]) | int main(int argc, char *argv[]) | ||||
| @@ -40,6 +43,9 @@ int main(int argc, char *argv[]) | |||||
| ActiveRegisterRepository active_register_repository(virtual_register_repository); | ActiveRegisterRepository active_register_repository(virtual_register_repository); | ||||
| PlcCommunicationService plc_communication_service(plc_register_repository); | PlcCommunicationService plc_communication_service(plc_register_repository); | ||||
| HmiRuntimeService hmi_runtime_service(active_register_repository); | HmiRuntimeService hmi_runtime_service(active_register_repository); | ||||
| AlarmEditorService alarm_editor_service(project_service); | |||||
| AlarmService alarm_service(project_service, active_register_repository); | |||||
| RegisterCommentService register_comment_service(project_service); | |||||
| RegisterMonitorService register_monitor_service(active_register_repository); | RegisterMonitorService register_monitor_service(active_register_repository); | ||||
| OfflineSimulationService offline_simulation_service(virtual_register_repository); | OfflineSimulationService offline_simulation_service(virtual_register_repository); | ||||
| RuntimeModeService runtime_mode_service( | RuntimeModeService runtime_mode_service( | ||||
| @@ -55,7 +61,10 @@ int main(int argc, char *argv[]) | |||||
| hmi_editor_service, | hmi_editor_service, | ||||
| logic_editor_service, | logic_editor_service, | ||||
| hmi_runtime_service, | hmi_runtime_service, | ||||
| alarm_editor_service, | |||||
| alarm_service, | |||||
| hmi_navigation_service, | hmi_navigation_service, | ||||
| register_comment_service, | |||||
| register_monitor_service); | register_monitor_service); | ||||
| main_window.show(); | main_window.show(); | ||||
| @@ -0,0 +1,108 @@ | |||||
| #include "alarm_editor_service.h" | |||||
| #include "project_service.h" | |||||
| #include "domain/project_limits.h" | |||||
| #include <algorithm> | |||||
| namespace { | |||||
| AlarmEditorResult failure(AlarmEditorError error, const std::string &message) | |||||
| { | |||||
| return {false, error, message, {}}; | |||||
| } | |||||
| } // namespace | |||||
| AlarmEditorService::AlarmEditorService(ProjectService &project_service) | |||||
| : project_service_(project_service) | |||||
| { | |||||
| } | |||||
| const std::vector<AlarmDefinition> &AlarmEditorService::definitions() const | |||||
| { | |||||
| return project_service_.project().alarmDefinitions; | |||||
| } | |||||
| const AlarmDefinition *AlarmEditorService::findDefinition( | |||||
| const std::string &id) const | |||||
| { | |||||
| const auto &definitions = project_service_.project().alarmDefinitions; | |||||
| const auto definition = std::find_if( | |||||
| definitions.cbegin(), definitions.cend(), | |||||
| [&id](const AlarmDefinition &candidate) { return candidate.id == id; }); | |||||
| return definition == definitions.cend() ? nullptr : &*definition; | |||||
| } | |||||
| AlarmEditorResult AlarmEditorService::addDefinition( | |||||
| const AlarmDefinition &definition) | |||||
| { | |||||
| if (definitions().size() >= ProjectLimits::kMaximumAlarmDefinitions) | |||||
| { | |||||
| return failure(AlarmEditorError::InvalidDefinition, "单个工程最多包含 512 条报警定义"); | |||||
| } | |||||
| AlarmDefinition candidate = definition; | |||||
| candidate.id = makeUniqueId(); | |||||
| std::string error; | |||||
| if (!candidate.validate(&error)) | |||||
| { | |||||
| return failure(AlarmEditorError::InvalidDefinition, error); | |||||
| } | |||||
| project_service_.editProject().alarmDefinitions.push_back(candidate); | |||||
| return {true, AlarmEditorError::None, {}, candidate.id}; | |||||
| } | |||||
| AlarmEditorResult AlarmEditorService::updateDefinition( | |||||
| const std::string &id, const AlarmDefinition &definition) | |||||
| { | |||||
| if (findDefinition(id) == nullptr) | |||||
| { | |||||
| return failure(AlarmEditorError::NotFound, "未找到报警定义"); | |||||
| } | |||||
| AlarmDefinition candidate = definition; | |||||
| candidate.id = id; | |||||
| std::string error; | |||||
| if (!candidate.validate(&error)) | |||||
| { | |||||
| return failure(AlarmEditorError::InvalidDefinition, error); | |||||
| } | |||||
| Project &project = project_service_.editProject(); | |||||
| auto editable = std::find_if( | |||||
| project.alarmDefinitions.begin(), project.alarmDefinitions.end(), | |||||
| [&id](const AlarmDefinition &item) { return item.id == id; }); | |||||
| *editable = std::move(candidate); | |||||
| return {true, AlarmEditorError::None, {}, id}; | |||||
| } | |||||
| AlarmEditorResult AlarmEditorService::removeDefinition(const std::string &id) | |||||
| { | |||||
| if (findDefinition(id) == nullptr) | |||||
| { | |||||
| return failure(AlarmEditorError::NotFound, "未找到报警定义"); | |||||
| } | |||||
| Project &project = project_service_.editProject(); | |||||
| project.alarmDefinitions.erase( | |||||
| std::remove_if( | |||||
| project.alarmDefinitions.begin(), project.alarmDefinitions.end(), | |||||
| [&id](const AlarmDefinition &definition) | |||||
| { | |||||
| return definition.id == id; | |||||
| }), | |||||
| project.alarmDefinitions.end()); | |||||
| return {true, AlarmEditorError::None, {}, id}; | |||||
| } | |||||
| std::string AlarmEditorService::makeUniqueId() const | |||||
| { | |||||
| int suffix = 1; | |||||
| while (true) | |||||
| { | |||||
| const std::string candidate = "alarm-" + std::to_string(suffix); | |||||
| if (findDefinition(candidate) == nullptr) | |||||
| { | |||||
| return candidate; | |||||
| } | |||||
| ++suffix; | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,42 @@ | |||||
| #pragma once | |||||
| #include "domain/alarm_model.h" | |||||
| #include <string> | |||||
| #include <vector> | |||||
| class ProjectService; | |||||
| enum class AlarmEditorError | |||||
| { | |||||
| None, | |||||
| NotFound, | |||||
| DuplicateId, | |||||
| InvalidDefinition | |||||
| }; | |||||
| struct AlarmEditorResult | |||||
| { | |||||
| bool succeeded = false; | |||||
| AlarmEditorError error = AlarmEditorError::None; | |||||
| std::string message; | |||||
| std::string id; | |||||
| }; | |||||
| class AlarmEditorService | |||||
| { | |||||
| public: | |||||
| explicit AlarmEditorService(ProjectService &project_service); | |||||
| const std::vector<AlarmDefinition> &definitions() const; | |||||
| const AlarmDefinition *findDefinition(const std::string &id) const; | |||||
| AlarmEditorResult addDefinition(const AlarmDefinition &definition); | |||||
| AlarmEditorResult updateDefinition( | |||||
| const std::string &id, const AlarmDefinition &definition); | |||||
| AlarmEditorResult removeDefinition(const std::string &id); | |||||
| private: | |||||
| std::string makeUniqueId() const; | |||||
| ProjectService &project_service_; | |||||
| }; | |||||
| @@ -0,0 +1,95 @@ | |||||
| #include "alarm_service.h" | |||||
| #include "domain/alarm_model.h" | |||||
| #include "domain/register_repository.h" | |||||
| #include "project_service.h" | |||||
| #include <algorithm> | |||||
| AlarmService::AlarmService( | |||||
| const ProjectService &project_service, | |||||
| RegisterRepository &repository) | |||||
| : project_service_(project_service), | |||||
| repository_(repository) | |||||
| { | |||||
| } | |||||
| void AlarmService::refresh() | |||||
| { | |||||
| const auto now = std::chrono::system_clock::now(); | |||||
| for (const AlarmDefinition &definition | |||||
| : project_service_.project().alarmDefinitions) | |||||
| { | |||||
| const std::optional<bool> active = evaluate(definition); | |||||
| if (!active.has_value()) | |||||
| { | |||||
| continue; | |||||
| } | |||||
| const auto current = std::find_if( | |||||
| records_.begin(), records_.end(), | |||||
| [&definition](const AlarmRecord &record) | |||||
| { | |||||
| return record.definitionId == definition.id; | |||||
| }); | |||||
| if (*active && current == records_.end()) | |||||
| { | |||||
| records_.insert( | |||||
| records_.begin(), | |||||
| AlarmRecord{definition.id, definition.message, false, now}); | |||||
| } | |||||
| else if (!*active && current != records_.end()) | |||||
| { | |||||
| records_.erase(current); | |||||
| } | |||||
| } | |||||
| } | |||||
| bool AlarmService::acknowledge(const std::string &definition_id) | |||||
| { | |||||
| const auto record = std::find_if( | |||||
| records_.begin(), records_.end(), | |||||
| [&definition_id](const AlarmRecord &candidate) | |||||
| { | |||||
| return candidate.definitionId == definition_id; | |||||
| }); | |||||
| if (record == records_.end()) | |||||
| { | |||||
| return false; | |||||
| } | |||||
| record->acknowledged = true; | |||||
| return true; | |||||
| } | |||||
| void AlarmService::reset() | |||||
| { | |||||
| records_.clear(); | |||||
| } | |||||
| const std::vector<AlarmRecord> &AlarmService::records() const | |||||
| { | |||||
| return records_; | |||||
| } | |||||
| std::optional<bool> AlarmService::evaluate( | |||||
| const AlarmDefinition &definition) const | |||||
| { | |||||
| if (definition.condition == AlarmCondition::MOn) | |||||
| { | |||||
| const BitReadResult value = repository_.readBit(definition.address); | |||||
| return value.succeeded ? std::optional<bool>{value.value} : std::nullopt; | |||||
| } | |||||
| const WordReadResult value = repository_.readWord(definition.address); | |||||
| if (!value.succeeded) | |||||
| { | |||||
| return std::nullopt; | |||||
| } | |||||
| if (definition.condition == AlarmCondition::DHigh) | |||||
| { | |||||
| return value.value >= definition.threshold; | |||||
| } | |||||
| if (definition.condition == AlarmCondition::DLow) | |||||
| { | |||||
| return value.value <= definition.threshold; | |||||
| } | |||||
| return std::nullopt; | |||||
| } | |||||
| @@ -0,0 +1,38 @@ | |||||
| #pragma once | |||||
| #include <chrono> | |||||
| #include <optional> | |||||
| #include <string> | |||||
| #include <vector> | |||||
| class ProjectService; | |||||
| class RegisterRepository; | |||||
| struct AlarmDefinition; | |||||
| struct AlarmRecord | |||||
| { | |||||
| std::string definitionId; | |||||
| std::string message; | |||||
| bool acknowledged = false; | |||||
| std::chrono::system_clock::time_point occurredAt; | |||||
| }; | |||||
| class AlarmService | |||||
| { | |||||
| public: | |||||
| AlarmService( | |||||
| const ProjectService &project_service, | |||||
| RegisterRepository &repository); | |||||
| void refresh(); | |||||
| bool acknowledge(const std::string &definition_id); | |||||
| void reset(); | |||||
| const std::vector<AlarmRecord> &records() const; | |||||
| private: | |||||
| std::optional<bool> evaluate(const AlarmDefinition &definition) const; | |||||
| const ProjectService &project_service_; | |||||
| RegisterRepository &repository_; | |||||
| std::vector<AlarmRecord> records_; | |||||
| }; | |||||
| @@ -0,0 +1,84 @@ | |||||
| #pragma once | |||||
| #include <cstddef> | |||||
| #include <optional> | |||||
| #include <utility> | |||||
| #include <vector> | |||||
| /** | |||||
| * @brief 保存有限数量的编辑前快照并提供撤销、重做 | |||||
| * | |||||
| * 历史只存在当前编辑会话内。新编辑会清空重做栈,超过容量时丢弃最早记录。 | |||||
| */ | |||||
| template <typename State> | |||||
| class EditorHistory final | |||||
| { | |||||
| public: | |||||
| static constexpr std::size_t kMaximumEntries = 100U; | |||||
| template <typename Equal> | |||||
| void record(State before, const State &after, Equal equal) | |||||
| { | |||||
| if (equal(before, after)) | |||||
| { | |||||
| return; | |||||
| } | |||||
| undo_states_.push_back(std::move(before)); | |||||
| trim(&undo_states_); | |||||
| redo_states_.clear(); | |||||
| } | |||||
| std::optional<State> undo(State current) | |||||
| { | |||||
| if (undo_states_.empty()) | |||||
| { | |||||
| return std::nullopt; | |||||
| } | |||||
| redo_states_.push_back(std::move(current)); | |||||
| trim(&redo_states_); | |||||
| State target = std::move(undo_states_.back()); | |||||
| undo_states_.pop_back(); | |||||
| return target; | |||||
| } | |||||
| std::optional<State> redo(State current) | |||||
| { | |||||
| if (redo_states_.empty()) | |||||
| { | |||||
| return std::nullopt; | |||||
| } | |||||
| undo_states_.push_back(std::move(current)); | |||||
| trim(&undo_states_); | |||||
| State target = std::move(redo_states_.back()); | |||||
| redo_states_.pop_back(); | |||||
| return target; | |||||
| } | |||||
| void clear() | |||||
| { | |||||
| undo_states_.clear(); | |||||
| redo_states_.clear(); | |||||
| } | |||||
| bool canUndo() const | |||||
| { | |||||
| return !undo_states_.empty(); | |||||
| } | |||||
| bool canRedo() const | |||||
| { | |||||
| return !redo_states_.empty(); | |||||
| } | |||||
| private: | |||||
| static void trim(std::vector<State> *states) | |||||
| { | |||||
| while (states->size() > kMaximumEntries) | |||||
| { | |||||
| states->erase(states->begin()); | |||||
| } | |||||
| } | |||||
| std::vector<State> undo_states_; | |||||
| std::vector<State> redo_states_; | |||||
| }; | |||||
| @@ -1,10 +1,14 @@ | |||||
| #include "hmi_editor_service.h" | #include "hmi_editor_service.h" | ||||
| #include "domain/hmi_control_registry.h" | |||||
| #include "project_service.h" | #include "project_service.h" | ||||
| #include "domain/project_limits.h" | |||||
| #include <algorithm> | #include <algorithm> | ||||
| #include <cctype> | #include <cctype> | ||||
| #include <cstddef> | #include <cstddef> | ||||
| #include <map> | |||||
| #include <set> | |||||
| #include <utility> | #include <utility> | ||||
| namespace { | namespace { | ||||
| @@ -17,76 +21,6 @@ void setError(std::string *error, const std::string &message) | |||||
| } | } | ||||
| } | } | ||||
| std::string controlPrefix(HmiControlType type) | |||||
| { | |||||
| switch (type) | |||||
| { | |||||
| case HmiControlType::Button: | |||||
| { | |||||
| return "button"; | |||||
| } | |||||
| case HmiControlType::Indicator: | |||||
| { | |||||
| return "indicator"; | |||||
| } | |||||
| case HmiControlType::NumericDisplay: | |||||
| { | |||||
| return "numeric-display"; | |||||
| } | |||||
| case HmiControlType::NumericInput: | |||||
| { | |||||
| return "numeric-input"; | |||||
| } | |||||
| case HmiControlType::Label: | |||||
| { | |||||
| return "label"; | |||||
| } | |||||
| case HmiControlType::PageJump: | |||||
| { | |||||
| return "page-jump"; | |||||
| } | |||||
| default: | |||||
| { | |||||
| return "control"; | |||||
| } | |||||
| } | |||||
| } | |||||
| std::string defaultText(HmiControlType type) | |||||
| { | |||||
| switch (type) | |||||
| { | |||||
| case HmiControlType::Button: | |||||
| { | |||||
| return "按钮"; | |||||
| } | |||||
| case HmiControlType::Indicator: | |||||
| { | |||||
| return "指示灯"; | |||||
| } | |||||
| case HmiControlType::NumericDisplay: | |||||
| { | |||||
| return "数值显示"; | |||||
| } | |||||
| case HmiControlType::NumericInput: | |||||
| { | |||||
| return "数值输入"; | |||||
| } | |||||
| case HmiControlType::Label: | |||||
| { | |||||
| return "文本"; | |||||
| } | |||||
| case HmiControlType::PageJump: | |||||
| { | |||||
| return "页面跳转"; | |||||
| } | |||||
| default: | |||||
| { | |||||
| return "控件"; | |||||
| } | |||||
| } | |||||
| } | |||||
| HmiEditorResult failure(HmiEditorError error, const std::string &message) | HmiEditorResult failure(HmiEditorError error, const std::string &message) | ||||
| { | { | ||||
| return {false, error, message, {}}; | return {false, error, message, {}}; | ||||
| @@ -124,6 +58,86 @@ HmiEditorService::HmiEditorService(ProjectService &project_service) | |||||
| { | { | ||||
| } | } | ||||
| HmiEditorService::HistoryState HmiEditorService::captureState() const | |||||
| { | |||||
| const Project &project = project_service_.project(); | |||||
| return {project.hmiPages, project.initialHmiPageId}; | |||||
| } | |||||
| void HmiEditorService::recordHistory(HistoryState before) | |||||
| { | |||||
| const HistoryState after = captureState(); | |||||
| history_.record(std::move(before), after, &HmiEditorService::statesEqual); | |||||
| } | |||||
| bool HmiEditorService::statesEqual( | |||||
| const HistoryState &left, const HistoryState &right) | |||||
| { | |||||
| if (left.initial_page_id != right.initial_page_id | |||||
| || left.pages.size() != right.pages.size()) | |||||
| { | |||||
| return false; | |||||
| } | |||||
| for (std::size_t index = 0; index < left.pages.size(); ++index) | |||||
| { | |||||
| if (!pagesEqual(left.pages[index], right.pages[index])) | |||||
| { | |||||
| return false; | |||||
| } | |||||
| } | |||||
| return true; | |||||
| } | |||||
| bool HmiEditorService::pagesEqual( | |||||
| const HmiPage &left, const HmiPage &right) | |||||
| { | |||||
| if (left.id != right.id || left.name != right.name | |||||
| || left.width != right.width || left.height != right.height | |||||
| || left.controls.size() != right.controls.size()) | |||||
| { | |||||
| return false; | |||||
| } | |||||
| for (std::size_t index = 0; index < left.controls.size(); ++index) | |||||
| { | |||||
| if (!controlsEqual(left.controls[index], right.controls[index])) | |||||
| { | |||||
| return false; | |||||
| } | |||||
| } | |||||
| return true; | |||||
| } | |||||
| bool HmiEditorService::controlsEqual( | |||||
| const HmiControl &left, const HmiControl &right) | |||||
| { | |||||
| const bool progress_equal = left.progressBar.has_value() | |||||
| == right.progressBar.has_value() | |||||
| && (!left.progressBar.has_value() | |||||
| || (left.progressBar->minimumValue == right.progressBar->minimumValue | |||||
| && left.progressBar->maximumValue == right.progressBar->maximumValue | |||||
| && left.progressBar->showValue == right.progressBar->showValue)); | |||||
| const bool page_jump_equal = left.pageJump.has_value() == right.pageJump.has_value() | |||||
| && (!left.pageJump.has_value() | |||||
| || left.pageJump->targetPageId == right.pageJump->targetPageId); | |||||
| return left.id == right.id | |||||
| && left.type == right.type | |||||
| && left.bounds.x == right.bounds.x | |||||
| && left.bounds.y == right.bounds.y | |||||
| && left.bounds.width == right.bounds.width | |||||
| && left.bounds.height == right.bounds.height | |||||
| && left.text == right.text | |||||
| && left.binding == right.binding | |||||
| && left.properties == right.properties | |||||
| && left.buttonOperation == right.buttonOperation | |||||
| && page_jump_equal | |||||
| && progress_equal; | |||||
| } | |||||
| HmiEditorResult HmiEditorService::historyFailure(const std::string &message) | |||||
| { | |||||
| return {false, HmiEditorError::InvalidOperation, message, {}}; | |||||
| } | |||||
| // 遍历工程所有 HmiPage,根据页面 ID 查找页面,找不到返回空指针 | // 遍历工程所有 HmiPage,根据页面 ID 查找页面,找不到返回空指针 | ||||
| const HmiPage *HmiEditorService::findPage(const std::string &page_id) const | const HmiPage *HmiEditorService::findPage(const std::string &page_id) const | ||||
| { | { | ||||
| @@ -178,6 +192,7 @@ HmiEditorResult HmiEditorService::ensureDefaultPage() | |||||
| } | } | ||||
| // 仅在首个控件操作前创建默认页面,空工程仍可正常保存 | // 仅在首个控件操作前创建默认页面,空工程仍可正常保存 | ||||
| HistoryState before = captureState(); | |||||
| HmiPage page; | HmiPage page; | ||||
| page.id = "page-1"; | page.id = "page-1"; | ||||
| page.name = "主操作页面"; | page.name = "主操作页面"; | ||||
| @@ -185,6 +200,7 @@ HmiEditorResult HmiEditorService::ensureDefaultPage() | |||||
| Project &project = project_service_.editProject(); | Project &project = project_service_.editProject(); | ||||
| project.hmiPages.push_back(std::move(page)); | project.hmiPages.push_back(std::move(page)); | ||||
| project.initialHmiPageId = project.hmiPages.back().id; | project.initialHmiPageId = project.hmiPages.back().id; | ||||
| recordHistory(std::move(before)); | |||||
| return {true, HmiEditorError::None, {}, project.hmiPages.back().id}; | return {true, HmiEditorError::None, {}, project.hmiPages.back().id}; | ||||
| } | } | ||||
| @@ -195,6 +211,14 @@ HmiEditorResult HmiEditorService::addPage(const std::string &name) | |||||
| return failure(HmiEditorError::InvalidPage, "HMI 页面名称不能为空"); | return failure(HmiEditorError::InvalidPage, "HMI 页面名称不能为空"); | ||||
| } | } | ||||
| const Project ¤t = project_service_.project(); | const Project ¤t = project_service_.project(); | ||||
| if (current.hmiPages.size() >= ProjectLimits::kMaximumHmiPages) | |||||
| { | |||||
| return failure(HmiEditorError::InvalidPage, "单个工程最多包含 128 个 HMI 页面"); | |||||
| } | |||||
| if (name.size() > ProjectLimits::kMaximumTextBytes) | |||||
| { | |||||
| return failure(HmiEditorError::InvalidPage, "HMI 页面名称不能超过 4096 个 UTF-8 字节"); | |||||
| } | |||||
| const bool duplicate = std::any_of( | const bool duplicate = std::any_of( | ||||
| current.hmiPages.cbegin(), current.hmiPages.cend(), | current.hmiPages.cbegin(), current.hmiPages.cend(), | ||||
| [&name](const HmiPage &page) { return page.name == name; }); | [&name](const HmiPage &page) { return page.name == name; }); | ||||
| @@ -206,15 +230,51 @@ HmiEditorResult HmiEditorService::addPage(const std::string &name) | |||||
| HmiPage page; | HmiPage page; | ||||
| page.id = makeUniquePageId(current); | page.id = makeUniquePageId(current); | ||||
| page.name = name; | page.name = name; | ||||
| HistoryState before = captureState(); | |||||
| Project &project = project_service_.editProject(); | Project &project = project_service_.editProject(); | ||||
| project.hmiPages.push_back(std::move(page)); | project.hmiPages.push_back(std::move(page)); | ||||
| if (project.initialHmiPageId.empty()) | if (project.initialHmiPageId.empty()) | ||||
| { | { | ||||
| project.initialHmiPageId = project.hmiPages.back().id; | project.initialHmiPageId = project.hmiPages.back().id; | ||||
| } | } | ||||
| recordHistory(std::move(before)); | |||||
| return {true, HmiEditorError::None, {}, project.hmiPages.back().id}; | return {true, HmiEditorError::None, {}, project.hmiPages.back().id}; | ||||
| } | } | ||||
| HmiEditorResult HmiEditorService::resizePage( | |||||
| const std::string &page_id, int width, int height) | |||||
| { | |||||
| const HmiPage *page = findPage(page_id); | |||||
| if (page == nullptr) | |||||
| { | |||||
| return failure(HmiEditorError::PageNotFound, "未找到 HMI 页面"); | |||||
| } | |||||
| if (page->width == width && page->height == height) | |||||
| { | |||||
| return {true, HmiEditorError::None, {}, page_id}; | |||||
| } | |||||
| // 先在副本上校验新页面边界,失败时不触碰当前工程 | |||||
| HmiPage candidate = *page; | |||||
| candidate.width = width; | |||||
| candidate.height = height; | |||||
| std::string error; | |||||
| if (!candidate.validate(&error)) | |||||
| { | |||||
| return failure(HmiEditorError::InvalidPage, error); | |||||
| } | |||||
| HistoryState before = captureState(); | |||||
| Project &project = project_service_.editProject(); | |||||
| auto editable = std::find_if( | |||||
| project.hmiPages.begin(), project.hmiPages.end(), | |||||
| [&page_id](const HmiPage &item) { return item.id == page_id; }); | |||||
| editable->width = width; | |||||
| editable->height = height; | |||||
| recordHistory(std::move(before)); | |||||
| return {true, HmiEditorError::None, {}, page_id}; | |||||
| } | |||||
| HmiEditorResult HmiEditorService::renamePage( | HmiEditorResult HmiEditorService::renamePage( | ||||
| const std::string &page_id, const std::string &name) | const std::string &page_id, const std::string &name) | ||||
| { | { | ||||
| @@ -222,6 +282,10 @@ HmiEditorResult HmiEditorService::renamePage( | |||||
| { | { | ||||
| return failure(HmiEditorError::InvalidPage, "HMI 页面名称不能为空"); | return failure(HmiEditorError::InvalidPage, "HMI 页面名称不能为空"); | ||||
| } | } | ||||
| if (name.size() > ProjectLimits::kMaximumTextBytes) | |||||
| { | |||||
| return failure(HmiEditorError::InvalidPage, "HMI 页面名称不能超过 4096 个 UTF-8 字节"); | |||||
| } | |||||
| const Project ¤t = project_service_.project(); | const Project ¤t = project_service_.project(); | ||||
| const auto existing = std::find_if( | const auto existing = std::find_if( | ||||
| current.hmiPages.cbegin(), current.hmiPages.cend(), | current.hmiPages.cbegin(), current.hmiPages.cend(), | ||||
| @@ -240,12 +304,18 @@ HmiEditorResult HmiEditorService::renamePage( | |||||
| { | { | ||||
| return failure(HmiEditorError::DuplicateName, "HMI 页面名称必须唯一"); | return failure(HmiEditorError::DuplicateName, "HMI 页面名称必须唯一"); | ||||
| } | } | ||||
| if (existing->name == name) | |||||
| { | |||||
| return {true, HmiEditorError::None, {}, page_id}; | |||||
| } | |||||
| HistoryState before = captureState(); | |||||
| Project &project = project_service_.editProject(); | Project &project = project_service_.editProject(); | ||||
| auto page = std::find_if( | auto page = std::find_if( | ||||
| project.hmiPages.begin(), project.hmiPages.end(), | project.hmiPages.begin(), project.hmiPages.end(), | ||||
| [&page_id](const HmiPage &candidate) { return candidate.id == page_id; }); | [&page_id](const HmiPage &candidate) { return candidate.id == page_id; }); | ||||
| page->name = name; | page->name = name; | ||||
| recordHistory(std::move(before)); | |||||
| return {true, HmiEditorError::None, {}, page_id}; | return {true, HmiEditorError::None, {}, page_id}; | ||||
| } | } | ||||
| @@ -287,12 +357,14 @@ HmiEditorResult HmiEditorService::removePage(const std::string &page_id) | |||||
| } | } | ||||
| } | } | ||||
| HistoryState before = captureState(); | |||||
| Project &project = project_service_.editProject(); | Project &project = project_service_.editProject(); | ||||
| project.hmiPages.erase( | project.hmiPages.erase( | ||||
| std::remove_if( | std::remove_if( | ||||
| project.hmiPages.begin(), project.hmiPages.end(), | project.hmiPages.begin(), project.hmiPages.end(), | ||||
| [&page_id](const HmiPage &candidate) { return candidate.id == page_id; }), | [&page_id](const HmiPage &candidate) { return candidate.id == page_id; }), | ||||
| project.hmiPages.end()); | project.hmiPages.end()); | ||||
| recordHistory(std::move(before)); | |||||
| return {true, HmiEditorError::None, {}, page_id}; | return {true, HmiEditorError::None, {}, page_id}; | ||||
| } | } | ||||
| @@ -318,10 +390,12 @@ HmiEditorResult HmiEditorService::movePage(const std::string &page_id, int offse | |||||
| return failure(HmiEditorError::InvalidOperation, "HMI 页面已经位于目标边界"); | return failure(HmiEditorError::InvalidOperation, "HMI 页面已经位于目标边界"); | ||||
| } | } | ||||
| HistoryState before = captureState(); | |||||
| Project &project = project_service_.editProject(); | Project &project = project_service_.editProject(); | ||||
| std::iter_swap( | std::iter_swap( | ||||
| project.hmiPages.begin() + index, | project.hmiPages.begin() + index, | ||||
| project.hmiPages.begin() + target_index); | project.hmiPages.begin() + target_index); | ||||
| recordHistory(std::move(before)); | |||||
| return {true, HmiEditorError::None, {}, page_id}; | return {true, HmiEditorError::None, {}, page_id}; | ||||
| } | } | ||||
| @@ -331,7 +405,13 @@ HmiEditorResult HmiEditorService::setInitialPage(const std::string &page_id) | |||||
| { | { | ||||
| return failure(HmiEditorError::PageNotFound, "未找到 HMI 页面"); | return failure(HmiEditorError::PageNotFound, "未找到 HMI 页面"); | ||||
| } | } | ||||
| if (project_service_.project().initialHmiPageId == page_id) | |||||
| { | |||||
| return {true, HmiEditorError::None, {}, page_id}; | |||||
| } | |||||
| HistoryState before = captureState(); | |||||
| project_service_.editProject().initialHmiPageId = page_id; | project_service_.editProject().initialHmiPageId = page_id; | ||||
| recordHistory(std::move(before)); | |||||
| return {true, HmiEditorError::None, {}, page_id}; | return {true, HmiEditorError::None, {}, page_id}; | ||||
| } | } | ||||
| @@ -343,18 +423,29 @@ HmiEditorResult HmiEditorService::addControl( | |||||
| { | { | ||||
| return failure(HmiEditorError::PageNotFound, "未找到 HMI 页面"); | return failure(HmiEditorError::PageNotFound, "未找到 HMI 页面"); | ||||
| } | } | ||||
| if (page->controls.size() >= ProjectLimits::kMaximumHmiControlsPerPage) | |||||
| { | |||||
| return failure(HmiEditorError::InvalidControl, "单个 HMI 页面最多包含 512 个控件"); | |||||
| } | |||||
| const HmiControlDescriptor *descriptor = findHmiControlDescriptor(type); | |||||
| if (descriptor == nullptr) | |||||
| { | |||||
| return failure(HmiEditorError::InvalidControl, "不支持的 HMI 控件类型"); | |||||
| } | |||||
| // 新控件初始不绑定寄存器,避免自动分配地址造成误写风险 | // 新控件初始不绑定寄存器,避免自动分配地址造成误写风险 | ||||
| HmiControl control = makeControl(*page, type); | |||||
| HmiControl control = makeControl(*page, *descriptor); | |||||
| HistoryState before = captureState(); | |||||
| Project &project = project_service_.editProject(); | Project &project = project_service_.editProject(); | ||||
| auto target_page = std::find_if( | auto target_page = std::find_if( | ||||
| project.hmiPages.begin(), | project.hmiPages.begin(), | ||||
| project.hmiPages.end(), | project.hmiPages.end(), | ||||
| [&page_id](const HmiPage &candidate) | [&page_id](const HmiPage &candidate) | ||||
| { | |||||
| return candidate.id == page_id; | |||||
| { | |||||
| return candidate.id == page_id; | |||||
| }); | }); | ||||
| target_page->controls.push_back(std::move(control)); | target_page->controls.push_back(std::move(control)); | ||||
| recordHistory(std::move(before)); | |||||
| return {true, | return {true, | ||||
| HmiEditorError::None, | HmiEditorError::None, | ||||
| {}, | {}, | ||||
| @@ -363,16 +454,36 @@ HmiEditorResult HmiEditorService::addControl( | |||||
| HmiEditorResult HmiEditorService::removeControl( | HmiEditorResult HmiEditorService::removeControl( | ||||
| const std::string &page_id, const std::string &control_id) | const std::string &page_id, const std::string &control_id) | ||||
| { | |||||
| return removeControls(page_id, {control_id}); | |||||
| } | |||||
| HmiEditorResult HmiEditorService::removeControls( | |||||
| const std::string &page_id, | |||||
| const std::vector<std::string> &control_ids) | |||||
| { | { | ||||
| if (findPage(page_id) == nullptr) | if (findPage(page_id) == nullptr) | ||||
| { | { | ||||
| return failure(HmiEditorError::PageNotFound, "未找到 HMI 页面"); | return failure(HmiEditorError::PageNotFound, "未找到 HMI 页面"); | ||||
| } | } | ||||
| if (findControl(page_id, control_id) == nullptr) | |||||
| if (control_ids.empty()) | |||||
| { | { | ||||
| return failure(HmiEditorError::ControlNotFound, "未找到 HMI 控件"); | |||||
| return failure(HmiEditorError::InvalidOperation, "请先选择要删除的 HMI 控件"); | |||||
| } | |||||
| std::set<std::string> selected_ids; | |||||
| for (const std::string &control_id : control_ids) | |||||
| { | |||||
| if (!selected_ids.insert(control_id).second) | |||||
| { | |||||
| return failure(HmiEditorError::InvalidOperation, "删除列表中存在重复控件"); | |||||
| } | |||||
| if (findControl(page_id, control_id) == nullptr) | |||||
| { | |||||
| return failure(HmiEditorError::ControlNotFound, "未找到 HMI 控件"); | |||||
| } | |||||
| } | } | ||||
| HistoryState before = captureState(); | |||||
| Project &project = project_service_.editProject(); | Project &project = project_service_.editProject(); | ||||
| auto page = std::find_if( | auto page = std::find_if( | ||||
| project.hmiPages.begin(), | project.hmiPages.begin(), | ||||
| @@ -385,12 +496,13 @@ HmiEditorResult HmiEditorService::removeControl( | |||||
| std::remove_if( | std::remove_if( | ||||
| page->controls.begin(), | page->controls.begin(), | ||||
| page->controls.end(), | page->controls.end(), | ||||
| [&control_id](const HmiControl &candidate) | |||||
| [&selected_ids](const HmiControl &candidate) | |||||
| { | { | ||||
| return candidate.id == control_id; | |||||
| return selected_ids.find(candidate.id) != selected_ids.end(); | |||||
| }), | }), | ||||
| page->controls.end()); | page->controls.end()); | ||||
| return {true, HmiEditorError::None, {}, control_id}; | |||||
| recordHistory(std::move(before)); | |||||
| return {true, HmiEditorError::None, {}, control_ids.front()}; | |||||
| } | } | ||||
| // 移动 / 缩放控件 | // 移动 / 缩放控件 | ||||
| @@ -419,7 +531,15 @@ HmiEditorResult HmiEditorService::moveControl( | |||||
| { | { | ||||
| return failure(HmiEditorError::InvalidControl, error); | return failure(HmiEditorError::InvalidControl, error); | ||||
| } | } | ||||
| if (control->bounds.x == bounds.x | |||||
| && control->bounds.y == bounds.y | |||||
| && control->bounds.width == bounds.width | |||||
| && control->bounds.height == bounds.height) | |||||
| { | |||||
| return {true, HmiEditorError::None, {}, control_id}; | |||||
| } | |||||
| HistoryState before = captureState(); | |||||
| Project &project = project_service_.editProject(); | Project &project = project_service_.editProject(); | ||||
| auto target = std::find_if( | auto target = std::find_if( | ||||
| project.hmiPages.begin(), project.hmiPages.end(), | project.hmiPages.begin(), project.hmiPages.end(), | ||||
| @@ -428,6 +548,7 @@ HmiEditorResult HmiEditorService::moveControl( | |||||
| target->controls.begin(), target->controls.end(), | target->controls.begin(), target->controls.end(), | ||||
| [&control_id](const HmiControl &item) { return item.id == control_id; }); | [&control_id](const HmiControl &item) { return item.id == control_id; }); | ||||
| editable->bounds = bounds; | editable->bounds = bounds; | ||||
| recordHistory(std::move(before)); | |||||
| return {true, HmiEditorError::None, {}, control_id}; | return {true, HmiEditorError::None, {}, control_id}; | ||||
| } | } | ||||
| @@ -462,6 +583,11 @@ HmiEditorResult HmiEditorService::updateControl( | |||||
| return failure(HmiEditorError::DuplicateId, "同一 HMI 页面内的控件 ID 必须唯一"); | return failure(HmiEditorError::DuplicateId, "同一 HMI 页面内的控件 ID 必须唯一"); | ||||
| } | } | ||||
| if (controlsEqual(*existing, control)) | |||||
| { | |||||
| return {true, HmiEditorError::None, {}, control.id}; | |||||
| } | |||||
| HistoryState before = captureState(); | |||||
| Project &project = project_service_.editProject(); | Project &project = project_service_.editProject(); | ||||
| auto target = std::find_if( | auto target = std::find_if( | ||||
| project.hmiPages.begin(), project.hmiPages.end(), | project.hmiPages.begin(), project.hmiPages.end(), | ||||
| @@ -470,32 +596,59 @@ HmiEditorResult HmiEditorService::updateControl( | |||||
| target->controls.begin(), target->controls.end(), | target->controls.begin(), target->controls.end(), | ||||
| [&control_id](const HmiControl &item) { return item.id == control_id; }); | [&control_id](const HmiControl &item) { return item.id == control_id; }); | ||||
| *editable = control; | *editable = control; | ||||
| recordHistory(std::move(before)); | |||||
| return {true, HmiEditorError::None, {}, control.id}; | return {true, HmiEditorError::None, {}, control.id}; | ||||
| } | } | ||||
| bool HmiEditorService::canUndo() const | |||||
| { | |||||
| return history_.canUndo(); | |||||
| } | |||||
| bool HmiEditorService::canRedo() const | |||||
| { | |||||
| return history_.canRedo(); | |||||
| } | |||||
| HmiEditorResult HmiEditorService::undo() | |||||
| { | |||||
| const std::optional<HistoryState> target = history_.undo(captureState()); | |||||
| if (!target.has_value()) | |||||
| { | |||||
| return historyFailure("没有可撤销的 HMI 编辑操作"); | |||||
| } | |||||
| Project &project = project_service_.editProject(); | |||||
| project.hmiPages = target->pages; | |||||
| project.initialHmiPageId = target->initial_page_id; | |||||
| return {true, HmiEditorError::None, {}, {}}; | |||||
| } | |||||
| HmiEditorResult HmiEditorService::redo() | |||||
| { | |||||
| const std::optional<HistoryState> target = history_.redo(captureState()); | |||||
| if (!target.has_value()) | |||||
| { | |||||
| return historyFailure("没有可重做的 HMI 编辑操作"); | |||||
| } | |||||
| Project &project = project_service_.editProject(); | |||||
| project.hmiPages = target->pages; | |||||
| project.initialHmiPageId = target->initial_page_id; | |||||
| return {true, HmiEditorError::None, {}, {}}; | |||||
| } | |||||
| void HmiEditorService::clearHistory() | |||||
| { | |||||
| history_.clear(); | |||||
| } | |||||
| bool HmiEditorService::validateEditableControl( | bool HmiEditorService::validateEditableControl( | ||||
| const HmiPage &page, const HmiControl &control, std::string *error) const | const HmiPage &page, const HmiControl &control, std::string *error) const | ||||
| { | { | ||||
| switch (control.type) | |||||
| { | |||||
| case HmiControlType::Button: | |||||
| case HmiControlType::Indicator: | |||||
| case HmiControlType::NumericDisplay: | |||||
| case HmiControlType::NumericInput: | |||||
| case HmiControlType::Label: | |||||
| case HmiControlType::PageJump: | |||||
| break; | |||||
| default: | |||||
| setError(error, "不支持的 HMI 控件类型"); | |||||
| return false; | |||||
| } | |||||
| if (control.id.empty()) | |||||
| if (!control.validate(error)) | |||||
| { | { | ||||
| setError(error, "HMI 控件 ID 不能为空"); | |||||
| return false; | return false; | ||||
| } | } | ||||
| if (control.bounds.width <= 0 || control.bounds.height <= 0 | |||||
| || control.bounds.x < 0 || control.bounds.y < 0 | |||||
| if (control.bounds.x < 0 || control.bounds.y < 0 | |||||
| || control.bounds.width > page.width || control.bounds.height > page.height | || control.bounds.width > page.width || control.bounds.height > page.height | ||||
| || control.bounds.x > page.width - control.bounds.width | || control.bounds.x > page.width - control.bounds.width | ||||
| || control.bounds.y > page.height - control.bounds.height) | || control.bounds.y > page.height - control.bounds.height) | ||||
| @@ -503,59 +656,11 @@ bool HmiEditorService::validateEditableControl( | |||||
| setError(error, "HMI 控件不能超出页面范围"); | setError(error, "HMI 控件不能超出页面范围"); | ||||
| return false; | return false; | ||||
| } | } | ||||
| for (const auto &property : control.properties) | |||||
| { | |||||
| if (property.first.empty()) | |||||
| { | |||||
| setError(error, "HMI 控件属性名称不能为空"); | |||||
| return false; | |||||
| } | |||||
| } | |||||
| if (control.binding.has_value() && !control.binding->isValid()) | |||||
| { | |||||
| setError(error, "HMI 控件绑定了无效地址"); | |||||
| return false; | |||||
| } | |||||
| if (control.binding.has_value() | |||||
| && (control.type == HmiControlType::Button | |||||
| || control.type == HmiControlType::Indicator) | |||||
| && control.binding->area() != RegisterArea::M) | |||||
| { | |||||
| setError(error, "按钮和指示灯必须绑定 M 区地址"); | |||||
| return false; | |||||
| } | |||||
| if (control.binding.has_value() | |||||
| && (control.type == HmiControlType::NumericDisplay | |||||
| || control.type == HmiControlType::NumericInput) | |||||
| && control.binding->area() != RegisterArea::D) | |||||
| { | |||||
| setError(error, "数值控件必须绑定 D 区地址"); | |||||
| return false; | |||||
| } | |||||
| if ((control.type == HmiControlType::Label | |||||
| || control.type == HmiControlType::PageJump) | |||||
| && control.binding.has_value()) | |||||
| { | |||||
| setError(error, "文本和页面跳转控件不能绑定寄存器"); | |||||
| return false; | |||||
| } | |||||
| if (control.type == HmiControlType::PageJump) | |||||
| if (control.type == HmiControlType::PageJump | |||||
| && !control.pageJump->targetPageId.empty() | |||||
| && findPage(control.pageJump->targetPageId) == nullptr) | |||||
| { | { | ||||
| if (!control.pageJump.has_value()) | |||||
| { | |||||
| setError(error, "页面跳转控件缺少跳转配置"); | |||||
| return false; | |||||
| } | |||||
| if (!control.pageJump->targetPageId.empty() | |||||
| && findPage(control.pageJump->targetPageId) == nullptr) | |||||
| { | |||||
| setError(error, "页面跳转控件的目标页面不存在"); | |||||
| return false; | |||||
| } | |||||
| } | |||||
| else if (control.pageJump.has_value()) | |||||
| { | |||||
| setError(error, "非页面跳转控件不能包含跳转配置"); | |||||
| setError(error, "页面跳转控件的目标页面不存在"); | |||||
| return false; | return false; | ||||
| } | } | ||||
| return true; | return true; | ||||
| @@ -576,18 +681,22 @@ bool HmiEditorService::hasDuplicateControlId( | |||||
| // 根据控件类型生成默认样式、文字、大小、初始坐标 | // 根据控件类型生成默认样式、文字、大小、初始坐标 | ||||
| HmiControl HmiEditorService::makeControl( | HmiControl HmiEditorService::makeControl( | ||||
| const HmiPage &page, HmiControlType type) | |||||
| const HmiPage &page, const HmiControlDescriptor &descriptor) | |||||
| { | { | ||||
| HmiControl control; | HmiControl control; | ||||
| control.id = makeUniqueId(page, controlPrefix(type)); | |||||
| control.type = type; | |||||
| control.text = defaultText(type); | |||||
| control.bounds.width = type == HmiControlType::Indicator ? 64 : 120; | |||||
| control.bounds.height = type == HmiControlType::Indicator ? 64 : 40; | |||||
| if (type == HmiControlType::PageJump) | |||||
| control.id = makeUniqueId(page, descriptor.idPrefix); | |||||
| control.type = descriptor.type; | |||||
| control.text = descriptor.defaultText; | |||||
| control.bounds.width = std::min(descriptor.defaultBounds.width, page.width); | |||||
| control.bounds.height = std::min(descriptor.defaultBounds.height, page.height); | |||||
| if (descriptor.type == HmiControlType::PageJump) | |||||
| { | { | ||||
| control.pageJump = HmiPageJumpConfig{}; | control.pageJump = HmiPageJumpConfig{}; | ||||
| } | } | ||||
| if (descriptor.type == HmiControlType::ProgressBar) | |||||
| { | |||||
| control.progressBar = HmiProgressBarConfig{}; | |||||
| } | |||||
| const int offset = static_cast<int>(page.controls.size()) * 16; | const int offset = static_cast<int>(page.controls.size()) * 16; | ||||
| control.bounds.x = std::min(20 + offset, page.width - control.bounds.width); | control.bounds.x = std::min(20 + offset, page.width - control.bounds.width); | ||||
| control.bounds.y = std::min(20 + offset, page.height - control.bounds.height); | control.bounds.y = std::min(20 + offset, page.height - control.bounds.height); | ||||
| @@ -8,10 +8,13 @@ | |||||
| #pragma once | #pragma once | ||||
| #include "domain/hmi_model.h" | #include "domain/hmi_model.h" | ||||
| #include "editor_history.h" | |||||
| #include <string> | #include <string> | ||||
| #include <vector> | |||||
| class ProjectService; | class ProjectService; | ||||
| struct HmiControlDescriptor; | |||||
| /** | /** | ||||
| * @brief HMI 编辑操作的失败分类 | * @brief HMI 编辑操作的失败分类 | ||||
| @@ -80,6 +83,8 @@ public: | |||||
| */ | */ | ||||
| HmiEditorResult ensureDefaultPage(); | HmiEditorResult ensureDefaultPage(); | ||||
| HmiEditorResult addPage(const std::string &name); | HmiEditorResult addPage(const std::string &name); | ||||
| HmiEditorResult resizePage( | |||||
| const std::string &page_id, int width, int height); | |||||
| HmiEditorResult renamePage( | HmiEditorResult renamePage( | ||||
| const std::string &page_id, const std::string &name); | const std::string &page_id, const std::string &name); | ||||
| HmiEditorResult removePage(const std::string &page_id); | HmiEditorResult removePage(const std::string &page_id); | ||||
| @@ -103,6 +108,9 @@ public: | |||||
| */ | */ | ||||
| HmiEditorResult removeControl( | HmiEditorResult removeControl( | ||||
| const std::string &page_id, const std::string &control_id); | const std::string &page_id, const std::string &control_id); | ||||
| HmiEditorResult removeControls( | |||||
| const std::string &page_id, | |||||
| const std::vector<std::string> &control_ids); | |||||
| /** | /** | ||||
| * @brief 更新控件位置和尺寸 | * @brief 更新控件位置和尺寸 | ||||
| * @param page_id 所属页面唯一标识 | * @param page_id 所属页面唯一标识 | ||||
| @@ -128,7 +136,28 @@ public: | |||||
| const std::string &control_id, | const std::string &control_id, | ||||
| const HmiControl &control); | const HmiControl &control); | ||||
| bool canUndo() const; | |||||
| bool canRedo() const; | |||||
| HmiEditorResult undo(); | |||||
| HmiEditorResult redo(); | |||||
| void clearHistory(); | |||||
| private: | private: | ||||
| struct HistoryState | |||||
| { | |||||
| std::vector<HmiPage> pages; | |||||
| std::string initial_page_id; | |||||
| }; | |||||
| HistoryState captureState() const; | |||||
| void recordHistory(HistoryState before); | |||||
| static bool statesEqual( | |||||
| const HistoryState &left, const HistoryState &right); | |||||
| static bool controlsEqual( | |||||
| const HmiControl &left, const HmiControl &right); | |||||
| static bool pagesEqual(const HmiPage &left, const HmiPage &right); | |||||
| static HmiEditorResult historyFailure(const std::string &message); | |||||
| /** | /** | ||||
| * @brief 校验控件编辑后仍满足页面内的基础约束 | * @brief 校验控件编辑后仍满足页面内的基础约束 | ||||
| * @param page 控件所属页面 | * @param page 控件所属页面 | ||||
| @@ -158,7 +187,7 @@ private: | |||||
| * @return 未绑定寄存器的默认控件配置 | * @return 未绑定寄存器的默认控件配置 | ||||
| */ | */ | ||||
| static HmiControl makeControl( | static HmiControl makeControl( | ||||
| const HmiPage &page, HmiControlType type); | |||||
| const HmiPage &page, const HmiControlDescriptor &descriptor); | |||||
| /** | /** | ||||
| * @brief 在页面内生成带指定前缀的唯一控件标识 | * @brief 在页面内生成带指定前缀的唯一控件标识 | ||||
| * @param page 待检查页面 | * @param page 待检查页面 | ||||
| @@ -172,4 +201,5 @@ private: | |||||
| * @brief 非拥有的工程服务依赖,由应用入口保证生命周期 | * @brief 非拥有的工程服务依赖,由应用入口保证生命周期 | ||||
| */ | */ | ||||
| ProjectService &project_service_; | ProjectService &project_service_; | ||||
| EditorHistory<HistoryState> history_; | |||||
| }; | }; | ||||
| @@ -1,5 +1,7 @@ | |||||
| #include "hmi_runtime_service.h" | #include "hmi_runtime_service.h" | ||||
| #include "domain/hmi_control_registry.h" | |||||
| namespace { | namespace { | ||||
| HmiRuntimeReadResult readFailure(HmiRuntimeError error) | HmiRuntimeReadResult readFailure(HmiRuntimeError error) | ||||
| @@ -12,17 +14,6 @@ HmiRuntimeWriteResult writeFailure(HmiRuntimeError error) | |||||
| return {false, error}; | return {false, error}; | ||||
| } | } | ||||
| bool isBitControl(HmiControlType type) | |||||
| { | |||||
| return type == HmiControlType::Button || type == HmiControlType::Indicator; | |||||
| } | |||||
| bool isWordControl(HmiControlType type) | |||||
| { | |||||
| return type == HmiControlType::NumericDisplay | |||||
| || type == HmiControlType::NumericInput; | |||||
| } | |||||
| } // namespace | } // namespace | ||||
| HmiRuntimeService::HmiRuntimeService(RegisterRepository &repository) | HmiRuntimeService::HmiRuntimeService(RegisterRepository &repository) | ||||
| @@ -32,8 +23,10 @@ HmiRuntimeService::HmiRuntimeService(RegisterRepository &repository) | |||||
| HmiRuntimeReadResult HmiRuntimeService::readControl(const HmiControl &control) const | HmiRuntimeReadResult HmiRuntimeService::readControl(const HmiControl &control) const | ||||
| { | { | ||||
| // 标签等静态控件不参与寄存器读写 | |||||
| if (!isBitControl(control.type) && !isWordControl(control.type)) | |||||
| const HmiControlDescriptor *descriptor = | |||||
| findHmiControlDescriptor(control.type); | |||||
| if (descriptor == nullptr | |||||
| || descriptor->runtimeValueKind == HmiRuntimeValueKind::None) | |||||
| { | { | ||||
| return readFailure(HmiRuntimeError::UnsupportedControl); | return readFailure(HmiRuntimeError::UnsupportedControl); | ||||
| } | } | ||||
| @@ -45,7 +38,16 @@ HmiRuntimeReadResult HmiRuntimeService::readControl(const HmiControl &control) c | |||||
| { | { | ||||
| return readFailure(HmiRuntimeError::InvalidBinding); | return readFailure(HmiRuntimeError::InvalidBinding); | ||||
| } | } | ||||
| if (isBitControl(control.type)) | |||||
| const std::optional<RegisterArea> binding_area = | |||||
| hmiBindingArea(descriptor->bindingKind); | |||||
| if (!binding_area.has_value() || control.binding->area() != *binding_area) | |||||
| { | |||||
| return readFailure(HmiRuntimeError::InvalidBinding); | |||||
| } | |||||
| switch (descriptor->runtimeValueKind) | |||||
| { | |||||
| case HmiRuntimeValueKind::Bit: | |||||
| { | { | ||||
| // 按控件类型选择仓库的 M 位读取契约 | // 按控件类型选择仓库的 M 位读取契约 | ||||
| const BitReadResult result = repository_.readBit(*control.binding); | const BitReadResult result = repository_.readBit(*control.binding); | ||||
| @@ -55,7 +57,7 @@ HmiRuntimeReadResult HmiRuntimeService::readControl(const HmiControl &control) c | |||||
| } | } | ||||
| return {true, HmiRuntimeError::None, result.value, 0}; | return {true, HmiRuntimeError::None, result.value, 0}; | ||||
| } | } | ||||
| if (isWordControl(control.type)) | |||||
| case HmiRuntimeValueKind::Word: | |||||
| { | { | ||||
| const WordReadResult result = repository_.readWord(*control.binding); | const WordReadResult result = repository_.readWord(*control.binding); | ||||
| if (!result.succeeded) | if (!result.succeeded) | ||||
| @@ -64,7 +66,12 @@ HmiRuntimeReadResult HmiRuntimeService::readControl(const HmiControl &control) c | |||||
| } | } | ||||
| return {true, HmiRuntimeError::None, false, result.value}; | return {true, HmiRuntimeError::None, false, result.value}; | ||||
| } | } | ||||
| return readFailure(HmiRuntimeError::UnsupportedControl); | |||||
| case HmiRuntimeValueKind::None: | |||||
| default: | |||||
| { | |||||
| return readFailure(HmiRuntimeError::UnsupportedControl); | |||||
| } | |||||
| } | |||||
| } | } | ||||
| HmiRuntimeWriteResult HmiRuntimeService::operateButton( | HmiRuntimeWriteResult HmiRuntimeService::operateButton( | ||||
| @@ -1,6 +1,7 @@ | |||||
| #pragma once | #pragma once | ||||
| #include "domain/control_logic_model.h" | #include "domain/control_logic_model.h" | ||||
| #include "editor_history.h" | |||||
| #include <string> | #include <string> | ||||
| #include <vector> | #include <vector> | ||||
| @@ -47,6 +48,7 @@ public: | |||||
| std::string firstRungId(const std::string &logic_id) const; | std::string firstRungId(const std::string &logic_id) const; | ||||
| std::string rungIdForNode( | std::string rungIdForNode( | ||||
| const std::string &logic_id, const std::string &node_id) const; | const std::string &logic_id, const std::string &node_id) const; | ||||
| std::string registerCommentFor(const RegisterAddress &address) const; | |||||
| LogicEditorResult ensureDefaultLogic(); | LogicEditorResult ensureDefaultLogic(); | ||||
| LogicEditorResult addLogic(const std::string &name); | LogicEditorResult addLogic(const std::string &name); | ||||
| @@ -58,45 +60,115 @@ public: | |||||
| LogicEditorResult addRung(const std::string &logic_id); | LogicEditorResult addRung(const std::string &logic_id); | ||||
| LogicEditorResult removeRung( | LogicEditorResult removeRung( | ||||
| const std::string &logic_id, const std::string &rung_id); | const std::string &logic_id, const std::string &rung_id); | ||||
| LogicEditorResult updateRungComment( | |||||
| const std::string &logic_id, | |||||
| const std::string &rung_id, | |||||
| const std::string &comment); | |||||
| LogicEditorResult appendCondition( | LogicEditorResult appendCondition( | ||||
| const std::string &logic_id, | const std::string &logic_id, | ||||
| const std::string &rung_id, | const std::string &rung_id, | ||||
| const LogicNodeConfig &config); | const LogicNodeConfig &config); | ||||
| LogicEditorResult insertConditionAtColumn( | |||||
| const std::string &logic_id, | |||||
| const std::string &rung_id, | |||||
| int column, | |||||
| const LogicNodeConfig &config); | |||||
| LogicEditorResult appendWire( | |||||
| const std::string &logic_id, | |||||
| const std::string &rung_id, | |||||
| int column_span = 1); | |||||
| LogicEditorResult insertConditionAfter( | LogicEditorResult insertConditionAfter( | ||||
| const std::string &logic_id, | const std::string &logic_id, | ||||
| const std::string &rung_id, | const std::string &rung_id, | ||||
| const std::string &target_node_id, | const std::string &target_node_id, | ||||
| const LogicNodeConfig &config); | const LogicNodeConfig &config); | ||||
| LogicEditorResult insertWireAfter( | |||||
| const std::string &logic_id, | |||||
| const std::string &rung_id, | |||||
| const std::string &target_expression_id, | |||||
| int column_span = 1); | |||||
| LogicEditorResult replaceWireWithCondition( | |||||
| const std::string &logic_id, | |||||
| const std::string &rung_id, | |||||
| const std::string &wire_expression_id, | |||||
| const LogicNodeConfig &config); | |||||
| LogicEditorResult replaceWireColumnWithCondition( | |||||
| const std::string &logic_id, | |||||
| const std::string &rung_id, | |||||
| const std::string &wire_expression_id, | |||||
| int column_offset, | |||||
| const LogicNodeConfig &config); | |||||
| LogicEditorResult addParallelBranch( | LogicEditorResult addParallelBranch( | ||||
| const std::string &logic_id, | const std::string &logic_id, | ||||
| const std::string &rung_id, | const std::string &rung_id, | ||||
| const std::vector<std::string> &selected_node_ids, | const std::vector<std::string> &selected_node_ids, | ||||
| const LogicNodeConfig &config); | const LogicNodeConfig &config); | ||||
| LogicEditorResult addParallelWireBranch( | |||||
| const std::string &logic_id, | |||||
| const std::string &rung_id, | |||||
| const std::vector<std::string> &selected_expression_ids); | |||||
| LogicEditorResult setOutput( | LogicEditorResult setOutput( | ||||
| const std::string &logic_id, | const std::string &logic_id, | ||||
| const std::string &rung_id, | const std::string &rung_id, | ||||
| const LogicNodeConfig &config); | |||||
| const LogicNodeConfig &config, | |||||
| bool configured = false); | |||||
| LogicEditorResult updateNodeConfig( | LogicEditorResult updateNodeConfig( | ||||
| const std::string &logic_id, | const std::string &logic_id, | ||||
| const std::string &node_id, | const std::string &node_id, | ||||
| const LogicNodeConfig &config); | const LogicNodeConfig &config); | ||||
| LogicEditorResult removeNode( | LogicEditorResult removeNode( | ||||
| const std::string &logic_id, const std::string &node_id); | const std::string &logic_id, const std::string &node_id); | ||||
| LogicEditorResult removeNodes( | |||||
| const std::string &logic_id, | |||||
| const std::vector<std::string> &node_ids); | |||||
| LogicEditorResult removeExpression( | LogicEditorResult removeExpression( | ||||
| const std::string &logic_id, | const std::string &logic_id, | ||||
| const std::string &rung_id, | const std::string &rung_id, | ||||
| const std::string &expression_id); | const std::string &expression_id); | ||||
| LogicEditorResult removeExpressions( | |||||
| const std::string &logic_id, | |||||
| const std::string &rung_id, | |||||
| const std::vector<std::string> &expression_ids); | |||||
| bool canUndo() const; | |||||
| bool canRedo() const; | |||||
| LogicEditorResult undo(); | |||||
| LogicEditorResult redo(); | |||||
| void clearHistory(); | |||||
| private: | private: | ||||
| struct HistoryState | |||||
| { | |||||
| std::vector<ControlLogic> logics; | |||||
| }; | |||||
| HistoryState captureState() const; | |||||
| void recordHistory(HistoryState before); | |||||
| void rollbackEdit(HistoryState before, bool modified_before); | |||||
| static bool statesEqual( | |||||
| const HistoryState &left, const HistoryState &right); | |||||
| static bool logicsEqual( | |||||
| const ControlLogic &left, const ControlLogic &right); | |||||
| static bool rungsEqual( | |||||
| const LadderRung &left, const LadderRung &right); | |||||
| static bool expressionsEqual( | |||||
| const ConditionExpression &left, const ConditionExpression &right); | |||||
| static bool nodesEqual(const LogicNode &left, const LogicNode &right); | |||||
| static bool configsEqual( | |||||
| const LogicNodeConfig &left, const LogicNodeConfig &right); | |||||
| static LogicEditorResult historyFailure(const std::string &message); | |||||
| static bool isConditionConfig(const LogicNodeConfig &config); | static bool isConditionConfig(const LogicNodeConfig &config); | ||||
| static bool isOutputConfig(const LogicNodeConfig &config); | static bool isOutputConfig(const LogicNodeConfig &config); | ||||
| static std::string nodePrefix(const LogicNodeConfig &config); | static std::string nodePrefix(const LogicNodeConfig &config); | ||||
| static std::string makeUniqueNodeId( | static std::string makeUniqueNodeId( | ||||
| const ControlLogic &logic, const std::string &prefix); | const ControlLogic &logic, const std::string &prefix); | ||||
| static std::string makeUniqueWireId(const ControlLogic &logic); | |||||
| static std::string makeUniqueExpressionId(const ControlLogic &logic); | static std::string makeUniqueExpressionId(const ControlLogic &logic); | ||||
| static std::string makeUniqueRungId(const ControlLogic &logic); | static std::string makeUniqueRungId(const ControlLogic &logic); | ||||
| static LogicEditorResult failure( | static LogicEditorResult failure( | ||||
| LogicEditorError error, const std::string &message); | LogicEditorError error, const std::string &message); | ||||
| ProjectService &project_service_; | ProjectService &project_service_; | ||||
| EditorHistory<HistoryState> history_; | |||||
| }; | }; | ||||
| @@ -31,6 +31,7 @@ SimulationStartResult OfflineSimulationService::start( | |||||
| } | } | ||||
| timer_.stop(); | timer_.stop(); | ||||
| executor_.resetRuntime(); | |||||
| repository_.clear(); | repository_.clear(); | ||||
| logic_snapshot_ = std::move(snapshot); | logic_snapshot_ = std::move(snapshot); | ||||
| successful_scan_count_ = 0; | successful_scan_count_ = 0; | ||||
| @@ -45,6 +46,7 @@ SimulationStartResult OfflineSimulationService::start( | |||||
| void OfflineSimulationService::stop() | void OfflineSimulationService::stop() | ||||
| { | { | ||||
| timer_.stop(); | timer_.stop(); | ||||
| executor_.resetRuntime(); | |||||
| logic_snapshot_.clear(); | logic_snapshot_.clear(); | ||||
| if (state_ == SimulationState::Stopped) | if (state_ == SimulationState::Stopped) | ||||
| { | { | ||||
| @@ -111,6 +113,7 @@ void OfflineSimulationService::handleTimeout() | |||||
| void OfflineSimulationService::enterFault(const LogicScanResult &error) | void OfflineSimulationService::enterFault(const LogicScanResult &error) | ||||
| { | { | ||||
| timer_.stop(); | timer_.stop(); | ||||
| executor_.resetRuntime(); | |||||
| last_error_ = error; | last_error_ = error; | ||||
| state_ = SimulationState::Faulted; | state_ = SimulationState::Faulted; | ||||
| emit stateChanged(); | emit stateChanged(); | ||||
| @@ -1,7 +1,10 @@ | |||||
| #pragma once | #pragma once | ||||
| #include "domain/register_address.h" | #include "domain/register_address.h" | ||||
| #include "domain/project_limits.h" | |||||
| #include <algorithm> | |||||
| #include <cctype> | |||||
| #include <functional> | #include <functional> | ||||
| #include <string> | #include <string> | ||||
| #include <vector> | #include <vector> | ||||
| @@ -50,6 +53,61 @@ struct PlcCommunicationResult | |||||
| std::string message; | std::string message; | ||||
| }; | }; | ||||
| inline PlcCommunicationResult validatePlcSerialConfiguration( | |||||
| const PlcSerialConfiguration &configuration) | |||||
| { | |||||
| const bool has_port_name = std::any_of( | |||||
| configuration.portName.cbegin(), configuration.portName.cend(), | |||||
| [](unsigned char character) { return std::isspace(character) == 0; }); | |||||
| if (!has_port_name) | |||||
| { | |||||
| return {false, "必须填写串口端口"}; | |||||
| } | |||||
| if (configuration.serverAddress < ProjectLimits::kMinimumPlcServerAddress | |||||
| || configuration.serverAddress > ProjectLimits::kMaximumPlcServerAddress) | |||||
| { | |||||
| return {false, "PLC 站号必须在 1~247 范围内"}; | |||||
| } | |||||
| if (configuration.baudRate != 9600 | |||||
| && configuration.baudRate != 19200 | |||||
| && configuration.baudRate != 38400 | |||||
| && configuration.baudRate != 57600 | |||||
| && configuration.baudRate != 115200) | |||||
| { | |||||
| return {false, "波特率只支持 9600、19200、38400、57600 或 115200"}; | |||||
| } | |||||
| if (configuration.dataBits != 7 && configuration.dataBits != 8) | |||||
| { | |||||
| return {false, "数据位只支持 7 或 8"}; | |||||
| } | |||||
| if (configuration.parity != 0 | |||||
| && configuration.parity != 2 | |||||
| && configuration.parity != 3) | |||||
| { | |||||
| return {false, "校验方式只支持无校验、偶校验或奇校验"}; | |||||
| } | |||||
| if (configuration.stopBits != 1 && configuration.stopBits != 2) | |||||
| { | |||||
| return {false, "停止位只支持 1 或 2"}; | |||||
| } | |||||
| if (configuration.responseTimeoutMs < ProjectLimits::kMinimumResponseTimeoutMs | |||||
| || configuration.responseTimeoutMs > ProjectLimits::kMaximumResponseTimeoutMs) | |||||
| { | |||||
| return {false, "PLC 响应超时必须在 100~30000 ms 范围内"}; | |||||
| } | |||||
| if (configuration.retries < ProjectLimits::kMinimumRetries | |||||
| || configuration.retries > ProjectLimits::kMaximumRetries) | |||||
| { | |||||
| return {false, "PLC 失败重试次数必须在 0~5 范围内"}; | |||||
| } | |||||
| if (configuration.pollIntervalMs < ProjectLimits::kMinimumPollIntervalMs | |||||
| || configuration.pollIntervalMs > ProjectLimits::kMaximumPollIntervalMs) | |||||
| { | |||||
| return {false, "PLC 轮询周期必须在 50~10000 ms 范围内"}; | |||||
| } | |||||
| return {true, {}}; | |||||
| } | |||||
| class PlcCommunicationGateway | class PlcCommunicationGateway | ||||
| { | { | ||||
| public: | public: | ||||
| @@ -58,7 +116,7 @@ public: | |||||
| virtual PlcCommunicationResult connectDevice( | virtual PlcCommunicationResult connectDevice( | ||||
| const PlcSerialConfiguration &configuration) = 0; | const PlcSerialConfiguration &configuration) = 0; | ||||
| virtual void disconnectDevice() = 0; | virtual void disconnectDevice() = 0; | ||||
| virtual void setPollAddresses( | |||||
| virtual PlcCommunicationResult setPollAddresses( | |||||
| const std::vector<RegisterAddress> &addresses) = 0; | const std::vector<RegisterAddress> &addresses) = 0; | ||||
| virtual PlcConnectionState state() const = 0; | virtual PlcConnectionState state() const = 0; | ||||
| virtual bool initialReadCompleted() const = 0; | virtual bool initialReadCompleted() const = 0; | ||||
| @@ -57,6 +57,11 @@ bool ProjectService::isModified() const | |||||
| return modified_; | return modified_; | ||||
| } | } | ||||
| void ProjectService::restoreModifiedState(bool modified) | |||||
| { | |||||
| modified_ = modified; | |||||
| } | |||||
| ProjectOperationResult ProjectService::createNewProject(const std::string &name) | ProjectOperationResult ProjectService::createNewProject(const std::string &name) | ||||
| { | { | ||||
| if (isBlank(name)) | if (isBlank(name)) | ||||
| @@ -35,6 +35,8 @@ public: | |||||
| const std::string ¤tFilePath() const; | const std::string ¤tFilePath() const; | ||||
| bool hasCurrentFile() const; | bool hasCurrentFile() const; | ||||
| bool isModified() const; | bool isModified() const; | ||||
| // 失败编辑回滚时恢复进入编辑前的脏状态 | |||||
| void restoreModifiedState(bool modified); | |||||
| /** | /** | ||||
| * @brief 创建未保存的新工程 | * @brief 创建未保存的新工程 | ||||
| @@ -0,0 +1,102 @@ | |||||
| #include "register_comment_service.h" | |||||
| #include "project_service.h" | |||||
| #include <algorithm> | |||||
| #include <cctype> | |||||
| #include <utility> | |||||
| namespace { | |||||
| std::string trim(const std::string &value) | |||||
| { | |||||
| const auto first = std::find_if_not( | |||||
| value.cbegin(), value.cend(), | |||||
| [](unsigned char character) { return std::isspace(character) != 0; }); | |||||
| const auto last = std::find_if_not( | |||||
| value.crbegin(), value.crend(), | |||||
| [](unsigned char character) { return std::isspace(character) != 0; }).base(); | |||||
| return first >= last ? std::string{} : std::string(first, last); | |||||
| } | |||||
| RegisterCommentResult failure( | |||||
| RegisterCommentError error, const std::string &message) | |||||
| { | |||||
| return {false, error, message}; | |||||
| } | |||||
| } // namespace | |||||
| RegisterCommentService::RegisterCommentService(ProjectService &project_service) | |||||
| : project_service_(project_service) | |||||
| { | |||||
| } | |||||
| const std::vector<RegisterComment> &RegisterCommentService::comments() const | |||||
| { | |||||
| return project_service_.project().registerComments; | |||||
| } | |||||
| const RegisterComment *RegisterCommentService::findComment( | |||||
| const RegisterAddress &address) const | |||||
| { | |||||
| return project_service_.project().findRegisterComment(address); | |||||
| } | |||||
| RegisterCommentResult RegisterCommentService::setComment( | |||||
| const RegisterAddress &address, const std::string &text) | |||||
| { | |||||
| const std::string normalized = trim(text); | |||||
| RegisterComment candidate{address, normalized}; | |||||
| std::string error; | |||||
| if (!candidate.validate(&error)) | |||||
| { | |||||
| return failure(RegisterCommentError::InvalidComment, error); | |||||
| } | |||||
| Project &project = project_service_.editProject(); | |||||
| auto existing = std::find_if( | |||||
| project.registerComments.begin(), project.registerComments.end(), | |||||
| [&address](const RegisterComment &comment) | |||||
| { | |||||
| return comment.address == address; | |||||
| }); | |||||
| if (existing == project.registerComments.end()) | |||||
| { | |||||
| project.registerComments.push_back(std::move(candidate)); | |||||
| } | |||||
| else | |||||
| { | |||||
| existing->text = normalized; | |||||
| } | |||||
| std::sort( | |||||
| project.registerComments.begin(), project.registerComments.end(), | |||||
| [](const RegisterComment &left, const RegisterComment &right) | |||||
| { | |||||
| if (left.address.area() != right.address.area()) | |||||
| { | |||||
| return left.address.area() == RegisterArea::M; | |||||
| } | |||||
| return left.address.index() < right.address.index(); | |||||
| }); | |||||
| return {true, RegisterCommentError::None, {}}; | |||||
| } | |||||
| RegisterCommentResult RegisterCommentService::removeComment( | |||||
| const RegisterAddress &address) | |||||
| { | |||||
| if (findComment(address) == nullptr) | |||||
| { | |||||
| return failure(RegisterCommentError::NotFound, "未找到软元件注释"); | |||||
| } | |||||
| Project &project = project_service_.editProject(); | |||||
| project.registerComments.erase( | |||||
| std::remove_if( | |||||
| project.registerComments.begin(), project.registerComments.end(), | |||||
| [&address](const RegisterComment &comment) | |||||
| { | |||||
| return comment.address == address; | |||||
| }), | |||||
| project.registerComments.end()); | |||||
| return {true, RegisterCommentError::None, {}}; | |||||
| } | |||||
| @@ -0,0 +1,37 @@ | |||||
| #pragma once | |||||
| #include "domain/project_model.h" | |||||
| #include <string> | |||||
| #include <vector> | |||||
| class ProjectService; | |||||
| enum class RegisterCommentError | |||||
| { | |||||
| None, | |||||
| InvalidComment, | |||||
| NotFound | |||||
| }; | |||||
| struct RegisterCommentResult | |||||
| { | |||||
| bool succeeded = false; | |||||
| RegisterCommentError error = RegisterCommentError::None; | |||||
| std::string message; | |||||
| }; | |||||
| class RegisterCommentService | |||||
| { | |||||
| public: | |||||
| explicit RegisterCommentService(ProjectService &project_service); | |||||
| const std::vector<RegisterComment> &comments() const; | |||||
| const RegisterComment *findComment(const RegisterAddress &address) const; | |||||
| RegisterCommentResult setComment( | |||||
| const RegisterAddress &address, const std::string &text); | |||||
| RegisterCommentResult removeComment(const RegisterAddress &address); | |||||
| private: | |||||
| ProjectService &project_service_; | |||||
| }; | |||||
| @@ -112,6 +112,40 @@ RegisterMonitorResult RegisterMonitorService::clear() | |||||
| return {true, RegisterMonitorError::None, {}, removed_count}; | return {true, RegisterMonitorError::None, {}, removed_count}; | ||||
| } | } | ||||
| RegisterMonitorWriteResult RegisterMonitorService::writeBit( | |||||
| const RegisterAddress &address, bool value) | |||||
| { | |||||
| const RegisterWriteResult result = repository_.writeBit(address, value); | |||||
| if (result.succeeded) | |||||
| { | |||||
| return {true, RegisterError::None, {}}; | |||||
| } | |||||
| return {false, result.error, result.error == RegisterError::AreaMismatch | |||||
| ? "位写入只允许访问 M 区" | |||||
| : result.error == RegisterError::InvalidAddress | |||||
| ? "写入地址无效" | |||||
| : result.error == RegisterError::Unavailable | |||||
| ? "当前寄存器源不可用" | |||||
| : "寄存器写入请求被拒绝"}; | |||||
| } | |||||
| RegisterMonitorWriteResult RegisterMonitorService::writeWord( | |||||
| const RegisterAddress &address, std::int16_t value) | |||||
| { | |||||
| const RegisterWriteResult result = repository_.writeWord(address, value); | |||||
| if (result.succeeded) | |||||
| { | |||||
| return {true, RegisterError::None, {}}; | |||||
| } | |||||
| return {false, result.error, result.error == RegisterError::AreaMismatch | |||||
| ? "字写入只允许访问 D 区" | |||||
| : result.error == RegisterError::InvalidAddress | |||||
| ? "写入地址无效" | |||||
| : result.error == RegisterError::Unavailable | |||||
| ? "当前寄存器源不可用" | |||||
| : "寄存器写入请求被拒绝"}; | |||||
| } | |||||
| const std::vector<RegisterAddress> &RegisterMonitorService::addresses() const | const std::vector<RegisterAddress> &RegisterMonitorService::addresses() const | ||||
| { | { | ||||
| return model_.addresses(); | return model_.addresses(); | ||||
| @@ -3,6 +3,7 @@ | |||||
| #include "domain/register_monitor_model.h" | #include "domain/register_monitor_model.h" | ||||
| #include "domain/register_repository.h" | #include "domain/register_repository.h" | ||||
| #include <cstdint> | |||||
| #include <functional> | #include <functional> | ||||
| #include <string> | #include <string> | ||||
| #include <vector> | #include <vector> | ||||
| @@ -24,6 +25,13 @@ struct RegisterMonitorResult | |||||
| int affectedCount = 0; | int affectedCount = 0; | ||||
| }; | }; | ||||
| struct RegisterMonitorWriteResult | |||||
| { | |||||
| bool succeeded = false; | |||||
| RegisterError error = RegisterError::Unavailable; | |||||
| std::string message; | |||||
| }; | |||||
| class RegisterMonitorService | class RegisterMonitorService | ||||
| { | { | ||||
| public: | public: | ||||
| @@ -32,6 +40,9 @@ public: | |||||
| RegisterMonitorResult addRange(const std::string &start_address, int count); | RegisterMonitorResult addRange(const std::string &start_address, int count); | ||||
| RegisterMonitorResult remove(const std::vector<RegisterAddress> &addresses); | RegisterMonitorResult remove(const std::vector<RegisterAddress> &addresses); | ||||
| RegisterMonitorResult clear(); | RegisterMonitorResult clear(); | ||||
| RegisterMonitorWriteResult writeBit(const RegisterAddress &address, bool value); | |||||
| RegisterMonitorWriteResult writeWord( | |||||
| const RegisterAddress &address, std::int16_t value); | |||||
| const std::vector<RegisterAddress> &addresses() const; | const std::vector<RegisterAddress> &addresses() const; | ||||
| std::vector<MonitorValue> values(bool communication_fault) const; | std::vector<MonitorValue> values(bool communication_fault) const; | ||||
| void setAddressesChangedCallback(std::function<void()> callback); | void setAddressesChangedCallback(std::function<void()> callback); | ||||
| @@ -10,6 +10,7 @@ | |||||
| #include "project_service.h" | #include "project_service.h" | ||||
| #include "domain/active_register_repository.h" | #include "domain/active_register_repository.h" | ||||
| #include "domain/project_limits.h" | |||||
| #include <algorithm> | #include <algorithm> | ||||
| @@ -189,7 +190,11 @@ PlcCommunicationResult RuntimeModeService::connectPlc( | |||||
| { | { | ||||
| plc_gateway_->disconnectDevice(); | plc_gateway_->disconnectDevice(); | ||||
| } | } | ||||
| refreshPlcPollAddresses(); | |||||
| const PlcCommunicationResult poll_result = refreshPlcPollAddresses(); | |||||
| if (!poll_result.succeeded) | |||||
| { | |||||
| return poll_result; | |||||
| } | |||||
| setInitialPlcReadCompleted(false); | setInitialPlcReadCompleted(false); | ||||
| return plc_gateway_->connectDevice(configuration); | return plc_gateway_->connectDevice(configuration); | ||||
| } | } | ||||
| @@ -201,11 +206,11 @@ void RuntimeModeService::setMonitorAddresses( | |||||
| refreshPlcPollAddresses(); | refreshPlcPollAddresses(); | ||||
| } | } | ||||
| void RuntimeModeService::refreshPlcPollAddresses() | |||||
| PlcCommunicationResult RuntimeModeService::refreshPlcPollAddresses() | |||||
| { | { | ||||
| if (plc_gateway_ == nullptr) | if (plc_gateway_ == nullptr) | ||||
| { | { | ||||
| return; | |||||
| return {false, "PLC 通信服务尚未配置"}; | |||||
| } | } | ||||
| std::vector<RegisterAddress> addresses = monitor_addresses_; | std::vector<RegisterAddress> addresses = monitor_addresses_; | ||||
| const Project &project = project_service_.project(); | const Project &project = project_service_.project(); | ||||
| @@ -219,6 +224,10 @@ void RuntimeModeService::refreshPlcPollAddresses() | |||||
| } | } | ||||
| } | } | ||||
| } | } | ||||
| for (const AlarmDefinition &definition : project.alarmDefinitions) | |||||
| { | |||||
| addresses.push_back(definition.address); | |||||
| } | |||||
| for (const ControlLogic &logic : project.controlLogics) | for (const ControlLogic &logic : project.controlLogics) | ||||
| { | { | ||||
| for (const LadderRung &rung : logic.rungs) | for (const LadderRung &rung : logic.rungs) | ||||
| @@ -234,9 +243,7 @@ void RuntimeModeService::refreshPlcPollAddresses() | |||||
| } | } | ||||
| for (const LogicNode *node : nodes) | for (const LogicNode *node : nodes) | ||||
| { | { | ||||
| std::visit( | |||||
| [&addresses](const auto &config) { addresses.push_back(config.address); }, | |||||
| node->config); | |||||
| collectRegisterAddressesForLogicNode(node->config, &addresses); | |||||
| } | } | ||||
| } | } | ||||
| } | } | ||||
| @@ -256,7 +263,11 @@ void RuntimeModeService::refreshPlcPollAddresses() | |||||
| return left.index() < right.index(); | return left.index() < right.index(); | ||||
| }); | }); | ||||
| addresses.erase(std::unique(addresses.begin(), addresses.end()), addresses.end()); | addresses.erase(std::unique(addresses.begin(), addresses.end()), addresses.end()); | ||||
| plc_gateway_->setPollAddresses(addresses); | |||||
| if (addresses.size() > ProjectLimits::kMaximumPollAddresses) | |||||
| { | |||||
| return {false, "PLC 轮询的去重 M/D 地址最多为 1024 个"}; | |||||
| } | |||||
| return plc_gateway_->setPollAddresses(addresses); | |||||
| } | } | ||||
| void RuntimeModeService::disconnectPlc() | void RuntimeModeService::disconnectPlc() | ||||
| @@ -76,7 +76,7 @@ public: | |||||
| RegisterRepository &plc_repository); | RegisterRepository &plc_repository); | ||||
| PlcCommunicationResult connectPlc(const PlcSerialConfiguration &configuration); | PlcCommunicationResult connectPlc(const PlcSerialConfiguration &configuration); | ||||
| void setMonitorAddresses(const std::vector<RegisterAddress> &addresses); | void setMonitorAddresses(const std::vector<RegisterAddress> &addresses); | ||||
| void refreshPlcPollAddresses(); | |||||
| PlcCommunicationResult refreshPlcPollAddresses(); | |||||
| void disconnectPlc(); | void disconnectPlc(); | ||||
| PlcConnectionState plcConnectionState() const; | PlcConnectionState plcConnectionState() const; | ||||
| const std::string &plcError() const; | const std::string &plcError() const; | ||||
| @@ -1,6 +1,7 @@ | |||||
| #include "software_logic_executor.h" | #include "software_logic_executor.h" | ||||
| #include <algorithm> | #include <algorithm> | ||||
| #include <limits> | |||||
| #include <map> | #include <map> | ||||
| #include <type_traits> | #include <type_traits> | ||||
| @@ -73,8 +74,14 @@ bool coilModesAreCompatible(CoilMode existing, CoilMode current) | |||||
| void LogicTraceValues::clear() | void LogicTraceValues::clear() | ||||
| { | { | ||||
| nodeValues.clear(); | nodeValues.clear(); | ||||
| nodePowerValues.clear(); | |||||
| expressionValues.clear(); | expressionValues.clear(); | ||||
| expressionInputValues.clear(); | |||||
| expressionPowerValues.clear(); | |||||
| rungValues.clear(); | rungValues.clear(); | ||||
| tonValues.clear(); | |||||
| counterValues.clear(); | |||||
| wordValues.clear(); | |||||
| } | } | ||||
| void LogicTraceSnapshot::clear() | void LogicTraceSnapshot::clear() | ||||
| @@ -91,8 +98,14 @@ LogicTraceSnapshot LogicTraceSnapshot::forLogic( | |||||
| if (values != logicValues.cend()) | if (values != logicValues.cend()) | ||||
| { | { | ||||
| projection.nodeValues = values->second.nodeValues; | projection.nodeValues = values->second.nodeValues; | ||||
| projection.nodePowerValues = values->second.nodePowerValues; | |||||
| projection.expressionValues = values->second.expressionValues; | projection.expressionValues = values->second.expressionValues; | ||||
| projection.expressionInputValues = values->second.expressionInputValues; | |||||
| projection.expressionPowerValues = values->second.expressionPowerValues; | |||||
| projection.rungValues = values->second.rungValues; | projection.rungValues = values->second.rungValues; | ||||
| projection.tonValues = values->second.tonValues; | |||||
| projection.counterValues = values->second.counterValues; | |||||
| projection.wordValues = values->second.wordValues; | |||||
| } | } | ||||
| return projection; | return projection; | ||||
| } | } | ||||
| @@ -131,9 +144,17 @@ LogicScanResult SoftwareLogicExecutor::validate( | |||||
| const auto *output = std::get_if<CoilNodeConfig>(&rung.output->config); | const auto *output = std::get_if<CoilNodeConfig>(&rung.output->config); | ||||
| if (output == nullptr) | if (output == nullptr) | ||||
| { | { | ||||
| if (std::holds_alternative<TonNodeConfig>(rung.output->config) | |||||
| || std::holds_alternative<CounterNodeConfig>(rung.output->config) | |||||
| || std::holds_alternative<MoveNodeConfig>(rung.output->config) | |||||
| || std::holds_alternative<ArithmeticNodeConfig>( | |||||
| rung.output->config)) | |||||
| { | |||||
| continue; | |||||
| } | |||||
| return failure( | return failure( | ||||
| LogicScanError::InvalidLogic, | LogicScanError::InvalidLogic, | ||||
| "梯形图输出不是线圈节点", | |||||
| "梯形图输出不是有效的输出指令", | |||||
| logic.id, | logic.id, | ||||
| rung.id, | rung.id, | ||||
| rung.output->id); | rung.output->id); | ||||
| @@ -152,13 +173,35 @@ LogicScanResult SoftwareLogicExecutor::validate( | |||||
| output_modes[output->address.index()] = output->mode; | output_modes[output->address.index()] = output->mode; | ||||
| } | } | ||||
| } | } | ||||
| std::string resource_error; | |||||
| if (!validateLogicResourceReferencesForRunning(logics, &resource_error)) | |||||
| { | |||||
| return failure(LogicScanError::InvalidLogic, resource_error); | |||||
| } | |||||
| return success(); | return success(); | ||||
| } | } | ||||
| void SoftwareLogicExecutor::resetRuntime() | |||||
| { | |||||
| previous_edge_inputs_.clear(); | |||||
| previous_counter_inputs_.clear(); | |||||
| ton_states_.clear(); | |||||
| counter_states_.clear(); | |||||
| } | |||||
| LogicScanResult SoftwareLogicExecutor::executeScan( | LogicScanResult SoftwareLogicExecutor::executeScan( | ||||
| const std::vector<ControlLogic> &logics, | const std::vector<ControlLogic> &logics, | ||||
| RegisterRepository &repository, | RegisterRepository &repository, | ||||
| LogicTraceSnapshot *trace) const | |||||
| LogicTraceSnapshot *trace) | |||||
| { | |||||
| return executeScanAt(logics, repository, Clock::now(), trace); | |||||
| } | |||||
| LogicScanResult SoftwareLogicExecutor::executeScanAt( | |||||
| const std::vector<ControlLogic> &logics, | |||||
| RegisterRepository &repository, | |||||
| TimePoint now, | |||||
| LogicTraceSnapshot *trace) | |||||
| { | { | ||||
| const LogicScanResult validation = validate(logics); | const LogicScanResult validation = validate(logics); | ||||
| if (!validation.succeeded) | if (!validation.succeeded) | ||||
| @@ -185,9 +228,19 @@ LogicScanResult SoftwareLogicExecutor::executeScan( | |||||
| continue; | continue; | ||||
| } | } | ||||
| bool rung_value = false; | |||||
| LogicScanResult result = evaluateExpression( | |||||
| *rung.condition, repository, logic_trace, &rung_value); | |||||
| // 没有条件节点的网络等价于左侧电源线直接接通,作为恒真条件执行 | |||||
| bool rung_value = true; | |||||
| LogicScanResult result = success(); | |||||
| if (rung.condition.has_value()) | |||||
| { | |||||
| result = evaluateExpression( | |||||
| logic.id, | |||||
| *rung.condition, | |||||
| repository, | |||||
| logic_trace, | |||||
| true, | |||||
| &rung_value); | |||||
| } | |||||
| if (!result.succeeded) | if (!result.succeeded) | ||||
| { | { | ||||
| result.logicId = logic.id; | result.logicId = logic.id; | ||||
| @@ -198,15 +251,28 @@ LogicScanResult SoftwareLogicExecutor::executeScan( | |||||
| { | { | ||||
| logic_trace->rungValues[rung.id] = rung_value; | logic_trace->rungValues[rung.id] = rung_value; | ||||
| logic_trace->nodeValues[rung.output->id] = rung_value; | logic_trace->nodeValues[rung.output->id] = rung_value; | ||||
| logic_trace->nodePowerValues[rung.output->id] = rung_value; | |||||
| } | } | ||||
| result = writeOutput( | |||||
| *rung.output, rung_value, repository); | |||||
| bool output_value = false; | |||||
| result = executeOutput( | |||||
| logic.id, | |||||
| *rung.output, | |||||
| rung_value, | |||||
| repository, | |||||
| now, | |||||
| logic_trace, | |||||
| &output_value); | |||||
| if (!result.succeeded) | if (!result.succeeded) | ||||
| { | { | ||||
| result.logicId = logic.id; | result.logicId = logic.id; | ||||
| result.rungId = rung.id; | result.rungId = rung.id; | ||||
| return result; | return result; | ||||
| } | } | ||||
| if (logic_trace != nullptr) | |||||
| { | |||||
| logic_trace->nodeValues[rung.output->id] = output_value; | |||||
| logic_trace->nodePowerValues[rung.output->id] = output_value; | |||||
| } | |||||
| } | } | ||||
| } | } | ||||
| if (trace != nullptr) | if (trace != nullptr) | ||||
| @@ -220,8 +286,14 @@ LogicScanResult SoftwareLogicExecutor::executeScan( | |||||
| if (values != trace->logicValues.cend()) | if (values != trace->logicValues.cend()) | ||||
| { | { | ||||
| trace->nodeValues = values->second.nodeValues; | trace->nodeValues = values->second.nodeValues; | ||||
| trace->nodePowerValues = values->second.nodePowerValues; | |||||
| trace->expressionValues = values->second.expressionValues; | trace->expressionValues = values->second.expressionValues; | ||||
| trace->expressionInputValues = values->second.expressionInputValues; | |||||
| trace->expressionPowerValues = values->second.expressionPowerValues; | |||||
| trace->rungValues = values->second.rungValues; | trace->rungValues = values->second.rungValues; | ||||
| trace->tonValues = values->second.tonValues; | |||||
| trace->counterValues = values->second.counterValues; | |||||
| trace->wordValues = values->second.wordValues; | |||||
| } | } | ||||
| } | } | ||||
| } | } | ||||
| @@ -229,50 +301,84 @@ LogicScanResult SoftwareLogicExecutor::executeScan( | |||||
| } | } | ||||
| LogicScanResult SoftwareLogicExecutor::evaluateExpression( | LogicScanResult SoftwareLogicExecutor::evaluateExpression( | ||||
| const std::string &logic_id, | |||||
| const ConditionExpression &expression, | const ConditionExpression &expression, | ||||
| RegisterRepository &repository, | RegisterRepository &repository, | ||||
| LogicTraceValues *trace, | LogicTraceValues *trace, | ||||
| bool *value) const | |||||
| bool input_power, | |||||
| bool *value) | |||||
| { | { | ||||
| if (value == nullptr) | if (value == nullptr) | ||||
| { | { | ||||
| return failure(LogicScanError::InvalidLogic, "缺少表达式结果接收对象"); | return failure(LogicScanError::InvalidLogic, "缺少表达式结果接收对象"); | ||||
| } | } | ||||
| if (trace != nullptr) | |||||
| { | |||||
| trace->expressionInputValues[expression.id] = input_power; | |||||
| } | |||||
| if (expression.kind == ConditionExpressionKind::Wire) | |||||
| { | |||||
| *value = true; | |||||
| if (trace != nullptr) | |||||
| { | |||||
| trace->expressionValues[expression.id] = true; | |||||
| trace->expressionPowerValues[expression.id] = input_power; | |||||
| } | |||||
| return success(); | |||||
| } | |||||
| if (expression.kind == ConditionExpressionKind::Node) | if (expression.kind == ConditionExpressionKind::Node) | ||||
| { | { | ||||
| LogicScanResult result = evaluateCondition(*expression.node, repository, value); | |||||
| LogicScanResult result = evaluateCondition( | |||||
| logic_id, *expression.node, repository, value); | |||||
| if (result.succeeded && trace != nullptr) | if (result.succeeded && trace != nullptr) | ||||
| { | { | ||||
| trace->nodeValues[expression.node->id] = *value; | trace->nodeValues[expression.node->id] = *value; | ||||
| trace->nodePowerValues[expression.node->id] = input_power && *value; | |||||
| trace->expressionValues[expression.id] = *value; | trace->expressionValues[expression.id] = *value; | ||||
| trace->expressionPowerValues[expression.id] = input_power && *value; | |||||
| } | } | ||||
| return result; | return result; | ||||
| } | } | ||||
| bool accumulated = expression.kind == ConditionExpressionKind::Series; | bool accumulated = expression.kind == ConditionExpressionKind::Series; | ||||
| bool power = input_power; | |||||
| for (const ConditionExpression &child : expression.children) | for (const ConditionExpression &child : expression.children) | ||||
| { | { | ||||
| bool child_value = false; | bool child_value = false; | ||||
| LogicScanResult result = evaluateExpression(child, repository, trace, &child_value); | |||||
| const bool child_input = expression.kind == ConditionExpressionKind::Series | |||||
| ? power : input_power; | |||||
| LogicScanResult result = evaluateExpression( | |||||
| logic_id, | |||||
| child, | |||||
| repository, | |||||
| trace, | |||||
| child_input, | |||||
| &child_value); | |||||
| if (!result.succeeded) | if (!result.succeeded) | ||||
| { | { | ||||
| return result; | return result; | ||||
| } | } | ||||
| accumulated = expression.kind == ConditionExpressionKind::Series | accumulated = expression.kind == ConditionExpressionKind::Series | ||||
| ? accumulated && child_value : accumulated || child_value; | ? accumulated && child_value : accumulated || child_value; | ||||
| if (expression.kind == ConditionExpressionKind::Series) | |||||
| { | |||||
| power = power && child_value; | |||||
| } | |||||
| } | } | ||||
| *value = accumulated; | *value = accumulated; | ||||
| if (trace != nullptr) | if (trace != nullptr) | ||||
| { | { | ||||
| trace->expressionValues[expression.id] = accumulated; | trace->expressionValues[expression.id] = accumulated; | ||||
| trace->expressionPowerValues[expression.id] = input_power && accumulated; | |||||
| } | } | ||||
| return success(); | return success(); | ||||
| } | } | ||||
| LogicScanResult SoftwareLogicExecutor::evaluateCondition( | LogicScanResult SoftwareLogicExecutor::evaluateCondition( | ||||
| const std::string &logic_id, | |||||
| const LogicNode &node, | const LogicNode &node, | ||||
| RegisterRepository &repository, | RegisterRepository &repository, | ||||
| bool *value) const | |||||
| bool *value) | |||||
| { | { | ||||
| if (value == nullptr) | if (value == nullptr) | ||||
| { | { | ||||
| @@ -285,7 +391,8 @@ LogicScanResult SoftwareLogicExecutor::evaluateCondition( | |||||
| } | } | ||||
| return std::visit( | return std::visit( | ||||
| [&repository, value, &node](const auto &config) -> LogicScanResult | |||||
| [this, &logic_id, &repository, value, &node]( | |||||
| const auto &config) -> LogicScanResult | |||||
| { | { | ||||
| using Config = std::decay_t<decltype(config)>; | using Config = std::decay_t<decltype(config)>; | ||||
| if constexpr (std::is_same_v<Config, ContactNodeConfig>) | if constexpr (std::is_same_v<Config, ContactNodeConfig>) | ||||
| @@ -304,6 +411,37 @@ LogicScanResult SoftwareLogicExecutor::evaluateCondition( | |||||
| ? read.value : !read.value; | ? read.value : !read.value; | ||||
| return success(); | return success(); | ||||
| } | } | ||||
| else if constexpr (std::is_same_v<Config, EdgeContactNodeConfig>) | |||||
| { | |||||
| const BitReadResult read = repository.readBit(config.address); | |||||
| if (!read.succeeded) | |||||
| { | |||||
| return failure( | |||||
| LogicScanError::RegisterReadFailed, | |||||
| "读取寄存器失败:" + config.address.toString(), | |||||
| {}, | |||||
| {}, | |||||
| node.id); | |||||
| } | |||||
| const auto runtime_key = std::make_pair(logic_id, node.id); | |||||
| const bool previous = previous_edge_inputs_[runtime_key]; | |||||
| *value = config.mode == EdgeMode::Rising | |||||
| ? read.value && !previous : !read.value && previous; | |||||
| previous_edge_inputs_[runtime_key] = read.value; | |||||
| return success(); | |||||
| } | |||||
| else if constexpr (std::is_same_v<Config, TimerContactNodeConfig>) | |||||
| { | |||||
| const bool done = ton_states_[config.address.index()].done; | |||||
| *value = config.mode == ContactMode::NormallyOpen ? done : !done; | |||||
| return success(); | |||||
| } | |||||
| else if constexpr (std::is_same_v<Config, CounterContactNodeConfig>) | |||||
| { | |||||
| const bool done = counter_states_[config.address.index()].done; | |||||
| *value = config.mode == ContactMode::NormallyOpen ? done : !done; | |||||
| return success(); | |||||
| } | |||||
| else if constexpr (std::is_same_v<Config, CompareNodeConfig>) | else if constexpr (std::is_same_v<Config, CompareNodeConfig>) | ||||
| { | { | ||||
| const WordReadResult read = repository.readWord(config.address); | const WordReadResult read = repository.readWord(config.address); | ||||
| @@ -323,7 +461,7 @@ LogicScanResult SoftwareLogicExecutor::evaluateCondition( | |||||
| { | { | ||||
| return failure( | return failure( | ||||
| LogicScanError::InvalidLogic, | LogicScanError::InvalidLogic, | ||||
| "条件节点不能包含输出线圈", | |||||
| "条件节点不能包含输出节点", | |||||
| {}, | {}, | ||||
| {}, | {}, | ||||
| node.id); | node.id); | ||||
| @@ -332,24 +470,291 @@ LogicScanResult SoftwareLogicExecutor::evaluateCondition( | |||||
| node.config); | node.config); | ||||
| } | } | ||||
| LogicScanResult SoftwareLogicExecutor::writeOutput( | |||||
| LogicScanResult SoftwareLogicExecutor::readWordOperand( | |||||
| const WordOperand &operand, | |||||
| RegisterRepository &repository, | |||||
| std::int16_t *value, | |||||
| const std::string &node_id) const | |||||
| { | |||||
| if (value == nullptr) | |||||
| { | |||||
| return failure( | |||||
| LogicScanError::InvalidLogic, | |||||
| "缺少字操作数结果接收对象", | |||||
| {}, | |||||
| {}, | |||||
| node_id); | |||||
| } | |||||
| if (operand.kind == WordOperandKind::Constant) | |||||
| { | |||||
| *value = operand.constant; | |||||
| return success(); | |||||
| } | |||||
| if (operand.kind != WordOperandKind::Register) | |||||
| { | |||||
| return failure( | |||||
| LogicScanError::InvalidLogic, | |||||
| "字操作数类型无效", | |||||
| {}, | |||||
| {}, | |||||
| node_id); | |||||
| } | |||||
| const WordReadResult read = repository.readWord(operand.address); | |||||
| if (!read.succeeded) | |||||
| { | |||||
| return failure( | |||||
| LogicScanError::RegisterReadFailed, | |||||
| "读取寄存器失败:" + operand.address.toString(), | |||||
| {}, | |||||
| {}, | |||||
| node_id); | |||||
| } | |||||
| *value = read.value; | |||||
| return success(); | |||||
| } | |||||
| LogicScanResult SoftwareLogicExecutor::executeOutput( | |||||
| const std::string &logic_id, | |||||
| const LogicNode &node, | const LogicNode &node, | ||||
| bool rung_value, | bool rung_value, | ||||
| RegisterRepository &repository) const | |||||
| RegisterRepository &repository, | |||||
| TimePoint now, | |||||
| LogicTraceValues *trace, | |||||
| bool *output_value) | |||||
| { | { | ||||
| if (output_value == nullptr) | |||||
| { | |||||
| return failure( | |||||
| LogicScanError::InvalidLogic, | |||||
| "缺少输出指令结果接收对象", | |||||
| {}, | |||||
| {}, | |||||
| node.id); | |||||
| } | |||||
| if (const auto *ton = std::get_if<TonNodeConfig>(&node.config)) | |||||
| { | |||||
| TonRuntimeState &state = ton_states_[ton->address.index()]; | |||||
| if (!rung_value) | |||||
| { | |||||
| state = TonRuntimeState{}; | |||||
| } | |||||
| else if (!state.timing) | |||||
| { | |||||
| state.timing = true; | |||||
| state.startedAt = now; | |||||
| state.elapsed = std::chrono::milliseconds{0}; | |||||
| state.done = false; | |||||
| } | |||||
| else | |||||
| { | |||||
| state.elapsed = std::chrono::duration_cast<std::chrono::milliseconds>( | |||||
| now >= state.startedAt ? now - state.startedAt : TimePoint::duration::zero()); | |||||
| state.done = state.elapsed.count() >= ton->presetMs; | |||||
| } | |||||
| *output_value = state.done; | |||||
| if (trace != nullptr) | |||||
| { | |||||
| trace->tonValues[node.id] = { | |||||
| rung_value, | |||||
| state.done, | |||||
| state.elapsed.count(), | |||||
| ton->presetMs}; | |||||
| } | |||||
| return success(); | |||||
| } | |||||
| if (const auto *counter = std::get_if<CounterNodeConfig>(&node.config)) | |||||
| { | |||||
| const BitReadResult reset_read = repository.readBit(counter->resetAddress); | |||||
| if (!reset_read.succeeded) | |||||
| { | |||||
| return failure( | |||||
| LogicScanError::RegisterReadFailed, | |||||
| "读取计数器复位输入失败:" + counter->resetAddress.toString(), | |||||
| {}, | |||||
| {}, | |||||
| node.id); | |||||
| } | |||||
| std::int16_t preset = 0; | |||||
| LogicScanResult result = readWordOperand( | |||||
| counter->preset, repository, &preset, node.id); | |||||
| if (!result.succeeded) | |||||
| { | |||||
| return result; | |||||
| } | |||||
| const std::int32_t bounded_preset = std::max<std::int32_t>(0, preset); | |||||
| preset = static_cast<std::int16_t>( | |||||
| std::min<std::int32_t>(bounded_preset, std::numeric_limits<std::int16_t>::max())); | |||||
| const WordReadResult current_read = repository.readWord( | |||||
| counter->currentValueAddress); | |||||
| if (!current_read.succeeded) | |||||
| { | |||||
| return failure( | |||||
| LogicScanError::RegisterReadFailed, | |||||
| "读取计数当前值失败:" + counter->currentValueAddress.toString(), | |||||
| {}, | |||||
| {}, | |||||
| node.id); | |||||
| } | |||||
| std::int32_t current = std::max<std::int32_t>(0, current_read.value); | |||||
| current = std::min<std::int32_t>(current, std::numeric_limits<std::int16_t>::max()); | |||||
| const auto runtime_key = std::make_pair(logic_id, node.id); | |||||
| const bool previous = previous_counter_inputs_[runtime_key]; | |||||
| const bool rising = rung_value && !previous; | |||||
| previous_counter_inputs_[runtime_key] = rung_value; | |||||
| bool done = counter_states_[counter->address.index()].done; | |||||
| if (reset_read.value) | |||||
| { | |||||
| current = counter->mode == CounterMode::Up ? 0 : preset; | |||||
| done = counter->mode == CounterMode::Down && current == 0; | |||||
| } | |||||
| else if (rising) | |||||
| { | |||||
| if (counter->mode == CounterMode::Up) | |||||
| { | |||||
| if (current < preset) | |||||
| { | |||||
| ++current; | |||||
| } | |||||
| done = current >= preset; | |||||
| } | |||||
| else | |||||
| { | |||||
| if (current > 0) | |||||
| { | |||||
| --current; | |||||
| } | |||||
| done = current <= 0; | |||||
| } | |||||
| } | |||||
| if (counter->mode == CounterMode::Up && current >= preset) | |||||
| { | |||||
| done = true; | |||||
| } | |||||
| if (counter->mode == CounterMode::Down && current <= 0) | |||||
| { | |||||
| done = true; | |||||
| } | |||||
| const std::int16_t current_value = static_cast<std::int16_t>(current); | |||||
| const RegisterWriteResult write = repository.writeWord( | |||||
| counter->currentValueAddress, current_value); | |||||
| if (!write.succeeded) | |||||
| { | |||||
| return failure( | |||||
| LogicScanError::RegisterWriteFailed, | |||||
| "写入计数当前值失败:" + counter->currentValueAddress.toString(), | |||||
| {}, | |||||
| {}, | |||||
| node.id); | |||||
| } | |||||
| counter_states_[counter->address.index()].done = done; | |||||
| *output_value = done; | |||||
| if (trace != nullptr) | |||||
| { | |||||
| trace->counterValues[node.id] = { | |||||
| rung_value, | |||||
| reset_read.value, | |||||
| done, | |||||
| current_value, | |||||
| preset}; | |||||
| } | |||||
| return success(); | |||||
| } | |||||
| if (const auto *move = std::get_if<MoveNodeConfig>(&node.config)) | |||||
| { | |||||
| if (!rung_value) | |||||
| { | |||||
| *output_value = false; | |||||
| return success(); | |||||
| } | |||||
| std::int16_t source = 0; | |||||
| LogicScanResult result = readWordOperand( | |||||
| move->source, repository, &source, node.id); | |||||
| if (!result.succeeded) | |||||
| { | |||||
| return result; | |||||
| } | |||||
| const RegisterWriteResult write = repository.writeWord( | |||||
| move->destination, source); | |||||
| if (!write.succeeded) | |||||
| { | |||||
| return failure( | |||||
| LogicScanError::RegisterWriteFailed, | |||||
| "MOVE 写入寄存器失败:" + move->destination.toString(), | |||||
| {}, | |||||
| {}, | |||||
| node.id); | |||||
| } | |||||
| *output_value = true; | |||||
| if (trace != nullptr) | |||||
| { | |||||
| trace->wordValues[node.id] = {source, false}; | |||||
| } | |||||
| return success(); | |||||
| } | |||||
| if (const auto *arithmetic = std::get_if<ArithmeticNodeConfig>(&node.config)) | |||||
| { | |||||
| if (!rung_value) | |||||
| { | |||||
| *output_value = false; | |||||
| return success(); | |||||
| } | |||||
| std::int16_t left = 0; | |||||
| std::int16_t right = 0; | |||||
| LogicScanResult result = readWordOperand( | |||||
| arithmetic->left, repository, &left, node.id); | |||||
| if (!result.succeeded) | |||||
| { | |||||
| return result; | |||||
| } | |||||
| result = readWordOperand(arithmetic->right, repository, &right, node.id); | |||||
| if (!result.succeeded) | |||||
| { | |||||
| return result; | |||||
| } | |||||
| const std::int32_t raw = arithmetic->operation == ArithmeticOperation::Add | |||||
| ? static_cast<std::int32_t>(left) + right | |||||
| : static_cast<std::int32_t>(left) - right; | |||||
| const bool overflow = raw < std::numeric_limits<std::int16_t>::min() | |||||
| || raw > std::numeric_limits<std::int16_t>::max(); | |||||
| const std::int32_t bounded = std::max<std::int32_t>( | |||||
| std::numeric_limits<std::int16_t>::min(), | |||||
| std::min<std::int32_t>(raw, std::numeric_limits<std::int16_t>::max())); | |||||
| const std::int16_t result_value = static_cast<std::int16_t>(bounded); | |||||
| const RegisterWriteResult write = repository.writeWord( | |||||
| arithmetic->destination, result_value); | |||||
| if (!write.succeeded) | |||||
| { | |||||
| return failure( | |||||
| LogicScanError::RegisterWriteFailed, | |||||
| "算术指令写入寄存器失败:" + arithmetic->destination.toString(), | |||||
| {}, | |||||
| {}, | |||||
| node.id); | |||||
| } | |||||
| *output_value = true; | |||||
| if (trace != nullptr) | |||||
| { | |||||
| trace->wordValues[node.id] = {result_value, overflow}; | |||||
| } | |||||
| return success(); | |||||
| } | |||||
| const auto *config = std::get_if<CoilNodeConfig>(&node.config); | const auto *config = std::get_if<CoilNodeConfig>(&node.config); | ||||
| if (config == nullptr) | if (config == nullptr) | ||||
| { | { | ||||
| return failure( | return failure( | ||||
| LogicScanError::InvalidLogic, | LogicScanError::InvalidLogic, | ||||
| "梯形图输出不是线圈节点", | |||||
| "梯形图输出不是有效的输出指令", | |||||
| {}, | {}, | ||||
| {}, | {}, | ||||
| node.id); | node.id); | ||||
| } | } | ||||
| bool should_write = true; | bool should_write = true; | ||||
| bool output_value = rung_value; | |||||
| bool register_value = rung_value; | |||||
| switch (config->mode) | switch (config->mode) | ||||
| { | { | ||||
| case CoilMode::Normal: | case CoilMode::Normal: | ||||
| @@ -359,13 +764,13 @@ LogicScanResult SoftwareLogicExecutor::writeOutput( | |||||
| case CoilMode::Set: | case CoilMode::Set: | ||||
| { | { | ||||
| should_write = rung_value; | should_write = rung_value; | ||||
| output_value = true; | |||||
| register_value = true; | |||||
| break; | break; | ||||
| } | } | ||||
| case CoilMode::Reset: | case CoilMode::Reset: | ||||
| { | { | ||||
| should_write = rung_value; | should_write = rung_value; | ||||
| output_value = false; | |||||
| register_value = false; | |||||
| break; | break; | ||||
| } | } | ||||
| default: | default: | ||||
| @@ -381,10 +786,11 @@ LogicScanResult SoftwareLogicExecutor::writeOutput( | |||||
| if (!should_write) | if (!should_write) | ||||
| { | { | ||||
| *output_value = rung_value; | |||||
| return success(); | return success(); | ||||
| } | } | ||||
| const RegisterWriteResult write = repository.writeBit( | const RegisterWriteResult write = repository.writeBit( | ||||
| config->address, output_value); | |||||
| config->address, register_value); | |||||
| if (!write.succeeded) | if (!write.succeeded) | ||||
| { | { | ||||
| return failure( | return failure( | ||||
| @@ -394,5 +800,6 @@ LogicScanResult SoftwareLogicExecutor::writeOutput( | |||||
| {}, | {}, | ||||
| node.id); | node.id); | ||||
| } | } | ||||
| *output_value = rung_value; | |||||
| return success(); | return success(); | ||||
| } | } | ||||
| @@ -3,8 +3,12 @@ | |||||
| #include "domain/control_logic_model.h" | #include "domain/control_logic_model.h" | ||||
| #include "domain/register_repository.h" | #include "domain/register_repository.h" | ||||
| #include <chrono> | |||||
| #include <cstdint> | |||||
| #include <map> | |||||
| #include <string> | #include <string> | ||||
| #include <unordered_map> | #include <unordered_map> | ||||
| #include <utility> | |||||
| #include <vector> | #include <vector> | ||||
| enum class LogicScanError | enum class LogicScanError | ||||
| @@ -26,11 +30,40 @@ struct LogicScanResult | |||||
| std::string nodeId; | std::string nodeId; | ||||
| }; | }; | ||||
| struct TonTraceValue | |||||
| { | |||||
| bool input = false; | |||||
| bool done = false; | |||||
| std::int64_t elapsedMs = 0; | |||||
| int presetMs = 0; | |||||
| }; | |||||
| struct CounterTraceValue | |||||
| { | |||||
| bool input = false; | |||||
| bool reset = false; | |||||
| bool done = false; | |||||
| std::int16_t value = 0; | |||||
| std::int16_t preset = 0; | |||||
| }; | |||||
| struct WordTraceValue | |||||
| { | |||||
| std::int16_t value = 0; | |||||
| bool overflow = false; | |||||
| }; | |||||
| struct LogicTraceValues | struct LogicTraceValues | ||||
| { | { | ||||
| std::unordered_map<std::string, bool> nodeValues; | std::unordered_map<std::string, bool> nodeValues; | ||||
| std::unordered_map<std::string, bool> nodePowerValues; | |||||
| std::unordered_map<std::string, bool> expressionValues; | std::unordered_map<std::string, bool> expressionValues; | ||||
| std::unordered_map<std::string, bool> expressionInputValues; | |||||
| std::unordered_map<std::string, bool> expressionPowerValues; | |||||
| std::unordered_map<std::string, bool> rungValues; | std::unordered_map<std::string, bool> rungValues; | ||||
| std::unordered_map<std::string, TonTraceValue> tonValues; | |||||
| std::unordered_map<std::string, CounterTraceValue> counterValues; | |||||
| std::unordered_map<std::string, WordTraceValue> wordValues; | |||||
| void clear(); | void clear(); | ||||
| }; | }; | ||||
| @@ -47,24 +80,62 @@ struct LogicTraceSnapshot : LogicTraceValues | |||||
| class SoftwareLogicExecutor | class SoftwareLogicExecutor | ||||
| { | { | ||||
| public: | public: | ||||
| using Clock = std::chrono::steady_clock; | |||||
| using TimePoint = Clock::time_point; | |||||
| LogicScanResult validate(const std::vector<ControlLogic> &logics) const; | LogicScanResult validate(const std::vector<ControlLogic> &logics) const; | ||||
| void resetRuntime(); | |||||
| LogicScanResult executeScan( | LogicScanResult executeScan( | ||||
| const std::vector<ControlLogic> &logics, | const std::vector<ControlLogic> &logics, | ||||
| RegisterRepository &repository, | RegisterRepository &repository, | ||||
| LogicTraceSnapshot *trace = nullptr) const; | |||||
| LogicTraceSnapshot *trace = nullptr); | |||||
| LogicScanResult executeScanAt( | |||||
| const std::vector<ControlLogic> &logics, | |||||
| RegisterRepository &repository, | |||||
| TimePoint now, | |||||
| LogicTraceSnapshot *trace = nullptr); | |||||
| private: | private: | ||||
| struct TonRuntimeState | |||||
| { | |||||
| bool timing = false; | |||||
| bool done = false; | |||||
| TimePoint startedAt{}; | |||||
| std::chrono::milliseconds elapsed{0}; | |||||
| }; | |||||
| LogicScanResult evaluateCondition( | LogicScanResult evaluateCondition( | ||||
| const std::string &logic_id, | |||||
| const LogicNode &node, | const LogicNode &node, | ||||
| RegisterRepository &repository, | RegisterRepository &repository, | ||||
| bool *value) const; | |||||
| bool *value); | |||||
| LogicScanResult evaluateExpression( | LogicScanResult evaluateExpression( | ||||
| const std::string &logic_id, | |||||
| const ConditionExpression &expression, | const ConditionExpression &expression, | ||||
| RegisterRepository &repository, | RegisterRepository &repository, | ||||
| LogicTraceValues *trace, | LogicTraceValues *trace, | ||||
| bool *value) const; | |||||
| LogicScanResult writeOutput( | |||||
| bool input_power, | |||||
| bool *value); | |||||
| LogicScanResult executeOutput( | |||||
| const std::string &logic_id, | |||||
| const LogicNode &node, | const LogicNode &node, | ||||
| bool rung_value, | bool rung_value, | ||||
| RegisterRepository &repository) const; | |||||
| RegisterRepository &repository, | |||||
| TimePoint now, | |||||
| LogicTraceValues *trace, | |||||
| bool *output_value); | |||||
| LogicScanResult readWordOperand( | |||||
| const WordOperand &operand, | |||||
| RegisterRepository &repository, | |||||
| std::int16_t *value, | |||||
| const std::string &node_id) const; | |||||
| std::map<std::pair<std::string, std::string>, bool> previous_edge_inputs_; | |||||
| std::map<std::pair<std::string, std::string>, bool> previous_counter_inputs_; | |||||
| std::unordered_map<int, TonRuntimeState> ton_states_; | |||||
| struct CounterRuntimeState | |||||
| { | |||||
| bool done = false; | |||||
| }; | |||||
| std::unordered_map<int, CounterRuntimeState> counter_states_; | |||||
| }; | }; | ||||
| @@ -0,0 +1,248 @@ | |||||
| #include "alarm_configuration_dialog.h" | |||||
| #include "domain/project_limits.h" | |||||
| #include "services/alarm_editor_service.h" | |||||
| #include "ui_alarm_configuration_dialog.h" | |||||
| #include <QHeaderView> | |||||
| #include <QMessageBox> | |||||
| #include <QTableWidgetItem> | |||||
| namespace { | |||||
| QString fromUtf8(const std::string &value) | |||||
| { | |||||
| return QString::fromUtf8(value.data(), static_cast<int>(value.size())); | |||||
| } | |||||
| std::string toUtf8(const QString &value) | |||||
| { | |||||
| const QByteArray bytes = value.toUtf8(); | |||||
| return std::string(bytes.constData(), static_cast<std::size_t>(bytes.size())); | |||||
| } | |||||
| QString conditionText(AlarmCondition condition) | |||||
| { | |||||
| switch (condition) | |||||
| { | |||||
| case AlarmCondition::MOn: | |||||
| { | |||||
| return AlarmConfigurationDialog::tr("M 为 ON"); | |||||
| } | |||||
| case AlarmCondition::DHigh: | |||||
| { | |||||
| return AlarmConfigurationDialog::tr("D 大于等于阈值"); | |||||
| } | |||||
| case AlarmCondition::DLow: | |||||
| { | |||||
| return AlarmConfigurationDialog::tr("D 小于等于阈值"); | |||||
| } | |||||
| default: | |||||
| { | |||||
| return {}; | |||||
| } | |||||
| } | |||||
| } | |||||
| } // namespace | |||||
| AlarmConfigurationDialog::AlarmConfigurationDialog( | |||||
| AlarmEditorService &service, | |||||
| QWidget *parent) | |||||
| : QDialog(parent), | |||||
| ui_(std::make_unique<Ui::AlarmConfigurationDialog>()), | |||||
| service_(service) | |||||
| { | |||||
| ui_->setupUi(this); | |||||
| ui_->messageEdit->setMaxLength( | |||||
| static_cast<int>(ProjectLimits::kMaximumAlarmMessageCharacters)); | |||||
| ui_->alarmTable->horizontalHeader()->setSectionResizeMode( | |||||
| 0, QHeaderView::ResizeToContents); | |||||
| ui_->alarmTable->horizontalHeader()->setSectionResizeMode( | |||||
| 1, QHeaderView::ResizeToContents); | |||||
| ui_->alarmTable->horizontalHeader()->setSectionResizeMode( | |||||
| 2, QHeaderView::ResizeToContents); | |||||
| ui_->alarmTable->horizontalHeader()->setSectionResizeMode( | |||||
| 3, QHeaderView::ResizeToContents); | |||||
| ui_->alarmTable->horizontalHeader()->setSectionResizeMode( | |||||
| 4, QHeaderView::Stretch); | |||||
| ui_->areaComboBox->setItemData(0, static_cast<int>(RegisterArea::M)); | |||||
| ui_->areaComboBox->setItemData(1, static_cast<int>(RegisterArea::D)); | |||||
| connect(ui_->areaComboBox, | |||||
| QOverload<int>::of(&QComboBox::currentIndexChanged), | |||||
| this, | |||||
| [this] { updateConditionOptions(); }); | |||||
| connect(ui_->alarmTable, &QTableWidget::itemSelectionChanged, | |||||
| this, [this] { loadSelectedDefinition(); }); | |||||
| connect(ui_->addButton, &QPushButton::clicked, | |||||
| this, [this] { addDefinition(); }); | |||||
| connect(ui_->updateButton, &QPushButton::clicked, | |||||
| this, [this] { updateDefinition(); }); | |||||
| connect(ui_->removeButton, &QPushButton::clicked, | |||||
| this, [this] { removeDefinition(); }); | |||||
| connect(ui_->buttonBox, &QDialogButtonBox::rejected, | |||||
| this, &QDialog::reject); | |||||
| updateConditionOptions(); | |||||
| reloadDefinitions(); | |||||
| } | |||||
| AlarmConfigurationDialog::~AlarmConfigurationDialog() = default; | |||||
| void AlarmConfigurationDialog::reloadDefinitions(const std::string &selected_id) | |||||
| { | |||||
| const auto &definitions = service_.definitions(); | |||||
| ui_->alarmTable->setRowCount(static_cast<int>(definitions.size())); | |||||
| int selected_row = -1; | |||||
| for (std::size_t index = 0; index < definitions.size(); ++index) | |||||
| { | |||||
| const AlarmDefinition &definition = definitions[index]; | |||||
| const int row = static_cast<int>(index); | |||||
| auto *id_item = new QTableWidgetItem(fromUtf8(definition.id)); | |||||
| id_item->setData(Qt::UserRole, fromUtf8(definition.id)); | |||||
| ui_->alarmTable->setItem(row, 0, id_item); | |||||
| ui_->alarmTable->setItem( | |||||
| row, 1, new QTableWidgetItem(QString::fromStdString( | |||||
| definition.address.toString()))); | |||||
| ui_->alarmTable->setItem( | |||||
| row, 2, new QTableWidgetItem(conditionText(definition.condition))); | |||||
| ui_->alarmTable->setItem( | |||||
| row, | |||||
| 3, | |||||
| new QTableWidgetItem( | |||||
| definition.condition == AlarmCondition::MOn | |||||
| ? QStringLiteral("-") | |||||
| : QString::number(definition.threshold))); | |||||
| ui_->alarmTable->setItem( | |||||
| row, 4, new QTableWidgetItem(fromUtf8(definition.message))); | |||||
| if (definition.id == selected_id) | |||||
| { | |||||
| selected_row = row; | |||||
| } | |||||
| } | |||||
| if (selected_row >= 0) | |||||
| { | |||||
| ui_->alarmTable->selectRow(selected_row); | |||||
| } | |||||
| else | |||||
| { | |||||
| ui_->alarmTable->clearSelection(); | |||||
| ui_->updateButton->setEnabled(false); | |||||
| ui_->removeButton->setEnabled(false); | |||||
| } | |||||
| } | |||||
| void AlarmConfigurationDialog::loadSelectedDefinition() | |||||
| { | |||||
| const AlarmDefinition *definition = service_.findDefinition(selectedId()); | |||||
| const bool selected = definition != nullptr; | |||||
| ui_->updateButton->setEnabled(selected); | |||||
| ui_->removeButton->setEnabled(selected); | |||||
| if (!selected) | |||||
| { | |||||
| return; | |||||
| } | |||||
| ui_->areaComboBox->setCurrentIndex( | |||||
| definition->address.area() == RegisterArea::M ? 0 : 1); | |||||
| updateConditionOptions(); | |||||
| ui_->conditionComboBox->setCurrentIndex( | |||||
| ui_->conditionComboBox->findData( | |||||
| static_cast<int>(definition->condition))); | |||||
| ui_->indexSpinBox->setValue(definition->address.index()); | |||||
| ui_->thresholdSpinBox->setValue(definition->threshold); | |||||
| ui_->messageEdit->setText(fromUtf8(definition->message)); | |||||
| } | |||||
| void AlarmConfigurationDialog::updateConditionOptions() | |||||
| { | |||||
| const RegisterArea area = static_cast<RegisterArea>( | |||||
| ui_->areaComboBox->currentData().toInt()); | |||||
| ui_->conditionComboBox->clear(); | |||||
| if (area == RegisterArea::M) | |||||
| { | |||||
| ui_->conditionComboBox->addItem( | |||||
| conditionText(AlarmCondition::MOn), | |||||
| static_cast<int>(AlarmCondition::MOn)); | |||||
| } | |||||
| else | |||||
| { | |||||
| ui_->conditionComboBox->addItem( | |||||
| conditionText(AlarmCondition::DHigh), | |||||
| static_cast<int>(AlarmCondition::DHigh)); | |||||
| ui_->conditionComboBox->addItem( | |||||
| conditionText(AlarmCondition::DLow), | |||||
| static_cast<int>(AlarmCondition::DLow)); | |||||
| } | |||||
| ui_->thresholdLabel->setEnabled(area == RegisterArea::D); | |||||
| ui_->thresholdSpinBox->setEnabled(area == RegisterArea::D); | |||||
| } | |||||
| void AlarmConfigurationDialog::addDefinition() | |||||
| { | |||||
| const AlarmEditorResult result = service_.addDefinition(definitionFromInputs()); | |||||
| if (!result.succeeded) | |||||
| { | |||||
| QMessageBox::warning(this, tr("新增报警"), fromUtf8(result.message)); | |||||
| return; | |||||
| } | |||||
| reloadDefinitions(result.id); | |||||
| } | |||||
| void AlarmConfigurationDialog::updateDefinition() | |||||
| { | |||||
| const std::string id = selectedId(); | |||||
| if (id.empty()) | |||||
| { | |||||
| return; | |||||
| } | |||||
| const AlarmEditorResult result = service_.updateDefinition( | |||||
| id, definitionFromInputs()); | |||||
| if (!result.succeeded) | |||||
| { | |||||
| QMessageBox::warning(this, tr("更新报警"), fromUtf8(result.message)); | |||||
| return; | |||||
| } | |||||
| reloadDefinitions(result.id); | |||||
| } | |||||
| void AlarmConfigurationDialog::removeDefinition() | |||||
| { | |||||
| const std::string id = selectedId(); | |||||
| if (id.empty()) | |||||
| { | |||||
| return; | |||||
| } | |||||
| if (QMessageBox::question( | |||||
| this, tr("删除报警"), tr("确定删除当前报警定义吗?")) | |||||
| != QMessageBox::Yes) | |||||
| { | |||||
| return; | |||||
| } | |||||
| const AlarmEditorResult result = service_.removeDefinition(id); | |||||
| if (!result.succeeded) | |||||
| { | |||||
| QMessageBox::warning(this, tr("删除报警"), fromUtf8(result.message)); | |||||
| return; | |||||
| } | |||||
| reloadDefinitions(); | |||||
| } | |||||
| AlarmDefinition AlarmConfigurationDialog::definitionFromInputs() const | |||||
| { | |||||
| AlarmDefinition definition; | |||||
| const RegisterArea area = static_cast<RegisterArea>( | |||||
| ui_->areaComboBox->currentData().toInt()); | |||||
| definition.address = RegisterAddress{area, ui_->indexSpinBox->value()}; | |||||
| definition.condition = static_cast<AlarmCondition>( | |||||
| ui_->conditionComboBox->currentData().toInt()); | |||||
| definition.threshold = static_cast<std::int16_t>( | |||||
| ui_->thresholdSpinBox->value()); | |||||
| definition.message = toUtf8(ui_->messageEdit->text().trimmed()); | |||||
| return definition; | |||||
| } | |||||
| std::string AlarmConfigurationDialog::selectedId() const | |||||
| { | |||||
| const int row = ui_->alarmTable->currentRow(); | |||||
| const QTableWidgetItem *item = row >= 0 ? ui_->alarmTable->item(row, 0) : nullptr; | |||||
| return item == nullptr ? std::string{} : toUtf8(item->data(Qt::UserRole).toString()); | |||||
| } | |||||
| @@ -0,0 +1,35 @@ | |||||
| #pragma once | |||||
| #include <QDialog> | |||||
| #include <memory> | |||||
| #include <string> | |||||
| namespace Ui { | |||||
| class AlarmConfigurationDialog; | |||||
| } | |||||
| class AlarmEditorService; | |||||
| struct AlarmDefinition; | |||||
| class AlarmConfigurationDialog final : public QDialog | |||||
| { | |||||
| public: | |||||
| explicit AlarmConfigurationDialog( | |||||
| AlarmEditorService &service, | |||||
| QWidget *parent = nullptr); | |||||
| ~AlarmConfigurationDialog() override; | |||||
| private: | |||||
| void reloadDefinitions(const std::string &selected_id = {}); | |||||
| void loadSelectedDefinition(); | |||||
| void updateConditionOptions(); | |||||
| void addDefinition(); | |||||
| void updateDefinition(); | |||||
| void removeDefinition(); | |||||
| AlarmDefinition definitionFromInputs() const; | |||||
| std::string selectedId() const; | |||||
| std::unique_ptr<Ui::AlarmConfigurationDialog> ui_; | |||||
| AlarmEditorService &service_; | |||||
| }; | |||||
| @@ -0,0 +1,49 @@ | |||||
| <?xml version="1.0" encoding="UTF-8"?> | |||||
| <ui version="4.0"> | |||||
| <class>AlarmConfigurationDialog</class> | |||||
| <widget class="QDialog" name="AlarmConfigurationDialog"> | |||||
| <property name="windowTitle"><string>报警配置</string></property> | |||||
| <property name="minimumSize"><size><width>720</width><height>430</height></size></property> | |||||
| <layout class="QVBoxLayout" name="verticalLayout"> | |||||
| <item> | |||||
| <widget class="QTableWidget" name="alarmTable"> | |||||
| <property name="selectionBehavior"><enum>QAbstractItemView::SelectRows</enum></property> | |||||
| <property name="selectionMode"><enum>QAbstractItemView::SingleSelection</enum></property> | |||||
| <property name="editTriggers"><set>QAbstractItemView::NoEditTriggers</set></property> | |||||
| <property name="columnCount"><number>5</number></property> | |||||
| <column><property name="text"><string>ID</string></property></column> | |||||
| <column><property name="text"><string>地址</string></property></column> | |||||
| <column><property name="text"><string>条件</string></property></column> | |||||
| <column><property name="text"><string>阈值</string></property></column> | |||||
| <column><property name="text"><string>报警文本</string></property></column> | |||||
| </widget> | |||||
| </item> | |||||
| <item> | |||||
| <layout class="QFormLayout" name="alarmForm"> | |||||
| <property name="fieldGrowthPolicy"><enum>QFormLayout::AllNonFixedFieldsGrow</enum></property> | |||||
| <item row="0" column="0"><widget class="QLabel" name="areaLabel"><property name="text"><string>地址区域</string></property></widget></item> | |||||
| <item row="0" column="1"><widget class="QComboBox" name="areaComboBox"><item><property name="text"><string>M</string></property></item><item><property name="text"><string>D</string></property></item></widget></item> | |||||
| <item row="1" column="0"><widget class="QLabel" name="indexLabel"><property name="text"><string>地址</string></property></widget></item> | |||||
| <item row="1" column="1"><widget class="QSpinBox" name="indexSpinBox"><property name="maximum"><number>4000</number></property></widget></item> | |||||
| <item row="2" column="0"><widget class="QLabel" name="conditionLabel"><property name="text"><string>触发条件</string></property></widget></item> | |||||
| <item row="2" column="1"><widget class="QComboBox" name="conditionComboBox"/></item> | |||||
| <item row="3" column="0"><widget class="QLabel" name="thresholdLabel"><property name="text"><string>阈值</string></property></widget></item> | |||||
| <item row="3" column="1"><widget class="QSpinBox" name="thresholdSpinBox"><property name="minimum"><number>-32768</number></property><property name="maximum"><number>32767</number></property></widget></item> | |||||
| <item row="4" column="0"><widget class="QLabel" name="messageLabel"><property name="text"><string>报警文本</string></property></widget></item> | |||||
| <item row="4" column="1"><widget class="QLineEdit" name="messageEdit"><property name="placeholderText"><string>例如:温度过高</string></property></widget></item> | |||||
| </layout> | |||||
| </item> | |||||
| <item> | |||||
| <layout class="QHBoxLayout" name="operationLayout"> | |||||
| <item><widget class="QPushButton" name="addButton"><property name="text"><string>新增</string></property></widget></item> | |||||
| <item><widget class="QPushButton" name="updateButton"><property name="text"><string>更新</string></property></widget></item> | |||||
| <item><widget class="QPushButton" name="removeButton"><property name="text"><string>删除</string></property></widget></item> | |||||
| <item><spacer name="operationSpacer"><property name="orientation"><enum>Qt::Horizontal</enum></property><property name="sizeHint" stdset="0"><size><width>40</width><height>20</height></size></property></spacer></item> | |||||
| <item><widget class="QDialogButtonBox" name="buttonBox"><property name="standardButtons"><set>QDialogButtonBox::Close</set></property></widget></item> | |||||
| </layout> | |||||
| </item> | |||||
| </layout> | |||||
| </widget> | |||||
| <resources/> | |||||
| <connections/> | |||||
| </ui> | |||||
| @@ -1,13 +1,18 @@ | |||||
| #include "free_monitor_widget.h" | #include "free_monitor_widget.h" | ||||
| #include "services/register_monitor_service.h" | #include "services/register_monitor_service.h" | ||||
| #include "toolbar_icon_factory.h" | |||||
| #include "ui_free_monitor_widget.h" | #include "ui_free_monitor_widget.h" | ||||
| #include <QComboBox> | |||||
| #include <QHeaderView> | #include <QHeaderView> | ||||
| #include <QIntValidator> | |||||
| #include <QLineEdit> | |||||
| #include <QMessageBox> | #include <QMessageBox> | ||||
| #include <QStyle> | |||||
| #include <QPushButton> | |||||
| #include <QTableWidgetItem> | #include <QTableWidgetItem> | ||||
| #include <cstdint> | |||||
| #include <set> | #include <set> | ||||
| namespace { | namespace { | ||||
| @@ -32,12 +37,14 @@ FreeMonitorWidget::FreeMonitorWidget( | |||||
| service_(service) | service_(service) | ||||
| { | { | ||||
| ui_->setupUi(this); | ui_->setupUi(this); | ||||
| ui_->removeButton->setIcon(style()->standardIcon(QStyle::SP_TrashIcon)); | |||||
| ui_->clearButton->setIcon(style()->standardIcon(QStyle::SP_DialogResetButton)); | |||||
| ui_->removeButton->setIcon(makeUiIcon(UiIcon::Delete)); | |||||
| ui_->clearButton->setIcon(makeUiIcon(UiIcon::ClearList)); | |||||
| ui_->monitorTable->horizontalHeader()->setSectionResizeMode(0, QHeaderView::ResizeToContents); | ui_->monitorTable->horizontalHeader()->setSectionResizeMode(0, QHeaderView::ResizeToContents); | ||||
| ui_->monitorTable->horizontalHeader()->setSectionResizeMode(1, QHeaderView::ResizeToContents); | ui_->monitorTable->horizontalHeader()->setSectionResizeMode(1, QHeaderView::ResizeToContents); | ||||
| ui_->monitorTable->horizontalHeader()->setSectionResizeMode(2, QHeaderView::Stretch); | ui_->monitorTable->horizontalHeader()->setSectionResizeMode(2, QHeaderView::Stretch); | ||||
| ui_->monitorTable->horizontalHeader()->setSectionResizeMode(3, QHeaderView::Stretch); | ui_->monitorTable->horizontalHeader()->setSectionResizeMode(3, QHeaderView::Stretch); | ||||
| ui_->monitorTable->horizontalHeader()->setSectionResizeMode(4, QHeaderView::ResizeToContents); | |||||
| ui_->monitorTable->horizontalHeader()->setSectionResizeMode(5, QHeaderView::Stretch); | |||||
| ui_->monitorTable->verticalHeader()->setVisible(false); | ui_->monitorTable->verticalHeader()->setVisible(false); | ||||
| connect(ui_->addButton, &QPushButton::clicked, this, &FreeMonitorWidget::addAddresses); | connect(ui_->addButton, &QPushButton::clicked, this, &FreeMonitorWidget::addAddresses); | ||||
| connect(ui_->addressEdit, &QLineEdit::returnPressed, | connect(ui_->addressEdit, &QLineEdit::returnPressed, | ||||
| @@ -51,6 +58,12 @@ FreeMonitorWidget::FreeMonitorWidget( | |||||
| FreeMonitorWidget::~FreeMonitorWidget() = default; | FreeMonitorWidget::~FreeMonitorWidget() = default; | ||||
| void FreeMonitorWidget::setWriteEnabled(bool enabled) | |||||
| { | |||||
| write_enabled_ = enabled; | |||||
| updateWriteControls(); | |||||
| } | |||||
| void FreeMonitorWidget::refreshValues( | void FreeMonitorWidget::refreshValues( | ||||
| ApplicationMode mode, PlcConnectionState plc_state) | ApplicationMode mode, PlcConnectionState plc_state) | ||||
| { | { | ||||
| @@ -75,7 +88,7 @@ void FreeMonitorWidget::refreshValues( | |||||
| for (int row = 0; row < ui_->monitorTable->rowCount(); ++row) | for (int row = 0; row < ui_->monitorTable->rowCount(); ++row) | ||||
| { | { | ||||
| QTableWidgetItem *value_item = ui_->monitorTable->item(row, 2); | QTableWidgetItem *value_item = ui_->monitorTable->item(row, 2); | ||||
| QTableWidgetItem *state_item = ui_->monitorTable->item(row, 3); | |||||
| QTableWidgetItem *state_item = ui_->monitorTable->item(row, 5); | |||||
| if (!values_available || static_cast<std::size_t>(row) >= values.size()) | if (!values_available || static_cast<std::size_t>(row) >= values.size()) | ||||
| { | { | ||||
| value_item->setText(QStringLiteral("--")); | value_item->setText(QStringLiteral("--")); | ||||
| @@ -97,6 +110,7 @@ void FreeMonitorWidget::refreshValues( | |||||
| state_item->setText(value.state == MonitorValueState::CommunicationFault | state_item->setText(value.state == MonitorValueState::CommunicationFault | ||||
| ? tr("通信故障,最后有效值") : tr("有效")); | ? tr("通信故障,最后有效值") : tr("有效")); | ||||
| } | } | ||||
| updateWriteControls(); | |||||
| } | } | ||||
| void FreeMonitorWidget::reloadAddresses() | void FreeMonitorWidget::reloadAddresses() | ||||
| @@ -112,7 +126,97 @@ void FreeMonitorWidget::reloadAddresses() | |||||
| row, 1, new QTableWidgetItem(address.area() == RegisterArea::M | row, 1, new QTableWidgetItem(address.area() == RegisterArea::M | ||||
| ? tr("位") : tr("16 位整数"))); | ? tr("位") : tr("16 位整数"))); | ||||
| ui_->monitorTable->setItem(row, 2, new QTableWidgetItem(QStringLiteral("--"))); | ui_->monitorTable->setItem(row, 2, new QTableWidgetItem(QStringLiteral("--"))); | ||||
| ui_->monitorTable->setItem(row, 3, new QTableWidgetItem(tr("未运行"))); | |||||
| if (address.area() == RegisterArea::M) | |||||
| { | |||||
| auto *target = new QComboBox(ui_->monitorTable); | |||||
| target->addItem(tr("OFF"), false); | |||||
| target->addItem(tr("ON"), true); | |||||
| ui_->monitorTable->setCellWidget(row, 3, target); | |||||
| } | |||||
| else | |||||
| { | |||||
| auto *target = new QLineEdit(ui_->monitorTable); | |||||
| target->setValidator(new QIntValidator(-32768, 32767, target)); | |||||
| target->setText(QStringLiteral("0")); | |||||
| target->setAlignment(Qt::AlignRight | Qt::AlignVCenter); | |||||
| ui_->monitorTable->setCellWidget(row, 3, target); | |||||
| } | |||||
| auto *write_button = new QPushButton(tr("写入"), ui_->monitorTable); | |||||
| write_button->setProperty("monitorRow", row); | |||||
| connect(write_button, &QPushButton::clicked, this, | |||||
| [this, write_button] | |||||
| { | |||||
| writeRow(write_button->property("monitorRow").toInt()); | |||||
| }); | |||||
| ui_->monitorTable->setCellWidget(row, 4, write_button); | |||||
| ui_->monitorTable->setItem(row, 5, new QTableWidgetItem(tr("未运行"))); | |||||
| } | |||||
| updateWriteControls(); | |||||
| } | |||||
| void FreeMonitorWidget::writeRow(int row) | |||||
| { | |||||
| if (!write_enabled_ || row < 0 | |||||
| || static_cast<std::size_t>(row) >= service_.addresses().size()) | |||||
| { | |||||
| return; | |||||
| } | |||||
| const RegisterAddress address = service_.addresses().at(static_cast<std::size_t>(row)); | |||||
| RegisterMonitorWriteResult result; | |||||
| if (address.area() == RegisterArea::M) | |||||
| { | |||||
| auto *target = qobject_cast<QComboBox *>(ui_->monitorTable->cellWidget(row, 3)); | |||||
| if (target == nullptr) | |||||
| { | |||||
| return; | |||||
| } | |||||
| result = service_.writeBit(address, target->currentData().toBool()); | |||||
| } | |||||
| else | |||||
| { | |||||
| auto *target = qobject_cast<QLineEdit *>(ui_->monitorTable->cellWidget(row, 3)); | |||||
| if (target == nullptr) | |||||
| { | |||||
| return; | |||||
| } | |||||
| bool converted = false; | |||||
| const int value = target->text().toInt(&converted); | |||||
| if (!converted || value < -32768 || value > 32767) | |||||
| { | |||||
| ui_->monitorTable->item(row, 5)->setText(tr("目标值无效")); | |||||
| emit operationMessage(tr("写入 D 区地址失败:请输入 -32768~32767 的整数")); | |||||
| return; | |||||
| } | |||||
| result = service_.writeWord(address, static_cast<std::int16_t>(value)); | |||||
| } | |||||
| QTableWidgetItem *state_item = ui_->monitorTable->item(row, 5); | |||||
| if (result.succeeded) | |||||
| { | |||||
| state_item->setText(tr("写入请求已发送")); | |||||
| emit operationMessage(tr("%1 写入请求已发送,等待当前值刷新") | |||||
| .arg(addressText(address))); | |||||
| return; | |||||
| } | |||||
| state_item->setText(tr("写入失败")); | |||||
| handleResult(tr("写入 %1").arg(addressText(address)), false, result.message); | |||||
| } | |||||
| void FreeMonitorWidget::updateWriteControls() | |||||
| { | |||||
| for (int row = 0; row < ui_->monitorTable->rowCount(); ++row) | |||||
| { | |||||
| QWidget *target = ui_->monitorTable->cellWidget(row, 3); | |||||
| if (target != nullptr) | |||||
| { | |||||
| target->setEnabled(write_enabled_); | |||||
| } | |||||
| QWidget *write_button = ui_->monitorTable->cellWidget(row, 4); | |||||
| if (write_button != nullptr) | |||||
| { | |||||
| write_button->setEnabled(write_enabled_); | |||||
| } | |||||
| } | } | ||||
| } | } | ||||
| @@ -23,6 +23,7 @@ public: | |||||
| QWidget *parent = nullptr); | QWidget *parent = nullptr); | ||||
| ~FreeMonitorWidget() override; | ~FreeMonitorWidget() override; | ||||
| void setWriteEnabled(bool enabled); | |||||
| void refreshValues(ApplicationMode mode, PlcConnectionState plc_state); | void refreshValues(ApplicationMode mode, PlcConnectionState plc_state); | ||||
| void reloadAddresses(); | void reloadAddresses(); | ||||
| @@ -34,8 +35,11 @@ private: | |||||
| void addAddresses(); | void addAddresses(); | ||||
| void removeSelectedAddresses(); | void removeSelectedAddresses(); | ||||
| void clearAddresses(); | void clearAddresses(); | ||||
| void writeRow(int row); | |||||
| void updateWriteControls(); | |||||
| void handleResult(const QString &action, bool succeeded, const std::string &message); | void handleResult(const QString &action, bool succeeded, const std::string &message); | ||||
| std::unique_ptr<Ui::FreeMonitorWidget> ui_; | std::unique_ptr<Ui::FreeMonitorWidget> ui_; | ||||
| RegisterMonitorService &service_; | RegisterMonitorService &service_; | ||||
| bool write_enabled_ = false; | |||||
| }; | }; | ||||
| @@ -3,7 +3,7 @@ | |||||
| <class>FreeMonitorWidget</class> | <class>FreeMonitorWidget</class> | ||||
| <widget class="QWidget" name="FreeMonitorWidget"> | <widget class="QWidget" name="FreeMonitorWidget"> | ||||
| <property name="minimumSize"> | <property name="minimumSize"> | ||||
| <size><width>420</width><height>190</height></size> | |||||
| <size><width>620</width><height>190</height></size> | |||||
| </property> | </property> | ||||
| <layout class="QVBoxLayout" name="verticalLayout"> | <layout class="QVBoxLayout" name="verticalLayout"> | ||||
| <property name="spacing"><number>6</number></property> | <property name="spacing"><number>6</number></property> | ||||
| @@ -31,12 +31,14 @@ | |||||
| <property name="selectionBehavior"><enum>QAbstractItemView::SelectRows</enum></property> | <property name="selectionBehavior"><enum>QAbstractItemView::SelectRows</enum></property> | ||||
| <property name="editTriggers"><set>QAbstractItemView::NoEditTriggers</set></property> | <property name="editTriggers"><set>QAbstractItemView::NoEditTriggers</set></property> | ||||
| <property name="showGrid"><bool>true</bool></property> | <property name="showGrid"><bool>true</bool></property> | ||||
| <property name="columnCount"><number>4</number></property> | |||||
| <property name="columnCount"><number>6</number></property> | |||||
| <property name="rowCount"><number>0</number></property> | <property name="rowCount"><number>0</number></property> | ||||
| <column><property name="text"><string>地址</string></property></column> | |||||
| <column><property name="text"><string>类型</string></property></column> | |||||
| <column><property name="text"><string>当前值</string></property></column> | |||||
| <column><property name="text"><string>状态</string></property></column> | |||||
| <column><property name="text"><string>地址</string></property></column> | |||||
| <column><property name="text"><string>类型</string></property></column> | |||||
| <column><property name="text"><string>当前值</string></property></column> | |||||
| <column><property name="text"><string>目标值</string></property></column> | |||||
| <column><property name="text"><string>写入</string></property></column> | |||||
| <column><property name="text"><string>状态</string></property></column> | |||||
| </widget> | </widget> | ||||
| </item> | </item> | ||||
| </layout> | </layout> | ||||
| @@ -2,7 +2,12 @@ | |||||
| #include "services/hmi_editor_service.h" | #include "services/hmi_editor_service.h" | ||||
| #include "services/hmi_runtime_service.h" | #include "services/hmi_runtime_service.h" | ||||
| #include "services/alarm_service.h" | |||||
| #include "domain/project_limits.h" | |||||
| #include <QApplication> | |||||
| #include <QDateTime> | |||||
| #include <QFont> | |||||
| #include <QGraphicsItem> | #include <QGraphicsItem> | ||||
| #include <QGraphicsRectItem> | #include <QGraphicsRectItem> | ||||
| #include <QGraphicsScene> | #include <QGraphicsScene> | ||||
| @@ -11,6 +16,8 @@ | |||||
| #include <QInputDialog> | #include <QInputDialog> | ||||
| #include <QPainter> | #include <QPainter> | ||||
| #include <QResizeEvent> | #include <QResizeEvent> | ||||
| #include <QStyle> | |||||
| #include <QStyleOption> | |||||
| #include <QStyleOptionGraphicsItem> | #include <QStyleOptionGraphicsItem> | ||||
| #include <algorithm> | #include <algorithm> | ||||
| @@ -21,6 +28,23 @@ | |||||
| namespace { | namespace { | ||||
| constexpr qreal kAlarmHeaderHeight = 24.0; | |||||
| constexpr qreal kAlarmRowHeight = 22.0; | |||||
| constexpr qreal kAlarmCellLeftPadding = 5.0; | |||||
| constexpr qreal kAlarmCellRightPadding = 5.0; | |||||
| constexpr qreal kAlarmColumnSpacing = 3.0; | |||||
| constexpr qreal kAlarmTimeColumnWidth = 76.0; | |||||
| constexpr qreal kAlarmStateColumnWidth = 52.0; | |||||
| constexpr qreal kAlarmPageButtonSize = 16.0; | |||||
| constexpr qreal kAlarmPageIndicatorWidth = 34.0; | |||||
| QString alarmTimeText(const std::chrono::system_clock::time_point &time) | |||||
| { | |||||
| const auto seconds = std::chrono::duration_cast<std::chrono::seconds>( | |||||
| time.time_since_epoch()).count(); | |||||
| return QDateTime::fromSecsSinceEpoch(seconds).toString(QStringLiteral("HH:mm:ss")); | |||||
| } | |||||
| // 将领域层 HMI 控件投影为可绘制、可选择和可交互的场景图元 | // 将领域层 HMI 控件投影为可绘制、可选择和可交互的场景图元 | ||||
| class HmiGraphicsItem final : public QGraphicsItem | class HmiGraphicsItem final : public QGraphicsItem | ||||
| { | { | ||||
| @@ -33,14 +57,16 @@ public: | |||||
| std::function<void(const std::string &, const QPointF &)> moved, | std::function<void(const std::string &, const QPointF &)> moved, | ||||
| std::function<void(const std::string &, HmiButtonEvent)> button_event, | std::function<void(const std::string &, HmiButtonEvent)> button_event, | ||||
| std::function<void(const std::string &)> numeric_input_activated, | std::function<void(const std::string &)> numeric_input_activated, | ||||
| std::function<void(const std::string &)> page_navigation) | |||||
| std::function<void(const std::string &)> page_navigation, | |||||
| std::function<void(const std::string &)> alarm_acknowledge) | |||||
| : control_(control), | : control_(control), | ||||
| page_width_(page_width), | page_width_(page_width), | ||||
| page_height_(page_height), | page_height_(page_height), | ||||
| moved_(std::move(moved)), | moved_(std::move(moved)), | ||||
| button_event_(std::move(button_event)), | button_event_(std::move(button_event)), | ||||
| numeric_input_activated_(std::move(numeric_input_activated)), | numeric_input_activated_(std::move(numeric_input_activated)), | ||||
| page_navigation_(std::move(page_navigation)) | |||||
| page_navigation_(std::move(page_navigation)), | |||||
| alarm_acknowledge_(std::move(alarm_acknowledge)) | |||||
| { | { | ||||
| // 领域坐标直接作为图元在场景中的初始位置 | // 领域坐标直接作为图元在场景中的初始位置 | ||||
| setPos(control_.bounds.x, control_.bounds.y); | setPos(control_.bounds.x, control_.bounds.y); | ||||
| @@ -57,9 +83,9 @@ public: | |||||
| { | { | ||||
| if (!control_.binding.has_value()) | if (!control_.binding.has_value()) | ||||
| { | { | ||||
| return controlRect(); | |||||
| return visibleControlRect(); | |||||
| } | } | ||||
| return controlRect().united(addressRect()); | |||||
| return visibleControlRect().united(addressRect()); | |||||
| } | } | ||||
| void paint( | void paint( | ||||
| @@ -68,7 +94,7 @@ public: | |||||
| QWidget *) override | QWidget *) override | ||||
| { | { | ||||
| // 留出一个像素边距,避免描边被图元边界裁剪 | // 留出一个像素边距,避免描边被图元边界裁剪 | ||||
| const QRectF rect = controlRect().adjusted(1, 1, -1, -1); | |||||
| const QRectF rect = visibleControlRect().adjusted(1, 1, -1, -1); | |||||
| painter->setRenderHint(QPainter::Antialiasing, true); | painter->setRenderHint(QPainter::Antialiasing, true); | ||||
| painter->setPen(QPen(QColor(QStringLiteral("#47545f")), 1)); | painter->setPen(QPen(QColor(QStringLiteral("#47545f")), 1)); | ||||
| @@ -84,6 +110,8 @@ public: | |||||
| painter->setPen(QPen(QColor(QStringLiteral("#47545f")), 1)); | painter->setPen(QPen(QColor(QStringLiteral("#47545f")), 1)); | ||||
| } | } | ||||
| applyConfiguredFont(painter); | |||||
| switch (control_.type) | switch (control_.type) | ||||
| { | { | ||||
| case HmiControlType::Button: | case HmiControlType::Button: | ||||
| @@ -101,7 +129,7 @@ public: | |||||
| : QColor(QStringLiteral("#5e816b")); | : QColor(QStringLiteral("#5e816b")); | ||||
| const QColor text = disabled | const QColor text = disabled | ||||
| ? QColor(QStringLiteral("#7a837e")) | ? QColor(QStringLiteral("#7a837e")) | ||||
| : QColor(QStringLiteral("#205c3b")); | |||||
| : configuredTextColor(QColor(QStringLiteral("#205c3b"))); | |||||
| // 未按下时保留下沿阴影,按下后将按钮面下移形成明确的回弹感 | // 未按下时保留下沿阴影,按下后将按钮面下移形成明确的回弹感 | ||||
| QRectF face = rect.adjusted(0, 0, 0, -2); | QRectF face = rect.adjusted(0, 0, 0, -2); | ||||
| @@ -136,7 +164,7 @@ public: | |||||
| painter->setBrush(bit_value_ ? QColor(QStringLiteral("#24a148")) | painter->setBrush(bit_value_ ? QColor(QStringLiteral("#24a148")) | ||||
| : QColor(QStringLiteral("#b8c1c8"))); | : QColor(QStringLiteral("#b8c1c8"))); | ||||
| painter->drawEllipse(lamp); | painter->drawEllipse(lamp); | ||||
| painter->setPen(QColor(QStringLiteral("#24313b"))); | |||||
| painter->setPen(configuredTextColor(QColor(QStringLiteral("#24313b")))); | |||||
| painter->drawText( | painter->drawText( | ||||
| QRectF(rect.left(), lamp.bottom() + 1, rect.width(), 16), | QRectF(rect.left(), lamp.bottom() + 1, rect.width(), 16), | ||||
| Qt::AlignCenter, | Qt::AlignCenter, | ||||
| @@ -149,7 +177,7 @@ public: | |||||
| // 数值显示为只读样式,文本由运行值刷新 | // 数值显示为只读样式,文本由运行值刷新 | ||||
| painter->setBrush(QColor(QStringLiteral("#edf2f6"))); | painter->setBrush(QColor(QStringLiteral("#edf2f6"))); | ||||
| painter->drawRect(rect); | painter->drawRect(rect); | ||||
| painter->setPen(QColor(QStringLiteral("#24313b"))); | |||||
| painter->setPen(configuredTextColor(QColor(QStringLiteral("#24313b")))); | |||||
| painter->drawText(rect.adjusted(7, 0, -7, 0), | painter->drawText(rect.adjusted(7, 0, -7, 0), | ||||
| Qt::AlignVCenter | Qt::AlignLeft, | Qt::AlignVCenter | Qt::AlignLeft, | ||||
| textWithValue()); | textWithValue()); | ||||
| @@ -160,12 +188,54 @@ public: | |||||
| // 数值输入以白色编辑框样式呈现,双击后才请求写入 | // 数值输入以白色编辑框样式呈现,双击后才请求写入 | ||||
| painter->setBrush(QColor(QStringLiteral("#ffffff"))); | painter->setBrush(QColor(QStringLiteral("#ffffff"))); | ||||
| painter->drawRoundedRect(rect, 3, 3); | painter->drawRoundedRect(rect, 3, 3); | ||||
| painter->setPen(QColor(QStringLiteral("#24313b"))); | |||||
| painter->setPen(configuredTextColor(QColor(QStringLiteral("#24313b")))); | |||||
| painter->drawText(rect.adjusted(7, 0, -7, 0), | painter->drawText(rect.adjusted(7, 0, -7, 0), | ||||
| Qt::AlignVCenter | Qt::AlignLeft, | Qt::AlignVCenter | Qt::AlignLeft, | ||||
| textWithValue()); | textWithValue()); | ||||
| break; | break; | ||||
| } | } | ||||
| case HmiControlType::ProgressBar: | |||||
| { | |||||
| const HmiProgressBarConfig config = control_.progressBar.value_or( | |||||
| HmiProgressBarConfig{}); | |||||
| const int percentage = has_runtime_value_ | |||||
| ? config.percentageForValue(word_value_) : 0; | |||||
| painter->setPen(Qt::NoPen); | |||||
| painter->setBrush(QColor(QStringLiteral("#e4e9e7"))); | |||||
| painter->drawRoundedRect(rect, 4, 4); | |||||
| const QRectF track = rect.adjusted(2, 2, -2, -2); | |||||
| if (has_runtime_value_ && percentage > 0) | |||||
| { | |||||
| QRectF fill = track; | |||||
| fill.setWidth(track.width() * percentage / 100.0); | |||||
| painter->setBrush(QColor(QStringLiteral("#3c8c62"))); | |||||
| painter->drawRoundedRect(fill, 3, 3); | |||||
| } | |||||
| painter->setBrush(Qt::NoBrush); | |||||
| painter->setPen(QPen(QColor(QStringLiteral("#617069")), 1)); | |||||
| painter->drawRoundedRect(rect, 4, 4); | |||||
| QString display_text = QString::fromUtf8( | |||||
| control_.text.data(), static_cast<int>(control_.text.size())); | |||||
| if (config.showValue) | |||||
| { | |||||
| const QString percentage_text = has_runtime_value_ | |||||
| ? QString::number(percentage) + QLatin1Char('%') | |||||
| : runtime_active_ ? QStringLiteral("--") | |||||
| : QStringLiteral("0%"); | |||||
| display_text = display_text.isEmpty() | |||||
| ? percentage_text | |||||
| : display_text + QStringLiteral(": ") + percentage_text; | |||||
| } | |||||
| painter->setPen(configuredTextColor(QColor(QStringLiteral("#20342a")))); | |||||
| painter->drawText(rect.adjusted(6, 0, -6, 0), | |||||
| Qt::AlignCenter, | |||||
| display_text); | |||||
| break; | |||||
| } | |||||
| case HmiControlType::PageJump: | case HmiControlType::PageJump: | ||||
| { | { | ||||
| const QColor fill = runtime_active_ && page_hovered_ | const QColor fill = runtime_active_ && page_hovered_ | ||||
| @@ -174,15 +244,141 @@ public: | |||||
| painter->setBrush(fill); | painter->setBrush(fill); | ||||
| painter->setPen(QPen(QColor(QStringLiteral("#4e789f")), 1)); | painter->setPen(QPen(QColor(QStringLiteral("#4e789f")), 1)); | ||||
| painter->drawRoundedRect(rect, 4, 4); | painter->drawRoundedRect(rect, 4, 4); | ||||
| painter->setPen(QColor(QStringLiteral("#244b6b"))); | |||||
| painter->setPen(configuredTextColor(QColor(QStringLiteral("#244b6b")))); | |||||
| painter->drawText(rect, Qt::AlignCenter, textWithValue()); | painter->drawText(rect, Qt::AlignCenter, textWithValue()); | ||||
| break; | break; | ||||
| } | } | ||||
| case HmiControlType::AlarmList: | |||||
| { | |||||
| painter->setPen(QPen(QColor(QStringLiteral("#9b3a3a")), 1)); | |||||
| painter->setBrush(QColor(QStringLiteral("#ffffff"))); | |||||
| painter->drawRect(rect); | |||||
| const QRectF header( | |||||
| rect.left(), rect.top(), rect.width(), kAlarmHeaderHeight); | |||||
| painter->fillRect(header, QColor(QStringLiteral("#a63f3f"))); | |||||
| painter->setPen(Qt::white); | |||||
| QRectF title_rect = header.adjusted(7, 0, -7, 0); | |||||
| const std::size_t page_count = alarmPageCount(); | |||||
| if (runtime_active_ && page_count > 1U) | |||||
| { | |||||
| const QRectF previous_rect = alarmPreviousPageRect(header); | |||||
| const QRectF next_rect = alarmNextPageRect(header); | |||||
| const QRectF indicator_rect = alarmPageIndicatorRect(header); | |||||
| title_rect.setRight(previous_rect.left() - kAlarmColumnSpacing); | |||||
| QStyleOption previous_option; | |||||
| previous_option.rect = previous_rect.toAlignedRect(); | |||||
| previous_option.state = alarm_page_ > 0U | |||||
| ? QStyle::State_Enabled : QStyle::State_None; | |||||
| QApplication::style()->drawPrimitive( | |||||
| QStyle::PE_IndicatorArrowLeft, | |||||
| &previous_option, | |||||
| painter); | |||||
| QStyleOption next_option; | |||||
| next_option.rect = next_rect.toAlignedRect(); | |||||
| next_option.state = alarm_page_ + 1U < page_count | |||||
| ? QStyle::State_Enabled : QStyle::State_None; | |||||
| QApplication::style()->drawPrimitive( | |||||
| QStyle::PE_IndicatorArrowRight, | |||||
| &next_option, | |||||
| painter); | |||||
| painter->setPen(Qt::white); | |||||
| painter->drawText( | |||||
| indicator_rect, | |||||
| Qt::AlignCenter, | |||||
| QStringLiteral("%1/%2") | |||||
| .arg(static_cast<qulonglong>(alarm_page_ + 1U)) | |||||
| .arg(static_cast<qulonglong>(page_count))); | |||||
| } | |||||
| const QString title = QString::fromUtf8( | |||||
| control_.text.data(), static_cast<int>(control_.text.size())); | |||||
| painter->drawText( | |||||
| title_rect, | |||||
| Qt::AlignVCenter | Qt::AlignLeft, | |||||
| painter->fontMetrics().elidedText( | |||||
| title, | |||||
| Qt::ElideRight, | |||||
| std::max(0, static_cast<int>(title_rect.width())))); | |||||
| const int visible_rows = static_cast<int>(visibleAlarmRecordCount()); | |||||
| if (visible_rows == 0) | |||||
| { | |||||
| painter->setPen(configuredTextColor(QColor(QStringLiteral("#6f7a82")))); | |||||
| painter->drawText( | |||||
| QRectF( | |||||
| rect.left(), | |||||
| header.bottom(), | |||||
| rect.width(), | |||||
| rect.height() - kAlarmHeaderHeight), | |||||
| Qt::AlignCenter, | |||||
| runtime_active_ ? QObject::tr("暂无报警") | |||||
| : QObject::tr("运行时显示当前报警")); | |||||
| break; | |||||
| } | |||||
| const std::size_t first_record = alarmFirstRecordIndex(); | |||||
| for (int row = 0; row < visible_rows; ++row) | |||||
| { | |||||
| const AlarmRecord &record = alarm_records_[ | |||||
| first_record + static_cast<std::size_t>(row)]; | |||||
| const QRectF row_rect( | |||||
| rect.left(), | |||||
| header.bottom() + row * kAlarmRowHeight, | |||||
| rect.width(), | |||||
| kAlarmRowHeight); | |||||
| painter->fillRect( | |||||
| row_rect, | |||||
| QColor(QStringLiteral("#fde8e8"))); | |||||
| painter->setPen(QColor(QStringLiteral("#d3d8dc"))); | |||||
| painter->drawLine(row_rect.bottomLeft(), row_rect.bottomRight()); | |||||
| painter->setPen(configuredTextColor(QColor(QStringLiteral("#8d1f1f")))); | |||||
| const QString state = record.acknowledged | |||||
| ? QObject::tr("已确认") : QObject::tr("未确认"); | |||||
| const QRectF time_rect( | |||||
| row_rect.left() + kAlarmCellLeftPadding, | |||||
| row_rect.top(), | |||||
| kAlarmTimeColumnWidth, | |||||
| row_rect.height()); | |||||
| const QRectF state_rect( | |||||
| time_rect.right() + kAlarmColumnSpacing, | |||||
| row_rect.top(), | |||||
| kAlarmStateColumnWidth, | |||||
| row_rect.height()); | |||||
| const qreal message_left = | |||||
| state_rect.right() + kAlarmColumnSpacing; | |||||
| const QRectF message_rect( | |||||
| message_left, | |||||
| row_rect.top(), | |||||
| std::max( | |||||
| 0.0, | |||||
| row_rect.right() - kAlarmCellRightPadding | |||||
| - message_left), | |||||
| row_rect.height()); | |||||
| painter->drawText( | |||||
| time_rect, | |||||
| Qt::AlignVCenter | Qt::AlignLeft, | |||||
| alarmTimeText(record.occurredAt)); | |||||
| painter->drawText( | |||||
| state_rect, | |||||
| Qt::AlignVCenter | Qt::AlignLeft, | |||||
| state); | |||||
| painter->drawText( | |||||
| message_rect, | |||||
| Qt::AlignVCenter | Qt::AlignLeft, | |||||
| painter->fontMetrics().elidedText( | |||||
| QString::fromUtf8( | |||||
| record.message.data(), | |||||
| static_cast<int>(record.message.size())), | |||||
| Qt::ElideRight, | |||||
| std::max(0, static_cast<int>(message_rect.width())))); | |||||
| } | |||||
| break; | |||||
| } | |||||
| case HmiControlType::Label: | case HmiControlType::Label: | ||||
| default: | default: | ||||
| { | { | ||||
| // 标签只显示固定文本,不绑定寄存器运行值 | // 标签只显示固定文本,不绑定寄存器运行值 | ||||
| painter->setPen(QColor(QStringLiteral("#24313b"))); | |||||
| painter->setPen(configuredTextColor(QColor(QStringLiteral("#24313b")))); | |||||
| painter->drawText(rect, Qt::AlignCenter, | painter->drawText(rect, Qt::AlignCenter, | ||||
| QString::fromUtf8(control_.text.data(), | QString::fromUtf8(control_.text.data(), | ||||
| static_cast<int>(control_.text.size()))); | static_cast<int>(control_.text.size()))); | ||||
| @@ -209,6 +405,14 @@ public: | |||||
| void setInteractionState( | void setInteractionState( | ||||
| bool editable, bool runtime_active, bool runtime_write_enabled) | bool editable, bool runtime_active, bool runtime_write_enabled) | ||||
| { | { | ||||
| if (control_.type == HmiControlType::AlarmList) | |||||
| { | |||||
| prepareGeometryChange(); | |||||
| if (!runtime_active) | |||||
| { | |||||
| alarm_page_ = 0U; | |||||
| } | |||||
| } | |||||
| editing_enabled_ = editable; | editing_enabled_ = editable; | ||||
| runtime_active_ = runtime_active; | runtime_active_ = runtime_active; | ||||
| runtime_write_enabled_ = runtime_active && runtime_write_enabled; | runtime_write_enabled_ = runtime_active && runtime_write_enabled; | ||||
| @@ -222,9 +426,12 @@ public: | |||||
| const bool runtime_input = runtime_active_ | const bool runtime_input = runtime_active_ | ||||
| && (control_.type == HmiControlType::Button | && (control_.type == HmiControlType::Button | ||||
| || control_.type == HmiControlType::NumericInput | || control_.type == HmiControlType::NumericInput | ||||
| || control_.type == HmiControlType::PageJump); | |||||
| || control_.type == HmiControlType::PageJump | |||||
| || control_.type == HmiControlType::AlarmList); | |||||
| const bool runtime_input_enabled = runtime_input | const bool runtime_input_enabled = runtime_input | ||||
| && (control_.type == HmiControlType::PageJump || runtime_write_enabled_); | |||||
| && (control_.type == HmiControlType::PageJump | |||||
| || control_.type == HmiControlType::AlarmList | |||||
| || runtime_write_enabled_); | |||||
| setAcceptedMouseButtons( | setAcceptedMouseButtons( | ||||
| editable || runtime_input_enabled ? Qt::LeftButton : Qt::NoButton); | editable || runtime_input_enabled ? Qt::LeftButton : Qt::NoButton); | ||||
| @@ -232,8 +439,11 @@ public: | |||||
| && control_.type == HmiControlType::Button && runtime_write_enabled_; | && control_.type == HmiControlType::Button && runtime_write_enabled_; | ||||
| const bool runtime_page_jump_enabled = runtime_active_ | const bool runtime_page_jump_enabled = runtime_active_ | ||||
| && control_.type == HmiControlType::PageJump; | && control_.type == HmiControlType::PageJump; | ||||
| const bool runtime_alarm_enabled = runtime_active_ | |||||
| && control_.type == HmiControlType::AlarmList; | |||||
| setAcceptHoverEvents(runtime_button_enabled || runtime_page_jump_enabled); | setAcceptHoverEvents(runtime_button_enabled || runtime_page_jump_enabled); | ||||
| if (runtime_button_enabled || runtime_page_jump_enabled) | |||||
| if (runtime_button_enabled || runtime_page_jump_enabled | |||||
| || runtime_alarm_enabled) | |||||
| { | { | ||||
| setCursor(Qt::PointingHandCursor); | setCursor(Qt::PointingHandCursor); | ||||
| } | } | ||||
| @@ -244,6 +454,7 @@ public: | |||||
| page_hovered_ = false; | page_hovered_ = false; | ||||
| button_pressed_ = false; | button_pressed_ = false; | ||||
| } | } | ||||
| updateAlarmVisibility(); | |||||
| update(); | update(); | ||||
| } | } | ||||
| @@ -256,6 +467,20 @@ public: | |||||
| update(); | update(); | ||||
| } | } | ||||
| void setAlarmRecords(const std::vector<AlarmRecord> &records) | |||||
| { | |||||
| if (control_.type == HmiControlType::AlarmList) | |||||
| { | |||||
| prepareGeometryChange(); | |||||
| } | |||||
| alarm_records_ = records; | |||||
| const std::size_t page_count = alarmPageCount(); | |||||
| alarm_page_ = page_count == 0U | |||||
| ? 0U : std::min(alarm_page_, page_count - 1U); | |||||
| updateAlarmVisibility(); | |||||
| update(); | |||||
| } | |||||
| protected: | protected: | ||||
| // 拖拽过程中将新位置限制在页面可见边界内 | // 拖拽过程中将新位置限制在页面可见边界内 | ||||
| QVariant itemChange(GraphicsItemChange change, const QVariant &value) override | QVariant itemChange(GraphicsItemChange change, const QVariant &value) override | ||||
| @@ -279,6 +504,52 @@ protected: | |||||
| // 运行态按钮按下时通知外层执行配置的 M 位操作 | // 运行态按钮按下时通知外层执行配置的 M 位操作 | ||||
| void mousePressEvent(QGraphicsSceneMouseEvent *event) override | void mousePressEvent(QGraphicsSceneMouseEvent *event) override | ||||
| { | { | ||||
| if (runtime_active_ && control_.type == HmiControlType::AlarmList) | |||||
| { | |||||
| if (event->pos().y() < kAlarmHeaderHeight) | |||||
| { | |||||
| const QRectF content_rect = | |||||
| visibleControlRect().adjusted(1, 1, -1, -1); | |||||
| const QRectF header( | |||||
| content_rect.left(), | |||||
| content_rect.top(), | |||||
| content_rect.width(), | |||||
| kAlarmHeaderHeight); | |||||
| const std::size_t page_count = alarmPageCount(); | |||||
| if (alarmPreviousPageRect(header).contains(event->pos()) | |||||
| && alarm_page_ > 0U) | |||||
| { | |||||
| prepareGeometryChange(); | |||||
| --alarm_page_; | |||||
| update(); | |||||
| } | |||||
| else if (alarmNextPageRect(header).contains(event->pos()) | |||||
| && alarm_page_ + 1U < page_count) | |||||
| { | |||||
| prepareGeometryChange(); | |||||
| ++alarm_page_; | |||||
| update(); | |||||
| } | |||||
| event->accept(); | |||||
| return; | |||||
| } | |||||
| const int row = static_cast<int>( | |||||
| (event->pos().y() - kAlarmHeaderHeight) / kAlarmRowHeight); | |||||
| const std::size_t record_index = alarmFirstRecordIndex() | |||||
| + static_cast<std::size_t>(std::max(0, row)); | |||||
| if (row >= 0 | |||||
| && row < static_cast<int>(visibleAlarmRecordCount()) | |||||
| && record_index < alarm_records_.size()) | |||||
| { | |||||
| const AlarmRecord &record = alarm_records_[record_index]; | |||||
| if (!record.acknowledged && alarm_acknowledge_) | |||||
| { | |||||
| alarm_acknowledge_(record.definitionId); | |||||
| } | |||||
| } | |||||
| event->accept(); | |||||
| return; | |||||
| } | |||||
| if (runtime_active_ && control_.type == HmiControlType::PageJump) | if (runtime_active_ && control_.type == HmiControlType::PageJump) | ||||
| { | { | ||||
| page_pressed_ = true; | page_pressed_ = true; | ||||
| @@ -378,6 +649,97 @@ protected: | |||||
| } | } | ||||
| private: | private: | ||||
| std::size_t alarmPageSize() const | |||||
| { | |||||
| const QRectF rect = controlRect().adjusted(1, 1, -1, -1); | |||||
| const int rows_by_height = std::max( | |||||
| 0, | |||||
| static_cast<int>((rect.height() - kAlarmHeaderHeight) | |||||
| / kAlarmRowHeight)); | |||||
| return static_cast<std::size_t>(std::min( | |||||
| rows_by_height, ProjectLimits::kMaximumVisibleAlarmRows)); | |||||
| } | |||||
| std::size_t alarmPageCount() const | |||||
| { | |||||
| const std::size_t page_size = alarmPageSize(); | |||||
| return page_size == 0U || alarm_records_.empty() | |||||
| ? 0U | |||||
| : (alarm_records_.size() + page_size - 1U) / page_size; | |||||
| } | |||||
| std::size_t alarmFirstRecordIndex() const | |||||
| { | |||||
| return alarm_page_ * alarmPageSize(); | |||||
| } | |||||
| std::size_t visibleAlarmRecordCount() const | |||||
| { | |||||
| const std::size_t first_record = alarmFirstRecordIndex(); | |||||
| if (first_record >= alarm_records_.size()) | |||||
| { | |||||
| return 0U; | |||||
| } | |||||
| return std::min( | |||||
| alarmPageSize(), alarm_records_.size() - first_record); | |||||
| } | |||||
| QRectF visibleControlRect() const | |||||
| { | |||||
| const QRectF full_rect = controlRect(); | |||||
| if (control_.type != HmiControlType::AlarmList || !runtime_active_) | |||||
| { | |||||
| return full_rect; | |||||
| } | |||||
| const qreal content_height = kAlarmHeaderHeight | |||||
| + static_cast<qreal>(visibleAlarmRecordCount()) * kAlarmRowHeight | |||||
| + 2.0; | |||||
| return {full_rect.left(), | |||||
| full_rect.top(), | |||||
| full_rect.width(), | |||||
| std::min(full_rect.height(), content_height)}; | |||||
| } | |||||
| QRectF alarmNextPageRect(const QRectF &header) const | |||||
| { | |||||
| return { | |||||
| header.right() - kAlarmCellRightPadding - kAlarmPageButtonSize, | |||||
| header.center().y() - kAlarmPageButtonSize / 2.0, | |||||
| kAlarmPageButtonSize, | |||||
| kAlarmPageButtonSize}; | |||||
| } | |||||
| QRectF alarmPageIndicatorRect(const QRectF &header) const | |||||
| { | |||||
| const QRectF next_rect = alarmNextPageRect(header); | |||||
| return { | |||||
| next_rect.left() - kAlarmPageIndicatorWidth, | |||||
| header.top(), | |||||
| kAlarmPageIndicatorWidth, | |||||
| header.height()}; | |||||
| } | |||||
| QRectF alarmPreviousPageRect(const QRectF &header) const | |||||
| { | |||||
| const QRectF indicator_rect = alarmPageIndicatorRect(header); | |||||
| return { | |||||
| indicator_rect.left() - kAlarmPageButtonSize, | |||||
| header.center().y() - kAlarmPageButtonSize / 2.0, | |||||
| kAlarmPageButtonSize, | |||||
| kAlarmPageButtonSize}; | |||||
| } | |||||
| void updateAlarmVisibility() | |||||
| { | |||||
| if (control_.type != HmiControlType::AlarmList) | |||||
| { | |||||
| return; | |||||
| } | |||||
| const bool has_active_alarm = !alarm_records_.empty(); | |||||
| setVisible(editing_enabled_ || !runtime_active_ || has_active_alarm); | |||||
| setZValue(runtime_active_ && has_active_alarm ? 1000.0 : 0.0); | |||||
| } | |||||
| // 控件本体范围保持领域坐标和尺寸语义不变 | // 控件本体范围保持领域坐标和尺寸语义不变 | ||||
| QRectF controlRect() const | QRectF controlRect() const | ||||
| { | { | ||||
| @@ -405,6 +767,64 @@ private: | |||||
| : QString{}; | : QString{}; | ||||
| } | } | ||||
| QColor configuredTextColor(const QColor &fallback) const | |||||
| { | |||||
| const auto property = control_.properties.find( | |||||
| HmiAppearanceProperty::kTextColor); | |||||
| if (property == control_.properties.cend()) | |||||
| { | |||||
| return fallback; | |||||
| } | |||||
| const QColor color = QColor(QString::fromUtf8( | |||||
| property->second.data(), static_cast<int>(property->second.size()))); | |||||
| return color.isValid() ? color : fallback; | |||||
| } | |||||
| void applyConfiguredFont(QPainter *painter) const | |||||
| { | |||||
| if (painter == nullptr) | |||||
| { | |||||
| return; | |||||
| } | |||||
| QFont font = painter->font(); | |||||
| const auto font_size = control_.properties.find( | |||||
| HmiAppearanceProperty::kFontSize); | |||||
| if (font_size != control_.properties.cend()) | |||||
| { | |||||
| bool ok = false; | |||||
| const int point_size = QString::fromUtf8( | |||||
| font_size->second.data(), | |||||
| static_cast<int>(font_size->second.size())).toInt(&ok); | |||||
| if (ok | |||||
| && point_size >= ProjectLimits::kMinimumHmiFontPointSize | |||||
| && point_size <= ProjectLimits::kMaximumHmiFontPointSize) | |||||
| { | |||||
| font.setPointSize(point_size); | |||||
| } | |||||
| } | |||||
| else if (control_.type == HmiControlType::AlarmList | |||||
| && font.pointSize() > 0) | |||||
| { | |||||
| font.setPointSize(std::max( | |||||
| ProjectLimits::kMinimumHmiFontPointSize, | |||||
| font.pointSize() | |||||
| - ProjectLimits::kAlarmDefaultFontPointReduction)); | |||||
| } | |||||
| const auto font_bold = control_.properties.find( | |||||
| HmiAppearanceProperty::kFontBold); | |||||
| if (font_bold != control_.properties.cend()) | |||||
| { | |||||
| font.setBold(font_bold->second == "true"); | |||||
| } | |||||
| const auto font_italic = control_.properties.find( | |||||
| HmiAppearanceProperty::kFontItalic); | |||||
| if (font_italic != control_.properties.cend()) | |||||
| { | |||||
| font.setItalic(font_italic->second == "true"); | |||||
| } | |||||
| painter->setFont(font); | |||||
| } | |||||
| // 根据控件类型和运行数据组合当前应绘制的文字 | // 根据控件类型和运行数据组合当前应绘制的文字 | ||||
| QString textWithValue() const | QString textWithValue() const | ||||
| { | { | ||||
| @@ -433,6 +853,9 @@ private: | |||||
| std::function<void(const std::string &, HmiButtonEvent)> button_event_; | std::function<void(const std::string &, HmiButtonEvent)> button_event_; | ||||
| std::function<void(const std::string &)> numeric_input_activated_; | std::function<void(const std::string &)> numeric_input_activated_; | ||||
| std::function<void(const std::string &)> page_navigation_; | std::function<void(const std::string &)> page_navigation_; | ||||
| std::function<void(const std::string &)> alarm_acknowledge_; | |||||
| std::vector<AlarmRecord> alarm_records_; | |||||
| std::size_t alarm_page_ = 0U; | |||||
| bool editing_enabled_ = true; | bool editing_enabled_ = true; | ||||
| bool runtime_active_ = false; | bool runtime_active_ = false; | ||||
| bool runtime_write_enabled_ = false; | bool runtime_write_enabled_ = false; | ||||
| @@ -459,10 +882,12 @@ HmiGraphicsItem *asHmiItem(QGraphicsItem *item) | |||||
| HmiEditorWidget::HmiEditorWidget( | HmiEditorWidget::HmiEditorWidget( | ||||
| HmiEditorService &editor_service, | HmiEditorService &editor_service, | ||||
| HmiRuntimeService &runtime_service, | HmiRuntimeService &runtime_service, | ||||
| AlarmService &alarm_service, | |||||
| QWidget *parent) | QWidget *parent) | ||||
| : QGraphicsView(parent), | : QGraphicsView(parent), | ||||
| editor_service_(editor_service), | editor_service_(editor_service), | ||||
| runtime_service_(runtime_service), | runtime_service_(runtime_service), | ||||
| alarm_service_(alarm_service), | |||||
| scene_(new QGraphicsScene(this)) | scene_(new QGraphicsScene(this)) | ||||
| { | { | ||||
| setObjectName(QStringLiteral("hmiEditorWidget")); | setObjectName(QStringLiteral("hmiEditorWidget")); | ||||
| @@ -504,6 +929,7 @@ void HmiEditorWidget::setRuntimeActive(bool active) | |||||
| if (control_item != nullptr) | if (control_item != nullptr) | ||||
| { | { | ||||
| control_item->setRuntimeValue(false, 0, false); | control_item->setRuntimeValue(false, 0, false); | ||||
| control_item->setAlarmRecords({}); | |||||
| } | } | ||||
| } | } | ||||
| } | } | ||||
| @@ -557,6 +983,11 @@ void HmiEditorWidget::reloadPage() | |||||
| { | { | ||||
| emit pageNavigationRequested( | emit pageNavigationRequested( | ||||
| QString::fromStdString(target_page_id)); | QString::fromStdString(target_page_id)); | ||||
| }, | |||||
| [this](const std::string &definition_id) | |||||
| { | |||||
| alarm_service_.acknowledge(definition_id); | |||||
| refreshRuntimeValues(); | |||||
| }); | }); | ||||
| scene_->addItem(item); | scene_->addItem(item); | ||||
| item->setInteractionState( | item->setInteractionState( | ||||
| @@ -569,6 +1000,7 @@ void HmiEditorWidget::reloadPage() | |||||
| // 遍历场景所有图元,找到对应 id 的图元,设置选中,视图滚动到把控件显示出来 | // 遍历场景所有图元,找到对应 id 的图元,设置选中,视图滚动到把控件显示出来 | ||||
| void HmiEditorWidget::selectControl(const std::string &control_id) | void HmiEditorWidget::selectControl(const std::string &control_id) | ||||
| { | { | ||||
| scene_->clearSelection(); | |||||
| for (QGraphicsItem *item : scene_->items()) | for (QGraphicsItem *item : scene_->items()) | ||||
| { | { | ||||
| HmiGraphicsItem *control_item = asHmiItem(item); | HmiGraphicsItem *control_item = asHmiItem(item); | ||||
| @@ -583,16 +1015,39 @@ void HmiEditorWidget::selectControl(const std::string &control_id) | |||||
| std::string HmiEditorWidget::selectedControlId() const | std::string HmiEditorWidget::selectedControlId() const | ||||
| { | { | ||||
| const QList<QGraphicsItem *> selected = scene_->selectedItems(); | |||||
| for (QGraphicsItem *item : selected) | |||||
| const std::vector<std::string> ids = selectedControlIds(); | |||||
| return ids.empty() ? std::string{} : ids.front(); | |||||
| } | |||||
| std::vector<std::string> HmiEditorWidget::selectedControlIds() const | |||||
| { | |||||
| std::vector<std::pair<QPointF, std::string>> positioned_ids; | |||||
| for (QGraphicsItem *item : scene_->selectedItems()) | |||||
| { | { | ||||
| const HmiGraphicsItem *control_item = asHmiItem(item); | const HmiGraphicsItem *control_item = asHmiItem(item); | ||||
| if (control_item != nullptr) | if (control_item != nullptr) | ||||
| { | { | ||||
| return control_item->controlId(); | |||||
| positioned_ids.emplace_back( | |||||
| control_item->scenePos(), control_item->controlId()); | |||||
| } | } | ||||
| } | } | ||||
| return {}; | |||||
| std::sort( | |||||
| positioned_ids.begin(), positioned_ids.end(), | |||||
| [](const auto &left, const auto &right) | |||||
| { | |||||
| if (!qFuzzyCompare(left.first.y(), right.first.y())) | |||||
| { | |||||
| return left.first.y() < right.first.y(); | |||||
| } | |||||
| return left.first.x() < right.first.x(); | |||||
| }); | |||||
| std::vector<std::string> ids; | |||||
| ids.reserve(positioned_ids.size()); | |||||
| for (const auto &positioned_id : positioned_ids) | |||||
| { | |||||
| ids.push_back(positioned_id.second); | |||||
| } | |||||
| return ids; | |||||
| } | } | ||||
| void HmiEditorWidget::refreshRuntimeValues() | void HmiEditorWidget::refreshRuntimeValues() | ||||
| @@ -614,6 +1069,11 @@ void HmiEditorWidget::refreshRuntimeValues() | |||||
| { | { | ||||
| continue; | continue; | ||||
| } | } | ||||
| if (control->type == HmiControlType::AlarmList) | |||||
| { | |||||
| control_item->setAlarmRecords(alarm_service_.records()); | |||||
| continue; | |||||
| } | |||||
| // 运行值通过服务读取,图元不直接接触寄存器仓库 | // 运行值通过服务读取,图元不直接接触寄存器仓库 | ||||
| const HmiRuntimeReadResult value = runtime_service_.readControl(*control); | const HmiRuntimeReadResult value = runtime_service_.readControl(*control); | ||||
| control_item->setRuntimeValue(value.bit_value, value.word_value, value.succeeded); | control_item->setRuntimeValue(value.bit_value, value.word_value, value.succeeded); | ||||
| @@ -10,9 +10,11 @@ | |||||
| #include <QGraphicsView> | #include <QGraphicsView> | ||||
| #include <string> | #include <string> | ||||
| #include <vector> | |||||
| class HmiEditorService; | class HmiEditorService; | ||||
| class HmiRuntimeService; | class HmiRuntimeService; | ||||
| class AlarmService; | |||||
| class QGraphicsScene; | class QGraphicsScene; | ||||
| class QResizeEvent; | class QResizeEvent; | ||||
| enum class HmiButtonEvent; | enum class HmiButtonEvent; | ||||
| @@ -36,6 +38,7 @@ public: | |||||
| explicit HmiEditorWidget( | explicit HmiEditorWidget( | ||||
| HmiEditorService &editor_service, | HmiEditorService &editor_service, | ||||
| HmiRuntimeService &runtime_service, | HmiRuntimeService &runtime_service, | ||||
| AlarmService &alarm_service, | |||||
| QWidget *parent = nullptr); | QWidget *parent = nullptr); | ||||
| /** | /** | ||||
| @@ -67,6 +70,7 @@ public: | |||||
| */ | */ | ||||
| void selectControl(const std::string &control_id); | void selectControl(const std::string &control_id); | ||||
| std::string selectedControlId() const; | std::string selectedControlId() const; | ||||
| std::vector<std::string> selectedControlIds() const; | |||||
| /** | /** | ||||
| * @brief 从统一寄存器仓库读取当前值并刷新画布显示 | * @brief 从统一寄存器仓库读取当前值并刷新画布显示 | ||||
| * | * | ||||
| @@ -114,6 +118,7 @@ private: | |||||
| HmiEditorService &editor_service_; | HmiEditorService &editor_service_; | ||||
| // 提供运行态寄存器读写能力,画布不直接访问寄存器仓库 | // 提供运行态寄存器读写能力,画布不直接访问寄存器仓库 | ||||
| HmiRuntimeService &runtime_service_; | HmiRuntimeService &runtime_service_; | ||||
| AlarmService &alarm_service_; | |||||
| // 持有所有页面图元和页面边框 | // 持有所有页面图元和页面边框 | ||||
| QGraphicsScene *scene_ = nullptr; | QGraphicsScene *scene_ = nullptr; | ||||
| // 当前画布投影的页面标识 | // 当前画布投影的页面标识 | ||||
| @@ -7,6 +7,7 @@ | |||||
| #include <QGraphicsView> | #include <QGraphicsView> | ||||
| #include <string> | #include <string> | ||||
| #include <utility> | |||||
| #include <vector> | #include <vector> | ||||
| class QGraphicsScene; | class QGraphicsScene; | ||||
| @@ -18,7 +19,11 @@ class LogicEditorWidget final : public QGraphicsView | |||||
| public: | public: | ||||
| class NodeItem; | class NodeItem; | ||||
| class WireItem; | |||||
| class WireCellItem; | |||||
| class VerticalConnectorItem; | |||||
| class RungItem; | class RungItem; | ||||
| class EmptySlotItem; | |||||
| explicit LogicEditorWidget( | explicit LogicEditorWidget( | ||||
| LogicEditorService &editor_service, | LogicEditorService &editor_service, | ||||
| @@ -39,7 +44,12 @@ public: | |||||
| LogicEditorResult addRung(); | LogicEditorResult addRung(); | ||||
| LogicEditorResult addCondition(const LogicNodeConfig &config); | LogicEditorResult addCondition(const LogicNodeConfig &config); | ||||
| LogicEditorResult addParallelBranch(const LogicNodeConfig &config); | LogicEditorResult addParallelBranch(const LogicNodeConfig &config); | ||||
| LogicEditorResult setOutput(const LogicNodeConfig &config); | |||||
| LogicEditorResult addHorizontalWire(); | |||||
| LogicEditorResult addVerticalWire(); | |||||
| LogicEditorResult deleteHorizontalWire(); | |||||
| LogicEditorResult deleteVerticalWire(); | |||||
| LogicEditorResult setOutput( | |||||
| const LogicNodeConfig &config, bool configured = false); | |||||
| LogicEditorResult deleteSelected(); | LogicEditorResult deleteSelected(); | ||||
| signals: | signals: | ||||
| @@ -54,6 +64,13 @@ private: | |||||
| void handleSelectionChanged(); | void handleSelectionChanged(); | ||||
| void reportFailure(const LogicEditorResult &result); | void reportFailure(const LogicEditorResult &result); | ||||
| std::string currentRungId() const; | std::string currentRungId() const; | ||||
| void selectExpression(const std::string &expression_id); | |||||
| void selectWire(const std::string &wire_id); | |||||
| std::vector<std::string> selectedExpressionIds() const; | |||||
| std::vector<std::string> selectedWireIds() const; | |||||
| std::vector<std::string> selectedBranchIds() const; | |||||
| std::vector<int> selectedEmptySlotColumns() const; | |||||
| std::vector<std::pair<std::string, int>> selectedWireCells() const; | |||||
| LogicEditorService &editor_service_; | LogicEditorService &editor_service_; | ||||
| QGraphicsScene *scene_ = nullptr; | QGraphicsScene *scene_ = nullptr; | ||||
| @@ -0,0 +1,207 @@ | |||||
| #include "logic_instruction_dialog.h" | |||||
| #include "ui_logic_instruction_dialog.h" | |||||
| #include <QComboBox> | |||||
| #include <QDialogButtonBox> | |||||
| #include <QLabel> | |||||
| #include <QSpinBox> | |||||
| #include <limits> | |||||
| namespace { | |||||
| constexpr int kConstantOperandData = 0; | |||||
| constexpr int kRegisterOperandData = 1; | |||||
| void configureOperandCombo(QComboBox *combo) | |||||
| { | |||||
| combo->clear(); | |||||
| combo->addItem(LogicInstructionDialog::tr("常量"), kConstantOperandData); | |||||
| combo->addItem(LogicInstructionDialog::tr("D 寄存器"), kRegisterOperandData); | |||||
| } | |||||
| } // namespace | |||||
| LogicInstructionDialog::LogicInstructionDialog( | |||||
| const LogicNodeConfig &config, | |||||
| QWidget *parent) | |||||
| : QDialog(parent), | |||||
| ui_(std::make_unique<Ui::LogicInstructionDialog>()), | |||||
| original_config_(config) | |||||
| { | |||||
| ui_->setupUi(this); | |||||
| configureOperandCombo(ui_->counterPresetKindComboBox); | |||||
| configureOperandCombo(ui_->leftOperandKindComboBox); | |||||
| configureOperandCombo(ui_->rightOperandKindComboBox); | |||||
| connect(ui_->buttonBox, &QDialogButtonBox::accepted, this, &QDialog::accept); | |||||
| connect(ui_->buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject); | |||||
| for (QComboBox *combo : { | |||||
| ui_->counterPresetKindComboBox, | |||||
| ui_->leftOperandKindComboBox, | |||||
| ui_->rightOperandKindComboBox}) | |||||
| { | |||||
| connect( | |||||
| combo, | |||||
| qOverload<int>(&QComboBox::currentIndexChanged), | |||||
| this, | |||||
| &LogicInstructionDialog::updateOperandRanges); | |||||
| } | |||||
| if (const auto *counter = std::get_if<CounterNodeConfig>(&config)) | |||||
| { | |||||
| configureCounter(*counter); | |||||
| } | |||||
| else if (const auto *move = std::get_if<MoveNodeConfig>(&config)) | |||||
| { | |||||
| configureMove(*move); | |||||
| } | |||||
| else if (const auto *arithmetic = std::get_if<ArithmeticNodeConfig>(&config)) | |||||
| { | |||||
| configureArithmetic(*arithmetic); | |||||
| } | |||||
| updateOperandRanges(); | |||||
| } | |||||
| LogicInstructionDialog::~LogicInstructionDialog() = default; | |||||
| LogicNodeConfig LogicInstructionDialog::config() const | |||||
| { | |||||
| if (std::holds_alternative<CounterNodeConfig>(original_config_)) | |||||
| { | |||||
| return CounterNodeConfig{ | |||||
| CounterAddress{ui_->counterAddressSpinBox->value()}, | |||||
| static_cast<CounterMode>(ui_->counterModeComboBox->currentData().toInt()), | |||||
| RegisterAddress{ | |||||
| RegisterArea::D, | |||||
| ui_->counterCurrentAddressSpinBox->value()}, | |||||
| operandFrom( | |||||
| ui_->counterPresetKindComboBox, | |||||
| ui_->counterPresetValueSpinBox), | |||||
| RegisterAddress{ | |||||
| RegisterArea::M, | |||||
| ui_->counterResetAddressSpinBox->value()}}; | |||||
| } | |||||
| if (std::holds_alternative<MoveNodeConfig>(original_config_)) | |||||
| { | |||||
| return MoveNodeConfig{ | |||||
| operandFrom(ui_->leftOperandKindComboBox, ui_->leftOperandValueSpinBox), | |||||
| RegisterAddress{ | |||||
| RegisterArea::D, | |||||
| ui_->destinationAddressSpinBox->value()}}; | |||||
| } | |||||
| const auto *arithmetic = std::get_if<ArithmeticNodeConfig>(&original_config_); | |||||
| return ArithmeticNodeConfig{ | |||||
| arithmetic->operation, | |||||
| operandFrom(ui_->leftOperandKindComboBox, ui_->leftOperandValueSpinBox), | |||||
| operandFrom(ui_->rightOperandKindComboBox, ui_->rightOperandValueSpinBox), | |||||
| RegisterAddress{ | |||||
| RegisterArea::D, | |||||
| ui_->destinationAddressSpinBox->value()}}; | |||||
| } | |||||
| void LogicInstructionDialog::configureCounter(const CounterNodeConfig &config) | |||||
| { | |||||
| setWindowTitle(tr("配置计数器")); | |||||
| ui_->instructionStackedWidget->setCurrentWidget(ui_->counterPage); | |||||
| ui_->counterModeComboBox->clear(); | |||||
| ui_->counterModeComboBox->addItem(tr("CTU 加计数"), static_cast<int>(CounterMode::Up)); | |||||
| ui_->counterModeComboBox->addItem( | |||||
| tr("CTD 减计数"), static_cast<int>(CounterMode::Down)); | |||||
| ui_->counterModeComboBox->setCurrentIndex( | |||||
| ui_->counterModeComboBox->findData(static_cast<int>(config.mode))); | |||||
| ui_->counterAddressSpinBox->setValue(config.address.index()); | |||||
| ui_->counterCurrentAddressSpinBox->setValue(config.currentValueAddress.index()); | |||||
| ui_->counterResetAddressSpinBox->setValue(config.resetAddress.index()); | |||||
| setOperand( | |||||
| ui_->counterPresetKindComboBox, | |||||
| ui_->counterPresetValueSpinBox, | |||||
| config.preset); | |||||
| } | |||||
| void LogicInstructionDialog::configureMove(const MoveNodeConfig &config) | |||||
| { | |||||
| setWindowTitle(tr("配置 MOVE")); | |||||
| ui_->instructionStackedWidget->setCurrentWidget(ui_->dataInstructionPage); | |||||
| ui_->instructionNameLabel->setText(QStringLiteral("MOVE")); | |||||
| ui_->leftOperandLabel->setText(tr("源操作数")); | |||||
| ui_->rightOperandLabel->setVisible(false); | |||||
| ui_->rightOperandEditor->setVisible(false); | |||||
| setOperand( | |||||
| ui_->leftOperandKindComboBox, | |||||
| ui_->leftOperandValueSpinBox, | |||||
| config.source); | |||||
| ui_->destinationAddressSpinBox->setValue(config.destination.index()); | |||||
| } | |||||
| void LogicInstructionDialog::configureArithmetic(const ArithmeticNodeConfig &config) | |||||
| { | |||||
| const bool is_add = config.operation == ArithmeticOperation::Add; | |||||
| setWindowTitle(is_add ? tr("配置 ADD") : tr("配置 SUB")); | |||||
| ui_->instructionStackedWidget->setCurrentWidget(ui_->dataInstructionPage); | |||||
| ui_->instructionNameLabel->setText(is_add | |||||
| ? QStringLiteral("ADD") : QStringLiteral("SUB")); | |||||
| ui_->leftOperandLabel->setText(tr("左操作数")); | |||||
| ui_->rightOperandLabel->setVisible(true); | |||||
| ui_->rightOperandEditor->setVisible(true); | |||||
| setOperand( | |||||
| ui_->leftOperandKindComboBox, | |||||
| ui_->leftOperandValueSpinBox, | |||||
| config.left); | |||||
| setOperand( | |||||
| ui_->rightOperandKindComboBox, | |||||
| ui_->rightOperandValueSpinBox, | |||||
| config.right); | |||||
| ui_->destinationAddressSpinBox->setValue(config.destination.index()); | |||||
| } | |||||
| void LogicInstructionDialog::updateOperandRanges() | |||||
| { | |||||
| const auto update = [](const QComboBox *combo, QSpinBox *spin) | |||||
| { | |||||
| const bool register_operand = combo->currentData().toInt() | |||||
| == kRegisterOperandData; | |||||
| spin->setPrefix(register_operand ? QStringLiteral("D") : QString{}); | |||||
| spin->setMinimum(register_operand | |||||
| ? RegisterAddress::kMinimumIndex | |||||
| : (combo->objectName() == QStringLiteral("counterPresetKindComboBox") | |||||
| ? 0 : std::numeric_limits<std::int16_t>::min())); | |||||
| spin->setMaximum(register_operand | |||||
| ? RegisterAddress::kMaximumIndex | |||||
| : std::numeric_limits<std::int16_t>::max()); | |||||
| }; | |||||
| update(ui_->counterPresetKindComboBox, ui_->counterPresetValueSpinBox); | |||||
| update(ui_->leftOperandKindComboBox, ui_->leftOperandValueSpinBox); | |||||
| update(ui_->rightOperandKindComboBox, ui_->rightOperandValueSpinBox); | |||||
| } | |||||
| void LogicInstructionDialog::setOperand( | |||||
| QComboBox *kind_combo, | |||||
| QSpinBox *value_spin, | |||||
| const WordOperand &operand) | |||||
| { | |||||
| kind_combo->setCurrentIndex(kind_combo->findData( | |||||
| operand.kind == WordOperandKind::Register | |||||
| ? kRegisterOperandData : kConstantOperandData)); | |||||
| value_spin->setValue( | |||||
| operand.kind == WordOperandKind::Register | |||||
| ? operand.address.index() : operand.constant); | |||||
| } | |||||
| WordOperand LogicInstructionDialog::operandFrom( | |||||
| const QComboBox *kind_combo, | |||||
| const QSpinBox *value_spin) | |||||
| { | |||||
| if (kind_combo->currentData().toInt() == kRegisterOperandData) | |||||
| { | |||||
| return WordOperand{ | |||||
| WordOperandKind::Register, | |||||
| RegisterAddress{RegisterArea::D, value_spin->value()}, | |||||
| 0}; | |||||
| } | |||||
| return WordOperand{ | |||||
| WordOperandKind::Constant, | |||||
| RegisterAddress{RegisterArea::D, 0}, | |||||
| static_cast<std::int16_t>(value_spin->value())}; | |||||
| } | |||||
| @@ -0,0 +1,43 @@ | |||||
| #pragma once | |||||
| #include "domain/control_logic_model.h" | |||||
| #include <QDialog> | |||||
| #include <memory> | |||||
| class QComboBox; | |||||
| class QSpinBox; | |||||
| namespace Ui { | |||||
| class LogicInstructionDialog; | |||||
| } | |||||
| class LogicInstructionDialog final : public QDialog | |||||
| { | |||||
| Q_OBJECT | |||||
| public: | |||||
| explicit LogicInstructionDialog( | |||||
| const LogicNodeConfig &config, | |||||
| QWidget *parent = nullptr); | |||||
| ~LogicInstructionDialog() override; | |||||
| LogicNodeConfig config() const; | |||||
| private: | |||||
| void configureCounter(const CounterNodeConfig &config); | |||||
| void configureMove(const MoveNodeConfig &config); | |||||
| void configureArithmetic(const ArithmeticNodeConfig &config); | |||||
| void updateOperandRanges(); | |||||
| static void setOperand( | |||||
| QComboBox *kind_combo, | |||||
| QSpinBox *value_spin, | |||||
| const WordOperand &operand); | |||||
| static WordOperand operandFrom( | |||||
| const QComboBox *kind_combo, | |||||
| const QSpinBox *value_spin); | |||||
| std::unique_ptr<Ui::LogicInstructionDialog> ui_; | |||||
| LogicNodeConfig original_config_; | |||||
| }; | |||||
| @@ -0,0 +1,66 @@ | |||||
| <?xml version="1.0" encoding="UTF-8"?> | |||||
| <ui version="4.0"> | |||||
| <class>LogicInstructionDialog</class> | |||||
| <widget class="QDialog" name="LogicInstructionDialog"> | |||||
| <property name="geometry"> | |||||
| <rect><x>0</x><y>0</y><width>420</width><height>310</height></rect> | |||||
| </property> | |||||
| <property name="windowTitle"><string>配置逻辑指令</string></property> | |||||
| <layout class="QVBoxLayout" name="verticalLayout"> | |||||
| <item> | |||||
| <widget class="QStackedWidget" name="instructionStackedWidget"> | |||||
| <widget class="QWidget" name="counterPage"> | |||||
| <layout class="QFormLayout" name="counterFormLayout"> | |||||
| <item row="0" column="0"><widget class="QLabel" name="counterModeLabel"><property name="text"><string>计数方式</string></property></widget></item> | |||||
| <item row="0" column="1"><widget class="QComboBox" name="counterModeComboBox"/></item> | |||||
| <item row="1" column="0"><widget class="QLabel" name="counterAddressLabel"><property name="text"><string>计数器资源</string></property></widget></item> | |||||
| <item row="1" column="1"><widget class="QSpinBox" name="counterAddressSpinBox"><property name="prefix"><string>C</string></property><property name="maximum"><number>4000</number></property></widget></item> | |||||
| <item row="2" column="0"><widget class="QLabel" name="counterCurrentAddressLabel"><property name="text"><string>当前值 CV</string></property></widget></item> | |||||
| <item row="2" column="1"><widget class="QSpinBox" name="counterCurrentAddressSpinBox"><property name="prefix"><string>D</string></property><property name="maximum"><number>4000</number></property></widget></item> | |||||
| <item row="3" column="0"><widget class="QLabel" name="counterPresetLabel"><property name="text"><string>预设值 PV</string></property></widget></item> | |||||
| <item row="3" column="1"> | |||||
| <widget class="QWidget" name="counterPresetEditor" native="true"> | |||||
| <layout class="QHBoxLayout" name="counterPresetLayout"><property name="leftMargin"><number>0</number></property><property name="topMargin"><number>0</number></property><property name="rightMargin"><number>0</number></property><property name="bottomMargin"><number>0</number></property> | |||||
| <item><widget class="QComboBox" name="counterPresetKindComboBox"/></item> | |||||
| <item><widget class="QSpinBox" name="counterPresetValueSpinBox"><property name="maximum"><number>32767</number></property><property name="value"><number>10</number></property></widget></item> | |||||
| </layout> | |||||
| </widget> | |||||
| </item> | |||||
| <item row="4" column="0"><widget class="QLabel" name="counterResetAddressLabel"><property name="text"><string>复位输入</string></property></widget></item> | |||||
| <item row="4" column="1"><widget class="QSpinBox" name="counterResetAddressSpinBox"><property name="prefix"><string>M</string></property><property name="maximum"><number>4000</number></property></widget></item> | |||||
| </layout> | |||||
| </widget> | |||||
| <widget class="QWidget" name="dataInstructionPage"> | |||||
| <layout class="QFormLayout" name="dataInstructionFormLayout"> | |||||
| <item row="0" column="0"><widget class="QLabel" name="instructionCaptionLabel"><property name="text"><string>指令</string></property></widget></item> | |||||
| <item row="0" column="1"><widget class="QLabel" name="instructionNameLabel"><property name="text"><string>MOVE</string></property></widget></item> | |||||
| <item row="1" column="0"><widget class="QLabel" name="leftOperandLabel"><property name="text"><string>源操作数</string></property></widget></item> | |||||
| <item row="1" column="1"> | |||||
| <widget class="QWidget" name="leftOperandEditor" native="true"> | |||||
| <layout class="QHBoxLayout" name="leftOperandLayout"><property name="leftMargin"><number>0</number></property><property name="topMargin"><number>0</number></property><property name="rightMargin"><number>0</number></property><property name="bottomMargin"><number>0</number></property> | |||||
| <item><widget class="QComboBox" name="leftOperandKindComboBox"/></item> | |||||
| <item><widget class="QSpinBox" name="leftOperandValueSpinBox"><property name="minimum"><number>-32768</number></property><property name="maximum"><number>32767</number></property></widget></item> | |||||
| </layout> | |||||
| </widget> | |||||
| </item> | |||||
| <item row="2" column="0"><widget class="QLabel" name="rightOperandLabel"><property name="text"><string>右操作数</string></property></widget></item> | |||||
| <item row="2" column="1"> | |||||
| <widget class="QWidget" name="rightOperandEditor" native="true"> | |||||
| <layout class="QHBoxLayout" name="rightOperandLayout"><property name="leftMargin"><number>0</number></property><property name="topMargin"><number>0</number></property><property name="rightMargin"><number>0</number></property><property name="bottomMargin"><number>0</number></property> | |||||
| <item><widget class="QComboBox" name="rightOperandKindComboBox"/></item> | |||||
| <item><widget class="QSpinBox" name="rightOperandValueSpinBox"><property name="minimum"><number>-32768</number></property><property name="maximum"><number>32767</number></property></widget></item> | |||||
| </layout> | |||||
| </widget> | |||||
| </item> | |||||
| <item row="3" column="0"><widget class="QLabel" name="destinationAddressLabel"><property name="text"><string>目标地址</string></property></widget></item> | |||||
| <item row="3" column="1"><widget class="QSpinBox" name="destinationAddressSpinBox"><property name="prefix"><string>D</string></property><property name="maximum"><number>4000</number></property></widget></item> | |||||
| </layout> | |||||
| </widget> | |||||
| </widget> | |||||
| </item> | |||||
| <item><widget class="QDialogButtonBox" name="buttonBox"><property name="standardButtons"><set>QDialogButtonBox::Cancel|QDialogButtonBox::Ok</set></property></widget></item> | |||||
| </layout> | |||||
| </widget> | |||||
| <resources/> | |||||
| <connections/> | |||||
| </ui> | |||||
| @@ -20,8 +20,9 @@ | |||||
| QT_BEGIN_NAMESPACE | QT_BEGIN_NAMESPACE | ||||
| class QAction; | class QAction; | ||||
| class QActionGroup; | class QActionGroup; | ||||
| class QCloseEvent; | |||||
| class QEvent; | |||||
| class QLabel; | class QLabel; | ||||
| class QTimer; | |||||
| namespace Ui { | namespace Ui { | ||||
| class MainWindow; | class MainWindow; | ||||
| @@ -32,12 +33,18 @@ class RuntimeModeService; | |||||
| class ProjectService; | class ProjectService; | ||||
| class HmiEditorService; | class HmiEditorService; | ||||
| class HmiRuntimeService; | class HmiRuntimeService; | ||||
| class AlarmEditorService; | |||||
| class AlarmService; | |||||
| class HmiNavigationService; | class HmiNavigationService; | ||||
| class RegisterCommentService; | |||||
| class HmiEditorWidget; | class HmiEditorWidget; | ||||
| class LogicEditorService; | class LogicEditorService; | ||||
| class LogicEditorWidget; | class LogicEditorWidget; | ||||
| class RegisterMonitorService; | class RegisterMonitorService; | ||||
| class RuntimeMonitorWidget; | class RuntimeMonitorWidget; | ||||
| class ProjectWorkspaceController; | |||||
| class PropertyPanelController; | |||||
| class RuntimePanelController; | |||||
| /** | /** | ||||
| * @brief 组织主界面区域并将用户操作转交给服务层 | * @brief 组织主界面区域并将用户操作转交给服务层 | ||||
| @@ -63,6 +70,9 @@ public: | |||||
| HmiEditorService &hmi_editor_service, | HmiEditorService &hmi_editor_service, | ||||
| LogicEditorService &logic_editor_service, | LogicEditorService &logic_editor_service, | ||||
| HmiRuntimeService &hmi_runtime_service, | HmiRuntimeService &hmi_runtime_service, | ||||
| AlarmEditorService &alarm_editor_service, | |||||
| AlarmService &alarm_service, | |||||
| RegisterCommentService ®ister_comment_service, | |||||
| RegisterMonitorService ®ister_monitor_service, | RegisterMonitorService ®ister_monitor_service, | ||||
| QWidget *parent = nullptr); | QWidget *parent = nullptr); | ||||
| MainWindow( | MainWindow( | ||||
| @@ -71,7 +81,10 @@ public: | |||||
| HmiEditorService &hmi_editor_service, | HmiEditorService &hmi_editor_service, | ||||
| LogicEditorService &logic_editor_service, | LogicEditorService &logic_editor_service, | ||||
| HmiRuntimeService &hmi_runtime_service, | HmiRuntimeService &hmi_runtime_service, | ||||
| AlarmEditorService &alarm_editor_service, | |||||
| AlarmService &alarm_service, | |||||
| HmiNavigationService &hmi_navigation_service, | HmiNavigationService &hmi_navigation_service, | ||||
| RegisterCommentService ®ister_comment_service, | |||||
| RegisterMonitorService ®ister_monitor_service, | RegisterMonitorService ®ister_monitor_service, | ||||
| QWidget *parent = nullptr); | QWidget *parent = nullptr); | ||||
| @@ -80,6 +93,10 @@ public: | |||||
| const std::string ¤tHmiPageId() const; | const std::string ¤tHmiPageId() const; | ||||
| const std::string ¤tLogicId() const; | const std::string ¤tLogicId() const; | ||||
| protected: | |||||
| bool eventFilter(QObject *watched, QEvent *event) override; | |||||
| void closeEvent(QCloseEvent *event) override; | |||||
| private: | private: | ||||
| // 配置菜单和工具栏动作 | // 配置菜单和工具栏动作 | ||||
| void configureActions(); | void configureActions(); | ||||
| @@ -94,17 +111,19 @@ private: | |||||
| // 创建并连接控件属性编辑表单 | // 创建并连接控件属性编辑表单 | ||||
| void configurePropertyEditor(); | void configurePropertyEditor(); | ||||
| void configurePlcConnection(); | void configurePlcConnection(); | ||||
| void configureAlarms(); | |||||
| void configureRegisterComments(); | |||||
| void undoActiveEditor(); | |||||
| void redoActiveEditor(); | |||||
| void deleteActiveSelection(); | |||||
| void clearActiveSelection(); | |||||
| void updateEditActions(); | |||||
| void clearEditorHistories(); | |||||
| void updateWindowTitle(); | |||||
| bool confirmSaveBeforeDestructiveAction(); | |||||
| // 刷新工程树和当前 HMI 页面信息 | // 刷新工程树和当前 HMI 页面信息 | ||||
| void refreshProjectUi(); | void refreshProjectUi(); | ||||
| void handleProjectTreeSelection(); | |||||
| void updateProjectTreeActions(); | void updateProjectTreeActions(); | ||||
| void addPage(); | |||||
| void addLogic(); | |||||
| void renameProjectItem(); | |||||
| void deleteProjectItem(); | |||||
| void moveProjectItem(int offset); | |||||
| void setSelectedPageAsInitial(); | |||||
| void toggleSelectedLogicEnabled(); | |||||
| // 显示指定 HMI 控件的可编辑属性 | // 显示指定 HMI 控件的可编辑属性 | ||||
| void showControlProperties(const std::string &control_id); | void showControlProperties(const std::string &control_id); | ||||
| // 显示指定控制逻辑节点的可编辑属性 | // 显示指定控制逻辑节点的可编辑属性 | ||||
| @@ -113,20 +132,22 @@ private: | |||||
| void addHmiControl(HmiControlType type); | void addHmiControl(HmiControlType type); | ||||
| // 删除当前选中的 HMI 控件 | // 删除当前选中的 HMI 控件 | ||||
| void deleteSelectedControl(); | void deleteSelectedControl(); | ||||
| // 将属性表单内容应用到当前选中控件 | |||||
| void applySelectedControlProperties(); | |||||
| // 在当前光标节点后添加串联条件,没有选择时追加到网络末尾 | // 在当前光标节点后添加串联条件,没有选择时追加到网络末尾 | ||||
| void addLogicCondition(const LogicNodeConfig &config); | void addLogicCondition(const LogicNodeConfig &config); | ||||
| // 为当前选中的连续逻辑范围建立并联支路 | // 为当前选中的连续逻辑范围建立并联支路 | ||||
| void addLogicParallelBranch(const LogicNodeConfig &config); | void addLogicParallelBranch(const LogicNodeConfig &config); | ||||
| void addLogicHorizontalWire(); | |||||
| void addLogicVerticalWire(); | |||||
| void deleteLogicHorizontalWire(); | |||||
| void deleteLogicVerticalWire(); | |||||
| // 设置当前网络右侧的输出线圈 | // 设置当前网络右侧的输出线圈 | ||||
| void setLogicOutput(const LogicNodeConfig &config); | void setLogicOutput(const LogicNodeConfig &config); | ||||
| void configureAndSetLogicOutput(const LogicNodeConfig &config); | |||||
| // 新增一个梯形图网络 | // 新增一个梯形图网络 | ||||
| void addLogicRung(); | void addLogicRung(); | ||||
| void editSelectedRungComment(); | |||||
| // 删除当前选中的逻辑节点或网络 | // 删除当前选中的逻辑节点或网络 | ||||
| void deleteSelectedLogicObject(); | void deleteSelectedLogicObject(); | ||||
| // 将逻辑属性表单内容应用到当前选中节点 | |||||
| void applySelectedLogicNodeProperties(); | |||||
| void connectPlc(); | void connectPlc(); | ||||
| void disconnectPlc(); | void disconnectPlc(); | ||||
| // 合并同一事件循环内的 PLC 状态通知,避免重复刷新和重复日志 | // 合并同一事件循环内的 PLC 状态通知,避免重复刷新和重复日志 | ||||
| @@ -141,6 +162,7 @@ private: | |||||
| void loadProject(); | void loadProject(); | ||||
| // 在状态栏和输出面板显示工程操作结果 | // 在状态栏和输出面板显示工程操作结果 | ||||
| void showProjectResult(const QString &action, const QString &message, bool succeeded); | void showProjectResult(const QString &action, const QString &message, bool succeeded); | ||||
| void appendOutputMessage(const QString &message); | |||||
| /** | /** | ||||
| * @brief 请求服务层切换模式并同步界面状态 | * @brief 请求服务层切换模式并同步界面状态 | ||||
| @@ -176,30 +198,26 @@ private: | |||||
| HmiEditorService &hmi_editor_service_; | HmiEditorService &hmi_editor_service_; | ||||
| LogicEditorService &logic_editor_service_; | LogicEditorService &logic_editor_service_; | ||||
| HmiRuntimeService &hmi_runtime_service_; | HmiRuntimeService &hmi_runtime_service_; | ||||
| AlarmEditorService &alarm_editor_service_; | |||||
| AlarmService &alarm_service_; | |||||
| RegisterCommentService ®ister_comment_service_; | |||||
| RegisterMonitorService ®ister_monitor_service_; | RegisterMonitorService ®ister_monitor_service_; | ||||
| std::unique_ptr<HmiNavigationService> owned_hmi_navigation_service_; | std::unique_ptr<HmiNavigationService> owned_hmi_navigation_service_; | ||||
| HmiNavigationService *hmi_navigation_service_ = nullptr; | HmiNavigationService *hmi_navigation_service_ = nullptr; | ||||
| std::unique_ptr<ProjectWorkspaceController> project_workspace_controller_; | |||||
| std::unique_ptr<PropertyPanelController> property_panel_controller_; | |||||
| std::unique_ptr<RuntimePanelController> runtime_panel_controller_; | |||||
| QActionGroup *mode_action_group_ = nullptr; | QActionGroup *mode_action_group_ = nullptr; | ||||
| HmiEditorWidget *hmi_editor_widget_ = nullptr; | HmiEditorWidget *hmi_editor_widget_ = nullptr; | ||||
| LogicEditorWidget *logic_editor_widget_ = nullptr; | LogicEditorWidget *logic_editor_widget_ = nullptr; | ||||
| RuntimeMonitorWidget *runtime_monitor_widget_ = nullptr; | RuntimeMonitorWidget *runtime_monitor_widget_ = nullptr; | ||||
| QTimer *runtime_refresh_timer_ = nullptr; | |||||
| QLabel *mode_status_label_ = nullptr; | QLabel *mode_status_label_ = nullptr; | ||||
| QLabel *register_status_label_ = nullptr; | QLabel *register_status_label_ = nullptr; | ||||
| QLabel *executor_status_label_ = nullptr; | QLabel *executor_status_label_ = nullptr; | ||||
| QAction *add_page_action_ = nullptr; | |||||
| QAction *add_logic_action_ = nullptr; | |||||
| QAction *rename_project_item_action_ = nullptr; | |||||
| QAction *delete_project_item_action_ = nullptr; | |||||
| QAction *move_project_item_up_action_ = nullptr; | |||||
| QAction *move_project_item_down_action_ = nullptr; | |||||
| QAction *set_initial_page_action_ = nullptr; | |||||
| QAction *toggle_logic_enabled_action_ = nullptr; | |||||
| PlcSerialConfiguration plc_configuration_; | PlcSerialConfiguration plc_configuration_; | ||||
| std::string selected_control_id_; | std::string selected_control_id_; | ||||
| std::string selected_logic_node_id_; | std::string selected_logic_node_id_; | ||||
| std::string current_hmi_page_id_; | std::string current_hmi_page_id_; | ||||
| std::string current_logic_id_; | std::string current_logic_id_; | ||||
| bool plc_status_update_pending_ = false; | bool plc_status_update_pending_ = false; | ||||
| int last_editing_tab_index_ = 0; | |||||
| }; | }; | ||||
| @@ -132,7 +132,7 @@ | |||||
| <item> | <item> | ||||
| <widget class="QLabel" name="hmiPageSizeLabel"> | <widget class="QLabel" name="hmiPageSizeLabel"> | ||||
| <property name="text"> | <property name="text"> | ||||
| <string>800 x 480</string> | |||||
| <string>800 x 400</string> | |||||
| </property> | </property> | ||||
| <property name="alignment"> | <property name="alignment"> | ||||
| <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set> | <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set> | ||||
| @@ -207,18 +207,7 @@ | |||||
| </item> | </item> | ||||
| </layout> | </layout> | ||||
| </widget> | </widget> | ||||
| <widget class="QWidget" name="runtimeMonitorTab"> | |||||
| <attribute name="title"> | |||||
| <string>运行监控</string> | |||||
| </attribute> | |||||
| <layout class="QVBoxLayout" name="runtimeMonitorLayout"> | |||||
| <property name="leftMargin"><number>0</number></property> | |||||
| <property name="topMargin"><number>0</number></property> | |||||
| <property name="rightMargin"><number>0</number></property> | |||||
| <property name="bottomMargin"><number>0</number></property> | |||||
| </layout> | |||||
| </widget> | </widget> | ||||
| </widget> | |||||
| </item> | </item> | ||||
| </layout> | </layout> | ||||
| </widget> | </widget> | ||||
| @@ -240,7 +229,19 @@ | |||||
| <addaction name="saveAsProjectAction"/> | <addaction name="saveAsProjectAction"/> | ||||
| <addaction name="loadProjectAction"/> | <addaction name="loadProjectAction"/> | ||||
| <addaction name="separator"/> | <addaction name="separator"/> | ||||
| <addaction name="exitAction"/> | |||||
| <addaction name="exitAction"/> | |||||
| </widget> | |||||
| <widget class="QMenu" name="editMenu"> | |||||
| <property name="title"> | |||||
| <string>编辑(&E)</string> | |||||
| </property> | |||||
| <addaction name="undoAction"/> | |||||
| <addaction name="redoAction"/> | |||||
| <addaction name="separator"/> | |||||
| <addaction name="deleteSelectionAction"/> | |||||
| <addaction name="deleteHorizontalWireAction"/> | |||||
| <addaction name="deleteVerticalWireAction"/> | |||||
| <addaction name="clearSelectionAction"/> | |||||
| </widget> | </widget> | ||||
| <widget class="QMenu" name="runMenu"> | <widget class="QMenu" name="runMenu"> | ||||
| <property name="title"> | <property name="title"> | ||||
| @@ -254,11 +255,13 @@ | |||||
| <addaction name="disconnectPlcAction"/> | <addaction name="disconnectPlcAction"/> | ||||
| </widget> | </widget> | ||||
| <widget class="QMenu" name="viewMenu"> | <widget class="QMenu" name="viewMenu"> | ||||
| <property name="title"> | |||||
| <string>视图(&V)</string> | |||||
| </property> | |||||
| <property name="title"> | |||||
| <string>视图(&V)</string> | |||||
| </property> | |||||
| <addaction name="configureRegisterCommentsAction"/> | |||||
| </widget> | </widget> | ||||
| <addaction name="fileMenu"/> | <addaction name="fileMenu"/> | ||||
| <addaction name="editMenu"/> | |||||
| <addaction name="runMenu"/> | <addaction name="runMenu"/> | ||||
| <addaction name="viewMenu"/> | <addaction name="viewMenu"/> | ||||
| </widget> | </widget> | ||||
| @@ -296,14 +299,13 @@ | |||||
| <enum>TopToolBarArea</enum> | <enum>TopToolBarArea</enum> | ||||
| </attribute> | </attribute> | ||||
| <attribute name="toolBarBreak"> | <attribute name="toolBarBreak"> | ||||
| <bool>false</bool> | |||||
| <bool>true</bool> | |||||
| </attribute> | </attribute> | ||||
| <addaction name="addButtonAction"/> | <addaction name="addButtonAction"/> | ||||
| <addaction name="addIndicatorAction"/> | <addaction name="addIndicatorAction"/> | ||||
| <addaction name="addNumericDisplayAction"/> | <addaction name="addNumericDisplayAction"/> | ||||
| <addaction name="addNumericInputAction"/> | <addaction name="addNumericInputAction"/> | ||||
| <addaction name="addLabelAction"/> | <addaction name="addLabelAction"/> | ||||
| <addaction name="addPageJumpAction"/> | |||||
| <addaction name="deleteControlAction"/> | <addaction name="deleteControlAction"/> | ||||
| </widget> | </widget> | ||||
| <widget class="QToolBar" name="logicToolBar"> | <widget class="QToolBar" name="logicToolBar"> | ||||
| @@ -317,18 +319,18 @@ | |||||
| <enum>TopToolBarArea</enum> | <enum>TopToolBarArea</enum> | ||||
| </attribute> | </attribute> | ||||
| <attribute name="toolBarBreak"> | <attribute name="toolBarBreak"> | ||||
| <bool>false</bool> | |||||
| <bool>true</bool> | |||||
| </attribute> | </attribute> | ||||
| <addaction name="addRungAction"/> | <addaction name="addRungAction"/> | ||||
| <addaction name="insertHorizontalWireAction"/> | |||||
| <addaction name="insertVerticalWireAction"/> | |||||
| <addaction name="parallelInsertAction"/> | <addaction name="parallelInsertAction"/> | ||||
| <addaction name="deleteLogicAction"/> | |||||
| <addaction name="separator"/> | <addaction name="separator"/> | ||||
| <addaction name="addNormallyOpenAction"/> | <addaction name="addNormallyOpenAction"/> | ||||
| <addaction name="addNormallyClosedAction"/> | <addaction name="addNormallyClosedAction"/> | ||||
| <addaction name="addNormalCoilAction"/> | <addaction name="addNormalCoilAction"/> | ||||
| <addaction name="addSetCoilAction"/> | |||||
| <addaction name="addResetCoilAction"/> | |||||
| <addaction name="addCompareAction"/> | |||||
| <addaction name="deleteLogicAction"/> | |||||
| <addaction name="editRungCommentAction"/> | |||||
| </widget> | </widget> | ||||
| <widget class="QDockWidget" name="projectDock"> | <widget class="QDockWidget" name="projectDock"> | ||||
| <property name="minimumSize"> | <property name="minimumSize"> | ||||
| @@ -450,6 +452,54 @@ | |||||
| </item> | </item> | ||||
| <item> | <item> | ||||
| <widget class="QStackedWidget" name="propertyStack"> | <widget class="QStackedWidget" name="propertyStack"> | ||||
| <widget class="QWidget" name="pagePropertiesPage"> | |||||
| <layout class="QFormLayout" name="pagePropertiesForm"> | |||||
| <property name="fieldGrowthPolicy"> | |||||
| <enum>QFormLayout::AllNonFixedFieldsGrow</enum> | |||||
| </property> | |||||
| <item row="0" column="0"> | |||||
| <widget class="QLabel" name="pageWidthLabel"> | |||||
| <property name="text"> | |||||
| <string>页面宽度</string> | |||||
| </property> | |||||
| </widget> | |||||
| </item> | |||||
| <item row="0" column="1"> | |||||
| <widget class="QSpinBox" name="pageWidthSpinBox"> | |||||
| <property name="minimum"> | |||||
| <number>320</number> | |||||
| </property> | |||||
| <property name="maximum"> | |||||
| <number>1600</number> | |||||
| </property> | |||||
| </widget> | |||||
| </item> | |||||
| <item row="1" column="0"> | |||||
| <widget class="QLabel" name="pageHeightLabel"> | |||||
| <property name="text"> | |||||
| <string>页面高度</string> | |||||
| </property> | |||||
| </widget> | |||||
| </item> | |||||
| <item row="1" column="1"> | |||||
| <widget class="QSpinBox" name="pageHeightSpinBox"> | |||||
| <property name="minimum"> | |||||
| <number>200</number> | |||||
| </property> | |||||
| <property name="maximum"> | |||||
| <number>800</number> | |||||
| </property> | |||||
| </widget> | |||||
| </item> | |||||
| <item row="2" column="0" colspan="2"> | |||||
| <widget class="QPushButton" name="applyPagePropertiesButton"> | |||||
| <property name="text"> | |||||
| <string>应用页面尺寸</string> | |||||
| </property> | |||||
| </widget> | |||||
| </item> | |||||
| </layout> | |||||
| </widget> | |||||
| <widget class="QWidget" name="hmiPropertiesPage"> | <widget class="QWidget" name="hmiPropertiesPage"> | ||||
| <layout class="QFormLayout" name="hmiPropertiesForm"> | <layout class="QFormLayout" name="hmiPropertiesForm"> | ||||
| <property name="fieldGrowthPolicy"> | <property name="fieldGrowthPolicy"> | ||||
| @@ -485,7 +535,7 @@ | |||||
| <item row="2" column="1"> | <item row="2" column="1"> | ||||
| <widget class="QSpinBox" name="controlXSpinBox"> | <widget class="QSpinBox" name="controlXSpinBox"> | ||||
| <property name="maximum"> | <property name="maximum"> | ||||
| <number>4000</number> | |||||
| <number>8192</number> | |||||
| </property> | </property> | ||||
| </widget> | </widget> | ||||
| </item> | </item> | ||||
| @@ -499,7 +549,7 @@ | |||||
| <item row="3" column="1"> | <item row="3" column="1"> | ||||
| <widget class="QSpinBox" name="controlYSpinBox"> | <widget class="QSpinBox" name="controlYSpinBox"> | ||||
| <property name="maximum"> | <property name="maximum"> | ||||
| <number>4000</number> | |||||
| <number>8192</number> | |||||
| </property> | </property> | ||||
| </widget> | </widget> | ||||
| </item> | </item> | ||||
| @@ -516,7 +566,7 @@ | |||||
| <number>1</number> | <number>1</number> | ||||
| </property> | </property> | ||||
| <property name="maximum"> | <property name="maximum"> | ||||
| <number>4000</number> | |||||
| <number>8192</number> | |||||
| </property> | </property> | ||||
| </widget> | </widget> | ||||
| </item> | </item> | ||||
| @@ -533,7 +583,7 @@ | |||||
| <number>1</number> | <number>1</number> | ||||
| </property> | </property> | ||||
| <property name="maximum"> | <property name="maximum"> | ||||
| <number>4000</number> | |||||
| <number>8192</number> | |||||
| </property> | </property> | ||||
| </widget> | </widget> | ||||
| </item> | </item> | ||||
| @@ -618,7 +668,123 @@ | |||||
| </item> | </item> | ||||
| </widget> | </widget> | ||||
| </item> | </item> | ||||
| <item row="10" column="0" colspan="2"> | |||||
| <item row="10" column="0"> | |||||
| <widget class="QLabel" name="progressMinimumLabel"> | |||||
| <property name="text"> | |||||
| <string>最小值</string> | |||||
| </property> | |||||
| </widget> | |||||
| </item> | |||||
| <item row="10" column="1"> | |||||
| <widget class="QSpinBox" name="progressMinimumSpinBox"> | |||||
| <property name="minimum"> | |||||
| <number>-32768</number> | |||||
| </property> | |||||
| <property name="maximum"> | |||||
| <number>32767</number> | |||||
| </property> | |||||
| </widget> | |||||
| </item> | |||||
| <item row="11" column="0"> | |||||
| <widget class="QLabel" name="progressMaximumLabel"> | |||||
| <property name="text"> | |||||
| <string>最大值</string> | |||||
| </property> | |||||
| </widget> | |||||
| </item> | |||||
| <item row="11" column="1"> | |||||
| <widget class="QSpinBox" name="progressMaximumSpinBox"> | |||||
| <property name="minimum"> | |||||
| <number>-32768</number> | |||||
| </property> | |||||
| <property name="maximum"> | |||||
| <number>32767</number> | |||||
| </property> | |||||
| </widget> | |||||
| </item> | |||||
| <item row="12" column="0"> | |||||
| <widget class="QLabel" name="progressShowValueLabel"> | |||||
| <property name="text"> | |||||
| <string>数值显示</string> | |||||
| </property> | |||||
| </widget> | |||||
| </item> | |||||
| <item row="12" column="1"> | |||||
| <widget class="QCheckBox" name="progressShowValueCheckBox"> | |||||
| <property name="text"> | |||||
| <string>显示百分比</string> | |||||
| </property> | |||||
| </widget> | |||||
| </item> | |||||
| <item row="13" column="0"> | |||||
| <widget class="QLabel" name="textColorLabel"> | |||||
| <property name="text"> | |||||
| <string>字体颜色</string> | |||||
| </property> | |||||
| </widget> | |||||
| </item> | |||||
| <item row="13" column="1"> | |||||
| <widget class="QWidget" name="textColorContainer"> | |||||
| <layout class="QHBoxLayout" name="textColorLayout"> | |||||
| <property name="leftMargin"><number>0</number></property> | |||||
| <property name="topMargin"><number>0</number></property> | |||||
| <property name="rightMargin"><number>0</number></property> | |||||
| <property name="bottomMargin"><number>0</number></property> | |||||
| <item> | |||||
| <widget class="QLineEdit" name="textColorEdit"> | |||||
| <property name="placeholderText"> | |||||
| <string>默认,例如 #E53935</string> | |||||
| </property> | |||||
| </widget> | |||||
| </item> | |||||
| <item> | |||||
| <widget class="QPushButton" name="textColorButton"> | |||||
| <property name="text"> | |||||
| <string>选择</string> | |||||
| </property> | |||||
| </widget> | |||||
| </item> | |||||
| </layout> | |||||
| </widget> | |||||
| </item> | |||||
| <item row="14" column="0"> | |||||
| <widget class="QLabel" name="fontSizeLabel"> | |||||
| <property name="text"> | |||||
| <string>字号</string> | |||||
| </property> | |||||
| </widget> | |||||
| </item> | |||||
| <item row="14" column="1"> | |||||
| <widget class="QSpinBox" name="fontSizeSpinBox"/> | |||||
| </item> | |||||
| <item row="15" column="0"> | |||||
| <widget class="QLabel" name="fontBoldLabel"> | |||||
| <property name="text"> | |||||
| <string>字体样式</string> | |||||
| </property> | |||||
| </widget> | |||||
| </item> | |||||
| <item row="15" column="1"> | |||||
| <widget class="QWidget" name="fontStyleContainer"> | |||||
| <layout class="QHBoxLayout" name="fontStyleLayout"> | |||||
| <property name="leftMargin"><number>0</number></property> | |||||
| <property name="topMargin"><number>0</number></property> | |||||
| <property name="rightMargin"><number>0</number></property> | |||||
| <property name="bottomMargin"><number>0</number></property> | |||||
| <item> | |||||
| <widget class="QCheckBox" name="fontBoldCheckBox"> | |||||
| <property name="text"><string>粗体</string></property> | |||||
| </widget> | |||||
| </item> | |||||
| <item> | |||||
| <widget class="QCheckBox" name="fontItalicCheckBox"> | |||||
| <property name="text"><string>斜体</string></property> | |||||
| </widget> | |||||
| </item> | |||||
| </layout> | |||||
| </widget> | |||||
| </item> | |||||
| <item row="16" column="0" colspan="2"> | |||||
| <widget class="QPushButton" name="applyPropertiesButton"> | <widget class="QPushButton" name="applyPropertiesButton"> | ||||
| <property name="text"> | <property name="text"> | ||||
| <string>应用属性</string> | <string>应用属性</string> | ||||
| @@ -756,7 +922,27 @@ | |||||
| </property> | </property> | ||||
| </widget> | </widget> | ||||
| </item> | </item> | ||||
| <item row="7" column="0" colspan="2"> | |||||
| <item row="7" column="0"> | |||||
| <widget class="QLabel" name="logicPresetLabel"> | |||||
| <property name="text"> | |||||
| <string>预设时间(ms)</string> | |||||
| </property> | |||||
| </widget> | |||||
| </item> | |||||
| <item row="7" column="1"> | |||||
| <widget class="QSpinBox" name="logicPresetSpinBox"> | |||||
| <property name="minimum"> | |||||
| <number>1</number> | |||||
| </property> | |||||
| <property name="maximum"> | |||||
| <number>86400000</number> | |||||
| </property> | |||||
| <property name="value"> | |||||
| <number>1000</number> | |||||
| </property> | |||||
| </widget> | |||||
| </item> | |||||
| <item row="8" column="0" colspan="2"> | |||||
| <widget class="QPushButton" name="applyLogicPropertiesButton"> | <widget class="QPushButton" name="applyLogicPropertiesButton"> | ||||
| <property name="text"> | <property name="text"> | ||||
| <string>应用属性</string> | <string>应用属性</string> | ||||
| @@ -822,21 +1008,53 @@ | |||||
| <property name="text"> | <property name="text"> | ||||
| <string>新建工程</string> | <string>新建工程</string> | ||||
| </property> | </property> | ||||
| <property name="shortcut"> | |||||
| <string>Ctrl+N</string> | |||||
| </property> | |||||
| </action> | </action> | ||||
| <action name="saveProjectAction"> | <action name="saveProjectAction"> | ||||
| <property name="text"> | <property name="text"> | ||||
| <string>保存工程</string> | <string>保存工程</string> | ||||
| </property> | </property> | ||||
| <property name="shortcut"> | |||||
| <string>Ctrl+S</string> | |||||
| </property> | |||||
| </action> | </action> | ||||
| <action name="saveAsProjectAction"> | <action name="saveAsProjectAction"> | ||||
| <property name="text"> | <property name="text"> | ||||
| <string>工程另存为</string> | <string>工程另存为</string> | ||||
| </property> | </property> | ||||
| <property name="shortcut"> | |||||
| <string>Ctrl+Shift+S</string> | |||||
| </property> | |||||
| </action> | </action> | ||||
| <action name="loadProjectAction"> | <action name="loadProjectAction"> | ||||
| <property name="text"> | <property name="text"> | ||||
| <string>加载工程</string> | <string>加载工程</string> | ||||
| </property> | </property> | ||||
| <property name="shortcut"> | |||||
| <string>Ctrl+O</string> | |||||
| </property> | |||||
| </action> | |||||
| <action name="undoAction"> | |||||
| <property name="text"><string>撤销</string></property> | |||||
| <property name="toolTip"><string>撤销当前编辑器的上一步操作</string></property> | |||||
| <property name="shortcut"><string>Ctrl+Z</string></property> | |||||
| </action> | |||||
| <action name="redoAction"> | |||||
| <property name="text"><string>重做</string></property> | |||||
| <property name="toolTip"><string>重做当前编辑器最近撤销的操作</string></property> | |||||
| <property name="shortcut"><string>Ctrl+Y</string></property> | |||||
| </action> | |||||
| <action name="deleteSelectionAction"> | |||||
| <property name="text"><string>删除所选</string></property> | |||||
| <property name="toolTip"><string>删除当前画布中选中的对象</string></property> | |||||
| <property name="shortcut"><string>Delete</string></property> | |||||
| </action> | |||||
| <action name="clearSelectionAction"> | |||||
| <property name="text"><string>清除选择</string></property> | |||||
| <property name="toolTip"><string>清除当前画布选择</string></property> | |||||
| <property name="shortcut"><string>Esc</string></property> | |||||
| </action> | </action> | ||||
| <action name="configurePlcAction"> | <action name="configurePlcAction"> | ||||
| <property name="text"> | <property name="text"> | ||||
| @@ -846,6 +1064,14 @@ | |||||
| <string>配置参数并连接真实 PLC</string> | <string>配置参数并连接真实 PLC</string> | ||||
| </property> | </property> | ||||
| </action> | </action> | ||||
| <action name="configureRegisterCommentsAction"> | |||||
| <property name="text"> | |||||
| <string>软元件注释</string> | |||||
| </property> | |||||
| <property name="toolTip"> | |||||
| <string>维护工程级 M/D 地址注释</string> | |||||
| </property> | |||||
| </action> | |||||
| <action name="disconnectPlcAction"> | <action name="disconnectPlcAction"> | ||||
| <property name="text"> | <property name="text"> | ||||
| <string>断开 PLC</string> | <string>断开 PLC</string> | ||||
| @@ -886,6 +1112,14 @@ | |||||
| <string>添加数值输入控件</string> | <string>添加数值输入控件</string> | ||||
| </property> | </property> | ||||
| </action> | </action> | ||||
| <action name="addProgressBarAction"> | |||||
| <property name="text"> | |||||
| <string>进度条</string> | |||||
| </property> | |||||
| <property name="toolTip"> | |||||
| <string>添加进度条控件</string> | |||||
| </property> | |||||
| </action> | |||||
| <action name="addLabelAction"> | <action name="addLabelAction"> | ||||
| <property name="text"> | <property name="text"> | ||||
| <string>文本</string> | <string>文本</string> | ||||
| @@ -902,6 +1136,22 @@ | |||||
| <string>添加页面跳转控件</string> | <string>添加页面跳转控件</string> | ||||
| </property> | </property> | ||||
| </action> | </action> | ||||
| <action name="addAlarmListAction"> | |||||
| <property name="text"> | |||||
| <string>报警列表</string> | |||||
| </property> | |||||
| <property name="toolTip"> | |||||
| <string>添加报警列表控件</string> | |||||
| </property> | |||||
| </action> | |||||
| <action name="configureAlarmsAction"> | |||||
| <property name="text"> | |||||
| <string>报警配置</string> | |||||
| </property> | |||||
| <property name="toolTip"> | |||||
| <string>配置 M 和 D 报警条件</string> | |||||
| </property> | |||||
| </action> | |||||
| <action name="deleteControlAction"> | <action name="deleteControlAction"> | ||||
| <property name="text"> | <property name="text"> | ||||
| <string>删除</string> | <string>删除</string> | ||||
| @@ -918,6 +1168,50 @@ | |||||
| <string>在梯形图末尾新增网络</string> | <string>在梯形图末尾新增网络</string> | ||||
| </property> | </property> | ||||
| </action> | </action> | ||||
| <action name="insertHorizontalWireAction"> | |||||
| <property name="text"> | |||||
| <string>横线</string> | |||||
| </property> | |||||
| <property name="toolTip"> | |||||
| <string>在所选条件或横线后插入横线;没有选择时追加到当前网络</string> | |||||
| </property> | |||||
| <property name="shortcut"> | |||||
| <string>F11</string> | |||||
| </property> | |||||
| </action> | |||||
| <action name="insertVerticalWireAction"> | |||||
| <property name="text"> | |||||
| <string>竖线</string> | |||||
| </property> | |||||
| <property name="toolTip"> | |||||
| <string>为所选连续逻辑范围建立横线旁路和竖线连接</string> | |||||
| </property> | |||||
| <property name="shortcut"> | |||||
| <string>F12</string> | |||||
| </property> | |||||
| </action> | |||||
| <action name="deleteHorizontalWireAction"> | |||||
| <property name="text"> | |||||
| <string>删除横线</string> | |||||
| </property> | |||||
| <property name="toolTip"> | |||||
| <string>删除画布中选中的横线</string> | |||||
| </property> | |||||
| <property name="shortcut"> | |||||
| <string>Shift+F11</string> | |||||
| </property> | |||||
| </action> | |||||
| <action name="deleteVerticalWireAction"> | |||||
| <property name="text"> | |||||
| <string>删除竖线</string> | |||||
| </property> | |||||
| <property name="toolTip"> | |||||
| <string>删除画布中选中的竖线及其对应并联支路</string> | |||||
| </property> | |||||
| <property name="shortcut"> | |||||
| <string>Shift+F12</string> | |||||
| </property> | |||||
| </action> | |||||
| <action name="parallelInsertAction"> | <action name="parallelInsertAction"> | ||||
| <property name="text"> | <property name="text"> | ||||
| <string>并联支路</string> | <string>并联支路</string> | ||||
| @@ -931,7 +1225,7 @@ | |||||
| <string>常开</string> | <string>常开</string> | ||||
| </property> | </property> | ||||
| <property name="toolTip"> | <property name="toolTip"> | ||||
| <string>向当前网络添加常开触点</string> | |||||
| <string>在选中的空网格或横线处添加常开触点;选中条件时在其后插入</string> | |||||
| </property> | </property> | ||||
| </action> | </action> | ||||
| <action name="addNormallyClosedAction"> | <action name="addNormallyClosedAction"> | ||||
| @@ -939,9 +1233,37 @@ | |||||
| <string>常闭</string> | <string>常闭</string> | ||||
| </property> | </property> | ||||
| <property name="toolTip"> | <property name="toolTip"> | ||||
| <string>向当前网络添加常闭触点</string> | |||||
| <string>在选中的空网格或横线处添加常闭触点;选中条件时在其后插入</string> | |||||
| </property> | |||||
| </action> | |||||
| <action name="addRisingEdgeAction"> | |||||
| <property name="text"> | |||||
| <string>上升沿</string> | |||||
| </property> | |||||
| <property name="toolTip"> | |||||
| <string>在选中的空网格或横线处添加一个扫描周期的上升沿触点</string> | |||||
| </property> | |||||
| </action> | |||||
| <action name="addFallingEdgeAction"> | |||||
| <property name="text"> | |||||
| <string>下降沿</string> | |||||
| </property> | |||||
| <property name="toolTip"> | |||||
| <string>在选中的空网格或横线处添加一个扫描周期的下降沿触点</string> | |||||
| </property> | |||||
| </action> | |||||
| <action name="addTimerContactAction"> | |||||
| <property name="text"> | |||||
| <string>T 触点</string> | |||||
| </property> | |||||
| <property name="toolTip"> | |||||
| <string>在选中的空网格或横线处添加离线仿真定时器触点</string> | |||||
| </property> | </property> | ||||
| </action> | </action> | ||||
| <action name="addCounterContactAction"> | |||||
| <property name="text"><string>C 触点</string></property> | |||||
| <property name="toolTip"><string>在选中的空网格或横线处添加离线仿真计数器完成触点</string></property> | |||||
| </action> | |||||
| <action name="addNormalCoilAction"> | <action name="addNormalCoilAction"> | ||||
| <property name="text"> | <property name="text"> | ||||
| <string>线圈</string> | <string>线圈</string> | ||||
| @@ -966,17 +1288,53 @@ | |||||
| <string>设置当前网络的复位线圈</string> | <string>设置当前网络的复位线圈</string> | ||||
| </property> | </property> | ||||
| </action> | </action> | ||||
| <action name="addTonAction"> | |||||
| <property name="text"> | |||||
| <string>TON</string> | |||||
| </property> | |||||
| <property name="toolTip"> | |||||
| <string>设置非保持接通延时定时器输出</string> | |||||
| </property> | |||||
| </action> | |||||
| <action name="addCtuAction"> | |||||
| <property name="text"><string>CTU</string></property> | |||||
| <property name="toolTip"><string>设置按上升沿执行的加计数器</string></property> | |||||
| </action> | |||||
| <action name="addCtdAction"> | |||||
| <property name="text"><string>CTD</string></property> | |||||
| <property name="toolTip"><string>设置按上升沿执行的减计数器</string></property> | |||||
| </action> | |||||
| <action name="addMoveAction"> | |||||
| <property name="text"><string>MOVE</string></property> | |||||
| <property name="toolTip"><string>设置 D 数据传送指令</string></property> | |||||
| </action> | |||||
| <action name="addAddAction"> | |||||
| <property name="text"><string>ADD</string></property> | |||||
| <property name="toolTip"><string>设置带饱和处理的加法指令</string></property> | |||||
| </action> | |||||
| <action name="addSubAction"> | |||||
| <property name="text"><string>SUB</string></property> | |||||
| <property name="toolTip"><string>设置带饱和处理的减法指令</string></property> | |||||
| </action> | |||||
| <action name="addCompareAction"> | <action name="addCompareAction"> | ||||
| <property name="text"> | <property name="text"> | ||||
| <string>D 比较</string> | <string>D 比较</string> | ||||
| </property> | </property> | ||||
| <property name="toolTip"> | <property name="toolTip"> | ||||
| <string>向当前网络添加 D 值与常量比较</string> | |||||
| <string>在选中的空网格或横线处添加 D 值与常量比较条件</string> | |||||
| </property> | |||||
| </action> | |||||
| <action name="editRungCommentAction"> | |||||
| <property name="text"> | |||||
| <string>网络注释</string> | |||||
| </property> | |||||
| <property name="toolTip"> | |||||
| <string>编辑当前梯形图网络说明</string> | |||||
| </property> | </property> | ||||
| </action> | </action> | ||||
| <action name="deleteLogicAction"> | <action name="deleteLogicAction"> | ||||
| <property name="text"> | <property name="text"> | ||||
| <string>删除</string> | |||||
| <string>删除节点/网络</string> | |||||
| </property> | </property> | ||||
| <property name="toolTip"> | <property name="toolTip"> | ||||
| <string>删除选中的逻辑节点或网络</string> | <string>删除选中的逻辑节点或网络</string> | ||||
| @@ -65,7 +65,7 @@ | |||||
| <item row="0" column="0"><widget class="QLabel" name="responseTimeoutLabel"><property name="text"><string>响应超时</string></property></widget></item> | <item row="0" column="0"><widget class="QLabel" name="responseTimeoutLabel"><property name="text"><string>响应超时</string></property></widget></item> | ||||
| <item row="0" column="1"><widget class="QSpinBox" name="responseTimeoutSpinBox"><property name="suffix"><string> ms</string></property><property name="minimum"><number>100</number></property><property name="maximum"><number>30000</number></property><property name="singleStep"><number>100</number></property><property name="value"><number>1000</number></property></widget></item> | <item row="0" column="1"><widget class="QSpinBox" name="responseTimeoutSpinBox"><property name="suffix"><string> ms</string></property><property name="minimum"><number>100</number></property><property name="maximum"><number>30000</number></property><property name="singleStep"><number>100</number></property><property name="value"><number>1000</number></property></widget></item> | ||||
| <item row="1" column="0"><widget class="QLabel" name="retriesLabel"><property name="text"><string>失败重试</string></property></widget></item> | <item row="1" column="0"><widget class="QLabel" name="retriesLabel"><property name="text"><string>失败重试</string></property></widget></item> | ||||
| <item row="1" column="1"><widget class="QSpinBox" name="retriesSpinBox"><property name="suffix"><string> 次</string></property><property name="maximum"><number>10</number></property><property name="value"><number>2</number></property></widget></item> | |||||
| <item row="1" column="1"><widget class="QSpinBox" name="retriesSpinBox"><property name="suffix"><string> 次</string></property><property name="maximum"><number>5</number></property><property name="value"><number>2</number></property></widget></item> | |||||
| <item row="2" column="0"><widget class="QLabel" name="pollIntervalLabel"><property name="text"><string>轮询周期</string></property></widget></item> | <item row="2" column="0"><widget class="QLabel" name="pollIntervalLabel"><property name="text"><string>轮询周期</string></property></widget></item> | ||||
| <item row="2" column="1"><widget class="QSpinBox" name="pollIntervalSpinBox"><property name="suffix"><string> ms</string></property><property name="minimum"><number>50</number></property><property name="maximum"><number>10000</number></property><property name="singleStep"><number>50</number></property><property name="value"><number>200</number></property></widget></item> | <item row="2" column="1"><widget class="QSpinBox" name="pollIntervalSpinBox"><property name="suffix"><string> ms</string></property><property name="minimum"><number>50</number></property><property name="maximum"><number>10000</number></property><property name="singleStep"><number>50</number></property><property name="value"><number>200</number></property></widget></item> | ||||
| </layout> | </layout> | ||||
| @@ -0,0 +1,520 @@ | |||||
| #include "project_workspace_controller.h" | |||||
| #include "hmi_editor_widget.h" | |||||
| #include "logic_editor_widget.h" | |||||
| #include "runtime_monitor_widget.h" | |||||
| #include "services/hmi_editor_service.h" | |||||
| #include "services/hmi_navigation_service.h" | |||||
| #include "services/logic_editor_service.h" | |||||
| #include "services/project_service.h" | |||||
| #include "services/runtime_mode_service.h" | |||||
| #include "ui_main_window.h" | |||||
| #include <QAction> | |||||
| #include <QInputDialog> | |||||
| #include <QLineEdit> | |||||
| #include <QObject> | |||||
| #include <QSignalBlocker> | |||||
| #include <QTabWidget> | |||||
| #include <QTreeWidget> | |||||
| #include <QTreeWidgetItem> | |||||
| #include <QWidget> | |||||
| #include <utility> | |||||
| namespace { | |||||
| constexpr int kProjectTreeKindRole = Qt::UserRole; | |||||
| constexpr int kProjectTreeIdRole = Qt::UserRole + 1; | |||||
| QString fromUtf8(const std::string &value) | |||||
| { | |||||
| return QString::fromUtf8(value.data(), static_cast<int>(value.size())); | |||||
| } | |||||
| std::string toUtf8(const QString &value) | |||||
| { | |||||
| const QByteArray bytes = value.toUtf8(); | |||||
| return std::string(bytes.constData(), static_cast<std::size_t>(bytes.size())); | |||||
| } | |||||
| } // namespace | |||||
| ProjectWorkspaceController::ProjectWorkspaceController( | |||||
| QWidget &parent, | |||||
| Ui::MainWindow &ui, | |||||
| ProjectService &project_service, | |||||
| HmiEditorService &hmi_editor_service, | |||||
| LogicEditorService &logic_editor_service, | |||||
| RuntimeModeService &runtime_mode_service, | |||||
| HmiNavigationService &hmi_navigation_service, | |||||
| HmiEditorWidget &hmi_editor_widget, | |||||
| LogicEditorWidget &logic_editor_widget, | |||||
| RuntimeMonitorWidget &runtime_monitor_widget, | |||||
| std::string ¤t_hmi_page_id, | |||||
| std::string ¤t_logic_id, | |||||
| std::function<void(const std::string &)> show_control_properties, | |||||
| std::function<void(const std::string &)> show_logic_properties, | |||||
| ResultReporter result_reporter, | |||||
| StatusReporter status_reporter, | |||||
| EditStateChanged edit_state_changed) | |||||
| : parent_(parent), | |||||
| ui_(ui), | |||||
| project_service_(project_service), | |||||
| hmi_editor_service_(hmi_editor_service), | |||||
| logic_editor_service_(logic_editor_service), | |||||
| runtime_mode_service_(runtime_mode_service), | |||||
| hmi_navigation_service_(hmi_navigation_service), | |||||
| hmi_editor_widget_(hmi_editor_widget), | |||||
| logic_editor_widget_(logic_editor_widget), | |||||
| runtime_monitor_widget_(runtime_monitor_widget), | |||||
| current_hmi_page_id_(current_hmi_page_id), | |||||
| current_logic_id_(current_logic_id), | |||||
| show_control_properties_(std::move(show_control_properties)), | |||||
| show_logic_properties_(std::move(show_logic_properties)), | |||||
| result_reporter_(std::move(result_reporter)), | |||||
| status_reporter_(std::move(status_reporter)), | |||||
| edit_state_changed_(std::move(edit_state_changed)) | |||||
| { | |||||
| } | |||||
| void ProjectWorkspaceController::configure() | |||||
| { | |||||
| ui_.projectTree->setContextMenuPolicy(Qt::ActionsContextMenu); | |||||
| add_page_action_ = new QAction(QObject::tr("新建页面"), &parent_); | |||||
| add_page_action_->setObjectName(QStringLiteral("addPageAction")); | |||||
| add_logic_action_ = new QAction(QObject::tr("新建控制逻辑"), &parent_); | |||||
| add_logic_action_->setObjectName(QStringLiteral("addLogicAction")); | |||||
| rename_item_action_ = new QAction(QObject::tr("重命名"), &parent_); | |||||
| rename_item_action_->setObjectName(QStringLiteral("renameProjectItemAction")); | |||||
| delete_item_action_ = new QAction(QObject::tr("删除"), &parent_); | |||||
| delete_item_action_->setObjectName(QStringLiteral("deleteProjectItemAction")); | |||||
| move_item_up_action_ = new QAction(QObject::tr("上移"), &parent_); | |||||
| move_item_up_action_->setObjectName(QStringLiteral("moveProjectItemUpAction")); | |||||
| move_item_down_action_ = new QAction(QObject::tr("下移"), &parent_); | |||||
| move_item_down_action_->setObjectName( | |||||
| QStringLiteral("moveProjectItemDownAction")); | |||||
| set_initial_page_action_ = new QAction(QObject::tr("设为初始页面"), &parent_); | |||||
| set_initial_page_action_->setObjectName(QStringLiteral("setInitialPageAction")); | |||||
| toggle_logic_enabled_action_ = new QAction( | |||||
| QObject::tr("禁用控制逻辑"), &parent_); | |||||
| toggle_logic_enabled_action_->setObjectName( | |||||
| QStringLiteral("toggleLogicEnabledAction")); | |||||
| ui_.projectTree->addActions({ | |||||
| add_page_action_, | |||||
| add_logic_action_, | |||||
| rename_item_action_, | |||||
| delete_item_action_, | |||||
| move_item_up_action_, | |||||
| move_item_down_action_, | |||||
| set_initial_page_action_, | |||||
| toggle_logic_enabled_action_}); | |||||
| QObject::connect(add_page_action_, &QAction::triggered, | |||||
| &parent_, [this] { addPage(); }); | |||||
| QObject::connect(add_logic_action_, &QAction::triggered, | |||||
| &parent_, [this] { addLogic(); }); | |||||
| QObject::connect(rename_item_action_, &QAction::triggered, | |||||
| &parent_, [this] { renameSelectedItem(); }); | |||||
| QObject::connect(delete_item_action_, &QAction::triggered, | |||||
| &parent_, [this] { deleteSelectedItem(); }); | |||||
| QObject::connect(move_item_up_action_, &QAction::triggered, | |||||
| &parent_, [this] { moveSelectedItem(-1); }); | |||||
| QObject::connect(move_item_down_action_, &QAction::triggered, | |||||
| &parent_, [this] { moveSelectedItem(1); }); | |||||
| QObject::connect(set_initial_page_action_, &QAction::triggered, | |||||
| &parent_, [this] { setSelectedPageAsInitial(); }); | |||||
| QObject::connect(toggle_logic_enabled_action_, &QAction::triggered, | |||||
| &parent_, [this] { toggleSelectedLogicEnabled(); }); | |||||
| QObject::connect(ui_.projectTree, &QTreeWidget::currentItemChanged, | |||||
| &parent_, [this](QTreeWidgetItem *, QTreeWidgetItem *) | |||||
| { | |||||
| handleSelectionChanged(); | |||||
| }); | |||||
| updateActions(); | |||||
| } | |||||
| void ProjectWorkspaceController::refresh() | |||||
| { | |||||
| if (hmi_editor_service_.findPage(current_hmi_page_id_) == nullptr) | |||||
| { | |||||
| const std::string &initial_id = project_service_.project().initialHmiPageId; | |||||
| current_hmi_page_id_ = hmi_editor_service_.findPage(initial_id) != nullptr | |||||
| ? initial_id : hmi_editor_service_.firstPageId(); | |||||
| show_control_properties_({}); | |||||
| } | |||||
| if (logic_editor_service_.findLogic(current_logic_id_) == nullptr) | |||||
| { | |||||
| current_logic_id_ = logic_editor_service_.firstLogicId(); | |||||
| show_logic_properties_({}); | |||||
| } | |||||
| hmi_editor_widget_.setPageId(current_hmi_page_id_); | |||||
| logic_editor_widget_.setLogicId(current_logic_id_); | |||||
| if (runtime_mode_service_.mode() != ApplicationMode::Editing) | |||||
| { | |||||
| runtime_monitor_widget_.setProjectObjects( | |||||
| hmi_navigation_service_.currentPageId(), current_logic_id_); | |||||
| } | |||||
| runtime_mode_service_.refreshPlcPollAddresses(); | |||||
| const QSignalBlocker blocker(ui_.projectTree); | |||||
| ui_.projectTree->clear(); | |||||
| auto *hmi_root = new QTreeWidgetItem(ui_.projectTree, {QObject::tr("HMI 页面")}); | |||||
| hmi_root->setData(0, kProjectTreeKindRole, static_cast<int>(ItemKind::Root)); | |||||
| QTreeWidgetItem *selected_item = nullptr; | |||||
| for (const HmiPage &page : project_service_.project().hmiPages) | |||||
| { | |||||
| const QString suffix = page.id == project_service_.project().initialHmiPageId | |||||
| ? QObject::tr("(初始)") : QString{}; | |||||
| auto *item = new QTreeWidgetItem( | |||||
| hmi_root, {fromUtf8(page.name) + suffix}); | |||||
| item->setData(0, kProjectTreeKindRole, static_cast<int>(ItemKind::HmiPage)); | |||||
| item->setData(0, kProjectTreeIdRole, fromUtf8(page.id)); | |||||
| if (page.id == current_hmi_page_id_ | |||||
| && ui_.editorTabWidget->currentIndex() == 0) | |||||
| { | |||||
| selected_item = item; | |||||
| } | |||||
| } | |||||
| const HmiPage *current_page = hmi_editor_service_.findPage(current_hmi_page_id_); | |||||
| if (current_page != nullptr) | |||||
| { | |||||
| ui_.hmiPageTitleLabel->setText(fromUtf8(current_page->name)); | |||||
| ui_.hmiPageSizeLabel->setText( | |||||
| QObject::tr("%1 x %2").arg(current_page->width).arg(current_page->height)); | |||||
| } | |||||
| else | |||||
| { | |||||
| ui_.hmiPageTitleLabel->setText(QObject::tr("尚未创建 HMI 页面")); | |||||
| ui_.hmiPageSizeLabel->clear(); | |||||
| } | |||||
| auto *logic_root = new QTreeWidgetItem( | |||||
| ui_.projectTree, {QObject::tr("控制逻辑")}); | |||||
| logic_root->setData(0, kProjectTreeKindRole, static_cast<int>(ItemKind::Root)); | |||||
| for (const ControlLogic &logic : project_service_.project().controlLogics) | |||||
| { | |||||
| const QString suffix = logic.enabled ? QString{} : QObject::tr("(已禁用)"); | |||||
| auto *item = new QTreeWidgetItem( | |||||
| logic_root, {fromUtf8(logic.name) + suffix}); | |||||
| item->setData( | |||||
| 0, kProjectTreeKindRole, static_cast<int>(ItemKind::ControlLogic)); | |||||
| item->setData(0, kProjectTreeIdRole, fromUtf8(logic.id)); | |||||
| if (logic.id == current_logic_id_ | |||||
| && ui_.editorTabWidget->currentIndex() == 1) | |||||
| { | |||||
| selected_item = item; | |||||
| } | |||||
| } | |||||
| ui_.projectTree->expandAll(); | |||||
| if (selected_item != nullptr) | |||||
| { | |||||
| ui_.projectTree->setCurrentItem(selected_item); | |||||
| } | |||||
| updateActions(); | |||||
| } | |||||
| const std::string &ProjectWorkspaceController::currentHmiPageId() const | |||||
| { | |||||
| return current_hmi_page_id_; | |||||
| } | |||||
| const std::string &ProjectWorkspaceController::currentLogicId() const | |||||
| { | |||||
| return current_logic_id_; | |||||
| } | |||||
| void ProjectWorkspaceController::handleSelectionChanged() | |||||
| { | |||||
| QTreeWidgetItem *item = ui_.projectTree->currentItem(); | |||||
| if (item == nullptr) | |||||
| { | |||||
| updateActions(); | |||||
| return; | |||||
| } | |||||
| const auto kind = static_cast<ItemKind>( | |||||
| item->data(0, kProjectTreeKindRole).toInt()); | |||||
| const std::string id = toUtf8(item->data(0, kProjectTreeIdRole).toString()); | |||||
| if (kind == ItemKind::HmiPage && hmi_editor_service_.findPage(id) != nullptr) | |||||
| { | |||||
| current_hmi_page_id_ = id; | |||||
| hmi_editor_widget_.setPageId(id); | |||||
| hmi_editor_widget_.reloadPage(); | |||||
| ui_.editorTabWidget->setCurrentWidget(ui_.hmiEditorTab); | |||||
| show_control_properties_({}); | |||||
| const HmiPage *page = hmi_editor_service_.findPage(id); | |||||
| ui_.hmiPageTitleLabel->setText(fromUtf8(page->name)); | |||||
| ui_.hmiPageSizeLabel->setText( | |||||
| QObject::tr("%1 x %2").arg(page->width).arg(page->height)); | |||||
| } | |||||
| else if (kind == ItemKind::ControlLogic | |||||
| && logic_editor_service_.findLogic(id) != nullptr) | |||||
| { | |||||
| current_logic_id_ = id; | |||||
| logic_editor_widget_.setLogicId(id); | |||||
| logic_editor_widget_.reloadLogic(); | |||||
| ui_.editorTabWidget->setCurrentWidget(ui_.logicEditorTab); | |||||
| show_logic_properties_({}); | |||||
| } | |||||
| updateActions(); | |||||
| } | |||||
| void ProjectWorkspaceController::updateActions() | |||||
| { | |||||
| if (add_page_action_ == nullptr) | |||||
| { | |||||
| return; | |||||
| } | |||||
| QTreeWidgetItem *item = ui_.projectTree->currentItem(); | |||||
| const auto kind = item == nullptr ? ItemKind::Root | |||||
| : static_cast<ItemKind>(item->data(0, kProjectTreeKindRole).toInt()); | |||||
| const bool page_selected = kind == ItemKind::HmiPage; | |||||
| const bool logic_selected = kind == ItemKind::ControlLogic; | |||||
| const bool editable = runtime_mode_service_.policy().allowsProjectEditing; | |||||
| add_page_action_->setEnabled(editable); | |||||
| add_logic_action_->setEnabled(editable); | |||||
| rename_item_action_->setEnabled(editable && (page_selected || logic_selected)); | |||||
| delete_item_action_->setEnabled(editable && (page_selected || logic_selected)); | |||||
| move_item_up_action_->setEnabled(editable && (page_selected || logic_selected)); | |||||
| move_item_down_action_->setEnabled(editable && (page_selected || logic_selected)); | |||||
| set_initial_page_action_->setEnabled(editable && page_selected); | |||||
| toggle_logic_enabled_action_->setEnabled(editable && logic_selected); | |||||
| if (logic_selected) | |||||
| { | |||||
| const std::string id = toUtf8(item->data(0, kProjectTreeIdRole).toString()); | |||||
| const ControlLogic *logic = logic_editor_service_.findLogic(id); | |||||
| toggle_logic_enabled_action_->setText( | |||||
| logic != nullptr && logic->enabled | |||||
| ? QObject::tr("禁用控制逻辑") : QObject::tr("启用控制逻辑")); | |||||
| } | |||||
| } | |||||
| void ProjectWorkspaceController::addPage() | |||||
| { | |||||
| const QString name = QInputDialog::getText( | |||||
| &parent_, QObject::tr("新建页面"), QObject::tr("页面名称")); | |||||
| if (name.isEmpty()) | |||||
| { | |||||
| return; | |||||
| } | |||||
| const HmiEditorResult result = hmi_editor_service_.addPage(toUtf8(name)); | |||||
| if (!result.succeeded) | |||||
| { | |||||
| reportFailure(QObject::tr("新建页面"), result.message); | |||||
| return; | |||||
| } | |||||
| current_hmi_page_id_ = result.id; | |||||
| show_control_properties_({}); | |||||
| ui_.editorTabWidget->setCurrentWidget(ui_.hmiEditorTab); | |||||
| refresh(); | |||||
| hmi_editor_widget_.reloadPage(); | |||||
| edit_state_changed_(); | |||||
| } | |||||
| void ProjectWorkspaceController::addLogic() | |||||
| { | |||||
| const QString name = QInputDialog::getText( | |||||
| &parent_, QObject::tr("新建控制逻辑"), QObject::tr("逻辑名称")); | |||||
| if (name.isEmpty()) | |||||
| { | |||||
| return; | |||||
| } | |||||
| const LogicEditorResult result = logic_editor_service_.addLogic(toUtf8(name)); | |||||
| if (!result.succeeded) | |||||
| { | |||||
| reportFailure(QObject::tr("新建控制逻辑"), result.message); | |||||
| return; | |||||
| } | |||||
| current_logic_id_ = result.id; | |||||
| show_logic_properties_({}); | |||||
| ui_.editorTabWidget->setCurrentWidget(ui_.logicEditorTab); | |||||
| refresh(); | |||||
| logic_editor_widget_.reloadLogic(); | |||||
| edit_state_changed_(); | |||||
| } | |||||
| void ProjectWorkspaceController::renameSelectedItem() | |||||
| { | |||||
| QTreeWidgetItem *item = ui_.projectTree->currentItem(); | |||||
| if (item == nullptr) | |||||
| { | |||||
| return; | |||||
| } | |||||
| const auto kind = static_cast<ItemKind>( | |||||
| item->data(0, kProjectTreeKindRole).toInt()); | |||||
| const std::string id = toUtf8(item->data(0, kProjectTreeIdRole).toString()); | |||||
| QString current_name; | |||||
| if (kind == ItemKind::HmiPage) | |||||
| { | |||||
| const HmiPage *page = hmi_editor_service_.findPage(id); | |||||
| current_name = page == nullptr ? QString{} : fromUtf8(page->name); | |||||
| } | |||||
| else if (kind == ItemKind::ControlLogic) | |||||
| { | |||||
| const ControlLogic *logic = logic_editor_service_.findLogic(id); | |||||
| current_name = logic == nullptr ? QString{} : fromUtf8(logic->name); | |||||
| } | |||||
| else | |||||
| { | |||||
| return; | |||||
| } | |||||
| bool accepted = false; | |||||
| const QString name = QInputDialog::getText( | |||||
| &parent_, QObject::tr("重命名"), QObject::tr("新名称"), | |||||
| QLineEdit::Normal, current_name, &accepted); | |||||
| if (!accepted) | |||||
| { | |||||
| return; | |||||
| } | |||||
| if (kind == ItemKind::HmiPage) | |||||
| { | |||||
| const HmiEditorResult result = hmi_editor_service_.renamePage(id, toUtf8(name)); | |||||
| if (!result.succeeded) | |||||
| { | |||||
| reportFailure(QObject::tr("重命名页面"), result.message); | |||||
| return; | |||||
| } | |||||
| } | |||||
| else | |||||
| { | |||||
| const LogicEditorResult result = logic_editor_service_.renameLogic( | |||||
| id, toUtf8(name)); | |||||
| if (!result.succeeded) | |||||
| { | |||||
| reportFailure(QObject::tr("重命名控制逻辑"), result.message); | |||||
| return; | |||||
| } | |||||
| } | |||||
| refresh(); | |||||
| edit_state_changed_(); | |||||
| } | |||||
| void ProjectWorkspaceController::deleteSelectedItem() | |||||
| { | |||||
| QTreeWidgetItem *item = ui_.projectTree->currentItem(); | |||||
| if (item == nullptr) | |||||
| { | |||||
| return; | |||||
| } | |||||
| const auto kind = static_cast<ItemKind>( | |||||
| item->data(0, kProjectTreeKindRole).toInt()); | |||||
| const std::string id = toUtf8(item->data(0, kProjectTreeIdRole).toString()); | |||||
| if (kind == ItemKind::HmiPage) | |||||
| { | |||||
| const HmiEditorResult result = hmi_editor_service_.removePage(id); | |||||
| if (!result.succeeded) | |||||
| { | |||||
| reportFailure(QObject::tr("删除页面"), result.message); | |||||
| return; | |||||
| } | |||||
| } | |||||
| else if (kind == ItemKind::ControlLogic) | |||||
| { | |||||
| const LogicEditorResult result = logic_editor_service_.removeLogic(id); | |||||
| if (!result.succeeded) | |||||
| { | |||||
| reportFailure(QObject::tr("删除控制逻辑"), result.message); | |||||
| return; | |||||
| } | |||||
| } | |||||
| else | |||||
| { | |||||
| return; | |||||
| } | |||||
| refresh(); | |||||
| show_control_properties_({}); | |||||
| show_logic_properties_({}); | |||||
| edit_state_changed_(); | |||||
| } | |||||
| void ProjectWorkspaceController::moveSelectedItem(int offset) | |||||
| { | |||||
| QTreeWidgetItem *item = ui_.projectTree->currentItem(); | |||||
| if (item == nullptr) | |||||
| { | |||||
| return; | |||||
| } | |||||
| const auto kind = static_cast<ItemKind>( | |||||
| item->data(0, kProjectTreeKindRole).toInt()); | |||||
| const std::string id = toUtf8(item->data(0, kProjectTreeIdRole).toString()); | |||||
| if (kind == ItemKind::HmiPage) | |||||
| { | |||||
| const HmiEditorResult result = hmi_editor_service_.movePage(id, offset); | |||||
| if (!result.succeeded) | |||||
| { | |||||
| status_reporter_(fromUtf8(result.message), 4000); | |||||
| return; | |||||
| } | |||||
| } | |||||
| else if (kind == ItemKind::ControlLogic) | |||||
| { | |||||
| const LogicEditorResult result = logic_editor_service_.moveLogic(id, offset); | |||||
| if (!result.succeeded) | |||||
| { | |||||
| status_reporter_(fromUtf8(result.message), 4000); | |||||
| return; | |||||
| } | |||||
| } | |||||
| else | |||||
| { | |||||
| return; | |||||
| } | |||||
| refresh(); | |||||
| edit_state_changed_(); | |||||
| } | |||||
| void ProjectWorkspaceController::setSelectedPageAsInitial() | |||||
| { | |||||
| QTreeWidgetItem *item = ui_.projectTree->currentItem(); | |||||
| if (item == nullptr | |||||
| || static_cast<ItemKind>(item->data(0, kProjectTreeKindRole).toInt()) | |||||
| != ItemKind::HmiPage) | |||||
| { | |||||
| return; | |||||
| } | |||||
| const HmiEditorResult result = hmi_editor_service_.setInitialPage( | |||||
| toUtf8(item->data(0, kProjectTreeIdRole).toString())); | |||||
| if (!result.succeeded) | |||||
| { | |||||
| reportFailure(QObject::tr("设置初始页面"), result.message); | |||||
| return; | |||||
| } | |||||
| refresh(); | |||||
| edit_state_changed_(); | |||||
| } | |||||
| void ProjectWorkspaceController::toggleSelectedLogicEnabled() | |||||
| { | |||||
| QTreeWidgetItem *item = ui_.projectTree->currentItem(); | |||||
| if (item == nullptr | |||||
| || static_cast<ItemKind>(item->data(0, kProjectTreeKindRole).toInt()) | |||||
| != ItemKind::ControlLogic) | |||||
| { | |||||
| return; | |||||
| } | |||||
| const std::string id = toUtf8(item->data(0, kProjectTreeIdRole).toString()); | |||||
| const ControlLogic *logic = logic_editor_service_.findLogic(id); | |||||
| if (logic == nullptr) | |||||
| { | |||||
| return; | |||||
| } | |||||
| const LogicEditorResult result = logic_editor_service_.setLogicEnabled( | |||||
| id, !logic->enabled); | |||||
| if (!result.succeeded) | |||||
| { | |||||
| reportFailure(QObject::tr("启停控制逻辑"), result.message); | |||||
| return; | |||||
| } | |||||
| refresh(); | |||||
| edit_state_changed_(); | |||||
| } | |||||
| void ProjectWorkspaceController::reportFailure( | |||||
| const QString &action, const std::string &message) const | |||||
| { | |||||
| result_reporter_(action, fromUtf8(message), false); | |||||
| } | |||||
| @@ -0,0 +1,107 @@ | |||||
| #pragma once | |||||
| #include "domain/hmi_model.h" | |||||
| #include "domain/runtime_state.h" | |||||
| #include <QString> | |||||
| #include <functional> | |||||
| #include <string> | |||||
| class HmiEditorService; | |||||
| class HmiEditorWidget; | |||||
| class HmiNavigationService; | |||||
| class LogicEditorService; | |||||
| class LogicEditorWidget; | |||||
| class ProjectService; | |||||
| class RuntimeModeService; | |||||
| class RuntimeMonitorWidget; | |||||
| class QWidget; | |||||
| class QAction; | |||||
| namespace Ui { | |||||
| class MainWindow; | |||||
| } | |||||
| // 组织工程树、当前页面/逻辑选择和工程对象的界面刷新 | |||||
| class ProjectWorkspaceController final | |||||
| { | |||||
| public: | |||||
| using ResultReporter = std::function<void( | |||||
| const QString &action, const QString &message, bool succeeded)>; | |||||
| using StatusReporter = std::function<void( | |||||
| const QString &message, int timeout_ms)>; | |||||
| using EditStateChanged = std::function<void()>; | |||||
| ProjectWorkspaceController( | |||||
| QWidget &parent, | |||||
| Ui::MainWindow &ui, | |||||
| ProjectService &project_service, | |||||
| HmiEditorService &hmi_editor_service, | |||||
| LogicEditorService &logic_editor_service, | |||||
| RuntimeModeService &runtime_mode_service, | |||||
| HmiNavigationService &hmi_navigation_service, | |||||
| HmiEditorWidget &hmi_editor_widget, | |||||
| LogicEditorWidget &logic_editor_widget, | |||||
| RuntimeMonitorWidget &runtime_monitor_widget, | |||||
| std::string ¤t_hmi_page_id, | |||||
| std::string ¤t_logic_id, | |||||
| std::function<void(const std::string &)> show_control_properties, | |||||
| std::function<void(const std::string &)> show_logic_properties, | |||||
| ResultReporter result_reporter, | |||||
| StatusReporter status_reporter, | |||||
| EditStateChanged edit_state_changed); | |||||
| void configure(); | |||||
| void refresh(); | |||||
| void updateActions(); | |||||
| void addPage(); | |||||
| void addLogic(); | |||||
| void renameSelectedItem(); | |||||
| void deleteSelectedItem(); | |||||
| void moveSelectedItem(int offset); | |||||
| void setSelectedPageAsInitial(); | |||||
| void toggleSelectedLogicEnabled(); | |||||
| const std::string ¤tHmiPageId() const; | |||||
| const std::string ¤tLogicId() const; | |||||
| private: | |||||
| enum class ItemKind | |||||
| { | |||||
| Root = 0, | |||||
| HmiPage = 1, | |||||
| ControlLogic = 2 | |||||
| }; | |||||
| void handleSelectionChanged(); | |||||
| void reportFailure( | |||||
| const QString &action, const std::string &message) const; | |||||
| QWidget &parent_; | |||||
| Ui::MainWindow &ui_; | |||||
| ProjectService &project_service_; | |||||
| HmiEditorService &hmi_editor_service_; | |||||
| LogicEditorService &logic_editor_service_; | |||||
| RuntimeModeService &runtime_mode_service_; | |||||
| HmiNavigationService &hmi_navigation_service_; | |||||
| HmiEditorWidget &hmi_editor_widget_; | |||||
| LogicEditorWidget &logic_editor_widget_; | |||||
| RuntimeMonitorWidget &runtime_monitor_widget_; | |||||
| std::string ¤t_hmi_page_id_; | |||||
| std::string ¤t_logic_id_; | |||||
| std::function<void(const std::string &)> show_control_properties_; | |||||
| std::function<void(const std::string &)> show_logic_properties_; | |||||
| ResultReporter result_reporter_; | |||||
| StatusReporter status_reporter_; | |||||
| EditStateChanged edit_state_changed_; | |||||
| QAction *add_page_action_ = nullptr; | |||||
| QAction *add_logic_action_ = nullptr; | |||||
| QAction *rename_item_action_ = nullptr; | |||||
| QAction *delete_item_action_ = nullptr; | |||||
| QAction *move_item_up_action_ = nullptr; | |||||
| QAction *move_item_down_action_ = nullptr; | |||||
| QAction *set_initial_page_action_ = nullptr; | |||||
| QAction *toggle_logic_enabled_action_ = nullptr; | |||||
| }; | |||||
| @@ -0,0 +1,799 @@ | |||||
| #include "property_panel_controller.h" | |||||
| #include "domain/hmi_control_registry.h" | |||||
| #include "domain/project_limits.h" | |||||
| #include "hmi_editor_widget.h" | |||||
| #include "logic_editor_widget.h" | |||||
| #include "logic_instruction_dialog.h" | |||||
| #include "services/hmi_editor_service.h" | |||||
| #include "services/logic_editor_service.h" | |||||
| #include "services/project_service.h" | |||||
| #include "ui_main_window.h" | |||||
| #include <QApplication> | |||||
| #include <QCheckBox> | |||||
| #include <QColorDialog> | |||||
| #include <QComboBox> | |||||
| #include <QLabel> | |||||
| #include <QLineEdit> | |||||
| #include <QPushButton> | |||||
| #include <QDialog> | |||||
| #include <QObject> | |||||
| #include <QSpinBox> | |||||
| #include <QStackedWidget> | |||||
| #include <QWidget> | |||||
| #include <algorithm> | |||||
| #include <cstdint> | |||||
| #include <utility> | |||||
| #include <vector> | |||||
| namespace { | |||||
| QString fromUtf8(const std::string &value) | |||||
| { | |||||
| return QString::fromUtf8(value.data(), static_cast<int>(value.size())); | |||||
| } | |||||
| std::string toUtf8(const QString &value) | |||||
| { | |||||
| const QByteArray bytes = value.toUtf8(); | |||||
| return std::string(bytes.constData(), static_cast<std::size_t>(bytes.size())); | |||||
| } | |||||
| int defaultFontPointSize(HmiControlType type) | |||||
| { | |||||
| int point_size = QApplication::font().pointSize(); | |||||
| if (type == HmiControlType::AlarmList) | |||||
| { | |||||
| point_size -= ProjectLimits::kAlarmDefaultFontPointReduction; | |||||
| } | |||||
| return std::clamp( | |||||
| point_size, | |||||
| ProjectLimits::kMinimumHmiFontPointSize, | |||||
| ProjectLimits::kMaximumHmiFontPointSize); | |||||
| } | |||||
| } // namespace | |||||
| PropertyPanelController::PropertyPanelController( | |||||
| QWidget &parent, | |||||
| Ui::MainWindow &ui, | |||||
| ProjectService &project_service, | |||||
| HmiEditorService &hmi_editor_service, | |||||
| LogicEditorService &logic_editor_service, | |||||
| std::function<std::string()> current_page_id, | |||||
| std::function<std::string()> current_logic_id, | |||||
| std::string &selected_control_id, | |||||
| std::string &selected_logic_node_id, | |||||
| std::function<void()> refresh_project_ui, | |||||
| ResultReporter result_reporter, | |||||
| StatusReporter status_reporter) | |||||
| : parent_(parent), | |||||
| ui_(ui), | |||||
| project_service_(project_service), | |||||
| hmi_editor_service_(hmi_editor_service), | |||||
| logic_editor_service_(logic_editor_service), | |||||
| current_page_id_(std::move(current_page_id)), | |||||
| current_logic_id_(std::move(current_logic_id)), | |||||
| selected_control_id_(selected_control_id), | |||||
| selected_logic_node_id_(selected_logic_node_id), | |||||
| refresh_project_ui_(std::move(refresh_project_ui)), | |||||
| result_reporter_(std::move(result_reporter)), | |||||
| status_reporter_(std::move(status_reporter)) | |||||
| { | |||||
| } | |||||
| void PropertyPanelController::configure() | |||||
| { | |||||
| ui_.pageWidthSpinBox->setRange( | |||||
| ProjectLimits::kMinimumHmiPageWidth, | |||||
| ProjectLimits::kMaximumHmiPageWidth); | |||||
| ui_.pageHeightSpinBox->setRange( | |||||
| ProjectLimits::kMinimumHmiPageHeight, | |||||
| ProjectLimits::kMaximumHmiPageHeight); | |||||
| ui_.fontSizeSpinBox->setRange( | |||||
| ProjectLimits::kMinimumHmiFontPointSize, | |||||
| ProjectLimits::kMaximumHmiFontPointSize); | |||||
| ui_.bindingAreaComboBox->setItemData(0, -1); | |||||
| ui_.bindingAreaComboBox->setItemData(1, 0); | |||||
| ui_.bindingAreaComboBox->setItemData(2, 1); | |||||
| ui_.buttonOperationComboBox->setItemData( | |||||
| 0, static_cast<int>(HmiButtonOperation::SetOn)); | |||||
| ui_.buttonOperationComboBox->setItemData( | |||||
| 1, static_cast<int>(HmiButtonOperation::SetOff)); | |||||
| ui_.buttonOperationComboBox->setItemData( | |||||
| 2, static_cast<int>(HmiButtonOperation::Toggle)); | |||||
| ui_.buttonOperationComboBox->setItemData( | |||||
| 3, static_cast<int>(HmiButtonOperation::MomentaryOn)); | |||||
| for (int index = 0; index < ui_.logicComparisonComboBox->count(); ++index) | |||||
| { | |||||
| ui_.logicComparisonComboBox->setItemData(index, index); | |||||
| } | |||||
| QObject::connect(ui_.applyPropertiesButton, &QPushButton::clicked, | |||||
| &parent_, [this] { applySelectedControlProperties(); }); | |||||
| QObject::connect(ui_.applyPagePropertiesButton, &QPushButton::clicked, | |||||
| &parent_, [this] { applySelectedPageProperties(); }); | |||||
| QObject::connect(ui_.applyLogicPropertiesButton, &QPushButton::clicked, | |||||
| &parent_, [this] { applySelectedLogicNodeProperties(); }); | |||||
| QObject::connect(ui_.textColorButton, &QPushButton::clicked, | |||||
| &parent_, [this] { chooseTextColor(); }); | |||||
| ui_.targetPageLabel->setVisible(false); | |||||
| ui_.targetPageComboBox->setVisible(false); | |||||
| showControlProperties({}); | |||||
| } | |||||
| void PropertyPanelController::bindEditorWidgets( | |||||
| HmiEditorWidget &hmi_editor_widget, | |||||
| LogicEditorWidget &logic_editor_widget) | |||||
| { | |||||
| hmi_editor_widget_ = &hmi_editor_widget; | |||||
| logic_editor_widget_ = &logic_editor_widget; | |||||
| QObject::connect(&hmi_editor_widget, &HmiEditorWidget::controlSelected, | |||||
| &parent_, [this](const QString &id) | |||||
| { | |||||
| showControlProperties(toUtf8(id)); | |||||
| }); | |||||
| QObject::connect(&hmi_editor_widget, &HmiEditorWidget::controlChanged, | |||||
| &parent_, [this](const QString &id) | |||||
| { | |||||
| showControlProperties(toUtf8(id)); | |||||
| refresh_project_ui_(); | |||||
| }); | |||||
| QObject::connect(&hmi_editor_widget, &HmiEditorWidget::editorError, | |||||
| &parent_, [this](const QString &message) | |||||
| { | |||||
| handleHmiEditorError(message); | |||||
| }); | |||||
| QObject::connect(&logic_editor_widget, &LogicEditorWidget::nodeSelected, | |||||
| &parent_, [this](const QString &id) | |||||
| { | |||||
| showLogicNodeProperties(toUtf8(id)); | |||||
| }); | |||||
| QObject::connect(&logic_editor_widget, &LogicEditorWidget::graphChanged, | |||||
| &parent_, [this] | |||||
| { | |||||
| refresh_project_ui_(); | |||||
| }); | |||||
| QObject::connect(&logic_editor_widget, &LogicEditorWidget::editorError, | |||||
| &parent_, [this](const QString &message) | |||||
| { | |||||
| handleLogicEditorError(message); | |||||
| }); | |||||
| } | |||||
| void PropertyPanelController::showControlProperties(const std::string &control_id) | |||||
| { | |||||
| if (control_id.empty()) | |||||
| { | |||||
| showPageProperties(current_page_id_()); | |||||
| return; | |||||
| } | |||||
| ui_.propertyStack->setCurrentWidget(ui_.hmiPropertiesPage); | |||||
| selected_control_id_ = control_id; | |||||
| const HmiControl *control = hmi_editor_service_.findControl( | |||||
| current_page_id_(), control_id); | |||||
| const bool has_control = control != nullptr; | |||||
| const HmiControlDescriptor *descriptor = has_control | |||||
| ? findHmiControlDescriptor(control->type) : nullptr; | |||||
| ui_.selectionValueLabel->setText( | |||||
| has_control ? fromUtf8(control->id) | |||||
| + (control->isConfigured() ? QString{} | |||||
| : control->type == HmiControlType::PageJump | |||||
| ? QObject::tr("(未配置)") : QObject::tr("(未绑定)")) | |||||
| : QObject::tr("未选择")); | |||||
| for (QWidget *widget : {static_cast<QWidget *>(ui_.controlIdEdit), | |||||
| static_cast<QWidget *>(ui_.controlTextEdit), | |||||
| static_cast<QWidget *>(ui_.controlXSpinBox), | |||||
| static_cast<QWidget *>(ui_.controlYSpinBox), | |||||
| static_cast<QWidget *>(ui_.controlWidthSpinBox), | |||||
| static_cast<QWidget *>(ui_.controlHeightSpinBox), | |||||
| static_cast<QWidget *>(ui_.bindingAreaComboBox), | |||||
| static_cast<QWidget *>(ui_.bindingIndexSpinBox), | |||||
| static_cast<QWidget *>(ui_.targetPageComboBox), | |||||
| static_cast<QWidget *>(ui_.buttonOperationComboBox), | |||||
| static_cast<QWidget *>(ui_.progressMinimumSpinBox), | |||||
| static_cast<QWidget *>(ui_.progressMaximumSpinBox), | |||||
| static_cast<QWidget *>(ui_.progressShowValueCheckBox), | |||||
| static_cast<QWidget *>(ui_.textColorEdit), | |||||
| static_cast<QWidget *>(ui_.textColorButton), | |||||
| static_cast<QWidget *>(ui_.fontSizeSpinBox), | |||||
| static_cast<QWidget *>(ui_.fontBoldCheckBox), | |||||
| static_cast<QWidget *>(ui_.fontItalicCheckBox), | |||||
| static_cast<QWidget *>(ui_.applyPropertiesButton)}) | |||||
| { | |||||
| widget->setEnabled(has_control); | |||||
| } | |||||
| if (!has_control) | |||||
| { | |||||
| ui_.controlTextEdit->setMaxLength( | |||||
| static_cast<int>(ProjectLimits::kMaximumHmiControlTextCharacters)); | |||||
| ui_.bindingAreaLabel->setVisible(false); | |||||
| ui_.bindingAreaComboBox->setVisible(false); | |||||
| ui_.bindingIndexLabel->setVisible(false); | |||||
| ui_.bindingIndexSpinBox->setVisible(false); | |||||
| ui_.targetPageLabel->setVisible(false); | |||||
| ui_.targetPageComboBox->setVisible(false); | |||||
| ui_.buttonOperationLabel->setVisible(false); | |||||
| ui_.buttonOperationComboBox->setVisible(false); | |||||
| ui_.progressMinimumLabel->setVisible(false); | |||||
| ui_.progressMinimumSpinBox->setVisible(false); | |||||
| ui_.progressMaximumLabel->setVisible(false); | |||||
| ui_.progressMaximumSpinBox->setVisible(false); | |||||
| ui_.progressShowValueLabel->setVisible(false); | |||||
| ui_.progressShowValueCheckBox->setVisible(false); | |||||
| ui_.textColorEdit->clear(); | |||||
| ui_.textColorButton->setStyleSheet(QString{}); | |||||
| ui_.fontSizeSpinBox->setValue( | |||||
| std::clamp( | |||||
| QApplication::font().pointSize(), | |||||
| ProjectLimits::kMinimumHmiFontPointSize, | |||||
| ProjectLimits::kMaximumHmiFontPointSize)); | |||||
| ui_.fontBoldCheckBox->setChecked(false); | |||||
| ui_.fontItalicCheckBox->setChecked(false); | |||||
| return; | |||||
| } | |||||
| ui_.controlTextEdit->setMaxLength( | |||||
| static_cast<int>(ProjectLimits::kMaximumHmiControlTextCharacters)); | |||||
| ui_.controlIdEdit->setText(fromUtf8(control->id)); | |||||
| ui_.controlTextEdit->setText(fromUtf8(control->text)); | |||||
| ui_.controlXSpinBox->setValue(control->bounds.x); | |||||
| ui_.controlYSpinBox->setValue(control->bounds.y); | |||||
| ui_.controlWidthSpinBox->setValue(control->bounds.width); | |||||
| ui_.controlHeightSpinBox->setValue(control->bounds.height); | |||||
| ui_.bindingAreaComboBox->setCurrentIndex(!control->binding.has_value() ? 0 | |||||
| : control->binding->area() == RegisterArea::M ? 1 : 2); | |||||
| ui_.bindingIndexSpinBox->setValue( | |||||
| control->binding.has_value() ? control->binding->index() : 0); | |||||
| const bool has_binding = descriptor != nullptr | |||||
| && descriptor->bindingKind != HmiBindingKind::None; | |||||
| ui_.bindingAreaLabel->setVisible(has_binding); | |||||
| ui_.bindingAreaComboBox->setVisible(has_binding); | |||||
| ui_.bindingIndexLabel->setVisible(has_binding); | |||||
| ui_.bindingIndexSpinBox->setVisible(has_binding); | |||||
| const bool is_button = control->type == HmiControlType::Button; | |||||
| ui_.buttonOperationLabel->setVisible(is_button); | |||||
| ui_.buttonOperationComboBox->setVisible(is_button); | |||||
| ui_.buttonOperationComboBox->setEnabled(is_button); | |||||
| ui_.buttonOperationComboBox->setCurrentIndex( | |||||
| ui_.buttonOperationComboBox->findData( | |||||
| static_cast<int>(control->buttonOperation))); | |||||
| const bool is_page_jump = control->type == HmiControlType::PageJump; | |||||
| ui_.targetPageLabel->setVisible(is_page_jump); | |||||
| ui_.targetPageComboBox->setVisible(is_page_jump); | |||||
| ui_.targetPageComboBox->setEnabled(is_page_jump); | |||||
| ui_.targetPageComboBox->clear(); | |||||
| if (is_page_jump) | |||||
| { | |||||
| for (const HmiPage &page : project_service_.project().hmiPages) | |||||
| { | |||||
| ui_.targetPageComboBox->addItem( | |||||
| fromUtf8(page.name), fromUtf8(page.id)); | |||||
| } | |||||
| const std::string target_id = control->pageJump.has_value() | |||||
| ? control->pageJump->targetPageId : std::string{}; | |||||
| ui_.targetPageComboBox->setCurrentIndex( | |||||
| ui_.targetPageComboBox->findData(fromUtf8(target_id))); | |||||
| } | |||||
| const bool is_progress_bar = control->type == HmiControlType::ProgressBar; | |||||
| ui_.progressMinimumLabel->setVisible(is_progress_bar); | |||||
| ui_.progressMinimumSpinBox->setVisible(is_progress_bar); | |||||
| ui_.progressMaximumLabel->setVisible(is_progress_bar); | |||||
| ui_.progressMaximumSpinBox->setVisible(is_progress_bar); | |||||
| ui_.progressShowValueLabel->setVisible(is_progress_bar); | |||||
| ui_.progressShowValueCheckBox->setVisible(is_progress_bar); | |||||
| ui_.progressMinimumSpinBox->setEnabled(is_progress_bar); | |||||
| ui_.progressMaximumSpinBox->setEnabled(is_progress_bar); | |||||
| ui_.progressShowValueCheckBox->setEnabled(is_progress_bar); | |||||
| const HmiProgressBarConfig progress_config = control->progressBar.value_or( | |||||
| HmiProgressBarConfig{}); | |||||
| ui_.progressMinimumSpinBox->setValue(progress_config.minimumValue); | |||||
| ui_.progressMaximumSpinBox->setValue(progress_config.maximumValue); | |||||
| ui_.progressShowValueCheckBox->setChecked(progress_config.showValue); | |||||
| const auto text_color = control->properties.find( | |||||
| HmiAppearanceProperty::kTextColor); | |||||
| ui_.textColorEdit->setText(text_color == control->properties.cend() | |||||
| ? QString{} | |||||
| : fromUtf8(text_color->second)); | |||||
| const QColor swatch_color(ui_.textColorEdit->text()); | |||||
| ui_.textColorButton->setStyleSheet( | |||||
| swatch_color.isValid() | |||||
| ? QStringLiteral("background-color: %1;").arg( | |||||
| swatch_color.name(QColor::HexRgb)) | |||||
| : QString{}); | |||||
| const int default_font_size = defaultFontPointSize(control->type); | |||||
| int font_size = default_font_size; | |||||
| const auto font_size_property = control->properties.find( | |||||
| HmiAppearanceProperty::kFontSize); | |||||
| if (font_size_property != control->properties.cend()) | |||||
| { | |||||
| bool ok = false; | |||||
| const int parsed = fromUtf8(font_size_property->second).toInt(&ok); | |||||
| if (ok) | |||||
| { | |||||
| font_size = parsed; | |||||
| } | |||||
| } | |||||
| ui_.fontSizeSpinBox->setValue(std::clamp( | |||||
| font_size, | |||||
| ProjectLimits::kMinimumHmiFontPointSize, | |||||
| ProjectLimits::kMaximumHmiFontPointSize)); | |||||
| const auto font_bold = control->properties.find( | |||||
| HmiAppearanceProperty::kFontBold); | |||||
| ui_.fontBoldCheckBox->setChecked( | |||||
| font_bold != control->properties.cend() && font_bold->second == "true"); | |||||
| const auto font_italic = control->properties.find( | |||||
| HmiAppearanceProperty::kFontItalic); | |||||
| ui_.fontItalicCheckBox->setChecked( | |||||
| font_italic != control->properties.cend() | |||||
| && font_italic->second == "true"); | |||||
| } | |||||
| void PropertyPanelController::showPageProperties(const std::string &page_id) | |||||
| { | |||||
| ui_.propertyStack->setCurrentWidget(ui_.pagePropertiesPage); | |||||
| selected_control_id_.clear(); | |||||
| const HmiPage *page = hmi_editor_service_.findPage(page_id); | |||||
| const bool has_page = page != nullptr; | |||||
| ui_.pageWidthSpinBox->setRange( | |||||
| ProjectLimits::kMinimumHmiPageWidth, | |||||
| ProjectLimits::kMaximumHmiPageWidth); | |||||
| ui_.pageHeightSpinBox->setRange( | |||||
| ProjectLimits::kMinimumHmiPageHeight, | |||||
| ProjectLimits::kMaximumHmiPageHeight); | |||||
| ui_.selectionValueLabel->setText( | |||||
| has_page ? fromUtf8(page->name) : QObject::tr("未选择")); | |||||
| ui_.pageWidthSpinBox->setEnabled(has_page); | |||||
| ui_.pageHeightSpinBox->setEnabled(has_page); | |||||
| ui_.applyPagePropertiesButton->setEnabled(has_page); | |||||
| if (!has_page) | |||||
| { | |||||
| ui_.pageWidthSpinBox->setValue(ProjectLimits::kDefaultHmiPageWidth); | |||||
| ui_.pageHeightSpinBox->setValue(ProjectLimits::kDefaultHmiPageHeight); | |||||
| return; | |||||
| } | |||||
| ui_.pageWidthSpinBox->setValue(page->width); | |||||
| ui_.pageHeightSpinBox->setValue(page->height); | |||||
| } | |||||
| void PropertyPanelController::showLogicNodeProperties(const std::string &node_id) | |||||
| { | |||||
| ui_.propertyStack->setCurrentWidget(ui_.logicPropertiesPage); | |||||
| selected_logic_node_id_ = node_id; | |||||
| const LogicNode *node = logic_editor_service_.findNode( | |||||
| current_logic_id_(), node_id); | |||||
| const bool has_node = node != nullptr; | |||||
| ui_.selectionValueLabel->setText( | |||||
| has_node ? fromUtf8(node->id) : QObject::tr("未选择")); | |||||
| for (QWidget *widget : {static_cast<QWidget *>(ui_.logicNodeIdEdit), | |||||
| static_cast<QWidget *>(ui_.logicAddressSpinBox), | |||||
| static_cast<QWidget *>(ui_.logicModeComboBox), | |||||
| static_cast<QWidget *>(ui_.logicComparisonComboBox), | |||||
| static_cast<QWidget *>(ui_.logicValueSpinBox), | |||||
| static_cast<QWidget *>(ui_.logicPresetSpinBox), | |||||
| static_cast<QWidget *>(ui_.applyLogicPropertiesButton)}) | |||||
| { | |||||
| widget->setEnabled(has_node); | |||||
| } | |||||
| ui_.logicNodeTypeLabel->setEnabled(has_node); | |||||
| ui_.logicAddressAreaLabel->setEnabled(has_node); | |||||
| if (!has_node) | |||||
| { | |||||
| ui_.logicNodeIdEdit->clear(); | |||||
| ui_.logicNodeTypeLabel->clear(); | |||||
| ui_.logicAddressAreaLabel->clear(); | |||||
| ui_.logicModeComboBox->clear(); | |||||
| ui_.logicPresetSpinBox->setValue(TonNodeConfig::kMinimumPresetMs); | |||||
| return; | |||||
| } | |||||
| ui_.logicNodeIdEdit->setText(fromUtf8(node->id)); | |||||
| ui_.logicModeComboBox->clear(); | |||||
| ui_.logicComparisonComboBox->setEnabled(false); | |||||
| ui_.logicValueSpinBox->setEnabled(false); | |||||
| ui_.logicPresetSpinBox->setEnabled(false); | |||||
| if (const auto *contact = std::get_if<ContactNodeConfig>(&node->config)) | |||||
| { | |||||
| ui_.logicNodeTypeLabel->setText(QObject::tr("触点")); | |||||
| ui_.logicAddressAreaLabel->setText(QStringLiteral("M")); | |||||
| ui_.logicAddressSpinBox->setValue(contact->address.index()); | |||||
| ui_.logicModeComboBox->addItem( | |||||
| QObject::tr("常开"), static_cast<int>(ContactMode::NormallyOpen)); | |||||
| ui_.logicModeComboBox->addItem( | |||||
| QObject::tr("常闭"), static_cast<int>(ContactMode::NormallyClosed)); | |||||
| ui_.logicModeComboBox->setCurrentIndex( | |||||
| ui_.logicModeComboBox->findData(static_cast<int>(contact->mode))); | |||||
| ui_.logicModeComboBox->setEnabled(true); | |||||
| } | |||||
| else if (const auto *edge = std::get_if<EdgeContactNodeConfig>(&node->config)) | |||||
| { | |||||
| ui_.logicNodeTypeLabel->setText(QObject::tr("边沿触点")); | |||||
| ui_.logicAddressAreaLabel->setText(QStringLiteral("M")); | |||||
| ui_.logicAddressSpinBox->setValue(edge->address.index()); | |||||
| ui_.logicModeComboBox->addItem( | |||||
| QObject::tr("上升沿"), static_cast<int>(EdgeMode::Rising)); | |||||
| ui_.logicModeComboBox->addItem( | |||||
| QObject::tr("下降沿"), static_cast<int>(EdgeMode::Falling)); | |||||
| ui_.logicModeComboBox->setCurrentIndex( | |||||
| ui_.logicModeComboBox->findData(static_cast<int>(edge->mode))); | |||||
| ui_.logicModeComboBox->setEnabled(true); | |||||
| } | |||||
| else if (const auto *timer = std::get_if<TimerContactNodeConfig>(&node->config)) | |||||
| { | |||||
| ui_.logicNodeTypeLabel->setText(QObject::tr("T 触点")); | |||||
| ui_.logicAddressAreaLabel->setText(QStringLiteral("T")); | |||||
| ui_.logicAddressSpinBox->setValue(timer->address.index()); | |||||
| ui_.logicModeComboBox->addItem( | |||||
| QObject::tr("常开"), static_cast<int>(ContactMode::NormallyOpen)); | |||||
| ui_.logicModeComboBox->addItem( | |||||
| QObject::tr("常闭"), static_cast<int>(ContactMode::NormallyClosed)); | |||||
| ui_.logicModeComboBox->setCurrentIndex( | |||||
| ui_.logicModeComboBox->findData(static_cast<int>(timer->mode))); | |||||
| ui_.logicModeComboBox->setEnabled(true); | |||||
| } | |||||
| else if (const auto *counter_contact = | |||||
| std::get_if<CounterContactNodeConfig>(&node->config)) | |||||
| { | |||||
| ui_.logicNodeTypeLabel->setText(QObject::tr("C 触点")); | |||||
| ui_.logicAddressAreaLabel->setText(QStringLiteral("C")); | |||||
| ui_.logicAddressSpinBox->setValue(counter_contact->address.index()); | |||||
| ui_.logicModeComboBox->addItem( | |||||
| QObject::tr("常开"), static_cast<int>(ContactMode::NormallyOpen)); | |||||
| ui_.logicModeComboBox->addItem( | |||||
| QObject::tr("常闭"), static_cast<int>(ContactMode::NormallyClosed)); | |||||
| ui_.logicModeComboBox->setCurrentIndex(ui_.logicModeComboBox->findData( | |||||
| static_cast<int>(counter_contact->mode))); | |||||
| ui_.logicModeComboBox->setEnabled(true); | |||||
| } | |||||
| else if (const auto *coil = std::get_if<CoilNodeConfig>(&node->config)) | |||||
| { | |||||
| ui_.logicNodeTypeLabel->setText(QObject::tr("线圈")); | |||||
| ui_.logicAddressAreaLabel->setText(QStringLiteral("M")); | |||||
| ui_.logicAddressSpinBox->setValue(coil->address.index()); | |||||
| ui_.logicModeComboBox->addItem( | |||||
| QObject::tr("普通"), static_cast<int>(CoilMode::Normal)); | |||||
| ui_.logicModeComboBox->addItem( | |||||
| QObject::tr("置位"), static_cast<int>(CoilMode::Set)); | |||||
| ui_.logicModeComboBox->addItem( | |||||
| QObject::tr("复位"), static_cast<int>(CoilMode::Reset)); | |||||
| ui_.logicModeComboBox->setCurrentIndex( | |||||
| ui_.logicModeComboBox->findData(static_cast<int>(coil->mode))); | |||||
| ui_.logicModeComboBox->setEnabled(true); | |||||
| } | |||||
| else if (const auto *compare = std::get_if<CompareNodeConfig>(&node->config)) | |||||
| { | |||||
| ui_.logicNodeTypeLabel->setText(QObject::tr("D 值比较")); | |||||
| ui_.logicAddressAreaLabel->setText(QStringLiteral("D")); | |||||
| ui_.logicAddressSpinBox->setValue(compare->address.index()); | |||||
| ui_.logicModeComboBox->addItem(QObject::tr("不适用")); | |||||
| ui_.logicModeComboBox->setEnabled(false); | |||||
| ui_.logicComparisonComboBox->setEnabled(true); | |||||
| ui_.logicComparisonComboBox->setCurrentIndex( | |||||
| ui_.logicComparisonComboBox->findData( | |||||
| static_cast<int>(compare->comparison))); | |||||
| ui_.logicValueSpinBox->setEnabled(true); | |||||
| ui_.logicValueSpinBox->setValue(compare->value); | |||||
| } | |||||
| else if (const auto *ton = std::get_if<TonNodeConfig>(&node->config)) | |||||
| { | |||||
| ui_.logicNodeTypeLabel->setText(QObject::tr("TON")); | |||||
| ui_.logicAddressAreaLabel->setText(QStringLiteral("T")); | |||||
| ui_.logicAddressSpinBox->setValue(ton->address.index()); | |||||
| ui_.logicModeComboBox->addItem(QObject::tr("非保持接通延时")); | |||||
| ui_.logicModeComboBox->setEnabled(false); | |||||
| ui_.logicPresetSpinBox->setEnabled(true); | |||||
| ui_.logicPresetSpinBox->setValue(ton->presetMs); | |||||
| } | |||||
| else if (const auto *counter = std::get_if<CounterNodeConfig>(&node->config)) | |||||
| { | |||||
| ui_.logicNodeTypeLabel->setText( | |||||
| counter->mode == CounterMode::Up ? QStringLiteral("CTU") | |||||
| : QStringLiteral("CTD")); | |||||
| ui_.logicAddressAreaLabel->setText(QStringLiteral("C")); | |||||
| ui_.logicAddressSpinBox->setValue(counter->address.index()); | |||||
| ui_.logicAddressSpinBox->setEnabled(false); | |||||
| ui_.logicModeComboBox->addItem(QObject::tr("使用配置对话框编辑")); | |||||
| ui_.logicModeComboBox->setEnabled(false); | |||||
| } | |||||
| else if (const auto *move = std::get_if<MoveNodeConfig>(&node->config)) | |||||
| { | |||||
| ui_.logicNodeTypeLabel->setText(QStringLiteral("MOVE")); | |||||
| ui_.logicAddressAreaLabel->setText(QStringLiteral("D")); | |||||
| ui_.logicAddressSpinBox->setValue(move->destination.index()); | |||||
| ui_.logicAddressSpinBox->setEnabled(false); | |||||
| ui_.logicModeComboBox->addItem(QObject::tr("使用配置对话框编辑")); | |||||
| ui_.logicModeComboBox->setEnabled(false); | |||||
| } | |||||
| else | |||||
| { | |||||
| const auto &arithmetic = std::get<ArithmeticNodeConfig>(node->config); | |||||
| ui_.logicNodeTypeLabel->setText( | |||||
| arithmetic.operation == ArithmeticOperation::Add | |||||
| ? QStringLiteral("ADD") : QStringLiteral("SUB")); | |||||
| ui_.logicAddressAreaLabel->setText(QStringLiteral("D")); | |||||
| ui_.logicAddressSpinBox->setValue(arithmetic.destination.index()); | |||||
| ui_.logicAddressSpinBox->setEnabled(false); | |||||
| ui_.logicModeComboBox->addItem(QObject::tr("使用配置对话框编辑")); | |||||
| ui_.logicModeComboBox->setEnabled(false); | |||||
| } | |||||
| if (!node->configured) | |||||
| { | |||||
| ui_.logicNodeTypeLabel->setText(QObject::tr("待配置:%1").arg( | |||||
| ui_.logicNodeTypeLabel->text())); | |||||
| } | |||||
| } | |||||
| void PropertyPanelController::addHmiControl(HmiControlType type) | |||||
| { | |||||
| const HmiEditorResult result = hmi_editor_service_.addControl( | |||||
| current_page_id_(), type); | |||||
| if (!result.succeeded) | |||||
| { | |||||
| reportFailure(QObject::tr("添加控件"), result.message); | |||||
| return; | |||||
| } | |||||
| hmi_editor_widget_->reloadPage(); | |||||
| hmi_editor_widget_->selectControl(result.id); | |||||
| showControlProperties(result.id); | |||||
| refresh_project_ui_(); | |||||
| const HmiControlDescriptor *descriptor = findHmiControlDescriptor(type); | |||||
| const QString display_name = descriptor == nullptr | |||||
| ? QObject::tr("未知控件") | |||||
| : QString::fromUtf8(descriptor->displayName); | |||||
| status_reporter_(QObject::tr("已添加%1").arg(display_name), 3000); | |||||
| } | |||||
| void PropertyPanelController::deleteSelectedControl() | |||||
| { | |||||
| if (hmi_editor_widget_ == nullptr) | |||||
| { | |||||
| return; | |||||
| } | |||||
| const std::vector<std::string> selected_ids = | |||||
| hmi_editor_widget_->selectedControlIds(); | |||||
| if (selected_ids.empty()) | |||||
| { | |||||
| return; | |||||
| } | |||||
| const HmiEditorResult result = hmi_editor_service_.removeControls( | |||||
| current_page_id_(), selected_ids); | |||||
| if (!result.succeeded) | |||||
| { | |||||
| reportFailure(QObject::tr("删除控件"), result.message); | |||||
| return; | |||||
| } | |||||
| selected_control_id_.clear(); | |||||
| hmi_editor_widget_->reloadPage(); | |||||
| showControlProperties({}); | |||||
| refresh_project_ui_(); | |||||
| } | |||||
| void PropertyPanelController::applySelectedControlProperties() | |||||
| { | |||||
| const HmiControl *old_control = hmi_editor_service_.findControl( | |||||
| current_page_id_(), selected_control_id_); | |||||
| if (old_control == nullptr) | |||||
| { | |||||
| return; | |||||
| } | |||||
| HmiControl control = *old_control; | |||||
| control.id = toUtf8(ui_.controlIdEdit->text()); | |||||
| control.text = toUtf8(ui_.controlTextEdit->text()); | |||||
| control.bounds.x = ui_.controlXSpinBox->value(); | |||||
| control.bounds.y = ui_.controlYSpinBox->value(); | |||||
| control.bounds.width = ui_.controlWidthSpinBox->value(); | |||||
| control.bounds.height = ui_.controlHeightSpinBox->value(); | |||||
| const HmiControlDescriptor *descriptor = | |||||
| findHmiControlDescriptor(control.type); | |||||
| if (descriptor == nullptr | |||||
| || descriptor->bindingKind == HmiBindingKind::None) | |||||
| { | |||||
| control.binding.reset(); | |||||
| } | |||||
| else | |||||
| { | |||||
| const int binding_area = ui_.bindingAreaComboBox->currentData().toInt(); | |||||
| if (binding_area < 0) | |||||
| { | |||||
| control.binding.reset(); | |||||
| } | |||||
| else | |||||
| { | |||||
| const RegisterArea area = binding_area == 0 | |||||
| ? RegisterArea::M : RegisterArea::D; | |||||
| control.binding = RegisterAddress{ | |||||
| area, ui_.bindingIndexSpinBox->value()}; | |||||
| } | |||||
| } | |||||
| if (control.type == HmiControlType::Button) | |||||
| { | |||||
| control.buttonOperation = static_cast<HmiButtonOperation>( | |||||
| ui_.buttonOperationComboBox->currentData().toInt()); | |||||
| } | |||||
| if (control.type == HmiControlType::PageJump) | |||||
| { | |||||
| control.pageJump = HmiPageJumpConfig{ | |||||
| toUtf8(ui_.targetPageComboBox->currentData().toString())}; | |||||
| } | |||||
| if (control.type == HmiControlType::ProgressBar) | |||||
| { | |||||
| control.progressBar = HmiProgressBarConfig{ | |||||
| static_cast<std::int16_t>(ui_.progressMinimumSpinBox->value()), | |||||
| static_cast<std::int16_t>(ui_.progressMaximumSpinBox->value()), | |||||
| ui_.progressShowValueCheckBox->isChecked()}; | |||||
| } | |||||
| const std::string text_color = toUtf8(ui_.textColorEdit->text()); | |||||
| if (text_color.empty()) | |||||
| { | |||||
| control.properties.erase(HmiAppearanceProperty::kTextColor); | |||||
| } | |||||
| else | |||||
| { | |||||
| control.properties[HmiAppearanceProperty::kTextColor] = text_color; | |||||
| } | |||||
| control.properties[HmiAppearanceProperty::kFontSize] = std::to_string( | |||||
| ui_.fontSizeSpinBox->value()); | |||||
| control.properties[HmiAppearanceProperty::kFontBold] = | |||||
| ui_.fontBoldCheckBox->isChecked() ? "true" : "false"; | |||||
| control.properties[HmiAppearanceProperty::kFontItalic] = | |||||
| ui_.fontItalicCheckBox->isChecked() ? "true" : "false"; | |||||
| const HmiEditorResult result = hmi_editor_service_.updateControl( | |||||
| current_page_id_(), selected_control_id_, control); | |||||
| if (!result.succeeded) | |||||
| { | |||||
| reportFailure(QObject::tr("应用属性"), result.message); | |||||
| return; | |||||
| } | |||||
| const std::string updated_control_id = result.id; | |||||
| hmi_editor_widget_->reloadPage(); | |||||
| selected_control_id_ = updated_control_id; | |||||
| hmi_editor_widget_->selectControl(updated_control_id); | |||||
| showControlProperties(updated_control_id); | |||||
| refresh_project_ui_(); | |||||
| status_reporter_(QObject::tr("控件属性已更新"), 3000); | |||||
| } | |||||
| void PropertyPanelController::applySelectedPageProperties() | |||||
| { | |||||
| const std::string page_id = current_page_id_(); | |||||
| if (hmi_editor_service_.findPage(page_id) == nullptr) | |||||
| { | |||||
| return; | |||||
| } | |||||
| const HmiEditorResult result = hmi_editor_service_.resizePage( | |||||
| page_id, | |||||
| ui_.pageWidthSpinBox->value(), | |||||
| ui_.pageHeightSpinBox->value()); | |||||
| if (!result.succeeded) | |||||
| { | |||||
| reportFailure(QObject::tr("应用页面尺寸"), result.message); | |||||
| return; | |||||
| } | |||||
| if (hmi_editor_widget_ != nullptr) | |||||
| { | |||||
| hmi_editor_widget_->reloadPage(); | |||||
| } | |||||
| showPageProperties(page_id); | |||||
| refresh_project_ui_(); | |||||
| status_reporter_(QObject::tr("页面尺寸已更新"), 3000); | |||||
| } | |||||
| void PropertyPanelController::chooseTextColor() | |||||
| { | |||||
| QColor initial_color(ui_.textColorEdit->text()); | |||||
| if (!initial_color.isValid()) | |||||
| { | |||||
| initial_color = QColor(QStringLiteral("#24313b")); | |||||
| } | |||||
| const QColor color = QColorDialog::getColor( | |||||
| initial_color, &parent_, QObject::tr("选择字体颜色")); | |||||
| if (color.isValid()) | |||||
| { | |||||
| ui_.textColorEdit->setText(color.name(QColor::HexRgb)); | |||||
| ui_.textColorButton->setStyleSheet( | |||||
| QStringLiteral("background-color: %1;").arg( | |||||
| color.name(QColor::HexRgb))); | |||||
| } | |||||
| } | |||||
| void PropertyPanelController::applySelectedLogicNodeProperties() | |||||
| { | |||||
| const LogicNode *node = logic_editor_service_.findNode( | |||||
| current_logic_id_(), selected_logic_node_id_); | |||||
| if (node == nullptr) | |||||
| { | |||||
| return; | |||||
| } | |||||
| LogicNodeConfig config = node->config; | |||||
| if (std::holds_alternative<ContactNodeConfig>(config)) | |||||
| { | |||||
| config = ContactNodeConfig{ | |||||
| RegisterAddress{RegisterArea::M, ui_.logicAddressSpinBox->value()}, | |||||
| static_cast<ContactMode>(ui_.logicModeComboBox->currentData().toInt())}; | |||||
| } | |||||
| else if (std::holds_alternative<EdgeContactNodeConfig>(config)) | |||||
| { | |||||
| config = EdgeContactNodeConfig{ | |||||
| RegisterAddress{RegisterArea::M, ui_.logicAddressSpinBox->value()}, | |||||
| static_cast<EdgeMode>(ui_.logicModeComboBox->currentData().toInt())}; | |||||
| } | |||||
| else if (std::holds_alternative<TimerContactNodeConfig>(config)) | |||||
| { | |||||
| config = TimerContactNodeConfig{ | |||||
| TimerAddress{ui_.logicAddressSpinBox->value()}, | |||||
| static_cast<ContactMode>(ui_.logicModeComboBox->currentData().toInt())}; | |||||
| } | |||||
| else if (std::holds_alternative<CounterContactNodeConfig>(config)) | |||||
| { | |||||
| config = CounterContactNodeConfig{ | |||||
| CounterAddress{ui_.logicAddressSpinBox->value()}, | |||||
| static_cast<ContactMode>(ui_.logicModeComboBox->currentData().toInt())}; | |||||
| } | |||||
| else if (std::holds_alternative<CoilNodeConfig>(config)) | |||||
| { | |||||
| config = CoilNodeConfig{ | |||||
| RegisterAddress{RegisterArea::M, ui_.logicAddressSpinBox->value()}, | |||||
| static_cast<CoilMode>(ui_.logicModeComboBox->currentData().toInt())}; | |||||
| } | |||||
| else if (std::holds_alternative<CompareNodeConfig>(config)) | |||||
| { | |||||
| config = CompareNodeConfig{ | |||||
| RegisterAddress{RegisterArea::D, ui_.logicAddressSpinBox->value()}, | |||||
| static_cast<ComparisonOperator>( | |||||
| ui_.logicComparisonComboBox->currentData().toInt()), | |||||
| static_cast<std::int16_t>(ui_.logicValueSpinBox->value())}; | |||||
| } | |||||
| else if (std::holds_alternative<TonNodeConfig>(config)) | |||||
| { | |||||
| config = TonNodeConfig{ | |||||
| TimerAddress{ui_.logicAddressSpinBox->value()}, | |||||
| ui_.logicPresetSpinBox->value()}; | |||||
| } | |||||
| else | |||||
| { | |||||
| LogicInstructionDialog dialog(node->config, &parent_); | |||||
| if (dialog.exec() != QDialog::Accepted) | |||||
| { | |||||
| return; | |||||
| } | |||||
| config = dialog.config(); | |||||
| } | |||||
| const LogicEditorResult result = logic_editor_service_.updateNodeConfig( | |||||
| current_logic_id_(), selected_logic_node_id_, config); | |||||
| if (!result.succeeded) | |||||
| { | |||||
| reportFailure(QObject::tr("应用逻辑属性"), result.message); | |||||
| return; | |||||
| } | |||||
| logic_editor_widget_->reloadLogic(); | |||||
| logic_editor_widget_->selectNode(selected_logic_node_id_); | |||||
| showLogicNodeProperties(selected_logic_node_id_); | |||||
| refresh_project_ui_(); | |||||
| status_reporter_(QObject::tr("逻辑节点属性已更新,节点已配置"), 3000); | |||||
| } | |||||
| void PropertyPanelController::handleHmiEditorError(const QString &message) const | |||||
| { | |||||
| status_reporter_(message, 5000); | |||||
| } | |||||
| void PropertyPanelController::handleLogicEditorError(const QString &message) const | |||||
| { | |||||
| status_reporter_(message, 5000); | |||||
| } | |||||
| void PropertyPanelController::reportFailure( | |||||
| const QString &action, const std::string &message) const | |||||
| { | |||||
| result_reporter_(action, fromUtf8(message), false); | |||||
| } | |||||
| @@ -0,0 +1,80 @@ | |||||
| #pragma once | |||||
| #include "domain/control_logic_model.h" | |||||
| #include "domain/hmi_model.h" | |||||
| #include <QString> | |||||
| #include <functional> | |||||
| #include <string> | |||||
| class HmiEditorService; | |||||
| class HmiEditorWidget; | |||||
| class LogicEditorService; | |||||
| class LogicEditorWidget; | |||||
| class ProjectService; | |||||
| class QWidget; | |||||
| namespace Ui { | |||||
| class MainWindow; | |||||
| } | |||||
| // 组织 HMI/梯形图选择状态、属性面板和编辑器信号 | |||||
| class PropertyPanelController final | |||||
| { | |||||
| public: | |||||
| using ResultReporter = std::function<void( | |||||
| const QString &action, const QString &message, bool succeeded)>; | |||||
| using StatusReporter = std::function<void( | |||||
| const QString &message, int timeout_ms)>; | |||||
| PropertyPanelController( | |||||
| QWidget &parent, | |||||
| Ui::MainWindow &ui, | |||||
| ProjectService &project_service, | |||||
| HmiEditorService &hmi_editor_service, | |||||
| LogicEditorService &logic_editor_service, | |||||
| std::function<std::string()> current_page_id, | |||||
| std::function<std::string()> current_logic_id, | |||||
| std::string &selected_control_id, | |||||
| std::string &selected_logic_node_id, | |||||
| std::function<void()> refresh_project_ui, | |||||
| ResultReporter result_reporter, | |||||
| StatusReporter status_reporter); | |||||
| void configure(); | |||||
| void bindEditorWidgets( | |||||
| HmiEditorWidget &hmi_editor_widget, | |||||
| LogicEditorWidget &logic_editor_widget); | |||||
| void showControlProperties(const std::string &control_id); | |||||
| void showPageProperties(const std::string &page_id); | |||||
| void showLogicNodeProperties(const std::string &node_id); | |||||
| void addHmiControl(HmiControlType type); | |||||
| void deleteSelectedControl(); | |||||
| void applySelectedControlProperties(); | |||||
| void applySelectedPageProperties(); | |||||
| void applySelectedLogicNodeProperties(); | |||||
| private: | |||||
| void handleHmiEditorError(const QString &message) const; | |||||
| void handleLogicEditorError(const QString &message) const; | |||||
| void chooseTextColor(); | |||||
| void reportFailure( | |||||
| const QString &action, const std::string &message) const; | |||||
| QWidget &parent_; | |||||
| Ui::MainWindow &ui_; | |||||
| ProjectService &project_service_; | |||||
| HmiEditorService &hmi_editor_service_; | |||||
| LogicEditorService &logic_editor_service_; | |||||
| std::function<std::string()> current_page_id_; | |||||
| std::function<std::string()> current_logic_id_; | |||||
| std::string &selected_control_id_; | |||||
| std::string &selected_logic_node_id_; | |||||
| std::function<void()> refresh_project_ui_; | |||||
| ResultReporter result_reporter_; | |||||
| StatusReporter status_reporter_; | |||||
| HmiEditorWidget *hmi_editor_widget_ = nullptr; | |||||
| LogicEditorWidget *logic_editor_widget_ = nullptr; | |||||
| }; | |||||
| @@ -0,0 +1,155 @@ | |||||
| #include "register_comment_dialog.h" | |||||
| #include "services/register_comment_service.h" | |||||
| #include "ui_register_comment_dialog.h" | |||||
| #include <QHeaderView> | |||||
| #include <QMessageBox> | |||||
| #include <QTableWidgetItem> | |||||
| #include <optional> | |||||
| namespace { | |||||
| QString fromUtf8(const std::string &value) | |||||
| { | |||||
| return QString::fromUtf8(value.data(), static_cast<int>(value.size())); | |||||
| } | |||||
| std::string toUtf8(const QString &value) | |||||
| { | |||||
| const QByteArray bytes = value.toUtf8(); | |||||
| return std::string(bytes.constData(), static_cast<std::size_t>(bytes.size())); | |||||
| } | |||||
| } // namespace | |||||
| RegisterCommentDialog::RegisterCommentDialog( | |||||
| RegisterCommentService &service, | |||||
| QWidget *parent) | |||||
| : QDialog(parent), | |||||
| ui_(std::make_unique<Ui::RegisterCommentDialog>()), | |||||
| service_(service) | |||||
| { | |||||
| ui_->setupUi(this); | |||||
| ui_->commentTable->horizontalHeader()->setSectionResizeMode( | |||||
| 0, QHeaderView::ResizeToContents); | |||||
| ui_->commentTable->horizontalHeader()->setSectionResizeMode( | |||||
| 1, QHeaderView::Stretch); | |||||
| ui_->areaComboBox->setItemData(0, static_cast<int>(RegisterArea::M)); | |||||
| ui_->areaComboBox->setItemData(1, static_cast<int>(RegisterArea::D)); | |||||
| connect(ui_->commentTable, &QTableWidget::itemSelectionChanged, | |||||
| this, [this] { loadSelectedComment(); }); | |||||
| connect(ui_->saveButton, &QPushButton::clicked, | |||||
| this, [this] { saveComment(); }); | |||||
| connect(ui_->removeButton, &QPushButton::clicked, | |||||
| this, [this] { removeComment(); }); | |||||
| connect(ui_->buttonBox, &QDialogButtonBox::rejected, | |||||
| this, &QDialog::reject); | |||||
| reloadComments(); | |||||
| } | |||||
| RegisterCommentDialog::~RegisterCommentDialog() = default; | |||||
| void RegisterCommentDialog::reloadComments( | |||||
| const RegisterAddress *selected_address) | |||||
| { | |||||
| const auto &comments = service_.comments(); | |||||
| ui_->commentTable->setRowCount(static_cast<int>(comments.size())); | |||||
| int selected_row = -1; | |||||
| for (std::size_t index = 0; index < comments.size(); ++index) | |||||
| { | |||||
| const RegisterComment &comment = comments[index]; | |||||
| const int row = static_cast<int>(index); | |||||
| auto *address_item = new QTableWidgetItem( | |||||
| QString::fromStdString(comment.address.toString())); | |||||
| address_item->setData(Qt::UserRole, static_cast<int>(comment.address.area())); | |||||
| address_item->setData(Qt::UserRole + 1, comment.address.index()); | |||||
| ui_->commentTable->setItem(row, 0, address_item); | |||||
| ui_->commentTable->setItem(row, 1, new QTableWidgetItem(fromUtf8(comment.text))); | |||||
| if (selected_address != nullptr && comment.address == *selected_address) | |||||
| { | |||||
| selected_row = row; | |||||
| } | |||||
| } | |||||
| if (selected_row >= 0) | |||||
| { | |||||
| ui_->commentTable->selectRow(selected_row); | |||||
| } | |||||
| else | |||||
| { | |||||
| ui_->commentTable->clearSelection(); | |||||
| ui_->removeButton->setEnabled(false); | |||||
| } | |||||
| } | |||||
| void RegisterCommentDialog::loadSelectedComment() | |||||
| { | |||||
| const std::optional<RegisterAddress> address = selectedAddress(); | |||||
| const RegisterComment *comment = address.has_value() | |||||
| ? service_.findComment(*address) : nullptr; | |||||
| ui_->removeButton->setEnabled(comment != nullptr); | |||||
| if (comment == nullptr) | |||||
| { | |||||
| return; | |||||
| } | |||||
| ui_->areaComboBox->setCurrentIndex( | |||||
| comment->address.area() == RegisterArea::M ? 0 : 1); | |||||
| ui_->indexSpinBox->setValue(comment->address.index()); | |||||
| ui_->commentEdit->setText(fromUtf8(comment->text)); | |||||
| } | |||||
| void RegisterCommentDialog::saveComment() | |||||
| { | |||||
| const RegisterAddress address = addressFromInputs(); | |||||
| const RegisterCommentResult result = service_.setComment( | |||||
| address, toUtf8(ui_->commentEdit->text())); | |||||
| if (!result.succeeded) | |||||
| { | |||||
| QMessageBox::warning(this, tr("保存注释"), fromUtf8(result.message)); | |||||
| return; | |||||
| } | |||||
| reloadComments(&address); | |||||
| } | |||||
| void RegisterCommentDialog::removeComment() | |||||
| { | |||||
| const std::optional<RegisterAddress> address = selectedAddress(); | |||||
| if (!address.has_value()) | |||||
| { | |||||
| return; | |||||
| } | |||||
| if (QMessageBox::question( | |||||
| this, tr("删除注释"), tr("确定删除当前软元件注释吗?")) | |||||
| != QMessageBox::Yes) | |||||
| { | |||||
| return; | |||||
| } | |||||
| const RegisterCommentResult result = service_.removeComment(*address); | |||||
| if (!result.succeeded) | |||||
| { | |||||
| QMessageBox::warning(this, tr("删除注释"), fromUtf8(result.message)); | |||||
| return; | |||||
| } | |||||
| reloadComments(); | |||||
| } | |||||
| RegisterAddress RegisterCommentDialog::addressFromInputs() const | |||||
| { | |||||
| const RegisterArea area = static_cast<RegisterArea>( | |||||
| ui_->areaComboBox->currentData().toInt()); | |||||
| return RegisterAddress{area, ui_->indexSpinBox->value()}; | |||||
| } | |||||
| std::optional<RegisterAddress> RegisterCommentDialog::selectedAddress() const | |||||
| { | |||||
| const int row = ui_->commentTable->currentRow(); | |||||
| const QTableWidgetItem *item = row >= 0 ? ui_->commentTable->item(row, 0) : nullptr; | |||||
| if (item == nullptr) | |||||
| { | |||||
| return std::nullopt; | |||||
| } | |||||
| return RegisterAddress{ | |||||
| static_cast<RegisterArea>(item->data(Qt::UserRole).toInt()), | |||||
| item->data(Qt::UserRole + 1).toInt()}; | |||||
| } | |||||
| @@ -0,0 +1,34 @@ | |||||
| #pragma once | |||||
| #include "domain/register_address.h" | |||||
| #include <QDialog> | |||||
| #include <memory> | |||||
| #include <optional> | |||||
| namespace Ui { | |||||
| class RegisterCommentDialog; | |||||
| } | |||||
| class RegisterCommentService; | |||||
| class RegisterCommentDialog final : public QDialog | |||||
| { | |||||
| public: | |||||
| explicit RegisterCommentDialog( | |||||
| RegisterCommentService &service, | |||||
| QWidget *parent = nullptr); | |||||
| ~RegisterCommentDialog() override; | |||||
| private: | |||||
| void reloadComments(const RegisterAddress *selected_address = nullptr); | |||||
| void loadSelectedComment(); | |||||
| void saveComment(); | |||||
| void removeComment(); | |||||
| RegisterAddress addressFromInputs() const; | |||||
| std::optional<RegisterAddress> selectedAddress() const; | |||||
| std::unique_ptr<Ui::RegisterCommentDialog> ui_; | |||||
| RegisterCommentService &service_; | |||||
| }; | |||||
| @@ -0,0 +1,40 @@ | |||||
| <?xml version="1.0" encoding="UTF-8"?> | |||||
| <ui version="4.0"> | |||||
| <class>RegisterCommentDialog</class> | |||||
| <widget class="QDialog" name="RegisterCommentDialog"> | |||||
| <property name="windowTitle"><string>软元件注释</string></property> | |||||
| <property name="minimumSize"><size><width>620</width><height>400</height></size></property> | |||||
| <layout class="QVBoxLayout" name="verticalLayout"> | |||||
| <item> | |||||
| <widget class="QTableWidget" name="commentTable"> | |||||
| <property name="selectionBehavior"><enum>QAbstractItemView::SelectRows</enum></property> | |||||
| <property name="selectionMode"><enum>QAbstractItemView::SingleSelection</enum></property> | |||||
| <property name="editTriggers"><set>QAbstractItemView::NoEditTriggers</set></property> | |||||
| <property name="columnCount"><number>2</number></property> | |||||
| <column><property name="text"><string>地址</string></property></column> | |||||
| <column><property name="text"><string>注释</string></property></column> | |||||
| </widget> | |||||
| </item> | |||||
| <item> | |||||
| <layout class="QFormLayout" name="commentForm"> | |||||
| <item row="0" column="0"><widget class="QLabel" name="areaLabel"><property name="text"><string>地址区域</string></property></widget></item> | |||||
| <item row="0" column="1"><widget class="QComboBox" name="areaComboBox"><item><property name="text"><string>M</string></property></item><item><property name="text"><string>D</string></property></item></widget></item> | |||||
| <item row="1" column="0"><widget class="QLabel" name="indexLabel"><property name="text"><string>地址</string></property></widget></item> | |||||
| <item row="1" column="1"><widget class="QSpinBox" name="indexSpinBox"><property name="maximum"><number>4000</number></property></widget></item> | |||||
| <item row="2" column="0"><widget class="QLabel" name="commentLabel"><property name="text"><string>注释</string></property></widget></item> | |||||
| <item row="2" column="1"><widget class="QLineEdit" name="commentEdit"><property name="placeholderText"><string>例如:启动按钮</string></property></widget></item> | |||||
| </layout> | |||||
| </item> | |||||
| <item> | |||||
| <layout class="QHBoxLayout" name="operationLayout"> | |||||
| <item><widget class="QPushButton" name="saveButton"><property name="text"><string>保存</string></property></widget></item> | |||||
| <item><widget class="QPushButton" name="removeButton"><property name="text"><string>删除</string></property></widget></item> | |||||
| <item><spacer name="operationSpacer"><property name="orientation"><enum>Qt::Horizontal</enum></property><property name="sizeHint" stdset="0"><size><width>40</width><height>20</height></size></property></spacer></item> | |||||
| <item><widget class="QDialogButtonBox" name="buttonBox"><property name="standardButtons"><set>QDialogButtonBox::Close</set></property></widget></item> | |||||
| </layout> | |||||
| </item> | |||||
| </layout> | |||||
| </widget> | |||||
| <resources/> | |||||
| <connections/> | |||||
| </ui> | |||||
| @@ -4,12 +4,15 @@ | |||||
| #include "hmi_editor_widget.h" | #include "hmi_editor_widget.h" | ||||
| #include "logic_editor_widget.h" | #include "logic_editor_widget.h" | ||||
| #include "services/hmi_navigation_service.h" | #include "services/hmi_navigation_service.h" | ||||
| #include "services/alarm_service.h" | |||||
| #include "services/project_service.h" | #include "services/project_service.h" | ||||
| #include "toolbar_icon_factory.h" | |||||
| #include "ui_runtime_monitor_widget.h" | #include "ui_runtime_monitor_widget.h" | ||||
| #include <QSplitter> | #include <QSplitter> | ||||
| #include <QComboBox> | #include <QComboBox> | ||||
| #include <QSignalBlocker> | #include <QSignalBlocker> | ||||
| #include <QToolButton> | |||||
| #include <algorithm> | #include <algorithm> | ||||
| @@ -34,6 +37,7 @@ RuntimeMonitorWidget::RuntimeMonitorWidget( | |||||
| LogicEditorService &logic_editor_service, | LogicEditorService &logic_editor_service, | ||||
| ProjectService &project_service, | ProjectService &project_service, | ||||
| HmiNavigationService &hmi_navigation_service, | HmiNavigationService &hmi_navigation_service, | ||||
| AlarmService &alarm_service, | |||||
| RegisterMonitorService ®ister_monitor_service, | RegisterMonitorService ®ister_monitor_service, | ||||
| QWidget *parent) | QWidget *parent) | ||||
| : QWidget(parent), | : QWidget(parent), | ||||
| @@ -42,8 +46,12 @@ RuntimeMonitorWidget::RuntimeMonitorWidget( | |||||
| hmi_navigation_service_(hmi_navigation_service) | hmi_navigation_service_(hmi_navigation_service) | ||||
| { | { | ||||
| ui_->setupUi(this); | ui_->setupUi(this); | ||||
| ui_->exitRuntimeButton->setIcon(makeUiIcon(UiIcon::Exit)); | |||||
| hmi_view_ = new HmiEditorWidget( | hmi_view_ = new HmiEditorWidget( | ||||
| hmi_editor_service, hmi_runtime_service, ui_->hmiViewContainer); | |||||
| hmi_editor_service, | |||||
| hmi_runtime_service, | |||||
| alarm_service, | |||||
| ui_->hmiViewContainer); | |||||
| hmi_view_->setObjectName(QStringLiteral("runtimeHmiView")); | hmi_view_->setObjectName(QStringLiteral("runtimeHmiView")); | ||||
| hmi_view_->setEditingEnabled(false); | hmi_view_->setEditingEnabled(false); | ||||
| hmi_view_->setRuntimeActive(true); | hmi_view_->setRuntimeActive(true); | ||||
| @@ -72,8 +80,9 @@ RuntimeMonitorWidget::RuntimeMonitorWidget( | |||||
| return; | return; | ||||
| } | } | ||||
| showRuntimePage(result.pageId); | showRuntimePage(result.pageId); | ||||
| emit runtimePageChanged(fromUtf8(result.pageId)); | |||||
| }); | }); | ||||
| connect(ui_->exitRuntimeButton, &QToolButton::clicked, | |||||
| this, &RuntimeMonitorWidget::exitRequested); | |||||
| connect(ui_->runtimeLogicComboBox, | connect(ui_->runtimeLogicComboBox, | ||||
| QOverload<int>::of(&QComboBox::currentIndexChanged), | QOverload<int>::of(&QComboBox::currentIndexChanged), | ||||
| this, | this, | ||||
| @@ -92,7 +101,7 @@ RuntimeMonitorWidget::~RuntimeMonitorWidget() = default; | |||||
| void RuntimeMonitorWidget::setProjectObjects( | void RuntimeMonitorWidget::setProjectObjects( | ||||
| const std::string &page_id, const std::string &logic_id) | const std::string &page_id, const std::string &logic_id) | ||||
| { | { | ||||
| showRuntimePage(page_id); | |||||
| setRuntimePage(page_id); | |||||
| const QSignalBlocker blocker(ui_->runtimeLogicComboBox); | const QSignalBlocker blocker(ui_->runtimeLogicComboBox); | ||||
| ui_->runtimeLogicComboBox->clear(); | ui_->runtimeLogicComboBox->clear(); | ||||
| @@ -115,6 +124,11 @@ void RuntimeMonitorWidget::setProjectObjects( | |||||
| : std::string{}); | : std::string{}); | ||||
| } | } | ||||
| void RuntimeMonitorWidget::setRuntimePage(const std::string &page_id) | |||||
| { | |||||
| showRuntimePage(page_id); | |||||
| } | |||||
| void RuntimeMonitorWidget::setMode( | void RuntimeMonitorWidget::setMode( | ||||
| ApplicationMode mode, PlcConnectionState plc_state) | ApplicationMode mode, PlcConnectionState plc_state) | ||||
| { | { | ||||
| @@ -122,8 +136,12 @@ void RuntimeMonitorWidget::setMode( | |||||
| const bool online = mode == ApplicationMode::OnlineRunning; | const bool online = mode == ApplicationMode::OnlineRunning; | ||||
| ui_->logicFrame->setVisible(offline); | ui_->logicFrame->setVisible(offline); | ||||
| hmi_view_->setRuntimeActive(offline || online); | hmi_view_->setRuntimeActive(offline || online); | ||||
| hmi_view_->setRuntimeWriteEnabled( | |||||
| offline || (online && plc_state == PlcConnectionState::Connected)); | |||||
| if (!offline && !online) | |||||
| { | |||||
| setHmiWriteEnabled(false); | |||||
| } | |||||
| setFreeMonitorWriteEnabled(offline | |||||
| || (online && plc_state == PlcConnectionState::Connected)); | |||||
| if (!offline) | if (!offline) | ||||
| { | { | ||||
| logic_view_->clearRuntimeTrace(); | logic_view_->clearRuntimeTrace(); | ||||
| @@ -142,6 +160,11 @@ void RuntimeMonitorWidget::setHmiWriteEnabled(bool enabled) | |||||
| hmi_view_->setRuntimeWriteEnabled(enabled); | hmi_view_->setRuntimeWriteEnabled(enabled); | ||||
| } | } | ||||
| void RuntimeMonitorWidget::setFreeMonitorWriteEnabled(bool enabled) | |||||
| { | |||||
| free_monitor_widget_->setWriteEnabled(enabled); | |||||
| } | |||||
| void RuntimeMonitorWidget::refreshValues( | void RuntimeMonitorWidget::refreshValues( | ||||
| ApplicationMode mode, PlcConnectionState plc_state) | ApplicationMode mode, PlcConnectionState plc_state) | ||||
| { | { | ||||
| @@ -17,6 +17,7 @@ class FreeMonitorWidget; | |||||
| class HmiEditorService; | class HmiEditorService; | ||||
| class HmiEditorWidget; | class HmiEditorWidget; | ||||
| class HmiRuntimeService; | class HmiRuntimeService; | ||||
| class AlarmService; | |||||
| class HmiNavigationService; | class HmiNavigationService; | ||||
| class LogicEditorService; | class LogicEditorService; | ||||
| class LogicEditorWidget; | class LogicEditorWidget; | ||||
| @@ -34,21 +35,24 @@ public: | |||||
| LogicEditorService &logic_editor_service, | LogicEditorService &logic_editor_service, | ||||
| ProjectService &project_service, | ProjectService &project_service, | ||||
| HmiNavigationService &hmi_navigation_service, | HmiNavigationService &hmi_navigation_service, | ||||
| AlarmService &alarm_service, | |||||
| RegisterMonitorService ®ister_monitor_service, | RegisterMonitorService ®ister_monitor_service, | ||||
| QWidget *parent = nullptr); | QWidget *parent = nullptr); | ||||
| ~RuntimeMonitorWidget() override; | ~RuntimeMonitorWidget() override; | ||||
| void setProjectObjects(const std::string &page_id, const std::string &logic_id); | void setProjectObjects(const std::string &page_id, const std::string &logic_id); | ||||
| void setRuntimePage(const std::string &page_id); | |||||
| void setMode(ApplicationMode mode, PlcConnectionState plc_state); | void setMode(ApplicationMode mode, PlcConnectionState plc_state); | ||||
| void refreshValues(ApplicationMode mode, PlcConnectionState plc_state); | void refreshValues(ApplicationMode mode, PlcConnectionState plc_state); | ||||
| void setHmiWriteEnabled(bool enabled); | void setHmiWriteEnabled(bool enabled); | ||||
| void setFreeMonitorWriteEnabled(bool enabled); | |||||
| void setLogicTrace(const LogicTraceSnapshot &trace, const std::string &fault_node_id = {}); | void setLogicTrace(const LogicTraceSnapshot &trace, const std::string &fault_node_id = {}); | ||||
| FreeMonitorWidget *freeMonitorWidget() const; | FreeMonitorWidget *freeMonitorWidget() const; | ||||
| std::string selectedLogicId() const; | std::string selectedLogicId() const; | ||||
| signals: | signals: | ||||
| void navigationFailed(const QString &message); | void navigationFailed(const QString &message); | ||||
| void runtimePageChanged(const QString &page_id); | |||||
| void exitRequested(); | |||||
| private: | private: | ||||
| void showRuntimePage(const std::string &page_id); | void showRuntimePage(const std::string &page_id); | ||||
| @@ -13,6 +13,7 @@ | |||||
| <item><widget class="QLabel" name="titleLabel"><property name="text"><string>运行监控</string></property><property name="styleSheet"><string notr="true">font-weight: 600;</string></property></widget></item> | <item><widget class="QLabel" name="titleLabel"><property name="text"><string>运行监控</string></property><property name="styleSheet"><string notr="true">font-weight: 600;</string></property></widget></item> | ||||
| <item><spacer name="headerSpacer"><property name="orientation"><enum>Qt::Horizontal</enum></property><property name="sizeHint" stdset="0"><size><width>40</width><height>20</height></size></property></spacer></item> | <item><spacer name="headerSpacer"><property name="orientation"><enum>Qt::Horizontal</enum></property><property name="sizeHint" stdset="0"><size><width>40</width><height>20</height></size></property></spacer></item> | ||||
| <item><widget class="QLabel" name="modeLabel"><property name="text"><string>当前未运行</string></property><property name="alignment"><set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set></property></widget></item> | <item><widget class="QLabel" name="modeLabel"><property name="text"><string>当前未运行</string></property><property name="alignment"><set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set></property></widget></item> | ||||
| <item><widget class="QToolButton" name="exitRuntimeButton"><property name="toolTip"><string>返回编辑态</string></property><property name="accessibleName"><string>返回编辑态</string></property><property name="text"><string>返回编辑态</string></property><property name="toolButtonStyle"><enum>Qt::ToolButtonIconOnly</enum></property></widget></item> | |||||
| </layout> | </layout> | ||||
| </item> | </item> | ||||
| <item> | <item> | ||||
| @@ -0,0 +1,54 @@ | |||||
| #include "runtime_monitor_window.h" | |||||
| #include "ui_runtime_monitor_window.h" | |||||
| #include <QCloseEvent> | |||||
| #include <QWidget> | |||||
| RuntimeMonitorWindow::RuntimeMonitorWindow(QWidget *parent) | |||||
| : QMainWindow(parent, Qt::Window), | |||||
| ui_(std::make_unique<Ui::RuntimeMonitorWindow>()) | |||||
| { | |||||
| ui_->setupUi(this); | |||||
| } | |||||
| RuntimeMonitorWindow::~RuntimeMonitorWindow() = default; | |||||
| void RuntimeMonitorWindow::setMonitorWidget(QWidget &widget) | |||||
| { | |||||
| widget.setParent(ui_->centralWidget); | |||||
| ui_->runtimeMonitorWindowLayout->addWidget(&widget); | |||||
| } | |||||
| void RuntimeMonitorWindow::showForRuntime() | |||||
| { | |||||
| application_exit_ = false; | |||||
| runtime_active_ = true; | |||||
| showMaximized(); | |||||
| raise(); | |||||
| activateWindow(); | |||||
| } | |||||
| void RuntimeMonitorWindow::hideForEditing() | |||||
| { | |||||
| runtime_active_ = false; | |||||
| hide(); | |||||
| } | |||||
| void RuntimeMonitorWindow::closeForApplicationExit() | |||||
| { | |||||
| application_exit_ = true; | |||||
| runtime_active_ = false; | |||||
| close(); | |||||
| } | |||||
| void RuntimeMonitorWindow::closeEvent(QCloseEvent *event) | |||||
| { | |||||
| if (runtime_active_ && !application_exit_) | |||||
| { | |||||
| emit exitRequested(); | |||||
| event->ignore(); | |||||
| return; | |||||
| } | |||||
| QMainWindow::closeEvent(event); | |||||
| } | |||||
| @@ -0,0 +1,38 @@ | |||||
| #pragma once | |||||
| #include <QMainWindow> | |||||
| #include <memory> | |||||
| namespace Ui { | |||||
| class RuntimeMonitorWindow; | |||||
| } | |||||
| class QCloseEvent; | |||||
| class QWidget; | |||||
| // 承载唯一工程师运行监控投影的独立顶层窗口 | |||||
| class RuntimeMonitorWindow final : public QMainWindow | |||||
| { | |||||
| Q_OBJECT | |||||
| public: | |||||
| explicit RuntimeMonitorWindow(QWidget *parent = nullptr); | |||||
| ~RuntimeMonitorWindow() override; | |||||
| void setMonitorWidget(QWidget &widget); | |||||
| void showForRuntime(); | |||||
| void hideForEditing(); | |||||
| void closeForApplicationExit(); | |||||
| signals: | |||||
| void exitRequested(); | |||||
| protected: | |||||
| void closeEvent(QCloseEvent *event) override; | |||||
| private: | |||||
| std::unique_ptr<Ui::RuntimeMonitorWindow> ui_; | |||||
| bool runtime_active_ = false; | |||||
| bool application_exit_ = false; | |||||
| }; | |||||
| @@ -0,0 +1,44 @@ | |||||
| <?xml version="1.0" encoding="UTF-8"?> | |||||
| <ui version="4.0"> | |||||
| <class>RuntimeMonitorWindow</class> | |||||
| <widget class="QMainWindow" name="RuntimeMonitorWindow"> | |||||
| <property name="geometry"> | |||||
| <rect> | |||||
| <x>0</x> | |||||
| <y>0</y> | |||||
| <width>1280</width> | |||||
| <height>800</height> | |||||
| </rect> | |||||
| </property> | |||||
| <property name="minimumSize"> | |||||
| <size> | |||||
| <width>960</width> | |||||
| <height>640</height> | |||||
| </size> | |||||
| </property> | |||||
| <property name="windowTitle"> | |||||
| <string>运行监控 - 综合平台编程器</string> | |||||
| </property> | |||||
| <widget class="QWidget" name="centralWidget"> | |||||
| <layout class="QVBoxLayout" name="runtimeMonitorWindowLayout"> | |||||
| <property name="spacing"> | |||||
| <number>0</number> | |||||
| </property> | |||||
| <property name="leftMargin"> | |||||
| <number>0</number> | |||||
| </property> | |||||
| <property name="topMargin"> | |||||
| <number>0</number> | |||||
| </property> | |||||
| <property name="rightMargin"> | |||||
| <number>0</number> | |||||
| </property> | |||||
| <property name="bottomMargin"> | |||||
| <number>0</number> | |||||
| </property> | |||||
| </layout> | |||||
| </widget> | |||||
| </widget> | |||||
| <resources/> | |||||
| <connections/> | |||||
| </ui> | |||||
| @@ -0,0 +1,307 @@ | |||||
| #include "runtime_panel_controller.h" | |||||
| #include "free_monitor_widget.h" | |||||
| #include "hmi_editor_widget.h" | |||||
| #include "logic_editor_widget.h" | |||||
| #include "runtime_monitor_widget.h" | |||||
| #include "runtime_monitor_window.h" | |||||
| #include "services/alarm_service.h" | |||||
| #include "services/hmi_editor_service.h" | |||||
| #include "services/hmi_navigation_service.h" | |||||
| #include "services/hmi_runtime_service.h" | |||||
| #include "services/logic_editor_service.h" | |||||
| #include "services/offline_simulation_service.h" | |||||
| #include "services/project_service.h" | |||||
| #include "services/register_monitor_service.h" | |||||
| #include "services/runtime_mode_service.h" | |||||
| #include <QListWidget> | |||||
| #include <QLabel> | |||||
| #include <QObject> | |||||
| #include <QTimer> | |||||
| #include <QWidget> | |||||
| #include <utility> | |||||
| namespace { | |||||
| QString fromUtf8(const std::string &value) | |||||
| { | |||||
| return QString::fromUtf8(value.data(), static_cast<int>(value.size())); | |||||
| } | |||||
| } // namespace | |||||
| RuntimePanelController::RuntimePanelController( | |||||
| QWidget &parent, | |||||
| RuntimeModeService &runtime_mode_service, | |||||
| ProjectService &project_service, | |||||
| HmiEditorService &hmi_editor_service, | |||||
| HmiRuntimeService &hmi_runtime_service, | |||||
| LogicEditorService &logic_editor_service, | |||||
| HmiNavigationService &hmi_navigation_service, | |||||
| AlarmService &alarm_service, | |||||
| RegisterMonitorService ®ister_monitor_service, | |||||
| HmiEditorWidget &hmi_editor_widget, | |||||
| LogicEditorWidget &logic_editor_widget, | |||||
| QLabel &executor_status_label, | |||||
| std::function<std::string()> current_logic_id, | |||||
| std::function<void(const std::string &)> select_logic, | |||||
| std::function<void(const std::string &)> show_logic_properties, | |||||
| StatusReporter status_reporter, | |||||
| OutputReporter output_reporter, | |||||
| RuntimeExitRequester runtime_exit_requester) | |||||
| : parent_(parent), | |||||
| runtime_mode_service_(runtime_mode_service), | |||||
| project_service_(project_service), | |||||
| hmi_editor_service_(hmi_editor_service), | |||||
| hmi_runtime_service_(hmi_runtime_service), | |||||
| logic_editor_service_(logic_editor_service), | |||||
| hmi_navigation_service_(hmi_navigation_service), | |||||
| alarm_service_(alarm_service), | |||||
| register_monitor_service_(register_monitor_service), | |||||
| hmi_editor_widget_(hmi_editor_widget), | |||||
| logic_editor_widget_(logic_editor_widget), | |||||
| executor_status_label_(executor_status_label), | |||||
| current_logic_id_(std::move(current_logic_id)), | |||||
| select_logic_(std::move(select_logic)), | |||||
| show_logic_properties_(std::move(show_logic_properties)), | |||||
| status_reporter_(std::move(status_reporter)), | |||||
| output_reporter_(std::move(output_reporter)), | |||||
| runtime_exit_requester_(std::move(runtime_exit_requester)) | |||||
| { | |||||
| } | |||||
| RuntimePanelController::~RuntimePanelController() = default; | |||||
| void RuntimePanelController::configure() | |||||
| { | |||||
| runtime_monitor_window_ = std::make_unique<RuntimeMonitorWindow>(&parent_); | |||||
| runtime_monitor_window_->setObjectName( | |||||
| QStringLiteral("runtimeMonitorWindow")); | |||||
| runtime_monitor_widget_ = new RuntimeMonitorWidget( | |||||
| hmi_editor_service_, | |||||
| hmi_runtime_service_, | |||||
| logic_editor_service_, | |||||
| project_service_, | |||||
| hmi_navigation_service_, | |||||
| alarm_service_, | |||||
| register_monitor_service_, | |||||
| runtime_monitor_window_.get()); | |||||
| runtime_monitor_widget_->setObjectName(QStringLiteral("runtimeMonitorWidget")); | |||||
| runtime_monitor_window_->setMonitorWidget(*runtime_monitor_widget_); | |||||
| QObject::connect(runtime_monitor_widget_, &RuntimeMonitorWidget::exitRequested, | |||||
| &parent_, [this] | |||||
| { | |||||
| if (runtime_exit_requester_) | |||||
| { | |||||
| runtime_exit_requester_(); | |||||
| } | |||||
| }); | |||||
| QObject::connect(runtime_monitor_window_.get(), | |||||
| &RuntimeMonitorWindow::exitRequested, | |||||
| &parent_, [this] | |||||
| { | |||||
| if (runtime_exit_requester_) | |||||
| { | |||||
| runtime_exit_requester_(); | |||||
| } | |||||
| }); | |||||
| QObject::connect(runtime_monitor_widget_->freeMonitorWidget(), | |||||
| &FreeMonitorWidget::monitorAddressesChanged, | |||||
| &parent_, [this] | |||||
| { | |||||
| runtime_mode_service_.setMonitorAddresses( | |||||
| register_monitor_service_.addresses()); | |||||
| runtime_monitor_widget_->refreshValues( | |||||
| runtime_mode_service_.mode(), | |||||
| runtime_mode_service_.plcConnectionState()); | |||||
| }); | |||||
| QObject::connect(runtime_monitor_widget_->freeMonitorWidget(), | |||||
| &FreeMonitorWidget::operationMessage, | |||||
| &parent_, [this](const QString &message) | |||||
| { | |||||
| status_reporter_(message, 4000); | |||||
| }); | |||||
| QObject::connect(runtime_monitor_widget_, &RuntimeMonitorWidget::navigationFailed, | |||||
| &parent_, [this](const QString &message) | |||||
| { | |||||
| status_reporter_(message, 5000); | |||||
| output_reporter_(QObject::tr("页面导航失败:%1").arg(message)); | |||||
| }); | |||||
| runtime_refresh_timer_ = new QTimer(&parent_); | |||||
| runtime_refresh_timer_->setInterval(150); | |||||
| QObject::connect(runtime_refresh_timer_, &QTimer::timeout, | |||||
| &parent_, [this] { handleRuntimeTimer(); }); | |||||
| runtime_refresh_timer_->start(); | |||||
| QObject::connect(&runtime_mode_service_.offlineSimulationService(), | |||||
| &OfflineSimulationService::stateChanged, | |||||
| &parent_, [this] { handleSimulationStateChanged(); }, | |||||
| Qt::QueuedConnection); | |||||
| QObject::connect(&runtime_mode_service_.offlineSimulationService(), | |||||
| &OfflineSimulationService::scanCompleted, | |||||
| &parent_, [this] { handleScanCompleted(); }, | |||||
| Qt::QueuedConnection); | |||||
| } | |||||
| RuntimeMonitorWidget *RuntimePanelController::runtimeMonitorWidget() const | |||||
| { | |||||
| return runtime_monitor_widget_; | |||||
| } | |||||
| void RuntimePanelController::enterRuntime( | |||||
| const std::string &page_id, | |||||
| const std::string &logic_id, | |||||
| ApplicationMode mode, | |||||
| PlcConnectionState plc_state) | |||||
| { | |||||
| if (!runtime_session_active_) | |||||
| { | |||||
| runtime_monitor_widget_->setProjectObjects(page_id, logic_id); | |||||
| runtime_session_active_ = true; | |||||
| } | |||||
| runtime_monitor_widget_->setMode(mode, plc_state); | |||||
| runtime_monitor_window_->showForRuntime(); | |||||
| } | |||||
| void RuntimePanelController::leaveRuntime( | |||||
| ApplicationMode mode, PlcConnectionState plc_state) | |||||
| { | |||||
| runtime_session_active_ = false; | |||||
| runtime_monitor_widget_->setMode(mode, plc_state); | |||||
| runtime_monitor_window_->hideForEditing(); | |||||
| } | |||||
| void RuntimePanelController::closeForApplicationExit() | |||||
| { | |||||
| if (runtime_monitor_window_ != nullptr) | |||||
| { | |||||
| runtime_monitor_window_->closeForApplicationExit(); | |||||
| } | |||||
| } | |||||
| void RuntimePanelController::updateSimulationUi(bool report_fault) | |||||
| { | |||||
| const bool offline = runtime_mode_service_.mode() | |||||
| == ApplicationMode::OfflineRunning; | |||||
| const bool online = runtime_mode_service_.mode() | |||||
| == ApplicationMode::OnlineRunning; | |||||
| const bool plc_connected = runtime_mode_service_.plcConnectionState() | |||||
| == PlcConnectionState::Connected; | |||||
| const SimulationState state = runtime_mode_service_.simulationState(); | |||||
| const bool write_enabled = (online && plc_connected) | |||||
| || (offline && state == SimulationState::Running); | |||||
| // 编辑画布始终只读,运行操作统一由运行监控中的 HMI 发出 | |||||
| hmi_editor_widget_.setRuntimeWriteEnabled(false); | |||||
| if (runtime_monitor_widget_ != nullptr) | |||||
| { | |||||
| runtime_monitor_widget_->setHmiWriteEnabled(write_enabled); | |||||
| runtime_monitor_widget_->setFreeMonitorWriteEnabled(write_enabled); | |||||
| runtime_monitor_widget_->refreshValues( | |||||
| runtime_mode_service_.mode(), | |||||
| runtime_mode_service_.plcConnectionState()); | |||||
| } | |||||
| switch (state) | |||||
| { | |||||
| case SimulationState::Running: | |||||
| { | |||||
| executor_status_label_.setText( | |||||
| QObject::tr("逻辑执行器:运行(%1 次)") | |||||
| .arg(runtime_mode_service_.successfulScanCount())); | |||||
| break; | |||||
| } | |||||
| case SimulationState::Faulted: | |||||
| { | |||||
| executor_status_label_.setText(QObject::tr("逻辑执行器:故障")); | |||||
| if (!report_fault) | |||||
| { | |||||
| break; | |||||
| } | |||||
| const LogicScanResult &error = runtime_mode_service_.simulationError(); | |||||
| if (!error.logicId.empty() | |||||
| && logic_editor_service_.findLogic(error.logicId) != nullptr) | |||||
| { | |||||
| select_logic_(error.logicId); | |||||
| } | |||||
| const std::string logic_id = current_logic_id_(); | |||||
| logic_editor_widget_.setLogicId(logic_id); | |||||
| if (runtime_monitor_widget_ != nullptr) | |||||
| { | |||||
| runtime_monitor_widget_->setProjectObjects( | |||||
| hmi_navigation_service_.currentPageId(), logic_id); | |||||
| } | |||||
| logic_editor_widget_.setRuntimeTrace( | |||||
| runtime_mode_service_.offlineSimulationService() | |||||
| .traceSnapshot().forLogic(logic_id), | |||||
| error.nodeId); | |||||
| if (runtime_monitor_widget_ != nullptr) | |||||
| { | |||||
| runtime_monitor_widget_->setLogicTrace( | |||||
| runtime_mode_service_.offlineSimulationService().traceSnapshot(), | |||||
| error.nodeId); | |||||
| } | |||||
| if (!error.nodeId.empty()) | |||||
| { | |||||
| logic_editor_widget_.selectNode(error.nodeId); | |||||
| show_logic_properties_(error.nodeId); | |||||
| } | |||||
| QString message = QObject::tr("离线仿真异常:%1").arg(fromUtf8(error.message)); | |||||
| if (!error.logicId.empty()) | |||||
| { | |||||
| message += QObject::tr(",逻辑 %1").arg(fromUtf8(error.logicId)); | |||||
| } | |||||
| if (!error.rungId.empty()) | |||||
| { | |||||
| message += QObject::tr(",网络 %1").arg(fromUtf8(error.rungId)); | |||||
| } | |||||
| if (!error.nodeId.empty()) | |||||
| { | |||||
| message += QObject::tr(",节点 %1").arg(fromUtf8(error.nodeId)); | |||||
| } | |||||
| status_reporter_(message, 0); | |||||
| output_reporter_(message); | |||||
| break; | |||||
| } | |||||
| case SimulationState::Stopped: | |||||
| default: | |||||
| { | |||||
| executor_status_label_.setText(QObject::tr("逻辑执行器:停止")); | |||||
| break; | |||||
| } | |||||
| } | |||||
| } | |||||
| void RuntimePanelController::handleRuntimeTimer() | |||||
| { | |||||
| if (runtime_mode_service_.mode() == ApplicationMode::Editing) | |||||
| { | |||||
| return; | |||||
| } | |||||
| alarm_service_.refresh(); | |||||
| hmi_editor_widget_.refreshRuntimeValues(); | |||||
| if (runtime_monitor_widget_ != nullptr) | |||||
| { | |||||
| runtime_monitor_widget_->refreshValues( | |||||
| runtime_mode_service_.mode(), | |||||
| runtime_mode_service_.plcConnectionState()); | |||||
| } | |||||
| updateSimulationUi(false); | |||||
| } | |||||
| void RuntimePanelController::handleSimulationStateChanged() | |||||
| { | |||||
| updateSimulationUi(true); | |||||
| } | |||||
| void RuntimePanelController::handleScanCompleted() | |||||
| { | |||||
| const std::string logic_id = current_logic_id_(); | |||||
| logic_editor_widget_.setRuntimeTrace( | |||||
| runtime_mode_service_.offlineSimulationService() | |||||
| .traceSnapshot().forLogic(logic_id)); | |||||
| if (runtime_monitor_widget_ != nullptr) | |||||
| { | |||||
| runtime_monitor_widget_->setLogicTrace( | |||||
| runtime_mode_service_.offlineSimulationService().traceSnapshot()); | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,96 @@ | |||||
| #pragma once | |||||
| #include "domain/runtime_state.h" | |||||
| #include "services/plc_communication_gateway.h" | |||||
| #include <QString> | |||||
| #include <functional> | |||||
| #include <memory> | |||||
| #include <string> | |||||
| class AlarmService; | |||||
| class HmiEditorService; | |||||
| class HmiEditorWidget; | |||||
| class HmiNavigationService; | |||||
| class HmiRuntimeService; | |||||
| class LogicEditorService; | |||||
| class LogicEditorWidget; | |||||
| class QLabel; | |||||
| class ProjectService; | |||||
| class RegisterMonitorService; | |||||
| class RuntimeModeService; | |||||
| class RuntimeMonitorWidget; | |||||
| class RuntimeMonitorWindow; | |||||
| class QTimer; | |||||
| class QWidget; | |||||
| // 组织运行监控工作台、运行刷新定时器和离线执行器反馈 | |||||
| class RuntimePanelController final | |||||
| { | |||||
| public: | |||||
| using StatusReporter = std::function<void( | |||||
| const QString &message, int timeout_ms)>; | |||||
| using OutputReporter = std::function<void(const QString &message)>; | |||||
| using RuntimeExitRequester = std::function<void()>; | |||||
| RuntimePanelController( | |||||
| QWidget &parent, | |||||
| RuntimeModeService &runtime_mode_service, | |||||
| ProjectService &project_service, | |||||
| HmiEditorService &hmi_editor_service, | |||||
| HmiRuntimeService &hmi_runtime_service, | |||||
| LogicEditorService &logic_editor_service, | |||||
| HmiNavigationService &hmi_navigation_service, | |||||
| AlarmService &alarm_service, | |||||
| RegisterMonitorService ®ister_monitor_service, | |||||
| HmiEditorWidget &hmi_editor_widget, | |||||
| LogicEditorWidget &logic_editor_widget, | |||||
| QLabel &executor_status_label, | |||||
| std::function<std::string()> current_logic_id, | |||||
| std::function<void(const std::string &)> select_logic, | |||||
| std::function<void(const std::string &)> show_logic_properties, | |||||
| StatusReporter status_reporter, | |||||
| OutputReporter output_reporter, | |||||
| RuntimeExitRequester runtime_exit_requester); | |||||
| ~RuntimePanelController(); | |||||
| void configure(); | |||||
| void enterRuntime( | |||||
| const std::string &page_id, | |||||
| const std::string &logic_id, | |||||
| ApplicationMode mode, | |||||
| PlcConnectionState plc_state); | |||||
| void leaveRuntime(ApplicationMode mode, PlcConnectionState plc_state); | |||||
| void closeForApplicationExit(); | |||||
| void updateSimulationUi(bool report_fault); | |||||
| RuntimeMonitorWidget *runtimeMonitorWidget() const; | |||||
| private: | |||||
| void handleRuntimeTimer(); | |||||
| void handleSimulationStateChanged(); | |||||
| void handleScanCompleted(); | |||||
| QWidget &parent_; | |||||
| RuntimeModeService &runtime_mode_service_; | |||||
| ProjectService &project_service_; | |||||
| HmiEditorService &hmi_editor_service_; | |||||
| HmiRuntimeService &hmi_runtime_service_; | |||||
| LogicEditorService &logic_editor_service_; | |||||
| HmiNavigationService &hmi_navigation_service_; | |||||
| AlarmService &alarm_service_; | |||||
| RegisterMonitorService ®ister_monitor_service_; | |||||
| HmiEditorWidget &hmi_editor_widget_; | |||||
| LogicEditorWidget &logic_editor_widget_; | |||||
| QLabel &executor_status_label_; | |||||
| std::function<std::string()> current_logic_id_; | |||||
| std::function<void(const std::string &)> select_logic_; | |||||
| std::function<void(const std::string &)> show_logic_properties_; | |||||
| StatusReporter status_reporter_; | |||||
| OutputReporter output_reporter_; | |||||
| RuntimeExitRequester runtime_exit_requester_; | |||||
| std::unique_ptr<RuntimeMonitorWindow> runtime_monitor_window_; | |||||
| RuntimeMonitorWidget *runtime_monitor_widget_ = nullptr; | |||||
| QTimer *runtime_refresh_timer_ = nullptr; | |||||
| bool runtime_session_active_ = false; | |||||
| }; | |||||
| @@ -0,0 +1,551 @@ | |||||
| #include "toolbar_icon_factory.h" | |||||
| #include <QFont> | |||||
| #include <QPainter> | |||||
| #include <QPainterPath> | |||||
| #include <QPixmap> | |||||
| #include <array> | |||||
| namespace { | |||||
| const QColor kInk(QStringLiteral("#263842")); | |||||
| const QColor kAccent(QStringLiteral("#2f7d4a")); | |||||
| const QColor kAccentFill(QStringLiteral("#dcece3")); | |||||
| const QColor kWarning(QStringLiteral("#b87808")); | |||||
| const QColor kDanger(QStringLiteral("#a84545")); | |||||
| const QColor kPaper(QStringLiteral("#ffffff")); | |||||
| QPen iconPen(const QColor &color = kInk, qreal width = 1.7) | |||||
| { | |||||
| QPen pen(color, width); | |||||
| pen.setCapStyle(Qt::RoundCap); | |||||
| pen.setJoinStyle(Qt::RoundJoin); | |||||
| return pen; | |||||
| } | |||||
| void drawCenteredText( | |||||
| QPainter &painter, | |||||
| const QRectF &bounds, | |||||
| const QString &text, | |||||
| qreal point_size, | |||||
| const QColor &color = kInk) | |||||
| { | |||||
| QFont font = painter.font(); | |||||
| font.setFamily(QStringLiteral("Arial")); | |||||
| font.setPointSizeF(point_size); | |||||
| font.setBold(true); | |||||
| painter.setFont(font); | |||||
| painter.setPen(color); | |||||
| painter.drawText(bounds, Qt::AlignCenter, text); | |||||
| } | |||||
| void drawPlc(QPainter &painter) | |||||
| { | |||||
| painter.setPen(iconPen()); | |||||
| painter.setBrush(kPaper); | |||||
| painter.drawRoundedRect(QRectF(3, 4, 18, 16), 1.5, 1.5); | |||||
| painter.drawLine(QPointF(8, 4), QPointF(8, 20)); | |||||
| painter.setBrush(kAccent); | |||||
| painter.setPen(Qt::NoPen); | |||||
| painter.drawEllipse(QPointF(5.5, 8), 1.1, 1.1); | |||||
| painter.drawEllipse(QPointF(5.5, 12), 1.1, 1.1); | |||||
| painter.setPen(iconPen()); | |||||
| painter.drawLine(QPointF(11, 8), QPointF(18, 8)); | |||||
| painter.drawLine(QPointF(11, 12), QPointF(18, 12)); | |||||
| painter.drawLine(QPointF(11, 16), QPointF(16, 16)); | |||||
| } | |||||
| void drawSave(QPainter &painter) | |||||
| { | |||||
| painter.setPen(iconPen()); | |||||
| painter.setBrush(kPaper); | |||||
| painter.drawRoundedRect(QRectF(4, 3, 16, 18), 1.2, 1.2); | |||||
| painter.drawRect(QRectF(7, 3, 9, 6)); | |||||
| painter.drawRect(QRectF(7, 13, 10, 8)); | |||||
| } | |||||
| void drawContact(QPainter &painter, const QString &label, bool closed) | |||||
| { | |||||
| painter.setPen(iconPen()); | |||||
| painter.drawLine(QPointF(2, 12), QPointF(7, 12)); | |||||
| painter.drawLine(QPointF(17, 12), QPointF(22, 12)); | |||||
| painter.drawLine(QPointF(8, 7), QPointF(8, 17)); | |||||
| painter.drawLine(QPointF(16, 7), QPointF(16, 17)); | |||||
| if (closed) | |||||
| { | |||||
| painter.drawLine(QPointF(7, 17), QPointF(17, 7)); | |||||
| } | |||||
| if (!label.isEmpty()) | |||||
| { | |||||
| drawCenteredText(painter, QRectF(8, 7, 8, 10), label, 5.5); | |||||
| } | |||||
| } | |||||
| void drawCoil(QPainter &painter, const QString &label) | |||||
| { | |||||
| painter.setPen(iconPen()); | |||||
| painter.drawLine(QPointF(2, 12), QPointF(6, 12)); | |||||
| painter.drawLine(QPointF(18, 12), QPointF(22, 12)); | |||||
| QPainterPath left; | |||||
| left.moveTo(11, 5); | |||||
| left.cubicTo(5, 6, 5, 18, 11, 19); | |||||
| painter.drawPath(left); | |||||
| QPainterPath right; | |||||
| right.moveTo(13, 5); | |||||
| right.cubicTo(19, 6, 19, 18, 13, 19); | |||||
| painter.drawPath(right); | |||||
| if (!label.isEmpty()) | |||||
| { | |||||
| drawCenteredText(painter, QRectF(8, 7, 8, 10), label, 6.0); | |||||
| } | |||||
| } | |||||
| void drawInstructionBlock(QPainter &painter, const QString &label) | |||||
| { | |||||
| painter.setPen(iconPen()); | |||||
| painter.setBrush(kPaper); | |||||
| painter.drawRoundedRect(QRectF(2.5, 5, 19, 14), 1.5, 1.5); | |||||
| drawCenteredText(painter, QRectF(3, 5.5, 18, 13), label, 5.5); | |||||
| } | |||||
| QPixmap renderIcon(UiIcon icon, int size) | |||||
| { | |||||
| QPixmap pixmap(size, size); | |||||
| pixmap.fill(Qt::transparent); | |||||
| QPainter painter(&pixmap); | |||||
| painter.setRenderHint(QPainter::Antialiasing, true); | |||||
| painter.scale(static_cast<qreal>(size) / 24.0, static_cast<qreal>(size) / 24.0); | |||||
| painter.setPen(iconPen()); | |||||
| painter.setBrush(Qt::NoBrush); | |||||
| switch (icon) | |||||
| { | |||||
| case UiIcon::Edit: | |||||
| { | |||||
| painter.drawLine(QPointF(5, 19), QPointF(16.5, 7.5)); | |||||
| painter.drawLine(QPointF(8, 20), QPointF(19.5, 8.5)); | |||||
| painter.drawLine(QPointF(16.5, 7.5), QPointF(19.5, 8.5)); | |||||
| painter.drawLine(QPointF(5, 19), QPointF(4, 21)); | |||||
| break; | |||||
| } | |||||
| case UiIcon::RunOffline: | |||||
| { | |||||
| QPainterPath path; | |||||
| path.moveTo(7, 4); | |||||
| path.lineTo(20, 12); | |||||
| path.lineTo(7, 20); | |||||
| path.closeSubpath(); | |||||
| painter.setPen(iconPen(kAccent)); | |||||
| painter.setBrush(kAccentFill); | |||||
| painter.drawPath(path); | |||||
| break; | |||||
| } | |||||
| case UiIcon::RunOnline: | |||||
| { | |||||
| drawPlc(painter); | |||||
| painter.setPen(Qt::NoPen); | |||||
| painter.setBrush(kAccent); | |||||
| QPainterPath path; | |||||
| path.moveTo(14, 10); | |||||
| path.lineTo(19, 13); | |||||
| path.lineTo(14, 16); | |||||
| path.closeSubpath(); | |||||
| painter.drawPath(path); | |||||
| break; | |||||
| } | |||||
| case UiIcon::NewDocument: | |||||
| { | |||||
| QPainterPath page; | |||||
| page.moveTo(5, 2); | |||||
| page.lineTo(15, 2); | |||||
| page.lineTo(20, 7); | |||||
| page.lineTo(20, 22); | |||||
| page.lineTo(5, 22); | |||||
| page.closeSubpath(); | |||||
| painter.setBrush(kPaper); | |||||
| painter.drawPath(page); | |||||
| painter.drawLine(QPointF(15, 2), QPointF(15, 7)); | |||||
| painter.drawLine(QPointF(15, 7), QPointF(20, 7)); | |||||
| painter.drawLine(QPointF(8, 15), QPointF(16, 15)); | |||||
| painter.drawLine(QPointF(12, 11), QPointF(12, 19)); | |||||
| break; | |||||
| } | |||||
| case UiIcon::Save: | |||||
| { | |||||
| drawSave(painter); | |||||
| break; | |||||
| } | |||||
| case UiIcon::SaveAs: | |||||
| { | |||||
| drawSave(painter); | |||||
| painter.setPen(iconPen(kAccent, 2.0)); | |||||
| painter.drawLine(QPointF(13, 19), QPointF(21, 11)); | |||||
| break; | |||||
| } | |||||
| case UiIcon::Open: | |||||
| { | |||||
| painter.setBrush(kPaper); | |||||
| QPainterPath folder; | |||||
| folder.moveTo(2, 7); | |||||
| folder.lineTo(9, 7); | |||||
| folder.lineTo(11, 10); | |||||
| folder.lineTo(22, 10); | |||||
| folder.lineTo(19, 20); | |||||
| folder.lineTo(3, 20); | |||||
| folder.closeSubpath(); | |||||
| painter.setBrush(kAccentFill); | |||||
| painter.drawPath(folder); | |||||
| break; | |||||
| } | |||||
| case UiIcon::Undo: | |||||
| case UiIcon::Redo: | |||||
| { | |||||
| const bool redo = icon == UiIcon::Redo; | |||||
| QPainterPath arrow; | |||||
| if (redo) | |||||
| { | |||||
| arrow.moveTo(5, 7); | |||||
| arrow.cubicTo(12, 3, 20, 7, 20, 15); | |||||
| arrow.lineTo(20, 19); | |||||
| painter.drawPath(arrow); | |||||
| painter.drawLine(QPointF(20, 19), QPointF(16, 15)); | |||||
| painter.drawLine(QPointF(20, 19), QPointF(23, 15)); | |||||
| } | |||||
| else | |||||
| { | |||||
| arrow.moveTo(19, 7); | |||||
| arrow.cubicTo(12, 3, 4, 7, 4, 15); | |||||
| arrow.lineTo(4, 19); | |||||
| painter.drawPath(arrow); | |||||
| painter.drawLine(QPointF(4, 19), QPointF(1, 15)); | |||||
| painter.drawLine(QPointF(4, 19), QPointF(8, 15)); | |||||
| } | |||||
| break; | |||||
| } | |||||
| case UiIcon::Exit: | |||||
| { | |||||
| painter.drawRect(QRectF(4, 3, 10, 18)); | |||||
| painter.drawLine(QPointF(10, 12), QPointF(22, 12)); | |||||
| painter.drawLine(QPointF(18, 8), QPointF(22, 12)); | |||||
| painter.drawLine(QPointF(18, 16), QPointF(22, 12)); | |||||
| break; | |||||
| } | |||||
| case UiIcon::PlcSettings: | |||||
| { | |||||
| drawPlc(painter); | |||||
| painter.setPen(iconPen(kAccent)); | |||||
| painter.drawEllipse(QPointF(17.5, 16.5), 3.0, 3.0); | |||||
| painter.drawLine(QPointF(17.5, 12), QPointF(17.5, 14)); | |||||
| painter.drawLine(QPointF(17.5, 19), QPointF(17.5, 21)); | |||||
| break; | |||||
| } | |||||
| case UiIcon::Disconnect: | |||||
| { | |||||
| painter.drawLine(QPointF(3, 12), QPointF(8, 12)); | |||||
| painter.drawRect(QRectF(8, 8, 4, 8)); | |||||
| painter.drawLine(QPointF(12, 10), QPointF(15, 10)); | |||||
| painter.drawLine(QPointF(12, 14), QPointF(15, 14)); | |||||
| painter.drawRect(QRectF(16, 8, 4, 8)); | |||||
| painter.drawLine(QPointF(20, 12), QPointF(23, 12)); | |||||
| painter.setPen(iconPen(kDanger, 2.0)); | |||||
| painter.drawLine(QPointF(6, 20), QPointF(19, 4)); | |||||
| break; | |||||
| } | |||||
| case UiIcon::RegisterComments: | |||||
| { | |||||
| painter.drawRoundedRect(QRectF(3, 3, 18, 18), 1.5, 1.5); | |||||
| drawCenteredText(painter, QRectF(4, 4, 8, 7), QStringLiteral("M"), 5.0); | |||||
| drawCenteredText(painter, QRectF(4, 12, 8, 7), QStringLiteral("D"), 5.0); | |||||
| painter.drawLine(QPointF(13, 8), QPointF(19, 8)); | |||||
| painter.drawLine(QPointF(13, 16), QPointF(19, 16)); | |||||
| break; | |||||
| } | |||||
| case UiIcon::HmiButton: | |||||
| { | |||||
| painter.setBrush(kAccentFill); | |||||
| painter.drawRoundedRect(QRectF(3, 5, 18, 14), 3, 3); | |||||
| painter.setBrush(kPaper); | |||||
| painter.drawEllipse(QPointF(12, 12), 4, 4); | |||||
| break; | |||||
| } | |||||
| case UiIcon::Indicator: | |||||
| { | |||||
| painter.setBrush(kAccentFill); | |||||
| painter.drawEllipse(QPointF(12, 12), 8.5, 8.5); | |||||
| painter.setBrush(kAccent); | |||||
| painter.setPen(Qt::NoPen); | |||||
| painter.drawEllipse(QPointF(12, 12), 4.5, 4.5); | |||||
| break; | |||||
| } | |||||
| case UiIcon::NumericDisplay: | |||||
| { | |||||
| painter.drawRoundedRect(QRectF(2, 5, 20, 14), 2, 2); | |||||
| drawCenteredText(painter, QRectF(3, 6, 18, 12), QStringLiteral("123"), 6.0); | |||||
| break; | |||||
| } | |||||
| case UiIcon::NumericInput: | |||||
| { | |||||
| painter.drawRoundedRect(QRectF(2, 5, 20, 14), 2, 2); | |||||
| drawCenteredText(painter, QRectF(3, 6, 13, 12), QStringLiteral("12"), 6.0); | |||||
| painter.drawLine(QPointF(18, 7), QPointF(18, 17)); | |||||
| break; | |||||
| } | |||||
| case UiIcon::ProgressBar: | |||||
| { | |||||
| painter.drawRoundedRect(QRectF(2, 8, 20, 8), 2, 2); | |||||
| painter.setPen(Qt::NoPen); | |||||
| painter.setBrush(kAccent); | |||||
| painter.drawRoundedRect(QRectF(4, 10, 11, 4), 1, 1); | |||||
| break; | |||||
| } | |||||
| case UiIcon::Text: | |||||
| { | |||||
| painter.drawLine(QPointF(5, 5), QPointF(19, 5)); | |||||
| painter.drawLine(QPointF(12, 5), QPointF(12, 20)); | |||||
| drawCenteredText(painter, QRectF(7, 6, 10, 14), QStringLiteral("T"), 9.0); | |||||
| break; | |||||
| } | |||||
| case UiIcon::PageJump: | |||||
| { | |||||
| painter.drawRect(QRectF(3, 3, 14, 18)); | |||||
| painter.drawLine(QPointF(9, 12), QPointF(22, 12)); | |||||
| painter.drawLine(QPointF(18, 8), QPointF(22, 12)); | |||||
| painter.drawLine(QPointF(18, 16), QPointF(22, 12)); | |||||
| break; | |||||
| } | |||||
| case UiIcon::AlarmList: | |||||
| { | |||||
| QPainterPath triangle; | |||||
| triangle.moveTo(12, 3); | |||||
| triangle.lineTo(22, 21); | |||||
| triangle.lineTo(2, 21); | |||||
| triangle.closeSubpath(); | |||||
| painter.setPen(iconPen(kWarning)); | |||||
| painter.setBrush(QColor(QStringLiteral("#fff0d2"))); | |||||
| painter.drawPath(triangle); | |||||
| painter.drawLine(QPointF(12, 8), QPointF(12, 14)); | |||||
| painter.drawPoint(QPointF(12, 17)); | |||||
| break; | |||||
| } | |||||
| case UiIcon::AlarmSettings: | |||||
| { | |||||
| painter.setPen(iconPen(kWarning)); | |||||
| painter.drawArc(QRectF(4, 4, 12, 13), 0, 180 * 16); | |||||
| painter.drawLine(QPointF(4, 10), QPointF(4, 17)); | |||||
| painter.drawLine(QPointF(16, 10), QPointF(16, 17)); | |||||
| painter.drawLine(QPointF(3, 17), QPointF(17, 17)); | |||||
| painter.drawEllipse(QPointF(18, 17), 3.0, 3.0); | |||||
| painter.drawLine(QPointF(18, 12), QPointF(18, 14)); | |||||
| break; | |||||
| } | |||||
| case UiIcon::Delete: | |||||
| { | |||||
| painter.setPen(iconPen(kDanger)); | |||||
| painter.drawLine(QPointF(5, 7), QPointF(19, 7)); | |||||
| painter.drawLine(QPointF(9, 4), QPointF(15, 4)); | |||||
| painter.drawRoundedRect(QRectF(7, 7, 10, 14), 1, 1); | |||||
| painter.drawLine(QPointF(10, 10), QPointF(10, 18)); | |||||
| painter.drawLine(QPointF(14, 10), QPointF(14, 18)); | |||||
| break; | |||||
| } | |||||
| case UiIcon::AddRung: | |||||
| { | |||||
| painter.drawLine(QPointF(4, 3), QPointF(4, 21)); | |||||
| painter.drawLine(QPointF(20, 3), QPointF(20, 21)); | |||||
| painter.drawLine(QPointF(4, 12), QPointF(20, 12)); | |||||
| painter.setPen(iconPen(kAccent, 2.0)); | |||||
| painter.drawLine(QPointF(12, 7), QPointF(12, 17)); | |||||
| painter.drawLine(QPointF(7, 12), QPointF(17, 12)); | |||||
| break; | |||||
| } | |||||
| case UiIcon::HorizontalWire: | |||||
| { | |||||
| painter.drawLine(QPointF(3, 12), QPointF(21, 12)); | |||||
| painter.setPen(iconPen(kAccent, 2.0)); | |||||
| painter.drawLine(QPointF(9, 7), QPointF(15, 7)); | |||||
| painter.drawLine(QPointF(12, 4), QPointF(12, 10)); | |||||
| break; | |||||
| } | |||||
| case UiIcon::VerticalWire: | |||||
| { | |||||
| painter.drawLine(QPointF(5, 7), QPointF(19, 7)); | |||||
| painter.drawLine(QPointF(5, 17), QPointF(19, 17)); | |||||
| painter.drawLine(QPointF(12, 7), QPointF(12, 17)); | |||||
| painter.setPen(iconPen(kAccent, 2.0)); | |||||
| painter.drawLine(QPointF(17, 9), QPointF(17, 15)); | |||||
| painter.drawLine(QPointF(14, 12), QPointF(20, 12)); | |||||
| break; | |||||
| } | |||||
| case UiIcon::ParallelBranch: | |||||
| { | |||||
| painter.drawLine(QPointF(2, 12), QPointF(6, 12)); | |||||
| painter.drawLine(QPointF(18, 12), QPointF(22, 12)); | |||||
| painter.drawLine(QPointF(6, 7), QPointF(6, 17)); | |||||
| painter.drawLine(QPointF(18, 7), QPointF(18, 17)); | |||||
| painter.drawLine(QPointF(6, 7), QPointF(18, 7)); | |||||
| painter.drawLine(QPointF(6, 17), QPointF(18, 17)); | |||||
| painter.setPen(iconPen(kAccent)); | |||||
| painter.drawLine(QPointF(12, 9), QPointF(12, 15)); | |||||
| painter.drawLine(QPointF(9, 12), QPointF(15, 12)); | |||||
| break; | |||||
| } | |||||
| case UiIcon::NormallyOpenContact: | |||||
| { | |||||
| drawContact(painter, {}, false); | |||||
| break; | |||||
| } | |||||
| case UiIcon::NormallyClosedContact: | |||||
| { | |||||
| drawContact(painter, {}, true); | |||||
| break; | |||||
| } | |||||
| case UiIcon::RisingEdgeContact: | |||||
| { | |||||
| drawContact(painter, QStringLiteral("P"), false); | |||||
| break; | |||||
| } | |||||
| case UiIcon::FallingEdgeContact: | |||||
| { | |||||
| drawContact(painter, QStringLiteral("N"), false); | |||||
| break; | |||||
| } | |||||
| case UiIcon::TimerContact: | |||||
| { | |||||
| drawContact(painter, QStringLiteral("T"), false); | |||||
| break; | |||||
| } | |||||
| case UiIcon::CounterContact: | |||||
| { | |||||
| drawContact(painter, QStringLiteral("C"), false); | |||||
| break; | |||||
| } | |||||
| case UiIcon::Coil: | |||||
| { | |||||
| drawCoil(painter, {}); | |||||
| break; | |||||
| } | |||||
| case UiIcon::SetCoil: | |||||
| { | |||||
| drawCoil(painter, QStringLiteral("S")); | |||||
| break; | |||||
| } | |||||
| case UiIcon::ResetCoil: | |||||
| { | |||||
| drawCoil(painter, QStringLiteral("R")); | |||||
| break; | |||||
| } | |||||
| case UiIcon::Ton: | |||||
| { | |||||
| drawInstructionBlock(painter, QStringLiteral("TON")); | |||||
| break; | |||||
| } | |||||
| case UiIcon::Ctu: | |||||
| { | |||||
| drawInstructionBlock(painter, QStringLiteral("CTU")); | |||||
| break; | |||||
| } | |||||
| case UiIcon::Ctd: | |||||
| { | |||||
| drawInstructionBlock(painter, QStringLiteral("CTD")); | |||||
| break; | |||||
| } | |||||
| case UiIcon::Move: | |||||
| { | |||||
| painter.drawRect(QRectF(2, 6, 6, 12)); | |||||
| painter.drawRect(QRectF(16, 6, 6, 12)); | |||||
| painter.drawLine(QPointF(8, 12), QPointF(16, 12)); | |||||
| painter.drawLine(QPointF(13, 9), QPointF(16, 12)); | |||||
| painter.drawLine(QPointF(13, 15), QPointF(16, 12)); | |||||
| break; | |||||
| } | |||||
| case UiIcon::Add: | |||||
| { | |||||
| drawInstructionBlock(painter, QStringLiteral("+")); | |||||
| break; | |||||
| } | |||||
| case UiIcon::Subtract: | |||||
| { | |||||
| drawInstructionBlock(painter, QStringLiteral("-")); | |||||
| break; | |||||
| } | |||||
| case UiIcon::Compare: | |||||
| { | |||||
| drawInstructionBlock(painter, QStringLiteral("<>")); | |||||
| break; | |||||
| } | |||||
| case UiIcon::Comment: | |||||
| { | |||||
| painter.drawRoundedRect(QRectF(3, 3, 18, 18), 1.5, 1.5); | |||||
| painter.drawLine(QPointF(7, 8), QPointF(17, 8)); | |||||
| painter.drawLine(QPointF(7, 12), QPointF(17, 12)); | |||||
| painter.drawLine(QPointF(7, 16), QPointF(13, 16)); | |||||
| painter.setPen(iconPen(kAccent, 2.0)); | |||||
| painter.drawLine(QPointF(14, 19), QPointF(21, 12)); | |||||
| break; | |||||
| } | |||||
| case UiIcon::More: | |||||
| { | |||||
| painter.setPen(Qt::NoPen); | |||||
| painter.setBrush(kInk); | |||||
| painter.drawEllipse(QPointF(6, 12), 1.7, 1.7); | |||||
| painter.drawEllipse(QPointF(12, 12), 1.7, 1.7); | |||||
| painter.drawEllipse(QPointF(18, 12), 1.7, 1.7); | |||||
| break; | |||||
| } | |||||
| case UiIcon::HmiPage: | |||||
| { | |||||
| painter.drawRoundedRect(QRectF(2, 4, 20, 14), 1.5, 1.5); | |||||
| painter.drawLine(QPointF(8, 21), QPointF(16, 21)); | |||||
| painter.drawLine(QPointF(12, 18), QPointF(12, 21)); | |||||
| painter.setBrush(kAccentFill); | |||||
| painter.drawRoundedRect(QRectF(6, 8, 12, 6), 1, 1); | |||||
| break; | |||||
| } | |||||
| case UiIcon::Logic: | |||||
| { | |||||
| painter.drawLine(QPointF(3, 3), QPointF(3, 21)); | |||||
| painter.drawLine(QPointF(21, 3), QPointF(21, 21)); | |||||
| drawContact(painter, {}, false); | |||||
| break; | |||||
| } | |||||
| case UiIcon::Runtime: | |||||
| { | |||||
| painter.drawRoundedRect(QRectF(2, 4, 20, 16), 1.5, 1.5); | |||||
| QPainterPath pulse; | |||||
| pulse.moveTo(4, 13); | |||||
| pulse.lineTo(8, 13); | |||||
| pulse.lineTo(10, 8); | |||||
| pulse.lineTo(13, 17); | |||||
| pulse.lineTo(16, 11); | |||||
| pulse.lineTo(20, 11); | |||||
| painter.setPen(iconPen(kAccent)); | |||||
| painter.drawPath(pulse); | |||||
| break; | |||||
| } | |||||
| case UiIcon::ClearList: | |||||
| { | |||||
| painter.drawLine(QPointF(4, 6), QPointF(16, 6)); | |||||
| painter.drawLine(QPointF(4, 11), QPointF(13, 11)); | |||||
| painter.drawLine(QPointF(4, 16), QPointF(10, 16)); | |||||
| painter.setPen(iconPen(kDanger)); | |||||
| painter.drawLine(QPointF(14, 13), QPointF(21, 20)); | |||||
| painter.drawLine(QPointF(21, 13), QPointF(14, 20)); | |||||
| break; | |||||
| } | |||||
| } | |||||
| return pixmap; | |||||
| } | |||||
| } // namespace | |||||
| QIcon makeUiIcon(UiIcon icon) | |||||
| { | |||||
| QIcon result; | |||||
| constexpr std::array<int, 3> sizes{16, 24, 32}; | |||||
| for (int size : sizes) | |||||
| { | |||||
| result.addPixmap(renderIcon(icon, size)); | |||||
| } | |||||
| return result; | |||||
| } | |||||
| @@ -0,0 +1,58 @@ | |||||
| #pragma once | |||||
| #include <QIcon> | |||||
| enum class UiIcon | |||||
| { | |||||
| Edit, | |||||
| RunOffline, | |||||
| RunOnline, | |||||
| NewDocument, | |||||
| Save, | |||||
| SaveAs, | |||||
| Open, | |||||
| Undo, | |||||
| Redo, | |||||
| Exit, | |||||
| PlcSettings, | |||||
| Disconnect, | |||||
| RegisterComments, | |||||
| HmiButton, | |||||
| Indicator, | |||||
| NumericDisplay, | |||||
| NumericInput, | |||||
| ProgressBar, | |||||
| Text, | |||||
| PageJump, | |||||
| AlarmList, | |||||
| AlarmSettings, | |||||
| Delete, | |||||
| AddRung, | |||||
| HorizontalWire, | |||||
| VerticalWire, | |||||
| ParallelBranch, | |||||
| NormallyOpenContact, | |||||
| NormallyClosedContact, | |||||
| RisingEdgeContact, | |||||
| FallingEdgeContact, | |||||
| TimerContact, | |||||
| CounterContact, | |||||
| Coil, | |||||
| SetCoil, | |||||
| ResetCoil, | |||||
| Ton, | |||||
| Ctu, | |||||
| Ctd, | |||||
| Move, | |||||
| Add, | |||||
| Subtract, | |||||
| Compare, | |||||
| Comment, | |||||
| More, | |||||
| HmiPage, | |||||
| Logic, | |||||
| Runtime, | |||||
| ClearList | |||||
| }; | |||||
| QIcon makeUiIcon(UiIcon icon); | |||||
| @@ -0,0 +1,120 @@ | |||||
| #include "domain/project_storage.h" | |||||
| #include "domain/register_repository.h" | |||||
| #include "services/alarm_editor_service.h" | |||||
| #include "services/alarm_service.h" | |||||
| #include "services/project_service.h" | |||||
| #include "support/test_support.h" | |||||
| #include <algorithm> | |||||
| #include <exception> | |||||
| #include <iostream> | |||||
| #include <stdexcept> | |||||
| #include <string> | |||||
| namespace { | |||||
| using TestProjectStorage = TestSupport::InMemoryProjectStorage; | |||||
| using TestSupport::require; | |||||
| const AlarmRecord *findRecord( | |||||
| const AlarmService &service, const std::string &definition_id) | |||||
| { | |||||
| const auto &records = service.records(); | |||||
| const auto record = std::find_if( | |||||
| records.cbegin(), records.cend(), | |||||
| [&definition_id](const AlarmRecord &candidate) | |||||
| { | |||||
| return candidate.definitionId == definition_id; | |||||
| }); | |||||
| return record == records.cend() ? nullptr : &*record; | |||||
| } | |||||
| void testAlarmDefinitionsAndRuntimeLifecycle() | |||||
| { | |||||
| TestProjectStorage storage; | |||||
| ProjectService project_service(storage); | |||||
| AlarmEditorService editor_service(project_service); | |||||
| VirtualRegisterRepository repository; | |||||
| AlarmService alarm_service(project_service, repository); | |||||
| AlarmDefinition m_alarm; | |||||
| m_alarm.address = RegisterAddress{RegisterArea::M, 0}; | |||||
| m_alarm.condition = AlarmCondition::MOn; | |||||
| m_alarm.message = "Emergency stop"; | |||||
| const AlarmEditorResult m_result = editor_service.addDefinition(m_alarm); | |||||
| AlarmDefinition high_alarm; | |||||
| high_alarm.address = RegisterAddress{RegisterArea::D, 0}; | |||||
| high_alarm.condition = AlarmCondition::DHigh; | |||||
| high_alarm.threshold = 80; | |||||
| high_alarm.message = "Temperature high"; | |||||
| const AlarmEditorResult high_result = editor_service.addDefinition(high_alarm); | |||||
| AlarmDefinition low_alarm; | |||||
| low_alarm.address = RegisterAddress{RegisterArea::D, 1}; | |||||
| low_alarm.condition = AlarmCondition::DLow; | |||||
| low_alarm.threshold = 10; | |||||
| low_alarm.message = "Pressure low"; | |||||
| const AlarmEditorResult low_result = editor_service.addDefinition(low_alarm); | |||||
| require(m_result.succeeded && high_result.succeeded && low_result.succeeded, | |||||
| "M, D high and D low alarm definitions must be accepted"); | |||||
| AlarmDefinition invalid = high_alarm; | |||||
| invalid.address = RegisterAddress{RegisterArea::M, 2}; | |||||
| require(editor_service.addDefinition(invalid).error | |||||
| == AlarmEditorError::InvalidDefinition, | |||||
| "D alarm conditions must reject M addresses"); | |||||
| repository.writeWord(RegisterAddress{RegisterArea::D, 1}, 20); | |||||
| alarm_service.refresh(); | |||||
| require(alarm_service.records().empty(), | |||||
| "inactive alarm conditions must not create records"); | |||||
| repository.writeBit(RegisterAddress{RegisterArea::M, 0}, true); | |||||
| repository.writeWord(RegisterAddress{RegisterArea::D, 0}, 80); | |||||
| repository.writeWord(RegisterAddress{RegisterArea::D, 1}, 10); | |||||
| alarm_service.refresh(); | |||||
| require(findRecord(alarm_service, m_result.id) != nullptr | |||||
| && findRecord(alarm_service, high_result.id) != nullptr | |||||
| && findRecord(alarm_service, low_result.id) != nullptr, | |||||
| "all three alarm conditions must create active records"); | |||||
| require(alarm_service.acknowledge(m_result.id) | |||||
| && findRecord(alarm_service, m_result.id)->acknowledged, | |||||
| "an active alarm must be locally acknowledged"); | |||||
| repository.writeBit(RegisterAddress{RegisterArea::M, 0}, false); | |||||
| repository.writeWord(RegisterAddress{RegisterArea::D, 0}, 79); | |||||
| repository.writeWord(RegisterAddress{RegisterArea::D, 1}, 11); | |||||
| alarm_service.refresh(); | |||||
| require(alarm_service.records().empty(), | |||||
| "cleared conditions must immediately remove active alarms"); | |||||
| repository.writeBit(RegisterAddress{RegisterArea::M, 0}, true); | |||||
| alarm_service.refresh(); | |||||
| require(findRecord(alarm_service, m_result.id) != nullptr | |||||
| && alarm_service.records().size() == 1 | |||||
| && !alarm_service.records().front().acknowledged, | |||||
| "a repeated alarm must create a new unacknowledged occurrence"); | |||||
| alarm_service.reset(); | |||||
| require(alarm_service.records().empty(), | |||||
| "leaving runtime must clear session alarm records"); | |||||
| } | |||||
| } // namespace | |||||
| int main() | |||||
| { | |||||
| try | |||||
| { | |||||
| testAlarmDefinitionsAndRuntimeLifecycle(); | |||||
| } | |||||
| catch (const std::exception &error) | |||||
| { | |||||
| std::cerr << "alarm service tests failed: " << error.what() << '\n'; | |||||
| return 1; | |||||
| } | |||||
| std::cout << "alarm service tests passed\n"; | |||||
| return 0; | |||||
| } | |||||
| @@ -0,0 +1,24 @@ | |||||
| include(pri/test_defaults.pri) | |||||
| include(pri/test_layers.pri) | |||||
| TARGET = alarm_service_tests | |||||
| QT -= gui | |||||
| CONFIG += testcase | |||||
| SOURCES += \ | |||||
| alarm_service_tests.cpp \ | |||||
| $$DOMAIN_REGISTER_SOURCES \ | |||||
| $$DOMAIN_HMI_SOURCES \ | |||||
| $$DOMAIN_LOGIC_SOURCES \ | |||||
| $$DOMAIN_PROJECT_SOURCES \ | |||||
| $$SERVICE_PROJECT_SOURCES \ | |||||
| $$SERVICE_ALARM_SOURCES | |||||
| HEADERS += \ | |||||
| $$DOMAIN_REGISTER_HEADERS \ | |||||
| $$DOMAIN_HMI_HEADERS \ | |||||
| $$DOMAIN_LOGIC_HEADERS \ | |||||
| $$DOMAIN_PROJECT_HEADERS \ | |||||
| $$SERVICE_PROJECT_HEADERS \ | |||||
| $$SERVICE_ALARM_HEADERS \ | |||||
| $$TEST_SUPPORT_HEADERS | |||||
| @@ -1,25 +1,24 @@ | |||||
| #include "domain/control_logic_model.h" | #include "domain/control_logic_model.h" | ||||
| #include "domain/hmi_control_registry.h" | |||||
| #include "domain/hmi_model.h" | #include "domain/hmi_model.h" | ||||
| #include "domain/project_model.h" | #include "domain/project_model.h" | ||||
| #include "domain/project_limits.h" | |||||
| #include "domain/register_address.h" | #include "domain/register_address.h" | ||||
| #include "domain/register_repository.h" | #include "domain/register_repository.h" | ||||
| #include "domain/runtime_state.h" | #include "domain/runtime_state.h" | ||||
| #include "support/test_support.h" | |||||
| #include <cstdint> | #include <cstdint> | ||||
| #include <exception> | #include <exception> | ||||
| #include <functional> | |||||
| #include <iostream> | #include <iostream> | ||||
| #include <set> | |||||
| #include <stdexcept> | #include <stdexcept> | ||||
| #include <string> | #include <string> | ||||
| namespace { | namespace { | ||||
| void require(bool condition, const std::string &message) | |||||
| { | |||||
| if (!condition) | |||||
| { | |||||
| throw std::runtime_error(message); | |||||
| } | |||||
| } | |||||
| using TestSupport::require; | |||||
| void testRegisterAddressBoundaries() | void testRegisterAddressBoundaries() | ||||
| { | { | ||||
| @@ -76,6 +75,166 @@ void testRegisterRepositorySeparatesAreas() | |||||
| "M address must not be read as a word"); | "M address must not be read as a word"); | ||||
| } | } | ||||
| void testHmiControlRegistryCompleteness() | |||||
| { | |||||
| std::set<const HmiControlDescriptor *> descriptors; | |||||
| std::set<std::string> storage_names; | |||||
| std::set<std::string> id_prefixes; | |||||
| const std::size_t control_type_count = | |||||
| static_cast<std::size_t>(HmiControlType::Count); | |||||
| for (std::size_t index = 0; index < control_type_count; ++index) | |||||
| { | |||||
| const HmiControlType type = static_cast<HmiControlType>(index); | |||||
| const HmiControlDescriptor *descriptor = | |||||
| findHmiControlDescriptor(type); | |||||
| require(descriptor != nullptr, | |||||
| "every HMI control type must have one descriptor"); | |||||
| require(descriptor->type == type, | |||||
| "HMI type lookup must return the requested descriptor"); | |||||
| require(descriptors.emplace(descriptor).second, | |||||
| "each HMI control type must resolve to a different descriptor"); | |||||
| require(descriptor->storageName != nullptr | |||||
| && descriptor->storageName[0] != '\0', | |||||
| "HMI storage names must not be empty"); | |||||
| require(descriptor->displayName != nullptr | |||||
| && descriptor->displayName[0] != '\0', | |||||
| "HMI display names must not be empty"); | |||||
| require(descriptor->idPrefix != nullptr | |||||
| && descriptor->idPrefix[0] != '\0', | |||||
| "HMI id prefixes must not be empty"); | |||||
| require(descriptor->defaultText != nullptr, | |||||
| "HMI default text must not be null"); | |||||
| require(descriptor->defaultBounds.width > 0 | |||||
| && descriptor->defaultBounds.height > 0, | |||||
| "HMI default bounds must have a positive size"); | |||||
| require(findHmiControlDescriptor(descriptor->storageName) == descriptor, | |||||
| "HMI storage name lookup must return its registered descriptor"); | |||||
| require(storage_names.emplace(descriptor->storageName).second, | |||||
| "HMI storage names must be unique"); | |||||
| require(id_prefixes.emplace(descriptor->idPrefix).second, | |||||
| "HMI id prefixes must be unique"); | |||||
| const std::optional<RegisterArea> binding_area = | |||||
| hmiBindingArea(descriptor->bindingKind); | |||||
| switch (descriptor->runtimeValueKind) | |||||
| { | |||||
| case HmiRuntimeValueKind::Bit: | |||||
| { | |||||
| require(descriptor->bindingKind == HmiBindingKind::Bit | |||||
| && binding_area == RegisterArea::M, | |||||
| "bit runtime controls must bind to the M area"); | |||||
| break; | |||||
| } | |||||
| case HmiRuntimeValueKind::Word: | |||||
| { | |||||
| require(descriptor->bindingKind == HmiBindingKind::Word | |||||
| && binding_area == RegisterArea::D, | |||||
| "word runtime controls must bind to the D area"); | |||||
| break; | |||||
| } | |||||
| case HmiRuntimeValueKind::None: | |||||
| { | |||||
| require(descriptor->bindingKind == HmiBindingKind::None | |||||
| && !binding_area.has_value(), | |||||
| "static controls must not declare a register binding"); | |||||
| break; | |||||
| } | |||||
| } | |||||
| require(descriptor->requiresBindingForRunning | |||||
| == (descriptor->runtimeValueKind | |||||
| != HmiRuntimeValueKind::None), | |||||
| "runtime value controls must require a configured binding"); | |||||
| } | |||||
| require(findHmiControlDescriptor(HmiControlType::Count) == nullptr, | |||||
| "the HMI control count marker must not be registered"); | |||||
| require(findHmiControlDescriptor(static_cast<HmiControlType>(99)) == nullptr, | |||||
| "unknown HMI control types must not resolve"); | |||||
| require(findHmiControlDescriptor("unknown") == nullptr, | |||||
| "unknown HMI storage names must not resolve"); | |||||
| require(hmiBindingArea(HmiBindingKind::Bit) == RegisterArea::M, | |||||
| "bit bindings must use the M area"); | |||||
| require(hmiBindingArea(HmiBindingKind::Word) == RegisterArea::D, | |||||
| "word bindings must use the D area"); | |||||
| require(!hmiBindingArea(HmiBindingKind::None).has_value(), | |||||
| "controls without bindings must not resolve a register area"); | |||||
| } | |||||
| void testProgressBarConfigurationBoundaries() | |||||
| { | |||||
| const HmiProgressBarConfig config{-20, 80, true}; | |||||
| require(config.isValid(), "a progress bar range must accept increasing bounds"); | |||||
| require(config.percentageForValue(-20) == 0, | |||||
| "progress bar minimum must map to zero percent"); | |||||
| require(config.percentageForValue(30) == 50, | |||||
| "progress bar midpoint must map to fifty percent"); | |||||
| require(config.percentageForValue(80) == 100, | |||||
| "progress bar maximum must map to one hundred percent"); | |||||
| require(config.percentageForValue(-100) == 0 | |||||
| && config.percentageForValue(100) == 100, | |||||
| "progress bar percentages must clamp values outside the range"); | |||||
| HmiProgressBarConfig invalid_range{10, 10, true}; | |||||
| require(!invalid_range.isValid() | |||||
| && invalid_range.percentageForValue(10) == 0, | |||||
| "progress bar must reject an empty range defensively"); | |||||
| HmiControl progress; | |||||
| progress.id = "progress"; | |||||
| progress.type = HmiControlType::ProgressBar; | |||||
| progress.progressBar = config; | |||||
| require(progress.validate(), | |||||
| "an unbound progress bar with valid configuration must remain a valid draft"); | |||||
| require(!progress.isConfigured(), | |||||
| "an unbound progress bar must not be ready for running"); | |||||
| progress.binding = RegisterAddress{RegisterArea::D, 0}; | |||||
| require(progress.validate() && progress.isConfigured(), | |||||
| "a progress bar with a D binding must be ready for running"); | |||||
| progress.progressBar->maximumValue = progress.progressBar->minimumValue; | |||||
| require(!progress.validate(), | |||||
| "a progress bar with an invalid range must be rejected"); | |||||
| progress.progressBar = config; | |||||
| progress.binding = RegisterAddress{RegisterArea::M, 0}; | |||||
| require(!progress.validate(), | |||||
| "a progress bar must reject an M binding"); | |||||
| } | |||||
| Project makeValidProject(); | |||||
| void testHmiAppearancePropertyBoundaries() | |||||
| { | |||||
| // 外观属性必须在领域层拒绝格式错误,但不能影响未知扩展属性 | |||||
| Project project = makeValidProject(); | |||||
| HmiControl &button = project.hmiPages.front().controls.front(); | |||||
| button.properties[HmiAppearanceProperty::kTextColor] = "#E53935"; | |||||
| button.properties[HmiAppearanceProperty::kFontSize] = "18"; | |||||
| button.properties[HmiAppearanceProperty::kFontBold] = "true"; | |||||
| button.properties[HmiAppearanceProperty::kFontItalic] = "false"; | |||||
| require(project.validate(), "valid HMI appearance properties must pass validation"); | |||||
| button.properties[HmiAppearanceProperty::kTextColor] = "red"; | |||||
| require(!project.validate(), "text colors must use the #RRGGBB format"); | |||||
| project = makeValidProject(); | |||||
| HmiControl &font_control = project.hmiPages.front().controls.front(); | |||||
| font_control.properties[HmiAppearanceProperty::kFontSize] = "5"; | |||||
| require(!project.validate(), "font sizes below the minimum must be rejected"); | |||||
| font_control.properties[HmiAppearanceProperty::kFontSize] = "73"; | |||||
| require(!project.validate(), "font sizes above the maximum must be rejected"); | |||||
| font_control.properties[HmiAppearanceProperty::kFontSize] = "large"; | |||||
| require(!project.validate(), "non-numeric font sizes must be rejected"); | |||||
| project = makeValidProject(); | |||||
| HmiControl &style_control = project.hmiPages.front().controls.front(); | |||||
| style_control.properties[HmiAppearanceProperty::kFontBold] = "yes"; | |||||
| require(!project.validate(), "font style flags must be true or false"); | |||||
| project = makeValidProject(); | |||||
| project.hmiPages.front().controls.front().properties["legacyColor"] = "green"; | |||||
| require(project.validate(), "unknown HMI extension properties must remain supported"); | |||||
| } | |||||
| Project makeValidProject() | Project makeValidProject() | ||||
| { | { | ||||
| // 构造包含 HMI 绑定和完整梯形图网络的最小合法工程作为测试基线 | // 构造包含 HMI 绑定和完整梯形图网络的最小合法工程作为测试基线 | ||||
| @@ -169,7 +328,7 @@ void testMultiPageAndLogicDomainRules() | |||||
| disabled_draft.name = "Draft logic"; | disabled_draft.name = "Draft logic"; | ||||
| disabled_draft.enabled = false; | disabled_draft.enabled = false; | ||||
| disabled_draft.rungs.push_back( | disabled_draft.rungs.push_back( | ||||
| {"rung-1", "Draft network", std::nullopt, std::nullopt}); | |||||
| {"rung-1", "Draft network", {}, std::nullopt, std::nullopt}); | |||||
| project.controlLogics.push_back(disabled_draft); | project.controlLogics.push_back(disabled_draft); | ||||
| require(project.validateForRunning(), | require(project.validateForRunning(), | ||||
| "a disabled draft logic must not block offline running"); | "a disabled draft logic must not block offline running"); | ||||
| @@ -184,6 +343,107 @@ void testMultiPageAndLogicDomainRules() | |||||
| require(!project.validate(), "unknown HMI control types must be rejected"); | require(!project.validate(), "unknown HMI control types must be rejected"); | ||||
| } | } | ||||
| void testQuantityBoundaries() | |||||
| { | |||||
| Project project = makeValidProject(); | |||||
| for (std::size_t index = 1U; index < ProjectLimits::kMaximumHmiPages; ++index) | |||||
| { | |||||
| project.hmiPages.push_back({ | |||||
| "page-" + std::to_string(index), | |||||
| "Page " + std::to_string(index), | |||||
| 800, | |||||
| 400, | |||||
| {}}); | |||||
| } | |||||
| require(project.validate(), "an HMI page count of 128 must be accepted"); | |||||
| project.hmiPages.push_back({"page-over", "Page over", 800, 400, {}}); | |||||
| require(!project.validate(), "an HMI page count of 129 must be rejected"); | |||||
| project = makeValidProject(); | |||||
| project.hmiPages.front().controls.clear(); | |||||
| for (std::size_t index = 0U; | |||||
| index < ProjectLimits::kMaximumHmiControlsPerPage; | |||||
| ++index) | |||||
| { | |||||
| HmiControl label; | |||||
| label.id = "label-" + std::to_string(index); | |||||
| label.type = HmiControlType::Label; | |||||
| label.bounds = {0, 0, 1, 1}; | |||||
| label.text = "label"; | |||||
| project.hmiPages.front().controls.push_back(std::move(label)); | |||||
| } | |||||
| require(project.validate(), "a page control count of 512 must be accepted"); | |||||
| HmiControl extra_label; | |||||
| extra_label.id = "label-over"; | |||||
| extra_label.type = HmiControlType::Label; | |||||
| extra_label.bounds = {0, 0, 1, 1}; | |||||
| extra_label.text = "label"; | |||||
| project.hmiPages.front().controls.push_back(std::move(extra_label)); | |||||
| require(!project.validate(), "a page control count of 513 must be rejected"); | |||||
| project = makeValidProject(); | |||||
| project.hmiPages.front().width = ProjectLimits::kMaximumHmiPageWidth; | |||||
| project.hmiPages.front().height = ProjectLimits::kMaximumHmiPageHeight; | |||||
| require(project.validate(), "an HMI page size of 1600 by 800 must be accepted"); | |||||
| project.hmiPages.front().width = ProjectLimits::kMaximumHmiPageWidth + 1; | |||||
| require(!project.validate(), "an HMI page width of 1601 must be rejected"); | |||||
| project.hmiPages.front().width = ProjectLimits::kMinimumHmiPageWidth - 1; | |||||
| require(!project.validate(), "an HMI page width of 319 must be rejected"); | |||||
| project.hmiPages.front().width = ProjectLimits::kMaximumHmiPageWidth; | |||||
| project.hmiPages.front().height = ProjectLimits::kMinimumHmiPageHeight - 1; | |||||
| require(!project.validate(), "an HMI page height of 199 must be rejected"); | |||||
| ConditionExpression leaf = ConditionExpression::fromNode({ | |||||
| "depth-node-0", | |||||
| ContactNodeConfig{RegisterAddress{RegisterArea::M, 0}}, | |||||
| true}); | |||||
| std::function<ConditionExpression(int, int *)> makeNested = | |||||
| [&makeNested](int depth, int *next_address) | |||||
| { | |||||
| if (depth == 1) | |||||
| { | |||||
| const int address = (*next_address)++; | |||||
| return ConditionExpression::fromNode({ | |||||
| "depth-node-" + std::to_string(address), | |||||
| ContactNodeConfig{RegisterAddress{RegisterArea::M, address}}, | |||||
| true}); | |||||
| } | |||||
| const int address = (*next_address)++; | |||||
| ConditionExpression expression; | |||||
| expression.id = "depth-expression-" + std::to_string(address); | |||||
| expression.kind = depth % 2 == 0 | |||||
| ? ConditionExpressionKind::Parallel | |||||
| : ConditionExpressionKind::Series; | |||||
| expression.children = { | |||||
| makeNested(depth - 1, next_address), | |||||
| ConditionExpression::fromNode({ | |||||
| "depth-node-" + std::to_string(address), | |||||
| ContactNodeConfig{RegisterAddress{RegisterArea::M, address}}, | |||||
| true})}; | |||||
| return expression; | |||||
| }; | |||||
| int next_address = 1; | |||||
| ConditionExpression maximum_depth = makeNested( | |||||
| static_cast<int>(ProjectLimits::kMaximumExpressionDepth), &next_address); | |||||
| require(maximum_depth.validate(), | |||||
| "an expression depth of 20 must be accepted"); | |||||
| ConditionExpression excessive_depth = makeNested( | |||||
| static_cast<int>(ProjectLimits::kMaximumExpressionDepth) + 1, | |||||
| &next_address); | |||||
| require(!excessive_depth.validate(), | |||||
| "an expression depth of 21 must be rejected"); | |||||
| CounterNodeConfig counter_config{ | |||||
| CounterAddress{0}, | |||||
| CounterMode::Up, | |||||
| RegisterAddress{RegisterArea::D, 0}, | |||||
| WordOperand{WordOperandKind::Constant, RegisterAddress{RegisterArea::D, 0}, -1}, | |||||
| RegisterAddress{RegisterArea::M, 0}}; | |||||
| LogicNode counter{"counter-negative-preset", counter_config, true}; | |||||
| require(!counter.validate(), "a negative constant counter preset must be rejected"); | |||||
| (void)leaf; | |||||
| } | |||||
| void testLogicNodeConfigurationBoundaries() | void testLogicNodeConfigurationBoundaries() | ||||
| { | { | ||||
| // 触点只能绑定 M 区,数值比较只能绑定 D 区 | // 触点只能绑定 M 区,数值比较只能绑定 D 区 | ||||
| @@ -208,6 +468,245 @@ void testLogicNodeConfigurationBoundaries() | |||||
| require(comparison.validate(), "comparison node bound to D address must be valid"); | require(comparison.validate(), "comparison node bound to D address must be valid"); | ||||
| } | } | ||||
| void testTimerAndCommentBoundaries() | |||||
| { | |||||
| LogicNode edge; | |||||
| edge.id = "edge"; | |||||
| edge.config = EdgeContactNodeConfig{ | |||||
| RegisterAddress{RegisterArea::M, 0}, EdgeMode::Rising}; | |||||
| require(edge.validate(), "a valid rising edge contact must pass validation"); | |||||
| LogicNode timer_contact; | |||||
| timer_contact.id = "timer-contact"; | |||||
| timer_contact.config = TimerContactNodeConfig{ | |||||
| TimerAddress{4000}, ContactMode::NormallyOpen}; | |||||
| require(timer_contact.validate(), "T4000 must be a valid timer contact"); | |||||
| timer_contact.config = TimerContactNodeConfig{ | |||||
| TimerAddress{-1}, ContactMode::NormallyOpen}; | |||||
| require(!timer_contact.validate(), "a negative T address must be rejected"); | |||||
| LogicNode ton; | |||||
| ton.id = "ton"; | |||||
| ton.config = TonNodeConfig{TimerAddress{0}, TonNodeConfig::kMinimumPresetMs}; | |||||
| require(ton.validate(), "the minimum TON preset must be valid"); | |||||
| ton.config = TonNodeConfig{TimerAddress{0}, 0}; | |||||
| require(!ton.validate(), "a zero TON preset must be rejected"); | |||||
| ton.config = TonNodeConfig{ | |||||
| TimerAddress{0}, TonNodeConfig::kMaximumPresetMs + 1}; | |||||
| require(!ton.validate(), "an oversized TON preset must be rejected"); | |||||
| RegisterComment comment{RegisterAddress{RegisterArea::M, 0}, "启动按钮"}; | |||||
| require(comment.validate(), "a nonblank register comment must be valid"); | |||||
| comment.text.assign(ProjectLimits::kMaximumRegisterCommentBytes, 'a'); | |||||
| require(comment.validate(), "a register comment at the byte limit must be valid"); | |||||
| comment.text.push_back('a'); | |||||
| require(!comment.validate(), "a register comment above the byte limit must fail"); | |||||
| comment.text = "启动\n按钮"; | |||||
| require(!comment.validate(), "a multiline register comment must be rejected"); | |||||
| comment.text = "启动\r按钮"; | |||||
| require(!comment.validate(), "a register comment containing CR must be rejected"); | |||||
| comment.text = " \t"; | |||||
| require(!comment.validate(), "a blank register comment must be rejected"); | |||||
| LadderRung comment_rung; | |||||
| comment_rung.id = "comment-rung"; | |||||
| comment_rung.name = "Comment rung"; | |||||
| comment_rung.comment.assign(ProjectLimits::kMaximumRungCommentBytes, 'a'); | |||||
| require(comment_rung.validate(), "a rung comment at the byte limit must be valid"); | |||||
| comment_rung.comment.push_back('a'); | |||||
| require(!comment_rung.validate(), "a rung comment above the byte limit must fail"); | |||||
| comment_rung.comment = "第一行\n第二行"; | |||||
| require(!comment_rung.validate(), "a multiline rung comment must be rejected"); | |||||
| comment_rung.comment = "第一行\r第二行"; | |||||
| require(!comment_rung.validate(), "a rung comment containing CR must be rejected"); | |||||
| Project project = makeValidProject(); | |||||
| project.registerComments = { | |||||
| {RegisterAddress{RegisterArea::M, 0}, "启动按钮"}, | |||||
| {RegisterAddress{RegisterArea::M, 0}, "重复地址"}}; | |||||
| require(!project.validate(), "duplicate register comments must be rejected"); | |||||
| } | |||||
| LadderRung makeTimerRung( | |||||
| const std::string &rung_id, | |||||
| const std::string &condition_id, | |||||
| const std::string &output_id, | |||||
| int timer_index, | |||||
| int preset_ms) | |||||
| { | |||||
| LogicNode condition; | |||||
| condition.id = condition_id; | |||||
| condition.config = ContactNodeConfig{ | |||||
| RegisterAddress{RegisterArea::M, timer_index}, ContactMode::NormallyOpen}; | |||||
| LogicNode output; | |||||
| output.id = output_id; | |||||
| output.config = TonNodeConfig{TimerAddress{timer_index}, preset_ms}; | |||||
| LadderRung rung; | |||||
| rung.id = rung_id; | |||||
| rung.name = rung_id; | |||||
| rung.condition = ConditionExpression::fromNode(condition); | |||||
| rung.output = output; | |||||
| return rung; | |||||
| } | |||||
| void testTimerReferencesForRunning() | |||||
| { | |||||
| ControlLogic valid; | |||||
| valid.id = "timer-valid"; | |||||
| valid.name = "Timer valid"; | |||||
| valid.rungs.push_back(makeTimerRung("rung-0", "input-0", "ton-0", 0, 100)); | |||||
| require(validateTimerReferencesForRunning({valid}), | |||||
| "a timer contact-free TON network must pass timer reference validation"); | |||||
| ControlLogic duplicate = valid; | |||||
| duplicate.id = "timer-duplicate"; | |||||
| duplicate.name = "Timer duplicate"; | |||||
| duplicate.rungs.front().output->id = "ton-duplicate"; | |||||
| require(!validateTimerReferencesForRunning({valid, duplicate}), | |||||
| "the same T must not have two enabled TON drivers"); | |||||
| ControlLogic missing; | |||||
| missing.id = "timer-missing"; | |||||
| missing.name = "Timer missing"; | |||||
| LogicNode contact; | |||||
| contact.id = "missing-contact"; | |||||
| contact.config = TimerContactNodeConfig{ | |||||
| TimerAddress{7}, ContactMode::NormallyOpen}; | |||||
| LogicNode output; | |||||
| output.id = "missing-coil"; | |||||
| output.config = CoilNodeConfig{ | |||||
| RegisterAddress{RegisterArea::M, 7}, CoilMode::Normal}; | |||||
| LadderRung missing_rung; | |||||
| missing_rung.id = "missing-rung"; | |||||
| missing_rung.name = "Missing rung"; | |||||
| missing_rung.condition = ConditionExpression::fromNode(contact); | |||||
| missing_rung.output = output; | |||||
| missing.rungs.push_back(missing_rung); | |||||
| require(!validateTimerReferencesForRunning({missing}), | |||||
| "a T contact without an enabled TON driver must be rejected"); | |||||
| missing.enabled = false; | |||||
| require(validateTimerReferencesForRunning({missing}), | |||||
| "disabled timer drafts must not block runtime timer validation"); | |||||
| } | |||||
| LadderRung makeCounterRung( | |||||
| const std::string &rung_id, | |||||
| const std::string &output_id, | |||||
| int counter_index) | |||||
| { | |||||
| LogicNode condition; | |||||
| condition.id = rung_id + "-input"; | |||||
| condition.config = ContactNodeConfig{ | |||||
| RegisterAddress{RegisterArea::M, counter_index}, | |||||
| ContactMode::NormallyOpen}; | |||||
| LogicNode output; | |||||
| output.id = output_id; | |||||
| output.config = CounterNodeConfig{ | |||||
| CounterAddress{counter_index}, | |||||
| CounterMode::Up, | |||||
| RegisterAddress{RegisterArea::D, counter_index}, | |||||
| WordOperand{ | |||||
| WordOperandKind::Constant, | |||||
| RegisterAddress{RegisterArea::D, 0}, | |||||
| 10}, | |||||
| RegisterAddress{RegisterArea::M, counter_index + 1}}; | |||||
| LadderRung rung; | |||||
| rung.id = rung_id; | |||||
| rung.name = rung_id; | |||||
| rung.condition = ConditionExpression::fromNode(condition); | |||||
| rung.output = output; | |||||
| return rung; | |||||
| } | |||||
| void testCounterAndDataInstructionBoundaries() | |||||
| { | |||||
| require(CounterAddress{0}.isValid() && CounterAddress{4000}.isValid(), | |||||
| "C0 and C4000 must be valid counter resources"); | |||||
| require(!CounterAddress{-1}.isValid() && !CounterAddress{4001}.isValid(), | |||||
| "counter resources outside 0 through 4000 must be rejected"); | |||||
| LogicNode counter; | |||||
| counter.id = "counter"; | |||||
| counter.config = CounterNodeConfig{ | |||||
| CounterAddress{0}, | |||||
| CounterMode::Up, | |||||
| RegisterAddress{RegisterArea::D, 10}, | |||||
| WordOperand{ | |||||
| WordOperandKind::Register, | |||||
| RegisterAddress{RegisterArea::D, 11}, | |||||
| 0}, | |||||
| RegisterAddress{RegisterArea::M, 12}}; | |||||
| require(counter.validate(), | |||||
| "a counter with C identity and external M/D addresses must be valid"); | |||||
| CounterNodeConfig invalid_counter = std::get<CounterNodeConfig>(counter.config); | |||||
| invalid_counter.currentValueAddress = RegisterAddress{RegisterArea::M, 10}; | |||||
| counter.config = invalid_counter; | |||||
| require(!counter.validate(), "counter CV must reject M addresses"); | |||||
| LogicNode move; | |||||
| move.id = "move"; | |||||
| move.config = MoveNodeConfig{ | |||||
| WordOperand{ | |||||
| WordOperandKind::Constant, | |||||
| RegisterAddress{RegisterArea::D, 0}, | |||||
| -100}, | |||||
| RegisterAddress{RegisterArea::D, 20}}; | |||||
| require(move.validate() && move.isOutput(), | |||||
| "MOVE with a constant source and D destination must be a valid output"); | |||||
| LogicNode add; | |||||
| add.id = "add"; | |||||
| add.config = ArithmeticNodeConfig{ | |||||
| ArithmeticOperation::Add, | |||||
| WordOperand{ | |||||
| WordOperandKind::Register, | |||||
| RegisterAddress{RegisterArea::D, 20}, | |||||
| 0}, | |||||
| WordOperand{ | |||||
| WordOperandKind::Constant, | |||||
| RegisterAddress{RegisterArea::D, 0}, | |||||
| 1}, | |||||
| RegisterAddress{RegisterArea::D, 20}}; | |||||
| require(add.validate() && add.isOutput(), | |||||
| "ADD must allow the same D register as source and destination"); | |||||
| ControlLogic valid; | |||||
| valid.id = "counter-valid"; | |||||
| valid.name = "Counter valid"; | |||||
| valid.rungs.push_back(makeCounterRung("counter-rung", "ctu-0", 0)); | |||||
| require(validateCounterReferencesForRunning({valid}), | |||||
| "a counter output without contacts must pass reference validation"); | |||||
| ControlLogic duplicate = valid; | |||||
| duplicate.id = "counter-duplicate"; | |||||
| duplicate.name = "Counter duplicate"; | |||||
| duplicate.rungs.front().output->id = "ctu-duplicate"; | |||||
| require(!validateCounterReferencesForRunning({valid, duplicate}), | |||||
| "the same C resource must not have multiple enabled drivers"); | |||||
| ControlLogic missing; | |||||
| missing.id = "counter-missing"; | |||||
| missing.name = "Counter missing"; | |||||
| LogicNode missing_contact; | |||||
| missing_contact.id = "missing-counter-contact"; | |||||
| missing_contact.config = CounterContactNodeConfig{ | |||||
| CounterAddress{7}, ContactMode::NormallyOpen}; | |||||
| LogicNode output; | |||||
| output.id = "missing-counter-coil"; | |||||
| output.config = CoilNodeConfig{ | |||||
| RegisterAddress{RegisterArea::M, 7}, CoilMode::Normal}; | |||||
| LadderRung missing_rung; | |||||
| missing_rung.id = "missing-counter-rung"; | |||||
| missing_rung.name = "Missing counter rung"; | |||||
| missing_rung.condition = ConditionExpression::fromNode(missing_contact); | |||||
| missing_rung.output = output; | |||||
| missing.rungs.push_back(missing_rung); | |||||
| require(!validateCounterReferencesForRunning({missing}), | |||||
| "a C contact without an enabled counter driver must be rejected"); | |||||
| } | |||||
| void testLadderLogicBoundaries() | void testLadderLogicBoundaries() | ||||
| { | { | ||||
| LogicNode stop; | LogicNode stop; | ||||
| @@ -257,6 +756,51 @@ void testLadderLogicBoundaries() | |||||
| logic.rungs.push_back(rung); | logic.rungs.push_back(rung); | ||||
| require(logic.validate(), "stop AND (start OR run) self-hold ladder must be valid"); | require(logic.validate(), "stop AND (start OR run) self-hold ladder must be valid"); | ||||
| ConditionExpression wire = ConditionExpression::fromWire("wire-1", 2); | |||||
| require(wire.validate() && wire.validateForRunning() | |||||
| && wire.wire->columnSpan == 2, | |||||
| "a configured horizontal wire must be a valid runnable expression leaf"); | |||||
| ConditionExpression invalid_wire = ConditionExpression::fromWire("wire-invalid", 0); | |||||
| require(!invalid_wire.validate(), "a zero-column horizontal wire must be rejected"); | |||||
| invalid_wire = ConditionExpression::fromWire( | |||||
| "wire-too-wide", WireSegment::kMaximumColumnSpan + 1); | |||||
| require(!invalid_wire.validate(), "an oversized horizontal wire must be rejected"); | |||||
| ConditionExpression maximum_columns; | |||||
| maximum_columns.id = "maximum-columns"; | |||||
| maximum_columns.kind = ConditionExpressionKind::Series; | |||||
| for (int column = 0; column < ProjectLimits::kMaximumConditionColumns; ++column) | |||||
| { | |||||
| LogicNode node; | |||||
| node.id = "column-" + std::to_string(column + 1); | |||||
| node.config = ContactNodeConfig{ | |||||
| RegisterAddress{RegisterArea::M, column}, | |||||
| ContactMode::NormallyOpen}; | |||||
| maximum_columns.children.push_back( | |||||
| ConditionExpression::fromNode(std::move(node))); | |||||
| } | |||||
| require(maximum_columns.validate(), | |||||
| "ten condition columns must be accepted"); | |||||
| LogicNode extra_column; | |||||
| extra_column.id = "column-11"; | |||||
| extra_column.config = ContactNodeConfig{ | |||||
| RegisterAddress{RegisterArea::M, 10}, | |||||
| ContactMode::NormallyOpen}; | |||||
| maximum_columns.children.push_back( | |||||
| ConditionExpression::fromNode(std::move(extra_column))); | |||||
| require(!maximum_columns.validate(), | |||||
| "an eleventh condition column must be rejected"); | |||||
| ConditionExpression wired_series; | |||||
| wired_series.id = "wired-series"; | |||||
| wired_series.kind = ConditionExpressionKind::Series; | |||||
| wired_series.children = { | |||||
| ConditionExpression::fromNode(stop), | |||||
| ConditionExpression::fromWire("wire-series"), | |||||
| start_parallel}; | |||||
| require(wired_series.validateForRunning(), | |||||
| "a wire must preserve a valid structured series expression"); | |||||
| logic.rungs.front().condition->children.front() = | logic.rungs.front().condition->children.front() = | ||||
| ConditionExpression::fromNode(coil); | ConditionExpression::fromNode(coil); | ||||
| require(!logic.validate(), "a ladder condition expression must reject coils"); | require(!logic.validate(), "a ladder condition expression must reject coils"); | ||||
| @@ -269,13 +813,14 @@ void testLadderLogicBoundaries() | |||||
| require(!logic.validateForRunning(), | require(!logic.validateForRunning(), | ||||
| "conditions without an output must block runtime validation"); | "conditions without an output must block runtime validation"); | ||||
| LadderRung empty_rung{"rung-empty", "Empty network", std::nullopt, std::nullopt}; | |||||
| LadderRung empty_rung{ | |||||
| "rung-empty", "Empty network", {}, std::nullopt, std::nullopt}; | |||||
| require(empty_rung.validate(), "an empty editing network must be valid"); | require(empty_rung.validate(), "an empty editing network must be valid"); | ||||
| empty_rung.output = coil; | empty_rung.output = coil; | ||||
| require(empty_rung.validate(), "output-only network may remain in an editable draft"); | require(empty_rung.validate(), "output-only network may remain in an editable draft"); | ||||
| require(!empty_rung.validateForRunning(), | |||||
| "an output without conditions must block runtime validation"); | |||||
| require(empty_rung.validateForRunning(), | |||||
| "an output-only network must be valid as an unconditional rung"); | |||||
| logic.rungs.front().output = coil; | logic.rungs.front().output = coil; | ||||
| logic.rungs.front().condition = root; | logic.rungs.front().condition = root; | ||||
| @@ -364,10 +909,17 @@ int main() | |||||
| testRegisterAddressBoundaries(); | testRegisterAddressBoundaries(); | ||||
| testRegisterAddressParsing(); | testRegisterAddressParsing(); | ||||
| testRegisterRepositorySeparatesAreas(); | testRegisterRepositorySeparatesAreas(); | ||||
| testHmiControlRegistryCompleteness(); | |||||
| testProgressBarConfigurationBoundaries(); | |||||
| testHmiAppearancePropertyBoundaries(); | |||||
| testLogicNodeConfigurationBoundaries(); | testLogicNodeConfigurationBoundaries(); | ||||
| testTimerAndCommentBoundaries(); | |||||
| testTimerReferencesForRunning(); | |||||
| testCounterAndDataInstructionBoundaries(); | |||||
| testLadderLogicBoundaries(); | testLadderLogicBoundaries(); | ||||
| testModelsValidateBindingsAndIdentifiers(); | testModelsValidateBindingsAndIdentifiers(); | ||||
| testMultiPageAndLogicDomainRules(); | testMultiPageAndLogicDomainRules(); | ||||
| testQuantityBoundaries(); | |||||
| testRuntimeStateBoundaries(); | testRuntimeStateBoundaries(); | ||||
| } | } | ||||
| catch (const std::exception &error) | catch (const std::exception &error) | ||||
| @@ -1,24 +1,14 @@ | |||||
| TEMPLATE = app | |||||
| TARGET = domain_tests | |||||
| CONFIG += console c++17 testcase warn_on | |||||
| CONFIG -= app_bundle qt | |||||
| include(pri/test_defaults.pri) | |||||
| include(pri/test_layers.pri) | |||||
| INCLUDEPATH += ../src | |||||
| TARGET = domain_tests | |||||
| QT -= gui | |||||
| CONFIG += testcase | |||||
| SOURCES += \ | SOURCES += \ | ||||
| domain_tests.cpp \ | domain_tests.cpp \ | ||||
| ../src/domain/register_address.cpp \ | |||||
| ../src/domain/register_repository.cpp \ | |||||
| ../src/domain/hmi_model.cpp \ | |||||
| ../src/domain/control_logic_model.cpp \ | |||||
| ../src/domain/project_model.cpp \ | |||||
| ../src/domain/runtime_state.cpp | |||||
| $$DOMAIN_ALL_SOURCES | |||||
| HEADERS += \ | HEADERS += \ | ||||
| ../src/domain/register_address.h \ | |||||
| ../src/domain/register_repository.h \ | |||||
| ../src/domain/hmi_model.h \ | |||||
| ../src/domain/control_logic_model.h \ | |||||
| ../src/domain/project_model.h \ | |||||
| ../src/domain/runtime_state.h | |||||
| $$DOMAIN_ALL_HEADERS \ | |||||
| $$TEST_SUPPORT_HEADERS | |||||
| @@ -4,6 +4,7 @@ | |||||
| #include "services/hmi_runtime_service.h" | #include "services/hmi_runtime_service.h" | ||||
| #include "services/hmi_navigation_service.h" | #include "services/hmi_navigation_service.h" | ||||
| #include "services/project_service.h" | #include "services/project_service.h" | ||||
| #include "support/test_support.h" | |||||
| #include <exception> | #include <exception> | ||||
| #include <iostream> | #include <iostream> | ||||
| @@ -12,28 +13,8 @@ | |||||
| namespace { | namespace { | ||||
| class TestProjectStorage final : public ProjectStorage | |||||
| { | |||||
| public: | |||||
| // 编辑服务测试只关注内存模型变更,不依赖真实文件系统 | |||||
| ProjectSaveResult save(const Project &, const std::string &) override | |||||
| { | |||||
| return {true, ProjectStorageError::None, {}}; | |||||
| } | |||||
| ProjectLoadResult load(const std::string &) override | |||||
| { | |||||
| return {false, {}, ProjectStorageError::FileReadFailed, {}}; | |||||
| } | |||||
| }; | |||||
| void require(bool condition, const std::string &message) | |||||
| { | |||||
| if (!condition) | |||||
| { | |||||
| throw std::runtime_error(message); | |||||
| } | |||||
| } | |||||
| using TestProjectStorage = TestSupport::InMemoryProjectStorage; | |||||
| using TestSupport::require; | |||||
| void testControlEditing() | void testControlEditing() | ||||
| { | { | ||||
| @@ -50,12 +31,26 @@ void testControlEditing() | |||||
| const HmiEditorResult display = service.addControl( | const HmiEditorResult display = service.addControl( | ||||
| page_id, HmiControlType::NumericDisplay); | page_id, HmiControlType::NumericDisplay); | ||||
| const HmiEditorResult input = service.addControl(page_id, HmiControlType::NumericInput); | const HmiEditorResult input = service.addControl(page_id, HmiControlType::NumericInput); | ||||
| require(button.succeeded && indicator.succeeded && display.succeeded && input.succeeded, | |||||
| "four basic HMI controls must be added"); | |||||
| const HmiEditorResult progress = service.addControl( | |||||
| page_id, HmiControlType::ProgressBar); | |||||
| require(button.succeeded && indicator.succeeded && display.succeeded | |||||
| && input.succeeded && progress.succeeded, | |||||
| "basic HMI controls must be added"); | |||||
| const HmiPage *page = service.findPage(page_id); | const HmiPage *page = service.findPage(page_id); | ||||
| require(page != nullptr && page->controls.size() == 4, | |||||
| require(page != nullptr && page->controls.size() == 5, | |||||
| "all added controls must be kept in the page model"); | "all added controls must be kept in the page model"); | ||||
| const HmiControl *progress_control = service.findControl(page_id, progress.id); | |||||
| require(progress_control != nullptr | |||||
| && progress_control->id == "progress-bar-1" | |||||
| && progress_control->text == "进度" | |||||
| && progress_control->bounds.width == 240 | |||||
| && progress_control->bounds.height == 36 | |||||
| && progress_control->progressBar.has_value() | |||||
| && progress_control->progressBar->minimumValue == 0 | |||||
| && progress_control->progressBar->maximumValue == 100 | |||||
| && progress_control->progressBar->showValue, | |||||
| "new progress bars must use the registered defaults"); | |||||
| require(service.moveControl(page_id, button.id, {120, 80, 120, 40}).succeeded, | require(service.moveControl(page_id, button.id, {120, 80, 120, 40}).succeeded, | ||||
| "a valid control move must succeed"); | "a valid control move must succeed"); | ||||
| require(!service.moveControl(page_id, button.id, {790, 460, 120, 40}).succeeded, | require(!service.moveControl(page_id, button.id, {790, 460, 120, 40}).succeeded, | ||||
| @@ -80,6 +75,20 @@ void testControlEditing() | |||||
| "deleting a selected control must succeed"); | "deleting a selected control must succeed"); | ||||
| require(service.findControl(page_id, indicator.id) == nullptr, | require(service.findControl(page_id, indicator.id) == nullptr, | ||||
| "deleted controls must not remain in the model"); | "deleted controls must not remain in the model"); | ||||
| HmiControl configured_progress = *service.findControl(page_id, progress.id); | |||||
| configured_progress.binding = RegisterAddress{RegisterArea::D, 8}; | |||||
| configured_progress.progressBar->minimumValue = -10; | |||||
| configured_progress.progressBar->maximumValue = 90; | |||||
| require(service.updateControl(page_id, progress.id, configured_progress).succeeded, | |||||
| "a progress bar must accept a valid D binding and value range"); | |||||
| configured_progress.progressBar->maximumValue = -10; | |||||
| require(!service.updateControl(page_id, progress.id, configured_progress).succeeded, | |||||
| "a progress bar must reject a non-increasing value range"); | |||||
| configured_progress.progressBar->maximumValue = 90; | |||||
| configured_progress.binding = RegisterAddress{RegisterArea::M, 8}; | |||||
| require(!service.updateControl(page_id, progress.id, configured_progress).succeeded, | |||||
| "a progress bar must reject an M binding"); | |||||
| } | } | ||||
| void testRuntimeUsesRegisterRepository() | void testRuntimeUsesRegisterRepository() | ||||
| @@ -147,6 +156,110 @@ void testRuntimeUsesRegisterRepository() | |||||
| const HmiRuntimeReadResult numeric_value = runtime_service.readControl(numeric_display); | const HmiRuntimeReadResult numeric_value = runtime_service.readControl(numeric_display); | ||||
| require(numeric_value.succeeded && numeric_value.word_value == -18, | require(numeric_value.succeeded && numeric_value.word_value == -18, | ||||
| "numeric display must read D values through the repository"); | "numeric display must read D values through the repository"); | ||||
| HmiControl progress; | |||||
| progress.id = "progress"; | |||||
| progress.type = HmiControlType::ProgressBar; | |||||
| progress.binding = RegisterAddress{RegisterArea::D, 9}; | |||||
| progress.progressBar = HmiProgressBarConfig{-20, 80, true}; | |||||
| const HmiRuntimeReadResult progress_value = runtime_service.readControl(progress); | |||||
| require(progress_value.succeeded && progress_value.word_value == -18, | |||||
| "progress bars must read D values through the repository"); | |||||
| } | |||||
| void testHistoryAndAtomicBatchDelete() | |||||
| { | |||||
| TestProjectStorage storage; | |||||
| ProjectService project_service(storage); | |||||
| HmiEditorService service(project_service); | |||||
| const std::string page_id = service.ensureDefaultPage().id; | |||||
| const HmiEditorResult first = service.addControl(page_id, HmiControlType::Label); | |||||
| const HmiEditorResult second = service.addControl(page_id, HmiControlType::Label); | |||||
| const HmiEditorResult third = service.addControl(page_id, HmiControlType::Label); | |||||
| require(first.succeeded && second.succeeded && third.succeeded, | |||||
| "controls for history testing must be created"); | |||||
| service.clearHistory(); | |||||
| require(!service.removeControls(page_id, {first.id, "missing-control"}).succeeded, | |||||
| "batch deletion must reject an unknown control before changing the page"); | |||||
| require(service.findPage(page_id)->controls.size() == 3U | |||||
| && !service.canUndo(), | |||||
| "failed batch deletion must be atomic and leave history unchanged"); | |||||
| require(service.removeControls(page_id, {first.id, second.id}).succeeded, | |||||
| "batch deletion of valid controls must succeed"); | |||||
| require(service.findPage(page_id)->controls.size() == 1U | |||||
| && service.canUndo(), | |||||
| "valid batch deletion must remove all selected controls in one history step"); | |||||
| require(service.undo().succeeded && service.findPage(page_id)->controls.size() == 3U, | |||||
| "HMI undo must restore a deleted batch"); | |||||
| require(service.redo().succeeded && service.findPage(page_id)->controls.size() == 1U, | |||||
| "HMI redo must reapply a deleted batch"); | |||||
| service.clearHistory(); | |||||
| const HmiControl *remaining = service.findControl(page_id, third.id); | |||||
| require(remaining != nullptr, | |||||
| "the unselected control must survive a batch deletion"); | |||||
| require(service.moveControl(page_id, third.id, remaining->bounds).succeeded | |||||
| && !service.canUndo(), | |||||
| "a no-op control move must not consume an undo step"); | |||||
| service.clearHistory(); | |||||
| HmiControl candidate = *service.findControl(page_id, third.id); | |||||
| for (int index = 1; index <= 101; ++index) | |||||
| { | |||||
| candidate.bounds.x = index; | |||||
| require(service.updateControl(page_id, third.id, candidate).succeeded, | |||||
| "repeated valid HMI edits must succeed"); | |||||
| } | |||||
| int undo_count = 0; | |||||
| while (service.undo().succeeded) | |||||
| { | |||||
| ++undo_count; | |||||
| } | |||||
| require(undo_count == 100, | |||||
| "HMI history must retain exactly the configured 100 most recent steps"); | |||||
| } | |||||
| void testAppearanceEditing() | |||||
| { | |||||
| TestProjectStorage storage; | |||||
| ProjectService project_service(storage); | |||||
| HmiEditorService service(project_service); | |||||
| const std::string page_id = service.ensureDefaultPage().id; | |||||
| const HmiEditorResult label = service.addControl(page_id, HmiControlType::Label); | |||||
| require(label.succeeded, "a label must be available for appearance editing"); | |||||
| HmiControl appearance = *service.findControl(page_id, label.id); | |||||
| appearance.properties[HmiAppearanceProperty::kTextColor] = "#E53935"; | |||||
| appearance.properties[HmiAppearanceProperty::kFontSize] = "18"; | |||||
| appearance.properties[HmiAppearanceProperty::kFontBold] = "true"; | |||||
| appearance.properties[HmiAppearanceProperty::kFontItalic] = "false"; | |||||
| require(service.updateControl(page_id, label.id, appearance).succeeded, | |||||
| "valid appearance properties must be applied atomically"); | |||||
| const HmiControl *updated = service.findControl(page_id, label.id); | |||||
| require(updated != nullptr | |||||
| && updated->properties.at(HmiAppearanceProperty::kTextColor) == "#E53935" | |||||
| && updated->properties.at(HmiAppearanceProperty::kFontSize) == "18" | |||||
| && updated->properties.at(HmiAppearanceProperty::kFontBold) == "true", | |||||
| "appearance properties must be stored on the HMI control"); | |||||
| HmiControl invalid = *updated; | |||||
| invalid.properties[HmiAppearanceProperty::kTextColor] = "invalid"; | |||||
| require(!service.updateControl(page_id, label.id, invalid).succeeded, | |||||
| "invalid appearance properties must be rejected without a partial update"); | |||||
| require(service.findControl(page_id, label.id)->properties.at( | |||||
| HmiAppearanceProperty::kTextColor) == "#E53935", | |||||
| "failed appearance updates must leave the old color intact"); | |||||
| require(service.undo().succeeded, | |||||
| "appearance updates must participate in HMI undo history"); | |||||
| require(service.findControl(page_id, label.id)->properties.empty(), | |||||
| "undo must remove the applied appearance properties"); | |||||
| require(service.redo().succeeded | |||||
| && service.findControl(page_id, label.id)->properties.at( | |||||
| HmiAppearanceProperty::kFontSize) == "18", | |||||
| "redo must restore the applied appearance properties"); | |||||
| } | } | ||||
| void testPageLifecycleAndNavigation() | void testPageLifecycleAndNavigation() | ||||
| @@ -175,6 +288,17 @@ void testPageLifecycleAndNavigation() | |||||
| require(label.succeeded | require(label.succeeded | ||||
| && service.findControl(main_id, label.id)->isConfigured(), | && service.findControl(main_id, label.id)->isConfigured(), | ||||
| "the editor service must expose a configured static Label control"); | "the editor service must expose a configured static Label control"); | ||||
| const HmiEditorResult alarm_list = service.addControl( | |||||
| main_id, HmiControlType::AlarmList); | |||||
| require(alarm_list.succeeded | |||||
| && service.findControl(main_id, alarm_list.id)->isConfigured() | |||||
| && !service.findControl(main_id, alarm_list.id)->binding.has_value(), | |||||
| "AlarmList must be configured without a register binding"); | |||||
| HmiControl bound_alarm_list = *service.findControl(main_id, alarm_list.id); | |||||
| bound_alarm_list.binding = RegisterAddress{RegisterArea::M, 20}; | |||||
| require(!service.updateControl( | |||||
| main_id, alarm_list.id, bound_alarm_list).succeeded, | |||||
| "AlarmList must reject direct register bindings"); | |||||
| const HmiEditorResult jump = service.addControl(main_id, HmiControlType::PageJump); | const HmiEditorResult jump = service.addControl(main_id, HmiControlType::PageJump); | ||||
| require(jump.succeeded, "the editor service must expose PageJump creation"); | require(jump.succeeded, "the editor service must expose PageJump creation"); | ||||
| HmiControl jump_control = *service.findControl(main_id, jump.id); | HmiControl jump_control = *service.findControl(main_id, jump.id); | ||||
| @@ -205,6 +329,43 @@ void testPageLifecycleAndNavigation() | |||||
| "stopping runtime navigation must clear session state"); | "stopping runtime navigation must clear session state"); | ||||
| } | } | ||||
| void testPageResizeIsAtomicAndUndoable() | |||||
| { | |||||
| TestProjectStorage storage; | |||||
| ProjectService project_service(storage); | |||||
| HmiEditorService service(project_service); | |||||
| const std::string page_id = service.ensureDefaultPage().id; | |||||
| const HmiEditorResult label = service.addControl(page_id, HmiControlType::Label); | |||||
| require(label.succeeded, "page resize fixture must add a control"); | |||||
| HmiControl control = *service.findControl(page_id, label.id); | |||||
| control.bounds = {700, 340, 80, 32}; | |||||
| require(service.updateControl(page_id, label.id, control).succeeded, | |||||
| "page resize fixture must place the control near the page edge"); | |||||
| require(service.resizePage(page_id, 1024, 600).succeeded, | |||||
| "an HMI page must be resizable to a larger valid rectangle"); | |||||
| require(service.findPage(page_id)->width == 1024 | |||||
| && service.findPage(page_id)->height == 600, | |||||
| "page resize must update both dimensions"); | |||||
| require(service.resizePage(page_id, 700, 300).error | |||||
| == HmiEditorError::InvalidPage, | |||||
| "page resize must reject dimensions that clip an existing control"); | |||||
| require(service.findPage(page_id)->width == 1024 | |||||
| && service.findPage(page_id)->height == 600, | |||||
| "a rejected page resize must leave the original dimensions intact"); | |||||
| require(service.undo().succeeded | |||||
| && service.findPage(page_id)->width == 800 | |||||
| && service.findPage(page_id)->height == 400, | |||||
| "page resize must participate in HMI undo history"); | |||||
| require(service.redo().succeeded | |||||
| && service.findPage(page_id)->width == 1024 | |||||
| && service.findPage(page_id)->height == 600, | |||||
| "page resize redo must restore the new dimensions"); | |||||
| require(service.resizePage(page_id, 319, 600).error | |||||
| == HmiEditorError::InvalidPage, | |||||
| "page width below the business minimum must be rejected"); | |||||
| } | |||||
| } // namespace | } // namespace | ||||
| int main() | int main() | ||||
| @@ -213,8 +374,11 @@ int main() | |||||
| { | { | ||||
| // 编辑和运行场景分别验证服务层两条独立职责 | // 编辑和运行场景分别验证服务层两条独立职责 | ||||
| testControlEditing(); | testControlEditing(); | ||||
| testHistoryAndAtomicBatchDelete(); | |||||
| testAppearanceEditing(); | |||||
| testRuntimeUsesRegisterRepository(); | testRuntimeUsesRegisterRepository(); | ||||
| testPageLifecycleAndNavigation(); | testPageLifecycleAndNavigation(); | ||||
| testPageResizeIsAtomicAndUndoable(); | |||||
| } | } | ||||
| catch (const std::exception &error) | catch (const std::exception &error) | ||||
| { | { | ||||
| @@ -1,31 +1,22 @@ | |||||
| TEMPLATE = app | |||||
| TARGET = hmi_editor_service_tests | |||||
| CONFIG += console c++17 warn_on | |||||
| CONFIG -= app_bundle | |||||
| include(pri/test_defaults.pri) | |||||
| include(pri/test_layers.pri) | |||||
| INCLUDEPATH += ../src | |||||
| TARGET = hmi_editor_service_tests | |||||
| SOURCES += \ | SOURCES += \ | ||||
| hmi_editor_service_tests.cpp \ | hmi_editor_service_tests.cpp \ | ||||
| ../src/domain/register_address.cpp \ | |||||
| ../src/domain/register_repository.cpp \ | |||||
| ../src/domain/hmi_model.cpp \ | |||||
| ../src/domain/control_logic_model.cpp \ | |||||
| ../src/domain/project_model.cpp \ | |||||
| ../src/services/project_service.cpp \ | |||||
| ../src/services/hmi_editor_service.cpp \ | |||||
| ../src/services/hmi_navigation_service.cpp \ | |||||
| ../src/services/hmi_runtime_service.cpp | |||||
| $$DOMAIN_REGISTER_SOURCES \ | |||||
| $$DOMAIN_HMI_SOURCES \ | |||||
| $$DOMAIN_LOGIC_SOURCES \ | |||||
| $$DOMAIN_PROJECT_SOURCES \ | |||||
| $$SERVICE_PROJECT_SOURCES \ | |||||
| $$SERVICE_HMI_SOURCES | |||||
| HEADERS += \ | HEADERS += \ | ||||
| ../src/domain/register_address.h \ | |||||
| ../src/domain/register_repository.h \ | |||||
| ../src/domain/hmi_model.h \ | |||||
| ../src/domain/control_logic_model.h \ | |||||
| ../src/domain/project_model.h \ | |||||
| ../src/domain/project_storage.h \ | |||||
| ../src/services/project_service.h \ | |||||
| ../src/services/hmi_editor_service.h \ | |||||
| ../src/services/hmi_navigation_service.h \ | |||||
| ../src/services/hmi_runtime_service.h | |||||
| $$DOMAIN_REGISTER_HEADERS \ | |||||
| $$DOMAIN_HMI_HEADERS \ | |||||
| $$DOMAIN_LOGIC_HEADERS \ | |||||
| $$DOMAIN_PROJECT_HEADERS \ | |||||
| $$SERVICE_PROJECT_HEADERS \ | |||||
| $$SERVICE_HMI_HEADERS \ | |||||
| $$TEST_SUPPORT_HEADERS | |||||
| @@ -1,37 +1,59 @@ | |||||
| #include "domain/project_storage.h" | #include "domain/project_storage.h" | ||||
| #include "domain/project_limits.h" | |||||
| #include "services/logic_editor_service.h" | #include "services/logic_editor_service.h" | ||||
| #include "services/project_service.h" | #include "services/project_service.h" | ||||
| #include "support/test_support.h" | |||||
| #include <algorithm> | |||||
| #include <iostream> | #include <iostream> | ||||
| #include <stdexcept> | #include <stdexcept> | ||||
| namespace { | namespace { | ||||
| class TestProjectStorage final : public ProjectStorage | |||||
| using TestProjectStorage = TestSupport::InMemoryProjectStorage; | |||||
| using TestSupport::require; | |||||
| ContactNodeConfig contact(int address) | |||||
| { | |||||
| return {RegisterAddress{RegisterArea::M, address}, ContactMode::NormallyOpen}; | |||||
| } | |||||
| int conditionColumns(const ConditionExpression &expression) | |||||
| { | { | ||||
| public: | |||||
| ProjectSaveResult save(const Project &, const std::string &) override | |||||
| if (expression.kind == ConditionExpressionKind::Node) | |||||
| { | { | ||||
| return {true, ProjectStorageError::None, {}}; | |||||
| return 1; | |||||
| } | } | ||||
| ProjectLoadResult load(const std::string &) override | |||||
| if (expression.kind == ConditionExpressionKind::Wire) | |||||
| { | { | ||||
| return {false, {}, ProjectStorageError::FileReadFailed, {}}; | |||||
| return expression.wire->columnSpan; | |||||
| } | } | ||||
| }; | |||||
| void require(bool condition, const std::string &message) | |||||
| { | |||||
| if (!condition) | |||||
| int columns = expression.kind == ConditionExpressionKind::Series ? 0 : 1; | |||||
| for (const ConditionExpression &child : expression.children) | |||||
| { | { | ||||
| throw std::runtime_error(message); | |||||
| const int child_columns = conditionColumns(child); | |||||
| columns = expression.kind == ConditionExpressionKind::Series | |||||
| ? columns + child_columns : std::max(columns, child_columns); | |||||
| } | } | ||||
| return columns; | |||||
| } | } | ||||
| ContactNodeConfig contact(int address) | |||||
| int wireColumns(const ConditionExpression &expression) | |||||
| { | { | ||||
| return {RegisterAddress{RegisterArea::M, address}, ContactMode::NormallyOpen}; | |||||
| if (expression.kind == ConditionExpressionKind::Node) | |||||
| { | |||||
| return 0; | |||||
| } | |||||
| if (expression.kind == ConditionExpressionKind::Wire) | |||||
| { | |||||
| return expression.wire->columnSpan; | |||||
| } | |||||
| int columns = 0; | |||||
| for (const ConditionExpression &child : expression.children) | |||||
| { | |||||
| columns += wireColumns(child); | |||||
| } | |||||
| return columns; | |||||
| } | } | ||||
| void testStructuredEditingAndNormalization() | void testStructuredEditingAndNormalization() | ||||
| @@ -132,6 +154,488 @@ void testRangeParallelInsertion() | |||||
| "a non-contiguous selection must be rejected"); | "a non-contiguous selection must be rejected"); | ||||
| } | } | ||||
| void testStructuredWireEditing() | |||||
| { | |||||
| TestProjectStorage storage; | |||||
| ProjectService project_service(storage); | |||||
| LogicEditorService service(project_service); | |||||
| const std::string logic_id = service.ensureDefaultLogic().id; | |||||
| const std::string rung_id = service.firstRungId(logic_id); | |||||
| service.appendCondition(logic_id, rung_id, contact(0)); | |||||
| service.appendCondition(logic_id, rung_id, contact(1)); | |||||
| service.appendCondition(logic_id, rung_id, contact(2)); | |||||
| const LogicEditorResult branch = service.addParallelWireBranch( | |||||
| logic_id, rung_id, {"contact-2", "contact-3"}); | |||||
| require(branch.succeeded && branch.id == "wire-1", | |||||
| "a continuous range must accept a structured horizontal bypass"); | |||||
| const LadderRung *rung = service.findRung(logic_id, rung_id); | |||||
| require(rung->condition->kind == ConditionExpressionKind::Series | |||||
| && rung->condition->children.at(1).kind | |||||
| == ConditionExpressionKind::Parallel, | |||||
| "a vertical connection must produce a parallel expression"); | |||||
| const ConditionExpression &wire = | |||||
| rung->condition->children.at(1).children.at(1); | |||||
| require(wire.kind == ConditionExpressionKind::Wire | |||||
| && wire.wire->columnSpan == 2, | |||||
| "the bypass wire span must match the selected two-column range"); | |||||
| const LogicEditorResult replacement = service.replaceWireWithCondition( | |||||
| logic_id, rung_id, branch.id, contact(3)); | |||||
| require(replacement.succeeded && replacement.id == "contact-4", | |||||
| "a selected wire must be replaceable by a configured node type"); | |||||
| const LogicEditorResult extension = service.insertWireAfter( | |||||
| logic_id, rung_id, replacement.id); | |||||
| require(extension.succeeded && extension.id == "wire-1", | |||||
| "a wire id must become reusable after its expression is replaced"); | |||||
| rung = service.findRung(logic_id, rung_id); | |||||
| const ConditionExpression *extended_branch = service.findExpression( | |||||
| logic_id, rung_id, rung->condition->children.at(1).children.at(1).id); | |||||
| require(extended_branch != nullptr | |||||
| && extended_branch->kind == ConditionExpressionKind::Series | |||||
| && extended_branch->children.at(1).kind | |||||
| == ConditionExpressionKind::Wire, | |||||
| "inserting a horizontal wire after a branch node must preserve structure"); | |||||
| const std::string extended_branch_id = extended_branch->id; | |||||
| require(!service.addParallelWireBranch( | |||||
| logic_id, rung_id, {"contact-1", "contact-3"}).succeeded, | |||||
| "non-contiguous wire connection targets must be rejected"); | |||||
| require(service.removeExpressions( | |||||
| logic_id, rung_id, {extended_branch_id}).succeeded, | |||||
| "deleting a selected vertical connection branch must succeed atomically"); | |||||
| rung = service.findRung(logic_id, rung_id); | |||||
| require(rung->condition->kind == ConditionExpressionKind::Series | |||||
| && rung->condition->children.size() == 3U, | |||||
| "removing a bypass branch must normalize back to the original series"); | |||||
| require(service.undo().succeeded | |||||
| && service.findExpression(logic_id, rung_id, extended_branch_id) != nullptr, | |||||
| "wire branch deletion must participate in ladder undo history"); | |||||
| } | |||||
| void testBatchDeleteAllNodesInParallelBranch() | |||||
| { | |||||
| TestProjectStorage storage; | |||||
| ProjectService project_service(storage); | |||||
| LogicEditorService service(project_service); | |||||
| const std::string logic_id = service.ensureDefaultLogic().id; | |||||
| const std::string rung_id = service.firstRungId(logic_id); | |||||
| const LogicEditorResult first = service.appendCondition( | |||||
| logic_id, rung_id, contact(0)); | |||||
| const LogicEditorResult second = service.appendCondition( | |||||
| logic_id, rung_id, contact(1)); | |||||
| const LogicEditorResult third = service.appendCondition( | |||||
| logic_id, rung_id, contact(2)); | |||||
| const LogicEditorResult fourth = service.appendCondition( | |||||
| logic_id, rung_id, contact(3)); | |||||
| require(first.succeeded && second.succeeded && third.succeeded | |||||
| && fourth.succeeded, | |||||
| "parallel batch deletion setup contacts must be created"); | |||||
| const LogicEditorResult branch = service.addParallelBranch( | |||||
| logic_id, rung_id, | |||||
| {second.id, third.id, fourth.id}, | |||||
| contact(10)); | |||||
| require(branch.succeeded, | |||||
| "parallel batch deletion setup branch must be created"); | |||||
| require(service.removeNodes( | |||||
| logic_id, {second.id, third.id, fourth.id}) | |||||
| .succeeded, | |||||
| "deleting every node in a parallel branch as one batch must succeed"); | |||||
| const LadderRung *rung = service.findRung(logic_id, rung_id); | |||||
| require(rung != nullptr && rung->condition.has_value() | |||||
| && rung->validate(), | |||||
| "batch deletion must leave a valid normalized ladder expression"); | |||||
| require(rung->condition->kind == ConditionExpressionKind::Series | |||||
| && rung->condition->children.size() == 2U | |||||
| && rung->condition->children.at(0).kind | |||||
| == ConditionExpressionKind::Node | |||||
| && rung->condition->children.at(1).kind | |||||
| == ConditionExpressionKind::Node, | |||||
| "an empty parallel branch must collapse into the remaining branch"); | |||||
| require(service.findNode(logic_id, second.id) == nullptr | |||||
| && service.findNode(logic_id, third.id) == nullptr | |||||
| && service.findNode(logic_id, fourth.id) == nullptr, | |||||
| "all selected parallel branch nodes must be removed"); | |||||
| require(service.undo().succeeded, | |||||
| "parallel batch deletion must be undoable"); | |||||
| require(service.findNode(logic_id, second.id) != nullptr | |||||
| && service.findNode(logic_id, third.id) != nullptr | |||||
| && service.findNode(logic_id, fourth.id) != nullptr, | |||||
| "undo must restore every deleted parallel branch node"); | |||||
| } | |||||
| void testConditionColumnLimit() | |||||
| { | |||||
| TestProjectStorage storage; | |||||
| ProjectService project_service(storage); | |||||
| LogicEditorService service(project_service); | |||||
| const std::string logic_id = service.ensureDefaultLogic().id; | |||||
| const std::string rung_id = service.firstRungId(logic_id); | |||||
| for (int column = 0; column < ProjectLimits::kMaximumConditionColumns; ++column) | |||||
| { | |||||
| require(service.appendCondition(logic_id, rung_id, contact(column)).succeeded, | |||||
| "the first ten condition columns must be editable"); | |||||
| } | |||||
| require(project_service.saveAs("logic-editor-condition-limit.json").succeeded, | |||||
| "the ten-column network must be saveable before testing overflow"); | |||||
| require(!project_service.isModified(), | |||||
| "saving the ten-column network must clear the modified state"); | |||||
| const LogicEditorResult overflow = service.appendCondition( | |||||
| logic_id, rung_id, contact(ProjectLimits::kMaximumConditionColumns)); | |||||
| require(!overflow.succeeded | |||||
| && overflow.error == LogicEditorError::InvalidOperation, | |||||
| "the eleventh condition column must be rejected by the editor service"); | |||||
| require(!project_service.isModified(), | |||||
| "a failed eleventh-column edit must preserve the saved state"); | |||||
| const LadderRung *rung = service.findRung(logic_id, rung_id); | |||||
| require(rung != nullptr && rung->condition.has_value() | |||||
| && rung->condition->kind == ConditionExpressionKind::Series | |||||
| && rung->condition->children.size() | |||||
| == static_cast<std::size_t>( | |||||
| ProjectLimits::kMaximumConditionColumns), | |||||
| "a rejected eleventh column must leave the ten-column network unchanged"); | |||||
| require(service.setOutput( | |||||
| logic_id, | |||||
| rung_id, | |||||
| CoilNodeConfig{ | |||||
| RegisterAddress{RegisterArea::M, 20}, CoilMode::Normal}, | |||||
| true) | |||||
| .succeeded | |||||
| && project_service.isModified(), | |||||
| "a successful edit must make the project modified again"); | |||||
| require(!service.appendCondition( | |||||
| logic_id, rung_id, | |||||
| contact(ProjectLimits::kMaximumConditionColumns)) | |||||
| .succeeded | |||||
| && project_service.isModified(), | |||||
| "a failed edit must preserve an existing modified state"); | |||||
| } | |||||
| void testUnconditionalOutputEditing() | |||||
| { | |||||
| TestProjectStorage storage; | |||||
| ProjectService project_service(storage); | |||||
| LogicEditorService service(project_service); | |||||
| const std::string logic_id = service.ensureDefaultLogic().id; | |||||
| const std::string rung_id = service.firstRungId(logic_id); | |||||
| require(service.setOutput( | |||||
| logic_id, | |||||
| rung_id, | |||||
| CoilNodeConfig{ | |||||
| RegisterAddress{RegisterArea::M, 10}, CoilMode::Normal}, | |||||
| true) | |||||
| .succeeded, | |||||
| "the editor must allow a coil before any condition is added"); | |||||
| const LadderRung *rung = service.findRung(logic_id, rung_id); | |||||
| require(rung != nullptr && !rung->condition.has_value() | |||||
| && rung->output.has_value() && rung->validateForRunning(), | |||||
| "an editor-created output-only network must be runnable as unconditional"); | |||||
| } | |||||
| void testColumnTargetedConditionInsertion() | |||||
| { | |||||
| TestProjectStorage storage; | |||||
| ProjectService project_service(storage); | |||||
| LogicEditorService service(project_service); | |||||
| const std::string logic_id = service.ensureDefaultLogic().id; | |||||
| const std::string first_rung_id = service.firstRungId(logic_id); | |||||
| require(service.insertConditionAtColumn( | |||||
| logic_id, first_rung_id, 4, contact(4)).succeeded, | |||||
| "an empty network must accept a condition at the selected fifth column"); | |||||
| const LadderRung *rung = service.findRung(logic_id, first_rung_id); | |||||
| require(rung != nullptr && rung->condition.has_value() | |||||
| && rung->condition->kind == ConditionExpressionKind::Series | |||||
| && rung->condition->children.size() == 2U | |||||
| && rung->condition->children.front().kind | |||||
| == ConditionExpressionKind::Wire | |||||
| && rung->condition->children.front().wire->columnSpan == 4 | |||||
| && rung->condition->children.back().kind | |||||
| == ConditionExpressionKind::Node | |||||
| && rung->validate() | |||||
| && conditionColumns(*rung->condition) == 5, | |||||
| "column insertion must preserve the requested horizontal position"); | |||||
| require(service.insertConditionAtColumn( | |||||
| logic_id, first_rung_id, 2, contact(2)).error | |||||
| == LogicEditorError::InvalidOperation, | |||||
| "inserting into an already occupied column must be rejected atomically"); | |||||
| require(service.insertConditionAtColumn( | |||||
| logic_id, first_rung_id, 10, contact(10)).error | |||||
| == LogicEditorError::InvalidOperation, | |||||
| "the eleventh condition column must be rejected"); | |||||
| require(service.insertConditionAtColumn( | |||||
| logic_id, first_rung_id, -1, contact(10)).error | |||||
| == LogicEditorError::InvalidOperation, | |||||
| "a negative grid column must be rejected"); | |||||
| require(service.insertConditionAtColumn( | |||||
| logic_id, | |||||
| first_rung_id, | |||||
| 5, | |||||
| CoilNodeConfig{ | |||||
| RegisterAddress{RegisterArea::M, 10}, CoilMode::Normal}) | |||||
| .error == LogicEditorError::InvalidNode, | |||||
| "a condition grid slot must reject output instructions"); | |||||
| const LogicEditorResult second_rung = service.addRung(logic_id); | |||||
| require(second_rung.succeeded, | |||||
| "column insertion test must create a second empty network"); | |||||
| require(service.setOutput( | |||||
| logic_id, | |||||
| second_rung.id, | |||||
| CoilNodeConfig{RegisterAddress{RegisterArea::M, 20}, CoilMode::Normal}, | |||||
| true) | |||||
| .succeeded, | |||||
| "an output-only network must be configurable before adding a condition"); | |||||
| require(service.insertConditionAtColumn( | |||||
| logic_id, second_rung.id, 0, contact(0)).succeeded, | |||||
| "a selected first grid slot must work on an output-only network"); | |||||
| const LadderRung *output_rung = service.findRung(logic_id, second_rung.id); | |||||
| require(output_rung != nullptr && output_rung->condition.has_value() | |||||
| && output_rung->condition->kind == ConditionExpressionKind::Node | |||||
| && output_rung->output.has_value() | |||||
| && output_rung->validate(), | |||||
| "condition insertion must keep the independent output slot intact"); | |||||
| const LogicEditorResult last_column_rung = service.addRung(logic_id); | |||||
| require(last_column_rung.succeeded | |||||
| && service.insertConditionAtColumn( | |||||
| logic_id, last_column_rung.id, 9, contact(9)).succeeded, | |||||
| "the tenth condition column must remain a valid insertion target"); | |||||
| const LadderRung *full_width = service.findRung( | |||||
| logic_id, last_column_rung.id); | |||||
| require(full_width != nullptr && full_width->condition.has_value() | |||||
| && full_width->condition->kind == ConditionExpressionKind::Series | |||||
| && full_width->condition->children.front().wire->columnSpan == 9 | |||||
| && conditionColumns(*full_width->condition) == 10, | |||||
| "last-column insertion must fill exactly the ten-column condition area"); | |||||
| require(!service.appendCondition( | |||||
| logic_id, last_column_rung.id, contact(10)).succeeded | |||||
| && conditionColumns(*service.findRung( | |||||
| logic_id, last_column_rung.id)->condition) == 10, | |||||
| "a full-width grid must reject another condition without partial changes"); | |||||
| } | |||||
| void testWireColumnReplacement() | |||||
| { | |||||
| TestProjectStorage storage; | |||||
| ProjectService project_service(storage); | |||||
| LogicEditorService service(project_service); | |||||
| const std::string logic_id = service.ensureDefaultLogic().id; | |||||
| const std::string rung_id = service.firstRungId(logic_id); | |||||
| const LogicEditorResult wire = service.appendWire(logic_id, rung_id, 4); | |||||
| require(wire.succeeded, | |||||
| "wire-column replacement test must create a four-column wire"); | |||||
| require(service.replaceWireColumnWithCondition( | |||||
| logic_id, rung_id, wire.id, 1, | |||||
| ContactNodeConfig{ | |||||
| RegisterAddress{RegisterArea::M, 1}, | |||||
| ContactMode::NormallyClosed}) | |||||
| .succeeded, | |||||
| "a selected wire cell must be replaceable without removing adjacent cells"); | |||||
| const LadderRung *rung = service.findRung(logic_id, rung_id); | |||||
| require(rung != nullptr && rung->condition.has_value() | |||||
| && rung->condition->kind == ConditionExpressionKind::Series | |||||
| && rung->condition->children.size() == 3U | |||||
| && rung->condition->children.front().wire->columnSpan == 1 | |||||
| && rung->condition->children.at(1).kind | |||||
| == ConditionExpressionKind::Node | |||||
| && std::get<ContactNodeConfig>( | |||||
| rung->condition->children.at(1).node->config).mode | |||||
| == ContactMode::NormallyClosed | |||||
| && rung->condition->children.back().wire->columnSpan == 2 | |||||
| && conditionColumns(*rung->condition) == 4, | |||||
| "wire-cell replacement must split the wire around the new contact"); | |||||
| const std::vector<ControlLogic> before_invalid = | |||||
| project_service.project().controlLogics; | |||||
| require(service.replaceWireColumnWithCondition( | |||||
| logic_id, | |||||
| rung_id, | |||||
| rung->condition->children.front().id, | |||||
| 1, | |||||
| contact(2)) | |||||
| .error == LogicEditorError::InvalidOperation, | |||||
| "an out-of-range wire-cell offset must be rejected"); | |||||
| require(project_service.project().controlLogics.size() == before_invalid.size() | |||||
| && conditionColumns(*service.findRung( | |||||
| logic_id, rung_id)->condition) == 4, | |||||
| "a rejected wire-cell replacement must leave the network width unchanged"); | |||||
| const LogicEditorResult first_cell_rung = service.addRung(logic_id); | |||||
| const LogicEditorResult first_cell_wire = service.appendWire( | |||||
| logic_id, first_cell_rung.id, 4); | |||||
| require(first_cell_rung.succeeded && first_cell_wire.succeeded | |||||
| && service.replaceWireColumnWithCondition( | |||||
| logic_id, | |||||
| first_cell_rung.id, | |||||
| first_cell_wire.id, | |||||
| 0, | |||||
| contact(10)) | |||||
| .succeeded, | |||||
| "the first cell of a multi-column wire must be replaceable"); | |||||
| const LadderRung *first_cell = service.findRung(logic_id, first_cell_rung.id); | |||||
| require(first_cell != nullptr && first_cell->condition.has_value() | |||||
| && first_cell->condition->kind == ConditionExpressionKind::Series | |||||
| && first_cell->condition->children.size() == 2U | |||||
| && first_cell->condition->children.front().kind | |||||
| == ConditionExpressionKind::Node | |||||
| && first_cell->condition->children.back().kind | |||||
| == ConditionExpressionKind::Wire | |||||
| && first_cell->condition->children.back().wire->columnSpan == 3, | |||||
| "first-cell replacement must preserve the trailing wire cells"); | |||||
| require(service.undo().succeeded, | |||||
| "wire-cell replacement must be one undoable edit"); | |||||
| const LadderRung *undone = service.findRung(logic_id, first_cell_rung.id); | |||||
| require(undone != nullptr && undone->condition.has_value() | |||||
| && undone->condition->kind == ConditionExpressionKind::Wire | |||||
| && undone->condition->wire->columnSpan == 4, | |||||
| "undo must restore the original unsplit wire"); | |||||
| require(service.redo().succeeded, | |||||
| "wire-cell replacement must be redoable"); | |||||
| const LadderRung *redone = service.findRung(logic_id, first_cell_rung.id); | |||||
| require(redone != nullptr && redone->condition.has_value() | |||||
| && redone->condition->kind == ConditionExpressionKind::Series | |||||
| && conditionColumns(*redone->condition) == 4, | |||||
| "redo must restore the split wire without changing its width"); | |||||
| const std::string redone_trailing_wire_id = | |||||
| redone->condition->children.back().id; | |||||
| const LogicEditorResult last_cell_rung = service.addRung(logic_id); | |||||
| const LogicEditorResult last_cell_wire = service.appendWire( | |||||
| logic_id, last_cell_rung.id, 4); | |||||
| require(last_cell_rung.succeeded && last_cell_wire.succeeded | |||||
| && service.replaceWireColumnWithCondition( | |||||
| logic_id, | |||||
| last_cell_rung.id, | |||||
| last_cell_wire.id, | |||||
| 3, | |||||
| contact(11)) | |||||
| .succeeded, | |||||
| "the last cell of a multi-column wire must be replaceable"); | |||||
| const LadderRung *last_cell = service.findRung(logic_id, last_cell_rung.id); | |||||
| require(last_cell != nullptr && last_cell->condition.has_value() | |||||
| && last_cell->condition->kind == ConditionExpressionKind::Series | |||||
| && last_cell->condition->children.size() == 2U | |||||
| && last_cell->condition->children.front().kind | |||||
| == ConditionExpressionKind::Wire | |||||
| && last_cell->condition->children.front().wire->columnSpan == 3 | |||||
| && last_cell->condition->children.back().kind | |||||
| == ConditionExpressionKind::Node, | |||||
| "last-cell replacement must preserve the leading wire cells"); | |||||
| const LogicEditorResult single_cell_rung = service.addRung(logic_id); | |||||
| const LogicEditorResult single_cell_wire = service.appendWire( | |||||
| logic_id, single_cell_rung.id, 1); | |||||
| require(single_cell_rung.succeeded && single_cell_wire.succeeded | |||||
| && service.replaceWireColumnWithCondition( | |||||
| logic_id, | |||||
| single_cell_rung.id, | |||||
| single_cell_wire.id, | |||||
| 0, | |||||
| contact(12)) | |||||
| .succeeded, | |||||
| "a one-column wire must use the same grid-cell replacement API"); | |||||
| const LadderRung *single_cell = service.findRung( | |||||
| logic_id, single_cell_rung.id); | |||||
| require(single_cell != nullptr && single_cell->condition.has_value() | |||||
| && single_cell->condition->kind == ConditionExpressionKind::Node, | |||||
| "one-column wire replacement must normalize directly to a condition node"); | |||||
| require(service.replaceWireColumnWithCondition( | |||||
| logic_id, | |||||
| first_cell_rung.id, | |||||
| redone_trailing_wire_id, | |||||
| 0, | |||||
| CoilNodeConfig{ | |||||
| RegisterAddress{RegisterArea::M, 13}, CoilMode::Set}) | |||||
| .error == LogicEditorError::InvalidNode, | |||||
| "wire grid cells must reject output instructions"); | |||||
| require(service.replaceWireColumnWithCondition( | |||||
| logic_id, | |||||
| first_cell_rung.id, | |||||
| "missing-wire", | |||||
| 0, | |||||
| contact(13)) | |||||
| .error == LogicEditorError::ExpressionNotFound, | |||||
| "wire grid replacement must reject an unknown wire without mutation"); | |||||
| } | |||||
| void testSequentialConditionInsertionConsumesFollowingWire() | |||||
| { | |||||
| TestProjectStorage storage; | |||||
| ProjectService project_service(storage); | |||||
| LogicEditorService service(project_service); | |||||
| const std::string logic_id = service.ensureDefaultLogic().id; | |||||
| const std::string rung_id = service.firstRungId(logic_id); | |||||
| const LogicEditorResult wire = service.appendWire(logic_id, rung_id, 10); | |||||
| require(wire.succeeded, | |||||
| "sequential wire replacement must start with a full-width wire"); | |||||
| LogicEditorResult inserted = service.replaceWireColumnWithCondition( | |||||
| logic_id, rung_id, wire.id, 0, contact(0)); | |||||
| require(inserted.succeeded, | |||||
| "the first condition must replace the first full-wire cell"); | |||||
| std::string selected_node_id = inserted.id; | |||||
| for (int address = 1; address < 10; ++address) | |||||
| { | |||||
| inserted = service.insertConditionAfter( | |||||
| logic_id, rung_id, selected_node_id, contact(address)); | |||||
| require(inserted.succeeded, | |||||
| "continuous condition insertion must consume the following wire cell"); | |||||
| selected_node_id = inserted.id; | |||||
| const LadderRung *rung = service.findRung(logic_id, rung_id); | |||||
| std::vector<const LogicNode *> nodes; | |||||
| collectConditionNodes(*rung->condition, &nodes); | |||||
| require(rung != nullptr && rung->condition.has_value() | |||||
| && conditionColumns(*rung->condition) == 10 | |||||
| && nodes.size() == static_cast<std::size_t>(address + 1) | |||||
| && wireColumns(*rung->condition) == 9 - address, | |||||
| "each continuous insertion must preserve width while consuming one wire cell"); | |||||
| } | |||||
| const LadderRung *full = service.findRung(logic_id, rung_id); | |||||
| require(full != nullptr && full->condition.has_value() | |||||
| && conditionColumns(*full->condition) == 10 | |||||
| && wireColumns(*full->condition) == 0, | |||||
| "ten continuous insertions must replace the entire wire without expanding it"); | |||||
| require(service.insertConditionAfter( | |||||
| logic_id, rung_id, selected_node_id, contact(10)) | |||||
| .error == LogicEditorError::InvalidOperation, | |||||
| "the eleventh condition must still be rejected after all wire cells are consumed"); | |||||
| require(conditionColumns(*service.findRung( | |||||
| logic_id, rung_id)->condition) == 10, | |||||
| "a rejected eleventh insertion must leave the full network unchanged"); | |||||
| require(service.undo().succeeded, | |||||
| "a failed eleventh insertion must not displace the last successful undo step"); | |||||
| const LadderRung *undone = service.findRung(logic_id, rung_id); | |||||
| std::vector<const LogicNode *> undone_nodes; | |||||
| collectConditionNodes(*undone->condition, &undone_nodes); | |||||
| require(undone_nodes.size() == 9U | |||||
| && wireColumns(*undone->condition) == 1 | |||||
| && conditionColumns(*undone->condition) == 10, | |||||
| "undo must restore nine contacts followed by one wire cell"); | |||||
| require(service.redo().succeeded, | |||||
| "the final wire-consuming insertion must be redoable"); | |||||
| const LadderRung *redone = service.findRung(logic_id, rung_id); | |||||
| std::vector<const LogicNode *> redone_nodes; | |||||
| collectConditionNodes(*redone->condition, &redone_nodes); | |||||
| require(redone_nodes.size() == 10U | |||||
| && wireColumns(*redone->condition) == 0 | |||||
| && conditionColumns(*redone->condition) == 10, | |||||
| "redo must restore all ten contacts without wire cells"); | |||||
| } | |||||
| void testLogicLifecycleAndOrdering() | void testLogicLifecycleAndOrdering() | ||||
| { | { | ||||
| TestProjectStorage storage; | TestProjectStorage storage; | ||||
| @@ -165,6 +669,147 @@ void testLogicLifecycleAndOrdering() | |||||
| "the project must retain at least one control logic module"); | "the project must retain at least one control logic module"); | ||||
| } | } | ||||
| void testEdgeTimerNodesAndRungComments() | |||||
| { | |||||
| TestProjectStorage storage; | |||||
| ProjectService project_service(storage); | |||||
| LogicEditorService service(project_service); | |||||
| const std::string logic_id = service.ensureDefaultLogic().id; | |||||
| const std::string rung_id = service.firstRungId(logic_id); | |||||
| const LogicEditorResult rising = service.appendCondition( | |||||
| logic_id, | |||||
| rung_id, | |||||
| EdgeContactNodeConfig{ | |||||
| RegisterAddress{RegisterArea::M, 3}, EdgeMode::Rising}); | |||||
| require(rising.succeeded && rising.id == "edge-1", | |||||
| "the editor must create rising edge nodes with a stable prefix"); | |||||
| const LogicEditorResult timer = service.addParallelBranch( | |||||
| logic_id, | |||||
| rung_id, | |||||
| {rising.id}, | |||||
| TimerContactNodeConfig{TimerAddress{2}, ContactMode::NormallyOpen}); | |||||
| require(timer.succeeded && timer.id == "timer-contact-1", | |||||
| "the editor must insert T contacts as conditions"); | |||||
| const LogicEditorResult ton = service.setOutput( | |||||
| logic_id, rung_id, TonNodeConfig{TimerAddress{2}, 500}); | |||||
| require(ton.succeeded && ton.id == "ton-1", | |||||
| "the editor must create TON outputs with a stable prefix"); | |||||
| require(service.updateNodeConfig( | |||||
| logic_id, | |||||
| rising.id, | |||||
| EdgeContactNodeConfig{ | |||||
| RegisterAddress{RegisterArea::M, 4}, EdgeMode::Falling}) | |||||
| .succeeded, | |||||
| "the editor must apply edge mode and M address properties"); | |||||
| require(service.updateNodeConfig( | |||||
| logic_id, | |||||
| timer.id, | |||||
| TimerContactNodeConfig{TimerAddress{4}, ContactMode::NormallyClosed}) | |||||
| .succeeded, | |||||
| "the editor must apply T contact mode and address properties"); | |||||
| require(service.updateNodeConfig( | |||||
| logic_id, | |||||
| ton.id, | |||||
| TonNodeConfig{TimerAddress{4}, 750}) | |||||
| .succeeded, | |||||
| "the editor must apply TON preset properties"); | |||||
| require(service.updateRungComment(logic_id, rung_id, "延时启动网络").succeeded, | |||||
| "the editor must update a network comment by stable rung id"); | |||||
| require(!service.updateRungComment( | |||||
| logic_id, rung_id, "第一行\n第二行").succeeded, | |||||
| "the editor must reject multiline network comments"); | |||||
| require(!service.updateRungComment( | |||||
| logic_id, | |||||
| rung_id, | |||||
| std::string(ProjectLimits::kMaximumRungCommentBytes + 1U, 'a')) | |||||
| .succeeded, | |||||
| "the editor must reject oversized network comments"); | |||||
| const LadderRung *rung = service.findRung(logic_id, rung_id); | |||||
| require(rung != nullptr && rung->comment == "延时启动网络" | |||||
| && std::get<EdgeContactNodeConfig>( | |||||
| rung->condition->children.front().node->config).mode | |||||
| == EdgeMode::Falling | |||||
| && std::get<TimerContactNodeConfig>( | |||||
| rung->condition->children.at(1).node->config).address | |||||
| == TimerAddress{4} | |||||
| && std::get<TonNodeConfig>(rung->output->config).presetMs == 750, | |||||
| "edge, T contact, TON and rung comment updates must remain in the model"); | |||||
| } | |||||
| void testHistoryAndAtomicBatchDelete() | |||||
| { | |||||
| TestProjectStorage storage; | |||||
| ProjectService project_service(storage); | |||||
| LogicEditorService service(project_service); | |||||
| const std::string logic_id = service.ensureDefaultLogic().id; | |||||
| const std::string first_rung_id = service.firstRungId(logic_id); | |||||
| const LogicEditorResult first = service.appendCondition( | |||||
| logic_id, first_rung_id, contact(0)); | |||||
| const LogicEditorResult second = service.appendCondition( | |||||
| logic_id, first_rung_id, contact(1)); | |||||
| const LogicEditorResult second_rung = service.addRung(logic_id); | |||||
| const LogicEditorResult third = service.appendCondition( | |||||
| logic_id, second_rung.id, contact(2)); | |||||
| require(first.succeeded && second.succeeded && second_rung.succeeded | |||||
| && third.succeeded, | |||||
| "nodes for history testing must be created"); | |||||
| service.clearHistory(); | |||||
| require(!service.removeNodes(logic_id, {first.id, "missing-node"}).succeeded, | |||||
| "batch node deletion must validate every id before changing the logic"); | |||||
| require(service.findNode(logic_id, first.id) != nullptr | |||||
| && service.findNode(logic_id, third.id) != nullptr | |||||
| && !service.canUndo(), | |||||
| "failed batch node deletion must be atomic and leave history unchanged"); | |||||
| require(service.removeNodes(logic_id, {first.id, third.id}).succeeded, | |||||
| "valid nodes across multiple rungs must be deleted together"); | |||||
| require(service.findNode(logic_id, first.id) == nullptr | |||||
| && service.findNode(logic_id, third.id) == nullptr, | |||||
| "all selected nodes must be removed by one batch operation"); | |||||
| require(service.undo().succeeded | |||||
| && service.findNode(logic_id, first.id) != nullptr | |||||
| && service.findNode(logic_id, third.id) != nullptr, | |||||
| "logic undo must restore a cross-rung batch deletion"); | |||||
| require(service.redo().succeeded | |||||
| && service.findNode(logic_id, first.id) == nullptr | |||||
| && service.findNode(logic_id, third.id) == nullptr, | |||||
| "logic redo must reapply a cross-rung batch deletion"); | |||||
| service.clearHistory(); | |||||
| const ControlLogic *logic = service.findLogic(logic_id); | |||||
| require(logic != nullptr && service.setLogicEnabled(logic_id, logic->enabled).succeeded | |||||
| && !service.canUndo(), | |||||
| "setting an unchanged logic state must not consume history"); | |||||
| service.clearHistory(); | |||||
| for (int index = 1; index <= 101; ++index) | |||||
| { | |||||
| require(service.updateRungComment( | |||||
| logic_id, first_rung_id, "comment-" + std::to_string(index)) | |||||
| .succeeded, | |||||
| "repeated valid rung edits must succeed"); | |||||
| } | |||||
| int undo_count = 0; | |||||
| while (service.undo().succeeded) | |||||
| { | |||||
| ++undo_count; | |||||
| } | |||||
| require(undo_count == 100, | |||||
| "logic history must retain exactly the configured 100 most recent steps"); | |||||
| require(service.redo().succeeded, | |||||
| "logic redo must be available after an undo"); | |||||
| require(service.appendCondition(logic_id, first_rung_id, contact(5)).succeeded, | |||||
| "a new logic edit must succeed after undo"); | |||||
| require(!service.canRedo(), | |||||
| "a new logic edit must clear the redo history"); | |||||
| (void)second; | |||||
| } | |||||
| } // namespace | } // namespace | ||||
| int main() | int main() | ||||
| @@ -173,7 +818,16 @@ int main() | |||||
| { | { | ||||
| testStructuredEditingAndNormalization(); | testStructuredEditingAndNormalization(); | ||||
| testRangeParallelInsertion(); | testRangeParallelInsertion(); | ||||
| testStructuredWireEditing(); | |||||
| testBatchDeleteAllNodesInParallelBranch(); | |||||
| testConditionColumnLimit(); | |||||
| testUnconditionalOutputEditing(); | |||||
| testColumnTargetedConditionInsertion(); | |||||
| testWireColumnReplacement(); | |||||
| testSequentialConditionInsertionConsumesFollowingWire(); | |||||
| testLogicLifecycleAndOrdering(); | testLogicLifecycleAndOrdering(); | ||||
| testEdgeTimerNodesAndRungComments(); | |||||
| testHistoryAndAtomicBatchDelete(); | |||||
| } | } | ||||
| catch (const std::exception &error) | catch (const std::exception &error) | ||||
| { | { | ||||
| @@ -1,27 +1,24 @@ | |||||
| TEMPLATE = app | |||||
| TARGET = logic_editor_service_tests | |||||
| CONFIG += console c++17 testcase warn_on | |||||
| CONFIG -= app_bundle qt | |||||
| include(pri/test_defaults.pri) | |||||
| include(pri/test_layers.pri) | |||||
| INCLUDEPATH += ../src | |||||
| TARGET = logic_editor_service_tests | |||||
| QT -= gui | |||||
| CONFIG += testcase | |||||
| SOURCES += \ | SOURCES += \ | ||||
| logic_editor_service_tests.cpp \ | logic_editor_service_tests.cpp \ | ||||
| ../src/domain/register_address.cpp \ | |||||
| ../src/domain/control_logic_model.cpp \ | |||||
| ../src/domain/project_model.cpp \ | |||||
| ../src/domain/hmi_model.cpp \ | |||||
| ../src/domain/register_repository.cpp \ | |||||
| ../src/services/project_service.cpp \ | |||||
| ../src/services/logic_editor_service.cpp | |||||
| $$DOMAIN_REGISTER_SOURCES \ | |||||
| $$DOMAIN_HMI_SOURCES \ | |||||
| $$DOMAIN_LOGIC_SOURCES \ | |||||
| $$DOMAIN_PROJECT_SOURCES \ | |||||
| $$SERVICE_PROJECT_SOURCES \ | |||||
| $$SERVICE_LOGIC_SOURCES | |||||
| HEADERS += \ | HEADERS += \ | ||||
| ../src/domain/register_address.h \ | |||||
| ../src/domain/control_logic_model.h \ | |||||
| ../src/domain/project_model.h \ | |||||
| ../src/domain/hmi_model.h \ | |||||
| ../src/domain/register_repository.h \ | |||||
| ../src/domain/project_storage.h \ | |||||
| ../src/services/project_service.h \ | |||||
| ../src/services/logic_editor_service.h | |||||
| $$DOMAIN_REGISTER_HEADERS \ | |||||
| $$DOMAIN_HMI_HEADERS \ | |||||
| $$DOMAIN_LOGIC_HEADERS \ | |||||
| $$DOMAIN_PROJECT_HEADERS \ | |||||
| $$SERVICE_PROJECT_HEADERS \ | |||||
| $$SERVICE_LOGIC_HEADERS \ | |||||
| $$TEST_SUPPORT_HEADERS | |||||
| @@ -1,70 +1,32 @@ | |||||
| QT += widgets serialport testlib | |||||
| include(pri/test_defaults.pri) | |||||
| include(pri/test_layers.pri) | |||||
| TEMPLATE = app | |||||
| TARGET = main_window_tests | TARGET = main_window_tests | ||||
| CONFIG += console c++17 warn_on | |||||
| CONFIG -= app_bundle | |||||
| INCLUDEPATH += ../src | |||||
| QT += widgets serialport testlib | |||||
| SOURCES += \ | SOURCES += \ | ||||
| main_window_tests.cpp \ | main_window_tests.cpp \ | ||||
| ../src/ui/main_window.cpp \ | |||||
| ../src/ui/plc_connection_dialog.cpp \ | |||||
| ../src/ui/free_monitor_widget.cpp \ | |||||
| ../src/ui/runtime_monitor_widget.cpp \ | |||||
| ../src/ui/hmi_editor_widget.cpp \ | |||||
| ../src/ui/logic_editor_widget.cpp \ | |||||
| ../src/domain/register_address.cpp \ | |||||
| ../src/domain/register_repository.cpp \ | |||||
| ../src/domain/active_register_repository.cpp \ | |||||
| ../src/domain/register_monitor_model.cpp \ | |||||
| ../src/domain/hmi_model.cpp \ | |||||
| ../src/domain/control_logic_model.cpp \ | |||||
| ../src/domain/project_model.cpp \ | |||||
| ../src/domain/runtime_state.cpp \ | |||||
| ../src/services/project_service.cpp \ | |||||
| ../src/services/hmi_editor_service.cpp \ | |||||
| ../src/services/hmi_navigation_service.cpp \ | |||||
| ../src/services/logic_editor_service.cpp \ | |||||
| ../src/services/hmi_runtime_service.cpp \ | |||||
| ../src/services/software_logic_executor.cpp \ | |||||
| ../src/services/offline_simulation_service.cpp \ | |||||
| ../src/services/runtime_mode_service.cpp \ | |||||
| ../src/services/register_monitor_service.cpp | |||||
| HEADERS += \ | |||||
| ../src/ui/main_window.h \ | |||||
| ../src/ui/plc_connection_dialog.h \ | |||||
| ../src/ui/free_monitor_widget.h \ | |||||
| ../src/ui/runtime_monitor_widget.h \ | |||||
| ../src/ui/hmi_editor_widget.h \ | |||||
| ../src/ui/logic_editor_widget.h \ | |||||
| ../src/domain/register_address.h \ | |||||
| ../src/domain/register_repository.h \ | |||||
| ../src/domain/active_register_repository.h \ | |||||
| ../src/domain/register_monitor_model.h \ | |||||
| ../src/domain/hmi_model.h \ | |||||
| ../src/domain/control_logic_model.h \ | |||||
| ../src/domain/project_model.h \ | |||||
| ../src/domain/project_storage.h \ | |||||
| ../src/domain/runtime_state.h \ | |||||
| ../src/services/project_service.h \ | |||||
| ../src/services/hmi_editor_service.h \ | |||||
| ../src/services/hmi_navigation_service.h \ | |||||
| ../src/services/logic_editor_service.h \ | |||||
| ../src/services/hmi_runtime_service.h \ | |||||
| ../src/services/software_logic_executor.h \ | |||||
| ../src/services/offline_simulation_service.h \ | |||||
| ../src/services/runtime_mode_service.h \ | |||||
| ../src/services/register_monitor_service.h | |||||
| $$UI_MAIN_SOURCES \ | |||||
| $$DOMAIN_ALL_SOURCES \ | |||||
| $$SERVICE_PROJECT_SOURCES \ | |||||
| $$SERVICE_ALARM_SOURCES \ | |||||
| $$SERVICE_HMI_SOURCES \ | |||||
| $$SERVICE_LOGIC_SOURCES \ | |||||
| $$SERVICE_OFFLINE_SOURCES \ | |||||
| $$SERVICE_RUNTIME_SOURCES \ | |||||
| $$SERVICE_MONITOR_SOURCES | |||||
| HEADERS += \ | HEADERS += \ | ||||
| ../src/services/plc_communication_gateway.h | |||||
| $$UI_MAIN_HEADERS \ | |||||
| $$DOMAIN_ALL_HEADERS \ | |||||
| $$SERVICE_PROJECT_HEADERS \ | |||||
| $$SERVICE_ALARM_HEADERS \ | |||||
| $$SERVICE_HMI_HEADERS \ | |||||
| $$SERVICE_LOGIC_HEADERS \ | |||||
| $$SERVICE_OFFLINE_HEADERS \ | |||||
| $$SERVICE_RUNTIME_HEADERS \ | |||||
| $$SERVICE_MONITOR_HEADERS \ | |||||
| ../src/services/plc_communication_gateway.h \ | |||||
| $$TEST_SUPPORT_HEADERS | |||||
| FORMS += \ | |||||
| ../src/ui/main_window.ui \ | |||||
| ../src/ui/plc_connection_dialog.ui \ | |||||
| ../src/ui/free_monitor_widget.ui \ | |||||
| ../src/ui/runtime_monitor_widget.ui | |||||
| FORMS += $$UI_MAIN_FORMS | |||||
| @@ -3,6 +3,7 @@ | |||||
| #include "services/hmi_runtime_service.h" | #include "services/hmi_runtime_service.h" | ||||
| #include "services/offline_simulation_service.h" | #include "services/offline_simulation_service.h" | ||||
| #include "services/software_logic_executor.h" | #include "services/software_logic_executor.h" | ||||
| #include "support/test_support.h" | |||||
| #include <QCoreApplication> | #include <QCoreApplication> | ||||
| @@ -14,13 +15,7 @@ | |||||
| namespace { | namespace { | ||||
| void require(bool condition, const std::string &message) | |||||
| { | |||||
| if (!condition) | |||||
| { | |||||
| throw std::runtime_error(message); | |||||
| } | |||||
| } | |||||
| using TestSupport::require; | |||||
| LogicNode contact(const std::string &id, int address, | LogicNode contact(const std::string &id, int address, | ||||
| ContactMode mode = ContactMode::NormallyOpen) | ContactMode mode = ContactMode::NormallyOpen) | ||||
| @@ -28,6 +23,100 @@ LogicNode contact(const std::string &id, int address, | |||||
| return {id, ContactNodeConfig{RegisterAddress{RegisterArea::M, address}, mode}, true}; | return {id, ContactNodeConfig{RegisterAddress{RegisterArea::M, address}, mode}, true}; | ||||
| } | } | ||||
| LogicNode edgeContact(const std::string &id, int address, EdgeMode mode) | |||||
| { | |||||
| return {id, | |||||
| EdgeContactNodeConfig{ | |||||
| RegisterAddress{RegisterArea::M, address}, mode}, | |||||
| true}; | |||||
| } | |||||
| LogicNode timerContact( | |||||
| const std::string &id, | |||||
| int timer_index, | |||||
| ContactMode mode = ContactMode::NormallyOpen) | |||||
| { | |||||
| return {id, TimerContactNodeConfig{TimerAddress{timer_index}, mode}, true}; | |||||
| } | |||||
| LogicNode counterContact( | |||||
| const std::string &id, | |||||
| int counter_index, | |||||
| ContactMode mode = ContactMode::NormallyOpen) | |||||
| { | |||||
| return {id, CounterContactNodeConfig{CounterAddress{counter_index}, mode}, true}; | |||||
| } | |||||
| LogicNode ton(const std::string &id, int timer_index, int preset_ms) | |||||
| { | |||||
| return {id, TonNodeConfig{TimerAddress{timer_index}, preset_ms}, true}; | |||||
| } | |||||
| WordOperand constantOperand(std::int16_t value) | |||||
| { | |||||
| return { | |||||
| WordOperandKind::Constant, | |||||
| RegisterAddress{RegisterArea::D, 0}, | |||||
| value}; | |||||
| } | |||||
| WordOperand registerOperand(int address) | |||||
| { | |||||
| return { | |||||
| WordOperandKind::Register, | |||||
| RegisterAddress{RegisterArea::D, address}, | |||||
| 0}; | |||||
| } | |||||
| LogicNode counter( | |||||
| const std::string &id, | |||||
| int counter_index, | |||||
| CounterMode mode, | |||||
| int current_address, | |||||
| WordOperand preset, | |||||
| int reset_address) | |||||
| { | |||||
| return { | |||||
| id, | |||||
| CounterNodeConfig{ | |||||
| CounterAddress{counter_index}, | |||||
| mode, | |||||
| RegisterAddress{RegisterArea::D, current_address}, | |||||
| preset, | |||||
| RegisterAddress{RegisterArea::M, reset_address}}, | |||||
| true}; | |||||
| } | |||||
| LogicNode move( | |||||
| const std::string &id, | |||||
| WordOperand source, | |||||
| int destination) | |||||
| { | |||||
| return { | |||||
| id, | |||||
| MoveNodeConfig{ | |||||
| source, | |||||
| RegisterAddress{RegisterArea::D, destination}}, | |||||
| true}; | |||||
| } | |||||
| LogicNode arithmetic( | |||||
| const std::string &id, | |||||
| ArithmeticOperation operation, | |||||
| WordOperand left, | |||||
| WordOperand right, | |||||
| int destination) | |||||
| { | |||||
| return { | |||||
| id, | |||||
| ArithmeticNodeConfig{ | |||||
| operation, | |||||
| left, | |||||
| right, | |||||
| RegisterAddress{RegisterArea::D, destination}}, | |||||
| true}; | |||||
| } | |||||
| LogicNode comparison(const std::string &id, int address, | LogicNode comparison(const std::string &id, int address, | ||||
| ComparisonOperator operation, std::int16_t value) | ComparisonOperator operation, std::int16_t value) | ||||
| { | { | ||||
| @@ -111,6 +200,14 @@ void writeWord(RegisterRepository &repository, int address, std::int16_t value) | |||||
| "test word write must succeed"); | "test word write must succeed"); | ||||
| } | } | ||||
| std::int16_t readWord(RegisterRepository &repository, int address) | |||||
| { | |||||
| const WordReadResult result = repository.readWord( | |||||
| RegisterAddress{RegisterArea::D, address}); | |||||
| require(result.succeeded, "test word read must succeed"); | |||||
| return result.value; | |||||
| } | |||||
| void testNestedSeriesParallelExpression() | void testNestedSeriesParallelExpression() | ||||
| { | { | ||||
| VirtualRegisterRepository repository; | VirtualRegisterRepository repository; | ||||
| @@ -155,6 +252,66 @@ void testNestedSeriesParallelExpression() | |||||
| require(readBit(repository, 10), "A branch must independently energize output"); | require(readBit(repository, 10), "A branch must independently energize output"); | ||||
| } | } | ||||
| void testUnconditionalCoil() | |||||
| { | |||||
| VirtualRegisterRepository repository; | |||||
| SoftwareLogicExecutor executor; | |||||
| LadderRung unconditional; | |||||
| unconditional.id = "unconditional-rung"; | |||||
| unconditional.name = "unconditional-rung"; | |||||
| unconditional.output = coil("unconditional-coil", 10); | |||||
| const ControlLogic program = logic({unconditional}); | |||||
| LogicTraceSnapshot trace; | |||||
| require(executor.validate({program}).succeeded, | |||||
| "an output-only network must pass runtime validation"); | |||||
| require(executor.executeScan({program}, repository, &trace).succeeded, | |||||
| "an output-only network scan must succeed"); | |||||
| require(readBit(repository, 10), | |||||
| "an output-only network must energize its coil as a constant-true rung"); | |||||
| require(trace.rungValues.at("unconditional-rung") | |||||
| && trace.nodePowerValues.at("unconditional-coil"), | |||||
| "an unconditional rung must report energized power flow"); | |||||
| } | |||||
| void testWirePassThroughAndPowerTrace() | |||||
| { | |||||
| VirtualRegisterRepository repository; | |||||
| SoftwareLogicExecutor executor; | |||||
| ConditionExpression root; | |||||
| root.id = "wire-series"; | |||||
| root.kind = ConditionExpressionKind::Series; | |||||
| root.children = { | |||||
| ConditionExpression::fromNode(contact("wire-input", 0)), | |||||
| ConditionExpression::fromWire("wire-segment", 2), | |||||
| ConditionExpression::fromNode(contact("wire-output", 1))}; | |||||
| LadderRung wired_rung; | |||||
| wired_rung.id = "wired-rung"; | |||||
| wired_rung.name = "wired-rung"; | |||||
| wired_rung.condition = root; | |||||
| wired_rung.output = coil("wired-coil", 10); | |||||
| const ControlLogic program = logic({wired_rung}); | |||||
| writeBit(repository, 1, true); | |||||
| LogicTraceSnapshot trace; | |||||
| require(executor.executeScan({program}, repository, &trace).succeeded, | |||||
| "wire expression scan must succeed"); | |||||
| require(!readBit(repository, 10), | |||||
| "a horizontal wire must not bypass a false upstream series contact"); | |||||
| require(trace.expressionValues.at("wire-segment") | |||||
| && !trace.expressionInputValues.at("wire-segment") | |||||
| && !trace.expressionPowerValues.at("wire-segment"), | |||||
| "a wire must remain logically true without showing false upstream power"); | |||||
| writeBit(repository, 0, true); | |||||
| require(executor.executeScan({program}, repository, &trace).succeeded, | |||||
| "powered wire expression scan must succeed"); | |||||
| require(readBit(repository, 10) | |||||
| && trace.expressionInputValues.at("wire-segment") | |||||
| && trace.expressionPowerValues.at("wire-segment"), | |||||
| "a powered horizontal wire must pass current to the downstream contact"); | |||||
| } | |||||
| void testSeriesParallelContactsAndSequentialVisibility() | void testSeriesParallelContactsAndSequentialVisibility() | ||||
| { | { | ||||
| VirtualRegisterRepository repository; | VirtualRegisterRepository repository; | ||||
| @@ -260,11 +417,346 @@ void testMultipleLogicScanOrderAndTraceIsolation() | |||||
| disabled_draft.name = "Draft"; | disabled_draft.name = "Draft"; | ||||
| disabled_draft.enabled = false; | disabled_draft.enabled = false; | ||||
| disabled_draft.rungs.push_back( | disabled_draft.rungs.push_back( | ||||
| {"rung-1", "Draft", std::nullopt, std::nullopt}); | |||||
| {"rung-1", "Draft", {}, std::nullopt, std::nullopt}); | |||||
| require(executor.validate({first, disabled_draft}).succeeded, | require(executor.validate({first, disabled_draft}).succeeded, | ||||
| "a disabled incomplete logic module must not block offline execution"); | "a disabled incomplete logic module must not block offline execution"); | ||||
| } | } | ||||
| void testEdgeContactsAreOneScanPulsesAndAreLogicScoped() | |||||
| { | |||||
| VirtualRegisterRepository repository; | |||||
| SoftwareLogicExecutor executor; | |||||
| const ControlLogic program = logic({ | |||||
| rung("rising-rung", {{edgeContact("rising", 0, EdgeMode::Rising)}}, | |||||
| coil("rising-output", 10)), | |||||
| rung("falling-rung", {{edgeContact("falling", 1, EdgeMode::Falling)}}, | |||||
| coil("falling-output", 11))}); | |||||
| writeBit(repository, 0, true); | |||||
| require(executor.executeScan({program}, repository).succeeded, | |||||
| "the first high scan must evaluate rising edge input"); | |||||
| require(readBit(repository, 10), "rising edge must pulse on OFF to ON"); | |||||
| require(!readBit(repository, 11), "falling edge must stay off without ON to OFF"); | |||||
| require(executor.executeScan({program}, repository).succeeded, | |||||
| "a repeated high scan must succeed"); | |||||
| require(!readBit(repository, 10), "rising edge must clear after one scan"); | |||||
| writeBit(repository, 1, true); | |||||
| require(executor.executeScan({program}, repository).succeeded, | |||||
| "the falling edge input must establish its previous high state"); | |||||
| writeBit(repository, 1, false); | |||||
| require(executor.executeScan({program}, repository).succeeded, | |||||
| "the first low scan after high must succeed"); | |||||
| require(readBit(repository, 11), "falling edge must pulse on ON to OFF"); | |||||
| require(executor.executeScan({program}, repository).succeeded, | |||||
| "a repeated low scan must succeed"); | |||||
| require(!readBit(repository, 11), "falling edge must clear after one scan"); | |||||
| ControlLogic first = logic({ | |||||
| rung("shared-rung-first", {{edgeContact("shared-edge", 2, EdgeMode::Rising)}}, | |||||
| coil("shared-output-first", 20))}); | |||||
| first.id = "logic-first"; | |||||
| first.name = "First"; | |||||
| ControlLogic second = logic({ | |||||
| rung("shared-rung-second", {{edgeContact("shared-edge", 3, EdgeMode::Rising)}}, | |||||
| coil("shared-output-second", 21))}); | |||||
| second.id = "logic-second"; | |||||
| second.name = "Second"; | |||||
| writeBit(repository, 2, true); | |||||
| require(executor.executeScan({first, second}, repository).succeeded, | |||||
| "logic-scoped edge history must support duplicate node ids"); | |||||
| writeBit(repository, 3, true); | |||||
| require(executor.executeScan({first, second}, repository).succeeded, | |||||
| "each duplicate edge node must update its own history"); | |||||
| require(!readBit(repository, 20) && readBit(repository, 21), | |||||
| "duplicate node ids in different logic modules must not share edge history"); | |||||
| executor.resetRuntime(); | |||||
| require(executor.executeScan({first, second}, repository).succeeded, | |||||
| "resetting runtime must clear edge history"); | |||||
| require(readBit(repository, 20) && readBit(repository, 21), | |||||
| "a restarted runtime must treat current ON inputs as fresh rising edges"); | |||||
| } | |||||
| void testTonUsesElapsedTimeAndResetsAsNonRetentive() | |||||
| { | |||||
| VirtualRegisterRepository repository; | |||||
| SoftwareLogicExecutor executor; | |||||
| const ControlLogic program = logic({ | |||||
| rung("ton-rung", {{contact("ton-input", 0)}}, ton("ton-0", 0, 100)), | |||||
| rung("ton-feedback-rung", {{timerContact("timer-done", 0)}}, | |||||
| coil("ton-output", 30))}); | |||||
| const auto start = SoftwareLogicExecutor::TimePoint{}; | |||||
| LogicTraceSnapshot trace; | |||||
| require(executor.executeScanAt({program}, repository, start, &trace).succeeded, | |||||
| "an inactive TON must scan successfully"); | |||||
| require(!readBit(repository, 30) | |||||
| && trace.tonValues.at("ton-0").elapsedMs == 0 | |||||
| && !trace.tonValues.at("ton-0").done, | |||||
| "an inactive TON must have Q false and ET zero"); | |||||
| writeBit(repository, 0, true); | |||||
| require(executor.executeScanAt({program}, repository, start, &trace).succeeded, | |||||
| "the first active TON scan must start timing"); | |||||
| require(!readBit(repository, 30) && trace.tonValues.at("ton-0").elapsedMs == 0, | |||||
| "TON must not complete on its starting scan"); | |||||
| require(executor.executeScanAt( | |||||
| {program}, repository, | |||||
| start + std::chrono::milliseconds{99}, &trace) | |||||
| .succeeded, | |||||
| "TON must use elapsed monotonic time before the preset"); | |||||
| require(!readBit(repository, 30) | |||||
| && trace.tonValues.at("ton-0").elapsedMs == 99, | |||||
| "TON must remain false before PT"); | |||||
| require(executor.executeScanAt( | |||||
| {program}, repository, | |||||
| start + std::chrono::milliseconds{100}, &trace) | |||||
| .succeeded, | |||||
| "TON must complete at its preset time"); | |||||
| require(readBit(repository, 30) | |||||
| && trace.tonValues.at("ton-0").done, | |||||
| "the T contact must observe TON Q in a later network of the same scan"); | |||||
| writeBit(repository, 0, false); | |||||
| require(executor.executeScanAt( | |||||
| {program}, repository, | |||||
| start + std::chrono::milliseconds{150}, &trace) | |||||
| .succeeded, | |||||
| "disconnecting TON input must scan successfully"); | |||||
| require(!readBit(repository, 30) | |||||
| && trace.tonValues.at("ton-0").elapsedMs == 0 | |||||
| && !trace.tonValues.at("ton-0").done, | |||||
| "a non-retentive TON must reset ET and Q when input opens"); | |||||
| writeBit(repository, 0, true); | |||||
| require(executor.executeScanAt( | |||||
| {program}, repository, | |||||
| start + std::chrono::milliseconds{200}, &trace) | |||||
| .succeeded, | |||||
| "TON must restart timing after an input reset"); | |||||
| require(!readBit(repository, 30), "TON restart must not retain the old done state"); | |||||
| executor.resetRuntime(); | |||||
| require(executor.executeScanAt( | |||||
| {program}, repository, | |||||
| start + std::chrono::milliseconds{1000}, &trace) | |||||
| .succeeded, | |||||
| "resetRuntime must clear TON state"); | |||||
| require(!readBit(repository, 30), | |||||
| "a restarted runtime must start a currently active TON from zero"); | |||||
| } | |||||
| void testMultipleTimersAndNetworkOrder() | |||||
| { | |||||
| VirtualRegisterRepository repository; | |||||
| SoftwareLogicExecutor executor; | |||||
| const ControlLogic independent = logic({ | |||||
| rung("ton-1-rung", {{contact("input-1", 1)}}, ton("ton-1", 1, 100)), | |||||
| rung("ton-2-rung", {{contact("input-2", 2)}}, ton("ton-2", 2, 200))}); | |||||
| writeBit(repository, 1, true); | |||||
| writeBit(repository, 2, true); | |||||
| const auto start = SoftwareLogicExecutor::TimePoint{}; | |||||
| LogicTraceSnapshot trace; | |||||
| require(executor.executeScanAt({independent}, repository, start, &trace).succeeded, | |||||
| "multiple TON resources must start independently"); | |||||
| require(executor.executeScanAt( | |||||
| {independent}, repository, | |||||
| start + std::chrono::milliseconds{100}, &trace) | |||||
| .succeeded, | |||||
| "the first independent TON must reach PT without completing the second"); | |||||
| require(trace.tonValues.at("ton-1").done | |||||
| && !trace.tonValues.at("ton-2").done, | |||||
| "different T resources must retain independent elapsed time"); | |||||
| VirtualRegisterRepository ordered_repository; | |||||
| SoftwareLogicExecutor ordered_executor; | |||||
| const ControlLogic ordered = logic({ | |||||
| rung("feedback-before-ton", {{timerContact("early-timer", 4)}}, | |||||
| coil("early-output", 40)), | |||||
| rung("ton-after-feedback", {{contact("late-input", 4)}}, ton("ton-4", 4, 100))}); | |||||
| writeBit(ordered_repository, 4, true); | |||||
| require(ordered_executor.executeScanAt( | |||||
| {ordered}, ordered_repository, start, nullptr) | |||||
| .succeeded, | |||||
| "network order test must start TON after its earlier T contact"); | |||||
| require(!readBit(ordered_repository, 40), | |||||
| "an earlier T contact must see the previous scan state"); | |||||
| require(ordered_executor.executeScanAt( | |||||
| {ordered}, ordered_repository, | |||||
| start + std::chrono::milliseconds{100}, nullptr) | |||||
| .succeeded, | |||||
| "network order test must complete TON on the next scan"); | |||||
| require(!readBit(ordered_repository, 40), | |||||
| "the earlier T contact must remain false on the completion scan"); | |||||
| require(ordered_executor.executeScanAt( | |||||
| {ordered}, ordered_repository, | |||||
| start + std::chrono::milliseconds{101}, nullptr) | |||||
| .succeeded, | |||||
| "network order test must expose TON Q on the following scan"); | |||||
| require(readBit(ordered_repository, 40), | |||||
| "a later scan must observe the completed TON through T contact"); | |||||
| } | |||||
| void testCountersUseRisingEdgesAndExternalDValues() | |||||
| { | |||||
| VirtualRegisterRepository repository; | |||||
| SoftwareLogicExecutor executor; | |||||
| const ControlLogic up_program = logic({ | |||||
| rung( | |||||
| "ctu-rung", | |||||
| {{contact("count-input", 0)}}, | |||||
| counter( | |||||
| "ctu-0", | |||||
| 0, | |||||
| CounterMode::Up, | |||||
| 0, | |||||
| constantOperand(3), | |||||
| 1)), | |||||
| rung( | |||||
| "ctu-done-rung", | |||||
| {{counterContact("ctu-done", 0)}}, | |||||
| coil("ctu-done-output", 2))}); | |||||
| LogicTraceSnapshot trace; | |||||
| require(executor.executeScan({up_program}, repository, &trace).succeeded, | |||||
| "an inactive CTU must scan successfully"); | |||||
| require(readWord(repository, 0) == 0 && !readBit(repository, 2), | |||||
| "an inactive CTU must keep CV zero and Q false"); | |||||
| writeBit(repository, 0, true); | |||||
| require(executor.executeScan({up_program}, repository, &trace).succeeded, | |||||
| "the first CTU rising edge must scan successfully"); | |||||
| require(readWord(repository, 0) == 1, | |||||
| "CTU must increment the external D current value on a rising edge"); | |||||
| require(executor.executeScan({up_program}, repository, &trace).succeeded, | |||||
| "a held CTU input must scan successfully"); | |||||
| require(readWord(repository, 0) == 1, | |||||
| "a held CTU input must not count every scan"); | |||||
| for (int expected = 2; expected <= 3; ++expected) | |||||
| { | |||||
| writeBit(repository, 0, false); | |||||
| require(executor.executeScan({up_program}, repository).succeeded, | |||||
| "CTU falling preparation scan must succeed"); | |||||
| writeBit(repository, 0, true); | |||||
| require(executor.executeScan({up_program}, repository, &trace).succeeded, | |||||
| "CTU repeated rising edge must succeed"); | |||||
| require(readWord(repository, 0) == expected, | |||||
| "CTU must increment exactly once for each rising edge"); | |||||
| } | |||||
| require(readBit(repository, 2) | |||||
| && trace.counterValues.at("ctu-0").done, | |||||
| "the C contact must observe CTU completion in a later network"); | |||||
| writeBit(repository, 1, true); | |||||
| require(executor.executeScan({up_program}, repository, &trace).succeeded, | |||||
| "CTU reset must scan successfully"); | |||||
| require(readWord(repository, 0) == 0 && !readBit(repository, 2), | |||||
| "CTU reset must clear CV and the C completion state"); | |||||
| executor.resetRuntime(); | |||||
| repository.clear(); | |||||
| const ControlLogic down_program = logic({ | |||||
| rung( | |||||
| "ctd-rung", | |||||
| {{contact("down-input", 3)}}, | |||||
| counter( | |||||
| "ctd-1", | |||||
| 1, | |||||
| CounterMode::Down, | |||||
| 1, | |||||
| constantOperand(2), | |||||
| 4)), | |||||
| rung( | |||||
| "ctd-done-rung", | |||||
| {{counterContact("ctd-done", 1)}}, | |||||
| coil("ctd-done-output", 5))}); | |||||
| writeBit(repository, 4, true); | |||||
| require(executor.executeScan({down_program}, repository).succeeded, | |||||
| "CTD load reset must scan successfully"); | |||||
| require(readWord(repository, 1) == 2, | |||||
| "CTD reset must load PV into the external D current value"); | |||||
| writeBit(repository, 4, false); | |||||
| for (int expected = 1; expected >= 0; --expected) | |||||
| { | |||||
| writeBit(repository, 3, false); | |||||
| require(executor.executeScan({down_program}, repository).succeeded, | |||||
| "CTD falling preparation scan must succeed"); | |||||
| writeBit(repository, 3, true); | |||||
| require(executor.executeScan({down_program}, repository).succeeded, | |||||
| "CTD rising edge must scan successfully"); | |||||
| require(readWord(repository, 1) == expected, | |||||
| "CTD must decrement exactly once for each rising edge"); | |||||
| } | |||||
| require(readBit(repository, 5), | |||||
| "CTD completion contact must turn on when CV reaches zero"); | |||||
| } | |||||
| void testMoveAndSaturatingArithmetic() | |||||
| { | |||||
| VirtualRegisterRepository repository; | |||||
| SoftwareLogicExecutor executor; | |||||
| const ControlLogic program = logic({ | |||||
| rung( | |||||
| "move-rung", | |||||
| {{contact("execute", 0)}}, | |||||
| move("move", constantOperand(7), 10)), | |||||
| rung( | |||||
| "add-rung", | |||||
| {{contact("execute-add", 0)}}, | |||||
| arithmetic( | |||||
| "add", | |||||
| ArithmeticOperation::Add, | |||||
| registerOperand(10), | |||||
| constantOperand(32767), | |||||
| 11)), | |||||
| rung( | |||||
| "sub-rung", | |||||
| {{contact("execute-sub", 0)}}, | |||||
| arithmetic( | |||||
| "sub", | |||||
| ArithmeticOperation::Subtract, | |||||
| constantOperand(-32768), | |||||
| constantOperand(1), | |||||
| 12))}); | |||||
| LogicTraceSnapshot trace; | |||||
| require(executor.executeScan({program}, repository, &trace).succeeded, | |||||
| "inactive data instructions must scan successfully"); | |||||
| require(readWord(repository, 10) == 0, | |||||
| "MOVE must not write while its rung is false"); | |||||
| writeBit(repository, 0, true); | |||||
| require(executor.executeScan({program}, repository, &trace).succeeded, | |||||
| "active data instructions must scan successfully"); | |||||
| require(readWord(repository, 10) == 7, | |||||
| "MOVE must copy a constant into the destination D register"); | |||||
| require(readWord(repository, 11) == 32767 | |||||
| && trace.wordValues.at("add").overflow, | |||||
| "ADD must saturate positive overflow and expose the overflow trace"); | |||||
| require(readWord(repository, 12) == -32768 | |||||
| && trace.wordValues.at("sub").overflow, | |||||
| "SUB must saturate negative overflow and expose the overflow trace"); | |||||
| const ControlLogic repeated_add = logic({ | |||||
| rung( | |||||
| "repeated-add-rung", | |||||
| {{contact("repeated-add-input", 1)}}, | |||||
| arithmetic( | |||||
| "repeated-add", | |||||
| ArithmeticOperation::Add, | |||||
| registerOperand(20), | |||||
| constantOperand(1), | |||||
| 20))}); | |||||
| writeBit(repository, 1, true); | |||||
| require(executor.executeScan({repeated_add}, repository).succeeded | |||||
| && executor.executeScan({repeated_add}, repository).succeeded, | |||||
| "ADD must execute on every scan while its rung remains true"); | |||||
| require(readWord(repository, 20) == 2, | |||||
| "ADD with the same source and destination must accumulate by scan"); | |||||
| } | |||||
| void testSetResetPairOnSameAddress() | void testSetResetPairOnSameAddress() | ||||
| { | { | ||||
| VirtualRegisterRepository repository; | VirtualRegisterRepository repository; | ||||
| @@ -425,9 +917,16 @@ int main(int argc, char *argv[]) | |||||
| { | { | ||||
| testSeriesParallelContactsAndSequentialVisibility(); | testSeriesParallelContactsAndSequentialVisibility(); | ||||
| testNestedSeriesParallelExpression(); | testNestedSeriesParallelExpression(); | ||||
| testUnconditionalCoil(); | |||||
| testWirePassThroughAndPowerTrace(); | |||||
| testAllComparisons(); | testAllComparisons(); | ||||
| testSetResetAndDisabledLogic(); | testSetResetAndDisabledLogic(); | ||||
| testMultipleLogicScanOrderAndTraceIsolation(); | testMultipleLogicScanOrderAndTraceIsolation(); | ||||
| testEdgeContactsAreOneScanPulsesAndAreLogicScoped(); | |||||
| testTonUsesElapsedTimeAndResetsAsNonRetentive(); | |||||
| testMultipleTimersAndNetworkOrder(); | |||||
| testCountersUseRisingEdgesAndExternalDValues(); | |||||
| testMoveAndSaturatingArithmetic(); | |||||
| testSetResetPairOnSameAddress(); | testSetResetPairOnSameAddress(); | ||||
| testConflictingCoilsAreRejected(); | testConflictingCoilsAreRejected(); | ||||
| testHmiSimulationClosedLoop(); | testHmiSimulationClosedLoop(); | ||||
| @@ -1,29 +1,22 @@ | |||||
| QT += core | |||||
| QT -= gui | |||||
| include(pri/test_defaults.pri) | |||||
| include(pri/test_layers.pri) | |||||
| TEMPLATE = app | |||||
| TARGET = offline_simulation_service_tests | TARGET = offline_simulation_service_tests | ||||
| CONFIG += console c++17 warn_on | |||||
| CONFIG -= app_bundle | |||||
| INCLUDEPATH += ../src | |||||
| QT += core | |||||
| QT -= gui | |||||
| SOURCES += \ | SOURCES += \ | ||||
| offline_simulation_service_tests.cpp \ | offline_simulation_service_tests.cpp \ | ||||
| ../src/domain/register_address.cpp \ | |||||
| ../src/domain/register_repository.cpp \ | |||||
| ../src/domain/hmi_model.cpp \ | |||||
| ../src/domain/control_logic_model.cpp \ | |||||
| $$DOMAIN_REGISTER_SOURCES \ | |||||
| $$DOMAIN_HMI_SOURCES \ | |||||
| $$DOMAIN_LOGIC_SOURCES \ | |||||
| ../src/services/hmi_runtime_service.cpp \ | ../src/services/hmi_runtime_service.cpp \ | ||||
| ../src/services/software_logic_executor.cpp \ | |||||
| ../src/services/offline_simulation_service.cpp | |||||
| $$SERVICE_OFFLINE_SOURCES | |||||
| HEADERS += \ | HEADERS += \ | ||||
| ../src/domain/register_address.h \ | |||||
| ../src/domain/register_repository.h \ | |||||
| ../src/domain/hmi_model.h \ | |||||
| ../src/domain/control_logic_model.h \ | |||||
| $$DOMAIN_REGISTER_HEADERS \ | |||||
| $$DOMAIN_HMI_HEADERS \ | |||||
| $$DOMAIN_LOGIC_HEADERS \ | |||||
| ../src/services/hmi_runtime_service.h \ | ../src/services/hmi_runtime_service.h \ | ||||
| ../src/services/software_logic_executor.h \ | |||||
| ../src/services/offline_simulation_service.h | |||||
| $$SERVICE_OFFLINE_HEADERS \ | |||||
| $$TEST_SUPPORT_HEADERS | |||||
| @@ -0,0 +1,97 @@ | |||||
| #include "domain/control_logic_model.h" | |||||
| #include "domain/register_repository.h" | |||||
| #include "services/software_logic_executor.h" | |||||
| #include <QTest> | |||||
| #include <string> | |||||
| #include <vector> | |||||
| namespace | |||||
| { | |||||
| LogicNode contact(const std::string &id, int address) | |||||
| { | |||||
| return {id, | |||||
| ContactNodeConfig{ | |||||
| RegisterAddress{RegisterArea::M, address}, | |||||
| ContactMode::NormallyOpen}, | |||||
| true}; | |||||
| } | |||||
| LogicNode coil(const std::string &id, int address) | |||||
| { | |||||
| return {id, | |||||
| CoilNodeConfig{ | |||||
| RegisterAddress{RegisterArea::M, address}, | |||||
| CoilMode::Normal}, | |||||
| true}; | |||||
| } | |||||
| ControlLogic makeLogic(int index) | |||||
| { | |||||
| LadderRung rung; | |||||
| rung.id = "rung-" + std::to_string(index); | |||||
| rung.name = rung.id; | |||||
| rung.condition = ConditionExpression::fromNode( | |||||
| contact("contact-" + std::to_string(index), index)); | |||||
| rung.output = coil("coil-" + std::to_string(index), index + 100); | |||||
| return {"logic-" + std::to_string(index), | |||||
| "logic-" + std::to_string(index), | |||||
| {rung}, | |||||
| true}; | |||||
| } | |||||
| class PerformanceTests final : public QObject | |||||
| { | |||||
| Q_OBJECT | |||||
| private slots: | |||||
| void softwareExecutor_scansRepresentativeProject() | |||||
| { | |||||
| std::vector<ControlLogic> logics; | |||||
| logics.reserve(100); | |||||
| for (int index = 0; index < 100; ++index) | |||||
| { | |||||
| logics.push_back(makeLogic(index)); | |||||
| } | |||||
| SoftwareLogicExecutor executor; | |||||
| VirtualRegisterRepository repository; | |||||
| QVERIFY(executor.validate(logics).succeeded); | |||||
| QVERIFY(executor.executeScan(logics, repository).succeeded); | |||||
| QBENCHMARK | |||||
| { | |||||
| executor.executeScan(logics, repository); | |||||
| } | |||||
| } | |||||
| void virtualRepository_handlesBoundaryWorkload() | |||||
| { | |||||
| VirtualRegisterRepository repository; | |||||
| for (int index = 0; index <= RegisterAddress::kMaximumIndex; ++index) | |||||
| { | |||||
| QVERIFY(repository.writeBit( | |||||
| {RegisterArea::M, index}, (index % 2) == 0) | |||||
| .succeeded); | |||||
| QVERIFY(repository.readBit({RegisterArea::M, index}).succeeded); | |||||
| } | |||||
| QBENCHMARK | |||||
| { | |||||
| for (int index = 0; index <= RegisterAddress::kMaximumIndex; ++index) | |||||
| { | |||||
| repository.writeBit( | |||||
| {RegisterArea::M, index}, (index % 2) == 0); | |||||
| repository.readBit({RegisterArea::M, index}); | |||||
| } | |||||
| } | |||||
| } | |||||
| }; | |||||
| } // namespace | |||||
| QTEST_APPLESS_MAIN(PerformanceTests) | |||||
| #include "performance_tests.moc" | |||||
| @@ -0,0 +1,16 @@ | |||||
| include(pri/test_defaults.pri) | |||||
| include(pri/test_layers.pri) | |||||
| TARGET = performance_tests | |||||
| QT += testlib | |||||
| SOURCES += \ | |||||
| performance_tests.cpp \ | |||||
| $$DOMAIN_REGISTER_SOURCES \ | |||||
| $$DOMAIN_LOGIC_SOURCES \ | |||||
| ../src/services/software_logic_executor.cpp | |||||
| HEADERS += \ | |||||
| $$DOMAIN_REGISTER_HEADERS \ | |||||
| $$DOMAIN_LOGIC_HEADERS \ | |||||
| ../src/services/software_logic_executor.h | |||||
| @@ -1,6 +1,9 @@ | |||||
| #include "domain/active_register_repository.h" | #include "domain/active_register_repository.h" | ||||
| #include "domain/project_limits.h" | |||||
| #include "domain/project_storage.h" | #include "domain/project_storage.h" | ||||
| #include "infrastructure/plc_communication_error_classifier.h" | #include "infrastructure/plc_communication_error_classifier.h" | ||||
| #include "infrastructure/plc_communication_service.h" | |||||
| #include "support/test_support.h" | |||||
| #include "infrastructure/plc_register_repository.h" | #include "infrastructure/plc_register_repository.h" | ||||
| #include "services/offline_simulation_service.h" | #include "services/offline_simulation_service.h" | ||||
| #include "services/plc_communication_gateway.h" | #include "services/plc_communication_gateway.h" | ||||
| @@ -14,19 +17,7 @@ | |||||
| namespace { | namespace { | ||||
| class TestProjectStorage final : public ProjectStorage | |||||
| { | |||||
| public: | |||||
| ProjectSaveResult save(const Project &, const std::string &) override | |||||
| { | |||||
| return {true, ProjectStorageError::None, {}}; | |||||
| } | |||||
| ProjectLoadResult load(const std::string &) override | |||||
| { | |||||
| return {false, {}, ProjectStorageError::FileReadFailed, {}}; | |||||
| } | |||||
| }; | |||||
| using TestProjectStorage = TestSupport::InMemoryProjectStorage; | |||||
| class FakePlcGateway final : public PlcCommunicationGateway | class FakePlcGateway final : public PlcCommunicationGateway | ||||
| { | { | ||||
| @@ -61,9 +52,11 @@ public: | |||||
| } | } | ||||
| } | } | ||||
| void setPollAddresses(const std::vector<RegisterAddress> &addresses) override | |||||
| PlcCommunicationResult setPollAddresses( | |||||
| const std::vector<RegisterAddress> &addresses) override | |||||
| { | { | ||||
| poll_addresses = addresses; | poll_addresses = addresses; | ||||
| return {true, {}}; | |||||
| } | } | ||||
| PlcConnectionState state() const override { return connection_state; } | PlcConnectionState state() const override { return connection_state; } | ||||
| @@ -126,13 +119,7 @@ public: | |||||
| std::function<void(const std::string &)> error_reported; | std::function<void(const std::string &)> error_reported; | ||||
| }; | }; | ||||
| void require(bool condition, const std::string &message) | |||||
| { | |||||
| if (!condition) | |||||
| { | |||||
| throw std::runtime_error(message); | |||||
| } | |||||
| } | |||||
| using TestSupport::require; | |||||
| void testPlcCacheAndWriteForwarding() | void testPlcCacheAndWriteForwarding() | ||||
| { | { | ||||
| @@ -311,6 +298,68 @@ void testPlcCommunicationErrorClassification() | |||||
| "protocol errors must remain distinct from timeouts and disconnections"); | "protocol errors must remain distinct from timeouts and disconnections"); | ||||
| } | } | ||||
| void testPlcConfigurationBoundaries() | |||||
| { | |||||
| PlcSerialConfiguration configuration; | |||||
| configuration.portName = "COM3"; | |||||
| require(validatePlcSerialConfiguration(configuration).succeeded, | |||||
| "the standard COM3 9600 8E1 configuration must be accepted"); | |||||
| configuration.serverAddress = ProjectLimits::kMaximumPlcServerAddress; | |||||
| require(validatePlcSerialConfiguration(configuration).succeeded, | |||||
| "PLC station 247 must be accepted"); | |||||
| configuration.serverAddress = ProjectLimits::kMaximumPlcServerAddress + 1; | |||||
| require(!validatePlcSerialConfiguration(configuration).succeeded, | |||||
| "PLC station 248 must be rejected"); | |||||
| configuration.serverAddress = 1; | |||||
| configuration.retries = ProjectLimits::kMaximumRetries; | |||||
| require(validatePlcSerialConfiguration(configuration).succeeded, | |||||
| "five retries must be accepted"); | |||||
| configuration.retries = ProjectLimits::kMaximumRetries + 1; | |||||
| require(!validatePlcSerialConfiguration(configuration).succeeded, | |||||
| "six retries must be rejected"); | |||||
| configuration.retries = 0; | |||||
| configuration.responseTimeoutMs = ProjectLimits::kMinimumResponseTimeoutMs - 1; | |||||
| require(!validatePlcSerialConfiguration(configuration).succeeded, | |||||
| "a response timeout below 100 ms must be rejected"); | |||||
| configuration.responseTimeoutMs = ProjectLimits::kMinimumResponseTimeoutMs; | |||||
| require(validatePlcSerialConfiguration(configuration).succeeded, | |||||
| "a response timeout of 100 ms must be accepted"); | |||||
| } | |||||
| void testPlcPollQuantityBoundaries() | |||||
| { | |||||
| PlcRegisterRepository repository; | |||||
| PlcCommunicationService service(repository); | |||||
| std::vector<RegisterAddress> too_many_addresses; | |||||
| for (int index = 0; index <= RegisterAddress::kMaximumIndex; ++index) | |||||
| { | |||||
| too_many_addresses.push_back({RegisterArea::M, index}); | |||||
| } | |||||
| too_many_addresses.push_back({RegisterArea::D, 0}); | |||||
| require(!service.setPollAddresses(too_many_addresses).succeeded, | |||||
| "more than 1024 poll addresses must be rejected"); | |||||
| std::vector<RegisterAddress> too_many_blocks; | |||||
| for (int index = 0; index < 65; ++index) | |||||
| { | |||||
| too_many_blocks.push_back({RegisterArea::M, index * 2}); | |||||
| } | |||||
| require(!service.setPollAddresses(too_many_blocks).succeeded, | |||||
| "more than 64 Modbus read blocks must be rejected"); | |||||
| std::vector<RegisterAddress> maximum_contiguous_block; | |||||
| for (int index = 0; index < ProjectLimits::kMaximumModbusReadCount; ++index) | |||||
| { | |||||
| maximum_contiguous_block.push_back({RegisterArea::M, index}); | |||||
| } | |||||
| require(service.setPollAddresses(maximum_contiguous_block).succeeded, | |||||
| "a contiguous Modbus read block of 120 values must be accepted"); | |||||
| } | |||||
| } // namespace | } // namespace | ||||
| int main() | int main() | ||||
| @@ -321,6 +370,8 @@ int main() | |||||
| testRuntimeRepositorySwitchingAndDisconnect(); | testRuntimeRepositorySwitchingAndDisconnect(); | ||||
| testRuntimeFaultRevokesOnlineReadinessAndAllowsReconnect(); | testRuntimeFaultRevokesOnlineReadinessAndAllowsReconnect(); | ||||
| testPlcCommunicationErrorClassification(); | testPlcCommunicationErrorClassification(); | ||||
| testPlcConfigurationBoundaries(); | |||||
| testPlcPollQuantityBoundaries(); | |||||
| } | } | ||||
| catch (const std::exception &error) | catch (const std::exception &error) | ||||
| { | { | ||||
| @@ -1,44 +1,24 @@ | |||||
| QT += core serialbus | |||||
| include(pri/test_defaults.pri) | |||||
| include(pri/test_layers.pri) | |||||
| TEMPLATE = app | |||||
| TARGET = plc_runtime_tests | TARGET = plc_runtime_tests | ||||
| QT += core serialbus serialport | |||||
| CONFIG += console c++17 testcase warn_on | |||||
| CONFIG -= app_bundle | |||||
| INCLUDEPATH += ../src | |||||
| CONFIG += testcase | |||||
| SOURCES += \ | SOURCES += \ | ||||
| plc_runtime_tests.cpp \ | plc_runtime_tests.cpp \ | ||||
| ../src/domain/register_address.cpp \ | |||||
| ../src/domain/register_repository.cpp \ | |||||
| ../src/domain/active_register_repository.cpp \ | |||||
| ../src/domain/register_monitor_model.cpp \ | |||||
| ../src/domain/hmi_model.cpp \ | |||||
| ../src/domain/control_logic_model.cpp \ | |||||
| ../src/domain/project_model.cpp \ | |||||
| ../src/domain/runtime_state.cpp \ | |||||
| ../src/services/project_service.cpp \ | |||||
| ../src/services/software_logic_executor.cpp \ | |||||
| ../src/services/offline_simulation_service.cpp \ | |||||
| ../src/services/runtime_mode_service.cpp \ | |||||
| ../src/infrastructure/plc_communication_error_classifier.cpp \ | |||||
| ../src/infrastructure/plc_register_repository.cpp | |||||
| $$DOMAIN_ALL_SOURCES \ | |||||
| $$SERVICE_PROJECT_SOURCES \ | |||||
| $$SERVICE_OFFLINE_SOURCES \ | |||||
| $$SERVICE_RUNTIME_SOURCES \ | |||||
| $$INFRASTRUCTURE_PLC_SOURCES | |||||
| HEADERS += \ | HEADERS += \ | ||||
| ../src/domain/register_address.h \ | |||||
| ../src/domain/register_repository.h \ | |||||
| ../src/domain/active_register_repository.h \ | |||||
| ../src/domain/register_monitor_model.h \ | |||||
| ../src/domain/hmi_model.h \ | |||||
| ../src/domain/control_logic_model.h \ | |||||
| ../src/domain/project_model.h \ | |||||
| ../src/domain/project_storage.h \ | |||||
| ../src/domain/runtime_state.h \ | |||||
| ../src/services/project_service.h \ | |||||
| ../src/services/software_logic_executor.h \ | |||||
| ../src/services/offline_simulation_service.h \ | |||||
| ../src/services/runtime_mode_service.h \ | |||||
| $$DOMAIN_ALL_HEADERS \ | |||||
| $$SERVICE_PROJECT_HEADERS \ | |||||
| $$SERVICE_OFFLINE_HEADERS \ | |||||
| $$SERVICE_RUNTIME_HEADERS \ | |||||
| $$INFRASTRUCTURE_PLC_HEADERS \ | |||||
| ../src/services/plc_communication_gateway.h \ | ../src/services/plc_communication_gateway.h \ | ||||
| ../src/infrastructure/plc_communication_error_classifier.h \ | |||||
| ../src/infrastructure/plc_register_repository.h | |||||
| $$TEST_SUPPORT_HEADERS | |||||
| @@ -0,0 +1,8 @@ | |||||
| # 所有测试目标共享的 qmake 基础配置 | |||||
| TEMPLATE = app | |||||
| CONFIG += console c++17 warn_on | |||||
| CONFIG -= app_bundle | |||||
| INCLUDEPATH += $$PWD/../../src $$PWD/.. | |||||
| TEST_SUPPORT_HEADERS = $$PWD/../support/test_support.h | |||||
| @@ -0,0 +1,175 @@ | |||||
| # 测试按生产分层选择源码,避免每个 .pro 重复维护同一批路径 | |||||
| DOMAIN_REGISTER_SOURCES = \ | |||||
| ../src/domain/register_address.cpp \ | |||||
| ../src/domain/register_repository.cpp | |||||
| DOMAIN_REGISTER_HEADERS = \ | |||||
| ../src/domain/register_address.h \ | |||||
| ../src/domain/register_repository.h | |||||
| DOMAIN_HMI_SOURCES = \ | |||||
| ../src/domain/hmi_model.cpp \ | |||||
| ../src/domain/hmi_control_registry.cpp | |||||
| DOMAIN_HMI_HEADERS = \ | |||||
| ../src/domain/hmi_model.h \ | |||||
| ../src/domain/hmi_control_registry.h | |||||
| DOMAIN_LOGIC_SOURCES = \ | |||||
| ../src/domain/control_logic_model.cpp | |||||
| DOMAIN_LOGIC_HEADERS = \ | |||||
| ../src/domain/control_logic_model.h | |||||
| DOMAIN_PROJECT_SOURCES = \ | |||||
| ../src/domain/alarm_model.cpp \ | |||||
| ../src/domain/project_model.cpp | |||||
| DOMAIN_PROJECT_HEADERS = \ | |||||
| ../src/domain/alarm_model.h \ | |||||
| ../src/domain/project_model.h \ | |||||
| ../src/domain/project_limits.h \ | |||||
| ../src/domain/project_storage.h | |||||
| DOMAIN_RUNTIME_SOURCES = \ | |||||
| ../src/domain/runtime_state.cpp | |||||
| DOMAIN_RUNTIME_HEADERS = \ | |||||
| ../src/domain/runtime_state.h | |||||
| DOMAIN_MONITOR_SOURCES = \ | |||||
| ../src/domain/active_register_repository.cpp \ | |||||
| ../src/domain/register_monitor_model.cpp | |||||
| DOMAIN_MONITOR_HEADERS = \ | |||||
| ../src/domain/active_register_repository.h \ | |||||
| ../src/domain/register_monitor_model.h | |||||
| DOMAIN_ALL_SOURCES = \ | |||||
| $$DOMAIN_REGISTER_SOURCES \ | |||||
| $$DOMAIN_HMI_SOURCES \ | |||||
| $$DOMAIN_LOGIC_SOURCES \ | |||||
| $$DOMAIN_PROJECT_SOURCES \ | |||||
| $$DOMAIN_RUNTIME_SOURCES \ | |||||
| $$DOMAIN_MONITOR_SOURCES | |||||
| DOMAIN_ALL_HEADERS = \ | |||||
| $$DOMAIN_REGISTER_HEADERS \ | |||||
| $$DOMAIN_HMI_HEADERS \ | |||||
| $$DOMAIN_LOGIC_HEADERS \ | |||||
| $$DOMAIN_PROJECT_HEADERS \ | |||||
| $$DOMAIN_RUNTIME_HEADERS \ | |||||
| $$DOMAIN_MONITOR_HEADERS | |||||
| SERVICE_PROJECT_SOURCES = \ | |||||
| ../src/services/project_service.cpp | |||||
| SERVICE_PROJECT_HEADERS = \ | |||||
| ../src/services/project_service.h | |||||
| SERVICE_ALARM_SOURCES = \ | |||||
| ../src/services/alarm_editor_service.cpp \ | |||||
| ../src/services/alarm_service.cpp | |||||
| SERVICE_ALARM_HEADERS = \ | |||||
| ../src/services/alarm_editor_service.h \ | |||||
| ../src/services/alarm_service.h | |||||
| SERVICE_HMI_SOURCES = \ | |||||
| ../src/services/hmi_editor_service.cpp \ | |||||
| ../src/services/hmi_navigation_service.cpp \ | |||||
| ../src/services/hmi_runtime_service.cpp | |||||
| SERVICE_HMI_HEADERS = \ | |||||
| ../src/services/editor_history.h \ | |||||
| ../src/services/hmi_editor_service.h \ | |||||
| ../src/services/hmi_navigation_service.h \ | |||||
| ../src/services/hmi_runtime_service.h | |||||
| SERVICE_LOGIC_SOURCES = \ | |||||
| ../src/services/logic_editor_service.cpp | |||||
| SERVICE_LOGIC_HEADERS = \ | |||||
| ../src/services/editor_history.h \ | |||||
| ../src/services/logic_editor_service.h | |||||
| SERVICE_OFFLINE_SOURCES = \ | |||||
| ../src/services/software_logic_executor.cpp \ | |||||
| ../src/services/offline_simulation_service.cpp | |||||
| SERVICE_OFFLINE_HEADERS = \ | |||||
| ../src/services/software_logic_executor.h \ | |||||
| ../src/services/offline_simulation_service.h | |||||
| SERVICE_RUNTIME_SOURCES = \ | |||||
| ../src/services/runtime_mode_service.cpp | |||||
| SERVICE_RUNTIME_HEADERS = \ | |||||
| ../src/services/runtime_mode_service.h | |||||
| SERVICE_MONITOR_SOURCES = \ | |||||
| ../src/services/register_monitor_service.cpp \ | |||||
| ../src/services/register_comment_service.cpp | |||||
| SERVICE_MONITOR_HEADERS = \ | |||||
| ../src/services/register_monitor_service.h \ | |||||
| ../src/services/register_comment_service.h | |||||
| INFRASTRUCTURE_JSON_SOURCES = \ | |||||
| ../src/infrastructure/json_project_storage.cpp | |||||
| INFRASTRUCTURE_JSON_HEADERS = \ | |||||
| ../src/infrastructure/json_project_storage.h | |||||
| INFRASTRUCTURE_PLC_SOURCES = \ | |||||
| ../src/infrastructure/plc_communication_error_classifier.cpp \ | |||||
| ../src/infrastructure/plc_register_repository.cpp \ | |||||
| ../src/infrastructure/plc_communication_service.cpp | |||||
| INFRASTRUCTURE_PLC_HEADERS = \ | |||||
| ../src/infrastructure/plc_communication_error_classifier.h \ | |||||
| ../src/infrastructure/plc_register_repository.h \ | |||||
| ../src/infrastructure/plc_communication_service.h | |||||
| UI_MAIN_SOURCES = \ | |||||
| ../src/ui/main_window.cpp \ | |||||
| ../src/ui/logic_instruction_dialog.cpp \ | |||||
| ../src/ui/alarm_configuration_dialog.cpp \ | |||||
| ../src/ui/register_comment_dialog.cpp \ | |||||
| ../src/ui/plc_connection_dialog.cpp \ | |||||
| ../src/ui/free_monitor_widget.cpp \ | |||||
| ../src/ui/runtime_monitor_window.cpp \ | |||||
| ../src/ui/runtime_monitor_widget.cpp \ | |||||
| ../src/ui/toolbar_icon_factory.cpp \ | |||||
| ../src/ui/project_workspace_controller.cpp \ | |||||
| ../src/ui/property_panel_controller.cpp \ | |||||
| ../src/ui/runtime_panel_controller.cpp \ | |||||
| ../src/ui/hmi_editor_widget.cpp \ | |||||
| ../src/ui/logic_editor_widget.cpp | |||||
| UI_MAIN_HEADERS = \ | |||||
| ../src/ui/main_window.h \ | |||||
| ../src/ui/logic_instruction_dialog.h \ | |||||
| ../src/ui/alarm_configuration_dialog.h \ | |||||
| ../src/ui/register_comment_dialog.h \ | |||||
| ../src/ui/plc_connection_dialog.h \ | |||||
| ../src/ui/free_monitor_widget.h \ | |||||
| ../src/ui/runtime_monitor_window.h \ | |||||
| ../src/ui/runtime_monitor_widget.h \ | |||||
| ../src/ui/toolbar_icon_factory.h \ | |||||
| ../src/ui/project_workspace_controller.h \ | |||||
| ../src/ui/property_panel_controller.h \ | |||||
| ../src/ui/runtime_panel_controller.h \ | |||||
| ../src/ui/hmi_editor_widget.h \ | |||||
| ../src/ui/logic_editor_widget.h | |||||
| UI_MAIN_FORMS = \ | |||||
| ../src/ui/main_window.ui \ | |||||
| ../src/ui/logic_instruction_dialog.ui \ | |||||
| ../src/ui/alarm_configuration_dialog.ui \ | |||||
| ../src/ui/register_comment_dialog.ui \ | |||||
| ../src/ui/plc_connection_dialog.ui \ | |||||
| ../src/ui/free_monitor_widget.ui \ | |||||
| ../src/ui/runtime_monitor_window.ui \ | |||||
| ../src/ui/runtime_monitor_widget.ui | |||||
| @@ -1,6 +1,10 @@ | |||||
| #include "domain/project_storage.h" | #include "domain/project_storage.h" | ||||
| #include "domain/hmi_model.h" | |||||
| #include "infrastructure/json_project_storage.h" | #include "infrastructure/json_project_storage.h" | ||||
| #include "support/test_support.h" | |||||
| #include "services/register_comment_service.h" | |||||
| #include "services/project_service.h" | #include "services/project_service.h" | ||||
| #include "domain/project_limits.h" | |||||
| #include <QFile> | #include <QFile> | ||||
| #include <QJsonArray> | #include <QJsonArray> | ||||
| @@ -12,16 +16,11 @@ | |||||
| #include <iostream> | #include <iostream> | ||||
| #include <stdexcept> | #include <stdexcept> | ||||
| #include <string> | #include <string> | ||||
| #include <utility> | |||||
| namespace { | namespace { | ||||
| void require(bool condition, const std::string &message) | |||||
| { | |||||
| if (!condition) | |||||
| { | |||||
| throw std::runtime_error(message); | |||||
| } | |||||
| } | |||||
| using TestSupport::require; | |||||
| Project makeExampleProject() | Project makeExampleProject() | ||||
| { | { | ||||
| @@ -34,6 +33,10 @@ Project makeExampleProject() | |||||
| start_button.binding = RegisterAddress{RegisterArea::M, 0}; | start_button.binding = RegisterAddress{RegisterArea::M, 0}; | ||||
| start_button.buttonOperation = HmiButtonOperation::SetOn; | start_button.buttonOperation = HmiButtonOperation::SetOn; | ||||
| start_button.properties.emplace("color", "green"); | start_button.properties.emplace("color", "green"); | ||||
| start_button.properties.emplace(HmiAppearanceProperty::kTextColor, "#E53935"); | |||||
| start_button.properties.emplace(HmiAppearanceProperty::kFontSize, "18"); | |||||
| start_button.properties.emplace(HmiAppearanceProperty::kFontBold, "true"); | |||||
| start_button.properties.emplace(HmiAppearanceProperty::kFontItalic, "false"); | |||||
| HmiControl running_indicator; | HmiControl running_indicator; | ||||
| running_indicator.id = "running-indicator"; | running_indicator.id = "running-indicator"; | ||||
| @@ -72,6 +75,20 @@ Project makeExampleProject() | |||||
| settings_jump.text = "Settings"; | settings_jump.text = "Settings"; | ||||
| settings_jump.pageJump = HmiPageJumpConfig{"settings-page"}; | settings_jump.pageJump = HmiPageJumpConfig{"settings-page"}; | ||||
| HmiControl alarm_list; | |||||
| alarm_list.id = "alarm-list"; | |||||
| alarm_list.type = HmiControlType::AlarmList; | |||||
| alarm_list.bounds = {10, 200, 360, 180}; | |||||
| alarm_list.text = "Alarms"; | |||||
| HmiControl progress_bar; | |||||
| progress_bar.id = "progress-bar"; | |||||
| progress_bar.type = HmiControlType::ProgressBar; | |||||
| progress_bar.bounds = {400, 20, 240, 36}; | |||||
| progress_bar.text = "Completion"; | |||||
| progress_bar.binding = RegisterAddress{RegisterArea::D, 4}; | |||||
| progress_bar.progressBar = HmiProgressBarConfig{-20, 80, true}; | |||||
| HmiPage page; | HmiPage page; | ||||
| page.id = "main-page"; | page.id = "main-page"; | ||||
| page.name = "Main"; | page.name = "Main"; | ||||
| @@ -81,6 +98,8 @@ Project makeExampleProject() | |||||
| page.controls.push_back(target_input); | page.controls.push_back(target_input); | ||||
| page.controls.push_back(title_label); | page.controls.push_back(title_label); | ||||
| page.controls.push_back(settings_jump); | page.controls.push_back(settings_jump); | ||||
| page.controls.push_back(alarm_list); | |||||
| page.controls.push_back(progress_bar); | |||||
| HmiPage settings_page; | HmiPage settings_page; | ||||
| settings_page.id = "settings-page"; | settings_page.id = "settings-page"; | ||||
| @@ -118,6 +137,7 @@ Project makeExampleProject() | |||||
| LadderRung rung; | LadderRung rung; | ||||
| rung.id = "rung-1"; | rung.id = "rung-1"; | ||||
| rung.name = "Network 1"; | rung.name = "Network 1"; | ||||
| rung.comment = "启动条件与温度检查"; | |||||
| ConditionExpression parallel; | ConditionExpression parallel; | ||||
| parallel.id = "parallel-start"; | parallel.id = "parallel-start"; | ||||
| parallel.kind = ConditionExpressionKind::Parallel; | parallel.kind = ConditionExpressionKind::Parallel; | ||||
| @@ -129,23 +149,186 @@ Project makeExampleProject() | |||||
| series.kind = ConditionExpressionKind::Series; | series.kind = ConditionExpressionKind::Series; | ||||
| series.children = { | series.children = { | ||||
| std::move(parallel), | std::move(parallel), | ||||
| ConditionExpression::fromWire("start-wire", 2), | |||||
| ConditionExpression::fromNode(compare)}; | ConditionExpression::fromNode(compare)}; | ||||
| rung.condition = std::move(series); | rung.condition = std::move(series); | ||||
| rung.output = coil; | rung.output = coil; | ||||
| logic.rungs.push_back(rung); | logic.rungs.push_back(rung); | ||||
| LogicNode rising_edge; | |||||
| rising_edge.id = "rising-edge"; | |||||
| rising_edge.config = EdgeContactNodeConfig{ | |||||
| RegisterAddress{RegisterArea::M, 4}, EdgeMode::Rising}; | |||||
| LogicNode ton_output; | |||||
| ton_output.id = "ton-output"; | |||||
| ton_output.config = TonNodeConfig{TimerAddress{7}, 2500}; | |||||
| LadderRung ton_rung; | |||||
| ton_rung.id = "ton-rung"; | |||||
| ton_rung.name = "TON network"; | |||||
| ton_rung.comment = "延时启动"; | |||||
| ton_rung.condition = ConditionExpression::fromNode(rising_edge); | |||||
| ton_rung.output = ton_output; | |||||
| logic.rungs.push_back(ton_rung); | |||||
| LogicNode falling_edge; | |||||
| falling_edge.id = "falling-edge"; | |||||
| falling_edge.config = EdgeContactNodeConfig{ | |||||
| RegisterAddress{RegisterArea::M, 5}, EdgeMode::Falling}; | |||||
| LogicNode timer_contact; | |||||
| timer_contact.id = "timer-contact"; | |||||
| timer_contact.config = TimerContactNodeConfig{ | |||||
| TimerAddress{7}, ContactMode::NormallyOpen}; | |||||
| LogicNode timer_coil; | |||||
| timer_coil.id = "timer-coil"; | |||||
| timer_coil.config = CoilNodeConfig{ | |||||
| RegisterAddress{RegisterArea::M, 6}, CoilMode::Normal}; | |||||
| LadderRung timer_rung; | |||||
| timer_rung.id = "timer-rung"; | |||||
| timer_rung.name = "Timer feedback"; | |||||
| timer_rung.comment = "定时器完成反馈"; | |||||
| ConditionExpression timer_series; | |||||
| timer_series.id = "timer-series"; | |||||
| timer_series.kind = ConditionExpressionKind::Series; | |||||
| timer_series.children = { | |||||
| ConditionExpression::fromNode(falling_edge), | |||||
| ConditionExpression::fromNode(timer_contact)}; | |||||
| timer_rung.condition = std::move(timer_series); | |||||
| timer_rung.output = timer_coil; | |||||
| logic.rungs.push_back(timer_rung); | |||||
| LogicNode counter_input; | |||||
| counter_input.id = "counter-input"; | |||||
| counter_input.config = ContactNodeConfig{ | |||||
| RegisterAddress{RegisterArea::M, 7}, ContactMode::NormallyOpen}; | |||||
| LogicNode counter_output; | |||||
| counter_output.id = "counter-output"; | |||||
| counter_output.config = CounterNodeConfig{ | |||||
| CounterAddress{3}, | |||||
| CounterMode::Up, | |||||
| RegisterAddress{RegisterArea::D, 10}, | |||||
| WordOperand{ | |||||
| WordOperandKind::Register, | |||||
| RegisterAddress{RegisterArea::D, 11}, | |||||
| 0}, | |||||
| RegisterAddress{RegisterArea::M, 8}}; | |||||
| LadderRung counter_rung; | |||||
| counter_rung.id = "counter-rung"; | |||||
| counter_rung.name = "Counter network"; | |||||
| counter_rung.comment = "计数器上升沿计数"; | |||||
| counter_rung.condition = ConditionExpression::fromNode(counter_input); | |||||
| counter_rung.output = counter_output; | |||||
| logic.rungs.push_back(counter_rung); | |||||
| LogicNode counter_contact; | |||||
| counter_contact.id = "counter-contact"; | |||||
| counter_contact.config = CounterContactNodeConfig{ | |||||
| CounterAddress{3}, ContactMode::NormallyOpen}; | |||||
| LogicNode counter_coil; | |||||
| counter_coil.id = "counter-coil"; | |||||
| counter_coil.config = CoilNodeConfig{ | |||||
| RegisterAddress{RegisterArea::M, 9}, CoilMode::Normal}; | |||||
| LadderRung counter_feedback_rung; | |||||
| counter_feedback_rung.id = "counter-feedback-rung"; | |||||
| counter_feedback_rung.name = "Counter feedback"; | |||||
| counter_feedback_rung.condition = ConditionExpression::fromNode(counter_contact); | |||||
| counter_feedback_rung.output = counter_coil; | |||||
| logic.rungs.push_back(counter_feedback_rung); | |||||
| const auto addDataRung = [&logic]( | |||||
| const std::string &rung_id, | |||||
| const std::string &input_id, | |||||
| int input_address, | |||||
| LogicNode output) | |||||
| { | |||||
| LogicNode input; | |||||
| input.id = input_id; | |||||
| input.config = ContactNodeConfig{ | |||||
| RegisterAddress{RegisterArea::M, input_address}, | |||||
| ContactMode::NormallyOpen}; | |||||
| LadderRung data_rung; | |||||
| data_rung.id = rung_id; | |||||
| data_rung.name = rung_id; | |||||
| data_rung.condition = ConditionExpression::fromNode(input); | |||||
| data_rung.output = std::move(output); | |||||
| logic.rungs.push_back(std::move(data_rung)); | |||||
| }; | |||||
| addDataRung( | |||||
| "move-rung", | |||||
| "move-input", | |||||
| 20, | |||||
| LogicNode{ | |||||
| "move-output", | |||||
| MoveNodeConfig{ | |||||
| WordOperand{ | |||||
| WordOperandKind::Constant, | |||||
| RegisterAddress{RegisterArea::D, 0}, | |||||
| 25}, | |||||
| RegisterAddress{RegisterArea::D, 20}}, | |||||
| true}); | |||||
| addDataRung( | |||||
| "add-rung", | |||||
| "add-input", | |||||
| 21, | |||||
| LogicNode{ | |||||
| "add-output", | |||||
| ArithmeticNodeConfig{ | |||||
| ArithmeticOperation::Add, | |||||
| WordOperand{ | |||||
| WordOperandKind::Register, | |||||
| RegisterAddress{RegisterArea::D, 20}, | |||||
| 0}, | |||||
| WordOperand{ | |||||
| WordOperandKind::Constant, | |||||
| RegisterAddress{RegisterArea::D, 0}, | |||||
| 1}, | |||||
| RegisterAddress{RegisterArea::D, 21}}, | |||||
| true}); | |||||
| addDataRung( | |||||
| "sub-rung", | |||||
| "sub-input", | |||||
| 22, | |||||
| LogicNode{ | |||||
| "sub-output", | |||||
| ArithmeticNodeConfig{ | |||||
| ArithmeticOperation::Subtract, | |||||
| WordOperand{ | |||||
| WordOperandKind::Register, | |||||
| RegisterAddress{RegisterArea::D, 21}, | |||||
| 0}, | |||||
| WordOperand{ | |||||
| WordOperandKind::Constant, | |||||
| RegisterAddress{RegisterArea::D, 0}, | |||||
| 1}, | |||||
| RegisterAddress{RegisterArea::D, 22}}, | |||||
| true}); | |||||
| Project project; | Project project; | ||||
| project.metadata = {"example-project", "Example project", "1.0"}; | project.metadata = {"example-project", "Example project", "1.0"}; | ||||
| project.hmiPages.push_back(page); | project.hmiPages.push_back(page); | ||||
| project.hmiPages.push_back(settings_page); | project.hmiPages.push_back(settings_page); | ||||
| project.initialHmiPageId = page.id; | project.initialHmiPageId = page.id; | ||||
| project.alarmDefinitions.push_back( | |||||
| {"alarm-emergency", | |||||
| RegisterAddress{RegisterArea::M, 10}, | |||||
| AlarmCondition::MOn, | |||||
| 0, | |||||
| "Emergency stop"}); | |||||
| project.alarmDefinitions.push_back( | |||||
| {"alarm-temperature", | |||||
| RegisterAddress{RegisterArea::D, 2}, | |||||
| AlarmCondition::DHigh, | |||||
| 80, | |||||
| "Temperature high"}); | |||||
| project.registerComments = { | |||||
| {RegisterAddress{RegisterArea::M, 0}, "启动按钮"}, | |||||
| {RegisterAddress{RegisterArea::D, 2}, "当前温度"}}; | |||||
| project.controlLogics.push_back(logic); | project.controlLogics.push_back(logic); | ||||
| ControlLogic draft_logic; | ControlLogic draft_logic; | ||||
| draft_logic.id = "draft-logic"; | draft_logic.id = "draft-logic"; | ||||
| draft_logic.name = "Draft logic"; | draft_logic.name = "Draft logic"; | ||||
| draft_logic.enabled = false; | draft_logic.enabled = false; | ||||
| draft_logic.rungs.push_back( | draft_logic.rungs.push_back( | ||||
| {"rung-1", "Draft network", std::nullopt, std::nullopt}); | |||||
| {"rung-1", "Draft network", {}, std::nullopt, std::nullopt}); | |||||
| project.controlLogics.push_back(draft_logic); | project.controlLogics.push_back(draft_logic); | ||||
| return project; | return project; | ||||
| } | } | ||||
| @@ -188,6 +371,41 @@ void testEmptyProjectRoundTrip() | |||||
| require(service.project().hmiPages.empty(), "empty project must have no HMI pages"); | require(service.project().hmiPages.empty(), "empty project must have no HMI pages"); | ||||
| require(service.project().controlLogics.empty(), | require(service.project().controlLogics.empty(), | ||||
| "empty project must have no control logics"); | "empty project must have no control logics"); | ||||
| require(service.project().alarmDefinitions.empty(), | |||||
| "empty project must have no alarm definitions"); | |||||
| } | |||||
| void testUnconditionalOutputRoundTrip() | |||||
| { | |||||
| QTemporaryDir directory; | |||||
| require(directory.isValid(), "temporary directory must be valid"); | |||||
| JsonProjectStorage storage; | |||||
| ProjectService service(storage); | |||||
| require(service.createNewProject("Unconditional output").succeeded, | |||||
| "unconditional output project creation must succeed"); | |||||
| LadderRung rung; | |||||
| rung.id = "unconditional-rung"; | |||||
| rung.name = "Unconditional rung"; | |||||
| LogicNode coil; | |||||
| coil.id = "unconditional-coil"; | |||||
| coil.config = CoilNodeConfig{ | |||||
| RegisterAddress{RegisterArea::M, 10}, CoilMode::Normal}; | |||||
| rung.output = coil; | |||||
| service.editProject().controlLogics.push_back( | |||||
| ControlLogic{"logic-1", "Logic 1", {rung}, true}); | |||||
| require(service.project().validateForRunning(), | |||||
| "an output-only project must be runnable before saving"); | |||||
| const QString path = directory.filePath("unconditional-output.json"); | |||||
| require(service.saveAs(path.toStdString()).succeeded, | |||||
| "an output-only project must save successfully"); | |||||
| require(service.load(path.toStdString()).succeeded, | |||||
| "an output-only project must load successfully"); | |||||
| const LadderRung &loaded = service.project().controlLogics.front().rungs.front(); | |||||
| require(!loaded.condition.has_value() && loaded.output.has_value() | |||||
| && loaded.validateForRunning(), | |||||
| "JSON round trip must preserve unconditional output semantics"); | |||||
| } | } | ||||
| void testExampleProjectRoundTrip() | void testExampleProjectRoundTrip() | ||||
| @@ -205,6 +423,17 @@ void testExampleProjectRoundTrip() | |||||
| const QString invalid_operation_path = directory.filePath("invalid-operation.json"); | const QString invalid_operation_path = directory.filePath("invalid-operation.json"); | ||||
| const QString missing_initial_path = directory.filePath("missing-initial.json"); | const QString missing_initial_path = directory.filePath("missing-initial.json"); | ||||
| const QString missing_target_path = directory.filePath("missing-target.json"); | const QString missing_target_path = directory.filePath("missing-target.json"); | ||||
| const QString missing_progress_range_path = directory.filePath( | |||||
| "missing-progress-range.json"); | |||||
| const QString missing_alarms_path = directory.filePath("missing-alarms.json"); | |||||
| const QString missing_register_comments_path = directory.filePath( | |||||
| "missing-register-comments.json"); | |||||
| const QString missing_rung_comment_path = directory.filePath( | |||||
| "missing-rung-comment.json"); | |||||
| const QString multiline_register_comment_path = directory.filePath( | |||||
| "multiline-register-comment.json"); | |||||
| const QString multiline_rung_comment_path = directory.filePath( | |||||
| "multiline-rung-comment.json"); | |||||
| require(service.saveAs(first_path.toStdString()).succeeded, | require(service.saveAs(first_path.toStdString()).succeeded, | ||||
| "example project save must succeed"); | "example project save must succeed"); | ||||
| const QByteArray saved_json = readBytes(first_path); | const QByteArray saved_json = readBytes(first_path); | ||||
| @@ -218,8 +447,26 @@ void testExampleProjectRoundTrip() | |||||
| require(saved_json.contains("\"formatVersion\": \"1.0\"") | require(saved_json.contains("\"formatVersion\": \"1.0\"") | ||||
| && saved_json.contains("\"buttonOperation\": \"setOn\"") | && saved_json.contains("\"buttonOperation\": \"setOn\"") | ||||
| && saved_json.contains("\"initialHmiPageId\": \"main-page\"") | && saved_json.contains("\"initialHmiPageId\": \"main-page\"") | ||||
| && saved_json.contains("\"targetPageId\": \"settings-page\""), | |||||
| "version 1.0 projects must persist the current multi-page schema"); | |||||
| && saved_json.contains("\"targetPageId\": \"settings-page\"") | |||||
| && saved_json.contains("\"alarmDefinitions\"") | |||||
| && saved_json.contains("\"registerComments\"") | |||||
| && saved_json.contains("\"text\": \"启动按钮\"") | |||||
| && saved_json.contains("\"type\": \"edgeContact\"") | |||||
| && saved_json.contains("\"type\": \"timerContact\"") | |||||
| && saved_json.contains("\"type\": \"ton\"") | |||||
| && saved_json.contains("\"type\": \"counterContact\"") | |||||
| && saved_json.contains("\"type\": \"counter\"") | |||||
| && saved_json.contains("\"type\": \"move\"") | |||||
| && saved_json.contains("\"type\": \"arithmetic\"") | |||||
| && saved_json.contains("\"operation\": \"add\"") | |||||
| && saved_json.contains("\"operation\": \"subtract\"") | |||||
| && saved_json.contains("\"comment\": \"启动条件与温度检查\"") | |||||
| && saved_json.contains("\"type\": \"alarmList\"") | |||||
| && saved_json.contains("\"type\": \"progressBar\"") | |||||
| && saved_json.contains("\"minimumValue\": -20") | |||||
| && saved_json.contains("\"maximumValue\": 80") | |||||
| && saved_json.contains("\"showValue\": true"), | |||||
| "version 1.0 projects must persist pages and alarm definitions"); | |||||
| require(!saved_json.contains("\"stages\"") | require(!saved_json.contains("\"stages\"") | ||||
| && !saved_json.contains("\"branches\""), | && !saved_json.contains("\"branches\""), | ||||
| "current project format must not contain the removed stage model"); | "current project format must not contain the removed stage model"); | ||||
| @@ -234,8 +481,8 @@ void testExampleProjectRoundTrip() | |||||
| require(project.hmiPages.size() == 2, "HMI page count must survive round trip"); | require(project.hmiPages.size() == 2, "HMI page count must survive round trip"); | ||||
| require(project.initialHmiPageId == "main-page", | require(project.initialHmiPageId == "main-page", | ||||
| "the initial HMI page id must survive round trip"); | "the initial HMI page id must survive round trip"); | ||||
| require(project.hmiPages.front().controls.size() == 6, | |||||
| "register, Label and PageJump controls must survive round trip"); | |||||
| require(project.hmiPages.front().controls.size() == 8, | |||||
| "register, navigation, AlarmList and ProgressBar controls must survive round trip"); | |||||
| require(project.hmiPages.front().controls.front().binding->area() | require(project.hmiPages.front().controls.front().binding->area() | ||||
| == RegisterArea::M, | == RegisterArea::M, | ||||
| "HMI M binding must survive round trip"); | "HMI M binding must survive round trip"); | ||||
| @@ -244,6 +491,15 @@ void testExampleProjectRoundTrip() | |||||
| "HMI button operation must survive round trip"); | "HMI button operation must survive round trip"); | ||||
| require(project.hmiPages.front().controls.front().properties.at("color") == "green", | require(project.hmiPages.front().controls.front().properties.at("color") == "green", | ||||
| "HMI properties must survive round trip"); | "HMI properties must survive round trip"); | ||||
| require(project.hmiPages.front().controls.front().properties.at( | |||||
| HmiAppearanceProperty::kTextColor) == "#E53935" | |||||
| && project.hmiPages.front().controls.front().properties.at( | |||||
| HmiAppearanceProperty::kFontSize) == "18" | |||||
| && project.hmiPages.front().controls.front().properties.at( | |||||
| HmiAppearanceProperty::kFontBold) == "true" | |||||
| && project.hmiPages.front().controls.front().properties.at( | |||||
| HmiAppearanceProperty::kFontItalic) == "false", | |||||
| "HMI appearance properties must survive round trip"); | |||||
| require(project.hmiPages.front().controls.at(1).type == HmiControlType::Indicator, | require(project.hmiPages.front().controls.at(1).type == HmiControlType::Indicator, | ||||
| "indicator control type must survive round trip"); | "indicator control type must survive round trip"); | ||||
| require(project.hmiPages.front().controls.at(2).binding->area() == RegisterArea::D, | require(project.hmiPages.front().controls.at(2).binding->area() == RegisterArea::D, | ||||
| @@ -254,11 +510,33 @@ void testExampleProjectRoundTrip() | |||||
| && project.hmiPages.front().controls.at(5).pageJump->targetPageId | && project.hmiPages.front().controls.at(5).pageJump->targetPageId | ||||
| == "settings-page", | == "settings-page", | ||||
| "Label and PageJump typed data must survive round trip"); | "Label and PageJump typed data must survive round trip"); | ||||
| require(project.hmiPages.front().controls.at(6).type | |||||
| == HmiControlType::AlarmList, | |||||
| "AlarmList control type must survive round trip"); | |||||
| require(project.hmiPages.front().controls.at(7).type | |||||
| == HmiControlType::ProgressBar | |||||
| && project.hmiPages.front().controls.at(7).progressBar.has_value() | |||||
| && project.hmiPages.front().controls.at(7).progressBar->minimumValue == -20 | |||||
| && project.hmiPages.front().controls.at(7).progressBar->maximumValue == 80 | |||||
| && project.hmiPages.front().controls.at(7).progressBar->showValue, | |||||
| "ProgressBar configuration must survive round trip"); | |||||
| require(project.alarmDefinitions.size() == 2 | |||||
| && project.alarmDefinitions.front().condition | |||||
| == AlarmCondition::MOn | |||||
| && project.alarmDefinitions.at(1).threshold == 80, | |||||
| "M and D alarm definitions must survive round trip"); | |||||
| require(project.registerComments.size() == 2 | |||||
| && project.registerComments.front().address.area() == RegisterArea::M | |||||
| && project.registerComments.front().text == "启动按钮" | |||||
| && project.registerComments.at(1).address.area() == RegisterArea::D, | |||||
| "M/D register comments must survive round trip"); | |||||
| require(project.controlLogics.size() == 2, | require(project.controlLogics.size() == 2, | ||||
| "control logic count must survive round trip"); | "control logic count must survive round trip"); | ||||
| require(!project.controlLogics.front().enabled, | require(!project.controlLogics.front().enabled, | ||||
| "control logic enabled state must survive round trip"); | "control logic enabled state must survive round trip"); | ||||
| const LadderRung &rung = project.controlLogics.front().rungs.front(); | const LadderRung &rung = project.controlLogics.front().rungs.front(); | ||||
| require(rung.comment == "启动条件与温度检查", | |||||
| "rung comments must survive round trip"); | |||||
| require(rung.condition.has_value() | require(rung.condition.has_value() | ||||
| && rung.condition->kind == ConditionExpressionKind::Series, | && rung.condition->kind == ConditionExpressionKind::Series, | ||||
| "series root expression must survive round trip"); | "series root expression must survive round trip"); | ||||
| @@ -266,11 +544,67 @@ void testExampleProjectRoundTrip() | |||||
| == ConditionExpressionKind::Parallel | == ConditionExpressionKind::Parallel | ||||
| && rung.condition->children.front().children.size() == 2U, | && rung.condition->children.front().children.size() == 2U, | ||||
| "parallel expression branches must survive round trip"); | "parallel expression branches must survive round trip"); | ||||
| require(rung.condition->children.at(1).kind == ConditionExpressionKind::Wire | |||||
| && rung.condition->children.at(1).wire->columnSpan == 2, | |||||
| "horizontal wire span must survive JSON round trip"); | |||||
| require(rung.output.has_value(), | require(rung.output.has_value(), | ||||
| "ladder output must survive round trip"); | "ladder output must survive round trip"); | ||||
| const LadderRung &ton_rung = project.controlLogics.front().rungs.at(1); | |||||
| require(ton_rung.output.has_value() | |||||
| && std::holds_alternative<TonNodeConfig>(ton_rung.output->config) | |||||
| && std::get<TonNodeConfig>(ton_rung.output->config).address | |||||
| == TimerAddress{7} | |||||
| && std::get<TonNodeConfig>(ton_rung.output->config).presetMs == 2500, | |||||
| "TON timer address and preset must survive round trip"); | |||||
| require(std::holds_alternative<EdgeContactNodeConfig>( | |||||
| ton_rung.condition->node->config) | |||||
| && std::get<EdgeContactNodeConfig>(ton_rung.condition->node->config).mode | |||||
| == EdgeMode::Rising, | |||||
| "rising edge configuration must survive round trip"); | |||||
| const LadderRung &timer_rung = project.controlLogics.front().rungs.at(2); | |||||
| require(timer_rung.comment == "定时器完成反馈" | |||||
| && std::holds_alternative<TimerContactNodeConfig>( | |||||
| timer_rung.condition->children.at(1).node->config) | |||||
| && std::get<TimerContactNodeConfig>( | |||||
| timer_rung.condition->children.at(1).node->config).address | |||||
| == TimerAddress{7} | |||||
| && std::holds_alternative<EdgeContactNodeConfig>( | |||||
| timer_rung.condition->children.front().node->config) | |||||
| && std::get<EdgeContactNodeConfig>( | |||||
| timer_rung.condition->children.front().node->config).mode | |||||
| == EdgeMode::Falling, | |||||
| "falling edge and T contact configurations must survive round trip"); | |||||
| const LadderRung &counter_rung = project.controlLogics.front().rungs.at(3); | |||||
| const auto &counter = std::get<CounterNodeConfig>(counter_rung.output->config); | |||||
| require(counter.address == CounterAddress{3} | |||||
| && counter.currentValueAddress == RegisterAddress{RegisterArea::D, 10} | |||||
| && counter.preset.kind == WordOperandKind::Register | |||||
| && counter.preset.address == RegisterAddress{RegisterArea::D, 11} | |||||
| && counter.resetAddress == RegisterAddress{RegisterArea::M, 8}, | |||||
| "counter resource and external M/D addresses must survive round trip"); | |||||
| const LadderRung &counter_feedback = project.controlLogics.front().rungs.at(4); | |||||
| require(std::holds_alternative<CounterContactNodeConfig>( | |||||
| counter_feedback.condition->node->config) | |||||
| && std::get<CounterContactNodeConfig>( | |||||
| counter_feedback.condition->node->config).address | |||||
| == CounterAddress{3}, | |||||
| "counter contacts must survive round trip"); | |||||
| require(std::get<MoveNodeConfig>( | |||||
| project.controlLogics.front().rungs.at(5).output->config) | |||||
| .source.constant == 25, | |||||
| "MOVE operands must survive round trip"); | |||||
| require(std::get<ArithmeticNodeConfig>( | |||||
| project.controlLogics.front().rungs.at(6).output->config) | |||||
| .operation == ArithmeticOperation::Add | |||||
| && std::get<ArithmeticNodeConfig>( | |||||
| project.controlLogics.front().rungs.at(7).output->config) | |||||
| .operation == ArithmeticOperation::Subtract, | |||||
| "ADD and SUB operations must survive round trip"); | |||||
| const auto &compare = std::get<CompareNodeConfig>( | const auto &compare = std::get<CompareNodeConfig>( | ||||
| rung.condition->children.at(1).node->config); | |||||
| rung.condition->children.at(2).node->config); | |||||
| require(compare.address.index() == 2 && compare.value == 100, | require(compare.address.index() == 2 && compare.value == 100, | ||||
| "comparison configuration must survive round trip"); | "comparison configuration must survive round trip"); | ||||
| require(service.saveAs(second_path.toStdString()).succeeded, | require(service.saveAs(second_path.toStdString()).succeeded, | ||||
| @@ -297,6 +631,80 @@ void testExampleProjectRoundTrip() | |||||
| && missing_result.storageError == ProjectStorageError::MissingField, | && missing_result.storageError == ProjectStorageError::MissingField, | ||||
| "the 1.0 schema must require initialHmiPageId without migration defaults"); | "the 1.0 schema must require initialHmiPageId without migration defaults"); | ||||
| QJsonObject missing_alarms = QJsonDocument::fromJson(saved_json).object(); | |||||
| missing_alarms.remove(QStringLiteral("alarmDefinitions")); | |||||
| writeText( | |||||
| missing_alarms_path, | |||||
| QJsonDocument(missing_alarms).toJson(QJsonDocument::Compact)); | |||||
| missing_result = service.load(missing_alarms_path.toStdString()); | |||||
| require(!missing_result.succeeded | |||||
| && missing_result.storageError == ProjectStorageError::MissingField, | |||||
| "the 1.0 schema must require alarmDefinitions without migration defaults"); | |||||
| QJsonObject missing_register_comments = QJsonDocument::fromJson(saved_json).object(); | |||||
| missing_register_comments.remove(QStringLiteral("registerComments")); | |||||
| writeText( | |||||
| missing_register_comments_path, | |||||
| QJsonDocument(missing_register_comments).toJson(QJsonDocument::Compact)); | |||||
| missing_result = service.load(missing_register_comments_path.toStdString()); | |||||
| require(!missing_result.succeeded | |||||
| && missing_result.storageError == ProjectStorageError::MissingField, | |||||
| "the 1.0 schema must require registerComments without migration defaults"); | |||||
| QJsonObject missing_rung_comment = QJsonDocument::fromJson(saved_json).object(); | |||||
| QJsonArray missing_comment_logics = missing_rung_comment.value( | |||||
| QStringLiteral("controlLogics")).toArray(); | |||||
| QJsonObject first_logic = missing_comment_logics.at(0).toObject(); | |||||
| QJsonArray first_rungs = first_logic.value(QStringLiteral("rungs")).toArray(); | |||||
| QJsonObject first_rung = first_rungs.at(0).toObject(); | |||||
| first_rung.remove(QStringLiteral("comment")); | |||||
| first_rungs.replace(0, first_rung); | |||||
| first_logic.insert(QStringLiteral("rungs"), first_rungs); | |||||
| missing_comment_logics.replace(0, first_logic); | |||||
| missing_rung_comment.insert(QStringLiteral("controlLogics"), missing_comment_logics); | |||||
| writeText( | |||||
| missing_rung_comment_path, | |||||
| QJsonDocument(missing_rung_comment).toJson(QJsonDocument::Compact)); | |||||
| missing_result = service.load(missing_rung_comment_path.toStdString()); | |||||
| require(!missing_result.succeeded | |||||
| && missing_result.storageError == ProjectStorageError::MissingField, | |||||
| "the 1.0 schema must require rung comments without migration defaults"); | |||||
| QJsonObject multiline_register_comment = QJsonDocument::fromJson( | |||||
| saved_json).object(); | |||||
| QJsonArray comments = multiline_register_comment.value( | |||||
| QStringLiteral("registerComments")).toArray(); | |||||
| QJsonObject first_comment = comments.at(0).toObject(); | |||||
| first_comment.insert(QStringLiteral("text"), QStringLiteral("第一行\n第二行")); | |||||
| comments.replace(0, first_comment); | |||||
| multiline_register_comment.insert(QStringLiteral("registerComments"), comments); | |||||
| writeText( | |||||
| multiline_register_comment_path, | |||||
| QJsonDocument(multiline_register_comment).toJson(QJsonDocument::Compact)); | |||||
| missing_result = service.load(multiline_register_comment_path.toStdString()); | |||||
| require(!missing_result.succeeded | |||||
| && missing_result.storageError == ProjectStorageError::InvalidProject, | |||||
| "JSON loading must reject multiline register comments"); | |||||
| QJsonObject multiline_rung_comment = QJsonDocument::fromJson(saved_json).object(); | |||||
| QJsonArray multiline_logics = multiline_rung_comment.value( | |||||
| QStringLiteral("controlLogics")).toArray(); | |||||
| first_logic = multiline_logics.at(0).toObject(); | |||||
| first_rungs = first_logic.value(QStringLiteral("rungs")).toArray(); | |||||
| first_rung = first_rungs.at(0).toObject(); | |||||
| first_rung.insert(QStringLiteral("comment"), QStringLiteral("第一行\n第二行")); | |||||
| first_rungs.replace(0, first_rung); | |||||
| first_logic.insert(QStringLiteral("rungs"), first_rungs); | |||||
| multiline_logics.replace(0, first_logic); | |||||
| multiline_rung_comment.insert(QStringLiteral("controlLogics"), multiline_logics); | |||||
| writeText( | |||||
| multiline_rung_comment_path, | |||||
| QJsonDocument(multiline_rung_comment).toJson(QJsonDocument::Compact)); | |||||
| missing_result = service.load(multiline_rung_comment_path.toStdString()); | |||||
| require(!missing_result.succeeded | |||||
| && missing_result.storageError == ProjectStorageError::InvalidProject, | |||||
| "JSON loading must reject multiline rung comments"); | |||||
| QJsonObject missing_target = QJsonDocument::fromJson(saved_json).object(); | QJsonObject missing_target = QJsonDocument::fromJson(saved_json).object(); | ||||
| QJsonArray pages = missing_target.value(QStringLiteral("hmiPages")).toArray(); | QJsonArray pages = missing_target.value(QStringLiteral("hmiPages")).toArray(); | ||||
| QJsonObject main_page = pages.at(0).toObject(); | QJsonObject main_page = pages.at(0).toObject(); | ||||
| @@ -314,6 +722,24 @@ void testExampleProjectRoundTrip() | |||||
| require(!missing_result.succeeded | require(!missing_result.succeeded | ||||
| && missing_result.storageError == ProjectStorageError::MissingField, | && missing_result.storageError == ProjectStorageError::MissingField, | ||||
| "the 1.0 schema must require PageJump targetPageId"); | "the 1.0 schema must require PageJump targetPageId"); | ||||
| QJsonObject missing_progress_range = QJsonDocument::fromJson(saved_json).object(); | |||||
| pages = missing_progress_range.value(QStringLiteral("hmiPages")).toArray(); | |||||
| main_page = pages.at(0).toObject(); | |||||
| controls = main_page.value(QStringLiteral("controls")).toArray(); | |||||
| QJsonObject progress = controls.at(7).toObject(); | |||||
| progress.remove(QStringLiteral("maximumValue")); | |||||
| controls.replace(7, progress); | |||||
| main_page.insert(QStringLiteral("controls"), controls); | |||||
| pages.replace(0, main_page); | |||||
| missing_progress_range.insert(QStringLiteral("hmiPages"), pages); | |||||
| writeText( | |||||
| missing_progress_range_path, | |||||
| QJsonDocument(missing_progress_range).toJson(QJsonDocument::Compact)); | |||||
| missing_result = service.load(missing_progress_range_path.toStdString()); | |||||
| require(!missing_result.succeeded | |||||
| && missing_result.storageError == ProjectStorageError::MissingField, | |||||
| "the 1.0 schema must require the ProgressBar value range"); | |||||
| } | } | ||||
| void testInvalidFiles() | void testInvalidFiles() | ||||
| @@ -350,6 +776,178 @@ void testInvalidFiles() | |||||
| "unsupported versions must be rejected"); | "unsupported versions must be rejected"); | ||||
| } | } | ||||
| void testQuantityFileLimits() | |||||
| { | |||||
| QTemporaryDir directory; | |||||
| require(directory.isValid(), "temporary directory must be valid"); | |||||
| JsonProjectStorage storage; | |||||
| ProjectService service(storage); | |||||
| require(service.createNewProject("Quantity limits").succeeded, | |||||
| "the quantity-limit fixture project must be created"); | |||||
| const QString oversized_path = directory.filePath("oversized.json"); | |||||
| const QByteArray oversized( | |||||
| static_cast<int>(ProjectLimits::kMaximumProjectFileBytes + 1U), ' '); | |||||
| writeText(oversized_path, oversized); | |||||
| const ProjectLoadResult oversized_result = | |||||
| storage.load(oversized_path.toStdString()); | |||||
| require(!oversized_result.succeeded | |||||
| && oversized_result.error == ProjectStorageError::InvalidJson, | |||||
| "a JSON file over 16 MiB must be rejected before parsing"); | |||||
| const QString valid_path = directory.filePath("fixture.json"); | |||||
| require(service.saveAs(valid_path.toStdString()).succeeded, | |||||
| "the quantity-limit fixture must be saved"); | |||||
| QJsonObject root = QJsonDocument::fromJson(readBytes(valid_path)).object(); | |||||
| QJsonArray too_many_pages; | |||||
| for (int index = 0; | |||||
| index <= static_cast<int>(ProjectLimits::kMaximumHmiPages); | |||||
| ++index) | |||||
| { | |||||
| too_many_pages.append(QJsonObject{}); | |||||
| } | |||||
| root.insert(QStringLiteral("hmiPages"), too_many_pages); | |||||
| const QString too_many_pages_path = directory.filePath("too-many-pages.json"); | |||||
| writeText( | |||||
| too_many_pages_path, | |||||
| QJsonDocument(root).toJson(QJsonDocument::Compact)); | |||||
| const ProjectLoadResult too_many_pages_result = | |||||
| storage.load(too_many_pages_path.toStdString()); | |||||
| require(!too_many_pages_result.succeeded | |||||
| && too_many_pages_result.error == ProjectStorageError::InvalidField, | |||||
| "a JSON page array over 128 items must be rejected"); | |||||
| service.editProject() = makeExampleProject(); | |||||
| require(service.saveAs(valid_path.toStdString()).succeeded, | |||||
| "the nested-expression fixture must be saved"); | |||||
| root = QJsonDocument::fromJson(readBytes(valid_path)).object(); | |||||
| QJsonArray logics = root.value(QStringLiteral("controlLogics")).toArray(); | |||||
| QJsonObject logic = logics.at(0).toObject(); | |||||
| QJsonArray rungs = logic.value(QStringLiteral("rungs")).toArray(); | |||||
| QJsonObject rung = rungs.at(0).toObject(); | |||||
| QJsonObject leaf = rung.value(QStringLiteral("condition")).toObject(); | |||||
| while (leaf.value(QStringLiteral("kind")).toString() != QStringLiteral("node")) | |||||
| { | |||||
| leaf = leaf.value(QStringLiteral("children")).toArray().at(0).toObject(); | |||||
| } | |||||
| QJsonObject nested = leaf; | |||||
| for (int depth = 1; | |||||
| depth <= static_cast<int>(ProjectLimits::kMaximumExpressionDepth) + 1; | |||||
| ++depth) | |||||
| { | |||||
| QJsonObject sibling = leaf; | |||||
| sibling.insert( | |||||
| QStringLiteral("id"), | |||||
| QStringLiteral("json-depth-sibling-") + QString::number(depth)); | |||||
| QJsonArray children; | |||||
| children.append(nested); | |||||
| children.append(sibling); | |||||
| QJsonObject parent; | |||||
| parent.insert( | |||||
| QStringLiteral("id"), | |||||
| QStringLiteral("json-depth-") + QString::number(depth)); | |||||
| parent.insert( | |||||
| QStringLiteral("kind"), | |||||
| depth % 2 == 0 ? QStringLiteral("parallel") : QStringLiteral("series")); | |||||
| parent.insert(QStringLiteral("children"), children); | |||||
| nested = parent; | |||||
| } | |||||
| rung.insert(QStringLiteral("condition"), nested); | |||||
| rungs[0] = rung; | |||||
| logic.insert(QStringLiteral("rungs"), rungs); | |||||
| logics[0] = logic; | |||||
| root.insert(QStringLiteral("controlLogics"), logics); | |||||
| const QString deep_path = directory.filePath("too-deep.json"); | |||||
| writeText(deep_path, QJsonDocument(root).toJson(QJsonDocument::Compact)); | |||||
| const ProjectLoadResult deep_result = storage.load(deep_path.toStdString()); | |||||
| require(!deep_result.succeeded | |||||
| && deep_result.error == ProjectStorageError::InvalidField, | |||||
| "a condition expression deeper than 20 levels must be rejected while parsing"); | |||||
| } | |||||
| void testHmiPageJsonBoundaries() | |||||
| { | |||||
| // JSON 入口必须和页面领域校验使用同一组业务边界 | |||||
| QTemporaryDir directory; | |||||
| require(directory.isValid(), "temporary directory must be valid"); | |||||
| JsonProjectStorage storage; | |||||
| ProjectService service(storage); | |||||
| Project boundary_project = makeExampleProject(); | |||||
| boundary_project.hmiPages.front().controls.clear(); | |||||
| service.editProject() = std::move(boundary_project); | |||||
| const QString fixture_path = directory.filePath("hmi-page-boundary-fixture.json"); | |||||
| require(service.saveAs(fixture_path.toStdString()).succeeded, | |||||
| "the HMI boundary fixture must be saved"); | |||||
| const QJsonObject original_root = | |||||
| QJsonDocument::fromJson(readBytes(fixture_path)).object(); | |||||
| const auto loadWithSize = [&](int width, int height, const QString &name) | |||||
| { | |||||
| QJsonObject root = original_root; | |||||
| QJsonArray pages = root.value(QStringLiteral("hmiPages")).toArray(); | |||||
| QJsonObject page = pages.at(0).toObject(); | |||||
| page.insert(QStringLiteral("width"), width); | |||||
| page.insert(QStringLiteral("height"), height); | |||||
| pages.replace(0, page); | |||||
| root.insert(QStringLiteral("hmiPages"), pages); | |||||
| const QString path = directory.filePath(name); | |||||
| writeText(path, QJsonDocument(root).toJson(QJsonDocument::Compact)); | |||||
| return storage.load(path.toStdString()); | |||||
| }; | |||||
| require(!loadWithSize( | |||||
| ProjectLimits::kMinimumHmiPageWidth - 1, | |||||
| ProjectLimits::kDefaultHmiPageHeight, | |||||
| QStringLiteral("width-below-minimum.json")) | |||||
| .succeeded, | |||||
| "JSON must reject an HMI page width below 320"); | |||||
| require(!loadWithSize( | |||||
| ProjectLimits::kMaximumHmiPageWidth + 1, | |||||
| ProjectLimits::kDefaultHmiPageHeight, | |||||
| QStringLiteral("width-above-maximum.json")) | |||||
| .succeeded, | |||||
| "JSON must reject an HMI page width above 1600"); | |||||
| require(!loadWithSize( | |||||
| ProjectLimits::kDefaultHmiPageWidth, | |||||
| ProjectLimits::kMinimumHmiPageHeight - 1, | |||||
| QStringLiteral("height-below-minimum.json")) | |||||
| .succeeded, | |||||
| "JSON must reject an HMI page height below 200"); | |||||
| require(!loadWithSize( | |||||
| ProjectLimits::kDefaultHmiPageWidth, | |||||
| ProjectLimits::kMaximumHmiPageHeight + 1, | |||||
| QStringLiteral("height-above-maximum.json")) | |||||
| .succeeded, | |||||
| "JSON must reject an HMI page height above 800"); | |||||
| const ProjectLoadResult minimum_result = loadWithSize( | |||||
| ProjectLimits::kMinimumHmiPageWidth, | |||||
| ProjectLimits::kMinimumHmiPageHeight, | |||||
| QStringLiteral("minimum-size.json")); | |||||
| require(minimum_result.succeeded | |||||
| && minimum_result.project.hmiPages.front().width | |||||
| == ProjectLimits::kMinimumHmiPageWidth | |||||
| && minimum_result.project.hmiPages.front().height | |||||
| == ProjectLimits::kMinimumHmiPageHeight, | |||||
| "JSON must accept the minimum HMI page size 320x200"); | |||||
| const ProjectLoadResult maximum_result = loadWithSize( | |||||
| ProjectLimits::kMaximumHmiPageWidth, | |||||
| ProjectLimits::kMaximumHmiPageHeight, | |||||
| QStringLiteral("maximum-size.json")); | |||||
| require(maximum_result.succeeded | |||||
| && maximum_result.project.hmiPages.front().width | |||||
| == ProjectLimits::kMaximumHmiPageWidth | |||||
| && maximum_result.project.hmiPages.front().height | |||||
| == ProjectLimits::kMaximumHmiPageHeight, | |||||
| "JSON must accept the maximum HMI page size 1600x800"); | |||||
| } | |||||
| void testServiceStateAndSaveErrors() | void testServiceStateAndSaveErrors() | ||||
| { | { | ||||
| // 保存路径和修改标记只在成功持久化后更新 | // 保存路径和修改标记只在成功持久化后更新 | ||||
| @@ -380,6 +978,51 @@ void testServiceStateAndSaveErrors() | |||||
| "failed save as must keep the previous current path"); | "failed save as must keep the previous current path"); | ||||
| } | } | ||||
| void testRegisterCommentService() | |||||
| { | |||||
| JsonProjectStorage storage; | |||||
| ProjectService project_service(storage); | |||||
| RegisterCommentService service(project_service); | |||||
| require(service.setComment( | |||||
| RegisterAddress{RegisterArea::D, 3}, " 目标温度 ").succeeded, | |||||
| "register comment service must create a trimmed D comment"); | |||||
| require(service.setComment( | |||||
| RegisterAddress{RegisterArea::M, 2}, "启动信号").succeeded, | |||||
| "register comment service must create an M comment"); | |||||
| require(service.comments().size() == 2U | |||||
| && service.comments().front().address.area() == RegisterArea::M | |||||
| && service.comments().front().address.index() == 2 | |||||
| && service.comments().back().text == "目标温度", | |||||
| "register comments must be sorted by area and address"); | |||||
| require(service.setComment( | |||||
| RegisterAddress{RegisterArea::M, 2}, "新的启动信号").succeeded | |||||
| && service.comments().size() == 2U | |||||
| && service.findComment(RegisterAddress{RegisterArea::M, 2})->text | |||||
| == "新的启动信号", | |||||
| "setting an existing address must update instead of duplicating it"); | |||||
| require(!service.setComment(RegisterAddress{RegisterArea::M, 4}, " \t").succeeded, | |||||
| "blank register comments must be rejected by the service"); | |||||
| require(service.setComment( | |||||
| RegisterAddress{RegisterArea::M, 4}, | |||||
| std::string(ProjectLimits::kMaximumRegisterCommentBytes, 'a')) | |||||
| .succeeded, | |||||
| "a register comment at the byte limit must be accepted"); | |||||
| require(!service.setComment( | |||||
| RegisterAddress{RegisterArea::M, 5}, | |||||
| std::string(ProjectLimits::kMaximumRegisterCommentBytes + 1U, 'a')) | |||||
| .succeeded, | |||||
| "an oversized register comment must be rejected by the service"); | |||||
| require(!service.setComment( | |||||
| RegisterAddress{RegisterArea::M, 5}, "第一行\n第二行").succeeded, | |||||
| "a multiline register comment must be rejected by the service"); | |||||
| require(service.removeComment(RegisterAddress{RegisterArea::M, 2}).succeeded | |||||
| && service.findComment(RegisterAddress{RegisterArea::M, 2}) == nullptr, | |||||
| "register comments must be removable"); | |||||
| require(!service.removeComment(RegisterAddress{RegisterArea::M, 2}).succeeded, | |||||
| "removing a missing register comment must fail explicitly"); | |||||
| } | |||||
| } // namespace | } // namespace | ||||
| int main() | int main() | ||||
| @@ -388,8 +1031,12 @@ int main() | |||||
| { | { | ||||
| // 工程服务和 JSON 存储在同一测试进程中验证完整闭环 | // 工程服务和 JSON 存储在同一测试进程中验证完整闭环 | ||||
| testEmptyProjectRoundTrip(); | testEmptyProjectRoundTrip(); | ||||
| testUnconditionalOutputRoundTrip(); | |||||
| testExampleProjectRoundTrip(); | testExampleProjectRoundTrip(); | ||||
| testRegisterCommentService(); | |||||
| testInvalidFiles(); | testInvalidFiles(); | ||||
| testQuantityFileLimits(); | |||||
| testHmiPageJsonBoundaries(); | |||||
| testServiceStateAndSaveErrors(); | testServiceStateAndSaveErrors(); | ||||
| } | } | ||||
| catch (const std::exception &error) | catch (const std::exception &error) | ||||
| @@ -1,31 +1,20 @@ | |||||
| TEMPLATE = app | |||||
| TARGET = project_management_tests | |||||
| CONFIG += console c++17 testcase warn_on | |||||
| CONFIG -= app_bundle | |||||
| include(pri/test_defaults.pri) | |||||
| include(pri/test_layers.pri) | |||||
| TARGET = project_management_tests | |||||
| QT += core | QT += core | ||||
| INCLUDEPATH += ../src | |||||
| CONFIG += testcase | |||||
| SOURCES += \ | SOURCES += \ | ||||
| project_management_tests.cpp \ | project_management_tests.cpp \ | ||||
| ../src/domain/register_address.cpp \ | |||||
| ../src/domain/register_repository.cpp \ | |||||
| ../src/domain/hmi_model.cpp \ | |||||
| ../src/domain/control_logic_model.cpp \ | |||||
| ../src/domain/project_model.cpp \ | |||||
| ../src/domain/runtime_state.cpp \ | |||||
| ../src/services/project_service.cpp \ | |||||
| ../src/infrastructure/json_project_storage.cpp | |||||
| $$DOMAIN_ALL_SOURCES \ | |||||
| $$SERVICE_PROJECT_SOURCES \ | |||||
| $$SERVICE_MONITOR_SOURCES \ | |||||
| $$INFRASTRUCTURE_JSON_SOURCES | |||||
| HEADERS += \ | HEADERS += \ | ||||
| ../src/domain/register_address.h \ | |||||
| ../src/domain/register_repository.h \ | |||||
| ../src/domain/hmi_model.h \ | |||||
| ../src/domain/control_logic_model.h \ | |||||
| ../src/domain/project_model.h \ | |||||
| ../src/domain/runtime_state.h \ | |||||
| ../src/domain/project_storage.h \ | |||||
| ../src/services/project_service.h \ | |||||
| ../src/infrastructure/json_project_storage.h | |||||
| $$DOMAIN_ALL_HEADERS \ | |||||
| $$SERVICE_PROJECT_HEADERS \ | |||||
| $$SERVICE_MONITOR_HEADERS \ | |||||
| $$INFRASTRUCTURE_JSON_HEADERS \ | |||||
| $$TEST_SUPPORT_HEADERS | |||||
| @@ -2,6 +2,7 @@ | |||||
| #include "domain/register_monitor_model.h" | #include "domain/register_monitor_model.h" | ||||
| #include "domain/register_repository.h" | #include "domain/register_repository.h" | ||||
| #include "services/register_monitor_service.h" | #include "services/register_monitor_service.h" | ||||
| #include "support/test_support.h" | |||||
| #include <iostream> | #include <iostream> | ||||
| #include <stdexcept> | #include <stdexcept> | ||||
| @@ -9,13 +10,7 @@ | |||||
| namespace { | namespace { | ||||
| void require(bool condition, const std::string &message) | |||||
| { | |||||
| if (!condition) | |||||
| { | |||||
| throw std::runtime_error(message); | |||||
| } | |||||
| } | |||||
| using TestSupport::require; | |||||
| void testRangeManagement() | void testRangeManagement() | ||||
| { | { | ||||
| @@ -76,6 +71,35 @@ void testSharedActiveRepositoryValues() | |||||
| "valid cached values must be marked stale during a communication fault"); | "valid cached values must be marked stale during a communication fault"); | ||||
| } | } | ||||
| void testRegisterWrites() | |||||
| { | |||||
| VirtualRegisterRepository repository; | |||||
| RegisterMonitorService service(repository); | |||||
| const RegisterAddress m0{RegisterArea::M, 0}; | |||||
| const RegisterAddress d0{RegisterArea::D, 0}; | |||||
| const RegisterMonitorWriteResult bit_write = service.writeBit(m0, true); | |||||
| require(bit_write.succeeded && repository.readBit(m0).value, | |||||
| "monitor service must write M values through the active repository"); | |||||
| const RegisterMonitorWriteResult word_write = service.writeWord(d0, -123); | |||||
| require(word_write.succeeded && repository.readWord(d0).value == -123, | |||||
| "monitor service must write signed D values through the active repository"); | |||||
| const RegisterMonitorWriteResult wrong_bit = service.writeBit(d0, true); | |||||
| require(!wrong_bit.succeeded && wrong_bit.error == RegisterError::AreaMismatch, | |||||
| "monitor bit writes must reject D addresses"); | |||||
| const RegisterMonitorWriteResult wrong_word = service.writeWord(m0, 123); | |||||
| require(!wrong_word.succeeded && wrong_word.error == RegisterError::AreaMismatch, | |||||
| "monitor word writes must reject M addresses"); | |||||
| require(service.writeWord(d0, static_cast<std::int16_t>(-32768)).succeeded | |||||
| && repository.readWord(d0).value == -32768 | |||||
| && service.writeWord(d0, static_cast<std::int16_t>(32767)).succeeded | |||||
| && repository.readWord(d0).value == 32767, | |||||
| "monitor word writes must preserve signed 16-bit boundary values"); | |||||
| } | |||||
| } // namespace | } // namespace | ||||
| int main() | int main() | ||||
| @@ -84,6 +108,7 @@ int main() | |||||
| { | { | ||||
| testRangeManagement(); | testRangeManagement(); | ||||
| testSharedActiveRepositoryValues(); | testSharedActiveRepositoryValues(); | ||||
| testRegisterWrites(); | |||||
| } | } | ||||
| catch (const std::exception &error) | catch (const std::exception &error) | ||||
| { | { | ||||
| @@ -1,24 +1,17 @@ | |||||
| QT -= gui | |||||
| include(pri/test_defaults.pri) | |||||
| include(pri/test_layers.pri) | |||||
| TEMPLATE = app | |||||
| TARGET = register_monitor_service_tests | TARGET = register_monitor_service_tests | ||||
| CONFIG += console c++17 warn_on | |||||
| CONFIG -= app_bundle | |||||
| INCLUDEPATH += ../src | |||||
| QT -= gui | |||||
| SOURCES += \ | SOURCES += \ | ||||
| register_monitor_service_tests.cpp \ | register_monitor_service_tests.cpp \ | ||||
| ../src/domain/register_address.cpp \ | |||||
| ../src/domain/register_repository.cpp \ | |||||
| ../src/domain/active_register_repository.cpp \ | |||||
| ../src/domain/register_monitor_model.cpp \ | |||||
| $$DOMAIN_REGISTER_SOURCES \ | |||||
| $$DOMAIN_MONITOR_SOURCES \ | |||||
| ../src/services/register_monitor_service.cpp | ../src/services/register_monitor_service.cpp | ||||
| HEADERS += \ | HEADERS += \ | ||||
| ../src/domain/register_address.h \ | |||||
| ../src/domain/register_repository.h \ | |||||
| ../src/domain/active_register_repository.h \ | |||||
| ../src/domain/register_monitor_model.h \ | |||||
| ../src/services/register_monitor_service.h | |||||
| $$DOMAIN_REGISTER_HEADERS \ | |||||
| $$DOMAIN_MONITOR_HEADERS \ | |||||
| ../src/services/register_monitor_service.h \ | |||||
| $$TEST_SUPPORT_HEADERS | |||||
| @@ -4,6 +4,7 @@ | |||||
| #include "domain/active_register_repository.h" | #include "domain/active_register_repository.h" | ||||
| #include "domain/project_storage.h" | #include "domain/project_storage.h" | ||||
| #include "domain/register_repository.h" | #include "domain/register_repository.h" | ||||
| #include "support/test_support.h" | |||||
| #include <functional> | #include <functional> | ||||
| #include <iostream> | #include <iostream> | ||||
| @@ -13,19 +14,7 @@ | |||||
| namespace { | namespace { | ||||
| class TestProjectStorage final : public ProjectStorage | |||||
| { | |||||
| public: | |||||
| ProjectSaveResult save(const Project &, const std::string &) override | |||||
| { | |||||
| return {true, ProjectStorageError::None, {}}; | |||||
| } | |||||
| ProjectLoadResult load(const std::string &) override | |||||
| { | |||||
| return {false, {}, ProjectStorageError::FileReadFailed, {}}; | |||||
| } | |||||
| }; | |||||
| using TestProjectStorage = TestSupport::InMemoryProjectStorage; | |||||
| class ReadyPlcGateway final : public PlcCommunicationGateway | class ReadyPlcGateway final : public PlcCommunicationGateway | ||||
| { | { | ||||
| @@ -46,7 +35,12 @@ public: | |||||
| initial_read = false; | initial_read = false; | ||||
| } | } | ||||
| void setPollAddresses(const std::vector<RegisterAddress> &) override {} | |||||
| PlcCommunicationResult setPollAddresses( | |||||
| const std::vector<RegisterAddress> &addresses) override | |||||
| { | |||||
| poll_addresses = addresses; | |||||
| return {true, {}}; | |||||
| } | |||||
| PlcConnectionState state() const override { return connection_state; } | PlcConnectionState state() const override { return connection_state; } | ||||
| bool initialReadCompleted() const override { return initial_read; } | bool initialReadCompleted() const override { return initial_read; } | ||||
| PlcCommunicationError lastErrorType() const override | PlcCommunicationError lastErrorType() const override | ||||
| @@ -76,6 +70,11 @@ public: | |||||
| } | } | ||||
| } | } | ||||
| const std::vector<RegisterAddress> &pollAddresses() const | |||||
| { | |||||
| return poll_addresses; | |||||
| } | |||||
| private: | private: | ||||
| PlcConnectionState connection_state = PlcConnectionState::Disconnected; | PlcConnectionState connection_state = PlcConnectionState::Disconnected; | ||||
| bool initial_read = false; | bool initial_read = false; | ||||
| @@ -84,15 +83,10 @@ private: | |||||
| std::function<void(bool)> initial_read_changed; | std::function<void(bool)> initial_read_changed; | ||||
| std::function<void()> cache_updated; | std::function<void()> cache_updated; | ||||
| std::function<void(const std::string &)> error_reported; | std::function<void(const std::string &)> error_reported; | ||||
| std::vector<RegisterAddress> poll_addresses; | |||||
| }; | }; | ||||
| void require(bool condition, const std::string &message) | |||||
| { | |||||
| if (!condition) | |||||
| { | |||||
| throw std::runtime_error(message); | |||||
| } | |||||
| } | |||||
| using TestSupport::require; | |||||
| void testModeTransitions() | void testModeTransitions() | ||||
| { | { | ||||
| @@ -108,6 +102,137 @@ void testModeTransitions() | |||||
| service.configurePlc( | service.configurePlc( | ||||
| gateway, active_repository, virtual_repository, plc_repository); | gateway, active_repository, virtual_repository, plc_repository); | ||||
| Project &project = project_service.editProject(); | |||||
| project.alarmDefinitions.push_back( | |||||
| {"alarm-m", {RegisterArea::M, 12}, AlarmCondition::MOn, 0, "M alarm"}); | |||||
| project.alarmDefinitions.push_back( | |||||
| {"alarm-d", {RegisterArea::D, 34}, AlarmCondition::DHigh, 100, "D alarm"}); | |||||
| project.registerComments = { | |||||
| {RegisterAddress{RegisterArea::M, 3999}, "只用于说明"}, | |||||
| {RegisterAddress{RegisterArea::D, 3999}, "只用于说明"}}; | |||||
| ControlLogic logic; | |||||
| logic.id = "poll-logic"; | |||||
| logic.name = "Poll logic"; | |||||
| LogicNode edge; | |||||
| edge.id = "poll-edge"; | |||||
| edge.config = EdgeContactNodeConfig{ | |||||
| RegisterAddress{RegisterArea::M, 20}, EdgeMode::Rising}; | |||||
| LogicNode edge_coil; | |||||
| edge_coil.id = "poll-edge-coil"; | |||||
| edge_coil.config = CoilNodeConfig{ | |||||
| RegisterAddress{RegisterArea::M, 21}, CoilMode::Normal}; | |||||
| LadderRung edge_rung; | |||||
| edge_rung.id = "poll-edge-rung"; | |||||
| edge_rung.name = "Poll edge"; | |||||
| edge_rung.condition = ConditionExpression::fromNode(edge); | |||||
| edge_rung.output = edge_coil; | |||||
| logic.rungs.push_back(edge_rung); | |||||
| LogicNode comparison; | |||||
| comparison.id = "poll-comparison"; | |||||
| comparison.config = CompareNodeConfig{ | |||||
| RegisterAddress{RegisterArea::D, 35}, ComparisonOperator::GreaterThan, 0}; | |||||
| LogicNode comparison_coil; | |||||
| comparison_coil.id = "poll-comparison-coil"; | |||||
| comparison_coil.config = CoilNodeConfig{ | |||||
| RegisterAddress{RegisterArea::M, 22}, CoilMode::Normal}; | |||||
| LadderRung comparison_rung; | |||||
| comparison_rung.id = "poll-comparison-rung"; | |||||
| comparison_rung.name = "Poll comparison"; | |||||
| comparison_rung.condition = ConditionExpression::fromNode(comparison); | |||||
| comparison_rung.output = comparison_coil; | |||||
| logic.rungs.push_back(comparison_rung); | |||||
| LogicNode timer_input; | |||||
| timer_input.id = "poll-timer-input"; | |||||
| timer_input.config = ContactNodeConfig{ | |||||
| RegisterAddress{RegisterArea::M, 23}, ContactMode::NormallyOpen}; | |||||
| LogicNode ton; | |||||
| ton.id = "poll-ton"; | |||||
| ton.config = TonNodeConfig{TimerAddress{4}, 100}; | |||||
| LadderRung ton_rung; | |||||
| ton_rung.id = "poll-ton-rung"; | |||||
| ton_rung.name = "Poll TON"; | |||||
| ton_rung.condition = ConditionExpression::fromNode(timer_input); | |||||
| ton_rung.output = ton; | |||||
| logic.rungs.push_back(ton_rung); | |||||
| LogicNode timer_contact; | |||||
| timer_contact.id = "poll-timer-contact"; | |||||
| timer_contact.config = TimerContactNodeConfig{ | |||||
| TimerAddress{4}, ContactMode::NormallyOpen}; | |||||
| LogicNode timer_coil; | |||||
| timer_coil.id = "poll-timer-coil"; | |||||
| timer_coil.config = CoilNodeConfig{ | |||||
| RegisterAddress{RegisterArea::M, 24}, CoilMode::Normal}; | |||||
| LadderRung timer_rung; | |||||
| timer_rung.id = "poll-timer-rung"; | |||||
| timer_rung.name = "Poll timer contact"; | |||||
| timer_rung.condition = ConditionExpression::fromNode(timer_contact); | |||||
| timer_rung.output = timer_coil; | |||||
| logic.rungs.push_back(timer_rung); | |||||
| LogicNode counter_input; | |||||
| counter_input.id = "poll-counter-input"; | |||||
| counter_input.config = ContactNodeConfig{ | |||||
| RegisterAddress{RegisterArea::M, 25}, ContactMode::NormallyOpen}; | |||||
| LogicNode counter_output; | |||||
| counter_output.id = "poll-counter"; | |||||
| counter_output.config = CounterNodeConfig{ | |||||
| CounterAddress{2}, | |||||
| CounterMode::Up, | |||||
| RegisterAddress{RegisterArea::D, 36}, | |||||
| WordOperand{ | |||||
| WordOperandKind::Register, | |||||
| RegisterAddress{RegisterArea::D, 37}, | |||||
| 0}, | |||||
| RegisterAddress{RegisterArea::M, 26}}; | |||||
| LadderRung counter_rung; | |||||
| counter_rung.id = "poll-counter-rung"; | |||||
| counter_rung.name = "Poll counter"; | |||||
| counter_rung.condition = ConditionExpression::fromNode(counter_input); | |||||
| counter_rung.output = counter_output; | |||||
| logic.rungs.push_back(counter_rung); | |||||
| LogicNode move_input; | |||||
| move_input.id = "poll-move-input"; | |||||
| move_input.config = ContactNodeConfig{ | |||||
| RegisterAddress{RegisterArea::M, 27}, ContactMode::NormallyOpen}; | |||||
| LogicNode move_output; | |||||
| move_output.id = "poll-move"; | |||||
| move_output.config = MoveNodeConfig{ | |||||
| WordOperand{ | |||||
| WordOperandKind::Register, | |||||
| RegisterAddress{RegisterArea::D, 38}, | |||||
| 0}, | |||||
| RegisterAddress{RegisterArea::D, 39}}; | |||||
| LadderRung move_rung; | |||||
| move_rung.id = "poll-move-rung"; | |||||
| move_rung.name = "Poll MOVE"; | |||||
| move_rung.condition = ConditionExpression::fromNode(move_input); | |||||
| move_rung.output = move_output; | |||||
| logic.rungs.push_back(move_rung); | |||||
| LogicNode add_input; | |||||
| add_input.id = "poll-add-input"; | |||||
| add_input.config = ContactNodeConfig{ | |||||
| RegisterAddress{RegisterArea::M, 28}, ContactMode::NormallyOpen}; | |||||
| LogicNode add_output; | |||||
| add_output.id = "poll-add"; | |||||
| add_output.config = ArithmeticNodeConfig{ | |||||
| ArithmeticOperation::Add, | |||||
| WordOperand{ | |||||
| WordOperandKind::Register, | |||||
| RegisterAddress{RegisterArea::D, 40}, | |||||
| 0}, | |||||
| WordOperand{ | |||||
| WordOperandKind::Constant, | |||||
| RegisterAddress{RegisterArea::D, 0}, | |||||
| 1}, | |||||
| RegisterAddress{RegisterArea::D, 41}}; | |||||
| LadderRung add_rung; | |||||
| add_rung.id = "poll-add-rung"; | |||||
| add_rung.name = "Poll ADD"; | |||||
| add_rung.condition = ConditionExpression::fromNode(add_input); | |||||
| add_rung.output = add_output; | |||||
| logic.rungs.push_back(add_rung); | |||||
| project.controlLogics.push_back(logic); | |||||
| require(service.mode() == ApplicationMode::Editing, | require(service.mode() == ApplicationMode::Editing, | ||||
| "service must start in editing mode"); | "service must start in editing mode"); | ||||
| require(service.policy().allowsProjectEditing, | require(service.policy().allowsProjectEditing, | ||||
| @@ -134,6 +259,18 @@ void testModeTransitions() | |||||
| require(service.connectPlc( | require(service.connectPlc( | ||||
| {"COM9", 1, 9600, 8, 2, 1, 1000, 2, 200}).succeeded, | {"COM9", 1, 9600, 8, 2, 1, 1000, 2, 200}).succeeded, | ||||
| "PLC connection must be established before its initial read can complete"); | "PLC connection must be established before its initial read can complete"); | ||||
| require(gateway.pollAddresses() | |||||
| == std::vector<RegisterAddress>({ | |||||
| {RegisterArea::M, 12}, {RegisterArea::M, 20}, | |||||
| {RegisterArea::M, 21}, {RegisterArea::M, 22}, | |||||
| {RegisterArea::M, 23}, {RegisterArea::M, 24}, | |||||
| {RegisterArea::M, 25}, {RegisterArea::M, 26}, | |||||
| {RegisterArea::M, 27}, {RegisterArea::M, 28}, | |||||
| {RegisterArea::D, 34}, {RegisterArea::D, 35}, | |||||
| {RegisterArea::D, 36}, {RegisterArea::D, 37}, | |||||
| {RegisterArea::D, 38}, {RegisterArea::D, 39}, | |||||
| {RegisterArea::D, 40}, {RegisterArea::D, 41}}), | |||||
| "all instruction M/D references must be polled while T/C resources and comments stay offline-only"); | |||||
| gateway.completeInitialRead(); | gateway.completeInitialRead(); | ||||
| require(service.initialPlcReadCompleted(), | require(service.initialPlcReadCompleted(), | ||||
| "service must retain the initial PLC read state"); | "service must retain the initial PLC read state"); | ||||
| @@ -1,39 +1,19 @@ | |||||
| QT -= gui | |||||
| include(pri/test_defaults.pri) | |||||
| include(pri/test_layers.pri) | |||||
| TEMPLATE = app | |||||
| TARGET = runtime_mode_service_tests | TARGET = runtime_mode_service_tests | ||||
| CONFIG += console c++17 warn_on | |||||
| CONFIG -= app_bundle | |||||
| INCLUDEPATH += ../src | |||||
| QT -= gui | |||||
| SOURCES += \ | SOURCES += \ | ||||
| runtime_mode_service_tests.cpp \ | runtime_mode_service_tests.cpp \ | ||||
| ../src/domain/register_address.cpp \ | |||||
| ../src/domain/register_repository.cpp \ | |||||
| ../src/domain/active_register_repository.cpp \ | |||||
| ../src/domain/register_monitor_model.cpp \ | |||||
| ../src/domain/hmi_model.cpp \ | |||||
| ../src/domain/control_logic_model.cpp \ | |||||
| ../src/domain/project_model.cpp \ | |||||
| ../src/domain/runtime_state.cpp \ | |||||
| ../src/services/project_service.cpp \ | |||||
| ../src/services/software_logic_executor.cpp \ | |||||
| ../src/services/offline_simulation_service.cpp \ | |||||
| ../src/services/runtime_mode_service.cpp | |||||
| $$DOMAIN_ALL_SOURCES \ | |||||
| $$SERVICE_PROJECT_SOURCES \ | |||||
| $$SERVICE_OFFLINE_SOURCES \ | |||||
| $$SERVICE_RUNTIME_SOURCES | |||||
| HEADERS += \ | HEADERS += \ | ||||
| ../src/domain/register_address.h \ | |||||
| ../src/domain/register_repository.h \ | |||||
| ../src/domain/active_register_repository.h \ | |||||
| ../src/domain/register_monitor_model.h \ | |||||
| ../src/domain/hmi_model.h \ | |||||
| ../src/domain/control_logic_model.h \ | |||||
| ../src/domain/project_model.h \ | |||||
| ../src/domain/project_storage.h \ | |||||
| ../src/domain/runtime_state.h \ | |||||
| ../src/services/project_service.h \ | |||||
| ../src/services/software_logic_executor.h \ | |||||
| ../src/services/offline_simulation_service.h \ | |||||
| ../src/services/runtime_mode_service.h | |||||
| $$DOMAIN_ALL_HEADERS \ | |||||
| $$SERVICE_PROJECT_HEADERS \ | |||||
| $$SERVICE_OFFLINE_HEADERS \ | |||||
| $$SERVICE_RUNTIME_HEADERS \ | |||||
| $$TEST_SUPPORT_HEADERS | |||||
| @@ -0,0 +1,34 @@ | |||||
| #pragma once | |||||
| #include "domain/project_storage.h" | |||||
| #include <stdexcept> | |||||
| #include <string> | |||||
| namespace TestSupport | |||||
| { | |||||
| inline void require(bool condition, const std::string &message) | |||||
| { | |||||
| if (!condition) | |||||
| { | |||||
| throw std::runtime_error(message); | |||||
| } | |||||
| } | |||||
| // 编辑服务测试只验证内存模型和服务契约,不应依赖真实文件系统 | |||||
| class InMemoryProjectStorage final : public ProjectStorage | |||||
| { | |||||
| public: | |||||
| ProjectSaveResult save(const Project &, const std::string &) override | |||||
| { | |||||
| return {true, ProjectStorageError::None, {}}; | |||||
| } | |||||
| ProjectLoadResult load(const std::string &) override | |||||
| { | |||||
| return {false, {}, ProjectStorageError::FileReadFailed, {}}; | |||||
| } | |||||
| }; | |||||
| } // namespace TestSupport | |||||
| @@ -0,0 +1,28 @@ | |||||
| # 测试总入口:只负责编排目标,不把不同环境的测试揉成一个可执行文件 | |||||
| TEMPLATE = subdirs | |||||
| CONFIG += ordered | |||||
| SUBDIRS += \ | |||||
| domain \ | |||||
| alarm_service \ | |||||
| hmi_editor_service \ | |||||
| logic_editor_service \ | |||||
| offline_simulation \ | |||||
| project_management \ | |||||
| register_monitor \ | |||||
| runtime_mode \ | |||||
| plc_runtime \ | |||||
| main_window \ | |||||
| performance | |||||
| domain.file = domain_tests.pro | |||||
| alarm_service.file = alarm_service_tests.pro | |||||
| hmi_editor_service.file = hmi_editor_service_tests.pro | |||||
| logic_editor_service.file = logic_editor_service_tests.pro | |||||
| offline_simulation.file = offline_simulation_service_tests.pro | |||||
| project_management.file = project_management_tests.pro | |||||
| register_monitor.file = register_monitor_service_tests.pro | |||||
| runtime_mode.file = runtime_mode_service_tests.pro | |||||
| plc_runtime.file = plc_runtime_tests.pro | |||||
| main_window.file = main_window_tests.pro | |||||
| performance.file = performance_tests.pro | |||||