29 Commit

Autore SHA1 Messaggio Data
  suyu 669bb7ee4b docs: 补充用户运行程序导出说明 3 settimane fa
  suyu 51ca203ad3 feat: 完善用户运行程序导出链路 3 settimane fa
  suyu c5075fd922 docs: document register monitoring workflow 3 settimane fa
  suyu bf1961ea1c feat: add editable register monitoring 3 settimane fa
  suyu 11d00e4d0e docs: document ladder command input 3 settimane fa
  suyu 6d9cd22729 feat: add ladder command input editing 3 settimane fa
  suyu c628724a98 docs: 更新Float32与运行模式说明 3 settimane fa
  suyu 1034198aed feat: 支持D区Float32读写 3 settimane fa
  suyu 19398b2641 feat: 增加真机本地梯形图只读推算 3 settimane fa
  suyu ec7d099e3a fix: 禁止提交空白页面和逻辑名称 3 settimane fa
  suyu 6594ef1183 feat: 添加应用外部配置 3 settimane fa
  suyu d882034c46 chore: 更新项目文档与构建脚本 3 settimane fa
  suyu 839ce495de feat: 添加 PLC 串口自动搜索 3 settimane fa
  suyu 1f094e7324 refactor: 移除 HMI 进度条控件 3 settimane fa
  suyu 1ba67589f5 fix: 清理退出仿真后的旧轨迹 3 settimane fa
  suyu f1af345409 feat: 完善梯形图显式连接模型 3 settimane fa
  suyu 66ce714bf9 feat: 完善编辑复制粘贴与数量边界 3 settimane fa
  suyu 7b220e59be feat: 添加编辑器复制粘贴功能 3 settimane fa
  suyu a1b3863726 docs: 同步梯形图能力边界 3 settimane fa
  suyu 54476bd248 refactor: 移除定时器和计数器指令 3 settimane fa
  suyu 644035ecee docs: 整理项目交付与测试说明 3 settimane fa
  suyu 00b1035942 refactor: 收敛测试体系与运行服务边界 3 settimane fa
  suyu ab5085e42b fix: 限制运行监控窗口系统关闭路径 4 settimane fa
  suyu b852940d7d feat: 完善梯形图编辑并整理寄存器仓库结构 4 settimane fa
  suyu bcd0e1c40b fix: 修复横线撤销后无法再次插入 4 settimane fa
  suyu 340577abe4 fix: 保持并联补线选中态横线可见 4 settimane fa
  suyu e8b9b18b61 docs: 更新架构与交接资料 4 settimane fa
  suyu 88cacc0f57 feat: 完善梯形图网格编辑与运行界面 4 settimane fa
  suyu 875d9ba3c6 feat: 完善运行监控与控制逻辑能力 4 settimane fa
100 ha cambiato i file con 10423 aggiunte e 2497 eliminazioni
  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 Vedi File

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

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

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

+ 13
- 7
AGENTS.md Vedi File

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

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

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

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

## 关键文件索引

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

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



+ 20
- 1
app/integrated_platform.pro Vedi File

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

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

+ 14
- 0
app/src/domain/active_register_repository.cpp Vedi File

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

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

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

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

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

+ 6
- 0
app/src/domain/active_register_repository.h Vedi File

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

#include "register_repository.h"

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

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

private:
RegisterRepository *repository_ = nullptr;


+ 1
- 1
app/src/domain/alarm_model.cpp Vedi File

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


+ 11
- 8
app/src/domain/alarm_model.h Vedi File

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

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

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

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

+ 129
- 318
app/src/domain/control_logic_model.cpp Vedi File

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

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

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

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

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

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

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

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

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

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

} // namespace

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

} // namespace

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

+ 132
- 148
app/src/domain/control_logic_model.h Vedi File

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

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

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

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

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

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

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

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

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

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

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

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

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

explicit TimerAddress(int index = 0);

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

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

private:
int index_ = 0;
};

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

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

explicit CounterAddress(int index = 0);

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

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

private:
int index_ = 0;
};

enum class CounterMode
{
Up,
Down
};

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

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

TimerAddress address;
int presetMs = 1000;
};

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

+ 1
- 10
app/src/domain/hmi_control_registry.cpp Vedi File

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

static_assert(


+ 15
- 15
app/src/domain/hmi_control_registry.h Vedi File

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

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

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

/**


+ 41
- 43
app/src/domain/hmi_model.cpp Vedi File

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

} // namespace

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

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

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

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

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

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

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

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

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


+ 8
- 16
app/src/domain/hmi_model.h Vedi File

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

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

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

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

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

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

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

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

+ 94
- 59
app/src/domain/project_limits.h Vedi File

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

#include <cstddef>

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

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

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

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

static_assert(kMaximumLadderColumns == kMaximumConditionColumns + 1);

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

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

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

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

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

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

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

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

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

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

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

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

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

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

}

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

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

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

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

+ 163
- 17
app/src/domain/project_model.cpp Vedi File

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

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

namespace {

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

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

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

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

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

} // namespace

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

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

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

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

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

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

+ 13
- 2
app/src/domain/project_model.h Vedi File

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

#include <string>
#include <vector>

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

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

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

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

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

+ 10
- 0
app/src/domain/register_address.h Vedi File

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

#include <string>

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

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

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

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

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

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

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

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

+ 52
- 9
app/src/domain/register_monitor_model.cpp Vedi File

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

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

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

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

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

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

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

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

+ 25
- 3
app/src/domain/register_monitor_model.h Vedi File

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

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

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

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

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

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

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

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

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

+ 43
- 33
app/src/domain/register_repository.h Vedi File

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

#include "register_address.h"

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

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

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

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

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

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

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

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

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

+ 51
- 0
app/src/domain/register_value_type.cpp Vedi File

@@ -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 Vedi File

@@ -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 Vedi File

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

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

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

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

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

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

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


app/src/domain/register_repository.cpp → app/src/domain/virtual_register_repository.cpp Vedi File

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

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

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

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

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

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

+ 38
- 0
app/src/domain/virtual_register_repository.h Vedi File

@@ -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 Vedi File

@@ -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 Vedi File

@@ -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 Vedi File

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

if (type == "timerContact" || type == "ton"
|| type == "counterContact" || type == "counter")
{
const char *resource_field =
type == "timerContact" || type == "ton" ? "timer" : "counter";
QJsonObject resource_object;
if (!readObject(object, resource_field, context, &resource_object, state))
{
return false;
}
if (type == "counterContact" || type == "counter")
{
CounterAddress address;
if (!parseCounterAddress(
resource_object, context + ".counter", &address, state))
{
return false;
}
if (type == "counterContact")
{
std::string mode_text;
ContactMode mode = ContactMode::NormallyOpen;
if (!readString(object, "mode", context, &mode_text, state)
|| !parseContactMode(mode_text, &mode, state))
{
return false;
}
*config = CounterContactNodeConfig{address, mode};
return true;
}
std::string mode_text;
CounterMode mode = CounterMode::Up;
if (!readString(object, "mode", context, &mode_text, state))
{
return false;
}
if (mode_text == "up")
{
mode = CounterMode::Up;
}
else if (mode_text == "down")
{
mode = CounterMode::Down;
}
else
{
return state->fail(
ProjectStorageError::InvalidField,
"不支持的计数器方向:" + mode_text);
}
QJsonObject current_object;
QJsonObject preset_object;
QJsonObject reset_object;
RegisterAddress current{RegisterArea::D, 0};
RegisterAddress reset{RegisterArea::M, 0};
WordOperand preset;
if (!readObject(
object, "currentValueAddress", context, &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")
{
QJsonObject source_object;
@@ -1410,6 +1181,14 @@ bool parseNodeConfig(
return true;
}

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

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


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

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

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

} // namespace

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

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

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


+ 7
- 1
app/src/infrastructure/json_project_storage.h Vedi File

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

#include "domain/project_storage.h"

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

private:
const ProjectLimitSettings &project_limits_;
};

+ 3
- 0
app/src/infrastructure/plc_communication_error_classifier.h Vedi File

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

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

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

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

+ 245
- 8
app/src/infrastructure/plc_communication_service.cpp Vedi File

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

namespace {

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

+ 73
- 27
app/src/infrastructure/plc_communication_service.h Vedi File

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

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

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

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

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

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

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

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

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

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

+ 304
- 0
app/src/infrastructure/plc_discovery_service.cpp Vedi File

@@ -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 Vedi File

@@ -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 Vedi File

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

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

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

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

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


+ 22
- 7
app/src/infrastructure/plc_register_repository.h Vedi File

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

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

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

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

+ 127
- 9
app/src/main.cpp Vedi File

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

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

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

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

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

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

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

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

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

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

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

+ 6
- 2
app/src/services/alarm_editor_service.cpp Vedi File

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


+ 8
- 0
app/src/services/alarm_editor_service.h Vedi File

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

class ProjectService;

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

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

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

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

private:


+ 11
- 0
app/src/services/alarm_service.cpp Vedi File

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

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

+ 43
- 7
app/src/services/alarm_service.h Vedi File

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

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

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

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

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

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

+ 30
- 0
app/src/services/application_settings.h Vedi File

@@ -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 Vedi File

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

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

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

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

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

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

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

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

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

+ 148
- 21
app/src/services/hmi_editor_service.cpp Vedi File

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

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

} // namespace

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

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

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

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

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

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

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

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


+ 111
- 19
app/src/services/hmi_editor_service.h Vedi File

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

+ 1
- 0
app/src/services/hmi_navigation_service.cpp Vedi File

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

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


+ 7
- 0
app/src/services/hmi_navigation_service.h Vedi File

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

class ProjectService;

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

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

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

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

private:


+ 52
- 6
app/src/services/hmi_runtime_service.cpp Vedi File

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

#include "domain/hmi_control_registry.h"

#include <cmath>
#include <limits>

namespace {

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

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

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


+ 36
- 12
app/src/services/hmi_runtime_service.h Vedi File

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

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

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

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

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

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

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

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

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

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

+ 588
- 0
app/src/services/logic_command_service.cpp Vedi File

@@ -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 Vedi File

@@ -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
File diff soppresso perché troppo grande
Vedi File


+ 212
- 21
app/src/services/logic_editor_service.h Vedi File

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

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

class ProjectService;

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

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

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

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

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

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

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

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

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

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

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

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

+ 22
- 1
app/src/services/offline_simulation_service.cpp Vedi File

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

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

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

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

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

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

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


+ 44
- 17
app/src/services/offline_simulation_service.h Vedi File

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

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

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

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

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

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

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

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

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

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

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

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

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

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

+ 209
- 0
app/src/services/online_logic_monitor_service.cpp Vedi File

@@ -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 Vedi File

@@ -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 Vedi File

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

+ 126
- 0
app/src/services/plc_discovery_gateway.cpp Vedi File

@@ -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 Vedi File

@@ -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 Vedi File

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

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

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

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

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

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

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

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

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

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


+ 10
- 1
app/src/services/project_service.h Vedi File

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

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

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

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

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

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


+ 6
- 0
app/src/services/register_comment_service.h Vedi File

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

class ProjectService;

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

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

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

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

private:


+ 200
- 21
app/src/services/register_monitor_service.cpp Vedi File

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

#include "domain/virtual_register_repository.h"

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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


+ 94
- 15
app/src/services/register_monitor_service.h Vedi File

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

class VirtualRegisterRepository;

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

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

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

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

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

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

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

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

+ 101
- 8
app/src/services/runtime_mode_service.cpp Vedi File

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

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

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

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

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

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

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

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

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

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

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

void RuntimeModeService::disconnectPlc()


+ 68
- 12
app/src/services/runtime_mode_service.h Vedi File

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

+ 20
- 173
app/src/services/software_logic_executor.cpp Vedi File

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

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

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

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

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

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

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

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

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

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


+ 87
- 68
app/src/services/software_logic_executor.h Vedi File

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

+ 21
- 0
app/src/ui/alarm_configuration_dialog.h Vedi File

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

#pragma once

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

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

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

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

+ 66
- 19
app/src/ui/free_monitor_widget.cpp Vedi File

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

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

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

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

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


+ 25
- 0
app/src/ui/free_monitor_widget.h Vedi File

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

#pragma once

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

class RegisterMonitorService;

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

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

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

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

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

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

+ 7
- 5
app/src/ui/free_monitor_widget.ui Vedi File

@@ -15,13 +15,15 @@
<layout class="QGridLayout" name="commandLayout">
<item row="0" column="0"><widget class="QLabel" name="addressLabel"><property name="text"><string>起始地址</string></property></widget></item>
<item row="0" column="1"><widget class="QLineEdit" name="addressEdit"><property name="minimumSize"><size><width>90</width><height>0</height></size></property><property name="maximumSize"><size><width>160</width><height>16777215</height></size></property><property name="placeholderText"><string>M0 或 D0</string></property><property name="clearButtonEnabled"><bool>true</bool></property></widget></item>
<item row="0" column="2"><widget class="QLabel" name="countLabel"><property name="text"><string>连续个数</string></property></widget></item>
<item row="0" column="3"><widget class="QSpinBox" name="countSpinBox"><property name="minimum"><number>1</number></property><property name="maximum"><number>64</number></property><property name="value"><number>1</number></property></widget></item>
<item row="0" column="4"><widget class="QPushButton" name="addButton"><property name="text"><string>添加</string></property></widget></item>
<item row="0" column="5"><spacer name="firstRowSpacer"><property name="orientation"><enum>Qt::Horizontal</enum></property><property name="sizeHint" stdset="0"><size><width>40</width><height>20</height></size></property></spacer></item>
<item row="0" column="2"><widget class="QLabel" name="dataTypeLabel"><property name="text"><string>类型</string></property></widget></item>
<item row="0" column="3"><widget class="QComboBox" name="dataTypeComboBox"><item><property name="text"><string>Int16</string></property></item><item><property name="text"><string>Float32</string></property></item></widget></item>
<item row="0" column="4"><widget class="QLabel" name="countLabel"><property name="text"><string>连续个数</string></property></widget></item>
<item row="0" column="5"><widget class="QSpinBox" name="countSpinBox"><property name="minimum"><number>1</number></property><property name="maximum"><number>64</number></property><property name="value"><number>1</number></property></widget></item>
<item row="0" column="6"><widget class="QPushButton" name="addButton"><property name="text"><string>添加</string></property></widget></item>
<item row="0" column="7"><spacer name="firstRowSpacer"><property name="orientation"><enum>Qt::Horizontal</enum></property><property name="sizeHint" stdset="0"><size><width>40</width><height>20</height></size></property></spacer></item>
<item row="1" column="0"><widget class="QToolButton" name="removeButton"><property name="toolTip"><string>删除选中的监控地址</string></property><property name="text"><string>删除</string></property></widget></item>
<item row="1" column="1"><widget class="QToolButton" name="clearButton"><property name="toolTip"><string>清空监控表</string></property><property name="text"><string>清空</string></property></widget></item>
<item row="1" column="2" colspan="4"><widget class="QLabel" name="sourceLabel"><property name="text"><string>数据源:未启用</string></property><property name="alignment"><set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set></property></widget></item>
<item row="1" column="2" colspan="6"><widget class="QLabel" name="sourceLabel"><property name="text"><string>数据源:未启用</string></property><property name="alignment"><set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set></property></widget></item>
</layout>
</item>
<item>


+ 44
- 57
app/src/ui/hmi_editor_widget.cpp Vedi File

@@ -194,48 +194,6 @@ public:
textWithValue());
break;
}
case HmiControlType::ProgressBar:
{
const HmiProgressBarConfig config = control_.progressBar.value_or(
HmiProgressBarConfig{});
const int percentage = has_runtime_value_
? config.percentageForValue(word_value_) : 0;

painter->setPen(Qt::NoPen);
painter->setBrush(QColor(QStringLiteral("#e4e9e7")));
painter->drawRoundedRect(rect, 4, 4);

const QRectF track = rect.adjusted(2, 2, -2, -2);
if (has_runtime_value_ && percentage > 0)
{
QRectF fill = track;
fill.setWidth(track.width() * percentage / 100.0);
painter->setBrush(QColor(QStringLiteral("#3c8c62")));
painter->drawRoundedRect(fill, 3, 3);
}

painter->setBrush(Qt::NoBrush);
painter->setPen(QPen(QColor(QStringLiteral("#617069")), 1));
painter->drawRoundedRect(rect, 4, 4);

QString display_text = QString::fromUtf8(
control_.text.data(), static_cast<int>(control_.text.size()));
if (config.showValue)
{
const QString percentage_text = has_runtime_value_
? QString::number(percentage) + QLatin1Char('%')
: runtime_active_ ? QStringLiteral("--")
: QStringLiteral("0%");
display_text = display_text.isEmpty()
? percentage_text
: display_text + QStringLiteral(": ") + percentage_text;
}
painter->setPen(configuredTextColor(QColor(QStringLiteral("#20342a"))));
painter->drawText(rect.adjusted(6, 0, -6, 0),
Qt::AlignCenter,
display_text);
break;
}
case HmiControlType::PageJump:
{
const QColor fill = runtime_active_ && page_hovered_
@@ -459,10 +417,12 @@ public:
}

// 缓存运行服务读出的值并触发 Qt 重绘
void setRuntimeValue(bool bit_value, std::int16_t word_value, bool available)
void setRuntimeValue(
bool bit_value, std::int16_t word_value, float float_value, bool available)
{
bit_value_ = bit_value;
word_value_ = word_value;
float_value_ = float_value;
has_runtime_value_ = available;
update();
}
@@ -837,7 +797,10 @@ private:
if (control_.type == HmiControlType::NumericDisplay
|| control_.type == HmiControlType::NumericInput)
{
return text + QStringLiteral(": ") + QString::number(word_value_);
const QString value = control_.dataType == RegisterDataType::Float32
? QString::number(float_value_, 'g', 7)
: QString::number(word_value_);
return text + QStringLiteral(": ") + value;
}
return text;
}
@@ -867,6 +830,7 @@ private:
bool bit_value_ = false;
// 数值控件读取到的 D 字值
std::int16_t word_value_ = 0;
float float_value_ = 0.0f;
// 标记当前缓存值是否来自一次成功的运行时读取
bool has_runtime_value_ = false;
};
@@ -902,6 +866,7 @@ HmiEditorWidget::HmiEditorWidget(
// 切换显示页面
void HmiEditorWidget::setPageId(const std::string &page_id)
{
// 切换页面只改变投影上下文,真正的页面数据仍由 ProjectService 持有
if (page_id_ == page_id)
{
return;
@@ -928,7 +893,7 @@ void HmiEditorWidget::setRuntimeActive(bool active)
HmiGraphicsItem *control_item = asHmiItem(item);
if (control_item != nullptr)
{
control_item->setRuntimeValue(false, 0, false);
control_item->setRuntimeValue(false, 0, 0.0f, false);
control_item->setAlarmRecords({});
}
}
@@ -945,6 +910,7 @@ void HmiEditorWidget::setRuntimeWriteEnabled(bool enabled)
// 全部重新加载当前页面,把内存模型 HmiPage 渲染成画面上图形
void HmiEditorWidget::reloadPage()
{
// 模型发生变化后完全重建图元,避免增量刷新遗漏属性或选择状态
// 画布始终从当前领域页面重建,避免保留已删除控件的图元
scene_->clear();
const HmiPage *page = editor_service_.findPage(page_id_);
@@ -1052,6 +1018,7 @@ std::vector<std::string> HmiEditorWidget::selectedControlIds() const

void HmiEditorWidget::refreshRuntimeValues()
{
// 运行态才读取寄存器;编辑态画布只显示工程配置,不显示缓存值
if (!runtime_active_)
{
return;
@@ -1076,7 +1043,8 @@ void HmiEditorWidget::refreshRuntimeValues()
}
// 运行值通过服务读取,图元不直接接触寄存器仓库
const HmiRuntimeReadResult value = runtime_service_.readControl(*control);
control_item->setRuntimeValue(value.bit_value, value.word_value, value.succeeded);
control_item->setRuntimeValue(
value.bit_value, value.word_value, value.float_value, value.succeeded);
}
}

@@ -1119,6 +1087,7 @@ void HmiEditorWidget::handleSelectionChanged()
void HmiEditorWidget::handleControlMoved(
const std::string &control_id, const QPointF &position)
{
// 拖动结束后把坐标交给服务层,由服务层负责页面边界校验和历史记录
const HmiControl *control = editor_service_.findControl(page_id_, control_id);
if (control == nullptr)
{
@@ -1146,6 +1115,7 @@ void HmiEditorWidget::handleButtonEvent(
{
return;
}
// 运行态按钮事件只通过 HmiRuntimeService 写寄存器,不直接访问仓库
const HmiControl *control = editor_service_.findControl(page_id_, control_id);
if (control == nullptr)
{
@@ -1177,22 +1147,39 @@ void HmiEditorWidget::handleNumericInputActivated(const std::string &control_id)
}
bool accepted = false;
const HmiRuntimeReadResult current = runtime_service_.readControl(*control);
const int initial = current.succeeded ? current.word_value : 0;
const int value = QInputDialog::getInt(
this,
tr("输入数值"),
QString::fromUtf8(control->text.data(), static_cast<int>(control->text.size())),
initial,
std::numeric_limits<std::int16_t>::min(),
std::numeric_limits<std::int16_t>::max(),
1,
&accepted);
double value = 0.0;
if (control->dataType == RegisterDataType::Float32)
{
value = QInputDialog::getDouble(
this,
tr("输入 Float32"),
QString::fromUtf8(
control->text.data(), static_cast<int>(control->text.size())),
current.succeeded ? static_cast<double>(current.float_value) : 0.0,
-static_cast<double>(std::numeric_limits<float>::max()),
static_cast<double>(std::numeric_limits<float>::max()),
7,
&accepted);
}
else
{
value = QInputDialog::getInt(
this,
tr("输入 Int16"),
QString::fromUtf8(
control->text.data(), static_cast<int>(control->text.size())),
current.succeeded ? current.word_value : 0,
std::numeric_limits<std::int16_t>::min(),
std::numeric_limits<std::int16_t>::max(),
1,
&accepted);
}
if (!accepted)
{
return;
}
const HmiRuntimeWriteResult result = runtime_service_.writeNumericInput(
*control, static_cast<std::int16_t>(value));
*control, value);
if (!result.succeeded)
{
emit editorError(tr("寄存器操作失败"));


+ 13
- 7
app/src/ui/hmi_editor_widget.h Vedi File

@@ -1,6 +1,7 @@
/**
* @file hmi_editor_widget.h
* @brief 定义基于 QGraphicsScene 的 HMI 页面编辑和运行画布
* @version 1.0.0
* @author suyu
* @date 2026-08-10
*/
@@ -56,7 +57,7 @@ public:
* @param active 为 true 时允许按钮和数值输入调用运行时服务
*/
void setRuntimeActive(bool active);
// 故障态保持运行值可见,但禁止按钮和数值输入继续写入
/** 故障态保持运行值可见,但禁止按钮和数值输入继续写入 */
void setRuntimeWriteEnabled(bool enabled);
/**
* @brief 使用当前页面模型完全重建场景
@@ -69,7 +70,9 @@ public:
* @param control_id 待选中控件标识,不存在时不改变当前选择
*/
void selectControl(const std::string &control_id);
/** 返回当前选中的单个控件标识 */
std::string selectedControlId() const;
/** 返回当前选中的多个控件标识 */
std::vector<std::string> selectedControlIds() const;
/**
* @brief 从统一寄存器仓库读取当前值并刷新画布显示
@@ -94,6 +97,7 @@ signals:
* @param message 可直接显示的错误信息
*/
void editorError(const QString &message);
/** 运行态请求跳转到指定 HMI 页面 */
void pageNavigationRequested(const QString &target_page_id);

protected:
@@ -114,18 +118,20 @@ private:
// 在运行态处理数值输入激活
void handleNumericInputActivated(const std::string &control_id);

// 提供控件查找、移动和属性更新能力,不直接操作 Qt 图元数据
/** 提供控件查找、移动和属性更新能力,不直接操作 Qt 图元数据 */
HmiEditorService &editor_service_;
// 提供运行态寄存器读写能力,画布不直接访问寄存器仓库
/** 提供运行态寄存器读写能力,画布不直接访问寄存器仓库 */
HmiRuntimeService &runtime_service_;
/** 报警查询服务,不由控件拥有 */
AlarmService &alarm_service_;
// 持有所有页面图元和页面边框
/** 持有所有页面图元和页面边框 */
QGraphicsScene *scene_ = nullptr;
// 当前画布投影的页面标识
/** 当前画布投影的页面标识 */
std::string page_id_;
// 控制是否允许拖动和编辑控件
/** 控制是否允许拖动和编辑控件 */
bool editing_enabled_ = true;
// 控制是否刷新运行值并响应运行态控件操作
/** 控制是否刷新运行值并响应运行态控件操作 */
bool runtime_active_ = false;
/** 控制运行态按钮和数值输入是否允许写入 */
bool runtime_write_enabled_ = false;
};

+ 1257
- 220
app/src/ui/logic_editor_widget.cpp
File diff soppresso perché troppo grande
Vedi File


+ 124
- 1
app/src/ui/logic_editor_widget.h Vedi File

@@ -1,6 +1,15 @@
/**
* @file logic_editor_widget.h
* @brief 定义结构化梯形图编辑和运行轨迹画布
* @version 1.0.0
* @author suyu
* @date 2026-08-22
*/

#pragma once

#include "domain/control_logic_model.h"
#include "services/logic_command_service.h"
#include "services/logic_editor_service.h"
#include "services/software_logic_executor.h"

@@ -11,73 +20,187 @@
#include <vector>

class QGraphicsScene;
class QCompleter;
class QEvent;
class QLineEdit;
class QMouseEvent;
class QRubberBand;
class QResizeEvent;

/** 将结构化梯形图表达式投影成网格图元;不保存自由线段,编辑通过服务提交 */
class LogicEditorWidget final : public QGraphicsView
{
Q_OBJECT

public:
/** 梯形图中的条件、输出、横线和辅助选择图元类型 */
class NodeItem;
class WireItem;
class WireCellItem;
class GapCellItem;
class VerticalConnectorItem;
class RungItem;
class EmptySlotItem;
class OutputSlotItem;

/** 创建梯形图画布并绑定编辑服务 */
explicit LogicEditorWidget(
LogicEditorService &editor_service,
QWidget *parent = nullptr);

/** 切换当前显示的控制逻辑;不存在时显示空场景 */
void setLogicId(const std::string &logic_id);
/** 设置是否允许编辑梯形图 */
void setEditingEnabled(bool enabled);
/** 设置离线运行轨迹;真机模式应传入空轨迹,避免显示本地伪轨迹 */
void setRuntimeTrace(
const LogicTraceSnapshot &trace,
const std::string &fault_node_id = {});
/** 清除当前运行轨迹和故障节点标记 */
void clearRuntimeTrace();
/** 返回当前是否正在显示运行轨迹 */
bool runtimeTraceEnabled() const;
/** 根据当前逻辑模型重建梯形图场景 */
void reloadLogic();
/** 选中指定节点并滚动到可见区域 */
void selectNode(const std::string &node_id);
/** 返回当前选中的单个节点标识 */
std::string selectedNodeId() const;
/** 返回当前选中的多个节点标识 */
std::vector<std::string> selectedNodeIds() const;
/** 返回当前选中的网络标识 */
std::string selectedRungId() const;

/** 新增一个空的梯形图网络 */
LogicEditorResult addRung();
/** 将当前选择转换为服务调用并新增串联条件 */
LogicEditorResult addCondition(const LogicNodeConfig &config);
/** 将当前选择转换为服务调用并新增并联支路 */
LogicEditorResult addParallelBranch(const LogicNodeConfig &config);
/** 在当前选择位置新增横线 */
LogicEditorResult addHorizontalWire();
/** 在当前选择位置新增竖线 */
LogicEditorResult addVerticalWire();
/** 删除当前选择位置的横线 */
LogicEditorResult deleteHorizontalWire();
/** 删除当前选择位置的竖线 */
LogicEditorResult deleteVerticalWire();
/** 设置当前网络的输出线圈 */
LogicEditorResult setOutput(
const LogicNodeConfig &config, bool configured = false);
/** 按当前画布选择位置粘贴一组连续条件 */
LogicEditorResult pasteConditionNodes(const std::vector<LogicNode> &nodes);
/** 删除当前选中的节点、线段或网络 */
LogicEditorResult deleteSelected();

signals:
/** 当前选中的节点发生变化时发出 */
void nodeSelected(const QString &node_id);
/** 梯形图模型成功发生变化时发出 */
void graphChanged();
/** 编辑服务操作失败时发出可显示的错误信息 */
void editorError(const QString &message);

protected:
/** 在任意网络区域开始鼠标框选 */
void mousePressEvent(QMouseEvent *event) override;
/** 更新鼠标框选区域 */
void mouseMoveEvent(QMouseEvent *event) override;
/** 完成鼠标框选并提交场景选择 */
void mouseReleaseEvent(QMouseEvent *event) override;
/** 在条件网格或输出槽双击时打开命令输入框 */
void mouseDoubleClickEvent(QMouseEvent *event) override;
/** 在窗口大小变化后重新布局梯形图场景 */
void resizeEvent(QResizeEvent *event) override;
/** 滚动画布时同步命令输入框位置 */
void scrollContentsBy(int dx, int dy) override;
/** 处理命令输入框的回车、取消和按键事件 */
bool eventFilter(QObject *watched, QEvent *event) override;

private:
/** 处理图形场景选择变化 */
void handleSelectionChanged();
/** 把编辑服务结果转换为错误信号 */
void reportFailure(const LogicEditorResult &result);
/** 返回当前选中的网络标识 */
std::string currentRungId() const;
/** 选中指定表达式图元 */
void selectExpression(const std::string &expression_id);
/** 选中指定横线图元 */
void selectWire(const std::string &wire_id);
/** 收集当前选中的表达式标识 */
std::vector<std::string> selectedExpressionIds() const;
/** 收集当前选中的横线标识 */
std::vector<std::string> selectedWireIds() const;
/** 收集当前选中的并联支路标识 */
std::vector<std::string> selectedBranchIds() const;
std::vector<int> selectedEmptySlotColumns() const;
/** 收集当前选中的空网格位置 */
std::vector<std::pair<std::string, int>> selectedEmptySlots() const;
/** 收集当前选中的横线网格位置 */
std::vector<std::pair<std::string, int>> selectedWireCells() const;
/** 收集当前选中的显式断路网格位置 */
std::vector<std::pair<std::string, int>> selectedGapCells() const;
/** 判断当前是否选中了网络内的图元 */
bool hasSelectedRungItem() const;
/** 收集当前明确选中的网络图元 */
std::vector<std::string> selectedRungItemIds() const;
/** 打开指定目标位置的命令输入框 */
void beginCommandInput(
const LogicCommandTarget &target,
const QPointF &scene_center);
/** 提交当前命令输入;失败时保留输入框并标红 */
void commitCommandInput();
/** 关闭命令输入框并清理临时状态 */
void cancelCommandInput();
/** 根据网络、列和支路标识查找下一输入位置 */
bool beginNextCommandInput();
/** 按目标定位输入框的屏幕矩形 */
void positionCommandInput(const QPointF &scene_center);
/** 返回当前场景目标的中心位置 */
bool findCommandTargetCenter(
const LogicCommandTarget &target,
QPointF *scene_center) const;
/** 将成功命令选中并刷新属性面板 */
void selectCommandResult(const LogicCommandResult &result);

/** 梯形图编辑服务,不由控件拥有 */
LogicEditorService &editor_service_;
/** 命令语解析和结构化编辑适配服务 */
LogicCommandService command_service_;
/** 承载梯形图图元的场景 */
QGraphicsScene *scene_ = nullptr;
/** 当前显示的控制逻辑标识 */
std::string logic_id_;
/** 当前选中的网络标识 */
std::string current_rung_id_;
/** 最近一次收到的运行轨迹 */
LogicTraceSnapshot trace_;
/** 最近一次运行故障节点标识 */
std::string fault_node_id_;
/** 是否显示运行轨迹 */
bool runtime_trace_enabled_ = false;
/** 是否允许编辑梯形图 */
bool editing_enabled_ = true;
/** 鼠标框选覆盖层 */
QRubberBand *selection_band_ = nullptr;
/** 框选起点(视口坐标) */
QPoint selection_origin_;
/** 框选时是否已超过拖拽阈值 */
bool selection_dragging_ = false;
/** 框选起始时的键盘修饰键 */
Qt::KeyboardModifiers selection_modifiers_ = Qt::NoModifier;
/** 命令输入框,仅在编辑态临时创建 */
QLineEdit *command_editor_ = nullptr;
/** 命令输入补全器 */
QCompleter *command_completer_ = nullptr;
/** 当前命令输入目标 */
LogicCommandTarget command_target_;
/** 是否沿当前命令序列继续输入 */
bool command_continuing_ = false;
/** OR/ORI 只增加支路,不推进主表达式列 */
bool command_keep_column_ = false;
/** 连续输入对应的当前网络 */
std::string command_current_rung_id_;
/** 编辑已有网络 OR 时保留的选择范围 */
std::vector<std::string> command_parallel_node_ids_;
};

+ 2
- 44
app/src/ui/logic_instruction_dialog.cpp Vedi File

@@ -31,14 +31,12 @@ LogicInstructionDialog::LogicInstructionDialog(
original_config_(config)
{
ui_->setupUi(this);
configureOperandCombo(ui_->counterPresetKindComboBox);
configureOperandCombo(ui_->leftOperandKindComboBox);
configureOperandCombo(ui_->rightOperandKindComboBox);

connect(ui_->buttonBox, &QDialogButtonBox::accepted, this, &QDialog::accept);
connect(ui_->buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject);
for (QComboBox *combo : {
ui_->counterPresetKindComboBox,
ui_->leftOperandKindComboBox,
ui_->rightOperandKindComboBox})
{
@@ -49,11 +47,7 @@ LogicInstructionDialog::LogicInstructionDialog(
&LogicInstructionDialog::updateOperandRanges);
}

if (const auto *counter = std::get_if<CounterNodeConfig>(&config))
{
configureCounter(*counter);
}
else if (const auto *move = std::get_if<MoveNodeConfig>(&config))
if (const auto *move = std::get_if<MoveNodeConfig>(&config))
{
configureMove(*move);
}
@@ -68,21 +62,6 @@ LogicInstructionDialog::~LogicInstructionDialog() = default;

LogicNodeConfig LogicInstructionDialog::config() const
{
if (std::holds_alternative<CounterNodeConfig>(original_config_))
{
return CounterNodeConfig{
CounterAddress{ui_->counterAddressSpinBox->value()},
static_cast<CounterMode>(ui_->counterModeComboBox->currentData().toInt()),
RegisterAddress{
RegisterArea::D,
ui_->counterCurrentAddressSpinBox->value()},
operandFrom(
ui_->counterPresetKindComboBox,
ui_->counterPresetValueSpinBox),
RegisterAddress{
RegisterArea::M,
ui_->counterResetAddressSpinBox->value()}};
}
if (std::holds_alternative<MoveNodeConfig>(original_config_))
{
return MoveNodeConfig{
@@ -101,25 +80,6 @@ LogicNodeConfig LogicInstructionDialog::config() const
ui_->destinationAddressSpinBox->value()}};
}

void LogicInstructionDialog::configureCounter(const CounterNodeConfig &config)
{
setWindowTitle(tr("配置计数器"));
ui_->instructionStackedWidget->setCurrentWidget(ui_->counterPage);
ui_->counterModeComboBox->clear();
ui_->counterModeComboBox->addItem(tr("CTU 加计数"), static_cast<int>(CounterMode::Up));
ui_->counterModeComboBox->addItem(
tr("CTD 减计数"), static_cast<int>(CounterMode::Down));
ui_->counterModeComboBox->setCurrentIndex(
ui_->counterModeComboBox->findData(static_cast<int>(config.mode)));
ui_->counterAddressSpinBox->setValue(config.address.index());
ui_->counterCurrentAddressSpinBox->setValue(config.currentValueAddress.index());
ui_->counterResetAddressSpinBox->setValue(config.resetAddress.index());
setOperand(
ui_->counterPresetKindComboBox,
ui_->counterPresetValueSpinBox,
config.preset);
}

void LogicInstructionDialog::configureMove(const MoveNodeConfig &config)
{
setWindowTitle(tr("配置 MOVE"));
@@ -165,13 +125,11 @@ void LogicInstructionDialog::updateOperandRanges()
spin->setPrefix(register_operand ? QStringLiteral("D") : QString{});
spin->setMinimum(register_operand
? RegisterAddress::kMinimumIndex
: (combo->objectName() == QStringLiteral("counterPresetKindComboBox")
? 0 : std::numeric_limits<std::int16_t>::min()));
: std::numeric_limits<std::int16_t>::min());
spin->setMaximum(register_operand
? RegisterAddress::kMaximumIndex
: std::numeric_limits<std::int16_t>::max());
};
update(ui_->counterPresetKindComboBox, ui_->counterPresetValueSpinBox);
update(ui_->leftOperandKindComboBox, ui_->leftOperandValueSpinBox);
update(ui_->rightOperandKindComboBox, ui_->rightOperandValueSpinBox);
}


+ 19
- 1
app/src/ui/logic_instruction_dialog.h Vedi File

@@ -1,3 +1,11 @@
/**
* @file logic_instruction_dialog.h
* @brief 定义梯形图指令参数编辑对话框
* @version 1.0.0
* @author suyu
* @date 2026-08-22
*/

#pragma once

#include "domain/control_logic_model.h"
@@ -13,31 +21,41 @@ namespace Ui {
class LogicInstructionDialog;
}

/** 梯形图节点配置对话框;只负责把表单转换为强类型 LogicNodeConfig */
class LogicInstructionDialog final : public QDialog
{
Q_OBJECT

public:
/** 使用已有节点配置初始化指令参数表单 */
explicit LogicInstructionDialog(
const LogicNodeConfig &config,
QWidget *parent = nullptr);
/** 释放 Designer 界面对象 */
~LogicInstructionDialog() override;

/** 返回当前表单配置,调用方仍需交给 LogicEditorService 校验 */
LogicNodeConfig config() const;

private:
void configureCounter(const CounterNodeConfig &config);
/** 根据传送配置初始化传送输入项 */
void configureMove(const MoveNodeConfig &config);
/** 根据算术配置初始化运算输入项 */
void configureArithmetic(const ArithmeticNodeConfig &config);
/** 根据操作数类型更新数值输入框的合法范围 */
void updateOperandRanges();
/** 将领域操作数写入对应的下拉框和数值框 */
static void setOperand(
QComboBox *kind_combo,
QSpinBox *value_spin,
const WordOperand &operand);
/** 从下拉框和数值框读取领域操作数 */
static WordOperand operandFrom(
const QComboBox *kind_combo,
const QSpinBox *value_spin);

/** Qt Designer 生成的界面对象 */
std::unique_ptr<Ui::LogicInstructionDialog> ui_;
/** 打开对话框时保存的原始节点配置 */
LogicNodeConfig original_config_;
};

+ 0
- 21
app/src/ui/logic_instruction_dialog.ui Vedi File

@@ -9,27 +9,6 @@
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QStackedWidget" name="instructionStackedWidget">
<widget class="QWidget" name="counterPage">
<layout class="QFormLayout" name="counterFormLayout">
<item row="0" column="0"><widget class="QLabel" name="counterModeLabel"><property name="text"><string>计数方式</string></property></widget></item>
<item row="0" column="1"><widget class="QComboBox" name="counterModeComboBox"/></item>
<item row="1" column="0"><widget class="QLabel" name="counterAddressLabel"><property name="text"><string>计数器资源</string></property></widget></item>
<item row="1" column="1"><widget class="QSpinBox" name="counterAddressSpinBox"><property name="prefix"><string>C</string></property><property name="maximum"><number>4000</number></property></widget></item>
<item row="2" column="0"><widget class="QLabel" name="counterCurrentAddressLabel"><property name="text"><string>当前值 CV</string></property></widget></item>
<item row="2" column="1"><widget class="QSpinBox" name="counterCurrentAddressSpinBox"><property name="prefix"><string>D</string></property><property name="maximum"><number>4000</number></property></widget></item>
<item row="3" column="0"><widget class="QLabel" name="counterPresetLabel"><property name="text"><string>预设值 PV</string></property></widget></item>
<item row="3" column="1">
<widget class="QWidget" name="counterPresetEditor" native="true">
<layout class="QHBoxLayout" name="counterPresetLayout"><property name="leftMargin"><number>0</number></property><property name="topMargin"><number>0</number></property><property name="rightMargin"><number>0</number></property><property name="bottomMargin"><number>0</number></property>
<item><widget class="QComboBox" name="counterPresetKindComboBox"/></item>
<item><widget class="QSpinBox" name="counterPresetValueSpinBox"><property name="maximum"><number>32767</number></property><property name="value"><number>10</number></property></widget></item>
</layout>
</widget>
</item>
<item row="4" column="0"><widget class="QLabel" name="counterResetAddressLabel"><property name="text"><string>复位输入</string></property></widget></item>
<item row="4" column="1"><widget class="QSpinBox" name="counterResetAddressSpinBox"><property name="prefix"><string>M</string></property><property name="maximum"><number>4000</number></property></widget></item>
</layout>
</widget>
<widget class="QWidget" name="dataInstructionPage">
<layout class="QFormLayout" name="dataInstructionFormLayout">
<item row="0" column="0"><widget class="QLabel" name="instructionCaptionLabel"><property name="text"><string>指令</string></property></widget></item>


+ 833
- 103
app/src/ui/main_window.cpp
File diff soppresso perché troppo grande
Vedi File


+ 162
- 24
app/src/ui/main_window.h Vedi File

@@ -1,7 +1,7 @@
/**
* @file main_window.h
* @brief 定义综合平台编程器主窗口及运行模式界面协调逻辑
* @version 0.2.0
* @version 1.0.0
* @author suyu
* @date 2026-08-08
*/
@@ -12,10 +12,14 @@
#include "domain/control_logic_model.h"
#include "domain/runtime_state.h"
#include "services/plc_communication_gateway.h"
#include "services/application_settings.h"

#include <QMainWindow>
#include <QProcess>

#include <memory>
#include <variant>
#include <vector>

QT_BEGIN_NAMESPACE
class QAction;
@@ -23,6 +27,9 @@ class QActionGroup;
class QCloseEvent;
class QEvent;
class QLabel;
class QDockWidget;
class QTimer;
class QProgressDialog;

namespace Ui {
class MainWindow;
@@ -41,7 +48,9 @@ class HmiEditorWidget;
class LogicEditorService;
class LogicEditorWidget;
class RegisterMonitorService;
class PlcDiscoveryGateway;
class RuntimeMonitorWidget;
class FreeMonitorWidget;
class ProjectWorkspaceController;
class PropertyPanelController;
class RuntimePanelController;
@@ -51,6 +60,7 @@ class RuntimePanelController;
*
* 主窗口不直接访问寄存器、工程文件或 PLC 通信实现
*/
// 编程器主窗口:负责组装 UI 控制器、窗口动作和模式切换,不承载领域规则
class MainWindow final : public QMainWindow
{
Q_OBJECT
@@ -74,7 +84,15 @@ public:
AlarmService &alarm_service,
RegisterCommentService &register_comment_service,
RegisterMonitorService &register_monitor_service,
PlcDiscoveryGateway &plc_discovery_gateway,
const ApplicationSettingsLoadResult &application_settings_result,
bool user_runtime_mode = false,
QWidget *parent = nullptr);
/**
* @brief 创建主窗口,并使用调用方提供的 HMI 页面导航服务
*
* 两个构造函数的区别只在导航服务的所有权;其余服务均由应用入口管理
*/
MainWindow(
RuntimeModeService &runtime_mode_service,
ProjectService &project_service,
@@ -86,82 +104,125 @@ public:
HmiNavigationService &hmi_navigation_service,
RegisterCommentService &register_comment_service,
RegisterMonitorService &register_monitor_service,
PlcDiscoveryGateway &plc_discovery_gateway,
const ApplicationSettingsLoadResult &application_settings_result,
bool user_runtime_mode = false,
QWidget *parent = nullptr);

/** 释放窗口拥有的控制器和可选导航服务 */
~MainWindow() override;

/** 返回当前选中的 HMI 页面标识 */
const std::string &currentHmiPageId() const;
/** 返回当前选中的控制逻辑标识 */
const std::string &currentLogicId() const;

protected:
/** 处理主窗口及子控件的事件过滤请求 */
bool eventFilter(QObject *watched, QEvent *event) override;
/** 关闭窗口前处理保存确认和运行态退出 */
void closeEvent(QCloseEvent *event) override;

private:
// 配置菜单和工具栏动作
/** 配置菜单和工具栏动作 */
void configureActions();
// 配置主窗口外观和状态栏
/** 配置主窗口外观和状态栏 */
void configureAppearance();
// 创建并连接 HMI 编辑画布
/** 创建并连接 HMI 编辑画布 */
void configureHmiEditor();
// 创建并连接控制逻辑编辑画布
/** 创建并连接控制逻辑编辑画布 */
void configureLogicEditor();
/** 创建并连接运行监控面板 */
void configureRuntimeMonitor();
/** 创建并连接编辑态数据监控面板 */
void configureDataMonitor();
/** 创建并连接工程树 */
void configureProjectTree();
// 创建并连接控件属性编辑表单
/** 创建并连接控件属性编辑表单 */
void configurePropertyEditor();
/** 创建并连接 PLC 串口配置入口 */
void configurePlcConnection();
/** 创建并连接报警配置入口 */
void configureAlarms();
/** 创建并连接寄存器注释入口 */
void configureRegisterComments();
/** 撤销当前处于焦点的编辑器操作 */
void undoActiveEditor();
/** 重做当前处于焦点的编辑器操作 */
void redoActiveEditor();
/** 复制当前编辑画布中的选中对象 */
void copyActiveSelection();
/** 粘贴剪贴板中的对象到当前编辑画布 */
void pasteActiveSelection();
/** 删除当前处于焦点的编辑器选中项 */
void deleteActiveSelection();
/** 清除当前处于焦点的编辑器选择 */
void clearActiveSelection();
/** 根据编辑器选择状态更新撤销、重做和删除动作 */
void updateEditActions();
/** 清空 HMI 和梯形图编辑器的撤销历史 */
void clearEditorHistories();
/** 根据工程路径和修改状态刷新窗口标题 */
void updateWindowTitle();
/** 对可能破坏当前工程的操作请求保存确认 */
bool confirmSaveBeforeDestructiveAction();
// 刷新工程树和当前 HMI 页面信息
/** 刷新工程树和当前 HMI 页面信息 */
void refreshProjectUi();
/** 更新工程树相关动作的可用状态 */
void updateProjectTreeActions();
// 显示指定 HMI 控件的可编辑属性
/** 显示指定 HMI 控件的可编辑属性 */
void showControlProperties(const std::string &control_id);
// 显示指定控制逻辑节点的可编辑属性
/** 显示指定控制逻辑节点的可编辑属性 */
void showLogicNodeProperties(const std::string &node_id);
// 向当前页面添加指定类型的 HMI 控件
/** 向当前页面添加指定类型的 HMI 控件 */
void addHmiControl(HmiControlType type);
// 删除当前选中的 HMI 控件
/** 删除当前选中的 HMI 控件 */
void deleteSelectedControl();
// 在当前光标节点后添加串联条件,没有选择时追加到网络末尾
/** 在当前光标节点后添加串联条件,没有选择时追加到网络末尾 */
void addLogicCondition(const LogicNodeConfig &config);
// 为当前选中的连续逻辑范围建立并联支路
/** 为当前选中的连续逻辑范围建立并联支路 */
void addLogicParallelBranch(const LogicNodeConfig &config);
/** 在当前梯形图位置添加横线 */
void addLogicHorizontalWire();
/** 在当前梯形图位置添加竖线 */
void addLogicVerticalWire();
/** 删除当前梯形图位置的横线 */
void deleteLogicHorizontalWire();
/** 删除当前梯形图位置的竖线 */
void deleteLogicVerticalWire();
// 设置当前网络右侧的输出线圈
/** 设置当前网络右侧的输出线圈 */
void setLogicOutput(const LogicNodeConfig &config);
/** 打开输出指令配置并提交输出线圈 */
void configureAndSetLogicOutput(const LogicNodeConfig &config);
// 新增一个梯形图网络
/** 新增一个梯形图网络 */
void addLogicRung();
/** 编辑当前网络的注释 */
void editSelectedRungComment();
// 删除当前选中的逻辑节点或网络
/** 删除当前选中的逻辑节点或网络 */
void deleteSelectedLogicObject();
/** 打开 PLC 连接 */
void connectPlc();
/** 关闭 PLC 连接 */
void disconnectPlc();
// 合并同一事件循环内的 PLC 状态通知,避免重复刷新和重复日志
/** 合并同一事件循环内的 PLC 状态通知,避免重复刷新和重复日志 */
void schedulePlcStatusUpdate();
// 创建新的工程并刷新编辑界面
/** 创建新的工程并刷新编辑界面 */
void createNewProject();
// 保存当前工程到已关联的路径
/** 保存当前工程到已关联的路径 */
void saveProject();
// 将当前工程保存到用户指定的路径
/** 将当前工程保存到用户指定的路径 */
void saveProjectAs();
// 从用户指定的路径加载工程
/** 从用户指定的路径加载工程 */
void loadProject();
// 在状态栏和输出面板显示工程操作结果
/** 将当前工程构建为用户可直接运行的专用 exe */
void exportRuntimeProgram();
/** 更新用户运行程序导出的阶段进度 */
void handleRuntimeExportOutput();
/** 完成用户运行程序导出并复制最终目录 */
void handleRuntimeExportFinished(int exit_code, QProcess::ExitStatus exit_status);
/** 在状态栏和输出面板显示工程操作结果 */
void showProjectResult(const QString &action, const QString &message, bool succeeded);
/** 向输出面板追加一条消息 */
void appendOutputMessage(const QString &message);

/**
@@ -170,22 +231,30 @@ private:
*
* 失败时恢复与服务层当前状态一致的工具栏选项
*/
void requestMode(ApplicationMode requested_mode);
bool requestMode(ApplicationMode requested_mode);

/**
* @brief 根据服务层模式策略更新编辑权限和状态反馈
* @param message 要显示在状态栏和输出区的模式结果消息
*/
void updateModeUi(const QString &message);
// 根据仿真服务实际状态刷新执行器反馈和 HMI 写权限
/** 根据仿真服务实际状态刷新执行器反馈和 HMI 写权限 */
void updateSimulationUi(bool report_fault);

/**
* @brief 将互斥模式动作同步到服务层当前状态
*/
void restoreCurrentModeAction();
/** 完成主窗口初始化和首次界面刷新 */
void initializeUi();
/** 专用用户运行版启动后直接进入运行监控 */
void initializeUserRuntime();
/** 用户运行版允许在离线和真机之间自动经过编辑态切换 */
void requestUserRuntimeMode(ApplicationMode requested_mode);
/** 刷新编辑态数据监控的模式、权限和当前值 */
void refreshDataMonitorUi();

/** Qt Designer 生成的主窗口界面对象 */
std::unique_ptr<Ui::MainWindow> ui_;
/**
* @brief 非拥有的运行模式服务依赖,由应用入口保证生命周期
@@ -195,29 +264,98 @@ private:
* @brief 非拥有的工程和 HMI 服务依赖,由应用入口保证生命周期
*/
ProjectService &project_service_;
/** 非拥有的 HMI 编辑服务依赖 */
HmiEditorService &hmi_editor_service_;
/** 非拥有的梯形图编辑服务依赖 */
LogicEditorService &logic_editor_service_;
/** 非拥有的 HMI 运行服务依赖 */
HmiRuntimeService &hmi_runtime_service_;
/** 非拥有的报警编辑服务依赖 */
AlarmEditorService &alarm_editor_service_;
/** 非拥有的报警查询服务依赖 */
AlarmService &alarm_service_;
/** 非拥有的寄存器注释服务依赖 */
RegisterCommentService &register_comment_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_;
/** 当前使用的 HMI 页面导航服务 */
HmiNavigationService *hmi_navigation_service_ = nullptr;
/** 工程树控制器 */
std::unique_ptr<ProjectWorkspaceController> project_workspace_controller_;
/** 属性面板控制器 */
std::unique_ptr<PropertyPanelController> property_panel_controller_;
/** 运行监控面板控制器 */
std::unique_ptr<RuntimePanelController> runtime_panel_controller_;
/** 运行模式互斥动作组 */
QActionGroup *mode_action_group_ = nullptr;
/** 主界面的 HMI 编辑器 */
HmiEditorWidget *hmi_editor_widget_ = nullptr;
/** 主界面的梯形图编辑器 */
LogicEditorWidget *logic_editor_widget_ = nullptr;
/** 唯一的运行监控控件 */
RuntimeMonitorWidget *runtime_monitor_widget_ = nullptr;
/** 主窗口中的数据监控面板 */
QDockWidget *data_monitor_dock_ = nullptr;
FreeMonitorWidget *data_monitor_widget_ = nullptr;
QTimer *data_monitor_refresh_timer_ = nullptr;
/** 显示当前运行模式的状态标签 */
QLabel *mode_status_label_ = nullptr;
/** 显示 PLC 连接状态的标签 */
QLabel *register_status_label_ = nullptr;
/** 显示离线执行器状态的标签 */
QLabel *executor_status_label_ = nullptr;
/** 当前保存的 PLC 串口配置 */
PlcSerialConfiguration plc_configuration_;
/** 当前选中的 HMI 控件标识 */
std::string selected_control_id_;
/** 当前选中的梯形图节点标识 */
std::string selected_logic_node_id_;
/** 当前选中的 HMI 页面标识 */
std::string current_hmi_page_id_;
/** 当前选中的控制逻辑标识 */
std::string current_logic_id_;
/** 是否已经安排了待处理的 PLC 状态刷新 */
bool plc_status_update_pending_ = false;
/** 用户运行程序导出进程及其临时状态 */
std::unique_ptr<QProcess> runtime_export_process_;
std::unique_ptr<QProgressDialog> runtime_export_progress_;
QString runtime_export_temp_project_;
QString runtime_export_output_name_;
QString runtime_export_destination_directory_;
QString runtime_export_package_directory_;
QString runtime_export_process_output_;
bool runtime_export_completion_handled_ = false;

struct HmiClipboardData
{
std::vector<HmiControl> controls;
int pasteCount = 0;
};
struct LogicNodesClipboardData
{
std::vector<LogicNode> nodes;
};
struct LogicOutputClipboardData
{
LogicNode output;
};
struct LogicRungClipboardData
{
LadderRung rung;
};
using EditorClipboard = std::variant<
std::monostate,
HmiClipboardData,
LogicNodesClipboardData,
LogicOutputClipboardData,
LogicRungClipboardData>;
EditorClipboard editor_clipboard_;
};

+ 57
- 111
app/src/ui/main_window.ui Vedi File

@@ -228,6 +228,7 @@
<addaction name="saveProjectAction"/>
<addaction name="saveAsProjectAction"/>
<addaction name="loadProjectAction"/>
<addaction name="exportRuntimeAction"/>
<addaction name="separator"/>
<addaction name="exitAction"/>
</widget>
@@ -237,6 +238,8 @@
</property>
<addaction name="undoAction"/>
<addaction name="redoAction"/>
<addaction name="copyAction"/>
<addaction name="pasteAction"/>
<addaction name="separator"/>
<addaction name="deleteSelectionAction"/>
<addaction name="deleteHorizontalWireAction"/>
@@ -669,61 +672,13 @@
</widget>
</item>
<item row="10" column="0">
<widget class="QLabel" name="progressMinimumLabel">
<property name="text">
<string>最小值</string>
</property>
</widget>
</item>
<item row="10" column="1">
<widget class="QSpinBox" name="progressMinimumSpinBox">
<property name="minimum">
<number>-32768</number>
</property>
<property name="maximum">
<number>32767</number>
</property>
</widget>
</item>
<item row="11" column="0">
<widget class="QLabel" name="progressMaximumLabel">
<property name="text">
<string>最大值</string>
</property>
</widget>
</item>
<item row="11" column="1">
<widget class="QSpinBox" name="progressMaximumSpinBox">
<property name="minimum">
<number>-32768</number>
</property>
<property name="maximum">
<number>32767</number>
</property>
</widget>
</item>
<item row="12" column="0">
<widget class="QLabel" name="progressShowValueLabel">
<property name="text">
<string>数值显示</string>
</property>
</widget>
</item>
<item row="12" column="1">
<widget class="QCheckBox" name="progressShowValueCheckBox">
<property name="text">
<string>显示百分比</string>
</property>
</widget>
</item>
<item row="13" column="0">
<widget class="QLabel" name="textColorLabel">
<property name="text">
<string>字体颜色</string>
</property>
</widget>
</item>
<item row="13" column="1">
<item row="10" column="1">
<widget class="QWidget" name="textColorContainer">
<layout class="QHBoxLayout" name="textColorLayout">
<property name="leftMargin"><number>0</number></property>
@@ -747,24 +702,24 @@
</layout>
</widget>
</item>
<item row="14" column="0">
<item row="11" column="0">
<widget class="QLabel" name="fontSizeLabel">
<property name="text">
<string>字号</string>
</property>
</widget>
</item>
<item row="14" column="1">
<item row="11" column="1">
<widget class="QSpinBox" name="fontSizeSpinBox"/>
</item>
<item row="15" column="0">
<item row="12" column="0">
<widget class="QLabel" name="fontBoldLabel">
<property name="text">
<string>字体样式</string>
</property>
</widget>
</item>
<item row="15" column="1">
<item row="12" column="1">
<widget class="QWidget" name="fontStyleContainer">
<layout class="QHBoxLayout" name="fontStyleLayout">
<property name="leftMargin"><number>0</number></property>
@@ -784,7 +739,18 @@
</layout>
</widget>
</item>
<item row="16" column="0" colspan="2">
<item row="13" column="0">
<widget class="QLabel" name="dataTypeLabel">
<property name="text"><string>数值类型</string></property>
</widget>
</item>
<item row="13" column="1">
<widget class="QComboBox" name="dataTypeComboBox">
<item><property name="text"><string>Int16</string></property></item>
<item><property name="text"><string>Float32</string></property></item>
</widget>
</item>
<item row="14" column="0" colspan="2">
<widget class="QPushButton" name="applyPropertiesButton">
<property name="text">
<string>应用属性</string>
@@ -922,27 +888,7 @@
</property>
</widget>
</item>
<item row="7" column="0">
<widget class="QLabel" name="logicPresetLabel">
<property name="text">
<string>预设时间(ms)</string>
</property>
</widget>
</item>
<item row="7" column="1">
<widget class="QSpinBox" name="logicPresetSpinBox">
<property name="minimum">
<number>1</number>
</property>
<property name="maximum">
<number>86400000</number>
</property>
<property name="value">
<number>1000</number>
</property>
</widget>
</item>
<item row="8" column="0" colspan="2">
<item row="7" column="0" colspan="2">
<widget class="QPushButton" name="applyLogicPropertiesButton">
<property name="text">
<string>应用属性</string>
@@ -960,6 +906,28 @@
</layout>
</widget>
</widget>
<widget class="QDockWidget" name="dataMonitorDock">
<property name="minimumSize">
<size>
<width>640</width>
<height>220</height>
</size>
</property>
<property name="windowTitle">
<string>数据监控</string>
</property>
<attribute name="dockWidgetArea">
<number>2</number>
</attribute>
<widget class="QWidget" name="dataMonitorDockContents">
<layout class="QVBoxLayout" name="dataMonitorDockLayout">
<property name="leftMargin"><number>6</number></property>
<property name="topMargin"><number>6</number></property>
<property name="rightMargin"><number>6</number></property>
<property name="bottomMargin"><number>6</number></property>
</layout>
</widget>
</widget>
<widget class="QDockWidget" name="outputDock">
<property name="minimumSize">
<size>
@@ -1036,6 +1004,10 @@
<string>Ctrl+O</string>
</property>
</action>
<action name="exportRuntimeAction">
<property name="text"><string>导出用户运行程序</string></property>
<property name="toolTip"><string>将当前工程导出为用户可直接运行的专用程序</string></property>
</action>
<action name="undoAction">
<property name="text"><string>撤销</string></property>
<property name="toolTip"><string>撤销当前编辑器的上一步操作</string></property>
@@ -1046,6 +1018,16 @@
<property name="toolTip"><string>重做当前编辑器最近撤销的操作</string></property>
<property name="shortcut"><string>Ctrl+Y</string></property>
</action>
<action name="copyAction">
<property name="text"><string>复制</string></property>
<property name="toolTip"><string>复制当前编辑画布中选中的对象</string></property>
<property name="shortcut"><string>Ctrl+C</string></property>
</action>
<action name="pasteAction">
<property name="text"><string>粘贴</string></property>
<property name="toolTip"><string>粘贴已复制的编辑对象</string></property>
<property name="shortcut"><string>Ctrl+V</string></property>
</action>
<action name="deleteSelectionAction">
<property name="text"><string>删除所选</string></property>
<property name="toolTip"><string>删除当前画布中选中的对象</string></property>
@@ -1112,14 +1094,6 @@
<string>添加数值输入控件</string>
</property>
</action>
<action name="addProgressBarAction">
<property name="text">
<string>进度条</string>
</property>
<property name="toolTip">
<string>添加进度条控件</string>
</property>
</action>
<action name="addLabelAction">
<property name="text">
<string>文本</string>
@@ -1252,18 +1226,6 @@
<string>在选中的空网格或横线处添加一个扫描周期的下降沿触点</string>
</property>
</action>
<action name="addTimerContactAction">
<property name="text">
<string>T 触点</string>
</property>
<property name="toolTip">
<string>在选中的空网格或横线处添加离线仿真定时器触点</string>
</property>
</action>
<action name="addCounterContactAction">
<property name="text"><string>C 触点</string></property>
<property name="toolTip"><string>在选中的空网格或横线处添加离线仿真计数器完成触点</string></property>
</action>
<action name="addNormalCoilAction">
<property name="text">
<string>线圈</string>
@@ -1288,22 +1250,6 @@
<string>设置当前网络的复位线圈</string>
</property>
</action>
<action name="addTonAction">
<property name="text">
<string>TON</string>
</property>
<property name="toolTip">
<string>设置非保持接通延时定时器输出</string>
</property>
</action>
<action name="addCtuAction">
<property name="text"><string>CTU</string></property>
<property name="toolTip"><string>设置按上升沿执行的加计数器</string></property>
</action>
<action name="addCtdAction">
<property name="text"><string>CTD</string></property>
<property name="toolTip"><string>设置按上升沿执行的减计数器</string></property>
</action>
<action name="addMoveAction">
<property name="text"><string>MOVE</string></property>
<property name="toolTip"><string>设置 D 数据传送指令</string></property>


+ 178
- 17
app/src/ui/plc_connection_dialog.cpp Vedi File

@@ -3,9 +3,17 @@
#include "ui_plc_connection_dialog.h"

#include <QComboBox>
#include <QDialogButtonBox>
#include <QMessageBox>
#include <QProgressBar>
#include <QPushButton>
#include <QSerialPortInfo>
#include <QStyle>
#include <QTimer>
#include <QToolButton>

#include <initializer_list>
#include <utility>

namespace {

@@ -32,9 +40,13 @@ void selectData(QComboBox *combo_box, int value)

PlcConnectionDialog::PlcConnectionDialog(
const PlcSerialConfiguration &configuration,
PlcDiscoveryGateway &discovery_gateway,
std::function<void()> prepare_discovery,
QWidget *parent)
: QDialog(parent),
ui_(std::make_unique<Ui::PlcConnectionDialog>())
ui_(std::make_unique<Ui::PlcConnectionDialog>()),
discovery_gateway_(discovery_gateway),
prepare_discovery_(std::move(prepare_discovery))
{
ui_->setupUi(this);
setItemData(ui_->baudRateComboBox, {9600, 19200, 38400, 57600, 115200});
@@ -42,22 +54,8 @@ PlcConnectionDialog::PlcConnectionDialog(
setItemData(ui_->parityComboBox, {0, 3, 2});
setItemData(ui_->stopBitsComboBox, {1, 2});

for (const QSerialPortInfo &port : QSerialPortInfo::availablePorts())
{
ui_->serialPortComboBox->addItem(
port.portName(),
port.description().isEmpty() ? port.portName() : port.description());
}
const QString port_name = QString::fromUtf8(configuration.portName.c_str());
if (!port_name.isEmpty())
{
const int port_index = ui_->serialPortComboBox->findText(port_name);
if (port_index < 0)
{
ui_->serialPortComboBox->addItem(port_name);
}
ui_->serialPortComboBox->setCurrentText(port_name);
}
refreshSerialPorts(port_name);

ui_->serverAddressSpinBox->setValue(configuration.serverAddress);
selectData(ui_->baudRateComboBox, configuration.baudRate);
@@ -67,9 +65,36 @@ PlcConnectionDialog::PlcConnectionDialog(
ui_->responseTimeoutSpinBox->setValue(configuration.responseTimeoutMs);
ui_->retriesSpinBox->setValue(configuration.retries);
ui_->pollIntervalSpinBox->setValue(configuration.pollIntervalMs);

ui_->autoSearchButton->setIcon(style()->standardIcon(QStyle::SP_BrowserReload));
ui_->refreshPortsButton->setIcon(style()->standardIcon(QStyle::SP_BrowserReload));
ui_->discoveryProgressBar->setVisible(false);
connect(
ui_->refreshPortsButton, &QToolButton::clicked,
this,
[this]
{
refreshSerialPorts(ui_->serialPortComboBox->currentText().trimmed());
});
connect(
ui_->autoSearchButton, &QPushButton::clicked,
this, &PlcConnectionDialog::toggleAutomaticDiscovery);
discovery_gateway_.setCallbacks(
[this](const PlcDiscoveryProgress &progress)
{
showDiscoveryProgress(progress);
},
[this](const PlcDiscoveryOutcome &outcome)
{
handleDiscoveryFinished(outcome);
});
}

PlcConnectionDialog::~PlcConnectionDialog() = default;
PlcConnectionDialog::~PlcConnectionDialog()
{
discovery_gateway_.setCallbacks({}, {});
discovery_gateway_.cancelDiscovery();
}

PlcSerialConfiguration PlcConnectionDialog::configuration() const
{
@@ -86,3 +111,139 @@ PlcSerialConfiguration PlcConnectionDialog::configuration() const
result.pollIntervalMs = ui_->pollIntervalSpinBox->value();
return result;
}

void PlcConnectionDialog::refreshSerialPorts(const QString &preferred_port)
{
ui_->serialPortComboBox->clear();
for (const QSerialPortInfo &port : QSerialPortInfo::availablePorts())
{
ui_->serialPortComboBox->addItem(
port.portName(),
port.description().isEmpty() ? port.portName() : port.description());
}
const int preferred_index = ui_->serialPortComboBox->findText(preferred_port);
if (preferred_index >= 0)
{
ui_->serialPortComboBox->setCurrentIndex(preferred_index);
}
else if (ui_->serialPortComboBox->count() > 0)
{
ui_->serialPortComboBox->setCurrentIndex(0);
}
else
{
ui_->serialPortComboBox->clearEditText();
}
}

void PlcConnectionDialog::toggleAutomaticDiscovery()
{
if (discovery_gateway_.isDiscovering())
{
ui_->autoSearchButton->setEnabled(false);
ui_->autoSearchButton->setText(tr("正在停止"));
discovery_gateway_.cancelDiscovery();
return;
}
const PlcCommunicationResult result = discovery_gateway_.startDiscovery(
configuration());
if (!result.succeeded)
{
const QString message = QString::fromUtf8(result.message.c_str());
ui_->discoveryStatusLabel->setText(message);
QMessageBox::information(this, tr("自动搜索 PLC"), message);
return;
}
if (prepare_discovery_)
{
prepare_discovery_();
}
setDiscoveryUiActive(true);
}

void PlcConnectionDialog::showDiscoveryProgress(
const PlcDiscoveryProgress &progress)
{
const PlcSerialConfiguration &candidate = progress.configuration;
const QChar parity = candidate.parity == 2
? QLatin1Char('E')
: candidate.parity == 3 ? QLatin1Char('O') : QLatin1Char('N');
ui_->discoveryProgressBar->setMaximum(
static_cast<int>(progress.totalAttempts));
ui_->discoveryProgressBar->setValue(
static_cast<int>(progress.currentAttempt));
ui_->discoveryStatusLabel->setText(
tr("正在搜索 %1:站号 %2,%3,%4%5%6(%7/%8)")
.arg(QString::fromUtf8(candidate.portName.c_str()))
.arg(candidate.serverAddress)
.arg(candidate.baudRate)
.arg(candidate.dataBits)
.arg(parity)
.arg(candidate.stopBits)
.arg(progress.currentAttempt)
.arg(progress.totalAttempts));
}

void PlcConnectionDialog::handleDiscoveryFinished(
const PlcDiscoveryOutcome &outcome)
{
setDiscoveryUiActive(false);
if (outcome.cancelled)
{
ui_->discoveryStatusLabel->setText(tr("已停止自动搜索"));
return;
}
if (!outcome.found)
{
const QString message = outcome.message.empty()
? tr("未找到可响应的 PLC")
: QString::fromUtf8(outcome.message.c_str());
ui_->discoveryStatusLabel->setText(message);
QMessageBox::information(this, tr("自动搜索 PLC"), message);
return;
}
applyConfiguration(outcome.configuration);
ui_->discoveryStatusLabel->setText(tr("已找到 PLC,正在连接"));
QTimer::singleShot(0, this, &QDialog::accept);
}

void PlcConnectionDialog::setDiscoveryUiActive(bool active)
{
ui_->discoveryProgressBar->setVisible(active);
ui_->serialPortComboBox->setEnabled(!active);
ui_->refreshPortsButton->setEnabled(!active);
ui_->serverAddressSpinBox->setEnabled(!active);
ui_->baudRateComboBox->setEnabled(!active);
ui_->dataBitsComboBox->setEnabled(!active);
ui_->parityComboBox->setEnabled(!active);
ui_->stopBitsComboBox->setEnabled(!active);
ui_->timingGroupBox->setEnabled(!active);
QPushButton *ok_button = ui_->buttonBox->button(QDialogButtonBox::Ok);
if (ok_button != nullptr)
{
ok_button->setEnabled(!active);
}
ui_->autoSearchButton->setEnabled(true);
ui_->autoSearchButton->setText(active ? tr("停止搜索") : tr("自动搜索"));
ui_->autoSearchButton->setIcon(style()->standardIcon(
active ? QStyle::SP_BrowserStop : QStyle::SP_BrowserReload));
}

void PlcConnectionDialog::applyConfiguration(
const PlcSerialConfiguration &configuration)
{
const QString port_name = QString::fromUtf8(configuration.portName.c_str());
if (ui_->serialPortComboBox->findText(port_name) < 0)
{
ui_->serialPortComboBox->addItem(port_name);
}
ui_->serialPortComboBox->setCurrentText(port_name);
ui_->serverAddressSpinBox->setValue(configuration.serverAddress);
selectData(ui_->baudRateComboBox, configuration.baudRate);
selectData(ui_->dataBitsComboBox, configuration.dataBits);
selectData(ui_->parityComboBox, configuration.parity);
selectData(ui_->stopBitsComboBox, configuration.stopBits);
ui_->responseTimeoutSpinBox->setValue(configuration.responseTimeoutMs);
ui_->retriesSpinBox->setValue(configuration.retries);
ui_->pollIntervalSpinBox->setValue(configuration.pollIntervalMs);
}

+ 35
- 0
app/src/ui/plc_connection_dialog.h Vedi File

@@ -1,27 +1,62 @@
/**
* @file plc_connection_dialog.h
* @brief 定义 PLC 串口连接参数对话框
* @version 1.0.0
* @author suyu
* @date 2026-08-22
*/

#pragma once

#include "services/plc_communication_gateway.h"
#include "services/plc_discovery_gateway.h"

#include <QDialog>
#include <QString>

#include <functional>
#include <memory>

namespace Ui {
class PlcConnectionDialog;
}

/** PLC 串口参数对话框;只编辑配置,不负责打开串口 */
class PlcConnectionDialog final : public QDialog
{
Q_OBJECT

public:
/** 使用现有配置初始化 PLC 参数表单 */
explicit PlcConnectionDialog(
const PlcSerialConfiguration &configuration,
PlcDiscoveryGateway &discovery_gateway,
std::function<void()> prepare_discovery,
QWidget *parent = nullptr);
/** 释放 Designer 界面对象 */
~PlcConnectionDialog() override;

/** 返回表单中的串口配置,连接前由网关再次校验 */
PlcSerialConfiguration configuration() const;

private:
/** 重新枚举当前实际存在的串口,并尽量保留仍然可用的选择 */
void refreshSerialPorts(const QString &preferred_port);
/** 开始自动搜索或请求停止当前搜索 */
void toggleAutomaticDiscovery();
/** 根据异步探测进度刷新界面 */
void showDiscoveryProgress(const PlcDiscoveryProgress &progress);
/** 处理找到、未找到和用户取消三种结束结果 */
void handleDiscoveryFinished(const PlcDiscoveryOutcome &outcome);
/** 切换搜索期间的表单编辑权限和按钮状态 */
void setDiscoveryUiActive(bool active);
/** 把一组串口配置回填到界面 */
void applyConfiguration(const PlcSerialConfiguration &configuration);

/** Qt Designer 生成的界面对象 */
std::unique_ptr<Ui::PlcConnectionDialog> ui_;
/** 异步 PLC 自动搜索服务 */
PlcDiscoveryGateway &discovery_gateway_;
/** 搜索前释放正式 PLC 连接占用的串口 */
std::function<void()> prepare_discovery_;
};

+ 80
- 40
app/src/ui/plc_connection_dialog.ui Vedi File

@@ -6,54 +6,94 @@
<string>PLC 通信配置</string>
</property>
<property name="minimumSize">
<size><width>420</width><height>0</height></size>
<size><width>440</width><height>0</height></size>
</property>
<layout class="QVBoxLayout" name="dialogLayout">
<item>
<widget class="QGroupBox" name="serialGroupBox">
<property name="title"><string>Modbus RTU 串口</string></property>
<layout class="QFormLayout" name="serialFormLayout">
<property name="fieldGrowthPolicy"><enum>QFormLayout::AllNonFixedFieldsGrow</enum></property>
<item row="0" column="0"><widget class="QLabel" name="serialPortLabel"><property name="text"><string>端口</string></property></widget></item>
<item row="0" column="1">
<widget class="QComboBox" name="serialPortComboBox">
<property name="editable"><bool>true</bool></property>
<property name="placeholderText"><string>选择或输入 COM 端口</string></property>
</widget>
</item>
<item row="1" column="0"><widget class="QLabel" name="serverAddressLabel"><property name="text"><string>PLC 站号</string></property></widget></item>
<item row="1" column="1"><widget class="QSpinBox" name="serverAddressSpinBox"><property name="minimum"><number>1</number></property><property name="maximum"><number>247</number></property></widget></item>
<item row="2" column="0"><widget class="QLabel" name="baudRateLabel"><property name="text"><string>波特率</string></property></widget></item>
<item row="2" column="1">
<widget class="QComboBox" name="baudRateComboBox">
<item><property name="text"><string>9600</string></property><property name="userData"><string notr="true">9600</string></property></item>
<item><property name="text"><string>19200</string></property><property name="userData"><string notr="true">19200</string></property></item>
<item><property name="text"><string>38400</string></property><property name="userData"><string notr="true">38400</string></property></item>
<item><property name="text"><string>57600</string></property><property name="userData"><string notr="true">57600</string></property></item>
<item><property name="text"><string>115200</string></property><property name="userData"><string notr="true">115200</string></property></item>
</widget>
<layout class="QVBoxLayout" name="serialGroupLayout">
<item>
<layout class="QHBoxLayout" name="discoveryControlLayout">
<item>
<widget class="QPushButton" name="autoSearchButton">
<property name="text"><string>自动搜索</string></property>
</widget>
</item>
<item>
<widget class="QProgressBar" name="discoveryProgressBar">
<property name="minimum"><number>0</number></property>
<property name="maximum"><number>100</number></property>
<property name="value"><number>0</number></property>
<property name="textVisible"><bool>false</bool></property>
</widget>
</item>
</layout>
</item>
<item row="3" column="0"><widget class="QLabel" name="dataBitsLabel"><property name="text"><string>数据位</string></property></widget></item>
<item row="3" column="1">
<widget class="QComboBox" name="dataBitsComboBox">
<item><property name="text"><string>7</string></property><property name="userData"><string notr="true">7</string></property></item>
<item><property name="text"><string>8</string></property><property name="userData"><string notr="true">8</string></property></item>
<item>
<widget class="QLabel" name="discoveryStatusLabel">
<property name="text"><string>自动搜索使用当前 PLC 站号</string></property>
<property name="wordWrap"><bool>true</bool></property>
</widget>
</item>
<item row="4" column="0"><widget class="QLabel" name="parityLabel"><property name="text"><string>校验方式</string></property></widget></item>
<item row="4" column="1">
<widget class="QComboBox" name="parityComboBox">
<item><property name="text"><string>无校验</string></property><property name="userData"><string notr="true">0</string></property></item>
<item><property name="text"><string>奇校验</string></property><property name="userData"><string notr="true">3</string></property></item>
<item><property name="text"><string>偶校验</string></property><property name="userData"><string notr="true">2</string></property></item>
</widget>
</item>
<item row="5" column="0"><widget class="QLabel" name="stopBitsLabel"><property name="text"><string>停止位</string></property></widget></item>
<item row="5" column="1">
<widget class="QComboBox" name="stopBitsComboBox">
<item><property name="text"><string>1</string></property><property name="userData"><string notr="true">1</string></property></item>
<item><property name="text"><string>2</string></property><property name="userData"><string notr="true">2</string></property></item>
</widget>
<item>
<layout class="QFormLayout" name="serialFormLayout">
<property name="fieldGrowthPolicy"><enum>QFormLayout::AllNonFixedFieldsGrow</enum></property>
<item row="0" column="0"><widget class="QLabel" name="serialPortLabel"><property name="text"><string>端口</string></property></widget></item>
<item row="0" column="1">
<layout class="QHBoxLayout" name="serialPortEditorLayout">
<item>
<widget class="QComboBox" name="serialPortComboBox">
<property name="editable"><bool>true</bool></property>
<property name="placeholderText"><string>选择或输入 COM 端口</string></property>
</widget>
</item>
<item>
<widget class="QToolButton" name="refreshPortsButton">
<property name="minimumSize"><size><width>28</width><height>28</height></size></property>
<property name="maximumSize"><size><width>28</width><height>28</height></size></property>
<property name="toolTip"><string>刷新串口</string></property>
<property name="accessibleName"><string>刷新串口</string></property>
<property name="autoRaise"><bool>true</bool></property>
</widget>
</item>
</layout>
</item>
<item row="1" column="0"><widget class="QLabel" name="serverAddressLabel"><property name="text"><string>PLC 站号</string></property></widget></item>
<item row="1" column="1"><widget class="QSpinBox" name="serverAddressSpinBox"><property name="minimum"><number>1</number></property><property name="maximum"><number>247</number></property></widget></item>
<item row="2" column="0"><widget class="QLabel" name="baudRateLabel"><property name="text"><string>波特率</string></property></widget></item>
<item row="2" column="1">
<widget class="QComboBox" name="baudRateComboBox">
<item><property name="text"><string>9600</string></property><property name="userData"><string notr="true">9600</string></property></item>
<item><property name="text"><string>19200</string></property><property name="userData"><string notr="true">19200</string></property></item>
<item><property name="text"><string>38400</string></property><property name="userData"><string notr="true">38400</string></property></item>
<item><property name="text"><string>57600</string></property><property name="userData"><string notr="true">57600</string></property></item>
<item><property name="text"><string>115200</string></property><property name="userData"><string notr="true">115200</string></property></item>
</widget>
</item>
<item row="3" column="0"><widget class="QLabel" name="dataBitsLabel"><property name="text"><string>数据位</string></property></widget></item>
<item row="3" column="1">
<widget class="QComboBox" name="dataBitsComboBox">
<item><property name="text"><string>7</string></property><property name="userData"><string notr="true">7</string></property></item>
<item><property name="text"><string>8</string></property><property name="userData"><string notr="true">8</string></property></item>
</widget>
</item>
<item row="4" column="0"><widget class="QLabel" name="parityLabel"><property name="text"><string>校验方式</string></property></widget></item>
<item row="4" column="1">
<widget class="QComboBox" name="parityComboBox">
<item><property name="text"><string>无校验</string></property><property name="userData"><string notr="true">0</string></property></item>
<item><property name="text"><string>奇校验</string></property><property name="userData"><string notr="true">3</string></property></item>
<item><property name="text"><string>偶校验</string></property><property name="userData"><string notr="true">2</string></property></item>
</widget>
</item>
<item row="5" column="0"><widget class="QLabel" name="stopBitsLabel"><property name="text"><string>停止位</string></property></widget></item>
<item row="5" column="1">
<widget class="QComboBox" name="stopBitsComboBox">
<item><property name="text"><string>1</string></property><property name="userData"><string notr="true">1</string></property></item>
<item><property name="text"><string>2</string></property><property name="userData"><string notr="true">2</string></property></item>
</widget>
</item>
</layout>
</item>
</layout>
</widget>


+ 48
- 12
app/src/ui/project_workspace_controller.cpp Vedi File

@@ -11,9 +11,10 @@
#include "ui_main_window.h"

#include <QAction>
#include <QDialogButtonBox>
#include <QInputDialog>
#include <QLineEdit>
#include <QObject>
#include <QPushButton>
#include <QSignalBlocker>
#include <QTabWidget>
#include <QTreeWidget>
@@ -38,6 +39,43 @@ std::string toUtf8(const QString &value)
return std::string(bytes.constData(), static_cast<std::size_t>(bytes.size()));
}

bool requestRequiredName(
QWidget &parent,
const QString &title,
const QString &label,
const QString &initial_value,
QString *name)
{
QInputDialog dialog(&parent);
dialog.setWindowTitle(title);
dialog.setLabelText(label);
dialog.setInputMode(QInputDialog::TextInput);
dialog.setTextValue(initial_value);

QDialogButtonBox *button_box = dialog.findChild<QDialogButtonBox *>();
QPushButton *ok_button = button_box != nullptr
? button_box->button(QDialogButtonBox::Ok)
: nullptr;
if (ok_button != nullptr)
{
ok_button->setEnabled(!initial_value.trimmed().isEmpty());
QObject::connect(
&dialog,
&QInputDialog::textValueChanged,
&dialog,
[ok_button](const QString &text) {
ok_button->setEnabled(!text.trimmed().isEmpty());
});
}

if (dialog.exec() != QDialog::Accepted)
{
return false;
}
*name = dialog.textValue().trimmed();
return true;
}

} // namespace

ProjectWorkspaceController::ProjectWorkspaceController(
@@ -295,9 +333,9 @@ void ProjectWorkspaceController::updateActions()

void ProjectWorkspaceController::addPage()
{
const QString name = QInputDialog::getText(
&parent_, QObject::tr("新建页面"), QObject::tr("页面名称"));
if (name.isEmpty())
QString name;
if (!requestRequiredName(
parent_, QObject::tr("新建页面"), QObject::tr("页面名称"), {}, &name))
{
return;
}
@@ -317,9 +355,9 @@ void ProjectWorkspaceController::addPage()

void ProjectWorkspaceController::addLogic()
{
const QString name = QInputDialog::getText(
&parent_, QObject::tr("新建控制逻辑"), QObject::tr("逻辑名称"));
if (name.isEmpty())
QString name;
if (!requestRequiredName(
parent_, QObject::tr("新建控制逻辑"), QObject::tr("逻辑名称"), {}, &name))
{
return;
}
@@ -362,11 +400,9 @@ void ProjectWorkspaceController::renameSelectedItem()
{
return;
}
bool accepted = false;
const QString name = QInputDialog::getText(
&parent_, QObject::tr("重命名"), QObject::tr("新名称"),
QLineEdit::Normal, current_name, &accepted);
if (!accepted)
QString name;
if (!requestRequiredName(
parent_, QObject::tr("重命名"), QObject::tr("新名称"), current_name, &name))
{
return;
}


+ 60
- 4
app/src/ui/project_workspace_controller.h Vedi File

@@ -1,3 +1,11 @@
/**
* @file project_workspace_controller.h
* @brief 定义工程树和当前编辑对象控制器
* @version 1.0.0
* @author suyu
* @date 2026-08-22
*/

#pragma once

#include "domain/hmi_model.h"
@@ -23,16 +31,24 @@ namespace Ui {
class MainWindow;
}

// 组织工程树、当前页面/逻辑选择和工程对象的界面刷新
/**
* 组织工程树、当前页面/逻辑选择和工程对象的界面刷新
*
* 它不直接改 Project,所有增删改都转发给对应服务
*/
class ProjectWorkspaceController final
{
public:
/** 报告工程操作结果,参数依次为操作名、消息和是否成功 */
using ResultReporter = std::function<void(
const QString &action, const QString &message, bool succeeded)>;
/** 向状态栏报告一条带超时的消息 */
using StatusReporter = std::function<void(
const QString &message, int timeout_ms)>;
/** 通知主窗口编辑状态已经发生变化 */
using EditStateChanged = std::function<void()>;

/** 创建工程树控制器并绑定主窗口服务和编辑器 */
ProjectWorkspaceController(
QWidget &parent,
Ui::MainWindow &ui,
@@ -52,56 +68,96 @@ public:
StatusReporter status_reporter,
EditStateChanged edit_state_changed);

/** 连接工程树动作和选择变化信号 */
void configure();
/** 根据当前工程重新生成工程树和编辑器内容 */
void refresh();
/** 根据当前选中对象更新工程树动作的可用状态 */
void updateActions();
/** 新增一个 HMI 页面 */
void addPage();
/** 新增一个控制逻辑 */
void addLogic();
/** 重命名当前选中的工程树项目 */
void renameSelectedItem();
/** 删除当前选中的工程树项目 */
void deleteSelectedItem();
/** 按偏移量移动当前选中的工程树项目 */
void moveSelectedItem(int offset);
/** 把当前选中的 HMI 页面设为初始页面 */
void setSelectedPageAsInitial();
/** 启用或停用当前选中的控制逻辑 */
void toggleSelectedLogicEnabled();

/** 返回当前 HMI 页面标识 */
const std::string &currentHmiPageId() const;
/** 返回当前控制逻辑标识 */
const std::string &currentLogicId() const;

private:
/** 工程树项目的三种业务类型 */
enum class ItemKind
{
Root = 0,
HmiPage = 1,
ControlLogic = 2
Root = 0, // 工程树根节点
HmiPage = 1, // HMI 页面节点
ControlLogic = 2 // 控制逻辑节点
};

/** 处理工程树选择变化并刷新当前编辑器 */
void handleSelectionChanged();
/** 统一报告工程操作失败 */
void reportFailure(
const QString &action, const std::string &message) const;

/** 主窗口父对象,不由控制器拥有 */
QWidget &parent_;
/** 主窗口的 Designer 界面对象 */
Ui::MainWindow &ui_;
/** 工程服务,不由控制器拥有 */
ProjectService &project_service_;
/** HMI 编辑服务,不由控制器拥有 */
HmiEditorService &hmi_editor_service_;
/** 梯形图编辑服务,不由控制器拥有 */
LogicEditorService &logic_editor_service_;
/** 运行模式服务,不由控制器拥有 */
RuntimeModeService &runtime_mode_service_;
/** HMI 页面导航服务,不由控制器拥有 */
HmiNavigationService &hmi_navigation_service_;
/** 主窗口中的 HMI 编辑器 */
HmiEditorWidget &hmi_editor_widget_;
/** 主窗口中的梯形图编辑器 */
LogicEditorWidget &logic_editor_widget_;
/** 运行监控控件 */
RuntimeMonitorWidget &runtime_monitor_widget_;
/** 主窗口保存的当前 HMI 页面标识 */
std::string &current_hmi_page_id_;
/** 主窗口保存的当前控制逻辑标识 */
std::string &current_logic_id_;
/** 显示 HMI 控件属性的回调 */
std::function<void(const std::string &)> show_control_properties_;
/** 显示梯形图节点属性的回调 */
std::function<void(const std::string &)> show_logic_properties_;
/** 工程操作结果回调 */
ResultReporter result_reporter_;
/** 状态栏消息回调 */
StatusReporter status_reporter_;
/** 编辑状态变化回调 */
EditStateChanged edit_state_changed_;

/** 新增页面动作 */
QAction *add_page_action_ = nullptr;
/** 新增逻辑动作 */
QAction *add_logic_action_ = nullptr;
/** 重命名动作 */
QAction *rename_item_action_ = nullptr;
/** 删除工程树项目动作 */
QAction *delete_item_action_ = nullptr;
/** 上移工程树项目动作 */
QAction *move_item_up_action_ = nullptr;
/** 下移工程树项目动作 */
QAction *move_item_down_action_ = nullptr;
/** 设置初始页面动作 */
QAction *set_initial_page_action_ = nullptr;
/** 启停控制逻辑动作 */
QAction *toggle_logic_enabled_action_ = nullptr;
};

+ 21
- 103
app/src/ui/property_panel_controller.cpp Vedi File

@@ -62,6 +62,7 @@ PropertyPanelController::PropertyPanelController(
ProjectService &project_service,
HmiEditorService &hmi_editor_service,
LogicEditorService &logic_editor_service,
const HmiDefaultSettings &hmi_defaults,
std::function<std::string()> current_page_id,
std::function<std::string()> current_logic_id,
std::string &selected_control_id,
@@ -74,6 +75,7 @@ PropertyPanelController::PropertyPanelController(
project_service_(project_service),
hmi_editor_service_(hmi_editor_service),
logic_editor_service_(logic_editor_service),
hmi_defaults_(hmi_defaults),
current_page_id_(std::move(current_page_id)),
current_logic_id_(std::move(current_logic_id)),
selected_control_id_(selected_control_id),
@@ -106,6 +108,10 @@ void PropertyPanelController::configure()
2, static_cast<int>(HmiButtonOperation::Toggle));
ui_.buttonOperationComboBox->setItemData(
3, static_cast<int>(HmiButtonOperation::MomentaryOn));
ui_.dataTypeComboBox->setItemData(
0, static_cast<int>(RegisterDataType::Int16));
ui_.dataTypeComboBox->setItemData(
1, static_cast<int>(RegisterDataType::Float32));
for (int index = 0; index < ui_.logicComparisonComboBox->count(); ++index)
{
ui_.logicComparisonComboBox->setItemData(index, index);
@@ -193,9 +199,7 @@ void PropertyPanelController::showControlProperties(const std::string &control_i
static_cast<QWidget *>(ui_.bindingIndexSpinBox),
static_cast<QWidget *>(ui_.targetPageComboBox),
static_cast<QWidget *>(ui_.buttonOperationComboBox),
static_cast<QWidget *>(ui_.progressMinimumSpinBox),
static_cast<QWidget *>(ui_.progressMaximumSpinBox),
static_cast<QWidget *>(ui_.progressShowValueCheckBox),
static_cast<QWidget *>(ui_.dataTypeComboBox),
static_cast<QWidget *>(ui_.textColorEdit),
static_cast<QWidget *>(ui_.textColorButton),
static_cast<QWidget *>(ui_.fontSizeSpinBox),
@@ -218,12 +222,8 @@ void PropertyPanelController::showControlProperties(const std::string &control_i
ui_.targetPageComboBox->setVisible(false);
ui_.buttonOperationLabel->setVisible(false);
ui_.buttonOperationComboBox->setVisible(false);
ui_.progressMinimumLabel->setVisible(false);
ui_.progressMinimumSpinBox->setVisible(false);
ui_.progressMaximumLabel->setVisible(false);
ui_.progressMaximumSpinBox->setVisible(false);
ui_.progressShowValueLabel->setVisible(false);
ui_.progressShowValueCheckBox->setVisible(false);
ui_.dataTypeLabel->setVisible(false);
ui_.dataTypeComboBox->setVisible(false);
ui_.textColorEdit->clear();
ui_.textColorButton->setStyleSheet(QString{});
ui_.fontSizeSpinBox->setValue(
@@ -263,6 +263,14 @@ void PropertyPanelController::showControlProperties(const std::string &control_i
ui_.buttonOperationComboBox->findData(
static_cast<int>(control->buttonOperation)));

const bool is_numeric = control->type == HmiControlType::NumericDisplay
|| control->type == HmiControlType::NumericInput;
ui_.dataTypeLabel->setVisible(is_numeric);
ui_.dataTypeComboBox->setVisible(is_numeric);
ui_.dataTypeComboBox->setEnabled(is_numeric);
ui_.dataTypeComboBox->setCurrentIndex(
ui_.dataTypeComboBox->findData(static_cast<int>(control->dataType)));

const bool is_page_jump = control->type == HmiControlType::PageJump;
ui_.targetPageLabel->setVisible(is_page_jump);
ui_.targetPageComboBox->setVisible(is_page_jump);
@@ -281,22 +289,6 @@ void PropertyPanelController::showControlProperties(const std::string &control_i
ui_.targetPageComboBox->findData(fromUtf8(target_id)));
}

const bool is_progress_bar = control->type == HmiControlType::ProgressBar;
ui_.progressMinimumLabel->setVisible(is_progress_bar);
ui_.progressMinimumSpinBox->setVisible(is_progress_bar);
ui_.progressMaximumLabel->setVisible(is_progress_bar);
ui_.progressMaximumSpinBox->setVisible(is_progress_bar);
ui_.progressShowValueLabel->setVisible(is_progress_bar);
ui_.progressShowValueCheckBox->setVisible(is_progress_bar);
ui_.progressMinimumSpinBox->setEnabled(is_progress_bar);
ui_.progressMaximumSpinBox->setEnabled(is_progress_bar);
ui_.progressShowValueCheckBox->setEnabled(is_progress_bar);
const HmiProgressBarConfig progress_config = control->progressBar.value_or(
HmiProgressBarConfig{});
ui_.progressMinimumSpinBox->setValue(progress_config.minimumValue);
ui_.progressMaximumSpinBox->setValue(progress_config.maximumValue);
ui_.progressShowValueCheckBox->setChecked(progress_config.showValue);

const auto text_color = control->properties.find(
HmiAppearanceProperty::kTextColor);
ui_.textColorEdit->setText(text_color == control->properties.cend()
@@ -356,8 +348,8 @@ void PropertyPanelController::showPageProperties(const std::string &page_id)
ui_.applyPagePropertiesButton->setEnabled(has_page);
if (!has_page)
{
ui_.pageWidthSpinBox->setValue(ProjectLimits::kDefaultHmiPageWidth);
ui_.pageHeightSpinBox->setValue(ProjectLimits::kDefaultHmiPageHeight);
ui_.pageWidthSpinBox->setValue(hmi_defaults_.pageWidth);
ui_.pageHeightSpinBox->setValue(hmi_defaults_.pageHeight);
return;
}
ui_.pageWidthSpinBox->setValue(page->width);
@@ -378,7 +370,6 @@ void PropertyPanelController::showLogicNodeProperties(const std::string &node_id
static_cast<QWidget *>(ui_.logicModeComboBox),
static_cast<QWidget *>(ui_.logicComparisonComboBox),
static_cast<QWidget *>(ui_.logicValueSpinBox),
static_cast<QWidget *>(ui_.logicPresetSpinBox),
static_cast<QWidget *>(ui_.applyLogicPropertiesButton)})
{
widget->setEnabled(has_node);
@@ -391,7 +382,6 @@ void PropertyPanelController::showLogicNodeProperties(const std::string &node_id
ui_.logicNodeTypeLabel->clear();
ui_.logicAddressAreaLabel->clear();
ui_.logicModeComboBox->clear();
ui_.logicPresetSpinBox->setValue(TonNodeConfig::kMinimumPresetMs);
return;
}

@@ -399,7 +389,6 @@ void PropertyPanelController::showLogicNodeProperties(const std::string &node_id
ui_.logicModeComboBox->clear();
ui_.logicComparisonComboBox->setEnabled(false);
ui_.logicValueSpinBox->setEnabled(false);
ui_.logicPresetSpinBox->setEnabled(false);
if (const auto *contact = std::get_if<ContactNodeConfig>(&node->config))
{
ui_.logicNodeTypeLabel->setText(QObject::tr("触点"));
@@ -426,33 +415,6 @@ void PropertyPanelController::showLogicNodeProperties(const std::string &node_id
ui_.logicModeComboBox->findData(static_cast<int>(edge->mode)));
ui_.logicModeComboBox->setEnabled(true);
}
else if (const auto *timer = std::get_if<TimerContactNodeConfig>(&node->config))
{
ui_.logicNodeTypeLabel->setText(QObject::tr("T 触点"));
ui_.logicAddressAreaLabel->setText(QStringLiteral("T"));
ui_.logicAddressSpinBox->setValue(timer->address.index());
ui_.logicModeComboBox->addItem(
QObject::tr("常开"), static_cast<int>(ContactMode::NormallyOpen));
ui_.logicModeComboBox->addItem(
QObject::tr("常闭"), static_cast<int>(ContactMode::NormallyClosed));
ui_.logicModeComboBox->setCurrentIndex(
ui_.logicModeComboBox->findData(static_cast<int>(timer->mode)));
ui_.logicModeComboBox->setEnabled(true);
}
else if (const auto *counter_contact =
std::get_if<CounterContactNodeConfig>(&node->config))
{
ui_.logicNodeTypeLabel->setText(QObject::tr("C 触点"));
ui_.logicAddressAreaLabel->setText(QStringLiteral("C"));
ui_.logicAddressSpinBox->setValue(counter_contact->address.index());
ui_.logicModeComboBox->addItem(
QObject::tr("常开"), static_cast<int>(ContactMode::NormallyOpen));
ui_.logicModeComboBox->addItem(
QObject::tr("常闭"), static_cast<int>(ContactMode::NormallyClosed));
ui_.logicModeComboBox->setCurrentIndex(ui_.logicModeComboBox->findData(
static_cast<int>(counter_contact->mode)));
ui_.logicModeComboBox->setEnabled(true);
}
else if (const auto *coil = std::get_if<CoilNodeConfig>(&node->config))
{
ui_.logicNodeTypeLabel->setText(QObject::tr("线圈"));
@@ -482,27 +444,6 @@ void PropertyPanelController::showLogicNodeProperties(const std::string &node_id
ui_.logicValueSpinBox->setEnabled(true);
ui_.logicValueSpinBox->setValue(compare->value);
}
else if (const auto *ton = std::get_if<TonNodeConfig>(&node->config))
{
ui_.logicNodeTypeLabel->setText(QObject::tr("TON"));
ui_.logicAddressAreaLabel->setText(QStringLiteral("T"));
ui_.logicAddressSpinBox->setValue(ton->address.index());
ui_.logicModeComboBox->addItem(QObject::tr("非保持接通延时"));
ui_.logicModeComboBox->setEnabled(false);
ui_.logicPresetSpinBox->setEnabled(true);
ui_.logicPresetSpinBox->setValue(ton->presetMs);
}
else if (const auto *counter = std::get_if<CounterNodeConfig>(&node->config))
{
ui_.logicNodeTypeLabel->setText(
counter->mode == CounterMode::Up ? QStringLiteral("CTU")
: QStringLiteral("CTD"));
ui_.logicAddressAreaLabel->setText(QStringLiteral("C"));
ui_.logicAddressSpinBox->setValue(counter->address.index());
ui_.logicAddressSpinBox->setEnabled(false);
ui_.logicModeComboBox->addItem(QObject::tr("使用配置对话框编辑"));
ui_.logicModeComboBox->setEnabled(false);
}
else if (const auto *move = std::get_if<MoveNodeConfig>(&node->config))
{
ui_.logicNodeTypeLabel->setText(QStringLiteral("MOVE"));
@@ -591,6 +532,8 @@ void PropertyPanelController::applySelectedControlProperties()
control.bounds.y = ui_.controlYSpinBox->value();
control.bounds.width = ui_.controlWidthSpinBox->value();
control.bounds.height = ui_.controlHeightSpinBox->value();
control.dataType = static_cast<RegisterDataType>(
ui_.dataTypeComboBox->currentData().toInt());
const HmiControlDescriptor *descriptor =
findHmiControlDescriptor(control.type);
if (descriptor == nullptr
@@ -623,13 +566,6 @@ void PropertyPanelController::applySelectedControlProperties()
control.pageJump = HmiPageJumpConfig{
toUtf8(ui_.targetPageComboBox->currentData().toString())};
}
if (control.type == HmiControlType::ProgressBar)
{
control.progressBar = HmiProgressBarConfig{
static_cast<std::int16_t>(ui_.progressMinimumSpinBox->value()),
static_cast<std::int16_t>(ui_.progressMaximumSpinBox->value()),
ui_.progressShowValueCheckBox->isChecked()};
}
const std::string text_color = toUtf8(ui_.textColorEdit->text());
if (text_color.empty())
{
@@ -726,18 +662,6 @@ void PropertyPanelController::applySelectedLogicNodeProperties()
RegisterAddress{RegisterArea::M, ui_.logicAddressSpinBox->value()},
static_cast<EdgeMode>(ui_.logicModeComboBox->currentData().toInt())};
}
else if (std::holds_alternative<TimerContactNodeConfig>(config))
{
config = TimerContactNodeConfig{
TimerAddress{ui_.logicAddressSpinBox->value()},
static_cast<ContactMode>(ui_.logicModeComboBox->currentData().toInt())};
}
else if (std::holds_alternative<CounterContactNodeConfig>(config))
{
config = CounterContactNodeConfig{
CounterAddress{ui_.logicAddressSpinBox->value()},
static_cast<ContactMode>(ui_.logicModeComboBox->currentData().toInt())};
}
else if (std::holds_alternative<CoilNodeConfig>(config))
{
config = CoilNodeConfig{
@@ -752,12 +676,6 @@ void PropertyPanelController::applySelectedLogicNodeProperties()
ui_.logicComparisonComboBox->currentData().toInt()),
static_cast<std::int16_t>(ui_.logicValueSpinBox->value())};
}
else if (std::holds_alternative<TonNodeConfig>(config))
{
config = TonNodeConfig{
TimerAddress{ui_.logicAddressSpinBox->value()},
ui_.logicPresetSpinBox->value()};
}
else
{
LogicInstructionDialog dialog(node->config, &parent_);


+ 47
- 1
app/src/ui/property_panel_controller.h Vedi File

@@ -1,3 +1,11 @@
/**
* @file property_panel_controller.h
* @brief 定义 HMI 和梯形图属性面板控制器
* @version 1.0.0
* @author suyu
* @date 2026-08-22
*/

#pragma once

#include "domain/control_logic_model.h"
@@ -19,21 +27,29 @@ namespace Ui {
class MainWindow;
}

// 组织 HMI/梯形图选择状态、属性面板和编辑器信号
/**
* 组织 HMI/梯形图选择状态、属性面板和编辑器信号
*
* 属性提交统一经过编辑服务,以便校验、原子回滚和撤销/重做
*/
class PropertyPanelController final
{
public:
/** 报告属性操作结果,参数依次为操作名、消息和是否成功 */
using ResultReporter = std::function<void(
const QString &action, const QString &message, bool succeeded)>;
/** 向状态栏报告一条带超时的消息 */
using StatusReporter = std::function<void(
const QString &message, int timeout_ms)>;

/** 创建属性面板控制器并注入编辑服务和选择状态 */
PropertyPanelController(
QWidget &parent,
Ui::MainWindow &ui,
ProjectService &project_service,
HmiEditorService &hmi_editor_service,
LogicEditorService &logic_editor_service,
const HmiDefaultSettings &hmi_defaults,
std::function<std::string()> current_page_id,
std::function<std::string()> current_logic_id,
std::string &selected_control_id,
@@ -42,39 +58,69 @@ public:
ResultReporter result_reporter,
StatusReporter status_reporter);

/** 连接属性面板按钮、输入框和服务信号 */
void configure();
/** 绑定 HMI 与梯形图编辑器,接收它们的选择变化 */
void bindEditorWidgets(
HmiEditorWidget &hmi_editor_widget,
LogicEditorWidget &logic_editor_widget);

/** 显示指定 HMI 控件的属性 */
void showControlProperties(const std::string &control_id);
/** 显示指定 HMI 页面尺寸属性 */
void showPageProperties(const std::string &page_id);
/** 显示指定梯形图节点的属性 */
void showLogicNodeProperties(const std::string &node_id);
/** 在当前页面添加指定类型的 HMI 控件 */
void addHmiControl(HmiControlType type);
/** 删除当前选中的 HMI 控件 */
void deleteSelectedControl();
/** 提交当前 HMI 控件属性 */
void applySelectedControlProperties();
/** 提交当前 HMI 页面属性 */
void applySelectedPageProperties();
/** 提交当前梯形图节点属性 */
void applySelectedLogicNodeProperties();

private:
/** 显示 HMI 编辑服务返回的错误 */
void handleHmiEditorError(const QString &message) const;
/** 显示梯形图编辑服务返回的错误 */
void handleLogicEditorError(const QString &message) const;
/** 打开颜色选择器并写回属性输入框 */
void chooseTextColor();
/** 统一报告属性操作失败 */
void reportFailure(
const QString &action, const std::string &message) const;

/** 主窗口父对象,不由控制器拥有 */
QWidget &parent_;
/** 主窗口的 Designer 界面对象 */
Ui::MainWindow &ui_;
/** 工程服务,不由控制器拥有 */
ProjectService &project_service_;
/** HMI 编辑服务,不由控制器拥有 */
HmiEditorService &hmi_editor_service_;
/** 梯形图编辑服务,不由控制器拥有 */
LogicEditorService &logic_editor_service_;
/** 新建页面的启动期只读默认尺寸 */
const HmiDefaultSettings &hmi_defaults_;
/** 获取当前 HMI 页面标识的回调 */
std::function<std::string()> current_page_id_;
/** 获取当前控制逻辑标识的回调 */
std::function<std::string()> current_logic_id_;
/** 主窗口保存的当前控件标识 */
std::string &selected_control_id_;
/** 主窗口保存的当前逻辑节点标识 */
std::string &selected_logic_node_id_;
/** 请求主窗口刷新工程界面的回调 */
std::function<void()> refresh_project_ui_;
/** 属性操作结果回调 */
ResultReporter result_reporter_;
/** 状态栏消息回调 */
StatusReporter status_reporter_;
/** 绑定后的 HMI 编辑器 */
HmiEditorWidget *hmi_editor_widget_ = nullptr;
/** 绑定后的梯形图编辑器 */
LogicEditorWidget *logic_editor_widget_ = nullptr;
};

+ 19
- 0
app/src/ui/register_comment_dialog.h Vedi File

@@ -1,3 +1,11 @@
/**
* @file register_comment_dialog.h
* @brief 定义 M/D 寄存器工程注释编辑对话框
* @version 1.0.0
* @author suyu
* @date 2026-08-22
*/

#pragma once

#include "domain/register_address.h"
@@ -13,22 +21,33 @@ class RegisterCommentDialog;

class RegisterCommentService;

/** 工程级 M/D 注释编辑对话框;注释不包含实时寄存器值 */
class RegisterCommentDialog final : public QDialog
{
public:
/** 创建寄存器注释对话框并绑定注释服务 */
explicit RegisterCommentDialog(
RegisterCommentService &service,
QWidget *parent = nullptr);
/** 释放 Designer 界面对象 */
~RegisterCommentDialog() override;

private:
/** 重新加载注释列表,并尽量保持原选中地址 */
void reloadComments(const RegisterAddress *selected_address = nullptr);
/** 把当前选中地址的注释加载到编辑框 */
void loadSelectedComment();
/** 校验并保存当前地址的注释 */
void saveComment();
/** 删除当前选中地址的注释 */
void removeComment();
/** 从输入框读取寄存器地址 */
RegisterAddress addressFromInputs() const;
/** 返回列表中当前选中的寄存器地址,没有选择时返回空值 */
std::optional<RegisterAddress> selectedAddress() const;

/** Qt Designer 生成的界面对象 */
std::unique_ptr<Ui::RegisterCommentDialog> ui_;
/** 注释读写服务,不由对话框拥有 */
RegisterCommentService &service_;
};

+ 56
- 3
app/src/ui/runtime_monitor_widget.cpp Vedi File

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

#include <QSplitter>
#include <QComboBox>
#include <QPushButton>
#include <QSignalBlocker>
#include <QToolButton>

@@ -66,6 +67,10 @@ RuntimeMonitorWidget::RuntimeMonitorWidget(
register_monitor_service, ui_->monitorContainer);
free_monitor_widget_->setObjectName(QStringLiteral("freeMonitorWidget"));
ui_->monitorContainerLayout->addWidget(free_monitor_widget_);
ui_->logicNoticeLabel->setVisible(false);
ui_->runtimeModeComboBox->setVisible(false);
ui_->runtimePlcButton->setVisible(false);
ui_->runtimeDisconnectPlcButton->setVisible(false);
ui_->upperSplitter->setSizes({600, 600});
ui_->verticalSplitter->setSizes({440, 260});
connect(hmi_view_, &HmiEditorWidget::pageNavigationRequested,
@@ -94,6 +99,22 @@ RuntimeMonitorWidget::RuntimeMonitorWidget(
ui_->runtimeLogicComboBox->itemData(index).toString()));
}
});
connect(ui_->runtimeModeComboBox,
QOverload<int>::of(&QComboBox::currentIndexChanged),
this,
[this](int index)
{
if (user_runtime_mode_ && index >= 0)
{
emit modeChangeRequested(index == 0
? static_cast<int>(ApplicationMode::OfflineRunning)
: static_cast<int>(ApplicationMode::OnlineRunning));
}
});
connect(ui_->runtimePlcButton, &QPushButton::clicked,
this, &RuntimeMonitorWidget::plcConfigurationRequested);
connect(ui_->runtimeDisconnectPlcButton, &QPushButton::clicked,
this, &RuntimeMonitorWidget::plcDisconnectRequested);
}

RuntimeMonitorWidget::~RuntimeMonitorWidget() = default;
@@ -134,7 +155,7 @@ void RuntimeMonitorWidget::setMode(
{
const bool offline = mode == ApplicationMode::OfflineRunning;
const bool online = mode == ApplicationMode::OnlineRunning;
ui_->logicFrame->setVisible(offline);
ui_->logicFrame->setVisible(offline || online);
hmi_view_->setRuntimeActive(offline || online);
if (!offline && !online)
{
@@ -142,19 +163,50 @@ void RuntimeMonitorWidget::setMode(
}
setFreeMonitorWriteEnabled(offline
|| (online && plc_state == PlcConnectionState::Connected));
if (!offline)
if (!offline && !online)
{
logic_view_->clearRuntimeTrace();
latest_trace_.clear();
latest_fault_node_id_.clear();
has_logic_trace_ = false;
}
ui_->logicLabel->setText(
online ? tr("本地推算轨迹") : tr("梯形图运行状态"));
ui_->logicNoticeLabel->setVisible(online);
ui_->modeLabel->setText(
offline ? tr("离线仿真 · 虚拟 M/D")
: online ? tr("真机运行 · PLC 缓存") : tr("当前未运行"));
: online ? tr("真机运行 · PLC 缓存 · 本地推算轨迹")
: tr("当前未运行"));
if (user_runtime_mode_)
{
const QSignalBlocker blocker(ui_->runtimeModeComboBox);
ui_->runtimeModeComboBox->setCurrentIndex(
offline ? 0 : online ? 1 : -1);
const bool plc_configurable = plc_state == PlcConnectionState::Disconnected
|| plc_state == PlcConnectionState::Faulted;
ui_->runtimePlcButton->setEnabled(plc_configurable);
ui_->runtimeDisconnectPlcButton->setEnabled(
plc_state != PlcConnectionState::Disconnected);
}
free_monitor_widget_->refreshValues(mode, plc_state);
}

void RuntimeMonitorWidget::setUserRuntimeMode(bool enabled)
{
user_runtime_mode_ = enabled;
ui_->runtimeModeComboBox->setVisible(enabled);
ui_->runtimePlcButton->setVisible(enabled);
ui_->runtimeDisconnectPlcButton->setVisible(enabled);
ui_->exitRuntimeButton->setToolTip(
enabled ? tr("退出运行程序") : tr("返回编辑态"));
ui_->exitRuntimeButton->setAccessibleName(
enabled ? tr("退出运行程序") : tr("返回编辑态"));
if (enabled)
{
setMode(ApplicationMode::Editing, PlcConnectionState::Disconnected);
}
}

void RuntimeMonitorWidget::setHmiWriteEnabled(bool enabled)
{
hmi_view_->setRuntimeWriteEnabled(enabled);
@@ -168,6 +220,7 @@ void RuntimeMonitorWidget::setFreeMonitorWriteEnabled(bool enabled)
void RuntimeMonitorWidget::refreshValues(
ApplicationMode mode, PlcConnectionState plc_state)
{
// HMI、报警和自由监控共享同一个活动寄存器仓库
hmi_view_->refreshRuntimeValues();
free_monitor_widget_->refreshValues(mode, plc_state);
}


+ 44
- 0
app/src/ui/runtime_monitor_widget.h Vedi File

@@ -1,3 +1,11 @@
/**
* @file runtime_monitor_widget.h
* @brief 定义工程师运行监控主控件
* @version 1.0.0
* @author suyu
* @date 2026-08-22
*/

#pragma once

#include "domain/runtime_state.h"
@@ -24,11 +32,13 @@ class LogicEditorWidget;
class RegisterMonitorService;
class ProjectService;

/** 唯一的工程师运行监控投影,组合 HMI、梯形图轨迹和自由监控 */
class RuntimeMonitorWidget final : public QWidget
{
Q_OBJECT

public:
/** 创建组合 HMI、梯形图和自由监控的运行投影 */
RuntimeMonitorWidget(
HmiEditorService &hmi_editor_service,
HmiRuntimeService &hmi_runtime_service,
@@ -38,34 +48,68 @@ public:
AlarmService &alarm_service,
RegisterMonitorService &register_monitor_service,
QWidget *parent = nullptr);
/** 释放组合的运行监控子控件 */
~RuntimeMonitorWidget() override;

/** 将当前页面和控制逻辑绑定到运行监控界面 */
void setProjectObjects(const std::string &page_id, const std::string &logic_id);
/** 切换运行监控中显示的 HMI 页面 */
void setRuntimePage(const std::string &page_id);
/** 根据模式决定是否显示本地逻辑轨迹以及是否允许写入 */
void setMode(ApplicationMode mode, PlcConnectionState plc_state);
/** 切换专用用户运行版的简化操作栏 */
void setUserRuntimeMode(bool enabled);
/** 从寄存器仓库刷新 HMI 和自由监控中的当前值 */
void refreshValues(ApplicationMode mode, PlcConnectionState plc_state);
/** 设置 HMI 控件是否允许写入运行值 */
void setHmiWriteEnabled(bool enabled);
/** 设置自由监控是否允许写入寄存器 */
void setFreeMonitorWriteEnabled(bool enabled);
/** 接收离线轨迹或基于 PLC 缓存计算的真机本地推算轨迹 */
void setLogicTrace(const LogicTraceSnapshot &trace, const std::string &fault_node_id = {});
/** 返回自由监控子控件,供上层刷新或连接信号 */
FreeMonitorWidget *freeMonitorWidget() const;
/** 返回当前选中的控制逻辑标识 */
std::string selectedLogicId() const;

signals:
/** 页面跳转失败时发出可直接显示的错误信息 */
void navigationFailed(const QString &message);
/** 用户请求退出运行态 */
void exitRequested();
/** 用户在专用运行版中请求切换模式 */
void modeChangeRequested(int mode);
/** 用户请求打开 PLC 配置窗口 */
void plcConfigurationRequested();
/** 用户请求断开 PLC */
void plcDisconnectRequested();

private:
/** 显示指定的运行监控页面 */
void showRuntimePage(const std::string &page_id);
/** 选中指定控制逻辑并刷新梯形图投影 */
void selectRuntimeLogic(const std::string &logic_id);

/** Qt Designer 生成的界面对象 */
std::unique_ptr<Ui::RuntimeMonitorWidget> ui_;
/** 工程服务,不由控件拥有 */
ProjectService &project_service_;
/** HMI 页面导航服务,不由控件拥有 */
HmiNavigationService &hmi_navigation_service_;
/** 运行监控中的 HMI 视图 */
HmiEditorWidget *hmi_view_ = nullptr;
/** 运行监控中的梯形图视图 */
LogicEditorWidget *logic_view_ = nullptr;
/** 运行监控中的自由监控控件 */
FreeMonitorWidget *free_monitor_widget_ = nullptr;
/** 当前选中的控制逻辑标识 */
std::string selected_logic_id_;
/** 最近一次收到的本地逻辑执行轨迹 */
LogicTraceSnapshot latest_trace_;
/** 最近一次本地执行故障节点标识 */
std::string latest_fault_node_id_;
/** 是否已经收到过可显示的逻辑轨迹 */
bool has_logic_trace_ = false;
/** 是否显示专用用户运行版操作栏 */
bool user_runtime_mode_ = false;
};

+ 4
- 0
app/src/ui/runtime_monitor_widget.ui Vedi File

@@ -13,6 +13,9 @@
<item><widget class="QLabel" name="titleLabel"><property name="text"><string>运行监控</string></property><property name="styleSheet"><string notr="true">font-weight: 600;</string></property></widget></item>
<item><spacer name="headerSpacer"><property name="orientation"><enum>Qt::Horizontal</enum></property><property name="sizeHint" stdset="0"><size><width>40</width><height>20</height></size></property></spacer></item>
<item><widget class="QLabel" name="modeLabel"><property name="text"><string>当前未运行</string></property><property name="alignment"><set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set></property></widget></item>
<item><widget class="QComboBox" name="runtimeModeComboBox"><property name="minimumSize"><size><width>120</width><height>0</height></size></property><item><property name="text"><string>离线仿真</string></property></item><item><property name="text"><string>真机运行</string></property></item></widget></item>
<item><widget class="QPushButton" name="runtimePlcButton"><property name="text"><string>PLC 配置</string></property></widget></item>
<item><widget class="QPushButton" name="runtimeDisconnectPlcButton"><property name="text"><string>断开 PLC</string></property></widget></item>
<item><widget class="QToolButton" name="exitRuntimeButton"><property name="toolTip"><string>返回编辑态</string></property><property name="accessibleName"><string>返回编辑态</string></property><property name="text"><string>返回编辑态</string></property><property name="toolButtonStyle"><enum>Qt::ToolButtonIconOnly</enum></property></widget></item>
</layout>
</item>
@@ -46,6 +49,7 @@
<item><widget class="QComboBox" name="runtimeLogicComboBox"><property name="minimumSize"><size><width>150</width><height>0</height></size></property></widget></item>
</layout>
</item>
<item><widget class="QLabel" name="logicNoticeLabel"><property name="styleSheet"><string notr="true">color: #8a5a00;</string></property><property name="text"><string>真机模式:本地梯形图只读推算&#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>
</layout>
</widget>


+ 4
- 2
app/src/ui/runtime_monitor_window.cpp Vedi File

@@ -6,7 +6,9 @@
#include <QWidget>

RuntimeMonitorWindow::RuntimeMonitorWindow(QWidget *parent)
: QMainWindow(parent, Qt::Window),
: QMainWindow(
parent,
Qt::Window | Qt::CustomizeWindowHint | Qt::WindowTitleHint),
ui_(std::make_unique<Ui::RuntimeMonitorWindow>())
{
ui_->setupUi(this);
@@ -44,9 +46,9 @@ void RuntimeMonitorWindow::closeForApplicationExit()

void RuntimeMonitorWindow::closeEvent(QCloseEvent *event)
{
// 运行期间禁止通过系统关闭路径离开运行界面,只允许应用退出时关闭
if (runtime_active_ && !application_exit_)
{
emit exitRequested();
event->ignore();
return;
}


+ 19
- 4
app/src/ui/runtime_monitor_window.h Vedi File

@@ -1,3 +1,11 @@
/**
* @file runtime_monitor_window.h
* @brief 定义独立的工程师运行监控窗口
* @version 1.0.0
* @author suyu
* @date 2026-08-22
*/

#pragma once

#include <QMainWindow>
@@ -11,28 +19,35 @@ class RuntimeMonitorWindow;
class QCloseEvent;
class QWidget;

// 承载唯一工程师运行监控投影的独立顶层窗口
/** 承载唯一工程师运行监控投影的独立顶层窗口 */
class RuntimeMonitorWindow final : public QMainWindow
{
Q_OBJECT

public:
/** 创建运行监控顶层窗口 */
explicit RuntimeMonitorWindow(QWidget *parent = nullptr);
/** 释放窗口和 Designer 界面对象 */
~RuntimeMonitorWindow() override;

/** 将运行监控控件放入窗口中央区域 */
void setMonitorWidget(QWidget &widget);
/** 进入运行态时显示并最大化窗口 */
void showForRuntime();
/** 返回编辑态时隐藏窗口但保留唯一监控控件 */
void hideForEditing();
/** 应用退出时允许真正关闭窗口 */
void closeForApplicationExit();

signals:
void exitRequested();

protected:
/** 仅在应用退出时放行窗口关闭事件 */
void closeEvent(QCloseEvent *event) override;

private:
/** Qt Designer 生成的窗口界面对象 */
std::unique_ptr<Ui::RuntimeMonitorWindow> ui_;
/** 当前是否处于运行态显示阶段 */
bool runtime_active_ = false;
/** 应用是否正在退出,决定关闭事件是否真正销毁窗口 */
bool application_exit_ = false;
};

+ 109
- 15
app/src/ui/runtime_panel_controller.cpp Vedi File

@@ -98,21 +98,13 @@ void RuntimePanelController::configure()
runtime_exit_requester_();
}
});
QObject::connect(runtime_monitor_window_.get(),
&RuntimeMonitorWindow::exitRequested,
&parent_, [this]
{
if (runtime_exit_requester_)
{
runtime_exit_requester_();
}
});
QObject::connect(runtime_monitor_widget_->freeMonitorWidget(),
&FreeMonitorWidget::monitorAddressesChanged,
&parent_, [this]
{
runtime_mode_service_.setMonitorAddresses(
register_monitor_service_.addresses());
register_monitor_service_.pollAddresses(),
register_monitor_service_.float32Starts());
runtime_monitor_widget_->refreshValues(
runtime_mode_service_.mode(),
runtime_mode_service_.plcConnectionState());
@@ -129,6 +121,36 @@ void RuntimePanelController::configure()
status_reporter_(message, 5000);
output_reporter_(QObject::tr("页面导航失败:%1").arg(message));
});
QObject::connect(runtime_monitor_widget_,
&RuntimeMonitorWidget::modeChangeRequested,
&parent_,
[this](int mode)
{
if (mode_requester_)
{
mode_requester_(mode);
}
});
QObject::connect(runtime_monitor_widget_,
&RuntimeMonitorWidget::plcConfigurationRequested,
&parent_,
[this]
{
if (plc_configuration_requester_)
{
plc_configuration_requester_();
}
});
QObject::connect(runtime_monitor_widget_,
&RuntimeMonitorWidget::plcDisconnectRequested,
&parent_,
[this]
{
if (plc_disconnect_requester_)
{
plc_disconnect_requester_();
}
});
runtime_refresh_timer_ = new QTimer(&parent_);
runtime_refresh_timer_->setInterval(150);
QObject::connect(runtime_refresh_timer_, &QTimer::timeout,
@@ -142,6 +164,28 @@ void RuntimePanelController::configure()
&OfflineSimulationService::scanCompleted,
&parent_, [this] { handleScanCompleted(); },
Qt::QueuedConnection);
QObject::connect(&runtime_mode_service_.onlineLogicMonitorService(),
&OnlineLogicMonitorService::stateChanged,
&parent_, [this] { handleSimulationStateChanged(); });
QObject::connect(&runtime_mode_service_.onlineLogicMonitorService(),
&OnlineLogicMonitorService::scanCompleted,
&parent_, [this] { handleScanCompleted(); },
Qt::QueuedConnection);
}

void RuntimePanelController::configureUserRuntimeMode(
bool enabled,
std::function<void(int)> mode_requester,
std::function<void()> plc_configuration_requester,
std::function<void()> plc_disconnect_requester)
{
mode_requester_ = std::move(mode_requester);
plc_configuration_requester_ = std::move(plc_configuration_requester);
plc_disconnect_requester_ = std::move(plc_disconnect_requester);
if (runtime_monitor_widget_ != nullptr)
{
runtime_monitor_widget_->setUserRuntimeMode(enabled);
}
}

RuntimeMonitorWidget *RuntimePanelController::runtimeMonitorWidget() const
@@ -158,9 +202,13 @@ void RuntimePanelController::enterRuntime(
if (!runtime_session_active_)
{
runtime_monitor_widget_->setProjectObjects(page_id, logic_id);
// 运行窗口只创建一次,后续进入运行态复用同一个监控投影
runtime_session_active_ = true;
}
runtime_monitor_widget_->freeMonitorWidget()->reloadAddresses();
runtime_monitor_widget_->setMode(mode, plc_state);
runtime_monitor_window_->setWindowTitle(
fromUtf8(project_service_.project().metadata.name));
runtime_monitor_window_->showForRuntime();
}

@@ -201,6 +249,40 @@ void RuntimePanelController::updateSimulationUi(bool report_fault)
runtime_mode_service_.mode(),
runtime_mode_service_.plcConnectionState());
}
if (online)
{
switch (runtime_mode_service_.onlineLogicMonitorService().state())
{
case OnlineLogicMonitorState::Running:
{
executor_status_label_.setText(
QObject::tr("本地推算轨迹:运行(%1 次)")
.arg(runtime_mode_service_.onlineLogicMonitorService()
.successfulScanCount()));
return;
}
case OnlineLogicMonitorState::Faulted:
{
executor_status_label_.setText(QObject::tr("本地推算轨迹:故障"));
if (report_fault)
{
const LogicScanResult &error = runtime_mode_service_
.onlineLogicMonitorService().lastError();
const QString message = QObject::tr("真机本地轨迹异常:%1")
.arg(fromUtf8(error.message));
status_reporter_(message, 0);
output_reporter_(message);
}
return;
}
case OnlineLogicMonitorState::Stopped:
default:
{
executor_status_label_.setText(QObject::tr("本地推算轨迹:停止"));
return;
}
}
}
switch (state)
{
case SimulationState::Running:
@@ -273,6 +355,7 @@ void RuntimePanelController::updateSimulationUi(bool report_fault)

void RuntimePanelController::handleRuntimeTimer()
{
// 定时器只刷新投影;离线按定时器扫描,真机在完整 PLC 轮询后推算
if (runtime_mode_service_.mode() == ApplicationMode::Editing)
{
return;
@@ -295,13 +378,24 @@ void RuntimePanelController::handleSimulationStateChanged()

void RuntimePanelController::handleScanCompleted()
{
const ApplicationMode mode = runtime_mode_service_.mode();
const bool offline_running = mode == ApplicationMode::OfflineRunning
&& runtime_mode_service_.simulationState() == SimulationState::Running;
const bool online_running = mode == ApplicationMode::OnlineRunning
&& runtime_mode_service_.onlineLogicMonitorService().state()
== OnlineLogicMonitorState::Running;
// 排队信号可能晚于退出操作到达,编辑态不得恢复旧运行轨迹
if (!offline_running && !online_running)
{
return;
}
const std::string logic_id = current_logic_id_();
logic_editor_widget_.setRuntimeTrace(
runtime_mode_service_.offlineSimulationService()
.traceSnapshot().forLogic(logic_id));
const LogicTraceSnapshot &trace = offline_running
? runtime_mode_service_.offlineSimulationService().traceSnapshot()
: runtime_mode_service_.onlineLogicMonitorService().traceSnapshot();
logic_editor_widget_.setRuntimeTrace(trace.forLogic(logic_id));
if (runtime_monitor_widget_ != nullptr)
{
runtime_monitor_widget_->setLogicTrace(
runtime_mode_service_.offlineSimulationService().traceSnapshot());
runtime_monitor_widget_->setLogicTrace(trace);
}
}

+ 54
- 1
app/src/ui/runtime_panel_controller.h Vedi File

@@ -1,3 +1,11 @@
/**
* @file runtime_panel_controller.h
* @brief 定义运行监控面板控制器
* @version 1.0.0
* @author suyu
* @date 2026-08-22
*/

#pragma once

#include "domain/runtime_state.h"
@@ -25,15 +33,19 @@ class RuntimeMonitorWindow;
class QTimer;
class QWidget;

// 组织运行监控工作台、运行刷新定时器和离线执行器反馈
/** 组织运行监控窗口、刷新定时器和本地逻辑执行器反馈 */
class RuntimePanelController final
{
public:
/** 向状态栏报告一条带超时的消息 */
using StatusReporter = std::function<void(
const QString &message, int timeout_ms)>;
/** 向输出面板报告一条消息 */
using OutputReporter = std::function<void(const QString &message)>;
/** 请求上层退出当前运行态 */
using RuntimeExitRequester = std::function<void()>;

/** 创建运行面板控制器并注入运行模式、编辑器和监控窗口依赖 */
RuntimePanelController(
QWidget &parent,
RuntimeModeService &runtime_mode_service,
@@ -53,44 +65,85 @@ public:
StatusReporter status_reporter,
OutputReporter output_reporter,
RuntimeExitRequester runtime_exit_requester);
/** 停止刷新并释放控制器拥有的运行监控窗口 */
~RuntimePanelController();

/** 连接运行监控相关信号并完成初始配置 */
void configure();
/** 配置专用用户运行版的精简控制栏行为 */
void configureUserRuntimeMode(
bool enabled,
std::function<void(int)> mode_requester,
std::function<void()> plc_configuration_requester,
std::function<void()> plc_disconnect_requester);
/** 创建或复用唯一监控窗口,并把当前工程投影到运行态 */
void enterRuntime(
const std::string &page_id,
const std::string &logic_id,
ApplicationMode mode,
PlcConnectionState plc_state);
/** 停止刷新并隐藏监控窗口,离线服务的停止由运行模式服务负责 */
void leaveRuntime(ApplicationMode mode, PlcConnectionState plc_state);
/** 应用退出时关闭运行监控窗口并停止相关刷新 */
void closeForApplicationExit();
/** 根据当前模式的本地执行器状态刷新运行界面 */
void updateSimulationUi(bool report_fault);
/** 返回当前唯一的运行监控控件 */
RuntimeMonitorWidget *runtimeMonitorWidget() const;

private:
/** 处理运行刷新定时器到期事件 */
void handleRuntimeTimer();
/** 处理离线或真机只读轨迹执行器状态变化 */
void handleSimulationStateChanged();
/** 处理一次离线扫描完成事件 */
void handleScanCompleted();

/** 主窗口父对象,不由控制器拥有 */
QWidget &parent_;
/** 运行模式服务,不由控制器拥有 */
RuntimeModeService &runtime_mode_service_;
/** 工程服务,不由控制器拥有 */
ProjectService &project_service_;
/** HMI 编辑服务,不由控制器拥有 */
HmiEditorService &hmi_editor_service_;
/** HMI 运行服务,不由控制器拥有 */
HmiRuntimeService &hmi_runtime_service_;
/** 梯形图编辑服务,不由控制器拥有 */
LogicEditorService &logic_editor_service_;
/** HMI 页面导航服务,不由控制器拥有 */
HmiNavigationService &hmi_navigation_service_;
/** 报警服务,不由控制器拥有 */
AlarmService &alarm_service_;
/** 寄存器监控服务,不由控制器拥有 */
RegisterMonitorService &register_monitor_service_;
/** 主窗口中的 HMI 编辑器,不由控制器拥有 */
HmiEditorWidget &hmi_editor_widget_;
/** 主窗口中的梯形图编辑器,不由控制器拥有 */
LogicEditorWidget &logic_editor_widget_;
/** 显示当前本地执行器状态的标签 */
QLabel &executor_status_label_;
/** 获取当前控制逻辑标识的回调 */
std::function<std::string()> current_logic_id_;
/** 切换当前控制逻辑的回调 */
std::function<void(const std::string &)> select_logic_;
/** 显示控制逻辑属性的回调 */
std::function<void(const std::string &)> show_logic_properties_;
/** 向状态栏报告消息的回调 */
StatusReporter status_reporter_;
/** 向输出面板报告消息的回调 */
OutputReporter output_reporter_;
/** 请求主窗口退出运行态的回调 */
RuntimeExitRequester runtime_exit_requester_;
/** 唯一的运行监控顶层窗口 */
std::unique_ptr<RuntimeMonitorWindow> runtime_monitor_window_;
/** 运行监控窗口中的主控件,窗口拥有它 */
RuntimeMonitorWidget *runtime_monitor_widget_ = nullptr;
/** 定时刷新运行监控数据的计时器 */
QTimer *runtime_refresh_timer_ = nullptr;
/** 当前是否已经进入运行监控会话 */
bool runtime_session_active_ = false;
std::function<void(int)> mode_requester_;
std::function<void()> plc_configuration_requester_;
std::function<void()> plc_disconnect_requester_;
};

+ 0
- 33
app/src/ui/toolbar_icon_factory.cpp Vedi File

@@ -291,14 +291,6 @@ QPixmap renderIcon(UiIcon icon, int size)
painter.drawLine(QPointF(18, 7), QPointF(18, 17));
break;
}
case UiIcon::ProgressBar:
{
painter.drawRoundedRect(QRectF(2, 8, 20, 8), 2, 2);
painter.setPen(Qt::NoPen);
painter.setBrush(kAccent);
painter.drawRoundedRect(QRectF(4, 10, 11, 4), 1, 1);
break;
}
case UiIcon::Text:
{
painter.drawLine(QPointF(5, 5), QPointF(19, 5));
@@ -410,16 +402,6 @@ QPixmap renderIcon(UiIcon icon, int size)
drawContact(painter, QStringLiteral("N"), false);
break;
}
case UiIcon::TimerContact:
{
drawContact(painter, QStringLiteral("T"), false);
break;
}
case UiIcon::CounterContact:
{
drawContact(painter, QStringLiteral("C"), false);
break;
}
case UiIcon::Coil:
{
drawCoil(painter, {});
@@ -435,21 +417,6 @@ QPixmap renderIcon(UiIcon icon, int size)
drawCoil(painter, QStringLiteral("R"));
break;
}
case UiIcon::Ton:
{
drawInstructionBlock(painter, QStringLiteral("TON"));
break;
}
case UiIcon::Ctu:
{
drawInstructionBlock(painter, QStringLiteral("CTU"));
break;
}
case UiIcon::Ctd:
{
drawInstructionBlock(painter, QStringLiteral("CTD"));
break;
}
case UiIcon::Move:
{
painter.drawRect(QRectF(2, 6, 6, 12));


+ 57
- 49
app/src/ui/toolbar_icon_factory.h Vedi File

@@ -1,58 +1,66 @@
/**
* @file toolbar_icon_factory.h
* @brief 定义工具栏图标类型和图标创建函数
* @version 1.0.0
* @author suyu
* @date 2026-08-22
*/

#pragma once

#include <QIcon>

/** 工具栏中使用的语义图标类型 */
enum class UiIcon
{
Edit,
RunOffline,
RunOnline,
NewDocument,
Save,
SaveAs,
Open,
Undo,
Redo,
Exit,
PlcSettings,
Disconnect,
RegisterComments,
HmiButton,
Indicator,
NumericDisplay,
NumericInput,
ProgressBar,
Text,
PageJump,
AlarmList,
AlarmSettings,
Delete,
AddRung,
HorizontalWire,
VerticalWire,
ParallelBranch,
NormallyOpenContact,
NormallyClosedContact,
RisingEdgeContact,
FallingEdgeContact,
TimerContact,
CounterContact,
Coil,
SetCoil,
ResetCoil,
Ton,
Ctu,
Ctd,
Move,
Add,
Subtract,
Compare,
Comment,
More,
HmiPage,
Logic,
Runtime,
ClearList
Edit, // 编辑模式
RunOffline, // 离线运行
RunOnline, // 真机运行
NewDocument, // 新建工程
Save, // 保存工程
SaveAs, // 另存工程
Open, // 打开工程
Undo, // 撤销
Redo, // 重做
Exit, // 退出程序
PlcSettings, // PLC 连接设置
Disconnect, // 断开 PLC
RegisterComments, // 寄存器注释
HmiButton, // HMI 按钮
Indicator, // HMI 指示灯
NumericDisplay, // HMI 数值显示
NumericInput, // HMI 数值输入
Text, // HMI 文本
PageJump, // HMI 页面跳转
AlarmList, // HMI 报警列表
AlarmSettings, // 报警配置
Delete, // 删除
AddRung, // 新增网络
HorizontalWire, // 横线
VerticalWire, // 竖线
ParallelBranch, // 并联支路
NormallyOpenContact, // 常开触点
NormallyClosedContact, // 常闭触点
RisingEdgeContact, // 上升沿触点
FallingEdgeContact, // 下降沿触点
Coil, // 普通线圈
SetCoil, // 置位线圈
ResetCoil, // 复位线圈
Move, // MOVE 指令
Add, // ADD 指令
Subtract, // SUB 指令
Compare, // 比较指令
Comment, // 注释
More, // 更多操作
HmiPage, // HMI 页面
Logic, // 控制逻辑
Runtime, // 运行监控
ClearList // 清空列表
};

/**
* @brief 根据语义图标类型创建 Qt 图标
* @param icon 要创建的图标类型
* @return 创建好的 Qt 图标
*/
QIcon makeUiIcon(UiIcon icon);

Dato che sono stati cambiati molti file in questo diff, alcuni di essi non verranno mostrati

Caricamento…
Annulla
Salva