| @@ -55,10 +55,11 @@ tmp/ | |||
| Thumbs.db | |||
| Desktop.ini | |||
| /scripts | |||
| /scripts/* | |||
| !/scripts/run_qt_tests.ps1 | |||
| /docs/images | |||
| /docs/类图Mermaid代码.md | |||
| /other | |||
| 设计方案书.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 += \ | |||
| src/main.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/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/domain/register_address.cpp \ | |||
| src/domain/register_repository.cpp \ | |||
| src/domain/active_register_repository.cpp \ | |||
| src/domain/register_monitor_model.cpp \ | |||
| src/domain/alarm_model.cpp \ | |||
| src/domain/hmi_model.cpp \ | |||
| src/domain/hmi_control_registry.cpp \ | |||
| src/domain/control_logic_model.cpp \ | |||
| src/domain/project_model.cpp \ | |||
| src/domain/runtime_state.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_navigation_service.cpp \ | |||
| src/services/logic_editor_service.cpp \ | |||
| @@ -37,6 +49,7 @@ SOURCES += \ | |||
| src/services/offline_simulation_service.cpp \ | |||
| src/services/runtime_mode_service.cpp \ | |||
| src/services/register_monitor_service.cpp \ | |||
| src/services/register_comment_service.cpp \ | |||
| src/infrastructure/plc_register_repository.cpp \ | |||
| src/infrastructure/plc_communication_error_classifier.cpp \ | |||
| src/infrastructure/plc_communication_service.cpp \ | |||
| @@ -46,19 +59,33 @@ SOURCES += \ | |||
| 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/domain/register_address.h \ | |||
| src/domain/register_repository.h \ | |||
| src/domain/active_register_repository.h \ | |||
| src/domain/register_monitor_model.h \ | |||
| src/domain/alarm_model.h \ | |||
| src/domain/hmi_model.h \ | |||
| src/domain/hmi_control_registry.h \ | |||
| src/domain/control_logic_model.h \ | |||
| src/domain/project_limits.h \ | |||
| src/domain/project_model.h \ | |||
| src/domain/runtime_state.h \ | |||
| src/domain/project_storage.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_navigation_service.h \ | |||
| src/services/logic_editor_service.h \ | |||
| @@ -67,6 +94,7 @@ HEADERS += \ | |||
| src/services/offline_simulation_service.h \ | |||
| src/services/runtime_mode_service.h \ | |||
| src/services/register_monitor_service.h \ | |||
| src/services/register_comment_service.h \ | |||
| src/services/plc_communication_gateway.h \ | |||
| src/infrastructure/plc_register_repository.h \ | |||
| src/infrastructure/plc_communication_error_classifier.h \ | |||
| @@ -77,6 +105,10 @@ HEADERS += \ | |||
| 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 | |||
| @@ -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 "project_limits.h" | |||
| #include <algorithm> | |||
| #include <map> | |||
| #include <type_traits> | |||
| #include <utility> | |||
| 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) | |||
| { | |||
| if (!config.address.isValid() || config.address.area() != RegisterArea::M) | |||
| @@ -29,6 +39,50 @@ bool validateConfig(const ContactNodeConfig &config, std::string *error) | |||
| 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) | |||
| { | |||
| if (!config.address.isValid() || config.address.area() != RegisterArea::M) | |||
| @@ -66,6 +120,94 @@ bool validateConfig(const CompareNodeConfig &config, std::string *error) | |||
| 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> | |||
| bool hasDuplicateId(const std::vector<TItem> &items) | |||
| { | |||
| @@ -101,7 +243,9 @@ bool validateUniqueExpressionIds( | |||
| void normalizeExpression(ConditionExpression *expression) | |||
| { | |||
| if (expression == nullptr || expression->kind == ConditionExpressionKind::Node) | |||
| if (expression == nullptr | |||
| || expression->kind == ConditionExpressionKind::Node | |||
| || expression->kind == ConditionExpressionKind::Wire) | |||
| { | |||
| return; | |||
| } | |||
| @@ -113,6 +257,12 @@ void normalizeExpression(ConditionExpression *expression) | |||
| std::vector<ConditionExpression> flattened; | |||
| for (ConditionExpression &child : expression->children) | |||
| { | |||
| if ((child.kind == ConditionExpressionKind::Series | |||
| || child.kind == ConditionExpressionKind::Parallel) | |||
| && child.children.empty()) | |||
| { | |||
| continue; | |||
| } | |||
| if (child.kind == expression->kind) | |||
| { | |||
| 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 | |||
| 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 | |||
| { | |||
| if (id.empty()) | |||
| @@ -141,6 +589,11 @@ bool LogicNode::validate(std::string *error) const | |||
| setError(error, "逻辑节点 ID 不能为空"); | |||
| return false; | |||
| } | |||
| if (id.size() > ProjectLimits::kMaximumIdBytes) | |||
| { | |||
| setError(error, "逻辑节点 ID 不能超过 128 个 UTF-8 字节"); | |||
| return false; | |||
| } | |||
| return std::visit( | |||
| [error](const auto &config) { return validateConfig(config, error); }, | |||
| config); | |||
| @@ -153,12 +606,20 @@ bool LogicNode::isConfigured() 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 | |||
| { | |||
| 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) | |||
| @@ -170,42 +631,35 @@ ConditionExpression ConditionExpression::fromNode(LogicNode logic_node) | |||
| 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; | |||
| } | |||
| 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; | |||
| } | |||
| 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 | |||
| { | |||
| if (!validate(error)) | |||
| @@ -221,6 +675,10 @@ bool ConditionExpression::validateForRunning(std::string *error) const | |||
| } | |||
| return true; | |||
| } | |||
| if (kind == ConditionExpressionKind::Wire) | |||
| { | |||
| return true; | |||
| } | |||
| for (const ConditionExpression &child : children) | |||
| { | |||
| if (!child.validateForRunning(error)) | |||
| @@ -239,6 +697,7 @@ void normalizeConditionExpression(std::optional<ConditionExpression> *expression | |||
| } | |||
| normalizeExpression(&expression->value()); | |||
| if (expression->value().kind != ConditionExpressionKind::Node | |||
| && expression->value().kind != ConditionExpressionKind::Wire | |||
| && expression->value().children.empty()) | |||
| { | |||
| expression->reset(); | |||
| @@ -248,6 +707,10 @@ void normalizeConditionExpression(std::optional<ConditionExpression> *expression | |||
| const LogicNode *findConditionNode( | |||
| const ConditionExpression &expression, const std::string &node_id) | |||
| { | |||
| if (expression.kind == ConditionExpressionKind::Wire) | |||
| { | |||
| return nullptr; | |||
| } | |||
| if (expression.kind == ConditionExpressionKind::Node) | |||
| { | |||
| return expression.node->id == node_id ? &*expression.node : nullptr; | |||
| @@ -306,6 +769,10 @@ void collectConditionNodes( | |||
| nodes->push_back(&*expression.node); | |||
| return; | |||
| } | |||
| if (expression.kind == ConditionExpressionKind::Wire) | |||
| { | |||
| return; | |||
| } | |||
| for (const ConditionExpression &child : expression.children) | |||
| { | |||
| collectConditionNodes(child, nodes); | |||
| @@ -341,18 +808,18 @@ bool LadderRung::validateForRunning(std::string *error) const | |||
| { | |||
| return true; | |||
| } | |||
| if (!condition.has_value() || !output.has_value()) | |||
| if (!output.has_value()) | |||
| { | |||
| setError(error, "未完成的梯形图网络必须同时包含条件和输出线圈"); | |||
| setError(error, "梯形图网络必须包含输出指令"); | |||
| return false; | |||
| } | |||
| if (!condition->validateForRunning(error)) | |||
| if (condition.has_value() && !condition->validateForRunning(error)) | |||
| { | |||
| return false; | |||
| } | |||
| if (!output->isConfigured()) | |||
| { | |||
| setError(error, "输出线圈 " + output->id + " 尚未配置"); | |||
| setError(error, "输出节点 " + output->id + " 尚未配置"); | |||
| return false; | |||
| } | |||
| return true; | |||
| @@ -365,6 +832,26 @@ bool LadderRung::validateStructure(std::string *error) const | |||
| setError(error, "梯形图网络 ID 和名称不能为空"); | |||
| 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; | |||
| if (condition.has_value()) | |||
| { | |||
| @@ -383,7 +870,7 @@ bool LadderRung::validateStructure(std::string *error) const | |||
| { | |||
| if (!output->validate(error) || !output->isOutput()) | |||
| { | |||
| setError(error, "梯形图网络输出必须是线圈节点"); | |||
| setError(error, "梯形图网络输出必须是有效的输出指令"); | |||
| return false; | |||
| } | |||
| node_ids.push_back(output->id); | |||
| @@ -420,6 +907,21 @@ bool ControlLogic::validateStructure(std::string *error) const | |||
| setError(error, "控制逻辑 ID 和名称不能为空"); | |||
| 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)) | |||
| { | |||
| setError(error, "同一控制逻辑内的网络 ID 必须唯一"); | |||
| @@ -479,3 +981,148 @@ bool ControlLogic::validateForRunning(std::string *error) const | |||
| } | |||
| 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 | |||
| #include "project_limits.h" | |||
| #include "register_address.h" | |||
| #include <cstdint> | |||
| @@ -21,6 +22,12 @@ enum class CoilMode | |||
| Reset | |||
| }; | |||
| enum class EdgeMode | |||
| { | |||
| Rising, | |||
| Falling | |||
| }; | |||
| enum class ComparisonOperator | |||
| { | |||
| Equal, | |||
| @@ -31,12 +38,33 @@ enum class ComparisonOperator | |||
| 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 | |||
| { | |||
| RegisterAddress address{RegisterArea::M, 0}; | |||
| ContactMode mode = ContactMode::NormallyOpen; | |||
| }; | |||
| struct EdgeContactNodeConfig | |||
| { | |||
| RegisterAddress address{RegisterArea::M, 0}; | |||
| EdgeMode mode = EdgeMode::Rising; | |||
| }; | |||
| struct CoilNodeConfig | |||
| { | |||
| RegisterAddress address{RegisterArea::M, 0}; | |||
| @@ -50,7 +78,117 @@ struct CompareNodeConfig | |||
| 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 | |||
| { | |||
| @@ -67,19 +205,33 @@ struct LogicNode | |||
| enum class ConditionExpressionKind | |||
| { | |||
| Node, | |||
| Wire, | |||
| Series, | |||
| 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 | |||
| { | |||
| std::string id; | |||
| ConditionExpressionKind kind = ConditionExpressionKind::Node; | |||
| std::optional<LogicNode> node; | |||
| std::optional<WireSegment> wire; | |||
| std::vector<ConditionExpression> children; | |||
| static ConditionExpression fromNode(LogicNode node); | |||
| static ConditionExpression fromWire(std::string id, int column_span = 1); | |||
| bool validate(std::string *error = nullptr) const; | |||
| bool validateForRunning(std::string *error = nullptr) const; | |||
| }; | |||
| @@ -99,11 +251,12 @@ void collectConditionNodes( | |||
| void collectConditionExpressionIds( | |||
| const ConditionExpression &expression, std::vector<std::string> *ids); | |||
| // 一个网络包含一棵结构化条件表达式,输出线圈固定在最右侧 | |||
| // 条件表达式为空时表示恒真网络,输出指令固定在最右侧 | |||
| struct LadderRung | |||
| { | |||
| std::string id; | |||
| std::string name; | |||
| std::string comment; | |||
| std::optional<ConditionExpression> condition; | |||
| std::optional<LogicNode> output; | |||
| @@ -123,3 +276,13 @@ struct ControlLogic | |||
| bool validateStructure(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_control_registry.h" | |||
| #include "project_limits.h" | |||
| #include <algorithm> | |||
| #include <cctype> | |||
| #include <charconv> | |||
| 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 | |||
| 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 | |||
| { | |||
| if (!isSupportedControlType(type)) | |||
| const HmiControlDescriptor *descriptor = findHmiControlDescriptor(type); | |||
| if (descriptor == nullptr) | |||
| { | |||
| setError(error, "不支持的 HMI 控件类型"); | |||
| return false; | |||
| @@ -59,9 +119,26 @@ bool HmiControl::validate(std::string *error) const | |||
| setError(error, "HMI 控件 ID 不能为空"); | |||
| 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; | |||
| } | |||
| for (const auto &property : properties) | |||
| @@ -71,31 +148,44 @@ bool HmiControl::validate(std::string *error) const | |||
| setError(error, "HMI 控件属性名称不能为空"); | |||
| 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; | |||
| } | |||
| } | |||
| 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; | |||
| } | |||
| } | |||
| 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; | |||
| } | |||
| if (!supportsRegisterBinding(type) && binding.has_value()) | |||
| if (binding.has_value() && !binding->isValid()) | |||
| { | |||
| setError(error, "文本和页面跳转控件不能绑定寄存器"); | |||
| setError(error, "HMI 控件绑定了无效地址"); | |||
| return false; | |||
| } | |||
| if (type == HmiControlType::PageJump) | |||
| @@ -105,18 +195,47 @@ bool HmiControl::validate(std::string *error) const | |||
| setError(error, "页面跳转控件缺少跳转配置"); | |||
| return false; | |||
| } | |||
| if (pageJump->targetPageId.size() > ProjectLimits::kMaximumIdBytes) | |||
| { | |||
| setError(error, "页面跳转目标 ID 不能超过 128 个 UTF-8 字节"); | |||
| return false; | |||
| } | |||
| } | |||
| else if (pageJump.has_value()) | |||
| { | |||
| setError(error, "非页面跳转控件不能包含跳转配置"); | |||
| 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; | |||
| } | |||
| 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; | |||
| } | |||
| @@ -124,15 +243,15 @@ bool HmiControl::isConfigured() const | |||
| { | |||
| return pageJump.has_value() && !pageJump->targetPageId.empty(); | |||
| } | |||
| if (!binding.has_value() || !binding->isValid()) | |||
| if (!descriptor->requiresBindingForRunning | |||
| || !binding.has_value() | |||
| || !binding->isValid()) | |||
| { | |||
| 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 | |||
| @@ -142,9 +261,26 @@ bool HmiPage::validate(std::string *error) const | |||
| setError(error, "HMI 页面 ID 和名称不能为空"); | |||
| 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; | |||
| } | |||
| @@ -8,12 +8,24 @@ | |||
| #pragma once | |||
| #include "register_address.h" | |||
| #include "project_limits.h" | |||
| #include <cstdint> | |||
| #include <map> | |||
| #include <optional> | |||
| #include <string> | |||
| #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 控件在页面坐标系中的位置和尺寸 | |||
| * | |||
| @@ -42,7 +54,10 @@ enum class HmiControlType | |||
| NumericDisplay, // 数值显示 | |||
| NumericInput, // 数值输入 | |||
| Label, // 标签 | |||
| PageJump // 页面跳转 | |||
| PageJump, // 页面跳转 | |||
| AlarmList, // 报警列表 | |||
| ProgressBar, // 进度条 | |||
| Count // 已注册控件类型数量,不作为实际控件使用 | |||
| }; | |||
| /** | |||
| @@ -61,6 +76,19 @@ struct HmiPageJumpConfig | |||
| 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 控件及其显示和寄存器配置 | |||
| * | |||
| @@ -76,13 +104,14 @@ struct HmiControl | |||
| std::map<std::string, std::string> properties; | |||
| HmiButtonOperation buttonOperation = HmiButtonOperation::MomentaryOn; | |||
| std::optional<HmiPageJumpConfig> pageJump; | |||
| std::optional<HmiProgressBarConfig> progressBar; | |||
| /** | |||
| * @brief 校验控件的标识、尺寸、扩展属性和寄存器绑定 | |||
| * @param error 可选错误输出,失败时写入首个校验原因 | |||
| * @return 配置完整且满足控件类型绑定规则时返回 true | |||
| * | |||
| * 按钮和指示灯必须绑定有效 M 地址,数值显示和数值输入必须绑定有效 D 地址 | |||
| * 位控件必须绑定有效 M 地址,字控件必须绑定有效 D 地址 | |||
| */ | |||
| bool validate(std::string *error = nullptr) const; | |||
| bool isConfigured() const; | |||
| @@ -97,8 +126,8 @@ struct HmiPage | |||
| { | |||
| std::string id; | |||
| std::string name; | |||
| int width = 800; | |||
| int height = 480; | |||
| int width = ProjectLimits::kDefaultHmiPageWidth; | |||
| int height = ProjectLimits::kDefaultHmiPageHeight; | |||
| 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_limits.h" | |||
| #include <algorithm> | |||
| #include <cctype> | |||
| namespace { | |||
| @@ -53,8 +56,59 @@ bool containsDuplicateName(const std::vector<TItem> &items) | |||
| 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 | |||
| 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 | |||
| { | |||
| if (metadata.id.empty() || metadata.name.empty() || metadata.formatVersion.empty()) | |||
| @@ -62,6 +116,42 @@ bool Project::validate(std::string *error) const | |||
| setError(error, "工程 ID、名称和格式版本不能为空"); | |||
| 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)) | |||
| { | |||
| setError(error, "工程内的 HMI 页面 ID 必须唯一"); | |||
| @@ -101,6 +191,37 @@ bool Project::validate(std::string *error) const | |||
| setError(error, "工程内的控制逻辑名称必须唯一"); | |||
| 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) | |||
| { | |||
| // 工程聚合校验会向下委托页面和控件的完整规则 | |||
| @@ -136,6 +257,57 @@ bool Project::validate(std::string *error) const | |||
| 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; | |||
| } | |||
| @@ -159,5 +331,9 @@ bool Project::validateForRunning(std::string *error) const | |||
| return false; | |||
| } | |||
| } | |||
| if (!validateLogicResourceReferencesForRunning(controlLogics, error)) | |||
| { | |||
| return false; | |||
| } | |||
| return true; | |||
| } | |||
| @@ -1,11 +1,20 @@ | |||
| #pragma once | |||
| #include "alarm_model.h" | |||
| #include "control_logic_model.h" | |||
| #include "hmi_model.h" | |||
| #include <string> | |||
| #include <vector> | |||
| struct RegisterComment | |||
| { | |||
| RegisterAddress address{RegisterArea::M, 0}; | |||
| std::string text; | |||
| bool validate(std::string *error = nullptr) const; | |||
| }; | |||
| // 描述工程的基本身份和文件格式信息 | |||
| struct ProjectMetadata | |||
| { | |||
| @@ -26,9 +35,14 @@ struct Project | |||
| std::vector<HmiPage> hmiPages; | |||
| // 进入运行态时显示的初始 HMI 页面 | |||
| std::string initialHmiPageId; | |||
| // 工程统一管理的报警定义 | |||
| std::vector<AlarmDefinition> alarmDefinitions; | |||
| // 工程级 M/D 地址注释,不包含当前值和引用位置 | |||
| std::vector<RegisterComment> registerComments; | |||
| // 工程包含的控制逻辑集合 | |||
| std::vector<ControlLogic> controlLogics; | |||
| const RegisterComment *findRegisterComment(const RegisterAddress &address) const; | |||
| // 校验工程配置并通过 error 返回失败原因 | |||
| bool validate(std::string *error = nullptr) const; | |||
| bool validateForRunning(std::string *error = nullptr) const; | |||
| @@ -15,7 +15,6 @@ | |||
| namespace { | |||
| constexpr int kMaximumReadCount = 120; | |||
| constexpr int kRecoveryProbeIntervalMs = 2000; | |||
| std::string toUtf8(const QString &value) | |||
| @@ -42,6 +41,67 @@ bool isReadingState(PlcConnectionState state) | |||
| || 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 | |||
| PlcCommunicationService::PlcCommunicationService( | |||
| @@ -115,10 +175,11 @@ PlcCommunicationService::~PlcCommunicationService() = default; | |||
| PlcCommunicationResult PlcCommunicationService::connectDevice( | |||
| 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 | |||
| && master_->state() != QModbusDevice::UnconnectedState) | |||
| @@ -179,16 +240,27 @@ void PlcCommunicationService::disconnectDevice() | |||
| setState(PlcConnectionState::Disconnected); | |||
| } | |||
| void PlcCommunicationService::setPollAddresses( | |||
| PlcCommunicationResult PlcCommunicationService::setPollAddresses( | |||
| 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) | |||
| { | |||
| pending_poll_addresses_ = addresses; | |||
| pending_poll_addresses_ = std::move(normalized); | |||
| poll_update_pending_ = true; | |||
| return; | |||
| return {true, {}}; | |||
| } | |||
| applyPollAddresses(addresses); | |||
| applyPollAddresses(normalized); | |||
| return {true, {}}; | |||
| } | |||
| PlcConnectionState PlcCommunicationService::state() const | |||
| @@ -259,7 +331,8 @@ void PlcCommunicationService::rebuildPollBlocks() | |||
| || poll_blocks_.back().area != address.area() | |||
| || address.index() > poll_blocks_.back().startAddress | |||
| + poll_blocks_.back().count | |||
| || poll_blocks_.back().count >= kMaximumReadCount) | |||
| || poll_blocks_.back().count | |||
| >= ProjectLimits::kMaximumModbusReadCount) | |||
| { | |||
| poll_blocks_.push_back({address.area(), address.index(), 1}); | |||
| } | |||
| @@ -491,6 +564,10 @@ RegisterWriteResult PlcCommunicationService::sendBitWrite( | |||
| { | |||
| return {false, RegisterError::Unavailable}; | |||
| } | |||
| if (pending_write_reply_ != nullptr) | |||
| { | |||
| return {false, RegisterError::WriteRejected}; | |||
| } | |||
| QModbusDataUnit unit(QModbusDataUnit::Coils, address.index(), 1); | |||
| unit.setValue(0, value ? 1U : 0U); | |||
| QModbusReply *reply = master_->sendWriteRequest(unit, configuration_.serverAddress); | |||
| @@ -499,10 +576,15 @@ RegisterWriteResult PlcCommunicationService::sendBitWrite( | |||
| handleModbusError(master_->error()); | |||
| return {false, RegisterError::WriteRejected}; | |||
| } | |||
| pending_write_reply_ = reply; | |||
| connect(reply, &QModbusReply::finished, | |||
| this, | |||
| [this, reply, generation = connection_generation_] | |||
| { | |||
| if (pending_write_reply_ == reply) | |||
| { | |||
| pending_write_reply_ = nullptr; | |||
| } | |||
| if (generation != connection_generation_) | |||
| { | |||
| reply->deleteLater(); | |||
| @@ -524,6 +606,10 @@ RegisterWriteResult PlcCommunicationService::sendWordWrite( | |||
| { | |||
| return {false, RegisterError::Unavailable}; | |||
| } | |||
| if (pending_write_reply_ != nullptr) | |||
| { | |||
| return {false, RegisterError::WriteRejected}; | |||
| } | |||
| QModbusDataUnit unit(QModbusDataUnit::HoldingRegisters, address.index(), 1); | |||
| unit.setValue(0, static_cast<quint16>(value)); | |||
| QModbusReply *reply = master_->sendWriteRequest(unit, configuration_.serverAddress); | |||
| @@ -532,10 +618,15 @@ RegisterWriteResult PlcCommunicationService::sendWordWrite( | |||
| handleModbusError(master_->error()); | |||
| return {false, RegisterError::WriteRejected}; | |||
| } | |||
| pending_write_reply_ = reply; | |||
| connect(reply, &QModbusReply::finished, | |||
| this, | |||
| [this, reply, generation = connection_generation_] | |||
| { | |||
| if (pending_write_reply_ == reply) | |||
| { | |||
| pending_write_reply_ = nullptr; | |||
| } | |||
| if (generation != connection_generation_) | |||
| { | |||
| reply->deleteLater(); | |||
| @@ -601,6 +692,7 @@ void PlcCommunicationService::closeSerialSession() | |||
| poll_timer_.stop(); | |||
| recovery_timer_.stop(); | |||
| pending_reply_ = nullptr; | |||
| pending_write_reply_ = nullptr; | |||
| poll_update_pending_ = false; | |||
| pending_poll_addresses_.clear(); | |||
| if (master_->state() != QModbusDevice::UnconnectedState) | |||
| @@ -30,7 +30,8 @@ public: | |||
| PlcCommunicationResult connectDevice( | |||
| const PlcSerialConfiguration &configuration) override; | |||
| void disconnectDevice() override; | |||
| void setPollAddresses(const std::vector<RegisterAddress> &addresses) override; | |||
| PlcCommunicationResult setPollAddresses( | |||
| const std::vector<RegisterAddress> &addresses) override; | |||
| PlcConnectionState state() const override; | |||
| bool initialReadCompleted() const override; | |||
| @@ -84,6 +85,7 @@ private: | |||
| std::vector<PollBlock> poll_blocks_; | |||
| std::size_t next_poll_block_ = 0; | |||
| QModbusReply *pending_reply_ = nullptr; | |||
| QModbusReply *pending_write_reply_ = nullptr; | |||
| bool poll_update_pending_ = false; | |||
| bool disconnecting_ = false; | |||
| bool serial_session_opened_ = false; | |||
| @@ -14,12 +14,15 @@ | |||
| #include "domain/active_register_repository.h" | |||
| #include "services/hmi_editor_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/logic_editor_service.h" | |||
| #include "services/offline_simulation_service.h" | |||
| #include "services/project_service.h" | |||
| #include "services/runtime_mode_service.h" | |||
| #include "services/register_monitor_service.h" | |||
| #include "services/register_comment_service.h" | |||
| #include "ui/main_window.h" | |||
| int main(int argc, char *argv[]) | |||
| @@ -40,6 +43,9 @@ int main(int argc, char *argv[]) | |||
| ActiveRegisterRepository active_register_repository(virtual_register_repository); | |||
| PlcCommunicationService plc_communication_service(plc_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); | |||
| OfflineSimulationService offline_simulation_service(virtual_register_repository); | |||
| RuntimeModeService runtime_mode_service( | |||
| @@ -55,7 +61,10 @@ int main(int argc, char *argv[]) | |||
| hmi_editor_service, | |||
| logic_editor_service, | |||
| hmi_runtime_service, | |||
| alarm_editor_service, | |||
| alarm_service, | |||
| hmi_navigation_service, | |||
| register_comment_service, | |||
| register_monitor_service); | |||
| 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 "domain/hmi_control_registry.h" | |||
| #include "project_service.h" | |||
| #include "domain/project_limits.h" | |||
| #include <algorithm> | |||
| #include <cctype> | |||
| #include <cstddef> | |||
| #include <map> | |||
| #include <set> | |||
| #include <utility> | |||
| 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) | |||
| { | |||
| 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 查找页面,找不到返回空指针 | |||
| const HmiPage *HmiEditorService::findPage(const std::string &page_id) const | |||
| { | |||
| @@ -178,6 +192,7 @@ HmiEditorResult HmiEditorService::ensureDefaultPage() | |||
| } | |||
| // 仅在首个控件操作前创建默认页面,空工程仍可正常保存 | |||
| HistoryState before = captureState(); | |||
| HmiPage page; | |||
| page.id = "page-1"; | |||
| page.name = "主操作页面"; | |||
| @@ -185,6 +200,7 @@ HmiEditorResult HmiEditorService::ensureDefaultPage() | |||
| Project &project = project_service_.editProject(); | |||
| project.hmiPages.push_back(std::move(page)); | |||
| project.initialHmiPageId = project.hmiPages.back().id; | |||
| recordHistory(std::move(before)); | |||
| return {true, HmiEditorError::None, {}, project.hmiPages.back().id}; | |||
| } | |||
| @@ -195,6 +211,14 @@ HmiEditorResult HmiEditorService::addPage(const std::string &name) | |||
| return failure(HmiEditorError::InvalidPage, "HMI 页面名称不能为空"); | |||
| } | |||
| 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( | |||
| current.hmiPages.cbegin(), current.hmiPages.cend(), | |||
| [&name](const HmiPage &page) { return page.name == name; }); | |||
| @@ -206,15 +230,51 @@ HmiEditorResult HmiEditorService::addPage(const std::string &name) | |||
| HmiPage page; | |||
| page.id = makeUniquePageId(current); | |||
| page.name = name; | |||
| HistoryState before = captureState(); | |||
| Project &project = project_service_.editProject(); | |||
| project.hmiPages.push_back(std::move(page)); | |||
| if (project.initialHmiPageId.empty()) | |||
| { | |||
| project.initialHmiPageId = project.hmiPages.back().id; | |||
| } | |||
| recordHistory(std::move(before)); | |||
| 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( | |||
| const std::string &page_id, const std::string &name) | |||
| { | |||
| @@ -222,6 +282,10 @@ HmiEditorResult HmiEditorService::renamePage( | |||
| { | |||
| 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 auto existing = std::find_if( | |||
| current.hmiPages.cbegin(), current.hmiPages.cend(), | |||
| @@ -240,12 +304,18 @@ HmiEditorResult HmiEditorService::renamePage( | |||
| { | |||
| return failure(HmiEditorError::DuplicateName, "HMI 页面名称必须唯一"); | |||
| } | |||
| if (existing->name == name) | |||
| { | |||
| return {true, HmiEditorError::None, {}, page_id}; | |||
| } | |||
| HistoryState before = captureState(); | |||
| Project &project = project_service_.editProject(); | |||
| auto page = std::find_if( | |||
| project.hmiPages.begin(), project.hmiPages.end(), | |||
| [&page_id](const HmiPage &candidate) { return candidate.id == page_id; }); | |||
| page->name = name; | |||
| recordHistory(std::move(before)); | |||
| 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.hmiPages.erase( | |||
| std::remove_if( | |||
| project.hmiPages.begin(), project.hmiPages.end(), | |||
| [&page_id](const HmiPage &candidate) { return candidate.id == page_id; }), | |||
| project.hmiPages.end()); | |||
| recordHistory(std::move(before)); | |||
| 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 页面已经位于目标边界"); | |||
| } | |||
| HistoryState before = captureState(); | |||
| Project &project = project_service_.editProject(); | |||
| std::iter_swap( | |||
| project.hmiPages.begin() + index, | |||
| project.hmiPages.begin() + target_index); | |||
| recordHistory(std::move(before)); | |||
| return {true, HmiEditorError::None, {}, page_id}; | |||
| } | |||
| @@ -331,7 +405,13 @@ HmiEditorResult HmiEditorService::setInitialPage(const std::string &page_id) | |||
| { | |||
| 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; | |||
| recordHistory(std::move(before)); | |||
| return {true, HmiEditorError::None, {}, page_id}; | |||
| } | |||
| @@ -343,18 +423,29 @@ HmiEditorResult HmiEditorService::addControl( | |||
| { | |||
| 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(); | |||
| auto target_page = std::find_if( | |||
| project.hmiPages.begin(), | |||
| project.hmiPages.end(), | |||
| [&page_id](const HmiPage &candidate) | |||
| { | |||
| return candidate.id == page_id; | |||
| { | |||
| return candidate.id == page_id; | |||
| }); | |||
| target_page->controls.push_back(std::move(control)); | |||
| recordHistory(std::move(before)); | |||
| return {true, | |||
| HmiEditorError::None, | |||
| {}, | |||
| @@ -363,16 +454,36 @@ HmiEditorResult HmiEditorService::addControl( | |||
| HmiEditorResult HmiEditorService::removeControl( | |||
| 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) | |||
| { | |||
| 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(); | |||
| auto page = std::find_if( | |||
| project.hmiPages.begin(), | |||
| @@ -385,12 +496,13 @@ HmiEditorResult HmiEditorService::removeControl( | |||
| std::remove_if( | |||
| page->controls.begin(), | |||
| 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()); | |||
| 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); | |||
| } | |||
| 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(); | |||
| auto target = std::find_if( | |||
| project.hmiPages.begin(), project.hmiPages.end(), | |||
| @@ -428,6 +548,7 @@ HmiEditorResult HmiEditorService::moveControl( | |||
| target->controls.begin(), target->controls.end(), | |||
| [&control_id](const HmiControl &item) { return item.id == control_id; }); | |||
| editable->bounds = bounds; | |||
| recordHistory(std::move(before)); | |||
| return {true, HmiEditorError::None, {}, control_id}; | |||
| } | |||
| @@ -462,6 +583,11 @@ HmiEditorResult HmiEditorService::updateControl( | |||
| return failure(HmiEditorError::DuplicateId, "同一 HMI 页面内的控件 ID 必须唯一"); | |||
| } | |||
| if (controlsEqual(*existing, control)) | |||
| { | |||
| return {true, HmiEditorError::None, {}, control.id}; | |||
| } | |||
| HistoryState before = captureState(); | |||
| Project &project = project_service_.editProject(); | |||
| auto target = std::find_if( | |||
| project.hmiPages.begin(), project.hmiPages.end(), | |||
| @@ -470,32 +596,59 @@ HmiEditorResult HmiEditorService::updateControl( | |||
| target->controls.begin(), target->controls.end(), | |||
| [&control_id](const HmiControl &item) { return item.id == control_id; }); | |||
| *editable = control; | |||
| recordHistory(std::move(before)); | |||
| 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( | |||
| 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; | |||
| } | |||
| 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.x > page.width - control.bounds.width | |||
| || control.bounds.y > page.height - control.bounds.height) | |||
| @@ -503,59 +656,11 @@ bool HmiEditorService::validateEditableControl( | |||
| setError(error, "HMI 控件不能超出页面范围"); | |||
| 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 true; | |||
| @@ -576,18 +681,22 @@ bool HmiEditorService::hasDuplicateControlId( | |||
| // 根据控件类型生成默认样式、文字、大小、初始坐标 | |||
| HmiControl HmiEditorService::makeControl( | |||
| const HmiPage &page, HmiControlType type) | |||
| const HmiPage &page, const HmiControlDescriptor &descriptor) | |||
| { | |||
| 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{}; | |||
| } | |||
| if (descriptor.type == HmiControlType::ProgressBar) | |||
| { | |||
| control.progressBar = HmiProgressBarConfig{}; | |||
| } | |||
| const int offset = static_cast<int>(page.controls.size()) * 16; | |||
| control.bounds.x = std::min(20 + offset, page.width - control.bounds.width); | |||
| control.bounds.y = std::min(20 + offset, page.height - control.bounds.height); | |||
| @@ -8,10 +8,13 @@ | |||
| #pragma once | |||
| #include "domain/hmi_model.h" | |||
| #include "editor_history.h" | |||
| #include <string> | |||
| #include <vector> | |||
| class ProjectService; | |||
| struct HmiControlDescriptor; | |||
| /** | |||
| * @brief HMI 编辑操作的失败分类 | |||
| @@ -80,6 +83,8 @@ public: | |||
| */ | |||
| HmiEditorResult ensureDefaultPage(); | |||
| HmiEditorResult addPage(const std::string &name); | |||
| HmiEditorResult resizePage( | |||
| const std::string &page_id, int width, int height); | |||
| HmiEditorResult renamePage( | |||
| const std::string &page_id, const std::string &name); | |||
| HmiEditorResult removePage(const std::string &page_id); | |||
| @@ -103,6 +108,9 @@ public: | |||
| */ | |||
| HmiEditorResult removeControl( | |||
| 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 更新控件位置和尺寸 | |||
| * @param page_id 所属页面唯一标识 | |||
| @@ -128,7 +136,28 @@ public: | |||
| const std::string &control_id, | |||
| const HmiControl &control); | |||
| bool canUndo() const; | |||
| bool canRedo() const; | |||
| HmiEditorResult undo(); | |||
| HmiEditorResult redo(); | |||
| void clearHistory(); | |||
| 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 校验控件编辑后仍满足页面内的基础约束 | |||
| * @param page 控件所属页面 | |||
| @@ -158,7 +187,7 @@ private: | |||
| * @return 未绑定寄存器的默认控件配置 | |||
| */ | |||
| static HmiControl makeControl( | |||
| const HmiPage &page, HmiControlType type); | |||
| const HmiPage &page, const HmiControlDescriptor &descriptor); | |||
| /** | |||
| * @brief 在页面内生成带指定前缀的唯一控件标识 | |||
| * @param page 待检查页面 | |||
| @@ -172,4 +201,5 @@ private: | |||
| * @brief 非拥有的工程服务依赖,由应用入口保证生命周期 | |||
| */ | |||
| ProjectService &project_service_; | |||
| EditorHistory<HistoryState> history_; | |||
| }; | |||
| @@ -1,5 +1,7 @@ | |||
| #include "hmi_runtime_service.h" | |||
| #include "domain/hmi_control_registry.h" | |||
| namespace { | |||
| HmiRuntimeReadResult readFailure(HmiRuntimeError error) | |||
| @@ -12,17 +14,6 @@ HmiRuntimeWriteResult writeFailure(HmiRuntimeError 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 | |||
| HmiRuntimeService::HmiRuntimeService(RegisterRepository &repository) | |||
| @@ -32,8 +23,10 @@ HmiRuntimeService::HmiRuntimeService(RegisterRepository &repository) | |||
| 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); | |||
| } | |||
| @@ -45,7 +38,16 @@ HmiRuntimeReadResult HmiRuntimeService::readControl(const HmiControl &control) c | |||
| { | |||
| 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 位读取契约 | |||
| 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}; | |||
| } | |||
| if (isWordControl(control.type)) | |||
| case HmiRuntimeValueKind::Word: | |||
| { | |||
| const WordReadResult result = repository_.readWord(*control.binding); | |||
| if (!result.succeeded) | |||
| @@ -64,7 +66,12 @@ HmiRuntimeReadResult HmiRuntimeService::readControl(const HmiControl &control) c | |||
| } | |||
| return {true, HmiRuntimeError::None, false, result.value}; | |||
| } | |||
| return readFailure(HmiRuntimeError::UnsupportedControl); | |||
| case HmiRuntimeValueKind::None: | |||
| default: | |||
| { | |||
| return readFailure(HmiRuntimeError::UnsupportedControl); | |||
| } | |||
| } | |||
| } | |||
| HmiRuntimeWriteResult HmiRuntimeService::operateButton( | |||
| @@ -1,6 +1,7 @@ | |||
| #pragma once | |||
| #include "domain/control_logic_model.h" | |||
| #include "editor_history.h" | |||
| #include <string> | |||
| #include <vector> | |||
| @@ -47,6 +48,7 @@ public: | |||
| std::string firstRungId(const std::string &logic_id) const; | |||
| std::string rungIdForNode( | |||
| const std::string &logic_id, const std::string &node_id) const; | |||
| std::string registerCommentFor(const RegisterAddress &address) const; | |||
| LogicEditorResult ensureDefaultLogic(); | |||
| LogicEditorResult addLogic(const std::string &name); | |||
| @@ -58,45 +60,115 @@ public: | |||
| LogicEditorResult addRung(const std::string &logic_id); | |||
| LogicEditorResult removeRung( | |||
| 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( | |||
| const std::string &logic_id, | |||
| const std::string &rung_id, | |||
| 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( | |||
| const std::string &logic_id, | |||
| const std::string &rung_id, | |||
| const std::string &target_node_id, | |||
| 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( | |||
| const std::string &logic_id, | |||
| const std::string &rung_id, | |||
| const std::vector<std::string> &selected_node_ids, | |||
| 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( | |||
| const std::string &logic_id, | |||
| const std::string &rung_id, | |||
| const LogicNodeConfig &config); | |||
| const LogicNodeConfig &config, | |||
| bool configured = false); | |||
| LogicEditorResult updateNodeConfig( | |||
| const std::string &logic_id, | |||
| const std::string &node_id, | |||
| const LogicNodeConfig &config); | |||
| LogicEditorResult removeNode( | |||
| 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( | |||
| const std::string &logic_id, | |||
| const std::string &rung_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: | |||
| 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 isOutputConfig(const LogicNodeConfig &config); | |||
| static std::string nodePrefix(const LogicNodeConfig &config); | |||
| static std::string makeUniqueNodeId( | |||
| const ControlLogic &logic, const std::string &prefix); | |||
| static std::string makeUniqueWireId(const ControlLogic &logic); | |||
| static std::string makeUniqueExpressionId(const ControlLogic &logic); | |||
| static std::string makeUniqueRungId(const ControlLogic &logic); | |||
| static LogicEditorResult failure( | |||
| LogicEditorError error, const std::string &message); | |||
| ProjectService &project_service_; | |||
| EditorHistory<HistoryState> history_; | |||
| }; | |||
| @@ -31,6 +31,7 @@ SimulationStartResult OfflineSimulationService::start( | |||
| } | |||
| timer_.stop(); | |||
| executor_.resetRuntime(); | |||
| repository_.clear(); | |||
| logic_snapshot_ = std::move(snapshot); | |||
| successful_scan_count_ = 0; | |||
| @@ -45,6 +46,7 @@ SimulationStartResult OfflineSimulationService::start( | |||
| void OfflineSimulationService::stop() | |||
| { | |||
| timer_.stop(); | |||
| executor_.resetRuntime(); | |||
| logic_snapshot_.clear(); | |||
| if (state_ == SimulationState::Stopped) | |||
| { | |||
| @@ -111,6 +113,7 @@ void OfflineSimulationService::handleTimeout() | |||
| void OfflineSimulationService::enterFault(const LogicScanResult &error) | |||
| { | |||
| timer_.stop(); | |||
| executor_.resetRuntime(); | |||
| last_error_ = error; | |||
| state_ = SimulationState::Faulted; | |||
| emit stateChanged(); | |||
| @@ -1,7 +1,10 @@ | |||
| #pragma once | |||
| #include "domain/register_address.h" | |||
| #include "domain/project_limits.h" | |||
| #include <algorithm> | |||
| #include <cctype> | |||
| #include <functional> | |||
| #include <string> | |||
| #include <vector> | |||
| @@ -50,6 +53,61 @@ struct PlcCommunicationResult | |||
| 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 | |||
| { | |||
| public: | |||
| @@ -58,7 +116,7 @@ public: | |||
| virtual PlcCommunicationResult connectDevice( | |||
| const PlcSerialConfiguration &configuration) = 0; | |||
| virtual void disconnectDevice() = 0; | |||
| virtual void setPollAddresses( | |||
| virtual PlcCommunicationResult setPollAddresses( | |||
| const std::vector<RegisterAddress> &addresses) = 0; | |||
| virtual PlcConnectionState state() const = 0; | |||
| virtual bool initialReadCompleted() const = 0; | |||
| @@ -57,6 +57,11 @@ bool ProjectService::isModified() const | |||
| return modified_; | |||
| } | |||
| void ProjectService::restoreModifiedState(bool modified) | |||
| { | |||
| modified_ = modified; | |||
| } | |||
| ProjectOperationResult ProjectService::createNewProject(const std::string &name) | |||
| { | |||
| if (isBlank(name)) | |||
| @@ -35,6 +35,8 @@ public: | |||
| const std::string ¤tFilePath() const; | |||
| bool hasCurrentFile() const; | |||
| bool isModified() const; | |||
| // 失败编辑回滚时恢复进入编辑前的脏状态 | |||
| void restoreModifiedState(bool modified); | |||
| /** | |||
| * @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}; | |||
| } | |||
| 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 | |||
| { | |||
| return model_.addresses(); | |||
| @@ -3,6 +3,7 @@ | |||
| #include "domain/register_monitor_model.h" | |||
| #include "domain/register_repository.h" | |||
| #include <cstdint> | |||
| #include <functional> | |||
| #include <string> | |||
| #include <vector> | |||
| @@ -24,6 +25,13 @@ struct RegisterMonitorResult | |||
| int affectedCount = 0; | |||
| }; | |||
| struct RegisterMonitorWriteResult | |||
| { | |||
| bool succeeded = false; | |||
| RegisterError error = RegisterError::Unavailable; | |||
| std::string message; | |||
| }; | |||
| class RegisterMonitorService | |||
| { | |||
| public: | |||
| @@ -32,6 +40,9 @@ public: | |||
| RegisterMonitorResult addRange(const std::string &start_address, int count); | |||
| RegisterMonitorResult remove(const std::vector<RegisterAddress> &addresses); | |||
| RegisterMonitorResult clear(); | |||
| RegisterMonitorWriteResult writeBit(const RegisterAddress &address, bool value); | |||
| RegisterMonitorWriteResult writeWord( | |||
| const RegisterAddress &address, std::int16_t value); | |||
| const std::vector<RegisterAddress> &addresses() const; | |||
| std::vector<MonitorValue> values(bool communication_fault) const; | |||
| void setAddressesChangedCallback(std::function<void()> callback); | |||
| @@ -10,6 +10,7 @@ | |||
| #include "project_service.h" | |||
| #include "domain/active_register_repository.h" | |||
| #include "domain/project_limits.h" | |||
| #include <algorithm> | |||
| @@ -189,7 +190,11 @@ PlcCommunicationResult RuntimeModeService::connectPlc( | |||
| { | |||
| plc_gateway_->disconnectDevice(); | |||
| } | |||
| refreshPlcPollAddresses(); | |||
| const PlcCommunicationResult poll_result = refreshPlcPollAddresses(); | |||
| if (!poll_result.succeeded) | |||
| { | |||
| return poll_result; | |||
| } | |||
| setInitialPlcReadCompleted(false); | |||
| return plc_gateway_->connectDevice(configuration); | |||
| } | |||
| @@ -201,11 +206,11 @@ void RuntimeModeService::setMonitorAddresses( | |||
| refreshPlcPollAddresses(); | |||
| } | |||
| void RuntimeModeService::refreshPlcPollAddresses() | |||
| PlcCommunicationResult RuntimeModeService::refreshPlcPollAddresses() | |||
| { | |||
| if (plc_gateway_ == nullptr) | |||
| { | |||
| return; | |||
| return {false, "PLC 通信服务尚未配置"}; | |||
| } | |||
| std::vector<RegisterAddress> addresses = monitor_addresses_; | |||
| 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 LadderRung &rung : logic.rungs) | |||
| @@ -234,9 +243,7 @@ void RuntimeModeService::refreshPlcPollAddresses() | |||
| } | |||
| 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(); | |||
| }); | |||
| 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() | |||
| @@ -76,7 +76,7 @@ public: | |||
| RegisterRepository &plc_repository); | |||
| PlcCommunicationResult connectPlc(const PlcSerialConfiguration &configuration); | |||
| void setMonitorAddresses(const std::vector<RegisterAddress> &addresses); | |||
| void refreshPlcPollAddresses(); | |||
| PlcCommunicationResult refreshPlcPollAddresses(); | |||
| void disconnectPlc(); | |||
| PlcConnectionState plcConnectionState() const; | |||
| const std::string &plcError() const; | |||
| @@ -1,6 +1,7 @@ | |||
| #include "software_logic_executor.h" | |||
| #include <algorithm> | |||
| #include <limits> | |||
| #include <map> | |||
| #include <type_traits> | |||
| @@ -73,8 +74,14 @@ bool coilModesAreCompatible(CoilMode existing, CoilMode current) | |||
| void LogicTraceValues::clear() | |||
| { | |||
| nodeValues.clear(); | |||
| nodePowerValues.clear(); | |||
| expressionValues.clear(); | |||
| expressionInputValues.clear(); | |||
| expressionPowerValues.clear(); | |||
| rungValues.clear(); | |||
| tonValues.clear(); | |||
| counterValues.clear(); | |||
| wordValues.clear(); | |||
| } | |||
| void LogicTraceSnapshot::clear() | |||
| @@ -91,8 +98,14 @@ LogicTraceSnapshot LogicTraceSnapshot::forLogic( | |||
| if (values != logicValues.cend()) | |||
| { | |||
| projection.nodeValues = values->second.nodeValues; | |||
| projection.nodePowerValues = values->second.nodePowerValues; | |||
| projection.expressionValues = values->second.expressionValues; | |||
| projection.expressionInputValues = values->second.expressionInputValues; | |||
| projection.expressionPowerValues = values->second.expressionPowerValues; | |||
| projection.rungValues = values->second.rungValues; | |||
| projection.tonValues = values->second.tonValues; | |||
| projection.counterValues = values->second.counterValues; | |||
| projection.wordValues = values->second.wordValues; | |||
| } | |||
| return projection; | |||
| } | |||
| @@ -131,9 +144,17 @@ LogicScanResult SoftwareLogicExecutor::validate( | |||
| const auto *output = std::get_if<CoilNodeConfig>(&rung.output->config); | |||
| 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( | |||
| LogicScanError::InvalidLogic, | |||
| "梯形图输出不是线圈节点", | |||
| "梯形图输出不是有效的输出指令", | |||
| logic.id, | |||
| rung.id, | |||
| rung.output->id); | |||
| @@ -152,13 +173,35 @@ LogicScanResult SoftwareLogicExecutor::validate( | |||
| output_modes[output->address.index()] = output->mode; | |||
| } | |||
| } | |||
| std::string resource_error; | |||
| if (!validateLogicResourceReferencesForRunning(logics, &resource_error)) | |||
| { | |||
| return failure(LogicScanError::InvalidLogic, resource_error); | |||
| } | |||
| return success(); | |||
| } | |||
| void SoftwareLogicExecutor::resetRuntime() | |||
| { | |||
| previous_edge_inputs_.clear(); | |||
| previous_counter_inputs_.clear(); | |||
| ton_states_.clear(); | |||
| counter_states_.clear(); | |||
| } | |||
| LogicScanResult SoftwareLogicExecutor::executeScan( | |||
| const std::vector<ControlLogic> &logics, | |||
| 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); | |||
| if (!validation.succeeded) | |||
| @@ -185,9 +228,19 @@ LogicScanResult SoftwareLogicExecutor::executeScan( | |||
| 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) | |||
| { | |||
| result.logicId = logic.id; | |||
| @@ -198,15 +251,28 @@ LogicScanResult SoftwareLogicExecutor::executeScan( | |||
| { | |||
| logic_trace->rungValues[rung.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) | |||
| { | |||
| result.logicId = logic.id; | |||
| result.rungId = rung.id; | |||
| 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) | |||
| @@ -220,8 +286,14 @@ LogicScanResult SoftwareLogicExecutor::executeScan( | |||
| if (values != trace->logicValues.cend()) | |||
| { | |||
| trace->nodeValues = values->second.nodeValues; | |||
| trace->nodePowerValues = values->second.nodePowerValues; | |||
| trace->expressionValues = values->second.expressionValues; | |||
| trace->expressionInputValues = values->second.expressionInputValues; | |||
| trace->expressionPowerValues = values->second.expressionPowerValues; | |||
| 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( | |||
| const std::string &logic_id, | |||
| const ConditionExpression &expression, | |||
| RegisterRepository &repository, | |||
| LogicTraceValues *trace, | |||
| bool *value) const | |||
| bool input_power, | |||
| bool *value) | |||
| { | |||
| if (value == nullptr) | |||
| { | |||
| 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) | |||
| { | |||
| LogicScanResult result = evaluateCondition(*expression.node, repository, value); | |||
| LogicScanResult result = evaluateCondition( | |||
| logic_id, *expression.node, repository, value); | |||
| if (result.succeeded && trace != nullptr) | |||
| { | |||
| trace->nodeValues[expression.node->id] = *value; | |||
| trace->nodePowerValues[expression.node->id] = input_power && *value; | |||
| trace->expressionValues[expression.id] = *value; | |||
| trace->expressionPowerValues[expression.id] = input_power && *value; | |||
| } | |||
| return result; | |||
| } | |||
| bool accumulated = expression.kind == ConditionExpressionKind::Series; | |||
| bool power = input_power; | |||
| for (const ConditionExpression &child : expression.children) | |||
| { | |||
| 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) | |||
| { | |||
| return result; | |||
| } | |||
| accumulated = expression.kind == ConditionExpressionKind::Series | |||
| ? accumulated && child_value : accumulated || child_value; | |||
| if (expression.kind == ConditionExpressionKind::Series) | |||
| { | |||
| power = power && child_value; | |||
| } | |||
| } | |||
| *value = accumulated; | |||
| if (trace != nullptr) | |||
| { | |||
| trace->expressionValues[expression.id] = accumulated; | |||
| trace->expressionPowerValues[expression.id] = input_power && accumulated; | |||
| } | |||
| return success(); | |||
| } | |||
| LogicScanResult SoftwareLogicExecutor::evaluateCondition( | |||
| const std::string &logic_id, | |||
| const LogicNode &node, | |||
| RegisterRepository &repository, | |||
| bool *value) const | |||
| bool *value) | |||
| { | |||
| if (value == nullptr) | |||
| { | |||
| @@ -285,7 +391,8 @@ LogicScanResult SoftwareLogicExecutor::evaluateCondition( | |||
| } | |||
| 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)>; | |||
| if constexpr (std::is_same_v<Config, ContactNodeConfig>) | |||
| @@ -304,6 +411,37 @@ LogicScanResult SoftwareLogicExecutor::evaluateCondition( | |||
| ? read.value : !read.value; | |||
| 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>) | |||
| { | |||
| const WordReadResult read = repository.readWord(config.address); | |||
| @@ -323,7 +461,7 @@ LogicScanResult SoftwareLogicExecutor::evaluateCondition( | |||
| { | |||
| return failure( | |||
| LogicScanError::InvalidLogic, | |||
| "条件节点不能包含输出线圈", | |||
| "条件节点不能包含输出节点", | |||
| {}, | |||
| {}, | |||
| node.id); | |||
| @@ -332,24 +470,291 @@ LogicScanResult SoftwareLogicExecutor::evaluateCondition( | |||
| 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, | |||
| 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); | |||
| if (config == nullptr) | |||
| { | |||
| return failure( | |||
| LogicScanError::InvalidLogic, | |||
| "梯形图输出不是线圈节点", | |||
| "梯形图输出不是有效的输出指令", | |||
| {}, | |||
| {}, | |||
| node.id); | |||
| } | |||
| bool should_write = true; | |||
| bool output_value = rung_value; | |||
| bool register_value = rung_value; | |||
| switch (config->mode) | |||
| { | |||
| case CoilMode::Normal: | |||
| @@ -359,13 +764,13 @@ LogicScanResult SoftwareLogicExecutor::writeOutput( | |||
| case CoilMode::Set: | |||
| { | |||
| should_write = rung_value; | |||
| output_value = true; | |||
| register_value = true; | |||
| break; | |||
| } | |||
| case CoilMode::Reset: | |||
| { | |||
| should_write = rung_value; | |||
| output_value = false; | |||
| register_value = false; | |||
| break; | |||
| } | |||
| default: | |||
| @@ -381,10 +786,11 @@ LogicScanResult SoftwareLogicExecutor::writeOutput( | |||
| if (!should_write) | |||
| { | |||
| *output_value = rung_value; | |||
| return success(); | |||
| } | |||
| const RegisterWriteResult write = repository.writeBit( | |||
| config->address, output_value); | |||
| config->address, register_value); | |||
| if (!write.succeeded) | |||
| { | |||
| return failure( | |||
| @@ -394,5 +800,6 @@ LogicScanResult SoftwareLogicExecutor::writeOutput( | |||
| {}, | |||
| node.id); | |||
| } | |||
| *output_value = rung_value; | |||
| return success(); | |||
| } | |||
| @@ -3,8 +3,12 @@ | |||
| #include "domain/control_logic_model.h" | |||
| #include "domain/register_repository.h" | |||
| #include <chrono> | |||
| #include <cstdint> | |||
| #include <map> | |||
| #include <string> | |||
| #include <unordered_map> | |||
| #include <utility> | |||
| #include <vector> | |||
| enum class LogicScanError | |||
| @@ -26,11 +30,40 @@ struct LogicScanResult | |||
| 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 | |||
| { | |||
| 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> expressionInputValues; | |||
| std::unordered_map<std::string, bool> expressionPowerValues; | |||
| 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(); | |||
| }; | |||
| @@ -47,24 +80,62 @@ struct LogicTraceSnapshot : LogicTraceValues | |||
| class SoftwareLogicExecutor | |||
| { | |||
| public: | |||
| using Clock = std::chrono::steady_clock; | |||
| using TimePoint = Clock::time_point; | |||
| LogicScanResult validate(const std::vector<ControlLogic> &logics) const; | |||
| void resetRuntime(); | |||
| LogicScanResult executeScan( | |||
| const std::vector<ControlLogic> &logics, | |||
| RegisterRepository &repository, | |||
| LogicTraceSnapshot *trace = nullptr) const; | |||
| LogicTraceSnapshot *trace = nullptr); | |||
| LogicScanResult executeScanAt( | |||
| const std::vector<ControlLogic> &logics, | |||
| RegisterRepository &repository, | |||
| TimePoint now, | |||
| LogicTraceSnapshot *trace = nullptr); | |||
| private: | |||
| struct TonRuntimeState | |||
| { | |||
| bool timing = false; | |||
| bool done = false; | |||
| TimePoint startedAt{}; | |||
| std::chrono::milliseconds elapsed{0}; | |||
| }; | |||
| LogicScanResult evaluateCondition( | |||
| const std::string &logic_id, | |||
| const LogicNode &node, | |||
| RegisterRepository &repository, | |||
| bool *value) const; | |||
| bool *value); | |||
| LogicScanResult evaluateExpression( | |||
| const std::string &logic_id, | |||
| const ConditionExpression &expression, | |||
| RegisterRepository &repository, | |||
| LogicTraceValues *trace, | |||
| bool *value) const; | |||
| LogicScanResult writeOutput( | |||
| bool input_power, | |||
| bool *value); | |||
| LogicScanResult executeOutput( | |||
| const std::string &logic_id, | |||
| const LogicNode &node, | |||
| 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 "services/register_monitor_service.h" | |||
| #include "toolbar_icon_factory.h" | |||
| #include "ui_free_monitor_widget.h" | |||
| #include <QComboBox> | |||
| #include <QHeaderView> | |||
| #include <QIntValidator> | |||
| #include <QLineEdit> | |||
| #include <QMessageBox> | |||
| #include <QStyle> | |||
| #include <QPushButton> | |||
| #include <QTableWidgetItem> | |||
| #include <cstdint> | |||
| #include <set> | |||
| namespace { | |||
| @@ -32,12 +37,14 @@ FreeMonitorWidget::FreeMonitorWidget( | |||
| service_(service) | |||
| { | |||
| 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(1, QHeaderView::ResizeToContents); | |||
| ui_->monitorTable->horizontalHeader()->setSectionResizeMode(2, 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); | |||
| connect(ui_->addButton, &QPushButton::clicked, this, &FreeMonitorWidget::addAddresses); | |||
| connect(ui_->addressEdit, &QLineEdit::returnPressed, | |||
| @@ -51,6 +58,12 @@ FreeMonitorWidget::FreeMonitorWidget( | |||
| FreeMonitorWidget::~FreeMonitorWidget() = default; | |||
| void FreeMonitorWidget::setWriteEnabled(bool enabled) | |||
| { | |||
| write_enabled_ = enabled; | |||
| updateWriteControls(); | |||
| } | |||
| void FreeMonitorWidget::refreshValues( | |||
| ApplicationMode mode, PlcConnectionState plc_state) | |||
| { | |||
| @@ -75,7 +88,7 @@ void FreeMonitorWidget::refreshValues( | |||
| for (int row = 0; row < ui_->monitorTable->rowCount(); ++row) | |||
| { | |||
| 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()) | |||
| { | |||
| value_item->setText(QStringLiteral("--")); | |||
| @@ -97,6 +110,7 @@ void FreeMonitorWidget::refreshValues( | |||
| state_item->setText(value.state == MonitorValueState::CommunicationFault | |||
| ? tr("通信故障,最后有效值") : tr("有效")); | |||
| } | |||
| updateWriteControls(); | |||
| } | |||
| void FreeMonitorWidget::reloadAddresses() | |||
| @@ -112,7 +126,97 @@ void FreeMonitorWidget::reloadAddresses() | |||
| row, 1, new QTableWidgetItem(address.area() == RegisterArea::M | |||
| ? tr("位") : tr("16 位整数"))); | |||
| 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); | |||
| ~FreeMonitorWidget() override; | |||
| void setWriteEnabled(bool enabled); | |||
| void refreshValues(ApplicationMode mode, PlcConnectionState plc_state); | |||
| void reloadAddresses(); | |||
| @@ -34,8 +35,11 @@ private: | |||
| void addAddresses(); | |||
| void removeSelectedAddresses(); | |||
| void clearAddresses(); | |||
| void writeRow(int row); | |||
| void updateWriteControls(); | |||
| void handleResult(const QString &action, bool succeeded, const std::string &message); | |||
| std::unique_ptr<Ui::FreeMonitorWidget> ui_; | |||
| RegisterMonitorService &service_; | |||
| bool write_enabled_ = false; | |||
| }; | |||
| @@ -3,7 +3,7 @@ | |||
| <class>FreeMonitorWidget</class> | |||
| <widget class="QWidget" name="FreeMonitorWidget"> | |||
| <property name="minimumSize"> | |||
| <size><width>420</width><height>190</height></size> | |||
| <size><width>620</width><height>190</height></size> | |||
| </property> | |||
| <layout class="QVBoxLayout" name="verticalLayout"> | |||
| <property name="spacing"><number>6</number></property> | |||
| @@ -31,12 +31,14 @@ | |||
| <property name="selectionBehavior"><enum>QAbstractItemView::SelectRows</enum></property> | |||
| <property name="editTriggers"><set>QAbstractItemView::NoEditTriggers</set></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> | |||
| <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> | |||
| </item> | |||
| </layout> | |||
| @@ -2,7 +2,12 @@ | |||
| #include "services/hmi_editor_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 <QGraphicsRectItem> | |||
| #include <QGraphicsScene> | |||
| @@ -11,6 +16,8 @@ | |||
| #include <QInputDialog> | |||
| #include <QPainter> | |||
| #include <QResizeEvent> | |||
| #include <QStyle> | |||
| #include <QStyleOption> | |||
| #include <QStyleOptionGraphicsItem> | |||
| #include <algorithm> | |||
| @@ -21,6 +28,23 @@ | |||
| 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 控件投影为可绘制、可选择和可交互的场景图元 | |||
| 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 &, HmiButtonEvent)> button_event, | |||
| 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), | |||
| page_width_(page_width), | |||
| page_height_(page_height), | |||
| moved_(std::move(moved)), | |||
| button_event_(std::move(button_event)), | |||
| 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); | |||
| @@ -57,9 +83,9 @@ public: | |||
| { | |||
| if (!control_.binding.has_value()) | |||
| { | |||
| return controlRect(); | |||
| return visibleControlRect(); | |||
| } | |||
| return controlRect().united(addressRect()); | |||
| return visibleControlRect().united(addressRect()); | |||
| } | |||
| void paint( | |||
| @@ -68,7 +94,7 @@ public: | |||
| 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->setPen(QPen(QColor(QStringLiteral("#47545f")), 1)); | |||
| @@ -84,6 +110,8 @@ public: | |||
| painter->setPen(QPen(QColor(QStringLiteral("#47545f")), 1)); | |||
| } | |||
| applyConfiguredFont(painter); | |||
| switch (control_.type) | |||
| { | |||
| case HmiControlType::Button: | |||
| @@ -101,7 +129,7 @@ public: | |||
| : QColor(QStringLiteral("#5e816b")); | |||
| const QColor text = disabled | |||
| ? QColor(QStringLiteral("#7a837e")) | |||
| : QColor(QStringLiteral("#205c3b")); | |||
| : configuredTextColor(QColor(QStringLiteral("#205c3b"))); | |||
| // 未按下时保留下沿阴影,按下后将按钮面下移形成明确的回弹感 | |||
| QRectF face = rect.adjusted(0, 0, 0, -2); | |||
| @@ -136,7 +164,7 @@ public: | |||
| painter->setBrush(bit_value_ ? QColor(QStringLiteral("#24a148")) | |||
| : QColor(QStringLiteral("#b8c1c8"))); | |||
| painter->drawEllipse(lamp); | |||
| painter->setPen(QColor(QStringLiteral("#24313b"))); | |||
| painter->setPen(configuredTextColor(QColor(QStringLiteral("#24313b")))); | |||
| painter->drawText( | |||
| QRectF(rect.left(), lamp.bottom() + 1, rect.width(), 16), | |||
| Qt::AlignCenter, | |||
| @@ -149,7 +177,7 @@ public: | |||
| // 数值显示为只读样式,文本由运行值刷新 | |||
| painter->setBrush(QColor(QStringLiteral("#edf2f6"))); | |||
| painter->drawRect(rect); | |||
| painter->setPen(QColor(QStringLiteral("#24313b"))); | |||
| painter->setPen(configuredTextColor(QColor(QStringLiteral("#24313b")))); | |||
| painter->drawText(rect.adjusted(7, 0, -7, 0), | |||
| Qt::AlignVCenter | Qt::AlignLeft, | |||
| textWithValue()); | |||
| @@ -160,12 +188,54 @@ public: | |||
| // 数值输入以白色编辑框样式呈现,双击后才请求写入 | |||
| painter->setBrush(QColor(QStringLiteral("#ffffff"))); | |||
| painter->drawRoundedRect(rect, 3, 3); | |||
| painter->setPen(QColor(QStringLiteral("#24313b"))); | |||
| painter->setPen(configuredTextColor(QColor(QStringLiteral("#24313b")))); | |||
| painter->drawText(rect.adjusted(7, 0, -7, 0), | |||
| Qt::AlignVCenter | Qt::AlignLeft, | |||
| textWithValue()); | |||
| 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: | |||
| { | |||
| const QColor fill = runtime_active_ && page_hovered_ | |||
| @@ -174,15 +244,141 @@ public: | |||
| painter->setBrush(fill); | |||
| painter->setPen(QPen(QColor(QStringLiteral("#4e789f")), 1)); | |||
| painter->drawRoundedRect(rect, 4, 4); | |||
| painter->setPen(QColor(QStringLiteral("#244b6b"))); | |||
| painter->setPen(configuredTextColor(QColor(QStringLiteral("#244b6b")))); | |||
| painter->drawText(rect, Qt::AlignCenter, textWithValue()); | |||
| 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: | |||
| default: | |||
| { | |||
| // 标签只显示固定文本,不绑定寄存器运行值 | |||
| painter->setPen(QColor(QStringLiteral("#24313b"))); | |||
| painter->setPen(configuredTextColor(QColor(QStringLiteral("#24313b")))); | |||
| painter->drawText(rect, Qt::AlignCenter, | |||
| QString::fromUtf8(control_.text.data(), | |||
| static_cast<int>(control_.text.size()))); | |||
| @@ -209,6 +405,14 @@ public: | |||
| void setInteractionState( | |||
| bool editable, bool runtime_active, bool runtime_write_enabled) | |||
| { | |||
| if (control_.type == HmiControlType::AlarmList) | |||
| { | |||
| prepareGeometryChange(); | |||
| if (!runtime_active) | |||
| { | |||
| alarm_page_ = 0U; | |||
| } | |||
| } | |||
| editing_enabled_ = editable; | |||
| runtime_active_ = runtime_active; | |||
| runtime_write_enabled_ = runtime_active && runtime_write_enabled; | |||
| @@ -222,9 +426,12 @@ public: | |||
| const bool runtime_input = runtime_active_ | |||
| && (control_.type == HmiControlType::Button | |||
| || control_.type == HmiControlType::NumericInput | |||
| || control_.type == HmiControlType::PageJump); | |||
| || control_.type == HmiControlType::PageJump | |||
| || control_.type == HmiControlType::AlarmList); | |||
| 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( | |||
| editable || runtime_input_enabled ? Qt::LeftButton : Qt::NoButton); | |||
| @@ -232,8 +439,11 @@ public: | |||
| && control_.type == HmiControlType::Button && runtime_write_enabled_; | |||
| const bool runtime_page_jump_enabled = runtime_active_ | |||
| && control_.type == HmiControlType::PageJump; | |||
| const bool runtime_alarm_enabled = runtime_active_ | |||
| && control_.type == HmiControlType::AlarmList; | |||
| 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); | |||
| } | |||
| @@ -244,6 +454,7 @@ public: | |||
| page_hovered_ = false; | |||
| button_pressed_ = false; | |||
| } | |||
| updateAlarmVisibility(); | |||
| update(); | |||
| } | |||
| @@ -256,6 +467,20 @@ public: | |||
| 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: | |||
| // 拖拽过程中将新位置限制在页面可见边界内 | |||
| QVariant itemChange(GraphicsItemChange change, const QVariant &value) override | |||
| @@ -279,6 +504,52 @@ protected: | |||
| // 运行态按钮按下时通知外层执行配置的 M 位操作 | |||
| 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) | |||
| { | |||
| page_pressed_ = true; | |||
| @@ -378,6 +649,97 @@ protected: | |||
| } | |||
| 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 | |||
| { | |||
| @@ -405,6 +767,64 @@ private: | |||
| : 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 | |||
| { | |||
| @@ -433,6 +853,9 @@ private: | |||
| std::function<void(const std::string &, HmiButtonEvent)> button_event_; | |||
| std::function<void(const std::string &)> numeric_input_activated_; | |||
| 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 runtime_active_ = false; | |||
| bool runtime_write_enabled_ = false; | |||
| @@ -459,10 +882,12 @@ HmiGraphicsItem *asHmiItem(QGraphicsItem *item) | |||
| HmiEditorWidget::HmiEditorWidget( | |||
| HmiEditorService &editor_service, | |||
| HmiRuntimeService &runtime_service, | |||
| AlarmService &alarm_service, | |||
| QWidget *parent) | |||
| : QGraphicsView(parent), | |||
| editor_service_(editor_service), | |||
| runtime_service_(runtime_service), | |||
| alarm_service_(alarm_service), | |||
| scene_(new QGraphicsScene(this)) | |||
| { | |||
| setObjectName(QStringLiteral("hmiEditorWidget")); | |||
| @@ -504,6 +929,7 @@ void HmiEditorWidget::setRuntimeActive(bool active) | |||
| if (control_item != nullptr) | |||
| { | |||
| control_item->setRuntimeValue(false, 0, false); | |||
| control_item->setAlarmRecords({}); | |||
| } | |||
| } | |||
| } | |||
| @@ -557,6 +983,11 @@ void HmiEditorWidget::reloadPage() | |||
| { | |||
| emit pageNavigationRequested( | |||
| QString::fromStdString(target_page_id)); | |||
| }, | |||
| [this](const std::string &definition_id) | |||
| { | |||
| alarm_service_.acknowledge(definition_id); | |||
| refreshRuntimeValues(); | |||
| }); | |||
| scene_->addItem(item); | |||
| item->setInteractionState( | |||
| @@ -569,6 +1000,7 @@ void HmiEditorWidget::reloadPage() | |||
| // 遍历场景所有图元,找到对应 id 的图元,设置选中,视图滚动到把控件显示出来 | |||
| void HmiEditorWidget::selectControl(const std::string &control_id) | |||
| { | |||
| scene_->clearSelection(); | |||
| for (QGraphicsItem *item : scene_->items()) | |||
| { | |||
| HmiGraphicsItem *control_item = asHmiItem(item); | |||
| @@ -583,16 +1015,39 @@ void HmiEditorWidget::selectControl(const std::string &control_id) | |||
| 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); | |||
| 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() | |||
| @@ -614,6 +1069,11 @@ void HmiEditorWidget::refreshRuntimeValues() | |||
| { | |||
| continue; | |||
| } | |||
| if (control->type == HmiControlType::AlarmList) | |||
| { | |||
| control_item->setAlarmRecords(alarm_service_.records()); | |||
| continue; | |||
| } | |||
| // 运行值通过服务读取,图元不直接接触寄存器仓库 | |||
| const HmiRuntimeReadResult value = runtime_service_.readControl(*control); | |||
| control_item->setRuntimeValue(value.bit_value, value.word_value, value.succeeded); | |||
| @@ -10,9 +10,11 @@ | |||
| #include <QGraphicsView> | |||
| #include <string> | |||
| #include <vector> | |||
| class HmiEditorService; | |||
| class HmiRuntimeService; | |||
| class AlarmService; | |||
| class QGraphicsScene; | |||
| class QResizeEvent; | |||
| enum class HmiButtonEvent; | |||
| @@ -36,6 +38,7 @@ public: | |||
| explicit HmiEditorWidget( | |||
| HmiEditorService &editor_service, | |||
| HmiRuntimeService &runtime_service, | |||
| AlarmService &alarm_service, | |||
| QWidget *parent = nullptr); | |||
| /** | |||
| @@ -67,6 +70,7 @@ public: | |||
| */ | |||
| void selectControl(const std::string &control_id); | |||
| std::string selectedControlId() const; | |||
| std::vector<std::string> selectedControlIds() const; | |||
| /** | |||
| * @brief 从统一寄存器仓库读取当前值并刷新画布显示 | |||
| * | |||
| @@ -114,6 +118,7 @@ private: | |||
| HmiEditorService &editor_service_; | |||
| // 提供运行态寄存器读写能力,画布不直接访问寄存器仓库 | |||
| HmiRuntimeService &runtime_service_; | |||
| AlarmService &alarm_service_; | |||
| // 持有所有页面图元和页面边框 | |||
| QGraphicsScene *scene_ = nullptr; | |||
| // 当前画布投影的页面标识 | |||
| @@ -7,6 +7,7 @@ | |||
| #include <QGraphicsView> | |||
| #include <string> | |||
| #include <utility> | |||
| #include <vector> | |||
| class QGraphicsScene; | |||
| @@ -18,7 +19,11 @@ class LogicEditorWidget final : public QGraphicsView | |||
| public: | |||
| class NodeItem; | |||
| class WireItem; | |||
| class WireCellItem; | |||
| class VerticalConnectorItem; | |||
| class RungItem; | |||
| class EmptySlotItem; | |||
| explicit LogicEditorWidget( | |||
| LogicEditorService &editor_service, | |||
| @@ -39,7 +44,12 @@ public: | |||
| LogicEditorResult addRung(); | |||
| LogicEditorResult addCondition(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(); | |||
| signals: | |||
| @@ -54,6 +64,13 @@ private: | |||
| void handleSelectionChanged(); | |||
| void reportFailure(const LogicEditorResult &result); | |||
| 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_; | |||
| 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 | |||
| class QAction; | |||
| class QActionGroup; | |||
| class QCloseEvent; | |||
| class QEvent; | |||
| class QLabel; | |||
| class QTimer; | |||
| namespace Ui { | |||
| class MainWindow; | |||
| @@ -32,12 +33,18 @@ class RuntimeModeService; | |||
| class ProjectService; | |||
| class HmiEditorService; | |||
| class HmiRuntimeService; | |||
| class AlarmEditorService; | |||
| class AlarmService; | |||
| class HmiNavigationService; | |||
| class RegisterCommentService; | |||
| class HmiEditorWidget; | |||
| class LogicEditorService; | |||
| class LogicEditorWidget; | |||
| class RegisterMonitorService; | |||
| class RuntimeMonitorWidget; | |||
| class ProjectWorkspaceController; | |||
| class PropertyPanelController; | |||
| class RuntimePanelController; | |||
| /** | |||
| * @brief 组织主界面区域并将用户操作转交给服务层 | |||
| @@ -63,6 +70,9 @@ public: | |||
| HmiEditorService &hmi_editor_service, | |||
| LogicEditorService &logic_editor_service, | |||
| HmiRuntimeService &hmi_runtime_service, | |||
| AlarmEditorService &alarm_editor_service, | |||
| AlarmService &alarm_service, | |||
| RegisterCommentService ®ister_comment_service, | |||
| RegisterMonitorService ®ister_monitor_service, | |||
| QWidget *parent = nullptr); | |||
| MainWindow( | |||
| @@ -71,7 +81,10 @@ public: | |||
| HmiEditorService &hmi_editor_service, | |||
| LogicEditorService &logic_editor_service, | |||
| HmiRuntimeService &hmi_runtime_service, | |||
| AlarmEditorService &alarm_editor_service, | |||
| AlarmService &alarm_service, | |||
| HmiNavigationService &hmi_navigation_service, | |||
| RegisterCommentService ®ister_comment_service, | |||
| RegisterMonitorService ®ister_monitor_service, | |||
| QWidget *parent = nullptr); | |||
| @@ -80,6 +93,10 @@ public: | |||
| const std::string ¤tHmiPageId() const; | |||
| const std::string ¤tLogicId() const; | |||
| protected: | |||
| bool eventFilter(QObject *watched, QEvent *event) override; | |||
| void closeEvent(QCloseEvent *event) override; | |||
| private: | |||
| // 配置菜单和工具栏动作 | |||
| void configureActions(); | |||
| @@ -94,17 +111,19 @@ private: | |||
| // 创建并连接控件属性编辑表单 | |||
| void configurePropertyEditor(); | |||
| void configurePlcConnection(); | |||
| void configureAlarms(); | |||
| void configureRegisterComments(); | |||
| void undoActiveEditor(); | |||
| void redoActiveEditor(); | |||
| void deleteActiveSelection(); | |||
| void clearActiveSelection(); | |||
| void updateEditActions(); | |||
| void clearEditorHistories(); | |||
| void updateWindowTitle(); | |||
| bool confirmSaveBeforeDestructiveAction(); | |||
| // 刷新工程树和当前 HMI 页面信息 | |||
| void refreshProjectUi(); | |||
| void handleProjectTreeSelection(); | |||
| void updateProjectTreeActions(); | |||
| void addPage(); | |||
| void addLogic(); | |||
| void renameProjectItem(); | |||
| void deleteProjectItem(); | |||
| void moveProjectItem(int offset); | |||
| void setSelectedPageAsInitial(); | |||
| void toggleSelectedLogicEnabled(); | |||
| // 显示指定 HMI 控件的可编辑属性 | |||
| void showControlProperties(const std::string &control_id); | |||
| // 显示指定控制逻辑节点的可编辑属性 | |||
| @@ -113,20 +132,22 @@ private: | |||
| void addHmiControl(HmiControlType type); | |||
| // 删除当前选中的 HMI 控件 | |||
| void deleteSelectedControl(); | |||
| // 将属性表单内容应用到当前选中控件 | |||
| void applySelectedControlProperties(); | |||
| // 在当前光标节点后添加串联条件,没有选择时追加到网络末尾 | |||
| void addLogicCondition(const LogicNodeConfig &config); | |||
| // 为当前选中的连续逻辑范围建立并联支路 | |||
| void addLogicParallelBranch(const LogicNodeConfig &config); | |||
| void addLogicHorizontalWire(); | |||
| void addLogicVerticalWire(); | |||
| void deleteLogicHorizontalWire(); | |||
| void deleteLogicVerticalWire(); | |||
| // 设置当前网络右侧的输出线圈 | |||
| void setLogicOutput(const LogicNodeConfig &config); | |||
| void configureAndSetLogicOutput(const LogicNodeConfig &config); | |||
| // 新增一个梯形图网络 | |||
| void addLogicRung(); | |||
| void editSelectedRungComment(); | |||
| // 删除当前选中的逻辑节点或网络 | |||
| void deleteSelectedLogicObject(); | |||
| // 将逻辑属性表单内容应用到当前选中节点 | |||
| void applySelectedLogicNodeProperties(); | |||
| void connectPlc(); | |||
| void disconnectPlc(); | |||
| // 合并同一事件循环内的 PLC 状态通知,避免重复刷新和重复日志 | |||
| @@ -141,6 +162,7 @@ private: | |||
| void loadProject(); | |||
| // 在状态栏和输出面板显示工程操作结果 | |||
| void showProjectResult(const QString &action, const QString &message, bool succeeded); | |||
| void appendOutputMessage(const QString &message); | |||
| /** | |||
| * @brief 请求服务层切换模式并同步界面状态 | |||
| @@ -176,30 +198,26 @@ private: | |||
| HmiEditorService &hmi_editor_service_; | |||
| LogicEditorService &logic_editor_service_; | |||
| HmiRuntimeService &hmi_runtime_service_; | |||
| AlarmEditorService &alarm_editor_service_; | |||
| AlarmService &alarm_service_; | |||
| RegisterCommentService ®ister_comment_service_; | |||
| RegisterMonitorService ®ister_monitor_service_; | |||
| std::unique_ptr<HmiNavigationService> owned_hmi_navigation_service_; | |||
| 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; | |||
| HmiEditorWidget *hmi_editor_widget_ = nullptr; | |||
| LogicEditorWidget *logic_editor_widget_ = nullptr; | |||
| RuntimeMonitorWidget *runtime_monitor_widget_ = nullptr; | |||
| QTimer *runtime_refresh_timer_ = nullptr; | |||
| QLabel *mode_status_label_ = nullptr; | |||
| QLabel *register_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_; | |||
| std::string selected_control_id_; | |||
| std::string selected_logic_node_id_; | |||
| std::string current_hmi_page_id_; | |||
| std::string current_logic_id_; | |||
| bool plc_status_update_pending_ = false; | |||
| int last_editing_tab_index_ = 0; | |||
| }; | |||
| @@ -132,7 +132,7 @@ | |||
| <item> | |||
| <widget class="QLabel" name="hmiPageSizeLabel"> | |||
| <property name="text"> | |||
| <string>800 x 480</string> | |||
| <string>800 x 400</string> | |||
| </property> | |||
| <property name="alignment"> | |||
| <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set> | |||
| @@ -207,18 +207,7 @@ | |||
| </item> | |||
| </layout> | |||
| </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> | |||
| </item> | |||
| </layout> | |||
| </widget> | |||
| @@ -240,7 +229,19 @@ | |||
| <addaction name="saveAsProjectAction"/> | |||
| <addaction name="loadProjectAction"/> | |||
| <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 class="QMenu" name="runMenu"> | |||
| <property name="title"> | |||
| @@ -254,11 +255,13 @@ | |||
| <addaction name="disconnectPlcAction"/> | |||
| </widget> | |||
| <widget class="QMenu" name="viewMenu"> | |||
| <property name="title"> | |||
| <string>视图(&V)</string> | |||
| </property> | |||
| <property name="title"> | |||
| <string>视图(&V)</string> | |||
| </property> | |||
| <addaction name="configureRegisterCommentsAction"/> | |||
| </widget> | |||
| <addaction name="fileMenu"/> | |||
| <addaction name="editMenu"/> | |||
| <addaction name="runMenu"/> | |||
| <addaction name="viewMenu"/> | |||
| </widget> | |||
| @@ -296,14 +299,13 @@ | |||
| <enum>TopToolBarArea</enum> | |||
| </attribute> | |||
| <attribute name="toolBarBreak"> | |||
| <bool>false</bool> | |||
| <bool>true</bool> | |||
| </attribute> | |||
| <addaction name="addButtonAction"/> | |||
| <addaction name="addIndicatorAction"/> | |||
| <addaction name="addNumericDisplayAction"/> | |||
| <addaction name="addNumericInputAction"/> | |||
| <addaction name="addLabelAction"/> | |||
| <addaction name="addPageJumpAction"/> | |||
| <addaction name="deleteControlAction"/> | |||
| </widget> | |||
| <widget class="QToolBar" name="logicToolBar"> | |||
| @@ -317,18 +319,18 @@ | |||
| <enum>TopToolBarArea</enum> | |||
| </attribute> | |||
| <attribute name="toolBarBreak"> | |||
| <bool>false</bool> | |||
| <bool>true</bool> | |||
| </attribute> | |||
| <addaction name="addRungAction"/> | |||
| <addaction name="insertHorizontalWireAction"/> | |||
| <addaction name="insertVerticalWireAction"/> | |||
| <addaction name="parallelInsertAction"/> | |||
| <addaction name="deleteLogicAction"/> | |||
| <addaction name="separator"/> | |||
| <addaction name="addNormallyOpenAction"/> | |||
| <addaction name="addNormallyClosedAction"/> | |||
| <addaction name="addNormalCoilAction"/> | |||
| <addaction name="addSetCoilAction"/> | |||
| <addaction name="addResetCoilAction"/> | |||
| <addaction name="addCompareAction"/> | |||
| <addaction name="deleteLogicAction"/> | |||
| <addaction name="editRungCommentAction"/> | |||
| </widget> | |||
| <widget class="QDockWidget" name="projectDock"> | |||
| <property name="minimumSize"> | |||
| @@ -450,6 +452,54 @@ | |||
| </item> | |||
| <item> | |||
| <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"> | |||
| <layout class="QFormLayout" name="hmiPropertiesForm"> | |||
| <property name="fieldGrowthPolicy"> | |||
| @@ -485,7 +535,7 @@ | |||
| <item row="2" column="1"> | |||
| <widget class="QSpinBox" name="controlXSpinBox"> | |||
| <property name="maximum"> | |||
| <number>4000</number> | |||
| <number>8192</number> | |||
| </property> | |||
| </widget> | |||
| </item> | |||
| @@ -499,7 +549,7 @@ | |||
| <item row="3" column="1"> | |||
| <widget class="QSpinBox" name="controlYSpinBox"> | |||
| <property name="maximum"> | |||
| <number>4000</number> | |||
| <number>8192</number> | |||
| </property> | |||
| </widget> | |||
| </item> | |||
| @@ -516,7 +566,7 @@ | |||
| <number>1</number> | |||
| </property> | |||
| <property name="maximum"> | |||
| <number>4000</number> | |||
| <number>8192</number> | |||
| </property> | |||
| </widget> | |||
| </item> | |||
| @@ -533,7 +583,7 @@ | |||
| <number>1</number> | |||
| </property> | |||
| <property name="maximum"> | |||
| <number>4000</number> | |||
| <number>8192</number> | |||
| </property> | |||
| </widget> | |||
| </item> | |||
| @@ -618,7 +668,123 @@ | |||
| </item> | |||
| </widget> | |||
| </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"> | |||
| <property name="text"> | |||
| <string>应用属性</string> | |||
| @@ -756,7 +922,27 @@ | |||
| </property> | |||
| </widget> | |||
| </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"> | |||
| <property name="text"> | |||
| <string>应用属性</string> | |||
| @@ -822,21 +1008,53 @@ | |||
| <property name="text"> | |||
| <string>新建工程</string> | |||
| </property> | |||
| <property name="shortcut"> | |||
| <string>Ctrl+N</string> | |||
| </property> | |||
| </action> | |||
| <action name="saveProjectAction"> | |||
| <property name="text"> | |||
| <string>保存工程</string> | |||
| </property> | |||
| <property name="shortcut"> | |||
| <string>Ctrl+S</string> | |||
| </property> | |||
| </action> | |||
| <action name="saveAsProjectAction"> | |||
| <property name="text"> | |||
| <string>工程另存为</string> | |||
| </property> | |||
| <property name="shortcut"> | |||
| <string>Ctrl+Shift+S</string> | |||
| </property> | |||
| </action> | |||
| <action name="loadProjectAction"> | |||
| <property name="text"> | |||
| <string>加载工程</string> | |||
| </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 name="configurePlcAction"> | |||
| <property name="text"> | |||
| @@ -846,6 +1064,14 @@ | |||
| <string>配置参数并连接真实 PLC</string> | |||
| </property> | |||
| </action> | |||
| <action name="configureRegisterCommentsAction"> | |||
| <property name="text"> | |||
| <string>软元件注释</string> | |||
| </property> | |||
| <property name="toolTip"> | |||
| <string>维护工程级 M/D 地址注释</string> | |||
| </property> | |||
| </action> | |||
| <action name="disconnectPlcAction"> | |||
| <property name="text"> | |||
| <string>断开 PLC</string> | |||
| @@ -886,6 +1112,14 @@ | |||
| <string>添加数值输入控件</string> | |||
| </property> | |||
| </action> | |||
| <action name="addProgressBarAction"> | |||
| <property name="text"> | |||
| <string>进度条</string> | |||
| </property> | |||
| <property name="toolTip"> | |||
| <string>添加进度条控件</string> | |||
| </property> | |||
| </action> | |||
| <action name="addLabelAction"> | |||
| <property name="text"> | |||
| <string>文本</string> | |||
| @@ -902,6 +1136,22 @@ | |||
| <string>添加页面跳转控件</string> | |||
| </property> | |||
| </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"> | |||
| <property name="text"> | |||
| <string>删除</string> | |||
| @@ -918,6 +1168,50 @@ | |||
| <string>在梯形图末尾新增网络</string> | |||
| </property> | |||
| </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"> | |||
| <property name="text"> | |||
| <string>并联支路</string> | |||
| @@ -931,7 +1225,7 @@ | |||
| <string>常开</string> | |||
| </property> | |||
| <property name="toolTip"> | |||
| <string>向当前网络添加常开触点</string> | |||
| <string>在选中的空网格或横线处添加常开触点;选中条件时在其后插入</string> | |||
| </property> | |||
| </action> | |||
| <action name="addNormallyClosedAction"> | |||
| @@ -939,9 +1233,37 @@ | |||
| <string>常闭</string> | |||
| </property> | |||
| <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> | |||
| </action> | |||
| <action name="addCounterContactAction"> | |||
| <property name="text"><string>C 触点</string></property> | |||
| <property name="toolTip"><string>在选中的空网格或横线处添加离线仿真计数器完成触点</string></property> | |||
| </action> | |||
| <action name="addNormalCoilAction"> | |||
| <property name="text"> | |||
| <string>线圈</string> | |||
| @@ -966,17 +1288,53 @@ | |||
| <string>设置当前网络的复位线圈</string> | |||
| </property> | |||
| </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"> | |||
| <property name="text"> | |||
| <string>D 比较</string> | |||
| </property> | |||
| <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> | |||
| </action> | |||
| <action name="deleteLogicAction"> | |||
| <property name="text"> | |||
| <string>删除</string> | |||
| <string>删除节点/网络</string> | |||
| </property> | |||
| <property name="toolTip"> | |||
| <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="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="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="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> | |||
| @@ -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 "logic_editor_widget.h" | |||
| #include "services/hmi_navigation_service.h" | |||
| #include "services/alarm_service.h" | |||
| #include "services/project_service.h" | |||
| #include "toolbar_icon_factory.h" | |||
| #include "ui_runtime_monitor_widget.h" | |||
| #include <QSplitter> | |||
| #include <QComboBox> | |||
| #include <QSignalBlocker> | |||
| #include <QToolButton> | |||
| #include <algorithm> | |||
| @@ -34,6 +37,7 @@ RuntimeMonitorWidget::RuntimeMonitorWidget( | |||
| LogicEditorService &logic_editor_service, | |||
| ProjectService &project_service, | |||
| HmiNavigationService &hmi_navigation_service, | |||
| AlarmService &alarm_service, | |||
| RegisterMonitorService ®ister_monitor_service, | |||
| QWidget *parent) | |||
| : QWidget(parent), | |||
| @@ -42,8 +46,12 @@ RuntimeMonitorWidget::RuntimeMonitorWidget( | |||
| hmi_navigation_service_(hmi_navigation_service) | |||
| { | |||
| ui_->setupUi(this); | |||
| ui_->exitRuntimeButton->setIcon(makeUiIcon(UiIcon::Exit)); | |||
| 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_->setEditingEnabled(false); | |||
| hmi_view_->setRuntimeActive(true); | |||
| @@ -72,8 +80,9 @@ RuntimeMonitorWidget::RuntimeMonitorWidget( | |||
| return; | |||
| } | |||
| showRuntimePage(result.pageId); | |||
| emit runtimePageChanged(fromUtf8(result.pageId)); | |||
| }); | |||
| connect(ui_->exitRuntimeButton, &QToolButton::clicked, | |||
| this, &RuntimeMonitorWidget::exitRequested); | |||
| connect(ui_->runtimeLogicComboBox, | |||
| QOverload<int>::of(&QComboBox::currentIndexChanged), | |||
| this, | |||
| @@ -92,7 +101,7 @@ RuntimeMonitorWidget::~RuntimeMonitorWidget() = default; | |||
| void RuntimeMonitorWidget::setProjectObjects( | |||
| const std::string &page_id, const std::string &logic_id) | |||
| { | |||
| showRuntimePage(page_id); | |||
| setRuntimePage(page_id); | |||
| const QSignalBlocker blocker(ui_->runtimeLogicComboBox); | |||
| ui_->runtimeLogicComboBox->clear(); | |||
| @@ -115,6 +124,11 @@ void RuntimeMonitorWidget::setProjectObjects( | |||
| : std::string{}); | |||
| } | |||
| void RuntimeMonitorWidget::setRuntimePage(const std::string &page_id) | |||
| { | |||
| showRuntimePage(page_id); | |||
| } | |||
| void RuntimeMonitorWidget::setMode( | |||
| ApplicationMode mode, PlcConnectionState plc_state) | |||
| { | |||
| @@ -122,8 +136,12 @@ void RuntimeMonitorWidget::setMode( | |||
| const bool online = mode == ApplicationMode::OnlineRunning; | |||
| ui_->logicFrame->setVisible(offline); | |||
| 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) | |||
| { | |||
| logic_view_->clearRuntimeTrace(); | |||
| @@ -142,6 +160,11 @@ void RuntimeMonitorWidget::setHmiWriteEnabled(bool enabled) | |||
| hmi_view_->setRuntimeWriteEnabled(enabled); | |||
| } | |||
| void RuntimeMonitorWidget::setFreeMonitorWriteEnabled(bool enabled) | |||
| { | |||
| free_monitor_widget_->setWriteEnabled(enabled); | |||
| } | |||
| void RuntimeMonitorWidget::refreshValues( | |||
| ApplicationMode mode, PlcConnectionState plc_state) | |||
| { | |||
| @@ -17,6 +17,7 @@ class FreeMonitorWidget; | |||
| class HmiEditorService; | |||
| class HmiEditorWidget; | |||
| class HmiRuntimeService; | |||
| class AlarmService; | |||
| class HmiNavigationService; | |||
| class LogicEditorService; | |||
| class LogicEditorWidget; | |||
| @@ -34,21 +35,24 @@ public: | |||
| LogicEditorService &logic_editor_service, | |||
| ProjectService &project_service, | |||
| HmiNavigationService &hmi_navigation_service, | |||
| AlarmService &alarm_service, | |||
| RegisterMonitorService ®ister_monitor_service, | |||
| QWidget *parent = nullptr); | |||
| ~RuntimeMonitorWidget() override; | |||
| 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 refreshValues(ApplicationMode mode, PlcConnectionState plc_state); | |||
| void setHmiWriteEnabled(bool enabled); | |||
| void setFreeMonitorWriteEnabled(bool enabled); | |||
| void setLogicTrace(const LogicTraceSnapshot &trace, const std::string &fault_node_id = {}); | |||
| FreeMonitorWidget *freeMonitorWidget() const; | |||
| std::string selectedLogicId() const; | |||
| signals: | |||
| void navigationFailed(const QString &message); | |||
| void runtimePageChanged(const QString &page_id); | |||
| void exitRequested(); | |||
| private: | |||
| 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><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="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> | |||
| </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/hmi_control_registry.h" | |||
| #include "domain/hmi_model.h" | |||
| #include "domain/project_model.h" | |||
| #include "domain/project_limits.h" | |||
| #include "domain/register_address.h" | |||
| #include "domain/register_repository.h" | |||
| #include "domain/runtime_state.h" | |||
| #include "support/test_support.h" | |||
| #include <cstdint> | |||
| #include <exception> | |||
| #include <functional> | |||
| #include <iostream> | |||
| #include <set> | |||
| #include <stdexcept> | |||
| #include <string> | |||
| namespace { | |||
| void require(bool condition, const std::string &message) | |||
| { | |||
| if (!condition) | |||
| { | |||
| throw std::runtime_error(message); | |||
| } | |||
| } | |||
| using TestSupport::require; | |||
| void testRegisterAddressBoundaries() | |||
| { | |||
| @@ -76,6 +75,166 @@ void testRegisterRepositorySeparatesAreas() | |||
| "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() | |||
| { | |||
| // 构造包含 HMI 绑定和完整梯形图网络的最小合法工程作为测试基线 | |||
| @@ -169,7 +328,7 @@ void testMultiPageAndLogicDomainRules() | |||
| disabled_draft.name = "Draft logic"; | |||
| disabled_draft.enabled = false; | |||
| 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); | |||
| require(project.validateForRunning(), | |||
| "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"); | |||
| } | |||
| 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() | |||
| { | |||
| // 触点只能绑定 M 区,数值比较只能绑定 D 区 | |||
| @@ -208,6 +468,245 @@ void testLogicNodeConfigurationBoundaries() | |||
| 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() | |||
| { | |||
| LogicNode stop; | |||
| @@ -257,6 +756,51 @@ void testLadderLogicBoundaries() | |||
| logic.rungs.push_back(rung); | |||
| 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() = | |||
| ConditionExpression::fromNode(coil); | |||
| require(!logic.validate(), "a ladder condition expression must reject coils"); | |||
| @@ -269,13 +813,14 @@ void testLadderLogicBoundaries() | |||
| require(!logic.validateForRunning(), | |||
| "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"); | |||
| empty_rung.output = coil; | |||
| 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().condition = root; | |||
| @@ -364,10 +909,17 @@ int main() | |||
| testRegisterAddressBoundaries(); | |||
| testRegisterAddressParsing(); | |||
| testRegisterRepositorySeparatesAreas(); | |||
| testHmiControlRegistryCompleteness(); | |||
| testProgressBarConfigurationBoundaries(); | |||
| testHmiAppearancePropertyBoundaries(); | |||
| testLogicNodeConfigurationBoundaries(); | |||
| testTimerAndCommentBoundaries(); | |||
| testTimerReferencesForRunning(); | |||
| testCounterAndDataInstructionBoundaries(); | |||
| testLadderLogicBoundaries(); | |||
| testModelsValidateBindingsAndIdentifiers(); | |||
| testMultiPageAndLogicDomainRules(); | |||
| testQuantityBoundaries(); | |||
| testRuntimeStateBoundaries(); | |||
| } | |||
| 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 += \ | |||
| 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 += \ | |||
| ../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_navigation_service.h" | |||
| #include "services/project_service.h" | |||
| #include "support/test_support.h" | |||
| #include <exception> | |||
| #include <iostream> | |||
| @@ -12,28 +13,8 @@ | |||
| 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() | |||
| { | |||
| @@ -50,12 +31,26 @@ void testControlEditing() | |||
| const HmiEditorResult display = service.addControl( | |||
| page_id, HmiControlType::NumericDisplay); | |||
| 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); | |||
| require(page != nullptr && page->controls.size() == 4, | |||
| require(page != nullptr && page->controls.size() == 5, | |||
| "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, | |||
| "a valid control move must succeed"); | |||
| require(!service.moveControl(page_id, button.id, {790, 460, 120, 40}).succeeded, | |||
| @@ -80,6 +75,20 @@ void testControlEditing() | |||
| "deleting a selected control must succeed"); | |||
| require(service.findControl(page_id, indicator.id) == nullptr, | |||
| "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() | |||
| @@ -147,6 +156,110 @@ void testRuntimeUsesRegisterRepository() | |||
| const HmiRuntimeReadResult numeric_value = runtime_service.readControl(numeric_display); | |||
| require(numeric_value.succeeded && numeric_value.word_value == -18, | |||
| "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() | |||
| @@ -175,6 +288,17 @@ void testPageLifecycleAndNavigation() | |||
| require(label.succeeded | |||
| && service.findControl(main_id, label.id)->isConfigured(), | |||
| "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); | |||
| require(jump.succeeded, "the editor service must expose PageJump creation"); | |||
| HmiControl jump_control = *service.findControl(main_id, jump.id); | |||
| @@ -205,6 +329,43 @@ void testPageLifecycleAndNavigation() | |||
| "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 | |||
| int main() | |||
| @@ -213,8 +374,11 @@ int main() | |||
| { | |||
| // 编辑和运行场景分别验证服务层两条独立职责 | |||
| testControlEditing(); | |||
| testHistoryAndAtomicBatchDelete(); | |||
| testAppearanceEditing(); | |||
| testRuntimeUsesRegisterRepository(); | |||
| testPageLifecycleAndNavigation(); | |||
| testPageResizeIsAtomicAndUndoable(); | |||
| } | |||
| 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 += \ | |||
| 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 += \ | |||
| ../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_limits.h" | |||
| #include "services/logic_editor_service.h" | |||
| #include "services/project_service.h" | |||
| #include "support/test_support.h" | |||
| #include <algorithm> | |||
| #include <iostream> | |||
| #include <stdexcept> | |||
| 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() | |||
| @@ -132,6 +154,488 @@ void testRangeParallelInsertion() | |||
| "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() | |||
| { | |||
| TestProjectStorage storage; | |||
| @@ -165,6 +669,147 @@ void testLogicLifecycleAndOrdering() | |||
| "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 | |||
| int main() | |||
| @@ -173,7 +818,16 @@ int main() | |||
| { | |||
| testStructuredEditingAndNormalization(); | |||
| testRangeParallelInsertion(); | |||
| testStructuredWireEditing(); | |||
| testBatchDeleteAllNodesInParallelBranch(); | |||
| testConditionColumnLimit(); | |||
| testUnconditionalOutputEditing(); | |||
| testColumnTargetedConditionInsertion(); | |||
| testWireColumnReplacement(); | |||
| testSequentialConditionInsertionConsumesFollowingWire(); | |||
| testLogicLifecycleAndOrdering(); | |||
| testEdgeTimerNodesAndRungComments(); | |||
| testHistoryAndAtomicBatchDelete(); | |||
| } | |||
| 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 += \ | |||
| 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 += \ | |||
| ../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 | |||
| CONFIG += console c++17 warn_on | |||
| CONFIG -= app_bundle | |||
| INCLUDEPATH += ../src | |||
| QT += widgets serialport testlib | |||
| SOURCES += \ | |||
| 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 += \ | |||
| ../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/offline_simulation_service.h" | |||
| #include "services/software_logic_executor.h" | |||
| #include "support/test_support.h" | |||
| #include <QCoreApplication> | |||
| @@ -14,13 +15,7 @@ | |||
| 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, | |||
| 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}; | |||
| } | |||
| 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, | |||
| 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"); | |||
| } | |||
| 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() | |||
| { | |||
| VirtualRegisterRepository repository; | |||
| @@ -155,6 +252,66 @@ void testNestedSeriesParallelExpression() | |||
| 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() | |||
| { | |||
| VirtualRegisterRepository repository; | |||
| @@ -260,11 +417,346 @@ void testMultipleLogicScanOrderAndTraceIsolation() | |||
| disabled_draft.name = "Draft"; | |||
| disabled_draft.enabled = false; | |||
| 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, | |||
| "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() | |||
| { | |||
| VirtualRegisterRepository repository; | |||
| @@ -425,9 +917,16 @@ int main(int argc, char *argv[]) | |||
| { | |||
| testSeriesParallelContactsAndSequentialVisibility(); | |||
| testNestedSeriesParallelExpression(); | |||
| testUnconditionalCoil(); | |||
| testWirePassThroughAndPowerTrace(); | |||
| testAllComparisons(); | |||
| testSetResetAndDisabledLogic(); | |||
| testMultipleLogicScanOrderAndTraceIsolation(); | |||
| testEdgeContactsAreOneScanPulsesAndAreLogicScoped(); | |||
| testTonUsesElapsedTimeAndResetsAsNonRetentive(); | |||
| testMultipleTimersAndNetworkOrder(); | |||
| testCountersUseRisingEdgesAndExternalDValues(); | |||
| testMoveAndSaturatingArithmetic(); | |||
| testSetResetPairOnSameAddress(); | |||
| testConflictingCoilsAreRejected(); | |||
| 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 | |||
| CONFIG += console c++17 warn_on | |||
| CONFIG -= app_bundle | |||
| INCLUDEPATH += ../src | |||
| QT += core | |||
| QT -= gui | |||
| SOURCES += \ | |||
| 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/software_logic_executor.cpp \ | |||
| ../src/services/offline_simulation_service.cpp | |||
| $$SERVICE_OFFLINE_SOURCES | |||
| 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/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/project_limits.h" | |||
| #include "domain/project_storage.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 "services/offline_simulation_service.h" | |||
| #include "services/plc_communication_gateway.h" | |||
| @@ -14,19 +17,7 @@ | |||
| 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 | |||
| { | |||
| @@ -61,9 +52,11 @@ public: | |||
| } | |||
| } | |||
| void setPollAddresses(const std::vector<RegisterAddress> &addresses) override | |||
| PlcCommunicationResult setPollAddresses( | |||
| const std::vector<RegisterAddress> &addresses) override | |||
| { | |||
| poll_addresses = addresses; | |||
| return {true, {}}; | |||
| } | |||
| PlcConnectionState state() const override { return connection_state; } | |||
| @@ -126,13 +119,7 @@ public: | |||
| 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() | |||
| { | |||
| @@ -311,6 +298,68 @@ void testPlcCommunicationErrorClassification() | |||
| "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 | |||
| int main() | |||
| @@ -321,6 +370,8 @@ int main() | |||
| testRuntimeRepositorySwitchingAndDisconnect(); | |||
| testRuntimeFaultRevokesOnlineReadinessAndAllowsReconnect(); | |||
| testPlcCommunicationErrorClassification(); | |||
| testPlcConfigurationBoundaries(); | |||
| testPlcPollQuantityBoundaries(); | |||
| } | |||
| 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 | |||
| QT += core serialbus serialport | |||
| CONFIG += console c++17 testcase warn_on | |||
| CONFIG -= app_bundle | |||
| INCLUDEPATH += ../src | |||
| CONFIG += testcase | |||
| SOURCES += \ | |||
| 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 += \ | |||
| ../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/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/hmi_model.h" | |||
| #include "infrastructure/json_project_storage.h" | |||
| #include "support/test_support.h" | |||
| #include "services/register_comment_service.h" | |||
| #include "services/project_service.h" | |||
| #include "domain/project_limits.h" | |||
| #include <QFile> | |||
| #include <QJsonArray> | |||
| @@ -12,16 +16,11 @@ | |||
| #include <iostream> | |||
| #include <stdexcept> | |||
| #include <string> | |||
| #include <utility> | |||
| namespace { | |||
| void require(bool condition, const std::string &message) | |||
| { | |||
| if (!condition) | |||
| { | |||
| throw std::runtime_error(message); | |||
| } | |||
| } | |||
| using TestSupport::require; | |||
| Project makeExampleProject() | |||
| { | |||
| @@ -34,6 +33,10 @@ Project makeExampleProject() | |||
| start_button.binding = RegisterAddress{RegisterArea::M, 0}; | |||
| start_button.buttonOperation = HmiButtonOperation::SetOn; | |||
| 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; | |||
| running_indicator.id = "running-indicator"; | |||
| @@ -72,6 +75,20 @@ Project makeExampleProject() | |||
| settings_jump.text = "Settings"; | |||
| 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; | |||
| page.id = "main-page"; | |||
| page.name = "Main"; | |||
| @@ -81,6 +98,8 @@ Project makeExampleProject() | |||
| page.controls.push_back(target_input); | |||
| page.controls.push_back(title_label); | |||
| page.controls.push_back(settings_jump); | |||
| page.controls.push_back(alarm_list); | |||
| page.controls.push_back(progress_bar); | |||
| HmiPage settings_page; | |||
| settings_page.id = "settings-page"; | |||
| @@ -118,6 +137,7 @@ Project makeExampleProject() | |||
| LadderRung rung; | |||
| rung.id = "rung-1"; | |||
| rung.name = "Network 1"; | |||
| rung.comment = "启动条件与温度检查"; | |||
| ConditionExpression parallel; | |||
| parallel.id = "parallel-start"; | |||
| parallel.kind = ConditionExpressionKind::Parallel; | |||
| @@ -129,23 +149,186 @@ Project makeExampleProject() | |||
| series.kind = ConditionExpressionKind::Series; | |||
| series.children = { | |||
| std::move(parallel), | |||
| ConditionExpression::fromWire("start-wire", 2), | |||
| ConditionExpression::fromNode(compare)}; | |||
| rung.condition = std::move(series); | |||
| rung.output = coil; | |||
| 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.metadata = {"example-project", "Example project", "1.0"}; | |||
| project.hmiPages.push_back(page); | |||
| project.hmiPages.push_back(settings_page); | |||
| 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); | |||
| ControlLogic draft_logic; | |||
| draft_logic.id = "draft-logic"; | |||
| draft_logic.name = "Draft logic"; | |||
| draft_logic.enabled = false; | |||
| 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); | |||
| return project; | |||
| } | |||
| @@ -188,6 +371,41 @@ void testEmptyProjectRoundTrip() | |||
| require(service.project().hmiPages.empty(), "empty project must have no HMI pages"); | |||
| require(service.project().controlLogics.empty(), | |||
| "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() | |||
| @@ -205,6 +423,17 @@ void testExampleProjectRoundTrip() | |||
| const QString invalid_operation_path = directory.filePath("invalid-operation.json"); | |||
| const QString missing_initial_path = directory.filePath("missing-initial.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, | |||
| "example project save must succeed"); | |||
| const QByteArray saved_json = readBytes(first_path); | |||
| @@ -218,8 +447,26 @@ void testExampleProjectRoundTrip() | |||
| require(saved_json.contains("\"formatVersion\": \"1.0\"") | |||
| && saved_json.contains("\"buttonOperation\": \"setOn\"") | |||
| && 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\"") | |||
| && !saved_json.contains("\"branches\""), | |||
| "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.initialHmiPageId == "main-page", | |||
| "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() | |||
| == RegisterArea::M, | |||
| "HMI M binding must survive round trip"); | |||
| @@ -244,6 +491,15 @@ void testExampleProjectRoundTrip() | |||
| "HMI button operation must survive round trip"); | |||
| require(project.hmiPages.front().controls.front().properties.at("color") == "green", | |||
| "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, | |||
| "indicator control type must survive round trip"); | |||
| 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 | |||
| == "settings-page", | |||
| "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, | |||
| "control logic count must survive round trip"); | |||
| require(!project.controlLogics.front().enabled, | |||
| "control logic enabled state must survive round trip"); | |||
| const LadderRung &rung = project.controlLogics.front().rungs.front(); | |||
| require(rung.comment == "启动条件与温度检查", | |||
| "rung comments must survive round trip"); | |||
| require(rung.condition.has_value() | |||
| && rung.condition->kind == ConditionExpressionKind::Series, | |||
| "series root expression must survive round trip"); | |||
| @@ -266,11 +544,67 @@ void testExampleProjectRoundTrip() | |||
| == ConditionExpressionKind::Parallel | |||
| && rung.condition->children.front().children.size() == 2U, | |||
| "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(), | |||
| "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>( | |||
| rung.condition->children.at(1).node->config); | |||
| rung.condition->children.at(2).node->config); | |||
| require(compare.address.index() == 2 && compare.value == 100, | |||
| "comparison configuration must survive round trip"); | |||
| require(service.saveAs(second_path.toStdString()).succeeded, | |||
| @@ -297,6 +631,80 @@ void testExampleProjectRoundTrip() | |||
| && missing_result.storageError == ProjectStorageError::MissingField, | |||
| "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(); | |||
| QJsonArray pages = missing_target.value(QStringLiteral("hmiPages")).toArray(); | |||
| QJsonObject main_page = pages.at(0).toObject(); | |||
| @@ -314,6 +722,24 @@ void testExampleProjectRoundTrip() | |||
| require(!missing_result.succeeded | |||
| && missing_result.storageError == ProjectStorageError::MissingField, | |||
| "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() | |||
| @@ -350,6 +776,178 @@ void testInvalidFiles() | |||
| "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() | |||
| { | |||
| // 保存路径和修改标记只在成功持久化后更新 | |||
| @@ -380,6 +978,51 @@ void testServiceStateAndSaveErrors() | |||
| "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 | |||
| int main() | |||
| @@ -388,8 +1031,12 @@ int main() | |||
| { | |||
| // 工程服务和 JSON 存储在同一测试进程中验证完整闭环 | |||
| testEmptyProjectRoundTrip(); | |||
| testUnconditionalOutputRoundTrip(); | |||
| testExampleProjectRoundTrip(); | |||
| testRegisterCommentService(); | |||
| testInvalidFiles(); | |||
| testQuantityFileLimits(); | |||
| testHmiPageJsonBoundaries(); | |||
| testServiceStateAndSaveErrors(); | |||
| } | |||
| 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 | |||
| INCLUDEPATH += ../src | |||
| CONFIG += testcase | |||
| SOURCES += \ | |||
| 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 += \ | |||
| ../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_repository.h" | |||
| #include "services/register_monitor_service.h" | |||
| #include "support/test_support.h" | |||
| #include <iostream> | |||
| #include <stdexcept> | |||
| @@ -9,13 +10,7 @@ | |||
| namespace { | |||
| void require(bool condition, const std::string &message) | |||
| { | |||
| if (!condition) | |||
| { | |||
| throw std::runtime_error(message); | |||
| } | |||
| } | |||
| using TestSupport::require; | |||
| void testRangeManagement() | |||
| { | |||
| @@ -76,6 +71,35 @@ void testSharedActiveRepositoryValues() | |||
| "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 | |||
| int main() | |||
| @@ -84,6 +108,7 @@ int main() | |||
| { | |||
| testRangeManagement(); | |||
| testSharedActiveRepositoryValues(); | |||
| testRegisterWrites(); | |||
| } | |||
| 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 | |||
| CONFIG += console c++17 warn_on | |||
| CONFIG -= app_bundle | |||
| INCLUDEPATH += ../src | |||
| QT -= gui | |||
| SOURCES += \ | |||
| 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 | |||
| 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/project_storage.h" | |||
| #include "domain/register_repository.h" | |||
| #include "support/test_support.h" | |||
| #include <functional> | |||
| #include <iostream> | |||
| @@ -13,19 +14,7 @@ | |||
| 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 | |||
| { | |||
| @@ -46,7 +35,12 @@ public: | |||
| 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; } | |||
| bool initialReadCompleted() const override { return initial_read; } | |||
| PlcCommunicationError lastErrorType() const override | |||
| @@ -76,6 +70,11 @@ public: | |||
| } | |||
| } | |||
| const std::vector<RegisterAddress> &pollAddresses() const | |||
| { | |||
| return poll_addresses; | |||
| } | |||
| private: | |||
| PlcConnectionState connection_state = PlcConnectionState::Disconnected; | |||
| bool initial_read = false; | |||
| @@ -84,15 +83,10 @@ private: | |||
| std::function<void(bool)> initial_read_changed; | |||
| std::function<void()> cache_updated; | |||
| 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() | |||
| { | |||
| @@ -108,6 +102,137 @@ void testModeTransitions() | |||
| service.configurePlc( | |||
| 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, | |||
| "service must start in editing mode"); | |||
| require(service.policy().allowsProjectEditing, | |||
| @@ -134,6 +259,18 @@ void testModeTransitions() | |||
| require(service.connectPlc( | |||
| {"COM9", 1, 9600, 8, 2, 1, 1000, 2, 200}).succeeded, | |||
| "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(); | |||
| require(service.initialPlcReadCompleted(), | |||
| "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 | |||
| CONFIG += console c++17 warn_on | |||
| CONFIG -= app_bundle | |||
| INCLUDEPATH += ../src | |||
| QT -= gui | |||
| SOURCES += \ | |||
| 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 += \ | |||
| ../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 | |||