| @@ -45,6 +45,8 @@ tmp/ | |||
| # Local draft documents, not part of the project source of truth | |||
| /docs/需求规格书.md | |||
| /docs/设计方案书.md | |||
| *.doc | |||
| *.docx | |||
| # Local secrets and machine-specific configuration | |||
| .env | |||
| @@ -69,3 +71,6 @@ Desktop.ini | |||
| /代码 | |||
| /other_version | |||
| /app_copy | |||
| 当前窗口需求.md | |||
| /类图Mermaid | |||
| /苏宇_综合平台编程器_2026.8.31 | |||
| @@ -28,6 +28,8 @@ | |||
| - 应用配置文件:`docs/用户使用/应用配置说明.md` | |||
| - 用户运行程序导出:`docs/用户使用/用户运行程序导出说明.md` | |||
| - 数据监控与离线初始值:`docs/用户使用/数据监控与离线初始值说明.md` | |||
| - 状态文本控件:`docs/用户使用/状态文本控件说明.md` | |||
| - 鼠标画线与删线:`docs/用户使用/鼠标画线与删线说明.md` | |||
| - 工程 JSON 格式:`docs/工程格式说明.md` | |||
| - 开发阶段顺序:`docs/开发顺序.md`(阶段参考,当前状态以 handoff 为准) | |||
| - 测试约定:`docs/测试约定.md` | |||
| @@ -46,10 +48,16 @@ | |||
| - 复杂 PowerShell 逻辑写入 `.ps1` 后执行,避免嵌套引号;排查兼容问题时先确认 `$PSVersionTable.PSVersion` 和 `$PSVersionTable.PSEdition` | |||
| - 在独立构建目录运行 qmake 和 `mingw32-make`,不要把生成物写进 `app/` | |||
| - 项目编译运行:`pwsh -NoLogo -NoProfile -File .\scripts\build_and_run_qt.ps1` | |||
| - 测试编译运行:`pwsh -NoLogo -NoProfile -File .\scripts\run_qt_tests.ps1 -Configuration Release` | |||
| - 测试编译运行:`pwsh -NoLogo -NoProfile -File .\scripts\run_qt_tests.ps1 -Configuration Release -Suite All` | |||
| - 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'` | |||
| ## 实现原则 | |||
| - 新功能和重构默认不兼容旧代码;直接同步修改现有调用方,不为旧调用方式保留默认参数、重载、适配层、回退分支或废弃接口,除非用户明确要求兼容 | |||
| - 生产代码只提供实际业务运行需要的接口;不得为了让测试少改代码或方便构造测试对象,在生产代码中增加无业务用途的公开接口、默认构造入口、全局默认对象或其他测试专用兼容逻辑,测试应通过测试夹具、显式依赖注入或测试替身适配生产接口 | |||
| - 当前工程 JSON 只支持严格的 `formatVersion: "4.0"`;以后修改 JSON 格式时直接将生产代码、测试和格式文档整体升级到新的唯一版本,不为旧工程格式保留读取、写入、迁移、双版本解析或兼容分支,旧版本文件应明确拒绝,除非用户明确要求兼容 | |||
| ## 代码与架构约束 | |||
| - 保持 `UI -> Services -> Domain` 依赖方向,基础设施实现服务契约;`domain` 不依赖 Qt、串口或文件系统 | |||
| @@ -20,6 +20,8 @@ SOURCES += \ | |||
| src/ui/logic_instruction_dialog.cpp \ | |||
| src/ui/alarm_configuration_dialog.cpp \ | |||
| src/ui/register_comment_dialog.cpp \ | |||
| src/ui/status_text_dialog.cpp \ | |||
| src/ui/button_extension_dialog.cpp \ | |||
| src/ui/plc_connection_dialog.cpp \ | |||
| src/ui/free_monitor_widget.cpp \ | |||
| src/ui/runtime_monitor_window.cpp \ | |||
| @@ -51,6 +53,7 @@ SOURCES += \ | |||
| src/services/offline_simulation_service.cpp \ | |||
| src/services/online_logic_monitor_service.cpp \ | |||
| src/services/runtime_mode_service.cpp \ | |||
| src/services/user_runtime_plc_recovery_policy.cpp \ | |||
| src/services/plc_discovery_gateway.cpp \ | |||
| src/services/register_monitor_service.cpp \ | |||
| src/services/register_comment_service.cpp \ | |||
| @@ -59,6 +62,8 @@ SOURCES += \ | |||
| src/infrastructure/plc_communication_service.cpp \ | |||
| src/infrastructure/plc_discovery_service.cpp \ | |||
| src/infrastructure/application_settings_loader.cpp \ | |||
| src/infrastructure/runtime_settings_loader.cpp \ | |||
| src/infrastructure/runtime_project_bundle.cpp \ | |||
| src/infrastructure/json_project_storage.cpp \ | |||
| src/ui/hmi_editor_widget.cpp \ | |||
| src/ui/logic_editor_widget.cpp | |||
| @@ -68,6 +73,8 @@ HEADERS += \ | |||
| src/ui/logic_instruction_dialog.h \ | |||
| src/ui/alarm_configuration_dialog.h \ | |||
| src/ui/register_comment_dialog.h \ | |||
| src/ui/status_text_dialog.h \ | |||
| src/ui/button_extension_dialog.h \ | |||
| src/ui/plc_connection_dialog.h \ | |||
| src/ui/free_monitor_widget.h \ | |||
| src/ui/runtime_monitor_window.h \ | |||
| @@ -103,6 +110,7 @@ HEADERS += \ | |||
| src/services/offline_simulation_service.h \ | |||
| src/services/online_logic_monitor_service.h \ | |||
| src/services/runtime_mode_service.h \ | |||
| src/services/user_runtime_plc_recovery_policy.h \ | |||
| src/services/plc_discovery_gateway.h \ | |||
| src/services/register_monitor_service.h \ | |||
| src/services/register_comment_service.h \ | |||
| @@ -113,6 +121,8 @@ HEADERS += \ | |||
| src/infrastructure/plc_communication_service.h \ | |||
| src/infrastructure/plc_discovery_service.h \ | |||
| src/infrastructure/application_settings_loader.h \ | |||
| src/infrastructure/runtime_settings_loader.h \ | |||
| src/infrastructure/runtime_project_bundle.h \ | |||
| src/infrastructure/json_project_storage.h \ | |||
| src/ui/hmi_editor_widget.h \ | |||
| src/ui/logic_editor_widget.h | |||
| @@ -122,12 +132,9 @@ FORMS += \ | |||
| src/ui/logic_instruction_dialog.ui \ | |||
| src/ui/alarm_configuration_dialog.ui \ | |||
| src/ui/register_comment_dialog.ui \ | |||
| src/ui/status_text_dialog.ui \ | |||
| src/ui/button_extension_dialog.ui \ | |||
| src/ui/plc_connection_dialog.ui \ | |||
| src/ui/free_monitor_widget.ui \ | |||
| src/ui/runtime_monitor_window.ui \ | |||
| src/ui/runtime_monitor_widget.ui | |||
| # 专用用户运行版打包时由脚本传入生成的工程资源;普通编程器构建不包含工程 | |||
| !isEmpty(RUNTIME_PROJECT_RESOURCE) { | |||
| RESOURCES += $$RUNTIME_PROJECT_RESOURCE | |||
| } | |||
| @@ -1,47 +1,61 @@ | |||
| #include "active_register_repository.h" | |||
| // 使用初始仓库作为当前寄存器访问目标 | |||
| ActiveRegisterRepository::ActiveRegisterRepository(RegisterRepository &initial_repository) | |||
| : repository_(&initial_repository) | |||
| { | |||
| } | |||
| // 切换后续读写请求的转发目标,不在仓库之间复制寄存器值 | |||
| void ActiveRegisterRepository::use(RegisterRepository &repository) | |||
| { | |||
| // 只替换转发目标,不复制寄存器值;切换模式必须使用各自数据源 | |||
| repository_ = &repository; | |||
| } | |||
| // 将 M 位读取请求转发给当前活动仓库 | |||
| BitReadResult ActiveRegisterRepository::readBit(const RegisterAddress &address) const | |||
| { | |||
| // 代理本身不保存寄存器数据,结果由实际仓库负责提供 | |||
| return repository_->readBit(address); | |||
| } | |||
| // 将 M 位写入请求转发给当前活动仓库 | |||
| RegisterWriteResult ActiveRegisterRepository::writeBit( | |||
| const RegisterAddress &address, bool value) | |||
| { | |||
| // 真机模式会转发到 PLC 仓库,离线模式会转发到虚拟仓库 | |||
| return repository_->writeBit(address, value); | |||
| } | |||
| // 将单个 D 字读取请求转发给当前活动仓库 | |||
| WordReadResult ActiveRegisterRepository::readWord(const RegisterAddress &address) const | |||
| { | |||
| // 真机模式读取 PLC 最近一次成功轮询的 D 缓存 | |||
| return repository_->readWord(address); | |||
| } | |||
| // 将单个 D 字写入请求转发给当前活动仓库 | |||
| RegisterWriteResult ActiveRegisterRepository::writeWord( | |||
| const RegisterAddress &address, std::int16_t value) | |||
| { | |||
| // 写入目标由当前运行模式选择,代理本身不保存 D 值 | |||
| return repository_->writeWord(address, value); | |||
| } | |||
| WordPairReadResult ActiveRegisterRepository::readWordPair( | |||
| const RegisterAddress &address) const | |||
| // 将连续 D 字读取请求转发给当前活动仓库 | |||
| WordsReadResult ActiveRegisterRepository::readWords( | |||
| const RegisterAddress &address, int count) const | |||
| { | |||
| return repository_->readWordPair(address); | |||
| // 连续 D 读取保持同一活动仓库,避免跨数据源拼接数值 | |||
| return repository_->readWords(address, count); | |||
| } | |||
| RegisterWriteResult ActiveRegisterRepository::writeWordPair( | |||
| // 将连续 D 字写入请求转发给当前活动仓库 | |||
| RegisterWriteResult ActiveRegisterRepository::writeWords( | |||
| const RegisterAddress &address, | |||
| const std::array<std::int16_t, 2> &values) | |||
| const std::vector<std::int16_t> &values) | |||
| { | |||
| return repository_->writeWordPair(address, values); | |||
| // 连续 D 写入整体转发,真机模式由 PLC 通信层一次发送 | |||
| return repository_->writeWords(address, values); | |||
| } | |||
| @@ -10,16 +10,23 @@ public: | |||
| // 切换活动仓库;运行模式服务负责保证切换时机和生命周期 | |||
| void use(RegisterRepository &repository); | |||
| // 从当前活动仓库读取一个 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( | |||
| // 从当前活动仓库连续读取多个 D 区字值 | |||
| WordsReadResult readWords( | |||
| const RegisterAddress &address, int count) const override; | |||
| // 向当前活动仓库连续写入多个 D 区字值 | |||
| RegisterWriteResult writeWords( | |||
| const RegisterAddress &address, | |||
| const std::array<std::int16_t, 2> &values) override; | |||
| const std::vector<std::int16_t> &values) override; | |||
| private: | |||
| RegisterRepository *repository_ = nullptr; | |||
| RegisterRepository *repository_ = nullptr; // 当前接收寄存器读写请求的仓库 | |||
| }; | |||
| @@ -4,8 +4,10 @@ | |||
| namespace { | |||
| // 在调用方提供错误字符串时保存校验失败原因 | |||
| void setError(std::string *error, const std::string &message) | |||
| { | |||
| // 错误字符串是可选输出,不提供时只返回校验结果 | |||
| if (error != nullptr) | |||
| { | |||
| *error = message; | |||
| @@ -14,6 +16,7 @@ void setError(std::string *error, const std::string &message) | |||
| } // namespace | |||
| // 校验报警定义的文本、地址和触发条件是否匹配 | |||
| bool AlarmDefinition::validate(std::string *error) const | |||
| { | |||
| if (id.empty()) | |||
| @@ -41,15 +44,18 @@ bool AlarmDefinition::validate(std::string *error) const | |||
| setError(error, "报警定义使用了无效地址"); | |||
| return false; | |||
| } | |||
| if (condition == AlarmCondition::MOn) | |||
| // M ON/OFF 只能监控 M 区,避免把位条件用于 D 字地址 | |||
| if (condition == AlarmCondition::MOn | |||
| || condition == AlarmCondition::MOff) | |||
| { | |||
| if (address.area() != RegisterArea::M) | |||
| { | |||
| setError(error, "M ON 报警必须使用 M 地址"); | |||
| setError(error, "M ON/OFF 报警必须使用 M 地址"); | |||
| return false; | |||
| } | |||
| return true; | |||
| } | |||
| // D 高低限只能监控 D 区,阈值字段由运行时服务解释 | |||
| if (condition == AlarmCondition::DHigh | |||
| || condition == AlarmCondition::DLow) | |||
| { | |||
| @@ -5,15 +5,16 @@ | |||
| #include <cstdint> | |||
| #include <string> | |||
| // 报警触发条件:M 位为 1,或 D 字高于/低于阈值 | |||
| // 报警触发条件的类型 | |||
| enum class AlarmCondition | |||
| { | |||
| MOn, // M 位为 1 时触发 | |||
| MOff, // M 位为 0 时触发 | |||
| DHigh, // D 寄存器值高于阈值时触发 | |||
| DLow // D 寄存器值低于阈值时触发 | |||
| }; | |||
| // 可保存的报警定义;运行时 AlarmService 根据此定义生成报警记录 | |||
| // 可保存的报警定义,运行时服务根据它生成报警记录 | |||
| struct AlarmDefinition | |||
| { | |||
| std::string id; // 报警定义的唯一 ID | |||
| @@ -22,6 +23,6 @@ struct AlarmDefinition | |||
| std::int16_t threshold = 0; // D 地址比较时使用的阈值 | |||
| std::string message; // 报警触发后显示的提示文字 | |||
| // 校验标识、地址区域、消息长度和条件之间的组合是否有效 | |||
| // 校验标识、地址区域、消息长度和触发条件是否匹配 | |||
| bool validate(std::string *error = nullptr) const; | |||
| }; | |||
| @@ -9,29 +9,25 @@ | |||
| #include <variant> | |||
| #include <vector> | |||
| // 普通触点的导通方式;常闭触点会对读取到的位值取反 | |||
| enum class ContactMode | |||
| { | |||
| NormallyOpen, // 常开:位值为 1 时触点导通 | |||
| NormallyClosed // 常闭:位值为 0 时触点导通 | |||
| NormallyOpen, // 常开触点,M 为 1 时条件成立 | |||
| NormallyClosed // 常闭触点,M 为 0 时条件成立 | |||
| }; | |||
| // 线圈写入方式:普通写入、置位保持、复位清零 | |||
| enum class CoilMode | |||
| { | |||
| Normal, // 普通写入:直接写入当前逻辑结果 | |||
| Set, // 置位:逻辑结果为 1 时保持为 1 | |||
| Reset // 复位:逻辑结果为 1 时清零 | |||
| Normal, // 普通线圈,直接写入条件结果 | |||
| Set, // 条件成立时置位 M | |||
| Reset // 条件成立时复位 M | |||
| }; | |||
| // 上升沿/下降沿触点的边沿方向 | |||
| enum class EdgeMode | |||
| { | |||
| Rising, // 上升沿:信号从 0 变成 1 | |||
| Falling // 下降沿:信号从 1 变成 0 | |||
| Rising, // 检测 M 从 0 变为 1 | |||
| Falling // 检测 M 从 1 变为 0 | |||
| }; | |||
| // D 寄存器字比较,等于、不等于、大于、小于等 6 种比较 | |||
| enum class ComparisonOperator | |||
| { | |||
| Equal, // 等于 | |||
| @@ -42,63 +38,51 @@ enum class ComparisonOperator | |||
| GreaterThanOrEqual // 大于或等于 | |||
| }; | |||
| // 字操作数可以来自常量,也可以来自 D 寄存器 | |||
| enum class WordOperandKind | |||
| { | |||
| Constant, // 使用固定数值 | |||
| Register // 使用 D 寄存器中的数值 | |||
| Register // 使用 D 区寄存器的值 | |||
| }; | |||
| // MOVE、ADD/SUB 共用的字操作数 | |||
| struct WordOperand | |||
| { | |||
| WordOperandKind kind = WordOperandKind::Constant; // 操作数来源 | |||
| RegisterAddress address{RegisterArea::D, 0}; // kind 为 Register 时使用的 D 地址 | |||
| std::int16_t constant = 0; // kind 为 Constant 时使用的数值 | |||
| WordOperandKind kind = WordOperandKind::Constant; // 操作数来源类型 | |||
| RegisterAddress address{RegisterArea::D, 0}; // 寄存器操作数使用的 D 地址 | |||
| std::int16_t constant = 0; // 常量操作数的数值 | |||
| // 常量始终有效;寄存器操作数必须是有效 D 地址 | |||
| // 校验操作数类型和寄存器地址是否有效 | |||
| bool validate(std::string *error = nullptr) const; | |||
| }; | |||
| // 普通 M 寄存器触点,地址 + 常开常闭 | |||
| struct ContactNodeConfig | |||
| { | |||
| RegisterAddress address{RegisterArea::M, 0}; // 触点读取的 M 地址 | |||
| 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; | |||
| RegisterAddress address{RegisterArea::M, 0}; // 边沿触点监控的 M 地址 | |||
| EdgeMode mode = EdgeMode::Rising; // 上升沿或下降沿模式 | |||
| }; | |||
| // 线圈输出配置,决定如何写入 M 寄存器 | |||
| struct CoilNodeConfig | |||
| { | |||
| // 线圈要写入的 M 寄存器地址 | |||
| RegisterAddress address{RegisterArea::M, 0}; | |||
| CoilMode mode = CoilMode::Normal; | |||
| RegisterAddress address{RegisterArea::M, 0}; // 线圈写入的 M 地址 | |||
| CoilMode mode = CoilMode::Normal; // 普通、置位或复位模式 | |||
| }; | |||
| // D 寄存器比较配置 | |||
| struct CompareNodeConfig | |||
| { | |||
| // 要参与比较的 D 寄存器地址 | |||
| RegisterAddress address{RegisterArea::D, 0}; | |||
| // 比较运算符 | |||
| ComparisonOperator comparison = ComparisonOperator::Equal; | |||
| // 与寄存器值比较的常量 | |||
| std::int16_t value = 0; | |||
| RegisterAddress address{RegisterArea::D, 0}; // 参与比较的 D 地址 | |||
| ComparisonOperator comparison = ComparisonOperator::Equal; // 比较方式 | |||
| std::int16_t value = 0; // 与 D 值比较的常量 | |||
| }; | |||
| // MOVE 输出:把一个字操作数写入目标 D 地址 | |||
| struct MoveNodeConfig | |||
| { | |||
| WordOperand source; // 要写入的源操作数 | |||
| RegisterAddress destination{RegisterArea::D, 0}; // 接收数据的 D 地址 | |||
| WordOperand source; // MOVE 的源操作数 | |||
| RegisterAddress destination{RegisterArea::D, 0}; // MOVE 的目标 D 地址 | |||
| }; | |||
| enum class ArithmeticOperation | |||
| @@ -107,166 +91,137 @@ enum class ArithmeticOperation | |||
| Subtract // 减法 | |||
| }; | |||
| // ADD/SUB 输出:计算两个字操作数并写入目标 D 地址 | |||
| struct ArithmeticNodeConfig | |||
| { | |||
| ArithmeticOperation operation = ArithmeticOperation::Add; // 加法或减法 | |||
| ArithmeticOperation operation = ArithmeticOperation::Add; // 算术运算类型 | |||
| WordOperand left; // 左操作数 | |||
| WordOperand right; // 右操作数 | |||
| RegisterAddress destination{RegisterArea::D, 0}; // 运算结果写入的 D 地址 | |||
| }; | |||
| // 所有梯形图指令的强类型配置联合,避免用无关字段拼装指令 | |||
| using LogicNodeConfig = std::variant< | |||
| ContactNodeConfig, // 普通常开或常闭触点 | |||
| EdgeContactNodeConfig, // 上升沿或下降沿触点 | |||
| CoilNodeConfig, // 普通、置位或复位线圈 | |||
| CompareNodeConfig, // D 寄存器比较节点 | |||
| MoveNodeConfig, // MOVE 数据传送输出 | |||
| ArithmeticNodeConfig>; // ADD 或 SUB 算术输出 | |||
| // 返回节点最主要的 M/D 地址;复合输出请使用下面的收集函数 | |||
| ContactNodeConfig, | |||
| EdgeContactNodeConfig, | |||
| CoilNodeConfig, | |||
| CompareNodeConfig, | |||
| MoveNodeConfig, | |||
| ArithmeticNodeConfig>; | |||
| // 返回逻辑节点主要使用的地址 | |||
| 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; // 节点唯一 ID | |||
| LogicNodeConfig config; // 节点的具体指令配置 | |||
| bool configured = true; // 是否已完成配置 | |||
| std::string id; // 逻辑节点的唯一 ID | |||
| LogicNodeConfig config; // 节点类型和具体参数 | |||
| bool configured = true; // 是否已完成运行所需的配置 | |||
| // 校验节点 ID、配置类型和配置内容 | |||
| // 校验节点 ID 和节点参数 | |||
| bool validate(std::string *error = nullptr) const; | |||
| // 返回节点是否已完成运行配置 | |||
| bool isConfigured() const; | |||
| // 判断节点能否放在条件区或固定输出槽 | |||
| // 返回节点是否属于条件节点 | |||
| bool isCondition() const; | |||
| // 返回节点是否属于输出节点 | |||
| bool isOutput() const; | |||
| }; | |||
| enum class ConditionExpressionKind | |||
| enum class LadderCellKind | |||
| { | |||
| Node, // 一个实际的条件节点 | |||
| Wire, // 一段横线 | |||
| Gap, // 一段明确断开的空白网格 | |||
| Series, // 多个条件串联,必须全部满足 | |||
| Parallel // 多个条件并联,满足任意一条即可 | |||
| }; | |||
| // 条件区中的持久化横线;columnSpan 表示跨越的网格列数 | |||
| struct WireSegment | |||
| { | |||
| static constexpr int kMinimumColumnSpan = 1; | |||
| static constexpr int kMaximumColumnSpan = | |||
| ProjectLimits::kMaximumConditionColumns; | |||
| int columnSpan = 1; // 横线占用的网格列数 | |||
| bool validate(std::string *error = nullptr) const; | |||
| Gap, // 空白网格 | |||
| Wire, // 横向导线网格 | |||
| Node // 条件节点网格 | |||
| }; | |||
| // 条件区中的持久化断路;columnSpan 表示连续空白网格列数 | |||
| struct GapSegment | |||
| // 条件区的一个固定网格,横线和空白与条件节点具有同等持久化地位 | |||
| struct LadderCell | |||
| { | |||
| static constexpr int kMinimumColumnSpan = 1; | |||
| static constexpr int kMaximumColumnSpan = | |||
| ProjectLimits::kMaximumConditionColumns; | |||
| int columnSpan = 1; // 断路占用的网格列数 | |||
| std::string id; // 网格的唯一 ID | |||
| LadderCellKind kind = LadderCellKind::Gap; // 网格类型 | |||
| std::optional<LogicNode> node; // 网格中的条件节点 | |||
| // 校验网格 ID、类型和节点内容是否匹配 | |||
| bool validate(std::string *error = nullptr) const; | |||
| }; | |||
| // 结构化表达式只允许合法的串并联拓扑,不保存可产生悬空线或环路的像素连接 | |||
| struct ConditionExpression | |||
| // 两个相邻视觉行之间、指定列边界上的一段竖线 | |||
| struct VerticalConnection | |||
| { | |||
| 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; // 串联或并联的子表达式 | |||
| std::string id; // 竖线的唯一 ID | |||
| std::string upperRungId; // 竖线连接的上方行 ID | |||
| std::string lowerRungId; // 竖线连接的下方行 ID | |||
| int columnBoundary = 0; // 竖线所在的列边界 | |||
| // 把一个逻辑节点包装成条件表达式 | |||
| 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; // 网络唯一 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<LogicNode> output; // 行末输出节点 | |||
| std::vector<LadderCell> cells; // 固定数量的条件网格 | |||
| // 编辑态允许没有条件或输出,便于逐步搭建网络 | |||
| // 校验梯形图行的完整结构 | |||
| 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; // 控制逻辑唯一 ID | |||
| std::string id; // 控制逻辑的唯一 ID | |||
| std::string name; // 控制逻辑名称 | |||
| std::vector<LadderRung> rungs; // 很多条 LadderRung(梯形图网络/梯级) | |||
| std::vector<LadderRung> rungs; // 按视觉顺序保存的梯形图行 | |||
| bool enabled = true; // 是否参与运行扫描 | |||
| std::vector<VerticalConnection> verticalConnections; // 行之间的竖向连接 | |||
| // 校验可保存结构 | |||
| bool validate(std::string *error = nullptr) const; | |||
| // 返回指定行所在连续网络的首行下标 | |||
| std::size_t networkHeadIndex(std::size_t rung_index) 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; | |||
| }; | |||
| // 在梯形图行中查找指定 ID 的只读网格 | |||
| const LadderCell *findLadderCell( | |||
| const LadderRung &rung, const std::string &cell_id); | |||
| // 在梯形图行中查找指定 ID 的可写网格 | |||
| LadderCell *findLadderCell( | |||
| LadderRung &rung, const std::string &cell_id); | |||
| // 在控制逻辑中查找指定 ID 的只读竖线 | |||
| const VerticalConnection *findVerticalConnection( | |||
| const ControlLogic &logic, const std::string &connection_id); | |||
| // 在控制逻辑中查找指定 ID 的可写竖线 | |||
| VerticalConnection *findVerticalConnection( | |||
| ControlLogic &logic, const std::string &connection_id); | |||
| // 收集一行中的全部条件节点 | |||
| void collectConditionNodes( | |||
| const LadderRung &rung, std::vector<const LogicNode *> *nodes); | |||
| // 收集控制逻辑中的全部条件节点和输出节点 | |||
| void collectLogicNodes( | |||
| const ControlLogic &logic, std::vector<const LogicNode *> *nodes); | |||
| @@ -6,6 +6,7 @@ | |||
| namespace { | |||
| // 集中保存所有 HMI 控件的稳定类型名、默认外观和寄存器能力 | |||
| constexpr std::array kControlDescriptors = { | |||
| HmiControlDescriptor{HmiControlType::Button, | |||
| "button", | |||
| @@ -52,6 +53,15 @@ constexpr std::array kControlDescriptors = { | |||
| HmiBindingKind::None, | |||
| HmiRuntimeValueKind::None, | |||
| false}, | |||
| HmiControlDescriptor{HmiControlType::StatusText, | |||
| "statusText", | |||
| "状态文本", | |||
| "status-text", | |||
| "状态文本", | |||
| {0, 0, 140, 40}, | |||
| HmiBindingKind::BitOrWord, | |||
| HmiRuntimeValueKind::BitOrWord, | |||
| true}, | |||
| HmiControlDescriptor{HmiControlType::PageJump, | |||
| "pageJump", | |||
| "页面跳转", | |||
| @@ -72,6 +82,7 @@ constexpr std::array kControlDescriptors = { | |||
| false} | |||
| }; | |||
| // 新增控件类型时必须同步加入描述表,否则在编译阶段直接报错 | |||
| static_assert( | |||
| kControlDescriptors.size() | |||
| == static_cast<std::size_t>(HmiControlType::Count), | |||
| @@ -79,8 +90,10 @@ static_assert( | |||
| } // namespace | |||
| // 按领域控件类型查找对应的公共描述 | |||
| const HmiControlDescriptor *findHmiControlDescriptor(HmiControlType type) | |||
| { | |||
| // find_if 返回第一个匹配项,找不到时返回 end 迭代器 | |||
| const auto descriptor = std::find_if( | |||
| kControlDescriptors.cbegin(), | |||
| kControlDescriptors.cend(), | |||
| @@ -91,9 +104,11 @@ const HmiControlDescriptor *findHmiControlDescriptor(HmiControlType type) | |||
| return descriptor == kControlDescriptors.cend() ? nullptr : &*descriptor; | |||
| } | |||
| // 按工程文件保存的稳定名称查找对应的公共描述 | |||
| const HmiControlDescriptor *findHmiControlDescriptor( | |||
| std::string_view storage_name) | |||
| { | |||
| // string_view 只借用调用方字符串,不会额外复制名称 | |||
| const auto descriptor = std::find_if( | |||
| kControlDescriptors.cbegin(), | |||
| kControlDescriptors.cend(), | |||
| @@ -104,6 +119,7 @@ const HmiControlDescriptor *findHmiControlDescriptor( | |||
| return descriptor == kControlDescriptors.cend() ? nullptr : &*descriptor; | |||
| } | |||
| // 将控件绑定类型转换为具体的 M/D 寄存器区域 | |||
| std::optional<RegisterArea> hmiBindingArea(HmiBindingKind binding_kind) | |||
| { | |||
| switch (binding_kind) | |||
| @@ -117,8 +133,10 @@ std::optional<RegisterArea> hmiBindingArea(HmiBindingKind binding_kind) | |||
| return RegisterArea::D; | |||
| } | |||
| case HmiBindingKind::None: | |||
| case HmiBindingKind::BitOrWord: | |||
| default: | |||
| { | |||
| // None 和 BitOrWord 不能在这里确定唯一的寄存器区域 | |||
| return std::nullopt; | |||
| } | |||
| } | |||
| @@ -19,7 +19,8 @@ enum class HmiBindingKind | |||
| { | |||
| None, // 不支持寄存器绑定 | |||
| Bit, // 绑定 M 区位地址 | |||
| Word // 绑定 D 区字地址 | |||
| Word, // 绑定 D 区字地址 | |||
| BitOrWord // 根据控件配置绑定 M 位或 D 字 | |||
| }; | |||
| /** | |||
| @@ -29,7 +30,8 @@ enum class HmiRuntimeValueKind | |||
| { | |||
| None, // 运行态不读取寄存器值 | |||
| Bit, // 运行态读取绑定的 M 位值 | |||
| Word // 运行态读取绑定的 D 字值 | |||
| Word, // 运行态读取绑定的 D 字值 | |||
| BitOrWord // 运行态按绑定区域读取 M 位或 D 字 | |||
| }; | |||
| /** | |||
| @@ -6,23 +6,28 @@ | |||
| #include <algorithm> | |||
| #include <cctype> | |||
| #include <charconv> | |||
| #include <cmath> | |||
| #include <limits> | |||
| namespace { | |||
| // 在提供错误字符串时写入校验失败原因 | |||
| // 在调用方提供错误字符串时保存校验失败原因 | |||
| void setError(std::string *error, const std::string &message) | |||
| { | |||
| // 错误字符串是可选输出,不提供时只返回校验结果 | |||
| if (error != nullptr) | |||
| { | |||
| *error = message; | |||
| } | |||
| } | |||
| // 判断字符是否为十六进制数字 | |||
| bool isHexDigit(char value) | |||
| { | |||
| return std::isxdigit(static_cast<unsigned char>(value)) != 0; | |||
| } | |||
| // 判断颜色是否符合 #RRGGBB 格式 | |||
| bool isValidTextColor(const std::string &value) | |||
| { | |||
| return value.size() == 7U | |||
| @@ -30,6 +35,7 @@ bool isValidTextColor(const std::string &value) | |||
| && std::all_of(value.cbegin() + 1, value.cend(), isHexDigit); | |||
| } | |||
| // 将完整字符串解析为整数,拒绝前后带有其他字符的输入 | |||
| bool parseInteger(const std::string &value, int *result) | |||
| { | |||
| if (value.empty() || result == nullptr) | |||
| @@ -38,15 +44,117 @@ bool parseInteger(const std::string &value, int *result) | |||
| } | |||
| const char *begin = value.data(); | |||
| const char *end = begin + value.size(); | |||
| // from_chars 不受本地化设置影响,适合解析工程中保存的简单整数 | |||
| const auto parsed = std::from_chars(begin, end, *result); | |||
| return parsed.ec == std::errc{} && parsed.ptr == end; | |||
| } | |||
| // 判断字符串是否为允许保存的布尔值 | |||
| bool isBooleanValue(const std::string &value) | |||
| { | |||
| return value == "true" || value == "false"; | |||
| } | |||
| // 判断文本是否为空或只包含空白字符 | |||
| bool isBlank(const std::string &value) | |||
| { | |||
| return value.empty() | |||
| || std::all_of( | |||
| value.cbegin(), value.cend(), | |||
| [](unsigned char character) | |||
| { | |||
| return std::isspace(character) != 0; | |||
| }); | |||
| } | |||
| // 校验状态文本内容的长度、换行和空白规则 | |||
| bool validateStatusTextValue( | |||
| const std::string &value, const char *name, std::string *error) | |||
| { | |||
| if (isBlank(value)) | |||
| { | |||
| setError(error, std::string(name) + "不能为空"); | |||
| return false; | |||
| } | |||
| if (value.size() > ProjectLimits::kMaximumTextBytes) | |||
| { | |||
| setError(error, std::string(name) + "不能超过 256 个 UTF-8 字节"); | |||
| return false; | |||
| } | |||
| if (value.find('\r') != std::string::npos | |||
| || value.find('\n') != std::string::npos) | |||
| { | |||
| setError(error, std::string(name) + "只能使用单行文本"); | |||
| return false; | |||
| } | |||
| return true; | |||
| } | |||
| // 校验 D 状态文本的区间数量、边界和连续性 | |||
| bool validateStatusWordConfig( | |||
| const HmiStatusWordTextConfig &config, | |||
| RegisterDataType data_type, | |||
| std::string *error) | |||
| { | |||
| if (config.ranges.empty() | |||
| || config.ranges.size() > ProjectLimits::kMaximumStatusTextRanges) | |||
| { | |||
| setError(error, "D 状态文本必须配置 1~16 个连续区间"); | |||
| return false; | |||
| } | |||
| if (config.ranges.front().lowerBound.has_value() | |||
| || config.ranges.back().upperBound.has_value()) | |||
| { | |||
| setError(error, "D 状态文本的首个下限和最后一个上限必须为不限"); | |||
| return false; | |||
| } | |||
| // 整数类型的区间边界不能出现小数 | |||
| const bool integer_type = data_type == RegisterDataType::Int16 | |||
| || data_type == RegisterDataType::Int32; | |||
| for (std::size_t index = 0; index < config.ranges.size(); ++index) | |||
| { | |||
| const HmiStatusValueRange &range = config.ranges[index]; | |||
| if (!validateStatusTextValue(range.text, "区间显示文本", error)) | |||
| { | |||
| return false; | |||
| } | |||
| if ((range.lowerBound.has_value() && !std::isfinite(*range.lowerBound)) | |||
| || (range.upperBound.has_value() && !std::isfinite(*range.upperBound))) | |||
| { | |||
| setError(error, "D 状态文本区间边界必须是有限数值"); | |||
| return false; | |||
| } | |||
| if (integer_type | |||
| && ((range.lowerBound.has_value() | |||
| && std::trunc(*range.lowerBound) != *range.lowerBound) | |||
| || (range.upperBound.has_value() | |||
| && std::trunc(*range.upperBound) != *range.upperBound))) | |||
| { | |||
| setError(error, "Int16/Int32 状态文本的区间边界必须是整数"); | |||
| return false; | |||
| } | |||
| if (range.lowerBound.has_value() && range.upperBound.has_value() | |||
| && *range.lowerBound >= *range.upperBound) | |||
| { | |||
| setError(error, "D 状态文本区间必须满足下限小于上限"); | |||
| return false; | |||
| } | |||
| if (index > 0U) | |||
| { | |||
| const HmiStatusValueRange &previous = config.ranges[index - 1U]; | |||
| if (!previous.upperBound.has_value() | |||
| || !range.lowerBound.has_value() | |||
| || *previous.upperBound != *range.lowerBound) | |||
| { | |||
| setError(error, "D 状态文本相邻区间必须首尾相接"); | |||
| return false; | |||
| } | |||
| } | |||
| } | |||
| return true; | |||
| } | |||
| // 校验 HMI 外观扩展属性的格式和取值范围 | |||
| bool validateAppearanceProperty( | |||
| const std::string &key, const std::string &value, std::string *error) | |||
| { | |||
| @@ -87,8 +195,81 @@ bool validateAppearanceProperty( | |||
| return true; | |||
| } | |||
| // 校验按钮启用条件的地址、类型、运算符和比较值 | |||
| bool validateButtonEnableCondition( | |||
| const HmiButtonEnableCondition &condition, std::string *error) | |||
| { | |||
| if (const auto *bit = std::get_if<HmiButtonBitEnableCondition>(&condition)) | |||
| { | |||
| if (!bit->address.isValid() || bit->address.area() != RegisterArea::M) | |||
| { | |||
| setError(error, "按钮 M 启用条件必须使用有效的 M 区地址"); | |||
| return false; | |||
| } | |||
| return true; | |||
| } | |||
| const auto *word = std::get_if<HmiButtonWordEnableCondition>(&condition); | |||
| if (word == nullptr || !registerDataTypeIsSupported(word->dataType)) | |||
| { | |||
| setError(error, "按钮 D 启用条件的数据类型无效"); | |||
| return false; | |||
| } | |||
| switch (word->operation) | |||
| { | |||
| case HmiButtonConditionOperator::Equal: | |||
| case HmiButtonConditionOperator::NotEqual: | |||
| case HmiButtonConditionOperator::LessThan: | |||
| case HmiButtonConditionOperator::LessThanOrEqual: | |||
| case HmiButtonConditionOperator::GreaterThan: | |||
| case HmiButtonConditionOperator::GreaterThanOrEqual: | |||
| break; | |||
| default: | |||
| setError(error, "按钮 D 启用条件的比较方式无效"); | |||
| return false; | |||
| } | |||
| if (!registerDataTypeAddressIsValid(word->dataType, word->address)) | |||
| { | |||
| setError(error, "按钮 D 启用条件地址超出可用范围"); | |||
| return false; | |||
| } | |||
| if (!std::isfinite(word->value)) | |||
| { | |||
| setError(error, "按钮 D 启用条件比较值必须是有限数值"); | |||
| return false; | |||
| } | |||
| if (word->dataType == RegisterDataType::Int16) | |||
| { | |||
| if (std::trunc(word->value) != word->value | |||
| || word->value < std::numeric_limits<std::int16_t>::min() | |||
| || word->value > std::numeric_limits<std::int16_t>::max()) | |||
| { | |||
| setError(error, "Int16 按钮启用条件比较值必须是 -32768~32767 的整数"); | |||
| return false; | |||
| } | |||
| } | |||
| else if (word->dataType == RegisterDataType::Int32) | |||
| { | |||
| if (std::trunc(word->value) != word->value | |||
| || word->value < std::numeric_limits<std::int32_t>::min() | |||
| || word->value > std::numeric_limits<std::int32_t>::max()) | |||
| { | |||
| setError(error, "Int32 按钮启用条件比较值必须是有效的 32 位整数"); | |||
| return false; | |||
| } | |||
| } | |||
| else if (word->dataType == RegisterDataType::Float32 | |||
| && !std::isfinite(static_cast<float>(word->value))) | |||
| { | |||
| setError(error, "Float32 按钮启用条件比较值超出范围"); | |||
| return false; | |||
| } | |||
| return true; | |||
| } | |||
| } // namespace | |||
| // 校验 HMI 控件的基本属性、绑定和专用配置 | |||
| bool HmiControl::validate(std::string *error) const | |||
| { | |||
| const HmiControlDescriptor *descriptor = findHmiControlDescriptor(type); | |||
| @@ -148,7 +329,8 @@ bool HmiControl::validate(std::string *error) const | |||
| } | |||
| const std::optional<RegisterArea> binding_area = | |||
| hmiBindingArea(descriptor->bindingKind); | |||
| if (binding.has_value() && !binding_area.has_value()) | |||
| if (binding.has_value() && !binding_area.has_value() | |||
| && descriptor->bindingKind != HmiBindingKind::BitOrWord) | |||
| { | |||
| setError(error, "该 HMI 控件不能绑定寄存器"); | |||
| return false; | |||
| @@ -171,18 +353,78 @@ bool HmiControl::validate(std::string *error) const | |||
| setError(error, "HMI 控件绑定了无效地址"); | |||
| return false; | |||
| } | |||
| const bool status_text = type == HmiControlType::StatusText; | |||
| // 状态文本使用 D 区映射时也需要按数值控件检查地址跨度 | |||
| const bool numeric = type == HmiControlType::NumericDisplay | |||
| || type == HmiControlType::NumericInput; | |||
| || type == HmiControlType::NumericInput | |||
| || (status_text && statusText.has_value() | |||
| && std::holds_alternative<HmiStatusWordTextConfig>(*statusText)); | |||
| if (!registerDataTypeIsSupported(dataType)) | |||
| { | |||
| setError(error, "HMI 数值类型无效"); | |||
| return false; | |||
| } | |||
| if (!numeric && dataType != RegisterDataType::Int16) | |||
| { | |||
| setError(error, "只有 HMI 数值控件可以选择 Float32"); | |||
| setError(error, "只有 HMI 数值控件可以选择多字数值类型"); | |||
| return false; | |||
| } | |||
| if (numeric && binding.has_value() | |||
| && !registerDataTypeAddressIsValid(dataType, *binding)) | |||
| { | |||
| if (dataType == RegisterDataType::Float64) | |||
| { | |||
| setError(error, "Double 起始地址必须是 D0~D3996 范围内的偶数地址"); | |||
| } | |||
| else | |||
| { | |||
| setError( | |||
| error, | |||
| std::string(registerDataTypeDescriptor(dataType).displayName) | |||
| + " 起始地址超出可用 D 区范围"); | |||
| } | |||
| return false; | |||
| } | |||
| if (numeric && dataType == RegisterDataType::Float32 | |||
| && binding.has_value() | |||
| && binding->index() >= RegisterAddress::kMaximumIndex) | |||
| if (status_text) | |||
| { | |||
| setError(error, "Float32 起始地址必须位于 D0~D3999"); | |||
| if (!statusText.has_value()) | |||
| { | |||
| setError(error, "状态文本控件缺少状态映射配置"); | |||
| return false; | |||
| } | |||
| if (const auto *bit = std::get_if<HmiStatusBitTextConfig>(&*statusText)) | |||
| { | |||
| if (binding.has_value() && binding->area() != RegisterArea::M) | |||
| { | |||
| setError(error, "M 状态文本必须绑定 M 区地址"); | |||
| return false; | |||
| } | |||
| if (dataType != RegisterDataType::Int16 | |||
| || !validateStatusTextValue(bit->offText, "OFF 显示文本", error) | |||
| || !validateStatusTextValue(bit->onText, "ON 显示文本", error)) | |||
| { | |||
| return false; | |||
| } | |||
| } | |||
| else | |||
| { | |||
| if (binding.has_value() && binding->area() != RegisterArea::D) | |||
| { | |||
| setError(error, "D 状态文本必须绑定 D 区地址"); | |||
| return false; | |||
| } | |||
| if (!validateStatusWordConfig( | |||
| std::get<HmiStatusWordTextConfig>(*statusText), | |||
| dataType, | |||
| error)) | |||
| { | |||
| return false; | |||
| } | |||
| } | |||
| } | |||
| else if (statusText.has_value()) | |||
| { | |||
| setError(error, "非状态文本控件不能包含状态映射配置"); | |||
| return false; | |||
| } | |||
| if (type == HmiControlType::PageJump) | |||
| @@ -203,9 +445,23 @@ bool HmiControl::validate(std::string *error) const | |||
| setError(error, "非页面跳转控件不能包含跳转配置"); | |||
| return false; | |||
| } | |||
| if (type == HmiControlType::Button) | |||
| { | |||
| if (buttonEnableCondition.has_value() | |||
| && !validateButtonEnableCondition(*buttonEnableCondition, error)) | |||
| { | |||
| return false; | |||
| } | |||
| } | |||
| else if (buttonEnableCondition.has_value()) | |||
| { | |||
| setError(error, "非按钮控件不能包含按钮启用条件"); | |||
| return false; | |||
| } | |||
| return true; | |||
| } | |||
| // 判断控件是否已经具备运行所需的配置 | |||
| bool HmiControl::isConfigured() const | |||
| { | |||
| const HmiControlDescriptor *descriptor = findHmiControlDescriptor(type); | |||
| @@ -230,14 +486,21 @@ bool HmiControl::isConfigured() const | |||
| } | |||
| const std::optional<RegisterArea> binding_area = | |||
| hmiBindingArea(descriptor->bindingKind); | |||
| if (type == HmiControlType::StatusText) | |||
| { | |||
| if (!statusText.has_value()) | |||
| { | |||
| return false; | |||
| } | |||
| const RegisterArea expected = std::holds_alternative< | |||
| HmiStatusBitTextConfig>(*statusText) | |||
| ? RegisterArea::M : RegisterArea::D; | |||
| return binding->area() == expected; | |||
| } | |||
| return binding_area.has_value() && binding->area() == *binding_area; | |||
| } | |||
| bool HmiPage::validate(std::string *error) const | |||
| { | |||
| return validate(defaultProjectLimitSettings(), error); | |||
| } | |||
| // 使用指定工程限制校验页面和全部控件 | |||
| bool HmiPage::validate( | |||
| const ProjectLimitSettings &limits, std::string *error) const | |||
| { | |||
| @@ -302,11 +565,7 @@ bool HmiPage::validate( | |||
| return true; | |||
| } | |||
| bool HmiPage::validateForRunning(std::string *error) const | |||
| { | |||
| return validateForRunning(defaultProjectLimitSettings(), error); | |||
| } | |||
| // 校验页面结构,并确认所有控件都可以在运行态使用 | |||
| bool HmiPage::validateForRunning( | |||
| const ProjectLimitSettings &limits, std::string *error) const | |||
| { | |||
| @@ -14,15 +14,16 @@ | |||
| #include <map> | |||
| #include <optional> | |||
| #include <string> | |||
| #include <variant> | |||
| #include <vector> | |||
| namespace HmiAppearanceProperty | |||
| { | |||
| inline constexpr const char kTextColor[] = "textColor"; | |||
| inline constexpr const char kFontSize[] = "fontSize"; | |||
| inline constexpr const char kFontBold[] = "fontBold"; | |||
| inline constexpr const char kFontItalic[] = "fontItalic"; | |||
| inline constexpr const char kTextColor[] = "textColor"; // 字体颜色属性名 | |||
| inline constexpr const char kFontSize[] = "fontSize"; // 字号属性名 | |||
| inline constexpr const char kFontBold[] = "fontBold"; // 粗体属性名 | |||
| inline constexpr const char kFontItalic[] = "fontItalic"; // 斜体属性名 | |||
| } // namespace HmiAppearanceProperty | |||
| @@ -33,13 +34,13 @@ inline constexpr const char kFontItalic[] = "fontItalic"; | |||
| */ | |||
| struct HmiRect | |||
| { | |||
| static constexpr int kDefaultWidth = 80; | |||
| static constexpr int kDefaultHeight = 32; | |||
| static constexpr int kDefaultWidth = 80; // 新建控件的默认宽度 | |||
| static constexpr int kDefaultHeight = 32; // 新建控件的默认高度 | |||
| int x = 0; | |||
| int y = 0; | |||
| int width = kDefaultWidth; | |||
| int height = kDefaultHeight; | |||
| int x = 0; // 矩形左上角的横坐标 | |||
| int y = 0; // 矩形左上角的纵坐标 | |||
| int width = kDefaultWidth; // 控件宽度 | |||
| int height = kDefaultHeight; // 控件高度 | |||
| }; | |||
| /** | |||
| @@ -54,6 +55,7 @@ enum class HmiControlType | |||
| NumericDisplay, // 数值显示 | |||
| NumericInput, // 数值输入 | |||
| Label, // 标签 | |||
| StatusText, // 状态文本 | |||
| PageJump, // 页面跳转 | |||
| AlarmList, // 报警列表 | |||
| Count // 已注册控件类型数量,不作为实际控件使用 | |||
| @@ -70,11 +72,105 @@ enum class HmiButtonOperation | |||
| MomentaryOn // 按下时写入 1,释放时写入 0 | |||
| }; | |||
| /** @brief HMI 按钮 D 数值启用条件的比较方式 */ | |||
| enum class HmiButtonConditionOperator | |||
| { | |||
| Equal, // 等于 | |||
| NotEqual, // 不等于 | |||
| LessThan, // 小于 | |||
| LessThanOrEqual, // 小于或等于 | |||
| GreaterThan, // 大于 | |||
| GreaterThanOrEqual // 大于或等于 | |||
| }; | |||
| // 按钮使用 M 位判断是否允许操作的条件 | |||
| struct HmiButtonBitEnableCondition | |||
| { | |||
| RegisterAddress address{RegisterArea::M, 0}; // 条件读取的 M 地址 | |||
| bool expected = false; // 允许按钮操作时期望的 M 值 | |||
| // 比较两个 M 位启用条件是否完全相同 | |||
| bool operator==(const HmiButtonBitEnableCondition &other) const | |||
| { | |||
| return address == other.address && expected == other.expected; | |||
| } | |||
| }; | |||
| // 按钮使用 D 数值判断是否允许操作的条件 | |||
| struct HmiButtonWordEnableCondition | |||
| { | |||
| RegisterAddress address{RegisterArea::D, 0}; // 条件读取的 D 地址 | |||
| RegisterDataType dataType = RegisterDataType::Int16; // D 值的数据类型 | |||
| HmiButtonConditionOperator operation = HmiButtonConditionOperator::Equal; // 比较方式 | |||
| double value = 0.0; // 参与比较的数值 | |||
| // 比较两个 D 数值启用条件是否完全相同 | |||
| bool operator==(const HmiButtonWordEnableCondition &other) const | |||
| { | |||
| return address == other.address | |||
| && dataType == other.dataType | |||
| && operation == other.operation | |||
| && value == other.value; | |||
| } | |||
| }; | |||
| // 按钮启用条件可以使用 M 位或 D 数值 | |||
| using HmiButtonEnableCondition = std::variant< | |||
| HmiButtonBitEnableCondition, | |||
| HmiButtonWordEnableCondition>; | |||
| // 页面跳转控件的目标配置 | |||
| struct HmiPageJumpConfig | |||
| { | |||
| std::string targetPageId; | |||
| std::string targetPageId; // 页面跳转目标的 ID | |||
| }; | |||
| // M 状态文本的 OFF/ON 映射配置 | |||
| struct HmiStatusBitTextConfig | |||
| { | |||
| std::string offText; // M 为 0 时显示的文本 | |||
| std::string onText; // M 为 1 时显示的文本 | |||
| // 比较两个 M 状态文本配置是否完全相同 | |||
| bool operator==(const HmiStatusBitTextConfig &other) const | |||
| { | |||
| return offText == other.offText && onText == other.onText; | |||
| } | |||
| }; | |||
| // D 状态文本使用的一个左闭右开数值区间 | |||
| struct HmiStatusValueRange | |||
| { | |||
| std::optional<double> lowerBound; // 区间下限,不填写表示负无穷 | |||
| std::optional<double> upperBound; // 区间上限,不填写表示正无穷 | |||
| std::string text; // 命中区间时显示的文本 | |||
| // 比较两个状态值区间是否完全相同 | |||
| bool operator==(const HmiStatusValueRange &other) const | |||
| { | |||
| return lowerBound == other.lowerBound | |||
| && upperBound == other.upperBound | |||
| && text == other.text; | |||
| } | |||
| }; | |||
| // D 状态文本的连续区间配置 | |||
| struct HmiStatusWordTextConfig | |||
| { | |||
| std::vector<HmiStatusValueRange> ranges; // 按顺序排列的连续数值区间 | |||
| // 比较两个 D 状态文本配置是否完全相同 | |||
| bool operator==(const HmiStatusWordTextConfig &other) const | |||
| { | |||
| return ranges == other.ranges; | |||
| } | |||
| }; | |||
| // 状态文本可以使用 M 位映射或 D 数值区间映射 | |||
| using HmiStatusTextConfig = std::variant< | |||
| HmiStatusBitTextConfig, | |||
| HmiStatusWordTextConfig>; | |||
| /** | |||
| * @brief 描述一个可保存的 HMI 控件及其显示和寄存器配置 | |||
| * | |||
| @@ -82,15 +178,17 @@ struct HmiPageJumpConfig | |||
| */ | |||
| struct HmiControl | |||
| { | |||
| std::string id; | |||
| HmiControlType type = HmiControlType::Label; | |||
| 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::string id; // 控件的唯一 ID | |||
| HmiControlType type = HmiControlType::Label; // 控件类型 | |||
| HmiRect bounds; // 控件在页面中的位置和尺寸 | |||
| std::string text; // 控件显示的文本 | |||
| std::optional<RegisterAddress> binding; // 控件绑定的 M 或 D 地址 | |||
| RegisterDataType dataType = RegisterDataType::Int16; // 绑定 D 值的数据类型 | |||
| std::map<std::string, std::string> properties; // 控件的扩展外观属性 | |||
| HmiButtonOperation buttonOperation = HmiButtonOperation::MomentaryOn; // 按钮写入操作 | |||
| std::optional<HmiButtonEnableCondition> buttonEnableCondition; // 按钮启用条件 | |||
| std::optional<HmiPageJumpConfig> pageJump; // 页面跳转配置 | |||
| std::optional<HmiStatusTextConfig> statusText; // 状态文本映射配置 | |||
| /** | |||
| * @brief 校验控件的标识、尺寸、扩展属性和寄存器绑定 | |||
| @@ -100,6 +198,7 @@ struct HmiControl | |||
| * 位控件必须绑定有效 M 地址,字控件必须绑定有效 D 地址 | |||
| */ | |||
| bool validate(std::string *error = nullptr) const; | |||
| // 返回控件是否已具备运行所需的绑定和专用配置 | |||
| bool isConfigured() const; | |||
| }; | |||
| @@ -110,22 +209,21 @@ struct HmiControl | |||
| */ | |||
| struct HmiPage | |||
| { | |||
| std::string id; | |||
| std::string name; | |||
| int width = ProjectLimits::kDefaultHmiPageWidth; | |||
| int height = ProjectLimits::kDefaultHmiPageHeight; | |||
| std::vector<HmiControl> controls; | |||
| std::string id; // 页面唯一 ID | |||
| std::string name; // 页面名称 | |||
| int width = ProjectLimits::kDefaultHmiPageWidth; // 页面宽度 | |||
| int height = ProjectLimits::kDefaultHmiPageHeight; // 页面高度 | |||
| std::vector<HmiControl> controls; // 页面上的控件集合 | |||
| /** | |||
| * @brief 校验页面尺寸、控件标识唯一性和全部控件配置 | |||
| * @param error 可选错误输出,失败时写入首个校验原因 | |||
| * @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,7 +2,8 @@ | |||
| #include <cstddef> | |||
| // 所有跨层共享的数量和范围边界集中在这里,避免 UI、服务和 JSON 各自写数字 | |||
| // 程序允许的绝对硬上限统一放在这里,避免 UI、服务和文件读写各自使用不同数字 | |||
| // application.ini 只能收紧其中支持配置的上限,不能突破这些硬上限 | |||
| namespace ProjectLimits { | |||
| constexpr std::size_t kMaximumProjectFileBytes = 16U * 1024U * 1024U; // 一个工程文件最大 16 MiB,防止异常大文件占满内存 | |||
| @@ -13,19 +14,18 @@ constexpr std::size_t kMaximumHmiControlsPerProject = 2048U; // 一个工程最 | |||
| 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 行并联支路 | |||
| constexpr std::size_t kMaximumRungsPerLogic = 256U; // 一组控制逻辑最多放 256 行 | |||
| constexpr std::size_t kMaximumRungsPerProject = 2048U; // 一个工程最多保存 2048 行 | |||
| constexpr int kMaximumConditionColumns = 10; // 每行固定 10 个条件网格 | |||
| constexpr int kMaximumLadderColumns = 11; // 第 11 列固定用于输出 | |||
| constexpr std::size_t kMaximumVerticalConnectionsPerLogic = | |||
| (kMaximumRungsPerLogic - 1U) | |||
| * static_cast<std::size_t>(kMaximumConditionColumns + 1); // 一组控制逻辑最多保存的竖线总数 | |||
| static_assert(kMaximumLadderColumns == kMaximumConditionColumns + 1); // 确保总列数始终等于条件列加输出列 | |||
| constexpr std::size_t kMaximumHmiProperties = 64U; // 一个 HMI 控件最多保存 64 对扩展属性 | |||
| constexpr std::size_t kMaximumStatusTextRanges = 16U; // 一个状态文本控件最多配置 16 个连续数值区间 | |||
| 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 个字符 | |||
| @@ -65,36 +65,31 @@ constexpr int kMaximumPollIntervalMs = 10000; // PLC 最慢每 10 秒轮询一 | |||
| constexpr std::size_t kMaximumPendingWrites = 1U; // 同时只允许等待 1 个 PLC 写入,避免写入顺序混乱 | |||
| constexpr int kMaximumOutputMessages = 1000; // 输出日志最多保留 1000 条,超过后删除最旧的一条 | |||
| } | |||
| } // namespace ProjectLimits | |||
| // 用户可调的工程数量上限;默认值等于代码定义的绝对硬上限 | |||
| // 本次程序实际使用的可配置上限;默认采用代码硬上限,INI 只能将它们调小 | |||
| struct ProjectLimitSettings | |||
| { | |||
| std::size_t maximumHmiPages = ProjectLimits::kMaximumHmiPages; | |||
| std::size_t maximumHmiPages = ProjectLimits::kMaximumHmiPages; // 一个工程允许的 HMI 页面数 | |||
| std::size_t maximumHmiControlsPerPage = | |||
| ProjectLimits::kMaximumHmiControlsPerPage; | |||
| ProjectLimits::kMaximumHmiControlsPerPage; // 每个 HMI 页面允许的控件数 | |||
| std::size_t maximumAlarmDefinitions = | |||
| ProjectLimits::kMaximumAlarmDefinitions; | |||
| std::size_t maximumControlLogics = ProjectLimits::kMaximumControlLogics; | |||
| std::size_t maximumRungsPerLogic = ProjectLimits::kMaximumRungsPerLogic; | |||
| int maximumOutputMessages = ProjectLimits::kMaximumOutputMessages; | |||
| ProjectLimits::kMaximumAlarmDefinitions; // 一个工程允许的报警数 | |||
| std::size_t maximumControlLogics = ProjectLimits::kMaximumControlLogics; // 一个工程允许的控制逻辑组数 | |||
| std::size_t maximumRungsPerLogic = ProjectLimits::kMaximumRungsPerLogic; // 每组控制逻辑允许的梯形图行数 | |||
| int maximumOutputMessages = ProjectLimits::kMaximumOutputMessages; // 输出面板最多保留的消息数 | |||
| }; | |||
| // 新建 HMI 页面使用的默认尺寸;页面结构安全范围仍由上方硬限制控制 | |||
| // 新建 HMI 页面使用的默认尺寸;不会修改已有页面,也不能超出上面的宽高硬限制 | |||
| struct HmiDefaultSettings | |||
| { | |||
| int pageWidth = ProjectLimits::kDefaultHmiPageWidth; | |||
| int pageHeight = ProjectLimits::kDefaultHmiPageHeight; | |||
| int pageWidth = ProjectLimits::kDefaultHmiPageWidth; // 新建页面的默认宽度 | |||
| int pageHeight = ProjectLimits::kDefaultHmiPageHeight; // 新建页面的默认高度 | |||
| }; | |||
| // 返回长期有效的默认上限,供没有传入 INI 配置的代码使用 | |||
| inline const ProjectLimitSettings &defaultProjectLimitSettings() | |||
| { | |||
| static const ProjectLimitSettings settings; | |||
| return settings; | |||
| } | |||
| inline const HmiDefaultSettings &defaultHmiSettings() | |||
| { | |||
| static const HmiDefaultSettings settings; | |||
| return settings; | |||
| } | |||
| @@ -9,6 +9,7 @@ | |||
| namespace { | |||
| // 在调用方提供错误字符串时保存工程校验失败原因 | |||
| void setError(std::string *error, const std::string &message) | |||
| { | |||
| if (error != nullptr) | |||
| @@ -18,6 +19,7 @@ void setError(std::string *error, const std::string &message) | |||
| } | |||
| // 通过相邻区间查找避免为不同领域对象重复实现标识唯一性校验 | |||
| // 检查一组对象中的 ID 是否重复 | |||
| template <typename TItem> | |||
| bool containsDuplicateId(const std::vector<TItem> &items) | |||
| { | |||
| @@ -38,6 +40,7 @@ bool containsDuplicateId(const std::vector<TItem> &items) | |||
| return false; | |||
| } | |||
| // 检查一组对象中的名称是否重复 | |||
| template <typename TItem> | |||
| bool containsDuplicateName(const std::vector<TItem> &items) | |||
| { | |||
| @@ -58,6 +61,7 @@ bool containsDuplicateName(const std::vector<TItem> &items) | |||
| return false; | |||
| } | |||
| // 判断文本是否为空或只包含空白字符 | |||
| bool isBlank(const std::string &value) | |||
| { | |||
| return value.empty() | |||
| @@ -66,6 +70,7 @@ bool isBlank(const std::string &value) | |||
| [](unsigned char character) { return std::isspace(character) != 0; }); | |||
| } | |||
| // 判断文本中是否包含换行符 | |||
| bool containsLineBreak(const std::string &value) | |||
| { | |||
| return value.find('\r') != std::string::npos | |||
| @@ -74,17 +79,22 @@ bool containsLineBreak(const std::string &value) | |||
| struct HmiDataBinding | |||
| { | |||
| RegisterAddress address{RegisterArea::D, 0}; | |||
| RegisterDataType type = RegisterDataType::Int16; | |||
| std::string controlId; | |||
| RegisterAddress address{RegisterArea::D, 0}; // HMI 控件占用的起始地址 | |||
| RegisterDataType type = RegisterDataType::Int16; // HMI 控件使用的数据类型 | |||
| std::string controlId; // 发生冲突时用于提示的控件 ID | |||
| }; | |||
| // 判断 HMI 控件是否会占用 D 区数值地址 | |||
| bool isNumericControl(const HmiControl &control) | |||
| { | |||
| return control.type == HmiControlType::NumericDisplay | |||
| || control.type == HmiControlType::NumericInput; | |||
| || control.type == HmiControlType::NumericInput | |||
| || (control.type == HmiControlType::StatusText | |||
| && control.binding.has_value() | |||
| && control.binding->area() == RegisterArea::D); | |||
| } | |||
| // 判断两个 HMI D 区绑定占用的地址范围是否相交 | |||
| bool rangesIntersect(const HmiDataBinding &left, const HmiDataBinding &right) | |||
| { | |||
| const int leftEnd = left.address.index() | |||
| @@ -96,12 +106,14 @@ bool rangesIntersect(const HmiDataBinding &left, const HmiDataBinding &right) | |||
| && right.address.index() <= leftEnd; | |||
| } | |||
| bool isFloatWriteDestination(const LogicNodeConfig &config, RegisterAddress *address) | |||
| // 提取 MOVE 或算术指令写入的 D 区目标地址 | |||
| bool isWordWriteDestination(const LogicNodeConfig &config, RegisterAddress *address) | |||
| { | |||
| if (address == nullptr) | |||
| { | |||
| return false; | |||
| } | |||
| // variant 只对 MOVE 和算术配置写入目标地址,其他节点没有字写入目标 | |||
| return std::visit( | |||
| [address](const auto &value) | |||
| { | |||
| @@ -119,6 +131,7 @@ bool isFloatWriteDestination(const LogicNodeConfig &config, RegisterAddress *add | |||
| } // namespace | |||
| // 校验工程级软元件注释的地址和文本内容 | |||
| bool RegisterComment::validate(std::string *error) const | |||
| { | |||
| if (!address.isValid()) | |||
| @@ -144,6 +157,7 @@ bool RegisterComment::validate(std::string *error) const | |||
| return true; | |||
| } | |||
| // 按 M/D 地址查找工程中保存的软元件注释 | |||
| const RegisterComment *Project::findRegisterComment( | |||
| const RegisterAddress &address) const | |||
| { | |||
| @@ -156,11 +170,7 @@ const RegisterComment *Project::findRegisterComment( | |||
| return comment == registerComments.cend() ? nullptr : &*comment; | |||
| } | |||
| bool Project::validate(std::string *error) const | |||
| { | |||
| return validate(defaultProjectLimitSettings(), error); | |||
| } | |||
| // 校验工程元数据、数量边界、对象关系和寄存器占用冲突 | |||
| bool Project::validate( | |||
| const ProjectLimitSettings &limits, std::string *error) const | |||
| { | |||
| @@ -337,6 +347,7 @@ bool Project::validate( | |||
| } | |||
| } | |||
| } | |||
| // 先收集 HMI 数值控件的 D 占用范围,后面统一检查重叠 | |||
| std::vector<HmiDataBinding> hmi_bindings; | |||
| for (const HmiPage &page : hmiPages) | |||
| { | |||
| @@ -374,6 +385,7 @@ bool Project::validate( | |||
| } | |||
| // 同时收集所有显式 M/D 引用,提前检查 PLC 轮询集合不会超过上限 | |||
| // 收集所有需要轮询的地址,最后排序并去重后检查总量 | |||
| std::vector<RegisterAddress> poll_addresses; | |||
| for (const HmiPage &page : hmiPages) | |||
| { | |||
| @@ -383,11 +395,15 @@ bool Project::validate( | |||
| { | |||
| 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}); | |||
| for (int offset = 1; | |||
| offset < registerDataTypeWordCount(control.dataType); | |||
| ++offset) | |||
| { | |||
| poll_addresses.push_back(RegisterAddress{ | |||
| RegisterArea::D, control.binding->index() + offset}); | |||
| } | |||
| } | |||
| } | |||
| } | |||
| @@ -401,10 +417,7 @@ bool Project::validate( | |||
| for (const LadderRung &rung : logic.rungs) | |||
| { | |||
| std::vector<const LogicNode *> nodes; | |||
| if (rung.condition.has_value()) | |||
| { | |||
| collectConditionNodes(*rung.condition, &nodes); | |||
| } | |||
| collectConditionNodes(rung, &nodes); | |||
| if (rung.output.has_value()) | |||
| { | |||
| nodes.push_back(&*rung.output); | |||
| @@ -414,19 +427,20 @@ bool Project::validate( | |||
| collectRegisterAddressesForLogicNode(node->config, &poll_addresses); | |||
| RegisterAddress destination{RegisterArea::D, 0}; | |||
| if (node->configured | |||
| && isFloatWriteDestination(node->config, &destination)) | |||
| && isWordWriteDestination(node->config, &destination)) | |||
| { | |||
| for (const HmiDataBinding &binding : hmi_bindings) | |||
| { | |||
| if (binding.type == RegisterDataType::Float32 | |||
| const int word_count = registerDataTypeWordCount(binding.type); | |||
| if (word_count > 1 | |||
| && binding.address.area() == RegisterArea::D | |||
| && destination.index() >= binding.address.index() | |||
| && destination.index() | |||
| < binding.address.index() + 2) | |||
| < binding.address.index() + word_count) | |||
| { | |||
| setError( | |||
| error, | |||
| "普通整数指令不能写入 Float32 占用的 D" | |||
| "16 位整数指令不能写入多字 HMI 数值占用的 D" | |||
| + std::to_string(destination.index())); | |||
| return false; | |||
| } | |||
| @@ -435,6 +449,7 @@ bool Project::validate( | |||
| } | |||
| } | |||
| } | |||
| // 先按区域和地址排序,再用 unique 删除相邻重复项 | |||
| std::sort( | |||
| poll_addresses.begin(), poll_addresses.end(), | |||
| [](const RegisterAddress &left, const RegisterAddress &right) | |||
| @@ -454,11 +469,7 @@ bool Project::validate( | |||
| return true; | |||
| } | |||
| bool Project::validateForRunning(std::string *error) const | |||
| { | |||
| return validateForRunning(defaultProjectLimitSettings(), error); | |||
| } | |||
| // 在工程结构校验通过后,继续检查运行态对象是否全部配置完成 | |||
| bool Project::validateForRunning( | |||
| const ProjectLimitSettings &limits, std::string *error) const | |||
| { | |||
| @@ -26,7 +26,7 @@ struct ProjectMetadata | |||
| // 工程显示名称 | |||
| std::string name; | |||
| // 工程文件格式版本 | |||
| std::string formatVersion = "1.0"; | |||
| std::string formatVersion = "4.0"; | |||
| }; | |||
| // 聚合工程中的 HMI 页面、报警、寄存器注释和控制逻辑 | |||
| @@ -48,12 +48,10 @@ struct Project | |||
| // 按地址查找工程注释,找不到时返回 nullptr | |||
| const RegisterComment *findRegisterComment(const RegisterAddress &address) const; | |||
| // 编辑态校验:允许尚未绑定完成的草稿 | |||
| bool validate(std::string *error = nullptr) const; | |||
| bool validate( | |||
| const ProjectLimitSettings &limits, | |||
| std::string *error = nullptr) const; | |||
| // 运行态校验:额外要求所有运行所需对象都已配置 | |||
| bool validateForRunning(std::string *error = nullptr) const; | |||
| bool validateForRunning( | |||
| const ProjectLimitSettings &limits, | |||
| std::string *error = nullptr) const; | |||
| @@ -6,6 +6,7 @@ | |||
| namespace { | |||
| // 判断寄存器区域是否为项目支持的 M 区或 D 区 | |||
| bool isSupportedArea(RegisterArea area) | |||
| { | |||
| // 地址值对象只承认项目定义的 M 区和 D 区 | |||
| @@ -14,8 +15,10 @@ bool isSupportedArea(RegisterArea area) | |||
| } // namespace | |||
| // 解析文本中的 M/D 区域和数字下标,并返回具体失败原因 | |||
| RegisterAddressParseResult parseRegisterAddress(const std::string &text) | |||
| { | |||
| // 从两端找到第一个非空白字符,后续解析只处理去掉首尾空白后的内容 | |||
| const auto first = std::find_if_not( | |||
| text.begin(), text.end(), [](unsigned char value) { return std::isspace(value); }); | |||
| const auto last = std::find_if_not( | |||
| @@ -23,9 +26,11 @@ RegisterAddressParseResult parseRegisterAddress(const std::string &text) | |||
| .base(); | |||
| if (first >= last) | |||
| { | |||
| // 首尾空白之外没有任何内容,说明用户没有输入地址 | |||
| return {false, {RegisterArea::M, 0}, RegisterAddressParseError::Empty}; | |||
| } | |||
| // 将首字符统一转成大写,使 m10 和 M10 的解析结果保持一致 | |||
| const char area_character = static_cast<char>( | |||
| std::toupper(static_cast<unsigned char>(*first))); | |||
| RegisterArea area = RegisterArea::M; | |||
| @@ -39,57 +44,68 @@ RegisterAddressParseResult parseRegisterAddress(const std::string &text) | |||
| } | |||
| else | |||
| { | |||
| // 首字符不是 M 或 D,返回“不支持的区域”错误并保留默认地址 | |||
| return {false, {RegisterArea::M, 0}, RegisterAddressParseError::UnsupportedArea}; | |||
| } | |||
| if (first + 1 == last) | |||
| { | |||
| // 只有区域字母而没有数字下标,例如单独输入 M 或 D | |||
| return {false, {area, 0}, RegisterAddressParseError::InvalidFormat}; | |||
| } | |||
| int index = 0; | |||
| // 逐字符读取下标,避免直接调用字符串转数字函数带来格式不透明的问题 | |||
| for (auto current = first + 1; current != last; ++current) | |||
| { | |||
| const unsigned char character = static_cast<unsigned char>(*current); | |||
| if (!std::isdigit(character)) | |||
| { | |||
| // 下标部分只能由十进制数字组成 | |||
| return {false, {area, 0}, RegisterAddressParseError::InvalidFormat}; | |||
| } | |||
| const int digit = *current - '0'; | |||
| if (index > (std::numeric_limits<int>::max() - digit) / 10) | |||
| { | |||
| // 在执行 index * 10 + digit 前先检查,防止整数溢出 | |||
| return {false, {area, 0}, RegisterAddressParseError::OutOfRange}; | |||
| } | |||
| index = index * 10 + digit; | |||
| } | |||
| RegisterAddress address{area, index}; | |||
| // 解析完成后再检查项目边界,区分格式正确但下标超范围的地址 | |||
| return address.isValid() | |||
| ? RegisterAddressParseResult{true, address, RegisterAddressParseError::None} | |||
| : RegisterAddressParseResult{false, address, RegisterAddressParseError::OutOfRange}; | |||
| } | |||
| // 使用指定区域和下标创建寄存器地址对象 | |||
| RegisterAddress::RegisterAddress(RegisterArea area, int index) | |||
| : area_(area), | |||
| index_(index) | |||
| { | |||
| } | |||
| // 返回地址所属的寄存器区域 | |||
| RegisterArea RegisterAddress::area() const | |||
| { | |||
| return area_; | |||
| } | |||
| // 返回地址的数字下标 | |||
| int RegisterAddress::index() const | |||
| { | |||
| return index_; | |||
| } | |||
| // 检查区域和下标是否符合项目支持的地址范围 | |||
| bool RegisterAddress::isValid() const | |||
| { | |||
| return isSupportedArea(area_) | |||
| && index_ >= kMinimumIndex && index_ <= kMaximumIndex; | |||
| } | |||
| // 将地址转换为 M10 或 D4000 这样的显示文本 | |||
| std::string RegisterAddress::toString() const | |||
| { | |||
| const char *areaName = nullptr; | |||
| @@ -109,11 +125,13 @@ std::string RegisterAddress::toString() const | |||
| return std::string(areaName) + std::to_string(index_); | |||
| } | |||
| // 判断当前地址与另一个地址是否完全相同 | |||
| bool RegisterAddress::operator==(const RegisterAddress &other) const | |||
| { | |||
| return area_ == other.area_ && index_ == other.index_; | |||
| } | |||
| // 判断当前地址与另一个地址是否不同 | |||
| bool RegisterAddress::operator!=(const RegisterAddress &other) const | |||
| { | |||
| return !(*this == other); | |||
| @@ -5,18 +5,18 @@ | |||
| // 解析用户输入的 M/D 地址时可能出现的失败原因 | |||
| enum class RegisterAddressParseError | |||
| { | |||
| None, | |||
| Empty, | |||
| UnsupportedArea, | |||
| InvalidFormat, | |||
| OutOfRange | |||
| None, // 地址文本解析成功 | |||
| Empty, // 输入文本为空或只包含空白 | |||
| UnsupportedArea, // 地址区域不是项目支持的 M 或 D | |||
| InvalidFormat, // 地址文本格式不符合要求 | |||
| OutOfRange // 地址下标超出 0~4000 范围 | |||
| }; | |||
| // PLC 中支持的寄存器区域:M 是位,D 是 16 位有符号字 | |||
| enum class RegisterArea | |||
| { | |||
| M, | |||
| D | |||
| M, // M 位寄存器区域 | |||
| D // D 16 位数据寄存器区域 | |||
| }; | |||
| // 表示一个已经拆分为“区域 + 下标”的 M/D 地址 | |||
| @@ -24,33 +24,39 @@ enum class RegisterArea | |||
| class RegisterAddress | |||
| { | |||
| public: | |||
| static constexpr int kMinimumIndex = 0; | |||
| static constexpr int kMaximumIndex = 4000; | |||
| 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; | |||
| // 判断两个地址是否存在区域或下标差异 | |||
| bool operator!=(const RegisterAddress &other) const; | |||
| private: | |||
| RegisterArea area_; | |||
| int index_; | |||
| RegisterArea area_; // 保存地址所属的寄存器区域 | |||
| int index_; // 保存地址的数字下标 | |||
| }; | |||
| // 文本地址解析结果;失败时 address 保持默认值,具体原因见 error | |||
| struct RegisterAddressParseResult | |||
| { | |||
| // 表示文本是否成功解析为有效地址 | |||
| bool succeeded = false; | |||
| // 保存解析出的地址或失败时的默认地址 | |||
| RegisterAddress address{RegisterArea::M, 0}; | |||
| // 保存成功状态或具体失败原因 | |||
| RegisterAddressParseError error = RegisterAddressParseError::InvalidFormat; | |||
| }; | |||
| @@ -2,8 +2,10 @@ | |||
| #include <algorithm> | |||
| // 根据监控点列表生成按添加顺序排列的地址列表 | |||
| const std::vector<RegisterAddress> &RegisterMonitorModel::addresses() const | |||
| { | |||
| // 地址列表是兼容视图,每次访问时从完整监控点列表重新生成 | |||
| addresses_.clear(); | |||
| addresses_.reserve(points_.size()); | |||
| for (const MonitorPoint &point : points_) | |||
| @@ -13,13 +15,16 @@ const std::vector<RegisterAddress> &RegisterMonitorModel::addresses() const | |||
| return addresses_; | |||
| } | |||
| // 返回当前保存的完整监控点列表 | |||
| const std::vector<MonitorPoint> &RegisterMonitorModel::points() const | |||
| { | |||
| return points_; | |||
| } | |||
| // 判断相同地址和数据类型的监控点是否已经存在 | |||
| bool RegisterMonitorModel::contains(const MonitorPoint &point) const | |||
| { | |||
| // 地址相同但数据类型不同,可以作为不同监控点同时存在 | |||
| return std::find_if( | |||
| points_.cbegin(), points_.cend(), [&point](const MonitorPoint &candidate) | |||
| { | |||
| @@ -28,14 +33,15 @@ bool RegisterMonitorModel::contains(const MonitorPoint &point) const | |||
| }) != 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 | |||
| || (point.address.area() == RegisterArea::M | |||
| && point.dataType != RegisterDataType::Int16) | |||
| || (point.address.area() == RegisterArea::D | |||
| && !registerDataTypeAddressIsValid(point.dataType, point.address)) | |||
| || contains(point) | |||
| || points_.size() >= kMaximumItemCount) | |||
| { | |||
| @@ -45,6 +51,7 @@ bool RegisterMonitorModel::add(const MonitorPoint &point) | |||
| return true; | |||
| } | |||
| // 删除指定地址的第一个监控点 | |||
| bool RegisterMonitorModel::remove(const RegisterAddress &address) | |||
| { | |||
| const auto position = std::find_if( | |||
| @@ -60,6 +67,7 @@ bool RegisterMonitorModel::remove(const RegisterAddress &address) | |||
| return true; | |||
| } | |||
| // 删除地址和数据类型都匹配的监控点 | |||
| bool RegisterMonitorModel::remove(const MonitorPoint &point) | |||
| { | |||
| const auto position = std::find_if( | |||
| @@ -76,6 +84,7 @@ bool RegisterMonitorModel::remove(const MonitorPoint &point) | |||
| return true; | |||
| } | |||
| // 清空当前会话中的全部监控点和地址视图 | |||
| void RegisterMonitorModel::clear() | |||
| { | |||
| addresses_.clear(); | |||
| @@ -10,27 +10,27 @@ | |||
| // 监控读数的可用状态 | |||
| enum class MonitorValueState | |||
| { | |||
| Valid, | |||
| Unavailable, | |||
| CommunicationFault | |||
| Valid, // 已成功读取并可使用 | |||
| Unavailable, // 当前没有可用的寄存器值 | |||
| 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; | |||
| RegisterAddress address{RegisterArea::M, 0}; // 监控点起始地址 | |||
| RegisterDataType dataType = RegisterDataType::Int16; // 监控点的数据类型 | |||
| int endAddress = 0; // 多字监控点占用的结束地址 | |||
| MonitorValueState state = MonitorValueState::Unavailable; // 当前读数状态 | |||
| bool bitValue = false; // M 位监控点的当前值 | |||
| RegisterNumericValue numericValue = std::int16_t{0}; // D 数值监控点的当前值 | |||
| }; | |||
| // 监控列表中的一个地址和数据类型组合 | |||
| struct MonitorPoint | |||
| { | |||
| RegisterAddress address{RegisterArea::M, 0}; | |||
| RegisterDataType dataType = RegisterDataType::Int16; | |||
| RegisterAddress address{RegisterArea::M, 0}; // 监控点起始地址 | |||
| RegisterDataType dataType = RegisterDataType::Int16; // 监控点的数据类型 | |||
| }; | |||
| // 运行期间由用户临时维护的监控地址列表,不写入工程文件 | |||
| @@ -41,6 +41,7 @@ public: | |||
| // 返回当前按添加顺序保存的地址 | |||
| const std::vector<RegisterAddress> &addresses() const; | |||
| // 返回当前按添加顺序保存的监控点 | |||
| const std::vector<MonitorPoint> &points() const; | |||
| // 判断地址是否已经在监控列表中 | |||
| bool contains(const MonitorPoint &point) const; | |||
| @@ -54,6 +55,6 @@ public: | |||
| void clear(); | |||
| private: | |||
| mutable std::vector<RegisterAddress> addresses_; | |||
| std::vector<MonitorPoint> points_; | |||
| mutable std::vector<RegisterAddress> addresses_; // 由监控点生成的地址兼容列表 | |||
| std::vector<MonitorPoint> points_; // 当前会话中的监控点列表 | |||
| }; | |||
| @@ -3,7 +3,8 @@ | |||
| #include "register_address.h" | |||
| #include <cstdint> | |||
| #include <array> | |||
| #include <utility> | |||
| #include <vector> | |||
| // 寄存器仓库操作失败原因;服务层会把它转换成用户可读消息 | |||
| enum class RegisterError | |||
| @@ -38,11 +39,12 @@ struct RegisterWriteResult | |||
| RegisterError error = RegisterError::Unavailable; // 写入失败原因 | |||
| }; | |||
| struct WordPairReadResult | |||
| // 连续 D 区字寄存器读取结果 | |||
| struct WordsReadResult | |||
| { | |||
| bool succeeded = false; | |||
| std::array<std::int16_t, 2> values{}; | |||
| RegisterError error = RegisterError::Unavailable; | |||
| bool succeeded = false; // 读取是否成功 | |||
| std::vector<std::int16_t> values; // 按地址顺序返回的字值 | |||
| RegisterError error = RegisterError::Unavailable; // 读取失败原因 | |||
| }; | |||
| // M/D 寄存器访问边界 | |||
| @@ -61,33 +63,58 @@ public: | |||
| // 写入 D 区 16 位字寄存器 | |||
| virtual RegisterWriteResult writeWord( | |||
| const RegisterAddress &address, std::int16_t value) = 0; | |||
| virtual WordPairReadResult readWordPair(const RegisterAddress &address) const | |||
| // 连续读取多个 D 区字;默认实现逐个调用 readWord | |||
| virtual WordsReadResult readWords( | |||
| const RegisterAddress &address, int count) const | |||
| { | |||
| // 批量读取要求地址为 D 区且整个范围不能超出 0~4000 | |||
| if (!address.isValid() || address.area() != RegisterArea::D | |||
| || address.index() >= RegisterAddress::kMaximumIndex) | |||
| || count < 1 | |||
| || address.index() > RegisterAddress::kMaximumIndex - count + 1) | |||
| { | |||
| 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}; | |||
| std::vector<std::int16_t> values; | |||
| values.reserve(static_cast<std::size_t>(count)); | |||
| for (int offset = 0; offset < count; ++offset) | |||
| { | |||
| // 默认实现逐字读取,子类可按通信协议覆盖为一次批量读取 | |||
| const WordReadResult result = readWord( | |||
| RegisterAddress{RegisterArea::D, address.index() + offset}); | |||
| if (!result.succeeded) | |||
| { | |||
| return {false, {}, result.error}; | |||
| } | |||
| values.push_back(result.value); | |||
| } | |||
| return {true, std::move(values), RegisterError::None}; | |||
| } | |||
| virtual RegisterWriteResult writeWordPair( | |||
| // 连续写入多个 D 区字;默认实现逐个调用 writeWord | |||
| virtual RegisterWriteResult writeWords( | |||
| const RegisterAddress &address, | |||
| const std::array<std::int16_t, 2> &values) | |||
| const std::vector<std::int16_t> &values) | |||
| { | |||
| const int count = static_cast<int>(values.size()); | |||
| // 空数组和越界范围都不执行任何写入,避免留下部分结果 | |||
| if (!address.isValid() || address.area() != RegisterArea::D | |||
| || address.index() >= RegisterAddress::kMaximumIndex) | |||
| || count < 1 | |||
| || address.index() > RegisterAddress::kMaximumIndex - count + 1) | |||
| { | |||
| 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]); | |||
| for (int offset = 0; offset < count; ++offset) | |||
| { | |||
| // 某一个字写入失败时立即返回该错误,不继续写后面的字 | |||
| const RegisterWriteResult result = writeWord( | |||
| RegisterAddress{RegisterArea::D, address.index() + offset}, | |||
| values[static_cast<std::size_t>(offset)]); | |||
| if (!result.succeeded) | |||
| { | |||
| return result; | |||
| } | |||
| } | |||
| return {true, RegisterError::None}; | |||
| } | |||
| }; | |||
| @@ -2,12 +2,62 @@ | |||
| #include <cmath> | |||
| #include <cstring> | |||
| #include <limits> | |||
| namespace { | |||
| // 各数据类型的固定描述,供校验、JSON 和界面统一使用 | |||
| constexpr RegisterDataTypeDescriptor kInt16Descriptor{ | |||
| "int16", "Int16", 1, 1, false}; | |||
| constexpr RegisterDataTypeDescriptor kInt32Descriptor{ | |||
| "int32", "Int32", 2, 1, false}; | |||
| constexpr RegisterDataTypeDescriptor kFloat32Descriptor{ | |||
| "float32", "Float32", 2, 1, true}; | |||
| constexpr RegisterDataTypeDescriptor kFloat64Descriptor{ | |||
| "float64", "Double (Float64)", 4, 2, true}; | |||
| constexpr RegisterDataTypeDescriptor kInvalidDescriptor{ | |||
| "", "Unknown", 0, 1, false}; | |||
| // 将固定长度数组转换为动态数组,便于统一返回编码结果 | |||
| template <std::size_t Size> | |||
| std::vector<std::int16_t> toVector(const std::array<std::int16_t, Size> &words) | |||
| { | |||
| return std::vector<std::int16_t>(words.cbegin(), words.cend()); | |||
| } | |||
| } // namespace | |||
| // 根据枚举值返回对应的数据类型描述 | |||
| const RegisterDataTypeDescriptor ®isterDataTypeDescriptor(RegisterDataType type) | |||
| { | |||
| switch (type) | |||
| { | |||
| case RegisterDataType::Int16: | |||
| return kInt16Descriptor; | |||
| case RegisterDataType::Int32: | |||
| return kInt32Descriptor; | |||
| case RegisterDataType::Float32: | |||
| return kFloat32Descriptor; | |||
| case RegisterDataType::Float64: | |||
| return kFloat64Descriptor; | |||
| default: | |||
| return kInvalidDescriptor; | |||
| } | |||
| } | |||
| // 判断枚举值是否对应受支持的数据类型 | |||
| bool registerDataTypeIsSupported(RegisterDataType type) | |||
| { | |||
| return registerDataTypeDescriptor(type).wordCount > 0; | |||
| } | |||
| // 返回数据类型用于 JSON 的稳定名称 | |||
| const char *registerDataTypeName(RegisterDataType type) | |||
| { | |||
| return type == RegisterDataType::Float32 ? "float32" : "int16"; | |||
| return registerDataTypeDescriptor(type).jsonName; | |||
| } | |||
| // 将 JSON 中的数据类型名称解析为枚举值 | |||
| bool parseRegisterDataType(const std::string &text, RegisterDataType *type) | |||
| { | |||
| if (type == nullptr) | |||
| @@ -24,14 +74,59 @@ bool parseRegisterDataType(const std::string &text, RegisterDataType *type) | |||
| *type = RegisterDataType::Float32; | |||
| return true; | |||
| } | |||
| if (text == "int32") | |||
| { | |||
| *type = RegisterDataType::Int32; | |||
| return true; | |||
| } | |||
| if (text == "float64") | |||
| { | |||
| *type = RegisterDataType::Float64; | |||
| return true; | |||
| } | |||
| return false; | |||
| } | |||
| // 返回数据类型需要连续读取的 D 字数量 | |||
| int registerDataTypeWordCount(RegisterDataType type) | |||
| { | |||
| return type == RegisterDataType::Float32 ? 2 : 1; | |||
| return registerDataTypeDescriptor(type).wordCount; | |||
| } | |||
| // 检查数据类型的起始地址是否满足 D 区范围和对齐规则 | |||
| bool registerDataTypeAddressIsValid( | |||
| RegisterDataType type, const RegisterAddress &address) | |||
| { | |||
| const RegisterDataTypeDescriptor &descriptor = registerDataTypeDescriptor(type); | |||
| return descriptor.wordCount > 0 | |||
| && address.isValid() | |||
| && address.area() == RegisterArea::D | |||
| && address.index() <= RegisterAddress::kMaximumIndex - descriptor.wordCount + 1 | |||
| && address.index() % descriptor.addressAlignment == 0; | |||
| } | |||
| // 按低字在前的顺序编码 32 位整数 | |||
| std::array<std::int16_t, 2> Int32Codec::encode(std::int32_t value) | |||
| { | |||
| // memcpy 只复制位模式,避免直接把整数指针强转后读取造成未定义行为 | |||
| 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)}; | |||
| } | |||
| // 按低字在前的顺序解码 32 位整数 | |||
| std::int32_t Int32Codec::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); | |||
| std::int32_t value = 0; | |||
| std::memcpy(&value, &bits, sizeof(value)); | |||
| return value; | |||
| } | |||
| // 按低字在前的顺序编码 32 位浮点数 | |||
| std::array<std::int16_t, 2> Float32Codec::encode(float value) | |||
| { | |||
| std::uint32_t bits = 0; | |||
| @@ -40,6 +135,7 @@ std::array<std::int16_t, 2> Float32Codec::encode(float value) | |||
| static_cast<std::int16_t>(bits >> 16U)}; | |||
| } | |||
| // 按低字在前的顺序解码 32 位浮点数 | |||
| std::optional<float> Float32Codec::decode( | |||
| std::int16_t low_word, std::int16_t high_word) | |||
| { | |||
| @@ -49,3 +145,110 @@ std::optional<float> Float32Codec::decode( | |||
| std::memcpy(&value, &bits, sizeof(value)); | |||
| return std::isfinite(value) ? std::optional<float>(value) : std::nullopt; | |||
| } | |||
| // 按低字在前的顺序编码 64 位浮点数 | |||
| std::array<std::int16_t, 4> Float64Codec::encode(double value) | |||
| { | |||
| std::uint64_t bits = 0; | |||
| std::memcpy(&bits, &value, sizeof(bits)); | |||
| return { | |||
| static_cast<std::int16_t>(bits & 0xffffU), | |||
| static_cast<std::int16_t>((bits >> 16U) & 0xffffU), | |||
| static_cast<std::int16_t>((bits >> 32U) & 0xffffU), | |||
| static_cast<std::int16_t>(bits >> 48U)}; | |||
| } | |||
| // 按低字在前的顺序解码 64 位浮点数 | |||
| std::optional<double> Float64Codec::decode( | |||
| const std::array<std::int16_t, 4> &words) | |||
| { | |||
| const std::uint64_t bits = static_cast<std::uint16_t>(words[0]) | |||
| | (static_cast<std::uint64_t>(static_cast<std::uint16_t>(words[1])) << 16U) | |||
| | (static_cast<std::uint64_t>(static_cast<std::uint16_t>(words[2])) << 32U) | |||
| | (static_cast<std::uint64_t>(static_cast<std::uint16_t>(words[3])) << 48U); | |||
| double value = 0.0; | |||
| std::memcpy(&value, &bits, sizeof(value)); | |||
| return std::isfinite(value) ? std::optional<double>(value) : std::nullopt; | |||
| } | |||
| // 根据数据类型和字数解码寄存器数值 | |||
| std::optional<RegisterNumericValue> decodeRegisterNumericValue( | |||
| RegisterDataType type, const std::vector<std::int16_t> &words) | |||
| { | |||
| if (static_cast<int>(words.size()) != registerDataTypeWordCount(type)) | |||
| { | |||
| return std::nullopt; | |||
| } | |||
| // 不同类型的字数不同,先拒绝长度不匹配的数据 | |||
| switch (type) | |||
| { | |||
| case RegisterDataType::Int16: | |||
| return RegisterNumericValue{words[0]}; | |||
| case RegisterDataType::Int32: | |||
| return RegisterNumericValue{Int32Codec::decode(words[0], words[1])}; | |||
| case RegisterDataType::Float32: | |||
| { | |||
| const std::optional<float> value = Float32Codec::decode(words[0], words[1]); | |||
| return value.has_value() | |||
| ? std::optional<RegisterNumericValue>{RegisterNumericValue{*value}} | |||
| : std::nullopt; | |||
| } | |||
| case RegisterDataType::Float64: | |||
| { | |||
| const std::optional<double> value = Float64Codec::decode( | |||
| {words[0], words[1], words[2], words[3]}); | |||
| return value.has_value() | |||
| ? std::optional<RegisterNumericValue>{RegisterNumericValue{*value}} | |||
| : std::nullopt; | |||
| } | |||
| default: | |||
| return std::nullopt; | |||
| } | |||
| } | |||
| // 根据数据类型和数值范围编码寄存器字序列 | |||
| std::optional<std::vector<std::int16_t>> encodeRegisterNumericValue( | |||
| RegisterDataType type, double value) | |||
| { | |||
| if (!std::isfinite(value)) | |||
| { | |||
| return std::nullopt; | |||
| } | |||
| // 整数类型需要先检查整数性和目标类型的上下限 | |||
| switch (type) | |||
| { | |||
| case RegisterDataType::Int16: | |||
| if (std::trunc(value) != value | |||
| || value < std::numeric_limits<std::int16_t>::min() | |||
| || value > std::numeric_limits<std::int16_t>::max()) | |||
| { | |||
| return std::nullopt; | |||
| } | |||
| return std::vector<std::int16_t>{static_cast<std::int16_t>(value)}; | |||
| case RegisterDataType::Int32: | |||
| if (std::trunc(value) != value | |||
| || value < std::numeric_limits<std::int32_t>::min() | |||
| || value > std::numeric_limits<std::int32_t>::max()) | |||
| { | |||
| return std::nullopt; | |||
| } | |||
| return toVector(Int32Codec::encode(static_cast<std::int32_t>(value))); | |||
| case RegisterDataType::Float32: | |||
| { | |||
| if (value < -static_cast<double>(std::numeric_limits<float>::max()) | |||
| || value > static_cast<double>(std::numeric_limits<float>::max())) | |||
| { | |||
| return std::nullopt; | |||
| } | |||
| const float converted = static_cast<float>(value); | |||
| return std::isfinite(converted) | |||
| ? std::optional<std::vector<std::int16_t>>{ | |||
| toVector(Float32Codec::encode(converted))} | |||
| : std::nullopt; | |||
| } | |||
| case RegisterDataType::Float64: | |||
| return toVector(Float64Codec::encode(value)); | |||
| default: | |||
| return std::nullopt; | |||
| } | |||
| } | |||
| @@ -1,22 +1,90 @@ | |||
| #pragma once | |||
| #include "register_address.h" | |||
| #include <array> | |||
| #include <cstdint> | |||
| #include <optional> | |||
| #include <string> | |||
| #include <variant> | |||
| #include <vector> | |||
| enum class RegisterDataType | |||
| { | |||
| Int16, | |||
| Float32 | |||
| Int16, // 一个 16 位有符号整数,占 1 个 D 字 | |||
| Int32, // 一个 32 位有符号整数,占 2 个 D 字 | |||
| Float32, // 一个 32 位浮点数,占 2 个 D 字 | |||
| Float64 // 一个 64 位浮点数,占 4 个 D 字 | |||
| }; | |||
| // 描述数据类型对应的工程名称、显示名称、字数和地址要求 | |||
| struct RegisterDataTypeDescriptor | |||
| { | |||
| const char *jsonName = "int16"; // JSON 中保存的稳定名称 | |||
| const char *displayName = "Int16"; // 界面显示名称 | |||
| int wordCount = 1; // 占用的连续 D 字数量 | |||
| int addressAlignment = 1; // 起始地址必须满足的对齐倍数 | |||
| bool floatingPoint = false; // 是否为浮点类型 | |||
| }; | |||
| // 支持在不同数值类型之间保存寄存器解码结果 | |||
| using RegisterNumericValue = std::variant<std::int16_t, std::int32_t, float, double>; | |||
| // 描述一段连续的 D 区字地址范围 | |||
| struct RegisterWordRange | |||
| { | |||
| RegisterAddress start{RegisterArea::D, 0}; // 连续范围的起始地址 | |||
| int wordCount = 1; // 连续范围包含的 D 字数量 | |||
| // 比较两个 D 区字范围是否完全相同 | |||
| bool operator==(const RegisterWordRange &other) const | |||
| { | |||
| return start == other.start && wordCount == other.wordCount; | |||
| } | |||
| }; | |||
| // 返回数据类型的静态描述,非法类型返回 Unknown 描述 | |||
| const RegisterDataTypeDescriptor ®isterDataTypeDescriptor(RegisterDataType type); | |||
| // 判断数据类型是否受当前实现支持 | |||
| bool registerDataTypeIsSupported(RegisterDataType type); | |||
| // 返回数据类型对应的 JSON 稳定名称 | |||
| const char *registerDataTypeName(RegisterDataType type); | |||
| // 将 JSON 文本解析为数据类型 | |||
| bool parseRegisterDataType(const std::string &text, RegisterDataType *type); | |||
| // 返回数据类型占用的 D 字数量 | |||
| int registerDataTypeWordCount(RegisterDataType type); | |||
| // 校验数据类型和起始地址是否满足区域、范围及对齐要求 | |||
| bool registerDataTypeAddressIsValid( | |||
| RegisterDataType type, const RegisterAddress &address); | |||
| struct Int32Codec | |||
| { | |||
| // 将 32 位整数拆成低字在前的两个 D 字 | |||
| static std::array<std::int16_t, 2> encode(std::int32_t value); | |||
| // 将低字在前的两个 D 字还原为 32 位整数 | |||
| static std::int32_t decode(std::int16_t low_word, std::int16_t high_word); | |||
| }; | |||
| struct Float32Codec | |||
| { | |||
| // 将 32 位浮点数拆成低字在前的两个 D 字 | |||
| static std::array<std::int16_t, 2> encode(float value); | |||
| // 解码浮点数,遇到 NaN 或 Inf 时返回空值 | |||
| static std::optional<float> decode(std::int16_t low_word, std::int16_t high_word); | |||
| }; | |||
| struct Float64Codec | |||
| { | |||
| // 将 64 位浮点数拆成低字在前的四个 D 字 | |||
| static std::array<std::int16_t, 4> encode(double value); | |||
| // 解码四个 D 字,遇到 NaN 或 Inf 时返回空值 | |||
| static std::optional<double> decode( | |||
| const std::array<std::int16_t, 4> &words); | |||
| }; | |||
| // 按指定数据类型把 D 字序列解码为数值 | |||
| std::optional<RegisterNumericValue> decodeRegisterNumericValue( | |||
| RegisterDataType type, const std::vector<std::int16_t> &words); | |||
| // 按指定数据类型把数值编码为 D 字序列 | |||
| std::optional<std::vector<std::int16_t>> encodeRegisterNumericValue( | |||
| RegisterDataType type, double value); | |||
| @@ -1,55 +1,60 @@ | |||
| #include "runtime_state.h" | |||
| // 返回状态机当前保存的运行模式 | |||
| ApplicationMode RuntimeState::mode() const | |||
| { | |||
| return mode_; | |||
| } | |||
| // 根据当前运行模式返回对应的能力策略 | |||
| ModePolicy RuntimeState::policy() const | |||
| { | |||
| return policyForMode(mode_); | |||
| } | |||
| // 尝试返回编辑态,编辑态是两个运行态之间的共同中转状态 | |||
| ModeTransitionResult RuntimeState::enterEditing() | |||
| { | |||
| // 两种运行态都必须先回到编辑态,作为后续模式切换的唯一中转点 | |||
| if (mode_ == ApplicationMode::Editing) | |||
| { | |||
| return {false, ModeTransitionError::AlreadyInRequestedMode}; | |||
| return {false, ModeTransitionError::AlreadyInRequestedMode, {}}; | |||
| } | |||
| mode_ = ApplicationMode::Editing; | |||
| return {true, ModeTransitionError::None}; | |||
| return {true, ModeTransitionError::None, {}}; | |||
| } | |||
| // 尝试从编辑态进入离线仿真运行态 | |||
| ModeTransitionResult RuntimeState::enterOfflineRunning() | |||
| { | |||
| if (mode_ == ApplicationMode::OfflineRunning) | |||
| { | |||
| return {false, ModeTransitionError::AlreadyInRequestedMode}; | |||
| return {false, ModeTransitionError::AlreadyInRequestedMode, {}}; | |||
| } | |||
| if (mode_ != ApplicationMode::Editing) | |||
| { | |||
| return {false, ModeTransitionError::MustReturnToEditing}; | |||
| return {false, ModeTransitionError::MustReturnToEditing, {}}; | |||
| } | |||
| mode_ = ApplicationMode::OfflineRunning; | |||
| return {true, ModeTransitionError::None}; | |||
| return {true, ModeTransitionError::None, {}}; | |||
| } | |||
| // 尝试从编辑态进入真机联机运行态,并检查 PLC 初次读取状态 | |||
| ModeTransitionResult RuntimeState::enterOnlineRunning(bool initial_plc_read_completed) | |||
| { | |||
| if (mode_ == ApplicationMode::OnlineRunning) | |||
| { | |||
| return {false, ModeTransitionError::AlreadyInRequestedMode}; | |||
| return {false, ModeTransitionError::AlreadyInRequestedMode, {}}; | |||
| } | |||
| if (mode_ != ApplicationMode::Editing) | |||
| { | |||
| return {false, ModeTransitionError::MustReturnToEditing}; | |||
| return {false, ModeTransitionError::MustReturnToEditing, {}}; | |||
| } | |||
| if (!initial_plc_read_completed) | |||
| { | |||
| // 未读取 PLC 时禁止进入真机态,防止用未知缓存值驱动界面 | |||
| return {false, ModeTransitionError::InitialPlcReadRequired}; | |||
| return {false, ModeTransitionError::InitialPlcReadRequired, {}}; | |||
| } | |||
| mode_ = ApplicationMode::OnlineRunning; | |||
| return {true, ModeTransitionError::None}; | |||
| return {true, ModeTransitionError::None, {}}; | |||
| } | |||
| @@ -1,5 +1,7 @@ | |||
| #pragma once | |||
| #include <string> | |||
| // 应用当前运行模式;三个状态之间不能直接从离线切到真机 | |||
| enum class ApplicationMode | |||
| { | |||
| @@ -63,6 +65,7 @@ constexpr ModePolicy policyForMode(ApplicationMode mode) | |||
| } | |||
| default: | |||
| { | |||
| // 非法枚举值没有任何运行能力,调用方应将其视为无效状态 | |||
| return {}; | |||
| } | |||
| } | |||
| @@ -86,6 +89,8 @@ struct ModeTransitionResult | |||
| bool succeeded = false; | |||
| // 切换失败原因 | |||
| ModeTransitionError error = ModeTransitionError::None; | |||
| // 服务层补充的具体失败原因,状态机自身可以留空 | |||
| std::string detail; | |||
| }; | |||
| // 保存当前模式并执行最基本的状态机约束 | |||
| @@ -1,11 +1,15 @@ | |||
| #include "virtual_register_repository.h" | |||
| #include <algorithm> | |||
| // 构造虚拟仓库并将所有寄存器初始化为默认值 | |||
| VirtualRegisterRepository::VirtualRegisterRepository() | |||
| { | |||
| // 构造时清空离线寄存器,确保初始值确定 | |||
| clear(); | |||
| } | |||
| // 校验地址后读取指定的 M 区位值 | |||
| BitReadResult VirtualRegisterRepository::readBit(const RegisterAddress &address) const | |||
| { | |||
| // 先校验地址和区域,再访问对应的内存槽位 | |||
| @@ -20,6 +24,7 @@ BitReadResult VirtualRegisterRepository::readBit(const RegisterAddress &address) | |||
| return {true, bits_[static_cast<std::size_t>(address.index())], RegisterError::None}; | |||
| } | |||
| // 校验地址后更新指定的 M 区位值 | |||
| RegisterWriteResult VirtualRegisterRepository::writeBit( | |||
| const RegisterAddress &address, bool value) | |||
| { | |||
| @@ -36,6 +41,7 @@ RegisterWriteResult VirtualRegisterRepository::writeBit( | |||
| return {true, RegisterError::None}; | |||
| } | |||
| // 校验地址后读取指定的 D 区字值 | |||
| WordReadResult VirtualRegisterRepository::readWord(const RegisterAddress &address) const | |||
| { | |||
| // 字操作只允许访问 D 区 | |||
| @@ -50,6 +56,7 @@ WordReadResult VirtualRegisterRepository::readWord(const RegisterAddress &addres | |||
| return {true, words_[static_cast<std::size_t>(address.index())], RegisterError::None}; | |||
| } | |||
| // 校验地址后更新指定的 D 区字值 | |||
| RegisterWriteResult VirtualRegisterRepository::writeWord( | |||
| const RegisterAddress &address, std::int16_t value) | |||
| { | |||
| @@ -66,19 +73,42 @@ RegisterWriteResult VirtualRegisterRepository::writeWord( | |||
| return {true, RegisterError::None}; | |||
| } | |||
| WordPairReadResult VirtualRegisterRepository::readWordPair( | |||
| const RegisterAddress &address) const | |||
| // 校验连续范围后读取多个 D 区字值 | |||
| WordsReadResult VirtualRegisterRepository::readWords( | |||
| const RegisterAddress &address, int count) const | |||
| { | |||
| return RegisterRepository::readWordPair(address); | |||
| if (!address.isValid() || address.area() != RegisterArea::D | |||
| || count < 1 | |||
| || address.index() > RegisterAddress::kMaximumIndex - count + 1) | |||
| { | |||
| return {false, {}, address.isValid() && address.area() != RegisterArea::D | |||
| ? RegisterError::AreaMismatch : RegisterError::InvalidAddress}; | |||
| } | |||
| // 地址范围已校验,因此可以直接把地址作为数组偏移量 | |||
| const auto begin = words_.cbegin() + address.index(); | |||
| return {true, std::vector<std::int16_t>(begin, begin + count), RegisterError::None}; | |||
| } | |||
| RegisterWriteResult VirtualRegisterRepository::writeWordPair( | |||
| // 校验连续范围后更新多个 D 区字值 | |||
| RegisterWriteResult VirtualRegisterRepository::writeWords( | |||
| const RegisterAddress &address, | |||
| const std::array<std::int16_t, 2> &values) | |||
| const std::vector<std::int16_t> &values) | |||
| { | |||
| return RegisterRepository::writeWordPair(address, values); | |||
| const int count = static_cast<int>(values.size()); | |||
| if (!address.isValid() || address.area() != RegisterArea::D | |||
| || count < 1 | |||
| || address.index() > RegisterAddress::kMaximumIndex - count + 1) | |||
| { | |||
| return {false, address.isValid() && address.area() != RegisterArea::D | |||
| ? RegisterError::AreaMismatch : RegisterError::InvalidAddress}; | |||
| } | |||
| // 范围已校验,copy 可以一次完成连续字值写入 | |||
| std::copy( | |||
| values.cbegin(), values.cend(), words_.begin() + address.index()); | |||
| return {true, RegisterError::None}; | |||
| } | |||
| // 将 M 区和 D 区全部恢复为离线默认值 | |||
| void VirtualRegisterRepository::clear() | |||
| { | |||
| // 离线运行复位时同时清空 M 区和 D 区 | |||
| @@ -86,8 +116,10 @@ void VirtualRegisterRepository::clear() | |||
| words_.fill(0); | |||
| } | |||
| // 复制另一份虚拟仓库的完整 M/D 快照 | |||
| void VirtualRegisterRepository::copyFrom(const VirtualRegisterRepository &source) | |||
| { | |||
| // array 赋值会复制全部槽位,不会让两个仓库共享存储 | |||
| bits_ = source.bits_; | |||
| words_ = source.words_; | |||
| } | |||
| @@ -20,10 +20,13 @@ public: | |||
| // 更新内存中的 D 区字值 | |||
| RegisterWriteResult writeWord( | |||
| const RegisterAddress &address, std::int16_t value) override; | |||
| WordPairReadResult readWordPair(const RegisterAddress &address) const override; | |||
| RegisterWriteResult writeWordPair( | |||
| // 连续读取内存中的多个 D 区字值 | |||
| WordsReadResult readWords( | |||
| const RegisterAddress &address, int count) const override; | |||
| // 连续更新内存中的多个 D 区字值 | |||
| RegisterWriteResult writeWords( | |||
| const RegisterAddress &address, | |||
| const std::array<std::int16_t, 2> &values) override; | |||
| const std::vector<std::int16_t> &values) override; | |||
| // 将所有虚拟寄存器恢复为默认值,开始新的离线会话 | |||
| void clear(); | |||
| @@ -31,6 +34,7 @@ public: | |||
| void copyFrom(const VirtualRegisterRepository &source); | |||
| private: | |||
| // M/D 数组均按 0~4000 的寄存器地址直接建立下标 | |||
| static constexpr std::size_t kRegisterCount = | |||
| static_cast<std::size_t>(RegisterAddress::kMaximumIndex + 1); | |||
| std::array<bool, kRegisterCount> bits_{}; // M 区位寄存器内存 | |||
| @@ -16,20 +16,24 @@ | |||
| namespace { | |||
| // 当前应用配置文件格式版本 | |||
| constexpr int kCurrentConfigurationVersion = 1; | |||
| // 保存字段值及其所在行号,便于生成准确的错误提示 | |||
| struct ParsedEntry | |||
| { | |||
| QString value; | |||
| int line = 0; | |||
| QString value; // 配置字段值 | |||
| int line = 0; // 字段在文件中的行号 | |||
| }; | |||
| // 将 Qt 字符串转换为 UTF-8 标准字符串 | |||
| std::string toUtf8(const QString &value) | |||
| { | |||
| const QByteArray bytes = value.toUtf8(); | |||
| return std::string(bytes.constData(), static_cast<std::size_t>(bytes.size())); | |||
| } | |||
| // 生成首次运行时写入的默认 INI 内容 | |||
| QString defaultFileContents() | |||
| { | |||
| return QStringLiteral( | |||
| @@ -57,6 +61,7 @@ QString defaultFileContents() | |||
| "StopBits=1\n"); | |||
| } | |||
| // 创建统一的配置失败结果,表示整份配置回退到代码默认值 | |||
| ApplicationSettingsLoadResult failureResult( | |||
| const QString &file_path, const QString &reason) | |||
| { | |||
| @@ -73,6 +78,7 @@ ApplicationSettingsLoadResult failureResult( | |||
| return result; | |||
| } | |||
| // 判断整数是否在允许值列表中 | |||
| bool containsValue(std::initializer_list<int> values, int value) | |||
| { | |||
| for (int candidate : values) | |||
| @@ -85,6 +91,7 @@ bool containsValue(std::initializer_list<int> values, int value) | |||
| return false; | |||
| } | |||
| // 读取并校验一个整数配置项,缺失时按 required 决定报错或使用默认值 | |||
| bool readInteger( | |||
| const QMap<QString, ParsedEntry> &entries, | |||
| const QString &path, | |||
| @@ -96,6 +103,7 @@ bool readInteger( | |||
| QStringList *errors, | |||
| bool required = false) | |||
| { | |||
| // constFind 只读取已解析的字段,不会修改配置表 | |||
| const auto entry = entries.constFind(path); | |||
| if (entry == entries.cend()) | |||
| { | |||
| @@ -130,12 +138,14 @@ bool readInteger( | |||
| 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") | |||
| @@ -145,12 +155,14 @@ void addUnsupportedValueError( | |||
| } // namespace | |||
| // 返回程序目录下的应用配置路径 | |||
| QString ApplicationSettingsLoader::defaultFilePath() | |||
| { | |||
| return QDir(QCoreApplication::applicationDirPath()).filePath( | |||
| QStringLiteral("config/application.ini")); | |||
| } | |||
| // 创建或读取配置文件,并把解析结果转换为应用设置 | |||
| ApplicationSettingsLoadResult ApplicationSettingsLoader::load( | |||
| const QString &file_path) | |||
| { | |||
| @@ -165,6 +177,7 @@ ApplicationSettingsLoadResult ApplicationSettingsLoader::load( | |||
| .arg(QDir::toNativeSeparators(directory))); | |||
| } | |||
| // QSaveFile 通过临时文件提交,避免程序中断时留下半份配置 | |||
| QSaveFile file(file_path); | |||
| if (!file.open(QIODevice::WriteOnly)) | |||
| { | |||
| @@ -191,6 +204,7 @@ ApplicationSettingsLoadResult ApplicationSettingsLoader::load( | |||
| return result; | |||
| } | |||
| // 已存在的配置按只读方式读取,任何读错误都会让整份配置回退 | |||
| QFile file(file_path); | |||
| if (!file.open(QIODevice::ReadOnly)) | |||
| { | |||
| @@ -202,6 +216,7 @@ ApplicationSettingsLoadResult ApplicationSettingsLoader::load( | |||
| return failureResult(file_path, file.errorString()); | |||
| } | |||
| // 使用 UTF-8 解码并检查非法字节,避免中文配置被错误解析 | |||
| QTextCodec::ConverterState converter_state; | |||
| const QString text = QTextCodec::codecForName("UTF-8")->toUnicode( | |||
| data.constData(), data.size(), &converter_state); | |||
| @@ -210,6 +225,7 @@ ApplicationSettingsLoadResult ApplicationSettingsLoader::load( | |||
| return failureResult(file_path, QStringLiteral("配置文件不是有效的 UTF-8 文本")); | |||
| } | |||
| // 未知字段只提示不报错,便于未来版本增加字段后旧程序仍可读取 | |||
| const QSet<QString> known_fields{ | |||
| QStringLiteral("Config.Version"), | |||
| QStringLiteral("ProjectLimits.MaxHmiPages"), | |||
| @@ -237,6 +253,7 @@ ApplicationSettingsLoadResult ApplicationSettingsLoader::load( | |||
| { | |||
| normalized_text.remove(0, 1); | |||
| } | |||
| // 按行解析 INI,保留行号用于定位格式和取值错误 | |||
| const QStringList lines = normalized_text.split(QLatin1Char('\n')); | |||
| for (int index = 0; index < lines.size(); ++index) | |||
| { | |||
| @@ -247,6 +264,7 @@ ApplicationSettingsLoadResult ApplicationSettingsLoader::load( | |||
| } | |||
| const QString trimmed = line.trimmed(); | |||
| const int line_number = index + 1; | |||
| // 空行、分号注释和井号注释不参与字段解析 | |||
| if (trimmed.isEmpty() || trimmed.startsWith(QLatin1Char(';')) | |||
| || trimmed.startsWith(QLatin1Char('#'))) | |||
| { | |||
| @@ -307,6 +325,7 @@ ApplicationSettingsLoadResult ApplicationSettingsLoader::load( | |||
| } | |||
| } | |||
| // 先写入候选设置,只有全部字段无严重错误时才作为最终结果返回 | |||
| ApplicationSettings candidate; | |||
| qint64 parsed = 0; | |||
| if (readInteger( | |||
| @@ -477,6 +496,7 @@ ApplicationSettingsLoadResult ApplicationSettingsLoader::load( | |||
| candidate.plcDefaults.stopBits = static_cast<int>(parsed); | |||
| } | |||
| // 任一严重错误都会使整份配置失效,避免混用部分外部值和默认值 | |||
| if (!errors.isEmpty()) | |||
| { | |||
| ApplicationSettingsLoadResult result; | |||
| @@ -8,6 +8,12 @@ | |||
| class ApplicationSettingsLoader final | |||
| { | |||
| public: | |||
| // 返回程序目录下应用配置文件的默认路径 | |||
| static QString defaultFilePath(); | |||
| /** | |||
| * @brief 读取指定 INI 文件,不存在时创建默认文件 | |||
| * @param file_path 应用配置文件的完整路径 | |||
| * @return 配置加载结果,包含实际配置、过程消息和警告信息 | |||
| */ | |||
| static ApplicationSettingsLoadResult load(const QString &file_path); | |||
| }; | |||
| @@ -18,7 +18,7 @@ | |||
| namespace { | |||
| // 当前读写实现支持的工程文件格式版本 | |||
| constexpr const char *kCurrentFormatVersion = "1.0"; | |||
| constexpr const char *kCurrentFormatVersion = "4.0"; | |||
| // 保存解析过程中遇到的第一个错误,避免后续失败覆盖根因 | |||
| struct ParseState | |||
| @@ -272,12 +272,14 @@ bool parseAddress( | |||
| return true; | |||
| } | |||
| // 将字操作数类型转换为工程文件中的稳定字符串 | |||
| QString wordOperandKindName(WordOperandKind kind) | |||
| { | |||
| return kind == WordOperandKind::Constant | |||
| ? QStringLiteral("constant") : QStringLiteral("register"); | |||
| } | |||
| // 将字操作数序列化为常量或寄存器对象 | |||
| QJsonObject serializeWordOperand(const WordOperand &operand) | |||
| { | |||
| QJsonObject object; | |||
| @@ -293,6 +295,7 @@ QJsonObject serializeWordOperand(const WordOperand &operand) | |||
| return object; | |||
| } | |||
| // 从 JSON 对象解析字操作数并校验其地址类型 | |||
| bool parseWordOperand( | |||
| const QJsonObject &object, | |||
| const std::string &context, | |||
| @@ -374,6 +377,7 @@ bool parseHmiControlType( | |||
| return true; | |||
| } | |||
| // 将按钮操作转换为工程文件中的稳定字符串 | |||
| QString hmiButtonOperationName(HmiButtonOperation operation) | |||
| { | |||
| switch (operation) | |||
| @@ -398,6 +402,7 @@ QString hmiButtonOperationName(HmiButtonOperation operation) | |||
| } | |||
| } | |||
| // 将工程文件中的按钮操作字符串解析为枚举值 | |||
| bool parseHmiButtonOperation( | |||
| const std::string &value, HmiButtonOperation *operation, ParseState *state) | |||
| { | |||
| @@ -426,6 +431,161 @@ bool parseHmiButtonOperation( | |||
| return true; | |||
| } | |||
| // 将按钮启用条件比较方式转换为稳定字符串 | |||
| QString hmiButtonConditionOperatorName(HmiButtonConditionOperator operation) | |||
| { | |||
| switch (operation) | |||
| { | |||
| case HmiButtonConditionOperator::Equal: | |||
| return QStringLiteral("equal"); | |||
| case HmiButtonConditionOperator::NotEqual: | |||
| return QStringLiteral("notEqual"); | |||
| case HmiButtonConditionOperator::LessThan: | |||
| return QStringLiteral("lessThan"); | |||
| case HmiButtonConditionOperator::LessThanOrEqual: | |||
| return QStringLiteral("lessThanOrEqual"); | |||
| case HmiButtonConditionOperator::GreaterThan: | |||
| return QStringLiteral("greaterThan"); | |||
| case HmiButtonConditionOperator::GreaterThanOrEqual: | |||
| return QStringLiteral("greaterThanOrEqual"); | |||
| default: | |||
| return {}; | |||
| } | |||
| } | |||
| // 将按钮启用条件比较方式字符串解析为枚举值 | |||
| bool parseHmiButtonConditionOperator( | |||
| const std::string &value, | |||
| HmiButtonConditionOperator *operation, | |||
| ParseState *state) | |||
| { | |||
| if (value == "equal") | |||
| { | |||
| *operation = HmiButtonConditionOperator::Equal; | |||
| } | |||
| else if (value == "notEqual") | |||
| { | |||
| *operation = HmiButtonConditionOperator::NotEqual; | |||
| } | |||
| else if (value == "lessThan") | |||
| { | |||
| *operation = HmiButtonConditionOperator::LessThan; | |||
| } | |||
| else if (value == "lessThanOrEqual") | |||
| { | |||
| *operation = HmiButtonConditionOperator::LessThanOrEqual; | |||
| } | |||
| else if (value == "greaterThan") | |||
| { | |||
| *operation = HmiButtonConditionOperator::GreaterThan; | |||
| } | |||
| else if (value == "greaterThanOrEqual") | |||
| { | |||
| *operation = HmiButtonConditionOperator::GreaterThanOrEqual; | |||
| } | |||
| else | |||
| { | |||
| return state->fail( | |||
| ProjectStorageError::InvalidField, | |||
| "不支持的按钮 D 启用条件比较方式:" + value); | |||
| } | |||
| return true; | |||
| } | |||
| // 将按钮 M/D 启用条件序列化为 JSON 对象 | |||
| QJsonObject serializeHmiButtonEnableCondition( | |||
| const HmiButtonEnableCondition &condition) | |||
| { | |||
| QJsonObject object; | |||
| if (const auto *bit = std::get_if<HmiButtonBitEnableCondition>(&condition)) | |||
| { | |||
| object.insert(QStringLiteral("type"), QStringLiteral("mBit")); | |||
| object.insert(QStringLiteral("address"), serializeAddress(bit->address)); | |||
| object.insert(QStringLiteral("expected"), bit->expected); | |||
| return object; | |||
| } | |||
| const auto &word = std::get<HmiButtonWordEnableCondition>(condition); | |||
| object.insert(QStringLiteral("type"), QStringLiteral("dValue")); | |||
| object.insert(QStringLiteral("address"), serializeAddress(word.address)); | |||
| object.insert( | |||
| QStringLiteral("dataType"), | |||
| QString::fromLatin1(registerDataTypeName(word.dataType))); | |||
| object.insert( | |||
| QStringLiteral("operation"), | |||
| hmiButtonConditionOperatorName(word.operation)); | |||
| object.insert(QStringLiteral("value"), word.value); | |||
| return object; | |||
| } | |||
| // 从 JSON 对象解析按钮 M/D 启用条件 | |||
| bool parseHmiButtonEnableCondition( | |||
| const QJsonObject &object, | |||
| const std::string &context, | |||
| HmiButtonEnableCondition *condition, | |||
| ParseState *state) | |||
| { | |||
| std::string type; | |||
| QJsonObject address_object; | |||
| if (!readString(object, "type", context, &type, state) | |||
| || !readObject(object, "address", context, &address_object, state)) | |||
| { | |||
| return false; | |||
| } | |||
| RegisterAddress address{RegisterArea::M, 0}; | |||
| if (!parseAddress(address_object, context + ".address", &address, state)) | |||
| { | |||
| return false; | |||
| } | |||
| if (type == "mBit") | |||
| { | |||
| bool expected = false; | |||
| if (!readBool(object, "expected", context, &expected, state)) | |||
| { | |||
| return false; | |||
| } | |||
| *condition = HmiButtonBitEnableCondition{address, expected}; | |||
| return true; | |||
| } | |||
| if (type != "dValue") | |||
| { | |||
| return state->fail( | |||
| ProjectStorageError::InvalidField, | |||
| context + ".type 必须是 mBit 或 dValue"); | |||
| } | |||
| std::string data_type_text; | |||
| std::string operation_text; | |||
| QJsonValue value_json; | |||
| if (!readString(object, "dataType", context, &data_type_text, state) | |||
| || !readString(object, "operation", context, &operation_text, state) | |||
| || !readValue(object, "value", context, &value_json, state)) | |||
| { | |||
| return false; | |||
| } | |||
| RegisterDataType data_type = RegisterDataType::Int16; | |||
| if (!parseRegisterDataType(data_type_text, &data_type)) | |||
| { | |||
| return state->fail( | |||
| ProjectStorageError::InvalidField, | |||
| context + ".dataType 必须是 int16、int32、float32 或 float64"); | |||
| } | |||
| if (!value_json.isDouble() || !std::isfinite(value_json.toDouble())) | |||
| { | |||
| return state->fail( | |||
| ProjectStorageError::InvalidField, | |||
| context + ".value 必须是有限数值"); | |||
| } | |||
| HmiButtonConditionOperator operation = HmiButtonConditionOperator::Equal; | |||
| if (!parseHmiButtonConditionOperator(operation_text, &operation, state)) | |||
| { | |||
| return false; | |||
| } | |||
| *condition = HmiButtonWordEnableCondition{ | |||
| address, data_type, operation, value_json.toDouble()}; | |||
| return true; | |||
| } | |||
| // 将报警触发条件转换为工程文件中的稳定字符串 | |||
| QString alarmConditionName(AlarmCondition condition) | |||
| { | |||
| switch (condition) | |||
| @@ -434,6 +594,10 @@ QString alarmConditionName(AlarmCondition condition) | |||
| { | |||
| return QStringLiteral("mOn"); | |||
| } | |||
| case AlarmCondition::MOff: | |||
| { | |||
| return QStringLiteral("mOff"); | |||
| } | |||
| case AlarmCondition::DHigh: | |||
| { | |||
| return QStringLiteral("dHigh"); | |||
| @@ -449,6 +613,7 @@ QString alarmConditionName(AlarmCondition condition) | |||
| } | |||
| } | |||
| // 将报警触发条件字符串解析为枚举值 | |||
| bool parseAlarmCondition( | |||
| const std::string &value, AlarmCondition *condition, ParseState *state) | |||
| { | |||
| @@ -456,6 +621,10 @@ bool parseAlarmCondition( | |||
| { | |||
| *condition = AlarmCondition::MOn; | |||
| } | |||
| else if (value == "mOff") | |||
| { | |||
| *condition = AlarmCondition::MOff; | |||
| } | |||
| else if (value == "dHigh") | |||
| { | |||
| *condition = AlarmCondition::DHigh; | |||
| @@ -473,6 +642,7 @@ bool parseAlarmCondition( | |||
| return true; | |||
| } | |||
| // 将报警定义序列化为 JSON 对象 | |||
| QJsonObject serializeAlarmDefinition(const AlarmDefinition &definition) | |||
| { | |||
| QJsonObject object; | |||
| @@ -485,6 +655,7 @@ QJsonObject serializeAlarmDefinition(const AlarmDefinition &definition) | |||
| return object; | |||
| } | |||
| // 从 JSON 对象解析报警定义 | |||
| bool parseAlarmDefinition( | |||
| const QJsonObject &object, | |||
| const std::string &context, | |||
| @@ -520,6 +691,7 @@ bool parseAlarmDefinition( | |||
| return true; | |||
| } | |||
| // 将工程级软元件注释序列化为 JSON 对象 | |||
| QJsonObject serializeRegisterComment(const RegisterComment &comment) | |||
| { | |||
| QJsonObject object; | |||
| @@ -528,6 +700,7 @@ QJsonObject serializeRegisterComment(const RegisterComment &comment) | |||
| return object; | |||
| } | |||
| // 从 JSON 对象解析工程级软元件注释 | |||
| bool parseRegisterComment( | |||
| const QJsonObject &object, | |||
| const std::string &context, | |||
| @@ -640,6 +813,138 @@ bool parseProperties( | |||
| return true; | |||
| } | |||
| // 将有值或无值的数值转换为 JSON 数值或 null | |||
| QJsonValue serializeOptionalNumber(const std::optional<double> &value) | |||
| { | |||
| return value.has_value() ? QJsonValue(*value) : QJsonValue(QJsonValue::Null); | |||
| } | |||
| // 将 HMI 状态文本映射配置序列化为 JSON 对象 | |||
| QJsonObject serializeStatusTextConfig(const HmiStatusTextConfig &config) | |||
| { | |||
| QJsonObject object; | |||
| if (const auto *bit = std::get_if<HmiStatusBitTextConfig>(&config)) | |||
| { | |||
| object.insert(QStringLiteral("source"), QStringLiteral("m")); | |||
| object.insert(QStringLiteral("offText"), fromUtf8(bit->offText)); | |||
| object.insert(QStringLiteral("onText"), fromUtf8(bit->onText)); | |||
| return object; | |||
| } | |||
| object.insert(QStringLiteral("source"), QStringLiteral("d")); | |||
| QJsonArray ranges; | |||
| for (const HmiStatusValueRange &range : | |||
| std::get<HmiStatusWordTextConfig>(config).ranges) | |||
| { | |||
| QJsonObject range_object; | |||
| range_object.insert( | |||
| QStringLiteral("lower"), serializeOptionalNumber(range.lowerBound)); | |||
| range_object.insert( | |||
| QStringLiteral("upper"), serializeOptionalNumber(range.upperBound)); | |||
| range_object.insert(QStringLiteral("text"), fromUtf8(range.text)); | |||
| ranges.append(range_object); | |||
| } | |||
| object.insert(QStringLiteral("ranges"), ranges); | |||
| return object; | |||
| } | |||
| // 读取可为空的有限数值,用于状态文本区间边界 | |||
| bool readNullableFiniteNumber( | |||
| const QJsonObject &object, | |||
| const char *field, | |||
| const std::string &context, | |||
| std::optional<double> *value, | |||
| ParseState *state) | |||
| { | |||
| QJsonValue json_value; | |||
| if (!readValue(object, field, context, &json_value, state)) | |||
| { | |||
| return false; | |||
| } | |||
| if (json_value.isNull()) | |||
| { | |||
| value->reset(); | |||
| return true; | |||
| } | |||
| if (!json_value.isDouble() || !std::isfinite(json_value.toDouble())) | |||
| { | |||
| return state->fail( | |||
| ProjectStorageError::InvalidField, | |||
| fieldPath(context, field) + " 必须是有限数值或 null"); | |||
| } | |||
| *value = json_value.toDouble(); | |||
| return true; | |||
| } | |||
| // 从 JSON 对象解析 M 或 D 状态文本映射配置 | |||
| bool parseStatusTextConfig( | |||
| const QJsonObject &object, | |||
| const std::string &context, | |||
| HmiStatusTextConfig *config, | |||
| ParseState *state) | |||
| { | |||
| std::string source; | |||
| if (!readString(object, "source", context, &source, state)) | |||
| { | |||
| return false; | |||
| } | |||
| if (source == "m") | |||
| { | |||
| HmiStatusBitTextConfig bit; | |||
| if (!readString(object, "offText", context, &bit.offText, state) | |||
| || !readString(object, "onText", context, &bit.onText, state)) | |||
| { | |||
| return false; | |||
| } | |||
| *config = std::move(bit); | |||
| return true; | |||
| } | |||
| if (source != "d") | |||
| { | |||
| return state->fail( | |||
| ProjectStorageError::InvalidField, | |||
| context + ".source 必须是 m 或 d"); | |||
| } | |||
| QJsonArray range_array; | |||
| if (!readArray(object, "ranges", context, &range_array, state) | |||
| || range_array.isEmpty() | |||
| || range_array.size() | |||
| > static_cast<int>(ProjectLimits::kMaximumStatusTextRanges)) | |||
| { | |||
| return state->fail( | |||
| ProjectStorageError::InvalidField, | |||
| context + ".ranges 必须包含 1~16 个区间"); | |||
| } | |||
| HmiStatusWordTextConfig word; | |||
| word.ranges.reserve(static_cast<std::size_t>(range_array.size())); | |||
| for (int index = 0; index < range_array.size(); ++index) | |||
| { | |||
| if (!range_array.at(index).isObject()) | |||
| { | |||
| return state->fail( | |||
| ProjectStorageError::InvalidField, | |||
| context + ".ranges 的元素必须是对象"); | |||
| } | |||
| const QJsonObject range_object = range_array.at(index).toObject(); | |||
| const std::string range_context = context + ".ranges[" | |||
| + std::to_string(index) + ']'; | |||
| HmiStatusValueRange range; | |||
| if (!readNullableFiniteNumber( | |||
| range_object, "lower", range_context, | |||
| &range.lowerBound, state) | |||
| || !readNullableFiniteNumber( | |||
| range_object, "upper", range_context, | |||
| &range.upperBound, state) | |||
| || !readString( | |||
| range_object, "text", range_context, &range.text, state)) | |||
| { | |||
| return false; | |||
| } | |||
| word.ranges.push_back(std::move(range)); | |||
| } | |||
| *config = std::move(word); | |||
| return true; | |||
| } | |||
| // 将单个 HMI 控件及其可选寄存器绑定序列化为 JSON 对象 | |||
| QJsonObject serializeHmiControl(const HmiControl &control) | |||
| { | |||
| @@ -658,8 +963,12 @@ QJsonObject serializeHmiControl(const HmiControl &control) | |||
| object.insert(QStringLiteral("binding"), QJsonValue::Null); | |||
| } | |||
| object.insert(QStringLiteral("properties"), serializeProperties(control.properties)); | |||
| const bool status_word = control.type == HmiControlType::StatusText | |||
| && control.statusText.has_value() | |||
| && std::holds_alternative<HmiStatusWordTextConfig>(*control.statusText); | |||
| if (control.type == HmiControlType::NumericDisplay | |||
| || control.type == HmiControlType::NumericInput) | |||
| || control.type == HmiControlType::NumericInput | |||
| || status_word) | |||
| { | |||
| object.insert( | |||
| QStringLiteral("dataType"), | |||
| @@ -670,6 +979,11 @@ QJsonObject serializeHmiControl(const HmiControl &control) | |||
| object.insert( | |||
| QStringLiteral("buttonOperation"), | |||
| hmiButtonOperationName(control.buttonOperation)); | |||
| object.insert( | |||
| QStringLiteral("buttonEnableCondition"), | |||
| control.buttonEnableCondition.has_value() | |||
| ? serializeHmiButtonEnableCondition(*control.buttonEnableCondition) | |||
| : QJsonValue(QJsonValue::Null)); | |||
| } | |||
| if (control.type == HmiControlType::PageJump) | |||
| { | |||
| @@ -679,6 +993,14 @@ QJsonObject serializeHmiControl(const HmiControl &control) | |||
| ? control.pageJump->targetPageId | |||
| : std::string{})); | |||
| } | |||
| if (control.type == HmiControlType::StatusText) | |||
| { | |||
| object.insert( | |||
| QStringLiteral("statusText"), | |||
| control.statusText.has_value() | |||
| ? serializeStatusTextConfig(*control.statusText) | |||
| : QJsonObject{}); | |||
| } | |||
| return object; | |||
| } | |||
| @@ -710,8 +1032,24 @@ bool parseHmiControl( | |||
| { | |||
| return false; | |||
| } | |||
| if (control->type == HmiControlType::StatusText) | |||
| { | |||
| QJsonObject status_object; | |||
| HmiStatusTextConfig status_config = HmiStatusBitTextConfig{}; | |||
| if (!readObject(object, "statusText", context, &status_object, state) | |||
| || !parseStatusTextConfig( | |||
| status_object, context + ".statusText", &status_config, state)) | |||
| { | |||
| return false; | |||
| } | |||
| control->statusText = std::move(status_config); | |||
| } | |||
| const bool status_word = control->type == HmiControlType::StatusText | |||
| && control->statusText.has_value() | |||
| && std::holds_alternative<HmiStatusWordTextConfig>(*control->statusText); | |||
| if (control->type == HmiControlType::NumericDisplay | |||
| || control->type == HmiControlType::NumericInput) | |||
| || control->type == HmiControlType::NumericInput | |||
| || status_word) | |||
| { | |||
| std::string data_type_text; | |||
| if (!readString(object, "dataType", context, &data_type_text, state) | |||
| @@ -719,12 +1057,13 @@ bool parseHmiControl( | |||
| { | |||
| return state->fail( | |||
| ProjectStorageError::InvalidField, | |||
| context + ".dataType 必须是 int16 或 float32"); | |||
| context + ".dataType 必须是 int16、int32、float32 或 float64"); | |||
| } | |||
| } | |||
| if (control->type == HmiControlType::Button) | |||
| { | |||
| std::string operation_text; | |||
| QJsonValue enable_condition_value; | |||
| if (!readString( | |||
| object, | |||
| "buttonOperation", | |||
| @@ -736,6 +1075,38 @@ bool parseHmiControl( | |||
| { | |||
| return false; | |||
| } | |||
| if (!readValue( | |||
| object, | |||
| "buttonEnableCondition", | |||
| context, | |||
| &enable_condition_value, | |||
| state)) | |||
| { | |||
| return false; | |||
| } | |||
| if (enable_condition_value.isNull()) | |||
| { | |||
| control->buttonEnableCondition.reset(); | |||
| } | |||
| else if (!enable_condition_value.isObject()) | |||
| { | |||
| return state->fail( | |||
| ProjectStorageError::InvalidField, | |||
| context + ".buttonEnableCondition 必须是对象或 null"); | |||
| } | |||
| else | |||
| { | |||
| HmiButtonEnableCondition condition; | |||
| if (!parseHmiButtonEnableCondition( | |||
| enable_condition_value.toObject(), | |||
| context + ".buttonEnableCondition", | |||
| &condition, | |||
| state)) | |||
| { | |||
| return false; | |||
| } | |||
| control->buttonEnableCondition = std::move(condition); | |||
| } | |||
| } | |||
| if (control->type == HmiControlType::PageJump) | |||
| { | |||
| @@ -869,6 +1240,7 @@ bool parseContactMode( | |||
| return true; | |||
| } | |||
| // 将边沿模式转换为工程文件中的稳定字符串 | |||
| QString edgeModeName(EdgeMode mode) | |||
| { | |||
| return mode == EdgeMode::Rising | |||
| @@ -876,6 +1248,7 @@ QString edgeModeName(EdgeMode mode) | |||
| : QStringLiteral("falling"); | |||
| } | |||
| // 将边沿模式字符串解析为枚举值 | |||
| bool parseEdgeMode( | |||
| const std::string &value, EdgeMode *mode, ParseState *state) | |||
| { | |||
| @@ -1029,6 +1402,7 @@ QJsonObject serializeNodeConfig(const ContactNodeConfig &config) | |||
| return object; | |||
| } | |||
| // 将边沿触点配置序列化为 JSON 对象 | |||
| QJsonObject serializeNodeConfig(const EdgeContactNodeConfig &config) | |||
| { | |||
| QJsonObject object; | |||
| @@ -1059,6 +1433,7 @@ QJsonObject serializeNodeConfig(const CompareNodeConfig &config) | |||
| return object; | |||
| } | |||
| // 将 MOVE 节点配置序列化为 JSON 对象 | |||
| QJsonObject serializeNodeConfig(const MoveNodeConfig &config) | |||
| { | |||
| QJsonObject object; | |||
| @@ -1068,12 +1443,14 @@ QJsonObject serializeNodeConfig(const MoveNodeConfig &config) | |||
| return object; | |||
| } | |||
| // 将算术操作转换为工程文件中的稳定字符串 | |||
| QString arithmeticOperationName(ArithmeticOperation operation) | |||
| { | |||
| return operation == ArithmeticOperation::Add | |||
| ? QStringLiteral("add") : QStringLiteral("subtract"); | |||
| } | |||
| // 将算术节点配置序列化为 JSON 对象 | |||
| QJsonObject serializeNodeConfig(const ArithmeticNodeConfig &config) | |||
| { | |||
| QJsonObject object; | |||
| @@ -1283,185 +1660,31 @@ bool parseLogicNode( | |||
| return true; | |||
| } | |||
| QString expressionKindText(ConditionExpressionKind kind) | |||
| { | |||
| switch (kind) | |||
| { | |||
| case ConditionExpressionKind::Node: | |||
| return QStringLiteral("node"); | |||
| case ConditionExpressionKind::Wire: | |||
| return QStringLiteral("wire"); | |||
| case ConditionExpressionKind::Gap: | |||
| return QStringLiteral("gap"); | |||
| case ConditionExpressionKind::Series: | |||
| return QStringLiteral("series"); | |||
| case ConditionExpressionKind::Parallel: | |||
| return QStringLiteral("parallel"); | |||
| } | |||
| return {}; | |||
| } | |||
| QJsonObject serializeConditionExpression(const ConditionExpression &expression) | |||
| { | |||
| QJsonObject object; | |||
| object.insert(QStringLiteral("id"), fromUtf8(expression.id)); | |||
| object.insert(QStringLiteral("kind"), expressionKindText(expression.kind)); | |||
| if (expression.kind == ConditionExpressionKind::Node) | |||
| { | |||
| object.insert(QStringLiteral("node"), serializeLogicNode(*expression.node)); | |||
| } | |||
| else if (expression.kind == ConditionExpressionKind::Wire) | |||
| { | |||
| object.insert(QStringLiteral("columnSpan"), expression.wire->columnSpan); | |||
| } | |||
| else if (expression.kind == ConditionExpressionKind::Gap) | |||
| { | |||
| object.insert(QStringLiteral("columnSpan"), expression.gap->columnSpan); | |||
| } | |||
| else | |||
| { | |||
| QJsonArray children; | |||
| for (const ConditionExpression &child : expression.children) | |||
| { | |||
| children.append(serializeConditionExpression(child)); | |||
| } | |||
| object.insert(QStringLiteral("children"), children); | |||
| } | |||
| return object; | |||
| } | |||
| bool parseConditionExpression( | |||
| const QJsonObject &object, | |||
| const std::string &context, | |||
| ConditionExpression *expression, | |||
| ParseState *state, | |||
| std::size_t depth, | |||
| std::size_t *node_count) | |||
| { | |||
| if (depth > ProjectLimits::kMaximumExpressionDepth) | |||
| { | |||
| return state->fail( | |||
| ProjectStorageError::InvalidField, | |||
| context + " 的嵌套深度超过 12 层"); | |||
| } | |||
| ++*node_count; | |||
| if (*node_count > ProjectLimits::kMaximumExpressionNodesPerRung) | |||
| { | |||
| return state->fail( | |||
| ProjectStorageError::InvalidField, | |||
| context + " 的表达式节点总数超过 1024 个"); | |||
| } | |||
| std::string kind; | |||
| if (!readString( | |||
| object, "id", context, &expression->id, state, | |||
| ProjectLimits::kMaximumIdBytes) | |||
| || !readString(object, "kind", context, &kind, state)) | |||
| { | |||
| return false; | |||
| } | |||
| if (kind == "node") | |||
| { | |||
| QJsonObject node; | |||
| if (!readObject(object, "node", context, &node, state)) | |||
| { | |||
| return false; | |||
| } | |||
| LogicNode parsed_node; | |||
| if (!parseLogicNode(node, context + ".node", &parsed_node, state)) | |||
| { | |||
| return false; | |||
| } | |||
| expression->kind = ConditionExpressionKind::Node; | |||
| expression->node = std::move(parsed_node); | |||
| return true; | |||
| } | |||
| if (kind == "wire") | |||
| { | |||
| int column_span = 0; | |||
| if (!readInt( | |||
| object, | |||
| "columnSpan", | |||
| context, | |||
| WireSegment::kMinimumColumnSpan, | |||
| WireSegment::kMaximumColumnSpan, | |||
| &column_span, | |||
| state)) | |||
| { | |||
| return false; | |||
| } | |||
| expression->kind = ConditionExpressionKind::Wire; | |||
| 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、gap、series 或 parallel"); | |||
| } | |||
| QJsonArray children; | |||
| if (!readArray( | |||
| object, "children", context, &children, state, | |||
| static_cast<int>(ProjectLimits::kMaximumExpressionChildren))) | |||
| { | |||
| return false; | |||
| } | |||
| expression->kind = kind == "series" | |||
| ? ConditionExpressionKind::Series : ConditionExpressionKind::Parallel; | |||
| expression->children.reserve(static_cast<std::size_t>(children.size())); | |||
| for (int index = 0; index < children.size(); ++index) | |||
| { | |||
| if (!children.at(index).isObject()) | |||
| { | |||
| return state->fail( | |||
| ProjectStorageError::InvalidField, | |||
| context + ".children 的元素必须是对象"); | |||
| } | |||
| ConditionExpression child; | |||
| if (!parseConditionExpression( | |||
| children.at(index).toObject(), | |||
| context + ".children[" + std::to_string(index) + ']', | |||
| &child, | |||
| state, | |||
| depth + 1U, | |||
| node_count)) | |||
| { | |||
| return false; | |||
| } | |||
| expression->children.push_back(std::move(child)); | |||
| } | |||
| return true; | |||
| } | |||
| // 将梯形图行、条件网格和输出节点序列化为 JSON 对象 | |||
| QJsonObject serializeLadderRung(const LadderRung &rung) | |||
| { | |||
| QJsonObject object; | |||
| object.insert(QStringLiteral("id"), fromUtf8(rung.id)); | |||
| object.insert(QStringLiteral("name"), fromUtf8(rung.name)); | |||
| object.insert(QStringLiteral("comment"), fromUtf8(rung.comment)); | |||
| object.insert( | |||
| QStringLiteral("condition"), | |||
| rung.condition.has_value() | |||
| ? QJsonValue(serializeConditionExpression(*rung.condition)) | |||
| : QJsonValue(QJsonValue::Null)); | |||
| QJsonArray cells; | |||
| for (const LadderCell &cell : rung.cells) | |||
| { | |||
| QJsonObject cell_object; | |||
| cell_object.insert(QStringLiteral("id"), fromUtf8(cell.id)); | |||
| cell_object.insert( | |||
| QStringLiteral("kind"), | |||
| cell.kind == LadderCellKind::Node | |||
| ? QStringLiteral("node") | |||
| : cell.kind == LadderCellKind::Wire | |||
| ? QStringLiteral("wire") : QStringLiteral("gap")); | |||
| if (cell.node.has_value()) | |||
| { | |||
| cell_object.insert(QStringLiteral("node"), serializeLogicNode(*cell.node)); | |||
| } | |||
| cells.append(cell_object); | |||
| } | |||
| object.insert(QStringLiteral("cells"), cells); | |||
| object.insert( | |||
| QStringLiteral("output"), | |||
| rung.output.has_value() ? QJsonValue(serializeLogicNode(*rung.output)) | |||
| @@ -1469,48 +1692,78 @@ QJsonObject serializeLadderRung(const LadderRung &rung) | |||
| return object; | |||
| } | |||
| // 从 JSON 对象解析梯形图行及其网格 | |||
| bool parseLadderRung( | |||
| const QJsonObject &object, | |||
| const std::string &context, | |||
| LadderRung *rung, | |||
| ParseState *state) | |||
| { | |||
| QJsonValue condition; | |||
| QJsonArray cells; | |||
| QJsonValue output; | |||
| if (!readString( | |||
| object, "id", context, &rung->id, state, | |||
| ProjectLimits::kMaximumIdBytes) | |||
| || !readString(object, "name", context, &rung->name, state) | |||
| || !readString(object, "comment", context, &rung->comment, state) | |||
| || !readArray(object, "cells", context, &cells, state, | |||
| ProjectLimits::kMaximumConditionColumns) | |||
| || !readValue(object, "output", context, &output, state)) | |||
| { | |||
| return false; | |||
| } | |||
| if (!readValue(object, "condition", context, &condition, state)) | |||
| { | |||
| return false; | |||
| } | |||
| if (condition.isNull()) | |||
| if (cells.size() != ProjectLimits::kMaximumConditionColumns) | |||
| { | |||
| rung->condition.reset(); | |||
| return state->fail(ProjectStorageError::InvalidField, | |||
| context + ".cells 必须严格包含 10 个网格"); | |||
| } | |||
| else if (!condition.isObject()) | |||
| for (int index = 0; index < cells.size(); ++index) | |||
| { | |||
| return state->fail( | |||
| ProjectStorageError::InvalidField, | |||
| context + ".condition 必须是对象或 null"); | |||
| } | |||
| else | |||
| { | |||
| ConditionExpression parsed_condition; | |||
| std::size_t expression_node_count = 0U; | |||
| if (!parseConditionExpression( | |||
| condition.toObject(), context + ".condition", &parsed_condition, | |||
| state, 1U, &expression_node_count)) | |||
| if (!cells.at(index).isObject()) | |||
| { | |||
| return state->fail(ProjectStorageError::InvalidField, | |||
| context + ".cells 的元素必须是对象"); | |||
| } | |||
| const QJsonObject cell_object = cells.at(index).toObject(); | |||
| const std::string cell_context = context + ".cells[" | |||
| + std::to_string(index) + ']'; | |||
| LadderCell cell; | |||
| std::string kind; | |||
| if (!readString(cell_object, "id", cell_context, &cell.id, state, | |||
| ProjectLimits::kMaximumIdBytes) | |||
| || !readString(cell_object, "kind", cell_context, &kind, state)) | |||
| { | |||
| return false; | |||
| } | |||
| rung->condition = std::move(parsed_condition); | |||
| if (kind == "gap") | |||
| { | |||
| cell.kind = LadderCellKind::Gap; | |||
| } | |||
| else if (kind == "wire") | |||
| { | |||
| cell.kind = LadderCellKind::Wire; | |||
| } | |||
| else if (kind == "node") | |||
| { | |||
| cell.kind = LadderCellKind::Node; | |||
| QJsonObject node_object; | |||
| if (!readObject(cell_object, "node", cell_context, &node_object, state)) | |||
| { | |||
| return false; | |||
| } | |||
| LogicNode node; | |||
| if (!parseLogicNode(node_object, cell_context + ".node", &node, state)) | |||
| { | |||
| return false; | |||
| } | |||
| cell.node = std::move(node); | |||
| } | |||
| else | |||
| { | |||
| return state->fail(ProjectStorageError::InvalidField, | |||
| cell_context + ".kind 必须是 gap、wire 或 node"); | |||
| } | |||
| rung->cells.push_back(std::move(cell)); | |||
| } | |||
| if (output.isNull()) | |||
| { | |||
| @@ -1532,6 +1785,7 @@ bool parseLadderRung( | |||
| return true; | |||
| } | |||
| // 将控制逻辑及其行、竖线连接序列化为 JSON 对象 | |||
| QJsonObject serializeControlLogic(const ControlLogic &logic) | |||
| { | |||
| QJsonArray rungs; | |||
| @@ -1539,14 +1793,26 @@ QJsonObject serializeControlLogic(const ControlLogic &logic) | |||
| { | |||
| rungs.append(serializeLadderRung(rung)); | |||
| } | |||
| QJsonArray connections; | |||
| for (const VerticalConnection &connection : logic.verticalConnections) | |||
| { | |||
| QJsonObject item; | |||
| item.insert(QStringLiteral("id"), fromUtf8(connection.id)); | |||
| item.insert(QStringLiteral("upperRungId"), fromUtf8(connection.upperRungId)); | |||
| item.insert(QStringLiteral("lowerRungId"), fromUtf8(connection.lowerRungId)); | |||
| item.insert(QStringLiteral("columnBoundary"), connection.columnBoundary); | |||
| connections.append(item); | |||
| } | |||
| QJsonObject object; | |||
| object.insert(QStringLiteral("id"), fromUtf8(logic.id)); | |||
| object.insert(QStringLiteral("name"), fromUtf8(logic.name)); | |||
| object.insert(QStringLiteral("enabled"), logic.enabled); | |||
| object.insert(QStringLiteral("rungs"), rungs); | |||
| object.insert(QStringLiteral("verticalConnections"), connections); | |||
| return object; | |||
| } | |||
| // 从 JSON 对象解析控制逻辑及其行、竖线连接 | |||
| bool parseControlLogic( | |||
| const QJsonObject &object, | |||
| const std::string &context, | |||
| @@ -1555,6 +1821,7 @@ bool parseControlLogic( | |||
| ParseState *state) | |||
| { | |||
| QJsonArray rungs; | |||
| QJsonArray connections; | |||
| if (!readString( | |||
| object, "id", context, &logic->id, state, | |||
| ProjectLimits::kMaximumIdBytes) | |||
| @@ -1562,7 +1829,10 @@ bool parseControlLogic( | |||
| || !readBool(object, "enabled", context, &logic->enabled, state) | |||
| || !readArray( | |||
| object, "rungs", context, &rungs, state, | |||
| static_cast<int>(limits.maximumRungsPerLogic))) | |||
| static_cast<int>(limits.maximumRungsPerLogic)) | |||
| || !readArray( | |||
| object, "verticalConnections", context, &connections, state, | |||
| static_cast<int>(ProjectLimits::kMaximumVerticalConnectionsPerLogic))) | |||
| { | |||
| return false; | |||
| } | |||
| @@ -1586,6 +1856,35 @@ bool parseControlLogic( | |||
| } | |||
| logic->rungs.push_back(std::move(rung)); | |||
| } | |||
| for (int index = 0; index < connections.size(); ++index) | |||
| { | |||
| if (!connections.at(index).isObject()) | |||
| { | |||
| return state->fail(ProjectStorageError::InvalidField, | |||
| context + ".verticalConnections 的元素必须是对象"); | |||
| } | |||
| const QJsonObject item = connections.at(index).toObject(); | |||
| VerticalConnection connection; | |||
| int boundary = 0; | |||
| const std::string item_context = context + ".verticalConnections[" | |||
| + std::to_string(index) + ']'; | |||
| if (!readString(item, "id", item_context, &connection.id, state, | |||
| ProjectLimits::kMaximumIdBytes) | |||
| || !readString(item, "upperRungId", item_context, | |||
| &connection.upperRungId, state, | |||
| ProjectLimits::kMaximumIdBytes) | |||
| || !readString(item, "lowerRungId", item_context, | |||
| &connection.lowerRungId, state, | |||
| ProjectLimits::kMaximumIdBytes) | |||
| || !readInt(item, "columnBoundary", item_context, 0, | |||
| ProjectLimits::kMaximumConditionColumns, | |||
| &boundary, state)) | |||
| { | |||
| return false; | |||
| } | |||
| connection.columnBoundary = boundary; | |||
| logic->verticalConnections.push_back(std::move(connection)); | |||
| } | |||
| return true; | |||
| } | |||
| @@ -1780,6 +2079,7 @@ ProjectSaveResult saveFailure( | |||
| } // namespace | |||
| // 保存工程级数量限制,供后续保存和加载校验使用 | |||
| JsonProjectStorage::JsonProjectStorage( | |||
| const ProjectLimitSettings &project_limits) | |||
| : project_limits_(project_limits) | |||
| @@ -2,17 +2,31 @@ | |||
| #include "domain/project_storage.h" | |||
| // 严格读写当前 1.0 JSON 工程格式 | |||
| // 严格读写当前 4.0 JSON 工程格式 | |||
| class JsonProjectStorage final : public ProjectStorage | |||
| { | |||
| public: | |||
| /** | |||
| * @brief 创建严格 JSON 4.0 工程存储 | |||
| * @param project_limits 保存和加载时使用的工程数量限制 | |||
| */ | |||
| explicit JsonProjectStorage( | |||
| const ProjectLimitSettings &project_limits = | |||
| defaultProjectLimitSettings()); | |||
| const ProjectLimitSettings &project_limits); | |||
| /** | |||
| * @brief 校验并以 JSON 4.0 格式原子保存工程 | |||
| * @param project 要保存的完整工程 | |||
| * @param file_path 目标 JSON 文件路径 | |||
| * @return 保存结果,失败时包含存储错误类型和具体原因 | |||
| */ | |||
| ProjectSaveResult save( | |||
| const Project &project, const std::string &file_path) override; | |||
| /** | |||
| * @brief 从 JSON 4.0 文件读取并校验工程 | |||
| * @param file_path 要读取的 JSON 文件路径 | |||
| * @return 加载结果,成功时包含完整工程,失败时包含具体原因 | |||
| */ | |||
| ProjectLoadResult load(const std::string &file_path) override; | |||
| private: | |||
| const ProjectLimitSettings &project_limits_; | |||
| const ProjectLimitSettings &project_limits_; // 工程校验和数组解析使用的数量限制 | |||
| }; | |||
| @@ -2,6 +2,7 @@ | |||
| namespace { | |||
| // 生成适合显示的串口名称,空名称时使用通用提示 | |||
| QString displayPortName(const QString &port_name) | |||
| { | |||
| const QString trimmed = port_name.trimmed(); | |||
| @@ -10,12 +11,14 @@ QString displayPortName(const QString &port_name) | |||
| } // namespace | |||
| // 将 Qt Modbus 错误码转换为项目统一的通信故障类型和提示文本 | |||
| PlcCommunicationFailure classifyPlcCommunicationError( | |||
| QModbusDevice::Error error, | |||
| const PlcCommunicationErrorContext &context) | |||
| { | |||
| const QString port_name = displayPortName(context.portName); | |||
| // Qt 只提供底层错误码,连接上下文用于进一步区分打开失败和断线 | |||
| switch (error) | |||
| { | |||
| case QModbusDevice::ConnectionError: | |||
| @@ -36,6 +39,7 @@ PlcCommunicationFailure classifyPlcCommunicationError( | |||
| } | |||
| case QModbusDevice::TimeoutError: | |||
| { | |||
| // 从未收到有效响应通常表示站号、串口参数或接线不匹配 | |||
| if (!context.receivedValidResponse) | |||
| { | |||
| return { | |||
| @@ -8,19 +8,24 @@ | |||
| // Qt 错误码之外的通信现场信息,用于区分“未打开串口”和“已断线” | |||
| struct PlcCommunicationErrorContext | |||
| { | |||
| QString portName; | |||
| bool serialSessionOpened = false; | |||
| bool receivedValidResponse = false; | |||
| QString portName; // 当前使用的串口名称 | |||
| bool serialSessionOpened = false; // 串口会话是否已经成功打开 | |||
| bool receivedValidResponse = false; // 是否曾收到 PLC 的有效响应 | |||
| }; | |||
| // 分类后的用户可读通信故障 | |||
| struct PlcCommunicationFailure | |||
| { | |||
| PlcCommunicationError type = PlcCommunicationError::Unknown; | |||
| QString message; | |||
| PlcCommunicationError type = PlcCommunicationError::Unknown; // 统一的错误类型 | |||
| QString message; // 面向用户显示的错误说明 | |||
| }; | |||
| // 将 Qt Modbus 错误和上下文转换为项目统一错误类型 | |||
| /** | |||
| * @brief 将 Qt Modbus 错误和现场上下文转换为项目统一通信故障 | |||
| * @param error Qt Modbus 返回的设备错误 | |||
| * @param context 串口会话和 PLC 有效响应状态 | |||
| * @return 分类后的项目错误类型和用户提示文字 | |||
| */ | |||
| PlcCommunicationFailure classifyPlcCommunicationError( | |||
| QModbusDevice::Error error, | |||
| const PlcCommunicationErrorContext &context); | |||
| @@ -46,6 +46,7 @@ bool isReadingState(PlcConnectionState state) | |||
| || state == PlcConnectionState::Recovering; | |||
| } | |||
| // 排序并去重轮询地址,同时检查地址总量上限 | |||
| bool normalizePollAddresses( | |||
| const std::vector<RegisterAddress> &addresses, | |||
| std::vector<RegisterAddress> *normalized, | |||
| @@ -78,71 +79,76 @@ bool normalizePollAddresses( | |||
| return true; | |||
| } | |||
| bool isFloat32Start( | |||
| const std::vector<RegisterAddress> &float32_starts, | |||
| // 判断读块边界是否落在多字范围内部,避免拆开一次多字读取 | |||
| bool boundaryBelongsToMultiWordRange( | |||
| RegisterArea area, | |||
| int index) | |||
| int boundary, | |||
| const std::vector<RegisterWordRange> &ranges) | |||
| { | |||
| return std::binary_search( | |||
| float32_starts.cbegin(), float32_starts.cend(), | |||
| RegisterAddress{area, index}, | |||
| [](const RegisterAddress &left, const RegisterAddress &right) | |||
| return std::any_of( | |||
| ranges.cbegin(), ranges.cend(), | |||
| [area, boundary](const RegisterWordRange &range) | |||
| { | |||
| return left.area() == right.area() | |||
| ? left.index() < right.index() | |||
| : left.area() == RegisterArea::M; | |||
| return range.start.area() == area | |||
| && range.start.index() <= boundary | |||
| && boundary < range.start.index() + range.wordCount - 1; | |||
| }); | |||
| } | |||
| std::size_t pollBlockCount( | |||
| // 将地址集合拆成符合 Modbus 单次读取上限的连续读块 | |||
| bool calculatePollBlocks( | |||
| const std::vector<RegisterAddress> &addresses, | |||
| const std::vector<RegisterAddress> &float32_starts) | |||
| const std::vector<RegisterWordRange> &multi_word_ranges, | |||
| std::vector<PlcPollBlock> *blocks) | |||
| { | |||
| blocks->clear(); | |||
| if (addresses.empty()) | |||
| { | |||
| // 空集合也会保留 M0、D0 两个默认读块 | |||
| return 2U; | |||
| } | |||
| std::size_t blocks = 0U; | |||
| RegisterArea current_area = RegisterArea::M; | |||
| int start_address = 0; | |||
| 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 | |||
| || count >= ProjectLimits::kMaximumModbusReadCount) | |||
| blocks->push_back({RegisterArea::M, 0, 1}); | |||
| blocks->push_back({RegisterArea::D, 0, 1}); | |||
| return true; | |||
| } | |||
| // 先处理同一区域内连续地址,再按单次读取上限切分 | |||
| std::size_t run_start = 0U; | |||
| while (run_start < addresses.size()) | |||
| { | |||
| std::size_t run_end = run_start; | |||
| while (run_end + 1U < addresses.size() | |||
| && addresses[run_end + 1U].area() == addresses[run_start].area() | |||
| && addresses[run_end + 1U].index() == addresses[run_end].index() + 1) | |||
| { | |||
| ++blocks; | |||
| current_area = address.area(); | |||
| start_address = address.index(); | |||
| count = 1; | |||
| ++run_end; | |||
| } | |||
| else | |||
| const RegisterArea area = addresses[run_start].area(); | |||
| int cursor = addresses[run_start].index(); | |||
| const int last = addresses[run_end].index(); | |||
| while (cursor <= last) | |||
| { | |||
| count = address.index() - start_address + 1; | |||
| int block_end = std::min( | |||
| cursor + ProjectLimits::kMaximumModbusReadCount - 1, last); | |||
| while (block_end < last | |||
| && boundaryBelongsToMultiWordRange( | |||
| area, block_end, multi_word_ranges)) | |||
| { | |||
| --block_end; | |||
| } | |||
| if (block_end < cursor) | |||
| { | |||
| blocks->clear(); | |||
| return false; | |||
| } | |||
| blocks->push_back({area, cursor, block_end - cursor + 1}); | |||
| cursor = block_end + 1; | |||
| } | |||
| run_start = run_end + 1U; | |||
| } | |||
| return blocks; | |||
| return true; | |||
| } | |||
| } // namespace | |||
| // 创建 Qt Modbus 主站,绑定异步信号和 PLC 缓存写入回调 | |||
| PlcCommunicationService::PlcCommunicationService( | |||
| PlcRegisterRepository &repository, | |||
| QObject *parent) | |||
| @@ -154,20 +160,24 @@ PlcCommunicationService::PlcCommunicationService( | |||
| repository_.setWriteHandlers( | |||
| [this](const RegisterAddress &address, bool value) | |||
| { | |||
| // PLC 仓库收到 M 写入时转成 Modbus 线圈单写请求 | |||
| return sendBitWrite(address, value); | |||
| }, | |||
| [this](const RegisterAddress &address, std::int16_t value) | |||
| { | |||
| // PLC 仓库收到单个 D 写入时转成保持寄存器单写请求 | |||
| return sendWordWrite(address, value); | |||
| }, | |||
| [this](const RegisterAddress &address, | |||
| const std::array<std::int16_t, 2> &values) | |||
| const std::vector<std::int16_t> &values) | |||
| { | |||
| return sendWordPairWrite(address, values); | |||
| // PLC 仓库收到连续 D 写入时转成保持寄存器多写请求 | |||
| return sendWordsWrite(address, values); | |||
| }); | |||
| // 轮询定时器每次只推动一个读块,避免一次压入大量异步请求 | |||
| connect(&poll_timer_, &QTimer::timeout, this, &PlcCommunicationService::pollNextBlock); | |||
| recovery_timer_.setSingleShot(true); | |||
| // PLC 超时进入 Faulted 后,由恢复定时器调用轻量探测入口 | |||
| connect( | |||
| &recovery_timer_, | |||
| &QTimer::timeout, | |||
| @@ -184,6 +194,7 @@ PlcCommunicationService::PlcCommunicationService( | |||
| serial_session_opened_ = true; | |||
| setState(PlcConnectionState::Connected); | |||
| poll_timer_.start(configuration_.pollIntervalMs); | |||
| // 串口连上后立即异步读取第一个 M/D 地址块 | |||
| pollNextBlock(); | |||
| } | |||
| else if (device_state == QModbusDevice::ConnectingState) | |||
| @@ -215,13 +226,16 @@ PlcCommunicationService::PlcCommunicationService( | |||
| { | |||
| if (error != QModbusDevice::NoError) | |||
| { | |||
| // 底层 Modbus 错误统一交给项目错误分类和恢复流程 | |||
| handleModbusError(error); | |||
| } | |||
| }); | |||
| } | |||
| // 释放通信服务持有的 Qt 资源 | |||
| PlcCommunicationService::~PlcCommunicationService() = default; | |||
| // 校验串口配置并启动一次新的 PLC 异步连接 | |||
| PlcCommunicationResult PlcCommunicationService::connectDevice( | |||
| const PlcSerialConfiguration &configuration) | |||
| { | |||
| @@ -270,12 +284,14 @@ PlcCommunicationResult PlcCommunicationService::connectDevice( | |||
| updateInitialReadCompleted(false, true); | |||
| rebuildPollBlocks(); | |||
| setState(PlcConnectionState::Connecting); | |||
| // 只发起异步串口连接,连接结果由 Qt 状态回调继续推进 | |||
| if (!master_->connectDevice()) | |||
| { | |||
| if (last_error_.empty()) | |||
| { | |||
| const QModbusDevice::Error error = master_->error() == QModbusDevice::NoError | |||
| ? QModbusDevice::ConnectionError : master_->error(); | |||
| // 串口连接请求启动失败也进入统一错误分类和状态更新 | |||
| handleModbusError(error); | |||
| } | |||
| return {false, last_error_}; | |||
| @@ -283,6 +299,7 @@ PlcCommunicationResult PlcCommunicationService::connectDevice( | |||
| return {true, {}}; | |||
| } | |||
| // 停止定时器、关闭串口并清除本次连接的缓存有效状态 | |||
| void PlcCommunicationService::disconnectDevice() | |||
| { | |||
| // 关闭串口、清除缓存有效标记,防止断开后继续使用旧值 | |||
| @@ -294,15 +311,17 @@ void PlcCommunicationService::disconnectDevice() | |||
| setState(PlcConnectionState::Disconnected); | |||
| } | |||
| // 使用默认的单字范围设置轮询地址 | |||
| 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) | |||
| const std::vector<RegisterWordRange> &multi_word_ranges) | |||
| { | |||
| std::vector<RegisterAddress> normalized; | |||
| std::string error; | |||
| @@ -310,43 +329,38 @@ PlcCommunicationResult PlcCommunicationService::setPollAddresses( | |||
| { | |||
| return {false, error}; | |||
| } | |||
| std::vector<RegisterAddress> normalized_float32_starts = float32_starts; | |||
| std::vector<RegisterWordRange> normalized_ranges = multi_word_ranges; | |||
| std::sort( | |||
| normalized_float32_starts.begin(), normalized_float32_starts.end(), | |||
| [](const RegisterAddress &left, const RegisterAddress &right) | |||
| normalized_ranges.begin(), normalized_ranges.end(), | |||
| [](const RegisterWordRange &left, const RegisterWordRange &right) | |||
| { | |||
| return left.area() == right.area() | |||
| ? left.index() < right.index() | |||
| : left.area() == RegisterArea::M; | |||
| return left.start.index() == right.start.index() | |||
| ? left.wordCount < right.wordCount | |||
| : left.start.index() < right.start.index(); | |||
| }); | |||
| normalized_float32_starts.erase( | |||
| normalized_ranges.erase( | |||
| std::unique( | |||
| normalized_float32_starts.begin(), normalized_float32_starts.end()), | |||
| normalized_float32_starts.end()); | |||
| normalized_ranges.begin(), normalized_ranges.end()), | |||
| normalized_ranges.end()); | |||
| if (std::any_of( | |||
| normalized_float32_starts.cbegin(), normalized_float32_starts.cend(), | |||
| [](const RegisterAddress &address) | |||
| normalized_ranges.cbegin(), normalized_ranges.cend(), | |||
| [](const RegisterWordRange &range) | |||
| { | |||
| return !address.isValid() | |||
| || address.area() != RegisterArea::D | |||
| || address.index() >= RegisterAddress::kMaximumIndex; | |||
| return !range.start.isValid() | |||
| || range.start.area() != RegisterArea::D | |||
| || range.wordCount < 2 || range.wordCount > 4 | |||
| || range.start.index() | |||
| > RegisterAddress::kMaximumIndex - range.wordCount + 1; | |||
| })) | |||
| { | |||
| return {false, "Float32 轮询起始地址必须位于 D0~D3999"}; | |||
| return {false, "多字轮询范围必须是 D 区内连续的 2~4 个字"}; | |||
| } | |||
| for (const RegisterAddress &start : normalized_float32_starts) | |||
| for (const RegisterWordRange &range : normalized_ranges) | |||
| { | |||
| const RegisterAddress high{ | |||
| RegisterArea::D, start.index() + 1}; | |||
| if (std::find(normalized.cbegin(), normalized.cend(), start) | |||
| == normalized.cend()) | |||
| for (int offset = 0; offset < range.wordCount; ++offset) | |||
| { | |||
| normalized.push_back(start); | |||
| } | |||
| if (std::find(normalized.cbegin(), normalized.cend(), high) | |||
| == normalized.cend()) | |||
| { | |||
| normalized.push_back(high); | |||
| normalized.push_back(RegisterAddress{ | |||
| RegisterArea::D, range.start.index() + offset}); | |||
| } | |||
| } | |||
| std::sort( | |||
| @@ -362,8 +376,12 @@ PlcCommunicationResult PlcCommunicationService::setPollAddresses( | |||
| { | |||
| return {false, "PLC 轮询的去重 M/D 地址最多为 256 个"}; | |||
| } | |||
| if (pollBlockCount(normalized, normalized_float32_starts) | |||
| > ProjectLimits::kMaximumPollBlocks) | |||
| std::vector<PlcPollBlock> calculated_blocks; | |||
| if (!calculatePollBlocks(normalized, normalized_ranges, &calculated_blocks)) | |||
| { | |||
| return {false, "重叠的多字范围无法在单次 120 字读取边界内完整轮询"}; | |||
| } | |||
| if (calculated_blocks.size() > ProjectLimits::kMaximumPollBlocks) | |||
| { | |||
| return {false, "PLC 轮询地址拆分后最多允许 8 个读块"}; | |||
| } | |||
| @@ -372,40 +390,40 @@ PlcCommunicationResult PlcCommunicationService::setPollAddresses( | |||
| { | |||
| // 当前回复仍在解析旧集合,等它结束后再切换到新集合 | |||
| pending_poll_addresses_ = std::move(normalized); | |||
| pending_poll_float32_starts_ = std::move(normalized_float32_starts); | |||
| pending_poll_multi_word_ranges_ = std::move(normalized_ranges); | |||
| poll_update_pending_ = true; | |||
| return {true, {}}; | |||
| } | |||
| poll_float32_starts_ = std::move(normalized_float32_starts); | |||
| poll_multi_word_ranges_ = std::move(normalized_ranges); | |||
| applyPollAddresses(normalized); | |||
| return {true, {}}; | |||
| } | |||
| // 返回当前 PLC 连接状态 | |||
| PlcConnectionState PlcCommunicationService::state() const | |||
| { | |||
| return state_; | |||
| } | |||
| // 返回当前连接是否已完成全部轮询块的首次读取 | |||
| bool PlcCommunicationService::initialReadCompleted() const | |||
| { | |||
| return initial_read_completed_; | |||
| } | |||
| // 返回最近一次通信错误的统一类型 | |||
| PlcCommunicationError PlcCommunicationService::lastErrorType() const | |||
| { | |||
| return last_error_type_; | |||
| } | |||
| // 返回最近一次通信错误的可读文字 | |||
| const std::string &PlcCommunicationService::lastError() const | |||
| { | |||
| return last_error_; | |||
| } | |||
| const PlcSerialConfiguration &PlcCommunicationService::configuration() const | |||
| { | |||
| return configuration_; | |||
| } | |||
| // 保存外部回调,在对应通信事件发生时通知调用方 | |||
| void PlcCommunicationService::setCallbacks( | |||
| std::function<void()> state_changed, | |||
| std::function<void(bool)> initial_read_changed, | |||
| @@ -421,6 +439,7 @@ void PlcCommunicationService::setCallbacks( | |||
| error_reported_callback_ = std::move(error_reported); // 发生通信错误时通知外部 | |||
| } | |||
| // 根据当前地址集合重新生成连续轮询读块 | |||
| void PlcCommunicationService::rebuildPollBlocks() | |||
| { | |||
| std::vector<RegisterAddress> addresses = poll_addresses_; | |||
| @@ -442,54 +461,13 @@ 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 单次读取上限 | |||
| std::vector<PlcPollBlock> calculated_blocks; | |||
| calculatePollBlocks(addresses, poll_multi_word_ranges_, &calculated_blocks); | |||
| poll_blocks_.clear(); | |||
| for (const RegisterAddress &address : addresses) | |||
| poll_blocks_.reserve(calculated_blocks.size()); | |||
| for (const PlcPollBlock &block : calculated_blocks) | |||
| { | |||
| if (!address.isValid()) | |||
| { | |||
| 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 | |||
| + poll_blocks_.back().count | |||
| || poll_blocks_.back().count | |||
| >= ProjectLimits::kMaximumModbusReadCount) | |||
| { | |||
| poll_blocks_.push_back({address.area(), address.index(), 1}); | |||
| } | |||
| else | |||
| { | |||
| poll_blocks_.back().count = address.index() | |||
| - poll_blocks_.back().startAddress + 1; | |||
| } | |||
| poll_blocks_.push_back(block); | |||
| } | |||
| next_poll_block_ = 0; | |||
| if (!initial_read_completed_) | |||
| @@ -503,6 +481,7 @@ void PlcCommunicationService::rebuildPollBlocks() | |||
| } | |||
| } | |||
| // 在没有读请求占用时正式切换到新的轮询地址集合 | |||
| void PlcCommunicationService::applyPollAddresses( | |||
| const std::vector<RegisterAddress> &addresses) | |||
| { | |||
| @@ -511,13 +490,15 @@ void PlcCommunicationService::applyPollAddresses( | |||
| rebuildPollBlocks(); | |||
| poll_update_pending_ = false; | |||
| pending_poll_addresses_.clear(); | |||
| pending_poll_float32_starts_.clear(); | |||
| pending_poll_multi_word_ranges_.clear(); | |||
| if (isReadingState(state_) && pending_reply_ == nullptr) | |||
| { | |||
| // 新轮询地址生效后立即从第一个读块继续异步轮询 | |||
| pollNextBlock(); | |||
| } | |||
| } | |||
| // 发送一个异步轮询读请求,并由完成回调推进后续读块 | |||
| void PlcCommunicationService::pollNextBlock() | |||
| { | |||
| if (!isReadingState(state_) | |||
| @@ -527,13 +508,15 @@ void PlcCommunicationService::pollNextBlock() | |||
| } | |||
| // 每次只发一个异步请求,完成回调中再推进到下一个块 | |||
| const std::size_t block_index = next_poll_block_; | |||
| const PollBlock block = poll_blocks_.at(block_index); | |||
| const PlcPollBlock block = poll_blocks_.at(block_index); | |||
| next_poll_block_ = (next_poll_block_ + 1U) % poll_blocks_.size(); | |||
| QModbusDataUnit request( | |||
| registerType(block.area), block.startAddress, static_cast<quint16>(block.count)); | |||
| // 发出当前 M 或 D 地址块的异步 Modbus 读取请求 | |||
| QModbusReply *reply = master_->sendReadRequest(request, configuration_.serverAddress); | |||
| if (reply == nullptr) | |||
| { | |||
| // 读请求未能创建时按通信故障撤销首读资格 | |||
| handleModbusError(master_->error()); | |||
| return; | |||
| } | |||
| @@ -550,6 +533,7 @@ void PlcCommunicationService::pollNextBlock() | |||
| reply->deleteLater(); | |||
| return; | |||
| } | |||
| // 读取完成后解析回复并只用成功读回值更新 PLC 缓存 | |||
| handleReadFinished(reply, block); | |||
| if (isReadingState(state_) | |||
| && reply->error() == QModbusDevice::NoError | |||
| @@ -584,7 +568,7 @@ void PlcCommunicationService::pollNextBlock() | |||
| if (poll_update_pending_) | |||
| { | |||
| const std::vector<RegisterAddress> addresses = pending_poll_addresses_; | |||
| poll_float32_starts_ = pending_poll_float32_starts_; | |||
| poll_multi_word_ranges_ = pending_poll_multi_word_ranges_; | |||
| applyPollAddresses(addresses); | |||
| } | |||
| if (poll_cycle_completed) | |||
| @@ -598,6 +582,7 @@ void PlcCommunicationService::pollNextBlock() | |||
| }); | |||
| } | |||
| // 对可恢复通信故障发送一个轻量读取请求探测链路 | |||
| void PlcCommunicationService::probeRecovery() | |||
| { | |||
| // 只在可恢复的超时故障中探测;串口拔出等故障不会反复探测 | |||
| @@ -621,7 +606,7 @@ void PlcCommunicationService::probeRecovery() | |||
| return; | |||
| } | |||
| // 恢复探测只读取一个地址,确认链路恢复后再进行完整首读 | |||
| const PollBlock block = poll_blocks_.front(); | |||
| const PlcPollBlock block = poll_blocks_.front(); | |||
| const QModbusDataUnit request( | |||
| registerType(block.area), block.startAddress, 1); | |||
| QModbusReply *reply = master_->sendReadRequest(request, configuration_.serverAddress); | |||
| @@ -650,7 +635,7 @@ void PlcCommunicationService::probeRecovery() | |||
| if (poll_update_pending_) | |||
| { | |||
| const std::vector<RegisterAddress> addresses = pending_poll_addresses_; | |||
| poll_float32_starts_ = pending_poll_float32_starts_; | |||
| poll_multi_word_ranges_ = pending_poll_multi_word_ranges_; | |||
| applyPollAddresses(addresses); | |||
| } | |||
| if (state_ != PlcConnectionState::Faulted | |||
| @@ -663,10 +648,12 @@ void PlcCommunicationService::probeRecovery() | |||
| handleRecoveryProbeFailure(error); | |||
| return; | |||
| } | |||
| // 单地址探测成功后恢复正常轮询,并重新开始完整首读 | |||
| restoreCommunication(); | |||
| }); | |||
| } | |||
| // 处理恢复探测错误,并决定立即上报或继续等待探测 | |||
| void PlcCommunicationService::handleRecoveryProbeFailure(QModbusDevice::Error error) | |||
| { | |||
| if (state_ != PlcConnectionState::Faulted | |||
| @@ -680,12 +667,14 @@ void PlcCommunicationService::handleRecoveryProbeFailure(QModbusDevice::Error er | |||
| QString::fromStdString(configuration_.portName), | |||
| serial_session_opened_, | |||
| received_valid_response_}; | |||
| // 探测阶段发现串口连接已经失效时,转入统一故障处理 | |||
| setError(classifyPlcCommunicationError(error, context)); | |||
| return; | |||
| } | |||
| recovery_timer_.start(kRecoveryProbeIntervalMs); | |||
| } | |||
| // 探测成功后恢复轮询,并重新开始完整首读 | |||
| void PlcCommunicationService::restoreCommunication() | |||
| { | |||
| // 探测成功只说明链路恢复,仍要重新读取全部读块才能恢复真机资格 | |||
| @@ -695,10 +684,13 @@ void PlcCommunicationService::restoreCommunication() | |||
| rebuildPollBlocks(); | |||
| setState(PlcConnectionState::Recovering); | |||
| poll_timer_.start(configuration_.pollIntervalMs); | |||
| // 恢复后从第一个地址块重新开始完整首读 | |||
| pollNextBlock(); | |||
| } | |||
| void PlcCommunicationService::handleReadFinished(QModbusReply *reply, PollBlock block) | |||
| // 处理轮询读回复并更新 PLC 缓存 | |||
| void PlcCommunicationService::handleReadFinished( | |||
| QModbusReply *reply, PlcPollBlock block) | |||
| { | |||
| if (!isReadingState(state_)) | |||
| { | |||
| @@ -706,6 +698,7 @@ void PlcCommunicationService::handleReadFinished(QModbusReply *reply, PollBlock | |||
| } | |||
| if (reply->error() != QModbusDevice::NoError) | |||
| { | |||
| // 轮询回复失败时统一记录故障并启动对应恢复路径 | |||
| handleModbusError(reply->error()); | |||
| return; | |||
| } | |||
| @@ -716,10 +709,12 @@ void PlcCommunicationService::handleReadFinished(QModbusReply *reply, PollBlock | |||
| const int address = block.startAddress + static_cast<int>(index); | |||
| if (block.area == RegisterArea::M) | |||
| { | |||
| // M 地址只用成功读回的线圈值刷新缓存 | |||
| repository_.updateBit(address, result.value(index) != 0U); | |||
| } | |||
| else | |||
| { | |||
| // D 地址只用成功读回的保持寄存器值刷新缓存 | |||
| repository_.updateWord(address, static_cast<std::int16_t>(result.value(index))); | |||
| } | |||
| } | |||
| @@ -733,6 +728,7 @@ void PlcCommunicationService::handleReadFinished(QModbusReply *reply, PollBlock | |||
| } | |||
| } | |||
| // 发送一个 M 位异步写请求,不直接修改缓存 | |||
| RegisterWriteResult PlcCommunicationService::sendBitWrite( | |||
| const RegisterAddress &address, bool value) | |||
| { | |||
| @@ -748,9 +744,11 @@ RegisterWriteResult PlcCommunicationService::sendBitWrite( | |||
| // M 区对应 Modbus Coils,单次只写一个地址 | |||
| QModbusDataUnit unit(QModbusDataUnit::Coils, address.index(), 1); | |||
| unit.setValue(0, value ? 1U : 0U); | |||
| // 异步发送 Modbus 线圈单写,成功后仍等待轮询读回确认 | |||
| QModbusReply *reply = master_->sendWriteRequest(unit, configuration_.serverAddress); | |||
| if (reply == nullptr) | |||
| { | |||
| // M 写请求未能创建时进入统一通信故障处理 | |||
| handleModbusError(master_->error()); | |||
| return {false, RegisterError::WriteRejected}; | |||
| } | |||
| @@ -771,6 +769,7 @@ RegisterWriteResult PlcCommunicationService::sendBitWrite( | |||
| } | |||
| if (reply->error() != QModbusDevice::NoError) | |||
| { | |||
| // M 异步写回复失败时进入统一通信故障处理 | |||
| handleModbusError(reply->error()); | |||
| } | |||
| reply->deleteLater(); | |||
| @@ -778,6 +777,7 @@ RegisterWriteResult PlcCommunicationService::sendBitWrite( | |||
| return {true, RegisterError::None}; | |||
| } | |||
| // 发送一个 D 字异步写请求,不直接修改缓存 | |||
| RegisterWriteResult PlcCommunicationService::sendWordWrite( | |||
| const RegisterAddress &address, std::int16_t value) | |||
| { | |||
| @@ -793,9 +793,11 @@ RegisterWriteResult PlcCommunicationService::sendWordWrite( | |||
| // D 区对应 Modbus HoldingRegisters,单次只写一个字 | |||
| QModbusDataUnit unit(QModbusDataUnit::HoldingRegisters, address.index(), 1); | |||
| unit.setValue(0, static_cast<quint16>(value)); | |||
| // 异步发送保持寄存器单写,成功后不直接修改本地缓存 | |||
| QModbusReply *reply = master_->sendWriteRequest(unit, configuration_.serverAddress); | |||
| if (reply == nullptr) | |||
| { | |||
| // 单个 D 写请求未能创建时进入统一通信故障处理 | |||
| handleModbusError(master_->error()); | |||
| return {false, RegisterError::WriteRejected}; | |||
| } | |||
| @@ -815,6 +817,7 @@ RegisterWriteResult PlcCommunicationService::sendWordWrite( | |||
| } | |||
| if (reply->error() != QModbusDevice::NoError) | |||
| { | |||
| // 单个 D 异步写回复失败时进入统一通信故障处理 | |||
| handleModbusError(reply->error()); | |||
| } | |||
| reply->deleteLater(); | |||
| @@ -822,27 +825,35 @@ RegisterWriteResult PlcCommunicationService::sendWordWrite( | |||
| return {true, RegisterError::None}; | |||
| } | |||
| RegisterWriteResult PlcCommunicationService::sendWordPairWrite( | |||
| // 发送一组连续 D 字异步写请求,不直接修改缓存 | |||
| RegisterWriteResult PlcCommunicationService::sendWordsWrite( | |||
| const RegisterAddress &address, | |||
| const std::array<std::int16_t, 2> &values) | |||
| const std::vector<std::int16_t> &values) | |||
| { | |||
| const int count = static_cast<int>(values.size()); | |||
| if (state_ != PlcConnectionState::Connected) | |||
| { | |||
| return {false, RegisterError::Unavailable}; | |||
| } | |||
| if (pending_write_reply_ != nullptr | |||
| || !address.isValid() || address.area() != RegisterArea::D | |||
| || address.index() >= RegisterAddress::kMaximumIndex) | |||
| || count < 2 || count > 4 | |||
| || address.index() > RegisterAddress::kMaximumIndex - count + 1) | |||
| { | |||
| 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])); | |||
| QModbusDataUnit unit(QModbusDataUnit::HoldingRegisters, address.index(), count); | |||
| for (int offset = 0; offset < count; ++offset) | |||
| { | |||
| unit.setValue( | |||
| offset, static_cast<quint16>(values[static_cast<std::size_t>(offset)])); | |||
| } | |||
| // 异步发送连续保持寄存器多写,供 32 位和 64 位数值使用 | |||
| QModbusReply *reply = master_->sendWriteRequest(unit, configuration_.serverAddress); | |||
| if (reply == nullptr) | |||
| { | |||
| // 连续 D 写请求未能创建时进入统一通信故障处理 | |||
| handleModbusError(master_->error()); | |||
| return {false, RegisterError::WriteRejected}; | |||
| } | |||
| @@ -862,6 +873,7 @@ RegisterWriteResult PlcCommunicationService::sendWordPairWrite( | |||
| } | |||
| if (reply->error() != QModbusDevice::NoError) | |||
| { | |||
| // 连续 D 异步写回复失败时进入统一通信故障处理 | |||
| handleModbusError(reply->error()); | |||
| } | |||
| reply->deleteLater(); | |||
| @@ -869,6 +881,7 @@ RegisterWriteResult PlcCommunicationService::sendWordPairWrite( | |||
| return {true, RegisterError::None}; | |||
| } | |||
| // 更新首读完成标记,并在需要时通知外部观察者 | |||
| void PlcCommunicationService::updateInitialReadCompleted( | |||
| bool completed, bool force_notification) | |||
| { | |||
| @@ -885,11 +898,13 @@ void PlcCommunicationService::updateInitialReadCompleted( | |||
| } | |||
| } | |||
| // 处理未主动断开时发生的串口连接丢失 | |||
| void PlcCommunicationService::handleUnexpectedDisconnect() | |||
| { | |||
| // 这里表示设备原本连上过,后来串口意外断开 | |||
| serial_session_opened_ = false; | |||
| const QString port_name = QString::fromStdString(configuration_.portName).trimmed(); | |||
| // 将 USB 串口拔出等异常记录为 Disconnected,交给运行版定时重连 | |||
| setError({ | |||
| PlcCommunicationError::SerialConnectionLost, | |||
| QStringLiteral( | |||
| @@ -897,6 +912,7 @@ void PlcCommunicationService::handleUnexpectedDisconnect() | |||
| .arg(port_name)}); | |||
| } | |||
| // 过滤重复错误后,分类并保存 Qt Modbus 错误 | |||
| void PlcCommunicationService::handleModbusError(QModbusDevice::Error error) | |||
| { | |||
| // 主动断开、已处理的故障和旧回复错误都不重复上报 | |||
| @@ -913,9 +929,11 @@ void PlcCommunicationService::handleModbusError(QModbusDevice::Error error) | |||
| QString::fromStdString(configuration_.portName), | |||
| serial_session_opened_, | |||
| received_valid_response_}; | |||
| // 将 PLC 无响应等错误记录为 Faulted,并启动通信恢复探测 | |||
| setError(classifyPlcCommunicationError(error, context)); | |||
| } | |||
| // 使旧异步请求失效并关闭当前串口会话 | |||
| void PlcCommunicationService::closeSerialSession() | |||
| { | |||
| // 先递增代次并停止定时器,再断开串口;旧异步回调会因此失效 | |||
| @@ -927,6 +945,7 @@ void PlcCommunicationService::closeSerialSession() | |||
| pending_write_reply_ = nullptr; | |||
| poll_update_pending_ = false; | |||
| pending_poll_addresses_.clear(); | |||
| pending_poll_multi_word_ranges_.clear(); | |||
| if (master_->state() != QModbusDevice::UnconnectedState) | |||
| { | |||
| master_->disconnectDevice(); | |||
| @@ -936,6 +955,7 @@ void PlcCommunicationService::closeSerialSession() | |||
| disconnecting_ = false; | |||
| } | |||
| // 修改连接状态并同步发出 Qt 信号和外部回调 | |||
| void PlcCommunicationService::setState(PlcConnectionState state) | |||
| { | |||
| if (state_ == state) | |||
| @@ -951,6 +971,7 @@ void PlcCommunicationService::setState(PlcConnectionState state) | |||
| } | |||
| } | |||
| // 保存通信故障、撤销首读资格并通知外部观察者 | |||
| void PlcCommunicationService::setError(const PlcCommunicationFailure &failure) | |||
| { | |||
| // 统一保存错误、停止正常轮询、撤销首读资格并通知 UI | |||
| @@ -980,5 +1001,3 @@ void PlcCommunicationService::setError(const PlcCommunicationFailure &failure) | |||
| recovery_timer_.start(kRecoveryProbeIntervalMs); | |||
| } | |||
| } | |||
| // 按区域和地址排序,方便后面合并连续读块 | |||
| // 相同区域和编号的地址只保留一个 | |||
| @@ -8,7 +8,6 @@ | |||
| #include <QTimer> | |||
| #include <cstdint> | |||
| #include <array> | |||
| #include <memory> | |||
| #include <string> | |||
| #include <vector> | |||
| @@ -18,6 +17,13 @@ class QModbusReply; | |||
| class QModbusRtuSerialMaster; | |||
| struct PlcCommunicationFailure; | |||
| struct PlcPollBlock | |||
| { | |||
| RegisterArea area = RegisterArea::M; // 读块所在的寄存器区域 | |||
| int startAddress = 0; // 读块的起始原始地址 | |||
| int count = 1; // 本次连续读取的字或位数量 | |||
| }; | |||
| // 基于 Qt Modbus RTU 的异步 PLC 通信实现 | |||
| // 负责连接、轮询、单点写入、故障恢复和首读资格,不阻塞 UI 线程 | |||
| class PlcCommunicationService final : public QObject, public PlcCommunicationGateway | |||
| @@ -25,24 +31,42 @@ class PlcCommunicationService final : public QObject, public PlcCommunicationGat | |||
| Q_OBJECT | |||
| public: | |||
| // 创建通信服务,并把 PLC 缓存仓库交给它管理 | |||
| /** | |||
| * @brief 创建通信服务,并把 PLC 缓存仓库交给它管理 | |||
| * @param repository 保存 PLC 最近一次成功读数的缓存仓库 | |||
| * @param parent Qt 父对象 | |||
| */ | |||
| explicit PlcCommunicationService( | |||
| PlcRegisterRepository &repository, | |||
| QObject *parent = nullptr); | |||
| // 释放 Modbus 主站和未完成的通信资源 | |||
| ~PlcCommunicationService() override; | |||
| // 按给定串口参数连接 PLC,并开始异步轮询 | |||
| /** | |||
| * @brief 按给定串口参数连接 PLC,并开始异步轮询 | |||
| * @param configuration 串口、通信格式和 PLC 站号配置 | |||
| * @return 连接请求启动结果,最终连接状态通过状态通知返回 | |||
| */ | |||
| PlcCommunicationResult connectDevice( | |||
| const PlcSerialConfiguration &configuration) override; | |||
| // 停止轮询并断开 PLC | |||
| void disconnectDevice() override; | |||
| // 设置需要周期性读取的 M/D 地址集合 | |||
| /** | |||
| * @brief 设置需要周期性读取的 M/D 地址集合 | |||
| * @param addresses 要轮询的寄存器地址,服务内部负责去重和合并 | |||
| * @return 地址校验和轮询集合更新结果 | |||
| */ | |||
| PlcCommunicationResult setPollAddresses( | |||
| const std::vector<RegisterAddress> &addresses) override; | |||
| /** | |||
| * @brief 设置轮询地址及需要保持完整边界的多字数据范围 | |||
| * @param addresses 要轮询的寄存器地址 | |||
| * @param multi_word_ranges 不能被拆散的连续 D 字数据范围 | |||
| * @return 地址和多字范围校验以及轮询集合更新结果 | |||
| */ | |||
| PlcCommunicationResult setPollAddresses( | |||
| const std::vector<RegisterAddress> &addresses, | |||
| const std::vector<RegisterAddress> &float32_starts) override; | |||
| const std::vector<RegisterWordRange> &multi_word_ranges) override; | |||
| // 返回当前连接状态 | |||
| PlcConnectionState state() const override; | |||
| @@ -52,9 +76,14 @@ public: | |||
| PlcCommunicationError lastErrorType() const override; | |||
| // 返回最近一次通信错误的文字 | |||
| const std::string &lastError() const override; | |||
| // 返回当前使用的串口和 Modbus 参数 | |||
| const PlcSerialConfiguration &configuration() const; | |||
| // 注册状态、首读、缓存更新和错误通知回调 | |||
| /** | |||
| * @brief 注册状态、首读、缓存更新、轮询完成和错误通知回调 | |||
| * @param state_changed PLC 连接状态变化时调用 | |||
| * @param initial_read_changed 首次完整读取资格变化时调用 | |||
| * @param cache_updated 任一轮询块成功写入缓存后调用 | |||
| * @param poll_cycle_completed 全部轮询块成功更新一轮后调用 | |||
| * @param error_reported 产生需要展示的通信错误时调用 | |||
| */ | |||
| void setCallbacks( | |||
| std::function<void()> state_changed, | |||
| std::function<void(bool)> initial_read_changed, | |||
| @@ -65,56 +94,94 @@ public: | |||
| signals: | |||
| // 连接状态发生变化 | |||
| void stateChanged(); | |||
| // 首次完整读取资格发生变化 | |||
| /** | |||
| * @brief 首次完整读取资格发生变化 | |||
| * @param completed 是否已经完成全部轮询块的首次读取 | |||
| */ | |||
| void initialReadCompletedChanged(bool completed); | |||
| // PLC 缓存收到新的成功读数 | |||
| void cacheUpdated(); | |||
| // 全部轮询块成功更新一轮 | |||
| void pollCycleCompleted(); | |||
| // 通信错误的可读提示文字 | |||
| /** | |||
| * @brief 发出通信错误的可读提示文字 | |||
| * @param message 可直接显示给用户的错误说明 | |||
| */ | |||
| void communicationError(const QString &message); | |||
| private: | |||
| struct PollBlock | |||
| { | |||
| // 同一区域的一段连续 Modbus 原始地址 | |||
| RegisterArea area = RegisterArea::M; // M 区或 D 区 | |||
| int startAddress = 0; // 读块的第一个原始地址 | |||
| int count = 1; // 从起始地址连续读取的数量 | |||
| }; | |||
| // 把去重后的地址集合压缩为有限数量的连续读块 | |||
| void rebuildPollBlocks(); | |||
| // 应用新的轮询集合;有请求在途时由 pending_* 延后应用 | |||
| /** | |||
| * @brief 应用新的轮询集合,有请求在途时由 pending 字段延后应用 | |||
| * @param addresses 已经完成校验和去重的轮询地址 | |||
| */ | |||
| void applyPollAddresses(const std::vector<RegisterAddress> &addresses); | |||
| // 发送下一段异步读取请求 | |||
| void pollNextBlock(); | |||
| // Faulted 状态下用轻量探测判断通信是否恢复 | |||
| void probeRecovery(); | |||
| // 处理恢复探测失败,并安排下一次探测 | |||
| /** | |||
| * @brief 处理恢复探测失败,并安排下一次探测 | |||
| * @param error Qt Modbus 返回的探测错误 | |||
| */ | |||
| void handleRecoveryProbeFailure(QModbusDevice::Error error); | |||
| // 探测成功后恢复轮询,但仍需重新完成首读 | |||
| void restoreCommunication(); | |||
| // 处理读回复并把值写入 PLC 缓存 | |||
| void handleReadFinished(QModbusReply *reply, PollBlock block); | |||
| // 发送单点 M/D 写请求;缓存等待后续轮询确认 | |||
| /** | |||
| * @brief 处理读回复并把成功读到的值写入 PLC 缓存 | |||
| * @param reply 当前轮询块的异步 Modbus 回复 | |||
| * @param block 发起本次请求时对应的轮询块 | |||
| */ | |||
| void handleReadFinished(QModbusReply *reply, PlcPollBlock block); | |||
| /** | |||
| * @brief 发送单个 M 位写请求,缓存等待后续轮询确认 | |||
| * @param address 要写入的 M 区地址 | |||
| * @param value 要写入的位值 | |||
| * @return 异步写请求提交结果 | |||
| */ | |||
| RegisterWriteResult sendBitWrite(const RegisterAddress &address, bool value); | |||
| /** | |||
| * @brief 发送单个 D 字写请求,缓存等待后续轮询确认 | |||
| * @param address 要写入的 D 区地址 | |||
| * @param value 要写入的 16 位字值 | |||
| * @return 异步写请求提交结果 | |||
| */ | |||
| RegisterWriteResult sendWordWrite( | |||
| const RegisterAddress &address, std::int16_t value); | |||
| RegisterWriteResult sendWordPairWrite( | |||
| /** | |||
| * @brief 发送连续 D 字写请求,缓存等待后续轮询确认 | |||
| * @param address 连续写入的起始 D 区地址 | |||
| * @param values 按地址递增顺序写入的 16 位字值 | |||
| * @return 异步写请求提交结果 | |||
| */ | |||
| RegisterWriteResult sendWordsWrite( | |||
| const RegisterAddress &address, | |||
| const std::array<std::int16_t, 2> &values); | |||
| // 更新“所有轮询块均成功读取”的真机进入资格 | |||
| const std::vector<std::int16_t> &values); | |||
| /** | |||
| * @brief 更新所有轮询块均成功读取的真机进入资格 | |||
| * @param completed 是否已经完成本轮首读要求 | |||
| * @param force_notification 是否在状态未变化时仍强制发送通知 | |||
| */ | |||
| void updateInitialReadCompleted(bool completed, bool force_notification = false); | |||
| // 处理没有主动断开时发生的串口断线 | |||
| void handleUnexpectedDisconnect(); | |||
| // 把 Qt Modbus 错误转换成项目自己的通信错误 | |||
| /** | |||
| * @brief 把 Qt Modbus 错误转换成项目自己的通信错误 | |||
| * @param error Qt Modbus 返回的设备错误 | |||
| */ | |||
| void handleModbusError(QModbusDevice::Error error); | |||
| // 关闭串口并清理当前连接的请求和状态 | |||
| void closeSerialSession(); | |||
| // 修改连接状态并通知外部观察者 | |||
| /** | |||
| * @brief 修改连接状态并通知外部观察者 | |||
| * @param state 要切换到的新连接状态 | |||
| */ | |||
| void setState(PlcConnectionState state); | |||
| // 保存错误信息、更新状态并通知外部观察者 | |||
| /** | |||
| * @brief 保存错误信息、更新状态并通知外部观察者 | |||
| * @param failure 已经分类的通信故障 | |||
| */ | |||
| void setError(const PlcCommunicationFailure &failure); | |||
| PlcRegisterRepository &repository_; // 用于保存 PLC 最近一次成功读回的 M/D 值 | |||
| @@ -124,9 +191,9 @@ private: | |||
| 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::vector<RegisterWordRange> poll_multi_word_ranges_; // 当前轮询集合中的多字范围 | |||
| std::vector<RegisterWordRange> pending_poll_multi_word_ranges_; // 请求在途时暂存的多字范围 | |||
| std::vector<PlcPollBlock> poll_blocks_; // 根据地址合并出的连续读块 | |||
| std::size_t next_poll_block_ = 0; // 下一次要读取的读块下标 | |||
| QModbusReply *pending_reply_ = nullptr; // 当前未完成的读请求或恢复探测 | |||
| QModbusReply *pending_write_reply_ = nullptr; // 当前未完成的单点写请求 | |||
| @@ -19,6 +19,7 @@ constexpr int kDiscoveryResponseTimeoutMs = 200; | |||
| // 正式连接可能刚被配置窗口断开,先留出时间让操作系统完全释放串口句柄 | |||
| constexpr int kDiscoveryStartDelayMs = 100; | |||
| // 将 Qt 字符串转换为项目内部使用的 UTF-8 标准字符串 | |||
| std::string toUtf8(const QString &value) | |||
| { | |||
| const QByteArray bytes = value.toUtf8(); | |||
| @@ -27,6 +28,7 @@ std::string toUtf8(const QString &value) | |||
| } // namespace | |||
| // 创建独立的 Modbus 探测主站并绑定状态变化处理 | |||
| PlcDiscoveryService::PlcDiscoveryService(QObject *parent) | |||
| : QObject(parent), | |||
| master_(std::make_unique<QModbusRtuSerialMaster>()) | |||
| @@ -36,6 +38,7 @@ PlcDiscoveryService::PlcDiscoveryService(QObject *parent) | |||
| this, &PlcDiscoveryService::handleDeviceStateChanged); | |||
| } | |||
| // 清理回调并释放仍可能占用串口的探测主站 | |||
| PlcDiscoveryService::~PlcDiscoveryService() | |||
| { | |||
| progress_changed_callback_ = {}; | |||
| @@ -46,6 +49,7 @@ PlcDiscoveryService::~PlcDiscoveryService() | |||
| } | |||
| } | |||
| // 收集可用串口并启动候选参数的异步逐项探测 | |||
| PlcCommunicationResult PlcDiscoveryService::startDiscovery( | |||
| const PlcSerialConfiguration &preferred) | |||
| { | |||
| @@ -67,6 +71,7 @@ PlcCommunicationResult PlcDiscoveryService::startDiscovery( | |||
| { | |||
| available_ports.push_back(toUtf8(port.portName())); | |||
| } | |||
| // 用可用端口和受支持的串口参数生成按顺序尝试的候选列表 | |||
| candidates_ = buildPlcDiscoveryCandidates(preferred, available_ports); | |||
| if (candidates_.empty()) | |||
| { | |||
| @@ -84,6 +89,7 @@ PlcCommunicationResult PlcDiscoveryService::startDiscovery( | |||
| cancel_requested_ = false; | |||
| found_pending_ = false; | |||
| ++attempt_generation_; | |||
| // 延迟启动,给刚关闭的正式通信服务留出释放串口句柄的时间 | |||
| QTimer::singleShot( | |||
| kDiscoveryStartDelayMs, | |||
| this, | |||
| @@ -91,6 +97,7 @@ PlcCommunicationResult PlcDiscoveryService::startDiscovery( | |||
| return {true, {}}; | |||
| } | |||
| // 标记取消并释放当前候选串口 | |||
| void PlcDiscoveryService::cancelDiscovery() | |||
| { | |||
| if (!discovering_ || cancel_requested_) | |||
| @@ -103,11 +110,13 @@ void PlcDiscoveryService::cancelDiscovery() | |||
| 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) | |||
| @@ -116,6 +125,7 @@ void PlcDiscoveryService::setCallbacks( | |||
| discovery_finished_callback_ = std::move(discovery_finished); | |||
| } | |||
| // 应用当前候选串口参数并发起异步连接 | |||
| void PlcDiscoveryService::tryCurrentCandidate() | |||
| { | |||
| if (!discovering_) | |||
| @@ -159,6 +169,7 @@ void PlcDiscoveryService::tryCurrentCandidate() | |||
| static_cast<QSerialPort::StopBits>(candidate.stopBits)); | |||
| master_->setTimeout(kDiscoveryResponseTimeoutMs); | |||
| master_->setNumberOfRetries(0); | |||
| // 异步打开当前候选串口,连接结果由状态变化回调继续处理 | |||
| if (!master_->connectDevice()) | |||
| { | |||
| QTimer::singleShot( | |||
| @@ -173,6 +184,7 @@ void PlcDiscoveryService::tryCurrentCandidate() | |||
| } | |||
| } | |||
| // 连接成功后读取 D0,使用只读请求判断 PLC 是否响应 | |||
| void PlcDiscoveryService::sendProbe() | |||
| { | |||
| if (!discovering_ || cancel_requested_ | |||
| @@ -185,6 +197,7 @@ void PlcDiscoveryService::sendProbe() | |||
| const PlcSerialConfiguration &candidate = candidates_.at(current_candidate_); | |||
| const QModbusDataUnit request( | |||
| QModbusDataUnit::HoldingRegisters, 0, 1); | |||
| // 异步读取 D0,只用回复判断当前参数能否与 PLC 通信 | |||
| QModbusReply *reply = master_->sendReadRequest( | |||
| request, candidate.serverAddress); | |||
| if (reply == nullptr) | |||
| @@ -199,10 +212,12 @@ void PlcDiscoveryService::sendProbe() | |||
| this, | |||
| [this, reply, generation] | |||
| { | |||
| // 回复到达后校验搜索代次,避免旧候选结果干扰当前尝试 | |||
| handleProbeFinished(reply, generation); | |||
| }); | |||
| } | |||
| // 处理 D0 探测回复,并确认它是否属于当前搜索尝试 | |||
| void PlcDiscoveryService::handleProbeFinished( | |||
| QModbusReply *reply, | |||
| std::uint64_t attempt_generation) | |||
| @@ -215,6 +230,7 @@ void PlcDiscoveryService::handleProbeFinished( | |||
| { | |||
| pending_reply_.clear(); | |||
| } | |||
| // 正常响应和 Modbus 异常响应都说明串口链路上确实有设备回应 | |||
| const bool valid_response = reply->error() == QModbusDevice::NoError | |||
| || reply->rawResult().isException(); | |||
| reply->deleteLater(); | |||
| @@ -224,6 +240,7 @@ void PlcDiscoveryService::handleProbeFinished( | |||
| } | |||
| } | |||
| // 释放当前候选串口,并记录是否已探测到 PLC | |||
| void PlcDiscoveryService::disconnectCurrent(bool found) | |||
| { | |||
| if (!discovering_) | |||
| @@ -240,6 +257,7 @@ void PlcDiscoveryService::disconnectCurrent(bool found) | |||
| master_->disconnectDevice(); | |||
| } | |||
| // 串口释放后决定报告成功、取消,或继续下一个候选 | |||
| void PlcDiscoveryService::continueAfterDisconnect() | |||
| { | |||
| if (!discovering_ || phase_ != Phase::Disconnecting) | |||
| @@ -260,9 +278,11 @@ void PlcDiscoveryService::continueAfterDisconnect() | |||
| } | |||
| ++current_candidate_; | |||
| phase_ = Phase::Idle; | |||
| // 当前候选无响应,释放串口后再异步尝试下一组参数 | |||
| QTimer::singleShot(0, this, &PlcDiscoveryService::tryCurrentCandidate); | |||
| } | |||
| // 清理搜索状态并报告最终搜索结果 | |||
| void PlcDiscoveryService::finishDiscovery(const PlcDiscoveryOutcome &outcome) | |||
| { | |||
| discovering_ = false; | |||
| @@ -279,6 +299,7 @@ void PlcDiscoveryService::finishDiscovery(const PlcDiscoveryOutcome &outcome) | |||
| current_candidate_ = 0; | |||
| } | |||
| // 根据 Qt Modbus 状态变化推进连接、探测和断开流程 | |||
| void PlcDiscoveryService::handleDeviceStateChanged(QModbusDevice::State state) | |||
| { | |||
| if (!discovering_) | |||
| @@ -287,6 +308,7 @@ void PlcDiscoveryService::handleDeviceStateChanged(QModbusDevice::State state) | |||
| } | |||
| if (state == QModbusDevice::ConnectedState && phase_ == Phase::Connecting) | |||
| { | |||
| // 候选串口打开成功后读取 D0,确认链路上是否有 PLC 回复 | |||
| sendProbe(); | |||
| return; | |||
| } | |||
| @@ -19,13 +19,29 @@ class QModbusRtuSerialMaster; | |||
| class PlcDiscoveryService final : public QObject, public PlcDiscoveryGateway | |||
| { | |||
| public: | |||
| /** | |||
| * @brief 创建独立的 PLC 自动搜索服务 | |||
| * @param parent Qt 父对象 | |||
| */ | |||
| explicit PlcDiscoveryService(QObject *parent = nullptr); | |||
| ~PlcDiscoveryService() override; | |||
| /** | |||
| * @brief 枚举串口和候选参数,并异步逐项探测 PLC | |||
| * @param preferred 优先尝试的串口参数 | |||
| * @return 搜索启动结果,最终搜索结果通过回调返回 | |||
| */ | |||
| PlcCommunicationResult startDiscovery( | |||
| const PlcSerialConfiguration &preferred) override; | |||
| // 取消当前自动搜索并释放探测串口 | |||
| void cancelDiscovery() override; | |||
| // 返回当前是否正在自动搜索 | |||
| bool isDiscovering() const override; | |||
| /** | |||
| * @brief 设置搜索进度和最终结果回调 | |||
| * @param progress_changed 每次候选参数或进度变化时调用 | |||
| * @param discovery_finished 搜索结束、取消或失败时调用 | |||
| */ | |||
| void setCallbacks( | |||
| std::function<void(const PlcDiscoveryProgress &)> progress_changed, | |||
| std::function<void(const PlcDiscoveryOutcome &)> discovery_finished) override; | |||
| @@ -33,36 +49,49 @@ public: | |||
| private: | |||
| enum class Phase | |||
| { | |||
| Idle, | |||
| Connecting, | |||
| Probing, | |||
| Disconnecting | |||
| Idle, // 等待尝试候选参数 | |||
| Connecting, // 正在异步打开候选串口 | |||
| Probing, // 已打开串口,正在读取 D0 | |||
| Disconnecting // 正在释放当前候选串口 | |||
| }; | |||
| // 尝试当前候选参数并等待串口异步打开 | |||
| void tryCurrentCandidate(); | |||
| // 串口打开后发送只读 D0 探测请求 | |||
| void sendProbe(); | |||
| // 处理当前 D0 探测结果 | |||
| /** | |||
| * @brief 处理当前 D0 探测结果 | |||
| * @param reply 当前候选串口的异步 Modbus 回复 | |||
| * @param attempt_generation 发起请求时的尝试代次,用于丢弃过期回复 | |||
| */ | |||
| void handleProbeFinished(QModbusReply *reply, std::uint64_t attempt_generation); | |||
| // 释放当前串口,释放完成后进入下一个候选或报告结果 | |||
| /** | |||
| * @brief 释放当前串口,释放完成后进入下一个候选或报告结果 | |||
| * @param found 当前候选参数是否已经探测到 PLC | |||
| */ | |||
| void disconnectCurrent(bool found); | |||
| // 当前串口完全释放后完成状态迁移 | |||
| void continueAfterDisconnect(); | |||
| // 报告最终结果并清理搜索状态 | |||
| /** | |||
| * @brief 报告最终结果并清理搜索状态 | |||
| * @param outcome 要交给调用方的最终搜索结果 | |||
| */ | |||
| void finishDiscovery(const PlcDiscoveryOutcome &outcome); | |||
| // 处理 Qt Modbus 主站连接状态变化 | |||
| /** | |||
| * @brief 处理 Qt Modbus 主站连接状态变化 | |||
| * @param state 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_; | |||
| std::unique_ptr<QModbusRtuSerialMaster> master_; // 独立的探测主站对象 | |||
| std::vector<PlcSerialConfiguration> candidates_; // 待依次尝试的串口参数 | |||
| std::size_t current_candidate_ = 0; // 当前候选参数下标 | |||
| QPointer<QModbusReply> pending_reply_; // 当前未完成的 D0 探测回复 | |||
| Phase phase_ = Phase::Idle; // 当前搜索阶段 | |||
| bool discovering_ = false; // 是否正在执行搜索 | |||
| bool cancel_requested_ = false; // 是否已收到取消请求 | |||
| bool found_pending_ = false; // 当前候选是否探测到 PLC | |||
| std::uint64_t attempt_generation_ = 0; // 尝试代次,用于丢弃旧异步回复 | |||
| std::function<void(const PlcDiscoveryProgress &)> progress_changed_callback_; // 搜索进度回调 | |||
| std::function<void(const PlcDiscoveryOutcome &)> discovery_finished_callback_; // 搜索结束回调 | |||
| }; | |||
| @@ -4,6 +4,7 @@ | |||
| namespace { | |||
| // 同时检查地址范围和寄存器区域是否符合操作要求 | |||
| bool validAddress(const RegisterAddress &address, RegisterArea area) | |||
| { | |||
| return address.isValid() && address.area() == area; | |||
| @@ -11,8 +12,10 @@ bool validAddress(const RegisterAddress &address, RegisterArea area) | |||
| } // namespace | |||
| // 初始化空缓存,所有地址在首次读回前都保持不可用 | |||
| PlcRegisterRepository::PlcRegisterRepository() = default; | |||
| // 读取 M 位缓存,未完成过成功读回的地址返回不可用 | |||
| BitReadResult PlcRegisterRepository::readBit(const RegisterAddress &address) const | |||
| { | |||
| if (!address.isValid()) | |||
| @@ -29,6 +32,7 @@ BitReadResult PlcRegisterRepository::readBit(const RegisterAddress &address) con | |||
| : BitReadResult{false, false, RegisterError::Unavailable}; | |||
| } | |||
| // 转发 M 位异步写请求,等待后续 PLC 读回再更新缓存 | |||
| RegisterWriteResult PlcRegisterRepository::writeBit( | |||
| const RegisterAddress &address, bool value) | |||
| { | |||
| @@ -37,10 +41,12 @@ RegisterWriteResult PlcRegisterRepository::writeBit( | |||
| return {false, address.isValid() | |||
| ? RegisterError::AreaMismatch : RegisterError::InvalidAddress}; | |||
| } | |||
| // 仓库不预改 M 缓存,只把请求交给通信服务异步写 PLC | |||
| return bit_handler_ ? bit_handler_(address, value) | |||
| : RegisterWriteResult{false, RegisterError::Unavailable}; | |||
| } | |||
| // 读取 D 字缓存,未完成过成功读回的地址返回不可用 | |||
| WordReadResult PlcRegisterRepository::readWord(const RegisterAddress &address) const | |||
| { | |||
| if (!address.isValid()) | |||
| @@ -57,6 +63,7 @@ WordReadResult PlcRegisterRepository::readWord(const RegisterAddress &address) c | |||
| : WordReadResult{false, 0, RegisterError::Unavailable}; | |||
| } | |||
| // 转发单个 D 字异步写请求,等待后续 PLC 读回再更新缓存 | |||
| RegisterWriteResult PlcRegisterRepository::writeWord( | |||
| const RegisterAddress &address, std::int16_t value) | |||
| { | |||
| @@ -65,41 +72,57 @@ RegisterWriteResult PlcRegisterRepository::writeWord( | |||
| return {false, address.isValid() | |||
| ? RegisterError::AreaMismatch : RegisterError::InvalidAddress}; | |||
| } | |||
| // 仓库不预改单字缓存,只把请求交给通信服务异步写 PLC | |||
| return word_handler_ ? word_handler_(address, value) | |||
| : RegisterWriteResult{false, RegisterError::Unavailable}; | |||
| } | |||
| WordPairReadResult PlcRegisterRepository::readWordPair( | |||
| const RegisterAddress &address) const | |||
| // 复用仓库基类的连续读取逻辑,逐字保留缓存有效性检查 | |||
| WordsReadResult PlcRegisterRepository::readWords( | |||
| const RegisterAddress &address, int count) const | |||
| { | |||
| return RegisterRepository::readWordPair(address); | |||
| return RegisterRepository::readWords(address, count); | |||
| } | |||
| RegisterWriteResult PlcRegisterRepository::writeWordPair( | |||
| // 校验连续范围后转发 D 字批量异步写请求 | |||
| RegisterWriteResult PlcRegisterRepository::writeWords( | |||
| const RegisterAddress &address, | |||
| const std::array<std::int16_t, 2> &values) | |||
| const std::vector<std::int16_t> &values) | |||
| { | |||
| const int count = static_cast<int>(values.size()); | |||
| if (!address.isValid() || address.area() != RegisterArea::D | |||
| || address.index() >= RegisterAddress::kMaximumIndex) | |||
| || count < 1 | |||
| || address.index() > RegisterAddress::kMaximumIndex - count + 1) | |||
| { | |||
| 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}; | |||
| if (count == 1) | |||
| { | |||
| // 单字写入统一走基础回调,避免通信层维护两条等价路径 | |||
| return word_handler_ ? word_handler_(address, values.front()) | |||
| : RegisterWriteResult{ | |||
| false, RegisterError::Unavailable}; | |||
| } | |||
| // 多字请求一次性交给通信服务,避免拆开 32 位或 64 位数值 | |||
| return words_handler_ ? words_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 &, const std::array<std::int16_t, 2> &)> word_pair_handler) | |||
| const RegisterAddress &, | |||
| const std::vector<std::int16_t> &)> words_handler) | |||
| { | |||
| bit_handler_ = std::move(bit_handler); | |||
| word_handler_ = std::move(word_handler); | |||
| word_pair_handler_ = std::move(word_pair_handler); | |||
| words_handler_ = std::move(words_handler); | |||
| } | |||
| // 用 PLC 成功读回的值更新 M 位缓存和有效标记 | |||
| void PlcRegisterRepository::updateBit(int address, bool value) | |||
| { | |||
| if (address < 0 || address > RegisterAddress::kMaximumIndex) | |||
| @@ -111,6 +134,7 @@ void PlcRegisterRepository::updateBit(int address, bool value) | |||
| valid_bits_[index] = true; | |||
| } | |||
| // 用 PLC 成功读回的值更新 D 字缓存和有效标记 | |||
| void PlcRegisterRepository::updateWord(int address, std::int16_t value) | |||
| { | |||
| if (address < 0 || address > RegisterAddress::kMaximumIndex) | |||
| @@ -122,15 +146,9 @@ void PlcRegisterRepository::updateWord(int address, std::int16_t value) | |||
| valid_words_[index] = true; | |||
| } | |||
| // 清除当前通信会话的全部缓存有效标记 | |||
| void PlcRegisterRepository::invalidate() | |||
| { | |||
| valid_bits_.fill(false); | |||
| valid_words_.fill(false); | |||
| } | |||
| bool PlcRegisterRepository::hasAnyValidValue() const | |||
| { | |||
| return std::any_of(valid_bits_.cbegin(), valid_bits_.cend(), [](bool value) { return value; }) | |||
| || std::any_of( | |||
| valid_words_.cbegin(), valid_words_.cend(), [](bool value) { return value; }); | |||
| } | |||
| @@ -2,8 +2,8 @@ | |||
| #include "domain/register_repository.h" | |||
| #include <array> | |||
| #include <functional> | |||
| #include <vector> | |||
| // 保存 PLC 最近一次成功读回的 M/D 缓存,并把写请求转交给通信服务 | |||
| // 未读到过的地址保持 Unavailable,避免界面显示伪造的初始值 | |||
| @@ -12,29 +12,77 @@ class PlcRegisterRepository final : public RegisterRepository | |||
| public: | |||
| PlcRegisterRepository(); | |||
| /** | |||
| * @brief 读取 PLC 最近一次成功读回的 M 位缓存 | |||
| * @param address 要读取的 M 区地址 | |||
| * @return 位读取结果,未成功读回过该地址时状态为不可用 | |||
| */ | |||
| BitReadResult readBit(const RegisterAddress &address) const override; | |||
| /** | |||
| * @brief 将单个 M 位写请求转交给异步通信回调 | |||
| * @param address 要写入的 M 区地址 | |||
| * @param value 要写入的位值 | |||
| * @return 写请求提交结果,成功不代表缓存已经更新 | |||
| */ | |||
| RegisterWriteResult writeBit(const RegisterAddress &address, bool value) override; | |||
| /** | |||
| * @brief 读取 PLC 最近一次成功读回的 D 字缓存 | |||
| * @param address 要读取的 D 区地址 | |||
| * @return 字读取结果,未成功读回过该地址时状态为不可用 | |||
| */ | |||
| WordReadResult readWord(const RegisterAddress &address) const override; | |||
| /** | |||
| * @brief 将单个 D 字写请求转交给异步通信回调 | |||
| * @param address 要写入的 D 区地址 | |||
| * @param value 要写入的 16 位字值 | |||
| * @return 写请求提交结果,成功不代表缓存已经更新 | |||
| */ | |||
| RegisterWriteResult writeWord( | |||
| const RegisterAddress &address, std::int16_t value) override; | |||
| WordPairReadResult readWordPair(const RegisterAddress &address) const override; | |||
| RegisterWriteResult writeWordPair( | |||
| /** | |||
| * @brief 连续读取多个已成功读回的 D 字缓存 | |||
| * @param address 连续读取的起始 D 区地址 | |||
| * @param count 要读取的连续字数量 | |||
| * @return 连续字读取结果,任一地址不可用时返回失败状态 | |||
| */ | |||
| WordsReadResult readWords( | |||
| const RegisterAddress &address, int count) const override; | |||
| /** | |||
| * @brief 将连续 D 字写请求转交给异步通信回调 | |||
| * @param address 连续写入的起始 D 区地址 | |||
| * @param values 按地址递增顺序写入的 16 位字值 | |||
| * @return 写请求提交结果,成功不代表缓存已经更新 | |||
| */ | |||
| RegisterWriteResult writeWords( | |||
| const RegisterAddress &address, | |||
| const std::array<std::int16_t, 2> &values) override; | |||
| const std::vector<std::int16_t> &values) override; | |||
| // 注入异步写入回调;回调成功不代表缓存已经更新 | |||
| /** | |||
| * @brief 注入 M 位、单个 D 字和连续 D 字的异步写入回调 | |||
| * @param bit_handler M 位写入回调 | |||
| * @param word_handler 单个 D 字写入回调 | |||
| * @param words_handler 连续 D 字写入回调,可为空 | |||
| */ | |||
| 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 &, const std::array<std::int16_t, 2> &)> word_pair_handler = {}); | |||
| // 由通信服务在读回成功后更新缓存并标记地址有效 | |||
| const RegisterAddress &, | |||
| const std::vector<std::int16_t> &)> words_handler = {}); | |||
| /** | |||
| * @brief 由通信服务在读回成功后更新 M 位缓存并标记地址有效 | |||
| * @param address M 区原始地址 | |||
| * @param value PLC 读回的位值 | |||
| */ | |||
| void updateBit(int address, bool value); | |||
| /** | |||
| * @brief 由通信服务在读回成功后更新 D 字缓存并标记地址有效 | |||
| * @param address D 区原始地址 | |||
| * @param value PLC 读回的 16 位字值 | |||
| */ | |||
| void updateWord(int address, std::int16_t value); | |||
| // 通信会话失效时清除“已读”标记,但保留数组内的旧值用于诊断 | |||
| void invalidate(); | |||
| // 判断是否至少收到过一个有效 M/D 值 | |||
| bool hasAnyValidValue() const; | |||
| private: | |||
| // M/D 区共用的数组长度,覆盖 0 到最大地址 | |||
| @@ -46,6 +94,7 @@ private: | |||
| 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 区异步写入回调 | |||
| // D 区连续多字异步写入回调 | |||
| std::function<RegisterWriteResult( | |||
| const RegisterAddress &, const std::array<std::int16_t, 2> &)> word_pair_handler_; | |||
| const RegisterAddress &, const std::vector<std::int16_t> &)> words_handler_; | |||
| }; | |||
| @@ -0,0 +1,249 @@ | |||
| /** | |||
| * @file runtime_project_bundle.cpp | |||
| * @brief 实现用户运行程序工程数据封装服务 | |||
| * @version 1.0.0 | |||
| * @author suyu | |||
| * @date 2026-08-26 | |||
| */ | |||
| #include "runtime_project_bundle.h" | |||
| #include <QCryptographicHash> | |||
| #include <QFile> | |||
| #include <QFileInfo> | |||
| #include <QObject> | |||
| #include <limits> | |||
| namespace { | |||
| // 尾部固定保存 16 字节魔数、4 字节版本、8 字节长度和 32 字节哈希 | |||
| constexpr int kMagicSize = 16; | |||
| constexpr int kFooterSize = kMagicSize + 4 + 8 + 32; | |||
| constexpr quint32 kFormatVersion = 1; | |||
| constexpr qint64 kCopyBufferSize = 64 * 1024; | |||
| const QByteArray kMagic = QByteArrayLiteral("QTPROX_RUNTIME_B"); | |||
| // 编译期锁定魔数长度,避免修改文本后破坏尾部字段偏移 | |||
| static_assert(sizeof("QTPROX_RUNTIME_B") - 1 == kMagicSize, | |||
| "runtime bundle magic must have a fixed length"); | |||
| // 按固定小端格式向尾部追加 32 位无符号整数 | |||
| void appendUint32LittleEndian(QByteArray *bytes, quint32 value) | |||
| { | |||
| for (int shift = 0; shift < 32; shift += 8) | |||
| { | |||
| bytes->append(static_cast<char>((value >> shift) & 0xff)); | |||
| } | |||
| } | |||
| // 按固定小端格式向尾部追加 64 位无符号整数 | |||
| void appendUint64LittleEndian(QByteArray *bytes, quint64 value) | |||
| { | |||
| for (int shift = 0; shift < 64; shift += 8) | |||
| { | |||
| bytes->append(static_cast<char>((value >> shift) & 0xff)); | |||
| } | |||
| } | |||
| // 从尾部的小端字节序读取 32 位无符号整数 | |||
| quint32 readUint32LittleEndian(const char *bytes) | |||
| { | |||
| quint32 value = 0; | |||
| for (int shift = 0; shift < 32; shift += 8) | |||
| { | |||
| value |= static_cast<quint32>( | |||
| static_cast<unsigned char>(bytes[shift / 8])) | |||
| << shift; | |||
| } | |||
| return value; | |||
| } | |||
| // 从尾部的小端字节序读取 64 位无符号整数 | |||
| quint64 readUint64LittleEndian(const char *bytes) | |||
| { | |||
| quint64 value = 0; | |||
| for (int shift = 0; shift < 64; shift += 8) | |||
| { | |||
| value |= static_cast<quint64>( | |||
| static_cast<unsigned char>(bytes[shift / 8])) | |||
| << shift; | |||
| } | |||
| return value; | |||
| } | |||
| // 组装用于定位和校验工程数据的固定长度尾部 | |||
| QByteArray makeFooter(quint64 project_size, const QByteArray &hash) | |||
| { | |||
| QByteArray footer; | |||
| footer.reserve(kFooterSize); | |||
| footer.append(kMagic); | |||
| appendUint32LittleEndian(&footer, kFormatVersion); | |||
| appendUint64LittleEndian(&footer, project_size); | |||
| footer.append(hash); | |||
| return footer; | |||
| } | |||
| // 构造带错误原因的无效封装结果 | |||
| RuntimeProjectBundleLoadResult invalidBundle(const QString &message) | |||
| { | |||
| return {RuntimeProjectBundleStatus::Invalid, {}, message}; | |||
| } | |||
| } // namespace | |||
| // 将模板和工程数据写入临时文件,全部成功后再生成目标运行程序 | |||
| RuntimeProjectBundleWriteResult RuntimeProjectBundleService::write( | |||
| const QString &template_executable, | |||
| const QString &project_file, | |||
| const QString &destination_executable) | |||
| { | |||
| if (!QFileInfo::exists(template_executable)) | |||
| { | |||
| return {false, | |||
| QObject::tr("未找到用户运行程序模板:%1") | |||
| .arg(template_executable)}; | |||
| } | |||
| if (!QFileInfo::exists(project_file)) | |||
| { | |||
| return {false, | |||
| QObject::tr("未找到待封装的工程文件:%1").arg(project_file)}; | |||
| } | |||
| if (QFileInfo::exists(destination_executable)) | |||
| { | |||
| return {false, | |||
| QObject::tr("导出 exe 已经存在:%1") | |||
| .arg(destination_executable)}; | |||
| } | |||
| const QString temporary_executable = destination_executable | |||
| + QStringLiteral(".runtime-bundle-tmp"); | |||
| // 所有追加操作都落在临时文件,失败时不会留下可被误用的目标 exe | |||
| QFile::remove(temporary_executable); | |||
| if (!QFile::copy(template_executable, temporary_executable)) | |||
| { | |||
| return {false, | |||
| QObject::tr("复制用户运行程序模板失败:%1") | |||
| .arg(template_executable)}; | |||
| } | |||
| QFile output(temporary_executable); | |||
| QFile project(project_file); | |||
| if (!output.open(QIODevice::ReadWrite | QIODevice::Append) | |||
| || !project.open(QIODevice::ReadOnly)) | |||
| { | |||
| output.close(); | |||
| project.close(); | |||
| QFile::remove(temporary_executable); | |||
| return {false, QObject::tr("无法打开用户运行程序封装文件")}; | |||
| } | |||
| QCryptographicHash hash(QCryptographicHash::Sha256); | |||
| quint64 project_size = 0; | |||
| bool succeeded = true; | |||
| // 分块复制可限制大工程的内存占用,并同步计算封装内容的 SHA-256 | |||
| while (!project.atEnd()) | |||
| { | |||
| const QByteArray chunk = project.read(kCopyBufferSize); | |||
| if (chunk.isEmpty() && project.error() != QFileDevice::NoError) | |||
| { | |||
| succeeded = false; | |||
| break; | |||
| } | |||
| if (chunk.isEmpty()) | |||
| { | |||
| break; | |||
| } | |||
| if (output.write(chunk) != chunk.size()) | |||
| { | |||
| succeeded = false; | |||
| break; | |||
| } | |||
| hash.addData(chunk); | |||
| project_size += static_cast<quint64>(chunk.size()); | |||
| } | |||
| if (succeeded) | |||
| { | |||
| const QByteArray footer = makeFooter(project_size, hash.result()); | |||
| succeeded = output.write(footer) == footer.size(); | |||
| } | |||
| output.flush(); | |||
| output.close(); | |||
| project.close(); | |||
| if (!succeeded || !QFile::rename(temporary_executable, destination_executable)) | |||
| { | |||
| QFile::remove(temporary_executable); | |||
| return {false, QObject::tr("写入用户运行程序工程数据失败")}; | |||
| } | |||
| return {true, {}}; | |||
| } | |||
| // 从可执行文件尾部定位工程数据并依次校验格式、长度和哈希 | |||
| RuntimeProjectBundleLoadResult RuntimeProjectBundleService::load( | |||
| const QString &executable_path) | |||
| { | |||
| QFile executable(executable_path); | |||
| if (!executable.open(QIODevice::ReadOnly)) | |||
| { | |||
| return invalidBundle( | |||
| QObject::tr("无法读取用户运行程序:%1").arg(executable_path)); | |||
| } | |||
| const qint64 executable_size = executable.size(); | |||
| if (executable_size < kFooterSize) | |||
| { | |||
| return {RuntimeProjectBundleStatus::NotFound, {}, {}}; | |||
| } | |||
| if (!executable.seek(executable_size - kFooterSize)) | |||
| { | |||
| return invalidBundle(QObject::tr("无法定位用户运行程序工程数据")); | |||
| } | |||
| const QByteArray footer = executable.read(kFooterSize); | |||
| if (footer.size() != kFooterSize) | |||
| { | |||
| return invalidBundle(QObject::tr("用户运行程序工程数据不完整")); | |||
| } | |||
| if (footer.left(kMagicSize) != kMagic) | |||
| { | |||
| return {RuntimeProjectBundleStatus::NotFound, {}, {}}; | |||
| } | |||
| // 尾部字段使用固定偏移,读取结果不依赖当前 CPU 的字节序 | |||
| const quint32 version = readUint32LittleEndian(footer.constData() + kMagicSize); | |||
| const quint64 project_size = readUint64LittleEndian( | |||
| footer.constData() + kMagicSize + 4); | |||
| if (version != kFormatVersion) | |||
| { | |||
| return invalidBundle(QObject::tr("不支持的用户运行程序工程数据版本")); | |||
| } | |||
| // 先验证长度边界再转换为 qint64,避免损坏数据触发整数溢出 | |||
| if (project_size > static_cast<quint64>( | |||
| executable_size - static_cast<qint64>(kFooterSize)) | |||
| || project_size > static_cast<quint64>(std::numeric_limits<qint64>::max())) | |||
| { | |||
| return invalidBundle(QObject::tr("用户运行程序工程数据长度无效")); | |||
| } | |||
| const qint64 project_offset = executable_size - kFooterSize | |||
| - static_cast<qint64>(project_size); | |||
| if (!executable.seek(project_offset)) | |||
| { | |||
| return invalidBundle(QObject::tr("无法读取用户运行程序工程数据")); | |||
| } | |||
| const QByteArray project_data = executable.read( | |||
| static_cast<qint64>(project_size)); | |||
| if (project_data.size() != static_cast<qint64>(project_size)) | |||
| { | |||
| return invalidBundle(QObject::tr("用户运行程序工程数据不完整")); | |||
| } | |||
| const QByteArray expected_hash = footer.right(32); | |||
| // 哈希不一致说明工程数据被截断或修改,不能交给工程解析器 | |||
| if (QCryptographicHash::hash(project_data, QCryptographicHash::Sha256) | |||
| != expected_hash) | |||
| { | |||
| return invalidBundle(QObject::tr("用户运行程序工程数据校验失败")); | |||
| } | |||
| return {RuntimeProjectBundleStatus::Loaded, project_data, {}}; | |||
| } | |||
| @@ -0,0 +1,68 @@ | |||
| /** | |||
| * @file runtime_project_bundle.h | |||
| * @brief 定义用户运行程序工程数据封装服务 | |||
| * @version 1.0.0 | |||
| * @author suyu | |||
| * @date 2026-08-26 | |||
| */ | |||
| #pragma once | |||
| #include <QByteArray> | |||
| #include <QString> | |||
| /** 用户运行程序工程数据的读取状态 */ | |||
| enum class RuntimeProjectBundleStatus | |||
| { | |||
| // 可执行文件中没有运行工程封装 | |||
| NotFound, | |||
| // 工程数据已通过格式和哈希校验 | |||
| Loaded, | |||
| // 找到了封装痕迹但内容无效 | |||
| Invalid | |||
| }; | |||
| /** 从用户运行程序中读取的工程数据 */ | |||
| struct RuntimeProjectBundleLoadResult | |||
| { | |||
| // 本次读取的最终状态 | |||
| RuntimeProjectBundleStatus status = RuntimeProjectBundleStatus::NotFound; | |||
| // 校验通过后的原始工程 JSON 数据 | |||
| QByteArray project_data; | |||
| // 封装无效时供界面显示的错误信息 | |||
| QString message; | |||
| }; | |||
| /** 创建用户运行程序的结果 */ | |||
| struct RuntimeProjectBundleWriteResult | |||
| { | |||
| // 是否成功生成带工程数据的运行程序 | |||
| bool succeeded = false; | |||
| // 写入失败时供界面显示的错误信息 | |||
| QString message; | |||
| }; | |||
| /** 将工程数据追加到已编译运行程序并负责校验读取 */ | |||
| class RuntimeProjectBundleService final | |||
| { | |||
| public: | |||
| /** | |||
| * @brief 复制运行程序模板,并将运行版工程 JSON 封装到目标 exe 末尾 | |||
| * @param template_executable 已编译运行程序模板的完整路径,文件必须存在且可读 | |||
| * @param project_file 要封装的运行版工程 JSON 完整路径,文件必须存在且可读 | |||
| * @param destination_executable 最终生成的用户运行程序完整路径,目标文件不能已存在 | |||
| * @return 封装结果,失败时 message 包含可直接显示的错误原因 | |||
| */ | |||
| static RuntimeProjectBundleWriteResult write( | |||
| const QString &template_executable, | |||
| const QString &project_file, | |||
| const QString &destination_executable); | |||
| /** | |||
| * @brief 从指定 exe 末尾读取运行版工程 JSON,并校验封装版本、长度和哈希 | |||
| * @param executable_path 要读取的用户运行程序完整路径,文件必须存在且可读 | |||
| * @return 读取结果,区分未封装、加载成功和封装无效三种状态 | |||
| */ | |||
| static RuntimeProjectBundleLoadResult load( | |||
| const QString &executable_path); | |||
| }; | |||
| @@ -0,0 +1,434 @@ | |||
| /** | |||
| * @file runtime_settings_loader.cpp | |||
| * @brief 实现 HMI 专用运行版的串口配置读写服务 | |||
| * @version 1.0.0 | |||
| * @author suyu | |||
| * @date 2026-08-28 | |||
| */ | |||
| #include "runtime_settings_loader.h" | |||
| #include <QCoreApplication> | |||
| #include <QDir> | |||
| #include <QFile> | |||
| #include <QFileInfo> | |||
| #include <QSaveFile> | |||
| #include <QSet> | |||
| #include <QStringList> | |||
| #include <QTextCodec> | |||
| #include <QMap> | |||
| #include <initializer_list> | |||
| namespace { | |||
| struct ParsedEntry | |||
| { | |||
| // 去除首尾空白后的字段值 | |||
| QString value; | |||
| // 字段在配置文件中的行号,用于给出明确错误位置 | |||
| int line = 0; | |||
| }; | |||
| // 按运行版支持的最小字段集合生成 UTF-8 INI 内容 | |||
| QString defaultFileContents(const PlcSerialConfiguration &configuration) | |||
| { | |||
| return QStringLiteral( | |||
| "[PlcDefaults]\n" | |||
| "PortName=%1\n" | |||
| "ServerAddress=%2\n" | |||
| "BaudRate=%3\n" | |||
| "DataBits=%4\n" | |||
| "Parity=%5\n" | |||
| "StopBits=%6\n") | |||
| .arg(QString::fromUtf8(configuration.portName.c_str())) | |||
| .arg(configuration.serverAddress) | |||
| .arg(configuration.baudRate) | |||
| .arg(configuration.dataBits) | |||
| .arg(configuration.parity) | |||
| .arg(configuration.stopBits); | |||
| } | |||
| // 返回运行版串口配置的代码默认值 | |||
| PlcSerialConfiguration defaultConfiguration() | |||
| { | |||
| PlcSerialConfiguration configuration; | |||
| configuration.portName = "COM3"; | |||
| return configuration; | |||
| } | |||
| // 将 Qt 字符串完整转换为 UTF-8 标准字符串 | |||
| std::string toUtf8(const QString &value) | |||
| { | |||
| const QByteArray bytes = value.toUtf8(); | |||
| return std::string(bytes.constData(), static_cast<std::size_t>(bytes.size())); | |||
| } | |||
| // 将一条 UTF-8 诊断信息加入加载结果 | |||
| void addMessage(RuntimeSettingsLoadResult *result, const QString &message) | |||
| { | |||
| result->messages.push_back(toUtf8(message)); | |||
| } | |||
| // 构造整份配置失效并回退代码默认值的加载结果 | |||
| RuntimeSettingsLoadResult failureResult( | |||
| const QString &file_path, const QString &reason) | |||
| { | |||
| RuntimeSettingsLoadResult result; | |||
| result.plcDefaults = defaultConfiguration(); | |||
| result.warningRequired = true; | |||
| result.warningMessage = toUtf8( | |||
| QStringLiteral("运行版串口配置无法使用,本次采用代码默认值。\n%1") | |||
| .arg(reason)); | |||
| addMessage( | |||
| &result, | |||
| QStringLiteral("运行版串口配置错误:%1(%2)") | |||
| .arg(reason, QDir::toNativeSeparators(file_path))); | |||
| return result; | |||
| } | |||
| // 判断整数是否属于业务允许的离散取值 | |||
| bool containsValue(std::initializer_list<int> values, int value) | |||
| { | |||
| for (const 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 *errors, | |||
| QStringList *notices) | |||
| { | |||
| const auto entry = entries.constFind(path); | |||
| if (entry == entries.cend()) | |||
| { | |||
| *value = default_value; | |||
| notices->append( | |||
| QStringLiteral("配置字段 %1 缺失,使用代码默认值 %2") | |||
| .arg(path) | |||
| .arg(default_value)); | |||
| return true; | |||
| } | |||
| 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 | |||
| // 返回当前运行程序同目录下固定的 runtime.ini 路径 | |||
| QString RuntimeSettingsLoader::defaultFilePath() | |||
| { | |||
| return QDir(QCoreApplication::applicationDirPath()).filePath( | |||
| QStringLiteral("config/runtime.ini")); | |||
| } | |||
| // 加载运行版串口配置,文件缺失时先创建默认配置 | |||
| RuntimeSettingsLoadResult RuntimeSettingsLoader::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))); | |||
| } | |||
| PlcSerialConfiguration configuration = defaultConfiguration(); | |||
| // QSaveFile 只有 commit 成功才替换目标文件,避免留下半份默认配置 | |||
| QSaveFile file(file_path); | |||
| const QByteArray data = defaultFileContents(configuration).toUtf8(); | |||
| if (!file.open(QIODevice::WriteOnly) | |||
| || file.write(data) != data.size() | |||
| || !file.commit()) | |||
| { | |||
| return failureResult(file_path, QStringLiteral("无法创建默认串口配置文件")); | |||
| } | |||
| RuntimeSettingsLoadResult result; | |||
| result.plcDefaults = configuration; | |||
| addMessage( | |||
| &result, | |||
| QStringLiteral("运行版串口配置:已创建默认配置文件 %1") | |||
| .arg(QDir::toNativeSeparators(file_path))); | |||
| 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()); | |||
| } | |||
| // ConverterState 用于严格识别非法 UTF-8,防止乱码字段被当成有效配置 | |||
| QTextCodec::ConverterState converter_state; | |||
| 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 文本")); | |||
| } | |||
| if (!text.isEmpty() && text.front() == QChar::ByteOrderMark) | |||
| { | |||
| text.remove(0, 1); | |||
| } | |||
| const QSet<QString> known_fields{ | |||
| 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; | |||
| // 手动解析可以同时识别重复字段、未知字段和准确行号 | |||
| const QStringList lines = 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(); | |||
| 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(); | |||
| 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)); | |||
| } | |||
| } | |||
| RuntimeSettingsLoadResult result; | |||
| result.plcDefaults = defaultConfiguration(); | |||
| const auto port_entry = entries.constFind(QStringLiteral("PlcDefaults.PortName")); | |||
| if (port_entry == entries.cend()) | |||
| { | |||
| notices.append(QStringLiteral("配置字段 PlcDefaults.PortName 缺失,使用代码默认值 COM3")); | |||
| } | |||
| else | |||
| { | |||
| const QByteArray port_bytes = port_entry->value.toUtf8(); | |||
| if (port_entry->value.trimmed().isEmpty() || port_bytes.size() > 128) | |||
| { | |||
| errors.append( | |||
| QStringLiteral("第 %1 行字段 PlcDefaults.PortName 无效,端口名称不能为空且不能超过 128 个 UTF-8 字节") | |||
| .arg(port_entry->line)); | |||
| } | |||
| else | |||
| { | |||
| result.plcDefaults.portName = toUtf8(port_entry->value.trimmed()); | |||
| } | |||
| } | |||
| qint64 parsed = 0; | |||
| if (readInteger(entries, QStringLiteral("PlcDefaults.ServerAddress"), 1, 247, | |||
| result.plcDefaults.serverAddress, &parsed, &errors, ¬ices)) | |||
| { | |||
| result.plcDefaults.serverAddress = static_cast<int>(parsed); | |||
| } | |||
| int baud_rate = result.plcDefaults.baudRate; | |||
| if (readInteger(entries, QStringLiteral("PlcDefaults.BaudRate"), 9600, 115200, | |||
| result.plcDefaults.baudRate, &parsed, &errors, ¬ices)) | |||
| { | |||
| 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 | |||
| { | |||
| result.plcDefaults.baudRate = baud_rate; | |||
| } | |||
| } | |||
| if (readInteger(entries, QStringLiteral("PlcDefaults.DataBits"), 7, 8, | |||
| result.plcDefaults.dataBits, &parsed, &errors, ¬ices)) | |||
| { | |||
| result.plcDefaults.dataBits = static_cast<int>(parsed); | |||
| } | |||
| int parity = result.plcDefaults.parity; | |||
| if (readInteger(entries, QStringLiteral("PlcDefaults.Parity"), 0, 3, | |||
| result.plcDefaults.parity, &parsed, &errors, ¬ices)) | |||
| { | |||
| parity = static_cast<int>(parsed); | |||
| if (!containsValue({0, 2, 3}, parity)) | |||
| { | |||
| addUnsupportedValueError(entries, QStringLiteral("PlcDefaults.Parity"), | |||
| QStringLiteral("0、2 或 3"), &errors); | |||
| } | |||
| else | |||
| { | |||
| result.plcDefaults.parity = parity; | |||
| } | |||
| } | |||
| if (readInteger(entries, QStringLiteral("PlcDefaults.StopBits"), 1, 2, | |||
| result.plcDefaults.stopBits, &parsed, &errors, ¬ices)) | |||
| { | |||
| result.plcDefaults.stopBits = static_cast<int>(parsed); | |||
| } | |||
| const PlcCommunicationResult configuration_result = | |||
| validatePlcSerialConfiguration(result.plcDefaults); | |||
| if (!configuration_result.succeeded) | |||
| { | |||
| errors.append(QString::fromUtf8(configuration_result.message.c_str())); | |||
| } | |||
| if (!errors.isEmpty()) | |||
| { | |||
| // 任一字段有错时整份回退,避免混用部分用户值和部分默认值 | |||
| result.plcDefaults = defaultConfiguration(); | |||
| result.warningRequired = true; | |||
| result.warningMessage = toUtf8( | |||
| QStringLiteral("运行版串口配置存在错误,本次采用代码默认值")); | |||
| for (const QString &error : errors) | |||
| { | |||
| addMessage(&result, QStringLiteral("运行版串口配置错误:") + error); | |||
| } | |||
| for (const QString ¬ice : notices) | |||
| { | |||
| addMessage(&result, QStringLiteral("运行版串口配置提示:") + notice); | |||
| } | |||
| return result; | |||
| } | |||
| addMessage( | |||
| &result, | |||
| QStringLiteral("运行版串口配置:已加载 %1") | |||
| .arg(QDir::toNativeSeparators(file_path))); | |||
| for (const QString ¬ice : notices) | |||
| { | |||
| addMessage(&result, QStringLiteral("运行版串口配置提示:") + notice); | |||
| } | |||
| return result; | |||
| } | |||
| // 校验并原子写入运行版串口配置 | |||
| bool RuntimeSettingsLoader::write( | |||
| const QString &file_path, | |||
| const PlcSerialConfiguration &configuration, | |||
| std::string *error) | |||
| { | |||
| const PlcCommunicationResult validation = | |||
| validatePlcSerialConfiguration(configuration); | |||
| if (!validation.succeeded) | |||
| { | |||
| if (error != nullptr) | |||
| { | |||
| *error = validation.message; | |||
| } | |||
| return false; | |||
| } | |||
| const QString directory = QFileInfo(file_path).absolutePath(); | |||
| if (!QDir().mkpath(directory)) | |||
| { | |||
| if (error != nullptr) | |||
| { | |||
| *error = toUtf8(QStringLiteral("无法创建运行版配置目录:%1") | |||
| .arg(QDir::toNativeSeparators(directory))); | |||
| } | |||
| return false; | |||
| } | |||
| // QSaveFile 提交失败时保留原文件,运行版不会读到未写完的配置 | |||
| QSaveFile file(file_path); | |||
| const QByteArray data = defaultFileContents(configuration).toUtf8(); | |||
| if (!file.open(QIODevice::WriteOnly) | |||
| || file.write(data) != data.size() | |||
| || !file.commit()) | |||
| { | |||
| if (error != nullptr) | |||
| { | |||
| *error = toUtf8(QStringLiteral("无法写入运行版串口配置:%1") | |||
| .arg(QDir::toNativeSeparators(file_path))); | |||
| } | |||
| return false; | |||
| } | |||
| return true; | |||
| } | |||
| @@ -0,0 +1,54 @@ | |||
| /** | |||
| * @file runtime_settings_loader.h | |||
| * @brief 定义 HMI 专用运行版的串口配置读写服务 | |||
| * @version 1.0.0 | |||
| * @author suyu | |||
| * @date 2026-08-28 | |||
| */ | |||
| #pragma once | |||
| #include "services/plc_communication_gateway.h" | |||
| #include <QString> | |||
| #include <string> | |||
| #include <vector> | |||
| /** HMI 专用运行版的串口配置加载结果 */ | |||
| struct RuntimeSettingsLoadResult | |||
| { | |||
| // 本次启动最终采用的 PLC 串口参数 | |||
| PlcSerialConfiguration plcDefaults; | |||
| // 显示到底部输出面板的加载过程和错误信息 | |||
| std::vector<std::string> messages; | |||
| // 是否需要向用户弹出一次配置警告 | |||
| bool warningRequired = false; | |||
| // 配置失效时向用户显示的警告内容 | |||
| std::string warningMessage; | |||
| }; | |||
| /** HMI 专用运行版只读取和写入 [PlcDefaults] */ | |||
| class RuntimeSettingsLoader final | |||
| { | |||
| public: | |||
| /** 返回导出运行版目录下的串口配置路径 */ | |||
| static QString defaultFilePath(); | |||
| /** | |||
| * @brief 读取只包含 PLC 串口字段的 UTF-8 INI | |||
| * @param file_path 运行版串口配置文件的完整路径 | |||
| * @return 配置加载结果,包含实际采用的参数、过程消息和警告信息 | |||
| */ | |||
| static RuntimeSettingsLoadResult load(const QString &file_path); | |||
| /** | |||
| * @brief 以原子方式写入导出运行版所需的最小 INI | |||
| * @param file_path 目标配置文件的完整路径 | |||
| * @param configuration 要写入的 PLC 串口参数 | |||
| * @param error 可选的错误信息输出,失败时写入可直接显示的原因 | |||
| * @return 写入成功返回 true,否则返回 false | |||
| */ | |||
| static bool write( | |||
| const QString &file_path, | |||
| const PlcSerialConfiguration &configuration, | |||
| std::string *error = nullptr); | |||
| }; | |||
| @@ -14,9 +14,11 @@ | |||
| #include "infrastructure/json_project_storage.h" | |||
| #include "infrastructure/application_settings_loader.h" | |||
| #include "infrastructure/runtime_settings_loader.h" | |||
| #include "infrastructure/plc_communication_service.h" | |||
| #include "infrastructure/plc_discovery_service.h" | |||
| #include "infrastructure/plc_register_repository.h" | |||
| #include "infrastructure/runtime_project_bundle.h" | |||
| #include "domain/active_register_repository.h" | |||
| #include "domain/virtual_register_repository.h" | |||
| #include "services/hmi_editor_service.h" | |||
| @@ -33,7 +35,6 @@ | |||
| #include "services/register_comment_service.h" | |||
| #include "ui/main_window.h" | |||
| // 程序从这里开始,argc 和 argv 保存用户启动程序时附带的命令行参数 | |||
| int main(int argc, char *argv[]) | |||
| { | |||
| // Windows 默认会给对话框添加上下文帮助按钮,应用内统一隐藏 | |||
| @@ -45,68 +46,73 @@ int main(int argc, char *argv[]) | |||
| // 设置组织名称,用来区分这个程序保存的 Qt 配置 | |||
| application.setOrganizationName(QStringLiteral("QtProXinJe")); | |||
| 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)) | |||
| // 读取当前 EXE 尾部的封装工程,并校验封装版本、长度和哈希 | |||
| const RuntimeProjectBundleLoadResult bundled_project_result = | |||
| RuntimeProjectBundleService::load(executable_info.absoluteFilePath()); | |||
| // 检测到封装标记但内容损坏时直接退出,避免带着不完整工程继续运行 | |||
| if (bundled_project_result.status == RuntimeProjectBundleStatus::Invalid) | |||
| { | |||
| runtime_project_path = bundled_project; | |||
| QMessageBox::critical( | |||
| nullptr, | |||
| QObject::tr("运行程序启动失败"), | |||
| bundled_project_result.message); | |||
| return 2; | |||
| } | |||
| const bool embedded_runtime_project = QFile::exists( | |||
| QStringLiteral(":/runtime-project.json")); | |||
| const bool user_runtime_mode = !runtime_project_path.isEmpty() | |||
| || embedded_runtime_project; | |||
| const bool bundled_runtime_project = bundled_project_result.status | |||
| == RuntimeProjectBundleStatus::Loaded; | |||
| // 当前只有成功加载封装工程的程序才进入隐藏编辑功能的用户运行模式 | |||
| const bool user_runtime_mode = bundled_runtime_project; | |||
| const ApplicationSettingsLoadResult application_settings = | |||
| ApplicationSettingsLoader::load( | |||
| ApplicationSettingsLoadResult application_settings; | |||
| // 用户运行版只读取随程序发布的串口配置,不读取编程器的常规应用配置 | |||
| if (user_runtime_mode) | |||
| { | |||
| // 用户运行版从同目录 config/runtime.ini 读取自动连接 PLC 的串口参数 | |||
| const RuntimeSettingsLoadResult runtime_settings = | |||
| RuntimeSettingsLoader::load(RuntimeSettingsLoader::defaultFilePath()); | |||
| application_settings.settings.plcDefaults = runtime_settings.plcDefaults; | |||
| application_settings.messages = runtime_settings.messages; | |||
| application_settings.warningRequired = runtime_settings.warningRequired; | |||
| application_settings.warningMessage = runtime_settings.warningMessage; | |||
| } | |||
| else | |||
| { | |||
| application_settings = ApplicationSettingsLoader::load( | |||
| ApplicationSettingsLoader::defaultFilePath()); | |||
| } | |||
| // 负责把工程保存到 JSON 文件,也负责从 JSON 文件读取工程 | |||
| JsonProjectStorage project_storage(application_settings.settings.projectLimits); | |||
| // 统一管理新建、打开、保存和校验工程,实际读写文件交给上面的对象 | |||
| ProjectService project_service( | |||
| project_storage, application_settings.settings.projectLimits); | |||
| // 工程存储接口按文件加载,因此先把可执行文件内的工程数据写入临时 JSON | |||
| if (user_runtime_mode) | |||
| { | |||
| ProjectOperationResult result; | |||
| if (embedded_runtime_project && runtime_project_path.isEmpty()) | |||
| // 进程号让同时启动的多个运行版使用不同临时文件,避免互相覆盖 | |||
| const QString temporary_path = QDir::tempPath() | |||
| + QStringLiteral("/qtproxinje-runtime-bundle-") | |||
| + QString::number(application.applicationPid()) | |||
| + QStringLiteral(".json"); | |||
| QFile temporary_file(temporary_path); | |||
| const QByteArray &bytes = bundled_project_result.project_data; | |||
| if (bytes.isEmpty() || !temporary_file.open(QIODevice::WriteOnly) | |||
| || temporary_file.write(bytes) != bytes.size()) | |||
| { | |||
| 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()); | |||
| QMessageBox::critical( | |||
| nullptr, | |||
| QObject::tr("运行程序启动失败"), | |||
| QObject::tr("无法读取封装的工程资源")); | |||
| return 2; | |||
| } | |||
| temporary_file.close(); | |||
| // 复用普通工程加载入口,将临时 JSON 校验后载入内存 | |||
| const ProjectOperationResult result = project_service.load( | |||
| temporary_path.toUtf8().toStdString()); | |||
| // 工程载入内存后立即删除临时文件,不在临时目录保留用户工程 | |||
| QFile::remove(temporary_path); | |||
| if (!result.succeeded) | |||
| { | |||
| QMessageBox::critical( | |||
| @@ -148,8 +154,11 @@ int main(int argc, char *argv[]) | |||
| RegisterCommentService register_comment_service(project_service); | |||
| // 处理自由监控列表中的寄存器读取和单点写入 | |||
| RegisterMonitorService register_monitor_service(active_register_repository); | |||
| // 编辑态离线写入同时记录为下次仿真的初始值 | |||
| register_monitor_service.setOfflineInitialRepository(offline_initial_repository); | |||
| register_monitor_service.setOfflineInitialCaptureEnabled(true); | |||
| // 编辑态选择真机监控时直接使用 PLC 读回缓存,不切换运行态的数据入口 | |||
| register_monitor_service.setEditingPlcRepository(plc_register_repository); | |||
| // 用虚拟寄存器执行本地梯形图,实现不连接 PLC 的离线仿真 | |||
| OfflineSimulationService offline_simulation_service( | |||
| virtual_register_repository, &offline_initial_repository); | |||
| @@ -157,13 +166,18 @@ int main(int argc, char *argv[]) | |||
| OnlineLogicMonitorService online_logic_monitor_service(plc_register_repository); | |||
| // 管理三种运行模式,并控制离线仿真和真机只读轨迹的生命周期 | |||
| RuntimeModeService runtime_mode_service( | |||
| project_service, offline_simulation_service, online_logic_monitor_service); | |||
| project_service, | |||
| logic_editor_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); | |||
| // 用户运行版只运行 HMI 和 PLC 通信,不启动本地梯形图扫描 | |||
| runtime_mode_service.setHmiOnlyRuntime(user_runtime_mode); | |||
| // 创建主窗口,并把界面操作需要的各项服务交给它使用 | |||
| MainWindow main_window( | |||
| @@ -180,6 +194,7 @@ int main(int argc, char *argv[]) | |||
| plc_discovery_service, | |||
| application_settings, | |||
| user_runtime_mode); | |||
| // 编程器立即显示主窗口,用户运行版等待自动连接和 PLC 首读完成后再显示 | |||
| if (!user_runtime_mode) | |||
| { | |||
| main_window.show(); | |||
| @@ -7,6 +7,7 @@ | |||
| namespace { | |||
| // 创建统一的报警编辑失败结果 | |||
| AlarmEditorResult failure(AlarmEditorError error, const std::string &message) | |||
| { | |||
| return {false, error, message, {}}; | |||
| @@ -14,16 +15,19 @@ AlarmEditorResult failure(AlarmEditorError error, const std::string &message) | |||
| } // namespace | |||
| // 保存工程服务引用,后续编辑都通过该服务修改当前工程 | |||
| AlarmEditorService::AlarmEditorService(ProjectService &project_service) | |||
| : project_service_(project_service) | |||
| { | |||
| } | |||
| // 返回当前工程持有的报警定义列表 | |||
| const std::vector<AlarmDefinition> &AlarmEditorService::definitions() const | |||
| { | |||
| return project_service_.project().alarmDefinitions; | |||
| } | |||
| // 按稳定 ID 查找报警定义,找不到时返回空指针 | |||
| const AlarmDefinition *AlarmEditorService::findDefinition( | |||
| const std::string &id) const | |||
| { | |||
| @@ -34,6 +38,7 @@ const AlarmDefinition *AlarmEditorService::findDefinition( | |||
| return definition == definitions.cend() ? nullptr : &*definition; | |||
| } | |||
| // 校验数量和定义内容后添加一条带唯一 ID 的报警定义 | |||
| AlarmEditorResult AlarmEditorService::addDefinition( | |||
| const AlarmDefinition &definition) | |||
| { | |||
| @@ -56,6 +61,7 @@ AlarmEditorResult AlarmEditorService::addDefinition( | |||
| return {true, AlarmEditorError::None, {}, candidate.id}; | |||
| } | |||
| // 校验候选定义后整体替换指定 ID 的报警定义 | |||
| AlarmEditorResult AlarmEditorService::updateDefinition( | |||
| const std::string &id, const AlarmDefinition &definition) | |||
| { | |||
| @@ -79,6 +85,7 @@ AlarmEditorResult AlarmEditorService::updateDefinition( | |||
| return {true, AlarmEditorError::None, {}, id}; | |||
| } | |||
| // 删除指定 ID 的报警定义 | |||
| AlarmEditorResult AlarmEditorService::removeDefinition(const std::string &id) | |||
| { | |||
| if (findDefinition(id) == nullptr) | |||
| @@ -86,6 +93,7 @@ AlarmEditorResult AlarmEditorService::removeDefinition(const std::string &id) | |||
| return failure(AlarmEditorError::NotFound, "未找到报警定义"); | |||
| } | |||
| Project &project = project_service_.editProject(); | |||
| // remove_if 先把目标项移到逻辑末尾,再由 erase 真正缩短容器 | |||
| project.alarmDefinitions.erase( | |||
| std::remove_if( | |||
| project.alarmDefinitions.begin(), project.alarmDefinitions.end(), | |||
| @@ -97,6 +105,7 @@ AlarmEditorResult AlarmEditorService::removeDefinition(const std::string &id) | |||
| return {true, AlarmEditorError::None, {}, id}; | |||
| } | |||
| // 从 alarm-1 开始递增查找当前工程内可用的 ID | |||
| std::string AlarmEditorService::makeUniqueId() const | |||
| { | |||
| int suffix = 1; | |||
| @@ -10,19 +10,19 @@ class ProjectService; | |||
| // 报警编辑操作的失败分类 | |||
| enum class AlarmEditorError | |||
| { | |||
| None, | |||
| NotFound, | |||
| DuplicateId, | |||
| InvalidDefinition | |||
| None, // 操作成功或没有错误 | |||
| NotFound, // 目标报警定义不存在 | |||
| DuplicateId, // 报警定义 ID 与现有定义重复 | |||
| InvalidDefinition // 报警定义内容或数量不符合规则 | |||
| }; | |||
| // 报警编辑结果;成功时 id 是新增或更新后的定义 ID | |||
| struct AlarmEditorResult | |||
| { | |||
| bool succeeded = false; | |||
| AlarmEditorError error = AlarmEditorError::None; | |||
| std::string message; | |||
| std::string id; | |||
| bool succeeded = false; // 操作是否成功 | |||
| AlarmEditorError error = AlarmEditorError::None; // 失败时的错误分类 | |||
| std::string message; // 面向用户的失败原因 | |||
| std::string id; // 新增、更新或删除的报警定义 ID | |||
| }; | |||
| // 负责报警定义的增删改,不负责读取实时寄存器 | |||
| @@ -44,7 +44,8 @@ public: | |||
| AlarmEditorResult removeDefinition(const std::string &id); | |||
| private: | |||
| // 生成当前工程内不重复的报警定义 ID | |||
| std::string makeUniqueId() const; | |||
| ProjectService &project_service_; | |||
| ProjectService &project_service_; // 非拥有的工程服务依赖 | |||
| }; | |||
| @@ -6,6 +6,7 @@ | |||
| #include <algorithm> | |||
| // 保存工程服务和当前活动寄存器仓库的引用 | |||
| AlarmService::AlarmService( | |||
| const ProjectService &project_service, | |||
| RegisterRepository &repository) | |||
| @@ -14,6 +15,7 @@ AlarmService::AlarmService( | |||
| { | |||
| } | |||
| // 根据最新寄存器值更新当前会话中的活动报警记录 | |||
| void AlarmService::refresh() | |||
| { | |||
| // 每次刷新只根据当前读回值增删记录,不会把通信失败误判为报警解除 | |||
| @@ -49,6 +51,7 @@ void AlarmService::refresh() | |||
| } | |||
| } | |||
| // 将指定活动报警记录标记为已确认 | |||
| bool AlarmService::acknowledge(const std::string &definition_id) | |||
| { | |||
| const auto record = std::find_if( | |||
| @@ -65,24 +68,33 @@ bool AlarmService::acknowledge(const std::string &definition_id) | |||
| return true; | |||
| } | |||
| // 清空当前运行会话中的全部报警记录 | |||
| void AlarmService::reset() | |||
| { | |||
| records_.clear(); | |||
| } | |||
| // 返回按触发先后维护的只读报警记录列表 | |||
| const std::vector<AlarmRecord> &AlarmService::records() const | |||
| { | |||
| return records_; | |||
| } | |||
| // 读取定义绑定的 M 位或 D 字并判断报警条件是否成立 | |||
| std::optional<bool> AlarmService::evaluate( | |||
| const AlarmDefinition &definition) const | |||
| { | |||
| if (definition.condition == AlarmCondition::MOn) | |||
| if (definition.condition == AlarmCondition::MOn | |||
| || definition.condition == AlarmCondition::MOff) | |||
| { | |||
| // MOn 报警直接读取 M 位;读取失败返回空值,交由 refresh 保留旧记录 | |||
| // M ON/OFF 共用一次位读取;读取失败时保留旧记录 | |||
| const BitReadResult value = repository_.readBit(definition.address); | |||
| return value.succeeded ? std::optional<bool>{value.value} : std::nullopt; | |||
| if (!value.succeeded) | |||
| { | |||
| return std::nullopt; | |||
| } | |||
| return definition.condition == AlarmCondition::MOn | |||
| ? value.value : !value.value; | |||
| } | |||
| // DHigh 和 DLow 报警读取 D 字,比较方向由定义中的条件决定 | |||
| const WordReadResult value = repository_.readWord(definition.address); | |||
| @@ -64,7 +64,7 @@ private: | |||
| * @param definition 待评估的报警定义 | |||
| * @return true 表示触发,false 表示未触发;读取失败时返回空值 | |||
| * | |||
| * MOn 比较 M 位是否为 1;DHigh 使用大于等于阈值,DLow 使用小于等于阈值 | |||
| * MOn/MOff 比较 M 位是否为 1/0;DHigh 使用大于等于阈值,DLow 使用小于等于阈值 | |||
| */ | |||
| std::optional<bool> evaluate(const AlarmDefinition &definition) const; | |||
| @@ -9,9 +9,10 @@ | |||
| // 应用启动后保持只读的用户配置;工程文件不会保存这些机器级偏好 | |||
| struct ApplicationSettings | |||
| { | |||
| int version = 1; | |||
| ProjectLimitSettings projectLimits; | |||
| HmiDefaultSettings hmiDefaults; | |||
| int version = 1; // 应用配置文件格式版本 | |||
| ProjectLimitSettings projectLimits; // 当前工程采用的数量上限 | |||
| HmiDefaultSettings hmiDefaults; // 新建 HMI 页面和控件的默认配置 | |||
| // PLC 串口默认值沿用通信层配置,只把端口改为项目约定的 COM3 | |||
| PlcSerialConfiguration plcDefaults = [] | |||
| { | |||
| PlcSerialConfiguration configuration; | |||
| @@ -23,8 +24,8 @@ struct ApplicationSettings | |||
| // 配置加载结果同时携带主界面启动后需要显示的诊断信息 | |||
| struct ApplicationSettingsLoadResult | |||
| { | |||
| ApplicationSettings settings; | |||
| std::vector<std::string> messages; | |||
| bool warningRequired = false; | |||
| std::string warningMessage; | |||
| ApplicationSettings settings; // 加载成功或回退后的最终应用配置 | |||
| std::vector<std::string> messages; // 可写入日志的全部诊断信息 | |||
| bool warningRequired = false; // 主界面启动后是否需要弹出警告 | |||
| std::string warningMessage; // 需要向用户显示的警告内容 | |||
| }; | |||
| @@ -8,11 +8,13 @@ | |||
| #include <cctype> | |||
| #include <cstddef> | |||
| #include <map> | |||
| #include <optional> | |||
| #include <set> | |||
| #include <utility> | |||
| namespace { | |||
| // 仅在调用方提供错误字符串时写入失败原因 | |||
| void setError(std::string *error, const std::string &message) | |||
| { | |||
| if (error != nullptr) | |||
| @@ -21,11 +23,77 @@ void setError(std::string *error, const std::string &message) | |||
| } | |||
| } | |||
| // 创建统一的 HMI 编辑失败结果 | |||
| HmiEditorResult failure(HmiEditorError error, const std::string &message) | |||
| { | |||
| return {false, error, message, {}}; | |||
| } | |||
| // 返回控件绑定值连续占用的 D 字数量 | |||
| int hmiBindingWordCount(const HmiControl &control) | |||
| { | |||
| if (control.binding.has_value() && control.binding->area() == RegisterArea::M) | |||
| { | |||
| return 1; | |||
| } | |||
| if (control.type == HmiControlType::NumericDisplay | |||
| || control.type == HmiControlType::NumericInput | |||
| || (control.type == HmiControlType::StatusText | |||
| && control.binding.has_value() | |||
| && control.binding->area() == RegisterArea::D)) | |||
| { | |||
| return registerDataTypeWordCount(control.dataType); | |||
| } | |||
| return 1; | |||
| } | |||
| // 在指定寄存器区域内查找控件可用的首个连续地址 | |||
| std::optional<RegisterAddress> nextHmiBindingAddress( | |||
| const Project &project, | |||
| const HmiControlDescriptor &descriptor, | |||
| RegisterDataType data_type) | |||
| { | |||
| std::optional<RegisterArea> area = hmiBindingArea(descriptor.bindingKind); | |||
| if (descriptor.bindingKind == HmiBindingKind::BitOrWord) | |||
| { | |||
| area = RegisterArea::M; | |||
| } | |||
| if (!area.has_value()) | |||
| { | |||
| return std::nullopt; | |||
| } | |||
| int next_index = RegisterAddress::kMinimumIndex; | |||
| for (const HmiPage &page : project.hmiPages) | |||
| { | |||
| for (const HmiControl &control : page.controls) | |||
| { | |||
| if (!control.binding.has_value() | |||
| || control.binding->area() != *area) | |||
| { | |||
| continue; | |||
| } | |||
| next_index = std::max( | |||
| next_index, | |||
| control.binding->index() + hmiBindingWordCount(control)); | |||
| } | |||
| } | |||
| for (int index = next_index; | |||
| index <= RegisterAddress::kMaximumIndex; | |||
| ++index) | |||
| { | |||
| const RegisterAddress candidate{*area, index}; | |||
| if (*area == RegisterArea::D | |||
| && !registerDataTypeAddressIsValid(data_type, candidate)) | |||
| { | |||
| continue; | |||
| } | |||
| return candidate; | |||
| } | |||
| return std::nullopt; | |||
| } | |||
| // 判断页面名称是否只包含空白字符 | |||
| bool isBlank(const std::string &value) | |||
| { | |||
| return value.empty() | |||
| @@ -34,6 +102,7 @@ bool isBlank(const std::string &value) | |||
| [](unsigned char character) { return std::isspace(character) != 0; }); | |||
| } | |||
| // 从 page-1 开始递增生成工程内唯一的页面 ID | |||
| std::string makeUniquePageId(const Project &project) | |||
| { | |||
| int suffix = 1; | |||
| @@ -51,6 +120,7 @@ std::string makeUniquePageId(const Project &project) | |||
| } | |||
| } | |||
| // 统计当前工程全部 HMI 页面中的控件总数 | |||
| std::size_t totalControlCount(const Project &project) | |||
| { | |||
| std::size_t total = 0U; | |||
| @@ -63,6 +133,7 @@ std::size_t totalControlCount(const Project &project) | |||
| } // namespace | |||
| // 保存工程服务和 HMI 默认配置的引用 | |||
| HmiEditorService::HmiEditorService( | |||
| ProjectService &project_service, | |||
| const HmiDefaultSettings &hmi_defaults) | |||
| @@ -71,18 +142,21 @@ HmiEditorService::HmiEditorService( | |||
| { | |||
| } | |||
| // 深拷贝当前页面集合和初始页面 ID 作为历史快照 | |||
| HmiEditorService::HistoryState HmiEditorService::captureState() const | |||
| { | |||
| const Project &project = project_service_.project(); | |||
| return {project.hmiPages, project.initialHmiPageId}; | |||
| } | |||
| // 仅在编辑确实改变工程状态时记录撤销快照 | |||
| void HmiEditorService::recordHistory(HistoryState before) | |||
| { | |||
| const HistoryState after = captureState(); | |||
| history_.record(std::move(before), after, &HmiEditorService::statesEqual); | |||
| } | |||
| // 比较两个完整 HMI 编辑快照是否一致 | |||
| bool HmiEditorService::statesEqual( | |||
| const HistoryState &left, const HistoryState &right) | |||
| { | |||
| @@ -101,6 +175,7 @@ bool HmiEditorService::statesEqual( | |||
| return true; | |||
| } | |||
| // 比较两个页面的基础属性和全部控件 | |||
| bool HmiEditorService::pagesEqual( | |||
| const HmiPage &left, const HmiPage &right) | |||
| { | |||
| @@ -120,6 +195,7 @@ bool HmiEditorService::pagesEqual( | |||
| return true; | |||
| } | |||
| // 比较两个控件中所有会保存到工程的属性 | |||
| bool HmiEditorService::controlsEqual( | |||
| const HmiControl &left, const HmiControl &right) | |||
| { | |||
| @@ -137,9 +213,12 @@ bool HmiEditorService::controlsEqual( | |||
| && left.dataType == right.dataType | |||
| && left.properties == right.properties | |||
| && left.buttonOperation == right.buttonOperation | |||
| && left.buttonEnableCondition == right.buttonEnableCondition | |||
| && left.statusText == right.statusText | |||
| && page_jump_equal; | |||
| } | |||
| // 创建撤销或重做不可执行时的统一失败结果 | |||
| HmiEditorResult HmiEditorService::historyFailure(const std::string &message) | |||
| { | |||
| return {false, HmiEditorError::InvalidOperation, message, {}}; | |||
| @@ -213,6 +292,7 @@ HmiEditorResult HmiEditorService::ensureDefaultPage() | |||
| return {true, HmiEditorError::None, {}, project.hmiPages.back().id}; | |||
| } | |||
| // 新建并校验一个页面,成功后记录单步撤销历史 | |||
| HmiEditorResult HmiEditorService::addPage(const std::string &name) | |||
| { | |||
| if (isBlank(name)) | |||
| @@ -256,6 +336,7 @@ HmiEditorResult HmiEditorService::addPage(const std::string &name) | |||
| return {true, HmiEditorError::None, {}, project.hmiPages.back().id}; | |||
| } | |||
| // 校验新尺寸不会让已有控件越界后调整页面大小 | |||
| HmiEditorResult HmiEditorService::resizePage( | |||
| const std::string &page_id, int width, int height) | |||
| { | |||
| @@ -290,6 +371,7 @@ HmiEditorResult HmiEditorService::resizePage( | |||
| return {true, HmiEditorError::None, {}, page_id}; | |||
| } | |||
| // 校验名称非空且不重复后修改页面名称 | |||
| HmiEditorResult HmiEditorService::renamePage( | |||
| const std::string &page_id, const std::string &name) | |||
| { | |||
| @@ -334,6 +416,7 @@ HmiEditorResult HmiEditorService::renamePage( | |||
| return {true, HmiEditorError::None, {}, page_id}; | |||
| } | |||
| // 确认页面可删除且未被引用后从工程中移除页面 | |||
| HmiEditorResult HmiEditorService::removePage(const std::string &page_id) | |||
| { | |||
| const Project ¤t = project_service_.project(); | |||
| @@ -383,6 +466,7 @@ HmiEditorResult HmiEditorService::removePage(const std::string &page_id) | |||
| return {true, HmiEditorError::None, {}, page_id}; | |||
| } | |||
| // 将指定页面在页面列表中上移或下移一位 | |||
| HmiEditorResult HmiEditorService::movePage(const std::string &page_id, int offset) | |||
| { | |||
| if (offset != -1 && offset != 1) | |||
| @@ -414,6 +498,7 @@ HmiEditorResult HmiEditorService::movePage(const std::string &page_id, int offse | |||
| return {true, HmiEditorError::None, {}, page_id}; | |||
| } | |||
| // 将指定页面设置为工程启动时显示的初始页面 | |||
| HmiEditorResult HmiEditorService::setInitialPage(const std::string &page_id) | |||
| { | |||
| if (findPage(page_id) == nullptr) | |||
| @@ -430,6 +515,7 @@ HmiEditorResult HmiEditorService::setInitialPage(const std::string &page_id) | |||
| return {true, HmiEditorError::None, {}, page_id}; | |||
| } | |||
| // 根据控件注册描述创建控件并分配默认寄存器地址 | |||
| HmiEditorResult HmiEditorService::addControl( | |||
| const std::string &page_id, HmiControlType type) | |||
| { | |||
| @@ -457,8 +543,16 @@ HmiEditorResult HmiEditorService::addControl( | |||
| return failure(HmiEditorError::InvalidControl, "不支持的 HMI 控件类型"); | |||
| } | |||
| // 新控件初始不绑定寄存器,避免自动分配地址造成误写风险 | |||
| HmiControl control = makeControl(*page, *descriptor); | |||
| const std::optional<RegisterAddress> binding = nextHmiBindingAddress( | |||
| project_service_.project(), *descriptor, control.dataType); | |||
| if (descriptor->bindingKind != HmiBindingKind::None && !binding.has_value()) | |||
| { | |||
| return failure( | |||
| HmiEditorError::InvalidControl, | |||
| "没有可用的 HMI 默认寄存器地址"); | |||
| } | |||
| control.binding = binding; | |||
| HistoryState before = captureState(); | |||
| Project &project = project_service_.editProject(); | |||
| auto target_page = std::find_if( | |||
| @@ -476,12 +570,14 @@ HmiEditorResult HmiEditorService::addControl( | |||
| target_page->controls.back().id}; | |||
| } | |||
| // 通过批量删除入口删除单个控件 | |||
| HmiEditorResult HmiEditorService::removeControl( | |||
| const std::string &page_id, const std::string &control_id) | |||
| { | |||
| return removeControls(page_id, {control_id}); | |||
| } | |||
| // 完整校验所选控件后一次性删除,避免留下部分修改 | |||
| HmiEditorResult HmiEditorService::removeControls( | |||
| const std::string &page_id, | |||
| const std::vector<std::string> &control_ids) | |||
| @@ -529,7 +625,7 @@ HmiEditorResult HmiEditorService::removeControls( | |||
| return {true, HmiEditorError::None, {}, control_ids.front()}; | |||
| } | |||
| // 移动 / 缩放控件 | |||
| // 校验候选矩形后移动或缩放指定控件 | |||
| HmiEditorResult HmiEditorService::moveControl( | |||
| const std::string &page_id, | |||
| const std::string &control_id, | |||
| @@ -577,7 +673,152 @@ HmiEditorResult HmiEditorService::moveControl( | |||
| return {true, HmiEditorError::None, {}, control_id}; | |||
| } | |||
| // 更新控件全部属性 | |||
| // 按所选控件的整体范围批量计算并应用对齐位置 | |||
| HmiEditorResult HmiEditorService::alignControls( | |||
| const std::string &page_id, | |||
| const std::vector<std::string> &control_ids, | |||
| HmiAlignment alignment) | |||
| { | |||
| const HmiPage *page = findPage(page_id); | |||
| if (page == nullptr) | |||
| { | |||
| return failure(HmiEditorError::PageNotFound, "未找到 HMI 页面"); | |||
| } | |||
| if (control_ids.size() < 2U) | |||
| { | |||
| return failure( | |||
| HmiEditorError::InvalidOperation, | |||
| "请至少选择两个 HMI 控件"); | |||
| } | |||
| std::set<std::string> selected_ids; | |||
| for (const std::string &control_id : control_ids) | |||
| { | |||
| if (!selected_ids.insert(control_id).second) | |||
| { | |||
| return failure( | |||
| HmiEditorError::InvalidOperation, | |||
| "对齐列表中存在重复控件"); | |||
| } | |||
| if (findControl(page_id, control_id) == nullptr) | |||
| { | |||
| return failure(HmiEditorError::ControlNotFound, "未找到 HMI 控件"); | |||
| } | |||
| } | |||
| const HmiControl *first = findControl(page_id, control_ids.front()); | |||
| int left = first->bounds.x; | |||
| int top = first->bounds.y; | |||
| int right = first->bounds.x + first->bounds.width; | |||
| int bottom = first->bounds.y + first->bounds.height; | |||
| for (std::size_t index = 1U; index < control_ids.size(); ++index) | |||
| { | |||
| const HmiControl *control = findControl(page_id, control_ids[index]); | |||
| left = std::min(left, control->bounds.x); | |||
| top = std::min(top, control->bounds.y); | |||
| right = std::max( | |||
| right, control->bounds.x + control->bounds.width); | |||
| bottom = std::max( | |||
| bottom, control->bounds.y + control->bounds.height); | |||
| } | |||
| bool supported = true; | |||
| switch (alignment) | |||
| { | |||
| case HmiAlignment::Left: | |||
| case HmiAlignment::HorizontalCenter: | |||
| case HmiAlignment::Right: | |||
| case HmiAlignment::Top: | |||
| case HmiAlignment::VerticalCenter: | |||
| case HmiAlignment::Bottom: | |||
| break; | |||
| default: | |||
| supported = false; | |||
| break; | |||
| } | |||
| if (!supported) | |||
| { | |||
| return failure(HmiEditorError::InvalidOperation, "不支持的 HMI 对齐方式"); | |||
| } | |||
| HmiPage candidate_page = *page; | |||
| const int horizontal_center = (left + right) / 2; | |||
| const int vertical_center = (top + bottom) / 2; | |||
| bool changed = false; | |||
| for (HmiControl &control : candidate_page.controls) | |||
| { | |||
| if (selected_ids.find(control.id) == selected_ids.end()) | |||
| { | |||
| continue; | |||
| } | |||
| const HmiRect original = control.bounds; | |||
| switch (alignment) | |||
| { | |||
| case HmiAlignment::Left: | |||
| control.bounds.x = left; | |||
| break; | |||
| case HmiAlignment::HorizontalCenter: | |||
| control.bounds.x = horizontal_center - control.bounds.width / 2; | |||
| break; | |||
| case HmiAlignment::Right: | |||
| control.bounds.x = right - control.bounds.width; | |||
| break; | |||
| case HmiAlignment::Top: | |||
| control.bounds.y = top; | |||
| break; | |||
| case HmiAlignment::VerticalCenter: | |||
| control.bounds.y = vertical_center - control.bounds.height / 2; | |||
| break; | |||
| case HmiAlignment::Bottom: | |||
| control.bounds.y = bottom - control.bounds.height; | |||
| break; | |||
| default: | |||
| break; | |||
| } | |||
| changed = changed || original.x != control.bounds.x | |||
| || original.y != control.bounds.y; | |||
| } | |||
| if (!changed) | |||
| { | |||
| return { | |||
| true, | |||
| HmiEditorError::None, | |||
| {}, | |||
| control_ids.front()}; | |||
| } | |||
| std::string error; | |||
| if (!candidate_page.validate(project_service_.projectLimits(), &error)) | |||
| { | |||
| return failure(HmiEditorError::InvalidControl, error); | |||
| } | |||
| const HistoryState before = captureState(); | |||
| Project &project = project_service_.editProject(); | |||
| auto target_page = std::find_if( | |||
| project.hmiPages.begin(), project.hmiPages.end(), | |||
| [&page_id](const HmiPage &item) { return item.id == page_id; }); | |||
| for (const HmiControl &candidate : candidate_page.controls) | |||
| { | |||
| if (selected_ids.find(candidate.id) == selected_ids.end()) | |||
| { | |||
| continue; | |||
| } | |||
| auto editable = std::find_if( | |||
| target_page->controls.begin(), target_page->controls.end(), | |||
| [&candidate](const HmiControl &item) | |||
| { | |||
| return item.id == candidate.id; | |||
| }); | |||
| editable->bounds = candidate.bounds; | |||
| } | |||
| recordHistory(before); | |||
| return {true, HmiEditorError::None, {}, control_ids.front()}; | |||
| } | |||
| // 校验并整体更新控件的全部可编辑属性 | |||
| HmiEditorResult HmiEditorService::updateControl( | |||
| const std::string &page_id, | |||
| const std::string &control_id, | |||
| @@ -625,6 +866,7 @@ HmiEditorResult HmiEditorService::updateControl( | |||
| return {true, HmiEditorError::None, {}, control.id}; | |||
| } | |||
| // 保留相对布局批量粘贴控件,任一控件失败时整体回滚 | |||
| HmiEditorResult HmiEditorService::pasteControls( | |||
| const std::string &page_id, | |||
| const std::vector<HmiControl> &controls, | |||
| @@ -731,16 +973,19 @@ HmiEditorResult HmiEditorService::pasteControls( | |||
| return {true, HmiEditorError::None, {}, new_ids.front()}; | |||
| } | |||
| // 判断当前 HMI 编辑会话是否有可撤销操作 | |||
| bool HmiEditorService::canUndo() const | |||
| { | |||
| return history_.canUndo(); | |||
| } | |||
| // 判断当前 HMI 编辑会话是否有可重做操作 | |||
| bool HmiEditorService::canRedo() const | |||
| { | |||
| return history_.canRedo(); | |||
| } | |||
| // 恢复最近一份编辑前快照 | |||
| HmiEditorResult HmiEditorService::undo() | |||
| { | |||
| const std::optional<HistoryState> target = history_.undo(captureState()); | |||
| @@ -754,6 +999,7 @@ HmiEditorResult HmiEditorService::undo() | |||
| return {true, HmiEditorError::None, {}, {}}; | |||
| } | |||
| // 重新应用最近一份被撤销的快照 | |||
| HmiEditorResult HmiEditorService::redo() | |||
| { | |||
| const std::optional<HistoryState> target = history_.redo(captureState()); | |||
| @@ -767,11 +1013,13 @@ HmiEditorResult HmiEditorService::redo() | |||
| return {true, HmiEditorError::None, {}, {}}; | |||
| } | |||
| // 清空当前 HMI 编辑会话的撤销和重做历史 | |||
| void HmiEditorService::clearHistory() | |||
| { | |||
| history_.clear(); | |||
| } | |||
| // 校验控件自身配置、页面边界和页面跳转目标 | |||
| bool HmiEditorService::validateEditableControl( | |||
| const HmiPage &page, const HmiControl &control, std::string *error) const | |||
| { | |||
| @@ -797,6 +1045,7 @@ bool HmiEditorService::validateEditableControl( | |||
| return true; | |||
| } | |||
| // 检查候选控件 ID 是否与页面内其他控件重复 | |||
| bool HmiEditorService::hasDuplicateControlId( | |||
| const HmiPage &page, | |||
| const std::string &excluded_id, | |||
| @@ -806,7 +1055,8 @@ bool HmiEditorService::hasDuplicateControlId( | |||
| page.controls.cbegin(), page.controls.cend(), | |||
| [&excluded_id, &candidate_id](const HmiControl &item) | |||
| { | |||
| return item.id != excluded_id && item.id == candidate_id; // 如果这个控件不是正在编辑的原控件,并且它的 ID 等于准备使用的新 ID | |||
| // 排除正在编辑的原控件,只检查同页的其他控件 | |||
| return item.id != excluded_id && item.id == candidate_id; | |||
| }); | |||
| } | |||
| @@ -824,13 +1074,18 @@ HmiControl HmiEditorService::makeControl( | |||
| { | |||
| control.pageJump = HmiPageJumpConfig{}; | |||
| } | |||
| if (descriptor.type == HmiControlType::StatusText) | |||
| { | |||
| control.statusText = HmiStatusBitTextConfig{ | |||
| "设备停止", "设备运行"}; | |||
| } | |||
| 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); | |||
| return control; | |||
| } | |||
| // 生成页面内不重复控件 ID,前缀 + 自增数字 | |||
| // 生成由类型前缀和递增数字组成的页面内唯一控件 ID | |||
| std::string HmiEditorService::makeUniqueId( | |||
| const HmiPage &page, const std::string &prefix) | |||
| { | |||
| @@ -47,6 +47,19 @@ struct HmiEditorResult | |||
| std::string id; // 成功时返回新建或更新后的页面/控件 ID | |||
| }; | |||
| /** | |||
| * @brief HMI 控件批量对齐方向 | |||
| */ | |||
| enum class HmiAlignment | |||
| { | |||
| Left, // 左边缘对齐 | |||
| HorizontalCenter, // 水平中心线对齐 | |||
| Right, // 右边缘对齐 | |||
| Top, // 上边缘对齐 | |||
| VerticalCenter, // 垂直中心线对齐 | |||
| Bottom // 下边缘对齐 | |||
| }; | |||
| /** | |||
| * @brief 编排 HMI 页面和控件的编辑操作,不依赖 Qt 视图 | |||
| * | |||
| @@ -58,10 +71,11 @@ public: | |||
| /** | |||
| * @brief 创建 HMI 编辑服务 | |||
| * @param project_service 用于读取和原子修改当前工程的项目服务 | |||
| * @param hmi_defaults 新建页面和控件使用的默认配置 | |||
| */ | |||
| explicit HmiEditorService( | |||
| ProjectService &project_service, | |||
| const HmiDefaultSettings &hmi_defaults = defaultHmiSettings()); | |||
| const HmiDefaultSettings &hmi_defaults); | |||
| /** | |||
| * @brief 按页面标识查找只读页面 | |||
| @@ -174,6 +188,19 @@ public: | |||
| const std::string &page_id, | |||
| const std::string &control_id, | |||
| const HmiRect &bounds); | |||
| /** | |||
| * @brief 批量对齐同一页面中的 HMI 控件 | |||
| * @param page_id 控件所属页面唯一标识 | |||
| * @param control_ids 待对齐控件 ID 列表,至少包含两个不重复 ID | |||
| * @param alignment 对齐方向,按所选控件整体外接矩形计算 | |||
| * @return 页面、控件、选择列表或对齐结果无效时返回失败结果 | |||
| * | |||
| * 对齐只修改控件位置,整批操作只产生一条撤销记录 | |||
| */ | |||
| HmiEditorResult alignControls( | |||
| const std::string &page_id, | |||
| const std::vector<std::string> &control_ids, | |||
| HmiAlignment alignment); | |||
| /** | |||
| * @brief 更新控件可编辑属性 | |||
| * @param page_id 所属页面唯一标识 | |||
| @@ -275,7 +302,7 @@ private: | |||
| * @brief 按控件类型创建带默认属性的新控件 | |||
| * @param page 新控件所属页面,用于确定初始位置和唯一标识 | |||
| * @param descriptor 新控件类型对应的注册描述 | |||
| * @return 未绑定寄存器的默认控件配置 | |||
| * @return 带默认外观和未分配地址的控件配置 | |||
| */ | |||
| static HmiControl makeControl( | |||
| const HmiPage &page, const HmiControlDescriptor &descriptor); | |||
| @@ -292,6 +319,6 @@ private: | |||
| * @brief 非拥有的工程服务依赖,由应用入口保证生命周期 | |||
| */ | |||
| ProjectService &project_service_; | |||
| const HmiDefaultSettings &hmi_defaults_; | |||
| EditorHistory<HistoryState> history_; | |||
| const HmiDefaultSettings &hmi_defaults_; // 非拥有的 HMI 默认配置依赖 | |||
| EditorHistory<HistoryState> history_; // 当前 HMI 编辑会话的撤销和重做历史 | |||
| }; | |||
| @@ -4,11 +4,13 @@ | |||
| #include <algorithm> | |||
| // 保存工程服务引用并等待运行会话启动 | |||
| HmiNavigationService::HmiNavigationService(ProjectService &project_service) | |||
| : project_service_(project_service) | |||
| { | |||
| } | |||
| // 从工程配置的初始页面开始新的 HMI 运行会话 | |||
| HmiNavigationResult HmiNavigationService::start() | |||
| { | |||
| // 运行开始始终从工程声明的初始页面进入,而不是沿用上次会话页面 | |||
| @@ -21,6 +23,7 @@ HmiNavigationResult HmiNavigationService::start() | |||
| return navigateTo(project.initialHmiPageId); | |||
| } | |||
| // 确认页面存在后切换当前运行页面 | |||
| HmiNavigationResult HmiNavigationService::navigateTo(const std::string &page_id) | |||
| { | |||
| const Project &project = project_service_.project(); | |||
| @@ -35,11 +38,13 @@ HmiNavigationResult HmiNavigationService::navigateTo(const std::string &page_id) | |||
| return {true, HmiNavigationError::None, {}, current_page_id_}; | |||
| } | |||
| // 结束运行会话并清空当前页面 ID | |||
| void HmiNavigationService::stop() | |||
| { | |||
| current_page_id_.clear(); | |||
| } | |||
| // 返回当前运行页面 ID,未启动时返回空字符串 | |||
| const std::string &HmiNavigationService::currentPageId() const | |||
| { | |||
| return current_page_id_; | |||
| @@ -7,18 +7,18 @@ class ProjectService; | |||
| // HMI 页面跳转失败分类 | |||
| enum class HmiNavigationError | |||
| { | |||
| None, | |||
| NoPages, | |||
| PageNotFound | |||
| None, // 跳转成功或没有错误 | |||
| NoPages, // 当前工程没有 HMI 页面 | |||
| PageNotFound // 指定的目标页面不存在 | |||
| }; | |||
| // 页面跳转结果;成功时 pageId 是当前页 ID | |||
| struct HmiNavigationResult | |||
| { | |||
| bool succeeded = false; | |||
| HmiNavigationError error = HmiNavigationError::None; | |||
| std::string message; | |||
| std::string pageId; | |||
| bool succeeded = false; // 页面跳转是否成功 | |||
| HmiNavigationError error = HmiNavigationError::None; // 失败时的错误分类 | |||
| std::string message; // 面向用户的失败原因 | |||
| std::string pageId; // 跳转成功后的当前页面 ID | |||
| }; | |||
| // 管理运行态当前页面,不直接操作 Qt 页面控件 | |||
| @@ -37,6 +37,6 @@ public: | |||
| const std::string ¤tPageId() const; | |||
| private: | |||
| ProjectService &project_service_; | |||
| std::string current_page_id_; | |||
| ProjectService &project_service_; // 非拥有的工程服务依赖 | |||
| std::string current_page_id_; // 当前运行会话显示的页面 ID | |||
| }; | |||
| @@ -2,28 +2,69 @@ | |||
| #include "domain/hmi_control_registry.h" | |||
| #include <algorithm> | |||
| #include <cmath> | |||
| #include <limits> | |||
| #include <type_traits> | |||
| namespace { | |||
| // 创建统一的 HMI 寄存器读取失败结果 | |||
| HmiRuntimeReadResult readFailure(HmiRuntimeError error) | |||
| { | |||
| return {false, error, false, 0, 0.0f}; | |||
| return {false, error, false, std::int16_t{0}}; | |||
| } | |||
| // 创建统一的 HMI 寄存器写入失败结果 | |||
| HmiRuntimeWriteResult writeFailure(HmiRuntimeError error) | |||
| { | |||
| return {false, error}; | |||
| } | |||
| // 将类型化寄存器数值统一转换为按钮条件比较使用的 double | |||
| double numericValueAsDouble(const RegisterNumericValue &value) | |||
| { | |||
| // std::visit 根据 variant 当前保存的实际数值类型调用转换逻辑 | |||
| return std::visit( | |||
| [](const auto &typed_value) | |||
| { | |||
| return static_cast<double>(typed_value); | |||
| }, | |||
| value); | |||
| } | |||
| // 按按钮条件配置的比较符判断实际值是否满足期望值 | |||
| bool compareButtonValue( | |||
| double actual, HmiButtonConditionOperator operation, double expected) | |||
| { | |||
| switch (operation) | |||
| { | |||
| case HmiButtonConditionOperator::Equal: | |||
| return actual == expected; | |||
| case HmiButtonConditionOperator::NotEqual: | |||
| return actual != expected; | |||
| case HmiButtonConditionOperator::LessThan: | |||
| return actual < expected; | |||
| case HmiButtonConditionOperator::LessThanOrEqual: | |||
| return actual <= expected; | |||
| case HmiButtonConditionOperator::GreaterThan: | |||
| return actual > expected; | |||
| case HmiButtonConditionOperator::GreaterThanOrEqual: | |||
| return actual >= expected; | |||
| default: | |||
| return false; | |||
| } | |||
| } | |||
| } // namespace | |||
| // 保存当前运行模式提供的寄存器仓库引用 | |||
| HmiRuntimeService::HmiRuntimeService(RegisterRepository &repository) | |||
| : repository_(repository) | |||
| { | |||
| } | |||
| // 按控件运行值类型读取 M 位或连续 D 字并转换为统一结果 | |||
| HmiRuntimeReadResult HmiRuntimeService::readControl(const HmiControl &control) const | |||
| { | |||
| const HmiControlDescriptor *descriptor = | |||
| @@ -43,7 +84,10 @@ HmiRuntimeReadResult HmiRuntimeService::readControl(const HmiControl &control) c | |||
| } | |||
| const std::optional<RegisterArea> binding_area = | |||
| hmiBindingArea(descriptor->bindingKind); | |||
| if (!binding_area.has_value() || control.binding->area() != *binding_area) | |||
| const bool flexible_binding = descriptor->bindingKind | |||
| == HmiBindingKind::BitOrWord; | |||
| if ((!binding_area.has_value() && !flexible_binding) | |||
| || (binding_area.has_value() && control.binding->area() != *binding_area)) | |||
| { | |||
| return readFailure(HmiRuntimeError::InvalidBinding); | |||
| } | |||
| @@ -58,30 +102,54 @@ HmiRuntimeReadResult HmiRuntimeService::readControl(const HmiControl &control) c | |||
| { | |||
| return readFailure(repositoryError(result.error)); | |||
| } | |||
| return {true, HmiRuntimeError::None, result.value, 0, 0.0f}; | |||
| return {true, HmiRuntimeError::None, result.value, std::int16_t{0}}; | |||
| } | |||
| case HmiRuntimeValueKind::Word: | |||
| { | |||
| if (control.dataType == RegisterDataType::Float32) | |||
| if (!registerDataTypeAddressIsValid(control.dataType, *control.binding)) | |||
| { | |||
| 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() | |||
| return readFailure(HmiRuntimeError::InvalidBinding); | |||
| } | |||
| const WordsReadResult result = repository_.readWords( | |||
| *control.binding, registerDataTypeWordCount(control.dataType)); | |||
| if (!result.succeeded) | |||
| { | |||
| return readFailure(repositoryError(result.error)); | |||
| } | |||
| const std::optional<RegisterNumericValue> value = | |||
| decodeRegisterNumericValue(control.dataType, result.values); | |||
| return value.has_value() | |||
| ? HmiRuntimeReadResult{ | |||
| true, HmiRuntimeError::None, false, *value} | |||
| : readFailure(HmiRuntimeError::RepositoryFailure); | |||
| } | |||
| case HmiRuntimeValueKind::BitOrWord: | |||
| { | |||
| if (control.binding->area() == RegisterArea::M) | |||
| { | |||
| const BitReadResult result = repository_.readBit(*control.binding); | |||
| return result.succeeded | |||
| ? HmiRuntimeReadResult{ | |||
| true, HmiRuntimeError::None, false, 0, *value} | |||
| : readFailure(HmiRuntimeError::RepositoryFailure); | |||
| true, HmiRuntimeError::None, result.value, std::int16_t{0}} | |||
| : readFailure(repositoryError(result.error)); | |||
| } | |||
| const WordReadResult result = repository_.readWord(*control.binding); | |||
| if (control.binding->area() != RegisterArea::D | |||
| || !registerDataTypeAddressIsValid(control.dataType, *control.binding)) | |||
| { | |||
| return readFailure(HmiRuntimeError::InvalidBinding); | |||
| } | |||
| const WordsReadResult result = repository_.readWords( | |||
| *control.binding, registerDataTypeWordCount(control.dataType)); | |||
| if (!result.succeeded) | |||
| { | |||
| return readFailure(repositoryError(result.error)); | |||
| } | |||
| return {true, HmiRuntimeError::None, false, result.value, 0.0f}; | |||
| const std::optional<RegisterNumericValue> value = | |||
| decodeRegisterNumericValue(control.dataType, result.values); | |||
| return value.has_value() | |||
| ? HmiRuntimeReadResult{ | |||
| true, HmiRuntimeError::None, false, *value} | |||
| : readFailure(HmiRuntimeError::RepositoryFailure); | |||
| } | |||
| case HmiRuntimeValueKind::None: | |||
| default: | |||
| @@ -91,6 +159,125 @@ HmiRuntimeReadResult HmiRuntimeService::readControl(const HmiControl &control) c | |||
| } | |||
| } | |||
| // 读取状态文本绑定值并映射为对应的运行态显示文本 | |||
| HmiStatusTextReadResult HmiRuntimeService::readStatusText( | |||
| const HmiControl &control) const | |||
| { | |||
| if (control.type != HmiControlType::StatusText | |||
| || !control.statusText.has_value()) | |||
| { | |||
| return {false, HmiRuntimeError::UnsupportedControl, {}}; | |||
| } | |||
| const bool bit_config = std::holds_alternative<HmiStatusBitTextConfig>( | |||
| *control.statusText); | |||
| if (!control.binding.has_value() | |||
| || control.binding->area() | |||
| != (bit_config ? RegisterArea::M : RegisterArea::D)) | |||
| { | |||
| return {false, HmiRuntimeError::InvalidBinding, {}}; | |||
| } | |||
| const HmiRuntimeReadResult value = readControl(control); | |||
| if (!value.succeeded) | |||
| { | |||
| return {false, value.error, {}}; | |||
| } | |||
| if (const auto *bit = std::get_if<HmiStatusBitTextConfig>( | |||
| &*control.statusText)) | |||
| { | |||
| return {true, | |||
| HmiRuntimeError::None, | |||
| value.bit_value ? bit->onText : bit->offText}; | |||
| } | |||
| const double number = numericValueAsDouble(value.numeric_value); | |||
| const auto &ranges = std::get<HmiStatusWordTextConfig>( | |||
| *control.statusText).ranges; | |||
| const auto match = std::find_if( | |||
| ranges.cbegin(), ranges.cend(), | |||
| [number](const HmiStatusValueRange &range) | |||
| { | |||
| // D 状态区间统一使用左闭右开规则 [下限, 上限) | |||
| return (!range.lowerBound.has_value() | |||
| || number >= *range.lowerBound) | |||
| && (!range.upperBound.has_value() | |||
| || number < *range.upperBound); | |||
| }); | |||
| return match == ranges.cend() | |||
| ? HmiStatusTextReadResult{ | |||
| false, HmiRuntimeError::InvalidBinding, {}} | |||
| : HmiStatusTextReadResult{ | |||
| true, HmiRuntimeError::None, match->text}; | |||
| } | |||
| // 读取并判断按钮配置的 M 位或 D 数值启用条件 | |||
| HmiButtonEnabledResult HmiRuntimeService::evaluateButtonEnabled( | |||
| const HmiControl &control) const | |||
| { | |||
| if (control.type != HmiControlType::Button) | |||
| { | |||
| return {false, false, HmiRuntimeError::UnsupportedControl}; | |||
| } | |||
| if (!control.buttonEnableCondition.has_value()) | |||
| { | |||
| return {true, true, HmiRuntimeError::None}; | |||
| } | |||
| if (const auto *bit = std::get_if<HmiButtonBitEnableCondition>( | |||
| &*control.buttonEnableCondition)) | |||
| { | |||
| if (!bit->address.isValid() || bit->address.area() != RegisterArea::M) | |||
| { | |||
| return {false, false, HmiRuntimeError::ConditionUnavailable}; | |||
| } | |||
| const BitReadResult result = repository_.readBit(bit->address); | |||
| if (!result.succeeded) | |||
| { | |||
| return {false, false, HmiRuntimeError::ConditionUnavailable}; | |||
| } | |||
| return {true, | |||
| result.value == bit->expected, | |||
| result.value == bit->expected | |||
| ? HmiRuntimeError::None : HmiRuntimeError::ConditionNotMet}; | |||
| } | |||
| const auto *word = std::get_if<HmiButtonWordEnableCondition>( | |||
| &*control.buttonEnableCondition); | |||
| if (word == nullptr | |||
| || !registerDataTypeAddressIsValid(word->dataType, word->address) | |||
| || !std::isfinite(word->value)) | |||
| { | |||
| return {false, false, HmiRuntimeError::ConditionUnavailable}; | |||
| } | |||
| switch (word->operation) | |||
| { | |||
| case HmiButtonConditionOperator::Equal: | |||
| case HmiButtonConditionOperator::NotEqual: | |||
| case HmiButtonConditionOperator::LessThan: | |||
| case HmiButtonConditionOperator::LessThanOrEqual: | |||
| case HmiButtonConditionOperator::GreaterThan: | |||
| case HmiButtonConditionOperator::GreaterThanOrEqual: | |||
| break; | |||
| default: | |||
| return {false, false, HmiRuntimeError::ConditionUnavailable}; | |||
| } | |||
| const WordsReadResult words = repository_.readWords( | |||
| word->address, registerDataTypeWordCount(word->dataType)); | |||
| if (!words.succeeded) | |||
| { | |||
| return {false, false, HmiRuntimeError::ConditionUnavailable}; | |||
| } | |||
| const std::optional<RegisterNumericValue> value = | |||
| decodeRegisterNumericValue(word->dataType, words.values); | |||
| if (!value.has_value()) | |||
| { | |||
| return {false, false, HmiRuntimeError::ConditionUnavailable}; | |||
| } | |||
| const bool enabled = compareButtonValue( | |||
| numericValueAsDouble(*value), word->operation, word->value); | |||
| return {true, | |||
| enabled, | |||
| enabled ? HmiRuntimeError::None : HmiRuntimeError::ConditionNotMet}; | |||
| } | |||
| // 根据按钮操作方式处理按下和释放事件并写入绑定的 M 位 | |||
| HmiRuntimeWriteResult HmiRuntimeService::operateButton( | |||
| const HmiControl &control, HmiButtonEvent event) | |||
| { | |||
| @@ -107,6 +294,20 @@ HmiRuntimeWriteResult HmiRuntimeService::operateButton( | |||
| return writeFailure(HmiRuntimeError::InvalidBinding); | |||
| } | |||
| // 释放事件不重复校验条件,确保点动按钮即使条件变化也能复位 | |||
| if (event == HmiButtonEvent::Pressed) | |||
| { | |||
| const HmiButtonEnabledResult enabled = evaluateButtonEnabled(control); | |||
| if (!enabled.succeeded) | |||
| { | |||
| return writeFailure(enabled.error); | |||
| } | |||
| if (!enabled.enabled) | |||
| { | |||
| return writeFailure(HmiRuntimeError::ConditionNotMet); | |||
| } | |||
| } | |||
| bool value = false; | |||
| switch (control.buttonOperation) | |||
| { | |||
| @@ -150,12 +351,14 @@ HmiRuntimeWriteResult HmiRuntimeService::operateButton( | |||
| } | |||
| } | |||
| // HMI 按钮只向活动仓库提交 M 写请求,真机模式再转发到 PLC | |||
| const RegisterWriteResult result = repository_.writeBit(*control.binding, value); | |||
| return result.succeeded | |||
| ? HmiRuntimeWriteResult{true, HmiRuntimeError::None} | |||
| : writeFailure(repositoryError(result.error)); | |||
| } | |||
| // 按控件数值类型编码并写入绑定的连续 D 字 | |||
| HmiRuntimeWriteResult HmiRuntimeService::writeNumericInput( | |||
| const HmiControl &control, double value) | |||
| { | |||
| @@ -171,51 +374,39 @@ HmiRuntimeWriteResult HmiRuntimeService::writeNumericInput( | |||
| { | |||
| return writeFailure(HmiRuntimeError::InvalidBinding); | |||
| } | |||
| RegisterWriteResult result; | |||
| if (control.dataType == RegisterDataType::Float32) | |||
| if (!registerDataTypeAddressIsValid(control.dataType, *control.binding)) | |||
| { | |||
| 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)); | |||
| return writeFailure(HmiRuntimeError::InvalidBinding); | |||
| } | |||
| else | |||
| const std::optional<std::vector<std::int16_t>> encoded = | |||
| encodeRegisterNumericValue(control.dataType, value); | |||
| if (!encoded.has_value()) | |||
| { | |||
| 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 writeFailure(HmiRuntimeError::InvalidBinding); | |||
| } | |||
| // 数值输入按完整字宽提交,真机模式由通信层一次写入连续 D | |||
| const RegisterWriteResult result = repository_.writeWords( | |||
| *control.binding, *encoded); | |||
| return result.succeeded | |||
| ? HmiRuntimeWriteResult{true, HmiRuntimeError::None} | |||
| : writeFailure(repositoryError(result.error)); | |||
| } | |||
| // 直接转发指定 M 位的仓库读取结果 | |||
| BitReadResult HmiRuntimeService::readBit(const RegisterAddress &address) const | |||
| { | |||
| // 真机模式读取活动 PLC 仓库中最近一次成功轮询的 M 缓存 | |||
| return repository_.readBit(address); | |||
| } | |||
| // 直接转发指定 D 字的仓库读取结果 | |||
| WordReadResult HmiRuntimeService::readWord(const RegisterAddress &address) const | |||
| { | |||
| // 真机模式读取活动 PLC 仓库中最近一次成功轮询的 D 缓存 | |||
| return repository_.readWord(address); | |||
| } | |||
| // 将底层寄存器错误转换为 HMI 运行态错误 | |||
| HmiRuntimeError HmiRuntimeService::repositoryError(RegisterError error) | |||
| { | |||
| // 领域仓库错误被收敛为 HMI 可理解的运行错误 | |||
| @@ -21,21 +21,22 @@ enum class HmiRuntimeError | |||
| UnsupportedControl, // 控件类型不支持寄存器读写或写入操作 | |||
| MissingBinding, // 控件未配置寄存器绑定 | |||
| InvalidBinding, // 控件绑定地址无效或地址区域不匹配 | |||
| RepositoryFailure // 寄存器仓库不可用或拒绝读写 | |||
| RepositoryFailure, // 寄存器仓库不可用或拒绝读写 | |||
| ConditionNotMet, // 按钮启用条件不满足 | |||
| ConditionUnavailable // 按钮启用条件无法读取或配置无效 | |||
| }; | |||
| /** | |||
| * @brief HMI 控件寄存器读取结果 | |||
| * | |||
| * 控件类型决定 `bit_value` 或 `word_value` 哪一个有效 | |||
| * 控件类型决定 `bit_value` 或 `numeric_value` 哪一个有效 | |||
| */ | |||
| struct HmiRuntimeReadResult | |||
| { | |||
| bool succeeded = false; // 是否成功读到控件绑定值 | |||
| HmiRuntimeError error = HmiRuntimeError::None; // 失败时的 HMI 错误分类 | |||
| bool bit_value = false; // 位控件的读回值;字控件读取成功时无效 | |||
| std::int16_t word_value = 0; // 字控件的读回值;位控件读取成功时无效 | |||
| float float_value = 0.0f; // Float32 数值控件的读回值 | |||
| RegisterNumericValue numeric_value = std::int16_t{0}; // 数值控件的类型化读回值 | |||
| }; | |||
| /** | |||
| @@ -47,6 +48,21 @@ struct HmiRuntimeWriteResult | |||
| HmiRuntimeError error = HmiRuntimeError::None; // 失败时的 HMI 错误分类 | |||
| }; | |||
| struct HmiButtonEnabledResult | |||
| { | |||
| bool succeeded = false; // 是否成功读取并评估启用条件 | |||
| bool enabled = false; // 当前按钮是否允许操作 | |||
| HmiRuntimeError error = HmiRuntimeError::ConditionUnavailable; // 不可用原因 | |||
| }; | |||
| // 状态文本控件读取和映射后的显示结果 | |||
| struct HmiStatusTextReadResult | |||
| { | |||
| bool succeeded = false; // 是否成功读到寄存器并匹配显示文本 | |||
| HmiRuntimeError error = HmiRuntimeError::None; // 失败时的错误分类 | |||
| std::string text; // 读取成功后应显示的文本 | |||
| }; | |||
| /** | |||
| * @brief HMI 按钮在运行态产生的输入事件 | |||
| */ | |||
| @@ -79,6 +95,11 @@ public: | |||
| * 控件未绑定、绑定区域错误或仓库不可用时不返回有效值 | |||
| */ | |||
| HmiRuntimeReadResult readControl(const HmiControl &control) const; | |||
| // 读取状态文本绑定值并按 M 状态或 D 数值区间映射显示文本 | |||
| HmiStatusTextReadResult readStatusText(const HmiControl &control) const; | |||
| /** @brief 读取并评估按钮启用条件;无条件按钮直接返回可用 */ | |||
| HmiButtonEnabledResult evaluateButtonEnabled( | |||
| const HmiControl &control) const; | |||
| /** | |||
| * @brief 根据按钮配置处理按下或释放事件 | |||
| * @param control 按钮控件,必须绑定有效 M 地址 | |||
| @@ -10,6 +10,7 @@ | |||
| namespace { | |||
| // 按空白字符把一行命令拆分为助记符和操作数 | |||
| std::vector<std::string> splitTokens(const std::string &text) | |||
| { | |||
| std::istringstream stream(text); | |||
| @@ -22,6 +23,7 @@ std::vector<std::string> splitTokens(const std::string &text) | |||
| return tokens; | |||
| } | |||
| // 将助记符中的 ASCII 字母统一转换为大写 | |||
| std::string upperAscii(std::string value) | |||
| { | |||
| std::transform( | |||
| @@ -33,11 +35,13 @@ std::string upperAscii(std::string value) | |||
| return value; | |||
| } | |||
| // 创建统一的命令解析失败结果 | |||
| LogicCommandParseResult parseFailure(const std::string &message) | |||
| { | |||
| return {false, message, {}}; | |||
| } | |||
| // 检查操作数数量并在不匹配时生成带示例的错误信息 | |||
| bool requiresOperands( | |||
| const std::vector<std::string> &tokens, | |||
| std::size_t count, | |||
| @@ -54,6 +58,7 @@ bool requiresOperands( | |||
| return false; | |||
| } | |||
| // 解析并校验指令要求的 M 区或 D 区地址 | |||
| bool parseAddress( | |||
| const std::string &text, | |||
| RegisterArea expected_area, | |||
| @@ -83,12 +88,14 @@ bool parseAddress( | |||
| return true; | |||
| } | |||
| // 声明 16 位常量解析函数,供字操作数解析提前调用 | |||
| bool parseInt16( | |||
| const std::string &text, | |||
| const std::string &instruction, | |||
| std::int16_t *value, | |||
| LogicCommandParseResult *failure); | |||
| // 将操作数解析为 D 寄存器或 16 位常量 | |||
| bool parseWordOperand( | |||
| const std::string &text, | |||
| const std::string &instruction, | |||
| @@ -129,6 +136,7 @@ bool parseWordOperand( | |||
| return true; | |||
| } | |||
| // 判断指令是否属于可放在网络起点或替换条件节点的加载类指令 | |||
| bool isLoad(LogicCommandOpcode opcode) | |||
| { | |||
| return opcode == LogicCommandOpcode::Load | |||
| @@ -143,12 +151,14 @@ bool isLoad(LogicCommandOpcode opcode) | |||
| || opcode == LogicCommandOpcode::CompareGreaterThanOrEqual; | |||
| } | |||
| // 判断指令是否属于并联条件指令 | |||
| bool isOr(LogicCommandOpcode opcode) | |||
| { | |||
| return opcode == LogicCommandOpcode::Or | |||
| || opcode == LogicCommandOpcode::OrInverse; | |||
| } | |||
| // 判断指令是否生成条件节点 | |||
| bool isCondition(LogicCommandOpcode opcode) | |||
| { | |||
| return isLoad(opcode) || isOr(opcode) | |||
| @@ -156,11 +166,13 @@ bool isCondition(LogicCommandOpcode opcode) | |||
| || opcode == LogicCommandOpcode::AndInverse; | |||
| } | |||
| // 判断当前编辑目标是否允许输入条件指令 | |||
| bool isConditionTarget(LogicCommandTargetKind kind) | |||
| { | |||
| return kind != LogicCommandTargetKind::Output; | |||
| } | |||
| // 判断指令是否生成输出节点 | |||
| bool isOutput(LogicCommandOpcode opcode) | |||
| { | |||
| return opcode == LogicCommandOpcode::Output | |||
| @@ -171,6 +183,7 @@ bool isOutput(LogicCommandOpcode opcode) | |||
| || opcode == LogicCommandOpcode::Subtract; | |||
| } | |||
| // 将十进制文本严格解析为带符号 16 位常量 | |||
| bool parseInt16( | |||
| const std::string &text, | |||
| const std::string &instruction, | |||
| @@ -180,6 +193,7 @@ bool parseInt16( | |||
| try | |||
| { | |||
| std::size_t consumed = 0; | |||
| // stol 配合 consumed 确保整段文本都是十进制整数而非部分匹配 | |||
| const long parsed = std::stol(text, &consumed, 10); | |||
| if (consumed != text.size() | |||
| || parsed < std::numeric_limits<std::int16_t>::min() | |||
| @@ -198,19 +212,22 @@ bool parseInt16( | |||
| } | |||
| } | |||
| // 创建统一的命令执行失败结果并保留指令类型 | |||
| LogicCommandResult executionFailure( | |||
| const std::string &message, LogicCommandOpcode opcode) | |||
| { | |||
| return {false, message, {}, {}, opcode}; | |||
| return {false, message, {}, {}, opcode, {}, false}; | |||
| } | |||
| } // namespace | |||
| // 保存逻辑编辑服务引用,所有命令最终通过该服务提交 | |||
| LogicCommandService::LogicCommandService(LogicEditorService &editor_service) | |||
| : editor_service_(editor_service) | |||
| { | |||
| } | |||
| // 解析助记符和操作数并生成对应的结构化节点配置 | |||
| LogicCommandParseResult LogicCommandService::parse(const std::string &text) | |||
| { | |||
| std::vector<std::string> tokens = splitTokens(text); | |||
| @@ -389,6 +406,7 @@ LogicCommandParseResult LogicCommandService::parse(const std::string &text) | |||
| return parseFailure("当前指令暂不支持:" + mnemonic); | |||
| } | |||
| // 返回静态指令建议表,避免每次打开输入框重复构造 | |||
| const std::vector<LogicCommandSuggestion> &LogicCommandService::suggestions() | |||
| { | |||
| static const std::vector<LogicCommandSuggestion> values{ | |||
| @@ -415,6 +433,7 @@ const std::vector<LogicCommandSuggestion> &LogicCommandService::suggestions() | |||
| return values; | |||
| } | |||
| // 根据目标位置把结构化命令转换为原子的逻辑编辑操作 | |||
| LogicCommandResult LogicCommandService::execute( | |||
| const LogicCommandRequest &request) | |||
| { | |||
| @@ -425,6 +444,8 @@ LogicCommandResult LogicCommandService::execute( | |||
| } | |||
| const LogicCommandOpcode opcode = parsed.command.opcode; | |||
| LogicEditorResult edited; | |||
| LogicEditCursor next_cursor; | |||
| bool has_next_cursor = false; | |||
| if (request.target.kind == LogicCommandTargetKind::ExistingNode) | |||
| { | |||
| @@ -439,7 +460,7 @@ LogicCommandResult LogicCommandService::execute( | |||
| if (!isLoad(opcode)) | |||
| { | |||
| return executionFailure( | |||
| "已有条件节点只能替换为 LD/LDI/LDP/LDF 指令", opcode); | |||
| "已有条件节点只能替换为触点或比较指令", opcode); | |||
| } | |||
| } | |||
| else if (existing->isOutput()) | |||
| @@ -461,12 +482,7 @@ LogicCommandResult LogicCommandService::execute( | |||
| } | |||
| else if (isCondition(opcode)) | |||
| { | |||
| if (isLoad(opcode) && request.continuing) | |||
| { | |||
| edited = editor_service_.appendCondition( | |||
| request.logicId, {}, parsed.command.config, true); | |||
| } | |||
| else if (isOr(opcode)) | |||
| if (isOr(opcode)) | |||
| { | |||
| const std::string rung_id = request.continuing | |||
| ? request.currentRungId : request.target.rungId; | |||
| @@ -489,20 +505,19 @@ LogicCommandResult LogicCommandService::execute( | |||
| 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()) | |||
| if (edited.succeeded) | |||
| { | |||
| return executionFailure( | |||
| "当前网络已有输出,下一条只能输入 LD/LDI/LDP/LDF 新建网络", | |||
| opcode); | |||
| const std::string edited_rung = editor_service_.rungIdForNode( | |||
| request.logicId, edited.id); | |||
| const int next_column = std::min( | |||
| request.target.column + 1, | |||
| ProjectLimits::kMaximumConditionColumns); | |||
| next_cursor = { | |||
| edited_rung.empty() ? rung_id : edited_rung, | |||
| next_column, | |||
| next_column == ProjectLimits::kMaximumConditionColumns}; | |||
| has_next_cursor = true; | |||
| } | |||
| edited = editor_service_.appendCondition( | |||
| request.logicId, request.currentRungId, | |||
| parsed.command.config, true); | |||
| } | |||
| else | |||
| { | |||
| @@ -513,46 +528,35 @@ LogicCommandResult LogicCommandService::execute( | |||
| } | |||
| 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()))) | |||
| && !request.target.rungId.empty()) | |||
| { | |||
| 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; | |||
| const LadderRung *target_rung = editor_service_.findRung( | |||
| request.logicId, request.target.rungId); | |||
| const bool has_condition = target_rung != nullptr | |||
| && std::any_of( | |||
| target_rung->cells.cbegin(), target_rung->cells.cend(), | |||
| [](const LadderCell &cell) | |||
| { | |||
| return cell.kind == LadderCellKind::Node; | |||
| }); | |||
| if (!has_condition) | |||
| { | |||
| return executionFailure( | |||
| "AND/ANI 前必须先输入 LD/LDI/LDP/LDF", opcode); | |||
| } | |||
| } | |||
| const LogicEditResult applied = | |||
| editor_service_.applyConditionAndAdvance( | |||
| request.logicId, | |||
| {request.continuing | |||
| ? request.currentRungId : request.target.rungId, | |||
| request.target.column, | |||
| false}, | |||
| parsed.command.config, | |||
| true); | |||
| edited = applied.edit; | |||
| next_cursor = applied.nextCursor; | |||
| has_next_cursor = edited.succeeded; | |||
| } | |||
| } | |||
| else | |||
| @@ -573,8 +577,14 @@ LogicCommandResult LogicCommandService::execute( | |||
| "当前网络已经有输出,下一条请输入 LD/LDI/LDP/LDF 新建网络", | |||
| opcode); | |||
| } | |||
| edited = editor_service_.setOutput( | |||
| request.logicId, rung_id, parsed.command.config, true); | |||
| const LogicEditResult applied = editor_service_.applyOutputAndAdvance( | |||
| request.logicId, | |||
| {rung_id, ProjectLimits::kMaximumConditionColumns, true}, | |||
| parsed.command.config, | |||
| true); | |||
| edited = applied.edit; | |||
| next_cursor = applied.nextCursor; | |||
| has_next_cursor = edited.succeeded; | |||
| } | |||
| if (!edited.succeeded) | |||
| @@ -584,5 +594,7 @@ LogicCommandResult LogicCommandService::execute( | |||
| return { | |||
| true, {}, edited.id, | |||
| editor_service_.rungIdForNode(request.logicId, edited.id), | |||
| opcode}; | |||
| opcode, | |||
| std::move(next_cursor), | |||
| has_next_cursor}; | |||
| } | |||
| @@ -1,93 +1,95 @@ | |||
| #pragma once | |||
| #include "domain/control_logic_model.h" | |||
| #include "logic_editor_service.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 | |||
| Load, // 常开触点 LD | |||
| LoadInverse, // 常闭触点 LDI | |||
| LoadRising, // 上升沿触点 LDP | |||
| LoadFalling, // 下降沿触点 LDF | |||
| CompareEqual, // 等于比较 LD= | |||
| CompareNotEqual, // 不等于比较 LD<> | |||
| CompareLessThan, // 小于比较 LD< | |||
| CompareLessThanOrEqual, // 小于等于比较 LD<= | |||
| CompareGreaterThan, // 大于比较 LD> | |||
| CompareGreaterThanOrEqual, // 大于等于比较 LD>= | |||
| And, // 串联常开触点 AND | |||
| AndInverse, // 串联常闭触点 ANI | |||
| Or, // 并联常开触点 OR | |||
| OrInverse, // 并联常闭触点 ORI | |||
| Output, // 普通输出线圈 OUT | |||
| Set, // 置位线圈 SET | |||
| Reset, // 复位线圈 RST | |||
| Move, // 数据传送 MOV | |||
| Add, // 加法 ADD | |||
| Subtract // 减法 SUB | |||
| }; | |||
| // 一条命令解析后的指令类型和节点配置 | |||
| struct ParsedLogicCommand | |||
| { | |||
| LogicCommandOpcode opcode = LogicCommandOpcode::Load; | |||
| LogicCommandOpcode opcode = LogicCommandOpcode::Load; // 识别出的指令类型 | |||
| // 与指令操作数对应的结构化逻辑节点配置 | |||
| LogicNodeConfig config = ContactNodeConfig{ | |||
| RegisterAddress{RegisterArea::M, 0}, ContactMode::NormallyOpen}; | |||
| }; | |||
| struct LogicCommandParseResult | |||
| { | |||
| bool succeeded = false; | |||
| std::string message; | |||
| ParsedLogicCommand command; | |||
| bool succeeded = false; // 命令文本是否解析成功 | |||
| std::string message; // 面向用户的语法或参数错误 | |||
| ParsedLogicCommand command; // 解析成功后的结构化命令 | |||
| }; | |||
| struct LogicCommandSuggestion | |||
| { | |||
| std::string mnemonic; | |||
| std::string description; | |||
| std::string operand_hint; | |||
| bool supported = true; | |||
| std::string mnemonic; // 指令助记符 | |||
| std::string description; // 面向用户的指令用途 | |||
| std::string operand_hint; // 操作数格式提示 | |||
| bool supported = true; // 当前版本是否支持执行 | |||
| }; | |||
| enum class LogicCommandTargetKind | |||
| { | |||
| EmptyColumn, | |||
| BranchEmptyColumn, | |||
| WireColumn, | |||
| GapColumn, | |||
| Output, | |||
| ExistingNode | |||
| EmptyColumn, // 普通空白条件列 | |||
| BranchEmptyColumn, // 并联分支中的空白条件列 | |||
| WireColumn, // 已有水平连线的条件列 | |||
| GapColumn, // 未连接的间隔列 | |||
| Output, // 第 11 列输出区 | |||
| ExistingNode // 已存在且准备替换的节点 | |||
| }; | |||
| struct LogicCommandTarget | |||
| { | |||
| LogicCommandTargetKind kind = LogicCommandTargetKind::EmptyColumn; | |||
| std::string rungId; | |||
| std::string expressionId; | |||
| int column = 0; | |||
| LogicCommandTargetKind kind = LogicCommandTargetKind::EmptyColumn; // 目标位置类型 | |||
| std::string rungId; // 目标梯级 ID | |||
| std::string expressionId; // 替换场景中的现有节点 ID | |||
| int column = 0; // 目标条件列索引 | |||
| }; | |||
| struct LogicCommandRequest | |||
| { | |||
| std::string logicId; | |||
| std::string text; | |||
| LogicCommandTarget target; | |||
| bool continuing = false; | |||
| std::string currentRungId; | |||
| std::vector<std::string> parallelNodeIds; | |||
| std::string logicId; // 所属控制逻辑 ID | |||
| std::string text; // 用户输入的原始指令文本 | |||
| LogicCommandTarget target; // 光标或现有节点对应的编辑目标 | |||
| bool continuing = false; // 是否延续上一条命令的自动光标 | |||
| std::string currentRungId; // 连续输入时当前所在梯级 ID | |||
| std::vector<std::string> parallelNodeIds; // OR/ORI 使用的连续条件节点 ID | |||
| }; | |||
| struct LogicCommandResult | |||
| { | |||
| bool succeeded = false; | |||
| std::string message; | |||
| std::string id; | |||
| std::string rungId; | |||
| LogicCommandOpcode opcode = LogicCommandOpcode::Load; | |||
| bool succeeded = false; // 命令是否成功转换为编辑操作 | |||
| std::string message; // 面向用户的解析或编辑失败原因 | |||
| std::string id; // 新建或更新后的节点 ID | |||
| std::string rungId; // 编辑完成后节点所在的梯级 ID | |||
| LogicCommandOpcode opcode = LogicCommandOpcode::Load; // 本次执行的指令类型 | |||
| LogicEditCursor nextCursor; // 连续输入建议移动到的下一个位置 | |||
| bool hasNextCursor = false; // 是否提供有效的下一光标位置 | |||
| }; | |||
| // 将单条命令语解析并原子转换为现有结构化梯形图编辑操作 | |||
| @@ -96,11 +98,14 @@ class LogicCommandService | |||
| public: | |||
| explicit LogicCommandService(LogicEditorService &editor_service); | |||
| // 将一行 PLC 指令文本解析为结构化节点配置 | |||
| static LogicCommandParseResult parse(const std::string &text); | |||
| // 返回命令输入框可展示的全部指令建议 | |||
| static const std::vector<LogicCommandSuggestion> &suggestions(); | |||
| // 解析命令并通过逻辑编辑服务原子应用到指定目标 | |||
| LogicCommandResult execute(const LogicCommandRequest &request); | |||
| private: | |||
| LogicEditorService &editor_service_; | |||
| LogicEditorService &editor_service_; // 非拥有的逻辑编辑服务依赖 | |||
| }; | |||
| @@ -9,357 +9,437 @@ | |||
| class ProjectService; | |||
| // 梯形图编辑失败分类 | |||
| enum class LogicEditorError | |||
| { | |||
| None, // 操作成功或没有错误 | |||
| LogicNotFound, // 控制逻辑不存在 | |||
| RungNotFound, // 网络不存在 | |||
| ExpressionNotFound, // 条件表达式不存在 | |||
| NodeNotFound, // 节点不存在 | |||
| InvalidNode, // 节点配置或节点位置无效 | |||
| InvalidOperation, // 操作参数或当前结构不允许该操作 | |||
| UnsupportedNodeChange, // 不允许把条件节点改成输出节点,或反向修改 | |||
| DuplicateName, // 控制逻辑名称与其他逻辑重复 | |||
| LogicNotFound, // 目标控制逻辑不存在 | |||
| RungNotFound, // 目标梯级不存在 | |||
| CellNotFound, // 目标网格单元不存在 | |||
| ConnectionNotFound, // 目标垂直连接不存在 | |||
| NodeNotFound, // 目标逻辑节点不存在 | |||
| InvalidNode, // 节点配置或放置位置无效 | |||
| InvalidOperation, // 编辑参数或当前状态不允许操作 | |||
| UnsupportedNodeChange, // 不允许把现有节点改成目标类型 | |||
| DuplicateName, // 控制逻辑名称与现有逻辑重复 | |||
| LastLogicRequired // 删除后不能少于一个控制逻辑 | |||
| }; | |||
| // 梯形图编辑结果;成功时 id 通常是新建或更新对象的稳定 ID | |||
| // 逻辑编辑操作的统一结果 | |||
| struct LogicEditorResult | |||
| { | |||
| bool succeeded = false; // 操作是否成功 | |||
| LogicEditorError error = LogicEditorError::None; // 失败时的分类 | |||
| std::string message; // 面向用户的 UTF-8 成功说明或失败原因 | |||
| std::string id; // 成功时返回新建或更新对象的稳定 ID | |||
| bool succeeded = false; // 编辑操作是否成功 | |||
| LogicEditorError error = LogicEditorError::None; // 失败时的错误分类 | |||
| std::string message; // 面向用户的失败原因 | |||
| std::string id; // 成功时返回相关逻辑、梯级或节点 ID | |||
| }; | |||
| enum class LogicConditionPasteTargetKind | |||
| // 一次批量删除所包含的网格对象 | |||
| struct LogicSelectionDeleteRequest | |||
| { | |||
| Append, | |||
| EmptyColumn, | |||
| BranchEmptyColumn, | |||
| AfterNode, | |||
| ReplaceWire, | |||
| ReplaceWireColumn, | |||
| ReplaceGapColumn | |||
| std::vector<std::pair<std::string, int>> cells; // 梯级 ID 和条件列组成的单元位置 | |||
| std::vector<std::string> outputRungIds; // 需要清除输出节点的梯级 ID | |||
| std::vector<std::string> verticalConnectionIds; // 需要删除的垂直连接 ID | |||
| }; | |||
| struct LogicConditionPasteTarget | |||
| // 剪贴板片段保存普通对象或完整梯级行 | |||
| enum class LogicClipboardMode | |||
| { | |||
| LogicConditionPasteTargetKind kind = LogicConditionPasteTargetKind::Append; | |||
| std::string expressionId; | |||
| int column = 0; | |||
| GridObjects, // 只保存明确选中的网格对象 | |||
| WholeRows // 保存完整行、空白单元和网络注释 | |||
| }; | |||
| // 负责把 UI 编辑命令转换为结构化表达式树操作,并维护撤销/重做 | |||
| // 剪贴板中的一个条件区单元 | |||
| struct LogicClipboardCell | |||
| { | |||
| int relativeRow = 0; // 相对复制区域首行的行偏移 | |||
| int relativeColumn = 0; // 相对复制区域首列的列偏移 | |||
| LadderCellKind kind = LadderCellKind::Gap; // 单元保存的空白、连线或节点类型 | |||
| std::optional<LogicNode> node; // 节点单元保存的完整节点配置 | |||
| }; | |||
| // 剪贴板中的一个输出节点 | |||
| struct LogicClipboardOutput | |||
| { | |||
| int relativeRow = 0; // 相对复制区域首行的行偏移 | |||
| int relativeColumn = 0; // 相对复制区域首列的列偏移 | |||
| LogicNode node; // 输出节点的完整配置 | |||
| }; | |||
| // 剪贴板中的一条垂直连接 | |||
| struct LogicClipboardVerticalConnection | |||
| { | |||
| int upperRelativeRow = 0; // 垂直连接上端相对首行的行偏移 | |||
| int relativeColumnBoundary = 0; // 相对复制区域首列的列边界 | |||
| }; | |||
| // 整行复制模式保存的一条梯级 | |||
| struct LogicClipboardRow | |||
| { | |||
| std::string comment; // 梯级所属网络的注释 | |||
| std::vector<LadderCell> cells; // 条件区全部单元 | |||
| std::optional<LogicNode> output; // 第 11 列的可选输出节点 | |||
| }; | |||
| // 普通片段只保存选中的对象,整行片段才保存 Gap 和网络注释 | |||
| struct LogicClipboardFragment | |||
| { | |||
| LogicClipboardMode mode = LogicClipboardMode::GridObjects; // 当前片段的复制模式 | |||
| std::vector<LogicClipboardCell> cells; // 普通对象模式中的条件区单元 | |||
| std::vector<LogicClipboardOutput> outputs; // 普通对象模式中的输出节点 | |||
| std::vector<LogicClipboardVerticalConnection> verticalConnections; // 垂直连接集合 | |||
| std::vector<LogicClipboardRow> rows; // 整行模式中的完整梯级 | |||
| int rowSpan = 0; // 片段覆盖的总行数 | |||
| int columnSpan = 0; // 片段覆盖的总列数 | |||
| }; | |||
| // 一次复制操作所选择的对象和完整梯级 | |||
| struct LogicSelectionCopyRequest | |||
| { | |||
| std::vector<std::pair<std::string, int>> cells; // 选中的条件区单元位置 | |||
| std::vector<std::string> outputRungIds; // 选中的输出节点所在梯级 ID | |||
| std::vector<std::string> verticalConnectionIds; // 选中的垂直连接 ID | |||
| std::vector<std::string> wholeRungIds; // 整行复制的梯级 ID | |||
| }; | |||
| // 复制操作结果及生成的剪贴板片段 | |||
| struct LogicClipboardCopyResult | |||
| { | |||
| LogicEditorResult copy; // 复制请求的校验结果 | |||
| LogicClipboardFragment fragment; // 校验成功后生成的剪贴板内容 | |||
| }; | |||
| // 剪贴板内容要粘贴到的网格位置 | |||
| struct LogicPasteTarget | |||
| { | |||
| std::string rungId; // 目标梯级 ID | |||
| int column = 0; // 目标条件列或输出列索引 | |||
| bool output = false; // 目标是否位于输出区 | |||
| bool boundary = false; // 目标是否位于列边界 | |||
| }; | |||
| // 粘贴操作结果和粘贴后应选中的对象 | |||
| struct LogicClipboardPasteResult | |||
| { | |||
| LogicEditorResult edit; // 粘贴编辑结果 | |||
| LogicSelectionDeleteRequest selection; // 普通对象模式下新对象的选择信息 | |||
| std::vector<std::string> wholeRungIds; // 整行模式下新梯级 ID | |||
| }; | |||
| // 连续编辑时的梯级和列位置 | |||
| struct LogicEditCursor | |||
| { | |||
| std::string rungId; // 光标所在梯级 ID | |||
| int column = 0; // 光标所在条件列或输出列 | |||
| bool output = false; // 光标是否处于输出区 | |||
| }; | |||
| // 编辑结果及下一次连续输入位置 | |||
| struct LogicEditResult | |||
| { | |||
| LogicEditorResult edit; // 本次编辑结果 | |||
| LogicEditCursor nextCursor; // 编辑成功后的下一光标位置 | |||
| }; | |||
| // 垂直画线结果及下一次连续画线位置 | |||
| struct LogicVerticalEditResult | |||
| { | |||
| LogicEditorResult edit; // 本次垂直连接编辑结果 | |||
| std::string nextRungId; // 下一段垂直连接的上端梯级 ID | |||
| int columnBoundary = -1; // 下一段连接继续使用的列边界 | |||
| bool changed = false; // 本次是否实际修改了连接 | |||
| }; | |||
| // 语法错误在逻辑网格中的定位信息 | |||
| struct LogicSyntaxLocation | |||
| { | |||
| std::string logicId; // 所属控制逻辑 ID | |||
| std::string rungId; // 所属梯级 ID | |||
| int network = 0; // 从 1 开始的网络编号 | |||
| int row = 0; // 从 1 开始的显示行号 | |||
| int column = 0; // 从 1 开始的显示列号 | |||
| }; | |||
| // 语法规整和检查的统计及错误结果 | |||
| struct LogicSyntaxCheckResult | |||
| { | |||
| bool completed = false; // 是否完成全部目标逻辑的检查 | |||
| bool valid = false; // 检查完成后逻辑是否有效 | |||
| bool changed = false; // 规整过程是否修改了工程 | |||
| std::size_t checkedLogicCount = 0U; // 已检查的控制逻辑数量 | |||
| std::size_t removedWireCells = 0U; // 规整时删除的无效水平线单元数 | |||
| std::size_t removedVerticalConnections = 0U; // 规整时删除的无效垂直连接数 | |||
| std::string message; // 检查结论或首个错误说明 | |||
| std::optional<LogicSyntaxLocation> location; // 首个语法错误的可选网格位置 | |||
| }; | |||
| // 连续网格的所有修改都经此服务原子提交,并进入同一份撤销历史 | |||
| class LogicEditorService | |||
| { | |||
| public: | |||
| /** | |||
| * @brief 创建梯形图编辑服务 | |||
| * @param project_service 用于读取和修改当前工程的项目服务 | |||
| */ | |||
| explicit LogicEditorService(ProjectService &project_service); | |||
| // 以下查询接口只读工程模型,供编辑器投影和属性面板使用 | |||
| /** @brief 按 ID 查找控制逻辑,未找到时返回空指针 */ | |||
| // 按 ID 查找只读控制逻辑,找不到时返回空指针 | |||
| const ControlLogic *findLogic(const std::string &logic_id) const; | |||
| /** @brief 在指定逻辑中按 ID 查找网络,未找到时返回空指针 */ | |||
| // 按逻辑和梯级 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 LadderRung *findNetworkHeadRung( | |||
| const std::string &logic_id, const std::string &rung_id) const; | |||
| // 按梯级和条件列查找只读网格单元 | |||
| const LadderCell *findCell( | |||
| const std::string &logic_id, | |||
| const std::string &rung_id, | |||
| const std::string &expression_id) const; | |||
| /** @brief 返回工程中第一个控制逻辑 ID,没有逻辑时返回空字符串 */ | |||
| int column) const; | |||
| // 按单元 ID 查找只读网格单元 | |||
| const LadderCell *findCell( | |||
| const std::string &logic_id, | |||
| const std::string &rung_id, | |||
| const std::string &cell_id) const; | |||
| // 按 ID 查找只读垂直连接 | |||
| const VerticalConnection *findConnection( | |||
| const std::string &logic_id, | |||
| const std::string &connection_id) const; | |||
| // 按 ID 查找条件或输出节点 | |||
| const LogicNode *findNode( | |||
| const std::string &logic_id, const std::string &node_id) const; | |||
| // 返回工程中第一个控制逻辑 ID | |||
| std::string firstLogicId() const; | |||
| /** @brief 返回指定逻辑中第一个网络 ID,没有网络或逻辑不存在时返回空字符串 */ | |||
| // 返回指定控制逻辑中的第一个梯级 ID | |||
| std::string firstRungId(const std::string &logic_id) const; | |||
| /** @brief 查找节点所属网络 ID,未找到时返回空字符串 */ | |||
| // 返回指定节点当前所在的梯级 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 在指定逻辑末尾添加空网络 */ | |||
| /** 规整并检查指定控制逻辑,规整改动作为一次撤销操作 */ | |||
| LogicSyntaxCheckResult checkSyntax(const std::string &logic_id); | |||
| /** 单独检查指定控制逻辑中的重复 M 线圈输出 */ | |||
| LogicSyntaxCheckResult checkDoubleCoils(const std::string &logic_id) const; | |||
| /** 运行前规整并检查工程中全部已启用控制逻辑 */ | |||
| LogicSyntaxCheckResult checkEnabledSyntax(); | |||
| // 在指定控制逻辑末尾添加空梯级 | |||
| LogicEditorResult addRung(const std::string &logic_id); | |||
| /** @brief 删除指定网络 */ | |||
| // 在参考梯级之前或之后插入空梯级 | |||
| LogicEditorResult insertRung( | |||
| const std::string &logic_id, | |||
| const std::string &reference_rung_id, | |||
| bool after); | |||
| // 删除指定梯级 | |||
| 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( | |||
| // 修改指定网络首行保存的网络注释 | |||
| LogicEditorResult updateNetworkComment( | |||
| const std::string &logic_id, | |||
| const std::string &rung_id, | |||
| const std::string &comment); | |||
| // 条件区编辑:串联、按列插入、横线和并联分支 | |||
| /** @brief 在网络条件末尾追加一个条件节点 */ | |||
| LogicEditorResult appendCondition( | |||
| // 在光标位置添加条件节点并返回下一编辑位置 | |||
| LogicEditResult applyConditionAndAdvance( | |||
| const std::string &logic_id, | |||
| const std::string &rung_id, | |||
| const LogicEditCursor &cursor, | |||
| const LogicNodeConfig &config, | |||
| bool configured = false); | |||
| /** | |||
| * @brief 按绝对条件列插入条件节点 | |||
| * @param column 从 0 开始的条件列号;插入位置必须位于允许的条件区 | |||
| */ | |||
| LogicEditorResult insertConditionAtColumn( | |||
| // 设置同一梯级内一段连续水平线的连接状态 | |||
| LogicEditorResult setHorizontalWireRange( | |||
| const std::string &logic_id, | |||
| const std::string &rung_id, | |||
| int column, | |||
| const LogicNodeConfig &config, | |||
| bool configured = false); | |||
| /** | |||
| * @brief 在直属并联分支的指定视觉列插入条件节点 | |||
| * | |||
| * 目标列可以是分支已有横线或 UI 投影出的补线格,服务会原子补齐必要横线 | |||
| */ | |||
| LogicEditorResult insertConditionInBranchAtColumn( | |||
| int first_column, | |||
| int last_column, | |||
| bool connected); | |||
| // 在光标位置添加水平线并返回下一编辑位置 | |||
| LogicEditResult applyWireAndAdvance( | |||
| 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 LogicEditCursor &cursor); | |||
| // 一次设置多个条件单元的水平线状态 | |||
| LogicEditorResult setWireCells( | |||
| const std::string &logic_id, | |||
| const std::string &rung_id, | |||
| int column_span = 1); | |||
| /** @brief 在目标节点后串联插入条件节点 */ | |||
| LogicEditorResult insertConditionAfter( | |||
| const std::vector<std::pair<std::string, int>> &cells, | |||
| bool connected); | |||
| // 清空多个条件区单元中的节点或水平线 | |||
| LogicEditorResult clearCells( | |||
| const std::string &logic_id, | |||
| const std::string &rung_id, | |||
| const std::string &target_node_id, | |||
| const LogicNodeConfig &config); | |||
| /** @brief 在目标表达式后串联插入横线 */ | |||
| LogicEditorResult insertWireAfter( | |||
| const std::vector<std::pair<std::string, int>> &cells); | |||
| // 设置相邻梯级在指定列边界上的垂直连接 | |||
| LogicEditorResult setVerticalConnection( | |||
| 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 &upper_rung_id, | |||
| const std::string &lower_rung_id, | |||
| int column_boundary, | |||
| bool connected); | |||
| // 从指定梯级添加一段垂直连接并返回下一段位置 | |||
| LogicVerticalEditResult applyVerticalConnectionAndAdvance( | |||
| 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 &upper_rung_id, | |||
| int column_boundary); | |||
| // 设置一段连续梯级范围内的垂直连接状态 | |||
| LogicEditorResult setVerticalConnectionRange( | |||
| const std::string &logic_id, | |||
| const std::string &rung_id, | |||
| const std::string &wire_expression_id, | |||
| int column_offset, | |||
| const LogicNodeConfig &config, | |||
| bool configured = false); | |||
| /** @brief 用条件节点替换断路表达式中的一个指定列单元格 */ | |||
| LogicEditorResult replaceGapColumnWithCondition( | |||
| const std::string &first_rung_id, | |||
| const std::string &last_rung_id, | |||
| int column_boundary, | |||
| bool connected); | |||
| // 一次删除多条垂直连接 | |||
| LogicEditorResult removeVerticalConnections( | |||
| 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::vector<std::string> &connection_ids); | |||
| // 原子删除选择中的单元、输出节点和垂直连接 | |||
| LogicEditorResult deleteSelection( | |||
| 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 | |||
| */ | |||
| const LogicSelectionDeleteRequest &selection); | |||
| // 为一组连续条件节点添加新的并联分支 | |||
| LogicEditorResult addParallelBranch( | |||
| const std::string &logic_id, | |||
| const std::string &rung_id, | |||
| const std::vector<std::string> &selected_node_ids, | |||
| 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 是否将新输出标记为已完成配置 | |||
| * | |||
| * 输出节点固定位于网络输出槽,替换已有输出时保持一次原子编辑 | |||
| */ | |||
| // 设置指定梯级第 11 列的输出节点 | |||
| LogicEditorResult setOutput( | |||
| const std::string &logic_id, | |||
| const std::string &rung_id, | |||
| const LogicNodeConfig &config, | |||
| bool configured = false); | |||
| // 节点属性和删除操作 | |||
| /** @brief 更新节点配置;条件节点和输出节点不能互相改型 */ | |||
| // 在光标所在网络设置输出并返回下一编辑位置 | |||
| LogicEditResult applyOutputAndAdvance( | |||
| const std::string &logic_id, | |||
| const LogicEditCursor &cursor, | |||
| const LogicNodeConfig &config, | |||
| bool configured = false); | |||
| // 在不改变节点类别的前提下更新节点配置 | |||
| 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,失败时整批回滚 | |||
| */ | |||
| // 校验当前选择并生成可复用的逻辑剪贴板片段 | |||
| LogicClipboardCopyResult copySelection( | |||
| const std::string &logic_id, | |||
| const LogicSelectionCopyRequest &selection) const; | |||
| // 将剪贴板片段原子粘贴到指定网格位置 | |||
| LogicClipboardPasteResult pasteClipboard( | |||
| const std::string &logic_id, | |||
| const LogicClipboardFragment &fragment, | |||
| const LogicPasteTarget &target); | |||
| // 从指定列开始粘贴一组连续条件节点 | |||
| LogicEditorResult pasteConditionNodes( | |||
| const std::string &logic_id, | |||
| const std::string &rung_id, | |||
| const std::vector<LogicNode> &nodes, | |||
| const LogicConditionPasteTarget &target = {}); | |||
| /** 判断所选条件是否位于同一串联层级并且视觉连续 */ | |||
| int start_column = -1); | |||
| // 判断给定节点是否位于同一梯级的连续条件单元中 | |||
| 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( | |||
| const ControlLogic &left, const ControlLogic &right); | |||
| // 比较两个梯级的单元、输出和网络属性 | |||
| static bool rungsEqual( | |||
| const LadderRung &left, const LadderRung &right); | |||
| static bool expressionsEqual( | |||
| const ConditionExpression &left, const ConditionExpression &right); | |||
| // 合并网络续行上的注释并检查同一网络的注释冲突 | |||
| static bool mergeNetworkComments( | |||
| ControlLogic *logic, std::string *error); | |||
| // 比较两个梯形图单元及其节点 | |||
| static bool cellsEqual( | |||
| const LadderCell &left, const LadderCell &right); | |||
| // 比较两个逻辑节点的 ID 和配置 | |||
| static bool nodesEqual(const LogicNode &left, const LogicNode &right); | |||
| // 比较两个节点配置变体的全部字段 | |||
| static bool configsEqual( | |||
| const LogicNodeConfig &left, const LogicNodeConfig &right); | |||
| // 创建撤销或重做不可执行时的统一失败结果 | |||
| static LogicEditorResult historyFailure(const std::string &message); | |||
| // 判断节点配置是否属于条件节点 | |||
| // 判断配置是否属于条件节点 | |||
| static bool isConditionConfig(const LogicNodeConfig &config); | |||
| // 判断节点配置是否属于输出节点 | |||
| // 判断配置是否属于输出节点 | |||
| static bool isOutputConfig(const LogicNodeConfig &config); | |||
| // 返回节点配置对应的稳定 ID 前缀 | |||
| static std::string nodePrefix(const LogicNodeConfig &config); | |||
| // 在一个控制逻辑的全部网络中生成唯一节点 ID | |||
| static std::string makeUniqueNodeId( | |||
| // 在控制逻辑内生成带指定前缀的唯一节点 ID | |||
| static std::string makeUniqueId( | |||
| 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 LadderRung makeEmptyRung( | |||
| const ControlLogic &logic, std::size_t visual_index); | |||
| // 在指定位置插入空梯级并维护相关垂直连接 | |||
| static std::string insertEmptyRungAt( | |||
| ControlLogic *logic, std::size_t position); | |||
| // 删除指定位置的梯级并重新连接可连续的垂直线 | |||
| static void removeRungAt(ControlLogic *logic, std::size_t position); | |||
| // 按当前显示顺序刷新全部梯级名称 | |||
| static void refreshRungNames(ControlLogic *logic); | |||
| // 创建统一的逻辑编辑失败结果 | |||
| static LogicEditorResult failure( | |||
| LogicEditorError error, const std::string &message); | |||
| // 在一个条件单元应用节点或连线并计算下一光标 | |||
| LogicEditResult applyCellAndAdvance( | |||
| const std::string &logic_id, | |||
| const LogicEditCursor &cursor, | |||
| const LogicNodeConfig *config, | |||
| bool configured); | |||
| // 规整并检查一组控制逻辑,任一失败时返回首个错误 | |||
| LogicSyntaxCheckResult checkSyntaxForLogics( | |||
| const std::vector<std::string> &logic_ids); | |||
| ProjectService &project_service_; // 不拥有的工程服务依赖 | |||
| EditorHistory<HistoryState> history_; // 当前编辑会话的撤销/重做历史 | |||
| bool suppress_history_ = false; // 复合粘贴期间由外层统一记录一次历史 | |||
| ProjectService &project_service_; // 非拥有的工程服务依赖 | |||
| EditorHistory<HistoryState> history_; // 当前逻辑编辑会话的撤销和重做历史 | |||
| }; | |||
| @@ -2,6 +2,7 @@ | |||
| #include <utility> | |||
| // 配置离线仓库和高精度定时器,并把超时信号连接到单轮扫描 | |||
| OfflineSimulationService::OfflineSimulationService( | |||
| VirtualRegisterRepository &repository, | |||
| VirtualRegisterRepository *initial_repository, | |||
| @@ -12,10 +13,12 @@ OfflineSimulationService::OfflineSimulationService( | |||
| { | |||
| timer_.setInterval(kDefaultScanIntervalMs); | |||
| timer_.setTimerType(Qt::PreciseTimer); | |||
| // 每次扫描定时器到期都调用统一入口执行一轮离线梯形图 | |||
| connect(&timer_, &QTimer::timeout, | |||
| this, &OfflineSimulationService::handleTimeout); | |||
| } | |||
| // 校验并保存逻辑快照后启动新的定时扫描会话 | |||
| SimulationStartResult OfflineSimulationService::start( | |||
| const std::vector<ControlLogic> &logics) | |||
| { | |||
| @@ -51,6 +54,7 @@ SimulationStartResult OfflineSimulationService::start( | |||
| return {true, SimulationStartError::None, {true, LogicScanError::None, {}, {}, {}, {}}}; | |||
| } | |||
| // 清空运行值并重新装载编辑态确认的离线初始值 | |||
| void OfflineSimulationService::restoreInitialValues() | |||
| { | |||
| repository_.clear(); | |||
| @@ -60,6 +64,7 @@ void OfflineSimulationService::restoreInitialValues() | |||
| } | |||
| } | |||
| // 停止扫描、清理运行状态并恢复离线初始值 | |||
| void OfflineSimulationService::stop() | |||
| { | |||
| timer_.stop(); | |||
| @@ -76,6 +81,7 @@ void OfflineSimulationService::stop() | |||
| emit stateChanged(); | |||
| } | |||
| // 立即执行一轮逻辑扫描并更新计数和运行轨迹 | |||
| LogicScanResult OfflineSimulationService::executeOnce() | |||
| { | |||
| if (state_ != SimulationState::Running) | |||
| @@ -100,36 +106,38 @@ LogicScanResult OfflineSimulationService::executeOnce() | |||
| return result; | |||
| } | |||
| // 返回当前离线仿真生命周期状态 | |||
| SimulationState OfflineSimulationService::state() const | |||
| { | |||
| return state_; | |||
| } | |||
| int OfflineSimulationService::scanIntervalMs() const | |||
| { | |||
| return timer_.interval(); | |||
| } | |||
| // 返回当前会话成功完成的扫描轮数 | |||
| std::uint64_t OfflineSimulationService::successfulScanCount() const | |||
| { | |||
| return successful_scan_count_; | |||
| } | |||
| // 返回最近一次扫描错误 | |||
| const LogicScanResult &OfflineSimulationService::lastError() const | |||
| { | |||
| return last_error_; | |||
| } | |||
| // 返回最近一轮扫描生成的逻辑运行轨迹 | |||
| const LogicTraceSnapshot &OfflineSimulationService::traceSnapshot() const | |||
| { | |||
| return trace_snapshot_; | |||
| } | |||
| // 响应定时器超时并执行一轮扫描 | |||
| void OfflineSimulationService::handleTimeout() | |||
| { | |||
| // 定时事件只负责触发,实际扫描和故障处理统一由 executeOnce 完成 | |||
| executeOnce(); | |||
| } | |||
| // 保存扫描错误、停止定时器并进入故障状态 | |||
| void OfflineSimulationService::enterFault(const LogicScanResult &error) | |||
| { | |||
| // 故障后停止定时器和运行时状态,保留错误及最后轨迹供 UI 诊断 | |||
| @@ -64,8 +64,6 @@ public: | |||
| // 返回当前离线仿真生命周期状态 | |||
| SimulationState state() const; | |||
| // 返回当前定时器扫描周期,单位为毫秒 | |||
| int scanIntervalMs() const; | |||
| // 返回本次会话成功完成的扫描轮数 | |||
| std::uint64_t successfulScanCount() const; | |||
| // 返回最近一次扫描错误;无错误时为成功结果 | |||
| @@ -5,11 +5,13 @@ | |||
| namespace { | |||
| // 构造成功的本地轨迹扫描结果 | |||
| LogicScanResult success() | |||
| { | |||
| return {true, LogicScanError::None, {}, {}, {}, {}}; | |||
| } | |||
| // 构造带具体寄存器地址的 PLC 缓存读取失败结果 | |||
| LogicScanResult snapshotFailure(const RegisterAddress &address) | |||
| { | |||
| return { | |||
| @@ -23,6 +25,7 @@ LogicScanResult snapshotFailure(const RegisterAddress &address) | |||
| } // namespace | |||
| // 绑定 PLC 读回缓存,所有本地输出会写入独立临时仓库 | |||
| OnlineLogicMonitorService::OnlineLogicMonitorService( | |||
| RegisterRepository &plc_repository, | |||
| QObject *parent) | |||
| @@ -31,6 +34,7 @@ OnlineLogicMonitorService::OnlineLogicMonitorService( | |||
| { | |||
| } | |||
| // 保存当前逻辑快照和引用地址,并启动真机本地轨迹会话 | |||
| OnlineLogicMonitorStartResult OnlineLogicMonitorService::start( | |||
| const std::vector<ControlLogic> &logics) | |||
| { | |||
| @@ -59,10 +63,7 @@ OnlineLogicMonitorStartResult OnlineLogicMonitorService::start( | |||
| for (const LadderRung &rung : logic.rungs) | |||
| { | |||
| std::vector<const LogicNode *> nodes; | |||
| if (rung.condition.has_value()) | |||
| { | |||
| collectConditionNodes(*rung.condition, &nodes); | |||
| } | |||
| collectConditionNodes(rung, &nodes); | |||
| if (rung.output.has_value()) | |||
| { | |||
| nodes.push_back(&*rung.output); | |||
| @@ -73,6 +74,7 @@ OnlineLogicMonitorStartResult OnlineLogicMonitorService::start( | |||
| } | |||
| } | |||
| } | |||
| // 固定按 M、D 区域和地址升序去重,保证每轮只复制实际引用值 | |||
| std::sort( | |||
| addresses.begin(), addresses.end(), | |||
| [](const RegisterAddress &left, const RegisterAddress &right) | |||
| @@ -93,6 +95,7 @@ OnlineLogicMonitorStartResult OnlineLogicMonitorService::start( | |||
| state_ = OnlineLogicMonitorState::Running; | |||
| emit stateChanged(); | |||
| // 启动时立即复制一次 PLC 缓存并扫描,确保首份轨迹真实可用 | |||
| const LogicScanResult first_scan = executeOnce(); | |||
| if (!first_scan.succeeded) | |||
| { | |||
| @@ -104,6 +107,7 @@ OnlineLogicMonitorStartResult OnlineLogicMonitorService::start( | |||
| return {true, OnlineLogicMonitorStartError::None, success()}; | |||
| } | |||
| // 停止本地轨迹推算并清除本次会话数据 | |||
| void OnlineLogicMonitorService::stop() | |||
| { | |||
| executor_.resetRuntime(); | |||
| @@ -121,6 +125,7 @@ void OnlineLogicMonitorService::stop() | |||
| emit stateChanged(); | |||
| } | |||
| // 用最新 PLC 缓存副本执行一轮只影响临时仓库的本地轨迹推算 | |||
| LogicScanResult OnlineLogicMonitorService::executeOnce() | |||
| { | |||
| if (state_ != OnlineLogicMonitorState::Running) | |||
| @@ -134,12 +139,14 @@ LogicScanResult OnlineLogicMonitorService::executeOnce() | |||
| {}}; | |||
| } | |||
| // 扫描前只复制梯形图实际引用的 M/D,绝不把 PLC 仓库直接交给执行器 | |||
| 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) | |||
| @@ -152,26 +159,31 @@ LogicScanResult OnlineLogicMonitorService::executeOnce() | |||
| 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_; | |||
| } | |||
| // 返回最近一轮按逻辑 ID 隔离的本地轨迹 | |||
| const LogicTraceSnapshot &OnlineLogicMonitorService::traceSnapshot() const | |||
| { | |||
| return trace_snapshot_; | |||
| } | |||
| // 将真实 PLC 读回缓存复制到本轮独立的临时虚拟仓库 | |||
| LogicScanResult OnlineLogicMonitorService::copyPlcSnapshot() | |||
| { | |||
| // 每轮从真实读回缓存重新开始,本地输出不会跨轮污染 PLC 数据 | |||
| @@ -200,6 +212,7 @@ LogicScanResult OnlineLogicMonitorService::copyPlcSnapshot() | |||
| return success(); | |||
| } | |||
| // 保存本地推算错误、清除边沿状态并进入故障态 | |||
| void OnlineLogicMonitorService::enterFault(const LogicScanResult &error) | |||
| { | |||
| executor_.resetRuntime(); | |||
| @@ -8,26 +8,29 @@ | |||
| #include <cstdint> | |||
| #include <vector> | |||
| // 真机本地轨迹推算的生命周期状态 | |||
| enum class OnlineLogicMonitorState | |||
| { | |||
| Stopped, | |||
| Running, | |||
| Faulted | |||
| Stopped, // 未启动或已主动停止 | |||
| Running, // 正在等待每轮 PLC 缓存更新后推算轨迹 | |||
| Faulted // 最近一次缓存复制或本地扫描失败 | |||
| }; | |||
| // 启动真机本地轨迹推算失败的原因 | |||
| enum class OnlineLogicMonitorStartError | |||
| { | |||
| None, | |||
| AlreadyRunning, | |||
| InvalidLogic, | |||
| PlcSnapshotUnavailable | |||
| None, // 启动成功 | |||
| AlreadyRunning, // 当前已经处于运行态 | |||
| InvalidLogic, // 启动前梯形图校验失败 | |||
| PlcSnapshotUnavailable // 首次 PLC 缓存快照不可用 | |||
| }; | |||
| // 真机本地轨迹启动结果,detail 保存具体校验或扫描错误 | |||
| struct OnlineLogicMonitorStartResult | |||
| { | |||
| bool succeeded = false; | |||
| OnlineLogicMonitorStartError error = OnlineLogicMonitorStartError::None; | |||
| LogicScanResult detail; | |||
| bool succeeded = false; // 是否成功启动并完成首次轨迹推算 | |||
| OnlineLogicMonitorStartError error = OnlineLogicMonitorStartError::None; // 失败分类 | |||
| LogicScanResult detail; // 执行器返回的详细错误和定位信息 | |||
| }; | |||
| // 使用 PLC 读回缓存推算本地梯形图轨迹,所有输出只写临时仓库 | |||
| @@ -36,35 +39,46 @@ class OnlineLogicMonitorService final : public QObject | |||
| Q_OBJECT | |||
| public: | |||
| // 绑定只读 PLC 缓存仓库,不直接持有或控制通信连接 | |||
| explicit OnlineLogicMonitorService( | |||
| RegisterRepository &plc_repository, | |||
| QObject *parent = nullptr); | |||
| // 校验并保存逻辑快照,随后立即使用 PLC 缓存推算首份轨迹 | |||
| OnlineLogicMonitorStartResult start(const std::vector<ControlLogic> &logics); | |||
| // 停止推算并清除会话快照和轨迹 | |||
| void stop(); | |||
| // 复制最新 PLC 缓存并在临时仓库执行一轮本地扫描 | |||
| LogicScanResult executeOnce(); | |||
| // 返回当前真机本地轨迹生命周期状态 | |||
| OnlineLogicMonitorState state() const; | |||
| // 返回当前会话成功完成的轨迹推算轮数 | |||
| std::uint64_t successfulScanCount() const; | |||
| // 返回最近一次推算错误;主动停止不会清除故障详情,下一次 start 会重置 | |||
| const LogicScanResult &lastError() const; | |||
| // 返回最近一轮按逻辑 ID 隔离的本地轨迹 | |||
| const LogicTraceSnapshot &traceSnapshot() const; | |||
| signals: | |||
| // 生命周期状态发生变化时通知 UI | |||
| void stateChanged(); | |||
| // 一轮本地轨迹推算成功后通知 UI | |||
| void scanCompleted(); | |||
| private: | |||
| // 将梯形图实际引用的 PLC 缓存值复制到临时仓库 | |||
| 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_; | |||
| RegisterRepository &plc_repository_; // 不拥有的 PLC 读回缓存仓库 | |||
| 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_; // 最近一轮本地推算轨迹 | |||
| }; | |||
| @@ -1,6 +1,7 @@ | |||
| #pragma once | |||
| #include "domain/register_address.h" | |||
| #include "domain/register_value_type.h" | |||
| #include "domain/project_limits.h" | |||
| #include <algorithm> | |||
| @@ -153,20 +154,16 @@ public: | |||
| const std::vector<RegisterAddress> &addresses) = 0; | |||
| /** | |||
| * @brief 设置轮询地址并标记必须成对读取的 Float32 起始地址 | |||
| * @brief 设置轮询地址并标记不能跨读块拆分的多字范围 | |||
| * @param addresses 需要周期性读回的 M/D 地址集合 | |||
| * @param float32_starts D 区 Float32 起始地址;每个地址必须和下一个 D 一起读取 | |||
| * @param multi_word_ranges Int32、Float32 或 Double 占用的连续 D 范围 | |||
| * @return true 表示集合已接受 | |||
| * | |||
| * 默认实现兼容只关心地址集合的测试网关和旧调用方;真实 Modbus 实现会使用成对信息避免拆分 Float32 | |||
| * 默认实现兼容只关心地址集合的测试网关和旧调用方;真实 Modbus 实现会避免拆分任何多字值 | |||
| */ | |||
| virtual PlcCommunicationResult setPollAddresses( | |||
| const std::vector<RegisterAddress> &addresses, | |||
| const std::vector<RegisterAddress> &float32_starts) | |||
| { | |||
| (void)float32_starts; | |||
| return setPollAddresses(addresses); | |||
| } | |||
| const std::vector<RegisterWordRange> &multi_word_ranges) = 0; | |||
| // 返回当前连接生命周期状态;Connected 不代表首读资格已经完成 | |||
| virtual PlcConnectionState state() const = 0; | |||
| @@ -14,6 +14,7 @@ struct SerialFrame | |||
| int stopBits = 1; | |||
| }; | |||
| // 判断波特率是否属于项目允许的固定集合 | |||
| bool isSupportedBaudRate(int baud_rate) | |||
| { | |||
| return baud_rate == 9600 | |||
| @@ -23,6 +24,7 @@ bool isSupportedBaudRate(int baud_rate) | |||
| || baud_rate == 115200; | |||
| } | |||
| // 判断数据位、校验位和停止位组合是否受支持 | |||
| bool isSupportedFrame(const SerialFrame &frame) | |||
| { | |||
| return (frame.dataBits == 7 || frame.dataBits == 8) | |||
| @@ -30,12 +32,14 @@ bool isSupportedFrame(const SerialFrame &frame) | |||
| && (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) | |||
| { | |||
| @@ -49,6 +53,7 @@ void appendUnique(std::vector<Value> *values, Value value, Equals equals) | |||
| } // namespace | |||
| // 优先保留界面当前配置,再生成全部受支持的串口搜索组合 | |||
| std::vector<PlcSerialConfiguration> buildPlcDiscoveryCandidates( | |||
| const PlcSerialConfiguration &preferred, | |||
| const std::vector<std::string> &available_ports) | |||
| @@ -70,6 +75,7 @@ std::vector<PlcSerialConfiguration> buildPlcDiscoveryCandidates( | |||
| ports.begin(), ports.end(), preferred.portName); | |||
| if (preferred_port != ports.end()) | |||
| { | |||
| // rotate 只把首选端口移到开头,其余端口仍保持系统枚举顺序 | |||
| std::rotate(ports.begin(), preferred_port, preferred_port + 1); | |||
| } | |||
| @@ -106,6 +112,7 @@ std::vector<PlcSerialConfiguration> buildPlcDiscoveryCandidates( | |||
| std::vector<PlcSerialConfiguration> candidates; | |||
| candidates.reserve(ports.size() * baud_rates.size() * frames.size()); | |||
| // 端口放在最内层,让同一帧和波特率下优先快速遍历全部可用端口 | |||
| for (const SerialFrame &frame : frames) | |||
| { | |||
| for (int baud_rate : baud_rates) | |||
| @@ -10,18 +10,18 @@ | |||
| // 自动搜索正在尝试的串口参数和总体进度 | |||
| struct PlcDiscoveryProgress | |||
| { | |||
| PlcSerialConfiguration configuration; | |||
| std::size_t currentAttempt = 0; | |||
| std::size_t totalAttempts = 0; | |||
| PlcSerialConfiguration configuration; // 当前正在尝试的串口配置 | |||
| std::size_t currentAttempt = 0; // 当前尝试序号,从 1 开始 | |||
| std::size_t totalAttempts = 0; // 本次搜索需要尝试的组合总数 | |||
| }; | |||
| // 自动搜索结束结果;found 和 cancelled 不会同时为 true | |||
| struct PlcDiscoveryOutcome | |||
| { | |||
| bool found = false; | |||
| bool cancelled = false; | |||
| PlcSerialConfiguration configuration; | |||
| std::string message; | |||
| bool found = false; // 是否找到可响应的 PLC 配置 | |||
| bool cancelled = false; // 是否由用户主动取消搜索 | |||
| PlcSerialConfiguration configuration; // 搜索成功时匹配的串口配置 | |||
| std::string message; // 搜索结果或失败原因 | |||
| }; | |||
| /** | |||
| @@ -24,11 +24,7 @@ std::string generateProjectId() | |||
| } // namespace | |||
| ProjectService::ProjectService(ProjectStorage &storage) | |||
| : ProjectService(storage, defaultProjectLimitSettings()) | |||
| { | |||
| } | |||
| // 绑定工程存储和数量上限,并创建默认未命名工程 | |||
| ProjectService::ProjectService( | |||
| ProjectStorage &storage, | |||
| const ProjectLimitSettings &project_limits) | |||
| @@ -38,22 +34,20 @@ ProjectService::ProjectService( | |||
| { | |||
| } | |||
| // 返回当前工程的只读视图 | |||
| const Project &ProjectService::project() const | |||
| { | |||
| return project_; | |||
| } | |||
| // 标记工程已修改并返回可编辑引用 | |||
| Project &ProjectService::editProject() | |||
| { | |||
| modified_ = true; | |||
| return project_; | |||
| } | |||
| const std::string &ProjectService::currentFilePath() const | |||
| { | |||
| return current_file_path_; | |||
| } | |||
| // 返回当前工程是否已经关联文件路径 | |||
| bool ProjectService::hasCurrentFile() const | |||
| { | |||
| return !current_file_path_.empty(); | |||
| @@ -65,16 +59,19 @@ bool ProjectService::isModified() const | |||
| return modified_; | |||
| } | |||
| // 返回应用启动时确定的工程数量上限 | |||
| const ProjectLimitSettings &ProjectService::projectLimits() const | |||
| { | |||
| return project_limits_; | |||
| } | |||
| // 恢复调用方进入编辑操作前保存的修改状态 | |||
| void ProjectService::restoreModifiedState(bool modified) | |||
| { | |||
| modified_ = modified; | |||
| } | |||
| // 校验名称后创建未保存的新工程 | |||
| ProjectOperationResult ProjectService::createNewProject(const std::string &name) | |||
| { | |||
| if (isBlank(name)) | |||
| @@ -92,6 +89,7 @@ ProjectOperationResult ProjectService::createNewProject(const std::string &name) | |||
| return {true, ProjectServiceError::None, ProjectStorageError::None, {}}; | |||
| } | |||
| // 将当前工程保存到已经关联的文件路径 | |||
| ProjectOperationResult ProjectService::save() | |||
| { | |||
| if (!hasCurrentFile()) | |||
| @@ -105,6 +103,7 @@ ProjectOperationResult ProjectService::save() | |||
| return saveAs(current_file_path_); | |||
| } | |||
| // 校验当前工程并保存到指定文件路径 | |||
| ProjectOperationResult ProjectService::saveAs(const std::string &file_path) | |||
| { | |||
| if (isBlank(file_path)) | |||
| @@ -137,6 +136,7 @@ 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)) | |||
| @@ -162,6 +162,41 @@ ProjectOperationResult ProjectService::exportAs(const std::string &file_path) co | |||
| return {true, ProjectServiceError::None, ProjectStorageError::None, {}}; | |||
| } | |||
| // 复制当前工程并移除梯形图和地址备注,再保存为运行版临时工程 | |||
| ProjectOperationResult ProjectService::exportHmiRuntimeAs( | |||
| const std::string &file_path) const | |||
| { | |||
| if (isBlank(file_path)) | |||
| { | |||
| return {false, | |||
| ProjectServiceError::FilePathRequired, | |||
| ProjectStorageError::None, | |||
| "必须指定工程文件路径"}; | |||
| } | |||
| // 用户运行版只保存 HMI、报警和页面导航需要的工程内容 | |||
| Project runtime_project = project_; | |||
| runtime_project.controlLogics.clear(); | |||
| runtime_project.registerComments.clear(); | |||
| std::string validation_error; | |||
| // 保存前确认裁剪后的 HMI、报警和页面跳转可以独立运行 | |||
| if (!runtime_project.validateForRunning(project_limits_, &validation_error)) | |||
| { | |||
| return {false, | |||
| ProjectServiceError::InvalidProject, | |||
| ProjectStorageError::InvalidProject, | |||
| validation_error}; | |||
| } | |||
| // 将裁剪结果写成临时 JSON,下一步会把它追加到运行版 EXE 尾部 | |||
| const ProjectSaveResult result = storage_.save(runtime_project, file_path); | |||
| if (!result.succeeded) | |||
| { | |||
| return storageFailure(result.error, result.message); | |||
| } | |||
| return {true, ProjectServiceError::None, ProjectStorageError::None, {}}; | |||
| } | |||
| // 从 JSON 文件加载并校验工程,封装运行版启动时也复用这条入口 | |||
| ProjectOperationResult ProjectService::load(const std::string &file_path) | |||
| { | |||
| if (isBlank(file_path)) | |||
| @@ -202,7 +237,7 @@ Project ProjectService::makeNewProject(const std::string &name) | |||
| project.metadata.id = generateProjectId(); | |||
| project.metadata.name = name; | |||
| // 新工程固定使用当前存储格式版本 | |||
| project.metadata.formatVersion = "1.0"; | |||
| project.metadata.formatVersion = "4.0"; | |||
| return project; | |||
| } | |||
| @@ -27,8 +27,7 @@ struct ProjectOperationResult | |||
| class ProjectService | |||
| { | |||
| public: | |||
| // 使用外部提供的工程存储实现创建服务 | |||
| explicit ProjectService(ProjectStorage &storage); | |||
| // 使用外部提供的工程存储实现和启动配置创建服务 | |||
| ProjectService( | |||
| ProjectStorage &storage, | |||
| const ProjectLimitSettings &project_limits); | |||
| @@ -37,9 +36,11 @@ public: | |||
| 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); | |||
| @@ -65,6 +66,8 @@ public: | |||
| ProjectOperationResult saveAs(const std::string &file_path); | |||
| /** 将当前工程导出到指定 JSON,不改变当前工程文件关联 */ | |||
| ProjectOperationResult exportAs(const std::string &file_path) const; | |||
| /** 将仅包含 HMI 运行所需内容的工程快照导出到指定 JSON */ | |||
| ProjectOperationResult exportHmiRuntimeAs(const std::string &file_path) const; | |||
| /** | |||
| * @brief 从指定文件加载工程 | |||
| @@ -74,8 +77,11 @@ public: | |||
| ProjectOperationResult load(const std::string &file_path); | |||
| private: | |||
| // 创建带唯一 ID 和当前格式版本的空白工程 | |||
| static Project makeNewProject(const std::string &name); | |||
| // 判断字符串是否为空或只包含空白字符 | |||
| static bool isBlank(const std::string &value); | |||
| // 将存储层错误统一转换为工程服务失败结果 | |||
| static ProjectOperationResult storageFailure( | |||
| ProjectStorageError error, const std::string &message); | |||
| @@ -8,6 +8,7 @@ | |||
| namespace { | |||
| // 去除注释文本首尾空白 | |||
| std::string trim(const std::string &value) | |||
| { | |||
| const auto first = std::find_if_not( | |||
| @@ -19,6 +20,7 @@ std::string trim(const std::string &value) | |||
| return first >= last ? std::string{} : std::string(first, last); | |||
| } | |||
| // 构造统一的注释操作失败结果 | |||
| RegisterCommentResult failure( | |||
| RegisterCommentError error, const std::string &message) | |||
| { | |||
| @@ -27,22 +29,26 @@ RegisterCommentResult failure( | |||
| } // namespace | |||
| // 创建寄存器注释服务并绑定工程服务 | |||
| RegisterCommentService::RegisterCommentService(ProjectService &project_service) | |||
| : project_service_(project_service) | |||
| { | |||
| } | |||
| // 返回当前工程中的全部寄存器注释 | |||
| const std::vector<RegisterComment> &RegisterCommentService::comments() const | |||
| { | |||
| return project_service_.project().registerComments; | |||
| } | |||
| // 按地址查找寄存器注释 | |||
| const RegisterComment *RegisterCommentService::findComment( | |||
| const RegisterAddress &address) const | |||
| { | |||
| return project_service_.project().findRegisterComment(address); | |||
| } | |||
| // 新建或更新指定地址的寄存器注释 | |||
| RegisterCommentResult RegisterCommentService::setComment( | |||
| const RegisterAddress &address, const std::string &text) | |||
| { | |||
| @@ -82,6 +88,7 @@ RegisterCommentResult RegisterCommentService::setComment( | |||
| return {true, RegisterCommentError::None, {}}; | |||
| } | |||
| // 删除指定地址的寄存器注释 | |||
| RegisterCommentResult RegisterCommentService::removeComment( | |||
| const RegisterAddress &address) | |||
| { | |||
| @@ -10,25 +10,27 @@ class ProjectService; | |||
| // 工程级寄存器注释操作的失败分类 | |||
| enum class RegisterCommentError | |||
| { | |||
| None, | |||
| InvalidComment, | |||
| NotFound | |||
| None, // 操作成功 | |||
| InvalidComment, // 地址或注释文本不符合领域规则 | |||
| NotFound // 删除时没有找到指定地址的注释 | |||
| }; | |||
| // 注释编辑结果 | |||
| struct RegisterCommentResult | |||
| { | |||
| bool succeeded = false; | |||
| RegisterCommentError error = RegisterCommentError::None; | |||
| std::string message; | |||
| bool succeeded = false; // 注释编辑是否成功 | |||
| RegisterCommentError error = RegisterCommentError::None; // 失败分类 | |||
| 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; | |||
| @@ -39,5 +41,6 @@ public: | |||
| RegisterCommentResult removeComment(const RegisterAddress &address); | |||
| private: | |||
| /** 提供当前工程读写能力,不由本服务拥有 */ | |||
| ProjectService &project_service_; | |||
| }; | |||
| @@ -9,6 +9,7 @@ | |||
| namespace { | |||
| // 将地址解析错误转换为用户可读提示 | |||
| std::string parseErrorMessage(RegisterAddressParseError error) | |||
| { | |||
| switch (error) | |||
| @@ -28,28 +29,139 @@ std::string parseErrorMessage(RegisterAddressParseError error) | |||
| } // namespace | |||
| // 创建自由监控服务并绑定当前运行模式的寄存器仓库 | |||
| RegisterMonitorService::RegisterMonitorService(RegisterRepository &repository) | |||
| : repository_(repository) | |||
| { | |||
| } | |||
| // 配置编辑态离线初始值仓库 | |||
| void RegisterMonitorService::setOfflineInitialRepository( | |||
| VirtualRegisterRepository &repository) | |||
| { | |||
| offline_initial_repository_ = &repository; | |||
| } | |||
| // 设置编辑态写入是否同步捕获到离线初始值仓库 | |||
| void RegisterMonitorService::setOfflineInitialCaptureEnabled(bool enabled) | |||
| { | |||
| capture_offline_initial_values_ = enabled; | |||
| } | |||
| // 配置编辑态可访问的 PLC 缓存仓库 | |||
| void RegisterMonitorService::setEditingPlcRepository( | |||
| RegisterRepository &repository) | |||
| { | |||
| editing_plc_repository_ = &repository; | |||
| } | |||
| // 设置当前是否由编辑态数据监控访问仓库 | |||
| void RegisterMonitorService::setEditingModeActive(bool active) | |||
| { | |||
| editing_mode_active_ = active; | |||
| } | |||
| // 更新编辑态 PLC 连接和首读状态 | |||
| void RegisterMonitorService::setEditingPlcState( | |||
| bool connected, bool initial_read_completed) | |||
| { | |||
| editing_plc_connected_ = connected; | |||
| editing_plc_ready_ = connected && initial_read_completed; | |||
| } | |||
| // 切换编辑态数据监控使用的离线或 PLC 数据源 | |||
| EditingMonitorSourceResult RegisterMonitorService::selectEditingSource( | |||
| EditingMonitorSource source) | |||
| { | |||
| if (source == EditingMonitorSource::Offline) | |||
| { | |||
| editing_source_ = source; | |||
| return {true, {}}; | |||
| } | |||
| if (!editing_mode_active_) | |||
| { | |||
| return {false, "只有编辑态数据监控可以选择真机 PLC M/D"}; | |||
| } | |||
| if (editing_plc_repository_ == nullptr) | |||
| { | |||
| return {false, "PLC 数据源尚未配置,无法切换到真机 M/D"}; | |||
| } | |||
| if (!editing_plc_connected_) | |||
| { | |||
| return {false, "PLC 未连接或连接已断开,无法切换到真机 M/D"}; | |||
| } | |||
| if (!editing_plc_ready_) | |||
| { | |||
| return {false, "PLC 尚未完成首次读取,请稍后再切换到真机 M/D"}; | |||
| } | |||
| editing_source_ = source; | |||
| return {true, {}}; | |||
| } | |||
| // 返回当前选择的编辑态数据源 | |||
| EditingMonitorSource RegisterMonitorService::editingSource() const | |||
| { | |||
| return editing_source_; | |||
| } | |||
| // 返回编辑态 PLC 是否已连接 | |||
| bool RegisterMonitorService::editingPlcConnected() const | |||
| { | |||
| return editing_plc_connected_ && editing_plc_repository_ != nullptr; | |||
| } | |||
| // 返回编辑态 PLC 是否已完成首读 | |||
| bool RegisterMonitorService::editingPlcReady() const | |||
| { | |||
| return editingPlcConnected() && editing_plc_ready_; | |||
| } | |||
| // 判断当前数据源选择是否请求访问编辑态 PLC | |||
| bool RegisterMonitorService::editingPlcAccessRequested() const | |||
| { | |||
| return editing_mode_active_ && editing_source_ == EditingMonitorSource::Plc; | |||
| } | |||
| // 判断当前是否可以使用编辑态 PLC 仓库 | |||
| bool RegisterMonitorService::usesEditingPlcRepository() const | |||
| { | |||
| return editingPlcAccessRequested() && editing_plc_repository_ != nullptr; | |||
| } | |||
| // 返回编辑态 PLC 不可用时的提示文字 | |||
| std::string RegisterMonitorService::editingPlcUnavailableMessage() const | |||
| { | |||
| if (editing_plc_repository_ == nullptr) | |||
| { | |||
| return "PLC 数据源尚未配置"; | |||
| } | |||
| if (!editing_plc_connected_) | |||
| { | |||
| return "PLC 未连接或连接已断开"; | |||
| } | |||
| return "PLC 尚未完成首次读取"; | |||
| } | |||
| // 返回当前数据源对应的可写寄存器仓库 | |||
| RegisterRepository &RegisterMonitorService::accessRepository() | |||
| { | |||
| return usesEditingPlcRepository() ? *editing_plc_repository_ : repository_; | |||
| } | |||
| // 返回当前数据源对应的只读寄存器仓库 | |||
| const RegisterRepository &RegisterMonitorService::accessRepository() const | |||
| { | |||
| return usesEditingPlcRepository() ? *editing_plc_repository_ : repository_; | |||
| } | |||
| // 添加使用默认数据类型的连续监控地址 | |||
| 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) | |||
| { | |||
| @@ -60,10 +172,20 @@ RegisterMonitorResult RegisterMonitorService::addRange( | |||
| parseErrorMessage(parsed.error), 0}; | |||
| } | |||
| if (parsed.address.area() == RegisterArea::M | |||
| && data_type == RegisterDataType::Float32) | |||
| && data_type != RegisterDataType::Int16) | |||
| { | |||
| return {false, RegisterMonitorError::InvalidAddress, | |||
| "M 区只支持位监控,Int32、Float32 和 Double 只允许使用 D 区", 0}; | |||
| } | |||
| if (parsed.address.area() == RegisterArea::D | |||
| && !registerDataTypeAddressIsValid(data_type, parsed.address)) | |||
| { | |||
| return {false, RegisterMonitorError::InvalidAddress, | |||
| "Float32 只允许使用 D 区地址", 0}; | |||
| data_type == RegisterDataType::Float64 | |||
| ? "Double 起始地址必须是 D0~D3996 范围内的偶数地址" | |||
| : std::string(registerDataTypeDescriptor(data_type).displayName) | |||
| + " 起始地址超出可用 D 区范围", | |||
| 0}; | |||
| } | |||
| const int step = registerDataTypeWordCount(data_type); | |||
| if (count < 1 | |||
| @@ -73,7 +195,7 @@ RegisterMonitorResult RegisterMonitorService::addRange( | |||
| "连续监控范围不能超过地址 4000", 0}; | |||
| } | |||
| std::vector<RegisterAddress> pending; | |||
| std::vector<MonitorPoint> pending; | |||
| pending.reserve(static_cast<std::size_t>(count)); | |||
| for (int offset = 0; offset < count; ++offset) | |||
| { | |||
| @@ -83,7 +205,7 @@ RegisterMonitorResult RegisterMonitorService::addRange( | |||
| data_type}; | |||
| if (!model_.contains(point)) | |||
| { | |||
| pending.push_back(point.address); | |||
| pending.push_back(point); | |||
| } | |||
| } | |||
| if (pending.empty()) | |||
| @@ -95,9 +217,21 @@ RegisterMonitorResult RegisterMonitorService::addRange( | |||
| return {false, RegisterMonitorError::LimitExceeded, | |||
| "自由监控最多允许 64 个地址", 0}; | |||
| } | |||
| for (const RegisterAddress &address : pending) | |||
| std::vector<MonitorPoint> candidate = model_.points(); | |||
| candidate.insert(candidate.end(), pending.cbegin(), pending.cend()); | |||
| if (poll_configuration_validator_) | |||
| { | |||
| model_.add(MonitorPoint{address, data_type}); | |||
| const std::string validation_error = poll_configuration_validator_( | |||
| pollAddressesForPoints(candidate), multiWordRangesForPoints(candidate)); | |||
| if (!validation_error.empty()) | |||
| { | |||
| return {false, RegisterMonitorError::PollConfigurationRejected, | |||
| validation_error, 0}; | |||
| } | |||
| } | |||
| for (const MonitorPoint &point : pending) | |||
| { | |||
| model_.add(point); | |||
| } | |||
| if (addresses_changed_callback_) | |||
| { | |||
| @@ -108,13 +242,16 @@ RegisterMonitorResult RegisterMonitorService::addRange( | |||
| { | |||
| const int existing_end = existing.address.index() | |||
| + registerDataTypeWordCount(existing.dataType) - 1; | |||
| for (const RegisterAddress &address : pending) | |||
| for (const MonitorPoint &pending_point : pending) | |||
| { | |||
| const int pending_end = address.index() + step - 1; | |||
| const RegisterAddress &address = pending_point.address; | |||
| const int pending_end = address.index() | |||
| + registerDataTypeWordCount(pending_point.dataType) - 1; | |||
| if (existing.address.area() == address.area() | |||
| && existing.address.index() <= pending_end | |||
| && address.index() <= existing_end | |||
| && !(existing.address == address && existing.dataType == data_type)) | |||
| && !(existing.address == address | |||
| && existing.dataType == pending_point.dataType)) | |||
| { | |||
| overlaps = true; | |||
| } | |||
| @@ -125,6 +262,7 @@ RegisterMonitorResult RegisterMonitorService::addRange( | |||
| static_cast<int>(pending.size())}; | |||
| } | |||
| // 删除一批监控地址 | |||
| RegisterMonitorResult RegisterMonitorService::remove( | |||
| const std::vector<RegisterAddress> &addresses) | |||
| { | |||
| @@ -147,6 +285,7 @@ RegisterMonitorResult RegisterMonitorService::remove( | |||
| return {true, RegisterMonitorError::None, {}, removed_count}; | |||
| } | |||
| // 删除一批带数据类型的监控点 | |||
| RegisterMonitorResult RegisterMonitorService::remove( | |||
| const std::vector<MonitorPoint> &points) | |||
| { | |||
| @@ -169,6 +308,7 @@ RegisterMonitorResult RegisterMonitorService::remove( | |||
| return {true, RegisterMonitorError::None, {}, removed_count}; | |||
| } | |||
| // 清空当前会话的监控列表 | |||
| RegisterMonitorResult RegisterMonitorService::clear() | |||
| { | |||
| if (model_.addresses().empty()) | |||
| @@ -184,10 +324,16 @@ RegisterMonitorResult RegisterMonitorService::clear() | |||
| return {true, RegisterMonitorError::None, {}, removed_count}; | |||
| } | |||
| // 向当前数据源提交 M 位写入 | |||
| RegisterMonitorWriteResult RegisterMonitorService::writeBit( | |||
| const RegisterAddress &address, bool value) | |||
| { | |||
| const RegisterWriteResult result = repository_.writeBit(address, value); | |||
| if (editingPlcAccessRequested() && !editingPlcReady()) | |||
| { | |||
| return {false, RegisterError::Unavailable, | |||
| editingPlcUnavailableMessage()}; | |||
| } | |||
| const RegisterWriteResult result = accessRepository().writeBit(address, value); | |||
| const RegisterMonitorWriteResult active_result = result.succeeded | |||
| ? RegisterMonitorWriteResult{true, RegisterError::None, {}} | |||
| : RegisterMonitorWriteResult{false, result.error, result.error == RegisterError::AreaMismatch | |||
| @@ -200,45 +346,55 @@ RegisterMonitorWriteResult RegisterMonitorService::writeBit( | |||
| return captureInitialBit(address, value, active_result); | |||
| } | |||
| RegisterMonitorWriteResult RegisterMonitorService::writeWord( | |||
| const RegisterAddress &address, std::int16_t value) | |||
| { | |||
| const RegisterWriteResult result = repository_.writeWord(address, value); | |||
| 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) | |||
| // 向当前数据源提交 D 数值写入 | |||
| RegisterMonitorWriteResult RegisterMonitorService::writeNumeric( | |||
| const RegisterAddress &address, RegisterDataType data_type, double value) | |||
| { | |||
| if (!std::isfinite(value) || !address.isValid() | |||
| || address.area() != RegisterArea::D | |||
| || address.index() >= RegisterAddress::kMaximumIndex) | |||
| if (editingPlcAccessRequested() && !editingPlcReady()) | |||
| { | |||
| return {false, RegisterError::InvalidAddress, "Float32 写入值或地址无效"}; | |||
| return {false, RegisterError::Unavailable, | |||
| editingPlcUnavailableMessage()}; | |||
| } | |||
| const RegisterWriteResult result = repository_.writeWordPair( | |||
| address, Float32Codec::encode(value)); | |||
| if (address.isValid() && address.area() != RegisterArea::D) | |||
| { | |||
| return {false, RegisterError::AreaMismatch, | |||
| "数值写入只允许访问 D 区"}; | |||
| } | |||
| if (!registerDataTypeAddressIsValid(data_type, address)) | |||
| { | |||
| return {false, RegisterError::InvalidAddress, | |||
| std::string(registerDataTypeDescriptor(data_type).displayName) | |||
| + " 写入地址无效"}; | |||
| } | |||
| const std::optional<std::vector<std::int16_t>> encoded = | |||
| encodeRegisterNumericValue(data_type, value); | |||
| if (!encoded.has_value()) | |||
| { | |||
| return {false, RegisterError::InvalidAddress, | |||
| std::string(registerDataTypeDescriptor(data_type).displayName) | |||
| + " 写入值无效"}; | |||
| } | |||
| const RegisterWriteResult result = accessRepository().writeWords(address, *encoded); | |||
| 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{false, result.error, | |||
| result.error == RegisterError::AreaMismatch | |||
| ? "数值写入只允许访问 D 区" | |||
| : result.error == RegisterError::InvalidAddress | |||
| ? "写入地址无效" | |||
| : result.error == RegisterError::Unavailable | |||
| ? "当前寄存器源不可用" | |||
| : "寄存器写入请求被拒绝"}; | |||
| return captureInitialWords(address, *encoded, active_result); | |||
| } | |||
| // 将 M 位写入同步捕获到离线初始值仓库 | |||
| RegisterMonitorWriteResult RegisterMonitorService::captureInitialBit( | |||
| const RegisterAddress &address, bool value, | |||
| const RegisterMonitorWriteResult &active_result) | |||
| { | |||
| if (!active_result.succeeded || !capture_offline_initial_values_ | |||
| if (!active_result.succeeded || usesEditingPlcRepository() | |||
| || !capture_offline_initial_values_ | |||
| || offline_initial_repository_ == nullptr) | |||
| { | |||
| return active_result; | |||
| @@ -247,38 +403,29 @@ RegisterMonitorWriteResult RegisterMonitorService::captureInitialBit( | |||
| 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, | |||
| // 将连续 D 字写入同步捕获到离线初始值仓库 | |||
| RegisterMonitorWriteResult RegisterMonitorService::captureInitialWords( | |||
| const RegisterAddress &address, | |||
| const std::vector<std::int16_t> &values, | |||
| const RegisterMonitorWriteResult &active_result) | |||
| { | |||
| if (!active_result.succeeded || !capture_offline_initial_values_ | |||
| if (!active_result.succeeded || usesEditingPlcRepository() | |||
| || !capture_offline_initial_values_ | |||
| || offline_initial_repository_ == nullptr) | |||
| { | |||
| return active_result; | |||
| } | |||
| offline_initial_repository_->writeWordPair( | |||
| address, Float32Codec::encode(value)); | |||
| offline_initial_repository_->writeWords(address, values); | |||
| return active_result; | |||
| } | |||
| // 返回当前会话的监控地址 | |||
| const std::vector<RegisterAddress> &RegisterMonitorService::addresses() const | |||
| { | |||
| return model_.addresses(); | |||
| } | |||
| // 读取监控点当前值并生成界面展示模型 | |||
| std::vector<MonitorValue> RegisterMonitorService::values(bool communication_fault) const | |||
| { | |||
| std::vector<MonitorValue> result; | |||
| @@ -291,9 +438,15 @@ std::vector<MonitorValue> RegisterMonitorService::values(bool communication_faul | |||
| value.dataType = point.dataType; | |||
| value.endAddress = address.index() | |||
| + registerDataTypeWordCount(point.dataType) - 1; | |||
| if (editingPlcAccessRequested() && !editingPlcReady()) | |||
| { | |||
| value.state = MonitorValueState::Unavailable; | |||
| result.push_back(value); | |||
| continue; | |||
| } | |||
| if (address.area() == RegisterArea::M) | |||
| { | |||
| const BitReadResult read = repository_.readBit(address); | |||
| const BitReadResult read = accessRepository().readBit(address); | |||
| value.bitValue = read.value; | |||
| value.state = read.succeeded | |||
| ? (communication_fault ? MonitorValueState::CommunicationFault | |||
| @@ -302,42 +455,43 @@ std::vector<MonitorValue> RegisterMonitorService::values(bool communication_faul | |||
| } | |||
| else | |||
| { | |||
| if (point.dataType == RegisterDataType::Float32) | |||
| const WordsReadResult read = accessRepository().readWords( | |||
| address, registerDataTypeWordCount(point.dataType)); | |||
| const std::optional<RegisterNumericValue> decoded = read.succeeded | |||
| ? decodeRegisterNumericValue(point.dataType, read.values) | |||
| : std::nullopt; | |||
| if (decoded.has_value()) | |||
| { | |||
| 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; | |||
| value.numericValue = *decoded; | |||
| } | |||
| else | |||
| { | |||
| const WordReadResult read = repository_.readWord(address); | |||
| value.wordValue = read.value; | |||
| value.state = read.succeeded | |||
| value.state = decoded.has_value() | |||
| ? (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 | |||
| { | |||
| return pollAddressesForPoints(model_.points()); | |||
| } | |||
| // 从监控点展开轮询地址并去重 | |||
| std::vector<RegisterAddress> RegisterMonitorService::pollAddressesForPoints( | |||
| const std::vector<MonitorPoint> &points) | |||
| { | |||
| std::vector<RegisterAddress> result; | |||
| for (const MonitorPoint &point : model_.points()) | |||
| for (const MonitorPoint &point : points) | |||
| { | |||
| for (int offset = 0; offset < registerDataTypeWordCount(point.dataType); ++offset) | |||
| { | |||
| @@ -348,20 +502,39 @@ std::vector<RegisterAddress> RegisterMonitorService::pollAddresses() const | |||
| return result; | |||
| } | |||
| std::vector<RegisterAddress> RegisterMonitorService::float32Starts() const | |||
| // 返回当前监控点需要保持连续的多字范围 | |||
| std::vector<RegisterWordRange> RegisterMonitorService::multiWordRanges() const | |||
| { | |||
| std::vector<RegisterAddress> result; | |||
| for (const MonitorPoint &point : model_.points()) | |||
| return multiWordRangesForPoints(model_.points()); | |||
| } | |||
| // 从多字监控点提取连续范围并合并重叠区间 | |||
| std::vector<RegisterWordRange> RegisterMonitorService::multiWordRangesForPoints( | |||
| const std::vector<MonitorPoint> &points) | |||
| { | |||
| std::vector<RegisterWordRange> result; | |||
| for (const MonitorPoint &point : points) | |||
| { | |||
| if (point.dataType == RegisterDataType::Float32) | |||
| const int word_count = registerDataTypeWordCount(point.dataType); | |||
| if (point.address.area() == RegisterArea::D && word_count > 1) | |||
| { | |||
| result.push_back(point.address); | |||
| result.push_back({point.address, word_count}); | |||
| } | |||
| } | |||
| return result; | |||
| } | |||
| // 设置监控地址变化通知回调 | |||
| void RegisterMonitorService::setAddressesChangedCallback(std::function<void()> callback) | |||
| { | |||
| addresses_changed_callback_ = std::move(callback); | |||
| } | |||
| // 设置 PLC 轮询资源校验回调 | |||
| void RegisterMonitorService::setPollConfigurationValidator( | |||
| std::function<std::string( | |||
| const std::vector<RegisterAddress> &, | |||
| const std::vector<RegisterWordRange> &)> validator) | |||
| { | |||
| poll_configuration_validator_ = std::move(validator); | |||
| } | |||
| @@ -17,6 +17,7 @@ enum class RegisterMonitorError | |||
| InvalidAddress, // 起始地址格式或区域不支持 | |||
| RangeOverflow, // 连续范围超出 M/D 地址上限 | |||
| LimitExceeded, // 监控地址数量超过会话上限 | |||
| PollConfigurationRejected, // 新增后超出 PLC 轮询资源边界 | |||
| NoChange // 操作没有产生任何列表变化 | |||
| }; | |||
| @@ -37,10 +38,24 @@ struct RegisterMonitorWriteResult | |||
| std::string message; // 面向用户的 UTF-8 错误说明 | |||
| }; | |||
| // 编辑态数据监控由用户明确选择离线初始值或真实 PLC | |||
| enum class EditingMonitorSource | |||
| { | |||
| Offline, // 当前进程内的离线初始值 | |||
| Plc // 真实 PLC 的最新读回缓存 | |||
| }; | |||
| // 编辑态数据源切换结果;真机不满足连接和首读条件时拒绝切换 | |||
| struct EditingMonitorSourceResult | |||
| { | |||
| bool succeeded = false; // 数据源是否切换成功 | |||
| std::string message; // 切换失败时的用户提示 | |||
| }; | |||
| /** | |||
| * @brief 编排自由监控地址列表和寄存器仓库读写 | |||
| * | |||
| * 地址列表和离线初始值只属于当前运行会话,不写入工程文件;仓库由当前运行模式注入 | |||
| * 地址列表和离线初始值只属于当前运行会话,不写入工程文件;编辑态数据源由用户明确选择 | |||
| */ | |||
| class RegisterMonitorService | |||
| { | |||
| @@ -53,8 +68,22 @@ public: | |||
| // 配置编辑态离线初始值仓库;该仓库只保存在当前进程内 | |||
| void setOfflineInitialRepository(VirtualRegisterRepository &repository); | |||
| // 编辑态开启时,监控写入同时更新离线初始值 | |||
| // 编辑态使用虚拟仓库时,监控写入同时更新离线初始值 | |||
| void setOfflineInitialCaptureEnabled(bool enabled); | |||
| // 配置编辑态可直接访问的 PLC 缓存仓库 | |||
| void setEditingPlcRepository(RegisterRepository &repository); | |||
| // 标记当前是否由编辑态数据监控访问仓库;运行态继续使用活动仓库 | |||
| void setEditingModeActive(bool active); | |||
| // 更新编辑态 PLC 连接和首读状态 | |||
| void setEditingPlcState(bool connected, bool initial_read_completed); | |||
| // 请求切换编辑态数据监控来源;切到真机前必须连接并完成首读 | |||
| EditingMonitorSourceResult selectEditingSource(EditingMonitorSource source); | |||
| // 返回用户当前选择的数据源;连接故障不会静默改回离线 | |||
| EditingMonitorSource editingSource() const; | |||
| // 编辑态 PLC 当前是否已经建立连接 | |||
| bool editingPlcConnected() const; | |||
| // 编辑态 PLC 是否已完成首轮读取,可执行写入 | |||
| bool editingPlcReady() const; | |||
| /** | |||
| * @brief 从起始地址连续添加同一区域的多个地址 | |||
| @@ -63,6 +92,7 @@ public: | |||
| * @return 去重后实际新增数量;格式、范围、容量或无变化时返回失败 | |||
| */ | |||
| RegisterMonitorResult addRange(const std::string &start_address, int count); | |||
| /** 使用指定数据类型连续添加同一区域的多个地址 */ | |||
| RegisterMonitorResult addRange( | |||
| const std::string &start_address, int count, RegisterDataType data_type); | |||
| /** | |||
| @@ -71,6 +101,7 @@ public: | |||
| * @return 实际删除数量,并在发生变化时通知轮询集合更新 | |||
| */ | |||
| RegisterMonitorResult remove(const std::vector<RegisterAddress> &addresses); | |||
| /** 删除一批带数据类型的监控点 */ | |||
| RegisterMonitorResult remove(const std::vector<MonitorPoint> &points); | |||
| /** | |||
| * @brief 清空当前会话的监控地址列表 | |||
| @@ -90,18 +121,19 @@ public: | |||
| * @param value 要写入的 D 字值 | |||
| * @return 成功或包含底层 RegisterError 和用户可读文本的失败结果 | |||
| */ | |||
| RegisterMonitorWriteResult writeWord( | |||
| const RegisterAddress &address, std::int16_t value); | |||
| RegisterMonitorWriteResult writeFloat( | |||
| const RegisterAddress &address, float value); | |||
| RegisterMonitorWriteResult writeNumeric( | |||
| const RegisterAddress &address, RegisterDataType data_type, double value); | |||
| /** | |||
| * @brief 返回当前会话的监控地址列表 | |||
| * @return 按服务内部顺序保存的只读地址列表 | |||
| */ | |||
| const std::vector<RegisterAddress> &addresses() const; | |||
| /** 返回当前会话的监控点及其数据类型 */ | |||
| const std::vector<MonitorPoint> &points() const; | |||
| /** 返回供 PLC 轮询的地址集合 */ | |||
| std::vector<RegisterAddress> pollAddresses() const; | |||
| std::vector<RegisterAddress> float32Starts() const; | |||
| /** 返回供 PLC 轮询使用的连续多字范围 */ | |||
| std::vector<RegisterWordRange> multiWordRanges() const; | |||
| /** | |||
| * @brief 读取监控列表的当前值并生成 UI 展示模型 | |||
| * @param communication_fault 通信处于故障态时,将成功读回值标记为通信故障 | |||
| @@ -113,21 +145,58 @@ public: | |||
| * @param callback 地址列表新增、删除或清空后调用;可传空函数取消通知 | |||
| */ | |||
| void setAddressesChangedCallback(std::function<void()> callback); | |||
| /** 设置新增监控点前调用的 PLC 轮询资源校验器 */ | |||
| void setPollConfigurationValidator( | |||
| std::function<std::string( | |||
| const std::vector<RegisterAddress> &, | |||
| const std::vector<RegisterWordRange> &)> validator); | |||
| private: | |||
| /** 返回当前编辑态数据源对应的实际仓库 */ | |||
| RegisterRepository &accessRepository(); | |||
| /** 返回当前编辑态数据源对应的只读仓库 */ | |||
| const RegisterRepository &accessRepository() const; | |||
| /** 判断当前是否请求访问编辑态 PLC 数据 */ | |||
| bool editingPlcAccessRequested() const; | |||
| /** 判断当前是否可以使用编辑态 PLC 仓库 */ | |||
| bool usesEditingPlcRepository() const; | |||
| /** 返回编辑态 PLC 不可用时的提示文字 */ | |||
| std::string editingPlcUnavailableMessage() const; | |||
| /** 将 M 位写入同步捕获到离线初始值仓库 */ | |||
| 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, | |||
| /** 将连续 D 字写入同步捕获到离线初始值仓库 */ | |||
| RegisterMonitorWriteResult captureInitialWords( | |||
| const RegisterAddress &address, | |||
| const std::vector<std::int16_t> &values, | |||
| const RegisterMonitorWriteResult &active_result); | |||
| /** 从监控点展开 PLC 轮询地址 */ | |||
| static std::vector<RegisterAddress> pollAddressesForPoints( | |||
| const std::vector<MonitorPoint> &points); | |||
| /** 从多字监控点提取连续轮询范围 */ | |||
| static std::vector<RegisterWordRange> multiWordRangesForPoints( | |||
| const std::vector<MonitorPoint> &points); | |||
| RegisterRepository &repository_; // 当前运行模式的寄存器仓库,不由服务拥有 | |||
| /** 编辑态直接读取的 PLC 缓存仓库,不由服务拥有 */ | |||
| RegisterRepository *editing_plc_repository_ = nullptr; | |||
| /** 当前是否处于编辑态数据监控 */ | |||
| bool editing_mode_active_ = false; | |||
| /** 用户当前选择的编辑态数据源 */ | |||
| EditingMonitorSource editing_source_ = EditingMonitorSource::Offline; | |||
| /** 编辑态 PLC 当前是否连接 */ | |||
| bool editing_plc_connected_ = false; | |||
| /** 编辑态 PLC 当前是否完成首读 */ | |||
| bool editing_plc_ready_ = false; | |||
| /** 当前会话的离线初始值仓库,不由服务拥有 */ | |||
| VirtualRegisterRepository *offline_initial_repository_ = nullptr; | |||
| /** 编辑态写入是否同步保存为离线初始值 */ | |||
| bool capture_offline_initial_values_ = false; | |||
| RegisterMonitorModel model_; // 当前会话的去重监控地址列表 | |||
| std::function<void()> addresses_changed_callback_; // 地址变化后的轮询刷新通知 | |||
| /** 新监控点提交前使用的 PLC 轮询资源校验器 */ | |||
| std::function<std::string( | |||
| const std::vector<RegisterAddress> &, | |||
| const std::vector<RegisterWordRange> &)> poll_configuration_validator_; | |||
| }; | |||
| @@ -13,17 +13,22 @@ | |||
| #include "domain/project_limits.h" | |||
| #include <algorithm> | |||
| #include <utility> | |||
| // 创建运行模式服务并绑定工程、仿真和真机轨迹服务 | |||
| RuntimeModeService::RuntimeModeService( | |||
| const ProjectService &project_service, | |||
| LogicEditorService &logic_editor_service, | |||
| OfflineSimulationService &offline_simulation_service, | |||
| OnlineLogicMonitorService &online_logic_monitor_service) | |||
| : project_service_(project_service), | |||
| logic_editor_service_(logic_editor_service), | |||
| offline_simulation_service_(offline_simulation_service), | |||
| online_logic_monitor_service_(online_logic_monitor_service) | |||
| { | |||
| } | |||
| // 解除 PLC 状态回调绑定 | |||
| RuntimeModeService::~RuntimeModeService() | |||
| { | |||
| if (plc_gateway_ != nullptr) | |||
| @@ -32,16 +37,19 @@ RuntimeModeService::~RuntimeModeService() | |||
| } | |||
| } | |||
| // 返回当前应用运行模式 | |||
| ApplicationMode RuntimeModeService::mode() const | |||
| { | |||
| return state_.mode(); | |||
| } | |||
| // 返回当前模式允许的编辑和数据访问策略 | |||
| ModePolicy RuntimeModeService::policy() const | |||
| { | |||
| return state_.policy(); | |||
| } | |||
| // 退出运行态并返回编辑态 | |||
| ModeTransitionResult RuntimeModeService::enterEditing() | |||
| { | |||
| if (state_.mode() == ApplicationMode::OfflineRunning) | |||
| @@ -51,32 +59,38 @@ ModeTransitionResult RuntimeModeService::enterEditing() | |||
| } | |||
| else if (state_.mode() == ApplicationMode::OnlineRunning) | |||
| { | |||
| // 返回编辑态前停止使用 PLC 缓存副本推算本地轨迹 | |||
| online_logic_monitor_service_.stop(); | |||
| } | |||
| // 执行领域状态机切换,成功后才允许恢复编辑数据源 | |||
| const ModeTransitionResult result = state_.enterEditing(); | |||
| if (result.succeeded && active_repository_ != nullptr | |||
| && virtual_repository_ != nullptr) | |||
| { | |||
| // 编辑态恢复虚拟 M/D,避免后续界面继续读写真机缓存 | |||
| active_repository_->use(*virtual_repository_); | |||
| } | |||
| return result; | |||
| } | |||
| // 从编辑态进入离线运行态 | |||
| ModeTransitionResult RuntimeModeService::enterOfflineRunning() | |||
| { | |||
| if (state_.mode() != ApplicationMode::Editing) | |||
| { | |||
| // 非编辑态的重复离线请求直接交给状态机判断是否合法 | |||
| return state_.enterOfflineRunning(); | |||
| } | |||
| std::string error; | |||
| if (!project_service_.project().validateForRunning( | |||
| project_service_.projectLimits(), &error)) | |||
| // 启动离线扫描前先规整梯形图并检查整个工程能否运行 | |||
| const ModeTransitionResult preparation = prepareProjectForRunning(); | |||
| if (!preparation.succeeded) | |||
| { | |||
| return {false, ModeTransitionError::ProjectNotReady}; | |||
| return preparation; | |||
| } | |||
| // 离线仿真必须先切到虚拟仓库,避免扫描结果写入 PLC 缓存 | |||
| if (active_repository_ != nullptr && virtual_repository_ != nullptr) | |||
| { | |||
| // HMI、报警和监控接下来统一读取离线虚拟 M/D | |||
| active_repository_->use(*virtual_repository_); | |||
| } | |||
| // start() 会复制逻辑快照并做运行前校验;失败时不进入运行态 | |||
| @@ -84,8 +98,9 @@ ModeTransitionResult RuntimeModeService::enterOfflineRunning() | |||
| project_service_.project().controlLogics); | |||
| if (!start_result.succeeded) | |||
| { | |||
| return {false, ModeTransitionError::SimulationStartFailed}; | |||
| return {false, ModeTransitionError::SimulationStartFailed, {}}; | |||
| } | |||
| // 离线扫描启动成功后再让状态机正式进入离线运行态 | |||
| const ModeTransitionResult transition = state_.enterOfflineRunning(); | |||
| if (!transition.succeeded) | |||
| { | |||
| @@ -94,41 +109,76 @@ ModeTransitionResult RuntimeModeService::enterOfflineRunning() | |||
| return transition; | |||
| } | |||
| // 首读和工程校验通过后进入真机运行,并将活动数据源切到 PLC 缓存 | |||
| // 从编辑态进入真机运行态 | |||
| ModeTransitionResult RuntimeModeService::enterOnlineRunning() | |||
| { | |||
| if (state_.mode() != ApplicationMode::Editing) | |||
| { | |||
| // 非编辑态的重复真机请求由状态机结合当前首读状态判断 | |||
| return state_.enterOnlineRunning(initialPlcReadCompleted()); | |||
| } | |||
| if (!initialPlcReadCompleted()) | |||
| { | |||
| // PLC 尚未完整首读时让状态机返回明确的进入失败原因 | |||
| return state_.enterOnlineRunning(false); | |||
| } | |||
| const OnlineLogicMonitorStartResult start_result = | |||
| online_logic_monitor_service_.start( | |||
| project_service_.project().controlLogics); | |||
| if (!start_result.succeeded) | |||
| // 首读完成后仍要检查工程,避免用非法逻辑生成误导性的真机轨迹 | |||
| const ModeTransitionResult preparation = prepareProjectForRunning(); | |||
| if (!preparation.succeeded) | |||
| { | |||
| return preparation; | |||
| } | |||
| if (!hmi_only_runtime_) | |||
| { | |||
| return {false, ModeTransitionError::SimulationStartFailed}; | |||
| // 普通编程器保存逻辑快照并启动 PLC 缓存副本上的轨迹推算 | |||
| 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) | |||
| { | |||
| // 真机 HMI 和自由监控统一改为读取 PLC 轮询缓存 | |||
| active_repository_->use(*plc_repository_); | |||
| } | |||
| else if (!result.succeeded) | |||
| { | |||
| online_logic_monitor_service_.stop(); | |||
| if (!hmi_only_runtime_) | |||
| { | |||
| online_logic_monitor_service_.stop(); | |||
| } | |||
| } | |||
| return result; | |||
| } | |||
| // 标记 HMI 专用运行版,并从启动起固定使用 PLC 缓存数据源 | |||
| // 设置用户运行版是否跳过本地梯形图执行器 | |||
| void RuntimeModeService::setHmiOnlyRuntime(bool enabled) | |||
| { | |||
| hmi_only_runtime_ = enabled; | |||
| if (enabled && active_repository_ != nullptr && plc_repository_ != nullptr) | |||
| { | |||
| // 运行版从启动起只读取 PLC 缓存,首读前显示不可用值而不是虚拟值 | |||
| active_repository_->use(*plc_repository_); | |||
| } | |||
| } | |||
| // 记录当前 PLC 连接是否已经完整读回全部运行地址 | |||
| // 更新 PLC 首读完成状态并通知模式观察者 | |||
| void RuntimeModeService::setInitialPlcReadCompleted(bool completed) | |||
| { | |||
| // 通信服务完成有效读回后才允许把此标志设为 true | |||
| initial_plc_read_completed_ = completed; | |||
| } | |||
| // 只有本地标记、网关状态和通信服务首读结果都有效时才确认首读完成 | |||
| // 返回 PLC 是否已完成首读 | |||
| bool RuntimeModeService::initialPlcReadCompleted() const | |||
| { | |||
| return initial_plc_read_completed_ | |||
| @@ -137,31 +187,80 @@ bool RuntimeModeService::initialPlcReadCompleted() const | |||
| && plc_gateway_->initialReadCompleted(); | |||
| } | |||
| // 返回离线仿真当前状态 | |||
| SimulationState RuntimeModeService::simulationState() const | |||
| { | |||
| return offline_simulation_service_.state(); | |||
| } | |||
| // 返回离线仿真成功扫描轮数 | |||
| std::uint64_t RuntimeModeService::successfulScanCount() const | |||
| { | |||
| return offline_simulation_service_.successfulScanCount(); | |||
| } | |||
| // 返回离线仿真最近一次错误 | |||
| const LogicScanResult &RuntimeModeService::simulationError() const | |||
| { | |||
| return offline_simulation_service_.lastError(); | |||
| } | |||
| // 返回离线仿真服务引用 | |||
| OfflineSimulationService &RuntimeModeService::offlineSimulationService() | |||
| { | |||
| return offline_simulation_service_; | |||
| } | |||
| // 返回真机只读轨迹服务引用 | |||
| OnlineLogicMonitorService &RuntimeModeService::onlineLogicMonitorService() | |||
| { | |||
| return online_logic_monitor_service_; | |||
| } | |||
| // 返回最近一次运行前语法检查结果 | |||
| const LogicSyntaxCheckResult &RuntimeModeService::lastSyntaxCheck() const | |||
| { | |||
| return last_syntax_check_; | |||
| } | |||
| // 按普通编程器或 HMI 运行版规则执行进入运行态前的工程校验 | |||
| // 执行运行前工程校验和梯形图规整 | |||
| ModeTransitionResult RuntimeModeService::prepareProjectForRunning() | |||
| { | |||
| if (!hmi_only_runtime_) | |||
| { | |||
| last_syntax_check_ = logic_editor_service_.checkEnabledSyntax(); | |||
| if (!last_syntax_check_.completed || !last_syntax_check_.valid) | |||
| { | |||
| return { | |||
| false, | |||
| ModeTransitionError::ProjectNotReady, | |||
| last_syntax_check_.message}; | |||
| } | |||
| } | |||
| else | |||
| { | |||
| // HMI 运行版没有梯形图,保留成功结果供公共诊断接口读取 | |||
| last_syntax_check_ = {}; | |||
| last_syntax_check_.completed = true; | |||
| last_syntax_check_.valid = true; | |||
| } | |||
| Project project = project_service_.project(); | |||
| if (hmi_only_runtime_) | |||
| { | |||
| project.controlLogics.clear(); | |||
| project.registerComments.clear(); | |||
| } | |||
| std::string error; | |||
| if (!project.validateForRunning(project_service_.projectLimits(), &error)) | |||
| { | |||
| return {false, ModeTransitionError::ProjectNotReady, std::move(error)}; | |||
| } | |||
| return {true, ModeTransitionError::None, {}}; | |||
| } | |||
| // 接入 PLC 网关和三套寄存器入口,并建立通信状态回调 | |||
| // 注入 PLC 网关、活动仓库和两种数据源 | |||
| void RuntimeModeService::configurePlc( | |||
| PlcCommunicationGateway &gateway, | |||
| ActiveRegisterRepository &active_repository, | |||
| @@ -172,7 +271,7 @@ void RuntimeModeService::configurePlc( | |||
| active_repository_ = &active_repository; | |||
| virtual_repository_ = &virtual_repository; | |||
| plc_repository_ = &plc_repository; | |||
| // 通信故障撤销首读资格;若正在真机运行,立即回到编辑态 | |||
| // 通信故障撤销首读资格;普通编程器回到编辑态,HMI 运行版保留 PLC 缓存画面 | |||
| gateway.setCallbacks( | |||
| [this] | |||
| { | |||
| @@ -183,7 +282,8 @@ void RuntimeModeService::configurePlc( | |||
| || plc_state == PlcConnectionState::Faulted) | |||
| { | |||
| setInitialPlcReadCompleted(false); | |||
| if (state_.mode() == ApplicationMode::OnlineRunning) | |||
| if (state_.mode() == ApplicationMode::OnlineRunning | |||
| && !hmi_only_runtime_) | |||
| { | |||
| enterEditing(); | |||
| } | |||
| @@ -206,9 +306,11 @@ void RuntimeModeService::configurePlc( | |||
| [this] | |||
| { | |||
| if (state_.mode() == ApplicationMode::OnlineRunning | |||
| && !hmi_only_runtime_ | |||
| && online_logic_monitor_service_.state() | |||
| == OnlineLogicMonitorState::Running) | |||
| { | |||
| // 每轮 PLC 地址全部读完后,用最新缓存副本重新推算一次本地轨迹 | |||
| const LogicScanResult result = | |||
| online_logic_monitor_service_.executeOnce(); | |||
| if (!result.succeeded | |||
| @@ -231,6 +333,8 @@ void RuntimeModeService::configurePlc( | |||
| }); | |||
| } | |||
| // 汇总最新轮询地址、撤销旧首读资格并启动 PLC 异步连接 | |||
| // 连接 PLC 并开始异步通信 | |||
| PlcCommunicationResult RuntimeModeService::connectPlc( | |||
| const PlcSerialConfiguration &configuration) | |||
| { | |||
| @@ -242,30 +346,44 @@ PlcCommunicationResult RuntimeModeService::connectPlc( | |||
| { | |||
| plc_gateway_->disconnectDevice(); | |||
| } | |||
| // 连接前按当前工程重新收集 HMI、报警、监控和逻辑引用地址 | |||
| const PlcCommunicationResult poll_result = refreshPlcPollAddresses(); | |||
| if (!poll_result.succeeded) | |||
| { | |||
| return poll_result; | |||
| } | |||
| setInitialPlcReadCompleted(false); | |||
| // 将串口配置交给通信服务,开始异步打开串口并连接 PLC | |||
| return plc_gateway_->connectDevice(configuration); | |||
| } | |||
| void RuntimeModeService::setMonitorAddresses( | |||
| // 设置自由监控地址并刷新 PLC 轮询集合 | |||
| PlcCommunicationResult RuntimeModeService::setMonitorAddresses( | |||
| const std::vector<RegisterAddress> &addresses) | |||
| { | |||
| setMonitorAddresses(addresses, {}); | |||
| return setMonitorAddresses(addresses, {}); | |||
| } | |||
| void RuntimeModeService::setMonitorAddresses( | |||
| // 设置自由监控地址和多字范围并刷新 PLC 轮询集合 | |||
| PlcCommunicationResult RuntimeModeService::setMonitorAddresses( | |||
| const std::vector<RegisterAddress> &addresses, | |||
| const std::vector<RegisterAddress> &float32_starts) | |||
| const std::vector<RegisterWordRange> &multi_word_ranges) | |||
| { | |||
| const std::vector<RegisterAddress> previous_addresses = monitor_addresses_; | |||
| const std::vector<RegisterWordRange> previous_ranges = monitor_multi_word_ranges_; | |||
| monitor_addresses_ = addresses; | |||
| monitor_float32_starts_ = float32_starts; | |||
| refreshPlcPollAddresses(); | |||
| monitor_multi_word_ranges_ = multi_word_ranges; | |||
| const PlcCommunicationResult result = refreshPlcPollAddresses(); | |||
| if (!result.succeeded) | |||
| { | |||
| monitor_addresses_ = previous_addresses; | |||
| monitor_multi_word_ranges_ = previous_ranges; | |||
| } | |||
| return result; | |||
| } | |||
| // 汇总监控、HMI、报警和梯形图引用,生成 PLC 实际轮询地址集合 | |||
| // 汇总工程和自由监控地址后更新 PLC 轮询配置 | |||
| PlcCommunicationResult RuntimeModeService::refreshPlcPollAddresses() | |||
| { | |||
| if (plc_gateway_ == nullptr) | |||
| @@ -274,7 +392,7 @@ PlcCommunicationResult RuntimeModeService::refreshPlcPollAddresses() | |||
| } | |||
| // 轮询集合来自自由监控、HMI、报警和梯形图引用的并集 | |||
| std::vector<RegisterAddress> addresses = monitor_addresses_; | |||
| std::vector<RegisterAddress> float32_starts = monitor_float32_starts_; | |||
| std::vector<RegisterWordRange> multi_word_ranges = monitor_multi_word_ranges_; | |||
| const Project &project = project_service_.project(); | |||
| for (const HmiPage &page : project.hmiPages) | |||
| { | |||
| @@ -284,13 +402,47 @@ PlcCommunicationResult RuntimeModeService::refreshPlcPollAddresses() | |||
| { | |||
| addresses.push_back(*control.binding); | |||
| if ((control.type == HmiControlType::NumericDisplay | |||
| || control.type == HmiControlType::NumericInput) | |||
| && control.dataType == RegisterDataType::Float32 | |||
| || control.type == HmiControlType::NumericInput | |||
| || control.type == HmiControlType::StatusText) | |||
| && control.binding->area() == RegisterArea::D) | |||
| { | |||
| addresses.push_back(RegisterAddress{ | |||
| RegisterArea::D, control.binding->index() + 1}); | |||
| float32_starts.push_back(*control.binding); | |||
| const int word_count = registerDataTypeWordCount(control.dataType); | |||
| for (int offset = 1; offset < word_count; ++offset) | |||
| { | |||
| addresses.push_back(RegisterAddress{ | |||
| RegisterArea::D, control.binding->index() + offset}); | |||
| } | |||
| if (word_count > 1) | |||
| { | |||
| multi_word_ranges.push_back({*control.binding, word_count}); | |||
| } | |||
| } | |||
| } | |||
| if (control.type == HmiControlType::Button | |||
| && control.buttonEnableCondition.has_value()) | |||
| { | |||
| if (const auto *bit = std::get_if< | |||
| HmiButtonBitEnableCondition>( | |||
| &*control.buttonEnableCondition)) | |||
| { | |||
| addresses.push_back(bit->address); | |||
| } | |||
| else if (const auto *word = std::get_if< | |||
| HmiButtonWordEnableCondition>( | |||
| &*control.buttonEnableCondition)) | |||
| { | |||
| addresses.push_back(word->address); | |||
| const int word_count = registerDataTypeWordCount( | |||
| word->dataType); | |||
| for (int offset = 1; offset < word_count; ++offset) | |||
| { | |||
| addresses.push_back(RegisterAddress{ | |||
| RegisterArea::D, word->address.index() + offset}); | |||
| } | |||
| if (word_count > 1) | |||
| { | |||
| multi_word_ranges.push_back({word->address, word_count}); | |||
| } | |||
| } | |||
| } | |||
| } | |||
| @@ -304,10 +456,7 @@ PlcCommunicationResult RuntimeModeService::refreshPlcPollAddresses() | |||
| for (const LadderRung &rung : logic.rungs) | |||
| { | |||
| std::vector<const LogicNode *> nodes; | |||
| if (rung.condition.has_value()) | |||
| { | |||
| collectConditionNodes(*rung.condition, &nodes); | |||
| } | |||
| collectConditionNodes(rung, &nodes); | |||
| if (rung.output.has_value()) | |||
| { | |||
| nodes.push_back(&*rung.output); | |||
| @@ -339,30 +488,11 @@ PlcCommunicationResult RuntimeModeService::refreshPlcPollAddresses() | |||
| { | |||
| return {false, "PLC 轮询的去重 M/D 地址最多为 256 个"}; | |||
| } | |||
| 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); | |||
| // 只把工程实际引用且去重后的 M/D 交给通信层分块轮询 | |||
| return plc_gateway_->setPollAddresses(addresses, multi_word_ranges); | |||
| } | |||
| // 断开 PLC 并清除真机首读资格 | |||
| void RuntimeModeService::disconnectPlc() | |||
| { | |||
| if (state_.mode() == ApplicationMode::OnlineRunning) | |||
| @@ -376,18 +506,21 @@ void RuntimeModeService::disconnectPlc() | |||
| setInitialPlcReadCompleted(false); | |||
| } | |||
| // 返回当前 PLC 连接状态 | |||
| PlcConnectionState RuntimeModeService::plcConnectionState() const | |||
| { | |||
| return plc_gateway_ == nullptr | |||
| ? PlcConnectionState::Disconnected : plc_gateway_->state(); | |||
| } | |||
| // 返回最近一次 PLC 通信错误 | |||
| const std::string &RuntimeModeService::plcError() const | |||
| { | |||
| static const std::string empty; | |||
| return plc_gateway_ == nullptr ? empty : plc_gateway_->lastError(); | |||
| } | |||
| // 设置 PLC 状态变化通知回调 | |||
| void RuntimeModeService::setPlcStatusChangedCallback(std::function<void()> callback) | |||
| { | |||
| plc_status_changed_callback_ = std::move(callback); | |||
| @@ -9,6 +9,7 @@ | |||
| #pragma once | |||
| #include "domain/runtime_state.h" | |||
| #include "logic_editor_service.h" | |||
| #include "offline_simulation_service.h" | |||
| #include "online_logic_monitor_service.h" | |||
| #include "plc_communication_gateway.h" | |||
| @@ -32,6 +33,7 @@ public: | |||
| */ | |||
| RuntimeModeService( | |||
| const ProjectService &project_service, | |||
| LogicEditorService &logic_editor_service, | |||
| OfflineSimulationService &offline_simulation_service, | |||
| OnlineLogicMonitorService &online_logic_monitor_service); | |||
| @@ -85,7 +87,10 @@ public: | |||
| const LogicScanResult &simulationError() const; | |||
| /** @brief 返回服务持有的离线仿真服务引用 */ | |||
| OfflineSimulationService &offlineSimulationService(); | |||
| /** 返回服务持有的真机只读轨迹服务引用 */ | |||
| OnlineLogicMonitorService &onlineLogicMonitorService(); | |||
| /** 返回最近一次进入运行模式前执行的梯形图语法检查结果 */ | |||
| const LogicSyntaxCheckResult &lastSyntaxCheck() const; | |||
| /** | |||
| * @brief 注入 PLC 网关、活动仓库和两种实际数据源 | |||
| @@ -97,6 +102,8 @@ public: | |||
| ActiveRegisterRepository &active_repository, | |||
| RegisterRepository &virtual_repository, | |||
| RegisterRepository &plc_repository); | |||
| /** 设置导出 HMI 运行版模式,跳过本地梯形图执行器 */ | |||
| void setHmiOnlyRuntime(bool enabled); | |||
| /** | |||
| * @brief 连接 PLC 并开始异步通信 | |||
| * @param configuration 串口、Modbus 和轮询配置 | |||
| @@ -108,10 +115,12 @@ public: | |||
| * @brief 设置自由监控引用的地址并刷新 PLC 轮询集合 | |||
| * @param addresses 自由监控当前需要读取的 M/D 地址 | |||
| */ | |||
| void setMonitorAddresses(const std::vector<RegisterAddress> &addresses); | |||
| void setMonitorAddresses( | |||
| PlcCommunicationResult setMonitorAddresses( | |||
| const std::vector<RegisterAddress> &addresses); | |||
| /** 设置自由监控地址和多字范围并刷新 PLC 轮询集合 */ | |||
| PlcCommunicationResult setMonitorAddresses( | |||
| const std::vector<RegisterAddress> &addresses, | |||
| const std::vector<RegisterAddress> &float32_starts); | |||
| const std::vector<RegisterWordRange> &multi_word_ranges); | |||
| /** | |||
| * @brief 汇总工程和自由监控引用并刷新 PLC 轮询地址 | |||
| @@ -137,7 +146,11 @@ public: | |||
| void setPlcStatusChangedCallback(std::function<void()> callback); | |||
| private: | |||
| /** 执行运行前工程检查并规整梯形图 */ | |||
| ModeTransitionResult prepareProjectForRunning(); | |||
| const ProjectService &project_service_; // 不拥有的只读工程服务 | |||
| LogicEditorService &logic_editor_service_; // 统一执行运行前规整和语法检查 | |||
| OfflineSimulationService &offline_simulation_service_; // 不拥有的离线仿真服务 | |||
| OnlineLogicMonitorService &online_logic_monitor_service_; // 不拥有的真机只读轨迹服务 | |||
| RuntimeState state_; // 编辑、离线和真机模式状态机 | |||
| @@ -149,5 +162,7 @@ private: | |||
| 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 起始地址 | |||
| std::vector<RegisterWordRange> monitor_multi_word_ranges_; // 自由监控中的多字范围 | |||
| LogicSyntaxCheckResult last_syntax_check_; // 最近一次运行前梯形图检查结果 | |||
| bool hmi_only_runtime_ = false; // 导出运行版只显示 HMI 并连接真实 PLC | |||
| }; | |||
| @@ -7,11 +7,13 @@ | |||
| namespace { | |||
| // 构造成功的软件扫描结果 | |||
| LogicScanResult success() | |||
| { | |||
| return {true, LogicScanError::None, {}, {}, {}, {}}; | |||
| } | |||
| // 构造带定位信息的软件扫描失败结果 | |||
| LogicScanResult failure( | |||
| LogicScanError error, | |||
| const std::string &message, | |||
| @@ -22,6 +24,7 @@ LogicScanResult failure( | |||
| return {false, error, message, logic_id, rung_id, node_id}; | |||
| } | |||
| // 按比较运算符计算两个 16 位字值的关系 | |||
| bool compareWord( | |||
| std::int16_t actual, | |||
| ComparisonOperator comparison, | |||
| @@ -60,6 +63,7 @@ bool compareWord( | |||
| } | |||
| } | |||
| // 判断同一 M 地址上的两种线圈模式是否允许共存 | |||
| bool coilModesAreCompatible(CoilMode existing, CoilMode current) | |||
| { | |||
| if (existing == current) | |||
| @@ -71,23 +75,27 @@ bool coilModesAreCompatible(CoilMode existing, CoilMode current) | |||
| } // namespace | |||
| // 清除当前逻辑或网络的全部运行轨迹 | |||
| void LogicTraceValues::clear() | |||
| { | |||
| nodeValues.clear(); | |||
| nodePowerValues.clear(); | |||
| expressionValues.clear(); | |||
| expressionInputValues.clear(); | |||
| expressionPowerValues.clear(); | |||
| cellValues.clear(); | |||
| cellInputPowerValues.clear(); | |||
| cellPowerValues.clear(); | |||
| verticalConnectionValues.clear(); | |||
| rungValues.clear(); | |||
| wordValues.clear(); | |||
| } | |||
| // 清除全工程及按逻辑分组的运行轨迹 | |||
| void LogicTraceSnapshot::clear() | |||
| { | |||
| LogicTraceValues::clear(); | |||
| logicValues.clear(); | |||
| } | |||
| // 返回指定逻辑的独立轨迹投影 | |||
| LogicTraceSnapshot LogicTraceSnapshot::forLogic( | |||
| const std::string &logic_id) const | |||
| { | |||
| @@ -97,15 +105,18 @@ LogicTraceSnapshot LogicTraceSnapshot::forLogic( | |||
| { | |||
| projection.nodeValues = values->second.nodeValues; | |||
| projection.nodePowerValues = values->second.nodePowerValues; | |||
| projection.expressionValues = values->second.expressionValues; | |||
| projection.expressionInputValues = values->second.expressionInputValues; | |||
| projection.expressionPowerValues = values->second.expressionPowerValues; | |||
| projection.cellValues = values->second.cellValues; | |||
| projection.cellInputPowerValues = values->second.cellInputPowerValues; | |||
| projection.cellPowerValues = values->second.cellPowerValues; | |||
| projection.verticalConnectionValues = | |||
| values->second.verticalConnectionValues; | |||
| projection.rungValues = values->second.rungValues; | |||
| projection.wordValues = values->second.wordValues; | |||
| } | |||
| return projection; | |||
| } | |||
| // 检查控制逻辑是否满足软件扫描要求 | |||
| LogicScanResult SoftwareLogicExecutor::validate( | |||
| const std::vector<ControlLogic> &logics) const | |||
| { | |||
| @@ -171,11 +182,13 @@ LogicScanResult SoftwareLogicExecutor::validate( | |||
| return success(); | |||
| } | |||
| // 清除边沿触点跨扫描保存的上一轮输入 | |||
| void SoftwareLogicExecutor::resetRuntime() | |||
| { | |||
| previous_edge_inputs_.clear(); | |||
| } | |||
| // 按工程顺序执行一轮完整的软件逻辑扫描 | |||
| LogicScanResult SoftwareLogicExecutor::executeScan( | |||
| const std::vector<ControlLogic> &logics, | |||
| RegisterRepository &repository, | |||
| @@ -192,7 +205,7 @@ LogicScanResult SoftwareLogicExecutor::executeScan( | |||
| { | |||
| trace->clear(); | |||
| } | |||
| // 按工程顺序扫描;前面网络写入的 M/D 值对后面网络立即可见 | |||
| // 互相没有竖线连接的行仍按先后顺序扫描,保持原有网络间可见性 | |||
| for (const ControlLogic &logic : logics) | |||
| { | |||
| if (!logic.enabled) | |||
| @@ -201,60 +214,205 @@ LogicScanResult SoftwareLogicExecutor::executeScan( | |||
| } | |||
| LogicTraceValues *logic_trace = trace == nullptr | |||
| ? nullptr : &trace->logicValues[logic.id]; | |||
| for (const LadderRung &rung : logic.rungs) | |||
| std::size_t group_start = 0U; | |||
| while (group_start < logic.rungs.size()) | |||
| { | |||
| if (!rung.condition.has_value() && !rung.output.has_value()) | |||
| std::size_t group_end = group_start; | |||
| while (group_end + 1U < logic.rungs.size()) | |||
| { | |||
| continue; | |||
| const std::string &upper_id = logic.rungs[group_end].id; | |||
| const std::string &lower_id = logic.rungs[group_end + 1U].id; | |||
| const bool connected = std::any_of( | |||
| logic.verticalConnections.cbegin(), | |||
| logic.verticalConnections.cend(), | |||
| [&upper_id, &lower_id](const VerticalConnection &connection) | |||
| { | |||
| return connection.upperRungId == upper_id | |||
| && connection.lowerRungId == lower_id; | |||
| }); | |||
| if (!connected) | |||
| { | |||
| break; | |||
| } | |||
| ++group_end; | |||
| } | |||
| // 输出网络的显式 Wire 会计算为真;空网络只作为编辑草稿跳过 | |||
| bool rung_value = true; | |||
| LogicScanResult result = success(); | |||
| if (rung.condition.has_value()) | |||
| { | |||
| result = evaluateExpression( | |||
| logic.id, | |||
| *rung.condition, | |||
| repository, | |||
| logic_trace, | |||
| true, | |||
| &rung_value); | |||
| } | |||
| if (!result.succeeded) | |||
| { | |||
| result.logicId = logic.id; | |||
| result.rungId = rung.id; | |||
| return result; | |||
| } | |||
| if (logic_trace != nullptr) | |||
| // 每个竖线连通组按列传播电源,组内行共享对应列边界的导通状态 | |||
| const std::size_t group_size = group_end - group_start + 1U; | |||
| std::vector<bool> power(group_size, true); | |||
| for (int boundary = 0; | |||
| boundary <= ProjectLimits::kMaximumConditionColumns; | |||
| ++boundary) | |||
| { | |||
| logic_trace->rungValues[rung.id] = rung_value; | |||
| logic_trace->nodeValues[rung.output->id] = rung_value; | |||
| logic_trace->nodePowerValues[rung.output->id] = rung_value; | |||
| } | |||
| bool output_value = false; | |||
| result = executeOutput( | |||
| *rung.output, | |||
| rung_value, | |||
| repository, | |||
| logic_trace, | |||
| &output_value); | |||
| if (!result.succeeded) | |||
| { | |||
| result.logicId = logic.id; | |||
| result.rungId = rung.id; | |||
| return result; | |||
| // 用并查集合并当前列边界上的竖线,避免反复沿上下支路递归查找 | |||
| std::vector<std::size_t> parent(group_size); | |||
| for (std::size_t index = 0U; index < group_size; ++index) | |||
| { | |||
| parent[index] = index; | |||
| } | |||
| const auto root = [&parent](std::size_t index) | |||
| { | |||
| while (parent[index] != index) | |||
| { | |||
| index = parent[index]; | |||
| } | |||
| return index; | |||
| }; | |||
| const auto unite = [&parent, &root]( | |||
| std::size_t left, std::size_t right) | |||
| { | |||
| const std::size_t left_root = root(left); | |||
| const std::size_t right_root = root(right); | |||
| if (left_root != right_root) | |||
| { | |||
| parent[right_root] = left_root; | |||
| } | |||
| }; | |||
| for (const VerticalConnection &connection | |||
| : logic.verticalConnections) | |||
| { | |||
| if (connection.columnBoundary != boundary) | |||
| { | |||
| continue; | |||
| } | |||
| for (std::size_t row = group_start; | |||
| row < group_end; | |||
| ++row) | |||
| { | |||
| if (logic.rungs[row].id == connection.upperRungId | |||
| && logic.rungs[row + 1U].id | |||
| == connection.lowerRungId) | |||
| { | |||
| unite(row - group_start, row + 1U - group_start); | |||
| break; | |||
| } | |||
| } | |||
| } | |||
| // 同一连通分量任意一行带电,当前边界上的全部成员都视为带电 | |||
| std::vector<bool> component_power(group_size, false); | |||
| for (std::size_t index = 0U; index < group_size; ++index) | |||
| { | |||
| component_power[root(index)] = | |||
| component_power[root(index)] || power[index]; | |||
| } | |||
| for (std::size_t index = 0U; index < group_size; ++index) | |||
| { | |||
| power[index] = component_power[root(index)]; | |||
| } | |||
| if (logic_trace != nullptr) | |||
| { | |||
| for (const VerticalConnection &connection | |||
| : logic.verticalConnections) | |||
| { | |||
| if (connection.columnBoundary != boundary) | |||
| { | |||
| continue; | |||
| } | |||
| for (std::size_t row = group_start; | |||
| row < group_end; | |||
| ++row) | |||
| { | |||
| if (logic.rungs[row].id == connection.upperRungId | |||
| && logic.rungs[row + 1U].id | |||
| == connection.lowerRungId) | |||
| { | |||
| logic_trace->verticalConnectionValues[ | |||
| connection.id] = power[row - group_start]; | |||
| break; | |||
| } | |||
| } | |||
| } | |||
| } | |||
| if (boundary == ProjectLimits::kMaximumConditionColumns) | |||
| { | |||
| break; | |||
| } | |||
| for (std::size_t local_row = 0U; | |||
| local_row < group_size; | |||
| ++local_row) | |||
| { | |||
| const LadderRung &rung = | |||
| logic.rungs[group_start + local_row]; | |||
| const LadderCell &cell = rung.cells[ | |||
| static_cast<std::size_t>(boundary)]; | |||
| const bool input_power = power[local_row]; | |||
| bool cell_value = cell.kind == LadderCellKind::Wire; | |||
| LogicScanResult result = success(); | |||
| if (cell.kind == LadderCellKind::Node) | |||
| { | |||
| // 条件格是 Node 时读取 M/D,并计算触点或比较条件结果 | |||
| result = evaluateCondition( | |||
| logic.id, | |||
| *cell.node, | |||
| repository, | |||
| &cell_value); | |||
| } | |||
| if (!result.succeeded) | |||
| { | |||
| result.logicId = logic.id; | |||
| result.rungId = rung.id; | |||
| return result; | |||
| } | |||
| power[local_row] = input_power && cell_value; | |||
| if (logic_trace != nullptr) | |||
| { | |||
| logic_trace->cellValues[cell.id] = cell_value; | |||
| logic_trace->cellInputPowerValues[cell.id] = input_power; | |||
| logic_trace->cellPowerValues[cell.id] = power[local_row]; | |||
| if (cell.node.has_value()) | |||
| { | |||
| logic_trace->nodeValues[cell.node->id] = cell_value; | |||
| logic_trace->nodePowerValues[cell.node->id] = | |||
| power[local_row]; | |||
| } | |||
| } | |||
| } | |||
| } | |||
| if (logic_trace != nullptr) | |||
| // 同一个竖线连通组先完成条件传播,再按视觉行顺序执行输出 | |||
| for (std::size_t local_row = 0U; | |||
| local_row < group_size; | |||
| ++local_row) | |||
| { | |||
| logic_trace->nodeValues[rung.output->id] = output_value; | |||
| logic_trace->nodePowerValues[rung.output->id] = output_value; | |||
| const LadderRung &rung = logic.rungs[group_start + local_row]; | |||
| const bool rung_value = power[local_row]; | |||
| if (logic_trace != nullptr) | |||
| { | |||
| logic_trace->rungValues[rung.id] = rung_value; | |||
| } | |||
| if (!rung.output.has_value()) | |||
| { | |||
| continue; | |||
| } | |||
| bool output_value = false; | |||
| // 使用这一行最终导通状态执行线圈或数据输出指令 | |||
| LogicScanResult result = executeOutput( | |||
| *rung.output, | |||
| rung_value, | |||
| repository, | |||
| logic_trace, | |||
| &output_value); | |||
| if (!result.succeeded) | |||
| { | |||
| result.logicId = logic.id; | |||
| result.rungId = rung.id; | |||
| return result; | |||
| } | |||
| if (logic_trace != nullptr) | |||
| { | |||
| logic_trace->nodeValues[rung.output->id] = output_value; | |||
| logic_trace->nodePowerValues[rung.output->id] = rung_value; | |||
| } | |||
| } | |||
| group_start = group_end + 1U; | |||
| } | |||
| } | |||
| if (trace != nullptr) | |||
| { | |||
| // 顶层兼容视图只投影第一条启用逻辑,完整轨迹仍保存在 logicValues 中 | |||
| const auto first_enabled = std::find_if( | |||
| logics.cbegin(), logics.cend(), | |||
| [](const ControlLogic &logic) { return logic.enabled; }); | |||
| @@ -265,9 +423,11 @@ LogicScanResult SoftwareLogicExecutor::executeScan( | |||
| { | |||
| trace->nodeValues = values->second.nodeValues; | |||
| trace->nodePowerValues = values->second.nodePowerValues; | |||
| trace->expressionValues = values->second.expressionValues; | |||
| trace->expressionInputValues = values->second.expressionInputValues; | |||
| trace->expressionPowerValues = values->second.expressionPowerValues; | |||
| trace->cellValues = values->second.cellValues; | |||
| trace->cellInputPowerValues = values->second.cellInputPowerValues; | |||
| trace->cellPowerValues = values->second.cellPowerValues; | |||
| trace->verticalConnectionValues = | |||
| values->second.verticalConnectionValues; | |||
| trace->rungValues = values->second.rungValues; | |||
| trace->wordValues = values->second.wordValues; | |||
| } | |||
| @@ -276,89 +436,7 @@ LogicScanResult SoftwareLogicExecutor::executeScan( | |||
| return success(); | |||
| } | |||
| LogicScanResult SoftwareLogicExecutor::evaluateExpression( | |||
| const std::string &logic_id, | |||
| const ConditionExpression &expression, | |||
| RegisterRepository &repository, | |||
| LogicTraceValues *trace, | |||
| bool input_power, | |||
| bool *value) | |||
| { | |||
| if (value == nullptr) | |||
| { | |||
| return failure(LogicScanError::InvalidLogic, "缺少表达式结果接收对象"); | |||
| } | |||
| if (trace != nullptr) | |||
| { | |||
| trace->expressionInputValues[expression.id] = input_power; | |||
| } | |||
| if (expression.kind == ConditionExpressionKind::Wire) | |||
| { | |||
| *value = true; | |||
| if (trace != nullptr) | |||
| { | |||
| trace->expressionValues[expression.id] = true; | |||
| trace->expressionPowerValues[expression.id] = input_power; | |||
| } | |||
| return success(); | |||
| } | |||
| if (expression.kind == ConditionExpressionKind::Gap) | |||
| { | |||
| return failure( | |||
| LogicScanError::InvalidLogic, | |||
| "梯形图条件区存在未连接的空白网格", | |||
| logic_id, | |||
| {}, | |||
| expression.id); | |||
| } | |||
| if (expression.kind == ConditionExpressionKind::Node) | |||
| { | |||
| LogicScanResult result = evaluateCondition( | |||
| logic_id, *expression.node, repository, value); | |||
| if (result.succeeded && trace != nullptr) | |||
| { | |||
| trace->nodeValues[expression.node->id] = *value; | |||
| trace->nodePowerValues[expression.node->id] = input_power && *value; | |||
| trace->expressionValues[expression.id] = *value; | |||
| trace->expressionPowerValues[expression.id] = input_power && *value; | |||
| } | |||
| return result; | |||
| } | |||
| bool accumulated = expression.kind == ConditionExpressionKind::Series; | |||
| bool power = input_power; | |||
| for (const ConditionExpression &child : expression.children) | |||
| { | |||
| bool child_value = false; | |||
| const bool child_input = expression.kind == ConditionExpressionKind::Series | |||
| ? power : input_power; | |||
| LogicScanResult result = evaluateExpression( | |||
| logic_id, | |||
| child, | |||
| repository, | |||
| trace, | |||
| child_input, | |||
| &child_value); | |||
| if (!result.succeeded) | |||
| { | |||
| return result; | |||
| } | |||
| accumulated = expression.kind == ConditionExpressionKind::Series | |||
| ? accumulated && child_value : accumulated || child_value; | |||
| if (expression.kind == ConditionExpressionKind::Series) | |||
| { | |||
| power = power && child_value; | |||
| } | |||
| } | |||
| *value = accumulated; | |||
| if (trace != nullptr) | |||
| { | |||
| trace->expressionValues[expression.id] = accumulated; | |||
| trace->expressionPowerValues[expression.id] = input_power && accumulated; | |||
| } | |||
| return success(); | |||
| } | |||
| // 读取寄存器并计算单个条件节点的导通结果 | |||
| LogicScanResult SoftwareLogicExecutor::evaluateCondition( | |||
| const std::string &logic_id, | |||
| const LogicNode &node, | |||
| @@ -377,6 +455,7 @@ LogicScanResult SoftwareLogicExecutor::evaluateCondition( | |||
| node.id); | |||
| } | |||
| // std::visit 根据 variant 的实际节点类型在编译期选择对应读取分支 | |||
| return std::visit( | |||
| [this, &logic_id, &repository, value, &node]( | |||
| const auto &config) -> LogicScanResult | |||
| @@ -449,6 +528,7 @@ LogicScanResult SoftwareLogicExecutor::evaluateCondition( | |||
| node.config); | |||
| } | |||
| // 解析常量或 D 寄存器字操作数 | |||
| LogicScanResult SoftwareLogicExecutor::readWordOperand( | |||
| const WordOperand &operand, | |||
| RegisterRepository &repository, | |||
| @@ -492,6 +572,7 @@ LogicScanResult SoftwareLogicExecutor::readWordOperand( | |||
| return success(); | |||
| } | |||
| // 根据网络结果执行线圈或字运算输出 | |||
| LogicScanResult SoftwareLogicExecutor::executeOutput( | |||
| const LogicNode &node, | |||
| bool rung_value, | |||
| @@ -561,6 +642,7 @@ LogicScanResult SoftwareLogicExecutor::executeOutput( | |||
| { | |||
| return result; | |||
| } | |||
| // 先用 32 位计算检测溢出,再饱和收敛到 D 字允许的 16 位范围 | |||
| const std::int32_t raw = arithmetic->operation == ArithmeticOperation::Add | |||
| ? static_cast<std::int32_t>(left) + right | |||
| : static_cast<std::int32_t>(left) - right; | |||
| @@ -38,15 +38,16 @@ struct WordTraceValue | |||
| 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, bool> nodeValues; // 节点自身结果 | |||
| std::unordered_map<std::string, bool> nodePowerValues; // 节点后的带电状态 | |||
| std::unordered_map<std::string, bool> cellValues; // 网格自身是否导通 | |||
| std::unordered_map<std::string, bool> cellInputPowerValues; // 网格左边界电源 | |||
| std::unordered_map<std::string, bool> cellPowerValues; // 网格右边界电源 | |||
| std::unordered_map<std::string, bool> verticalConnectionValues; // 竖线所在边界电源 | |||
| std::unordered_map<std::string, bool> rungValues; // 行末输出槽电源 | |||
| std::unordered_map<std::string, WordTraceValue> wordValues; // MOVE/ADD/SUB 节点状态 | |||
| // 清除当前逻辑或网络的全部轨迹 | |||
| @@ -109,23 +110,6 @@ private: | |||
| 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, | |||
| RegisterRepository &repository, | |||
| LogicTraceValues *trace, | |||
| bool input_power, | |||
| bool *value); | |||
| /** | |||
| * @brief 在网络结果驱动下执行单个输出节点 | |||
| * @param node 待读取配置并执行的输出节点 | |||
| @@ -0,0 +1,36 @@ | |||
| #include "user_runtime_plc_recovery_policy.h" | |||
| // 判断当前状态是否需要完整重新打开串口 | |||
| bool UserRuntimePlcRecoveryPolicy::shouldReconnectSerial( | |||
| PlcConnectionState state) const | |||
| { | |||
| return state == PlcConnectionState::Disconnected; | |||
| } | |||
| // 开始一次故障过程,并保证同一次故障只允许提示一次 | |||
| bool UserRuntimePlcRecoveryPolicy::beginFailureEpisode() | |||
| { | |||
| if (failure_episode_active_) | |||
| { | |||
| return false; | |||
| } | |||
| failure_episode_active_ = true; | |||
| return true; | |||
| } | |||
| // 连接并重新完成首读后结束本次故障过程 | |||
| void UserRuntimePlcRecoveryPolicy::observeStatus( | |||
| PlcConnectionState state, | |||
| bool initial_read_completed) | |||
| { | |||
| if (state == PlcConnectionState::Connected && initial_read_completed) | |||
| { | |||
| failure_episode_active_ = false; | |||
| } | |||
| } | |||
| // 返回当前是否仍处于尚未恢复的通信故障过程 | |||
| bool UserRuntimePlcRecoveryPolicy::failureEpisodeActive() const | |||
| { | |||
| return failure_episode_active_; | |||
| } | |||
| @@ -0,0 +1,23 @@ | |||
| #pragma once | |||
| #include "services/plc_communication_gateway.h" | |||
| // 管理用户运行版的串口重连条件和一次通信故障的提示周期 | |||
| class UserRuntimePlcRecoveryPolicy final | |||
| { | |||
| public: | |||
| /** 判断当前状态是否需要完整重新打开串口 */ | |||
| bool shouldReconnectSerial(PlcConnectionState state) const; | |||
| /** 开始一次故障过程,首次调用返回 true,后续调用不重复提示 */ | |||
| bool beginFailureEpisode(); | |||
| /** 观察通信状态,连接且完成首读后结束本轮故障 */ | |||
| void observeStatus( | |||
| PlcConnectionState state, | |||
| bool initial_read_completed); | |||
| /** 返回当前是否仍处于未恢复的故障过程 */ | |||
| bool failureEpisodeActive() const; | |||
| private: | |||
| /** 当前是否已经进入一次尚未恢复的故障过程 */ | |||
| bool failure_episode_active_ = false; | |||
| }; | |||
| @@ -10,17 +10,20 @@ | |||
| namespace { | |||
| // 将 UTF-8 标准字符串转换为 QString | |||
| QString fromUtf8(const std::string &value) | |||
| { | |||
| return QString::fromUtf8(value.data(), static_cast<int>(value.size())); | |||
| } | |||
| // 将 QString 按 UTF-8 转换为标准字符串 | |||
| std::string toUtf8(const QString &value) | |||
| { | |||
| const QByteArray bytes = value.toUtf8(); | |||
| return std::string(bytes.constData(), static_cast<std::size_t>(bytes.size())); | |||
| } | |||
| // 返回报警触发条件对应的界面文字 | |||
| QString conditionText(AlarmCondition condition) | |||
| { | |||
| switch (condition) | |||
| @@ -29,6 +32,10 @@ QString conditionText(AlarmCondition condition) | |||
| { | |||
| return AlarmConfigurationDialog::tr("M 为 ON"); | |||
| } | |||
| case AlarmCondition::MOff: | |||
| { | |||
| return AlarmConfigurationDialog::tr("M 为 OFF"); | |||
| } | |||
| case AlarmCondition::DHigh: | |||
| { | |||
| return AlarmConfigurationDialog::tr("D 大于等于阈值"); | |||
| @@ -46,6 +53,7 @@ QString conditionText(AlarmCondition condition) | |||
| } // namespace | |||
| // 创建报警配置对话框并连接表格和编辑动作 | |||
| AlarmConfigurationDialog::AlarmConfigurationDialog( | |||
| AlarmEditorService &service, | |||
| QWidget *parent) | |||
| @@ -86,8 +94,10 @@ AlarmConfigurationDialog::AlarmConfigurationDialog( | |||
| reloadDefinitions(); | |||
| } | |||
| // 释放报警配置对话框 | |||
| AlarmConfigurationDialog::~AlarmConfigurationDialog() = default; | |||
| // 重新加载报警列表并尽量恢复指定报警的选中状态 | |||
| void AlarmConfigurationDialog::reloadDefinitions(const std::string &selected_id) | |||
| { | |||
| const auto &definitions = service_.definitions(); | |||
| @@ -98,6 +108,7 @@ void AlarmConfigurationDialog::reloadDefinitions(const std::string &selected_id) | |||
| const AlarmDefinition &definition = definitions[index]; | |||
| const int row = static_cast<int>(index); | |||
| auto *id_item = new QTableWidgetItem(fromUtf8(definition.id)); | |||
| // UserRole 保存稳定标识,表格排序或显示文字变化后仍能找到原定义 | |||
| id_item->setData(Qt::UserRole, fromUtf8(definition.id)); | |||
| ui_->alarmTable->setItem(row, 0, id_item); | |||
| ui_->alarmTable->setItem( | |||
| @@ -109,7 +120,7 @@ void AlarmConfigurationDialog::reloadDefinitions(const std::string &selected_id) | |||
| row, | |||
| 3, | |||
| new QTableWidgetItem( | |||
| definition.condition == AlarmCondition::MOn | |||
| definition.address.area() == RegisterArea::M | |||
| ? QStringLiteral("-") | |||
| : QString::number(definition.threshold))); | |||
| ui_->alarmTable->setItem( | |||
| @@ -131,6 +142,7 @@ void AlarmConfigurationDialog::reloadDefinitions(const std::string &selected_id) | |||
| } | |||
| } | |||
| // 将当前选中的报警定义加载到编辑控件 | |||
| void AlarmConfigurationDialog::loadSelectedDefinition() | |||
| { | |||
| const AlarmDefinition *definition = service_.findDefinition(selectedId()); | |||
| @@ -152,6 +164,7 @@ void AlarmConfigurationDialog::loadSelectedDefinition() | |||
| ui_->messageEdit->setText(fromUtf8(definition->message)); | |||
| } | |||
| // 根据 M 位或 D 字区域刷新可选触发条件和阈值输入状态 | |||
| void AlarmConfigurationDialog::updateConditionOptions() | |||
| { | |||
| const RegisterArea area = static_cast<RegisterArea>( | |||
| @@ -162,6 +175,9 @@ void AlarmConfigurationDialog::updateConditionOptions() | |||
| ui_->conditionComboBox->addItem( | |||
| conditionText(AlarmCondition::MOn), | |||
| static_cast<int>(AlarmCondition::MOn)); | |||
| ui_->conditionComboBox->addItem( | |||
| conditionText(AlarmCondition::MOff), | |||
| static_cast<int>(AlarmCondition::MOff)); | |||
| } | |||
| else | |||
| { | |||
| @@ -176,6 +192,7 @@ void AlarmConfigurationDialog::updateConditionOptions() | |||
| ui_->thresholdSpinBox->setEnabled(area == RegisterArea::D); | |||
| } | |||
| // 从界面读取配置并新增报警定义 | |||
| void AlarmConfigurationDialog::addDefinition() | |||
| { | |||
| const AlarmEditorResult result = service_.addDefinition(definitionFromInputs()); | |||
| @@ -187,6 +204,7 @@ void AlarmConfigurationDialog::addDefinition() | |||
| reloadDefinitions(result.id); | |||
| } | |||
| // 使用界面中的配置更新当前报警定义 | |||
| void AlarmConfigurationDialog::updateDefinition() | |||
| { | |||
| const std::string id = selectedId(); | |||
| @@ -204,6 +222,7 @@ void AlarmConfigurationDialog::updateDefinition() | |||
| reloadDefinitions(result.id); | |||
| } | |||
| // 确认后删除当前选中的报警定义 | |||
| void AlarmConfigurationDialog::removeDefinition() | |||
| { | |||
| const std::string id = selectedId(); | |||
| @@ -226,6 +245,7 @@ void AlarmConfigurationDialog::removeDefinition() | |||
| reloadDefinitions(); | |||
| } | |||
| // 将编辑控件中的值组装成报警定义 | |||
| AlarmDefinition AlarmConfigurationDialog::definitionFromInputs() const | |||
| { | |||
| AlarmDefinition definition; | |||
| @@ -240,6 +260,7 @@ AlarmDefinition AlarmConfigurationDialog::definitionFromInputs() const | |||
| return definition; | |||
| } | |||
| // 从表格自定义数据中读取当前报警标识 | |||
| std::string AlarmConfigurationDialog::selectedId() const | |||
| { | |||
| const int row = ui_->alarmTable->currentRow(); | |||
| @@ -0,0 +1,132 @@ | |||
| #include "button_extension_dialog.h" | |||
| #include "domain/project_limits.h" | |||
| #include "ui_button_extension_dialog.h" | |||
| #include <QComboBox> | |||
| #include <QLineEdit> | |||
| #include <QLocale> | |||
| #include <QMessageBox> | |||
| namespace { | |||
| // 将 UTF-8 标准字符串转换为 QString | |||
| QString fromUtf8(const std::string &value) | |||
| { | |||
| return QString::fromUtf8(value.data(), static_cast<int>(value.size())); | |||
| } | |||
| } // namespace | |||
| // 创建对话框并将按钮现有启用条件加载到对应配置页 | |||
| ButtonExtensionDialog::ButtonExtensionDialog( | |||
| const HmiControl &control, QWidget *parent) | |||
| : QDialog(parent), | |||
| ui_(std::make_unique<Ui::ButtonExtensionDialog>()), | |||
| control_(control) | |||
| { | |||
| ui_->setupUi(this); | |||
| ui_->conditionTypeComboBox->setItemData(0, 0); | |||
| ui_->conditionTypeComboBox->setItemData(1, 1); | |||
| ui_->conditionTypeComboBox->setItemData(2, 2); | |||
| ui_->dataTypeComboBox->setItemData(0, static_cast<int>(RegisterDataType::Int16)); | |||
| ui_->dataTypeComboBox->setItemData(1, static_cast<int>(RegisterDataType::Int32)); | |||
| ui_->dataTypeComboBox->setItemData(2, static_cast<int>(RegisterDataType::Float32)); | |||
| ui_->dataTypeComboBox->setItemData(3, static_cast<int>(RegisterDataType::Float64)); | |||
| ui_->operatorComboBox->setItemData(0, static_cast<int>(HmiButtonConditionOperator::Equal)); | |||
| ui_->operatorComboBox->setItemData(1, static_cast<int>(HmiButtonConditionOperator::NotEqual)); | |||
| ui_->operatorComboBox->setItemData(2, static_cast<int>(HmiButtonConditionOperator::LessThan)); | |||
| ui_->operatorComboBox->setItemData(3, static_cast<int>(HmiButtonConditionOperator::LessThanOrEqual)); | |||
| ui_->operatorComboBox->setItemData(4, static_cast<int>(HmiButtonConditionOperator::GreaterThan)); | |||
| ui_->operatorComboBox->setItemData(5, static_cast<int>(HmiButtonConditionOperator::GreaterThanOrEqual)); | |||
| ui_->addressSpinBox->setRange(0, RegisterAddress::kMaximumIndex); | |||
| ui_->dAddressSpinBox->setRange(0, RegisterAddress::kMaximumIndex); | |||
| if (control.buttonEnableCondition.has_value()) | |||
| { | |||
| // variant 保存 M 位或 D 数值两种条件,按实际类型恢复界面 | |||
| if (const auto *bit = std::get_if<HmiButtonBitEnableCondition>( | |||
| &*control.buttonEnableCondition)) | |||
| { | |||
| ui_->conditionTypeComboBox->setCurrentIndex(1); | |||
| ui_->addressSpinBox->setValue(bit->address.index()); | |||
| ui_->expectedComboBox->setCurrentIndex(bit->expected ? 1 : 0); | |||
| } | |||
| else if (const auto *word = std::get_if<HmiButtonWordEnableCondition>( | |||
| &*control.buttonEnableCondition)) | |||
| { | |||
| ui_->conditionTypeComboBox->setCurrentIndex(2); | |||
| ui_->dAddressSpinBox->setValue(word->address.index()); | |||
| ui_->dataTypeComboBox->setCurrentIndex( | |||
| ui_->dataTypeComboBox->findData(static_cast<int>(word->dataType))); | |||
| ui_->operatorComboBox->setCurrentIndex( | |||
| ui_->operatorComboBox->findData(static_cast<int>(word->operation))); | |||
| ui_->valueEdit->setText(QLocale::c().toString(word->value, 'g', 17)); | |||
| } | |||
| } | |||
| connect(ui_->conditionTypeComboBox, | |||
| QOverload<int>::of(&QComboBox::currentIndexChanged), | |||
| this, &ButtonExtensionDialog::updateConditionPage); | |||
| updateConditionPage(); | |||
| } | |||
| // 释放按钮扩展配置对话框 | |||
| ButtonExtensionDialog::~ButtonExtensionDialog() = default; | |||
| // 返回用户确认后更新的按钮控件 | |||
| const HmiControl &ButtonExtensionDialog::control() const | |||
| { | |||
| return control_; | |||
| } | |||
| // 根据条件类型切换 M 位或 D 数值配置页面 | |||
| void ButtonExtensionDialog::updateConditionPage() | |||
| { | |||
| const int type = ui_->conditionTypeComboBox->currentData().toInt(); | |||
| ui_->configurationStack->setCurrentIndex(type == 2 ? 1 : 0); | |||
| ui_->bitPage->setEnabled(type == 1); | |||
| ui_->wordPage->setEnabled(type == 2); | |||
| } | |||
| // 读取、校验并保存按钮启用条件 | |||
| void ButtonExtensionDialog::accept() | |||
| { | |||
| const int type = ui_->conditionTypeComboBox->currentData().toInt(); | |||
| if (type == 0) | |||
| { | |||
| control_.buttonEnableCondition.reset(); | |||
| } | |||
| else if (type == 1) | |||
| { | |||
| control_.buttonEnableCondition = HmiButtonBitEnableCondition{ | |||
| RegisterAddress{RegisterArea::M, ui_->addressSpinBox->value()}, | |||
| ui_->expectedComboBox->currentData().toInt() != 0}; | |||
| } | |||
| else | |||
| { | |||
| bool converted = false; | |||
| // 使用固定 C locale,确保工程数值不受系统小数点格式影响 | |||
| const double value = QLocale::c().toDouble( | |||
| ui_->valueEdit->text().trimmed(), &converted); | |||
| if (!converted) | |||
| { | |||
| QMessageBox::warning(this, tr("按钮扩展配置"), | |||
| tr("D 条件比较值必须是有限数值")); | |||
| return; | |||
| } | |||
| control_.buttonEnableCondition = HmiButtonWordEnableCondition{ | |||
| RegisterAddress{RegisterArea::D, ui_->dAddressSpinBox->value()}, | |||
| static_cast<RegisterDataType>(ui_->dataTypeComboBox->currentData().toInt()), | |||
| static_cast<HmiButtonConditionOperator>( | |||
| ui_->operatorComboBox->currentData().toInt()), | |||
| value}; | |||
| } | |||
| std::string error; | |||
| if (!control_.validate(&error)) | |||
| { | |||
| QMessageBox::warning(this, tr("按钮扩展配置"), fromUtf8(error)); | |||
| return; | |||
| } | |||
| QDialog::accept(); | |||
| } | |||
| @@ -0,0 +1,45 @@ | |||
| /** | |||
| * @file button_extension_dialog.h | |||
| * @brief 定义 HMI 按钮启用条件配置对话框 | |||
| * @version 1.0.0 | |||
| * @author suyu | |||
| * @date 2026-08-30 | |||
| */ | |||
| #pragma once | |||
| #include "domain/hmi_model.h" | |||
| #include <QDialog> | |||
| #include <memory> | |||
| namespace Ui { | |||
| class ButtonExtensionDialog; | |||
| } | |||
| class ButtonExtensionDialog final : public QDialog | |||
| { | |||
| Q_OBJECT | |||
| public: | |||
| /** 创建对话框并加载按钮现有的启用条件 */ | |||
| explicit ButtonExtensionDialog(const HmiControl &control, QWidget *parent = nullptr); | |||
| /** 释放 Designer 界面对象 */ | |||
| ~ButtonExtensionDialog() override; | |||
| /** 返回用户确认后更新的按钮控件 */ | |||
| const HmiControl &control() const; | |||
| private slots: | |||
| /** 根据条件类型切换 M 位或 D 数值配置页面 */ | |||
| void updateConditionPage(); | |||
| /** 校验并保存按钮启用条件 */ | |||
| void accept() override; | |||
| private: | |||
| /** Qt Designer 生成的界面对象 */ | |||
| std::unique_ptr<Ui::ButtonExtensionDialog> ui_; | |||
| /** 正在编辑的按钮控件副本 */ | |||
| HmiControl control_; | |||
| }; | |||
| @@ -0,0 +1,36 @@ | |||
| <?xml version="1.0" encoding="UTF-8"?> | |||
| <ui version="4.0"> | |||
| <class>ButtonExtensionDialog</class> | |||
| <widget class="QDialog" name="ButtonExtensionDialog"> | |||
| <property name="windowTitle"><string>按钮扩展配置</string></property> | |||
| <property name="minimumSize"><size><width>460</width><height>260</height></size></property> | |||
| <layout class="QVBoxLayout" name="verticalLayout"> | |||
| <item> | |||
| <layout class="QFormLayout" name="conditionForm"> | |||
| <item row="0" column="0"><widget class="QLabel" name="conditionTypeLabel"><property name="text"><string>启用条件</string></property></widget></item> | |||
| <item row="0" column="1"><widget class="QComboBox" name="conditionTypeComboBox"><item><property name="text"><string>未设置</string></property></item><item><property name="text"><string>M 位条件</string></property></item><item><property name="text"><string>D 数值条件</string></property></item></widget></item> | |||
| </layout> | |||
| </item> | |||
| <item><widget class="QStackedWidget" name="configurationStack"> | |||
| <widget class="QWidget" name="bitPage"><layout class="QFormLayout" name="bitForm"> | |||
| <item row="0" column="0"><widget class="QLabel" name="addressLabel"><property name="text"><string>M 地址</string></property></widget></item> | |||
| <item row="0" column="1"><widget class="QSpinBox" name="addressSpinBox"><property name="maximum"><number>4000</number></property></widget></item> | |||
| <item row="1" column="0"><widget class="QLabel" name="expectedLabel"><property name="text"><string>要求状态</string></property></widget></item> | |||
| <item row="1" column="1"><widget class="QComboBox" name="expectedComboBox"><item><property name="text"><string>OFF</string></property><property name="userData"><bool>false</bool></property></item><item><property name="text"><string>ON</string></property><property name="userData"><bool>true</bool></property></item></widget></item> | |||
| </layout></widget> | |||
| <widget class="QWidget" name="wordPage"><layout class="QFormLayout" name="wordForm"> | |||
| <item row="0" column="0"><widget class="QLabel" name="dAddressLabel"><property name="text"><string>D 地址</string></property></widget></item> | |||
| <item row="0" column="1"><widget class="QSpinBox" name="dAddressSpinBox"><property name="maximum"><number>4000</number></property></widget></item> | |||
| <item row="1" column="0"><widget class="QLabel" name="dataTypeLabel"><property name="text"><string>数值类型</string></property></widget></item> | |||
| <item row="1" column="1"><widget class="QComboBox" name="dataTypeComboBox"><item><property name="text"><string>Int16</string></property></item><item><property name="text"><string>Int32</string></property></item><item><property name="text"><string>Float32</string></property></item><item><property name="text"><string>Double (Float64)</string></property></item></widget></item> | |||
| <item row="2" column="0"><widget class="QLabel" name="operatorLabel"><property name="text"><string>比较方式</string></property></widget></item> | |||
| <item row="2" column="1"><widget class="QComboBox" name="operatorComboBox"><item><property name="text"><string>等于</string></property></item><item><property name="text"><string>不等于</string></property></item><item><property name="text"><string>小于</string></property></item><item><property name="text"><string>小于等于</string></property></item><item><property name="text"><string>大于</string></property></item><item><property name="text"><string>大于等于</string></property></item></widget></item> | |||
| <item row="3" column="0"><widget class="QLabel" name="valueLabel"><property name="text"><string>比较值</string></property></widget></item> | |||
| <item row="3" column="1"><widget class="QLineEdit" name="valueEdit"><property name="placeholderText"><string>请输入数值</string></property></widget></item> | |||
| </layout></widget> | |||
| </widget></item> | |||
| <item><widget class="QDialogButtonBox" name="buttonBox"><property name="standardButtons"><set>QDialogButtonBox::Cancel|QDialogButtonBox::Ok</set></property></widget></item> | |||
| </layout> | |||
| </widget> | |||
| <connections><connection><sender>buttonBox</sender><signal>accepted()</signal><receiver>ButtonExtensionDialog</receiver><slot>accept()</slot></connection><connection><sender>buttonBox</sender><signal>rejected()</signal><receiver>ButtonExtensionDialog</receiver><slot>reject()</slot></connection></connections> | |||
| </ui> | |||
| @@ -5,31 +5,66 @@ | |||
| #include "ui_free_monitor_widget.h" | |||
| #include <QComboBox> | |||
| #include <QDoubleValidator> | |||
| #include <QHeaderView> | |||
| #include <QIntValidator> | |||
| #include <QLineEdit> | |||
| #include <QLocale> | |||
| #include <QMessageBox> | |||
| #include <QPushButton> | |||
| #include <QSignalBlocker> | |||
| #include <QTableWidgetItem> | |||
| #include <cstdint> | |||
| #include <cmath> | |||
| #include <limits> | |||
| #include <set> | |||
| #include <type_traits> | |||
| namespace { | |||
| // 将 UTF-8 标准字符串转换为 QString | |||
| QString fromUtf8(const std::string &value) | |||
| { | |||
| return QString::fromUtf8(value.data(), static_cast<int>(value.size())); | |||
| } | |||
| // 返回寄存器地址对应的界面文字 | |||
| QString addressText(const RegisterAddress &address) | |||
| { | |||
| return QString::fromStdString(address.toString()); | |||
| } | |||
| // 将不同数值类型的寄存器值格式化为无精度损失的文字 | |||
| QString numericValueText(const RegisterNumericValue &value) | |||
| { | |||
| // visit 在编译期按 variant 中的实际数值类型选择格式 | |||
| return std::visit( | |||
| [](const auto &typed_value) | |||
| { | |||
| using Value = std::decay_t<decltype(typed_value)>; | |||
| if constexpr (std::is_same_v<Value, float>) | |||
| { | |||
| return QString::number( | |||
| static_cast<double>(typed_value), 'g', | |||
| std::numeric_limits<float>::max_digits10); | |||
| } | |||
| else if constexpr (std::is_same_v<Value, double>) | |||
| { | |||
| return QString::number( | |||
| typed_value, 'g', std::numeric_limits<double>::max_digits10); | |||
| } | |||
| else | |||
| { | |||
| return QString::number(static_cast<qlonglong>(typed_value)); | |||
| } | |||
| }, | |||
| value); | |||
| } | |||
| } // namespace | |||
| // 创建自由监控控件并连接地址、数据源和写入操作 | |||
| FreeMonitorWidget::FreeMonitorWidget( | |||
| RegisterMonitorService &service, | |||
| QWidget *parent) | |||
| @@ -47,26 +82,47 @@ FreeMonitorWidget::FreeMonitorWidget( | |||
| 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_->dataTypeComboBox->setItemData(1, static_cast<int>(RegisterDataType::Int32)); | |||
| ui_->dataTypeComboBox->setItemData(2, static_cast<int>(RegisterDataType::Float32)); | |||
| ui_->dataTypeComboBox->setItemData(3, static_cast<int>(RegisterDataType::Float64)); | |||
| ui_->editingSourceComboBox->setItemData( | |||
| 0, static_cast<int>(EditingMonitorSource::Offline)); | |||
| ui_->editingSourceComboBox->setItemData( | |||
| 1, static_cast<int>(EditingMonitorSource::Plc)); | |||
| ui_->monitorTable->verticalHeader()->setVisible(false); | |||
| connect(ui_->addButton, &QPushButton::clicked, this, &FreeMonitorWidget::addAddresses); | |||
| connect(ui_->addressEdit, &QLineEdit::returnPressed, | |||
| this, &FreeMonitorWidget::addAddresses); | |||
| connect(ui_->addressEdit, &QLineEdit::textChanged, | |||
| this, &FreeMonitorWidget::updateDataTypeSelector); | |||
| connect(ui_->removeButton, &QToolButton::clicked, | |||
| this, &FreeMonitorWidget::removeSelectedAddresses); | |||
| connect(ui_->clearButton, &QToolButton::clicked, | |||
| this, &FreeMonitorWidget::clearAddresses); | |||
| connect(ui_->editingSourceComboBox, | |||
| QOverload<int>::of(&QComboBox::currentIndexChanged), | |||
| this, &FreeMonitorWidget::selectEditingSource); | |||
| reloadAddresses(); | |||
| } | |||
| // 释放自由监控控件 | |||
| FreeMonitorWidget::~FreeMonitorWidget() = default; | |||
| // 设置当前是否允许写入寄存器 | |||
| void FreeMonitorWidget::setWriteEnabled(bool enabled) | |||
| { | |||
| write_enabled_ = enabled; | |||
| updateWriteControls(); | |||
| } | |||
| // 设置编辑态数据源选择控件是否可见 | |||
| void FreeMonitorWidget::setEditingSourceSelectorVisible(bool visible) | |||
| { | |||
| ui_->editingSourceLabel->setVisible(visible); | |||
| ui_->editingSourceComboBox->setVisible(visible); | |||
| } | |||
| // 根据运行模式和 PLC 状态刷新数据源提示和监控值 | |||
| void FreeMonitorWidget::refreshValues( | |||
| ApplicationMode mode, PlcConnectionState plc_state) | |||
| { | |||
| @@ -75,8 +131,45 @@ void FreeMonitorWidget::refreshValues( | |||
| bool communication_fault = false; | |||
| if (mode == ApplicationMode::Editing) | |||
| { | |||
| source = tr("数据源:离线初始值 · 虚拟 M/D"); | |||
| const bool plc_selected = service_.editingSource() | |||
| == EditingMonitorSource::Plc; | |||
| if (plc_selected) | |||
| { | |||
| if (service_.editingPlcReady()) | |||
| { | |||
| source = tr("数据源:真机 PLC M/D"); | |||
| } | |||
| else if (plc_state == PlcConnectionState::Connecting) | |||
| { | |||
| source = tr("数据源:真机 PLC M/D · 正在连接"); | |||
| } | |||
| else if (plc_state == PlcConnectionState::Recovering) | |||
| { | |||
| source = tr("数据源:真机 PLC M/D · 正在恢复并重新首读"); | |||
| } | |||
| else if (plc_state == PlcConnectionState::Faulted) | |||
| { | |||
| source = tr("数据源:真机 PLC M/D · 通信故障"); | |||
| } | |||
| else if (plc_state == PlcConnectionState::Disconnected) | |||
| { | |||
| source = tr("数据源:真机 PLC M/D · 已断开"); | |||
| } | |||
| else | |||
| { | |||
| source = tr("数据源:真机 PLC M/D · 正在首读"); | |||
| } | |||
| } | |||
| else | |||
| { | |||
| source = tr("数据源:离线初始值 · 虚拟 M/D"); | |||
| } | |||
| values_available = true; | |||
| communication_fault = plc_selected | |||
| && plc_state == PlcConnectionState::Faulted; | |||
| // 程序同步选项时阻断信号,避免被当成用户主动切换数据源 | |||
| const QSignalBlocker blocker(ui_->editingSourceComboBox); | |||
| ui_->editingSourceComboBox->setCurrentIndex(plc_selected ? 1 : 0); | |||
| } | |||
| else if (mode == ApplicationMode::OfflineRunning) | |||
| { | |||
| @@ -107,8 +200,25 @@ void FreeMonitorWidget::refreshValues( | |||
| if (value.state == MonitorValueState::Unavailable) | |||
| { | |||
| value_item->setText(QStringLiteral("--")); | |||
| state_item->setText(mode == ApplicationMode::OnlineRunning | |||
| ? tr("等待读取") : tr("不可用")); | |||
| if (mode == ApplicationMode::Editing | |||
| && service_.editingSource() == EditingMonitorSource::Plc) | |||
| { | |||
| state_item->setText( | |||
| plc_state == PlcConnectionState::Disconnected | |||
| ? tr("PLC 已断开") | |||
| : plc_state == PlcConnectionState::Faulted | |||
| ? tr("PLC 通信故障") | |||
| : plc_state == PlcConnectionState::Connecting | |||
| ? tr("PLC 正在连接") | |||
| : plc_state == PlcConnectionState::Recovering | |||
| ? tr("PLC 恢复中") : tr("等待读取")); | |||
| } | |||
| else | |||
| { | |||
| state_item->setText( | |||
| mode == ApplicationMode::OnlineRunning | |||
| ? tr("等待读取") : tr("不可用")); | |||
| } | |||
| continue; | |||
| } | |||
| if (value.address.area() == RegisterArea::M) | |||
| @@ -116,14 +226,9 @@ void FreeMonitorWidget::refreshValues( | |||
| 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)); | |||
| value_item->setText(numericValueText(value.numericValue)); | |||
| } | |||
| state_item->setText(value.state == MonitorValueState::CommunicationFault | |||
| ? tr("通信故障,最后有效值") : tr("有效")); | |||
| @@ -131,6 +236,7 @@ void FreeMonitorWidget::refreshValues( | |||
| updateWriteControls(); | |||
| } | |||
| // 按服务中的监控点重建表格行和写入控件 | |||
| void FreeMonitorWidget::reloadAddresses() | |||
| { | |||
| const std::vector<RegisterAddress> &addresses = service_.addresses(); | |||
| @@ -141,16 +247,22 @@ void FreeMonitorWidget::reloadAddresses() | |||
| 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("位") | |||
| : point.dataType == RegisterDataType::Float32 | |||
| ? tr("Float32 %1 (%1~%2)") | |||
| .arg(addressText(address), | |||
| addressText(RegisterAddress{ | |||
| RegisterArea::D, | |||
| point.address.index() + 1})) | |||
| : tr("Int16"))); | |||
| QString type_text = tr("位"); | |||
| if (address.area() == RegisterArea::D) | |||
| { | |||
| const RegisterDataTypeDescriptor &descriptor = | |||
| registerDataTypeDescriptor(point.dataType); | |||
| type_text = QString::fromLatin1(descriptor.displayName); | |||
| if (descriptor.wordCount > 1) | |||
| { | |||
| type_text += tr(" (%1~%2)").arg( | |||
| addressText(address), | |||
| addressText(RegisterAddress{ | |||
| RegisterArea::D, | |||
| point.address.index() + descriptor.wordCount - 1})); | |||
| } | |||
| } | |||
| ui_->monitorTable->setItem(row, 1, new QTableWidgetItem(type_text)); | |||
| ui_->monitorTable->setItem(row, 2, new QTableWidgetItem(QStringLiteral("--"))); | |||
| if (address.area() == RegisterArea::M) | |||
| { | |||
| @@ -162,19 +274,36 @@ void FreeMonitorWidget::reloadAddresses() | |||
| else | |||
| { | |||
| auto *target = new QLineEdit(ui_->monitorTable); | |||
| if (point.dataType == RegisterDataType::Float32) | |||
| if (point.dataType == RegisterDataType::Int16) | |||
| { | |||
| target->setPlaceholderText(tr("小数或科学计数法")); | |||
| target->setValidator(new QIntValidator(-32768, 32767, target)); | |||
| } | |||
| else if (point.dataType == RegisterDataType::Int32) | |||
| { | |||
| target->setValidator(new QIntValidator( | |||
| std::numeric_limits<int>::min(), | |||
| std::numeric_limits<int>::max(), target)); | |||
| } | |||
| else | |||
| { | |||
| target->setValidator(new QIntValidator(-32768, 32767, target)); | |||
| const double maximum = point.dataType == RegisterDataType::Float32 | |||
| ? static_cast<double>(std::numeric_limits<float>::max()) | |||
| : std::numeric_limits<double>::max(); | |||
| const int digits = point.dataType == RegisterDataType::Float32 | |||
| ? std::numeric_limits<float>::max_digits10 | |||
| : std::numeric_limits<double>::max_digits10; | |||
| auto *validator = new QDoubleValidator(-maximum, maximum, digits, target); | |||
| validator->setNotation(QDoubleValidator::ScientificNotation); | |||
| validator->setLocale(QLocale::c()); | |||
| target->setValidator(validator); | |||
| target->setPlaceholderText(tr("小数或科学计数法")); | |||
| } | |||
| target->setText(QStringLiteral("0")); | |||
| target->setAlignment(Qt::AlignRight | Qt::AlignVCenter); | |||
| ui_->monitorTable->setCellWidget(row, 3, target); | |||
| } | |||
| auto *write_button = new QPushButton(tr("写入"), ui_->monitorTable); | |||
| // 行号保存在按钮动态属性中,点击时可以找到对应监控点 | |||
| write_button->setProperty("monitorRow", row); | |||
| connect(write_button, &QPushButton::clicked, this, | |||
| [this, write_button] | |||
| @@ -187,6 +316,7 @@ void FreeMonitorWidget::reloadAddresses() | |||
| updateWriteControls(); | |||
| } | |||
| // 读取指定表格行的目标值并请求服务写入寄存器 | |||
| void FreeMonitorWidget::writeRow(int row) | |||
| { | |||
| if (!write_enabled_ || row < 0 | |||
| @@ -215,20 +345,19 @@ void FreeMonitorWidget::writeRow(int row) | |||
| return; | |||
| } | |||
| bool converted = false; | |||
| const double value = target->text().toDouble(&converted); | |||
| if (!converted || !std::isfinite(value) | |||
| || (point.dataType == RegisterDataType::Int16 | |||
| && (value < -32768 || value > 32767 || std::trunc(value) != value))) | |||
| // 使用固定 C locale 解析数值,避免系统区域格式改变输入含义 | |||
| const double value = QLocale::c().toDouble(target->text(), &converted); | |||
| if (!converted | |||
| || !encodeRegisterNumericValue(point.dataType, value).has_value()) | |||
| { | |||
| ui_->monitorTable->item(row, 5)->setText(tr("目标值无效")); | |||
| emit operationMessage(point.dataType == RegisterDataType::Float32 | |||
| ? tr("写入 D 区地址失败:请输入有限 Float32 数值") | |||
| : tr("写入 D 区地址失败:请输入 -32768~32767 的整数")); | |||
| emit operationMessage( | |||
| tr("写入 D 区地址失败:请输入有效的 %1 数值") | |||
| .arg(QString::fromLatin1( | |||
| registerDataTypeDescriptor(point.dataType).displayName))); | |||
| return; | |||
| } | |||
| result = point.dataType == RegisterDataType::Float32 | |||
| ? service_.writeFloat(address, static_cast<float>(value)) | |||
| : service_.writeWord(address, static_cast<std::int16_t>(value)); | |||
| result = service_.writeNumeric(address, point.dataType, value); | |||
| } | |||
| QTableWidgetItem *state_item = ui_->monitorTable->item(row, 5); | |||
| @@ -243,6 +372,7 @@ void FreeMonitorWidget::writeRow(int row) | |||
| handleResult(tr("写入 %1").arg(addressText(address)), false, result.message); | |||
| } | |||
| // 将当前写入权限同步到每一行的输入控件和按钮 | |||
| void FreeMonitorWidget::updateWriteControls() | |||
| { | |||
| for (int row = 0; row < ui_->monitorTable->rowCount(); ++row) | |||
| @@ -260,6 +390,20 @@ void FreeMonitorWidget::updateWriteControls() | |||
| } | |||
| } | |||
| // M 地址固定为位类型,D 地址允许选择数值类型 | |||
| void FreeMonitorWidget::updateDataTypeSelector(const QString &address_text) | |||
| { | |||
| const bool bit_address = address_text.trimmed().startsWith( | |||
| QLatin1Char('M'), Qt::CaseInsensitive); | |||
| ui_->dataTypeComboBox->setCurrentIndex(0); | |||
| ui_->dataTypeComboBox->setItemText( | |||
| 0, bit_address ? tr("Bit(位)") : QStringLiteral("Int16")); | |||
| ui_->dataTypeComboBox->setEnabled(!bit_address); | |||
| ui_->dataTypeComboBox->setToolTip( | |||
| bit_address ? tr("M 地址固定使用位类型") : QString{}); | |||
| } | |||
| // 将输入的起始地址和数量添加到自由监控列表 | |||
| void FreeMonitorWidget::addAddresses() | |||
| { | |||
| const QByteArray address = ui_->addressEdit->text().toUtf8(); | |||
| @@ -284,6 +428,7 @@ void FreeMonitorWidget::addAddresses() | |||
| handleResult(tr("添加监控地址"), false, result.message); | |||
| } | |||
| // 删除表格中选中的所有监控点 | |||
| void FreeMonitorWidget::removeSelectedAddresses() | |||
| { | |||
| std::set<int> rows; | |||
| @@ -311,6 +456,7 @@ void FreeMonitorWidget::removeSelectedAddresses() | |||
| handleResult(tr("删除监控地址"), false, result.message); | |||
| } | |||
| // 确认后清空当前会话的自由监控列表 | |||
| void FreeMonitorWidget::clearAddresses() | |||
| { | |||
| if (service_.addresses().empty()) | |||
| @@ -334,6 +480,34 @@ void FreeMonitorWidget::clearAddresses() | |||
| handleResult(tr("清空自由监控"), false, result.message); | |||
| } | |||
| // 请求切换编辑态监控数据源,失败时恢复服务中的实际选项 | |||
| void FreeMonitorWidget::selectEditingSource(int index) | |||
| { | |||
| if (index < 0) | |||
| { | |||
| return; | |||
| } | |||
| const EditingMonitorSource source = static_cast<EditingMonitorSource>( | |||
| ui_->editingSourceComboBox->itemData(index).toInt()); | |||
| const EditingMonitorSourceResult result = | |||
| service_.selectEditingSource(source); | |||
| if (!result.succeeded) | |||
| { | |||
| // 恢复下拉框时阻断信号,避免失败操作反复触发 | |||
| const QSignalBlocker blocker(ui_->editingSourceComboBox); | |||
| ui_->editingSourceComboBox->setCurrentIndex( | |||
| service_.editingSource() == EditingMonitorSource::Plc ? 1 : 0); | |||
| emit operationMessage(fromUtf8(result.message)); | |||
| return; | |||
| } | |||
| emit operationMessage( | |||
| source == EditingMonitorSource::Plc | |||
| ? tr("数据监控已切换到真机 PLC M/D") | |||
| : tr("数据监控已切换到离线 M/D")); | |||
| emit editingSourceChanged(); | |||
| } | |||
| // 将监控服务的失败结果转换为界面消息 | |||
| void FreeMonitorWidget::handleResult( | |||
| const QString &action, bool succeeded, const std::string &message) | |||
| { | |||
| @@ -36,6 +36,8 @@ public: | |||
| /** 根据当前模式和通信状态开启或关闭写入入口 */ | |||
| void setWriteEnabled(bool enabled); | |||
| /** 仅编辑态数据监控页显示独立的数据源选择控件 */ | |||
| void setEditingSourceSelectorVisible(bool visible); | |||
| /** 根据运行模式和 PLC 状态刷新所有监控值 */ | |||
| void refreshValues(ApplicationMode mode, PlcConnectionState plc_state); | |||
| /** 从服务重新加载当前会话中的地址列表 */ | |||
| @@ -46,6 +48,8 @@ signals: | |||
| void monitorAddressesChanged(); | |||
| /** 操作完成后发出可直接显示的消息 */ | |||
| void operationMessage(const QString &message); | |||
| /** 编辑态监控数据源成功切换后发出 */ | |||
| void editingSourceChanged(); | |||
| private: | |||
| /** 从输入框添加一个或多个监控地址 */ | |||
| @@ -54,10 +58,14 @@ private: | |||
| void removeSelectedAddresses(); | |||
| /** 清空当前会话中的监控地址 */ | |||
| void clearAddresses(); | |||
| /** 处理用户选择的编辑态监控数据源 */ | |||
| void selectEditingSource(int index); | |||
| /** 写入指定行中的寄存器值 */ | |||
| void writeRow(int row); | |||
| /** 根据当前权限刷新写入控件状态 */ | |||
| void updateWriteControls(); | |||
| /** 根据地址区域切换可选的数据类型 */ | |||
| void updateDataTypeSelector(const QString &address_text); | |||
| /** 统一处理监控服务返回结果 */ | |||
| void handleResult(const QString &action, bool succeeded, const std::string &message); | |||
| @@ -16,14 +16,16 @@ | |||
| <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="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="3"><widget class="QComboBox" name="dataTypeComboBox"><item><property name="text"><string>Int16</string></property></item><item><property name="text"><string>Int32</string></property></item><item><property name="text"><string>Float32</string></property></item><item><property name="text"><string>Double (Float64)</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="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> | |||
| <item row="1" column="2"><widget class="QLabel" name="editingSourceLabel"><property name="visible"><bool>false</bool></property><property name="text"><string>监控数据</string></property></widget></item> | |||
| <item row="1" column="3"><widget class="QComboBox" name="editingSourceComboBox"><property name="visible"><bool>false</bool></property><property name="minimumSize"><size><width>135</width><height>0</height></size></property><property name="maximumSize"><size><width>180</width><height>16777215</height></size></property><item><property name="text"><string>离线 M/D</string></property></item><item><property name="text"><string>真机 PLC M/D</string></property></item></widget></item> | |||
| <item row="1" column="4" 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> | |||
| </layout> | |||
| </item> | |||
| <item> | |||
| @@ -7,6 +7,7 @@ | |||
| #include <QApplication> | |||
| #include <QDateTime> | |||
| #include <QDoubleValidator> | |||
| #include <QFont> | |||
| #include <QGraphicsItem> | |||
| #include <QGraphicsRectItem> | |||
| @@ -14,6 +15,8 @@ | |||
| #include <QGraphicsSceneHoverEvent> | |||
| #include <QGraphicsSceneMouseEvent> | |||
| #include <QInputDialog> | |||
| #include <QLineEdit> | |||
| #include <QLocale> | |||
| #include <QPainter> | |||
| #include <QResizeEvent> | |||
| #include <QStyle> | |||
| @@ -25,9 +28,12 @@ | |||
| #include <cstdint> | |||
| #include <functional> | |||
| #include <limits> | |||
| #include <optional> | |||
| #include <type_traits> | |||
| namespace { | |||
| // 报警列表使用固定行高和列宽计算绘制及点击区域 | |||
| constexpr qreal kAlarmHeaderHeight = 24.0; | |||
| constexpr qreal kAlarmRowHeight = 22.0; | |||
| constexpr qreal kAlarmCellLeftPadding = 5.0; | |||
| @@ -38,6 +44,7 @@ constexpr qreal kAlarmStateColumnWidth = 52.0; | |||
| constexpr qreal kAlarmPageButtonSize = 16.0; | |||
| constexpr qreal kAlarmPageIndicatorWidth = 34.0; | |||
| // 将报警时间转换为时分秒显示文字 | |||
| QString alarmTimeText(const std::chrono::system_clock::time_point &time) | |||
| { | |||
| const auto seconds = std::chrono::duration_cast<std::chrono::seconds>( | |||
| @@ -45,6 +52,90 @@ QString alarmTimeText(const std::chrono::system_clock::time_point &time) | |||
| return QDateTime::fromSecsSinceEpoch(seconds).toString(QStringLiteral("HH:mm:ss")); | |||
| } | |||
| // 将不同数值类型的寄存器值格式化为无精度损失的文字 | |||
| QString numericValueText(const RegisterNumericValue &value) | |||
| { | |||
| // visit 在编译期按 variant 中的实际数值类型选择格式 | |||
| return std::visit( | |||
| [](const auto &typed_value) | |||
| { | |||
| using Value = std::decay_t<decltype(typed_value)>; | |||
| if constexpr (std::is_same_v<Value, float>) | |||
| { | |||
| return QString::number( | |||
| static_cast<double>(typed_value), 'g', | |||
| std::numeric_limits<float>::max_digits10); | |||
| } | |||
| else if constexpr (std::is_same_v<Value, double>) | |||
| { | |||
| return QString::number( | |||
| typed_value, 'g', std::numeric_limits<double>::max_digits10); | |||
| } | |||
| else | |||
| { | |||
| return QString::number(static_cast<qlonglong>(typed_value)); | |||
| } | |||
| }, | |||
| value); | |||
| } | |||
| // 将类型化寄存器数值统一转换为输入对话框使用的 double | |||
| double numericValueAsDouble(const RegisterNumericValue &value) | |||
| { | |||
| // std::visit 统一处理整数、浮点数等不同寄存器数值类型 | |||
| return std::visit( | |||
| [](const auto &typed_value) | |||
| { | |||
| return static_cast<double>(typed_value); | |||
| }, | |||
| value); | |||
| } | |||
| // 弹出支持小数和科学计数法的浮点数输入对话框 | |||
| std::optional<double> requestFloatingPointInput( | |||
| QWidget *parent, | |||
| const HmiControl &control, | |||
| const RegisterNumericValue ¤t_value, | |||
| bool has_current_value) | |||
| { | |||
| const bool float32 = control.dataType == RegisterDataType::Float32; | |||
| const double maximum = float32 | |||
| ? static_cast<double>(std::numeric_limits<float>::max()) | |||
| : std::numeric_limits<double>::max(); | |||
| const int digits = float32 | |||
| ? std::numeric_limits<float>::max_digits10 | |||
| : std::numeric_limits<double>::max_digits10; | |||
| QInputDialog dialog(parent); | |||
| dialog.setInputMode(QInputDialog::TextInput); | |||
| dialog.setWindowTitle(QObject::tr("输入 %1").arg( | |||
| QString::fromLatin1(registerDataTypeDescriptor(control.dataType).displayName))); | |||
| dialog.setLabelText(QString::fromUtf8( | |||
| control.text.data(), static_cast<int>(control.text.size()))); | |||
| dialog.setTextValue(QString::number( | |||
| has_current_value ? numericValueAsDouble(current_value) : 0.0, | |||
| 'g', digits)); | |||
| // QInputDialog 没有直接暴露验证器,通过内部输入框安装范围验证 | |||
| QLineEdit *editor = dialog.findChild<QLineEdit *>(); | |||
| if (editor != nullptr) | |||
| { | |||
| auto *validator = new QDoubleValidator(-maximum, maximum, digits, editor); | |||
| validator->setNotation(QDoubleValidator::ScientificNotation); | |||
| validator->setLocale(QLocale::c()); | |||
| editor->setValidator(validator); | |||
| editor->selectAll(); | |||
| } | |||
| if (dialog.exec() != QDialog::Accepted) | |||
| { | |||
| return std::nullopt; | |||
| } | |||
| bool converted = false; | |||
| // 使用固定 C locale,确保输入格式不受系统区域设置影响 | |||
| const double value = QLocale::c().toDouble(dialog.textValue(), &converted); | |||
| return converted && encodeRegisterNumericValue(control.dataType, value).has_value() | |||
| ? std::optional<double>{value} : std::nullopt; | |||
| } | |||
| // 将领域层 HMI 控件投影为可绘制、可选择和可交互的场景图元 | |||
| class HmiGraphicsItem final : public QGraphicsItem | |||
| { | |||
| @@ -72,7 +163,7 @@ public: | |||
| setPos(control_.bounds.x, control_.bounds.y); | |||
| // 所有控件均可选中,便于主窗口显示对应属性 | |||
| setFlag(ItemIsSelectable, true); | |||
| // 开启可选中 | |||
| // 开启位置变化通知,让 itemChange 可以限制拖动坐标 | |||
| setFlag(ItemSendsGeometryChanges, true); | |||
| // 画布只处理左键交互,保留其他按键给视图默认行为 | |||
| setAcceptedMouseButtons(Qt::LeftButton); | |||
| @@ -83,18 +174,19 @@ public: | |||
| { | |||
| if (!control_.binding.has_value()) | |||
| { | |||
| return visibleControlRect(); | |||
| return controlRect(); | |||
| } | |||
| return visibleControlRect().united(addressRect()); | |||
| return controlRect().united(addressRect()); | |||
| } | |||
| // 根据控件类型、运行值和交互状态绘制完整 HMI 控件 | |||
| void paint( | |||
| QPainter *painter, | |||
| const QStyleOptionGraphicsItem *option, | |||
| QWidget *) override | |||
| { | |||
| // 留出一个像素边距,避免描边被图元边界裁剪 | |||
| const QRectF rect = visibleControlRect().adjusted(1, 1, -1, -1); | |||
| const QRectF rect = controlRect().adjusted(1, 1, -1, -1); | |||
| painter->setRenderHint(QPainter::Antialiasing, true); | |||
| painter->setPen(QPen(QColor(QStringLiteral("#47545f")), 1)); | |||
| @@ -117,7 +209,7 @@ public: | |||
| case HmiControlType::Button: | |||
| { | |||
| const bool disabled = !editing_enabled_ && runtime_active_ | |||
| && !runtime_write_enabled_; | |||
| && (!runtime_write_enabled_ || !button_condition_enabled_); | |||
| const QColor fill = disabled | |||
| ? QColor(QStringLiteral("#e2e6e4")) | |||
| : button_pressed_ ? QColor(QStringLiteral("#a9cbb6")) | |||
| @@ -194,6 +286,15 @@ public: | |||
| textWithValue()); | |||
| break; | |||
| } | |||
| case HmiControlType::StatusText: | |||
| { | |||
| painter->setPen(configuredTextColor(QColor(QStringLiteral("#24313b")))); | |||
| painter->drawText( | |||
| rect.adjusted(4, 0, -4, 0), | |||
| Qt::AlignCenter | Qt::TextWordWrap, | |||
| textWithValue()); | |||
| break; | |||
| } | |||
| case HmiControlType::PageJump: | |||
| { | |||
| const QColor fill = runtime_active_ && page_hovered_ | |||
| @@ -363,17 +464,22 @@ public: | |||
| void setInteractionState( | |||
| bool editable, bool runtime_active, bool runtime_write_enabled) | |||
| { | |||
| if (control_.type == HmiControlType::AlarmList) | |||
| const bool runtime_was_active = runtime_active_; | |||
| if (control_.type == HmiControlType::AlarmList && !runtime_active) | |||
| { | |||
| prepareGeometryChange(); | |||
| if (!runtime_active) | |||
| { | |||
| alarm_page_ = 0U; | |||
| } | |||
| alarm_page_ = 0U; | |||
| } | |||
| editing_enabled_ = editable; | |||
| runtime_active_ = runtime_active; | |||
| runtime_write_enabled_ = runtime_active && runtime_write_enabled; | |||
| if (runtime_active_ && !runtime_was_active) | |||
| { | |||
| button_condition_enabled_ = false; | |||
| } | |||
| else if (!runtime_active_) | |||
| { | |||
| button_condition_enabled_ = false; | |||
| } | |||
| setFlag(ItemIsMovable, editable); | |||
| setFlag(ItemIsSelectable, editable); | |||
| if (!editable) | |||
| @@ -386,15 +492,20 @@ public: | |||
| || control_.type == HmiControlType::NumericInput | |||
| || control_.type == HmiControlType::PageJump | |||
| || control_.type == HmiControlType::AlarmList); | |||
| const bool runtime_write_input_enabled = control_.type | |||
| == HmiControlType::Button | |||
| ? runtime_write_enabled_ && button_condition_enabled_ | |||
| : runtime_write_enabled_; | |||
| const bool runtime_input_enabled = runtime_input | |||
| && (control_.type == HmiControlType::PageJump | |||
| || control_.type == HmiControlType::AlarmList | |||
| || runtime_write_enabled_); | |||
| || runtime_write_input_enabled); | |||
| setAcceptedMouseButtons( | |||
| editable || runtime_input_enabled ? Qt::LeftButton : Qt::NoButton); | |||
| const bool runtime_button_enabled = runtime_active_ | |||
| && control_.type == HmiControlType::Button && runtime_write_enabled_; | |||
| && control_.type == HmiControlType::Button && runtime_write_enabled_ | |||
| && button_condition_enabled_; | |||
| const bool runtime_page_jump_enabled = runtime_active_ | |||
| && control_.type == HmiControlType::PageJump; | |||
| const bool runtime_alarm_enabled = runtime_active_ | |||
| @@ -410,34 +521,48 @@ public: | |||
| unsetCursor(); | |||
| button_hovered_ = false; | |||
| page_hovered_ = false; | |||
| button_pressed_ = false; | |||
| if (!runtime_active_ || !runtime_write_enabled_) | |||
| { | |||
| button_pressed_ = false; | |||
| } | |||
| } | |||
| updateAlarmVisibility(); | |||
| update(); | |||
| } | |||
| // 缓存运行服务读出的值并触发 Qt 重绘 | |||
| void setRuntimeValue( | |||
| bool bit_value, std::int16_t word_value, float float_value, bool available) | |||
| bool bit_value, const RegisterNumericValue &numeric_value, bool available) | |||
| { | |||
| bit_value_ = bit_value; | |||
| word_value_ = word_value; | |||
| float_value_ = float_value; | |||
| numeric_value_ = numeric_value; | |||
| has_runtime_value_ = available; | |||
| update(); | |||
| } | |||
| // 更新按钮启用条件结果并重新计算可交互状态 | |||
| void setButtonConditionEnabled(bool enabled) | |||
| { | |||
| button_condition_enabled_ = enabled; | |||
| setInteractionState( | |||
| editing_enabled_, runtime_active_, runtime_write_enabled_); | |||
| } | |||
| // 缓存状态文本读取结果并触发重绘 | |||
| void setStatusText(const std::string &text, bool available) | |||
| { | |||
| status_text_ = QString::fromUtf8( | |||
| text.data(), static_cast<int>(text.size())); | |||
| status_text_available_ = available; | |||
| update(); | |||
| } | |||
| // 更新报警记录并将当前页限制在有效分页范围内 | |||
| void setAlarmRecords(const std::vector<AlarmRecord> &records) | |||
| { | |||
| if (control_.type == HmiControlType::AlarmList) | |||
| { | |||
| prepareGeometryChange(); | |||
| } | |||
| alarm_records_ = records; | |||
| const std::size_t page_count = alarmPageCount(); | |||
| alarm_page_ = page_count == 0U | |||
| ? 0U : std::min(alarm_page_, page_count - 1U); | |||
| updateAlarmVisibility(); | |||
| update(); | |||
| } | |||
| @@ -469,7 +594,7 @@ protected: | |||
| if (event->pos().y() < kAlarmHeaderHeight) | |||
| { | |||
| const QRectF content_rect = | |||
| visibleControlRect().adjusted(1, 1, -1, -1); | |||
| controlRect().adjusted(1, 1, -1, -1); | |||
| const QRectF header( | |||
| content_rect.left(), | |||
| content_rect.top(), | |||
| @@ -518,6 +643,7 @@ protected: | |||
| return; | |||
| } | |||
| if (runtime_active_ && runtime_write_enabled_ | |||
| && button_condition_enabled_ | |||
| && control_.type == HmiControlType::Button && button_event_) | |||
| { | |||
| button_pressed_ = true; | |||
| @@ -577,6 +703,7 @@ protected: | |||
| } | |||
| } | |||
| // 鼠标进入时更新按钮或页面跳转控件的悬停效果 | |||
| void hoverEnterEvent(QGraphicsSceneHoverEvent *event) override | |||
| { | |||
| if (runtime_active_ && runtime_write_enabled_ | |||
| @@ -593,6 +720,7 @@ protected: | |||
| QGraphicsItem::hoverEnterEvent(event); | |||
| } | |||
| // 鼠标离开时清除运行态悬停效果 | |||
| void hoverLeaveEvent(QGraphicsSceneHoverEvent *event) override | |||
| { | |||
| if (button_hovered_) | |||
| @@ -609,6 +737,7 @@ protected: | |||
| } | |||
| private: | |||
| // 根据控件高度和项目上限计算报警列表每页可见行数 | |||
| std::size_t alarmPageSize() const | |||
| { | |||
| const QRectF rect = controlRect().adjusted(1, 1, -1, -1); | |||
| @@ -620,6 +749,7 @@ private: | |||
| rows_by_height, ProjectLimits::kMaximumVisibleAlarmRows)); | |||
| } | |||
| // 根据记录数量和每页行数计算报警总页数 | |||
| std::size_t alarmPageCount() const | |||
| { | |||
| const std::size_t page_size = alarmPageSize(); | |||
| @@ -628,11 +758,13 @@ private: | |||
| : (alarm_records_.size() + page_size - 1U) / page_size; | |||
| } | |||
| // 返回当前报警页第一条记录的下标 | |||
| std::size_t alarmFirstRecordIndex() const | |||
| { | |||
| return alarm_page_ * alarmPageSize(); | |||
| } | |||
| // 返回当前报警页实际可见的记录数量 | |||
| std::size_t visibleAlarmRecordCount() const | |||
| { | |||
| const std::size_t first_record = alarmFirstRecordIndex(); | |||
| @@ -644,22 +776,7 @@ private: | |||
| alarmPageSize(), alarm_records_.size() - first_record); | |||
| } | |||
| QRectF visibleControlRect() const | |||
| { | |||
| const QRectF full_rect = controlRect(); | |||
| if (control_.type != HmiControlType::AlarmList || !runtime_active_) | |||
| { | |||
| return full_rect; | |||
| } | |||
| const qreal content_height = kAlarmHeaderHeight | |||
| + static_cast<qreal>(visibleAlarmRecordCount()) * kAlarmRowHeight | |||
| + 2.0; | |||
| return {full_rect.left(), | |||
| full_rect.top(), | |||
| full_rect.width(), | |||
| std::min(full_rect.height(), content_height)}; | |||
| } | |||
| // 计算报警标题栏下一页按钮的点击区域 | |||
| QRectF alarmNextPageRect(const QRectF &header) const | |||
| { | |||
| return { | |||
| @@ -669,6 +786,7 @@ private: | |||
| kAlarmPageButtonSize}; | |||
| } | |||
| // 计算报警标题栏页码文字的显示区域 | |||
| QRectF alarmPageIndicatorRect(const QRectF &header) const | |||
| { | |||
| const QRectF next_rect = alarmNextPageRect(header); | |||
| @@ -679,6 +797,7 @@ private: | |||
| header.height()}; | |||
| } | |||
| // 计算报警标题栏上一页按钮的点击区域 | |||
| QRectF alarmPreviousPageRect(const QRectF &header) const | |||
| { | |||
| const QRectF indicator_rect = alarmPageIndicatorRect(header); | |||
| @@ -689,17 +808,6 @@ private: | |||
| kAlarmPageButtonSize}; | |||
| } | |||
| void updateAlarmVisibility() | |||
| { | |||
| if (control_.type != HmiControlType::AlarmList) | |||
| { | |||
| return; | |||
| } | |||
| const bool has_active_alarm = !alarm_records_.empty(); | |||
| setVisible(editing_enabled_ || !runtime_active_ || has_active_alarm); | |||
| setZValue(runtime_active_ && has_active_alarm ? 1000.0 : 0.0); | |||
| } | |||
| // 控件本体范围保持领域坐标和尺寸语义不变 | |||
| QRectF controlRect() const | |||
| { | |||
| @@ -720,6 +828,7 @@ private: | |||
| address_height}; | |||
| } | |||
| // 返回控件绑定地址的显示文字 | |||
| QString bindingText() const | |||
| { | |||
| return control_.binding.has_value() | |||
| @@ -727,6 +836,7 @@ private: | |||
| : QString{}; | |||
| } | |||
| // 读取控件文字颜色,无效或未配置时使用默认颜色 | |||
| QColor configuredTextColor(const QColor &fallback) const | |||
| { | |||
| const auto property = control_.properties.find( | |||
| @@ -740,6 +850,7 @@ private: | |||
| return color.isValid() ? color : fallback; | |||
| } | |||
| // 将控件配置的字号、粗体和斜体应用到画笔 | |||
| void applyConfiguredFont(QPainter *painter) const | |||
| { | |||
| if (painter == nullptr) | |||
| @@ -790,6 +901,12 @@ private: | |||
| { | |||
| const QString text = QString::fromUtf8( | |||
| control_.text.data(), static_cast<int>(control_.text.size())); | |||
| if (control_.type == HmiControlType::StatusText) | |||
| { | |||
| return runtime_active_ | |||
| ? (status_text_available_ ? status_text_ : QStringLiteral("--")) | |||
| : QStringLiteral("状态文本"); | |||
| } | |||
| if (!has_runtime_value_ || control_.type == HmiControlType::Button) | |||
| { | |||
| return text; | |||
| @@ -797,9 +914,7 @@ private: | |||
| if (control_.type == HmiControlType::NumericDisplay | |||
| || control_.type == HmiControlType::NumericInput) | |||
| { | |||
| const QString value = control_.dataType == RegisterDataType::Float32 | |||
| ? QString::number(float_value_, 'g', 7) | |||
| : QString::number(word_value_); | |||
| const QString value = numericValueText(numeric_value_); | |||
| return text + QStringLiteral(": ") + value; | |||
| } | |||
| return text; | |||
| @@ -813,26 +928,44 @@ private: | |||
| int page_height_ = 0; | |||
| // 拖拽完成后回调 HmiEditorWidget 提交控件新位置 | |||
| std::function<void(const std::string &, const QPointF &)> moved_; | |||
| // 运行态按钮按下和释放回调 | |||
| std::function<void(const std::string &, HmiButtonEvent)> button_event_; | |||
| // 运行态数值输入激活回调 | |||
| std::function<void(const std::string &)> numeric_input_activated_; | |||
| // 运行态页面跳转回调 | |||
| std::function<void(const std::string &)> page_navigation_; | |||
| // 运行态报警确认回调 | |||
| std::function<void(const std::string &)> alarm_acknowledge_; | |||
| // 当前报警列表控件显示的记录 | |||
| std::vector<AlarmRecord> alarm_records_; | |||
| // 当前报警分页下标 | |||
| std::size_t alarm_page_ = 0U; | |||
| // 当前是否允许选择和拖动图元 | |||
| bool editing_enabled_ = true; | |||
| // 当前是否处于 HMI 运行态 | |||
| bool runtime_active_ = false; | |||
| // 当前运行态是否允许写寄存器 | |||
| bool runtime_write_enabled_ = false; | |||
| // 当前按钮启用条件是否满足 | |||
| bool button_condition_enabled_ = true; | |||
| // 按钮是否处于鼠标悬停状态 | |||
| bool button_hovered_ = false; | |||
| // 按钮是否处于按下状态 | |||
| bool button_pressed_ = false; | |||
| // 页面跳转控件是否处于鼠标悬停状态 | |||
| bool page_hovered_ = false; | |||
| // 页面跳转控件是否处于按下状态 | |||
| bool page_pressed_ = false; | |||
| // 指示灯读取到的 M 位值 | |||
| bool bit_value_ = false; | |||
| // 数值控件读取到的 D 字值 | |||
| std::int16_t word_value_ = 0; | |||
| float float_value_ = 0.0f; | |||
| // 数值控件读取到的类型化 D 值 | |||
| RegisterNumericValue numeric_value_ = std::int16_t{0}; | |||
| // 标记当前缓存值是否来自一次成功的运行时读取 | |||
| bool has_runtime_value_ = false; | |||
| // 状态文本控件最近一次读取到的显示文字 | |||
| QString status_text_; | |||
| // 状态文本最近一次读取是否成功 | |||
| bool status_text_available_ = false; | |||
| }; | |||
| // 将场景通用图元安全转换为本文件定义的 HMI 控件图元 | |||
| @@ -843,6 +976,7 @@ HmiGraphicsItem *asHmiItem(QGraphicsItem *item) | |||
| } // namespace | |||
| // 创建 HMI 图形画布并连接场景选择信号 | |||
| HmiEditorWidget::HmiEditorWidget( | |||
| HmiEditorService &editor_service, | |||
| HmiRuntimeService &runtime_service, | |||
| @@ -875,6 +1009,7 @@ void HmiEditorWidget::setPageId(const std::string &page_id) | |||
| reloadPage(); | |||
| } | |||
| // 设置画布是否允许选择和拖动控件 | |||
| void HmiEditorWidget::setEditingEnabled(bool enabled) | |||
| { | |||
| editing_enabled_ = enabled; | |||
| @@ -882,6 +1017,7 @@ void HmiEditorWidget::setEditingEnabled(bool enabled) | |||
| updateItemInteractions(); | |||
| } | |||
| // 设置是否启用运行值刷新和运行态控件交互 | |||
| void HmiEditorWidget::setRuntimeActive(bool active) | |||
| { | |||
| runtime_active_ = active; | |||
| @@ -893,7 +1029,8 @@ void HmiEditorWidget::setRuntimeActive(bool active) | |||
| HmiGraphicsItem *control_item = asHmiItem(item); | |||
| if (control_item != nullptr) | |||
| { | |||
| control_item->setRuntimeValue(false, 0, 0.0f, false); | |||
| control_item->setRuntimeValue(false, std::int16_t{0}, false); | |||
| control_item->setStatusText({}, false); | |||
| control_item->setAlarmRecords({}); | |||
| } | |||
| } | |||
| @@ -901,6 +1038,7 @@ void HmiEditorWidget::setRuntimeActive(bool active) | |||
| updateItemInteractions(); | |||
| } | |||
| // 设置运行态按钮和数值输入是否允许写入 | |||
| void HmiEditorWidget::setRuntimeWriteEnabled(bool enabled) | |||
| { | |||
| runtime_write_enabled_ = runtime_active_ && enabled; | |||
| @@ -979,12 +1117,42 @@ void HmiEditorWidget::selectControl(const std::string &control_id) | |||
| } | |||
| } | |||
| // 选中指定的多个控件并滚动到第一个匹配项 | |||
| void HmiEditorWidget::selectControls( | |||
| const std::vector<std::string> &control_ids) | |||
| { | |||
| scene_->clearSelection(); | |||
| HmiGraphicsItem *first_selected = nullptr; | |||
| for (QGraphicsItem *item : scene_->items()) | |||
| { | |||
| HmiGraphicsItem *control_item = asHmiItem(item); | |||
| if (control_item == nullptr | |||
| || std::find( | |||
| control_ids.cbegin(), control_ids.cend(), | |||
| control_item->controlId()) == control_ids.cend()) | |||
| { | |||
| continue; | |||
| } | |||
| control_item->setSelected(true); | |||
| if (first_selected == nullptr) | |||
| { | |||
| first_selected = control_item; | |||
| } | |||
| } | |||
| if (first_selected != nullptr) | |||
| { | |||
| ensureVisible(first_selected); | |||
| } | |||
| } | |||
| // 返回当前排序后的第一个选中控件标识 | |||
| std::string HmiEditorWidget::selectedControlId() const | |||
| { | |||
| const std::vector<std::string> ids = selectedControlIds(); | |||
| return ids.empty() ? std::string{} : ids.front(); | |||
| } | |||
| // 按从上到下、从左到右的顺序返回选中控件标识 | |||
| std::vector<std::string> HmiEditorWidget::selectedControlIds() const | |||
| { | |||
| std::vector<std::pair<QPointF, std::string>> positioned_ids; | |||
| @@ -1016,6 +1184,7 @@ std::vector<std::string> HmiEditorWidget::selectedControlIds() const | |||
| return ids; | |||
| } | |||
| // 通过运行服务刷新当前页面所有控件的运行值 | |||
| void HmiEditorWidget::refreshRuntimeValues() | |||
| { | |||
| // 运行态才读取寄存器;编辑态画布只显示工程配置,不显示缓存值 | |||
| @@ -1041,30 +1210,48 @@ void HmiEditorWidget::refreshRuntimeValues() | |||
| control_item->setAlarmRecords(alarm_service_.records()); | |||
| continue; | |||
| } | |||
| if (control->type == HmiControlType::Button) | |||
| { | |||
| const HmiButtonEnabledResult enabled = | |||
| runtime_service_.evaluateButtonEnabled(*control); | |||
| control_item->setButtonConditionEnabled( | |||
| enabled.succeeded && enabled.enabled); | |||
| } | |||
| if (control->type == HmiControlType::StatusText) | |||
| { | |||
| const HmiStatusTextReadResult value = | |||
| runtime_service_.readStatusText(*control); | |||
| control_item->setStatusText(value.text, value.succeeded); | |||
| continue; | |||
| } | |||
| // 运行值通过服务读取,图元不直接接触寄存器仓库 | |||
| const HmiRuntimeReadResult value = runtime_service_.readControl(*control); | |||
| control_item->setRuntimeValue( | |||
| value.bit_value, value.word_value, value.float_value, value.succeeded); | |||
| value.bit_value, value.numeric_value, value.succeeded); | |||
| } | |||
| } | |||
| // 视图尺寸变化后保持完整页面可见 | |||
| void HmiEditorWidget::resizeEvent(QResizeEvent *event) | |||
| { | |||
| QGraphicsView::resizeEvent(event); | |||
| fitCurrentPage(); | |||
| } | |||
| // 按宽高比将当前页面缩放到视图可见范围 | |||
| void HmiEditorWidget::fitCurrentPage() | |||
| { | |||
| if (scene_->sceneRect().isEmpty()) | |||
| { | |||
| return; | |||
| } | |||
| // KeepAspectRatio 防止页面和控件被非等比拉伸 | |||
| fitInView( | |||
| scene_->sceneRect().adjusted(-24, -24, 24, 24), | |||
| Qt::KeepAspectRatio); | |||
| } | |||
| // 将编辑和运行权限同步到场景中的所有 HMI 图元 | |||
| void HmiEditorWidget::updateItemInteractions() | |||
| { | |||
| // 遍历所有控件图元,统一设置flag | |||
| @@ -1079,11 +1266,13 @@ void HmiEditorWidget::updateItemInteractions() | |||
| } | |||
| } | |||
| // 将场景选择变化转换为控件标识信号 | |||
| void HmiEditorWidget::handleSelectionChanged() | |||
| { | |||
| emit controlSelected(QString::fromStdString(selectedControlId())); | |||
| } | |||
| // 将图元最终位置取整后交给编辑服务保存 | |||
| void HmiEditorWidget::handleControlMoved( | |||
| const std::string &control_id, const QPointF &position) | |||
| { | |||
| @@ -1108,6 +1297,7 @@ void HmiEditorWidget::handleControlMoved( | |||
| emit controlChanged(QString::fromStdString(control_id)); | |||
| } | |||
| // 将运行态按钮事件交给运行服务处理 | |||
| void HmiEditorWidget::handleButtonEvent( | |||
| const std::string &control_id, HmiButtonEvent event) | |||
| { | |||
| @@ -1128,12 +1318,19 @@ void HmiEditorWidget::handleButtonEvent( | |||
| const HmiRuntimeWriteResult result = runtime_service_.operateButton(*control, event); | |||
| if (!result.succeeded) | |||
| { | |||
| if (result.error == HmiRuntimeError::ConditionNotMet | |||
| || result.error == HmiRuntimeError::ConditionUnavailable) | |||
| { | |||
| refreshRuntimeValues(); | |||
| return; | |||
| } | |||
| emit editorError(tr("寄存器操作失败")); | |||
| return; | |||
| } | |||
| refreshRuntimeValues(); | |||
| } | |||
| // 读取当前值并根据数据类型弹出数值输入对话框 | |||
| void HmiEditorWidget::handleNumericInputActivated(const std::string &control_id) | |||
| { | |||
| if (!runtime_active_ || !runtime_write_enabled_) | |||
| @@ -1145,41 +1342,45 @@ void HmiEditorWidget::handleNumericInputActivated(const std::string &control_id) | |||
| { | |||
| return; | |||
| } | |||
| bool accepted = false; | |||
| const HmiRuntimeReadResult current = runtime_service_.readControl(*control); | |||
| double value = 0.0; | |||
| if (control->dataType == RegisterDataType::Float32) | |||
| std::optional<double> value; | |||
| if (registerDataTypeDescriptor(control->dataType).floatingPoint) | |||
| { | |||
| 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); | |||
| value = requestFloatingPointInput( | |||
| this, *control, current.numeric_value, current.succeeded); | |||
| } | |||
| else | |||
| { | |||
| value = QInputDialog::getInt( | |||
| bool accepted = false; | |||
| const int minimum = control->dataType == RegisterDataType::Int16 | |||
| ? std::numeric_limits<std::int16_t>::min() | |||
| : std::numeric_limits<std::int32_t>::min(); | |||
| const int maximum = control->dataType == RegisterDataType::Int16 | |||
| ? std::numeric_limits<std::int16_t>::max() | |||
| : std::numeric_limits<std::int32_t>::max(); | |||
| const int input = QInputDialog::getInt( | |||
| this, | |||
| tr("输入 Int16"), | |||
| tr("输入 %1").arg(QString::fromLatin1( | |||
| registerDataTypeDescriptor(control->dataType).displayName)), | |||
| 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(), | |||
| current.succeeded | |||
| ? static_cast<int>(numericValueAsDouble(current.numeric_value)) : 0, | |||
| minimum, | |||
| maximum, | |||
| 1, | |||
| &accepted); | |||
| if (accepted) | |||
| { | |||
| value = input; | |||
| } | |||
| } | |||
| if (!accepted) | |||
| if (!value.has_value()) | |||
| { | |||
| return; | |||
| } | |||
| const HmiRuntimeWriteResult result = runtime_service_.writeNumericInput( | |||
| *control, value); | |||
| *control, *value); | |||
| if (!result.succeeded) | |||
| { | |||
| emit editorError(tr("寄存器操作失败")); | |||
| @@ -42,65 +42,39 @@ public: | |||
| AlarmService &alarm_service, | |||
| QWidget *parent = nullptr); | |||
| /** | |||
| * @brief 切换当前投影页面并重建画布图元 | |||
| * @param page_id 要显示的页面标识,不存在时显示空场景 | |||
| */ | |||
| /** 切换当前投影页面并重建画布图元 */ | |||
| void setPageId(const std::string &page_id); | |||
| /** | |||
| * @brief 设置图元是否可移动 | |||
| * @param enabled 为 false 时禁止编辑拖动,运行态交互由运行时开关单独控制 | |||
| */ | |||
| /** 设置图元是否可以在编辑态选择和移动 */ | |||
| void setEditingEnabled(bool enabled); | |||
| /** | |||
| * @brief 设置离线运行态寄存器刷新和控件交互是否启用 | |||
| * @param active 为 true 时允许按钮和数值输入调用运行时服务 | |||
| */ | |||
| /** 设置是否启用运行值刷新和运行态控件交互 */ | |||
| void setRuntimeActive(bool active); | |||
| /** 故障态保持运行值可见,但禁止按钮和数值输入继续写入 */ | |||
| void setRuntimeWriteEnabled(bool enabled); | |||
| /** | |||
| * @brief 使用当前页面模型完全重建场景 | |||
| * | |||
| * 用于工程加载、控件属性更新后同步模型投影 | |||
| */ | |||
| /** 使用当前页面模型完全重建场景 */ | |||
| void reloadPage(); | |||
| /** | |||
| * @brief 选中指定控件并滚动到可见区域 | |||
| * @param control_id 待选中控件标识,不存在时不改变当前选择 | |||
| */ | |||
| /** 选中指定控件并滚动到可见区域 */ | |||
| void selectControl(const std::string &control_id); | |||
| /** 选中指定的多个控件并滚动到第一个可见控件 */ | |||
| void selectControls(const std::vector<std::string> &control_ids); | |||
| /** 返回当前选中的单个控件标识 */ | |||
| std::string selectedControlId() const; | |||
| /** 返回当前选中的多个控件标识 */ | |||
| std::vector<std::string> selectedControlIds() const; | |||
| /** | |||
| * @brief 从统一寄存器仓库读取当前值并刷新画布显示 | |||
| * | |||
| * 仅在运行时开关启用时执行,仓库读取失败的控件显示为无有效运行值 | |||
| */ | |||
| /** 从统一寄存器仓库读取当前值并刷新画布显示 */ | |||
| void refreshRuntimeValues(); | |||
| signals: | |||
| /** | |||
| * @brief 选中控件变化时发出 | |||
| * @param control_id 当前控件标识,没有选择时为空字符串 | |||
| */ | |||
| /** 选中控件变化时发出,没有选择时标识为空 */ | |||
| void controlSelected(const QString &control_id); | |||
| /** | |||
| * @brief 编辑服务成功更新控件后发出 | |||
| * @param control_id 已更新控件标识 | |||
| */ | |||
| /** 编辑服务成功更新控件后发出 */ | |||
| void controlChanged(const QString &control_id); | |||
| /** | |||
| * @brief 编辑或运行时操作失败时发出 | |||
| * @param message 可直接显示的错误信息 | |||
| */ | |||
| /** 编辑或运行时操作失败时发出 */ | |||
| void editorError(const QString &message); | |||
| /** 运行态请求跳转到指定 HMI 页面 */ | |||
| void pageNavigationRequested(const QString &target_page_id); | |||
| protected: | |||
| /** 视图尺寸变化后重新缩放当前页面 */ | |||
| void resizeEvent(QResizeEvent *event) override; | |||
| private: | |||
| @@ -1,11 +1,3 @@ | |||
| /** | |||
| * @file logic_editor_widget.h | |||
| * @brief 定义结构化梯形图编辑和运行轨迹画布 | |||
| * @version 1.0.0 | |||
| * @author suyu | |||
| * @date 2026-08-22 | |||
| */ | |||
| #pragma once | |||
| #include "domain/control_logic_model.h" | |||
| @@ -14,6 +6,7 @@ | |||
| #include "services/software_logic_executor.h" | |||
| #include <QGraphicsView> | |||
| #include <QPoint> | |||
| #include <string> | |||
| #include <utility> | |||
| @@ -22,185 +15,238 @@ | |||
| class QGraphicsScene; | |||
| class QCompleter; | |||
| class QEvent; | |||
| class QKeyEvent; | |||
| class QLineEdit; | |||
| class QMouseEvent; | |||
| class QPainter; | |||
| 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; | |||
| // 鼠标在画布上的选择、画线和删线模式 | |||
| enum class MouseWireMode | |||
| { | |||
| Select, // 选择单元格、节点、输出、竖线或整行 | |||
| Draw, // 拖动绘制连续横线或竖线 | |||
| Erase // 拖动删除连续横线或竖线 | |||
| }; | |||
| /** 创建梯形图画布并绑定编辑服务 */ | |||
| // 创建梯形图画布并绑定编辑服务 | |||
| explicit LogicEditorWidget( | |||
| LogicEditorService &editor_service, | |||
| QWidget *parent = nullptr); | |||
| /** 切换当前显示的控制逻辑;不存在时显示空场景 */ | |||
| // 切换当前投影的控制逻辑 | |||
| void setLogicId(const std::string &logic_id); | |||
| /** 设置是否允许编辑梯形图 */ | |||
| // 设置画布是否允许编辑和选择 | |||
| void setEditingEnabled(bool enabled); | |||
| /** 设置离线运行轨迹;真机模式应传入空轨迹,避免显示本地伪轨迹 */ | |||
| // 切换鼠标选择、画线或删线模式 | |||
| void setMouseWireMode(MouseWireMode mode); | |||
| // 返回当前鼠标操作模式 | |||
| MouseWireMode mouseWireMode() const; | |||
| // 显示指定运行轨迹,并可突出故障节点 | |||
| void setRuntimeTrace( | |||
| const LogicTraceSnapshot &trace, | |||
| const std::string &fault_node_id = {}); | |||
| /** 清除当前运行轨迹和故障节点标记 */ | |||
| // 清除运行轨迹并恢复编辑态显示 | |||
| void clearRuntimeTrace(); | |||
| /** 返回当前是否正在显示运行轨迹 */ | |||
| bool runtimeTraceEnabled() const; | |||
| /** 根据当前逻辑模型重建梯形图场景 */ | |||
| // 使用当前逻辑模型完全重建场景 | |||
| void reloadLogic(); | |||
| /** 选中指定节点并滚动到可见区域 */ | |||
| // 清除画布中的全部对象选择 | |||
| void clearSelection(); | |||
| // 选中并滚动到指定节点 | |||
| void selectNode(const std::string &node_id); | |||
| /** 返回当前选中的单个节点标识 */ | |||
| /** 选中并滚动到语法错误位置,column 使用界面显示的 1 基列号 */ | |||
| void focusSyntaxLocation(const std::string &rung_id, int column); | |||
| // 返回当前唯一选中的节点标识 | |||
| std::string selectedNodeId() const; | |||
| /** 返回当前选中的多个节点标识 */ | |||
| // 返回当前选中的全部节点标识 | |||
| std::vector<std::string> selectedNodeIds() const; | |||
| /** 返回当前选中的网络标识 */ | |||
| // 返回当前光标或整行选择对应的网络行标识 | |||
| std::string selectedRungId() const; | |||
| // 返回当前选择是否可以生成梯形图剪贴板片段 | |||
| bool hasCopyableSelection() const; | |||
| // 将当前显式选择复制为服务层剪贴板片段 | |||
| LogicClipboardCopyResult copySelection() const; | |||
| // 根据当前选择计算粘贴目标位置 | |||
| LogicPasteTarget pasteTarget() const; | |||
| /** 新增一个空的梯形图网络 */ | |||
| // 在当前逻辑末尾新增一行 | |||
| LogicEditorResult addRung(); | |||
| /** 将当前选择转换为服务调用并新增串联条件 */ | |||
| // 在当前行之前或之后插入一行 | |||
| LogicEditorResult insertRung(bool after); | |||
| // 删除当前选中的梯形图行 | |||
| LogicEditorResult deleteRung(); | |||
| // 在当前光标位置添加串联条件 | |||
| 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); | |||
| /** 删除当前选中的节点、线段或网络 */ | |||
| // 将剪贴板片段粘贴到当前目标 | |||
| LogicClipboardPasteResult pasteClipboard( | |||
| const LogicClipboardFragment &fragment); | |||
| // 原子删除当前显式选中的网格对象 | |||
| 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 keyPressEvent(QKeyEvent *event) override; | |||
| // 在场景前景层绘制尚未提交的画线预览 | |||
| void drawForeground(QPainter *painter, const QRectF &rect) override; | |||
| // 视图尺寸变化后重新定位橡皮框和内嵌编辑器 | |||
| void resizeEvent(QResizeEvent *event) override; | |||
| /** 滚动画布时同步命令输入框位置 */ | |||
| // 滚动画布后同步内嵌指令编辑器位置 | |||
| void scrollContentsBy(int dx, int dy) override; | |||
| /** 处理命令输入框的回车、取消和按键事件 */ | |||
| // 过滤内嵌指令输入框的提交和取消按键 | |||
| bool eventFilter(QObject *watched, QEvent *event) override; | |||
| private: | |||
| /** 处理图形场景选择变化 */ | |||
| void handleSelectionChanged(); | |||
| /** 把编辑服务结果转换为错误信号 */ | |||
| // 保存一条视觉行在场景中的定位信息 | |||
| struct RowLayout | |||
| { | |||
| std::string rungId; // 对应的稳定网络行标识 | |||
| int row = -1; // 在当前逻辑中的视觉行号 | |||
| qreal gridTop = 0.0; // 条件网格顶部场景坐标 | |||
| qreal centerY = 0.0; // 本行母线中心场景坐标 | |||
| qreal bottom = 0.0; // 本行命中区域底部场景坐标 | |||
| bool networkHead = false; // 是否为当前连通网络的首行 | |||
| }; | |||
| // 描述一次场景命中的业务对象和位置 | |||
| struct Hit | |||
| { | |||
| std::string rungId; // 命中位置所在的网络行 | |||
| int column = -1; // 条件列或列边界编号 | |||
| LadderCellKind cellKind = LadderCellKind::Gap; // 命中单元格的模型类型 | |||
| bool output = false; // 是否命中第 11 列输出槽 | |||
| bool vertical = false; // 是否命中已有竖线 | |||
| bool boundary = false; // 是否命中可编辑的列边界 | |||
| bool rowHeader = false; // 是否命中左侧整行选择区域 | |||
| std::string lowerRungId; // 竖线命中时相邻下方行标识 | |||
| std::string objectId; // 命中节点、单元格或竖线的稳定标识 | |||
| }; | |||
| // 将编辑服务失败结果转换为界面错误信号 | |||
| 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<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); | |||
| /** 提交当前命令输入;失败时保留输入框并标红 */ | |||
| // 将场景坐标解析为梯形图业务命中结果 | |||
| Hit hitAt(const QPointF &scene_position) const; | |||
| // 记录鼠标手势起点和初始选择 | |||
| void beginGesture(const Hit &hit); | |||
| // 更新鼠标手势终点和前景预览 | |||
| void updateGesture(const Hit &hit, const QPointF &scene_position); | |||
| // 将完整鼠标手势一次性提交编辑服务 | |||
| void finishGesture(const Hit &hit); | |||
| // 清除尚未提交的手势状态和预览 | |||
| void clearGesture(); | |||
| // 绘制当前横向或纵向画线手势预览 | |||
| void drawGesturePreview(QPainter *painter) const; | |||
| // 在命中位置显示内嵌指令输入框 | |||
| void showCommandEditor(const Hit &hit); | |||
| // 解析并提交内嵌指令文本 | |||
| 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); | |||
| // 将服务层编辑光标转换为命令输入目标 | |||
| LogicCommandTarget commandTargetForCursor( | |||
| const LogicEditCursor &cursor) const; | |||
| // 根据当前选择生成条件插入光标 | |||
| LogicEditCursor conditionInsertionCursor() const; | |||
| // 根据服务返回光标移动画布选择 | |||
| void moveToCursor(const LogicEditCursor &cursor); | |||
| // 根据竖线编辑结果推进到下一视觉目标 | |||
| void moveToVerticalTarget(const LogicVerticalEditResult &result); | |||
| // 处理带下一光标的编辑结果并刷新场景 | |||
| LogicEditorResult finishCursorEdit(const LogicEditResult &result); | |||
| // 根据当前逻辑模型重新创建全部图元和布局缓存 | |||
| void rebuildScene(); | |||
| // 选中单次命中的对象并处理扩展节点选择 | |||
| void selectObject(const Hit &hit, bool extend_node_selection = false); | |||
| // 将视口橡皮框覆盖的图元加入显式选择 | |||
| void selectObjectsInBand(const QRect &viewport_rect, bool extend_selection); | |||
| // 清除所有业务对象选择状态 | |||
| void clearObjectSelection(); | |||
| // 同步节点选择和其他类型选择之间的互斥关系 | |||
| void synchronizeSelectedNodes(); | |||
| // 汇总当前选择并通知属性面板 | |||
| void notifySelectionChanged(); | |||
| // 从控制逻辑模型重建视觉行定位缓存 | |||
| void rebuildRowLayout(const ControlLogic &logic); | |||
| // 按网络行标识查找视觉布局 | |||
| const RowLayout *layoutForRung(const std::string &rung_id) const; | |||
| // 返回指定网络行的视觉序号 | |||
| int rowAt(const std::string &rung_id) const; | |||
| /** 梯形图编辑服务,不由控件拥有 */ | |||
| 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_; | |||
| LogicEditorService &editor_service_; // 不拥有的梯形图编辑服务 | |||
| LogicCommandService command_service_; // 解析内嵌指令文本并提交编辑命令 | |||
| QGraphicsScene *scene_ = nullptr; // 持有梯形图图元的场景 | |||
| std::string logic_id_; // 当前投影的控制逻辑标识 | |||
| std::string selected_rung_id_; // 当前光标或对象所在的网络行 | |||
| std::vector<std::string> selected_node_ids_; // 显式选中的条件和输出节点 | |||
| std::vector<std::pair<std::string, int>> selected_cells_; // 显式选中的网格位置 | |||
| std::vector<std::string> selected_output_rung_ids_; // 显式选中的输出槽所在行 | |||
| std::vector<std::string> selected_vertical_connection_ids_; // 显式选中的竖线 | |||
| std::vector<std::string> selected_row_ids_; // 通过行号区域选中的完整行 | |||
| std::vector<RowLayout> row_layouts_; // 当前场景的视觉行定位缓存 | |||
| LogicTraceSnapshot trace_; // 最近一次显示的本地逻辑轨迹 | |||
| std::string fault_node_id_; // 需要突出显示的执行故障节点 | |||
| bool runtime_trace_enabled_ = false; // 是否以只读运行轨迹方式绘制 | |||
| bool editing_enabled_ = true; // 是否允许编辑和业务对象选择 | |||
| MouseWireMode mouse_wire_mode_ = MouseWireMode::Select; // 当前鼠标操作模式 | |||
| int selected_column_ = -1; // 当前光标所在的条件列或边界 | |||
| bool selected_cell_ = false; // 当前光标是否明确选中单元格 | |||
| bool selected_output_ = false; // 当前光标是否明确选中输出槽 | |||
| bool selected_boundary_ = false; // 当前光标是否明确选中列边界 | |||
| std::string selected_vertical_connection_id_; // 当前光标命中的竖线标识 | |||
| QRubberBand *selection_band_ = nullptr; // 视口坐标下的橡皮框控件 | |||
| QPoint selection_origin_; // 橡皮框选择的视口起点 | |||
| bool selection_pressed_ = false; // 是否正在等待进入橡皮框拖动 | |||
| bool selection_dragging_ = false; // 是否已经形成有效橡皮框拖动 | |||
| Qt::KeyboardModifiers selection_modifiers_ = Qt::NoModifier; // 选择开始时的修饰键 | |||
| bool gesture_active_ = false; // 是否存在尚未提交的画线手势 | |||
| Hit gesture_origin_; // 当前画线手势的业务起点 | |||
| Hit gesture_current_; // 当前画线手势的业务终点 | |||
| QPointF gesture_scene_position_; // 鼠标当前场景坐标 | |||
| bool gesture_scene_position_valid_ = false; // 当前场景坐标是否可用于绘制预览 | |||
| QLineEdit *command_editor_ = nullptr; // 覆盖在视图上的内嵌指令输入框 | |||
| QCompleter *command_completer_ = nullptr; // 指令助记符补全器 | |||
| LogicCommandTarget command_target_; // 当前内嵌输入对应的服务层目标 | |||
| std::vector<std::string> command_parallel_node_ids_; // 并联命令关联的节点范围 | |||
| }; | |||
| @@ -14,6 +14,7 @@ namespace { | |||
| constexpr int kConstantOperandData = 0; | |||
| constexpr int kRegisterOperandData = 1; | |||
| // 填充字操作数的常量和 D 寄存器类型选项 | |||
| void configureOperandCombo(QComboBox *combo) | |||
| { | |||
| combo->clear(); | |||
| @@ -23,6 +24,7 @@ void configureOperandCombo(QComboBox *combo) | |||
| } // namespace | |||
| // 加载原节点配置并连接操作数类型变化信号 | |||
| LogicInstructionDialog::LogicInstructionDialog( | |||
| const LogicNodeConfig &config, | |||
| QWidget *parent) | |||
| @@ -40,6 +42,7 @@ LogicInstructionDialog::LogicInstructionDialog( | |||
| ui_->leftOperandKindComboBox, | |||
| ui_->rightOperandKindComboBox}) | |||
| { | |||
| // qOverload 明确选择带索引参数的重载信号 | |||
| connect( | |||
| combo, | |||
| qOverload<int>(&QComboBox::currentIndexChanged), | |||
| @@ -58,10 +61,13 @@ LogicInstructionDialog::LogicInstructionDialog( | |||
| updateOperandRanges(); | |||
| } | |||
| // 释放 Designer 界面对象 | |||
| LogicInstructionDialog::~LogicInstructionDialog() = default; | |||
| // 将当前表单转换为 MOVE、ADD 或 SUB 的强类型配置 | |||
| LogicNodeConfig LogicInstructionDialog::config() const | |||
| { | |||
| // variant 区分 MOVE 和算术指令,分别组装对应的强类型配置 | |||
| if (std::holds_alternative<MoveNodeConfig>(original_config_)) | |||
| { | |||
| return MoveNodeConfig{ | |||
| @@ -80,6 +86,7 @@ LogicNodeConfig LogicInstructionDialog::config() const | |||
| ui_->destinationAddressSpinBox->value()}}; | |||
| } | |||
| // 使用 MOVE 配置初始化单源操作数表单 | |||
| void LogicInstructionDialog::configureMove(const MoveNodeConfig &config) | |||
| { | |||
| setWindowTitle(tr("配置 MOVE")); | |||
| @@ -95,6 +102,7 @@ void LogicInstructionDialog::configureMove(const MoveNodeConfig &config) | |||
| ui_->destinationAddressSpinBox->setValue(config.destination.index()); | |||
| } | |||
| // 使用 ADD 或 SUB 配置初始化双操作数表单 | |||
| void LogicInstructionDialog::configureArithmetic(const ArithmeticNodeConfig &config) | |||
| { | |||
| const bool is_add = config.operation == ArithmeticOperation::Add; | |||
| @@ -116,6 +124,7 @@ void LogicInstructionDialog::configureArithmetic(const ArithmeticNodeConfig &con | |||
| ui_->destinationAddressSpinBox->setValue(config.destination.index()); | |||
| } | |||
| // 根据常量或 D 寄存器类型调整输入前缀和数值范围 | |||
| void LogicInstructionDialog::updateOperandRanges() | |||
| { | |||
| const auto update = [](const QComboBox *combo, QSpinBox *spin) | |||
| @@ -134,6 +143,7 @@ void LogicInstructionDialog::updateOperandRanges() | |||
| update(ui_->rightOperandKindComboBox, ui_->rightOperandValueSpinBox); | |||
| } | |||
| // 将领域字操作数写入类型下拉框和数值输入框 | |||
| void LogicInstructionDialog::setOperand( | |||
| QComboBox *kind_combo, | |||
| QSpinBox *value_spin, | |||
| @@ -147,6 +157,7 @@ void LogicInstructionDialog::setOperand( | |||
| ? operand.address.index() : operand.constant); | |||
| } | |||
| // 从类型下拉框和数值输入框读取领域字操作数 | |||
| WordOperand LogicInstructionDialog::operandFrom( | |||
| const QComboBox *kind_combo, | |||
| const QSpinBox *value_spin) | |||
| @@ -13,11 +13,13 @@ | |||
| #include "domain/runtime_state.h" | |||
| #include "services/plc_communication_gateway.h" | |||
| #include "services/application_settings.h" | |||
| #include "services/logic_editor_service.h" | |||
| #include "services/user_runtime_plc_recovery_policy.h" | |||
| #include <QMainWindow> | |||
| #include <QProcess> | |||
| #include <memory> | |||
| #include <optional> | |||
| #include <variant> | |||
| #include <vector> | |||
| @@ -27,9 +29,8 @@ class QActionGroup; | |||
| class QCloseEvent; | |||
| class QEvent; | |||
| class QLabel; | |||
| class QDockWidget; | |||
| class QTimer; | |||
| class QProgressDialog; | |||
| class QToolButton; | |||
| namespace Ui { | |||
| class MainWindow; | |||
| @@ -39,6 +40,7 @@ QT_END_NAMESPACE | |||
| class RuntimeModeService; | |||
| class ProjectService; | |||
| class HmiEditorService; | |||
| enum class HmiAlignment; | |||
| class HmiRuntimeService; | |||
| class AlarmEditorService; | |||
| class AlarmService; | |||
| @@ -112,11 +114,6 @@ public: | |||
| /** 释放窗口拥有的控制器和可选导航服务 */ | |||
| ~MainWindow() override; | |||
| /** 返回当前选中的 HMI 页面标识 */ | |||
| const std::string ¤tHmiPageId() const; | |||
| /** 返回当前选中的控制逻辑标识 */ | |||
| const std::string ¤tLogicId() const; | |||
| protected: | |||
| /** 处理主窗口及子控件的事件过滤请求 */ | |||
| bool eventFilter(QObject *watched, QEvent *event) override; | |||
| @@ -134,7 +131,7 @@ private: | |||
| void configureLogicEditor(); | |||
| /** 创建并连接运行监控面板 */ | |||
| void configureRuntimeMonitor(); | |||
| /** 创建并连接编辑态数据监控面板 */ | |||
| /** 创建并连接数据监控编辑页 */ | |||
| void configureDataMonitor(); | |||
| /** 创建并连接工程树 */ | |||
| void configureProjectTree(); | |||
| @@ -178,6 +175,8 @@ private: | |||
| void addHmiControl(HmiControlType type); | |||
| /** 删除当前选中的 HMI 控件 */ | |||
| void deleteSelectedControl(); | |||
| /** 对当前选中的 HMI 控件执行批量对齐 */ | |||
| void alignSelectedHmiControls(HmiAlignment alignment); | |||
| /** 在当前光标节点后添加串联条件,没有选择时追加到网络末尾 */ | |||
| void addLogicCondition(const LogicNodeConfig &config); | |||
| /** 为当前选中的连续逻辑范围建立并联支路 */ | |||
| @@ -196,8 +195,24 @@ private: | |||
| void configureAndSetLogicOutput(const LogicNodeConfig &config); | |||
| /** 新增一个梯形图网络 */ | |||
| void addLogicRung(); | |||
| /** 在当前网络上方插入一行 */ | |||
| void insertLogicRungAbove(); | |||
| /** 在当前网络下方插入一行 */ | |||
| void insertLogicRungBelow(); | |||
| /** 删除当前选中的梯形图行 */ | |||
| void deleteLogicRung(); | |||
| /** 编辑当前网络的注释 */ | |||
| void editSelectedRungComment(); | |||
| void editSelectedNetworkComment(); | |||
| /** 规整并检查当前梯形图 */ | |||
| void runLogicSyntaxCheck(); | |||
| /** 单独检查当前梯形图中的重复线圈输出 */ | |||
| void runDoubleCoilCheck(); | |||
| /** 将语法检查结果写入输出栏并定位错误 */ | |||
| void reportLogicSyntaxCheck( | |||
| const LogicSyntaxCheckResult &result, | |||
| const QString &action); | |||
| /** 切换到并聚焦语法错误所在的逻辑位置 */ | |||
| void focusLogicSyntaxLocation(const LogicSyntaxLocation &location); | |||
| /** 删除当前选中的逻辑节点或网络 */ | |||
| void deleteSelectedLogicObject(); | |||
| /** 打开 PLC 连接 */ | |||
| @@ -216,53 +231,41 @@ private: | |||
| 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); | |||
| void appendOutputMessage( | |||
| const QString &message, | |||
| const std::optional<LogicSyntaxLocation> &location = std::nullopt); | |||
| /** | |||
| * @brief 请求服务层切换模式并同步界面状态 | |||
| * @param requested_mode 目标应用模式 | |||
| * | |||
| * 失败时恢复与服务层当前状态一致的工具栏选项 | |||
| */ | |||
| /** 请求服务层切换模式,失败时恢复与实际状态一致的工具栏选项 */ | |||
| bool requestMode(ApplicationMode requested_mode); | |||
| /** | |||
| * @brief 根据服务层模式策略更新编辑权限和状态反馈 | |||
| * @param message 要显示在状态栏和输出区的模式结果消息 | |||
| */ | |||
| /** 根据服务层模式策略更新编辑权限和状态反馈 */ | |||
| void updateModeUi(const QString &message); | |||
| /** 根据仿真服务实际状态刷新执行器反馈和 HMI 写权限 */ | |||
| void updateSimulationUi(bool report_fault); | |||
| /** | |||
| * @brief 将互斥模式动作同步到服务层当前状态 | |||
| */ | |||
| /** 将互斥模式动作同步到服务层当前状态 */ | |||
| void restoreCurrentModeAction(); | |||
| /** 完成主窗口初始化和首次界面刷新 */ | |||
| void initializeUi(); | |||
| /** 专用用户运行版启动后直接进入运行监控 */ | |||
| void initializeUserRuntime(); | |||
| /** 用户运行版允许在离线和真机之间自动经过编辑态切换 */ | |||
| void requestUserRuntimeMode(ApplicationMode requested_mode); | |||
| /** 用户运行版完成 PLC 首读后自动进入真机运行 */ | |||
| void enterUserRuntimeAfterInitialRead(); | |||
| /** 用户运行版在本地串口断开后自动尝试重新打开串口 */ | |||
| void attemptUserRuntimeReconnect(); | |||
| /** 用户运行版在一次 PLC 通信故障周期内只显示一次提示 */ | |||
| void reportUserRuntimePlcFailure(const std::string &error); | |||
| /** 刷新编辑态数据监控的模式、权限和当前值 */ | |||
| void refreshDataMonitorUi(); | |||
| /** Qt Designer 生成的主窗口界面对象 */ | |||
| std::unique_ptr<Ui::MainWindow> ui_; | |||
| /** | |||
| * @brief 非拥有的运行模式服务依赖,由应用入口保证生命周期 | |||
| */ | |||
| /** 非拥有的运行模式服务依赖,由应用入口保证生命周期 */ | |||
| RuntimeModeService &runtime_mode_service_; | |||
| /** | |||
| * @brief 非拥有的工程和 HMI 服务依赖,由应用入口保证生命周期 | |||
| */ | |||
| /** 非拥有的工程服务依赖,由应用入口保证生命周期 */ | |||
| ProjectService &project_service_; | |||
| /** 非拥有的 HMI 编辑服务依赖 */ | |||
| HmiEditorService &hmi_editor_service_; | |||
| @@ -296,15 +299,29 @@ private: | |||
| std::unique_ptr<RuntimePanelController> runtime_panel_controller_; | |||
| /** 运行模式互斥动作组 */ | |||
| QActionGroup *mode_action_group_ = nullptr; | |||
| /** HMI 控件布局菜单按钮 */ | |||
| QToolButton *hmi_layout_button_ = nullptr; | |||
| /** HMI 控件左对齐动作 */ | |||
| QAction *align_left_action_ = nullptr; | |||
| /** HMI 控件水平居中动作 */ | |||
| QAction *align_horizontal_center_action_ = nullptr; | |||
| /** HMI 控件右对齐动作 */ | |||
| QAction *align_right_action_ = nullptr; | |||
| /** HMI 控件顶部对齐动作 */ | |||
| QAction *align_top_action_ = nullptr; | |||
| /** HMI 控件垂直居中动作 */ | |||
| QAction *align_vertical_center_action_ = nullptr; | |||
| /** HMI 控件底部对齐动作 */ | |||
| QAction *align_bottom_action_ = 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; | |||
| @@ -324,38 +341,29 @@ private: | |||
| 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; | |||
| /** 用户运行版串口重连和通信故障提示周期策略 */ | |||
| UserRuntimePlcRecoveryPolicy user_runtime_plc_recovery_policy_; | |||
| /** 用户运行版自动重连定时器 */ | |||
| QTimer *user_runtime_reconnect_timer_ = nullptr; | |||
| /** HMI 控件剪贴板内容和连续粘贴次数 */ | |||
| struct HmiClipboardData | |||
| { | |||
| /** 复制的 HMI 控件 */ | |||
| std::vector<HmiControl> controls; | |||
| /** 当前剪贴板内容已经粘贴的次数 */ | |||
| int pasteCount = 0; | |||
| }; | |||
| struct LogicNodesClipboardData | |||
| { | |||
| std::vector<LogicNode> nodes; | |||
| }; | |||
| struct LogicOutputClipboardData | |||
| { | |||
| LogicNode output; | |||
| }; | |||
| struct LogicRungClipboardData | |||
| /** 梯形图剪贴板内容 */ | |||
| struct LogicClipboardData | |||
| { | |||
| LadderRung rung; | |||
| /** 复制的梯形图片段 */ | |||
| LogicClipboardFragment fragment; | |||
| }; | |||
| /** 编辑器共用的空、HMI 和梯形图剪贴板类型 */ | |||
| using EditorClipboard = std::variant< | |||
| std::monostate, | |||
| HmiClipboardData, | |||
| LogicNodesClipboardData, | |||
| LogicOutputClipboardData, | |||
| LogicRungClipboardData>; | |||
| LogicClipboardData>; | |||
| /** 当前编辑器剪贴板内容 */ | |||
| EditorClipboard editor_clipboard_; | |||
| }; | |||
| @@ -207,7 +207,26 @@ | |||
| </item> | |||
| </layout> | |||
| </widget> | |||
| <widget class="QWidget" name="dataMonitorTab"> | |||
| <attribute name="title"> | |||
| <string>数据监控</string> | |||
| </attribute> | |||
| <layout class="QVBoxLayout" name="dataMonitorLayout"> | |||
| <property name="leftMargin"> | |||
| <number>8</number> | |||
| </property> | |||
| <property name="topMargin"> | |||
| <number>8</number> | |||
| </property> | |||
| <property name="rightMargin"> | |||
| <number>8</number> | |||
| </property> | |||
| <property name="bottomMargin"> | |||
| <number>8</number> | |||
| </property> | |||
| </layout> | |||
| </widget> | |||
| </widget> | |||
| </item> | |||
| </layout> | |||
| </widget> | |||
| @@ -242,14 +261,20 @@ | |||
| <addaction name="pasteAction"/> | |||
| <addaction name="separator"/> | |||
| <addaction name="deleteSelectionAction"/> | |||
| <addaction name="insertRungAboveAction"/> | |||
| <addaction name="insertRungBelowAction"/> | |||
| <addaction name="deleteRungAction"/> | |||
| <addaction name="deleteHorizontalWireAction"/> | |||
| <addaction name="deleteVerticalWireAction"/> | |||
| <addaction name="clearSelectionAction"/> | |||
| </widget> | |||
| <widget class="QMenu" name="runMenu"> | |||
| <property name="title"> | |||
| <string>运行(&R)</string> | |||
| </property> | |||
| <property name="title"> | |||
| <string>运行(&R)</string> | |||
| </property> | |||
| <addaction name="syntaxCheckAction"/> | |||
| <addaction name="doubleCoilCheckAction"/> | |||
| <addaction name="separator"/> | |||
| <addaction name="editingModeAction"/> | |||
| <addaction name="offlineModeAction"/> | |||
| <addaction name="onlineModeAction"/> | |||
| @@ -309,6 +334,7 @@ | |||
| <addaction name="addNumericDisplayAction"/> | |||
| <addaction name="addNumericInputAction"/> | |||
| <addaction name="addLabelAction"/> | |||
| <addaction name="addStatusTextAction"/> | |||
| <addaction name="deleteControlAction"/> | |||
| </widget> | |||
| <widget class="QToolBar" name="logicToolBar"> | |||
| @@ -325,6 +351,12 @@ | |||
| <bool>true</bool> | |||
| </attribute> | |||
| <addaction name="addRungAction"/> | |||
| <addaction name="insertRungAboveAction"/> | |||
| <addaction name="insertRungBelowAction"/> | |||
| <addaction name="deleteRungAction"/> | |||
| <addaction name="mouseDrawWireAction"/> | |||
| <addaction name="mouseEraseWireAction"/> | |||
| <addaction name="separator"/> | |||
| <addaction name="insertHorizontalWireAction"/> | |||
| <addaction name="insertVerticalWireAction"/> | |||
| <addaction name="parallelInsertAction"/> | |||
| @@ -333,7 +365,9 @@ | |||
| <addaction name="addNormallyOpenAction"/> | |||
| <addaction name="addNormallyClosedAction"/> | |||
| <addaction name="addNormalCoilAction"/> | |||
| <addaction name="editRungCommentAction"/> | |||
| <addaction name="editNetworkCommentAction"/> | |||
| <addaction name="separator"/> | |||
| <addaction name="syntaxCheckAction"/> | |||
| </widget> | |||
| <widget class="QDockWidget" name="projectDock"> | |||
| <property name="minimumSize"> | |||
| @@ -592,38 +626,12 @@ | |||
| </item> | |||
| <item row="6" column="0"> | |||
| <widget class="QLabel" name="bindingAreaLabel"> | |||
| <property name="text"> | |||
| <string>绑定区域</string> | |||
| </property> | |||
| </widget> | |||
| </item> | |||
| <item row="6" column="1"> | |||
| <widget class="QComboBox" name="bindingAreaComboBox"> | |||
| <item> | |||
| <property name="text"> | |||
| <string>未绑定</string> | |||
| </property> | |||
| </item> | |||
| <item> | |||
| <property name="text"> | |||
| <string>M</string> | |||
| </property> | |||
| </item> | |||
| <item> | |||
| <property name="text"> | |||
| <string>D</string> | |||
| <string>绑定地址</string> | |||
| </property> | |||
| </item> | |||
| </widget> | |||
| </item> | |||
| <item row="7" column="0"> | |||
| <widget class="QLabel" name="bindingIndexLabel"> | |||
| <property name="text"> | |||
| <string>绑定地址</string> | |||
| </property> | |||
| </widget> | |||
| </item> | |||
| <item row="7" column="1"> | |||
| <item row="6" column="1"> | |||
| <widget class="QSpinBox" name="bindingIndexSpinBox"> | |||
| <property name="maximum"> | |||
| <number>4000</number> | |||
| @@ -672,13 +680,23 @@ | |||
| </widget> | |||
| </item> | |||
| <item row="10" column="0"> | |||
| <widget class="QLabel" name="buttonEnableConditionLabel"> | |||
| <property name="text"><string>启用条件</string></property> | |||
| </widget> | |||
| </item> | |||
| <item row="10" column="1"> | |||
| <widget class="QPushButton" name="configureButtonExtensionButton"> | |||
| <property name="text"><string>未设置</string></property> | |||
| </widget> | |||
| </item> | |||
| <item row="11" column="0"> | |||
| <widget class="QLabel" name="textColorLabel"> | |||
| <property name="text"> | |||
| <string>字体颜色</string> | |||
| </property> | |||
| </widget> | |||
| </item> | |||
| <item row="10" column="1"> | |||
| <item row="11" column="1"> | |||
| <widget class="QWidget" name="textColorContainer"> | |||
| <layout class="QHBoxLayout" name="textColorLayout"> | |||
| <property name="leftMargin"><number>0</number></property> | |||
| @@ -702,24 +720,24 @@ | |||
| </layout> | |||
| </widget> | |||
| </item> | |||
| <item row="11" column="0"> | |||
| <item row="12" column="0"> | |||
| <widget class="QLabel" name="fontSizeLabel"> | |||
| <property name="text"> | |||
| <string>字号</string> | |||
| </property> | |||
| </widget> | |||
| </item> | |||
| <item row="11" column="1"> | |||
| <item row="12" column="1"> | |||
| <widget class="QSpinBox" name="fontSizeSpinBox"/> | |||
| </item> | |||
| <item row="12" column="0"> | |||
| <item row="13" column="0"> | |||
| <widget class="QLabel" name="fontBoldLabel"> | |||
| <property name="text"> | |||
| <string>字体样式</string> | |||
| </property> | |||
| </widget> | |||
| </item> | |||
| <item row="12" column="1"> | |||
| <item row="13" column="1"> | |||
| <widget class="QWidget" name="fontStyleContainer"> | |||
| <layout class="QHBoxLayout" name="fontStyleLayout"> | |||
| <property name="leftMargin"><number>0</number></property> | |||
| @@ -739,18 +757,30 @@ | |||
| </layout> | |||
| </widget> | |||
| </item> | |||
| <item row="13" column="0"> | |||
| <item row="14" 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> | |||
| <item row="14" column="1"> | |||
| <widget class="QComboBox" name="dataTypeComboBox"> | |||
| <item><property name="text"><string>Int16</string></property></item> | |||
| <item><property name="text"><string>Int32</string></property></item> | |||
| <item><property name="text"><string>Float32</string></property></item> | |||
| <item><property name="text"><string>Double (Float64)</string></property></item> | |||
| </widget> | |||
| </item> | |||
| <item row="15" column="0"> | |||
| <widget class="QLabel" name="statusTextConfigLabel"> | |||
| <property name="text"><string>状态映射</string></property> | |||
| </widget> | |||
| </item> | |||
| <item row="14" column="0" colspan="2"> | |||
| <item row="15" column="1"> | |||
| <widget class="QPushButton" name="configureStatusTextButton"> | |||
| <property name="text"><string>配置</string></property> | |||
| </widget> | |||
| </item> | |||
| <item row="16" column="0" colspan="2"> | |||
| <widget class="QPushButton" name="applyPropertiesButton"> | |||
| <property name="text"> | |||
| <string>应用属性</string> | |||
| @@ -906,28 +936,6 @@ | |||
| </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> | |||
| @@ -1110,6 +1118,10 @@ | |||
| <string>添加页面跳转控件</string> | |||
| </property> | |||
| </action> | |||
| <action name="addStatusTextAction"> | |||
| <property name="text"><string>状态文本</string></property> | |||
| <property name="toolTip"><string>添加状态文本控件</string></property> | |||
| </action> | |||
| <action name="addAlarmListAction"> | |||
| <property name="text"> | |||
| <string>报警列表</string> | |||
| @@ -1142,12 +1154,47 @@ | |||
| <string>在梯形图末尾新增网络</string> | |||
| </property> | |||
| </action> | |||
| <action name="insertRungAboveAction"> | |||
| <property name="text"><string>上方插入行</string></property> | |||
| <property name="toolTip"><string>在当前选中行的上方插入一条空白行,并保持竖线连接</string></property> | |||
| </action> | |||
| <action name="insertRungBelowAction"> | |||
| <property name="text"><string>下方插入行</string></property> | |||
| <property name="toolTip"><string>在当前选中行的下方插入一条空白行,并保持竖线连接</string></property> | |||
| </action> | |||
| <action name="deleteRungAction"> | |||
| <property name="text"><string>删除行</string></property> | |||
| <property name="toolTip"><string>删除当前选中行;上下同列竖线会自动合并</string></property> | |||
| <property name="shortcut"><string>Shift+Delete</string></property> | |||
| </action> | |||
| <action name="mouseDrawWireAction"> | |||
| <property name="checkable"> | |||
| <bool>true</bool> | |||
| </property> | |||
| <property name="text"> | |||
| <string>鼠标画线</string> | |||
| </property> | |||
| <property name="toolTip"> | |||
| <string>拖动鼠标画横线或竖线;横线按网格补齐,竖线按相邻行分段连接</string> | |||
| </property> | |||
| </action> | |||
| <action name="mouseEraseWireAction"> | |||
| <property name="checkable"> | |||
| <bool>true</bool> | |||
| </property> | |||
| <property name="text"> | |||
| <string>鼠标删线</string> | |||
| </property> | |||
| <property name="toolTip"> | |||
| <string>拖动鼠标删除经过的横线格;点击竖线只删除命中的连接段</string> | |||
| </property> | |||
| </action> | |||
| <action name="insertHorizontalWireAction"> | |||
| <property name="text"> | |||
| <string>横线</string> | |||
| </property> | |||
| <property name="toolTip"> | |||
| <string>在所选条件或横线后插入横线;没有选择时追加到当前网络</string> | |||
| <string>在当前条件格插入横线并自动右移;空逻辑会先创建首行</string> | |||
| </property> | |||
| <property name="shortcut"> | |||
| <string>F11</string> | |||
| @@ -1158,7 +1205,7 @@ | |||
| <string>竖线</string> | |||
| </property> | |||
| <property name="toolTip"> | |||
| <string>为所选连续逻辑范围建立横线旁路和竖线连接</string> | |||
| <string>在当前行与下一行的选中列边界建立竖线,成功后自动下移到下一行</string> | |||
| </property> | |||
| <property name="shortcut"> | |||
| <string>F12</string> | |||
| @@ -1180,7 +1227,7 @@ | |||
| <string>删除竖线</string> | |||
| </property> | |||
| <property name="toolTip"> | |||
| <string>删除画布中选中的竖线及其对应并联支路</string> | |||
| <string>删除画布中选中的竖线连接,让网络在此处拆分</string> | |||
| </property> | |||
| <property name="shortcut"> | |||
| <string>Shift+F12</string> | |||
| @@ -1199,7 +1246,7 @@ | |||
| <string>常开</string> | |||
| </property> | |||
| <property name="toolTip"> | |||
| <string>在选中的空网格或横线处添加常开触点;选中条件时在其后插入</string> | |||
| <string>在当前条件格添加常开触点并自动右移;空逻辑会先创建首行</string> | |||
| </property> | |||
| </action> | |||
| <action name="addNormallyClosedAction"> | |||
| @@ -1207,7 +1254,7 @@ | |||
| <string>常闭</string> | |||
| </property> | |||
| <property name="toolTip"> | |||
| <string>在选中的空网格或横线处添加常闭触点;选中条件时在其后插入</string> | |||
| <string>在当前条件格添加常闭触点并自动右移;空逻辑会先创建首行</string> | |||
| </property> | |||
| </action> | |||
| <action name="addRisingEdgeAction"> | |||
| @@ -1270,7 +1317,7 @@ | |||
| <string>在选中的空网格或横线处添加 D 值与常量比较条件</string> | |||
| </property> | |||
| </action> | |||
| <action name="editRungCommentAction"> | |||
| <action name="editNetworkCommentAction"> | |||
| <property name="text"> | |||
| <string>网络注释</string> | |||
| </property> | |||
| @@ -1280,10 +1327,29 @@ | |||
| </action> | |||
| <action name="deleteLogicAction"> | |||
| <property name="text"> | |||
| <string>删除节点/网络</string> | |||
| <string>删除所选对象</string> | |||
| </property> | |||
| <property name="toolTip"> | |||
| <string>删除选中的逻辑节点、横线格或竖线;整行请使用删除行</string> | |||
| </property> | |||
| </action> | |||
| <action name="syntaxCheckAction"> | |||
| <property name="text"> | |||
| <string>语法检查</string> | |||
| </property> | |||
| <property name="toolTip"> | |||
| <string>规整当前梯形图并检查输出路径</string> | |||
| </property> | |||
| <property name="shortcut"> | |||
| <string>Ctrl+G</string> | |||
| </property> | |||
| </action> | |||
| <action name="doubleCoilCheckAction"> | |||
| <property name="text"> | |||
| <string>双线圈检查</string> | |||
| </property> | |||
| <property name="toolTip"> | |||
| <string>删除选中的逻辑节点或网络</string> | |||
| <string>检查当前梯形图中的重复 M 线圈输出</string> | |||
| </property> | |||
| </action> | |||
| <action name="editingModeAction"> | |||
| @@ -17,6 +17,7 @@ | |||
| namespace { | |||
| // 为 Designer 中已有的下拉选项绑定实际整数值 | |||
| void setItemData(QComboBox *combo_box, const std::initializer_list<int> &values) | |||
| { | |||
| int index = 0; | |||
| @@ -27,6 +28,7 @@ void setItemData(QComboBox *combo_box, const std::initializer_list<int> &values) | |||
| } | |||
| } | |||
| // 按业务值选中下拉项,找不到时保持原选择 | |||
| void selectData(QComboBox *combo_box, int value) | |||
| { | |||
| const int index = combo_box->findData(value); | |||
| @@ -38,6 +40,7 @@ void selectData(QComboBox *combo_box, int value) | |||
| } // namespace | |||
| // 加载串口配置并绑定异步自动搜索回调 | |||
| PlcConnectionDialog::PlcConnectionDialog( | |||
| const PlcSerialConfiguration &configuration, | |||
| PlcDiscoveryGateway &discovery_gateway, | |||
| @@ -76,6 +79,7 @@ PlcConnectionDialog::PlcConnectionDialog( | |||
| { | |||
| refreshSerialPorts(ui_->serialPortComboBox->currentText().trimmed()); | |||
| }); | |||
| // 点击自动搜索后进入候选串口逐项探测入口 | |||
| connect( | |||
| ui_->autoSearchButton, &QPushButton::clicked, | |||
| this, &PlcConnectionDialog::toggleAutomaticDiscovery); | |||
| @@ -86,16 +90,19 @@ PlcConnectionDialog::PlcConnectionDialog( | |||
| }, | |||
| [this](const PlcDiscoveryOutcome &outcome) | |||
| { | |||
| // 搜索服务结束后统一处理找到、未找到和取消三种结果 | |||
| handleDiscoveryFinished(outcome); | |||
| }); | |||
| } | |||
| // 解绑异步回调并取消尚未结束的自动搜索 | |||
| PlcConnectionDialog::~PlcConnectionDialog() | |||
| { | |||
| discovery_gateway_.setCallbacks({}, {}); | |||
| discovery_gateway_.cancelDiscovery(); | |||
| } | |||
| // 将当前表单转换为 PLC 串口和轮询配置 | |||
| PlcSerialConfiguration PlcConnectionDialog::configuration() const | |||
| { | |||
| PlcSerialConfiguration result; | |||
| @@ -112,6 +119,7 @@ PlcSerialConfiguration PlcConnectionDialog::configuration() const | |||
| return result; | |||
| } | |||
| // 重新枚举可用串口并尽量恢复原选择 | |||
| void PlcConnectionDialog::refreshSerialPorts(const QString &preferred_port) | |||
| { | |||
| ui_->serialPortComboBox->clear(); | |||
| @@ -136,6 +144,7 @@ void PlcConnectionDialog::refreshSerialPorts(const QString &preferred_port) | |||
| } | |||
| } | |||
| // 开始自动搜索,搜索中再次触发则请求取消 | |||
| void PlcConnectionDialog::toggleAutomaticDiscovery() | |||
| { | |||
| if (discovery_gateway_.isDiscovering()) | |||
| @@ -145,6 +154,7 @@ void PlcConnectionDialog::toggleAutomaticDiscovery() | |||
| discovery_gateway_.cancelDiscovery(); | |||
| return; | |||
| } | |||
| // 把当前表单配置作为首选组合,交给搜索服务异步逐项探测 | |||
| const PlcCommunicationResult result = discovery_gateway_.startDiscovery( | |||
| configuration()); | |||
| if (!result.succeeded) | |||
| @@ -161,6 +171,7 @@ void PlcConnectionDialog::toggleAutomaticDiscovery() | |||
| setDiscoveryUiActive(true); | |||
| } | |||
| // 显示当前搜索组合和总体尝试进度 | |||
| void PlcConnectionDialog::showDiscoveryProgress( | |||
| const PlcDiscoveryProgress &progress) | |||
| { | |||
| @@ -184,6 +195,7 @@ void PlcConnectionDialog::showDiscoveryProgress( | |||
| .arg(progress.totalAttempts)); | |||
| } | |||
| // 处理取消、未找到或成功找到 PLC 的搜索结果 | |||
| void PlcConnectionDialog::handleDiscoveryFinished( | |||
| const PlcDiscoveryOutcome &outcome) | |||
| { | |||
| @@ -202,11 +214,14 @@ void PlcConnectionDialog::handleDiscoveryFinished( | |||
| QMessageBox::information(this, tr("自动搜索 PLC"), message); | |||
| return; | |||
| } | |||
| // 命中 PLC 后把实际可用参数完整填回表单 | |||
| applyConfiguration(outcome.configuration); | |||
| ui_->discoveryStatusLabel->setText(tr("已找到 PLC,正在连接")); | |||
| // 延迟到当前回调返回后再接受对话框,避免在网关通知栈中销毁界面 | |||
| QTimer::singleShot(0, this, &QDialog::accept); | |||
| } | |||
| // 搜索期间锁定配置输入,只保留停止搜索入口 | |||
| void PlcConnectionDialog::setDiscoveryUiActive(bool active) | |||
| { | |||
| ui_->discoveryProgressBar->setVisible(active); | |||
| @@ -229,6 +244,7 @@ void PlcConnectionDialog::setDiscoveryUiActive(bool active) | |||
| active ? QStyle::SP_BrowserStop : QStyle::SP_BrowserReload)); | |||
| } | |||
| // 将自动搜索命中的配置完整回填到表单 | |||
| void PlcConnectionDialog::applyConfiguration( | |||
| const PlcSerialConfiguration &configuration) | |||
| { | |||
| @@ -28,17 +28,20 @@ namespace { | |||
| constexpr int kProjectTreeKindRole = Qt::UserRole; | |||
| constexpr int kProjectTreeIdRole = Qt::UserRole + 1; | |||
| // 将 UTF-8 标准字符串转换为 Qt 文本 | |||
| QString fromUtf8(const std::string &value) | |||
| { | |||
| return QString::fromUtf8(value.data(), static_cast<int>(value.size())); | |||
| } | |||
| // 将 Qt 文本转换为 UTF-8 标准字符串 | |||
| std::string toUtf8(const QString &value) | |||
| { | |||
| const QByteArray bytes = value.toUtf8(); | |||
| return std::string(bytes.constData(), static_cast<std::size_t>(bytes.size())); | |||
| } | |||
| // 显示必填名称对话框并只在非空时允许确认 | |||
| bool requestRequiredName( | |||
| QWidget &parent, | |||
| const QString &title, | |||
| @@ -116,6 +119,7 @@ ProjectWorkspaceController::ProjectWorkspaceController( | |||
| { | |||
| } | |||
| // 创建工程树动作并连接选择和操作信号 | |||
| void ProjectWorkspaceController::configure() | |||
| { | |||
| ui_.projectTree->setContextMenuPolicy(Qt::ActionsContextMenu); | |||
| @@ -174,6 +178,7 @@ void ProjectWorkspaceController::configure() | |||
| updateActions(); | |||
| } | |||
| // 根据当前工程重建树节点和两类编辑器投影 | |||
| void ProjectWorkspaceController::refresh() | |||
| { | |||
| if (hmi_editor_service_.findPage(current_hmi_page_id_) == nullptr) | |||
| @@ -198,6 +203,7 @@ void ProjectWorkspaceController::refresh() | |||
| } | |||
| runtime_mode_service_.refreshPlcPollAddresses(); | |||
| // 重建期间屏蔽选择信号,避免树内容尚未完整时触发编辑器切换 | |||
| const QSignalBlocker blocker(ui_.projectTree); | |||
| ui_.projectTree->clear(); | |||
| auto *hmi_root = new QTreeWidgetItem(ui_.projectTree, {QObject::tr("HMI 页面")}); | |||
| @@ -256,16 +262,7 @@ void ProjectWorkspaceController::refresh() | |||
| updateActions(); | |||
| } | |||
| const std::string &ProjectWorkspaceController::currentHmiPageId() const | |||
| { | |||
| return current_hmi_page_id_; | |||
| } | |||
| const std::string &ProjectWorkspaceController::currentLogicId() const | |||
| { | |||
| return current_logic_id_; | |||
| } | |||
| // 将工程树选择同步到当前页面、逻辑和属性面板 | |||
| void ProjectWorkspaceController::handleSelectionChanged() | |||
| { | |||
| QTreeWidgetItem *item = ui_.projectTree->currentItem(); | |||
| @@ -301,6 +298,7 @@ void ProjectWorkspaceController::handleSelectionChanged() | |||
| updateActions(); | |||
| } | |||
| // 根据当前树节点类型和位置刷新动作可用状态 | |||
| void ProjectWorkspaceController::updateActions() | |||
| { | |||
| if (add_page_action_ == nullptr) | |||
| @@ -331,6 +329,7 @@ void ProjectWorkspaceController::updateActions() | |||
| } | |||
| } | |||
| // 请求服务层新增 HMI 页面并刷新选择 | |||
| void ProjectWorkspaceController::addPage() | |||
| { | |||
| QString name; | |||
| @@ -353,6 +352,7 @@ void ProjectWorkspaceController::addPage() | |||
| edit_state_changed_(); | |||
| } | |||
| // 请求服务层新增控制逻辑并刷新选择 | |||
| void ProjectWorkspaceController::addLogic() | |||
| { | |||
| QString name; | |||
| @@ -375,6 +375,7 @@ void ProjectWorkspaceController::addLogic() | |||
| edit_state_changed_(); | |||
| } | |||
| // 校验新名称后重命名当前页面或控制逻辑 | |||
| void ProjectWorkspaceController::renameSelectedItem() | |||
| { | |||
| QTreeWidgetItem *item = ui_.projectTree->currentItem(); | |||
| @@ -429,6 +430,7 @@ void ProjectWorkspaceController::renameSelectedItem() | |||
| edit_state_changed_(); | |||
| } | |||
| // 确认后删除当前页面或控制逻辑 | |||
| void ProjectWorkspaceController::deleteSelectedItem() | |||
| { | |||
| QTreeWidgetItem *item = ui_.projectTree->currentItem(); | |||
| @@ -467,6 +469,7 @@ void ProjectWorkspaceController::deleteSelectedItem() | |||
| edit_state_changed_(); | |||
| } | |||
| // 按指定方向移动当前页面或控制逻辑 | |||
| void ProjectWorkspaceController::moveSelectedItem(int offset) | |||
| { | |||
| QTreeWidgetItem *item = ui_.projectTree->currentItem(); | |||
| @@ -503,6 +506,7 @@ void ProjectWorkspaceController::moveSelectedItem(int offset) | |||
| edit_state_changed_(); | |||
| } | |||
| // 将当前 HMI 页面设置为工程初始页面 | |||
| void ProjectWorkspaceController::setSelectedPageAsInitial() | |||
| { | |||
| QTreeWidgetItem *item = ui_.projectTree->currentItem(); | |||
| @@ -523,6 +527,7 @@ void ProjectWorkspaceController::setSelectedPageAsInitial() | |||
| edit_state_changed_(); | |||
| } | |||
| // 切换当前控制逻辑的启用状态 | |||
| void ProjectWorkspaceController::toggleSelectedLogicEnabled() | |||
| { | |||
| QTreeWidgetItem *item = ui_.projectTree->currentItem(); | |||
| @@ -549,6 +554,7 @@ void ProjectWorkspaceController::toggleSelectedLogicEnabled() | |||
| edit_state_changed_(); | |||
| } | |||
| // 将服务层 UTF-8 错误统一报告给主窗口 | |||
| void ProjectWorkspaceController::reportFailure( | |||
| const QString &action, const std::string &message) const | |||
| { | |||
| @@ -89,11 +89,6 @@ public: | |||
| /** 启用或停用当前选中的控制逻辑 */ | |||
| void toggleSelectedLogicEnabled(); | |||
| /** 返回当前 HMI 页面标识 */ | |||
| const std::string ¤tHmiPageId() const; | |||
| /** 返回当前控制逻辑标识 */ | |||
| const std::string ¤tLogicId() const; | |||
| private: | |||
| /** 工程树项目的三种业务类型 */ | |||
| enum class ItemKind | |||
| @@ -5,13 +5,14 @@ | |||
| #include "hmi_editor_widget.h" | |||
| #include "logic_editor_widget.h" | |||
| #include "logic_instruction_dialog.h" | |||
| #include "status_text_dialog.h" | |||
| #include "button_extension_dialog.h" | |||
| #include "services/hmi_editor_service.h" | |||
| #include "services/logic_editor_service.h" | |||
| #include "services/project_service.h" | |||
| #include "ui_main_window.h" | |||
| #include <QApplication> | |||
| #include <QCheckBox> | |||
| #include <QColorDialog> | |||
| #include <QComboBox> | |||
| #include <QLabel> | |||
| @@ -30,17 +31,20 @@ | |||
| namespace { | |||
| // 将 UTF-8 标准字符串转换为 Qt 文本 | |||
| QString fromUtf8(const std::string &value) | |||
| { | |||
| return QString::fromUtf8(value.data(), static_cast<int>(value.size())); | |||
| } | |||
| // 将 Qt 文本转换为 UTF-8 标准字符串 | |||
| std::string toUtf8(const QString &value) | |||
| { | |||
| const QByteArray bytes = value.toUtf8(); | |||
| return std::string(bytes.constData(), static_cast<std::size_t>(bytes.size())); | |||
| } | |||
| // 返回不同 HMI 控件类型的新建默认字号 | |||
| int defaultFontPointSize(HmiControlType type) | |||
| { | |||
| int point_size = QApplication::font().pointSize(); | |||
| @@ -86,6 +90,7 @@ PropertyPanelController::PropertyPanelController( | |||
| { | |||
| } | |||
| // 配置属性输入范围并连接提交、颜色和专用配置入口 | |||
| void PropertyPanelController::configure() | |||
| { | |||
| ui_.pageWidthSpinBox->setRange( | |||
| @@ -97,9 +102,6 @@ void PropertyPanelController::configure() | |||
| ui_.fontSizeSpinBox->setRange( | |||
| ProjectLimits::kMinimumHmiFontPointSize, | |||
| ProjectLimits::kMaximumHmiFontPointSize); | |||
| ui_.bindingAreaComboBox->setItemData(0, -1); | |||
| ui_.bindingAreaComboBox->setItemData(1, 0); | |||
| ui_.bindingAreaComboBox->setItemData(2, 1); | |||
| ui_.buttonOperationComboBox->setItemData( | |||
| 0, static_cast<int>(HmiButtonOperation::SetOn)); | |||
| ui_.buttonOperationComboBox->setItemData( | |||
| @@ -111,7 +113,11 @@ void PropertyPanelController::configure() | |||
| ui_.dataTypeComboBox->setItemData( | |||
| 0, static_cast<int>(RegisterDataType::Int16)); | |||
| ui_.dataTypeComboBox->setItemData( | |||
| 1, static_cast<int>(RegisterDataType::Float32)); | |||
| 1, static_cast<int>(RegisterDataType::Int32)); | |||
| ui_.dataTypeComboBox->setItemData( | |||
| 2, static_cast<int>(RegisterDataType::Float32)); | |||
| ui_.dataTypeComboBox->setItemData( | |||
| 3, static_cast<int>(RegisterDataType::Float64)); | |||
| for (int index = 0; index < ui_.logicComparisonComboBox->count(); ++index) | |||
| { | |||
| ui_.logicComparisonComboBox->setItemData(index, index); | |||
| @@ -124,11 +130,16 @@ void PropertyPanelController::configure() | |||
| &parent_, [this] { applySelectedLogicNodeProperties(); }); | |||
| QObject::connect(ui_.textColorButton, &QPushButton::clicked, | |||
| &parent_, [this] { chooseTextColor(); }); | |||
| QObject::connect(ui_.configureStatusTextButton, &QPushButton::clicked, | |||
| &parent_, [this] { configureStatusText(); }); | |||
| QObject::connect(ui_.configureButtonExtensionButton, &QPushButton::clicked, | |||
| &parent_, [this] { configureButtonExtension(); }); | |||
| ui_.targetPageLabel->setVisible(false); | |||
| ui_.targetPageComboBox->setVisible(false); | |||
| showControlProperties({}); | |||
| } | |||
| // 绑定两个画布的选择、修改和错误信号 | |||
| void PropertyPanelController::bindEditorWidgets( | |||
| HmiEditorWidget &hmi_editor_widget, | |||
| LogicEditorWidget &logic_editor_widget) | |||
| @@ -168,6 +179,7 @@ void PropertyPanelController::bindEditorWidgets( | |||
| }); | |||
| } | |||
| // 将指定 HMI 控件的当前属性加载到表单 | |||
| void PropertyPanelController::showControlProperties(const std::string &control_id) | |||
| { | |||
| if (control_id.empty()) | |||
| @@ -195,11 +207,12 @@ void PropertyPanelController::showControlProperties(const std::string &control_i | |||
| static_cast<QWidget *>(ui_.controlYSpinBox), | |||
| static_cast<QWidget *>(ui_.controlWidthSpinBox), | |||
| static_cast<QWidget *>(ui_.controlHeightSpinBox), | |||
| static_cast<QWidget *>(ui_.bindingAreaComboBox), | |||
| static_cast<QWidget *>(ui_.bindingIndexSpinBox), | |||
| static_cast<QWidget *>(ui_.targetPageComboBox), | |||
| static_cast<QWidget *>(ui_.buttonOperationComboBox), | |||
| static_cast<QWidget *>(ui_.configureButtonExtensionButton), | |||
| static_cast<QWidget *>(ui_.dataTypeComboBox), | |||
| static_cast<QWidget *>(ui_.configureStatusTextButton), | |||
| static_cast<QWidget *>(ui_.textColorEdit), | |||
| static_cast<QWidget *>(ui_.textColorButton), | |||
| static_cast<QWidget *>(ui_.fontSizeSpinBox), | |||
| @@ -215,15 +228,17 @@ void PropertyPanelController::showControlProperties(const std::string &control_i | |||
| ui_.controlTextEdit->setMaxLength( | |||
| static_cast<int>(ProjectLimits::kMaximumHmiControlTextCharacters)); | |||
| ui_.bindingAreaLabel->setVisible(false); | |||
| ui_.bindingAreaComboBox->setVisible(false); | |||
| ui_.bindingIndexLabel->setVisible(false); | |||
| ui_.bindingIndexSpinBox->setVisible(false); | |||
| ui_.targetPageLabel->setVisible(false); | |||
| ui_.targetPageComboBox->setVisible(false); | |||
| ui_.buttonOperationLabel->setVisible(false); | |||
| ui_.buttonOperationComboBox->setVisible(false); | |||
| ui_.buttonEnableConditionLabel->setVisible(false); | |||
| ui_.configureButtonExtensionButton->setVisible(false); | |||
| ui_.dataTypeLabel->setVisible(false); | |||
| ui_.dataTypeComboBox->setVisible(false); | |||
| ui_.statusTextConfigLabel->setVisible(false); | |||
| ui_.configureStatusTextButton->setVisible(false); | |||
| ui_.textColorEdit->clear(); | |||
| ui_.textColorButton->setStyleSheet(QString{}); | |||
| ui_.fontSizeSpinBox->setValue( | |||
| @@ -238,22 +253,31 @@ void PropertyPanelController::showControlProperties(const std::string &control_i | |||
| ui_.controlTextEdit->setMaxLength( | |||
| static_cast<int>(ProjectLimits::kMaximumHmiControlTextCharacters)); | |||
| const bool is_status_text = control->type == HmiControlType::StatusText; | |||
| ui_.controlIdEdit->setText(fromUtf8(control->id)); | |||
| ui_.controlTextEdit->setText(fromUtf8(control->text)); | |||
| ui_.controlTextEdit->setVisible(!is_status_text); | |||
| ui_.controlTextLabel->setVisible(!is_status_text); | |||
| ui_.controlXSpinBox->setValue(control->bounds.x); | |||
| ui_.controlYSpinBox->setValue(control->bounds.y); | |||
| ui_.controlWidthSpinBox->setValue(control->bounds.width); | |||
| ui_.controlHeightSpinBox->setValue(control->bounds.height); | |||
| ui_.bindingAreaComboBox->setCurrentIndex(!control->binding.has_value() ? 0 | |||
| : control->binding->area() == RegisterArea::M ? 1 : 2); | |||
| const std::optional<RegisterArea> fixed_binding_area = descriptor == nullptr | |||
| ? std::nullopt : hmiBindingArea(descriptor->bindingKind); | |||
| ui_.bindingAreaLabel->setText( | |||
| fixed_binding_area.has_value() | |||
| ? QObject::tr("绑定地址(%1)") | |||
| .arg(*fixed_binding_area == RegisterArea::M ? QStringLiteral("M") | |||
| : QStringLiteral("D")) | |||
| : QObject::tr("绑定地址")); | |||
| ui_.bindingIndexSpinBox->setValue( | |||
| control->binding.has_value() ? control->binding->index() : 0); | |||
| const bool has_binding = descriptor != nullptr | |||
| && descriptor->bindingKind != HmiBindingKind::None; | |||
| && descriptor->bindingKind != HmiBindingKind::None | |||
| && !is_status_text; | |||
| ui_.bindingAreaLabel->setVisible(has_binding); | |||
| ui_.bindingAreaComboBox->setVisible(has_binding); | |||
| ui_.bindingIndexLabel->setVisible(has_binding); | |||
| ui_.bindingIndexSpinBox->setVisible(has_binding); | |||
| ui_.bindingIndexSpinBox->setEnabled(has_binding); | |||
| const bool is_button = control->type == HmiControlType::Button; | |||
| ui_.buttonOperationLabel->setVisible(is_button); | |||
| @@ -262,6 +286,50 @@ void PropertyPanelController::showControlProperties(const std::string &control_i | |||
| ui_.buttonOperationComboBox->setCurrentIndex( | |||
| ui_.buttonOperationComboBox->findData( | |||
| static_cast<int>(control->buttonOperation))); | |||
| ui_.buttonEnableConditionLabel->setVisible(is_button); | |||
| ui_.configureButtonExtensionButton->setVisible(is_button); | |||
| ui_.configureButtonExtensionButton->setEnabled(is_button); | |||
| if (is_button) | |||
| { | |||
| // variant 保存两种按钮启用条件,按实际类型显示对应摘要 | |||
| if (!control->buttonEnableCondition.has_value()) | |||
| { | |||
| ui_.configureButtonExtensionButton->setText(QObject::tr("未设置")); | |||
| } | |||
| else if (std::holds_alternative<HmiButtonBitEnableCondition>( | |||
| *control->buttonEnableCondition)) | |||
| { | |||
| const auto &condition = std::get<HmiButtonBitEnableCondition>( | |||
| *control->buttonEnableCondition); | |||
| ui_.configureButtonExtensionButton->setText( | |||
| QObject::tr("M%1 = %2") | |||
| .arg(condition.address.index()) | |||
| .arg(condition.expected ? QObject::tr("ON") : QObject::tr("OFF"))); | |||
| } | |||
| else | |||
| { | |||
| const auto &condition = std::get<HmiButtonWordEnableCondition>( | |||
| *control->buttonEnableCondition); | |||
| const QString operation = [&condition] | |||
| { | |||
| switch (condition.operation) | |||
| { | |||
| case HmiButtonConditionOperator::Equal: return QObject::tr("="); | |||
| case HmiButtonConditionOperator::NotEqual: return QObject::tr("!="); | |||
| case HmiButtonConditionOperator::LessThan: return QObject::tr("<"); | |||
| case HmiButtonConditionOperator::LessThanOrEqual: return QObject::tr("<="); | |||
| case HmiButtonConditionOperator::GreaterThan: return QObject::tr(">"); | |||
| case HmiButtonConditionOperator::GreaterThanOrEqual: return QObject::tr(">="); | |||
| default: return QObject::tr("?"); | |||
| } | |||
| }(); | |||
| ui_.configureButtonExtensionButton->setText( | |||
| QObject::tr("D%1 %2 %3") | |||
| .arg(condition.address.index()) | |||
| .arg(operation) | |||
| .arg(QString::number(condition.value, 'g', 8))); | |||
| } | |||
| } | |||
| const bool is_numeric = control->type == HmiControlType::NumericDisplay | |||
| || control->type == HmiControlType::NumericInput; | |||
| @@ -270,6 +338,21 @@ void PropertyPanelController::showControlProperties(const std::string &control_i | |||
| ui_.dataTypeComboBox->setEnabled(is_numeric); | |||
| ui_.dataTypeComboBox->setCurrentIndex( | |||
| ui_.dataTypeComboBox->findData(static_cast<int>(control->dataType))); | |||
| ui_.statusTextConfigLabel->setVisible(is_status_text); | |||
| ui_.configureStatusTextButton->setVisible(is_status_text); | |||
| ui_.configureStatusTextButton->setEnabled(is_status_text); | |||
| if (is_status_text) | |||
| { | |||
| const QString source = control->statusText.has_value() | |||
| && std::holds_alternative<HmiStatusWordTextConfig>(*control->statusText) | |||
| ? QStringLiteral("D") : QStringLiteral("M"); | |||
| ui_.configureStatusTextButton->setText( | |||
| control->binding.has_value() | |||
| ? QObject::tr("配置(%1%2)") | |||
| .arg(source) | |||
| .arg(control->binding->index()) | |||
| : QObject::tr("配置")); | |||
| } | |||
| const bool is_page_jump = control->type == HmiControlType::PageJump; | |||
| ui_.targetPageLabel->setVisible(is_page_jump); | |||
| @@ -329,6 +412,7 @@ void PropertyPanelController::showControlProperties(const std::string &control_i | |||
| && font_italic->second == "true"); | |||
| } | |||
| // 将指定 HMI 页面尺寸加载到属性表单 | |||
| void PropertyPanelController::showPageProperties(const std::string &page_id) | |||
| { | |||
| ui_.propertyStack->setCurrentWidget(ui_.pagePropertiesPage); | |||
| @@ -356,6 +440,7 @@ void PropertyPanelController::showPageProperties(const std::string &page_id) | |||
| ui_.pageHeightSpinBox->setValue(page->height); | |||
| } | |||
| // 将指定梯形图节点配置加载到属性表单 | |||
| void PropertyPanelController::showLogicNodeProperties(const std::string &node_id) | |||
| { | |||
| ui_.propertyStack->setCurrentWidget(ui_.logicPropertiesPage); | |||
| @@ -389,6 +474,7 @@ void PropertyPanelController::showLogicNodeProperties(const std::string &node_id | |||
| ui_.logicModeComboBox->clear(); | |||
| ui_.logicComparisonComboBox->setEnabled(false); | |||
| ui_.logicValueSpinBox->setEnabled(false); | |||
| // 节点配置使用 variant,按节点种类恢复不同的属性控件 | |||
| if (const auto *contact = std::get_if<ContactNodeConfig>(&node->config)) | |||
| { | |||
| ui_.logicNodeTypeLabel->setText(QObject::tr("触点")); | |||
| @@ -472,6 +558,7 @@ void PropertyPanelController::showLogicNodeProperties(const std::string &node_id | |||
| } | |||
| } | |||
| // 使用启动默认值创建指定类型的 HMI 控件 | |||
| void PropertyPanelController::addHmiControl(HmiControlType type) | |||
| { | |||
| const HmiEditorResult result = hmi_editor_service_.addControl( | |||
| @@ -492,6 +579,7 @@ void PropertyPanelController::addHmiControl(HmiControlType type) | |||
| status_reporter_(QObject::tr("已添加%1").arg(display_name), 3000); | |||
| } | |||
| // 删除当前选中的 HMI 控件并清理选择 | |||
| void PropertyPanelController::deleteSelectedControl() | |||
| { | |||
| if (hmi_editor_widget_ == nullptr) | |||
| @@ -517,6 +605,7 @@ void PropertyPanelController::deleteSelectedControl() | |||
| refresh_project_ui_(); | |||
| } | |||
| // 读取 HMI 属性表单并通过编辑服务原子提交 | |||
| void PropertyPanelController::applySelectedControlProperties() | |||
| { | |||
| const HmiControl *old_control = hmi_editor_service_.findControl( | |||
| @@ -532,28 +621,31 @@ 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()); | |||
| if (control.type != HmiControlType::StatusText) | |||
| { | |||
| control.dataType = static_cast<RegisterDataType>( | |||
| ui_.dataTypeComboBox->currentData().toInt()); | |||
| } | |||
| const HmiControlDescriptor *descriptor = | |||
| findHmiControlDescriptor(control.type); | |||
| if (descriptor == nullptr | |||
| || descriptor->bindingKind == HmiBindingKind::None) | |||
| if (control.type != HmiControlType::StatusText | |||
| && (descriptor == nullptr | |||
| || descriptor->bindingKind == HmiBindingKind::None)) | |||
| { | |||
| control.binding.reset(); | |||
| } | |||
| else | |||
| else if (control.type != HmiControlType::StatusText) | |||
| { | |||
| const int binding_area = ui_.bindingAreaComboBox->currentData().toInt(); | |||
| if (binding_area < 0) | |||
| const std::optional<RegisterArea> fixed_binding_area = descriptor == nullptr | |||
| ? std::nullopt : hmiBindingArea(descriptor->bindingKind); | |||
| if (fixed_binding_area.has_value()) | |||
| { | |||
| control.binding.reset(); | |||
| control.binding = RegisterAddress{ | |||
| *fixed_binding_area, ui_.bindingIndexSpinBox->value()}; | |||
| } | |||
| else | |||
| { | |||
| const RegisterArea area = binding_area == 0 | |||
| ? RegisterArea::M : RegisterArea::D; | |||
| control.binding = RegisterAddress{ | |||
| area, ui_.bindingIndexSpinBox->value()}; | |||
| control.binding.reset(); | |||
| } | |||
| } | |||
| if (control.type == HmiControlType::Button) | |||
| @@ -597,6 +689,7 @@ void PropertyPanelController::applySelectedControlProperties() | |||
| status_reporter_(QObject::tr("控件属性已更新"), 3000); | |||
| } | |||
| // 读取页面尺寸表单并通过编辑服务原子提交 | |||
| void PropertyPanelController::applySelectedPageProperties() | |||
| { | |||
| const std::string page_id = current_page_id_(); | |||
| @@ -622,6 +715,7 @@ void PropertyPanelController::applySelectedPageProperties() | |||
| status_reporter_(QObject::tr("页面尺寸已更新"), 3000); | |||
| } | |||
| // 打开颜色选择器并把有效颜色写回十六进制文本 | |||
| void PropertyPanelController::chooseTextColor() | |||
| { | |||
| QColor initial_color(ui_.textColorEdit->text()); | |||
| @@ -640,6 +734,65 @@ void PropertyPanelController::chooseTextColor() | |||
| } | |||
| } | |||
| // 打开状态文本专用对话框并提交完整控件配置 | |||
| void PropertyPanelController::configureStatusText() | |||
| { | |||
| const HmiControl *old_control = hmi_editor_service_.findControl( | |||
| current_page_id_(), selected_control_id_); | |||
| if (old_control == nullptr || old_control->type != HmiControlType::StatusText) | |||
| { | |||
| return; | |||
| } | |||
| StatusTextDialog dialog(*old_control, &parent_); | |||
| if (dialog.exec() != QDialog::Accepted) | |||
| { | |||
| return; | |||
| } | |||
| const HmiEditorResult result = hmi_editor_service_.updateControl( | |||
| current_page_id_(), selected_control_id_, dialog.control()); | |||
| if (!result.succeeded) | |||
| { | |||
| reportFailure(QObject::tr("配置状态文本"), result.message); | |||
| return; | |||
| } | |||
| hmi_editor_widget_->reloadPage(); | |||
| hmi_editor_widget_->selectControl(result.id); | |||
| showControlProperties(result.id); | |||
| refresh_project_ui_(); | |||
| status_reporter_(QObject::tr("状态文本配置已更新"), 3000); | |||
| } | |||
| // 打开按钮启用条件对话框并提交完整控件配置 | |||
| void PropertyPanelController::configureButtonExtension() | |||
| { | |||
| const HmiControl *old_control = hmi_editor_service_.findControl( | |||
| current_page_id_(), selected_control_id_); | |||
| if (old_control == nullptr || old_control->type != HmiControlType::Button) | |||
| { | |||
| return; | |||
| } | |||
| ButtonExtensionDialog dialog(*old_control, &parent_); | |||
| if (dialog.exec() != QDialog::Accepted) | |||
| { | |||
| return; | |||
| } | |||
| const HmiEditorResult result = hmi_editor_service_.updateControl( | |||
| current_page_id_(), selected_control_id_, dialog.control()); | |||
| if (!result.succeeded) | |||
| { | |||
| reportFailure(QObject::tr("按钮扩展配置"), result.message); | |||
| return; | |||
| } | |||
| const std::string updated_control_id = result.id; | |||
| hmi_editor_widget_->reloadPage(); | |||
| selected_control_id_ = updated_control_id; | |||
| hmi_editor_widget_->selectControl(updated_control_id); | |||
| showControlProperties(updated_control_id); | |||
| refresh_project_ui_(); | |||
| status_reporter_(QObject::tr("按钮扩展配置已更新"), 3000); | |||
| } | |||
| // 读取梯形图节点表单并提交强类型配置 | |||
| void PropertyPanelController::applySelectedLogicNodeProperties() | |||
| { | |||
| const LogicNode *node = logic_editor_service_.findNode( | |||
| @@ -650,6 +803,7 @@ void PropertyPanelController::applySelectedLogicNodeProperties() | |||
| } | |||
| LogicNodeConfig config = node->config; | |||
| // 保留节点原有种类,只替换界面中允许编辑的字段 | |||
| if (std::holds_alternative<ContactNodeConfig>(config)) | |||
| { | |||
| config = ContactNodeConfig{ | |||
| @@ -700,16 +854,19 @@ void PropertyPanelController::applySelectedLogicNodeProperties() | |||
| status_reporter_(QObject::tr("逻辑节点属性已更新,节点已配置"), 3000); | |||
| } | |||
| // 将 HMI 编辑错误显示到统一结果和状态入口 | |||
| void PropertyPanelController::handleHmiEditorError(const QString &message) const | |||
| { | |||
| status_reporter_(message, 5000); | |||
| } | |||
| // 将梯形图编辑错误显示到统一结果和状态入口 | |||
| void PropertyPanelController::handleLogicEditorError(const QString &message) const | |||
| { | |||
| status_reporter_(message, 5000); | |||
| } | |||
| // 将服务层 UTF-8 错误统一报告给主窗口 | |||
| void PropertyPanelController::reportFailure( | |||
| const QString &action, const std::string &message) const | |||
| { | |||
| @@ -89,6 +89,10 @@ private: | |||
| void handleLogicEditorError(const QString &message) const; | |||
| /** 打开颜色选择器并写回属性输入框 */ | |||
| void chooseTextColor(); | |||
| /** 打开状态文本专用配置并原子更新控件 */ | |||
| void configureStatusText(); | |||
| /** 打开按钮启用条件专用配置并原子更新控件 */ | |||
| void configureButtonExtension(); | |||
| /** 统一报告属性操作失败 */ | |||
| void reportFailure( | |||
| const QString &action, const std::string &message) const; | |||
| @@ -11,11 +11,13 @@ | |||
| namespace { | |||
| // 将 UTF-8 标准字符串转换为 Qt 文本 | |||
| QString fromUtf8(const std::string &value) | |||
| { | |||
| return QString::fromUtf8(value.data(), static_cast<int>(value.size())); | |||
| } | |||
| // 将 Qt 文本转换为 UTF-8 标准字符串 | |||
| std::string toUtf8(const QString &value) | |||
| { | |||
| const QByteArray bytes = value.toUtf8(); | |||
| @@ -24,6 +26,7 @@ std::string toUtf8(const QString &value) | |||
| } // namespace | |||
| // 初始化寄存器注释表格并绑定保存、删除和选择事件 | |||
| RegisterCommentDialog::RegisterCommentDialog( | |||
| RegisterCommentService &service, | |||
| QWidget *parent) | |||
| @@ -49,8 +52,10 @@ RegisterCommentDialog::RegisterCommentDialog( | |||
| reloadComments(); | |||
| } | |||
| // 释放 Designer 界面对象 | |||
| RegisterCommentDialog::~RegisterCommentDialog() = default; | |||
| // 重新加载注释列表并尽量恢复指定地址的选择 | |||
| void RegisterCommentDialog::reloadComments( | |||
| const RegisterAddress *selected_address) | |||
| { | |||
| @@ -63,6 +68,7 @@ void RegisterCommentDialog::reloadComments( | |||
| const int row = static_cast<int>(index); | |||
| auto *address_item = new QTableWidgetItem( | |||
| QString::fromStdString(comment.address.toString())); | |||
| // UserRole 保存地址区域和下标,显示文本变化不会影响业务定位 | |||
| address_item->setData(Qt::UserRole, static_cast<int>(comment.address.area())); | |||
| address_item->setData(Qt::UserRole + 1, comment.address.index()); | |||
| ui_->commentTable->setItem(row, 0, address_item); | |||
| @@ -83,6 +89,7 @@ void RegisterCommentDialog::reloadComments( | |||
| } | |||
| } | |||
| // 将当前选中地址的注释加载到编辑表单 | |||
| void RegisterCommentDialog::loadSelectedComment() | |||
| { | |||
| const std::optional<RegisterAddress> address = selectedAddress(); | |||
| @@ -99,6 +106,7 @@ void RegisterCommentDialog::loadSelectedComment() | |||
| ui_->commentEdit->setText(fromUtf8(comment->text)); | |||
| } | |||
| // 校验并保存当前表单中的寄存器注释 | |||
| void RegisterCommentDialog::saveComment() | |||
| { | |||
| const RegisterAddress address = addressFromInputs(); | |||
| @@ -112,6 +120,7 @@ void RegisterCommentDialog::saveComment() | |||
| reloadComments(&address); | |||
| } | |||
| // 确认后删除当前选中地址的寄存器注释 | |||
| void RegisterCommentDialog::removeComment() | |||
| { | |||
| const std::optional<RegisterAddress> address = selectedAddress(); | |||
| @@ -134,6 +143,7 @@ void RegisterCommentDialog::removeComment() | |||
| reloadComments(); | |||
| } | |||
| // 将区域和下标输入组合为寄存器地址 | |||
| RegisterAddress RegisterCommentDialog::addressFromInputs() const | |||
| { | |||
| const RegisterArea area = static_cast<RegisterArea>( | |||
| @@ -141,6 +151,7 @@ RegisterAddress RegisterCommentDialog::addressFromInputs() const | |||
| return RegisterAddress{area, ui_->indexSpinBox->value()}; | |||
| } | |||
| // 从当前表格行保存的业务数据还原寄存器地址 | |||
| std::optional<RegisterAddress> RegisterCommentDialog::selectedAddress() const | |||
| { | |||
| const int row = ui_->commentTable->currentRow(); | |||
| @@ -11,7 +11,7 @@ | |||
| #include <QSplitter> | |||
| #include <QComboBox> | |||
| #include <QPushButton> | |||
| #include <QFrame> | |||
| #include <QSignalBlocker> | |||
| #include <QToolButton> | |||
| @@ -19,11 +19,13 @@ | |||
| namespace { | |||
| // 将 UTF-8 标准字符串转换为 Qt 文本 | |||
| QString fromUtf8(const std::string &value) | |||
| { | |||
| return QString::fromUtf8(value.data(), static_cast<int>(value.size())); | |||
| } | |||
| // 将 Qt 文本转换为 UTF-8 标准字符串 | |||
| std::string toUtf8(const QString &value) | |||
| { | |||
| const QByteArray bytes = value.toUtf8(); | |||
| @@ -32,6 +34,7 @@ std::string toUtf8(const QString &value) | |||
| } // namespace | |||
| // 创建并组合运行态 HMI、梯形图和自由监控视图 | |||
| RuntimeMonitorWidget::RuntimeMonitorWidget( | |||
| HmiEditorService &hmi_editor_service, | |||
| HmiRuntimeService &hmi_runtime_service, | |||
| @@ -69,8 +72,6 @@ RuntimeMonitorWidget::RuntimeMonitorWidget( | |||
| 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, | |||
| @@ -104,21 +105,19 @@ RuntimeMonitorWidget::RuntimeMonitorWidget( | |||
| this, | |||
| [this](int index) | |||
| { | |||
| if (user_runtime_mode_ && index >= 0) | |||
| if (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); | |||
| } | |||
| // 释放 Designer 界面和 Qt 父子对象拥有的子控件 | |||
| RuntimeMonitorWidget::~RuntimeMonitorWidget() = default; | |||
| // 绑定当前运行页面和控制逻辑,并刷新选择列表 | |||
| void RuntimeMonitorWidget::setProjectObjects( | |||
| const std::string &page_id, const std::string &logic_id) | |||
| { | |||
| @@ -145,16 +144,31 @@ void RuntimeMonitorWidget::setProjectObjects( | |||
| : std::string{}); | |||
| } | |||
| // 切换当前运行 HMI 页面 | |||
| void RuntimeMonitorWidget::setRuntimePage(const std::string &page_id) | |||
| { | |||
| showRuntimePage(page_id); | |||
| } | |||
| // 根据编辑、离线或真机状态更新运行大屏的可见内容和操作资格 | |||
| void RuntimeMonitorWidget::setMode( | |||
| ApplicationMode mode, PlcConnectionState plc_state) | |||
| { | |||
| const bool offline = mode == ApplicationMode::OfflineRunning; | |||
| const bool online = mode == ApplicationMode::OnlineRunning; | |||
| if (hmi_only_mode_) | |||
| { | |||
| // 导出运行版只显示 HMI,数据源在进入真机态后固定为 PLC 缓存 | |||
| ui_->logicFrame->setVisible(false); | |||
| ui_->monitorFrame->setVisible(false); | |||
| ui_->runtimeModeComboBox->setVisible(false); | |||
| ui_->exitRuntimeButton->setVisible(false); | |||
| hmi_view_->setRuntimeActive(true); | |||
| // 首读资格由控制器按当前 PLC 状态统一恢复,连接刚建立时仍保持只读 | |||
| setHmiWriteEnabled(false); | |||
| setFreeMonitorWriteEnabled(false); | |||
| return; | |||
| } | |||
| ui_->logicFrame->setVisible(offline || online); | |||
| hmi_view_->setRuntimeActive(offline || online); | |||
| if (!offline && !online) | |||
| @@ -177,26 +191,50 @@ void RuntimeMonitorWidget::setMode( | |||
| offline ? tr("离线仿真 · 虚拟 M/D") | |||
| : 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); | |||
| ui_->runtimeModeComboBox->setCurrentIndex(offline ? 0 : online ? 1 : -1); | |||
| } | |||
| free_monitor_widget_->refreshValues(mode, plc_state); | |||
| } | |||
| void RuntimeMonitorWidget::setUserRuntimeMode(bool enabled) | |||
| // 切换只显示 HMI 的用户运行版布局 | |||
| void RuntimeMonitorWidget::setHmiOnlyRuntime(bool enabled) | |||
| { | |||
| user_runtime_mode_ = enabled; | |||
| ui_->runtimeModeComboBox->setVisible(enabled); | |||
| ui_->runtimePlcButton->setVisible(enabled); | |||
| ui_->runtimeDisconnectPlcButton->setVisible(enabled); | |||
| hmi_only_mode_ = enabled; | |||
| if (enabled) | |||
| { | |||
| // 用户运行版没有模式、连接配置和诊断面板,启动后只投影 HMI 页面 | |||
| ui_->logicFrame->setVisible(false); | |||
| ui_->monitorFrame->setVisible(false); | |||
| ui_->titleLabel->setVisible(false); | |||
| ui_->modeLabel->setVisible(false); | |||
| ui_->runtimeModeComboBox->setVisible(false); | |||
| ui_->exitRuntimeButton->setVisible(false); | |||
| ui_->hmiLabel->setVisible(false); | |||
| ui_->runtimePageLabel->setVisible(false); | |||
| ui_->hmiFrame->setFrameShape(QFrame::NoFrame); | |||
| ui_->hmiLayout->setContentsMargins(0, 0, 0, 0); | |||
| ui_->verticalLayout->setContentsMargins(0, 0, 0, 0); | |||
| ui_->verticalSplitter->setSizes({1, 0}); | |||
| ui_->upperSplitter->setSizes({1, 0}); | |||
| } | |||
| else | |||
| { | |||
| // 普通编程器仍保留完整运行监控区域,运行版模式只在进程启动时启用 | |||
| ui_->logicFrame->setVisible(false); | |||
| ui_->monitorFrame->setVisible(true); | |||
| ui_->titleLabel->setVisible(true); | |||
| ui_->modeLabel->setVisible(true); | |||
| ui_->hmiLabel->setVisible(true); | |||
| ui_->runtimePageLabel->setVisible(true); | |||
| ui_->exitRuntimeButton->setVisible(true); | |||
| ui_->hmiFrame->setFrameShape(QFrame::StyledPanel); | |||
| ui_->hmiLayout->setContentsMargins(4, 4, 4, 4); | |||
| ui_->verticalLayout->setContentsMargins(8, 8, 8, 8); | |||
| } | |||
| // 普通编程器和导出的运行程序都允许通过控制器注入模式行为 | |||
| ui_->runtimeModeComboBox->setVisible(!enabled); | |||
| ui_->exitRuntimeButton->setToolTip( | |||
| enabled ? tr("退出运行程序") : tr("返回编辑态")); | |||
| ui_->exitRuntimeButton->setAccessibleName( | |||
| @@ -207,44 +245,53 @@ void RuntimeMonitorWidget::setUserRuntimeMode(bool enabled) | |||
| } | |||
| } | |||
| // 设置 HMI 按钮和数值输入是否允许写入 | |||
| void RuntimeMonitorWidget::setHmiWriteEnabled(bool enabled) | |||
| { | |||
| hmi_view_->setRuntimeWriteEnabled(enabled); | |||
| } | |||
| // 设置自由监控是否允许写入寄存器 | |||
| void RuntimeMonitorWidget::setFreeMonitorWriteEnabled(bool enabled) | |||
| { | |||
| free_monitor_widget_->setWriteEnabled(enabled); | |||
| } | |||
| // 从活动仓库刷新 HMI、报警和自由监控值 | |||
| void RuntimeMonitorWidget::refreshValues( | |||
| ApplicationMode mode, PlcConnectionState plc_state) | |||
| { | |||
| // HMI、报警和自由监控共享同一个活动寄存器仓库 | |||
| hmi_view_->refreshRuntimeValues(); | |||
| free_monitor_widget_->refreshValues(mode, plc_state); | |||
| if (!hmi_only_mode_) | |||
| { | |||
| free_monitor_widget_->refreshValues(mode, plc_state); | |||
| } | |||
| } | |||
| // 保存最新逻辑轨迹并投影到当前大屏显示的控制逻辑 | |||
| void RuntimeMonitorWidget::setLogicTrace( | |||
| const LogicTraceSnapshot &trace, const std::string &fault_node_id) | |||
| { | |||
| latest_trace_ = trace; | |||
| latest_fault_node_id_ = fault_node_id; | |||
| has_logic_trace_ = true; | |||
| logic_view_->setRuntimeTrace( | |||
| trace.forLogic(selected_logic_id_), fault_node_id); | |||
| logic_view_->setRuntimeTrace(trace, fault_node_id); | |||
| } | |||
| // 返回运行监控中的自由监控子控件 | |||
| FreeMonitorWidget *RuntimeMonitorWidget::freeMonitorWidget() const | |||
| { | |||
| return free_monitor_widget_; | |||
| } | |||
| // 返回当前大屏选中的控制逻辑标识 | |||
| std::string RuntimeMonitorWidget::selectedLogicId() const | |||
| { | |||
| return selected_logic_id_; | |||
| } | |||
| // 校验页面后切换运行 HMI 投影 | |||
| void RuntimeMonitorWidget::showRuntimePage(const std::string &page_id) | |||
| { | |||
| hmi_view_->setPageId(page_id); | |||
| @@ -258,6 +305,7 @@ void RuntimeMonitorWidget::showRuntimePage(const std::string &page_id) | |||
| ? tr("未选择页面") : fromUtf8(page->name)); | |||
| } | |||
| // 更新逻辑选择并投影对应的独立轨迹 | |||
| void RuntimeMonitorWidget::selectRuntimeLogic(const std::string &logic_id) | |||
| { | |||
| selected_logic_id_ = logic_id; | |||
| @@ -265,7 +313,6 @@ void RuntimeMonitorWidget::selectRuntimeLogic(const std::string &logic_id) | |||
| logic_view_->reloadLogic(); | |||
| if (has_logic_trace_) | |||
| { | |||
| logic_view_->setRuntimeTrace( | |||
| latest_trace_.forLogic(logic_id), latest_fault_node_id_); | |||
| logic_view_->setRuntimeTrace(latest_trace_, latest_fault_node_id_); | |||
| } | |||
| } | |||