| @@ -23,6 +23,7 @@ ui_*.h | |||
| *.ilk | |||
| *.exp | |||
| *.manifest | |||
| *.autosave | |||
| # Qt Creator local user settings | |||
| *.pro.user | |||
| @@ -56,10 +57,15 @@ Thumbs.db | |||
| Desktop.ini | |||
| /scripts/* | |||
| !/scripts/build_and_run_qt.ps1 | |||
| !/scripts/measure_cpp_code.ps1 | |||
| !/scripts/package_qt_app.ps1 | |||
| !/scripts/run_qt_tests.ps1 | |||
| /docs/images | |||
| /docs/类图Mermaid代码.md | |||
| /other | |||
| 设计方案书.doc | |||
| /学习 | |||
| /json | |||
| /代码 | |||
| /other_version | |||
| /app_copy | |||
| @@ -10,11 +10,13 @@ | |||
| 2. 涉及模块边界、数据流、运行模式或持久化时,再读 `docs/architecture.md` | |||
| 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` | |||
| - 原始需求:`docs/0_综合平台编程器_修改.md` | |||
| - 数量边界最终规则:`docs/用户使用/数量边界确认方案.md` | |||
| - 应用配置文件:`docs/用户使用/应用配置说明.md` | |||
| - 用户运行程序导出:`docs/用户使用/用户运行程序导出说明.md` | |||
| - 数据监控与离线初始值:`docs/用户使用/数据监控与离线初始值说明.md` | |||
| - 工程 JSON 格式:`docs/工程格式说明.md` | |||
| - 开发阶段顺序:`docs/开发顺序.md`(阶段参考,当前状态以 handoff 为准) | |||
| - 测试约定:`docs/测试约定.md` | |||
| @@ -40,8 +45,9 @@ | |||
| - Windows 命令优先使用 PowerShell 7;显式调用使用 `pwsh -NoLogo -NoProfile -Command`,脚本使用 `pwsh -NoLogo -NoProfile -File` | |||
| - 复杂 PowerShell 逻辑写入 `.ps1` 后执行,避免嵌套引号;排查兼容问题时先确认 `$PSVersionTable.PSVersion` 和 `$PSVersionTable.PSEdition` | |||
| - 在独立构建目录运行 qmake 和 `mingw32-make`,不要把生成物写进 `app/` | |||
| - Debug 构建运行:`pwsh -NoLogo -NoProfile -File .\scripts\build_and_run_qt.ps1` | |||
| - Release 构建运行:`pwsh -NoLogo -NoProfile -File .\scripts\build_and_run_qt.ps1 -Configuration Release` | |||
| - 项目编译运行:`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'` | |||
| ## 代码与架构约束 | |||
| @@ -53,7 +59,7 @@ | |||
| - 数量边界统一定义在 `app/src/domain/project_limits.h`,修改时同步更新数量边界文档和边界测试 | |||
| - M/D 项目地址范围固定为 `0~4000`;Modbus 使用从 `0` 开始的原始地址 | |||
| - 离线模式使用虚拟 M/D 并运行软件逻辑执行器 | |||
| - 真机模式使用 PLC 读回缓存并停止软件逻辑执行器;切换时先读 PLC,不复制离线值 | |||
| - 真机模式使用 PLC 读回缓存;每轮完整轮询后在临时仓库推算本地梯形图轨迹,不把本地输出写入 PLC;切换时先读 PLC,不复制离线值 | |||
| - 本项目不生成、编译或下载 PLC 程序,不得把本地梯形图轨迹当成 PLC 内部程序轨迹 | |||
| - 代码注释只解释不明显的约束,末尾不要加句号 `。` | |||
| @@ -29,7 +29,8 @@ SOURCES += \ | |||
| src/ui/property_panel_controller.cpp \ | |||
| src/ui/runtime_panel_controller.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/register_monitor_model.cpp \ | |||
| src/domain/alarm_model.cpp \ | |||
| @@ -44,15 +45,20 @@ SOURCES += \ | |||
| src/services/hmi_editor_service.cpp \ | |||
| src/services/hmi_navigation_service.cpp \ | |||
| src/services/logic_editor_service.cpp \ | |||
| src/services/logic_command_service.cpp \ | |||
| src/services/hmi_runtime_service.cpp \ | |||
| src/services/software_logic_executor.cpp \ | |||
| src/services/offline_simulation_service.cpp \ | |||
| src/services/online_logic_monitor_service.cpp \ | |||
| src/services/runtime_mode_service.cpp \ | |||
| src/services/plc_discovery_gateway.cpp \ | |||
| src/services/register_monitor_service.cpp \ | |||
| src/services/register_comment_service.cpp \ | |||
| src/infrastructure/plc_register_repository.cpp \ | |||
| src/infrastructure/plc_communication_error_classifier.cpp \ | |||
| src/infrastructure/plc_communication_service.cpp \ | |||
| src/infrastructure/plc_discovery_service.cpp \ | |||
| src/infrastructure/application_settings_loader.cpp \ | |||
| src/infrastructure/json_project_storage.cpp \ | |||
| src/ui/hmi_editor_widget.cpp \ | |||
| src/ui/logic_editor_widget.cpp | |||
| @@ -71,7 +77,9 @@ HEADERS += \ | |||
| src/ui/property_panel_controller.h \ | |||
| src/ui/runtime_panel_controller.h \ | |||
| src/domain/register_address.h \ | |||
| src/domain/register_value_type.h \ | |||
| src/domain/register_repository.h \ | |||
| src/domain/virtual_register_repository.h \ | |||
| src/domain/active_register_repository.h \ | |||
| src/domain/register_monitor_model.h \ | |||
| src/domain/alarm_model.h \ | |||
| @@ -89,16 +97,22 @@ HEADERS += \ | |||
| src/services/hmi_editor_service.h \ | |||
| src/services/hmi_navigation_service.h \ | |||
| src/services/logic_editor_service.h \ | |||
| src/services/logic_command_service.h \ | |||
| src/services/hmi_runtime_service.h \ | |||
| src/services/software_logic_executor.h \ | |||
| src/services/offline_simulation_service.h \ | |||
| src/services/online_logic_monitor_service.h \ | |||
| src/services/runtime_mode_service.h \ | |||
| src/services/plc_discovery_gateway.h \ | |||
| src/services/register_monitor_service.h \ | |||
| src/services/register_comment_service.h \ | |||
| src/services/plc_communication_gateway.h \ | |||
| src/services/application_settings.h \ | |||
| src/infrastructure/plc_register_repository.h \ | |||
| src/infrastructure/plc_communication_error_classifier.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/ui/hmi_editor_widget.h \ | |||
| src/ui/logic_editor_widget.h | |||
| @@ -112,3 +126,8 @@ FORMS += \ | |||
| src/ui/free_monitor_widget.ui \ | |||
| src/ui/runtime_monitor_window.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) | |||
| { | |||
| // 只替换转发目标,不复制寄存器值;切换模式必须使用各自数据源 | |||
| repository_ = &repository; | |||
| } | |||
| @@ -31,3 +32,16 @@ RegisterWriteResult ActiveRegisterRepository::writeWord( | |||
| { | |||
| 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" | |||
| // 根据当前运行模式把寄存器访问转发给虚拟仓库或 PLC 缓存仓库 | |||
| class ActiveRegisterRepository final : public RegisterRepository | |||
| { | |||
| public: | |||
| explicit ActiveRegisterRepository(RegisterRepository &initial_repository); | |||
| // 切换活动仓库;运行模式服务负责保证切换时机和生命周期 | |||
| void use(RegisterRepository &repository); | |||
| BitReadResult readBit(const RegisterAddress &address) const override; | |||
| RegisterWriteResult writeBit(const RegisterAddress &address, bool value) override; | |||
| WordReadResult readWord(const RegisterAddress &address) const override; | |||
| 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; | |||
| private: | |||
| RegisterRepository *repository_ = nullptr; | |||
| @@ -33,7 +33,7 @@ bool AlarmDefinition::validate(std::string *error) const | |||
| } | |||
| if (message.size() > ProjectLimits::kMaximumTextBytes) | |||
| { | |||
| setError(error, "报警文本不能超过 4096 个 UTF-8 字节"); | |||
| setError(error, "报警文本不能超过 256 个 UTF-8 字节"); | |||
| return false; | |||
| } | |||
| if (!address.isValid()) | |||
| @@ -5,20 +5,23 @@ | |||
| #include <cstdint> | |||
| #include <string> | |||
| // 报警触发条件:M 位为 1,或 D 字高于/低于阈值 | |||
| enum class AlarmCondition | |||
| { | |||
| MOn, | |||
| DHigh, | |||
| DLow | |||
| MOn, // M 位为 1 时触发 | |||
| DHigh, // D 寄存器值高于阈值时触发 | |||
| DLow // D 寄存器值低于阈值时触发 | |||
| }; | |||
| // 可保存的报警定义;运行时 AlarmService 根据此定义生成报警记录 | |||
| 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; | |||
| }; | |||
| @@ -3,7 +3,6 @@ | |||
| #include "project_limits.h" | |||
| #include <algorithm> | |||
| #include <map> | |||
| #include <type_traits> | |||
| #include <utility> | |||
| @@ -52,22 +51,6 @@ bool validateWordAddress( | |||
| return true; | |||
| } | |||
| bool validateConfig(const CounterContactNodeConfig &config, std::string *error) | |||
| { | |||
| if (!config.address.isValid()) | |||
| { | |||
| setError(error, "计数器触点必须使用有效的 C 地址"); | |||
| return false; | |||
| } | |||
| if (config.mode != ContactMode::NormallyOpen | |||
| && config.mode != ContactMode::NormallyClosed) | |||
| { | |||
| setError(error, "计数器触点使用了不支持的模式"); | |||
| return false; | |||
| } | |||
| return true; | |||
| } | |||
| bool validateConfig(const EdgeContactNodeConfig &config, std::string *error) | |||
| { | |||
| if (!config.address.isValid() || config.address.area() != RegisterArea::M) | |||
| @@ -120,70 +103,6 @@ bool validateConfig(const CompareNodeConfig &config, std::string *error) | |||
| return true; | |||
| } | |||
| bool validateConfig(const TimerContactNodeConfig &config, std::string *error) | |||
| { | |||
| if (!config.address.isValid()) | |||
| { | |||
| setError(error, "定时器触点必须使用有效的 T 地址"); | |||
| return false; | |||
| } | |||
| if (config.mode != ContactMode::NormallyOpen | |||
| && config.mode != ContactMode::NormallyClosed) | |||
| { | |||
| setError(error, "定时器触点使用了不支持的模式"); | |||
| return false; | |||
| } | |||
| return true; | |||
| } | |||
| bool validateConfig(const TonNodeConfig &config, std::string *error) | |||
| { | |||
| if (!config.address.isValid()) | |||
| { | |||
| setError(error, "TON 必须使用有效的 T 地址"); | |||
| return false; | |||
| } | |||
| if (config.presetMs < TonNodeConfig::kMinimumPresetMs | |||
| || config.presetMs > TonNodeConfig::kMaximumPresetMs) | |||
| { | |||
| setError(error, "TON 预设时间必须在 1~86400000 ms 范围内"); | |||
| return false; | |||
| } | |||
| return true; | |||
| } | |||
| bool validateConfig(const CounterNodeConfig &config, std::string *error) | |||
| { | |||
| if (!config.address.isValid()) | |||
| { | |||
| setError(error, "计数器指令必须使用有效的 C 地址"); | |||
| return false; | |||
| } | |||
| if (config.mode != CounterMode::Up && config.mode != CounterMode::Down) | |||
| { | |||
| setError(error, "计数器指令使用了不支持的方向"); | |||
| return false; | |||
| } | |||
| if (!validateWordAddress(config.currentValueAddress, "计数当前值", error) | |||
| || !config.preset.validate(error)) | |||
| { | |||
| return false; | |||
| } | |||
| if (config.preset.kind == WordOperandKind::Constant | |||
| && config.preset.constant < 0) | |||
| { | |||
| setError(error, "计数器常量预设值必须在 0~32767 范围内"); | |||
| return false; | |||
| } | |||
| if (!config.resetAddress.isValid() | |||
| || config.resetAddress.area() != RegisterArea::M) | |||
| { | |||
| setError(error, "计数器复位输入必须使用有效的 M 区地址"); | |||
| return false; | |||
| } | |||
| return true; | |||
| } | |||
| bool validateConfig(const MoveNodeConfig &config, std::string *error) | |||
| { | |||
| if (!config.source.validate(error)) | |||
| @@ -245,7 +164,8 @@ void normalizeExpression(ConditionExpression *expression) | |||
| { | |||
| if (expression == nullptr | |||
| || expression->kind == ConditionExpressionKind::Node | |||
| || expression->kind == ConditionExpressionKind::Wire) | |||
| || expression->kind == ConditionExpressionKind::Wire | |||
| || expression->kind == ConditionExpressionKind::Gap) | |||
| { | |||
| return; | |||
| } | |||
| @@ -302,13 +222,13 @@ bool validateConditionExpression( | |||
| { | |||
| if (depth > ProjectLimits::kMaximumExpressionDepth) | |||
| { | |||
| setError(error, "条件表达式最多嵌套 20 层"); | |||
| setError(error, "条件表达式最多嵌套 12 层"); | |||
| return false; | |||
| } | |||
| ++*node_count; | |||
| if (*node_count > ProjectLimits::kMaximumExpressionNodesPerRung) | |||
| { | |||
| setError(error, "单个网络最多包含 4096 个条件表达式节点和叶子"); | |||
| setError(error, "单个网络最多包含 1024 个条件表达式节点和叶子"); | |||
| return false; | |||
| } | |||
| if (expression.id.empty()) | |||
| @@ -324,6 +244,7 @@ bool validateConditionExpression( | |||
| if (expression.kind == ConditionExpressionKind::Node) | |||
| { | |||
| if (!expression.node.has_value() || expression.wire.has_value() | |||
| || expression.gap.has_value() | |||
| || !expression.children.empty() || !expression.node->isCondition()) | |||
| { | |||
| setError(error, "条件叶节点必须包含一个条件节点且不能包含子表达式"); | |||
| @@ -336,6 +257,7 @@ bool validateConditionExpression( | |||
| if (expression.kind == ConditionExpressionKind::Wire) | |||
| { | |||
| if (expression.node.has_value() || !expression.wire.has_value() | |||
| || expression.gap.has_value() | |||
| || !expression.children.empty()) | |||
| { | |||
| setError(error, "横线叶节点必须包含横线配置且不能包含逻辑节点或子表达式"); | |||
| @@ -349,6 +271,22 @@ bool validateConditionExpression( | |||
| *rows = 1; | |||
| 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 | |||
| && expression.kind != ConditionExpressionKind::Parallel) | |||
| { | |||
| @@ -356,6 +294,7 @@ bool validateConditionExpression( | |||
| return false; | |||
| } | |||
| if (expression.node.has_value() || expression.wire.has_value() | |||
| || expression.gap.has_value() | |||
| || expression.children.size() < 2U) | |||
| { | |||
| setError(error, "串联和并联表达式至少需要两个子表达式"); | |||
| @@ -363,12 +302,13 @@ bool validateConditionExpression( | |||
| } | |||
| if (expression.children.size() > ProjectLimits::kMaximumExpressionChildren) | |||
| { | |||
| setError(error, "单个串联或并联容器最多包含 256 个子表达式"); | |||
| setError(error, "单个串联或并联容器最多包含 64 个子表达式"); | |||
| return false; | |||
| } | |||
| int total_columns = expression.kind == ConditionExpressionKind::Series ? 0 : 1; | |||
| int total_rows = expression.kind == ConditionExpressionKind::Parallel ? 0 : 1; | |||
| int parallel_columns = -1; | |||
| for (const ConditionExpression &child : expression.children) | |||
| { | |||
| if (child.kind == expression.kind) | |||
| @@ -397,10 +337,16 @@ bool validateConditionExpression( | |||
| } | |||
| 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); | |||
| if (total_rows > ProjectLimits::kMaximumLogicRows - child_rows) | |||
| { | |||
| setError(error, "单个网络的逻辑总行数最多为 256 行"); | |||
| setError(error, "单个网络的逻辑总行数最多为 64 行"); | |||
| return false; | |||
| } | |||
| total_rows += child_rows; | |||
| @@ -409,7 +355,7 @@ bool validateConditionExpression( | |||
| if (total_columns > ProjectLimits::kMaximumConditionColumns | |||
| || total_rows > ProjectLimits::kMaximumLogicRows) | |||
| { | |||
| setError(error, "单个网络最多使用 10 列条件、1 列输出和 256 行逻辑网格"); | |||
| setError(error, "单个网络最多使用 10 列条件、1 列输出和 64 行逻辑网格"); | |||
| return false; | |||
| } | |||
| *columns = total_columns; | |||
| @@ -417,13 +363,32 @@ bool validateConditionExpression( | |||
| 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 | |||
| { | |||
| if (kind == WordOperandKind::Constant) | |||
| @@ -443,61 +408,6 @@ bool WordOperand::validate(std::string *error) const | |||
| return true; | |||
| } | |||
| CounterAddress::CounterAddress(int index) | |||
| : index_(index) | |||
| { | |||
| } | |||
| int CounterAddress::index() const | |||
| { | |||
| return index_; | |||
| } | |||
| bool CounterAddress::isValid() const | |||
| { | |||
| return index_ >= kMinimumIndex && index_ <= kMaximumIndex; | |||
| } | |||
| std::string CounterAddress::toString() const | |||
| { | |||
| return isValid() ? "C" + std::to_string(index_) : "InvalidCounterAddress"; | |||
| } | |||
| bool CounterAddress::operator==(const CounterAddress &other) const | |||
| { | |||
| return index_ == other.index_; | |||
| } | |||
| bool CounterAddress::operator!=(const CounterAddress &other) const | |||
| { | |||
| return !(*this == other); | |||
| } | |||
| int TimerAddress::index() const | |||
| { | |||
| return index_; | |||
| } | |||
| bool TimerAddress::isValid() const | |||
| { | |||
| return index_ >= kMinimumIndex && index_ <= kMaximumIndex; | |||
| } | |||
| std::string TimerAddress::toString() const | |||
| { | |||
| return isValid() ? "T" + std::to_string(index_) : "InvalidTimerAddress"; | |||
| } | |||
| bool TimerAddress::operator==(const TimerAddress &other) const | |||
| { | |||
| return index_ == other.index_; | |||
| } | |||
| bool TimerAddress::operator!=(const TimerAddress &other) const | |||
| { | |||
| return !(*this == other); | |||
| } | |||
| std::optional<RegisterAddress> registerAddressForLogicNode( | |||
| const LogicNodeConfig &config) | |||
| { | |||
| @@ -512,10 +422,6 @@ std::optional<RegisterAddress> registerAddressForLogicNode( | |||
| { | |||
| return value.address; | |||
| } | |||
| else if constexpr (std::is_same_v<Config, CounterNodeConfig>) | |||
| { | |||
| return value.currentValueAddress; | |||
| } | |||
| else if constexpr (std::is_same_v<Config, MoveNodeConfig> | |||
| || std::is_same_v<Config, ArithmeticNodeConfig>) | |||
| { | |||
| @@ -545,15 +451,6 @@ void collectRegisterAddressesForLogicNode( | |||
| { | |||
| appendValidAddress(addresses, value.address); | |||
| } | |||
| else if constexpr (std::is_same_v<Config, CounterNodeConfig>) | |||
| { | |||
| appendValidAddress(addresses, value.currentValueAddress); | |||
| appendValidAddress(addresses, value.resetAddress); | |||
| if (value.preset.kind == WordOperandKind::Register) | |||
| { | |||
| appendValidAddress(addresses, value.preset.address); | |||
| } | |||
| } | |||
| else if constexpr (std::is_same_v<Config, MoveNodeConfig>) | |||
| { | |||
| appendValidAddress(addresses, value.destination); | |||
| @@ -574,10 +471,6 @@ void collectRegisterAddressesForLogicNode( | |||
| appendValidAddress(addresses, value.right.address); | |||
| } | |||
| } | |||
| else if constexpr (std::is_same_v<Config, CounterContactNodeConfig>) | |||
| { | |||
| return; | |||
| } | |||
| }, | |||
| config); | |||
| } | |||
| @@ -604,20 +497,18 @@ bool LogicNode::isConfigured() const | |||
| return configured; | |||
| } | |||
| // 判断当前节点是不是条件触点类节点 | |||
| bool LogicNode::isCondition() const | |||
| { | |||
| return std::holds_alternative<ContactNodeConfig>(config) | |||
| || std::holds_alternative<EdgeContactNodeConfig>(config) | |||
| || std::holds_alternative<TimerContactNodeConfig>(config) | |||
| || std::holds_alternative<CounterContactNodeConfig>(config) | |||
| || std::holds_alternative<CompareNodeConfig>(config); | |||
| } | |||
| // 判断当前节点是不是输出执行类节点 | |||
| bool LogicNode::isOutput() const | |||
| { | |||
| return std::holds_alternative<CoilNodeConfig>(config) | |||
| || std::holds_alternative<TonNodeConfig>(config) | |||
| || std::holds_alternative<CounterNodeConfig>(config) | |||
| || std::holds_alternative<MoveNodeConfig>(config) | |||
| || std::holds_alternative<ArithmeticNodeConfig>(config); | |||
| } | |||
| @@ -641,6 +532,16 @@ bool WireSegment::validate(std::string *error) const | |||
| 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( | |||
| std::string expression_id, int column_span) | |||
| { | |||
| @@ -651,6 +552,16 @@ ConditionExpression ConditionExpression::fromWire( | |||
| 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 | |||
| { | |||
| std::size_t node_count = 0U; | |||
| @@ -679,6 +590,11 @@ bool ConditionExpression::validateForRunning(std::string *error) const | |||
| { | |||
| return true; | |||
| } | |||
| if (kind == ConditionExpressionKind::Gap) | |||
| { | |||
| setError(error, "梯形图条件区存在未连接的空白网格"); | |||
| return false; | |||
| } | |||
| for (const ConditionExpression &child : children) | |||
| { | |||
| if (!child.validateForRunning(error)) | |||
| @@ -691,6 +607,7 @@ bool ConditionExpression::validateForRunning(std::string *error) const | |||
| void normalizeConditionExpression(std::optional<ConditionExpression> *expression) | |||
| { | |||
| // 删除节点后折叠单子项容器,并合并相邻同类容器,保持 JSON 结构稳定 | |||
| if (expression == nullptr || !expression->has_value()) | |||
| { | |||
| return; | |||
| @@ -698,6 +615,7 @@ void normalizeConditionExpression(std::optional<ConditionExpression> *expression | |||
| normalizeExpression(&expression->value()); | |||
| if (expression->value().kind != ConditionExpressionKind::Node | |||
| && expression->value().kind != ConditionExpressionKind::Wire | |||
| && expression->value().kind != ConditionExpressionKind::Gap | |||
| && expression->value().children.empty()) | |||
| { | |||
| expression->reset(); | |||
| @@ -711,6 +629,10 @@ const LogicNode *findConditionNode( | |||
| { | |||
| return nullptr; | |||
| } | |||
| if (expression.kind == ConditionExpressionKind::Gap) | |||
| { | |||
| return nullptr; | |||
| } | |||
| if (expression.kind == ConditionExpressionKind::Node) | |||
| { | |||
| return expression.node->id == node_id ? &*expression.node : nullptr; | |||
| @@ -773,6 +695,10 @@ void collectConditionNodes( | |||
| { | |||
| return; | |||
| } | |||
| if (expression.kind == ConditionExpressionKind::Gap) | |||
| { | |||
| return; | |||
| } | |||
| for (const ConditionExpression &child : expression.children) | |||
| { | |||
| collectConditionNodes(child, nodes); | |||
| @@ -839,7 +765,7 @@ bool LadderRung::validateStructure(std::string *error) const | |||
| } | |||
| if (name.size() > ProjectLimits::kMaximumTextBytes) | |||
| { | |||
| setError(error, "梯形图网络名称不能超过 4096 个 UTF-8 字节"); | |||
| setError(error, "梯形图网络名称不能超过 256 个 UTF-8 字节"); | |||
| return false; | |||
| } | |||
| if (containsLineBreak(comment)) | |||
| @@ -866,6 +792,14 @@ bool LadderRung::validateStructure(std::string *error) const | |||
| 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->validate(error) || !output->isOutput()) | |||
| @@ -886,7 +820,13 @@ bool LadderRung::validateStructure(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; | |||
| } | |||
| @@ -901,6 +841,12 @@ bool ControlLogic::validate(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()) | |||
| { | |||
| @@ -914,12 +860,16 @@ bool ControlLogic::validateStructure(std::string *error) const | |||
| } | |||
| if (name.size() > ProjectLimits::kMaximumTextBytes) | |||
| { | |||
| setError(error, "控制逻辑名称不能超过 4096 个 UTF-8 字节"); | |||
| setError(error, "控制逻辑名称不能超过 256 个 UTF-8 字节"); | |||
| 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; | |||
| } | |||
| if (hasDuplicateId(rungs)) | |||
| @@ -968,7 +918,13 @@ bool ControlLogic::validateStructure(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; | |||
| } | |||
| @@ -981,148 +937,3 @@ bool ControlLogic::validateForRunning(std::string *error) const | |||
| } | |||
| return true; | |||
| } | |||
| bool validateTimerReferencesForRunning( | |||
| const std::vector<ControlLogic> &logics, | |||
| std::string *error) | |||
| { | |||
| std::map<int, std::string> timer_outputs; | |||
| for (const ControlLogic &logic : logics) | |||
| { | |||
| if (!logic.enabled) | |||
| { | |||
| continue; | |||
| } | |||
| for (const LadderRung &rung : logic.rungs) | |||
| { | |||
| if (!rung.output.has_value()) | |||
| { | |||
| continue; | |||
| } | |||
| const auto *ton = std::get_if<TonNodeConfig>(&rung.output->config); | |||
| if (ton == nullptr) | |||
| { | |||
| continue; | |||
| } | |||
| const auto inserted = timer_outputs.emplace( | |||
| ton->address.index(), rung.output->id); | |||
| if (!inserted.second) | |||
| { | |||
| setError( | |||
| error, | |||
| "定时器 " + ton->address.toString() | |||
| + " 只能由一个已启用 TON 指令驱动"); | |||
| return false; | |||
| } | |||
| } | |||
| } | |||
| for (const ControlLogic &logic : logics) | |||
| { | |||
| if (!logic.enabled) | |||
| { | |||
| continue; | |||
| } | |||
| for (const LadderRung &rung : logic.rungs) | |||
| { | |||
| if (!rung.condition.has_value()) | |||
| { | |||
| continue; | |||
| } | |||
| std::vector<const LogicNode *> nodes; | |||
| collectConditionNodes(*rung.condition, &nodes); | |||
| for (const LogicNode *node : nodes) | |||
| { | |||
| const auto *contact = std::get_if<TimerContactNodeConfig>( | |||
| &node->config); | |||
| if (contact != nullptr | |||
| && timer_outputs.count(contact->address.index()) == 0U) | |||
| { | |||
| setError( | |||
| error, | |||
| "定时器触点 " + contact->address.toString() | |||
| + " 没有对应的已启用 TON 指令"); | |||
| return false; | |||
| } | |||
| } | |||
| } | |||
| } | |||
| return true; | |||
| } | |||
| bool validateCounterReferencesForRunning( | |||
| const std::vector<ControlLogic> &logics, | |||
| std::string *error) | |||
| { | |||
| std::map<int, std::string> counter_outputs; | |||
| for (const ControlLogic &logic : logics) | |||
| { | |||
| if (!logic.enabled) | |||
| { | |||
| continue; | |||
| } | |||
| for (const LadderRung &rung : logic.rungs) | |||
| { | |||
| if (!rung.output.has_value()) | |||
| { | |||
| continue; | |||
| } | |||
| const auto *counter = std::get_if<CounterNodeConfig>( | |||
| &rung.output->config); | |||
| if (counter == nullptr) | |||
| { | |||
| continue; | |||
| } | |||
| const auto inserted = counter_outputs.emplace( | |||
| counter->address.index(), rung.output->id); | |||
| if (!inserted.second) | |||
| { | |||
| setError( | |||
| error, | |||
| "计数器 " + counter->address.toString() | |||
| + " 只能由一个已启用计数指令驱动"); | |||
| return false; | |||
| } | |||
| } | |||
| } | |||
| for (const ControlLogic &logic : logics) | |||
| { | |||
| if (!logic.enabled) | |||
| { | |||
| continue; | |||
| } | |||
| for (const LadderRung &rung : logic.rungs) | |||
| { | |||
| if (!rung.condition.has_value()) | |||
| { | |||
| continue; | |||
| } | |||
| std::vector<const LogicNode *> nodes; | |||
| collectConditionNodes(*rung.condition, &nodes); | |||
| for (const LogicNode *node : nodes) | |||
| { | |||
| const auto *contact = std::get_if<CounterContactNodeConfig>( | |||
| &node->config); | |||
| if (contact != nullptr | |||
| && counter_outputs.count(contact->address.index()) == 0U) | |||
| { | |||
| setError( | |||
| error, | |||
| "计数器触点 " + contact->address.toString() | |||
| + " 没有对应的已启用计数指令"); | |||
| return false; | |||
| } | |||
| } | |||
| } | |||
| } | |||
| return true; | |||
| } | |||
| bool validateLogicResourceReferencesForRunning( | |||
| const std::vector<ControlLogic> &logics, | |||
| std::string *error) | |||
| { | |||
| return validateTimerReferencesForRunning(logics, error) | |||
| && validateCounterReferencesForRunning(logics, error); | |||
| } | |||
| @@ -9,214 +9,174 @@ | |||
| #include <variant> | |||
| #include <vector> | |||
| // 普通触点的导通方式;常闭触点会对读取到的位值取反 | |||
| enum class ContactMode | |||
| { | |||
| NormallyOpen, | |||
| NormallyClosed | |||
| NormallyOpen, // 常开:位值为 1 时触点导通 | |||
| NormallyClosed // 常闭:位值为 0 时触点导通 | |||
| }; | |||
| // 线圈写入方式:普通写入、置位保持、复位清零 | |||
| enum class CoilMode | |||
| { | |||
| Normal, | |||
| Set, | |||
| Reset | |||
| Normal, // 普通写入:直接写入当前逻辑结果 | |||
| Set, // 置位:逻辑结果为 1 时保持为 1 | |||
| Reset // 复位:逻辑结果为 1 时清零 | |||
| }; | |||
| // 上升沿/下降沿触点的边沿方向 | |||
| enum class EdgeMode | |||
| { | |||
| Rising, | |||
| Falling | |||
| Rising, // 上升沿:信号从 0 变成 1 | |||
| Falling // 下降沿:信号从 1 变成 0 | |||
| }; | |||
| // D 寄存器字比较,等于、不等于、大于、小于等 6 种比较 | |||
| enum class ComparisonOperator | |||
| { | |||
| Equal, | |||
| NotEqual, | |||
| LessThan, | |||
| LessThanOrEqual, | |||
| GreaterThan, | |||
| GreaterThanOrEqual | |||
| Equal, // 等于 | |||
| NotEqual, // 不等于 | |||
| LessThan, // 小于 | |||
| LessThanOrEqual, // 小于或等于 | |||
| GreaterThan, // 大于 | |||
| GreaterThanOrEqual // 大于或等于 | |||
| }; | |||
| // 字操作数可以来自常量,也可以来自 D 寄存器 | |||
| enum class WordOperandKind | |||
| { | |||
| Constant, | |||
| Register | |||
| Constant, // 使用固定数值 | |||
| Register // 使用 D 寄存器中的数值 | |||
| }; | |||
| // MOVE、ADD/SUB 共用的字操作数 | |||
| 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; | |||
| }; | |||
| // 普通 M 寄存器触点,地址 + 常开常闭 | |||
| struct ContactNodeConfig | |||
| { | |||
| RegisterAddress address{RegisterArea::M, 0}; | |||
| ContactMode mode = ContactMode::NormallyOpen; | |||
| RegisterAddress address{RegisterArea::M, 0}; // 触点读取的 M 地址 | |||
| ContactMode mode = ContactMode::NormallyOpen; // 常开或常闭 | |||
| }; | |||
| // 边沿触点,地址 + 上升 / 下降沿 | |||
| struct EdgeContactNodeConfig | |||
| { | |||
| // 用于检测 M 寄存器的上升沿或下降沿 | |||
| RegisterAddress address{RegisterArea::M, 0}; | |||
| EdgeMode mode = EdgeMode::Rising; | |||
| }; | |||
| // 线圈输出配置,决定如何写入 M 寄存器 | |||
| struct CoilNodeConfig | |||
| { | |||
| // 线圈要写入的 M 寄存器地址 | |||
| RegisterAddress address{RegisterArea::M, 0}; | |||
| CoilMode mode = CoilMode::Normal; | |||
| }; | |||
| // D 寄存器比较配置 | |||
| struct CompareNodeConfig | |||
| { | |||
| // 要参与比较的 D 寄存器地址 | |||
| RegisterAddress address{RegisterArea::D, 0}; | |||
| // 比较运算符 | |||
| ComparisonOperator comparison = ComparisonOperator::Equal; | |||
| // 与寄存器值比较的常量 | |||
| 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 | |||
| { | |||
| WordOperand source; | |||
| RegisterAddress destination{RegisterArea::D, 0}; | |||
| WordOperand source; // 要写入的源操作数 | |||
| RegisterAddress destination{RegisterArea::D, 0}; // 接收数据的 D 地址 | |||
| }; | |||
| enum class ArithmeticOperation | |||
| { | |||
| Add, | |||
| Subtract | |||
| Add, // 加法 | |||
| Subtract // 减法 | |||
| }; | |||
| // ADD/SUB 输出:计算两个字操作数并写入目标 D 地址 | |||
| 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< | |||
| 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( | |||
| const LogicNodeConfig &config); | |||
| // 收集节点中所有显式 M/D 引用,用于 PLC 轮询集合构建 | |||
| void collectRegisterAddressesForLogicNode( | |||
| const LogicNodeConfig &config, | |||
| std::vector<RegisterAddress> *addresses); | |||
| // 表达式中的一个指令节点;configured=false 表示编辑中的未完成草稿 | |||
| 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 isConfigured() const; | |||
| // 判断节点能否放在条件区或固定输出槽 | |||
| bool isCondition() const; | |||
| bool isOutput() const; | |||
| }; | |||
| enum class ConditionExpressionKind | |||
| { | |||
| Node, | |||
| Wire, | |||
| Series, | |||
| Parallel | |||
| Node, // 一个实际的条件节点 | |||
| Wire, // 一段横线 | |||
| Gap, // 一段明确断开的空白网格 | |||
| Series, // 多个条件串联,必须全部满足 | |||
| Parallel // 多个条件并联,满足任意一条即可 | |||
| }; | |||
| // 条件区中的持久化横线;columnSpan 表示跨越的网格列数 | |||
| struct WireSegment | |||
| { | |||
| static constexpr int kMinimumColumnSpan = 1; | |||
| static constexpr int kMaximumColumnSpan = | |||
| 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; | |||
| }; | |||
| @@ -224,65 +184,89 @@ struct WireSegment | |||
| // 结构化表达式只允许合法的串并联拓扑,不保存可产生悬空线或环路的像素连接 | |||
| 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 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 validateForRunning(std::string *error = nullptr) const; | |||
| }; | |||
| // 删除后折叠单子项容器并合并相邻同类容器,保持表达式的规范形态 | |||
| // 删除节点后整理表达式结构 | |||
| void normalizeConditionExpression(std::optional<ConditionExpression> *expression); | |||
| // 按节点 ID 查找条件节点,只读版本 | |||
| const LogicNode *findConditionNode( | |||
| const ConditionExpression &expression, const std::string &node_id); | |||
| // 按节点 ID 查找条件节点,可修改版本 | |||
| LogicNode *findConditionNode( | |||
| ConditionExpression &expression, const std::string &node_id); | |||
| // 按表达式 ID 查找子表达式,只读版本 | |||
| const ConditionExpression *findConditionExpression( | |||
| const ConditionExpression &expression, const std::string &expression_id); | |||
| // 按表达式 ID 查找子表达式,可修改版本 | |||
| ConditionExpression *findConditionExpression( | |||
| ConditionExpression &expression, const std::string &expression_id); | |||
| // 收集表达式中的所有条件节点 | |||
| void collectConditionNodes( | |||
| const ConditionExpression &expression, std::vector<const LogicNode *> *nodes); | |||
| // 收集表达式和子表达式的所有 ID | |||
| void collectConditionExpressionIds( | |||
| const ConditionExpression &expression, std::vector<std::string> *ids); | |||
| // 条件表达式为空时表示恒真网络,输出指令固定在最右侧 | |||
| // 输出指令固定在第 11 列;有输出时前 10 列必须由显式条件、横线或断路占满 | |||
| 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 validateStructure(std::string *error = nullptr) const; | |||
| // 运行态要求有输出且所有引用都已配置 | |||
| bool validateForRunning(std::string *error = nullptr) const; | |||
| }; | |||
| // 一组按顺序扫描的梯形图网络;enabled=false 时运行校验会跳过它 | |||
| 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( | |||
| const ProjectLimitSettings &limits, | |||
| 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( | |||
| 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}, | |||
| HmiBindingKind::None, | |||
| HmiRuntimeValueKind::None, | |||
| false}, | |||
| HmiControlDescriptor{HmiControlType::ProgressBar, | |||
| "progressBar", | |||
| "进度条", | |||
| "progress-bar", | |||
| "进度", | |||
| {0, 0, 240, 36}, | |||
| HmiBindingKind::Word, | |||
| HmiRuntimeValueKind::Word, | |||
| true} | |||
| false} | |||
| }; | |||
| static_assert( | |||
| @@ -17,9 +17,9 @@ | |||
| */ | |||
| enum class HmiBindingKind | |||
| { | |||
| None, | |||
| Bit, | |||
| Word | |||
| None, // 不支持寄存器绑定 | |||
| Bit, // 绑定 M 区位地址 | |||
| Word // 绑定 D 区字地址 | |||
| }; | |||
| /** | |||
| @@ -27,9 +27,9 @@ enum class HmiBindingKind | |||
| */ | |||
| enum class HmiRuntimeValueKind | |||
| { | |||
| None, | |||
| Bit, | |||
| Word | |||
| None, // 运行态不读取寄存器值 | |||
| Bit, // 运行态读取绑定的 M 位值 | |||
| Word // 运行态读取绑定的 D 字值 | |||
| }; | |||
| /** | |||
| @@ -37,15 +37,15 @@ enum class HmiRuntimeValueKind | |||
| */ | |||
| 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 | |||
| 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 | |||
| { | |||
| const HmiControlDescriptor *descriptor = findHmiControlDescriptor(type); | |||
| @@ -126,7 +109,7 @@ bool HmiControl::validate(std::string *error) const | |||
| } | |||
| if (text.size() > ProjectLimits::kMaximumTextBytes) | |||
| { | |||
| setError(error, "HMI 控件文本不能超过 4096 个 UTF-8 字节"); | |||
| setError(error, "HMI 控件文本不能超过 256 个 UTF-8 字节"); | |||
| return false; | |||
| } | |||
| if (bounds.width <= 0 || bounds.height <= 0 | |||
| @@ -155,7 +138,7 @@ bool HmiControl::validate(std::string *error) const | |||
| } | |||
| if (property.second.size() > ProjectLimits::kMaximumPropertyValueBytes) | |||
| { | |||
| setError(error, "HMI 控件属性值不能超过 4096 个 UTF-8 字节"); | |||
| setError(error, "HMI 控件属性值不能超过 1024 个 UTF-8 字节"); | |||
| return false; | |||
| } | |||
| if (!validateAppearanceProperty(property.first, property.second, error)) | |||
| @@ -188,6 +171,20 @@ bool HmiControl::validate(std::string *error) const | |||
| setError(error, "HMI 控件绑定了无效地址"); | |||
| 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 (!pageJump.has_value()) | |||
| @@ -206,19 +203,6 @@ bool HmiControl::validate(std::string *error) const | |||
| setError(error, "非页面跳转控件不能包含跳转配置"); | |||
| return false; | |||
| } | |||
| if (type == HmiControlType::ProgressBar) | |||
| { | |||
| if (!progressBar.has_value() || !progressBar->isValid()) | |||
| { | |||
| setError(error, "进度条最小值必须小于最大值"); | |||
| return false; | |||
| } | |||
| } | |||
| else if (progressBar.has_value()) | |||
| { | |||
| setError(error, "非进度条控件不能包含进度条配置"); | |||
| return false; | |||
| } | |||
| return true; | |||
| } | |||
| @@ -229,11 +213,6 @@ bool HmiControl::isConfigured() const | |||
| { | |||
| return false; | |||
| } | |||
| if (type == HmiControlType::ProgressBar | |||
| && (!progressBar.has_value() || !progressBar->isValid())) | |||
| { | |||
| return false; | |||
| } | |||
| if (!descriptor->requiresBindingForRunning | |||
| && type != HmiControlType::PageJump) | |||
| { | |||
| @@ -255,6 +234,12 @@ bool HmiControl::isConfigured() 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()) | |||
| { | |||
| @@ -268,19 +253,26 @@ bool HmiPage::validate(std::string *error) const | |||
| } | |||
| if (name.size() > ProjectLimits::kMaximumTextBytes) | |||
| { | |||
| setError(error, "HMI 页面名称不能超过 4096 个 UTF-8 字节"); | |||
| setError(error, "HMI 页面名称不能超过 256 个 UTF-8 字节"); | |||
| return false; | |||
| } | |||
| if (width <= 0 || height <= 0 | |||
| if (width < ProjectLimits::kMinimumHmiPageWidth | |||
| || height < ProjectLimits::kMinimumHmiPageHeight | |||
| || width > ProjectLimits::kMaximumHmiPageWidth | |||
| || height > ProjectLimits::kMaximumHmiPageHeight) | |||
| { | |||
| setError(error, "HMI 页面宽度和高度必须在 1~8192 范围内"); | |||
| setError( | |||
| error, | |||
| "HMI 页面宽度必须在 320~1600、高度必须在 200~800 范围内"); | |||
| 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; | |||
| } | |||
| @@ -312,7 +304,13 @@ bool HmiPage::validate(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; | |||
| } | |||
| @@ -8,9 +8,9 @@ | |||
| #pragma once | |||
| #include "register_address.h" | |||
| #include "register_value_type.h" | |||
| #include "project_limits.h" | |||
| #include <cstdint> | |||
| #include <map> | |||
| #include <optional> | |||
| #include <string> | |||
| @@ -56,7 +56,6 @@ enum class HmiControlType | |||
| Label, // 标签 | |||
| PageJump, // 页面跳转 | |||
| AlarmList, // 报警列表 | |||
| ProgressBar, // 进度条 | |||
| Count // 已注册控件类型数量,不作为实际控件使用 | |||
| }; | |||
| @@ -76,19 +75,6 @@ struct HmiPageJumpConfig | |||
| std::string targetPageId; | |||
| }; | |||
| /** | |||
| * @brief 进度条的有效值范围和文字显示方式 | |||
| */ | |||
| struct HmiProgressBarConfig | |||
| { | |||
| std::int16_t minimumValue = 0; | |||
| std::int16_t maximumValue = 100; | |||
| bool showValue = true; | |||
| bool isValid() const; | |||
| int percentageForValue(std::int16_t value) const; | |||
| }; | |||
| /** | |||
| * @brief 描述一个可保存的 HMI 控件及其显示和寄存器配置 | |||
| * | |||
| @@ -101,10 +87,10 @@ struct HmiControl | |||
| HmiRect bounds; | |||
| std::string text; | |||
| std::optional<RegisterAddress> binding; | |||
| RegisterDataType dataType = RegisterDataType::Int16; | |||
| std::map<std::string, std::string> properties; | |||
| HmiButtonOperation buttonOperation = HmiButtonOperation::MomentaryOn; | |||
| std::optional<HmiPageJumpConfig> pageJump; | |||
| std::optional<HmiProgressBarConfig> progressBar; | |||
| /** | |||
| * @brief 校验控件的标识、尺寸、扩展属性和寄存器绑定 | |||
| @@ -136,5 +122,11 @@ struct HmiPage | |||
| * @return 页面及其全部控件有效时返回 true | |||
| */ | |||
| 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( | |||
| const ProjectLimitSettings &limits, | |||
| std::string *error = nullptr) const; | |||
| }; | |||
| @@ -2,64 +2,99 @@ | |||
| #include <cstddef> | |||
| // 所有跨层共享的数量和范围边界集中在这里,避免 UI、服务和 JSON 各自写数字 | |||
| 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 <cctype> | |||
| #include <variant> | |||
| #include <type_traits> | |||
| namespace { | |||
| @@ -70,6 +72,51 @@ bool containsLineBreak(const std::string &value) | |||
| || 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 | |||
| bool RegisterComment::validate(std::string *error) const | |||
| @@ -111,6 +158,13 @@ const RegisterComment *Project::findRegisterComment( | |||
| 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()) | |||
| { | |||
| setError(error, "工程 ID、名称和格式版本不能为空"); | |||
| @@ -124,7 +178,7 @@ bool Project::validate(std::string *error) const | |||
| } | |||
| if (metadata.name.size() > ProjectLimits::kMaximumTextBytes) | |||
| { | |||
| setError(error, "工程名称不能超过 4096 个 UTF-8 字节"); | |||
| setError(error, "工程名称不能超过 256 个 UTF-8 字节"); | |||
| return false; | |||
| } | |||
| if (initialHmiPageId.size() > ProjectLimits::kMaximumIdBytes) | |||
| @@ -132,14 +186,23 @@ bool Project::validate(std::string *error) const | |||
| setError(error, "初始 HMI 页面 ID 不能超过 128 个 UTF-8 字节"); | |||
| 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; | |||
| } | |||
| 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; | |||
| } | |||
| if (registerComments.size() > ProjectLimits::kMaximumRegisterComments) | |||
| @@ -147,9 +210,33 @@ bool Project::validate(std::string *error) const | |||
| setError(error, "单个工程最多包含 8002 条软元件注释"); | |||
| 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; | |||
| } | |||
| if (containsDuplicateId(hmiPages)) | |||
| @@ -225,7 +312,7 @@ bool Project::validate(std::string *error) const | |||
| for (const HmiPage &page : hmiPages) | |||
| { | |||
| // 工程聚合校验会向下委托页面和控件的完整规则 | |||
| if (!page.validate(error)) | |||
| if (!page.validate(limits, error)) | |||
| { | |||
| 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) | |||
| { | |||
| if (!logic.validate(error)) | |||
| if (!logic.validate(limits, error)) | |||
| { | |||
| return false; | |||
| } | |||
| } | |||
| // 同时收集所有显式 M/D 引用,提前检查 PLC 轮询集合不会超过上限 | |||
| std::vector<RegisterAddress> poll_addresses; | |||
| for (const HmiPage &page : hmiPages) | |||
| { | |||
| @@ -266,6 +382,13 @@ bool Project::validate(std::string *error) const | |||
| if (control.binding.has_value()) | |||
| { | |||
| 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) | |||
| { | |||
| 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()); | |||
| if (poll_addresses.size() > ProjectLimits::kMaximumPollAddresses) | |||
| { | |||
| setError(error, "工程中需要 PLC 轮询的去重 M/D 地址最多为 1024 个"); | |||
| setError(error, "工程中需要 PLC 轮询的去重 M/D 地址最多为 256 个"); | |||
| return false; | |||
| } | |||
| return true; | |||
| @@ -313,27 +456,30 @@ bool Project::validate(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; | |||
| } | |||
| for (const HmiPage &page : hmiPages) | |||
| { | |||
| if (!page.validateForRunning(error)) | |||
| if (!page.validateForRunning(limits, error)) | |||
| { | |||
| return false; | |||
| } | |||
| } | |||
| for (const ControlLogic &logic : controlLogics) | |||
| { | |||
| if (logic.enabled && !logic.validateForRunning(error)) | |||
| if (logic.enabled && !logic.validateForRunning(limits, error)) | |||
| { | |||
| return false; | |||
| } | |||
| } | |||
| if (!validateLogicResourceReferencesForRunning(controlLogics, error)) | |||
| { | |||
| return false; | |||
| } | |||
| return true; | |||
| } | |||
| @@ -3,15 +3,18 @@ | |||
| #include "alarm_model.h" | |||
| #include "control_logic_model.h" | |||
| #include "hmi_model.h" | |||
| #include "project_limits.h" | |||
| #include <string> | |||
| #include <vector> | |||
| // 工程级 M/D 注释,只描述地址含义,不保存实时值 | |||
| struct RegisterComment | |||
| { | |||
| RegisterAddress address{RegisterArea::M, 0}; | |||
| std::string text; | |||
| // 校验地址有效性和 UTF-8 文本长度 | |||
| bool validate(std::string *error = nullptr) const; | |||
| }; | |||
| @@ -26,7 +29,7 @@ struct ProjectMetadata | |||
| std::string formatVersion = "1.0"; | |||
| }; | |||
| // 聚合工程中的 HMI 页面和控制逻辑 | |||
| // 聚合工程中的 HMI 页面、报警、寄存器注释和控制逻辑 | |||
| struct Project | |||
| { | |||
| // 工程基本信息 | |||
| @@ -42,8 +45,16 @@ struct Project | |||
| // 工程包含的控制逻辑集合 | |||
| std::vector<ControlLogic> controlLogics; | |||
| // 按地址查找工程注释,找不到时返回 nullptr | |||
| const RegisterComment *findRegisterComment(const RegisterAddress &address) const; | |||
| // 校验工程配置并通过 error 返回失败原因 | |||
| // 编辑态校验:允许尚未绑定完成的草稿 | |||
| 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( | |||
| const ProjectLimitSettings &limits, | |||
| std::string *error = nullptr) const; | |||
| }; | |||
| @@ -2,6 +2,7 @@ | |||
| #include <string> | |||
| // 解析用户输入的 M/D 地址时可能出现的失败原因 | |||
| enum class RegisterAddressParseError | |||
| { | |||
| None, | |||
| @@ -11,23 +12,30 @@ enum class RegisterAddressParseError | |||
| OutOfRange | |||
| }; | |||
| // PLC 中支持的寄存器区域:M 是位,D 是 16 位有符号字 | |||
| enum class RegisterArea | |||
| { | |||
| M, | |||
| D | |||
| }; | |||
| // 表示一个已经拆分为“区域 + 下标”的 M/D 地址 | |||
| // 下标统一使用 0~4000,和 Modbus 原始地址保持一致 | |||
| class RegisterAddress | |||
| { | |||
| public: | |||
| static constexpr int kMinimumIndex = 0; | |||
| static constexpr int kMaximumIndex = 4000; | |||
| // 构造地址对象;非法下标不会在构造时抛异常,应通过 isValid() 检查 | |||
| RegisterAddress(RegisterArea area, int index); | |||
| // 返回地址区域和数字下标 | |||
| RegisterArea area() const; | |||
| int index() const; | |||
| // 判断区域和下标是否满足项目边界 | |||
| bool isValid() const; | |||
| // 转成界面和日志中使用的形式,例如 M10 或 D4000 | |||
| std::string toString() const; | |||
| bool operator==(const RegisterAddress &other) const; | |||
| @@ -38,6 +46,7 @@ private: | |||
| int index_; | |||
| }; | |||
| // 文本地址解析结果;失败时 address 保持默认值,具体原因见 error | |||
| struct RegisterAddressParseResult | |||
| { | |||
| bool succeeded = false; | |||
| @@ -45,4 +54,5 @@ struct RegisterAddressParseResult | |||
| RegisterAddressParseError error = RegisterAddressParseError::InvalidFormat; | |||
| }; | |||
| // 解析 M0、D4000 这类地址文本,不接受带偏移的 Modbus 地址 | |||
| RegisterAddressParseResult parseRegisterAddress(const std::string &text); | |||
| @@ -4,37 +4,80 @@ | |||
| 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_; | |||
| } | |||
| 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; | |||
| } | |||
| addresses_.push_back(address); | |||
| points_.push_back(point); | |||
| return true; | |||
| } | |||
| 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; | |||
| } | |||
| addresses_.erase(position); | |||
| points_.erase(position); | |||
| return true; | |||
| } | |||
| void RegisterMonitorModel::clear() | |||
| { | |||
| addresses_.clear(); | |||
| points_.clear(); | |||
| } | |||
| @@ -1,11 +1,13 @@ | |||
| #pragma once | |||
| #include "register_address.h" | |||
| #include "register_value_type.h" | |||
| #include <cstddef> | |||
| #include <cstdint> | |||
| #include <vector> | |||
| // 监控读数的可用状态 | |||
| enum class MonitorValueState | |||
| { | |||
| Valid, | |||
| @@ -13,25 +15,45 @@ enum class MonitorValueState | |||
| CommunicationFault | |||
| }; | |||
| // 一个监控行的地址、可用性和当前值 | |||
| struct MonitorValue | |||
| { | |||
| RegisterAddress address{RegisterArea::M, 0}; | |||
| RegisterDataType dataType = RegisterDataType::Int16; | |||
| int endAddress = 0; | |||
| MonitorValueState state = MonitorValueState::Unavailable; | |||
| bool bitValue = false; | |||
| std::int16_t wordValue = 0; | |||
| float floatValue = 0.0f; | |||
| }; | |||
| struct MonitorPoint | |||
| { | |||
| RegisterAddress address{RegisterArea::M, 0}; | |||
| RegisterDataType dataType = RegisterDataType::Int16; | |||
| }; | |||
| // 运行期间由用户临时维护的监控地址列表,不写入工程文件 | |||
| class RegisterMonitorModel | |||
| { | |||
| public: | |||
| static constexpr std::size_t kMaximumItemCount = 64U; | |||
| // 返回当前按添加顺序保存的地址 | |||
| 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 MonitorPoint &point); | |||
| // 清空当前会话的监控列表 | |||
| void clear(); | |||
| private: | |||
| std::vector<RegisterAddress> addresses_; | |||
| mutable std::vector<RegisterAddress> addresses_; | |||
| std::vector<MonitorPoint> points_; | |||
| }; | |||
| @@ -2,11 +2,10 @@ | |||
| #include "register_address.h" | |||
| #include <array> | |||
| #include <cstddef> | |||
| #include <cstdint> | |||
| #include <array> | |||
| // 寄存器仓库操作结果 | |||
| // 寄存器仓库操作失败原因;服务层会把它转换成用户可读消息 | |||
| enum class RegisterError | |||
| { | |||
| None, // 操作成功 无错误 | |||
| @@ -39,45 +38,56 @@ struct RegisterWriteResult | |||
| RegisterError error = RegisterError::Unavailable; // 写入失败原因 | |||
| }; | |||
| struct WordPairReadResult | |||
| { | |||
| bool succeeded = false; | |||
| std::array<std::int16_t, 2> values{}; | |||
| RegisterError error = RegisterError::Unavailable; | |||
| }; | |||
| // M/D 寄存器访问边界 | |||
| // 所有运行态读写都必须经过此接口,UI 不直接接触串口或缓存实现 | |||
| class RegisterRepository | |||
| { | |||
| public: | |||
| virtual ~RegisterRepository() = default; | |||
| // 读取 M 区位寄存器 | |||
| // 读取 M 区位寄存器;传入 D 区地址会返回 AreaMismatch | |||
| virtual BitReadResult readBit(const RegisterAddress &address) const = 0; | |||
| // 写入 M 区位寄存器 | |||
| // 写入 M 区位寄存器;真机实现不会在收到 PLC 确认前乐观修改缓存 | |||
| virtual RegisterWriteResult writeBit(const RegisterAddress &address, bool value) = 0; | |||
| // 读取 D 区字寄存器 | |||
| // 读取 D 区 16 位字寄存器 | |||
| virtual WordReadResult readWord(const RegisterAddress &address) const = 0; | |||
| // 写入 D 区字寄存器 | |||
| // 写入 D 区 16 位字寄存器 | |||
| virtual RegisterWriteResult writeWord( | |||
| 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 | |||
| // 应用当前运行模式 | |||
| // 应用当前运行模式;三个状态之间不能直接从离线切到真机 | |||
| enum class ApplicationMode | |||
| { | |||
| Editing, // 编辑工程状态 | |||
| @@ -8,7 +8,7 @@ enum class ApplicationMode | |||
| OnlineRunning // 真机联机运行状态 | |||
| }; | |||
| // 当前运行模式对应的能力策略 | |||
| // 当前运行模式对应的能力策略,UI 用它决定哪些入口可用 | |||
| struct ModePolicy | |||
| { | |||
| // 是否允许编辑工程 | |||
| @@ -23,7 +23,7 @@ struct ModePolicy | |||
| bool requiresInitialPlcRead = false; | |||
| }; | |||
| // 根据运行模式返回对应的能力策略 | |||
| // 根据运行模式返回对应的能力策略;default 用于防御非法枚举值 | |||
| constexpr ModePolicy policyForMode(ApplicationMode mode) | |||
| { | |||
| switch (mode) | |||
| @@ -52,12 +52,12 @@ constexpr ModePolicy policyForMode(ApplicationMode mode) | |||
| } | |||
| case ApplicationMode::OnlineRunning: | |||
| { | |||
| // 真机运行态禁止改工程,使用 PLC 数据且禁止软件逻辑重复控制 PLC | |||
| // 真机运行态使用 PLC 数据,并在本地只读推算梯形图轨迹 | |||
| return { | |||
| false, // allowsProjectEditing 禁止编辑工程 | |||
| false, // usesVirtualRegisters 不使用虚拟寄存器 | |||
| true, // usesPlcRegisters 使用 PLC 寄存器 | |||
| false, // runsLogicExecutor 不运行软件逻辑执行器 | |||
| true, // runsLogicExecutor 运行只读本地轨迹执行器 | |||
| true // requiresInitialPlcRead 必须先完成 PLC 初次读取 | |||
| }; | |||
| } | |||
| @@ -68,7 +68,7 @@ constexpr ModePolicy policyForMode(ApplicationMode mode) | |||
| } | |||
| } | |||
| // 模式切换失败原因 | |||
| // 模式切换失败原因,服务层会进一步补充可读消息 | |||
| enum class ModeTransitionError | |||
| { | |||
| None, // 切换成功 | |||
| @@ -76,7 +76,7 @@ enum class ModeTransitionError | |||
| MustReturnToEditing, // 必须先返回编辑态 | |||
| InitialPlcReadRequired, // 进入真机态前必须完成 PLC 初次读取 | |||
| ProjectNotReady, // 工程存在未配置或未完成的运行项 | |||
| SimulationStartFailed // 离线执行器预检或启动失败 | |||
| SimulationStartFailed // 当前模式的本地执行器预检或启动失败 | |||
| }; | |||
| // 模式切换操作结果 | |||
| @@ -88,6 +88,7 @@ struct ModeTransitionResult | |||
| ModeTransitionError error = ModeTransitionError::None; | |||
| }; | |||
| // 保存当前模式并执行最基本的状态机约束 | |||
| class RuntimeState | |||
| { | |||
| public: | |||
| @@ -1,4 +1,4 @@ | |||
| #include "register_repository.h" | |||
| #include "virtual_register_repository.h" | |||
| VirtualRegisterRepository::VirtualRegisterRepository() | |||
| { | |||
| @@ -66,9 +66,28 @@ RegisterWriteResult VirtualRegisterRepository::writeWord( | |||
| 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() | |||
| { | |||
| // 离线运行复位时同时清空 M 区和 D 区 | |||
| bits_.fill(false); | |||
| 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( | |||
| ProjectStorageError::InvalidField, | |||
| fieldPath(context, field) + " 的元素数量超过上限"); | |||
| fieldPath(context, field) + " 的元素数量为 " | |||
| + std::to_string(value->size()) + ",当前上限为 " | |||
| + std::to_string(maximum_count)); | |||
| } | |||
| return true; | |||
| } | |||
| @@ -270,64 +272,6 @@ bool parseAddress( | |||
| 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) | |||
| { | |||
| return kind == WordOperandKind::Constant | |||
| @@ -689,7 +633,7 @@ bool parseProperties( | |||
| { | |||
| return state->fail( | |||
| ProjectStorageError::InvalidField, | |||
| "HMI 控件属性名称最多 128 字节,属性值最多 4096 字节"); | |||
| "HMI 控件属性名称最多 128 字节,属性值最多 1024 字节"); | |||
| } | |||
| properties->emplace(key, value); | |||
| } | |||
| @@ -714,6 +658,13 @@ QJsonObject serializeHmiControl(const HmiControl &control) | |||
| object.insert(QStringLiteral("binding"), QJsonValue::Null); | |||
| } | |||
| 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) | |||
| { | |||
| object.insert( | |||
| @@ -728,14 +679,6 @@ QJsonObject serializeHmiControl(const HmiControl &control) | |||
| ? control.pageJump->targetPageId | |||
| : 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; | |||
| } | |||
| @@ -767,6 +710,18 @@ bool parseHmiControl( | |||
| { | |||
| 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) | |||
| { | |||
| std::string operation_text; | |||
| @@ -793,37 +748,6 @@ bool parseHmiControl( | |||
| } | |||
| 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,其余非空值必须是合法的寄存器地址对象 | |||
| if (binding.isNull()) | |||
| { | |||
| @@ -868,6 +792,7 @@ QJsonObject serializeHmiPage(const HmiPage &page) | |||
| bool parseHmiPage( | |||
| const QJsonObject &object, | |||
| const std::string &context, | |||
| const ProjectLimitSettings &limits, | |||
| HmiPage *page, | |||
| ParseState *state) | |||
| { | |||
| @@ -886,7 +811,7 @@ bool parseHmiPage( | |||
| &page->height, state) | |||
| || !readArray( | |||
| object, "controls", context, &controls, state, | |||
| static_cast<int>(ProjectLimits::kMaximumHmiControlsPerPage))) | |||
| static_cast<int>(limits.maximumHmiControlsPerPage))) | |||
| { | |||
| return false; | |||
| } | |||
| @@ -1113,24 +1038,6 @@ QJsonObject serializeNodeConfig(const EdgeContactNodeConfig &config) | |||
| 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) | |||
| { | |||
| @@ -1152,35 +1059,6 @@ QJsonObject serializeNodeConfig(const CompareNodeConfig &config) | |||
| 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 object; | |||
| @@ -1238,113 +1116,6 @@ bool parseNodeConfig( | |||
| 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") | |||
| { | |||
| QJsonObject source_object; | |||
| @@ -1410,6 +1181,14 @@ bool parseNodeConfig( | |||
| return true; | |||
| } | |||
| if (type != "contact" && type != "edgeContact" | |||
| && type != "coil" && type != "compare") | |||
| { | |||
| return state->fail( | |||
| ProjectStorageError::InvalidField, | |||
| "不支持的逻辑节点类型:" + type); | |||
| } | |||
| QJsonObject address_object; | |||
| if (!readObject(object, "address", context, &address_object, state)) | |||
| { | |||
| @@ -1481,11 +1260,7 @@ bool parseNodeConfig( | |||
| address, comparison, static_cast<std::int16_t>(value)}; | |||
| return true; | |||
| } | |||
| return state->fail( | |||
| ProjectStorageError::InvalidField, | |||
| "不支持的逻辑节点类型:" + type); | |||
| return state->fail(ProjectStorageError::InvalidField, "逻辑节点类型解析失败"); | |||
| } | |||
| // 解析逻辑节点标识及其多态配置 | |||
| @@ -1516,6 +1291,8 @@ QString expressionKindText(ConditionExpressionKind kind) | |||
| return QStringLiteral("node"); | |||
| case ConditionExpressionKind::Wire: | |||
| return QStringLiteral("wire"); | |||
| case ConditionExpressionKind::Gap: | |||
| return QStringLiteral("gap"); | |||
| case ConditionExpressionKind::Series: | |||
| return QStringLiteral("series"); | |||
| case ConditionExpressionKind::Parallel: | |||
| @@ -1537,6 +1314,10 @@ QJsonObject serializeConditionExpression(const ConditionExpression &expression) | |||
| { | |||
| object.insert(QStringLiteral("columnSpan"), expression.wire->columnSpan); | |||
| } | |||
| else if (expression.kind == ConditionExpressionKind::Gap) | |||
| { | |||
| object.insert(QStringLiteral("columnSpan"), expression.gap->columnSpan); | |||
| } | |||
| else | |||
| { | |||
| QJsonArray children; | |||
| @@ -1561,14 +1342,14 @@ bool parseConditionExpression( | |||
| { | |||
| return state->fail( | |||
| ProjectStorageError::InvalidField, | |||
| context + " 的嵌套深度超过 20 层"); | |||
| context + " 的嵌套深度超过 12 层"); | |||
| } | |||
| ++*node_count; | |||
| if (*node_count > ProjectLimits::kMaximumExpressionNodesPerRung) | |||
| { | |||
| return state->fail( | |||
| ProjectStorageError::InvalidField, | |||
| context + " 的表达式节点总数超过 4096 个"); | |||
| context + " 的表达式节点总数超过 1024 个"); | |||
| } | |||
| std::string kind; | |||
| if (!readString( | |||
| @@ -1612,11 +1393,29 @@ bool parseConditionExpression( | |||
| expression->wire = WireSegment{column_span}; | |||
| 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") | |||
| { | |||
| return state->fail( | |||
| ProjectStorageError::InvalidField, | |||
| context + ".kind 必须是 node、wire、series 或 parallel"); | |||
| context + ".kind 必须是 node、wire、gap、series 或 parallel"); | |||
| } | |||
| QJsonArray children; | |||
| if (!readArray( | |||
| @@ -1751,6 +1550,7 @@ QJsonObject serializeControlLogic(const ControlLogic &logic) | |||
| bool parseControlLogic( | |||
| const QJsonObject &object, | |||
| const std::string &context, | |||
| const ProjectLimitSettings &limits, | |||
| ControlLogic *logic, | |||
| ParseState *state) | |||
| { | |||
| @@ -1762,7 +1562,7 @@ bool parseControlLogic( | |||
| || !readBool(object, "enabled", context, &logic->enabled, state) | |||
| || !readArray( | |||
| object, "rungs", context, &rungs, state, | |||
| static_cast<int>(ProjectLimits::kMaximumRungsPerLogic))) | |||
| static_cast<int>(limits.maximumRungsPerLogic))) | |||
| { | |||
| return false; | |||
| } | |||
| @@ -1831,7 +1631,10 @@ QJsonObject serializeProject(const Project &project) | |||
| // 从顶层 JSON 对象解析完整工程,并在解析子对象前检查格式版本 | |||
| bool parseProject( | |||
| const QJsonObject &object, Project *project, ParseState *state) | |||
| const QJsonObject &object, | |||
| const ProjectLimitSettings &limits, | |||
| Project *project, | |||
| ParseState *state) | |||
| { | |||
| QJsonArray pages; | |||
| QJsonArray alarms; | |||
| @@ -1861,20 +1664,20 @@ bool parseProject( | |||
| || !readString(object, "name", "project", &project->metadata.name, state) | |||
| || !readArray( | |||
| object, "hmiPages", "project", &pages, state, | |||
| static_cast<int>(ProjectLimits::kMaximumHmiPages)) | |||
| static_cast<int>(limits.maximumHmiPages)) | |||
| || !readString( | |||
| object, "initialHmiPageId", "project", | |||
| &project->initialHmiPageId, state, | |||
| ProjectLimits::kMaximumIdBytes) | |||
| || !readArray( | |||
| object, "alarmDefinitions", "project", &alarms, state, | |||
| static_cast<int>(ProjectLimits::kMaximumAlarmDefinitions)) | |||
| static_cast<int>(limits.maximumAlarmDefinitions)) | |||
| || !readArray( | |||
| object, "registerComments", "project", ®ister_comments, state, | |||
| static_cast<int>(ProjectLimits::kMaximumRegisterComments)) | |||
| || !readArray( | |||
| object, "controlLogics", "project", &logics, state, | |||
| static_cast<int>(ProjectLimits::kMaximumControlLogics))) | |||
| static_cast<int>(limits.maximumControlLogics))) | |||
| { | |||
| return false; | |||
| } | |||
| @@ -1893,6 +1696,7 @@ bool parseProject( | |||
| if (!parseHmiPage( | |||
| pages.at(index).toObject(), | |||
| "project.hmiPages[" + std::to_string(index) + ']', | |||
| limits, | |||
| &page, | |||
| state)) | |||
| { | |||
| @@ -1956,6 +1760,7 @@ bool parseProject( | |||
| if (!parseControlLogic( | |||
| logics.at(index).toObject(), | |||
| "project.controlLogics[" + std::to_string(index) + ']', | |||
| limits, | |||
| &logic, | |||
| state)) | |||
| { | |||
| @@ -1975,13 +1780,19 @@ ProjectSaveResult saveFailure( | |||
| } // namespace | |||
| JsonProjectStorage::JsonProjectStorage( | |||
| const ProjectLimitSettings &project_limits) | |||
| : project_limits_(project_limits) | |||
| { | |||
| } | |||
| // 校验工程后将其序列化,并通过 QSaveFile 原子写入目标文件 | |||
| ProjectSaveResult JsonProjectStorage::save( | |||
| const Project &project, const std::string &file_path) | |||
| { | |||
| // 写文件前先执行领域校验,防止持久化内部关系不合法的工程 | |||
| std::string validation_error; | |||
| if (!project.validate(&validation_error)) | |||
| if (!project.validate(project_limits_, &validation_error)) | |||
| { | |||
| return {false, ProjectStorageError::InvalidProject, validation_error}; | |||
| } | |||
| @@ -2079,14 +1890,13 @@ ProjectLoadResult JsonProjectStorage::load(const std::string &file_path) | |||
| // 先在局部对象中完成结构解析,失败时不会暴露半成品工程 | |||
| Project project; | |||
| ParseState state; | |||
| if (!parseProject(document.object(), &project, &state)) | |||
| if (!parseProject(document.object(), project_limits_, &project, &state)) | |||
| { | |||
| return {false, {}, state.error, state.message}; | |||
| } | |||
| // JSON 字段合法不代表业务关系合法,还需执行领域层整体校验 | |||
| std::string validation_error; | |||
| if (!project.validate(&validation_error)) | |||
| if (!project.validate(project_limits_, &validation_error)) | |||
| { | |||
| return {false, | |||
| {}, | |||
| @@ -2,11 +2,17 @@ | |||
| #include "domain/project_storage.h" | |||
| // 使用版本化 JSON 文件保存和加载工程 | |||
| // 严格读写当前 1.0 JSON 工程格式 | |||
| class JsonProjectStorage final : public ProjectStorage | |||
| { | |||
| public: | |||
| explicit JsonProjectStorage( | |||
| const ProjectLimitSettings &project_limits = | |||
| defaultProjectLimitSettings()); | |||
| ProjectSaveResult save( | |||
| const Project &project, 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 <QString> | |||
| // Qt 错误码之外的通信现场信息,用于区分“未打开串口”和“已断线” | |||
| struct PlcCommunicationErrorContext | |||
| { | |||
| QString portName; | |||
| @@ -12,12 +13,14 @@ struct PlcCommunicationErrorContext | |||
| bool receivedValidResponse = false; | |||
| }; | |||
| // 分类后的用户可读通信故障 | |||
| struct PlcCommunicationFailure | |||
| { | |||
| PlcCommunicationError type = PlcCommunicationError::Unknown; | |||
| QString message; | |||
| }; | |||
| // 将 Qt Modbus 错误和上下文转换为项目统一错误类型 | |||
| PlcCommunicationFailure classifyPlcCommunicationError( | |||
| QModbusDevice::Error error, | |||
| const PlcCommunicationErrorContext &context); | |||
| @@ -15,26 +15,31 @@ | |||
| namespace { | |||
| // 故障恢复时,两次轻量探测之间等待的时间 | |||
| constexpr int kRecoveryProbeIntervalMs = 2000; | |||
| // Qt 使用 QString,项目内部错误信息统一使用 UTF-8 std::string | |||
| std::string toUtf8(const QString &value) | |||
| { | |||
| const QByteArray bytes = value.toUtf8(); | |||
| return std::string(bytes.constData(), static_cast<std::size_t>(bytes.size())); | |||
| } | |||
| // M 区在 Modbus 中按线圈读取,D 区按保持寄存器读取 | |||
| QModbusDataUnit::RegisterType registerType(RegisterArea area) | |||
| { | |||
| return area == RegisterArea::M | |||
| ? QModbusDataUnit::Coils : QModbusDataUnit::HoldingRegisters; | |||
| } | |||
| // 只有超时和 PLC 不响应属于可以自动探测恢复的故障 | |||
| bool isRecoverableTimeout(PlcCommunicationError error) | |||
| { | |||
| return error == PlcCommunicationError::PlcNotResponding | |||
| || error == PlcCommunicationError::CommunicationTimeout; | |||
| } | |||
| // Connected 正常轮询,Recovering 正在恢复后的重新首读 | |||
| bool isReadingState(PlcConnectionState state) | |||
| { | |||
| return state == PlcConnectionState::Connected | |||
| @@ -46,6 +51,7 @@ bool normalizePollAddresses( | |||
| std::vector<RegisterAddress> *normalized, | |||
| std::string *error) | |||
| { | |||
| // 先复制,后续排序和去重不能改变调用方传入的地址列表 | |||
| *normalized = addresses; | |||
| if (std::any_of( | |||
| normalized->cbegin(), normalized->cend(), | |||
| @@ -66,16 +72,35 @@ bool normalizePollAddresses( | |||
| std::unique(normalized->begin(), normalized->end()), normalized->end()); | |||
| if (normalized->size() > ProjectLimits::kMaximumPollAddresses) | |||
| { | |||
| *error = "PLC 轮询的去重 M/D 地址最多为 1024 个"; | |||
| *error = "PLC 轮询的去重 M/D 地址最多为 256 个"; | |||
| return false; | |||
| } | |||
| 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()) | |||
| { | |||
| // 空集合也会保留 M0、D0 两个默认读块 | |||
| return 2U; | |||
| } | |||
| std::size_t blocks = 0U; | |||
| @@ -84,6 +109,20 @@ std::size_t pollBlockCount(const std::vector<RegisterAddress> &addresses) | |||
| int count = 0; | |||
| 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 | |||
| || address.area() != current_area | |||
| || address.index() > start_address + count | |||
| @@ -111,6 +150,7 @@ PlcCommunicationService::PlcCommunicationService( | |||
| repository_(repository), | |||
| master_(std::make_unique<QModbusRtuSerialMaster>()) | |||
| { | |||
| // 仓库只负责缓存;写入请求通过这里注入的回调转给通信服务 | |||
| repository_.setWriteHandlers( | |||
| [this](const RegisterAddress &address, bool value) | |||
| { | |||
| @@ -119,7 +159,13 @@ PlcCommunicationService::PlcCommunicationService( | |||
| [this](const RegisterAddress &address, std::int16_t 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); | |||
| recovery_timer_.setSingleShot(true); | |||
| connect( | |||
| @@ -127,12 +173,14 @@ PlcCommunicationService::PlcCommunicationService( | |||
| &QTimer::timeout, | |||
| this, | |||
| &PlcCommunicationService::probeRecovery); | |||
| // Qt 串口状态变化是异步通知,所有连接状态都从这里统一处理 | |||
| connect(master_.get(), &QModbusClient::stateChanged, | |||
| this, | |||
| [this](QModbusDevice::State device_state) | |||
| { | |||
| if (device_state == QModbusDevice::ConnectedState) | |||
| { | |||
| // 串口连接成功后立即开始轮询,并不代表首读已经完成 | |||
| serial_session_opened_ = true; | |||
| setState(PlcConnectionState::Connected); | |||
| poll_timer_.start(configuration_.pollIntervalMs); | |||
| @@ -145,6 +193,7 @@ PlcCommunicationService::PlcCommunicationService( | |||
| } | |||
| else if (device_state == QModbusDevice::UnconnectedState) | |||
| { | |||
| // 主动断开和意外断线要区分,后者需要报告故障 | |||
| if (disconnecting_) | |||
| { | |||
| serial_session_opened_ = false; | |||
| @@ -159,6 +208,7 @@ PlcCommunicationService::PlcCommunicationService( | |||
| } | |||
| } | |||
| }); | |||
| // Qt 报告底层错误时,转换为项目自己的错误和状态 | |||
| connect(master_.get(), &QModbusClient::errorOccurred, | |||
| this, | |||
| [this](QModbusDevice::Error error) | |||
| @@ -190,6 +240,7 @@ PlcCommunicationResult PlcCommunicationService::connectDevice( | |||
| { | |||
| return {false, "PLC 连接已经启动,请先断开当前连接"}; | |||
| } | |||
| // 每次连接都递增代次;旧连接的异步回复即使晚到也不能污染新缓存 | |||
| configuration_ = configuration; | |||
| ++connection_generation_; | |||
| disconnecting_ = false; | |||
| @@ -197,6 +248,7 @@ PlcCommunicationResult PlcCommunicationService::connectDevice( | |||
| received_valid_response_ = false; | |||
| last_error_type_ = PlcCommunicationError::None; | |||
| last_error_.clear(); | |||
| // 把项目配置转换成 Qt 串口参数 | |||
| master_->setConnectionParameter( | |||
| QModbusDevice::SerialPortNameParameter, | |||
| QString::fromStdString(configuration.portName)); | |||
| @@ -213,6 +265,7 @@ PlcCommunicationResult PlcCommunicationService::connectDevice( | |||
| static_cast<QSerialPort::StopBits>(configuration.stopBits)); | |||
| master_->setTimeout(configuration.responseTimeoutMs); | |||
| master_->setNumberOfRetries(configuration.retries); | |||
| // 新连接必须重新完成完整首读,不能沿用上一条连接的运行资格 | |||
| repository_.invalidate(); | |||
| updateInitialReadCompleted(false, true); | |||
| rebuildPollBlocks(); | |||
| @@ -232,6 +285,7 @@ PlcCommunicationResult PlcCommunicationService::connectDevice( | |||
| void PlcCommunicationService::disconnectDevice() | |||
| { | |||
| // 关闭串口、清除缓存有效标记,防止断开后继续使用旧值 | |||
| closeSerialSession(); | |||
| repository_.invalidate(); | |||
| updateInitialReadCompleted(false, true); | |||
| @@ -242,6 +296,13 @@ void PlcCommunicationService::disconnectDevice() | |||
| PlcCommunicationResult PlcCommunicationService::setPollAddresses( | |||
| 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::string error; | |||
| @@ -249,16 +310,73 @@ PlcCommunicationResult PlcCommunicationService::setPollAddresses( | |||
| { | |||
| 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) | |||
| { | |||
| // 当前回复仍在解析旧集合,等它结束后再切换到新集合 | |||
| pending_poll_addresses_ = std::move(normalized); | |||
| pending_poll_float32_starts_ = std::move(normalized_float32_starts); | |||
| poll_update_pending_ = true; | |||
| return {true, {}}; | |||
| } | |||
| poll_float32_starts_ = std::move(normalized_float32_starts); | |||
| applyPollAddresses(normalized); | |||
| return {true, {}}; | |||
| } | |||
| @@ -292,12 +410,15 @@ void PlcCommunicationService::setCallbacks( | |||
| std::function<void()> state_changed, | |||
| std::function<void(bool)> initial_read_changed, | |||
| std::function<void()> cache_updated, | |||
| std::function<void()> poll_cycle_completed, | |||
| 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() | |||
| @@ -305,6 +426,7 @@ void PlcCommunicationService::rebuildPollBlocks() | |||
| std::vector<RegisterAddress> addresses = poll_addresses_; | |||
| if (addresses.empty()) | |||
| { | |||
| // 没有指定地址时保留 M0/D0,保证连接后仍能验证设备是否响应 | |||
| addresses = { | |||
| RegisterAddress{RegisterArea::M, 0}, | |||
| RegisterAddress{RegisterArea::D, 0}}; | |||
| @@ -320,6 +442,18 @@ void PlcCommunicationService::rebuildPollBlocks() | |||
| return left.index() < right.index(); | |||
| }); | |||
| 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(); | |||
| for (const RegisterAddress &address : addresses) | |||
| { | |||
| @@ -327,6 +461,21 @@ void PlcCommunicationService::rebuildPollBlocks() | |||
| { | |||
| 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() | |||
| || poll_blocks_.back().area != address.area() | |||
| || address.index() > poll_blocks_.back().startAddress | |||
| @@ -345,6 +494,7 @@ void PlcCommunicationService::rebuildPollBlocks() | |||
| next_poll_block_ = 0; | |||
| if (!initial_read_completed_) | |||
| { | |||
| // 地址集合变化或重新连接后,首读资格从头统计 | |||
| initial_blocks_read_.assign(poll_blocks_.size(), false); | |||
| } | |||
| else | |||
| @@ -356,10 +506,12 @@ void PlcCommunicationService::rebuildPollBlocks() | |||
| void PlcCommunicationService::applyPollAddresses( | |||
| const std::vector<RegisterAddress> &addresses) | |||
| { | |||
| // 只有在没有读请求占用时,新的地址集合才会真正生效 | |||
| poll_addresses_ = addresses; | |||
| rebuildPollBlocks(); | |||
| poll_update_pending_ = false; | |||
| pending_poll_addresses_.clear(); | |||
| pending_poll_float32_starts_.clear(); | |||
| if (isReadingState(state_) && pending_reply_ == nullptr) | |||
| { | |||
| pollNextBlock(); | |||
| @@ -373,6 +525,7 @@ void PlcCommunicationService::pollNextBlock() | |||
| { | |||
| return; | |||
| } | |||
| // 每次只发一个异步请求,完成回调中再推进到下一个块 | |||
| const std::size_t block_index = next_poll_block_; | |||
| const PollBlock block = poll_blocks_.at(block_index); | |||
| next_poll_block_ = (next_poll_block_ + 1U) % poll_blocks_.size(); | |||
| @@ -385,11 +538,13 @@ void PlcCommunicationService::pollNextBlock() | |||
| return; | |||
| } | |||
| pending_reply_ = reply; | |||
| // 把当前连接代次带进回调,防止断线重连后旧回复误更新新连接 | |||
| const std::uint64_t generation = connection_generation_; | |||
| connect(reply, &QModbusReply::finished, | |||
| this, | |||
| [this, reply, block, block_index, generation] | |||
| { | |||
| // 连接已经重建时,直接丢弃旧回复 | |||
| if (generation != connection_generation_) | |||
| { | |||
| reply->deleteLater(); | |||
| @@ -406,6 +561,7 @@ void PlcCommunicationService::pollNextBlock() | |||
| initial_blocks_read_.cbegin(), | |||
| initial_blocks_read_.cend(), | |||
| [](bool read) { return read; }); | |||
| // 所有轮询块都成功读过,才授予真机运行资格 | |||
| if (completed && !initial_read_completed_) | |||
| { | |||
| 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) | |||
| { | |||
| // 只有当前指针仍指向本次回复时才清空,避免误清新请求 | |||
| pending_reply_ = nullptr; | |||
| } | |||
| reply->deleteLater(); | |||
| if (poll_update_pending_) | |||
| { | |||
| const std::vector<RegisterAddress> addresses = pending_poll_addresses_; | |||
| poll_float32_starts_ = pending_poll_float32_starts_; | |||
| applyPollAddresses(addresses); | |||
| } | |||
| if (poll_cycle_completed) | |||
| { | |||
| emit pollCycleCompleted(); | |||
| if (poll_cycle_completed_callback_) | |||
| { | |||
| poll_cycle_completed_callback_(); | |||
| } | |||
| } | |||
| }); | |||
| } | |||
| void PlcCommunicationService::probeRecovery() | |||
| { | |||
| // 只在可恢复的超时故障中探测;串口拔出等故障不会反复探测 | |||
| if (state_ != PlcConnectionState::Faulted | |||
| || !isRecoverableTimeout(last_error_type_)) | |||
| { | |||
| @@ -441,6 +612,7 @@ void PlcCommunicationService::probeRecovery() | |||
| } | |||
| if (pending_reply_ != nullptr) | |||
| { | |||
| // 已有读请求在途,稍后再探测,保证同一时间只有一个读回复 | |||
| recovery_timer_.start(kRecoveryProbeIntervalMs); | |||
| return; | |||
| } | |||
| @@ -448,6 +620,7 @@ void PlcCommunicationService::probeRecovery() | |||
| { | |||
| return; | |||
| } | |||
| // 恢复探测只读取一个地址,确认链路恢复后再进行完整首读 | |||
| const PollBlock block = poll_blocks_.front(); | |||
| const QModbusDataUnit request( | |||
| registerType(block.area), block.startAddress, 1); | |||
| @@ -477,6 +650,7 @@ void PlcCommunicationService::probeRecovery() | |||
| if (poll_update_pending_) | |||
| { | |||
| const std::vector<RegisterAddress> addresses = pending_poll_addresses_; | |||
| poll_float32_starts_ = pending_poll_float32_starts_; | |||
| applyPollAddresses(addresses); | |||
| } | |||
| if (state_ != PlcConnectionState::Faulted | |||
| @@ -514,6 +688,7 @@ void PlcCommunicationService::handleRecoveryProbeFailure(QModbusDevice::Error er | |||
| void PlcCommunicationService::restoreCommunication() | |||
| { | |||
| // 探测成功只说明链路恢复,仍要重新读取全部读块才能恢复真机资格 | |||
| received_valid_response_ = true; | |||
| last_error_type_ = PlcCommunicationError::None; | |||
| last_error_.clear(); | |||
| @@ -534,6 +709,7 @@ void PlcCommunicationService::handleReadFinished(QModbusReply *reply, PollBlock | |||
| handleModbusError(reply->error()); | |||
| return; | |||
| } | |||
| // 只有成功回复才能更新 PLC 缓存;写请求不会直接改缓存 | |||
| const QModbusDataUnit result = reply->result(); | |||
| for (uint index = 0; index < result.valueCount(); ++index) | |||
| { | |||
| @@ -560,6 +736,7 @@ void PlcCommunicationService::handleReadFinished(QModbusReply *reply, PollBlock | |||
| RegisterWriteResult PlcCommunicationService::sendBitWrite( | |||
| const RegisterAddress &address, bool value) | |||
| { | |||
| // 写入只允许在正常 Connected 状态进行,Recovering/Faulted 都拒绝 | |||
| if (state_ != PlcConnectionState::Connected) | |||
| { | |||
| return {false, RegisterError::Unavailable}; | |||
| @@ -568,6 +745,7 @@ RegisterWriteResult PlcCommunicationService::sendBitWrite( | |||
| { | |||
| return {false, RegisterError::WriteRejected}; | |||
| } | |||
| // M 区对应 Modbus Coils,单次只写一个地址 | |||
| QModbusDataUnit unit(QModbusDataUnit::Coils, address.index(), 1); | |||
| unit.setValue(0, value ? 1U : 0U); | |||
| QModbusReply *reply = master_->sendWriteRequest(unit, configuration_.serverAddress); | |||
| @@ -577,6 +755,7 @@ RegisterWriteResult PlcCommunicationService::sendBitWrite( | |||
| return {false, RegisterError::WriteRejected}; | |||
| } | |||
| pending_write_reply_ = reply; | |||
| // 写回复完成后只处理成功或错误,不直接改缓存;后续轮询负责确认真实值 | |||
| connect(reply, &QModbusReply::finished, | |||
| this, | |||
| [this, reply, generation = connection_generation_] | |||
| @@ -602,6 +781,7 @@ RegisterWriteResult PlcCommunicationService::sendBitWrite( | |||
| RegisterWriteResult PlcCommunicationService::sendWordWrite( | |||
| const RegisterAddress &address, std::int16_t value) | |||
| { | |||
| // D 区写入流程与 M 区相同,只是 Modbus 类型不同 | |||
| if (state_ != PlcConnectionState::Connected) | |||
| { | |||
| return {false, RegisterError::Unavailable}; | |||
| @@ -610,6 +790,7 @@ RegisterWriteResult PlcCommunicationService::sendWordWrite( | |||
| { | |||
| return {false, RegisterError::WriteRejected}; | |||
| } | |||
| // D 区对应 Modbus HoldingRegisters,单次只写一个字 | |||
| QModbusDataUnit unit(QModbusDataUnit::HoldingRegisters, address.index(), 1); | |||
| unit.setValue(0, static_cast<quint16>(value)); | |||
| QModbusReply *reply = master_->sendWriteRequest(unit, configuration_.serverAddress); | |||
| @@ -641,6 +822,53 @@ RegisterWriteResult PlcCommunicationService::sendWordWrite( | |||
| 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( | |||
| bool completed, bool force_notification) | |||
| { | |||
| @@ -648,6 +876,7 @@ void PlcCommunicationService::updateInitialReadCompleted( | |||
| { | |||
| return; | |||
| } | |||
| // 只有状态真的变化,或调用方明确要求通知时才发出信号 | |||
| initial_read_completed_ = completed; | |||
| emit initialReadCompletedChanged(completed); | |||
| if (initial_read_changed_callback_) | |||
| @@ -658,6 +887,7 @@ void PlcCommunicationService::updateInitialReadCompleted( | |||
| void PlcCommunicationService::handleUnexpectedDisconnect() | |||
| { | |||
| // 这里表示设备原本连上过,后来串口意外断开 | |||
| serial_session_opened_ = false; | |||
| const QString port_name = QString::fromStdString(configuration_.portName).trimmed(); | |||
| setError({ | |||
| @@ -669,6 +899,7 @@ void PlcCommunicationService::handleUnexpectedDisconnect() | |||
| void PlcCommunicationService::handleModbusError(QModbusDevice::Error error) | |||
| { | |||
| // 主动断开、已处理的故障和旧回复错误都不重复上报 | |||
| if (disconnecting_ | |||
| || (error == QModbusDevice::ReplyAbortedError | |||
| && state_ == PlcConnectionState::Disconnected) | |||
| @@ -687,6 +918,7 @@ void PlcCommunicationService::handleModbusError(QModbusDevice::Error error) | |||
| void PlcCommunicationService::closeSerialSession() | |||
| { | |||
| // 先递增代次并停止定时器,再断开串口;旧异步回调会因此失效 | |||
| ++connection_generation_; | |||
| disconnecting_ = true; | |||
| poll_timer_.stop(); | |||
| @@ -710,6 +942,7 @@ void PlcCommunicationService::setState(PlcConnectionState state) | |||
| { | |||
| return; | |||
| } | |||
| // 状态集中从这里修改,确保 Qt 信号和 std::function 回调同步触发 | |||
| state_ = state; | |||
| emit stateChanged(); | |||
| if (state_changed_callback_) | |||
| @@ -720,10 +953,12 @@ void PlcCommunicationService::setState(PlcConnectionState state) | |||
| void PlcCommunicationService::setError(const PlcCommunicationFailure &failure) | |||
| { | |||
| // 统一保存错误、停止正常轮询、撤销首读资格并通知 UI | |||
| last_error_type_ = failure.type; | |||
| last_error_ = toUtf8(failure.message); | |||
| poll_timer_.stop(); | |||
| recovery_timer_.stop(); | |||
| // 任意通信故障都会撤销首读资格;恢复后必须重新完整读取 | |||
| updateInitialReadCompleted(false); | |||
| const bool disconnected = failure.type == PlcCommunicationError::SerialPortOpenFailed | |||
| || failure.type == PlcCommunicationError::SerialConnectionLost | |||
| @@ -745,3 +980,5 @@ void PlcCommunicationService::setError(const PlcCommunicationFailure &failure) | |||
| recovery_timer_.start(kRecoveryProbeIntervalMs); | |||
| } | |||
| } | |||
| // 按区域和地址排序,方便后面合并连续读块 | |||
| // 相同区域和编号的地址只保留一个 | |||
| @@ -8,6 +8,7 @@ | |||
| #include <QTimer> | |||
| #include <cstdint> | |||
| #include <array> | |||
| #include <memory> | |||
| #include <string> | |||
| #include <vector> | |||
| @@ -17,87 +18,132 @@ class QModbusReply; | |||
| class QModbusRtuSerialMaster; | |||
| struct PlcCommunicationFailure; | |||
| // 基于 Qt Modbus RTU 的异步 PLC 通信实现 | |||
| // 负责连接、轮询、单点写入、故障恢复和首读资格,不阻塞 UI 线程 | |||
| class PlcCommunicationService final : public QObject, public PlcCommunicationGateway | |||
| { | |||
| Q_OBJECT | |||
| public: | |||
| // 创建通信服务,并把 PLC 缓存仓库交给它管理 | |||
| explicit PlcCommunicationService( | |||
| PlcRegisterRepository &repository, | |||
| QObject *parent = nullptr); | |||
| // 释放 Modbus 主站和未完成的通信资源 | |||
| ~PlcCommunicationService() override; | |||
| // 按给定串口参数连接 PLC,并开始异步轮询 | |||
| PlcCommunicationResult connectDevice( | |||
| const PlcSerialConfiguration &configuration) override; | |||
| // 停止轮询并断开 PLC | |||
| void disconnectDevice() override; | |||
| // 设置需要周期性读取的 M/D 地址集合 | |||
| PlcCommunicationResult setPollAddresses( | |||
| const std::vector<RegisterAddress> &addresses) override; | |||
| PlcCommunicationResult setPollAddresses( | |||
| const std::vector<RegisterAddress> &addresses, | |||
| const std::vector<RegisterAddress> &float32_starts) override; | |||
| // 返回当前连接状态 | |||
| PlcConnectionState state() const override; | |||
| // 判断本次连接是否已经完成所有轮询块的首次读取 | |||
| bool initialReadCompleted() const override; | |||
| // 返回最近一次通信错误的类型 | |||
| PlcCommunicationError lastErrorType() const override; | |||
| // 返回最近一次通信错误的文字 | |||
| const std::string &lastError() const override; | |||
| // 返回当前使用的串口和 Modbus 参数 | |||
| const PlcSerialConfiguration &configuration() const; | |||
| // 注册状态、首读、缓存更新和错误通知回调 | |||
| void setCallbacks( | |||
| std::function<void()> state_changed, | |||
| std::function<void(bool)> initial_read_changed, | |||
| std::function<void()> cache_updated, | |||
| std::function<void()> poll_cycle_completed, | |||
| std::function<void(const std::string &)> error_reported) override; | |||
| signals: | |||
| // 连接状态发生变化 | |||
| void stateChanged(); | |||
| // 首次完整读取资格发生变化 | |||
| void initialReadCompletedChanged(bool completed); | |||
| // PLC 缓存收到新的成功读数 | |||
| void cacheUpdated(); | |||
| // 全部轮询块成功更新一轮 | |||
| void pollCycleCompleted(); | |||
| // 通信错误的可读提示文字 | |||
| void communicationError(const QString &message); | |||
| private: | |||
| 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(); | |||
| // 应用新的轮询集合;有请求在途时由 pending_* 延后应用 | |||
| void applyPollAddresses(const std::vector<RegisterAddress> &addresses); | |||
| // 发送下一段异步读取请求 | |||
| void pollNextBlock(); | |||
| // Faulted 状态下用轻量探测判断通信是否恢复 | |||
| void probeRecovery(); | |||
| // 处理恢复探测失败,并安排下一次探测 | |||
| void handleRecoveryProbeFailure(QModbusDevice::Error error); | |||
| // 探测成功后恢复轮询,但仍需重新完成首读 | |||
| void restoreCommunication(); | |||
| // 处理读回复并把值写入 PLC 缓存 | |||
| void handleReadFinished(QModbusReply *reply, PollBlock block); | |||
| // 发送单点 M/D 写请求;缓存等待后续轮询确认 | |||
| RegisterWriteResult sendBitWrite(const RegisterAddress &address, bool value); | |||
| RegisterWriteResult sendWordWrite( | |||
| 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 handleUnexpectedDisconnect(); | |||
| // 把 Qt Modbus 错误转换成项目自己的通信错误 | |||
| void handleModbusError(QModbusDevice::Error error); | |||
| // 关闭串口并清理当前连接的请求和状态 | |||
| void closeSerialSession(); | |||
| // 修改连接状态并通知外部观察者 | |||
| void setState(PlcConnectionState state); | |||
| // 保存错误信息、更新状态并通知外部观察者 | |||
| 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_; | |||
| 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}; | |||
| } | |||
| 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( | |||
| 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); | |||
| word_handler_ = std::move(word_handler); | |||
| word_pair_handler_ = std::move(word_pair_handler); | |||
| } | |||
| void PlcRegisterRepository::updateBit(int address, bool value) | |||
| @@ -5,6 +5,8 @@ | |||
| #include <array> | |||
| #include <functional> | |||
| // 保存 PLC 最近一次成功读回的 M/D 缓存,并把写请求转交给通信服务 | |||
| // 未读到过的地址保持 Unavailable,避免界面显示伪造的初始值 | |||
| class PlcRegisterRepository final : public RegisterRepository | |||
| { | |||
| public: | |||
| @@ -15,22 +17,35 @@ public: | |||
| WordReadResult readWord(const RegisterAddress &address) const override; | |||
| 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 setWriteHandlers( | |||
| 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 updateWord(int address, std::int16_t value); | |||
| // 通信会话失效时清除“已读”标记,但保留数组内的旧值用于诊断 | |||
| void invalidate(); | |||
| // 判断是否至少收到过一个有效 M/D 值 | |||
| bool hasAnyValidValue() const; | |||
| private: | |||
| // M/D 区共用的数组长度,覆盖 0 到最大地址 | |||
| static constexpr std::size_t kRegisterCount = | |||
| 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 <QFile> | |||
| #include <QFileInfo> | |||
| #include <QMessageBox> | |||
| #include <QDir> | |||
| #include "infrastructure/json_project_storage.h" | |||
| #include "infrastructure/application_settings_loader.h" | |||
| #include "infrastructure/plc_communication_service.h" | |||
| #include "infrastructure/plc_discovery_service.h" | |||
| #include "infrastructure/plc_register_repository.h" | |||
| #include "domain/active_register_repository.h" | |||
| #include "domain/virtual_register_repository.h" | |||
| #include "services/hmi_editor_service.h" | |||
| #include "services/hmi_runtime_service.h" | |||
| #include "services/alarm_editor_service.h" | |||
| @@ -19,42 +26,146 @@ | |||
| #include "services/hmi_navigation_service.h" | |||
| #include "services/logic_editor_service.h" | |||
| #include "services/offline_simulation_service.h" | |||
| #include "services/online_logic_monitor_service.h" | |||
| #include "services/project_service.h" | |||
| #include "services/runtime_mode_service.h" | |||
| #include "services/register_monitor_service.h" | |||
| #include "services/register_comment_service.h" | |||
| #include "ui/main_window.h" | |||
| // 程序从这里开始,argc 和 argv 保存用户启动程序时附带的命令行参数 | |||
| int main(int argc, char *argv[]) | |||
| { | |||
| // Windows 默认会给对话框添加上下文帮助按钮,应用内统一隐藏 | |||
| QApplication::setAttribute(Qt::AA_DisableWindowContextHelpButton); | |||
| // 创建整个 Qt 应用,后面的窗口、按钮和消息循环都要依靠它 | |||
| QApplication application(argc, argv); | |||
| // 设置程序名称,Qt 的窗口信息和配置中会用到这个名字 | |||
| application.setApplicationName(QObject::tr("综合平台编程器")); | |||
| // 设置组织名称,用来区分这个程序保存的 Qt 配置 | |||
| 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); | |||
| // 处理梯形图网络和节点的编辑、校验、撤销与重做 | |||
| LogicEditorService logic_editor_service(project_service); | |||
| // 当前离线模式使用内存仓库,后续真机模式替换为 PLC 缓存实现 | |||
| // 在内存中保存离线仿真使用的 M、D 寄存器值,不会写入真实 PLC | |||
| VirtualRegisterRepository virtual_register_repository; | |||
| // 保存编辑态确认的离线初始值,不参与仿真扫描和工程持久化 | |||
| VirtualRegisterRepository offline_initial_repository; | |||
| // 保存从真实 PLC 最近一次读取到的 M、D 值,并转交写入请求 | |||
| PlcRegisterRepository plc_register_repository; | |||
| // 给界面提供统一的寄存器入口,启动时先使用上面的离线寄存器 | |||
| ActiveRegisterRepository active_register_repository(virtual_register_repository); | |||
| // 负责通过串口异步连接、读取和写入真实 PLC | |||
| PlcCommunicationService plc_communication_service(plc_register_repository); | |||
| // 使用独立串口会话自动搜索 PLC,不影响正式通信状态机 | |||
| PlcDiscoveryService plc_discovery_service; | |||
| // 处理运行画面读取和写入寄存器,数据来自当前选中的寄存器入口 | |||
| HmiRuntimeService hmi_runtime_service(active_register_repository); | |||
| // 处理报警规则的添加、修改和删除 | |||
| AlarmEditorService alarm_editor_service(project_service); | |||
| // 根据当前寄存器值判断报警是否触发,并处理报警确认 | |||
| AlarmService alarm_service(project_service, active_register_repository); | |||
| // 管理工程中 M、D 地址对应的文字备注 | |||
| RegisterCommentService register_comment_service(project_service); | |||
| // 处理自由监控列表中的寄存器读取和单点写入 | |||
| 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( | |||
| project_service, offline_simulation_service); | |||
| project_service, offline_simulation_service, online_logic_monitor_service); | |||
| // 把 PLC 通信和两套寄存器接入运行模式,切换模式时才能切换数据来源 | |||
| runtime_mode_service.configurePlc( | |||
| plc_communication_service, | |||
| active_register_repository, | |||
| virtual_register_repository, | |||
| plc_register_repository); | |||
| // 创建主窗口,并把界面操作需要的各项服务交给它使用 | |||
| MainWindow main_window( | |||
| runtime_mode_service, | |||
| project_service, | |||
| @@ -65,8 +176,15 @@ int main(int argc, char *argv[]) | |||
| alarm_service, | |||
| hmi_navigation_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(); | |||
| } | |||
| @@ -37,9 +37,13 @@ const AlarmDefinition *AlarmEditorService::findDefinition( | |||
| AlarmEditorResult AlarmEditorService::addDefinition( | |||
| 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; | |||
| candidate.id = makeUniqueId(); | |||
| @@ -7,6 +7,7 @@ | |||
| class ProjectService; | |||
| // 报警编辑操作的失败分类 | |||
| enum class AlarmEditorError | |||
| { | |||
| None, | |||
| @@ -15,6 +16,7 @@ enum class AlarmEditorError | |||
| InvalidDefinition | |||
| }; | |||
| // 报警编辑结果;成功时 id 是新增或更新后的定义 ID | |||
| struct AlarmEditorResult | |||
| { | |||
| bool succeeded = false; | |||
| @@ -23,16 +25,22 @@ struct AlarmEditorResult | |||
| std::string id; | |||
| }; | |||
| // 负责报警定义的增删改,不负责读取实时寄存器 | |||
| class AlarmEditorService | |||
| { | |||
| public: | |||
| explicit AlarmEditorService(ProjectService &project_service); | |||
| // 返回当前工程中的报警定义 | |||
| const std::vector<AlarmDefinition> &definitions() const; | |||
| // 按稳定 ID 查找报警定义 | |||
| const AlarmDefinition *findDefinition(const std::string &id) const; | |||
| // 添加定义;失败时工程保持不变 | |||
| AlarmEditorResult addDefinition(const AlarmDefinition &definition); | |||
| // 更新指定 ID;definition.id 会被服务统一为传入的 id | |||
| AlarmEditorResult updateDefinition( | |||
| const std::string &id, const AlarmDefinition &definition); | |||
| // 删除指定报警定义 | |||
| AlarmEditorResult removeDefinition(const std::string &id); | |||
| private: | |||
| @@ -16,15 +16,18 @@ AlarmService::AlarmService( | |||
| void AlarmService::refresh() | |||
| { | |||
| // 每次刷新只根据当前读回值增删记录,不会把通信失败误判为报警解除 | |||
| const auto now = std::chrono::system_clock::now(); | |||
| for (const AlarmDefinition &definition | |||
| : project_service_.project().alarmDefinitions) | |||
| { | |||
| // 空值表示寄存器读取失败;保留原记录,等待下一轮成功读回 | |||
| const std::optional<bool> active = evaluate(definition); | |||
| if (!active.has_value()) | |||
| { | |||
| continue; | |||
| } | |||
| // 同一报警定义在当前会话中最多保留一条活动记录 | |||
| const auto current = std::find_if( | |||
| records_.begin(), records_.end(), | |||
| [&definition](const AlarmRecord &record) | |||
| @@ -33,12 +36,14 @@ void AlarmService::refresh() | |||
| }); | |||
| if (*active && current == records_.end()) | |||
| { | |||
| // 首次检测到触发时创建未确认记录,并置于列表头部 | |||
| records_.insert( | |||
| records_.begin(), | |||
| AlarmRecord{definition.id, definition.message, false, now}); | |||
| } | |||
| else if (!*active && current != records_.end()) | |||
| { | |||
| // 条件恢复后立即移除对应记录,下一次再次触发会生成新记录 | |||
| records_.erase(current); | |||
| } | |||
| } | |||
| @@ -75,21 +80,27 @@ std::optional<bool> AlarmService::evaluate( | |||
| { | |||
| if (definition.condition == AlarmCondition::MOn) | |||
| { | |||
| // MOn 报警直接读取 M 位;读取失败返回空值,交由 refresh 保留旧记录 | |||
| const BitReadResult value = repository_.readBit(definition.address); | |||
| return value.succeeded ? std::optional<bool>{value.value} : std::nullopt; | |||
| } | |||
| // DHigh 和 DLow 报警读取 D 字,比较方向由定义中的条件决定 | |||
| const WordReadResult value = repository_.readWord(definition.address); | |||
| if (!value.succeeded) | |||
| { | |||
| // 通信或仓库读取失败不等同于报警解除 | |||
| return std::nullopt; | |||
| } | |||
| if (definition.condition == AlarmCondition::DHigh) | |||
| { | |||
| // 达到阈值即触发高限报警 | |||
| return value.value >= definition.threshold; | |||
| } | |||
| if (definition.condition == AlarmCondition::DLow) | |||
| { | |||
| // 降到阈值即触发低限报警 | |||
| return value.value <= definition.threshold; | |||
| } | |||
| // 防御未来新增但尚未实现的报警条件 | |||
| return std::nullopt; | |||
| } | |||
| @@ -9,30 +9,66 @@ class ProjectService; | |||
| class RegisterRepository; | |||
| struct AlarmDefinition; | |||
| // 运行时产生的报警记录,只存在当前会话,不会写入工程 JSON | |||
| 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 | |||
| { | |||
| public: | |||
| /** | |||
| * @brief 创建报警服务 | |||
| * @param project_service 只读工程服务,提供报警定义集合 | |||
| * @param repository 当前运行模式使用的寄存器仓库 | |||
| */ | |||
| AlarmService( | |||
| const ProjectService &project_service, | |||
| RegisterRepository &repository); | |||
| /** | |||
| * @brief 重新评估全部报警定义 | |||
| * | |||
| * 仍处于触发状态的记录会保留确认状态;读取失败时跳过该定义并保留原记录。 | |||
| * 新触发记录插入列表头部,解除触发的记录立即移除 | |||
| */ | |||
| void refresh(); | |||
| /** | |||
| * @brief 确认当前会话中的指定报警记录 | |||
| * @param definition_id 报警定义的稳定 ID | |||
| * @return 找到当前活动记录并完成确认时返回 true,否则返回 false | |||
| */ | |||
| bool acknowledge(const std::string &definition_id); | |||
| /** | |||
| * @brief 清空当前运行会话的全部报警记录和确认状态 | |||
| */ | |||
| void reset(); | |||
| /** | |||
| * @brief 返回当前会话的报警记录 | |||
| * @return 按触发时间倒序排列的只读记录列表,列表由服务持有 | |||
| */ | |||
| const std::vector<AlarmRecord> &records() const; | |||
| private: | |||
| /** | |||
| * @brief 使用当前寄存器值评估单条报警定义 | |||
| * @param definition 待评估的报警定义 | |||
| * @return true 表示触发,false 表示未触发;读取失败时返回空值 | |||
| * | |||
| * MOn 比较 M 位是否为 1;DHigh 使用大于等于阈值,DLow 使用小于等于阈值 | |||
| */ | |||
| 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 保存有限数量的编辑前快照并提供撤销、重做 | |||
| * | |||
| * 历史只存在当前编辑会话内。新编辑会清空重做栈,超过容量时丢弃最早记录。 | |||
| * State 由具体编辑服务决定,可以是 HMI 页面集合或控制逻辑集合 | |||
| * 历史只存在当前编辑会话内,不写入工程文件 | |||
| * 新编辑会清空重做栈,超过容量时丢弃最早记录 | |||
| */ | |||
| template <typename State> | |||
| class EditorHistory final | |||
| { | |||
| public: | |||
| static constexpr std::size_t kMaximumEntries = 100U; | |||
| // 单个编辑服务最多保留 30 条撤销和重做历史 | |||
| static constexpr std::size_t kMaximumEntries = 30U; | |||
| template <typename Equal> | |||
| /** | |||
| * @brief 记录一次编辑前状态 | |||
| * @param before 编辑前状态 | |||
| * @param after 编辑完成后的当前状态 | |||
| * @param equal 比较前后状态是否相同的可调用对象 | |||
| * | |||
| * 状态没有变化时不创建空历史 | |||
| * 新编辑成功记录后清空重做栈 | |||
| */ | |||
| void record(State before, const State &after, Equal equal) | |||
| { | |||
| if (equal(before, after)) | |||
| @@ -28,6 +40,13 @@ public: | |||
| redo_states_.clear(); | |||
| } | |||
| /** | |||
| * @brief 撤销最近一次编辑 | |||
| * @param current 当前状态 | |||
| * @return 撤销后的目标状态;没有可撤销历史时返回空值 | |||
| * | |||
| * 当前状态会先进入重做栈,之后取出撤销栈顶的编辑前快照 | |||
| */ | |||
| std::optional<State> undo(State current) | |||
| { | |||
| if (undo_states_.empty()) | |||
| @@ -41,6 +60,13 @@ public: | |||
| return target; | |||
| } | |||
| /** | |||
| * @brief 重做最近一次被撤销的编辑 | |||
| * @param current 当前状态 | |||
| * @return 重做后的目标状态;没有可重做历史时返回空值 | |||
| * | |||
| * 当前状态会先放回撤销栈,之后取出重做栈顶的目标状态 | |||
| */ | |||
| std::optional<State> redo(State current) | |||
| { | |||
| if (redo_states_.empty()) | |||
| @@ -54,23 +80,33 @@ public: | |||
| return target; | |||
| } | |||
| /** | |||
| * @brief 清空撤销栈和重做栈 | |||
| */ | |||
| void clear() | |||
| { | |||
| undo_states_.clear(); | |||
| redo_states_.clear(); | |||
| } | |||
| /** | |||
| * @brief 判断是否存在可撤销历史 | |||
| */ | |||
| bool canUndo() const | |||
| { | |||
| return !undo_states_.empty(); | |||
| } | |||
| /** | |||
| * @brief 判断是否存在可重做历史 | |||
| */ | |||
| bool canRedo() const | |||
| { | |||
| return !redo_states_.empty(); | |||
| } | |||
| private: | |||
| // 保持历史不超过上限,超出时从最早的记录开始丢弃 | |||
| static void trim(std::vector<State> *states) | |||
| { | |||
| 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 | |||
| 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( | |||
| const HmiControl &left, const HmiControl &right) | |||
| { | |||
| const bool progress_equal = left.progressBar.has_value() | |||
| == right.progressBar.has_value() | |||
| && (!left.progressBar.has_value() | |||
| || (left.progressBar->minimumValue == right.progressBar->minimumValue | |||
| && left.progressBar->maximumValue == right.progressBar->maximumValue | |||
| && left.progressBar->showValue == right.progressBar->showValue)); | |||
| const bool page_jump_equal = left.pageJump.has_value() == right.pageJump.has_value() | |||
| && (!left.pageJump.has_value() | |||
| || left.pageJump->targetPageId == right.pageJump->targetPageId); | |||
| @@ -127,10 +134,10 @@ bool HmiEditorService::controlsEqual( | |||
| && left.bounds.height == right.bounds.height | |||
| && left.text == right.text | |||
| && left.binding == right.binding | |||
| && left.dataType == right.dataType | |||
| && left.properties == right.properties | |||
| && left.buttonOperation == right.buttonOperation | |||
| && page_jump_equal | |||
| && progress_equal; | |||
| && page_jump_equal; | |||
| } | |||
| HmiEditorResult HmiEditorService::historyFailure(const std::string &message) | |||
| @@ -196,6 +203,8 @@ HmiEditorResult HmiEditorService::ensureDefaultPage() | |||
| HmiPage page; | |||
| page.id = "page-1"; | |||
| page.name = "主操作页面"; | |||
| page.width = hmi_defaults_.pageWidth; | |||
| page.height = hmi_defaults_.pageHeight; | |||
| Project &project = project_service_.editProject(); | |||
| project.hmiPages.push_back(std::move(page)); | |||
| @@ -211,13 +220,17 @@ HmiEditorResult HmiEditorService::addPage(const std::string &name) | |||
| return failure(HmiEditorError::InvalidPage, "HMI 页面名称不能为空"); | |||
| } | |||
| 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) | |||
| { | |||
| return failure(HmiEditorError::InvalidPage, "HMI 页面名称不能超过 4096 个 UTF-8 字节"); | |||
| return failure(HmiEditorError::InvalidPage, "HMI 页面名称不能超过 256 个 UTF-8 字节"); | |||
| } | |||
| const bool duplicate = std::any_of( | |||
| current.hmiPages.cbegin(), current.hmiPages.cend(), | |||
| @@ -230,6 +243,8 @@ HmiEditorResult HmiEditorService::addPage(const std::string &name) | |||
| HmiPage page; | |||
| page.id = makeUniquePageId(current); | |||
| page.name = name; | |||
| page.width = hmi_defaults_.pageWidth; | |||
| page.height = hmi_defaults_.pageHeight; | |||
| HistoryState before = captureState(); | |||
| Project &project = project_service_.editProject(); | |||
| project.hmiPages.push_back(std::move(page)); | |||
| @@ -259,7 +274,7 @@ HmiEditorResult HmiEditorService::resizePage( | |||
| candidate.width = width; | |||
| candidate.height = height; | |||
| std::string error; | |||
| if (!candidate.validate(&error)) | |||
| if (!candidate.validate(project_service_.projectLimits(), &error)) | |||
| { | |||
| return failure(HmiEditorError::InvalidPage, error); | |||
| } | |||
| @@ -284,7 +299,7 @@ HmiEditorResult HmiEditorService::renamePage( | |||
| } | |||
| 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 auto existing = std::find_if( | |||
| @@ -423,9 +438,18 @@ HmiEditorResult HmiEditorService::addControl( | |||
| { | |||
| 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); | |||
| if (descriptor == nullptr) | |||
| @@ -526,6 +550,7 @@ HmiEditorResult HmiEditorService::moveControl( | |||
| HmiControl candidate = *control; | |||
| candidate.bounds = bounds; | |||
| // 更新前保留原控件,只有全部编辑规则通过才覆盖原值 | |||
| // 属性更新是整体替换:类型不变,但绑定、外观和扩展属性必须一起通过校验 | |||
| std::string error; | |||
| if (!validateEditableControl(*page, candidate, &error)) | |||
| { | |||
| @@ -600,6 +625,112 @@ HmiEditorResult HmiEditorService::updateControl( | |||
| 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 | |||
| { | |||
| return history_.canUndo(); | |||
| @@ -693,10 +824,6 @@ HmiControl HmiEditorService::makeControl( | |||
| { | |||
| control.pageJump = HmiPageJumpConfig{}; | |||
| } | |||
| if (descriptor.type == HmiControlType::ProgressBar) | |||
| { | |||
| control.progressBar = HmiProgressBarConfig{}; | |||
| } | |||
| const int offset = static_cast<int>(page.controls.size()) * 16; | |||
| control.bounds.x = std::min(20 + offset, page.width - control.bounds.width); | |||
| control.bounds.y = std::min(20 + offset, page.height - control.bounds.height); | |||
| @@ -21,17 +21,17 @@ struct HmiControlDescriptor; | |||
| */ | |||
| 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 | |||
| { | |||
| 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 | |||
| { | |||
| public: | |||
| explicit HmiEditorService(ProjectService &project_service); | |||
| /** | |||
| * @brief 创建 HMI 编辑服务 | |||
| * @param project_service 用于读取和原子修改当前工程的项目服务 | |||
| */ | |||
| explicit HmiEditorService( | |||
| ProjectService &project_service, | |||
| const HmiDefaultSettings &hmi_defaults = defaultHmiSettings()); | |||
| /** | |||
| * @brief 按页面标识查找只读页面 | |||
| @@ -82,13 +88,52 @@ public: | |||
| * @return 已有或新建页面的成功结果及其页面标识 | |||
| */ | |||
| HmiEditorResult ensureDefaultPage(); | |||
| /** | |||
| * @brief 添加一个 HMI 页面 | |||
| * @param name 页面显示名称,不能为空且必须在工程内唯一 | |||
| * @return 成功时返回新页面 ID | |||
| */ | |||
| HmiEditorResult addPage(const std::string &name); | |||
| /** | |||
| * @brief 调整页面尺寸 | |||
| * @param page_id 目标页面唯一标识 | |||
| * @param width 页面宽度,必须满足页面边界且不能使已有控件越界 | |||
| * @param height 页面高度,必须满足页面边界且不能使已有控件越界 | |||
| * @return 页面不存在或新尺寸无效时返回失败结果 | |||
| */ | |||
| HmiEditorResult resizePage( | |||
| const std::string &page_id, int width, int height); | |||
| /** | |||
| * @brief 修改页面显示名称 | |||
| * @param page_id 目标页面唯一标识 | |||
| * @param name 新页面名称,不能为空且必须在工程内唯一 | |||
| * @return 页面不存在或名称无效/重复时返回失败结果 | |||
| */ | |||
| HmiEditorResult renamePage( | |||
| const std::string &page_id, const std::string &name); | |||
| /** | |||
| * @brief 删除一个 HMI 页面 | |||
| * @param page_id 待删除页面唯一标识 | |||
| * @return 页面不存在、页面是唯一页面、仍为初始页面或仍被引用时返回失败结果 | |||
| */ | |||
| 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); | |||
| /** | |||
| * @brief 设置工程的初始 HMI 页面 | |||
| * @param page_id 要作为启动页面的页面唯一标识 | |||
| * @return 页面不存在时返回 PageNotFound | |||
| */ | |||
| HmiEditorResult setInitialPage(const std::string &page_id); | |||
| /** | |||
| * @brief 向指定页面添加待配置的基础控件 | |||
| @@ -108,6 +153,13 @@ public: | |||
| */ | |||
| HmiEditorResult removeControl( | |||
| const std::string &page_id, const std::string &control_id); | |||
| /** | |||
| * @brief 一次删除同一页面中的多个控件 | |||
| * @param page_id 所属页面唯一标识 | |||
| * @param control_ids 待删除控件 ID 列表,不能为空且不能包含重复 ID | |||
| * @return 页面、控件不存在或 ID 列表无效时返回失败结果 | |||
| */ | |||
| HmiEditorResult removeControls( | |||
| const std::string &page_id, | |||
| const std::vector<std::string> &control_ids); | |||
| @@ -136,26 +188,65 @@ public: | |||
| const std::string &control_id, | |||
| 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; | |||
| /** @brief 判断是否存在可重做的 HMI 编辑操作 */ | |||
| bool canRedo() const; | |||
| /** | |||
| * @brief 撤销最近一次成功的编辑操作 | |||
| * @return 没有可撤销操作时返回 InvalidOperation | |||
| */ | |||
| HmiEditorResult undo(); | |||
| /** | |||
| * @brief 重做最近一次被撤销的编辑操作 | |||
| * @return 没有可重做操作时返回 InvalidOperation | |||
| */ | |||
| HmiEditorResult redo(); | |||
| /** | |||
| * @brief 清空撤销和重做历史 | |||
| * | |||
| * 不修改当前工程内容 | |||
| */ | |||
| void clearHistory(); | |||
| private: | |||
| // 撤销/重做需要保存的完整 HMI 编辑状态 | |||
| struct HistoryState | |||
| { | |||
| std::vector<HmiPage> pages; | |||
| std::string initial_page_id; | |||
| std::vector<HmiPage> pages; // 页面及其控件的深拷贝 | |||
| std::string initial_page_id; // 工程初始页面 ID | |||
| }; | |||
| // 捕获当前工程状态,作为一次编辑操作的前后快照 | |||
| HistoryState captureState() const; | |||
| // 比较编辑前后状态并记录实际发生的修改 | |||
| void recordHistory(HistoryState before); | |||
| // 比较两个历史快照是否完全相同 | |||
| static bool statesEqual( | |||
| const HistoryState &left, const HistoryState &right); | |||
| // 比较两个 HMI 控件的全部可保存属性 | |||
| static bool controlsEqual( | |||
| const HmiControl &left, const HmiControl &right); | |||
| // 比较两个 HMI 页面及其控件集合 | |||
| static bool pagesEqual(const HmiPage &left, const HmiPage &right); | |||
| // 生成统一的历史操作失败结果 | |||
| static HmiEditorResult historyFailure(const std::string &message); | |||
| /** | |||
| @@ -183,7 +274,7 @@ private: | |||
| /** | |||
| * @brief 按控件类型创建带默认属性的新控件 | |||
| * @param page 新控件所属页面,用于确定初始位置和唯一标识 | |||
| * @param type 新控件类型 | |||
| * @param descriptor 新控件类型对应的注册描述 | |||
| * @return 未绑定寄存器的默认控件配置 | |||
| */ | |||
| static HmiControl makeControl( | |||
| @@ -201,5 +292,6 @@ private: | |||
| * @brief 非拥有的工程服务依赖,由应用入口保证生命周期 | |||
| */ | |||
| ProjectService &project_service_; | |||
| const HmiDefaultSettings &hmi_defaults_; | |||
| EditorHistory<HistoryState> history_; | |||
| }; | |||
| @@ -11,6 +11,7 @@ HmiNavigationService::HmiNavigationService(ProjectService &project_service) | |||
| HmiNavigationResult HmiNavigationService::start() | |||
| { | |||
| // 运行开始始终从工程声明的初始页面进入,而不是沿用上次会话页面 | |||
| const Project &project = project_service_.project(); | |||
| if (project.hmiPages.empty()) | |||
| { | |||
| @@ -4,6 +4,7 @@ | |||
| class ProjectService; | |||
| // HMI 页面跳转失败分类 | |||
| enum class HmiNavigationError | |||
| { | |||
| None, | |||
| @@ -11,6 +12,7 @@ enum class HmiNavigationError | |||
| PageNotFound | |||
| }; | |||
| // 页面跳转结果;成功时 pageId 是当前页 ID | |||
| struct HmiNavigationResult | |||
| { | |||
| bool succeeded = false; | |||
| @@ -19,14 +21,19 @@ struct HmiNavigationResult | |||
| std::string pageId; | |||
| }; | |||
| // 管理运行态当前页面,不直接操作 Qt 页面控件 | |||
| class HmiNavigationService | |||
| { | |||
| public: | |||
| explicit HmiNavigationService(ProjectService &project_service); | |||
| // 进入工程初始页面 | |||
| HmiNavigationResult start(); | |||
| // 跳转到指定页面;目标不存在时保留原页面 | |||
| HmiNavigationResult navigateTo(const std::string &page_id); | |||
| // 结束运行会话并清空当前页 | |||
| void stop(); | |||
| // 返回当前运行页面 ID,未启动时为空 | |||
| const std::string ¤tPageId() const; | |||
| private: | |||
| @@ -2,11 +2,14 @@ | |||
| #include "domain/hmi_control_registry.h" | |||
| #include <cmath> | |||
| #include <limits> | |||
| namespace { | |||
| HmiRuntimeReadResult readFailure(HmiRuntimeError error) | |||
| { | |||
| return {false, error, false, 0}; | |||
| return {false, error, false, 0, 0.0f}; | |||
| } | |||
| HmiRuntimeWriteResult writeFailure(HmiRuntimeError error) | |||
| @@ -55,16 +58,30 @@ HmiRuntimeReadResult HmiRuntimeService::readControl(const HmiControl &control) c | |||
| { | |||
| return readFailure(repositoryError(result.error)); | |||
| } | |||
| return {true, HmiRuntimeError::None, result.value, 0}; | |||
| return {true, HmiRuntimeError::None, result.value, 0, 0.0f}; | |||
| } | |||
| 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); | |||
| if (!result.succeeded) | |||
| { | |||
| return readFailure(repositoryError(result.error)); | |||
| } | |||
| return {true, HmiRuntimeError::None, false, result.value}; | |||
| return {true, HmiRuntimeError::None, false, result.value, 0.0f}; | |||
| } | |||
| case HmiRuntimeValueKind::None: | |||
| default: | |||
| @@ -140,7 +157,7 @@ HmiRuntimeWriteResult HmiRuntimeService::operateButton( | |||
| } | |||
| HmiRuntimeWriteResult HmiRuntimeService::writeNumericInput( | |||
| const HmiControl &control, std::int16_t value) | |||
| const HmiControl &control, double value) | |||
| { | |||
| if (control.type != HmiControlType::NumericInput) | |||
| { | |||
| @@ -150,11 +167,40 @@ HmiRuntimeWriteResult HmiRuntimeService::writeNumericInput( | |||
| { | |||
| return writeFailure(HmiRuntimeError::MissingBinding); | |||
| } | |||
| if (!control.binding->isValid()) | |||
| if (!control.binding->isValid() || control.binding->area() != RegisterArea::D) | |||
| { | |||
| 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 | |||
| ? HmiRuntimeWriteResult{true, HmiRuntimeError::None} | |||
| : writeFailure(repositoryError(result.error)); | |||
| @@ -18,10 +18,10 @@ | |||
| enum class HmiRuntimeError | |||
| { | |||
| None, // 无错误 | |||
| UnsupportedControl, // 控件类型不支持寄存器读写 | |||
| UnsupportedControl, // 控件类型不支持寄存器读写或写入操作 | |||
| MissingBinding, // 控件未配置寄存器绑定 | |||
| InvalidBinding, // 控件绑定地址无效或地址区域不匹配 | |||
| RepositoryFailure // 寄存器仓库读取或写入失败 | |||
| RepositoryFailure // 寄存器仓库不可用或拒绝读写 | |||
| }; | |||
| /** | |||
| @@ -31,10 +31,11 @@ enum class HmiRuntimeError | |||
| */ | |||
| 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 | |||
| { | |||
| bool succeeded = false; | |||
| HmiRuntimeError error = HmiRuntimeError::None; | |||
| bool succeeded = false; // 写入请求是否被仓库接受 | |||
| HmiRuntimeError error = HmiRuntimeError::None; // 失败时的 HMI 错误分类 | |||
| }; | |||
| /** | |||
| @@ -51,8 +52,8 @@ struct HmiRuntimeWriteResult | |||
| */ | |||
| enum class HmiButtonEvent | |||
| { | |||
| Pressed, | |||
| Released | |||
| Pressed, // 按钮按下事件 | |||
| Released // 按钮释放事件 | |||
| }; | |||
| /** | |||
| @@ -60,9 +61,14 @@ enum class HmiButtonEvent | |||
| * | |||
| * 服务不依赖虚拟寄存器实现、串口或 Modbus,可由运行模式注入不同仓库 | |||
| */ | |||
| // 仅通过统一寄存器仓库执行 HMI 运行态读写 | |||
| class HmiRuntimeService | |||
| { | |||
| public: | |||
| /** | |||
| * @brief 创建 HMI 运行态读写服务 | |||
| * @param repository 当前运行模式提供的寄存器仓库,不由本服务拥有 | |||
| */ | |||
| explicit HmiRuntimeService(RegisterRepository &repository); | |||
| /** | |||
| @@ -78,6 +84,9 @@ public: | |||
| * @param control 按钮控件,必须绑定有效 M 地址 | |||
| * @param event 当前输入事件 | |||
| * @return 无需写入的事件返回成功,配置无效或仓库拒绝写入时返回失败 | |||
| * | |||
| * SetOn、SetOff 和 Toggle 只在按下时写入;MomentaryOn 在按下时写 1、 | |||
| * 释放时写 0。写入结果以仓库返回值为准,不直接假定缓存已经更新 | |||
| */ | |||
| HmiRuntimeWriteResult operateButton( | |||
| const HmiControl &control, HmiButtonEvent event); | |||
| @@ -86,14 +95,29 @@ public: | |||
| * @param control 数值输入控件,必须绑定有效 D 地址 | |||
| * @param value 要写入的 D 字值 | |||
| * @return 非数值输入、绑定无效或仓库拒绝写入时返回失败结果 | |||
| * | |||
| * 该服务只负责提交写入请求,实际读回值由后续仓库轮询确认 | |||
| */ | |||
| 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; | |||
| /** | |||
| * @brief 读取指定 D 字 | |||
| * @param address 必须是有效的 D 区地址 | |||
| * @return 仓库原始读取结果,不转换为 HMI 错误分类 | |||
| */ | |||
| WordReadResult readWord(const RegisterAddress &address) const; | |||
| private: | |||
| // 将寄存器仓库错误统一转换为 HMI 运行态错误 | |||
| 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 <string> | |||
| #include <utility> | |||
| #include <vector> | |||
| class ProjectService; | |||
| // 梯形图编辑失败分类 | |||
| 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 | |||
| { | |||
| 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 | |||
| { | |||
| public: | |||
| /** | |||
| * @brief 创建梯形图编辑服务 | |||
| * @param project_service 用于读取和修改当前工程的项目服务 | |||
| */ | |||
| explicit LogicEditorService(ProjectService &project_service); | |||
| // 以下查询接口只读工程模型,供编辑器投影和属性面板使用 | |||
| /** @brief 按 ID 查找控制逻辑,未找到时返回空指针 */ | |||
| const ControlLogic *findLogic(const std::string &logic_id) const; | |||
| /** @brief 在指定逻辑中按 ID 查找网络,未找到时返回空指针 */ | |||
| const LadderRung *findRung( | |||
| const std::string &logic_id, const std::string &rung_id) const; | |||
| /** @brief 在指定逻辑的所有网络中按 ID 查找节点,未找到时返回空指针 */ | |||
| const LogicNode *findNode( | |||
| const std::string &logic_id, const std::string &node_id) const; | |||
| /** @brief 在指定网络中按 ID 查找条件表达式,未找到时返回空指针 */ | |||
| const ConditionExpression *findExpression( | |||
| const std::string &logic_id, | |||
| const std::string &rung_id, | |||
| const std::string &expression_id) const; | |||
| /** @brief 返回工程中第一个控制逻辑 ID,没有逻辑时返回空字符串 */ | |||
| std::string firstLogicId() const; | |||
| /** @brief 返回指定逻辑中第一个网络 ID,没有网络或逻辑不存在时返回空字符串 */ | |||
| std::string firstRungId(const std::string &logic_id) const; | |||
| /** @brief 查找节点所属网络 ID,未找到时返回空字符串 */ | |||
| std::string rungIdForNode( | |||
| const std::string &logic_id, const std::string &node_id) const; | |||
| /** @brief 返回指定 M/D 地址的工程注释,没有注释时返回空字符串 */ | |||
| std::string registerCommentFor(const RegisterAddress &address) const; | |||
| /** | |||
| * @brief 确保工程至少有一组可编辑逻辑 | |||
| * @return 已有或新建逻辑的成功结果及其 ID | |||
| * | |||
| * 新建逻辑时允许暂时没有网络,第一次实际编辑网络内容时再创建网络 | |||
| */ | |||
| LogicEditorResult ensureDefaultLogic(); | |||
| /** @brief 添加控制逻辑;名称不能为空且必须唯一 */ | |||
| LogicEditorResult addLogic(const std::string &name); | |||
| /** @brief 修改控制逻辑名称;名称不能为空且必须唯一 */ | |||
| LogicEditorResult renameLogic( | |||
| const std::string &logic_id, const std::string &name); | |||
| /** @brief 删除控制逻辑;工程至少保留一组逻辑 */ | |||
| LogicEditorResult removeLogic(const std::string &logic_id); | |||
| /** @brief 将逻辑在工程列表中上移或下移一位,offset 只能为 -1 或 1 */ | |||
| LogicEditorResult moveLogic(const std::string &logic_id, int offset); | |||
| /** @brief 启用或停用指定控制逻辑 */ | |||
| LogicEditorResult setLogicEnabled(const std::string &logic_id, bool enabled); | |||
| /** @brief 在指定逻辑末尾添加空网络 */ | |||
| LogicEditorResult addRung(const std::string &logic_id); | |||
| /** @brief 删除指定网络 */ | |||
| LogicEditorResult removeRung( | |||
| 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( | |||
| const std::string &logic_id, | |||
| const std::string &rung_id, | |||
| const std::string &comment); | |||
| // 条件区编辑:串联、按列插入、横线和并联分支 | |||
| /** @brief 在网络条件末尾追加一个条件节点 */ | |||
| LogicEditorResult appendCondition( | |||
| const std::string &logic_id, | |||
| const std::string &rung_id, | |||
| const LogicNodeConfig &config); | |||
| const LogicNodeConfig &config, | |||
| bool configured = false); | |||
| /** | |||
| * @brief 按绝对条件列插入条件节点 | |||
| * @param column 从 0 开始的条件列号;插入位置必须位于允许的条件区 | |||
| */ | |||
| LogicEditorResult insertConditionAtColumn( | |||
| const std::string &logic_id, | |||
| const std::string &rung_id, | |||
| 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( | |||
| const std::string &logic_id, | |||
| const std::string &rung_id, | |||
| int column_span = 1); | |||
| /** @brief 在目标节点后串联插入条件节点 */ | |||
| LogicEditorResult insertConditionAfter( | |||
| const std::string &logic_id, | |||
| const std::string &rung_id, | |||
| const std::string &target_node_id, | |||
| const LogicNodeConfig &config); | |||
| /** @brief 在目标表达式后串联插入横线 */ | |||
| LogicEditorResult insertWireAfter( | |||
| const std::string &logic_id, | |||
| const std::string &rung_id, | |||
| const std::string &target_expression_id, | |||
| int column_span = 1); | |||
| /** @brief 用条件节点整体替换一条横线表达式 */ | |||
| LogicEditorResult replaceWireWithCondition( | |||
| const std::string &logic_id, | |||
| const std::string &rung_id, | |||
| const std::string &wire_expression_id, | |||
| const LogicNodeConfig &config); | |||
| /** @brief 只替换横线表达式中的一个指定列单元格 */ | |||
| LogicEditorResult replaceWireColumnWithCondition( | |||
| const std::string &logic_id, | |||
| const std::string &rung_id, | |||
| const std::string &wire_expression_id, | |||
| 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( | |||
| const std::string &logic_id, | |||
| const std::string &rung_id, | |||
| 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( | |||
| const std::string &logic_id, | |||
| const std::string &rung_id, | |||
| 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( | |||
| const std::string &logic_id, | |||
| const std::string &rung_id, | |||
| const LogicNodeConfig &config, | |||
| bool configured = false); | |||
| // 节点属性和删除操作 | |||
| /** @brief 更新节点配置;条件节点和输出节点不能互相改型 */ | |||
| LogicEditorResult updateNodeConfig( | |||
| const std::string &logic_id, | |||
| const std::string &node_id, | |||
| 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( | |||
| const std::string &logic_id, const std::string &node_id); | |||
| /** | |||
| * @brief 批量删除节点 | |||
| * @param node_ids 节点 ID 列表,不能为空且不能包含重复或不存在的 ID | |||
| * @return 成功时作为一次编辑记录,失败时整体回滚 | |||
| */ | |||
| LogicEditorResult removeNodes( | |||
| const std::string &logic_id, | |||
| const std::vector<std::string> &node_ids); | |||
| /** @brief 删除指定条件表达式并归一化表达式树 */ | |||
| LogicEditorResult removeExpression( | |||
| const std::string &logic_id, | |||
| const std::string &rung_id, | |||
| const std::string &expression_id); | |||
| /** @brief 批量删除条件表达式,失败时整体回滚 */ | |||
| LogicEditorResult removeExpressions( | |||
| const std::string &logic_id, | |||
| const std::string &rung_id, | |||
| 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; | |||
| /** @brief 判断是否存在可重做的梯形图编辑操作 */ | |||
| bool canRedo() const; | |||
| /** @brief 撤销最近一次成功的梯形图编辑操作 */ | |||
| LogicEditorResult undo(); | |||
| /** @brief 重做最近一次被撤销的梯形图编辑操作 */ | |||
| LogicEditorResult redo(); | |||
| /** @brief 清空撤销/重做历史,不修改当前工程内容 */ | |||
| void clearHistory(); | |||
| private: | |||
| // 只保存逻辑集合快照,当前选择等 UI 会话状态不进入历史 | |||
| struct HistoryState | |||
| { | |||
| std::vector<ControlLogic> logics; | |||
| std::vector<ControlLogic> logics; // 编辑前或编辑后的完整逻辑集合 | |||
| }; | |||
| // 捕获当前工程中的全部控制逻辑 | |||
| HistoryState captureState() const; | |||
| // 比较编辑前后状态并记录实际发生的修改 | |||
| void recordHistory(HistoryState before); | |||
| // 失败时恢复工程内容和操作前脏标记,保证编辑原子性 | |||
| void rollbackEdit(HistoryState before, bool modified_before); | |||
| // 比较两个历史快照是否完全相同 | |||
| static bool statesEqual( | |||
| const HistoryState &left, const HistoryState &right); | |||
| static bool logicsEqual( | |||
| @@ -158,17 +335,31 @@ private: | |||
| const LogicNodeConfig &left, const LogicNodeConfig &right); | |||
| static LogicEditorResult historyFailure(const std::string &message); | |||
| // 判断节点配置是否属于条件节点 | |||
| static bool isConditionConfig(const LogicNodeConfig &config); | |||
| // 判断节点配置是否属于输出节点 | |||
| static bool isOutputConfig(const LogicNodeConfig &config); | |||
| // 返回节点配置对应的稳定 ID 前缀 | |||
| static std::string nodePrefix(const LogicNodeConfig &config); | |||
| // 在一个控制逻辑的全部网络中生成唯一节点 ID | |||
| static std::string makeUniqueNodeId( | |||
| const ControlLogic &logic, const std::string &prefix); | |||
| // 生成唯一横线表达式 ID | |||
| static std::string makeUniqueWireId(const ControlLogic &logic); | |||
| // 生成控制逻辑内唯一的断路表达式 ID | |||
| static std::string makeUniqueGapId(const ControlLogic &logic); | |||
| // 生成唯一容器表达式 ID | |||
| static std::string makeUniqueExpressionId(const ControlLogic &logic); | |||
| // 生成唯一网络 ID | |||
| static std::string makeUniqueRungId(const ControlLogic &logic); | |||
| // 把并联补线和输出前连接线转换为显式 Wire,并保持输出网络十列布局 | |||
| static bool repairExplicitLayout( | |||
| ControlLogic &logic, LadderRung &rung, std::string *error = nullptr); | |||
| // 创建统一的失败结果 | |||
| static LogicEditorResult failure( | |||
| 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( | |||
| VirtualRegisterRepository &repository, | |||
| VirtualRegisterRepository *initial_repository, | |||
| QObject *parent) | |||
| : QObject(parent), | |||
| repository_(repository) | |||
| repository_(repository), | |||
| initial_repository_(initial_repository) | |||
| { | |||
| timer_.setInterval(kDefaultScanIntervalMs); | |||
| timer_.setTimerType(Qt::PreciseTimer); | |||
| @@ -22,6 +24,7 @@ SimulationStartResult OfflineSimulationService::start( | |||
| return {false, SimulationStartError::AlreadyRunning, {}}; | |||
| } | |||
| // 仿真期间使用启动时快照,编辑器后续修改不会改变正在扫描的逻辑 | |||
| std::vector<ControlLogic> snapshot = logics; | |||
| const LogicScanResult validation = executor_.validate(snapshot); | |||
| if (!validation.succeeded) | |||
| @@ -30,9 +33,14 @@ SimulationStartResult OfflineSimulationService::start( | |||
| return {false, SimulationStartError::InvalidLogic, validation}; | |||
| } | |||
| // 重启会话时清空沿触发状态和运行值,再装载编辑态确认的初始值 | |||
| timer_.stop(); | |||
| executor_.resetRuntime(); | |||
| repository_.clear(); | |||
| if (initial_repository_ != nullptr) | |||
| { | |||
| repository_.copyFrom(*initial_repository_); | |||
| } | |||
| logic_snapshot_ = std::move(snapshot); | |||
| successful_scan_count_ = 0; | |||
| last_error_ = {true, LogicScanError::None, {}, {}, {}, {}}; | |||
| @@ -43,6 +51,15 @@ SimulationStartResult OfflineSimulationService::start( | |||
| return {true, SimulationStartError::None, {true, LogicScanError::None, {}, {}, {}, {}}}; | |||
| } | |||
| void OfflineSimulationService::restoreInitialValues() | |||
| { | |||
| repository_.clear(); | |||
| if (initial_repository_ != nullptr) | |||
| { | |||
| repository_.copyFrom(*initial_repository_); | |||
| } | |||
| } | |||
| void OfflineSimulationService::stop() | |||
| { | |||
| timer_.stop(); | |||
| @@ -52,6 +69,8 @@ void OfflineSimulationService::stop() | |||
| { | |||
| return; | |||
| } | |||
| // 运行结束后丢弃本轮输出,编辑态继续看到下一轮要使用的初始值 | |||
| restoreInitialValues(); | |||
| state_ = SimulationState::Stopped; | |||
| last_error_ = {true, LogicScanError::None, {}, {}, {}, {}}; | |||
| emit stateChanged(); | |||
| @@ -68,6 +87,7 @@ LogicScanResult OfflineSimulationService::executeOnce() | |||
| {}, | |||
| {}}; | |||
| } | |||
| // 执行器负责一轮完整扫描;任何读写错误都会让服务进入 Faulted | |||
| const LogicScanResult result = executor_.executeScan( | |||
| logic_snapshot_, repository_, &trace_snapshot_); | |||
| if (!result.succeeded) | |||
| @@ -112,6 +132,7 @@ void OfflineSimulationService::handleTimeout() | |||
| void OfflineSimulationService::enterFault(const LogicScanResult &error) | |||
| { | |||
| // 故障后停止定时器和运行时状态,保留错误及最后轨迹供 UI 诊断 | |||
| timer_.stop(); | |||
| executor_.resetRuntime(); | |||
| last_error_ = error; | |||
| @@ -1,5 +1,6 @@ | |||
| #pragma once | |||
| #include "domain/virtual_register_repository.h" | |||
| #include "software_logic_executor.h" | |||
| #include <QObject> | |||
| @@ -8,25 +9,28 @@ | |||
| #include <cstdint> | |||
| #include <vector> | |||
| // 离线扫描器的生命周期状态 | |||
| enum class SimulationState | |||
| { | |||
| Stopped, | |||
| Running, | |||
| Faulted | |||
| Stopped, // 未运行或已主动停止 | |||
| Running, // 定时器正在驱动离线扫描 | |||
| Faulted // 最近一轮扫描失败,定时器已停止 | |||
| }; | |||
| // 启动离线仿真失败的原因 | |||
| enum class SimulationStartError | |||
| { | |||
| None, | |||
| AlreadyRunning, | |||
| InvalidLogic | |||
| None, // 启动成功 | |||
| AlreadyRunning, // 当前已经处于运行态 | |||
| InvalidLogic // 启动前逻辑校验失败 | |||
| }; | |||
| // 离线仿真启动结果;detail 保存领域执行器的具体校验错误 | |||
| 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 | |||
| public: | |||
| // 默认离线扫描周期,单位为毫秒 | |||
| static constexpr int kDefaultScanIntervalMs = 50; | |||
| /** | |||
| * @brief 创建离线仿真服务 | |||
| * @param repository 离线模式使用的虚拟 M/D 寄存器仓库 | |||
| * @param initial_repository 编辑态确认的离线初始值仓库,可为空以保持旧的全清零行为 | |||
| * @param parent Qt 对象父级 | |||
| */ | |||
| explicit OfflineSimulationService( | |||
| VirtualRegisterRepository &repository, | |||
| VirtualRegisterRepository *initial_repository = nullptr, | |||
| QObject *parent = nullptr); | |||
| // 复制工程逻辑作为本次会话快照并启动定时扫描;启动失败时不进入运行态 | |||
| SimulationStartResult start(const std::vector<ControlLogic> &logics); | |||
| // 停止扫描并清除运行快照,但保留最近一次轨迹供 UI 显示 | |||
| void stop(); | |||
| // 立即执行一轮扫描;非 Running 状态调用会返回失败结果 | |||
| LogicScanResult executeOnce(); | |||
| // 将当前会话的离线初始值恢复到运行仓库 | |||
| void restoreInitialValues(); | |||
| // 返回当前离线仿真生命周期状态 | |||
| SimulationState state() const; | |||
| // 返回当前定时器扫描周期,单位为毫秒 | |||
| int scanIntervalMs() const; | |||
| // 返回本次会话成功完成的扫描轮数 | |||
| std::uint64_t successfulScanCount() const; | |||
| // 返回最近一次扫描错误;无错误时为成功结果 | |||
| const LogicScanResult &lastError() const; | |||
| // 返回最近一轮按逻辑 ID 隔离的运行轨迹 | |||
| const LogicTraceSnapshot &traceSnapshot() const; | |||
| signals: | |||
| // 仿真状态从 Stopped、Running 或 Faulted 之一切换时发出 | |||
| void stateChanged(); | |||
| // 一轮扫描成功完成并更新轨迹后发出 | |||
| void scanCompleted(); | |||
| private: | |||
| // 定时器到期时触发一轮扫描 | |||
| void handleTimeout(); | |||
| // 保存错误、停止定时器并切换到 Faulted | |||
| 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 <vector> | |||
| // PLC 串口和轮询参数;parity 使用 Qt 约定值:0 无、2 偶、3 奇 | |||
| 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 | |||
| { | |||
| Disconnected, | |||
| Connecting, | |||
| Connected, | |||
| Recovering, | |||
| Faulted | |||
| Disconnected, // 未建立 PLC 连接 | |||
| Connecting, // 正在异步建立 PLC 连接 | |||
| Connected, // 串口已连接并进行正常轮询,不代表首读已完成 | |||
| Recovering, // 从可恢复通信故障中探测恢复并重新进行首读 | |||
| Faulted // 当前通信发生故障,等待恢复探测或重新连接 | |||
| }; | |||
| // 通信故障分类,用于状态栏提示和真机运行资格撤销 | |||
| 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 | |||
| { | |||
| 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( | |||
| const PlcSerialConfiguration &configuration) | |||
| { | |||
| @@ -108,23 +119,81 @@ inline PlcCommunicationResult validatePlcSerialConfiguration( | |||
| return {true, {}}; | |||
| } | |||
| // UI/运行服务使用的异步 PLC 通信契约,具体实现位于 infrastructure | |||
| class PlcCommunicationGateway | |||
| { | |||
| public: | |||
| // 允许通过网关基类指针安全释放具体通信实现 | |||
| virtual ~PlcCommunicationGateway() = default; | |||
| /** | |||
| * @brief 校验串口配置并启动异步 PLC 连接 | |||
| * @param configuration 本次连接使用的串口、Modbus 和轮询参数 | |||
| * @return true 仅表示连接请求已受理,实际连接结果通过状态回调通知 | |||
| */ | |||
| virtual PlcCommunicationResult connectDevice( | |||
| const PlcSerialConfiguration &configuration) = 0; | |||
| /** | |||
| * @brief 停止轮询并断开当前 PLC 连接 | |||
| * | |||
| * 断开后当前缓存视为无效,首读资格同时清除 | |||
| */ | |||
| virtual void disconnectDevice() = 0; | |||
| /** | |||
| * @brief 设置后续轮询的 M/D 地址集合 | |||
| * | |||
| * 实现可以校验、排序、去重并合并相邻地址块;已有读请求进行时, | |||
| * 新集合可以延后到当前请求完成后生效 | |||
| * @param addresses 需要周期性读回的 M/D 地址集合,空集合使用实现的默认探测地址 | |||
| * @return true 表示集合已接受,false 表示地址或轮询资源校验失败 | |||
| */ | |||
| virtual PlcCommunicationResult setPollAddresses( | |||
| 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 bool initialReadCompleted() const = 0; | |||
| // 返回最近一次通信错误的分类;没有错误或错误已清除时返回 None | |||
| virtual PlcCommunicationError lastErrorType() const = 0; | |||
| // 返回最近一次通信错误的 UTF-8 可读文本;没有错误时返回空字符串 | |||
| 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( | |||
| std::function<void()> state_changed, | |||
| std::function<void(bool)> initial_read_changed, | |||
| std::function<void()> cache_updated, | |||
| std::function<void()> poll_cycle_completed, | |||
| 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 | |||
| ProjectService::ProjectService(ProjectStorage &storage) | |||
| : ProjectService(storage, defaultProjectLimitSettings()) | |||
| { | |||
| } | |||
| ProjectService::ProjectService( | |||
| ProjectStorage &storage, | |||
| const ProjectLimitSettings &project_limits) | |||
| : storage_(storage), | |||
| project_limits_(project_limits), | |||
| project_(makeNewProject("未命名工程")) | |||
| { | |||
| } | |||
| @@ -57,6 +65,11 @@ bool ProjectService::isModified() const | |||
| return modified_; | |||
| } | |||
| const ProjectLimitSettings &ProjectService::projectLimits() const | |||
| { | |||
| return project_limits_; | |||
| } | |||
| void ProjectService::restoreModifiedState(bool modified) | |||
| { | |||
| modified_ = modified; | |||
| @@ -103,7 +116,7 @@ ProjectOperationResult ProjectService::saveAs(const std::string &file_path) | |||
| } | |||
| std::string validation_error; | |||
| if (!project_.validate(&validation_error)) | |||
| if (!project_.validate(project_limits_, &validation_error)) | |||
| { | |||
| return {false, | |||
| ProjectServiceError::InvalidProject, | |||
| @@ -124,6 +137,31 @@ ProjectOperationResult ProjectService::saveAs(const std::string &file_path) | |||
| 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) | |||
| { | |||
| if (isBlank(file_path)) | |||
| @@ -134,6 +172,7 @@ ProjectOperationResult ProjectService::load(const std::string &file_path) | |||
| "必须指定工程文件路径"}; | |||
| } | |||
| // 存储层先在临时结果中解析,避免半个工程替换当前工程 | |||
| ProjectLoadResult result = storage_.load(file_path); | |||
| if (!result.succeeded) | |||
| { | |||
| @@ -141,7 +180,7 @@ ProjectOperationResult ProjectService::load(const std::string &file_path) | |||
| } | |||
| std::string validation_error; | |||
| if (!result.project.validate(&validation_error)) | |||
| if (!result.project.validate(project_limits_, &validation_error)) | |||
| { | |||
| return {false, | |||
| ProjectServiceError::InvalidProject, | |||
| @@ -14,7 +14,7 @@ enum class ProjectServiceError | |||
| StorageFailure // 工程文件存储操作失败 | |||
| }; | |||
| // 工程管理操作结果 | |||
| // 工程管理操作结果;失败时当前工程和文件路径保持原状 | |||
| struct ProjectOperationResult | |||
| { | |||
| bool succeeded = false; // 操作是否成功 | |||
| @@ -29,12 +29,18 @@ class ProjectService | |||
| public: | |||
| // 使用外部提供的工程存储实现创建服务 | |||
| explicit ProjectService(ProjectStorage &storage); | |||
| ProjectService( | |||
| ProjectStorage &storage, | |||
| const ProjectLimitSettings &project_limits); | |||
| // 只读访问当前工程;UI 和其他服务不应绕过 editProject 修改它 | |||
| const Project &project() const; | |||
| // 获取服务层授权的可编辑引用,调用方必须随后完成校验 | |||
| Project &editProject(); | |||
| const std::string ¤tFilePath() const; | |||
| bool hasCurrentFile() const; | |||
| bool isModified() const; | |||
| const ProjectLimitSettings &projectLimits() const; | |||
| // 失败编辑回滚时恢复进入编辑前的脏状态 | |||
| void restoreModifiedState(bool modified); | |||
| @@ -57,6 +63,8 @@ public: | |||
| * @return 保存操作结果 | |||
| */ | |||
| ProjectOperationResult saveAs(const std::string &file_path); | |||
| /** 将当前工程导出到指定 JSON,不改变当前工程文件关联 */ | |||
| ProjectOperationResult exportAs(const std::string &file_path) const; | |||
| /** | |||
| * @brief 从指定文件加载工程 | |||
| @@ -72,6 +80,7 @@ private: | |||
| ProjectStorageError error, const std::string &message); | |||
| ProjectStorage &storage_; // 非拥有的工程存储依赖 | |||
| const ProjectLimitSettings &project_limits_; // 应用启动时确定的只读数量上限 | |||
| Project project_; // 当前编辑中的工程 | |||
| std::string current_file_path_; // 当前工程对应的文件路径 | |||
| bool modified_ = true; // 当前工程是否有未保存修改 | |||
| @@ -7,6 +7,7 @@ | |||
| class ProjectService; | |||
| // 工程级寄存器注释操作的失败分类 | |||
| enum class RegisterCommentError | |||
| { | |||
| None, | |||
| @@ -14,6 +15,7 @@ enum class RegisterCommentError | |||
| NotFound | |||
| }; | |||
| // 注释编辑结果 | |||
| struct RegisterCommentResult | |||
| { | |||
| bool succeeded = false; | |||
| @@ -21,15 +23,19 @@ struct RegisterCommentResult | |||
| std::string message; | |||
| }; | |||
| // 维护工程里的 M/D 注释,不读取也不修改寄存器实时值 | |||
| class RegisterCommentService | |||
| { | |||
| public: | |||
| explicit RegisterCommentService(ProjectService &project_service); | |||
| const std::vector<RegisterComment> &comments() const; | |||
| // 按地址查找注释 | |||
| const RegisterComment *findComment(const RegisterAddress &address) const; | |||
| // 新建或更新注释;空文本通常表示删除该注释 | |||
| RegisterCommentResult setComment( | |||
| const RegisterAddress &address, const std::string &text); | |||
| // 删除指定地址注释 | |||
| RegisterCommentResult removeComment(const RegisterAddress &address); | |||
| private: | |||
| @@ -1,6 +1,10 @@ | |||
| #include "register_monitor_service.h" | |||
| #include "domain/virtual_register_repository.h" | |||
| #include <algorithm> | |||
| #include <cmath> | |||
| #include <optional> | |||
| #include <utility> | |||
| 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( | |||
| 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); | |||
| if (!parsed.succeeded) | |||
| @@ -38,8 +59,15 @@ RegisterMonitorResult RegisterMonitorService::addRange( | |||
| return {false, RegisterMonitorError::InvalidAddress, | |||
| 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 | |||
| || parsed.address.index() > RegisterAddress::kMaximumIndex - (count - 1)) | |||
| || parsed.address.index() > RegisterAddress::kMaximumIndex - step * count + 1) | |||
| { | |||
| return {false, RegisterMonitorError::RangeOverflow, | |||
| "连续监控范围不能超过地址 4000", 0}; | |||
| @@ -49,10 +77,13 @@ RegisterMonitorResult RegisterMonitorService::addRange( | |||
| pending.reserve(static_cast<std::size_t>(count)); | |||
| 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()) | |||
| @@ -66,13 +97,32 @@ RegisterMonitorResult RegisterMonitorService::addRange( | |||
| } | |||
| for (const RegisterAddress &address : pending) | |||
| { | |||
| model_.add(address); | |||
| model_.add(MonitorPoint{address, data_type}); | |||
| } | |||
| if (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( | |||
| @@ -97,6 +147,28 @@ RegisterMonitorResult RegisterMonitorService::remove( | |||
| 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() | |||
| { | |||
| if (model_.addresses().empty()) | |||
| @@ -116,34 +188,90 @@ RegisterMonitorWriteResult RegisterMonitorService::writeBit( | |||
| const RegisterAddress &address, bool value) | |||
| { | |||
| const RegisterWriteResult result = repository_.writeBit(address, value); | |||
| if (result.succeeded) | |||
| { | |||
| return {true, RegisterError::None, {}}; | |||
| } | |||
| return {false, result.error, result.error == RegisterError::AreaMismatch | |||
| const RegisterMonitorWriteResult active_result = result.succeeded | |||
| ? RegisterMonitorWriteResult{true, RegisterError::None, {}} | |||
| : RegisterMonitorWriteResult{false, result.error, result.error == RegisterError::AreaMismatch | |||
| ? "位写入只允许访问 M 区" | |||
| : result.error == RegisterError::InvalidAddress | |||
| ? "写入地址无效" | |||
| : result.error == RegisterError::Unavailable | |||
| ? "当前寄存器源不可用" | |||
| : "寄存器写入请求被拒绝"}; | |||
| return captureInitialBit(address, value, active_result); | |||
| } | |||
| RegisterMonitorWriteResult RegisterMonitorService::writeWord( | |||
| const RegisterAddress &address, std::int16_t value) | |||
| { | |||
| const RegisterWriteResult result = repository_.writeWord(address, value); | |||
| if (result.succeeded) | |||
| { | |||
| return {true, RegisterError::None, {}}; | |||
| } | |||
| return {false, result.error, result.error == RegisterError::AreaMismatch | |||
| const RegisterMonitorWriteResult active_result = result.succeeded | |||
| ? RegisterMonitorWriteResult{true, RegisterError::None, {}} | |||
| : RegisterMonitorWriteResult{false, result.error, result.error == RegisterError::AreaMismatch | |||
| ? "字写入只允许访问 D 区" | |||
| : result.error == RegisterError::InvalidAddress | |||
| ? "写入地址无效" | |||
| : 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 | |||
| @@ -154,11 +282,15 @@ const std::vector<RegisterAddress> &RegisterMonitorService::addresses() const | |||
| std::vector<MonitorValue> RegisterMonitorService::values(bool communication_fault) const | |||
| { | |||
| 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; | |||
| value.address = address; | |||
| value.dataType = point.dataType; | |||
| value.endAddress = address.index() | |||
| + registerDataTypeWordCount(point.dataType) - 1; | |||
| if (address.area() == RegisterArea::M) | |||
| { | |||
| const BitReadResult read = repository_.readBit(address); | |||
| @@ -170,18 +302,65 @@ std::vector<MonitorValue> RegisterMonitorService::values(bool communication_faul | |||
| } | |||
| 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 | |||
| : MonitorValueState::Valid) | |||
| : MonitorValueState::Unavailable; | |||
| } | |||
| } | |||
| result.push_back(value); | |||
| } | |||
| 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) | |||
| { | |||
| addresses_changed_callback_ = std::move(callback); | |||
| @@ -8,47 +8,126 @@ | |||
| #include <string> | |||
| #include <vector> | |||
| class VirtualRegisterRepository; | |||
| // 自由监控列表编辑的失败分类 | |||
| enum class RegisterMonitorError | |||
| { | |||
| None, | |||
| InvalidAddress, | |||
| RangeOverflow, | |||
| LimitExceeded, | |||
| NoChange | |||
| None, // 操作成功 | |||
| InvalidAddress, // 起始地址格式或区域不支持 | |||
| RangeOverflow, // 连续范围超出 M/D 地址上限 | |||
| LimitExceeded, // 监控地址数量超过会话上限 | |||
| NoChange // 操作没有产生任何列表变化 | |||
| }; | |||
| // 监控列表编辑结果;affectedCount 表示本次受影响的地址数量 | |||
| 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 | |||
| { | |||
| 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 | |||
| { | |||
| public: | |||
| /** | |||
| * @brief 创建自由监控服务 | |||
| * @param 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, RegisterDataType data_type); | |||
| /** | |||
| * @brief 删除一批监控地址 | |||
| * @param addresses 待删除地址;不存在的地址会被忽略 | |||
| * @return 实际删除数量,并在发生变化时通知轮询集合更新 | |||
| */ | |||
| RegisterMonitorResult remove(const std::vector<RegisterAddress> &addresses); | |||
| RegisterMonitorResult remove(const std::vector<MonitorPoint> &points); | |||
| /** | |||
| * @brief 清空当前会话的监控地址列表 | |||
| * @return 实际清除数量;列表本来为空时返回 NoChange | |||
| */ | |||
| RegisterMonitorResult clear(); | |||
| /** | |||
| * @brief 对单个 M 地址提交位写入 | |||
| * @param address 必须是有效的 M 区地址 | |||
| * @param value 要写入的位值 | |||
| * @return 成功或包含底层 RegisterError 和用户可读文本的失败结果 | |||
| */ | |||
| RegisterMonitorWriteResult writeBit(const RegisterAddress &address, bool value); | |||
| /** | |||
| * @brief 对单个 D 地址提交有符号 16 位字写入 | |||
| * @param address 必须是有效的 D 区地址 | |||
| * @param value 要写入的 D 字值 | |||
| * @return 成功或包含底层 RegisterError 和用户可读文本的失败结果 | |||
| */ | |||
| RegisterMonitorWriteResult writeWord( | |||
| 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<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; | |||
| /** | |||
| * @brief 设置监控地址变化通知回调 | |||
| * @param callback 地址列表新增、删除或清空后调用;可传空函数取消通知 | |||
| */ | |||
| void setAddressesChangedCallback(std::function<void()> callback); | |||
| 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( | |||
| const ProjectService &project_service, | |||
| OfflineSimulationService &offline_simulation_service) | |||
| OfflineSimulationService &offline_simulation_service, | |||
| OnlineLogicMonitorService &online_logic_monitor_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) | |||
| { | |||
| plc_gateway_->setCallbacks({}, {}, {}, {}); | |||
| plc_gateway_->setCallbacks({}, {}, {}, {}, {}); | |||
| } | |||
| } | |||
| @@ -47,6 +49,10 @@ ModeTransitionResult RuntimeModeService::enterEditing() | |||
| // 先停止扫描再开放编辑,防止运行快照继续写寄存器 | |||
| offline_simulation_service_.stop(); | |||
| } | |||
| else if (state_.mode() == ApplicationMode::OnlineRunning) | |||
| { | |||
| online_logic_monitor_service_.stop(); | |||
| } | |||
| const ModeTransitionResult result = state_.enterEditing(); | |||
| if (result.succeeded && active_repository_ != nullptr | |||
| && virtual_repository_ != nullptr) | |||
| @@ -63,14 +69,17 @@ ModeTransitionResult RuntimeModeService::enterOfflineRunning() | |||
| return state_.enterOfflineRunning(); | |||
| } | |||
| std::string error; | |||
| if (!project_service_.project().validateForRunning(&error)) | |||
| if (!project_service_.project().validateForRunning( | |||
| project_service_.projectLimits(), &error)) | |||
| { | |||
| return {false, ModeTransitionError::ProjectNotReady}; | |||
| } | |||
| // 离线仿真必须先切到虚拟仓库,避免扫描结果写入 PLC 缓存 | |||
| if (active_repository_ != nullptr && virtual_repository_ != nullptr) | |||
| { | |||
| active_repository_->use(*virtual_repository_); | |||
| } | |||
| // start() 会复制逻辑快照并做运行前校验;失败时不进入运行态 | |||
| const SimulationStartResult start_result = offline_simulation_service_.start( | |||
| project_service_.project().controlLogics); | |||
| if (!start_result.succeeded) | |||
| @@ -87,12 +96,30 @@ ModeTransitionResult RuntimeModeService::enterOfflineRunning() | |||
| 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) | |||
| { | |||
| active_repository_->use(*plc_repository_); | |||
| } | |||
| else if (!result.succeeded) | |||
| { | |||
| online_logic_monitor_service_.stop(); | |||
| } | |||
| return result; | |||
| } | |||
| @@ -130,6 +157,11 @@ OfflineSimulationService &RuntimeModeService::offlineSimulationService() | |||
| return offline_simulation_service_; | |||
| } | |||
| OnlineLogicMonitorService &RuntimeModeService::onlineLogicMonitorService() | |||
| { | |||
| return online_logic_monitor_service_; | |||
| } | |||
| void RuntimeModeService::configurePlc( | |||
| PlcCommunicationGateway &gateway, | |||
| ActiveRegisterRepository &active_repository, | |||
| @@ -140,6 +172,7 @@ void RuntimeModeService::configurePlc( | |||
| active_repository_ = &active_repository; | |||
| virtual_repository_ = &virtual_repository; | |||
| plc_repository_ = &plc_repository; | |||
| // 通信故障撤销首读资格;若正在真机运行,立即回到编辑态 | |||
| gateway.setCallbacks( | |||
| [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 &) | |||
| { | |||
| if (plc_status_changed_callback_) | |||
| @@ -201,8 +253,16 @@ PlcCommunicationResult RuntimeModeService::connectPlc( | |||
| void RuntimeModeService::setMonitorAddresses( | |||
| 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_float32_starts_ = float32_starts; | |||
| refreshPlcPollAddresses(); | |||
| } | |||
| @@ -212,7 +272,9 @@ PlcCommunicationResult RuntimeModeService::refreshPlcPollAddresses() | |||
| { | |||
| return {false, "PLC 通信服务尚未配置"}; | |||
| } | |||
| // 轮询集合来自自由监控、HMI、报警和梯形图引用的并集 | |||
| std::vector<RegisterAddress> addresses = monitor_addresses_; | |||
| std::vector<RegisterAddress> float32_starts = monitor_float32_starts_; | |||
| const Project &project = project_service_.project(); | |||
| for (const HmiPage &page : project.hmiPages) | |||
| { | |||
| @@ -221,6 +283,15 @@ PlcCommunicationResult RuntimeModeService::refreshPlcPollAddresses() | |||
| if (control.binding.has_value()) | |||
| { | |||
| 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(); | |||
| }); | |||
| // 排序后去重,保证同一个地址只占一份轮询配额 | |||
| addresses.erase(std::unique(addresses.begin(), addresses.end()), addresses.end()); | |||
| 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() | |||
| @@ -10,6 +10,7 @@ | |||
| #include "domain/runtime_state.h" | |||
| #include "offline_simulation_service.h" | |||
| #include "online_logic_monitor_service.h" | |||
| #include "plc_communication_gateway.h" | |||
| #include <cstdint> | |||
| @@ -20,15 +21,26 @@ class ProjectService; | |||
| class ActiveRegisterRepository; | |||
| class RegisterRepository; | |||
| // 隔离 UI 与领域状态机并编排进入真机运行态的前置条件 | |||
| // 隔离 UI 与领域状态机,并编排寄存器仓库切换和进入真机的前置条件 | |||
| class RuntimeModeService | |||
| { | |||
| public: | |||
| /** | |||
| * @brief 创建运行模式服务 | |||
| * @param project_service 只读工程服务,用于运行前校验和收集轮询地址 | |||
| * @param offline_simulation_service 离线仿真服务,由应用层负责其生命周期 | |||
| */ | |||
| RuntimeModeService( | |||
| const ProjectService &project_service, | |||
| OfflineSimulationService &offline_simulation_service); | |||
| OfflineSimulationService &offline_simulation_service, | |||
| OnlineLogicMonitorService &online_logic_monitor_service); | |||
| /** | |||
| * @brief 解除 PLC 网关回调绑定 | |||
| */ | |||
| ~RuntimeModeService(); | |||
| /** @brief 获取当前应用运行模式 */ | |||
| ApplicationMode mode() const; | |||
| /** | |||
| * @brief 获取当前模式下允许的工程编辑和寄存器使用策略 | |||
| @@ -65,33 +77,77 @@ public: | |||
| */ | |||
| bool initialPlcReadCompleted() const; | |||
| /** @brief 获取离线仿真当前状态 */ | |||
| SimulationState simulationState() const; | |||
| /** @brief 获取离线仿真成功完成的扫描轮数 */ | |||
| std::uint64_t successfulScanCount() const; | |||
| /** @brief 获取离线仿真最近一次扫描错误 */ | |||
| const LogicScanResult &simulationError() const; | |||
| /** @brief 返回服务持有的离线仿真服务引用 */ | |||
| OfflineSimulationService &offlineSimulationService(); | |||
| OnlineLogicMonitorService &onlineLogicMonitorService(); | |||
| /** | |||
| * @brief 注入 PLC 网关、活动仓库和两种实际数据源 | |||
| * | |||
| * 应用生命周期内只调用一次;真机模式使用 PLC 仓库,编辑/离线模式使用虚拟仓库 | |||
| */ | |||
| void configurePlc( | |||
| PlcCommunicationGateway &gateway, | |||
| ActiveRegisterRepository &active_repository, | |||
| RegisterRepository &virtual_repository, | |||
| RegisterRepository &plc_repository); | |||
| /** | |||
| * @brief 连接 PLC 并开始异步通信 | |||
| * @param configuration 串口、Modbus 和轮询配置 | |||
| * @return 连接请求受理结果,不阻塞 UI 等待首读完成 | |||
| */ | |||
| 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, | |||
| const std::vector<RegisterAddress> &float32_starts); | |||
| /** | |||
| * @brief 汇总工程和自由监控引用并刷新 PLC 轮询地址 | |||
| * @return 网关未配置或地址数量超限时返回失败结果 | |||
| */ | |||
| PlcCommunicationResult refreshPlcPollAddresses(); | |||
| /** | |||
| * @brief 退出真机运行、断开 PLC 并清除首读资格 | |||
| */ | |||
| void disconnectPlc(); | |||
| /** @brief 获取当前 PLC 连接状态;网关未配置时返回 Disconnected */ | |||
| PlcConnectionState plcConnectionState() const; | |||
| /** @brief 获取 PLC 最近一次通信错误文本;网关未配置时返回空字符串 */ | |||
| const std::string &plcError() const; | |||
| /** | |||
| * @brief 设置 PLC 状态变化通知回调 | |||
| * @param callback 状态、首读资格或通信错误变化时调用的函数;可传空函数取消通知 | |||
| */ | |||
| void setPlcStatusChangedCallback(std::function<void()> callback); | |||
| 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; | |||
| 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(); | |||
| expressionPowerValues.clear(); | |||
| rungValues.clear(); | |||
| tonValues.clear(); | |||
| counterValues.clear(); | |||
| wordValues.clear(); | |||
| } | |||
| @@ -103,8 +101,6 @@ LogicTraceSnapshot LogicTraceSnapshot::forLogic( | |||
| projection.expressionInputValues = values->second.expressionInputValues; | |||
| projection.expressionPowerValues = values->second.expressionPowerValues; | |||
| projection.rungValues = values->second.rungValues; | |||
| projection.tonValues = values->second.tonValues; | |||
| projection.counterValues = values->second.counterValues; | |||
| projection.wordValues = values->second.wordValues; | |||
| } | |||
| return projection; | |||
| @@ -113,6 +109,7 @@ LogicTraceSnapshot LogicTraceSnapshot::forLogic( | |||
| LogicScanResult SoftwareLogicExecutor::validate( | |||
| const std::vector<ControlLogic> &logics) const | |||
| { | |||
| // 先检查每个逻辑自身,再检查跨网络共享线圈的冲突 | |||
| std::map<int, CoilMode> output_modes; | |||
| for (const ControlLogic &logic : logics) | |||
| { | |||
| @@ -144,9 +141,7 @@ LogicScanResult SoftwareLogicExecutor::validate( | |||
| const auto *output = std::get_if<CoilNodeConfig>(&rung.output->config); | |||
| if (output == nullptr) | |||
| { | |||
| if (std::holds_alternative<TonNodeConfig>(rung.output->config) | |||
| || std::holds_alternative<CounterNodeConfig>(rung.output->config) | |||
| || std::holds_alternative<MoveNodeConfig>(rung.output->config) | |||
| if (std::holds_alternative<MoveNodeConfig>(rung.output->config) | |||
| || std::holds_alternative<ArithmeticNodeConfig>( | |||
| rung.output->config)) | |||
| { | |||
| @@ -173,35 +168,18 @@ LogicScanResult SoftwareLogicExecutor::validate( | |||
| output_modes[output->address.index()] = output->mode; | |||
| } | |||
| } | |||
| std::string resource_error; | |||
| if (!validateLogicResourceReferencesForRunning(logics, &resource_error)) | |||
| { | |||
| return failure(LogicScanError::InvalidLogic, resource_error); | |||
| } | |||
| return success(); | |||
| } | |||
| void SoftwareLogicExecutor::resetRuntime() | |||
| { | |||
| previous_edge_inputs_.clear(); | |||
| previous_counter_inputs_.clear(); | |||
| ton_states_.clear(); | |||
| counter_states_.clear(); | |||
| } | |||
| LogicScanResult SoftwareLogicExecutor::executeScan( | |||
| const std::vector<ControlLogic> &logics, | |||
| RegisterRepository &repository, | |||
| LogicTraceSnapshot *trace) | |||
| { | |||
| return executeScanAt(logics, repository, Clock::now(), trace); | |||
| } | |||
| LogicScanResult SoftwareLogicExecutor::executeScanAt( | |||
| const std::vector<ControlLogic> &logics, | |||
| RegisterRepository &repository, | |||
| TimePoint now, | |||
| LogicTraceSnapshot *trace) | |||
| { | |||
| const LogicScanResult validation = validate(logics); | |||
| if (!validation.succeeded) | |||
| @@ -209,10 +187,12 @@ LogicScanResult SoftwareLogicExecutor::executeScanAt( | |||
| return validation; | |||
| } | |||
| // 每轮从空轨迹开始,防止已删除或未执行的节点残留旧状态 | |||
| if (trace != nullptr) | |||
| { | |||
| trace->clear(); | |||
| } | |||
| // 按工程顺序扫描;前面网络写入的 M/D 值对后面网络立即可见 | |||
| for (const ControlLogic &logic : logics) | |||
| { | |||
| if (!logic.enabled) | |||
| @@ -228,7 +208,7 @@ LogicScanResult SoftwareLogicExecutor::executeScanAt( | |||
| continue; | |||
| } | |||
| // 没有条件节点的网络等价于左侧电源线直接接通,作为恒真条件执行 | |||
| // 输出网络的显式 Wire 会计算为真;空网络只作为编辑草稿跳过 | |||
| bool rung_value = true; | |||
| LogicScanResult result = success(); | |||
| if (rung.condition.has_value()) | |||
| @@ -255,11 +235,9 @@ LogicScanResult SoftwareLogicExecutor::executeScanAt( | |||
| } | |||
| bool output_value = false; | |||
| result = executeOutput( | |||
| logic.id, | |||
| *rung.output, | |||
| rung_value, | |||
| repository, | |||
| now, | |||
| logic_trace, | |||
| &output_value); | |||
| if (!result.succeeded) | |||
| @@ -291,8 +269,6 @@ LogicScanResult SoftwareLogicExecutor::executeScanAt( | |||
| trace->expressionInputValues = values->second.expressionInputValues; | |||
| trace->expressionPowerValues = values->second.expressionPowerValues; | |||
| trace->rungValues = values->second.rungValues; | |||
| trace->tonValues = values->second.tonValues; | |||
| trace->counterValues = values->second.counterValues; | |||
| trace->wordValues = values->second.wordValues; | |||
| } | |||
| } | |||
| @@ -326,6 +302,15 @@ LogicScanResult SoftwareLogicExecutor::evaluateExpression( | |||
| } | |||
| return success(); | |||
| } | |||
| if (expression.kind == ConditionExpressionKind::Gap) | |||
| { | |||
| return failure( | |||
| LogicScanError::InvalidLogic, | |||
| "梯形图条件区存在未连接的空白网格", | |||
| logic_id, | |||
| {}, | |||
| expression.id); | |||
| } | |||
| if (expression.kind == ConditionExpressionKind::Node) | |||
| { | |||
| LogicScanResult result = evaluateCondition( | |||
| @@ -380,6 +365,8 @@ LogicScanResult SoftwareLogicExecutor::evaluateCondition( | |||
| RegisterRepository &repository, | |||
| bool *value) | |||
| { | |||
| // 根据条件节点的具体配置读取 M/D 状态,并计算触点结果 | |||
| // 边沿触点使用 logic_id 和 node.id 组成运行时状态键,避免同名节点互相影响 | |||
| if (value == nullptr) | |||
| { | |||
| return failure( | |||
| @@ -397,6 +384,7 @@ LogicScanResult SoftwareLogicExecutor::evaluateCondition( | |||
| using Config = std::decay_t<decltype(config)>; | |||
| if constexpr (std::is_same_v<Config, ContactNodeConfig>) | |||
| { | |||
| // 普通触点直接读取 M 位;常闭触点使用读回值的反值 | |||
| const BitReadResult read = repository.readBit(config.address); | |||
| if (!read.succeeded) | |||
| { | |||
| @@ -413,6 +401,7 @@ LogicScanResult SoftwareLogicExecutor::evaluateCondition( | |||
| } | |||
| else if constexpr (std::is_same_v<Config, EdgeContactNodeConfig>) | |||
| { | |||
| // 边沿触点需要比较本轮与上一轮的 M 位值,只在目标边沿出现时导通 | |||
| const BitReadResult read = repository.readBit(config.address); | |||
| if (!read.succeeded) | |||
| { | |||
| @@ -430,20 +419,9 @@ LogicScanResult SoftwareLogicExecutor::evaluateCondition( | |||
| previous_edge_inputs_[runtime_key] = read.value; | |||
| return success(); | |||
| } | |||
| else if constexpr (std::is_same_v<Config, TimerContactNodeConfig>) | |||
| { | |||
| const bool done = ton_states_[config.address.index()].done; | |||
| *value = config.mode == ContactMode::NormallyOpen ? done : !done; | |||
| return success(); | |||
| } | |||
| else if constexpr (std::is_same_v<Config, CounterContactNodeConfig>) | |||
| { | |||
| const bool done = counter_states_[config.address.index()].done; | |||
| *value = config.mode == ContactMode::NormallyOpen ? done : !done; | |||
| return success(); | |||
| } | |||
| else if constexpr (std::is_same_v<Config, CompareNodeConfig>) | |||
| { | |||
| // 字比较触点读取 D 字,再按配置的比较运算符与目标值比较 | |||
| const WordReadResult read = repository.readWord(config.address); | |||
| if (!read.succeeded) | |||
| { | |||
| @@ -459,6 +437,7 @@ LogicScanResult SoftwareLogicExecutor::evaluateCondition( | |||
| } | |||
| else | |||
| { | |||
| // 输出节点只能由 executeOutput 处理,不能作为条件触点计算 | |||
| return failure( | |||
| LogicScanError::InvalidLogic, | |||
| "条件节点不能包含输出节点", | |||
| @@ -514,11 +493,9 @@ LogicScanResult SoftwareLogicExecutor::readWordOperand( | |||
| } | |||
| LogicScanResult SoftwareLogicExecutor::executeOutput( | |||
| const std::string &logic_id, | |||
| const LogicNode &node, | |||
| bool rung_value, | |||
| RegisterRepository &repository, | |||
| TimePoint now, | |||
| LogicTraceValues *trace, | |||
| bool *output_value) | |||
| { | |||
| @@ -531,136 +508,6 @@ LogicScanResult SoftwareLogicExecutor::executeOutput( | |||
| {}, | |||
| node.id); | |||
| } | |||
| if (const auto *ton = std::get_if<TonNodeConfig>(&node.config)) | |||
| { | |||
| TonRuntimeState &state = ton_states_[ton->address.index()]; | |||
| if (!rung_value) | |||
| { | |||
| state = TonRuntimeState{}; | |||
| } | |||
| else if (!state.timing) | |||
| { | |||
| state.timing = true; | |||
| state.startedAt = now; | |||
| state.elapsed = std::chrono::milliseconds{0}; | |||
| state.done = false; | |||
| } | |||
| else | |||
| { | |||
| state.elapsed = std::chrono::duration_cast<std::chrono::milliseconds>( | |||
| now >= state.startedAt ? now - state.startedAt : TimePoint::duration::zero()); | |||
| state.done = state.elapsed.count() >= ton->presetMs; | |||
| } | |||
| *output_value = state.done; | |||
| if (trace != nullptr) | |||
| { | |||
| trace->tonValues[node.id] = { | |||
| rung_value, | |||
| state.done, | |||
| state.elapsed.count(), | |||
| ton->presetMs}; | |||
| } | |||
| return success(); | |||
| } | |||
| if (const auto *counter = std::get_if<CounterNodeConfig>(&node.config)) | |||
| { | |||
| const BitReadResult reset_read = repository.readBit(counter->resetAddress); | |||
| if (!reset_read.succeeded) | |||
| { | |||
| return failure( | |||
| LogicScanError::RegisterReadFailed, | |||
| "读取计数器复位输入失败:" + counter->resetAddress.toString(), | |||
| {}, | |||
| {}, | |||
| node.id); | |||
| } | |||
| std::int16_t preset = 0; | |||
| LogicScanResult result = readWordOperand( | |||
| counter->preset, repository, &preset, node.id); | |||
| if (!result.succeeded) | |||
| { | |||
| return result; | |||
| } | |||
| const std::int32_t bounded_preset = std::max<std::int32_t>(0, preset); | |||
| preset = static_cast<std::int16_t>( | |||
| std::min<std::int32_t>(bounded_preset, std::numeric_limits<std::int16_t>::max())); | |||
| const WordReadResult current_read = repository.readWord( | |||
| counter->currentValueAddress); | |||
| if (!current_read.succeeded) | |||
| { | |||
| return failure( | |||
| LogicScanError::RegisterReadFailed, | |||
| "读取计数当前值失败:" + counter->currentValueAddress.toString(), | |||
| {}, | |||
| {}, | |||
| node.id); | |||
| } | |||
| std::int32_t current = std::max<std::int32_t>(0, current_read.value); | |||
| current = std::min<std::int32_t>(current, std::numeric_limits<std::int16_t>::max()); | |||
| const auto runtime_key = std::make_pair(logic_id, node.id); | |||
| const bool previous = previous_counter_inputs_[runtime_key]; | |||
| const bool rising = rung_value && !previous; | |||
| previous_counter_inputs_[runtime_key] = rung_value; | |||
| bool done = counter_states_[counter->address.index()].done; | |||
| if (reset_read.value) | |||
| { | |||
| current = counter->mode == CounterMode::Up ? 0 : preset; | |||
| done = counter->mode == CounterMode::Down && current == 0; | |||
| } | |||
| else if (rising) | |||
| { | |||
| if (counter->mode == CounterMode::Up) | |||
| { | |||
| if (current < preset) | |||
| { | |||
| ++current; | |||
| } | |||
| done = current >= preset; | |||
| } | |||
| else | |||
| { | |||
| if (current > 0) | |||
| { | |||
| --current; | |||
| } | |||
| done = current <= 0; | |||
| } | |||
| } | |||
| if (counter->mode == CounterMode::Up && current >= preset) | |||
| { | |||
| done = true; | |||
| } | |||
| if (counter->mode == CounterMode::Down && current <= 0) | |||
| { | |||
| done = true; | |||
| } | |||
| const std::int16_t current_value = static_cast<std::int16_t>(current); | |||
| const RegisterWriteResult write = repository.writeWord( | |||
| counter->currentValueAddress, current_value); | |||
| if (!write.succeeded) | |||
| { | |||
| return failure( | |||
| LogicScanError::RegisterWriteFailed, | |||
| "写入计数当前值失败:" + counter->currentValueAddress.toString(), | |||
| {}, | |||
| {}, | |||
| node.id); | |||
| } | |||
| counter_states_[counter->address.index()].done = done; | |||
| *output_value = done; | |||
| if (trace != nullptr) | |||
| { | |||
| trace->counterValues[node.id] = { | |||
| rung_value, | |||
| reset_read.value, | |||
| done, | |||
| current_value, | |||
| preset}; | |||
| } | |||
| return success(); | |||
| } | |||
| if (const auto *move = std::get_if<MoveNodeConfig>(&node.config)) | |||
| { | |||
| if (!rung_value) | |||
| @@ -3,7 +3,6 @@ | |||
| #include "domain/control_logic_model.h" | |||
| #include "domain/register_repository.h" | |||
| #include <chrono> | |||
| #include <cstdint> | |||
| #include <map> | |||
| #include <string> | |||
| @@ -11,68 +10,57 @@ | |||
| #include <utility> | |||
| #include <vector> | |||
| // 一轮软件逻辑扫描可能返回的错误 | |||
| enum class LogicScanError | |||
| { | |||
| None, | |||
| InvalidLogic, | |||
| ConflictingOutput, | |||
| RegisterReadFailed, | |||
| RegisterWriteFailed | |||
| None, // 扫描成功 | |||
| InvalidLogic, // 逻辑结构或运行配置无效 | |||
| ConflictingOutput, // 多个输出以不兼容的线圈模式写入同一地址 | |||
| RegisterReadFailed, // 扫描读取寄存器失败 | |||
| RegisterWriteFailed // 扫描写入寄存器失败 | |||
| }; | |||
| // 扫描结果;失败时携带出错的逻辑、网络和节点 ID | |||
| 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 | |||
| { | |||
| std::int16_t value = 0; | |||
| bool overflow = false; | |||
| std::int16_t value = 0; // MOVE/ADD/SUB 的结果值 | |||
| bool overflow = false; // ADD/SUB 是否发生饱和溢出 | |||
| }; | |||
| // 一条控制逻辑的运行轨迹快照;各映射使用节点、表达式或网络 ID 作为键 | |||
| 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(); | |||
| }; | |||
| // 全工程轨迹;logicValues 按逻辑 ID 隔离,避免不同逻辑中的节点 ID 互相覆盖 | |||
| struct LogicTraceSnapshot : LogicTraceValues | |||
| { | |||
| std::unordered_map<std::string, LogicTraceValues> logicValues; | |||
| std::unordered_map<std::string, LogicTraceValues> logicValues; // 按逻辑 ID 保存的轨迹 | |||
| // 清除全工程轨迹及按逻辑分组的轨迹 | |||
| void clear(); | |||
| // 返回指定逻辑的轨迹投影;不存在时返回空轨迹 | |||
| LogicTraceSnapshot forLogic(const std::string &logic_id) const; | |||
| }; | |||
| @@ -80,35 +68,57 @@ struct LogicTraceSnapshot : LogicTraceValues | |||
| class SoftwareLogicExecutor | |||
| { | |||
| public: | |||
| using Clock = std::chrono::steady_clock; | |||
| using TimePoint = Clock::time_point; | |||
| /** | |||
| * @brief 检查逻辑是否满足软件扫描的运行要求 | |||
| * @param logics 待检查的控制逻辑集合 | |||
| * @return 成功或包含逻辑、网络、节点定位信息的失败结果 | |||
| * | |||
| * 检查逻辑结构、启用逻辑的运行配置和共享输出冲突 | |||
| */ | |||
| LogicScanResult validate(const std::vector<ControlLogic> &logics) const; | |||
| /** | |||
| * @brief 清除所有跨扫描运行状态 | |||
| * | |||
| * 重置边沿触发状态;不修改寄存器仓库和轨迹对象 | |||
| */ | |||
| void resetRuntime(); | |||
| /** | |||
| * @brief 使用当前 steady_clock 执行一轮完整扫描 | |||
| * @param logics 按工程顺序扫描的控制逻辑集合 | |||
| * @param repository 扫描读取和写入的寄存器仓库 | |||
| * @param trace 可选的轨迹输出;非空时会先清空再写入本轮结果 | |||
| * @return 扫描结果,失败时不会返回部分成功状态作为成功结果 | |||
| */ | |||
| LogicScanResult executeScan( | |||
| const std::vector<ControlLogic> &logics, | |||
| RegisterRepository &repository, | |||
| LogicTraceSnapshot *trace = nullptr); | |||
| LogicScanResult executeScanAt( | |||
| const std::vector<ControlLogic> &logics, | |||
| RegisterRepository &repository, | |||
| TimePoint now, | |||
| LogicTraceSnapshot *trace = nullptr); | |||
| private: | |||
| struct TonRuntimeState | |||
| { | |||
| bool timing = false; | |||
| bool done = false; | |||
| TimePoint startedAt{}; | |||
| std::chrono::milliseconds elapsed{0}; | |||
| }; | |||
| /** | |||
| * @brief 计算单个条件节点的导通结果 | |||
| * @param logic_id 所属控制逻辑 ID,用于隔离边沿触点的跨扫描状态 | |||
| * @param node 待读取和计算的条件节点 | |||
| * @param repository 提供 M/D 值的寄存器仓库 | |||
| * @param value 输出节点导通结果,不能为空 | |||
| * @return 成功结果,或包含节点 ID 的逻辑/寄存器读取失败结果 | |||
| */ | |||
| LogicScanResult evaluateCondition( | |||
| const std::string &logic_id, | |||
| const LogicNode &node, | |||
| RegisterRepository &repository, | |||
| bool *value); | |||
| /** | |||
| * @brief 递归计算串联、并联或叶子条件表达式 | |||
| * @param logic_id 所属控制逻辑 ID,用于传递给叶子节点状态计算 | |||
| * @param expression 待读取的条件表达式树 | |||
| * @param repository 提供 M/D 值的寄存器仓库 | |||
| * @param trace 可选的轨迹输出,用于记录表达式值、电源输入和电源输出 | |||
| * @param input_power 进入当前表达式的电源状态 | |||
| * @param value 输出表达式最终值,不能为空 | |||
| * @return 成功结果,或包含失败节点 ID 的扫描错误 | |||
| */ | |||
| LogicScanResult evaluateExpression( | |||
| const std::string &logic_id, | |||
| const ConditionExpression &expression, | |||
| @@ -116,26 +126,35 @@ private: | |||
| LogicTraceValues *trace, | |||
| bool input_power, | |||
| bool *value); | |||
| /** | |||
| * @brief 在网络结果驱动下执行单个输出节点 | |||
| * @param node 待读取配置并执行的输出节点 | |||
| * @param rung_value 当前网络的逻辑结果,决定输出是否动作 | |||
| * @param repository 提供输出读写所需的 M/D 寄存器仓库 | |||
| * @param trace 可选的轨迹输出,用于记录字操作结果 | |||
| * @param output_value 输出节点最终逻辑值,不能为空 | |||
| * @return 成功结果,或包含节点 ID 的逻辑/寄存器读写失败结果 | |||
| */ | |||
| LogicScanResult executeOutput( | |||
| const std::string &logic_id, | |||
| const LogicNode &node, | |||
| bool rung_value, | |||
| RegisterRepository &repository, | |||
| TimePoint now, | |||
| LogicTraceValues *trace, | |||
| bool *output_value); | |||
| /** | |||
| * @brief 解析并读取一个字操作数 | |||
| * @param operand 常量或 D 寄存器操作数配置 | |||
| * @param repository 读取寄存器操作数时使用的仓库 | |||
| * @param value 输出解析后的 int16 数值,不能为空 | |||
| * @param node_id 关联节点 ID,用于构造可定位的失败结果 | |||
| * @return 常量读取成功,或 D 寄存器读取/操作数类型校验失败 | |||
| */ | |||
| LogicScanResult readWordOperand( | |||
| const WordOperand &operand, | |||
| RegisterRepository &repository, | |||
| std::int16_t *value, | |||
| 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_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 | |||
| #include <QDialog> | |||
| @@ -12,24 +20,37 @@ class AlarmConfigurationDialog; | |||
| class AlarmEditorService; | |||
| struct AlarmDefinition; | |||
| /** 报警定义编辑对话框;保存、更新和删除都委托 AlarmEditorService */ | |||
| class AlarmConfigurationDialog final : public QDialog | |||
| { | |||
| public: | |||
| /** 创建报警配置对话框并绑定报警编辑服务 */ | |||
| explicit AlarmConfigurationDialog( | |||
| AlarmEditorService &service, | |||
| QWidget *parent = nullptr); | |||
| /** 释放 Designer 界面对象 */ | |||
| ~AlarmConfigurationDialog() override; | |||
| private: | |||
| /** 刷新左侧列表并尽量恢复原选中项 */ | |||
| void reloadDefinitions(const std::string &selected_id = {}); | |||
| /** 将当前选中报警加载到编辑区 */ | |||
| void loadSelectedDefinition(); | |||
| /** 根据报警类型更新条件输入项 */ | |||
| void updateConditionOptions(); | |||
| /** 新增一条报警定义 */ | |||
| void addDefinition(); | |||
| /** 更新当前选中的报警定义 */ | |||
| void updateDefinition(); | |||
| /** 删除当前选中的报警定义 */ | |||
| void removeDefinition(); | |||
| /** 从输入框读取报警定义 */ | |||
| AlarmDefinition definitionFromInputs() const; | |||
| /** 返回当前选中的报警标识 */ | |||
| std::string selectedId() const; | |||
| /** Qt Designer 生成的界面对象 */ | |||
| std::unique_ptr<Ui::AlarmConfigurationDialog> ui_; | |||
| /** 报警定义服务,不由对话框拥有 */ | |||
| AlarmEditorService &service_; | |||
| }; | |||
| @@ -13,6 +13,7 @@ | |||
| #include <QTableWidgetItem> | |||
| #include <cstdint> | |||
| #include <cmath> | |||
| #include <set> | |||
| namespace { | |||
| @@ -45,6 +46,8 @@ FreeMonitorWidget::FreeMonitorWidget( | |||
| ui_->monitorTable->horizontalHeader()->setSectionResizeMode(3, QHeaderView::Stretch); | |||
| ui_->monitorTable->horizontalHeader()->setSectionResizeMode(4, QHeaderView::ResizeToContents); | |||
| 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); | |||
| connect(ui_->addButton, &QPushButton::clicked, this, &FreeMonitorWidget::addAddresses); | |||
| connect(ui_->addressEdit, &QLineEdit::returnPressed, | |||
| @@ -70,7 +73,12 @@ void FreeMonitorWidget::refreshValues( | |||
| QString source = tr("数据源:未启用"); | |||
| bool values_available = 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"); | |||
| values_available = true; | |||
| @@ -103,10 +111,20 @@ void FreeMonitorWidget::refreshValues( | |||
| ? tr("等待读取") : tr("不可用")); | |||
| 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 | |||
| ? tr("通信故障,最后有效值") : tr("有效")); | |||
| } | |||
| @@ -122,9 +140,17 @@ void FreeMonitorWidget::reloadAddresses() | |||
| const RegisterAddress &address = addresses[index]; | |||
| const int row = static_cast<int>(index); | |||
| ui_->monitorTable->setItem(row, 0, new QTableWidgetItem(addressText(address))); | |||
| const MonitorPoint &point = service_.points().at(index); | |||
| ui_->monitorTable->setItem( | |||
| 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("--"))); | |||
| if (address.area() == RegisterArea::M) | |||
| { | |||
| @@ -136,7 +162,14 @@ void FreeMonitorWidget::reloadAddresses() | |||
| else | |||
| { | |||
| 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->setAlignment(Qt::AlignRight | Qt::AlignVCenter); | |||
| ui_->monitorTable->setCellWidget(row, 3, target); | |||
| @@ -162,7 +195,8 @@ void FreeMonitorWidget::writeRow(int row) | |||
| 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; | |||
| if (address.area() == RegisterArea::M) | |||
| { | |||
| @@ -181,14 +215,20 @@ void FreeMonitorWidget::writeRow(int row) | |||
| return; | |||
| } | |||
| 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("目标值无效")); | |||
| emit operationMessage(tr("写入 D 区地址失败:请输入 -32768~32767 的整数")); | |||
| emit operationMessage(point.dataType == RegisterDataType::Float32 | |||
| ? tr("写入 D 区地址失败:请输入有限 Float32 数值") | |||
| : tr("写入 D 区地址失败:请输入 -32768~32767 的整数")); | |||
| 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); | |||
| @@ -225,13 +265,20 @@ void FreeMonitorWidget::addAddresses() | |||
| const QByteArray address = ui_->addressEdit->text().toUtf8(); | |||
| const RegisterMonitorResult result = service_.addRange( | |||
| 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) | |||
| { | |||
| reloadAddresses(); | |||
| ui_->addressEdit->selectAll(); | |||
| 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; | |||
| } | |||
| handleResult(tr("添加监控地址"), false, result.message); | |||
| @@ -244,16 +291,16 @@ void FreeMonitorWidget::removeSelectedAddresses() | |||
| { | |||
| 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) | |||
| { | |||
| 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) | |||
| { | |||
| reloadAddresses(); | |||
| @@ -1,3 +1,11 @@ | |||
| /** | |||
| * @file free_monitor_widget.h | |||
| * @brief 定义自由寄存器监控控件 | |||
| * @version 1.0.0 | |||
| * @author suyu | |||
| * @date 2026-08-22 | |||
| */ | |||
| #pragma once | |||
| #include "domain/runtime_state.h" | |||
| @@ -13,33 +21,50 @@ class FreeMonitorWidget; | |||
| class RegisterMonitorService; | |||
| /** 自由监控 UI:地址列表属于当前会话,读写统一委托 RegisterMonitorService */ | |||
| class FreeMonitorWidget final : public QWidget | |||
| { | |||
| Q_OBJECT | |||
| public: | |||
| /** 创建自由监控控件并绑定监控服务 */ | |||
| explicit FreeMonitorWidget( | |||
| RegisterMonitorService &service, | |||
| QWidget *parent = nullptr); | |||
| /** 释放 Designer 界面对象 */ | |||
| ~FreeMonitorWidget() override; | |||
| /** 根据当前模式和通信状态开启或关闭写入入口 */ | |||
| void setWriteEnabled(bool enabled); | |||
| /** 根据运行模式和 PLC 状态刷新所有监控值 */ | |||
| void refreshValues(ApplicationMode mode, PlcConnectionState plc_state); | |||
| /** 从服务重新加载当前会话中的地址列表 */ | |||
| void reloadAddresses(); | |||
| signals: | |||
| /** 监控地址列表发生变化时发出 */ | |||
| void monitorAddressesChanged(); | |||
| /** 操作完成后发出可直接显示的消息 */ | |||
| void operationMessage(const QString &message); | |||
| private: | |||
| /** 从输入框添加一个或多个监控地址 */ | |||
| void addAddresses(); | |||
| /** 删除当前选中的监控地址 */ | |||
| void removeSelectedAddresses(); | |||
| /** 清空当前会话中的监控地址 */ | |||
| void clearAddresses(); | |||
| /** 写入指定行中的寄存器值 */ | |||
| void writeRow(int row); | |||
| /** 根据当前权限刷新写入控件状态 */ | |||
| void updateWriteControls(); | |||
| /** 统一处理监控服务返回结果 */ | |||
| void handleResult(const QString &action, bool succeeded, const std::string &message); | |||
| /** Qt Designer 生成的界面对象 */ | |||
| std::unique_ptr<Ui::FreeMonitorWidget> ui_; | |||
| /** 寄存器监控服务,不由控件拥有 */ | |||
| RegisterMonitorService &service_; | |||
| /** 当前是否允许执行寄存器写入 */ | |||
| bool write_enabled_ = false; | |||
| }; | |||
| @@ -15,13 +15,15 @@ | |||
| <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="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="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> | |||
| </item> | |||
| <item> | |||
| @@ -194,48 +194,6 @@ public: | |||
| textWithValue()); | |||
| break; | |||
| } | |||
| case HmiControlType::ProgressBar: | |||
| { | |||
| const HmiProgressBarConfig config = control_.progressBar.value_or( | |||
| HmiProgressBarConfig{}); | |||
| const int percentage = has_runtime_value_ | |||
| ? config.percentageForValue(word_value_) : 0; | |||
| painter->setPen(Qt::NoPen); | |||
| painter->setBrush(QColor(QStringLiteral("#e4e9e7"))); | |||
| painter->drawRoundedRect(rect, 4, 4); | |||
| const QRectF track = rect.adjusted(2, 2, -2, -2); | |||
| if (has_runtime_value_ && percentage > 0) | |||
| { | |||
| QRectF fill = track; | |||
| fill.setWidth(track.width() * percentage / 100.0); | |||
| painter->setBrush(QColor(QStringLiteral("#3c8c62"))); | |||
| painter->drawRoundedRect(fill, 3, 3); | |||
| } | |||
| painter->setBrush(Qt::NoBrush); | |||
| painter->setPen(QPen(QColor(QStringLiteral("#617069")), 1)); | |||
| painter->drawRoundedRect(rect, 4, 4); | |||
| QString display_text = QString::fromUtf8( | |||
| control_.text.data(), static_cast<int>(control_.text.size())); | |||
| if (config.showValue) | |||
| { | |||
| const QString percentage_text = has_runtime_value_ | |||
| ? QString::number(percentage) + QLatin1Char('%') | |||
| : runtime_active_ ? QStringLiteral("--") | |||
| : QStringLiteral("0%"); | |||
| display_text = display_text.isEmpty() | |||
| ? percentage_text | |||
| : display_text + QStringLiteral(": ") + percentage_text; | |||
| } | |||
| painter->setPen(configuredTextColor(QColor(QStringLiteral("#20342a")))); | |||
| painter->drawText(rect.adjusted(6, 0, -6, 0), | |||
| Qt::AlignCenter, | |||
| display_text); | |||
| break; | |||
| } | |||
| case HmiControlType::PageJump: | |||
| { | |||
| const QColor fill = runtime_active_ && page_hovered_ | |||
| @@ -459,10 +417,12 @@ public: | |||
| } | |||
| // 缓存运行服务读出的值并触发 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; | |||
| word_value_ = word_value; | |||
| float_value_ = float_value; | |||
| has_runtime_value_ = available; | |||
| update(); | |||
| } | |||
| @@ -837,7 +797,10 @@ private: | |||
| if (control_.type == HmiControlType::NumericDisplay | |||
| || 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; | |||
| } | |||
| @@ -867,6 +830,7 @@ private: | |||
| bool bit_value_ = false; | |||
| // 数值控件读取到的 D 字值 | |||
| std::int16_t word_value_ = 0; | |||
| float float_value_ = 0.0f; | |||
| // 标记当前缓存值是否来自一次成功的运行时读取 | |||
| bool has_runtime_value_ = false; | |||
| }; | |||
| @@ -902,6 +866,7 @@ HmiEditorWidget::HmiEditorWidget( | |||
| // 切换显示页面 | |||
| void HmiEditorWidget::setPageId(const std::string &page_id) | |||
| { | |||
| // 切换页面只改变投影上下文,真正的页面数据仍由 ProjectService 持有 | |||
| if (page_id_ == page_id) | |||
| { | |||
| return; | |||
| @@ -928,7 +893,7 @@ void HmiEditorWidget::setRuntimeActive(bool active) | |||
| HmiGraphicsItem *control_item = asHmiItem(item); | |||
| if (control_item != nullptr) | |||
| { | |||
| control_item->setRuntimeValue(false, 0, false); | |||
| control_item->setRuntimeValue(false, 0, 0.0f, false); | |||
| control_item->setAlarmRecords({}); | |||
| } | |||
| } | |||
| @@ -945,6 +910,7 @@ void HmiEditorWidget::setRuntimeWriteEnabled(bool enabled) | |||
| // 全部重新加载当前页面,把内存模型 HmiPage 渲染成画面上图形 | |||
| void HmiEditorWidget::reloadPage() | |||
| { | |||
| // 模型发生变化后完全重建图元,避免增量刷新遗漏属性或选择状态 | |||
| // 画布始终从当前领域页面重建,避免保留已删除控件的图元 | |||
| scene_->clear(); | |||
| const HmiPage *page = editor_service_.findPage(page_id_); | |||
| @@ -1052,6 +1018,7 @@ std::vector<std::string> HmiEditorWidget::selectedControlIds() const | |||
| void HmiEditorWidget::refreshRuntimeValues() | |||
| { | |||
| // 运行态才读取寄存器;编辑态画布只显示工程配置,不显示缓存值 | |||
| if (!runtime_active_) | |||
| { | |||
| return; | |||
| @@ -1076,7 +1043,8 @@ void HmiEditorWidget::refreshRuntimeValues() | |||
| } | |||
| // 运行值通过服务读取,图元不直接接触寄存器仓库 | |||
| 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( | |||
| const std::string &control_id, const QPointF &position) | |||
| { | |||
| // 拖动结束后把坐标交给服务层,由服务层负责页面边界校验和历史记录 | |||
| const HmiControl *control = editor_service_.findControl(page_id_, control_id); | |||
| if (control == nullptr) | |||
| { | |||
| @@ -1146,6 +1115,7 @@ void HmiEditorWidget::handleButtonEvent( | |||
| { | |||
| return; | |||
| } | |||
| // 运行态按钮事件只通过 HmiRuntimeService 写寄存器,不直接访问仓库 | |||
| const HmiControl *control = editor_service_.findControl(page_id_, control_id); | |||
| if (control == nullptr) | |||
| { | |||
| @@ -1177,22 +1147,39 @@ void HmiEditorWidget::handleNumericInputActivated(const std::string &control_id) | |||
| } | |||
| bool accepted = false; | |||
| 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) | |||
| { | |||
| return; | |||
| } | |||
| const HmiRuntimeWriteResult result = runtime_service_.writeNumericInput( | |||
| *control, static_cast<std::int16_t>(value)); | |||
| *control, value); | |||
| if (!result.succeeded) | |||
| { | |||
| emit editorError(tr("寄存器操作失败")); | |||
| @@ -1,6 +1,7 @@ | |||
| /** | |||
| * @file hmi_editor_widget.h | |||
| * @brief 定义基于 QGraphicsScene 的 HMI 页面编辑和运行画布 | |||
| * @version 1.0.0 | |||
| * @author suyu | |||
| * @date 2026-08-10 | |||
| */ | |||
| @@ -56,7 +57,7 @@ public: | |||
| * @param active 为 true 时允许按钮和数值输入调用运行时服务 | |||
| */ | |||
| void setRuntimeActive(bool active); | |||
| // 故障态保持运行值可见,但禁止按钮和数值输入继续写入 | |||
| /** 故障态保持运行值可见,但禁止按钮和数值输入继续写入 */ | |||
| void setRuntimeWriteEnabled(bool enabled); | |||
| /** | |||
| * @brief 使用当前页面模型完全重建场景 | |||
| @@ -69,7 +70,9 @@ public: | |||
| * @param control_id 待选中控件标识,不存在时不改变当前选择 | |||
| */ | |||
| void selectControl(const std::string &control_id); | |||
| /** 返回当前选中的单个控件标识 */ | |||
| std::string selectedControlId() const; | |||
| /** 返回当前选中的多个控件标识 */ | |||
| std::vector<std::string> selectedControlIds() const; | |||
| /** | |||
| * @brief 从统一寄存器仓库读取当前值并刷新画布显示 | |||
| @@ -94,6 +97,7 @@ signals: | |||
| * @param message 可直接显示的错误信息 | |||
| */ | |||
| void editorError(const QString &message); | |||
| /** 运行态请求跳转到指定 HMI 页面 */ | |||
| void pageNavigationRequested(const QString &target_page_id); | |||
| protected: | |||
| @@ -114,18 +118,20 @@ private: | |||
| // 在运行态处理数值输入激活 | |||
| void handleNumericInputActivated(const std::string &control_id); | |||
| // 提供控件查找、移动和属性更新能力,不直接操作 Qt 图元数据 | |||
| /** 提供控件查找、移动和属性更新能力,不直接操作 Qt 图元数据 */ | |||
| HmiEditorService &editor_service_; | |||
| // 提供运行态寄存器读写能力,画布不直接访问寄存器仓库 | |||
| /** 提供运行态寄存器读写能力,画布不直接访问寄存器仓库 */ | |||
| HmiRuntimeService &runtime_service_; | |||
| /** 报警查询服务,不由控件拥有 */ | |||
| AlarmService &alarm_service_; | |||
| // 持有所有页面图元和页面边框 | |||
| /** 持有所有页面图元和页面边框 */ | |||
| QGraphicsScene *scene_ = nullptr; | |||
| // 当前画布投影的页面标识 | |||
| /** 当前画布投影的页面标识 */ | |||
| std::string page_id_; | |||
| // 控制是否允许拖动和编辑控件 | |||
| /** 控制是否允许拖动和编辑控件 */ | |||
| bool editing_enabled_ = true; | |||
| // 控制是否刷新运行值并响应运行态控件操作 | |||
| /** 控制是否刷新运行值并响应运行态控件操作 */ | |||
| bool runtime_active_ = 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 | |||
| #include "domain/control_logic_model.h" | |||
| #include "services/logic_command_service.h" | |||
| #include "services/logic_editor_service.h" | |||
| #include "services/software_logic_executor.h" | |||
| @@ -11,73 +20,187 @@ | |||
| #include <vector> | |||
| class QGraphicsScene; | |||
| class QCompleter; | |||
| class QEvent; | |||
| class QLineEdit; | |||
| class QMouseEvent; | |||
| class QRubberBand; | |||
| class QResizeEvent; | |||
| /** 将结构化梯形图表达式投影成网格图元;不保存自由线段,编辑通过服务提交 */ | |||
| class LogicEditorWidget final : public QGraphicsView | |||
| { | |||
| Q_OBJECT | |||
| public: | |||
| /** 梯形图中的条件、输出、横线和辅助选择图元类型 */ | |||
| class NodeItem; | |||
| class WireItem; | |||
| class WireCellItem; | |||
| class GapCellItem; | |||
| class VerticalConnectorItem; | |||
| class RungItem; | |||
| class EmptySlotItem; | |||
| class OutputSlotItem; | |||
| /** 创建梯形图画布并绑定编辑服务 */ | |||
| explicit LogicEditorWidget( | |||
| LogicEditorService &editor_service, | |||
| QWidget *parent = nullptr); | |||
| /** 切换当前显示的控制逻辑;不存在时显示空场景 */ | |||
| void setLogicId(const std::string &logic_id); | |||
| /** 设置是否允许编辑梯形图 */ | |||
| void setEditingEnabled(bool enabled); | |||
| /** 设置离线运行轨迹;真机模式应传入空轨迹,避免显示本地伪轨迹 */ | |||
| void setRuntimeTrace( | |||
| const LogicTraceSnapshot &trace, | |||
| const std::string &fault_node_id = {}); | |||
| /** 清除当前运行轨迹和故障节点标记 */ | |||
| void clearRuntimeTrace(); | |||
| /** 返回当前是否正在显示运行轨迹 */ | |||
| bool runtimeTraceEnabled() const; | |||
| /** 根据当前逻辑模型重建梯形图场景 */ | |||
| void reloadLogic(); | |||
| /** 选中指定节点并滚动到可见区域 */ | |||
| void selectNode(const std::string &node_id); | |||
| /** 返回当前选中的单个节点标识 */ | |||
| std::string selectedNodeId() const; | |||
| /** 返回当前选中的多个节点标识 */ | |||
| std::vector<std::string> selectedNodeIds() const; | |||
| /** 返回当前选中的网络标识 */ | |||
| std::string selectedRungId() const; | |||
| /** 新增一个空的梯形图网络 */ | |||
| LogicEditorResult addRung(); | |||
| /** 将当前选择转换为服务调用并新增串联条件 */ | |||
| LogicEditorResult addCondition(const LogicNodeConfig &config); | |||
| /** 将当前选择转换为服务调用并新增并联支路 */ | |||
| LogicEditorResult addParallelBranch(const LogicNodeConfig &config); | |||
| /** 在当前选择位置新增横线 */ | |||
| LogicEditorResult addHorizontalWire(); | |||
| /** 在当前选择位置新增竖线 */ | |||
| LogicEditorResult addVerticalWire(); | |||
| /** 删除当前选择位置的横线 */ | |||
| LogicEditorResult deleteHorizontalWire(); | |||
| /** 删除当前选择位置的竖线 */ | |||
| LogicEditorResult deleteVerticalWire(); | |||
| /** 设置当前网络的输出线圈 */ | |||
| LogicEditorResult setOutput( | |||
| const LogicNodeConfig &config, bool configured = false); | |||
| /** 按当前画布选择位置粘贴一组连续条件 */ | |||
| LogicEditorResult pasteConditionNodes(const std::vector<LogicNode> &nodes); | |||
| /** 删除当前选中的节点、线段或网络 */ | |||
| LogicEditorResult deleteSelected(); | |||
| signals: | |||
| /** 当前选中的节点发生变化时发出 */ | |||
| void nodeSelected(const QString &node_id); | |||
| /** 梯形图模型成功发生变化时发出 */ | |||
| void graphChanged(); | |||
| /** 编辑服务操作失败时发出可显示的错误信息 */ | |||
| void editorError(const QString &message); | |||
| 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 scrollContentsBy(int dx, int dy) override; | |||
| /** 处理命令输入框的回车、取消和按键事件 */ | |||
| bool eventFilter(QObject *watched, QEvent *event) override; | |||
| private: | |||
| /** 处理图形场景选择变化 */ | |||
| void handleSelectionChanged(); | |||
| /** 把编辑服务结果转换为错误信号 */ | |||
| void reportFailure(const LogicEditorResult &result); | |||
| /** 返回当前选中的网络标识 */ | |||
| std::string currentRungId() const; | |||
| /** 选中指定表达式图元 */ | |||
| void selectExpression(const std::string &expression_id); | |||
| /** 选中指定横线图元 */ | |||
| void selectWire(const std::string &wire_id); | |||
| /** 收集当前选中的表达式标识 */ | |||
| std::vector<std::string> selectedExpressionIds() const; | |||
| /** 收集当前选中的横线标识 */ | |||
| std::vector<std::string> selectedWireIds() const; | |||
| /** 收集当前选中的并联支路标识 */ | |||
| std::vector<std::string> selectedBranchIds() const; | |||
| std::vector<int> selectedEmptySlotColumns() const; | |||
| /** 收集当前选中的空网格位置 */ | |||
| std::vector<std::pair<std::string, int>> selectedEmptySlots() 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_; | |||
| /** 命令语解析和结构化编辑适配服务 */ | |||
| LogicCommandService command_service_; | |||
| /** 承载梯形图图元的场景 */ | |||
| QGraphicsScene *scene_ = nullptr; | |||
| /** 当前显示的控制逻辑标识 */ | |||
| std::string logic_id_; | |||
| /** 当前选中的网络标识 */ | |||
| std::string current_rung_id_; | |||
| /** 最近一次收到的运行轨迹 */ | |||
| LogicTraceSnapshot trace_; | |||
| /** 最近一次运行故障节点标识 */ | |||
| std::string fault_node_id_; | |||
| /** 是否显示运行轨迹 */ | |||
| bool runtime_trace_enabled_ = false; | |||
| /** 是否允许编辑梯形图 */ | |||
| 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) | |||
| { | |||
| ui_->setupUi(this); | |||
| configureOperandCombo(ui_->counterPresetKindComboBox); | |||
| configureOperandCombo(ui_->leftOperandKindComboBox); | |||
| configureOperandCombo(ui_->rightOperandKindComboBox); | |||
| connect(ui_->buttonBox, &QDialogButtonBox::accepted, this, &QDialog::accept); | |||
| connect(ui_->buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject); | |||
| for (QComboBox *combo : { | |||
| ui_->counterPresetKindComboBox, | |||
| ui_->leftOperandKindComboBox, | |||
| ui_->rightOperandKindComboBox}) | |||
| { | |||
| @@ -49,11 +47,7 @@ LogicInstructionDialog::LogicInstructionDialog( | |||
| &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); | |||
| } | |||
| @@ -68,21 +62,6 @@ LogicInstructionDialog::~LogicInstructionDialog() = default; | |||
| LogicNodeConfig LogicInstructionDialog::config() const | |||
| { | |||
| if (std::holds_alternative<CounterNodeConfig>(original_config_)) | |||
| { | |||
| return CounterNodeConfig{ | |||
| CounterAddress{ui_->counterAddressSpinBox->value()}, | |||
| static_cast<CounterMode>(ui_->counterModeComboBox->currentData().toInt()), | |||
| RegisterAddress{ | |||
| RegisterArea::D, | |||
| ui_->counterCurrentAddressSpinBox->value()}, | |||
| operandFrom( | |||
| ui_->counterPresetKindComboBox, | |||
| ui_->counterPresetValueSpinBox), | |||
| RegisterAddress{ | |||
| RegisterArea::M, | |||
| ui_->counterResetAddressSpinBox->value()}}; | |||
| } | |||
| if (std::holds_alternative<MoveNodeConfig>(original_config_)) | |||
| { | |||
| return MoveNodeConfig{ | |||
| @@ -101,25 +80,6 @@ LogicNodeConfig LogicInstructionDialog::config() const | |||
| ui_->destinationAddressSpinBox->value()}}; | |||
| } | |||
| void LogicInstructionDialog::configureCounter(const CounterNodeConfig &config) | |||
| { | |||
| setWindowTitle(tr("配置计数器")); | |||
| ui_->instructionStackedWidget->setCurrentWidget(ui_->counterPage); | |||
| ui_->counterModeComboBox->clear(); | |||
| ui_->counterModeComboBox->addItem(tr("CTU 加计数"), static_cast<int>(CounterMode::Up)); | |||
| ui_->counterModeComboBox->addItem( | |||
| tr("CTD 减计数"), static_cast<int>(CounterMode::Down)); | |||
| ui_->counterModeComboBox->setCurrentIndex( | |||
| ui_->counterModeComboBox->findData(static_cast<int>(config.mode))); | |||
| ui_->counterAddressSpinBox->setValue(config.address.index()); | |||
| ui_->counterCurrentAddressSpinBox->setValue(config.currentValueAddress.index()); | |||
| ui_->counterResetAddressSpinBox->setValue(config.resetAddress.index()); | |||
| setOperand( | |||
| ui_->counterPresetKindComboBox, | |||
| ui_->counterPresetValueSpinBox, | |||
| config.preset); | |||
| } | |||
| void LogicInstructionDialog::configureMove(const MoveNodeConfig &config) | |||
| { | |||
| setWindowTitle(tr("配置 MOVE")); | |||
| @@ -165,13 +125,11 @@ void LogicInstructionDialog::updateOperandRanges() | |||
| spin->setPrefix(register_operand ? QStringLiteral("D") : QString{}); | |||
| spin->setMinimum(register_operand | |||
| ? RegisterAddress::kMinimumIndex | |||
| : (combo->objectName() == QStringLiteral("counterPresetKindComboBox") | |||
| ? 0 : std::numeric_limits<std::int16_t>::min())); | |||
| : std::numeric_limits<std::int16_t>::min()); | |||
| spin->setMaximum(register_operand | |||
| ? RegisterAddress::kMaximumIndex | |||
| : std::numeric_limits<std::int16_t>::max()); | |||
| }; | |||
| update(ui_->counterPresetKindComboBox, ui_->counterPresetValueSpinBox); | |||
| update(ui_->leftOperandKindComboBox, ui_->leftOperandValueSpinBox); | |||
| update(ui_->rightOperandKindComboBox, ui_->rightOperandValueSpinBox); | |||
| } | |||
| @@ -1,3 +1,11 @@ | |||
| /** | |||
| * @file logic_instruction_dialog.h | |||
| * @brief 定义梯形图指令参数编辑对话框 | |||
| * @version 1.0.0 | |||
| * @author suyu | |||
| * @date 2026-08-22 | |||
| */ | |||
| #pragma once | |||
| #include "domain/control_logic_model.h" | |||
| @@ -13,31 +21,41 @@ namespace Ui { | |||
| class LogicInstructionDialog; | |||
| } | |||
| /** 梯形图节点配置对话框;只负责把表单转换为强类型 LogicNodeConfig */ | |||
| class LogicInstructionDialog final : public QDialog | |||
| { | |||
| Q_OBJECT | |||
| public: | |||
| /** 使用已有节点配置初始化指令参数表单 */ | |||
| explicit LogicInstructionDialog( | |||
| const LogicNodeConfig &config, | |||
| QWidget *parent = nullptr); | |||
| /** 释放 Designer 界面对象 */ | |||
| ~LogicInstructionDialog() override; | |||
| /** 返回当前表单配置,调用方仍需交给 LogicEditorService 校验 */ | |||
| LogicNodeConfig config() const; | |||
| private: | |||
| void configureCounter(const CounterNodeConfig &config); | |||
| /** 根据传送配置初始化传送输入项 */ | |||
| void configureMove(const MoveNodeConfig &config); | |||
| /** 根据算术配置初始化运算输入项 */ | |||
| void configureArithmetic(const ArithmeticNodeConfig &config); | |||
| /** 根据操作数类型更新数值输入框的合法范围 */ | |||
| void updateOperandRanges(); | |||
| /** 将领域操作数写入对应的下拉框和数值框 */ | |||
| static void setOperand( | |||
| QComboBox *kind_combo, | |||
| QSpinBox *value_spin, | |||
| const WordOperand &operand); | |||
| /** 从下拉框和数值框读取领域操作数 */ | |||
| static WordOperand operandFrom( | |||
| const QComboBox *kind_combo, | |||
| const QSpinBox *value_spin); | |||
| /** Qt Designer 生成的界面对象 */ | |||
| std::unique_ptr<Ui::LogicInstructionDialog> ui_; | |||
| /** 打开对话框时保存的原始节点配置 */ | |||
| LogicNodeConfig original_config_; | |||
| }; | |||
| @@ -9,27 +9,6 @@ | |||
| <layout class="QVBoxLayout" name="verticalLayout"> | |||
| <item> | |||
| <widget class="QStackedWidget" name="instructionStackedWidget"> | |||
| <widget class="QWidget" name="counterPage"> | |||
| <layout class="QFormLayout" name="counterFormLayout"> | |||
| <item row="0" column="0"><widget class="QLabel" name="counterModeLabel"><property name="text"><string>计数方式</string></property></widget></item> | |||
| <item row="0" column="1"><widget class="QComboBox" name="counterModeComboBox"/></item> | |||
| <item row="1" column="0"><widget class="QLabel" name="counterAddressLabel"><property name="text"><string>计数器资源</string></property></widget></item> | |||
| <item row="1" column="1"><widget class="QSpinBox" name="counterAddressSpinBox"><property name="prefix"><string>C</string></property><property name="maximum"><number>4000</number></property></widget></item> | |||
| <item row="2" column="0"><widget class="QLabel" name="counterCurrentAddressLabel"><property name="text"><string>当前值 CV</string></property></widget></item> | |||
| <item row="2" column="1"><widget class="QSpinBox" name="counterCurrentAddressSpinBox"><property name="prefix"><string>D</string></property><property name="maximum"><number>4000</number></property></widget></item> | |||
| <item row="3" column="0"><widget class="QLabel" name="counterPresetLabel"><property name="text"><string>预设值 PV</string></property></widget></item> | |||
| <item row="3" column="1"> | |||
| <widget class="QWidget" name="counterPresetEditor" native="true"> | |||
| <layout class="QHBoxLayout" name="counterPresetLayout"><property name="leftMargin"><number>0</number></property><property name="topMargin"><number>0</number></property><property name="rightMargin"><number>0</number></property><property name="bottomMargin"><number>0</number></property> | |||
| <item><widget class="QComboBox" name="counterPresetKindComboBox"/></item> | |||
| <item><widget class="QSpinBox" name="counterPresetValueSpinBox"><property name="maximum"><number>32767</number></property><property name="value"><number>10</number></property></widget></item> | |||
| </layout> | |||
| </widget> | |||
| </item> | |||
| <item row="4" column="0"><widget class="QLabel" name="counterResetAddressLabel"><property name="text"><string>复位输入</string></property></widget></item> | |||
| <item row="4" column="1"><widget class="QSpinBox" name="counterResetAddressSpinBox"><property name="prefix"><string>M</string></property><property name="maximum"><number>4000</number></property></widget></item> | |||
| </layout> | |||
| </widget> | |||
| <widget class="QWidget" name="dataInstructionPage"> | |||
| <layout class="QFormLayout" name="dataInstructionFormLayout"> | |||
| <item row="0" column="0"><widget class="QLabel" name="instructionCaptionLabel"><property name="text"><string>指令</string></property></widget></item> | |||
| @@ -1,7 +1,7 @@ | |||
| /** | |||
| * @file main_window.h | |||
| * @brief 定义综合平台编程器主窗口及运行模式界面协调逻辑 | |||
| * @version 0.2.0 | |||
| * @version 1.0.0 | |||
| * @author suyu | |||
| * @date 2026-08-08 | |||
| */ | |||
| @@ -12,10 +12,14 @@ | |||
| #include "domain/control_logic_model.h" | |||
| #include "domain/runtime_state.h" | |||
| #include "services/plc_communication_gateway.h" | |||
| #include "services/application_settings.h" | |||
| #include <QMainWindow> | |||
| #include <QProcess> | |||
| #include <memory> | |||
| #include <variant> | |||
| #include <vector> | |||
| QT_BEGIN_NAMESPACE | |||
| class QAction; | |||
| @@ -23,6 +27,9 @@ class QActionGroup; | |||
| class QCloseEvent; | |||
| class QEvent; | |||
| class QLabel; | |||
| class QDockWidget; | |||
| class QTimer; | |||
| class QProgressDialog; | |||
| namespace Ui { | |||
| class MainWindow; | |||
| @@ -41,7 +48,9 @@ class HmiEditorWidget; | |||
| class LogicEditorService; | |||
| class LogicEditorWidget; | |||
| class RegisterMonitorService; | |||
| class PlcDiscoveryGateway; | |||
| class RuntimeMonitorWidget; | |||
| class FreeMonitorWidget; | |||
| class ProjectWorkspaceController; | |||
| class PropertyPanelController; | |||
| class RuntimePanelController; | |||
| @@ -51,6 +60,7 @@ class RuntimePanelController; | |||
| * | |||
| * 主窗口不直接访问寄存器、工程文件或 PLC 通信实现 | |||
| */ | |||
| // 编程器主窗口:负责组装 UI 控制器、窗口动作和模式切换,不承载领域规则 | |||
| class MainWindow final : public QMainWindow | |||
| { | |||
| Q_OBJECT | |||
| @@ -74,7 +84,15 @@ public: | |||
| AlarmService &alarm_service, | |||
| RegisterCommentService ®ister_comment_service, | |||
| RegisterMonitorService ®ister_monitor_service, | |||
| PlcDiscoveryGateway &plc_discovery_gateway, | |||
| const ApplicationSettingsLoadResult &application_settings_result, | |||
| bool user_runtime_mode = false, | |||
| QWidget *parent = nullptr); | |||
| /** | |||
| * @brief 创建主窗口,并使用调用方提供的 HMI 页面导航服务 | |||
| * | |||
| * 两个构造函数的区别只在导航服务的所有权;其余服务均由应用入口管理 | |||
| */ | |||
| MainWindow( | |||
| RuntimeModeService &runtime_mode_service, | |||
| ProjectService &project_service, | |||
| @@ -86,82 +104,125 @@ public: | |||
| HmiNavigationService &hmi_navigation_service, | |||
| RegisterCommentService ®ister_comment_service, | |||
| RegisterMonitorService ®ister_monitor_service, | |||
| PlcDiscoveryGateway &plc_discovery_gateway, | |||
| const ApplicationSettingsLoadResult &application_settings_result, | |||
| bool user_runtime_mode = false, | |||
| QWidget *parent = nullptr); | |||
| /** 释放窗口拥有的控制器和可选导航服务 */ | |||
| ~MainWindow() override; | |||
| /** 返回当前选中的 HMI 页面标识 */ | |||
| const std::string ¤tHmiPageId() const; | |||
| /** 返回当前选中的控制逻辑标识 */ | |||
| const std::string ¤tLogicId() const; | |||
| protected: | |||
| /** 处理主窗口及子控件的事件过滤请求 */ | |||
| bool eventFilter(QObject *watched, QEvent *event) override; | |||
| /** 关闭窗口前处理保存确认和运行态退出 */ | |||
| void closeEvent(QCloseEvent *event) override; | |||
| private: | |||
| // 配置菜单和工具栏动作 | |||
| /** 配置菜单和工具栏动作 */ | |||
| void configureActions(); | |||
| // 配置主窗口外观和状态栏 | |||
| /** 配置主窗口外观和状态栏 */ | |||
| void configureAppearance(); | |||
| // 创建并连接 HMI 编辑画布 | |||
| /** 创建并连接 HMI 编辑画布 */ | |||
| void configureHmiEditor(); | |||
| // 创建并连接控制逻辑编辑画布 | |||
| /** 创建并连接控制逻辑编辑画布 */ | |||
| void configureLogicEditor(); | |||
| /** 创建并连接运行监控面板 */ | |||
| void configureRuntimeMonitor(); | |||
| /** 创建并连接编辑态数据监控面板 */ | |||
| void configureDataMonitor(); | |||
| /** 创建并连接工程树 */ | |||
| void configureProjectTree(); | |||
| // 创建并连接控件属性编辑表单 | |||
| /** 创建并连接控件属性编辑表单 */ | |||
| void configurePropertyEditor(); | |||
| /** 创建并连接 PLC 串口配置入口 */ | |||
| void configurePlcConnection(); | |||
| /** 创建并连接报警配置入口 */ | |||
| void configureAlarms(); | |||
| /** 创建并连接寄存器注释入口 */ | |||
| void configureRegisterComments(); | |||
| /** 撤销当前处于焦点的编辑器操作 */ | |||
| void undoActiveEditor(); | |||
| /** 重做当前处于焦点的编辑器操作 */ | |||
| void redoActiveEditor(); | |||
| /** 复制当前编辑画布中的选中对象 */ | |||
| void copyActiveSelection(); | |||
| /** 粘贴剪贴板中的对象到当前编辑画布 */ | |||
| void pasteActiveSelection(); | |||
| /** 删除当前处于焦点的编辑器选中项 */ | |||
| void deleteActiveSelection(); | |||
| /** 清除当前处于焦点的编辑器选择 */ | |||
| void clearActiveSelection(); | |||
| /** 根据编辑器选择状态更新撤销、重做和删除动作 */ | |||
| void updateEditActions(); | |||
| /** 清空 HMI 和梯形图编辑器的撤销历史 */ | |||
| void clearEditorHistories(); | |||
| /** 根据工程路径和修改状态刷新窗口标题 */ | |||
| void updateWindowTitle(); | |||
| /** 对可能破坏当前工程的操作请求保存确认 */ | |||
| bool confirmSaveBeforeDestructiveAction(); | |||
| // 刷新工程树和当前 HMI 页面信息 | |||
| /** 刷新工程树和当前 HMI 页面信息 */ | |||
| void refreshProjectUi(); | |||
| /** 更新工程树相关动作的可用状态 */ | |||
| void updateProjectTreeActions(); | |||
| // 显示指定 HMI 控件的可编辑属性 | |||
| /** 显示指定 HMI 控件的可编辑属性 */ | |||
| void showControlProperties(const std::string &control_id); | |||
| // 显示指定控制逻辑节点的可编辑属性 | |||
| /** 显示指定控制逻辑节点的可编辑属性 */ | |||
| void showLogicNodeProperties(const std::string &node_id); | |||
| // 向当前页面添加指定类型的 HMI 控件 | |||
| /** 向当前页面添加指定类型的 HMI 控件 */ | |||
| void addHmiControl(HmiControlType type); | |||
| // 删除当前选中的 HMI 控件 | |||
| /** 删除当前选中的 HMI 控件 */ | |||
| void deleteSelectedControl(); | |||
| // 在当前光标节点后添加串联条件,没有选择时追加到网络末尾 | |||
| /** 在当前光标节点后添加串联条件,没有选择时追加到网络末尾 */ | |||
| void addLogicCondition(const LogicNodeConfig &config); | |||
| // 为当前选中的连续逻辑范围建立并联支路 | |||
| /** 为当前选中的连续逻辑范围建立并联支路 */ | |||
| void addLogicParallelBranch(const LogicNodeConfig &config); | |||
| /** 在当前梯形图位置添加横线 */ | |||
| void addLogicHorizontalWire(); | |||
| /** 在当前梯形图位置添加竖线 */ | |||
| void addLogicVerticalWire(); | |||
| /** 删除当前梯形图位置的横线 */ | |||
| void deleteLogicHorizontalWire(); | |||
| /** 删除当前梯形图位置的竖线 */ | |||
| void deleteLogicVerticalWire(); | |||
| // 设置当前网络右侧的输出线圈 | |||
| /** 设置当前网络右侧的输出线圈 */ | |||
| void setLogicOutput(const LogicNodeConfig &config); | |||
| /** 打开输出指令配置并提交输出线圈 */ | |||
| void configureAndSetLogicOutput(const LogicNodeConfig &config); | |||
| // 新增一个梯形图网络 | |||
| /** 新增一个梯形图网络 */ | |||
| void addLogicRung(); | |||
| /** 编辑当前网络的注释 */ | |||
| void editSelectedRungComment(); | |||
| // 删除当前选中的逻辑节点或网络 | |||
| /** 删除当前选中的逻辑节点或网络 */ | |||
| void deleteSelectedLogicObject(); | |||
| /** 打开 PLC 连接 */ | |||
| void connectPlc(); | |||
| /** 关闭 PLC 连接 */ | |||
| void disconnectPlc(); | |||
| // 合并同一事件循环内的 PLC 状态通知,避免重复刷新和重复日志 | |||
| /** 合并同一事件循环内的 PLC 状态通知,避免重复刷新和重复日志 */ | |||
| void schedulePlcStatusUpdate(); | |||
| // 创建新的工程并刷新编辑界面 | |||
| /** 创建新的工程并刷新编辑界面 */ | |||
| void createNewProject(); | |||
| // 保存当前工程到已关联的路径 | |||
| /** 保存当前工程到已关联的路径 */ | |||
| void saveProject(); | |||
| // 将当前工程保存到用户指定的路径 | |||
| /** 将当前工程保存到用户指定的路径 */ | |||
| void saveProjectAs(); | |||
| // 从用户指定的路径加载工程 | |||
| /** 从用户指定的路径加载工程 */ | |||
| 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 appendOutputMessage(const QString &message); | |||
| /** | |||
| @@ -170,22 +231,30 @@ private: | |||
| * | |||
| * 失败时恢复与服务层当前状态一致的工具栏选项 | |||
| */ | |||
| void requestMode(ApplicationMode requested_mode); | |||
| bool requestMode(ApplicationMode requested_mode); | |||
| /** | |||
| * @brief 根据服务层模式策略更新编辑权限和状态反馈 | |||
| * @param message 要显示在状态栏和输出区的模式结果消息 | |||
| */ | |||
| void updateModeUi(const QString &message); | |||
| // 根据仿真服务实际状态刷新执行器反馈和 HMI 写权限 | |||
| /** 根据仿真服务实际状态刷新执行器反馈和 HMI 写权限 */ | |||
| void updateSimulationUi(bool report_fault); | |||
| /** | |||
| * @brief 将互斥模式动作同步到服务层当前状态 | |||
| */ | |||
| void restoreCurrentModeAction(); | |||
| /** 完成主窗口初始化和首次界面刷新 */ | |||
| void initializeUi(); | |||
| /** 专用用户运行版启动后直接进入运行监控 */ | |||
| void initializeUserRuntime(); | |||
| /** 用户运行版允许在离线和真机之间自动经过编辑态切换 */ | |||
| void requestUserRuntimeMode(ApplicationMode requested_mode); | |||
| /** 刷新编辑态数据监控的模式、权限和当前值 */ | |||
| void refreshDataMonitorUi(); | |||
| /** Qt Designer 生成的主窗口界面对象 */ | |||
| std::unique_ptr<Ui::MainWindow> ui_; | |||
| /** | |||
| * @brief 非拥有的运行模式服务依赖,由应用入口保证生命周期 | |||
| @@ -195,29 +264,98 @@ private: | |||
| * @brief 非拥有的工程和 HMI 服务依赖,由应用入口保证生命周期 | |||
| */ | |||
| ProjectService &project_service_; | |||
| /** 非拥有的 HMI 编辑服务依赖 */ | |||
| HmiEditorService &hmi_editor_service_; | |||
| /** 非拥有的梯形图编辑服务依赖 */ | |||
| LogicEditorService &logic_editor_service_; | |||
| /** 非拥有的 HMI 运行服务依赖 */ | |||
| HmiRuntimeService &hmi_runtime_service_; | |||
| /** 非拥有的报警编辑服务依赖 */ | |||
| AlarmEditorService &alarm_editor_service_; | |||
| /** 非拥有的报警查询服务依赖 */ | |||
| AlarmService &alarm_service_; | |||
| /** 非拥有的寄存器注释服务依赖 */ | |||
| RegisterCommentService ®ister_comment_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_; | |||
| /** 当前使用的 HMI 页面导航服务 */ | |||
| HmiNavigationService *hmi_navigation_service_ = nullptr; | |||
| /** 工程树控制器 */ | |||
| std::unique_ptr<ProjectWorkspaceController> project_workspace_controller_; | |||
| /** 属性面板控制器 */ | |||
| std::unique_ptr<PropertyPanelController> property_panel_controller_; | |||
| /** 运行监控面板控制器 */ | |||
| std::unique_ptr<RuntimePanelController> runtime_panel_controller_; | |||
| /** 运行模式互斥动作组 */ | |||
| QActionGroup *mode_action_group_ = nullptr; | |||
| /** 主界面的 HMI 编辑器 */ | |||
| HmiEditorWidget *hmi_editor_widget_ = nullptr; | |||
| /** 主界面的梯形图编辑器 */ | |||
| LogicEditorWidget *logic_editor_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; | |||
| /** 显示 PLC 连接状态的标签 */ | |||
| QLabel *register_status_label_ = nullptr; | |||
| /** 显示离线执行器状态的标签 */ | |||
| QLabel *executor_status_label_ = nullptr; | |||
| /** 当前保存的 PLC 串口配置 */ | |||
| PlcSerialConfiguration plc_configuration_; | |||
| /** 当前选中的 HMI 控件标识 */ | |||
| std::string selected_control_id_; | |||
| /** 当前选中的梯形图节点标识 */ | |||
| std::string selected_logic_node_id_; | |||
| /** 当前选中的 HMI 页面标识 */ | |||
| std::string current_hmi_page_id_; | |||
| /** 当前选中的控制逻辑标识 */ | |||
| std::string current_logic_id_; | |||
| /** 是否已经安排了待处理的 PLC 状态刷新 */ | |||
| 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="saveAsProjectAction"/> | |||
| <addaction name="loadProjectAction"/> | |||
| <addaction name="exportRuntimeAction"/> | |||
| <addaction name="separator"/> | |||
| <addaction name="exitAction"/> | |||
| </widget> | |||
| @@ -237,6 +238,8 @@ | |||
| </property> | |||
| <addaction name="undoAction"/> | |||
| <addaction name="redoAction"/> | |||
| <addaction name="copyAction"/> | |||
| <addaction name="pasteAction"/> | |||
| <addaction name="separator"/> | |||
| <addaction name="deleteSelectionAction"/> | |||
| <addaction name="deleteHorizontalWireAction"/> | |||
| @@ -669,61 +672,13 @@ | |||
| </widget> | |||
| </item> | |||
| <item row="10" column="0"> | |||
| <widget class="QLabel" name="progressMinimumLabel"> | |||
| <property name="text"> | |||
| <string>最小值</string> | |||
| </property> | |||
| </widget> | |||
| </item> | |||
| <item row="10" column="1"> | |||
| <widget class="QSpinBox" name="progressMinimumSpinBox"> | |||
| <property name="minimum"> | |||
| <number>-32768</number> | |||
| </property> | |||
| <property name="maximum"> | |||
| <number>32767</number> | |||
| </property> | |||
| </widget> | |||
| </item> | |||
| <item row="11" column="0"> | |||
| <widget class="QLabel" name="progressMaximumLabel"> | |||
| <property name="text"> | |||
| <string>最大值</string> | |||
| </property> | |||
| </widget> | |||
| </item> | |||
| <item row="11" column="1"> | |||
| <widget class="QSpinBox" name="progressMaximumSpinBox"> | |||
| <property name="minimum"> | |||
| <number>-32768</number> | |||
| </property> | |||
| <property name="maximum"> | |||
| <number>32767</number> | |||
| </property> | |||
| </widget> | |||
| </item> | |||
| <item row="12" column="0"> | |||
| <widget class="QLabel" name="progressShowValueLabel"> | |||
| <property name="text"> | |||
| <string>数值显示</string> | |||
| </property> | |||
| </widget> | |||
| </item> | |||
| <item row="12" column="1"> | |||
| <widget class="QCheckBox" name="progressShowValueCheckBox"> | |||
| <property name="text"> | |||
| <string>显示百分比</string> | |||
| </property> | |||
| </widget> | |||
| </item> | |||
| <item row="13" column="0"> | |||
| <widget class="QLabel" name="textColorLabel"> | |||
| <property name="text"> | |||
| <string>字体颜色</string> | |||
| </property> | |||
| </widget> | |||
| </item> | |||
| <item row="13" column="1"> | |||
| <item row="10" column="1"> | |||
| <widget class="QWidget" name="textColorContainer"> | |||
| <layout class="QHBoxLayout" name="textColorLayout"> | |||
| <property name="leftMargin"><number>0</number></property> | |||
| @@ -747,24 +702,24 @@ | |||
| </layout> | |||
| </widget> | |||
| </item> | |||
| <item row="14" column="0"> | |||
| <item row="11" column="0"> | |||
| <widget class="QLabel" name="fontSizeLabel"> | |||
| <property name="text"> | |||
| <string>字号</string> | |||
| </property> | |||
| </widget> | |||
| </item> | |||
| <item row="14" column="1"> | |||
| <item row="11" column="1"> | |||
| <widget class="QSpinBox" name="fontSizeSpinBox"/> | |||
| </item> | |||
| <item row="15" column="0"> | |||
| <item row="12" column="0"> | |||
| <widget class="QLabel" name="fontBoldLabel"> | |||
| <property name="text"> | |||
| <string>字体样式</string> | |||
| </property> | |||
| </widget> | |||
| </item> | |||
| <item row="15" column="1"> | |||
| <item row="12" column="1"> | |||
| <widget class="QWidget" name="fontStyleContainer"> | |||
| <layout class="QHBoxLayout" name="fontStyleLayout"> | |||
| <property name="leftMargin"><number>0</number></property> | |||
| @@ -784,7 +739,18 @@ | |||
| </layout> | |||
| </widget> | |||
| </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"> | |||
| <property name="text"> | |||
| <string>应用属性</string> | |||
| @@ -922,27 +888,7 @@ | |||
| </property> | |||
| </widget> | |||
| </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"> | |||
| <property name="text"> | |||
| <string>应用属性</string> | |||
| @@ -960,6 +906,28 @@ | |||
| </layout> | |||
| </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"> | |||
| <property name="minimumSize"> | |||
| <size> | |||
| @@ -1036,6 +1004,10 @@ | |||
| <string>Ctrl+O</string> | |||
| </property> | |||
| </action> | |||
| <action name="exportRuntimeAction"> | |||
| <property name="text"><string>导出用户运行程序</string></property> | |||
| <property name="toolTip"><string>将当前工程导出为用户可直接运行的专用程序</string></property> | |||
| </action> | |||
| <action name="undoAction"> | |||
| <property name="text"><string>撤销</string></property> | |||
| <property name="toolTip"><string>撤销当前编辑器的上一步操作</string></property> | |||
| @@ -1046,6 +1018,16 @@ | |||
| <property name="toolTip"><string>重做当前编辑器最近撤销的操作</string></property> | |||
| <property name="shortcut"><string>Ctrl+Y</string></property> | |||
| </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"> | |||
| <property name="text"><string>删除所选</string></property> | |||
| <property name="toolTip"><string>删除当前画布中选中的对象</string></property> | |||
| @@ -1112,14 +1094,6 @@ | |||
| <string>添加数值输入控件</string> | |||
| </property> | |||
| </action> | |||
| <action name="addProgressBarAction"> | |||
| <property name="text"> | |||
| <string>进度条</string> | |||
| </property> | |||
| <property name="toolTip"> | |||
| <string>添加进度条控件</string> | |||
| </property> | |||
| </action> | |||
| <action name="addLabelAction"> | |||
| <property name="text"> | |||
| <string>文本</string> | |||
| @@ -1252,18 +1226,6 @@ | |||
| <string>在选中的空网格或横线处添加一个扫描周期的下降沿触点</string> | |||
| </property> | |||
| </action> | |||
| <action name="addTimerContactAction"> | |||
| <property name="text"> | |||
| <string>T 触点</string> | |||
| </property> | |||
| <property name="toolTip"> | |||
| <string>在选中的空网格或横线处添加离线仿真定时器触点</string> | |||
| </property> | |||
| </action> | |||
| <action name="addCounterContactAction"> | |||
| <property name="text"><string>C 触点</string></property> | |||
| <property name="toolTip"><string>在选中的空网格或横线处添加离线仿真计数器完成触点</string></property> | |||
| </action> | |||
| <action name="addNormalCoilAction"> | |||
| <property name="text"> | |||
| <string>线圈</string> | |||
| @@ -1288,22 +1250,6 @@ | |||
| <string>设置当前网络的复位线圈</string> | |||
| </property> | |||
| </action> | |||
| <action name="addTonAction"> | |||
| <property name="text"> | |||
| <string>TON</string> | |||
| </property> | |||
| <property name="toolTip"> | |||
| <string>设置非保持接通延时定时器输出</string> | |||
| </property> | |||
| </action> | |||
| <action name="addCtuAction"> | |||
| <property name="text"><string>CTU</string></property> | |||
| <property name="toolTip"><string>设置按上升沿执行的加计数器</string></property> | |||
| </action> | |||
| <action name="addCtdAction"> | |||
| <property name="text"><string>CTD</string></property> | |||
| <property name="toolTip"><string>设置按上升沿执行的减计数器</string></property> | |||
| </action> | |||
| <action name="addMoveAction"> | |||
| <property name="text"><string>MOVE</string></property> | |||
| <property name="toolTip"><string>设置 D 数据传送指令</string></property> | |||
| @@ -3,9 +3,17 @@ | |||
| #include "ui_plc_connection_dialog.h" | |||
| #include <QComboBox> | |||
| #include <QDialogButtonBox> | |||
| #include <QMessageBox> | |||
| #include <QProgressBar> | |||
| #include <QPushButton> | |||
| #include <QSerialPortInfo> | |||
| #include <QStyle> | |||
| #include <QTimer> | |||
| #include <QToolButton> | |||
| #include <initializer_list> | |||
| #include <utility> | |||
| namespace { | |||
| @@ -32,9 +40,13 @@ void selectData(QComboBox *combo_box, int value) | |||
| PlcConnectionDialog::PlcConnectionDialog( | |||
| const PlcSerialConfiguration &configuration, | |||
| PlcDiscoveryGateway &discovery_gateway, | |||
| std::function<void()> prepare_discovery, | |||
| QWidget *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); | |||
| setItemData(ui_->baudRateComboBox, {9600, 19200, 38400, 57600, 115200}); | |||
| @@ -42,22 +54,8 @@ PlcConnectionDialog::PlcConnectionDialog( | |||
| setItemData(ui_->parityComboBox, {0, 3, 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()); | |||
| 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); | |||
| selectData(ui_->baudRateComboBox, configuration.baudRate); | |||
| @@ -67,9 +65,36 @@ PlcConnectionDialog::PlcConnectionDialog( | |||
| ui_->responseTimeoutSpinBox->setValue(configuration.responseTimeoutMs); | |||
| ui_->retriesSpinBox->setValue(configuration.retries); | |||
| 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 | |||
| { | |||
| @@ -86,3 +111,139 @@ PlcSerialConfiguration PlcConnectionDialog::configuration() const | |||
| result.pollIntervalMs = ui_->pollIntervalSpinBox->value(); | |||
| 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 | |||
| #include "services/plc_communication_gateway.h" | |||
| #include "services/plc_discovery_gateway.h" | |||
| #include <QDialog> | |||
| #include <QString> | |||
| #include <functional> | |||
| #include <memory> | |||
| namespace Ui { | |||
| class PlcConnectionDialog; | |||
| } | |||
| /** PLC 串口参数对话框;只编辑配置,不负责打开串口 */ | |||
| class PlcConnectionDialog final : public QDialog | |||
| { | |||
| Q_OBJECT | |||
| public: | |||
| /** 使用现有配置初始化 PLC 参数表单 */ | |||
| explicit PlcConnectionDialog( | |||
| const PlcSerialConfiguration &configuration, | |||
| PlcDiscoveryGateway &discovery_gateway, | |||
| std::function<void()> prepare_discovery, | |||
| QWidget *parent = nullptr); | |||
| /** 释放 Designer 界面对象 */ | |||
| ~PlcConnectionDialog() override; | |||
| /** 返回表单中的串口配置,连接前由网关再次校验 */ | |||
| PlcSerialConfiguration configuration() const; | |||
| 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_; | |||
| /** 异步 PLC 自动搜索服务 */ | |||
| PlcDiscoveryGateway &discovery_gateway_; | |||
| /** 搜索前释放正式 PLC 连接占用的串口 */ | |||
| std::function<void()> prepare_discovery_; | |||
| }; | |||
| @@ -6,54 +6,94 @@ | |||
| <string>PLC 通信配置</string> | |||
| </property> | |||
| <property name="minimumSize"> | |||
| <size><width>420</width><height>0</height></size> | |||
| <size><width>440</width><height>0</height></size> | |||
| </property> | |||
| <layout class="QVBoxLayout" name="dialogLayout"> | |||
| <item> | |||
| <widget class="QGroupBox" name="serialGroupBox"> | |||
| <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 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> | |||
| </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> | |||
| </layout> | |||
| </widget> | |||
| @@ -11,9 +11,10 @@ | |||
| #include "ui_main_window.h" | |||
| #include <QAction> | |||
| #include <QDialogButtonBox> | |||
| #include <QInputDialog> | |||
| #include <QLineEdit> | |||
| #include <QObject> | |||
| #include <QPushButton> | |||
| #include <QSignalBlocker> | |||
| #include <QTabWidget> | |||
| #include <QTreeWidget> | |||
| @@ -38,6 +39,43 @@ std::string toUtf8(const QString &value) | |||
| 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 | |||
| ProjectWorkspaceController::ProjectWorkspaceController( | |||
| @@ -295,9 +333,9 @@ void ProjectWorkspaceController::updateActions() | |||
| 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; | |||
| } | |||
| @@ -317,9 +355,9 @@ void ProjectWorkspaceController::addPage() | |||
| 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; | |||
| } | |||
| @@ -362,11 +400,9 @@ void ProjectWorkspaceController::renameSelectedItem() | |||
| { | |||
| 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; | |||
| } | |||
| @@ -1,3 +1,11 @@ | |||
| /** | |||
| * @file project_workspace_controller.h | |||
| * @brief 定义工程树和当前编辑对象控制器 | |||
| * @version 1.0.0 | |||
| * @author suyu | |||
| * @date 2026-08-22 | |||
| */ | |||
| #pragma once | |||
| #include "domain/hmi_model.h" | |||
| @@ -23,16 +31,24 @@ namespace Ui { | |||
| class MainWindow; | |||
| } | |||
| // 组织工程树、当前页面/逻辑选择和工程对象的界面刷新 | |||
| /** | |||
| * 组织工程树、当前页面/逻辑选择和工程对象的界面刷新 | |||
| * | |||
| * 它不直接改 Project,所有增删改都转发给对应服务 | |||
| */ | |||
| class ProjectWorkspaceController final | |||
| { | |||
| public: | |||
| /** 报告工程操作结果,参数依次为操作名、消息和是否成功 */ | |||
| using ResultReporter = std::function<void( | |||
| const QString &action, const QString &message, bool succeeded)>; | |||
| /** 向状态栏报告一条带超时的消息 */ | |||
| using StatusReporter = std::function<void( | |||
| const QString &message, int timeout_ms)>; | |||
| /** 通知主窗口编辑状态已经发生变化 */ | |||
| using EditStateChanged = std::function<void()>; | |||
| /** 创建工程树控制器并绑定主窗口服务和编辑器 */ | |||
| ProjectWorkspaceController( | |||
| QWidget &parent, | |||
| Ui::MainWindow &ui, | |||
| @@ -52,56 +68,96 @@ public: | |||
| StatusReporter status_reporter, | |||
| EditStateChanged edit_state_changed); | |||
| /** 连接工程树动作和选择变化信号 */ | |||
| void configure(); | |||
| /** 根据当前工程重新生成工程树和编辑器内容 */ | |||
| void refresh(); | |||
| /** 根据当前选中对象更新工程树动作的可用状态 */ | |||
| void updateActions(); | |||
| /** 新增一个 HMI 页面 */ | |||
| void addPage(); | |||
| /** 新增一个控制逻辑 */ | |||
| void addLogic(); | |||
| /** 重命名当前选中的工程树项目 */ | |||
| void renameSelectedItem(); | |||
| /** 删除当前选中的工程树项目 */ | |||
| void deleteSelectedItem(); | |||
| /** 按偏移量移动当前选中的工程树项目 */ | |||
| void moveSelectedItem(int offset); | |||
| /** 把当前选中的 HMI 页面设为初始页面 */ | |||
| void setSelectedPageAsInitial(); | |||
| /** 启用或停用当前选中的控制逻辑 */ | |||
| void toggleSelectedLogicEnabled(); | |||
| /** 返回当前 HMI 页面标识 */ | |||
| const std::string ¤tHmiPageId() const; | |||
| /** 返回当前控制逻辑标识 */ | |||
| const std::string ¤tLogicId() const; | |||
| private: | |||
| /** 工程树项目的三种业务类型 */ | |||
| enum class ItemKind | |||
| { | |||
| Root = 0, | |||
| HmiPage = 1, | |||
| ControlLogic = 2 | |||
| Root = 0, // 工程树根节点 | |||
| HmiPage = 1, // HMI 页面节点 | |||
| ControlLogic = 2 // 控制逻辑节点 | |||
| }; | |||
| /** 处理工程树选择变化并刷新当前编辑器 */ | |||
| void handleSelectionChanged(); | |||
| /** 统一报告工程操作失败 */ | |||
| void reportFailure( | |||
| const QString &action, const std::string &message) const; | |||
| /** 主窗口父对象,不由控制器拥有 */ | |||
| QWidget &parent_; | |||
| /** 主窗口的 Designer 界面对象 */ | |||
| Ui::MainWindow &ui_; | |||
| /** 工程服务,不由控制器拥有 */ | |||
| ProjectService &project_service_; | |||
| /** HMI 编辑服务,不由控制器拥有 */ | |||
| HmiEditorService &hmi_editor_service_; | |||
| /** 梯形图编辑服务,不由控制器拥有 */ | |||
| LogicEditorService &logic_editor_service_; | |||
| /** 运行模式服务,不由控制器拥有 */ | |||
| RuntimeModeService &runtime_mode_service_; | |||
| /** HMI 页面导航服务,不由控制器拥有 */ | |||
| HmiNavigationService &hmi_navigation_service_; | |||
| /** 主窗口中的 HMI 编辑器 */ | |||
| HmiEditorWidget &hmi_editor_widget_; | |||
| /** 主窗口中的梯形图编辑器 */ | |||
| LogicEditorWidget &logic_editor_widget_; | |||
| /** 运行监控控件 */ | |||
| RuntimeMonitorWidget &runtime_monitor_widget_; | |||
| /** 主窗口保存的当前 HMI 页面标识 */ | |||
| std::string ¤t_hmi_page_id_; | |||
| /** 主窗口保存的当前控制逻辑标识 */ | |||
| std::string ¤t_logic_id_; | |||
| /** 显示 HMI 控件属性的回调 */ | |||
| std::function<void(const std::string &)> show_control_properties_; | |||
| /** 显示梯形图节点属性的回调 */ | |||
| std::function<void(const std::string &)> show_logic_properties_; | |||
| /** 工程操作结果回调 */ | |||
| ResultReporter result_reporter_; | |||
| /** 状态栏消息回调 */ | |||
| StatusReporter status_reporter_; | |||
| /** 编辑状态变化回调 */ | |||
| EditStateChanged edit_state_changed_; | |||
| /** 新增页面动作 */ | |||
| QAction *add_page_action_ = nullptr; | |||
| /** 新增逻辑动作 */ | |||
| QAction *add_logic_action_ = nullptr; | |||
| /** 重命名动作 */ | |||
| QAction *rename_item_action_ = nullptr; | |||
| /** 删除工程树项目动作 */ | |||
| QAction *delete_item_action_ = nullptr; | |||
| /** 上移工程树项目动作 */ | |||
| QAction *move_item_up_action_ = nullptr; | |||
| /** 下移工程树项目动作 */ | |||
| QAction *move_item_down_action_ = nullptr; | |||
| /** 设置初始页面动作 */ | |||
| QAction *set_initial_page_action_ = nullptr; | |||
| /** 启停控制逻辑动作 */ | |||
| QAction *toggle_logic_enabled_action_ = nullptr; | |||
| }; | |||
| @@ -62,6 +62,7 @@ PropertyPanelController::PropertyPanelController( | |||
| ProjectService &project_service, | |||
| HmiEditorService &hmi_editor_service, | |||
| LogicEditorService &logic_editor_service, | |||
| const HmiDefaultSettings &hmi_defaults, | |||
| std::function<std::string()> current_page_id, | |||
| std::function<std::string()> current_logic_id, | |||
| std::string &selected_control_id, | |||
| @@ -74,6 +75,7 @@ PropertyPanelController::PropertyPanelController( | |||
| project_service_(project_service), | |||
| hmi_editor_service_(hmi_editor_service), | |||
| logic_editor_service_(logic_editor_service), | |||
| hmi_defaults_(hmi_defaults), | |||
| current_page_id_(std::move(current_page_id)), | |||
| current_logic_id_(std::move(current_logic_id)), | |||
| selected_control_id_(selected_control_id), | |||
| @@ -106,6 +108,10 @@ void PropertyPanelController::configure() | |||
| 2, static_cast<int>(HmiButtonOperation::Toggle)); | |||
| ui_.buttonOperationComboBox->setItemData( | |||
| 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) | |||
| { | |||
| 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_.targetPageComboBox), | |||
| static_cast<QWidget *>(ui_.buttonOperationComboBox), | |||
| static_cast<QWidget *>(ui_.progressMinimumSpinBox), | |||
| static_cast<QWidget *>(ui_.progressMaximumSpinBox), | |||
| static_cast<QWidget *>(ui_.progressShowValueCheckBox), | |||
| static_cast<QWidget *>(ui_.dataTypeComboBox), | |||
| static_cast<QWidget *>(ui_.textColorEdit), | |||
| static_cast<QWidget *>(ui_.textColorButton), | |||
| static_cast<QWidget *>(ui_.fontSizeSpinBox), | |||
| @@ -218,12 +222,8 @@ void PropertyPanelController::showControlProperties(const std::string &control_i | |||
| ui_.targetPageComboBox->setVisible(false); | |||
| ui_.buttonOperationLabel->setVisible(false); | |||
| ui_.buttonOperationComboBox->setVisible(false); | |||
| ui_.progressMinimumLabel->setVisible(false); | |||
| ui_.progressMinimumSpinBox->setVisible(false); | |||
| ui_.progressMaximumLabel->setVisible(false); | |||
| ui_.progressMaximumSpinBox->setVisible(false); | |||
| ui_.progressShowValueLabel->setVisible(false); | |||
| ui_.progressShowValueCheckBox->setVisible(false); | |||
| ui_.dataTypeLabel->setVisible(false); | |||
| ui_.dataTypeComboBox->setVisible(false); | |||
| ui_.textColorEdit->clear(); | |||
| ui_.textColorButton->setStyleSheet(QString{}); | |||
| ui_.fontSizeSpinBox->setValue( | |||
| @@ -263,6 +263,14 @@ void PropertyPanelController::showControlProperties(const std::string &control_i | |||
| ui_.buttonOperationComboBox->findData( | |||
| 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; | |||
| ui_.targetPageLabel->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))); | |||
| } | |||
| const bool is_progress_bar = control->type == HmiControlType::ProgressBar; | |||
| ui_.progressMinimumLabel->setVisible(is_progress_bar); | |||
| ui_.progressMinimumSpinBox->setVisible(is_progress_bar); | |||
| ui_.progressMaximumLabel->setVisible(is_progress_bar); | |||
| ui_.progressMaximumSpinBox->setVisible(is_progress_bar); | |||
| ui_.progressShowValueLabel->setVisible(is_progress_bar); | |||
| ui_.progressShowValueCheckBox->setVisible(is_progress_bar); | |||
| ui_.progressMinimumSpinBox->setEnabled(is_progress_bar); | |||
| ui_.progressMaximumSpinBox->setEnabled(is_progress_bar); | |||
| ui_.progressShowValueCheckBox->setEnabled(is_progress_bar); | |||
| const HmiProgressBarConfig progress_config = control->progressBar.value_or( | |||
| HmiProgressBarConfig{}); | |||
| ui_.progressMinimumSpinBox->setValue(progress_config.minimumValue); | |||
| ui_.progressMaximumSpinBox->setValue(progress_config.maximumValue); | |||
| ui_.progressShowValueCheckBox->setChecked(progress_config.showValue); | |||
| const auto text_color = control->properties.find( | |||
| HmiAppearanceProperty::kTextColor); | |||
| ui_.textColorEdit->setText(text_color == control->properties.cend() | |||
| @@ -356,8 +348,8 @@ void PropertyPanelController::showPageProperties(const std::string &page_id) | |||
| ui_.applyPagePropertiesButton->setEnabled(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; | |||
| } | |||
| 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_.logicComparisonComboBox), | |||
| static_cast<QWidget *>(ui_.logicValueSpinBox), | |||
| static_cast<QWidget *>(ui_.logicPresetSpinBox), | |||
| static_cast<QWidget *>(ui_.applyLogicPropertiesButton)}) | |||
| { | |||
| widget->setEnabled(has_node); | |||
| @@ -391,7 +382,6 @@ void PropertyPanelController::showLogicNodeProperties(const std::string &node_id | |||
| ui_.logicNodeTypeLabel->clear(); | |||
| ui_.logicAddressAreaLabel->clear(); | |||
| ui_.logicModeComboBox->clear(); | |||
| ui_.logicPresetSpinBox->setValue(TonNodeConfig::kMinimumPresetMs); | |||
| return; | |||
| } | |||
| @@ -399,7 +389,6 @@ void PropertyPanelController::showLogicNodeProperties(const std::string &node_id | |||
| ui_.logicModeComboBox->clear(); | |||
| ui_.logicComparisonComboBox->setEnabled(false); | |||
| ui_.logicValueSpinBox->setEnabled(false); | |||
| ui_.logicPresetSpinBox->setEnabled(false); | |||
| if (const auto *contact = std::get_if<ContactNodeConfig>(&node->config)) | |||
| { | |||
| ui_.logicNodeTypeLabel->setText(QObject::tr("触点")); | |||
| @@ -426,33 +415,6 @@ void PropertyPanelController::showLogicNodeProperties(const std::string &node_id | |||
| ui_.logicModeComboBox->findData(static_cast<int>(edge->mode))); | |||
| ui_.logicModeComboBox->setEnabled(true); | |||
| } | |||
| else if (const auto *timer = std::get_if<TimerContactNodeConfig>(&node->config)) | |||
| { | |||
| ui_.logicNodeTypeLabel->setText(QObject::tr("T 触点")); | |||
| ui_.logicAddressAreaLabel->setText(QStringLiteral("T")); | |||
| ui_.logicAddressSpinBox->setValue(timer->address.index()); | |||
| ui_.logicModeComboBox->addItem( | |||
| QObject::tr("常开"), static_cast<int>(ContactMode::NormallyOpen)); | |||
| ui_.logicModeComboBox->addItem( | |||
| QObject::tr("常闭"), static_cast<int>(ContactMode::NormallyClosed)); | |||
| ui_.logicModeComboBox->setCurrentIndex( | |||
| ui_.logicModeComboBox->findData(static_cast<int>(timer->mode))); | |||
| ui_.logicModeComboBox->setEnabled(true); | |||
| } | |||
| else if (const auto *counter_contact = | |||
| std::get_if<CounterContactNodeConfig>(&node->config)) | |||
| { | |||
| ui_.logicNodeTypeLabel->setText(QObject::tr("C 触点")); | |||
| ui_.logicAddressAreaLabel->setText(QStringLiteral("C")); | |||
| ui_.logicAddressSpinBox->setValue(counter_contact->address.index()); | |||
| ui_.logicModeComboBox->addItem( | |||
| QObject::tr("常开"), static_cast<int>(ContactMode::NormallyOpen)); | |||
| ui_.logicModeComboBox->addItem( | |||
| QObject::tr("常闭"), static_cast<int>(ContactMode::NormallyClosed)); | |||
| ui_.logicModeComboBox->setCurrentIndex(ui_.logicModeComboBox->findData( | |||
| static_cast<int>(counter_contact->mode))); | |||
| ui_.logicModeComboBox->setEnabled(true); | |||
| } | |||
| else if (const auto *coil = std::get_if<CoilNodeConfig>(&node->config)) | |||
| { | |||
| ui_.logicNodeTypeLabel->setText(QObject::tr("线圈")); | |||
| @@ -482,27 +444,6 @@ void PropertyPanelController::showLogicNodeProperties(const std::string &node_id | |||
| ui_.logicValueSpinBox->setEnabled(true); | |||
| ui_.logicValueSpinBox->setValue(compare->value); | |||
| } | |||
| else if (const auto *ton = std::get_if<TonNodeConfig>(&node->config)) | |||
| { | |||
| ui_.logicNodeTypeLabel->setText(QObject::tr("TON")); | |||
| ui_.logicAddressAreaLabel->setText(QStringLiteral("T")); | |||
| ui_.logicAddressSpinBox->setValue(ton->address.index()); | |||
| ui_.logicModeComboBox->addItem(QObject::tr("非保持接通延时")); | |||
| ui_.logicModeComboBox->setEnabled(false); | |||
| ui_.logicPresetSpinBox->setEnabled(true); | |||
| ui_.logicPresetSpinBox->setValue(ton->presetMs); | |||
| } | |||
| else if (const auto *counter = std::get_if<CounterNodeConfig>(&node->config)) | |||
| { | |||
| ui_.logicNodeTypeLabel->setText( | |||
| counter->mode == CounterMode::Up ? QStringLiteral("CTU") | |||
| : QStringLiteral("CTD")); | |||
| ui_.logicAddressAreaLabel->setText(QStringLiteral("C")); | |||
| ui_.logicAddressSpinBox->setValue(counter->address.index()); | |||
| ui_.logicAddressSpinBox->setEnabled(false); | |||
| ui_.logicModeComboBox->addItem(QObject::tr("使用配置对话框编辑")); | |||
| ui_.logicModeComboBox->setEnabled(false); | |||
| } | |||
| else if (const auto *move = std::get_if<MoveNodeConfig>(&node->config)) | |||
| { | |||
| ui_.logicNodeTypeLabel->setText(QStringLiteral("MOVE")); | |||
| @@ -591,6 +532,8 @@ void PropertyPanelController::applySelectedControlProperties() | |||
| control.bounds.y = ui_.controlYSpinBox->value(); | |||
| control.bounds.width = ui_.controlWidthSpinBox->value(); | |||
| control.bounds.height = ui_.controlHeightSpinBox->value(); | |||
| control.dataType = static_cast<RegisterDataType>( | |||
| ui_.dataTypeComboBox->currentData().toInt()); | |||
| const HmiControlDescriptor *descriptor = | |||
| findHmiControlDescriptor(control.type); | |||
| if (descriptor == nullptr | |||
| @@ -623,13 +566,6 @@ void PropertyPanelController::applySelectedControlProperties() | |||
| control.pageJump = HmiPageJumpConfig{ | |||
| toUtf8(ui_.targetPageComboBox->currentData().toString())}; | |||
| } | |||
| if (control.type == HmiControlType::ProgressBar) | |||
| { | |||
| control.progressBar = HmiProgressBarConfig{ | |||
| static_cast<std::int16_t>(ui_.progressMinimumSpinBox->value()), | |||
| static_cast<std::int16_t>(ui_.progressMaximumSpinBox->value()), | |||
| ui_.progressShowValueCheckBox->isChecked()}; | |||
| } | |||
| const std::string text_color = toUtf8(ui_.textColorEdit->text()); | |||
| if (text_color.empty()) | |||
| { | |||
| @@ -726,18 +662,6 @@ void PropertyPanelController::applySelectedLogicNodeProperties() | |||
| RegisterAddress{RegisterArea::M, ui_.logicAddressSpinBox->value()}, | |||
| static_cast<EdgeMode>(ui_.logicModeComboBox->currentData().toInt())}; | |||
| } | |||
| else if (std::holds_alternative<TimerContactNodeConfig>(config)) | |||
| { | |||
| config = TimerContactNodeConfig{ | |||
| TimerAddress{ui_.logicAddressSpinBox->value()}, | |||
| static_cast<ContactMode>(ui_.logicModeComboBox->currentData().toInt())}; | |||
| } | |||
| else if (std::holds_alternative<CounterContactNodeConfig>(config)) | |||
| { | |||
| config = CounterContactNodeConfig{ | |||
| CounterAddress{ui_.logicAddressSpinBox->value()}, | |||
| static_cast<ContactMode>(ui_.logicModeComboBox->currentData().toInt())}; | |||
| } | |||
| else if (std::holds_alternative<CoilNodeConfig>(config)) | |||
| { | |||
| config = CoilNodeConfig{ | |||
| @@ -752,12 +676,6 @@ void PropertyPanelController::applySelectedLogicNodeProperties() | |||
| ui_.logicComparisonComboBox->currentData().toInt()), | |||
| static_cast<std::int16_t>(ui_.logicValueSpinBox->value())}; | |||
| } | |||
| else if (std::holds_alternative<TonNodeConfig>(config)) | |||
| { | |||
| config = TonNodeConfig{ | |||
| TimerAddress{ui_.logicAddressSpinBox->value()}, | |||
| ui_.logicPresetSpinBox->value()}; | |||
| } | |||
| else | |||
| { | |||
| LogicInstructionDialog dialog(node->config, &parent_); | |||
| @@ -1,3 +1,11 @@ | |||
| /** | |||
| * @file property_panel_controller.h | |||
| * @brief 定义 HMI 和梯形图属性面板控制器 | |||
| * @version 1.0.0 | |||
| * @author suyu | |||
| * @date 2026-08-22 | |||
| */ | |||
| #pragma once | |||
| #include "domain/control_logic_model.h" | |||
| @@ -19,21 +27,29 @@ namespace Ui { | |||
| class MainWindow; | |||
| } | |||
| // 组织 HMI/梯形图选择状态、属性面板和编辑器信号 | |||
| /** | |||
| * 组织 HMI/梯形图选择状态、属性面板和编辑器信号 | |||
| * | |||
| * 属性提交统一经过编辑服务,以便校验、原子回滚和撤销/重做 | |||
| */ | |||
| class PropertyPanelController final | |||
| { | |||
| public: | |||
| /** 报告属性操作结果,参数依次为操作名、消息和是否成功 */ | |||
| using ResultReporter = std::function<void( | |||
| const QString &action, const QString &message, bool succeeded)>; | |||
| /** 向状态栏报告一条带超时的消息 */ | |||
| using StatusReporter = std::function<void( | |||
| const QString &message, int timeout_ms)>; | |||
| /** 创建属性面板控制器并注入编辑服务和选择状态 */ | |||
| PropertyPanelController( | |||
| QWidget &parent, | |||
| Ui::MainWindow &ui, | |||
| ProjectService &project_service, | |||
| HmiEditorService &hmi_editor_service, | |||
| LogicEditorService &logic_editor_service, | |||
| const HmiDefaultSettings &hmi_defaults, | |||
| std::function<std::string()> current_page_id, | |||
| std::function<std::string()> current_logic_id, | |||
| std::string &selected_control_id, | |||
| @@ -42,39 +58,69 @@ public: | |||
| ResultReporter result_reporter, | |||
| StatusReporter status_reporter); | |||
| /** 连接属性面板按钮、输入框和服务信号 */ | |||
| void configure(); | |||
| /** 绑定 HMI 与梯形图编辑器,接收它们的选择变化 */ | |||
| void bindEditorWidgets( | |||
| HmiEditorWidget &hmi_editor_widget, | |||
| LogicEditorWidget &logic_editor_widget); | |||
| /** 显示指定 HMI 控件的属性 */ | |||
| void showControlProperties(const std::string &control_id); | |||
| /** 显示指定 HMI 页面尺寸属性 */ | |||
| void showPageProperties(const std::string &page_id); | |||
| /** 显示指定梯形图节点的属性 */ | |||
| void showLogicNodeProperties(const std::string &node_id); | |||
| /** 在当前页面添加指定类型的 HMI 控件 */ | |||
| void addHmiControl(HmiControlType type); | |||
| /** 删除当前选中的 HMI 控件 */ | |||
| void deleteSelectedControl(); | |||
| /** 提交当前 HMI 控件属性 */ | |||
| void applySelectedControlProperties(); | |||
| /** 提交当前 HMI 页面属性 */ | |||
| void applySelectedPageProperties(); | |||
| /** 提交当前梯形图节点属性 */ | |||
| void applySelectedLogicNodeProperties(); | |||
| private: | |||
| /** 显示 HMI 编辑服务返回的错误 */ | |||
| void handleHmiEditorError(const QString &message) const; | |||
| /** 显示梯形图编辑服务返回的错误 */ | |||
| void handleLogicEditorError(const QString &message) const; | |||
| /** 打开颜色选择器并写回属性输入框 */ | |||
| void chooseTextColor(); | |||
| /** 统一报告属性操作失败 */ | |||
| void reportFailure( | |||
| const QString &action, const std::string &message) const; | |||
| /** 主窗口父对象,不由控制器拥有 */ | |||
| QWidget &parent_; | |||
| /** 主窗口的 Designer 界面对象 */ | |||
| Ui::MainWindow &ui_; | |||
| /** 工程服务,不由控制器拥有 */ | |||
| ProjectService &project_service_; | |||
| /** HMI 编辑服务,不由控制器拥有 */ | |||
| HmiEditorService &hmi_editor_service_; | |||
| /** 梯形图编辑服务,不由控制器拥有 */ | |||
| LogicEditorService &logic_editor_service_; | |||
| /** 新建页面的启动期只读默认尺寸 */ | |||
| const HmiDefaultSettings &hmi_defaults_; | |||
| /** 获取当前 HMI 页面标识的回调 */ | |||
| std::function<std::string()> current_page_id_; | |||
| /** 获取当前控制逻辑标识的回调 */ | |||
| std::function<std::string()> current_logic_id_; | |||
| /** 主窗口保存的当前控件标识 */ | |||
| std::string &selected_control_id_; | |||
| /** 主窗口保存的当前逻辑节点标识 */ | |||
| std::string &selected_logic_node_id_; | |||
| /** 请求主窗口刷新工程界面的回调 */ | |||
| std::function<void()> refresh_project_ui_; | |||
| /** 属性操作结果回调 */ | |||
| ResultReporter result_reporter_; | |||
| /** 状态栏消息回调 */ | |||
| StatusReporter status_reporter_; | |||
| /** 绑定后的 HMI 编辑器 */ | |||
| HmiEditorWidget *hmi_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 | |||
| #include "domain/register_address.h" | |||
| @@ -13,22 +21,33 @@ class RegisterCommentDialog; | |||
| class RegisterCommentService; | |||
| /** 工程级 M/D 注释编辑对话框;注释不包含实时寄存器值 */ | |||
| class RegisterCommentDialog final : public QDialog | |||
| { | |||
| public: | |||
| /** 创建寄存器注释对话框并绑定注释服务 */ | |||
| explicit RegisterCommentDialog( | |||
| RegisterCommentService &service, | |||
| QWidget *parent = nullptr); | |||
| /** 释放 Designer 界面对象 */ | |||
| ~RegisterCommentDialog() override; | |||
| private: | |||
| /** 重新加载注释列表,并尽量保持原选中地址 */ | |||
| void reloadComments(const RegisterAddress *selected_address = nullptr); | |||
| /** 把当前选中地址的注释加载到编辑框 */ | |||
| void loadSelectedComment(); | |||
| /** 校验并保存当前地址的注释 */ | |||
| void saveComment(); | |||
| /** 删除当前选中地址的注释 */ | |||
| void removeComment(); | |||
| /** 从输入框读取寄存器地址 */ | |||
| RegisterAddress addressFromInputs() const; | |||
| /** 返回列表中当前选中的寄存器地址,没有选择时返回空值 */ | |||
| std::optional<RegisterAddress> selectedAddress() const; | |||
| /** Qt Designer 生成的界面对象 */ | |||
| std::unique_ptr<Ui::RegisterCommentDialog> ui_; | |||
| /** 注释读写服务,不由对话框拥有 */ | |||
| RegisterCommentService &service_; | |||
| }; | |||
| @@ -11,6 +11,7 @@ | |||
| #include <QSplitter> | |||
| #include <QComboBox> | |||
| #include <QPushButton> | |||
| #include <QSignalBlocker> | |||
| #include <QToolButton> | |||
| @@ -66,6 +67,10 @@ RuntimeMonitorWidget::RuntimeMonitorWidget( | |||
| register_monitor_service, ui_->monitorContainer); | |||
| free_monitor_widget_->setObjectName(QStringLiteral("freeMonitorWidget")); | |||
| 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_->verticalSplitter->setSizes({440, 260}); | |||
| connect(hmi_view_, &HmiEditorWidget::pageNavigationRequested, | |||
| @@ -94,6 +99,22 @@ RuntimeMonitorWidget::RuntimeMonitorWidget( | |||
| 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; | |||
| @@ -134,7 +155,7 @@ void RuntimeMonitorWidget::setMode( | |||
| { | |||
| const bool offline = mode == ApplicationMode::OfflineRunning; | |||
| const bool online = mode == ApplicationMode::OnlineRunning; | |||
| ui_->logicFrame->setVisible(offline); | |||
| ui_->logicFrame->setVisible(offline || online); | |||
| hmi_view_->setRuntimeActive(offline || online); | |||
| if (!offline && !online) | |||
| { | |||
| @@ -142,19 +163,50 @@ void RuntimeMonitorWidget::setMode( | |||
| } | |||
| setFreeMonitorWriteEnabled(offline | |||
| || (online && plc_state == PlcConnectionState::Connected)); | |||
| if (!offline) | |||
| if (!offline && !online) | |||
| { | |||
| logic_view_->clearRuntimeTrace(); | |||
| latest_trace_.clear(); | |||
| latest_fault_node_id_.clear(); | |||
| has_logic_trace_ = false; | |||
| } | |||
| ui_->logicLabel->setText( | |||
| online ? tr("本地推算轨迹") : tr("梯形图运行状态")); | |||
| ui_->logicNoticeLabel->setVisible(online); | |||
| ui_->modeLabel->setText( | |||
| 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); | |||
| } | |||
| 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) | |||
| { | |||
| hmi_view_->setRuntimeWriteEnabled(enabled); | |||
| @@ -168,6 +220,7 @@ void RuntimeMonitorWidget::setFreeMonitorWriteEnabled(bool enabled) | |||
| void RuntimeMonitorWidget::refreshValues( | |||
| ApplicationMode mode, PlcConnectionState plc_state) | |||
| { | |||
| // HMI、报警和自由监控共享同一个活动寄存器仓库 | |||
| hmi_view_->refreshRuntimeValues(); | |||
| 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 | |||
| #include "domain/runtime_state.h" | |||
| @@ -24,11 +32,13 @@ class LogicEditorWidget; | |||
| class RegisterMonitorService; | |||
| class ProjectService; | |||
| /** 唯一的工程师运行监控投影,组合 HMI、梯形图轨迹和自由监控 */ | |||
| class RuntimeMonitorWidget final : public QWidget | |||
| { | |||
| Q_OBJECT | |||
| public: | |||
| /** 创建组合 HMI、梯形图和自由监控的运行投影 */ | |||
| RuntimeMonitorWidget( | |||
| HmiEditorService &hmi_editor_service, | |||
| HmiRuntimeService &hmi_runtime_service, | |||
| @@ -38,34 +48,68 @@ public: | |||
| AlarmService &alarm_service, | |||
| RegisterMonitorService ®ister_monitor_service, | |||
| QWidget *parent = nullptr); | |||
| /** 释放组合的运行监控子控件 */ | |||
| ~RuntimeMonitorWidget() override; | |||
| /** 将当前页面和控制逻辑绑定到运行监控界面 */ | |||
| void setProjectObjects(const std::string &page_id, const std::string &logic_id); | |||
| /** 切换运行监控中显示的 HMI 页面 */ | |||
| void setRuntimePage(const std::string &page_id); | |||
| /** 根据模式决定是否显示本地逻辑轨迹以及是否允许写入 */ | |||
| void setMode(ApplicationMode mode, PlcConnectionState plc_state); | |||
| /** 切换专用用户运行版的简化操作栏 */ | |||
| void setUserRuntimeMode(bool enabled); | |||
| /** 从寄存器仓库刷新 HMI 和自由监控中的当前值 */ | |||
| void refreshValues(ApplicationMode mode, PlcConnectionState plc_state); | |||
| /** 设置 HMI 控件是否允许写入运行值 */ | |||
| void setHmiWriteEnabled(bool enabled); | |||
| /** 设置自由监控是否允许写入寄存器 */ | |||
| void setFreeMonitorWriteEnabled(bool enabled); | |||
| /** 接收离线轨迹或基于 PLC 缓存计算的真机本地推算轨迹 */ | |||
| void setLogicTrace(const LogicTraceSnapshot &trace, const std::string &fault_node_id = {}); | |||
| /** 返回自由监控子控件,供上层刷新或连接信号 */ | |||
| FreeMonitorWidget *freeMonitorWidget() const; | |||
| /** 返回当前选中的控制逻辑标识 */ | |||
| std::string selectedLogicId() const; | |||
| signals: | |||
| /** 页面跳转失败时发出可直接显示的错误信息 */ | |||
| void navigationFailed(const QString &message); | |||
| /** 用户请求退出运行态 */ | |||
| void exitRequested(); | |||
| /** 用户在专用运行版中请求切换模式 */ | |||
| void modeChangeRequested(int mode); | |||
| /** 用户请求打开 PLC 配置窗口 */ | |||
| void plcConfigurationRequested(); | |||
| /** 用户请求断开 PLC */ | |||
| void plcDisconnectRequested(); | |||
| private: | |||
| /** 显示指定的运行监控页面 */ | |||
| void showRuntimePage(const std::string &page_id); | |||
| /** 选中指定控制逻辑并刷新梯形图投影 */ | |||
| void selectRuntimeLogic(const std::string &logic_id); | |||
| /** Qt Designer 生成的界面对象 */ | |||
| std::unique_ptr<Ui::RuntimeMonitorWidget> ui_; | |||
| /** 工程服务,不由控件拥有 */ | |||
| ProjectService &project_service_; | |||
| /** HMI 页面导航服务,不由控件拥有 */ | |||
| HmiNavigationService &hmi_navigation_service_; | |||
| /** 运行监控中的 HMI 视图 */ | |||
| HmiEditorWidget *hmi_view_ = nullptr; | |||
| /** 运行监控中的梯形图视图 */ | |||
| LogicEditorWidget *logic_view_ = nullptr; | |||
| /** 运行监控中的自由监控控件 */ | |||
| FreeMonitorWidget *free_monitor_widget_ = nullptr; | |||
| /** 当前选中的控制逻辑标识 */ | |||
| std::string selected_logic_id_; | |||
| /** 最近一次收到的本地逻辑执行轨迹 */ | |||
| LogicTraceSnapshot latest_trace_; | |||
| /** 最近一次本地执行故障节点标识 */ | |||
| std::string latest_fault_node_id_; | |||
| /** 是否已经收到过可显示的逻辑轨迹 */ | |||
| 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><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="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> | |||
| </layout> | |||
| </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> | |||
| </layout> | |||
| </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> | |||
| </layout> | |||
| </widget> | |||
| @@ -6,7 +6,9 @@ | |||
| #include <QWidget> | |||
| RuntimeMonitorWindow::RuntimeMonitorWindow(QWidget *parent) | |||
| : QMainWindow(parent, Qt::Window), | |||
| : QMainWindow( | |||
| parent, | |||
| Qt::Window | Qt::CustomizeWindowHint | Qt::WindowTitleHint), | |||
| ui_(std::make_unique<Ui::RuntimeMonitorWindow>()) | |||
| { | |||
| ui_->setupUi(this); | |||
| @@ -44,9 +46,9 @@ void RuntimeMonitorWindow::closeForApplicationExit() | |||
| void RuntimeMonitorWindow::closeEvent(QCloseEvent *event) | |||
| { | |||
| // 运行期间禁止通过系统关闭路径离开运行界面,只允许应用退出时关闭 | |||
| if (runtime_active_ && !application_exit_) | |||
| { | |||
| emit exitRequested(); | |||
| event->ignore(); | |||
| return; | |||
| } | |||
| @@ -1,3 +1,11 @@ | |||
| /** | |||
| * @file runtime_monitor_window.h | |||
| * @brief 定义独立的工程师运行监控窗口 | |||
| * @version 1.0.0 | |||
| * @author suyu | |||
| * @date 2026-08-22 | |||
| */ | |||
| #pragma once | |||
| #include <QMainWindow> | |||
| @@ -11,28 +19,35 @@ class RuntimeMonitorWindow; | |||
| class QCloseEvent; | |||
| class QWidget; | |||
| // 承载唯一工程师运行监控投影的独立顶层窗口 | |||
| /** 承载唯一工程师运行监控投影的独立顶层窗口 */ | |||
| class RuntimeMonitorWindow final : public QMainWindow | |||
| { | |||
| Q_OBJECT | |||
| public: | |||
| /** 创建运行监控顶层窗口 */ | |||
| explicit RuntimeMonitorWindow(QWidget *parent = nullptr); | |||
| /** 释放窗口和 Designer 界面对象 */ | |||
| ~RuntimeMonitorWindow() override; | |||
| /** 将运行监控控件放入窗口中央区域 */ | |||
| void setMonitorWidget(QWidget &widget); | |||
| /** 进入运行态时显示并最大化窗口 */ | |||
| void showForRuntime(); | |||
| /** 返回编辑态时隐藏窗口但保留唯一监控控件 */ | |||
| void hideForEditing(); | |||
| /** 应用退出时允许真正关闭窗口 */ | |||
| void closeForApplicationExit(); | |||
| signals: | |||
| void exitRequested(); | |||
| protected: | |||
| /** 仅在应用退出时放行窗口关闭事件 */ | |||
| void closeEvent(QCloseEvent *event) override; | |||
| private: | |||
| /** Qt Designer 生成的窗口界面对象 */ | |||
| std::unique_ptr<Ui::RuntimeMonitorWindow> ui_; | |||
| /** 当前是否处于运行态显示阶段 */ | |||
| bool runtime_active_ = false; | |||
| /** 应用是否正在退出,决定关闭事件是否真正销毁窗口 */ | |||
| bool application_exit_ = false; | |||
| }; | |||
| @@ -98,21 +98,13 @@ void RuntimePanelController::configure() | |||
| runtime_exit_requester_(); | |||
| } | |||
| }); | |||
| QObject::connect(runtime_monitor_window_.get(), | |||
| &RuntimeMonitorWindow::exitRequested, | |||
| &parent_, [this] | |||
| { | |||
| if (runtime_exit_requester_) | |||
| { | |||
| runtime_exit_requester_(); | |||
| } | |||
| }); | |||
| QObject::connect(runtime_monitor_widget_->freeMonitorWidget(), | |||
| &FreeMonitorWidget::monitorAddressesChanged, | |||
| &parent_, [this] | |||
| { | |||
| runtime_mode_service_.setMonitorAddresses( | |||
| register_monitor_service_.addresses()); | |||
| register_monitor_service_.pollAddresses(), | |||
| register_monitor_service_.float32Starts()); | |||
| runtime_monitor_widget_->refreshValues( | |||
| runtime_mode_service_.mode(), | |||
| runtime_mode_service_.plcConnectionState()); | |||
| @@ -129,6 +121,36 @@ void RuntimePanelController::configure() | |||
| status_reporter_(message, 5000); | |||
| 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_->setInterval(150); | |||
| QObject::connect(runtime_refresh_timer_, &QTimer::timeout, | |||
| @@ -142,6 +164,28 @@ void RuntimePanelController::configure() | |||
| &OfflineSimulationService::scanCompleted, | |||
| &parent_, [this] { handleScanCompleted(); }, | |||
| 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 | |||
| @@ -158,9 +202,13 @@ void RuntimePanelController::enterRuntime( | |||
| if (!runtime_session_active_) | |||
| { | |||
| runtime_monitor_widget_->setProjectObjects(page_id, logic_id); | |||
| // 运行窗口只创建一次,后续进入运行态复用同一个监控投影 | |||
| runtime_session_active_ = true; | |||
| } | |||
| runtime_monitor_widget_->freeMonitorWidget()->reloadAddresses(); | |||
| runtime_monitor_widget_->setMode(mode, plc_state); | |||
| runtime_monitor_window_->setWindowTitle( | |||
| fromUtf8(project_service_.project().metadata.name)); | |||
| runtime_monitor_window_->showForRuntime(); | |||
| } | |||
| @@ -201,6 +249,40 @@ void RuntimePanelController::updateSimulationUi(bool report_fault) | |||
| runtime_mode_service_.mode(), | |||
| 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) | |||
| { | |||
| case SimulationState::Running: | |||
| @@ -273,6 +355,7 @@ void RuntimePanelController::updateSimulationUi(bool report_fault) | |||
| void RuntimePanelController::handleRuntimeTimer() | |||
| { | |||
| // 定时器只刷新投影;离线按定时器扫描,真机在完整 PLC 轮询后推算 | |||
| if (runtime_mode_service_.mode() == ApplicationMode::Editing) | |||
| { | |||
| return; | |||
| @@ -295,13 +378,24 @@ void RuntimePanelController::handleSimulationStateChanged() | |||
| 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_(); | |||
| 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) | |||
| { | |||
| 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 | |||
| #include "domain/runtime_state.h" | |||
| @@ -25,15 +33,19 @@ class RuntimeMonitorWindow; | |||
| class QTimer; | |||
| class QWidget; | |||
| // 组织运行监控工作台、运行刷新定时器和离线执行器反馈 | |||
| /** 组织运行监控窗口、刷新定时器和本地逻辑执行器反馈 */ | |||
| class RuntimePanelController final | |||
| { | |||
| public: | |||
| /** 向状态栏报告一条带超时的消息 */ | |||
| using StatusReporter = std::function<void( | |||
| const QString &message, int timeout_ms)>; | |||
| /** 向输出面板报告一条消息 */ | |||
| using OutputReporter = std::function<void(const QString &message)>; | |||
| /** 请求上层退出当前运行态 */ | |||
| using RuntimeExitRequester = std::function<void()>; | |||
| /** 创建运行面板控制器并注入运行模式、编辑器和监控窗口依赖 */ | |||
| RuntimePanelController( | |||
| QWidget &parent, | |||
| RuntimeModeService &runtime_mode_service, | |||
| @@ -53,44 +65,85 @@ public: | |||
| StatusReporter status_reporter, | |||
| OutputReporter output_reporter, | |||
| RuntimeExitRequester runtime_exit_requester); | |||
| /** 停止刷新并释放控制器拥有的运行监控窗口 */ | |||
| ~RuntimePanelController(); | |||
| /** 连接运行监控相关信号并完成初始配置 */ | |||
| 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( | |||
| const std::string &page_id, | |||
| const std::string &logic_id, | |||
| ApplicationMode mode, | |||
| PlcConnectionState plc_state); | |||
| /** 停止刷新并隐藏监控窗口,离线服务的停止由运行模式服务负责 */ | |||
| void leaveRuntime(ApplicationMode mode, PlcConnectionState plc_state); | |||
| /** 应用退出时关闭运行监控窗口并停止相关刷新 */ | |||
| void closeForApplicationExit(); | |||
| /** 根据当前模式的本地执行器状态刷新运行界面 */ | |||
| void updateSimulationUi(bool report_fault); | |||
| /** 返回当前唯一的运行监控控件 */ | |||
| RuntimeMonitorWidget *runtimeMonitorWidget() const; | |||
| private: | |||
| /** 处理运行刷新定时器到期事件 */ | |||
| void handleRuntimeTimer(); | |||
| /** 处理离线或真机只读轨迹执行器状态变化 */ | |||
| void handleSimulationStateChanged(); | |||
| /** 处理一次离线扫描完成事件 */ | |||
| void handleScanCompleted(); | |||
| /** 主窗口父对象,不由控制器拥有 */ | |||
| QWidget &parent_; | |||
| /** 运行模式服务,不由控制器拥有 */ | |||
| RuntimeModeService &runtime_mode_service_; | |||
| /** 工程服务,不由控制器拥有 */ | |||
| ProjectService &project_service_; | |||
| /** HMI 编辑服务,不由控制器拥有 */ | |||
| HmiEditorService &hmi_editor_service_; | |||
| /** HMI 运行服务,不由控制器拥有 */ | |||
| HmiRuntimeService &hmi_runtime_service_; | |||
| /** 梯形图编辑服务,不由控制器拥有 */ | |||
| LogicEditorService &logic_editor_service_; | |||
| /** HMI 页面导航服务,不由控制器拥有 */ | |||
| HmiNavigationService &hmi_navigation_service_; | |||
| /** 报警服务,不由控制器拥有 */ | |||
| AlarmService &alarm_service_; | |||
| /** 寄存器监控服务,不由控制器拥有 */ | |||
| RegisterMonitorService ®ister_monitor_service_; | |||
| /** 主窗口中的 HMI 编辑器,不由控制器拥有 */ | |||
| HmiEditorWidget &hmi_editor_widget_; | |||
| /** 主窗口中的梯形图编辑器,不由控制器拥有 */ | |||
| LogicEditorWidget &logic_editor_widget_; | |||
| /** 显示当前本地执行器状态的标签 */ | |||
| QLabel &executor_status_label_; | |||
| /** 获取当前控制逻辑标识的回调 */ | |||
| std::function<std::string()> current_logic_id_; | |||
| /** 切换当前控制逻辑的回调 */ | |||
| std::function<void(const std::string &)> select_logic_; | |||
| /** 显示控制逻辑属性的回调 */ | |||
| std::function<void(const std::string &)> show_logic_properties_; | |||
| /** 向状态栏报告消息的回调 */ | |||
| StatusReporter status_reporter_; | |||
| /** 向输出面板报告消息的回调 */ | |||
| OutputReporter output_reporter_; | |||
| /** 请求主窗口退出运行态的回调 */ | |||
| RuntimeExitRequester runtime_exit_requester_; | |||
| /** 唯一的运行监控顶层窗口 */ | |||
| std::unique_ptr<RuntimeMonitorWindow> runtime_monitor_window_; | |||
| /** 运行监控窗口中的主控件,窗口拥有它 */ | |||
| RuntimeMonitorWidget *runtime_monitor_widget_ = nullptr; | |||
| /** 定时刷新运行监控数据的计时器 */ | |||
| QTimer *runtime_refresh_timer_ = nullptr; | |||
| /** 当前是否已经进入运行监控会话 */ | |||
| bool runtime_session_active_ = false; | |||
| 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)); | |||
| break; | |||
| } | |||
| case UiIcon::ProgressBar: | |||
| { | |||
| painter.drawRoundedRect(QRectF(2, 8, 20, 8), 2, 2); | |||
| painter.setPen(Qt::NoPen); | |||
| painter.setBrush(kAccent); | |||
| painter.drawRoundedRect(QRectF(4, 10, 11, 4), 1, 1); | |||
| break; | |||
| } | |||
| case UiIcon::Text: | |||
| { | |||
| painter.drawLine(QPointF(5, 5), QPointF(19, 5)); | |||
| @@ -410,16 +402,6 @@ QPixmap renderIcon(UiIcon icon, int size) | |||
| drawContact(painter, QStringLiteral("N"), false); | |||
| break; | |||
| } | |||
| case UiIcon::TimerContact: | |||
| { | |||
| drawContact(painter, QStringLiteral("T"), false); | |||
| break; | |||
| } | |||
| case UiIcon::CounterContact: | |||
| { | |||
| drawContact(painter, QStringLiteral("C"), false); | |||
| break; | |||
| } | |||
| case UiIcon::Coil: | |||
| { | |||
| drawCoil(painter, {}); | |||
| @@ -435,21 +417,6 @@ QPixmap renderIcon(UiIcon icon, int size) | |||
| drawCoil(painter, QStringLiteral("R")); | |||
| break; | |||
| } | |||
| case UiIcon::Ton: | |||
| { | |||
| drawInstructionBlock(painter, QStringLiteral("TON")); | |||
| break; | |||
| } | |||
| case UiIcon::Ctu: | |||
| { | |||
| drawInstructionBlock(painter, QStringLiteral("CTU")); | |||
| break; | |||
| } | |||
| case UiIcon::Ctd: | |||
| { | |||
| drawInstructionBlock(painter, QStringLiteral("CTD")); | |||
| break; | |||
| } | |||
| case UiIcon::Move: | |||
| { | |||
| painter.drawRect(QRectF(2, 6, 6, 12)); | |||
| @@ -1,58 +1,66 @@ | |||
| /** | |||
| * @file toolbar_icon_factory.h | |||
| * @brief 定义工具栏图标类型和图标创建函数 | |||
| * @version 1.0.0 | |||
| * @author suyu | |||
| * @date 2026-08-22 | |||
| */ | |||
| #pragma once | |||
| #include <QIcon> | |||
| /** 工具栏中使用的语义图标类型 */ | |||
| enum class UiIcon | |||
| { | |||
| Edit, | |||
| RunOffline, | |||
| RunOnline, | |||
| NewDocument, | |||
| Save, | |||
| SaveAs, | |||
| Open, | |||
| Undo, | |||
| Redo, | |||
| Exit, | |||
| PlcSettings, | |||
| Disconnect, | |||
| RegisterComments, | |||
| HmiButton, | |||
| Indicator, | |||
| NumericDisplay, | |||
| NumericInput, | |||
| ProgressBar, | |||
| Text, | |||
| PageJump, | |||
| AlarmList, | |||
| AlarmSettings, | |||
| Delete, | |||
| AddRung, | |||
| HorizontalWire, | |||
| VerticalWire, | |||
| ParallelBranch, | |||
| NormallyOpenContact, | |||
| NormallyClosedContact, | |||
| RisingEdgeContact, | |||
| FallingEdgeContact, | |||
| TimerContact, | |||
| CounterContact, | |||
| Coil, | |||
| SetCoil, | |||
| ResetCoil, | |||
| Ton, | |||
| Ctu, | |||
| Ctd, | |||
| Move, | |||
| Add, | |||
| Subtract, | |||
| Compare, | |||
| Comment, | |||
| More, | |||
| HmiPage, | |||
| Logic, | |||
| Runtime, | |||
| ClearList | |||
| 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); | |||