| @@ -23,6 +23,7 @@ ui_*.h | |||||
| *.ilk | *.ilk | ||||
| *.exp | *.exp | ||||
| *.manifest | *.manifest | ||||
| *.autosave | |||||
| # Qt Creator local user settings | # Qt Creator local user settings | ||||
| *.pro.user | *.pro.user | ||||
| @@ -56,10 +57,15 @@ Thumbs.db | |||||
| Desktop.ini | Desktop.ini | ||||
| /scripts/* | /scripts/* | ||||
| !/scripts/build_and_run_qt.ps1 | |||||
| !/scripts/measure_cpp_code.ps1 | |||||
| !/scripts/package_qt_app.ps1 | |||||
| !/scripts/run_qt_tests.ps1 | !/scripts/run_qt_tests.ps1 | ||||
| /docs/images | /docs/images | ||||
| /docs/类图Mermaid代码.md | /docs/类图Mermaid代码.md | ||||
| /other | /other | ||||
| 设计方案书.doc | |||||
| /学习 | /学习 | ||||
| /json | /json | ||||
| /代码 | |||||
| /other_version | |||||
| /app_copy | |||||
| @@ -10,11 +10,13 @@ | |||||
| 2. 涉及模块边界、数据流、运行模式或持久化时,再读 `docs/architecture.md` | 2. 涉及模块边界、数据流、运行模式或持久化时,再读 `docs/architecture.md` | ||||
| 3. 按任务读取下方索引中的业务文档;Markdown 没有答案时再查 `docs/pdf/` | 3. 按任务读取下方索引中的业务文档;Markdown 没有答案时再查 `docs/pdf/` | ||||
| 三个入口文档各自只负责一类信息: | |||||
| 三个入口文档各自只负责一类信息,不互相复制正文: | |||||
| - `AGENTS.md`:长期有效的开发、构建和安全约束 | |||||
| - `docs/architecture.md`:稳定的分层、模块边界、核心模型和数据流 | |||||
| - `docs/ai/handoff.md`:当前进度、工作区状态、最近验证和下一步;更新时替换旧状态,不追加开发流水账 | |||||
| - `AGENTS.md`:长期有效的协作、构建、验证、安全和 Git 约束;不记录当前功能清单或开发进度 | |||||
| - `docs/architecture.md`:当前代码的稳定分层、模块边界、核心模型和数据流;不记录未提交文件和按日期排列的验证结果 | |||||
| - `docs/ai/handoff.md`:当前工作区状态、本轮改动、最近验证和下一步;更新时替换旧状态,不追加开发流水账或重复专题文档 | |||||
| 格式字段、数量边界、测试范围和硬件资料分别以对应专题文档为准。功能变化时更新所属专题文档,入口文档只保留导航、稳定约束或当前交接信息。 | |||||
| ## 关键文件索引 | ## 关键文件索引 | ||||
| @@ -23,6 +25,9 @@ | |||||
| - C++ 规范:`docs/C++代码规范.md` | - C++ 规范:`docs/C++代码规范.md` | ||||
| - 原始需求:`docs/0_综合平台编程器_修改.md` | - 原始需求:`docs/0_综合平台编程器_修改.md` | ||||
| - 数量边界最终规则:`docs/用户使用/数量边界确认方案.md` | - 数量边界最终规则:`docs/用户使用/数量边界确认方案.md` | ||||
| - 应用配置文件:`docs/用户使用/应用配置说明.md` | |||||
| - 用户运行程序导出:`docs/用户使用/用户运行程序导出说明.md` | |||||
| - 数据监控与离线初始值:`docs/用户使用/数据监控与离线初始值说明.md` | |||||
| - 工程 JSON 格式:`docs/工程格式说明.md` | - 工程 JSON 格式:`docs/工程格式说明.md` | ||||
| - 开发阶段顺序:`docs/开发顺序.md`(阶段参考,当前状态以 handoff 为准) | - 开发阶段顺序:`docs/开发顺序.md`(阶段参考,当前状态以 handoff 为准) | ||||
| - 测试约定:`docs/测试约定.md` | - 测试约定:`docs/测试约定.md` | ||||
| @@ -40,8 +45,9 @@ | |||||
| - Windows 命令优先使用 PowerShell 7;显式调用使用 `pwsh -NoLogo -NoProfile -Command`,脚本使用 `pwsh -NoLogo -NoProfile -File` | - Windows 命令优先使用 PowerShell 7;显式调用使用 `pwsh -NoLogo -NoProfile -Command`,脚本使用 `pwsh -NoLogo -NoProfile -File` | ||||
| - 复杂 PowerShell 逻辑写入 `.ps1` 后执行,避免嵌套引号;排查兼容问题时先确认 `$PSVersionTable.PSVersion` 和 `$PSVersionTable.PSEdition` | - 复杂 PowerShell 逻辑写入 `.ps1` 后执行,避免嵌套引号;排查兼容问题时先确认 `$PSVersionTable.PSVersion` 和 `$PSVersionTable.PSEdition` | ||||
| - 在独立构建目录运行 qmake 和 `mingw32-make`,不要把生成物写进 `app/` | - 在独立构建目录运行 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` | |||||
| - 项目编译运行:`pwsh -NoLogo -NoProfile -File .\scripts\build_and_run_qt.ps1` | |||||
| - 测试编译运行:`pwsh -NoLogo -NoProfile -File .\scripts\run_qt_tests.ps1 -Configuration Release` | |||||
| - Qt 项目打包:`pwsh -NoLogo -NoProfile -File .\scripts\package_qt_app.ps1` | |||||
| - 打开主界面:`& 'D:\Qt5.15.2\5.15.2\mingw81_64\bin\designer.exe' '.\app\src\ui\main_window.ui'` | - 打开主界面:`& 'D:\Qt5.15.2\5.15.2\mingw81_64\bin\designer.exe' '.\app\src\ui\main_window.ui'` | ||||
| ## 代码与架构约束 | ## 代码与架构约束 | ||||
| @@ -53,7 +59,7 @@ | |||||
| - 数量边界统一定义在 `app/src/domain/project_limits.h`,修改时同步更新数量边界文档和边界测试 | - 数量边界统一定义在 `app/src/domain/project_limits.h`,修改时同步更新数量边界文档和边界测试 | ||||
| - M/D 项目地址范围固定为 `0~4000`;Modbus 使用从 `0` 开始的原始地址 | - M/D 项目地址范围固定为 `0~4000`;Modbus 使用从 `0` 开始的原始地址 | ||||
| - 离线模式使用虚拟 M/D 并运行软件逻辑执行器 | - 离线模式使用虚拟 M/D 并运行软件逻辑执行器 | ||||
| - 真机模式使用 PLC 读回缓存并停止软件逻辑执行器;切换时先读 PLC,不复制离线值 | |||||
| - 真机模式使用 PLC 读回缓存;每轮完整轮询后在临时仓库推算本地梯形图轨迹,不把本地输出写入 PLC;切换时先读 PLC,不复制离线值 | |||||
| - 本项目不生成、编译或下载 PLC 程序,不得把本地梯形图轨迹当成 PLC 内部程序轨迹 | - 本项目不生成、编译或下载 PLC 程序,不得把本地梯形图轨迹当成 PLC 内部程序轨迹 | ||||
| - 代码注释只解释不明显的约束,末尾不要加句号 `。` | - 代码注释只解释不明显的约束,末尾不要加句号 `。` | ||||
| @@ -29,7 +29,8 @@ SOURCES += \ | |||||
| src/ui/property_panel_controller.cpp \ | src/ui/property_panel_controller.cpp \ | ||||
| src/ui/runtime_panel_controller.cpp \ | src/ui/runtime_panel_controller.cpp \ | ||||
| src/domain/register_address.cpp \ | src/domain/register_address.cpp \ | ||||
| src/domain/register_repository.cpp \ | |||||
| src/domain/register_value_type.cpp \ | |||||
| src/domain/virtual_register_repository.cpp \ | |||||
| src/domain/active_register_repository.cpp \ | src/domain/active_register_repository.cpp \ | ||||
| src/domain/register_monitor_model.cpp \ | src/domain/register_monitor_model.cpp \ | ||||
| src/domain/alarm_model.cpp \ | src/domain/alarm_model.cpp \ | ||||
| @@ -44,15 +45,20 @@ SOURCES += \ | |||||
| src/services/hmi_editor_service.cpp \ | src/services/hmi_editor_service.cpp \ | ||||
| src/services/hmi_navigation_service.cpp \ | src/services/hmi_navigation_service.cpp \ | ||||
| src/services/logic_editor_service.cpp \ | src/services/logic_editor_service.cpp \ | ||||
| src/services/logic_command_service.cpp \ | |||||
| src/services/hmi_runtime_service.cpp \ | src/services/hmi_runtime_service.cpp \ | ||||
| src/services/software_logic_executor.cpp \ | src/services/software_logic_executor.cpp \ | ||||
| src/services/offline_simulation_service.cpp \ | src/services/offline_simulation_service.cpp \ | ||||
| src/services/online_logic_monitor_service.cpp \ | |||||
| src/services/runtime_mode_service.cpp \ | src/services/runtime_mode_service.cpp \ | ||||
| src/services/plc_discovery_gateway.cpp \ | |||||
| src/services/register_monitor_service.cpp \ | src/services/register_monitor_service.cpp \ | ||||
| src/services/register_comment_service.cpp \ | src/services/register_comment_service.cpp \ | ||||
| src/infrastructure/plc_register_repository.cpp \ | src/infrastructure/plc_register_repository.cpp \ | ||||
| src/infrastructure/plc_communication_error_classifier.cpp \ | src/infrastructure/plc_communication_error_classifier.cpp \ | ||||
| src/infrastructure/plc_communication_service.cpp \ | src/infrastructure/plc_communication_service.cpp \ | ||||
| src/infrastructure/plc_discovery_service.cpp \ | |||||
| src/infrastructure/application_settings_loader.cpp \ | |||||
| src/infrastructure/json_project_storage.cpp \ | src/infrastructure/json_project_storage.cpp \ | ||||
| src/ui/hmi_editor_widget.cpp \ | src/ui/hmi_editor_widget.cpp \ | ||||
| src/ui/logic_editor_widget.cpp | src/ui/logic_editor_widget.cpp | ||||
| @@ -71,7 +77,9 @@ HEADERS += \ | |||||
| src/ui/property_panel_controller.h \ | src/ui/property_panel_controller.h \ | ||||
| src/ui/runtime_panel_controller.h \ | src/ui/runtime_panel_controller.h \ | ||||
| src/domain/register_address.h \ | src/domain/register_address.h \ | ||||
| src/domain/register_value_type.h \ | |||||
| src/domain/register_repository.h \ | src/domain/register_repository.h \ | ||||
| src/domain/virtual_register_repository.h \ | |||||
| src/domain/active_register_repository.h \ | src/domain/active_register_repository.h \ | ||||
| src/domain/register_monitor_model.h \ | src/domain/register_monitor_model.h \ | ||||
| src/domain/alarm_model.h \ | src/domain/alarm_model.h \ | ||||
| @@ -89,16 +97,22 @@ HEADERS += \ | |||||
| src/services/hmi_editor_service.h \ | src/services/hmi_editor_service.h \ | ||||
| src/services/hmi_navigation_service.h \ | src/services/hmi_navigation_service.h \ | ||||
| src/services/logic_editor_service.h \ | src/services/logic_editor_service.h \ | ||||
| src/services/logic_command_service.h \ | |||||
| src/services/hmi_runtime_service.h \ | src/services/hmi_runtime_service.h \ | ||||
| src/services/software_logic_executor.h \ | src/services/software_logic_executor.h \ | ||||
| src/services/offline_simulation_service.h \ | src/services/offline_simulation_service.h \ | ||||
| src/services/online_logic_monitor_service.h \ | |||||
| src/services/runtime_mode_service.h \ | src/services/runtime_mode_service.h \ | ||||
| src/services/plc_discovery_gateway.h \ | |||||
| src/services/register_monitor_service.h \ | src/services/register_monitor_service.h \ | ||||
| src/services/register_comment_service.h \ | src/services/register_comment_service.h \ | ||||
| src/services/plc_communication_gateway.h \ | src/services/plc_communication_gateway.h \ | ||||
| src/services/application_settings.h \ | |||||
| src/infrastructure/plc_register_repository.h \ | src/infrastructure/plc_register_repository.h \ | ||||
| src/infrastructure/plc_communication_error_classifier.h \ | src/infrastructure/plc_communication_error_classifier.h \ | ||||
| src/infrastructure/plc_communication_service.h \ | src/infrastructure/plc_communication_service.h \ | ||||
| src/infrastructure/plc_discovery_service.h \ | |||||
| src/infrastructure/application_settings_loader.h \ | |||||
| src/infrastructure/json_project_storage.h \ | src/infrastructure/json_project_storage.h \ | ||||
| src/ui/hmi_editor_widget.h \ | src/ui/hmi_editor_widget.h \ | ||||
| src/ui/logic_editor_widget.h | src/ui/logic_editor_widget.h | ||||
| @@ -112,3 +126,8 @@ FORMS += \ | |||||
| src/ui/free_monitor_widget.ui \ | src/ui/free_monitor_widget.ui \ | ||||
| src/ui/runtime_monitor_window.ui \ | src/ui/runtime_monitor_window.ui \ | ||||
| src/ui/runtime_monitor_widget.ui | src/ui/runtime_monitor_widget.ui | ||||
| # 专用用户运行版打包时由脚本传入生成的工程资源;普通编程器构建不包含工程 | |||||
| !isEmpty(RUNTIME_PROJECT_RESOURCE) { | |||||
| RESOURCES += $$RUNTIME_PROJECT_RESOURCE | |||||
| } | |||||
| @@ -7,6 +7,7 @@ ActiveRegisterRepository::ActiveRegisterRepository(RegisterRepository &initial_r | |||||
| void ActiveRegisterRepository::use(RegisterRepository &repository) | void ActiveRegisterRepository::use(RegisterRepository &repository) | ||||
| { | { | ||||
| // 只替换转发目标,不复制寄存器值;切换模式必须使用各自数据源 | |||||
| repository_ = &repository; | repository_ = &repository; | ||||
| } | } | ||||
| @@ -31,3 +32,16 @@ RegisterWriteResult ActiveRegisterRepository::writeWord( | |||||
| { | { | ||||
| return repository_->writeWord(address, value); | return repository_->writeWord(address, value); | ||||
| } | } | ||||
| WordPairReadResult ActiveRegisterRepository::readWordPair( | |||||
| const RegisterAddress &address) const | |||||
| { | |||||
| return repository_->readWordPair(address); | |||||
| } | |||||
| RegisterWriteResult ActiveRegisterRepository::writeWordPair( | |||||
| const RegisterAddress &address, | |||||
| const std::array<std::int16_t, 2> &values) | |||||
| { | |||||
| return repository_->writeWordPair(address, values); | |||||
| } | |||||
| @@ -2,17 +2,23 @@ | |||||
| #include "register_repository.h" | #include "register_repository.h" | ||||
| // 根据当前运行模式把寄存器访问转发给虚拟仓库或 PLC 缓存仓库 | |||||
| class ActiveRegisterRepository final : public RegisterRepository | class ActiveRegisterRepository final : public RegisterRepository | ||||
| { | { | ||||
| public: | public: | ||||
| explicit ActiveRegisterRepository(RegisterRepository &initial_repository); | explicit ActiveRegisterRepository(RegisterRepository &initial_repository); | ||||
| // 切换活动仓库;运行模式服务负责保证切换时机和生命周期 | |||||
| void use(RegisterRepository &repository); | void use(RegisterRepository &repository); | ||||
| BitReadResult readBit(const RegisterAddress &address) const override; | BitReadResult readBit(const RegisterAddress &address) const override; | ||||
| RegisterWriteResult writeBit(const RegisterAddress &address, bool value) override; | RegisterWriteResult writeBit(const RegisterAddress &address, bool value) override; | ||||
| WordReadResult readWord(const RegisterAddress &address) const override; | WordReadResult readWord(const RegisterAddress &address) const override; | ||||
| RegisterWriteResult writeWord( | RegisterWriteResult writeWord( | ||||
| const RegisterAddress &address, std::int16_t value) override; | const RegisterAddress &address, std::int16_t value) override; | ||||
| WordPairReadResult readWordPair(const RegisterAddress &address) const override; | |||||
| RegisterWriteResult writeWordPair( | |||||
| const RegisterAddress &address, | |||||
| const std::array<std::int16_t, 2> &values) override; | |||||
| private: | private: | ||||
| RegisterRepository *repository_ = nullptr; | RegisterRepository *repository_ = nullptr; | ||||
| @@ -33,7 +33,7 @@ bool AlarmDefinition::validate(std::string *error) const | |||||
| } | } | ||||
| if (message.size() > ProjectLimits::kMaximumTextBytes) | if (message.size() > ProjectLimits::kMaximumTextBytes) | ||||
| { | { | ||||
| setError(error, "报警文本不能超过 4096 个 UTF-8 字节"); | |||||
| setError(error, "报警文本不能超过 256 个 UTF-8 字节"); | |||||
| return false; | return false; | ||||
| } | } | ||||
| if (!address.isValid()) | if (!address.isValid()) | ||||
| @@ -5,20 +5,23 @@ | |||||
| #include <cstdint> | #include <cstdint> | ||||
| #include <string> | #include <string> | ||||
| // 报警触发条件:M 位为 1,或 D 字高于/低于阈值 | |||||
| enum class AlarmCondition | enum class AlarmCondition | ||||
| { | { | ||||
| MOn, | |||||
| DHigh, | |||||
| DLow | |||||
| MOn, // M 位为 1 时触发 | |||||
| DHigh, // D 寄存器值高于阈值时触发 | |||||
| DLow // D 寄存器值低于阈值时触发 | |||||
| }; | }; | ||||
| // 可保存的报警定义;运行时 AlarmService 根据此定义生成报警记录 | |||||
| struct AlarmDefinition | struct AlarmDefinition | ||||
| { | { | ||||
| std::string id; | |||||
| RegisterAddress address{RegisterArea::M, 0}; | |||||
| AlarmCondition condition = AlarmCondition::MOn; | |||||
| std::int16_t threshold = 0; | |||||
| std::string message; | |||||
| std::string id; // 报警定义的唯一 ID | |||||
| RegisterAddress address{RegisterArea::M, 0}; // 报警监控的 M 或 D 地址 | |||||
| AlarmCondition condition = AlarmCondition::MOn; // 报警触发条件 | |||||
| std::int16_t threshold = 0; // D 地址比较时使用的阈值 | |||||
| std::string message; // 报警触发后显示的提示文字 | |||||
| // 校验标识、地址区域、消息长度和条件之间的组合是否有效 | |||||
| bool validate(std::string *error = nullptr) const; | bool validate(std::string *error = nullptr) const; | ||||
| }; | }; | ||||
| @@ -3,7 +3,6 @@ | |||||
| #include "project_limits.h" | #include "project_limits.h" | ||||
| #include <algorithm> | #include <algorithm> | ||||
| #include <map> | |||||
| #include <type_traits> | #include <type_traits> | ||||
| #include <utility> | #include <utility> | ||||
| @@ -52,22 +51,6 @@ bool validateWordAddress( | |||||
| return true; | 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) | bool validateConfig(const EdgeContactNodeConfig &config, std::string *error) | ||||
| { | { | ||||
| if (!config.address.isValid() || config.address.area() != RegisterArea::M) | if (!config.address.isValid() || config.address.area() != RegisterArea::M) | ||||
| @@ -120,70 +103,6 @@ bool validateConfig(const CompareNodeConfig &config, std::string *error) | |||||
| return true; | return true; | ||||
| } | } | ||||
| bool validateConfig(const TimerContactNodeConfig &config, std::string *error) | |||||
| { | |||||
| if (!config.address.isValid()) | |||||
| { | |||||
| setError(error, "定时器触点必须使用有效的 T 地址"); | |||||
| return false; | |||||
| } | |||||
| if (config.mode != ContactMode::NormallyOpen | |||||
| && config.mode != ContactMode::NormallyClosed) | |||||
| { | |||||
| setError(error, "定时器触点使用了不支持的模式"); | |||||
| return false; | |||||
| } | |||||
| return true; | |||||
| } | |||||
| bool validateConfig(const TonNodeConfig &config, std::string *error) | |||||
| { | |||||
| if (!config.address.isValid()) | |||||
| { | |||||
| setError(error, "TON 必须使用有效的 T 地址"); | |||||
| return false; | |||||
| } | |||||
| if (config.presetMs < TonNodeConfig::kMinimumPresetMs | |||||
| || config.presetMs > TonNodeConfig::kMaximumPresetMs) | |||||
| { | |||||
| setError(error, "TON 预设时间必须在 1~86400000 ms 范围内"); | |||||
| return false; | |||||
| } | |||||
| return true; | |||||
| } | |||||
| bool validateConfig(const CounterNodeConfig &config, std::string *error) | |||||
| { | |||||
| if (!config.address.isValid()) | |||||
| { | |||||
| setError(error, "计数器指令必须使用有效的 C 地址"); | |||||
| return false; | |||||
| } | |||||
| if (config.mode != CounterMode::Up && config.mode != CounterMode::Down) | |||||
| { | |||||
| setError(error, "计数器指令使用了不支持的方向"); | |||||
| return false; | |||||
| } | |||||
| if (!validateWordAddress(config.currentValueAddress, "计数当前值", error) | |||||
| || !config.preset.validate(error)) | |||||
| { | |||||
| return false; | |||||
| } | |||||
| if (config.preset.kind == WordOperandKind::Constant | |||||
| && config.preset.constant < 0) | |||||
| { | |||||
| setError(error, "计数器常量预设值必须在 0~32767 范围内"); | |||||
| return false; | |||||
| } | |||||
| if (!config.resetAddress.isValid() | |||||
| || config.resetAddress.area() != RegisterArea::M) | |||||
| { | |||||
| setError(error, "计数器复位输入必须使用有效的 M 区地址"); | |||||
| return false; | |||||
| } | |||||
| return true; | |||||
| } | |||||
| bool validateConfig(const MoveNodeConfig &config, std::string *error) | bool validateConfig(const MoveNodeConfig &config, std::string *error) | ||||
| { | { | ||||
| if (!config.source.validate(error)) | if (!config.source.validate(error)) | ||||
| @@ -245,7 +164,8 @@ void normalizeExpression(ConditionExpression *expression) | |||||
| { | { | ||||
| if (expression == nullptr | if (expression == nullptr | ||||
| || expression->kind == ConditionExpressionKind::Node | || expression->kind == ConditionExpressionKind::Node | ||||
| || expression->kind == ConditionExpressionKind::Wire) | |||||
| || expression->kind == ConditionExpressionKind::Wire | |||||
| || expression->kind == ConditionExpressionKind::Gap) | |||||
| { | { | ||||
| return; | return; | ||||
| } | } | ||||
| @@ -302,13 +222,13 @@ bool validateConditionExpression( | |||||
| { | { | ||||
| if (depth > ProjectLimits::kMaximumExpressionDepth) | if (depth > ProjectLimits::kMaximumExpressionDepth) | ||||
| { | { | ||||
| setError(error, "条件表达式最多嵌套 20 层"); | |||||
| setError(error, "条件表达式最多嵌套 12 层"); | |||||
| return false; | return false; | ||||
| } | } | ||||
| ++*node_count; | ++*node_count; | ||||
| if (*node_count > ProjectLimits::kMaximumExpressionNodesPerRung) | if (*node_count > ProjectLimits::kMaximumExpressionNodesPerRung) | ||||
| { | { | ||||
| setError(error, "单个网络最多包含 4096 个条件表达式节点和叶子"); | |||||
| setError(error, "单个网络最多包含 1024 个条件表达式节点和叶子"); | |||||
| return false; | return false; | ||||
| } | } | ||||
| if (expression.id.empty()) | if (expression.id.empty()) | ||||
| @@ -324,6 +244,7 @@ bool validateConditionExpression( | |||||
| if (expression.kind == ConditionExpressionKind::Node) | if (expression.kind == ConditionExpressionKind::Node) | ||||
| { | { | ||||
| if (!expression.node.has_value() || expression.wire.has_value() | if (!expression.node.has_value() || expression.wire.has_value() | ||||
| || expression.gap.has_value() | |||||
| || !expression.children.empty() || !expression.node->isCondition()) | || !expression.children.empty() || !expression.node->isCondition()) | ||||
| { | { | ||||
| setError(error, "条件叶节点必须包含一个条件节点且不能包含子表达式"); | setError(error, "条件叶节点必须包含一个条件节点且不能包含子表达式"); | ||||
| @@ -336,6 +257,7 @@ bool validateConditionExpression( | |||||
| if (expression.kind == ConditionExpressionKind::Wire) | if (expression.kind == ConditionExpressionKind::Wire) | ||||
| { | { | ||||
| if (expression.node.has_value() || !expression.wire.has_value() | if (expression.node.has_value() || !expression.wire.has_value() | ||||
| || expression.gap.has_value() | |||||
| || !expression.children.empty()) | || !expression.children.empty()) | ||||
| { | { | ||||
| setError(error, "横线叶节点必须包含横线配置且不能包含逻辑节点或子表达式"); | setError(error, "横线叶节点必须包含横线配置且不能包含逻辑节点或子表达式"); | ||||
| @@ -349,6 +271,22 @@ bool validateConditionExpression( | |||||
| *rows = 1; | *rows = 1; | ||||
| return true; | return true; | ||||
| } | } | ||||
| if (expression.kind == ConditionExpressionKind::Gap) | |||||
| { | |||||
| if (expression.node.has_value() || expression.wire.has_value() | |||||
| || !expression.gap.has_value() || !expression.children.empty()) | |||||
| { | |||||
| setError(error, "断路叶节点必须包含断路配置且不能包含逻辑节点或子表达式"); | |||||
| return false; | |||||
| } | |||||
| if (!expression.gap->validate(error)) | |||||
| { | |||||
| return false; | |||||
| } | |||||
| *columns = expression.gap->columnSpan; | |||||
| *rows = 1; | |||||
| return true; | |||||
| } | |||||
| if (expression.kind != ConditionExpressionKind::Series | if (expression.kind != ConditionExpressionKind::Series | ||||
| && expression.kind != ConditionExpressionKind::Parallel) | && expression.kind != ConditionExpressionKind::Parallel) | ||||
| { | { | ||||
| @@ -356,6 +294,7 @@ bool validateConditionExpression( | |||||
| return false; | return false; | ||||
| } | } | ||||
| if (expression.node.has_value() || expression.wire.has_value() | if (expression.node.has_value() || expression.wire.has_value() | ||||
| || expression.gap.has_value() | |||||
| || expression.children.size() < 2U) | || expression.children.size() < 2U) | ||||
| { | { | ||||
| setError(error, "串联和并联表达式至少需要两个子表达式"); | setError(error, "串联和并联表达式至少需要两个子表达式"); | ||||
| @@ -363,12 +302,13 @@ bool validateConditionExpression( | |||||
| } | } | ||||
| if (expression.children.size() > ProjectLimits::kMaximumExpressionChildren) | if (expression.children.size() > ProjectLimits::kMaximumExpressionChildren) | ||||
| { | { | ||||
| setError(error, "单个串联或并联容器最多包含 256 个子表达式"); | |||||
| setError(error, "单个串联或并联容器最多包含 64 个子表达式"); | |||||
| return false; | return false; | ||||
| } | } | ||||
| int total_columns = expression.kind == ConditionExpressionKind::Series ? 0 : 1; | int total_columns = expression.kind == ConditionExpressionKind::Series ? 0 : 1; | ||||
| int total_rows = expression.kind == ConditionExpressionKind::Parallel ? 0 : 1; | int total_rows = expression.kind == ConditionExpressionKind::Parallel ? 0 : 1; | ||||
| int parallel_columns = -1; | |||||
| for (const ConditionExpression &child : expression.children) | for (const ConditionExpression &child : expression.children) | ||||
| { | { | ||||
| if (child.kind == expression.kind) | if (child.kind == expression.kind) | ||||
| @@ -397,10 +337,16 @@ bool validateConditionExpression( | |||||
| } | } | ||||
| else | else | ||||
| { | { | ||||
| if (parallel_columns >= 0 && child_columns != parallel_columns) | |||||
| { | |||||
| setError(error, "并联各支路必须使用显式横线或断路补齐到相同列宽"); | |||||
| return false; | |||||
| } | |||||
| parallel_columns = child_columns; | |||||
| total_columns = std::max(total_columns, child_columns); | total_columns = std::max(total_columns, child_columns); | ||||
| if (total_rows > ProjectLimits::kMaximumLogicRows - child_rows) | if (total_rows > ProjectLimits::kMaximumLogicRows - child_rows) | ||||
| { | { | ||||
| setError(error, "单个网络的逻辑总行数最多为 256 行"); | |||||
| setError(error, "单个网络的逻辑总行数最多为 64 行"); | |||||
| return false; | return false; | ||||
| } | } | ||||
| total_rows += child_rows; | total_rows += child_rows; | ||||
| @@ -409,7 +355,7 @@ bool validateConditionExpression( | |||||
| if (total_columns > ProjectLimits::kMaximumConditionColumns | if (total_columns > ProjectLimits::kMaximumConditionColumns | ||||
| || total_rows > ProjectLimits::kMaximumLogicRows) | || total_rows > ProjectLimits::kMaximumLogicRows) | ||||
| { | { | ||||
| setError(error, "单个网络最多使用 10 列条件、1 列输出和 256 行逻辑网格"); | |||||
| setError(error, "单个网络最多使用 10 列条件、1 列输出和 64 行逻辑网格"); | |||||
| return false; | return false; | ||||
| } | } | ||||
| *columns = total_columns; | *columns = total_columns; | ||||
| @@ -417,13 +363,32 @@ bool validateConditionExpression( | |||||
| return true; | return true; | ||||
| } | } | ||||
| } // namespace | |||||
| TimerAddress::TimerAddress(int index) | |||||
| : index_(index) | |||||
| int conditionColumnCount(const ConditionExpression &expression) | |||||
| { | { | ||||
| if (expression.kind == ConditionExpressionKind::Node) | |||||
| { | |||||
| return 1; | |||||
| } | |||||
| if (expression.kind == ConditionExpressionKind::Wire) | |||||
| { | |||||
| return expression.wire->columnSpan; | |||||
| } | |||||
| if (expression.kind == ConditionExpressionKind::Gap) | |||||
| { | |||||
| return expression.gap->columnSpan; | |||||
| } | |||||
| int columns = expression.kind == ConditionExpressionKind::Series ? 0 : 1; | |||||
| for (const ConditionExpression &child : expression.children) | |||||
| { | |||||
| const int child_columns = conditionColumnCount(child); | |||||
| columns = expression.kind == ConditionExpressionKind::Series | |||||
| ? columns + child_columns : std::max(columns, child_columns); | |||||
| } | |||||
| return columns; | |||||
| } | } | ||||
| } // namespace | |||||
| bool WordOperand::validate(std::string *error) const | bool WordOperand::validate(std::string *error) const | ||||
| { | { | ||||
| if (kind == WordOperandKind::Constant) | if (kind == WordOperandKind::Constant) | ||||
| @@ -443,61 +408,6 @@ bool WordOperand::validate(std::string *error) const | |||||
| return true; | 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( | std::optional<RegisterAddress> registerAddressForLogicNode( | ||||
| const LogicNodeConfig &config) | const LogicNodeConfig &config) | ||||
| { | { | ||||
| @@ -512,10 +422,6 @@ std::optional<RegisterAddress> registerAddressForLogicNode( | |||||
| { | { | ||||
| return value.address; | return value.address; | ||||
| } | } | ||||
| else if constexpr (std::is_same_v<Config, CounterNodeConfig>) | |||||
| { | |||||
| return value.currentValueAddress; | |||||
| } | |||||
| else if constexpr (std::is_same_v<Config, MoveNodeConfig> | else if constexpr (std::is_same_v<Config, MoveNodeConfig> | ||||
| || std::is_same_v<Config, ArithmeticNodeConfig>) | || std::is_same_v<Config, ArithmeticNodeConfig>) | ||||
| { | { | ||||
| @@ -545,15 +451,6 @@ void collectRegisterAddressesForLogicNode( | |||||
| { | { | ||||
| appendValidAddress(addresses, value.address); | 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>) | else if constexpr (std::is_same_v<Config, MoveNodeConfig>) | ||||
| { | { | ||||
| appendValidAddress(addresses, value.destination); | appendValidAddress(addresses, value.destination); | ||||
| @@ -574,10 +471,6 @@ void collectRegisterAddressesForLogicNode( | |||||
| appendValidAddress(addresses, value.right.address); | appendValidAddress(addresses, value.right.address); | ||||
| } | } | ||||
| } | } | ||||
| else if constexpr (std::is_same_v<Config, CounterContactNodeConfig>) | |||||
| { | |||||
| return; | |||||
| } | |||||
| }, | }, | ||||
| config); | config); | ||||
| } | } | ||||
| @@ -604,20 +497,18 @@ bool LogicNode::isConfigured() const | |||||
| return configured; | return configured; | ||||
| } | } | ||||
| // 判断当前节点是不是条件触点类节点 | |||||
| bool LogicNode::isCondition() const | bool LogicNode::isCondition() const | ||||
| { | { | ||||
| return std::holds_alternative<ContactNodeConfig>(config) | return std::holds_alternative<ContactNodeConfig>(config) | ||||
| || std::holds_alternative<EdgeContactNodeConfig>(config) | || std::holds_alternative<EdgeContactNodeConfig>(config) | ||||
| || std::holds_alternative<TimerContactNodeConfig>(config) | |||||
| || std::holds_alternative<CounterContactNodeConfig>(config) | |||||
| || std::holds_alternative<CompareNodeConfig>(config); | || std::holds_alternative<CompareNodeConfig>(config); | ||||
| } | } | ||||
| // 判断当前节点是不是输出执行类节点 | |||||
| bool LogicNode::isOutput() const | bool LogicNode::isOutput() const | ||||
| { | { | ||||
| return std::holds_alternative<CoilNodeConfig>(config) | return std::holds_alternative<CoilNodeConfig>(config) | ||||
| || std::holds_alternative<TonNodeConfig>(config) | |||||
| || std::holds_alternative<CounterNodeConfig>(config) | |||||
| || std::holds_alternative<MoveNodeConfig>(config) | || std::holds_alternative<MoveNodeConfig>(config) | ||||
| || std::holds_alternative<ArithmeticNodeConfig>(config); | || std::holds_alternative<ArithmeticNodeConfig>(config); | ||||
| } | } | ||||
| @@ -641,6 +532,16 @@ bool WireSegment::validate(std::string *error) const | |||||
| return true; | return true; | ||||
| } | } | ||||
| bool GapSegment::validate(std::string *error) const | |||||
| { | |||||
| if (columnSpan < kMinimumColumnSpan || columnSpan > kMaximumColumnSpan) | |||||
| { | |||||
| setError(error, "断路跨度必须在 1~10 列范围内"); | |||||
| return false; | |||||
| } | |||||
| return true; | |||||
| } | |||||
| ConditionExpression ConditionExpression::fromWire( | ConditionExpression ConditionExpression::fromWire( | ||||
| std::string expression_id, int column_span) | std::string expression_id, int column_span) | ||||
| { | { | ||||
| @@ -651,6 +552,16 @@ ConditionExpression ConditionExpression::fromWire( | |||||
| return expression; | return expression; | ||||
| } | } | ||||
| ConditionExpression ConditionExpression::fromGap( | |||||
| std::string expression_id, int column_span) | |||||
| { | |||||
| ConditionExpression expression; | |||||
| expression.id = std::move(expression_id); | |||||
| expression.kind = ConditionExpressionKind::Gap; | |||||
| expression.gap = GapSegment{column_span}; | |||||
| return expression; | |||||
| } | |||||
| bool ConditionExpression::validate(std::string *error) const | bool ConditionExpression::validate(std::string *error) const | ||||
| { | { | ||||
| std::size_t node_count = 0U; | std::size_t node_count = 0U; | ||||
| @@ -679,6 +590,11 @@ bool ConditionExpression::validateForRunning(std::string *error) const | |||||
| { | { | ||||
| return true; | return true; | ||||
| } | } | ||||
| if (kind == ConditionExpressionKind::Gap) | |||||
| { | |||||
| setError(error, "梯形图条件区存在未连接的空白网格"); | |||||
| return false; | |||||
| } | |||||
| for (const ConditionExpression &child : children) | for (const ConditionExpression &child : children) | ||||
| { | { | ||||
| if (!child.validateForRunning(error)) | if (!child.validateForRunning(error)) | ||||
| @@ -691,6 +607,7 @@ bool ConditionExpression::validateForRunning(std::string *error) const | |||||
| void normalizeConditionExpression(std::optional<ConditionExpression> *expression) | void normalizeConditionExpression(std::optional<ConditionExpression> *expression) | ||||
| { | { | ||||
| // 删除节点后折叠单子项容器,并合并相邻同类容器,保持 JSON 结构稳定 | |||||
| if (expression == nullptr || !expression->has_value()) | if (expression == nullptr || !expression->has_value()) | ||||
| { | { | ||||
| return; | return; | ||||
| @@ -698,6 +615,7 @@ void normalizeConditionExpression(std::optional<ConditionExpression> *expression | |||||
| normalizeExpression(&expression->value()); | normalizeExpression(&expression->value()); | ||||
| if (expression->value().kind != ConditionExpressionKind::Node | if (expression->value().kind != ConditionExpressionKind::Node | ||||
| && expression->value().kind != ConditionExpressionKind::Wire | && expression->value().kind != ConditionExpressionKind::Wire | ||||
| && expression->value().kind != ConditionExpressionKind::Gap | |||||
| && expression->value().children.empty()) | && expression->value().children.empty()) | ||||
| { | { | ||||
| expression->reset(); | expression->reset(); | ||||
| @@ -711,6 +629,10 @@ const LogicNode *findConditionNode( | |||||
| { | { | ||||
| return nullptr; | return nullptr; | ||||
| } | } | ||||
| if (expression.kind == ConditionExpressionKind::Gap) | |||||
| { | |||||
| return nullptr; | |||||
| } | |||||
| if (expression.kind == ConditionExpressionKind::Node) | if (expression.kind == ConditionExpressionKind::Node) | ||||
| { | { | ||||
| return expression.node->id == node_id ? &*expression.node : nullptr; | return expression.node->id == node_id ? &*expression.node : nullptr; | ||||
| @@ -773,6 +695,10 @@ void collectConditionNodes( | |||||
| { | { | ||||
| return; | return; | ||||
| } | } | ||||
| if (expression.kind == ConditionExpressionKind::Gap) | |||||
| { | |||||
| return; | |||||
| } | |||||
| for (const ConditionExpression &child : expression.children) | for (const ConditionExpression &child : expression.children) | ||||
| { | { | ||||
| collectConditionNodes(child, nodes); | collectConditionNodes(child, nodes); | ||||
| @@ -839,7 +765,7 @@ bool LadderRung::validateStructure(std::string *error) const | |||||
| } | } | ||||
| if (name.size() > ProjectLimits::kMaximumTextBytes) | if (name.size() > ProjectLimits::kMaximumTextBytes) | ||||
| { | { | ||||
| setError(error, "梯形图网络名称不能超过 4096 个 UTF-8 字节"); | |||||
| setError(error, "梯形图网络名称不能超过 256 个 UTF-8 字节"); | |||||
| return false; | return false; | ||||
| } | } | ||||
| if (containsLineBreak(comment)) | if (containsLineBreak(comment)) | ||||
| @@ -866,6 +792,14 @@ bool LadderRung::validateStructure(std::string *error) const | |||||
| node_ids.push_back(node->id); | node_ids.push_back(node->id); | ||||
| } | } | ||||
| } | } | ||||
| if (output.has_value() | |||||
| && (!condition.has_value() | |||||
| || conditionColumnCount(*condition) | |||||
| != ProjectLimits::kMaximumConditionColumns)) | |||||
| { | |||||
| setError(error, "带输出的梯形图网络必须用条件、横线或断路显式占满前 10 列"); | |||||
| return false; | |||||
| } | |||||
| if (output.has_value()) | if (output.has_value()) | ||||
| { | { | ||||
| if (!output->validate(error) || !output->isOutput()) | if (!output->validate(error) || !output->isOutput()) | ||||
| @@ -886,7 +820,13 @@ bool LadderRung::validateStructure(std::string *error) const | |||||
| bool ControlLogic::validate(std::string *error) const | bool ControlLogic::validate(std::string *error) const | ||||
| { | { | ||||
| if (!validateStructure(error)) | |||||
| return validate(defaultProjectLimitSettings(), error); | |||||
| } | |||||
| bool ControlLogic::validate( | |||||
| const ProjectLimitSettings &limits, std::string *error) const | |||||
| { | |||||
| if (!validateStructure(limits, error)) | |||||
| { | { | ||||
| return false; | return false; | ||||
| } | } | ||||
| @@ -901,6 +841,12 @@ bool ControlLogic::validate(std::string *error) const | |||||
| } | } | ||||
| bool ControlLogic::validateStructure(std::string *error) const | bool ControlLogic::validateStructure(std::string *error) const | ||||
| { | |||||
| return validateStructure(defaultProjectLimitSettings(), error); | |||||
| } | |||||
| bool ControlLogic::validateStructure( | |||||
| const ProjectLimitSettings &limits, std::string *error) const | |||||
| { | { | ||||
| if (id.empty() || name.empty()) | if (id.empty() || name.empty()) | ||||
| { | { | ||||
| @@ -914,12 +860,16 @@ bool ControlLogic::validateStructure(std::string *error) const | |||||
| } | } | ||||
| if (name.size() > ProjectLimits::kMaximumTextBytes) | if (name.size() > ProjectLimits::kMaximumTextBytes) | ||||
| { | { | ||||
| setError(error, "控制逻辑名称不能超过 4096 个 UTF-8 字节"); | |||||
| setError(error, "控制逻辑名称不能超过 256 个 UTF-8 字节"); | |||||
| return false; | return false; | ||||
| } | } | ||||
| if (rungs.size() > ProjectLimits::kMaximumRungsPerLogic) | |||||
| if (rungs.size() > limits.maximumRungsPerLogic) | |||||
| { | { | ||||
| setError(error, "单组控制逻辑最多包含 1024 个网络"); | |||||
| setError( | |||||
| error, | |||||
| "控制逻辑“" + name + "”的网络数量为 " | |||||
| + std::to_string(rungs.size()) + ",当前配置上限为 " | |||||
| + std::to_string(limits.maximumRungsPerLogic)); | |||||
| return false; | return false; | ||||
| } | } | ||||
| if (hasDuplicateId(rungs)) | if (hasDuplicateId(rungs)) | ||||
| @@ -968,7 +918,13 @@ bool ControlLogic::validateStructure(std::string *error) const | |||||
| bool ControlLogic::validateForRunning(std::string *error) const | bool ControlLogic::validateForRunning(std::string *error) const | ||||
| { | { | ||||
| if (!validateStructure(error)) | |||||
| return validateForRunning(defaultProjectLimitSettings(), error); | |||||
| } | |||||
| bool ControlLogic::validateForRunning( | |||||
| const ProjectLimitSettings &limits, std::string *error) const | |||||
| { | |||||
| if (!validateStructure(limits, error)) | |||||
| { | { | ||||
| return false; | return false; | ||||
| } | } | ||||
| @@ -981,148 +937,3 @@ bool ControlLogic::validateForRunning(std::string *error) const | |||||
| } | } | ||||
| return true; | return true; | ||||
| } | } | ||||
| bool validateTimerReferencesForRunning( | |||||
| const std::vector<ControlLogic> &logics, | |||||
| std::string *error) | |||||
| { | |||||
| std::map<int, std::string> timer_outputs; | |||||
| for (const ControlLogic &logic : logics) | |||||
| { | |||||
| if (!logic.enabled) | |||||
| { | |||||
| continue; | |||||
| } | |||||
| for (const LadderRung &rung : logic.rungs) | |||||
| { | |||||
| if (!rung.output.has_value()) | |||||
| { | |||||
| continue; | |||||
| } | |||||
| const auto *ton = std::get_if<TonNodeConfig>(&rung.output->config); | |||||
| if (ton == nullptr) | |||||
| { | |||||
| continue; | |||||
| } | |||||
| const auto inserted = timer_outputs.emplace( | |||||
| ton->address.index(), rung.output->id); | |||||
| if (!inserted.second) | |||||
| { | |||||
| setError( | |||||
| error, | |||||
| "定时器 " + ton->address.toString() | |||||
| + " 只能由一个已启用 TON 指令驱动"); | |||||
| return false; | |||||
| } | |||||
| } | |||||
| } | |||||
| for (const ControlLogic &logic : logics) | |||||
| { | |||||
| if (!logic.enabled) | |||||
| { | |||||
| continue; | |||||
| } | |||||
| for (const LadderRung &rung : logic.rungs) | |||||
| { | |||||
| if (!rung.condition.has_value()) | |||||
| { | |||||
| continue; | |||||
| } | |||||
| std::vector<const LogicNode *> nodes; | |||||
| collectConditionNodes(*rung.condition, &nodes); | |||||
| for (const LogicNode *node : nodes) | |||||
| { | |||||
| const auto *contact = std::get_if<TimerContactNodeConfig>( | |||||
| &node->config); | |||||
| if (contact != nullptr | |||||
| && timer_outputs.count(contact->address.index()) == 0U) | |||||
| { | |||||
| setError( | |||||
| error, | |||||
| "定时器触点 " + contact->address.toString() | |||||
| + " 没有对应的已启用 TON 指令"); | |||||
| return false; | |||||
| } | |||||
| } | |||||
| } | |||||
| } | |||||
| return true; | |||||
| } | |||||
| bool validateCounterReferencesForRunning( | |||||
| const std::vector<ControlLogic> &logics, | |||||
| std::string *error) | |||||
| { | |||||
| std::map<int, std::string> counter_outputs; | |||||
| for (const ControlLogic &logic : logics) | |||||
| { | |||||
| if (!logic.enabled) | |||||
| { | |||||
| continue; | |||||
| } | |||||
| for (const LadderRung &rung : logic.rungs) | |||||
| { | |||||
| if (!rung.output.has_value()) | |||||
| { | |||||
| continue; | |||||
| } | |||||
| const auto *counter = std::get_if<CounterNodeConfig>( | |||||
| &rung.output->config); | |||||
| if (counter == nullptr) | |||||
| { | |||||
| continue; | |||||
| } | |||||
| const auto inserted = counter_outputs.emplace( | |||||
| counter->address.index(), rung.output->id); | |||||
| if (!inserted.second) | |||||
| { | |||||
| setError( | |||||
| error, | |||||
| "计数器 " + counter->address.toString() | |||||
| + " 只能由一个已启用计数指令驱动"); | |||||
| return false; | |||||
| } | |||||
| } | |||||
| } | |||||
| for (const ControlLogic &logic : logics) | |||||
| { | |||||
| if (!logic.enabled) | |||||
| { | |||||
| continue; | |||||
| } | |||||
| for (const LadderRung &rung : logic.rungs) | |||||
| { | |||||
| if (!rung.condition.has_value()) | |||||
| { | |||||
| continue; | |||||
| } | |||||
| std::vector<const LogicNode *> nodes; | |||||
| collectConditionNodes(*rung.condition, &nodes); | |||||
| for (const LogicNode *node : nodes) | |||||
| { | |||||
| const auto *contact = std::get_if<CounterContactNodeConfig>( | |||||
| &node->config); | |||||
| if (contact != nullptr | |||||
| && counter_outputs.count(contact->address.index()) == 0U) | |||||
| { | |||||
| setError( | |||||
| error, | |||||
| "计数器触点 " + contact->address.toString() | |||||
| + " 没有对应的已启用计数指令"); | |||||
| return false; | |||||
| } | |||||
| } | |||||
| } | |||||
| } | |||||
| return true; | |||||
| } | |||||
| bool validateLogicResourceReferencesForRunning( | |||||
| const std::vector<ControlLogic> &logics, | |||||
| std::string *error) | |||||
| { | |||||
| return validateTimerReferencesForRunning(logics, error) | |||||
| && validateCounterReferencesForRunning(logics, error); | |||||
| } | |||||
| @@ -9,214 +9,174 @@ | |||||
| #include <variant> | #include <variant> | ||||
| #include <vector> | #include <vector> | ||||
| // 普通触点的导通方式;常闭触点会对读取到的位值取反 | |||||
| enum class ContactMode | enum class ContactMode | ||||
| { | { | ||||
| NormallyOpen, | |||||
| NormallyClosed | |||||
| NormallyOpen, // 常开:位值为 1 时触点导通 | |||||
| NormallyClosed // 常闭:位值为 0 时触点导通 | |||||
| }; | }; | ||||
| // 线圈写入方式:普通写入、置位保持、复位清零 | |||||
| enum class CoilMode | enum class CoilMode | ||||
| { | { | ||||
| Normal, | |||||
| Set, | |||||
| Reset | |||||
| Normal, // 普通写入:直接写入当前逻辑结果 | |||||
| Set, // 置位:逻辑结果为 1 时保持为 1 | |||||
| Reset // 复位:逻辑结果为 1 时清零 | |||||
| }; | }; | ||||
| // 上升沿/下降沿触点的边沿方向 | |||||
| enum class EdgeMode | enum class EdgeMode | ||||
| { | { | ||||
| Rising, | |||||
| Falling | |||||
| Rising, // 上升沿:信号从 0 变成 1 | |||||
| Falling // 下降沿:信号从 1 变成 0 | |||||
| }; | }; | ||||
| // D 寄存器字比较,等于、不等于、大于、小于等 6 种比较 | |||||
| enum class ComparisonOperator | enum class ComparisonOperator | ||||
| { | { | ||||
| Equal, | |||||
| NotEqual, | |||||
| LessThan, | |||||
| LessThanOrEqual, | |||||
| GreaterThan, | |||||
| GreaterThanOrEqual | |||||
| Equal, // 等于 | |||||
| NotEqual, // 不等于 | |||||
| LessThan, // 小于 | |||||
| LessThanOrEqual, // 小于或等于 | |||||
| GreaterThan, // 大于 | |||||
| GreaterThanOrEqual // 大于或等于 | |||||
| }; | }; | ||||
| // 字操作数可以来自常量,也可以来自 D 寄存器 | |||||
| enum class WordOperandKind | enum class WordOperandKind | ||||
| { | { | ||||
| Constant, | |||||
| Register | |||||
| Constant, // 使用固定数值 | |||||
| Register // 使用 D 寄存器中的数值 | |||||
| }; | }; | ||||
| // MOVE、ADD/SUB 共用的字操作数 | |||||
| struct WordOperand | struct WordOperand | ||||
| { | { | ||||
| WordOperandKind kind = WordOperandKind::Constant; | |||||
| RegisterAddress address{RegisterArea::D, 0}; | |||||
| std::int16_t constant = 0; | |||||
| WordOperandKind kind = WordOperandKind::Constant; // 操作数来源 | |||||
| RegisterAddress address{RegisterArea::D, 0}; // kind 为 Register 时使用的 D 地址 | |||||
| std::int16_t constant = 0; // kind 为 Constant 时使用的数值 | |||||
| // 常量始终有效;寄存器操作数必须是有效 D 地址 | |||||
| bool validate(std::string *error = nullptr) const; | bool validate(std::string *error = nullptr) const; | ||||
| }; | }; | ||||
| // 普通 M 寄存器触点,地址 + 常开常闭 | |||||
| struct ContactNodeConfig | struct ContactNodeConfig | ||||
| { | { | ||||
| RegisterAddress address{RegisterArea::M, 0}; | |||||
| ContactMode mode = ContactMode::NormallyOpen; | |||||
| RegisterAddress address{RegisterArea::M, 0}; // 触点读取的 M 地址 | |||||
| ContactMode mode = ContactMode::NormallyOpen; // 常开或常闭 | |||||
| }; | }; | ||||
| // 边沿触点,地址 + 上升 / 下降沿 | |||||
| struct EdgeContactNodeConfig | struct EdgeContactNodeConfig | ||||
| { | { | ||||
| // 用于检测 M 寄存器的上升沿或下降沿 | |||||
| RegisterAddress address{RegisterArea::M, 0}; | RegisterAddress address{RegisterArea::M, 0}; | ||||
| EdgeMode mode = EdgeMode::Rising; | EdgeMode mode = EdgeMode::Rising; | ||||
| }; | }; | ||||
| // 线圈输出配置,决定如何写入 M 寄存器 | |||||
| struct CoilNodeConfig | struct CoilNodeConfig | ||||
| { | { | ||||
| // 线圈要写入的 M 寄存器地址 | |||||
| RegisterAddress address{RegisterArea::M, 0}; | RegisterAddress address{RegisterArea::M, 0}; | ||||
| CoilMode mode = CoilMode::Normal; | CoilMode mode = CoilMode::Normal; | ||||
| }; | }; | ||||
| // D 寄存器比较配置 | |||||
| struct CompareNodeConfig | struct CompareNodeConfig | ||||
| { | { | ||||
| // 要参与比较的 D 寄存器地址 | |||||
| RegisterAddress address{RegisterArea::D, 0}; | RegisterAddress address{RegisterArea::D, 0}; | ||||
| // 比较运算符 | |||||
| ComparisonOperator comparison = ComparisonOperator::Equal; | ComparisonOperator comparison = ComparisonOperator::Equal; | ||||
| // 与寄存器值比较的常量 | |||||
| std::int16_t value = 0; | std::int16_t value = 0; | ||||
| }; | }; | ||||
| 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}; | |||||
| }; | |||||
| // MOVE 输出:把一个字操作数写入目标 D 地址 | |||||
| struct MoveNodeConfig | struct MoveNodeConfig | ||||
| { | { | ||||
| WordOperand source; | |||||
| RegisterAddress destination{RegisterArea::D, 0}; | |||||
| WordOperand source; // 要写入的源操作数 | |||||
| RegisterAddress destination{RegisterArea::D, 0}; // 接收数据的 D 地址 | |||||
| }; | }; | ||||
| enum class ArithmeticOperation | enum class ArithmeticOperation | ||||
| { | { | ||||
| Add, | |||||
| Subtract | |||||
| Add, // 加法 | |||||
| Subtract // 减法 | |||||
| }; | }; | ||||
| // ADD/SUB 输出:计算两个字操作数并写入目标 D 地址 | |||||
| struct ArithmeticNodeConfig | struct ArithmeticNodeConfig | ||||
| { | { | ||||
| ArithmeticOperation operation = ArithmeticOperation::Add; | |||||
| WordOperand left; | |||||
| WordOperand right; | |||||
| RegisterAddress destination{RegisterArea::D, 0}; | |||||
| ArithmeticOperation operation = ArithmeticOperation::Add; // 加法或减法 | |||||
| WordOperand left; // 左操作数 | |||||
| WordOperand right; // 右操作数 | |||||
| RegisterAddress destination{RegisterArea::D, 0}; // 运算结果写入的 D 地址 | |||||
| }; | }; | ||||
| // 所有梯形图指令的强类型配置联合,避免用无关字段拼装指令 | |||||
| using LogicNodeConfig = std::variant< | using LogicNodeConfig = std::variant< | ||||
| ContactNodeConfig, | |||||
| EdgeContactNodeConfig, | |||||
| TimerContactNodeConfig, | |||||
| CounterContactNodeConfig, | |||||
| CoilNodeConfig, | |||||
| CompareNodeConfig, | |||||
| TonNodeConfig, | |||||
| CounterNodeConfig, | |||||
| MoveNodeConfig, | |||||
| ArithmeticNodeConfig>; | |||||
| ContactNodeConfig, // 普通常开或常闭触点 | |||||
| EdgeContactNodeConfig, // 上升沿或下降沿触点 | |||||
| CoilNodeConfig, // 普通、置位或复位线圈 | |||||
| CompareNodeConfig, // D 寄存器比较节点 | |||||
| MoveNodeConfig, // MOVE 数据传送输出 | |||||
| ArithmeticNodeConfig>; // ADD 或 SUB 算术输出 | |||||
| // 返回节点最主要的 M/D 地址;复合输出请使用下面的收集函数 | |||||
| std::optional<RegisterAddress> registerAddressForLogicNode( | std::optional<RegisterAddress> registerAddressForLogicNode( | ||||
| const LogicNodeConfig &config); | const LogicNodeConfig &config); | ||||
| // 收集节点中所有显式 M/D 引用,用于 PLC 轮询集合构建 | |||||
| void collectRegisterAddressesForLogicNode( | void collectRegisterAddressesForLogicNode( | ||||
| const LogicNodeConfig &config, | const LogicNodeConfig &config, | ||||
| std::vector<RegisterAddress> *addresses); | std::vector<RegisterAddress> *addresses); | ||||
| // 表达式中的一个指令节点;configured=false 表示编辑中的未完成草稿 | |||||
| struct LogicNode | struct LogicNode | ||||
| { | { | ||||
| std::string id; | |||||
| LogicNodeConfig config; | |||||
| bool configured = true; | |||||
| std::string id; // 节点唯一 ID | |||||
| LogicNodeConfig config; // 节点的具体指令配置 | |||||
| bool configured = true; // 是否已完成配置 | |||||
| // 校验节点 ID、配置类型和配置内容 | |||||
| bool validate(std::string *error = nullptr) const; | bool validate(std::string *error = nullptr) const; | ||||
| bool isConfigured() const; | bool isConfigured() const; | ||||
| // 判断节点能否放在条件区或固定输出槽 | |||||
| bool isCondition() const; | bool isCondition() const; | ||||
| bool isOutput() const; | bool isOutput() const; | ||||
| }; | }; | ||||
| enum class ConditionExpressionKind | enum class ConditionExpressionKind | ||||
| { | { | ||||
| Node, | |||||
| Wire, | |||||
| Series, | |||||
| Parallel | |||||
| Node, // 一个实际的条件节点 | |||||
| Wire, // 一段横线 | |||||
| Gap, // 一段明确断开的空白网格 | |||||
| Series, // 多个条件串联,必须全部满足 | |||||
| Parallel // 多个条件并联,满足任意一条即可 | |||||
| }; | }; | ||||
| // 条件区中的持久化横线;columnSpan 表示跨越的网格列数 | |||||
| struct WireSegment | struct WireSegment | ||||
| { | { | ||||
| static constexpr int kMinimumColumnSpan = 1; | static constexpr int kMinimumColumnSpan = 1; | ||||
| static constexpr int kMaximumColumnSpan = | static constexpr int kMaximumColumnSpan = | ||||
| ProjectLimits::kMaximumConditionColumns; | ProjectLimits::kMaximumConditionColumns; | ||||
| int columnSpan = 1; | |||||
| int columnSpan = 1; // 横线占用的网格列数 | |||||
| bool validate(std::string *error = nullptr) const; | |||||
| }; | |||||
| // 条件区中的持久化断路;columnSpan 表示连续空白网格列数 | |||||
| struct GapSegment | |||||
| { | |||||
| static constexpr int kMinimumColumnSpan = 1; | |||||
| static constexpr int kMaximumColumnSpan = | |||||
| ProjectLimits::kMaximumConditionColumns; | |||||
| int columnSpan = 1; // 断路占用的网格列数 | |||||
| bool validate(std::string *error = nullptr) const; | bool validate(std::string *error = nullptr) const; | ||||
| }; | }; | ||||
| @@ -224,65 +184,89 @@ struct WireSegment | |||||
| // 结构化表达式只允许合法的串并联拓扑,不保存可产生悬空线或环路的像素连接 | // 结构化表达式只允许合法的串并联拓扑,不保存可产生悬空线或环路的像素连接 | ||||
| struct ConditionExpression | struct ConditionExpression | ||||
| { | { | ||||
| std::string id; | |||||
| ConditionExpressionKind kind = ConditionExpressionKind::Node; | |||||
| std::optional<LogicNode> node; | |||||
| std::optional<WireSegment> wire; | |||||
| std::vector<ConditionExpression> children; | |||||
| std::string id; // 表达式唯一 ID | |||||
| ConditionExpressionKind kind = ConditionExpressionKind::Node; // 表达式类型 | |||||
| std::optional<LogicNode> node; // kind 为 Node 时保存的节点 | |||||
| std::optional<WireSegment> wire; // kind 为 Wire 时保存的横线 | |||||
| std::optional<GapSegment> gap; // kind 为 Gap 时保存的断路 | |||||
| std::vector<ConditionExpression> children; // 串联或并联的子表达式 | |||||
| // 把一个逻辑节点包装成条件表达式 | |||||
| static ConditionExpression fromNode(LogicNode node); | static ConditionExpression fromNode(LogicNode node); | ||||
| // 创建一段指定列数的横线表达式 | |||||
| static ConditionExpression fromWire(std::string id, int column_span = 1); | static ConditionExpression fromWire(std::string id, int column_span = 1); | ||||
| // 创建一段指定列数的断路表达式 | |||||
| static ConditionExpression fromGap(std::string id, int column_span = 1); | |||||
| // 编辑态校验允许空配置节点,但必须保持树结构合法 | |||||
| bool validate(std::string *error = nullptr) const; | bool validate(std::string *error = nullptr) const; | ||||
| // 运行态校验额外要求每个节点都已经配置完成 | |||||
| bool validateForRunning(std::string *error = nullptr) const; | bool validateForRunning(std::string *error = nullptr) const; | ||||
| }; | }; | ||||
| // 删除后折叠单子项容器并合并相邻同类容器,保持表达式的规范形态 | |||||
| // 删除节点后整理表达式结构 | |||||
| void normalizeConditionExpression(std::optional<ConditionExpression> *expression); | void normalizeConditionExpression(std::optional<ConditionExpression> *expression); | ||||
| // 按节点 ID 查找条件节点,只读版本 | |||||
| const LogicNode *findConditionNode( | const LogicNode *findConditionNode( | ||||
| const ConditionExpression &expression, const std::string &node_id); | const ConditionExpression &expression, const std::string &node_id); | ||||
| // 按节点 ID 查找条件节点,可修改版本 | |||||
| LogicNode *findConditionNode( | LogicNode *findConditionNode( | ||||
| ConditionExpression &expression, const std::string &node_id); | ConditionExpression &expression, const std::string &node_id); | ||||
| // 按表达式 ID 查找子表达式,只读版本 | |||||
| const ConditionExpression *findConditionExpression( | const ConditionExpression *findConditionExpression( | ||||
| const ConditionExpression &expression, const std::string &expression_id); | const ConditionExpression &expression, const std::string &expression_id); | ||||
| // 按表达式 ID 查找子表达式,可修改版本 | |||||
| ConditionExpression *findConditionExpression( | ConditionExpression *findConditionExpression( | ||||
| ConditionExpression &expression, const std::string &expression_id); | ConditionExpression &expression, const std::string &expression_id); | ||||
| // 收集表达式中的所有条件节点 | |||||
| void collectConditionNodes( | void collectConditionNodes( | ||||
| const ConditionExpression &expression, std::vector<const LogicNode *> *nodes); | const ConditionExpression &expression, std::vector<const LogicNode *> *nodes); | ||||
| // 收集表达式和子表达式的所有 ID | |||||
| void collectConditionExpressionIds( | void collectConditionExpressionIds( | ||||
| const ConditionExpression &expression, std::vector<std::string> *ids); | const ConditionExpression &expression, std::vector<std::string> *ids); | ||||
| // 条件表达式为空时表示恒真网络,输出指令固定在最右侧 | |||||
| // 输出指令固定在第 11 列;有输出时前 10 列必须由显式条件、横线或断路占满 | |||||
| struct LadderRung | struct LadderRung | ||||
| { | { | ||||
| std::string id; | |||||
| std::string name; | |||||
| std::string comment; | |||||
| std::optional<ConditionExpression> condition; | |||||
| std::optional<LogicNode> output; | |||||
| std::string id; // 网络唯一 ID | |||||
| std::string name; // 网络名称 | |||||
| std::string comment; // 网络注释 | |||||
| std::optional<ConditionExpression> condition; // 条件区表达式;空值只用于空网络草稿 | |||||
| std::optional<LogicNode> output; // 网络右侧的输出指令 | |||||
| // 编辑态允许没有条件或输出,便于逐步搭建网络 | |||||
| bool validate(std::string *error = nullptr) const; | bool validate(std::string *error = nullptr) const; | ||||
| // 只检查表达式拓扑、列数和输出位置 | |||||
| bool validateStructure(std::string *error = nullptr) const; | bool validateStructure(std::string *error = nullptr) const; | ||||
| // 运行态要求有输出且所有引用都已配置 | |||||
| bool validateForRunning(std::string *error = nullptr) const; | bool validateForRunning(std::string *error = nullptr) const; | ||||
| }; | }; | ||||
| // 一组按顺序扫描的梯形图网络;enabled=false 时运行校验会跳过它 | |||||
| struct ControlLogic | struct ControlLogic | ||||
| { | { | ||||
| std::string id; | |||||
| std::string name; | |||||
| std::vector<LadderRung> rungs; | |||||
| bool enabled = true; | |||||
| std::string id; // 控制逻辑唯一 ID | |||||
| std::string name; // 控制逻辑名称 | |||||
| std::vector<LadderRung> rungs; // 很多条 LadderRung(梯形图网络/梯级) | |||||
| bool enabled = true; // 是否参与运行扫描 | |||||
| // 校验可保存结构 | |||||
| bool validate(std::string *error = nullptr) const; | bool validate(std::string *error = nullptr) const; | ||||
| bool validate( | |||||
| const ProjectLimitSettings &limits, | |||||
| std::string *error = nullptr) const; | |||||
| bool validateStructure(std::string *error = nullptr) const; | bool validateStructure(std::string *error = nullptr) const; | ||||
| bool validateStructure( | |||||
| const ProjectLimitSettings &limits, | |||||
| std::string *error = nullptr) const; | |||||
| // 校验运行所需资源和网络配置 | |||||
| bool validateForRunning(std::string *error = nullptr) const; | bool validateForRunning(std::string *error = nullptr) const; | ||||
| bool validateForRunning( | |||||
| const ProjectLimitSettings &limits, | |||||
| 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); | |||||
| @@ -69,16 +69,7 @@ constexpr std::array kControlDescriptors = { | |||||
| {0, 0, 360, 136}, | {0, 0, 360, 136}, | ||||
| HmiBindingKind::None, | HmiBindingKind::None, | ||||
| HmiRuntimeValueKind::None, | HmiRuntimeValueKind::None, | ||||
| false}, | |||||
| HmiControlDescriptor{HmiControlType::ProgressBar, | |||||
| "progressBar", | |||||
| "进度条", | |||||
| "progress-bar", | |||||
| "进度", | |||||
| {0, 0, 240, 36}, | |||||
| HmiBindingKind::Word, | |||||
| HmiRuntimeValueKind::Word, | |||||
| true} | |||||
| false} | |||||
| }; | }; | ||||
| static_assert( | static_assert( | ||||
| @@ -17,9 +17,9 @@ | |||||
| */ | */ | ||||
| enum class HmiBindingKind | enum class HmiBindingKind | ||||
| { | { | ||||
| None, | |||||
| Bit, | |||||
| Word | |||||
| None, // 不支持寄存器绑定 | |||||
| Bit, // 绑定 M 区位地址 | |||||
| Word // 绑定 D 区字地址 | |||||
| }; | }; | ||||
| /** | /** | ||||
| @@ -27,9 +27,9 @@ enum class HmiBindingKind | |||||
| */ | */ | ||||
| enum class HmiRuntimeValueKind | enum class HmiRuntimeValueKind | ||||
| { | { | ||||
| None, | |||||
| Bit, | |||||
| Word | |||||
| None, // 运行态不读取寄存器值 | |||||
| Bit, // 运行态读取绑定的 M 位值 | |||||
| Word // 运行态读取绑定的 D 字值 | |||||
| }; | }; | ||||
| /** | /** | ||||
| @@ -37,15 +37,15 @@ enum class HmiRuntimeValueKind | |||||
| */ | */ | ||||
| struct HmiControlDescriptor | struct HmiControlDescriptor | ||||
| { | { | ||||
| HmiControlType type; | |||||
| const char *storageName; | |||||
| const char *displayName; | |||||
| const char *idPrefix; | |||||
| const char *defaultText; | |||||
| HmiRect defaultBounds; | |||||
| HmiBindingKind bindingKind; | |||||
| HmiRuntimeValueKind runtimeValueKind; | |||||
| bool requiresBindingForRunning; | |||||
| HmiControlType type; // 控件的领域类型 | |||||
| const char *storageName; // 工程 JSON 中持久化的稳定类型名 | |||||
| const char *displayName; // 编辑器和属性面板使用的显示名称 | |||||
| const char *idPrefix; // 自动生成控件 ID 时使用的前缀 | |||||
| const char *defaultText; // 新建控件时使用的默认显示文本 | |||||
| HmiRect defaultBounds; // 新建控件时使用的默认位置和尺寸 | |||||
| HmiBindingKind bindingKind; // 编辑态允许的寄存器绑定类型 | |||||
| HmiRuntimeValueKind runtimeValueKind; // 运行态读取的寄存器值类型 | |||||
| bool requiresBindingForRunning; // 运行前是否必须完成有效寄存器绑定 | |||||
| }; | }; | ||||
| /** | /** | ||||
| @@ -89,23 +89,6 @@ bool validateAppearanceProperty( | |||||
| } // namespace | } // namespace | ||||
| bool HmiProgressBarConfig::isValid() const | |||||
| { | |||||
| return minimumValue < maximumValue; | |||||
| } | |||||
| int HmiProgressBarConfig::percentageForValue(std::int16_t value) const | |||||
| { | |||||
| if (!isValid()) | |||||
| { | |||||
| return 0; | |||||
| } | |||||
| const int minimum = minimumValue; | |||||
| const int maximum = maximumValue; | |||||
| const int bounded_value = std::clamp(static_cast<int>(value), minimum, maximum); | |||||
| return (bounded_value - minimum) * 100 / (maximum - minimum); | |||||
| } | |||||
| bool HmiControl::validate(std::string *error) const | bool HmiControl::validate(std::string *error) const | ||||
| { | { | ||||
| const HmiControlDescriptor *descriptor = findHmiControlDescriptor(type); | const HmiControlDescriptor *descriptor = findHmiControlDescriptor(type); | ||||
| @@ -126,7 +109,7 @@ bool HmiControl::validate(std::string *error) const | |||||
| } | } | ||||
| if (text.size() > ProjectLimits::kMaximumTextBytes) | if (text.size() > ProjectLimits::kMaximumTextBytes) | ||||
| { | { | ||||
| setError(error, "HMI 控件文本不能超过 4096 个 UTF-8 字节"); | |||||
| setError(error, "HMI 控件文本不能超过 256 个 UTF-8 字节"); | |||||
| return false; | return false; | ||||
| } | } | ||||
| if (bounds.width <= 0 || bounds.height <= 0 | if (bounds.width <= 0 || bounds.height <= 0 | ||||
| @@ -155,7 +138,7 @@ bool HmiControl::validate(std::string *error) const | |||||
| } | } | ||||
| if (property.second.size() > ProjectLimits::kMaximumPropertyValueBytes) | if (property.second.size() > ProjectLimits::kMaximumPropertyValueBytes) | ||||
| { | { | ||||
| setError(error, "HMI 控件属性值不能超过 4096 个 UTF-8 字节"); | |||||
| setError(error, "HMI 控件属性值不能超过 1024 个 UTF-8 字节"); | |||||
| return false; | return false; | ||||
| } | } | ||||
| if (!validateAppearanceProperty(property.first, property.second, error)) | if (!validateAppearanceProperty(property.first, property.second, error)) | ||||
| @@ -188,6 +171,20 @@ bool HmiControl::validate(std::string *error) const | |||||
| setError(error, "HMI 控件绑定了无效地址"); | setError(error, "HMI 控件绑定了无效地址"); | ||||
| return false; | return false; | ||||
| } | } | ||||
| const bool numeric = type == HmiControlType::NumericDisplay | |||||
| || type == HmiControlType::NumericInput; | |||||
| if (!numeric && dataType != RegisterDataType::Int16) | |||||
| { | |||||
| setError(error, "只有 HMI 数值控件可以选择 Float32"); | |||||
| return false; | |||||
| } | |||||
| if (numeric && dataType == RegisterDataType::Float32 | |||||
| && binding.has_value() | |||||
| && binding->index() >= RegisterAddress::kMaximumIndex) | |||||
| { | |||||
| setError(error, "Float32 起始地址必须位于 D0~D3999"); | |||||
| return false; | |||||
| } | |||||
| if (type == HmiControlType::PageJump) | if (type == HmiControlType::PageJump) | ||||
| { | { | ||||
| if (!pageJump.has_value()) | if (!pageJump.has_value()) | ||||
| @@ -206,19 +203,6 @@ bool HmiControl::validate(std::string *error) const | |||||
| setError(error, "非页面跳转控件不能包含跳转配置"); | setError(error, "非页面跳转控件不能包含跳转配置"); | ||||
| return false; | return false; | ||||
| } | } | ||||
| if (type == HmiControlType::ProgressBar) | |||||
| { | |||||
| if (!progressBar.has_value() || !progressBar->isValid()) | |||||
| { | |||||
| setError(error, "进度条最小值必须小于最大值"); | |||||
| return false; | |||||
| } | |||||
| } | |||||
| else if (progressBar.has_value()) | |||||
| { | |||||
| setError(error, "非进度条控件不能包含进度条配置"); | |||||
| return false; | |||||
| } | |||||
| return true; | return true; | ||||
| } | } | ||||
| @@ -229,11 +213,6 @@ bool HmiControl::isConfigured() const | |||||
| { | { | ||||
| return false; | return false; | ||||
| } | } | ||||
| if (type == HmiControlType::ProgressBar | |||||
| && (!progressBar.has_value() || !progressBar->isValid())) | |||||
| { | |||||
| return false; | |||||
| } | |||||
| if (!descriptor->requiresBindingForRunning | if (!descriptor->requiresBindingForRunning | ||||
| && type != HmiControlType::PageJump) | && type != HmiControlType::PageJump) | ||||
| { | { | ||||
| @@ -255,6 +234,12 @@ bool HmiControl::isConfigured() const | |||||
| } | } | ||||
| bool HmiPage::validate(std::string *error) const | bool HmiPage::validate(std::string *error) const | ||||
| { | |||||
| return validate(defaultProjectLimitSettings(), error); | |||||
| } | |||||
| bool HmiPage::validate( | |||||
| const ProjectLimitSettings &limits, std::string *error) const | |||||
| { | { | ||||
| if (id.empty() || name.empty()) | if (id.empty() || name.empty()) | ||||
| { | { | ||||
| @@ -268,19 +253,26 @@ bool HmiPage::validate(std::string *error) const | |||||
| } | } | ||||
| if (name.size() > ProjectLimits::kMaximumTextBytes) | if (name.size() > ProjectLimits::kMaximumTextBytes) | ||||
| { | { | ||||
| setError(error, "HMI 页面名称不能超过 4096 个 UTF-8 字节"); | |||||
| setError(error, "HMI 页面名称不能超过 256 个 UTF-8 字节"); | |||||
| return false; | return false; | ||||
| } | } | ||||
| if (width <= 0 || height <= 0 | |||||
| if (width < ProjectLimits::kMinimumHmiPageWidth | |||||
| || height < ProjectLimits::kMinimumHmiPageHeight | |||||
| || width > ProjectLimits::kMaximumHmiPageWidth | || width > ProjectLimits::kMaximumHmiPageWidth | ||||
| || height > ProjectLimits::kMaximumHmiPageHeight) | || height > ProjectLimits::kMaximumHmiPageHeight) | ||||
| { | { | ||||
| setError(error, "HMI 页面宽度和高度必须在 1~8192 范围内"); | |||||
| setError( | |||||
| error, | |||||
| "HMI 页面宽度必须在 320~1600、高度必须在 200~800 范围内"); | |||||
| return false; | return false; | ||||
| } | } | ||||
| if (controls.size() > ProjectLimits::kMaximumHmiControlsPerPage) | |||||
| if (controls.size() > limits.maximumHmiControlsPerPage) | |||||
| { | { | ||||
| setError(error, "单个 HMI 页面最多包含 512 个控件"); | |||||
| setError( | |||||
| error, | |||||
| "HMI 页面“" + name + "”的控件数量为 " | |||||
| + std::to_string(controls.size()) + ",当前配置上限为 " | |||||
| + std::to_string(limits.maximumHmiControlsPerPage)); | |||||
| return false; | return false; | ||||
| } | } | ||||
| @@ -312,7 +304,13 @@ bool HmiPage::validate(std::string *error) const | |||||
| bool HmiPage::validateForRunning(std::string *error) const | bool HmiPage::validateForRunning(std::string *error) const | ||||
| { | { | ||||
| if (!validate(error)) | |||||
| return validateForRunning(defaultProjectLimitSettings(), error); | |||||
| } | |||||
| bool HmiPage::validateForRunning( | |||||
| const ProjectLimitSettings &limits, std::string *error) const | |||||
| { | |||||
| if (!validate(limits, error)) | |||||
| { | { | ||||
| return false; | return false; | ||||
| } | } | ||||
| @@ -8,9 +8,9 @@ | |||||
| #pragma once | #pragma once | ||||
| #include "register_address.h" | #include "register_address.h" | ||||
| #include "register_value_type.h" | |||||
| #include "project_limits.h" | #include "project_limits.h" | ||||
| #include <cstdint> | |||||
| #include <map> | #include <map> | ||||
| #include <optional> | #include <optional> | ||||
| #include <string> | #include <string> | ||||
| @@ -56,7 +56,6 @@ enum class HmiControlType | |||||
| Label, // 标签 | Label, // 标签 | ||||
| PageJump, // 页面跳转 | PageJump, // 页面跳转 | ||||
| AlarmList, // 报警列表 | AlarmList, // 报警列表 | ||||
| ProgressBar, // 进度条 | |||||
| Count // 已注册控件类型数量,不作为实际控件使用 | Count // 已注册控件类型数量,不作为实际控件使用 | ||||
| }; | }; | ||||
| @@ -76,19 +75,6 @@ struct HmiPageJumpConfig | |||||
| std::string targetPageId; | std::string targetPageId; | ||||
| }; | }; | ||||
| /** | |||||
| * @brief 进度条的有效值范围和文字显示方式 | |||||
| */ | |||||
| struct HmiProgressBarConfig | |||||
| { | |||||
| std::int16_t minimumValue = 0; | |||||
| std::int16_t maximumValue = 100; | |||||
| bool showValue = true; | |||||
| bool isValid() const; | |||||
| int percentageForValue(std::int16_t value) const; | |||||
| }; | |||||
| /** | /** | ||||
| * @brief 描述一个可保存的 HMI 控件及其显示和寄存器配置 | * @brief 描述一个可保存的 HMI 控件及其显示和寄存器配置 | ||||
| * | * | ||||
| @@ -101,10 +87,10 @@ struct HmiControl | |||||
| HmiRect bounds; | HmiRect bounds; | ||||
| std::string text; | std::string text; | ||||
| std::optional<RegisterAddress> binding; | std::optional<RegisterAddress> binding; | ||||
| RegisterDataType dataType = RegisterDataType::Int16; | |||||
| std::map<std::string, std::string> properties; | std::map<std::string, std::string> properties; | ||||
| HmiButtonOperation buttonOperation = HmiButtonOperation::MomentaryOn; | HmiButtonOperation buttonOperation = HmiButtonOperation::MomentaryOn; | ||||
| std::optional<HmiPageJumpConfig> pageJump; | std::optional<HmiPageJumpConfig> pageJump; | ||||
| std::optional<HmiProgressBarConfig> progressBar; | |||||
| /** | /** | ||||
| * @brief 校验控件的标识、尺寸、扩展属性和寄存器绑定 | * @brief 校验控件的标识、尺寸、扩展属性和寄存器绑定 | ||||
| @@ -136,5 +122,11 @@ struct HmiPage | |||||
| * @return 页面及其全部控件有效时返回 true | * @return 页面及其全部控件有效时返回 true | ||||
| */ | */ | ||||
| bool validate(std::string *error = nullptr) const; | bool validate(std::string *error = nullptr) const; | ||||
| bool validate( | |||||
| const ProjectLimitSettings &limits, | |||||
| std::string *error = nullptr) const; | |||||
| bool validateForRunning(std::string *error = nullptr) const; | bool validateForRunning(std::string *error = nullptr) const; | ||||
| bool validateForRunning( | |||||
| const ProjectLimitSettings &limits, | |||||
| std::string *error = nullptr) const; | |||||
| }; | }; | ||||
| @@ -2,64 +2,99 @@ | |||||
| #include <cstddef> | #include <cstddef> | ||||
| // 所有跨层共享的数量和范围边界集中在这里,避免 UI、服务和 JSON 各自写数字 | |||||
| namespace ProjectLimits { | 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 | |||||
| constexpr std::size_t kMaximumProjectFileBytes = 16U * 1024U * 1024U; // 一个工程文件最大 16 MiB,防止异常大文件占满内存 | |||||
| constexpr std::size_t kMaximumHmiPages = 32U; // 一个工程最多放 32 个 HMI 页面 | |||||
| constexpr std::size_t kMaximumHmiControlsPerPage = 128U; // 一个 HMI 页面最多放 128 个控件 | |||||
| constexpr std::size_t kMaximumHmiControlsPerProject = 2048U; // 一个工程最多保存 2048 个 HMI 控件 | |||||
| constexpr std::size_t kMaximumAlarmDefinitions = 256U; // 一个工程最多配置 256 条报警 | |||||
| constexpr std::size_t kMaximumRegisterComments = 8002U; // M0~M4000 和 D0~D4000 最多各写一条注释 | |||||
| constexpr std::size_t kMaximumControlLogics = 32U; // 一个工程最多放 32 组梯形图控制逻辑 | |||||
| constexpr std::size_t kMaximumRungsPerLogic = 256U; // 一组控制逻辑最多放 256 个网络 | |||||
| constexpr std::size_t kMaximumRungsPerProject = 2048U; // 一个工程最多保存 2048 个网络 | |||||
| constexpr std::size_t kMaximumExpressionNodesPerRung = 1024U; // 一个网络里的触点、横线和内部结构最多共 1024 个 | |||||
| constexpr std::size_t kMaximumExpressionDepth = 12U; // 并联套并联时最多套 12 层,防止结构无限变复杂 | |||||
| constexpr std::size_t kMaximumExpressionChildren = 64U; // 一个串联组或并联组最多放 64 个子项 | |||||
| constexpr int kMaximumConditionColumns = 10; // 一个网络前面的条件区最多 10 列 | |||||
| constexpr int kMaximumLadderColumns = 11; // 一个网络总共 11 列,最后 1 列专门放输出 | |||||
| constexpr int kMaximumLogicRows = 64; // 一个网络上下最多 64 行并联支路 | |||||
| static_assert(kMaximumLadderColumns == kMaximumConditionColumns + 1); // 确保总列数始终等于条件列加输出列 | |||||
| constexpr std::size_t kMaximumHmiProperties = 64U; // 一个 HMI 控件最多保存 64 对扩展属性 | |||||
| constexpr std::size_t kMaximumIdBytes = 128U; // ID 最多 128 个 UTF-8 字节,够用来做稳定标识 | |||||
| constexpr std::size_t kMaximumTextBytes = 256U; // 工程名称等普通文本最多 256 个 UTF-8 字节 | |||||
| constexpr std::size_t kMaximumHmiControlTextCharacters = 12U; // HMI 控件显示文字最多输入 12 个字符 | |||||
| constexpr std::size_t kMaximumAlarmTitleCharacters = 12U; // 报警列表标题最多输入 12 个字符 | |||||
| constexpr std::size_t kMaximumAlarmMessageCharacters = 20U; // 一条报警内容最多输入 20 个字符 | |||||
| constexpr std::size_t kMaximumPropertyKeyBytes = 128U; // HMI 属性名最多 128 个 UTF-8 字节 | |||||
| constexpr std::size_t kMaximumPropertyValueBytes = 1024U; // HMI 属性值最多 1024 个 UTF-8 字节 | |||||
| constexpr std::size_t kMaximumRegisterCommentBytes = 64U; // M/D 注释最多 64 个 UTF-8 字节,保证节点下方放得下 | |||||
| constexpr std::size_t kMaximumRungCommentBytes = 128U; // 网络注释最多 128 个 UTF-8 字节,并且保持单行显示 | |||||
| constexpr int kMinimumHmiPageWidth = 320; // HMI 页面最窄 320 像素,太窄会不好编辑 | |||||
| constexpr int kMaximumHmiPageWidth = 1600; // HMI 页面最宽 1600 像素,避免画布无限变大 | |||||
| constexpr int kMinimumHmiPageHeight = 200; // HMI 页面最矮 200 像素,保证基本可用空间 | |||||
| constexpr int kMaximumHmiPageHeight = 800; // HMI 页面最高 800 像素,适合当前桌面编辑器 | |||||
| constexpr int kDefaultHmiPageWidth = 800; // 新建 HMI 页面默认宽度 800 像素 | |||||
| constexpr int kDefaultHmiPageHeight = 400; // 新建 HMI 页面默认高度 400 像素 | |||||
| constexpr int kMaximumHmiControlWidth = 8192; // 单个 HMI 控件最大宽度,防止异常尺寸撑坏画布 | |||||
| constexpr int kMaximumHmiControlHeight = 8192; // 单个 HMI 控件最大高度,防止异常尺寸撑坏画布 | |||||
| constexpr int kMinimumHmiFontPointSize = 6; // HMI 字体最小 6 磅,太小基本看不清 | |||||
| constexpr int kMaximumHmiFontPointSize = 72; // HMI 字体最大 72 磅,防止文字撑坏布局 | |||||
| constexpr int kAlarmDefaultFontPointReduction = 2; // 报警列表默认比普通文字小 2 磅,给表头留空间 | |||||
| constexpr int kMaximumVisibleAlarmRows = 5; // 报警列表默认最多同时显示 5 行,更多内容通过翻页查看 | |||||
| constexpr std::size_t kMaximumPollAddresses = 256U; // PLC 一轮轮询最多读取 256 个去重后的地址 | |||||
| constexpr std::size_t kMaximumPollBlocks = 8U; // PLC 一轮轮询最多拆成 8 个读块 | |||||
| constexpr int kMaximumModbusReadCount = 120; // 一条 Modbus 读指令最多读 120 个数据项 | |||||
| constexpr int kMinimumPlcServerAddress = 1; // Modbus 站号从 1 开始 | |||||
| constexpr int kMaximumPlcServerAddress = 247; // 站号最大 247,避开 Modbus 保留站号 | |||||
| constexpr int kMinimumResponseTimeoutMs = 100; // PLC 最短等待回复 100 毫秒,避免过早判定超时 | |||||
| constexpr int kMaximumResponseTimeoutMs = 30000; // PLC 最长等待回复 30 秒,避免故障时一直卡住 | |||||
| constexpr int kMinimumRetries = 0; // 最少可以不重试,收到失败就直接返回 | |||||
| constexpr int kMaximumRetries = 5; // PLC 通信最多自动重试 5 次 | |||||
| constexpr int kMinimumPollIntervalMs = 50; // PLC 最快每 50 毫秒轮询一次,避免压满串口 | |||||
| constexpr int kMaximumPollIntervalMs = 10000; // PLC 最慢每 10 秒轮询一次,避免画面长时间不刷新 | |||||
| constexpr std::size_t kMaximumPendingWrites = 1U; // 同时只允许等待 1 个 PLC 写入,避免写入顺序混乱 | |||||
| constexpr int kMaximumOutputMessages = 1000; // 输出日志最多保留 1000 条,超过后删除最旧的一条 | |||||
| } | |||||
| // 用户可调的工程数量上限;默认值等于代码定义的绝对硬上限 | |||||
| struct ProjectLimitSettings | |||||
| { | |||||
| std::size_t maximumHmiPages = ProjectLimits::kMaximumHmiPages; | |||||
| std::size_t maximumHmiControlsPerPage = | |||||
| ProjectLimits::kMaximumHmiControlsPerPage; | |||||
| std::size_t maximumAlarmDefinitions = | |||||
| ProjectLimits::kMaximumAlarmDefinitions; | |||||
| std::size_t maximumControlLogics = ProjectLimits::kMaximumControlLogics; | |||||
| std::size_t maximumRungsPerLogic = ProjectLimits::kMaximumRungsPerLogic; | |||||
| int maximumOutputMessages = ProjectLimits::kMaximumOutputMessages; | |||||
| }; | |||||
| // 新建 HMI 页面使用的默认尺寸;页面结构安全范围仍由上方硬限制控制 | |||||
| struct HmiDefaultSettings | |||||
| { | |||||
| int pageWidth = ProjectLimits::kDefaultHmiPageWidth; | |||||
| int pageHeight = ProjectLimits::kDefaultHmiPageHeight; | |||||
| }; | |||||
| inline const ProjectLimitSettings &defaultProjectLimitSettings() | |||||
| { | |||||
| static const ProjectLimitSettings settings; | |||||
| return settings; | |||||
| } | |||||
| inline const HmiDefaultSettings &defaultHmiSettings() | |||||
| { | |||||
| static const HmiDefaultSettings settings; | |||||
| return settings; | |||||
| } | |||||
| @@ -4,6 +4,8 @@ | |||||
| #include <algorithm> | #include <algorithm> | ||||
| #include <cctype> | #include <cctype> | ||||
| #include <variant> | |||||
| #include <type_traits> | |||||
| namespace { | namespace { | ||||
| @@ -70,6 +72,51 @@ bool containsLineBreak(const std::string &value) | |||||
| || value.find('\n') != std::string::npos; | || value.find('\n') != std::string::npos; | ||||
| } | } | ||||
| struct HmiDataBinding | |||||
| { | |||||
| RegisterAddress address{RegisterArea::D, 0}; | |||||
| RegisterDataType type = RegisterDataType::Int16; | |||||
| std::string controlId; | |||||
| }; | |||||
| bool isNumericControl(const HmiControl &control) | |||||
| { | |||||
| return control.type == HmiControlType::NumericDisplay | |||||
| || control.type == HmiControlType::NumericInput; | |||||
| } | |||||
| bool rangesIntersect(const HmiDataBinding &left, const HmiDataBinding &right) | |||||
| { | |||||
| const int leftEnd = left.address.index() | |||||
| + registerDataTypeWordCount(left.type) - 1; | |||||
| const int rightEnd = right.address.index() | |||||
| + registerDataTypeWordCount(right.type) - 1; | |||||
| return left.address.area() == right.address.area() | |||||
| && left.address.index() <= rightEnd | |||||
| && right.address.index() <= leftEnd; | |||||
| } | |||||
| bool isFloatWriteDestination(const LogicNodeConfig &config, RegisterAddress *address) | |||||
| { | |||||
| if (address == nullptr) | |||||
| { | |||||
| return false; | |||||
| } | |||||
| return std::visit( | |||||
| [address](const auto &value) | |||||
| { | |||||
| using Config = std::decay_t<decltype(value)>; | |||||
| if constexpr (std::is_same_v<Config, MoveNodeConfig> | |||||
| || std::is_same_v<Config, ArithmeticNodeConfig>) | |||||
| { | |||||
| *address = value.destination; | |||||
| return true; | |||||
| } | |||||
| return false; | |||||
| }, | |||||
| config); | |||||
| } | |||||
| } // namespace | } // namespace | ||||
| bool RegisterComment::validate(std::string *error) const | bool RegisterComment::validate(std::string *error) const | ||||
| @@ -111,6 +158,13 @@ const RegisterComment *Project::findRegisterComment( | |||||
| bool Project::validate(std::string *error) const | bool Project::validate(std::string *error) const | ||||
| { | { | ||||
| return validate(defaultProjectLimitSettings(), error); | |||||
| } | |||||
| bool Project::validate( | |||||
| const ProjectLimitSettings &limits, std::string *error) const | |||||
| { | |||||
| // 这里是工程级总闸门:先检查数量和唯一性,再向下委托页面、报警和逻辑 | |||||
| if (metadata.id.empty() || metadata.name.empty() || metadata.formatVersion.empty()) | if (metadata.id.empty() || metadata.name.empty() || metadata.formatVersion.empty()) | ||||
| { | { | ||||
| setError(error, "工程 ID、名称和格式版本不能为空"); | setError(error, "工程 ID、名称和格式版本不能为空"); | ||||
| @@ -124,7 +178,7 @@ bool Project::validate(std::string *error) const | |||||
| } | } | ||||
| if (metadata.name.size() > ProjectLimits::kMaximumTextBytes) | if (metadata.name.size() > ProjectLimits::kMaximumTextBytes) | ||||
| { | { | ||||
| setError(error, "工程名称不能超过 4096 个 UTF-8 字节"); | |||||
| setError(error, "工程名称不能超过 256 个 UTF-8 字节"); | |||||
| return false; | return false; | ||||
| } | } | ||||
| if (initialHmiPageId.size() > ProjectLimits::kMaximumIdBytes) | if (initialHmiPageId.size() > ProjectLimits::kMaximumIdBytes) | ||||
| @@ -132,14 +186,23 @@ bool Project::validate(std::string *error) const | |||||
| setError(error, "初始 HMI 页面 ID 不能超过 128 个 UTF-8 字节"); | setError(error, "初始 HMI 页面 ID 不能超过 128 个 UTF-8 字节"); | ||||
| return false; | return false; | ||||
| } | } | ||||
| if (hmiPages.size() > ProjectLimits::kMaximumHmiPages) | |||||
| if (hmiPages.size() > limits.maximumHmiPages) | |||||
| { | { | ||||
| setError(error, "单个工程最多包含 128 个 HMI 页面"); | |||||
| setError( | |||||
| error, | |||||
| "工程 HMI 页面数量为 " + std::to_string(hmiPages.size()) | |||||
| + ",当前配置上限为 " | |||||
| + std::to_string(limits.maximumHmiPages)); | |||||
| return false; | return false; | ||||
| } | } | ||||
| if (alarmDefinitions.size() > ProjectLimits::kMaximumAlarmDefinitions) | |||||
| if (alarmDefinitions.size() > limits.maximumAlarmDefinitions) | |||||
| { | { | ||||
| setError(error, "单个工程最多包含 512 条报警定义"); | |||||
| setError( | |||||
| error, | |||||
| "工程报警定义数量为 " | |||||
| + std::to_string(alarmDefinitions.size()) | |||||
| + ",当前配置上限为 " | |||||
| + std::to_string(limits.maximumAlarmDefinitions)); | |||||
| return false; | return false; | ||||
| } | } | ||||
| if (registerComments.size() > ProjectLimits::kMaximumRegisterComments) | if (registerComments.size() > ProjectLimits::kMaximumRegisterComments) | ||||
| @@ -147,9 +210,33 @@ bool Project::validate(std::string *error) const | |||||
| setError(error, "单个工程最多包含 8002 条软元件注释"); | setError(error, "单个工程最多包含 8002 条软元件注释"); | ||||
| return false; | return false; | ||||
| } | } | ||||
| if (controlLogics.size() > ProjectLimits::kMaximumControlLogics) | |||||
| if (controlLogics.size() > limits.maximumControlLogics) | |||||
| { | |||||
| setError( | |||||
| error, | |||||
| "工程控制逻辑数量为 " + std::to_string(controlLogics.size()) | |||||
| + ",当前配置上限为 " | |||||
| + std::to_string(limits.maximumControlLogics)); | |||||
| return false; | |||||
| } | |||||
| std::size_t total_hmi_controls = 0U; | |||||
| for (const HmiPage &page : hmiPages) | |||||
| { | |||||
| total_hmi_controls += page.controls.size(); | |||||
| } | |||||
| if (total_hmi_controls > ProjectLimits::kMaximumHmiControlsPerProject) | |||||
| { | |||||
| setError(error, "单个工程最多包含 2048 个 HMI 控件"); | |||||
| return false; | |||||
| } | |||||
| std::size_t total_rungs = 0U; | |||||
| for (const ControlLogic &logic : controlLogics) | |||||
| { | { | ||||
| setError(error, "单个工程最多包含 128 组控制逻辑"); | |||||
| total_rungs += logic.rungs.size(); | |||||
| } | |||||
| if (total_rungs > ProjectLimits::kMaximumRungsPerProject) | |||||
| { | |||||
| setError(error, "单个工程最多包含 2048 个梯形图网络"); | |||||
| return false; | return false; | ||||
| } | } | ||||
| if (containsDuplicateId(hmiPages)) | if (containsDuplicateId(hmiPages)) | ||||
| @@ -225,7 +312,7 @@ bool Project::validate(std::string *error) const | |||||
| for (const HmiPage &page : hmiPages) | for (const HmiPage &page : hmiPages) | ||||
| { | { | ||||
| // 工程聚合校验会向下委托页面和控件的完整规则 | // 工程聚合校验会向下委托页面和控件的完整规则 | ||||
| if (!page.validate(error)) | |||||
| if (!page.validate(limits, error)) | |||||
| { | { | ||||
| return false; | return false; | ||||
| } | } | ||||
| @@ -250,14 +337,43 @@ bool Project::validate(std::string *error) const | |||||
| } | } | ||||
| } | } | ||||
| } | } | ||||
| std::vector<HmiDataBinding> hmi_bindings; | |||||
| for (const HmiPage &page : hmiPages) | |||||
| { | |||||
| for (const HmiControl &control : page.controls) | |||||
| { | |||||
| if (isNumericControl(control) && control.binding.has_value() | |||||
| && control.binding->area() == RegisterArea::D) | |||||
| { | |||||
| hmi_bindings.push_back({*control.binding, control.dataType, control.id}); | |||||
| } | |||||
| } | |||||
| } | |||||
| for (auto current = hmi_bindings.cbegin(); current != hmi_bindings.cend(); ++current) | |||||
| { | |||||
| for (auto other = current + 1; other != hmi_bindings.cend(); ++other) | |||||
| { | |||||
| const bool samePoint = current->address == other->address | |||||
| && current->type == other->type; | |||||
| if (rangesIntersect(*current, *other) && !samePoint) | |||||
| { | |||||
| setError( | |||||
| error, | |||||
| "HMI 数值控件 " + current->controlId + " 与 " | |||||
| + other->controlId + " 的 D 占用范围重叠或类型冲突"); | |||||
| return false; | |||||
| } | |||||
| } | |||||
| } | |||||
| for (const ControlLogic &logic : controlLogics) | for (const ControlLogic &logic : controlLogics) | ||||
| { | { | ||||
| if (!logic.validate(error)) | |||||
| if (!logic.validate(limits, error)) | |||||
| { | { | ||||
| return false; | return false; | ||||
| } | } | ||||
| } | } | ||||
| // 同时收集所有显式 M/D 引用,提前检查 PLC 轮询集合不会超过上限 | |||||
| std::vector<RegisterAddress> poll_addresses; | std::vector<RegisterAddress> poll_addresses; | ||||
| for (const HmiPage &page : hmiPages) | for (const HmiPage &page : hmiPages) | ||||
| { | { | ||||
| @@ -266,6 +382,13 @@ bool Project::validate(std::string *error) const | |||||
| if (control.binding.has_value()) | if (control.binding.has_value()) | ||||
| { | { | ||||
| poll_addresses.push_back(*control.binding); | poll_addresses.push_back(*control.binding); | ||||
| if (isNumericControl(control) | |||||
| && control.dataType == RegisterDataType::Float32 | |||||
| && control.binding->area() == RegisterArea::D) | |||||
| { | |||||
| poll_addresses.push_back(RegisterAddress{ | |||||
| RegisterArea::D, control.binding->index() + 1}); | |||||
| } | |||||
| } | } | ||||
| } | } | ||||
| } | } | ||||
| @@ -289,6 +412,26 @@ bool Project::validate(std::string *error) const | |||||
| for (const LogicNode *node : nodes) | for (const LogicNode *node : nodes) | ||||
| { | { | ||||
| collectRegisterAddressesForLogicNode(node->config, &poll_addresses); | collectRegisterAddressesForLogicNode(node->config, &poll_addresses); | ||||
| RegisterAddress destination{RegisterArea::D, 0}; | |||||
| if (node->configured | |||||
| && isFloatWriteDestination(node->config, &destination)) | |||||
| { | |||||
| for (const HmiDataBinding &binding : hmi_bindings) | |||||
| { | |||||
| if (binding.type == RegisterDataType::Float32 | |||||
| && binding.address.area() == RegisterArea::D | |||||
| && destination.index() >= binding.address.index() | |||||
| && destination.index() | |||||
| < binding.address.index() + 2) | |||||
| { | |||||
| setError( | |||||
| error, | |||||
| "普通整数指令不能写入 Float32 占用的 D" | |||||
| + std::to_string(destination.index())); | |||||
| return false; | |||||
| } | |||||
| } | |||||
| } | |||||
| } | } | ||||
| } | } | ||||
| } | } | ||||
| @@ -305,7 +448,7 @@ bool Project::validate(std::string *error) const | |||||
| poll_addresses.end()); | poll_addresses.end()); | ||||
| if (poll_addresses.size() > ProjectLimits::kMaximumPollAddresses) | if (poll_addresses.size() > ProjectLimits::kMaximumPollAddresses) | ||||
| { | { | ||||
| setError(error, "工程中需要 PLC 轮询的去重 M/D 地址最多为 1024 个"); | |||||
| setError(error, "工程中需要 PLC 轮询的去重 M/D 地址最多为 256 个"); | |||||
| return false; | return false; | ||||
| } | } | ||||
| return true; | return true; | ||||
| @@ -313,27 +456,30 @@ bool Project::validate(std::string *error) const | |||||
| bool Project::validateForRunning(std::string *error) const | bool Project::validateForRunning(std::string *error) const | ||||
| { | { | ||||
| if (!validate(error)) | |||||
| return validateForRunning(defaultProjectLimitSettings(), error); | |||||
| } | |||||
| bool Project::validateForRunning( | |||||
| const ProjectLimitSettings &limits, std::string *error) const | |||||
| { | |||||
| // 运行校验建立在可保存校验之上,再额外拒绝未配置的运行对象 | |||||
| if (!validate(limits, error)) | |||||
| { | { | ||||
| return false; | return false; | ||||
| } | } | ||||
| for (const HmiPage &page : hmiPages) | for (const HmiPage &page : hmiPages) | ||||
| { | { | ||||
| if (!page.validateForRunning(error)) | |||||
| if (!page.validateForRunning(limits, error)) | |||||
| { | { | ||||
| return false; | return false; | ||||
| } | } | ||||
| } | } | ||||
| for (const ControlLogic &logic : controlLogics) | for (const ControlLogic &logic : controlLogics) | ||||
| { | { | ||||
| if (logic.enabled && !logic.validateForRunning(error)) | |||||
| if (logic.enabled && !logic.validateForRunning(limits, error)) | |||||
| { | { | ||||
| return false; | return false; | ||||
| } | } | ||||
| } | } | ||||
| if (!validateLogicResourceReferencesForRunning(controlLogics, error)) | |||||
| { | |||||
| return false; | |||||
| } | |||||
| return true; | return true; | ||||
| } | } | ||||
| @@ -3,15 +3,18 @@ | |||||
| #include "alarm_model.h" | #include "alarm_model.h" | ||||
| #include "control_logic_model.h" | #include "control_logic_model.h" | ||||
| #include "hmi_model.h" | #include "hmi_model.h" | ||||
| #include "project_limits.h" | |||||
| #include <string> | #include <string> | ||||
| #include <vector> | #include <vector> | ||||
| // 工程级 M/D 注释,只描述地址含义,不保存实时值 | |||||
| struct RegisterComment | struct RegisterComment | ||||
| { | { | ||||
| RegisterAddress address{RegisterArea::M, 0}; | RegisterAddress address{RegisterArea::M, 0}; | ||||
| std::string text; | std::string text; | ||||
| // 校验地址有效性和 UTF-8 文本长度 | |||||
| bool validate(std::string *error = nullptr) const; | bool validate(std::string *error = nullptr) const; | ||||
| }; | }; | ||||
| @@ -26,7 +29,7 @@ struct ProjectMetadata | |||||
| std::string formatVersion = "1.0"; | std::string formatVersion = "1.0"; | ||||
| }; | }; | ||||
| // 聚合工程中的 HMI 页面和控制逻辑 | |||||
| // 聚合工程中的 HMI 页面、报警、寄存器注释和控制逻辑 | |||||
| struct Project | struct Project | ||||
| { | { | ||||
| // 工程基本信息 | // 工程基本信息 | ||||
| @@ -42,8 +45,16 @@ struct Project | |||||
| // 工程包含的控制逻辑集合 | // 工程包含的控制逻辑集合 | ||||
| std::vector<ControlLogic> controlLogics; | std::vector<ControlLogic> controlLogics; | ||||
| // 按地址查找工程注释,找不到时返回 nullptr | |||||
| const RegisterComment *findRegisterComment(const RegisterAddress &address) const; | const RegisterComment *findRegisterComment(const RegisterAddress &address) const; | ||||
| // 校验工程配置并通过 error 返回失败原因 | |||||
| // 编辑态校验:允许尚未绑定完成的草稿 | |||||
| bool validate(std::string *error = nullptr) const; | bool validate(std::string *error = nullptr) const; | ||||
| bool validate( | |||||
| const ProjectLimitSettings &limits, | |||||
| std::string *error = nullptr) const; | |||||
| // 运行态校验:额外要求所有运行所需对象都已配置 | |||||
| bool validateForRunning(std::string *error = nullptr) const; | bool validateForRunning(std::string *error = nullptr) const; | ||||
| bool validateForRunning( | |||||
| const ProjectLimitSettings &limits, | |||||
| std::string *error = nullptr) const; | |||||
| }; | }; | ||||
| @@ -2,6 +2,7 @@ | |||||
| #include <string> | #include <string> | ||||
| // 解析用户输入的 M/D 地址时可能出现的失败原因 | |||||
| enum class RegisterAddressParseError | enum class RegisterAddressParseError | ||||
| { | { | ||||
| None, | None, | ||||
| @@ -11,23 +12,30 @@ enum class RegisterAddressParseError | |||||
| OutOfRange | OutOfRange | ||||
| }; | }; | ||||
| // PLC 中支持的寄存器区域:M 是位,D 是 16 位有符号字 | |||||
| enum class RegisterArea | enum class RegisterArea | ||||
| { | { | ||||
| M, | M, | ||||
| D | D | ||||
| }; | }; | ||||
| // 表示一个已经拆分为“区域 + 下标”的 M/D 地址 | |||||
| // 下标统一使用 0~4000,和 Modbus 原始地址保持一致 | |||||
| class RegisterAddress | class RegisterAddress | ||||
| { | { | ||||
| public: | public: | ||||
| static constexpr int kMinimumIndex = 0; | static constexpr int kMinimumIndex = 0; | ||||
| static constexpr int kMaximumIndex = 4000; | static constexpr int kMaximumIndex = 4000; | ||||
| // 构造地址对象;非法下标不会在构造时抛异常,应通过 isValid() 检查 | |||||
| RegisterAddress(RegisterArea area, int index); | RegisterAddress(RegisterArea area, int index); | ||||
| // 返回地址区域和数字下标 | |||||
| RegisterArea area() const; | RegisterArea area() const; | ||||
| int index() const; | int index() const; | ||||
| // 判断区域和下标是否满足项目边界 | |||||
| bool isValid() const; | bool isValid() const; | ||||
| // 转成界面和日志中使用的形式,例如 M10 或 D4000 | |||||
| std::string toString() const; | std::string toString() const; | ||||
| bool operator==(const RegisterAddress &other) const; | bool operator==(const RegisterAddress &other) const; | ||||
| @@ -38,6 +46,7 @@ private: | |||||
| int index_; | int index_; | ||||
| }; | }; | ||||
| // 文本地址解析结果;失败时 address 保持默认值,具体原因见 error | |||||
| struct RegisterAddressParseResult | struct RegisterAddressParseResult | ||||
| { | { | ||||
| bool succeeded = false; | bool succeeded = false; | ||||
| @@ -45,4 +54,5 @@ struct RegisterAddressParseResult | |||||
| RegisterAddressParseError error = RegisterAddressParseError::InvalidFormat; | RegisterAddressParseError error = RegisterAddressParseError::InvalidFormat; | ||||
| }; | }; | ||||
| // 解析 M0、D4000 这类地址文本,不接受带偏移的 Modbus 地址 | |||||
| RegisterAddressParseResult parseRegisterAddress(const std::string &text); | RegisterAddressParseResult parseRegisterAddress(const std::string &text); | ||||
| @@ -4,37 +4,80 @@ | |||||
| const std::vector<RegisterAddress> &RegisterMonitorModel::addresses() const | const std::vector<RegisterAddress> &RegisterMonitorModel::addresses() const | ||||
| { | { | ||||
| addresses_.clear(); | |||||
| addresses_.reserve(points_.size()); | |||||
| for (const MonitorPoint &point : points_) | |||||
| { | |||||
| addresses_.push_back(point.address); | |||||
| } | |||||
| return addresses_; | return addresses_; | ||||
| } | } | ||||
| bool RegisterMonitorModel::contains(const RegisterAddress &address) const | |||||
| const std::vector<MonitorPoint> &RegisterMonitorModel::points() const | |||||
| { | { | ||||
| return std::find(addresses_.begin(), addresses_.end(), address) != addresses_.end(); | |||||
| return points_; | |||||
| } | } | ||||
| bool RegisterMonitorModel::add(const RegisterAddress &address) | |||||
| bool RegisterMonitorModel::contains(const MonitorPoint &point) const | |||||
| { | { | ||||
| if (!address.isValid() || contains(address) | |||||
| || addresses_.size() >= kMaximumItemCount) | |||||
| return std::find_if( | |||||
| points_.cbegin(), points_.cend(), [&point](const MonitorPoint &candidate) | |||||
| { | |||||
| return candidate.address == point.address | |||||
| && candidate.dataType == point.dataType; | |||||
| }) != points_.cend(); | |||||
| } | |||||
| bool RegisterMonitorModel::add(const MonitorPoint &point) | |||||
| { | |||||
| const int end = point.address.index() | |||||
| + registerDataTypeWordCount(point.dataType) - 1; | |||||
| if (!point.address.isValid() | |||||
| || (point.dataType == RegisterDataType::Float32 | |||||
| && point.address.area() != RegisterArea::D) | |||||
| || end > RegisterAddress::kMaximumIndex | |||||
| || contains(point) | |||||
| || points_.size() >= kMaximumItemCount) | |||||
| { | { | ||||
| return false; | return false; | ||||
| } | } | ||||
| addresses_.push_back(address); | |||||
| points_.push_back(point); | |||||
| return true; | return true; | ||||
| } | } | ||||
| bool RegisterMonitorModel::remove(const RegisterAddress &address) | bool RegisterMonitorModel::remove(const RegisterAddress &address) | ||||
| { | { | ||||
| const auto position = std::find(addresses_.begin(), addresses_.end(), address); | |||||
| if (position == addresses_.end()) | |||||
| const auto position = std::find_if( | |||||
| points_.begin(), points_.end(), [&address](const MonitorPoint &point) | |||||
| { | |||||
| return point.address == address; | |||||
| }); | |||||
| if (position == points_.end()) | |||||
| { | |||||
| return false; | |||||
| } | |||||
| points_.erase(position); | |||||
| return true; | |||||
| } | |||||
| bool RegisterMonitorModel::remove(const MonitorPoint &point) | |||||
| { | |||||
| const auto position = std::find_if( | |||||
| points_.begin(), points_.end(), [&point](const MonitorPoint &candidate) | |||||
| { | |||||
| return candidate.address == point.address | |||||
| && candidate.dataType == point.dataType; | |||||
| }); | |||||
| if (position == points_.end()) | |||||
| { | { | ||||
| return false; | return false; | ||||
| } | } | ||||
| addresses_.erase(position); | |||||
| points_.erase(position); | |||||
| return true; | return true; | ||||
| } | } | ||||
| void RegisterMonitorModel::clear() | void RegisterMonitorModel::clear() | ||||
| { | { | ||||
| addresses_.clear(); | addresses_.clear(); | ||||
| points_.clear(); | |||||
| } | } | ||||
| @@ -1,11 +1,13 @@ | |||||
| #pragma once | #pragma once | ||||
| #include "register_address.h" | #include "register_address.h" | ||||
| #include "register_value_type.h" | |||||
| #include <cstddef> | #include <cstddef> | ||||
| #include <cstdint> | #include <cstdint> | ||||
| #include <vector> | #include <vector> | ||||
| // 监控读数的可用状态 | |||||
| enum class MonitorValueState | enum class MonitorValueState | ||||
| { | { | ||||
| Valid, | Valid, | ||||
| @@ -13,25 +15,45 @@ enum class MonitorValueState | |||||
| CommunicationFault | CommunicationFault | ||||
| }; | }; | ||||
| // 一个监控行的地址、可用性和当前值 | |||||
| struct MonitorValue | struct MonitorValue | ||||
| { | { | ||||
| RegisterAddress address{RegisterArea::M, 0}; | RegisterAddress address{RegisterArea::M, 0}; | ||||
| RegisterDataType dataType = RegisterDataType::Int16; | |||||
| int endAddress = 0; | |||||
| MonitorValueState state = MonitorValueState::Unavailable; | MonitorValueState state = MonitorValueState::Unavailable; | ||||
| bool bitValue = false; | bool bitValue = false; | ||||
| std::int16_t wordValue = 0; | std::int16_t wordValue = 0; | ||||
| float floatValue = 0.0f; | |||||
| }; | }; | ||||
| struct MonitorPoint | |||||
| { | |||||
| RegisterAddress address{RegisterArea::M, 0}; | |||||
| RegisterDataType dataType = RegisterDataType::Int16; | |||||
| }; | |||||
| // 运行期间由用户临时维护的监控地址列表,不写入工程文件 | |||||
| class RegisterMonitorModel | class RegisterMonitorModel | ||||
| { | { | ||||
| public: | public: | ||||
| static constexpr std::size_t kMaximumItemCount = 64U; | static constexpr std::size_t kMaximumItemCount = 64U; | ||||
| // 返回当前按添加顺序保存的地址 | |||||
| const std::vector<RegisterAddress> &addresses() const; | const std::vector<RegisterAddress> &addresses() const; | ||||
| bool contains(const RegisterAddress &address) const; | |||||
| bool add(const RegisterAddress &address); | |||||
| const std::vector<MonitorPoint> &points() const; | |||||
| // 判断地址是否已经在监控列表中 | |||||
| bool contains(const MonitorPoint &point) const; | |||||
| // 添加一个地址;重复或超过上限时返回 false | |||||
| bool add(const MonitorPoint &point); | |||||
| // 删除一个地址;地址不存在时返回 false | |||||
| bool remove(const RegisterAddress &address); | bool remove(const RegisterAddress &address); | ||||
| // 删除一个精确的监控点;地址相同但类型不同的点互不影响 | |||||
| bool remove(const MonitorPoint &point); | |||||
| // 清空当前会话的监控列表 | |||||
| void clear(); | void clear(); | ||||
| private: | private: | ||||
| std::vector<RegisterAddress> addresses_; | |||||
| mutable std::vector<RegisterAddress> addresses_; | |||||
| std::vector<MonitorPoint> points_; | |||||
| }; | }; | ||||
| @@ -2,11 +2,10 @@ | |||||
| #include "register_address.h" | #include "register_address.h" | ||||
| #include <array> | |||||
| #include <cstddef> | |||||
| #include <cstdint> | #include <cstdint> | ||||
| #include <array> | |||||
| // 寄存器仓库操作结果 | |||||
| // 寄存器仓库操作失败原因;服务层会把它转换成用户可读消息 | |||||
| enum class RegisterError | enum class RegisterError | ||||
| { | { | ||||
| None, // 操作成功 无错误 | None, // 操作成功 无错误 | ||||
| @@ -39,45 +38,56 @@ struct RegisterWriteResult | |||||
| RegisterError error = RegisterError::Unavailable; // 写入失败原因 | RegisterError error = RegisterError::Unavailable; // 写入失败原因 | ||||
| }; | }; | ||||
| struct WordPairReadResult | |||||
| { | |||||
| bool succeeded = false; | |||||
| std::array<std::int16_t, 2> values{}; | |||||
| RegisterError error = RegisterError::Unavailable; | |||||
| }; | |||||
| // M/D 寄存器访问边界 | // M/D 寄存器访问边界 | ||||
| // 所有运行态读写都必须经过此接口,UI 不直接接触串口或缓存实现 | |||||
| class RegisterRepository | class RegisterRepository | ||||
| { | { | ||||
| public: | public: | ||||
| virtual ~RegisterRepository() = default; | virtual ~RegisterRepository() = default; | ||||
| // 读取 M 区位寄存器 | |||||
| // 读取 M 区位寄存器;传入 D 区地址会返回 AreaMismatch | |||||
| virtual BitReadResult readBit(const RegisterAddress &address) const = 0; | virtual BitReadResult readBit(const RegisterAddress &address) const = 0; | ||||
| // 写入 M 区位寄存器 | |||||
| // 写入 M 区位寄存器;真机实现不会在收到 PLC 确认前乐观修改缓存 | |||||
| virtual RegisterWriteResult writeBit(const RegisterAddress &address, bool value) = 0; | virtual RegisterWriteResult writeBit(const RegisterAddress &address, bool value) = 0; | ||||
| // 读取 D 区字寄存器 | |||||
| // 读取 D 区 16 位字寄存器 | |||||
| virtual WordReadResult readWord(const RegisterAddress &address) const = 0; | virtual WordReadResult readWord(const RegisterAddress &address) const = 0; | ||||
| // 写入 D 区字寄存器 | |||||
| // 写入 D 区 16 位字寄存器 | |||||
| virtual RegisterWriteResult writeWord( | virtual RegisterWriteResult writeWord( | ||||
| const RegisterAddress &address, std::int16_t value) = 0; | const RegisterAddress &address, std::int16_t value) = 0; | ||||
| }; | |||||
| // 基于内存的离线 M/D 寄存器仓库 | |||||
| class VirtualRegisterRepository : public RegisterRepository | |||||
| { | |||||
| public: | |||||
| VirtualRegisterRepository(); | |||||
| // 读取内存中的 M 区位值 | |||||
| BitReadResult readBit(const RegisterAddress &address) const override; | |||||
| // 更新内存中的 M 区位值 | |||||
| RegisterWriteResult writeBit(const RegisterAddress &address, bool value) override; | |||||
| // 读取内存中的 D 区字值 | |||||
| WordReadResult readWord(const RegisterAddress &address) const override; | |||||
| // 更新内存中的 D 区字值 | |||||
| RegisterWriteResult writeWord( | |||||
| const RegisterAddress &address, std::int16_t value) override; | |||||
| // 将所有虚拟寄存器恢复为默认值 | |||||
| void clear(); | |||||
| private: | |||||
| static constexpr std::size_t kRegisterCount = | |||||
| static_cast<std::size_t>(RegisterAddress::kMaximumIndex + 1); | |||||
| std::array<bool, kRegisterCount> bits_{}; // M 区位寄存器内存 | |||||
| std::array<std::int16_t, kRegisterCount> words_{}; // D 区字寄存器内存 | |||||
| virtual WordPairReadResult readWordPair(const RegisterAddress &address) const | |||||
| { | |||||
| if (!address.isValid() || address.area() != RegisterArea::D | |||||
| || address.index() >= RegisterAddress::kMaximumIndex) | |||||
| { | |||||
| return {false, {}, address.isValid() && address.area() != RegisterArea::D | |||||
| ? RegisterError::AreaMismatch : RegisterError::InvalidAddress}; | |||||
| } | |||||
| const WordReadResult low = readWord(address); | |||||
| const WordReadResult high = readWord( | |||||
| RegisterAddress{RegisterArea::D, address.index() + 1}); | |||||
| if (!low.succeeded) return {false, {}, low.error}; | |||||
| if (!high.succeeded) return {false, {}, high.error}; | |||||
| return {true, {low.value, high.value}, RegisterError::None}; | |||||
| } | |||||
| virtual RegisterWriteResult writeWordPair( | |||||
| const RegisterAddress &address, | |||||
| const std::array<std::int16_t, 2> &values) | |||||
| { | |||||
| if (!address.isValid() || address.area() != RegisterArea::D | |||||
| || address.index() >= RegisterAddress::kMaximumIndex) | |||||
| { | |||||
| return {false, address.isValid() && address.area() != RegisterArea::D | |||||
| ? RegisterError::AreaMismatch : RegisterError::InvalidAddress}; | |||||
| } | |||||
| const RegisterWriteResult low = writeWord(address, values[0]); | |||||
| if (!low.succeeded) return low; | |||||
| return writeWord(RegisterAddress{RegisterArea::D, address.index() + 1}, values[1]); | |||||
| } | |||||
| }; | }; | ||||
| @@ -0,0 +1,51 @@ | |||||
| #include "register_value_type.h" | |||||
| #include <cmath> | |||||
| #include <cstring> | |||||
| const char *registerDataTypeName(RegisterDataType type) | |||||
| { | |||||
| return type == RegisterDataType::Float32 ? "float32" : "int16"; | |||||
| } | |||||
| bool parseRegisterDataType(const std::string &text, RegisterDataType *type) | |||||
| { | |||||
| if (type == nullptr) | |||||
| { | |||||
| return false; | |||||
| } | |||||
| if (text == "int16") | |||||
| { | |||||
| *type = RegisterDataType::Int16; | |||||
| return true; | |||||
| } | |||||
| if (text == "float32") | |||||
| { | |||||
| *type = RegisterDataType::Float32; | |||||
| return true; | |||||
| } | |||||
| return false; | |||||
| } | |||||
| int registerDataTypeWordCount(RegisterDataType type) | |||||
| { | |||||
| return type == RegisterDataType::Float32 ? 2 : 1; | |||||
| } | |||||
| std::array<std::int16_t, 2> Float32Codec::encode(float value) | |||||
| { | |||||
| std::uint32_t bits = 0; | |||||
| std::memcpy(&bits, &value, sizeof(bits)); | |||||
| return {static_cast<std::int16_t>(bits & 0xffffU), | |||||
| static_cast<std::int16_t>(bits >> 16U)}; | |||||
| } | |||||
| std::optional<float> Float32Codec::decode( | |||||
| std::int16_t low_word, std::int16_t high_word) | |||||
| { | |||||
| const std::uint32_t bits = static_cast<std::uint16_t>(low_word) | |||||
| | (static_cast<std::uint32_t>(static_cast<std::uint16_t>(high_word)) << 16U); | |||||
| float value = 0.0f; | |||||
| std::memcpy(&value, &bits, sizeof(value)); | |||||
| return std::isfinite(value) ? std::optional<float>(value) : std::nullopt; | |||||
| } | |||||
| @@ -0,0 +1,22 @@ | |||||
| #pragma once | |||||
| #include <array> | |||||
| #include <cstdint> | |||||
| #include <optional> | |||||
| #include <string> | |||||
| enum class RegisterDataType | |||||
| { | |||||
| Int16, | |||||
| Float32 | |||||
| }; | |||||
| const char *registerDataTypeName(RegisterDataType type); | |||||
| bool parseRegisterDataType(const std::string &text, RegisterDataType *type); | |||||
| int registerDataTypeWordCount(RegisterDataType type); | |||||
| struct Float32Codec | |||||
| { | |||||
| static std::array<std::int16_t, 2> encode(float value); | |||||
| static std::optional<float> decode(std::int16_t low_word, std::int16_t high_word); | |||||
| }; | |||||
| @@ -1,6 +1,6 @@ | |||||
| #pragma once | #pragma once | ||||
| // 应用当前运行模式 | |||||
| // 应用当前运行模式;三个状态之间不能直接从离线切到真机 | |||||
| enum class ApplicationMode | enum class ApplicationMode | ||||
| { | { | ||||
| Editing, // 编辑工程状态 | Editing, // 编辑工程状态 | ||||
| @@ -8,7 +8,7 @@ enum class ApplicationMode | |||||
| OnlineRunning // 真机联机运行状态 | OnlineRunning // 真机联机运行状态 | ||||
| }; | }; | ||||
| // 当前运行模式对应的能力策略 | |||||
| // 当前运行模式对应的能力策略,UI 用它决定哪些入口可用 | |||||
| struct ModePolicy | struct ModePolicy | ||||
| { | { | ||||
| // 是否允许编辑工程 | // 是否允许编辑工程 | ||||
| @@ -23,7 +23,7 @@ struct ModePolicy | |||||
| bool requiresInitialPlcRead = false; | bool requiresInitialPlcRead = false; | ||||
| }; | }; | ||||
| // 根据运行模式返回对应的能力策略 | |||||
| // 根据运行模式返回对应的能力策略;default 用于防御非法枚举值 | |||||
| constexpr ModePolicy policyForMode(ApplicationMode mode) | constexpr ModePolicy policyForMode(ApplicationMode mode) | ||||
| { | { | ||||
| switch (mode) | switch (mode) | ||||
| @@ -52,12 +52,12 @@ constexpr ModePolicy policyForMode(ApplicationMode mode) | |||||
| } | } | ||||
| case ApplicationMode::OnlineRunning: | case ApplicationMode::OnlineRunning: | ||||
| { | { | ||||
| // 真机运行态禁止改工程,使用 PLC 数据且禁止软件逻辑重复控制 PLC | |||||
| // 真机运行态使用 PLC 数据,并在本地只读推算梯形图轨迹 | |||||
| return { | return { | ||||
| false, // allowsProjectEditing 禁止编辑工程 | false, // allowsProjectEditing 禁止编辑工程 | ||||
| false, // usesVirtualRegisters 不使用虚拟寄存器 | false, // usesVirtualRegisters 不使用虚拟寄存器 | ||||
| true, // usesPlcRegisters 使用 PLC 寄存器 | true, // usesPlcRegisters 使用 PLC 寄存器 | ||||
| false, // runsLogicExecutor 不运行软件逻辑执行器 | |||||
| true, // runsLogicExecutor 运行只读本地轨迹执行器 | |||||
| true // requiresInitialPlcRead 必须先完成 PLC 初次读取 | true // requiresInitialPlcRead 必须先完成 PLC 初次读取 | ||||
| }; | }; | ||||
| } | } | ||||
| @@ -68,7 +68,7 @@ constexpr ModePolicy policyForMode(ApplicationMode mode) | |||||
| } | } | ||||
| } | } | ||||
| // 模式切换失败原因 | |||||
| // 模式切换失败原因,服务层会进一步补充可读消息 | |||||
| enum class ModeTransitionError | enum class ModeTransitionError | ||||
| { | { | ||||
| None, // 切换成功 | None, // 切换成功 | ||||
| @@ -76,7 +76,7 @@ enum class ModeTransitionError | |||||
| MustReturnToEditing, // 必须先返回编辑态 | MustReturnToEditing, // 必须先返回编辑态 | ||||
| InitialPlcReadRequired, // 进入真机态前必须完成 PLC 初次读取 | InitialPlcReadRequired, // 进入真机态前必须完成 PLC 初次读取 | ||||
| ProjectNotReady, // 工程存在未配置或未完成的运行项 | ProjectNotReady, // 工程存在未配置或未完成的运行项 | ||||
| SimulationStartFailed // 离线执行器预检或启动失败 | |||||
| SimulationStartFailed // 当前模式的本地执行器预检或启动失败 | |||||
| }; | }; | ||||
| // 模式切换操作结果 | // 模式切换操作结果 | ||||
| @@ -88,6 +88,7 @@ struct ModeTransitionResult | |||||
| ModeTransitionError error = ModeTransitionError::None; | ModeTransitionError error = ModeTransitionError::None; | ||||
| }; | }; | ||||
| // 保存当前模式并执行最基本的状态机约束 | |||||
| class RuntimeState | class RuntimeState | ||||
| { | { | ||||
| public: | public: | ||||
| @@ -1,4 +1,4 @@ | |||||
| #include "register_repository.h" | |||||
| #include "virtual_register_repository.h" | |||||
| VirtualRegisterRepository::VirtualRegisterRepository() | VirtualRegisterRepository::VirtualRegisterRepository() | ||||
| { | { | ||||
| @@ -66,9 +66,28 @@ RegisterWriteResult VirtualRegisterRepository::writeWord( | |||||
| return {true, RegisterError::None}; | return {true, RegisterError::None}; | ||||
| } | } | ||||
| WordPairReadResult VirtualRegisterRepository::readWordPair( | |||||
| const RegisterAddress &address) const | |||||
| { | |||||
| return RegisterRepository::readWordPair(address); | |||||
| } | |||||
| RegisterWriteResult VirtualRegisterRepository::writeWordPair( | |||||
| const RegisterAddress &address, | |||||
| const std::array<std::int16_t, 2> &values) | |||||
| { | |||||
| return RegisterRepository::writeWordPair(address, values); | |||||
| } | |||||
| void VirtualRegisterRepository::clear() | void VirtualRegisterRepository::clear() | ||||
| { | { | ||||
| // 离线运行复位时同时清空 M 区和 D 区 | // 离线运行复位时同时清空 M 区和 D 区 | ||||
| bits_.fill(false); | bits_.fill(false); | ||||
| words_.fill(0); | words_.fill(0); | ||||
| } | } | ||||
| void VirtualRegisterRepository::copyFrom(const VirtualRegisterRepository &source) | |||||
| { | |||||
| bits_ = source.bits_; | |||||
| words_ = source.words_; | |||||
| } | |||||
| @@ -0,0 +1,38 @@ | |||||
| #pragma once | |||||
| #include "register_repository.h" | |||||
| #include <array> | |||||
| #include <cstddef> | |||||
| // 基于内存的离线 M/D 寄存器仓库;地址范围与真实 PLC 保持一致 | |||||
| class VirtualRegisterRepository : public RegisterRepository | |||||
| { | |||||
| public: | |||||
| VirtualRegisterRepository(); | |||||
| // 读取内存中的 M 区位值 | |||||
| BitReadResult readBit(const RegisterAddress &address) const override; | |||||
| // 更新内存中的 M 区位值 | |||||
| RegisterWriteResult writeBit(const RegisterAddress &address, bool value) override; | |||||
| // 读取内存中的 D 区字值 | |||||
| WordReadResult readWord(const RegisterAddress &address) const override; | |||||
| // 更新内存中的 D 区字值 | |||||
| RegisterWriteResult writeWord( | |||||
| const RegisterAddress &address, std::int16_t value) override; | |||||
| WordPairReadResult readWordPair(const RegisterAddress &address) const override; | |||||
| RegisterWriteResult writeWordPair( | |||||
| const RegisterAddress &address, | |||||
| const std::array<std::int16_t, 2> &values) override; | |||||
| // 将所有虚拟寄存器恢复为默认值,开始新的离线会话 | |||||
| void clear(); | |||||
| // 从另一份虚拟寄存器复制完整的 M/D 快照 | |||||
| void copyFrom(const VirtualRegisterRepository &source); | |||||
| private: | |||||
| static constexpr std::size_t kRegisterCount = | |||||
| static_cast<std::size_t>(RegisterAddress::kMaximumIndex + 1); | |||||
| std::array<bool, kRegisterCount> bits_{}; // M 区位寄存器内存 | |||||
| std::array<std::int16_t, kRegisterCount> words_{}; // D 区字寄存器内存 | |||||
| }; | |||||
| @@ -0,0 +1,509 @@ | |||||
| #include "application_settings_loader.h" | |||||
| #include "domain/project_limits.h" | |||||
| #include <QCoreApplication> | |||||
| #include <QDir> | |||||
| #include <QFile> | |||||
| #include <QFileInfo> | |||||
| #include <QMap> | |||||
| #include <QSaveFile> | |||||
| #include <QSet> | |||||
| #include <QStringList> | |||||
| #include <QTextCodec> | |||||
| #include <initializer_list> | |||||
| namespace { | |||||
| constexpr int kCurrentConfigurationVersion = 1; | |||||
| struct ParsedEntry | |||||
| { | |||||
| QString value; | |||||
| int line = 0; | |||||
| }; | |||||
| std::string toUtf8(const QString &value) | |||||
| { | |||||
| const QByteArray bytes = value.toUtf8(); | |||||
| return std::string(bytes.constData(), static_cast<std::size_t>(bytes.size())); | |||||
| } | |||||
| QString defaultFileContents() | |||||
| { | |||||
| return QStringLiteral( | |||||
| "[Config]\n" | |||||
| "Version=1\n" | |||||
| "\n" | |||||
| "[ProjectLimits]\n" | |||||
| "MaxHmiPages=32\n" | |||||
| "MaxHmiControlsPerPage=128\n" | |||||
| "MaxAlarmDefinitions=256\n" | |||||
| "MaxControlLogics=32\n" | |||||
| "MaxRungsPerLogic=256\n" | |||||
| "MaxOutputMessages=1000\n" | |||||
| "\n" | |||||
| "[HmiDefaults]\n" | |||||
| "DefaultPageWidth=800\n" | |||||
| "DefaultPageHeight=400\n" | |||||
| "\n" | |||||
| "[PlcDefaults]\n" | |||||
| "PortName=COM3\n" | |||||
| "ServerAddress=1\n" | |||||
| "BaudRate=9600\n" | |||||
| "DataBits=8\n" | |||||
| "Parity=2\n" | |||||
| "StopBits=1\n"); | |||||
| } | |||||
| ApplicationSettingsLoadResult failureResult( | |||||
| const QString &file_path, const QString &reason) | |||||
| { | |||||
| ApplicationSettingsLoadResult result; | |||||
| result.warningRequired = true; | |||||
| result.warningMessage = toUtf8( | |||||
| QStringLiteral("应用配置无法使用,本次已全部采用代码默认值。\n%1") | |||||
| .arg(reason)); | |||||
| result.messages.push_back(toUtf8( | |||||
| QStringLiteral("应用配置错误:%1(%2)") | |||||
| .arg(reason, QDir::toNativeSeparators(file_path)))); | |||||
| result.messages.push_back( | |||||
| "应用配置:本次运行全部使用代码默认值"); | |||||
| return result; | |||||
| } | |||||
| bool containsValue(std::initializer_list<int> values, int value) | |||||
| { | |||||
| for (int candidate : values) | |||||
| { | |||||
| if (candidate == value) | |||||
| { | |||||
| return true; | |||||
| } | |||||
| } | |||||
| return false; | |||||
| } | |||||
| bool readInteger( | |||||
| const QMap<QString, ParsedEntry> &entries, | |||||
| const QString &path, | |||||
| qint64 minimum, | |||||
| qint64 maximum, | |||||
| qint64 default_value, | |||||
| qint64 *value, | |||||
| QStringList *notices, | |||||
| QStringList *errors, | |||||
| bool required = false) | |||||
| { | |||||
| const auto entry = entries.constFind(path); | |||||
| if (entry == entries.cend()) | |||||
| { | |||||
| if (required) | |||||
| { | |||||
| errors->append(QStringLiteral("缺少必填字段 %1").arg(path)); | |||||
| } | |||||
| else | |||||
| { | |||||
| notices->append( | |||||
| QStringLiteral("配置字段 %1 缺失,使用代码默认值 %2") | |||||
| .arg(path) | |||||
| .arg(default_value)); | |||||
| } | |||||
| *value = default_value; | |||||
| return !required; | |||||
| } | |||||
| bool converted = false; | |||||
| const qint64 parsed = entry->value.toLongLong(&converted, 10); | |||||
| if (!converted || parsed < minimum || parsed > maximum) | |||||
| { | |||||
| errors->append( | |||||
| QStringLiteral("第 %1 行字段 %2 的值“%3”无效,允许范围为 %4~%5") | |||||
| .arg(entry->line) | |||||
| .arg(path, entry->value) | |||||
| .arg(minimum) | |||||
| .arg(maximum)); | |||||
| return false; | |||||
| } | |||||
| *value = parsed; | |||||
| return true; | |||||
| } | |||||
| void addUnsupportedValueError( | |||||
| const QMap<QString, ParsedEntry> &entries, | |||||
| const QString &path, | |||||
| const QString &allowed, | |||||
| QStringList *errors) | |||||
| { | |||||
| const ParsedEntry &entry = entries[path]; | |||||
| errors->append( | |||||
| QStringLiteral("第 %1 行字段 %2 的值“%3”无效,只允许 %4") | |||||
| .arg(entry.line) | |||||
| .arg(path, entry.value, allowed)); | |||||
| } | |||||
| } // namespace | |||||
| QString ApplicationSettingsLoader::defaultFilePath() | |||||
| { | |||||
| return QDir(QCoreApplication::applicationDirPath()).filePath( | |||||
| QStringLiteral("config/application.ini")); | |||||
| } | |||||
| ApplicationSettingsLoadResult ApplicationSettingsLoader::load( | |||||
| const QString &file_path) | |||||
| { | |||||
| if (!QFileInfo::exists(file_path)) | |||||
| { | |||||
| const QString directory = QFileInfo(file_path).absolutePath(); | |||||
| if (!QDir().mkpath(directory)) | |||||
| { | |||||
| return failureResult( | |||||
| file_path, | |||||
| QStringLiteral("无法创建配置目录 %1,可能没有写入权限") | |||||
| .arg(QDir::toNativeSeparators(directory))); | |||||
| } | |||||
| QSaveFile file(file_path); | |||||
| if (!file.open(QIODevice::WriteOnly)) | |||||
| { | |||||
| return failureResult(file_path, file.errorString()); | |||||
| } | |||||
| const QByteArray data = defaultFileContents().toUtf8(); | |||||
| if (file.write(data) != data.size()) | |||||
| { | |||||
| const QString reason = file.errorString(); | |||||
| file.cancelWriting(); | |||||
| return failureResult(file_path, reason); | |||||
| } | |||||
| if (!file.commit()) | |||||
| { | |||||
| return failureResult(file_path, file.errorString()); | |||||
| } | |||||
| ApplicationSettingsLoadResult result; | |||||
| result.messages.push_back(toUtf8( | |||||
| QStringLiteral("应用配置:已创建默认配置文件 %1") | |||||
| .arg(QDir::toNativeSeparators(file_path)))); | |||||
| result.messages.push_back( | |||||
| "应用配置:首次运行使用代码默认值,修改后请重启程序"); | |||||
| return result; | |||||
| } | |||||
| QFile file(file_path); | |||||
| if (!file.open(QIODevice::ReadOnly)) | |||||
| { | |||||
| return failureResult(file_path, file.errorString()); | |||||
| } | |||||
| const QByteArray data = file.readAll(); | |||||
| if (file.error() != QFileDevice::NoError) | |||||
| { | |||||
| return failureResult(file_path, file.errorString()); | |||||
| } | |||||
| QTextCodec::ConverterState converter_state; | |||||
| const QString text = QTextCodec::codecForName("UTF-8")->toUnicode( | |||||
| data.constData(), data.size(), &converter_state); | |||||
| if (converter_state.invalidChars > 0) | |||||
| { | |||||
| return failureResult(file_path, QStringLiteral("配置文件不是有效的 UTF-8 文本")); | |||||
| } | |||||
| const QSet<QString> known_fields{ | |||||
| QStringLiteral("Config.Version"), | |||||
| QStringLiteral("ProjectLimits.MaxHmiPages"), | |||||
| QStringLiteral("ProjectLimits.MaxHmiControlsPerPage"), | |||||
| QStringLiteral("ProjectLimits.MaxAlarmDefinitions"), | |||||
| QStringLiteral("ProjectLimits.MaxControlLogics"), | |||||
| QStringLiteral("ProjectLimits.MaxRungsPerLogic"), | |||||
| QStringLiteral("ProjectLimits.MaxOutputMessages"), | |||||
| QStringLiteral("HmiDefaults.DefaultPageWidth"), | |||||
| QStringLiteral("HmiDefaults.DefaultPageHeight"), | |||||
| QStringLiteral("PlcDefaults.PortName"), | |||||
| QStringLiteral("PlcDefaults.ServerAddress"), | |||||
| QStringLiteral("PlcDefaults.BaudRate"), | |||||
| QStringLiteral("PlcDefaults.DataBits"), | |||||
| QStringLiteral("PlcDefaults.Parity"), | |||||
| QStringLiteral("PlcDefaults.StopBits")}; | |||||
| QMap<QString, ParsedEntry> entries; | |||||
| QStringList notices; | |||||
| QStringList errors; | |||||
| QString current_section; | |||||
| QString normalized_text = text; | |||||
| if (!normalized_text.isEmpty() | |||||
| && normalized_text.front() == QChar::ByteOrderMark) | |||||
| { | |||||
| normalized_text.remove(0, 1); | |||||
| } | |||||
| const QStringList lines = normalized_text.split(QLatin1Char('\n')); | |||||
| for (int index = 0; index < lines.size(); ++index) | |||||
| { | |||||
| QString line = lines.at(index); | |||||
| if (line.endsWith(QLatin1Char('\r'))) | |||||
| { | |||||
| line.chop(1); | |||||
| } | |||||
| const QString trimmed = line.trimmed(); | |||||
| const int line_number = index + 1; | |||||
| if (trimmed.isEmpty() || trimmed.startsWith(QLatin1Char(';')) | |||||
| || trimmed.startsWith(QLatin1Char('#'))) | |||||
| { | |||||
| continue; | |||||
| } | |||||
| if (trimmed.startsWith(QLatin1Char('['))) | |||||
| { | |||||
| if (!trimmed.endsWith(QLatin1Char(']')) || trimmed.size() < 3) | |||||
| { | |||||
| errors.append( | |||||
| QStringLiteral("第 %1 行分区格式无效:%2") | |||||
| .arg(line_number) | |||||
| .arg(trimmed)); | |||||
| current_section.clear(); | |||||
| continue; | |||||
| } | |||||
| current_section = trimmed.mid(1, trimmed.size() - 2).trimmed(); | |||||
| if (current_section.isEmpty()) | |||||
| { | |||||
| errors.append(QStringLiteral("第 %1 行分区名称不能为空").arg(line_number)); | |||||
| } | |||||
| continue; | |||||
| } | |||||
| const int separator = trimmed.indexOf(QLatin1Char('=')); | |||||
| if (separator <= 0 || current_section.isEmpty()) | |||||
| { | |||||
| errors.append( | |||||
| QStringLiteral("第 %1 行字段格式无效:%2") | |||||
| .arg(line_number) | |||||
| .arg(trimmed)); | |||||
| continue; | |||||
| } | |||||
| const QString key = trimmed.left(separator).trimmed(); | |||||
| const QString value = trimmed.mid(separator + 1).trimmed(); | |||||
| if (key.isEmpty()) | |||||
| { | |||||
| errors.append(QStringLiteral("第 %1 行字段名称不能为空").arg(line_number)); | |||||
| continue; | |||||
| } | |||||
| const QString path = current_section + QLatin1Char('.') + key; | |||||
| if (entries.contains(path)) | |||||
| { | |||||
| errors.append( | |||||
| QStringLiteral("第 %1 行字段 %2 重复,首次出现在第 %3 行") | |||||
| .arg(line_number) | |||||
| .arg(path) | |||||
| .arg(entries.value(path).line)); | |||||
| continue; | |||||
| } | |||||
| entries.insert(path, {value, line_number}); | |||||
| if (!known_fields.contains(path)) | |||||
| { | |||||
| notices.append( | |||||
| QStringLiteral("第 %1 行未知字段 %2 已忽略") | |||||
| .arg(line_number) | |||||
| .arg(path)); | |||||
| } | |||||
| } | |||||
| ApplicationSettings candidate; | |||||
| qint64 parsed = 0; | |||||
| if (readInteger( | |||||
| entries, QStringLiteral("Config.Version"), | |||||
| kCurrentConfigurationVersion, kCurrentConfigurationVersion, | |||||
| kCurrentConfigurationVersion, &parsed, ¬ices, &errors, true)) | |||||
| { | |||||
| candidate.version = static_cast<int>(parsed); | |||||
| } | |||||
| if (readInteger( | |||||
| entries, QStringLiteral("ProjectLimits.MaxHmiPages"), | |||||
| 1, ProjectLimits::kMaximumHmiPages, | |||||
| ProjectLimits::kMaximumHmiPages, &parsed, ¬ices, &errors)) | |||||
| { | |||||
| candidate.projectLimits.maximumHmiPages = static_cast<std::size_t>(parsed); | |||||
| } | |||||
| if (readInteger( | |||||
| entries, QStringLiteral("ProjectLimits.MaxHmiControlsPerPage"), | |||||
| 1, ProjectLimits::kMaximumHmiControlsPerPage, | |||||
| ProjectLimits::kMaximumHmiControlsPerPage, | |||||
| &parsed, ¬ices, &errors)) | |||||
| { | |||||
| candidate.projectLimits.maximumHmiControlsPerPage = | |||||
| static_cast<std::size_t>(parsed); | |||||
| } | |||||
| if (readInteger( | |||||
| entries, QStringLiteral("ProjectLimits.MaxAlarmDefinitions"), | |||||
| 0, ProjectLimits::kMaximumAlarmDefinitions, | |||||
| ProjectLimits::kMaximumAlarmDefinitions, | |||||
| &parsed, ¬ices, &errors)) | |||||
| { | |||||
| candidate.projectLimits.maximumAlarmDefinitions = | |||||
| static_cast<std::size_t>(parsed); | |||||
| } | |||||
| if (readInteger( | |||||
| entries, QStringLiteral("ProjectLimits.MaxControlLogics"), | |||||
| 1, ProjectLimits::kMaximumControlLogics, | |||||
| ProjectLimits::kMaximumControlLogics, | |||||
| &parsed, ¬ices, &errors)) | |||||
| { | |||||
| candidate.projectLimits.maximumControlLogics = | |||||
| static_cast<std::size_t>(parsed); | |||||
| } | |||||
| if (readInteger( | |||||
| entries, QStringLiteral("ProjectLimits.MaxRungsPerLogic"), | |||||
| 0, ProjectLimits::kMaximumRungsPerLogic, | |||||
| ProjectLimits::kMaximumRungsPerLogic, | |||||
| &parsed, ¬ices, &errors)) | |||||
| { | |||||
| candidate.projectLimits.maximumRungsPerLogic = | |||||
| static_cast<std::size_t>(parsed); | |||||
| } | |||||
| if (readInteger( | |||||
| entries, QStringLiteral("ProjectLimits.MaxOutputMessages"), | |||||
| 1, ProjectLimits::kMaximumOutputMessages, | |||||
| ProjectLimits::kMaximumOutputMessages, | |||||
| &parsed, ¬ices, &errors)) | |||||
| { | |||||
| candidate.projectLimits.maximumOutputMessages = static_cast<int>(parsed); | |||||
| } | |||||
| if (readInteger( | |||||
| entries, QStringLiteral("HmiDefaults.DefaultPageWidth"), | |||||
| ProjectLimits::kMinimumHmiPageWidth, | |||||
| ProjectLimits::kMaximumHmiPageWidth, | |||||
| ProjectLimits::kDefaultHmiPageWidth, | |||||
| &parsed, ¬ices, &errors)) | |||||
| { | |||||
| candidate.hmiDefaults.pageWidth = static_cast<int>(parsed); | |||||
| } | |||||
| if (readInteger( | |||||
| entries, QStringLiteral("HmiDefaults.DefaultPageHeight"), | |||||
| ProjectLimits::kMinimumHmiPageHeight, | |||||
| ProjectLimits::kMaximumHmiPageHeight, | |||||
| ProjectLimits::kDefaultHmiPageHeight, | |||||
| &parsed, ¬ices, &errors)) | |||||
| { | |||||
| candidate.hmiDefaults.pageHeight = static_cast<int>(parsed); | |||||
| } | |||||
| const QString port_path = QStringLiteral("PlcDefaults.PortName"); | |||||
| const auto port_entry = entries.constFind(port_path); | |||||
| if (port_entry == entries.cend()) | |||||
| { | |||||
| notices.append( | |||||
| QStringLiteral("配置字段 %1 缺失,使用代码默认值 COM3") | |||||
| .arg(port_path)); | |||||
| } | |||||
| else | |||||
| { | |||||
| const QByteArray port_bytes = port_entry->value.toUtf8(); | |||||
| if (port_entry->value.trimmed().isEmpty() | |||||
| || port_bytes.size() > static_cast<int>(ProjectLimits::kMaximumIdBytes)) | |||||
| { | |||||
| errors.append( | |||||
| QStringLiteral("第 %1 行字段 %2 的值“%3”无效,端口名称不能为空且不能超过 128 个 UTF-8 字节") | |||||
| .arg(port_entry->line) | |||||
| .arg(port_path, port_entry->value)); | |||||
| } | |||||
| else | |||||
| { | |||||
| candidate.plcDefaults.portName = toUtf8(port_entry->value.trimmed()); | |||||
| } | |||||
| } | |||||
| if (readInteger( | |||||
| entries, QStringLiteral("PlcDefaults.ServerAddress"), | |||||
| ProjectLimits::kMinimumPlcServerAddress, | |||||
| ProjectLimits::kMaximumPlcServerAddress, | |||||
| candidate.plcDefaults.serverAddress, | |||||
| &parsed, ¬ices, &errors)) | |||||
| { | |||||
| candidate.plcDefaults.serverAddress = static_cast<int>(parsed); | |||||
| } | |||||
| int baud_rate = candidate.plcDefaults.baudRate; | |||||
| if (readInteger( | |||||
| entries, QStringLiteral("PlcDefaults.BaudRate"), | |||||
| 9600, 115200, candidate.plcDefaults.baudRate, | |||||
| &parsed, ¬ices, &errors)) | |||||
| { | |||||
| baud_rate = static_cast<int>(parsed); | |||||
| if (!containsValue({9600, 19200, 38400, 57600, 115200}, baud_rate)) | |||||
| { | |||||
| addUnsupportedValueError( | |||||
| entries, QStringLiteral("PlcDefaults.BaudRate"), | |||||
| QStringLiteral("9600、19200、38400、57600 或 115200"), &errors); | |||||
| } | |||||
| else | |||||
| { | |||||
| candidate.plcDefaults.baudRate = baud_rate; | |||||
| } | |||||
| } | |||||
| if (readInteger( | |||||
| entries, QStringLiteral("PlcDefaults.DataBits"), | |||||
| 7, 8, candidate.plcDefaults.dataBits, | |||||
| &parsed, ¬ices, &errors)) | |||||
| { | |||||
| candidate.plcDefaults.dataBits = static_cast<int>(parsed); | |||||
| } | |||||
| int parity = candidate.plcDefaults.parity; | |||||
| if (readInteger( | |||||
| entries, QStringLiteral("PlcDefaults.Parity"), | |||||
| 0, 3, candidate.plcDefaults.parity, | |||||
| &parsed, ¬ices, &errors)) | |||||
| { | |||||
| parity = static_cast<int>(parsed); | |||||
| if (!containsValue({0, 2, 3}, parity)) | |||||
| { | |||||
| addUnsupportedValueError( | |||||
| entries, QStringLiteral("PlcDefaults.Parity"), | |||||
| QStringLiteral("0、2 或 3"), &errors); | |||||
| } | |||||
| else | |||||
| { | |||||
| candidate.plcDefaults.parity = parity; | |||||
| } | |||||
| } | |||||
| if (readInteger( | |||||
| entries, QStringLiteral("PlcDefaults.StopBits"), | |||||
| 1, 2, candidate.plcDefaults.stopBits, | |||||
| &parsed, ¬ices, &errors)) | |||||
| { | |||||
| candidate.plcDefaults.stopBits = static_cast<int>(parsed); | |||||
| } | |||||
| if (!errors.isEmpty()) | |||||
| { | |||||
| ApplicationSettingsLoadResult result; | |||||
| result.warningRequired = true; | |||||
| result.warningMessage = | |||||
| "应用配置存在严重错误,本次已全部采用代码默认值。详细原因请查看底部输出面板。"; | |||||
| for (const QString &error : errors) | |||||
| { | |||||
| result.messages.push_back(toUtf8(QStringLiteral("应用配置错误:") + error)); | |||||
| } | |||||
| for (const QString ¬ice : notices) | |||||
| { | |||||
| result.messages.push_back(toUtf8(QStringLiteral("应用配置提示:") + notice)); | |||||
| } | |||||
| result.messages.push_back( | |||||
| "应用配置:整份配置不生效,本次运行全部使用代码默认值"); | |||||
| return result; | |||||
| } | |||||
| ApplicationSettingsLoadResult result; | |||||
| result.settings = candidate; | |||||
| result.messages.push_back(toUtf8( | |||||
| QStringLiteral("应用配置:已加载 %1,修改后需重启程序") | |||||
| .arg(QDir::toNativeSeparators(file_path)))); | |||||
| for (const QString ¬ice : notices) | |||||
| { | |||||
| result.messages.push_back(toUtf8(QStringLiteral("应用配置提示:") + notice)); | |||||
| } | |||||
| return result; | |||||
| } | |||||
| @@ -0,0 +1,13 @@ | |||||
| #pragma once | |||||
| #include "services/application_settings.h" | |||||
| #include <QString> | |||||
| // 从程序目录下的严格 UTF-8 INI 文件加载一次应用配置 | |||||
| class ApplicationSettingsLoader final | |||||
| { | |||||
| public: | |||||
| static QString defaultFilePath(); | |||||
| static ApplicationSettingsLoadResult load(const QString &file_path); | |||||
| }; | |||||
| @@ -212,7 +212,9 @@ bool readArray( | |||||
| { | { | ||||
| return state->fail( | return state->fail( | ||||
| ProjectStorageError::InvalidField, | ProjectStorageError::InvalidField, | ||||
| fieldPath(context, field) + " 的元素数量超过上限"); | |||||
| fieldPath(context, field) + " 的元素数量为 " | |||||
| + std::to_string(value->size()) + ",当前上限为 " | |||||
| + std::to_string(maximum_count)); | |||||
| } | } | ||||
| return true; | return true; | ||||
| } | } | ||||
| @@ -270,64 +272,6 @@ bool parseAddress( | |||||
| return true; | return true; | ||||
| } | } | ||||
| QJsonObject serializeTimerAddress(const TimerAddress &address) | |||||
| { | |||||
| QJsonObject object; | |||||
| object.insert(QStringLiteral("index"), address.index()); | |||||
| return object; | |||||
| } | |||||
| bool parseTimerAddress( | |||||
| const QJsonObject &object, | |||||
| const std::string &context, | |||||
| TimerAddress *address, | |||||
| ParseState *state) | |||||
| { | |||||
| int index = 0; | |||||
| if (!readInt( | |||||
| object, | |||||
| "index", | |||||
| context, | |||||
| TimerAddress::kMinimumIndex, | |||||
| TimerAddress::kMaximumIndex, | |||||
| &index, | |||||
| state)) | |||||
| { | |||||
| return false; | |||||
| } | |||||
| *address = TimerAddress{index}; | |||||
| return true; | |||||
| } | |||||
| QJsonObject serializeCounterAddress(const CounterAddress &address) | |||||
| { | |||||
| QJsonObject object; | |||||
| object.insert(QStringLiteral("index"), address.index()); | |||||
| return object; | |||||
| } | |||||
| bool parseCounterAddress( | |||||
| const QJsonObject &object, | |||||
| const std::string &context, | |||||
| CounterAddress *address, | |||||
| ParseState *state) | |||||
| { | |||||
| int index = 0; | |||||
| if (!readInt( | |||||
| object, | |||||
| "index", | |||||
| context, | |||||
| CounterAddress::kMinimumIndex, | |||||
| CounterAddress::kMaximumIndex, | |||||
| &index, | |||||
| state)) | |||||
| { | |||||
| return false; | |||||
| } | |||||
| *address = CounterAddress{index}; | |||||
| return true; | |||||
| } | |||||
| QString wordOperandKindName(WordOperandKind kind) | QString wordOperandKindName(WordOperandKind kind) | ||||
| { | { | ||||
| return kind == WordOperandKind::Constant | return kind == WordOperandKind::Constant | ||||
| @@ -689,7 +633,7 @@ bool parseProperties( | |||||
| { | { | ||||
| return state->fail( | return state->fail( | ||||
| ProjectStorageError::InvalidField, | ProjectStorageError::InvalidField, | ||||
| "HMI 控件属性名称最多 128 字节,属性值最多 4096 字节"); | |||||
| "HMI 控件属性名称最多 128 字节,属性值最多 1024 字节"); | |||||
| } | } | ||||
| properties->emplace(key, value); | properties->emplace(key, value); | ||||
| } | } | ||||
| @@ -714,6 +658,13 @@ QJsonObject serializeHmiControl(const HmiControl &control) | |||||
| object.insert(QStringLiteral("binding"), QJsonValue::Null); | object.insert(QStringLiteral("binding"), QJsonValue::Null); | ||||
| } | } | ||||
| object.insert(QStringLiteral("properties"), serializeProperties(control.properties)); | object.insert(QStringLiteral("properties"), serializeProperties(control.properties)); | ||||
| if (control.type == HmiControlType::NumericDisplay | |||||
| || control.type == HmiControlType::NumericInput) | |||||
| { | |||||
| object.insert( | |||||
| QStringLiteral("dataType"), | |||||
| QString::fromLatin1(registerDataTypeName(control.dataType))); | |||||
| } | |||||
| if (control.type == HmiControlType::Button) | if (control.type == HmiControlType::Button) | ||||
| { | { | ||||
| object.insert( | object.insert( | ||||
| @@ -728,14 +679,6 @@ QJsonObject serializeHmiControl(const HmiControl &control) | |||||
| ? control.pageJump->targetPageId | ? control.pageJump->targetPageId | ||||
| : std::string{})); | : std::string{})); | ||||
| } | } | ||||
| if (control.type == HmiControlType::ProgressBar) | |||||
| { | |||||
| const HmiProgressBarConfig config = control.progressBar.value_or( | |||||
| HmiProgressBarConfig{}); | |||||
| object.insert(QStringLiteral("minimumValue"), config.minimumValue); | |||||
| object.insert(QStringLiteral("maximumValue"), config.maximumValue); | |||||
| object.insert(QStringLiteral("showValue"), config.showValue); | |||||
| } | |||||
| return object; | return object; | ||||
| } | } | ||||
| @@ -767,6 +710,18 @@ bool parseHmiControl( | |||||
| { | { | ||||
| return false; | return false; | ||||
| } | } | ||||
| if (control->type == HmiControlType::NumericDisplay | |||||
| || control->type == HmiControlType::NumericInput) | |||||
| { | |||||
| std::string data_type_text; | |||||
| if (!readString(object, "dataType", context, &data_type_text, state) | |||||
| || !parseRegisterDataType(data_type_text, &control->dataType)) | |||||
| { | |||||
| return state->fail( | |||||
| ProjectStorageError::InvalidField, | |||||
| context + ".dataType 必须是 int16 或 float32"); | |||||
| } | |||||
| } | |||||
| if (control->type == HmiControlType::Button) | if (control->type == HmiControlType::Button) | ||||
| { | { | ||||
| std::string operation_text; | std::string operation_text; | ||||
| @@ -793,37 +748,6 @@ bool parseHmiControl( | |||||
| } | } | ||||
| control->pageJump = HmiPageJumpConfig{std::move(target_page_id)}; | control->pageJump = HmiPageJumpConfig{std::move(target_page_id)}; | ||||
| } | } | ||||
| if (control->type == HmiControlType::ProgressBar) | |||||
| { | |||||
| int minimum_value = 0; | |||||
| int maximum_value = 0; | |||||
| bool show_value = true; | |||||
| if (!readInt( | |||||
| object, | |||||
| "minimumValue", | |||||
| context, | |||||
| std::numeric_limits<std::int16_t>::min(), | |||||
| std::numeric_limits<std::int16_t>::max(), | |||||
| &minimum_value, | |||||
| state) | |||||
| || !readInt( | |||||
| object, | |||||
| "maximumValue", | |||||
| context, | |||||
| std::numeric_limits<std::int16_t>::min(), | |||||
| std::numeric_limits<std::int16_t>::max(), | |||||
| &maximum_value, | |||||
| state) | |||||
| || !readBool(object, "showValue", context, &show_value, state)) | |||||
| { | |||||
| return false; | |||||
| } | |||||
| control->progressBar = HmiProgressBarConfig{ | |||||
| static_cast<std::int16_t>(minimum_value), | |||||
| static_cast<std::int16_t>(maximum_value), | |||||
| show_value}; | |||||
| } | |||||
| // binding 允许为 null,其余非空值必须是合法的寄存器地址对象 | // binding 允许为 null,其余非空值必须是合法的寄存器地址对象 | ||||
| if (binding.isNull()) | if (binding.isNull()) | ||||
| { | { | ||||
| @@ -868,6 +792,7 @@ QJsonObject serializeHmiPage(const HmiPage &page) | |||||
| bool parseHmiPage( | bool parseHmiPage( | ||||
| const QJsonObject &object, | const QJsonObject &object, | ||||
| const std::string &context, | const std::string &context, | ||||
| const ProjectLimitSettings &limits, | |||||
| HmiPage *page, | HmiPage *page, | ||||
| ParseState *state) | ParseState *state) | ||||
| { | { | ||||
| @@ -886,7 +811,7 @@ bool parseHmiPage( | |||||
| &page->height, state) | &page->height, state) | ||||
| || !readArray( | || !readArray( | ||||
| object, "controls", context, &controls, state, | object, "controls", context, &controls, state, | ||||
| static_cast<int>(ProjectLimits::kMaximumHmiControlsPerPage))) | |||||
| static_cast<int>(limits.maximumHmiControlsPerPage))) | |||||
| { | { | ||||
| return false; | return false; | ||||
| } | } | ||||
| @@ -1113,24 +1038,6 @@ QJsonObject serializeNodeConfig(const EdgeContactNodeConfig &config) | |||||
| return object; | return object; | ||||
| } | } | ||||
| QJsonObject serializeNodeConfig(const TimerContactNodeConfig &config) | |||||
| { | |||||
| QJsonObject object; | |||||
| object.insert(QStringLiteral("type"), QStringLiteral("timerContact")); | |||||
| object.insert(QStringLiteral("timer"), serializeTimerAddress(config.address)); | |||||
| object.insert(QStringLiteral("mode"), contactModeName(config.mode)); | |||||
| return object; | |||||
| } | |||||
| QJsonObject serializeNodeConfig(const CounterContactNodeConfig &config) | |||||
| { | |||||
| QJsonObject object; | |||||
| object.insert(QStringLiteral("type"), QStringLiteral("counterContact")); | |||||
| object.insert(QStringLiteral("counter"), serializeCounterAddress(config.address)); | |||||
| object.insert(QStringLiteral("mode"), contactModeName(config.mode)); | |||||
| return object; | |||||
| } | |||||
| // 将线圈节点配置序列化,并写入用于反序列化分派的类型标记 | // 将线圈节点配置序列化,并写入用于反序列化分派的类型标记 | ||||
| QJsonObject serializeNodeConfig(const CoilNodeConfig &config) | QJsonObject serializeNodeConfig(const CoilNodeConfig &config) | ||||
| { | { | ||||
| @@ -1152,35 +1059,6 @@ QJsonObject serializeNodeConfig(const CompareNodeConfig &config) | |||||
| return object; | return object; | ||||
| } | } | ||||
| QJsonObject serializeNodeConfig(const TonNodeConfig &config) | |||||
| { | |||||
| QJsonObject object; | |||||
| object.insert(QStringLiteral("type"), QStringLiteral("ton")); | |||||
| object.insert(QStringLiteral("timer"), serializeTimerAddress(config.address)); | |||||
| object.insert(QStringLiteral("presetMs"), config.presetMs); | |||||
| return object; | |||||
| } | |||||
| QString counterModeName(CounterMode mode) | |||||
| { | |||||
| return mode == CounterMode::Up | |||||
| ? QStringLiteral("up") : QStringLiteral("down"); | |||||
| } | |||||
| QJsonObject serializeNodeConfig(const CounterNodeConfig &config) | |||||
| { | |||||
| QJsonObject object; | |||||
| object.insert(QStringLiteral("type"), QStringLiteral("counter")); | |||||
| object.insert(QStringLiteral("counter"), serializeCounterAddress(config.address)); | |||||
| object.insert(QStringLiteral("mode"), counterModeName(config.mode)); | |||||
| object.insert( | |||||
| QStringLiteral("currentValueAddress"), | |||||
| serializeAddress(config.currentValueAddress)); | |||||
| object.insert(QStringLiteral("preset"), serializeWordOperand(config.preset)); | |||||
| object.insert(QStringLiteral("resetAddress"), serializeAddress(config.resetAddress)); | |||||
| return object; | |||||
| } | |||||
| QJsonObject serializeNodeConfig(const MoveNodeConfig &config) | QJsonObject serializeNodeConfig(const MoveNodeConfig &config) | ||||
| { | { | ||||
| QJsonObject object; | QJsonObject object; | ||||
| @@ -1238,113 +1116,6 @@ bool parseNodeConfig( | |||||
| return false; | return false; | ||||
| } | } | ||||
| if (type == "timerContact" || type == "ton" | |||||
| || type == "counterContact" || type == "counter") | |||||
| { | |||||
| const char *resource_field = | |||||
| type == "timerContact" || type == "ton" ? "timer" : "counter"; | |||||
| QJsonObject resource_object; | |||||
| if (!readObject(object, resource_field, context, &resource_object, state)) | |||||
| { | |||||
| return false; | |||||
| } | |||||
| if (type == "counterContact" || type == "counter") | |||||
| { | |||||
| CounterAddress address; | |||||
| if (!parseCounterAddress( | |||||
| resource_object, context + ".counter", &address, state)) | |||||
| { | |||||
| return false; | |||||
| } | |||||
| if (type == "counterContact") | |||||
| { | |||||
| std::string mode_text; | |||||
| ContactMode mode = ContactMode::NormallyOpen; | |||||
| if (!readString(object, "mode", context, &mode_text, state) | |||||
| || !parseContactMode(mode_text, &mode, state)) | |||||
| { | |||||
| return false; | |||||
| } | |||||
| *config = CounterContactNodeConfig{address, mode}; | |||||
| return true; | |||||
| } | |||||
| std::string mode_text; | |||||
| CounterMode mode = CounterMode::Up; | |||||
| if (!readString(object, "mode", context, &mode_text, state)) | |||||
| { | |||||
| return false; | |||||
| } | |||||
| if (mode_text == "up") | |||||
| { | |||||
| mode = CounterMode::Up; | |||||
| } | |||||
| else if (mode_text == "down") | |||||
| { | |||||
| mode = CounterMode::Down; | |||||
| } | |||||
| else | |||||
| { | |||||
| return state->fail( | |||||
| ProjectStorageError::InvalidField, | |||||
| "不支持的计数器方向:" + mode_text); | |||||
| } | |||||
| QJsonObject current_object; | |||||
| QJsonObject preset_object; | |||||
| QJsonObject reset_object; | |||||
| RegisterAddress current{RegisterArea::D, 0}; | |||||
| RegisterAddress reset{RegisterArea::M, 0}; | |||||
| WordOperand preset; | |||||
| if (!readObject( | |||||
| object, "currentValueAddress", context, ¤t_object, state) | |||||
| || !parseAddress( | |||||
| current_object, | |||||
| context + ".currentValueAddress", | |||||
| ¤t, | |||||
| state) | |||||
| || !readObject(object, "preset", context, &preset_object, state) | |||||
| || !parseWordOperand(preset_object, context + ".preset", &preset, state) | |||||
| || !readObject(object, "resetAddress", context, &reset_object, state) | |||||
| || !parseAddress( | |||||
| reset_object, context + ".resetAddress", &reset, state)) | |||||
| { | |||||
| return false; | |||||
| } | |||||
| *config = CounterNodeConfig{address, mode, current, preset, reset}; | |||||
| return true; | |||||
| } | |||||
| TimerAddress address; | |||||
| if (!parseTimerAddress(resource_object, context + ".timer", &address, state)) | |||||
| { | |||||
| return false; | |||||
| } | |||||
| if (type == "timerContact") | |||||
| { | |||||
| std::string mode_text; | |||||
| ContactMode mode = ContactMode::NormallyOpen; | |||||
| if (!readString(object, "mode", context, &mode_text, state) | |||||
| || !parseContactMode(mode_text, &mode, state)) | |||||
| { | |||||
| return false; | |||||
| } | |||||
| *config = TimerContactNodeConfig{address, mode}; | |||||
| return true; | |||||
| } | |||||
| int preset_ms = 0; | |||||
| if (!readInt( | |||||
| object, | |||||
| "presetMs", | |||||
| context, | |||||
| TonNodeConfig::kMinimumPresetMs, | |||||
| TonNodeConfig::kMaximumPresetMs, | |||||
| &preset_ms, | |||||
| state)) | |||||
| { | |||||
| return false; | |||||
| } | |||||
| *config = TonNodeConfig{address, preset_ms}; | |||||
| return true; | |||||
| } | |||||
| if (type == "move") | if (type == "move") | ||||
| { | { | ||||
| QJsonObject source_object; | QJsonObject source_object; | ||||
| @@ -1410,6 +1181,14 @@ bool parseNodeConfig( | |||||
| return true; | return true; | ||||
| } | } | ||||
| if (type != "contact" && type != "edgeContact" | |||||
| && type != "coil" && type != "compare") | |||||
| { | |||||
| return state->fail( | |||||
| ProjectStorageError::InvalidField, | |||||
| "不支持的逻辑节点类型:" + type); | |||||
| } | |||||
| QJsonObject address_object; | QJsonObject address_object; | ||||
| if (!readObject(object, "address", context, &address_object, state)) | if (!readObject(object, "address", context, &address_object, state)) | ||||
| { | { | ||||
| @@ -1481,11 +1260,7 @@ bool parseNodeConfig( | |||||
| address, comparison, static_cast<std::int16_t>(value)}; | address, comparison, static_cast<std::int16_t>(value)}; | ||||
| return true; | return true; | ||||
| } | } | ||||
| return state->fail( | |||||
| ProjectStorageError::InvalidField, | |||||
| "不支持的逻辑节点类型:" + type); | |||||
| return state->fail(ProjectStorageError::InvalidField, "逻辑节点类型解析失败"); | |||||
| } | } | ||||
| // 解析逻辑节点标识及其多态配置 | // 解析逻辑节点标识及其多态配置 | ||||
| @@ -1516,6 +1291,8 @@ QString expressionKindText(ConditionExpressionKind kind) | |||||
| return QStringLiteral("node"); | return QStringLiteral("node"); | ||||
| case ConditionExpressionKind::Wire: | case ConditionExpressionKind::Wire: | ||||
| return QStringLiteral("wire"); | return QStringLiteral("wire"); | ||||
| case ConditionExpressionKind::Gap: | |||||
| return QStringLiteral("gap"); | |||||
| case ConditionExpressionKind::Series: | case ConditionExpressionKind::Series: | ||||
| return QStringLiteral("series"); | return QStringLiteral("series"); | ||||
| case ConditionExpressionKind::Parallel: | case ConditionExpressionKind::Parallel: | ||||
| @@ -1537,6 +1314,10 @@ QJsonObject serializeConditionExpression(const ConditionExpression &expression) | |||||
| { | { | ||||
| object.insert(QStringLiteral("columnSpan"), expression.wire->columnSpan); | object.insert(QStringLiteral("columnSpan"), expression.wire->columnSpan); | ||||
| } | } | ||||
| else if (expression.kind == ConditionExpressionKind::Gap) | |||||
| { | |||||
| object.insert(QStringLiteral("columnSpan"), expression.gap->columnSpan); | |||||
| } | |||||
| else | else | ||||
| { | { | ||||
| QJsonArray children; | QJsonArray children; | ||||
| @@ -1561,14 +1342,14 @@ bool parseConditionExpression( | |||||
| { | { | ||||
| return state->fail( | return state->fail( | ||||
| ProjectStorageError::InvalidField, | ProjectStorageError::InvalidField, | ||||
| context + " 的嵌套深度超过 20 层"); | |||||
| context + " 的嵌套深度超过 12 层"); | |||||
| } | } | ||||
| ++*node_count; | ++*node_count; | ||||
| if (*node_count > ProjectLimits::kMaximumExpressionNodesPerRung) | if (*node_count > ProjectLimits::kMaximumExpressionNodesPerRung) | ||||
| { | { | ||||
| return state->fail( | return state->fail( | ||||
| ProjectStorageError::InvalidField, | ProjectStorageError::InvalidField, | ||||
| context + " 的表达式节点总数超过 4096 个"); | |||||
| context + " 的表达式节点总数超过 1024 个"); | |||||
| } | } | ||||
| std::string kind; | std::string kind; | ||||
| if (!readString( | if (!readString( | ||||
| @@ -1612,11 +1393,29 @@ bool parseConditionExpression( | |||||
| expression->wire = WireSegment{column_span}; | expression->wire = WireSegment{column_span}; | ||||
| return true; | return true; | ||||
| } | } | ||||
| if (kind == "gap") | |||||
| { | |||||
| int column_span = 0; | |||||
| if (!readInt( | |||||
| object, | |||||
| "columnSpan", | |||||
| context, | |||||
| GapSegment::kMinimumColumnSpan, | |||||
| GapSegment::kMaximumColumnSpan, | |||||
| &column_span, | |||||
| state)) | |||||
| { | |||||
| return false; | |||||
| } | |||||
| expression->kind = ConditionExpressionKind::Gap; | |||||
| expression->gap = GapSegment{column_span}; | |||||
| return true; | |||||
| } | |||||
| if (kind != "series" && kind != "parallel") | if (kind != "series" && kind != "parallel") | ||||
| { | { | ||||
| return state->fail( | return state->fail( | ||||
| ProjectStorageError::InvalidField, | ProjectStorageError::InvalidField, | ||||
| context + ".kind 必须是 node、wire、series 或 parallel"); | |||||
| context + ".kind 必须是 node、wire、gap、series 或 parallel"); | |||||
| } | } | ||||
| QJsonArray children; | QJsonArray children; | ||||
| if (!readArray( | if (!readArray( | ||||
| @@ -1751,6 +1550,7 @@ QJsonObject serializeControlLogic(const ControlLogic &logic) | |||||
| bool parseControlLogic( | bool parseControlLogic( | ||||
| const QJsonObject &object, | const QJsonObject &object, | ||||
| const std::string &context, | const std::string &context, | ||||
| const ProjectLimitSettings &limits, | |||||
| ControlLogic *logic, | ControlLogic *logic, | ||||
| ParseState *state) | ParseState *state) | ||||
| { | { | ||||
| @@ -1762,7 +1562,7 @@ bool parseControlLogic( | |||||
| || !readBool(object, "enabled", context, &logic->enabled, state) | || !readBool(object, "enabled", context, &logic->enabled, state) | ||||
| || !readArray( | || !readArray( | ||||
| object, "rungs", context, &rungs, state, | object, "rungs", context, &rungs, state, | ||||
| static_cast<int>(ProjectLimits::kMaximumRungsPerLogic))) | |||||
| static_cast<int>(limits.maximumRungsPerLogic))) | |||||
| { | { | ||||
| return false; | return false; | ||||
| } | } | ||||
| @@ -1831,7 +1631,10 @@ QJsonObject serializeProject(const Project &project) | |||||
| // 从顶层 JSON 对象解析完整工程,并在解析子对象前检查格式版本 | // 从顶层 JSON 对象解析完整工程,并在解析子对象前检查格式版本 | ||||
| bool parseProject( | bool parseProject( | ||||
| const QJsonObject &object, Project *project, ParseState *state) | |||||
| const QJsonObject &object, | |||||
| const ProjectLimitSettings &limits, | |||||
| Project *project, | |||||
| ParseState *state) | |||||
| { | { | ||||
| QJsonArray pages; | QJsonArray pages; | ||||
| QJsonArray alarms; | QJsonArray alarms; | ||||
| @@ -1861,20 +1664,20 @@ bool parseProject( | |||||
| || !readString(object, "name", "project", &project->metadata.name, state) | || !readString(object, "name", "project", &project->metadata.name, state) | ||||
| || !readArray( | || !readArray( | ||||
| object, "hmiPages", "project", &pages, state, | object, "hmiPages", "project", &pages, state, | ||||
| static_cast<int>(ProjectLimits::kMaximumHmiPages)) | |||||
| static_cast<int>(limits.maximumHmiPages)) | |||||
| || !readString( | || !readString( | ||||
| object, "initialHmiPageId", "project", | object, "initialHmiPageId", "project", | ||||
| &project->initialHmiPageId, state, | &project->initialHmiPageId, state, | ||||
| ProjectLimits::kMaximumIdBytes) | ProjectLimits::kMaximumIdBytes) | ||||
| || !readArray( | || !readArray( | ||||
| object, "alarmDefinitions", "project", &alarms, state, | object, "alarmDefinitions", "project", &alarms, state, | ||||
| static_cast<int>(ProjectLimits::kMaximumAlarmDefinitions)) | |||||
| static_cast<int>(limits.maximumAlarmDefinitions)) | |||||
| || !readArray( | || !readArray( | ||||
| object, "registerComments", "project", ®ister_comments, state, | object, "registerComments", "project", ®ister_comments, state, | ||||
| static_cast<int>(ProjectLimits::kMaximumRegisterComments)) | static_cast<int>(ProjectLimits::kMaximumRegisterComments)) | ||||
| || !readArray( | || !readArray( | ||||
| object, "controlLogics", "project", &logics, state, | object, "controlLogics", "project", &logics, state, | ||||
| static_cast<int>(ProjectLimits::kMaximumControlLogics))) | |||||
| static_cast<int>(limits.maximumControlLogics))) | |||||
| { | { | ||||
| return false; | return false; | ||||
| } | } | ||||
| @@ -1893,6 +1696,7 @@ bool parseProject( | |||||
| if (!parseHmiPage( | if (!parseHmiPage( | ||||
| pages.at(index).toObject(), | pages.at(index).toObject(), | ||||
| "project.hmiPages[" + std::to_string(index) + ']', | "project.hmiPages[" + std::to_string(index) + ']', | ||||
| limits, | |||||
| &page, | &page, | ||||
| state)) | state)) | ||||
| { | { | ||||
| @@ -1956,6 +1760,7 @@ bool parseProject( | |||||
| if (!parseControlLogic( | if (!parseControlLogic( | ||||
| logics.at(index).toObject(), | logics.at(index).toObject(), | ||||
| "project.controlLogics[" + std::to_string(index) + ']', | "project.controlLogics[" + std::to_string(index) + ']', | ||||
| limits, | |||||
| &logic, | &logic, | ||||
| state)) | state)) | ||||
| { | { | ||||
| @@ -1975,13 +1780,19 @@ ProjectSaveResult saveFailure( | |||||
| } // namespace | } // namespace | ||||
| JsonProjectStorage::JsonProjectStorage( | |||||
| const ProjectLimitSettings &project_limits) | |||||
| : project_limits_(project_limits) | |||||
| { | |||||
| } | |||||
| // 校验工程后将其序列化,并通过 QSaveFile 原子写入目标文件 | // 校验工程后将其序列化,并通过 QSaveFile 原子写入目标文件 | ||||
| ProjectSaveResult JsonProjectStorage::save( | ProjectSaveResult JsonProjectStorage::save( | ||||
| const Project &project, const std::string &file_path) | const Project &project, const std::string &file_path) | ||||
| { | { | ||||
| // 写文件前先执行领域校验,防止持久化内部关系不合法的工程 | // 写文件前先执行领域校验,防止持久化内部关系不合法的工程 | ||||
| std::string validation_error; | std::string validation_error; | ||||
| if (!project.validate(&validation_error)) | |||||
| if (!project.validate(project_limits_, &validation_error)) | |||||
| { | { | ||||
| return {false, ProjectStorageError::InvalidProject, validation_error}; | return {false, ProjectStorageError::InvalidProject, validation_error}; | ||||
| } | } | ||||
| @@ -2079,14 +1890,13 @@ ProjectLoadResult JsonProjectStorage::load(const std::string &file_path) | |||||
| // 先在局部对象中完成结构解析,失败时不会暴露半成品工程 | // 先在局部对象中完成结构解析,失败时不会暴露半成品工程 | ||||
| Project project; | Project project; | ||||
| ParseState state; | ParseState state; | ||||
| if (!parseProject(document.object(), &project, &state)) | |||||
| if (!parseProject(document.object(), project_limits_, &project, &state)) | |||||
| { | { | ||||
| return {false, {}, state.error, state.message}; | return {false, {}, state.error, state.message}; | ||||
| } | } | ||||
| // JSON 字段合法不代表业务关系合法,还需执行领域层整体校验 | // JSON 字段合法不代表业务关系合法,还需执行领域层整体校验 | ||||
| std::string validation_error; | std::string validation_error; | ||||
| if (!project.validate(&validation_error)) | |||||
| if (!project.validate(project_limits_, &validation_error)) | |||||
| { | { | ||||
| return {false, | return {false, | ||||
| {}, | {}, | ||||
| @@ -2,11 +2,17 @@ | |||||
| #include "domain/project_storage.h" | #include "domain/project_storage.h" | ||||
| // 使用版本化 JSON 文件保存和加载工程 | |||||
| // 严格读写当前 1.0 JSON 工程格式 | |||||
| class JsonProjectStorage final : public ProjectStorage | class JsonProjectStorage final : public ProjectStorage | ||||
| { | { | ||||
| public: | public: | ||||
| explicit JsonProjectStorage( | |||||
| const ProjectLimitSettings &project_limits = | |||||
| defaultProjectLimitSettings()); | |||||
| ProjectSaveResult save( | ProjectSaveResult save( | ||||
| const Project &project, const std::string &file_path) override; | const Project &project, const std::string &file_path) override; | ||||
| ProjectLoadResult load(const std::string &file_path) override; | ProjectLoadResult load(const std::string &file_path) override; | ||||
| private: | |||||
| const ProjectLimitSettings &project_limits_; | |||||
| }; | }; | ||||
| @@ -5,6 +5,7 @@ | |||||
| #include <QModbusDevice> | #include <QModbusDevice> | ||||
| #include <QString> | #include <QString> | ||||
| // Qt 错误码之外的通信现场信息,用于区分“未打开串口”和“已断线” | |||||
| struct PlcCommunicationErrorContext | struct PlcCommunicationErrorContext | ||||
| { | { | ||||
| QString portName; | QString portName; | ||||
| @@ -12,12 +13,14 @@ struct PlcCommunicationErrorContext | |||||
| bool receivedValidResponse = false; | bool receivedValidResponse = false; | ||||
| }; | }; | ||||
| // 分类后的用户可读通信故障 | |||||
| struct PlcCommunicationFailure | struct PlcCommunicationFailure | ||||
| { | { | ||||
| PlcCommunicationError type = PlcCommunicationError::Unknown; | PlcCommunicationError type = PlcCommunicationError::Unknown; | ||||
| QString message; | QString message; | ||||
| }; | }; | ||||
| // 将 Qt Modbus 错误和上下文转换为项目统一错误类型 | |||||
| PlcCommunicationFailure classifyPlcCommunicationError( | PlcCommunicationFailure classifyPlcCommunicationError( | ||||
| QModbusDevice::Error error, | QModbusDevice::Error error, | ||||
| const PlcCommunicationErrorContext &context); | const PlcCommunicationErrorContext &context); | ||||
| @@ -15,26 +15,31 @@ | |||||
| namespace { | namespace { | ||||
| // 故障恢复时,两次轻量探测之间等待的时间 | |||||
| constexpr int kRecoveryProbeIntervalMs = 2000; | constexpr int kRecoveryProbeIntervalMs = 2000; | ||||
| // Qt 使用 QString,项目内部错误信息统一使用 UTF-8 std::string | |||||
| std::string toUtf8(const QString &value) | std::string toUtf8(const QString &value) | ||||
| { | { | ||||
| const QByteArray bytes = value.toUtf8(); | const QByteArray bytes = value.toUtf8(); | ||||
| return std::string(bytes.constData(), static_cast<std::size_t>(bytes.size())); | return std::string(bytes.constData(), static_cast<std::size_t>(bytes.size())); | ||||
| } | } | ||||
| // M 区在 Modbus 中按线圈读取,D 区按保持寄存器读取 | |||||
| QModbusDataUnit::RegisterType registerType(RegisterArea area) | QModbusDataUnit::RegisterType registerType(RegisterArea area) | ||||
| { | { | ||||
| return area == RegisterArea::M | return area == RegisterArea::M | ||||
| ? QModbusDataUnit::Coils : QModbusDataUnit::HoldingRegisters; | ? QModbusDataUnit::Coils : QModbusDataUnit::HoldingRegisters; | ||||
| } | } | ||||
| // 只有超时和 PLC 不响应属于可以自动探测恢复的故障 | |||||
| bool isRecoverableTimeout(PlcCommunicationError error) | bool isRecoverableTimeout(PlcCommunicationError error) | ||||
| { | { | ||||
| return error == PlcCommunicationError::PlcNotResponding | return error == PlcCommunicationError::PlcNotResponding | ||||
| || error == PlcCommunicationError::CommunicationTimeout; | || error == PlcCommunicationError::CommunicationTimeout; | ||||
| } | } | ||||
| // Connected 正常轮询,Recovering 正在恢复后的重新首读 | |||||
| bool isReadingState(PlcConnectionState state) | bool isReadingState(PlcConnectionState state) | ||||
| { | { | ||||
| return state == PlcConnectionState::Connected | return state == PlcConnectionState::Connected | ||||
| @@ -46,6 +51,7 @@ bool normalizePollAddresses( | |||||
| std::vector<RegisterAddress> *normalized, | std::vector<RegisterAddress> *normalized, | ||||
| std::string *error) | std::string *error) | ||||
| { | { | ||||
| // 先复制,后续排序和去重不能改变调用方传入的地址列表 | |||||
| *normalized = addresses; | *normalized = addresses; | ||||
| if (std::any_of( | if (std::any_of( | ||||
| normalized->cbegin(), normalized->cend(), | normalized->cbegin(), normalized->cend(), | ||||
| @@ -66,16 +72,35 @@ bool normalizePollAddresses( | |||||
| std::unique(normalized->begin(), normalized->end()), normalized->end()); | std::unique(normalized->begin(), normalized->end()), normalized->end()); | ||||
| if (normalized->size() > ProjectLimits::kMaximumPollAddresses) | if (normalized->size() > ProjectLimits::kMaximumPollAddresses) | ||||
| { | { | ||||
| *error = "PLC 轮询的去重 M/D 地址最多为 1024 个"; | |||||
| *error = "PLC 轮询的去重 M/D 地址最多为 256 个"; | |||||
| return false; | return false; | ||||
| } | } | ||||
| return true; | return true; | ||||
| } | } | ||||
| std::size_t pollBlockCount(const std::vector<RegisterAddress> &addresses) | |||||
| bool isFloat32Start( | |||||
| const std::vector<RegisterAddress> &float32_starts, | |||||
| RegisterArea area, | |||||
| int index) | |||||
| { | |||||
| return std::binary_search( | |||||
| float32_starts.cbegin(), float32_starts.cend(), | |||||
| RegisterAddress{area, index}, | |||||
| [](const RegisterAddress &left, const RegisterAddress &right) | |||||
| { | |||||
| return left.area() == right.area() | |||||
| ? left.index() < right.index() | |||||
| : left.area() == RegisterArea::M; | |||||
| }); | |||||
| } | |||||
| std::size_t pollBlockCount( | |||||
| const std::vector<RegisterAddress> &addresses, | |||||
| const std::vector<RegisterAddress> &float32_starts) | |||||
| { | { | ||||
| if (addresses.empty()) | if (addresses.empty()) | ||||
| { | { | ||||
| // 空集合也会保留 M0、D0 两个默认读块 | |||||
| return 2U; | return 2U; | ||||
| } | } | ||||
| std::size_t blocks = 0U; | std::size_t blocks = 0U; | ||||
| @@ -84,6 +109,20 @@ std::size_t pollBlockCount(const std::vector<RegisterAddress> &addresses) | |||||
| int count = 0; | int count = 0; | ||||
| for (const RegisterAddress &address : addresses) | for (const RegisterAddress &address : addresses) | ||||
| { | { | ||||
| const bool must_keep_pair = count >= ProjectLimits::kMaximumModbusReadCount | |||||
| && address.area() == current_area | |||||
| && address.index() == start_address + count | |||||
| && isFloat32Start(float32_starts, address.area(), address.index() - 1) | |||||
| && start_address + count - 1 == address.index() - 1; | |||||
| if (must_keep_pair) | |||||
| { | |||||
| ++blocks; | |||||
| count = 2; | |||||
| start_address = address.index() - 1; | |||||
| current_area = address.area(); | |||||
| continue; | |||||
| } | |||||
| // 跨区域、出现地址间隔或达到 Modbus 单次上限时,必须开始新读块 | |||||
| if (count == 0 | if (count == 0 | ||||
| || address.area() != current_area | || address.area() != current_area | ||||
| || address.index() > start_address + count | || address.index() > start_address + count | ||||
| @@ -111,6 +150,7 @@ PlcCommunicationService::PlcCommunicationService( | |||||
| repository_(repository), | repository_(repository), | ||||
| master_(std::make_unique<QModbusRtuSerialMaster>()) | master_(std::make_unique<QModbusRtuSerialMaster>()) | ||||
| { | { | ||||
| // 仓库只负责缓存;写入请求通过这里注入的回调转给通信服务 | |||||
| repository_.setWriteHandlers( | repository_.setWriteHandlers( | ||||
| [this](const RegisterAddress &address, bool value) | [this](const RegisterAddress &address, bool value) | ||||
| { | { | ||||
| @@ -119,7 +159,13 @@ PlcCommunicationService::PlcCommunicationService( | |||||
| [this](const RegisterAddress &address, std::int16_t value) | [this](const RegisterAddress &address, std::int16_t value) | ||||
| { | { | ||||
| return sendWordWrite(address, value); | return sendWordWrite(address, value); | ||||
| }, | |||||
| [this](const RegisterAddress &address, | |||||
| const std::array<std::int16_t, 2> &values) | |||||
| { | |||||
| return sendWordPairWrite(address, values); | |||||
| }); | }); | ||||
| // 轮询定时器每次只推动一个读块,避免一次压入大量异步请求 | |||||
| connect(&poll_timer_, &QTimer::timeout, this, &PlcCommunicationService::pollNextBlock); | connect(&poll_timer_, &QTimer::timeout, this, &PlcCommunicationService::pollNextBlock); | ||||
| recovery_timer_.setSingleShot(true); | recovery_timer_.setSingleShot(true); | ||||
| connect( | connect( | ||||
| @@ -127,12 +173,14 @@ PlcCommunicationService::PlcCommunicationService( | |||||
| &QTimer::timeout, | &QTimer::timeout, | ||||
| this, | this, | ||||
| &PlcCommunicationService::probeRecovery); | &PlcCommunicationService::probeRecovery); | ||||
| // Qt 串口状态变化是异步通知,所有连接状态都从这里统一处理 | |||||
| connect(master_.get(), &QModbusClient::stateChanged, | connect(master_.get(), &QModbusClient::stateChanged, | ||||
| this, | this, | ||||
| [this](QModbusDevice::State device_state) | [this](QModbusDevice::State device_state) | ||||
| { | { | ||||
| if (device_state == QModbusDevice::ConnectedState) | if (device_state == QModbusDevice::ConnectedState) | ||||
| { | { | ||||
| // 串口连接成功后立即开始轮询,并不代表首读已经完成 | |||||
| serial_session_opened_ = true; | serial_session_opened_ = true; | ||||
| setState(PlcConnectionState::Connected); | setState(PlcConnectionState::Connected); | ||||
| poll_timer_.start(configuration_.pollIntervalMs); | poll_timer_.start(configuration_.pollIntervalMs); | ||||
| @@ -145,6 +193,7 @@ PlcCommunicationService::PlcCommunicationService( | |||||
| } | } | ||||
| else if (device_state == QModbusDevice::UnconnectedState) | else if (device_state == QModbusDevice::UnconnectedState) | ||||
| { | { | ||||
| // 主动断开和意外断线要区分,后者需要报告故障 | |||||
| if (disconnecting_) | if (disconnecting_) | ||||
| { | { | ||||
| serial_session_opened_ = false; | serial_session_opened_ = false; | ||||
| @@ -159,6 +208,7 @@ PlcCommunicationService::PlcCommunicationService( | |||||
| } | } | ||||
| } | } | ||||
| }); | }); | ||||
| // Qt 报告底层错误时,转换为项目自己的错误和状态 | |||||
| connect(master_.get(), &QModbusClient::errorOccurred, | connect(master_.get(), &QModbusClient::errorOccurred, | ||||
| this, | this, | ||||
| [this](QModbusDevice::Error error) | [this](QModbusDevice::Error error) | ||||
| @@ -190,6 +240,7 @@ PlcCommunicationResult PlcCommunicationService::connectDevice( | |||||
| { | { | ||||
| return {false, "PLC 连接已经启动,请先断开当前连接"}; | return {false, "PLC 连接已经启动,请先断开当前连接"}; | ||||
| } | } | ||||
| // 每次连接都递增代次;旧连接的异步回复即使晚到也不能污染新缓存 | |||||
| configuration_ = configuration; | configuration_ = configuration; | ||||
| ++connection_generation_; | ++connection_generation_; | ||||
| disconnecting_ = false; | disconnecting_ = false; | ||||
| @@ -197,6 +248,7 @@ PlcCommunicationResult PlcCommunicationService::connectDevice( | |||||
| received_valid_response_ = false; | received_valid_response_ = false; | ||||
| last_error_type_ = PlcCommunicationError::None; | last_error_type_ = PlcCommunicationError::None; | ||||
| last_error_.clear(); | last_error_.clear(); | ||||
| // 把项目配置转换成 Qt 串口参数 | |||||
| master_->setConnectionParameter( | master_->setConnectionParameter( | ||||
| QModbusDevice::SerialPortNameParameter, | QModbusDevice::SerialPortNameParameter, | ||||
| QString::fromStdString(configuration.portName)); | QString::fromStdString(configuration.portName)); | ||||
| @@ -213,6 +265,7 @@ PlcCommunicationResult PlcCommunicationService::connectDevice( | |||||
| static_cast<QSerialPort::StopBits>(configuration.stopBits)); | static_cast<QSerialPort::StopBits>(configuration.stopBits)); | ||||
| master_->setTimeout(configuration.responseTimeoutMs); | master_->setTimeout(configuration.responseTimeoutMs); | ||||
| master_->setNumberOfRetries(configuration.retries); | master_->setNumberOfRetries(configuration.retries); | ||||
| // 新连接必须重新完成完整首读,不能沿用上一条连接的运行资格 | |||||
| repository_.invalidate(); | repository_.invalidate(); | ||||
| updateInitialReadCompleted(false, true); | updateInitialReadCompleted(false, true); | ||||
| rebuildPollBlocks(); | rebuildPollBlocks(); | ||||
| @@ -232,6 +285,7 @@ PlcCommunicationResult PlcCommunicationService::connectDevice( | |||||
| void PlcCommunicationService::disconnectDevice() | void PlcCommunicationService::disconnectDevice() | ||||
| { | { | ||||
| // 关闭串口、清除缓存有效标记,防止断开后继续使用旧值 | |||||
| closeSerialSession(); | closeSerialSession(); | ||||
| repository_.invalidate(); | repository_.invalidate(); | ||||
| updateInitialReadCompleted(false, true); | updateInitialReadCompleted(false, true); | ||||
| @@ -242,6 +296,13 @@ void PlcCommunicationService::disconnectDevice() | |||||
| PlcCommunicationResult PlcCommunicationService::setPollAddresses( | PlcCommunicationResult PlcCommunicationService::setPollAddresses( | ||||
| const std::vector<RegisterAddress> &addresses) | const std::vector<RegisterAddress> &addresses) | ||||
| { | |||||
| return setPollAddresses(addresses, {}); | |||||
| } | |||||
| PlcCommunicationResult PlcCommunicationService::setPollAddresses( | |||||
| const std::vector<RegisterAddress> &addresses, | |||||
| const std::vector<RegisterAddress> &float32_starts) | |||||
| { | { | ||||
| std::vector<RegisterAddress> normalized; | std::vector<RegisterAddress> normalized; | ||||
| std::string error; | std::string error; | ||||
| @@ -249,16 +310,73 @@ PlcCommunicationResult PlcCommunicationService::setPollAddresses( | |||||
| { | { | ||||
| return {false, error}; | return {false, error}; | ||||
| } | } | ||||
| if (pollBlockCount(normalized) > ProjectLimits::kMaximumPollBlocks) | |||||
| std::vector<RegisterAddress> normalized_float32_starts = float32_starts; | |||||
| std::sort( | |||||
| normalized_float32_starts.begin(), normalized_float32_starts.end(), | |||||
| [](const RegisterAddress &left, const RegisterAddress &right) | |||||
| { | |||||
| return left.area() == right.area() | |||||
| ? left.index() < right.index() | |||||
| : left.area() == RegisterArea::M; | |||||
| }); | |||||
| normalized_float32_starts.erase( | |||||
| std::unique( | |||||
| normalized_float32_starts.begin(), normalized_float32_starts.end()), | |||||
| normalized_float32_starts.end()); | |||||
| if (std::any_of( | |||||
| normalized_float32_starts.cbegin(), normalized_float32_starts.cend(), | |||||
| [](const RegisterAddress &address) | |||||
| { | |||||
| return !address.isValid() | |||||
| || address.area() != RegisterArea::D | |||||
| || address.index() >= RegisterAddress::kMaximumIndex; | |||||
| })) | |||||
| { | { | ||||
| return {false, "PLC 轮询地址拆分后最多允许 64 个读块"}; | |||||
| return {false, "Float32 轮询起始地址必须位于 D0~D3999"}; | |||||
| } | } | ||||
| for (const RegisterAddress &start : normalized_float32_starts) | |||||
| { | |||||
| const RegisterAddress high{ | |||||
| RegisterArea::D, start.index() + 1}; | |||||
| if (std::find(normalized.cbegin(), normalized.cend(), start) | |||||
| == normalized.cend()) | |||||
| { | |||||
| normalized.push_back(start); | |||||
| } | |||||
| if (std::find(normalized.cbegin(), normalized.cend(), high) | |||||
| == normalized.cend()) | |||||
| { | |||||
| normalized.push_back(high); | |||||
| } | |||||
| } | |||||
| 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) | |||||
| { | |||||
| return {false, "PLC 轮询的去重 M/D 地址最多为 256 个"}; | |||||
| } | |||||
| if (pollBlockCount(normalized, normalized_float32_starts) | |||||
| > ProjectLimits::kMaximumPollBlocks) | |||||
| { | |||||
| return {false, "PLC 轮询地址拆分后最多允许 8 个读块"}; | |||||
| } | |||||
| // 当前读请求完成前暂存新集合,避免按半旧半新的地址解析回复 | |||||
| if (pending_reply_ != nullptr) | if (pending_reply_ != nullptr) | ||||
| { | { | ||||
| // 当前回复仍在解析旧集合,等它结束后再切换到新集合 | |||||
| pending_poll_addresses_ = std::move(normalized); | pending_poll_addresses_ = std::move(normalized); | ||||
| pending_poll_float32_starts_ = std::move(normalized_float32_starts); | |||||
| poll_update_pending_ = true; | poll_update_pending_ = true; | ||||
| return {true, {}}; | return {true, {}}; | ||||
| } | } | ||||
| poll_float32_starts_ = std::move(normalized_float32_starts); | |||||
| applyPollAddresses(normalized); | applyPollAddresses(normalized); | ||||
| return {true, {}}; | return {true, {}}; | ||||
| } | } | ||||
| @@ -292,12 +410,15 @@ void PlcCommunicationService::setCallbacks( | |||||
| std::function<void()> state_changed, | std::function<void()> state_changed, | ||||
| std::function<void(bool)> initial_read_changed, | std::function<void(bool)> initial_read_changed, | ||||
| std::function<void()> cache_updated, | std::function<void()> cache_updated, | ||||
| std::function<void()> poll_cycle_completed, | |||||
| std::function<void(const std::string &)> error_reported) | std::function<void(const std::string &)> error_reported) | ||||
| { | { | ||||
| state_changed_callback_ = std::move(state_changed); | |||||
| initial_read_changed_callback_ = std::move(initial_read_changed); | |||||
| cache_updated_callback_ = std::move(cache_updated); | |||||
| error_reported_callback_ = std::move(error_reported); | |||||
| // 保存外部通知函数,通信事件发生时再调用对应函数 | |||||
| state_changed_callback_ = std::move(state_changed); // 通信状态变化时通知外部 | |||||
| initial_read_changed_callback_ = std::move(initial_read_changed); // 首读资格变化时通知外部 | |||||
| cache_updated_callback_ = std::move(cache_updated); // PLC 缓存更新后通知外部 | |||||
| poll_cycle_completed_callback_ = std::move(poll_cycle_completed); | |||||
| error_reported_callback_ = std::move(error_reported); // 发生通信错误时通知外部 | |||||
| } | } | ||||
| void PlcCommunicationService::rebuildPollBlocks() | void PlcCommunicationService::rebuildPollBlocks() | ||||
| @@ -305,6 +426,7 @@ void PlcCommunicationService::rebuildPollBlocks() | |||||
| std::vector<RegisterAddress> addresses = poll_addresses_; | std::vector<RegisterAddress> addresses = poll_addresses_; | ||||
| if (addresses.empty()) | if (addresses.empty()) | ||||
| { | { | ||||
| // 没有指定地址时保留 M0/D0,保证连接后仍能验证设备是否响应 | |||||
| addresses = { | addresses = { | ||||
| RegisterAddress{RegisterArea::M, 0}, | RegisterAddress{RegisterArea::M, 0}, | ||||
| RegisterAddress{RegisterArea::D, 0}}; | RegisterAddress{RegisterArea::D, 0}}; | ||||
| @@ -320,6 +442,18 @@ void PlcCommunicationService::rebuildPollBlocks() | |||||
| return left.index() < right.index(); | return left.index() < right.index(); | ||||
| }); | }); | ||||
| addresses.erase(std::unique(addresses.begin(), addresses.end()), addresses.end()); | addresses.erase(std::unique(addresses.begin(), addresses.end()), addresses.end()); | ||||
| std::sort( | |||||
| poll_float32_starts_.begin(), poll_float32_starts_.end(), | |||||
| [](const RegisterAddress &left, const RegisterAddress &right) | |||||
| { | |||||
| return left.area() == right.area() | |||||
| ? left.index() < right.index() | |||||
| : left.area() == RegisterArea::M; | |||||
| }); | |||||
| poll_float32_starts_.erase( | |||||
| std::unique(poll_float32_starts_.begin(), poll_float32_starts_.end()), | |||||
| poll_float32_starts_.end()); | |||||
| // 将相邻地址合并为读块,同时遵守 Modbus 单次读取上限 | |||||
| poll_blocks_.clear(); | poll_blocks_.clear(); | ||||
| for (const RegisterAddress &address : addresses) | for (const RegisterAddress &address : addresses) | ||||
| { | { | ||||
| @@ -327,6 +461,21 @@ void PlcCommunicationService::rebuildPollBlocks() | |||||
| { | { | ||||
| continue; | continue; | ||||
| } | } | ||||
| if (!poll_blocks_.empty() | |||||
| && poll_blocks_.back().area == address.area() | |||||
| && address.index() == poll_blocks_.back().startAddress | |||||
| + poll_blocks_.back().count | |||||
| && poll_blocks_.back().count >= ProjectLimits::kMaximumModbusReadCount | |||||
| && isFloat32Start( | |||||
| poll_float32_starts_, address.area(), address.index() - 1) | |||||
| && poll_blocks_.back().startAddress | |||||
| + poll_blocks_.back().count - 1 == address.index() - 1) | |||||
| { | |||||
| // 把 Float32 低字从已满读块移到新块,保证高字不会单独读取 | |||||
| --poll_blocks_.back().count; | |||||
| poll_blocks_.push_back({address.area(), address.index() - 1, 2}); | |||||
| continue; | |||||
| } | |||||
| if (poll_blocks_.empty() | if (poll_blocks_.empty() | ||||
| || poll_blocks_.back().area != address.area() | || poll_blocks_.back().area != address.area() | ||||
| || address.index() > poll_blocks_.back().startAddress | || address.index() > poll_blocks_.back().startAddress | ||||
| @@ -345,6 +494,7 @@ void PlcCommunicationService::rebuildPollBlocks() | |||||
| next_poll_block_ = 0; | next_poll_block_ = 0; | ||||
| if (!initial_read_completed_) | if (!initial_read_completed_) | ||||
| { | { | ||||
| // 地址集合变化或重新连接后,首读资格从头统计 | |||||
| initial_blocks_read_.assign(poll_blocks_.size(), false); | initial_blocks_read_.assign(poll_blocks_.size(), false); | ||||
| } | } | ||||
| else | else | ||||
| @@ -356,10 +506,12 @@ void PlcCommunicationService::rebuildPollBlocks() | |||||
| void PlcCommunicationService::applyPollAddresses( | void PlcCommunicationService::applyPollAddresses( | ||||
| const std::vector<RegisterAddress> &addresses) | const std::vector<RegisterAddress> &addresses) | ||||
| { | { | ||||
| // 只有在没有读请求占用时,新的地址集合才会真正生效 | |||||
| poll_addresses_ = addresses; | poll_addresses_ = addresses; | ||||
| rebuildPollBlocks(); | rebuildPollBlocks(); | ||||
| poll_update_pending_ = false; | poll_update_pending_ = false; | ||||
| pending_poll_addresses_.clear(); | pending_poll_addresses_.clear(); | ||||
| pending_poll_float32_starts_.clear(); | |||||
| if (isReadingState(state_) && pending_reply_ == nullptr) | if (isReadingState(state_) && pending_reply_ == nullptr) | ||||
| { | { | ||||
| pollNextBlock(); | pollNextBlock(); | ||||
| @@ -373,6 +525,7 @@ void PlcCommunicationService::pollNextBlock() | |||||
| { | { | ||||
| return; | return; | ||||
| } | } | ||||
| // 每次只发一个异步请求,完成回调中再推进到下一个块 | |||||
| const std::size_t block_index = next_poll_block_; | const std::size_t block_index = next_poll_block_; | ||||
| const PollBlock block = poll_blocks_.at(block_index); | const PollBlock block = poll_blocks_.at(block_index); | ||||
| next_poll_block_ = (next_poll_block_ + 1U) % poll_blocks_.size(); | next_poll_block_ = (next_poll_block_ + 1U) % poll_blocks_.size(); | ||||
| @@ -385,11 +538,13 @@ void PlcCommunicationService::pollNextBlock() | |||||
| return; | return; | ||||
| } | } | ||||
| pending_reply_ = reply; | pending_reply_ = reply; | ||||
| // 把当前连接代次带进回调,防止断线重连后旧回复误更新新连接 | |||||
| const std::uint64_t generation = connection_generation_; | const std::uint64_t generation = connection_generation_; | ||||
| connect(reply, &QModbusReply::finished, | connect(reply, &QModbusReply::finished, | ||||
| this, | this, | ||||
| [this, reply, block, block_index, generation] | [this, reply, block, block_index, generation] | ||||
| { | { | ||||
| // 连接已经重建时,直接丢弃旧回复 | |||||
| if (generation != connection_generation_) | if (generation != connection_generation_) | ||||
| { | { | ||||
| reply->deleteLater(); | reply->deleteLater(); | ||||
| @@ -406,6 +561,7 @@ void PlcCommunicationService::pollNextBlock() | |||||
| initial_blocks_read_.cbegin(), | initial_blocks_read_.cbegin(), | ||||
| initial_blocks_read_.cend(), | initial_blocks_read_.cend(), | ||||
| [](bool read) { return read; }); | [](bool read) { return read; }); | ||||
| // 所有轮询块都成功读过,才授予真机运行资格 | |||||
| if (completed && !initial_read_completed_) | if (completed && !initial_read_completed_) | ||||
| { | { | ||||
| updateInitialReadCompleted(true); | updateInitialReadCompleted(true); | ||||
| @@ -415,21 +571,36 @@ void PlcCommunicationService::pollNextBlock() | |||||
| } | } | ||||
| } | } | ||||
| } | } | ||||
| const bool poll_cycle_completed = isReadingState(state_) | |||||
| && reply->error() == QModbusDevice::NoError | |||||
| && !poll_update_pending_ | |||||
| && next_poll_block_ == 0U; | |||||
| if (pending_reply_ == reply) | if (pending_reply_ == reply) | ||||
| { | { | ||||
| // 只有当前指针仍指向本次回复时才清空,避免误清新请求 | |||||
| pending_reply_ = nullptr; | pending_reply_ = nullptr; | ||||
| } | } | ||||
| reply->deleteLater(); | reply->deleteLater(); | ||||
| if (poll_update_pending_) | if (poll_update_pending_) | ||||
| { | { | ||||
| const std::vector<RegisterAddress> addresses = pending_poll_addresses_; | const std::vector<RegisterAddress> addresses = pending_poll_addresses_; | ||||
| poll_float32_starts_ = pending_poll_float32_starts_; | |||||
| applyPollAddresses(addresses); | applyPollAddresses(addresses); | ||||
| } | } | ||||
| if (poll_cycle_completed) | |||||
| { | |||||
| emit pollCycleCompleted(); | |||||
| if (poll_cycle_completed_callback_) | |||||
| { | |||||
| poll_cycle_completed_callback_(); | |||||
| } | |||||
| } | |||||
| }); | }); | ||||
| } | } | ||||
| void PlcCommunicationService::probeRecovery() | void PlcCommunicationService::probeRecovery() | ||||
| { | { | ||||
| // 只在可恢复的超时故障中探测;串口拔出等故障不会反复探测 | |||||
| if (state_ != PlcConnectionState::Faulted | if (state_ != PlcConnectionState::Faulted | ||||
| || !isRecoverableTimeout(last_error_type_)) | || !isRecoverableTimeout(last_error_type_)) | ||||
| { | { | ||||
| @@ -441,6 +612,7 @@ void PlcCommunicationService::probeRecovery() | |||||
| } | } | ||||
| if (pending_reply_ != nullptr) | if (pending_reply_ != nullptr) | ||||
| { | { | ||||
| // 已有读请求在途,稍后再探测,保证同一时间只有一个读回复 | |||||
| recovery_timer_.start(kRecoveryProbeIntervalMs); | recovery_timer_.start(kRecoveryProbeIntervalMs); | ||||
| return; | return; | ||||
| } | } | ||||
| @@ -448,6 +620,7 @@ void PlcCommunicationService::probeRecovery() | |||||
| { | { | ||||
| return; | return; | ||||
| } | } | ||||
| // 恢复探测只读取一个地址,确认链路恢复后再进行完整首读 | |||||
| const PollBlock block = poll_blocks_.front(); | const PollBlock block = poll_blocks_.front(); | ||||
| const QModbusDataUnit request( | const QModbusDataUnit request( | ||||
| registerType(block.area), block.startAddress, 1); | registerType(block.area), block.startAddress, 1); | ||||
| @@ -477,6 +650,7 @@ void PlcCommunicationService::probeRecovery() | |||||
| if (poll_update_pending_) | if (poll_update_pending_) | ||||
| { | { | ||||
| const std::vector<RegisterAddress> addresses = pending_poll_addresses_; | const std::vector<RegisterAddress> addresses = pending_poll_addresses_; | ||||
| poll_float32_starts_ = pending_poll_float32_starts_; | |||||
| applyPollAddresses(addresses); | applyPollAddresses(addresses); | ||||
| } | } | ||||
| if (state_ != PlcConnectionState::Faulted | if (state_ != PlcConnectionState::Faulted | ||||
| @@ -514,6 +688,7 @@ void PlcCommunicationService::handleRecoveryProbeFailure(QModbusDevice::Error er | |||||
| void PlcCommunicationService::restoreCommunication() | void PlcCommunicationService::restoreCommunication() | ||||
| { | { | ||||
| // 探测成功只说明链路恢复,仍要重新读取全部读块才能恢复真机资格 | |||||
| received_valid_response_ = true; | received_valid_response_ = true; | ||||
| last_error_type_ = PlcCommunicationError::None; | last_error_type_ = PlcCommunicationError::None; | ||||
| last_error_.clear(); | last_error_.clear(); | ||||
| @@ -534,6 +709,7 @@ void PlcCommunicationService::handleReadFinished(QModbusReply *reply, PollBlock | |||||
| handleModbusError(reply->error()); | handleModbusError(reply->error()); | ||||
| return; | return; | ||||
| } | } | ||||
| // 只有成功回复才能更新 PLC 缓存;写请求不会直接改缓存 | |||||
| const QModbusDataUnit result = reply->result(); | const QModbusDataUnit result = reply->result(); | ||||
| for (uint index = 0; index < result.valueCount(); ++index) | for (uint index = 0; index < result.valueCount(); ++index) | ||||
| { | { | ||||
| @@ -560,6 +736,7 @@ void PlcCommunicationService::handleReadFinished(QModbusReply *reply, PollBlock | |||||
| RegisterWriteResult PlcCommunicationService::sendBitWrite( | RegisterWriteResult PlcCommunicationService::sendBitWrite( | ||||
| const RegisterAddress &address, bool value) | const RegisterAddress &address, bool value) | ||||
| { | { | ||||
| // 写入只允许在正常 Connected 状态进行,Recovering/Faulted 都拒绝 | |||||
| if (state_ != PlcConnectionState::Connected) | if (state_ != PlcConnectionState::Connected) | ||||
| { | { | ||||
| return {false, RegisterError::Unavailable}; | return {false, RegisterError::Unavailable}; | ||||
| @@ -568,6 +745,7 @@ RegisterWriteResult PlcCommunicationService::sendBitWrite( | |||||
| { | { | ||||
| return {false, RegisterError::WriteRejected}; | return {false, RegisterError::WriteRejected}; | ||||
| } | } | ||||
| // M 区对应 Modbus Coils,单次只写一个地址 | |||||
| QModbusDataUnit unit(QModbusDataUnit::Coils, address.index(), 1); | QModbusDataUnit unit(QModbusDataUnit::Coils, address.index(), 1); | ||||
| unit.setValue(0, value ? 1U : 0U); | unit.setValue(0, value ? 1U : 0U); | ||||
| QModbusReply *reply = master_->sendWriteRequest(unit, configuration_.serverAddress); | QModbusReply *reply = master_->sendWriteRequest(unit, configuration_.serverAddress); | ||||
| @@ -577,6 +755,7 @@ RegisterWriteResult PlcCommunicationService::sendBitWrite( | |||||
| return {false, RegisterError::WriteRejected}; | return {false, RegisterError::WriteRejected}; | ||||
| } | } | ||||
| pending_write_reply_ = reply; | pending_write_reply_ = reply; | ||||
| // 写回复完成后只处理成功或错误,不直接改缓存;后续轮询负责确认真实值 | |||||
| connect(reply, &QModbusReply::finished, | connect(reply, &QModbusReply::finished, | ||||
| this, | this, | ||||
| [this, reply, generation = connection_generation_] | [this, reply, generation = connection_generation_] | ||||
| @@ -602,6 +781,7 @@ RegisterWriteResult PlcCommunicationService::sendBitWrite( | |||||
| RegisterWriteResult PlcCommunicationService::sendWordWrite( | RegisterWriteResult PlcCommunicationService::sendWordWrite( | ||||
| const RegisterAddress &address, std::int16_t value) | const RegisterAddress &address, std::int16_t value) | ||||
| { | { | ||||
| // D 区写入流程与 M 区相同,只是 Modbus 类型不同 | |||||
| if (state_ != PlcConnectionState::Connected) | if (state_ != PlcConnectionState::Connected) | ||||
| { | { | ||||
| return {false, RegisterError::Unavailable}; | return {false, RegisterError::Unavailable}; | ||||
| @@ -610,6 +790,7 @@ RegisterWriteResult PlcCommunicationService::sendWordWrite( | |||||
| { | { | ||||
| return {false, RegisterError::WriteRejected}; | return {false, RegisterError::WriteRejected}; | ||||
| } | } | ||||
| // D 区对应 Modbus HoldingRegisters,单次只写一个字 | |||||
| QModbusDataUnit unit(QModbusDataUnit::HoldingRegisters, address.index(), 1); | QModbusDataUnit unit(QModbusDataUnit::HoldingRegisters, address.index(), 1); | ||||
| unit.setValue(0, static_cast<quint16>(value)); | unit.setValue(0, static_cast<quint16>(value)); | ||||
| QModbusReply *reply = master_->sendWriteRequest(unit, configuration_.serverAddress); | QModbusReply *reply = master_->sendWriteRequest(unit, configuration_.serverAddress); | ||||
| @@ -641,6 +822,53 @@ RegisterWriteResult PlcCommunicationService::sendWordWrite( | |||||
| return {true, RegisterError::None}; | return {true, RegisterError::None}; | ||||
| } | } | ||||
| RegisterWriteResult PlcCommunicationService::sendWordPairWrite( | |||||
| const RegisterAddress &address, | |||||
| const std::array<std::int16_t, 2> &values) | |||||
| { | |||||
| if (state_ != PlcConnectionState::Connected) | |||||
| { | |||||
| return {false, RegisterError::Unavailable}; | |||||
| } | |||||
| if (pending_write_reply_ != nullptr | |||||
| || !address.isValid() || address.area() != RegisterArea::D | |||||
| || address.index() >= RegisterAddress::kMaximumIndex) | |||||
| { | |||||
| return {false, pending_write_reply_ != nullptr | |||||
| ? RegisterError::WriteRejected : RegisterError::InvalidAddress}; | |||||
| } | |||||
| QModbusDataUnit unit(QModbusDataUnit::HoldingRegisters, address.index(), 2); | |||||
| unit.setValue(0, static_cast<quint16>(values[0])); | |||||
| unit.setValue(1, static_cast<quint16>(values[1])); | |||||
| QModbusReply *reply = master_->sendWriteRequest(unit, configuration_.serverAddress); | |||||
| if (reply == nullptr) | |||||
| { | |||||
| 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(); | |||||
| return; | |||||
| } | |||||
| if (reply->error() != QModbusDevice::NoError) | |||||
| { | |||||
| handleModbusError(reply->error()); | |||||
| } | |||||
| reply->deleteLater(); | |||||
| }); | |||||
| return {true, RegisterError::None}; | |||||
| } | |||||
| void PlcCommunicationService::updateInitialReadCompleted( | void PlcCommunicationService::updateInitialReadCompleted( | ||||
| bool completed, bool force_notification) | bool completed, bool force_notification) | ||||
| { | { | ||||
| @@ -648,6 +876,7 @@ void PlcCommunicationService::updateInitialReadCompleted( | |||||
| { | { | ||||
| return; | return; | ||||
| } | } | ||||
| // 只有状态真的变化,或调用方明确要求通知时才发出信号 | |||||
| initial_read_completed_ = completed; | initial_read_completed_ = completed; | ||||
| emit initialReadCompletedChanged(completed); | emit initialReadCompletedChanged(completed); | ||||
| if (initial_read_changed_callback_) | if (initial_read_changed_callback_) | ||||
| @@ -658,6 +887,7 @@ void PlcCommunicationService::updateInitialReadCompleted( | |||||
| void PlcCommunicationService::handleUnexpectedDisconnect() | void PlcCommunicationService::handleUnexpectedDisconnect() | ||||
| { | { | ||||
| // 这里表示设备原本连上过,后来串口意外断开 | |||||
| serial_session_opened_ = false; | serial_session_opened_ = false; | ||||
| const QString port_name = QString::fromStdString(configuration_.portName).trimmed(); | const QString port_name = QString::fromStdString(configuration_.portName).trimmed(); | ||||
| setError({ | setError({ | ||||
| @@ -669,6 +899,7 @@ void PlcCommunicationService::handleUnexpectedDisconnect() | |||||
| void PlcCommunicationService::handleModbusError(QModbusDevice::Error error) | void PlcCommunicationService::handleModbusError(QModbusDevice::Error error) | ||||
| { | { | ||||
| // 主动断开、已处理的故障和旧回复错误都不重复上报 | |||||
| if (disconnecting_ | if (disconnecting_ | ||||
| || (error == QModbusDevice::ReplyAbortedError | || (error == QModbusDevice::ReplyAbortedError | ||||
| && state_ == PlcConnectionState::Disconnected) | && state_ == PlcConnectionState::Disconnected) | ||||
| @@ -687,6 +918,7 @@ void PlcCommunicationService::handleModbusError(QModbusDevice::Error error) | |||||
| void PlcCommunicationService::closeSerialSession() | void PlcCommunicationService::closeSerialSession() | ||||
| { | { | ||||
| // 先递增代次并停止定时器,再断开串口;旧异步回调会因此失效 | |||||
| ++connection_generation_; | ++connection_generation_; | ||||
| disconnecting_ = true; | disconnecting_ = true; | ||||
| poll_timer_.stop(); | poll_timer_.stop(); | ||||
| @@ -710,6 +942,7 @@ void PlcCommunicationService::setState(PlcConnectionState state) | |||||
| { | { | ||||
| return; | return; | ||||
| } | } | ||||
| // 状态集中从这里修改,确保 Qt 信号和 std::function 回调同步触发 | |||||
| state_ = state; | state_ = state; | ||||
| emit stateChanged(); | emit stateChanged(); | ||||
| if (state_changed_callback_) | if (state_changed_callback_) | ||||
| @@ -720,10 +953,12 @@ void PlcCommunicationService::setState(PlcConnectionState state) | |||||
| void PlcCommunicationService::setError(const PlcCommunicationFailure &failure) | void PlcCommunicationService::setError(const PlcCommunicationFailure &failure) | ||||
| { | { | ||||
| // 统一保存错误、停止正常轮询、撤销首读资格并通知 UI | |||||
| last_error_type_ = failure.type; | last_error_type_ = failure.type; | ||||
| last_error_ = toUtf8(failure.message); | last_error_ = toUtf8(failure.message); | ||||
| poll_timer_.stop(); | poll_timer_.stop(); | ||||
| recovery_timer_.stop(); | recovery_timer_.stop(); | ||||
| // 任意通信故障都会撤销首读资格;恢复后必须重新完整读取 | |||||
| updateInitialReadCompleted(false); | updateInitialReadCompleted(false); | ||||
| const bool disconnected = failure.type == PlcCommunicationError::SerialPortOpenFailed | const bool disconnected = failure.type == PlcCommunicationError::SerialPortOpenFailed | ||||
| || failure.type == PlcCommunicationError::SerialConnectionLost | || failure.type == PlcCommunicationError::SerialConnectionLost | ||||
| @@ -745,3 +980,5 @@ void PlcCommunicationService::setError(const PlcCommunicationFailure &failure) | |||||
| recovery_timer_.start(kRecoveryProbeIntervalMs); | recovery_timer_.start(kRecoveryProbeIntervalMs); | ||||
| } | } | ||||
| } | } | ||||
| // 按区域和地址排序,方便后面合并连续读块 | |||||
| // 相同区域和编号的地址只保留一个 | |||||
| @@ -8,6 +8,7 @@ | |||||
| #include <QTimer> | #include <QTimer> | ||||
| #include <cstdint> | #include <cstdint> | ||||
| #include <array> | |||||
| #include <memory> | #include <memory> | ||||
| #include <string> | #include <string> | ||||
| #include <vector> | #include <vector> | ||||
| @@ -17,87 +18,132 @@ class QModbusReply; | |||||
| class QModbusRtuSerialMaster; | class QModbusRtuSerialMaster; | ||||
| struct PlcCommunicationFailure; | struct PlcCommunicationFailure; | ||||
| // 基于 Qt Modbus RTU 的异步 PLC 通信实现 | |||||
| // 负责连接、轮询、单点写入、故障恢复和首读资格,不阻塞 UI 线程 | |||||
| class PlcCommunicationService final : public QObject, public PlcCommunicationGateway | class PlcCommunicationService final : public QObject, public PlcCommunicationGateway | ||||
| { | { | ||||
| Q_OBJECT | Q_OBJECT | ||||
| public: | public: | ||||
| // 创建通信服务,并把 PLC 缓存仓库交给它管理 | |||||
| explicit PlcCommunicationService( | explicit PlcCommunicationService( | ||||
| PlcRegisterRepository &repository, | PlcRegisterRepository &repository, | ||||
| QObject *parent = nullptr); | QObject *parent = nullptr); | ||||
| // 释放 Modbus 主站和未完成的通信资源 | |||||
| ~PlcCommunicationService() override; | ~PlcCommunicationService() override; | ||||
| // 按给定串口参数连接 PLC,并开始异步轮询 | |||||
| PlcCommunicationResult connectDevice( | PlcCommunicationResult connectDevice( | ||||
| const PlcSerialConfiguration &configuration) override; | const PlcSerialConfiguration &configuration) override; | ||||
| // 停止轮询并断开 PLC | |||||
| void disconnectDevice() override; | void disconnectDevice() override; | ||||
| // 设置需要周期性读取的 M/D 地址集合 | |||||
| PlcCommunicationResult setPollAddresses( | PlcCommunicationResult setPollAddresses( | ||||
| const std::vector<RegisterAddress> &addresses) override; | const std::vector<RegisterAddress> &addresses) override; | ||||
| PlcCommunicationResult setPollAddresses( | |||||
| const std::vector<RegisterAddress> &addresses, | |||||
| const std::vector<RegisterAddress> &float32_starts) override; | |||||
| // 返回当前连接状态 | |||||
| PlcConnectionState state() const override; | PlcConnectionState state() const override; | ||||
| // 判断本次连接是否已经完成所有轮询块的首次读取 | |||||
| bool initialReadCompleted() const override; | bool initialReadCompleted() const override; | ||||
| // 返回最近一次通信错误的类型 | |||||
| PlcCommunicationError lastErrorType() const override; | PlcCommunicationError lastErrorType() const override; | ||||
| // 返回最近一次通信错误的文字 | |||||
| const std::string &lastError() const override; | const std::string &lastError() const override; | ||||
| // 返回当前使用的串口和 Modbus 参数 | |||||
| const PlcSerialConfiguration &configuration() const; | const PlcSerialConfiguration &configuration() const; | ||||
| // 注册状态、首读、缓存更新和错误通知回调 | |||||
| void setCallbacks( | void setCallbacks( | ||||
| std::function<void()> state_changed, | std::function<void()> state_changed, | ||||
| std::function<void(bool)> initial_read_changed, | std::function<void(bool)> initial_read_changed, | ||||
| std::function<void()> cache_updated, | std::function<void()> cache_updated, | ||||
| std::function<void()> poll_cycle_completed, | |||||
| std::function<void(const std::string &)> error_reported) override; | std::function<void(const std::string &)> error_reported) override; | ||||
| signals: | signals: | ||||
| // 连接状态发生变化 | |||||
| void stateChanged(); | void stateChanged(); | ||||
| // 首次完整读取资格发生变化 | |||||
| void initialReadCompletedChanged(bool completed); | void initialReadCompletedChanged(bool completed); | ||||
| // PLC 缓存收到新的成功读数 | |||||
| void cacheUpdated(); | void cacheUpdated(); | ||||
| // 全部轮询块成功更新一轮 | |||||
| void pollCycleCompleted(); | |||||
| // 通信错误的可读提示文字 | |||||
| void communicationError(const QString &message); | void communicationError(const QString &message); | ||||
| private: | private: | ||||
| struct PollBlock | struct PollBlock | ||||
| { | { | ||||
| RegisterArea area = RegisterArea::M; | |||||
| int startAddress = 0; | |||||
| int count = 1; | |||||
| // 同一区域的一段连续 Modbus 原始地址 | |||||
| RegisterArea area = RegisterArea::M; // M 区或 D 区 | |||||
| int startAddress = 0; // 读块的第一个原始地址 | |||||
| int count = 1; // 从起始地址连续读取的数量 | |||||
| }; | }; | ||||
| // 把去重后的地址集合压缩为有限数量的连续读块 | |||||
| void rebuildPollBlocks(); | void rebuildPollBlocks(); | ||||
| // 应用新的轮询集合;有请求在途时由 pending_* 延后应用 | |||||
| void applyPollAddresses(const std::vector<RegisterAddress> &addresses); | void applyPollAddresses(const std::vector<RegisterAddress> &addresses); | ||||
| // 发送下一段异步读取请求 | |||||
| void pollNextBlock(); | void pollNextBlock(); | ||||
| // Faulted 状态下用轻量探测判断通信是否恢复 | |||||
| void probeRecovery(); | void probeRecovery(); | ||||
| // 处理恢复探测失败,并安排下一次探测 | |||||
| void handleRecoveryProbeFailure(QModbusDevice::Error error); | void handleRecoveryProbeFailure(QModbusDevice::Error error); | ||||
| // 探测成功后恢复轮询,但仍需重新完成首读 | |||||
| void restoreCommunication(); | void restoreCommunication(); | ||||
| // 处理读回复并把值写入 PLC 缓存 | |||||
| void handleReadFinished(QModbusReply *reply, PollBlock block); | void handleReadFinished(QModbusReply *reply, PollBlock block); | ||||
| // 发送单点 M/D 写请求;缓存等待后续轮询确认 | |||||
| RegisterWriteResult sendBitWrite(const RegisterAddress &address, bool value); | RegisterWriteResult sendBitWrite(const RegisterAddress &address, bool value); | ||||
| RegisterWriteResult sendWordWrite( | RegisterWriteResult sendWordWrite( | ||||
| const RegisterAddress &address, std::int16_t value); | const RegisterAddress &address, std::int16_t value); | ||||
| RegisterWriteResult sendWordPairWrite( | |||||
| const RegisterAddress &address, | |||||
| const std::array<std::int16_t, 2> &values); | |||||
| // 更新“所有轮询块均成功读取”的真机进入资格 | |||||
| void updateInitialReadCompleted(bool completed, bool force_notification = false); | void updateInitialReadCompleted(bool completed, bool force_notification = false); | ||||
| // 处理没有主动断开时发生的串口断线 | |||||
| void handleUnexpectedDisconnect(); | void handleUnexpectedDisconnect(); | ||||
| // 把 Qt Modbus 错误转换成项目自己的通信错误 | |||||
| void handleModbusError(QModbusDevice::Error error); | void handleModbusError(QModbusDevice::Error error); | ||||
| // 关闭串口并清理当前连接的请求和状态 | |||||
| void closeSerialSession(); | void closeSerialSession(); | ||||
| // 修改连接状态并通知外部观察者 | |||||
| void setState(PlcConnectionState state); | void setState(PlcConnectionState state); | ||||
| // 保存错误信息、更新状态并通知外部观察者 | |||||
| void setError(const PlcCommunicationFailure &failure); | void setError(const PlcCommunicationFailure &failure); | ||||
| PlcRegisterRepository &repository_; | |||||
| std::unique_ptr<QModbusRtuSerialMaster> master_; | |||||
| QTimer poll_timer_; | |||||
| QTimer recovery_timer_; | |||||
| PlcSerialConfiguration configuration_; | |||||
| std::vector<RegisterAddress> poll_addresses_; | |||||
| std::vector<RegisterAddress> pending_poll_addresses_; | |||||
| 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; | |||||
| bool received_valid_response_ = false; | |||||
| std::uint64_t connection_generation_ = 0; | |||||
| PlcConnectionState state_ = PlcConnectionState::Disconnected; | |||||
| bool initial_read_completed_ = false; | |||||
| PlcRegisterRepository &repository_; // 用于保存 PLC 最近一次成功读回的 M/D 值 | |||||
| std::unique_ptr<QModbusRtuSerialMaster> master_; // Qt Modbus RTU 主站对象 | |||||
| QTimer poll_timer_; // 周期性触发下一轮轮询 | |||||
| QTimer recovery_timer_; // Faulted 状态下定时发起恢复探测 | |||||
| PlcSerialConfiguration configuration_; // 当前串口和站号配置 | |||||
| std::vector<RegisterAddress> poll_addresses_; // 当前生效的轮询地址 | |||||
| std::vector<RegisterAddress> pending_poll_addresses_; // 请求在途时暂存的新地址 | |||||
| std::vector<RegisterAddress> poll_float32_starts_; // 当前轮询集合中的 Float32 起始地址 | |||||
| std::vector<RegisterAddress> pending_poll_float32_starts_; // 请求在途时暂存的 Float32 起始地址 | |||||
| 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; // 本次串口会话是否曾经成功打开 | |||||
| bool received_valid_response_ = false; // 本次连接是否收到过有效 PLC 回复 | |||||
| std::uint64_t connection_generation_ = 0; // 连接代次,用于丢弃旧连接的异步回复 | |||||
| PlcConnectionState state_ = PlcConnectionState::Disconnected; // 当前通信状态 | |||||
| bool initial_read_completed_ = false; // 是否已完成首读,可作为真机运行门槛 | |||||
| // 首读阶段每个读块的完成标记,全部为 true 后才允许进入真机 | |||||
| std::vector<bool> initial_blocks_read_; | std::vector<bool> initial_blocks_read_; | ||||
| PlcCommunicationError last_error_type_ = PlcCommunicationError::None; | |||||
| std::string last_error_; | |||||
| std::function<void()> state_changed_callback_; | |||||
| std::function<void(bool)> initial_read_changed_callback_; | |||||
| std::function<void()> cache_updated_callback_; | |||||
| std::function<void(const std::string &)> error_reported_callback_; | |||||
| PlcCommunicationError last_error_type_ = PlcCommunicationError::None; // 最近一次错误类型 | |||||
| std::string last_error_; // 最近一次错误的可读文字 | |||||
| std::function<void()> state_changed_callback_; // 状态变化时调用 | |||||
| std::function<void(bool)> initial_read_changed_callback_; // 首读资格变化时调用 | |||||
| std::function<void()> cache_updated_callback_; // 缓存更新后调用 | |||||
| std::function<void()> poll_cycle_completed_callback_; // 完整轮询一轮后调用 | |||||
| std::function<void(const std::string &)> error_reported_callback_; // 发生错误时调用 | |||||
| }; | }; | ||||
| @@ -0,0 +1,304 @@ | |||||
| #include "plc_discovery_service.h" | |||||
| #include <QModbusDataUnit> | |||||
| #include <QModbusPdu> | |||||
| #include <QModbusReply> | |||||
| #include <QModbusRtuSerialMaster> | |||||
| #include <QSerialPort> | |||||
| #include <QSerialPortInfo> | |||||
| #include <QTimer> | |||||
| #include <QVariant> | |||||
| #include <string> | |||||
| #include <utility> | |||||
| namespace { | |||||
| // 本地 RTU 探测无需沿用正式轮询的长超时和重试,否则多端口搜索会过慢 | |||||
| constexpr int kDiscoveryResponseTimeoutMs = 200; | |||||
| // 正式连接可能刚被配置窗口断开,先留出时间让操作系统完全释放串口句柄 | |||||
| constexpr int kDiscoveryStartDelayMs = 100; | |||||
| std::string toUtf8(const QString &value) | |||||
| { | |||||
| const QByteArray bytes = value.toUtf8(); | |||||
| return std::string(bytes.constData(), static_cast<std::size_t>(bytes.size())); | |||||
| } | |||||
| } // namespace | |||||
| PlcDiscoveryService::PlcDiscoveryService(QObject *parent) | |||||
| : QObject(parent), | |||||
| master_(std::make_unique<QModbusRtuSerialMaster>()) | |||||
| { | |||||
| connect( | |||||
| master_.get(), &QModbusClient::stateChanged, | |||||
| this, &PlcDiscoveryService::handleDeviceStateChanged); | |||||
| } | |||||
| PlcDiscoveryService::~PlcDiscoveryService() | |||||
| { | |||||
| progress_changed_callback_ = {}; | |||||
| discovery_finished_callback_ = {}; | |||||
| if (master_->state() != QModbusDevice::UnconnectedState) | |||||
| { | |||||
| master_->disconnectDevice(); | |||||
| } | |||||
| } | |||||
| PlcCommunicationResult PlcDiscoveryService::startDiscovery( | |||||
| const PlcSerialConfiguration &preferred) | |||||
| { | |||||
| if (discovering_) | |||||
| { | |||||
| return {false, "PLC 自动搜索正在进行"}; | |||||
| } | |||||
| PlcSerialConfiguration validation_configuration = preferred; | |||||
| validation_configuration.portName = "DISCOVERY"; | |||||
| const PlcCommunicationResult validation = | |||||
| validatePlcSerialConfiguration(validation_configuration); | |||||
| if (!validation.succeeded) | |||||
| { | |||||
| return validation; | |||||
| } | |||||
| std::vector<std::string> available_ports; | |||||
| for (const QSerialPortInfo &port : QSerialPortInfo::availablePorts()) | |||||
| { | |||||
| available_ports.push_back(toUtf8(port.portName())); | |||||
| } | |||||
| candidates_ = buildPlcDiscoveryCandidates(preferred, available_ports); | |||||
| if (candidates_.empty()) | |||||
| { | |||||
| return {false, "没有检测到可用串口"}; | |||||
| } | |||||
| if (master_->state() != QModbusDevice::UnconnectedState) | |||||
| { | |||||
| return {false, "自动搜索串口尚未释放,请稍后重试"}; | |||||
| } | |||||
| current_candidate_ = 0; | |||||
| pending_reply_.clear(); | |||||
| phase_ = Phase::Idle; | |||||
| discovering_ = true; | |||||
| cancel_requested_ = false; | |||||
| found_pending_ = false; | |||||
| ++attempt_generation_; | |||||
| QTimer::singleShot( | |||||
| kDiscoveryStartDelayMs, | |||||
| this, | |||||
| &PlcDiscoveryService::tryCurrentCandidate); | |||||
| return {true, {}}; | |||||
| } | |||||
| void PlcDiscoveryService::cancelDiscovery() | |||||
| { | |||||
| if (!discovering_ || cancel_requested_) | |||||
| { | |||||
| return; | |||||
| } | |||||
| cancel_requested_ = true; | |||||
| found_pending_ = false; | |||||
| ++attempt_generation_; | |||||
| disconnectCurrent(false); | |||||
| } | |||||
| bool PlcDiscoveryService::isDiscovering() const | |||||
| { | |||||
| return discovering_; | |||||
| } | |||||
| void PlcDiscoveryService::setCallbacks( | |||||
| std::function<void(const PlcDiscoveryProgress &)> progress_changed, | |||||
| std::function<void(const PlcDiscoveryOutcome &)> discovery_finished) | |||||
| { | |||||
| progress_changed_callback_ = std::move(progress_changed); | |||||
| discovery_finished_callback_ = std::move(discovery_finished); | |||||
| } | |||||
| void PlcDiscoveryService::tryCurrentCandidate() | |||||
| { | |||||
| if (!discovering_) | |||||
| { | |||||
| return; | |||||
| } | |||||
| if (cancel_requested_) | |||||
| { | |||||
| disconnectCurrent(false); | |||||
| return; | |||||
| } | |||||
| if (current_candidate_ >= candidates_.size()) | |||||
| { | |||||
| finishDiscovery({ | |||||
| false, false, {}, | |||||
| "未找到可响应的 PLC,请检查站号、接线和 PLC RTU 设置"}); | |||||
| return; | |||||
| } | |||||
| const PlcSerialConfiguration &candidate = candidates_.at(current_candidate_); | |||||
| if (progress_changed_callback_) | |||||
| { | |||||
| progress_changed_callback_({ | |||||
| candidate, current_candidate_ + 1U, candidates_.size()}); | |||||
| } | |||||
| ++attempt_generation_; | |||||
| phase_ = Phase::Connecting; | |||||
| master_->setConnectionParameter( | |||||
| QModbusDevice::SerialPortNameParameter, | |||||
| QString::fromUtf8(candidate.portName.c_str())); | |||||
| master_->setConnectionParameter( | |||||
| QModbusDevice::SerialBaudRateParameter, candidate.baudRate); | |||||
| master_->setConnectionParameter( | |||||
| QModbusDevice::SerialDataBitsParameter, | |||||
| static_cast<QSerialPort::DataBits>(candidate.dataBits)); | |||||
| master_->setConnectionParameter( | |||||
| QModbusDevice::SerialParityParameter, | |||||
| static_cast<QSerialPort::Parity>(candidate.parity)); | |||||
| master_->setConnectionParameter( | |||||
| QModbusDevice::SerialStopBitsParameter, | |||||
| static_cast<QSerialPort::StopBits>(candidate.stopBits)); | |||||
| master_->setTimeout(kDiscoveryResponseTimeoutMs); | |||||
| master_->setNumberOfRetries(0); | |||||
| if (!master_->connectDevice()) | |||||
| { | |||||
| QTimer::singleShot( | |||||
| 0, this, | |||||
| [this] | |||||
| { | |||||
| if (discovering_ && phase_ == Phase::Connecting) | |||||
| { | |||||
| disconnectCurrent(false); | |||||
| } | |||||
| }); | |||||
| } | |||||
| } | |||||
| void PlcDiscoveryService::sendProbe() | |||||
| { | |||||
| if (!discovering_ || cancel_requested_ | |||||
| || current_candidate_ >= candidates_.size()) | |||||
| { | |||||
| disconnectCurrent(false); | |||||
| return; | |||||
| } | |||||
| phase_ = Phase::Probing; | |||||
| const PlcSerialConfiguration &candidate = candidates_.at(current_candidate_); | |||||
| const QModbusDataUnit request( | |||||
| QModbusDataUnit::HoldingRegisters, 0, 1); | |||||
| QModbusReply *reply = master_->sendReadRequest( | |||||
| request, candidate.serverAddress); | |||||
| if (reply == nullptr) | |||||
| { | |||||
| disconnectCurrent(false); | |||||
| return; | |||||
| } | |||||
| pending_reply_ = reply; | |||||
| const std::uint64_t generation = attempt_generation_; | |||||
| connect( | |||||
| reply, &QModbusReply::finished, | |||||
| this, | |||||
| [this, reply, generation] | |||||
| { | |||||
| handleProbeFinished(reply, generation); | |||||
| }); | |||||
| } | |||||
| void PlcDiscoveryService::handleProbeFinished( | |||||
| QModbusReply *reply, | |||||
| std::uint64_t attempt_generation) | |||||
| { | |||||
| const bool is_current_attempt = discovering_ | |||||
| && !cancel_requested_ | |||||
| && phase_ == Phase::Probing | |||||
| && attempt_generation == attempt_generation_; | |||||
| if (pending_reply_ == reply) | |||||
| { | |||||
| pending_reply_.clear(); | |||||
| } | |||||
| const bool valid_response = reply->error() == QModbusDevice::NoError | |||||
| || reply->rawResult().isException(); | |||||
| reply->deleteLater(); | |||||
| if (is_current_attempt) | |||||
| { | |||||
| disconnectCurrent(valid_response); | |||||
| } | |||||
| } | |||||
| void PlcDiscoveryService::disconnectCurrent(bool found) | |||||
| { | |||||
| if (!discovering_) | |||||
| { | |||||
| return; | |||||
| } | |||||
| found_pending_ = found && !cancel_requested_; | |||||
| phase_ = Phase::Disconnecting; | |||||
| if (master_->state() == QModbusDevice::UnconnectedState) | |||||
| { | |||||
| QTimer::singleShot(0, this, &PlcDiscoveryService::continueAfterDisconnect); | |||||
| return; | |||||
| } | |||||
| master_->disconnectDevice(); | |||||
| } | |||||
| void PlcDiscoveryService::continueAfterDisconnect() | |||||
| { | |||||
| if (!discovering_ || phase_ != Phase::Disconnecting) | |||||
| { | |||||
| return; | |||||
| } | |||||
| if (cancel_requested_) | |||||
| { | |||||
| finishDiscovery({false, true, {}, {}}); | |||||
| return; | |||||
| } | |||||
| if (found_pending_) | |||||
| { | |||||
| finishDiscovery({ | |||||
| true, false, candidates_.at(current_candidate_), | |||||
| "已找到 PLC,正在连接"}); | |||||
| return; | |||||
| } | |||||
| ++current_candidate_; | |||||
| phase_ = Phase::Idle; | |||||
| QTimer::singleShot(0, this, &PlcDiscoveryService::tryCurrentCandidate); | |||||
| } | |||||
| void PlcDiscoveryService::finishDiscovery(const PlcDiscoveryOutcome &outcome) | |||||
| { | |||||
| discovering_ = false; | |||||
| cancel_requested_ = false; | |||||
| found_pending_ = false; | |||||
| pending_reply_.clear(); | |||||
| phase_ = Phase::Idle; | |||||
| ++attempt_generation_; | |||||
| if (discovery_finished_callback_) | |||||
| { | |||||
| discovery_finished_callback_(outcome); | |||||
| } | |||||
| candidates_.clear(); | |||||
| current_candidate_ = 0; | |||||
| } | |||||
| void PlcDiscoveryService::handleDeviceStateChanged(QModbusDevice::State state) | |||||
| { | |||||
| if (!discovering_) | |||||
| { | |||||
| return; | |||||
| } | |||||
| if (state == QModbusDevice::ConnectedState && phase_ == Phase::Connecting) | |||||
| { | |||||
| sendProbe(); | |||||
| return; | |||||
| } | |||||
| if (state == QModbusDevice::UnconnectedState) | |||||
| { | |||||
| if (phase_ == Phase::Disconnecting) | |||||
| { | |||||
| QTimer::singleShot(0, this, &PlcDiscoveryService::continueAfterDisconnect); | |||||
| } | |||||
| else if (phase_ == Phase::Connecting || phase_ == Phase::Probing) | |||||
| { | |||||
| disconnectCurrent(false); | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,68 @@ | |||||
| #pragma once | |||||
| #include "services/plc_discovery_gateway.h" | |||||
| #include <QModbusDevice> | |||||
| #include <QObject> | |||||
| #include <QPointer> | |||||
| #include <cstddef> | |||||
| #include <cstdint> | |||||
| #include <functional> | |||||
| #include <memory> | |||||
| #include <vector> | |||||
| class QModbusReply; | |||||
| class QModbusRtuSerialMaster; | |||||
| // 使用独立 Qt Modbus RTU 主站逐项探测串口参数,不污染正式 PLC 连接状态 | |||||
| class PlcDiscoveryService final : public QObject, public PlcDiscoveryGateway | |||||
| { | |||||
| public: | |||||
| explicit PlcDiscoveryService(QObject *parent = nullptr); | |||||
| ~PlcDiscoveryService() override; | |||||
| PlcCommunicationResult startDiscovery( | |||||
| const PlcSerialConfiguration &preferred) override; | |||||
| void cancelDiscovery() override; | |||||
| bool isDiscovering() const override; | |||||
| void setCallbacks( | |||||
| std::function<void(const PlcDiscoveryProgress &)> progress_changed, | |||||
| std::function<void(const PlcDiscoveryOutcome &)> discovery_finished) override; | |||||
| private: | |||||
| enum class Phase | |||||
| { | |||||
| Idle, | |||||
| Connecting, | |||||
| Probing, | |||||
| Disconnecting | |||||
| }; | |||||
| // 尝试当前候选参数并等待串口异步打开 | |||||
| void tryCurrentCandidate(); | |||||
| // 串口打开后发送只读 D0 探测请求 | |||||
| void sendProbe(); | |||||
| // 处理当前 D0 探测结果 | |||||
| void handleProbeFinished(QModbusReply *reply, std::uint64_t attempt_generation); | |||||
| // 释放当前串口,释放完成后进入下一个候选或报告结果 | |||||
| void disconnectCurrent(bool found); | |||||
| // 当前串口完全释放后完成状态迁移 | |||||
| void continueAfterDisconnect(); | |||||
| // 报告最终结果并清理搜索状态 | |||||
| void finishDiscovery(const PlcDiscoveryOutcome &outcome); | |||||
| // 处理 Qt Modbus 主站连接状态变化 | |||||
| void handleDeviceStateChanged(QModbusDevice::State state); | |||||
| std::unique_ptr<QModbusRtuSerialMaster> master_; | |||||
| std::vector<PlcSerialConfiguration> candidates_; | |||||
| std::size_t current_candidate_ = 0; | |||||
| QPointer<QModbusReply> pending_reply_; | |||||
| Phase phase_ = Phase::Idle; | |||||
| bool discovering_ = false; | |||||
| bool cancel_requested_ = false; | |||||
| bool found_pending_ = false; | |||||
| std::uint64_t attempt_generation_ = 0; | |||||
| std::function<void(const PlcDiscoveryProgress &)> progress_changed_callback_; | |||||
| std::function<void(const PlcDiscoveryOutcome &)> discovery_finished_callback_; | |||||
| }; | |||||
| @@ -69,12 +69,35 @@ RegisterWriteResult PlcRegisterRepository::writeWord( | |||||
| : RegisterWriteResult{false, RegisterError::Unavailable}; | : RegisterWriteResult{false, RegisterError::Unavailable}; | ||||
| } | } | ||||
| WordPairReadResult PlcRegisterRepository::readWordPair( | |||||
| const RegisterAddress &address) const | |||||
| { | |||||
| return RegisterRepository::readWordPair(address); | |||||
| } | |||||
| RegisterWriteResult PlcRegisterRepository::writeWordPair( | |||||
| const RegisterAddress &address, | |||||
| const std::array<std::int16_t, 2> &values) | |||||
| { | |||||
| if (!address.isValid() || address.area() != RegisterArea::D | |||||
| || address.index() >= RegisterAddress::kMaximumIndex) | |||||
| { | |||||
| return {false, address.isValid() && address.area() != RegisterArea::D | |||||
| ? RegisterError::AreaMismatch : RegisterError::InvalidAddress}; | |||||
| } | |||||
| return word_pair_handler_ ? word_pair_handler_(address, values) | |||||
| : RegisterWriteResult{false, RegisterError::Unavailable}; | |||||
| } | |||||
| void PlcRegisterRepository::setWriteHandlers( | void PlcRegisterRepository::setWriteHandlers( | ||||
| std::function<RegisterWriteResult(const RegisterAddress &, bool)> bit_handler, | std::function<RegisterWriteResult(const RegisterAddress &, bool)> bit_handler, | ||||
| std::function<RegisterWriteResult(const RegisterAddress &, std::int16_t)> word_handler) | |||||
| std::function<RegisterWriteResult(const RegisterAddress &, std::int16_t)> word_handler, | |||||
| std::function<RegisterWriteResult( | |||||
| const RegisterAddress &, const std::array<std::int16_t, 2> &)> word_pair_handler) | |||||
| { | { | ||||
| bit_handler_ = std::move(bit_handler); | bit_handler_ = std::move(bit_handler); | ||||
| word_handler_ = std::move(word_handler); | word_handler_ = std::move(word_handler); | ||||
| word_pair_handler_ = std::move(word_pair_handler); | |||||
| } | } | ||||
| void PlcRegisterRepository::updateBit(int address, bool value) | void PlcRegisterRepository::updateBit(int address, bool value) | ||||
| @@ -5,6 +5,8 @@ | |||||
| #include <array> | #include <array> | ||||
| #include <functional> | #include <functional> | ||||
| // 保存 PLC 最近一次成功读回的 M/D 缓存,并把写请求转交给通信服务 | |||||
| // 未读到过的地址保持 Unavailable,避免界面显示伪造的初始值 | |||||
| class PlcRegisterRepository final : public RegisterRepository | class PlcRegisterRepository final : public RegisterRepository | ||||
| { | { | ||||
| public: | public: | ||||
| @@ -15,22 +17,35 @@ public: | |||||
| WordReadResult readWord(const RegisterAddress &address) const override; | WordReadResult readWord(const RegisterAddress &address) const override; | ||||
| RegisterWriteResult writeWord( | RegisterWriteResult writeWord( | ||||
| const RegisterAddress &address, std::int16_t value) override; | const RegisterAddress &address, std::int16_t value) override; | ||||
| WordPairReadResult readWordPair(const RegisterAddress &address) const override; | |||||
| RegisterWriteResult writeWordPair( | |||||
| const RegisterAddress &address, | |||||
| const std::array<std::int16_t, 2> &values) override; | |||||
| // 注入异步写入回调;回调成功不代表缓存已经更新 | |||||
| void setWriteHandlers( | void setWriteHandlers( | ||||
| std::function<RegisterWriteResult(const RegisterAddress &, bool)> bit_handler, | std::function<RegisterWriteResult(const RegisterAddress &, bool)> bit_handler, | ||||
| std::function<RegisterWriteResult(const RegisterAddress &, std::int16_t)> word_handler); | |||||
| std::function<RegisterWriteResult(const RegisterAddress &, std::int16_t)> word_handler, | |||||
| std::function<RegisterWriteResult( | |||||
| const RegisterAddress &, const std::array<std::int16_t, 2> &)> word_pair_handler = {}); | |||||
| // 由通信服务在读回成功后更新缓存并标记地址有效 | |||||
| void updateBit(int address, bool value); | void updateBit(int address, bool value); | ||||
| void updateWord(int address, std::int16_t value); | void updateWord(int address, std::int16_t value); | ||||
| // 通信会话失效时清除“已读”标记,但保留数组内的旧值用于诊断 | |||||
| void invalidate(); | void invalidate(); | ||||
| // 判断是否至少收到过一个有效 M/D 值 | |||||
| bool hasAnyValidValue() const; | bool hasAnyValidValue() const; | ||||
| private: | private: | ||||
| // M/D 区共用的数组长度,覆盖 0 到最大地址 | |||||
| static constexpr std::size_t kRegisterCount = | static constexpr std::size_t kRegisterCount = | ||||
| static_cast<std::size_t>(RegisterAddress::kMaximumIndex + 1); | static_cast<std::size_t>(RegisterAddress::kMaximumIndex + 1); | ||||
| std::array<bool, kRegisterCount> bits_{}; | |||||
| std::array<std::int16_t, kRegisterCount> words_{}; | |||||
| std::array<bool, kRegisterCount> valid_bits_{}; | |||||
| std::array<bool, kRegisterCount> valid_words_{}; | |||||
| std::function<RegisterWriteResult(const RegisterAddress &, bool)> bit_handler_; | |||||
| std::function<RegisterWriteResult(const RegisterAddress &, std::int16_t)> word_handler_; | |||||
| std::array<bool, kRegisterCount> bits_{}; // 最近一次读到的 M 区位值 | |||||
| std::array<std::int16_t, kRegisterCount> words_{}; // 最近一次读到的 D 区字值 | |||||
| std::array<bool, kRegisterCount> valid_bits_{}; // 对应 M 地址是否读到过有效值 | |||||
| std::array<bool, kRegisterCount> valid_words_{}; // 对应 D 地址是否读到过有效值 | |||||
| std::function<RegisterWriteResult(const RegisterAddress &, bool)> bit_handler_; // M 区异步写入回调 | |||||
| std::function<RegisterWriteResult(const RegisterAddress &, std::int16_t)> word_handler_; // D 区异步写入回调 | |||||
| std::function<RegisterWriteResult( | |||||
| const RegisterAddress &, const std::array<std::int16_t, 2> &)> word_pair_handler_; | |||||
| }; | }; | ||||
| @@ -7,11 +7,18 @@ | |||||
| */ | */ | ||||
| #include <QApplication> | #include <QApplication> | ||||
| #include <QFile> | |||||
| #include <QFileInfo> | |||||
| #include <QMessageBox> | |||||
| #include <QDir> | |||||
| #include "infrastructure/json_project_storage.h" | #include "infrastructure/json_project_storage.h" | ||||
| #include "infrastructure/application_settings_loader.h" | |||||
| #include "infrastructure/plc_communication_service.h" | #include "infrastructure/plc_communication_service.h" | ||||
| #include "infrastructure/plc_discovery_service.h" | |||||
| #include "infrastructure/plc_register_repository.h" | #include "infrastructure/plc_register_repository.h" | ||||
| #include "domain/active_register_repository.h" | #include "domain/active_register_repository.h" | ||||
| #include "domain/virtual_register_repository.h" | |||||
| #include "services/hmi_editor_service.h" | #include "services/hmi_editor_service.h" | ||||
| #include "services/hmi_runtime_service.h" | #include "services/hmi_runtime_service.h" | ||||
| #include "services/alarm_editor_service.h" | #include "services/alarm_editor_service.h" | ||||
| @@ -19,42 +26,146 @@ | |||||
| #include "services/hmi_navigation_service.h" | #include "services/hmi_navigation_service.h" | ||||
| #include "services/logic_editor_service.h" | #include "services/logic_editor_service.h" | ||||
| #include "services/offline_simulation_service.h" | #include "services/offline_simulation_service.h" | ||||
| #include "services/online_logic_monitor_service.h" | |||||
| #include "services/project_service.h" | #include "services/project_service.h" | ||||
| #include "services/runtime_mode_service.h" | #include "services/runtime_mode_service.h" | ||||
| #include "services/register_monitor_service.h" | #include "services/register_monitor_service.h" | ||||
| #include "services/register_comment_service.h" | #include "services/register_comment_service.h" | ||||
| #include "ui/main_window.h" | #include "ui/main_window.h" | ||||
| // 程序从这里开始,argc 和 argv 保存用户启动程序时附带的命令行参数 | |||||
| int main(int argc, char *argv[]) | int main(int argc, char *argv[]) | ||||
| { | { | ||||
| // Windows 默认会给对话框添加上下文帮助按钮,应用内统一隐藏 | |||||
| QApplication::setAttribute(Qt::AA_DisableWindowContextHelpButton); | |||||
| // 创建整个 Qt 应用,后面的窗口、按钮和消息循环都要依靠它 | |||||
| QApplication application(argc, argv); | QApplication application(argc, argv); | ||||
| // 设置程序名称,Qt 的窗口信息和配置中会用到这个名字 | |||||
| application.setApplicationName(QObject::tr("综合平台编程器")); | application.setApplicationName(QObject::tr("综合平台编程器")); | ||||
| // 设置组织名称,用来区分这个程序保存的 Qt 配置 | |||||
| application.setOrganizationName(QStringLiteral("QtProXinJe")); | application.setOrganizationName(QStringLiteral("QtProXinJe")); | ||||
| // 组合根负责创建具体实现并将抽象依赖注入服务和 UI | |||||
| JsonProjectStorage project_storage; | |||||
| ProjectService project_service(project_storage); | |||||
| HmiEditorService hmi_editor_service(project_service); | |||||
| QString runtime_project_path; | |||||
| const QStringList arguments = application.arguments(); | |||||
| const int project_option = arguments.indexOf(QStringLiteral("--runtime-project")); | |||||
| if (project_option >= 0 && project_option + 1 < arguments.size()) | |||||
| { | |||||
| runtime_project_path = arguments.at(project_option + 1); | |||||
| } | |||||
| const QFileInfo executable_info(application.applicationFilePath()); | |||||
| const QString bundled_project = executable_info.absolutePath() | |||||
| + QStringLiteral("/runtime-project.json"); | |||||
| if (runtime_project_path.isEmpty() | |||||
| && executable_info.completeBaseName() != QStringLiteral("integrated_platform") | |||||
| && QFileInfo::exists(bundled_project)) | |||||
| { | |||||
| runtime_project_path = bundled_project; | |||||
| } | |||||
| const bool embedded_runtime_project = QFile::exists( | |||||
| QStringLiteral(":/runtime-project.json")); | |||||
| const bool user_runtime_mode = !runtime_project_path.isEmpty() | |||||
| || embedded_runtime_project; | |||||
| const ApplicationSettingsLoadResult application_settings = | |||||
| ApplicationSettingsLoader::load( | |||||
| ApplicationSettingsLoader::defaultFilePath()); | |||||
| // 负责把工程保存到 JSON 文件,也负责从 JSON 文件读取工程 | |||||
| JsonProjectStorage project_storage(application_settings.settings.projectLimits); | |||||
| // 统一管理新建、打开、保存和校验工程,实际读写文件交给上面的对象 | |||||
| ProjectService project_service( | |||||
| project_storage, application_settings.settings.projectLimits); | |||||
| if (user_runtime_mode) | |||||
| { | |||||
| ProjectOperationResult result; | |||||
| if (embedded_runtime_project && runtime_project_path.isEmpty()) | |||||
| { | |||||
| const QByteArray bytes = [] | |||||
| { | |||||
| QFile resource(QStringLiteral(":/runtime-project.json")); | |||||
| return resource.open(QIODevice::ReadOnly) | |||||
| ? resource.readAll() : QByteArray{}; | |||||
| }(); | |||||
| const QString temporary_path = QDir::tempPath() | |||||
| + QStringLiteral("/qtproxinje-runtime-project.json"); | |||||
| QFile temporary_file(temporary_path); | |||||
| if (bytes.isEmpty() || !temporary_file.open(QIODevice::WriteOnly) | |||||
| || temporary_file.write(bytes) != bytes.size()) | |||||
| { | |||||
| QMessageBox::critical( | |||||
| nullptr, | |||||
| QObject::tr("运行程序启动失败"), | |||||
| QObject::tr("无法读取内嵌项目资源")); | |||||
| return 2; | |||||
| } | |||||
| temporary_file.close(); | |||||
| result = project_service.load(temporary_path.toUtf8().toStdString()); | |||||
| QFile::remove(temporary_path); | |||||
| } | |||||
| else | |||||
| { | |||||
| result = project_service.load( | |||||
| runtime_project_path.toUtf8().toStdString()); | |||||
| } | |||||
| if (!result.succeeded) | |||||
| { | |||||
| QMessageBox::critical( | |||||
| nullptr, | |||||
| QObject::tr("运行程序启动失败"), | |||||
| QObject::tr("无法加载项目:%1") | |||||
| .arg(QString::fromUtf8(result.message.c_str()))); | |||||
| return 2; | |||||
| } | |||||
| } | |||||
| // 处理 HMI 页面和控件的添加、修改、删除、撤销与重做 | |||||
| HmiEditorService hmi_editor_service( | |||||
| project_service, application_settings.settings.hmiDefaults); | |||||
| // 处理运行画面在不同 HMI 页面之间的跳转 | |||||
| HmiNavigationService hmi_navigation_service(project_service); | HmiNavigationService hmi_navigation_service(project_service); | ||||
| // 处理梯形图网络和节点的编辑、校验、撤销与重做 | |||||
| LogicEditorService logic_editor_service(project_service); | LogicEditorService logic_editor_service(project_service); | ||||
| // 当前离线模式使用内存仓库,后续真机模式替换为 PLC 缓存实现 | |||||
| // 在内存中保存离线仿真使用的 M、D 寄存器值,不会写入真实 PLC | |||||
| VirtualRegisterRepository virtual_register_repository; | VirtualRegisterRepository virtual_register_repository; | ||||
| // 保存编辑态确认的离线初始值,不参与仿真扫描和工程持久化 | |||||
| VirtualRegisterRepository offline_initial_repository; | |||||
| // 保存从真实 PLC 最近一次读取到的 M、D 值,并转交写入请求 | |||||
| PlcRegisterRepository plc_register_repository; | PlcRegisterRepository plc_register_repository; | ||||
| // 给界面提供统一的寄存器入口,启动时先使用上面的离线寄存器 | |||||
| ActiveRegisterRepository active_register_repository(virtual_register_repository); | ActiveRegisterRepository active_register_repository(virtual_register_repository); | ||||
| // 负责通过串口异步连接、读取和写入真实 PLC | |||||
| PlcCommunicationService plc_communication_service(plc_register_repository); | PlcCommunicationService plc_communication_service(plc_register_repository); | ||||
| // 使用独立串口会话自动搜索 PLC,不影响正式通信状态机 | |||||
| PlcDiscoveryService plc_discovery_service; | |||||
| // 处理运行画面读取和写入寄存器,数据来自当前选中的寄存器入口 | |||||
| HmiRuntimeService hmi_runtime_service(active_register_repository); | HmiRuntimeService hmi_runtime_service(active_register_repository); | ||||
| // 处理报警规则的添加、修改和删除 | |||||
| AlarmEditorService alarm_editor_service(project_service); | AlarmEditorService alarm_editor_service(project_service); | ||||
| // 根据当前寄存器值判断报警是否触发,并处理报警确认 | |||||
| AlarmService alarm_service(project_service, active_register_repository); | AlarmService alarm_service(project_service, active_register_repository); | ||||
| // 管理工程中 M、D 地址对应的文字备注 | |||||
| RegisterCommentService register_comment_service(project_service); | RegisterCommentService register_comment_service(project_service); | ||||
| // 处理自由监控列表中的寄存器读取和单点写入 | |||||
| RegisterMonitorService register_monitor_service(active_register_repository); | RegisterMonitorService register_monitor_service(active_register_repository); | ||||
| OfflineSimulationService offline_simulation_service(virtual_register_repository); | |||||
| register_monitor_service.setOfflineInitialRepository(offline_initial_repository); | |||||
| register_monitor_service.setOfflineInitialCaptureEnabled(true); | |||||
| // 用虚拟寄存器执行本地梯形图,实现不连接 PLC 的离线仿真 | |||||
| OfflineSimulationService offline_simulation_service( | |||||
| virtual_register_repository, &offline_initial_repository); | |||||
| // 从 PLC 缓存推算本地梯形图轨迹,输出只写内部临时仓库 | |||||
| OnlineLogicMonitorService online_logic_monitor_service(plc_register_repository); | |||||
| // 管理三种运行模式,并控制离线仿真和真机只读轨迹的生命周期 | |||||
| RuntimeModeService runtime_mode_service( | RuntimeModeService runtime_mode_service( | ||||
| project_service, offline_simulation_service); | |||||
| project_service, offline_simulation_service, online_logic_monitor_service); | |||||
| // 把 PLC 通信和两套寄存器接入运行模式,切换模式时才能切换数据来源 | |||||
| runtime_mode_service.configurePlc( | runtime_mode_service.configurePlc( | ||||
| plc_communication_service, | plc_communication_service, | ||||
| active_register_repository, | active_register_repository, | ||||
| virtual_register_repository, | virtual_register_repository, | ||||
| plc_register_repository); | plc_register_repository); | ||||
| // 创建主窗口,并把界面操作需要的各项服务交给它使用 | |||||
| MainWindow main_window( | MainWindow main_window( | ||||
| runtime_mode_service, | runtime_mode_service, | ||||
| project_service, | project_service, | ||||
| @@ -65,8 +176,15 @@ int main(int argc, char *argv[]) | |||||
| alarm_service, | alarm_service, | ||||
| hmi_navigation_service, | hmi_navigation_service, | ||||
| register_comment_service, | register_comment_service, | ||||
| register_monitor_service); | |||||
| main_window.show(); | |||||
| register_monitor_service, | |||||
| plc_discovery_service, | |||||
| application_settings, | |||||
| user_runtime_mode); | |||||
| if (!user_runtime_mode) | |||||
| { | |||||
| main_window.show(); | |||||
| } | |||||
| // 启动 Qt 消息循环,持续响应鼠标、键盘、定时器和串口事件,窗口关闭后才返回 | |||||
| return application.exec(); | return application.exec(); | ||||
| } | } | ||||
| @@ -37,9 +37,13 @@ const AlarmDefinition *AlarmEditorService::findDefinition( | |||||
| AlarmEditorResult AlarmEditorService::addDefinition( | AlarmEditorResult AlarmEditorService::addDefinition( | ||||
| const AlarmDefinition &definition) | const AlarmDefinition &definition) | ||||
| { | { | ||||
| if (definitions().size() >= ProjectLimits::kMaximumAlarmDefinitions) | |||||
| const std::size_t maximum = | |||||
| project_service_.projectLimits().maximumAlarmDefinitions; | |||||
| if (definitions().size() >= maximum) | |||||
| { | { | ||||
| return failure(AlarmEditorError::InvalidDefinition, "单个工程最多包含 512 条报警定义"); | |||||
| return failure( | |||||
| AlarmEditorError::InvalidDefinition, | |||||
| "当前配置最多允许 " + std::to_string(maximum) + " 条报警定义"); | |||||
| } | } | ||||
| AlarmDefinition candidate = definition; | AlarmDefinition candidate = definition; | ||||
| candidate.id = makeUniqueId(); | candidate.id = makeUniqueId(); | ||||
| @@ -7,6 +7,7 @@ | |||||
| class ProjectService; | class ProjectService; | ||||
| // 报警编辑操作的失败分类 | |||||
| enum class AlarmEditorError | enum class AlarmEditorError | ||||
| { | { | ||||
| None, | None, | ||||
| @@ -15,6 +16,7 @@ enum class AlarmEditorError | |||||
| InvalidDefinition | InvalidDefinition | ||||
| }; | }; | ||||
| // 报警编辑结果;成功时 id 是新增或更新后的定义 ID | |||||
| struct AlarmEditorResult | struct AlarmEditorResult | ||||
| { | { | ||||
| bool succeeded = false; | bool succeeded = false; | ||||
| @@ -23,16 +25,22 @@ struct AlarmEditorResult | |||||
| std::string id; | std::string id; | ||||
| }; | }; | ||||
| // 负责报警定义的增删改,不负责读取实时寄存器 | |||||
| class AlarmEditorService | class AlarmEditorService | ||||
| { | { | ||||
| public: | public: | ||||
| explicit AlarmEditorService(ProjectService &project_service); | explicit AlarmEditorService(ProjectService &project_service); | ||||
| // 返回当前工程中的报警定义 | |||||
| const std::vector<AlarmDefinition> &definitions() const; | const std::vector<AlarmDefinition> &definitions() const; | ||||
| // 按稳定 ID 查找报警定义 | |||||
| const AlarmDefinition *findDefinition(const std::string &id) const; | const AlarmDefinition *findDefinition(const std::string &id) const; | ||||
| // 添加定义;失败时工程保持不变 | |||||
| AlarmEditorResult addDefinition(const AlarmDefinition &definition); | AlarmEditorResult addDefinition(const AlarmDefinition &definition); | ||||
| // 更新指定 ID;definition.id 会被服务统一为传入的 id | |||||
| AlarmEditorResult updateDefinition( | AlarmEditorResult updateDefinition( | ||||
| const std::string &id, const AlarmDefinition &definition); | const std::string &id, const AlarmDefinition &definition); | ||||
| // 删除指定报警定义 | |||||
| AlarmEditorResult removeDefinition(const std::string &id); | AlarmEditorResult removeDefinition(const std::string &id); | ||||
| private: | private: | ||||
| @@ -16,15 +16,18 @@ AlarmService::AlarmService( | |||||
| void AlarmService::refresh() | void AlarmService::refresh() | ||||
| { | { | ||||
| // 每次刷新只根据当前读回值增删记录,不会把通信失败误判为报警解除 | |||||
| const auto now = std::chrono::system_clock::now(); | const auto now = std::chrono::system_clock::now(); | ||||
| for (const AlarmDefinition &definition | for (const AlarmDefinition &definition | ||||
| : project_service_.project().alarmDefinitions) | : project_service_.project().alarmDefinitions) | ||||
| { | { | ||||
| // 空值表示寄存器读取失败;保留原记录,等待下一轮成功读回 | |||||
| const std::optional<bool> active = evaluate(definition); | const std::optional<bool> active = evaluate(definition); | ||||
| if (!active.has_value()) | if (!active.has_value()) | ||||
| { | { | ||||
| continue; | continue; | ||||
| } | } | ||||
| // 同一报警定义在当前会话中最多保留一条活动记录 | |||||
| const auto current = std::find_if( | const auto current = std::find_if( | ||||
| records_.begin(), records_.end(), | records_.begin(), records_.end(), | ||||
| [&definition](const AlarmRecord &record) | [&definition](const AlarmRecord &record) | ||||
| @@ -33,12 +36,14 @@ void AlarmService::refresh() | |||||
| }); | }); | ||||
| if (*active && current == records_.end()) | if (*active && current == records_.end()) | ||||
| { | { | ||||
| // 首次检测到触发时创建未确认记录,并置于列表头部 | |||||
| records_.insert( | records_.insert( | ||||
| records_.begin(), | records_.begin(), | ||||
| AlarmRecord{definition.id, definition.message, false, now}); | AlarmRecord{definition.id, definition.message, false, now}); | ||||
| } | } | ||||
| else if (!*active && current != records_.end()) | else if (!*active && current != records_.end()) | ||||
| { | { | ||||
| // 条件恢复后立即移除对应记录,下一次再次触发会生成新记录 | |||||
| records_.erase(current); | records_.erase(current); | ||||
| } | } | ||||
| } | } | ||||
| @@ -75,21 +80,27 @@ std::optional<bool> AlarmService::evaluate( | |||||
| { | { | ||||
| if (definition.condition == AlarmCondition::MOn) | if (definition.condition == AlarmCondition::MOn) | ||||
| { | { | ||||
| // MOn 报警直接读取 M 位;读取失败返回空值,交由 refresh 保留旧记录 | |||||
| const BitReadResult value = repository_.readBit(definition.address); | const BitReadResult value = repository_.readBit(definition.address); | ||||
| return value.succeeded ? std::optional<bool>{value.value} : std::nullopt; | return value.succeeded ? std::optional<bool>{value.value} : std::nullopt; | ||||
| } | } | ||||
| // DHigh 和 DLow 报警读取 D 字,比较方向由定义中的条件决定 | |||||
| const WordReadResult value = repository_.readWord(definition.address); | const WordReadResult value = repository_.readWord(definition.address); | ||||
| if (!value.succeeded) | if (!value.succeeded) | ||||
| { | { | ||||
| // 通信或仓库读取失败不等同于报警解除 | |||||
| return std::nullopt; | return std::nullopt; | ||||
| } | } | ||||
| if (definition.condition == AlarmCondition::DHigh) | if (definition.condition == AlarmCondition::DHigh) | ||||
| { | { | ||||
| // 达到阈值即触发高限报警 | |||||
| return value.value >= definition.threshold; | return value.value >= definition.threshold; | ||||
| } | } | ||||
| if (definition.condition == AlarmCondition::DLow) | if (definition.condition == AlarmCondition::DLow) | ||||
| { | { | ||||
| // 降到阈值即触发低限报警 | |||||
| return value.value <= definition.threshold; | return value.value <= definition.threshold; | ||||
| } | } | ||||
| // 防御未来新增但尚未实现的报警条件 | |||||
| return std::nullopt; | return std::nullopt; | ||||
| } | } | ||||
| @@ -9,30 +9,66 @@ class ProjectService; | |||||
| class RegisterRepository; | class RegisterRepository; | ||||
| struct AlarmDefinition; | struct AlarmDefinition; | ||||
| // 运行时产生的报警记录,只存在当前会话,不会写入工程 JSON | |||||
| struct AlarmRecord | struct AlarmRecord | ||||
| { | { | ||||
| std::string definitionId; | |||||
| std::string message; | |||||
| bool acknowledged = false; | |||||
| std::chrono::system_clock::time_point occurredAt; | |||||
| std::string definitionId; // 对应 AlarmDefinition 的稳定 ID | |||||
| std::string message; // 触发时复制的报警提示文本 | |||||
| bool acknowledged = false; // 当前记录是否已被操作员确认 | |||||
| std::chrono::system_clock::time_point occurredAt; // 本次触发时间 | |||||
| }; | }; | ||||
| /** | |||||
| * @brief 根据当前活动寄存器仓库评估报警并维护会话记录 | |||||
| * | |||||
| * 服务只读取仓库,不修改工程中的报警定义;仓库可以由运行模式注入虚拟或 PLC 数据源 | |||||
| */ | |||||
| class AlarmService | class AlarmService | ||||
| { | { | ||||
| public: | public: | ||||
| /** | |||||
| * @brief 创建报警服务 | |||||
| * @param project_service 只读工程服务,提供报警定义集合 | |||||
| * @param repository 当前运行模式使用的寄存器仓库 | |||||
| */ | |||||
| AlarmService( | AlarmService( | ||||
| const ProjectService &project_service, | const ProjectService &project_service, | ||||
| RegisterRepository &repository); | RegisterRepository &repository); | ||||
| /** | |||||
| * @brief 重新评估全部报警定义 | |||||
| * | |||||
| * 仍处于触发状态的记录会保留确认状态;读取失败时跳过该定义并保留原记录。 | |||||
| * 新触发记录插入列表头部,解除触发的记录立即移除 | |||||
| */ | |||||
| void refresh(); | void refresh(); | ||||
| /** | |||||
| * @brief 确认当前会话中的指定报警记录 | |||||
| * @param definition_id 报警定义的稳定 ID | |||||
| * @return 找到当前活动记录并完成确认时返回 true,否则返回 false | |||||
| */ | |||||
| bool acknowledge(const std::string &definition_id); | bool acknowledge(const std::string &definition_id); | ||||
| /** | |||||
| * @brief 清空当前运行会话的全部报警记录和确认状态 | |||||
| */ | |||||
| void reset(); | void reset(); | ||||
| /** | |||||
| * @brief 返回当前会话的报警记录 | |||||
| * @return 按触发时间倒序排列的只读记录列表,列表由服务持有 | |||||
| */ | |||||
| const std::vector<AlarmRecord> &records() const; | const std::vector<AlarmRecord> &records() const; | ||||
| private: | private: | ||||
| /** | |||||
| * @brief 使用当前寄存器值评估单条报警定义 | |||||
| * @param definition 待评估的报警定义 | |||||
| * @return true 表示触发,false 表示未触发;读取失败时返回空值 | |||||
| * | |||||
| * MOn 比较 M 位是否为 1;DHigh 使用大于等于阈值,DLow 使用小于等于阈值 | |||||
| */ | |||||
| std::optional<bool> evaluate(const AlarmDefinition &definition) const; | std::optional<bool> evaluate(const AlarmDefinition &definition) const; | ||||
| const ProjectService &project_service_; | |||||
| RegisterRepository &repository_; | |||||
| std::vector<AlarmRecord> records_; | |||||
| const ProjectService &project_service_; // 不拥有的只读工程服务 | |||||
| RegisterRepository &repository_; // 当前运行模式的活动寄存器仓库 | |||||
| std::vector<AlarmRecord> records_; // 当前会话记录,最新触发项位于列表头部 | |||||
| }; | }; | ||||
| @@ -0,0 +1,30 @@ | |||||
| #pragma once | |||||
| #include "domain/project_limits.h" | |||||
| #include "plc_communication_gateway.h" | |||||
| #include <string> | |||||
| #include <vector> | |||||
| // 应用启动后保持只读的用户配置;工程文件不会保存这些机器级偏好 | |||||
| struct ApplicationSettings | |||||
| { | |||||
| int version = 1; | |||||
| ProjectLimitSettings projectLimits; | |||||
| HmiDefaultSettings hmiDefaults; | |||||
| PlcSerialConfiguration plcDefaults = [] | |||||
| { | |||||
| PlcSerialConfiguration configuration; | |||||
| configuration.portName = "COM3"; | |||||
| return configuration; | |||||
| }(); | |||||
| }; | |||||
| // 配置加载结果同时携带主界面启动后需要显示的诊断信息 | |||||
| struct ApplicationSettingsLoadResult | |||||
| { | |||||
| ApplicationSettings settings; | |||||
| std::vector<std::string> messages; | |||||
| bool warningRequired = false; | |||||
| std::string warningMessage; | |||||
| }; | |||||
| @@ -8,15 +8,27 @@ | |||||
| /** | /** | ||||
| * @brief 保存有限数量的编辑前快照并提供撤销、重做 | * @brief 保存有限数量的编辑前快照并提供撤销、重做 | ||||
| * | * | ||||
| * 历史只存在当前编辑会话内。新编辑会清空重做栈,超过容量时丢弃最早记录。 | |||||
| * State 由具体编辑服务决定,可以是 HMI 页面集合或控制逻辑集合 | |||||
| * 历史只存在当前编辑会话内,不写入工程文件 | |||||
| * 新编辑会清空重做栈,超过容量时丢弃最早记录 | |||||
| */ | */ | ||||
| template <typename State> | template <typename State> | ||||
| class EditorHistory final | class EditorHistory final | ||||
| { | { | ||||
| public: | public: | ||||
| static constexpr std::size_t kMaximumEntries = 100U; | |||||
| // 单个编辑服务最多保留 30 条撤销和重做历史 | |||||
| static constexpr std::size_t kMaximumEntries = 30U; | |||||
| template <typename Equal> | template <typename Equal> | ||||
| /** | |||||
| * @brief 记录一次编辑前状态 | |||||
| * @param before 编辑前状态 | |||||
| * @param after 编辑完成后的当前状态 | |||||
| * @param equal 比较前后状态是否相同的可调用对象 | |||||
| * | |||||
| * 状态没有变化时不创建空历史 | |||||
| * 新编辑成功记录后清空重做栈 | |||||
| */ | |||||
| void record(State before, const State &after, Equal equal) | void record(State before, const State &after, Equal equal) | ||||
| { | { | ||||
| if (equal(before, after)) | if (equal(before, after)) | ||||
| @@ -28,6 +40,13 @@ public: | |||||
| redo_states_.clear(); | redo_states_.clear(); | ||||
| } | } | ||||
| /** | |||||
| * @brief 撤销最近一次编辑 | |||||
| * @param current 当前状态 | |||||
| * @return 撤销后的目标状态;没有可撤销历史时返回空值 | |||||
| * | |||||
| * 当前状态会先进入重做栈,之后取出撤销栈顶的编辑前快照 | |||||
| */ | |||||
| std::optional<State> undo(State current) | std::optional<State> undo(State current) | ||||
| { | { | ||||
| if (undo_states_.empty()) | if (undo_states_.empty()) | ||||
| @@ -41,6 +60,13 @@ public: | |||||
| return target; | return target; | ||||
| } | } | ||||
| /** | |||||
| * @brief 重做最近一次被撤销的编辑 | |||||
| * @param current 当前状态 | |||||
| * @return 重做后的目标状态;没有可重做历史时返回空值 | |||||
| * | |||||
| * 当前状态会先放回撤销栈,之后取出重做栈顶的目标状态 | |||||
| */ | |||||
| std::optional<State> redo(State current) | std::optional<State> redo(State current) | ||||
| { | { | ||||
| if (redo_states_.empty()) | if (redo_states_.empty()) | ||||
| @@ -54,23 +80,33 @@ public: | |||||
| return target; | return target; | ||||
| } | } | ||||
| /** | |||||
| * @brief 清空撤销栈和重做栈 | |||||
| */ | |||||
| void clear() | void clear() | ||||
| { | { | ||||
| undo_states_.clear(); | undo_states_.clear(); | ||||
| redo_states_.clear(); | redo_states_.clear(); | ||||
| } | } | ||||
| /** | |||||
| * @brief 判断是否存在可撤销历史 | |||||
| */ | |||||
| bool canUndo() const | bool canUndo() const | ||||
| { | { | ||||
| return !undo_states_.empty(); | return !undo_states_.empty(); | ||||
| } | } | ||||
| /** | |||||
| * @brief 判断是否存在可重做历史 | |||||
| */ | |||||
| bool canRedo() const | bool canRedo() const | ||||
| { | { | ||||
| return !redo_states_.empty(); | return !redo_states_.empty(); | ||||
| } | } | ||||
| private: | private: | ||||
| // 保持历史不超过上限,超出时从最早的记录开始丢弃 | |||||
| static void trim(std::vector<State> *states) | static void trim(std::vector<State> *states) | ||||
| { | { | ||||
| while (states->size() > kMaximumEntries) | while (states->size() > kMaximumEntries) | ||||
| @@ -79,6 +115,6 @@ private: | |||||
| } | } | ||||
| } | } | ||||
| std::vector<State> undo_states_; | |||||
| std::vector<State> redo_states_; | |||||
| std::vector<State> undo_states_; // 编辑前快照栈,末尾是下一次撤销要恢复的状态 | |||||
| std::vector<State> redo_states_; // 撤销后状态栈,末尾是下一次重做要恢复的状态 | |||||
| }; | }; | ||||
| @@ -51,10 +51,23 @@ std::string makeUniquePageId(const Project &project) | |||||
| } | } | ||||
| } | } | ||||
| std::size_t totalControlCount(const Project &project) | |||||
| { | |||||
| std::size_t total = 0U; | |||||
| for (const HmiPage &page : project.hmiPages) | |||||
| { | |||||
| total += page.controls.size(); | |||||
| } | |||||
| return total; | |||||
| } | |||||
| } // namespace | } // namespace | ||||
| HmiEditorService::HmiEditorService(ProjectService &project_service) | |||||
| : project_service_(project_service) | |||||
| HmiEditorService::HmiEditorService( | |||||
| ProjectService &project_service, | |||||
| const HmiDefaultSettings &hmi_defaults) | |||||
| : project_service_(project_service), | |||||
| hmi_defaults_(hmi_defaults) | |||||
| { | { | ||||
| } | } | ||||
| @@ -110,12 +123,6 @@ bool HmiEditorService::pagesEqual( | |||||
| bool HmiEditorService::controlsEqual( | bool HmiEditorService::controlsEqual( | ||||
| const HmiControl &left, const HmiControl &right) | 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() | const bool page_jump_equal = left.pageJump.has_value() == right.pageJump.has_value() | ||||
| && (!left.pageJump.has_value() | && (!left.pageJump.has_value() | ||||
| || left.pageJump->targetPageId == right.pageJump->targetPageId); | || left.pageJump->targetPageId == right.pageJump->targetPageId); | ||||
| @@ -127,10 +134,10 @@ bool HmiEditorService::controlsEqual( | |||||
| && left.bounds.height == right.bounds.height | && left.bounds.height == right.bounds.height | ||||
| && left.text == right.text | && left.text == right.text | ||||
| && left.binding == right.binding | && left.binding == right.binding | ||||
| && left.dataType == right.dataType | |||||
| && left.properties == right.properties | && left.properties == right.properties | ||||
| && left.buttonOperation == right.buttonOperation | && left.buttonOperation == right.buttonOperation | ||||
| && page_jump_equal | |||||
| && progress_equal; | |||||
| && page_jump_equal; | |||||
| } | } | ||||
| HmiEditorResult HmiEditorService::historyFailure(const std::string &message) | HmiEditorResult HmiEditorService::historyFailure(const std::string &message) | ||||
| @@ -196,6 +203,8 @@ HmiEditorResult HmiEditorService::ensureDefaultPage() | |||||
| HmiPage page; | HmiPage page; | ||||
| page.id = "page-1"; | page.id = "page-1"; | ||||
| page.name = "主操作页面"; | page.name = "主操作页面"; | ||||
| page.width = hmi_defaults_.pageWidth; | |||||
| page.height = hmi_defaults_.pageHeight; | |||||
| Project &project = project_service_.editProject(); | Project &project = project_service_.editProject(); | ||||
| project.hmiPages.push_back(std::move(page)); | project.hmiPages.push_back(std::move(page)); | ||||
| @@ -211,13 +220,17 @@ HmiEditorResult HmiEditorService::addPage(const std::string &name) | |||||
| return failure(HmiEditorError::InvalidPage, "HMI 页面名称不能为空"); | return failure(HmiEditorError::InvalidPage, "HMI 页面名称不能为空"); | ||||
| } | } | ||||
| const Project ¤t = project_service_.project(); | const Project ¤t = project_service_.project(); | ||||
| if (current.hmiPages.size() >= ProjectLimits::kMaximumHmiPages) | |||||
| const ProjectLimitSettings &limits = project_service_.projectLimits(); | |||||
| if (current.hmiPages.size() >= limits.maximumHmiPages) | |||||
| { | { | ||||
| return failure(HmiEditorError::InvalidPage, "单个工程最多包含 128 个 HMI 页面"); | |||||
| return failure( | |||||
| HmiEditorError::InvalidPage, | |||||
| "当前配置最多允许 " | |||||
| + std::to_string(limits.maximumHmiPages) + " 个 HMI 页面"); | |||||
| } | } | ||||
| if (name.size() > ProjectLimits::kMaximumTextBytes) | if (name.size() > ProjectLimits::kMaximumTextBytes) | ||||
| { | { | ||||
| return failure(HmiEditorError::InvalidPage, "HMI 页面名称不能超过 4096 个 UTF-8 字节"); | |||||
| return failure(HmiEditorError::InvalidPage, "HMI 页面名称不能超过 256 个 UTF-8 字节"); | |||||
| } | } | ||||
| const bool duplicate = std::any_of( | const bool duplicate = std::any_of( | ||||
| current.hmiPages.cbegin(), current.hmiPages.cend(), | current.hmiPages.cbegin(), current.hmiPages.cend(), | ||||
| @@ -230,6 +243,8 @@ HmiEditorResult HmiEditorService::addPage(const std::string &name) | |||||
| HmiPage page; | HmiPage page; | ||||
| page.id = makeUniquePageId(current); | page.id = makeUniquePageId(current); | ||||
| page.name = name; | page.name = name; | ||||
| page.width = hmi_defaults_.pageWidth; | |||||
| page.height = hmi_defaults_.pageHeight; | |||||
| HistoryState before = captureState(); | HistoryState before = captureState(); | ||||
| Project &project = project_service_.editProject(); | Project &project = project_service_.editProject(); | ||||
| project.hmiPages.push_back(std::move(page)); | project.hmiPages.push_back(std::move(page)); | ||||
| @@ -259,7 +274,7 @@ HmiEditorResult HmiEditorService::resizePage( | |||||
| candidate.width = width; | candidate.width = width; | ||||
| candidate.height = height; | candidate.height = height; | ||||
| std::string error; | std::string error; | ||||
| if (!candidate.validate(&error)) | |||||
| if (!candidate.validate(project_service_.projectLimits(), &error)) | |||||
| { | { | ||||
| return failure(HmiEditorError::InvalidPage, error); | return failure(HmiEditorError::InvalidPage, error); | ||||
| } | } | ||||
| @@ -284,7 +299,7 @@ HmiEditorResult HmiEditorService::renamePage( | |||||
| } | } | ||||
| if (name.size() > ProjectLimits::kMaximumTextBytes) | if (name.size() > ProjectLimits::kMaximumTextBytes) | ||||
| { | { | ||||
| return failure(HmiEditorError::InvalidPage, "HMI 页面名称不能超过 4096 个 UTF-8 字节"); | |||||
| return failure(HmiEditorError::InvalidPage, "HMI 页面名称不能超过 256 个 UTF-8 字节"); | |||||
| } | } | ||||
| const Project ¤t = project_service_.project(); | const Project ¤t = project_service_.project(); | ||||
| const auto existing = std::find_if( | const auto existing = std::find_if( | ||||
| @@ -423,9 +438,18 @@ HmiEditorResult HmiEditorService::addControl( | |||||
| { | { | ||||
| return failure(HmiEditorError::PageNotFound, "未找到 HMI 页面"); | return failure(HmiEditorError::PageNotFound, "未找到 HMI 页面"); | ||||
| } | } | ||||
| if (page->controls.size() >= ProjectLimits::kMaximumHmiControlsPerPage) | |||||
| const ProjectLimitSettings &limits = project_service_.projectLimits(); | |||||
| if (page->controls.size() >= limits.maximumHmiControlsPerPage) | |||||
| { | { | ||||
| return failure(HmiEditorError::InvalidControl, "单个 HMI 页面最多包含 512 个控件"); | |||||
| return failure( | |||||
| HmiEditorError::InvalidControl, | |||||
| "当前配置下单个 HMI 页面最多允许 " | |||||
| + std::to_string(limits.maximumHmiControlsPerPage) + " 个控件"); | |||||
| } | |||||
| if (totalControlCount(project_service_.project()) | |||||
| >= ProjectLimits::kMaximumHmiControlsPerProject) | |||||
| { | |||||
| return failure(HmiEditorError::InvalidControl, "单个工程最多包含 2048 个 HMI 控件"); | |||||
| } | } | ||||
| const HmiControlDescriptor *descriptor = findHmiControlDescriptor(type); | const HmiControlDescriptor *descriptor = findHmiControlDescriptor(type); | ||||
| if (descriptor == nullptr) | if (descriptor == nullptr) | ||||
| @@ -526,6 +550,7 @@ HmiEditorResult HmiEditorService::moveControl( | |||||
| HmiControl candidate = *control; | HmiControl candidate = *control; | ||||
| candidate.bounds = bounds; | candidate.bounds = bounds; | ||||
| // 更新前保留原控件,只有全部编辑规则通过才覆盖原值 | // 更新前保留原控件,只有全部编辑规则通过才覆盖原值 | ||||
| // 属性更新是整体替换:类型不变,但绑定、外观和扩展属性必须一起通过校验 | |||||
| std::string error; | std::string error; | ||||
| if (!validateEditableControl(*page, candidate, &error)) | if (!validateEditableControl(*page, candidate, &error)) | ||||
| { | { | ||||
| @@ -600,6 +625,112 @@ HmiEditorResult HmiEditorService::updateControl( | |||||
| return {true, HmiEditorError::None, {}, control.id}; | return {true, HmiEditorError::None, {}, control.id}; | ||||
| } | } | ||||
| HmiEditorResult HmiEditorService::pasteControls( | |||||
| const std::string &page_id, | |||||
| const std::vector<HmiControl> &controls, | |||||
| int offset_x, | |||||
| int offset_y) | |||||
| { | |||||
| const HmiPage *existing_page = findPage(page_id); | |||||
| if (existing_page == nullptr) | |||||
| { | |||||
| return failure(HmiEditorError::PageNotFound, "未找到 HMI 页面"); | |||||
| } | |||||
| if (controls.empty()) | |||||
| { | |||||
| return failure(HmiEditorError::InvalidOperation, "请先复制 HMI 控件"); | |||||
| } | |||||
| const ProjectLimitSettings &limits = project_service_.projectLimits(); | |||||
| if (controls.size() > limits.maximumHmiControlsPerPage | |||||
| || existing_page->controls.size() + controls.size() | |||||
| > limits.maximumHmiControlsPerPage) | |||||
| { | |||||
| return failure( | |||||
| HmiEditorError::InvalidControl, | |||||
| "当前配置下单个 HMI 页面最多允许 " | |||||
| + std::to_string(limits.maximumHmiControlsPerPage) + " 个控件"); | |||||
| } | |||||
| if (totalControlCount(project_service_.project()) + controls.size() | |||||
| > ProjectLimits::kMaximumHmiControlsPerProject) | |||||
| { | |||||
| return failure(HmiEditorError::InvalidControl, "单个工程最多包含 2048 个 HMI 控件"); | |||||
| } | |||||
| // 让整组控件尽量保留相对位置,靠近页面边界时自动向页面内收拢 | |||||
| int minimum_x = controls.front().bounds.x; | |||||
| int minimum_y = controls.front().bounds.y; | |||||
| int maximum_x = controls.front().bounds.x + controls.front().bounds.width; | |||||
| int maximum_y = controls.front().bounds.y + controls.front().bounds.height; | |||||
| for (const HmiControl &control : controls) | |||||
| { | |||||
| minimum_x = std::min(minimum_x, control.bounds.x); | |||||
| minimum_y = std::min(minimum_y, control.bounds.y); | |||||
| maximum_x = std::max( | |||||
| maximum_x, control.bounds.x + control.bounds.width); | |||||
| maximum_y = std::max( | |||||
| maximum_y, control.bounds.y + control.bounds.height); | |||||
| } | |||||
| if (maximum_x - minimum_x > existing_page->width | |||||
| || maximum_y - minimum_y > existing_page->height) | |||||
| { | |||||
| return failure(HmiEditorError::InvalidControl, "复制的控件尺寸超出目标 HMI 页面"); | |||||
| } | |||||
| int final_offset_x = offset_x; | |||||
| int final_offset_y = offset_y; | |||||
| if (minimum_x + final_offset_x < 0) | |||||
| { | |||||
| final_offset_x = -minimum_x; | |||||
| } | |||||
| if (minimum_y + final_offset_y < 0) | |||||
| { | |||||
| final_offset_y = -minimum_y; | |||||
| } | |||||
| if (maximum_x + final_offset_x > existing_page->width) | |||||
| { | |||||
| final_offset_x = existing_page->width - maximum_x; | |||||
| } | |||||
| if (maximum_y + final_offset_y > existing_page->height) | |||||
| { | |||||
| final_offset_y = existing_page->height - maximum_y; | |||||
| } | |||||
| const HistoryState before = captureState(); | |||||
| const bool modified_before = project_service_.isModified(); | |||||
| 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; }); | |||||
| std::vector<std::string> new_ids; | |||||
| new_ids.reserve(controls.size()); | |||||
| for (const HmiControl &source : controls) | |||||
| { | |||||
| const HmiControlDescriptor *descriptor = findHmiControlDescriptor(source.type); | |||||
| if (descriptor == nullptr) | |||||
| { | |||||
| project.hmiPages = before.pages; | |||||
| project.initialHmiPageId = before.initial_page_id; | |||||
| project_service_.restoreModifiedState(modified_before); | |||||
| return failure(HmiEditorError::InvalidControl, "不支持的 HMI 控件类型"); | |||||
| } | |||||
| HmiControl candidate = source; | |||||
| candidate.id = makeUniqueId(*target_page, descriptor->idPrefix); | |||||
| candidate.bounds.x += final_offset_x; | |||||
| candidate.bounds.y += final_offset_y; | |||||
| std::string error; | |||||
| if (!validateEditableControl(*target_page, candidate, &error)) | |||||
| { | |||||
| project.hmiPages = before.pages; | |||||
| project.initialHmiPageId = before.initial_page_id; | |||||
| project_service_.restoreModifiedState(modified_before); | |||||
| return failure(HmiEditorError::InvalidControl, error); | |||||
| } | |||||
| new_ids.push_back(candidate.id); | |||||
| target_page->controls.push_back(std::move(candidate)); | |||||
| } | |||||
| recordHistory(before); | |||||
| return {true, HmiEditorError::None, {}, new_ids.front()}; | |||||
| } | |||||
| bool HmiEditorService::canUndo() const | bool HmiEditorService::canUndo() const | ||||
| { | { | ||||
| return history_.canUndo(); | return history_.canUndo(); | ||||
| @@ -693,10 +824,6 @@ HmiControl HmiEditorService::makeControl( | |||||
| { | { | ||||
| control.pageJump = HmiPageJumpConfig{}; | control.pageJump = HmiPageJumpConfig{}; | ||||
| } | } | ||||
| if (descriptor.type == HmiControlType::ProgressBar) | |||||
| { | |||||
| control.progressBar = HmiProgressBarConfig{}; | |||||
| } | |||||
| const int offset = static_cast<int>(page.controls.size()) * 16; | const int offset = static_cast<int>(page.controls.size()) * 16; | ||||
| control.bounds.x = std::min(20 + offset, page.width - control.bounds.width); | control.bounds.x = std::min(20 + offset, page.width - control.bounds.width); | ||||
| control.bounds.y = std::min(20 + offset, page.height - control.bounds.height); | control.bounds.y = std::min(20 + offset, page.height - control.bounds.height); | ||||
| @@ -21,17 +21,17 @@ struct HmiControlDescriptor; | |||||
| */ | */ | ||||
| enum class HmiEditorError | enum class HmiEditorError | ||||
| { | { | ||||
| None, | |||||
| PageNotFound, | |||||
| ControlNotFound, | |||||
| DuplicateId, | |||||
| DuplicateName, | |||||
| InvalidPage, | |||||
| InvalidControl, | |||||
| LastPageRequired, | |||||
| InitialPageCannotBeRemoved, | |||||
| PageReferenced, | |||||
| InvalidOperation | |||||
| None, // 操作成功或没有错误 | |||||
| PageNotFound, // 目标页面不存在 | |||||
| ControlNotFound, // 目标控件不存在 | |||||
| DuplicateId, // 控件 ID 与同页其他控件重复 | |||||
| DuplicateName, // 页面名称与其他页面重复 | |||||
| InvalidPage, // 页面名称、尺寸或页面配置无效 | |||||
| InvalidControl, // 控件属性、绑定或页面边界无效 | |||||
| LastPageRequired, // 删除后不能少于一个 HMI 页面 | |||||
| InitialPageCannotBeRemoved, // 初始页面仍未切换,不能删除 | |||||
| PageReferenced, // 页面仍被页面跳转控件引用 | |||||
| InvalidOperation // 操作参数或撤销/重做状态不允许 | |||||
| }; | }; | ||||
| /** | /** | ||||
| @@ -41,10 +41,10 @@ enum class HmiEditorError | |||||
| */ | */ | ||||
| struct HmiEditorResult | struct HmiEditorResult | ||||
| { | { | ||||
| bool succeeded = false; | |||||
| HmiEditorError error = HmiEditorError::None; | |||||
| std::string message; | |||||
| std::string id; | |||||
| bool succeeded = false; // 操作是否成功 | |||||
| HmiEditorError error = HmiEditorError::None; // 失败时的分类 | |||||
| std::string message; // 面向用户的 UTF-8 成功说明或失败原因 | |||||
| std::string id; // 成功时返回新建或更新后的页面/控件 ID | |||||
| }; | }; | ||||
| /** | /** | ||||
| @@ -55,7 +55,13 @@ struct HmiEditorResult | |||||
| class HmiEditorService | class HmiEditorService | ||||
| { | { | ||||
| public: | public: | ||||
| explicit HmiEditorService(ProjectService &project_service); | |||||
| /** | |||||
| * @brief 创建 HMI 编辑服务 | |||||
| * @param project_service 用于读取和原子修改当前工程的项目服务 | |||||
| */ | |||||
| explicit HmiEditorService( | |||||
| ProjectService &project_service, | |||||
| const HmiDefaultSettings &hmi_defaults = defaultHmiSettings()); | |||||
| /** | /** | ||||
| * @brief 按页面标识查找只读页面 | * @brief 按页面标识查找只读页面 | ||||
| @@ -82,13 +88,52 @@ public: | |||||
| * @return 已有或新建页面的成功结果及其页面标识 | * @return 已有或新建页面的成功结果及其页面标识 | ||||
| */ | */ | ||||
| HmiEditorResult ensureDefaultPage(); | HmiEditorResult ensureDefaultPage(); | ||||
| /** | |||||
| * @brief 添加一个 HMI 页面 | |||||
| * @param name 页面显示名称,不能为空且必须在工程内唯一 | |||||
| * @return 成功时返回新页面 ID | |||||
| */ | |||||
| HmiEditorResult addPage(const std::string &name); | HmiEditorResult addPage(const std::string &name); | ||||
| /** | |||||
| * @brief 调整页面尺寸 | |||||
| * @param page_id 目标页面唯一标识 | |||||
| * @param width 页面宽度,必须满足页面边界且不能使已有控件越界 | |||||
| * @param height 页面高度,必须满足页面边界且不能使已有控件越界 | |||||
| * @return 页面不存在或新尺寸无效时返回失败结果 | |||||
| */ | |||||
| HmiEditorResult resizePage( | HmiEditorResult resizePage( | ||||
| const std::string &page_id, int width, int height); | const std::string &page_id, int width, int height); | ||||
| /** | |||||
| * @brief 修改页面显示名称 | |||||
| * @param page_id 目标页面唯一标识 | |||||
| * @param name 新页面名称,不能为空且必须在工程内唯一 | |||||
| * @return 页面不存在或名称无效/重复时返回失败结果 | |||||
| */ | |||||
| HmiEditorResult renamePage( | HmiEditorResult renamePage( | ||||
| const std::string &page_id, const std::string &name); | const std::string &page_id, const std::string &name); | ||||
| /** | |||||
| * @brief 删除一个 HMI 页面 | |||||
| * @param page_id 待删除页面唯一标识 | |||||
| * @return 页面不存在、页面是唯一页面、仍为初始页面或仍被引用时返回失败结果 | |||||
| */ | |||||
| HmiEditorResult removePage(const std::string &page_id); | HmiEditorResult removePage(const std::string &page_id); | ||||
| /** | |||||
| * @brief 将页面在工程页面列表中上移或下移一位 | |||||
| * @param page_id 目标页面唯一标识 | |||||
| * @param offset 只能为 -1(上移)或 1(下移) | |||||
| * @return 页面不存在或已经位于目标边界时返回失败结果 | |||||
| */ | |||||
| HmiEditorResult movePage(const std::string &page_id, int offset); | HmiEditorResult movePage(const std::string &page_id, int offset); | ||||
| /** | |||||
| * @brief 设置工程的初始 HMI 页面 | |||||
| * @param page_id 要作为启动页面的页面唯一标识 | |||||
| * @return 页面不存在时返回 PageNotFound | |||||
| */ | |||||
| HmiEditorResult setInitialPage(const std::string &page_id); | HmiEditorResult setInitialPage(const std::string &page_id); | ||||
| /** | /** | ||||
| * @brief 向指定页面添加待配置的基础控件 | * @brief 向指定页面添加待配置的基础控件 | ||||
| @@ -108,6 +153,13 @@ public: | |||||
| */ | */ | ||||
| HmiEditorResult removeControl( | HmiEditorResult removeControl( | ||||
| const std::string &page_id, const std::string &control_id); | const std::string &page_id, const std::string &control_id); | ||||
| /** | |||||
| * @brief 一次删除同一页面中的多个控件 | |||||
| * @param page_id 所属页面唯一标识 | |||||
| * @param control_ids 待删除控件 ID 列表,不能为空且不能包含重复 ID | |||||
| * @return 页面、控件不存在或 ID 列表无效时返回失败结果 | |||||
| */ | |||||
| HmiEditorResult removeControls( | HmiEditorResult removeControls( | ||||
| const std::string &page_id, | const std::string &page_id, | ||||
| const std::vector<std::string> &control_ids); | const std::vector<std::string> &control_ids); | ||||
| @@ -136,26 +188,65 @@ public: | |||||
| const std::string &control_id, | const std::string &control_id, | ||||
| const HmiControl &control); | const HmiControl &control); | ||||
| /** | |||||
| * @brief 批量粘贴 HMI 控件 | |||||
| * @param page_id 目标页面唯一标识 | |||||
| * @param controls 待复制的控件配置,不使用其中的 ID | |||||
| * @param offset_x 控件整体横向偏移 | |||||
| * @param offset_y 控件整体纵向偏移 | |||||
| * @return 成功时返回第一个新控件 ID,失败时整批回滚 | |||||
| */ | |||||
| HmiEditorResult pasteControls( | |||||
| const std::string &page_id, | |||||
| const std::vector<HmiControl> &controls, | |||||
| int offset_x = 20, | |||||
| int offset_y = 20); | |||||
| /** @brief 判断是否存在可撤销的 HMI 编辑操作 */ | |||||
| bool canUndo() const; | bool canUndo() const; | ||||
| /** @brief 判断是否存在可重做的 HMI 编辑操作 */ | |||||
| bool canRedo() const; | bool canRedo() const; | ||||
| /** | |||||
| * @brief 撤销最近一次成功的编辑操作 | |||||
| * @return 没有可撤销操作时返回 InvalidOperation | |||||
| */ | |||||
| HmiEditorResult undo(); | HmiEditorResult undo(); | ||||
| /** | |||||
| * @brief 重做最近一次被撤销的编辑操作 | |||||
| * @return 没有可重做操作时返回 InvalidOperation | |||||
| */ | |||||
| HmiEditorResult redo(); | HmiEditorResult redo(); | ||||
| /** | |||||
| * @brief 清空撤销和重做历史 | |||||
| * | |||||
| * 不修改当前工程内容 | |||||
| */ | |||||
| void clearHistory(); | void clearHistory(); | ||||
| private: | private: | ||||
| // 撤销/重做需要保存的完整 HMI 编辑状态 | |||||
| struct HistoryState | struct HistoryState | ||||
| { | { | ||||
| std::vector<HmiPage> pages; | |||||
| std::string initial_page_id; | |||||
| std::vector<HmiPage> pages; // 页面及其控件的深拷贝 | |||||
| std::string initial_page_id; // 工程初始页面 ID | |||||
| }; | }; | ||||
| // 捕获当前工程状态,作为一次编辑操作的前后快照 | |||||
| HistoryState captureState() const; | HistoryState captureState() const; | ||||
| // 比较编辑前后状态并记录实际发生的修改 | |||||
| void recordHistory(HistoryState before); | void recordHistory(HistoryState before); | ||||
| // 比较两个历史快照是否完全相同 | |||||
| static bool statesEqual( | static bool statesEqual( | ||||
| const HistoryState &left, const HistoryState &right); | const HistoryState &left, const HistoryState &right); | ||||
| // 比较两个 HMI 控件的全部可保存属性 | |||||
| static bool controlsEqual( | static bool controlsEqual( | ||||
| const HmiControl &left, const HmiControl &right); | const HmiControl &left, const HmiControl &right); | ||||
| // 比较两个 HMI 页面及其控件集合 | |||||
| static bool pagesEqual(const HmiPage &left, const HmiPage &right); | static bool pagesEqual(const HmiPage &left, const HmiPage &right); | ||||
| // 生成统一的历史操作失败结果 | |||||
| static HmiEditorResult historyFailure(const std::string &message); | static HmiEditorResult historyFailure(const std::string &message); | ||||
| /** | /** | ||||
| @@ -183,7 +274,7 @@ private: | |||||
| /** | /** | ||||
| * @brief 按控件类型创建带默认属性的新控件 | * @brief 按控件类型创建带默认属性的新控件 | ||||
| * @param page 新控件所属页面,用于确定初始位置和唯一标识 | * @param page 新控件所属页面,用于确定初始位置和唯一标识 | ||||
| * @param type 新控件类型 | |||||
| * @param descriptor 新控件类型对应的注册描述 | |||||
| * @return 未绑定寄存器的默认控件配置 | * @return 未绑定寄存器的默认控件配置 | ||||
| */ | */ | ||||
| static HmiControl makeControl( | static HmiControl makeControl( | ||||
| @@ -201,5 +292,6 @@ private: | |||||
| * @brief 非拥有的工程服务依赖,由应用入口保证生命周期 | * @brief 非拥有的工程服务依赖,由应用入口保证生命周期 | ||||
| */ | */ | ||||
| ProjectService &project_service_; | ProjectService &project_service_; | ||||
| const HmiDefaultSettings &hmi_defaults_; | |||||
| EditorHistory<HistoryState> history_; | EditorHistory<HistoryState> history_; | ||||
| }; | }; | ||||
| @@ -11,6 +11,7 @@ HmiNavigationService::HmiNavigationService(ProjectService &project_service) | |||||
| HmiNavigationResult HmiNavigationService::start() | HmiNavigationResult HmiNavigationService::start() | ||||
| { | { | ||||
| // 运行开始始终从工程声明的初始页面进入,而不是沿用上次会话页面 | |||||
| const Project &project = project_service_.project(); | const Project &project = project_service_.project(); | ||||
| if (project.hmiPages.empty()) | if (project.hmiPages.empty()) | ||||
| { | { | ||||
| @@ -4,6 +4,7 @@ | |||||
| class ProjectService; | class ProjectService; | ||||
| // HMI 页面跳转失败分类 | |||||
| enum class HmiNavigationError | enum class HmiNavigationError | ||||
| { | { | ||||
| None, | None, | ||||
| @@ -11,6 +12,7 @@ enum class HmiNavigationError | |||||
| PageNotFound | PageNotFound | ||||
| }; | }; | ||||
| // 页面跳转结果;成功时 pageId 是当前页 ID | |||||
| struct HmiNavigationResult | struct HmiNavigationResult | ||||
| { | { | ||||
| bool succeeded = false; | bool succeeded = false; | ||||
| @@ -19,14 +21,19 @@ struct HmiNavigationResult | |||||
| std::string pageId; | std::string pageId; | ||||
| }; | }; | ||||
| // 管理运行态当前页面,不直接操作 Qt 页面控件 | |||||
| class HmiNavigationService | class HmiNavigationService | ||||
| { | { | ||||
| public: | public: | ||||
| explicit HmiNavigationService(ProjectService &project_service); | explicit HmiNavigationService(ProjectService &project_service); | ||||
| // 进入工程初始页面 | |||||
| HmiNavigationResult start(); | HmiNavigationResult start(); | ||||
| // 跳转到指定页面;目标不存在时保留原页面 | |||||
| HmiNavigationResult navigateTo(const std::string &page_id); | HmiNavigationResult navigateTo(const std::string &page_id); | ||||
| // 结束运行会话并清空当前页 | |||||
| void stop(); | void stop(); | ||||
| // 返回当前运行页面 ID,未启动时为空 | |||||
| const std::string ¤tPageId() const; | const std::string ¤tPageId() const; | ||||
| private: | private: | ||||
| @@ -2,11 +2,14 @@ | |||||
| #include "domain/hmi_control_registry.h" | #include "domain/hmi_control_registry.h" | ||||
| #include <cmath> | |||||
| #include <limits> | |||||
| namespace { | namespace { | ||||
| HmiRuntimeReadResult readFailure(HmiRuntimeError error) | HmiRuntimeReadResult readFailure(HmiRuntimeError error) | ||||
| { | { | ||||
| return {false, error, false, 0}; | |||||
| return {false, error, false, 0, 0.0f}; | |||||
| } | } | ||||
| HmiRuntimeWriteResult writeFailure(HmiRuntimeError error) | HmiRuntimeWriteResult writeFailure(HmiRuntimeError error) | ||||
| @@ -55,16 +58,30 @@ HmiRuntimeReadResult HmiRuntimeService::readControl(const HmiControl &control) c | |||||
| { | { | ||||
| return readFailure(repositoryError(result.error)); | return readFailure(repositoryError(result.error)); | ||||
| } | } | ||||
| return {true, HmiRuntimeError::None, result.value, 0}; | |||||
| return {true, HmiRuntimeError::None, result.value, 0, 0.0f}; | |||||
| } | } | ||||
| case HmiRuntimeValueKind::Word: | case HmiRuntimeValueKind::Word: | ||||
| { | { | ||||
| if (control.dataType == RegisterDataType::Float32) | |||||
| { | |||||
| const WordPairReadResult result = repository_.readWordPair(*control.binding); | |||||
| if (!result.succeeded) | |||||
| { | |||||
| return readFailure(repositoryError(result.error)); | |||||
| } | |||||
| const std::optional<float> value = Float32Codec::decode( | |||||
| result.values[0], result.values[1]); | |||||
| return value.has_value() | |||||
| ? HmiRuntimeReadResult{ | |||||
| true, HmiRuntimeError::None, false, 0, *value} | |||||
| : readFailure(HmiRuntimeError::RepositoryFailure); | |||||
| } | |||||
| const WordReadResult result = repository_.readWord(*control.binding); | const WordReadResult result = repository_.readWord(*control.binding); | ||||
| if (!result.succeeded) | if (!result.succeeded) | ||||
| { | { | ||||
| return readFailure(repositoryError(result.error)); | return readFailure(repositoryError(result.error)); | ||||
| } | } | ||||
| return {true, HmiRuntimeError::None, false, result.value}; | |||||
| return {true, HmiRuntimeError::None, false, result.value, 0.0f}; | |||||
| } | } | ||||
| case HmiRuntimeValueKind::None: | case HmiRuntimeValueKind::None: | ||||
| default: | default: | ||||
| @@ -140,7 +157,7 @@ HmiRuntimeWriteResult HmiRuntimeService::operateButton( | |||||
| } | } | ||||
| HmiRuntimeWriteResult HmiRuntimeService::writeNumericInput( | HmiRuntimeWriteResult HmiRuntimeService::writeNumericInput( | ||||
| const HmiControl &control, std::int16_t value) | |||||
| const HmiControl &control, double value) | |||||
| { | { | ||||
| if (control.type != HmiControlType::NumericInput) | if (control.type != HmiControlType::NumericInput) | ||||
| { | { | ||||
| @@ -150,11 +167,40 @@ HmiRuntimeWriteResult HmiRuntimeService::writeNumericInput( | |||||
| { | { | ||||
| return writeFailure(HmiRuntimeError::MissingBinding); | return writeFailure(HmiRuntimeError::MissingBinding); | ||||
| } | } | ||||
| if (!control.binding->isValid()) | |||||
| if (!control.binding->isValid() || control.binding->area() != RegisterArea::D) | |||||
| { | { | ||||
| return writeFailure(HmiRuntimeError::InvalidBinding); | return writeFailure(HmiRuntimeError::InvalidBinding); | ||||
| } | } | ||||
| const RegisterWriteResult result = repository_.writeWord(*control.binding, value); | |||||
| RegisterWriteResult result; | |||||
| if (control.dataType == RegisterDataType::Float32) | |||||
| { | |||||
| if (control.binding->index() >= RegisterAddress::kMaximumIndex | |||||
| || !std::isfinite(value) | |||||
| || value > static_cast<double>(std::numeric_limits<float>::max()) | |||||
| || value < -static_cast<double>(std::numeric_limits<float>::max())) | |||||
| { | |||||
| return writeFailure(HmiRuntimeError::InvalidBinding); | |||||
| } | |||||
| const float float_value = static_cast<float>(value); | |||||
| if (!std::isfinite(float_value)) | |||||
| { | |||||
| return writeFailure(HmiRuntimeError::InvalidBinding); | |||||
| } | |||||
| result = repository_.writeWordPair( | |||||
| *control.binding, Float32Codec::encode(float_value)); | |||||
| } | |||||
| else | |||||
| { | |||||
| if (!std::isfinite(value) | |||||
| || value < std::numeric_limits<std::int16_t>::min() | |||||
| || value > std::numeric_limits<std::int16_t>::max() | |||||
| || std::trunc(value) != value) | |||||
| { | |||||
| return writeFailure(HmiRuntimeError::InvalidBinding); | |||||
| } | |||||
| result = repository_.writeWord( | |||||
| *control.binding, static_cast<std::int16_t>(value)); | |||||
| } | |||||
| return result.succeeded | return result.succeeded | ||||
| ? HmiRuntimeWriteResult{true, HmiRuntimeError::None} | ? HmiRuntimeWriteResult{true, HmiRuntimeError::None} | ||||
| : writeFailure(repositoryError(result.error)); | : writeFailure(repositoryError(result.error)); | ||||
| @@ -18,10 +18,10 @@ | |||||
| enum class HmiRuntimeError | enum class HmiRuntimeError | ||||
| { | { | ||||
| None, // 无错误 | None, // 无错误 | ||||
| UnsupportedControl, // 控件类型不支持寄存器读写 | |||||
| UnsupportedControl, // 控件类型不支持寄存器读写或写入操作 | |||||
| MissingBinding, // 控件未配置寄存器绑定 | MissingBinding, // 控件未配置寄存器绑定 | ||||
| InvalidBinding, // 控件绑定地址无效或地址区域不匹配 | InvalidBinding, // 控件绑定地址无效或地址区域不匹配 | ||||
| RepositoryFailure // 寄存器仓库读取或写入失败 | |||||
| RepositoryFailure // 寄存器仓库不可用或拒绝读写 | |||||
| }; | }; | ||||
| /** | /** | ||||
| @@ -31,10 +31,11 @@ enum class HmiRuntimeError | |||||
| */ | */ | ||||
| struct HmiRuntimeReadResult | struct HmiRuntimeReadResult | ||||
| { | { | ||||
| bool succeeded = false; | |||||
| HmiRuntimeError error = HmiRuntimeError::None; | |||||
| bool bit_value = false; | |||||
| std::int16_t word_value = 0; | |||||
| bool succeeded = false; // 是否成功读到控件绑定值 | |||||
| HmiRuntimeError error = HmiRuntimeError::None; // 失败时的 HMI 错误分类 | |||||
| bool bit_value = false; // 位控件的读回值;字控件读取成功时无效 | |||||
| std::int16_t word_value = 0; // 字控件的读回值;位控件读取成功时无效 | |||||
| float float_value = 0.0f; // Float32 数值控件的读回值 | |||||
| }; | }; | ||||
| /** | /** | ||||
| @@ -42,8 +43,8 @@ struct HmiRuntimeReadResult | |||||
| */ | */ | ||||
| struct HmiRuntimeWriteResult | struct HmiRuntimeWriteResult | ||||
| { | { | ||||
| bool succeeded = false; | |||||
| HmiRuntimeError error = HmiRuntimeError::None; | |||||
| bool succeeded = false; // 写入请求是否被仓库接受 | |||||
| HmiRuntimeError error = HmiRuntimeError::None; // 失败时的 HMI 错误分类 | |||||
| }; | }; | ||||
| /** | /** | ||||
| @@ -51,8 +52,8 @@ struct HmiRuntimeWriteResult | |||||
| */ | */ | ||||
| enum class HmiButtonEvent | enum class HmiButtonEvent | ||||
| { | { | ||||
| Pressed, | |||||
| Released | |||||
| Pressed, // 按钮按下事件 | |||||
| Released // 按钮释放事件 | |||||
| }; | }; | ||||
| /** | /** | ||||
| @@ -60,9 +61,14 @@ enum class HmiButtonEvent | |||||
| * | * | ||||
| * 服务不依赖虚拟寄存器实现、串口或 Modbus,可由运行模式注入不同仓库 | * 服务不依赖虚拟寄存器实现、串口或 Modbus,可由运行模式注入不同仓库 | ||||
| */ | */ | ||||
| // 仅通过统一寄存器仓库执行 HMI 运行态读写 | |||||
| class HmiRuntimeService | class HmiRuntimeService | ||||
| { | { | ||||
| public: | public: | ||||
| /** | |||||
| * @brief 创建 HMI 运行态读写服务 | |||||
| * @param repository 当前运行模式提供的寄存器仓库,不由本服务拥有 | |||||
| */ | |||||
| explicit HmiRuntimeService(RegisterRepository &repository); | explicit HmiRuntimeService(RegisterRepository &repository); | ||||
| /** | /** | ||||
| @@ -78,6 +84,9 @@ public: | |||||
| * @param control 按钮控件,必须绑定有效 M 地址 | * @param control 按钮控件,必须绑定有效 M 地址 | ||||
| * @param event 当前输入事件 | * @param event 当前输入事件 | ||||
| * @return 无需写入的事件返回成功,配置无效或仓库拒绝写入时返回失败 | * @return 无需写入的事件返回成功,配置无效或仓库拒绝写入时返回失败 | ||||
| * | |||||
| * SetOn、SetOff 和 Toggle 只在按下时写入;MomentaryOn 在按下时写 1、 | |||||
| * 释放时写 0。写入结果以仓库返回值为准,不直接假定缓存已经更新 | |||||
| */ | */ | ||||
| HmiRuntimeWriteResult operateButton( | HmiRuntimeWriteResult operateButton( | ||||
| const HmiControl &control, HmiButtonEvent event); | const HmiControl &control, HmiButtonEvent event); | ||||
| @@ -86,14 +95,29 @@ public: | |||||
| * @param control 数值输入控件,必须绑定有效 D 地址 | * @param control 数值输入控件,必须绑定有效 D 地址 | ||||
| * @param value 要写入的 D 字值 | * @param value 要写入的 D 字值 | ||||
| * @return 非数值输入、绑定无效或仓库拒绝写入时返回失败结果 | * @return 非数值输入、绑定无效或仓库拒绝写入时返回失败结果 | ||||
| * | |||||
| * 该服务只负责提交写入请求,实际读回值由后续仓库轮询确认 | |||||
| */ | */ | ||||
| HmiRuntimeWriteResult writeNumericInput( | HmiRuntimeWriteResult writeNumericInput( | ||||
| const HmiControl &control, std::int16_t value); | |||||
| const HmiControl &control, double value); | |||||
| /** | |||||
| * @brief 读取指定 M 位 | |||||
| * @param address 必须是有效的 M 区地址 | |||||
| * @return 仓库原始读取结果,不转换为 HMI 错误分类 | |||||
| */ | |||||
| BitReadResult readBit(const RegisterAddress &address) const; | BitReadResult readBit(const RegisterAddress &address) const; | ||||
| /** | |||||
| * @brief 读取指定 D 字 | |||||
| * @param address 必须是有效的 D 区地址 | |||||
| * @return 仓库原始读取结果,不转换为 HMI 错误分类 | |||||
| */ | |||||
| WordReadResult readWord(const RegisterAddress &address) const; | WordReadResult readWord(const RegisterAddress &address) const; | ||||
| private: | private: | ||||
| // 将寄存器仓库错误统一转换为 HMI 运行态错误 | |||||
| static HmiRuntimeError repositoryError(RegisterError error); | static HmiRuntimeError repositoryError(RegisterError error); | ||||
| RegisterRepository &repository_; | |||||
| RegisterRepository &repository_; // 当前运行模式使用的寄存器仓库,不由本服务拥有 | |||||
| }; | }; | ||||
| @@ -0,0 +1,588 @@ | |||||
| #include "logic_command_service.h" | |||||
| #include "logic_editor_service.h" | |||||
| #include <algorithm> | |||||
| #include <cctype> | |||||
| #include <limits> | |||||
| #include <sstream> | |||||
| #include <unordered_map> | |||||
| namespace { | |||||
| std::vector<std::string> splitTokens(const std::string &text) | |||||
| { | |||||
| std::istringstream stream(text); | |||||
| std::vector<std::string> tokens; | |||||
| std::string token; | |||||
| while (stream >> token) | |||||
| { | |||||
| tokens.push_back(std::move(token)); | |||||
| } | |||||
| return tokens; | |||||
| } | |||||
| std::string upperAscii(std::string value) | |||||
| { | |||||
| std::transform( | |||||
| value.begin(), value.end(), value.begin(), | |||||
| [](unsigned char character) | |||||
| { | |||||
| return static_cast<char>(std::toupper(character)); | |||||
| }); | |||||
| return value; | |||||
| } | |||||
| LogicCommandParseResult parseFailure(const std::string &message) | |||||
| { | |||||
| return {false, message, {}}; | |||||
| } | |||||
| bool requiresOperands( | |||||
| const std::vector<std::string> &tokens, | |||||
| std::size_t count, | |||||
| const std::string &example, | |||||
| LogicCommandParseResult *failure) | |||||
| { | |||||
| if (tokens.size() == count + 1U) | |||||
| { | |||||
| return true; | |||||
| } | |||||
| *failure = parseFailure( | |||||
| tokens.front() + " 需要 " + std::to_string(count) | |||||
| + " 个操作数,例如 " + example); | |||||
| return false; | |||||
| } | |||||
| bool parseAddress( | |||||
| const std::string &text, | |||||
| RegisterArea expected_area, | |||||
| const std::string &instruction, | |||||
| RegisterAddress *address, | |||||
| LogicCommandParseResult *failure) | |||||
| { | |||||
| const RegisterAddressParseResult parsed = parseRegisterAddress(text); | |||||
| const char expected_prefix = expected_area == RegisterArea::M ? 'M' : 'D'; | |||||
| if (!parsed.succeeded) | |||||
| { | |||||
| const std::string range = std::string(1, expected_prefix) + "0~" | |||||
| + expected_prefix + std::to_string(RegisterAddress::kMaximumIndex); | |||||
| *failure = parseFailure( | |||||
| parsed.error == RegisterAddressParseError::OutOfRange | |||||
| ? std::string(1, expected_prefix) + " 地址超出范围,应为 " + range | |||||
| : "地址格式错误,应为 " + range); | |||||
| return false; | |||||
| } | |||||
| if (parsed.address.area() != expected_area) | |||||
| { | |||||
| *failure = parseFailure( | |||||
| instruction + " 只支持 " + std::string(1, expected_prefix) + " 地址"); | |||||
| return false; | |||||
| } | |||||
| *address = parsed.address; | |||||
| return true; | |||||
| } | |||||
| bool parseInt16( | |||||
| const std::string &text, | |||||
| const std::string &instruction, | |||||
| std::int16_t *value, | |||||
| LogicCommandParseResult *failure); | |||||
| bool parseWordOperand( | |||||
| const std::string &text, | |||||
| const std::string &instruction, | |||||
| WordOperand *operand, | |||||
| LogicCommandParseResult *failure) | |||||
| { | |||||
| const RegisterAddressParseResult parsed = parseRegisterAddress(text); | |||||
| if (parsed.succeeded) | |||||
| { | |||||
| if (parsed.address.area() != RegisterArea::D) | |||||
| { | |||||
| *failure = parseFailure(instruction + " 的寄存器操作数必须使用 D 地址"); | |||||
| return false; | |||||
| } | |||||
| *operand = WordOperand{ | |||||
| WordOperandKind::Register, parsed.address, 0}; | |||||
| return true; | |||||
| } | |||||
| if (!text.empty() | |||||
| && (text.front() == 'D' || text.front() == 'd' | |||||
| || text.front() == 'M' || text.front() == 'm')) | |||||
| { | |||||
| RegisterAddress address{RegisterArea::D, 0}; | |||||
| return parseAddress( | |||||
| text, RegisterArea::D, instruction, &address, failure) | |||||
| && ((*operand = WordOperand{ | |||||
| WordOperandKind::Register, address, 0}), true); | |||||
| } | |||||
| std::int16_t constant = 0; | |||||
| if (!parseInt16(text, instruction, &constant, failure)) | |||||
| { | |||||
| return false; | |||||
| } | |||||
| *operand = WordOperand{ | |||||
| WordOperandKind::Constant, | |||||
| RegisterAddress{RegisterArea::D, 0}, | |||||
| constant}; | |||||
| return true; | |||||
| } | |||||
| bool isLoad(LogicCommandOpcode opcode) | |||||
| { | |||||
| return opcode == LogicCommandOpcode::Load | |||||
| || opcode == LogicCommandOpcode::LoadInverse | |||||
| || opcode == LogicCommandOpcode::LoadRising | |||||
| || opcode == LogicCommandOpcode::LoadFalling | |||||
| || opcode == LogicCommandOpcode::CompareEqual | |||||
| || opcode == LogicCommandOpcode::CompareNotEqual | |||||
| || opcode == LogicCommandOpcode::CompareLessThan | |||||
| || opcode == LogicCommandOpcode::CompareLessThanOrEqual | |||||
| || opcode == LogicCommandOpcode::CompareGreaterThan | |||||
| || opcode == LogicCommandOpcode::CompareGreaterThanOrEqual; | |||||
| } | |||||
| bool isOr(LogicCommandOpcode opcode) | |||||
| { | |||||
| return opcode == LogicCommandOpcode::Or | |||||
| || opcode == LogicCommandOpcode::OrInverse; | |||||
| } | |||||
| bool isCondition(LogicCommandOpcode opcode) | |||||
| { | |||||
| return isLoad(opcode) || isOr(opcode) | |||||
| || opcode == LogicCommandOpcode::And | |||||
| || opcode == LogicCommandOpcode::AndInverse; | |||||
| } | |||||
| bool isConditionTarget(LogicCommandTargetKind kind) | |||||
| { | |||||
| return kind != LogicCommandTargetKind::Output; | |||||
| } | |||||
| bool isOutput(LogicCommandOpcode opcode) | |||||
| { | |||||
| return opcode == LogicCommandOpcode::Output | |||||
| || opcode == LogicCommandOpcode::Set | |||||
| || opcode == LogicCommandOpcode::Reset | |||||
| || opcode == LogicCommandOpcode::Move | |||||
| || opcode == LogicCommandOpcode::Add | |||||
| || opcode == LogicCommandOpcode::Subtract; | |||||
| } | |||||
| bool parseInt16( | |||||
| const std::string &text, | |||||
| const std::string &instruction, | |||||
| std::int16_t *value, | |||||
| LogicCommandParseResult *failure) | |||||
| { | |||||
| try | |||||
| { | |||||
| std::size_t consumed = 0; | |||||
| const long parsed = std::stol(text, &consumed, 10); | |||||
| if (consumed != text.size() | |||||
| || parsed < std::numeric_limits<std::int16_t>::min() | |||||
| || parsed > std::numeric_limits<std::int16_t>::max()) | |||||
| { | |||||
| throw std::out_of_range("int16"); | |||||
| } | |||||
| *value = static_cast<std::int16_t>(parsed); | |||||
| return true; | |||||
| } | |||||
| catch (const std::exception &) | |||||
| { | |||||
| *failure = parseFailure( | |||||
| instruction + " 的第二个操作数应为 -32768~32767 的常量"); | |||||
| return false; | |||||
| } | |||||
| } | |||||
| LogicCommandResult executionFailure( | |||||
| const std::string &message, LogicCommandOpcode opcode) | |||||
| { | |||||
| return {false, message, {}, {}, opcode}; | |||||
| } | |||||
| } // namespace | |||||
| LogicCommandService::LogicCommandService(LogicEditorService &editor_service) | |||||
| : editor_service_(editor_service) | |||||
| { | |||||
| } | |||||
| LogicCommandParseResult LogicCommandService::parse(const std::string &text) | |||||
| { | |||||
| std::vector<std::string> tokens = splitTokens(text); | |||||
| if (tokens.empty()) | |||||
| { | |||||
| return parseFailure("请输入 PLC 指令"); | |||||
| } | |||||
| tokens.front() = upperAscii(tokens.front()); | |||||
| const std::string &mnemonic = tokens.front(); | |||||
| LogicCommandParseResult failure; | |||||
| const std::unordered_map<std::string, LogicCommandOpcode> condition_opcodes{ | |||||
| {"LD", LogicCommandOpcode::Load}, | |||||
| {"LDI", LogicCommandOpcode::LoadInverse}, | |||||
| {"LDP", LogicCommandOpcode::LoadRising}, | |||||
| {"LDF", LogicCommandOpcode::LoadFalling}, | |||||
| {"AND", LogicCommandOpcode::And}, | |||||
| {"ANI", LogicCommandOpcode::AndInverse}, | |||||
| {"OR", LogicCommandOpcode::Or}, | |||||
| {"ORI", LogicCommandOpcode::OrInverse}}; | |||||
| const std::unordered_map<std::string, LogicCommandOpcode> compare_opcodes{ | |||||
| {"LD=", LogicCommandOpcode::CompareEqual}, | |||||
| {"LD<>", LogicCommandOpcode::CompareNotEqual}, | |||||
| {"LD<", LogicCommandOpcode::CompareLessThan}, | |||||
| {"LD<=", LogicCommandOpcode::CompareLessThanOrEqual}, | |||||
| {"LD>", LogicCommandOpcode::CompareGreaterThan}, | |||||
| {"LD>=", LogicCommandOpcode::CompareGreaterThanOrEqual}}; | |||||
| const auto compare = compare_opcodes.find(mnemonic); | |||||
| if (compare != compare_opcodes.end()) | |||||
| { | |||||
| if (!requiresOperands(tokens, 2U, mnemonic + " D0 0", &failure)) | |||||
| { | |||||
| return failure; | |||||
| } | |||||
| RegisterAddress address{RegisterArea::D, 0}; | |||||
| if (!parseAddress(tokens[1], RegisterArea::D, mnemonic, &address, &failure)) | |||||
| { | |||||
| return failure; | |||||
| } | |||||
| std::int16_t value = 0; | |||||
| if (!parseInt16(tokens[2], mnemonic, &value, &failure)) | |||||
| { | |||||
| return failure; | |||||
| } | |||||
| ComparisonOperator operation = ComparisonOperator::Equal; | |||||
| switch (compare->second) | |||||
| { | |||||
| case LogicCommandOpcode::CompareEqual: | |||||
| operation = ComparisonOperator::Equal; | |||||
| break; | |||||
| case LogicCommandOpcode::CompareNotEqual: | |||||
| operation = ComparisonOperator::NotEqual; | |||||
| break; | |||||
| case LogicCommandOpcode::CompareLessThan: | |||||
| operation = ComparisonOperator::LessThan; | |||||
| break; | |||||
| case LogicCommandOpcode::CompareLessThanOrEqual: | |||||
| operation = ComparisonOperator::LessThanOrEqual; | |||||
| break; | |||||
| case LogicCommandOpcode::CompareGreaterThan: | |||||
| operation = ComparisonOperator::GreaterThan; | |||||
| break; | |||||
| case LogicCommandOpcode::CompareGreaterThanOrEqual: | |||||
| operation = ComparisonOperator::GreaterThanOrEqual; | |||||
| break; | |||||
| default: | |||||
| break; | |||||
| } | |||||
| return {true, {}, {compare->second, | |||||
| CompareNodeConfig{address, operation, value}}}; | |||||
| } | |||||
| const auto condition = condition_opcodes.find(mnemonic); | |||||
| if (condition != condition_opcodes.end()) | |||||
| { | |||||
| if (!requiresOperands(tokens, 1U, mnemonic + " M0", &failure)) | |||||
| { | |||||
| return failure; | |||||
| } | |||||
| RegisterAddress address{RegisterArea::M, 0}; | |||||
| if (!parseAddress(tokens[1], RegisterArea::M, mnemonic, &address, &failure)) | |||||
| { | |||||
| return failure; | |||||
| } | |||||
| LogicNodeConfig config; | |||||
| if (condition->second == LogicCommandOpcode::LoadRising | |||||
| || condition->second == LogicCommandOpcode::LoadFalling) | |||||
| { | |||||
| config = EdgeContactNodeConfig{ | |||||
| address, | |||||
| condition->second == LogicCommandOpcode::LoadRising | |||||
| ? EdgeMode::Rising : EdgeMode::Falling}; | |||||
| } | |||||
| else | |||||
| { | |||||
| const bool inverse = condition->second == LogicCommandOpcode::LoadInverse | |||||
| || condition->second == LogicCommandOpcode::AndInverse | |||||
| || condition->second == LogicCommandOpcode::OrInverse; | |||||
| config = ContactNodeConfig{ | |||||
| address, | |||||
| inverse ? ContactMode::NormallyClosed | |||||
| : ContactMode::NormallyOpen}; | |||||
| } | |||||
| return {true, {}, {condition->second, std::move(config)}}; | |||||
| } | |||||
| const std::unordered_map<std::string, LogicCommandOpcode> coil_opcodes{ | |||||
| {"OUT", LogicCommandOpcode::Output}, | |||||
| {"SET", LogicCommandOpcode::Set}, | |||||
| {"RST", LogicCommandOpcode::Reset}}; | |||||
| const auto coil = coil_opcodes.find(mnemonic); | |||||
| if (coil != coil_opcodes.end()) | |||||
| { | |||||
| if (!requiresOperands(tokens, 1U, mnemonic + " M0", &failure)) | |||||
| { | |||||
| return failure; | |||||
| } | |||||
| RegisterAddress address{RegisterArea::M, 0}; | |||||
| if (!parseAddress(tokens[1], RegisterArea::M, mnemonic, &address, &failure)) | |||||
| { | |||||
| return failure; | |||||
| } | |||||
| const CoilMode mode = coil->second == LogicCommandOpcode::Set | |||||
| ? CoilMode::Set | |||||
| : coil->second == LogicCommandOpcode::Reset | |||||
| ? CoilMode::Reset : CoilMode::Normal; | |||||
| return {true, {}, {coil->second, CoilNodeConfig{address, mode}}}; | |||||
| } | |||||
| if (mnemonic == "MOV") | |||||
| { | |||||
| if (!requiresOperands(tokens, 2U, "MOV D0 D1", &failure)) | |||||
| { | |||||
| return failure; | |||||
| } | |||||
| RegisterAddress destination{RegisterArea::D, 0}; | |||||
| WordOperand source; | |||||
| if (!parseWordOperand(tokens[1], mnemonic, &source, &failure) | |||||
| || !parseAddress(tokens[2], RegisterArea::D, mnemonic, &destination, &failure)) | |||||
| { | |||||
| return failure; | |||||
| } | |||||
| return { | |||||
| true, {}, | |||||
| {LogicCommandOpcode::Move, | |||||
| MoveNodeConfig{source, destination}}}; | |||||
| } | |||||
| if (mnemonic == "ADD" || mnemonic == "SUB") | |||||
| { | |||||
| if (!requiresOperands(tokens, 3U, mnemonic + " D0 D1 D2", &failure)) | |||||
| { | |||||
| return failure; | |||||
| } | |||||
| WordOperand left; | |||||
| WordOperand right; | |||||
| RegisterAddress destination{RegisterArea::D, 0}; | |||||
| if (!parseWordOperand(tokens[1], mnemonic, &left, &failure) | |||||
| || !parseWordOperand(tokens[2], mnemonic, &right, &failure) | |||||
| || !parseAddress(tokens[3], RegisterArea::D, mnemonic, &destination, &failure)) | |||||
| { | |||||
| return failure; | |||||
| } | |||||
| const LogicCommandOpcode opcode = mnemonic == "ADD" | |||||
| ? LogicCommandOpcode::Add : LogicCommandOpcode::Subtract; | |||||
| return { | |||||
| true, {}, | |||||
| {opcode, | |||||
| ArithmeticNodeConfig{ | |||||
| opcode == LogicCommandOpcode::Add | |||||
| ? ArithmeticOperation::Add : ArithmeticOperation::Subtract, | |||||
| left, | |||||
| right, | |||||
| destination}}}; | |||||
| } | |||||
| return parseFailure("当前指令暂不支持:" + mnemonic); | |||||
| } | |||||
| const std::vector<LogicCommandSuggestion> &LogicCommandService::suggestions() | |||||
| { | |||||
| static const std::vector<LogicCommandSuggestion> values{ | |||||
| {"LD", "常开触点", "M 地址"}, | |||||
| {"LDI", "常闭触点", "M 地址"}, | |||||
| {"LDP", "上升沿触点", "M 地址"}, | |||||
| {"LDF", "下降沿触点", "M 地址"}, | |||||
| {"LD=", "D 值等于常量", "D 地址 + 常量"}, | |||||
| {"LD<>", "D 值不等于常量", "D 地址 + 常量"}, | |||||
| {"LD<", "D 值小于常量", "D 地址 + 常量"}, | |||||
| {"LD<=", "D 值小于等于常量", "D 地址 + 常量"}, | |||||
| {"LD>", "D 值大于常量", "D 地址 + 常量"}, | |||||
| {"LD>=", "D 值大于等于常量", "D 地址 + 常量"}, | |||||
| {"AND", "串联常开触点", "M 地址"}, | |||||
| {"ANI", "串联常闭触点", "M 地址"}, | |||||
| {"OR", "并联常开触点", "M 地址"}, | |||||
| {"ORI", "并联常闭触点", "M 地址"}, | |||||
| {"OUT", "普通线圈", "M 地址"}, | |||||
| {"SET", "置位线圈", "M 地址"}, | |||||
| {"RST", "复位线圈", "M 地址"}, | |||||
| {"MOV", "数据传送", "D 地址 -> D 地址"}, | |||||
| {"ADD", "加法", "D 地址 + D 地址 -> D 地址"}, | |||||
| {"SUB", "减法", "D 地址 - D 地址 -> D 地址"}}; | |||||
| return values; | |||||
| } | |||||
| LogicCommandResult LogicCommandService::execute( | |||||
| const LogicCommandRequest &request) | |||||
| { | |||||
| const LogicCommandParseResult parsed = parse(request.text); | |||||
| if (!parsed.succeeded) | |||||
| { | |||||
| return executionFailure(parsed.message, parsed.command.opcode); | |||||
| } | |||||
| const LogicCommandOpcode opcode = parsed.command.opcode; | |||||
| LogicEditorResult edited; | |||||
| if (request.target.kind == LogicCommandTargetKind::ExistingNode) | |||||
| { | |||||
| const LogicNode *existing = editor_service_.findNode( | |||||
| request.logicId, request.target.expressionId); | |||||
| if (existing == nullptr) | |||||
| { | |||||
| return executionFailure("未找到要编辑的逻辑节点", opcode); | |||||
| } | |||||
| if (existing->isCondition()) | |||||
| { | |||||
| if (!isLoad(opcode)) | |||||
| { | |||||
| return executionFailure( | |||||
| "已有条件节点只能替换为 LD/LDI/LDP/LDF 指令", opcode); | |||||
| } | |||||
| } | |||||
| else if (existing->isOutput()) | |||||
| { | |||||
| if (!isOutput(opcode)) | |||||
| { | |||||
| return executionFailure( | |||||
| "已有输出节点只能替换为 OUT/SET/RST/MOV/ADD/SUB 指令", | |||||
| opcode); | |||||
| } | |||||
| } | |||||
| else | |||||
| { | |||||
| return executionFailure("当前节点类型不支持命令替换", opcode); | |||||
| } | |||||
| edited = editor_service_.updateNodeConfig( | |||||
| request.logicId, request.target.expressionId, | |||||
| parsed.command.config); | |||||
| } | |||||
| else if (isCondition(opcode)) | |||||
| { | |||||
| if (isLoad(opcode) && request.continuing) | |||||
| { | |||||
| edited = editor_service_.appendCondition( | |||||
| request.logicId, {}, parsed.command.config, true); | |||||
| } | |||||
| else if (isOr(opcode)) | |||||
| { | |||||
| const std::string rung_id = request.continuing | |||||
| ? request.currentRungId : request.target.rungId; | |||||
| if (request.continuing) | |||||
| { | |||||
| edited = editor_service_.addParallelToWholeCondition( | |||||
| request.logicId, rung_id, parsed.command.config, true); | |||||
| } | |||||
| else if (request.parallelNodeIds.empty()) | |||||
| { | |||||
| return executionFailure( | |||||
| "OR/ORI 需要先选中同一网络中的连续条件", opcode); | |||||
| } | |||||
| else | |||||
| { | |||||
| edited = editor_service_.addParallelBranch( | |||||
| request.logicId, | |||||
| rung_id, | |||||
| request.parallelNodeIds, | |||||
| parsed.command.config, | |||||
| true); | |||||
| } | |||||
| } | |||||
| else if (request.continuing) | |||||
| { | |||||
| const LadderRung *rung = editor_service_.findRung( | |||||
| request.logicId, request.currentRungId); | |||||
| if (rung != nullptr && rung->output.has_value()) | |||||
| { | |||||
| return executionFailure( | |||||
| "当前网络已有输出,下一条只能输入 LD/LDI/LDP/LDF 新建网络", | |||||
| opcode); | |||||
| } | |||||
| edited = editor_service_.appendCondition( | |||||
| request.logicId, request.currentRungId, | |||||
| parsed.command.config, true); | |||||
| } | |||||
| else | |||||
| { | |||||
| if (!isConditionTarget(request.target.kind)) | |||||
| { | |||||
| return executionFailure( | |||||
| "条件指令只能输入在第 1~10 列条件区", opcode); | |||||
| } | |||||
| if ((opcode == LogicCommandOpcode::And | |||||
| || opcode == LogicCommandOpcode::AndInverse) | |||||
| && (!request.target.rungId.empty() | |||||
| && (editor_service_.findRung( | |||||
| request.logicId, request.target.rungId) == nullptr | |||||
| || !editor_service_.findRung( | |||||
| request.logicId, request.target.rungId) | |||||
| ->condition.has_value()))) | |||||
| { | |||||
| return executionFailure( | |||||
| "AND/ANI 前必须先输入 LD/LDI/LDP/LDF", opcode); | |||||
| } | |||||
| switch (request.target.kind) | |||||
| { | |||||
| case LogicCommandTargetKind::EmptyColumn: | |||||
| edited = editor_service_.insertConditionAtColumn( | |||||
| request.logicId, request.target.rungId, | |||||
| request.target.column, parsed.command.config, true); | |||||
| break; | |||||
| case LogicCommandTargetKind::BranchEmptyColumn: | |||||
| edited = editor_service_.insertConditionInBranchAtColumn( | |||||
| request.logicId, request.target.rungId, | |||||
| request.target.expressionId, request.target.column, | |||||
| parsed.command.config, true); | |||||
| break; | |||||
| case LogicCommandTargetKind::WireColumn: | |||||
| edited = editor_service_.replaceWireColumnWithCondition( | |||||
| request.logicId, request.target.rungId, | |||||
| request.target.expressionId, request.target.column, | |||||
| parsed.command.config, true); | |||||
| break; | |||||
| case LogicCommandTargetKind::GapColumn: | |||||
| edited = editor_service_.replaceGapColumnWithCondition( | |||||
| request.logicId, request.target.rungId, | |||||
| request.target.expressionId, request.target.column, | |||||
| parsed.command.config, true); | |||||
| break; | |||||
| case LogicCommandTargetKind::Output: | |||||
| break; | |||||
| case LogicCommandTargetKind::ExistingNode: | |||||
| break; | |||||
| } | |||||
| } | |||||
| } | |||||
| else | |||||
| { | |||||
| const std::string rung_id = request.continuing | |||||
| ? request.currentRungId : request.target.rungId; | |||||
| if (!request.continuing | |||||
| && request.target.kind != LogicCommandTargetKind::Output) | |||||
| { | |||||
| return executionFailure( | |||||
| "输出指令只能输入在第 11 列输出区", opcode); | |||||
| } | |||||
| const LadderRung *rung = editor_service_.findRung( | |||||
| request.logicId, rung_id); | |||||
| if (request.continuing && rung != nullptr && rung->output.has_value()) | |||||
| { | |||||
| return executionFailure( | |||||
| "当前网络已经有输出,下一条请输入 LD/LDI/LDP/LDF 新建网络", | |||||
| opcode); | |||||
| } | |||||
| edited = editor_service_.setOutput( | |||||
| request.logicId, rung_id, parsed.command.config, true); | |||||
| } | |||||
| if (!edited.succeeded) | |||||
| { | |||||
| return executionFailure(edited.message, opcode); | |||||
| } | |||||
| return { | |||||
| true, {}, edited.id, | |||||
| editor_service_.rungIdForNode(request.logicId, edited.id), | |||||
| opcode}; | |||||
| } | |||||
| @@ -0,0 +1,106 @@ | |||||
| #pragma once | |||||
| #include "domain/control_logic_model.h" | |||||
| #include <string> | |||||
| #include <vector> | |||||
| class LogicEditorService; | |||||
| enum class LogicCommandOpcode | |||||
| { | |||||
| Load, | |||||
| LoadInverse, | |||||
| LoadRising, | |||||
| LoadFalling, | |||||
| CompareEqual, | |||||
| CompareNotEqual, | |||||
| CompareLessThan, | |||||
| CompareLessThanOrEqual, | |||||
| CompareGreaterThan, | |||||
| CompareGreaterThanOrEqual, | |||||
| And, | |||||
| AndInverse, | |||||
| Or, | |||||
| OrInverse, | |||||
| Output, | |||||
| Set, | |||||
| Reset, | |||||
| Move, | |||||
| Add, | |||||
| Subtract | |||||
| }; | |||||
| struct ParsedLogicCommand | |||||
| { | |||||
| LogicCommandOpcode opcode = LogicCommandOpcode::Load; | |||||
| LogicNodeConfig config = ContactNodeConfig{ | |||||
| RegisterAddress{RegisterArea::M, 0}, ContactMode::NormallyOpen}; | |||||
| }; | |||||
| struct LogicCommandParseResult | |||||
| { | |||||
| bool succeeded = false; | |||||
| std::string message; | |||||
| ParsedLogicCommand command; | |||||
| }; | |||||
| struct LogicCommandSuggestion | |||||
| { | |||||
| std::string mnemonic; | |||||
| std::string description; | |||||
| std::string operand_hint; | |||||
| bool supported = true; | |||||
| }; | |||||
| enum class LogicCommandTargetKind | |||||
| { | |||||
| EmptyColumn, | |||||
| BranchEmptyColumn, | |||||
| WireColumn, | |||||
| GapColumn, | |||||
| Output, | |||||
| ExistingNode | |||||
| }; | |||||
| struct LogicCommandTarget | |||||
| { | |||||
| LogicCommandTargetKind kind = LogicCommandTargetKind::EmptyColumn; | |||||
| std::string rungId; | |||||
| std::string expressionId; | |||||
| int column = 0; | |||||
| }; | |||||
| struct LogicCommandRequest | |||||
| { | |||||
| std::string logicId; | |||||
| std::string text; | |||||
| LogicCommandTarget target; | |||||
| bool continuing = false; | |||||
| std::string currentRungId; | |||||
| std::vector<std::string> parallelNodeIds; | |||||
| }; | |||||
| struct LogicCommandResult | |||||
| { | |||||
| bool succeeded = false; | |||||
| std::string message; | |||||
| std::string id; | |||||
| std::string rungId; | |||||
| LogicCommandOpcode opcode = LogicCommandOpcode::Load; | |||||
| }; | |||||
| // 将单条命令语解析并原子转换为现有结构化梯形图编辑操作 | |||||
| class LogicCommandService | |||||
| { | |||||
| public: | |||||
| explicit LogicCommandService(LogicEditorService &editor_service); | |||||
| static LogicCommandParseResult parse(const std::string &text); | |||||
| static const std::vector<LogicCommandSuggestion> &suggestions(); | |||||
| LogicCommandResult execute(const LogicCommandRequest &request); | |||||
| private: | |||||
| LogicEditorService &editor_service_; | |||||
| }; | |||||
| @@ -4,147 +4,324 @@ | |||||
| #include "editor_history.h" | #include "editor_history.h" | ||||
| #include <string> | #include <string> | ||||
| #include <utility> | |||||
| #include <vector> | #include <vector> | ||||
| class ProjectService; | class ProjectService; | ||||
| // 梯形图编辑失败分类 | |||||
| enum class LogicEditorError | enum class LogicEditorError | ||||
| { | { | ||||
| None, | |||||
| LogicNotFound, | |||||
| RungNotFound, | |||||
| ExpressionNotFound, | |||||
| NodeNotFound, | |||||
| InvalidNode, | |||||
| InvalidOperation, | |||||
| UnsupportedNodeChange, | |||||
| DuplicateName, | |||||
| LastLogicRequired | |||||
| None, // 操作成功或没有错误 | |||||
| LogicNotFound, // 控制逻辑不存在 | |||||
| RungNotFound, // 网络不存在 | |||||
| ExpressionNotFound, // 条件表达式不存在 | |||||
| NodeNotFound, // 节点不存在 | |||||
| InvalidNode, // 节点配置或节点位置无效 | |||||
| InvalidOperation, // 操作参数或当前结构不允许该操作 | |||||
| UnsupportedNodeChange, // 不允许把条件节点改成输出节点,或反向修改 | |||||
| DuplicateName, // 控制逻辑名称与其他逻辑重复 | |||||
| LastLogicRequired // 删除后不能少于一个控制逻辑 | |||||
| }; | }; | ||||
| // 梯形图编辑结果;成功时 id 通常是新建或更新对象的稳定 ID | |||||
| struct LogicEditorResult | struct LogicEditorResult | ||||
| { | { | ||||
| bool succeeded = false; | |||||
| LogicEditorError error = LogicEditorError::None; | |||||
| std::string message; | |||||
| std::string id; | |||||
| bool succeeded = false; // 操作是否成功 | |||||
| LogicEditorError error = LogicEditorError::None; // 失败时的分类 | |||||
| std::string message; // 面向用户的 UTF-8 成功说明或失败原因 | |||||
| std::string id; // 成功时返回新建或更新对象的稳定 ID | |||||
| }; | }; | ||||
| enum class LogicConditionPasteTargetKind | |||||
| { | |||||
| Append, | |||||
| EmptyColumn, | |||||
| BranchEmptyColumn, | |||||
| AfterNode, | |||||
| ReplaceWire, | |||||
| ReplaceWireColumn, | |||||
| ReplaceGapColumn | |||||
| }; | |||||
| struct LogicConditionPasteTarget | |||||
| { | |||||
| LogicConditionPasteTargetKind kind = LogicConditionPasteTargetKind::Append; | |||||
| std::string expressionId; | |||||
| int column = 0; | |||||
| }; | |||||
| // 负责把 UI 编辑命令转换为结构化表达式树操作,并维护撤销/重做 | |||||
| class LogicEditorService | class LogicEditorService | ||||
| { | { | ||||
| public: | public: | ||||
| /** | |||||
| * @brief 创建梯形图编辑服务 | |||||
| * @param project_service 用于读取和修改当前工程的项目服务 | |||||
| */ | |||||
| explicit LogicEditorService(ProjectService &project_service); | explicit LogicEditorService(ProjectService &project_service); | ||||
| // 以下查询接口只读工程模型,供编辑器投影和属性面板使用 | |||||
| /** @brief 按 ID 查找控制逻辑,未找到时返回空指针 */ | |||||
| const ControlLogic *findLogic(const std::string &logic_id) const; | const ControlLogic *findLogic(const std::string &logic_id) const; | ||||
| /** @brief 在指定逻辑中按 ID 查找网络,未找到时返回空指针 */ | |||||
| const LadderRung *findRung( | const LadderRung *findRung( | ||||
| const std::string &logic_id, const std::string &rung_id) const; | const std::string &logic_id, const std::string &rung_id) const; | ||||
| /** @brief 在指定逻辑的所有网络中按 ID 查找节点,未找到时返回空指针 */ | |||||
| const LogicNode *findNode( | const LogicNode *findNode( | ||||
| const std::string &logic_id, const std::string &node_id) const; | const std::string &logic_id, const std::string &node_id) const; | ||||
| /** @brief 在指定网络中按 ID 查找条件表达式,未找到时返回空指针 */ | |||||
| const ConditionExpression *findExpression( | const ConditionExpression *findExpression( | ||||
| const std::string &logic_id, | const std::string &logic_id, | ||||
| const std::string &rung_id, | const std::string &rung_id, | ||||
| const std::string &expression_id) const; | const std::string &expression_id) const; | ||||
| /** @brief 返回工程中第一个控制逻辑 ID,没有逻辑时返回空字符串 */ | |||||
| std::string firstLogicId() const; | std::string firstLogicId() const; | ||||
| /** @brief 返回指定逻辑中第一个网络 ID,没有网络或逻辑不存在时返回空字符串 */ | |||||
| std::string firstRungId(const std::string &logic_id) const; | std::string firstRungId(const std::string &logic_id) const; | ||||
| /** @brief 查找节点所属网络 ID,未找到时返回空字符串 */ | |||||
| std::string rungIdForNode( | std::string rungIdForNode( | ||||
| const std::string &logic_id, const std::string &node_id) const; | const std::string &logic_id, const std::string &node_id) const; | ||||
| /** @brief 返回指定 M/D 地址的工程注释,没有注释时返回空字符串 */ | |||||
| std::string registerCommentFor(const RegisterAddress &address) const; | std::string registerCommentFor(const RegisterAddress &address) const; | ||||
| /** | |||||
| * @brief 确保工程至少有一组可编辑逻辑 | |||||
| * @return 已有或新建逻辑的成功结果及其 ID | |||||
| * | |||||
| * 新建逻辑时允许暂时没有网络,第一次实际编辑网络内容时再创建网络 | |||||
| */ | |||||
| LogicEditorResult ensureDefaultLogic(); | LogicEditorResult ensureDefaultLogic(); | ||||
| /** @brief 添加控制逻辑;名称不能为空且必须唯一 */ | |||||
| LogicEditorResult addLogic(const std::string &name); | LogicEditorResult addLogic(const std::string &name); | ||||
| /** @brief 修改控制逻辑名称;名称不能为空且必须唯一 */ | |||||
| LogicEditorResult renameLogic( | LogicEditorResult renameLogic( | ||||
| const std::string &logic_id, const std::string &name); | const std::string &logic_id, const std::string &name); | ||||
| /** @brief 删除控制逻辑;工程至少保留一组逻辑 */ | |||||
| LogicEditorResult removeLogic(const std::string &logic_id); | LogicEditorResult removeLogic(const std::string &logic_id); | ||||
| /** @brief 将逻辑在工程列表中上移或下移一位,offset 只能为 -1 或 1 */ | |||||
| LogicEditorResult moveLogic(const std::string &logic_id, int offset); | LogicEditorResult moveLogic(const std::string &logic_id, int offset); | ||||
| /** @brief 启用或停用指定控制逻辑 */ | |||||
| LogicEditorResult setLogicEnabled(const std::string &logic_id, bool enabled); | LogicEditorResult setLogicEnabled(const std::string &logic_id, bool enabled); | ||||
| /** @brief 在指定逻辑末尾添加空网络 */ | |||||
| LogicEditorResult addRung(const std::string &logic_id); | LogicEditorResult addRung(const std::string &logic_id); | ||||
| /** @brief 删除指定网络 */ | |||||
| LogicEditorResult removeRung( | LogicEditorResult removeRung( | ||||
| const std::string &logic_id, const std::string &rung_id); | const std::string &logic_id, const std::string &rung_id); | ||||
| /** @brief 批量删除指定网络,失败时整体回滚 */ | |||||
| LogicEditorResult removeRungs( | |||||
| const std::string &logic_id, | |||||
| const std::vector<std::string> &rung_ids); | |||||
| /** @brief 修改网络注释;注释长度和换行规则由领域校验约束 */ | |||||
| LogicEditorResult updateRungComment( | LogicEditorResult updateRungComment( | ||||
| const std::string &logic_id, | const std::string &logic_id, | ||||
| const std::string &rung_id, | const std::string &rung_id, | ||||
| const std::string &comment); | const std::string &comment); | ||||
| // 条件区编辑:串联、按列插入、横线和并联分支 | |||||
| /** @brief 在网络条件末尾追加一个条件节点 */ | |||||
| LogicEditorResult appendCondition( | LogicEditorResult appendCondition( | ||||
| const std::string &logic_id, | const std::string &logic_id, | ||||
| const std::string &rung_id, | const std::string &rung_id, | ||||
| const LogicNodeConfig &config); | |||||
| const LogicNodeConfig &config, | |||||
| bool configured = false); | |||||
| /** | |||||
| * @brief 按绝对条件列插入条件节点 | |||||
| * @param column 从 0 开始的条件列号;插入位置必须位于允许的条件区 | |||||
| */ | |||||
| LogicEditorResult insertConditionAtColumn( | LogicEditorResult insertConditionAtColumn( | ||||
| const std::string &logic_id, | const std::string &logic_id, | ||||
| const std::string &rung_id, | const std::string &rung_id, | ||||
| int column, | int column, | ||||
| const LogicNodeConfig &config); | |||||
| const LogicNodeConfig &config, | |||||
| bool configured = false); | |||||
| /** | |||||
| * @brief 在直属并联分支的指定视觉列插入条件节点 | |||||
| * | |||||
| * 目标列可以是分支已有横线或 UI 投影出的补线格,服务会原子补齐必要横线 | |||||
| */ | |||||
| LogicEditorResult insertConditionInBranchAtColumn( | |||||
| const std::string &logic_id, | |||||
| const std::string &rung_id, | |||||
| const std::string &branch_expression_id, | |||||
| int column, | |||||
| const LogicNodeConfig &config, | |||||
| bool configured = false); | |||||
| /** @brief 在网络条件末尾追加指定列宽的横线 */ | |||||
| LogicEditorResult appendWire( | LogicEditorResult appendWire( | ||||
| const std::string &logic_id, | const std::string &logic_id, | ||||
| const std::string &rung_id, | const std::string &rung_id, | ||||
| int column_span = 1); | int column_span = 1); | ||||
| /** @brief 在目标节点后串联插入条件节点 */ | |||||
| LogicEditorResult insertConditionAfter( | LogicEditorResult insertConditionAfter( | ||||
| const std::string &logic_id, | const std::string &logic_id, | ||||
| const std::string &rung_id, | const std::string &rung_id, | ||||
| const std::string &target_node_id, | const std::string &target_node_id, | ||||
| const LogicNodeConfig &config); | const LogicNodeConfig &config); | ||||
| /** @brief 在目标表达式后串联插入横线 */ | |||||
| LogicEditorResult insertWireAfter( | LogicEditorResult insertWireAfter( | ||||
| const std::string &logic_id, | const std::string &logic_id, | ||||
| const std::string &rung_id, | const std::string &rung_id, | ||||
| const std::string &target_expression_id, | const std::string &target_expression_id, | ||||
| int column_span = 1); | int column_span = 1); | ||||
| /** @brief 用条件节点整体替换一条横线表达式 */ | |||||
| LogicEditorResult replaceWireWithCondition( | LogicEditorResult replaceWireWithCondition( | ||||
| const std::string &logic_id, | const std::string &logic_id, | ||||
| const std::string &rung_id, | const std::string &rung_id, | ||||
| const std::string &wire_expression_id, | const std::string &wire_expression_id, | ||||
| const LogicNodeConfig &config); | const LogicNodeConfig &config); | ||||
| /** @brief 只替换横线表达式中的一个指定列单元格 */ | |||||
| LogicEditorResult replaceWireColumnWithCondition( | LogicEditorResult replaceWireColumnWithCondition( | ||||
| const std::string &logic_id, | const std::string &logic_id, | ||||
| const std::string &rung_id, | const std::string &rung_id, | ||||
| const std::string &wire_expression_id, | const std::string &wire_expression_id, | ||||
| int column_offset, | int column_offset, | ||||
| const LogicNodeConfig &config); | |||||
| const LogicNodeConfig &config, | |||||
| bool configured = false); | |||||
| /** @brief 用条件节点替换断路表达式中的一个指定列单元格 */ | |||||
| LogicEditorResult replaceGapColumnWithCondition( | |||||
| const std::string &logic_id, | |||||
| const std::string &rung_id, | |||||
| const std::string &gap_expression_id, | |||||
| int column_offset, | |||||
| const LogicNodeConfig &config, | |||||
| bool configured = false); | |||||
| /** @brief 用一格横线修复断路表达式中的指定网格 */ | |||||
| LogicEditorResult replaceGapColumnWithWire( | |||||
| const std::string &logic_id, | |||||
| const std::string &rung_id, | |||||
| const std::string &gap_expression_id, | |||||
| int column_offset); | |||||
| /** | |||||
| * @brief 将选中的条件节点建立为并联分支 | |||||
| * @param selected_node_ids 同一网络中按视觉连续范围选择的条件节点 ID | |||||
| */ | |||||
| LogicEditorResult addParallelBranch( | LogicEditorResult addParallelBranch( | ||||
| const std::string &logic_id, | const std::string &logic_id, | ||||
| const std::string &rung_id, | const std::string &rung_id, | ||||
| const std::vector<std::string> &selected_node_ids, | const std::vector<std::string> &selected_node_ids, | ||||
| const LogicNodeConfig &config); | |||||
| const LogicNodeConfig &config, | |||||
| bool configured = false); | |||||
| /** @brief 将当前网络的完整条件表达式与一个新条件并联 */ | |||||
| LogicEditorResult addParallelToWholeCondition( | |||||
| const std::string &logic_id, | |||||
| const std::string &rung_id, | |||||
| const LogicNodeConfig &config, | |||||
| bool configured = false); | |||||
| /** @brief 将选中的横线表达式建立为并联旁路 */ | |||||
| LogicEditorResult addParallelWireBranch( | LogicEditorResult addParallelWireBranch( | ||||
| const std::string &logic_id, | const std::string &logic_id, | ||||
| const std::string &rung_id, | const std::string &rung_id, | ||||
| const std::vector<std::string> &selected_expression_ids); | const std::vector<std::string> &selected_expression_ids); | ||||
| /** | |||||
| * @brief 按选中的横线网格建立并联旁路 | |||||
| * | |||||
| * 所有网格必须来自同一条横线,且列号连续;操作失败时不保留部分修改 | |||||
| */ | |||||
| LogicEditorResult addParallelWireBranchAtCells( | |||||
| const std::string &logic_id, | |||||
| const std::string &rung_id, | |||||
| const std::vector<std::pair<std::string, int>> &selected_wire_cells); | |||||
| /** | |||||
| * @brief 设置网络唯一的输出节点 | |||||
| * @param configured 是否将新输出标记为已完成配置 | |||||
| * | |||||
| * 输出节点固定位于网络输出槽,替换已有输出时保持一次原子编辑 | |||||
| */ | |||||
| LogicEditorResult setOutput( | LogicEditorResult setOutput( | ||||
| const std::string &logic_id, | const std::string &logic_id, | ||||
| const std::string &rung_id, | const std::string &rung_id, | ||||
| const LogicNodeConfig &config, | const LogicNodeConfig &config, | ||||
| bool configured = false); | bool configured = false); | ||||
| // 节点属性和删除操作 | |||||
| /** @brief 更新节点配置;条件节点和输出节点不能互相改型 */ | |||||
| LogicEditorResult updateNodeConfig( | LogicEditorResult updateNodeConfig( | ||||
| const std::string &logic_id, | const std::string &logic_id, | ||||
| const std::string &node_id, | const std::string &node_id, | ||||
| const LogicNodeConfig &config); | const LogicNodeConfig &config); | ||||
| /** | |||||
| * @brief 批量粘贴条件节点到目标网络末尾 | |||||
| * @param logic_id 目标控制逻辑 | |||||
| * @param rung_id 目标网络;为空时自动创建网络 | |||||
| * @param nodes 待复制节点,只读取配置和 configured 状态 | |||||
| * @return 成功时返回第一个新节点 ID,失败时整批回滚 | |||||
| */ | |||||
| LogicEditorResult pasteConditionNodes( | |||||
| const std::string &logic_id, | |||||
| const std::string &rung_id, | |||||
| const std::vector<LogicNode> &nodes, | |||||
| const LogicConditionPasteTarget &target = {}); | |||||
| /** 判断所选条件是否位于同一串联层级并且视觉连续 */ | |||||
| bool areConditionNodesContiguous( | |||||
| const std::string &logic_id, | |||||
| const std::string &rung_id, | |||||
| const std::vector<std::string> &node_ids) const; | |||||
| /** | |||||
| * @brief 将整条网络复制到目标控制逻辑末尾 | |||||
| * @param logic_id 目标控制逻辑 | |||||
| * @param source 要复制的网络 | |||||
| * @return 成功时返回新网络 ID,所有节点和表达式都会获得新 ID | |||||
| */ | |||||
| LogicEditorResult pasteRung( | |||||
| const std::string &logic_id, | |||||
| const LadderRung &source); | |||||
| /** @brief 删除指定节点并归一化受影响的表达式树 */ | |||||
| LogicEditorResult removeNode( | LogicEditorResult removeNode( | ||||
| const std::string &logic_id, const std::string &node_id); | const std::string &logic_id, const std::string &node_id); | ||||
| /** | |||||
| * @brief 批量删除节点 | |||||
| * @param node_ids 节点 ID 列表,不能为空且不能包含重复或不存在的 ID | |||||
| * @return 成功时作为一次编辑记录,失败时整体回滚 | |||||
| */ | |||||
| LogicEditorResult removeNodes( | LogicEditorResult removeNodes( | ||||
| const std::string &logic_id, | const std::string &logic_id, | ||||
| const std::vector<std::string> &node_ids); | const std::vector<std::string> &node_ids); | ||||
| /** @brief 删除指定条件表达式并归一化表达式树 */ | |||||
| LogicEditorResult removeExpression( | LogicEditorResult removeExpression( | ||||
| const std::string &logic_id, | const std::string &logic_id, | ||||
| const std::string &rung_id, | const std::string &rung_id, | ||||
| const std::string &expression_id); | const std::string &expression_id); | ||||
| /** @brief 批量删除条件表达式,失败时整体回滚 */ | |||||
| LogicEditorResult removeExpressions( | LogicEditorResult removeExpressions( | ||||
| const std::string &logic_id, | const std::string &logic_id, | ||||
| const std::string &rung_id, | const std::string &rung_id, | ||||
| const std::vector<std::string> &expression_ids); | const std::vector<std::string> &expression_ids); | ||||
| /** @brief 把选中的横线网格替换为断路,保留网络列位置 */ | |||||
| LogicEditorResult disconnectWireCells( | |||||
| const std::string &logic_id, | |||||
| const std::string &rung_id, | |||||
| const std::vector<std::pair<std::string, int>> &wire_cells); | |||||
| /** @brief 把整段横线替换为等宽断路 */ | |||||
| LogicEditorResult disconnectWires( | |||||
| const std::string &logic_id, | |||||
| const std::string &rung_id, | |||||
| const std::vector<std::string> &wire_ids); | |||||
| // 当前编辑会话的撤销/重做 | |||||
| /** @brief 判断是否存在可撤销的梯形图编辑操作 */ | |||||
| bool canUndo() const; | bool canUndo() const; | ||||
| /** @brief 判断是否存在可重做的梯形图编辑操作 */ | |||||
| bool canRedo() const; | bool canRedo() const; | ||||
| /** @brief 撤销最近一次成功的梯形图编辑操作 */ | |||||
| LogicEditorResult undo(); | LogicEditorResult undo(); | ||||
| /** @brief 重做最近一次被撤销的梯形图编辑操作 */ | |||||
| LogicEditorResult redo(); | LogicEditorResult redo(); | ||||
| /** @brief 清空撤销/重做历史,不修改当前工程内容 */ | |||||
| void clearHistory(); | void clearHistory(); | ||||
| private: | private: | ||||
| // 只保存逻辑集合快照,当前选择等 UI 会话状态不进入历史 | |||||
| struct HistoryState | struct HistoryState | ||||
| { | { | ||||
| std::vector<ControlLogic> logics; | |||||
| std::vector<ControlLogic> logics; // 编辑前或编辑后的完整逻辑集合 | |||||
| }; | }; | ||||
| // 捕获当前工程中的全部控制逻辑 | |||||
| HistoryState captureState() const; | HistoryState captureState() const; | ||||
| // 比较编辑前后状态并记录实际发生的修改 | |||||
| void recordHistory(HistoryState before); | void recordHistory(HistoryState before); | ||||
| // 失败时恢复工程内容和操作前脏标记,保证编辑原子性 | |||||
| void rollbackEdit(HistoryState before, bool modified_before); | void rollbackEdit(HistoryState before, bool modified_before); | ||||
| // 比较两个历史快照是否完全相同 | |||||
| static bool statesEqual( | static bool statesEqual( | ||||
| const HistoryState &left, const HistoryState &right); | const HistoryState &left, const HistoryState &right); | ||||
| static bool logicsEqual( | static bool logicsEqual( | ||||
| @@ -158,17 +335,31 @@ private: | |||||
| const LogicNodeConfig &left, const LogicNodeConfig &right); | const LogicNodeConfig &left, const LogicNodeConfig &right); | ||||
| static LogicEditorResult historyFailure(const std::string &message); | static LogicEditorResult historyFailure(const std::string &message); | ||||
| // 判断节点配置是否属于条件节点 | |||||
| static bool isConditionConfig(const LogicNodeConfig &config); | static bool isConditionConfig(const LogicNodeConfig &config); | ||||
| // 判断节点配置是否属于输出节点 | |||||
| static bool isOutputConfig(const LogicNodeConfig &config); | static bool isOutputConfig(const LogicNodeConfig &config); | ||||
| // 返回节点配置对应的稳定 ID 前缀 | |||||
| static std::string nodePrefix(const LogicNodeConfig &config); | static std::string nodePrefix(const LogicNodeConfig &config); | ||||
| // 在一个控制逻辑的全部网络中生成唯一节点 ID | |||||
| static std::string makeUniqueNodeId( | static std::string makeUniqueNodeId( | ||||
| const ControlLogic &logic, const std::string &prefix); | const ControlLogic &logic, const std::string &prefix); | ||||
| // 生成唯一横线表达式 ID | |||||
| static std::string makeUniqueWireId(const ControlLogic &logic); | static std::string makeUniqueWireId(const ControlLogic &logic); | ||||
| // 生成控制逻辑内唯一的断路表达式 ID | |||||
| static std::string makeUniqueGapId(const ControlLogic &logic); | |||||
| // 生成唯一容器表达式 ID | |||||
| static std::string makeUniqueExpressionId(const ControlLogic &logic); | static std::string makeUniqueExpressionId(const ControlLogic &logic); | ||||
| // 生成唯一网络 ID | |||||
| static std::string makeUniqueRungId(const ControlLogic &logic); | static std::string makeUniqueRungId(const ControlLogic &logic); | ||||
| // 把并联补线和输出前连接线转换为显式 Wire,并保持输出网络十列布局 | |||||
| static bool repairExplicitLayout( | |||||
| ControlLogic &logic, LadderRung &rung, std::string *error = nullptr); | |||||
| // 创建统一的失败结果 | |||||
| static LogicEditorResult failure( | static LogicEditorResult failure( | ||||
| LogicEditorError error, const std::string &message); | LogicEditorError error, const std::string &message); | ||||
| ProjectService &project_service_; | |||||
| EditorHistory<HistoryState> history_; | |||||
| ProjectService &project_service_; // 不拥有的工程服务依赖 | |||||
| EditorHistory<HistoryState> history_; // 当前编辑会话的撤销/重做历史 | |||||
| bool suppress_history_ = false; // 复合粘贴期间由外层统一记录一次历史 | |||||
| }; | }; | ||||
| @@ -4,9 +4,11 @@ | |||||
| OfflineSimulationService::OfflineSimulationService( | OfflineSimulationService::OfflineSimulationService( | ||||
| VirtualRegisterRepository &repository, | VirtualRegisterRepository &repository, | ||||
| VirtualRegisterRepository *initial_repository, | |||||
| QObject *parent) | QObject *parent) | ||||
| : QObject(parent), | : QObject(parent), | ||||
| repository_(repository) | |||||
| repository_(repository), | |||||
| initial_repository_(initial_repository) | |||||
| { | { | ||||
| timer_.setInterval(kDefaultScanIntervalMs); | timer_.setInterval(kDefaultScanIntervalMs); | ||||
| timer_.setTimerType(Qt::PreciseTimer); | timer_.setTimerType(Qt::PreciseTimer); | ||||
| @@ -22,6 +24,7 @@ SimulationStartResult OfflineSimulationService::start( | |||||
| return {false, SimulationStartError::AlreadyRunning, {}}; | return {false, SimulationStartError::AlreadyRunning, {}}; | ||||
| } | } | ||||
| // 仿真期间使用启动时快照,编辑器后续修改不会改变正在扫描的逻辑 | |||||
| std::vector<ControlLogic> snapshot = logics; | std::vector<ControlLogic> snapshot = logics; | ||||
| const LogicScanResult validation = executor_.validate(snapshot); | const LogicScanResult validation = executor_.validate(snapshot); | ||||
| if (!validation.succeeded) | if (!validation.succeeded) | ||||
| @@ -30,9 +33,14 @@ SimulationStartResult OfflineSimulationService::start( | |||||
| return {false, SimulationStartError::InvalidLogic, validation}; | return {false, SimulationStartError::InvalidLogic, validation}; | ||||
| } | } | ||||
| // 重启会话时清空沿触发状态和运行值,再装载编辑态确认的初始值 | |||||
| timer_.stop(); | timer_.stop(); | ||||
| executor_.resetRuntime(); | executor_.resetRuntime(); | ||||
| repository_.clear(); | repository_.clear(); | ||||
| if (initial_repository_ != nullptr) | |||||
| { | |||||
| repository_.copyFrom(*initial_repository_); | |||||
| } | |||||
| logic_snapshot_ = std::move(snapshot); | logic_snapshot_ = std::move(snapshot); | ||||
| successful_scan_count_ = 0; | successful_scan_count_ = 0; | ||||
| last_error_ = {true, LogicScanError::None, {}, {}, {}, {}}; | last_error_ = {true, LogicScanError::None, {}, {}, {}, {}}; | ||||
| @@ -43,6 +51,15 @@ SimulationStartResult OfflineSimulationService::start( | |||||
| return {true, SimulationStartError::None, {true, LogicScanError::None, {}, {}, {}, {}}}; | return {true, SimulationStartError::None, {true, LogicScanError::None, {}, {}, {}, {}}}; | ||||
| } | } | ||||
| void OfflineSimulationService::restoreInitialValues() | |||||
| { | |||||
| repository_.clear(); | |||||
| if (initial_repository_ != nullptr) | |||||
| { | |||||
| repository_.copyFrom(*initial_repository_); | |||||
| } | |||||
| } | |||||
| void OfflineSimulationService::stop() | void OfflineSimulationService::stop() | ||||
| { | { | ||||
| timer_.stop(); | timer_.stop(); | ||||
| @@ -52,6 +69,8 @@ void OfflineSimulationService::stop() | |||||
| { | { | ||||
| return; | return; | ||||
| } | } | ||||
| // 运行结束后丢弃本轮输出,编辑态继续看到下一轮要使用的初始值 | |||||
| restoreInitialValues(); | |||||
| state_ = SimulationState::Stopped; | state_ = SimulationState::Stopped; | ||||
| last_error_ = {true, LogicScanError::None, {}, {}, {}, {}}; | last_error_ = {true, LogicScanError::None, {}, {}, {}, {}}; | ||||
| emit stateChanged(); | emit stateChanged(); | ||||
| @@ -68,6 +87,7 @@ LogicScanResult OfflineSimulationService::executeOnce() | |||||
| {}, | {}, | ||||
| {}}; | {}}; | ||||
| } | } | ||||
| // 执行器负责一轮完整扫描;任何读写错误都会让服务进入 Faulted | |||||
| const LogicScanResult result = executor_.executeScan( | const LogicScanResult result = executor_.executeScan( | ||||
| logic_snapshot_, repository_, &trace_snapshot_); | logic_snapshot_, repository_, &trace_snapshot_); | ||||
| if (!result.succeeded) | if (!result.succeeded) | ||||
| @@ -112,6 +132,7 @@ void OfflineSimulationService::handleTimeout() | |||||
| void OfflineSimulationService::enterFault(const LogicScanResult &error) | void OfflineSimulationService::enterFault(const LogicScanResult &error) | ||||
| { | { | ||||
| // 故障后停止定时器和运行时状态,保留错误及最后轨迹供 UI 诊断 | |||||
| timer_.stop(); | timer_.stop(); | ||||
| executor_.resetRuntime(); | executor_.resetRuntime(); | ||||
| last_error_ = error; | last_error_ = error; | ||||
| @@ -1,5 +1,6 @@ | |||||
| #pragma once | #pragma once | ||||
| #include "domain/virtual_register_repository.h" | |||||
| #include "software_logic_executor.h" | #include "software_logic_executor.h" | ||||
| #include <QObject> | #include <QObject> | ||||
| @@ -8,25 +9,28 @@ | |||||
| #include <cstdint> | #include <cstdint> | ||||
| #include <vector> | #include <vector> | ||||
| // 离线扫描器的生命周期状态 | |||||
| enum class SimulationState | enum class SimulationState | ||||
| { | { | ||||
| Stopped, | |||||
| Running, | |||||
| Faulted | |||||
| Stopped, // 未运行或已主动停止 | |||||
| Running, // 定时器正在驱动离线扫描 | |||||
| Faulted // 最近一轮扫描失败,定时器已停止 | |||||
| }; | }; | ||||
| // 启动离线仿真失败的原因 | |||||
| enum class SimulationStartError | enum class SimulationStartError | ||||
| { | { | ||||
| None, | |||||
| AlreadyRunning, | |||||
| InvalidLogic | |||||
| None, // 启动成功 | |||||
| AlreadyRunning, // 当前已经处于运行态 | |||||
| InvalidLogic // 启动前逻辑校验失败 | |||||
| }; | }; | ||||
| // 离线仿真启动结果;detail 保存领域执行器的具体校验错误 | |||||
| struct SimulationStartResult | struct SimulationStartResult | ||||
| { | { | ||||
| bool succeeded = false; | |||||
| SimulationStartError error = SimulationStartError::None; | |||||
| LogicScanResult detail; | |||||
| bool succeeded = false; // 是否成功启动仿真 | |||||
| SimulationStartError error = SimulationStartError::None; // 启动失败分类 | |||||
| LogicScanResult detail; // 领域执行器的详细校验或执行结果 | |||||
| }; | }; | ||||
| // 管理离线逻辑快照、固定周期扫描和实际执行状态 | // 管理离线逻辑快照、固定周期扫描和实际执行状态 | ||||
| @@ -35,36 +39,59 @@ class OfflineSimulationService final : public QObject | |||||
| Q_OBJECT | Q_OBJECT | ||||
| public: | public: | ||||
| // 默认离线扫描周期,单位为毫秒 | |||||
| static constexpr int kDefaultScanIntervalMs = 50; | static constexpr int kDefaultScanIntervalMs = 50; | ||||
| /** | |||||
| * @brief 创建离线仿真服务 | |||||
| * @param repository 离线模式使用的虚拟 M/D 寄存器仓库 | |||||
| * @param initial_repository 编辑态确认的离线初始值仓库,可为空以保持旧的全清零行为 | |||||
| * @param parent Qt 对象父级 | |||||
| */ | |||||
| explicit OfflineSimulationService( | explicit OfflineSimulationService( | ||||
| VirtualRegisterRepository &repository, | VirtualRegisterRepository &repository, | ||||
| VirtualRegisterRepository *initial_repository = nullptr, | |||||
| QObject *parent = nullptr); | QObject *parent = nullptr); | ||||
| // 复制工程逻辑作为本次会话快照并启动定时扫描;启动失败时不进入运行态 | |||||
| SimulationStartResult start(const std::vector<ControlLogic> &logics); | SimulationStartResult start(const std::vector<ControlLogic> &logics); | ||||
| // 停止扫描并清除运行快照,但保留最近一次轨迹供 UI 显示 | |||||
| void stop(); | void stop(); | ||||
| // 立即执行一轮扫描;非 Running 状态调用会返回失败结果 | |||||
| LogicScanResult executeOnce(); | LogicScanResult executeOnce(); | ||||
| // 将当前会话的离线初始值恢复到运行仓库 | |||||
| void restoreInitialValues(); | |||||
| // 返回当前离线仿真生命周期状态 | |||||
| SimulationState state() const; | SimulationState state() const; | ||||
| // 返回当前定时器扫描周期,单位为毫秒 | |||||
| int scanIntervalMs() const; | int scanIntervalMs() const; | ||||
| // 返回本次会话成功完成的扫描轮数 | |||||
| std::uint64_t successfulScanCount() const; | std::uint64_t successfulScanCount() const; | ||||
| // 返回最近一次扫描错误;无错误时为成功结果 | |||||
| const LogicScanResult &lastError() const; | const LogicScanResult &lastError() const; | ||||
| // 返回最近一轮按逻辑 ID 隔离的运行轨迹 | |||||
| const LogicTraceSnapshot &traceSnapshot() const; | const LogicTraceSnapshot &traceSnapshot() const; | ||||
| signals: | signals: | ||||
| // 仿真状态从 Stopped、Running 或 Faulted 之一切换时发出 | |||||
| void stateChanged(); | void stateChanged(); | ||||
| // 一轮扫描成功完成并更新轨迹后发出 | |||||
| void scanCompleted(); | void scanCompleted(); | ||||
| private: | private: | ||||
| // 定时器到期时触发一轮扫描 | |||||
| void handleTimeout(); | void handleTimeout(); | ||||
| // 保存错误、停止定时器并切换到 Faulted | |||||
| void enterFault(const LogicScanResult &error); | void enterFault(const LogicScanResult &error); | ||||
| VirtualRegisterRepository &repository_; | |||||
| SoftwareLogicExecutor executor_; | |||||
| QTimer timer_; | |||||
| std::vector<ControlLogic> logic_snapshot_; | |||||
| SimulationState state_ = SimulationState::Stopped; | |||||
| std::uint64_t successful_scan_count_ = 0; | |||||
| LogicScanResult last_error_{true, LogicScanError::None, {}, {}, {}, {}}; | |||||
| LogicTraceSnapshot trace_snapshot_; | |||||
| VirtualRegisterRepository &repository_; // 不拥有的虚拟寄存器仓库 | |||||
| VirtualRegisterRepository *initial_repository_ = nullptr; // 不拥有的离线初始值仓库 | |||||
| SoftwareLogicExecutor executor_; // 执行逻辑校验和扫描的领域执行器 | |||||
| QTimer timer_; // 按固定周期触发离线扫描 | |||||
| std::vector<ControlLogic> logic_snapshot_; // 本次仿真使用的逻辑快照 | |||||
| SimulationState state_ = SimulationState::Stopped; // 当前仿真状态 | |||||
| std::uint64_t successful_scan_count_ = 0; // 当前会话成功扫描次数 | |||||
| LogicScanResult last_error_{true, LogicScanError::None, {}, {}, {}, {}}; // 最近一次错误 | |||||
| LogicTraceSnapshot trace_snapshot_; // 最近一次扫描的运行轨迹 | |||||
| }; | }; | ||||
| @@ -0,0 +1,209 @@ | |||||
| #include "online_logic_monitor_service.h" | |||||
| #include <algorithm> | |||||
| #include <utility> | |||||
| namespace { | |||||
| LogicScanResult success() | |||||
| { | |||||
| return {true, LogicScanError::None, {}, {}, {}, {}}; | |||||
| } | |||||
| LogicScanResult snapshotFailure(const RegisterAddress &address) | |||||
| { | |||||
| return { | |||||
| false, | |||||
| LogicScanError::RegisterReadFailed, | |||||
| "读取 PLC 缓存失败:" + address.toString(), | |||||
| {}, | |||||
| {}, | |||||
| {}}; | |||||
| } | |||||
| } // namespace | |||||
| OnlineLogicMonitorService::OnlineLogicMonitorService( | |||||
| RegisterRepository &plc_repository, | |||||
| QObject *parent) | |||||
| : QObject(parent), | |||||
| plc_repository_(plc_repository) | |||||
| { | |||||
| } | |||||
| OnlineLogicMonitorStartResult OnlineLogicMonitorService::start( | |||||
| const std::vector<ControlLogic> &logics) | |||||
| { | |||||
| if (state_ == OnlineLogicMonitorState::Running) | |||||
| { | |||||
| return {false, OnlineLogicMonitorStartError::AlreadyRunning, {}}; | |||||
| } | |||||
| std::vector<ControlLogic> snapshot = logics; | |||||
| const LogicScanResult validation = executor_.validate(snapshot); | |||||
| if (!validation.succeeded) | |||||
| { | |||||
| last_error_ = validation; | |||||
| state_ = OnlineLogicMonitorState::Faulted; | |||||
| emit stateChanged(); | |||||
| return {false, OnlineLogicMonitorStartError::InvalidLogic, validation}; | |||||
| } | |||||
| std::vector<RegisterAddress> addresses; | |||||
| for (const ControlLogic &logic : snapshot) | |||||
| { | |||||
| if (!logic.enabled) | |||||
| { | |||||
| continue; | |||||
| } | |||||
| 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, &addresses); | |||||
| } | |||||
| } | |||||
| } | |||||
| std::sort( | |||||
| addresses.begin(), addresses.end(), | |||||
| [](const RegisterAddress &left, const RegisterAddress &right) | |||||
| { | |||||
| return left.area() == right.area() | |||||
| ? left.index() < right.index() | |||||
| : left.area() == RegisterArea::M; | |||||
| }); | |||||
| addresses.erase(std::unique(addresses.begin(), addresses.end()), addresses.end()); | |||||
| executor_.resetRuntime(); | |||||
| working_repository_.clear(); | |||||
| logic_snapshot_ = std::move(snapshot); | |||||
| referenced_addresses_ = std::move(addresses); | |||||
| successful_scan_count_ = 0; | |||||
| last_error_ = success(); | |||||
| trace_snapshot_.clear(); | |||||
| state_ = OnlineLogicMonitorState::Running; | |||||
| emit stateChanged(); | |||||
| const LogicScanResult first_scan = executeOnce(); | |||||
| if (!first_scan.succeeded) | |||||
| { | |||||
| return { | |||||
| false, | |||||
| OnlineLogicMonitorStartError::PlcSnapshotUnavailable, | |||||
| first_scan}; | |||||
| } | |||||
| return {true, OnlineLogicMonitorStartError::None, success()}; | |||||
| } | |||||
| void OnlineLogicMonitorService::stop() | |||||
| { | |||||
| executor_.resetRuntime(); | |||||
| working_repository_.clear(); | |||||
| logic_snapshot_.clear(); | |||||
| referenced_addresses_.clear(); | |||||
| trace_snapshot_.clear(); | |||||
| successful_scan_count_ = 0; | |||||
| // 主动停止时保留最近一次故障,便于真机退出后继续查看诊断信息 | |||||
| if (state_ == OnlineLogicMonitorState::Stopped) | |||||
| { | |||||
| return; | |||||
| } | |||||
| state_ = OnlineLogicMonitorState::Stopped; | |||||
| emit stateChanged(); | |||||
| } | |||||
| LogicScanResult OnlineLogicMonitorService::executeOnce() | |||||
| { | |||||
| if (state_ != OnlineLogicMonitorState::Running) | |||||
| { | |||||
| return { | |||||
| false, | |||||
| LogicScanError::InvalidLogic, | |||||
| "真机本地轨迹尚未运行", | |||||
| {}, | |||||
| {}, | |||||
| {}}; | |||||
| } | |||||
| const LogicScanResult copied = copyPlcSnapshot(); | |||||
| if (!copied.succeeded) | |||||
| { | |||||
| enterFault(copied); | |||||
| return copied; | |||||
| } | |||||
| const LogicScanResult result = executor_.executeScan( | |||||
| logic_snapshot_, working_repository_, &trace_snapshot_); | |||||
| if (!result.succeeded) | |||||
| { | |||||
| enterFault(result); | |||||
| return result; | |||||
| } | |||||
| ++successful_scan_count_; | |||||
| emit scanCompleted(); | |||||
| return result; | |||||
| } | |||||
| OnlineLogicMonitorState OnlineLogicMonitorService::state() const | |||||
| { | |||||
| return state_; | |||||
| } | |||||
| std::uint64_t OnlineLogicMonitorService::successfulScanCount() const | |||||
| { | |||||
| return successful_scan_count_; | |||||
| } | |||||
| const LogicScanResult &OnlineLogicMonitorService::lastError() const | |||||
| { | |||||
| return last_error_; | |||||
| } | |||||
| const LogicTraceSnapshot &OnlineLogicMonitorService::traceSnapshot() const | |||||
| { | |||||
| return trace_snapshot_; | |||||
| } | |||||
| LogicScanResult OnlineLogicMonitorService::copyPlcSnapshot() | |||||
| { | |||||
| // 每轮从真实读回缓存重新开始,本地输出不会跨轮污染 PLC 数据 | |||||
| working_repository_.clear(); | |||||
| for (const RegisterAddress &address : referenced_addresses_) | |||||
| { | |||||
| if (address.area() == RegisterArea::M) | |||||
| { | |||||
| const BitReadResult read = plc_repository_.readBit(address); | |||||
| if (!read.succeeded) | |||||
| { | |||||
| return snapshotFailure(address); | |||||
| } | |||||
| working_repository_.writeBit(address, read.value); | |||||
| } | |||||
| else | |||||
| { | |||||
| const WordReadResult read = plc_repository_.readWord(address); | |||||
| if (!read.succeeded) | |||||
| { | |||||
| return snapshotFailure(address); | |||||
| } | |||||
| working_repository_.writeWord(address, read.value); | |||||
| } | |||||
| } | |||||
| return success(); | |||||
| } | |||||
| void OnlineLogicMonitorService::enterFault(const LogicScanResult &error) | |||||
| { | |||||
| executor_.resetRuntime(); | |||||
| last_error_ = error; | |||||
| state_ = OnlineLogicMonitorState::Faulted; | |||||
| emit stateChanged(); | |||||
| } | |||||
| @@ -0,0 +1,70 @@ | |||||
| #pragma once | |||||
| #include "domain/virtual_register_repository.h" | |||||
| #include "software_logic_executor.h" | |||||
| #include <QObject> | |||||
| #include <cstdint> | |||||
| #include <vector> | |||||
| enum class OnlineLogicMonitorState | |||||
| { | |||||
| Stopped, | |||||
| Running, | |||||
| Faulted | |||||
| }; | |||||
| enum class OnlineLogicMonitorStartError | |||||
| { | |||||
| None, | |||||
| AlreadyRunning, | |||||
| InvalidLogic, | |||||
| PlcSnapshotUnavailable | |||||
| }; | |||||
| struct OnlineLogicMonitorStartResult | |||||
| { | |||||
| bool succeeded = false; | |||||
| OnlineLogicMonitorStartError error = OnlineLogicMonitorStartError::None; | |||||
| LogicScanResult detail; | |||||
| }; | |||||
| // 使用 PLC 读回缓存推算本地梯形图轨迹,所有输出只写临时仓库 | |||||
| class OnlineLogicMonitorService final : public QObject | |||||
| { | |||||
| Q_OBJECT | |||||
| public: | |||||
| explicit OnlineLogicMonitorService( | |||||
| RegisterRepository &plc_repository, | |||||
| QObject *parent = nullptr); | |||||
| OnlineLogicMonitorStartResult start(const std::vector<ControlLogic> &logics); | |||||
| void stop(); | |||||
| LogicScanResult executeOnce(); | |||||
| OnlineLogicMonitorState state() const; | |||||
| std::uint64_t successfulScanCount() const; | |||||
| // 返回最近一次推算错误;主动停止不会清除故障详情,下一次 start 会重置 | |||||
| const LogicScanResult &lastError() const; | |||||
| const LogicTraceSnapshot &traceSnapshot() const; | |||||
| signals: | |||||
| void stateChanged(); | |||||
| void scanCompleted(); | |||||
| private: | |||||
| LogicScanResult copyPlcSnapshot(); | |||||
| void enterFault(const LogicScanResult &error); | |||||
| RegisterRepository &plc_repository_; | |||||
| VirtualRegisterRepository working_repository_; | |||||
| SoftwareLogicExecutor executor_; | |||||
| std::vector<ControlLogic> logic_snapshot_; | |||||
| std::vector<RegisterAddress> referenced_addresses_; | |||||
| OnlineLogicMonitorState state_ = OnlineLogicMonitorState::Stopped; | |||||
| std::uint64_t successful_scan_count_ = 0; | |||||
| LogicScanResult last_error_{true, LogicScanError::None, {}, {}, {}, {}}; | |||||
| LogicTraceSnapshot trace_snapshot_; | |||||
| }; | |||||
| @@ -9,50 +9,61 @@ | |||||
| #include <string> | #include <string> | ||||
| #include <vector> | #include <vector> | ||||
| // PLC 串口和轮询参数;parity 使用 Qt 约定值:0 无、2 偶、3 奇 | |||||
| struct PlcSerialConfiguration | struct PlcSerialConfiguration | ||||
| { | { | ||||
| std::string portName; | |||||
| int serverAddress = 1; | |||||
| int baudRate = 9600; | |||||
| int dataBits = 8; | |||||
| int parity = 2; | |||||
| int stopBits = 1; | |||||
| int responseTimeoutMs = 1000; | |||||
| int retries = 2; | |||||
| int pollIntervalMs = 200; | |||||
| std::string portName; // 串口名称,例如 COM3 | |||||
| int serverAddress = 1; // Modbus 从站地址,范围为 1~247 | |||||
| int baudRate = 9600; // 串口波特率,支持 9600、19200、38400、57600 和 115200 | |||||
| int dataBits = 8; // 数据位,支持 7 或 8 | |||||
| int parity = 2; // 校验方式,0 为无校验、2 为偶校验、3 为奇校验 | |||||
| int stopBits = 1; // 停止位,支持 1 或 2 | |||||
| int responseTimeoutMs = 1000; // 单次 Modbus 请求的响应超时时间,单位为毫秒 | |||||
| int retries = 2; // 请求失败后的重试次数,范围为 0~5 | |||||
| int pollIntervalMs = 200; // 轮询周期,单位为毫秒,范围为 50~10000 | |||||
| }; | }; | ||||
| // PLC 异步通信生命周期 | |||||
| enum class PlcConnectionState | enum class PlcConnectionState | ||||
| { | { | ||||
| Disconnected, | |||||
| Connecting, | |||||
| Connected, | |||||
| Recovering, | |||||
| Faulted | |||||
| Disconnected, // 未建立 PLC 连接 | |||||
| Connecting, // 正在异步建立 PLC 连接 | |||||
| Connected, // 串口已连接并进行正常轮询,不代表首读已完成 | |||||
| Recovering, // 从可恢复通信故障中探测恢复并重新进行首读 | |||||
| Faulted // 当前通信发生故障,等待恢复探测或重新连接 | |||||
| }; | }; | ||||
| // 通信故障分类,用于状态栏提示和真机运行资格撤销 | |||||
| enum class PlcCommunicationError | enum class PlcCommunicationError | ||||
| { | { | ||||
| None, | |||||
| SerialPortOpenFailed, | |||||
| PlcNotResponding, | |||||
| UsbSerialAdapterRemoved, | |||||
| SerialConnectionLost, | |||||
| CommunicationTimeout, | |||||
| ProtocolError, | |||||
| ReadFailed, | |||||
| WriteFailed, | |||||
| ConfigurationError, | |||||
| RequestAborted, | |||||
| Unknown | |||||
| None, // 没有待报告的通信错误 | |||||
| SerialPortOpenFailed, // 串口打开失败 | |||||
| PlcNotResponding, // PLC 未响应请求 | |||||
| UsbSerialAdapterRemoved, // USB 转串口设备被移除 | |||||
| SerialConnectionLost, // 已建立的串口连接意外中断 | |||||
| CommunicationTimeout, // 请求等待响应超时 | |||||
| ProtocolError, // 收到的 Modbus 数据不符合协议 | |||||
| ReadFailed, // 读取请求失败 | |||||
| WriteFailed, // 写入请求失败 | |||||
| ConfigurationError, // 串口或通信参数无效 | |||||
| RequestAborted, // 请求在完成前被中止 | |||||
| Unknown // 未分类的通信错误 | |||||
| }; | }; | ||||
| // 通信命令的受理结果;真正的读写完成由回调和缓存更新通知 | |||||
| struct PlcCommunicationResult | struct PlcCommunicationResult | ||||
| { | { | ||||
| bool succeeded = false; | |||||
| std::string message; | |||||
| bool succeeded = false; // 命令是否被接受或参数校验是否通过 | |||||
| std::string message; // 失败原因或补充说明,成功时通常为空 | |||||
| }; | }; | ||||
| /** | |||||
| * @brief 校验 PLC 串口、Modbus 和轮询参数 | |||||
| * | |||||
| * 此函数只检查配置,不打开串口、不启动通信,也不会修改传入对象 | |||||
| * @param configuration 待校验的连接配置 | |||||
| * @return 校验通过时返回 succeeded 为 true,否则返回 false 和 UTF-8 错误说明 | |||||
| */ | |||||
| inline PlcCommunicationResult validatePlcSerialConfiguration( | inline PlcCommunicationResult validatePlcSerialConfiguration( | ||||
| const PlcSerialConfiguration &configuration) | const PlcSerialConfiguration &configuration) | ||||
| { | { | ||||
| @@ -108,23 +119,81 @@ inline PlcCommunicationResult validatePlcSerialConfiguration( | |||||
| return {true, {}}; | return {true, {}}; | ||||
| } | } | ||||
| // UI/运行服务使用的异步 PLC 通信契约,具体实现位于 infrastructure | |||||
| class PlcCommunicationGateway | class PlcCommunicationGateway | ||||
| { | { | ||||
| public: | public: | ||||
| // 允许通过网关基类指针安全释放具体通信实现 | |||||
| virtual ~PlcCommunicationGateway() = default; | virtual ~PlcCommunicationGateway() = default; | ||||
| /** | |||||
| * @brief 校验串口配置并启动异步 PLC 连接 | |||||
| * @param configuration 本次连接使用的串口、Modbus 和轮询参数 | |||||
| * @return true 仅表示连接请求已受理,实际连接结果通过状态回调通知 | |||||
| */ | |||||
| virtual PlcCommunicationResult connectDevice( | virtual PlcCommunicationResult connectDevice( | ||||
| const PlcSerialConfiguration &configuration) = 0; | const PlcSerialConfiguration &configuration) = 0; | ||||
| /** | |||||
| * @brief 停止轮询并断开当前 PLC 连接 | |||||
| * | |||||
| * 断开后当前缓存视为无效,首读资格同时清除 | |||||
| */ | |||||
| virtual void disconnectDevice() = 0; | virtual void disconnectDevice() = 0; | ||||
| /** | |||||
| * @brief 设置后续轮询的 M/D 地址集合 | |||||
| * | |||||
| * 实现可以校验、排序、去重并合并相邻地址块;已有读请求进行时, | |||||
| * 新集合可以延后到当前请求完成后生效 | |||||
| * @param addresses 需要周期性读回的 M/D 地址集合,空集合使用实现的默认探测地址 | |||||
| * @return true 表示集合已接受,false 表示地址或轮询资源校验失败 | |||||
| */ | |||||
| virtual PlcCommunicationResult setPollAddresses( | virtual PlcCommunicationResult setPollAddresses( | ||||
| const std::vector<RegisterAddress> &addresses) = 0; | const std::vector<RegisterAddress> &addresses) = 0; | ||||
| /** | |||||
| * @brief 设置轮询地址并标记必须成对读取的 Float32 起始地址 | |||||
| * @param addresses 需要周期性读回的 M/D 地址集合 | |||||
| * @param float32_starts D 区 Float32 起始地址;每个地址必须和下一个 D 一起读取 | |||||
| * @return true 表示集合已接受 | |||||
| * | |||||
| * 默认实现兼容只关心地址集合的测试网关和旧调用方;真实 Modbus 实现会使用成对信息避免拆分 Float32 | |||||
| */ | |||||
| virtual PlcCommunicationResult setPollAddresses( | |||||
| const std::vector<RegisterAddress> &addresses, | |||||
| const std::vector<RegisterAddress> &float32_starts) | |||||
| { | |||||
| (void)float32_starts; | |||||
| return setPollAddresses(addresses); | |||||
| } | |||||
| // 返回当前连接生命周期状态;Connected 不代表首读资格已经完成 | |||||
| virtual PlcConnectionState state() const = 0; | virtual PlcConnectionState state() const = 0; | ||||
| // 返回本次连接是否已完成全部轮询块的成功首读 | |||||
| virtual bool initialReadCompleted() const = 0; | virtual bool initialReadCompleted() const = 0; | ||||
| // 返回最近一次通信错误的分类;没有错误或错误已清除时返回 None | |||||
| virtual PlcCommunicationError lastErrorType() const = 0; | virtual PlcCommunicationError lastErrorType() const = 0; | ||||
| // 返回最近一次通信错误的 UTF-8 可读文本;没有错误时返回空字符串 | |||||
| virtual const std::string &lastError() const = 0; | virtual const std::string &lastError() const = 0; | ||||
| /** | |||||
| * @brief 替换异步通信事件回调 | |||||
| * | |||||
| * 传入空 std::function 可取消对应通知;回调由实现在线程或事件循环中按事件发生时调用 | |||||
| * @param state_changed 连接状态发生变化时调用 | |||||
| * @param initial_read_changed 首读资格发生变化时调用,参数表示当前是否已完成首读 | |||||
| * @param cache_updated 任一轮询块成功更新 PLC 缓存后调用 | |||||
| * @param poll_cycle_completed 全部轮询块成功更新一轮后调用 | |||||
| * @param error_reported 发生通信错误时调用,参数为 UTF-8 可读错误文本 | |||||
| */ | |||||
| virtual void setCallbacks( | virtual void setCallbacks( | ||||
| std::function<void()> state_changed, | std::function<void()> state_changed, | ||||
| std::function<void(bool)> initial_read_changed, | std::function<void(bool)> initial_read_changed, | ||||
| std::function<void()> cache_updated, | std::function<void()> cache_updated, | ||||
| std::function<void()> poll_cycle_completed, | |||||
| std::function<void(const std::string &)> error_reported) = 0; | std::function<void(const std::string &)> error_reported) = 0; | ||||
| }; | }; | ||||
| @@ -0,0 +1,126 @@ | |||||
| #include "plc_discovery_gateway.h" | |||||
| #include <algorithm> | |||||
| #include <array> | |||||
| #include <tuple> | |||||
| #include <utility> | |||||
| namespace { | |||||
| struct SerialFrame | |||||
| { | |||||
| int dataBits = 8; | |||||
| int parity = 2; | |||||
| int stopBits = 1; | |||||
| }; | |||||
| bool isSupportedBaudRate(int baud_rate) | |||||
| { | |||||
| return baud_rate == 9600 | |||||
| || baud_rate == 19200 | |||||
| || baud_rate == 38400 | |||||
| || baud_rate == 57600 | |||||
| || baud_rate == 115200; | |||||
| } | |||||
| bool isSupportedFrame(const SerialFrame &frame) | |||||
| { | |||||
| return (frame.dataBits == 7 || frame.dataBits == 8) | |||||
| && (frame.parity == 0 || frame.parity == 2 || frame.parity == 3) | |||||
| && (frame.stopBits == 1 || frame.stopBits == 2); | |||||
| } | |||||
| bool sameFrame(const SerialFrame &left, const SerialFrame &right) | |||||
| { | |||||
| return std::tie(left.dataBits, left.parity, left.stopBits) | |||||
| == std::tie(right.dataBits, right.parity, right.stopBits); | |||||
| } | |||||
| template<typename Value, typename Equals> | |||||
| void appendUnique(std::vector<Value> *values, Value value, Equals equals) | |||||
| { | |||||
| if (std::none_of( | |||||
| values->cbegin(), values->cend(), | |||||
| [&value, &equals](const Value &existing) { return equals(existing, value); })) | |||||
| { | |||||
| values->push_back(std::move(value)); | |||||
| } | |||||
| } | |||||
| } // namespace | |||||
| std::vector<PlcSerialConfiguration> buildPlcDiscoveryCandidates( | |||||
| const PlcSerialConfiguration &preferred, | |||||
| const std::vector<std::string> &available_ports) | |||||
| { | |||||
| std::vector<std::string> ports; | |||||
| ports.reserve(available_ports.size()); | |||||
| for (const std::string &port : available_ports) | |||||
| { | |||||
| if (!port.empty()) | |||||
| { | |||||
| appendUnique( | |||||
| &ports, port, | |||||
| [](const std::string &left, const std::string &right) { | |||||
| return left == right; | |||||
| }); | |||||
| } | |||||
| } | |||||
| const auto preferred_port = std::find( | |||||
| ports.begin(), ports.end(), preferred.portName); | |||||
| if (preferred_port != ports.end()) | |||||
| { | |||||
| std::rotate(ports.begin(), preferred_port, preferred_port + 1); | |||||
| } | |||||
| std::vector<int> baud_rates; | |||||
| if (isSupportedBaudRate(preferred.baudRate)) | |||||
| { | |||||
| baud_rates.push_back(preferred.baudRate); | |||||
| } | |||||
| constexpr std::array<int, 5> kSupportedBaudRates{ | |||||
| 9600, 19200, 38400, 57600, 115200}; | |||||
| for (int baud_rate : kSupportedBaudRates) | |||||
| { | |||||
| appendUnique( | |||||
| &baud_rates, baud_rate, | |||||
| [](int left, int right) { return left == right; }); | |||||
| } | |||||
| std::vector<SerialFrame> frames; | |||||
| const SerialFrame preferred_frame{ | |||||
| preferred.dataBits, preferred.parity, preferred.stopBits}; | |||||
| if (isSupportedFrame(preferred_frame)) | |||||
| { | |||||
| frames.push_back(preferred_frame); | |||||
| } | |||||
| constexpr std::array<SerialFrame, 12> kSupportedFrames{{ | |||||
| {8, 2, 1}, {8, 0, 1}, {8, 3, 1}, | |||||
| {8, 0, 2}, {8, 2, 2}, {8, 3, 2}, | |||||
| {7, 2, 1}, {7, 3, 1}, {7, 0, 1}, | |||||
| {7, 0, 2}, {7, 2, 2}, {7, 3, 2}}}; | |||||
| for (const SerialFrame &frame : kSupportedFrames) | |||||
| { | |||||
| appendUnique(&frames, frame, sameFrame); | |||||
| } | |||||
| std::vector<PlcSerialConfiguration> candidates; | |||||
| candidates.reserve(ports.size() * baud_rates.size() * frames.size()); | |||||
| for (const SerialFrame &frame : frames) | |||||
| { | |||||
| for (int baud_rate : baud_rates) | |||||
| { | |||||
| for (const std::string &port : ports) | |||||
| { | |||||
| PlcSerialConfiguration candidate = preferred; | |||||
| candidate.portName = port; | |||||
| candidate.baudRate = baud_rate; | |||||
| candidate.dataBits = frame.dataBits; | |||||
| candidate.parity = frame.parity; | |||||
| candidate.stopBits = frame.stopBits; | |||||
| candidates.push_back(std::move(candidate)); | |||||
| } | |||||
| } | |||||
| } | |||||
| return candidates; | |||||
| } | |||||
| @@ -0,0 +1,53 @@ | |||||
| #pragma once | |||||
| #include "plc_communication_gateway.h" | |||||
| #include <cstddef> | |||||
| #include <functional> | |||||
| #include <string> | |||||
| #include <vector> | |||||
| // 自动搜索正在尝试的串口参数和总体进度 | |||||
| struct PlcDiscoveryProgress | |||||
| { | |||||
| PlcSerialConfiguration configuration; | |||||
| std::size_t currentAttempt = 0; | |||||
| std::size_t totalAttempts = 0; | |||||
| }; | |||||
| // 自动搜索结束结果;found 和 cancelled 不会同时为 true | |||||
| struct PlcDiscoveryOutcome | |||||
| { | |||||
| bool found = false; | |||||
| bool cancelled = false; | |||||
| PlcSerialConfiguration configuration; | |||||
| std::string message; | |||||
| }; | |||||
| /** | |||||
| * 按优先级生成搜索组合 | |||||
| * | |||||
| * 站号和通信时序沿用界面当前值,只遍历可用端口及项目支持的串口参数 | |||||
| */ | |||||
| std::vector<PlcSerialConfiguration> buildPlcDiscoveryCandidates( | |||||
| const PlcSerialConfiguration &preferred, | |||||
| const std::vector<std::string> &available_ports); | |||||
| // UI 使用的异步 PLC 自动搜索契约,具体串口实现位于 infrastructure | |||||
| class PlcDiscoveryGateway | |||||
| { | |||||
| public: | |||||
| virtual ~PlcDiscoveryGateway() = default; | |||||
| // 开始搜索,实际进度和结果通过回调通知 | |||||
| virtual PlcCommunicationResult startDiscovery( | |||||
| const PlcSerialConfiguration &preferred) = 0; | |||||
| // 取消当前搜索;串口释放后通过结束回调通知 | |||||
| virtual void cancelDiscovery() = 0; | |||||
| // 返回当前是否正在搜索或等待释放搜索串口 | |||||
| virtual bool isDiscovering() const = 0; | |||||
| // 替换进度和结束回调,传入空函数可取消通知 | |||||
| virtual void setCallbacks( | |||||
| std::function<void(const PlcDiscoveryProgress &)> progress_changed, | |||||
| std::function<void(const PlcDiscoveryOutcome &)> discovery_finished) = 0; | |||||
| }; | |||||
| @@ -25,7 +25,15 @@ std::string generateProjectId() | |||||
| } // namespace | } // namespace | ||||
| ProjectService::ProjectService(ProjectStorage &storage) | ProjectService::ProjectService(ProjectStorage &storage) | ||||
| : ProjectService(storage, defaultProjectLimitSettings()) | |||||
| { | |||||
| } | |||||
| ProjectService::ProjectService( | |||||
| ProjectStorage &storage, | |||||
| const ProjectLimitSettings &project_limits) | |||||
| : storage_(storage), | : storage_(storage), | ||||
| project_limits_(project_limits), | |||||
| project_(makeNewProject("未命名工程")) | project_(makeNewProject("未命名工程")) | ||||
| { | { | ||||
| } | } | ||||
| @@ -57,6 +65,11 @@ bool ProjectService::isModified() const | |||||
| return modified_; | return modified_; | ||||
| } | } | ||||
| const ProjectLimitSettings &ProjectService::projectLimits() const | |||||
| { | |||||
| return project_limits_; | |||||
| } | |||||
| void ProjectService::restoreModifiedState(bool modified) | void ProjectService::restoreModifiedState(bool modified) | ||||
| { | { | ||||
| modified_ = modified; | modified_ = modified; | ||||
| @@ -103,7 +116,7 @@ ProjectOperationResult ProjectService::saveAs(const std::string &file_path) | |||||
| } | } | ||||
| std::string validation_error; | std::string validation_error; | ||||
| if (!project_.validate(&validation_error)) | |||||
| if (!project_.validate(project_limits_, &validation_error)) | |||||
| { | { | ||||
| return {false, | return {false, | ||||
| ProjectServiceError::InvalidProject, | ProjectServiceError::InvalidProject, | ||||
| @@ -124,6 +137,31 @@ ProjectOperationResult ProjectService::saveAs(const std::string &file_path) | |||||
| return {true, ProjectServiceError::None, ProjectStorageError::None, {}}; | return {true, ProjectServiceError::None, ProjectStorageError::None, {}}; | ||||
| } | } | ||||
| ProjectOperationResult ProjectService::exportAs(const std::string &file_path) const | |||||
| { | |||||
| if (isBlank(file_path)) | |||||
| { | |||||
| return {false, | |||||
| ProjectServiceError::FilePathRequired, | |||||
| ProjectStorageError::None, | |||||
| "必须指定导出文件路径"}; | |||||
| } | |||||
| std::string validation_error; | |||||
| if (!project_.validate(project_limits_, &validation_error)) | |||||
| { | |||||
| return {false, | |||||
| ProjectServiceError::InvalidProject, | |||||
| ProjectStorageError::InvalidProject, | |||||
| validation_error}; | |||||
| } | |||||
| const ProjectSaveResult result = storage_.save(project_, file_path); | |||||
| if (!result.succeeded) | |||||
| { | |||||
| return storageFailure(result.error, result.message); | |||||
| } | |||||
| return {true, ProjectServiceError::None, ProjectStorageError::None, {}}; | |||||
| } | |||||
| ProjectOperationResult ProjectService::load(const std::string &file_path) | ProjectOperationResult ProjectService::load(const std::string &file_path) | ||||
| { | { | ||||
| if (isBlank(file_path)) | if (isBlank(file_path)) | ||||
| @@ -134,6 +172,7 @@ ProjectOperationResult ProjectService::load(const std::string &file_path) | |||||
| "必须指定工程文件路径"}; | "必须指定工程文件路径"}; | ||||
| } | } | ||||
| // 存储层先在临时结果中解析,避免半个工程替换当前工程 | |||||
| ProjectLoadResult result = storage_.load(file_path); | ProjectLoadResult result = storage_.load(file_path); | ||||
| if (!result.succeeded) | if (!result.succeeded) | ||||
| { | { | ||||
| @@ -141,7 +180,7 @@ ProjectOperationResult ProjectService::load(const std::string &file_path) | |||||
| } | } | ||||
| std::string validation_error; | std::string validation_error; | ||||
| if (!result.project.validate(&validation_error)) | |||||
| if (!result.project.validate(project_limits_, &validation_error)) | |||||
| { | { | ||||
| return {false, | return {false, | ||||
| ProjectServiceError::InvalidProject, | ProjectServiceError::InvalidProject, | ||||
| @@ -14,7 +14,7 @@ enum class ProjectServiceError | |||||
| StorageFailure // 工程文件存储操作失败 | StorageFailure // 工程文件存储操作失败 | ||||
| }; | }; | ||||
| // 工程管理操作结果 | |||||
| // 工程管理操作结果;失败时当前工程和文件路径保持原状 | |||||
| struct ProjectOperationResult | struct ProjectOperationResult | ||||
| { | { | ||||
| bool succeeded = false; // 操作是否成功 | bool succeeded = false; // 操作是否成功 | ||||
| @@ -29,12 +29,18 @@ class ProjectService | |||||
| public: | public: | ||||
| // 使用外部提供的工程存储实现创建服务 | // 使用外部提供的工程存储实现创建服务 | ||||
| explicit ProjectService(ProjectStorage &storage); | explicit ProjectService(ProjectStorage &storage); | ||||
| ProjectService( | |||||
| ProjectStorage &storage, | |||||
| const ProjectLimitSettings &project_limits); | |||||
| // 只读访问当前工程;UI 和其他服务不应绕过 editProject 修改它 | |||||
| const Project &project() const; | const Project &project() const; | ||||
| // 获取服务层授权的可编辑引用,调用方必须随后完成校验 | |||||
| Project &editProject(); | Project &editProject(); | ||||
| const std::string ¤tFilePath() const; | const std::string ¤tFilePath() const; | ||||
| bool hasCurrentFile() const; | bool hasCurrentFile() const; | ||||
| bool isModified() const; | bool isModified() const; | ||||
| const ProjectLimitSettings &projectLimits() const; | |||||
| // 失败编辑回滚时恢复进入编辑前的脏状态 | // 失败编辑回滚时恢复进入编辑前的脏状态 | ||||
| void restoreModifiedState(bool modified); | void restoreModifiedState(bool modified); | ||||
| @@ -57,6 +63,8 @@ public: | |||||
| * @return 保存操作结果 | * @return 保存操作结果 | ||||
| */ | */ | ||||
| ProjectOperationResult saveAs(const std::string &file_path); | ProjectOperationResult saveAs(const std::string &file_path); | ||||
| /** 将当前工程导出到指定 JSON,不改变当前工程文件关联 */ | |||||
| ProjectOperationResult exportAs(const std::string &file_path) const; | |||||
| /** | /** | ||||
| * @brief 从指定文件加载工程 | * @brief 从指定文件加载工程 | ||||
| @@ -72,6 +80,7 @@ private: | |||||
| ProjectStorageError error, const std::string &message); | ProjectStorageError error, const std::string &message); | ||||
| ProjectStorage &storage_; // 非拥有的工程存储依赖 | ProjectStorage &storage_; // 非拥有的工程存储依赖 | ||||
| const ProjectLimitSettings &project_limits_; // 应用启动时确定的只读数量上限 | |||||
| Project project_; // 当前编辑中的工程 | Project project_; // 当前编辑中的工程 | ||||
| std::string current_file_path_; // 当前工程对应的文件路径 | std::string current_file_path_; // 当前工程对应的文件路径 | ||||
| bool modified_ = true; // 当前工程是否有未保存修改 | bool modified_ = true; // 当前工程是否有未保存修改 | ||||
| @@ -7,6 +7,7 @@ | |||||
| class ProjectService; | class ProjectService; | ||||
| // 工程级寄存器注释操作的失败分类 | |||||
| enum class RegisterCommentError | enum class RegisterCommentError | ||||
| { | { | ||||
| None, | None, | ||||
| @@ -14,6 +15,7 @@ enum class RegisterCommentError | |||||
| NotFound | NotFound | ||||
| }; | }; | ||||
| // 注释编辑结果 | |||||
| struct RegisterCommentResult | struct RegisterCommentResult | ||||
| { | { | ||||
| bool succeeded = false; | bool succeeded = false; | ||||
| @@ -21,15 +23,19 @@ struct RegisterCommentResult | |||||
| std::string message; | std::string message; | ||||
| }; | }; | ||||
| // 维护工程里的 M/D 注释,不读取也不修改寄存器实时值 | |||||
| class RegisterCommentService | class RegisterCommentService | ||||
| { | { | ||||
| public: | public: | ||||
| explicit RegisterCommentService(ProjectService &project_service); | explicit RegisterCommentService(ProjectService &project_service); | ||||
| const std::vector<RegisterComment> &comments() const; | const std::vector<RegisterComment> &comments() const; | ||||
| // 按地址查找注释 | |||||
| const RegisterComment *findComment(const RegisterAddress &address) const; | const RegisterComment *findComment(const RegisterAddress &address) const; | ||||
| // 新建或更新注释;空文本通常表示删除该注释 | |||||
| RegisterCommentResult setComment( | RegisterCommentResult setComment( | ||||
| const RegisterAddress &address, const std::string &text); | const RegisterAddress &address, const std::string &text); | ||||
| // 删除指定地址注释 | |||||
| RegisterCommentResult removeComment(const RegisterAddress &address); | RegisterCommentResult removeComment(const RegisterAddress &address); | ||||
| private: | private: | ||||
| @@ -1,6 +1,10 @@ | |||||
| #include "register_monitor_service.h" | #include "register_monitor_service.h" | ||||
| #include "domain/virtual_register_repository.h" | |||||
| #include <algorithm> | #include <algorithm> | ||||
| #include <cmath> | |||||
| #include <optional> | |||||
| #include <utility> | #include <utility> | ||||
| namespace { | namespace { | ||||
| @@ -29,8 +33,25 @@ RegisterMonitorService::RegisterMonitorService(RegisterRepository &repository) | |||||
| { | { | ||||
| } | } | ||||
| void RegisterMonitorService::setOfflineInitialRepository( | |||||
| VirtualRegisterRepository &repository) | |||||
| { | |||||
| offline_initial_repository_ = &repository; | |||||
| } | |||||
| void RegisterMonitorService::setOfflineInitialCaptureEnabled(bool enabled) | |||||
| { | |||||
| capture_offline_initial_values_ = enabled; | |||||
| } | |||||
| RegisterMonitorResult RegisterMonitorService::addRange( | RegisterMonitorResult RegisterMonitorService::addRange( | ||||
| const std::string &start_address, int count) | const std::string &start_address, int count) | ||||
| { | |||||
| return addRange(start_address, count, RegisterDataType::Int16); | |||||
| } | |||||
| RegisterMonitorResult RegisterMonitorService::addRange( | |||||
| const std::string &start_address, int count, RegisterDataType data_type) | |||||
| { | { | ||||
| const RegisterAddressParseResult parsed = parseRegisterAddress(start_address); | const RegisterAddressParseResult parsed = parseRegisterAddress(start_address); | ||||
| if (!parsed.succeeded) | if (!parsed.succeeded) | ||||
| @@ -38,8 +59,15 @@ RegisterMonitorResult RegisterMonitorService::addRange( | |||||
| return {false, RegisterMonitorError::InvalidAddress, | return {false, RegisterMonitorError::InvalidAddress, | ||||
| parseErrorMessage(parsed.error), 0}; | parseErrorMessage(parsed.error), 0}; | ||||
| } | } | ||||
| if (parsed.address.area() == RegisterArea::M | |||||
| && data_type == RegisterDataType::Float32) | |||||
| { | |||||
| return {false, RegisterMonitorError::InvalidAddress, | |||||
| "Float32 只允许使用 D 区地址", 0}; | |||||
| } | |||||
| const int step = registerDataTypeWordCount(data_type); | |||||
| if (count < 1 | if (count < 1 | ||||
| || parsed.address.index() > RegisterAddress::kMaximumIndex - (count - 1)) | |||||
| || parsed.address.index() > RegisterAddress::kMaximumIndex - step * count + 1) | |||||
| { | { | ||||
| return {false, RegisterMonitorError::RangeOverflow, | return {false, RegisterMonitorError::RangeOverflow, | ||||
| "连续监控范围不能超过地址 4000", 0}; | "连续监控范围不能超过地址 4000", 0}; | ||||
| @@ -49,10 +77,13 @@ RegisterMonitorResult RegisterMonitorService::addRange( | |||||
| pending.reserve(static_cast<std::size_t>(count)); | pending.reserve(static_cast<std::size_t>(count)); | ||||
| for (int offset = 0; offset < count; ++offset) | for (int offset = 0; offset < count; ++offset) | ||||
| { | { | ||||
| RegisterAddress address{parsed.address.area(), parsed.address.index() + offset}; | |||||
| if (!model_.contains(address)) | |||||
| MonitorPoint point{ | |||||
| RegisterAddress{ | |||||
| parsed.address.area(), parsed.address.index() + offset * step}, | |||||
| data_type}; | |||||
| if (!model_.contains(point)) | |||||
| { | { | ||||
| pending.push_back(address); | |||||
| pending.push_back(point.address); | |||||
| } | } | ||||
| } | } | ||||
| if (pending.empty()) | if (pending.empty()) | ||||
| @@ -66,13 +97,32 @@ RegisterMonitorResult RegisterMonitorService::addRange( | |||||
| } | } | ||||
| for (const RegisterAddress &address : pending) | for (const RegisterAddress &address : pending) | ||||
| { | { | ||||
| model_.add(address); | |||||
| model_.add(MonitorPoint{address, data_type}); | |||||
| } | } | ||||
| if (addresses_changed_callback_) | if (addresses_changed_callback_) | ||||
| { | { | ||||
| addresses_changed_callback_(); | addresses_changed_callback_(); | ||||
| } | } | ||||
| return {true, RegisterMonitorError::None, {}, static_cast<int>(pending.size())}; | |||||
| bool overlaps = false; | |||||
| for (const MonitorPoint &existing : model_.points()) | |||||
| { | |||||
| const int existing_end = existing.address.index() | |||||
| + registerDataTypeWordCount(existing.dataType) - 1; | |||||
| for (const RegisterAddress &address : pending) | |||||
| { | |||||
| const int pending_end = address.index() + step - 1; | |||||
| if (existing.address.area() == address.area() | |||||
| && existing.address.index() <= pending_end | |||||
| && address.index() <= existing_end | |||||
| && !(existing.address == address && existing.dataType == data_type)) | |||||
| { | |||||
| overlaps = true; | |||||
| } | |||||
| } | |||||
| } | |||||
| return {true, RegisterMonitorError::None, | |||||
| overlaps ? "已添加,但与已有监控点的 D 地址范围重叠" : std::string{}, | |||||
| static_cast<int>(pending.size())}; | |||||
| } | } | ||||
| RegisterMonitorResult RegisterMonitorService::remove( | RegisterMonitorResult RegisterMonitorService::remove( | ||||
| @@ -97,6 +147,28 @@ RegisterMonitorResult RegisterMonitorService::remove( | |||||
| return {true, RegisterMonitorError::None, {}, removed_count}; | return {true, RegisterMonitorError::None, {}, removed_count}; | ||||
| } | } | ||||
| RegisterMonitorResult RegisterMonitorService::remove( | |||||
| const std::vector<MonitorPoint> &points) | |||||
| { | |||||
| int removed_count = 0; | |||||
| for (const MonitorPoint &point : points) | |||||
| { | |||||
| if (model_.remove(point)) | |||||
| { | |||||
| ++removed_count; | |||||
| } | |||||
| } | |||||
| if (removed_count == 0) | |||||
| { | |||||
| return {false, RegisterMonitorError::NoChange, "请先选择要删除的监控点", 0}; | |||||
| } | |||||
| if (addresses_changed_callback_) | |||||
| { | |||||
| addresses_changed_callback_(); | |||||
| } | |||||
| return {true, RegisterMonitorError::None, {}, removed_count}; | |||||
| } | |||||
| RegisterMonitorResult RegisterMonitorService::clear() | RegisterMonitorResult RegisterMonitorService::clear() | ||||
| { | { | ||||
| if (model_.addresses().empty()) | if (model_.addresses().empty()) | ||||
| @@ -116,34 +188,90 @@ RegisterMonitorWriteResult RegisterMonitorService::writeBit( | |||||
| const RegisterAddress &address, bool value) | const RegisterAddress &address, bool value) | ||||
| { | { | ||||
| const RegisterWriteResult result = repository_.writeBit(address, value); | const RegisterWriteResult result = repository_.writeBit(address, value); | ||||
| if (result.succeeded) | |||||
| { | |||||
| return {true, RegisterError::None, {}}; | |||||
| } | |||||
| return {false, result.error, result.error == RegisterError::AreaMismatch | |||||
| const RegisterMonitorWriteResult active_result = result.succeeded | |||||
| ? RegisterMonitorWriteResult{true, RegisterError::None, {}} | |||||
| : RegisterMonitorWriteResult{false, result.error, result.error == RegisterError::AreaMismatch | |||||
| ? "位写入只允许访问 M 区" | ? "位写入只允许访问 M 区" | ||||
| : result.error == RegisterError::InvalidAddress | : result.error == RegisterError::InvalidAddress | ||||
| ? "写入地址无效" | ? "写入地址无效" | ||||
| : result.error == RegisterError::Unavailable | : result.error == RegisterError::Unavailable | ||||
| ? "当前寄存器源不可用" | ? "当前寄存器源不可用" | ||||
| : "寄存器写入请求被拒绝"}; | : "寄存器写入请求被拒绝"}; | ||||
| return captureInitialBit(address, value, active_result); | |||||
| } | } | ||||
| RegisterMonitorWriteResult RegisterMonitorService::writeWord( | RegisterMonitorWriteResult RegisterMonitorService::writeWord( | ||||
| const RegisterAddress &address, std::int16_t value) | const RegisterAddress &address, std::int16_t value) | ||||
| { | { | ||||
| const RegisterWriteResult result = repository_.writeWord(address, value); | const RegisterWriteResult result = repository_.writeWord(address, value); | ||||
| if (result.succeeded) | |||||
| { | |||||
| return {true, RegisterError::None, {}}; | |||||
| } | |||||
| return {false, result.error, result.error == RegisterError::AreaMismatch | |||||
| const RegisterMonitorWriteResult active_result = result.succeeded | |||||
| ? RegisterMonitorWriteResult{true, RegisterError::None, {}} | |||||
| : RegisterMonitorWriteResult{false, result.error, result.error == RegisterError::AreaMismatch | |||||
| ? "字写入只允许访问 D 区" | ? "字写入只允许访问 D 区" | ||||
| : result.error == RegisterError::InvalidAddress | : result.error == RegisterError::InvalidAddress | ||||
| ? "写入地址无效" | ? "写入地址无效" | ||||
| : result.error == RegisterError::Unavailable | : result.error == RegisterError::Unavailable | ||||
| ? "当前寄存器源不可用" | ? "当前寄存器源不可用" | ||||
| : "寄存器写入请求被拒绝"}; | : "寄存器写入请求被拒绝"}; | ||||
| return captureInitialWord(address, value, active_result); | |||||
| } | |||||
| RegisterMonitorWriteResult RegisterMonitorService::writeFloat( | |||||
| const RegisterAddress &address, float value) | |||||
| { | |||||
| if (!std::isfinite(value) || !address.isValid() | |||||
| || address.area() != RegisterArea::D | |||||
| || address.index() >= RegisterAddress::kMaximumIndex) | |||||
| { | |||||
| return {false, RegisterError::InvalidAddress, "Float32 写入值或地址无效"}; | |||||
| } | |||||
| const RegisterWriteResult result = repository_.writeWordPair( | |||||
| address, Float32Codec::encode(value)); | |||||
| const RegisterMonitorWriteResult active_result = result.succeeded | |||||
| ? RegisterMonitorWriteResult{true, RegisterError::None, {}} | |||||
| : RegisterMonitorWriteResult{false, result.error, result.error == RegisterError::Unavailable | |||||
| ? "当前寄存器源不可用" : "寄存器写入请求被拒绝"}; | |||||
| return captureInitialFloat(address, value, active_result); | |||||
| } | |||||
| RegisterMonitorWriteResult RegisterMonitorService::captureInitialBit( | |||||
| const RegisterAddress &address, bool value, | |||||
| const RegisterMonitorWriteResult &active_result) | |||||
| { | |||||
| if (!active_result.succeeded || !capture_offline_initial_values_ | |||||
| || offline_initial_repository_ == nullptr) | |||||
| { | |||||
| return active_result; | |||||
| } | |||||
| offline_initial_repository_->writeBit(address, value); | |||||
| return active_result; | |||||
| } | |||||
| RegisterMonitorWriteResult RegisterMonitorService::captureInitialWord( | |||||
| const RegisterAddress &address, std::int16_t value, | |||||
| const RegisterMonitorWriteResult &active_result) | |||||
| { | |||||
| if (!active_result.succeeded || !capture_offline_initial_values_ | |||||
| || offline_initial_repository_ == nullptr) | |||||
| { | |||||
| return active_result; | |||||
| } | |||||
| offline_initial_repository_->writeWord(address, value); | |||||
| return active_result; | |||||
| } | |||||
| RegisterMonitorWriteResult RegisterMonitorService::captureInitialFloat( | |||||
| const RegisterAddress &address, float value, | |||||
| const RegisterMonitorWriteResult &active_result) | |||||
| { | |||||
| if (!active_result.succeeded || !capture_offline_initial_values_ | |||||
| || offline_initial_repository_ == nullptr) | |||||
| { | |||||
| return active_result; | |||||
| } | |||||
| offline_initial_repository_->writeWordPair( | |||||
| address, Float32Codec::encode(value)); | |||||
| return active_result; | |||||
| } | } | ||||
| const std::vector<RegisterAddress> &RegisterMonitorService::addresses() const | const std::vector<RegisterAddress> &RegisterMonitorService::addresses() const | ||||
| @@ -154,11 +282,15 @@ const std::vector<RegisterAddress> &RegisterMonitorService::addresses() const | |||||
| std::vector<MonitorValue> RegisterMonitorService::values(bool communication_fault) const | std::vector<MonitorValue> RegisterMonitorService::values(bool communication_fault) const | ||||
| { | { | ||||
| std::vector<MonitorValue> result; | std::vector<MonitorValue> result; | ||||
| result.reserve(model_.addresses().size()); | |||||
| for (const RegisterAddress &address : model_.addresses()) | |||||
| result.reserve(model_.points().size()); | |||||
| for (const MonitorPoint &point : model_.points()) | |||||
| { | { | ||||
| const RegisterAddress &address = point.address; | |||||
| MonitorValue value; | MonitorValue value; | ||||
| value.address = address; | value.address = address; | ||||
| value.dataType = point.dataType; | |||||
| value.endAddress = address.index() | |||||
| + registerDataTypeWordCount(point.dataType) - 1; | |||||
| if (address.area() == RegisterArea::M) | if (address.area() == RegisterArea::M) | ||||
| { | { | ||||
| const BitReadResult read = repository_.readBit(address); | const BitReadResult read = repository_.readBit(address); | ||||
| @@ -170,18 +302,65 @@ std::vector<MonitorValue> RegisterMonitorService::values(bool communication_faul | |||||
| } | } | ||||
| else | else | ||||
| { | { | ||||
| const WordReadResult read = repository_.readWord(address); | |||||
| value.wordValue = read.value; | |||||
| value.state = read.succeeded | |||||
| if (point.dataType == RegisterDataType::Float32) | |||||
| { | |||||
| const WordPairReadResult read = repository_.readWordPair(address); | |||||
| const std::optional<float> decoded = read.succeeded | |||||
| ? Float32Codec::decode(read.values[0], read.values[1]) | |||||
| : std::nullopt; | |||||
| value.floatValue = decoded.value_or(0.0f); | |||||
| value.state = decoded.has_value() | |||||
| ? (communication_fault ? MonitorValueState::CommunicationFault | |||||
| : MonitorValueState::Valid) | |||||
| : MonitorValueState::Unavailable; | |||||
| } | |||||
| else | |||||
| { | |||||
| const WordReadResult read = repository_.readWord(address); | |||||
| value.wordValue = read.value; | |||||
| value.state = read.succeeded | |||||
| ? (communication_fault ? MonitorValueState::CommunicationFault | ? (communication_fault ? MonitorValueState::CommunicationFault | ||||
| : MonitorValueState::Valid) | : MonitorValueState::Valid) | ||||
| : MonitorValueState::Unavailable; | : MonitorValueState::Unavailable; | ||||
| } | |||||
| } | } | ||||
| result.push_back(value); | result.push_back(value); | ||||
| } | } | ||||
| return result; | return result; | ||||
| } | } | ||||
| const std::vector<MonitorPoint> &RegisterMonitorService::points() const | |||||
| { | |||||
| return model_.points(); | |||||
| } | |||||
| std::vector<RegisterAddress> RegisterMonitorService::pollAddresses() const | |||||
| { | |||||
| std::vector<RegisterAddress> result; | |||||
| for (const MonitorPoint &point : model_.points()) | |||||
| { | |||||
| for (int offset = 0; offset < registerDataTypeWordCount(point.dataType); ++offset) | |||||
| { | |||||
| result.push_back(RegisterAddress{ | |||||
| point.address.area(), point.address.index() + offset}); | |||||
| } | |||||
| } | |||||
| return result; | |||||
| } | |||||
| std::vector<RegisterAddress> RegisterMonitorService::float32Starts() const | |||||
| { | |||||
| std::vector<RegisterAddress> result; | |||||
| for (const MonitorPoint &point : model_.points()) | |||||
| { | |||||
| if (point.dataType == RegisterDataType::Float32) | |||||
| { | |||||
| result.push_back(point.address); | |||||
| } | |||||
| } | |||||
| return result; | |||||
| } | |||||
| void RegisterMonitorService::setAddressesChangedCallback(std::function<void()> callback) | void RegisterMonitorService::setAddressesChangedCallback(std::function<void()> callback) | ||||
| { | { | ||||
| addresses_changed_callback_ = std::move(callback); | addresses_changed_callback_ = std::move(callback); | ||||
| @@ -8,47 +8,126 @@ | |||||
| #include <string> | #include <string> | ||||
| #include <vector> | #include <vector> | ||||
| class VirtualRegisterRepository; | |||||
| // 自由监控列表编辑的失败分类 | |||||
| enum class RegisterMonitorError | enum class RegisterMonitorError | ||||
| { | { | ||||
| None, | |||||
| InvalidAddress, | |||||
| RangeOverflow, | |||||
| LimitExceeded, | |||||
| NoChange | |||||
| None, // 操作成功 | |||||
| InvalidAddress, // 起始地址格式或区域不支持 | |||||
| RangeOverflow, // 连续范围超出 M/D 地址上限 | |||||
| LimitExceeded, // 监控地址数量超过会话上限 | |||||
| NoChange // 操作没有产生任何列表变化 | |||||
| }; | }; | ||||
| // 监控列表编辑结果;affectedCount 表示本次受影响的地址数量 | |||||
| struct RegisterMonitorResult | struct RegisterMonitorResult | ||||
| { | { | ||||
| bool succeeded = false; | |||||
| RegisterMonitorError error = RegisterMonitorError::None; | |||||
| std::string message; | |||||
| int affectedCount = 0; | |||||
| bool succeeded = false; // 列表编辑是否成功 | |||||
| RegisterMonitorError error = RegisterMonitorError::None; // 失败时的分类 | |||||
| std::string message; // 面向用户的 UTF-8 成功说明或失败原因 | |||||
| int affectedCount = 0; // 本次新增或删除的地址数量 | |||||
| }; | }; | ||||
| // 自由监控写入结果;底层 RegisterError 会原样保留 | |||||
| struct RegisterMonitorWriteResult | struct RegisterMonitorWriteResult | ||||
| { | { | ||||
| bool succeeded = false; | |||||
| RegisterError error = RegisterError::Unavailable; | |||||
| std::string message; | |||||
| bool succeeded = false; // 写入请求是否成功提交 | |||||
| RegisterError error = RegisterError::Unavailable; // 底层仓库错误分类 | |||||
| std::string message; // 面向用户的 UTF-8 错误说明 | |||||
| }; | }; | ||||
| /** | |||||
| * @brief 编排自由监控地址列表和寄存器仓库读写 | |||||
| * | |||||
| * 地址列表和离线初始值只属于当前运行会话,不写入工程文件;仓库由当前运行模式注入 | |||||
| */ | |||||
| class RegisterMonitorService | class RegisterMonitorService | ||||
| { | { | ||||
| public: | public: | ||||
| /** | |||||
| * @brief 创建自由监控服务 | |||||
| * @param repository 当前运行模式使用的寄存器仓库,不由服务拥有 | |||||
| */ | |||||
| explicit RegisterMonitorService(RegisterRepository &repository); | explicit RegisterMonitorService(RegisterRepository &repository); | ||||
| // 配置编辑态离线初始值仓库;该仓库只保存在当前进程内 | |||||
| void setOfflineInitialRepository(VirtualRegisterRepository &repository); | |||||
| // 编辑态开启时,监控写入同时更新离线初始值 | |||||
| void setOfflineInitialCaptureEnabled(bool enabled); | |||||
| /** | |||||
| * @brief 从起始地址连续添加同一区域的多个地址 | |||||
| * @param start_address 文本地址,例如 M0 或 D100 | |||||
| * @param count 连续地址数量,必须为正且不能越过 4000 | |||||
| * @return 去重后实际新增数量;格式、范围、容量或无变化时返回失败 | |||||
| */ | |||||
| RegisterMonitorResult addRange(const std::string &start_address, int count); | RegisterMonitorResult addRange(const std::string &start_address, int count); | ||||
| RegisterMonitorResult addRange( | |||||
| const std::string &start_address, int count, RegisterDataType data_type); | |||||
| /** | |||||
| * @brief 删除一批监控地址 | |||||
| * @param addresses 待删除地址;不存在的地址会被忽略 | |||||
| * @return 实际删除数量,并在发生变化时通知轮询集合更新 | |||||
| */ | |||||
| RegisterMonitorResult remove(const std::vector<RegisterAddress> &addresses); | RegisterMonitorResult remove(const std::vector<RegisterAddress> &addresses); | ||||
| RegisterMonitorResult remove(const std::vector<MonitorPoint> &points); | |||||
| /** | |||||
| * @brief 清空当前会话的监控地址列表 | |||||
| * @return 实际清除数量;列表本来为空时返回 NoChange | |||||
| */ | |||||
| RegisterMonitorResult clear(); | RegisterMonitorResult clear(); | ||||
| /** | |||||
| * @brief 对单个 M 地址提交位写入 | |||||
| * @param address 必须是有效的 M 区地址 | |||||
| * @param value 要写入的位值 | |||||
| * @return 成功或包含底层 RegisterError 和用户可读文本的失败结果 | |||||
| */ | |||||
| RegisterMonitorWriteResult writeBit(const RegisterAddress &address, bool value); | RegisterMonitorWriteResult writeBit(const RegisterAddress &address, bool value); | ||||
| /** | |||||
| * @brief 对单个 D 地址提交有符号 16 位字写入 | |||||
| * @param address 必须是有效的 D 区地址 | |||||
| * @param value 要写入的 D 字值 | |||||
| * @return 成功或包含底层 RegisterError 和用户可读文本的失败结果 | |||||
| */ | |||||
| RegisterMonitorWriteResult writeWord( | RegisterMonitorWriteResult writeWord( | ||||
| const RegisterAddress &address, std::int16_t value); | const RegisterAddress &address, std::int16_t value); | ||||
| RegisterMonitorWriteResult writeFloat( | |||||
| const RegisterAddress &address, float value); | |||||
| /** | |||||
| * @brief 返回当前会话的监控地址列表 | |||||
| * @return 按服务内部顺序保存的只读地址列表 | |||||
| */ | |||||
| const std::vector<RegisterAddress> &addresses() const; | const std::vector<RegisterAddress> &addresses() const; | ||||
| const std::vector<MonitorPoint> &points() const; | |||||
| std::vector<RegisterAddress> pollAddresses() const; | |||||
| std::vector<RegisterAddress> float32Starts() const; | |||||
| /** | |||||
| * @brief 读取监控列表的当前值并生成 UI 展示模型 | |||||
| * @param communication_fault 通信处于故障态时,将成功读回值标记为通信故障 | |||||
| * @return 每个监控地址对应一个值和可用性状态 | |||||
| */ | |||||
| std::vector<MonitorValue> values(bool communication_fault) const; | std::vector<MonitorValue> values(bool communication_fault) const; | ||||
| /** | |||||
| * @brief 设置监控地址变化通知回调 | |||||
| * @param callback 地址列表新增、删除或清空后调用;可传空函数取消通知 | |||||
| */ | |||||
| void setAddressesChangedCallback(std::function<void()> callback); | void setAddressesChangedCallback(std::function<void()> callback); | ||||
| private: | private: | ||||
| RegisterRepository &repository_; | |||||
| RegisterMonitorModel model_; | |||||
| std::function<void()> addresses_changed_callback_; | |||||
| RegisterMonitorWriteResult captureInitialBit( | |||||
| const RegisterAddress &address, bool value, | |||||
| const RegisterMonitorWriteResult &active_result); | |||||
| RegisterMonitorWriteResult captureInitialWord( | |||||
| const RegisterAddress &address, std::int16_t value, | |||||
| const RegisterMonitorWriteResult &active_result); | |||||
| RegisterMonitorWriteResult captureInitialFloat( | |||||
| const RegisterAddress &address, float value, | |||||
| const RegisterMonitorWriteResult &active_result); | |||||
| RegisterRepository &repository_; // 当前运行模式的寄存器仓库,不由服务拥有 | |||||
| VirtualRegisterRepository *offline_initial_repository_ = nullptr; | |||||
| bool capture_offline_initial_values_ = false; | |||||
| RegisterMonitorModel model_; // 当前会话的去重监控地址列表 | |||||
| std::function<void()> addresses_changed_callback_; // 地址变化后的轮询刷新通知 | |||||
| }; | }; | ||||
| @@ -16,9 +16,11 @@ | |||||
| RuntimeModeService::RuntimeModeService( | RuntimeModeService::RuntimeModeService( | ||||
| const ProjectService &project_service, | const ProjectService &project_service, | ||||
| OfflineSimulationService &offline_simulation_service) | |||||
| OfflineSimulationService &offline_simulation_service, | |||||
| OnlineLogicMonitorService &online_logic_monitor_service) | |||||
| : project_service_(project_service), | : project_service_(project_service), | ||||
| offline_simulation_service_(offline_simulation_service) | |||||
| offline_simulation_service_(offline_simulation_service), | |||||
| online_logic_monitor_service_(online_logic_monitor_service) | |||||
| { | { | ||||
| } | } | ||||
| @@ -26,7 +28,7 @@ RuntimeModeService::~RuntimeModeService() | |||||
| { | { | ||||
| if (plc_gateway_ != nullptr) | if (plc_gateway_ != nullptr) | ||||
| { | { | ||||
| plc_gateway_->setCallbacks({}, {}, {}, {}); | |||||
| plc_gateway_->setCallbacks({}, {}, {}, {}, {}); | |||||
| } | } | ||||
| } | } | ||||
| @@ -47,6 +49,10 @@ ModeTransitionResult RuntimeModeService::enterEditing() | |||||
| // 先停止扫描再开放编辑,防止运行快照继续写寄存器 | // 先停止扫描再开放编辑,防止运行快照继续写寄存器 | ||||
| offline_simulation_service_.stop(); | offline_simulation_service_.stop(); | ||||
| } | } | ||||
| else if (state_.mode() == ApplicationMode::OnlineRunning) | |||||
| { | |||||
| online_logic_monitor_service_.stop(); | |||||
| } | |||||
| const ModeTransitionResult result = state_.enterEditing(); | const ModeTransitionResult result = state_.enterEditing(); | ||||
| if (result.succeeded && active_repository_ != nullptr | if (result.succeeded && active_repository_ != nullptr | ||||
| && virtual_repository_ != nullptr) | && virtual_repository_ != nullptr) | ||||
| @@ -63,14 +69,17 @@ ModeTransitionResult RuntimeModeService::enterOfflineRunning() | |||||
| return state_.enterOfflineRunning(); | return state_.enterOfflineRunning(); | ||||
| } | } | ||||
| std::string error; | std::string error; | ||||
| if (!project_service_.project().validateForRunning(&error)) | |||||
| if (!project_service_.project().validateForRunning( | |||||
| project_service_.projectLimits(), &error)) | |||||
| { | { | ||||
| return {false, ModeTransitionError::ProjectNotReady}; | return {false, ModeTransitionError::ProjectNotReady}; | ||||
| } | } | ||||
| // 离线仿真必须先切到虚拟仓库,避免扫描结果写入 PLC 缓存 | |||||
| if (active_repository_ != nullptr && virtual_repository_ != nullptr) | if (active_repository_ != nullptr && virtual_repository_ != nullptr) | ||||
| { | { | ||||
| active_repository_->use(*virtual_repository_); | active_repository_->use(*virtual_repository_); | ||||
| } | } | ||||
| // start() 会复制逻辑快照并做运行前校验;失败时不进入运行态 | |||||
| const SimulationStartResult start_result = offline_simulation_service_.start( | const SimulationStartResult start_result = offline_simulation_service_.start( | ||||
| project_service_.project().controlLogics); | project_service_.project().controlLogics); | ||||
| if (!start_result.succeeded) | if (!start_result.succeeded) | ||||
| @@ -87,12 +96,30 @@ ModeTransitionResult RuntimeModeService::enterOfflineRunning() | |||||
| ModeTransitionResult RuntimeModeService::enterOnlineRunning() | ModeTransitionResult RuntimeModeService::enterOnlineRunning() | ||||
| { | { | ||||
| const ModeTransitionResult result = state_.enterOnlineRunning( | |||||
| initialPlcReadCompleted()); | |||||
| if (state_.mode() != ApplicationMode::Editing) | |||||
| { | |||||
| return state_.enterOnlineRunning(initialPlcReadCompleted()); | |||||
| } | |||||
| if (!initialPlcReadCompleted()) | |||||
| { | |||||
| return state_.enterOnlineRunning(false); | |||||
| } | |||||
| const OnlineLogicMonitorStartResult start_result = | |||||
| online_logic_monitor_service_.start( | |||||
| project_service_.project().controlLogics); | |||||
| if (!start_result.succeeded) | |||||
| { | |||||
| return {false, ModeTransitionError::SimulationStartFailed}; | |||||
| } | |||||
| const ModeTransitionResult result = state_.enterOnlineRunning(true); | |||||
| if (result.succeeded && active_repository_ != nullptr && plc_repository_ != nullptr) | if (result.succeeded && active_repository_ != nullptr && plc_repository_ != nullptr) | ||||
| { | { | ||||
| active_repository_->use(*plc_repository_); | active_repository_->use(*plc_repository_); | ||||
| } | } | ||||
| else if (!result.succeeded) | |||||
| { | |||||
| online_logic_monitor_service_.stop(); | |||||
| } | |||||
| return result; | return result; | ||||
| } | } | ||||
| @@ -130,6 +157,11 @@ OfflineSimulationService &RuntimeModeService::offlineSimulationService() | |||||
| return offline_simulation_service_; | return offline_simulation_service_; | ||||
| } | } | ||||
| OnlineLogicMonitorService &RuntimeModeService::onlineLogicMonitorService() | |||||
| { | |||||
| return online_logic_monitor_service_; | |||||
| } | |||||
| void RuntimeModeService::configurePlc( | void RuntimeModeService::configurePlc( | ||||
| PlcCommunicationGateway &gateway, | PlcCommunicationGateway &gateway, | ||||
| ActiveRegisterRepository &active_repository, | ActiveRegisterRepository &active_repository, | ||||
| @@ -140,6 +172,7 @@ void RuntimeModeService::configurePlc( | |||||
| active_repository_ = &active_repository; | active_repository_ = &active_repository; | ||||
| virtual_repository_ = &virtual_repository; | virtual_repository_ = &virtual_repository; | ||||
| plc_repository_ = &plc_repository; | plc_repository_ = &plc_repository; | ||||
| // 通信故障撤销首读资格;若正在真机运行,立即回到编辑态 | |||||
| gateway.setCallbacks( | gateway.setCallbacks( | ||||
| [this] | [this] | ||||
| { | { | ||||
| @@ -170,6 +203,25 @@ void RuntimeModeService::configurePlc( | |||||
| } | } | ||||
| }, | }, | ||||
| [] {}, | [] {}, | ||||
| [this] | |||||
| { | |||||
| if (state_.mode() == ApplicationMode::OnlineRunning | |||||
| && online_logic_monitor_service_.state() | |||||
| == OnlineLogicMonitorState::Running) | |||||
| { | |||||
| const LogicScanResult result = | |||||
| online_logic_monitor_service_.executeOnce(); | |||||
| if (!result.succeeded | |||||
| && state_.mode() == ApplicationMode::OnlineRunning) | |||||
| { | |||||
| enterEditing(); | |||||
| if (plc_status_changed_callback_) | |||||
| { | |||||
| plc_status_changed_callback_(); | |||||
| } | |||||
| } | |||||
| } | |||||
| }, | |||||
| [this](const std::string &) | [this](const std::string &) | ||||
| { | { | ||||
| if (plc_status_changed_callback_) | if (plc_status_changed_callback_) | ||||
| @@ -201,8 +253,16 @@ PlcCommunicationResult RuntimeModeService::connectPlc( | |||||
| void RuntimeModeService::setMonitorAddresses( | void RuntimeModeService::setMonitorAddresses( | ||||
| const std::vector<RegisterAddress> &addresses) | const std::vector<RegisterAddress> &addresses) | ||||
| { | |||||
| setMonitorAddresses(addresses, {}); | |||||
| } | |||||
| void RuntimeModeService::setMonitorAddresses( | |||||
| const std::vector<RegisterAddress> &addresses, | |||||
| const std::vector<RegisterAddress> &float32_starts) | |||||
| { | { | ||||
| monitor_addresses_ = addresses; | monitor_addresses_ = addresses; | ||||
| monitor_float32_starts_ = float32_starts; | |||||
| refreshPlcPollAddresses(); | refreshPlcPollAddresses(); | ||||
| } | } | ||||
| @@ -212,7 +272,9 @@ PlcCommunicationResult RuntimeModeService::refreshPlcPollAddresses() | |||||
| { | { | ||||
| return {false, "PLC 通信服务尚未配置"}; | return {false, "PLC 通信服务尚未配置"}; | ||||
| } | } | ||||
| // 轮询集合来自自由监控、HMI、报警和梯形图引用的并集 | |||||
| std::vector<RegisterAddress> addresses = monitor_addresses_; | std::vector<RegisterAddress> addresses = monitor_addresses_; | ||||
| std::vector<RegisterAddress> float32_starts = monitor_float32_starts_; | |||||
| const Project &project = project_service_.project(); | const Project &project = project_service_.project(); | ||||
| for (const HmiPage &page : project.hmiPages) | for (const HmiPage &page : project.hmiPages) | ||||
| { | { | ||||
| @@ -221,6 +283,15 @@ PlcCommunicationResult RuntimeModeService::refreshPlcPollAddresses() | |||||
| if (control.binding.has_value()) | if (control.binding.has_value()) | ||||
| { | { | ||||
| addresses.push_back(*control.binding); | addresses.push_back(*control.binding); | ||||
| if ((control.type == HmiControlType::NumericDisplay | |||||
| || control.type == HmiControlType::NumericInput) | |||||
| && control.dataType == RegisterDataType::Float32 | |||||
| && control.binding->area() == RegisterArea::D) | |||||
| { | |||||
| addresses.push_back(RegisterAddress{ | |||||
| RegisterArea::D, control.binding->index() + 1}); | |||||
| float32_starts.push_back(*control.binding); | |||||
| } | |||||
| } | } | ||||
| } | } | ||||
| } | } | ||||
| @@ -262,12 +333,34 @@ PlcCommunicationResult RuntimeModeService::refreshPlcPollAddresses() | |||||
| } | } | ||||
| return left.index() < right.index(); | return left.index() < right.index(); | ||||
| }); | }); | ||||
| // 排序后去重,保证同一个地址只占一份轮询配额 | |||||
| addresses.erase(std::unique(addresses.begin(), addresses.end()), addresses.end()); | addresses.erase(std::unique(addresses.begin(), addresses.end()), addresses.end()); | ||||
| if (addresses.size() > ProjectLimits::kMaximumPollAddresses) | if (addresses.size() > ProjectLimits::kMaximumPollAddresses) | ||||
| { | { | ||||
| return {false, "PLC 轮询的去重 M/D 地址最多为 1024 个"}; | |||||
| return {false, "PLC 轮询的去重 M/D 地址最多为 256 个"}; | |||||
| } | } | ||||
| return plc_gateway_->setPollAddresses(addresses); | |||||
| std::sort( | |||||
| float32_starts.begin(), float32_starts.end(), | |||||
| [](const RegisterAddress &left, const RegisterAddress &right) | |||||
| { | |||||
| return left.area() == right.area() | |||||
| ? left.index() < right.index() | |||||
| : left.area() == RegisterArea::M; | |||||
| }); | |||||
| float32_starts.erase( | |||||
| std::unique(float32_starts.begin(), float32_starts.end()), | |||||
| float32_starts.end()); | |||||
| float32_starts.erase( | |||||
| std::remove_if( | |||||
| float32_starts.begin(), float32_starts.end(), | |||||
| [](const RegisterAddress &address) | |||||
| { | |||||
| return !address.isValid() | |||||
| || address.area() != RegisterArea::D | |||||
| || address.index() >= RegisterAddress::kMaximumIndex; | |||||
| }), | |||||
| float32_starts.end()); | |||||
| return plc_gateway_->setPollAddresses(addresses, float32_starts); | |||||
| } | } | ||||
| void RuntimeModeService::disconnectPlc() | void RuntimeModeService::disconnectPlc() | ||||
| @@ -10,6 +10,7 @@ | |||||
| #include "domain/runtime_state.h" | #include "domain/runtime_state.h" | ||||
| #include "offline_simulation_service.h" | #include "offline_simulation_service.h" | ||||
| #include "online_logic_monitor_service.h" | |||||
| #include "plc_communication_gateway.h" | #include "plc_communication_gateway.h" | ||||
| #include <cstdint> | #include <cstdint> | ||||
| @@ -20,15 +21,26 @@ class ProjectService; | |||||
| class ActiveRegisterRepository; | class ActiveRegisterRepository; | ||||
| class RegisterRepository; | class RegisterRepository; | ||||
| // 隔离 UI 与领域状态机并编排进入真机运行态的前置条件 | |||||
| // 隔离 UI 与领域状态机,并编排寄存器仓库切换和进入真机的前置条件 | |||||
| class RuntimeModeService | class RuntimeModeService | ||||
| { | { | ||||
| public: | public: | ||||
| /** | |||||
| * @brief 创建运行模式服务 | |||||
| * @param project_service 只读工程服务,用于运行前校验和收集轮询地址 | |||||
| * @param offline_simulation_service 离线仿真服务,由应用层负责其生命周期 | |||||
| */ | |||||
| RuntimeModeService( | RuntimeModeService( | ||||
| const ProjectService &project_service, | const ProjectService &project_service, | ||||
| OfflineSimulationService &offline_simulation_service); | |||||
| OfflineSimulationService &offline_simulation_service, | |||||
| OnlineLogicMonitorService &online_logic_monitor_service); | |||||
| /** | |||||
| * @brief 解除 PLC 网关回调绑定 | |||||
| */ | |||||
| ~RuntimeModeService(); | ~RuntimeModeService(); | ||||
| /** @brief 获取当前应用运行模式 */ | |||||
| ApplicationMode mode() const; | ApplicationMode mode() const; | ||||
| /** | /** | ||||
| * @brief 获取当前模式下允许的工程编辑和寄存器使用策略 | * @brief 获取当前模式下允许的工程编辑和寄存器使用策略 | ||||
| @@ -65,33 +77,77 @@ public: | |||||
| */ | */ | ||||
| bool initialPlcReadCompleted() const; | bool initialPlcReadCompleted() const; | ||||
| /** @brief 获取离线仿真当前状态 */ | |||||
| SimulationState simulationState() const; | SimulationState simulationState() const; | ||||
| /** @brief 获取离线仿真成功完成的扫描轮数 */ | |||||
| std::uint64_t successfulScanCount() const; | std::uint64_t successfulScanCount() const; | ||||
| /** @brief 获取离线仿真最近一次扫描错误 */ | |||||
| const LogicScanResult &simulationError() const; | const LogicScanResult &simulationError() const; | ||||
| /** @brief 返回服务持有的离线仿真服务引用 */ | |||||
| OfflineSimulationService &offlineSimulationService(); | OfflineSimulationService &offlineSimulationService(); | ||||
| OnlineLogicMonitorService &onlineLogicMonitorService(); | |||||
| /** | |||||
| * @brief 注入 PLC 网关、活动仓库和两种实际数据源 | |||||
| * | |||||
| * 应用生命周期内只调用一次;真机模式使用 PLC 仓库,编辑/离线模式使用虚拟仓库 | |||||
| */ | |||||
| void configurePlc( | void configurePlc( | ||||
| PlcCommunicationGateway &gateway, | PlcCommunicationGateway &gateway, | ||||
| ActiveRegisterRepository &active_repository, | ActiveRegisterRepository &active_repository, | ||||
| RegisterRepository &virtual_repository, | RegisterRepository &virtual_repository, | ||||
| RegisterRepository &plc_repository); | RegisterRepository &plc_repository); | ||||
| /** | |||||
| * @brief 连接 PLC 并开始异步通信 | |||||
| * @param configuration 串口、Modbus 和轮询配置 | |||||
| * @return 连接请求受理结果,不阻塞 UI 等待首读完成 | |||||
| */ | |||||
| PlcCommunicationResult connectPlc(const PlcSerialConfiguration &configuration); | PlcCommunicationResult connectPlc(const PlcSerialConfiguration &configuration); | ||||
| /** | |||||
| * @brief 设置自由监控引用的地址并刷新 PLC 轮询集合 | |||||
| * @param addresses 自由监控当前需要读取的 M/D 地址 | |||||
| */ | |||||
| void setMonitorAddresses(const std::vector<RegisterAddress> &addresses); | void setMonitorAddresses(const std::vector<RegisterAddress> &addresses); | ||||
| void setMonitorAddresses( | |||||
| const std::vector<RegisterAddress> &addresses, | |||||
| const std::vector<RegisterAddress> &float32_starts); | |||||
| /** | |||||
| * @brief 汇总工程和自由监控引用并刷新 PLC 轮询地址 | |||||
| * @return 网关未配置或地址数量超限时返回失败结果 | |||||
| */ | |||||
| PlcCommunicationResult refreshPlcPollAddresses(); | PlcCommunicationResult refreshPlcPollAddresses(); | ||||
| /** | |||||
| * @brief 退出真机运行、断开 PLC 并清除首读资格 | |||||
| */ | |||||
| void disconnectPlc(); | void disconnectPlc(); | ||||
| /** @brief 获取当前 PLC 连接状态;网关未配置时返回 Disconnected */ | |||||
| PlcConnectionState plcConnectionState() const; | PlcConnectionState plcConnectionState() const; | ||||
| /** @brief 获取 PLC 最近一次通信错误文本;网关未配置时返回空字符串 */ | |||||
| const std::string &plcError() const; | const std::string &plcError() const; | ||||
| /** | |||||
| * @brief 设置 PLC 状态变化通知回调 | |||||
| * @param callback 状态、首读资格或通信错误变化时调用的函数;可传空函数取消通知 | |||||
| */ | |||||
| void setPlcStatusChangedCallback(std::function<void()> callback); | void setPlcStatusChangedCallback(std::function<void()> callback); | ||||
| private: | private: | ||||
| const ProjectService &project_service_; | |||||
| OfflineSimulationService &offline_simulation_service_; | |||||
| RuntimeState state_; | |||||
| // 表示 PLC 缓存是否已通过至少一次有效读取建立 | |||||
| const ProjectService &project_service_; // 不拥有的只读工程服务 | |||||
| OfflineSimulationService &offline_simulation_service_; // 不拥有的离线仿真服务 | |||||
| OnlineLogicMonitorService &online_logic_monitor_service_; // 不拥有的真机只读轨迹服务 | |||||
| RuntimeState state_; // 编辑、离线和真机模式状态机 | |||||
| // PLC 缓存是否已通过有效首读建立,通信故障时清除 | |||||
| bool initial_plc_read_completed_ = false; | bool initial_plc_read_completed_ = false; | ||||
| PlcCommunicationGateway *plc_gateway_ = nullptr; | |||||
| ActiveRegisterRepository *active_repository_ = nullptr; | |||||
| RegisterRepository *virtual_repository_ = nullptr; | |||||
| RegisterRepository *plc_repository_ = nullptr; | |||||
| std::function<void()> plc_status_changed_callback_; | |||||
| std::vector<RegisterAddress> monitor_addresses_; | |||||
| PlcCommunicationGateway *plc_gateway_ = nullptr; // 不拥有的 PLC 通信网关 | |||||
| ActiveRegisterRepository *active_repository_ = nullptr; // 当前模式使用的仓库代理 | |||||
| RegisterRepository *virtual_repository_ = nullptr; // 离线/编辑模式的虚拟仓库 | |||||
| RegisterRepository *plc_repository_ = nullptr; // 真机模式的 PLC 缓存仓库 | |||||
| std::function<void()> plc_status_changed_callback_; // PLC 状态变化通知 | |||||
| std::vector<RegisterAddress> monitor_addresses_; // 自由监控额外引用的地址 | |||||
| std::vector<RegisterAddress> monitor_float32_starts_; // 自由监控中的 Float32 起始地址 | |||||
| }; | }; | ||||
| @@ -79,8 +79,6 @@ void LogicTraceValues::clear() | |||||
| expressionInputValues.clear(); | expressionInputValues.clear(); | ||||
| expressionPowerValues.clear(); | expressionPowerValues.clear(); | ||||
| rungValues.clear(); | rungValues.clear(); | ||||
| tonValues.clear(); | |||||
| counterValues.clear(); | |||||
| wordValues.clear(); | wordValues.clear(); | ||||
| } | } | ||||
| @@ -103,8 +101,6 @@ LogicTraceSnapshot LogicTraceSnapshot::forLogic( | |||||
| projection.expressionInputValues = values->second.expressionInputValues; | projection.expressionInputValues = values->second.expressionInputValues; | ||||
| projection.expressionPowerValues = values->second.expressionPowerValues; | projection.expressionPowerValues = values->second.expressionPowerValues; | ||||
| projection.rungValues = values->second.rungValues; | projection.rungValues = values->second.rungValues; | ||||
| projection.tonValues = values->second.tonValues; | |||||
| projection.counterValues = values->second.counterValues; | |||||
| projection.wordValues = values->second.wordValues; | projection.wordValues = values->second.wordValues; | ||||
| } | } | ||||
| return projection; | return projection; | ||||
| @@ -113,6 +109,7 @@ LogicTraceSnapshot LogicTraceSnapshot::forLogic( | |||||
| LogicScanResult SoftwareLogicExecutor::validate( | LogicScanResult SoftwareLogicExecutor::validate( | ||||
| const std::vector<ControlLogic> &logics) const | const std::vector<ControlLogic> &logics) const | ||||
| { | { | ||||
| // 先检查每个逻辑自身,再检查跨网络共享线圈的冲突 | |||||
| std::map<int, CoilMode> output_modes; | std::map<int, CoilMode> output_modes; | ||||
| for (const ControlLogic &logic : logics) | for (const ControlLogic &logic : logics) | ||||
| { | { | ||||
| @@ -144,9 +141,7 @@ LogicScanResult SoftwareLogicExecutor::validate( | |||||
| const auto *output = std::get_if<CoilNodeConfig>(&rung.output->config); | const auto *output = std::get_if<CoilNodeConfig>(&rung.output->config); | ||||
| if (output == nullptr) | if (output == nullptr) | ||||
| { | { | ||||
| if (std::holds_alternative<TonNodeConfig>(rung.output->config) | |||||
| || std::holds_alternative<CounterNodeConfig>(rung.output->config) | |||||
| || std::holds_alternative<MoveNodeConfig>(rung.output->config) | |||||
| if (std::holds_alternative<MoveNodeConfig>(rung.output->config) | |||||
| || std::holds_alternative<ArithmeticNodeConfig>( | || std::holds_alternative<ArithmeticNodeConfig>( | ||||
| rung.output->config)) | rung.output->config)) | ||||
| { | { | ||||
| @@ -173,35 +168,18 @@ LogicScanResult SoftwareLogicExecutor::validate( | |||||
| output_modes[output->address.index()] = output->mode; | output_modes[output->address.index()] = output->mode; | ||||
| } | } | ||||
| } | } | ||||
| std::string resource_error; | |||||
| if (!validateLogicResourceReferencesForRunning(logics, &resource_error)) | |||||
| { | |||||
| return failure(LogicScanError::InvalidLogic, resource_error); | |||||
| } | |||||
| return success(); | return success(); | ||||
| } | } | ||||
| void SoftwareLogicExecutor::resetRuntime() | void SoftwareLogicExecutor::resetRuntime() | ||||
| { | { | ||||
| previous_edge_inputs_.clear(); | previous_edge_inputs_.clear(); | ||||
| previous_counter_inputs_.clear(); | |||||
| ton_states_.clear(); | |||||
| counter_states_.clear(); | |||||
| } | } | ||||
| LogicScanResult SoftwareLogicExecutor::executeScan( | LogicScanResult SoftwareLogicExecutor::executeScan( | ||||
| const std::vector<ControlLogic> &logics, | const std::vector<ControlLogic> &logics, | ||||
| RegisterRepository &repository, | RegisterRepository &repository, | ||||
| LogicTraceSnapshot *trace) | LogicTraceSnapshot *trace) | ||||
| { | |||||
| return executeScanAt(logics, repository, Clock::now(), trace); | |||||
| } | |||||
| LogicScanResult SoftwareLogicExecutor::executeScanAt( | |||||
| const std::vector<ControlLogic> &logics, | |||||
| RegisterRepository &repository, | |||||
| TimePoint now, | |||||
| LogicTraceSnapshot *trace) | |||||
| { | { | ||||
| const LogicScanResult validation = validate(logics); | const LogicScanResult validation = validate(logics); | ||||
| if (!validation.succeeded) | if (!validation.succeeded) | ||||
| @@ -209,10 +187,12 @@ LogicScanResult SoftwareLogicExecutor::executeScanAt( | |||||
| return validation; | return validation; | ||||
| } | } | ||||
| // 每轮从空轨迹开始,防止已删除或未执行的节点残留旧状态 | |||||
| if (trace != nullptr) | if (trace != nullptr) | ||||
| { | { | ||||
| trace->clear(); | trace->clear(); | ||||
| } | } | ||||
| // 按工程顺序扫描;前面网络写入的 M/D 值对后面网络立即可见 | |||||
| for (const ControlLogic &logic : logics) | for (const ControlLogic &logic : logics) | ||||
| { | { | ||||
| if (!logic.enabled) | if (!logic.enabled) | ||||
| @@ -228,7 +208,7 @@ LogicScanResult SoftwareLogicExecutor::executeScanAt( | |||||
| continue; | continue; | ||||
| } | } | ||||
| // 没有条件节点的网络等价于左侧电源线直接接通,作为恒真条件执行 | |||||
| // 输出网络的显式 Wire 会计算为真;空网络只作为编辑草稿跳过 | |||||
| bool rung_value = true; | bool rung_value = true; | ||||
| LogicScanResult result = success(); | LogicScanResult result = success(); | ||||
| if (rung.condition.has_value()) | if (rung.condition.has_value()) | ||||
| @@ -255,11 +235,9 @@ LogicScanResult SoftwareLogicExecutor::executeScanAt( | |||||
| } | } | ||||
| bool output_value = false; | bool output_value = false; | ||||
| result = executeOutput( | result = executeOutput( | ||||
| logic.id, | |||||
| *rung.output, | *rung.output, | ||||
| rung_value, | rung_value, | ||||
| repository, | repository, | ||||
| now, | |||||
| logic_trace, | logic_trace, | ||||
| &output_value); | &output_value); | ||||
| if (!result.succeeded) | if (!result.succeeded) | ||||
| @@ -291,8 +269,6 @@ LogicScanResult SoftwareLogicExecutor::executeScanAt( | |||||
| trace->expressionInputValues = values->second.expressionInputValues; | trace->expressionInputValues = values->second.expressionInputValues; | ||||
| trace->expressionPowerValues = values->second.expressionPowerValues; | trace->expressionPowerValues = values->second.expressionPowerValues; | ||||
| trace->rungValues = values->second.rungValues; | trace->rungValues = values->second.rungValues; | ||||
| trace->tonValues = values->second.tonValues; | |||||
| trace->counterValues = values->second.counterValues; | |||||
| trace->wordValues = values->second.wordValues; | trace->wordValues = values->second.wordValues; | ||||
| } | } | ||||
| } | } | ||||
| @@ -326,6 +302,15 @@ LogicScanResult SoftwareLogicExecutor::evaluateExpression( | |||||
| } | } | ||||
| return success(); | return success(); | ||||
| } | } | ||||
| if (expression.kind == ConditionExpressionKind::Gap) | |||||
| { | |||||
| return failure( | |||||
| LogicScanError::InvalidLogic, | |||||
| "梯形图条件区存在未连接的空白网格", | |||||
| logic_id, | |||||
| {}, | |||||
| expression.id); | |||||
| } | |||||
| if (expression.kind == ConditionExpressionKind::Node) | if (expression.kind == ConditionExpressionKind::Node) | ||||
| { | { | ||||
| LogicScanResult result = evaluateCondition( | LogicScanResult result = evaluateCondition( | ||||
| @@ -380,6 +365,8 @@ LogicScanResult SoftwareLogicExecutor::evaluateCondition( | |||||
| RegisterRepository &repository, | RegisterRepository &repository, | ||||
| bool *value) | bool *value) | ||||
| { | { | ||||
| // 根据条件节点的具体配置读取 M/D 状态,并计算触点结果 | |||||
| // 边沿触点使用 logic_id 和 node.id 组成运行时状态键,避免同名节点互相影响 | |||||
| if (value == nullptr) | if (value == nullptr) | ||||
| { | { | ||||
| return failure( | return failure( | ||||
| @@ -397,6 +384,7 @@ LogicScanResult SoftwareLogicExecutor::evaluateCondition( | |||||
| using Config = std::decay_t<decltype(config)>; | using Config = std::decay_t<decltype(config)>; | ||||
| if constexpr (std::is_same_v<Config, ContactNodeConfig>) | if constexpr (std::is_same_v<Config, ContactNodeConfig>) | ||||
| { | { | ||||
| // 普通触点直接读取 M 位;常闭触点使用读回值的反值 | |||||
| const BitReadResult read = repository.readBit(config.address); | const BitReadResult read = repository.readBit(config.address); | ||||
| if (!read.succeeded) | if (!read.succeeded) | ||||
| { | { | ||||
| @@ -413,6 +401,7 @@ LogicScanResult SoftwareLogicExecutor::evaluateCondition( | |||||
| } | } | ||||
| else if constexpr (std::is_same_v<Config, EdgeContactNodeConfig>) | else if constexpr (std::is_same_v<Config, EdgeContactNodeConfig>) | ||||
| { | { | ||||
| // 边沿触点需要比较本轮与上一轮的 M 位值,只在目标边沿出现时导通 | |||||
| const BitReadResult read = repository.readBit(config.address); | const BitReadResult read = repository.readBit(config.address); | ||||
| if (!read.succeeded) | if (!read.succeeded) | ||||
| { | { | ||||
| @@ -430,20 +419,9 @@ LogicScanResult SoftwareLogicExecutor::evaluateCondition( | |||||
| previous_edge_inputs_[runtime_key] = read.value; | previous_edge_inputs_[runtime_key] = read.value; | ||||
| return success(); | return success(); | ||||
| } | } | ||||
| else if constexpr (std::is_same_v<Config, TimerContactNodeConfig>) | |||||
| { | |||||
| const bool done = ton_states_[config.address.index()].done; | |||||
| *value = config.mode == ContactMode::NormallyOpen ? done : !done; | |||||
| return success(); | |||||
| } | |||||
| else if constexpr (std::is_same_v<Config, CounterContactNodeConfig>) | |||||
| { | |||||
| const bool done = counter_states_[config.address.index()].done; | |||||
| *value = config.mode == ContactMode::NormallyOpen ? done : !done; | |||||
| return success(); | |||||
| } | |||||
| else if constexpr (std::is_same_v<Config, CompareNodeConfig>) | else if constexpr (std::is_same_v<Config, CompareNodeConfig>) | ||||
| { | { | ||||
| // 字比较触点读取 D 字,再按配置的比较运算符与目标值比较 | |||||
| const WordReadResult read = repository.readWord(config.address); | const WordReadResult read = repository.readWord(config.address); | ||||
| if (!read.succeeded) | if (!read.succeeded) | ||||
| { | { | ||||
| @@ -459,6 +437,7 @@ LogicScanResult SoftwareLogicExecutor::evaluateCondition( | |||||
| } | } | ||||
| else | else | ||||
| { | { | ||||
| // 输出节点只能由 executeOutput 处理,不能作为条件触点计算 | |||||
| return failure( | return failure( | ||||
| LogicScanError::InvalidLogic, | LogicScanError::InvalidLogic, | ||||
| "条件节点不能包含输出节点", | "条件节点不能包含输出节点", | ||||
| @@ -514,11 +493,9 @@ LogicScanResult SoftwareLogicExecutor::readWordOperand( | |||||
| } | } | ||||
| LogicScanResult SoftwareLogicExecutor::executeOutput( | LogicScanResult SoftwareLogicExecutor::executeOutput( | ||||
| const std::string &logic_id, | |||||
| const LogicNode &node, | const LogicNode &node, | ||||
| bool rung_value, | bool rung_value, | ||||
| RegisterRepository &repository, | RegisterRepository &repository, | ||||
| TimePoint now, | |||||
| LogicTraceValues *trace, | LogicTraceValues *trace, | ||||
| bool *output_value) | bool *output_value) | ||||
| { | { | ||||
| @@ -531,136 +508,6 @@ LogicScanResult SoftwareLogicExecutor::executeOutput( | |||||
| {}, | {}, | ||||
| node.id); | 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 (const auto *move = std::get_if<MoveNodeConfig>(&node.config)) | ||||
| { | { | ||||
| if (!rung_value) | if (!rung_value) | ||||
| @@ -3,7 +3,6 @@ | |||||
| #include "domain/control_logic_model.h" | #include "domain/control_logic_model.h" | ||||
| #include "domain/register_repository.h" | #include "domain/register_repository.h" | ||||
| #include <chrono> | |||||
| #include <cstdint> | #include <cstdint> | ||||
| #include <map> | #include <map> | ||||
| #include <string> | #include <string> | ||||
| @@ -11,68 +10,57 @@ | |||||
| #include <utility> | #include <utility> | ||||
| #include <vector> | #include <vector> | ||||
| // 一轮软件逻辑扫描可能返回的错误 | |||||
| enum class LogicScanError | enum class LogicScanError | ||||
| { | { | ||||
| None, | |||||
| InvalidLogic, | |||||
| ConflictingOutput, | |||||
| RegisterReadFailed, | |||||
| RegisterWriteFailed | |||||
| None, // 扫描成功 | |||||
| InvalidLogic, // 逻辑结构或运行配置无效 | |||||
| ConflictingOutput, // 多个输出以不兼容的线圈模式写入同一地址 | |||||
| RegisterReadFailed, // 扫描读取寄存器失败 | |||||
| RegisterWriteFailed // 扫描写入寄存器失败 | |||||
| }; | }; | ||||
| // 扫描结果;失败时携带出错的逻辑、网络和节点 ID | |||||
| struct LogicScanResult | struct LogicScanResult | ||||
| { | { | ||||
| bool succeeded = false; | |||||
| LogicScanError error = LogicScanError::None; | |||||
| std::string message; | |||||
| std::string logicId; | |||||
| std::string rungId; | |||||
| 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; | |||||
| bool succeeded = false; // 本轮扫描是否完成 | |||||
| LogicScanError error = LogicScanError::None; // 失败时的分类 | |||||
| std::string message; // UTF-8 可读结果或失败原因 | |||||
| std::string logicId; // 失败关联的控制逻辑 ID | |||||
| std::string rungId; // 失败关联的网络 ID | |||||
| std::string nodeId; // 失败关联的节点 ID | |||||
| }; | }; | ||||
| // MOVE/ADD/SUB 节点的结果值和溢出标记 | |||||
| struct WordTraceValue | struct WordTraceValue | ||||
| { | { | ||||
| std::int16_t value = 0; | |||||
| bool overflow = false; | |||||
| std::int16_t value = 0; // MOVE/ADD/SUB 的结果值 | |||||
| bool overflow = false; // ADD/SUB 是否发生饱和溢出 | |||||
| }; | }; | ||||
| // 一条控制逻辑的运行轨迹快照;各映射使用节点、表达式或网络 ID 作为键 | |||||
| struct LogicTraceValues | 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; | |||||
| 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, WordTraceValue> wordValues; // MOVE/ADD/SUB 节点状态 | |||||
| // 清除当前逻辑或网络的全部轨迹 | |||||
| void clear(); | void clear(); | ||||
| }; | }; | ||||
| // 全工程轨迹;logicValues 按逻辑 ID 隔离,避免不同逻辑中的节点 ID 互相覆盖 | |||||
| struct LogicTraceSnapshot : LogicTraceValues | struct LogicTraceSnapshot : LogicTraceValues | ||||
| { | { | ||||
| std::unordered_map<std::string, LogicTraceValues> logicValues; | |||||
| std::unordered_map<std::string, LogicTraceValues> logicValues; // 按逻辑 ID 保存的轨迹 | |||||
| // 清除全工程轨迹及按逻辑分组的轨迹 | |||||
| void clear(); | void clear(); | ||||
| // 返回指定逻辑的轨迹投影;不存在时返回空轨迹 | |||||
| LogicTraceSnapshot forLogic(const std::string &logic_id) const; | LogicTraceSnapshot forLogic(const std::string &logic_id) const; | ||||
| }; | }; | ||||
| @@ -80,35 +68,57 @@ struct LogicTraceSnapshot : LogicTraceValues | |||||
| class SoftwareLogicExecutor | class SoftwareLogicExecutor | ||||
| { | { | ||||
| public: | public: | ||||
| using Clock = std::chrono::steady_clock; | |||||
| using TimePoint = Clock::time_point; | |||||
| /** | |||||
| * @brief 检查逻辑是否满足软件扫描的运行要求 | |||||
| * @param logics 待检查的控制逻辑集合 | |||||
| * @return 成功或包含逻辑、网络、节点定位信息的失败结果 | |||||
| * | |||||
| * 检查逻辑结构、启用逻辑的运行配置和共享输出冲突 | |||||
| */ | |||||
| LogicScanResult validate(const std::vector<ControlLogic> &logics) const; | LogicScanResult validate(const std::vector<ControlLogic> &logics) const; | ||||
| /** | |||||
| * @brief 清除所有跨扫描运行状态 | |||||
| * | |||||
| * 重置边沿触发状态;不修改寄存器仓库和轨迹对象 | |||||
| */ | |||||
| void resetRuntime(); | void resetRuntime(); | ||||
| /** | |||||
| * @brief 使用当前 steady_clock 执行一轮完整扫描 | |||||
| * @param logics 按工程顺序扫描的控制逻辑集合 | |||||
| * @param repository 扫描读取和写入的寄存器仓库 | |||||
| * @param trace 可选的轨迹输出;非空时会先清空再写入本轮结果 | |||||
| * @return 扫描结果,失败时不会返回部分成功状态作为成功结果 | |||||
| */ | |||||
| LogicScanResult executeScan( | LogicScanResult executeScan( | ||||
| const std::vector<ControlLogic> &logics, | const std::vector<ControlLogic> &logics, | ||||
| RegisterRepository &repository, | RegisterRepository &repository, | ||||
| LogicTraceSnapshot *trace = nullptr); | LogicTraceSnapshot *trace = nullptr); | ||||
| LogicScanResult executeScanAt( | |||||
| const std::vector<ControlLogic> &logics, | |||||
| RegisterRepository &repository, | |||||
| TimePoint now, | |||||
| LogicTraceSnapshot *trace = nullptr); | |||||
| private: | private: | ||||
| struct TonRuntimeState | |||||
| { | |||||
| bool timing = false; | |||||
| bool done = false; | |||||
| TimePoint startedAt{}; | |||||
| std::chrono::milliseconds elapsed{0}; | |||||
| }; | |||||
| /** | |||||
| * @brief 计算单个条件节点的导通结果 | |||||
| * @param logic_id 所属控制逻辑 ID,用于隔离边沿触点的跨扫描状态 | |||||
| * @param node 待读取和计算的条件节点 | |||||
| * @param repository 提供 M/D 值的寄存器仓库 | |||||
| * @param value 输出节点导通结果,不能为空 | |||||
| * @return 成功结果,或包含节点 ID 的逻辑/寄存器读取失败结果 | |||||
| */ | |||||
| LogicScanResult evaluateCondition( | LogicScanResult evaluateCondition( | ||||
| const std::string &logic_id, | const std::string &logic_id, | ||||
| const LogicNode &node, | const LogicNode &node, | ||||
| RegisterRepository &repository, | RegisterRepository &repository, | ||||
| bool *value); | bool *value); | ||||
| /** | |||||
| * @brief 递归计算串联、并联或叶子条件表达式 | |||||
| * @param logic_id 所属控制逻辑 ID,用于传递给叶子节点状态计算 | |||||
| * @param expression 待读取的条件表达式树 | |||||
| * @param repository 提供 M/D 值的寄存器仓库 | |||||
| * @param trace 可选的轨迹输出,用于记录表达式值、电源输入和电源输出 | |||||
| * @param input_power 进入当前表达式的电源状态 | |||||
| * @param value 输出表达式最终值,不能为空 | |||||
| * @return 成功结果,或包含失败节点 ID 的扫描错误 | |||||
| */ | |||||
| LogicScanResult evaluateExpression( | LogicScanResult evaluateExpression( | ||||
| const std::string &logic_id, | const std::string &logic_id, | ||||
| const ConditionExpression &expression, | const ConditionExpression &expression, | ||||
| @@ -116,26 +126,35 @@ private: | |||||
| LogicTraceValues *trace, | LogicTraceValues *trace, | ||||
| bool input_power, | bool input_power, | ||||
| bool *value); | bool *value); | ||||
| /** | |||||
| * @brief 在网络结果驱动下执行单个输出节点 | |||||
| * @param node 待读取配置并执行的输出节点 | |||||
| * @param rung_value 当前网络的逻辑结果,决定输出是否动作 | |||||
| * @param repository 提供输出读写所需的 M/D 寄存器仓库 | |||||
| * @param trace 可选的轨迹输出,用于记录字操作结果 | |||||
| * @param output_value 输出节点最终逻辑值,不能为空 | |||||
| * @return 成功结果,或包含节点 ID 的逻辑/寄存器读写失败结果 | |||||
| */ | |||||
| LogicScanResult executeOutput( | LogicScanResult executeOutput( | ||||
| const std::string &logic_id, | |||||
| const LogicNode &node, | const LogicNode &node, | ||||
| bool rung_value, | bool rung_value, | ||||
| RegisterRepository &repository, | RegisterRepository &repository, | ||||
| TimePoint now, | |||||
| LogicTraceValues *trace, | LogicTraceValues *trace, | ||||
| bool *output_value); | bool *output_value); | ||||
| /** | |||||
| * @brief 解析并读取一个字操作数 | |||||
| * @param operand 常量或 D 寄存器操作数配置 | |||||
| * @param repository 读取寄存器操作数时使用的仓库 | |||||
| * @param value 输出解析后的 int16 数值,不能为空 | |||||
| * @param node_id 关联节点 ID,用于构造可定位的失败结果 | |||||
| * @return 常量读取成功,或 D 寄存器读取/操作数类型校验失败 | |||||
| */ | |||||
| LogicScanResult readWordOperand( | LogicScanResult readWordOperand( | ||||
| const WordOperand &operand, | const WordOperand &operand, | ||||
| RegisterRepository &repository, | RegisterRepository &repository, | ||||
| std::int16_t *value, | std::int16_t *value, | ||||
| const std::string &node_id) const; | const std::string &node_id) const; | ||||
| // 按逻辑 ID 和节点 ID 保存上一轮边沿输入 | |||||
| std::map<std::pair<std::string, std::string>, bool> previous_edge_inputs_; | 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_; | |||||
| }; | }; | ||||
| @@ -1,3 +1,11 @@ | |||||
| /** | |||||
| * @file alarm_configuration_dialog.h | |||||
| * @brief 定义报警定义配置对话框 | |||||
| * @version 1.0.0 | |||||
| * @author suyu | |||||
| * @date 2026-08-22 | |||||
| */ | |||||
| #pragma once | #pragma once | ||||
| #include <QDialog> | #include <QDialog> | ||||
| @@ -12,24 +20,37 @@ class AlarmConfigurationDialog; | |||||
| class AlarmEditorService; | class AlarmEditorService; | ||||
| struct AlarmDefinition; | struct AlarmDefinition; | ||||
| /** 报警定义编辑对话框;保存、更新和删除都委托 AlarmEditorService */ | |||||
| class AlarmConfigurationDialog final : public QDialog | class AlarmConfigurationDialog final : public QDialog | ||||
| { | { | ||||
| public: | public: | ||||
| /** 创建报警配置对话框并绑定报警编辑服务 */ | |||||
| explicit AlarmConfigurationDialog( | explicit AlarmConfigurationDialog( | ||||
| AlarmEditorService &service, | AlarmEditorService &service, | ||||
| QWidget *parent = nullptr); | QWidget *parent = nullptr); | ||||
| /** 释放 Designer 界面对象 */ | |||||
| ~AlarmConfigurationDialog() override; | ~AlarmConfigurationDialog() override; | ||||
| private: | private: | ||||
| /** 刷新左侧列表并尽量恢复原选中项 */ | |||||
| void reloadDefinitions(const std::string &selected_id = {}); | void reloadDefinitions(const std::string &selected_id = {}); | ||||
| /** 将当前选中报警加载到编辑区 */ | |||||
| void loadSelectedDefinition(); | void loadSelectedDefinition(); | ||||
| /** 根据报警类型更新条件输入项 */ | |||||
| void updateConditionOptions(); | void updateConditionOptions(); | ||||
| /** 新增一条报警定义 */ | |||||
| void addDefinition(); | void addDefinition(); | ||||
| /** 更新当前选中的报警定义 */ | |||||
| void updateDefinition(); | void updateDefinition(); | ||||
| /** 删除当前选中的报警定义 */ | |||||
| void removeDefinition(); | void removeDefinition(); | ||||
| /** 从输入框读取报警定义 */ | |||||
| AlarmDefinition definitionFromInputs() const; | AlarmDefinition definitionFromInputs() const; | ||||
| /** 返回当前选中的报警标识 */ | |||||
| std::string selectedId() const; | std::string selectedId() const; | ||||
| /** Qt Designer 生成的界面对象 */ | |||||
| std::unique_ptr<Ui::AlarmConfigurationDialog> ui_; | std::unique_ptr<Ui::AlarmConfigurationDialog> ui_; | ||||
| /** 报警定义服务,不由对话框拥有 */ | |||||
| AlarmEditorService &service_; | AlarmEditorService &service_; | ||||
| }; | }; | ||||
| @@ -13,6 +13,7 @@ | |||||
| #include <QTableWidgetItem> | #include <QTableWidgetItem> | ||||
| #include <cstdint> | #include <cstdint> | ||||
| #include <cmath> | |||||
| #include <set> | #include <set> | ||||
| namespace { | namespace { | ||||
| @@ -45,6 +46,8 @@ FreeMonitorWidget::FreeMonitorWidget( | |||||
| ui_->monitorTable->horizontalHeader()->setSectionResizeMode(3, QHeaderView::Stretch); | ui_->monitorTable->horizontalHeader()->setSectionResizeMode(3, QHeaderView::Stretch); | ||||
| ui_->monitorTable->horizontalHeader()->setSectionResizeMode(4, QHeaderView::ResizeToContents); | ui_->monitorTable->horizontalHeader()->setSectionResizeMode(4, QHeaderView::ResizeToContents); | ||||
| ui_->monitorTable->horizontalHeader()->setSectionResizeMode(5, QHeaderView::Stretch); | ui_->monitorTable->horizontalHeader()->setSectionResizeMode(5, QHeaderView::Stretch); | ||||
| ui_->dataTypeComboBox->setItemData(0, static_cast<int>(RegisterDataType::Int16)); | |||||
| ui_->dataTypeComboBox->setItemData(1, static_cast<int>(RegisterDataType::Float32)); | |||||
| ui_->monitorTable->verticalHeader()->setVisible(false); | ui_->monitorTable->verticalHeader()->setVisible(false); | ||||
| connect(ui_->addButton, &QPushButton::clicked, this, &FreeMonitorWidget::addAddresses); | connect(ui_->addButton, &QPushButton::clicked, this, &FreeMonitorWidget::addAddresses); | ||||
| connect(ui_->addressEdit, &QLineEdit::returnPressed, | connect(ui_->addressEdit, &QLineEdit::returnPressed, | ||||
| @@ -70,7 +73,12 @@ void FreeMonitorWidget::refreshValues( | |||||
| QString source = tr("数据源:未启用"); | QString source = tr("数据源:未启用"); | ||||
| bool values_available = false; | bool values_available = false; | ||||
| bool communication_fault = false; | bool communication_fault = false; | ||||
| if (mode == ApplicationMode::OfflineRunning) | |||||
| if (mode == ApplicationMode::Editing) | |||||
| { | |||||
| source = tr("数据源:离线初始值 · 虚拟 M/D"); | |||||
| values_available = true; | |||||
| } | |||||
| else if (mode == ApplicationMode::OfflineRunning) | |||||
| { | { | ||||
| source = tr("数据源:虚拟 M/D"); | source = tr("数据源:虚拟 M/D"); | ||||
| values_available = true; | values_available = true; | ||||
| @@ -103,10 +111,20 @@ void FreeMonitorWidget::refreshValues( | |||||
| ? tr("等待读取") : tr("不可用")); | ? tr("等待读取") : tr("不可用")); | ||||
| continue; | continue; | ||||
| } | } | ||||
| value_item->setText(value.address.area() == RegisterArea::M | |||||
| ? (value.bitValue ? QStringLiteral("ON") | |||||
| : QStringLiteral("OFF")) | |||||
| : QString::number(value.wordValue)); | |||||
| if (value.address.area() == RegisterArea::M) | |||||
| { | |||||
| value_item->setText(value.bitValue ? QStringLiteral("ON") | |||||
| : QStringLiteral("OFF")); | |||||
| } | |||||
| else if (value.dataType == RegisterDataType::Float32) | |||||
| { | |||||
| value_item->setText(QString::number( | |||||
| static_cast<double>(value.floatValue), 'g', 7)); | |||||
| } | |||||
| else | |||||
| { | |||||
| value_item->setText(QString::number(value.wordValue)); | |||||
| } | |||||
| state_item->setText(value.state == MonitorValueState::CommunicationFault | state_item->setText(value.state == MonitorValueState::CommunicationFault | ||||
| ? tr("通信故障,最后有效值") : tr("有效")); | ? tr("通信故障,最后有效值") : tr("有效")); | ||||
| } | } | ||||
| @@ -122,9 +140,17 @@ void FreeMonitorWidget::reloadAddresses() | |||||
| const RegisterAddress &address = addresses[index]; | const RegisterAddress &address = addresses[index]; | ||||
| const int row = static_cast<int>(index); | const int row = static_cast<int>(index); | ||||
| ui_->monitorTable->setItem(row, 0, new QTableWidgetItem(addressText(address))); | ui_->monitorTable->setItem(row, 0, new QTableWidgetItem(addressText(address))); | ||||
| const MonitorPoint &point = service_.points().at(index); | |||||
| ui_->monitorTable->setItem( | ui_->monitorTable->setItem( | ||||
| row, 1, new QTableWidgetItem(address.area() == RegisterArea::M | row, 1, new QTableWidgetItem(address.area() == RegisterArea::M | ||||
| ? tr("位") : tr("16 位整数"))); | |||||
| ? tr("位") | |||||
| : point.dataType == RegisterDataType::Float32 | |||||
| ? tr("Float32 %1 (%1~%2)") | |||||
| .arg(addressText(address), | |||||
| addressText(RegisterAddress{ | |||||
| RegisterArea::D, | |||||
| point.address.index() + 1})) | |||||
| : tr("Int16"))); | |||||
| ui_->monitorTable->setItem(row, 2, new QTableWidgetItem(QStringLiteral("--"))); | ui_->monitorTable->setItem(row, 2, new QTableWidgetItem(QStringLiteral("--"))); | ||||
| if (address.area() == RegisterArea::M) | if (address.area() == RegisterArea::M) | ||||
| { | { | ||||
| @@ -136,7 +162,14 @@ void FreeMonitorWidget::reloadAddresses() | |||||
| else | else | ||||
| { | { | ||||
| auto *target = new QLineEdit(ui_->monitorTable); | auto *target = new QLineEdit(ui_->monitorTable); | ||||
| target->setValidator(new QIntValidator(-32768, 32767, target)); | |||||
| if (point.dataType == RegisterDataType::Float32) | |||||
| { | |||||
| target->setPlaceholderText(tr("小数或科学计数法")); | |||||
| } | |||||
| else | |||||
| { | |||||
| target->setValidator(new QIntValidator(-32768, 32767, target)); | |||||
| } | |||||
| target->setText(QStringLiteral("0")); | target->setText(QStringLiteral("0")); | ||||
| target->setAlignment(Qt::AlignRight | Qt::AlignVCenter); | target->setAlignment(Qt::AlignRight | Qt::AlignVCenter); | ||||
| ui_->monitorTable->setCellWidget(row, 3, target); | ui_->monitorTable->setCellWidget(row, 3, target); | ||||
| @@ -162,7 +195,8 @@ void FreeMonitorWidget::writeRow(int row) | |||||
| return; | return; | ||||
| } | } | ||||
| const RegisterAddress address = service_.addresses().at(static_cast<std::size_t>(row)); | |||||
| const MonitorPoint point = service_.points().at(static_cast<std::size_t>(row)); | |||||
| const RegisterAddress address = point.address; | |||||
| RegisterMonitorWriteResult result; | RegisterMonitorWriteResult result; | ||||
| if (address.area() == RegisterArea::M) | if (address.area() == RegisterArea::M) | ||||
| { | { | ||||
| @@ -181,14 +215,20 @@ void FreeMonitorWidget::writeRow(int row) | |||||
| return; | return; | ||||
| } | } | ||||
| bool converted = false; | bool converted = false; | ||||
| const int value = target->text().toInt(&converted); | |||||
| if (!converted || value < -32768 || value > 32767) | |||||
| const double value = target->text().toDouble(&converted); | |||||
| if (!converted || !std::isfinite(value) | |||||
| || (point.dataType == RegisterDataType::Int16 | |||||
| && (value < -32768 || value > 32767 || std::trunc(value) != value))) | |||||
| { | { | ||||
| ui_->monitorTable->item(row, 5)->setText(tr("目标值无效")); | ui_->monitorTable->item(row, 5)->setText(tr("目标值无效")); | ||||
| emit operationMessage(tr("写入 D 区地址失败:请输入 -32768~32767 的整数")); | |||||
| emit operationMessage(point.dataType == RegisterDataType::Float32 | |||||
| ? tr("写入 D 区地址失败:请输入有限 Float32 数值") | |||||
| : tr("写入 D 区地址失败:请输入 -32768~32767 的整数")); | |||||
| return; | return; | ||||
| } | } | ||||
| result = service_.writeWord(address, static_cast<std::int16_t>(value)); | |||||
| result = point.dataType == RegisterDataType::Float32 | |||||
| ? service_.writeFloat(address, static_cast<float>(value)) | |||||
| : service_.writeWord(address, static_cast<std::int16_t>(value)); | |||||
| } | } | ||||
| QTableWidgetItem *state_item = ui_->monitorTable->item(row, 5); | QTableWidgetItem *state_item = ui_->monitorTable->item(row, 5); | ||||
| @@ -225,13 +265,20 @@ void FreeMonitorWidget::addAddresses() | |||||
| const QByteArray address = ui_->addressEdit->text().toUtf8(); | const QByteArray address = ui_->addressEdit->text().toUtf8(); | ||||
| const RegisterMonitorResult result = service_.addRange( | const RegisterMonitorResult result = service_.addRange( | ||||
| std::string(address.constData(), static_cast<std::size_t>(address.size())), | std::string(address.constData(), static_cast<std::size_t>(address.size())), | ||||
| ui_->countSpinBox->value()); | |||||
| ui_->countSpinBox->value(), | |||||
| static_cast<RegisterDataType>(ui_->dataTypeComboBox->currentData().toInt())); | |||||
| if (result.succeeded) | if (result.succeeded) | ||||
| { | { | ||||
| reloadAddresses(); | reloadAddresses(); | ||||
| ui_->addressEdit->selectAll(); | ui_->addressEdit->selectAll(); | ||||
| emit monitorAddressesChanged(); | emit monitorAddressesChanged(); | ||||
| emit operationMessage(tr("已添加 %1 个监控地址").arg(result.affectedCount)); | |||||
| const QString message = fromUtf8(result.message); | |||||
| emit operationMessage( | |||||
| message.isEmpty() | |||||
| ? tr("已添加 %1 个监控点").arg(result.affectedCount) | |||||
| : tr("已添加 %1 个监控点;%2") | |||||
| .arg(result.affectedCount) | |||||
| .arg(message)); | |||||
| return; | return; | ||||
| } | } | ||||
| handleResult(tr("添加监控地址"), false, result.message); | handleResult(tr("添加监控地址"), false, result.message); | ||||
| @@ -244,16 +291,16 @@ void FreeMonitorWidget::removeSelectedAddresses() | |||||
| { | { | ||||
| rows.insert(index.row()); | rows.insert(index.row()); | ||||
| } | } | ||||
| std::vector<RegisterAddress> addresses; | |||||
| const std::vector<RegisterAddress> &all_addresses = service_.addresses(); | |||||
| std::vector<MonitorPoint> points; | |||||
| const std::vector<MonitorPoint> &all_points = service_.points(); | |||||
| for (int row : rows) | for (int row : rows) | ||||
| { | { | ||||
| if (row >= 0 && static_cast<std::size_t>(row) < all_addresses.size()) | |||||
| if (row >= 0 && static_cast<std::size_t>(row) < all_points.size()) | |||||
| { | { | ||||
| addresses.push_back(all_addresses.at(static_cast<std::size_t>(row))); | |||||
| points.push_back(all_points.at(static_cast<std::size_t>(row))); | |||||
| } | } | ||||
| } | } | ||||
| const RegisterMonitorResult result = service_.remove(addresses); | |||||
| const RegisterMonitorResult result = service_.remove(points); | |||||
| if (result.succeeded) | if (result.succeeded) | ||||
| { | { | ||||
| reloadAddresses(); | reloadAddresses(); | ||||
| @@ -1,3 +1,11 @@ | |||||
| /** | |||||
| * @file free_monitor_widget.h | |||||
| * @brief 定义自由寄存器监控控件 | |||||
| * @version 1.0.0 | |||||
| * @author suyu | |||||
| * @date 2026-08-22 | |||||
| */ | |||||
| #pragma once | #pragma once | ||||
| #include "domain/runtime_state.h" | #include "domain/runtime_state.h" | ||||
| @@ -13,33 +21,50 @@ class FreeMonitorWidget; | |||||
| class RegisterMonitorService; | class RegisterMonitorService; | ||||
| /** 自由监控 UI:地址列表属于当前会话,读写统一委托 RegisterMonitorService */ | |||||
| class FreeMonitorWidget final : public QWidget | class FreeMonitorWidget final : public QWidget | ||||
| { | { | ||||
| Q_OBJECT | Q_OBJECT | ||||
| public: | public: | ||||
| /** 创建自由监控控件并绑定监控服务 */ | |||||
| explicit FreeMonitorWidget( | explicit FreeMonitorWidget( | ||||
| RegisterMonitorService &service, | RegisterMonitorService &service, | ||||
| QWidget *parent = nullptr); | QWidget *parent = nullptr); | ||||
| /** 释放 Designer 界面对象 */ | |||||
| ~FreeMonitorWidget() override; | ~FreeMonitorWidget() override; | ||||
| /** 根据当前模式和通信状态开启或关闭写入入口 */ | |||||
| void setWriteEnabled(bool enabled); | void setWriteEnabled(bool enabled); | ||||
| /** 根据运行模式和 PLC 状态刷新所有监控值 */ | |||||
| void refreshValues(ApplicationMode mode, PlcConnectionState plc_state); | void refreshValues(ApplicationMode mode, PlcConnectionState plc_state); | ||||
| /** 从服务重新加载当前会话中的地址列表 */ | |||||
| void reloadAddresses(); | void reloadAddresses(); | ||||
| signals: | signals: | ||||
| /** 监控地址列表发生变化时发出 */ | |||||
| void monitorAddressesChanged(); | void monitorAddressesChanged(); | ||||
| /** 操作完成后发出可直接显示的消息 */ | |||||
| void operationMessage(const QString &message); | void operationMessage(const QString &message); | ||||
| private: | private: | ||||
| /** 从输入框添加一个或多个监控地址 */ | |||||
| void addAddresses(); | void addAddresses(); | ||||
| /** 删除当前选中的监控地址 */ | |||||
| void removeSelectedAddresses(); | void removeSelectedAddresses(); | ||||
| /** 清空当前会话中的监控地址 */ | |||||
| void clearAddresses(); | void clearAddresses(); | ||||
| /** 写入指定行中的寄存器值 */ | |||||
| void writeRow(int row); | void writeRow(int row); | ||||
| /** 根据当前权限刷新写入控件状态 */ | |||||
| void updateWriteControls(); | void updateWriteControls(); | ||||
| /** 统一处理监控服务返回结果 */ | |||||
| void handleResult(const QString &action, bool succeeded, const std::string &message); | void handleResult(const QString &action, bool succeeded, const std::string &message); | ||||
| /** Qt Designer 生成的界面对象 */ | |||||
| std::unique_ptr<Ui::FreeMonitorWidget> ui_; | std::unique_ptr<Ui::FreeMonitorWidget> ui_; | ||||
| /** 寄存器监控服务,不由控件拥有 */ | |||||
| RegisterMonitorService &service_; | RegisterMonitorService &service_; | ||||
| /** 当前是否允许执行寄存器写入 */ | |||||
| bool write_enabled_ = false; | bool write_enabled_ = false; | ||||
| }; | }; | ||||
| @@ -15,13 +15,15 @@ | |||||
| <layout class="QGridLayout" name="commandLayout"> | <layout class="QGridLayout" name="commandLayout"> | ||||
| <item row="0" column="0"><widget class="QLabel" name="addressLabel"><property name="text"><string>起始地址</string></property></widget></item> | <item row="0" column="0"><widget class="QLabel" name="addressLabel"><property name="text"><string>起始地址</string></property></widget></item> | ||||
| <item row="0" column="1"><widget class="QLineEdit" name="addressEdit"><property name="minimumSize"><size><width>90</width><height>0</height></size></property><property name="maximumSize"><size><width>160</width><height>16777215</height></size></property><property name="placeholderText"><string>M0 或 D0</string></property><property name="clearButtonEnabled"><bool>true</bool></property></widget></item> | <item row="0" column="1"><widget class="QLineEdit" name="addressEdit"><property name="minimumSize"><size><width>90</width><height>0</height></size></property><property name="maximumSize"><size><width>160</width><height>16777215</height></size></property><property name="placeholderText"><string>M0 或 D0</string></property><property name="clearButtonEnabled"><bool>true</bool></property></widget></item> | ||||
| <item row="0" column="2"><widget class="QLabel" name="countLabel"><property name="text"><string>连续个数</string></property></widget></item> | |||||
| <item row="0" column="3"><widget class="QSpinBox" name="countSpinBox"><property name="minimum"><number>1</number></property><property name="maximum"><number>64</number></property><property name="value"><number>1</number></property></widget></item> | |||||
| <item row="0" column="4"><widget class="QPushButton" name="addButton"><property name="text"><string>添加</string></property></widget></item> | |||||
| <item row="0" column="5"><spacer name="firstRowSpacer"><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 row="0" column="2"><widget class="QLabel" name="dataTypeLabel"><property name="text"><string>类型</string></property></widget></item> | |||||
| <item row="0" column="3"><widget class="QComboBox" name="dataTypeComboBox"><item><property name="text"><string>Int16</string></property></item><item><property name="text"><string>Float32</string></property></item></widget></item> | |||||
| <item row="0" column="4"><widget class="QLabel" name="countLabel"><property name="text"><string>连续个数</string></property></widget></item> | |||||
| <item row="0" column="5"><widget class="QSpinBox" name="countSpinBox"><property name="minimum"><number>1</number></property><property name="maximum"><number>64</number></property><property name="value"><number>1</number></property></widget></item> | |||||
| <item row="0" column="6"><widget class="QPushButton" name="addButton"><property name="text"><string>添加</string></property></widget></item> | |||||
| <item row="0" column="7"><spacer name="firstRowSpacer"><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 row="1" column="0"><widget class="QToolButton" name="removeButton"><property name="toolTip"><string>删除选中的监控地址</string></property><property name="text"><string>删除</string></property></widget></item> | <item row="1" column="0"><widget class="QToolButton" name="removeButton"><property name="toolTip"><string>删除选中的监控地址</string></property><property name="text"><string>删除</string></property></widget></item> | ||||
| <item row="1" column="1"><widget class="QToolButton" name="clearButton"><property name="toolTip"><string>清空监控表</string></property><property name="text"><string>清空</string></property></widget></item> | <item row="1" column="1"><widget class="QToolButton" name="clearButton"><property name="toolTip"><string>清空监控表</string></property><property name="text"><string>清空</string></property></widget></item> | ||||
| <item row="1" column="2" colspan="4"><widget class="QLabel" name="sourceLabel"><property name="text"><string>数据源:未启用</string></property><property name="alignment"><set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set></property></widget></item> | |||||
| <item row="1" column="2" colspan="6"><widget class="QLabel" name="sourceLabel"><property name="text"><string>数据源:未启用</string></property><property name="alignment"><set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set></property></widget></item> | |||||
| </layout> | </layout> | ||||
| </item> | </item> | ||||
| <item> | <item> | ||||
| @@ -194,48 +194,6 @@ public: | |||||
| textWithValue()); | textWithValue()); | ||||
| break; | break; | ||||
| } | } | ||||
| case HmiControlType::ProgressBar: | |||||
| { | |||||
| const HmiProgressBarConfig config = control_.progressBar.value_or( | |||||
| HmiProgressBarConfig{}); | |||||
| const int percentage = has_runtime_value_ | |||||
| ? config.percentageForValue(word_value_) : 0; | |||||
| painter->setPen(Qt::NoPen); | |||||
| painter->setBrush(QColor(QStringLiteral("#e4e9e7"))); | |||||
| painter->drawRoundedRect(rect, 4, 4); | |||||
| const QRectF track = rect.adjusted(2, 2, -2, -2); | |||||
| if (has_runtime_value_ && percentage > 0) | |||||
| { | |||||
| QRectF fill = track; | |||||
| fill.setWidth(track.width() * percentage / 100.0); | |||||
| painter->setBrush(QColor(QStringLiteral("#3c8c62"))); | |||||
| painter->drawRoundedRect(fill, 3, 3); | |||||
| } | |||||
| painter->setBrush(Qt::NoBrush); | |||||
| painter->setPen(QPen(QColor(QStringLiteral("#617069")), 1)); | |||||
| painter->drawRoundedRect(rect, 4, 4); | |||||
| QString display_text = QString::fromUtf8( | |||||
| control_.text.data(), static_cast<int>(control_.text.size())); | |||||
| if (config.showValue) | |||||
| { | |||||
| const QString percentage_text = has_runtime_value_ | |||||
| ? QString::number(percentage) + QLatin1Char('%') | |||||
| : runtime_active_ ? QStringLiteral("--") | |||||
| : QStringLiteral("0%"); | |||||
| display_text = display_text.isEmpty() | |||||
| ? percentage_text | |||||
| : display_text + QStringLiteral(": ") + percentage_text; | |||||
| } | |||||
| painter->setPen(configuredTextColor(QColor(QStringLiteral("#20342a")))); | |||||
| painter->drawText(rect.adjusted(6, 0, -6, 0), | |||||
| Qt::AlignCenter, | |||||
| display_text); | |||||
| break; | |||||
| } | |||||
| case HmiControlType::PageJump: | case HmiControlType::PageJump: | ||||
| { | { | ||||
| const QColor fill = runtime_active_ && page_hovered_ | const QColor fill = runtime_active_ && page_hovered_ | ||||
| @@ -459,10 +417,12 @@ public: | |||||
| } | } | ||||
| // 缓存运行服务读出的值并触发 Qt 重绘 | // 缓存运行服务读出的值并触发 Qt 重绘 | ||||
| void setRuntimeValue(bool bit_value, std::int16_t word_value, bool available) | |||||
| void setRuntimeValue( | |||||
| bool bit_value, std::int16_t word_value, float float_value, bool available) | |||||
| { | { | ||||
| bit_value_ = bit_value; | bit_value_ = bit_value; | ||||
| word_value_ = word_value; | word_value_ = word_value; | ||||
| float_value_ = float_value; | |||||
| has_runtime_value_ = available; | has_runtime_value_ = available; | ||||
| update(); | update(); | ||||
| } | } | ||||
| @@ -837,7 +797,10 @@ private: | |||||
| if (control_.type == HmiControlType::NumericDisplay | if (control_.type == HmiControlType::NumericDisplay | ||||
| || control_.type == HmiControlType::NumericInput) | || control_.type == HmiControlType::NumericInput) | ||||
| { | { | ||||
| return text + QStringLiteral(": ") + QString::number(word_value_); | |||||
| const QString value = control_.dataType == RegisterDataType::Float32 | |||||
| ? QString::number(float_value_, 'g', 7) | |||||
| : QString::number(word_value_); | |||||
| return text + QStringLiteral(": ") + value; | |||||
| } | } | ||||
| return text; | return text; | ||||
| } | } | ||||
| @@ -867,6 +830,7 @@ private: | |||||
| bool bit_value_ = false; | bool bit_value_ = false; | ||||
| // 数值控件读取到的 D 字值 | // 数值控件读取到的 D 字值 | ||||
| std::int16_t word_value_ = 0; | std::int16_t word_value_ = 0; | ||||
| float float_value_ = 0.0f; | |||||
| // 标记当前缓存值是否来自一次成功的运行时读取 | // 标记当前缓存值是否来自一次成功的运行时读取 | ||||
| bool has_runtime_value_ = false; | bool has_runtime_value_ = false; | ||||
| }; | }; | ||||
| @@ -902,6 +866,7 @@ HmiEditorWidget::HmiEditorWidget( | |||||
| // 切换显示页面 | // 切换显示页面 | ||||
| void HmiEditorWidget::setPageId(const std::string &page_id) | void HmiEditorWidget::setPageId(const std::string &page_id) | ||||
| { | { | ||||
| // 切换页面只改变投影上下文,真正的页面数据仍由 ProjectService 持有 | |||||
| if (page_id_ == page_id) | if (page_id_ == page_id) | ||||
| { | { | ||||
| return; | return; | ||||
| @@ -928,7 +893,7 @@ void HmiEditorWidget::setRuntimeActive(bool active) | |||||
| HmiGraphicsItem *control_item = asHmiItem(item); | HmiGraphicsItem *control_item = asHmiItem(item); | ||||
| if (control_item != nullptr) | if (control_item != nullptr) | ||||
| { | { | ||||
| control_item->setRuntimeValue(false, 0, false); | |||||
| control_item->setRuntimeValue(false, 0, 0.0f, false); | |||||
| control_item->setAlarmRecords({}); | control_item->setAlarmRecords({}); | ||||
| } | } | ||||
| } | } | ||||
| @@ -945,6 +910,7 @@ void HmiEditorWidget::setRuntimeWriteEnabled(bool enabled) | |||||
| // 全部重新加载当前页面,把内存模型 HmiPage 渲染成画面上图形 | // 全部重新加载当前页面,把内存模型 HmiPage 渲染成画面上图形 | ||||
| void HmiEditorWidget::reloadPage() | void HmiEditorWidget::reloadPage() | ||||
| { | { | ||||
| // 模型发生变化后完全重建图元,避免增量刷新遗漏属性或选择状态 | |||||
| // 画布始终从当前领域页面重建,避免保留已删除控件的图元 | // 画布始终从当前领域页面重建,避免保留已删除控件的图元 | ||||
| scene_->clear(); | scene_->clear(); | ||||
| const HmiPage *page = editor_service_.findPage(page_id_); | const HmiPage *page = editor_service_.findPage(page_id_); | ||||
| @@ -1052,6 +1018,7 @@ std::vector<std::string> HmiEditorWidget::selectedControlIds() const | |||||
| void HmiEditorWidget::refreshRuntimeValues() | void HmiEditorWidget::refreshRuntimeValues() | ||||
| { | { | ||||
| // 运行态才读取寄存器;编辑态画布只显示工程配置,不显示缓存值 | |||||
| if (!runtime_active_) | if (!runtime_active_) | ||||
| { | { | ||||
| return; | return; | ||||
| @@ -1076,7 +1043,8 @@ void HmiEditorWidget::refreshRuntimeValues() | |||||
| } | } | ||||
| // 运行值通过服务读取,图元不直接接触寄存器仓库 | // 运行值通过服务读取,图元不直接接触寄存器仓库 | ||||
| const HmiRuntimeReadResult value = runtime_service_.readControl(*control); | const HmiRuntimeReadResult value = runtime_service_.readControl(*control); | ||||
| control_item->setRuntimeValue(value.bit_value, value.word_value, value.succeeded); | |||||
| control_item->setRuntimeValue( | |||||
| value.bit_value, value.word_value, value.float_value, value.succeeded); | |||||
| } | } | ||||
| } | } | ||||
| @@ -1119,6 +1087,7 @@ void HmiEditorWidget::handleSelectionChanged() | |||||
| void HmiEditorWidget::handleControlMoved( | void HmiEditorWidget::handleControlMoved( | ||||
| const std::string &control_id, const QPointF &position) | const std::string &control_id, const QPointF &position) | ||||
| { | { | ||||
| // 拖动结束后把坐标交给服务层,由服务层负责页面边界校验和历史记录 | |||||
| const HmiControl *control = editor_service_.findControl(page_id_, control_id); | const HmiControl *control = editor_service_.findControl(page_id_, control_id); | ||||
| if (control == nullptr) | if (control == nullptr) | ||||
| { | { | ||||
| @@ -1146,6 +1115,7 @@ void HmiEditorWidget::handleButtonEvent( | |||||
| { | { | ||||
| return; | return; | ||||
| } | } | ||||
| // 运行态按钮事件只通过 HmiRuntimeService 写寄存器,不直接访问仓库 | |||||
| const HmiControl *control = editor_service_.findControl(page_id_, control_id); | const HmiControl *control = editor_service_.findControl(page_id_, control_id); | ||||
| if (control == nullptr) | if (control == nullptr) | ||||
| { | { | ||||
| @@ -1177,22 +1147,39 @@ void HmiEditorWidget::handleNumericInputActivated(const std::string &control_id) | |||||
| } | } | ||||
| bool accepted = false; | bool accepted = false; | ||||
| const HmiRuntimeReadResult current = runtime_service_.readControl(*control); | const HmiRuntimeReadResult current = runtime_service_.readControl(*control); | ||||
| const int initial = current.succeeded ? current.word_value : 0; | |||||
| const int value = QInputDialog::getInt( | |||||
| this, | |||||
| tr("输入数值"), | |||||
| QString::fromUtf8(control->text.data(), static_cast<int>(control->text.size())), | |||||
| initial, | |||||
| std::numeric_limits<std::int16_t>::min(), | |||||
| std::numeric_limits<std::int16_t>::max(), | |||||
| 1, | |||||
| &accepted); | |||||
| double value = 0.0; | |||||
| if (control->dataType == RegisterDataType::Float32) | |||||
| { | |||||
| value = QInputDialog::getDouble( | |||||
| this, | |||||
| tr("输入 Float32"), | |||||
| QString::fromUtf8( | |||||
| control->text.data(), static_cast<int>(control->text.size())), | |||||
| current.succeeded ? static_cast<double>(current.float_value) : 0.0, | |||||
| -static_cast<double>(std::numeric_limits<float>::max()), | |||||
| static_cast<double>(std::numeric_limits<float>::max()), | |||||
| 7, | |||||
| &accepted); | |||||
| } | |||||
| else | |||||
| { | |||||
| value = QInputDialog::getInt( | |||||
| this, | |||||
| tr("输入 Int16"), | |||||
| QString::fromUtf8( | |||||
| control->text.data(), static_cast<int>(control->text.size())), | |||||
| current.succeeded ? current.word_value : 0, | |||||
| std::numeric_limits<std::int16_t>::min(), | |||||
| std::numeric_limits<std::int16_t>::max(), | |||||
| 1, | |||||
| &accepted); | |||||
| } | |||||
| if (!accepted) | if (!accepted) | ||||
| { | { | ||||
| return; | return; | ||||
| } | } | ||||
| const HmiRuntimeWriteResult result = runtime_service_.writeNumericInput( | const HmiRuntimeWriteResult result = runtime_service_.writeNumericInput( | ||||
| *control, static_cast<std::int16_t>(value)); | |||||
| *control, value); | |||||
| if (!result.succeeded) | if (!result.succeeded) | ||||
| { | { | ||||
| emit editorError(tr("寄存器操作失败")); | emit editorError(tr("寄存器操作失败")); | ||||
| @@ -1,6 +1,7 @@ | |||||
| /** | /** | ||||
| * @file hmi_editor_widget.h | * @file hmi_editor_widget.h | ||||
| * @brief 定义基于 QGraphicsScene 的 HMI 页面编辑和运行画布 | * @brief 定义基于 QGraphicsScene 的 HMI 页面编辑和运行画布 | ||||
| * @version 1.0.0 | |||||
| * @author suyu | * @author suyu | ||||
| * @date 2026-08-10 | * @date 2026-08-10 | ||||
| */ | */ | ||||
| @@ -56,7 +57,7 @@ public: | |||||
| * @param active 为 true 时允许按钮和数值输入调用运行时服务 | * @param active 为 true 时允许按钮和数值输入调用运行时服务 | ||||
| */ | */ | ||||
| void setRuntimeActive(bool active); | void setRuntimeActive(bool active); | ||||
| // 故障态保持运行值可见,但禁止按钮和数值输入继续写入 | |||||
| /** 故障态保持运行值可见,但禁止按钮和数值输入继续写入 */ | |||||
| void setRuntimeWriteEnabled(bool enabled); | void setRuntimeWriteEnabled(bool enabled); | ||||
| /** | /** | ||||
| * @brief 使用当前页面模型完全重建场景 | * @brief 使用当前页面模型完全重建场景 | ||||
| @@ -69,7 +70,9 @@ public: | |||||
| * @param control_id 待选中控件标识,不存在时不改变当前选择 | * @param control_id 待选中控件标识,不存在时不改变当前选择 | ||||
| */ | */ | ||||
| void selectControl(const std::string &control_id); | void selectControl(const std::string &control_id); | ||||
| /** 返回当前选中的单个控件标识 */ | |||||
| std::string selectedControlId() const; | std::string selectedControlId() const; | ||||
| /** 返回当前选中的多个控件标识 */ | |||||
| std::vector<std::string> selectedControlIds() const; | std::vector<std::string> selectedControlIds() const; | ||||
| /** | /** | ||||
| * @brief 从统一寄存器仓库读取当前值并刷新画布显示 | * @brief 从统一寄存器仓库读取当前值并刷新画布显示 | ||||
| @@ -94,6 +97,7 @@ signals: | |||||
| * @param message 可直接显示的错误信息 | * @param message 可直接显示的错误信息 | ||||
| */ | */ | ||||
| void editorError(const QString &message); | void editorError(const QString &message); | ||||
| /** 运行态请求跳转到指定 HMI 页面 */ | |||||
| void pageNavigationRequested(const QString &target_page_id); | void pageNavigationRequested(const QString &target_page_id); | ||||
| protected: | protected: | ||||
| @@ -114,18 +118,20 @@ private: | |||||
| // 在运行态处理数值输入激活 | // 在运行态处理数值输入激活 | ||||
| void handleNumericInputActivated(const std::string &control_id); | void handleNumericInputActivated(const std::string &control_id); | ||||
| // 提供控件查找、移动和属性更新能力,不直接操作 Qt 图元数据 | |||||
| /** 提供控件查找、移动和属性更新能力,不直接操作 Qt 图元数据 */ | |||||
| HmiEditorService &editor_service_; | HmiEditorService &editor_service_; | ||||
| // 提供运行态寄存器读写能力,画布不直接访问寄存器仓库 | |||||
| /** 提供运行态寄存器读写能力,画布不直接访问寄存器仓库 */ | |||||
| HmiRuntimeService &runtime_service_; | HmiRuntimeService &runtime_service_; | ||||
| /** 报警查询服务,不由控件拥有 */ | |||||
| AlarmService &alarm_service_; | AlarmService &alarm_service_; | ||||
| // 持有所有页面图元和页面边框 | |||||
| /** 持有所有页面图元和页面边框 */ | |||||
| QGraphicsScene *scene_ = nullptr; | QGraphicsScene *scene_ = nullptr; | ||||
| // 当前画布投影的页面标识 | |||||
| /** 当前画布投影的页面标识 */ | |||||
| std::string page_id_; | std::string page_id_; | ||||
| // 控制是否允许拖动和编辑控件 | |||||
| /** 控制是否允许拖动和编辑控件 */ | |||||
| bool editing_enabled_ = true; | bool editing_enabled_ = true; | ||||
| // 控制是否刷新运行值并响应运行态控件操作 | |||||
| /** 控制是否刷新运行值并响应运行态控件操作 */ | |||||
| bool runtime_active_ = false; | bool runtime_active_ = false; | ||||
| /** 控制运行态按钮和数值输入是否允许写入 */ | |||||
| bool runtime_write_enabled_ = false; | bool runtime_write_enabled_ = false; | ||||
| }; | }; | ||||
| @@ -1,6 +1,15 @@ | |||||
| /** | |||||
| * @file logic_editor_widget.h | |||||
| * @brief 定义结构化梯形图编辑和运行轨迹画布 | |||||
| * @version 1.0.0 | |||||
| * @author suyu | |||||
| * @date 2026-08-22 | |||||
| */ | |||||
| #pragma once | #pragma once | ||||
| #include "domain/control_logic_model.h" | #include "domain/control_logic_model.h" | ||||
| #include "services/logic_command_service.h" | |||||
| #include "services/logic_editor_service.h" | #include "services/logic_editor_service.h" | ||||
| #include "services/software_logic_executor.h" | #include "services/software_logic_executor.h" | ||||
| @@ -11,73 +20,187 @@ | |||||
| #include <vector> | #include <vector> | ||||
| class QGraphicsScene; | class QGraphicsScene; | ||||
| class QCompleter; | |||||
| class QEvent; | |||||
| class QLineEdit; | |||||
| class QMouseEvent; | |||||
| class QRubberBand; | |||||
| class QResizeEvent; | class QResizeEvent; | ||||
| /** 将结构化梯形图表达式投影成网格图元;不保存自由线段,编辑通过服务提交 */ | |||||
| class LogicEditorWidget final : public QGraphicsView | class LogicEditorWidget final : public QGraphicsView | ||||
| { | { | ||||
| Q_OBJECT | Q_OBJECT | ||||
| public: | public: | ||||
| /** 梯形图中的条件、输出、横线和辅助选择图元类型 */ | |||||
| class NodeItem; | class NodeItem; | ||||
| class WireItem; | class WireItem; | ||||
| class WireCellItem; | class WireCellItem; | ||||
| class GapCellItem; | |||||
| class VerticalConnectorItem; | class VerticalConnectorItem; | ||||
| class RungItem; | class RungItem; | ||||
| class EmptySlotItem; | class EmptySlotItem; | ||||
| class OutputSlotItem; | |||||
| /** 创建梯形图画布并绑定编辑服务 */ | |||||
| explicit LogicEditorWidget( | explicit LogicEditorWidget( | ||||
| LogicEditorService &editor_service, | LogicEditorService &editor_service, | ||||
| QWidget *parent = nullptr); | QWidget *parent = nullptr); | ||||
| /** 切换当前显示的控制逻辑;不存在时显示空场景 */ | |||||
| void setLogicId(const std::string &logic_id); | void setLogicId(const std::string &logic_id); | ||||
| /** 设置是否允许编辑梯形图 */ | |||||
| void setEditingEnabled(bool enabled); | void setEditingEnabled(bool enabled); | ||||
| /** 设置离线运行轨迹;真机模式应传入空轨迹,避免显示本地伪轨迹 */ | |||||
| void setRuntimeTrace( | void setRuntimeTrace( | ||||
| const LogicTraceSnapshot &trace, | const LogicTraceSnapshot &trace, | ||||
| const std::string &fault_node_id = {}); | const std::string &fault_node_id = {}); | ||||
| /** 清除当前运行轨迹和故障节点标记 */ | |||||
| void clearRuntimeTrace(); | void clearRuntimeTrace(); | ||||
| /** 返回当前是否正在显示运行轨迹 */ | |||||
| bool runtimeTraceEnabled() const; | |||||
| /** 根据当前逻辑模型重建梯形图场景 */ | |||||
| void reloadLogic(); | void reloadLogic(); | ||||
| /** 选中指定节点并滚动到可见区域 */ | |||||
| void selectNode(const std::string &node_id); | void selectNode(const std::string &node_id); | ||||
| /** 返回当前选中的单个节点标识 */ | |||||
| std::string selectedNodeId() const; | std::string selectedNodeId() const; | ||||
| /** 返回当前选中的多个节点标识 */ | |||||
| std::vector<std::string> selectedNodeIds() const; | std::vector<std::string> selectedNodeIds() const; | ||||
| /** 返回当前选中的网络标识 */ | |||||
| std::string selectedRungId() const; | std::string selectedRungId() const; | ||||
| /** 新增一个空的梯形图网络 */ | |||||
| LogicEditorResult addRung(); | LogicEditorResult addRung(); | ||||
| /** 将当前选择转换为服务调用并新增串联条件 */ | |||||
| LogicEditorResult addCondition(const LogicNodeConfig &config); | LogicEditorResult addCondition(const LogicNodeConfig &config); | ||||
| /** 将当前选择转换为服务调用并新增并联支路 */ | |||||
| LogicEditorResult addParallelBranch(const LogicNodeConfig &config); | LogicEditorResult addParallelBranch(const LogicNodeConfig &config); | ||||
| /** 在当前选择位置新增横线 */ | |||||
| LogicEditorResult addHorizontalWire(); | LogicEditorResult addHorizontalWire(); | ||||
| /** 在当前选择位置新增竖线 */ | |||||
| LogicEditorResult addVerticalWire(); | LogicEditorResult addVerticalWire(); | ||||
| /** 删除当前选择位置的横线 */ | |||||
| LogicEditorResult deleteHorizontalWire(); | LogicEditorResult deleteHorizontalWire(); | ||||
| /** 删除当前选择位置的竖线 */ | |||||
| LogicEditorResult deleteVerticalWire(); | LogicEditorResult deleteVerticalWire(); | ||||
| /** 设置当前网络的输出线圈 */ | |||||
| LogicEditorResult setOutput( | LogicEditorResult setOutput( | ||||
| const LogicNodeConfig &config, bool configured = false); | const LogicNodeConfig &config, bool configured = false); | ||||
| /** 按当前画布选择位置粘贴一组连续条件 */ | |||||
| LogicEditorResult pasteConditionNodes(const std::vector<LogicNode> &nodes); | |||||
| /** 删除当前选中的节点、线段或网络 */ | |||||
| LogicEditorResult deleteSelected(); | LogicEditorResult deleteSelected(); | ||||
| signals: | signals: | ||||
| /** 当前选中的节点发生变化时发出 */ | |||||
| void nodeSelected(const QString &node_id); | void nodeSelected(const QString &node_id); | ||||
| /** 梯形图模型成功发生变化时发出 */ | |||||
| void graphChanged(); | void graphChanged(); | ||||
| /** 编辑服务操作失败时发出可显示的错误信息 */ | |||||
| void editorError(const QString &message); | void editorError(const QString &message); | ||||
| protected: | protected: | ||||
| /** 在任意网络区域开始鼠标框选 */ | |||||
| void mousePressEvent(QMouseEvent *event) override; | |||||
| /** 更新鼠标框选区域 */ | |||||
| void mouseMoveEvent(QMouseEvent *event) override; | |||||
| /** 完成鼠标框选并提交场景选择 */ | |||||
| void mouseReleaseEvent(QMouseEvent *event) override; | |||||
| /** 在条件网格或输出槽双击时打开命令输入框 */ | |||||
| void mouseDoubleClickEvent(QMouseEvent *event) override; | |||||
| /** 在窗口大小变化后重新布局梯形图场景 */ | |||||
| void resizeEvent(QResizeEvent *event) override; | void resizeEvent(QResizeEvent *event) override; | ||||
| /** 滚动画布时同步命令输入框位置 */ | |||||
| void scrollContentsBy(int dx, int dy) override; | |||||
| /** 处理命令输入框的回车、取消和按键事件 */ | |||||
| bool eventFilter(QObject *watched, QEvent *event) override; | |||||
| private: | private: | ||||
| /** 处理图形场景选择变化 */ | |||||
| void handleSelectionChanged(); | void handleSelectionChanged(); | ||||
| /** 把编辑服务结果转换为错误信号 */ | |||||
| void reportFailure(const LogicEditorResult &result); | void reportFailure(const LogicEditorResult &result); | ||||
| /** 返回当前选中的网络标识 */ | |||||
| std::string currentRungId() const; | std::string currentRungId() const; | ||||
| /** 选中指定表达式图元 */ | |||||
| void selectExpression(const std::string &expression_id); | void selectExpression(const std::string &expression_id); | ||||
| /** 选中指定横线图元 */ | |||||
| void selectWire(const std::string &wire_id); | void selectWire(const std::string &wire_id); | ||||
| /** 收集当前选中的表达式标识 */ | |||||
| std::vector<std::string> selectedExpressionIds() const; | std::vector<std::string> selectedExpressionIds() const; | ||||
| /** 收集当前选中的横线标识 */ | |||||
| std::vector<std::string> selectedWireIds() const; | std::vector<std::string> selectedWireIds() const; | ||||
| /** 收集当前选中的并联支路标识 */ | |||||
| std::vector<std::string> selectedBranchIds() const; | std::vector<std::string> selectedBranchIds() const; | ||||
| std::vector<int> selectedEmptySlotColumns() const; | |||||
| /** 收集当前选中的空网格位置 */ | |||||
| std::vector<std::pair<std::string, int>> selectedEmptySlots() const; | |||||
| /** 收集当前选中的横线网格位置 */ | |||||
| std::vector<std::pair<std::string, int>> selectedWireCells() const; | std::vector<std::pair<std::string, int>> selectedWireCells() const; | ||||
| /** 收集当前选中的显式断路网格位置 */ | |||||
| std::vector<std::pair<std::string, int>> selectedGapCells() const; | |||||
| /** 判断当前是否选中了网络内的图元 */ | |||||
| bool hasSelectedRungItem() const; | |||||
| /** 收集当前明确选中的网络图元 */ | |||||
| std::vector<std::string> selectedRungItemIds() const; | |||||
| /** 打开指定目标位置的命令输入框 */ | |||||
| void beginCommandInput( | |||||
| const LogicCommandTarget &target, | |||||
| const QPointF &scene_center); | |||||
| /** 提交当前命令输入;失败时保留输入框并标红 */ | |||||
| void commitCommandInput(); | |||||
| /** 关闭命令输入框并清理临时状态 */ | |||||
| void cancelCommandInput(); | |||||
| /** 根据网络、列和支路标识查找下一输入位置 */ | |||||
| bool beginNextCommandInput(); | |||||
| /** 按目标定位输入框的屏幕矩形 */ | |||||
| void positionCommandInput(const QPointF &scene_center); | |||||
| /** 返回当前场景目标的中心位置 */ | |||||
| bool findCommandTargetCenter( | |||||
| const LogicCommandTarget &target, | |||||
| QPointF *scene_center) const; | |||||
| /** 将成功命令选中并刷新属性面板 */ | |||||
| void selectCommandResult(const LogicCommandResult &result); | |||||
| /** 梯形图编辑服务,不由控件拥有 */ | |||||
| LogicEditorService &editor_service_; | LogicEditorService &editor_service_; | ||||
| /** 命令语解析和结构化编辑适配服务 */ | |||||
| LogicCommandService command_service_; | |||||
| /** 承载梯形图图元的场景 */ | |||||
| QGraphicsScene *scene_ = nullptr; | QGraphicsScene *scene_ = nullptr; | ||||
| /** 当前显示的控制逻辑标识 */ | |||||
| std::string logic_id_; | std::string logic_id_; | ||||
| /** 当前选中的网络标识 */ | |||||
| std::string current_rung_id_; | std::string current_rung_id_; | ||||
| /** 最近一次收到的运行轨迹 */ | |||||
| LogicTraceSnapshot trace_; | LogicTraceSnapshot trace_; | ||||
| /** 最近一次运行故障节点标识 */ | |||||
| std::string fault_node_id_; | std::string fault_node_id_; | ||||
| /** 是否显示运行轨迹 */ | |||||
| bool runtime_trace_enabled_ = false; | bool runtime_trace_enabled_ = false; | ||||
| /** 是否允许编辑梯形图 */ | |||||
| bool editing_enabled_ = true; | bool editing_enabled_ = true; | ||||
| /** 鼠标框选覆盖层 */ | |||||
| QRubberBand *selection_band_ = nullptr; | |||||
| /** 框选起点(视口坐标) */ | |||||
| QPoint selection_origin_; | |||||
| /** 框选时是否已超过拖拽阈值 */ | |||||
| bool selection_dragging_ = false; | |||||
| /** 框选起始时的键盘修饰键 */ | |||||
| Qt::KeyboardModifiers selection_modifiers_ = Qt::NoModifier; | |||||
| /** 命令输入框,仅在编辑态临时创建 */ | |||||
| QLineEdit *command_editor_ = nullptr; | |||||
| /** 命令输入补全器 */ | |||||
| QCompleter *command_completer_ = nullptr; | |||||
| /** 当前命令输入目标 */ | |||||
| LogicCommandTarget command_target_; | |||||
| /** 是否沿当前命令序列继续输入 */ | |||||
| bool command_continuing_ = false; | |||||
| /** OR/ORI 只增加支路,不推进主表达式列 */ | |||||
| bool command_keep_column_ = false; | |||||
| /** 连续输入对应的当前网络 */ | |||||
| std::string command_current_rung_id_; | |||||
| /** 编辑已有网络 OR 时保留的选择范围 */ | |||||
| std::vector<std::string> command_parallel_node_ids_; | |||||
| }; | }; | ||||
| @@ -31,14 +31,12 @@ LogicInstructionDialog::LogicInstructionDialog( | |||||
| original_config_(config) | original_config_(config) | ||||
| { | { | ||||
| ui_->setupUi(this); | ui_->setupUi(this); | ||||
| configureOperandCombo(ui_->counterPresetKindComboBox); | |||||
| configureOperandCombo(ui_->leftOperandKindComboBox); | configureOperandCombo(ui_->leftOperandKindComboBox); | ||||
| configureOperandCombo(ui_->rightOperandKindComboBox); | configureOperandCombo(ui_->rightOperandKindComboBox); | ||||
| connect(ui_->buttonBox, &QDialogButtonBox::accepted, this, &QDialog::accept); | connect(ui_->buttonBox, &QDialogButtonBox::accepted, this, &QDialog::accept); | ||||
| connect(ui_->buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject); | connect(ui_->buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject); | ||||
| for (QComboBox *combo : { | for (QComboBox *combo : { | ||||
| ui_->counterPresetKindComboBox, | |||||
| ui_->leftOperandKindComboBox, | ui_->leftOperandKindComboBox, | ||||
| ui_->rightOperandKindComboBox}) | ui_->rightOperandKindComboBox}) | ||||
| { | { | ||||
| @@ -49,11 +47,7 @@ LogicInstructionDialog::LogicInstructionDialog( | |||||
| &LogicInstructionDialog::updateOperandRanges); | &LogicInstructionDialog::updateOperandRanges); | ||||
| } | } | ||||
| if (const auto *counter = std::get_if<CounterNodeConfig>(&config)) | |||||
| { | |||||
| configureCounter(*counter); | |||||
| } | |||||
| else if (const auto *move = std::get_if<MoveNodeConfig>(&config)) | |||||
| if (const auto *move = std::get_if<MoveNodeConfig>(&config)) | |||||
| { | { | ||||
| configureMove(*move); | configureMove(*move); | ||||
| } | } | ||||
| @@ -68,21 +62,6 @@ LogicInstructionDialog::~LogicInstructionDialog() = default; | |||||
| LogicNodeConfig LogicInstructionDialog::config() const | 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_)) | if (std::holds_alternative<MoveNodeConfig>(original_config_)) | ||||
| { | { | ||||
| return MoveNodeConfig{ | return MoveNodeConfig{ | ||||
| @@ -101,25 +80,6 @@ LogicNodeConfig LogicInstructionDialog::config() const | |||||
| ui_->destinationAddressSpinBox->value()}}; | 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) | void LogicInstructionDialog::configureMove(const MoveNodeConfig &config) | ||||
| { | { | ||||
| setWindowTitle(tr("配置 MOVE")); | setWindowTitle(tr("配置 MOVE")); | ||||
| @@ -165,13 +125,11 @@ void LogicInstructionDialog::updateOperandRanges() | |||||
| spin->setPrefix(register_operand ? QStringLiteral("D") : QString{}); | spin->setPrefix(register_operand ? QStringLiteral("D") : QString{}); | ||||
| spin->setMinimum(register_operand | spin->setMinimum(register_operand | ||||
| ? RegisterAddress::kMinimumIndex | ? RegisterAddress::kMinimumIndex | ||||
| : (combo->objectName() == QStringLiteral("counterPresetKindComboBox") | |||||
| ? 0 : std::numeric_limits<std::int16_t>::min())); | |||||
| : std::numeric_limits<std::int16_t>::min()); | |||||
| spin->setMaximum(register_operand | spin->setMaximum(register_operand | ||||
| ? RegisterAddress::kMaximumIndex | ? RegisterAddress::kMaximumIndex | ||||
| : std::numeric_limits<std::int16_t>::max()); | : std::numeric_limits<std::int16_t>::max()); | ||||
| }; | }; | ||||
| update(ui_->counterPresetKindComboBox, ui_->counterPresetValueSpinBox); | |||||
| update(ui_->leftOperandKindComboBox, ui_->leftOperandValueSpinBox); | update(ui_->leftOperandKindComboBox, ui_->leftOperandValueSpinBox); | ||||
| update(ui_->rightOperandKindComboBox, ui_->rightOperandValueSpinBox); | update(ui_->rightOperandKindComboBox, ui_->rightOperandValueSpinBox); | ||||
| } | } | ||||
| @@ -1,3 +1,11 @@ | |||||
| /** | |||||
| * @file logic_instruction_dialog.h | |||||
| * @brief 定义梯形图指令参数编辑对话框 | |||||
| * @version 1.0.0 | |||||
| * @author suyu | |||||
| * @date 2026-08-22 | |||||
| */ | |||||
| #pragma once | #pragma once | ||||
| #include "domain/control_logic_model.h" | #include "domain/control_logic_model.h" | ||||
| @@ -13,31 +21,41 @@ namespace Ui { | |||||
| class LogicInstructionDialog; | class LogicInstructionDialog; | ||||
| } | } | ||||
| /** 梯形图节点配置对话框;只负责把表单转换为强类型 LogicNodeConfig */ | |||||
| class LogicInstructionDialog final : public QDialog | class LogicInstructionDialog final : public QDialog | ||||
| { | { | ||||
| Q_OBJECT | Q_OBJECT | ||||
| public: | public: | ||||
| /** 使用已有节点配置初始化指令参数表单 */ | |||||
| explicit LogicInstructionDialog( | explicit LogicInstructionDialog( | ||||
| const LogicNodeConfig &config, | const LogicNodeConfig &config, | ||||
| QWidget *parent = nullptr); | QWidget *parent = nullptr); | ||||
| /** 释放 Designer 界面对象 */ | |||||
| ~LogicInstructionDialog() override; | ~LogicInstructionDialog() override; | ||||
| /** 返回当前表单配置,调用方仍需交给 LogicEditorService 校验 */ | |||||
| LogicNodeConfig config() const; | LogicNodeConfig config() const; | ||||
| private: | private: | ||||
| void configureCounter(const CounterNodeConfig &config); | |||||
| /** 根据传送配置初始化传送输入项 */ | |||||
| void configureMove(const MoveNodeConfig &config); | void configureMove(const MoveNodeConfig &config); | ||||
| /** 根据算术配置初始化运算输入项 */ | |||||
| void configureArithmetic(const ArithmeticNodeConfig &config); | void configureArithmetic(const ArithmeticNodeConfig &config); | ||||
| /** 根据操作数类型更新数值输入框的合法范围 */ | |||||
| void updateOperandRanges(); | void updateOperandRanges(); | ||||
| /** 将领域操作数写入对应的下拉框和数值框 */ | |||||
| static void setOperand( | static void setOperand( | ||||
| QComboBox *kind_combo, | QComboBox *kind_combo, | ||||
| QSpinBox *value_spin, | QSpinBox *value_spin, | ||||
| const WordOperand &operand); | const WordOperand &operand); | ||||
| /** 从下拉框和数值框读取领域操作数 */ | |||||
| static WordOperand operandFrom( | static WordOperand operandFrom( | ||||
| const QComboBox *kind_combo, | const QComboBox *kind_combo, | ||||
| const QSpinBox *value_spin); | const QSpinBox *value_spin); | ||||
| /** Qt Designer 生成的界面对象 */ | |||||
| std::unique_ptr<Ui::LogicInstructionDialog> ui_; | std::unique_ptr<Ui::LogicInstructionDialog> ui_; | ||||
| /** 打开对话框时保存的原始节点配置 */ | |||||
| LogicNodeConfig original_config_; | LogicNodeConfig original_config_; | ||||
| }; | }; | ||||
| @@ -9,27 +9,6 @@ | |||||
| <layout class="QVBoxLayout" name="verticalLayout"> | <layout class="QVBoxLayout" name="verticalLayout"> | ||||
| <item> | <item> | ||||
| <widget class="QStackedWidget" name="instructionStackedWidget"> | <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"> | <widget class="QWidget" name="dataInstructionPage"> | ||||
| <layout class="QFormLayout" name="dataInstructionFormLayout"> | <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="0"><widget class="QLabel" name="instructionCaptionLabel"><property name="text"><string>指令</string></property></widget></item> | ||||
| @@ -1,7 +1,7 @@ | |||||
| /** | /** | ||||
| * @file main_window.h | * @file main_window.h | ||||
| * @brief 定义综合平台编程器主窗口及运行模式界面协调逻辑 | * @brief 定义综合平台编程器主窗口及运行模式界面协调逻辑 | ||||
| * @version 0.2.0 | |||||
| * @version 1.0.0 | |||||
| * @author suyu | * @author suyu | ||||
| * @date 2026-08-08 | * @date 2026-08-08 | ||||
| */ | */ | ||||
| @@ -12,10 +12,14 @@ | |||||
| #include "domain/control_logic_model.h" | #include "domain/control_logic_model.h" | ||||
| #include "domain/runtime_state.h" | #include "domain/runtime_state.h" | ||||
| #include "services/plc_communication_gateway.h" | #include "services/plc_communication_gateway.h" | ||||
| #include "services/application_settings.h" | |||||
| #include <QMainWindow> | #include <QMainWindow> | ||||
| #include <QProcess> | |||||
| #include <memory> | #include <memory> | ||||
| #include <variant> | |||||
| #include <vector> | |||||
| QT_BEGIN_NAMESPACE | QT_BEGIN_NAMESPACE | ||||
| class QAction; | class QAction; | ||||
| @@ -23,6 +27,9 @@ class QActionGroup; | |||||
| class QCloseEvent; | class QCloseEvent; | ||||
| class QEvent; | class QEvent; | ||||
| class QLabel; | class QLabel; | ||||
| class QDockWidget; | |||||
| class QTimer; | |||||
| class QProgressDialog; | |||||
| namespace Ui { | namespace Ui { | ||||
| class MainWindow; | class MainWindow; | ||||
| @@ -41,7 +48,9 @@ class HmiEditorWidget; | |||||
| class LogicEditorService; | class LogicEditorService; | ||||
| class LogicEditorWidget; | class LogicEditorWidget; | ||||
| class RegisterMonitorService; | class RegisterMonitorService; | ||||
| class PlcDiscoveryGateway; | |||||
| class RuntimeMonitorWidget; | class RuntimeMonitorWidget; | ||||
| class FreeMonitorWidget; | |||||
| class ProjectWorkspaceController; | class ProjectWorkspaceController; | ||||
| class PropertyPanelController; | class PropertyPanelController; | ||||
| class RuntimePanelController; | class RuntimePanelController; | ||||
| @@ -51,6 +60,7 @@ class RuntimePanelController; | |||||
| * | * | ||||
| * 主窗口不直接访问寄存器、工程文件或 PLC 通信实现 | * 主窗口不直接访问寄存器、工程文件或 PLC 通信实现 | ||||
| */ | */ | ||||
| // 编程器主窗口:负责组装 UI 控制器、窗口动作和模式切换,不承载领域规则 | |||||
| class MainWindow final : public QMainWindow | class MainWindow final : public QMainWindow | ||||
| { | { | ||||
| Q_OBJECT | Q_OBJECT | ||||
| @@ -74,7 +84,15 @@ public: | |||||
| AlarmService &alarm_service, | AlarmService &alarm_service, | ||||
| RegisterCommentService ®ister_comment_service, | RegisterCommentService ®ister_comment_service, | ||||
| RegisterMonitorService ®ister_monitor_service, | RegisterMonitorService ®ister_monitor_service, | ||||
| PlcDiscoveryGateway &plc_discovery_gateway, | |||||
| const ApplicationSettingsLoadResult &application_settings_result, | |||||
| bool user_runtime_mode = false, | |||||
| QWidget *parent = nullptr); | QWidget *parent = nullptr); | ||||
| /** | |||||
| * @brief 创建主窗口,并使用调用方提供的 HMI 页面导航服务 | |||||
| * | |||||
| * 两个构造函数的区别只在导航服务的所有权;其余服务均由应用入口管理 | |||||
| */ | |||||
| MainWindow( | MainWindow( | ||||
| RuntimeModeService &runtime_mode_service, | RuntimeModeService &runtime_mode_service, | ||||
| ProjectService &project_service, | ProjectService &project_service, | ||||
| @@ -86,82 +104,125 @@ public: | |||||
| HmiNavigationService &hmi_navigation_service, | HmiNavigationService &hmi_navigation_service, | ||||
| RegisterCommentService ®ister_comment_service, | RegisterCommentService ®ister_comment_service, | ||||
| RegisterMonitorService ®ister_monitor_service, | RegisterMonitorService ®ister_monitor_service, | ||||
| PlcDiscoveryGateway &plc_discovery_gateway, | |||||
| const ApplicationSettingsLoadResult &application_settings_result, | |||||
| bool user_runtime_mode = false, | |||||
| QWidget *parent = nullptr); | QWidget *parent = nullptr); | ||||
| /** 释放窗口拥有的控制器和可选导航服务 */ | |||||
| ~MainWindow() override; | ~MainWindow() override; | ||||
| /** 返回当前选中的 HMI 页面标识 */ | |||||
| const std::string ¤tHmiPageId() const; | const std::string ¤tHmiPageId() const; | ||||
| /** 返回当前选中的控制逻辑标识 */ | |||||
| const std::string ¤tLogicId() const; | const std::string ¤tLogicId() const; | ||||
| protected: | protected: | ||||
| /** 处理主窗口及子控件的事件过滤请求 */ | |||||
| bool eventFilter(QObject *watched, QEvent *event) override; | bool eventFilter(QObject *watched, QEvent *event) override; | ||||
| /** 关闭窗口前处理保存确认和运行态退出 */ | |||||
| void closeEvent(QCloseEvent *event) override; | void closeEvent(QCloseEvent *event) override; | ||||
| private: | private: | ||||
| // 配置菜单和工具栏动作 | |||||
| /** 配置菜单和工具栏动作 */ | |||||
| void configureActions(); | void configureActions(); | ||||
| // 配置主窗口外观和状态栏 | |||||
| /** 配置主窗口外观和状态栏 */ | |||||
| void configureAppearance(); | void configureAppearance(); | ||||
| // 创建并连接 HMI 编辑画布 | |||||
| /** 创建并连接 HMI 编辑画布 */ | |||||
| void configureHmiEditor(); | void configureHmiEditor(); | ||||
| // 创建并连接控制逻辑编辑画布 | |||||
| /** 创建并连接控制逻辑编辑画布 */ | |||||
| void configureLogicEditor(); | void configureLogicEditor(); | ||||
| /** 创建并连接运行监控面板 */ | |||||
| void configureRuntimeMonitor(); | void configureRuntimeMonitor(); | ||||
| /** 创建并连接编辑态数据监控面板 */ | |||||
| void configureDataMonitor(); | |||||
| /** 创建并连接工程树 */ | |||||
| void configureProjectTree(); | void configureProjectTree(); | ||||
| // 创建并连接控件属性编辑表单 | |||||
| /** 创建并连接控件属性编辑表单 */ | |||||
| void configurePropertyEditor(); | void configurePropertyEditor(); | ||||
| /** 创建并连接 PLC 串口配置入口 */ | |||||
| void configurePlcConnection(); | void configurePlcConnection(); | ||||
| /** 创建并连接报警配置入口 */ | |||||
| void configureAlarms(); | void configureAlarms(); | ||||
| /** 创建并连接寄存器注释入口 */ | |||||
| void configureRegisterComments(); | void configureRegisterComments(); | ||||
| /** 撤销当前处于焦点的编辑器操作 */ | |||||
| void undoActiveEditor(); | void undoActiveEditor(); | ||||
| /** 重做当前处于焦点的编辑器操作 */ | |||||
| void redoActiveEditor(); | void redoActiveEditor(); | ||||
| /** 复制当前编辑画布中的选中对象 */ | |||||
| void copyActiveSelection(); | |||||
| /** 粘贴剪贴板中的对象到当前编辑画布 */ | |||||
| void pasteActiveSelection(); | |||||
| /** 删除当前处于焦点的编辑器选中项 */ | |||||
| void deleteActiveSelection(); | void deleteActiveSelection(); | ||||
| /** 清除当前处于焦点的编辑器选择 */ | |||||
| void clearActiveSelection(); | void clearActiveSelection(); | ||||
| /** 根据编辑器选择状态更新撤销、重做和删除动作 */ | |||||
| void updateEditActions(); | void updateEditActions(); | ||||
| /** 清空 HMI 和梯形图编辑器的撤销历史 */ | |||||
| void clearEditorHistories(); | void clearEditorHistories(); | ||||
| /** 根据工程路径和修改状态刷新窗口标题 */ | |||||
| void updateWindowTitle(); | void updateWindowTitle(); | ||||
| /** 对可能破坏当前工程的操作请求保存确认 */ | |||||
| bool confirmSaveBeforeDestructiveAction(); | bool confirmSaveBeforeDestructiveAction(); | ||||
| // 刷新工程树和当前 HMI 页面信息 | |||||
| /** 刷新工程树和当前 HMI 页面信息 */ | |||||
| void refreshProjectUi(); | void refreshProjectUi(); | ||||
| /** 更新工程树相关动作的可用状态 */ | |||||
| void updateProjectTreeActions(); | void updateProjectTreeActions(); | ||||
| // 显示指定 HMI 控件的可编辑属性 | |||||
| /** 显示指定 HMI 控件的可编辑属性 */ | |||||
| void showControlProperties(const std::string &control_id); | void showControlProperties(const std::string &control_id); | ||||
| // 显示指定控制逻辑节点的可编辑属性 | |||||
| /** 显示指定控制逻辑节点的可编辑属性 */ | |||||
| void showLogicNodeProperties(const std::string &node_id); | void showLogicNodeProperties(const std::string &node_id); | ||||
| // 向当前页面添加指定类型的 HMI 控件 | |||||
| /** 向当前页面添加指定类型的 HMI 控件 */ | |||||
| void addHmiControl(HmiControlType type); | void addHmiControl(HmiControlType type); | ||||
| // 删除当前选中的 HMI 控件 | |||||
| /** 删除当前选中的 HMI 控件 */ | |||||
| void deleteSelectedControl(); | void deleteSelectedControl(); | ||||
| // 在当前光标节点后添加串联条件,没有选择时追加到网络末尾 | |||||
| /** 在当前光标节点后添加串联条件,没有选择时追加到网络末尾 */ | |||||
| void addLogicCondition(const LogicNodeConfig &config); | void addLogicCondition(const LogicNodeConfig &config); | ||||
| // 为当前选中的连续逻辑范围建立并联支路 | |||||
| /** 为当前选中的连续逻辑范围建立并联支路 */ | |||||
| void addLogicParallelBranch(const LogicNodeConfig &config); | void addLogicParallelBranch(const LogicNodeConfig &config); | ||||
| /** 在当前梯形图位置添加横线 */ | |||||
| void addLogicHorizontalWire(); | void addLogicHorizontalWire(); | ||||
| /** 在当前梯形图位置添加竖线 */ | |||||
| void addLogicVerticalWire(); | void addLogicVerticalWire(); | ||||
| /** 删除当前梯形图位置的横线 */ | |||||
| void deleteLogicHorizontalWire(); | void deleteLogicHorizontalWire(); | ||||
| /** 删除当前梯形图位置的竖线 */ | |||||
| void deleteLogicVerticalWire(); | void deleteLogicVerticalWire(); | ||||
| // 设置当前网络右侧的输出线圈 | |||||
| /** 设置当前网络右侧的输出线圈 */ | |||||
| void setLogicOutput(const LogicNodeConfig &config); | void setLogicOutput(const LogicNodeConfig &config); | ||||
| /** 打开输出指令配置并提交输出线圈 */ | |||||
| void configureAndSetLogicOutput(const LogicNodeConfig &config); | void configureAndSetLogicOutput(const LogicNodeConfig &config); | ||||
| // 新增一个梯形图网络 | |||||
| /** 新增一个梯形图网络 */ | |||||
| void addLogicRung(); | void addLogicRung(); | ||||
| /** 编辑当前网络的注释 */ | |||||
| void editSelectedRungComment(); | void editSelectedRungComment(); | ||||
| // 删除当前选中的逻辑节点或网络 | |||||
| /** 删除当前选中的逻辑节点或网络 */ | |||||
| void deleteSelectedLogicObject(); | void deleteSelectedLogicObject(); | ||||
| /** 打开 PLC 连接 */ | |||||
| void connectPlc(); | void connectPlc(); | ||||
| /** 关闭 PLC 连接 */ | |||||
| void disconnectPlc(); | void disconnectPlc(); | ||||
| // 合并同一事件循环内的 PLC 状态通知,避免重复刷新和重复日志 | |||||
| /** 合并同一事件循环内的 PLC 状态通知,避免重复刷新和重复日志 */ | |||||
| void schedulePlcStatusUpdate(); | void schedulePlcStatusUpdate(); | ||||
| // 创建新的工程并刷新编辑界面 | |||||
| /** 创建新的工程并刷新编辑界面 */ | |||||
| void createNewProject(); | void createNewProject(); | ||||
| // 保存当前工程到已关联的路径 | |||||
| /** 保存当前工程到已关联的路径 */ | |||||
| void saveProject(); | void saveProject(); | ||||
| // 将当前工程保存到用户指定的路径 | |||||
| /** 将当前工程保存到用户指定的路径 */ | |||||
| void saveProjectAs(); | void saveProjectAs(); | ||||
| // 从用户指定的路径加载工程 | |||||
| /** 从用户指定的路径加载工程 */ | |||||
| void loadProject(); | void loadProject(); | ||||
| // 在状态栏和输出面板显示工程操作结果 | |||||
| /** 将当前工程构建为用户可直接运行的专用 exe */ | |||||
| void exportRuntimeProgram(); | |||||
| /** 更新用户运行程序导出的阶段进度 */ | |||||
| void handleRuntimeExportOutput(); | |||||
| /** 完成用户运行程序导出并复制最终目录 */ | |||||
| void handleRuntimeExportFinished(int exit_code, QProcess::ExitStatus exit_status); | |||||
| /** 在状态栏和输出面板显示工程操作结果 */ | |||||
| void showProjectResult(const QString &action, const QString &message, bool succeeded); | void showProjectResult(const QString &action, const QString &message, bool succeeded); | ||||
| /** 向输出面板追加一条消息 */ | |||||
| void appendOutputMessage(const QString &message); | void appendOutputMessage(const QString &message); | ||||
| /** | /** | ||||
| @@ -170,22 +231,30 @@ private: | |||||
| * | * | ||||
| * 失败时恢复与服务层当前状态一致的工具栏选项 | * 失败时恢复与服务层当前状态一致的工具栏选项 | ||||
| */ | */ | ||||
| void requestMode(ApplicationMode requested_mode); | |||||
| bool requestMode(ApplicationMode requested_mode); | |||||
| /** | /** | ||||
| * @brief 根据服务层模式策略更新编辑权限和状态反馈 | * @brief 根据服务层模式策略更新编辑权限和状态反馈 | ||||
| * @param message 要显示在状态栏和输出区的模式结果消息 | * @param message 要显示在状态栏和输出区的模式结果消息 | ||||
| */ | */ | ||||
| void updateModeUi(const QString &message); | void updateModeUi(const QString &message); | ||||
| // 根据仿真服务实际状态刷新执行器反馈和 HMI 写权限 | |||||
| /** 根据仿真服务实际状态刷新执行器反馈和 HMI 写权限 */ | |||||
| void updateSimulationUi(bool report_fault); | void updateSimulationUi(bool report_fault); | ||||
| /** | /** | ||||
| * @brief 将互斥模式动作同步到服务层当前状态 | * @brief 将互斥模式动作同步到服务层当前状态 | ||||
| */ | */ | ||||
| void restoreCurrentModeAction(); | void restoreCurrentModeAction(); | ||||
| /** 完成主窗口初始化和首次界面刷新 */ | |||||
| void initializeUi(); | void initializeUi(); | ||||
| /** 专用用户运行版启动后直接进入运行监控 */ | |||||
| void initializeUserRuntime(); | |||||
| /** 用户运行版允许在离线和真机之间自动经过编辑态切换 */ | |||||
| void requestUserRuntimeMode(ApplicationMode requested_mode); | |||||
| /** 刷新编辑态数据监控的模式、权限和当前值 */ | |||||
| void refreshDataMonitorUi(); | |||||
| /** Qt Designer 生成的主窗口界面对象 */ | |||||
| std::unique_ptr<Ui::MainWindow> ui_; | std::unique_ptr<Ui::MainWindow> ui_; | ||||
| /** | /** | ||||
| * @brief 非拥有的运行模式服务依赖,由应用入口保证生命周期 | * @brief 非拥有的运行模式服务依赖,由应用入口保证生命周期 | ||||
| @@ -195,29 +264,98 @@ private: | |||||
| * @brief 非拥有的工程和 HMI 服务依赖,由应用入口保证生命周期 | * @brief 非拥有的工程和 HMI 服务依赖,由应用入口保证生命周期 | ||||
| */ | */ | ||||
| ProjectService &project_service_; | ProjectService &project_service_; | ||||
| /** 非拥有的 HMI 编辑服务依赖 */ | |||||
| HmiEditorService &hmi_editor_service_; | HmiEditorService &hmi_editor_service_; | ||||
| /** 非拥有的梯形图编辑服务依赖 */ | |||||
| LogicEditorService &logic_editor_service_; | LogicEditorService &logic_editor_service_; | ||||
| /** 非拥有的 HMI 运行服务依赖 */ | |||||
| HmiRuntimeService &hmi_runtime_service_; | HmiRuntimeService &hmi_runtime_service_; | ||||
| /** 非拥有的报警编辑服务依赖 */ | |||||
| AlarmEditorService &alarm_editor_service_; | AlarmEditorService &alarm_editor_service_; | ||||
| /** 非拥有的报警查询服务依赖 */ | |||||
| AlarmService &alarm_service_; | AlarmService &alarm_service_; | ||||
| /** 非拥有的寄存器注释服务依赖 */ | |||||
| RegisterCommentService ®ister_comment_service_; | RegisterCommentService ®ister_comment_service_; | ||||
| /** 非拥有的寄存器监控服务依赖 */ | |||||
| RegisterMonitorService ®ister_monitor_service_; | RegisterMonitorService ®ister_monitor_service_; | ||||
| /** 非拥有的 PLC 自动搜索服务依赖 */ | |||||
| PlcDiscoveryGateway &plc_discovery_gateway_; | |||||
| /** 启动期配置和诊断结果,由应用入口保证生命周期 */ | |||||
| const ApplicationSettingsLoadResult &application_settings_result_; | |||||
| /** 是否以专用用户运行版入口启动 */ | |||||
| bool user_runtime_mode_ = false; | |||||
| /** 当调用方未提供导航服务时由主窗口负责拥有的实例 */ | |||||
| std::unique_ptr<HmiNavigationService> owned_hmi_navigation_service_; | std::unique_ptr<HmiNavigationService> owned_hmi_navigation_service_; | ||||
| /** 当前使用的 HMI 页面导航服务 */ | |||||
| HmiNavigationService *hmi_navigation_service_ = nullptr; | HmiNavigationService *hmi_navigation_service_ = nullptr; | ||||
| /** 工程树控制器 */ | |||||
| std::unique_ptr<ProjectWorkspaceController> project_workspace_controller_; | std::unique_ptr<ProjectWorkspaceController> project_workspace_controller_; | ||||
| /** 属性面板控制器 */ | |||||
| std::unique_ptr<PropertyPanelController> property_panel_controller_; | std::unique_ptr<PropertyPanelController> property_panel_controller_; | ||||
| /** 运行监控面板控制器 */ | |||||
| std::unique_ptr<RuntimePanelController> runtime_panel_controller_; | std::unique_ptr<RuntimePanelController> runtime_panel_controller_; | ||||
| /** 运行模式互斥动作组 */ | |||||
| QActionGroup *mode_action_group_ = nullptr; | QActionGroup *mode_action_group_ = nullptr; | ||||
| /** 主界面的 HMI 编辑器 */ | |||||
| HmiEditorWidget *hmi_editor_widget_ = nullptr; | HmiEditorWidget *hmi_editor_widget_ = nullptr; | ||||
| /** 主界面的梯形图编辑器 */ | |||||
| LogicEditorWidget *logic_editor_widget_ = nullptr; | LogicEditorWidget *logic_editor_widget_ = nullptr; | ||||
| /** 唯一的运行监控控件 */ | |||||
| RuntimeMonitorWidget *runtime_monitor_widget_ = nullptr; | RuntimeMonitorWidget *runtime_monitor_widget_ = nullptr; | ||||
| /** 主窗口中的数据监控面板 */ | |||||
| QDockWidget *data_monitor_dock_ = nullptr; | |||||
| FreeMonitorWidget *data_monitor_widget_ = nullptr; | |||||
| QTimer *data_monitor_refresh_timer_ = nullptr; | |||||
| /** 显示当前运行模式的状态标签 */ | |||||
| QLabel *mode_status_label_ = nullptr; | QLabel *mode_status_label_ = nullptr; | ||||
| /** 显示 PLC 连接状态的标签 */ | |||||
| QLabel *register_status_label_ = nullptr; | QLabel *register_status_label_ = nullptr; | ||||
| /** 显示离线执行器状态的标签 */ | |||||
| QLabel *executor_status_label_ = nullptr; | QLabel *executor_status_label_ = nullptr; | ||||
| /** 当前保存的 PLC 串口配置 */ | |||||
| PlcSerialConfiguration plc_configuration_; | PlcSerialConfiguration plc_configuration_; | ||||
| /** 当前选中的 HMI 控件标识 */ | |||||
| std::string selected_control_id_; | std::string selected_control_id_; | ||||
| /** 当前选中的梯形图节点标识 */ | |||||
| std::string selected_logic_node_id_; | std::string selected_logic_node_id_; | ||||
| /** 当前选中的 HMI 页面标识 */ | |||||
| std::string current_hmi_page_id_; | std::string current_hmi_page_id_; | ||||
| /** 当前选中的控制逻辑标识 */ | |||||
| std::string current_logic_id_; | std::string current_logic_id_; | ||||
| /** 是否已经安排了待处理的 PLC 状态刷新 */ | |||||
| bool plc_status_update_pending_ = false; | bool plc_status_update_pending_ = false; | ||||
| /** 用户运行程序导出进程及其临时状态 */ | |||||
| std::unique_ptr<QProcess> runtime_export_process_; | |||||
| std::unique_ptr<QProgressDialog> runtime_export_progress_; | |||||
| QString runtime_export_temp_project_; | |||||
| QString runtime_export_output_name_; | |||||
| QString runtime_export_destination_directory_; | |||||
| QString runtime_export_package_directory_; | |||||
| QString runtime_export_process_output_; | |||||
| bool runtime_export_completion_handled_ = false; | |||||
| struct HmiClipboardData | |||||
| { | |||||
| std::vector<HmiControl> controls; | |||||
| int pasteCount = 0; | |||||
| }; | |||||
| struct LogicNodesClipboardData | |||||
| { | |||||
| std::vector<LogicNode> nodes; | |||||
| }; | |||||
| struct LogicOutputClipboardData | |||||
| { | |||||
| LogicNode output; | |||||
| }; | |||||
| struct LogicRungClipboardData | |||||
| { | |||||
| LadderRung rung; | |||||
| }; | |||||
| using EditorClipboard = std::variant< | |||||
| std::monostate, | |||||
| HmiClipboardData, | |||||
| LogicNodesClipboardData, | |||||
| LogicOutputClipboardData, | |||||
| LogicRungClipboardData>; | |||||
| EditorClipboard editor_clipboard_; | |||||
| }; | }; | ||||
| @@ -228,6 +228,7 @@ | |||||
| <addaction name="saveProjectAction"/> | <addaction name="saveProjectAction"/> | ||||
| <addaction name="saveAsProjectAction"/> | <addaction name="saveAsProjectAction"/> | ||||
| <addaction name="loadProjectAction"/> | <addaction name="loadProjectAction"/> | ||||
| <addaction name="exportRuntimeAction"/> | |||||
| <addaction name="separator"/> | <addaction name="separator"/> | ||||
| <addaction name="exitAction"/> | <addaction name="exitAction"/> | ||||
| </widget> | </widget> | ||||
| @@ -237,6 +238,8 @@ | |||||
| </property> | </property> | ||||
| <addaction name="undoAction"/> | <addaction name="undoAction"/> | ||||
| <addaction name="redoAction"/> | <addaction name="redoAction"/> | ||||
| <addaction name="copyAction"/> | |||||
| <addaction name="pasteAction"/> | |||||
| <addaction name="separator"/> | <addaction name="separator"/> | ||||
| <addaction name="deleteSelectionAction"/> | <addaction name="deleteSelectionAction"/> | ||||
| <addaction name="deleteHorizontalWireAction"/> | <addaction name="deleteHorizontalWireAction"/> | ||||
| @@ -669,61 +672,13 @@ | |||||
| </widget> | </widget> | ||||
| </item> | </item> | ||||
| <item row="10" column="0"> | <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"> | <widget class="QLabel" name="textColorLabel"> | ||||
| <property name="text"> | <property name="text"> | ||||
| <string>字体颜色</string> | <string>字体颜色</string> | ||||
| </property> | </property> | ||||
| </widget> | </widget> | ||||
| </item> | </item> | ||||
| <item row="13" column="1"> | |||||
| <item row="10" column="1"> | |||||
| <widget class="QWidget" name="textColorContainer"> | <widget class="QWidget" name="textColorContainer"> | ||||
| <layout class="QHBoxLayout" name="textColorLayout"> | <layout class="QHBoxLayout" name="textColorLayout"> | ||||
| <property name="leftMargin"><number>0</number></property> | <property name="leftMargin"><number>0</number></property> | ||||
| @@ -747,24 +702,24 @@ | |||||
| </layout> | </layout> | ||||
| </widget> | </widget> | ||||
| </item> | </item> | ||||
| <item row="14" column="0"> | |||||
| <item row="11" column="0"> | |||||
| <widget class="QLabel" name="fontSizeLabel"> | <widget class="QLabel" name="fontSizeLabel"> | ||||
| <property name="text"> | <property name="text"> | ||||
| <string>字号</string> | <string>字号</string> | ||||
| </property> | </property> | ||||
| </widget> | </widget> | ||||
| </item> | </item> | ||||
| <item row="14" column="1"> | |||||
| <item row="11" column="1"> | |||||
| <widget class="QSpinBox" name="fontSizeSpinBox"/> | <widget class="QSpinBox" name="fontSizeSpinBox"/> | ||||
| </item> | </item> | ||||
| <item row="15" column="0"> | |||||
| <item row="12" column="0"> | |||||
| <widget class="QLabel" name="fontBoldLabel"> | <widget class="QLabel" name="fontBoldLabel"> | ||||
| <property name="text"> | <property name="text"> | ||||
| <string>字体样式</string> | <string>字体样式</string> | ||||
| </property> | </property> | ||||
| </widget> | </widget> | ||||
| </item> | </item> | ||||
| <item row="15" column="1"> | |||||
| <item row="12" column="1"> | |||||
| <widget class="QWidget" name="fontStyleContainer"> | <widget class="QWidget" name="fontStyleContainer"> | ||||
| <layout class="QHBoxLayout" name="fontStyleLayout"> | <layout class="QHBoxLayout" name="fontStyleLayout"> | ||||
| <property name="leftMargin"><number>0</number></property> | <property name="leftMargin"><number>0</number></property> | ||||
| @@ -784,7 +739,18 @@ | |||||
| </layout> | </layout> | ||||
| </widget> | </widget> | ||||
| </item> | </item> | ||||
| <item row="16" column="0" colspan="2"> | |||||
| <item row="13" column="0"> | |||||
| <widget class="QLabel" name="dataTypeLabel"> | |||||
| <property name="text"><string>数值类型</string></property> | |||||
| </widget> | |||||
| </item> | |||||
| <item row="13" column="1"> | |||||
| <widget class="QComboBox" name="dataTypeComboBox"> | |||||
| <item><property name="text"><string>Int16</string></property></item> | |||||
| <item><property name="text"><string>Float32</string></property></item> | |||||
| </widget> | |||||
| </item> | |||||
| <item row="14" column="0" colspan="2"> | |||||
| <widget class="QPushButton" name="applyPropertiesButton"> | <widget class="QPushButton" name="applyPropertiesButton"> | ||||
| <property name="text"> | <property name="text"> | ||||
| <string>应用属性</string> | <string>应用属性</string> | ||||
| @@ -922,27 +888,7 @@ | |||||
| </property> | </property> | ||||
| </widget> | </widget> | ||||
| </item> | </item> | ||||
| <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"> | |||||
| <item row="7" column="0" colspan="2"> | |||||
| <widget class="QPushButton" name="applyLogicPropertiesButton"> | <widget class="QPushButton" name="applyLogicPropertiesButton"> | ||||
| <property name="text"> | <property name="text"> | ||||
| <string>应用属性</string> | <string>应用属性</string> | ||||
| @@ -960,6 +906,28 @@ | |||||
| </layout> | </layout> | ||||
| </widget> | </widget> | ||||
| </widget> | </widget> | ||||
| <widget class="QDockWidget" name="dataMonitorDock"> | |||||
| <property name="minimumSize"> | |||||
| <size> | |||||
| <width>640</width> | |||||
| <height>220</height> | |||||
| </size> | |||||
| </property> | |||||
| <property name="windowTitle"> | |||||
| <string>数据监控</string> | |||||
| </property> | |||||
| <attribute name="dockWidgetArea"> | |||||
| <number>2</number> | |||||
| </attribute> | |||||
| <widget class="QWidget" name="dataMonitorDockContents"> | |||||
| <layout class="QVBoxLayout" name="dataMonitorDockLayout"> | |||||
| <property name="leftMargin"><number>6</number></property> | |||||
| <property name="topMargin"><number>6</number></property> | |||||
| <property name="rightMargin"><number>6</number></property> | |||||
| <property name="bottomMargin"><number>6</number></property> | |||||
| </layout> | |||||
| </widget> | |||||
| </widget> | |||||
| <widget class="QDockWidget" name="outputDock"> | <widget class="QDockWidget" name="outputDock"> | ||||
| <property name="minimumSize"> | <property name="minimumSize"> | ||||
| <size> | <size> | ||||
| @@ -1036,6 +1004,10 @@ | |||||
| <string>Ctrl+O</string> | <string>Ctrl+O</string> | ||||
| </property> | </property> | ||||
| </action> | </action> | ||||
| <action name="exportRuntimeAction"> | |||||
| <property name="text"><string>导出用户运行程序</string></property> | |||||
| <property name="toolTip"><string>将当前工程导出为用户可直接运行的专用程序</string></property> | |||||
| </action> | |||||
| <action name="undoAction"> | <action name="undoAction"> | ||||
| <property name="text"><string>撤销</string></property> | <property name="text"><string>撤销</string></property> | ||||
| <property name="toolTip"><string>撤销当前编辑器的上一步操作</string></property> | <property name="toolTip"><string>撤销当前编辑器的上一步操作</string></property> | ||||
| @@ -1046,6 +1018,16 @@ | |||||
| <property name="toolTip"><string>重做当前编辑器最近撤销的操作</string></property> | <property name="toolTip"><string>重做当前编辑器最近撤销的操作</string></property> | ||||
| <property name="shortcut"><string>Ctrl+Y</string></property> | <property name="shortcut"><string>Ctrl+Y</string></property> | ||||
| </action> | </action> | ||||
| <action name="copyAction"> | |||||
| <property name="text"><string>复制</string></property> | |||||
| <property name="toolTip"><string>复制当前编辑画布中选中的对象</string></property> | |||||
| <property name="shortcut"><string>Ctrl+C</string></property> | |||||
| </action> | |||||
| <action name="pasteAction"> | |||||
| <property name="text"><string>粘贴</string></property> | |||||
| <property name="toolTip"><string>粘贴已复制的编辑对象</string></property> | |||||
| <property name="shortcut"><string>Ctrl+V</string></property> | |||||
| </action> | |||||
| <action name="deleteSelectionAction"> | <action name="deleteSelectionAction"> | ||||
| <property name="text"><string>删除所选</string></property> | <property name="text"><string>删除所选</string></property> | ||||
| <property name="toolTip"><string>删除当前画布中选中的对象</string></property> | <property name="toolTip"><string>删除当前画布中选中的对象</string></property> | ||||
| @@ -1112,14 +1094,6 @@ | |||||
| <string>添加数值输入控件</string> | <string>添加数值输入控件</string> | ||||
| </property> | </property> | ||||
| </action> | </action> | ||||
| <action name="addProgressBarAction"> | |||||
| <property name="text"> | |||||
| <string>进度条</string> | |||||
| </property> | |||||
| <property name="toolTip"> | |||||
| <string>添加进度条控件</string> | |||||
| </property> | |||||
| </action> | |||||
| <action name="addLabelAction"> | <action name="addLabelAction"> | ||||
| <property name="text"> | <property name="text"> | ||||
| <string>文本</string> | <string>文本</string> | ||||
| @@ -1252,18 +1226,6 @@ | |||||
| <string>在选中的空网格或横线处添加一个扫描周期的下降沿触点</string> | <string>在选中的空网格或横线处添加一个扫描周期的下降沿触点</string> | ||||
| </property> | </property> | ||||
| </action> | </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"> | <action name="addNormalCoilAction"> | ||||
| <property name="text"> | <property name="text"> | ||||
| <string>线圈</string> | <string>线圈</string> | ||||
| @@ -1288,22 +1250,6 @@ | |||||
| <string>设置当前网络的复位线圈</string> | <string>设置当前网络的复位线圈</string> | ||||
| </property> | </property> | ||||
| </action> | </action> | ||||
| <action name="addTonAction"> | |||||
| <property name="text"> | |||||
| <string>TON</string> | |||||
| </property> | |||||
| <property name="toolTip"> | |||||
| <string>设置非保持接通延时定时器输出</string> | |||||
| </property> | |||||
| </action> | |||||
| <action name="addCtuAction"> | |||||
| <property name="text"><string>CTU</string></property> | |||||
| <property name="toolTip"><string>设置按上升沿执行的加计数器</string></property> | |||||
| </action> | |||||
| <action name="addCtdAction"> | |||||
| <property name="text"><string>CTD</string></property> | |||||
| <property name="toolTip"><string>设置按上升沿执行的减计数器</string></property> | |||||
| </action> | |||||
| <action name="addMoveAction"> | <action name="addMoveAction"> | ||||
| <property name="text"><string>MOVE</string></property> | <property name="text"><string>MOVE</string></property> | ||||
| <property name="toolTip"><string>设置 D 数据传送指令</string></property> | <property name="toolTip"><string>设置 D 数据传送指令</string></property> | ||||
| @@ -3,9 +3,17 @@ | |||||
| #include "ui_plc_connection_dialog.h" | #include "ui_plc_connection_dialog.h" | ||||
| #include <QComboBox> | #include <QComboBox> | ||||
| #include <QDialogButtonBox> | |||||
| #include <QMessageBox> | |||||
| #include <QProgressBar> | |||||
| #include <QPushButton> | |||||
| #include <QSerialPortInfo> | #include <QSerialPortInfo> | ||||
| #include <QStyle> | |||||
| #include <QTimer> | |||||
| #include <QToolButton> | |||||
| #include <initializer_list> | #include <initializer_list> | ||||
| #include <utility> | |||||
| namespace { | namespace { | ||||
| @@ -32,9 +40,13 @@ void selectData(QComboBox *combo_box, int value) | |||||
| PlcConnectionDialog::PlcConnectionDialog( | PlcConnectionDialog::PlcConnectionDialog( | ||||
| const PlcSerialConfiguration &configuration, | const PlcSerialConfiguration &configuration, | ||||
| PlcDiscoveryGateway &discovery_gateway, | |||||
| std::function<void()> prepare_discovery, | |||||
| QWidget *parent) | QWidget *parent) | ||||
| : QDialog(parent), | : QDialog(parent), | ||||
| ui_(std::make_unique<Ui::PlcConnectionDialog>()) | |||||
| ui_(std::make_unique<Ui::PlcConnectionDialog>()), | |||||
| discovery_gateway_(discovery_gateway), | |||||
| prepare_discovery_(std::move(prepare_discovery)) | |||||
| { | { | ||||
| ui_->setupUi(this); | ui_->setupUi(this); | ||||
| setItemData(ui_->baudRateComboBox, {9600, 19200, 38400, 57600, 115200}); | setItemData(ui_->baudRateComboBox, {9600, 19200, 38400, 57600, 115200}); | ||||
| @@ -42,22 +54,8 @@ PlcConnectionDialog::PlcConnectionDialog( | |||||
| setItemData(ui_->parityComboBox, {0, 3, 2}); | setItemData(ui_->parityComboBox, {0, 3, 2}); | ||||
| setItemData(ui_->stopBitsComboBox, {1, 2}); | setItemData(ui_->stopBitsComboBox, {1, 2}); | ||||
| for (const QSerialPortInfo &port : QSerialPortInfo::availablePorts()) | |||||
| { | |||||
| ui_->serialPortComboBox->addItem( | |||||
| port.portName(), | |||||
| port.description().isEmpty() ? port.portName() : port.description()); | |||||
| } | |||||
| const QString port_name = QString::fromUtf8(configuration.portName.c_str()); | const QString port_name = QString::fromUtf8(configuration.portName.c_str()); | ||||
| if (!port_name.isEmpty()) | |||||
| { | |||||
| const int port_index = ui_->serialPortComboBox->findText(port_name); | |||||
| if (port_index < 0) | |||||
| { | |||||
| ui_->serialPortComboBox->addItem(port_name); | |||||
| } | |||||
| ui_->serialPortComboBox->setCurrentText(port_name); | |||||
| } | |||||
| refreshSerialPorts(port_name); | |||||
| ui_->serverAddressSpinBox->setValue(configuration.serverAddress); | ui_->serverAddressSpinBox->setValue(configuration.serverAddress); | ||||
| selectData(ui_->baudRateComboBox, configuration.baudRate); | selectData(ui_->baudRateComboBox, configuration.baudRate); | ||||
| @@ -67,9 +65,36 @@ PlcConnectionDialog::PlcConnectionDialog( | |||||
| ui_->responseTimeoutSpinBox->setValue(configuration.responseTimeoutMs); | ui_->responseTimeoutSpinBox->setValue(configuration.responseTimeoutMs); | ||||
| ui_->retriesSpinBox->setValue(configuration.retries); | ui_->retriesSpinBox->setValue(configuration.retries); | ||||
| ui_->pollIntervalSpinBox->setValue(configuration.pollIntervalMs); | ui_->pollIntervalSpinBox->setValue(configuration.pollIntervalMs); | ||||
| ui_->autoSearchButton->setIcon(style()->standardIcon(QStyle::SP_BrowserReload)); | |||||
| ui_->refreshPortsButton->setIcon(style()->standardIcon(QStyle::SP_BrowserReload)); | |||||
| ui_->discoveryProgressBar->setVisible(false); | |||||
| connect( | |||||
| ui_->refreshPortsButton, &QToolButton::clicked, | |||||
| this, | |||||
| [this] | |||||
| { | |||||
| refreshSerialPorts(ui_->serialPortComboBox->currentText().trimmed()); | |||||
| }); | |||||
| connect( | |||||
| ui_->autoSearchButton, &QPushButton::clicked, | |||||
| this, &PlcConnectionDialog::toggleAutomaticDiscovery); | |||||
| discovery_gateway_.setCallbacks( | |||||
| [this](const PlcDiscoveryProgress &progress) | |||||
| { | |||||
| showDiscoveryProgress(progress); | |||||
| }, | |||||
| [this](const PlcDiscoveryOutcome &outcome) | |||||
| { | |||||
| handleDiscoveryFinished(outcome); | |||||
| }); | |||||
| } | } | ||||
| PlcConnectionDialog::~PlcConnectionDialog() = default; | |||||
| PlcConnectionDialog::~PlcConnectionDialog() | |||||
| { | |||||
| discovery_gateway_.setCallbacks({}, {}); | |||||
| discovery_gateway_.cancelDiscovery(); | |||||
| } | |||||
| PlcSerialConfiguration PlcConnectionDialog::configuration() const | PlcSerialConfiguration PlcConnectionDialog::configuration() const | ||||
| { | { | ||||
| @@ -86,3 +111,139 @@ PlcSerialConfiguration PlcConnectionDialog::configuration() const | |||||
| result.pollIntervalMs = ui_->pollIntervalSpinBox->value(); | result.pollIntervalMs = ui_->pollIntervalSpinBox->value(); | ||||
| return result; | return result; | ||||
| } | } | ||||
| void PlcConnectionDialog::refreshSerialPorts(const QString &preferred_port) | |||||
| { | |||||
| ui_->serialPortComboBox->clear(); | |||||
| for (const QSerialPortInfo &port : QSerialPortInfo::availablePorts()) | |||||
| { | |||||
| ui_->serialPortComboBox->addItem( | |||||
| port.portName(), | |||||
| port.description().isEmpty() ? port.portName() : port.description()); | |||||
| } | |||||
| const int preferred_index = ui_->serialPortComboBox->findText(preferred_port); | |||||
| if (preferred_index >= 0) | |||||
| { | |||||
| ui_->serialPortComboBox->setCurrentIndex(preferred_index); | |||||
| } | |||||
| else if (ui_->serialPortComboBox->count() > 0) | |||||
| { | |||||
| ui_->serialPortComboBox->setCurrentIndex(0); | |||||
| } | |||||
| else | |||||
| { | |||||
| ui_->serialPortComboBox->clearEditText(); | |||||
| } | |||||
| } | |||||
| void PlcConnectionDialog::toggleAutomaticDiscovery() | |||||
| { | |||||
| if (discovery_gateway_.isDiscovering()) | |||||
| { | |||||
| ui_->autoSearchButton->setEnabled(false); | |||||
| ui_->autoSearchButton->setText(tr("正在停止")); | |||||
| discovery_gateway_.cancelDiscovery(); | |||||
| return; | |||||
| } | |||||
| const PlcCommunicationResult result = discovery_gateway_.startDiscovery( | |||||
| configuration()); | |||||
| if (!result.succeeded) | |||||
| { | |||||
| const QString message = QString::fromUtf8(result.message.c_str()); | |||||
| ui_->discoveryStatusLabel->setText(message); | |||||
| QMessageBox::information(this, tr("自动搜索 PLC"), message); | |||||
| return; | |||||
| } | |||||
| if (prepare_discovery_) | |||||
| { | |||||
| prepare_discovery_(); | |||||
| } | |||||
| setDiscoveryUiActive(true); | |||||
| } | |||||
| void PlcConnectionDialog::showDiscoveryProgress( | |||||
| const PlcDiscoveryProgress &progress) | |||||
| { | |||||
| const PlcSerialConfiguration &candidate = progress.configuration; | |||||
| const QChar parity = candidate.parity == 2 | |||||
| ? QLatin1Char('E') | |||||
| : candidate.parity == 3 ? QLatin1Char('O') : QLatin1Char('N'); | |||||
| ui_->discoveryProgressBar->setMaximum( | |||||
| static_cast<int>(progress.totalAttempts)); | |||||
| ui_->discoveryProgressBar->setValue( | |||||
| static_cast<int>(progress.currentAttempt)); | |||||
| ui_->discoveryStatusLabel->setText( | |||||
| tr("正在搜索 %1:站号 %2,%3,%4%5%6(%7/%8)") | |||||
| .arg(QString::fromUtf8(candidate.portName.c_str())) | |||||
| .arg(candidate.serverAddress) | |||||
| .arg(candidate.baudRate) | |||||
| .arg(candidate.dataBits) | |||||
| .arg(parity) | |||||
| .arg(candidate.stopBits) | |||||
| .arg(progress.currentAttempt) | |||||
| .arg(progress.totalAttempts)); | |||||
| } | |||||
| void PlcConnectionDialog::handleDiscoveryFinished( | |||||
| const PlcDiscoveryOutcome &outcome) | |||||
| { | |||||
| setDiscoveryUiActive(false); | |||||
| if (outcome.cancelled) | |||||
| { | |||||
| ui_->discoveryStatusLabel->setText(tr("已停止自动搜索")); | |||||
| return; | |||||
| } | |||||
| if (!outcome.found) | |||||
| { | |||||
| const QString message = outcome.message.empty() | |||||
| ? tr("未找到可响应的 PLC") | |||||
| : QString::fromUtf8(outcome.message.c_str()); | |||||
| ui_->discoveryStatusLabel->setText(message); | |||||
| QMessageBox::information(this, tr("自动搜索 PLC"), message); | |||||
| return; | |||||
| } | |||||
| applyConfiguration(outcome.configuration); | |||||
| ui_->discoveryStatusLabel->setText(tr("已找到 PLC,正在连接")); | |||||
| QTimer::singleShot(0, this, &QDialog::accept); | |||||
| } | |||||
| void PlcConnectionDialog::setDiscoveryUiActive(bool active) | |||||
| { | |||||
| ui_->discoveryProgressBar->setVisible(active); | |||||
| ui_->serialPortComboBox->setEnabled(!active); | |||||
| ui_->refreshPortsButton->setEnabled(!active); | |||||
| ui_->serverAddressSpinBox->setEnabled(!active); | |||||
| ui_->baudRateComboBox->setEnabled(!active); | |||||
| ui_->dataBitsComboBox->setEnabled(!active); | |||||
| ui_->parityComboBox->setEnabled(!active); | |||||
| ui_->stopBitsComboBox->setEnabled(!active); | |||||
| ui_->timingGroupBox->setEnabled(!active); | |||||
| QPushButton *ok_button = ui_->buttonBox->button(QDialogButtonBox::Ok); | |||||
| if (ok_button != nullptr) | |||||
| { | |||||
| ok_button->setEnabled(!active); | |||||
| } | |||||
| ui_->autoSearchButton->setEnabled(true); | |||||
| ui_->autoSearchButton->setText(active ? tr("停止搜索") : tr("自动搜索")); | |||||
| ui_->autoSearchButton->setIcon(style()->standardIcon( | |||||
| active ? QStyle::SP_BrowserStop : QStyle::SP_BrowserReload)); | |||||
| } | |||||
| void PlcConnectionDialog::applyConfiguration( | |||||
| const PlcSerialConfiguration &configuration) | |||||
| { | |||||
| const QString port_name = QString::fromUtf8(configuration.portName.c_str()); | |||||
| if (ui_->serialPortComboBox->findText(port_name) < 0) | |||||
| { | |||||
| ui_->serialPortComboBox->addItem(port_name); | |||||
| } | |||||
| ui_->serialPortComboBox->setCurrentText(port_name); | |||||
| ui_->serverAddressSpinBox->setValue(configuration.serverAddress); | |||||
| selectData(ui_->baudRateComboBox, configuration.baudRate); | |||||
| selectData(ui_->dataBitsComboBox, configuration.dataBits); | |||||
| selectData(ui_->parityComboBox, configuration.parity); | |||||
| selectData(ui_->stopBitsComboBox, configuration.stopBits); | |||||
| ui_->responseTimeoutSpinBox->setValue(configuration.responseTimeoutMs); | |||||
| ui_->retriesSpinBox->setValue(configuration.retries); | |||||
| ui_->pollIntervalSpinBox->setValue(configuration.pollIntervalMs); | |||||
| } | |||||
| @@ -1,27 +1,62 @@ | |||||
| /** | |||||
| * @file plc_connection_dialog.h | |||||
| * @brief 定义 PLC 串口连接参数对话框 | |||||
| * @version 1.0.0 | |||||
| * @author suyu | |||||
| * @date 2026-08-22 | |||||
| */ | |||||
| #pragma once | #pragma once | ||||
| #include "services/plc_communication_gateway.h" | #include "services/plc_communication_gateway.h" | ||||
| #include "services/plc_discovery_gateway.h" | |||||
| #include <QDialog> | #include <QDialog> | ||||
| #include <QString> | |||||
| #include <functional> | |||||
| #include <memory> | #include <memory> | ||||
| namespace Ui { | namespace Ui { | ||||
| class PlcConnectionDialog; | class PlcConnectionDialog; | ||||
| } | } | ||||
| /** PLC 串口参数对话框;只编辑配置,不负责打开串口 */ | |||||
| class PlcConnectionDialog final : public QDialog | class PlcConnectionDialog final : public QDialog | ||||
| { | { | ||||
| Q_OBJECT | Q_OBJECT | ||||
| public: | public: | ||||
| /** 使用现有配置初始化 PLC 参数表单 */ | |||||
| explicit PlcConnectionDialog( | explicit PlcConnectionDialog( | ||||
| const PlcSerialConfiguration &configuration, | const PlcSerialConfiguration &configuration, | ||||
| PlcDiscoveryGateway &discovery_gateway, | |||||
| std::function<void()> prepare_discovery, | |||||
| QWidget *parent = nullptr); | QWidget *parent = nullptr); | ||||
| /** 释放 Designer 界面对象 */ | |||||
| ~PlcConnectionDialog() override; | ~PlcConnectionDialog() override; | ||||
| /** 返回表单中的串口配置,连接前由网关再次校验 */ | |||||
| PlcSerialConfiguration configuration() const; | PlcSerialConfiguration configuration() const; | ||||
| private: | private: | ||||
| /** 重新枚举当前实际存在的串口,并尽量保留仍然可用的选择 */ | |||||
| void refreshSerialPorts(const QString &preferred_port); | |||||
| /** 开始自动搜索或请求停止当前搜索 */ | |||||
| void toggleAutomaticDiscovery(); | |||||
| /** 根据异步探测进度刷新界面 */ | |||||
| void showDiscoveryProgress(const PlcDiscoveryProgress &progress); | |||||
| /** 处理找到、未找到和用户取消三种结束结果 */ | |||||
| void handleDiscoveryFinished(const PlcDiscoveryOutcome &outcome); | |||||
| /** 切换搜索期间的表单编辑权限和按钮状态 */ | |||||
| void setDiscoveryUiActive(bool active); | |||||
| /** 把一组串口配置回填到界面 */ | |||||
| void applyConfiguration(const PlcSerialConfiguration &configuration); | |||||
| /** Qt Designer 生成的界面对象 */ | |||||
| std::unique_ptr<Ui::PlcConnectionDialog> ui_; | std::unique_ptr<Ui::PlcConnectionDialog> ui_; | ||||
| /** 异步 PLC 自动搜索服务 */ | |||||
| PlcDiscoveryGateway &discovery_gateway_; | |||||
| /** 搜索前释放正式 PLC 连接占用的串口 */ | |||||
| std::function<void()> prepare_discovery_; | |||||
| }; | }; | ||||
| @@ -6,54 +6,94 @@ | |||||
| <string>PLC 通信配置</string> | <string>PLC 通信配置</string> | ||||
| </property> | </property> | ||||
| <property name="minimumSize"> | <property name="minimumSize"> | ||||
| <size><width>420</width><height>0</height></size> | |||||
| <size><width>440</width><height>0</height></size> | |||||
| </property> | </property> | ||||
| <layout class="QVBoxLayout" name="dialogLayout"> | <layout class="QVBoxLayout" name="dialogLayout"> | ||||
| <item> | <item> | ||||
| <widget class="QGroupBox" name="serialGroupBox"> | <widget class="QGroupBox" name="serialGroupBox"> | ||||
| <property name="title"><string>Modbus RTU 串口</string></property> | <property name="title"><string>Modbus RTU 串口</string></property> | ||||
| <layout class="QFormLayout" name="serialFormLayout"> | |||||
| <property name="fieldGrowthPolicy"><enum>QFormLayout::AllNonFixedFieldsGrow</enum></property> | |||||
| <item row="0" column="0"><widget class="QLabel" name="serialPortLabel"><property name="text"><string>端口</string></property></widget></item> | |||||
| <item row="0" column="1"> | |||||
| <widget class="QComboBox" name="serialPortComboBox"> | |||||
| <property name="editable"><bool>true</bool></property> | |||||
| <property name="placeholderText"><string>选择或输入 COM 端口</string></property> | |||||
| </widget> | |||||
| </item> | |||||
| <item row="1" column="0"><widget class="QLabel" name="serverAddressLabel"><property name="text"><string>PLC 站号</string></property></widget></item> | |||||
| <item row="1" column="1"><widget class="QSpinBox" name="serverAddressSpinBox"><property name="minimum"><number>1</number></property><property name="maximum"><number>247</number></property></widget></item> | |||||
| <item row="2" column="0"><widget class="QLabel" name="baudRateLabel"><property name="text"><string>波特率</string></property></widget></item> | |||||
| <item row="2" column="1"> | |||||
| <widget class="QComboBox" name="baudRateComboBox"> | |||||
| <item><property name="text"><string>9600</string></property><property name="userData"><string notr="true">9600</string></property></item> | |||||
| <item><property name="text"><string>19200</string></property><property name="userData"><string notr="true">19200</string></property></item> | |||||
| <item><property name="text"><string>38400</string></property><property name="userData"><string notr="true">38400</string></property></item> | |||||
| <item><property name="text"><string>57600</string></property><property name="userData"><string notr="true">57600</string></property></item> | |||||
| <item><property name="text"><string>115200</string></property><property name="userData"><string notr="true">115200</string></property></item> | |||||
| </widget> | |||||
| <layout class="QVBoxLayout" name="serialGroupLayout"> | |||||
| <item> | |||||
| <layout class="QHBoxLayout" name="discoveryControlLayout"> | |||||
| <item> | |||||
| <widget class="QPushButton" name="autoSearchButton"> | |||||
| <property name="text"><string>自动搜索</string></property> | |||||
| </widget> | |||||
| </item> | |||||
| <item> | |||||
| <widget class="QProgressBar" name="discoveryProgressBar"> | |||||
| <property name="minimum"><number>0</number></property> | |||||
| <property name="maximum"><number>100</number></property> | |||||
| <property name="value"><number>0</number></property> | |||||
| <property name="textVisible"><bool>false</bool></property> | |||||
| </widget> | |||||
| </item> | |||||
| </layout> | |||||
| </item> | </item> | ||||
| <item row="3" column="0"><widget class="QLabel" name="dataBitsLabel"><property name="text"><string>数据位</string></property></widget></item> | |||||
| <item row="3" column="1"> | |||||
| <widget class="QComboBox" name="dataBitsComboBox"> | |||||
| <item><property name="text"><string>7</string></property><property name="userData"><string notr="true">7</string></property></item> | |||||
| <item><property name="text"><string>8</string></property><property name="userData"><string notr="true">8</string></property></item> | |||||
| <item> | |||||
| <widget class="QLabel" name="discoveryStatusLabel"> | |||||
| <property name="text"><string>自动搜索使用当前 PLC 站号</string></property> | |||||
| <property name="wordWrap"><bool>true</bool></property> | |||||
| </widget> | </widget> | ||||
| </item> | </item> | ||||
| <item row="4" column="0"><widget class="QLabel" name="parityLabel"><property name="text"><string>校验方式</string></property></widget></item> | |||||
| <item row="4" column="1"> | |||||
| <widget class="QComboBox" name="parityComboBox"> | |||||
| <item><property name="text"><string>无校验</string></property><property name="userData"><string notr="true">0</string></property></item> | |||||
| <item><property name="text"><string>奇校验</string></property><property name="userData"><string notr="true">3</string></property></item> | |||||
| <item><property name="text"><string>偶校验</string></property><property name="userData"><string notr="true">2</string></property></item> | |||||
| </widget> | |||||
| </item> | |||||
| <item row="5" column="0"><widget class="QLabel" name="stopBitsLabel"><property name="text"><string>停止位</string></property></widget></item> | |||||
| <item row="5" column="1"> | |||||
| <widget class="QComboBox" name="stopBitsComboBox"> | |||||
| <item><property name="text"><string>1</string></property><property name="userData"><string notr="true">1</string></property></item> | |||||
| <item><property name="text"><string>2</string></property><property name="userData"><string notr="true">2</string></property></item> | |||||
| </widget> | |||||
| <item> | |||||
| <layout class="QFormLayout" name="serialFormLayout"> | |||||
| <property name="fieldGrowthPolicy"><enum>QFormLayout::AllNonFixedFieldsGrow</enum></property> | |||||
| <item row="0" column="0"><widget class="QLabel" name="serialPortLabel"><property name="text"><string>端口</string></property></widget></item> | |||||
| <item row="0" column="1"> | |||||
| <layout class="QHBoxLayout" name="serialPortEditorLayout"> | |||||
| <item> | |||||
| <widget class="QComboBox" name="serialPortComboBox"> | |||||
| <property name="editable"><bool>true</bool></property> | |||||
| <property name="placeholderText"><string>选择或输入 COM 端口</string></property> | |||||
| </widget> | |||||
| </item> | |||||
| <item> | |||||
| <widget class="QToolButton" name="refreshPortsButton"> | |||||
| <property name="minimumSize"><size><width>28</width><height>28</height></size></property> | |||||
| <property name="maximumSize"><size><width>28</width><height>28</height></size></property> | |||||
| <property name="toolTip"><string>刷新串口</string></property> | |||||
| <property name="accessibleName"><string>刷新串口</string></property> | |||||
| <property name="autoRaise"><bool>true</bool></property> | |||||
| </widget> | |||||
| </item> | |||||
| </layout> | |||||
| </item> | |||||
| <item row="1" column="0"><widget class="QLabel" name="serverAddressLabel"><property name="text"><string>PLC 站号</string></property></widget></item> | |||||
| <item row="1" column="1"><widget class="QSpinBox" name="serverAddressSpinBox"><property name="minimum"><number>1</number></property><property name="maximum"><number>247</number></property></widget></item> | |||||
| <item row="2" column="0"><widget class="QLabel" name="baudRateLabel"><property name="text"><string>波特率</string></property></widget></item> | |||||
| <item row="2" column="1"> | |||||
| <widget class="QComboBox" name="baudRateComboBox"> | |||||
| <item><property name="text"><string>9600</string></property><property name="userData"><string notr="true">9600</string></property></item> | |||||
| <item><property name="text"><string>19200</string></property><property name="userData"><string notr="true">19200</string></property></item> | |||||
| <item><property name="text"><string>38400</string></property><property name="userData"><string notr="true">38400</string></property></item> | |||||
| <item><property name="text"><string>57600</string></property><property name="userData"><string notr="true">57600</string></property></item> | |||||
| <item><property name="text"><string>115200</string></property><property name="userData"><string notr="true">115200</string></property></item> | |||||
| </widget> | |||||
| </item> | |||||
| <item row="3" column="0"><widget class="QLabel" name="dataBitsLabel"><property name="text"><string>数据位</string></property></widget></item> | |||||
| <item row="3" column="1"> | |||||
| <widget class="QComboBox" name="dataBitsComboBox"> | |||||
| <item><property name="text"><string>7</string></property><property name="userData"><string notr="true">7</string></property></item> | |||||
| <item><property name="text"><string>8</string></property><property name="userData"><string notr="true">8</string></property></item> | |||||
| </widget> | |||||
| </item> | |||||
| <item row="4" column="0"><widget class="QLabel" name="parityLabel"><property name="text"><string>校验方式</string></property></widget></item> | |||||
| <item row="4" column="1"> | |||||
| <widget class="QComboBox" name="parityComboBox"> | |||||
| <item><property name="text"><string>无校验</string></property><property name="userData"><string notr="true">0</string></property></item> | |||||
| <item><property name="text"><string>奇校验</string></property><property name="userData"><string notr="true">3</string></property></item> | |||||
| <item><property name="text"><string>偶校验</string></property><property name="userData"><string notr="true">2</string></property></item> | |||||
| </widget> | |||||
| </item> | |||||
| <item row="5" column="0"><widget class="QLabel" name="stopBitsLabel"><property name="text"><string>停止位</string></property></widget></item> | |||||
| <item row="5" column="1"> | |||||
| <widget class="QComboBox" name="stopBitsComboBox"> | |||||
| <item><property name="text"><string>1</string></property><property name="userData"><string notr="true">1</string></property></item> | |||||
| <item><property name="text"><string>2</string></property><property name="userData"><string notr="true">2</string></property></item> | |||||
| </widget> | |||||
| </item> | |||||
| </layout> | |||||
| </item> | </item> | ||||
| </layout> | </layout> | ||||
| </widget> | </widget> | ||||
| @@ -11,9 +11,10 @@ | |||||
| #include "ui_main_window.h" | #include "ui_main_window.h" | ||||
| #include <QAction> | #include <QAction> | ||||
| #include <QDialogButtonBox> | |||||
| #include <QInputDialog> | #include <QInputDialog> | ||||
| #include <QLineEdit> | |||||
| #include <QObject> | #include <QObject> | ||||
| #include <QPushButton> | |||||
| #include <QSignalBlocker> | #include <QSignalBlocker> | ||||
| #include <QTabWidget> | #include <QTabWidget> | ||||
| #include <QTreeWidget> | #include <QTreeWidget> | ||||
| @@ -38,6 +39,43 @@ std::string toUtf8(const QString &value) | |||||
| return std::string(bytes.constData(), static_cast<std::size_t>(bytes.size())); | return std::string(bytes.constData(), static_cast<std::size_t>(bytes.size())); | ||||
| } | } | ||||
| bool requestRequiredName( | |||||
| QWidget &parent, | |||||
| const QString &title, | |||||
| const QString &label, | |||||
| const QString &initial_value, | |||||
| QString *name) | |||||
| { | |||||
| QInputDialog dialog(&parent); | |||||
| dialog.setWindowTitle(title); | |||||
| dialog.setLabelText(label); | |||||
| dialog.setInputMode(QInputDialog::TextInput); | |||||
| dialog.setTextValue(initial_value); | |||||
| QDialogButtonBox *button_box = dialog.findChild<QDialogButtonBox *>(); | |||||
| QPushButton *ok_button = button_box != nullptr | |||||
| ? button_box->button(QDialogButtonBox::Ok) | |||||
| : nullptr; | |||||
| if (ok_button != nullptr) | |||||
| { | |||||
| ok_button->setEnabled(!initial_value.trimmed().isEmpty()); | |||||
| QObject::connect( | |||||
| &dialog, | |||||
| &QInputDialog::textValueChanged, | |||||
| &dialog, | |||||
| [ok_button](const QString &text) { | |||||
| ok_button->setEnabled(!text.trimmed().isEmpty()); | |||||
| }); | |||||
| } | |||||
| if (dialog.exec() != QDialog::Accepted) | |||||
| { | |||||
| return false; | |||||
| } | |||||
| *name = dialog.textValue().trimmed(); | |||||
| return true; | |||||
| } | |||||
| } // namespace | } // namespace | ||||
| ProjectWorkspaceController::ProjectWorkspaceController( | ProjectWorkspaceController::ProjectWorkspaceController( | ||||
| @@ -295,9 +333,9 @@ void ProjectWorkspaceController::updateActions() | |||||
| void ProjectWorkspaceController::addPage() | void ProjectWorkspaceController::addPage() | ||||
| { | { | ||||
| const QString name = QInputDialog::getText( | |||||
| &parent_, QObject::tr("新建页面"), QObject::tr("页面名称")); | |||||
| if (name.isEmpty()) | |||||
| QString name; | |||||
| if (!requestRequiredName( | |||||
| parent_, QObject::tr("新建页面"), QObject::tr("页面名称"), {}, &name)) | |||||
| { | { | ||||
| return; | return; | ||||
| } | } | ||||
| @@ -317,9 +355,9 @@ void ProjectWorkspaceController::addPage() | |||||
| void ProjectWorkspaceController::addLogic() | void ProjectWorkspaceController::addLogic() | ||||
| { | { | ||||
| const QString name = QInputDialog::getText( | |||||
| &parent_, QObject::tr("新建控制逻辑"), QObject::tr("逻辑名称")); | |||||
| if (name.isEmpty()) | |||||
| QString name; | |||||
| if (!requestRequiredName( | |||||
| parent_, QObject::tr("新建控制逻辑"), QObject::tr("逻辑名称"), {}, &name)) | |||||
| { | { | ||||
| return; | return; | ||||
| } | } | ||||
| @@ -362,11 +400,9 @@ void ProjectWorkspaceController::renameSelectedItem() | |||||
| { | { | ||||
| return; | return; | ||||
| } | } | ||||
| bool accepted = false; | |||||
| const QString name = QInputDialog::getText( | |||||
| &parent_, QObject::tr("重命名"), QObject::tr("新名称"), | |||||
| QLineEdit::Normal, current_name, &accepted); | |||||
| if (!accepted) | |||||
| QString name; | |||||
| if (!requestRequiredName( | |||||
| parent_, QObject::tr("重命名"), QObject::tr("新名称"), current_name, &name)) | |||||
| { | { | ||||
| return; | return; | ||||
| } | } | ||||
| @@ -1,3 +1,11 @@ | |||||
| /** | |||||
| * @file project_workspace_controller.h | |||||
| * @brief 定义工程树和当前编辑对象控制器 | |||||
| * @version 1.0.0 | |||||
| * @author suyu | |||||
| * @date 2026-08-22 | |||||
| */ | |||||
| #pragma once | #pragma once | ||||
| #include "domain/hmi_model.h" | #include "domain/hmi_model.h" | ||||
| @@ -23,16 +31,24 @@ namespace Ui { | |||||
| class MainWindow; | class MainWindow; | ||||
| } | } | ||||
| // 组织工程树、当前页面/逻辑选择和工程对象的界面刷新 | |||||
| /** | |||||
| * 组织工程树、当前页面/逻辑选择和工程对象的界面刷新 | |||||
| * | |||||
| * 它不直接改 Project,所有增删改都转发给对应服务 | |||||
| */ | |||||
| class ProjectWorkspaceController final | class ProjectWorkspaceController final | ||||
| { | { | ||||
| public: | public: | ||||
| /** 报告工程操作结果,参数依次为操作名、消息和是否成功 */ | |||||
| using ResultReporter = std::function<void( | using ResultReporter = std::function<void( | ||||
| const QString &action, const QString &message, bool succeeded)>; | const QString &action, const QString &message, bool succeeded)>; | ||||
| /** 向状态栏报告一条带超时的消息 */ | |||||
| using StatusReporter = std::function<void( | using StatusReporter = std::function<void( | ||||
| const QString &message, int timeout_ms)>; | const QString &message, int timeout_ms)>; | ||||
| /** 通知主窗口编辑状态已经发生变化 */ | |||||
| using EditStateChanged = std::function<void()>; | using EditStateChanged = std::function<void()>; | ||||
| /** 创建工程树控制器并绑定主窗口服务和编辑器 */ | |||||
| ProjectWorkspaceController( | ProjectWorkspaceController( | ||||
| QWidget &parent, | QWidget &parent, | ||||
| Ui::MainWindow &ui, | Ui::MainWindow &ui, | ||||
| @@ -52,56 +68,96 @@ public: | |||||
| StatusReporter status_reporter, | StatusReporter status_reporter, | ||||
| EditStateChanged edit_state_changed); | EditStateChanged edit_state_changed); | ||||
| /** 连接工程树动作和选择变化信号 */ | |||||
| void configure(); | void configure(); | ||||
| /** 根据当前工程重新生成工程树和编辑器内容 */ | |||||
| void refresh(); | void refresh(); | ||||
| /** 根据当前选中对象更新工程树动作的可用状态 */ | |||||
| void updateActions(); | void updateActions(); | ||||
| /** 新增一个 HMI 页面 */ | |||||
| void addPage(); | void addPage(); | ||||
| /** 新增一个控制逻辑 */ | |||||
| void addLogic(); | void addLogic(); | ||||
| /** 重命名当前选中的工程树项目 */ | |||||
| void renameSelectedItem(); | void renameSelectedItem(); | ||||
| /** 删除当前选中的工程树项目 */ | |||||
| void deleteSelectedItem(); | void deleteSelectedItem(); | ||||
| /** 按偏移量移动当前选中的工程树项目 */ | |||||
| void moveSelectedItem(int offset); | void moveSelectedItem(int offset); | ||||
| /** 把当前选中的 HMI 页面设为初始页面 */ | |||||
| void setSelectedPageAsInitial(); | void setSelectedPageAsInitial(); | ||||
| /** 启用或停用当前选中的控制逻辑 */ | |||||
| void toggleSelectedLogicEnabled(); | void toggleSelectedLogicEnabled(); | ||||
| /** 返回当前 HMI 页面标识 */ | |||||
| const std::string ¤tHmiPageId() const; | const std::string ¤tHmiPageId() const; | ||||
| /** 返回当前控制逻辑标识 */ | |||||
| const std::string ¤tLogicId() const; | const std::string ¤tLogicId() const; | ||||
| private: | private: | ||||
| /** 工程树项目的三种业务类型 */ | |||||
| enum class ItemKind | enum class ItemKind | ||||
| { | { | ||||
| Root = 0, | |||||
| HmiPage = 1, | |||||
| ControlLogic = 2 | |||||
| Root = 0, // 工程树根节点 | |||||
| HmiPage = 1, // HMI 页面节点 | |||||
| ControlLogic = 2 // 控制逻辑节点 | |||||
| }; | }; | ||||
| /** 处理工程树选择变化并刷新当前编辑器 */ | |||||
| void handleSelectionChanged(); | void handleSelectionChanged(); | ||||
| /** 统一报告工程操作失败 */ | |||||
| void reportFailure( | void reportFailure( | ||||
| const QString &action, const std::string &message) const; | const QString &action, const std::string &message) const; | ||||
| /** 主窗口父对象,不由控制器拥有 */ | |||||
| QWidget &parent_; | QWidget &parent_; | ||||
| /** 主窗口的 Designer 界面对象 */ | |||||
| Ui::MainWindow &ui_; | Ui::MainWindow &ui_; | ||||
| /** 工程服务,不由控制器拥有 */ | |||||
| ProjectService &project_service_; | ProjectService &project_service_; | ||||
| /** HMI 编辑服务,不由控制器拥有 */ | |||||
| HmiEditorService &hmi_editor_service_; | HmiEditorService &hmi_editor_service_; | ||||
| /** 梯形图编辑服务,不由控制器拥有 */ | |||||
| LogicEditorService &logic_editor_service_; | LogicEditorService &logic_editor_service_; | ||||
| /** 运行模式服务,不由控制器拥有 */ | |||||
| RuntimeModeService &runtime_mode_service_; | RuntimeModeService &runtime_mode_service_; | ||||
| /** HMI 页面导航服务,不由控制器拥有 */ | |||||
| HmiNavigationService &hmi_navigation_service_; | HmiNavigationService &hmi_navigation_service_; | ||||
| /** 主窗口中的 HMI 编辑器 */ | |||||
| HmiEditorWidget &hmi_editor_widget_; | HmiEditorWidget &hmi_editor_widget_; | ||||
| /** 主窗口中的梯形图编辑器 */ | |||||
| LogicEditorWidget &logic_editor_widget_; | LogicEditorWidget &logic_editor_widget_; | ||||
| /** 运行监控控件 */ | |||||
| RuntimeMonitorWidget &runtime_monitor_widget_; | RuntimeMonitorWidget &runtime_monitor_widget_; | ||||
| /** 主窗口保存的当前 HMI 页面标识 */ | |||||
| std::string ¤t_hmi_page_id_; | std::string ¤t_hmi_page_id_; | ||||
| /** 主窗口保存的当前控制逻辑标识 */ | |||||
| std::string ¤t_logic_id_; | std::string ¤t_logic_id_; | ||||
| /** 显示 HMI 控件属性的回调 */ | |||||
| std::function<void(const std::string &)> show_control_properties_; | std::function<void(const std::string &)> show_control_properties_; | ||||
| /** 显示梯形图节点属性的回调 */ | |||||
| std::function<void(const std::string &)> show_logic_properties_; | std::function<void(const std::string &)> show_logic_properties_; | ||||
| /** 工程操作结果回调 */ | |||||
| ResultReporter result_reporter_; | ResultReporter result_reporter_; | ||||
| /** 状态栏消息回调 */ | |||||
| StatusReporter status_reporter_; | StatusReporter status_reporter_; | ||||
| /** 编辑状态变化回调 */ | |||||
| EditStateChanged edit_state_changed_; | EditStateChanged edit_state_changed_; | ||||
| /** 新增页面动作 */ | |||||
| QAction *add_page_action_ = nullptr; | QAction *add_page_action_ = nullptr; | ||||
| /** 新增逻辑动作 */ | |||||
| QAction *add_logic_action_ = nullptr; | QAction *add_logic_action_ = nullptr; | ||||
| /** 重命名动作 */ | |||||
| QAction *rename_item_action_ = nullptr; | QAction *rename_item_action_ = nullptr; | ||||
| /** 删除工程树项目动作 */ | |||||
| QAction *delete_item_action_ = nullptr; | QAction *delete_item_action_ = nullptr; | ||||
| /** 上移工程树项目动作 */ | |||||
| QAction *move_item_up_action_ = nullptr; | QAction *move_item_up_action_ = nullptr; | ||||
| /** 下移工程树项目动作 */ | |||||
| QAction *move_item_down_action_ = nullptr; | QAction *move_item_down_action_ = nullptr; | ||||
| /** 设置初始页面动作 */ | |||||
| QAction *set_initial_page_action_ = nullptr; | QAction *set_initial_page_action_ = nullptr; | ||||
| /** 启停控制逻辑动作 */ | |||||
| QAction *toggle_logic_enabled_action_ = nullptr; | QAction *toggle_logic_enabled_action_ = nullptr; | ||||
| }; | }; | ||||
| @@ -62,6 +62,7 @@ PropertyPanelController::PropertyPanelController( | |||||
| ProjectService &project_service, | ProjectService &project_service, | ||||
| HmiEditorService &hmi_editor_service, | HmiEditorService &hmi_editor_service, | ||||
| LogicEditorService &logic_editor_service, | LogicEditorService &logic_editor_service, | ||||
| const HmiDefaultSettings &hmi_defaults, | |||||
| std::function<std::string()> current_page_id, | std::function<std::string()> current_page_id, | ||||
| std::function<std::string()> current_logic_id, | std::function<std::string()> current_logic_id, | ||||
| std::string &selected_control_id, | std::string &selected_control_id, | ||||
| @@ -74,6 +75,7 @@ PropertyPanelController::PropertyPanelController( | |||||
| project_service_(project_service), | project_service_(project_service), | ||||
| hmi_editor_service_(hmi_editor_service), | hmi_editor_service_(hmi_editor_service), | ||||
| logic_editor_service_(logic_editor_service), | logic_editor_service_(logic_editor_service), | ||||
| hmi_defaults_(hmi_defaults), | |||||
| current_page_id_(std::move(current_page_id)), | current_page_id_(std::move(current_page_id)), | ||||
| current_logic_id_(std::move(current_logic_id)), | current_logic_id_(std::move(current_logic_id)), | ||||
| selected_control_id_(selected_control_id), | selected_control_id_(selected_control_id), | ||||
| @@ -106,6 +108,10 @@ void PropertyPanelController::configure() | |||||
| 2, static_cast<int>(HmiButtonOperation::Toggle)); | 2, static_cast<int>(HmiButtonOperation::Toggle)); | ||||
| ui_.buttonOperationComboBox->setItemData( | ui_.buttonOperationComboBox->setItemData( | ||||
| 3, static_cast<int>(HmiButtonOperation::MomentaryOn)); | 3, static_cast<int>(HmiButtonOperation::MomentaryOn)); | ||||
| ui_.dataTypeComboBox->setItemData( | |||||
| 0, static_cast<int>(RegisterDataType::Int16)); | |||||
| ui_.dataTypeComboBox->setItemData( | |||||
| 1, static_cast<int>(RegisterDataType::Float32)); | |||||
| for (int index = 0; index < ui_.logicComparisonComboBox->count(); ++index) | for (int index = 0; index < ui_.logicComparisonComboBox->count(); ++index) | ||||
| { | { | ||||
| ui_.logicComparisonComboBox->setItemData(index, index); | ui_.logicComparisonComboBox->setItemData(index, index); | ||||
| @@ -193,9 +199,7 @@ void PropertyPanelController::showControlProperties(const std::string &control_i | |||||
| static_cast<QWidget *>(ui_.bindingIndexSpinBox), | static_cast<QWidget *>(ui_.bindingIndexSpinBox), | ||||
| static_cast<QWidget *>(ui_.targetPageComboBox), | static_cast<QWidget *>(ui_.targetPageComboBox), | ||||
| static_cast<QWidget *>(ui_.buttonOperationComboBox), | static_cast<QWidget *>(ui_.buttonOperationComboBox), | ||||
| static_cast<QWidget *>(ui_.progressMinimumSpinBox), | |||||
| static_cast<QWidget *>(ui_.progressMaximumSpinBox), | |||||
| static_cast<QWidget *>(ui_.progressShowValueCheckBox), | |||||
| static_cast<QWidget *>(ui_.dataTypeComboBox), | |||||
| static_cast<QWidget *>(ui_.textColorEdit), | static_cast<QWidget *>(ui_.textColorEdit), | ||||
| static_cast<QWidget *>(ui_.textColorButton), | static_cast<QWidget *>(ui_.textColorButton), | ||||
| static_cast<QWidget *>(ui_.fontSizeSpinBox), | static_cast<QWidget *>(ui_.fontSizeSpinBox), | ||||
| @@ -218,12 +222,8 @@ void PropertyPanelController::showControlProperties(const std::string &control_i | |||||
| ui_.targetPageComboBox->setVisible(false); | ui_.targetPageComboBox->setVisible(false); | ||||
| ui_.buttonOperationLabel->setVisible(false); | ui_.buttonOperationLabel->setVisible(false); | ||||
| ui_.buttonOperationComboBox->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_.dataTypeLabel->setVisible(false); | |||||
| ui_.dataTypeComboBox->setVisible(false); | |||||
| ui_.textColorEdit->clear(); | ui_.textColorEdit->clear(); | ||||
| ui_.textColorButton->setStyleSheet(QString{}); | ui_.textColorButton->setStyleSheet(QString{}); | ||||
| ui_.fontSizeSpinBox->setValue( | ui_.fontSizeSpinBox->setValue( | ||||
| @@ -263,6 +263,14 @@ void PropertyPanelController::showControlProperties(const std::string &control_i | |||||
| ui_.buttonOperationComboBox->findData( | ui_.buttonOperationComboBox->findData( | ||||
| static_cast<int>(control->buttonOperation))); | static_cast<int>(control->buttonOperation))); | ||||
| const bool is_numeric = control->type == HmiControlType::NumericDisplay | |||||
| || control->type == HmiControlType::NumericInput; | |||||
| ui_.dataTypeLabel->setVisible(is_numeric); | |||||
| ui_.dataTypeComboBox->setVisible(is_numeric); | |||||
| ui_.dataTypeComboBox->setEnabled(is_numeric); | |||||
| ui_.dataTypeComboBox->setCurrentIndex( | |||||
| ui_.dataTypeComboBox->findData(static_cast<int>(control->dataType))); | |||||
| const bool is_page_jump = control->type == HmiControlType::PageJump; | const bool is_page_jump = control->type == HmiControlType::PageJump; | ||||
| ui_.targetPageLabel->setVisible(is_page_jump); | ui_.targetPageLabel->setVisible(is_page_jump); | ||||
| ui_.targetPageComboBox->setVisible(is_page_jump); | ui_.targetPageComboBox->setVisible(is_page_jump); | ||||
| @@ -281,22 +289,6 @@ void PropertyPanelController::showControlProperties(const std::string &control_i | |||||
| ui_.targetPageComboBox->findData(fromUtf8(target_id))); | 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( | const auto text_color = control->properties.find( | ||||
| HmiAppearanceProperty::kTextColor); | HmiAppearanceProperty::kTextColor); | ||||
| ui_.textColorEdit->setText(text_color == control->properties.cend() | ui_.textColorEdit->setText(text_color == control->properties.cend() | ||||
| @@ -356,8 +348,8 @@ void PropertyPanelController::showPageProperties(const std::string &page_id) | |||||
| ui_.applyPagePropertiesButton->setEnabled(has_page); | ui_.applyPagePropertiesButton->setEnabled(has_page); | ||||
| if (!has_page) | if (!has_page) | ||||
| { | { | ||||
| ui_.pageWidthSpinBox->setValue(ProjectLimits::kDefaultHmiPageWidth); | |||||
| ui_.pageHeightSpinBox->setValue(ProjectLimits::kDefaultHmiPageHeight); | |||||
| ui_.pageWidthSpinBox->setValue(hmi_defaults_.pageWidth); | |||||
| ui_.pageHeightSpinBox->setValue(hmi_defaults_.pageHeight); | |||||
| return; | return; | ||||
| } | } | ||||
| ui_.pageWidthSpinBox->setValue(page->width); | ui_.pageWidthSpinBox->setValue(page->width); | ||||
| @@ -378,7 +370,6 @@ void PropertyPanelController::showLogicNodeProperties(const std::string &node_id | |||||
| static_cast<QWidget *>(ui_.logicModeComboBox), | static_cast<QWidget *>(ui_.logicModeComboBox), | ||||
| static_cast<QWidget *>(ui_.logicComparisonComboBox), | static_cast<QWidget *>(ui_.logicComparisonComboBox), | ||||
| static_cast<QWidget *>(ui_.logicValueSpinBox), | static_cast<QWidget *>(ui_.logicValueSpinBox), | ||||
| static_cast<QWidget *>(ui_.logicPresetSpinBox), | |||||
| static_cast<QWidget *>(ui_.applyLogicPropertiesButton)}) | static_cast<QWidget *>(ui_.applyLogicPropertiesButton)}) | ||||
| { | { | ||||
| widget->setEnabled(has_node); | widget->setEnabled(has_node); | ||||
| @@ -391,7 +382,6 @@ void PropertyPanelController::showLogicNodeProperties(const std::string &node_id | |||||
| ui_.logicNodeTypeLabel->clear(); | ui_.logicNodeTypeLabel->clear(); | ||||
| ui_.logicAddressAreaLabel->clear(); | ui_.logicAddressAreaLabel->clear(); | ||||
| ui_.logicModeComboBox->clear(); | ui_.logicModeComboBox->clear(); | ||||
| ui_.logicPresetSpinBox->setValue(TonNodeConfig::kMinimumPresetMs); | |||||
| return; | return; | ||||
| } | } | ||||
| @@ -399,7 +389,6 @@ void PropertyPanelController::showLogicNodeProperties(const std::string &node_id | |||||
| ui_.logicModeComboBox->clear(); | ui_.logicModeComboBox->clear(); | ||||
| ui_.logicComparisonComboBox->setEnabled(false); | ui_.logicComparisonComboBox->setEnabled(false); | ||||
| ui_.logicValueSpinBox->setEnabled(false); | ui_.logicValueSpinBox->setEnabled(false); | ||||
| ui_.logicPresetSpinBox->setEnabled(false); | |||||
| if (const auto *contact = std::get_if<ContactNodeConfig>(&node->config)) | if (const auto *contact = std::get_if<ContactNodeConfig>(&node->config)) | ||||
| { | { | ||||
| ui_.logicNodeTypeLabel->setText(QObject::tr("触点")); | ui_.logicNodeTypeLabel->setText(QObject::tr("触点")); | ||||
| @@ -426,33 +415,6 @@ void PropertyPanelController::showLogicNodeProperties(const std::string &node_id | |||||
| ui_.logicModeComboBox->findData(static_cast<int>(edge->mode))); | ui_.logicModeComboBox->findData(static_cast<int>(edge->mode))); | ||||
| ui_.logicModeComboBox->setEnabled(true); | 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)) | else if (const auto *coil = std::get_if<CoilNodeConfig>(&node->config)) | ||||
| { | { | ||||
| ui_.logicNodeTypeLabel->setText(QObject::tr("线圈")); | ui_.logicNodeTypeLabel->setText(QObject::tr("线圈")); | ||||
| @@ -482,27 +444,6 @@ void PropertyPanelController::showLogicNodeProperties(const std::string &node_id | |||||
| ui_.logicValueSpinBox->setEnabled(true); | ui_.logicValueSpinBox->setEnabled(true); | ||||
| ui_.logicValueSpinBox->setValue(compare->value); | 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)) | else if (const auto *move = std::get_if<MoveNodeConfig>(&node->config)) | ||||
| { | { | ||||
| ui_.logicNodeTypeLabel->setText(QStringLiteral("MOVE")); | ui_.logicNodeTypeLabel->setText(QStringLiteral("MOVE")); | ||||
| @@ -591,6 +532,8 @@ void PropertyPanelController::applySelectedControlProperties() | |||||
| control.bounds.y = ui_.controlYSpinBox->value(); | control.bounds.y = ui_.controlYSpinBox->value(); | ||||
| control.bounds.width = ui_.controlWidthSpinBox->value(); | control.bounds.width = ui_.controlWidthSpinBox->value(); | ||||
| control.bounds.height = ui_.controlHeightSpinBox->value(); | control.bounds.height = ui_.controlHeightSpinBox->value(); | ||||
| control.dataType = static_cast<RegisterDataType>( | |||||
| ui_.dataTypeComboBox->currentData().toInt()); | |||||
| const HmiControlDescriptor *descriptor = | const HmiControlDescriptor *descriptor = | ||||
| findHmiControlDescriptor(control.type); | findHmiControlDescriptor(control.type); | ||||
| if (descriptor == nullptr | if (descriptor == nullptr | ||||
| @@ -623,13 +566,6 @@ void PropertyPanelController::applySelectedControlProperties() | |||||
| control.pageJump = HmiPageJumpConfig{ | control.pageJump = HmiPageJumpConfig{ | ||||
| toUtf8(ui_.targetPageComboBox->currentData().toString())}; | 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()); | const std::string text_color = toUtf8(ui_.textColorEdit->text()); | ||||
| if (text_color.empty()) | if (text_color.empty()) | ||||
| { | { | ||||
| @@ -726,18 +662,6 @@ void PropertyPanelController::applySelectedLogicNodeProperties() | |||||
| RegisterAddress{RegisterArea::M, ui_.logicAddressSpinBox->value()}, | RegisterAddress{RegisterArea::M, ui_.logicAddressSpinBox->value()}, | ||||
| static_cast<EdgeMode>(ui_.logicModeComboBox->currentData().toInt())}; | 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)) | else if (std::holds_alternative<CoilNodeConfig>(config)) | ||||
| { | { | ||||
| config = CoilNodeConfig{ | config = CoilNodeConfig{ | ||||
| @@ -752,12 +676,6 @@ void PropertyPanelController::applySelectedLogicNodeProperties() | |||||
| ui_.logicComparisonComboBox->currentData().toInt()), | ui_.logicComparisonComboBox->currentData().toInt()), | ||||
| static_cast<std::int16_t>(ui_.logicValueSpinBox->value())}; | 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 | else | ||||
| { | { | ||||
| LogicInstructionDialog dialog(node->config, &parent_); | LogicInstructionDialog dialog(node->config, &parent_); | ||||
| @@ -1,3 +1,11 @@ | |||||
| /** | |||||
| * @file property_panel_controller.h | |||||
| * @brief 定义 HMI 和梯形图属性面板控制器 | |||||
| * @version 1.0.0 | |||||
| * @author suyu | |||||
| * @date 2026-08-22 | |||||
| */ | |||||
| #pragma once | #pragma once | ||||
| #include "domain/control_logic_model.h" | #include "domain/control_logic_model.h" | ||||
| @@ -19,21 +27,29 @@ namespace Ui { | |||||
| class MainWindow; | class MainWindow; | ||||
| } | } | ||||
| // 组织 HMI/梯形图选择状态、属性面板和编辑器信号 | |||||
| /** | |||||
| * 组织 HMI/梯形图选择状态、属性面板和编辑器信号 | |||||
| * | |||||
| * 属性提交统一经过编辑服务,以便校验、原子回滚和撤销/重做 | |||||
| */ | |||||
| class PropertyPanelController final | class PropertyPanelController final | ||||
| { | { | ||||
| public: | public: | ||||
| /** 报告属性操作结果,参数依次为操作名、消息和是否成功 */ | |||||
| using ResultReporter = std::function<void( | using ResultReporter = std::function<void( | ||||
| const QString &action, const QString &message, bool succeeded)>; | const QString &action, const QString &message, bool succeeded)>; | ||||
| /** 向状态栏报告一条带超时的消息 */ | |||||
| using StatusReporter = std::function<void( | using StatusReporter = std::function<void( | ||||
| const QString &message, int timeout_ms)>; | const QString &message, int timeout_ms)>; | ||||
| /** 创建属性面板控制器并注入编辑服务和选择状态 */ | |||||
| PropertyPanelController( | PropertyPanelController( | ||||
| QWidget &parent, | QWidget &parent, | ||||
| Ui::MainWindow &ui, | Ui::MainWindow &ui, | ||||
| ProjectService &project_service, | ProjectService &project_service, | ||||
| HmiEditorService &hmi_editor_service, | HmiEditorService &hmi_editor_service, | ||||
| LogicEditorService &logic_editor_service, | LogicEditorService &logic_editor_service, | ||||
| const HmiDefaultSettings &hmi_defaults, | |||||
| std::function<std::string()> current_page_id, | std::function<std::string()> current_page_id, | ||||
| std::function<std::string()> current_logic_id, | std::function<std::string()> current_logic_id, | ||||
| std::string &selected_control_id, | std::string &selected_control_id, | ||||
| @@ -42,39 +58,69 @@ public: | |||||
| ResultReporter result_reporter, | ResultReporter result_reporter, | ||||
| StatusReporter status_reporter); | StatusReporter status_reporter); | ||||
| /** 连接属性面板按钮、输入框和服务信号 */ | |||||
| void configure(); | void configure(); | ||||
| /** 绑定 HMI 与梯形图编辑器,接收它们的选择变化 */ | |||||
| void bindEditorWidgets( | void bindEditorWidgets( | ||||
| HmiEditorWidget &hmi_editor_widget, | HmiEditorWidget &hmi_editor_widget, | ||||
| LogicEditorWidget &logic_editor_widget); | LogicEditorWidget &logic_editor_widget); | ||||
| /** 显示指定 HMI 控件的属性 */ | |||||
| void showControlProperties(const std::string &control_id); | void showControlProperties(const std::string &control_id); | ||||
| /** 显示指定 HMI 页面尺寸属性 */ | |||||
| void showPageProperties(const std::string &page_id); | void showPageProperties(const std::string &page_id); | ||||
| /** 显示指定梯形图节点的属性 */ | |||||
| void showLogicNodeProperties(const std::string &node_id); | void showLogicNodeProperties(const std::string &node_id); | ||||
| /** 在当前页面添加指定类型的 HMI 控件 */ | |||||
| void addHmiControl(HmiControlType type); | void addHmiControl(HmiControlType type); | ||||
| /** 删除当前选中的 HMI 控件 */ | |||||
| void deleteSelectedControl(); | void deleteSelectedControl(); | ||||
| /** 提交当前 HMI 控件属性 */ | |||||
| void applySelectedControlProperties(); | void applySelectedControlProperties(); | ||||
| /** 提交当前 HMI 页面属性 */ | |||||
| void applySelectedPageProperties(); | void applySelectedPageProperties(); | ||||
| /** 提交当前梯形图节点属性 */ | |||||
| void applySelectedLogicNodeProperties(); | void applySelectedLogicNodeProperties(); | ||||
| private: | private: | ||||
| /** 显示 HMI 编辑服务返回的错误 */ | |||||
| void handleHmiEditorError(const QString &message) const; | void handleHmiEditorError(const QString &message) const; | ||||
| /** 显示梯形图编辑服务返回的错误 */ | |||||
| void handleLogicEditorError(const QString &message) const; | void handleLogicEditorError(const QString &message) const; | ||||
| /** 打开颜色选择器并写回属性输入框 */ | |||||
| void chooseTextColor(); | void chooseTextColor(); | ||||
| /** 统一报告属性操作失败 */ | |||||
| void reportFailure( | void reportFailure( | ||||
| const QString &action, const std::string &message) const; | const QString &action, const std::string &message) const; | ||||
| /** 主窗口父对象,不由控制器拥有 */ | |||||
| QWidget &parent_; | QWidget &parent_; | ||||
| /** 主窗口的 Designer 界面对象 */ | |||||
| Ui::MainWindow &ui_; | Ui::MainWindow &ui_; | ||||
| /** 工程服务,不由控制器拥有 */ | |||||
| ProjectService &project_service_; | ProjectService &project_service_; | ||||
| /** HMI 编辑服务,不由控制器拥有 */ | |||||
| HmiEditorService &hmi_editor_service_; | HmiEditorService &hmi_editor_service_; | ||||
| /** 梯形图编辑服务,不由控制器拥有 */ | |||||
| LogicEditorService &logic_editor_service_; | LogicEditorService &logic_editor_service_; | ||||
| /** 新建页面的启动期只读默认尺寸 */ | |||||
| const HmiDefaultSettings &hmi_defaults_; | |||||
| /** 获取当前 HMI 页面标识的回调 */ | |||||
| std::function<std::string()> current_page_id_; | std::function<std::string()> current_page_id_; | ||||
| /** 获取当前控制逻辑标识的回调 */ | |||||
| std::function<std::string()> current_logic_id_; | std::function<std::string()> current_logic_id_; | ||||
| /** 主窗口保存的当前控件标识 */ | |||||
| std::string &selected_control_id_; | std::string &selected_control_id_; | ||||
| /** 主窗口保存的当前逻辑节点标识 */ | |||||
| std::string &selected_logic_node_id_; | std::string &selected_logic_node_id_; | ||||
| /** 请求主窗口刷新工程界面的回调 */ | |||||
| std::function<void()> refresh_project_ui_; | std::function<void()> refresh_project_ui_; | ||||
| /** 属性操作结果回调 */ | |||||
| ResultReporter result_reporter_; | ResultReporter result_reporter_; | ||||
| /** 状态栏消息回调 */ | |||||
| StatusReporter status_reporter_; | StatusReporter status_reporter_; | ||||
| /** 绑定后的 HMI 编辑器 */ | |||||
| HmiEditorWidget *hmi_editor_widget_ = nullptr; | HmiEditorWidget *hmi_editor_widget_ = nullptr; | ||||
| /** 绑定后的梯形图编辑器 */ | |||||
| LogicEditorWidget *logic_editor_widget_ = nullptr; | LogicEditorWidget *logic_editor_widget_ = nullptr; | ||||
| }; | }; | ||||
| @@ -1,3 +1,11 @@ | |||||
| /** | |||||
| * @file register_comment_dialog.h | |||||
| * @brief 定义 M/D 寄存器工程注释编辑对话框 | |||||
| * @version 1.0.0 | |||||
| * @author suyu | |||||
| * @date 2026-08-22 | |||||
| */ | |||||
| #pragma once | #pragma once | ||||
| #include "domain/register_address.h" | #include "domain/register_address.h" | ||||
| @@ -13,22 +21,33 @@ class RegisterCommentDialog; | |||||
| class RegisterCommentService; | class RegisterCommentService; | ||||
| /** 工程级 M/D 注释编辑对话框;注释不包含实时寄存器值 */ | |||||
| class RegisterCommentDialog final : public QDialog | class RegisterCommentDialog final : public QDialog | ||||
| { | { | ||||
| public: | public: | ||||
| /** 创建寄存器注释对话框并绑定注释服务 */ | |||||
| explicit RegisterCommentDialog( | explicit RegisterCommentDialog( | ||||
| RegisterCommentService &service, | RegisterCommentService &service, | ||||
| QWidget *parent = nullptr); | QWidget *parent = nullptr); | ||||
| /** 释放 Designer 界面对象 */ | |||||
| ~RegisterCommentDialog() override; | ~RegisterCommentDialog() override; | ||||
| private: | private: | ||||
| /** 重新加载注释列表,并尽量保持原选中地址 */ | |||||
| void reloadComments(const RegisterAddress *selected_address = nullptr); | void reloadComments(const RegisterAddress *selected_address = nullptr); | ||||
| /** 把当前选中地址的注释加载到编辑框 */ | |||||
| void loadSelectedComment(); | void loadSelectedComment(); | ||||
| /** 校验并保存当前地址的注释 */ | |||||
| void saveComment(); | void saveComment(); | ||||
| /** 删除当前选中地址的注释 */ | |||||
| void removeComment(); | void removeComment(); | ||||
| /** 从输入框读取寄存器地址 */ | |||||
| RegisterAddress addressFromInputs() const; | RegisterAddress addressFromInputs() const; | ||||
| /** 返回列表中当前选中的寄存器地址,没有选择时返回空值 */ | |||||
| std::optional<RegisterAddress> selectedAddress() const; | std::optional<RegisterAddress> selectedAddress() const; | ||||
| /** Qt Designer 生成的界面对象 */ | |||||
| std::unique_ptr<Ui::RegisterCommentDialog> ui_; | std::unique_ptr<Ui::RegisterCommentDialog> ui_; | ||||
| /** 注释读写服务,不由对话框拥有 */ | |||||
| RegisterCommentService &service_; | RegisterCommentService &service_; | ||||
| }; | }; | ||||
| @@ -11,6 +11,7 @@ | |||||
| #include <QSplitter> | #include <QSplitter> | ||||
| #include <QComboBox> | #include <QComboBox> | ||||
| #include <QPushButton> | |||||
| #include <QSignalBlocker> | #include <QSignalBlocker> | ||||
| #include <QToolButton> | #include <QToolButton> | ||||
| @@ -66,6 +67,10 @@ RuntimeMonitorWidget::RuntimeMonitorWidget( | |||||
| register_monitor_service, ui_->monitorContainer); | register_monitor_service, ui_->monitorContainer); | ||||
| free_monitor_widget_->setObjectName(QStringLiteral("freeMonitorWidget")); | free_monitor_widget_->setObjectName(QStringLiteral("freeMonitorWidget")); | ||||
| ui_->monitorContainerLayout->addWidget(free_monitor_widget_); | ui_->monitorContainerLayout->addWidget(free_monitor_widget_); | ||||
| ui_->logicNoticeLabel->setVisible(false); | |||||
| ui_->runtimeModeComboBox->setVisible(false); | |||||
| ui_->runtimePlcButton->setVisible(false); | |||||
| ui_->runtimeDisconnectPlcButton->setVisible(false); | |||||
| ui_->upperSplitter->setSizes({600, 600}); | ui_->upperSplitter->setSizes({600, 600}); | ||||
| ui_->verticalSplitter->setSizes({440, 260}); | ui_->verticalSplitter->setSizes({440, 260}); | ||||
| connect(hmi_view_, &HmiEditorWidget::pageNavigationRequested, | connect(hmi_view_, &HmiEditorWidget::pageNavigationRequested, | ||||
| @@ -94,6 +99,22 @@ RuntimeMonitorWidget::RuntimeMonitorWidget( | |||||
| ui_->runtimeLogicComboBox->itemData(index).toString())); | ui_->runtimeLogicComboBox->itemData(index).toString())); | ||||
| } | } | ||||
| }); | }); | ||||
| connect(ui_->runtimeModeComboBox, | |||||
| QOverload<int>::of(&QComboBox::currentIndexChanged), | |||||
| this, | |||||
| [this](int index) | |||||
| { | |||||
| if (user_runtime_mode_ && index >= 0) | |||||
| { | |||||
| emit modeChangeRequested(index == 0 | |||||
| ? static_cast<int>(ApplicationMode::OfflineRunning) | |||||
| : static_cast<int>(ApplicationMode::OnlineRunning)); | |||||
| } | |||||
| }); | |||||
| connect(ui_->runtimePlcButton, &QPushButton::clicked, | |||||
| this, &RuntimeMonitorWidget::plcConfigurationRequested); | |||||
| connect(ui_->runtimeDisconnectPlcButton, &QPushButton::clicked, | |||||
| this, &RuntimeMonitorWidget::plcDisconnectRequested); | |||||
| } | } | ||||
| RuntimeMonitorWidget::~RuntimeMonitorWidget() = default; | RuntimeMonitorWidget::~RuntimeMonitorWidget() = default; | ||||
| @@ -134,7 +155,7 @@ void RuntimeMonitorWidget::setMode( | |||||
| { | { | ||||
| const bool offline = mode == ApplicationMode::OfflineRunning; | const bool offline = mode == ApplicationMode::OfflineRunning; | ||||
| const bool online = mode == ApplicationMode::OnlineRunning; | const bool online = mode == ApplicationMode::OnlineRunning; | ||||
| ui_->logicFrame->setVisible(offline); | |||||
| ui_->logicFrame->setVisible(offline || online); | |||||
| hmi_view_->setRuntimeActive(offline || online); | hmi_view_->setRuntimeActive(offline || online); | ||||
| if (!offline && !online) | if (!offline && !online) | ||||
| { | { | ||||
| @@ -142,19 +163,50 @@ void RuntimeMonitorWidget::setMode( | |||||
| } | } | ||||
| setFreeMonitorWriteEnabled(offline | setFreeMonitorWriteEnabled(offline | ||||
| || (online && plc_state == PlcConnectionState::Connected)); | || (online && plc_state == PlcConnectionState::Connected)); | ||||
| if (!offline) | |||||
| if (!offline && !online) | |||||
| { | { | ||||
| logic_view_->clearRuntimeTrace(); | logic_view_->clearRuntimeTrace(); | ||||
| latest_trace_.clear(); | latest_trace_.clear(); | ||||
| latest_fault_node_id_.clear(); | latest_fault_node_id_.clear(); | ||||
| has_logic_trace_ = false; | has_logic_trace_ = false; | ||||
| } | } | ||||
| ui_->logicLabel->setText( | |||||
| online ? tr("本地推算轨迹") : tr("梯形图运行状态")); | |||||
| ui_->logicNoticeLabel->setVisible(online); | |||||
| ui_->modeLabel->setText( | ui_->modeLabel->setText( | ||||
| offline ? tr("离线仿真 · 虚拟 M/D") | offline ? tr("离线仿真 · 虚拟 M/D") | ||||
| : online ? tr("真机运行 · PLC 缓存") : tr("当前未运行")); | |||||
| : online ? tr("真机运行 · PLC 缓存 · 本地推算轨迹") | |||||
| : tr("当前未运行")); | |||||
| if (user_runtime_mode_) | |||||
| { | |||||
| const QSignalBlocker blocker(ui_->runtimeModeComboBox); | |||||
| ui_->runtimeModeComboBox->setCurrentIndex( | |||||
| offline ? 0 : online ? 1 : -1); | |||||
| const bool plc_configurable = plc_state == PlcConnectionState::Disconnected | |||||
| || plc_state == PlcConnectionState::Faulted; | |||||
| ui_->runtimePlcButton->setEnabled(plc_configurable); | |||||
| ui_->runtimeDisconnectPlcButton->setEnabled( | |||||
| plc_state != PlcConnectionState::Disconnected); | |||||
| } | |||||
| free_monitor_widget_->refreshValues(mode, plc_state); | free_monitor_widget_->refreshValues(mode, plc_state); | ||||
| } | } | ||||
| void RuntimeMonitorWidget::setUserRuntimeMode(bool enabled) | |||||
| { | |||||
| user_runtime_mode_ = enabled; | |||||
| ui_->runtimeModeComboBox->setVisible(enabled); | |||||
| ui_->runtimePlcButton->setVisible(enabled); | |||||
| ui_->runtimeDisconnectPlcButton->setVisible(enabled); | |||||
| ui_->exitRuntimeButton->setToolTip( | |||||
| enabled ? tr("退出运行程序") : tr("返回编辑态")); | |||||
| ui_->exitRuntimeButton->setAccessibleName( | |||||
| enabled ? tr("退出运行程序") : tr("返回编辑态")); | |||||
| if (enabled) | |||||
| { | |||||
| setMode(ApplicationMode::Editing, PlcConnectionState::Disconnected); | |||||
| } | |||||
| } | |||||
| void RuntimeMonitorWidget::setHmiWriteEnabled(bool enabled) | void RuntimeMonitorWidget::setHmiWriteEnabled(bool enabled) | ||||
| { | { | ||||
| hmi_view_->setRuntimeWriteEnabled(enabled); | hmi_view_->setRuntimeWriteEnabled(enabled); | ||||
| @@ -168,6 +220,7 @@ void RuntimeMonitorWidget::setFreeMonitorWriteEnabled(bool enabled) | |||||
| void RuntimeMonitorWidget::refreshValues( | void RuntimeMonitorWidget::refreshValues( | ||||
| ApplicationMode mode, PlcConnectionState plc_state) | ApplicationMode mode, PlcConnectionState plc_state) | ||||
| { | { | ||||
| // HMI、报警和自由监控共享同一个活动寄存器仓库 | |||||
| hmi_view_->refreshRuntimeValues(); | hmi_view_->refreshRuntimeValues(); | ||||
| free_monitor_widget_->refreshValues(mode, plc_state); | free_monitor_widget_->refreshValues(mode, plc_state); | ||||
| } | } | ||||
| @@ -1,3 +1,11 @@ | |||||
| /** | |||||
| * @file runtime_monitor_widget.h | |||||
| * @brief 定义工程师运行监控主控件 | |||||
| * @version 1.0.0 | |||||
| * @author suyu | |||||
| * @date 2026-08-22 | |||||
| */ | |||||
| #pragma once | #pragma once | ||||
| #include "domain/runtime_state.h" | #include "domain/runtime_state.h" | ||||
| @@ -24,11 +32,13 @@ class LogicEditorWidget; | |||||
| class RegisterMonitorService; | class RegisterMonitorService; | ||||
| class ProjectService; | class ProjectService; | ||||
| /** 唯一的工程师运行监控投影,组合 HMI、梯形图轨迹和自由监控 */ | |||||
| class RuntimeMonitorWidget final : public QWidget | class RuntimeMonitorWidget final : public QWidget | ||||
| { | { | ||||
| Q_OBJECT | Q_OBJECT | ||||
| public: | public: | ||||
| /** 创建组合 HMI、梯形图和自由监控的运行投影 */ | |||||
| RuntimeMonitorWidget( | RuntimeMonitorWidget( | ||||
| HmiEditorService &hmi_editor_service, | HmiEditorService &hmi_editor_service, | ||||
| HmiRuntimeService &hmi_runtime_service, | HmiRuntimeService &hmi_runtime_service, | ||||
| @@ -38,34 +48,68 @@ public: | |||||
| AlarmService &alarm_service, | AlarmService &alarm_service, | ||||
| RegisterMonitorService ®ister_monitor_service, | RegisterMonitorService ®ister_monitor_service, | ||||
| QWidget *parent = nullptr); | QWidget *parent = nullptr); | ||||
| /** 释放组合的运行监控子控件 */ | |||||
| ~RuntimeMonitorWidget() override; | ~RuntimeMonitorWidget() override; | ||||
| /** 将当前页面和控制逻辑绑定到运行监控界面 */ | |||||
| void setProjectObjects(const std::string &page_id, const std::string &logic_id); | void setProjectObjects(const std::string &page_id, const std::string &logic_id); | ||||
| /** 切换运行监控中显示的 HMI 页面 */ | |||||
| void setRuntimePage(const std::string &page_id); | void setRuntimePage(const std::string &page_id); | ||||
| /** 根据模式决定是否显示本地逻辑轨迹以及是否允许写入 */ | |||||
| void setMode(ApplicationMode mode, PlcConnectionState plc_state); | void setMode(ApplicationMode mode, PlcConnectionState plc_state); | ||||
| /** 切换专用用户运行版的简化操作栏 */ | |||||
| void setUserRuntimeMode(bool enabled); | |||||
| /** 从寄存器仓库刷新 HMI 和自由监控中的当前值 */ | |||||
| void refreshValues(ApplicationMode mode, PlcConnectionState plc_state); | void refreshValues(ApplicationMode mode, PlcConnectionState plc_state); | ||||
| /** 设置 HMI 控件是否允许写入运行值 */ | |||||
| void setHmiWriteEnabled(bool enabled); | void setHmiWriteEnabled(bool enabled); | ||||
| /** 设置自由监控是否允许写入寄存器 */ | |||||
| void setFreeMonitorWriteEnabled(bool enabled); | void setFreeMonitorWriteEnabled(bool enabled); | ||||
| /** 接收离线轨迹或基于 PLC 缓存计算的真机本地推算轨迹 */ | |||||
| void setLogicTrace(const LogicTraceSnapshot &trace, const std::string &fault_node_id = {}); | void setLogicTrace(const LogicTraceSnapshot &trace, const std::string &fault_node_id = {}); | ||||
| /** 返回自由监控子控件,供上层刷新或连接信号 */ | |||||
| FreeMonitorWidget *freeMonitorWidget() const; | FreeMonitorWidget *freeMonitorWidget() const; | ||||
| /** 返回当前选中的控制逻辑标识 */ | |||||
| std::string selectedLogicId() const; | std::string selectedLogicId() const; | ||||
| signals: | signals: | ||||
| /** 页面跳转失败时发出可直接显示的错误信息 */ | |||||
| void navigationFailed(const QString &message); | void navigationFailed(const QString &message); | ||||
| /** 用户请求退出运行态 */ | |||||
| void exitRequested(); | void exitRequested(); | ||||
| /** 用户在专用运行版中请求切换模式 */ | |||||
| void modeChangeRequested(int mode); | |||||
| /** 用户请求打开 PLC 配置窗口 */ | |||||
| void plcConfigurationRequested(); | |||||
| /** 用户请求断开 PLC */ | |||||
| void plcDisconnectRequested(); | |||||
| private: | private: | ||||
| /** 显示指定的运行监控页面 */ | |||||
| void showRuntimePage(const std::string &page_id); | void showRuntimePage(const std::string &page_id); | ||||
| /** 选中指定控制逻辑并刷新梯形图投影 */ | |||||
| void selectRuntimeLogic(const std::string &logic_id); | void selectRuntimeLogic(const std::string &logic_id); | ||||
| /** Qt Designer 生成的界面对象 */ | |||||
| std::unique_ptr<Ui::RuntimeMonitorWidget> ui_; | std::unique_ptr<Ui::RuntimeMonitorWidget> ui_; | ||||
| /** 工程服务,不由控件拥有 */ | |||||
| ProjectService &project_service_; | ProjectService &project_service_; | ||||
| /** HMI 页面导航服务,不由控件拥有 */ | |||||
| HmiNavigationService &hmi_navigation_service_; | HmiNavigationService &hmi_navigation_service_; | ||||
| /** 运行监控中的 HMI 视图 */ | |||||
| HmiEditorWidget *hmi_view_ = nullptr; | HmiEditorWidget *hmi_view_ = nullptr; | ||||
| /** 运行监控中的梯形图视图 */ | |||||
| LogicEditorWidget *logic_view_ = nullptr; | LogicEditorWidget *logic_view_ = nullptr; | ||||
| /** 运行监控中的自由监控控件 */ | |||||
| FreeMonitorWidget *free_monitor_widget_ = nullptr; | FreeMonitorWidget *free_monitor_widget_ = nullptr; | ||||
| /** 当前选中的控制逻辑标识 */ | |||||
| std::string selected_logic_id_; | std::string selected_logic_id_; | ||||
| /** 最近一次收到的本地逻辑执行轨迹 */ | |||||
| LogicTraceSnapshot latest_trace_; | LogicTraceSnapshot latest_trace_; | ||||
| /** 最近一次本地执行故障节点标识 */ | |||||
| std::string latest_fault_node_id_; | std::string latest_fault_node_id_; | ||||
| /** 是否已经收到过可显示的逻辑轨迹 */ | |||||
| bool has_logic_trace_ = false; | bool has_logic_trace_ = false; | ||||
| /** 是否显示专用用户运行版操作栏 */ | |||||
| bool user_runtime_mode_ = false; | |||||
| }; | }; | ||||
| @@ -13,6 +13,9 @@ | |||||
| <item><widget class="QLabel" name="titleLabel"><property name="text"><string>运行监控</string></property><property name="styleSheet"><string notr="true">font-weight: 600;</string></property></widget></item> | <item><widget class="QLabel" name="titleLabel"><property name="text"><string>运行监控</string></property><property name="styleSheet"><string notr="true">font-weight: 600;</string></property></widget></item> | ||||
| <item><spacer name="headerSpacer"><property name="orientation"><enum>Qt::Horizontal</enum></property><property name="sizeHint" stdset="0"><size><width>40</width><height>20</height></size></property></spacer></item> | <item><spacer name="headerSpacer"><property name="orientation"><enum>Qt::Horizontal</enum></property><property name="sizeHint" stdset="0"><size><width>40</width><height>20</height></size></property></spacer></item> | ||||
| <item><widget class="QLabel" name="modeLabel"><property name="text"><string>当前未运行</string></property><property name="alignment"><set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set></property></widget></item> | <item><widget class="QLabel" name="modeLabel"><property name="text"><string>当前未运行</string></property><property name="alignment"><set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set></property></widget></item> | ||||
| <item><widget class="QComboBox" name="runtimeModeComboBox"><property name="minimumSize"><size><width>120</width><height>0</height></size></property><item><property name="text"><string>离线仿真</string></property></item><item><property name="text"><string>真机运行</string></property></item></widget></item> | |||||
| <item><widget class="QPushButton" name="runtimePlcButton"><property name="text"><string>PLC 配置</string></property></widget></item> | |||||
| <item><widget class="QPushButton" name="runtimeDisconnectPlcButton"><property name="text"><string>断开 PLC</string></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> | <item><widget class="QToolButton" name="exitRuntimeButton"><property name="toolTip"><string>返回编辑态</string></property><property name="accessibleName"><string>返回编辑态</string></property><property name="text"><string>返回编辑态</string></property><property name="toolButtonStyle"><enum>Qt::ToolButtonIconOnly</enum></property></widget></item> | ||||
| </layout> | </layout> | ||||
| </item> | </item> | ||||
| @@ -46,6 +49,7 @@ | |||||
| <item><widget class="QComboBox" name="runtimeLogicComboBox"><property name="minimumSize"><size><width>150</width><height>0</height></size></property></widget></item> | <item><widget class="QComboBox" name="runtimeLogicComboBox"><property name="minimumSize"><size><width>150</width><height>0</height></size></property></widget></item> | ||||
| </layout> | </layout> | ||||
| </item> | </item> | ||||
| <item><widget class="QLabel" name="logicNoticeLabel"><property name="styleSheet"><string notr="true">color: #8a5a00;</string></property><property name="text"><string>真机模式:本地梯形图只读推算 仅使用 PLC 缓存显示轨迹,本地输出不写入 PLC 程序或 M/D,不影响 PLC 内部逻辑;HMI 和自由监控仍可写 PLC。</string></property><property name="wordWrap"><bool>true</bool></property></widget></item> | |||||
| <item><widget class="QWidget" name="logicViewContainer" native="true"><layout class="QVBoxLayout" name="logicViewLayout"><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></item> | <item><widget class="QWidget" name="logicViewContainer" native="true"><layout class="QVBoxLayout" name="logicViewLayout"><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></item> | ||||
| </layout> | </layout> | ||||
| </widget> | </widget> | ||||
| @@ -6,7 +6,9 @@ | |||||
| #include <QWidget> | #include <QWidget> | ||||
| RuntimeMonitorWindow::RuntimeMonitorWindow(QWidget *parent) | RuntimeMonitorWindow::RuntimeMonitorWindow(QWidget *parent) | ||||
| : QMainWindow(parent, Qt::Window), | |||||
| : QMainWindow( | |||||
| parent, | |||||
| Qt::Window | Qt::CustomizeWindowHint | Qt::WindowTitleHint), | |||||
| ui_(std::make_unique<Ui::RuntimeMonitorWindow>()) | ui_(std::make_unique<Ui::RuntimeMonitorWindow>()) | ||||
| { | { | ||||
| ui_->setupUi(this); | ui_->setupUi(this); | ||||
| @@ -44,9 +46,9 @@ void RuntimeMonitorWindow::closeForApplicationExit() | |||||
| void RuntimeMonitorWindow::closeEvent(QCloseEvent *event) | void RuntimeMonitorWindow::closeEvent(QCloseEvent *event) | ||||
| { | { | ||||
| // 运行期间禁止通过系统关闭路径离开运行界面,只允许应用退出时关闭 | |||||
| if (runtime_active_ && !application_exit_) | if (runtime_active_ && !application_exit_) | ||||
| { | { | ||||
| emit exitRequested(); | |||||
| event->ignore(); | event->ignore(); | ||||
| return; | return; | ||||
| } | } | ||||
| @@ -1,3 +1,11 @@ | |||||
| /** | |||||
| * @file runtime_monitor_window.h | |||||
| * @brief 定义独立的工程师运行监控窗口 | |||||
| * @version 1.0.0 | |||||
| * @author suyu | |||||
| * @date 2026-08-22 | |||||
| */ | |||||
| #pragma once | #pragma once | ||||
| #include <QMainWindow> | #include <QMainWindow> | ||||
| @@ -11,28 +19,35 @@ class RuntimeMonitorWindow; | |||||
| class QCloseEvent; | class QCloseEvent; | ||||
| class QWidget; | class QWidget; | ||||
| // 承载唯一工程师运行监控投影的独立顶层窗口 | |||||
| /** 承载唯一工程师运行监控投影的独立顶层窗口 */ | |||||
| class RuntimeMonitorWindow final : public QMainWindow | class RuntimeMonitorWindow final : public QMainWindow | ||||
| { | { | ||||
| Q_OBJECT | Q_OBJECT | ||||
| public: | public: | ||||
| /** 创建运行监控顶层窗口 */ | |||||
| explicit RuntimeMonitorWindow(QWidget *parent = nullptr); | explicit RuntimeMonitorWindow(QWidget *parent = nullptr); | ||||
| /** 释放窗口和 Designer 界面对象 */ | |||||
| ~RuntimeMonitorWindow() override; | ~RuntimeMonitorWindow() override; | ||||
| /** 将运行监控控件放入窗口中央区域 */ | |||||
| void setMonitorWidget(QWidget &widget); | void setMonitorWidget(QWidget &widget); | ||||
| /** 进入运行态时显示并最大化窗口 */ | |||||
| void showForRuntime(); | void showForRuntime(); | ||||
| /** 返回编辑态时隐藏窗口但保留唯一监控控件 */ | |||||
| void hideForEditing(); | void hideForEditing(); | ||||
| /** 应用退出时允许真正关闭窗口 */ | |||||
| void closeForApplicationExit(); | void closeForApplicationExit(); | ||||
| signals: | |||||
| void exitRequested(); | |||||
| protected: | protected: | ||||
| /** 仅在应用退出时放行窗口关闭事件 */ | |||||
| void closeEvent(QCloseEvent *event) override; | void closeEvent(QCloseEvent *event) override; | ||||
| private: | private: | ||||
| /** Qt Designer 生成的窗口界面对象 */ | |||||
| std::unique_ptr<Ui::RuntimeMonitorWindow> ui_; | std::unique_ptr<Ui::RuntimeMonitorWindow> ui_; | ||||
| /** 当前是否处于运行态显示阶段 */ | |||||
| bool runtime_active_ = false; | bool runtime_active_ = false; | ||||
| /** 应用是否正在退出,决定关闭事件是否真正销毁窗口 */ | |||||
| bool application_exit_ = false; | bool application_exit_ = false; | ||||
| }; | }; | ||||
| @@ -98,21 +98,13 @@ void RuntimePanelController::configure() | |||||
| 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(), | QObject::connect(runtime_monitor_widget_->freeMonitorWidget(), | ||||
| &FreeMonitorWidget::monitorAddressesChanged, | &FreeMonitorWidget::monitorAddressesChanged, | ||||
| &parent_, [this] | &parent_, [this] | ||||
| { | { | ||||
| runtime_mode_service_.setMonitorAddresses( | runtime_mode_service_.setMonitorAddresses( | ||||
| register_monitor_service_.addresses()); | |||||
| register_monitor_service_.pollAddresses(), | |||||
| register_monitor_service_.float32Starts()); | |||||
| runtime_monitor_widget_->refreshValues( | runtime_monitor_widget_->refreshValues( | ||||
| runtime_mode_service_.mode(), | runtime_mode_service_.mode(), | ||||
| runtime_mode_service_.plcConnectionState()); | runtime_mode_service_.plcConnectionState()); | ||||
| @@ -129,6 +121,36 @@ void RuntimePanelController::configure() | |||||
| status_reporter_(message, 5000); | status_reporter_(message, 5000); | ||||
| output_reporter_(QObject::tr("页面导航失败:%1").arg(message)); | output_reporter_(QObject::tr("页面导航失败:%1").arg(message)); | ||||
| }); | }); | ||||
| QObject::connect(runtime_monitor_widget_, | |||||
| &RuntimeMonitorWidget::modeChangeRequested, | |||||
| &parent_, | |||||
| [this](int mode) | |||||
| { | |||||
| if (mode_requester_) | |||||
| { | |||||
| mode_requester_(mode); | |||||
| } | |||||
| }); | |||||
| QObject::connect(runtime_monitor_widget_, | |||||
| &RuntimeMonitorWidget::plcConfigurationRequested, | |||||
| &parent_, | |||||
| [this] | |||||
| { | |||||
| if (plc_configuration_requester_) | |||||
| { | |||||
| plc_configuration_requester_(); | |||||
| } | |||||
| }); | |||||
| QObject::connect(runtime_monitor_widget_, | |||||
| &RuntimeMonitorWidget::plcDisconnectRequested, | |||||
| &parent_, | |||||
| [this] | |||||
| { | |||||
| if (plc_disconnect_requester_) | |||||
| { | |||||
| plc_disconnect_requester_(); | |||||
| } | |||||
| }); | |||||
| runtime_refresh_timer_ = new QTimer(&parent_); | runtime_refresh_timer_ = new QTimer(&parent_); | ||||
| runtime_refresh_timer_->setInterval(150); | runtime_refresh_timer_->setInterval(150); | ||||
| QObject::connect(runtime_refresh_timer_, &QTimer::timeout, | QObject::connect(runtime_refresh_timer_, &QTimer::timeout, | ||||
| @@ -142,6 +164,28 @@ void RuntimePanelController::configure() | |||||
| &OfflineSimulationService::scanCompleted, | &OfflineSimulationService::scanCompleted, | ||||
| &parent_, [this] { handleScanCompleted(); }, | &parent_, [this] { handleScanCompleted(); }, | ||||
| Qt::QueuedConnection); | Qt::QueuedConnection); | ||||
| QObject::connect(&runtime_mode_service_.onlineLogicMonitorService(), | |||||
| &OnlineLogicMonitorService::stateChanged, | |||||
| &parent_, [this] { handleSimulationStateChanged(); }); | |||||
| QObject::connect(&runtime_mode_service_.onlineLogicMonitorService(), | |||||
| &OnlineLogicMonitorService::scanCompleted, | |||||
| &parent_, [this] { handleScanCompleted(); }, | |||||
| Qt::QueuedConnection); | |||||
| } | |||||
| void RuntimePanelController::configureUserRuntimeMode( | |||||
| bool enabled, | |||||
| std::function<void(int)> mode_requester, | |||||
| std::function<void()> plc_configuration_requester, | |||||
| std::function<void()> plc_disconnect_requester) | |||||
| { | |||||
| mode_requester_ = std::move(mode_requester); | |||||
| plc_configuration_requester_ = std::move(plc_configuration_requester); | |||||
| plc_disconnect_requester_ = std::move(plc_disconnect_requester); | |||||
| if (runtime_monitor_widget_ != nullptr) | |||||
| { | |||||
| runtime_monitor_widget_->setUserRuntimeMode(enabled); | |||||
| } | |||||
| } | } | ||||
| RuntimeMonitorWidget *RuntimePanelController::runtimeMonitorWidget() const | RuntimeMonitorWidget *RuntimePanelController::runtimeMonitorWidget() const | ||||
| @@ -158,9 +202,13 @@ void RuntimePanelController::enterRuntime( | |||||
| if (!runtime_session_active_) | if (!runtime_session_active_) | ||||
| { | { | ||||
| runtime_monitor_widget_->setProjectObjects(page_id, logic_id); | runtime_monitor_widget_->setProjectObjects(page_id, logic_id); | ||||
| // 运行窗口只创建一次,后续进入运行态复用同一个监控投影 | |||||
| runtime_session_active_ = true; | runtime_session_active_ = true; | ||||
| } | } | ||||
| runtime_monitor_widget_->freeMonitorWidget()->reloadAddresses(); | |||||
| runtime_monitor_widget_->setMode(mode, plc_state); | runtime_monitor_widget_->setMode(mode, plc_state); | ||||
| runtime_monitor_window_->setWindowTitle( | |||||
| fromUtf8(project_service_.project().metadata.name)); | |||||
| runtime_monitor_window_->showForRuntime(); | runtime_monitor_window_->showForRuntime(); | ||||
| } | } | ||||
| @@ -201,6 +249,40 @@ void RuntimePanelController::updateSimulationUi(bool report_fault) | |||||
| runtime_mode_service_.mode(), | runtime_mode_service_.mode(), | ||||
| runtime_mode_service_.plcConnectionState()); | runtime_mode_service_.plcConnectionState()); | ||||
| } | } | ||||
| if (online) | |||||
| { | |||||
| switch (runtime_mode_service_.onlineLogicMonitorService().state()) | |||||
| { | |||||
| case OnlineLogicMonitorState::Running: | |||||
| { | |||||
| executor_status_label_.setText( | |||||
| QObject::tr("本地推算轨迹:运行(%1 次)") | |||||
| .arg(runtime_mode_service_.onlineLogicMonitorService() | |||||
| .successfulScanCount())); | |||||
| return; | |||||
| } | |||||
| case OnlineLogicMonitorState::Faulted: | |||||
| { | |||||
| executor_status_label_.setText(QObject::tr("本地推算轨迹:故障")); | |||||
| if (report_fault) | |||||
| { | |||||
| const LogicScanResult &error = runtime_mode_service_ | |||||
| .onlineLogicMonitorService().lastError(); | |||||
| const QString message = QObject::tr("真机本地轨迹异常:%1") | |||||
| .arg(fromUtf8(error.message)); | |||||
| status_reporter_(message, 0); | |||||
| output_reporter_(message); | |||||
| } | |||||
| return; | |||||
| } | |||||
| case OnlineLogicMonitorState::Stopped: | |||||
| default: | |||||
| { | |||||
| executor_status_label_.setText(QObject::tr("本地推算轨迹:停止")); | |||||
| return; | |||||
| } | |||||
| } | |||||
| } | |||||
| switch (state) | switch (state) | ||||
| { | { | ||||
| case SimulationState::Running: | case SimulationState::Running: | ||||
| @@ -273,6 +355,7 @@ void RuntimePanelController::updateSimulationUi(bool report_fault) | |||||
| void RuntimePanelController::handleRuntimeTimer() | void RuntimePanelController::handleRuntimeTimer() | ||||
| { | { | ||||
| // 定时器只刷新投影;离线按定时器扫描,真机在完整 PLC 轮询后推算 | |||||
| if (runtime_mode_service_.mode() == ApplicationMode::Editing) | if (runtime_mode_service_.mode() == ApplicationMode::Editing) | ||||
| { | { | ||||
| return; | return; | ||||
| @@ -295,13 +378,24 @@ void RuntimePanelController::handleSimulationStateChanged() | |||||
| void RuntimePanelController::handleScanCompleted() | void RuntimePanelController::handleScanCompleted() | ||||
| { | { | ||||
| const ApplicationMode mode = runtime_mode_service_.mode(); | |||||
| const bool offline_running = mode == ApplicationMode::OfflineRunning | |||||
| && runtime_mode_service_.simulationState() == SimulationState::Running; | |||||
| const bool online_running = mode == ApplicationMode::OnlineRunning | |||||
| && runtime_mode_service_.onlineLogicMonitorService().state() | |||||
| == OnlineLogicMonitorState::Running; | |||||
| // 排队信号可能晚于退出操作到达,编辑态不得恢复旧运行轨迹 | |||||
| if (!offline_running && !online_running) | |||||
| { | |||||
| return; | |||||
| } | |||||
| const std::string logic_id = current_logic_id_(); | const std::string logic_id = current_logic_id_(); | ||||
| logic_editor_widget_.setRuntimeTrace( | |||||
| runtime_mode_service_.offlineSimulationService() | |||||
| .traceSnapshot().forLogic(logic_id)); | |||||
| const LogicTraceSnapshot &trace = offline_running | |||||
| ? runtime_mode_service_.offlineSimulationService().traceSnapshot() | |||||
| : runtime_mode_service_.onlineLogicMonitorService().traceSnapshot(); | |||||
| logic_editor_widget_.setRuntimeTrace(trace.forLogic(logic_id)); | |||||
| if (runtime_monitor_widget_ != nullptr) | if (runtime_monitor_widget_ != nullptr) | ||||
| { | { | ||||
| runtime_monitor_widget_->setLogicTrace( | |||||
| runtime_mode_service_.offlineSimulationService().traceSnapshot()); | |||||
| runtime_monitor_widget_->setLogicTrace(trace); | |||||
| } | } | ||||
| } | } | ||||
| @@ -1,3 +1,11 @@ | |||||
| /** | |||||
| * @file runtime_panel_controller.h | |||||
| * @brief 定义运行监控面板控制器 | |||||
| * @version 1.0.0 | |||||
| * @author suyu | |||||
| * @date 2026-08-22 | |||||
| */ | |||||
| #pragma once | #pragma once | ||||
| #include "domain/runtime_state.h" | #include "domain/runtime_state.h" | ||||
| @@ -25,15 +33,19 @@ class RuntimeMonitorWindow; | |||||
| class QTimer; | class QTimer; | ||||
| class QWidget; | class QWidget; | ||||
| // 组织运行监控工作台、运行刷新定时器和离线执行器反馈 | |||||
| /** 组织运行监控窗口、刷新定时器和本地逻辑执行器反馈 */ | |||||
| class RuntimePanelController final | class RuntimePanelController final | ||||
| { | { | ||||
| public: | public: | ||||
| /** 向状态栏报告一条带超时的消息 */ | |||||
| using StatusReporter = std::function<void( | using StatusReporter = std::function<void( | ||||
| const QString &message, int timeout_ms)>; | const QString &message, int timeout_ms)>; | ||||
| /** 向输出面板报告一条消息 */ | |||||
| using OutputReporter = std::function<void(const QString &message)>; | using OutputReporter = std::function<void(const QString &message)>; | ||||
| /** 请求上层退出当前运行态 */ | |||||
| using RuntimeExitRequester = std::function<void()>; | using RuntimeExitRequester = std::function<void()>; | ||||
| /** 创建运行面板控制器并注入运行模式、编辑器和监控窗口依赖 */ | |||||
| RuntimePanelController( | RuntimePanelController( | ||||
| QWidget &parent, | QWidget &parent, | ||||
| RuntimeModeService &runtime_mode_service, | RuntimeModeService &runtime_mode_service, | ||||
| @@ -53,44 +65,85 @@ public: | |||||
| StatusReporter status_reporter, | StatusReporter status_reporter, | ||||
| OutputReporter output_reporter, | OutputReporter output_reporter, | ||||
| RuntimeExitRequester runtime_exit_requester); | RuntimeExitRequester runtime_exit_requester); | ||||
| /** 停止刷新并释放控制器拥有的运行监控窗口 */ | |||||
| ~RuntimePanelController(); | ~RuntimePanelController(); | ||||
| /** 连接运行监控相关信号并完成初始配置 */ | |||||
| void configure(); | void configure(); | ||||
| /** 配置专用用户运行版的精简控制栏行为 */ | |||||
| void configureUserRuntimeMode( | |||||
| bool enabled, | |||||
| std::function<void(int)> mode_requester, | |||||
| std::function<void()> plc_configuration_requester, | |||||
| std::function<void()> plc_disconnect_requester); | |||||
| /** 创建或复用唯一监控窗口,并把当前工程投影到运行态 */ | |||||
| void enterRuntime( | void enterRuntime( | ||||
| const std::string &page_id, | const std::string &page_id, | ||||
| const std::string &logic_id, | const std::string &logic_id, | ||||
| ApplicationMode mode, | ApplicationMode mode, | ||||
| PlcConnectionState plc_state); | PlcConnectionState plc_state); | ||||
| /** 停止刷新并隐藏监控窗口,离线服务的停止由运行模式服务负责 */ | |||||
| void leaveRuntime(ApplicationMode mode, PlcConnectionState plc_state); | void leaveRuntime(ApplicationMode mode, PlcConnectionState plc_state); | ||||
| /** 应用退出时关闭运行监控窗口并停止相关刷新 */ | |||||
| void closeForApplicationExit(); | void closeForApplicationExit(); | ||||
| /** 根据当前模式的本地执行器状态刷新运行界面 */ | |||||
| void updateSimulationUi(bool report_fault); | void updateSimulationUi(bool report_fault); | ||||
| /** 返回当前唯一的运行监控控件 */ | |||||
| RuntimeMonitorWidget *runtimeMonitorWidget() const; | RuntimeMonitorWidget *runtimeMonitorWidget() const; | ||||
| private: | private: | ||||
| /** 处理运行刷新定时器到期事件 */ | |||||
| void handleRuntimeTimer(); | void handleRuntimeTimer(); | ||||
| /** 处理离线或真机只读轨迹执行器状态变化 */ | |||||
| void handleSimulationStateChanged(); | void handleSimulationStateChanged(); | ||||
| /** 处理一次离线扫描完成事件 */ | |||||
| void handleScanCompleted(); | void handleScanCompleted(); | ||||
| /** 主窗口父对象,不由控制器拥有 */ | |||||
| QWidget &parent_; | QWidget &parent_; | ||||
| /** 运行模式服务,不由控制器拥有 */ | |||||
| RuntimeModeService &runtime_mode_service_; | RuntimeModeService &runtime_mode_service_; | ||||
| /** 工程服务,不由控制器拥有 */ | |||||
| ProjectService &project_service_; | ProjectService &project_service_; | ||||
| /** HMI 编辑服务,不由控制器拥有 */ | |||||
| HmiEditorService &hmi_editor_service_; | HmiEditorService &hmi_editor_service_; | ||||
| /** HMI 运行服务,不由控制器拥有 */ | |||||
| HmiRuntimeService &hmi_runtime_service_; | HmiRuntimeService &hmi_runtime_service_; | ||||
| /** 梯形图编辑服务,不由控制器拥有 */ | |||||
| LogicEditorService &logic_editor_service_; | LogicEditorService &logic_editor_service_; | ||||
| /** HMI 页面导航服务,不由控制器拥有 */ | |||||
| HmiNavigationService &hmi_navigation_service_; | HmiNavigationService &hmi_navigation_service_; | ||||
| /** 报警服务,不由控制器拥有 */ | |||||
| AlarmService &alarm_service_; | AlarmService &alarm_service_; | ||||
| /** 寄存器监控服务,不由控制器拥有 */ | |||||
| RegisterMonitorService ®ister_monitor_service_; | RegisterMonitorService ®ister_monitor_service_; | ||||
| /** 主窗口中的 HMI 编辑器,不由控制器拥有 */ | |||||
| HmiEditorWidget &hmi_editor_widget_; | HmiEditorWidget &hmi_editor_widget_; | ||||
| /** 主窗口中的梯形图编辑器,不由控制器拥有 */ | |||||
| LogicEditorWidget &logic_editor_widget_; | LogicEditorWidget &logic_editor_widget_; | ||||
| /** 显示当前本地执行器状态的标签 */ | |||||
| QLabel &executor_status_label_; | QLabel &executor_status_label_; | ||||
| /** 获取当前控制逻辑标识的回调 */ | |||||
| std::function<std::string()> current_logic_id_; | std::function<std::string()> current_logic_id_; | ||||
| /** 切换当前控制逻辑的回调 */ | |||||
| std::function<void(const std::string &)> select_logic_; | std::function<void(const std::string &)> select_logic_; | ||||
| /** 显示控制逻辑属性的回调 */ | |||||
| std::function<void(const std::string &)> show_logic_properties_; | std::function<void(const std::string &)> show_logic_properties_; | ||||
| /** 向状态栏报告消息的回调 */ | |||||
| StatusReporter status_reporter_; | StatusReporter status_reporter_; | ||||
| /** 向输出面板报告消息的回调 */ | |||||
| OutputReporter output_reporter_; | OutputReporter output_reporter_; | ||||
| /** 请求主窗口退出运行态的回调 */ | |||||
| RuntimeExitRequester runtime_exit_requester_; | RuntimeExitRequester runtime_exit_requester_; | ||||
| /** 唯一的运行监控顶层窗口 */ | |||||
| std::unique_ptr<RuntimeMonitorWindow> runtime_monitor_window_; | std::unique_ptr<RuntimeMonitorWindow> runtime_monitor_window_; | ||||
| /** 运行监控窗口中的主控件,窗口拥有它 */ | |||||
| RuntimeMonitorWidget *runtime_monitor_widget_ = nullptr; | RuntimeMonitorWidget *runtime_monitor_widget_ = nullptr; | ||||
| /** 定时刷新运行监控数据的计时器 */ | |||||
| QTimer *runtime_refresh_timer_ = nullptr; | QTimer *runtime_refresh_timer_ = nullptr; | ||||
| /** 当前是否已经进入运行监控会话 */ | |||||
| bool runtime_session_active_ = false; | bool runtime_session_active_ = false; | ||||
| std::function<void(int)> mode_requester_; | |||||
| std::function<void()> plc_configuration_requester_; | |||||
| std::function<void()> plc_disconnect_requester_; | |||||
| }; | }; | ||||
| @@ -291,14 +291,6 @@ QPixmap renderIcon(UiIcon icon, int size) | |||||
| painter.drawLine(QPointF(18, 7), QPointF(18, 17)); | painter.drawLine(QPointF(18, 7), QPointF(18, 17)); | ||||
| break; | 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: | case UiIcon::Text: | ||||
| { | { | ||||
| painter.drawLine(QPointF(5, 5), QPointF(19, 5)); | painter.drawLine(QPointF(5, 5), QPointF(19, 5)); | ||||
| @@ -410,16 +402,6 @@ QPixmap renderIcon(UiIcon icon, int size) | |||||
| drawContact(painter, QStringLiteral("N"), false); | drawContact(painter, QStringLiteral("N"), false); | ||||
| break; | break; | ||||
| } | } | ||||
| case UiIcon::TimerContact: | |||||
| { | |||||
| drawContact(painter, QStringLiteral("T"), false); | |||||
| break; | |||||
| } | |||||
| case UiIcon::CounterContact: | |||||
| { | |||||
| drawContact(painter, QStringLiteral("C"), false); | |||||
| break; | |||||
| } | |||||
| case UiIcon::Coil: | case UiIcon::Coil: | ||||
| { | { | ||||
| drawCoil(painter, {}); | drawCoil(painter, {}); | ||||
| @@ -435,21 +417,6 @@ QPixmap renderIcon(UiIcon icon, int size) | |||||
| drawCoil(painter, QStringLiteral("R")); | drawCoil(painter, QStringLiteral("R")); | ||||
| break; | 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: | case UiIcon::Move: | ||||
| { | { | ||||
| painter.drawRect(QRectF(2, 6, 6, 12)); | painter.drawRect(QRectF(2, 6, 6, 12)); | ||||
| @@ -1,58 +1,66 @@ | |||||
| /** | |||||
| * @file toolbar_icon_factory.h | |||||
| * @brief 定义工具栏图标类型和图标创建函数 | |||||
| * @version 1.0.0 | |||||
| * @author suyu | |||||
| * @date 2026-08-22 | |||||
| */ | |||||
| #pragma once | #pragma once | ||||
| #include <QIcon> | #include <QIcon> | ||||
| /** 工具栏中使用的语义图标类型 */ | |||||
| enum class UiIcon | 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 | |||||
| Edit, // 编辑模式 | |||||
| RunOffline, // 离线运行 | |||||
| RunOnline, // 真机运行 | |||||
| NewDocument, // 新建工程 | |||||
| Save, // 保存工程 | |||||
| SaveAs, // 另存工程 | |||||
| Open, // 打开工程 | |||||
| Undo, // 撤销 | |||||
| Redo, // 重做 | |||||
| Exit, // 退出程序 | |||||
| PlcSettings, // PLC 连接设置 | |||||
| Disconnect, // 断开 PLC | |||||
| RegisterComments, // 寄存器注释 | |||||
| HmiButton, // HMI 按钮 | |||||
| Indicator, // HMI 指示灯 | |||||
| NumericDisplay, // HMI 数值显示 | |||||
| NumericInput, // HMI 数值输入 | |||||
| Text, // HMI 文本 | |||||
| PageJump, // HMI 页面跳转 | |||||
| AlarmList, // HMI 报警列表 | |||||
| AlarmSettings, // 报警配置 | |||||
| Delete, // 删除 | |||||
| AddRung, // 新增网络 | |||||
| HorizontalWire, // 横线 | |||||
| VerticalWire, // 竖线 | |||||
| ParallelBranch, // 并联支路 | |||||
| NormallyOpenContact, // 常开触点 | |||||
| NormallyClosedContact, // 常闭触点 | |||||
| RisingEdgeContact, // 上升沿触点 | |||||
| FallingEdgeContact, // 下降沿触点 | |||||
| Coil, // 普通线圈 | |||||
| SetCoil, // 置位线圈 | |||||
| ResetCoil, // 复位线圈 | |||||
| Move, // MOVE 指令 | |||||
| Add, // ADD 指令 | |||||
| Subtract, // SUB 指令 | |||||
| Compare, // 比较指令 | |||||
| Comment, // 注释 | |||||
| More, // 更多操作 | |||||
| HmiPage, // HMI 页面 | |||||
| Logic, // 控制逻辑 | |||||
| Runtime, // 运行监控 | |||||
| ClearList // 清空列表 | |||||
| }; | }; | ||||
| /** | |||||
| * @brief 根据语义图标类型创建 Qt 图标 | |||||
| * @param icon 要创建的图标类型 | |||||
| * @return 创建好的 Qt 图标 | |||||
| */ | |||||
| QIcon makeUiIcon(UiIcon icon); | QIcon makeUiIcon(UiIcon icon); | ||||