| @@ -76,6 +76,7 @@ HEADERS += \ | |||
| src/domain/hmi_model.h \ | |||
| src/domain/hmi_control_registry.h \ | |||
| src/domain/control_logic_model.h \ | |||
| src/domain/project_limits.h \ | |||
| src/domain/project_model.h \ | |||
| src/domain/runtime_state.h \ | |||
| src/domain/project_storage.h \ | |||
| @@ -1,5 +1,7 @@ | |||
| #include "alarm_model.h" | |||
| #include "project_limits.h" | |||
| namespace { | |||
| void setError(std::string *error, const std::string &message) | |||
| @@ -19,11 +21,21 @@ bool AlarmDefinition::validate(std::string *error) const | |||
| setError(error, "报警定义 ID 不能为空"); | |||
| return false; | |||
| } | |||
| if (id.size() > ProjectLimits::kMaximumIdBytes) | |||
| { | |||
| setError(error, "报警定义 ID 不能超过 128 个 UTF-8 字节"); | |||
| return false; | |||
| } | |||
| if (message.empty()) | |||
| { | |||
| setError(error, "报警文本不能为空"); | |||
| return false; | |||
| } | |||
| if (message.size() > ProjectLimits::kMaximumTextBytes) | |||
| { | |||
| setError(error, "报警文本不能超过 4096 个 UTF-8 字节"); | |||
| return false; | |||
| } | |||
| if (!address.isValid()) | |||
| { | |||
| setError(error, "报警定义使用了无效地址"); | |||
| @@ -1,5 +1,7 @@ | |||
| #include "control_logic_model.h" | |||
| #include "project_limits.h" | |||
| #include <algorithm> | |||
| #include <map> | |||
| #include <type_traits> | |||
| @@ -161,6 +163,12 @@ bool validateConfig(const CounterNodeConfig &config, std::string *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) | |||
| { | |||
| @@ -272,6 +280,131 @@ void appendValidAddress( | |||
| } | |||
| } | |||
| bool validateConditionExpression( | |||
| const ConditionExpression &expression, | |||
| std::size_t depth, | |||
| std::size_t *node_count, | |||
| int *columns, | |||
| int *rows, | |||
| std::string *error) | |||
| { | |||
| if (depth > ProjectLimits::kMaximumExpressionDepth) | |||
| { | |||
| setError(error, "条件表达式最多嵌套 20 层"); | |||
| return false; | |||
| } | |||
| ++*node_count; | |||
| if (*node_count > ProjectLimits::kMaximumExpressionNodesPerRung) | |||
| { | |||
| setError(error, "单个网络最多包含 4096 个条件表达式节点和叶子"); | |||
| return false; | |||
| } | |||
| if (expression.id.empty()) | |||
| { | |||
| setError(error, "条件表达式 ID 不能为空"); | |||
| return false; | |||
| } | |||
| if (expression.id.size() > ProjectLimits::kMaximumIdBytes) | |||
| { | |||
| setError(error, "条件表达式 ID 不能超过 128 个 UTF-8 字节"); | |||
| return false; | |||
| } | |||
| if (expression.kind == ConditionExpressionKind::Node) | |||
| { | |||
| if (!expression.node.has_value() || expression.wire.has_value() | |||
| || !expression.children.empty() || !expression.node->isCondition()) | |||
| { | |||
| setError(error, "条件叶节点必须包含一个条件节点且不能包含子表达式"); | |||
| return false; | |||
| } | |||
| *columns = 1; | |||
| *rows = 1; | |||
| return expression.node->validate(error); | |||
| } | |||
| if (expression.kind == ConditionExpressionKind::Wire) | |||
| { | |||
| if (expression.node.has_value() || !expression.wire.has_value() | |||
| || !expression.children.empty()) | |||
| { | |||
| setError(error, "横线叶节点必须包含横线配置且不能包含逻辑节点或子表达式"); | |||
| return false; | |||
| } | |||
| if (!expression.wire->validate(error)) | |||
| { | |||
| return false; | |||
| } | |||
| *columns = expression.wire->columnSpan; | |||
| *rows = 1; | |||
| return true; | |||
| } | |||
| if (expression.kind != ConditionExpressionKind::Series | |||
| && expression.kind != ConditionExpressionKind::Parallel) | |||
| { | |||
| setError(error, "条件表达式使用了不支持的类型"); | |||
| return false; | |||
| } | |||
| if (expression.node.has_value() || expression.wire.has_value() | |||
| || expression.children.size() < 2U) | |||
| { | |||
| setError(error, "串联和并联表达式至少需要两个子表达式"); | |||
| return false; | |||
| } | |||
| if (expression.children.size() > ProjectLimits::kMaximumExpressionChildren) | |||
| { | |||
| setError(error, "单个串联或并联容器最多包含 256 个子表达式"); | |||
| return false; | |||
| } | |||
| int total_columns = expression.kind == ConditionExpressionKind::Series ? 0 : 1; | |||
| int total_rows = expression.kind == ConditionExpressionKind::Parallel ? 0 : 1; | |||
| for (const ConditionExpression &child : expression.children) | |||
| { | |||
| if (child.kind == expression.kind) | |||
| { | |||
| setError(error, "同类型嵌套表达式必须先完成归一化"); | |||
| return false; | |||
| } | |||
| int child_columns = 0; | |||
| int child_rows = 0; | |||
| if (!validateConditionExpression( | |||
| child, depth + 1U, node_count, | |||
| &child_columns, &child_rows, error)) | |||
| { | |||
| return false; | |||
| } | |||
| if (expression.kind == ConditionExpressionKind::Series) | |||
| { | |||
| if (total_columns | |||
| > ProjectLimits::kMaximumConditionColumns - child_columns) | |||
| { | |||
| setError(error, "单个网络的条件区最多为 10 列,第 11 列固定用于输出指令"); | |||
| return false; | |||
| } | |||
| total_columns += child_columns; | |||
| total_rows = std::max(total_rows, child_rows); | |||
| } | |||
| else | |||
| { | |||
| total_columns = std::max(total_columns, child_columns); | |||
| if (total_rows > ProjectLimits::kMaximumLogicRows - child_rows) | |||
| { | |||
| setError(error, "单个网络的逻辑总行数最多为 256 行"); | |||
| return false; | |||
| } | |||
| total_rows += child_rows; | |||
| } | |||
| } | |||
| if (total_columns > ProjectLimits::kMaximumConditionColumns | |||
| || total_rows > ProjectLimits::kMaximumLogicRows) | |||
| { | |||
| setError(error, "单个网络最多使用 10 列条件、1 列输出和 256 行逻辑网格"); | |||
| return false; | |||
| } | |||
| *columns = total_columns; | |||
| *rows = total_rows; | |||
| return true; | |||
| } | |||
| } // namespace | |||
| TimerAddress::TimerAddress(int index) | |||
| @@ -444,6 +577,11 @@ bool LogicNode::validate(std::string *error) const | |||
| setError(error, "逻辑节点 ID 不能为空"); | |||
| return false; | |||
| } | |||
| if (id.size() > ProjectLimits::kMaximumIdBytes) | |||
| { | |||
| setError(error, "逻辑节点 ID 不能超过 128 个 UTF-8 字节"); | |||
| return false; | |||
| } | |||
| return std::visit( | |||
| [error](const auto &config) { return validateConfig(config, error); }, | |||
| config); | |||
| @@ -485,7 +623,7 @@ bool WireSegment::validate(std::string *error) const | |||
| { | |||
| if (columnSpan < kMinimumColumnSpan || columnSpan > kMaximumColumnSpan) | |||
| { | |||
| setError(error, "横线跨度必须在 1~256 列范围内"); | |||
| setError(error, "横线跨度必须在 1~10 列范围内"); | |||
| return false; | |||
| } | |||
| return true; | |||
| @@ -503,54 +641,11 @@ ConditionExpression ConditionExpression::fromWire( | |||
| bool ConditionExpression::validate(std::string *error) const | |||
| { | |||
| if (id.empty()) | |||
| { | |||
| setError(error, "条件表达式 ID 不能为空"); | |||
| return false; | |||
| } | |||
| if (kind == ConditionExpressionKind::Node) | |||
| { | |||
| if (!node.has_value() || wire.has_value() | |||
| || !children.empty() || !node->isCondition()) | |||
| { | |||
| setError(error, "条件叶节点必须包含一个条件节点且不能包含子表达式"); | |||
| return false; | |||
| } | |||
| return node->validate(error); | |||
| } | |||
| if (kind == ConditionExpressionKind::Wire) | |||
| { | |||
| if (node.has_value() || !wire.has_value() || !children.empty()) | |||
| { | |||
| setError(error, "横线叶节点必须包含横线配置且不能包含逻辑节点或子表达式"); | |||
| return false; | |||
| } | |||
| return wire->validate(error); | |||
| } | |||
| if (kind != ConditionExpressionKind::Series | |||
| && kind != ConditionExpressionKind::Parallel) | |||
| { | |||
| setError(error, "条件表达式使用了不支持的类型"); | |||
| return false; | |||
| } | |||
| if (node.has_value() || wire.has_value() || children.size() < 2U) | |||
| { | |||
| setError(error, "串联和并联表达式至少需要两个子表达式"); | |||
| return false; | |||
| } | |||
| for (const ConditionExpression &child : children) | |||
| { | |||
| if (child.kind == kind) | |||
| { | |||
| setError(error, "同类型嵌套表达式必须先完成归一化"); | |||
| return false; | |||
| } | |||
| if (!child.validate(error)) | |||
| { | |||
| return false; | |||
| } | |||
| } | |||
| return true; | |||
| std::size_t node_count = 0U; | |||
| int columns = 0; | |||
| int rows = 0; | |||
| return validateConditionExpression( | |||
| *this, 1U, &node_count, &columns, &rows, error); | |||
| } | |||
| bool ConditionExpression::validateForRunning(std::string *error) const | |||
| @@ -701,12 +796,12 @@ bool LadderRung::validateForRunning(std::string *error) const | |||
| { | |||
| return true; | |||
| } | |||
| if (!condition.has_value() || !output.has_value()) | |||
| if (!output.has_value()) | |||
| { | |||
| setError(error, "未完成的梯形图网络必须同时包含条件和输出节点"); | |||
| setError(error, "梯形图网络必须包含输出指令"); | |||
| return false; | |||
| } | |||
| if (!condition->validateForRunning(error)) | |||
| if (condition.has_value() && !condition->validateForRunning(error)) | |||
| { | |||
| return false; | |||
| } | |||
| @@ -725,6 +820,17 @@ bool LadderRung::validateStructure(std::string *error) const | |||
| setError(error, "梯形图网络 ID 和名称不能为空"); | |||
| return false; | |||
| } | |||
| if (id.size() > ProjectLimits::kMaximumIdBytes) | |||
| { | |||
| setError(error, "梯形图网络 ID 不能超过 128 个 UTF-8 字节"); | |||
| return false; | |||
| } | |||
| if (name.size() > ProjectLimits::kMaximumTextBytes | |||
| || comment.size() > ProjectLimits::kMaximumTextBytes) | |||
| { | |||
| setError(error, "梯形图网络名称和注释不能超过 4096 个 UTF-8 字节"); | |||
| return false; | |||
| } | |||
| std::vector<std::string> node_ids; | |||
| if (condition.has_value()) | |||
| { | |||
| @@ -780,6 +886,21 @@ bool ControlLogic::validateStructure(std::string *error) const | |||
| setError(error, "控制逻辑 ID 和名称不能为空"); | |||
| return false; | |||
| } | |||
| if (id.size() > ProjectLimits::kMaximumIdBytes) | |||
| { | |||
| setError(error, "控制逻辑 ID 不能超过 128 个 UTF-8 字节"); | |||
| return false; | |||
| } | |||
| if (name.size() > ProjectLimits::kMaximumTextBytes) | |||
| { | |||
| setError(error, "控制逻辑名称不能超过 4096 个 UTF-8 字节"); | |||
| return false; | |||
| } | |||
| if (rungs.size() > ProjectLimits::kMaximumRungsPerLogic) | |||
| { | |||
| setError(error, "单组控制逻辑最多包含 1024 个网络"); | |||
| return false; | |||
| } | |||
| if (hasDuplicateId(rungs)) | |||
| { | |||
| setError(error, "同一控制逻辑内的网络 ID 必须唯一"); | |||
| @@ -1,5 +1,6 @@ | |||
| #pragma once | |||
| #include "project_limits.h" | |||
| #include "register_address.h" | |||
| #include <cstdint> | |||
| @@ -212,7 +213,8 @@ enum class ConditionExpressionKind | |||
| struct WireSegment | |||
| { | |||
| static constexpr int kMinimumColumnSpan = 1; | |||
| static constexpr int kMaximumColumnSpan = 256; | |||
| static constexpr int kMaximumColumnSpan = | |||
| ProjectLimits::kMaximumConditionColumns; | |||
| int columnSpan = 1; | |||
| @@ -249,7 +251,7 @@ void collectConditionNodes( | |||
| void collectConditionExpressionIds( | |||
| const ConditionExpression &expression, std::vector<std::string> *ids); | |||
| // 一个网络包含一棵结构化条件表达式,输出指令固定在最右侧 | |||
| // 条件表达式为空时表示恒真网络,输出指令固定在最右侧 | |||
| struct LadderRung | |||
| { | |||
| std::string id; | |||
| @@ -1,6 +1,7 @@ | |||
| #include "hmi_model.h" | |||
| #include "hmi_control_registry.h" | |||
| #include "project_limits.h" | |||
| #include <algorithm> | |||
| @@ -47,9 +48,26 @@ bool HmiControl::validate(std::string *error) const | |||
| setError(error, "HMI 控件 ID 不能为空"); | |||
| return false; | |||
| } | |||
| if (bounds.width <= 0 || bounds.height <= 0) | |||
| if (id.size() > ProjectLimits::kMaximumIdBytes) | |||
| { | |||
| setError(error, "HMI 控件的宽度和高度必须大于 0"); | |||
| setError(error, "HMI 控件 ID 不能超过 128 个 UTF-8 字节"); | |||
| return false; | |||
| } | |||
| if (text.size() > ProjectLimits::kMaximumTextBytes) | |||
| { | |||
| setError(error, "HMI 控件文本不能超过 4096 个 UTF-8 字节"); | |||
| return false; | |||
| } | |||
| if (bounds.width <= 0 || bounds.height <= 0 | |||
| || bounds.width > ProjectLimits::kMaximumHmiControlWidth | |||
| || bounds.height > ProjectLimits::kMaximumHmiControlHeight) | |||
| { | |||
| setError(error, "HMI 控件宽度和高度必须在 1~8192 范围内"); | |||
| return false; | |||
| } | |||
| if (properties.size() > ProjectLimits::kMaximumHmiProperties) | |||
| { | |||
| setError(error, "单个 HMI 控件最多保存 64 对扩展属性"); | |||
| return false; | |||
| } | |||
| for (const auto &property : properties) | |||
| @@ -59,6 +77,16 @@ bool HmiControl::validate(std::string *error) const | |||
| setError(error, "HMI 控件属性名称不能为空"); | |||
| return false; | |||
| } | |||
| if (property.first.size() > ProjectLimits::kMaximumPropertyKeyBytes) | |||
| { | |||
| setError(error, "HMI 控件属性名称不能超过 128 个 UTF-8 字节"); | |||
| return false; | |||
| } | |||
| if (property.second.size() > ProjectLimits::kMaximumPropertyValueBytes) | |||
| { | |||
| setError(error, "HMI 控件属性值不能超过 4096 个 UTF-8 字节"); | |||
| return false; | |||
| } | |||
| } | |||
| const std::optional<RegisterArea> binding_area = | |||
| hmiBindingArea(descriptor->bindingKind); | |||
| @@ -92,6 +120,11 @@ bool HmiControl::validate(std::string *error) const | |||
| setError(error, "页面跳转控件缺少跳转配置"); | |||
| return false; | |||
| } | |||
| if (pageJump->targetPageId.size() > ProjectLimits::kMaximumIdBytes) | |||
| { | |||
| setError(error, "页面跳转目标 ID 不能超过 128 个 UTF-8 字节"); | |||
| return false; | |||
| } | |||
| } | |||
| else if (pageJump.has_value()) | |||
| { | |||
| @@ -153,9 +186,26 @@ bool HmiPage::validate(std::string *error) const | |||
| setError(error, "HMI 页面 ID 和名称不能为空"); | |||
| return false; | |||
| } | |||
| if (width <= 0 || height <= 0) | |||
| if (id.size() > ProjectLimits::kMaximumIdBytes) | |||
| { | |||
| setError(error, "HMI 页面 ID 不能超过 128 个 UTF-8 字节"); | |||
| return false; | |||
| } | |||
| if (name.size() > ProjectLimits::kMaximumTextBytes) | |||
| { | |||
| setError(error, "HMI 页面名称不能超过 4096 个 UTF-8 字节"); | |||
| return false; | |||
| } | |||
| if (width <= 0 || height <= 0 | |||
| || width > ProjectLimits::kMaximumHmiPageWidth | |||
| || height > ProjectLimits::kMaximumHmiPageHeight) | |||
| { | |||
| setError(error, "HMI 页面宽度和高度必须在 1~8192 范围内"); | |||
| return false; | |||
| } | |||
| if (controls.size() > ProjectLimits::kMaximumHmiControlsPerPage) | |||
| { | |||
| setError(error, "HMI 页面尺寸必须大于 0"); | |||
| setError(error, "单个 HMI 页面最多包含 512 个控件"); | |||
| return false; | |||
| } | |||
| @@ -0,0 +1,52 @@ | |||
| #pragma once | |||
| #include <cstddef> | |||
| 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 kMaximumPropertyKeyBytes = 128U; | |||
| constexpr std::size_t kMaximumPropertyValueBytes = 4096U; | |||
| constexpr int kMaximumHmiPageWidth = 8192; | |||
| constexpr int kMaximumHmiPageHeight = 8192; | |||
| constexpr int kMaximumHmiControlWidth = 8192; | |||
| constexpr int kMaximumHmiControlHeight = 8192; | |||
| 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 | |||
| @@ -1,5 +1,7 @@ | |||
| #include "project_model.h" | |||
| #include "project_limits.h" | |||
| #include <algorithm> | |||
| #include <cctype> | |||
| @@ -76,6 +78,11 @@ bool RegisterComment::validate(std::string *error) const | |||
| setError(error, "软元件注释内容不能为空"); | |||
| return false; | |||
| } | |||
| if (text.size() > ProjectLimits::kMaximumTextBytes) | |||
| { | |||
| setError(error, "软元件注释不能超过 4096 个 UTF-8 字节"); | |||
| return false; | |||
| } | |||
| return true; | |||
| } | |||
| @@ -98,6 +105,42 @@ bool Project::validate(std::string *error) const | |||
| setError(error, "工程 ID、名称和格式版本不能为空"); | |||
| return false; | |||
| } | |||
| if (metadata.id.size() > ProjectLimits::kMaximumIdBytes | |||
| || metadata.formatVersion.size() > ProjectLimits::kMaximumIdBytes) | |||
| { | |||
| setError(error, "工程 ID 和格式版本不能超过 128 个 UTF-8 字节"); | |||
| return false; | |||
| } | |||
| if (metadata.name.size() > ProjectLimits::kMaximumTextBytes) | |||
| { | |||
| setError(error, "工程名称不能超过 4096 个 UTF-8 字节"); | |||
| return false; | |||
| } | |||
| if (initialHmiPageId.size() > ProjectLimits::kMaximumIdBytes) | |||
| { | |||
| setError(error, "初始 HMI 页面 ID 不能超过 128 个 UTF-8 字节"); | |||
| return false; | |||
| } | |||
| if (hmiPages.size() > ProjectLimits::kMaximumHmiPages) | |||
| { | |||
| setError(error, "单个工程最多包含 128 个 HMI 页面"); | |||
| return false; | |||
| } | |||
| if (alarmDefinitions.size() > ProjectLimits::kMaximumAlarmDefinitions) | |||
| { | |||
| setError(error, "单个工程最多包含 512 条报警定义"); | |||
| return false; | |||
| } | |||
| if (registerComments.size() > ProjectLimits::kMaximumRegisterComments) | |||
| { | |||
| setError(error, "单个工程最多包含 8002 条软元件注释"); | |||
| return false; | |||
| } | |||
| if (controlLogics.size() > ProjectLimits::kMaximumControlLogics) | |||
| { | |||
| setError(error, "单个工程最多包含 128 组控制逻辑"); | |||
| return false; | |||
| } | |||
| if (containsDuplicateId(hmiPages)) | |||
| { | |||
| setError(error, "工程内的 HMI 页面 ID 必须唯一"); | |||
| @@ -203,6 +246,57 @@ bool Project::validate(std::string *error) const | |||
| return false; | |||
| } | |||
| } | |||
| std::vector<RegisterAddress> poll_addresses; | |||
| for (const HmiPage &page : hmiPages) | |||
| { | |||
| for (const HmiControl &control : page.controls) | |||
| { | |||
| if (control.binding.has_value()) | |||
| { | |||
| poll_addresses.push_back(*control.binding); | |||
| } | |||
| } | |||
| } | |||
| for (const AlarmDefinition &definition : alarmDefinitions) | |||
| { | |||
| poll_addresses.push_back(definition.address); | |||
| } | |||
| for (const ControlLogic &logic : controlLogics) | |||
| { | |||
| 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, &poll_addresses); | |||
| } | |||
| } | |||
| } | |||
| std::sort( | |||
| poll_addresses.begin(), poll_addresses.end(), | |||
| [](const RegisterAddress &left, const RegisterAddress &right) | |||
| { | |||
| return left.area() == right.area() | |||
| ? left.index() < right.index() | |||
| : left.area() == RegisterArea::M; | |||
| }); | |||
| poll_addresses.erase( | |||
| std::unique(poll_addresses.begin(), poll_addresses.end()), | |||
| poll_addresses.end()); | |||
| if (poll_addresses.size() > ProjectLimits::kMaximumPollAddresses) | |||
| { | |||
| setError(error, "工程中需要 PLC 轮询的去重 M/D 地址最多为 1024 个"); | |||
| return false; | |||
| } | |||
| return true; | |||
| } | |||
| @@ -1,6 +1,7 @@ | |||
| #include "json_project_storage.h" | |||
| #include "domain/hmi_control_registry.h" | |||
| #include "domain/project_limits.h" | |||
| #include <QFile> | |||
| #include <QJsonArray> | |||
| @@ -81,7 +82,8 @@ bool readString( | |||
| const char *field, | |||
| const std::string &context, | |||
| std::string *value, | |||
| ParseState *state) | |||
| ParseState *state, | |||
| std::size_t maximum_bytes = ProjectLimits::kMaximumTextBytes) | |||
| { | |||
| QJsonValue json_value; | |||
| if (!readValue(object, field, context, &json_value, state)) | |||
| @@ -95,6 +97,12 @@ bool readString( | |||
| fieldPath(context, field) + " 必须是字符串"); | |||
| } | |||
| *value = toUtf8(json_value.toString()); | |||
| if (value->size() > maximum_bytes) | |||
| { | |||
| return state->fail( | |||
| ProjectStorageError::InvalidField, | |||
| fieldPath(context, field) + " 超出允许的 UTF-8 字节长度"); | |||
| } | |||
| return true; | |||
| } | |||
| @@ -185,7 +193,8 @@ bool readArray( | |||
| const char *field, | |||
| const std::string &context, | |||
| QJsonArray *value, | |||
| ParseState *state) | |||
| ParseState *state, | |||
| int maximum_count = std::numeric_limits<int>::max()) | |||
| { | |||
| QJsonValue json_value; | |||
| if (!readValue(object, field, context, &json_value, state)) | |||
| @@ -199,6 +208,12 @@ bool readArray( | |||
| fieldPath(context, field) + " 必须是数组"); | |||
| } | |||
| *value = json_value.toArray(); | |||
| if (value->size() > maximum_count) | |||
| { | |||
| return state->fail( | |||
| ProjectStorageError::InvalidField, | |||
| fieldPath(context, field) + " 的元素数量超过上限"); | |||
| } | |||
| return true; | |||
| } | |||
| @@ -535,7 +550,9 @@ bool parseAlarmDefinition( | |||
| QJsonObject address; | |||
| std::string condition; | |||
| int threshold = 0; | |||
| if (!readString(object, "id", context, &definition->id, state) | |||
| if (!readString( | |||
| object, "id", context, &definition->id, state, | |||
| ProjectLimits::kMaximumIdBytes) | |||
| || !readObject(object, "address", context, &address, state) | |||
| || !readString(object, "condition", context, &condition, state) | |||
| || !readInt( | |||
| @@ -604,16 +621,16 @@ bool parseBounds( | |||
| object, | |||
| "x", | |||
| context, | |||
| std::numeric_limits<int>::min(), | |||
| std::numeric_limits<int>::max(), | |||
| 0, | |||
| ProjectLimits::kMaximumHmiPageWidth, | |||
| &bounds->x, | |||
| state) | |||
| && readInt( | |||
| object, | |||
| "y", | |||
| context, | |||
| std::numeric_limits<int>::min(), | |||
| std::numeric_limits<int>::max(), | |||
| 0, | |||
| ProjectLimits::kMaximumHmiPageHeight, | |||
| &bounds->y, | |||
| state) | |||
| && readInt( | |||
| @@ -621,7 +638,7 @@ bool parseBounds( | |||
| "width", | |||
| context, | |||
| 1, | |||
| std::numeric_limits<int>::max(), | |||
| ProjectLimits::kMaximumHmiControlWidth, | |||
| &bounds->width, | |||
| state) | |||
| && readInt( | |||
| @@ -629,7 +646,7 @@ bool parseBounds( | |||
| "height", | |||
| context, | |||
| 1, | |||
| std::numeric_limits<int>::max(), | |||
| ProjectLimits::kMaximumHmiControlHeight, | |||
| &bounds->height, | |||
| state); | |||
| } | |||
| @@ -651,6 +668,12 @@ bool parseProperties( | |||
| std::map<std::string, std::string> *properties, | |||
| ParseState *state) | |||
| { | |||
| if (object.size() > static_cast<int>(ProjectLimits::kMaximumHmiProperties)) | |||
| { | |||
| return state->fail( | |||
| ProjectStorageError::InvalidField, | |||
| "单个 HMI 控件最多保存 64 对扩展属性"); | |||
| } | |||
| for (auto current = object.constBegin(); current != object.constEnd(); ++current) | |||
| { | |||
| if (!current.value().isString()) | |||
| @@ -659,7 +682,16 @@ bool parseProperties( | |||
| ProjectStorageError::InvalidField, | |||
| "HMI 控件属性值必须是字符串"); | |||
| } | |||
| properties->emplace(toUtf8(current.key()), toUtf8(current.value().toString())); | |||
| const std::string key = toUtf8(current.key()); | |||
| const std::string value = toUtf8(current.value().toString()); | |||
| if (key.empty() || key.size() > ProjectLimits::kMaximumPropertyKeyBytes | |||
| || value.size() > ProjectLimits::kMaximumPropertyValueBytes) | |||
| { | |||
| return state->fail( | |||
| ProjectStorageError::InvalidField, | |||
| "HMI 控件属性名称最多 128 字节,属性值最多 4096 字节"); | |||
| } | |||
| properties->emplace(key, value); | |||
| } | |||
| return true; | |||
| } | |||
| @@ -718,7 +750,9 @@ bool parseHmiControl( | |||
| QJsonObject bounds; | |||
| QJsonObject properties; | |||
| QJsonValue binding; | |||
| if (!readString(object, "id", context, &control->id, state) | |||
| if (!readString( | |||
| object, "id", context, &control->id, state, | |||
| ProjectLimits::kMaximumIdBytes) | |||
| || !readString(object, "type", context, &type_text, state) | |||
| || !readObject(object, "bounds", context, &bounds, state) | |||
| || !readString(object, "text", context, &control->text, state) | |||
| @@ -751,7 +785,9 @@ bool parseHmiControl( | |||
| if (control->type == HmiControlType::PageJump) | |||
| { | |||
| std::string target_page_id; | |||
| if (!readString(object, "targetPageId", context, &target_page_id, state)) | |||
| if (!readString( | |||
| object, "targetPageId", context, &target_page_id, state, | |||
| ProjectLimits::kMaximumIdBytes)) | |||
| { | |||
| return false; | |||
| } | |||
| @@ -836,13 +872,19 @@ bool parseHmiPage( | |||
| ParseState *state) | |||
| { | |||
| QJsonArray controls; | |||
| if (!readString(object, "id", context, &page->id, state) | |||
| if (!readString( | |||
| object, "id", context, &page->id, state, | |||
| ProjectLimits::kMaximumIdBytes) | |||
| || !readString(object, "name", context, &page->name, state) | |||
| || !readInt(object, "width", context, 1, std::numeric_limits<int>::max(), | |||
| || !readInt(object, "width", context, 1, | |||
| ProjectLimits::kMaximumHmiPageWidth, | |||
| &page->width, state) | |||
| || !readInt(object, "height", context, 1, std::numeric_limits<int>::max(), | |||
| || !readInt(object, "height", context, 1, | |||
| ProjectLimits::kMaximumHmiPageHeight, | |||
| &page->height, state) | |||
| || !readArray(object, "controls", context, &controls, state)) | |||
| || !readArray( | |||
| object, "controls", context, &controls, state, | |||
| static_cast<int>(ProjectLimits::kMaximumHmiControlsPerPage))) | |||
| { | |||
| return false; | |||
| } | |||
| @@ -1452,7 +1494,9 @@ bool parseLogicNode( | |||
| ParseState *state) | |||
| { | |||
| QJsonObject config; | |||
| if (!readString(object, "id", context, &node->id, state) | |||
| if (!readString( | |||
| object, "id", context, &node->id, state, | |||
| ProjectLimits::kMaximumIdBytes) | |||
| || !readBool(object, "configured", context, &node->configured, state) | |||
| || !readObject(object, "config", context, &config, state) | |||
| || !parseNodeConfig(config, context + ".config", &node->config, state)) | |||
| @@ -1507,10 +1551,27 @@ bool parseConditionExpression( | |||
| const QJsonObject &object, | |||
| const std::string &context, | |||
| ConditionExpression *expression, | |||
| ParseState *state) | |||
| ParseState *state, | |||
| std::size_t depth, | |||
| std::size_t *node_count) | |||
| { | |||
| if (depth > ProjectLimits::kMaximumExpressionDepth) | |||
| { | |||
| return state->fail( | |||
| ProjectStorageError::InvalidField, | |||
| context + " 的嵌套深度超过 20 层"); | |||
| } | |||
| ++*node_count; | |||
| if (*node_count > ProjectLimits::kMaximumExpressionNodesPerRung) | |||
| { | |||
| return state->fail( | |||
| ProjectStorageError::InvalidField, | |||
| context + " 的表达式节点总数超过 4096 个"); | |||
| } | |||
| std::string kind; | |||
| if (!readString(object, "id", context, &expression->id, state) | |||
| if (!readString( | |||
| object, "id", context, &expression->id, state, | |||
| ProjectLimits::kMaximumIdBytes) | |||
| || !readString(object, "kind", context, &kind, state)) | |||
| { | |||
| return false; | |||
| @@ -1556,7 +1617,9 @@ bool parseConditionExpression( | |||
| context + ".kind 必须是 node、wire、series 或 parallel"); | |||
| } | |||
| QJsonArray children; | |||
| if (!readArray(object, "children", context, &children, state)) | |||
| if (!readArray( | |||
| object, "children", context, &children, state, | |||
| static_cast<int>(ProjectLimits::kMaximumExpressionChildren))) | |||
| { | |||
| return false; | |||
| } | |||
| @@ -1576,7 +1639,9 @@ bool parseConditionExpression( | |||
| children.at(index).toObject(), | |||
| context + ".children[" + std::to_string(index) + ']', | |||
| &child, | |||
| state)) | |||
| state, | |||
| depth + 1U, | |||
| node_count)) | |||
| { | |||
| return false; | |||
| } | |||
| @@ -1611,7 +1676,9 @@ bool parseLadderRung( | |||
| { | |||
| QJsonValue condition; | |||
| QJsonValue output; | |||
| if (!readString(object, "id", context, &rung->id, state) | |||
| if (!readString( | |||
| object, "id", context, &rung->id, state, | |||
| ProjectLimits::kMaximumIdBytes) | |||
| || !readString(object, "name", context, &rung->name, state) | |||
| || !readString(object, "comment", context, &rung->comment, state) | |||
| || !readValue(object, "output", context, &output, state)) | |||
| @@ -1635,8 +1702,10 @@ bool parseLadderRung( | |||
| else | |||
| { | |||
| ConditionExpression parsed_condition; | |||
| std::size_t expression_node_count = 0U; | |||
| if (!parseConditionExpression( | |||
| condition.toObject(), context + ".condition", &parsed_condition, state)) | |||
| condition.toObject(), context + ".condition", &parsed_condition, | |||
| state, 1U, &expression_node_count)) | |||
| { | |||
| return false; | |||
| } | |||
| @@ -1684,10 +1753,14 @@ bool parseControlLogic( | |||
| ParseState *state) | |||
| { | |||
| QJsonArray rungs; | |||
| if (!readString(object, "id", context, &logic->id, state) | |||
| if (!readString( | |||
| object, "id", context, &logic->id, state, | |||
| ProjectLimits::kMaximumIdBytes) | |||
| || !readString(object, "name", context, &logic->name, state) | |||
| || !readBool(object, "enabled", context, &logic->enabled, state) | |||
| || !readArray(object, "rungs", context, &rungs, state)) | |||
| || !readArray( | |||
| object, "rungs", context, &rungs, state, | |||
| static_cast<int>(ProjectLimits::kMaximumRungsPerLogic))) | |||
| { | |||
| return false; | |||
| } | |||
| @@ -1767,7 +1840,8 @@ bool parseProject( | |||
| "formatVersion", | |||
| "project", | |||
| &project->metadata.formatVersion, | |||
| state)) | |||
| state, | |||
| ProjectLimits::kMaximumIdBytes)) | |||
| { | |||
| return false; | |||
| } | |||
| @@ -1779,14 +1853,26 @@ bool parseProject( | |||
| "不支持的工程格式版本:" + project->metadata.formatVersion); | |||
| } | |||
| if (!readString(object, "id", "project", &project->metadata.id, state) | |||
| if (!readString( | |||
| object, "id", "project", &project->metadata.id, state, | |||
| ProjectLimits::kMaximumIdBytes) | |||
| || !readString(object, "name", "project", &project->metadata.name, state) | |||
| || !readArray(object, "hmiPages", "project", &pages, state) | |||
| || !readString(object, "initialHmiPageId", "project", | |||
| &project->initialHmiPageId, state) | |||
| || !readArray(object, "alarmDefinitions", "project", &alarms, state) | |||
| || !readArray(object, "registerComments", "project", ®ister_comments, state) | |||
| || !readArray(object, "controlLogics", "project", &logics, state)) | |||
| || !readArray( | |||
| object, "hmiPages", "project", &pages, state, | |||
| static_cast<int>(ProjectLimits::kMaximumHmiPages)) | |||
| || !readString( | |||
| object, "initialHmiPageId", "project", | |||
| &project->initialHmiPageId, state, | |||
| ProjectLimits::kMaximumIdBytes) | |||
| || !readArray( | |||
| object, "alarmDefinitions", "project", &alarms, state, | |||
| static_cast<int>(ProjectLimits::kMaximumAlarmDefinitions)) | |||
| || !readArray( | |||
| object, "registerComments", "project", ®ister_comments, state, | |||
| static_cast<int>(ProjectLimits::kMaximumRegisterComments)) | |||
| || !readArray( | |||
| object, "controlLogics", "project", &logics, state, | |||
| static_cast<int>(ProjectLimits::kMaximumControlLogics))) | |||
| { | |||
| return false; | |||
| } | |||
| @@ -1913,6 +1999,14 @@ ProjectSaveResult JsonProjectStorage::save( | |||
| const QByteArray data = QJsonDocument(serializeProject(project)).toJson( | |||
| QJsonDocument::Indented); | |||
| if (static_cast<std::size_t>(data.size()) | |||
| > ProjectLimits::kMaximumProjectFileBytes) | |||
| { | |||
| file.cancelWriting(); | |||
| return {false, | |||
| ProjectStorageError::InvalidProject, | |||
| "工程 JSON 文件不能超过 16 MiB"}; | |||
| } | |||
| // 短写入也视为失败,并取消临时文件提交 | |||
| if (file.write(data) != data.size()) | |||
| { | |||
| @@ -1937,8 +2031,19 @@ ProjectLoadResult JsonProjectStorage::load(const std::string &file_path) | |||
| ProjectStorageError::FileOpenFailed, | |||
| toUtf8(file.errorString())}; | |||
| } | |||
| if (file.size() < 0 | |||
| || static_cast<quint64>(file.size()) | |||
| > static_cast<quint64>(ProjectLimits::kMaximumProjectFileBytes)) | |||
| { | |||
| return {false, | |||
| {}, | |||
| ProjectStorageError::InvalidJson, | |||
| "工程 JSON 文件不能超过 16 MiB"}; | |||
| } | |||
| const QByteArray data = file.readAll(); | |||
| // 最多只读到上限加 1 字节,避免文件属性检查后文件变大导致无限制分配 | |||
| const QByteArray data = file.read( | |||
| static_cast<qint64>(ProjectLimits::kMaximumProjectFileBytes) + 1); | |||
| if (file.error() != QFileDevice::NoError) | |||
| { | |||
| return {false, | |||
| @@ -1946,6 +2051,14 @@ ProjectLoadResult JsonProjectStorage::load(const std::string &file_path) | |||
| ProjectStorageError::FileReadFailed, | |||
| toUtf8(file.errorString())}; | |||
| } | |||
| if (static_cast<std::size_t>(data.size()) | |||
| > ProjectLimits::kMaximumProjectFileBytes) | |||
| { | |||
| return {false, | |||
| {}, | |||
| ProjectStorageError::InvalidJson, | |||
| "工程 JSON 文件不能超过 16 MiB"}; | |||
| } | |||
| // 顶层必须是 JSON 对象,数组或标量不能表示完整工程 | |||
| QJsonParseError parse_error; | |||
| @@ -15,7 +15,6 @@ | |||
| namespace { | |||
| constexpr int kMaximumReadCount = 120; | |||
| constexpr int kRecoveryProbeIntervalMs = 2000; | |||
| std::string toUtf8(const QString &value) | |||
| @@ -42,6 +41,67 @@ bool isReadingState(PlcConnectionState state) | |||
| || state == PlcConnectionState::Recovering; | |||
| } | |||
| bool normalizePollAddresses( | |||
| const std::vector<RegisterAddress> &addresses, | |||
| std::vector<RegisterAddress> *normalized, | |||
| std::string *error) | |||
| { | |||
| *normalized = addresses; | |||
| if (std::any_of( | |||
| normalized->cbegin(), normalized->cend(), | |||
| [](const RegisterAddress &address) { return !address.isValid(); })) | |||
| { | |||
| *error = "PLC 轮询地址中包含无效的 M/D 地址"; | |||
| return false; | |||
| } | |||
| 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) | |||
| { | |||
| *error = "PLC 轮询的去重 M/D 地址最多为 1024 个"; | |||
| return false; | |||
| } | |||
| return true; | |||
| } | |||
| std::size_t pollBlockCount(const std::vector<RegisterAddress> &addresses) | |||
| { | |||
| if (addresses.empty()) | |||
| { | |||
| return 2U; | |||
| } | |||
| std::size_t blocks = 0U; | |||
| RegisterArea current_area = RegisterArea::M; | |||
| int start_address = 0; | |||
| int count = 0; | |||
| for (const RegisterAddress &address : addresses) | |||
| { | |||
| if (count == 0 | |||
| || address.area() != current_area | |||
| || address.index() > start_address + count | |||
| || count >= ProjectLimits::kMaximumModbusReadCount) | |||
| { | |||
| ++blocks; | |||
| current_area = address.area(); | |||
| start_address = address.index(); | |||
| count = 1; | |||
| } | |||
| else | |||
| { | |||
| count = address.index() - start_address + 1; | |||
| } | |||
| } | |||
| return blocks; | |||
| } | |||
| } // namespace | |||
| PlcCommunicationService::PlcCommunicationService( | |||
| @@ -115,10 +175,11 @@ PlcCommunicationService::~PlcCommunicationService() = default; | |||
| PlcCommunicationResult PlcCommunicationService::connectDevice( | |||
| const PlcSerialConfiguration &configuration) | |||
| { | |||
| if (QString::fromStdString(configuration.portName).trimmed().isEmpty() | |||
| || configuration.serverAddress < 1 || configuration.serverAddress > 247) | |||
| const PlcCommunicationResult validation = | |||
| validatePlcSerialConfiguration(configuration); | |||
| if (!validation.succeeded) | |||
| { | |||
| return {false, "必须填写串口端口,并将 PLC 站号设置为 1~247"}; | |||
| return validation; | |||
| } | |||
| if (state_ == PlcConnectionState::Disconnected | |||
| && master_->state() != QModbusDevice::UnconnectedState) | |||
| @@ -179,16 +240,27 @@ void PlcCommunicationService::disconnectDevice() | |||
| setState(PlcConnectionState::Disconnected); | |||
| } | |||
| void PlcCommunicationService::setPollAddresses( | |||
| PlcCommunicationResult PlcCommunicationService::setPollAddresses( | |||
| const std::vector<RegisterAddress> &addresses) | |||
| { | |||
| std::vector<RegisterAddress> normalized; | |||
| std::string error; | |||
| if (!normalizePollAddresses(addresses, &normalized, &error)) | |||
| { | |||
| return {false, error}; | |||
| } | |||
| if (pollBlockCount(normalized) > ProjectLimits::kMaximumPollBlocks) | |||
| { | |||
| return {false, "PLC 轮询地址拆分后最多允许 64 个读块"}; | |||
| } | |||
| if (pending_reply_ != nullptr) | |||
| { | |||
| pending_poll_addresses_ = addresses; | |||
| pending_poll_addresses_ = std::move(normalized); | |||
| poll_update_pending_ = true; | |||
| return; | |||
| return {true, {}}; | |||
| } | |||
| applyPollAddresses(addresses); | |||
| applyPollAddresses(normalized); | |||
| return {true, {}}; | |||
| } | |||
| PlcConnectionState PlcCommunicationService::state() const | |||
| @@ -259,7 +331,8 @@ void PlcCommunicationService::rebuildPollBlocks() | |||
| || poll_blocks_.back().area != address.area() | |||
| || address.index() > poll_blocks_.back().startAddress | |||
| + poll_blocks_.back().count | |||
| || poll_blocks_.back().count >= kMaximumReadCount) | |||
| || poll_blocks_.back().count | |||
| >= ProjectLimits::kMaximumModbusReadCount) | |||
| { | |||
| poll_blocks_.push_back({address.area(), address.index(), 1}); | |||
| } | |||
| @@ -491,6 +564,10 @@ RegisterWriteResult PlcCommunicationService::sendBitWrite( | |||
| { | |||
| return {false, RegisterError::Unavailable}; | |||
| } | |||
| if (pending_write_reply_ != nullptr) | |||
| { | |||
| return {false, RegisterError::WriteRejected}; | |||
| } | |||
| QModbusDataUnit unit(QModbusDataUnit::Coils, address.index(), 1); | |||
| unit.setValue(0, value ? 1U : 0U); | |||
| QModbusReply *reply = master_->sendWriteRequest(unit, configuration_.serverAddress); | |||
| @@ -499,10 +576,15 @@ RegisterWriteResult PlcCommunicationService::sendBitWrite( | |||
| 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(); | |||
| @@ -524,6 +606,10 @@ RegisterWriteResult PlcCommunicationService::sendWordWrite( | |||
| { | |||
| return {false, RegisterError::Unavailable}; | |||
| } | |||
| if (pending_write_reply_ != nullptr) | |||
| { | |||
| return {false, RegisterError::WriteRejected}; | |||
| } | |||
| QModbusDataUnit unit(QModbusDataUnit::HoldingRegisters, address.index(), 1); | |||
| unit.setValue(0, static_cast<quint16>(value)); | |||
| QModbusReply *reply = master_->sendWriteRequest(unit, configuration_.serverAddress); | |||
| @@ -532,10 +618,15 @@ RegisterWriteResult PlcCommunicationService::sendWordWrite( | |||
| 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(); | |||
| @@ -601,6 +692,7 @@ void PlcCommunicationService::closeSerialSession() | |||
| poll_timer_.stop(); | |||
| recovery_timer_.stop(); | |||
| pending_reply_ = nullptr; | |||
| pending_write_reply_ = nullptr; | |||
| poll_update_pending_ = false; | |||
| pending_poll_addresses_.clear(); | |||
| if (master_->state() != QModbusDevice::UnconnectedState) | |||
| @@ -30,7 +30,8 @@ public: | |||
| PlcCommunicationResult connectDevice( | |||
| const PlcSerialConfiguration &configuration) override; | |||
| void disconnectDevice() override; | |||
| void setPollAddresses(const std::vector<RegisterAddress> &addresses) override; | |||
| PlcCommunicationResult setPollAddresses( | |||
| const std::vector<RegisterAddress> &addresses) override; | |||
| PlcConnectionState state() const override; | |||
| bool initialReadCompleted() const override; | |||
| @@ -84,6 +85,7 @@ private: | |||
| 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; | |||
| @@ -1,6 +1,7 @@ | |||
| #include "alarm_editor_service.h" | |||
| #include "project_service.h" | |||
| #include "domain/project_limits.h" | |||
| #include <algorithm> | |||
| @@ -36,6 +37,10 @@ const AlarmDefinition *AlarmEditorService::findDefinition( | |||
| AlarmEditorResult AlarmEditorService::addDefinition( | |||
| const AlarmDefinition &definition) | |||
| { | |||
| if (definitions().size() >= ProjectLimits::kMaximumAlarmDefinitions) | |||
| { | |||
| return failure(AlarmEditorError::InvalidDefinition, "单个工程最多包含 512 条报警定义"); | |||
| } | |||
| AlarmDefinition candidate = definition; | |||
| candidate.id = makeUniqueId(); | |||
| std::string error; | |||
| @@ -2,6 +2,7 @@ | |||
| #include "domain/hmi_control_registry.h" | |||
| #include "project_service.h" | |||
| #include "domain/project_limits.h" | |||
| #include <algorithm> | |||
| #include <cctype> | |||
| @@ -210,6 +211,14 @@ HmiEditorResult HmiEditorService::addPage(const std::string &name) | |||
| return failure(HmiEditorError::InvalidPage, "HMI 页面名称不能为空"); | |||
| } | |||
| const Project ¤t = project_service_.project(); | |||
| if (current.hmiPages.size() >= ProjectLimits::kMaximumHmiPages) | |||
| { | |||
| return failure(HmiEditorError::InvalidPage, "单个工程最多包含 128 个 HMI 页面"); | |||
| } | |||
| if (name.size() > ProjectLimits::kMaximumTextBytes) | |||
| { | |||
| return failure(HmiEditorError::InvalidPage, "HMI 页面名称不能超过 4096 个 UTF-8 字节"); | |||
| } | |||
| const bool duplicate = std::any_of( | |||
| current.hmiPages.cbegin(), current.hmiPages.cend(), | |||
| [&name](const HmiPage &page) { return page.name == name; }); | |||
| @@ -239,6 +248,10 @@ HmiEditorResult HmiEditorService::renamePage( | |||
| { | |||
| return failure(HmiEditorError::InvalidPage, "HMI 页面名称不能为空"); | |||
| } | |||
| if (name.size() > ProjectLimits::kMaximumTextBytes) | |||
| { | |||
| return failure(HmiEditorError::InvalidPage, "HMI 页面名称不能超过 4096 个 UTF-8 字节"); | |||
| } | |||
| const Project ¤t = project_service_.project(); | |||
| const auto existing = std::find_if( | |||
| current.hmiPages.cbegin(), current.hmiPages.cend(), | |||
| @@ -376,6 +389,10 @@ HmiEditorResult HmiEditorService::addControl( | |||
| { | |||
| return failure(HmiEditorError::PageNotFound, "未找到 HMI 页面"); | |||
| } | |||
| if (page->controls.size() >= ProjectLimits::kMaximumHmiControlsPerPage) | |||
| { | |||
| return failure(HmiEditorError::InvalidControl, "单个 HMI 页面最多包含 512 个控件"); | |||
| } | |||
| const HmiControlDescriptor *descriptor = findHmiControlDescriptor(type); | |||
| if (descriptor == nullptr) | |||
| { | |||
| @@ -1,6 +1,7 @@ | |||
| #include "logic_editor_service.h" | |||
| #include "project_service.h" | |||
| #include "domain/project_limits.h" | |||
| #include <algorithm> | |||
| #include <cctype> | |||
| @@ -651,6 +652,14 @@ LogicEditorResult LogicEditorService::addLogic(const std::string &name) | |||
| return failure(LogicEditorError::InvalidOperation, "控制逻辑名称不能为空"); | |||
| } | |||
| const Project ¤t = project_service_.project(); | |||
| if (current.controlLogics.size() >= ProjectLimits::kMaximumControlLogics) | |||
| { | |||
| return failure(LogicEditorError::InvalidOperation, "单个工程最多包含 128 组控制逻辑"); | |||
| } | |||
| if (name.size() > ProjectLimits::kMaximumTextBytes) | |||
| { | |||
| return failure(LogicEditorError::InvalidOperation, "控制逻辑名称不能超过 4096 个 UTF-8 字节"); | |||
| } | |||
| const bool duplicate = std::any_of( | |||
| current.controlLogics.cbegin(), current.controlLogics.cend(), | |||
| [&name](const ControlLogic &logic) { return logic.name == name; }); | |||
| @@ -677,6 +686,10 @@ LogicEditorResult LogicEditorService::renameLogic( | |||
| { | |||
| return failure(LogicEditorError::InvalidOperation, "控制逻辑名称不能为空"); | |||
| } | |||
| if (name.size() > ProjectLimits::kMaximumTextBytes) | |||
| { | |||
| return failure(LogicEditorError::InvalidOperation, "控制逻辑名称不能超过 4096 个 UTF-8 字节"); | |||
| } | |||
| const Project ¤t = project_service_.project(); | |||
| const auto logic = std::find_if( | |||
| current.controlLogics.cbegin(), current.controlLogics.cend(), | |||
| @@ -795,6 +808,10 @@ LogicEditorResult LogicEditorService::addRung(const std::string &logic_id) | |||
| { | |||
| return failure(LogicEditorError::LogicNotFound, "未找到控制逻辑"); | |||
| } | |||
| if (logic->rungs.size() >= ProjectLimits::kMaximumRungsPerLogic) | |||
| { | |||
| return failure(LogicEditorError::InvalidOperation, "单组控制逻辑最多包含 1024 个网络"); | |||
| } | |||
| LadderRung rung; | |||
| rung.id = makeUniqueRungId(*logic); | |||
| rung.name = "网络 " + std::to_string(logic->rungs.size() + 1U); | |||
| @@ -848,6 +865,12 @@ LogicEditorResult LogicEditorService::updateRungComment( | |||
| { | |||
| return failure(LogicEditorError::RungNotFound, "未找到梯形图网络"); | |||
| } | |||
| if (comment.size() > ProjectLimits::kMaximumTextBytes) | |||
| { | |||
| return failure( | |||
| LogicEditorError::InvalidOperation, | |||
| "梯形图网络注释不能超过 4096 个 UTF-8 字节"); | |||
| } | |||
| if (rung->comment == comment) | |||
| { | |||
| return {true, LogicEditorError::None, {}, rung_id}; | |||
| @@ -897,6 +920,12 @@ LogicEditorResult LogicEditorService::appendCondition( | |||
| std::move(*rung->condition), | |||
| std::move(leaf)); | |||
| } | |||
| std::string validation_error; | |||
| if (!rung->validate(&validation_error)) | |||
| { | |||
| project.controlLogics = std::move(before.logics); | |||
| return failure(LogicEditorError::InvalidOperation, validation_error); | |||
| } | |||
| recordHistory(std::move(before)); | |||
| return {true, LogicEditorError::None, {}, node_id}; | |||
| } | |||
| @@ -941,6 +970,12 @@ LogicEditorResult LogicEditorService::appendWire( | |||
| std::move(*rung->condition), | |||
| std::move(leaf)); | |||
| } | |||
| std::string validation_error; | |||
| if (!rung->validate(&validation_error)) | |||
| { | |||
| project_service_.editProject().controlLogics = std::move(before.logics); | |||
| return failure(LogicEditorError::InvalidOperation, validation_error); | |||
| } | |||
| recordHistory(std::move(before)); | |||
| return {true, LogicEditorError::None, {}, wire_id}; | |||
| } | |||
| @@ -990,6 +1025,12 @@ LogicEditorResult LogicEditorService::insertConditionAfter( | |||
| std::move(original), | |||
| std::move(leaf)); | |||
| } | |||
| std::string validation_error; | |||
| if (!rung->validate(&validation_error)) | |||
| { | |||
| project.controlLogics = std::move(before.logics); | |||
| return failure(LogicEditorError::InvalidOperation, validation_error); | |||
| } | |||
| recordHistory(std::move(before)); | |||
| return {true, LogicEditorError::None, {}, node_id}; | |||
| } | |||
| @@ -1046,6 +1087,12 @@ LogicEditorResult LogicEditorService::insertWireAfter( | |||
| std::move(original), | |||
| std::move(leaf)); | |||
| } | |||
| std::string validation_error; | |||
| if (!rung->validate(&validation_error)) | |||
| { | |||
| project_service_.editProject().controlLogics = std::move(before.logics); | |||
| return failure(LogicEditorError::InvalidOperation, validation_error); | |||
| } | |||
| recordHistory(std::move(before)); | |||
| return {true, LogicEditorError::None, {}, wire_id}; | |||
| } | |||
| @@ -1075,6 +1122,12 @@ LogicEditorResult LogicEditorService::replaceWireWithCondition( | |||
| ConditionExpression *editable = findConditionExpression( | |||
| *rung->condition, wire_expression_id); | |||
| *editable = ConditionExpression::fromNode(makeNode(node_id, config)); | |||
| std::string validation_error; | |||
| if (!rung->validate(&validation_error)) | |||
| { | |||
| project_service_.editProject().controlLogics = std::move(before.logics); | |||
| return failure(LogicEditorError::InvalidNode, validation_error); | |||
| } | |||
| recordHistory(std::move(before)); | |||
| return {true, LogicEditorError::None, {}, node_id}; | |||
| } | |||
| @@ -1131,10 +1184,17 @@ LogicEditorResult LogicEditorService::addParallelBranch( | |||
| parallel_id, | |||
| series_id)) | |||
| { | |||
| project.controlLogics = before.logics; | |||
| return failure( | |||
| LogicEditorError::InvalidOperation, | |||
| "并联选择必须是一个连续的逻辑范围"); | |||
| } | |||
| std::string validation_error; | |||
| if (!rung->validate(&validation_error)) | |||
| { | |||
| project.controlLogics = std::move(before.logics); | |||
| return failure(LogicEditorError::InvalidOperation, validation_error); | |||
| } | |||
| recordHistory(std::move(before)); | |||
| return {true, LogicEditorError::None, {}, node_id}; | |||
| } | |||
| @@ -1197,10 +1257,17 @@ LogicEditorResult LogicEditorService::addParallelWireBranch( | |||
| parallel_id, | |||
| series_id)) | |||
| { | |||
| project_service_.editProject().controlLogics = before.logics; | |||
| return failure( | |||
| LogicEditorError::InvalidOperation, | |||
| "竖线连接只能围绕同一支路中的连续逻辑范围"); | |||
| } | |||
| std::string validation_error; | |||
| if (!rung->validate(&validation_error)) | |||
| { | |||
| project_service_.editProject().controlLogics = std::move(before.logics); | |||
| return failure(LogicEditorError::InvalidOperation, validation_error); | |||
| } | |||
| recordHistory(std::move(before)); | |||
| return {true, LogicEditorError::None, {}, wire_id}; | |||
| } | |||
| @@ -1223,6 +1290,11 @@ LogicEditorResult LogicEditorService::setOutput( | |||
| ? existing_rung->output->id : makeUniqueNodeId(*logic, nodePrefix(config)); | |||
| LogicNode node = makeNode(node_id, config); | |||
| node.configured = configured; | |||
| std::string validation_error; | |||
| if (!node.validate(&validation_error)) | |||
| { | |||
| return failure(LogicEditorError::InvalidNode, validation_error); | |||
| } | |||
| if (existing_rung->output.has_value() | |||
| && nodesEqual(*existing_rung->output, node)) | |||
| { | |||
| @@ -1,7 +1,10 @@ | |||
| #pragma once | |||
| #include "domain/register_address.h" | |||
| #include "domain/project_limits.h" | |||
| #include <algorithm> | |||
| #include <cctype> | |||
| #include <functional> | |||
| #include <string> | |||
| #include <vector> | |||
| @@ -50,6 +53,61 @@ struct PlcCommunicationResult | |||
| std::string message; | |||
| }; | |||
| inline PlcCommunicationResult validatePlcSerialConfiguration( | |||
| const PlcSerialConfiguration &configuration) | |||
| { | |||
| const bool has_port_name = std::any_of( | |||
| configuration.portName.cbegin(), configuration.portName.cend(), | |||
| [](unsigned char character) { return std::isspace(character) == 0; }); | |||
| if (!has_port_name) | |||
| { | |||
| return {false, "必须填写串口端口"}; | |||
| } | |||
| if (configuration.serverAddress < ProjectLimits::kMinimumPlcServerAddress | |||
| || configuration.serverAddress > ProjectLimits::kMaximumPlcServerAddress) | |||
| { | |||
| return {false, "PLC 站号必须在 1~247 范围内"}; | |||
| } | |||
| if (configuration.baudRate != 9600 | |||
| && configuration.baudRate != 19200 | |||
| && configuration.baudRate != 38400 | |||
| && configuration.baudRate != 57600 | |||
| && configuration.baudRate != 115200) | |||
| { | |||
| return {false, "波特率只支持 9600、19200、38400、57600 或 115200"}; | |||
| } | |||
| if (configuration.dataBits != 7 && configuration.dataBits != 8) | |||
| { | |||
| return {false, "数据位只支持 7 或 8"}; | |||
| } | |||
| if (configuration.parity != 0 | |||
| && configuration.parity != 2 | |||
| && configuration.parity != 3) | |||
| { | |||
| return {false, "校验方式只支持无校验、偶校验或奇校验"}; | |||
| } | |||
| if (configuration.stopBits != 1 && configuration.stopBits != 2) | |||
| { | |||
| return {false, "停止位只支持 1 或 2"}; | |||
| } | |||
| if (configuration.responseTimeoutMs < ProjectLimits::kMinimumResponseTimeoutMs | |||
| || configuration.responseTimeoutMs > ProjectLimits::kMaximumResponseTimeoutMs) | |||
| { | |||
| return {false, "PLC 响应超时必须在 100~30000 ms 范围内"}; | |||
| } | |||
| if (configuration.retries < ProjectLimits::kMinimumRetries | |||
| || configuration.retries > ProjectLimits::kMaximumRetries) | |||
| { | |||
| return {false, "PLC 失败重试次数必须在 0~5 范围内"}; | |||
| } | |||
| if (configuration.pollIntervalMs < ProjectLimits::kMinimumPollIntervalMs | |||
| || configuration.pollIntervalMs > ProjectLimits::kMaximumPollIntervalMs) | |||
| { | |||
| return {false, "PLC 轮询周期必须在 50~10000 ms 范围内"}; | |||
| } | |||
| return {true, {}}; | |||
| } | |||
| class PlcCommunicationGateway | |||
| { | |||
| public: | |||
| @@ -58,7 +116,7 @@ public: | |||
| virtual PlcCommunicationResult connectDevice( | |||
| const PlcSerialConfiguration &configuration) = 0; | |||
| virtual void disconnectDevice() = 0; | |||
| virtual void setPollAddresses( | |||
| virtual PlcCommunicationResult setPollAddresses( | |||
| const std::vector<RegisterAddress> &addresses) = 0; | |||
| virtual PlcConnectionState state() const = 0; | |||
| virtual bool initialReadCompleted() const = 0; | |||
| @@ -10,6 +10,7 @@ | |||
| #include "project_service.h" | |||
| #include "domain/active_register_repository.h" | |||
| #include "domain/project_limits.h" | |||
| #include <algorithm> | |||
| @@ -189,7 +190,11 @@ PlcCommunicationResult RuntimeModeService::connectPlc( | |||
| { | |||
| plc_gateway_->disconnectDevice(); | |||
| } | |||
| refreshPlcPollAddresses(); | |||
| const PlcCommunicationResult poll_result = refreshPlcPollAddresses(); | |||
| if (!poll_result.succeeded) | |||
| { | |||
| return poll_result; | |||
| } | |||
| setInitialPlcReadCompleted(false); | |||
| return plc_gateway_->connectDevice(configuration); | |||
| } | |||
| @@ -201,11 +206,11 @@ void RuntimeModeService::setMonitorAddresses( | |||
| refreshPlcPollAddresses(); | |||
| } | |||
| void RuntimeModeService::refreshPlcPollAddresses() | |||
| PlcCommunicationResult RuntimeModeService::refreshPlcPollAddresses() | |||
| { | |||
| if (plc_gateway_ == nullptr) | |||
| { | |||
| return; | |||
| return {false, "PLC 通信服务尚未配置"}; | |||
| } | |||
| std::vector<RegisterAddress> addresses = monitor_addresses_; | |||
| const Project &project = project_service_.project(); | |||
| @@ -258,7 +263,11 @@ void RuntimeModeService::refreshPlcPollAddresses() | |||
| return left.index() < right.index(); | |||
| }); | |||
| addresses.erase(std::unique(addresses.begin(), addresses.end()), addresses.end()); | |||
| plc_gateway_->setPollAddresses(addresses); | |||
| if (addresses.size() > ProjectLimits::kMaximumPollAddresses) | |||
| { | |||
| return {false, "PLC 轮询的去重 M/D 地址最多为 1024 个"}; | |||
| } | |||
| return plc_gateway_->setPollAddresses(addresses); | |||
| } | |||
| void RuntimeModeService::disconnectPlc() | |||
| @@ -76,7 +76,7 @@ public: | |||
| RegisterRepository &plc_repository); | |||
| PlcCommunicationResult connectPlc(const PlcSerialConfiguration &configuration); | |||
| void setMonitorAddresses(const std::vector<RegisterAddress> &addresses); | |||
| void refreshPlcPollAddresses(); | |||
| PlcCommunicationResult refreshPlcPollAddresses(); | |||
| void disconnectPlc(); | |||
| PlcConnectionState plcConnectionState() const; | |||
| const std::string &plcError() const; | |||
| @@ -228,14 +228,19 @@ LogicScanResult SoftwareLogicExecutor::executeScanAt( | |||
| continue; | |||
| } | |||
| bool rung_value = false; | |||
| LogicScanResult result = evaluateExpression( | |||
| logic.id, | |||
| *rung.condition, | |||
| repository, | |||
| logic_trace, | |||
| true, | |||
| &rung_value); | |||
| // 没有条件节点的网络等价于左侧电源线直接接通,作为恒真条件执行 | |||
| bool rung_value = true; | |||
| LogicScanResult result = success(); | |||
| if (rung.condition.has_value()) | |||
| { | |||
| result = evaluateExpression( | |||
| logic.id, | |||
| *rung.condition, | |||
| repository, | |||
| logic_trace, | |||
| true, | |||
| &rung_value); | |||
| } | |||
| if (!result.succeeded) | |||
| { | |||
| result.logicId = logic.id; | |||
| @@ -2,12 +2,14 @@ | |||
| #include "domain/hmi_control_registry.h" | |||
| #include "domain/hmi_model.h" | |||
| #include "domain/project_model.h" | |||
| #include "domain/project_limits.h" | |||
| #include "domain/register_address.h" | |||
| #include "domain/register_repository.h" | |||
| #include "domain/runtime_state.h" | |||
| #include <cstdint> | |||
| #include <exception> | |||
| #include <functional> | |||
| #include <iostream> | |||
| #include <set> | |||
| #include <stdexcept> | |||
| @@ -311,6 +313,102 @@ void testMultiPageAndLogicDomainRules() | |||
| require(!project.validate(), "unknown HMI control types must be rejected"); | |||
| } | |||
| void testQuantityBoundaries() | |||
| { | |||
| Project project = makeValidProject(); | |||
| for (std::size_t index = 1U; index < ProjectLimits::kMaximumHmiPages; ++index) | |||
| { | |||
| project.hmiPages.push_back({ | |||
| "page-" + std::to_string(index), | |||
| "Page " + std::to_string(index), | |||
| 800, | |||
| 480, | |||
| {}}); | |||
| } | |||
| require(project.validate(), "an HMI page count of 128 must be accepted"); | |||
| project.hmiPages.push_back({"page-over", "Page over", 800, 480, {}}); | |||
| require(!project.validate(), "an HMI page count of 129 must be rejected"); | |||
| project = makeValidProject(); | |||
| project.hmiPages.front().controls.clear(); | |||
| for (std::size_t index = 0U; | |||
| index < ProjectLimits::kMaximumHmiControlsPerPage; | |||
| ++index) | |||
| { | |||
| HmiControl label; | |||
| label.id = "label-" + std::to_string(index); | |||
| label.type = HmiControlType::Label; | |||
| label.bounds = {0, 0, 1, 1}; | |||
| label.text = "label"; | |||
| project.hmiPages.front().controls.push_back(std::move(label)); | |||
| } | |||
| require(project.validate(), "a page control count of 512 must be accepted"); | |||
| HmiControl extra_label; | |||
| extra_label.id = "label-over"; | |||
| extra_label.type = HmiControlType::Label; | |||
| extra_label.bounds = {0, 0, 1, 1}; | |||
| extra_label.text = "label"; | |||
| project.hmiPages.front().controls.push_back(std::move(extra_label)); | |||
| require(!project.validate(), "a page control count of 513 must be rejected"); | |||
| project = makeValidProject(); | |||
| project.hmiPages.front().width = ProjectLimits::kMaximumHmiPageWidth; | |||
| project.hmiPages.front().height = ProjectLimits::kMaximumHmiPageHeight; | |||
| require(project.validate(), "an HMI page size of 8192 by 8192 must be accepted"); | |||
| project.hmiPages.front().width = ProjectLimits::kMaximumHmiPageWidth + 1; | |||
| require(!project.validate(), "an HMI page width of 8193 must be rejected"); | |||
| ConditionExpression leaf = ConditionExpression::fromNode({ | |||
| "depth-node-0", | |||
| ContactNodeConfig{RegisterAddress{RegisterArea::M, 0}}, | |||
| true}); | |||
| std::function<ConditionExpression(int, int *)> makeNested = | |||
| [&makeNested](int depth, int *next_address) | |||
| { | |||
| if (depth == 1) | |||
| { | |||
| const int address = (*next_address)++; | |||
| return ConditionExpression::fromNode({ | |||
| "depth-node-" + std::to_string(address), | |||
| ContactNodeConfig{RegisterAddress{RegisterArea::M, address}}, | |||
| true}); | |||
| } | |||
| const int address = (*next_address)++; | |||
| ConditionExpression expression; | |||
| expression.id = "depth-expression-" + std::to_string(address); | |||
| expression.kind = depth % 2 == 0 | |||
| ? ConditionExpressionKind::Parallel | |||
| : ConditionExpressionKind::Series; | |||
| expression.children = { | |||
| makeNested(depth - 1, next_address), | |||
| ConditionExpression::fromNode({ | |||
| "depth-node-" + std::to_string(address), | |||
| ContactNodeConfig{RegisterAddress{RegisterArea::M, address}}, | |||
| true})}; | |||
| return expression; | |||
| }; | |||
| int next_address = 1; | |||
| ConditionExpression maximum_depth = makeNested( | |||
| static_cast<int>(ProjectLimits::kMaximumExpressionDepth), &next_address); | |||
| require(maximum_depth.validate(), | |||
| "an expression depth of 20 must be accepted"); | |||
| ConditionExpression excessive_depth = makeNested( | |||
| static_cast<int>(ProjectLimits::kMaximumExpressionDepth) + 1, | |||
| &next_address); | |||
| require(!excessive_depth.validate(), | |||
| "an expression depth of 21 must be rejected"); | |||
| CounterNodeConfig counter_config{ | |||
| CounterAddress{0}, | |||
| CounterMode::Up, | |||
| RegisterAddress{RegisterArea::D, 0}, | |||
| WordOperand{WordOperandKind::Constant, RegisterAddress{RegisterArea::D, 0}, -1}, | |||
| RegisterAddress{RegisterArea::M, 0}}; | |||
| LogicNode counter{"counter-negative-preset", counter_config, true}; | |||
| require(!counter.validate(), "a negative constant counter preset must be rejected"); | |||
| (void)leaf; | |||
| } | |||
| void testLogicNodeConfigurationBoundaries() | |||
| { | |||
| // 触点只能绑定 M 区,数值比较只能绑定 D 区 | |||
| @@ -613,6 +711,31 @@ void testLadderLogicBoundaries() | |||
| "wire-too-wide", WireSegment::kMaximumColumnSpan + 1); | |||
| require(!invalid_wire.validate(), "an oversized horizontal wire must be rejected"); | |||
| ConditionExpression maximum_columns; | |||
| maximum_columns.id = "maximum-columns"; | |||
| maximum_columns.kind = ConditionExpressionKind::Series; | |||
| for (int column = 0; column < ProjectLimits::kMaximumConditionColumns; ++column) | |||
| { | |||
| LogicNode node; | |||
| node.id = "column-" + std::to_string(column + 1); | |||
| node.config = ContactNodeConfig{ | |||
| RegisterAddress{RegisterArea::M, column}, | |||
| ContactMode::NormallyOpen}; | |||
| maximum_columns.children.push_back( | |||
| ConditionExpression::fromNode(std::move(node))); | |||
| } | |||
| require(maximum_columns.validate(), | |||
| "ten condition columns must be accepted"); | |||
| LogicNode extra_column; | |||
| extra_column.id = "column-11"; | |||
| extra_column.config = ContactNodeConfig{ | |||
| RegisterAddress{RegisterArea::M, 10}, | |||
| ContactMode::NormallyOpen}; | |||
| maximum_columns.children.push_back( | |||
| ConditionExpression::fromNode(std::move(extra_column))); | |||
| require(!maximum_columns.validate(), | |||
| "an eleventh condition column must be rejected"); | |||
| ConditionExpression wired_series; | |||
| wired_series.id = "wired-series"; | |||
| wired_series.kind = ConditionExpressionKind::Series; | |||
| @@ -641,8 +764,8 @@ void testLadderLogicBoundaries() | |||
| empty_rung.output = coil; | |||
| require(empty_rung.validate(), "output-only network may remain in an editable draft"); | |||
| require(!empty_rung.validateForRunning(), | |||
| "an output without conditions must block runtime validation"); | |||
| require(empty_rung.validateForRunning(), | |||
| "an output-only network must be valid as an unconditional rung"); | |||
| logic.rungs.front().output = coil; | |||
| logic.rungs.front().condition = root; | |||
| @@ -740,6 +863,7 @@ int main() | |||
| testLadderLogicBoundaries(); | |||
| testModelsValidateBindingsAndIdentifiers(); | |||
| testMultiPageAndLogicDomainRules(); | |||
| testQuantityBoundaries(); | |||
| testRuntimeStateBoundaries(); | |||
| } | |||
| catch (const std::exception &error) | |||
| @@ -24,5 +24,6 @@ HEADERS += \ | |||
| ../src/domain/hmi_model.h \ | |||
| ../src/domain/hmi_control_registry.h \ | |||
| ../src/domain/control_logic_model.h \ | |||
| ../src/domain/project_limits.h \ | |||
| ../src/domain/project_model.h \ | |||
| ../src/domain/runtime_state.h | |||
| @@ -192,6 +192,55 @@ void testStructuredWireEditing() | |||
| "wire branch deletion must participate in ladder undo history"); | |||
| } | |||
| void testConditionColumnLimit() | |||
| { | |||
| TestProjectStorage storage; | |||
| ProjectService project_service(storage); | |||
| LogicEditorService service(project_service); | |||
| const std::string logic_id = service.ensureDefaultLogic().id; | |||
| const std::string rung_id = service.firstRungId(logic_id); | |||
| for (int column = 0; column < ProjectLimits::kMaximumConditionColumns; ++column) | |||
| { | |||
| require(service.appendCondition(logic_id, rung_id, contact(column)).succeeded, | |||
| "the first ten condition columns must be editable"); | |||
| } | |||
| const LogicEditorResult overflow = service.appendCondition( | |||
| logic_id, rung_id, contact(ProjectLimits::kMaximumConditionColumns)); | |||
| require(!overflow.succeeded | |||
| && overflow.error == LogicEditorError::InvalidOperation, | |||
| "the eleventh condition column must be rejected by the editor service"); | |||
| const LadderRung *rung = service.findRung(logic_id, rung_id); | |||
| require(rung != nullptr && rung->condition.has_value() | |||
| && rung->condition->kind == ConditionExpressionKind::Series | |||
| && rung->condition->children.size() | |||
| == static_cast<std::size_t>( | |||
| ProjectLimits::kMaximumConditionColumns), | |||
| "a rejected eleventh column must leave the ten-column network unchanged"); | |||
| } | |||
| void testUnconditionalOutputEditing() | |||
| { | |||
| TestProjectStorage storage; | |||
| ProjectService project_service(storage); | |||
| LogicEditorService service(project_service); | |||
| const std::string logic_id = service.ensureDefaultLogic().id; | |||
| const std::string rung_id = service.firstRungId(logic_id); | |||
| require(service.setOutput( | |||
| logic_id, | |||
| rung_id, | |||
| CoilNodeConfig{ | |||
| RegisterAddress{RegisterArea::M, 10}, CoilMode::Normal}, | |||
| true) | |||
| .succeeded, | |||
| "the editor must allow a coil before any condition is added"); | |||
| const LadderRung *rung = service.findRung(logic_id, rung_id); | |||
| require(rung != nullptr && !rung->condition.has_value() | |||
| && rung->output.has_value() && rung->validateForRunning(), | |||
| "an editor-created output-only network must be runnable as unconditional"); | |||
| } | |||
| void testLogicLifecycleAndOrdering() | |||
| { | |||
| TestProjectStorage storage; | |||
| @@ -366,6 +415,8 @@ int main() | |||
| testStructuredEditingAndNormalization(); | |||
| testRangeParallelInsertion(); | |||
| testStructuredWireEditing(); | |||
| testConditionColumnLimit(); | |||
| testUnconditionalOutputEditing(); | |||
| testLogicLifecycleAndOrdering(); | |||
| testEdgeTimerNodesAndRungComments(); | |||
| testHistoryAndAtomicBatchDelete(); | |||
| @@ -257,6 +257,28 @@ void testNestedSeriesParallelExpression() | |||
| require(readBit(repository, 10), "A branch must independently energize output"); | |||
| } | |||
| void testUnconditionalCoil() | |||
| { | |||
| VirtualRegisterRepository repository; | |||
| SoftwareLogicExecutor executor; | |||
| LadderRung unconditional; | |||
| unconditional.id = "unconditional-rung"; | |||
| unconditional.name = "unconditional-rung"; | |||
| unconditional.output = coil("unconditional-coil", 10); | |||
| const ControlLogic program = logic({unconditional}); | |||
| LogicTraceSnapshot trace; | |||
| require(executor.validate({program}).succeeded, | |||
| "an output-only network must pass runtime validation"); | |||
| require(executor.executeScan({program}, repository, &trace).succeeded, | |||
| "an output-only network scan must succeed"); | |||
| require(readBit(repository, 10), | |||
| "an output-only network must energize its coil as a constant-true rung"); | |||
| require(trace.rungValues.at("unconditional-rung") | |||
| && trace.nodePowerValues.at("unconditional-coil"), | |||
| "an unconditional rung must report energized power flow"); | |||
| } | |||
| void testWirePassThroughAndPowerTrace() | |||
| { | |||
| VirtualRegisterRepository repository; | |||
| @@ -900,6 +922,7 @@ int main(int argc, char *argv[]) | |||
| { | |||
| testSeriesParallelContactsAndSequentialVisibility(); | |||
| testNestedSeriesParallelExpression(); | |||
| testUnconditionalCoil(); | |||
| testWirePassThroughAndPowerTrace(); | |||
| testAllComparisons(); | |||
| testSetResetAndDisabledLogic(); | |||
| @@ -1,6 +1,8 @@ | |||
| #include "domain/active_register_repository.h" | |||
| #include "domain/project_limits.h" | |||
| #include "domain/project_storage.h" | |||
| #include "infrastructure/plc_communication_error_classifier.h" | |||
| #include "infrastructure/plc_communication_service.h" | |||
| #include "infrastructure/plc_register_repository.h" | |||
| #include "services/offline_simulation_service.h" | |||
| #include "services/plc_communication_gateway.h" | |||
| @@ -61,9 +63,11 @@ public: | |||
| } | |||
| } | |||
| void setPollAddresses(const std::vector<RegisterAddress> &addresses) override | |||
| PlcCommunicationResult setPollAddresses( | |||
| const std::vector<RegisterAddress> &addresses) override | |||
| { | |||
| poll_addresses = addresses; | |||
| return {true, {}}; | |||
| } | |||
| PlcConnectionState state() const override { return connection_state; } | |||
| @@ -311,6 +315,68 @@ void testPlcCommunicationErrorClassification() | |||
| "protocol errors must remain distinct from timeouts and disconnections"); | |||
| } | |||
| void testPlcConfigurationBoundaries() | |||
| { | |||
| PlcSerialConfiguration configuration; | |||
| configuration.portName = "COM3"; | |||
| require(validatePlcSerialConfiguration(configuration).succeeded, | |||
| "the standard COM3 9600 8E1 configuration must be accepted"); | |||
| configuration.serverAddress = ProjectLimits::kMaximumPlcServerAddress; | |||
| require(validatePlcSerialConfiguration(configuration).succeeded, | |||
| "PLC station 247 must be accepted"); | |||
| configuration.serverAddress = ProjectLimits::kMaximumPlcServerAddress + 1; | |||
| require(!validatePlcSerialConfiguration(configuration).succeeded, | |||
| "PLC station 248 must be rejected"); | |||
| configuration.serverAddress = 1; | |||
| configuration.retries = ProjectLimits::kMaximumRetries; | |||
| require(validatePlcSerialConfiguration(configuration).succeeded, | |||
| "five retries must be accepted"); | |||
| configuration.retries = ProjectLimits::kMaximumRetries + 1; | |||
| require(!validatePlcSerialConfiguration(configuration).succeeded, | |||
| "six retries must be rejected"); | |||
| configuration.retries = 0; | |||
| configuration.responseTimeoutMs = ProjectLimits::kMinimumResponseTimeoutMs - 1; | |||
| require(!validatePlcSerialConfiguration(configuration).succeeded, | |||
| "a response timeout below 100 ms must be rejected"); | |||
| configuration.responseTimeoutMs = ProjectLimits::kMinimumResponseTimeoutMs; | |||
| require(validatePlcSerialConfiguration(configuration).succeeded, | |||
| "a response timeout of 100 ms must be accepted"); | |||
| } | |||
| void testPlcPollQuantityBoundaries() | |||
| { | |||
| PlcRegisterRepository repository; | |||
| PlcCommunicationService service(repository); | |||
| std::vector<RegisterAddress> too_many_addresses; | |||
| for (int index = 0; index <= RegisterAddress::kMaximumIndex; ++index) | |||
| { | |||
| too_many_addresses.push_back({RegisterArea::M, index}); | |||
| } | |||
| too_many_addresses.push_back({RegisterArea::D, 0}); | |||
| require(!service.setPollAddresses(too_many_addresses).succeeded, | |||
| "more than 1024 poll addresses must be rejected"); | |||
| std::vector<RegisterAddress> too_many_blocks; | |||
| for (int index = 0; index < 65; ++index) | |||
| { | |||
| too_many_blocks.push_back({RegisterArea::M, index * 2}); | |||
| } | |||
| require(!service.setPollAddresses(too_many_blocks).succeeded, | |||
| "more than 64 Modbus read blocks must be rejected"); | |||
| std::vector<RegisterAddress> maximum_contiguous_block; | |||
| for (int index = 0; index < ProjectLimits::kMaximumModbusReadCount; ++index) | |||
| { | |||
| maximum_contiguous_block.push_back({RegisterArea::M, index}); | |||
| } | |||
| require(service.setPollAddresses(maximum_contiguous_block).succeeded, | |||
| "a contiguous Modbus read block of 120 values must be accepted"); | |||
| } | |||
| } // namespace | |||
| int main() | |||
| @@ -321,6 +387,8 @@ int main() | |||
| testRuntimeRepositorySwitchingAndDisconnect(); | |||
| testRuntimeFaultRevokesOnlineReadinessAndAllowsReconnect(); | |||
| testPlcCommunicationErrorClassification(); | |||
| testPlcConfigurationBoundaries(); | |||
| testPlcPollQuantityBoundaries(); | |||
| } | |||
| catch (const std::exception &error) | |||
| { | |||
| @@ -1,4 +1,4 @@ | |||
| QT += core serialbus | |||
| QT += core serialbus serialport | |||
| TEMPLATE = app | |||
| TARGET = plc_runtime_tests | |||
| @@ -25,7 +25,8 @@ SOURCES += \ | |||
| ../src/services/offline_simulation_service.cpp \ | |||
| ../src/services/runtime_mode_service.cpp \ | |||
| ../src/infrastructure/plc_communication_error_classifier.cpp \ | |||
| ../src/infrastructure/plc_register_repository.cpp | |||
| ../src/infrastructure/plc_register_repository.cpp \ | |||
| ../src/infrastructure/plc_communication_service.cpp | |||
| HEADERS += \ | |||
| ../src/domain/register_address.h \ | |||
| @@ -36,6 +37,7 @@ HEADERS += \ | |||
| ../src/domain/hmi_model.h \ | |||
| ../src/domain/hmi_control_registry.h \ | |||
| ../src/domain/control_logic_model.h \ | |||
| ../src/domain/project_limits.h \ | |||
| ../src/domain/project_model.h \ | |||
| ../src/domain/project_storage.h \ | |||
| ../src/domain/runtime_state.h \ | |||
| @@ -45,4 +47,5 @@ HEADERS += \ | |||
| ../src/services/runtime_mode_service.h \ | |||
| ../src/services/plc_communication_gateway.h \ | |||
| ../src/infrastructure/plc_communication_error_classifier.h \ | |||
| ../src/infrastructure/plc_register_repository.h | |||
| ../src/infrastructure/plc_register_repository.h \ | |||
| ../src/infrastructure/plc_communication_service.h | |||
| @@ -2,6 +2,7 @@ | |||
| #include "infrastructure/json_project_storage.h" | |||
| #include "services/register_comment_service.h" | |||
| #include "services/project_service.h" | |||
| #include "domain/project_limits.h" | |||
| #include <QFile> | |||
| #include <QJsonArray> | |||
| @@ -374,6 +375,39 @@ void testEmptyProjectRoundTrip() | |||
| "empty project must have no alarm definitions"); | |||
| } | |||
| void testUnconditionalOutputRoundTrip() | |||
| { | |||
| QTemporaryDir directory; | |||
| require(directory.isValid(), "temporary directory must be valid"); | |||
| JsonProjectStorage storage; | |||
| ProjectService service(storage); | |||
| require(service.createNewProject("Unconditional output").succeeded, | |||
| "unconditional output project creation must succeed"); | |||
| LadderRung rung; | |||
| rung.id = "unconditional-rung"; | |||
| rung.name = "Unconditional rung"; | |||
| LogicNode coil; | |||
| coil.id = "unconditional-coil"; | |||
| coil.config = CoilNodeConfig{ | |||
| RegisterAddress{RegisterArea::M, 10}, CoilMode::Normal}; | |||
| rung.output = coil; | |||
| service.editProject().controlLogics.push_back( | |||
| ControlLogic{"logic-1", "Logic 1", {rung}, true}); | |||
| require(service.project().validateForRunning(), | |||
| "an output-only project must be runnable before saving"); | |||
| const QString path = directory.filePath("unconditional-output.json"); | |||
| require(service.saveAs(path.toStdString()).succeeded, | |||
| "an output-only project must save successfully"); | |||
| require(service.load(path.toStdString()).succeeded, | |||
| "an output-only project must load successfully"); | |||
| const LadderRung &loaded = service.project().controlLogics.front().rungs.front(); | |||
| require(!loaded.condition.has_value() && loaded.output.has_value() | |||
| && loaded.validateForRunning(), | |||
| "JSON round trip must preserve unconditional output semantics"); | |||
| } | |||
| void testExampleProjectRoundTrip() | |||
| { | |||
| // 验证各层嵌套字段往返后保持不变且序列化结果稳定 | |||
| @@ -694,6 +728,96 @@ void testInvalidFiles() | |||
| "unsupported versions must be rejected"); | |||
| } | |||
| void testQuantityFileLimits() | |||
| { | |||
| QTemporaryDir directory; | |||
| require(directory.isValid(), "temporary directory must be valid"); | |||
| JsonProjectStorage storage; | |||
| ProjectService service(storage); | |||
| require(service.createNewProject("Quantity limits").succeeded, | |||
| "the quantity-limit fixture project must be created"); | |||
| const QString oversized_path = directory.filePath("oversized.json"); | |||
| const QByteArray oversized( | |||
| static_cast<int>(ProjectLimits::kMaximumProjectFileBytes + 1U), ' '); | |||
| writeText(oversized_path, oversized); | |||
| const ProjectLoadResult oversized_result = | |||
| storage.load(oversized_path.toStdString()); | |||
| require(!oversized_result.succeeded | |||
| && oversized_result.error == ProjectStorageError::InvalidJson, | |||
| "a JSON file over 16 MiB must be rejected before parsing"); | |||
| const QString valid_path = directory.filePath("fixture.json"); | |||
| require(service.saveAs(valid_path.toStdString()).succeeded, | |||
| "the quantity-limit fixture must be saved"); | |||
| QJsonObject root = QJsonDocument::fromJson(readBytes(valid_path)).object(); | |||
| QJsonArray too_many_pages; | |||
| for (int index = 0; | |||
| index <= static_cast<int>(ProjectLimits::kMaximumHmiPages); | |||
| ++index) | |||
| { | |||
| too_many_pages.append(QJsonObject{}); | |||
| } | |||
| root.insert(QStringLiteral("hmiPages"), too_many_pages); | |||
| const QString too_many_pages_path = directory.filePath("too-many-pages.json"); | |||
| writeText( | |||
| too_many_pages_path, | |||
| QJsonDocument(root).toJson(QJsonDocument::Compact)); | |||
| const ProjectLoadResult too_many_pages_result = | |||
| storage.load(too_many_pages_path.toStdString()); | |||
| require(!too_many_pages_result.succeeded | |||
| && too_many_pages_result.error == ProjectStorageError::InvalidField, | |||
| "a JSON page array over 128 items must be rejected"); | |||
| service.editProject() = makeExampleProject(); | |||
| require(service.saveAs(valid_path.toStdString()).succeeded, | |||
| "the nested-expression fixture must be saved"); | |||
| root = QJsonDocument::fromJson(readBytes(valid_path)).object(); | |||
| QJsonArray logics = root.value(QStringLiteral("controlLogics")).toArray(); | |||
| QJsonObject logic = logics.at(0).toObject(); | |||
| QJsonArray rungs = logic.value(QStringLiteral("rungs")).toArray(); | |||
| QJsonObject rung = rungs.at(0).toObject(); | |||
| QJsonObject leaf = rung.value(QStringLiteral("condition")).toObject(); | |||
| while (leaf.value(QStringLiteral("kind")).toString() != QStringLiteral("node")) | |||
| { | |||
| leaf = leaf.value(QStringLiteral("children")).toArray().at(0).toObject(); | |||
| } | |||
| QJsonObject nested = leaf; | |||
| for (int depth = 1; | |||
| depth <= static_cast<int>(ProjectLimits::kMaximumExpressionDepth) + 1; | |||
| ++depth) | |||
| { | |||
| QJsonObject sibling = leaf; | |||
| sibling.insert( | |||
| QStringLiteral("id"), | |||
| QStringLiteral("json-depth-sibling-") + QString::number(depth)); | |||
| QJsonArray children; | |||
| children.append(nested); | |||
| children.append(sibling); | |||
| QJsonObject parent; | |||
| parent.insert( | |||
| QStringLiteral("id"), | |||
| QStringLiteral("json-depth-") + QString::number(depth)); | |||
| parent.insert( | |||
| QStringLiteral("kind"), | |||
| depth % 2 == 0 ? QStringLiteral("parallel") : QStringLiteral("series")); | |||
| parent.insert(QStringLiteral("children"), children); | |||
| nested = parent; | |||
| } | |||
| rung.insert(QStringLiteral("condition"), nested); | |||
| rungs[0] = rung; | |||
| logic.insert(QStringLiteral("rungs"), rungs); | |||
| logics[0] = logic; | |||
| root.insert(QStringLiteral("controlLogics"), logics); | |||
| const QString deep_path = directory.filePath("too-deep.json"); | |||
| writeText(deep_path, QJsonDocument(root).toJson(QJsonDocument::Compact)); | |||
| const ProjectLoadResult deep_result = storage.load(deep_path.toStdString()); | |||
| require(!deep_result.succeeded | |||
| && deep_result.error == ProjectStorageError::InvalidField, | |||
| "a condition expression deeper than 20 levels must be rejected while parsing"); | |||
| } | |||
| void testServiceStateAndSaveErrors() | |||
| { | |||
| // 保存路径和修改标记只在成功持久化后更新 | |||
| @@ -764,9 +888,11 @@ int main() | |||
| { | |||
| // 工程服务和 JSON 存储在同一测试进程中验证完整闭环 | |||
| testEmptyProjectRoundTrip(); | |||
| testUnconditionalOutputRoundTrip(); | |||
| testExampleProjectRoundTrip(); | |||
| testRegisterCommentService(); | |||
| testInvalidFiles(); | |||
| testQuantityFileLimits(); | |||
| testServiceStateAndSaveErrors(); | |||
| } | |||
| catch (const std::exception &error) | |||
| @@ -29,6 +29,7 @@ HEADERS += \ | |||
| ../src/domain/hmi_model.h \ | |||
| ../src/domain/hmi_control_registry.h \ | |||
| ../src/domain/control_logic_model.h \ | |||
| ../src/domain/project_limits.h \ | |||
| ../src/domain/project_model.h \ | |||
| ../src/domain/runtime_state.h \ | |||
| ../src/domain/project_storage.h \ | |||
| @@ -46,10 +46,11 @@ public: | |||
| initial_read = false; | |||
| } | |||
| void setPollAddresses( | |||
| PlcCommunicationResult setPollAddresses( | |||
| const std::vector<RegisterAddress> &addresses) override | |||
| { | |||
| poll_addresses = addresses; | |||
| return {true, {}}; | |||
| } | |||
| PlcConnectionState state() const override { return connection_state; } | |||
| bool initialReadCompleted() const override { return initial_read; } | |||