29 次程式碼提交

作者 SHA1 備註 提交日期
  suyu 669bb7ee4b docs: 补充用户运行程序导出说明 3 週之前
  suyu 51ca203ad3 feat: 完善用户运行程序导出链路 3 週之前
  suyu c5075fd922 docs: document register monitoring workflow 3 週之前
  suyu bf1961ea1c feat: add editable register monitoring 3 週之前
  suyu 11d00e4d0e docs: document ladder command input 3 週之前
  suyu 6d9cd22729 feat: add ladder command input editing 3 週之前
  suyu c628724a98 docs: 更新Float32与运行模式说明 3 週之前
  suyu 1034198aed feat: 支持D区Float32读写 3 週之前
  suyu 19398b2641 feat: 增加真机本地梯形图只读推算 3 週之前
  suyu ec7d099e3a fix: 禁止提交空白页面和逻辑名称 3 週之前
  suyu 6594ef1183 feat: 添加应用外部配置 3 週之前
  suyu d882034c46 chore: 更新项目文档与构建脚本 3 週之前
  suyu 839ce495de feat: 添加 PLC 串口自动搜索 3 週之前
  suyu 1f094e7324 refactor: 移除 HMI 进度条控件 3 週之前
  suyu 1ba67589f5 fix: 清理退出仿真后的旧轨迹 3 週之前
  suyu f1af345409 feat: 完善梯形图显式连接模型 3 週之前
  suyu 66ce714bf9 feat: 完善编辑复制粘贴与数量边界 3 週之前
  suyu 7b220e59be feat: 添加编辑器复制粘贴功能 3 週之前
  suyu a1b3863726 docs: 同步梯形图能力边界 3 週之前
  suyu 54476bd248 refactor: 移除定时器和计数器指令 3 週之前
  suyu 644035ecee docs: 整理项目交付与测试说明 3 週之前
  suyu 00b1035942 refactor: 收敛测试体系与运行服务边界 3 週之前
  suyu ab5085e42b fix: 限制运行监控窗口系统关闭路径 4 週之前
  suyu b852940d7d feat: 完善梯形图编辑并整理寄存器仓库结构 4 週之前
  suyu bcd0e1c40b fix: 修复横线撤销后无法再次插入 4 週之前
  suyu 340577abe4 fix: 保持并联补线选中态横线可见 4 週之前
  suyu e8b9b18b61 docs: 更新架构与交接资料 4 週之前
  suyu 88cacc0f57 feat: 完善梯形图网格编辑与运行界面 4 週之前
  suyu 875d9ba3c6 feat: 完善运行监控与控制逻辑能力 4 週之前
共有 100 個檔案被更改,包括 10423 行新增2497 行删除
統一視圖
  1. +7
    -1
      .gitignore
  2. +13
    -7
      AGENTS.md
  3. +20
    -1
      app/integrated_platform.pro
  4. +14
    -0
      app/src/domain/active_register_repository.cpp
  5. +6
    -0
      app/src/domain/active_register_repository.h
  6. +1
    -1
      app/src/domain/alarm_model.cpp
  7. +11
    -8
      app/src/domain/alarm_model.h
  8. +129
    -318
      app/src/domain/control_logic_model.cpp
  9. +132
    -148
      app/src/domain/control_logic_model.h
  10. +1
    -10
      app/src/domain/hmi_control_registry.cpp
  11. +15
    -15
      app/src/domain/hmi_control_registry.h
  12. +41
    -43
      app/src/domain/hmi_model.cpp
  13. +8
    -16
      app/src/domain/hmi_model.h
  14. +94
    -59
      app/src/domain/project_limits.h
  15. +163
    -17
      app/src/domain/project_model.cpp
  16. +13
    -2
      app/src/domain/project_model.h
  17. +10
    -0
      app/src/domain/register_address.h
  18. +52
    -9
      app/src/domain/register_monitor_model.cpp
  19. +25
    -3
      app/src/domain/register_monitor_model.h
  20. +43
    -33
      app/src/domain/register_repository.h
  21. +51
    -0
      app/src/domain/register_value_type.cpp
  22. +22
    -0
      app/src/domain/register_value_type.h
  23. +8
    -7
      app/src/domain/runtime_state.h
  24. +20
    -1
      app/src/domain/virtual_register_repository.cpp
  25. +38
    -0
      app/src/domain/virtual_register_repository.h
  26. +509
    -0
      app/src/infrastructure/application_settings_loader.cpp
  27. +13
    -0
      app/src/infrastructure/application_settings_loader.h
  28. +81
    -271
      app/src/infrastructure/json_project_storage.cpp
  29. +7
    -1
      app/src/infrastructure/json_project_storage.h
  30. +3
    -0
      app/src/infrastructure/plc_communication_error_classifier.h
  31. +245
    -8
      app/src/infrastructure/plc_communication_service.cpp
  32. +73
    -27
      app/src/infrastructure/plc_communication_service.h
  33. +304
    -0
      app/src/infrastructure/plc_discovery_service.cpp
  34. +68
    -0
      app/src/infrastructure/plc_discovery_service.h
  35. +24
    -1
      app/src/infrastructure/plc_register_repository.cpp
  36. +22
    -7
      app/src/infrastructure/plc_register_repository.h
  37. +127
    -9
      app/src/main.cpp
  38. +6
    -2
      app/src/services/alarm_editor_service.cpp
  39. +8
    -0
      app/src/services/alarm_editor_service.h
  40. +11
    -0
      app/src/services/alarm_service.cpp
  41. +43
    -7
      app/src/services/alarm_service.h
  42. +30
    -0
      app/src/services/application_settings.h
  43. +40
    -4
      app/src/services/editor_history.h
  44. +148
    -21
      app/src/services/hmi_editor_service.cpp
  45. +111
    -19
      app/src/services/hmi_editor_service.h
  46. +1
    -0
      app/src/services/hmi_navigation_service.cpp
  47. +7
    -0
      app/src/services/hmi_navigation_service.h
  48. +52
    -6
      app/src/services/hmi_runtime_service.cpp
  49. +36
    -12
      app/src/services/hmi_runtime_service.h
  50. +588
    -0
      app/src/services/logic_command_service.cpp
  51. +106
    -0
      app/src/services/logic_command_service.h
  52. +1898
    -139
      app/src/services/logic_editor_service.cpp
  53. +212
    -21
      app/src/services/logic_editor_service.h
  54. +22
    -1
      app/src/services/offline_simulation_service.cpp
  55. +44
    -17
      app/src/services/offline_simulation_service.h
  56. +209
    -0
      app/src/services/online_logic_monitor_service.cpp
  57. +70
    -0
      app/src/services/online_logic_monitor_service.h
  58. +97
    -28
      app/src/services/plc_communication_gateway.h
  59. +126
    -0
      app/src/services/plc_discovery_gateway.cpp
  60. +53
    -0
      app/src/services/plc_discovery_gateway.h
  61. +41
    -2
      app/src/services/project_service.cpp
  62. +10
    -1
      app/src/services/project_service.h
  63. +6
    -0
      app/src/services/register_comment_service.h
  64. +200
    -21
      app/src/services/register_monitor_service.cpp
  65. +94
    -15
      app/src/services/register_monitor_service.h
  66. +101
    -8
      app/src/services/runtime_mode_service.cpp
  67. +68
    -12
      app/src/services/runtime_mode_service.h
  68. +20
    -173
      app/src/services/software_logic_executor.cpp
  69. +87
    -68
      app/src/services/software_logic_executor.h
  70. +21
    -0
      app/src/ui/alarm_configuration_dialog.h
  71. +66
    -19
      app/src/ui/free_monitor_widget.cpp
  72. +25
    -0
      app/src/ui/free_monitor_widget.h
  73. +7
    -5
      app/src/ui/free_monitor_widget.ui
  74. +44
    -57
      app/src/ui/hmi_editor_widget.cpp
  75. +13
    -7
      app/src/ui/hmi_editor_widget.h
  76. +1257
    -220
      app/src/ui/logic_editor_widget.cpp
  77. +124
    -1
      app/src/ui/logic_editor_widget.h
  78. +2
    -44
      app/src/ui/logic_instruction_dialog.cpp
  79. +19
    -1
      app/src/ui/logic_instruction_dialog.h
  80. +0
    -21
      app/src/ui/logic_instruction_dialog.ui
  81. +833
    -103
      app/src/ui/main_window.cpp
  82. +162
    -24
      app/src/ui/main_window.h
  83. +57
    -111
      app/src/ui/main_window.ui
  84. +178
    -17
      app/src/ui/plc_connection_dialog.cpp
  85. +35
    -0
      app/src/ui/plc_connection_dialog.h
  86. +80
    -40
      app/src/ui/plc_connection_dialog.ui
  87. +48
    -12
      app/src/ui/project_workspace_controller.cpp
  88. +60
    -4
      app/src/ui/project_workspace_controller.h
  89. +21
    -103
      app/src/ui/property_panel_controller.cpp
  90. +47
    -1
      app/src/ui/property_panel_controller.h
  91. +19
    -0
      app/src/ui/register_comment_dialog.h
  92. +56
    -3
      app/src/ui/runtime_monitor_widget.cpp
  93. +44
    -0
      app/src/ui/runtime_monitor_widget.h
  94. +4
    -0
      app/src/ui/runtime_monitor_widget.ui
  95. +4
    -2
      app/src/ui/runtime_monitor_window.cpp
  96. +19
    -4
      app/src/ui/runtime_monitor_window.h
  97. +109
    -15
      app/src/ui/runtime_panel_controller.cpp
  98. +54
    -1
      app/src/ui/runtime_panel_controller.h
  99. +0
    -33
      app/src/ui/toolbar_icon_factory.cpp
  100. +57
    -49
      app/src/ui/toolbar_icon_factory.h

+ 7
- 1
.gitignore 查看文件

@@ -23,6 +23,7 @@ ui_*.h
*.ilk *.ilk
*.exp *.exp
*.manifest *.manifest
*.autosave


# Qt Creator local user settings # Qt Creator local user settings
*.pro.user *.pro.user
@@ -56,10 +57,15 @@ Thumbs.db
Desktop.ini Desktop.ini


/scripts/* /scripts/*
!/scripts/build_and_run_qt.ps1
!/scripts/measure_cpp_code.ps1
!/scripts/package_qt_app.ps1
!/scripts/run_qt_tests.ps1 !/scripts/run_qt_tests.ps1
/docs/images /docs/images
/docs/类图Mermaid代码.md /docs/类图Mermaid代码.md
/other /other
设计方案书.doc
/学习 /学习
/json /json
/代码
/other_version
/app_copy

+ 13
- 7
AGENTS.md 查看文件

@@ -10,11 +10,13 @@
2. 涉及模块边界、数据流、运行模式或持久化时,再读 `docs/architecture.md` 2. 涉及模块边界、数据流、运行模式或持久化时,再读 `docs/architecture.md`
3. 按任务读取下方索引中的业务文档;Markdown 没有答案时再查 `docs/pdf/` 3. 按任务读取下方索引中的业务文档;Markdown 没有答案时再查 `docs/pdf/`


三个入口文档各自只负责一类信息:
三个入口文档各自只负责一类信息,不互相复制正文


- `AGENTS.md`:长期有效的开发、构建和安全约束
- `docs/architecture.md`:稳定的分层、模块边界、核心模型和数据流
- `docs/ai/handoff.md`:当前进度、工作区状态、最近验证和下一步;更新时替换旧状态,不追加开发流水账
- `AGENTS.md`:长期有效的协作、构建、验证、安全和 Git 约束;不记录当前功能清单或开发进度
- `docs/architecture.md`:当前代码的稳定分层、模块边界、核心模型和数据流;不记录未提交文件和按日期排列的验证结果
- `docs/ai/handoff.md`:当前工作区状态、本轮改动、最近验证和下一步;更新时替换旧状态,不追加开发流水账或重复专题文档

格式字段、数量边界、测试范围和硬件资料分别以对应专题文档为准。功能变化时更新所属专题文档,入口文档只保留导航、稳定约束或当前交接信息。


## 关键文件索引 ## 关键文件索引


@@ -23,6 +25,9 @@
- C++ 规范:`docs/C++代码规范.md` - C++ 规范:`docs/C++代码规范.md`
- 原始需求:`docs/0_综合平台编程器_修改.md` - 原始需求:`docs/0_综合平台编程器_修改.md`
- 数量边界最终规则:`docs/用户使用/数量边界确认方案.md` - 数量边界最终规则:`docs/用户使用/数量边界确认方案.md`
- 应用配置文件:`docs/用户使用/应用配置说明.md`
- 用户运行程序导出:`docs/用户使用/用户运行程序导出说明.md`
- 数据监控与离线初始值:`docs/用户使用/数据监控与离线初始值说明.md`
- 工程 JSON 格式:`docs/工程格式说明.md` - 工程 JSON 格式:`docs/工程格式说明.md`
- 开发阶段顺序:`docs/开发顺序.md`(阶段参考,当前状态以 handoff 为准) - 开发阶段顺序:`docs/开发顺序.md`(阶段参考,当前状态以 handoff 为准)
- 测试约定:`docs/测试约定.md` - 测试约定:`docs/测试约定.md`
@@ -40,8 +45,9 @@
- Windows 命令优先使用 PowerShell 7;显式调用使用 `pwsh -NoLogo -NoProfile -Command`,脚本使用 `pwsh -NoLogo -NoProfile -File` - Windows 命令优先使用 PowerShell 7;显式调用使用 `pwsh -NoLogo -NoProfile -Command`,脚本使用 `pwsh -NoLogo -NoProfile -File`
- 复杂 PowerShell 逻辑写入 `.ps1` 后执行,避免嵌套引号;排查兼容问题时先确认 `$PSVersionTable.PSVersion` 和 `$PSVersionTable.PSEdition` - 复杂 PowerShell 逻辑写入 `.ps1` 后执行,避免嵌套引号;排查兼容问题时先确认 `$PSVersionTable.PSVersion` 和 `$PSVersionTable.PSEdition`
- 在独立构建目录运行 qmake 和 `mingw32-make`,不要把生成物写进 `app/` - 在独立构建目录运行 qmake 和 `mingw32-make`,不要把生成物写进 `app/`
- Debug 构建运行:`pwsh -NoLogo -NoProfile -File .\scripts\build_and_run_qt.ps1`
- Release 构建运行:`pwsh -NoLogo -NoProfile -File .\scripts\build_and_run_qt.ps1 -Configuration Release`
- 项目编译运行:`pwsh -NoLogo -NoProfile -File .\scripts\build_and_run_qt.ps1`
- 测试编译运行:`pwsh -NoLogo -NoProfile -File .\scripts\run_qt_tests.ps1 -Configuration Release`
- Qt 项目打包:`pwsh -NoLogo -NoProfile -File .\scripts\package_qt_app.ps1`
- 打开主界面:`& 'D:\Qt5.15.2\5.15.2\mingw81_64\bin\designer.exe' '.\app\src\ui\main_window.ui'` - 打开主界面:`& 'D:\Qt5.15.2\5.15.2\mingw81_64\bin\designer.exe' '.\app\src\ui\main_window.ui'`


## 代码与架构约束 ## 代码与架构约束
@@ -53,7 +59,7 @@
- 数量边界统一定义在 `app/src/domain/project_limits.h`,修改时同步更新数量边界文档和边界测试 - 数量边界统一定义在 `app/src/domain/project_limits.h`,修改时同步更新数量边界文档和边界测试
- M/D 项目地址范围固定为 `0~4000`;Modbus 使用从 `0` 开始的原始地址 - M/D 项目地址范围固定为 `0~4000`;Modbus 使用从 `0` 开始的原始地址
- 离线模式使用虚拟 M/D 并运行软件逻辑执行器 - 离线模式使用虚拟 M/D 并运行软件逻辑执行器
- 真机模式使用 PLC 读回缓存并停止软件逻辑执行器;切换时先读 PLC,不复制离线值
- 真机模式使用 PLC 读回缓存;每轮完整轮询后在临时仓库推算本地梯形图轨迹,不把本地输出写入 PLC;切换时先读 PLC,不复制离线值
- 本项目不生成、编译或下载 PLC 程序,不得把本地梯形图轨迹当成 PLC 内部程序轨迹 - 本项目不生成、编译或下载 PLC 程序,不得把本地梯形图轨迹当成 PLC 内部程序轨迹
- 代码注释只解释不明显的约束,末尾不要加句号 `。` - 代码注释只解释不明显的约束,末尾不要加句号 `。`




+ 20
- 1
app/integrated_platform.pro 查看文件

@@ -29,7 +29,8 @@ SOURCES += \
src/ui/property_panel_controller.cpp \ src/ui/property_panel_controller.cpp \
src/ui/runtime_panel_controller.cpp \ src/ui/runtime_panel_controller.cpp \
src/domain/register_address.cpp \ src/domain/register_address.cpp \
src/domain/register_repository.cpp \
src/domain/register_value_type.cpp \
src/domain/virtual_register_repository.cpp \
src/domain/active_register_repository.cpp \ src/domain/active_register_repository.cpp \
src/domain/register_monitor_model.cpp \ src/domain/register_monitor_model.cpp \
src/domain/alarm_model.cpp \ src/domain/alarm_model.cpp \
@@ -44,15 +45,20 @@ SOURCES += \
src/services/hmi_editor_service.cpp \ src/services/hmi_editor_service.cpp \
src/services/hmi_navigation_service.cpp \ src/services/hmi_navigation_service.cpp \
src/services/logic_editor_service.cpp \ src/services/logic_editor_service.cpp \
src/services/logic_command_service.cpp \
src/services/hmi_runtime_service.cpp \ src/services/hmi_runtime_service.cpp \
src/services/software_logic_executor.cpp \ src/services/software_logic_executor.cpp \
src/services/offline_simulation_service.cpp \ src/services/offline_simulation_service.cpp \
src/services/online_logic_monitor_service.cpp \
src/services/runtime_mode_service.cpp \ src/services/runtime_mode_service.cpp \
src/services/plc_discovery_gateway.cpp \
src/services/register_monitor_service.cpp \ src/services/register_monitor_service.cpp \
src/services/register_comment_service.cpp \ src/services/register_comment_service.cpp \
src/infrastructure/plc_register_repository.cpp \ src/infrastructure/plc_register_repository.cpp \
src/infrastructure/plc_communication_error_classifier.cpp \ src/infrastructure/plc_communication_error_classifier.cpp \
src/infrastructure/plc_communication_service.cpp \ src/infrastructure/plc_communication_service.cpp \
src/infrastructure/plc_discovery_service.cpp \
src/infrastructure/application_settings_loader.cpp \
src/infrastructure/json_project_storage.cpp \ src/infrastructure/json_project_storage.cpp \
src/ui/hmi_editor_widget.cpp \ src/ui/hmi_editor_widget.cpp \
src/ui/logic_editor_widget.cpp src/ui/logic_editor_widget.cpp
@@ -71,7 +77,9 @@ HEADERS += \
src/ui/property_panel_controller.h \ src/ui/property_panel_controller.h \
src/ui/runtime_panel_controller.h \ src/ui/runtime_panel_controller.h \
src/domain/register_address.h \ src/domain/register_address.h \
src/domain/register_value_type.h \
src/domain/register_repository.h \ src/domain/register_repository.h \
src/domain/virtual_register_repository.h \
src/domain/active_register_repository.h \ src/domain/active_register_repository.h \
src/domain/register_monitor_model.h \ src/domain/register_monitor_model.h \
src/domain/alarm_model.h \ src/domain/alarm_model.h \
@@ -89,16 +97,22 @@ HEADERS += \
src/services/hmi_editor_service.h \ src/services/hmi_editor_service.h \
src/services/hmi_navigation_service.h \ src/services/hmi_navigation_service.h \
src/services/logic_editor_service.h \ src/services/logic_editor_service.h \
src/services/logic_command_service.h \
src/services/hmi_runtime_service.h \ src/services/hmi_runtime_service.h \
src/services/software_logic_executor.h \ src/services/software_logic_executor.h \
src/services/offline_simulation_service.h \ src/services/offline_simulation_service.h \
src/services/online_logic_monitor_service.h \
src/services/runtime_mode_service.h \ src/services/runtime_mode_service.h \
src/services/plc_discovery_gateway.h \
src/services/register_monitor_service.h \ src/services/register_monitor_service.h \
src/services/register_comment_service.h \ src/services/register_comment_service.h \
src/services/plc_communication_gateway.h \ src/services/plc_communication_gateway.h \
src/services/application_settings.h \
src/infrastructure/plc_register_repository.h \ src/infrastructure/plc_register_repository.h \
src/infrastructure/plc_communication_error_classifier.h \ src/infrastructure/plc_communication_error_classifier.h \
src/infrastructure/plc_communication_service.h \ src/infrastructure/plc_communication_service.h \
src/infrastructure/plc_discovery_service.h \
src/infrastructure/application_settings_loader.h \
src/infrastructure/json_project_storage.h \ src/infrastructure/json_project_storage.h \
src/ui/hmi_editor_widget.h \ src/ui/hmi_editor_widget.h \
src/ui/logic_editor_widget.h src/ui/logic_editor_widget.h
@@ -112,3 +126,8 @@ FORMS += \
src/ui/free_monitor_widget.ui \ src/ui/free_monitor_widget.ui \
src/ui/runtime_monitor_window.ui \ src/ui/runtime_monitor_window.ui \
src/ui/runtime_monitor_widget.ui src/ui/runtime_monitor_widget.ui

# 专用用户运行版打包时由脚本传入生成的工程资源;普通编程器构建不包含工程
!isEmpty(RUNTIME_PROJECT_RESOURCE) {
RESOURCES += $$RUNTIME_PROJECT_RESOURCE
}

+ 14
- 0
app/src/domain/active_register_repository.cpp 查看文件

@@ -7,6 +7,7 @@ ActiveRegisterRepository::ActiveRegisterRepository(RegisterRepository &initial_r


void ActiveRegisterRepository::use(RegisterRepository &repository) void ActiveRegisterRepository::use(RegisterRepository &repository)
{ {
// 只替换转发目标,不复制寄存器值;切换模式必须使用各自数据源
repository_ = &repository; repository_ = &repository;
} }


@@ -31,3 +32,16 @@ RegisterWriteResult ActiveRegisterRepository::writeWord(
{ {
return repository_->writeWord(address, value); return repository_->writeWord(address, value);
} }

WordPairReadResult ActiveRegisterRepository::readWordPair(
const RegisterAddress &address) const
{
return repository_->readWordPair(address);
}

RegisterWriteResult ActiveRegisterRepository::writeWordPair(
const RegisterAddress &address,
const std::array<std::int16_t, 2> &values)
{
return repository_->writeWordPair(address, values);
}

+ 6
- 0
app/src/domain/active_register_repository.h 查看文件

@@ -2,17 +2,23 @@


#include "register_repository.h" #include "register_repository.h"


// 根据当前运行模式把寄存器访问转发给虚拟仓库或 PLC 缓存仓库
class ActiveRegisterRepository final : public RegisterRepository class ActiveRegisterRepository final : public RegisterRepository
{ {
public: public:
explicit ActiveRegisterRepository(RegisterRepository &initial_repository); explicit ActiveRegisterRepository(RegisterRepository &initial_repository);


// 切换活动仓库;运行模式服务负责保证切换时机和生命周期
void use(RegisterRepository &repository); void use(RegisterRepository &repository);
BitReadResult readBit(const RegisterAddress &address) const override; BitReadResult readBit(const RegisterAddress &address) const override;
RegisterWriteResult writeBit(const RegisterAddress &address, bool value) override; RegisterWriteResult writeBit(const RegisterAddress &address, bool value) override;
WordReadResult readWord(const RegisterAddress &address) const override; WordReadResult readWord(const RegisterAddress &address) const override;
RegisterWriteResult writeWord( RegisterWriteResult writeWord(
const RegisterAddress &address, std::int16_t value) override; const RegisterAddress &address, std::int16_t value) override;
WordPairReadResult readWordPair(const RegisterAddress &address) const override;
RegisterWriteResult writeWordPair(
const RegisterAddress &address,
const std::array<std::int16_t, 2> &values) override;


private: private:
RegisterRepository *repository_ = nullptr; RegisterRepository *repository_ = nullptr;


+ 1
- 1
app/src/domain/alarm_model.cpp 查看文件

@@ -33,7 +33,7 @@ bool AlarmDefinition::validate(std::string *error) const
} }
if (message.size() > ProjectLimits::kMaximumTextBytes) if (message.size() > ProjectLimits::kMaximumTextBytes)
{ {
setError(error, "报警文本不能超过 4096 个 UTF-8 字节");
setError(error, "报警文本不能超过 256 个 UTF-8 字节");
return false; return false;
} }
if (!address.isValid()) if (!address.isValid())


+ 11
- 8
app/src/domain/alarm_model.h 查看文件

@@ -5,20 +5,23 @@
#include <cstdint> #include <cstdint>
#include <string> #include <string>


// 报警触发条件:M 位为 1,或 D 字高于/低于阈值
enum class AlarmCondition enum class AlarmCondition
{ {
MOn,
DHigh,
DLow
MOn, // M 位为 1 时触发
DHigh, // D 寄存器值高于阈值时触发
DLow // D 寄存器值低于阈值时触发
}; };


// 可保存的报警定义;运行时 AlarmService 根据此定义生成报警记录
struct AlarmDefinition struct AlarmDefinition
{ {
std::string id;
RegisterAddress address{RegisterArea::M, 0};
AlarmCondition condition = AlarmCondition::MOn;
std::int16_t threshold = 0;
std::string message;
std::string id; // 报警定义的唯一 ID
RegisterAddress address{RegisterArea::M, 0}; // 报警监控的 M 或 D 地址
AlarmCondition condition = AlarmCondition::MOn; // 报警触发条件
std::int16_t threshold = 0; // D 地址比较时使用的阈值
std::string message; // 报警触发后显示的提示文字


// 校验标识、地址区域、消息长度和条件之间的组合是否有效
bool validate(std::string *error = nullptr) const; bool validate(std::string *error = nullptr) const;
}; };

+ 129
- 318
app/src/domain/control_logic_model.cpp 查看文件

@@ -3,7 +3,6 @@
#include "project_limits.h" #include "project_limits.h"


#include <algorithm> #include <algorithm>
#include <map>
#include <type_traits> #include <type_traits>
#include <utility> #include <utility>


@@ -52,22 +51,6 @@ bool validateWordAddress(
return true; return true;
} }


bool validateConfig(const CounterContactNodeConfig &config, std::string *error)
{
if (!config.address.isValid())
{
setError(error, "计数器触点必须使用有效的 C 地址");
return false;
}
if (config.mode != ContactMode::NormallyOpen
&& config.mode != ContactMode::NormallyClosed)
{
setError(error, "计数器触点使用了不支持的模式");
return false;
}
return true;
}

bool validateConfig(const EdgeContactNodeConfig &config, std::string *error) bool validateConfig(const EdgeContactNodeConfig &config, std::string *error)
{ {
if (!config.address.isValid() || config.address.area() != RegisterArea::M) if (!config.address.isValid() || config.address.area() != RegisterArea::M)
@@ -120,70 +103,6 @@ bool validateConfig(const CompareNodeConfig &config, std::string *error)
return true; return true;
} }


bool validateConfig(const TimerContactNodeConfig &config, std::string *error)
{
if (!config.address.isValid())
{
setError(error, "定时器触点必须使用有效的 T 地址");
return false;
}
if (config.mode != ContactMode::NormallyOpen
&& config.mode != ContactMode::NormallyClosed)
{
setError(error, "定时器触点使用了不支持的模式");
return false;
}
return true;
}

bool validateConfig(const TonNodeConfig &config, std::string *error)
{
if (!config.address.isValid())
{
setError(error, "TON 必须使用有效的 T 地址");
return false;
}
if (config.presetMs < TonNodeConfig::kMinimumPresetMs
|| config.presetMs > TonNodeConfig::kMaximumPresetMs)
{
setError(error, "TON 预设时间必须在 1~86400000 ms 范围内");
return false;
}
return true;
}

bool validateConfig(const CounterNodeConfig &config, std::string *error)
{
if (!config.address.isValid())
{
setError(error, "计数器指令必须使用有效的 C 地址");
return false;
}
if (config.mode != CounterMode::Up && config.mode != CounterMode::Down)
{
setError(error, "计数器指令使用了不支持的方向");
return false;
}
if (!validateWordAddress(config.currentValueAddress, "计数当前值", error)
|| !config.preset.validate(error))
{
return false;
}
if (config.preset.kind == WordOperandKind::Constant
&& config.preset.constant < 0)
{
setError(error, "计数器常量预设值必须在 0~32767 范围内");
return false;
}
if (!config.resetAddress.isValid()
|| config.resetAddress.area() != RegisterArea::M)
{
setError(error, "计数器复位输入必须使用有效的 M 区地址");
return false;
}
return true;
}

bool validateConfig(const MoveNodeConfig &config, std::string *error) bool validateConfig(const MoveNodeConfig &config, std::string *error)
{ {
if (!config.source.validate(error)) if (!config.source.validate(error))
@@ -245,7 +164,8 @@ void normalizeExpression(ConditionExpression *expression)
{ {
if (expression == nullptr if (expression == nullptr
|| expression->kind == ConditionExpressionKind::Node || expression->kind == ConditionExpressionKind::Node
|| expression->kind == ConditionExpressionKind::Wire)
|| expression->kind == ConditionExpressionKind::Wire
|| expression->kind == ConditionExpressionKind::Gap)
{ {
return; return;
} }
@@ -302,13 +222,13 @@ bool validateConditionExpression(
{ {
if (depth > ProjectLimits::kMaximumExpressionDepth) if (depth > ProjectLimits::kMaximumExpressionDepth)
{ {
setError(error, "条件表达式最多嵌套 20 层");
setError(error, "条件表达式最多嵌套 12 层");
return false; return false;
} }
++*node_count; ++*node_count;
if (*node_count > ProjectLimits::kMaximumExpressionNodesPerRung) if (*node_count > ProjectLimits::kMaximumExpressionNodesPerRung)
{ {
setError(error, "单个网络最多包含 4096 个条件表达式节点和叶子");
setError(error, "单个网络最多包含 1024 个条件表达式节点和叶子");
return false; return false;
} }
if (expression.id.empty()) if (expression.id.empty())
@@ -324,6 +244,7 @@ bool validateConditionExpression(
if (expression.kind == ConditionExpressionKind::Node) if (expression.kind == ConditionExpressionKind::Node)
{ {
if (!expression.node.has_value() || expression.wire.has_value() if (!expression.node.has_value() || expression.wire.has_value()
|| expression.gap.has_value()
|| !expression.children.empty() || !expression.node->isCondition()) || !expression.children.empty() || !expression.node->isCondition())
{ {
setError(error, "条件叶节点必须包含一个条件节点且不能包含子表达式"); setError(error, "条件叶节点必须包含一个条件节点且不能包含子表达式");
@@ -336,6 +257,7 @@ bool validateConditionExpression(
if (expression.kind == ConditionExpressionKind::Wire) if (expression.kind == ConditionExpressionKind::Wire)
{ {
if (expression.node.has_value() || !expression.wire.has_value() if (expression.node.has_value() || !expression.wire.has_value()
|| expression.gap.has_value()
|| !expression.children.empty()) || !expression.children.empty())
{ {
setError(error, "横线叶节点必须包含横线配置且不能包含逻辑节点或子表达式"); setError(error, "横线叶节点必须包含横线配置且不能包含逻辑节点或子表达式");
@@ -349,6 +271,22 @@ bool validateConditionExpression(
*rows = 1; *rows = 1;
return true; return true;
} }
if (expression.kind == ConditionExpressionKind::Gap)
{
if (expression.node.has_value() || expression.wire.has_value()
|| !expression.gap.has_value() || !expression.children.empty())
{
setError(error, "断路叶节点必须包含断路配置且不能包含逻辑节点或子表达式");
return false;
}
if (!expression.gap->validate(error))
{
return false;
}
*columns = expression.gap->columnSpan;
*rows = 1;
return true;
}
if (expression.kind != ConditionExpressionKind::Series if (expression.kind != ConditionExpressionKind::Series
&& expression.kind != ConditionExpressionKind::Parallel) && expression.kind != ConditionExpressionKind::Parallel)
{ {
@@ -356,6 +294,7 @@ bool validateConditionExpression(
return false; return false;
} }
if (expression.node.has_value() || expression.wire.has_value() if (expression.node.has_value() || expression.wire.has_value()
|| expression.gap.has_value()
|| expression.children.size() < 2U) || expression.children.size() < 2U)
{ {
setError(error, "串联和并联表达式至少需要两个子表达式"); setError(error, "串联和并联表达式至少需要两个子表达式");
@@ -363,12 +302,13 @@ bool validateConditionExpression(
} }
if (expression.children.size() > ProjectLimits::kMaximumExpressionChildren) if (expression.children.size() > ProjectLimits::kMaximumExpressionChildren)
{ {
setError(error, "单个串联或并联容器最多包含 256 个子表达式");
setError(error, "单个串联或并联容器最多包含 64 个子表达式");
return false; return false;
} }


int total_columns = expression.kind == ConditionExpressionKind::Series ? 0 : 1; int total_columns = expression.kind == ConditionExpressionKind::Series ? 0 : 1;
int total_rows = expression.kind == ConditionExpressionKind::Parallel ? 0 : 1; int total_rows = expression.kind == ConditionExpressionKind::Parallel ? 0 : 1;
int parallel_columns = -1;
for (const ConditionExpression &child : expression.children) for (const ConditionExpression &child : expression.children)
{ {
if (child.kind == expression.kind) if (child.kind == expression.kind)
@@ -397,10 +337,16 @@ bool validateConditionExpression(
} }
else else
{ {
if (parallel_columns >= 0 && child_columns != parallel_columns)
{
setError(error, "并联各支路必须使用显式横线或断路补齐到相同列宽");
return false;
}
parallel_columns = child_columns;
total_columns = std::max(total_columns, child_columns); total_columns = std::max(total_columns, child_columns);
if (total_rows > ProjectLimits::kMaximumLogicRows - child_rows) if (total_rows > ProjectLimits::kMaximumLogicRows - child_rows)
{ {
setError(error, "单个网络的逻辑总行数最多为 256 行");
setError(error, "单个网络的逻辑总行数最多为 64 行");
return false; return false;
} }
total_rows += child_rows; total_rows += child_rows;
@@ -409,7 +355,7 @@ bool validateConditionExpression(
if (total_columns > ProjectLimits::kMaximumConditionColumns if (total_columns > ProjectLimits::kMaximumConditionColumns
|| total_rows > ProjectLimits::kMaximumLogicRows) || total_rows > ProjectLimits::kMaximumLogicRows)
{ {
setError(error, "单个网络最多使用 10 列条件、1 列输出和 256 行逻辑网格");
setError(error, "单个网络最多使用 10 列条件、1 列输出和 64 行逻辑网格");
return false; return false;
} }
*columns = total_columns; *columns = total_columns;
@@ -417,13 +363,32 @@ bool validateConditionExpression(
return true; return true;
} }


} // namespace

TimerAddress::TimerAddress(int index)
: index_(index)
int conditionColumnCount(const ConditionExpression &expression)
{ {
if (expression.kind == ConditionExpressionKind::Node)
{
return 1;
}
if (expression.kind == ConditionExpressionKind::Wire)
{
return expression.wire->columnSpan;
}
if (expression.kind == ConditionExpressionKind::Gap)
{
return expression.gap->columnSpan;
}
int columns = expression.kind == ConditionExpressionKind::Series ? 0 : 1;
for (const ConditionExpression &child : expression.children)
{
const int child_columns = conditionColumnCount(child);
columns = expression.kind == ConditionExpressionKind::Series
? columns + child_columns : std::max(columns, child_columns);
}
return columns;
} }


} // namespace

bool WordOperand::validate(std::string *error) const bool WordOperand::validate(std::string *error) const
{ {
if (kind == WordOperandKind::Constant) if (kind == WordOperandKind::Constant)
@@ -443,61 +408,6 @@ bool WordOperand::validate(std::string *error) const
return true; return true;
} }


CounterAddress::CounterAddress(int index)
: index_(index)
{
}

int CounterAddress::index() const
{
return index_;
}

bool CounterAddress::isValid() const
{
return index_ >= kMinimumIndex && index_ <= kMaximumIndex;
}

std::string CounterAddress::toString() const
{
return isValid() ? "C" + std::to_string(index_) : "InvalidCounterAddress";
}

bool CounterAddress::operator==(const CounterAddress &other) const
{
return index_ == other.index_;
}

bool CounterAddress::operator!=(const CounterAddress &other) const
{
return !(*this == other);
}

int TimerAddress::index() const
{
return index_;
}

bool TimerAddress::isValid() const
{
return index_ >= kMinimumIndex && index_ <= kMaximumIndex;
}

std::string TimerAddress::toString() const
{
return isValid() ? "T" + std::to_string(index_) : "InvalidTimerAddress";
}

bool TimerAddress::operator==(const TimerAddress &other) const
{
return index_ == other.index_;
}

bool TimerAddress::operator!=(const TimerAddress &other) const
{
return !(*this == other);
}

std::optional<RegisterAddress> registerAddressForLogicNode( std::optional<RegisterAddress> registerAddressForLogicNode(
const LogicNodeConfig &config) const LogicNodeConfig &config)
{ {
@@ -512,10 +422,6 @@ std::optional<RegisterAddress> registerAddressForLogicNode(
{ {
return value.address; return value.address;
} }
else if constexpr (std::is_same_v<Config, CounterNodeConfig>)
{
return value.currentValueAddress;
}
else if constexpr (std::is_same_v<Config, MoveNodeConfig> else if constexpr (std::is_same_v<Config, MoveNodeConfig>
|| std::is_same_v<Config, ArithmeticNodeConfig>) || std::is_same_v<Config, ArithmeticNodeConfig>)
{ {
@@ -545,15 +451,6 @@ void collectRegisterAddressesForLogicNode(
{ {
appendValidAddress(addresses, value.address); appendValidAddress(addresses, value.address);
} }
else if constexpr (std::is_same_v<Config, CounterNodeConfig>)
{
appendValidAddress(addresses, value.currentValueAddress);
appendValidAddress(addresses, value.resetAddress);
if (value.preset.kind == WordOperandKind::Register)
{
appendValidAddress(addresses, value.preset.address);
}
}
else if constexpr (std::is_same_v<Config, MoveNodeConfig>) else if constexpr (std::is_same_v<Config, MoveNodeConfig>)
{ {
appendValidAddress(addresses, value.destination); appendValidAddress(addresses, value.destination);
@@ -574,10 +471,6 @@ void collectRegisterAddressesForLogicNode(
appendValidAddress(addresses, value.right.address); appendValidAddress(addresses, value.right.address);
} }
} }
else if constexpr (std::is_same_v<Config, CounterContactNodeConfig>)
{
return;
}
}, },
config); config);
} }
@@ -604,20 +497,18 @@ bool LogicNode::isConfigured() const
return configured; return configured;
} }


// 判断当前节点是不是条件触点类节点
bool LogicNode::isCondition() const bool LogicNode::isCondition() const
{ {
return std::holds_alternative<ContactNodeConfig>(config) return std::holds_alternative<ContactNodeConfig>(config)
|| std::holds_alternative<EdgeContactNodeConfig>(config) || std::holds_alternative<EdgeContactNodeConfig>(config)
|| std::holds_alternative<TimerContactNodeConfig>(config)
|| std::holds_alternative<CounterContactNodeConfig>(config)
|| std::holds_alternative<CompareNodeConfig>(config); || std::holds_alternative<CompareNodeConfig>(config);
} }


// 判断当前节点是不是输出执行类节点
bool LogicNode::isOutput() const bool LogicNode::isOutput() const
{ {
return std::holds_alternative<CoilNodeConfig>(config) return std::holds_alternative<CoilNodeConfig>(config)
|| std::holds_alternative<TonNodeConfig>(config)
|| std::holds_alternative<CounterNodeConfig>(config)
|| std::holds_alternative<MoveNodeConfig>(config) || std::holds_alternative<MoveNodeConfig>(config)
|| std::holds_alternative<ArithmeticNodeConfig>(config); || std::holds_alternative<ArithmeticNodeConfig>(config);
} }
@@ -641,6 +532,16 @@ bool WireSegment::validate(std::string *error) const
return true; return true;
} }


bool GapSegment::validate(std::string *error) const
{
if (columnSpan < kMinimumColumnSpan || columnSpan > kMaximumColumnSpan)
{
setError(error, "断路跨度必须在 1~10 列范围内");
return false;
}
return true;
}

ConditionExpression ConditionExpression::fromWire( ConditionExpression ConditionExpression::fromWire(
std::string expression_id, int column_span) std::string expression_id, int column_span)
{ {
@@ -651,6 +552,16 @@ ConditionExpression ConditionExpression::fromWire(
return expression; return expression;
} }


ConditionExpression ConditionExpression::fromGap(
std::string expression_id, int column_span)
{
ConditionExpression expression;
expression.id = std::move(expression_id);
expression.kind = ConditionExpressionKind::Gap;
expression.gap = GapSegment{column_span};
return expression;
}

bool ConditionExpression::validate(std::string *error) const bool ConditionExpression::validate(std::string *error) const
{ {
std::size_t node_count = 0U; std::size_t node_count = 0U;
@@ -679,6 +590,11 @@ bool ConditionExpression::validateForRunning(std::string *error) const
{ {
return true; return true;
} }
if (kind == ConditionExpressionKind::Gap)
{
setError(error, "梯形图条件区存在未连接的空白网格");
return false;
}
for (const ConditionExpression &child : children) for (const ConditionExpression &child : children)
{ {
if (!child.validateForRunning(error)) if (!child.validateForRunning(error))
@@ -691,6 +607,7 @@ bool ConditionExpression::validateForRunning(std::string *error) const


void normalizeConditionExpression(std::optional<ConditionExpression> *expression) void normalizeConditionExpression(std::optional<ConditionExpression> *expression)
{ {
// 删除节点后折叠单子项容器,并合并相邻同类容器,保持 JSON 结构稳定
if (expression == nullptr || !expression->has_value()) if (expression == nullptr || !expression->has_value())
{ {
return; return;
@@ -698,6 +615,7 @@ void normalizeConditionExpression(std::optional<ConditionExpression> *expression
normalizeExpression(&expression->value()); normalizeExpression(&expression->value());
if (expression->value().kind != ConditionExpressionKind::Node if (expression->value().kind != ConditionExpressionKind::Node
&& expression->value().kind != ConditionExpressionKind::Wire && expression->value().kind != ConditionExpressionKind::Wire
&& expression->value().kind != ConditionExpressionKind::Gap
&& expression->value().children.empty()) && expression->value().children.empty())
{ {
expression->reset(); expression->reset();
@@ -711,6 +629,10 @@ const LogicNode *findConditionNode(
{ {
return nullptr; return nullptr;
} }
if (expression.kind == ConditionExpressionKind::Gap)
{
return nullptr;
}
if (expression.kind == ConditionExpressionKind::Node) if (expression.kind == ConditionExpressionKind::Node)
{ {
return expression.node->id == node_id ? &*expression.node : nullptr; return expression.node->id == node_id ? &*expression.node : nullptr;
@@ -773,6 +695,10 @@ void collectConditionNodes(
{ {
return; return;
} }
if (expression.kind == ConditionExpressionKind::Gap)
{
return;
}
for (const ConditionExpression &child : expression.children) for (const ConditionExpression &child : expression.children)
{ {
collectConditionNodes(child, nodes); collectConditionNodes(child, nodes);
@@ -839,7 +765,7 @@ bool LadderRung::validateStructure(std::string *error) const
} }
if (name.size() > ProjectLimits::kMaximumTextBytes) if (name.size() > ProjectLimits::kMaximumTextBytes)
{ {
setError(error, "梯形图网络名称不能超过 4096 个 UTF-8 字节");
setError(error, "梯形图网络名称不能超过 256 个 UTF-8 字节");
return false; return false;
} }
if (containsLineBreak(comment)) if (containsLineBreak(comment))
@@ -866,6 +792,14 @@ bool LadderRung::validateStructure(std::string *error) const
node_ids.push_back(node->id); node_ids.push_back(node->id);
} }
} }
if (output.has_value()
&& (!condition.has_value()
|| conditionColumnCount(*condition)
!= ProjectLimits::kMaximumConditionColumns))
{
setError(error, "带输出的梯形图网络必须用条件、横线或断路显式占满前 10 列");
return false;
}
if (output.has_value()) if (output.has_value())
{ {
if (!output->validate(error) || !output->isOutput()) if (!output->validate(error) || !output->isOutput())
@@ -886,7 +820,13 @@ bool LadderRung::validateStructure(std::string *error) const


bool ControlLogic::validate(std::string *error) const bool ControlLogic::validate(std::string *error) const
{ {
if (!validateStructure(error))
return validate(defaultProjectLimitSettings(), error);
}

bool ControlLogic::validate(
const ProjectLimitSettings &limits, std::string *error) const
{
if (!validateStructure(limits, error))
{ {
return false; return false;
} }
@@ -901,6 +841,12 @@ bool ControlLogic::validate(std::string *error) const
} }


bool ControlLogic::validateStructure(std::string *error) const bool ControlLogic::validateStructure(std::string *error) const
{
return validateStructure(defaultProjectLimitSettings(), error);
}

bool ControlLogic::validateStructure(
const ProjectLimitSettings &limits, std::string *error) const
{ {
if (id.empty() || name.empty()) if (id.empty() || name.empty())
{ {
@@ -914,12 +860,16 @@ bool ControlLogic::validateStructure(std::string *error) const
} }
if (name.size() > ProjectLimits::kMaximumTextBytes) if (name.size() > ProjectLimits::kMaximumTextBytes)
{ {
setError(error, "控制逻辑名称不能超过 4096 个 UTF-8 字节");
setError(error, "控制逻辑名称不能超过 256 个 UTF-8 字节");
return false; return false;
} }
if (rungs.size() > ProjectLimits::kMaximumRungsPerLogic)
if (rungs.size() > limits.maximumRungsPerLogic)
{ {
setError(error, "单组控制逻辑最多包含 1024 个网络");
setError(
error,
"控制逻辑“" + name + "”的网络数量为 "
+ std::to_string(rungs.size()) + ",当前配置上限为 "
+ std::to_string(limits.maximumRungsPerLogic));
return false; return false;
} }
if (hasDuplicateId(rungs)) if (hasDuplicateId(rungs))
@@ -968,7 +918,13 @@ bool ControlLogic::validateStructure(std::string *error) const


bool ControlLogic::validateForRunning(std::string *error) const bool ControlLogic::validateForRunning(std::string *error) const
{ {
if (!validateStructure(error))
return validateForRunning(defaultProjectLimitSettings(), error);
}

bool ControlLogic::validateForRunning(
const ProjectLimitSettings &limits, std::string *error) const
{
if (!validateStructure(limits, error))
{ {
return false; return false;
} }
@@ -981,148 +937,3 @@ bool ControlLogic::validateForRunning(std::string *error) const
} }
return true; return true;
} }

bool validateTimerReferencesForRunning(
const std::vector<ControlLogic> &logics,
std::string *error)
{
std::map<int, std::string> timer_outputs;
for (const ControlLogic &logic : logics)
{
if (!logic.enabled)
{
continue;
}
for (const LadderRung &rung : logic.rungs)
{
if (!rung.output.has_value())
{
continue;
}
const auto *ton = std::get_if<TonNodeConfig>(&rung.output->config);
if (ton == nullptr)
{
continue;
}
const auto inserted = timer_outputs.emplace(
ton->address.index(), rung.output->id);
if (!inserted.second)
{
setError(
error,
"定时器 " + ton->address.toString()
+ " 只能由一个已启用 TON 指令驱动");
return false;
}
}
}

for (const ControlLogic &logic : logics)
{
if (!logic.enabled)
{
continue;
}
for (const LadderRung &rung : logic.rungs)
{
if (!rung.condition.has_value())
{
continue;
}
std::vector<const LogicNode *> nodes;
collectConditionNodes(*rung.condition, &nodes);
for (const LogicNode *node : nodes)
{
const auto *contact = std::get_if<TimerContactNodeConfig>(
&node->config);
if (contact != nullptr
&& timer_outputs.count(contact->address.index()) == 0U)
{
setError(
error,
"定时器触点 " + contact->address.toString()
+ " 没有对应的已启用 TON 指令");
return false;
}
}
}
}
return true;
}

bool validateCounterReferencesForRunning(
const std::vector<ControlLogic> &logics,
std::string *error)
{
std::map<int, std::string> counter_outputs;
for (const ControlLogic &logic : logics)
{
if (!logic.enabled)
{
continue;
}
for (const LadderRung &rung : logic.rungs)
{
if (!rung.output.has_value())
{
continue;
}
const auto *counter = std::get_if<CounterNodeConfig>(
&rung.output->config);
if (counter == nullptr)
{
continue;
}
const auto inserted = counter_outputs.emplace(
counter->address.index(), rung.output->id);
if (!inserted.second)
{
setError(
error,
"计数器 " + counter->address.toString()
+ " 只能由一个已启用计数指令驱动");
return false;
}
}
}

for (const ControlLogic &logic : logics)
{
if (!logic.enabled)
{
continue;
}
for (const LadderRung &rung : logic.rungs)
{
if (!rung.condition.has_value())
{
continue;
}
std::vector<const LogicNode *> nodes;
collectConditionNodes(*rung.condition, &nodes);
for (const LogicNode *node : nodes)
{
const auto *contact = std::get_if<CounterContactNodeConfig>(
&node->config);
if (contact != nullptr
&& counter_outputs.count(contact->address.index()) == 0U)
{
setError(
error,
"计数器触点 " + contact->address.toString()
+ " 没有对应的已启用计数指令");
return false;
}
}
}
}
return true;
}

bool validateLogicResourceReferencesForRunning(
const std::vector<ControlLogic> &logics,
std::string *error)
{
return validateTimerReferencesForRunning(logics, error)
&& validateCounterReferencesForRunning(logics, error);
}

+ 132
- 148
app/src/domain/control_logic_model.h 查看文件

@@ -9,214 +9,174 @@
#include <variant> #include <variant>
#include <vector> #include <vector>


// 普通触点的导通方式;常闭触点会对读取到的位值取反
enum class ContactMode enum class ContactMode
{ {
NormallyOpen,
NormallyClosed
NormallyOpen, // 常开:位值为 1 时触点导通
NormallyClosed // 常闭:位值为 0 时触点导通
}; };


// 线圈写入方式:普通写入、置位保持、复位清零
enum class CoilMode enum class CoilMode
{ {
Normal,
Set,
Reset
Normal, // 普通写入:直接写入当前逻辑结果
Set, // 置位:逻辑结果为 1 时保持为 1
Reset // 复位:逻辑结果为 1 时清零
}; };


// 上升沿/下降沿触点的边沿方向
enum class EdgeMode enum class EdgeMode
{ {
Rising,
Falling
Rising, // 上升沿:信号从 0 变成 1
Falling // 下降沿:信号从 1 变成 0
}; };


// D 寄存器字比较,等于、不等于、大于、小于等 6 种比较
enum class ComparisonOperator enum class ComparisonOperator
{ {
Equal,
NotEqual,
LessThan,
LessThanOrEqual,
GreaterThan,
GreaterThanOrEqual
Equal, // 等于
NotEqual, // 不等于
LessThan, // 小于
LessThanOrEqual, // 小于或等于
GreaterThan, // 大于
GreaterThanOrEqual // 大于或等于
}; };


// 字操作数可以来自常量,也可以来自 D 寄存器
enum class WordOperandKind enum class WordOperandKind
{ {
Constant,
Register
Constant, // 使用固定数值
Register // 使用 D 寄存器中的数值
}; };


// MOVE、ADD/SUB 共用的字操作数
struct WordOperand struct WordOperand
{ {
WordOperandKind kind = WordOperandKind::Constant;
RegisterAddress address{RegisterArea::D, 0};
std::int16_t constant = 0;
WordOperandKind kind = WordOperandKind::Constant; // 操作数来源
RegisterAddress address{RegisterArea::D, 0}; // kind 为 Register 时使用的 D 地址
std::int16_t constant = 0; // kind 为 Constant 时使用的数值


// 常量始终有效;寄存器操作数必须是有效 D 地址
bool validate(std::string *error = nullptr) const; bool validate(std::string *error = nullptr) const;
}; };


// 普通 M 寄存器触点,地址 + 常开常闭
struct ContactNodeConfig struct ContactNodeConfig
{ {
RegisterAddress address{RegisterArea::M, 0};
ContactMode mode = ContactMode::NormallyOpen;
RegisterAddress address{RegisterArea::M, 0}; // 触点读取的 M 地址
ContactMode mode = ContactMode::NormallyOpen; // 常开或常闭
}; };


// 边沿触点,地址 + 上升 / 下降沿
struct EdgeContactNodeConfig struct EdgeContactNodeConfig
{ {
// 用于检测 M 寄存器的上升沿或下降沿
RegisterAddress address{RegisterArea::M, 0}; RegisterAddress address{RegisterArea::M, 0};
EdgeMode mode = EdgeMode::Rising; EdgeMode mode = EdgeMode::Rising;
}; };


// 线圈输出配置,决定如何写入 M 寄存器
struct CoilNodeConfig struct CoilNodeConfig
{ {
// 线圈要写入的 M 寄存器地址
RegisterAddress address{RegisterArea::M, 0}; RegisterAddress address{RegisterArea::M, 0};
CoilMode mode = CoilMode::Normal; CoilMode mode = CoilMode::Normal;
}; };


// D 寄存器比较配置
struct CompareNodeConfig struct CompareNodeConfig
{ {
// 要参与比较的 D 寄存器地址
RegisterAddress address{RegisterArea::D, 0}; RegisterAddress address{RegisterArea::D, 0};
// 比较运算符
ComparisonOperator comparison = ComparisonOperator::Equal; ComparisonOperator comparison = ComparisonOperator::Equal;
// 与寄存器值比较的常量
std::int16_t value = 0; std::int16_t value = 0;
}; };


class TimerAddress
{
public:
static constexpr int kMinimumIndex = 0;
static constexpr int kMaximumIndex = 4000;

explicit TimerAddress(int index = 0);

int index() const;
bool isValid() const;
std::string toString() const;

bool operator==(const TimerAddress &other) const;
bool operator!=(const TimerAddress &other) const;

private:
int index_ = 0;
};

struct TimerContactNodeConfig
{
TimerAddress address;
ContactMode mode = ContactMode::NormallyOpen;
};

class CounterAddress
{
public:
static constexpr int kMinimumIndex = 0;
static constexpr int kMaximumIndex = 4000;

explicit CounterAddress(int index = 0);

int index() const;
bool isValid() const;
std::string toString() const;

bool operator==(const CounterAddress &other) const;
bool operator!=(const CounterAddress &other) const;

private:
int index_ = 0;
};

enum class CounterMode
{
Up,
Down
};

struct CounterContactNodeConfig
{
CounterAddress address;
ContactMode mode = ContactMode::NormallyOpen;
};

struct TonNodeConfig
{
static constexpr int kMinimumPresetMs = 1;
static constexpr int kMaximumPresetMs = 86400000;

TimerAddress address;
int presetMs = 1000;
};

struct CounterNodeConfig
{
CounterAddress address;
CounterMode mode = CounterMode::Up;
RegisterAddress currentValueAddress{RegisterArea::D, 0};
WordOperand preset;
RegisterAddress resetAddress{RegisterArea::M, 0};
};

// MOVE 输出:把一个字操作数写入目标 D 地址
struct MoveNodeConfig struct MoveNodeConfig
{ {
WordOperand source;
RegisterAddress destination{RegisterArea::D, 0};
WordOperand source; // 要写入的源操作数
RegisterAddress destination{RegisterArea::D, 0}; // 接收数据的 D 地址
}; };


enum class ArithmeticOperation enum class ArithmeticOperation
{ {
Add,
Subtract
Add, // 加法
Subtract // 减法
}; };


// ADD/SUB 输出:计算两个字操作数并写入目标 D 地址
struct ArithmeticNodeConfig struct ArithmeticNodeConfig
{ {
ArithmeticOperation operation = ArithmeticOperation::Add;
WordOperand left;
WordOperand right;
RegisterAddress destination{RegisterArea::D, 0};
ArithmeticOperation operation = ArithmeticOperation::Add; // 加法或减法
WordOperand left; // 左操作数
WordOperand right; // 右操作数
RegisterAddress destination{RegisterArea::D, 0}; // 运算结果写入的 D 地址
}; };


// 所有梯形图指令的强类型配置联合,避免用无关字段拼装指令
using LogicNodeConfig = std::variant< using LogicNodeConfig = std::variant<
ContactNodeConfig,
EdgeContactNodeConfig,
TimerContactNodeConfig,
CounterContactNodeConfig,
CoilNodeConfig,
CompareNodeConfig,
TonNodeConfig,
CounterNodeConfig,
MoveNodeConfig,
ArithmeticNodeConfig>;

ContactNodeConfig, // 普通常开或常闭触点
EdgeContactNodeConfig, // 上升沿或下降沿触点
CoilNodeConfig, // 普通、置位或复位线圈
CompareNodeConfig, // D 寄存器比较节点
MoveNodeConfig, // MOVE 数据传送输出
ArithmeticNodeConfig>; // ADD 或 SUB 算术输出

// 返回节点最主要的 M/D 地址;复合输出请使用下面的收集函数
std::optional<RegisterAddress> registerAddressForLogicNode( std::optional<RegisterAddress> registerAddressForLogicNode(
const LogicNodeConfig &config); const LogicNodeConfig &config);
// 收集节点中所有显式 M/D 引用,用于 PLC 轮询集合构建
void collectRegisterAddressesForLogicNode( void collectRegisterAddressesForLogicNode(
const LogicNodeConfig &config, const LogicNodeConfig &config,
std::vector<RegisterAddress> *addresses); std::vector<RegisterAddress> *addresses);


// 表达式中的一个指令节点;configured=false 表示编辑中的未完成草稿
struct LogicNode struct LogicNode
{ {
std::string id;
LogicNodeConfig config;
bool configured = true;
std::string id; // 节点唯一 ID
LogicNodeConfig config; // 节点的具体指令配置
bool configured = true; // 是否已完成配置


// 校验节点 ID、配置类型和配置内容
bool validate(std::string *error = nullptr) const; bool validate(std::string *error = nullptr) const;
bool isConfigured() const; bool isConfigured() const;
// 判断节点能否放在条件区或固定输出槽
bool isCondition() const; bool isCondition() const;
bool isOutput() const; bool isOutput() const;
}; };


enum class ConditionExpressionKind enum class ConditionExpressionKind
{ {
Node,
Wire,
Series,
Parallel
Node, // 一个实际的条件节点
Wire, // 一段横线
Gap, // 一段明确断开的空白网格
Series, // 多个条件串联,必须全部满足
Parallel // 多个条件并联,满足任意一条即可
}; };


// 条件区中的持久化横线;columnSpan 表示跨越的网格列数
struct WireSegment struct WireSegment
{ {
static constexpr int kMinimumColumnSpan = 1; static constexpr int kMinimumColumnSpan = 1;
static constexpr int kMaximumColumnSpan = static constexpr int kMaximumColumnSpan =
ProjectLimits::kMaximumConditionColumns; ProjectLimits::kMaximumConditionColumns;


int columnSpan = 1;
int columnSpan = 1; // 横线占用的网格列数

bool validate(std::string *error = nullptr) const;
};

// 条件区中的持久化断路;columnSpan 表示连续空白网格列数
struct GapSegment
{
static constexpr int kMinimumColumnSpan = 1;
static constexpr int kMaximumColumnSpan =
ProjectLimits::kMaximumConditionColumns;

int columnSpan = 1; // 断路占用的网格列数


bool validate(std::string *error = nullptr) const; bool validate(std::string *error = nullptr) const;
}; };
@@ -224,65 +184,89 @@ struct WireSegment
// 结构化表达式只允许合法的串并联拓扑,不保存可产生悬空线或环路的像素连接 // 结构化表达式只允许合法的串并联拓扑,不保存可产生悬空线或环路的像素连接
struct ConditionExpression struct ConditionExpression
{ {
std::string id;
ConditionExpressionKind kind = ConditionExpressionKind::Node;
std::optional<LogicNode> node;
std::optional<WireSegment> wire;
std::vector<ConditionExpression> children;

std::string id; // 表达式唯一 ID
ConditionExpressionKind kind = ConditionExpressionKind::Node; // 表达式类型
std::optional<LogicNode> node; // kind 为 Node 时保存的节点
std::optional<WireSegment> wire; // kind 为 Wire 时保存的横线
std::optional<GapSegment> gap; // kind 为 Gap 时保存的断路
std::vector<ConditionExpression> children; // 串联或并联的子表达式

// 把一个逻辑节点包装成条件表达式
static ConditionExpression fromNode(LogicNode node); static ConditionExpression fromNode(LogicNode node);
// 创建一段指定列数的横线表达式
static ConditionExpression fromWire(std::string id, int column_span = 1); static ConditionExpression fromWire(std::string id, int column_span = 1);
// 创建一段指定列数的断路表达式
static ConditionExpression fromGap(std::string id, int column_span = 1);
// 编辑态校验允许空配置节点,但必须保持树结构合法
bool validate(std::string *error = nullptr) const; bool validate(std::string *error = nullptr) const;
// 运行态校验额外要求每个节点都已经配置完成
bool validateForRunning(std::string *error = nullptr) const; bool validateForRunning(std::string *error = nullptr) const;
}; };


// 删除后折叠单子项容器并合并相邻同类容器,保持表达式的规范形态
// 删除节点后整理表达式结构
void normalizeConditionExpression(std::optional<ConditionExpression> *expression); void normalizeConditionExpression(std::optional<ConditionExpression> *expression);

// 按节点 ID 查找条件节点,只读版本
const LogicNode *findConditionNode( const LogicNode *findConditionNode(
const ConditionExpression &expression, const std::string &node_id); const ConditionExpression &expression, const std::string &node_id);

// 按节点 ID 查找条件节点,可修改版本
LogicNode *findConditionNode( LogicNode *findConditionNode(
ConditionExpression &expression, const std::string &node_id); ConditionExpression &expression, const std::string &node_id);

// 按表达式 ID 查找子表达式,只读版本
const ConditionExpression *findConditionExpression( const ConditionExpression *findConditionExpression(
const ConditionExpression &expression, const std::string &expression_id); const ConditionExpression &expression, const std::string &expression_id);

// 按表达式 ID 查找子表达式,可修改版本
ConditionExpression *findConditionExpression( ConditionExpression *findConditionExpression(
ConditionExpression &expression, const std::string &expression_id); ConditionExpression &expression, const std::string &expression_id);

// 收集表达式中的所有条件节点
void collectConditionNodes( void collectConditionNodes(
const ConditionExpression &expression, std::vector<const LogicNode *> *nodes); const ConditionExpression &expression, std::vector<const LogicNode *> *nodes);

// 收集表达式和子表达式的所有 ID
void collectConditionExpressionIds( void collectConditionExpressionIds(
const ConditionExpression &expression, std::vector<std::string> *ids); const ConditionExpression &expression, std::vector<std::string> *ids);


// 条件表达式为空时表示恒真网络,输出指令固定在最右侧
// 输出指令固定在第 11 列;有输出时前 10 列必须由显式条件、横线或断路占满
struct LadderRung struct LadderRung
{ {
std::string id;
std::string name;
std::string comment;
std::optional<ConditionExpression> condition;
std::optional<LogicNode> output;
std::string id; // 网络唯一 ID
std::string name; // 网络名称
std::string comment; // 网络注释
std::optional<ConditionExpression> condition; // 条件区表达式;空值只用于空网络草稿
std::optional<LogicNode> output; // 网络右侧的输出指令


// 编辑态允许没有条件或输出,便于逐步搭建网络
bool validate(std::string *error = nullptr) const; bool validate(std::string *error = nullptr) const;
// 只检查表达式拓扑、列数和输出位置
bool validateStructure(std::string *error = nullptr) const; bool validateStructure(std::string *error = nullptr) const;
// 运行态要求有输出且所有引用都已配置
bool validateForRunning(std::string *error = nullptr) const; bool validateForRunning(std::string *error = nullptr) const;
}; };


// 一组按顺序扫描的梯形图网络;enabled=false 时运行校验会跳过它
struct ControlLogic struct ControlLogic
{ {
std::string id;
std::string name;
std::vector<LadderRung> rungs;
bool enabled = true;
std::string id; // 控制逻辑唯一 ID
std::string name; // 控制逻辑名称
std::vector<LadderRung> rungs; // 很多条 LadderRung(梯形图网络/梯级)
bool enabled = true; // 是否参与运行扫描


// 校验可保存结构
bool validate(std::string *error = nullptr) const; bool validate(std::string *error = nullptr) const;
bool validate(
const ProjectLimitSettings &limits,
std::string *error = nullptr) const;
bool validateStructure(std::string *error = nullptr) const; bool validateStructure(std::string *error = nullptr) const;
bool validateStructure(
const ProjectLimitSettings &limits,
std::string *error = nullptr) const;
// 校验运行所需资源和网络配置
bool validateForRunning(std::string *error = nullptr) const; bool validateForRunning(std::string *error = nullptr) const;
bool validateForRunning(
const ProjectLimitSettings &limits,
std::string *error = nullptr) const;
}; };

bool validateTimerReferencesForRunning(
const std::vector<ControlLogic> &logics,
std::string *error = nullptr);
bool validateCounterReferencesForRunning(
const std::vector<ControlLogic> &logics,
std::string *error = nullptr);
bool validateLogicResourceReferencesForRunning(
const std::vector<ControlLogic> &logics,
std::string *error = nullptr);

+ 1
- 10
app/src/domain/hmi_control_registry.cpp 查看文件

@@ -69,16 +69,7 @@ constexpr std::array kControlDescriptors = {
{0, 0, 360, 136}, {0, 0, 360, 136},
HmiBindingKind::None, HmiBindingKind::None,
HmiRuntimeValueKind::None, HmiRuntimeValueKind::None,
false},
HmiControlDescriptor{HmiControlType::ProgressBar,
"progressBar",
"进度条",
"progress-bar",
"进度",
{0, 0, 240, 36},
HmiBindingKind::Word,
HmiRuntimeValueKind::Word,
true}
false}
}; };


static_assert( static_assert(


+ 15
- 15
app/src/domain/hmi_control_registry.h 查看文件

@@ -17,9 +17,9 @@
*/ */
enum class HmiBindingKind enum class HmiBindingKind
{ {
None,
Bit,
Word
None, // 不支持寄存器绑定
Bit, // 绑定 M 区位地址
Word // 绑定 D 区字地址
}; };


/** /**
@@ -27,9 +27,9 @@ enum class HmiBindingKind
*/ */
enum class HmiRuntimeValueKind enum class HmiRuntimeValueKind
{ {
None,
Bit,
Word
None, // 运行态不读取寄存器值
Bit, // 运行态读取绑定的 M 位值
Word // 运行态读取绑定的 D 字值
}; };


/** /**
@@ -37,15 +37,15 @@ enum class HmiRuntimeValueKind
*/ */
struct HmiControlDescriptor struct HmiControlDescriptor
{ {
HmiControlType type;
const char *storageName;
const char *displayName;
const char *idPrefix;
const char *defaultText;
HmiRect defaultBounds;
HmiBindingKind bindingKind;
HmiRuntimeValueKind runtimeValueKind;
bool requiresBindingForRunning;
HmiControlType type; // 控件的领域类型
const char *storageName; // 工程 JSON 中持久化的稳定类型名
const char *displayName; // 编辑器和属性面板使用的显示名称
const char *idPrefix; // 自动生成控件 ID 时使用的前缀
const char *defaultText; // 新建控件时使用的默认显示文本
HmiRect defaultBounds; // 新建控件时使用的默认位置和尺寸
HmiBindingKind bindingKind; // 编辑态允许的寄存器绑定类型
HmiRuntimeValueKind runtimeValueKind; // 运行态读取的寄存器值类型
bool requiresBindingForRunning; // 运行前是否必须完成有效寄存器绑定
}; };


/** /**


+ 41
- 43
app/src/domain/hmi_model.cpp 查看文件

@@ -89,23 +89,6 @@ bool validateAppearanceProperty(


} // namespace } // namespace


bool HmiProgressBarConfig::isValid() const
{
return minimumValue < maximumValue;
}

int HmiProgressBarConfig::percentageForValue(std::int16_t value) const
{
if (!isValid())
{
return 0;
}
const int minimum = minimumValue;
const int maximum = maximumValue;
const int bounded_value = std::clamp(static_cast<int>(value), minimum, maximum);
return (bounded_value - minimum) * 100 / (maximum - minimum);
}

bool HmiControl::validate(std::string *error) const bool HmiControl::validate(std::string *error) const
{ {
const HmiControlDescriptor *descriptor = findHmiControlDescriptor(type); const HmiControlDescriptor *descriptor = findHmiControlDescriptor(type);
@@ -126,7 +109,7 @@ bool HmiControl::validate(std::string *error) const
} }
if (text.size() > ProjectLimits::kMaximumTextBytes) if (text.size() > ProjectLimits::kMaximumTextBytes)
{ {
setError(error, "HMI 控件文本不能超过 4096 个 UTF-8 字节");
setError(error, "HMI 控件文本不能超过 256 个 UTF-8 字节");
return false; return false;
} }
if (bounds.width <= 0 || bounds.height <= 0 if (bounds.width <= 0 || bounds.height <= 0
@@ -155,7 +138,7 @@ bool HmiControl::validate(std::string *error) const
} }
if (property.second.size() > ProjectLimits::kMaximumPropertyValueBytes) if (property.second.size() > ProjectLimits::kMaximumPropertyValueBytes)
{ {
setError(error, "HMI 控件属性值不能超过 4096 个 UTF-8 字节");
setError(error, "HMI 控件属性值不能超过 1024 个 UTF-8 字节");
return false; return false;
} }
if (!validateAppearanceProperty(property.first, property.second, error)) if (!validateAppearanceProperty(property.first, property.second, error))
@@ -188,6 +171,20 @@ bool HmiControl::validate(std::string *error) const
setError(error, "HMI 控件绑定了无效地址"); setError(error, "HMI 控件绑定了无效地址");
return false; return false;
} }
const bool numeric = type == HmiControlType::NumericDisplay
|| type == HmiControlType::NumericInput;
if (!numeric && dataType != RegisterDataType::Int16)
{
setError(error, "只有 HMI 数值控件可以选择 Float32");
return false;
}
if (numeric && dataType == RegisterDataType::Float32
&& binding.has_value()
&& binding->index() >= RegisterAddress::kMaximumIndex)
{
setError(error, "Float32 起始地址必须位于 D0~D3999");
return false;
}
if (type == HmiControlType::PageJump) if (type == HmiControlType::PageJump)
{ {
if (!pageJump.has_value()) if (!pageJump.has_value())
@@ -206,19 +203,6 @@ bool HmiControl::validate(std::string *error) const
setError(error, "非页面跳转控件不能包含跳转配置"); setError(error, "非页面跳转控件不能包含跳转配置");
return false; return false;
} }
if (type == HmiControlType::ProgressBar)
{
if (!progressBar.has_value() || !progressBar->isValid())
{
setError(error, "进度条最小值必须小于最大值");
return false;
}
}
else if (progressBar.has_value())
{
setError(error, "非进度条控件不能包含进度条配置");
return false;
}
return true; return true;
} }


@@ -229,11 +213,6 @@ bool HmiControl::isConfigured() const
{ {
return false; return false;
} }
if (type == HmiControlType::ProgressBar
&& (!progressBar.has_value() || !progressBar->isValid()))
{
return false;
}
if (!descriptor->requiresBindingForRunning if (!descriptor->requiresBindingForRunning
&& type != HmiControlType::PageJump) && type != HmiControlType::PageJump)
{ {
@@ -255,6 +234,12 @@ bool HmiControl::isConfigured() const
} }


bool HmiPage::validate(std::string *error) const bool HmiPage::validate(std::string *error) const
{
return validate(defaultProjectLimitSettings(), error);
}

bool HmiPage::validate(
const ProjectLimitSettings &limits, std::string *error) const
{ {
if (id.empty() || name.empty()) if (id.empty() || name.empty())
{ {
@@ -268,19 +253,26 @@ bool HmiPage::validate(std::string *error) const
} }
if (name.size() > ProjectLimits::kMaximumTextBytes) if (name.size() > ProjectLimits::kMaximumTextBytes)
{ {
setError(error, "HMI 页面名称不能超过 4096 个 UTF-8 字节");
setError(error, "HMI 页面名称不能超过 256 个 UTF-8 字节");
return false; return false;
} }
if (width <= 0 || height <= 0
if (width < ProjectLimits::kMinimumHmiPageWidth
|| height < ProjectLimits::kMinimumHmiPageHeight
|| width > ProjectLimits::kMaximumHmiPageWidth || width > ProjectLimits::kMaximumHmiPageWidth
|| height > ProjectLimits::kMaximumHmiPageHeight) || height > ProjectLimits::kMaximumHmiPageHeight)
{ {
setError(error, "HMI 页面宽度和高度必须在 1~8192 范围内");
setError(
error,
"HMI 页面宽度必须在 320~1600、高度必须在 200~800 范围内");
return false; return false;
} }
if (controls.size() > ProjectLimits::kMaximumHmiControlsPerPage)
if (controls.size() > limits.maximumHmiControlsPerPage)
{ {
setError(error, "单个 HMI 页面最多包含 512 个控件");
setError(
error,
"HMI 页面“" + name + "”的控件数量为 "
+ std::to_string(controls.size()) + ",当前配置上限为 "
+ std::to_string(limits.maximumHmiControlsPerPage));
return false; return false;
} }


@@ -312,7 +304,13 @@ bool HmiPage::validate(std::string *error) const


bool HmiPage::validateForRunning(std::string *error) const bool HmiPage::validateForRunning(std::string *error) const
{ {
if (!validate(error))
return validateForRunning(defaultProjectLimitSettings(), error);
}

bool HmiPage::validateForRunning(
const ProjectLimitSettings &limits, std::string *error) const
{
if (!validate(limits, error))
{ {
return false; return false;
} }


+ 8
- 16
app/src/domain/hmi_model.h 查看文件

@@ -8,9 +8,9 @@
#pragma once #pragma once


#include "register_address.h" #include "register_address.h"
#include "register_value_type.h"
#include "project_limits.h" #include "project_limits.h"


#include <cstdint>
#include <map> #include <map>
#include <optional> #include <optional>
#include <string> #include <string>
@@ -56,7 +56,6 @@ enum class HmiControlType
Label, // 标签 Label, // 标签
PageJump, // 页面跳转 PageJump, // 页面跳转
AlarmList, // 报警列表 AlarmList, // 报警列表
ProgressBar, // 进度条
Count // 已注册控件类型数量,不作为实际控件使用 Count // 已注册控件类型数量,不作为实际控件使用
}; };


@@ -76,19 +75,6 @@ struct HmiPageJumpConfig
std::string targetPageId; std::string targetPageId;
}; };


/**
* @brief 进度条的有效值范围和文字显示方式
*/
struct HmiProgressBarConfig
{
std::int16_t minimumValue = 0;
std::int16_t maximumValue = 100;
bool showValue = true;

bool isValid() const;
int percentageForValue(std::int16_t value) const;
};

/** /**
* @brief 描述一个可保存的 HMI 控件及其显示和寄存器配置 * @brief 描述一个可保存的 HMI 控件及其显示和寄存器配置
* *
@@ -101,10 +87,10 @@ struct HmiControl
HmiRect bounds; HmiRect bounds;
std::string text; std::string text;
std::optional<RegisterAddress> binding; std::optional<RegisterAddress> binding;
RegisterDataType dataType = RegisterDataType::Int16;
std::map<std::string, std::string> properties; std::map<std::string, std::string> properties;
HmiButtonOperation buttonOperation = HmiButtonOperation::MomentaryOn; HmiButtonOperation buttonOperation = HmiButtonOperation::MomentaryOn;
std::optional<HmiPageJumpConfig> pageJump; std::optional<HmiPageJumpConfig> pageJump;
std::optional<HmiProgressBarConfig> progressBar;


/** /**
* @brief 校验控件的标识、尺寸、扩展属性和寄存器绑定 * @brief 校验控件的标识、尺寸、扩展属性和寄存器绑定
@@ -136,5 +122,11 @@ struct HmiPage
* @return 页面及其全部控件有效时返回 true * @return 页面及其全部控件有效时返回 true
*/ */
bool validate(std::string *error = nullptr) const; bool validate(std::string *error = nullptr) const;
bool validate(
const ProjectLimitSettings &limits,
std::string *error = nullptr) const;
bool validateForRunning(std::string *error = nullptr) const; bool validateForRunning(std::string *error = nullptr) const;
bool validateForRunning(
const ProjectLimitSettings &limits,
std::string *error = nullptr) const;
}; };

+ 94
- 59
app/src/domain/project_limits.h 查看文件

@@ -2,64 +2,99 @@


#include <cstddef> #include <cstddef>


// 所有跨层共享的数量和范围边界集中在这里,避免 UI、服务和 JSON 各自写数字
namespace ProjectLimits { namespace ProjectLimits {


constexpr std::size_t kMaximumProjectFileBytes = 16U * 1024U * 1024U;

constexpr std::size_t kMaximumHmiPages = 128U;
constexpr std::size_t kMaximumHmiControlsPerPage = 512U;
constexpr std::size_t kMaximumAlarmDefinitions = 512U;
constexpr std::size_t kMaximumRegisterComments = 8002U;
constexpr std::size_t kMaximumControlLogics = 128U;
constexpr std::size_t kMaximumRungsPerLogic = 1024U;

constexpr std::size_t kMaximumExpressionNodesPerRung = 4096U;
constexpr std::size_t kMaximumExpressionDepth = 20U;
constexpr std::size_t kMaximumExpressionChildren = 256U;
constexpr int kMaximumConditionColumns = 10;
constexpr int kMaximumLadderColumns = 11;
constexpr int kMaximumLogicRows = 256;

static_assert(kMaximumLadderColumns == kMaximumConditionColumns + 1);

constexpr std::size_t kMaximumHmiProperties = 64U;
constexpr std::size_t kMaximumIdBytes = 128U;
constexpr std::size_t kMaximumTextBytes = 4096U;
constexpr std::size_t kMaximumHmiControlTextCharacters = 12U;
constexpr std::size_t kMaximumAlarmTitleCharacters = 12U;
constexpr std::size_t kMaximumAlarmMessageCharacters = 20U;
constexpr std::size_t kMaximumPropertyKeyBytes = 128U;
constexpr std::size_t kMaximumPropertyValueBytes = 4096U;
constexpr std::size_t kMaximumRegisterCommentBytes = 64U;
constexpr std::size_t kMaximumRungCommentBytes = 128U;

constexpr int kMinimumHmiPageWidth = 320;
constexpr int kMaximumHmiPageWidth = 1600;
constexpr int kMinimumHmiPageHeight = 200;
constexpr int kMaximumHmiPageHeight = 800;
constexpr int kDefaultHmiPageWidth = 800;
constexpr int kDefaultHmiPageHeight = 400;
constexpr int kMaximumHmiControlWidth = 8192;
constexpr int kMaximumHmiControlHeight = 8192;
constexpr int kMinimumHmiFontPointSize = 6;
constexpr int kMaximumHmiFontPointSize = 72;
constexpr int kAlarmDefaultFontPointReduction = 2;
constexpr int kMaximumVisibleAlarmRows = 5;

constexpr std::size_t kMaximumPollAddresses = 1024U;
constexpr std::size_t kMaximumPollBlocks = 64U;
constexpr int kMaximumModbusReadCount = 120;

constexpr int kMinimumPlcServerAddress = 1;
constexpr int kMaximumPlcServerAddress = 247;
constexpr int kMinimumResponseTimeoutMs = 100;
constexpr int kMaximumResponseTimeoutMs = 30000;
constexpr int kMinimumRetries = 0;
constexpr int kMaximumRetries = 5;
constexpr int kMinimumPollIntervalMs = 50;
constexpr int kMaximumPollIntervalMs = 10000;

constexpr std::size_t kMaximumPendingWrites = 1U;
constexpr int kMaximumOutputMessages = 1000;

} // namespace ProjectLimits
constexpr std::size_t kMaximumProjectFileBytes = 16U * 1024U * 1024U; // 一个工程文件最大 16 MiB,防止异常大文件占满内存

constexpr std::size_t kMaximumHmiPages = 32U; // 一个工程最多放 32 个 HMI 页面
constexpr std::size_t kMaximumHmiControlsPerPage = 128U; // 一个 HMI 页面最多放 128 个控件
constexpr std::size_t kMaximumHmiControlsPerProject = 2048U; // 一个工程最多保存 2048 个 HMI 控件
constexpr std::size_t kMaximumAlarmDefinitions = 256U; // 一个工程最多配置 256 条报警
constexpr std::size_t kMaximumRegisterComments = 8002U; // M0~M4000 和 D0~D4000 最多各写一条注释
constexpr std::size_t kMaximumControlLogics = 32U; // 一个工程最多放 32 组梯形图控制逻辑
constexpr std::size_t kMaximumRungsPerLogic = 256U; // 一组控制逻辑最多放 256 个网络
constexpr std::size_t kMaximumRungsPerProject = 2048U; // 一个工程最多保存 2048 个网络

constexpr std::size_t kMaximumExpressionNodesPerRung = 1024U; // 一个网络里的触点、横线和内部结构最多共 1024 个
constexpr std::size_t kMaximumExpressionDepth = 12U; // 并联套并联时最多套 12 层,防止结构无限变复杂
constexpr std::size_t kMaximumExpressionChildren = 64U; // 一个串联组或并联组最多放 64 个子项
constexpr int kMaximumConditionColumns = 10; // 一个网络前面的条件区最多 10 列
constexpr int kMaximumLadderColumns = 11; // 一个网络总共 11 列,最后 1 列专门放输出
constexpr int kMaximumLogicRows = 64; // 一个网络上下最多 64 行并联支路

static_assert(kMaximumLadderColumns == kMaximumConditionColumns + 1); // 确保总列数始终等于条件列加输出列

constexpr std::size_t kMaximumHmiProperties = 64U; // 一个 HMI 控件最多保存 64 对扩展属性
constexpr std::size_t kMaximumIdBytes = 128U; // ID 最多 128 个 UTF-8 字节,够用来做稳定标识
constexpr std::size_t kMaximumTextBytes = 256U; // 工程名称等普通文本最多 256 个 UTF-8 字节
constexpr std::size_t kMaximumHmiControlTextCharacters = 12U; // HMI 控件显示文字最多输入 12 个字符
constexpr std::size_t kMaximumAlarmTitleCharacters = 12U; // 报警列表标题最多输入 12 个字符
constexpr std::size_t kMaximumAlarmMessageCharacters = 20U; // 一条报警内容最多输入 20 个字符
constexpr std::size_t kMaximumPropertyKeyBytes = 128U; // HMI 属性名最多 128 个 UTF-8 字节
constexpr std::size_t kMaximumPropertyValueBytes = 1024U; // HMI 属性值最多 1024 个 UTF-8 字节
constexpr std::size_t kMaximumRegisterCommentBytes = 64U; // M/D 注释最多 64 个 UTF-8 字节,保证节点下方放得下
constexpr std::size_t kMaximumRungCommentBytes = 128U; // 网络注释最多 128 个 UTF-8 字节,并且保持单行显示

constexpr int kMinimumHmiPageWidth = 320; // HMI 页面最窄 320 像素,太窄会不好编辑
constexpr int kMaximumHmiPageWidth = 1600; // HMI 页面最宽 1600 像素,避免画布无限变大
constexpr int kMinimumHmiPageHeight = 200; // HMI 页面最矮 200 像素,保证基本可用空间
constexpr int kMaximumHmiPageHeight = 800; // HMI 页面最高 800 像素,适合当前桌面编辑器
constexpr int kDefaultHmiPageWidth = 800; // 新建 HMI 页面默认宽度 800 像素
constexpr int kDefaultHmiPageHeight = 400; // 新建 HMI 页面默认高度 400 像素
constexpr int kMaximumHmiControlWidth = 8192; // 单个 HMI 控件最大宽度,防止异常尺寸撑坏画布
constexpr int kMaximumHmiControlHeight = 8192; // 单个 HMI 控件最大高度,防止异常尺寸撑坏画布
constexpr int kMinimumHmiFontPointSize = 6; // HMI 字体最小 6 磅,太小基本看不清
constexpr int kMaximumHmiFontPointSize = 72; // HMI 字体最大 72 磅,防止文字撑坏布局
constexpr int kAlarmDefaultFontPointReduction = 2; // 报警列表默认比普通文字小 2 磅,给表头留空间
constexpr int kMaximumVisibleAlarmRows = 5; // 报警列表默认最多同时显示 5 行,更多内容通过翻页查看

constexpr std::size_t kMaximumPollAddresses = 256U; // PLC 一轮轮询最多读取 256 个去重后的地址
constexpr std::size_t kMaximumPollBlocks = 8U; // PLC 一轮轮询最多拆成 8 个读块
constexpr int kMaximumModbusReadCount = 120; // 一条 Modbus 读指令最多读 120 个数据项

constexpr int kMinimumPlcServerAddress = 1; // Modbus 站号从 1 开始
constexpr int kMaximumPlcServerAddress = 247; // 站号最大 247,避开 Modbus 保留站号
constexpr int kMinimumResponseTimeoutMs = 100; // PLC 最短等待回复 100 毫秒,避免过早判定超时
constexpr int kMaximumResponseTimeoutMs = 30000; // PLC 最长等待回复 30 秒,避免故障时一直卡住
constexpr int kMinimumRetries = 0; // 最少可以不重试,收到失败就直接返回
constexpr int kMaximumRetries = 5; // PLC 通信最多自动重试 5 次
constexpr int kMinimumPollIntervalMs = 50; // PLC 最快每 50 毫秒轮询一次,避免压满串口
constexpr int kMaximumPollIntervalMs = 10000; // PLC 最慢每 10 秒轮询一次,避免画面长时间不刷新

constexpr std::size_t kMaximumPendingWrites = 1U; // 同时只允许等待 1 个 PLC 写入,避免写入顺序混乱
constexpr int kMaximumOutputMessages = 1000; // 输出日志最多保留 1000 条,超过后删除最旧的一条

}

// 用户可调的工程数量上限;默认值等于代码定义的绝对硬上限
struct ProjectLimitSettings
{
std::size_t maximumHmiPages = ProjectLimits::kMaximumHmiPages;
std::size_t maximumHmiControlsPerPage =
ProjectLimits::kMaximumHmiControlsPerPage;
std::size_t maximumAlarmDefinitions =
ProjectLimits::kMaximumAlarmDefinitions;
std::size_t maximumControlLogics = ProjectLimits::kMaximumControlLogics;
std::size_t maximumRungsPerLogic = ProjectLimits::kMaximumRungsPerLogic;
int maximumOutputMessages = ProjectLimits::kMaximumOutputMessages;
};

// 新建 HMI 页面使用的默认尺寸;页面结构安全范围仍由上方硬限制控制
struct HmiDefaultSettings
{
int pageWidth = ProjectLimits::kDefaultHmiPageWidth;
int pageHeight = ProjectLimits::kDefaultHmiPageHeight;
};

inline const ProjectLimitSettings &defaultProjectLimitSettings()
{
static const ProjectLimitSettings settings;
return settings;
}

inline const HmiDefaultSettings &defaultHmiSettings()
{
static const HmiDefaultSettings settings;
return settings;
}

+ 163
- 17
app/src/domain/project_model.cpp 查看文件

@@ -4,6 +4,8 @@


#include <algorithm> #include <algorithm>
#include <cctype> #include <cctype>
#include <variant>
#include <type_traits>


namespace { namespace {


@@ -70,6 +72,51 @@ bool containsLineBreak(const std::string &value)
|| value.find('\n') != std::string::npos; || value.find('\n') != std::string::npos;
} }


struct HmiDataBinding
{
RegisterAddress address{RegisterArea::D, 0};
RegisterDataType type = RegisterDataType::Int16;
std::string controlId;
};

bool isNumericControl(const HmiControl &control)
{
return control.type == HmiControlType::NumericDisplay
|| control.type == HmiControlType::NumericInput;
}

bool rangesIntersect(const HmiDataBinding &left, const HmiDataBinding &right)
{
const int leftEnd = left.address.index()
+ registerDataTypeWordCount(left.type) - 1;
const int rightEnd = right.address.index()
+ registerDataTypeWordCount(right.type) - 1;
return left.address.area() == right.address.area()
&& left.address.index() <= rightEnd
&& right.address.index() <= leftEnd;
}

bool isFloatWriteDestination(const LogicNodeConfig &config, RegisterAddress *address)
{
if (address == nullptr)
{
return false;
}
return std::visit(
[address](const auto &value)
{
using Config = std::decay_t<decltype(value)>;
if constexpr (std::is_same_v<Config, MoveNodeConfig>
|| std::is_same_v<Config, ArithmeticNodeConfig>)
{
*address = value.destination;
return true;
}
return false;
},
config);
}

} // namespace } // namespace


bool RegisterComment::validate(std::string *error) const bool RegisterComment::validate(std::string *error) const
@@ -111,6 +158,13 @@ const RegisterComment *Project::findRegisterComment(


bool Project::validate(std::string *error) const bool Project::validate(std::string *error) const
{ {
return validate(defaultProjectLimitSettings(), error);
}

bool Project::validate(
const ProjectLimitSettings &limits, std::string *error) const
{
// 这里是工程级总闸门:先检查数量和唯一性,再向下委托页面、报警和逻辑
if (metadata.id.empty() || metadata.name.empty() || metadata.formatVersion.empty()) if (metadata.id.empty() || metadata.name.empty() || metadata.formatVersion.empty())
{ {
setError(error, "工程 ID、名称和格式版本不能为空"); setError(error, "工程 ID、名称和格式版本不能为空");
@@ -124,7 +178,7 @@ bool Project::validate(std::string *error) const
} }
if (metadata.name.size() > ProjectLimits::kMaximumTextBytes) if (metadata.name.size() > ProjectLimits::kMaximumTextBytes)
{ {
setError(error, "工程名称不能超过 4096 个 UTF-8 字节");
setError(error, "工程名称不能超过 256 个 UTF-8 字节");
return false; return false;
} }
if (initialHmiPageId.size() > ProjectLimits::kMaximumIdBytes) if (initialHmiPageId.size() > ProjectLimits::kMaximumIdBytes)
@@ -132,14 +186,23 @@ bool Project::validate(std::string *error) const
setError(error, "初始 HMI 页面 ID 不能超过 128 个 UTF-8 字节"); setError(error, "初始 HMI 页面 ID 不能超过 128 个 UTF-8 字节");
return false; return false;
} }
if (hmiPages.size() > ProjectLimits::kMaximumHmiPages)
if (hmiPages.size() > limits.maximumHmiPages)
{ {
setError(error, "单个工程最多包含 128 个 HMI 页面");
setError(
error,
"工程 HMI 页面数量为 " + std::to_string(hmiPages.size())
+ ",当前配置上限为 "
+ std::to_string(limits.maximumHmiPages));
return false; return false;
} }
if (alarmDefinitions.size() > ProjectLimits::kMaximumAlarmDefinitions)
if (alarmDefinitions.size() > limits.maximumAlarmDefinitions)
{ {
setError(error, "单个工程最多包含 512 条报警定义");
setError(
error,
"工程报警定义数量为 "
+ std::to_string(alarmDefinitions.size())
+ ",当前配置上限为 "
+ std::to_string(limits.maximumAlarmDefinitions));
return false; return false;
} }
if (registerComments.size() > ProjectLimits::kMaximumRegisterComments) if (registerComments.size() > ProjectLimits::kMaximumRegisterComments)
@@ -147,9 +210,33 @@ bool Project::validate(std::string *error) const
setError(error, "单个工程最多包含 8002 条软元件注释"); setError(error, "单个工程最多包含 8002 条软元件注释");
return false; return false;
} }
if (controlLogics.size() > ProjectLimits::kMaximumControlLogics)
if (controlLogics.size() > limits.maximumControlLogics)
{
setError(
error,
"工程控制逻辑数量为 " + std::to_string(controlLogics.size())
+ ",当前配置上限为 "
+ std::to_string(limits.maximumControlLogics));
return false;
}
std::size_t total_hmi_controls = 0U;
for (const HmiPage &page : hmiPages)
{
total_hmi_controls += page.controls.size();
}
if (total_hmi_controls > ProjectLimits::kMaximumHmiControlsPerProject)
{
setError(error, "单个工程最多包含 2048 个 HMI 控件");
return false;
}
std::size_t total_rungs = 0U;
for (const ControlLogic &logic : controlLogics)
{ {
setError(error, "单个工程最多包含 128 组控制逻辑");
total_rungs += logic.rungs.size();
}
if (total_rungs > ProjectLimits::kMaximumRungsPerProject)
{
setError(error, "单个工程最多包含 2048 个梯形图网络");
return false; return false;
} }
if (containsDuplicateId(hmiPages)) if (containsDuplicateId(hmiPages))
@@ -225,7 +312,7 @@ bool Project::validate(std::string *error) const
for (const HmiPage &page : hmiPages) for (const HmiPage &page : hmiPages)
{ {
// 工程聚合校验会向下委托页面和控件的完整规则 // 工程聚合校验会向下委托页面和控件的完整规则
if (!page.validate(error))
if (!page.validate(limits, error))
{ {
return false; return false;
} }
@@ -250,14 +337,43 @@ bool Project::validate(std::string *error) const
} }
} }
} }
std::vector<HmiDataBinding> hmi_bindings;
for (const HmiPage &page : hmiPages)
{
for (const HmiControl &control : page.controls)
{
if (isNumericControl(control) && control.binding.has_value()
&& control.binding->area() == RegisterArea::D)
{
hmi_bindings.push_back({*control.binding, control.dataType, control.id});
}
}
}
for (auto current = hmi_bindings.cbegin(); current != hmi_bindings.cend(); ++current)
{
for (auto other = current + 1; other != hmi_bindings.cend(); ++other)
{
const bool samePoint = current->address == other->address
&& current->type == other->type;
if (rangesIntersect(*current, *other) && !samePoint)
{
setError(
error,
"HMI 数值控件 " + current->controlId + " 与 "
+ other->controlId + " 的 D 占用范围重叠或类型冲突");
return false;
}
}
}
for (const ControlLogic &logic : controlLogics) for (const ControlLogic &logic : controlLogics)
{ {
if (!logic.validate(error))
if (!logic.validate(limits, error))
{ {
return false; return false;
} }
} }


// 同时收集所有显式 M/D 引用,提前检查 PLC 轮询集合不会超过上限
std::vector<RegisterAddress> poll_addresses; std::vector<RegisterAddress> poll_addresses;
for (const HmiPage &page : hmiPages) for (const HmiPage &page : hmiPages)
{ {
@@ -266,6 +382,13 @@ bool Project::validate(std::string *error) const
if (control.binding.has_value()) if (control.binding.has_value())
{ {
poll_addresses.push_back(*control.binding); poll_addresses.push_back(*control.binding);
if (isNumericControl(control)
&& control.dataType == RegisterDataType::Float32
&& control.binding->area() == RegisterArea::D)
{
poll_addresses.push_back(RegisterAddress{
RegisterArea::D, control.binding->index() + 1});
}
} }
} }
} }
@@ -289,6 +412,26 @@ bool Project::validate(std::string *error) const
for (const LogicNode *node : nodes) for (const LogicNode *node : nodes)
{ {
collectRegisterAddressesForLogicNode(node->config, &poll_addresses); collectRegisterAddressesForLogicNode(node->config, &poll_addresses);
RegisterAddress destination{RegisterArea::D, 0};
if (node->configured
&& isFloatWriteDestination(node->config, &destination))
{
for (const HmiDataBinding &binding : hmi_bindings)
{
if (binding.type == RegisterDataType::Float32
&& binding.address.area() == RegisterArea::D
&& destination.index() >= binding.address.index()
&& destination.index()
< binding.address.index() + 2)
{
setError(
error,
"普通整数指令不能写入 Float32 占用的 D"
+ std::to_string(destination.index()));
return false;
}
}
}
} }
} }
} }
@@ -305,7 +448,7 @@ bool Project::validate(std::string *error) const
poll_addresses.end()); poll_addresses.end());
if (poll_addresses.size() > ProjectLimits::kMaximumPollAddresses) if (poll_addresses.size() > ProjectLimits::kMaximumPollAddresses)
{ {
setError(error, "工程中需要 PLC 轮询的去重 M/D 地址最多为 1024 个");
setError(error, "工程中需要 PLC 轮询的去重 M/D 地址最多为 256 个");
return false; return false;
} }
return true; return true;
@@ -313,27 +456,30 @@ bool Project::validate(std::string *error) const


bool Project::validateForRunning(std::string *error) const bool Project::validateForRunning(std::string *error) const
{ {
if (!validate(error))
return validateForRunning(defaultProjectLimitSettings(), error);
}

bool Project::validateForRunning(
const ProjectLimitSettings &limits, std::string *error) const
{
// 运行校验建立在可保存校验之上,再额外拒绝未配置的运行对象
if (!validate(limits, error))
{ {
return false; return false;
} }
for (const HmiPage &page : hmiPages) for (const HmiPage &page : hmiPages)
{ {
if (!page.validateForRunning(error))
if (!page.validateForRunning(limits, error))
{ {
return false; return false;
} }
} }
for (const ControlLogic &logic : controlLogics) for (const ControlLogic &logic : controlLogics)
{ {
if (logic.enabled && !logic.validateForRunning(error))
if (logic.enabled && !logic.validateForRunning(limits, error))
{ {
return false; return false;
} }
} }
if (!validateLogicResourceReferencesForRunning(controlLogics, error))
{
return false;
}
return true; return true;
} }

+ 13
- 2
app/src/domain/project_model.h 查看文件

@@ -3,15 +3,18 @@
#include "alarm_model.h" #include "alarm_model.h"
#include "control_logic_model.h" #include "control_logic_model.h"
#include "hmi_model.h" #include "hmi_model.h"
#include "project_limits.h"


#include <string> #include <string>
#include <vector> #include <vector>


// 工程级 M/D 注释,只描述地址含义,不保存实时值
struct RegisterComment struct RegisterComment
{ {
RegisterAddress address{RegisterArea::M, 0}; RegisterAddress address{RegisterArea::M, 0};
std::string text; std::string text;


// 校验地址有效性和 UTF-8 文本长度
bool validate(std::string *error = nullptr) const; bool validate(std::string *error = nullptr) const;
}; };


@@ -26,7 +29,7 @@ struct ProjectMetadata
std::string formatVersion = "1.0"; std::string formatVersion = "1.0";
}; };


// 聚合工程中的 HMI 页面和控制逻辑
// 聚合工程中的 HMI 页面、报警、寄存器注释和控制逻辑
struct Project struct Project
{ {
// 工程基本信息 // 工程基本信息
@@ -42,8 +45,16 @@ struct Project
// 工程包含的控制逻辑集合 // 工程包含的控制逻辑集合
std::vector<ControlLogic> controlLogics; std::vector<ControlLogic> controlLogics;


// 按地址查找工程注释,找不到时返回 nullptr
const RegisterComment *findRegisterComment(const RegisterAddress &address) const; const RegisterComment *findRegisterComment(const RegisterAddress &address) const;
// 校验工程配置并通过 error 返回失败原因
// 编辑态校验:允许尚未绑定完成的草稿
bool validate(std::string *error = nullptr) const; bool validate(std::string *error = nullptr) const;
bool validate(
const ProjectLimitSettings &limits,
std::string *error = nullptr) const;
// 运行态校验:额外要求所有运行所需对象都已配置
bool validateForRunning(std::string *error = nullptr) const; bool validateForRunning(std::string *error = nullptr) const;
bool validateForRunning(
const ProjectLimitSettings &limits,
std::string *error = nullptr) const;
}; };

+ 10
- 0
app/src/domain/register_address.h 查看文件

@@ -2,6 +2,7 @@


#include <string> #include <string>


// 解析用户输入的 M/D 地址时可能出现的失败原因
enum class RegisterAddressParseError enum class RegisterAddressParseError
{ {
None, None,
@@ -11,23 +12,30 @@ enum class RegisterAddressParseError
OutOfRange OutOfRange
}; };


// PLC 中支持的寄存器区域:M 是位,D 是 16 位有符号字
enum class RegisterArea enum class RegisterArea
{ {
M, M,
D D
}; };


// 表示一个已经拆分为“区域 + 下标”的 M/D 地址
// 下标统一使用 0~4000,和 Modbus 原始地址保持一致
class RegisterAddress class RegisterAddress
{ {
public: public:
static constexpr int kMinimumIndex = 0; static constexpr int kMinimumIndex = 0;
static constexpr int kMaximumIndex = 4000; static constexpr int kMaximumIndex = 4000;


// 构造地址对象;非法下标不会在构造时抛异常,应通过 isValid() 检查
RegisterAddress(RegisterArea area, int index); RegisterAddress(RegisterArea area, int index);


// 返回地址区域和数字下标
RegisterArea area() const; RegisterArea area() const;
int index() const; int index() const;
// 判断区域和下标是否满足项目边界
bool isValid() const; bool isValid() const;
// 转成界面和日志中使用的形式,例如 M10 或 D4000
std::string toString() const; std::string toString() const;


bool operator==(const RegisterAddress &other) const; bool operator==(const RegisterAddress &other) const;
@@ -38,6 +46,7 @@ private:
int index_; int index_;
}; };


// 文本地址解析结果;失败时 address 保持默认值,具体原因见 error
struct RegisterAddressParseResult struct RegisterAddressParseResult
{ {
bool succeeded = false; bool succeeded = false;
@@ -45,4 +54,5 @@ struct RegisterAddressParseResult
RegisterAddressParseError error = RegisterAddressParseError::InvalidFormat; RegisterAddressParseError error = RegisterAddressParseError::InvalidFormat;
}; };


// 解析 M0、D4000 这类地址文本,不接受带偏移的 Modbus 地址
RegisterAddressParseResult parseRegisterAddress(const std::string &text); RegisterAddressParseResult parseRegisterAddress(const std::string &text);

+ 52
- 9
app/src/domain/register_monitor_model.cpp 查看文件

@@ -4,37 +4,80 @@


const std::vector<RegisterAddress> &RegisterMonitorModel::addresses() const const std::vector<RegisterAddress> &RegisterMonitorModel::addresses() const
{ {
addresses_.clear();
addresses_.reserve(points_.size());
for (const MonitorPoint &point : points_)
{
addresses_.push_back(point.address);
}
return addresses_; return addresses_;
} }


bool RegisterMonitorModel::contains(const RegisterAddress &address) const
const std::vector<MonitorPoint> &RegisterMonitorModel::points() const
{ {
return std::find(addresses_.begin(), addresses_.end(), address) != addresses_.end();
return points_;
} }


bool RegisterMonitorModel::add(const RegisterAddress &address)
bool RegisterMonitorModel::contains(const MonitorPoint &point) const
{ {
if (!address.isValid() || contains(address)
|| addresses_.size() >= kMaximumItemCount)
return std::find_if(
points_.cbegin(), points_.cend(), [&point](const MonitorPoint &candidate)
{
return candidate.address == point.address
&& candidate.dataType == point.dataType;
}) != points_.cend();
}

bool RegisterMonitorModel::add(const MonitorPoint &point)
{
const int end = point.address.index()
+ registerDataTypeWordCount(point.dataType) - 1;
if (!point.address.isValid()
|| (point.dataType == RegisterDataType::Float32
&& point.address.area() != RegisterArea::D)
|| end > RegisterAddress::kMaximumIndex
|| contains(point)
|| points_.size() >= kMaximumItemCount)
{ {
return false; return false;
} }
addresses_.push_back(address);
points_.push_back(point);
return true; return true;
} }


bool RegisterMonitorModel::remove(const RegisterAddress &address) bool RegisterMonitorModel::remove(const RegisterAddress &address)
{ {
const auto position = std::find(addresses_.begin(), addresses_.end(), address);
if (position == addresses_.end())
const auto position = std::find_if(
points_.begin(), points_.end(), [&address](const MonitorPoint &point)
{
return point.address == address;
});
if (position == points_.end())
{
return false;
}
points_.erase(position);
return true;
}

bool RegisterMonitorModel::remove(const MonitorPoint &point)
{
const auto position = std::find_if(
points_.begin(), points_.end(), [&point](const MonitorPoint &candidate)
{
return candidate.address == point.address
&& candidate.dataType == point.dataType;
});
if (position == points_.end())
{ {
return false; return false;
} }
addresses_.erase(position);
points_.erase(position);
return true; return true;
} }


void RegisterMonitorModel::clear() void RegisterMonitorModel::clear()
{ {
addresses_.clear(); addresses_.clear();
points_.clear();
} }

+ 25
- 3
app/src/domain/register_monitor_model.h 查看文件

@@ -1,11 +1,13 @@
#pragma once #pragma once


#include "register_address.h" #include "register_address.h"
#include "register_value_type.h"


#include <cstddef> #include <cstddef>
#include <cstdint> #include <cstdint>
#include <vector> #include <vector>


// 监控读数的可用状态
enum class MonitorValueState enum class MonitorValueState
{ {
Valid, Valid,
@@ -13,25 +15,45 @@ enum class MonitorValueState
CommunicationFault CommunicationFault
}; };


// 一个监控行的地址、可用性和当前值
struct MonitorValue struct MonitorValue
{ {
RegisterAddress address{RegisterArea::M, 0}; RegisterAddress address{RegisterArea::M, 0};
RegisterDataType dataType = RegisterDataType::Int16;
int endAddress = 0;
MonitorValueState state = MonitorValueState::Unavailable; MonitorValueState state = MonitorValueState::Unavailable;
bool bitValue = false; bool bitValue = false;
std::int16_t wordValue = 0; std::int16_t wordValue = 0;
float floatValue = 0.0f;
}; };


struct MonitorPoint
{
RegisterAddress address{RegisterArea::M, 0};
RegisterDataType dataType = RegisterDataType::Int16;
};

// 运行期间由用户临时维护的监控地址列表,不写入工程文件
class RegisterMonitorModel class RegisterMonitorModel
{ {
public: public:
static constexpr std::size_t kMaximumItemCount = 64U; static constexpr std::size_t kMaximumItemCount = 64U;


// 返回当前按添加顺序保存的地址
const std::vector<RegisterAddress> &addresses() const; const std::vector<RegisterAddress> &addresses() const;
bool contains(const RegisterAddress &address) const;
bool add(const RegisterAddress &address);
const std::vector<MonitorPoint> &points() const;
// 判断地址是否已经在监控列表中
bool contains(const MonitorPoint &point) const;
// 添加一个地址;重复或超过上限时返回 false
bool add(const MonitorPoint &point);
// 删除一个地址;地址不存在时返回 false
bool remove(const RegisterAddress &address); bool remove(const RegisterAddress &address);
// 删除一个精确的监控点;地址相同但类型不同的点互不影响
bool remove(const MonitorPoint &point);
// 清空当前会话的监控列表
void clear(); void clear();


private: private:
std::vector<RegisterAddress> addresses_;
mutable std::vector<RegisterAddress> addresses_;
std::vector<MonitorPoint> points_;
}; };

+ 43
- 33
app/src/domain/register_repository.h 查看文件

@@ -2,11 +2,10 @@


#include "register_address.h" #include "register_address.h"


#include <array>
#include <cstddef>
#include <cstdint> #include <cstdint>
#include <array>


// 寄存器仓库操作结果
// 寄存器仓库操作失败原因;服务层会把它转换成用户可读消息
enum class RegisterError enum class RegisterError
{ {
None, // 操作成功 无错误 None, // 操作成功 无错误
@@ -39,45 +38,56 @@ struct RegisterWriteResult
RegisterError error = RegisterError::Unavailable; // 写入失败原因 RegisterError error = RegisterError::Unavailable; // 写入失败原因
}; };


struct WordPairReadResult
{
bool succeeded = false;
std::array<std::int16_t, 2> values{};
RegisterError error = RegisterError::Unavailable;
};

// M/D 寄存器访问边界 // M/D 寄存器访问边界
// 所有运行态读写都必须经过此接口,UI 不直接接触串口或缓存实现
class RegisterRepository class RegisterRepository
{ {
public: public:
virtual ~RegisterRepository() = default; virtual ~RegisterRepository() = default;


// 读取 M 区位寄存器
// 读取 M 区位寄存器;传入 D 区地址会返回 AreaMismatch
virtual BitReadResult readBit(const RegisterAddress &address) const = 0; virtual BitReadResult readBit(const RegisterAddress &address) const = 0;
// 写入 M 区位寄存器
// 写入 M 区位寄存器;真机实现不会在收到 PLC 确认前乐观修改缓存
virtual RegisterWriteResult writeBit(const RegisterAddress &address, bool value) = 0; virtual RegisterWriteResult writeBit(const RegisterAddress &address, bool value) = 0;
// 读取 D 区字寄存器
// 读取 D 区 16 位字寄存器
virtual WordReadResult readWord(const RegisterAddress &address) const = 0; virtual WordReadResult readWord(const RegisterAddress &address) const = 0;
// 写入 D 区字寄存器
// 写入 D 区 16 位字寄存器
virtual RegisterWriteResult writeWord( virtual RegisterWriteResult writeWord(
const RegisterAddress &address, std::int16_t value) = 0; const RegisterAddress &address, std::int16_t value) = 0;
};

// 基于内存的离线 M/D 寄存器仓库
class VirtualRegisterRepository : public RegisterRepository
{
public:
VirtualRegisterRepository();

// 读取内存中的 M 区位值
BitReadResult readBit(const RegisterAddress &address) const override;
// 更新内存中的 M 区位值
RegisterWriteResult writeBit(const RegisterAddress &address, bool value) override;
// 读取内存中的 D 区字值
WordReadResult readWord(const RegisterAddress &address) const override;
// 更新内存中的 D 区字值
RegisterWriteResult writeWord(
const RegisterAddress &address, std::int16_t value) override;

// 将所有虚拟寄存器恢复为默认值
void clear();

private:
static constexpr std::size_t kRegisterCount =
static_cast<std::size_t>(RegisterAddress::kMaximumIndex + 1);
std::array<bool, kRegisterCount> bits_{}; // M 区位寄存器内存
std::array<std::int16_t, kRegisterCount> words_{}; // D 区字寄存器内存
virtual WordPairReadResult readWordPair(const RegisterAddress &address) const
{
if (!address.isValid() || address.area() != RegisterArea::D
|| address.index() >= RegisterAddress::kMaximumIndex)
{
return {false, {}, address.isValid() && address.area() != RegisterArea::D
? RegisterError::AreaMismatch : RegisterError::InvalidAddress};
}
const WordReadResult low = readWord(address);
const WordReadResult high = readWord(
RegisterAddress{RegisterArea::D, address.index() + 1});
if (!low.succeeded) return {false, {}, low.error};
if (!high.succeeded) return {false, {}, high.error};
return {true, {low.value, high.value}, RegisterError::None};
}
virtual RegisterWriteResult writeWordPair(
const RegisterAddress &address,
const std::array<std::int16_t, 2> &values)
{
if (!address.isValid() || address.area() != RegisterArea::D
|| address.index() >= RegisterAddress::kMaximumIndex)
{
return {false, address.isValid() && address.area() != RegisterArea::D
? RegisterError::AreaMismatch : RegisterError::InvalidAddress};
}
const RegisterWriteResult low = writeWord(address, values[0]);
if (!low.succeeded) return low;
return writeWord(RegisterAddress{RegisterArea::D, address.index() + 1}, values[1]);
}
}; };

+ 51
- 0
app/src/domain/register_value_type.cpp 查看文件

@@ -0,0 +1,51 @@
#include "register_value_type.h"

#include <cmath>
#include <cstring>

const char *registerDataTypeName(RegisterDataType type)
{
return type == RegisterDataType::Float32 ? "float32" : "int16";
}

bool parseRegisterDataType(const std::string &text, RegisterDataType *type)
{
if (type == nullptr)
{
return false;
}
if (text == "int16")
{
*type = RegisterDataType::Int16;
return true;
}
if (text == "float32")
{
*type = RegisterDataType::Float32;
return true;
}
return false;
}

int registerDataTypeWordCount(RegisterDataType type)
{
return type == RegisterDataType::Float32 ? 2 : 1;
}

std::array<std::int16_t, 2> Float32Codec::encode(float value)
{
std::uint32_t bits = 0;
std::memcpy(&bits, &value, sizeof(bits));
return {static_cast<std::int16_t>(bits & 0xffffU),
static_cast<std::int16_t>(bits >> 16U)};
}

std::optional<float> Float32Codec::decode(
std::int16_t low_word, std::int16_t high_word)
{
const std::uint32_t bits = static_cast<std::uint16_t>(low_word)
| (static_cast<std::uint32_t>(static_cast<std::uint16_t>(high_word)) << 16U);
float value = 0.0f;
std::memcpy(&value, &bits, sizeof(value));
return std::isfinite(value) ? std::optional<float>(value) : std::nullopt;
}

+ 22
- 0
app/src/domain/register_value_type.h 查看文件

@@ -0,0 +1,22 @@
#pragma once

#include <array>
#include <cstdint>
#include <optional>
#include <string>

enum class RegisterDataType
{
Int16,
Float32
};

const char *registerDataTypeName(RegisterDataType type);
bool parseRegisterDataType(const std::string &text, RegisterDataType *type);
int registerDataTypeWordCount(RegisterDataType type);

struct Float32Codec
{
static std::array<std::int16_t, 2> encode(float value);
static std::optional<float> decode(std::int16_t low_word, std::int16_t high_word);
};

+ 8
- 7
app/src/domain/runtime_state.h 查看文件

@@ -1,6 +1,6 @@
#pragma once #pragma once


// 应用当前运行模式
// 应用当前运行模式;三个状态之间不能直接从离线切到真机
enum class ApplicationMode enum class ApplicationMode
{ {
Editing, // 编辑工程状态 Editing, // 编辑工程状态
@@ -8,7 +8,7 @@ enum class ApplicationMode
OnlineRunning // 真机联机运行状态 OnlineRunning // 真机联机运行状态
}; };


// 当前运行模式对应的能力策略
// 当前运行模式对应的能力策略,UI 用它决定哪些入口可用
struct ModePolicy struct ModePolicy
{ {
// 是否允许编辑工程 // 是否允许编辑工程
@@ -23,7 +23,7 @@ struct ModePolicy
bool requiresInitialPlcRead = false; bool requiresInitialPlcRead = false;
}; };


// 根据运行模式返回对应的能力策略
// 根据运行模式返回对应的能力策略;default 用于防御非法枚举值
constexpr ModePolicy policyForMode(ApplicationMode mode) constexpr ModePolicy policyForMode(ApplicationMode mode)
{ {
switch (mode) switch (mode)
@@ -52,12 +52,12 @@ constexpr ModePolicy policyForMode(ApplicationMode mode)
} }
case ApplicationMode::OnlineRunning: case ApplicationMode::OnlineRunning:
{ {
// 真机运行态禁止改工程,使用 PLC 数据且禁止软件逻辑重复控制 PLC
// 真机运行态使用 PLC 数据,并在本地只读推算梯形图轨迹
return { return {
false, // allowsProjectEditing 禁止编辑工程 false, // allowsProjectEditing 禁止编辑工程
false, // usesVirtualRegisters 不使用虚拟寄存器 false, // usesVirtualRegisters 不使用虚拟寄存器
true, // usesPlcRegisters 使用 PLC 寄存器 true, // usesPlcRegisters 使用 PLC 寄存器
false, // runsLogicExecutor 不运行软件逻辑执行器
true, // runsLogicExecutor 运行只读本地轨迹执行器
true // requiresInitialPlcRead 必须先完成 PLC 初次读取 true // requiresInitialPlcRead 必须先完成 PLC 初次读取
}; };
} }
@@ -68,7 +68,7 @@ constexpr ModePolicy policyForMode(ApplicationMode mode)
} }
} }


// 模式切换失败原因
// 模式切换失败原因,服务层会进一步补充可读消息
enum class ModeTransitionError enum class ModeTransitionError
{ {
None, // 切换成功 None, // 切换成功
@@ -76,7 +76,7 @@ enum class ModeTransitionError
MustReturnToEditing, // 必须先返回编辑态 MustReturnToEditing, // 必须先返回编辑态
InitialPlcReadRequired, // 进入真机态前必须完成 PLC 初次读取 InitialPlcReadRequired, // 进入真机态前必须完成 PLC 初次读取
ProjectNotReady, // 工程存在未配置或未完成的运行项 ProjectNotReady, // 工程存在未配置或未完成的运行项
SimulationStartFailed // 离线执行器预检或启动失败
SimulationStartFailed // 当前模式的本地执行器预检或启动失败
}; };


// 模式切换操作结果 // 模式切换操作结果
@@ -88,6 +88,7 @@ struct ModeTransitionResult
ModeTransitionError error = ModeTransitionError::None; ModeTransitionError error = ModeTransitionError::None;
}; };


// 保存当前模式并执行最基本的状态机约束
class RuntimeState class RuntimeState
{ {
public: public:


app/src/domain/register_repository.cpp → app/src/domain/virtual_register_repository.cpp 查看文件

@@ -1,4 +1,4 @@
#include "register_repository.h"
#include "virtual_register_repository.h"


VirtualRegisterRepository::VirtualRegisterRepository() VirtualRegisterRepository::VirtualRegisterRepository()
{ {
@@ -66,9 +66,28 @@ RegisterWriteResult VirtualRegisterRepository::writeWord(
return {true, RegisterError::None}; return {true, RegisterError::None};
} }


WordPairReadResult VirtualRegisterRepository::readWordPair(
const RegisterAddress &address) const
{
return RegisterRepository::readWordPair(address);
}

RegisterWriteResult VirtualRegisterRepository::writeWordPair(
const RegisterAddress &address,
const std::array<std::int16_t, 2> &values)
{
return RegisterRepository::writeWordPair(address, values);
}

void VirtualRegisterRepository::clear() void VirtualRegisterRepository::clear()
{ {
// 离线运行复位时同时清空 M 区和 D 区 // 离线运行复位时同时清空 M 区和 D 区
bits_.fill(false); bits_.fill(false);
words_.fill(0); words_.fill(0);
} }

void VirtualRegisterRepository::copyFrom(const VirtualRegisterRepository &source)
{
bits_ = source.bits_;
words_ = source.words_;
}

+ 38
- 0
app/src/domain/virtual_register_repository.h 查看文件

@@ -0,0 +1,38 @@
#pragma once

#include "register_repository.h"

#include <array>
#include <cstddef>

// 基于内存的离线 M/D 寄存器仓库;地址范围与真实 PLC 保持一致
class VirtualRegisterRepository : public RegisterRepository
{
public:
VirtualRegisterRepository();

// 读取内存中的 M 区位值
BitReadResult readBit(const RegisterAddress &address) const override;
// 更新内存中的 M 区位值
RegisterWriteResult writeBit(const RegisterAddress &address, bool value) override;
// 读取内存中的 D 区字值
WordReadResult readWord(const RegisterAddress &address) const override;
// 更新内存中的 D 区字值
RegisterWriteResult writeWord(
const RegisterAddress &address, std::int16_t value) override;
WordPairReadResult readWordPair(const RegisterAddress &address) const override;
RegisterWriteResult writeWordPair(
const RegisterAddress &address,
const std::array<std::int16_t, 2> &values) override;

// 将所有虚拟寄存器恢复为默认值,开始新的离线会话
void clear();
// 从另一份虚拟寄存器复制完整的 M/D 快照
void copyFrom(const VirtualRegisterRepository &source);

private:
static constexpr std::size_t kRegisterCount =
static_cast<std::size_t>(RegisterAddress::kMaximumIndex + 1);
std::array<bool, kRegisterCount> bits_{}; // M 区位寄存器内存
std::array<std::int16_t, kRegisterCount> words_{}; // D 区字寄存器内存
};

+ 509
- 0
app/src/infrastructure/application_settings_loader.cpp 查看文件

@@ -0,0 +1,509 @@
#include "application_settings_loader.h"

#include "domain/project_limits.h"

#include <QCoreApplication>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QMap>
#include <QSaveFile>
#include <QSet>
#include <QStringList>
#include <QTextCodec>

#include <initializer_list>

namespace {

constexpr int kCurrentConfigurationVersion = 1;

struct ParsedEntry
{
QString value;
int line = 0;
};

std::string toUtf8(const QString &value)
{
const QByteArray bytes = value.toUtf8();
return std::string(bytes.constData(), static_cast<std::size_t>(bytes.size()));
}

QString defaultFileContents()
{
return QStringLiteral(
"[Config]\n"
"Version=1\n"
"\n"
"[ProjectLimits]\n"
"MaxHmiPages=32\n"
"MaxHmiControlsPerPage=128\n"
"MaxAlarmDefinitions=256\n"
"MaxControlLogics=32\n"
"MaxRungsPerLogic=256\n"
"MaxOutputMessages=1000\n"
"\n"
"[HmiDefaults]\n"
"DefaultPageWidth=800\n"
"DefaultPageHeight=400\n"
"\n"
"[PlcDefaults]\n"
"PortName=COM3\n"
"ServerAddress=1\n"
"BaudRate=9600\n"
"DataBits=8\n"
"Parity=2\n"
"StopBits=1\n");
}

ApplicationSettingsLoadResult failureResult(
const QString &file_path, const QString &reason)
{
ApplicationSettingsLoadResult result;
result.warningRequired = true;
result.warningMessage = toUtf8(
QStringLiteral("应用配置无法使用,本次已全部采用代码默认值。\n%1")
.arg(reason));
result.messages.push_back(toUtf8(
QStringLiteral("应用配置错误:%1(%2)")
.arg(reason, QDir::toNativeSeparators(file_path))));
result.messages.push_back(
"应用配置:本次运行全部使用代码默认值");
return result;
}

bool containsValue(std::initializer_list<int> values, int value)
{
for (int candidate : values)
{
if (candidate == value)
{
return true;
}
}
return false;
}

bool readInteger(
const QMap<QString, ParsedEntry> &entries,
const QString &path,
qint64 minimum,
qint64 maximum,
qint64 default_value,
qint64 *value,
QStringList *notices,
QStringList *errors,
bool required = false)
{
const auto entry = entries.constFind(path);
if (entry == entries.cend())
{
if (required)
{
errors->append(QStringLiteral("缺少必填字段 %1").arg(path));
}
else
{
notices->append(
QStringLiteral("配置字段 %1 缺失,使用代码默认值 %2")
.arg(path)
.arg(default_value));
}
*value = default_value;
return !required;
}

bool converted = false;
const qint64 parsed = entry->value.toLongLong(&converted, 10);
if (!converted || parsed < minimum || parsed > maximum)
{
errors->append(
QStringLiteral("第 %1 行字段 %2 的值“%3”无效,允许范围为 %4~%5")
.arg(entry->line)
.arg(path, entry->value)
.arg(minimum)
.arg(maximum));
return false;
}
*value = parsed;
return true;
}

void addUnsupportedValueError(
const QMap<QString, ParsedEntry> &entries,
const QString &path,
const QString &allowed,
QStringList *errors)
{
const ParsedEntry &entry = entries[path];
errors->append(
QStringLiteral("第 %1 行字段 %2 的值“%3”无效,只允许 %4")
.arg(entry.line)
.arg(path, entry.value, allowed));
}

} // namespace

QString ApplicationSettingsLoader::defaultFilePath()
{
return QDir(QCoreApplication::applicationDirPath()).filePath(
QStringLiteral("config/application.ini"));
}

ApplicationSettingsLoadResult ApplicationSettingsLoader::load(
const QString &file_path)
{
if (!QFileInfo::exists(file_path))
{
const QString directory = QFileInfo(file_path).absolutePath();
if (!QDir().mkpath(directory))
{
return failureResult(
file_path,
QStringLiteral("无法创建配置目录 %1,可能没有写入权限")
.arg(QDir::toNativeSeparators(directory)));
}

QSaveFile file(file_path);
if (!file.open(QIODevice::WriteOnly))
{
return failureResult(file_path, file.errorString());
}
const QByteArray data = defaultFileContents().toUtf8();
if (file.write(data) != data.size())
{
const QString reason = file.errorString();
file.cancelWriting();
return failureResult(file_path, reason);
}
if (!file.commit())
{
return failureResult(file_path, file.errorString());
}

ApplicationSettingsLoadResult result;
result.messages.push_back(toUtf8(
QStringLiteral("应用配置:已创建默认配置文件 %1")
.arg(QDir::toNativeSeparators(file_path))));
result.messages.push_back(
"应用配置:首次运行使用代码默认值,修改后请重启程序");
return result;
}

QFile file(file_path);
if (!file.open(QIODevice::ReadOnly))
{
return failureResult(file_path, file.errorString());
}
const QByteArray data = file.readAll();
if (file.error() != QFileDevice::NoError)
{
return failureResult(file_path, file.errorString());
}

QTextCodec::ConverterState converter_state;
const QString text = QTextCodec::codecForName("UTF-8")->toUnicode(
data.constData(), data.size(), &converter_state);
if (converter_state.invalidChars > 0)
{
return failureResult(file_path, QStringLiteral("配置文件不是有效的 UTF-8 文本"));
}

const QSet<QString> known_fields{
QStringLiteral("Config.Version"),
QStringLiteral("ProjectLimits.MaxHmiPages"),
QStringLiteral("ProjectLimits.MaxHmiControlsPerPage"),
QStringLiteral("ProjectLimits.MaxAlarmDefinitions"),
QStringLiteral("ProjectLimits.MaxControlLogics"),
QStringLiteral("ProjectLimits.MaxRungsPerLogic"),
QStringLiteral("ProjectLimits.MaxOutputMessages"),
QStringLiteral("HmiDefaults.DefaultPageWidth"),
QStringLiteral("HmiDefaults.DefaultPageHeight"),
QStringLiteral("PlcDefaults.PortName"),
QStringLiteral("PlcDefaults.ServerAddress"),
QStringLiteral("PlcDefaults.BaudRate"),
QStringLiteral("PlcDefaults.DataBits"),
QStringLiteral("PlcDefaults.Parity"),
QStringLiteral("PlcDefaults.StopBits")};

QMap<QString, ParsedEntry> entries;
QStringList notices;
QStringList errors;
QString current_section;
QString normalized_text = text;
if (!normalized_text.isEmpty()
&& normalized_text.front() == QChar::ByteOrderMark)
{
normalized_text.remove(0, 1);
}
const QStringList lines = normalized_text.split(QLatin1Char('\n'));
for (int index = 0; index < lines.size(); ++index)
{
QString line = lines.at(index);
if (line.endsWith(QLatin1Char('\r')))
{
line.chop(1);
}
const QString trimmed = line.trimmed();
const int line_number = index + 1;
if (trimmed.isEmpty() || trimmed.startsWith(QLatin1Char(';'))
|| trimmed.startsWith(QLatin1Char('#')))
{
continue;
}
if (trimmed.startsWith(QLatin1Char('[')))
{
if (!trimmed.endsWith(QLatin1Char(']')) || trimmed.size() < 3)
{
errors.append(
QStringLiteral("第 %1 行分区格式无效:%2")
.arg(line_number)
.arg(trimmed));
current_section.clear();
continue;
}
current_section = trimmed.mid(1, trimmed.size() - 2).trimmed();
if (current_section.isEmpty())
{
errors.append(QStringLiteral("第 %1 行分区名称不能为空").arg(line_number));
}
continue;
}

const int separator = trimmed.indexOf(QLatin1Char('='));
if (separator <= 0 || current_section.isEmpty())
{
errors.append(
QStringLiteral("第 %1 行字段格式无效:%2")
.arg(line_number)
.arg(trimmed));
continue;
}
const QString key = trimmed.left(separator).trimmed();
const QString value = trimmed.mid(separator + 1).trimmed();
if (key.isEmpty())
{
errors.append(QStringLiteral("第 %1 行字段名称不能为空").arg(line_number));
continue;
}
const QString path = current_section + QLatin1Char('.') + key;
if (entries.contains(path))
{
errors.append(
QStringLiteral("第 %1 行字段 %2 重复,首次出现在第 %3 行")
.arg(line_number)
.arg(path)
.arg(entries.value(path).line));
continue;
}
entries.insert(path, {value, line_number});
if (!known_fields.contains(path))
{
notices.append(
QStringLiteral("第 %1 行未知字段 %2 已忽略")
.arg(line_number)
.arg(path));
}
}

ApplicationSettings candidate;
qint64 parsed = 0;
if (readInteger(
entries, QStringLiteral("Config.Version"),
kCurrentConfigurationVersion, kCurrentConfigurationVersion,
kCurrentConfigurationVersion, &parsed, &notices, &errors, true))
{
candidate.version = static_cast<int>(parsed);
}

if (readInteger(
entries, QStringLiteral("ProjectLimits.MaxHmiPages"),
1, ProjectLimits::kMaximumHmiPages,
ProjectLimits::kMaximumHmiPages, &parsed, &notices, &errors))
{
candidate.projectLimits.maximumHmiPages = static_cast<std::size_t>(parsed);
}
if (readInteger(
entries, QStringLiteral("ProjectLimits.MaxHmiControlsPerPage"),
1, ProjectLimits::kMaximumHmiControlsPerPage,
ProjectLimits::kMaximumHmiControlsPerPage,
&parsed, &notices, &errors))
{
candidate.projectLimits.maximumHmiControlsPerPage =
static_cast<std::size_t>(parsed);
}
if (readInteger(
entries, QStringLiteral("ProjectLimits.MaxAlarmDefinitions"),
0, ProjectLimits::kMaximumAlarmDefinitions,
ProjectLimits::kMaximumAlarmDefinitions,
&parsed, &notices, &errors))
{
candidate.projectLimits.maximumAlarmDefinitions =
static_cast<std::size_t>(parsed);
}
if (readInteger(
entries, QStringLiteral("ProjectLimits.MaxControlLogics"),
1, ProjectLimits::kMaximumControlLogics,
ProjectLimits::kMaximumControlLogics,
&parsed, &notices, &errors))
{
candidate.projectLimits.maximumControlLogics =
static_cast<std::size_t>(parsed);
}
if (readInteger(
entries, QStringLiteral("ProjectLimits.MaxRungsPerLogic"),
0, ProjectLimits::kMaximumRungsPerLogic,
ProjectLimits::kMaximumRungsPerLogic,
&parsed, &notices, &errors))
{
candidate.projectLimits.maximumRungsPerLogic =
static_cast<std::size_t>(parsed);
}
if (readInteger(
entries, QStringLiteral("ProjectLimits.MaxOutputMessages"),
1, ProjectLimits::kMaximumOutputMessages,
ProjectLimits::kMaximumOutputMessages,
&parsed, &notices, &errors))
{
candidate.projectLimits.maximumOutputMessages = static_cast<int>(parsed);
}

if (readInteger(
entries, QStringLiteral("HmiDefaults.DefaultPageWidth"),
ProjectLimits::kMinimumHmiPageWidth,
ProjectLimits::kMaximumHmiPageWidth,
ProjectLimits::kDefaultHmiPageWidth,
&parsed, &notices, &errors))
{
candidate.hmiDefaults.pageWidth = static_cast<int>(parsed);
}
if (readInteger(
entries, QStringLiteral("HmiDefaults.DefaultPageHeight"),
ProjectLimits::kMinimumHmiPageHeight,
ProjectLimits::kMaximumHmiPageHeight,
ProjectLimits::kDefaultHmiPageHeight,
&parsed, &notices, &errors))
{
candidate.hmiDefaults.pageHeight = static_cast<int>(parsed);
}

const QString port_path = QStringLiteral("PlcDefaults.PortName");
const auto port_entry = entries.constFind(port_path);
if (port_entry == entries.cend())
{
notices.append(
QStringLiteral("配置字段 %1 缺失,使用代码默认值 COM3")
.arg(port_path));
}
else
{
const QByteArray port_bytes = port_entry->value.toUtf8();
if (port_entry->value.trimmed().isEmpty()
|| port_bytes.size() > static_cast<int>(ProjectLimits::kMaximumIdBytes))
{
errors.append(
QStringLiteral("第 %1 行字段 %2 的值“%3”无效,端口名称不能为空且不能超过 128 个 UTF-8 字节")
.arg(port_entry->line)
.arg(port_path, port_entry->value));
}
else
{
candidate.plcDefaults.portName = toUtf8(port_entry->value.trimmed());
}
}

if (readInteger(
entries, QStringLiteral("PlcDefaults.ServerAddress"),
ProjectLimits::kMinimumPlcServerAddress,
ProjectLimits::kMaximumPlcServerAddress,
candidate.plcDefaults.serverAddress,
&parsed, &notices, &errors))
{
candidate.plcDefaults.serverAddress = static_cast<int>(parsed);
}

int baud_rate = candidate.plcDefaults.baudRate;
if (readInteger(
entries, QStringLiteral("PlcDefaults.BaudRate"),
9600, 115200, candidate.plcDefaults.baudRate,
&parsed, &notices, &errors))
{
baud_rate = static_cast<int>(parsed);
if (!containsValue({9600, 19200, 38400, 57600, 115200}, baud_rate))
{
addUnsupportedValueError(
entries, QStringLiteral("PlcDefaults.BaudRate"),
QStringLiteral("9600、19200、38400、57600 或 115200"), &errors);
}
else
{
candidate.plcDefaults.baudRate = baud_rate;
}
}

if (readInteger(
entries, QStringLiteral("PlcDefaults.DataBits"),
7, 8, candidate.plcDefaults.dataBits,
&parsed, &notices, &errors))
{
candidate.plcDefaults.dataBits = static_cast<int>(parsed);
}

int parity = candidate.plcDefaults.parity;
if (readInteger(
entries, QStringLiteral("PlcDefaults.Parity"),
0, 3, candidate.plcDefaults.parity,
&parsed, &notices, &errors))
{
parity = static_cast<int>(parsed);
if (!containsValue({0, 2, 3}, parity))
{
addUnsupportedValueError(
entries, QStringLiteral("PlcDefaults.Parity"),
QStringLiteral("0、2 或 3"), &errors);
}
else
{
candidate.plcDefaults.parity = parity;
}
}

if (readInteger(
entries, QStringLiteral("PlcDefaults.StopBits"),
1, 2, candidate.plcDefaults.stopBits,
&parsed, &notices, &errors))
{
candidate.plcDefaults.stopBits = static_cast<int>(parsed);
}

if (!errors.isEmpty())
{
ApplicationSettingsLoadResult result;
result.warningRequired = true;
result.warningMessage =
"应用配置存在严重错误,本次已全部采用代码默认值。详细原因请查看底部输出面板。";
for (const QString &error : errors)
{
result.messages.push_back(toUtf8(QStringLiteral("应用配置错误:") + error));
}
for (const QString &notice : notices)
{
result.messages.push_back(toUtf8(QStringLiteral("应用配置提示:") + notice));
}
result.messages.push_back(
"应用配置:整份配置不生效,本次运行全部使用代码默认值");
return result;
}

ApplicationSettingsLoadResult result;
result.settings = candidate;
result.messages.push_back(toUtf8(
QStringLiteral("应用配置:已加载 %1,修改后需重启程序")
.arg(QDir::toNativeSeparators(file_path))));
for (const QString &notice : notices)
{
result.messages.push_back(toUtf8(QStringLiteral("应用配置提示:") + notice));
}
return result;
}

+ 13
- 0
app/src/infrastructure/application_settings_loader.h 查看文件

@@ -0,0 +1,13 @@
#pragma once

#include "services/application_settings.h"

#include <QString>

// 从程序目录下的严格 UTF-8 INI 文件加载一次应用配置
class ApplicationSettingsLoader final
{
public:
static QString defaultFilePath();
static ApplicationSettingsLoadResult load(const QString &file_path);
};

+ 81
- 271
app/src/infrastructure/json_project_storage.cpp 查看文件

@@ -212,7 +212,9 @@ bool readArray(
{ {
return state->fail( return state->fail(
ProjectStorageError::InvalidField, ProjectStorageError::InvalidField,
fieldPath(context, field) + " 的元素数量超过上限");
fieldPath(context, field) + " 的元素数量为 "
+ std::to_string(value->size()) + ",当前上限为 "
+ std::to_string(maximum_count));
} }
return true; return true;
} }
@@ -270,64 +272,6 @@ bool parseAddress(
return true; return true;
} }


QJsonObject serializeTimerAddress(const TimerAddress &address)
{
QJsonObject object;
object.insert(QStringLiteral("index"), address.index());
return object;
}

bool parseTimerAddress(
const QJsonObject &object,
const std::string &context,
TimerAddress *address,
ParseState *state)
{
int index = 0;
if (!readInt(
object,
"index",
context,
TimerAddress::kMinimumIndex,
TimerAddress::kMaximumIndex,
&index,
state))
{
return false;
}
*address = TimerAddress{index};
return true;
}

QJsonObject serializeCounterAddress(const CounterAddress &address)
{
QJsonObject object;
object.insert(QStringLiteral("index"), address.index());
return object;
}

bool parseCounterAddress(
const QJsonObject &object,
const std::string &context,
CounterAddress *address,
ParseState *state)
{
int index = 0;
if (!readInt(
object,
"index",
context,
CounterAddress::kMinimumIndex,
CounterAddress::kMaximumIndex,
&index,
state))
{
return false;
}
*address = CounterAddress{index};
return true;
}

QString wordOperandKindName(WordOperandKind kind) QString wordOperandKindName(WordOperandKind kind)
{ {
return kind == WordOperandKind::Constant return kind == WordOperandKind::Constant
@@ -689,7 +633,7 @@ bool parseProperties(
{ {
return state->fail( return state->fail(
ProjectStorageError::InvalidField, ProjectStorageError::InvalidField,
"HMI 控件属性名称最多 128 字节,属性值最多 4096 字节");
"HMI 控件属性名称最多 128 字节,属性值最多 1024 字节");
} }
properties->emplace(key, value); properties->emplace(key, value);
} }
@@ -714,6 +658,13 @@ QJsonObject serializeHmiControl(const HmiControl &control)
object.insert(QStringLiteral("binding"), QJsonValue::Null); object.insert(QStringLiteral("binding"), QJsonValue::Null);
} }
object.insert(QStringLiteral("properties"), serializeProperties(control.properties)); object.insert(QStringLiteral("properties"), serializeProperties(control.properties));
if (control.type == HmiControlType::NumericDisplay
|| control.type == HmiControlType::NumericInput)
{
object.insert(
QStringLiteral("dataType"),
QString::fromLatin1(registerDataTypeName(control.dataType)));
}
if (control.type == HmiControlType::Button) if (control.type == HmiControlType::Button)
{ {
object.insert( object.insert(
@@ -728,14 +679,6 @@ QJsonObject serializeHmiControl(const HmiControl &control)
? control.pageJump->targetPageId ? control.pageJump->targetPageId
: std::string{})); : std::string{}));
} }
if (control.type == HmiControlType::ProgressBar)
{
const HmiProgressBarConfig config = control.progressBar.value_or(
HmiProgressBarConfig{});
object.insert(QStringLiteral("minimumValue"), config.minimumValue);
object.insert(QStringLiteral("maximumValue"), config.maximumValue);
object.insert(QStringLiteral("showValue"), config.showValue);
}
return object; return object;
} }


@@ -767,6 +710,18 @@ bool parseHmiControl(
{ {
return false; return false;
} }
if (control->type == HmiControlType::NumericDisplay
|| control->type == HmiControlType::NumericInput)
{
std::string data_type_text;
if (!readString(object, "dataType", context, &data_type_text, state)
|| !parseRegisterDataType(data_type_text, &control->dataType))
{
return state->fail(
ProjectStorageError::InvalidField,
context + ".dataType 必须是 int16 或 float32");
}
}
if (control->type == HmiControlType::Button) if (control->type == HmiControlType::Button)
{ {
std::string operation_text; std::string operation_text;
@@ -793,37 +748,6 @@ bool parseHmiControl(
} }
control->pageJump = HmiPageJumpConfig{std::move(target_page_id)}; control->pageJump = HmiPageJumpConfig{std::move(target_page_id)};
} }
if (control->type == HmiControlType::ProgressBar)
{
int minimum_value = 0;
int maximum_value = 0;
bool show_value = true;
if (!readInt(
object,
"minimumValue",
context,
std::numeric_limits<std::int16_t>::min(),
std::numeric_limits<std::int16_t>::max(),
&minimum_value,
state)
|| !readInt(
object,
"maximumValue",
context,
std::numeric_limits<std::int16_t>::min(),
std::numeric_limits<std::int16_t>::max(),
&maximum_value,
state)
|| !readBool(object, "showValue", context, &show_value, state))
{
return false;
}
control->progressBar = HmiProgressBarConfig{
static_cast<std::int16_t>(minimum_value),
static_cast<std::int16_t>(maximum_value),
show_value};
}

// binding 允许为 null,其余非空值必须是合法的寄存器地址对象 // binding 允许为 null,其余非空值必须是合法的寄存器地址对象
if (binding.isNull()) if (binding.isNull())
{ {
@@ -868,6 +792,7 @@ QJsonObject serializeHmiPage(const HmiPage &page)
bool parseHmiPage( bool parseHmiPage(
const QJsonObject &object, const QJsonObject &object,
const std::string &context, const std::string &context,
const ProjectLimitSettings &limits,
HmiPage *page, HmiPage *page,
ParseState *state) ParseState *state)
{ {
@@ -886,7 +811,7 @@ bool parseHmiPage(
&page->height, state) &page->height, state)
|| !readArray( || !readArray(
object, "controls", context, &controls, state, object, "controls", context, &controls, state,
static_cast<int>(ProjectLimits::kMaximumHmiControlsPerPage)))
static_cast<int>(limits.maximumHmiControlsPerPage)))
{ {
return false; return false;
} }
@@ -1113,24 +1038,6 @@ QJsonObject serializeNodeConfig(const EdgeContactNodeConfig &config)
return object; return object;
} }


QJsonObject serializeNodeConfig(const TimerContactNodeConfig &config)
{
QJsonObject object;
object.insert(QStringLiteral("type"), QStringLiteral("timerContact"));
object.insert(QStringLiteral("timer"), serializeTimerAddress(config.address));
object.insert(QStringLiteral("mode"), contactModeName(config.mode));
return object;
}

QJsonObject serializeNodeConfig(const CounterContactNodeConfig &config)
{
QJsonObject object;
object.insert(QStringLiteral("type"), QStringLiteral("counterContact"));
object.insert(QStringLiteral("counter"), serializeCounterAddress(config.address));
object.insert(QStringLiteral("mode"), contactModeName(config.mode));
return object;
}

// 将线圈节点配置序列化,并写入用于反序列化分派的类型标记 // 将线圈节点配置序列化,并写入用于反序列化分派的类型标记
QJsonObject serializeNodeConfig(const CoilNodeConfig &config) QJsonObject serializeNodeConfig(const CoilNodeConfig &config)
{ {
@@ -1152,35 +1059,6 @@ QJsonObject serializeNodeConfig(const CompareNodeConfig &config)
return object; return object;
} }


QJsonObject serializeNodeConfig(const TonNodeConfig &config)
{
QJsonObject object;
object.insert(QStringLiteral("type"), QStringLiteral("ton"));
object.insert(QStringLiteral("timer"), serializeTimerAddress(config.address));
object.insert(QStringLiteral("presetMs"), config.presetMs);
return object;
}

QString counterModeName(CounterMode mode)
{
return mode == CounterMode::Up
? QStringLiteral("up") : QStringLiteral("down");
}

QJsonObject serializeNodeConfig(const CounterNodeConfig &config)
{
QJsonObject object;
object.insert(QStringLiteral("type"), QStringLiteral("counter"));
object.insert(QStringLiteral("counter"), serializeCounterAddress(config.address));
object.insert(QStringLiteral("mode"), counterModeName(config.mode));
object.insert(
QStringLiteral("currentValueAddress"),
serializeAddress(config.currentValueAddress));
object.insert(QStringLiteral("preset"), serializeWordOperand(config.preset));
object.insert(QStringLiteral("resetAddress"), serializeAddress(config.resetAddress));
return object;
}

QJsonObject serializeNodeConfig(const MoveNodeConfig &config) QJsonObject serializeNodeConfig(const MoveNodeConfig &config)
{ {
QJsonObject object; QJsonObject object;
@@ -1238,113 +1116,6 @@ bool parseNodeConfig(
return false; return false;
} }


if (type == "timerContact" || type == "ton"
|| type == "counterContact" || type == "counter")
{
const char *resource_field =
type == "timerContact" || type == "ton" ? "timer" : "counter";
QJsonObject resource_object;
if (!readObject(object, resource_field, context, &resource_object, state))
{
return false;
}
if (type == "counterContact" || type == "counter")
{
CounterAddress address;
if (!parseCounterAddress(
resource_object, context + ".counter", &address, state))
{
return false;
}
if (type == "counterContact")
{
std::string mode_text;
ContactMode mode = ContactMode::NormallyOpen;
if (!readString(object, "mode", context, &mode_text, state)
|| !parseContactMode(mode_text, &mode, state))
{
return false;
}
*config = CounterContactNodeConfig{address, mode};
return true;
}
std::string mode_text;
CounterMode mode = CounterMode::Up;
if (!readString(object, "mode", context, &mode_text, state))
{
return false;
}
if (mode_text == "up")
{
mode = CounterMode::Up;
}
else if (mode_text == "down")
{
mode = CounterMode::Down;
}
else
{
return state->fail(
ProjectStorageError::InvalidField,
"不支持的计数器方向:" + mode_text);
}
QJsonObject current_object;
QJsonObject preset_object;
QJsonObject reset_object;
RegisterAddress current{RegisterArea::D, 0};
RegisterAddress reset{RegisterArea::M, 0};
WordOperand preset;
if (!readObject(
object, "currentValueAddress", context, &current_object, state)
|| !parseAddress(
current_object,
context + ".currentValueAddress",
&current,
state)
|| !readObject(object, "preset", context, &preset_object, state)
|| !parseWordOperand(preset_object, context + ".preset", &preset, state)
|| !readObject(object, "resetAddress", context, &reset_object, state)
|| !parseAddress(
reset_object, context + ".resetAddress", &reset, state))
{
return false;
}
*config = CounterNodeConfig{address, mode, current, preset, reset};
return true;
}
TimerAddress address;
if (!parseTimerAddress(resource_object, context + ".timer", &address, state))
{
return false;
}
if (type == "timerContact")
{
std::string mode_text;
ContactMode mode = ContactMode::NormallyOpen;
if (!readString(object, "mode", context, &mode_text, state)
|| !parseContactMode(mode_text, &mode, state))
{
return false;
}
*config = TimerContactNodeConfig{address, mode};
return true;
}
int preset_ms = 0;
if (!readInt(
object,
"presetMs",
context,
TonNodeConfig::kMinimumPresetMs,
TonNodeConfig::kMaximumPresetMs,
&preset_ms,
state))
{
return false;
}
*config = TonNodeConfig{address, preset_ms};
return true;
}

if (type == "move") if (type == "move")
{ {
QJsonObject source_object; QJsonObject source_object;
@@ -1410,6 +1181,14 @@ bool parseNodeConfig(
return true; return true;
} }


if (type != "contact" && type != "edgeContact"
&& type != "coil" && type != "compare")
{
return state->fail(
ProjectStorageError::InvalidField,
"不支持的逻辑节点类型:" + type);
}

QJsonObject address_object; QJsonObject address_object;
if (!readObject(object, "address", context, &address_object, state)) if (!readObject(object, "address", context, &address_object, state))
{ {
@@ -1481,11 +1260,7 @@ bool parseNodeConfig(
address, comparison, static_cast<std::int16_t>(value)}; address, comparison, static_cast<std::int16_t>(value)};
return true; return true;
} }


return state->fail(
ProjectStorageError::InvalidField,
"不支持的逻辑节点类型:" + type);
return state->fail(ProjectStorageError::InvalidField, "逻辑节点类型解析失败");
} }


// 解析逻辑节点标识及其多态配置 // 解析逻辑节点标识及其多态配置
@@ -1516,6 +1291,8 @@ QString expressionKindText(ConditionExpressionKind kind)
return QStringLiteral("node"); return QStringLiteral("node");
case ConditionExpressionKind::Wire: case ConditionExpressionKind::Wire:
return QStringLiteral("wire"); return QStringLiteral("wire");
case ConditionExpressionKind::Gap:
return QStringLiteral("gap");
case ConditionExpressionKind::Series: case ConditionExpressionKind::Series:
return QStringLiteral("series"); return QStringLiteral("series");
case ConditionExpressionKind::Parallel: case ConditionExpressionKind::Parallel:
@@ -1537,6 +1314,10 @@ QJsonObject serializeConditionExpression(const ConditionExpression &expression)
{ {
object.insert(QStringLiteral("columnSpan"), expression.wire->columnSpan); object.insert(QStringLiteral("columnSpan"), expression.wire->columnSpan);
} }
else if (expression.kind == ConditionExpressionKind::Gap)
{
object.insert(QStringLiteral("columnSpan"), expression.gap->columnSpan);
}
else else
{ {
QJsonArray children; QJsonArray children;
@@ -1561,14 +1342,14 @@ bool parseConditionExpression(
{ {
return state->fail( return state->fail(
ProjectStorageError::InvalidField, ProjectStorageError::InvalidField,
context + " 的嵌套深度超过 20 层");
context + " 的嵌套深度超过 12 层");
} }
++*node_count; ++*node_count;
if (*node_count > ProjectLimits::kMaximumExpressionNodesPerRung) if (*node_count > ProjectLimits::kMaximumExpressionNodesPerRung)
{ {
return state->fail( return state->fail(
ProjectStorageError::InvalidField, ProjectStorageError::InvalidField,
context + " 的表达式节点总数超过 4096 个");
context + " 的表达式节点总数超过 1024 个");
} }
std::string kind; std::string kind;
if (!readString( if (!readString(
@@ -1612,11 +1393,29 @@ bool parseConditionExpression(
expression->wire = WireSegment{column_span}; expression->wire = WireSegment{column_span};
return true; return true;
} }
if (kind == "gap")
{
int column_span = 0;
if (!readInt(
object,
"columnSpan",
context,
GapSegment::kMinimumColumnSpan,
GapSegment::kMaximumColumnSpan,
&column_span,
state))
{
return false;
}
expression->kind = ConditionExpressionKind::Gap;
expression->gap = GapSegment{column_span};
return true;
}
if (kind != "series" && kind != "parallel") if (kind != "series" && kind != "parallel")
{ {
return state->fail( return state->fail(
ProjectStorageError::InvalidField, ProjectStorageError::InvalidField,
context + ".kind 必须是 node、wire、series 或 parallel");
context + ".kind 必须是 node、wire、gap、series 或 parallel");
} }
QJsonArray children; QJsonArray children;
if (!readArray( if (!readArray(
@@ -1751,6 +1550,7 @@ QJsonObject serializeControlLogic(const ControlLogic &logic)
bool parseControlLogic( bool parseControlLogic(
const QJsonObject &object, const QJsonObject &object,
const std::string &context, const std::string &context,
const ProjectLimitSettings &limits,
ControlLogic *logic, ControlLogic *logic,
ParseState *state) ParseState *state)
{ {
@@ -1762,7 +1562,7 @@ bool parseControlLogic(
|| !readBool(object, "enabled", context, &logic->enabled, state) || !readBool(object, "enabled", context, &logic->enabled, state)
|| !readArray( || !readArray(
object, "rungs", context, &rungs, state, object, "rungs", context, &rungs, state,
static_cast<int>(ProjectLimits::kMaximumRungsPerLogic)))
static_cast<int>(limits.maximumRungsPerLogic)))
{ {
return false; return false;
} }
@@ -1831,7 +1631,10 @@ QJsonObject serializeProject(const Project &project)


// 从顶层 JSON 对象解析完整工程,并在解析子对象前检查格式版本 // 从顶层 JSON 对象解析完整工程,并在解析子对象前检查格式版本
bool parseProject( bool parseProject(
const QJsonObject &object, Project *project, ParseState *state)
const QJsonObject &object,
const ProjectLimitSettings &limits,
Project *project,
ParseState *state)
{ {
QJsonArray pages; QJsonArray pages;
QJsonArray alarms; QJsonArray alarms;
@@ -1861,20 +1664,20 @@ bool parseProject(
|| !readString(object, "name", "project", &project->metadata.name, state) || !readString(object, "name", "project", &project->metadata.name, state)
|| !readArray( || !readArray(
object, "hmiPages", "project", &pages, state, object, "hmiPages", "project", &pages, state,
static_cast<int>(ProjectLimits::kMaximumHmiPages))
static_cast<int>(limits.maximumHmiPages))
|| !readString( || !readString(
object, "initialHmiPageId", "project", object, "initialHmiPageId", "project",
&project->initialHmiPageId, state, &project->initialHmiPageId, state,
ProjectLimits::kMaximumIdBytes) ProjectLimits::kMaximumIdBytes)
|| !readArray( || !readArray(
object, "alarmDefinitions", "project", &alarms, state, object, "alarmDefinitions", "project", &alarms, state,
static_cast<int>(ProjectLimits::kMaximumAlarmDefinitions))
static_cast<int>(limits.maximumAlarmDefinitions))
|| !readArray( || !readArray(
object, "registerComments", "project", &register_comments, state, object, "registerComments", "project", &register_comments, state,
static_cast<int>(ProjectLimits::kMaximumRegisterComments)) static_cast<int>(ProjectLimits::kMaximumRegisterComments))
|| !readArray( || !readArray(
object, "controlLogics", "project", &logics, state, object, "controlLogics", "project", &logics, state,
static_cast<int>(ProjectLimits::kMaximumControlLogics)))
static_cast<int>(limits.maximumControlLogics)))
{ {
return false; return false;
} }
@@ -1893,6 +1696,7 @@ bool parseProject(
if (!parseHmiPage( if (!parseHmiPage(
pages.at(index).toObject(), pages.at(index).toObject(),
"project.hmiPages[" + std::to_string(index) + ']', "project.hmiPages[" + std::to_string(index) + ']',
limits,
&page, &page,
state)) state))
{ {
@@ -1956,6 +1760,7 @@ bool parseProject(
if (!parseControlLogic( if (!parseControlLogic(
logics.at(index).toObject(), logics.at(index).toObject(),
"project.controlLogics[" + std::to_string(index) + ']', "project.controlLogics[" + std::to_string(index) + ']',
limits,
&logic, &logic,
state)) state))
{ {
@@ -1975,13 +1780,19 @@ ProjectSaveResult saveFailure(


} // namespace } // namespace


JsonProjectStorage::JsonProjectStorage(
const ProjectLimitSettings &project_limits)
: project_limits_(project_limits)
{
}

// 校验工程后将其序列化,并通过 QSaveFile 原子写入目标文件 // 校验工程后将其序列化,并通过 QSaveFile 原子写入目标文件
ProjectSaveResult JsonProjectStorage::save( ProjectSaveResult JsonProjectStorage::save(
const Project &project, const std::string &file_path) const Project &project, const std::string &file_path)
{ {
// 写文件前先执行领域校验,防止持久化内部关系不合法的工程 // 写文件前先执行领域校验,防止持久化内部关系不合法的工程
std::string validation_error; std::string validation_error;
if (!project.validate(&validation_error))
if (!project.validate(project_limits_, &validation_error))
{ {
return {false, ProjectStorageError::InvalidProject, validation_error}; return {false, ProjectStorageError::InvalidProject, validation_error};
} }
@@ -2079,14 +1890,13 @@ ProjectLoadResult JsonProjectStorage::load(const std::string &file_path)
// 先在局部对象中完成结构解析,失败时不会暴露半成品工程 // 先在局部对象中完成结构解析,失败时不会暴露半成品工程
Project project; Project project;
ParseState state; ParseState state;
if (!parseProject(document.object(), &project, &state))
if (!parseProject(document.object(), project_limits_, &project, &state))
{ {
return {false, {}, state.error, state.message}; return {false, {}, state.error, state.message};
} }

// JSON 字段合法不代表业务关系合法,还需执行领域层整体校验 // JSON 字段合法不代表业务关系合法,还需执行领域层整体校验
std::string validation_error; std::string validation_error;
if (!project.validate(&validation_error))
if (!project.validate(project_limits_, &validation_error))
{ {
return {false, return {false,
{}, {},


+ 7
- 1
app/src/infrastructure/json_project_storage.h 查看文件

@@ -2,11 +2,17 @@


#include "domain/project_storage.h" #include "domain/project_storage.h"


// 使用版本化 JSON 文件保存和加载工程
// 严格读写当前 1.0 JSON 工程格式
class JsonProjectStorage final : public ProjectStorage class JsonProjectStorage final : public ProjectStorage
{ {
public: public:
explicit JsonProjectStorage(
const ProjectLimitSettings &project_limits =
defaultProjectLimitSettings());
ProjectSaveResult save( ProjectSaveResult save(
const Project &project, const std::string &file_path) override; const Project &project, const std::string &file_path) override;
ProjectLoadResult load(const std::string &file_path) override; ProjectLoadResult load(const std::string &file_path) override;

private:
const ProjectLimitSettings &project_limits_;
}; };

+ 3
- 0
app/src/infrastructure/plc_communication_error_classifier.h 查看文件

@@ -5,6 +5,7 @@
#include <QModbusDevice> #include <QModbusDevice>
#include <QString> #include <QString>


// Qt 错误码之外的通信现场信息,用于区分“未打开串口”和“已断线”
struct PlcCommunicationErrorContext struct PlcCommunicationErrorContext
{ {
QString portName; QString portName;
@@ -12,12 +13,14 @@ struct PlcCommunicationErrorContext
bool receivedValidResponse = false; bool receivedValidResponse = false;
}; };


// 分类后的用户可读通信故障
struct PlcCommunicationFailure struct PlcCommunicationFailure
{ {
PlcCommunicationError type = PlcCommunicationError::Unknown; PlcCommunicationError type = PlcCommunicationError::Unknown;
QString message; QString message;
}; };


// 将 Qt Modbus 错误和上下文转换为项目统一错误类型
PlcCommunicationFailure classifyPlcCommunicationError( PlcCommunicationFailure classifyPlcCommunicationError(
QModbusDevice::Error error, QModbusDevice::Error error,
const PlcCommunicationErrorContext &context); const PlcCommunicationErrorContext &context);

+ 245
- 8
app/src/infrastructure/plc_communication_service.cpp 查看文件

@@ -15,26 +15,31 @@


namespace { namespace {


// 故障恢复时,两次轻量探测之间等待的时间
constexpr int kRecoveryProbeIntervalMs = 2000; constexpr int kRecoveryProbeIntervalMs = 2000;


// Qt 使用 QString,项目内部错误信息统一使用 UTF-8 std::string
std::string toUtf8(const QString &value) std::string toUtf8(const QString &value)
{ {
const QByteArray bytes = value.toUtf8(); const QByteArray bytes = value.toUtf8();
return std::string(bytes.constData(), static_cast<std::size_t>(bytes.size())); return std::string(bytes.constData(), static_cast<std::size_t>(bytes.size()));
} }


// M 区在 Modbus 中按线圈读取,D 区按保持寄存器读取
QModbusDataUnit::RegisterType registerType(RegisterArea area) QModbusDataUnit::RegisterType registerType(RegisterArea area)
{ {
return area == RegisterArea::M return area == RegisterArea::M
? QModbusDataUnit::Coils : QModbusDataUnit::HoldingRegisters; ? QModbusDataUnit::Coils : QModbusDataUnit::HoldingRegisters;
} }


// 只有超时和 PLC 不响应属于可以自动探测恢复的故障
bool isRecoverableTimeout(PlcCommunicationError error) bool isRecoverableTimeout(PlcCommunicationError error)
{ {
return error == PlcCommunicationError::PlcNotResponding return error == PlcCommunicationError::PlcNotResponding
|| error == PlcCommunicationError::CommunicationTimeout; || error == PlcCommunicationError::CommunicationTimeout;
} }


// Connected 正常轮询,Recovering 正在恢复后的重新首读
bool isReadingState(PlcConnectionState state) bool isReadingState(PlcConnectionState state)
{ {
return state == PlcConnectionState::Connected return state == PlcConnectionState::Connected
@@ -46,6 +51,7 @@ bool normalizePollAddresses(
std::vector<RegisterAddress> *normalized, std::vector<RegisterAddress> *normalized,
std::string *error) std::string *error)
{ {
// 先复制,后续排序和去重不能改变调用方传入的地址列表
*normalized = addresses; *normalized = addresses;
if (std::any_of( if (std::any_of(
normalized->cbegin(), normalized->cend(), normalized->cbegin(), normalized->cend(),
@@ -66,16 +72,35 @@ bool normalizePollAddresses(
std::unique(normalized->begin(), normalized->end()), normalized->end()); std::unique(normalized->begin(), normalized->end()), normalized->end());
if (normalized->size() > ProjectLimits::kMaximumPollAddresses) if (normalized->size() > ProjectLimits::kMaximumPollAddresses)
{ {
*error = "PLC 轮询的去重 M/D 地址最多为 1024 个";
*error = "PLC 轮询的去重 M/D 地址最多为 256 个";
return false; return false;
} }
return true; return true;
} }


std::size_t pollBlockCount(const std::vector<RegisterAddress> &addresses)
bool isFloat32Start(
const std::vector<RegisterAddress> &float32_starts,
RegisterArea area,
int index)
{
return std::binary_search(
float32_starts.cbegin(), float32_starts.cend(),
RegisterAddress{area, index},
[](const RegisterAddress &left, const RegisterAddress &right)
{
return left.area() == right.area()
? left.index() < right.index()
: left.area() == RegisterArea::M;
});
}

std::size_t pollBlockCount(
const std::vector<RegisterAddress> &addresses,
const std::vector<RegisterAddress> &float32_starts)
{ {
if (addresses.empty()) if (addresses.empty())
{ {
// 空集合也会保留 M0、D0 两个默认读块
return 2U; return 2U;
} }
std::size_t blocks = 0U; std::size_t blocks = 0U;
@@ -84,6 +109,20 @@ std::size_t pollBlockCount(const std::vector<RegisterAddress> &addresses)
int count = 0; int count = 0;
for (const RegisterAddress &address : addresses) for (const RegisterAddress &address : addresses)
{ {
const bool must_keep_pair = count >= ProjectLimits::kMaximumModbusReadCount
&& address.area() == current_area
&& address.index() == start_address + count
&& isFloat32Start(float32_starts, address.area(), address.index() - 1)
&& start_address + count - 1 == address.index() - 1;
if (must_keep_pair)
{
++blocks;
count = 2;
start_address = address.index() - 1;
current_area = address.area();
continue;
}
// 跨区域、出现地址间隔或达到 Modbus 单次上限时,必须开始新读块
if (count == 0 if (count == 0
|| address.area() != current_area || address.area() != current_area
|| address.index() > start_address + count || address.index() > start_address + count
@@ -111,6 +150,7 @@ PlcCommunicationService::PlcCommunicationService(
repository_(repository), repository_(repository),
master_(std::make_unique<QModbusRtuSerialMaster>()) master_(std::make_unique<QModbusRtuSerialMaster>())
{ {
// 仓库只负责缓存;写入请求通过这里注入的回调转给通信服务
repository_.setWriteHandlers( repository_.setWriteHandlers(
[this](const RegisterAddress &address, bool value) [this](const RegisterAddress &address, bool value)
{ {
@@ -119,7 +159,13 @@ PlcCommunicationService::PlcCommunicationService(
[this](const RegisterAddress &address, std::int16_t value) [this](const RegisterAddress &address, std::int16_t value)
{ {
return sendWordWrite(address, value); return sendWordWrite(address, value);
},
[this](const RegisterAddress &address,
const std::array<std::int16_t, 2> &values)
{
return sendWordPairWrite(address, values);
}); });
// 轮询定时器每次只推动一个读块,避免一次压入大量异步请求
connect(&poll_timer_, &QTimer::timeout, this, &PlcCommunicationService::pollNextBlock); connect(&poll_timer_, &QTimer::timeout, this, &PlcCommunicationService::pollNextBlock);
recovery_timer_.setSingleShot(true); recovery_timer_.setSingleShot(true);
connect( connect(
@@ -127,12 +173,14 @@ PlcCommunicationService::PlcCommunicationService(
&QTimer::timeout, &QTimer::timeout,
this, this,
&PlcCommunicationService::probeRecovery); &PlcCommunicationService::probeRecovery);
// Qt 串口状态变化是异步通知,所有连接状态都从这里统一处理
connect(master_.get(), &QModbusClient::stateChanged, connect(master_.get(), &QModbusClient::stateChanged,
this, this,
[this](QModbusDevice::State device_state) [this](QModbusDevice::State device_state)
{ {
if (device_state == QModbusDevice::ConnectedState) if (device_state == QModbusDevice::ConnectedState)
{ {
// 串口连接成功后立即开始轮询,并不代表首读已经完成
serial_session_opened_ = true; serial_session_opened_ = true;
setState(PlcConnectionState::Connected); setState(PlcConnectionState::Connected);
poll_timer_.start(configuration_.pollIntervalMs); poll_timer_.start(configuration_.pollIntervalMs);
@@ -145,6 +193,7 @@ PlcCommunicationService::PlcCommunicationService(
} }
else if (device_state == QModbusDevice::UnconnectedState) else if (device_state == QModbusDevice::UnconnectedState)
{ {
// 主动断开和意外断线要区分,后者需要报告故障
if (disconnecting_) if (disconnecting_)
{ {
serial_session_opened_ = false; serial_session_opened_ = false;
@@ -159,6 +208,7 @@ PlcCommunicationService::PlcCommunicationService(
} }
} }
}); });
// Qt 报告底层错误时,转换为项目自己的错误和状态
connect(master_.get(), &QModbusClient::errorOccurred, connect(master_.get(), &QModbusClient::errorOccurred,
this, this,
[this](QModbusDevice::Error error) [this](QModbusDevice::Error error)
@@ -190,6 +240,7 @@ PlcCommunicationResult PlcCommunicationService::connectDevice(
{ {
return {false, "PLC 连接已经启动,请先断开当前连接"}; return {false, "PLC 连接已经启动,请先断开当前连接"};
} }
// 每次连接都递增代次;旧连接的异步回复即使晚到也不能污染新缓存
configuration_ = configuration; configuration_ = configuration;
++connection_generation_; ++connection_generation_;
disconnecting_ = false; disconnecting_ = false;
@@ -197,6 +248,7 @@ PlcCommunicationResult PlcCommunicationService::connectDevice(
received_valid_response_ = false; received_valid_response_ = false;
last_error_type_ = PlcCommunicationError::None; last_error_type_ = PlcCommunicationError::None;
last_error_.clear(); last_error_.clear();
// 把项目配置转换成 Qt 串口参数
master_->setConnectionParameter( master_->setConnectionParameter(
QModbusDevice::SerialPortNameParameter, QModbusDevice::SerialPortNameParameter,
QString::fromStdString(configuration.portName)); QString::fromStdString(configuration.portName));
@@ -213,6 +265,7 @@ PlcCommunicationResult PlcCommunicationService::connectDevice(
static_cast<QSerialPort::StopBits>(configuration.stopBits)); static_cast<QSerialPort::StopBits>(configuration.stopBits));
master_->setTimeout(configuration.responseTimeoutMs); master_->setTimeout(configuration.responseTimeoutMs);
master_->setNumberOfRetries(configuration.retries); master_->setNumberOfRetries(configuration.retries);
// 新连接必须重新完成完整首读,不能沿用上一条连接的运行资格
repository_.invalidate(); repository_.invalidate();
updateInitialReadCompleted(false, true); updateInitialReadCompleted(false, true);
rebuildPollBlocks(); rebuildPollBlocks();
@@ -232,6 +285,7 @@ PlcCommunicationResult PlcCommunicationService::connectDevice(


void PlcCommunicationService::disconnectDevice() void PlcCommunicationService::disconnectDevice()
{ {
// 关闭串口、清除缓存有效标记,防止断开后继续使用旧值
closeSerialSession(); closeSerialSession();
repository_.invalidate(); repository_.invalidate();
updateInitialReadCompleted(false, true); updateInitialReadCompleted(false, true);
@@ -242,6 +296,13 @@ void PlcCommunicationService::disconnectDevice()


PlcCommunicationResult PlcCommunicationService::setPollAddresses( PlcCommunicationResult PlcCommunicationService::setPollAddresses(
const std::vector<RegisterAddress> &addresses) const std::vector<RegisterAddress> &addresses)
{
return setPollAddresses(addresses, {});
}

PlcCommunicationResult PlcCommunicationService::setPollAddresses(
const std::vector<RegisterAddress> &addresses,
const std::vector<RegisterAddress> &float32_starts)
{ {
std::vector<RegisterAddress> normalized; std::vector<RegisterAddress> normalized;
std::string error; std::string error;
@@ -249,16 +310,73 @@ PlcCommunicationResult PlcCommunicationService::setPollAddresses(
{ {
return {false, error}; return {false, error};
} }
if (pollBlockCount(normalized) > ProjectLimits::kMaximumPollBlocks)
std::vector<RegisterAddress> normalized_float32_starts = float32_starts;
std::sort(
normalized_float32_starts.begin(), normalized_float32_starts.end(),
[](const RegisterAddress &left, const RegisterAddress &right)
{
return left.area() == right.area()
? left.index() < right.index()
: left.area() == RegisterArea::M;
});
normalized_float32_starts.erase(
std::unique(
normalized_float32_starts.begin(), normalized_float32_starts.end()),
normalized_float32_starts.end());
if (std::any_of(
normalized_float32_starts.cbegin(), normalized_float32_starts.cend(),
[](const RegisterAddress &address)
{
return !address.isValid()
|| address.area() != RegisterArea::D
|| address.index() >= RegisterAddress::kMaximumIndex;
}))
{ {
return {false, "PLC 轮询地址拆分后最多允许 64 个读块"};
return {false, "Float32 轮询起始地址必须位于 D0~D3999"};
} }
for (const RegisterAddress &start : normalized_float32_starts)
{
const RegisterAddress high{
RegisterArea::D, start.index() + 1};
if (std::find(normalized.cbegin(), normalized.cend(), start)
== normalized.cend())
{
normalized.push_back(start);
}
if (std::find(normalized.cbegin(), normalized.cend(), high)
== normalized.cend())
{
normalized.push_back(high);
}
}
std::sort(
normalized.begin(), normalized.end(),
[](const RegisterAddress &left, const RegisterAddress &right)
{
return left.area() == right.area()
? left.index() < right.index()
: left.area() == RegisterArea::M;
});
normalized.erase(std::unique(normalized.begin(), normalized.end()), normalized.end());
if (normalized.size() > ProjectLimits::kMaximumPollAddresses)
{
return {false, "PLC 轮询的去重 M/D 地址最多为 256 个"};
}
if (pollBlockCount(normalized, normalized_float32_starts)
> ProjectLimits::kMaximumPollBlocks)
{
return {false, "PLC 轮询地址拆分后最多允许 8 个读块"};
}
// 当前读请求完成前暂存新集合,避免按半旧半新的地址解析回复
if (pending_reply_ != nullptr) if (pending_reply_ != nullptr)
{ {
// 当前回复仍在解析旧集合,等它结束后再切换到新集合
pending_poll_addresses_ = std::move(normalized); pending_poll_addresses_ = std::move(normalized);
pending_poll_float32_starts_ = std::move(normalized_float32_starts);
poll_update_pending_ = true; poll_update_pending_ = true;
return {true, {}}; return {true, {}};
} }
poll_float32_starts_ = std::move(normalized_float32_starts);
applyPollAddresses(normalized); applyPollAddresses(normalized);
return {true, {}}; return {true, {}};
} }
@@ -292,12 +410,15 @@ void PlcCommunicationService::setCallbacks(
std::function<void()> state_changed, std::function<void()> state_changed,
std::function<void(bool)> initial_read_changed, std::function<void(bool)> initial_read_changed,
std::function<void()> cache_updated, std::function<void()> cache_updated,
std::function<void()> poll_cycle_completed,
std::function<void(const std::string &)> error_reported) std::function<void(const std::string &)> error_reported)
{ {
state_changed_callback_ = std::move(state_changed);
initial_read_changed_callback_ = std::move(initial_read_changed);
cache_updated_callback_ = std::move(cache_updated);
error_reported_callback_ = std::move(error_reported);
// 保存外部通知函数,通信事件发生时再调用对应函数
state_changed_callback_ = std::move(state_changed); // 通信状态变化时通知外部
initial_read_changed_callback_ = std::move(initial_read_changed); // 首读资格变化时通知外部
cache_updated_callback_ = std::move(cache_updated); // PLC 缓存更新后通知外部
poll_cycle_completed_callback_ = std::move(poll_cycle_completed);
error_reported_callback_ = std::move(error_reported); // 发生通信错误时通知外部
} }


void PlcCommunicationService::rebuildPollBlocks() void PlcCommunicationService::rebuildPollBlocks()
@@ -305,6 +426,7 @@ void PlcCommunicationService::rebuildPollBlocks()
std::vector<RegisterAddress> addresses = poll_addresses_; std::vector<RegisterAddress> addresses = poll_addresses_;
if (addresses.empty()) if (addresses.empty())
{ {
// 没有指定地址时保留 M0/D0,保证连接后仍能验证设备是否响应
addresses = { addresses = {
RegisterAddress{RegisterArea::M, 0}, RegisterAddress{RegisterArea::M, 0},
RegisterAddress{RegisterArea::D, 0}}; RegisterAddress{RegisterArea::D, 0}};
@@ -320,6 +442,18 @@ void PlcCommunicationService::rebuildPollBlocks()
return left.index() < right.index(); return left.index() < right.index();
}); });
addresses.erase(std::unique(addresses.begin(), addresses.end()), addresses.end()); addresses.erase(std::unique(addresses.begin(), addresses.end()), addresses.end());
std::sort(
poll_float32_starts_.begin(), poll_float32_starts_.end(),
[](const RegisterAddress &left, const RegisterAddress &right)
{
return left.area() == right.area()
? left.index() < right.index()
: left.area() == RegisterArea::M;
});
poll_float32_starts_.erase(
std::unique(poll_float32_starts_.begin(), poll_float32_starts_.end()),
poll_float32_starts_.end());
// 将相邻地址合并为读块,同时遵守 Modbus 单次读取上限
poll_blocks_.clear(); poll_blocks_.clear();
for (const RegisterAddress &address : addresses) for (const RegisterAddress &address : addresses)
{ {
@@ -327,6 +461,21 @@ void PlcCommunicationService::rebuildPollBlocks()
{ {
continue; continue;
} }
if (!poll_blocks_.empty()
&& poll_blocks_.back().area == address.area()
&& address.index() == poll_blocks_.back().startAddress
+ poll_blocks_.back().count
&& poll_blocks_.back().count >= ProjectLimits::kMaximumModbusReadCount
&& isFloat32Start(
poll_float32_starts_, address.area(), address.index() - 1)
&& poll_blocks_.back().startAddress
+ poll_blocks_.back().count - 1 == address.index() - 1)
{
// 把 Float32 低字从已满读块移到新块,保证高字不会单独读取
--poll_blocks_.back().count;
poll_blocks_.push_back({address.area(), address.index() - 1, 2});
continue;
}
if (poll_blocks_.empty() if (poll_blocks_.empty()
|| poll_blocks_.back().area != address.area() || poll_blocks_.back().area != address.area()
|| address.index() > poll_blocks_.back().startAddress || address.index() > poll_blocks_.back().startAddress
@@ -345,6 +494,7 @@ void PlcCommunicationService::rebuildPollBlocks()
next_poll_block_ = 0; next_poll_block_ = 0;
if (!initial_read_completed_) if (!initial_read_completed_)
{ {
// 地址集合变化或重新连接后,首读资格从头统计
initial_blocks_read_.assign(poll_blocks_.size(), false); initial_blocks_read_.assign(poll_blocks_.size(), false);
} }
else else
@@ -356,10 +506,12 @@ void PlcCommunicationService::rebuildPollBlocks()
void PlcCommunicationService::applyPollAddresses( void PlcCommunicationService::applyPollAddresses(
const std::vector<RegisterAddress> &addresses) const std::vector<RegisterAddress> &addresses)
{ {
// 只有在没有读请求占用时,新的地址集合才会真正生效
poll_addresses_ = addresses; poll_addresses_ = addresses;
rebuildPollBlocks(); rebuildPollBlocks();
poll_update_pending_ = false; poll_update_pending_ = false;
pending_poll_addresses_.clear(); pending_poll_addresses_.clear();
pending_poll_float32_starts_.clear();
if (isReadingState(state_) && pending_reply_ == nullptr) if (isReadingState(state_) && pending_reply_ == nullptr)
{ {
pollNextBlock(); pollNextBlock();
@@ -373,6 +525,7 @@ void PlcCommunicationService::pollNextBlock()
{ {
return; return;
} }
// 每次只发一个异步请求,完成回调中再推进到下一个块
const std::size_t block_index = next_poll_block_; const std::size_t block_index = next_poll_block_;
const PollBlock block = poll_blocks_.at(block_index); const PollBlock block = poll_blocks_.at(block_index);
next_poll_block_ = (next_poll_block_ + 1U) % poll_blocks_.size(); next_poll_block_ = (next_poll_block_ + 1U) % poll_blocks_.size();
@@ -385,11 +538,13 @@ void PlcCommunicationService::pollNextBlock()
return; return;
} }
pending_reply_ = reply; pending_reply_ = reply;
// 把当前连接代次带进回调,防止断线重连后旧回复误更新新连接
const std::uint64_t generation = connection_generation_; const std::uint64_t generation = connection_generation_;
connect(reply, &QModbusReply::finished, connect(reply, &QModbusReply::finished,
this, this,
[this, reply, block, block_index, generation] [this, reply, block, block_index, generation]
{ {
// 连接已经重建时,直接丢弃旧回复
if (generation != connection_generation_) if (generation != connection_generation_)
{ {
reply->deleteLater(); reply->deleteLater();
@@ -406,6 +561,7 @@ void PlcCommunicationService::pollNextBlock()
initial_blocks_read_.cbegin(), initial_blocks_read_.cbegin(),
initial_blocks_read_.cend(), initial_blocks_read_.cend(),
[](bool read) { return read; }); [](bool read) { return read; });
// 所有轮询块都成功读过,才授予真机运行资格
if (completed && !initial_read_completed_) if (completed && !initial_read_completed_)
{ {
updateInitialReadCompleted(true); updateInitialReadCompleted(true);
@@ -415,21 +571,36 @@ void PlcCommunicationService::pollNextBlock()
} }
} }
} }
const bool poll_cycle_completed = isReadingState(state_)
&& reply->error() == QModbusDevice::NoError
&& !poll_update_pending_
&& next_poll_block_ == 0U;
if (pending_reply_ == reply) if (pending_reply_ == reply)
{ {
// 只有当前指针仍指向本次回复时才清空,避免误清新请求
pending_reply_ = nullptr; pending_reply_ = nullptr;
} }
reply->deleteLater(); reply->deleteLater();
if (poll_update_pending_) if (poll_update_pending_)
{ {
const std::vector<RegisterAddress> addresses = pending_poll_addresses_; const std::vector<RegisterAddress> addresses = pending_poll_addresses_;
poll_float32_starts_ = pending_poll_float32_starts_;
applyPollAddresses(addresses); applyPollAddresses(addresses);
} }
if (poll_cycle_completed)
{
emit pollCycleCompleted();
if (poll_cycle_completed_callback_)
{
poll_cycle_completed_callback_();
}
}
}); });
} }


void PlcCommunicationService::probeRecovery() void PlcCommunicationService::probeRecovery()
{ {
// 只在可恢复的超时故障中探测;串口拔出等故障不会反复探测
if (state_ != PlcConnectionState::Faulted if (state_ != PlcConnectionState::Faulted
|| !isRecoverableTimeout(last_error_type_)) || !isRecoverableTimeout(last_error_type_))
{ {
@@ -441,6 +612,7 @@ void PlcCommunicationService::probeRecovery()
} }
if (pending_reply_ != nullptr) if (pending_reply_ != nullptr)
{ {
// 已有读请求在途,稍后再探测,保证同一时间只有一个读回复
recovery_timer_.start(kRecoveryProbeIntervalMs); recovery_timer_.start(kRecoveryProbeIntervalMs);
return; return;
} }
@@ -448,6 +620,7 @@ void PlcCommunicationService::probeRecovery()
{ {
return; return;
} }
// 恢复探测只读取一个地址,确认链路恢复后再进行完整首读
const PollBlock block = poll_blocks_.front(); const PollBlock block = poll_blocks_.front();
const QModbusDataUnit request( const QModbusDataUnit request(
registerType(block.area), block.startAddress, 1); registerType(block.area), block.startAddress, 1);
@@ -477,6 +650,7 @@ void PlcCommunicationService::probeRecovery()
if (poll_update_pending_) if (poll_update_pending_)
{ {
const std::vector<RegisterAddress> addresses = pending_poll_addresses_; const std::vector<RegisterAddress> addresses = pending_poll_addresses_;
poll_float32_starts_ = pending_poll_float32_starts_;
applyPollAddresses(addresses); applyPollAddresses(addresses);
} }
if (state_ != PlcConnectionState::Faulted if (state_ != PlcConnectionState::Faulted
@@ -514,6 +688,7 @@ void PlcCommunicationService::handleRecoveryProbeFailure(QModbusDevice::Error er


void PlcCommunicationService::restoreCommunication() void PlcCommunicationService::restoreCommunication()
{ {
// 探测成功只说明链路恢复,仍要重新读取全部读块才能恢复真机资格
received_valid_response_ = true; received_valid_response_ = true;
last_error_type_ = PlcCommunicationError::None; last_error_type_ = PlcCommunicationError::None;
last_error_.clear(); last_error_.clear();
@@ -534,6 +709,7 @@ void PlcCommunicationService::handleReadFinished(QModbusReply *reply, PollBlock
handleModbusError(reply->error()); handleModbusError(reply->error());
return; return;
} }
// 只有成功回复才能更新 PLC 缓存;写请求不会直接改缓存
const QModbusDataUnit result = reply->result(); const QModbusDataUnit result = reply->result();
for (uint index = 0; index < result.valueCount(); ++index) for (uint index = 0; index < result.valueCount(); ++index)
{ {
@@ -560,6 +736,7 @@ void PlcCommunicationService::handleReadFinished(QModbusReply *reply, PollBlock
RegisterWriteResult PlcCommunicationService::sendBitWrite( RegisterWriteResult PlcCommunicationService::sendBitWrite(
const RegisterAddress &address, bool value) const RegisterAddress &address, bool value)
{ {
// 写入只允许在正常 Connected 状态进行,Recovering/Faulted 都拒绝
if (state_ != PlcConnectionState::Connected) if (state_ != PlcConnectionState::Connected)
{ {
return {false, RegisterError::Unavailable}; return {false, RegisterError::Unavailable};
@@ -568,6 +745,7 @@ RegisterWriteResult PlcCommunicationService::sendBitWrite(
{ {
return {false, RegisterError::WriteRejected}; return {false, RegisterError::WriteRejected};
} }
// M 区对应 Modbus Coils,单次只写一个地址
QModbusDataUnit unit(QModbusDataUnit::Coils, address.index(), 1); QModbusDataUnit unit(QModbusDataUnit::Coils, address.index(), 1);
unit.setValue(0, value ? 1U : 0U); unit.setValue(0, value ? 1U : 0U);
QModbusReply *reply = master_->sendWriteRequest(unit, configuration_.serverAddress); QModbusReply *reply = master_->sendWriteRequest(unit, configuration_.serverAddress);
@@ -577,6 +755,7 @@ RegisterWriteResult PlcCommunicationService::sendBitWrite(
return {false, RegisterError::WriteRejected}; return {false, RegisterError::WriteRejected};
} }
pending_write_reply_ = reply; pending_write_reply_ = reply;
// 写回复完成后只处理成功或错误,不直接改缓存;后续轮询负责确认真实值
connect(reply, &QModbusReply::finished, connect(reply, &QModbusReply::finished,
this, this,
[this, reply, generation = connection_generation_] [this, reply, generation = connection_generation_]
@@ -602,6 +781,7 @@ RegisterWriteResult PlcCommunicationService::sendBitWrite(
RegisterWriteResult PlcCommunicationService::sendWordWrite( RegisterWriteResult PlcCommunicationService::sendWordWrite(
const RegisterAddress &address, std::int16_t value) const RegisterAddress &address, std::int16_t value)
{ {
// D 区写入流程与 M 区相同,只是 Modbus 类型不同
if (state_ != PlcConnectionState::Connected) if (state_ != PlcConnectionState::Connected)
{ {
return {false, RegisterError::Unavailable}; return {false, RegisterError::Unavailable};
@@ -610,6 +790,7 @@ RegisterWriteResult PlcCommunicationService::sendWordWrite(
{ {
return {false, RegisterError::WriteRejected}; return {false, RegisterError::WriteRejected};
} }
// D 区对应 Modbus HoldingRegisters,单次只写一个字
QModbusDataUnit unit(QModbusDataUnit::HoldingRegisters, address.index(), 1); QModbusDataUnit unit(QModbusDataUnit::HoldingRegisters, address.index(), 1);
unit.setValue(0, static_cast<quint16>(value)); unit.setValue(0, static_cast<quint16>(value));
QModbusReply *reply = master_->sendWriteRequest(unit, configuration_.serverAddress); QModbusReply *reply = master_->sendWriteRequest(unit, configuration_.serverAddress);
@@ -641,6 +822,53 @@ RegisterWriteResult PlcCommunicationService::sendWordWrite(
return {true, RegisterError::None}; return {true, RegisterError::None};
} }


RegisterWriteResult PlcCommunicationService::sendWordPairWrite(
const RegisterAddress &address,
const std::array<std::int16_t, 2> &values)
{
if (state_ != PlcConnectionState::Connected)
{
return {false, RegisterError::Unavailable};
}
if (pending_write_reply_ != nullptr
|| !address.isValid() || address.area() != RegisterArea::D
|| address.index() >= RegisterAddress::kMaximumIndex)
{
return {false, pending_write_reply_ != nullptr
? RegisterError::WriteRejected : RegisterError::InvalidAddress};
}
QModbusDataUnit unit(QModbusDataUnit::HoldingRegisters, address.index(), 2);
unit.setValue(0, static_cast<quint16>(values[0]));
unit.setValue(1, static_cast<quint16>(values[1]));
QModbusReply *reply = master_->sendWriteRequest(unit, configuration_.serverAddress);
if (reply == nullptr)
{
handleModbusError(master_->error());
return {false, RegisterError::WriteRejected};
}
pending_write_reply_ = reply;
connect(reply, &QModbusReply::finished,
this,
[this, reply, generation = connection_generation_]
{
if (pending_write_reply_ == reply)
{
pending_write_reply_ = nullptr;
}
if (generation != connection_generation_)
{
reply->deleteLater();
return;
}
if (reply->error() != QModbusDevice::NoError)
{
handleModbusError(reply->error());
}
reply->deleteLater();
});
return {true, RegisterError::None};
}

void PlcCommunicationService::updateInitialReadCompleted( void PlcCommunicationService::updateInitialReadCompleted(
bool completed, bool force_notification) bool completed, bool force_notification)
{ {
@@ -648,6 +876,7 @@ void PlcCommunicationService::updateInitialReadCompleted(
{ {
return; return;
} }
// 只有状态真的变化,或调用方明确要求通知时才发出信号
initial_read_completed_ = completed; initial_read_completed_ = completed;
emit initialReadCompletedChanged(completed); emit initialReadCompletedChanged(completed);
if (initial_read_changed_callback_) if (initial_read_changed_callback_)
@@ -658,6 +887,7 @@ void PlcCommunicationService::updateInitialReadCompleted(


void PlcCommunicationService::handleUnexpectedDisconnect() void PlcCommunicationService::handleUnexpectedDisconnect()
{ {
// 这里表示设备原本连上过,后来串口意外断开
serial_session_opened_ = false; serial_session_opened_ = false;
const QString port_name = QString::fromStdString(configuration_.portName).trimmed(); const QString port_name = QString::fromStdString(configuration_.portName).trimmed();
setError({ setError({
@@ -669,6 +899,7 @@ void PlcCommunicationService::handleUnexpectedDisconnect()


void PlcCommunicationService::handleModbusError(QModbusDevice::Error error) void PlcCommunicationService::handleModbusError(QModbusDevice::Error error)
{ {
// 主动断开、已处理的故障和旧回复错误都不重复上报
if (disconnecting_ if (disconnecting_
|| (error == QModbusDevice::ReplyAbortedError || (error == QModbusDevice::ReplyAbortedError
&& state_ == PlcConnectionState::Disconnected) && state_ == PlcConnectionState::Disconnected)
@@ -687,6 +918,7 @@ void PlcCommunicationService::handleModbusError(QModbusDevice::Error error)


void PlcCommunicationService::closeSerialSession() void PlcCommunicationService::closeSerialSession()
{ {
// 先递增代次并停止定时器,再断开串口;旧异步回调会因此失效
++connection_generation_; ++connection_generation_;
disconnecting_ = true; disconnecting_ = true;
poll_timer_.stop(); poll_timer_.stop();
@@ -710,6 +942,7 @@ void PlcCommunicationService::setState(PlcConnectionState state)
{ {
return; return;
} }
// 状态集中从这里修改,确保 Qt 信号和 std::function 回调同步触发
state_ = state; state_ = state;
emit stateChanged(); emit stateChanged();
if (state_changed_callback_) if (state_changed_callback_)
@@ -720,10 +953,12 @@ void PlcCommunicationService::setState(PlcConnectionState state)


void PlcCommunicationService::setError(const PlcCommunicationFailure &failure) void PlcCommunicationService::setError(const PlcCommunicationFailure &failure)
{ {
// 统一保存错误、停止正常轮询、撤销首读资格并通知 UI
last_error_type_ = failure.type; last_error_type_ = failure.type;
last_error_ = toUtf8(failure.message); last_error_ = toUtf8(failure.message);
poll_timer_.stop(); poll_timer_.stop();
recovery_timer_.stop(); recovery_timer_.stop();
// 任意通信故障都会撤销首读资格;恢复后必须重新完整读取
updateInitialReadCompleted(false); updateInitialReadCompleted(false);
const bool disconnected = failure.type == PlcCommunicationError::SerialPortOpenFailed const bool disconnected = failure.type == PlcCommunicationError::SerialPortOpenFailed
|| failure.type == PlcCommunicationError::SerialConnectionLost || failure.type == PlcCommunicationError::SerialConnectionLost
@@ -745,3 +980,5 @@ void PlcCommunicationService::setError(const PlcCommunicationFailure &failure)
recovery_timer_.start(kRecoveryProbeIntervalMs); recovery_timer_.start(kRecoveryProbeIntervalMs);
} }
} }
// 按区域和地址排序,方便后面合并连续读块
// 相同区域和编号的地址只保留一个

+ 73
- 27
app/src/infrastructure/plc_communication_service.h 查看文件

@@ -8,6 +8,7 @@
#include <QTimer> #include <QTimer>


#include <cstdint> #include <cstdint>
#include <array>
#include <memory> #include <memory>
#include <string> #include <string>
#include <vector> #include <vector>
@@ -17,87 +18,132 @@ class QModbusReply;
class QModbusRtuSerialMaster; class QModbusRtuSerialMaster;
struct PlcCommunicationFailure; struct PlcCommunicationFailure;


// 基于 Qt Modbus RTU 的异步 PLC 通信实现
// 负责连接、轮询、单点写入、故障恢复和首读资格,不阻塞 UI 线程
class PlcCommunicationService final : public QObject, public PlcCommunicationGateway class PlcCommunicationService final : public QObject, public PlcCommunicationGateway
{ {
Q_OBJECT Q_OBJECT


public: public:
// 创建通信服务,并把 PLC 缓存仓库交给它管理
explicit PlcCommunicationService( explicit PlcCommunicationService(
PlcRegisterRepository &repository, PlcRegisterRepository &repository,
QObject *parent = nullptr); QObject *parent = nullptr);
// 释放 Modbus 主站和未完成的通信资源
~PlcCommunicationService() override; ~PlcCommunicationService() override;


// 按给定串口参数连接 PLC,并开始异步轮询
PlcCommunicationResult connectDevice( PlcCommunicationResult connectDevice(
const PlcSerialConfiguration &configuration) override; const PlcSerialConfiguration &configuration) override;
// 停止轮询并断开 PLC
void disconnectDevice() override; void disconnectDevice() override;
// 设置需要周期性读取的 M/D 地址集合
PlcCommunicationResult setPollAddresses( PlcCommunicationResult setPollAddresses(
const std::vector<RegisterAddress> &addresses) override; const std::vector<RegisterAddress> &addresses) override;
PlcCommunicationResult setPollAddresses(
const std::vector<RegisterAddress> &addresses,
const std::vector<RegisterAddress> &float32_starts) override;


// 返回当前连接状态
PlcConnectionState state() const override; PlcConnectionState state() const override;
// 判断本次连接是否已经完成所有轮询块的首次读取
bool initialReadCompleted() const override; bool initialReadCompleted() const override;
// 返回最近一次通信错误的类型
PlcCommunicationError lastErrorType() const override; PlcCommunicationError lastErrorType() const override;
// 返回最近一次通信错误的文字
const std::string &lastError() const override; const std::string &lastError() const override;
// 返回当前使用的串口和 Modbus 参数
const PlcSerialConfiguration &configuration() const; const PlcSerialConfiguration &configuration() const;
// 注册状态、首读、缓存更新和错误通知回调
void setCallbacks( void setCallbacks(
std::function<void()> state_changed, std::function<void()> state_changed,
std::function<void(bool)> initial_read_changed, std::function<void(bool)> initial_read_changed,
std::function<void()> cache_updated, std::function<void()> cache_updated,
std::function<void()> poll_cycle_completed,
std::function<void(const std::string &)> error_reported) override; std::function<void(const std::string &)> error_reported) override;


signals: signals:
// 连接状态发生变化
void stateChanged(); void stateChanged();
// 首次完整读取资格发生变化
void initialReadCompletedChanged(bool completed); void initialReadCompletedChanged(bool completed);
// PLC 缓存收到新的成功读数
void cacheUpdated(); void cacheUpdated();
// 全部轮询块成功更新一轮
void pollCycleCompleted();
// 通信错误的可读提示文字
void communicationError(const QString &message); void communicationError(const QString &message);


private: private:
struct PollBlock struct PollBlock
{ {
RegisterArea area = RegisterArea::M;
int startAddress = 0;
int count = 1;
// 同一区域的一段连续 Modbus 原始地址
RegisterArea area = RegisterArea::M; // M 区或 D 区
int startAddress = 0; // 读块的第一个原始地址
int count = 1; // 从起始地址连续读取的数量
}; };


// 把去重后的地址集合压缩为有限数量的连续读块
void rebuildPollBlocks(); void rebuildPollBlocks();
// 应用新的轮询集合;有请求在途时由 pending_* 延后应用
void applyPollAddresses(const std::vector<RegisterAddress> &addresses); void applyPollAddresses(const std::vector<RegisterAddress> &addresses);
// 发送下一段异步读取请求
void pollNextBlock(); void pollNextBlock();
// Faulted 状态下用轻量探测判断通信是否恢复
void probeRecovery(); void probeRecovery();
// 处理恢复探测失败,并安排下一次探测
void handleRecoveryProbeFailure(QModbusDevice::Error error); void handleRecoveryProbeFailure(QModbusDevice::Error error);
// 探测成功后恢复轮询,但仍需重新完成首读
void restoreCommunication(); void restoreCommunication();
// 处理读回复并把值写入 PLC 缓存
void handleReadFinished(QModbusReply *reply, PollBlock block); void handleReadFinished(QModbusReply *reply, PollBlock block);
// 发送单点 M/D 写请求;缓存等待后续轮询确认
RegisterWriteResult sendBitWrite(const RegisterAddress &address, bool value); RegisterWriteResult sendBitWrite(const RegisterAddress &address, bool value);
RegisterWriteResult sendWordWrite( RegisterWriteResult sendWordWrite(
const RegisterAddress &address, std::int16_t value); const RegisterAddress &address, std::int16_t value);
RegisterWriteResult sendWordPairWrite(
const RegisterAddress &address,
const std::array<std::int16_t, 2> &values);
// 更新“所有轮询块均成功读取”的真机进入资格
void updateInitialReadCompleted(bool completed, bool force_notification = false); void updateInitialReadCompleted(bool completed, bool force_notification = false);
// 处理没有主动断开时发生的串口断线
void handleUnexpectedDisconnect(); void handleUnexpectedDisconnect();
// 把 Qt Modbus 错误转换成项目自己的通信错误
void handleModbusError(QModbusDevice::Error error); void handleModbusError(QModbusDevice::Error error);
// 关闭串口并清理当前连接的请求和状态
void closeSerialSession(); void closeSerialSession();
// 修改连接状态并通知外部观察者
void setState(PlcConnectionState state); void setState(PlcConnectionState state);
// 保存错误信息、更新状态并通知外部观察者
void setError(const PlcCommunicationFailure &failure); void setError(const PlcCommunicationFailure &failure);


PlcRegisterRepository &repository_;
std::unique_ptr<QModbusRtuSerialMaster> master_;
QTimer poll_timer_;
QTimer recovery_timer_;
PlcSerialConfiguration configuration_;
std::vector<RegisterAddress> poll_addresses_;
std::vector<RegisterAddress> pending_poll_addresses_;
std::vector<PollBlock> poll_blocks_;
std::size_t next_poll_block_ = 0;
QModbusReply *pending_reply_ = nullptr;
QModbusReply *pending_write_reply_ = nullptr;
bool poll_update_pending_ = false;
bool disconnecting_ = false;
bool serial_session_opened_ = false;
bool received_valid_response_ = false;
std::uint64_t connection_generation_ = 0;
PlcConnectionState state_ = PlcConnectionState::Disconnected;
bool initial_read_completed_ = false;
PlcRegisterRepository &repository_; // 用于保存 PLC 最近一次成功读回的 M/D 值
std::unique_ptr<QModbusRtuSerialMaster> master_; // Qt Modbus RTU 主站对象
QTimer poll_timer_; // 周期性触发下一轮轮询
QTimer recovery_timer_; // Faulted 状态下定时发起恢复探测
PlcSerialConfiguration configuration_; // 当前串口和站号配置
std::vector<RegisterAddress> poll_addresses_; // 当前生效的轮询地址
std::vector<RegisterAddress> pending_poll_addresses_; // 请求在途时暂存的新地址
std::vector<RegisterAddress> poll_float32_starts_; // 当前轮询集合中的 Float32 起始地址
std::vector<RegisterAddress> pending_poll_float32_starts_; // 请求在途时暂存的 Float32 起始地址
std::vector<PollBlock> poll_blocks_; // 根据地址合并出的连续读块
std::size_t next_poll_block_ = 0; // 下一次要读取的读块下标
QModbusReply *pending_reply_ = nullptr; // 当前未完成的读请求或恢复探测
QModbusReply *pending_write_reply_ = nullptr; // 当前未完成的单点写请求
bool poll_update_pending_ = false; // 是否有等待请求完成后应用的新轮询集合
bool disconnecting_ = false; // 是否正在执行主动断开
bool serial_session_opened_ = false; // 本次串口会话是否曾经成功打开
bool received_valid_response_ = false; // 本次连接是否收到过有效 PLC 回复
std::uint64_t connection_generation_ = 0; // 连接代次,用于丢弃旧连接的异步回复
PlcConnectionState state_ = PlcConnectionState::Disconnected; // 当前通信状态
bool initial_read_completed_ = false; // 是否已完成首读,可作为真机运行门槛
// 首读阶段每个读块的完成标记,全部为 true 后才允许进入真机
std::vector<bool> initial_blocks_read_; std::vector<bool> initial_blocks_read_;
PlcCommunicationError last_error_type_ = PlcCommunicationError::None;
std::string last_error_;
std::function<void()> state_changed_callback_;
std::function<void(bool)> initial_read_changed_callback_;
std::function<void()> cache_updated_callback_;
std::function<void(const std::string &)> error_reported_callback_;
PlcCommunicationError last_error_type_ = PlcCommunicationError::None; // 最近一次错误类型
std::string last_error_; // 最近一次错误的可读文字
std::function<void()> state_changed_callback_; // 状态变化时调用
std::function<void(bool)> initial_read_changed_callback_; // 首读资格变化时调用
std::function<void()> cache_updated_callback_; // 缓存更新后调用
std::function<void()> poll_cycle_completed_callback_; // 完整轮询一轮后调用
std::function<void(const std::string &)> error_reported_callback_; // 发生错误时调用
}; };

+ 304
- 0
app/src/infrastructure/plc_discovery_service.cpp 查看文件

@@ -0,0 +1,304 @@
#include "plc_discovery_service.h"

#include <QModbusDataUnit>
#include <QModbusPdu>
#include <QModbusReply>
#include <QModbusRtuSerialMaster>
#include <QSerialPort>
#include <QSerialPortInfo>
#include <QTimer>
#include <QVariant>

#include <string>
#include <utility>

namespace {

// 本地 RTU 探测无需沿用正式轮询的长超时和重试,否则多端口搜索会过慢
constexpr int kDiscoveryResponseTimeoutMs = 200;
// 正式连接可能刚被配置窗口断开,先留出时间让操作系统完全释放串口句柄
constexpr int kDiscoveryStartDelayMs = 100;

std::string toUtf8(const QString &value)
{
const QByteArray bytes = value.toUtf8();
return std::string(bytes.constData(), static_cast<std::size_t>(bytes.size()));
}

} // namespace

PlcDiscoveryService::PlcDiscoveryService(QObject *parent)
: QObject(parent),
master_(std::make_unique<QModbusRtuSerialMaster>())
{
connect(
master_.get(), &QModbusClient::stateChanged,
this, &PlcDiscoveryService::handleDeviceStateChanged);
}

PlcDiscoveryService::~PlcDiscoveryService()
{
progress_changed_callback_ = {};
discovery_finished_callback_ = {};
if (master_->state() != QModbusDevice::UnconnectedState)
{
master_->disconnectDevice();
}
}

PlcCommunicationResult PlcDiscoveryService::startDiscovery(
const PlcSerialConfiguration &preferred)
{
if (discovering_)
{
return {false, "PLC 自动搜索正在进行"};
}
PlcSerialConfiguration validation_configuration = preferred;
validation_configuration.portName = "DISCOVERY";
const PlcCommunicationResult validation =
validatePlcSerialConfiguration(validation_configuration);
if (!validation.succeeded)
{
return validation;
}

std::vector<std::string> available_ports;
for (const QSerialPortInfo &port : QSerialPortInfo::availablePorts())
{
available_ports.push_back(toUtf8(port.portName()));
}
candidates_ = buildPlcDiscoveryCandidates(preferred, available_ports);
if (candidates_.empty())
{
return {false, "没有检测到可用串口"};
}
if (master_->state() != QModbusDevice::UnconnectedState)
{
return {false, "自动搜索串口尚未释放,请稍后重试"};
}

current_candidate_ = 0;
pending_reply_.clear();
phase_ = Phase::Idle;
discovering_ = true;
cancel_requested_ = false;
found_pending_ = false;
++attempt_generation_;
QTimer::singleShot(
kDiscoveryStartDelayMs,
this,
&PlcDiscoveryService::tryCurrentCandidate);
return {true, {}};
}

void PlcDiscoveryService::cancelDiscovery()
{
if (!discovering_ || cancel_requested_)
{
return;
}
cancel_requested_ = true;
found_pending_ = false;
++attempt_generation_;
disconnectCurrent(false);
}

bool PlcDiscoveryService::isDiscovering() const
{
return discovering_;
}

void PlcDiscoveryService::setCallbacks(
std::function<void(const PlcDiscoveryProgress &)> progress_changed,
std::function<void(const PlcDiscoveryOutcome &)> discovery_finished)
{
progress_changed_callback_ = std::move(progress_changed);
discovery_finished_callback_ = std::move(discovery_finished);
}

void PlcDiscoveryService::tryCurrentCandidate()
{
if (!discovering_)
{
return;
}
if (cancel_requested_)
{
disconnectCurrent(false);
return;
}
if (current_candidate_ >= candidates_.size())
{
finishDiscovery({
false, false, {},
"未找到可响应的 PLC,请检查站号、接线和 PLC RTU 设置"});
return;
}

const PlcSerialConfiguration &candidate = candidates_.at(current_candidate_);
if (progress_changed_callback_)
{
progress_changed_callback_({
candidate, current_candidate_ + 1U, candidates_.size()});
}
++attempt_generation_;
phase_ = Phase::Connecting;
master_->setConnectionParameter(
QModbusDevice::SerialPortNameParameter,
QString::fromUtf8(candidate.portName.c_str()));
master_->setConnectionParameter(
QModbusDevice::SerialBaudRateParameter, candidate.baudRate);
master_->setConnectionParameter(
QModbusDevice::SerialDataBitsParameter,
static_cast<QSerialPort::DataBits>(candidate.dataBits));
master_->setConnectionParameter(
QModbusDevice::SerialParityParameter,
static_cast<QSerialPort::Parity>(candidate.parity));
master_->setConnectionParameter(
QModbusDevice::SerialStopBitsParameter,
static_cast<QSerialPort::StopBits>(candidate.stopBits));
master_->setTimeout(kDiscoveryResponseTimeoutMs);
master_->setNumberOfRetries(0);
if (!master_->connectDevice())
{
QTimer::singleShot(
0, this,
[this]
{
if (discovering_ && phase_ == Phase::Connecting)
{
disconnectCurrent(false);
}
});
}
}

void PlcDiscoveryService::sendProbe()
{
if (!discovering_ || cancel_requested_
|| current_candidate_ >= candidates_.size())
{
disconnectCurrent(false);
return;
}
phase_ = Phase::Probing;
const PlcSerialConfiguration &candidate = candidates_.at(current_candidate_);
const QModbusDataUnit request(
QModbusDataUnit::HoldingRegisters, 0, 1);
QModbusReply *reply = master_->sendReadRequest(
request, candidate.serverAddress);
if (reply == nullptr)
{
disconnectCurrent(false);
return;
}
pending_reply_ = reply;
const std::uint64_t generation = attempt_generation_;
connect(
reply, &QModbusReply::finished,
this,
[this, reply, generation]
{
handleProbeFinished(reply, generation);
});
}

void PlcDiscoveryService::handleProbeFinished(
QModbusReply *reply,
std::uint64_t attempt_generation)
{
const bool is_current_attempt = discovering_
&& !cancel_requested_
&& phase_ == Phase::Probing
&& attempt_generation == attempt_generation_;
if (pending_reply_ == reply)
{
pending_reply_.clear();
}
const bool valid_response = reply->error() == QModbusDevice::NoError
|| reply->rawResult().isException();
reply->deleteLater();
if (is_current_attempt)
{
disconnectCurrent(valid_response);
}
}

void PlcDiscoveryService::disconnectCurrent(bool found)
{
if (!discovering_)
{
return;
}
found_pending_ = found && !cancel_requested_;
phase_ = Phase::Disconnecting;
if (master_->state() == QModbusDevice::UnconnectedState)
{
QTimer::singleShot(0, this, &PlcDiscoveryService::continueAfterDisconnect);
return;
}
master_->disconnectDevice();
}

void PlcDiscoveryService::continueAfterDisconnect()
{
if (!discovering_ || phase_ != Phase::Disconnecting)
{
return;
}
if (cancel_requested_)
{
finishDiscovery({false, true, {}, {}});
return;
}
if (found_pending_)
{
finishDiscovery({
true, false, candidates_.at(current_candidate_),
"已找到 PLC,正在连接"});
return;
}
++current_candidate_;
phase_ = Phase::Idle;
QTimer::singleShot(0, this, &PlcDiscoveryService::tryCurrentCandidate);
}

void PlcDiscoveryService::finishDiscovery(const PlcDiscoveryOutcome &outcome)
{
discovering_ = false;
cancel_requested_ = false;
found_pending_ = false;
pending_reply_.clear();
phase_ = Phase::Idle;
++attempt_generation_;
if (discovery_finished_callback_)
{
discovery_finished_callback_(outcome);
}
candidates_.clear();
current_candidate_ = 0;
}

void PlcDiscoveryService::handleDeviceStateChanged(QModbusDevice::State state)
{
if (!discovering_)
{
return;
}
if (state == QModbusDevice::ConnectedState && phase_ == Phase::Connecting)
{
sendProbe();
return;
}
if (state == QModbusDevice::UnconnectedState)
{
if (phase_ == Phase::Disconnecting)
{
QTimer::singleShot(0, this, &PlcDiscoveryService::continueAfterDisconnect);
}
else if (phase_ == Phase::Connecting || phase_ == Phase::Probing)
{
disconnectCurrent(false);
}
}
}

+ 68
- 0
app/src/infrastructure/plc_discovery_service.h 查看文件

@@ -0,0 +1,68 @@
#pragma once

#include "services/plc_discovery_gateway.h"

#include <QModbusDevice>
#include <QObject>
#include <QPointer>

#include <cstddef>
#include <cstdint>
#include <functional>
#include <memory>
#include <vector>

class QModbusReply;
class QModbusRtuSerialMaster;

// 使用独立 Qt Modbus RTU 主站逐项探测串口参数,不污染正式 PLC 连接状态
class PlcDiscoveryService final : public QObject, public PlcDiscoveryGateway
{
public:
explicit PlcDiscoveryService(QObject *parent = nullptr);
~PlcDiscoveryService() override;

PlcCommunicationResult startDiscovery(
const PlcSerialConfiguration &preferred) override;
void cancelDiscovery() override;
bool isDiscovering() const override;
void setCallbacks(
std::function<void(const PlcDiscoveryProgress &)> progress_changed,
std::function<void(const PlcDiscoveryOutcome &)> discovery_finished) override;

private:
enum class Phase
{
Idle,
Connecting,
Probing,
Disconnecting
};

// 尝试当前候选参数并等待串口异步打开
void tryCurrentCandidate();
// 串口打开后发送只读 D0 探测请求
void sendProbe();
// 处理当前 D0 探测结果
void handleProbeFinished(QModbusReply *reply, std::uint64_t attempt_generation);
// 释放当前串口,释放完成后进入下一个候选或报告结果
void disconnectCurrent(bool found);
// 当前串口完全释放后完成状态迁移
void continueAfterDisconnect();
// 报告最终结果并清理搜索状态
void finishDiscovery(const PlcDiscoveryOutcome &outcome);
// 处理 Qt Modbus 主站连接状态变化
void handleDeviceStateChanged(QModbusDevice::State state);

std::unique_ptr<QModbusRtuSerialMaster> master_;
std::vector<PlcSerialConfiguration> candidates_;
std::size_t current_candidate_ = 0;
QPointer<QModbusReply> pending_reply_;
Phase phase_ = Phase::Idle;
bool discovering_ = false;
bool cancel_requested_ = false;
bool found_pending_ = false;
std::uint64_t attempt_generation_ = 0;
std::function<void(const PlcDiscoveryProgress &)> progress_changed_callback_;
std::function<void(const PlcDiscoveryOutcome &)> discovery_finished_callback_;
};

+ 24
- 1
app/src/infrastructure/plc_register_repository.cpp 查看文件

@@ -69,12 +69,35 @@ RegisterWriteResult PlcRegisterRepository::writeWord(
: RegisterWriteResult{false, RegisterError::Unavailable}; : RegisterWriteResult{false, RegisterError::Unavailable};
} }


WordPairReadResult PlcRegisterRepository::readWordPair(
const RegisterAddress &address) const
{
return RegisterRepository::readWordPair(address);
}

RegisterWriteResult PlcRegisterRepository::writeWordPair(
const RegisterAddress &address,
const std::array<std::int16_t, 2> &values)
{
if (!address.isValid() || address.area() != RegisterArea::D
|| address.index() >= RegisterAddress::kMaximumIndex)
{
return {false, address.isValid() && address.area() != RegisterArea::D
? RegisterError::AreaMismatch : RegisterError::InvalidAddress};
}
return word_pair_handler_ ? word_pair_handler_(address, values)
: RegisterWriteResult{false, RegisterError::Unavailable};
}

void PlcRegisterRepository::setWriteHandlers( void PlcRegisterRepository::setWriteHandlers(
std::function<RegisterWriteResult(const RegisterAddress &, bool)> bit_handler, std::function<RegisterWriteResult(const RegisterAddress &, bool)> bit_handler,
std::function<RegisterWriteResult(const RegisterAddress &, std::int16_t)> word_handler)
std::function<RegisterWriteResult(const RegisterAddress &, std::int16_t)> word_handler,
std::function<RegisterWriteResult(
const RegisterAddress &, const std::array<std::int16_t, 2> &)> word_pair_handler)
{ {
bit_handler_ = std::move(bit_handler); bit_handler_ = std::move(bit_handler);
word_handler_ = std::move(word_handler); word_handler_ = std::move(word_handler);
word_pair_handler_ = std::move(word_pair_handler);
} }


void PlcRegisterRepository::updateBit(int address, bool value) void PlcRegisterRepository::updateBit(int address, bool value)


+ 22
- 7
app/src/infrastructure/plc_register_repository.h 查看文件

@@ -5,6 +5,8 @@
#include <array> #include <array>
#include <functional> #include <functional>


// 保存 PLC 最近一次成功读回的 M/D 缓存,并把写请求转交给通信服务
// 未读到过的地址保持 Unavailable,避免界面显示伪造的初始值
class PlcRegisterRepository final : public RegisterRepository class PlcRegisterRepository final : public RegisterRepository
{ {
public: public:
@@ -15,22 +17,35 @@ public:
WordReadResult readWord(const RegisterAddress &address) const override; WordReadResult readWord(const RegisterAddress &address) const override;
RegisterWriteResult writeWord( RegisterWriteResult writeWord(
const RegisterAddress &address, std::int16_t value) override; const RegisterAddress &address, std::int16_t value) override;
WordPairReadResult readWordPair(const RegisterAddress &address) const override;
RegisterWriteResult writeWordPair(
const RegisterAddress &address,
const std::array<std::int16_t, 2> &values) override;


// 注入异步写入回调;回调成功不代表缓存已经更新
void setWriteHandlers( void setWriteHandlers(
std::function<RegisterWriteResult(const RegisterAddress &, bool)> bit_handler, std::function<RegisterWriteResult(const RegisterAddress &, bool)> bit_handler,
std::function<RegisterWriteResult(const RegisterAddress &, std::int16_t)> word_handler);
std::function<RegisterWriteResult(const RegisterAddress &, std::int16_t)> word_handler,
std::function<RegisterWriteResult(
const RegisterAddress &, const std::array<std::int16_t, 2> &)> word_pair_handler = {});
// 由通信服务在读回成功后更新缓存并标记地址有效
void updateBit(int address, bool value); void updateBit(int address, bool value);
void updateWord(int address, std::int16_t value); void updateWord(int address, std::int16_t value);
// 通信会话失效时清除“已读”标记,但保留数组内的旧值用于诊断
void invalidate(); void invalidate();
// 判断是否至少收到过一个有效 M/D 值
bool hasAnyValidValue() const; bool hasAnyValidValue() const;


private: private:
// M/D 区共用的数组长度,覆盖 0 到最大地址
static constexpr std::size_t kRegisterCount = static constexpr std::size_t kRegisterCount =
static_cast<std::size_t>(RegisterAddress::kMaximumIndex + 1); static_cast<std::size_t>(RegisterAddress::kMaximumIndex + 1);
std::array<bool, kRegisterCount> bits_{};
std::array<std::int16_t, kRegisterCount> words_{};
std::array<bool, kRegisterCount> valid_bits_{};
std::array<bool, kRegisterCount> valid_words_{};
std::function<RegisterWriteResult(const RegisterAddress &, bool)> bit_handler_;
std::function<RegisterWriteResult(const RegisterAddress &, std::int16_t)> word_handler_;
std::array<bool, kRegisterCount> bits_{}; // 最近一次读到的 M 区位值
std::array<std::int16_t, kRegisterCount> words_{}; // 最近一次读到的 D 区字值
std::array<bool, kRegisterCount> valid_bits_{}; // 对应 M 地址是否读到过有效值
std::array<bool, kRegisterCount> valid_words_{}; // 对应 D 地址是否读到过有效值
std::function<RegisterWriteResult(const RegisterAddress &, bool)> bit_handler_; // M 区异步写入回调
std::function<RegisterWriteResult(const RegisterAddress &, std::int16_t)> word_handler_; // D 区异步写入回调
std::function<RegisterWriteResult(
const RegisterAddress &, const std::array<std::int16_t, 2> &)> word_pair_handler_;
}; };

+ 127
- 9
app/src/main.cpp 查看文件

@@ -7,11 +7,18 @@
*/ */


#include <QApplication> #include <QApplication>
#include <QFile>
#include <QFileInfo>
#include <QMessageBox>
#include <QDir>


#include "infrastructure/json_project_storage.h" #include "infrastructure/json_project_storage.h"
#include "infrastructure/application_settings_loader.h"
#include "infrastructure/plc_communication_service.h" #include "infrastructure/plc_communication_service.h"
#include "infrastructure/plc_discovery_service.h"
#include "infrastructure/plc_register_repository.h" #include "infrastructure/plc_register_repository.h"
#include "domain/active_register_repository.h" #include "domain/active_register_repository.h"
#include "domain/virtual_register_repository.h"
#include "services/hmi_editor_service.h" #include "services/hmi_editor_service.h"
#include "services/hmi_runtime_service.h" #include "services/hmi_runtime_service.h"
#include "services/alarm_editor_service.h" #include "services/alarm_editor_service.h"
@@ -19,42 +26,146 @@
#include "services/hmi_navigation_service.h" #include "services/hmi_navigation_service.h"
#include "services/logic_editor_service.h" #include "services/logic_editor_service.h"
#include "services/offline_simulation_service.h" #include "services/offline_simulation_service.h"
#include "services/online_logic_monitor_service.h"
#include "services/project_service.h" #include "services/project_service.h"
#include "services/runtime_mode_service.h" #include "services/runtime_mode_service.h"
#include "services/register_monitor_service.h" #include "services/register_monitor_service.h"
#include "services/register_comment_service.h" #include "services/register_comment_service.h"
#include "ui/main_window.h" #include "ui/main_window.h"


// 程序从这里开始,argc 和 argv 保存用户启动程序时附带的命令行参数
int main(int argc, char *argv[]) int main(int argc, char *argv[])
{ {
// Windows 默认会给对话框添加上下文帮助按钮,应用内统一隐藏
QApplication::setAttribute(Qt::AA_DisableWindowContextHelpButton);
// 创建整个 Qt 应用,后面的窗口、按钮和消息循环都要依靠它
QApplication application(argc, argv); QApplication application(argc, argv);
// 设置程序名称,Qt 的窗口信息和配置中会用到这个名字
application.setApplicationName(QObject::tr("综合平台编程器")); application.setApplicationName(QObject::tr("综合平台编程器"));
// 设置组织名称,用来区分这个程序保存的 Qt 配置
application.setOrganizationName(QStringLiteral("QtProXinJe")); application.setOrganizationName(QStringLiteral("QtProXinJe"));


// 组合根负责创建具体实现并将抽象依赖注入服务和 UI
JsonProjectStorage project_storage;
ProjectService project_service(project_storage);
HmiEditorService hmi_editor_service(project_service);
QString runtime_project_path;
const QStringList arguments = application.arguments();
const int project_option = arguments.indexOf(QStringLiteral("--runtime-project"));
if (project_option >= 0 && project_option + 1 < arguments.size())
{
runtime_project_path = arguments.at(project_option + 1);
}
const QFileInfo executable_info(application.applicationFilePath());
const QString bundled_project = executable_info.absolutePath()
+ QStringLiteral("/runtime-project.json");
if (runtime_project_path.isEmpty()
&& executable_info.completeBaseName() != QStringLiteral("integrated_platform")
&& QFileInfo::exists(bundled_project))
{
runtime_project_path = bundled_project;
}
const bool embedded_runtime_project = QFile::exists(
QStringLiteral(":/runtime-project.json"));
const bool user_runtime_mode = !runtime_project_path.isEmpty()
|| embedded_runtime_project;

const ApplicationSettingsLoadResult application_settings =
ApplicationSettingsLoader::load(
ApplicationSettingsLoader::defaultFilePath());

// 负责把工程保存到 JSON 文件,也负责从 JSON 文件读取工程
JsonProjectStorage project_storage(application_settings.settings.projectLimits);
// 统一管理新建、打开、保存和校验工程,实际读写文件交给上面的对象
ProjectService project_service(
project_storage, application_settings.settings.projectLimits);
if (user_runtime_mode)
{
ProjectOperationResult result;
if (embedded_runtime_project && runtime_project_path.isEmpty())
{
const QByteArray bytes = []
{
QFile resource(QStringLiteral(":/runtime-project.json"));
return resource.open(QIODevice::ReadOnly)
? resource.readAll() : QByteArray{};
}();
const QString temporary_path = QDir::tempPath()
+ QStringLiteral("/qtproxinje-runtime-project.json");
QFile temporary_file(temporary_path);
if (bytes.isEmpty() || !temporary_file.open(QIODevice::WriteOnly)
|| temporary_file.write(bytes) != bytes.size())
{
QMessageBox::critical(
nullptr,
QObject::tr("运行程序启动失败"),
QObject::tr("无法读取内嵌项目资源"));
return 2;
}
temporary_file.close();
result = project_service.load(temporary_path.toUtf8().toStdString());
QFile::remove(temporary_path);
}
else
{
result = project_service.load(
runtime_project_path.toUtf8().toStdString());
}
if (!result.succeeded)
{
QMessageBox::critical(
nullptr,
QObject::tr("运行程序启动失败"),
QObject::tr("无法加载项目:%1")
.arg(QString::fromUtf8(result.message.c_str())));
return 2;
}
}
// 处理 HMI 页面和控件的添加、修改、删除、撤销与重做
HmiEditorService hmi_editor_service(
project_service, application_settings.settings.hmiDefaults);
// 处理运行画面在不同 HMI 页面之间的跳转
HmiNavigationService hmi_navigation_service(project_service); HmiNavigationService hmi_navigation_service(project_service);
// 处理梯形图网络和节点的编辑、校验、撤销与重做
LogicEditorService logic_editor_service(project_service); LogicEditorService logic_editor_service(project_service);
// 当前离线模式使用内存仓库,后续真机模式替换为 PLC 缓存实现

// 在内存中保存离线仿真使用的 M、D 寄存器值,不会写入真实 PLC
VirtualRegisterRepository virtual_register_repository; VirtualRegisterRepository virtual_register_repository;
// 保存编辑态确认的离线初始值,不参与仿真扫描和工程持久化
VirtualRegisterRepository offline_initial_repository;
// 保存从真实 PLC 最近一次读取到的 M、D 值,并转交写入请求
PlcRegisterRepository plc_register_repository; PlcRegisterRepository plc_register_repository;
// 给界面提供统一的寄存器入口,启动时先使用上面的离线寄存器
ActiveRegisterRepository active_register_repository(virtual_register_repository); ActiveRegisterRepository active_register_repository(virtual_register_repository);
// 负责通过串口异步连接、读取和写入真实 PLC
PlcCommunicationService plc_communication_service(plc_register_repository); PlcCommunicationService plc_communication_service(plc_register_repository);
// 使用独立串口会话自动搜索 PLC,不影响正式通信状态机
PlcDiscoveryService plc_discovery_service;
// 处理运行画面读取和写入寄存器,数据来自当前选中的寄存器入口
HmiRuntimeService hmi_runtime_service(active_register_repository); HmiRuntimeService hmi_runtime_service(active_register_repository);

// 处理报警规则的添加、修改和删除
AlarmEditorService alarm_editor_service(project_service); AlarmEditorService alarm_editor_service(project_service);
// 根据当前寄存器值判断报警是否触发,并处理报警确认
AlarmService alarm_service(project_service, active_register_repository); AlarmService alarm_service(project_service, active_register_repository);
// 管理工程中 M、D 地址对应的文字备注
RegisterCommentService register_comment_service(project_service); RegisterCommentService register_comment_service(project_service);
// 处理自由监控列表中的寄存器读取和单点写入
RegisterMonitorService register_monitor_service(active_register_repository); RegisterMonitorService register_monitor_service(active_register_repository);
OfflineSimulationService offline_simulation_service(virtual_register_repository);
register_monitor_service.setOfflineInitialRepository(offline_initial_repository);
register_monitor_service.setOfflineInitialCaptureEnabled(true);
// 用虚拟寄存器执行本地梯形图,实现不连接 PLC 的离线仿真
OfflineSimulationService offline_simulation_service(
virtual_register_repository, &offline_initial_repository);
// 从 PLC 缓存推算本地梯形图轨迹,输出只写内部临时仓库
OnlineLogicMonitorService online_logic_monitor_service(plc_register_repository);
// 管理三种运行模式,并控制离线仿真和真机只读轨迹的生命周期
RuntimeModeService runtime_mode_service( RuntimeModeService runtime_mode_service(
project_service, offline_simulation_service);
project_service, offline_simulation_service, online_logic_monitor_service);
// 把 PLC 通信和两套寄存器接入运行模式,切换模式时才能切换数据来源
runtime_mode_service.configurePlc( runtime_mode_service.configurePlc(
plc_communication_service, plc_communication_service,
active_register_repository, active_register_repository,
virtual_register_repository, virtual_register_repository,
plc_register_repository); plc_register_repository);

// 创建主窗口,并把界面操作需要的各项服务交给它使用
MainWindow main_window( MainWindow main_window(
runtime_mode_service, runtime_mode_service,
project_service, project_service,
@@ -65,8 +176,15 @@ int main(int argc, char *argv[])
alarm_service, alarm_service,
hmi_navigation_service, hmi_navigation_service,
register_comment_service, register_comment_service,
register_monitor_service);
main_window.show();
register_monitor_service,
plc_discovery_service,
application_settings,
user_runtime_mode);
if (!user_runtime_mode)
{
main_window.show();
}


// 启动 Qt 消息循环,持续响应鼠标、键盘、定时器和串口事件,窗口关闭后才返回
return application.exec(); return application.exec();
} }

+ 6
- 2
app/src/services/alarm_editor_service.cpp 查看文件

@@ -37,9 +37,13 @@ const AlarmDefinition *AlarmEditorService::findDefinition(
AlarmEditorResult AlarmEditorService::addDefinition( AlarmEditorResult AlarmEditorService::addDefinition(
const AlarmDefinition &definition) const AlarmDefinition &definition)
{ {
if (definitions().size() >= ProjectLimits::kMaximumAlarmDefinitions)
const std::size_t maximum =
project_service_.projectLimits().maximumAlarmDefinitions;
if (definitions().size() >= maximum)
{ {
return failure(AlarmEditorError::InvalidDefinition, "单个工程最多包含 512 条报警定义");
return failure(
AlarmEditorError::InvalidDefinition,
"当前配置最多允许 " + std::to_string(maximum) + " 条报警定义");
} }
AlarmDefinition candidate = definition; AlarmDefinition candidate = definition;
candidate.id = makeUniqueId(); candidate.id = makeUniqueId();


+ 8
- 0
app/src/services/alarm_editor_service.h 查看文件

@@ -7,6 +7,7 @@


class ProjectService; class ProjectService;


// 报警编辑操作的失败分类
enum class AlarmEditorError enum class AlarmEditorError
{ {
None, None,
@@ -15,6 +16,7 @@ enum class AlarmEditorError
InvalidDefinition InvalidDefinition
}; };


// 报警编辑结果;成功时 id 是新增或更新后的定义 ID
struct AlarmEditorResult struct AlarmEditorResult
{ {
bool succeeded = false; bool succeeded = false;
@@ -23,16 +25,22 @@ struct AlarmEditorResult
std::string id; std::string id;
}; };


// 负责报警定义的增删改,不负责读取实时寄存器
class AlarmEditorService class AlarmEditorService
{ {
public: public:
explicit AlarmEditorService(ProjectService &project_service); explicit AlarmEditorService(ProjectService &project_service);


// 返回当前工程中的报警定义
const std::vector<AlarmDefinition> &definitions() const; const std::vector<AlarmDefinition> &definitions() const;
// 按稳定 ID 查找报警定义
const AlarmDefinition *findDefinition(const std::string &id) const; const AlarmDefinition *findDefinition(const std::string &id) const;
// 添加定义;失败时工程保持不变
AlarmEditorResult addDefinition(const AlarmDefinition &definition); AlarmEditorResult addDefinition(const AlarmDefinition &definition);
// 更新指定 ID;definition.id 会被服务统一为传入的 id
AlarmEditorResult updateDefinition( AlarmEditorResult updateDefinition(
const std::string &id, const AlarmDefinition &definition); const std::string &id, const AlarmDefinition &definition);
// 删除指定报警定义
AlarmEditorResult removeDefinition(const std::string &id); AlarmEditorResult removeDefinition(const std::string &id);


private: private:


+ 11
- 0
app/src/services/alarm_service.cpp 查看文件

@@ -16,15 +16,18 @@ AlarmService::AlarmService(


void AlarmService::refresh() void AlarmService::refresh()
{ {
// 每次刷新只根据当前读回值增删记录,不会把通信失败误判为报警解除
const auto now = std::chrono::system_clock::now(); const auto now = std::chrono::system_clock::now();
for (const AlarmDefinition &definition for (const AlarmDefinition &definition
: project_service_.project().alarmDefinitions) : project_service_.project().alarmDefinitions)
{ {
// 空值表示寄存器读取失败;保留原记录,等待下一轮成功读回
const std::optional<bool> active = evaluate(definition); const std::optional<bool> active = evaluate(definition);
if (!active.has_value()) if (!active.has_value())
{ {
continue; continue;
} }
// 同一报警定义在当前会话中最多保留一条活动记录
const auto current = std::find_if( const auto current = std::find_if(
records_.begin(), records_.end(), records_.begin(), records_.end(),
[&definition](const AlarmRecord &record) [&definition](const AlarmRecord &record)
@@ -33,12 +36,14 @@ void AlarmService::refresh()
}); });
if (*active && current == records_.end()) if (*active && current == records_.end())
{ {
// 首次检测到触发时创建未确认记录,并置于列表头部
records_.insert( records_.insert(
records_.begin(), records_.begin(),
AlarmRecord{definition.id, definition.message, false, now}); AlarmRecord{definition.id, definition.message, false, now});
} }
else if (!*active && current != records_.end()) else if (!*active && current != records_.end())
{ {
// 条件恢复后立即移除对应记录,下一次再次触发会生成新记录
records_.erase(current); records_.erase(current);
} }
} }
@@ -75,21 +80,27 @@ std::optional<bool> AlarmService::evaluate(
{ {
if (definition.condition == AlarmCondition::MOn) if (definition.condition == AlarmCondition::MOn)
{ {
// MOn 报警直接读取 M 位;读取失败返回空值,交由 refresh 保留旧记录
const BitReadResult value = repository_.readBit(definition.address); const BitReadResult value = repository_.readBit(definition.address);
return value.succeeded ? std::optional<bool>{value.value} : std::nullopt; return value.succeeded ? std::optional<bool>{value.value} : std::nullopt;
} }
// DHigh 和 DLow 报警读取 D 字,比较方向由定义中的条件决定
const WordReadResult value = repository_.readWord(definition.address); const WordReadResult value = repository_.readWord(definition.address);
if (!value.succeeded) if (!value.succeeded)
{ {
// 通信或仓库读取失败不等同于报警解除
return std::nullopt; return std::nullopt;
} }
if (definition.condition == AlarmCondition::DHigh) if (definition.condition == AlarmCondition::DHigh)
{ {
// 达到阈值即触发高限报警
return value.value >= definition.threshold; return value.value >= definition.threshold;
} }
if (definition.condition == AlarmCondition::DLow) if (definition.condition == AlarmCondition::DLow)
{ {
// 降到阈值即触发低限报警
return value.value <= definition.threshold; return value.value <= definition.threshold;
} }
// 防御未来新增但尚未实现的报警条件
return std::nullopt; return std::nullopt;
} }

+ 43
- 7
app/src/services/alarm_service.h 查看文件

@@ -9,30 +9,66 @@ class ProjectService;
class RegisterRepository; class RegisterRepository;
struct AlarmDefinition; struct AlarmDefinition;


// 运行时产生的报警记录,只存在当前会话,不会写入工程 JSON
struct AlarmRecord struct AlarmRecord
{ {
std::string definitionId;
std::string message;
bool acknowledged = false;
std::chrono::system_clock::time_point occurredAt;
std::string definitionId; // 对应 AlarmDefinition 的稳定 ID
std::string message; // 触发时复制的报警提示文本
bool acknowledged = false; // 当前记录是否已被操作员确认
std::chrono::system_clock::time_point occurredAt; // 本次触发时间
}; };


/**
* @brief 根据当前活动寄存器仓库评估报警并维护会话记录
*
* 服务只读取仓库,不修改工程中的报警定义;仓库可以由运行模式注入虚拟或 PLC 数据源
*/
class AlarmService class AlarmService
{ {
public: public:
/**
* @brief 创建报警服务
* @param project_service 只读工程服务,提供报警定义集合
* @param repository 当前运行模式使用的寄存器仓库
*/
AlarmService( AlarmService(
const ProjectService &project_service, const ProjectService &project_service,
RegisterRepository &repository); RegisterRepository &repository);


/**
* @brief 重新评估全部报警定义
*
* 仍处于触发状态的记录会保留确认状态;读取失败时跳过该定义并保留原记录。
* 新触发记录插入列表头部,解除触发的记录立即移除
*/
void refresh(); void refresh();
/**
* @brief 确认当前会话中的指定报警记录
* @param definition_id 报警定义的稳定 ID
* @return 找到当前活动记录并完成确认时返回 true,否则返回 false
*/
bool acknowledge(const std::string &definition_id); bool acknowledge(const std::string &definition_id);
/**
* @brief 清空当前运行会话的全部报警记录和确认状态
*/
void reset(); void reset();
/**
* @brief 返回当前会话的报警记录
* @return 按触发时间倒序排列的只读记录列表,列表由服务持有
*/
const std::vector<AlarmRecord> &records() const; const std::vector<AlarmRecord> &records() const;


private: private:
/**
* @brief 使用当前寄存器值评估单条报警定义
* @param definition 待评估的报警定义
* @return true 表示触发,false 表示未触发;读取失败时返回空值
*
* MOn 比较 M 位是否为 1;DHigh 使用大于等于阈值,DLow 使用小于等于阈值
*/
std::optional<bool> evaluate(const AlarmDefinition &definition) const; std::optional<bool> evaluate(const AlarmDefinition &definition) const;


const ProjectService &project_service_;
RegisterRepository &repository_;
std::vector<AlarmRecord> records_;
const ProjectService &project_service_; // 不拥有的只读工程服务
RegisterRepository &repository_; // 当前运行模式的活动寄存器仓库
std::vector<AlarmRecord> records_; // 当前会话记录,最新触发项位于列表头部
}; };

+ 30
- 0
app/src/services/application_settings.h 查看文件

@@ -0,0 +1,30 @@
#pragma once

#include "domain/project_limits.h"
#include "plc_communication_gateway.h"

#include <string>
#include <vector>

// 应用启动后保持只读的用户配置;工程文件不会保存这些机器级偏好
struct ApplicationSettings
{
int version = 1;
ProjectLimitSettings projectLimits;
HmiDefaultSettings hmiDefaults;
PlcSerialConfiguration plcDefaults = []
{
PlcSerialConfiguration configuration;
configuration.portName = "COM3";
return configuration;
}();
};

// 配置加载结果同时携带主界面启动后需要显示的诊断信息
struct ApplicationSettingsLoadResult
{
ApplicationSettings settings;
std::vector<std::string> messages;
bool warningRequired = false;
std::string warningMessage;
};

+ 40
- 4
app/src/services/editor_history.h 查看文件

@@ -8,15 +8,27 @@
/** /**
* @brief 保存有限数量的编辑前快照并提供撤销、重做 * @brief 保存有限数量的编辑前快照并提供撤销、重做
* *
* 历史只存在当前编辑会话内。新编辑会清空重做栈,超过容量时丢弃最早记录。
* State 由具体编辑服务决定,可以是 HMI 页面集合或控制逻辑集合
* 历史只存在当前编辑会话内,不写入工程文件
* 新编辑会清空重做栈,超过容量时丢弃最早记录
*/ */
template <typename State> template <typename State>
class EditorHistory final class EditorHistory final
{ {
public: public:
static constexpr std::size_t kMaximumEntries = 100U;
// 单个编辑服务最多保留 30 条撤销和重做历史
static constexpr std::size_t kMaximumEntries = 30U;


template <typename Equal> template <typename Equal>
/**
* @brief 记录一次编辑前状态
* @param before 编辑前状态
* @param after 编辑完成后的当前状态
* @param equal 比较前后状态是否相同的可调用对象
*
* 状态没有变化时不创建空历史
* 新编辑成功记录后清空重做栈
*/
void record(State before, const State &after, Equal equal) void record(State before, const State &after, Equal equal)
{ {
if (equal(before, after)) if (equal(before, after))
@@ -28,6 +40,13 @@ public:
redo_states_.clear(); redo_states_.clear();
} }


/**
* @brief 撤销最近一次编辑
* @param current 当前状态
* @return 撤销后的目标状态;没有可撤销历史时返回空值
*
* 当前状态会先进入重做栈,之后取出撤销栈顶的编辑前快照
*/
std::optional<State> undo(State current) std::optional<State> undo(State current)
{ {
if (undo_states_.empty()) if (undo_states_.empty())
@@ -41,6 +60,13 @@ public:
return target; return target;
} }


/**
* @brief 重做最近一次被撤销的编辑
* @param current 当前状态
* @return 重做后的目标状态;没有可重做历史时返回空值
*
* 当前状态会先放回撤销栈,之后取出重做栈顶的目标状态
*/
std::optional<State> redo(State current) std::optional<State> redo(State current)
{ {
if (redo_states_.empty()) if (redo_states_.empty())
@@ -54,23 +80,33 @@ public:
return target; return target;
} }


/**
* @brief 清空撤销栈和重做栈
*/
void clear() void clear()
{ {
undo_states_.clear(); undo_states_.clear();
redo_states_.clear(); redo_states_.clear();
} }


/**
* @brief 判断是否存在可撤销历史
*/
bool canUndo() const bool canUndo() const
{ {
return !undo_states_.empty(); return !undo_states_.empty();
} }


/**
* @brief 判断是否存在可重做历史
*/
bool canRedo() const bool canRedo() const
{ {
return !redo_states_.empty(); return !redo_states_.empty();
} }


private: private:
// 保持历史不超过上限,超出时从最早的记录开始丢弃
static void trim(std::vector<State> *states) static void trim(std::vector<State> *states)
{ {
while (states->size() > kMaximumEntries) while (states->size() > kMaximumEntries)
@@ -79,6 +115,6 @@ private:
} }
} }


std::vector<State> undo_states_;
std::vector<State> redo_states_;
std::vector<State> undo_states_; // 编辑前快照栈,末尾是下一次撤销要恢复的状态
std::vector<State> redo_states_; // 撤销后状态栈,末尾是下一次重做要恢复的状态
}; };

+ 148
- 21
app/src/services/hmi_editor_service.cpp 查看文件

@@ -51,10 +51,23 @@ std::string makeUniquePageId(const Project &project)
} }
} }


std::size_t totalControlCount(const Project &project)
{
std::size_t total = 0U;
for (const HmiPage &page : project.hmiPages)
{
total += page.controls.size();
}
return total;
}

} // namespace } // namespace


HmiEditorService::HmiEditorService(ProjectService &project_service)
: project_service_(project_service)
HmiEditorService::HmiEditorService(
ProjectService &project_service,
const HmiDefaultSettings &hmi_defaults)
: project_service_(project_service),
hmi_defaults_(hmi_defaults)
{ {
} }


@@ -110,12 +123,6 @@ bool HmiEditorService::pagesEqual(
bool HmiEditorService::controlsEqual( bool HmiEditorService::controlsEqual(
const HmiControl &left, const HmiControl &right) const HmiControl &left, const HmiControl &right)
{ {
const bool progress_equal = left.progressBar.has_value()
== right.progressBar.has_value()
&& (!left.progressBar.has_value()
|| (left.progressBar->minimumValue == right.progressBar->minimumValue
&& left.progressBar->maximumValue == right.progressBar->maximumValue
&& left.progressBar->showValue == right.progressBar->showValue));
const bool page_jump_equal = left.pageJump.has_value() == right.pageJump.has_value() const bool page_jump_equal = left.pageJump.has_value() == right.pageJump.has_value()
&& (!left.pageJump.has_value() && (!left.pageJump.has_value()
|| left.pageJump->targetPageId == right.pageJump->targetPageId); || left.pageJump->targetPageId == right.pageJump->targetPageId);
@@ -127,10 +134,10 @@ bool HmiEditorService::controlsEqual(
&& left.bounds.height == right.bounds.height && left.bounds.height == right.bounds.height
&& left.text == right.text && left.text == right.text
&& left.binding == right.binding && left.binding == right.binding
&& left.dataType == right.dataType
&& left.properties == right.properties && left.properties == right.properties
&& left.buttonOperation == right.buttonOperation && left.buttonOperation == right.buttonOperation
&& page_jump_equal
&& progress_equal;
&& page_jump_equal;
} }


HmiEditorResult HmiEditorService::historyFailure(const std::string &message) HmiEditorResult HmiEditorService::historyFailure(const std::string &message)
@@ -196,6 +203,8 @@ HmiEditorResult HmiEditorService::ensureDefaultPage()
HmiPage page; HmiPage page;
page.id = "page-1"; page.id = "page-1";
page.name = "主操作页面"; page.name = "主操作页面";
page.width = hmi_defaults_.pageWidth;
page.height = hmi_defaults_.pageHeight;


Project &project = project_service_.editProject(); Project &project = project_service_.editProject();
project.hmiPages.push_back(std::move(page)); project.hmiPages.push_back(std::move(page));
@@ -211,13 +220,17 @@ HmiEditorResult HmiEditorService::addPage(const std::string &name)
return failure(HmiEditorError::InvalidPage, "HMI 页面名称不能为空"); return failure(HmiEditorError::InvalidPage, "HMI 页面名称不能为空");
} }
const Project &current = project_service_.project(); const Project &current = project_service_.project();
if (current.hmiPages.size() >= ProjectLimits::kMaximumHmiPages)
const ProjectLimitSettings &limits = project_service_.projectLimits();
if (current.hmiPages.size() >= limits.maximumHmiPages)
{ {
return failure(HmiEditorError::InvalidPage, "单个工程最多包含 128 个 HMI 页面");
return failure(
HmiEditorError::InvalidPage,
"当前配置最多允许 "
+ std::to_string(limits.maximumHmiPages) + " 个 HMI 页面");
} }
if (name.size() > ProjectLimits::kMaximumTextBytes) if (name.size() > ProjectLimits::kMaximumTextBytes)
{ {
return failure(HmiEditorError::InvalidPage, "HMI 页面名称不能超过 4096 个 UTF-8 字节");
return failure(HmiEditorError::InvalidPage, "HMI 页面名称不能超过 256 个 UTF-8 字节");
} }
const bool duplicate = std::any_of( const bool duplicate = std::any_of(
current.hmiPages.cbegin(), current.hmiPages.cend(), current.hmiPages.cbegin(), current.hmiPages.cend(),
@@ -230,6 +243,8 @@ HmiEditorResult HmiEditorService::addPage(const std::string &name)
HmiPage page; HmiPage page;
page.id = makeUniquePageId(current); page.id = makeUniquePageId(current);
page.name = name; page.name = name;
page.width = hmi_defaults_.pageWidth;
page.height = hmi_defaults_.pageHeight;
HistoryState before = captureState(); HistoryState before = captureState();
Project &project = project_service_.editProject(); Project &project = project_service_.editProject();
project.hmiPages.push_back(std::move(page)); project.hmiPages.push_back(std::move(page));
@@ -259,7 +274,7 @@ HmiEditorResult HmiEditorService::resizePage(
candidate.width = width; candidate.width = width;
candidate.height = height; candidate.height = height;
std::string error; std::string error;
if (!candidate.validate(&error))
if (!candidate.validate(project_service_.projectLimits(), &error))
{ {
return failure(HmiEditorError::InvalidPage, error); return failure(HmiEditorError::InvalidPage, error);
} }
@@ -284,7 +299,7 @@ HmiEditorResult HmiEditorService::renamePage(
} }
if (name.size() > ProjectLimits::kMaximumTextBytes) if (name.size() > ProjectLimits::kMaximumTextBytes)
{ {
return failure(HmiEditorError::InvalidPage, "HMI 页面名称不能超过 4096 个 UTF-8 字节");
return failure(HmiEditorError::InvalidPage, "HMI 页面名称不能超过 256 个 UTF-8 字节");
} }
const Project &current = project_service_.project(); const Project &current = project_service_.project();
const auto existing = std::find_if( const auto existing = std::find_if(
@@ -423,9 +438,18 @@ HmiEditorResult HmiEditorService::addControl(
{ {
return failure(HmiEditorError::PageNotFound, "未找到 HMI 页面"); return failure(HmiEditorError::PageNotFound, "未找到 HMI 页面");
} }
if (page->controls.size() >= ProjectLimits::kMaximumHmiControlsPerPage)
const ProjectLimitSettings &limits = project_service_.projectLimits();
if (page->controls.size() >= limits.maximumHmiControlsPerPage)
{ {
return failure(HmiEditorError::InvalidControl, "单个 HMI 页面最多包含 512 个控件");
return failure(
HmiEditorError::InvalidControl,
"当前配置下单个 HMI 页面最多允许 "
+ std::to_string(limits.maximumHmiControlsPerPage) + " 个控件");
}
if (totalControlCount(project_service_.project())
>= ProjectLimits::kMaximumHmiControlsPerProject)
{
return failure(HmiEditorError::InvalidControl, "单个工程最多包含 2048 个 HMI 控件");
} }
const HmiControlDescriptor *descriptor = findHmiControlDescriptor(type); const HmiControlDescriptor *descriptor = findHmiControlDescriptor(type);
if (descriptor == nullptr) if (descriptor == nullptr)
@@ -526,6 +550,7 @@ HmiEditorResult HmiEditorService::moveControl(
HmiControl candidate = *control; HmiControl candidate = *control;
candidate.bounds = bounds; candidate.bounds = bounds;
// 更新前保留原控件,只有全部编辑规则通过才覆盖原值 // 更新前保留原控件,只有全部编辑规则通过才覆盖原值
// 属性更新是整体替换:类型不变,但绑定、外观和扩展属性必须一起通过校验
std::string error; std::string error;
if (!validateEditableControl(*page, candidate, &error)) if (!validateEditableControl(*page, candidate, &error))
{ {
@@ -600,6 +625,112 @@ HmiEditorResult HmiEditorService::updateControl(
return {true, HmiEditorError::None, {}, control.id}; return {true, HmiEditorError::None, {}, control.id};
} }


HmiEditorResult HmiEditorService::pasteControls(
const std::string &page_id,
const std::vector<HmiControl> &controls,
int offset_x,
int offset_y)
{
const HmiPage *existing_page = findPage(page_id);
if (existing_page == nullptr)
{
return failure(HmiEditorError::PageNotFound, "未找到 HMI 页面");
}
if (controls.empty())
{
return failure(HmiEditorError::InvalidOperation, "请先复制 HMI 控件");
}
const ProjectLimitSettings &limits = project_service_.projectLimits();
if (controls.size() > limits.maximumHmiControlsPerPage
|| existing_page->controls.size() + controls.size()
> limits.maximumHmiControlsPerPage)
{
return failure(
HmiEditorError::InvalidControl,
"当前配置下单个 HMI 页面最多允许 "
+ std::to_string(limits.maximumHmiControlsPerPage) + " 个控件");
}
if (totalControlCount(project_service_.project()) + controls.size()
> ProjectLimits::kMaximumHmiControlsPerProject)
{
return failure(HmiEditorError::InvalidControl, "单个工程最多包含 2048 个 HMI 控件");
}

// 让整组控件尽量保留相对位置,靠近页面边界时自动向页面内收拢
int minimum_x = controls.front().bounds.x;
int minimum_y = controls.front().bounds.y;
int maximum_x = controls.front().bounds.x + controls.front().bounds.width;
int maximum_y = controls.front().bounds.y + controls.front().bounds.height;
for (const HmiControl &control : controls)
{
minimum_x = std::min(minimum_x, control.bounds.x);
minimum_y = std::min(minimum_y, control.bounds.y);
maximum_x = std::max(
maximum_x, control.bounds.x + control.bounds.width);
maximum_y = std::max(
maximum_y, control.bounds.y + control.bounds.height);
}
if (maximum_x - minimum_x > existing_page->width
|| maximum_y - minimum_y > existing_page->height)
{
return failure(HmiEditorError::InvalidControl, "复制的控件尺寸超出目标 HMI 页面");
}
int final_offset_x = offset_x;
int final_offset_y = offset_y;
if (minimum_x + final_offset_x < 0)
{
final_offset_x = -minimum_x;
}
if (minimum_y + final_offset_y < 0)
{
final_offset_y = -minimum_y;
}
if (maximum_x + final_offset_x > existing_page->width)
{
final_offset_x = existing_page->width - maximum_x;
}
if (maximum_y + final_offset_y > existing_page->height)
{
final_offset_y = existing_page->height - maximum_y;
}

const HistoryState before = captureState();
const bool modified_before = project_service_.isModified();
Project &project = project_service_.editProject();
auto target_page = std::find_if(
project.hmiPages.begin(), project.hmiPages.end(),
[&page_id](const HmiPage &candidate) { return candidate.id == page_id; });
std::vector<std::string> new_ids;
new_ids.reserve(controls.size());
for (const HmiControl &source : controls)
{
const HmiControlDescriptor *descriptor = findHmiControlDescriptor(source.type);
if (descriptor == nullptr)
{
project.hmiPages = before.pages;
project.initialHmiPageId = before.initial_page_id;
project_service_.restoreModifiedState(modified_before);
return failure(HmiEditorError::InvalidControl, "不支持的 HMI 控件类型");
}
HmiControl candidate = source;
candidate.id = makeUniqueId(*target_page, descriptor->idPrefix);
candidate.bounds.x += final_offset_x;
candidate.bounds.y += final_offset_y;
std::string error;
if (!validateEditableControl(*target_page, candidate, &error))
{
project.hmiPages = before.pages;
project.initialHmiPageId = before.initial_page_id;
project_service_.restoreModifiedState(modified_before);
return failure(HmiEditorError::InvalidControl, error);
}
new_ids.push_back(candidate.id);
target_page->controls.push_back(std::move(candidate));
}
recordHistory(before);
return {true, HmiEditorError::None, {}, new_ids.front()};
}

bool HmiEditorService::canUndo() const bool HmiEditorService::canUndo() const
{ {
return history_.canUndo(); return history_.canUndo();
@@ -693,10 +824,6 @@ HmiControl HmiEditorService::makeControl(
{ {
control.pageJump = HmiPageJumpConfig{}; control.pageJump = HmiPageJumpConfig{};
} }
if (descriptor.type == HmiControlType::ProgressBar)
{
control.progressBar = HmiProgressBarConfig{};
}
const int offset = static_cast<int>(page.controls.size()) * 16; const int offset = static_cast<int>(page.controls.size()) * 16;
control.bounds.x = std::min(20 + offset, page.width - control.bounds.width); control.bounds.x = std::min(20 + offset, page.width - control.bounds.width);
control.bounds.y = std::min(20 + offset, page.height - control.bounds.height); control.bounds.y = std::min(20 + offset, page.height - control.bounds.height);


+ 111
- 19
app/src/services/hmi_editor_service.h 查看文件

@@ -21,17 +21,17 @@ struct HmiControlDescriptor;
*/ */
enum class HmiEditorError enum class HmiEditorError
{ {
None,
PageNotFound,
ControlNotFound,
DuplicateId,
DuplicateName,
InvalidPage,
InvalidControl,
LastPageRequired,
InitialPageCannotBeRemoved,
PageReferenced,
InvalidOperation
None, // 操作成功或没有错误
PageNotFound, // 目标页面不存在
ControlNotFound, // 目标控件不存在
DuplicateId, // 控件 ID 与同页其他控件重复
DuplicateName, // 页面名称与其他页面重复
InvalidPage, // 页面名称、尺寸或页面配置无效
InvalidControl, // 控件属性、绑定或页面边界无效
LastPageRequired, // 删除后不能少于一个 HMI 页面
InitialPageCannotBeRemoved, // 初始页面仍未切换,不能删除
PageReferenced, // 页面仍被页面跳转控件引用
InvalidOperation // 操作参数或撤销/重做状态不允许
}; };


/** /**
@@ -41,10 +41,10 @@ enum class HmiEditorError
*/ */
struct HmiEditorResult struct HmiEditorResult
{ {
bool succeeded = false;
HmiEditorError error = HmiEditorError::None;
std::string message;
std::string id;
bool succeeded = false; // 操作是否成功
HmiEditorError error = HmiEditorError::None; // 失败时的分类
std::string message; // 面向用户的 UTF-8 成功说明或失败原因
std::string id; // 成功时返回新建或更新后的页面/控件 ID
}; };


/** /**
@@ -55,7 +55,13 @@ struct HmiEditorResult
class HmiEditorService class HmiEditorService
{ {
public: public:
explicit HmiEditorService(ProjectService &project_service);
/**
* @brief 创建 HMI 编辑服务
* @param project_service 用于读取和原子修改当前工程的项目服务
*/
explicit HmiEditorService(
ProjectService &project_service,
const HmiDefaultSettings &hmi_defaults = defaultHmiSettings());


/** /**
* @brief 按页面标识查找只读页面 * @brief 按页面标识查找只读页面
@@ -82,13 +88,52 @@ public:
* @return 已有或新建页面的成功结果及其页面标识 * @return 已有或新建页面的成功结果及其页面标识
*/ */
HmiEditorResult ensureDefaultPage(); HmiEditorResult ensureDefaultPage();
/**
* @brief 添加一个 HMI 页面
* @param name 页面显示名称,不能为空且必须在工程内唯一
* @return 成功时返回新页面 ID
*/
HmiEditorResult addPage(const std::string &name); HmiEditorResult addPage(const std::string &name);

/**
* @brief 调整页面尺寸
* @param page_id 目标页面唯一标识
* @param width 页面宽度,必须满足页面边界且不能使已有控件越界
* @param height 页面高度,必须满足页面边界且不能使已有控件越界
* @return 页面不存在或新尺寸无效时返回失败结果
*/
HmiEditorResult resizePage( HmiEditorResult resizePage(
const std::string &page_id, int width, int height); const std::string &page_id, int width, int height);

/**
* @brief 修改页面显示名称
* @param page_id 目标页面唯一标识
* @param name 新页面名称,不能为空且必须在工程内唯一
* @return 页面不存在或名称无效/重复时返回失败结果
*/
HmiEditorResult renamePage( HmiEditorResult renamePage(
const std::string &page_id, const std::string &name); const std::string &page_id, const std::string &name);

/**
* @brief 删除一个 HMI 页面
* @param page_id 待删除页面唯一标识
* @return 页面不存在、页面是唯一页面、仍为初始页面或仍被引用时返回失败结果
*/
HmiEditorResult removePage(const std::string &page_id); HmiEditorResult removePage(const std::string &page_id);

/**
* @brief 将页面在工程页面列表中上移或下移一位
* @param page_id 目标页面唯一标识
* @param offset 只能为 -1(上移)或 1(下移)
* @return 页面不存在或已经位于目标边界时返回失败结果
*/
HmiEditorResult movePage(const std::string &page_id, int offset); HmiEditorResult movePage(const std::string &page_id, int offset);

/**
* @brief 设置工程的初始 HMI 页面
* @param page_id 要作为启动页面的页面唯一标识
* @return 页面不存在时返回 PageNotFound
*/
HmiEditorResult setInitialPage(const std::string &page_id); HmiEditorResult setInitialPage(const std::string &page_id);
/** /**
* @brief 向指定页面添加待配置的基础控件 * @brief 向指定页面添加待配置的基础控件
@@ -108,6 +153,13 @@ public:
*/ */
HmiEditorResult removeControl( HmiEditorResult removeControl(
const std::string &page_id, const std::string &control_id); const std::string &page_id, const std::string &control_id);

/**
* @brief 一次删除同一页面中的多个控件
* @param page_id 所属页面唯一标识
* @param control_ids 待删除控件 ID 列表,不能为空且不能包含重复 ID
* @return 页面、控件不存在或 ID 列表无效时返回失败结果
*/
HmiEditorResult removeControls( HmiEditorResult removeControls(
const std::string &page_id, const std::string &page_id,
const std::vector<std::string> &control_ids); const std::vector<std::string> &control_ids);
@@ -136,26 +188,65 @@ public:
const std::string &control_id, const std::string &control_id,
const HmiControl &control); const HmiControl &control);


/**
* @brief 批量粘贴 HMI 控件
* @param page_id 目标页面唯一标识
* @param controls 待复制的控件配置,不使用其中的 ID
* @param offset_x 控件整体横向偏移
* @param offset_y 控件整体纵向偏移
* @return 成功时返回第一个新控件 ID,失败时整批回滚
*/
HmiEditorResult pasteControls(
const std::string &page_id,
const std::vector<HmiControl> &controls,
int offset_x = 20,
int offset_y = 20);

/** @brief 判断是否存在可撤销的 HMI 编辑操作 */
bool canUndo() const; bool canUndo() const;
/** @brief 判断是否存在可重做的 HMI 编辑操作 */
bool canRedo() const; bool canRedo() const;

/**
* @brief 撤销最近一次成功的编辑操作
* @return 没有可撤销操作时返回 InvalidOperation
*/
HmiEditorResult undo(); HmiEditorResult undo();

/**
* @brief 重做最近一次被撤销的编辑操作
* @return 没有可重做操作时返回 InvalidOperation
*/
HmiEditorResult redo(); HmiEditorResult redo();

/**
* @brief 清空撤销和重做历史
*
* 不修改当前工程内容
*/
void clearHistory(); void clearHistory();


private: private:
// 撤销/重做需要保存的完整 HMI 编辑状态
struct HistoryState struct HistoryState
{ {
std::vector<HmiPage> pages;
std::string initial_page_id;
std::vector<HmiPage> pages; // 页面及其控件的深拷贝
std::string initial_page_id; // 工程初始页面 ID
}; };


// 捕获当前工程状态,作为一次编辑操作的前后快照
HistoryState captureState() const; HistoryState captureState() const;
// 比较编辑前后状态并记录实际发生的修改
void recordHistory(HistoryState before); void recordHistory(HistoryState before);
// 比较两个历史快照是否完全相同
static bool statesEqual( static bool statesEqual(
const HistoryState &left, const HistoryState &right); const HistoryState &left, const HistoryState &right);
// 比较两个 HMI 控件的全部可保存属性
static bool controlsEqual( static bool controlsEqual(
const HmiControl &left, const HmiControl &right); const HmiControl &left, const HmiControl &right);
// 比较两个 HMI 页面及其控件集合
static bool pagesEqual(const HmiPage &left, const HmiPage &right); static bool pagesEqual(const HmiPage &left, const HmiPage &right);
// 生成统一的历史操作失败结果
static HmiEditorResult historyFailure(const std::string &message); static HmiEditorResult historyFailure(const std::string &message);


/** /**
@@ -183,7 +274,7 @@ private:
/** /**
* @brief 按控件类型创建带默认属性的新控件 * @brief 按控件类型创建带默认属性的新控件
* @param page 新控件所属页面,用于确定初始位置和唯一标识 * @param page 新控件所属页面,用于确定初始位置和唯一标识
* @param type 新控件类型
* @param descriptor 新控件类型对应的注册描述
* @return 未绑定寄存器的默认控件配置 * @return 未绑定寄存器的默认控件配置
*/ */
static HmiControl makeControl( static HmiControl makeControl(
@@ -201,5 +292,6 @@ private:
* @brief 非拥有的工程服务依赖,由应用入口保证生命周期 * @brief 非拥有的工程服务依赖,由应用入口保证生命周期
*/ */
ProjectService &project_service_; ProjectService &project_service_;
const HmiDefaultSettings &hmi_defaults_;
EditorHistory<HistoryState> history_; EditorHistory<HistoryState> history_;
}; };

+ 1
- 0
app/src/services/hmi_navigation_service.cpp 查看文件

@@ -11,6 +11,7 @@ HmiNavigationService::HmiNavigationService(ProjectService &project_service)


HmiNavigationResult HmiNavigationService::start() HmiNavigationResult HmiNavigationService::start()
{ {
// 运行开始始终从工程声明的初始页面进入,而不是沿用上次会话页面
const Project &project = project_service_.project(); const Project &project = project_service_.project();
if (project.hmiPages.empty()) if (project.hmiPages.empty())
{ {


+ 7
- 0
app/src/services/hmi_navigation_service.h 查看文件

@@ -4,6 +4,7 @@


class ProjectService; class ProjectService;


// HMI 页面跳转失败分类
enum class HmiNavigationError enum class HmiNavigationError
{ {
None, None,
@@ -11,6 +12,7 @@ enum class HmiNavigationError
PageNotFound PageNotFound
}; };


// 页面跳转结果;成功时 pageId 是当前页 ID
struct HmiNavigationResult struct HmiNavigationResult
{ {
bool succeeded = false; bool succeeded = false;
@@ -19,14 +21,19 @@ struct HmiNavigationResult
std::string pageId; std::string pageId;
}; };


// 管理运行态当前页面,不直接操作 Qt 页面控件
class HmiNavigationService class HmiNavigationService
{ {
public: public:
explicit HmiNavigationService(ProjectService &project_service); explicit HmiNavigationService(ProjectService &project_service);


// 进入工程初始页面
HmiNavigationResult start(); HmiNavigationResult start();
// 跳转到指定页面;目标不存在时保留原页面
HmiNavigationResult navigateTo(const std::string &page_id); HmiNavigationResult navigateTo(const std::string &page_id);
// 结束运行会话并清空当前页
void stop(); void stop();
// 返回当前运行页面 ID,未启动时为空
const std::string &currentPageId() const; const std::string &currentPageId() const;


private: private:


+ 52
- 6
app/src/services/hmi_runtime_service.cpp 查看文件

@@ -2,11 +2,14 @@


#include "domain/hmi_control_registry.h" #include "domain/hmi_control_registry.h"


#include <cmath>
#include <limits>

namespace { namespace {


HmiRuntimeReadResult readFailure(HmiRuntimeError error) HmiRuntimeReadResult readFailure(HmiRuntimeError error)
{ {
return {false, error, false, 0};
return {false, error, false, 0, 0.0f};
} }


HmiRuntimeWriteResult writeFailure(HmiRuntimeError error) HmiRuntimeWriteResult writeFailure(HmiRuntimeError error)
@@ -55,16 +58,30 @@ HmiRuntimeReadResult HmiRuntimeService::readControl(const HmiControl &control) c
{ {
return readFailure(repositoryError(result.error)); return readFailure(repositoryError(result.error));
} }
return {true, HmiRuntimeError::None, result.value, 0};
return {true, HmiRuntimeError::None, result.value, 0, 0.0f};
} }
case HmiRuntimeValueKind::Word: case HmiRuntimeValueKind::Word:
{ {
if (control.dataType == RegisterDataType::Float32)
{
const WordPairReadResult result = repository_.readWordPair(*control.binding);
if (!result.succeeded)
{
return readFailure(repositoryError(result.error));
}
const std::optional<float> value = Float32Codec::decode(
result.values[0], result.values[1]);
return value.has_value()
? HmiRuntimeReadResult{
true, HmiRuntimeError::None, false, 0, *value}
: readFailure(HmiRuntimeError::RepositoryFailure);
}
const WordReadResult result = repository_.readWord(*control.binding); const WordReadResult result = repository_.readWord(*control.binding);
if (!result.succeeded) if (!result.succeeded)
{ {
return readFailure(repositoryError(result.error)); return readFailure(repositoryError(result.error));
} }
return {true, HmiRuntimeError::None, false, result.value};
return {true, HmiRuntimeError::None, false, result.value, 0.0f};
} }
case HmiRuntimeValueKind::None: case HmiRuntimeValueKind::None:
default: default:
@@ -140,7 +157,7 @@ HmiRuntimeWriteResult HmiRuntimeService::operateButton(
} }


HmiRuntimeWriteResult HmiRuntimeService::writeNumericInput( HmiRuntimeWriteResult HmiRuntimeService::writeNumericInput(
const HmiControl &control, std::int16_t value)
const HmiControl &control, double value)
{ {
if (control.type != HmiControlType::NumericInput) if (control.type != HmiControlType::NumericInput)
{ {
@@ -150,11 +167,40 @@ HmiRuntimeWriteResult HmiRuntimeService::writeNumericInput(
{ {
return writeFailure(HmiRuntimeError::MissingBinding); return writeFailure(HmiRuntimeError::MissingBinding);
} }
if (!control.binding->isValid())
if (!control.binding->isValid() || control.binding->area() != RegisterArea::D)
{ {
return writeFailure(HmiRuntimeError::InvalidBinding); return writeFailure(HmiRuntimeError::InvalidBinding);
} }
const RegisterWriteResult result = repository_.writeWord(*control.binding, value);
RegisterWriteResult result;
if (control.dataType == RegisterDataType::Float32)
{
if (control.binding->index() >= RegisterAddress::kMaximumIndex
|| !std::isfinite(value)
|| value > static_cast<double>(std::numeric_limits<float>::max())
|| value < -static_cast<double>(std::numeric_limits<float>::max()))
{
return writeFailure(HmiRuntimeError::InvalidBinding);
}
const float float_value = static_cast<float>(value);
if (!std::isfinite(float_value))
{
return writeFailure(HmiRuntimeError::InvalidBinding);
}
result = repository_.writeWordPair(
*control.binding, Float32Codec::encode(float_value));
}
else
{
if (!std::isfinite(value)
|| value < std::numeric_limits<std::int16_t>::min()
|| value > std::numeric_limits<std::int16_t>::max()
|| std::trunc(value) != value)
{
return writeFailure(HmiRuntimeError::InvalidBinding);
}
result = repository_.writeWord(
*control.binding, static_cast<std::int16_t>(value));
}
return result.succeeded return result.succeeded
? HmiRuntimeWriteResult{true, HmiRuntimeError::None} ? HmiRuntimeWriteResult{true, HmiRuntimeError::None}
: writeFailure(repositoryError(result.error)); : writeFailure(repositoryError(result.error));


+ 36
- 12
app/src/services/hmi_runtime_service.h 查看文件

@@ -18,10 +18,10 @@
enum class HmiRuntimeError enum class HmiRuntimeError
{ {
None, // 无错误 None, // 无错误
UnsupportedControl, // 控件类型不支持寄存器读写
UnsupportedControl, // 控件类型不支持寄存器读写或写入操作
MissingBinding, // 控件未配置寄存器绑定 MissingBinding, // 控件未配置寄存器绑定
InvalidBinding, // 控件绑定地址无效或地址区域不匹配 InvalidBinding, // 控件绑定地址无效或地址区域不匹配
RepositoryFailure // 寄存器仓库读取或入失败
RepositoryFailure // 寄存器仓库不可用或拒绝读写
}; };


/** /**
@@ -31,10 +31,11 @@ enum class HmiRuntimeError
*/ */
struct HmiRuntimeReadResult struct HmiRuntimeReadResult
{ {
bool succeeded = false;
HmiRuntimeError error = HmiRuntimeError::None;
bool bit_value = false;
std::int16_t word_value = 0;
bool succeeded = false; // 是否成功读到控件绑定值
HmiRuntimeError error = HmiRuntimeError::None; // 失败时的 HMI 错误分类
bool bit_value = false; // 位控件的读回值;字控件读取成功时无效
std::int16_t word_value = 0; // 字控件的读回值;位控件读取成功时无效
float float_value = 0.0f; // Float32 数值控件的读回值
}; };


/** /**
@@ -42,8 +43,8 @@ struct HmiRuntimeReadResult
*/ */
struct HmiRuntimeWriteResult struct HmiRuntimeWriteResult
{ {
bool succeeded = false;
HmiRuntimeError error = HmiRuntimeError::None;
bool succeeded = false; // 写入请求是否被仓库接受
HmiRuntimeError error = HmiRuntimeError::None; // 失败时的 HMI 错误分类
}; };


/** /**
@@ -51,8 +52,8 @@ struct HmiRuntimeWriteResult
*/ */
enum class HmiButtonEvent enum class HmiButtonEvent
{ {
Pressed,
Released
Pressed, // 按钮按下事件
Released // 按钮释放事件
}; };


/** /**
@@ -60,9 +61,14 @@ enum class HmiButtonEvent
* *
* 服务不依赖虚拟寄存器实现、串口或 Modbus,可由运行模式注入不同仓库 * 服务不依赖虚拟寄存器实现、串口或 Modbus,可由运行模式注入不同仓库
*/ */
// 仅通过统一寄存器仓库执行 HMI 运行态读写
class HmiRuntimeService class HmiRuntimeService
{ {
public: public:
/**
* @brief 创建 HMI 运行态读写服务
* @param repository 当前运行模式提供的寄存器仓库,不由本服务拥有
*/
explicit HmiRuntimeService(RegisterRepository &repository); explicit HmiRuntimeService(RegisterRepository &repository);


/** /**
@@ -78,6 +84,9 @@ public:
* @param control 按钮控件,必须绑定有效 M 地址 * @param control 按钮控件,必须绑定有效 M 地址
* @param event 当前输入事件 * @param event 当前输入事件
* @return 无需写入的事件返回成功,配置无效或仓库拒绝写入时返回失败 * @return 无需写入的事件返回成功,配置无效或仓库拒绝写入时返回失败
*
* SetOn、SetOff 和 Toggle 只在按下时写入;MomentaryOn 在按下时写 1、
* 释放时写 0。写入结果以仓库返回值为准,不直接假定缓存已经更新
*/ */
HmiRuntimeWriteResult operateButton( HmiRuntimeWriteResult operateButton(
const HmiControl &control, HmiButtonEvent event); const HmiControl &control, HmiButtonEvent event);
@@ -86,14 +95,29 @@ public:
* @param control 数值输入控件,必须绑定有效 D 地址 * @param control 数值输入控件,必须绑定有效 D 地址
* @param value 要写入的 D 字值 * @param value 要写入的 D 字值
* @return 非数值输入、绑定无效或仓库拒绝写入时返回失败结果 * @return 非数值输入、绑定无效或仓库拒绝写入时返回失败结果
*
* 该服务只负责提交写入请求,实际读回值由后续仓库轮询确认
*/ */
HmiRuntimeWriteResult writeNumericInput( HmiRuntimeWriteResult writeNumericInput(
const HmiControl &control, std::int16_t value);
const HmiControl &control, double value);

/**
* @brief 读取指定 M 位
* @param address 必须是有效的 M 区地址
* @return 仓库原始读取结果,不转换为 HMI 错误分类
*/
BitReadResult readBit(const RegisterAddress &address) const; BitReadResult readBit(const RegisterAddress &address) const;

/**
* @brief 读取指定 D 字
* @param address 必须是有效的 D 区地址
* @return 仓库原始读取结果,不转换为 HMI 错误分类
*/
WordReadResult readWord(const RegisterAddress &address) const; WordReadResult readWord(const RegisterAddress &address) const;


private: private:
// 将寄存器仓库错误统一转换为 HMI 运行态错误
static HmiRuntimeError repositoryError(RegisterError error); static HmiRuntimeError repositoryError(RegisterError error);


RegisterRepository &repository_;
RegisterRepository &repository_; // 当前运行模式使用的寄存器仓库,不由本服务拥有
}; };

+ 588
- 0
app/src/services/logic_command_service.cpp 查看文件

@@ -0,0 +1,588 @@
#include "logic_command_service.h"

#include "logic_editor_service.h"

#include <algorithm>
#include <cctype>
#include <limits>
#include <sstream>
#include <unordered_map>

namespace {

std::vector<std::string> splitTokens(const std::string &text)
{
std::istringstream stream(text);
std::vector<std::string> tokens;
std::string token;
while (stream >> token)
{
tokens.push_back(std::move(token));
}
return tokens;
}

std::string upperAscii(std::string value)
{
std::transform(
value.begin(), value.end(), value.begin(),
[](unsigned char character)
{
return static_cast<char>(std::toupper(character));
});
return value;
}

LogicCommandParseResult parseFailure(const std::string &message)
{
return {false, message, {}};
}

bool requiresOperands(
const std::vector<std::string> &tokens,
std::size_t count,
const std::string &example,
LogicCommandParseResult *failure)
{
if (tokens.size() == count + 1U)
{
return true;
}
*failure = parseFailure(
tokens.front() + " 需要 " + std::to_string(count)
+ " 个操作数,例如 " + example);
return false;
}

bool parseAddress(
const std::string &text,
RegisterArea expected_area,
const std::string &instruction,
RegisterAddress *address,
LogicCommandParseResult *failure)
{
const RegisterAddressParseResult parsed = parseRegisterAddress(text);
const char expected_prefix = expected_area == RegisterArea::M ? 'M' : 'D';
if (!parsed.succeeded)
{
const std::string range = std::string(1, expected_prefix) + "0~"
+ expected_prefix + std::to_string(RegisterAddress::kMaximumIndex);
*failure = parseFailure(
parsed.error == RegisterAddressParseError::OutOfRange
? std::string(1, expected_prefix) + " 地址超出范围,应为 " + range
: "地址格式错误,应为 " + range);
return false;
}
if (parsed.address.area() != expected_area)
{
*failure = parseFailure(
instruction + " 只支持 " + std::string(1, expected_prefix) + " 地址");
return false;
}
*address = parsed.address;
return true;
}

bool parseInt16(
const std::string &text,
const std::string &instruction,
std::int16_t *value,
LogicCommandParseResult *failure);

bool parseWordOperand(
const std::string &text,
const std::string &instruction,
WordOperand *operand,
LogicCommandParseResult *failure)
{
const RegisterAddressParseResult parsed = parseRegisterAddress(text);
if (parsed.succeeded)
{
if (parsed.address.area() != RegisterArea::D)
{
*failure = parseFailure(instruction + " 的寄存器操作数必须使用 D 地址");
return false;
}
*operand = WordOperand{
WordOperandKind::Register, parsed.address, 0};
return true;
}
if (!text.empty()
&& (text.front() == 'D' || text.front() == 'd'
|| text.front() == 'M' || text.front() == 'm'))
{
RegisterAddress address{RegisterArea::D, 0};
return parseAddress(
text, RegisterArea::D, instruction, &address, failure)
&& ((*operand = WordOperand{
WordOperandKind::Register, address, 0}), true);
}
std::int16_t constant = 0;
if (!parseInt16(text, instruction, &constant, failure))
{
return false;
}
*operand = WordOperand{
WordOperandKind::Constant,
RegisterAddress{RegisterArea::D, 0},
constant};
return true;
}

bool isLoad(LogicCommandOpcode opcode)
{
return opcode == LogicCommandOpcode::Load
|| opcode == LogicCommandOpcode::LoadInverse
|| opcode == LogicCommandOpcode::LoadRising
|| opcode == LogicCommandOpcode::LoadFalling
|| opcode == LogicCommandOpcode::CompareEqual
|| opcode == LogicCommandOpcode::CompareNotEqual
|| opcode == LogicCommandOpcode::CompareLessThan
|| opcode == LogicCommandOpcode::CompareLessThanOrEqual
|| opcode == LogicCommandOpcode::CompareGreaterThan
|| opcode == LogicCommandOpcode::CompareGreaterThanOrEqual;
}

bool isOr(LogicCommandOpcode opcode)
{
return opcode == LogicCommandOpcode::Or
|| opcode == LogicCommandOpcode::OrInverse;
}

bool isCondition(LogicCommandOpcode opcode)
{
return isLoad(opcode) || isOr(opcode)
|| opcode == LogicCommandOpcode::And
|| opcode == LogicCommandOpcode::AndInverse;
}

bool isConditionTarget(LogicCommandTargetKind kind)
{
return kind != LogicCommandTargetKind::Output;
}

bool isOutput(LogicCommandOpcode opcode)
{
return opcode == LogicCommandOpcode::Output
|| opcode == LogicCommandOpcode::Set
|| opcode == LogicCommandOpcode::Reset
|| opcode == LogicCommandOpcode::Move
|| opcode == LogicCommandOpcode::Add
|| opcode == LogicCommandOpcode::Subtract;
}

bool parseInt16(
const std::string &text,
const std::string &instruction,
std::int16_t *value,
LogicCommandParseResult *failure)
{
try
{
std::size_t consumed = 0;
const long parsed = std::stol(text, &consumed, 10);
if (consumed != text.size()
|| parsed < std::numeric_limits<std::int16_t>::min()
|| parsed > std::numeric_limits<std::int16_t>::max())
{
throw std::out_of_range("int16");
}
*value = static_cast<std::int16_t>(parsed);
return true;
}
catch (const std::exception &)
{
*failure = parseFailure(
instruction + " 的第二个操作数应为 -32768~32767 的常量");
return false;
}
}

LogicCommandResult executionFailure(
const std::string &message, LogicCommandOpcode opcode)
{
return {false, message, {}, {}, opcode};
}

} // namespace

LogicCommandService::LogicCommandService(LogicEditorService &editor_service)
: editor_service_(editor_service)
{
}

LogicCommandParseResult LogicCommandService::parse(const std::string &text)
{
std::vector<std::string> tokens = splitTokens(text);
if (tokens.empty())
{
return parseFailure("请输入 PLC 指令");
}
tokens.front() = upperAscii(tokens.front());
const std::string &mnemonic = tokens.front();
LogicCommandParseResult failure;

const std::unordered_map<std::string, LogicCommandOpcode> condition_opcodes{
{"LD", LogicCommandOpcode::Load},
{"LDI", LogicCommandOpcode::LoadInverse},
{"LDP", LogicCommandOpcode::LoadRising},
{"LDF", LogicCommandOpcode::LoadFalling},
{"AND", LogicCommandOpcode::And},
{"ANI", LogicCommandOpcode::AndInverse},
{"OR", LogicCommandOpcode::Or},
{"ORI", LogicCommandOpcode::OrInverse}};
const std::unordered_map<std::string, LogicCommandOpcode> compare_opcodes{
{"LD=", LogicCommandOpcode::CompareEqual},
{"LD<>", LogicCommandOpcode::CompareNotEqual},
{"LD<", LogicCommandOpcode::CompareLessThan},
{"LD<=", LogicCommandOpcode::CompareLessThanOrEqual},
{"LD>", LogicCommandOpcode::CompareGreaterThan},
{"LD>=", LogicCommandOpcode::CompareGreaterThanOrEqual}};
const auto compare = compare_opcodes.find(mnemonic);
if (compare != compare_opcodes.end())
{
if (!requiresOperands(tokens, 2U, mnemonic + " D0 0", &failure))
{
return failure;
}
RegisterAddress address{RegisterArea::D, 0};
if (!parseAddress(tokens[1], RegisterArea::D, mnemonic, &address, &failure))
{
return failure;
}
std::int16_t value = 0;
if (!parseInt16(tokens[2], mnemonic, &value, &failure))
{
return failure;
}
ComparisonOperator operation = ComparisonOperator::Equal;
switch (compare->second)
{
case LogicCommandOpcode::CompareEqual:
operation = ComparisonOperator::Equal;
break;
case LogicCommandOpcode::CompareNotEqual:
operation = ComparisonOperator::NotEqual;
break;
case LogicCommandOpcode::CompareLessThan:
operation = ComparisonOperator::LessThan;
break;
case LogicCommandOpcode::CompareLessThanOrEqual:
operation = ComparisonOperator::LessThanOrEqual;
break;
case LogicCommandOpcode::CompareGreaterThan:
operation = ComparisonOperator::GreaterThan;
break;
case LogicCommandOpcode::CompareGreaterThanOrEqual:
operation = ComparisonOperator::GreaterThanOrEqual;
break;
default:
break;
}
return {true, {}, {compare->second,
CompareNodeConfig{address, operation, value}}};
}
const auto condition = condition_opcodes.find(mnemonic);
if (condition != condition_opcodes.end())
{
if (!requiresOperands(tokens, 1U, mnemonic + " M0", &failure))
{
return failure;
}
RegisterAddress address{RegisterArea::M, 0};
if (!parseAddress(tokens[1], RegisterArea::M, mnemonic, &address, &failure))
{
return failure;
}
LogicNodeConfig config;
if (condition->second == LogicCommandOpcode::LoadRising
|| condition->second == LogicCommandOpcode::LoadFalling)
{
config = EdgeContactNodeConfig{
address,
condition->second == LogicCommandOpcode::LoadRising
? EdgeMode::Rising : EdgeMode::Falling};
}
else
{
const bool inverse = condition->second == LogicCommandOpcode::LoadInverse
|| condition->second == LogicCommandOpcode::AndInverse
|| condition->second == LogicCommandOpcode::OrInverse;
config = ContactNodeConfig{
address,
inverse ? ContactMode::NormallyClosed
: ContactMode::NormallyOpen};
}
return {true, {}, {condition->second, std::move(config)}};
}

const std::unordered_map<std::string, LogicCommandOpcode> coil_opcodes{
{"OUT", LogicCommandOpcode::Output},
{"SET", LogicCommandOpcode::Set},
{"RST", LogicCommandOpcode::Reset}};
const auto coil = coil_opcodes.find(mnemonic);
if (coil != coil_opcodes.end())
{
if (!requiresOperands(tokens, 1U, mnemonic + " M0", &failure))
{
return failure;
}
RegisterAddress address{RegisterArea::M, 0};
if (!parseAddress(tokens[1], RegisterArea::M, mnemonic, &address, &failure))
{
return failure;
}
const CoilMode mode = coil->second == LogicCommandOpcode::Set
? CoilMode::Set
: coil->second == LogicCommandOpcode::Reset
? CoilMode::Reset : CoilMode::Normal;
return {true, {}, {coil->second, CoilNodeConfig{address, mode}}};
}

if (mnemonic == "MOV")
{
if (!requiresOperands(tokens, 2U, "MOV D0 D1", &failure))
{
return failure;
}
RegisterAddress destination{RegisterArea::D, 0};
WordOperand source;
if (!parseWordOperand(tokens[1], mnemonic, &source, &failure)
|| !parseAddress(tokens[2], RegisterArea::D, mnemonic, &destination, &failure))
{
return failure;
}
return {
true, {},
{LogicCommandOpcode::Move,
MoveNodeConfig{source, destination}}};
}

if (mnemonic == "ADD" || mnemonic == "SUB")
{
if (!requiresOperands(tokens, 3U, mnemonic + " D0 D1 D2", &failure))
{
return failure;
}
WordOperand left;
WordOperand right;
RegisterAddress destination{RegisterArea::D, 0};
if (!parseWordOperand(tokens[1], mnemonic, &left, &failure)
|| !parseWordOperand(tokens[2], mnemonic, &right, &failure)
|| !parseAddress(tokens[3], RegisterArea::D, mnemonic, &destination, &failure))
{
return failure;
}
const LogicCommandOpcode opcode = mnemonic == "ADD"
? LogicCommandOpcode::Add : LogicCommandOpcode::Subtract;
return {
true, {},
{opcode,
ArithmeticNodeConfig{
opcode == LogicCommandOpcode::Add
? ArithmeticOperation::Add : ArithmeticOperation::Subtract,
left,
right,
destination}}};
}

return parseFailure("当前指令暂不支持:" + mnemonic);
}

const std::vector<LogicCommandSuggestion> &LogicCommandService::suggestions()
{
static const std::vector<LogicCommandSuggestion> values{
{"LD", "常开触点", "M 地址"},
{"LDI", "常闭触点", "M 地址"},
{"LDP", "上升沿触点", "M 地址"},
{"LDF", "下降沿触点", "M 地址"},
{"LD=", "D 值等于常量", "D 地址 + 常量"},
{"LD<>", "D 值不等于常量", "D 地址 + 常量"},
{"LD<", "D 值小于常量", "D 地址 + 常量"},
{"LD<=", "D 值小于等于常量", "D 地址 + 常量"},
{"LD>", "D 值大于常量", "D 地址 + 常量"},
{"LD>=", "D 值大于等于常量", "D 地址 + 常量"},
{"AND", "串联常开触点", "M 地址"},
{"ANI", "串联常闭触点", "M 地址"},
{"OR", "并联常开触点", "M 地址"},
{"ORI", "并联常闭触点", "M 地址"},
{"OUT", "普通线圈", "M 地址"},
{"SET", "置位线圈", "M 地址"},
{"RST", "复位线圈", "M 地址"},
{"MOV", "数据传送", "D 地址 -> D 地址"},
{"ADD", "加法", "D 地址 + D 地址 -> D 地址"},
{"SUB", "减法", "D 地址 - D 地址 -> D 地址"}};
return values;
}

LogicCommandResult LogicCommandService::execute(
const LogicCommandRequest &request)
{
const LogicCommandParseResult parsed = parse(request.text);
if (!parsed.succeeded)
{
return executionFailure(parsed.message, parsed.command.opcode);
}
const LogicCommandOpcode opcode = parsed.command.opcode;
LogicEditorResult edited;

if (request.target.kind == LogicCommandTargetKind::ExistingNode)
{
const LogicNode *existing = editor_service_.findNode(
request.logicId, request.target.expressionId);
if (existing == nullptr)
{
return executionFailure("未找到要编辑的逻辑节点", opcode);
}
if (existing->isCondition())
{
if (!isLoad(opcode))
{
return executionFailure(
"已有条件节点只能替换为 LD/LDI/LDP/LDF 指令", opcode);
}
}
else if (existing->isOutput())
{
if (!isOutput(opcode))
{
return executionFailure(
"已有输出节点只能替换为 OUT/SET/RST/MOV/ADD/SUB 指令",
opcode);
}
}
else
{
return executionFailure("当前节点类型不支持命令替换", opcode);
}
edited = editor_service_.updateNodeConfig(
request.logicId, request.target.expressionId,
parsed.command.config);
}
else if (isCondition(opcode))
{
if (isLoad(opcode) && request.continuing)
{
edited = editor_service_.appendCondition(
request.logicId, {}, parsed.command.config, true);
}
else if (isOr(opcode))
{
const std::string rung_id = request.continuing
? request.currentRungId : request.target.rungId;
if (request.continuing)
{
edited = editor_service_.addParallelToWholeCondition(
request.logicId, rung_id, parsed.command.config, true);
}
else if (request.parallelNodeIds.empty())
{
return executionFailure(
"OR/ORI 需要先选中同一网络中的连续条件", opcode);
}
else
{
edited = editor_service_.addParallelBranch(
request.logicId,
rung_id,
request.parallelNodeIds,
parsed.command.config,
true);
}
}
else if (request.continuing)
{
const LadderRung *rung = editor_service_.findRung(
request.logicId, request.currentRungId);
if (rung != nullptr && rung->output.has_value())
{
return executionFailure(
"当前网络已有输出,下一条只能输入 LD/LDI/LDP/LDF 新建网络",
opcode);
}
edited = editor_service_.appendCondition(
request.logicId, request.currentRungId,
parsed.command.config, true);
}
else
{
if (!isConditionTarget(request.target.kind))
{
return executionFailure(
"条件指令只能输入在第 1~10 列条件区", opcode);
}
if ((opcode == LogicCommandOpcode::And
|| opcode == LogicCommandOpcode::AndInverse)
&& (!request.target.rungId.empty()
&& (editor_service_.findRung(
request.logicId, request.target.rungId) == nullptr
|| !editor_service_.findRung(
request.logicId, request.target.rungId)
->condition.has_value())))
{
return executionFailure(
"AND/ANI 前必须先输入 LD/LDI/LDP/LDF", opcode);
}
switch (request.target.kind)
{
case LogicCommandTargetKind::EmptyColumn:
edited = editor_service_.insertConditionAtColumn(
request.logicId, request.target.rungId,
request.target.column, parsed.command.config, true);
break;
case LogicCommandTargetKind::BranchEmptyColumn:
edited = editor_service_.insertConditionInBranchAtColumn(
request.logicId, request.target.rungId,
request.target.expressionId, request.target.column,
parsed.command.config, true);
break;
case LogicCommandTargetKind::WireColumn:
edited = editor_service_.replaceWireColumnWithCondition(
request.logicId, request.target.rungId,
request.target.expressionId, request.target.column,
parsed.command.config, true);
break;
case LogicCommandTargetKind::GapColumn:
edited = editor_service_.replaceGapColumnWithCondition(
request.logicId, request.target.rungId,
request.target.expressionId, request.target.column,
parsed.command.config, true);
break;
case LogicCommandTargetKind::Output:
break;
case LogicCommandTargetKind::ExistingNode:
break;
}
}
}
else
{
const std::string rung_id = request.continuing
? request.currentRungId : request.target.rungId;
if (!request.continuing
&& request.target.kind != LogicCommandTargetKind::Output)
{
return executionFailure(
"输出指令只能输入在第 11 列输出区", opcode);
}
const LadderRung *rung = editor_service_.findRung(
request.logicId, rung_id);
if (request.continuing && rung != nullptr && rung->output.has_value())
{
return executionFailure(
"当前网络已经有输出,下一条请输入 LD/LDI/LDP/LDF 新建网络",
opcode);
}
edited = editor_service_.setOutput(
request.logicId, rung_id, parsed.command.config, true);
}

if (!edited.succeeded)
{
return executionFailure(edited.message, opcode);
}
return {
true, {}, edited.id,
editor_service_.rungIdForNode(request.logicId, edited.id),
opcode};
}

+ 106
- 0
app/src/services/logic_command_service.h 查看文件

@@ -0,0 +1,106 @@
#pragma once

#include "domain/control_logic_model.h"

#include <string>
#include <vector>

class LogicEditorService;

enum class LogicCommandOpcode
{
Load,
LoadInverse,
LoadRising,
LoadFalling,
CompareEqual,
CompareNotEqual,
CompareLessThan,
CompareLessThanOrEqual,
CompareGreaterThan,
CompareGreaterThanOrEqual,
And,
AndInverse,
Or,
OrInverse,
Output,
Set,
Reset,
Move,
Add,
Subtract
};

struct ParsedLogicCommand
{
LogicCommandOpcode opcode = LogicCommandOpcode::Load;
LogicNodeConfig config = ContactNodeConfig{
RegisterAddress{RegisterArea::M, 0}, ContactMode::NormallyOpen};
};

struct LogicCommandParseResult
{
bool succeeded = false;
std::string message;
ParsedLogicCommand command;
};

struct LogicCommandSuggestion
{
std::string mnemonic;
std::string description;
std::string operand_hint;
bool supported = true;
};

enum class LogicCommandTargetKind
{
EmptyColumn,
BranchEmptyColumn,
WireColumn,
GapColumn,
Output,
ExistingNode
};

struct LogicCommandTarget
{
LogicCommandTargetKind kind = LogicCommandTargetKind::EmptyColumn;
std::string rungId;
std::string expressionId;
int column = 0;
};

struct LogicCommandRequest
{
std::string logicId;
std::string text;
LogicCommandTarget target;
bool continuing = false;
std::string currentRungId;
std::vector<std::string> parallelNodeIds;
};

struct LogicCommandResult
{
bool succeeded = false;
std::string message;
std::string id;
std::string rungId;
LogicCommandOpcode opcode = LogicCommandOpcode::Load;
};

// 将单条命令语解析并原子转换为现有结构化梯形图编辑操作
class LogicCommandService
{
public:
explicit LogicCommandService(LogicEditorService &editor_service);

static LogicCommandParseResult parse(const std::string &text);
static const std::vector<LogicCommandSuggestion> &suggestions();

LogicCommandResult execute(const LogicCommandRequest &request);

private:
LogicEditorService &editor_service_;
};

+ 1898
- 139
app/src/services/logic_editor_service.cpp
文件差異過大導致無法顯示
查看文件


+ 212
- 21
app/src/services/logic_editor_service.h 查看文件

@@ -4,147 +4,324 @@
#include "editor_history.h" #include "editor_history.h"


#include <string> #include <string>
#include <utility>
#include <vector> #include <vector>


class ProjectService; class ProjectService;


// 梯形图编辑失败分类
enum class LogicEditorError enum class LogicEditorError
{ {
None,
LogicNotFound,
RungNotFound,
ExpressionNotFound,
NodeNotFound,
InvalidNode,
InvalidOperation,
UnsupportedNodeChange,
DuplicateName,
LastLogicRequired
None, // 操作成功或没有错误
LogicNotFound, // 控制逻辑不存在
RungNotFound, // 网络不存在
ExpressionNotFound, // 条件表达式不存在
NodeNotFound, // 节点不存在
InvalidNode, // 节点配置或节点位置无效
InvalidOperation, // 操作参数或当前结构不允许该操作
UnsupportedNodeChange, // 不允许把条件节点改成输出节点,或反向修改
DuplicateName, // 控制逻辑名称与其他逻辑重复
LastLogicRequired // 删除后不能少于一个控制逻辑
}; };


// 梯形图编辑结果;成功时 id 通常是新建或更新对象的稳定 ID
struct LogicEditorResult struct LogicEditorResult
{ {
bool succeeded = false;
LogicEditorError error = LogicEditorError::None;
std::string message;
std::string id;
bool succeeded = false; // 操作是否成功
LogicEditorError error = LogicEditorError::None; // 失败时的分类
std::string message; // 面向用户的 UTF-8 成功说明或失败原因
std::string id; // 成功时返回新建或更新对象的稳定 ID
}; };


enum class LogicConditionPasteTargetKind
{
Append,
EmptyColumn,
BranchEmptyColumn,
AfterNode,
ReplaceWire,
ReplaceWireColumn,
ReplaceGapColumn
};

struct LogicConditionPasteTarget
{
LogicConditionPasteTargetKind kind = LogicConditionPasteTargetKind::Append;
std::string expressionId;
int column = 0;
};

// 负责把 UI 编辑命令转换为结构化表达式树操作,并维护撤销/重做
class LogicEditorService class LogicEditorService
{ {
public: public:
/**
* @brief 创建梯形图编辑服务
* @param project_service 用于读取和修改当前工程的项目服务
*/
explicit LogicEditorService(ProjectService &project_service); explicit LogicEditorService(ProjectService &project_service);


// 以下查询接口只读工程模型,供编辑器投影和属性面板使用
/** @brief 按 ID 查找控制逻辑,未找到时返回空指针 */
const ControlLogic *findLogic(const std::string &logic_id) const; const ControlLogic *findLogic(const std::string &logic_id) const;
/** @brief 在指定逻辑中按 ID 查找网络,未找到时返回空指针 */
const LadderRung *findRung( const LadderRung *findRung(
const std::string &logic_id, const std::string &rung_id) const; const std::string &logic_id, const std::string &rung_id) const;
/** @brief 在指定逻辑的所有网络中按 ID 查找节点,未找到时返回空指针 */
const LogicNode *findNode( const LogicNode *findNode(
const std::string &logic_id, const std::string &node_id) const; const std::string &logic_id, const std::string &node_id) const;
/** @brief 在指定网络中按 ID 查找条件表达式,未找到时返回空指针 */
const ConditionExpression *findExpression( const ConditionExpression *findExpression(
const std::string &logic_id, const std::string &logic_id,
const std::string &rung_id, const std::string &rung_id,
const std::string &expression_id) const; const std::string &expression_id) const;
/** @brief 返回工程中第一个控制逻辑 ID,没有逻辑时返回空字符串 */
std::string firstLogicId() const; std::string firstLogicId() const;
/** @brief 返回指定逻辑中第一个网络 ID,没有网络或逻辑不存在时返回空字符串 */
std::string firstRungId(const std::string &logic_id) const; std::string firstRungId(const std::string &logic_id) const;
/** @brief 查找节点所属网络 ID,未找到时返回空字符串 */
std::string rungIdForNode( std::string rungIdForNode(
const std::string &logic_id, const std::string &node_id) const; const std::string &logic_id, const std::string &node_id) const;
/** @brief 返回指定 M/D 地址的工程注释,没有注释时返回空字符串 */
std::string registerCommentFor(const RegisterAddress &address) const; std::string registerCommentFor(const RegisterAddress &address) const;


/**
* @brief 确保工程至少有一组可编辑逻辑
* @return 已有或新建逻辑的成功结果及其 ID
*
* 新建逻辑时允许暂时没有网络,第一次实际编辑网络内容时再创建网络
*/
LogicEditorResult ensureDefaultLogic(); LogicEditorResult ensureDefaultLogic();
/** @brief 添加控制逻辑;名称不能为空且必须唯一 */
LogicEditorResult addLogic(const std::string &name); LogicEditorResult addLogic(const std::string &name);
/** @brief 修改控制逻辑名称;名称不能为空且必须唯一 */
LogicEditorResult renameLogic( LogicEditorResult renameLogic(
const std::string &logic_id, const std::string &name); const std::string &logic_id, const std::string &name);
/** @brief 删除控制逻辑;工程至少保留一组逻辑 */
LogicEditorResult removeLogic(const std::string &logic_id); LogicEditorResult removeLogic(const std::string &logic_id);
/** @brief 将逻辑在工程列表中上移或下移一位,offset 只能为 -1 或 1 */
LogicEditorResult moveLogic(const std::string &logic_id, int offset); LogicEditorResult moveLogic(const std::string &logic_id, int offset);
/** @brief 启用或停用指定控制逻辑 */
LogicEditorResult setLogicEnabled(const std::string &logic_id, bool enabled); LogicEditorResult setLogicEnabled(const std::string &logic_id, bool enabled);
/** @brief 在指定逻辑末尾添加空网络 */
LogicEditorResult addRung(const std::string &logic_id); LogicEditorResult addRung(const std::string &logic_id);
/** @brief 删除指定网络 */
LogicEditorResult removeRung( LogicEditorResult removeRung(
const std::string &logic_id, const std::string &rung_id); const std::string &logic_id, const std::string &rung_id);
/** @brief 批量删除指定网络,失败时整体回滚 */
LogicEditorResult removeRungs(
const std::string &logic_id,
const std::vector<std::string> &rung_ids);
/** @brief 修改网络注释;注释长度和换行规则由领域校验约束 */
LogicEditorResult updateRungComment( LogicEditorResult updateRungComment(
const std::string &logic_id, const std::string &logic_id,
const std::string &rung_id, const std::string &rung_id,
const std::string &comment); const std::string &comment);
// 条件区编辑:串联、按列插入、横线和并联分支
/** @brief 在网络条件末尾追加一个条件节点 */
LogicEditorResult appendCondition( LogicEditorResult appendCondition(
const std::string &logic_id, const std::string &logic_id,
const std::string &rung_id, const std::string &rung_id,
const LogicNodeConfig &config);
const LogicNodeConfig &config,
bool configured = false);
/**
* @brief 按绝对条件列插入条件节点
* @param column 从 0 开始的条件列号;插入位置必须位于允许的条件区
*/
LogicEditorResult insertConditionAtColumn( LogicEditorResult insertConditionAtColumn(
const std::string &logic_id, const std::string &logic_id,
const std::string &rung_id, const std::string &rung_id,
int column, int column,
const LogicNodeConfig &config);
const LogicNodeConfig &config,
bool configured = false);
/**
* @brief 在直属并联分支的指定视觉列插入条件节点
*
* 目标列可以是分支已有横线或 UI 投影出的补线格,服务会原子补齐必要横线
*/
LogicEditorResult insertConditionInBranchAtColumn(
const std::string &logic_id,
const std::string &rung_id,
const std::string &branch_expression_id,
int column,
const LogicNodeConfig &config,
bool configured = false);
/** @brief 在网络条件末尾追加指定列宽的横线 */
LogicEditorResult appendWire( LogicEditorResult appendWire(
const std::string &logic_id, const std::string &logic_id,
const std::string &rung_id, const std::string &rung_id,
int column_span = 1); int column_span = 1);
/** @brief 在目标节点后串联插入条件节点 */
LogicEditorResult insertConditionAfter( LogicEditorResult insertConditionAfter(
const std::string &logic_id, const std::string &logic_id,
const std::string &rung_id, const std::string &rung_id,
const std::string &target_node_id, const std::string &target_node_id,
const LogicNodeConfig &config); const LogicNodeConfig &config);
/** @brief 在目标表达式后串联插入横线 */
LogicEditorResult insertWireAfter( LogicEditorResult insertWireAfter(
const std::string &logic_id, const std::string &logic_id,
const std::string &rung_id, const std::string &rung_id,
const std::string &target_expression_id, const std::string &target_expression_id,
int column_span = 1); int column_span = 1);
/** @brief 用条件节点整体替换一条横线表达式 */
LogicEditorResult replaceWireWithCondition( LogicEditorResult replaceWireWithCondition(
const std::string &logic_id, const std::string &logic_id,
const std::string &rung_id, const std::string &rung_id,
const std::string &wire_expression_id, const std::string &wire_expression_id,
const LogicNodeConfig &config); const LogicNodeConfig &config);
/** @brief 只替换横线表达式中的一个指定列单元格 */
LogicEditorResult replaceWireColumnWithCondition( LogicEditorResult replaceWireColumnWithCondition(
const std::string &logic_id, const std::string &logic_id,
const std::string &rung_id, const std::string &rung_id,
const std::string &wire_expression_id, const std::string &wire_expression_id,
int column_offset, int column_offset,
const LogicNodeConfig &config);
const LogicNodeConfig &config,
bool configured = false);
/** @brief 用条件节点替换断路表达式中的一个指定列单元格 */
LogicEditorResult replaceGapColumnWithCondition(
const std::string &logic_id,
const std::string &rung_id,
const std::string &gap_expression_id,
int column_offset,
const LogicNodeConfig &config,
bool configured = false);
/** @brief 用一格横线修复断路表达式中的指定网格 */
LogicEditorResult replaceGapColumnWithWire(
const std::string &logic_id,
const std::string &rung_id,
const std::string &gap_expression_id,
int column_offset);
/**
* @brief 将选中的条件节点建立为并联分支
* @param selected_node_ids 同一网络中按视觉连续范围选择的条件节点 ID
*/
LogicEditorResult addParallelBranch( LogicEditorResult addParallelBranch(
const std::string &logic_id, const std::string &logic_id,
const std::string &rung_id, const std::string &rung_id,
const std::vector<std::string> &selected_node_ids, const std::vector<std::string> &selected_node_ids,
const LogicNodeConfig &config);
const LogicNodeConfig &config,
bool configured = false);
/** @brief 将当前网络的完整条件表达式与一个新条件并联 */
LogicEditorResult addParallelToWholeCondition(
const std::string &logic_id,
const std::string &rung_id,
const LogicNodeConfig &config,
bool configured = false);
/** @brief 将选中的横线表达式建立为并联旁路 */
LogicEditorResult addParallelWireBranch( LogicEditorResult addParallelWireBranch(
const std::string &logic_id, const std::string &logic_id,
const std::string &rung_id, const std::string &rung_id,
const std::vector<std::string> &selected_expression_ids); const std::vector<std::string> &selected_expression_ids);
/**
* @brief 按选中的横线网格建立并联旁路
*
* 所有网格必须来自同一条横线,且列号连续;操作失败时不保留部分修改
*/
LogicEditorResult addParallelWireBranchAtCells(
const std::string &logic_id,
const std::string &rung_id,
const std::vector<std::pair<std::string, int>> &selected_wire_cells);
/**
* @brief 设置网络唯一的输出节点
* @param configured 是否将新输出标记为已完成配置
*
* 输出节点固定位于网络输出槽,替换已有输出时保持一次原子编辑
*/
LogicEditorResult setOutput( LogicEditorResult setOutput(
const std::string &logic_id, const std::string &logic_id,
const std::string &rung_id, const std::string &rung_id,
const LogicNodeConfig &config, const LogicNodeConfig &config,
bool configured = false); bool configured = false);
// 节点属性和删除操作
/** @brief 更新节点配置;条件节点和输出节点不能互相改型 */
LogicEditorResult updateNodeConfig( LogicEditorResult updateNodeConfig(
const std::string &logic_id, const std::string &logic_id,
const std::string &node_id, const std::string &node_id,
const LogicNodeConfig &config); const LogicNodeConfig &config);
/**
* @brief 批量粘贴条件节点到目标网络末尾
* @param logic_id 目标控制逻辑
* @param rung_id 目标网络;为空时自动创建网络
* @param nodes 待复制节点,只读取配置和 configured 状态
* @return 成功时返回第一个新节点 ID,失败时整批回滚
*/
LogicEditorResult pasteConditionNodes(
const std::string &logic_id,
const std::string &rung_id,
const std::vector<LogicNode> &nodes,
const LogicConditionPasteTarget &target = {});
/** 判断所选条件是否位于同一串联层级并且视觉连续 */
bool areConditionNodesContiguous(
const std::string &logic_id,
const std::string &rung_id,
const std::vector<std::string> &node_ids) const;
/**
* @brief 将整条网络复制到目标控制逻辑末尾
* @param logic_id 目标控制逻辑
* @param source 要复制的网络
* @return 成功时返回新网络 ID,所有节点和表达式都会获得新 ID
*/
LogicEditorResult pasteRung(
const std::string &logic_id,
const LadderRung &source);
/** @brief 删除指定节点并归一化受影响的表达式树 */
LogicEditorResult removeNode( LogicEditorResult removeNode(
const std::string &logic_id, const std::string &node_id); const std::string &logic_id, const std::string &node_id);
/**
* @brief 批量删除节点
* @param node_ids 节点 ID 列表,不能为空且不能包含重复或不存在的 ID
* @return 成功时作为一次编辑记录,失败时整体回滚
*/
LogicEditorResult removeNodes( LogicEditorResult removeNodes(
const std::string &logic_id, const std::string &logic_id,
const std::vector<std::string> &node_ids); const std::vector<std::string> &node_ids);
/** @brief 删除指定条件表达式并归一化表达式树 */
LogicEditorResult removeExpression( LogicEditorResult removeExpression(
const std::string &logic_id, const std::string &logic_id,
const std::string &rung_id, const std::string &rung_id,
const std::string &expression_id); const std::string &expression_id);
/** @brief 批量删除条件表达式,失败时整体回滚 */
LogicEditorResult removeExpressions( LogicEditorResult removeExpressions(
const std::string &logic_id, const std::string &logic_id,
const std::string &rung_id, const std::string &rung_id,
const std::vector<std::string> &expression_ids); const std::vector<std::string> &expression_ids);
/** @brief 把选中的横线网格替换为断路,保留网络列位置 */
LogicEditorResult disconnectWireCells(
const std::string &logic_id,
const std::string &rung_id,
const std::vector<std::pair<std::string, int>> &wire_cells);
/** @brief 把整段横线替换为等宽断路 */
LogicEditorResult disconnectWires(
const std::string &logic_id,
const std::string &rung_id,
const std::vector<std::string> &wire_ids);


// 当前编辑会话的撤销/重做
/** @brief 判断是否存在可撤销的梯形图编辑操作 */
bool canUndo() const; bool canUndo() const;
/** @brief 判断是否存在可重做的梯形图编辑操作 */
bool canRedo() const; bool canRedo() const;
/** @brief 撤销最近一次成功的梯形图编辑操作 */
LogicEditorResult undo(); LogicEditorResult undo();
/** @brief 重做最近一次被撤销的梯形图编辑操作 */
LogicEditorResult redo(); LogicEditorResult redo();
/** @brief 清空撤销/重做历史,不修改当前工程内容 */
void clearHistory(); void clearHistory();


private: private:
// 只保存逻辑集合快照,当前选择等 UI 会话状态不进入历史
struct HistoryState struct HistoryState
{ {
std::vector<ControlLogic> logics;
std::vector<ControlLogic> logics; // 编辑前或编辑后的完整逻辑集合
}; };


// 捕获当前工程中的全部控制逻辑
HistoryState captureState() const; HistoryState captureState() const;
// 比较编辑前后状态并记录实际发生的修改
void recordHistory(HistoryState before); void recordHistory(HistoryState before);
// 失败时恢复工程内容和操作前脏标记,保证编辑原子性
void rollbackEdit(HistoryState before, bool modified_before); void rollbackEdit(HistoryState before, bool modified_before);
// 比较两个历史快照是否完全相同
static bool statesEqual( static bool statesEqual(
const HistoryState &left, const HistoryState &right); const HistoryState &left, const HistoryState &right);
static bool logicsEqual( static bool logicsEqual(
@@ -158,17 +335,31 @@ private:
const LogicNodeConfig &left, const LogicNodeConfig &right); const LogicNodeConfig &left, const LogicNodeConfig &right);
static LogicEditorResult historyFailure(const std::string &message); static LogicEditorResult historyFailure(const std::string &message);


// 判断节点配置是否属于条件节点
static bool isConditionConfig(const LogicNodeConfig &config); static bool isConditionConfig(const LogicNodeConfig &config);
// 判断节点配置是否属于输出节点
static bool isOutputConfig(const LogicNodeConfig &config); static bool isOutputConfig(const LogicNodeConfig &config);
// 返回节点配置对应的稳定 ID 前缀
static std::string nodePrefix(const LogicNodeConfig &config); static std::string nodePrefix(const LogicNodeConfig &config);
// 在一个控制逻辑的全部网络中生成唯一节点 ID
static std::string makeUniqueNodeId( static std::string makeUniqueNodeId(
const ControlLogic &logic, const std::string &prefix); const ControlLogic &logic, const std::string &prefix);
// 生成唯一横线表达式 ID
static std::string makeUniqueWireId(const ControlLogic &logic); static std::string makeUniqueWireId(const ControlLogic &logic);
// 生成控制逻辑内唯一的断路表达式 ID
static std::string makeUniqueGapId(const ControlLogic &logic);
// 生成唯一容器表达式 ID
static std::string makeUniqueExpressionId(const ControlLogic &logic); static std::string makeUniqueExpressionId(const ControlLogic &logic);
// 生成唯一网络 ID
static std::string makeUniqueRungId(const ControlLogic &logic); static std::string makeUniqueRungId(const ControlLogic &logic);
// 把并联补线和输出前连接线转换为显式 Wire,并保持输出网络十列布局
static bool repairExplicitLayout(
ControlLogic &logic, LadderRung &rung, std::string *error = nullptr);
// 创建统一的失败结果
static LogicEditorResult failure( static LogicEditorResult failure(
LogicEditorError error, const std::string &message); LogicEditorError error, const std::string &message);


ProjectService &project_service_;
EditorHistory<HistoryState> history_;
ProjectService &project_service_; // 不拥有的工程服务依赖
EditorHistory<HistoryState> history_; // 当前编辑会话的撤销/重做历史
bool suppress_history_ = false; // 复合粘贴期间由外层统一记录一次历史
}; };

+ 22
- 1
app/src/services/offline_simulation_service.cpp 查看文件

@@ -4,9 +4,11 @@


OfflineSimulationService::OfflineSimulationService( OfflineSimulationService::OfflineSimulationService(
VirtualRegisterRepository &repository, VirtualRegisterRepository &repository,
VirtualRegisterRepository *initial_repository,
QObject *parent) QObject *parent)
: QObject(parent), : QObject(parent),
repository_(repository)
repository_(repository),
initial_repository_(initial_repository)
{ {
timer_.setInterval(kDefaultScanIntervalMs); timer_.setInterval(kDefaultScanIntervalMs);
timer_.setTimerType(Qt::PreciseTimer); timer_.setTimerType(Qt::PreciseTimer);
@@ -22,6 +24,7 @@ SimulationStartResult OfflineSimulationService::start(
return {false, SimulationStartError::AlreadyRunning, {}}; return {false, SimulationStartError::AlreadyRunning, {}};
} }


// 仿真期间使用启动时快照,编辑器后续修改不会改变正在扫描的逻辑
std::vector<ControlLogic> snapshot = logics; std::vector<ControlLogic> snapshot = logics;
const LogicScanResult validation = executor_.validate(snapshot); const LogicScanResult validation = executor_.validate(snapshot);
if (!validation.succeeded) if (!validation.succeeded)
@@ -30,9 +33,14 @@ SimulationStartResult OfflineSimulationService::start(
return {false, SimulationStartError::InvalidLogic, validation}; return {false, SimulationStartError::InvalidLogic, validation};
} }


// 重启会话时清空沿触发状态和运行值,再装载编辑态确认的初始值
timer_.stop(); timer_.stop();
executor_.resetRuntime(); executor_.resetRuntime();
repository_.clear(); repository_.clear();
if (initial_repository_ != nullptr)
{
repository_.copyFrom(*initial_repository_);
}
logic_snapshot_ = std::move(snapshot); logic_snapshot_ = std::move(snapshot);
successful_scan_count_ = 0; successful_scan_count_ = 0;
last_error_ = {true, LogicScanError::None, {}, {}, {}, {}}; last_error_ = {true, LogicScanError::None, {}, {}, {}, {}};
@@ -43,6 +51,15 @@ SimulationStartResult OfflineSimulationService::start(
return {true, SimulationStartError::None, {true, LogicScanError::None, {}, {}, {}, {}}}; return {true, SimulationStartError::None, {true, LogicScanError::None, {}, {}, {}, {}}};
} }


void OfflineSimulationService::restoreInitialValues()
{
repository_.clear();
if (initial_repository_ != nullptr)
{
repository_.copyFrom(*initial_repository_);
}
}

void OfflineSimulationService::stop() void OfflineSimulationService::stop()
{ {
timer_.stop(); timer_.stop();
@@ -52,6 +69,8 @@ void OfflineSimulationService::stop()
{ {
return; return;
} }
// 运行结束后丢弃本轮输出,编辑态继续看到下一轮要使用的初始值
restoreInitialValues();
state_ = SimulationState::Stopped; state_ = SimulationState::Stopped;
last_error_ = {true, LogicScanError::None, {}, {}, {}, {}}; last_error_ = {true, LogicScanError::None, {}, {}, {}, {}};
emit stateChanged(); emit stateChanged();
@@ -68,6 +87,7 @@ LogicScanResult OfflineSimulationService::executeOnce()
{}, {},
{}}; {}};
} }
// 执行器负责一轮完整扫描;任何读写错误都会让服务进入 Faulted
const LogicScanResult result = executor_.executeScan( const LogicScanResult result = executor_.executeScan(
logic_snapshot_, repository_, &trace_snapshot_); logic_snapshot_, repository_, &trace_snapshot_);
if (!result.succeeded) if (!result.succeeded)
@@ -112,6 +132,7 @@ void OfflineSimulationService::handleTimeout()


void OfflineSimulationService::enterFault(const LogicScanResult &error) void OfflineSimulationService::enterFault(const LogicScanResult &error)
{ {
// 故障后停止定时器和运行时状态,保留错误及最后轨迹供 UI 诊断
timer_.stop(); timer_.stop();
executor_.resetRuntime(); executor_.resetRuntime();
last_error_ = error; last_error_ = error;


+ 44
- 17
app/src/services/offline_simulation_service.h 查看文件

@@ -1,5 +1,6 @@
#pragma once #pragma once


#include "domain/virtual_register_repository.h"
#include "software_logic_executor.h" #include "software_logic_executor.h"


#include <QObject> #include <QObject>
@@ -8,25 +9,28 @@
#include <cstdint> #include <cstdint>
#include <vector> #include <vector>


// 离线扫描器的生命周期状态
enum class SimulationState enum class SimulationState
{ {
Stopped,
Running,
Faulted
Stopped, // 未运行或已主动停止
Running, // 定时器正在驱动离线扫描
Faulted // 最近一轮扫描失败,定时器已停止
}; };


// 启动离线仿真失败的原因
enum class SimulationStartError enum class SimulationStartError
{ {
None,
AlreadyRunning,
InvalidLogic
None, // 启动成功
AlreadyRunning, // 当前已经处于运行态
InvalidLogic // 启动前逻辑校验失败
}; };


// 离线仿真启动结果;detail 保存领域执行器的具体校验错误
struct SimulationStartResult struct SimulationStartResult
{ {
bool succeeded = false;
SimulationStartError error = SimulationStartError::None;
LogicScanResult detail;
bool succeeded = false; // 是否成功启动仿真
SimulationStartError error = SimulationStartError::None; // 启动失败分类
LogicScanResult detail; // 领域执行器的详细校验或执行结果
}; };


// 管理离线逻辑快照、固定周期扫描和实际执行状态 // 管理离线逻辑快照、固定周期扫描和实际执行状态
@@ -35,36 +39,59 @@ class OfflineSimulationService final : public QObject
Q_OBJECT Q_OBJECT


public: public:
// 默认离线扫描周期,单位为毫秒
static constexpr int kDefaultScanIntervalMs = 50; static constexpr int kDefaultScanIntervalMs = 50;


/**
* @brief 创建离线仿真服务
* @param repository 离线模式使用的虚拟 M/D 寄存器仓库
* @param initial_repository 编辑态确认的离线初始值仓库,可为空以保持旧的全清零行为
* @param parent Qt 对象父级
*/
explicit OfflineSimulationService( explicit OfflineSimulationService(
VirtualRegisterRepository &repository, VirtualRegisterRepository &repository,
VirtualRegisterRepository *initial_repository = nullptr,
QObject *parent = nullptr); QObject *parent = nullptr);


// 复制工程逻辑作为本次会话快照并启动定时扫描;启动失败时不进入运行态
SimulationStartResult start(const std::vector<ControlLogic> &logics); SimulationStartResult start(const std::vector<ControlLogic> &logics);
// 停止扫描并清除运行快照,但保留最近一次轨迹供 UI 显示
void stop(); void stop();
// 立即执行一轮扫描;非 Running 状态调用会返回失败结果
LogicScanResult executeOnce(); LogicScanResult executeOnce();
// 将当前会话的离线初始值恢复到运行仓库
void restoreInitialValues();


// 返回当前离线仿真生命周期状态
SimulationState state() const; SimulationState state() const;
// 返回当前定时器扫描周期,单位为毫秒
int scanIntervalMs() const; int scanIntervalMs() const;
// 返回本次会话成功完成的扫描轮数
std::uint64_t successfulScanCount() const; std::uint64_t successfulScanCount() const;
// 返回最近一次扫描错误;无错误时为成功结果
const LogicScanResult &lastError() const; const LogicScanResult &lastError() const;
// 返回最近一轮按逻辑 ID 隔离的运行轨迹
const LogicTraceSnapshot &traceSnapshot() const; const LogicTraceSnapshot &traceSnapshot() const;


signals: signals:
// 仿真状态从 Stopped、Running 或 Faulted 之一切换时发出
void stateChanged(); void stateChanged();
// 一轮扫描成功完成并更新轨迹后发出
void scanCompleted(); void scanCompleted();


private: private:
// 定时器到期时触发一轮扫描
void handleTimeout(); void handleTimeout();
// 保存错误、停止定时器并切换到 Faulted
void enterFault(const LogicScanResult &error); void enterFault(const LogicScanResult &error);


VirtualRegisterRepository &repository_;
SoftwareLogicExecutor executor_;
QTimer timer_;
std::vector<ControlLogic> logic_snapshot_;
SimulationState state_ = SimulationState::Stopped;
std::uint64_t successful_scan_count_ = 0;
LogicScanResult last_error_{true, LogicScanError::None, {}, {}, {}, {}};
LogicTraceSnapshot trace_snapshot_;
VirtualRegisterRepository &repository_; // 不拥有的虚拟寄存器仓库
VirtualRegisterRepository *initial_repository_ = nullptr; // 不拥有的离线初始值仓库
SoftwareLogicExecutor executor_; // 执行逻辑校验和扫描的领域执行器
QTimer timer_; // 按固定周期触发离线扫描
std::vector<ControlLogic> logic_snapshot_; // 本次仿真使用的逻辑快照
SimulationState state_ = SimulationState::Stopped; // 当前仿真状态
std::uint64_t successful_scan_count_ = 0; // 当前会话成功扫描次数
LogicScanResult last_error_{true, LogicScanError::None, {}, {}, {}, {}}; // 最近一次错误
LogicTraceSnapshot trace_snapshot_; // 最近一次扫描的运行轨迹
}; };

+ 209
- 0
app/src/services/online_logic_monitor_service.cpp 查看文件

@@ -0,0 +1,209 @@
#include "online_logic_monitor_service.h"

#include <algorithm>
#include <utility>

namespace {

LogicScanResult success()
{
return {true, LogicScanError::None, {}, {}, {}, {}};
}

LogicScanResult snapshotFailure(const RegisterAddress &address)
{
return {
false,
LogicScanError::RegisterReadFailed,
"读取 PLC 缓存失败:" + address.toString(),
{},
{},
{}};
}

} // namespace

OnlineLogicMonitorService::OnlineLogicMonitorService(
RegisterRepository &plc_repository,
QObject *parent)
: QObject(parent),
plc_repository_(plc_repository)
{
}

OnlineLogicMonitorStartResult OnlineLogicMonitorService::start(
const std::vector<ControlLogic> &logics)
{
if (state_ == OnlineLogicMonitorState::Running)
{
return {false, OnlineLogicMonitorStartError::AlreadyRunning, {}};
}

std::vector<ControlLogic> snapshot = logics;
const LogicScanResult validation = executor_.validate(snapshot);
if (!validation.succeeded)
{
last_error_ = validation;
state_ = OnlineLogicMonitorState::Faulted;
emit stateChanged();
return {false, OnlineLogicMonitorStartError::InvalidLogic, validation};
}

std::vector<RegisterAddress> addresses;
for (const ControlLogic &logic : snapshot)
{
if (!logic.enabled)
{
continue;
}
for (const LadderRung &rung : logic.rungs)
{
std::vector<const LogicNode *> nodes;
if (rung.condition.has_value())
{
collectConditionNodes(*rung.condition, &nodes);
}
if (rung.output.has_value())
{
nodes.push_back(&*rung.output);
}
for (const LogicNode *node : nodes)
{
collectRegisterAddressesForLogicNode(node->config, &addresses);
}
}
}
std::sort(
addresses.begin(), addresses.end(),
[](const RegisterAddress &left, const RegisterAddress &right)
{
return left.area() == right.area()
? left.index() < right.index()
: left.area() == RegisterArea::M;
});
addresses.erase(std::unique(addresses.begin(), addresses.end()), addresses.end());

executor_.resetRuntime();
working_repository_.clear();
logic_snapshot_ = std::move(snapshot);
referenced_addresses_ = std::move(addresses);
successful_scan_count_ = 0;
last_error_ = success();
trace_snapshot_.clear();
state_ = OnlineLogicMonitorState::Running;
emit stateChanged();

const LogicScanResult first_scan = executeOnce();
if (!first_scan.succeeded)
{
return {
false,
OnlineLogicMonitorStartError::PlcSnapshotUnavailable,
first_scan};
}
return {true, OnlineLogicMonitorStartError::None, success()};
}

void OnlineLogicMonitorService::stop()
{
executor_.resetRuntime();
working_repository_.clear();
logic_snapshot_.clear();
referenced_addresses_.clear();
trace_snapshot_.clear();
successful_scan_count_ = 0;
// 主动停止时保留最近一次故障,便于真机退出后继续查看诊断信息
if (state_ == OnlineLogicMonitorState::Stopped)
{
return;
}
state_ = OnlineLogicMonitorState::Stopped;
emit stateChanged();
}

LogicScanResult OnlineLogicMonitorService::executeOnce()
{
if (state_ != OnlineLogicMonitorState::Running)
{
return {
false,
LogicScanError::InvalidLogic,
"真机本地轨迹尚未运行",
{},
{},
{}};
}

const LogicScanResult copied = copyPlcSnapshot();
if (!copied.succeeded)
{
enterFault(copied);
return copied;
}
const LogicScanResult result = executor_.executeScan(
logic_snapshot_, working_repository_, &trace_snapshot_);
if (!result.succeeded)
{
enterFault(result);
return result;
}
++successful_scan_count_;
emit scanCompleted();
return result;
}

OnlineLogicMonitorState OnlineLogicMonitorService::state() const
{
return state_;
}

std::uint64_t OnlineLogicMonitorService::successfulScanCount() const
{
return successful_scan_count_;
}

const LogicScanResult &OnlineLogicMonitorService::lastError() const
{
return last_error_;
}

const LogicTraceSnapshot &OnlineLogicMonitorService::traceSnapshot() const
{
return trace_snapshot_;
}

LogicScanResult OnlineLogicMonitorService::copyPlcSnapshot()
{
// 每轮从真实读回缓存重新开始,本地输出不会跨轮污染 PLC 数据
working_repository_.clear();
for (const RegisterAddress &address : referenced_addresses_)
{
if (address.area() == RegisterArea::M)
{
const BitReadResult read = plc_repository_.readBit(address);
if (!read.succeeded)
{
return snapshotFailure(address);
}
working_repository_.writeBit(address, read.value);
}
else
{
const WordReadResult read = plc_repository_.readWord(address);
if (!read.succeeded)
{
return snapshotFailure(address);
}
working_repository_.writeWord(address, read.value);
}
}
return success();
}

void OnlineLogicMonitorService::enterFault(const LogicScanResult &error)
{
executor_.resetRuntime();
last_error_ = error;
state_ = OnlineLogicMonitorState::Faulted;
emit stateChanged();
}

+ 70
- 0
app/src/services/online_logic_monitor_service.h 查看文件

@@ -0,0 +1,70 @@
#pragma once

#include "domain/virtual_register_repository.h"
#include "software_logic_executor.h"

#include <QObject>

#include <cstdint>
#include <vector>

enum class OnlineLogicMonitorState
{
Stopped,
Running,
Faulted
};

enum class OnlineLogicMonitorStartError
{
None,
AlreadyRunning,
InvalidLogic,
PlcSnapshotUnavailable
};

struct OnlineLogicMonitorStartResult
{
bool succeeded = false;
OnlineLogicMonitorStartError error = OnlineLogicMonitorStartError::None;
LogicScanResult detail;
};

// 使用 PLC 读回缓存推算本地梯形图轨迹,所有输出只写临时仓库
class OnlineLogicMonitorService final : public QObject
{
Q_OBJECT

public:
explicit OnlineLogicMonitorService(
RegisterRepository &plc_repository,
QObject *parent = nullptr);

OnlineLogicMonitorStartResult start(const std::vector<ControlLogic> &logics);
void stop();
LogicScanResult executeOnce();

OnlineLogicMonitorState state() const;
std::uint64_t successfulScanCount() const;
// 返回最近一次推算错误;主动停止不会清除故障详情,下一次 start 会重置
const LogicScanResult &lastError() const;
const LogicTraceSnapshot &traceSnapshot() const;

signals:
void stateChanged();
void scanCompleted();

private:
LogicScanResult copyPlcSnapshot();
void enterFault(const LogicScanResult &error);

RegisterRepository &plc_repository_;
VirtualRegisterRepository working_repository_;
SoftwareLogicExecutor executor_;
std::vector<ControlLogic> logic_snapshot_;
std::vector<RegisterAddress> referenced_addresses_;
OnlineLogicMonitorState state_ = OnlineLogicMonitorState::Stopped;
std::uint64_t successful_scan_count_ = 0;
LogicScanResult last_error_{true, LogicScanError::None, {}, {}, {}, {}};
LogicTraceSnapshot trace_snapshot_;
};

+ 97
- 28
app/src/services/plc_communication_gateway.h 查看文件

@@ -9,50 +9,61 @@
#include <string> #include <string>
#include <vector> #include <vector>


// PLC 串口和轮询参数;parity 使用 Qt 约定值:0 无、2 偶、3 奇
struct PlcSerialConfiguration struct PlcSerialConfiguration
{ {
std::string portName;
int serverAddress = 1;
int baudRate = 9600;
int dataBits = 8;
int parity = 2;
int stopBits = 1;
int responseTimeoutMs = 1000;
int retries = 2;
int pollIntervalMs = 200;
std::string portName; // 串口名称,例如 COM3
int serverAddress = 1; // Modbus 从站地址,范围为 1~247
int baudRate = 9600; // 串口波特率,支持 9600、19200、38400、57600 和 115200
int dataBits = 8; // 数据位,支持 7 或 8
int parity = 2; // 校验方式,0 为无校验、2 为偶校验、3 为奇校验
int stopBits = 1; // 停止位,支持 1 或 2
int responseTimeoutMs = 1000; // 单次 Modbus 请求的响应超时时间,单位为毫秒
int retries = 2; // 请求失败后的重试次数,范围为 0~5
int pollIntervalMs = 200; // 轮询周期,单位为毫秒,范围为 50~10000
}; };


// PLC 异步通信生命周期
enum class PlcConnectionState enum class PlcConnectionState
{ {
Disconnected,
Connecting,
Connected,
Recovering,
Faulted
Disconnected, // 未建立 PLC 连接
Connecting, // 正在异步建立 PLC 连接
Connected, // 串口已连接并进行正常轮询,不代表首读已完成
Recovering, // 从可恢复通信故障中探测恢复并重新进行首读
Faulted // 当前通信发生故障,等待恢复探测或重新连接
}; };


// 通信故障分类,用于状态栏提示和真机运行资格撤销
enum class PlcCommunicationError enum class PlcCommunicationError
{ {
None,
SerialPortOpenFailed,
PlcNotResponding,
UsbSerialAdapterRemoved,
SerialConnectionLost,
CommunicationTimeout,
ProtocolError,
ReadFailed,
WriteFailed,
ConfigurationError,
RequestAborted,
Unknown
None, // 没有待报告的通信错误
SerialPortOpenFailed, // 串口打开失败
PlcNotResponding, // PLC 未响应请求
UsbSerialAdapterRemoved, // USB 转串口设备被移除
SerialConnectionLost, // 已建立的串口连接意外中断
CommunicationTimeout, // 请求等待响应超时
ProtocolError, // 收到的 Modbus 数据不符合协议
ReadFailed, // 读取请求失败
WriteFailed, // 写入请求失败
ConfigurationError, // 串口或通信参数无效
RequestAborted, // 请求在完成前被中止
Unknown // 未分类的通信错误
}; };


// 通信命令的受理结果;真正的读写完成由回调和缓存更新通知
struct PlcCommunicationResult struct PlcCommunicationResult
{ {
bool succeeded = false;
std::string message;
bool succeeded = false; // 命令是否被接受或参数校验是否通过
std::string message; // 失败原因或补充说明,成功时通常为空
}; };


/**
* @brief 校验 PLC 串口、Modbus 和轮询参数
*
* 此函数只检查配置,不打开串口、不启动通信,也不会修改传入对象
* @param configuration 待校验的连接配置
* @return 校验通过时返回 succeeded 为 true,否则返回 false 和 UTF-8 错误说明
*/
inline PlcCommunicationResult validatePlcSerialConfiguration( inline PlcCommunicationResult validatePlcSerialConfiguration(
const PlcSerialConfiguration &configuration) const PlcSerialConfiguration &configuration)
{ {
@@ -108,23 +119,81 @@ inline PlcCommunicationResult validatePlcSerialConfiguration(
return {true, {}}; return {true, {}};
} }


// UI/运行服务使用的异步 PLC 通信契约,具体实现位于 infrastructure
class PlcCommunicationGateway class PlcCommunicationGateway
{ {
public: public:
// 允许通过网关基类指针安全释放具体通信实现
virtual ~PlcCommunicationGateway() = default; virtual ~PlcCommunicationGateway() = default;


/**
* @brief 校验串口配置并启动异步 PLC 连接
* @param configuration 本次连接使用的串口、Modbus 和轮询参数
* @return true 仅表示连接请求已受理,实际连接结果通过状态回调通知
*/
virtual PlcCommunicationResult connectDevice( virtual PlcCommunicationResult connectDevice(
const PlcSerialConfiguration &configuration) = 0; const PlcSerialConfiguration &configuration) = 0;

/**
* @brief 停止轮询并断开当前 PLC 连接
*
* 断开后当前缓存视为无效,首读资格同时清除
*/
virtual void disconnectDevice() = 0; virtual void disconnectDevice() = 0;

/**
* @brief 设置后续轮询的 M/D 地址集合
*
* 实现可以校验、排序、去重并合并相邻地址块;已有读请求进行时,
* 新集合可以延后到当前请求完成后生效
* @param addresses 需要周期性读回的 M/D 地址集合,空集合使用实现的默认探测地址
* @return true 表示集合已接受,false 表示地址或轮询资源校验失败
*/
virtual PlcCommunicationResult setPollAddresses( virtual PlcCommunicationResult setPollAddresses(
const std::vector<RegisterAddress> &addresses) = 0; const std::vector<RegisterAddress> &addresses) = 0;

/**
* @brief 设置轮询地址并标记必须成对读取的 Float32 起始地址
* @param addresses 需要周期性读回的 M/D 地址集合
* @param float32_starts D 区 Float32 起始地址;每个地址必须和下一个 D 一起读取
* @return true 表示集合已接受
*
* 默认实现兼容只关心地址集合的测试网关和旧调用方;真实 Modbus 实现会使用成对信息避免拆分 Float32
*/
virtual PlcCommunicationResult setPollAddresses(
const std::vector<RegisterAddress> &addresses,
const std::vector<RegisterAddress> &float32_starts)
{
(void)float32_starts;
return setPollAddresses(addresses);
}

// 返回当前连接生命周期状态;Connected 不代表首读资格已经完成
virtual PlcConnectionState state() const = 0; virtual PlcConnectionState state() const = 0;

// 返回本次连接是否已完成全部轮询块的成功首读
virtual bool initialReadCompleted() const = 0; virtual bool initialReadCompleted() const = 0;

// 返回最近一次通信错误的分类;没有错误或错误已清除时返回 None
virtual PlcCommunicationError lastErrorType() const = 0; virtual PlcCommunicationError lastErrorType() const = 0;

// 返回最近一次通信错误的 UTF-8 可读文本;没有错误时返回空字符串
virtual const std::string &lastError() const = 0; virtual const std::string &lastError() const = 0;

/**
* @brief 替换异步通信事件回调
*
* 传入空 std::function 可取消对应通知;回调由实现在线程或事件循环中按事件发生时调用
* @param state_changed 连接状态发生变化时调用
* @param initial_read_changed 首读资格发生变化时调用,参数表示当前是否已完成首读
* @param cache_updated 任一轮询块成功更新 PLC 缓存后调用
* @param poll_cycle_completed 全部轮询块成功更新一轮后调用
* @param error_reported 发生通信错误时调用,参数为 UTF-8 可读错误文本
*/
virtual void setCallbacks( virtual void setCallbacks(
std::function<void()> state_changed, std::function<void()> state_changed,
std::function<void(bool)> initial_read_changed, std::function<void(bool)> initial_read_changed,
std::function<void()> cache_updated, std::function<void()> cache_updated,
std::function<void()> poll_cycle_completed,
std::function<void(const std::string &)> error_reported) = 0; std::function<void(const std::string &)> error_reported) = 0;
}; };

+ 126
- 0
app/src/services/plc_discovery_gateway.cpp 查看文件

@@ -0,0 +1,126 @@
#include "plc_discovery_gateway.h"

#include <algorithm>
#include <array>
#include <tuple>
#include <utility>

namespace {

struct SerialFrame
{
int dataBits = 8;
int parity = 2;
int stopBits = 1;
};

bool isSupportedBaudRate(int baud_rate)
{
return baud_rate == 9600
|| baud_rate == 19200
|| baud_rate == 38400
|| baud_rate == 57600
|| baud_rate == 115200;
}

bool isSupportedFrame(const SerialFrame &frame)
{
return (frame.dataBits == 7 || frame.dataBits == 8)
&& (frame.parity == 0 || frame.parity == 2 || frame.parity == 3)
&& (frame.stopBits == 1 || frame.stopBits == 2);
}

bool sameFrame(const SerialFrame &left, const SerialFrame &right)
{
return std::tie(left.dataBits, left.parity, left.stopBits)
== std::tie(right.dataBits, right.parity, right.stopBits);
}

template<typename Value, typename Equals>
void appendUnique(std::vector<Value> *values, Value value, Equals equals)
{
if (std::none_of(
values->cbegin(), values->cend(),
[&value, &equals](const Value &existing) { return equals(existing, value); }))
{
values->push_back(std::move(value));
}
}

} // namespace

std::vector<PlcSerialConfiguration> buildPlcDiscoveryCandidates(
const PlcSerialConfiguration &preferred,
const std::vector<std::string> &available_ports)
{
std::vector<std::string> ports;
ports.reserve(available_ports.size());
for (const std::string &port : available_ports)
{
if (!port.empty())
{
appendUnique(
&ports, port,
[](const std::string &left, const std::string &right) {
return left == right;
});
}
}
const auto preferred_port = std::find(
ports.begin(), ports.end(), preferred.portName);
if (preferred_port != ports.end())
{
std::rotate(ports.begin(), preferred_port, preferred_port + 1);
}

std::vector<int> baud_rates;
if (isSupportedBaudRate(preferred.baudRate))
{
baud_rates.push_back(preferred.baudRate);
}
constexpr std::array<int, 5> kSupportedBaudRates{
9600, 19200, 38400, 57600, 115200};
for (int baud_rate : kSupportedBaudRates)
{
appendUnique(
&baud_rates, baud_rate,
[](int left, int right) { return left == right; });
}

std::vector<SerialFrame> frames;
const SerialFrame preferred_frame{
preferred.dataBits, preferred.parity, preferred.stopBits};
if (isSupportedFrame(preferred_frame))
{
frames.push_back(preferred_frame);
}
constexpr std::array<SerialFrame, 12> kSupportedFrames{{
{8, 2, 1}, {8, 0, 1}, {8, 3, 1},
{8, 0, 2}, {8, 2, 2}, {8, 3, 2},
{7, 2, 1}, {7, 3, 1}, {7, 0, 1},
{7, 0, 2}, {7, 2, 2}, {7, 3, 2}}};
for (const SerialFrame &frame : kSupportedFrames)
{
appendUnique(&frames, frame, sameFrame);
}

std::vector<PlcSerialConfiguration> candidates;
candidates.reserve(ports.size() * baud_rates.size() * frames.size());
for (const SerialFrame &frame : frames)
{
for (int baud_rate : baud_rates)
{
for (const std::string &port : ports)
{
PlcSerialConfiguration candidate = preferred;
candidate.portName = port;
candidate.baudRate = baud_rate;
candidate.dataBits = frame.dataBits;
candidate.parity = frame.parity;
candidate.stopBits = frame.stopBits;
candidates.push_back(std::move(candidate));
}
}
}
return candidates;
}

+ 53
- 0
app/src/services/plc_discovery_gateway.h 查看文件

@@ -0,0 +1,53 @@
#pragma once

#include "plc_communication_gateway.h"

#include <cstddef>
#include <functional>
#include <string>
#include <vector>

// 自动搜索正在尝试的串口参数和总体进度
struct PlcDiscoveryProgress
{
PlcSerialConfiguration configuration;
std::size_t currentAttempt = 0;
std::size_t totalAttempts = 0;
};

// 自动搜索结束结果;found 和 cancelled 不会同时为 true
struct PlcDiscoveryOutcome
{
bool found = false;
bool cancelled = false;
PlcSerialConfiguration configuration;
std::string message;
};

/**
* 按优先级生成搜索组合
*
* 站号和通信时序沿用界面当前值,只遍历可用端口及项目支持的串口参数
*/
std::vector<PlcSerialConfiguration> buildPlcDiscoveryCandidates(
const PlcSerialConfiguration &preferred,
const std::vector<std::string> &available_ports);

// UI 使用的异步 PLC 自动搜索契约,具体串口实现位于 infrastructure
class PlcDiscoveryGateway
{
public:
virtual ~PlcDiscoveryGateway() = default;

// 开始搜索,实际进度和结果通过回调通知
virtual PlcCommunicationResult startDiscovery(
const PlcSerialConfiguration &preferred) = 0;
// 取消当前搜索;串口释放后通过结束回调通知
virtual void cancelDiscovery() = 0;
// 返回当前是否正在搜索或等待释放搜索串口
virtual bool isDiscovering() const = 0;
// 替换进度和结束回调,传入空函数可取消通知
virtual void setCallbacks(
std::function<void(const PlcDiscoveryProgress &)> progress_changed,
std::function<void(const PlcDiscoveryOutcome &)> discovery_finished) = 0;
};

+ 41
- 2
app/src/services/project_service.cpp 查看文件

@@ -25,7 +25,15 @@ std::string generateProjectId()
} // namespace } // namespace


ProjectService::ProjectService(ProjectStorage &storage) ProjectService::ProjectService(ProjectStorage &storage)
: ProjectService(storage, defaultProjectLimitSettings())
{
}

ProjectService::ProjectService(
ProjectStorage &storage,
const ProjectLimitSettings &project_limits)
: storage_(storage), : storage_(storage),
project_limits_(project_limits),
project_(makeNewProject("未命名工程")) project_(makeNewProject("未命名工程"))
{ {
} }
@@ -57,6 +65,11 @@ bool ProjectService::isModified() const
return modified_; return modified_;
} }


const ProjectLimitSettings &ProjectService::projectLimits() const
{
return project_limits_;
}

void ProjectService::restoreModifiedState(bool modified) void ProjectService::restoreModifiedState(bool modified)
{ {
modified_ = modified; modified_ = modified;
@@ -103,7 +116,7 @@ ProjectOperationResult ProjectService::saveAs(const std::string &file_path)
} }


std::string validation_error; std::string validation_error;
if (!project_.validate(&validation_error))
if (!project_.validate(project_limits_, &validation_error))
{ {
return {false, return {false,
ProjectServiceError::InvalidProject, ProjectServiceError::InvalidProject,
@@ -124,6 +137,31 @@ ProjectOperationResult ProjectService::saveAs(const std::string &file_path)
return {true, ProjectServiceError::None, ProjectStorageError::None, {}}; return {true, ProjectServiceError::None, ProjectStorageError::None, {}};
} }


ProjectOperationResult ProjectService::exportAs(const std::string &file_path) const
{
if (isBlank(file_path))
{
return {false,
ProjectServiceError::FilePathRequired,
ProjectStorageError::None,
"必须指定导出文件路径"};
}
std::string validation_error;
if (!project_.validate(project_limits_, &validation_error))
{
return {false,
ProjectServiceError::InvalidProject,
ProjectStorageError::InvalidProject,
validation_error};
}
const ProjectSaveResult result = storage_.save(project_, file_path);
if (!result.succeeded)
{
return storageFailure(result.error, result.message);
}
return {true, ProjectServiceError::None, ProjectStorageError::None, {}};
}

ProjectOperationResult ProjectService::load(const std::string &file_path) ProjectOperationResult ProjectService::load(const std::string &file_path)
{ {
if (isBlank(file_path)) if (isBlank(file_path))
@@ -134,6 +172,7 @@ ProjectOperationResult ProjectService::load(const std::string &file_path)
"必须指定工程文件路径"}; "必须指定工程文件路径"};
} }


// 存储层先在临时结果中解析,避免半个工程替换当前工程
ProjectLoadResult result = storage_.load(file_path); ProjectLoadResult result = storage_.load(file_path);
if (!result.succeeded) if (!result.succeeded)
{ {
@@ -141,7 +180,7 @@ ProjectOperationResult ProjectService::load(const std::string &file_path)
} }


std::string validation_error; std::string validation_error;
if (!result.project.validate(&validation_error))
if (!result.project.validate(project_limits_, &validation_error))
{ {
return {false, return {false,
ProjectServiceError::InvalidProject, ProjectServiceError::InvalidProject,


+ 10
- 1
app/src/services/project_service.h 查看文件

@@ -14,7 +14,7 @@ enum class ProjectServiceError
StorageFailure // 工程文件存储操作失败 StorageFailure // 工程文件存储操作失败
}; };


// 工程管理操作结果
// 工程管理操作结果;失败时当前工程和文件路径保持原状
struct ProjectOperationResult struct ProjectOperationResult
{ {
bool succeeded = false; // 操作是否成功 bool succeeded = false; // 操作是否成功
@@ -29,12 +29,18 @@ class ProjectService
public: public:
// 使用外部提供的工程存储实现创建服务 // 使用外部提供的工程存储实现创建服务
explicit ProjectService(ProjectStorage &storage); explicit ProjectService(ProjectStorage &storage);
ProjectService(
ProjectStorage &storage,
const ProjectLimitSettings &project_limits);


// 只读访问当前工程;UI 和其他服务不应绕过 editProject 修改它
const Project &project() const; const Project &project() const;
// 获取服务层授权的可编辑引用,调用方必须随后完成校验
Project &editProject(); Project &editProject();
const std::string &currentFilePath() const; const std::string &currentFilePath() const;
bool hasCurrentFile() const; bool hasCurrentFile() const;
bool isModified() const; bool isModified() const;
const ProjectLimitSettings &projectLimits() const;
// 失败编辑回滚时恢复进入编辑前的脏状态 // 失败编辑回滚时恢复进入编辑前的脏状态
void restoreModifiedState(bool modified); void restoreModifiedState(bool modified);


@@ -57,6 +63,8 @@ public:
* @return 保存操作结果 * @return 保存操作结果
*/ */
ProjectOperationResult saveAs(const std::string &file_path); ProjectOperationResult saveAs(const std::string &file_path);
/** 将当前工程导出到指定 JSON,不改变当前工程文件关联 */
ProjectOperationResult exportAs(const std::string &file_path) const;


/** /**
* @brief 从指定文件加载工程 * @brief 从指定文件加载工程
@@ -72,6 +80,7 @@ private:
ProjectStorageError error, const std::string &message); ProjectStorageError error, const std::string &message);


ProjectStorage &storage_; // 非拥有的工程存储依赖 ProjectStorage &storage_; // 非拥有的工程存储依赖
const ProjectLimitSettings &project_limits_; // 应用启动时确定的只读数量上限
Project project_; // 当前编辑中的工程 Project project_; // 当前编辑中的工程
std::string current_file_path_; // 当前工程对应的文件路径 std::string current_file_path_; // 当前工程对应的文件路径
bool modified_ = true; // 当前工程是否有未保存修改 bool modified_ = true; // 当前工程是否有未保存修改


+ 6
- 0
app/src/services/register_comment_service.h 查看文件

@@ -7,6 +7,7 @@


class ProjectService; class ProjectService;


// 工程级寄存器注释操作的失败分类
enum class RegisterCommentError enum class RegisterCommentError
{ {
None, None,
@@ -14,6 +15,7 @@ enum class RegisterCommentError
NotFound NotFound
}; };


// 注释编辑结果
struct RegisterCommentResult struct RegisterCommentResult
{ {
bool succeeded = false; bool succeeded = false;
@@ -21,15 +23,19 @@ struct RegisterCommentResult
std::string message; std::string message;
}; };


// 维护工程里的 M/D 注释,不读取也不修改寄存器实时值
class RegisterCommentService class RegisterCommentService
{ {
public: public:
explicit RegisterCommentService(ProjectService &project_service); explicit RegisterCommentService(ProjectService &project_service);


const std::vector<RegisterComment> &comments() const; const std::vector<RegisterComment> &comments() const;
// 按地址查找注释
const RegisterComment *findComment(const RegisterAddress &address) const; const RegisterComment *findComment(const RegisterAddress &address) const;
// 新建或更新注释;空文本通常表示删除该注释
RegisterCommentResult setComment( RegisterCommentResult setComment(
const RegisterAddress &address, const std::string &text); const RegisterAddress &address, const std::string &text);
// 删除指定地址注释
RegisterCommentResult removeComment(const RegisterAddress &address); RegisterCommentResult removeComment(const RegisterAddress &address);


private: private:


+ 200
- 21
app/src/services/register_monitor_service.cpp 查看文件

@@ -1,6 +1,10 @@
#include "register_monitor_service.h" #include "register_monitor_service.h"


#include "domain/virtual_register_repository.h"

#include <algorithm> #include <algorithm>
#include <cmath>
#include <optional>
#include <utility> #include <utility>


namespace { namespace {
@@ -29,8 +33,25 @@ RegisterMonitorService::RegisterMonitorService(RegisterRepository &repository)
{ {
} }


void RegisterMonitorService::setOfflineInitialRepository(
VirtualRegisterRepository &repository)
{
offline_initial_repository_ = &repository;
}

void RegisterMonitorService::setOfflineInitialCaptureEnabled(bool enabled)
{
capture_offline_initial_values_ = enabled;
}

RegisterMonitorResult RegisterMonitorService::addRange( RegisterMonitorResult RegisterMonitorService::addRange(
const std::string &start_address, int count) const std::string &start_address, int count)
{
return addRange(start_address, count, RegisterDataType::Int16);
}

RegisterMonitorResult RegisterMonitorService::addRange(
const std::string &start_address, int count, RegisterDataType data_type)
{ {
const RegisterAddressParseResult parsed = parseRegisterAddress(start_address); const RegisterAddressParseResult parsed = parseRegisterAddress(start_address);
if (!parsed.succeeded) if (!parsed.succeeded)
@@ -38,8 +59,15 @@ RegisterMonitorResult RegisterMonitorService::addRange(
return {false, RegisterMonitorError::InvalidAddress, return {false, RegisterMonitorError::InvalidAddress,
parseErrorMessage(parsed.error), 0}; parseErrorMessage(parsed.error), 0};
} }
if (parsed.address.area() == RegisterArea::M
&& data_type == RegisterDataType::Float32)
{
return {false, RegisterMonitorError::InvalidAddress,
"Float32 只允许使用 D 区地址", 0};
}
const int step = registerDataTypeWordCount(data_type);
if (count < 1 if (count < 1
|| parsed.address.index() > RegisterAddress::kMaximumIndex - (count - 1))
|| parsed.address.index() > RegisterAddress::kMaximumIndex - step * count + 1)
{ {
return {false, RegisterMonitorError::RangeOverflow, return {false, RegisterMonitorError::RangeOverflow,
"连续监控范围不能超过地址 4000", 0}; "连续监控范围不能超过地址 4000", 0};
@@ -49,10 +77,13 @@ RegisterMonitorResult RegisterMonitorService::addRange(
pending.reserve(static_cast<std::size_t>(count)); pending.reserve(static_cast<std::size_t>(count));
for (int offset = 0; offset < count; ++offset) for (int offset = 0; offset < count; ++offset)
{ {
RegisterAddress address{parsed.address.area(), parsed.address.index() + offset};
if (!model_.contains(address))
MonitorPoint point{
RegisterAddress{
parsed.address.area(), parsed.address.index() + offset * step},
data_type};
if (!model_.contains(point))
{ {
pending.push_back(address);
pending.push_back(point.address);
} }
} }
if (pending.empty()) if (pending.empty())
@@ -66,13 +97,32 @@ RegisterMonitorResult RegisterMonitorService::addRange(
} }
for (const RegisterAddress &address : pending) for (const RegisterAddress &address : pending)
{ {
model_.add(address);
model_.add(MonitorPoint{address, data_type});
} }
if (addresses_changed_callback_) if (addresses_changed_callback_)
{ {
addresses_changed_callback_(); addresses_changed_callback_();
} }
return {true, RegisterMonitorError::None, {}, static_cast<int>(pending.size())};
bool overlaps = false;
for (const MonitorPoint &existing : model_.points())
{
const int existing_end = existing.address.index()
+ registerDataTypeWordCount(existing.dataType) - 1;
for (const RegisterAddress &address : pending)
{
const int pending_end = address.index() + step - 1;
if (existing.address.area() == address.area()
&& existing.address.index() <= pending_end
&& address.index() <= existing_end
&& !(existing.address == address && existing.dataType == data_type))
{
overlaps = true;
}
}
}
return {true, RegisterMonitorError::None,
overlaps ? "已添加,但与已有监控点的 D 地址范围重叠" : std::string{},
static_cast<int>(pending.size())};
} }


RegisterMonitorResult RegisterMonitorService::remove( RegisterMonitorResult RegisterMonitorService::remove(
@@ -97,6 +147,28 @@ RegisterMonitorResult RegisterMonitorService::remove(
return {true, RegisterMonitorError::None, {}, removed_count}; return {true, RegisterMonitorError::None, {}, removed_count};
} }


RegisterMonitorResult RegisterMonitorService::remove(
const std::vector<MonitorPoint> &points)
{
int removed_count = 0;
for (const MonitorPoint &point : points)
{
if (model_.remove(point))
{
++removed_count;
}
}
if (removed_count == 0)
{
return {false, RegisterMonitorError::NoChange, "请先选择要删除的监控点", 0};
}
if (addresses_changed_callback_)
{
addresses_changed_callback_();
}
return {true, RegisterMonitorError::None, {}, removed_count};
}

RegisterMonitorResult RegisterMonitorService::clear() RegisterMonitorResult RegisterMonitorService::clear()
{ {
if (model_.addresses().empty()) if (model_.addresses().empty())
@@ -116,34 +188,90 @@ RegisterMonitorWriteResult RegisterMonitorService::writeBit(
const RegisterAddress &address, bool value) const RegisterAddress &address, bool value)
{ {
const RegisterWriteResult result = repository_.writeBit(address, value); const RegisterWriteResult result = repository_.writeBit(address, value);
if (result.succeeded)
{
return {true, RegisterError::None, {}};
}
return {false, result.error, result.error == RegisterError::AreaMismatch
const RegisterMonitorWriteResult active_result = result.succeeded
? RegisterMonitorWriteResult{true, RegisterError::None, {}}
: RegisterMonitorWriteResult{false, result.error, result.error == RegisterError::AreaMismatch
? "位写入只允许访问 M 区" ? "位写入只允许访问 M 区"
: result.error == RegisterError::InvalidAddress : result.error == RegisterError::InvalidAddress
? "写入地址无效" ? "写入地址无效"
: result.error == RegisterError::Unavailable : result.error == RegisterError::Unavailable
? "当前寄存器源不可用" ? "当前寄存器源不可用"
: "寄存器写入请求被拒绝"}; : "寄存器写入请求被拒绝"};
return captureInitialBit(address, value, active_result);
} }


RegisterMonitorWriteResult RegisterMonitorService::writeWord( RegisterMonitorWriteResult RegisterMonitorService::writeWord(
const RegisterAddress &address, std::int16_t value) const RegisterAddress &address, std::int16_t value)
{ {
const RegisterWriteResult result = repository_.writeWord(address, value); const RegisterWriteResult result = repository_.writeWord(address, value);
if (result.succeeded)
{
return {true, RegisterError::None, {}};
}
return {false, result.error, result.error == RegisterError::AreaMismatch
const RegisterMonitorWriteResult active_result = result.succeeded
? RegisterMonitorWriteResult{true, RegisterError::None, {}}
: RegisterMonitorWriteResult{false, result.error, result.error == RegisterError::AreaMismatch
? "字写入只允许访问 D 区" ? "字写入只允许访问 D 区"
: result.error == RegisterError::InvalidAddress : result.error == RegisterError::InvalidAddress
? "写入地址无效" ? "写入地址无效"
: result.error == RegisterError::Unavailable : result.error == RegisterError::Unavailable
? "当前寄存器源不可用" ? "当前寄存器源不可用"
: "寄存器写入请求被拒绝"}; : "寄存器写入请求被拒绝"};
return captureInitialWord(address, value, active_result);
}

RegisterMonitorWriteResult RegisterMonitorService::writeFloat(
const RegisterAddress &address, float value)
{
if (!std::isfinite(value) || !address.isValid()
|| address.area() != RegisterArea::D
|| address.index() >= RegisterAddress::kMaximumIndex)
{
return {false, RegisterError::InvalidAddress, "Float32 写入值或地址无效"};
}
const RegisterWriteResult result = repository_.writeWordPair(
address, Float32Codec::encode(value));
const RegisterMonitorWriteResult active_result = result.succeeded
? RegisterMonitorWriteResult{true, RegisterError::None, {}}
: RegisterMonitorWriteResult{false, result.error, result.error == RegisterError::Unavailable
? "当前寄存器源不可用" : "寄存器写入请求被拒绝"};
return captureInitialFloat(address, value, active_result);
}

RegisterMonitorWriteResult RegisterMonitorService::captureInitialBit(
const RegisterAddress &address, bool value,
const RegisterMonitorWriteResult &active_result)
{
if (!active_result.succeeded || !capture_offline_initial_values_
|| offline_initial_repository_ == nullptr)
{
return active_result;
}
offline_initial_repository_->writeBit(address, value);
return active_result;
}

RegisterMonitorWriteResult RegisterMonitorService::captureInitialWord(
const RegisterAddress &address, std::int16_t value,
const RegisterMonitorWriteResult &active_result)
{
if (!active_result.succeeded || !capture_offline_initial_values_
|| offline_initial_repository_ == nullptr)
{
return active_result;
}
offline_initial_repository_->writeWord(address, value);
return active_result;
}

RegisterMonitorWriteResult RegisterMonitorService::captureInitialFloat(
const RegisterAddress &address, float value,
const RegisterMonitorWriteResult &active_result)
{
if (!active_result.succeeded || !capture_offline_initial_values_
|| offline_initial_repository_ == nullptr)
{
return active_result;
}
offline_initial_repository_->writeWordPair(
address, Float32Codec::encode(value));
return active_result;
} }


const std::vector<RegisterAddress> &RegisterMonitorService::addresses() const const std::vector<RegisterAddress> &RegisterMonitorService::addresses() const
@@ -154,11 +282,15 @@ const std::vector<RegisterAddress> &RegisterMonitorService::addresses() const
std::vector<MonitorValue> RegisterMonitorService::values(bool communication_fault) const std::vector<MonitorValue> RegisterMonitorService::values(bool communication_fault) const
{ {
std::vector<MonitorValue> result; std::vector<MonitorValue> result;
result.reserve(model_.addresses().size());
for (const RegisterAddress &address : model_.addresses())
result.reserve(model_.points().size());
for (const MonitorPoint &point : model_.points())
{ {
const RegisterAddress &address = point.address;
MonitorValue value; MonitorValue value;
value.address = address; value.address = address;
value.dataType = point.dataType;
value.endAddress = address.index()
+ registerDataTypeWordCount(point.dataType) - 1;
if (address.area() == RegisterArea::M) if (address.area() == RegisterArea::M)
{ {
const BitReadResult read = repository_.readBit(address); const BitReadResult read = repository_.readBit(address);
@@ -170,18 +302,65 @@ std::vector<MonitorValue> RegisterMonitorService::values(bool communication_faul
} }
else else
{ {
const WordReadResult read = repository_.readWord(address);
value.wordValue = read.value;
value.state = read.succeeded
if (point.dataType == RegisterDataType::Float32)
{
const WordPairReadResult read = repository_.readWordPair(address);
const std::optional<float> decoded = read.succeeded
? Float32Codec::decode(read.values[0], read.values[1])
: std::nullopt;
value.floatValue = decoded.value_or(0.0f);
value.state = decoded.has_value()
? (communication_fault ? MonitorValueState::CommunicationFault
: MonitorValueState::Valid)
: MonitorValueState::Unavailable;
}
else
{
const WordReadResult read = repository_.readWord(address);
value.wordValue = read.value;
value.state = read.succeeded
? (communication_fault ? MonitorValueState::CommunicationFault ? (communication_fault ? MonitorValueState::CommunicationFault
: MonitorValueState::Valid) : MonitorValueState::Valid)
: MonitorValueState::Unavailable; : MonitorValueState::Unavailable;
}
} }
result.push_back(value); result.push_back(value);
} }
return result; return result;
} }


const std::vector<MonitorPoint> &RegisterMonitorService::points() const
{
return model_.points();
}

std::vector<RegisterAddress> RegisterMonitorService::pollAddresses() const
{
std::vector<RegisterAddress> result;
for (const MonitorPoint &point : model_.points())
{
for (int offset = 0; offset < registerDataTypeWordCount(point.dataType); ++offset)
{
result.push_back(RegisterAddress{
point.address.area(), point.address.index() + offset});
}
}
return result;
}

std::vector<RegisterAddress> RegisterMonitorService::float32Starts() const
{
std::vector<RegisterAddress> result;
for (const MonitorPoint &point : model_.points())
{
if (point.dataType == RegisterDataType::Float32)
{
result.push_back(point.address);
}
}
return result;
}

void RegisterMonitorService::setAddressesChangedCallback(std::function<void()> callback) void RegisterMonitorService::setAddressesChangedCallback(std::function<void()> callback)
{ {
addresses_changed_callback_ = std::move(callback); addresses_changed_callback_ = std::move(callback);


+ 94
- 15
app/src/services/register_monitor_service.h 查看文件

@@ -8,47 +8,126 @@
#include <string> #include <string>
#include <vector> #include <vector>


class VirtualRegisterRepository;

// 自由监控列表编辑的失败分类
enum class RegisterMonitorError enum class RegisterMonitorError
{ {
None,
InvalidAddress,
RangeOverflow,
LimitExceeded,
NoChange
None, // 操作成功
InvalidAddress, // 起始地址格式或区域不支持
RangeOverflow, // 连续范围超出 M/D 地址上限
LimitExceeded, // 监控地址数量超过会话上限
NoChange // 操作没有产生任何列表变化
}; };


// 监控列表编辑结果;affectedCount 表示本次受影响的地址数量
struct RegisterMonitorResult struct RegisterMonitorResult
{ {
bool succeeded = false;
RegisterMonitorError error = RegisterMonitorError::None;
std::string message;
int affectedCount = 0;
bool succeeded = false; // 列表编辑是否成功
RegisterMonitorError error = RegisterMonitorError::None; // 失败时的分类
std::string message; // 面向用户的 UTF-8 成功说明或失败原因
int affectedCount = 0; // 本次新增或删除的地址数量
}; };


// 自由监控写入结果;底层 RegisterError 会原样保留
struct RegisterMonitorWriteResult struct RegisterMonitorWriteResult
{ {
bool succeeded = false;
RegisterError error = RegisterError::Unavailable;
std::string message;
bool succeeded = false; // 写入请求是否成功提交
RegisterError error = RegisterError::Unavailable; // 底层仓库错误分类
std::string message; // 面向用户的 UTF-8 错误说明
}; };


/**
* @brief 编排自由监控地址列表和寄存器仓库读写
*
* 地址列表和离线初始值只属于当前运行会话,不写入工程文件;仓库由当前运行模式注入
*/
class RegisterMonitorService class RegisterMonitorService
{ {
public: public:
/**
* @brief 创建自由监控服务
* @param repository 当前运行模式使用的寄存器仓库,不由服务拥有
*/
explicit RegisterMonitorService(RegisterRepository &repository); explicit RegisterMonitorService(RegisterRepository &repository);


// 配置编辑态离线初始值仓库;该仓库只保存在当前进程内
void setOfflineInitialRepository(VirtualRegisterRepository &repository);
// 编辑态开启时,监控写入同时更新离线初始值
void setOfflineInitialCaptureEnabled(bool enabled);

/**
* @brief 从起始地址连续添加同一区域的多个地址
* @param start_address 文本地址,例如 M0 或 D100
* @param count 连续地址数量,必须为正且不能越过 4000
* @return 去重后实际新增数量;格式、范围、容量或无变化时返回失败
*/
RegisterMonitorResult addRange(const std::string &start_address, int count); RegisterMonitorResult addRange(const std::string &start_address, int count);
RegisterMonitorResult addRange(
const std::string &start_address, int count, RegisterDataType data_type);
/**
* @brief 删除一批监控地址
* @param addresses 待删除地址;不存在的地址会被忽略
* @return 实际删除数量,并在发生变化时通知轮询集合更新
*/
RegisterMonitorResult remove(const std::vector<RegisterAddress> &addresses); RegisterMonitorResult remove(const std::vector<RegisterAddress> &addresses);
RegisterMonitorResult remove(const std::vector<MonitorPoint> &points);
/**
* @brief 清空当前会话的监控地址列表
* @return 实际清除数量;列表本来为空时返回 NoChange
*/
RegisterMonitorResult clear(); RegisterMonitorResult clear();
/**
* @brief 对单个 M 地址提交位写入
* @param address 必须是有效的 M 区地址
* @param value 要写入的位值
* @return 成功或包含底层 RegisterError 和用户可读文本的失败结果
*/
RegisterMonitorWriteResult writeBit(const RegisterAddress &address, bool value); RegisterMonitorWriteResult writeBit(const RegisterAddress &address, bool value);
/**
* @brief 对单个 D 地址提交有符号 16 位字写入
* @param address 必须是有效的 D 区地址
* @param value 要写入的 D 字值
* @return 成功或包含底层 RegisterError 和用户可读文本的失败结果
*/
RegisterMonitorWriteResult writeWord( RegisterMonitorWriteResult writeWord(
const RegisterAddress &address, std::int16_t value); const RegisterAddress &address, std::int16_t value);
RegisterMonitorWriteResult writeFloat(
const RegisterAddress &address, float value);
/**
* @brief 返回当前会话的监控地址列表
* @return 按服务内部顺序保存的只读地址列表
*/
const std::vector<RegisterAddress> &addresses() const; const std::vector<RegisterAddress> &addresses() const;
const std::vector<MonitorPoint> &points() const;
std::vector<RegisterAddress> pollAddresses() const;
std::vector<RegisterAddress> float32Starts() const;
/**
* @brief 读取监控列表的当前值并生成 UI 展示模型
* @param communication_fault 通信处于故障态时,将成功读回值标记为通信故障
* @return 每个监控地址对应一个值和可用性状态
*/
std::vector<MonitorValue> values(bool communication_fault) const; std::vector<MonitorValue> values(bool communication_fault) const;
/**
* @brief 设置监控地址变化通知回调
* @param callback 地址列表新增、删除或清空后调用;可传空函数取消通知
*/
void setAddressesChangedCallback(std::function<void()> callback); void setAddressesChangedCallback(std::function<void()> callback);


private: private:
RegisterRepository &repository_;
RegisterMonitorModel model_;
std::function<void()> addresses_changed_callback_;
RegisterMonitorWriteResult captureInitialBit(
const RegisterAddress &address, bool value,
const RegisterMonitorWriteResult &active_result);
RegisterMonitorWriteResult captureInitialWord(
const RegisterAddress &address, std::int16_t value,
const RegisterMonitorWriteResult &active_result);
RegisterMonitorWriteResult captureInitialFloat(
const RegisterAddress &address, float value,
const RegisterMonitorWriteResult &active_result);

RegisterRepository &repository_; // 当前运行模式的寄存器仓库,不由服务拥有
VirtualRegisterRepository *offline_initial_repository_ = nullptr;
bool capture_offline_initial_values_ = false;
RegisterMonitorModel model_; // 当前会话的去重监控地址列表
std::function<void()> addresses_changed_callback_; // 地址变化后的轮询刷新通知
}; };

+ 101
- 8
app/src/services/runtime_mode_service.cpp 查看文件

@@ -16,9 +16,11 @@


RuntimeModeService::RuntimeModeService( RuntimeModeService::RuntimeModeService(
const ProjectService &project_service, const ProjectService &project_service,
OfflineSimulationService &offline_simulation_service)
OfflineSimulationService &offline_simulation_service,
OnlineLogicMonitorService &online_logic_monitor_service)
: project_service_(project_service), : project_service_(project_service),
offline_simulation_service_(offline_simulation_service)
offline_simulation_service_(offline_simulation_service),
online_logic_monitor_service_(online_logic_monitor_service)
{ {
} }


@@ -26,7 +28,7 @@ RuntimeModeService::~RuntimeModeService()
{ {
if (plc_gateway_ != nullptr) if (plc_gateway_ != nullptr)
{ {
plc_gateway_->setCallbacks({}, {}, {}, {});
plc_gateway_->setCallbacks({}, {}, {}, {}, {});
} }
} }


@@ -47,6 +49,10 @@ ModeTransitionResult RuntimeModeService::enterEditing()
// 先停止扫描再开放编辑,防止运行快照继续写寄存器 // 先停止扫描再开放编辑,防止运行快照继续写寄存器
offline_simulation_service_.stop(); offline_simulation_service_.stop();
} }
else if (state_.mode() == ApplicationMode::OnlineRunning)
{
online_logic_monitor_service_.stop();
}
const ModeTransitionResult result = state_.enterEditing(); const ModeTransitionResult result = state_.enterEditing();
if (result.succeeded && active_repository_ != nullptr if (result.succeeded && active_repository_ != nullptr
&& virtual_repository_ != nullptr) && virtual_repository_ != nullptr)
@@ -63,14 +69,17 @@ ModeTransitionResult RuntimeModeService::enterOfflineRunning()
return state_.enterOfflineRunning(); return state_.enterOfflineRunning();
} }
std::string error; std::string error;
if (!project_service_.project().validateForRunning(&error))
if (!project_service_.project().validateForRunning(
project_service_.projectLimits(), &error))
{ {
return {false, ModeTransitionError::ProjectNotReady}; return {false, ModeTransitionError::ProjectNotReady};
} }
// 离线仿真必须先切到虚拟仓库,避免扫描结果写入 PLC 缓存
if (active_repository_ != nullptr && virtual_repository_ != nullptr) if (active_repository_ != nullptr && virtual_repository_ != nullptr)
{ {
active_repository_->use(*virtual_repository_); active_repository_->use(*virtual_repository_);
} }
// start() 会复制逻辑快照并做运行前校验;失败时不进入运行态
const SimulationStartResult start_result = offline_simulation_service_.start( const SimulationStartResult start_result = offline_simulation_service_.start(
project_service_.project().controlLogics); project_service_.project().controlLogics);
if (!start_result.succeeded) if (!start_result.succeeded)
@@ -87,12 +96,30 @@ ModeTransitionResult RuntimeModeService::enterOfflineRunning()


ModeTransitionResult RuntimeModeService::enterOnlineRunning() ModeTransitionResult RuntimeModeService::enterOnlineRunning()
{ {
const ModeTransitionResult result = state_.enterOnlineRunning(
initialPlcReadCompleted());
if (state_.mode() != ApplicationMode::Editing)
{
return state_.enterOnlineRunning(initialPlcReadCompleted());
}
if (!initialPlcReadCompleted())
{
return state_.enterOnlineRunning(false);
}
const OnlineLogicMonitorStartResult start_result =
online_logic_monitor_service_.start(
project_service_.project().controlLogics);
if (!start_result.succeeded)
{
return {false, ModeTransitionError::SimulationStartFailed};
}
const ModeTransitionResult result = state_.enterOnlineRunning(true);
if (result.succeeded && active_repository_ != nullptr && plc_repository_ != nullptr) if (result.succeeded && active_repository_ != nullptr && plc_repository_ != nullptr)
{ {
active_repository_->use(*plc_repository_); active_repository_->use(*plc_repository_);
} }
else if (!result.succeeded)
{
online_logic_monitor_service_.stop();
}
return result; return result;
} }


@@ -130,6 +157,11 @@ OfflineSimulationService &RuntimeModeService::offlineSimulationService()
return offline_simulation_service_; return offline_simulation_service_;
} }


OnlineLogicMonitorService &RuntimeModeService::onlineLogicMonitorService()
{
return online_logic_monitor_service_;
}

void RuntimeModeService::configurePlc( void RuntimeModeService::configurePlc(
PlcCommunicationGateway &gateway, PlcCommunicationGateway &gateway,
ActiveRegisterRepository &active_repository, ActiveRegisterRepository &active_repository,
@@ -140,6 +172,7 @@ void RuntimeModeService::configurePlc(
active_repository_ = &active_repository; active_repository_ = &active_repository;
virtual_repository_ = &virtual_repository; virtual_repository_ = &virtual_repository;
plc_repository_ = &plc_repository; plc_repository_ = &plc_repository;
// 通信故障撤销首读资格;若正在真机运行,立即回到编辑态
gateway.setCallbacks( gateway.setCallbacks(
[this] [this]
{ {
@@ -170,6 +203,25 @@ void RuntimeModeService::configurePlc(
} }
}, },
[] {}, [] {},
[this]
{
if (state_.mode() == ApplicationMode::OnlineRunning
&& online_logic_monitor_service_.state()
== OnlineLogicMonitorState::Running)
{
const LogicScanResult result =
online_logic_monitor_service_.executeOnce();
if (!result.succeeded
&& state_.mode() == ApplicationMode::OnlineRunning)
{
enterEditing();
if (plc_status_changed_callback_)
{
plc_status_changed_callback_();
}
}
}
},
[this](const std::string &) [this](const std::string &)
{ {
if (plc_status_changed_callback_) if (plc_status_changed_callback_)
@@ -201,8 +253,16 @@ PlcCommunicationResult RuntimeModeService::connectPlc(


void RuntimeModeService::setMonitorAddresses( void RuntimeModeService::setMonitorAddresses(
const std::vector<RegisterAddress> &addresses) const std::vector<RegisterAddress> &addresses)
{
setMonitorAddresses(addresses, {});
}

void RuntimeModeService::setMonitorAddresses(
const std::vector<RegisterAddress> &addresses,
const std::vector<RegisterAddress> &float32_starts)
{ {
monitor_addresses_ = addresses; monitor_addresses_ = addresses;
monitor_float32_starts_ = float32_starts;
refreshPlcPollAddresses(); refreshPlcPollAddresses();
} }


@@ -212,7 +272,9 @@ PlcCommunicationResult RuntimeModeService::refreshPlcPollAddresses()
{ {
return {false, "PLC 通信服务尚未配置"}; return {false, "PLC 通信服务尚未配置"};
} }
// 轮询集合来自自由监控、HMI、报警和梯形图引用的并集
std::vector<RegisterAddress> addresses = monitor_addresses_; std::vector<RegisterAddress> addresses = monitor_addresses_;
std::vector<RegisterAddress> float32_starts = monitor_float32_starts_;
const Project &project = project_service_.project(); const Project &project = project_service_.project();
for (const HmiPage &page : project.hmiPages) for (const HmiPage &page : project.hmiPages)
{ {
@@ -221,6 +283,15 @@ PlcCommunicationResult RuntimeModeService::refreshPlcPollAddresses()
if (control.binding.has_value()) if (control.binding.has_value())
{ {
addresses.push_back(*control.binding); addresses.push_back(*control.binding);
if ((control.type == HmiControlType::NumericDisplay
|| control.type == HmiControlType::NumericInput)
&& control.dataType == RegisterDataType::Float32
&& control.binding->area() == RegisterArea::D)
{
addresses.push_back(RegisterAddress{
RegisterArea::D, control.binding->index() + 1});
float32_starts.push_back(*control.binding);
}
} }
} }
} }
@@ -262,12 +333,34 @@ PlcCommunicationResult RuntimeModeService::refreshPlcPollAddresses()
} }
return left.index() < right.index(); return left.index() < right.index();
}); });
// 排序后去重,保证同一个地址只占一份轮询配额
addresses.erase(std::unique(addresses.begin(), addresses.end()), addresses.end()); addresses.erase(std::unique(addresses.begin(), addresses.end()), addresses.end());
if (addresses.size() > ProjectLimits::kMaximumPollAddresses) if (addresses.size() > ProjectLimits::kMaximumPollAddresses)
{ {
return {false, "PLC 轮询的去重 M/D 地址最多为 1024 个"};
return {false, "PLC 轮询的去重 M/D 地址最多为 256 个"};
} }
return plc_gateway_->setPollAddresses(addresses);
std::sort(
float32_starts.begin(), float32_starts.end(),
[](const RegisterAddress &left, const RegisterAddress &right)
{
return left.area() == right.area()
? left.index() < right.index()
: left.area() == RegisterArea::M;
});
float32_starts.erase(
std::unique(float32_starts.begin(), float32_starts.end()),
float32_starts.end());
float32_starts.erase(
std::remove_if(
float32_starts.begin(), float32_starts.end(),
[](const RegisterAddress &address)
{
return !address.isValid()
|| address.area() != RegisterArea::D
|| address.index() >= RegisterAddress::kMaximumIndex;
}),
float32_starts.end());
return plc_gateway_->setPollAddresses(addresses, float32_starts);
} }


void RuntimeModeService::disconnectPlc() void RuntimeModeService::disconnectPlc()


+ 68
- 12
app/src/services/runtime_mode_service.h 查看文件

@@ -10,6 +10,7 @@


#include "domain/runtime_state.h" #include "domain/runtime_state.h"
#include "offline_simulation_service.h" #include "offline_simulation_service.h"
#include "online_logic_monitor_service.h"
#include "plc_communication_gateway.h" #include "plc_communication_gateway.h"


#include <cstdint> #include <cstdint>
@@ -20,15 +21,26 @@ class ProjectService;
class ActiveRegisterRepository; class ActiveRegisterRepository;
class RegisterRepository; class RegisterRepository;


// 隔离 UI 与领域状态机并编排进入真机运行态的前置条件
// 隔离 UI 与领域状态机并编排寄存器仓库切换和进入真机的前置条件
class RuntimeModeService class RuntimeModeService
{ {
public: public:
/**
* @brief 创建运行模式服务
* @param project_service 只读工程服务,用于运行前校验和收集轮询地址
* @param offline_simulation_service 离线仿真服务,由应用层负责其生命周期
*/
RuntimeModeService( RuntimeModeService(
const ProjectService &project_service, const ProjectService &project_service,
OfflineSimulationService &offline_simulation_service);
OfflineSimulationService &offline_simulation_service,
OnlineLogicMonitorService &online_logic_monitor_service);

/**
* @brief 解除 PLC 网关回调绑定
*/
~RuntimeModeService(); ~RuntimeModeService();


/** @brief 获取当前应用运行模式 */
ApplicationMode mode() const; ApplicationMode mode() const;
/** /**
* @brief 获取当前模式下允许的工程编辑和寄存器使用策略 * @brief 获取当前模式下允许的工程编辑和寄存器使用策略
@@ -65,33 +77,77 @@ public:
*/ */
bool initialPlcReadCompleted() const; bool initialPlcReadCompleted() const;


/** @brief 获取离线仿真当前状态 */
SimulationState simulationState() const; SimulationState simulationState() const;
/** @brief 获取离线仿真成功完成的扫描轮数 */
std::uint64_t successfulScanCount() const; std::uint64_t successfulScanCount() const;
/** @brief 获取离线仿真最近一次扫描错误 */
const LogicScanResult &simulationError() const; const LogicScanResult &simulationError() const;
/** @brief 返回服务持有的离线仿真服务引用 */
OfflineSimulationService &offlineSimulationService(); OfflineSimulationService &offlineSimulationService();
OnlineLogicMonitorService &onlineLogicMonitorService();

/**
* @brief 注入 PLC 网关、活动仓库和两种实际数据源
*
* 应用生命周期内只调用一次;真机模式使用 PLC 仓库,编辑/离线模式使用虚拟仓库
*/
void configurePlc( void configurePlc(
PlcCommunicationGateway &gateway, PlcCommunicationGateway &gateway,
ActiveRegisterRepository &active_repository, ActiveRegisterRepository &active_repository,
RegisterRepository &virtual_repository, RegisterRepository &virtual_repository,
RegisterRepository &plc_repository); RegisterRepository &plc_repository);
/**
* @brief 连接 PLC 并开始异步通信
* @param configuration 串口、Modbus 和轮询配置
* @return 连接请求受理结果,不阻塞 UI 等待首读完成
*/
PlcCommunicationResult connectPlc(const PlcSerialConfiguration &configuration); PlcCommunicationResult connectPlc(const PlcSerialConfiguration &configuration);

/**
* @brief 设置自由监控引用的地址并刷新 PLC 轮询集合
* @param addresses 自由监控当前需要读取的 M/D 地址
*/
void setMonitorAddresses(const std::vector<RegisterAddress> &addresses); void setMonitorAddresses(const std::vector<RegisterAddress> &addresses);
void setMonitorAddresses(
const std::vector<RegisterAddress> &addresses,
const std::vector<RegisterAddress> &float32_starts);

/**
* @brief 汇总工程和自由监控引用并刷新 PLC 轮询地址
* @return 网关未配置或地址数量超限时返回失败结果
*/
PlcCommunicationResult refreshPlcPollAddresses(); PlcCommunicationResult refreshPlcPollAddresses();

/**
* @brief 退出真机运行、断开 PLC 并清除首读资格
*/
void disconnectPlc(); void disconnectPlc();

/** @brief 获取当前 PLC 连接状态;网关未配置时返回 Disconnected */
PlcConnectionState plcConnectionState() const; PlcConnectionState plcConnectionState() const;

/** @brief 获取 PLC 最近一次通信错误文本;网关未配置时返回空字符串 */
const std::string &plcError() const; const std::string &plcError() const;

/**
* @brief 设置 PLC 状态变化通知回调
* @param callback 状态、首读资格或通信错误变化时调用的函数;可传空函数取消通知
*/
void setPlcStatusChangedCallback(std::function<void()> callback); void setPlcStatusChangedCallback(std::function<void()> callback);


private: private:
const ProjectService &project_service_;
OfflineSimulationService &offline_simulation_service_;
RuntimeState state_;
// 表示 PLC 缓存是否已通过至少一次有效读取建立
const ProjectService &project_service_; // 不拥有的只读工程服务
OfflineSimulationService &offline_simulation_service_; // 不拥有的离线仿真服务
OnlineLogicMonitorService &online_logic_monitor_service_; // 不拥有的真机只读轨迹服务
RuntimeState state_; // 编辑、离线和真机模式状态机
// PLC 缓存是否已通过有效首读建立,通信故障时清除
bool initial_plc_read_completed_ = false; bool initial_plc_read_completed_ = false;
PlcCommunicationGateway *plc_gateway_ = nullptr;
ActiveRegisterRepository *active_repository_ = nullptr;
RegisterRepository *virtual_repository_ = nullptr;
RegisterRepository *plc_repository_ = nullptr;
std::function<void()> plc_status_changed_callback_;
std::vector<RegisterAddress> monitor_addresses_;
PlcCommunicationGateway *plc_gateway_ = nullptr; // 不拥有的 PLC 通信网关
ActiveRegisterRepository *active_repository_ = nullptr; // 当前模式使用的仓库代理
RegisterRepository *virtual_repository_ = nullptr; // 离线/编辑模式的虚拟仓库
RegisterRepository *plc_repository_ = nullptr; // 真机模式的 PLC 缓存仓库
std::function<void()> plc_status_changed_callback_; // PLC 状态变化通知
std::vector<RegisterAddress> monitor_addresses_; // 自由监控额外引用的地址
std::vector<RegisterAddress> monitor_float32_starts_; // 自由监控中的 Float32 起始地址
}; };

+ 20
- 173
app/src/services/software_logic_executor.cpp 查看文件

@@ -79,8 +79,6 @@ void LogicTraceValues::clear()
expressionInputValues.clear(); expressionInputValues.clear();
expressionPowerValues.clear(); expressionPowerValues.clear();
rungValues.clear(); rungValues.clear();
tonValues.clear();
counterValues.clear();
wordValues.clear(); wordValues.clear();
} }


@@ -103,8 +101,6 @@ LogicTraceSnapshot LogicTraceSnapshot::forLogic(
projection.expressionInputValues = values->second.expressionInputValues; projection.expressionInputValues = values->second.expressionInputValues;
projection.expressionPowerValues = values->second.expressionPowerValues; projection.expressionPowerValues = values->second.expressionPowerValues;
projection.rungValues = values->second.rungValues; projection.rungValues = values->second.rungValues;
projection.tonValues = values->second.tonValues;
projection.counterValues = values->second.counterValues;
projection.wordValues = values->second.wordValues; projection.wordValues = values->second.wordValues;
} }
return projection; return projection;
@@ -113,6 +109,7 @@ LogicTraceSnapshot LogicTraceSnapshot::forLogic(
LogicScanResult SoftwareLogicExecutor::validate( LogicScanResult SoftwareLogicExecutor::validate(
const std::vector<ControlLogic> &logics) const const std::vector<ControlLogic> &logics) const
{ {
// 先检查每个逻辑自身,再检查跨网络共享线圈的冲突
std::map<int, CoilMode> output_modes; std::map<int, CoilMode> output_modes;
for (const ControlLogic &logic : logics) for (const ControlLogic &logic : logics)
{ {
@@ -144,9 +141,7 @@ LogicScanResult SoftwareLogicExecutor::validate(
const auto *output = std::get_if<CoilNodeConfig>(&rung.output->config); const auto *output = std::get_if<CoilNodeConfig>(&rung.output->config);
if (output == nullptr) if (output == nullptr)
{ {
if (std::holds_alternative<TonNodeConfig>(rung.output->config)
|| std::holds_alternative<CounterNodeConfig>(rung.output->config)
|| std::holds_alternative<MoveNodeConfig>(rung.output->config)
if (std::holds_alternative<MoveNodeConfig>(rung.output->config)
|| std::holds_alternative<ArithmeticNodeConfig>( || std::holds_alternative<ArithmeticNodeConfig>(
rung.output->config)) rung.output->config))
{ {
@@ -173,35 +168,18 @@ LogicScanResult SoftwareLogicExecutor::validate(
output_modes[output->address.index()] = output->mode; output_modes[output->address.index()] = output->mode;
} }
} }
std::string resource_error;
if (!validateLogicResourceReferencesForRunning(logics, &resource_error))
{
return failure(LogicScanError::InvalidLogic, resource_error);
}
return success(); return success();
} }


void SoftwareLogicExecutor::resetRuntime() void SoftwareLogicExecutor::resetRuntime()
{ {
previous_edge_inputs_.clear(); previous_edge_inputs_.clear();
previous_counter_inputs_.clear();
ton_states_.clear();
counter_states_.clear();
} }


LogicScanResult SoftwareLogicExecutor::executeScan( LogicScanResult SoftwareLogicExecutor::executeScan(
const std::vector<ControlLogic> &logics, const std::vector<ControlLogic> &logics,
RegisterRepository &repository, RegisterRepository &repository,
LogicTraceSnapshot *trace) LogicTraceSnapshot *trace)
{
return executeScanAt(logics, repository, Clock::now(), trace);
}

LogicScanResult SoftwareLogicExecutor::executeScanAt(
const std::vector<ControlLogic> &logics,
RegisterRepository &repository,
TimePoint now,
LogicTraceSnapshot *trace)
{ {
const LogicScanResult validation = validate(logics); const LogicScanResult validation = validate(logics);
if (!validation.succeeded) if (!validation.succeeded)
@@ -209,10 +187,12 @@ LogicScanResult SoftwareLogicExecutor::executeScanAt(
return validation; return validation;
} }


// 每轮从空轨迹开始,防止已删除或未执行的节点残留旧状态
if (trace != nullptr) if (trace != nullptr)
{ {
trace->clear(); trace->clear();
} }
// 按工程顺序扫描;前面网络写入的 M/D 值对后面网络立即可见
for (const ControlLogic &logic : logics) for (const ControlLogic &logic : logics)
{ {
if (!logic.enabled) if (!logic.enabled)
@@ -228,7 +208,7 @@ LogicScanResult SoftwareLogicExecutor::executeScanAt(
continue; continue;
} }


// 没有条件节点的网络等价于左侧电源线直接接通,作为恒真条件执行
// 输出网络的显式 Wire 会计算为真;空网络只作为编辑草稿跳过
bool rung_value = true; bool rung_value = true;
LogicScanResult result = success(); LogicScanResult result = success();
if (rung.condition.has_value()) if (rung.condition.has_value())
@@ -255,11 +235,9 @@ LogicScanResult SoftwareLogicExecutor::executeScanAt(
} }
bool output_value = false; bool output_value = false;
result = executeOutput( result = executeOutput(
logic.id,
*rung.output, *rung.output,
rung_value, rung_value,
repository, repository,
now,
logic_trace, logic_trace,
&output_value); &output_value);
if (!result.succeeded) if (!result.succeeded)
@@ -291,8 +269,6 @@ LogicScanResult SoftwareLogicExecutor::executeScanAt(
trace->expressionInputValues = values->second.expressionInputValues; trace->expressionInputValues = values->second.expressionInputValues;
trace->expressionPowerValues = values->second.expressionPowerValues; trace->expressionPowerValues = values->second.expressionPowerValues;
trace->rungValues = values->second.rungValues; trace->rungValues = values->second.rungValues;
trace->tonValues = values->second.tonValues;
trace->counterValues = values->second.counterValues;
trace->wordValues = values->second.wordValues; trace->wordValues = values->second.wordValues;
} }
} }
@@ -326,6 +302,15 @@ LogicScanResult SoftwareLogicExecutor::evaluateExpression(
} }
return success(); return success();
} }
if (expression.kind == ConditionExpressionKind::Gap)
{
return failure(
LogicScanError::InvalidLogic,
"梯形图条件区存在未连接的空白网格",
logic_id,
{},
expression.id);
}
if (expression.kind == ConditionExpressionKind::Node) if (expression.kind == ConditionExpressionKind::Node)
{ {
LogicScanResult result = evaluateCondition( LogicScanResult result = evaluateCondition(
@@ -380,6 +365,8 @@ LogicScanResult SoftwareLogicExecutor::evaluateCondition(
RegisterRepository &repository, RegisterRepository &repository,
bool *value) bool *value)
{ {
// 根据条件节点的具体配置读取 M/D 状态,并计算触点结果
// 边沿触点使用 logic_id 和 node.id 组成运行时状态键,避免同名节点互相影响
if (value == nullptr) if (value == nullptr)
{ {
return failure( return failure(
@@ -397,6 +384,7 @@ LogicScanResult SoftwareLogicExecutor::evaluateCondition(
using Config = std::decay_t<decltype(config)>; using Config = std::decay_t<decltype(config)>;
if constexpr (std::is_same_v<Config, ContactNodeConfig>) if constexpr (std::is_same_v<Config, ContactNodeConfig>)
{ {
// 普通触点直接读取 M 位;常闭触点使用读回值的反值
const BitReadResult read = repository.readBit(config.address); const BitReadResult read = repository.readBit(config.address);
if (!read.succeeded) if (!read.succeeded)
{ {
@@ -413,6 +401,7 @@ LogicScanResult SoftwareLogicExecutor::evaluateCondition(
} }
else if constexpr (std::is_same_v<Config, EdgeContactNodeConfig>) else if constexpr (std::is_same_v<Config, EdgeContactNodeConfig>)
{ {
// 边沿触点需要比较本轮与上一轮的 M 位值,只在目标边沿出现时导通
const BitReadResult read = repository.readBit(config.address); const BitReadResult read = repository.readBit(config.address);
if (!read.succeeded) if (!read.succeeded)
{ {
@@ -430,20 +419,9 @@ LogicScanResult SoftwareLogicExecutor::evaluateCondition(
previous_edge_inputs_[runtime_key] = read.value; previous_edge_inputs_[runtime_key] = read.value;
return success(); return success();
} }
else if constexpr (std::is_same_v<Config, TimerContactNodeConfig>)
{
const bool done = ton_states_[config.address.index()].done;
*value = config.mode == ContactMode::NormallyOpen ? done : !done;
return success();
}
else if constexpr (std::is_same_v<Config, CounterContactNodeConfig>)
{
const bool done = counter_states_[config.address.index()].done;
*value = config.mode == ContactMode::NormallyOpen ? done : !done;
return success();
}
else if constexpr (std::is_same_v<Config, CompareNodeConfig>) else if constexpr (std::is_same_v<Config, CompareNodeConfig>)
{ {
// 字比较触点读取 D 字,再按配置的比较运算符与目标值比较
const WordReadResult read = repository.readWord(config.address); const WordReadResult read = repository.readWord(config.address);
if (!read.succeeded) if (!read.succeeded)
{ {
@@ -459,6 +437,7 @@ LogicScanResult SoftwareLogicExecutor::evaluateCondition(
} }
else else
{ {
// 输出节点只能由 executeOutput 处理,不能作为条件触点计算
return failure( return failure(
LogicScanError::InvalidLogic, LogicScanError::InvalidLogic,
"条件节点不能包含输出节点", "条件节点不能包含输出节点",
@@ -514,11 +493,9 @@ LogicScanResult SoftwareLogicExecutor::readWordOperand(
} }


LogicScanResult SoftwareLogicExecutor::executeOutput( LogicScanResult SoftwareLogicExecutor::executeOutput(
const std::string &logic_id,
const LogicNode &node, const LogicNode &node,
bool rung_value, bool rung_value,
RegisterRepository &repository, RegisterRepository &repository,
TimePoint now,
LogicTraceValues *trace, LogicTraceValues *trace,
bool *output_value) bool *output_value)
{ {
@@ -531,136 +508,6 @@ LogicScanResult SoftwareLogicExecutor::executeOutput(
{}, {},
node.id); node.id);
} }
if (const auto *ton = std::get_if<TonNodeConfig>(&node.config))
{
TonRuntimeState &state = ton_states_[ton->address.index()];
if (!rung_value)
{
state = TonRuntimeState{};
}
else if (!state.timing)
{
state.timing = true;
state.startedAt = now;
state.elapsed = std::chrono::milliseconds{0};
state.done = false;
}
else
{
state.elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(
now >= state.startedAt ? now - state.startedAt : TimePoint::duration::zero());
state.done = state.elapsed.count() >= ton->presetMs;
}
*output_value = state.done;
if (trace != nullptr)
{
trace->tonValues[node.id] = {
rung_value,
state.done,
state.elapsed.count(),
ton->presetMs};
}
return success();
}

if (const auto *counter = std::get_if<CounterNodeConfig>(&node.config))
{
const BitReadResult reset_read = repository.readBit(counter->resetAddress);
if (!reset_read.succeeded)
{
return failure(
LogicScanError::RegisterReadFailed,
"读取计数器复位输入失败:" + counter->resetAddress.toString(),
{},
{},
node.id);
}
std::int16_t preset = 0;
LogicScanResult result = readWordOperand(
counter->preset, repository, &preset, node.id);
if (!result.succeeded)
{
return result;
}
const std::int32_t bounded_preset = std::max<std::int32_t>(0, preset);
preset = static_cast<std::int16_t>(
std::min<std::int32_t>(bounded_preset, std::numeric_limits<std::int16_t>::max()));
const WordReadResult current_read = repository.readWord(
counter->currentValueAddress);
if (!current_read.succeeded)
{
return failure(
LogicScanError::RegisterReadFailed,
"读取计数当前值失败:" + counter->currentValueAddress.toString(),
{},
{},
node.id);
}
std::int32_t current = std::max<std::int32_t>(0, current_read.value);
current = std::min<std::int32_t>(current, std::numeric_limits<std::int16_t>::max());
const auto runtime_key = std::make_pair(logic_id, node.id);
const bool previous = previous_counter_inputs_[runtime_key];
const bool rising = rung_value && !previous;
previous_counter_inputs_[runtime_key] = rung_value;
bool done = counter_states_[counter->address.index()].done;
if (reset_read.value)
{
current = counter->mode == CounterMode::Up ? 0 : preset;
done = counter->mode == CounterMode::Down && current == 0;
}
else if (rising)
{
if (counter->mode == CounterMode::Up)
{
if (current < preset)
{
++current;
}
done = current >= preset;
}
else
{
if (current > 0)
{
--current;
}
done = current <= 0;
}
}
if (counter->mode == CounterMode::Up && current >= preset)
{
done = true;
}
if (counter->mode == CounterMode::Down && current <= 0)
{
done = true;
}
const std::int16_t current_value = static_cast<std::int16_t>(current);
const RegisterWriteResult write = repository.writeWord(
counter->currentValueAddress, current_value);
if (!write.succeeded)
{
return failure(
LogicScanError::RegisterWriteFailed,
"写入计数当前值失败:" + counter->currentValueAddress.toString(),
{},
{},
node.id);
}
counter_states_[counter->address.index()].done = done;
*output_value = done;
if (trace != nullptr)
{
trace->counterValues[node.id] = {
rung_value,
reset_read.value,
done,
current_value,
preset};
}
return success();
}

if (const auto *move = std::get_if<MoveNodeConfig>(&node.config)) if (const auto *move = std::get_if<MoveNodeConfig>(&node.config))
{ {
if (!rung_value) if (!rung_value)


+ 87
- 68
app/src/services/software_logic_executor.h 查看文件

@@ -3,7 +3,6 @@
#include "domain/control_logic_model.h" #include "domain/control_logic_model.h"
#include "domain/register_repository.h" #include "domain/register_repository.h"


#include <chrono>
#include <cstdint> #include <cstdint>
#include <map> #include <map>
#include <string> #include <string>
@@ -11,68 +10,57 @@
#include <utility> #include <utility>
#include <vector> #include <vector>


// 一轮软件逻辑扫描可能返回的错误
enum class LogicScanError enum class LogicScanError
{ {
None,
InvalidLogic,
ConflictingOutput,
RegisterReadFailed,
RegisterWriteFailed
None, // 扫描成功
InvalidLogic, // 逻辑结构或运行配置无效
ConflictingOutput, // 多个输出以不兼容的线圈模式写入同一地址
RegisterReadFailed, // 扫描读取寄存器失败
RegisterWriteFailed // 扫描写入寄存器失败
}; };


// 扫描结果;失败时携带出错的逻辑、网络和节点 ID
struct LogicScanResult struct LogicScanResult
{ {
bool succeeded = false;
LogicScanError error = LogicScanError::None;
std::string message;
std::string logicId;
std::string rungId;
std::string nodeId;
};

struct TonTraceValue
{
bool input = false;
bool done = false;
std::int64_t elapsedMs = 0;
int presetMs = 0;
};

struct CounterTraceValue
{
bool input = false;
bool reset = false;
bool done = false;
std::int16_t value = 0;
std::int16_t preset = 0;
bool succeeded = false; // 本轮扫描是否完成
LogicScanError error = LogicScanError::None; // 失败时的分类
std::string message; // UTF-8 可读结果或失败原因
std::string logicId; // 失败关联的控制逻辑 ID
std::string rungId; // 失败关联的网络 ID
std::string nodeId; // 失败关联的节点 ID
}; };


// MOVE/ADD/SUB 节点的结果值和溢出标记
struct WordTraceValue struct WordTraceValue
{ {
std::int16_t value = 0;
bool overflow = false;
std::int16_t value = 0; // MOVE/ADD/SUB 的结果值
bool overflow = false; // ADD/SUB 是否发生饱和溢出
}; };


// 一条控制逻辑的运行轨迹快照;各映射使用节点、表达式或网络 ID 作为键
struct LogicTraceValues struct LogicTraceValues
{ {
std::unordered_map<std::string, bool> nodeValues;
std::unordered_map<std::string, bool> nodePowerValues;
std::unordered_map<std::string, bool> expressionValues;
std::unordered_map<std::string, bool> expressionInputValues;
std::unordered_map<std::string, bool> expressionPowerValues;
std::unordered_map<std::string, bool> rungValues;
std::unordered_map<std::string, TonTraceValue> tonValues;
std::unordered_map<std::string, CounterTraceValue> counterValues;
std::unordered_map<std::string, WordTraceValue> wordValues;

std::unordered_map<std::string, bool> nodeValues; // 节点最终逻辑值
std::unordered_map<std::string, bool> nodePowerValues; // 节点输入电源状态
std::unordered_map<std::string, bool> expressionValues; // 条件表达式最终值
std::unordered_map<std::string, bool> expressionInputValues; // 表达式输入状态
std::unordered_map<std::string, bool> expressionPowerValues; // 表达式输出电源状态
std::unordered_map<std::string, bool> rungValues; // 网络最终逻辑值
std::unordered_map<std::string, WordTraceValue> wordValues; // MOVE/ADD/SUB 节点状态

// 清除当前逻辑或网络的全部轨迹
void clear(); void clear();
}; };


// 全工程轨迹;logicValues 按逻辑 ID 隔离,避免不同逻辑中的节点 ID 互相覆盖
struct LogicTraceSnapshot : LogicTraceValues struct LogicTraceSnapshot : LogicTraceValues
{ {
std::unordered_map<std::string, LogicTraceValues> logicValues;
std::unordered_map<std::string, LogicTraceValues> logicValues; // 按逻辑 ID 保存的轨迹


// 清除全工程轨迹及按逻辑分组的轨迹
void clear(); void clear();
// 返回指定逻辑的轨迹投影;不存在时返回空轨迹
LogicTraceSnapshot forLogic(const std::string &logic_id) const; LogicTraceSnapshot forLogic(const std::string &logic_id) const;
}; };


@@ -80,35 +68,57 @@ struct LogicTraceSnapshot : LogicTraceValues
class SoftwareLogicExecutor class SoftwareLogicExecutor
{ {
public: public:
using Clock = std::chrono::steady_clock;
using TimePoint = Clock::time_point;

/**
* @brief 检查逻辑是否满足软件扫描的运行要求
* @param logics 待检查的控制逻辑集合
* @return 成功或包含逻辑、网络、节点定位信息的失败结果
*
* 检查逻辑结构、启用逻辑的运行配置和共享输出冲突
*/
LogicScanResult validate(const std::vector<ControlLogic> &logics) const; LogicScanResult validate(const std::vector<ControlLogic> &logics) const;

/**
* @brief 清除所有跨扫描运行状态
*
* 重置边沿触发状态;不修改寄存器仓库和轨迹对象
*/
void resetRuntime(); void resetRuntime();

/**
* @brief 使用当前 steady_clock 执行一轮完整扫描
* @param logics 按工程顺序扫描的控制逻辑集合
* @param repository 扫描读取和写入的寄存器仓库
* @param trace 可选的轨迹输出;非空时会先清空再写入本轮结果
* @return 扫描结果,失败时不会返回部分成功状态作为成功结果
*/
LogicScanResult executeScan( LogicScanResult executeScan(
const std::vector<ControlLogic> &logics, const std::vector<ControlLogic> &logics,
RegisterRepository &repository, RegisterRepository &repository,
LogicTraceSnapshot *trace = nullptr); LogicTraceSnapshot *trace = nullptr);
LogicScanResult executeScanAt(
const std::vector<ControlLogic> &logics,
RegisterRepository &repository,
TimePoint now,
LogicTraceSnapshot *trace = nullptr);

private: private:
struct TonRuntimeState
{
bool timing = false;
bool done = false;
TimePoint startedAt{};
std::chrono::milliseconds elapsed{0};
};
/**
* @brief 计算单个条件节点的导通结果
* @param logic_id 所属控制逻辑 ID,用于隔离边沿触点的跨扫描状态
* @param node 待读取和计算的条件节点
* @param repository 提供 M/D 值的寄存器仓库
* @param value 输出节点导通结果,不能为空
* @return 成功结果,或包含节点 ID 的逻辑/寄存器读取失败结果
*/
LogicScanResult evaluateCondition( LogicScanResult evaluateCondition(
const std::string &logic_id, const std::string &logic_id,
const LogicNode &node, const LogicNode &node,
RegisterRepository &repository, RegisterRepository &repository,
bool *value); bool *value);
/**
* @brief 递归计算串联、并联或叶子条件表达式
* @param logic_id 所属控制逻辑 ID,用于传递给叶子节点状态计算
* @param expression 待读取的条件表达式树
* @param repository 提供 M/D 值的寄存器仓库
* @param trace 可选的轨迹输出,用于记录表达式值、电源输入和电源输出
* @param input_power 进入当前表达式的电源状态
* @param value 输出表达式最终值,不能为空
* @return 成功结果,或包含失败节点 ID 的扫描错误
*/
LogicScanResult evaluateExpression( LogicScanResult evaluateExpression(
const std::string &logic_id, const std::string &logic_id,
const ConditionExpression &expression, const ConditionExpression &expression,
@@ -116,26 +126,35 @@ private:
LogicTraceValues *trace, LogicTraceValues *trace,
bool input_power, bool input_power,
bool *value); bool *value);
/**
* @brief 在网络结果驱动下执行单个输出节点
* @param node 待读取配置并执行的输出节点
* @param rung_value 当前网络的逻辑结果,决定输出是否动作
* @param repository 提供输出读写所需的 M/D 寄存器仓库
* @param trace 可选的轨迹输出,用于记录字操作结果
* @param output_value 输出节点最终逻辑值,不能为空
* @return 成功结果,或包含节点 ID 的逻辑/寄存器读写失败结果
*/
LogicScanResult executeOutput( LogicScanResult executeOutput(
const std::string &logic_id,
const LogicNode &node, const LogicNode &node,
bool rung_value, bool rung_value,
RegisterRepository &repository, RegisterRepository &repository,
TimePoint now,
LogicTraceValues *trace, LogicTraceValues *trace,
bool *output_value); bool *output_value);
/**
* @brief 解析并读取一个字操作数
* @param operand 常量或 D 寄存器操作数配置
* @param repository 读取寄存器操作数时使用的仓库
* @param value 输出解析后的 int16 数值,不能为空
* @param node_id 关联节点 ID,用于构造可定位的失败结果
* @return 常量读取成功,或 D 寄存器读取/操作数类型校验失败
*/
LogicScanResult readWordOperand( LogicScanResult readWordOperand(
const WordOperand &operand, const WordOperand &operand,
RegisterRepository &repository, RegisterRepository &repository,
std::int16_t *value, std::int16_t *value,
const std::string &node_id) const; const std::string &node_id) const;


// 按逻辑 ID 和节点 ID 保存上一轮边沿输入
std::map<std::pair<std::string, std::string>, bool> previous_edge_inputs_; std::map<std::pair<std::string, std::string>, bool> previous_edge_inputs_;
std::map<std::pair<std::string, std::string>, bool> previous_counter_inputs_;
std::unordered_map<int, TonRuntimeState> ton_states_;
struct CounterRuntimeState
{
bool done = false;
};
std::unordered_map<int, CounterRuntimeState> counter_states_;
}; };

+ 21
- 0
app/src/ui/alarm_configuration_dialog.h 查看文件

@@ -1,3 +1,11 @@
/**
* @file alarm_configuration_dialog.h
* @brief 定义报警定义配置对话框
* @version 1.0.0
* @author suyu
* @date 2026-08-22
*/

#pragma once #pragma once


#include <QDialog> #include <QDialog>
@@ -12,24 +20,37 @@ class AlarmConfigurationDialog;
class AlarmEditorService; class AlarmEditorService;
struct AlarmDefinition; struct AlarmDefinition;


/** 报警定义编辑对话框;保存、更新和删除都委托 AlarmEditorService */
class AlarmConfigurationDialog final : public QDialog class AlarmConfigurationDialog final : public QDialog
{ {
public: public:
/** 创建报警配置对话框并绑定报警编辑服务 */
explicit AlarmConfigurationDialog( explicit AlarmConfigurationDialog(
AlarmEditorService &service, AlarmEditorService &service,
QWidget *parent = nullptr); QWidget *parent = nullptr);
/** 释放 Designer 界面对象 */
~AlarmConfigurationDialog() override; ~AlarmConfigurationDialog() override;


private: private:
/** 刷新左侧列表并尽量恢复原选中项 */
void reloadDefinitions(const std::string &selected_id = {}); void reloadDefinitions(const std::string &selected_id = {});
/** 将当前选中报警加载到编辑区 */
void loadSelectedDefinition(); void loadSelectedDefinition();
/** 根据报警类型更新条件输入项 */
void updateConditionOptions(); void updateConditionOptions();
/** 新增一条报警定义 */
void addDefinition(); void addDefinition();
/** 更新当前选中的报警定义 */
void updateDefinition(); void updateDefinition();
/** 删除当前选中的报警定义 */
void removeDefinition(); void removeDefinition();
/** 从输入框读取报警定义 */
AlarmDefinition definitionFromInputs() const; AlarmDefinition definitionFromInputs() const;
/** 返回当前选中的报警标识 */
std::string selectedId() const; std::string selectedId() const;


/** Qt Designer 生成的界面对象 */
std::unique_ptr<Ui::AlarmConfigurationDialog> ui_; std::unique_ptr<Ui::AlarmConfigurationDialog> ui_;
/** 报警定义服务,不由对话框拥有 */
AlarmEditorService &service_; AlarmEditorService &service_;
}; };

+ 66
- 19
app/src/ui/free_monitor_widget.cpp 查看文件

@@ -13,6 +13,7 @@
#include <QTableWidgetItem> #include <QTableWidgetItem>


#include <cstdint> #include <cstdint>
#include <cmath>
#include <set> #include <set>


namespace { namespace {
@@ -45,6 +46,8 @@ FreeMonitorWidget::FreeMonitorWidget(
ui_->monitorTable->horizontalHeader()->setSectionResizeMode(3, QHeaderView::Stretch); ui_->monitorTable->horizontalHeader()->setSectionResizeMode(3, QHeaderView::Stretch);
ui_->monitorTable->horizontalHeader()->setSectionResizeMode(4, QHeaderView::ResizeToContents); ui_->monitorTable->horizontalHeader()->setSectionResizeMode(4, QHeaderView::ResizeToContents);
ui_->monitorTable->horizontalHeader()->setSectionResizeMode(5, QHeaderView::Stretch); ui_->monitorTable->horizontalHeader()->setSectionResizeMode(5, QHeaderView::Stretch);
ui_->dataTypeComboBox->setItemData(0, static_cast<int>(RegisterDataType::Int16));
ui_->dataTypeComboBox->setItemData(1, static_cast<int>(RegisterDataType::Float32));
ui_->monitorTable->verticalHeader()->setVisible(false); ui_->monitorTable->verticalHeader()->setVisible(false);
connect(ui_->addButton, &QPushButton::clicked, this, &FreeMonitorWidget::addAddresses); connect(ui_->addButton, &QPushButton::clicked, this, &FreeMonitorWidget::addAddresses);
connect(ui_->addressEdit, &QLineEdit::returnPressed, connect(ui_->addressEdit, &QLineEdit::returnPressed,
@@ -70,7 +73,12 @@ void FreeMonitorWidget::refreshValues(
QString source = tr("数据源:未启用"); QString source = tr("数据源:未启用");
bool values_available = false; bool values_available = false;
bool communication_fault = false; bool communication_fault = false;
if (mode == ApplicationMode::OfflineRunning)
if (mode == ApplicationMode::Editing)
{
source = tr("数据源:离线初始值 · 虚拟 M/D");
values_available = true;
}
else if (mode == ApplicationMode::OfflineRunning)
{ {
source = tr("数据源:虚拟 M/D"); source = tr("数据源:虚拟 M/D");
values_available = true; values_available = true;
@@ -103,10 +111,20 @@ void FreeMonitorWidget::refreshValues(
? tr("等待读取") : tr("不可用")); ? tr("等待读取") : tr("不可用"));
continue; continue;
} }
value_item->setText(value.address.area() == RegisterArea::M
? (value.bitValue ? QStringLiteral("ON")
: QStringLiteral("OFF"))
: QString::number(value.wordValue));
if (value.address.area() == RegisterArea::M)
{
value_item->setText(value.bitValue ? QStringLiteral("ON")
: QStringLiteral("OFF"));
}
else if (value.dataType == RegisterDataType::Float32)
{
value_item->setText(QString::number(
static_cast<double>(value.floatValue), 'g', 7));
}
else
{
value_item->setText(QString::number(value.wordValue));
}
state_item->setText(value.state == MonitorValueState::CommunicationFault state_item->setText(value.state == MonitorValueState::CommunicationFault
? tr("通信故障,最后有效值") : tr("有效")); ? tr("通信故障,最后有效值") : tr("有效"));
} }
@@ -122,9 +140,17 @@ void FreeMonitorWidget::reloadAddresses()
const RegisterAddress &address = addresses[index]; const RegisterAddress &address = addresses[index];
const int row = static_cast<int>(index); const int row = static_cast<int>(index);
ui_->monitorTable->setItem(row, 0, new QTableWidgetItem(addressText(address))); ui_->monitorTable->setItem(row, 0, new QTableWidgetItem(addressText(address)));
const MonitorPoint &point = service_.points().at(index);
ui_->monitorTable->setItem( ui_->monitorTable->setItem(
row, 1, new QTableWidgetItem(address.area() == RegisterArea::M row, 1, new QTableWidgetItem(address.area() == RegisterArea::M
? tr("位") : tr("16 位整数")));
? tr("位")
: point.dataType == RegisterDataType::Float32
? tr("Float32 %1 (%1~%2)")
.arg(addressText(address),
addressText(RegisterAddress{
RegisterArea::D,
point.address.index() + 1}))
: tr("Int16")));
ui_->monitorTable->setItem(row, 2, new QTableWidgetItem(QStringLiteral("--"))); ui_->monitorTable->setItem(row, 2, new QTableWidgetItem(QStringLiteral("--")));
if (address.area() == RegisterArea::M) if (address.area() == RegisterArea::M)
{ {
@@ -136,7 +162,14 @@ void FreeMonitorWidget::reloadAddresses()
else else
{ {
auto *target = new QLineEdit(ui_->monitorTable); auto *target = new QLineEdit(ui_->monitorTable);
target->setValidator(new QIntValidator(-32768, 32767, target));
if (point.dataType == RegisterDataType::Float32)
{
target->setPlaceholderText(tr("小数或科学计数法"));
}
else
{
target->setValidator(new QIntValidator(-32768, 32767, target));
}
target->setText(QStringLiteral("0")); target->setText(QStringLiteral("0"));
target->setAlignment(Qt::AlignRight | Qt::AlignVCenter); target->setAlignment(Qt::AlignRight | Qt::AlignVCenter);
ui_->monitorTable->setCellWidget(row, 3, target); ui_->monitorTable->setCellWidget(row, 3, target);
@@ -162,7 +195,8 @@ void FreeMonitorWidget::writeRow(int row)
return; return;
} }


const RegisterAddress address = service_.addresses().at(static_cast<std::size_t>(row));
const MonitorPoint point = service_.points().at(static_cast<std::size_t>(row));
const RegisterAddress address = point.address;
RegisterMonitorWriteResult result; RegisterMonitorWriteResult result;
if (address.area() == RegisterArea::M) if (address.area() == RegisterArea::M)
{ {
@@ -181,14 +215,20 @@ void FreeMonitorWidget::writeRow(int row)
return; return;
} }
bool converted = false; bool converted = false;
const int value = target->text().toInt(&converted);
if (!converted || value < -32768 || value > 32767)
const double value = target->text().toDouble(&converted);
if (!converted || !std::isfinite(value)
|| (point.dataType == RegisterDataType::Int16
&& (value < -32768 || value > 32767 || std::trunc(value) != value)))
{ {
ui_->monitorTable->item(row, 5)->setText(tr("目标值无效")); ui_->monitorTable->item(row, 5)->setText(tr("目标值无效"));
emit operationMessage(tr("写入 D 区地址失败:请输入 -32768~32767 的整数"));
emit operationMessage(point.dataType == RegisterDataType::Float32
? tr("写入 D 区地址失败:请输入有限 Float32 数值")
: tr("写入 D 区地址失败:请输入 -32768~32767 的整数"));
return; return;
} }
result = service_.writeWord(address, static_cast<std::int16_t>(value));
result = point.dataType == RegisterDataType::Float32
? service_.writeFloat(address, static_cast<float>(value))
: service_.writeWord(address, static_cast<std::int16_t>(value));
} }


QTableWidgetItem *state_item = ui_->monitorTable->item(row, 5); QTableWidgetItem *state_item = ui_->monitorTable->item(row, 5);
@@ -225,13 +265,20 @@ void FreeMonitorWidget::addAddresses()
const QByteArray address = ui_->addressEdit->text().toUtf8(); const QByteArray address = ui_->addressEdit->text().toUtf8();
const RegisterMonitorResult result = service_.addRange( const RegisterMonitorResult result = service_.addRange(
std::string(address.constData(), static_cast<std::size_t>(address.size())), std::string(address.constData(), static_cast<std::size_t>(address.size())),
ui_->countSpinBox->value());
ui_->countSpinBox->value(),
static_cast<RegisterDataType>(ui_->dataTypeComboBox->currentData().toInt()));
if (result.succeeded) if (result.succeeded)
{ {
reloadAddresses(); reloadAddresses();
ui_->addressEdit->selectAll(); ui_->addressEdit->selectAll();
emit monitorAddressesChanged(); emit monitorAddressesChanged();
emit operationMessage(tr("已添加 %1 个监控地址").arg(result.affectedCount));
const QString message = fromUtf8(result.message);
emit operationMessage(
message.isEmpty()
? tr("已添加 %1 个监控点").arg(result.affectedCount)
: tr("已添加 %1 个监控点;%2")
.arg(result.affectedCount)
.arg(message));
return; return;
} }
handleResult(tr("添加监控地址"), false, result.message); handleResult(tr("添加监控地址"), false, result.message);
@@ -244,16 +291,16 @@ void FreeMonitorWidget::removeSelectedAddresses()
{ {
rows.insert(index.row()); rows.insert(index.row());
} }
std::vector<RegisterAddress> addresses;
const std::vector<RegisterAddress> &all_addresses = service_.addresses();
std::vector<MonitorPoint> points;
const std::vector<MonitorPoint> &all_points = service_.points();
for (int row : rows) for (int row : rows)
{ {
if (row >= 0 && static_cast<std::size_t>(row) < all_addresses.size())
if (row >= 0 && static_cast<std::size_t>(row) < all_points.size())
{ {
addresses.push_back(all_addresses.at(static_cast<std::size_t>(row)));
points.push_back(all_points.at(static_cast<std::size_t>(row)));
} }
} }
const RegisterMonitorResult result = service_.remove(addresses);
const RegisterMonitorResult result = service_.remove(points);
if (result.succeeded) if (result.succeeded)
{ {
reloadAddresses(); reloadAddresses();


+ 25
- 0
app/src/ui/free_monitor_widget.h 查看文件

@@ -1,3 +1,11 @@
/**
* @file free_monitor_widget.h
* @brief 定义自由寄存器监控控件
* @version 1.0.0
* @author suyu
* @date 2026-08-22
*/

#pragma once #pragma once


#include "domain/runtime_state.h" #include "domain/runtime_state.h"
@@ -13,33 +21,50 @@ class FreeMonitorWidget;


class RegisterMonitorService; class RegisterMonitorService;


/** 自由监控 UI:地址列表属于当前会话,读写统一委托 RegisterMonitorService */
class FreeMonitorWidget final : public QWidget class FreeMonitorWidget final : public QWidget
{ {
Q_OBJECT Q_OBJECT


public: public:
/** 创建自由监控控件并绑定监控服务 */
explicit FreeMonitorWidget( explicit FreeMonitorWidget(
RegisterMonitorService &service, RegisterMonitorService &service,
QWidget *parent = nullptr); QWidget *parent = nullptr);
/** 释放 Designer 界面对象 */
~FreeMonitorWidget() override; ~FreeMonitorWidget() override;


/** 根据当前模式和通信状态开启或关闭写入入口 */
void setWriteEnabled(bool enabled); void setWriteEnabled(bool enabled);
/** 根据运行模式和 PLC 状态刷新所有监控值 */
void refreshValues(ApplicationMode mode, PlcConnectionState plc_state); void refreshValues(ApplicationMode mode, PlcConnectionState plc_state);
/** 从服务重新加载当前会话中的地址列表 */
void reloadAddresses(); void reloadAddresses();


signals: signals:
/** 监控地址列表发生变化时发出 */
void monitorAddressesChanged(); void monitorAddressesChanged();
/** 操作完成后发出可直接显示的消息 */
void operationMessage(const QString &message); void operationMessage(const QString &message);


private: private:
/** 从输入框添加一个或多个监控地址 */
void addAddresses(); void addAddresses();
/** 删除当前选中的监控地址 */
void removeSelectedAddresses(); void removeSelectedAddresses();
/** 清空当前会话中的监控地址 */
void clearAddresses(); void clearAddresses();
/** 写入指定行中的寄存器值 */
void writeRow(int row); void writeRow(int row);
/** 根据当前权限刷新写入控件状态 */
void updateWriteControls(); void updateWriteControls();
/** 统一处理监控服务返回结果 */
void handleResult(const QString &action, bool succeeded, const std::string &message); void handleResult(const QString &action, bool succeeded, const std::string &message);


/** Qt Designer 生成的界面对象 */
std::unique_ptr<Ui::FreeMonitorWidget> ui_; std::unique_ptr<Ui::FreeMonitorWidget> ui_;
/** 寄存器监控服务,不由控件拥有 */
RegisterMonitorService &service_; RegisterMonitorService &service_;
/** 当前是否允许执行寄存器写入 */
bool write_enabled_ = false; bool write_enabled_ = false;
}; };

+ 7
- 5
app/src/ui/free_monitor_widget.ui 查看文件

@@ -15,13 +15,15 @@
<layout class="QGridLayout" name="commandLayout"> <layout class="QGridLayout" name="commandLayout">
<item row="0" column="0"><widget class="QLabel" name="addressLabel"><property name="text"><string>起始地址</string></property></widget></item> <item row="0" column="0"><widget class="QLabel" name="addressLabel"><property name="text"><string>起始地址</string></property></widget></item>
<item row="0" column="1"><widget class="QLineEdit" name="addressEdit"><property name="minimumSize"><size><width>90</width><height>0</height></size></property><property name="maximumSize"><size><width>160</width><height>16777215</height></size></property><property name="placeholderText"><string>M0 或 D0</string></property><property name="clearButtonEnabled"><bool>true</bool></property></widget></item> <item row="0" column="1"><widget class="QLineEdit" name="addressEdit"><property name="minimumSize"><size><width>90</width><height>0</height></size></property><property name="maximumSize"><size><width>160</width><height>16777215</height></size></property><property name="placeholderText"><string>M0 或 D0</string></property><property name="clearButtonEnabled"><bool>true</bool></property></widget></item>
<item row="0" column="2"><widget class="QLabel" name="countLabel"><property name="text"><string>连续个数</string></property></widget></item>
<item row="0" column="3"><widget class="QSpinBox" name="countSpinBox"><property name="minimum"><number>1</number></property><property name="maximum"><number>64</number></property><property name="value"><number>1</number></property></widget></item>
<item row="0" column="4"><widget class="QPushButton" name="addButton"><property name="text"><string>添加</string></property></widget></item>
<item row="0" column="5"><spacer name="firstRowSpacer"><property name="orientation"><enum>Qt::Horizontal</enum></property><property name="sizeHint" stdset="0"><size><width>40</width><height>20</height></size></property></spacer></item>
<item row="0" column="2"><widget class="QLabel" name="dataTypeLabel"><property name="text"><string>类型</string></property></widget></item>
<item row="0" column="3"><widget class="QComboBox" name="dataTypeComboBox"><item><property name="text"><string>Int16</string></property></item><item><property name="text"><string>Float32</string></property></item></widget></item>
<item row="0" column="4"><widget class="QLabel" name="countLabel"><property name="text"><string>连续个数</string></property></widget></item>
<item row="0" column="5"><widget class="QSpinBox" name="countSpinBox"><property name="minimum"><number>1</number></property><property name="maximum"><number>64</number></property><property name="value"><number>1</number></property></widget></item>
<item row="0" column="6"><widget class="QPushButton" name="addButton"><property name="text"><string>添加</string></property></widget></item>
<item row="0" column="7"><spacer name="firstRowSpacer"><property name="orientation"><enum>Qt::Horizontal</enum></property><property name="sizeHint" stdset="0"><size><width>40</width><height>20</height></size></property></spacer></item>
<item row="1" column="0"><widget class="QToolButton" name="removeButton"><property name="toolTip"><string>删除选中的监控地址</string></property><property name="text"><string>删除</string></property></widget></item> <item row="1" column="0"><widget class="QToolButton" name="removeButton"><property name="toolTip"><string>删除选中的监控地址</string></property><property name="text"><string>删除</string></property></widget></item>
<item row="1" column="1"><widget class="QToolButton" name="clearButton"><property name="toolTip"><string>清空监控表</string></property><property name="text"><string>清空</string></property></widget></item> <item row="1" column="1"><widget class="QToolButton" name="clearButton"><property name="toolTip"><string>清空监控表</string></property><property name="text"><string>清空</string></property></widget></item>
<item row="1" column="2" colspan="4"><widget class="QLabel" name="sourceLabel"><property name="text"><string>数据源:未启用</string></property><property name="alignment"><set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set></property></widget></item>
<item row="1" column="2" colspan="6"><widget class="QLabel" name="sourceLabel"><property name="text"><string>数据源:未启用</string></property><property name="alignment"><set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set></property></widget></item>
</layout> </layout>
</item> </item>
<item> <item>


+ 44
- 57
app/src/ui/hmi_editor_widget.cpp 查看文件

@@ -194,48 +194,6 @@ public:
textWithValue()); textWithValue());
break; break;
} }
case HmiControlType::ProgressBar:
{
const HmiProgressBarConfig config = control_.progressBar.value_or(
HmiProgressBarConfig{});
const int percentage = has_runtime_value_
? config.percentageForValue(word_value_) : 0;

painter->setPen(Qt::NoPen);
painter->setBrush(QColor(QStringLiteral("#e4e9e7")));
painter->drawRoundedRect(rect, 4, 4);

const QRectF track = rect.adjusted(2, 2, -2, -2);
if (has_runtime_value_ && percentage > 0)
{
QRectF fill = track;
fill.setWidth(track.width() * percentage / 100.0);
painter->setBrush(QColor(QStringLiteral("#3c8c62")));
painter->drawRoundedRect(fill, 3, 3);
}

painter->setBrush(Qt::NoBrush);
painter->setPen(QPen(QColor(QStringLiteral("#617069")), 1));
painter->drawRoundedRect(rect, 4, 4);

QString display_text = QString::fromUtf8(
control_.text.data(), static_cast<int>(control_.text.size()));
if (config.showValue)
{
const QString percentage_text = has_runtime_value_
? QString::number(percentage) + QLatin1Char('%')
: runtime_active_ ? QStringLiteral("--")
: QStringLiteral("0%");
display_text = display_text.isEmpty()
? percentage_text
: display_text + QStringLiteral(": ") + percentage_text;
}
painter->setPen(configuredTextColor(QColor(QStringLiteral("#20342a"))));
painter->drawText(rect.adjusted(6, 0, -6, 0),
Qt::AlignCenter,
display_text);
break;
}
case HmiControlType::PageJump: case HmiControlType::PageJump:
{ {
const QColor fill = runtime_active_ && page_hovered_ const QColor fill = runtime_active_ && page_hovered_
@@ -459,10 +417,12 @@ public:
} }


// 缓存运行服务读出的值并触发 Qt 重绘 // 缓存运行服务读出的值并触发 Qt 重绘
void setRuntimeValue(bool bit_value, std::int16_t word_value, bool available)
void setRuntimeValue(
bool bit_value, std::int16_t word_value, float float_value, bool available)
{ {
bit_value_ = bit_value; bit_value_ = bit_value;
word_value_ = word_value; word_value_ = word_value;
float_value_ = float_value;
has_runtime_value_ = available; has_runtime_value_ = available;
update(); update();
} }
@@ -837,7 +797,10 @@ private:
if (control_.type == HmiControlType::NumericDisplay if (control_.type == HmiControlType::NumericDisplay
|| control_.type == HmiControlType::NumericInput) || control_.type == HmiControlType::NumericInput)
{ {
return text + QStringLiteral(": ") + QString::number(word_value_);
const QString value = control_.dataType == RegisterDataType::Float32
? QString::number(float_value_, 'g', 7)
: QString::number(word_value_);
return text + QStringLiteral(": ") + value;
} }
return text; return text;
} }
@@ -867,6 +830,7 @@ private:
bool bit_value_ = false; bool bit_value_ = false;
// 数值控件读取到的 D 字值 // 数值控件读取到的 D 字值
std::int16_t word_value_ = 0; std::int16_t word_value_ = 0;
float float_value_ = 0.0f;
// 标记当前缓存值是否来自一次成功的运行时读取 // 标记当前缓存值是否来自一次成功的运行时读取
bool has_runtime_value_ = false; bool has_runtime_value_ = false;
}; };
@@ -902,6 +866,7 @@ HmiEditorWidget::HmiEditorWidget(
// 切换显示页面 // 切换显示页面
void HmiEditorWidget::setPageId(const std::string &page_id) void HmiEditorWidget::setPageId(const std::string &page_id)
{ {
// 切换页面只改变投影上下文,真正的页面数据仍由 ProjectService 持有
if (page_id_ == page_id) if (page_id_ == page_id)
{ {
return; return;
@@ -928,7 +893,7 @@ void HmiEditorWidget::setRuntimeActive(bool active)
HmiGraphicsItem *control_item = asHmiItem(item); HmiGraphicsItem *control_item = asHmiItem(item);
if (control_item != nullptr) if (control_item != nullptr)
{ {
control_item->setRuntimeValue(false, 0, false);
control_item->setRuntimeValue(false, 0, 0.0f, false);
control_item->setAlarmRecords({}); control_item->setAlarmRecords({});
} }
} }
@@ -945,6 +910,7 @@ void HmiEditorWidget::setRuntimeWriteEnabled(bool enabled)
// 全部重新加载当前页面,把内存模型 HmiPage 渲染成画面上图形 // 全部重新加载当前页面,把内存模型 HmiPage 渲染成画面上图形
void HmiEditorWidget::reloadPage() void HmiEditorWidget::reloadPage()
{ {
// 模型发生变化后完全重建图元,避免增量刷新遗漏属性或选择状态
// 画布始终从当前领域页面重建,避免保留已删除控件的图元 // 画布始终从当前领域页面重建,避免保留已删除控件的图元
scene_->clear(); scene_->clear();
const HmiPage *page = editor_service_.findPage(page_id_); const HmiPage *page = editor_service_.findPage(page_id_);
@@ -1052,6 +1018,7 @@ std::vector<std::string> HmiEditorWidget::selectedControlIds() const


void HmiEditorWidget::refreshRuntimeValues() void HmiEditorWidget::refreshRuntimeValues()
{ {
// 运行态才读取寄存器;编辑态画布只显示工程配置,不显示缓存值
if (!runtime_active_) if (!runtime_active_)
{ {
return; return;
@@ -1076,7 +1043,8 @@ void HmiEditorWidget::refreshRuntimeValues()
} }
// 运行值通过服务读取,图元不直接接触寄存器仓库 // 运行值通过服务读取,图元不直接接触寄存器仓库
const HmiRuntimeReadResult value = runtime_service_.readControl(*control); const HmiRuntimeReadResult value = runtime_service_.readControl(*control);
control_item->setRuntimeValue(value.bit_value, value.word_value, value.succeeded);
control_item->setRuntimeValue(
value.bit_value, value.word_value, value.float_value, value.succeeded);
} }
} }


@@ -1119,6 +1087,7 @@ void HmiEditorWidget::handleSelectionChanged()
void HmiEditorWidget::handleControlMoved( void HmiEditorWidget::handleControlMoved(
const std::string &control_id, const QPointF &position) const std::string &control_id, const QPointF &position)
{ {
// 拖动结束后把坐标交给服务层,由服务层负责页面边界校验和历史记录
const HmiControl *control = editor_service_.findControl(page_id_, control_id); const HmiControl *control = editor_service_.findControl(page_id_, control_id);
if (control == nullptr) if (control == nullptr)
{ {
@@ -1146,6 +1115,7 @@ void HmiEditorWidget::handleButtonEvent(
{ {
return; return;
} }
// 运行态按钮事件只通过 HmiRuntimeService 写寄存器,不直接访问仓库
const HmiControl *control = editor_service_.findControl(page_id_, control_id); const HmiControl *control = editor_service_.findControl(page_id_, control_id);
if (control == nullptr) if (control == nullptr)
{ {
@@ -1177,22 +1147,39 @@ void HmiEditorWidget::handleNumericInputActivated(const std::string &control_id)
} }
bool accepted = false; bool accepted = false;
const HmiRuntimeReadResult current = runtime_service_.readControl(*control); const HmiRuntimeReadResult current = runtime_service_.readControl(*control);
const int initial = current.succeeded ? current.word_value : 0;
const int value = QInputDialog::getInt(
this,
tr("输入数值"),
QString::fromUtf8(control->text.data(), static_cast<int>(control->text.size())),
initial,
std::numeric_limits<std::int16_t>::min(),
std::numeric_limits<std::int16_t>::max(),
1,
&accepted);
double value = 0.0;
if (control->dataType == RegisterDataType::Float32)
{
value = QInputDialog::getDouble(
this,
tr("输入 Float32"),
QString::fromUtf8(
control->text.data(), static_cast<int>(control->text.size())),
current.succeeded ? static_cast<double>(current.float_value) : 0.0,
-static_cast<double>(std::numeric_limits<float>::max()),
static_cast<double>(std::numeric_limits<float>::max()),
7,
&accepted);
}
else
{
value = QInputDialog::getInt(
this,
tr("输入 Int16"),
QString::fromUtf8(
control->text.data(), static_cast<int>(control->text.size())),
current.succeeded ? current.word_value : 0,
std::numeric_limits<std::int16_t>::min(),
std::numeric_limits<std::int16_t>::max(),
1,
&accepted);
}
if (!accepted) if (!accepted)
{ {
return; return;
} }
const HmiRuntimeWriteResult result = runtime_service_.writeNumericInput( const HmiRuntimeWriteResult result = runtime_service_.writeNumericInput(
*control, static_cast<std::int16_t>(value));
*control, value);
if (!result.succeeded) if (!result.succeeded)
{ {
emit editorError(tr("寄存器操作失败")); emit editorError(tr("寄存器操作失败"));


+ 13
- 7
app/src/ui/hmi_editor_widget.h 查看文件

@@ -1,6 +1,7 @@
/** /**
* @file hmi_editor_widget.h * @file hmi_editor_widget.h
* @brief 定义基于 QGraphicsScene 的 HMI 页面编辑和运行画布 * @brief 定义基于 QGraphicsScene 的 HMI 页面编辑和运行画布
* @version 1.0.0
* @author suyu * @author suyu
* @date 2026-08-10 * @date 2026-08-10
*/ */
@@ -56,7 +57,7 @@ public:
* @param active 为 true 时允许按钮和数值输入调用运行时服务 * @param active 为 true 时允许按钮和数值输入调用运行时服务
*/ */
void setRuntimeActive(bool active); void setRuntimeActive(bool active);
// 故障态保持运行值可见,但禁止按钮和数值输入继续写入
/** 故障态保持运行值可见,但禁止按钮和数值输入继续写入 */
void setRuntimeWriteEnabled(bool enabled); void setRuntimeWriteEnabled(bool enabled);
/** /**
* @brief 使用当前页面模型完全重建场景 * @brief 使用当前页面模型完全重建场景
@@ -69,7 +70,9 @@ public:
* @param control_id 待选中控件标识,不存在时不改变当前选择 * @param control_id 待选中控件标识,不存在时不改变当前选择
*/ */
void selectControl(const std::string &control_id); void selectControl(const std::string &control_id);
/** 返回当前选中的单个控件标识 */
std::string selectedControlId() const; std::string selectedControlId() const;
/** 返回当前选中的多个控件标识 */
std::vector<std::string> selectedControlIds() const; std::vector<std::string> selectedControlIds() const;
/** /**
* @brief 从统一寄存器仓库读取当前值并刷新画布显示 * @brief 从统一寄存器仓库读取当前值并刷新画布显示
@@ -94,6 +97,7 @@ signals:
* @param message 可直接显示的错误信息 * @param message 可直接显示的错误信息
*/ */
void editorError(const QString &message); void editorError(const QString &message);
/** 运行态请求跳转到指定 HMI 页面 */
void pageNavigationRequested(const QString &target_page_id); void pageNavigationRequested(const QString &target_page_id);


protected: protected:
@@ -114,18 +118,20 @@ private:
// 在运行态处理数值输入激活 // 在运行态处理数值输入激活
void handleNumericInputActivated(const std::string &control_id); void handleNumericInputActivated(const std::string &control_id);


// 提供控件查找、移动和属性更新能力,不直接操作 Qt 图元数据
/** 提供控件查找、移动和属性更新能力,不直接操作 Qt 图元数据 */
HmiEditorService &editor_service_; HmiEditorService &editor_service_;
// 提供运行态寄存器读写能力,画布不直接访问寄存器仓库
/** 提供运行态寄存器读写能力,画布不直接访问寄存器仓库 */
HmiRuntimeService &runtime_service_; HmiRuntimeService &runtime_service_;
/** 报警查询服务,不由控件拥有 */
AlarmService &alarm_service_; AlarmService &alarm_service_;
// 持有所有页面图元和页面边框
/** 持有所有页面图元和页面边框 */
QGraphicsScene *scene_ = nullptr; QGraphicsScene *scene_ = nullptr;
// 当前画布投影的页面标识
/** 当前画布投影的页面标识 */
std::string page_id_; std::string page_id_;
// 控制是否允许拖动和编辑控件
/** 控制是否允许拖动和编辑控件 */
bool editing_enabled_ = true; bool editing_enabled_ = true;
// 控制是否刷新运行值并响应运行态控件操作
/** 控制是否刷新运行值并响应运行态控件操作 */
bool runtime_active_ = false; bool runtime_active_ = false;
/** 控制运行态按钮和数值输入是否允许写入 */
bool runtime_write_enabled_ = false; bool runtime_write_enabled_ = false;
}; };

+ 1257
- 220
app/src/ui/logic_editor_widget.cpp
文件差異過大導致無法顯示
查看文件


+ 124
- 1
app/src/ui/logic_editor_widget.h 查看文件

@@ -1,6 +1,15 @@
/**
* @file logic_editor_widget.h
* @brief 定义结构化梯形图编辑和运行轨迹画布
* @version 1.0.0
* @author suyu
* @date 2026-08-22
*/

#pragma once #pragma once


#include "domain/control_logic_model.h" #include "domain/control_logic_model.h"
#include "services/logic_command_service.h"
#include "services/logic_editor_service.h" #include "services/logic_editor_service.h"
#include "services/software_logic_executor.h" #include "services/software_logic_executor.h"


@@ -11,73 +20,187 @@
#include <vector> #include <vector>


class QGraphicsScene; class QGraphicsScene;
class QCompleter;
class QEvent;
class QLineEdit;
class QMouseEvent;
class QRubberBand;
class QResizeEvent; class QResizeEvent;


/** 将结构化梯形图表达式投影成网格图元;不保存自由线段,编辑通过服务提交 */
class LogicEditorWidget final : public QGraphicsView class LogicEditorWidget final : public QGraphicsView
{ {
Q_OBJECT Q_OBJECT


public: public:
/** 梯形图中的条件、输出、横线和辅助选择图元类型 */
class NodeItem; class NodeItem;
class WireItem; class WireItem;
class WireCellItem; class WireCellItem;
class GapCellItem;
class VerticalConnectorItem; class VerticalConnectorItem;
class RungItem; class RungItem;
class EmptySlotItem; class EmptySlotItem;
class OutputSlotItem;


/** 创建梯形图画布并绑定编辑服务 */
explicit LogicEditorWidget( explicit LogicEditorWidget(
LogicEditorService &editor_service, LogicEditorService &editor_service,
QWidget *parent = nullptr); QWidget *parent = nullptr);


/** 切换当前显示的控制逻辑;不存在时显示空场景 */
void setLogicId(const std::string &logic_id); void setLogicId(const std::string &logic_id);
/** 设置是否允许编辑梯形图 */
void setEditingEnabled(bool enabled); void setEditingEnabled(bool enabled);
/** 设置离线运行轨迹;真机模式应传入空轨迹,避免显示本地伪轨迹 */
void setRuntimeTrace( void setRuntimeTrace(
const LogicTraceSnapshot &trace, const LogicTraceSnapshot &trace,
const std::string &fault_node_id = {}); const std::string &fault_node_id = {});
/** 清除当前运行轨迹和故障节点标记 */
void clearRuntimeTrace(); void clearRuntimeTrace();
/** 返回当前是否正在显示运行轨迹 */
bool runtimeTraceEnabled() const;
/** 根据当前逻辑模型重建梯形图场景 */
void reloadLogic(); void reloadLogic();
/** 选中指定节点并滚动到可见区域 */
void selectNode(const std::string &node_id); void selectNode(const std::string &node_id);
/** 返回当前选中的单个节点标识 */
std::string selectedNodeId() const; std::string selectedNodeId() const;
/** 返回当前选中的多个节点标识 */
std::vector<std::string> selectedNodeIds() const; std::vector<std::string> selectedNodeIds() const;
/** 返回当前选中的网络标识 */
std::string selectedRungId() const; std::string selectedRungId() const;


/** 新增一个空的梯形图网络 */
LogicEditorResult addRung(); LogicEditorResult addRung();
/** 将当前选择转换为服务调用并新增串联条件 */
LogicEditorResult addCondition(const LogicNodeConfig &config); LogicEditorResult addCondition(const LogicNodeConfig &config);
/** 将当前选择转换为服务调用并新增并联支路 */
LogicEditorResult addParallelBranch(const LogicNodeConfig &config); LogicEditorResult addParallelBranch(const LogicNodeConfig &config);
/** 在当前选择位置新增横线 */
LogicEditorResult addHorizontalWire(); LogicEditorResult addHorizontalWire();
/** 在当前选择位置新增竖线 */
LogicEditorResult addVerticalWire(); LogicEditorResult addVerticalWire();
/** 删除当前选择位置的横线 */
LogicEditorResult deleteHorizontalWire(); LogicEditorResult deleteHorizontalWire();
/** 删除当前选择位置的竖线 */
LogicEditorResult deleteVerticalWire(); LogicEditorResult deleteVerticalWire();
/** 设置当前网络的输出线圈 */
LogicEditorResult setOutput( LogicEditorResult setOutput(
const LogicNodeConfig &config, bool configured = false); const LogicNodeConfig &config, bool configured = false);
/** 按当前画布选择位置粘贴一组连续条件 */
LogicEditorResult pasteConditionNodes(const std::vector<LogicNode> &nodes);
/** 删除当前选中的节点、线段或网络 */
LogicEditorResult deleteSelected(); LogicEditorResult deleteSelected();


signals: signals:
/** 当前选中的节点发生变化时发出 */
void nodeSelected(const QString &node_id); void nodeSelected(const QString &node_id);
/** 梯形图模型成功发生变化时发出 */
void graphChanged(); void graphChanged();
/** 编辑服务操作失败时发出可显示的错误信息 */
void editorError(const QString &message); void editorError(const QString &message);


protected: protected:
/** 在任意网络区域开始鼠标框选 */
void mousePressEvent(QMouseEvent *event) override;
/** 更新鼠标框选区域 */
void mouseMoveEvent(QMouseEvent *event) override;
/** 完成鼠标框选并提交场景选择 */
void mouseReleaseEvent(QMouseEvent *event) override;
/** 在条件网格或输出槽双击时打开命令输入框 */
void mouseDoubleClickEvent(QMouseEvent *event) override;
/** 在窗口大小变化后重新布局梯形图场景 */
void resizeEvent(QResizeEvent *event) override; void resizeEvent(QResizeEvent *event) override;
/** 滚动画布时同步命令输入框位置 */
void scrollContentsBy(int dx, int dy) override;
/** 处理命令输入框的回车、取消和按键事件 */
bool eventFilter(QObject *watched, QEvent *event) override;


private: private:
/** 处理图形场景选择变化 */
void handleSelectionChanged(); void handleSelectionChanged();
/** 把编辑服务结果转换为错误信号 */
void reportFailure(const LogicEditorResult &result); void reportFailure(const LogicEditorResult &result);
/** 返回当前选中的网络标识 */
std::string currentRungId() const; std::string currentRungId() const;
/** 选中指定表达式图元 */
void selectExpression(const std::string &expression_id); void selectExpression(const std::string &expression_id);
/** 选中指定横线图元 */
void selectWire(const std::string &wire_id); void selectWire(const std::string &wire_id);
/** 收集当前选中的表达式标识 */
std::vector<std::string> selectedExpressionIds() const; std::vector<std::string> selectedExpressionIds() const;
/** 收集当前选中的横线标识 */
std::vector<std::string> selectedWireIds() const; std::vector<std::string> selectedWireIds() const;
/** 收集当前选中的并联支路标识 */
std::vector<std::string> selectedBranchIds() const; std::vector<std::string> selectedBranchIds() const;
std::vector<int> selectedEmptySlotColumns() const;
/** 收集当前选中的空网格位置 */
std::vector<std::pair<std::string, int>> selectedEmptySlots() const;
/** 收集当前选中的横线网格位置 */
std::vector<std::pair<std::string, int>> selectedWireCells() const; std::vector<std::pair<std::string, int>> selectedWireCells() const;
/** 收集当前选中的显式断路网格位置 */
std::vector<std::pair<std::string, int>> selectedGapCells() const;
/** 判断当前是否选中了网络内的图元 */
bool hasSelectedRungItem() const;
/** 收集当前明确选中的网络图元 */
std::vector<std::string> selectedRungItemIds() const;
/** 打开指定目标位置的命令输入框 */
void beginCommandInput(
const LogicCommandTarget &target,
const QPointF &scene_center);
/** 提交当前命令输入;失败时保留输入框并标红 */
void commitCommandInput();
/** 关闭命令输入框并清理临时状态 */
void cancelCommandInput();
/** 根据网络、列和支路标识查找下一输入位置 */
bool beginNextCommandInput();
/** 按目标定位输入框的屏幕矩形 */
void positionCommandInput(const QPointF &scene_center);
/** 返回当前场景目标的中心位置 */
bool findCommandTargetCenter(
const LogicCommandTarget &target,
QPointF *scene_center) const;
/** 将成功命令选中并刷新属性面板 */
void selectCommandResult(const LogicCommandResult &result);


/** 梯形图编辑服务,不由控件拥有 */
LogicEditorService &editor_service_; LogicEditorService &editor_service_;
/** 命令语解析和结构化编辑适配服务 */
LogicCommandService command_service_;
/** 承载梯形图图元的场景 */
QGraphicsScene *scene_ = nullptr; QGraphicsScene *scene_ = nullptr;
/** 当前显示的控制逻辑标识 */
std::string logic_id_; std::string logic_id_;
/** 当前选中的网络标识 */
std::string current_rung_id_; std::string current_rung_id_;
/** 最近一次收到的运行轨迹 */
LogicTraceSnapshot trace_; LogicTraceSnapshot trace_;
/** 最近一次运行故障节点标识 */
std::string fault_node_id_; std::string fault_node_id_;
/** 是否显示运行轨迹 */
bool runtime_trace_enabled_ = false; bool runtime_trace_enabled_ = false;
/** 是否允许编辑梯形图 */
bool editing_enabled_ = true; bool editing_enabled_ = true;
/** 鼠标框选覆盖层 */
QRubberBand *selection_band_ = nullptr;
/** 框选起点(视口坐标) */
QPoint selection_origin_;
/** 框选时是否已超过拖拽阈值 */
bool selection_dragging_ = false;
/** 框选起始时的键盘修饰键 */
Qt::KeyboardModifiers selection_modifiers_ = Qt::NoModifier;
/** 命令输入框,仅在编辑态临时创建 */
QLineEdit *command_editor_ = nullptr;
/** 命令输入补全器 */
QCompleter *command_completer_ = nullptr;
/** 当前命令输入目标 */
LogicCommandTarget command_target_;
/** 是否沿当前命令序列继续输入 */
bool command_continuing_ = false;
/** OR/ORI 只增加支路,不推进主表达式列 */
bool command_keep_column_ = false;
/** 连续输入对应的当前网络 */
std::string command_current_rung_id_;
/** 编辑已有网络 OR 时保留的选择范围 */
std::vector<std::string> command_parallel_node_ids_;
}; };

+ 2
- 44
app/src/ui/logic_instruction_dialog.cpp 查看文件

@@ -31,14 +31,12 @@ LogicInstructionDialog::LogicInstructionDialog(
original_config_(config) original_config_(config)
{ {
ui_->setupUi(this); ui_->setupUi(this);
configureOperandCombo(ui_->counterPresetKindComboBox);
configureOperandCombo(ui_->leftOperandKindComboBox); configureOperandCombo(ui_->leftOperandKindComboBox);
configureOperandCombo(ui_->rightOperandKindComboBox); configureOperandCombo(ui_->rightOperandKindComboBox);


connect(ui_->buttonBox, &QDialogButtonBox::accepted, this, &QDialog::accept); connect(ui_->buttonBox, &QDialogButtonBox::accepted, this, &QDialog::accept);
connect(ui_->buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject); connect(ui_->buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject);
for (QComboBox *combo : { for (QComboBox *combo : {
ui_->counterPresetKindComboBox,
ui_->leftOperandKindComboBox, ui_->leftOperandKindComboBox,
ui_->rightOperandKindComboBox}) ui_->rightOperandKindComboBox})
{ {
@@ -49,11 +47,7 @@ LogicInstructionDialog::LogicInstructionDialog(
&LogicInstructionDialog::updateOperandRanges); &LogicInstructionDialog::updateOperandRanges);
} }


if (const auto *counter = std::get_if<CounterNodeConfig>(&config))
{
configureCounter(*counter);
}
else if (const auto *move = std::get_if<MoveNodeConfig>(&config))
if (const auto *move = std::get_if<MoveNodeConfig>(&config))
{ {
configureMove(*move); configureMove(*move);
} }
@@ -68,21 +62,6 @@ LogicInstructionDialog::~LogicInstructionDialog() = default;


LogicNodeConfig LogicInstructionDialog::config() const LogicNodeConfig LogicInstructionDialog::config() const
{ {
if (std::holds_alternative<CounterNodeConfig>(original_config_))
{
return CounterNodeConfig{
CounterAddress{ui_->counterAddressSpinBox->value()},
static_cast<CounterMode>(ui_->counterModeComboBox->currentData().toInt()),
RegisterAddress{
RegisterArea::D,
ui_->counterCurrentAddressSpinBox->value()},
operandFrom(
ui_->counterPresetKindComboBox,
ui_->counterPresetValueSpinBox),
RegisterAddress{
RegisterArea::M,
ui_->counterResetAddressSpinBox->value()}};
}
if (std::holds_alternative<MoveNodeConfig>(original_config_)) if (std::holds_alternative<MoveNodeConfig>(original_config_))
{ {
return MoveNodeConfig{ return MoveNodeConfig{
@@ -101,25 +80,6 @@ LogicNodeConfig LogicInstructionDialog::config() const
ui_->destinationAddressSpinBox->value()}}; ui_->destinationAddressSpinBox->value()}};
} }


void LogicInstructionDialog::configureCounter(const CounterNodeConfig &config)
{
setWindowTitle(tr("配置计数器"));
ui_->instructionStackedWidget->setCurrentWidget(ui_->counterPage);
ui_->counterModeComboBox->clear();
ui_->counterModeComboBox->addItem(tr("CTU 加计数"), static_cast<int>(CounterMode::Up));
ui_->counterModeComboBox->addItem(
tr("CTD 减计数"), static_cast<int>(CounterMode::Down));
ui_->counterModeComboBox->setCurrentIndex(
ui_->counterModeComboBox->findData(static_cast<int>(config.mode)));
ui_->counterAddressSpinBox->setValue(config.address.index());
ui_->counterCurrentAddressSpinBox->setValue(config.currentValueAddress.index());
ui_->counterResetAddressSpinBox->setValue(config.resetAddress.index());
setOperand(
ui_->counterPresetKindComboBox,
ui_->counterPresetValueSpinBox,
config.preset);
}

void LogicInstructionDialog::configureMove(const MoveNodeConfig &config) void LogicInstructionDialog::configureMove(const MoveNodeConfig &config)
{ {
setWindowTitle(tr("配置 MOVE")); setWindowTitle(tr("配置 MOVE"));
@@ -165,13 +125,11 @@ void LogicInstructionDialog::updateOperandRanges()
spin->setPrefix(register_operand ? QStringLiteral("D") : QString{}); spin->setPrefix(register_operand ? QStringLiteral("D") : QString{});
spin->setMinimum(register_operand spin->setMinimum(register_operand
? RegisterAddress::kMinimumIndex ? RegisterAddress::kMinimumIndex
: (combo->objectName() == QStringLiteral("counterPresetKindComboBox")
? 0 : std::numeric_limits<std::int16_t>::min()));
: std::numeric_limits<std::int16_t>::min());
spin->setMaximum(register_operand spin->setMaximum(register_operand
? RegisterAddress::kMaximumIndex ? RegisterAddress::kMaximumIndex
: std::numeric_limits<std::int16_t>::max()); : std::numeric_limits<std::int16_t>::max());
}; };
update(ui_->counterPresetKindComboBox, ui_->counterPresetValueSpinBox);
update(ui_->leftOperandKindComboBox, ui_->leftOperandValueSpinBox); update(ui_->leftOperandKindComboBox, ui_->leftOperandValueSpinBox);
update(ui_->rightOperandKindComboBox, ui_->rightOperandValueSpinBox); update(ui_->rightOperandKindComboBox, ui_->rightOperandValueSpinBox);
} }


+ 19
- 1
app/src/ui/logic_instruction_dialog.h 查看文件

@@ -1,3 +1,11 @@
/**
* @file logic_instruction_dialog.h
* @brief 定义梯形图指令参数编辑对话框
* @version 1.0.0
* @author suyu
* @date 2026-08-22
*/

#pragma once #pragma once


#include "domain/control_logic_model.h" #include "domain/control_logic_model.h"
@@ -13,31 +21,41 @@ namespace Ui {
class LogicInstructionDialog; class LogicInstructionDialog;
} }


/** 梯形图节点配置对话框;只负责把表单转换为强类型 LogicNodeConfig */
class LogicInstructionDialog final : public QDialog class LogicInstructionDialog final : public QDialog
{ {
Q_OBJECT Q_OBJECT


public: public:
/** 使用已有节点配置初始化指令参数表单 */
explicit LogicInstructionDialog( explicit LogicInstructionDialog(
const LogicNodeConfig &config, const LogicNodeConfig &config,
QWidget *parent = nullptr); QWidget *parent = nullptr);
/** 释放 Designer 界面对象 */
~LogicInstructionDialog() override; ~LogicInstructionDialog() override;


/** 返回当前表单配置,调用方仍需交给 LogicEditorService 校验 */
LogicNodeConfig config() const; LogicNodeConfig config() const;


private: private:
void configureCounter(const CounterNodeConfig &config);
/** 根据传送配置初始化传送输入项 */
void configureMove(const MoveNodeConfig &config); void configureMove(const MoveNodeConfig &config);
/** 根据算术配置初始化运算输入项 */
void configureArithmetic(const ArithmeticNodeConfig &config); void configureArithmetic(const ArithmeticNodeConfig &config);
/** 根据操作数类型更新数值输入框的合法范围 */
void updateOperandRanges(); void updateOperandRanges();
/** 将领域操作数写入对应的下拉框和数值框 */
static void setOperand( static void setOperand(
QComboBox *kind_combo, QComboBox *kind_combo,
QSpinBox *value_spin, QSpinBox *value_spin,
const WordOperand &operand); const WordOperand &operand);
/** 从下拉框和数值框读取领域操作数 */
static WordOperand operandFrom( static WordOperand operandFrom(
const QComboBox *kind_combo, const QComboBox *kind_combo,
const QSpinBox *value_spin); const QSpinBox *value_spin);


/** Qt Designer 生成的界面对象 */
std::unique_ptr<Ui::LogicInstructionDialog> ui_; std::unique_ptr<Ui::LogicInstructionDialog> ui_;
/** 打开对话框时保存的原始节点配置 */
LogicNodeConfig original_config_; LogicNodeConfig original_config_;
}; };

+ 0
- 21
app/src/ui/logic_instruction_dialog.ui 查看文件

@@ -9,27 +9,6 @@
<layout class="QVBoxLayout" name="verticalLayout"> <layout class="QVBoxLayout" name="verticalLayout">
<item> <item>
<widget class="QStackedWidget" name="instructionStackedWidget"> <widget class="QStackedWidget" name="instructionStackedWidget">
<widget class="QWidget" name="counterPage">
<layout class="QFormLayout" name="counterFormLayout">
<item row="0" column="0"><widget class="QLabel" name="counterModeLabel"><property name="text"><string>计数方式</string></property></widget></item>
<item row="0" column="1"><widget class="QComboBox" name="counterModeComboBox"/></item>
<item row="1" column="0"><widget class="QLabel" name="counterAddressLabel"><property name="text"><string>计数器资源</string></property></widget></item>
<item row="1" column="1"><widget class="QSpinBox" name="counterAddressSpinBox"><property name="prefix"><string>C</string></property><property name="maximum"><number>4000</number></property></widget></item>
<item row="2" column="0"><widget class="QLabel" name="counterCurrentAddressLabel"><property name="text"><string>当前值 CV</string></property></widget></item>
<item row="2" column="1"><widget class="QSpinBox" name="counterCurrentAddressSpinBox"><property name="prefix"><string>D</string></property><property name="maximum"><number>4000</number></property></widget></item>
<item row="3" column="0"><widget class="QLabel" name="counterPresetLabel"><property name="text"><string>预设值 PV</string></property></widget></item>
<item row="3" column="1">
<widget class="QWidget" name="counterPresetEditor" native="true">
<layout class="QHBoxLayout" name="counterPresetLayout"><property name="leftMargin"><number>0</number></property><property name="topMargin"><number>0</number></property><property name="rightMargin"><number>0</number></property><property name="bottomMargin"><number>0</number></property>
<item><widget class="QComboBox" name="counterPresetKindComboBox"/></item>
<item><widget class="QSpinBox" name="counterPresetValueSpinBox"><property name="maximum"><number>32767</number></property><property name="value"><number>10</number></property></widget></item>
</layout>
</widget>
</item>
<item row="4" column="0"><widget class="QLabel" name="counterResetAddressLabel"><property name="text"><string>复位输入</string></property></widget></item>
<item row="4" column="1"><widget class="QSpinBox" name="counterResetAddressSpinBox"><property name="prefix"><string>M</string></property><property name="maximum"><number>4000</number></property></widget></item>
</layout>
</widget>
<widget class="QWidget" name="dataInstructionPage"> <widget class="QWidget" name="dataInstructionPage">
<layout class="QFormLayout" name="dataInstructionFormLayout"> <layout class="QFormLayout" name="dataInstructionFormLayout">
<item row="0" column="0"><widget class="QLabel" name="instructionCaptionLabel"><property name="text"><string>指令</string></property></widget></item> <item row="0" column="0"><widget class="QLabel" name="instructionCaptionLabel"><property name="text"><string>指令</string></property></widget></item>


+ 833
- 103
app/src/ui/main_window.cpp
文件差異過大導致無法顯示
查看文件


+ 162
- 24
app/src/ui/main_window.h 查看文件

@@ -1,7 +1,7 @@
/** /**
* @file main_window.h * @file main_window.h
* @brief 定义综合平台编程器主窗口及运行模式界面协调逻辑 * @brief 定义综合平台编程器主窗口及运行模式界面协调逻辑
* @version 0.2.0
* @version 1.0.0
* @author suyu * @author suyu
* @date 2026-08-08 * @date 2026-08-08
*/ */
@@ -12,10 +12,14 @@
#include "domain/control_logic_model.h" #include "domain/control_logic_model.h"
#include "domain/runtime_state.h" #include "domain/runtime_state.h"
#include "services/plc_communication_gateway.h" #include "services/plc_communication_gateway.h"
#include "services/application_settings.h"


#include <QMainWindow> #include <QMainWindow>
#include <QProcess>


#include <memory> #include <memory>
#include <variant>
#include <vector>


QT_BEGIN_NAMESPACE QT_BEGIN_NAMESPACE
class QAction; class QAction;
@@ -23,6 +27,9 @@ class QActionGroup;
class QCloseEvent; class QCloseEvent;
class QEvent; class QEvent;
class QLabel; class QLabel;
class QDockWidget;
class QTimer;
class QProgressDialog;


namespace Ui { namespace Ui {
class MainWindow; class MainWindow;
@@ -41,7 +48,9 @@ class HmiEditorWidget;
class LogicEditorService; class LogicEditorService;
class LogicEditorWidget; class LogicEditorWidget;
class RegisterMonitorService; class RegisterMonitorService;
class PlcDiscoveryGateway;
class RuntimeMonitorWidget; class RuntimeMonitorWidget;
class FreeMonitorWidget;
class ProjectWorkspaceController; class ProjectWorkspaceController;
class PropertyPanelController; class PropertyPanelController;
class RuntimePanelController; class RuntimePanelController;
@@ -51,6 +60,7 @@ class RuntimePanelController;
* *
* 主窗口不直接访问寄存器、工程文件或 PLC 通信实现 * 主窗口不直接访问寄存器、工程文件或 PLC 通信实现
*/ */
// 编程器主窗口:负责组装 UI 控制器、窗口动作和模式切换,不承载领域规则
class MainWindow final : public QMainWindow class MainWindow final : public QMainWindow
{ {
Q_OBJECT Q_OBJECT
@@ -74,7 +84,15 @@ public:
AlarmService &alarm_service, AlarmService &alarm_service,
RegisterCommentService &register_comment_service, RegisterCommentService &register_comment_service,
RegisterMonitorService &register_monitor_service, RegisterMonitorService &register_monitor_service,
PlcDiscoveryGateway &plc_discovery_gateway,
const ApplicationSettingsLoadResult &application_settings_result,
bool user_runtime_mode = false,
QWidget *parent = nullptr); QWidget *parent = nullptr);
/**
* @brief 创建主窗口,并使用调用方提供的 HMI 页面导航服务
*
* 两个构造函数的区别只在导航服务的所有权;其余服务均由应用入口管理
*/
MainWindow( MainWindow(
RuntimeModeService &runtime_mode_service, RuntimeModeService &runtime_mode_service,
ProjectService &project_service, ProjectService &project_service,
@@ -86,82 +104,125 @@ public:
HmiNavigationService &hmi_navigation_service, HmiNavigationService &hmi_navigation_service,
RegisterCommentService &register_comment_service, RegisterCommentService &register_comment_service,
RegisterMonitorService &register_monitor_service, RegisterMonitorService &register_monitor_service,
PlcDiscoveryGateway &plc_discovery_gateway,
const ApplicationSettingsLoadResult &application_settings_result,
bool user_runtime_mode = false,
QWidget *parent = nullptr); QWidget *parent = nullptr);


/** 释放窗口拥有的控制器和可选导航服务 */
~MainWindow() override; ~MainWindow() override;


/** 返回当前选中的 HMI 页面标识 */
const std::string &currentHmiPageId() const; const std::string &currentHmiPageId() const;
/** 返回当前选中的控制逻辑标识 */
const std::string &currentLogicId() const; const std::string &currentLogicId() const;


protected: protected:
/** 处理主窗口及子控件的事件过滤请求 */
bool eventFilter(QObject *watched, QEvent *event) override; bool eventFilter(QObject *watched, QEvent *event) override;
/** 关闭窗口前处理保存确认和运行态退出 */
void closeEvent(QCloseEvent *event) override; void closeEvent(QCloseEvent *event) override;


private: private:
// 配置菜单和工具栏动作
/** 配置菜单和工具栏动作 */
void configureActions(); void configureActions();
// 配置主窗口外观和状态栏
/** 配置主窗口外观和状态栏 */
void configureAppearance(); void configureAppearance();
// 创建并连接 HMI 编辑画布
/** 创建并连接 HMI 编辑画布 */
void configureHmiEditor(); void configureHmiEditor();
// 创建并连接控制逻辑编辑画布
/** 创建并连接控制逻辑编辑画布 */
void configureLogicEditor(); void configureLogicEditor();
/** 创建并连接运行监控面板 */
void configureRuntimeMonitor(); void configureRuntimeMonitor();
/** 创建并连接编辑态数据监控面板 */
void configureDataMonitor();
/** 创建并连接工程树 */
void configureProjectTree(); void configureProjectTree();
// 创建并连接控件属性编辑表单
/** 创建并连接控件属性编辑表单 */
void configurePropertyEditor(); void configurePropertyEditor();
/** 创建并连接 PLC 串口配置入口 */
void configurePlcConnection(); void configurePlcConnection();
/** 创建并连接报警配置入口 */
void configureAlarms(); void configureAlarms();
/** 创建并连接寄存器注释入口 */
void configureRegisterComments(); void configureRegisterComments();
/** 撤销当前处于焦点的编辑器操作 */
void undoActiveEditor(); void undoActiveEditor();
/** 重做当前处于焦点的编辑器操作 */
void redoActiveEditor(); void redoActiveEditor();
/** 复制当前编辑画布中的选中对象 */
void copyActiveSelection();
/** 粘贴剪贴板中的对象到当前编辑画布 */
void pasteActiveSelection();
/** 删除当前处于焦点的编辑器选中项 */
void deleteActiveSelection(); void deleteActiveSelection();
/** 清除当前处于焦点的编辑器选择 */
void clearActiveSelection(); void clearActiveSelection();
/** 根据编辑器选择状态更新撤销、重做和删除动作 */
void updateEditActions(); void updateEditActions();
/** 清空 HMI 和梯形图编辑器的撤销历史 */
void clearEditorHistories(); void clearEditorHistories();
/** 根据工程路径和修改状态刷新窗口标题 */
void updateWindowTitle(); void updateWindowTitle();
/** 对可能破坏当前工程的操作请求保存确认 */
bool confirmSaveBeforeDestructiveAction(); bool confirmSaveBeforeDestructiveAction();
// 刷新工程树和当前 HMI 页面信息
/** 刷新工程树和当前 HMI 页面信息 */
void refreshProjectUi(); void refreshProjectUi();
/** 更新工程树相关动作的可用状态 */
void updateProjectTreeActions(); void updateProjectTreeActions();
// 显示指定 HMI 控件的可编辑属性
/** 显示指定 HMI 控件的可编辑属性 */
void showControlProperties(const std::string &control_id); void showControlProperties(const std::string &control_id);
// 显示指定控制逻辑节点的可编辑属性
/** 显示指定控制逻辑节点的可编辑属性 */
void showLogicNodeProperties(const std::string &node_id); void showLogicNodeProperties(const std::string &node_id);
// 向当前页面添加指定类型的 HMI 控件
/** 向当前页面添加指定类型的 HMI 控件 */
void addHmiControl(HmiControlType type); void addHmiControl(HmiControlType type);
// 删除当前选中的 HMI 控件
/** 删除当前选中的 HMI 控件 */
void deleteSelectedControl(); void deleteSelectedControl();
// 在当前光标节点后添加串联条件,没有选择时追加到网络末尾
/** 在当前光标节点后添加串联条件,没有选择时追加到网络末尾 */
void addLogicCondition(const LogicNodeConfig &config); void addLogicCondition(const LogicNodeConfig &config);
// 为当前选中的连续逻辑范围建立并联支路
/** 为当前选中的连续逻辑范围建立并联支路 */
void addLogicParallelBranch(const LogicNodeConfig &config); void addLogicParallelBranch(const LogicNodeConfig &config);
/** 在当前梯形图位置添加横线 */
void addLogicHorizontalWire(); void addLogicHorizontalWire();
/** 在当前梯形图位置添加竖线 */
void addLogicVerticalWire(); void addLogicVerticalWire();
/** 删除当前梯形图位置的横线 */
void deleteLogicHorizontalWire(); void deleteLogicHorizontalWire();
/** 删除当前梯形图位置的竖线 */
void deleteLogicVerticalWire(); void deleteLogicVerticalWire();
// 设置当前网络右侧的输出线圈
/** 设置当前网络右侧的输出线圈 */
void setLogicOutput(const LogicNodeConfig &config); void setLogicOutput(const LogicNodeConfig &config);
/** 打开输出指令配置并提交输出线圈 */
void configureAndSetLogicOutput(const LogicNodeConfig &config); void configureAndSetLogicOutput(const LogicNodeConfig &config);
// 新增一个梯形图网络
/** 新增一个梯形图网络 */
void addLogicRung(); void addLogicRung();
/** 编辑当前网络的注释 */
void editSelectedRungComment(); void editSelectedRungComment();
// 删除当前选中的逻辑节点或网络
/** 删除当前选中的逻辑节点或网络 */
void deleteSelectedLogicObject(); void deleteSelectedLogicObject();
/** 打开 PLC 连接 */
void connectPlc(); void connectPlc();
/** 关闭 PLC 连接 */
void disconnectPlc(); void disconnectPlc();
// 合并同一事件循环内的 PLC 状态通知,避免重复刷新和重复日志
/** 合并同一事件循环内的 PLC 状态通知,避免重复刷新和重复日志 */
void schedulePlcStatusUpdate(); void schedulePlcStatusUpdate();
// 创建新的工程并刷新编辑界面
/** 创建新的工程并刷新编辑界面 */
void createNewProject(); void createNewProject();
// 保存当前工程到已关联的路径
/** 保存当前工程到已关联的路径 */
void saveProject(); void saveProject();
// 将当前工程保存到用户指定的路径
/** 将当前工程保存到用户指定的路径 */
void saveProjectAs(); void saveProjectAs();
// 从用户指定的路径加载工程
/** 从用户指定的路径加载工程 */
void loadProject(); void loadProject();
// 在状态栏和输出面板显示工程操作结果
/** 将当前工程构建为用户可直接运行的专用 exe */
void exportRuntimeProgram();
/** 更新用户运行程序导出的阶段进度 */
void handleRuntimeExportOutput();
/** 完成用户运行程序导出并复制最终目录 */
void handleRuntimeExportFinished(int exit_code, QProcess::ExitStatus exit_status);
/** 在状态栏和输出面板显示工程操作结果 */
void showProjectResult(const QString &action, const QString &message, bool succeeded); void showProjectResult(const QString &action, const QString &message, bool succeeded);
/** 向输出面板追加一条消息 */
void appendOutputMessage(const QString &message); void appendOutputMessage(const QString &message);


/** /**
@@ -170,22 +231,30 @@ private:
* *
* 失败时恢复与服务层当前状态一致的工具栏选项 * 失败时恢复与服务层当前状态一致的工具栏选项
*/ */
void requestMode(ApplicationMode requested_mode);
bool requestMode(ApplicationMode requested_mode);


/** /**
* @brief 根据服务层模式策略更新编辑权限和状态反馈 * @brief 根据服务层模式策略更新编辑权限和状态反馈
* @param message 要显示在状态栏和输出区的模式结果消息 * @param message 要显示在状态栏和输出区的模式结果消息
*/ */
void updateModeUi(const QString &message); void updateModeUi(const QString &message);
// 根据仿真服务实际状态刷新执行器反馈和 HMI 写权限
/** 根据仿真服务实际状态刷新执行器反馈和 HMI 写权限 */
void updateSimulationUi(bool report_fault); void updateSimulationUi(bool report_fault);


/** /**
* @brief 将互斥模式动作同步到服务层当前状态 * @brief 将互斥模式动作同步到服务层当前状态
*/ */
void restoreCurrentModeAction(); void restoreCurrentModeAction();
/** 完成主窗口初始化和首次界面刷新 */
void initializeUi(); void initializeUi();
/** 专用用户运行版启动后直接进入运行监控 */
void initializeUserRuntime();
/** 用户运行版允许在离线和真机之间自动经过编辑态切换 */
void requestUserRuntimeMode(ApplicationMode requested_mode);
/** 刷新编辑态数据监控的模式、权限和当前值 */
void refreshDataMonitorUi();


/** Qt Designer 生成的主窗口界面对象 */
std::unique_ptr<Ui::MainWindow> ui_; std::unique_ptr<Ui::MainWindow> ui_;
/** /**
* @brief 非拥有的运行模式服务依赖,由应用入口保证生命周期 * @brief 非拥有的运行模式服务依赖,由应用入口保证生命周期
@@ -195,29 +264,98 @@ private:
* @brief 非拥有的工程和 HMI 服务依赖,由应用入口保证生命周期 * @brief 非拥有的工程和 HMI 服务依赖,由应用入口保证生命周期
*/ */
ProjectService &project_service_; ProjectService &project_service_;
/** 非拥有的 HMI 编辑服务依赖 */
HmiEditorService &hmi_editor_service_; HmiEditorService &hmi_editor_service_;
/** 非拥有的梯形图编辑服务依赖 */
LogicEditorService &logic_editor_service_; LogicEditorService &logic_editor_service_;
/** 非拥有的 HMI 运行服务依赖 */
HmiRuntimeService &hmi_runtime_service_; HmiRuntimeService &hmi_runtime_service_;
/** 非拥有的报警编辑服务依赖 */
AlarmEditorService &alarm_editor_service_; AlarmEditorService &alarm_editor_service_;
/** 非拥有的报警查询服务依赖 */
AlarmService &alarm_service_; AlarmService &alarm_service_;
/** 非拥有的寄存器注释服务依赖 */
RegisterCommentService &register_comment_service_; RegisterCommentService &register_comment_service_;
/** 非拥有的寄存器监控服务依赖 */
RegisterMonitorService &register_monitor_service_; RegisterMonitorService &register_monitor_service_;
/** 非拥有的 PLC 自动搜索服务依赖 */
PlcDiscoveryGateway &plc_discovery_gateway_;
/** 启动期配置和诊断结果,由应用入口保证生命周期 */
const ApplicationSettingsLoadResult &application_settings_result_;
/** 是否以专用用户运行版入口启动 */
bool user_runtime_mode_ = false;
/** 当调用方未提供导航服务时由主窗口负责拥有的实例 */
std::unique_ptr<HmiNavigationService> owned_hmi_navigation_service_; std::unique_ptr<HmiNavigationService> owned_hmi_navigation_service_;
/** 当前使用的 HMI 页面导航服务 */
HmiNavigationService *hmi_navigation_service_ = nullptr; HmiNavigationService *hmi_navigation_service_ = nullptr;
/** 工程树控制器 */
std::unique_ptr<ProjectWorkspaceController> project_workspace_controller_; std::unique_ptr<ProjectWorkspaceController> project_workspace_controller_;
/** 属性面板控制器 */
std::unique_ptr<PropertyPanelController> property_panel_controller_; std::unique_ptr<PropertyPanelController> property_panel_controller_;
/** 运行监控面板控制器 */
std::unique_ptr<RuntimePanelController> runtime_panel_controller_; std::unique_ptr<RuntimePanelController> runtime_panel_controller_;
/** 运行模式互斥动作组 */
QActionGroup *mode_action_group_ = nullptr; QActionGroup *mode_action_group_ = nullptr;
/** 主界面的 HMI 编辑器 */
HmiEditorWidget *hmi_editor_widget_ = nullptr; HmiEditorWidget *hmi_editor_widget_ = nullptr;
/** 主界面的梯形图编辑器 */
LogicEditorWidget *logic_editor_widget_ = nullptr; LogicEditorWidget *logic_editor_widget_ = nullptr;
/** 唯一的运行监控控件 */
RuntimeMonitorWidget *runtime_monitor_widget_ = nullptr; RuntimeMonitorWidget *runtime_monitor_widget_ = nullptr;
/** 主窗口中的数据监控面板 */
QDockWidget *data_monitor_dock_ = nullptr;
FreeMonitorWidget *data_monitor_widget_ = nullptr;
QTimer *data_monitor_refresh_timer_ = nullptr;
/** 显示当前运行模式的状态标签 */
QLabel *mode_status_label_ = nullptr; QLabel *mode_status_label_ = nullptr;
/** 显示 PLC 连接状态的标签 */
QLabel *register_status_label_ = nullptr; QLabel *register_status_label_ = nullptr;
/** 显示离线执行器状态的标签 */
QLabel *executor_status_label_ = nullptr; QLabel *executor_status_label_ = nullptr;
/** 当前保存的 PLC 串口配置 */
PlcSerialConfiguration plc_configuration_; PlcSerialConfiguration plc_configuration_;
/** 当前选中的 HMI 控件标识 */
std::string selected_control_id_; std::string selected_control_id_;
/** 当前选中的梯形图节点标识 */
std::string selected_logic_node_id_; std::string selected_logic_node_id_;
/** 当前选中的 HMI 页面标识 */
std::string current_hmi_page_id_; std::string current_hmi_page_id_;
/** 当前选中的控制逻辑标识 */
std::string current_logic_id_; std::string current_logic_id_;
/** 是否已经安排了待处理的 PLC 状态刷新 */
bool plc_status_update_pending_ = false; bool plc_status_update_pending_ = false;
/** 用户运行程序导出进程及其临时状态 */
std::unique_ptr<QProcess> runtime_export_process_;
std::unique_ptr<QProgressDialog> runtime_export_progress_;
QString runtime_export_temp_project_;
QString runtime_export_output_name_;
QString runtime_export_destination_directory_;
QString runtime_export_package_directory_;
QString runtime_export_process_output_;
bool runtime_export_completion_handled_ = false;

struct HmiClipboardData
{
std::vector<HmiControl> controls;
int pasteCount = 0;
};
struct LogicNodesClipboardData
{
std::vector<LogicNode> nodes;
};
struct LogicOutputClipboardData
{
LogicNode output;
};
struct LogicRungClipboardData
{
LadderRung rung;
};
using EditorClipboard = std::variant<
std::monostate,
HmiClipboardData,
LogicNodesClipboardData,
LogicOutputClipboardData,
LogicRungClipboardData>;
EditorClipboard editor_clipboard_;
}; };

+ 57
- 111
app/src/ui/main_window.ui 查看文件

@@ -228,6 +228,7 @@
<addaction name="saveProjectAction"/> <addaction name="saveProjectAction"/>
<addaction name="saveAsProjectAction"/> <addaction name="saveAsProjectAction"/>
<addaction name="loadProjectAction"/> <addaction name="loadProjectAction"/>
<addaction name="exportRuntimeAction"/>
<addaction name="separator"/> <addaction name="separator"/>
<addaction name="exitAction"/> <addaction name="exitAction"/>
</widget> </widget>
@@ -237,6 +238,8 @@
</property> </property>
<addaction name="undoAction"/> <addaction name="undoAction"/>
<addaction name="redoAction"/> <addaction name="redoAction"/>
<addaction name="copyAction"/>
<addaction name="pasteAction"/>
<addaction name="separator"/> <addaction name="separator"/>
<addaction name="deleteSelectionAction"/> <addaction name="deleteSelectionAction"/>
<addaction name="deleteHorizontalWireAction"/> <addaction name="deleteHorizontalWireAction"/>
@@ -669,61 +672,13 @@
</widget> </widget>
</item> </item>
<item row="10" column="0"> <item row="10" column="0">
<widget class="QLabel" name="progressMinimumLabel">
<property name="text">
<string>最小值</string>
</property>
</widget>
</item>
<item row="10" column="1">
<widget class="QSpinBox" name="progressMinimumSpinBox">
<property name="minimum">
<number>-32768</number>
</property>
<property name="maximum">
<number>32767</number>
</property>
</widget>
</item>
<item row="11" column="0">
<widget class="QLabel" name="progressMaximumLabel">
<property name="text">
<string>最大值</string>
</property>
</widget>
</item>
<item row="11" column="1">
<widget class="QSpinBox" name="progressMaximumSpinBox">
<property name="minimum">
<number>-32768</number>
</property>
<property name="maximum">
<number>32767</number>
</property>
</widget>
</item>
<item row="12" column="0">
<widget class="QLabel" name="progressShowValueLabel">
<property name="text">
<string>数值显示</string>
</property>
</widget>
</item>
<item row="12" column="1">
<widget class="QCheckBox" name="progressShowValueCheckBox">
<property name="text">
<string>显示百分比</string>
</property>
</widget>
</item>
<item row="13" column="0">
<widget class="QLabel" name="textColorLabel"> <widget class="QLabel" name="textColorLabel">
<property name="text"> <property name="text">
<string>字体颜色</string> <string>字体颜色</string>
</property> </property>
</widget> </widget>
</item> </item>
<item row="13" column="1">
<item row="10" column="1">
<widget class="QWidget" name="textColorContainer"> <widget class="QWidget" name="textColorContainer">
<layout class="QHBoxLayout" name="textColorLayout"> <layout class="QHBoxLayout" name="textColorLayout">
<property name="leftMargin"><number>0</number></property> <property name="leftMargin"><number>0</number></property>
@@ -747,24 +702,24 @@
</layout> </layout>
</widget> </widget>
</item> </item>
<item row="14" column="0">
<item row="11" column="0">
<widget class="QLabel" name="fontSizeLabel"> <widget class="QLabel" name="fontSizeLabel">
<property name="text"> <property name="text">
<string>字号</string> <string>字号</string>
</property> </property>
</widget> </widget>
</item> </item>
<item row="14" column="1">
<item row="11" column="1">
<widget class="QSpinBox" name="fontSizeSpinBox"/> <widget class="QSpinBox" name="fontSizeSpinBox"/>
</item> </item>
<item row="15" column="0">
<item row="12" column="0">
<widget class="QLabel" name="fontBoldLabel"> <widget class="QLabel" name="fontBoldLabel">
<property name="text"> <property name="text">
<string>字体样式</string> <string>字体样式</string>
</property> </property>
</widget> </widget>
</item> </item>
<item row="15" column="1">
<item row="12" column="1">
<widget class="QWidget" name="fontStyleContainer"> <widget class="QWidget" name="fontStyleContainer">
<layout class="QHBoxLayout" name="fontStyleLayout"> <layout class="QHBoxLayout" name="fontStyleLayout">
<property name="leftMargin"><number>0</number></property> <property name="leftMargin"><number>0</number></property>
@@ -784,7 +739,18 @@
</layout> </layout>
</widget> </widget>
</item> </item>
<item row="16" column="0" colspan="2">
<item row="13" column="0">
<widget class="QLabel" name="dataTypeLabel">
<property name="text"><string>数值类型</string></property>
</widget>
</item>
<item row="13" column="1">
<widget class="QComboBox" name="dataTypeComboBox">
<item><property name="text"><string>Int16</string></property></item>
<item><property name="text"><string>Float32</string></property></item>
</widget>
</item>
<item row="14" column="0" colspan="2">
<widget class="QPushButton" name="applyPropertiesButton"> <widget class="QPushButton" name="applyPropertiesButton">
<property name="text"> <property name="text">
<string>应用属性</string> <string>应用属性</string>
@@ -922,27 +888,7 @@
</property> </property>
</widget> </widget>
</item> </item>
<item row="7" column="0">
<widget class="QLabel" name="logicPresetLabel">
<property name="text">
<string>预设时间(ms)</string>
</property>
</widget>
</item>
<item row="7" column="1">
<widget class="QSpinBox" name="logicPresetSpinBox">
<property name="minimum">
<number>1</number>
</property>
<property name="maximum">
<number>86400000</number>
</property>
<property name="value">
<number>1000</number>
</property>
</widget>
</item>
<item row="8" column="0" colspan="2">
<item row="7" column="0" colspan="2">
<widget class="QPushButton" name="applyLogicPropertiesButton"> <widget class="QPushButton" name="applyLogicPropertiesButton">
<property name="text"> <property name="text">
<string>应用属性</string> <string>应用属性</string>
@@ -960,6 +906,28 @@
</layout> </layout>
</widget> </widget>
</widget> </widget>
<widget class="QDockWidget" name="dataMonitorDock">
<property name="minimumSize">
<size>
<width>640</width>
<height>220</height>
</size>
</property>
<property name="windowTitle">
<string>数据监控</string>
</property>
<attribute name="dockWidgetArea">
<number>2</number>
</attribute>
<widget class="QWidget" name="dataMonitorDockContents">
<layout class="QVBoxLayout" name="dataMonitorDockLayout">
<property name="leftMargin"><number>6</number></property>
<property name="topMargin"><number>6</number></property>
<property name="rightMargin"><number>6</number></property>
<property name="bottomMargin"><number>6</number></property>
</layout>
</widget>
</widget>
<widget class="QDockWidget" name="outputDock"> <widget class="QDockWidget" name="outputDock">
<property name="minimumSize"> <property name="minimumSize">
<size> <size>
@@ -1036,6 +1004,10 @@
<string>Ctrl+O</string> <string>Ctrl+O</string>
</property> </property>
</action> </action>
<action name="exportRuntimeAction">
<property name="text"><string>导出用户运行程序</string></property>
<property name="toolTip"><string>将当前工程导出为用户可直接运行的专用程序</string></property>
</action>
<action name="undoAction"> <action name="undoAction">
<property name="text"><string>撤销</string></property> <property name="text"><string>撤销</string></property>
<property name="toolTip"><string>撤销当前编辑器的上一步操作</string></property> <property name="toolTip"><string>撤销当前编辑器的上一步操作</string></property>
@@ -1046,6 +1018,16 @@
<property name="toolTip"><string>重做当前编辑器最近撤销的操作</string></property> <property name="toolTip"><string>重做当前编辑器最近撤销的操作</string></property>
<property name="shortcut"><string>Ctrl+Y</string></property> <property name="shortcut"><string>Ctrl+Y</string></property>
</action> </action>
<action name="copyAction">
<property name="text"><string>复制</string></property>
<property name="toolTip"><string>复制当前编辑画布中选中的对象</string></property>
<property name="shortcut"><string>Ctrl+C</string></property>
</action>
<action name="pasteAction">
<property name="text"><string>粘贴</string></property>
<property name="toolTip"><string>粘贴已复制的编辑对象</string></property>
<property name="shortcut"><string>Ctrl+V</string></property>
</action>
<action name="deleteSelectionAction"> <action name="deleteSelectionAction">
<property name="text"><string>删除所选</string></property> <property name="text"><string>删除所选</string></property>
<property name="toolTip"><string>删除当前画布中选中的对象</string></property> <property name="toolTip"><string>删除当前画布中选中的对象</string></property>
@@ -1112,14 +1094,6 @@
<string>添加数值输入控件</string> <string>添加数值输入控件</string>
</property> </property>
</action> </action>
<action name="addProgressBarAction">
<property name="text">
<string>进度条</string>
</property>
<property name="toolTip">
<string>添加进度条控件</string>
</property>
</action>
<action name="addLabelAction"> <action name="addLabelAction">
<property name="text"> <property name="text">
<string>文本</string> <string>文本</string>
@@ -1252,18 +1226,6 @@
<string>在选中的空网格或横线处添加一个扫描周期的下降沿触点</string> <string>在选中的空网格或横线处添加一个扫描周期的下降沿触点</string>
</property> </property>
</action> </action>
<action name="addTimerContactAction">
<property name="text">
<string>T 触点</string>
</property>
<property name="toolTip">
<string>在选中的空网格或横线处添加离线仿真定时器触点</string>
</property>
</action>
<action name="addCounterContactAction">
<property name="text"><string>C 触点</string></property>
<property name="toolTip"><string>在选中的空网格或横线处添加离线仿真计数器完成触点</string></property>
</action>
<action name="addNormalCoilAction"> <action name="addNormalCoilAction">
<property name="text"> <property name="text">
<string>线圈</string> <string>线圈</string>
@@ -1288,22 +1250,6 @@
<string>设置当前网络的复位线圈</string> <string>设置当前网络的复位线圈</string>
</property> </property>
</action> </action>
<action name="addTonAction">
<property name="text">
<string>TON</string>
</property>
<property name="toolTip">
<string>设置非保持接通延时定时器输出</string>
</property>
</action>
<action name="addCtuAction">
<property name="text"><string>CTU</string></property>
<property name="toolTip"><string>设置按上升沿执行的加计数器</string></property>
</action>
<action name="addCtdAction">
<property name="text"><string>CTD</string></property>
<property name="toolTip"><string>设置按上升沿执行的减计数器</string></property>
</action>
<action name="addMoveAction"> <action name="addMoveAction">
<property name="text"><string>MOVE</string></property> <property name="text"><string>MOVE</string></property>
<property name="toolTip"><string>设置 D 数据传送指令</string></property> <property name="toolTip"><string>设置 D 数据传送指令</string></property>


+ 178
- 17
app/src/ui/plc_connection_dialog.cpp 查看文件

@@ -3,9 +3,17 @@
#include "ui_plc_connection_dialog.h" #include "ui_plc_connection_dialog.h"


#include <QComboBox> #include <QComboBox>
#include <QDialogButtonBox>
#include <QMessageBox>
#include <QProgressBar>
#include <QPushButton>
#include <QSerialPortInfo> #include <QSerialPortInfo>
#include <QStyle>
#include <QTimer>
#include <QToolButton>


#include <initializer_list> #include <initializer_list>
#include <utility>


namespace { namespace {


@@ -32,9 +40,13 @@ void selectData(QComboBox *combo_box, int value)


PlcConnectionDialog::PlcConnectionDialog( PlcConnectionDialog::PlcConnectionDialog(
const PlcSerialConfiguration &configuration, const PlcSerialConfiguration &configuration,
PlcDiscoveryGateway &discovery_gateway,
std::function<void()> prepare_discovery,
QWidget *parent) QWidget *parent)
: QDialog(parent), : QDialog(parent),
ui_(std::make_unique<Ui::PlcConnectionDialog>())
ui_(std::make_unique<Ui::PlcConnectionDialog>()),
discovery_gateway_(discovery_gateway),
prepare_discovery_(std::move(prepare_discovery))
{ {
ui_->setupUi(this); ui_->setupUi(this);
setItemData(ui_->baudRateComboBox, {9600, 19200, 38400, 57600, 115200}); setItemData(ui_->baudRateComboBox, {9600, 19200, 38400, 57600, 115200});
@@ -42,22 +54,8 @@ PlcConnectionDialog::PlcConnectionDialog(
setItemData(ui_->parityComboBox, {0, 3, 2}); setItemData(ui_->parityComboBox, {0, 3, 2});
setItemData(ui_->stopBitsComboBox, {1, 2}); setItemData(ui_->stopBitsComboBox, {1, 2});


for (const QSerialPortInfo &port : QSerialPortInfo::availablePorts())
{
ui_->serialPortComboBox->addItem(
port.portName(),
port.description().isEmpty() ? port.portName() : port.description());
}
const QString port_name = QString::fromUtf8(configuration.portName.c_str()); const QString port_name = QString::fromUtf8(configuration.portName.c_str());
if (!port_name.isEmpty())
{
const int port_index = ui_->serialPortComboBox->findText(port_name);
if (port_index < 0)
{
ui_->serialPortComboBox->addItem(port_name);
}
ui_->serialPortComboBox->setCurrentText(port_name);
}
refreshSerialPorts(port_name);


ui_->serverAddressSpinBox->setValue(configuration.serverAddress); ui_->serverAddressSpinBox->setValue(configuration.serverAddress);
selectData(ui_->baudRateComboBox, configuration.baudRate); selectData(ui_->baudRateComboBox, configuration.baudRate);
@@ -67,9 +65,36 @@ PlcConnectionDialog::PlcConnectionDialog(
ui_->responseTimeoutSpinBox->setValue(configuration.responseTimeoutMs); ui_->responseTimeoutSpinBox->setValue(configuration.responseTimeoutMs);
ui_->retriesSpinBox->setValue(configuration.retries); ui_->retriesSpinBox->setValue(configuration.retries);
ui_->pollIntervalSpinBox->setValue(configuration.pollIntervalMs); ui_->pollIntervalSpinBox->setValue(configuration.pollIntervalMs);

ui_->autoSearchButton->setIcon(style()->standardIcon(QStyle::SP_BrowserReload));
ui_->refreshPortsButton->setIcon(style()->standardIcon(QStyle::SP_BrowserReload));
ui_->discoveryProgressBar->setVisible(false);
connect(
ui_->refreshPortsButton, &QToolButton::clicked,
this,
[this]
{
refreshSerialPorts(ui_->serialPortComboBox->currentText().trimmed());
});
connect(
ui_->autoSearchButton, &QPushButton::clicked,
this, &PlcConnectionDialog::toggleAutomaticDiscovery);
discovery_gateway_.setCallbacks(
[this](const PlcDiscoveryProgress &progress)
{
showDiscoveryProgress(progress);
},
[this](const PlcDiscoveryOutcome &outcome)
{
handleDiscoveryFinished(outcome);
});
} }


PlcConnectionDialog::~PlcConnectionDialog() = default;
PlcConnectionDialog::~PlcConnectionDialog()
{
discovery_gateway_.setCallbacks({}, {});
discovery_gateway_.cancelDiscovery();
}


PlcSerialConfiguration PlcConnectionDialog::configuration() const PlcSerialConfiguration PlcConnectionDialog::configuration() const
{ {
@@ -86,3 +111,139 @@ PlcSerialConfiguration PlcConnectionDialog::configuration() const
result.pollIntervalMs = ui_->pollIntervalSpinBox->value(); result.pollIntervalMs = ui_->pollIntervalSpinBox->value();
return result; return result;
} }

void PlcConnectionDialog::refreshSerialPorts(const QString &preferred_port)
{
ui_->serialPortComboBox->clear();
for (const QSerialPortInfo &port : QSerialPortInfo::availablePorts())
{
ui_->serialPortComboBox->addItem(
port.portName(),
port.description().isEmpty() ? port.portName() : port.description());
}
const int preferred_index = ui_->serialPortComboBox->findText(preferred_port);
if (preferred_index >= 0)
{
ui_->serialPortComboBox->setCurrentIndex(preferred_index);
}
else if (ui_->serialPortComboBox->count() > 0)
{
ui_->serialPortComboBox->setCurrentIndex(0);
}
else
{
ui_->serialPortComboBox->clearEditText();
}
}

void PlcConnectionDialog::toggleAutomaticDiscovery()
{
if (discovery_gateway_.isDiscovering())
{
ui_->autoSearchButton->setEnabled(false);
ui_->autoSearchButton->setText(tr("正在停止"));
discovery_gateway_.cancelDiscovery();
return;
}
const PlcCommunicationResult result = discovery_gateway_.startDiscovery(
configuration());
if (!result.succeeded)
{
const QString message = QString::fromUtf8(result.message.c_str());
ui_->discoveryStatusLabel->setText(message);
QMessageBox::information(this, tr("自动搜索 PLC"), message);
return;
}
if (prepare_discovery_)
{
prepare_discovery_();
}
setDiscoveryUiActive(true);
}

void PlcConnectionDialog::showDiscoveryProgress(
const PlcDiscoveryProgress &progress)
{
const PlcSerialConfiguration &candidate = progress.configuration;
const QChar parity = candidate.parity == 2
? QLatin1Char('E')
: candidate.parity == 3 ? QLatin1Char('O') : QLatin1Char('N');
ui_->discoveryProgressBar->setMaximum(
static_cast<int>(progress.totalAttempts));
ui_->discoveryProgressBar->setValue(
static_cast<int>(progress.currentAttempt));
ui_->discoveryStatusLabel->setText(
tr("正在搜索 %1:站号 %2,%3,%4%5%6(%7/%8)")
.arg(QString::fromUtf8(candidate.portName.c_str()))
.arg(candidate.serverAddress)
.arg(candidate.baudRate)
.arg(candidate.dataBits)
.arg(parity)
.arg(candidate.stopBits)
.arg(progress.currentAttempt)
.arg(progress.totalAttempts));
}

void PlcConnectionDialog::handleDiscoveryFinished(
const PlcDiscoveryOutcome &outcome)
{
setDiscoveryUiActive(false);
if (outcome.cancelled)
{
ui_->discoveryStatusLabel->setText(tr("已停止自动搜索"));
return;
}
if (!outcome.found)
{
const QString message = outcome.message.empty()
? tr("未找到可响应的 PLC")
: QString::fromUtf8(outcome.message.c_str());
ui_->discoveryStatusLabel->setText(message);
QMessageBox::information(this, tr("自动搜索 PLC"), message);
return;
}
applyConfiguration(outcome.configuration);
ui_->discoveryStatusLabel->setText(tr("已找到 PLC,正在连接"));
QTimer::singleShot(0, this, &QDialog::accept);
}

void PlcConnectionDialog::setDiscoveryUiActive(bool active)
{
ui_->discoveryProgressBar->setVisible(active);
ui_->serialPortComboBox->setEnabled(!active);
ui_->refreshPortsButton->setEnabled(!active);
ui_->serverAddressSpinBox->setEnabled(!active);
ui_->baudRateComboBox->setEnabled(!active);
ui_->dataBitsComboBox->setEnabled(!active);
ui_->parityComboBox->setEnabled(!active);
ui_->stopBitsComboBox->setEnabled(!active);
ui_->timingGroupBox->setEnabled(!active);
QPushButton *ok_button = ui_->buttonBox->button(QDialogButtonBox::Ok);
if (ok_button != nullptr)
{
ok_button->setEnabled(!active);
}
ui_->autoSearchButton->setEnabled(true);
ui_->autoSearchButton->setText(active ? tr("停止搜索") : tr("自动搜索"));
ui_->autoSearchButton->setIcon(style()->standardIcon(
active ? QStyle::SP_BrowserStop : QStyle::SP_BrowserReload));
}

void PlcConnectionDialog::applyConfiguration(
const PlcSerialConfiguration &configuration)
{
const QString port_name = QString::fromUtf8(configuration.portName.c_str());
if (ui_->serialPortComboBox->findText(port_name) < 0)
{
ui_->serialPortComboBox->addItem(port_name);
}
ui_->serialPortComboBox->setCurrentText(port_name);
ui_->serverAddressSpinBox->setValue(configuration.serverAddress);
selectData(ui_->baudRateComboBox, configuration.baudRate);
selectData(ui_->dataBitsComboBox, configuration.dataBits);
selectData(ui_->parityComboBox, configuration.parity);
selectData(ui_->stopBitsComboBox, configuration.stopBits);
ui_->responseTimeoutSpinBox->setValue(configuration.responseTimeoutMs);
ui_->retriesSpinBox->setValue(configuration.retries);
ui_->pollIntervalSpinBox->setValue(configuration.pollIntervalMs);
}

+ 35
- 0
app/src/ui/plc_connection_dialog.h 查看文件

@@ -1,27 +1,62 @@
/**
* @file plc_connection_dialog.h
* @brief 定义 PLC 串口连接参数对话框
* @version 1.0.0
* @author suyu
* @date 2026-08-22
*/

#pragma once #pragma once


#include "services/plc_communication_gateway.h" #include "services/plc_communication_gateway.h"
#include "services/plc_discovery_gateway.h"


#include <QDialog> #include <QDialog>
#include <QString>


#include <functional>
#include <memory> #include <memory>


namespace Ui { namespace Ui {
class PlcConnectionDialog; class PlcConnectionDialog;
} }


/** PLC 串口参数对话框;只编辑配置,不负责打开串口 */
class PlcConnectionDialog final : public QDialog class PlcConnectionDialog final : public QDialog
{ {
Q_OBJECT Q_OBJECT


public: public:
/** 使用现有配置初始化 PLC 参数表单 */
explicit PlcConnectionDialog( explicit PlcConnectionDialog(
const PlcSerialConfiguration &configuration, const PlcSerialConfiguration &configuration,
PlcDiscoveryGateway &discovery_gateway,
std::function<void()> prepare_discovery,
QWidget *parent = nullptr); QWidget *parent = nullptr);
/** 释放 Designer 界面对象 */
~PlcConnectionDialog() override; ~PlcConnectionDialog() override;


/** 返回表单中的串口配置,连接前由网关再次校验 */
PlcSerialConfiguration configuration() const; PlcSerialConfiguration configuration() const;


private: private:
/** 重新枚举当前实际存在的串口,并尽量保留仍然可用的选择 */
void refreshSerialPorts(const QString &preferred_port);
/** 开始自动搜索或请求停止当前搜索 */
void toggleAutomaticDiscovery();
/** 根据异步探测进度刷新界面 */
void showDiscoveryProgress(const PlcDiscoveryProgress &progress);
/** 处理找到、未找到和用户取消三种结束结果 */
void handleDiscoveryFinished(const PlcDiscoveryOutcome &outcome);
/** 切换搜索期间的表单编辑权限和按钮状态 */
void setDiscoveryUiActive(bool active);
/** 把一组串口配置回填到界面 */
void applyConfiguration(const PlcSerialConfiguration &configuration);

/** Qt Designer 生成的界面对象 */
std::unique_ptr<Ui::PlcConnectionDialog> ui_; std::unique_ptr<Ui::PlcConnectionDialog> ui_;
/** 异步 PLC 自动搜索服务 */
PlcDiscoveryGateway &discovery_gateway_;
/** 搜索前释放正式 PLC 连接占用的串口 */
std::function<void()> prepare_discovery_;
}; };

+ 80
- 40
app/src/ui/plc_connection_dialog.ui 查看文件

@@ -6,54 +6,94 @@
<string>PLC 通信配置</string> <string>PLC 通信配置</string>
</property> </property>
<property name="minimumSize"> <property name="minimumSize">
<size><width>420</width><height>0</height></size>
<size><width>440</width><height>0</height></size>
</property> </property>
<layout class="QVBoxLayout" name="dialogLayout"> <layout class="QVBoxLayout" name="dialogLayout">
<item> <item>
<widget class="QGroupBox" name="serialGroupBox"> <widget class="QGroupBox" name="serialGroupBox">
<property name="title"><string>Modbus RTU 串口</string></property> <property name="title"><string>Modbus RTU 串口</string></property>
<layout class="QFormLayout" name="serialFormLayout">
<property name="fieldGrowthPolicy"><enum>QFormLayout::AllNonFixedFieldsGrow</enum></property>
<item row="0" column="0"><widget class="QLabel" name="serialPortLabel"><property name="text"><string>端口</string></property></widget></item>
<item row="0" column="1">
<widget class="QComboBox" name="serialPortComboBox">
<property name="editable"><bool>true</bool></property>
<property name="placeholderText"><string>选择或输入 COM 端口</string></property>
</widget>
</item>
<item row="1" column="0"><widget class="QLabel" name="serverAddressLabel"><property name="text"><string>PLC 站号</string></property></widget></item>
<item row="1" column="1"><widget class="QSpinBox" name="serverAddressSpinBox"><property name="minimum"><number>1</number></property><property name="maximum"><number>247</number></property></widget></item>
<item row="2" column="0"><widget class="QLabel" name="baudRateLabel"><property name="text"><string>波特率</string></property></widget></item>
<item row="2" column="1">
<widget class="QComboBox" name="baudRateComboBox">
<item><property name="text"><string>9600</string></property><property name="userData"><string notr="true">9600</string></property></item>
<item><property name="text"><string>19200</string></property><property name="userData"><string notr="true">19200</string></property></item>
<item><property name="text"><string>38400</string></property><property name="userData"><string notr="true">38400</string></property></item>
<item><property name="text"><string>57600</string></property><property name="userData"><string notr="true">57600</string></property></item>
<item><property name="text"><string>115200</string></property><property name="userData"><string notr="true">115200</string></property></item>
</widget>
<layout class="QVBoxLayout" name="serialGroupLayout">
<item>
<layout class="QHBoxLayout" name="discoveryControlLayout">
<item>
<widget class="QPushButton" name="autoSearchButton">
<property name="text"><string>自动搜索</string></property>
</widget>
</item>
<item>
<widget class="QProgressBar" name="discoveryProgressBar">
<property name="minimum"><number>0</number></property>
<property name="maximum"><number>100</number></property>
<property name="value"><number>0</number></property>
<property name="textVisible"><bool>false</bool></property>
</widget>
</item>
</layout>
</item> </item>
<item row="3" column="0"><widget class="QLabel" name="dataBitsLabel"><property name="text"><string>数据位</string></property></widget></item>
<item row="3" column="1">
<widget class="QComboBox" name="dataBitsComboBox">
<item><property name="text"><string>7</string></property><property name="userData"><string notr="true">7</string></property></item>
<item><property name="text"><string>8</string></property><property name="userData"><string notr="true">8</string></property></item>
<item>
<widget class="QLabel" name="discoveryStatusLabel">
<property name="text"><string>自动搜索使用当前 PLC 站号</string></property>
<property name="wordWrap"><bool>true</bool></property>
</widget> </widget>
</item> </item>
<item row="4" column="0"><widget class="QLabel" name="parityLabel"><property name="text"><string>校验方式</string></property></widget></item>
<item row="4" column="1">
<widget class="QComboBox" name="parityComboBox">
<item><property name="text"><string>无校验</string></property><property name="userData"><string notr="true">0</string></property></item>
<item><property name="text"><string>奇校验</string></property><property name="userData"><string notr="true">3</string></property></item>
<item><property name="text"><string>偶校验</string></property><property name="userData"><string notr="true">2</string></property></item>
</widget>
</item>
<item row="5" column="0"><widget class="QLabel" name="stopBitsLabel"><property name="text"><string>停止位</string></property></widget></item>
<item row="5" column="1">
<widget class="QComboBox" name="stopBitsComboBox">
<item><property name="text"><string>1</string></property><property name="userData"><string notr="true">1</string></property></item>
<item><property name="text"><string>2</string></property><property name="userData"><string notr="true">2</string></property></item>
</widget>
<item>
<layout class="QFormLayout" name="serialFormLayout">
<property name="fieldGrowthPolicy"><enum>QFormLayout::AllNonFixedFieldsGrow</enum></property>
<item row="0" column="0"><widget class="QLabel" name="serialPortLabel"><property name="text"><string>端口</string></property></widget></item>
<item row="0" column="1">
<layout class="QHBoxLayout" name="serialPortEditorLayout">
<item>
<widget class="QComboBox" name="serialPortComboBox">
<property name="editable"><bool>true</bool></property>
<property name="placeholderText"><string>选择或输入 COM 端口</string></property>
</widget>
</item>
<item>
<widget class="QToolButton" name="refreshPortsButton">
<property name="minimumSize"><size><width>28</width><height>28</height></size></property>
<property name="maximumSize"><size><width>28</width><height>28</height></size></property>
<property name="toolTip"><string>刷新串口</string></property>
<property name="accessibleName"><string>刷新串口</string></property>
<property name="autoRaise"><bool>true</bool></property>
</widget>
</item>
</layout>
</item>
<item row="1" column="0"><widget class="QLabel" name="serverAddressLabel"><property name="text"><string>PLC 站号</string></property></widget></item>
<item row="1" column="1"><widget class="QSpinBox" name="serverAddressSpinBox"><property name="minimum"><number>1</number></property><property name="maximum"><number>247</number></property></widget></item>
<item row="2" column="0"><widget class="QLabel" name="baudRateLabel"><property name="text"><string>波特率</string></property></widget></item>
<item row="2" column="1">
<widget class="QComboBox" name="baudRateComboBox">
<item><property name="text"><string>9600</string></property><property name="userData"><string notr="true">9600</string></property></item>
<item><property name="text"><string>19200</string></property><property name="userData"><string notr="true">19200</string></property></item>
<item><property name="text"><string>38400</string></property><property name="userData"><string notr="true">38400</string></property></item>
<item><property name="text"><string>57600</string></property><property name="userData"><string notr="true">57600</string></property></item>
<item><property name="text"><string>115200</string></property><property name="userData"><string notr="true">115200</string></property></item>
</widget>
</item>
<item row="3" column="0"><widget class="QLabel" name="dataBitsLabel"><property name="text"><string>数据位</string></property></widget></item>
<item row="3" column="1">
<widget class="QComboBox" name="dataBitsComboBox">
<item><property name="text"><string>7</string></property><property name="userData"><string notr="true">7</string></property></item>
<item><property name="text"><string>8</string></property><property name="userData"><string notr="true">8</string></property></item>
</widget>
</item>
<item row="4" column="0"><widget class="QLabel" name="parityLabel"><property name="text"><string>校验方式</string></property></widget></item>
<item row="4" column="1">
<widget class="QComboBox" name="parityComboBox">
<item><property name="text"><string>无校验</string></property><property name="userData"><string notr="true">0</string></property></item>
<item><property name="text"><string>奇校验</string></property><property name="userData"><string notr="true">3</string></property></item>
<item><property name="text"><string>偶校验</string></property><property name="userData"><string notr="true">2</string></property></item>
</widget>
</item>
<item row="5" column="0"><widget class="QLabel" name="stopBitsLabel"><property name="text"><string>停止位</string></property></widget></item>
<item row="5" column="1">
<widget class="QComboBox" name="stopBitsComboBox">
<item><property name="text"><string>1</string></property><property name="userData"><string notr="true">1</string></property></item>
<item><property name="text"><string>2</string></property><property name="userData"><string notr="true">2</string></property></item>
</widget>
</item>
</layout>
</item> </item>
</layout> </layout>
</widget> </widget>


+ 48
- 12
app/src/ui/project_workspace_controller.cpp 查看文件

@@ -11,9 +11,10 @@
#include "ui_main_window.h" #include "ui_main_window.h"


#include <QAction> #include <QAction>
#include <QDialogButtonBox>
#include <QInputDialog> #include <QInputDialog>
#include <QLineEdit>
#include <QObject> #include <QObject>
#include <QPushButton>
#include <QSignalBlocker> #include <QSignalBlocker>
#include <QTabWidget> #include <QTabWidget>
#include <QTreeWidget> #include <QTreeWidget>
@@ -38,6 +39,43 @@ std::string toUtf8(const QString &value)
return std::string(bytes.constData(), static_cast<std::size_t>(bytes.size())); return std::string(bytes.constData(), static_cast<std::size_t>(bytes.size()));
} }


bool requestRequiredName(
QWidget &parent,
const QString &title,
const QString &label,
const QString &initial_value,
QString *name)
{
QInputDialog dialog(&parent);
dialog.setWindowTitle(title);
dialog.setLabelText(label);
dialog.setInputMode(QInputDialog::TextInput);
dialog.setTextValue(initial_value);

QDialogButtonBox *button_box = dialog.findChild<QDialogButtonBox *>();
QPushButton *ok_button = button_box != nullptr
? button_box->button(QDialogButtonBox::Ok)
: nullptr;
if (ok_button != nullptr)
{
ok_button->setEnabled(!initial_value.trimmed().isEmpty());
QObject::connect(
&dialog,
&QInputDialog::textValueChanged,
&dialog,
[ok_button](const QString &text) {
ok_button->setEnabled(!text.trimmed().isEmpty());
});
}

if (dialog.exec() != QDialog::Accepted)
{
return false;
}
*name = dialog.textValue().trimmed();
return true;
}

} // namespace } // namespace


ProjectWorkspaceController::ProjectWorkspaceController( ProjectWorkspaceController::ProjectWorkspaceController(
@@ -295,9 +333,9 @@ void ProjectWorkspaceController::updateActions()


void ProjectWorkspaceController::addPage() void ProjectWorkspaceController::addPage()
{ {
const QString name = QInputDialog::getText(
&parent_, QObject::tr("新建页面"), QObject::tr("页面名称"));
if (name.isEmpty())
QString name;
if (!requestRequiredName(
parent_, QObject::tr("新建页面"), QObject::tr("页面名称"), {}, &name))
{ {
return; return;
} }
@@ -317,9 +355,9 @@ void ProjectWorkspaceController::addPage()


void ProjectWorkspaceController::addLogic() void ProjectWorkspaceController::addLogic()
{ {
const QString name = QInputDialog::getText(
&parent_, QObject::tr("新建控制逻辑"), QObject::tr("逻辑名称"));
if (name.isEmpty())
QString name;
if (!requestRequiredName(
parent_, QObject::tr("新建控制逻辑"), QObject::tr("逻辑名称"), {}, &name))
{ {
return; return;
} }
@@ -362,11 +400,9 @@ void ProjectWorkspaceController::renameSelectedItem()
{ {
return; return;
} }
bool accepted = false;
const QString name = QInputDialog::getText(
&parent_, QObject::tr("重命名"), QObject::tr("新名称"),
QLineEdit::Normal, current_name, &accepted);
if (!accepted)
QString name;
if (!requestRequiredName(
parent_, QObject::tr("重命名"), QObject::tr("新名称"), current_name, &name))
{ {
return; return;
} }


+ 60
- 4
app/src/ui/project_workspace_controller.h 查看文件

@@ -1,3 +1,11 @@
/**
* @file project_workspace_controller.h
* @brief 定义工程树和当前编辑对象控制器
* @version 1.0.0
* @author suyu
* @date 2026-08-22
*/

#pragma once #pragma once


#include "domain/hmi_model.h" #include "domain/hmi_model.h"
@@ -23,16 +31,24 @@ namespace Ui {
class MainWindow; class MainWindow;
} }


// 组织工程树、当前页面/逻辑选择和工程对象的界面刷新
/**
* 组织工程树、当前页面/逻辑选择和工程对象的界面刷新
*
* 它不直接改 Project,所有增删改都转发给对应服务
*/
class ProjectWorkspaceController final class ProjectWorkspaceController final
{ {
public: public:
/** 报告工程操作结果,参数依次为操作名、消息和是否成功 */
using ResultReporter = std::function<void( using ResultReporter = std::function<void(
const QString &action, const QString &message, bool succeeded)>; const QString &action, const QString &message, bool succeeded)>;
/** 向状态栏报告一条带超时的消息 */
using StatusReporter = std::function<void( using StatusReporter = std::function<void(
const QString &message, int timeout_ms)>; const QString &message, int timeout_ms)>;
/** 通知主窗口编辑状态已经发生变化 */
using EditStateChanged = std::function<void()>; using EditStateChanged = std::function<void()>;


/** 创建工程树控制器并绑定主窗口服务和编辑器 */
ProjectWorkspaceController( ProjectWorkspaceController(
QWidget &parent, QWidget &parent,
Ui::MainWindow &ui, Ui::MainWindow &ui,
@@ -52,56 +68,96 @@ public:
StatusReporter status_reporter, StatusReporter status_reporter,
EditStateChanged edit_state_changed); EditStateChanged edit_state_changed);


/** 连接工程树动作和选择变化信号 */
void configure(); void configure();
/** 根据当前工程重新生成工程树和编辑器内容 */
void refresh(); void refresh();
/** 根据当前选中对象更新工程树动作的可用状态 */
void updateActions(); void updateActions();
/** 新增一个 HMI 页面 */
void addPage(); void addPage();
/** 新增一个控制逻辑 */
void addLogic(); void addLogic();
/** 重命名当前选中的工程树项目 */
void renameSelectedItem(); void renameSelectedItem();
/** 删除当前选中的工程树项目 */
void deleteSelectedItem(); void deleteSelectedItem();
/** 按偏移量移动当前选中的工程树项目 */
void moveSelectedItem(int offset); void moveSelectedItem(int offset);
/** 把当前选中的 HMI 页面设为初始页面 */
void setSelectedPageAsInitial(); void setSelectedPageAsInitial();
/** 启用或停用当前选中的控制逻辑 */
void toggleSelectedLogicEnabled(); void toggleSelectedLogicEnabled();


/** 返回当前 HMI 页面标识 */
const std::string &currentHmiPageId() const; const std::string &currentHmiPageId() const;
/** 返回当前控制逻辑标识 */
const std::string &currentLogicId() const; const std::string &currentLogicId() const;


private: private:
/** 工程树项目的三种业务类型 */
enum class ItemKind enum class ItemKind
{ {
Root = 0,
HmiPage = 1,
ControlLogic = 2
Root = 0, // 工程树根节点
HmiPage = 1, // HMI 页面节点
ControlLogic = 2 // 控制逻辑节点
}; };


/** 处理工程树选择变化并刷新当前编辑器 */
void handleSelectionChanged(); void handleSelectionChanged();
/** 统一报告工程操作失败 */
void reportFailure( void reportFailure(
const QString &action, const std::string &message) const; const QString &action, const std::string &message) const;


/** 主窗口父对象,不由控制器拥有 */
QWidget &parent_; QWidget &parent_;
/** 主窗口的 Designer 界面对象 */
Ui::MainWindow &ui_; Ui::MainWindow &ui_;
/** 工程服务,不由控制器拥有 */
ProjectService &project_service_; ProjectService &project_service_;
/** HMI 编辑服务,不由控制器拥有 */
HmiEditorService &hmi_editor_service_; HmiEditorService &hmi_editor_service_;
/** 梯形图编辑服务,不由控制器拥有 */
LogicEditorService &logic_editor_service_; LogicEditorService &logic_editor_service_;
/** 运行模式服务,不由控制器拥有 */
RuntimeModeService &runtime_mode_service_; RuntimeModeService &runtime_mode_service_;
/** HMI 页面导航服务,不由控制器拥有 */
HmiNavigationService &hmi_navigation_service_; HmiNavigationService &hmi_navigation_service_;
/** 主窗口中的 HMI 编辑器 */
HmiEditorWidget &hmi_editor_widget_; HmiEditorWidget &hmi_editor_widget_;
/** 主窗口中的梯形图编辑器 */
LogicEditorWidget &logic_editor_widget_; LogicEditorWidget &logic_editor_widget_;
/** 运行监控控件 */
RuntimeMonitorWidget &runtime_monitor_widget_; RuntimeMonitorWidget &runtime_monitor_widget_;
/** 主窗口保存的当前 HMI 页面标识 */
std::string &current_hmi_page_id_; std::string &current_hmi_page_id_;
/** 主窗口保存的当前控制逻辑标识 */
std::string &current_logic_id_; std::string &current_logic_id_;
/** 显示 HMI 控件属性的回调 */
std::function<void(const std::string &)> show_control_properties_; std::function<void(const std::string &)> show_control_properties_;
/** 显示梯形图节点属性的回调 */
std::function<void(const std::string &)> show_logic_properties_; std::function<void(const std::string &)> show_logic_properties_;
/** 工程操作结果回调 */
ResultReporter result_reporter_; ResultReporter result_reporter_;
/** 状态栏消息回调 */
StatusReporter status_reporter_; StatusReporter status_reporter_;
/** 编辑状态变化回调 */
EditStateChanged edit_state_changed_; EditStateChanged edit_state_changed_;


/** 新增页面动作 */
QAction *add_page_action_ = nullptr; QAction *add_page_action_ = nullptr;
/** 新增逻辑动作 */
QAction *add_logic_action_ = nullptr; QAction *add_logic_action_ = nullptr;
/** 重命名动作 */
QAction *rename_item_action_ = nullptr; QAction *rename_item_action_ = nullptr;
/** 删除工程树项目动作 */
QAction *delete_item_action_ = nullptr; QAction *delete_item_action_ = nullptr;
/** 上移工程树项目动作 */
QAction *move_item_up_action_ = nullptr; QAction *move_item_up_action_ = nullptr;
/** 下移工程树项目动作 */
QAction *move_item_down_action_ = nullptr; QAction *move_item_down_action_ = nullptr;
/** 设置初始页面动作 */
QAction *set_initial_page_action_ = nullptr; QAction *set_initial_page_action_ = nullptr;
/** 启停控制逻辑动作 */
QAction *toggle_logic_enabled_action_ = nullptr; QAction *toggle_logic_enabled_action_ = nullptr;
}; };

+ 21
- 103
app/src/ui/property_panel_controller.cpp 查看文件

@@ -62,6 +62,7 @@ PropertyPanelController::PropertyPanelController(
ProjectService &project_service, ProjectService &project_service,
HmiEditorService &hmi_editor_service, HmiEditorService &hmi_editor_service,
LogicEditorService &logic_editor_service, LogicEditorService &logic_editor_service,
const HmiDefaultSettings &hmi_defaults,
std::function<std::string()> current_page_id, std::function<std::string()> current_page_id,
std::function<std::string()> current_logic_id, std::function<std::string()> current_logic_id,
std::string &selected_control_id, std::string &selected_control_id,
@@ -74,6 +75,7 @@ PropertyPanelController::PropertyPanelController(
project_service_(project_service), project_service_(project_service),
hmi_editor_service_(hmi_editor_service), hmi_editor_service_(hmi_editor_service),
logic_editor_service_(logic_editor_service), logic_editor_service_(logic_editor_service),
hmi_defaults_(hmi_defaults),
current_page_id_(std::move(current_page_id)), current_page_id_(std::move(current_page_id)),
current_logic_id_(std::move(current_logic_id)), current_logic_id_(std::move(current_logic_id)),
selected_control_id_(selected_control_id), selected_control_id_(selected_control_id),
@@ -106,6 +108,10 @@ void PropertyPanelController::configure()
2, static_cast<int>(HmiButtonOperation::Toggle)); 2, static_cast<int>(HmiButtonOperation::Toggle));
ui_.buttonOperationComboBox->setItemData( ui_.buttonOperationComboBox->setItemData(
3, static_cast<int>(HmiButtonOperation::MomentaryOn)); 3, static_cast<int>(HmiButtonOperation::MomentaryOn));
ui_.dataTypeComboBox->setItemData(
0, static_cast<int>(RegisterDataType::Int16));
ui_.dataTypeComboBox->setItemData(
1, static_cast<int>(RegisterDataType::Float32));
for (int index = 0; index < ui_.logicComparisonComboBox->count(); ++index) for (int index = 0; index < ui_.logicComparisonComboBox->count(); ++index)
{ {
ui_.logicComparisonComboBox->setItemData(index, index); ui_.logicComparisonComboBox->setItemData(index, index);
@@ -193,9 +199,7 @@ void PropertyPanelController::showControlProperties(const std::string &control_i
static_cast<QWidget *>(ui_.bindingIndexSpinBox), static_cast<QWidget *>(ui_.bindingIndexSpinBox),
static_cast<QWidget *>(ui_.targetPageComboBox), static_cast<QWidget *>(ui_.targetPageComboBox),
static_cast<QWidget *>(ui_.buttonOperationComboBox), static_cast<QWidget *>(ui_.buttonOperationComboBox),
static_cast<QWidget *>(ui_.progressMinimumSpinBox),
static_cast<QWidget *>(ui_.progressMaximumSpinBox),
static_cast<QWidget *>(ui_.progressShowValueCheckBox),
static_cast<QWidget *>(ui_.dataTypeComboBox),
static_cast<QWidget *>(ui_.textColorEdit), static_cast<QWidget *>(ui_.textColorEdit),
static_cast<QWidget *>(ui_.textColorButton), static_cast<QWidget *>(ui_.textColorButton),
static_cast<QWidget *>(ui_.fontSizeSpinBox), static_cast<QWidget *>(ui_.fontSizeSpinBox),
@@ -218,12 +222,8 @@ void PropertyPanelController::showControlProperties(const std::string &control_i
ui_.targetPageComboBox->setVisible(false); ui_.targetPageComboBox->setVisible(false);
ui_.buttonOperationLabel->setVisible(false); ui_.buttonOperationLabel->setVisible(false);
ui_.buttonOperationComboBox->setVisible(false); ui_.buttonOperationComboBox->setVisible(false);
ui_.progressMinimumLabel->setVisible(false);
ui_.progressMinimumSpinBox->setVisible(false);
ui_.progressMaximumLabel->setVisible(false);
ui_.progressMaximumSpinBox->setVisible(false);
ui_.progressShowValueLabel->setVisible(false);
ui_.progressShowValueCheckBox->setVisible(false);
ui_.dataTypeLabel->setVisible(false);
ui_.dataTypeComboBox->setVisible(false);
ui_.textColorEdit->clear(); ui_.textColorEdit->clear();
ui_.textColorButton->setStyleSheet(QString{}); ui_.textColorButton->setStyleSheet(QString{});
ui_.fontSizeSpinBox->setValue( ui_.fontSizeSpinBox->setValue(
@@ -263,6 +263,14 @@ void PropertyPanelController::showControlProperties(const std::string &control_i
ui_.buttonOperationComboBox->findData( ui_.buttonOperationComboBox->findData(
static_cast<int>(control->buttonOperation))); static_cast<int>(control->buttonOperation)));


const bool is_numeric = control->type == HmiControlType::NumericDisplay
|| control->type == HmiControlType::NumericInput;
ui_.dataTypeLabel->setVisible(is_numeric);
ui_.dataTypeComboBox->setVisible(is_numeric);
ui_.dataTypeComboBox->setEnabled(is_numeric);
ui_.dataTypeComboBox->setCurrentIndex(
ui_.dataTypeComboBox->findData(static_cast<int>(control->dataType)));

const bool is_page_jump = control->type == HmiControlType::PageJump; const bool is_page_jump = control->type == HmiControlType::PageJump;
ui_.targetPageLabel->setVisible(is_page_jump); ui_.targetPageLabel->setVisible(is_page_jump);
ui_.targetPageComboBox->setVisible(is_page_jump); ui_.targetPageComboBox->setVisible(is_page_jump);
@@ -281,22 +289,6 @@ void PropertyPanelController::showControlProperties(const std::string &control_i
ui_.targetPageComboBox->findData(fromUtf8(target_id))); ui_.targetPageComboBox->findData(fromUtf8(target_id)));
} }


const bool is_progress_bar = control->type == HmiControlType::ProgressBar;
ui_.progressMinimumLabel->setVisible(is_progress_bar);
ui_.progressMinimumSpinBox->setVisible(is_progress_bar);
ui_.progressMaximumLabel->setVisible(is_progress_bar);
ui_.progressMaximumSpinBox->setVisible(is_progress_bar);
ui_.progressShowValueLabel->setVisible(is_progress_bar);
ui_.progressShowValueCheckBox->setVisible(is_progress_bar);
ui_.progressMinimumSpinBox->setEnabled(is_progress_bar);
ui_.progressMaximumSpinBox->setEnabled(is_progress_bar);
ui_.progressShowValueCheckBox->setEnabled(is_progress_bar);
const HmiProgressBarConfig progress_config = control->progressBar.value_or(
HmiProgressBarConfig{});
ui_.progressMinimumSpinBox->setValue(progress_config.minimumValue);
ui_.progressMaximumSpinBox->setValue(progress_config.maximumValue);
ui_.progressShowValueCheckBox->setChecked(progress_config.showValue);

const auto text_color = control->properties.find( const auto text_color = control->properties.find(
HmiAppearanceProperty::kTextColor); HmiAppearanceProperty::kTextColor);
ui_.textColorEdit->setText(text_color == control->properties.cend() ui_.textColorEdit->setText(text_color == control->properties.cend()
@@ -356,8 +348,8 @@ void PropertyPanelController::showPageProperties(const std::string &page_id)
ui_.applyPagePropertiesButton->setEnabled(has_page); ui_.applyPagePropertiesButton->setEnabled(has_page);
if (!has_page) if (!has_page)
{ {
ui_.pageWidthSpinBox->setValue(ProjectLimits::kDefaultHmiPageWidth);
ui_.pageHeightSpinBox->setValue(ProjectLimits::kDefaultHmiPageHeight);
ui_.pageWidthSpinBox->setValue(hmi_defaults_.pageWidth);
ui_.pageHeightSpinBox->setValue(hmi_defaults_.pageHeight);
return; return;
} }
ui_.pageWidthSpinBox->setValue(page->width); ui_.pageWidthSpinBox->setValue(page->width);
@@ -378,7 +370,6 @@ void PropertyPanelController::showLogicNodeProperties(const std::string &node_id
static_cast<QWidget *>(ui_.logicModeComboBox), static_cast<QWidget *>(ui_.logicModeComboBox),
static_cast<QWidget *>(ui_.logicComparisonComboBox), static_cast<QWidget *>(ui_.logicComparisonComboBox),
static_cast<QWidget *>(ui_.logicValueSpinBox), static_cast<QWidget *>(ui_.logicValueSpinBox),
static_cast<QWidget *>(ui_.logicPresetSpinBox),
static_cast<QWidget *>(ui_.applyLogicPropertiesButton)}) static_cast<QWidget *>(ui_.applyLogicPropertiesButton)})
{ {
widget->setEnabled(has_node); widget->setEnabled(has_node);
@@ -391,7 +382,6 @@ void PropertyPanelController::showLogicNodeProperties(const std::string &node_id
ui_.logicNodeTypeLabel->clear(); ui_.logicNodeTypeLabel->clear();
ui_.logicAddressAreaLabel->clear(); ui_.logicAddressAreaLabel->clear();
ui_.logicModeComboBox->clear(); ui_.logicModeComboBox->clear();
ui_.logicPresetSpinBox->setValue(TonNodeConfig::kMinimumPresetMs);
return; return;
} }


@@ -399,7 +389,6 @@ void PropertyPanelController::showLogicNodeProperties(const std::string &node_id
ui_.logicModeComboBox->clear(); ui_.logicModeComboBox->clear();
ui_.logicComparisonComboBox->setEnabled(false); ui_.logicComparisonComboBox->setEnabled(false);
ui_.logicValueSpinBox->setEnabled(false); ui_.logicValueSpinBox->setEnabled(false);
ui_.logicPresetSpinBox->setEnabled(false);
if (const auto *contact = std::get_if<ContactNodeConfig>(&node->config)) if (const auto *contact = std::get_if<ContactNodeConfig>(&node->config))
{ {
ui_.logicNodeTypeLabel->setText(QObject::tr("触点")); ui_.logicNodeTypeLabel->setText(QObject::tr("触点"));
@@ -426,33 +415,6 @@ void PropertyPanelController::showLogicNodeProperties(const std::string &node_id
ui_.logicModeComboBox->findData(static_cast<int>(edge->mode))); ui_.logicModeComboBox->findData(static_cast<int>(edge->mode)));
ui_.logicModeComboBox->setEnabled(true); ui_.logicModeComboBox->setEnabled(true);
} }
else if (const auto *timer = std::get_if<TimerContactNodeConfig>(&node->config))
{
ui_.logicNodeTypeLabel->setText(QObject::tr("T 触点"));
ui_.logicAddressAreaLabel->setText(QStringLiteral("T"));
ui_.logicAddressSpinBox->setValue(timer->address.index());
ui_.logicModeComboBox->addItem(
QObject::tr("常开"), static_cast<int>(ContactMode::NormallyOpen));
ui_.logicModeComboBox->addItem(
QObject::tr("常闭"), static_cast<int>(ContactMode::NormallyClosed));
ui_.logicModeComboBox->setCurrentIndex(
ui_.logicModeComboBox->findData(static_cast<int>(timer->mode)));
ui_.logicModeComboBox->setEnabled(true);
}
else if (const auto *counter_contact =
std::get_if<CounterContactNodeConfig>(&node->config))
{
ui_.logicNodeTypeLabel->setText(QObject::tr("C 触点"));
ui_.logicAddressAreaLabel->setText(QStringLiteral("C"));
ui_.logicAddressSpinBox->setValue(counter_contact->address.index());
ui_.logicModeComboBox->addItem(
QObject::tr("常开"), static_cast<int>(ContactMode::NormallyOpen));
ui_.logicModeComboBox->addItem(
QObject::tr("常闭"), static_cast<int>(ContactMode::NormallyClosed));
ui_.logicModeComboBox->setCurrentIndex(ui_.logicModeComboBox->findData(
static_cast<int>(counter_contact->mode)));
ui_.logicModeComboBox->setEnabled(true);
}
else if (const auto *coil = std::get_if<CoilNodeConfig>(&node->config)) else if (const auto *coil = std::get_if<CoilNodeConfig>(&node->config))
{ {
ui_.logicNodeTypeLabel->setText(QObject::tr("线圈")); ui_.logicNodeTypeLabel->setText(QObject::tr("线圈"));
@@ -482,27 +444,6 @@ void PropertyPanelController::showLogicNodeProperties(const std::string &node_id
ui_.logicValueSpinBox->setEnabled(true); ui_.logicValueSpinBox->setEnabled(true);
ui_.logicValueSpinBox->setValue(compare->value); ui_.logicValueSpinBox->setValue(compare->value);
} }
else if (const auto *ton = std::get_if<TonNodeConfig>(&node->config))
{
ui_.logicNodeTypeLabel->setText(QObject::tr("TON"));
ui_.logicAddressAreaLabel->setText(QStringLiteral("T"));
ui_.logicAddressSpinBox->setValue(ton->address.index());
ui_.logicModeComboBox->addItem(QObject::tr("非保持接通延时"));
ui_.logicModeComboBox->setEnabled(false);
ui_.logicPresetSpinBox->setEnabled(true);
ui_.logicPresetSpinBox->setValue(ton->presetMs);
}
else if (const auto *counter = std::get_if<CounterNodeConfig>(&node->config))
{
ui_.logicNodeTypeLabel->setText(
counter->mode == CounterMode::Up ? QStringLiteral("CTU")
: QStringLiteral("CTD"));
ui_.logicAddressAreaLabel->setText(QStringLiteral("C"));
ui_.logicAddressSpinBox->setValue(counter->address.index());
ui_.logicAddressSpinBox->setEnabled(false);
ui_.logicModeComboBox->addItem(QObject::tr("使用配置对话框编辑"));
ui_.logicModeComboBox->setEnabled(false);
}
else if (const auto *move = std::get_if<MoveNodeConfig>(&node->config)) else if (const auto *move = std::get_if<MoveNodeConfig>(&node->config))
{ {
ui_.logicNodeTypeLabel->setText(QStringLiteral("MOVE")); ui_.logicNodeTypeLabel->setText(QStringLiteral("MOVE"));
@@ -591,6 +532,8 @@ void PropertyPanelController::applySelectedControlProperties()
control.bounds.y = ui_.controlYSpinBox->value(); control.bounds.y = ui_.controlYSpinBox->value();
control.bounds.width = ui_.controlWidthSpinBox->value(); control.bounds.width = ui_.controlWidthSpinBox->value();
control.bounds.height = ui_.controlHeightSpinBox->value(); control.bounds.height = ui_.controlHeightSpinBox->value();
control.dataType = static_cast<RegisterDataType>(
ui_.dataTypeComboBox->currentData().toInt());
const HmiControlDescriptor *descriptor = const HmiControlDescriptor *descriptor =
findHmiControlDescriptor(control.type); findHmiControlDescriptor(control.type);
if (descriptor == nullptr if (descriptor == nullptr
@@ -623,13 +566,6 @@ void PropertyPanelController::applySelectedControlProperties()
control.pageJump = HmiPageJumpConfig{ control.pageJump = HmiPageJumpConfig{
toUtf8(ui_.targetPageComboBox->currentData().toString())}; toUtf8(ui_.targetPageComboBox->currentData().toString())};
} }
if (control.type == HmiControlType::ProgressBar)
{
control.progressBar = HmiProgressBarConfig{
static_cast<std::int16_t>(ui_.progressMinimumSpinBox->value()),
static_cast<std::int16_t>(ui_.progressMaximumSpinBox->value()),
ui_.progressShowValueCheckBox->isChecked()};
}
const std::string text_color = toUtf8(ui_.textColorEdit->text()); const std::string text_color = toUtf8(ui_.textColorEdit->text());
if (text_color.empty()) if (text_color.empty())
{ {
@@ -726,18 +662,6 @@ void PropertyPanelController::applySelectedLogicNodeProperties()
RegisterAddress{RegisterArea::M, ui_.logicAddressSpinBox->value()}, RegisterAddress{RegisterArea::M, ui_.logicAddressSpinBox->value()},
static_cast<EdgeMode>(ui_.logicModeComboBox->currentData().toInt())}; static_cast<EdgeMode>(ui_.logicModeComboBox->currentData().toInt())};
} }
else if (std::holds_alternative<TimerContactNodeConfig>(config))
{
config = TimerContactNodeConfig{
TimerAddress{ui_.logicAddressSpinBox->value()},
static_cast<ContactMode>(ui_.logicModeComboBox->currentData().toInt())};
}
else if (std::holds_alternative<CounterContactNodeConfig>(config))
{
config = CounterContactNodeConfig{
CounterAddress{ui_.logicAddressSpinBox->value()},
static_cast<ContactMode>(ui_.logicModeComboBox->currentData().toInt())};
}
else if (std::holds_alternative<CoilNodeConfig>(config)) else if (std::holds_alternative<CoilNodeConfig>(config))
{ {
config = CoilNodeConfig{ config = CoilNodeConfig{
@@ -752,12 +676,6 @@ void PropertyPanelController::applySelectedLogicNodeProperties()
ui_.logicComparisonComboBox->currentData().toInt()), ui_.logicComparisonComboBox->currentData().toInt()),
static_cast<std::int16_t>(ui_.logicValueSpinBox->value())}; static_cast<std::int16_t>(ui_.logicValueSpinBox->value())};
} }
else if (std::holds_alternative<TonNodeConfig>(config))
{
config = TonNodeConfig{
TimerAddress{ui_.logicAddressSpinBox->value()},
ui_.logicPresetSpinBox->value()};
}
else else
{ {
LogicInstructionDialog dialog(node->config, &parent_); LogicInstructionDialog dialog(node->config, &parent_);


+ 47
- 1
app/src/ui/property_panel_controller.h 查看文件

@@ -1,3 +1,11 @@
/**
* @file property_panel_controller.h
* @brief 定义 HMI 和梯形图属性面板控制器
* @version 1.0.0
* @author suyu
* @date 2026-08-22
*/

#pragma once #pragma once


#include "domain/control_logic_model.h" #include "domain/control_logic_model.h"
@@ -19,21 +27,29 @@ namespace Ui {
class MainWindow; class MainWindow;
} }


// 组织 HMI/梯形图选择状态、属性面板和编辑器信号
/**
* 组织 HMI/梯形图选择状态、属性面板和编辑器信号
*
* 属性提交统一经过编辑服务,以便校验、原子回滚和撤销/重做
*/
class PropertyPanelController final class PropertyPanelController final
{ {
public: public:
/** 报告属性操作结果,参数依次为操作名、消息和是否成功 */
using ResultReporter = std::function<void( using ResultReporter = std::function<void(
const QString &action, const QString &message, bool succeeded)>; const QString &action, const QString &message, bool succeeded)>;
/** 向状态栏报告一条带超时的消息 */
using StatusReporter = std::function<void( using StatusReporter = std::function<void(
const QString &message, int timeout_ms)>; const QString &message, int timeout_ms)>;


/** 创建属性面板控制器并注入编辑服务和选择状态 */
PropertyPanelController( PropertyPanelController(
QWidget &parent, QWidget &parent,
Ui::MainWindow &ui, Ui::MainWindow &ui,
ProjectService &project_service, ProjectService &project_service,
HmiEditorService &hmi_editor_service, HmiEditorService &hmi_editor_service,
LogicEditorService &logic_editor_service, LogicEditorService &logic_editor_service,
const HmiDefaultSettings &hmi_defaults,
std::function<std::string()> current_page_id, std::function<std::string()> current_page_id,
std::function<std::string()> current_logic_id, std::function<std::string()> current_logic_id,
std::string &selected_control_id, std::string &selected_control_id,
@@ -42,39 +58,69 @@ public:
ResultReporter result_reporter, ResultReporter result_reporter,
StatusReporter status_reporter); StatusReporter status_reporter);


/** 连接属性面板按钮、输入框和服务信号 */
void configure(); void configure();
/** 绑定 HMI 与梯形图编辑器,接收它们的选择变化 */
void bindEditorWidgets( void bindEditorWidgets(
HmiEditorWidget &hmi_editor_widget, HmiEditorWidget &hmi_editor_widget,
LogicEditorWidget &logic_editor_widget); LogicEditorWidget &logic_editor_widget);


/** 显示指定 HMI 控件的属性 */
void showControlProperties(const std::string &control_id); void showControlProperties(const std::string &control_id);
/** 显示指定 HMI 页面尺寸属性 */
void showPageProperties(const std::string &page_id); void showPageProperties(const std::string &page_id);
/** 显示指定梯形图节点的属性 */
void showLogicNodeProperties(const std::string &node_id); void showLogicNodeProperties(const std::string &node_id);
/** 在当前页面添加指定类型的 HMI 控件 */
void addHmiControl(HmiControlType type); void addHmiControl(HmiControlType type);
/** 删除当前选中的 HMI 控件 */
void deleteSelectedControl(); void deleteSelectedControl();
/** 提交当前 HMI 控件属性 */
void applySelectedControlProperties(); void applySelectedControlProperties();
/** 提交当前 HMI 页面属性 */
void applySelectedPageProperties(); void applySelectedPageProperties();
/** 提交当前梯形图节点属性 */
void applySelectedLogicNodeProperties(); void applySelectedLogicNodeProperties();


private: private:
/** 显示 HMI 编辑服务返回的错误 */
void handleHmiEditorError(const QString &message) const; void handleHmiEditorError(const QString &message) const;
/** 显示梯形图编辑服务返回的错误 */
void handleLogicEditorError(const QString &message) const; void handleLogicEditorError(const QString &message) const;
/** 打开颜色选择器并写回属性输入框 */
void chooseTextColor(); void chooseTextColor();
/** 统一报告属性操作失败 */
void reportFailure( void reportFailure(
const QString &action, const std::string &message) const; const QString &action, const std::string &message) const;


/** 主窗口父对象,不由控制器拥有 */
QWidget &parent_; QWidget &parent_;
/** 主窗口的 Designer 界面对象 */
Ui::MainWindow &ui_; Ui::MainWindow &ui_;
/** 工程服务,不由控制器拥有 */
ProjectService &project_service_; ProjectService &project_service_;
/** HMI 编辑服务,不由控制器拥有 */
HmiEditorService &hmi_editor_service_; HmiEditorService &hmi_editor_service_;
/** 梯形图编辑服务,不由控制器拥有 */
LogicEditorService &logic_editor_service_; LogicEditorService &logic_editor_service_;
/** 新建页面的启动期只读默认尺寸 */
const HmiDefaultSettings &hmi_defaults_;
/** 获取当前 HMI 页面标识的回调 */
std::function<std::string()> current_page_id_; std::function<std::string()> current_page_id_;
/** 获取当前控制逻辑标识的回调 */
std::function<std::string()> current_logic_id_; std::function<std::string()> current_logic_id_;
/** 主窗口保存的当前控件标识 */
std::string &selected_control_id_; std::string &selected_control_id_;
/** 主窗口保存的当前逻辑节点标识 */
std::string &selected_logic_node_id_; std::string &selected_logic_node_id_;
/** 请求主窗口刷新工程界面的回调 */
std::function<void()> refresh_project_ui_; std::function<void()> refresh_project_ui_;
/** 属性操作结果回调 */
ResultReporter result_reporter_; ResultReporter result_reporter_;
/** 状态栏消息回调 */
StatusReporter status_reporter_; StatusReporter status_reporter_;
/** 绑定后的 HMI 编辑器 */
HmiEditorWidget *hmi_editor_widget_ = nullptr; HmiEditorWidget *hmi_editor_widget_ = nullptr;
/** 绑定后的梯形图编辑器 */
LogicEditorWidget *logic_editor_widget_ = nullptr; LogicEditorWidget *logic_editor_widget_ = nullptr;
}; };

+ 19
- 0
app/src/ui/register_comment_dialog.h 查看文件

@@ -1,3 +1,11 @@
/**
* @file register_comment_dialog.h
* @brief 定义 M/D 寄存器工程注释编辑对话框
* @version 1.0.0
* @author suyu
* @date 2026-08-22
*/

#pragma once #pragma once


#include "domain/register_address.h" #include "domain/register_address.h"
@@ -13,22 +21,33 @@ class RegisterCommentDialog;


class RegisterCommentService; class RegisterCommentService;


/** 工程级 M/D 注释编辑对话框;注释不包含实时寄存器值 */
class RegisterCommentDialog final : public QDialog class RegisterCommentDialog final : public QDialog
{ {
public: public:
/** 创建寄存器注释对话框并绑定注释服务 */
explicit RegisterCommentDialog( explicit RegisterCommentDialog(
RegisterCommentService &service, RegisterCommentService &service,
QWidget *parent = nullptr); QWidget *parent = nullptr);
/** 释放 Designer 界面对象 */
~RegisterCommentDialog() override; ~RegisterCommentDialog() override;


private: private:
/** 重新加载注释列表,并尽量保持原选中地址 */
void reloadComments(const RegisterAddress *selected_address = nullptr); void reloadComments(const RegisterAddress *selected_address = nullptr);
/** 把当前选中地址的注释加载到编辑框 */
void loadSelectedComment(); void loadSelectedComment();
/** 校验并保存当前地址的注释 */
void saveComment(); void saveComment();
/** 删除当前选中地址的注释 */
void removeComment(); void removeComment();
/** 从输入框读取寄存器地址 */
RegisterAddress addressFromInputs() const; RegisterAddress addressFromInputs() const;
/** 返回列表中当前选中的寄存器地址,没有选择时返回空值 */
std::optional<RegisterAddress> selectedAddress() const; std::optional<RegisterAddress> selectedAddress() const;


/** Qt Designer 生成的界面对象 */
std::unique_ptr<Ui::RegisterCommentDialog> ui_; std::unique_ptr<Ui::RegisterCommentDialog> ui_;
/** 注释读写服务,不由对话框拥有 */
RegisterCommentService &service_; RegisterCommentService &service_;
}; };

+ 56
- 3
app/src/ui/runtime_monitor_widget.cpp 查看文件

@@ -11,6 +11,7 @@


#include <QSplitter> #include <QSplitter>
#include <QComboBox> #include <QComboBox>
#include <QPushButton>
#include <QSignalBlocker> #include <QSignalBlocker>
#include <QToolButton> #include <QToolButton>


@@ -66,6 +67,10 @@ RuntimeMonitorWidget::RuntimeMonitorWidget(
register_monitor_service, ui_->monitorContainer); register_monitor_service, ui_->monitorContainer);
free_monitor_widget_->setObjectName(QStringLiteral("freeMonitorWidget")); free_monitor_widget_->setObjectName(QStringLiteral("freeMonitorWidget"));
ui_->monitorContainerLayout->addWidget(free_monitor_widget_); ui_->monitorContainerLayout->addWidget(free_monitor_widget_);
ui_->logicNoticeLabel->setVisible(false);
ui_->runtimeModeComboBox->setVisible(false);
ui_->runtimePlcButton->setVisible(false);
ui_->runtimeDisconnectPlcButton->setVisible(false);
ui_->upperSplitter->setSizes({600, 600}); ui_->upperSplitter->setSizes({600, 600});
ui_->verticalSplitter->setSizes({440, 260}); ui_->verticalSplitter->setSizes({440, 260});
connect(hmi_view_, &HmiEditorWidget::pageNavigationRequested, connect(hmi_view_, &HmiEditorWidget::pageNavigationRequested,
@@ -94,6 +99,22 @@ RuntimeMonitorWidget::RuntimeMonitorWidget(
ui_->runtimeLogicComboBox->itemData(index).toString())); ui_->runtimeLogicComboBox->itemData(index).toString()));
} }
}); });
connect(ui_->runtimeModeComboBox,
QOverload<int>::of(&QComboBox::currentIndexChanged),
this,
[this](int index)
{
if (user_runtime_mode_ && index >= 0)
{
emit modeChangeRequested(index == 0
? static_cast<int>(ApplicationMode::OfflineRunning)
: static_cast<int>(ApplicationMode::OnlineRunning));
}
});
connect(ui_->runtimePlcButton, &QPushButton::clicked,
this, &RuntimeMonitorWidget::plcConfigurationRequested);
connect(ui_->runtimeDisconnectPlcButton, &QPushButton::clicked,
this, &RuntimeMonitorWidget::plcDisconnectRequested);
} }


RuntimeMonitorWidget::~RuntimeMonitorWidget() = default; RuntimeMonitorWidget::~RuntimeMonitorWidget() = default;
@@ -134,7 +155,7 @@ void RuntimeMonitorWidget::setMode(
{ {
const bool offline = mode == ApplicationMode::OfflineRunning; const bool offline = mode == ApplicationMode::OfflineRunning;
const bool online = mode == ApplicationMode::OnlineRunning; const bool online = mode == ApplicationMode::OnlineRunning;
ui_->logicFrame->setVisible(offline);
ui_->logicFrame->setVisible(offline || online);
hmi_view_->setRuntimeActive(offline || online); hmi_view_->setRuntimeActive(offline || online);
if (!offline && !online) if (!offline && !online)
{ {
@@ -142,19 +163,50 @@ void RuntimeMonitorWidget::setMode(
} }
setFreeMonitorWriteEnabled(offline setFreeMonitorWriteEnabled(offline
|| (online && plc_state == PlcConnectionState::Connected)); || (online && plc_state == PlcConnectionState::Connected));
if (!offline)
if (!offline && !online)
{ {
logic_view_->clearRuntimeTrace(); logic_view_->clearRuntimeTrace();
latest_trace_.clear(); latest_trace_.clear();
latest_fault_node_id_.clear(); latest_fault_node_id_.clear();
has_logic_trace_ = false; has_logic_trace_ = false;
} }
ui_->logicLabel->setText(
online ? tr("本地推算轨迹") : tr("梯形图运行状态"));
ui_->logicNoticeLabel->setVisible(online);
ui_->modeLabel->setText( ui_->modeLabel->setText(
offline ? tr("离线仿真 · 虚拟 M/D") offline ? tr("离线仿真 · 虚拟 M/D")
: online ? tr("真机运行 · PLC 缓存") : tr("当前未运行"));
: online ? tr("真机运行 · PLC 缓存 · 本地推算轨迹")
: tr("当前未运行"));
if (user_runtime_mode_)
{
const QSignalBlocker blocker(ui_->runtimeModeComboBox);
ui_->runtimeModeComboBox->setCurrentIndex(
offline ? 0 : online ? 1 : -1);
const bool plc_configurable = plc_state == PlcConnectionState::Disconnected
|| plc_state == PlcConnectionState::Faulted;
ui_->runtimePlcButton->setEnabled(plc_configurable);
ui_->runtimeDisconnectPlcButton->setEnabled(
plc_state != PlcConnectionState::Disconnected);
}
free_monitor_widget_->refreshValues(mode, plc_state); free_monitor_widget_->refreshValues(mode, plc_state);
} }


void RuntimeMonitorWidget::setUserRuntimeMode(bool enabled)
{
user_runtime_mode_ = enabled;
ui_->runtimeModeComboBox->setVisible(enabled);
ui_->runtimePlcButton->setVisible(enabled);
ui_->runtimeDisconnectPlcButton->setVisible(enabled);
ui_->exitRuntimeButton->setToolTip(
enabled ? tr("退出运行程序") : tr("返回编辑态"));
ui_->exitRuntimeButton->setAccessibleName(
enabled ? tr("退出运行程序") : tr("返回编辑态"));
if (enabled)
{
setMode(ApplicationMode::Editing, PlcConnectionState::Disconnected);
}
}

void RuntimeMonitorWidget::setHmiWriteEnabled(bool enabled) void RuntimeMonitorWidget::setHmiWriteEnabled(bool enabled)
{ {
hmi_view_->setRuntimeWriteEnabled(enabled); hmi_view_->setRuntimeWriteEnabled(enabled);
@@ -168,6 +220,7 @@ void RuntimeMonitorWidget::setFreeMonitorWriteEnabled(bool enabled)
void RuntimeMonitorWidget::refreshValues( void RuntimeMonitorWidget::refreshValues(
ApplicationMode mode, PlcConnectionState plc_state) ApplicationMode mode, PlcConnectionState plc_state)
{ {
// HMI、报警和自由监控共享同一个活动寄存器仓库
hmi_view_->refreshRuntimeValues(); hmi_view_->refreshRuntimeValues();
free_monitor_widget_->refreshValues(mode, plc_state); free_monitor_widget_->refreshValues(mode, plc_state);
} }


+ 44
- 0
app/src/ui/runtime_monitor_widget.h 查看文件

@@ -1,3 +1,11 @@
/**
* @file runtime_monitor_widget.h
* @brief 定义工程师运行监控主控件
* @version 1.0.0
* @author suyu
* @date 2026-08-22
*/

#pragma once #pragma once


#include "domain/runtime_state.h" #include "domain/runtime_state.h"
@@ -24,11 +32,13 @@ class LogicEditorWidget;
class RegisterMonitorService; class RegisterMonitorService;
class ProjectService; class ProjectService;


/** 唯一的工程师运行监控投影,组合 HMI、梯形图轨迹和自由监控 */
class RuntimeMonitorWidget final : public QWidget class RuntimeMonitorWidget final : public QWidget
{ {
Q_OBJECT Q_OBJECT


public: public:
/** 创建组合 HMI、梯形图和自由监控的运行投影 */
RuntimeMonitorWidget( RuntimeMonitorWidget(
HmiEditorService &hmi_editor_service, HmiEditorService &hmi_editor_service,
HmiRuntimeService &hmi_runtime_service, HmiRuntimeService &hmi_runtime_service,
@@ -38,34 +48,68 @@ public:
AlarmService &alarm_service, AlarmService &alarm_service,
RegisterMonitorService &register_monitor_service, RegisterMonitorService &register_monitor_service,
QWidget *parent = nullptr); QWidget *parent = nullptr);
/** 释放组合的运行监控子控件 */
~RuntimeMonitorWidget() override; ~RuntimeMonitorWidget() override;


/** 将当前页面和控制逻辑绑定到运行监控界面 */
void setProjectObjects(const std::string &page_id, const std::string &logic_id); void setProjectObjects(const std::string &page_id, const std::string &logic_id);
/** 切换运行监控中显示的 HMI 页面 */
void setRuntimePage(const std::string &page_id); void setRuntimePage(const std::string &page_id);
/** 根据模式决定是否显示本地逻辑轨迹以及是否允许写入 */
void setMode(ApplicationMode mode, PlcConnectionState plc_state); void setMode(ApplicationMode mode, PlcConnectionState plc_state);
/** 切换专用用户运行版的简化操作栏 */
void setUserRuntimeMode(bool enabled);
/** 从寄存器仓库刷新 HMI 和自由监控中的当前值 */
void refreshValues(ApplicationMode mode, PlcConnectionState plc_state); void refreshValues(ApplicationMode mode, PlcConnectionState plc_state);
/** 设置 HMI 控件是否允许写入运行值 */
void setHmiWriteEnabled(bool enabled); void setHmiWriteEnabled(bool enabled);
/** 设置自由监控是否允许写入寄存器 */
void setFreeMonitorWriteEnabled(bool enabled); void setFreeMonitorWriteEnabled(bool enabled);
/** 接收离线轨迹或基于 PLC 缓存计算的真机本地推算轨迹 */
void setLogicTrace(const LogicTraceSnapshot &trace, const std::string &fault_node_id = {}); void setLogicTrace(const LogicTraceSnapshot &trace, const std::string &fault_node_id = {});
/** 返回自由监控子控件,供上层刷新或连接信号 */
FreeMonitorWidget *freeMonitorWidget() const; FreeMonitorWidget *freeMonitorWidget() const;
/** 返回当前选中的控制逻辑标识 */
std::string selectedLogicId() const; std::string selectedLogicId() const;


signals: signals:
/** 页面跳转失败时发出可直接显示的错误信息 */
void navigationFailed(const QString &message); void navigationFailed(const QString &message);
/** 用户请求退出运行态 */
void exitRequested(); void exitRequested();
/** 用户在专用运行版中请求切换模式 */
void modeChangeRequested(int mode);
/** 用户请求打开 PLC 配置窗口 */
void plcConfigurationRequested();
/** 用户请求断开 PLC */
void plcDisconnectRequested();


private: private:
/** 显示指定的运行监控页面 */
void showRuntimePage(const std::string &page_id); void showRuntimePage(const std::string &page_id);
/** 选中指定控制逻辑并刷新梯形图投影 */
void selectRuntimeLogic(const std::string &logic_id); void selectRuntimeLogic(const std::string &logic_id);


/** Qt Designer 生成的界面对象 */
std::unique_ptr<Ui::RuntimeMonitorWidget> ui_; std::unique_ptr<Ui::RuntimeMonitorWidget> ui_;
/** 工程服务,不由控件拥有 */
ProjectService &project_service_; ProjectService &project_service_;
/** HMI 页面导航服务,不由控件拥有 */
HmiNavigationService &hmi_navigation_service_; HmiNavigationService &hmi_navigation_service_;
/** 运行监控中的 HMI 视图 */
HmiEditorWidget *hmi_view_ = nullptr; HmiEditorWidget *hmi_view_ = nullptr;
/** 运行监控中的梯形图视图 */
LogicEditorWidget *logic_view_ = nullptr; LogicEditorWidget *logic_view_ = nullptr;
/** 运行监控中的自由监控控件 */
FreeMonitorWidget *free_monitor_widget_ = nullptr; FreeMonitorWidget *free_monitor_widget_ = nullptr;
/** 当前选中的控制逻辑标识 */
std::string selected_logic_id_; std::string selected_logic_id_;
/** 最近一次收到的本地逻辑执行轨迹 */
LogicTraceSnapshot latest_trace_; LogicTraceSnapshot latest_trace_;
/** 最近一次本地执行故障节点标识 */
std::string latest_fault_node_id_; std::string latest_fault_node_id_;
/** 是否已经收到过可显示的逻辑轨迹 */
bool has_logic_trace_ = false; bool has_logic_trace_ = false;
/** 是否显示专用用户运行版操作栏 */
bool user_runtime_mode_ = false;
}; };

+ 4
- 0
app/src/ui/runtime_monitor_widget.ui 查看文件

@@ -13,6 +13,9 @@
<item><widget class="QLabel" name="titleLabel"><property name="text"><string>运行监控</string></property><property name="styleSheet"><string notr="true">font-weight: 600;</string></property></widget></item> <item><widget class="QLabel" name="titleLabel"><property name="text"><string>运行监控</string></property><property name="styleSheet"><string notr="true">font-weight: 600;</string></property></widget></item>
<item><spacer name="headerSpacer"><property name="orientation"><enum>Qt::Horizontal</enum></property><property name="sizeHint" stdset="0"><size><width>40</width><height>20</height></size></property></spacer></item> <item><spacer name="headerSpacer"><property name="orientation"><enum>Qt::Horizontal</enum></property><property name="sizeHint" stdset="0"><size><width>40</width><height>20</height></size></property></spacer></item>
<item><widget class="QLabel" name="modeLabel"><property name="text"><string>当前未运行</string></property><property name="alignment"><set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set></property></widget></item> <item><widget class="QLabel" name="modeLabel"><property name="text"><string>当前未运行</string></property><property name="alignment"><set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set></property></widget></item>
<item><widget class="QComboBox" name="runtimeModeComboBox"><property name="minimumSize"><size><width>120</width><height>0</height></size></property><item><property name="text"><string>离线仿真</string></property></item><item><property name="text"><string>真机运行</string></property></item></widget></item>
<item><widget class="QPushButton" name="runtimePlcButton"><property name="text"><string>PLC 配置</string></property></widget></item>
<item><widget class="QPushButton" name="runtimeDisconnectPlcButton"><property name="text"><string>断开 PLC</string></property></widget></item>
<item><widget class="QToolButton" name="exitRuntimeButton"><property name="toolTip"><string>返回编辑态</string></property><property name="accessibleName"><string>返回编辑态</string></property><property name="text"><string>返回编辑态</string></property><property name="toolButtonStyle"><enum>Qt::ToolButtonIconOnly</enum></property></widget></item> <item><widget class="QToolButton" name="exitRuntimeButton"><property name="toolTip"><string>返回编辑态</string></property><property name="accessibleName"><string>返回编辑态</string></property><property name="text"><string>返回编辑态</string></property><property name="toolButtonStyle"><enum>Qt::ToolButtonIconOnly</enum></property></widget></item>
</layout> </layout>
</item> </item>
@@ -46,6 +49,7 @@
<item><widget class="QComboBox" name="runtimeLogicComboBox"><property name="minimumSize"><size><width>150</width><height>0</height></size></property></widget></item> <item><widget class="QComboBox" name="runtimeLogicComboBox"><property name="minimumSize"><size><width>150</width><height>0</height></size></property></widget></item>
</layout> </layout>
</item> </item>
<item><widget class="QLabel" name="logicNoticeLabel"><property name="styleSheet"><string notr="true">color: #8a5a00;</string></property><property name="text"><string>真机模式:本地梯形图只读推算&#10;仅使用 PLC 缓存显示轨迹,本地输出不写入 PLC 程序或 M/D,不影响 PLC 内部逻辑;HMI 和自由监控仍可写 PLC。</string></property><property name="wordWrap"><bool>true</bool></property></widget></item>
<item><widget class="QWidget" name="logicViewContainer" native="true"><layout class="QVBoxLayout" name="logicViewLayout"><property name="leftMargin"><number>0</number></property><property name="topMargin"><number>0</number></property><property name="rightMargin"><number>0</number></property><property name="bottomMargin"><number>0</number></property></layout></widget></item> <item><widget class="QWidget" name="logicViewContainer" native="true"><layout class="QVBoxLayout" name="logicViewLayout"><property name="leftMargin"><number>0</number></property><property name="topMargin"><number>0</number></property><property name="rightMargin"><number>0</number></property><property name="bottomMargin"><number>0</number></property></layout></widget></item>
</layout> </layout>
</widget> </widget>


+ 4
- 2
app/src/ui/runtime_monitor_window.cpp 查看文件

@@ -6,7 +6,9 @@
#include <QWidget> #include <QWidget>


RuntimeMonitorWindow::RuntimeMonitorWindow(QWidget *parent) RuntimeMonitorWindow::RuntimeMonitorWindow(QWidget *parent)
: QMainWindow(parent, Qt::Window),
: QMainWindow(
parent,
Qt::Window | Qt::CustomizeWindowHint | Qt::WindowTitleHint),
ui_(std::make_unique<Ui::RuntimeMonitorWindow>()) ui_(std::make_unique<Ui::RuntimeMonitorWindow>())
{ {
ui_->setupUi(this); ui_->setupUi(this);
@@ -44,9 +46,9 @@ void RuntimeMonitorWindow::closeForApplicationExit()


void RuntimeMonitorWindow::closeEvent(QCloseEvent *event) void RuntimeMonitorWindow::closeEvent(QCloseEvent *event)
{ {
// 运行期间禁止通过系统关闭路径离开运行界面,只允许应用退出时关闭
if (runtime_active_ && !application_exit_) if (runtime_active_ && !application_exit_)
{ {
emit exitRequested();
event->ignore(); event->ignore();
return; return;
} }


+ 19
- 4
app/src/ui/runtime_monitor_window.h 查看文件

@@ -1,3 +1,11 @@
/**
* @file runtime_monitor_window.h
* @brief 定义独立的工程师运行监控窗口
* @version 1.0.0
* @author suyu
* @date 2026-08-22
*/

#pragma once #pragma once


#include <QMainWindow> #include <QMainWindow>
@@ -11,28 +19,35 @@ class RuntimeMonitorWindow;
class QCloseEvent; class QCloseEvent;
class QWidget; class QWidget;


// 承载唯一工程师运行监控投影的独立顶层窗口
/** 承载唯一工程师运行监控投影的独立顶层窗口 */
class RuntimeMonitorWindow final : public QMainWindow class RuntimeMonitorWindow final : public QMainWindow
{ {
Q_OBJECT Q_OBJECT


public: public:
/** 创建运行监控顶层窗口 */
explicit RuntimeMonitorWindow(QWidget *parent = nullptr); explicit RuntimeMonitorWindow(QWidget *parent = nullptr);
/** 释放窗口和 Designer 界面对象 */
~RuntimeMonitorWindow() override; ~RuntimeMonitorWindow() override;


/** 将运行监控控件放入窗口中央区域 */
void setMonitorWidget(QWidget &widget); void setMonitorWidget(QWidget &widget);
/** 进入运行态时显示并最大化窗口 */
void showForRuntime(); void showForRuntime();
/** 返回编辑态时隐藏窗口但保留唯一监控控件 */
void hideForEditing(); void hideForEditing();
/** 应用退出时允许真正关闭窗口 */
void closeForApplicationExit(); void closeForApplicationExit();


signals:
void exitRequested();

protected: protected:
/** 仅在应用退出时放行窗口关闭事件 */
void closeEvent(QCloseEvent *event) override; void closeEvent(QCloseEvent *event) override;


private: private:
/** Qt Designer 生成的窗口界面对象 */
std::unique_ptr<Ui::RuntimeMonitorWindow> ui_; std::unique_ptr<Ui::RuntimeMonitorWindow> ui_;
/** 当前是否处于运行态显示阶段 */
bool runtime_active_ = false; bool runtime_active_ = false;
/** 应用是否正在退出,决定关闭事件是否真正销毁窗口 */
bool application_exit_ = false; bool application_exit_ = false;
}; };

+ 109
- 15
app/src/ui/runtime_panel_controller.cpp 查看文件

@@ -98,21 +98,13 @@ void RuntimePanelController::configure()
runtime_exit_requester_(); runtime_exit_requester_();
} }
}); });
QObject::connect(runtime_monitor_window_.get(),
&RuntimeMonitorWindow::exitRequested,
&parent_, [this]
{
if (runtime_exit_requester_)
{
runtime_exit_requester_();
}
});
QObject::connect(runtime_monitor_widget_->freeMonitorWidget(), QObject::connect(runtime_monitor_widget_->freeMonitorWidget(),
&FreeMonitorWidget::monitorAddressesChanged, &FreeMonitorWidget::monitorAddressesChanged,
&parent_, [this] &parent_, [this]
{ {
runtime_mode_service_.setMonitorAddresses( runtime_mode_service_.setMonitorAddresses(
register_monitor_service_.addresses());
register_monitor_service_.pollAddresses(),
register_monitor_service_.float32Starts());
runtime_monitor_widget_->refreshValues( runtime_monitor_widget_->refreshValues(
runtime_mode_service_.mode(), runtime_mode_service_.mode(),
runtime_mode_service_.plcConnectionState()); runtime_mode_service_.plcConnectionState());
@@ -129,6 +121,36 @@ void RuntimePanelController::configure()
status_reporter_(message, 5000); status_reporter_(message, 5000);
output_reporter_(QObject::tr("页面导航失败:%1").arg(message)); output_reporter_(QObject::tr("页面导航失败:%1").arg(message));
}); });
QObject::connect(runtime_monitor_widget_,
&RuntimeMonitorWidget::modeChangeRequested,
&parent_,
[this](int mode)
{
if (mode_requester_)
{
mode_requester_(mode);
}
});
QObject::connect(runtime_monitor_widget_,
&RuntimeMonitorWidget::plcConfigurationRequested,
&parent_,
[this]
{
if (plc_configuration_requester_)
{
plc_configuration_requester_();
}
});
QObject::connect(runtime_monitor_widget_,
&RuntimeMonitorWidget::plcDisconnectRequested,
&parent_,
[this]
{
if (plc_disconnect_requester_)
{
plc_disconnect_requester_();
}
});
runtime_refresh_timer_ = new QTimer(&parent_); runtime_refresh_timer_ = new QTimer(&parent_);
runtime_refresh_timer_->setInterval(150); runtime_refresh_timer_->setInterval(150);
QObject::connect(runtime_refresh_timer_, &QTimer::timeout, QObject::connect(runtime_refresh_timer_, &QTimer::timeout,
@@ -142,6 +164,28 @@ void RuntimePanelController::configure()
&OfflineSimulationService::scanCompleted, &OfflineSimulationService::scanCompleted,
&parent_, [this] { handleScanCompleted(); }, &parent_, [this] { handleScanCompleted(); },
Qt::QueuedConnection); Qt::QueuedConnection);
QObject::connect(&runtime_mode_service_.onlineLogicMonitorService(),
&OnlineLogicMonitorService::stateChanged,
&parent_, [this] { handleSimulationStateChanged(); });
QObject::connect(&runtime_mode_service_.onlineLogicMonitorService(),
&OnlineLogicMonitorService::scanCompleted,
&parent_, [this] { handleScanCompleted(); },
Qt::QueuedConnection);
}

void RuntimePanelController::configureUserRuntimeMode(
bool enabled,
std::function<void(int)> mode_requester,
std::function<void()> plc_configuration_requester,
std::function<void()> plc_disconnect_requester)
{
mode_requester_ = std::move(mode_requester);
plc_configuration_requester_ = std::move(plc_configuration_requester);
plc_disconnect_requester_ = std::move(plc_disconnect_requester);
if (runtime_monitor_widget_ != nullptr)
{
runtime_monitor_widget_->setUserRuntimeMode(enabled);
}
} }


RuntimeMonitorWidget *RuntimePanelController::runtimeMonitorWidget() const RuntimeMonitorWidget *RuntimePanelController::runtimeMonitorWidget() const
@@ -158,9 +202,13 @@ void RuntimePanelController::enterRuntime(
if (!runtime_session_active_) if (!runtime_session_active_)
{ {
runtime_monitor_widget_->setProjectObjects(page_id, logic_id); runtime_monitor_widget_->setProjectObjects(page_id, logic_id);
// 运行窗口只创建一次,后续进入运行态复用同一个监控投影
runtime_session_active_ = true; runtime_session_active_ = true;
} }
runtime_monitor_widget_->freeMonitorWidget()->reloadAddresses();
runtime_monitor_widget_->setMode(mode, plc_state); runtime_monitor_widget_->setMode(mode, plc_state);
runtime_monitor_window_->setWindowTitle(
fromUtf8(project_service_.project().metadata.name));
runtime_monitor_window_->showForRuntime(); runtime_monitor_window_->showForRuntime();
} }


@@ -201,6 +249,40 @@ void RuntimePanelController::updateSimulationUi(bool report_fault)
runtime_mode_service_.mode(), runtime_mode_service_.mode(),
runtime_mode_service_.plcConnectionState()); runtime_mode_service_.plcConnectionState());
} }
if (online)
{
switch (runtime_mode_service_.onlineLogicMonitorService().state())
{
case OnlineLogicMonitorState::Running:
{
executor_status_label_.setText(
QObject::tr("本地推算轨迹:运行(%1 次)")
.arg(runtime_mode_service_.onlineLogicMonitorService()
.successfulScanCount()));
return;
}
case OnlineLogicMonitorState::Faulted:
{
executor_status_label_.setText(QObject::tr("本地推算轨迹:故障"));
if (report_fault)
{
const LogicScanResult &error = runtime_mode_service_
.onlineLogicMonitorService().lastError();
const QString message = QObject::tr("真机本地轨迹异常:%1")
.arg(fromUtf8(error.message));
status_reporter_(message, 0);
output_reporter_(message);
}
return;
}
case OnlineLogicMonitorState::Stopped:
default:
{
executor_status_label_.setText(QObject::tr("本地推算轨迹:停止"));
return;
}
}
}
switch (state) switch (state)
{ {
case SimulationState::Running: case SimulationState::Running:
@@ -273,6 +355,7 @@ void RuntimePanelController::updateSimulationUi(bool report_fault)


void RuntimePanelController::handleRuntimeTimer() void RuntimePanelController::handleRuntimeTimer()
{ {
// 定时器只刷新投影;离线按定时器扫描,真机在完整 PLC 轮询后推算
if (runtime_mode_service_.mode() == ApplicationMode::Editing) if (runtime_mode_service_.mode() == ApplicationMode::Editing)
{ {
return; return;
@@ -295,13 +378,24 @@ void RuntimePanelController::handleSimulationStateChanged()


void RuntimePanelController::handleScanCompleted() void RuntimePanelController::handleScanCompleted()
{ {
const ApplicationMode mode = runtime_mode_service_.mode();
const bool offline_running = mode == ApplicationMode::OfflineRunning
&& runtime_mode_service_.simulationState() == SimulationState::Running;
const bool online_running = mode == ApplicationMode::OnlineRunning
&& runtime_mode_service_.onlineLogicMonitorService().state()
== OnlineLogicMonitorState::Running;
// 排队信号可能晚于退出操作到达,编辑态不得恢复旧运行轨迹
if (!offline_running && !online_running)
{
return;
}
const std::string logic_id = current_logic_id_(); const std::string logic_id = current_logic_id_();
logic_editor_widget_.setRuntimeTrace(
runtime_mode_service_.offlineSimulationService()
.traceSnapshot().forLogic(logic_id));
const LogicTraceSnapshot &trace = offline_running
? runtime_mode_service_.offlineSimulationService().traceSnapshot()
: runtime_mode_service_.onlineLogicMonitorService().traceSnapshot();
logic_editor_widget_.setRuntimeTrace(trace.forLogic(logic_id));
if (runtime_monitor_widget_ != nullptr) if (runtime_monitor_widget_ != nullptr)
{ {
runtime_monitor_widget_->setLogicTrace(
runtime_mode_service_.offlineSimulationService().traceSnapshot());
runtime_monitor_widget_->setLogicTrace(trace);
} }
} }

+ 54
- 1
app/src/ui/runtime_panel_controller.h 查看文件

@@ -1,3 +1,11 @@
/**
* @file runtime_panel_controller.h
* @brief 定义运行监控面板控制器
* @version 1.0.0
* @author suyu
* @date 2026-08-22
*/

#pragma once #pragma once


#include "domain/runtime_state.h" #include "domain/runtime_state.h"
@@ -25,15 +33,19 @@ class RuntimeMonitorWindow;
class QTimer; class QTimer;
class QWidget; class QWidget;


// 组织运行监控工作台、运行刷新定时器和离线执行器反馈
/** 组织运行监控窗口、刷新定时器和本地逻辑执行器反馈 */
class RuntimePanelController final class RuntimePanelController final
{ {
public: public:
/** 向状态栏报告一条带超时的消息 */
using StatusReporter = std::function<void( using StatusReporter = std::function<void(
const QString &message, int timeout_ms)>; const QString &message, int timeout_ms)>;
/** 向输出面板报告一条消息 */
using OutputReporter = std::function<void(const QString &message)>; using OutputReporter = std::function<void(const QString &message)>;
/** 请求上层退出当前运行态 */
using RuntimeExitRequester = std::function<void()>; using RuntimeExitRequester = std::function<void()>;


/** 创建运行面板控制器并注入运行模式、编辑器和监控窗口依赖 */
RuntimePanelController( RuntimePanelController(
QWidget &parent, QWidget &parent,
RuntimeModeService &runtime_mode_service, RuntimeModeService &runtime_mode_service,
@@ -53,44 +65,85 @@ public:
StatusReporter status_reporter, StatusReporter status_reporter,
OutputReporter output_reporter, OutputReporter output_reporter,
RuntimeExitRequester runtime_exit_requester); RuntimeExitRequester runtime_exit_requester);
/** 停止刷新并释放控制器拥有的运行监控窗口 */
~RuntimePanelController(); ~RuntimePanelController();


/** 连接运行监控相关信号并完成初始配置 */
void configure(); void configure();
/** 配置专用用户运行版的精简控制栏行为 */
void configureUserRuntimeMode(
bool enabled,
std::function<void(int)> mode_requester,
std::function<void()> plc_configuration_requester,
std::function<void()> plc_disconnect_requester);
/** 创建或复用唯一监控窗口,并把当前工程投影到运行态 */
void enterRuntime( void enterRuntime(
const std::string &page_id, const std::string &page_id,
const std::string &logic_id, const std::string &logic_id,
ApplicationMode mode, ApplicationMode mode,
PlcConnectionState plc_state); PlcConnectionState plc_state);
/** 停止刷新并隐藏监控窗口,离线服务的停止由运行模式服务负责 */
void leaveRuntime(ApplicationMode mode, PlcConnectionState plc_state); void leaveRuntime(ApplicationMode mode, PlcConnectionState plc_state);
/** 应用退出时关闭运行监控窗口并停止相关刷新 */
void closeForApplicationExit(); void closeForApplicationExit();
/** 根据当前模式的本地执行器状态刷新运行界面 */
void updateSimulationUi(bool report_fault); void updateSimulationUi(bool report_fault);
/** 返回当前唯一的运行监控控件 */
RuntimeMonitorWidget *runtimeMonitorWidget() const; RuntimeMonitorWidget *runtimeMonitorWidget() const;


private: private:
/** 处理运行刷新定时器到期事件 */
void handleRuntimeTimer(); void handleRuntimeTimer();
/** 处理离线或真机只读轨迹执行器状态变化 */
void handleSimulationStateChanged(); void handleSimulationStateChanged();
/** 处理一次离线扫描完成事件 */
void handleScanCompleted(); void handleScanCompleted();


/** 主窗口父对象,不由控制器拥有 */
QWidget &parent_; QWidget &parent_;
/** 运行模式服务,不由控制器拥有 */
RuntimeModeService &runtime_mode_service_; RuntimeModeService &runtime_mode_service_;
/** 工程服务,不由控制器拥有 */
ProjectService &project_service_; ProjectService &project_service_;
/** HMI 编辑服务,不由控制器拥有 */
HmiEditorService &hmi_editor_service_; HmiEditorService &hmi_editor_service_;
/** HMI 运行服务,不由控制器拥有 */
HmiRuntimeService &hmi_runtime_service_; HmiRuntimeService &hmi_runtime_service_;
/** 梯形图编辑服务,不由控制器拥有 */
LogicEditorService &logic_editor_service_; LogicEditorService &logic_editor_service_;
/** HMI 页面导航服务,不由控制器拥有 */
HmiNavigationService &hmi_navigation_service_; HmiNavigationService &hmi_navigation_service_;
/** 报警服务,不由控制器拥有 */
AlarmService &alarm_service_; AlarmService &alarm_service_;
/** 寄存器监控服务,不由控制器拥有 */
RegisterMonitorService &register_monitor_service_; RegisterMonitorService &register_monitor_service_;
/** 主窗口中的 HMI 编辑器,不由控制器拥有 */
HmiEditorWidget &hmi_editor_widget_; HmiEditorWidget &hmi_editor_widget_;
/** 主窗口中的梯形图编辑器,不由控制器拥有 */
LogicEditorWidget &logic_editor_widget_; LogicEditorWidget &logic_editor_widget_;
/** 显示当前本地执行器状态的标签 */
QLabel &executor_status_label_; QLabel &executor_status_label_;
/** 获取当前控制逻辑标识的回调 */
std::function<std::string()> current_logic_id_; std::function<std::string()> current_logic_id_;
/** 切换当前控制逻辑的回调 */
std::function<void(const std::string &)> select_logic_; std::function<void(const std::string &)> select_logic_;
/** 显示控制逻辑属性的回调 */
std::function<void(const std::string &)> show_logic_properties_; std::function<void(const std::string &)> show_logic_properties_;
/** 向状态栏报告消息的回调 */
StatusReporter status_reporter_; StatusReporter status_reporter_;
/** 向输出面板报告消息的回调 */
OutputReporter output_reporter_; OutputReporter output_reporter_;
/** 请求主窗口退出运行态的回调 */
RuntimeExitRequester runtime_exit_requester_; RuntimeExitRequester runtime_exit_requester_;
/** 唯一的运行监控顶层窗口 */
std::unique_ptr<RuntimeMonitorWindow> runtime_monitor_window_; std::unique_ptr<RuntimeMonitorWindow> runtime_monitor_window_;
/** 运行监控窗口中的主控件,窗口拥有它 */
RuntimeMonitorWidget *runtime_monitor_widget_ = nullptr; RuntimeMonitorWidget *runtime_monitor_widget_ = nullptr;
/** 定时刷新运行监控数据的计时器 */
QTimer *runtime_refresh_timer_ = nullptr; QTimer *runtime_refresh_timer_ = nullptr;
/** 当前是否已经进入运行监控会话 */
bool runtime_session_active_ = false; bool runtime_session_active_ = false;
std::function<void(int)> mode_requester_;
std::function<void()> plc_configuration_requester_;
std::function<void()> plc_disconnect_requester_;
}; };

+ 0
- 33
app/src/ui/toolbar_icon_factory.cpp 查看文件

@@ -291,14 +291,6 @@ QPixmap renderIcon(UiIcon icon, int size)
painter.drawLine(QPointF(18, 7), QPointF(18, 17)); painter.drawLine(QPointF(18, 7), QPointF(18, 17));
break; break;
} }
case UiIcon::ProgressBar:
{
painter.drawRoundedRect(QRectF(2, 8, 20, 8), 2, 2);
painter.setPen(Qt::NoPen);
painter.setBrush(kAccent);
painter.drawRoundedRect(QRectF(4, 10, 11, 4), 1, 1);
break;
}
case UiIcon::Text: case UiIcon::Text:
{ {
painter.drawLine(QPointF(5, 5), QPointF(19, 5)); painter.drawLine(QPointF(5, 5), QPointF(19, 5));
@@ -410,16 +402,6 @@ QPixmap renderIcon(UiIcon icon, int size)
drawContact(painter, QStringLiteral("N"), false); drawContact(painter, QStringLiteral("N"), false);
break; break;
} }
case UiIcon::TimerContact:
{
drawContact(painter, QStringLiteral("T"), false);
break;
}
case UiIcon::CounterContact:
{
drawContact(painter, QStringLiteral("C"), false);
break;
}
case UiIcon::Coil: case UiIcon::Coil:
{ {
drawCoil(painter, {}); drawCoil(painter, {});
@@ -435,21 +417,6 @@ QPixmap renderIcon(UiIcon icon, int size)
drawCoil(painter, QStringLiteral("R")); drawCoil(painter, QStringLiteral("R"));
break; break;
} }
case UiIcon::Ton:
{
drawInstructionBlock(painter, QStringLiteral("TON"));
break;
}
case UiIcon::Ctu:
{
drawInstructionBlock(painter, QStringLiteral("CTU"));
break;
}
case UiIcon::Ctd:
{
drawInstructionBlock(painter, QStringLiteral("CTD"));
break;
}
case UiIcon::Move: case UiIcon::Move:
{ {
painter.drawRect(QRectF(2, 6, 6, 12)); painter.drawRect(QRectF(2, 6, 6, 12));


+ 57
- 49
app/src/ui/toolbar_icon_factory.h 查看文件

@@ -1,58 +1,66 @@
/**
* @file toolbar_icon_factory.h
* @brief 定义工具栏图标类型和图标创建函数
* @version 1.0.0
* @author suyu
* @date 2026-08-22
*/

#pragma once #pragma once


#include <QIcon> #include <QIcon>


/** 工具栏中使用的语义图标类型 */
enum class UiIcon enum class UiIcon
{ {
Edit,
RunOffline,
RunOnline,
NewDocument,
Save,
SaveAs,
Open,
Undo,
Redo,
Exit,
PlcSettings,
Disconnect,
RegisterComments,
HmiButton,
Indicator,
NumericDisplay,
NumericInput,
ProgressBar,
Text,
PageJump,
AlarmList,
AlarmSettings,
Delete,
AddRung,
HorizontalWire,
VerticalWire,
ParallelBranch,
NormallyOpenContact,
NormallyClosedContact,
RisingEdgeContact,
FallingEdgeContact,
TimerContact,
CounterContact,
Coil,
SetCoil,
ResetCoil,
Ton,
Ctu,
Ctd,
Move,
Add,
Subtract,
Compare,
Comment,
More,
HmiPage,
Logic,
Runtime,
ClearList
Edit, // 编辑模式
RunOffline, // 离线运行
RunOnline, // 真机运行
NewDocument, // 新建工程
Save, // 保存工程
SaveAs, // 另存工程
Open, // 打开工程
Undo, // 撤销
Redo, // 重做
Exit, // 退出程序
PlcSettings, // PLC 连接设置
Disconnect, // 断开 PLC
RegisterComments, // 寄存器注释
HmiButton, // HMI 按钮
Indicator, // HMI 指示灯
NumericDisplay, // HMI 数值显示
NumericInput, // HMI 数值输入
Text, // HMI 文本
PageJump, // HMI 页面跳转
AlarmList, // HMI 报警列表
AlarmSettings, // 报警配置
Delete, // 删除
AddRung, // 新增网络
HorizontalWire, // 横线
VerticalWire, // 竖线
ParallelBranch, // 并联支路
NormallyOpenContact, // 常开触点
NormallyClosedContact, // 常闭触点
RisingEdgeContact, // 上升沿触点
FallingEdgeContact, // 下降沿触点
Coil, // 普通线圈
SetCoil, // 置位线圈
ResetCoil, // 复位线圈
Move, // MOVE 指令
Add, // ADD 指令
Subtract, // SUB 指令
Compare, // 比较指令
Comment, // 注释
More, // 更多操作
HmiPage, // HMI 页面
Logic, // 控制逻辑
Runtime, // 运行监控
ClearList // 清空列表
}; };


/**
* @brief 根据语义图标类型创建 Qt 图标
* @param icon 要创建的图标类型
* @return 创建好的 Qt 图标
*/
QIcon makeUiIcon(UiIcon icon); QIcon makeUiIcon(UiIcon icon);

部分文件因文件數量過多而無法顯示

Loading…
取消
儲存