| @@ -76,6 +76,7 @@ HEADERS += \ | |||||
| src/domain/hmi_model.h \ | src/domain/hmi_model.h \ | ||||
| src/domain/hmi_control_registry.h \ | src/domain/hmi_control_registry.h \ | ||||
| src/domain/control_logic_model.h \ | src/domain/control_logic_model.h \ | ||||
| src/domain/project_limits.h \ | |||||
| src/domain/project_model.h \ | src/domain/project_model.h \ | ||||
| src/domain/runtime_state.h \ | src/domain/runtime_state.h \ | ||||
| src/domain/project_storage.h \ | src/domain/project_storage.h \ | ||||
| @@ -1,5 +1,7 @@ | |||||
| #include "alarm_model.h" | #include "alarm_model.h" | ||||
| #include "project_limits.h" | |||||
| namespace { | namespace { | ||||
| void setError(std::string *error, const std::string &message) | void setError(std::string *error, const std::string &message) | ||||
| @@ -19,11 +21,21 @@ bool AlarmDefinition::validate(std::string *error) const | |||||
| setError(error, "报警定义 ID 不能为空"); | setError(error, "报警定义 ID 不能为空"); | ||||
| return false; | return false; | ||||
| } | } | ||||
| if (id.size() > ProjectLimits::kMaximumIdBytes) | |||||
| { | |||||
| setError(error, "报警定义 ID 不能超过 128 个 UTF-8 字节"); | |||||
| return false; | |||||
| } | |||||
| if (message.empty()) | if (message.empty()) | ||||
| { | { | ||||
| setError(error, "报警文本不能为空"); | setError(error, "报警文本不能为空"); | ||||
| return false; | return false; | ||||
| } | } | ||||
| if (message.size() > ProjectLimits::kMaximumTextBytes) | |||||
| { | |||||
| setError(error, "报警文本不能超过 4096 个 UTF-8 字节"); | |||||
| return false; | |||||
| } | |||||
| if (!address.isValid()) | if (!address.isValid()) | ||||
| { | { | ||||
| setError(error, "报警定义使用了无效地址"); | setError(error, "报警定义使用了无效地址"); | ||||
| @@ -1,5 +1,7 @@ | |||||
| #include "control_logic_model.h" | #include "control_logic_model.h" | ||||
| #include "project_limits.h" | |||||
| #include <algorithm> | #include <algorithm> | ||||
| #include <map> | #include <map> | ||||
| #include <type_traits> | #include <type_traits> | ||||
| @@ -161,6 +163,12 @@ bool validateConfig(const CounterNodeConfig &config, std::string *error) | |||||
| { | { | ||||
| return false; | return false; | ||||
| } | } | ||||
| if (config.preset.kind == WordOperandKind::Constant | |||||
| && config.preset.constant < 0) | |||||
| { | |||||
| setError(error, "计数器常量预设值必须在 0~32767 范围内"); | |||||
| return false; | |||||
| } | |||||
| if (!config.resetAddress.isValid() | if (!config.resetAddress.isValid() | ||||
| || config.resetAddress.area() != RegisterArea::M) | || 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 | } // namespace | ||||
| TimerAddress::TimerAddress(int index) | TimerAddress::TimerAddress(int index) | ||||
| @@ -444,6 +577,11 @@ bool LogicNode::validate(std::string *error) const | |||||
| setError(error, "逻辑节点 ID 不能为空"); | setError(error, "逻辑节点 ID 不能为空"); | ||||
| return false; | return false; | ||||
| } | } | ||||
| if (id.size() > ProjectLimits::kMaximumIdBytes) | |||||
| { | |||||
| setError(error, "逻辑节点 ID 不能超过 128 个 UTF-8 字节"); | |||||
| return false; | |||||
| } | |||||
| return std::visit( | return std::visit( | ||||
| [error](const auto &config) { return validateConfig(config, error); }, | [error](const auto &config) { return validateConfig(config, error); }, | ||||
| config); | config); | ||||
| @@ -485,7 +623,7 @@ bool WireSegment::validate(std::string *error) const | |||||
| { | { | ||||
| if (columnSpan < kMinimumColumnSpan || columnSpan > kMaximumColumnSpan) | if (columnSpan < kMinimumColumnSpan || columnSpan > kMaximumColumnSpan) | ||||
| { | { | ||||
| setError(error, "横线跨度必须在 1~256 列范围内"); | |||||
| setError(error, "横线跨度必须在 1~10 列范围内"); | |||||
| return false; | return false; | ||||
| } | } | ||||
| return true; | return true; | ||||
| @@ -503,54 +641,11 @@ ConditionExpression ConditionExpression::fromWire( | |||||
| bool ConditionExpression::validate(std::string *error) const | 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 | bool ConditionExpression::validateForRunning(std::string *error) const | ||||
| @@ -701,12 +796,12 @@ bool LadderRung::validateForRunning(std::string *error) const | |||||
| { | { | ||||
| return true; | return true; | ||||
| } | } | ||||
| if (!condition.has_value() || !output.has_value()) | |||||
| if (!output.has_value()) | |||||
| { | { | ||||
| setError(error, "未完成的梯形图网络必须同时包含条件和输出节点"); | |||||
| setError(error, "梯形图网络必须包含输出指令"); | |||||
| return false; | return false; | ||||
| } | } | ||||
| if (!condition->validateForRunning(error)) | |||||
| if (condition.has_value() && !condition->validateForRunning(error)) | |||||
| { | { | ||||
| return false; | return false; | ||||
| } | } | ||||
| @@ -725,6 +820,17 @@ bool LadderRung::validateStructure(std::string *error) const | |||||
| setError(error, "梯形图网络 ID 和名称不能为空"); | setError(error, "梯形图网络 ID 和名称不能为空"); | ||||
| return false; | 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; | std::vector<std::string> node_ids; | ||||
| if (condition.has_value()) | if (condition.has_value()) | ||||
| { | { | ||||
| @@ -780,6 +886,21 @@ bool ControlLogic::validateStructure(std::string *error) const | |||||
| setError(error, "控制逻辑 ID 和名称不能为空"); | setError(error, "控制逻辑 ID 和名称不能为空"); | ||||
| return false; | 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)) | if (hasDuplicateId(rungs)) | ||||
| { | { | ||||
| setError(error, "同一控制逻辑内的网络 ID 必须唯一"); | setError(error, "同一控制逻辑内的网络 ID 必须唯一"); | ||||
| @@ -1,5 +1,6 @@ | |||||
| #pragma once | #pragma once | ||||
| #include "project_limits.h" | |||||
| #include "register_address.h" | #include "register_address.h" | ||||
| #include <cstdint> | #include <cstdint> | ||||
| @@ -212,7 +213,8 @@ enum class ConditionExpressionKind | |||||
| struct WireSegment | struct WireSegment | ||||
| { | { | ||||
| static constexpr int kMinimumColumnSpan = 1; | static constexpr int kMinimumColumnSpan = 1; | ||||
| static constexpr int kMaximumColumnSpan = 256; | |||||
| static constexpr int kMaximumColumnSpan = | |||||
| ProjectLimits::kMaximumConditionColumns; | |||||
| int columnSpan = 1; | int columnSpan = 1; | ||||
| @@ -249,7 +251,7 @@ void collectConditionNodes( | |||||
| void collectConditionExpressionIds( | void collectConditionExpressionIds( | ||||
| const ConditionExpression &expression, std::vector<std::string> *ids); | const ConditionExpression &expression, std::vector<std::string> *ids); | ||||
| // 一个网络包含一棵结构化条件表达式,输出指令固定在最右侧 | |||||
| // 条件表达式为空时表示恒真网络,输出指令固定在最右侧 | |||||
| struct LadderRung | struct LadderRung | ||||
| { | { | ||||
| std::string id; | std::string id; | ||||
| @@ -1,6 +1,7 @@ | |||||
| #include "hmi_model.h" | #include "hmi_model.h" | ||||
| #include "hmi_control_registry.h" | #include "hmi_control_registry.h" | ||||
| #include "project_limits.h" | |||||
| #include <algorithm> | #include <algorithm> | ||||
| @@ -47,9 +48,26 @@ bool HmiControl::validate(std::string *error) const | |||||
| setError(error, "HMI 控件 ID 不能为空"); | setError(error, "HMI 控件 ID 不能为空"); | ||||
| return false; | 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; | return false; | ||||
| } | } | ||||
| for (const auto &property : properties) | for (const auto &property : properties) | ||||
| @@ -59,6 +77,16 @@ bool HmiControl::validate(std::string *error) const | |||||
| setError(error, "HMI 控件属性名称不能为空"); | setError(error, "HMI 控件属性名称不能为空"); | ||||
| return false; | 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 = | const std::optional<RegisterArea> binding_area = | ||||
| hmiBindingArea(descriptor->bindingKind); | hmiBindingArea(descriptor->bindingKind); | ||||
| @@ -92,6 +120,11 @@ bool HmiControl::validate(std::string *error) const | |||||
| setError(error, "页面跳转控件缺少跳转配置"); | setError(error, "页面跳转控件缺少跳转配置"); | ||||
| return false; | return false; | ||||
| } | } | ||||
| if (pageJump->targetPageId.size() > ProjectLimits::kMaximumIdBytes) | |||||
| { | |||||
| setError(error, "页面跳转目标 ID 不能超过 128 个 UTF-8 字节"); | |||||
| return false; | |||||
| } | |||||
| } | } | ||||
| else if (pageJump.has_value()) | else if (pageJump.has_value()) | ||||
| { | { | ||||
| @@ -153,9 +186,26 @@ bool HmiPage::validate(std::string *error) const | |||||
| setError(error, "HMI 页面 ID 和名称不能为空"); | setError(error, "HMI 页面 ID 和名称不能为空"); | ||||
| return false; | 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; | 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_model.h" | ||||
| #include "project_limits.h" | |||||
| #include <algorithm> | #include <algorithm> | ||||
| #include <cctype> | #include <cctype> | ||||
| @@ -76,6 +78,11 @@ bool RegisterComment::validate(std::string *error) const | |||||
| setError(error, "软元件注释内容不能为空"); | setError(error, "软元件注释内容不能为空"); | ||||
| return false; | return false; | ||||
| } | } | ||||
| if (text.size() > ProjectLimits::kMaximumTextBytes) | |||||
| { | |||||
| setError(error, "软元件注释不能超过 4096 个 UTF-8 字节"); | |||||
| return false; | |||||
| } | |||||
| return true; | return true; | ||||
| } | } | ||||
| @@ -98,6 +105,42 @@ bool Project::validate(std::string *error) const | |||||
| setError(error, "工程 ID、名称和格式版本不能为空"); | setError(error, "工程 ID、名称和格式版本不能为空"); | ||||
| return false; | 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)) | if (containsDuplicateId(hmiPages)) | ||||
| { | { | ||||
| setError(error, "工程内的 HMI 页面 ID 必须唯一"); | setError(error, "工程内的 HMI 页面 ID 必须唯一"); | ||||
| @@ -203,6 +246,57 @@ bool Project::validate(std::string *error) const | |||||
| return false; | 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; | return true; | ||||
| } | } | ||||
| @@ -1,6 +1,7 @@ | |||||
| #include "json_project_storage.h" | #include "json_project_storage.h" | ||||
| #include "domain/hmi_control_registry.h" | #include "domain/hmi_control_registry.h" | ||||
| #include "domain/project_limits.h" | |||||
| #include <QFile> | #include <QFile> | ||||
| #include <QJsonArray> | #include <QJsonArray> | ||||
| @@ -81,7 +82,8 @@ bool readString( | |||||
| const char *field, | const char *field, | ||||
| const std::string &context, | const std::string &context, | ||||
| std::string *value, | std::string *value, | ||||
| ParseState *state) | |||||
| ParseState *state, | |||||
| std::size_t maximum_bytes = ProjectLimits::kMaximumTextBytes) | |||||
| { | { | ||||
| QJsonValue json_value; | QJsonValue json_value; | ||||
| if (!readValue(object, field, context, &json_value, state)) | if (!readValue(object, field, context, &json_value, state)) | ||||
| @@ -95,6 +97,12 @@ bool readString( | |||||
| fieldPath(context, field) + " 必须是字符串"); | fieldPath(context, field) + " 必须是字符串"); | ||||
| } | } | ||||
| *value = toUtf8(json_value.toString()); | *value = toUtf8(json_value.toString()); | ||||
| if (value->size() > maximum_bytes) | |||||
| { | |||||
| return state->fail( | |||||
| ProjectStorageError::InvalidField, | |||||
| fieldPath(context, field) + " 超出允许的 UTF-8 字节长度"); | |||||
| } | |||||
| return true; | return true; | ||||
| } | } | ||||
| @@ -185,7 +193,8 @@ bool readArray( | |||||
| const char *field, | const char *field, | ||||
| const std::string &context, | const std::string &context, | ||||
| QJsonArray *value, | QJsonArray *value, | ||||
| ParseState *state) | |||||
| ParseState *state, | |||||
| int maximum_count = std::numeric_limits<int>::max()) | |||||
| { | { | ||||
| QJsonValue json_value; | QJsonValue json_value; | ||||
| if (!readValue(object, field, context, &json_value, state)) | if (!readValue(object, field, context, &json_value, state)) | ||||
| @@ -199,6 +208,12 @@ bool readArray( | |||||
| fieldPath(context, field) + " 必须是数组"); | fieldPath(context, field) + " 必须是数组"); | ||||
| } | } | ||||
| *value = json_value.toArray(); | *value = json_value.toArray(); | ||||
| if (value->size() > maximum_count) | |||||
| { | |||||
| return state->fail( | |||||
| ProjectStorageError::InvalidField, | |||||
| fieldPath(context, field) + " 的元素数量超过上限"); | |||||
| } | |||||
| return true; | return true; | ||||
| } | } | ||||
| @@ -535,7 +550,9 @@ bool parseAlarmDefinition( | |||||
| QJsonObject address; | QJsonObject address; | ||||
| std::string condition; | std::string condition; | ||||
| int threshold = 0; | 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) | || !readObject(object, "address", context, &address, state) | ||||
| || !readString(object, "condition", context, &condition, state) | || !readString(object, "condition", context, &condition, state) | ||||
| || !readInt( | || !readInt( | ||||
| @@ -604,16 +621,16 @@ bool parseBounds( | |||||
| object, | object, | ||||
| "x", | "x", | ||||
| context, | context, | ||||
| std::numeric_limits<int>::min(), | |||||
| std::numeric_limits<int>::max(), | |||||
| 0, | |||||
| ProjectLimits::kMaximumHmiPageWidth, | |||||
| &bounds->x, | &bounds->x, | ||||
| state) | state) | ||||
| && readInt( | && readInt( | ||||
| object, | object, | ||||
| "y", | "y", | ||||
| context, | context, | ||||
| std::numeric_limits<int>::min(), | |||||
| std::numeric_limits<int>::max(), | |||||
| 0, | |||||
| ProjectLimits::kMaximumHmiPageHeight, | |||||
| &bounds->y, | &bounds->y, | ||||
| state) | state) | ||||
| && readInt( | && readInt( | ||||
| @@ -621,7 +638,7 @@ bool parseBounds( | |||||
| "width", | "width", | ||||
| context, | context, | ||||
| 1, | 1, | ||||
| std::numeric_limits<int>::max(), | |||||
| ProjectLimits::kMaximumHmiControlWidth, | |||||
| &bounds->width, | &bounds->width, | ||||
| state) | state) | ||||
| && readInt( | && readInt( | ||||
| @@ -629,7 +646,7 @@ bool parseBounds( | |||||
| "height", | "height", | ||||
| context, | context, | ||||
| 1, | 1, | ||||
| std::numeric_limits<int>::max(), | |||||
| ProjectLimits::kMaximumHmiControlHeight, | |||||
| &bounds->height, | &bounds->height, | ||||
| state); | state); | ||||
| } | } | ||||
| @@ -651,6 +668,12 @@ bool parseProperties( | |||||
| std::map<std::string, std::string> *properties, | std::map<std::string, std::string> *properties, | ||||
| ParseState *state) | 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) | for (auto current = object.constBegin(); current != object.constEnd(); ++current) | ||||
| { | { | ||||
| if (!current.value().isString()) | if (!current.value().isString()) | ||||
| @@ -659,7 +682,16 @@ bool parseProperties( | |||||
| ProjectStorageError::InvalidField, | ProjectStorageError::InvalidField, | ||||
| "HMI 控件属性值必须是字符串"); | "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; | return true; | ||||
| } | } | ||||
| @@ -718,7 +750,9 @@ bool parseHmiControl( | |||||
| QJsonObject bounds; | QJsonObject bounds; | ||||
| QJsonObject properties; | QJsonObject properties; | ||||
| QJsonValue binding; | 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) | || !readString(object, "type", context, &type_text, state) | ||||
| || !readObject(object, "bounds", context, &bounds, state) | || !readObject(object, "bounds", context, &bounds, state) | ||||
| || !readString(object, "text", context, &control->text, state) | || !readString(object, "text", context, &control->text, state) | ||||
| @@ -751,7 +785,9 @@ bool parseHmiControl( | |||||
| if (control->type == HmiControlType::PageJump) | if (control->type == HmiControlType::PageJump) | ||||
| { | { | ||||
| std::string target_page_id; | 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; | return false; | ||||
| } | } | ||||
| @@ -836,13 +872,19 @@ bool parseHmiPage( | |||||
| ParseState *state) | ParseState *state) | ||||
| { | { | ||||
| QJsonArray controls; | 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) | || !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) | &page->width, state) | ||||
| || !readInt(object, "height", context, 1, std::numeric_limits<int>::max(), | |||||
| || !readInt(object, "height", context, 1, | |||||
| ProjectLimits::kMaximumHmiPageHeight, | |||||
| &page->height, state) | &page->height, state) | ||||
| || !readArray(object, "controls", context, &controls, state)) | |||||
| || !readArray( | |||||
| object, "controls", context, &controls, state, | |||||
| static_cast<int>(ProjectLimits::kMaximumHmiControlsPerPage))) | |||||
| { | { | ||||
| return false; | return false; | ||||
| } | } | ||||
| @@ -1452,7 +1494,9 @@ bool parseLogicNode( | |||||
| ParseState *state) | ParseState *state) | ||||
| { | { | ||||
| QJsonObject config; | 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) | || !readBool(object, "configured", context, &node->configured, state) | ||||
| || !readObject(object, "config", context, &config, state) | || !readObject(object, "config", context, &config, state) | ||||
| || !parseNodeConfig(config, context + ".config", &node->config, state)) | || !parseNodeConfig(config, context + ".config", &node->config, state)) | ||||
| @@ -1507,10 +1551,27 @@ bool parseConditionExpression( | |||||
| const QJsonObject &object, | const QJsonObject &object, | ||||
| const std::string &context, | const std::string &context, | ||||
| ConditionExpression *expression, | 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; | 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)) | || !readString(object, "kind", context, &kind, state)) | ||||
| { | { | ||||
| return false; | return false; | ||||
| @@ -1556,7 +1617,9 @@ bool parseConditionExpression( | |||||
| context + ".kind 必须是 node、wire、series 或 parallel"); | context + ".kind 必须是 node、wire、series 或 parallel"); | ||||
| } | } | ||||
| QJsonArray children; | QJsonArray children; | ||||
| if (!readArray(object, "children", context, &children, state)) | |||||
| if (!readArray( | |||||
| object, "children", context, &children, state, | |||||
| static_cast<int>(ProjectLimits::kMaximumExpressionChildren))) | |||||
| { | { | ||||
| return false; | return false; | ||||
| } | } | ||||
| @@ -1576,7 +1639,9 @@ bool parseConditionExpression( | |||||
| children.at(index).toObject(), | children.at(index).toObject(), | ||||
| context + ".children[" + std::to_string(index) + ']', | context + ".children[" + std::to_string(index) + ']', | ||||
| &child, | &child, | ||||
| state)) | |||||
| state, | |||||
| depth + 1U, | |||||
| node_count)) | |||||
| { | { | ||||
| return false; | return false; | ||||
| } | } | ||||
| @@ -1611,7 +1676,9 @@ bool parseLadderRung( | |||||
| { | { | ||||
| QJsonValue condition; | QJsonValue condition; | ||||
| QJsonValue output; | 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, "name", context, &rung->name, state) | ||||
| || !readString(object, "comment", context, &rung->comment, state) | || !readString(object, "comment", context, &rung->comment, state) | ||||
| || !readValue(object, "output", context, &output, state)) | || !readValue(object, "output", context, &output, state)) | ||||
| @@ -1635,8 +1702,10 @@ bool parseLadderRung( | |||||
| else | else | ||||
| { | { | ||||
| ConditionExpression parsed_condition; | ConditionExpression parsed_condition; | ||||
| std::size_t expression_node_count = 0U; | |||||
| if (!parseConditionExpression( | if (!parseConditionExpression( | ||||
| condition.toObject(), context + ".condition", &parsed_condition, state)) | |||||
| condition.toObject(), context + ".condition", &parsed_condition, | |||||
| state, 1U, &expression_node_count)) | |||||
| { | { | ||||
| return false; | return false; | ||||
| } | } | ||||
| @@ -1684,10 +1753,14 @@ bool parseControlLogic( | |||||
| ParseState *state) | ParseState *state) | ||||
| { | { | ||||
| QJsonArray rungs; | 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) | || !readString(object, "name", context, &logic->name, state) | ||||
| || !readBool(object, "enabled", context, &logic->enabled, 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; | return false; | ||||
| } | } | ||||
| @@ -1767,7 +1840,8 @@ bool parseProject( | |||||
| "formatVersion", | "formatVersion", | ||||
| "project", | "project", | ||||
| &project->metadata.formatVersion, | &project->metadata.formatVersion, | ||||
| state)) | |||||
| state, | |||||
| ProjectLimits::kMaximumIdBytes)) | |||||
| { | { | ||||
| return false; | return false; | ||||
| } | } | ||||
| @@ -1779,14 +1853,26 @@ bool parseProject( | |||||
| "不支持的工程格式版本:" + project->metadata.formatVersion); | "不支持的工程格式版本:" + 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) | || !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; | return false; | ||||
| } | } | ||||
| @@ -1913,6 +1999,14 @@ ProjectSaveResult JsonProjectStorage::save( | |||||
| const QByteArray data = QJsonDocument(serializeProject(project)).toJson( | const QByteArray data = QJsonDocument(serializeProject(project)).toJson( | ||||
| QJsonDocument::Indented); | 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()) | if (file.write(data) != data.size()) | ||||
| { | { | ||||
| @@ -1937,8 +2031,19 @@ ProjectLoadResult JsonProjectStorage::load(const std::string &file_path) | |||||
| ProjectStorageError::FileOpenFailed, | ProjectStorageError::FileOpenFailed, | ||||
| toUtf8(file.errorString())}; | 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) | if (file.error() != QFileDevice::NoError) | ||||
| { | { | ||||
| return {false, | return {false, | ||||
| @@ -1946,6 +2051,14 @@ ProjectLoadResult JsonProjectStorage::load(const std::string &file_path) | |||||
| ProjectStorageError::FileReadFailed, | ProjectStorageError::FileReadFailed, | ||||
| toUtf8(file.errorString())}; | toUtf8(file.errorString())}; | ||||
| } | } | ||||
| if (static_cast<std::size_t>(data.size()) | |||||
| > ProjectLimits::kMaximumProjectFileBytes) | |||||
| { | |||||
| return {false, | |||||
| {}, | |||||
| ProjectStorageError::InvalidJson, | |||||
| "工程 JSON 文件不能超过 16 MiB"}; | |||||
| } | |||||
| // 顶层必须是 JSON 对象,数组或标量不能表示完整工程 | // 顶层必须是 JSON 对象,数组或标量不能表示完整工程 | ||||
| QJsonParseError parse_error; | QJsonParseError parse_error; | ||||
| @@ -15,7 +15,6 @@ | |||||
| namespace { | namespace { | ||||
| constexpr int kMaximumReadCount = 120; | |||||
| constexpr int kRecoveryProbeIntervalMs = 2000; | constexpr int kRecoveryProbeIntervalMs = 2000; | ||||
| std::string toUtf8(const QString &value) | std::string toUtf8(const QString &value) | ||||
| @@ -42,6 +41,67 @@ bool isReadingState(PlcConnectionState state) | |||||
| || state == PlcConnectionState::Recovering; | || 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 | } // namespace | ||||
| PlcCommunicationService::PlcCommunicationService( | PlcCommunicationService::PlcCommunicationService( | ||||
| @@ -115,10 +175,11 @@ PlcCommunicationService::~PlcCommunicationService() = default; | |||||
| PlcCommunicationResult PlcCommunicationService::connectDevice( | PlcCommunicationResult PlcCommunicationService::connectDevice( | ||||
| const PlcSerialConfiguration &configuration) | 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 | if (state_ == PlcConnectionState::Disconnected | ||||
| && master_->state() != QModbusDevice::UnconnectedState) | && master_->state() != QModbusDevice::UnconnectedState) | ||||
| @@ -179,16 +240,27 @@ void PlcCommunicationService::disconnectDevice() | |||||
| setState(PlcConnectionState::Disconnected); | setState(PlcConnectionState::Disconnected); | ||||
| } | } | ||||
| void PlcCommunicationService::setPollAddresses( | |||||
| PlcCommunicationResult PlcCommunicationService::setPollAddresses( | |||||
| const std::vector<RegisterAddress> &addresses) | 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) | if (pending_reply_ != nullptr) | ||||
| { | { | ||||
| pending_poll_addresses_ = addresses; | |||||
| pending_poll_addresses_ = std::move(normalized); | |||||
| poll_update_pending_ = true; | poll_update_pending_ = true; | ||||
| return; | |||||
| return {true, {}}; | |||||
| } | } | ||||
| applyPollAddresses(addresses); | |||||
| applyPollAddresses(normalized); | |||||
| return {true, {}}; | |||||
| } | } | ||||
| PlcConnectionState PlcCommunicationService::state() const | PlcConnectionState PlcCommunicationService::state() const | ||||
| @@ -259,7 +331,8 @@ void PlcCommunicationService::rebuildPollBlocks() | |||||
| || poll_blocks_.back().area != address.area() | || poll_blocks_.back().area != address.area() | ||||
| || address.index() > poll_blocks_.back().startAddress | || address.index() > poll_blocks_.back().startAddress | ||||
| + poll_blocks_.back().count | + poll_blocks_.back().count | ||||
| || poll_blocks_.back().count >= kMaximumReadCount) | |||||
| || poll_blocks_.back().count | |||||
| >= ProjectLimits::kMaximumModbusReadCount) | |||||
| { | { | ||||
| poll_blocks_.push_back({address.area(), address.index(), 1}); | poll_blocks_.push_back({address.area(), address.index(), 1}); | ||||
| } | } | ||||
| @@ -491,6 +564,10 @@ RegisterWriteResult PlcCommunicationService::sendBitWrite( | |||||
| { | { | ||||
| return {false, RegisterError::Unavailable}; | return {false, RegisterError::Unavailable}; | ||||
| } | } | ||||
| if (pending_write_reply_ != nullptr) | |||||
| { | |||||
| return {false, RegisterError::WriteRejected}; | |||||
| } | |||||
| QModbusDataUnit unit(QModbusDataUnit::Coils, address.index(), 1); | QModbusDataUnit unit(QModbusDataUnit::Coils, address.index(), 1); | ||||
| unit.setValue(0, value ? 1U : 0U); | unit.setValue(0, value ? 1U : 0U); | ||||
| QModbusReply *reply = master_->sendWriteRequest(unit, configuration_.serverAddress); | QModbusReply *reply = master_->sendWriteRequest(unit, configuration_.serverAddress); | ||||
| @@ -499,10 +576,15 @@ RegisterWriteResult PlcCommunicationService::sendBitWrite( | |||||
| handleModbusError(master_->error()); | handleModbusError(master_->error()); | ||||
| return {false, RegisterError::WriteRejected}; | return {false, RegisterError::WriteRejected}; | ||||
| } | } | ||||
| pending_write_reply_ = reply; | |||||
| connect(reply, &QModbusReply::finished, | connect(reply, &QModbusReply::finished, | ||||
| this, | this, | ||||
| [this, reply, generation = connection_generation_] | [this, reply, generation = connection_generation_] | ||||
| { | { | ||||
| if (pending_write_reply_ == reply) | |||||
| { | |||||
| pending_write_reply_ = nullptr; | |||||
| } | |||||
| if (generation != connection_generation_) | if (generation != connection_generation_) | ||||
| { | { | ||||
| reply->deleteLater(); | reply->deleteLater(); | ||||
| @@ -524,6 +606,10 @@ RegisterWriteResult PlcCommunicationService::sendWordWrite( | |||||
| { | { | ||||
| return {false, RegisterError::Unavailable}; | return {false, RegisterError::Unavailable}; | ||||
| } | } | ||||
| if (pending_write_reply_ != nullptr) | |||||
| { | |||||
| return {false, RegisterError::WriteRejected}; | |||||
| } | |||||
| QModbusDataUnit unit(QModbusDataUnit::HoldingRegisters, address.index(), 1); | QModbusDataUnit unit(QModbusDataUnit::HoldingRegisters, address.index(), 1); | ||||
| unit.setValue(0, static_cast<quint16>(value)); | unit.setValue(0, static_cast<quint16>(value)); | ||||
| QModbusReply *reply = master_->sendWriteRequest(unit, configuration_.serverAddress); | QModbusReply *reply = master_->sendWriteRequest(unit, configuration_.serverAddress); | ||||
| @@ -532,10 +618,15 @@ RegisterWriteResult PlcCommunicationService::sendWordWrite( | |||||
| handleModbusError(master_->error()); | handleModbusError(master_->error()); | ||||
| return {false, RegisterError::WriteRejected}; | return {false, RegisterError::WriteRejected}; | ||||
| } | } | ||||
| pending_write_reply_ = reply; | |||||
| connect(reply, &QModbusReply::finished, | connect(reply, &QModbusReply::finished, | ||||
| this, | this, | ||||
| [this, reply, generation = connection_generation_] | [this, reply, generation = connection_generation_] | ||||
| { | { | ||||
| if (pending_write_reply_ == reply) | |||||
| { | |||||
| pending_write_reply_ = nullptr; | |||||
| } | |||||
| if (generation != connection_generation_) | if (generation != connection_generation_) | ||||
| { | { | ||||
| reply->deleteLater(); | reply->deleteLater(); | ||||
| @@ -601,6 +692,7 @@ void PlcCommunicationService::closeSerialSession() | |||||
| poll_timer_.stop(); | poll_timer_.stop(); | ||||
| recovery_timer_.stop(); | recovery_timer_.stop(); | ||||
| pending_reply_ = nullptr; | pending_reply_ = nullptr; | ||||
| pending_write_reply_ = nullptr; | |||||
| poll_update_pending_ = false; | poll_update_pending_ = false; | ||||
| pending_poll_addresses_.clear(); | pending_poll_addresses_.clear(); | ||||
| if (master_->state() != QModbusDevice::UnconnectedState) | if (master_->state() != QModbusDevice::UnconnectedState) | ||||
| @@ -30,7 +30,8 @@ public: | |||||
| PlcCommunicationResult connectDevice( | PlcCommunicationResult connectDevice( | ||||
| const PlcSerialConfiguration &configuration) override; | const PlcSerialConfiguration &configuration) override; | ||||
| void disconnectDevice() override; | void disconnectDevice() override; | ||||
| void setPollAddresses(const std::vector<RegisterAddress> &addresses) override; | |||||
| PlcCommunicationResult setPollAddresses( | |||||
| const std::vector<RegisterAddress> &addresses) override; | |||||
| PlcConnectionState state() const override; | PlcConnectionState state() const override; | ||||
| bool initialReadCompleted() const override; | bool initialReadCompleted() const override; | ||||
| @@ -84,6 +85,7 @@ private: | |||||
| std::vector<PollBlock> poll_blocks_; | std::vector<PollBlock> poll_blocks_; | ||||
| std::size_t next_poll_block_ = 0; | std::size_t next_poll_block_ = 0; | ||||
| QModbusReply *pending_reply_ = nullptr; | QModbusReply *pending_reply_ = nullptr; | ||||
| QModbusReply *pending_write_reply_ = nullptr; | |||||
| bool poll_update_pending_ = false; | bool poll_update_pending_ = false; | ||||
| bool disconnecting_ = false; | bool disconnecting_ = false; | ||||
| bool serial_session_opened_ = false; | bool serial_session_opened_ = false; | ||||
| @@ -1,6 +1,7 @@ | |||||
| #include "alarm_editor_service.h" | #include "alarm_editor_service.h" | ||||
| #include "project_service.h" | #include "project_service.h" | ||||
| #include "domain/project_limits.h" | |||||
| #include <algorithm> | #include <algorithm> | ||||
| @@ -36,6 +37,10 @@ const AlarmDefinition *AlarmEditorService::findDefinition( | |||||
| AlarmEditorResult AlarmEditorService::addDefinition( | AlarmEditorResult AlarmEditorService::addDefinition( | ||||
| const AlarmDefinition &definition) | const AlarmDefinition &definition) | ||||
| { | { | ||||
| if (definitions().size() >= ProjectLimits::kMaximumAlarmDefinitions) | |||||
| { | |||||
| return failure(AlarmEditorError::InvalidDefinition, "单个工程最多包含 512 条报警定义"); | |||||
| } | |||||
| AlarmDefinition candidate = definition; | AlarmDefinition candidate = definition; | ||||
| candidate.id = makeUniqueId(); | candidate.id = makeUniqueId(); | ||||
| std::string error; | std::string error; | ||||
| @@ -2,6 +2,7 @@ | |||||
| #include "domain/hmi_control_registry.h" | #include "domain/hmi_control_registry.h" | ||||
| #include "project_service.h" | #include "project_service.h" | ||||
| #include "domain/project_limits.h" | |||||
| #include <algorithm> | #include <algorithm> | ||||
| #include <cctype> | #include <cctype> | ||||
| @@ -210,6 +211,14 @@ HmiEditorResult HmiEditorService::addPage(const std::string &name) | |||||
| return failure(HmiEditorError::InvalidPage, "HMI 页面名称不能为空"); | return failure(HmiEditorError::InvalidPage, "HMI 页面名称不能为空"); | ||||
| } | } | ||||
| const Project ¤t = project_service_.project(); | 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( | const bool duplicate = std::any_of( | ||||
| current.hmiPages.cbegin(), current.hmiPages.cend(), | current.hmiPages.cbegin(), current.hmiPages.cend(), | ||||
| [&name](const HmiPage &page) { return page.name == name; }); | [&name](const HmiPage &page) { return page.name == name; }); | ||||
| @@ -239,6 +248,10 @@ HmiEditorResult HmiEditorService::renamePage( | |||||
| { | { | ||||
| return failure(HmiEditorError::InvalidPage, "HMI 页面名称不能为空"); | 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 Project ¤t = project_service_.project(); | ||||
| const auto existing = std::find_if( | const auto existing = std::find_if( | ||||
| current.hmiPages.cbegin(), current.hmiPages.cend(), | current.hmiPages.cbegin(), current.hmiPages.cend(), | ||||
| @@ -376,6 +389,10 @@ HmiEditorResult HmiEditorService::addControl( | |||||
| { | { | ||||
| return failure(HmiEditorError::PageNotFound, "未找到 HMI 页面"); | return failure(HmiEditorError::PageNotFound, "未找到 HMI 页面"); | ||||
| } | } | ||||
| if (page->controls.size() >= ProjectLimits::kMaximumHmiControlsPerPage) | |||||
| { | |||||
| return failure(HmiEditorError::InvalidControl, "单个 HMI 页面最多包含 512 个控件"); | |||||
| } | |||||
| const HmiControlDescriptor *descriptor = findHmiControlDescriptor(type); | const HmiControlDescriptor *descriptor = findHmiControlDescriptor(type); | ||||
| if (descriptor == nullptr) | if (descriptor == nullptr) | ||||
| { | { | ||||
| @@ -1,6 +1,7 @@ | |||||
| #include "logic_editor_service.h" | #include "logic_editor_service.h" | ||||
| #include "project_service.h" | #include "project_service.h" | ||||
| #include "domain/project_limits.h" | |||||
| #include <algorithm> | #include <algorithm> | ||||
| #include <cctype> | #include <cctype> | ||||
| @@ -651,6 +652,14 @@ LogicEditorResult LogicEditorService::addLogic(const std::string &name) | |||||
| return failure(LogicEditorError::InvalidOperation, "控制逻辑名称不能为空"); | return failure(LogicEditorError::InvalidOperation, "控制逻辑名称不能为空"); | ||||
| } | } | ||||
| const Project ¤t = project_service_.project(); | 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( | const bool duplicate = std::any_of( | ||||
| current.controlLogics.cbegin(), current.controlLogics.cend(), | current.controlLogics.cbegin(), current.controlLogics.cend(), | ||||
| [&name](const ControlLogic &logic) { return logic.name == name; }); | [&name](const ControlLogic &logic) { return logic.name == name; }); | ||||
| @@ -677,6 +686,10 @@ LogicEditorResult LogicEditorService::renameLogic( | |||||
| { | { | ||||
| return failure(LogicEditorError::InvalidOperation, "控制逻辑名称不能为空"); | return failure(LogicEditorError::InvalidOperation, "控制逻辑名称不能为空"); | ||||
| } | } | ||||
| if (name.size() > ProjectLimits::kMaximumTextBytes) | |||||
| { | |||||
| return failure(LogicEditorError::InvalidOperation, "控制逻辑名称不能超过 4096 个 UTF-8 字节"); | |||||
| } | |||||
| const Project ¤t = project_service_.project(); | const Project ¤t = project_service_.project(); | ||||
| const auto logic = std::find_if( | const auto logic = std::find_if( | ||||
| current.controlLogics.cbegin(), current.controlLogics.cend(), | current.controlLogics.cbegin(), current.controlLogics.cend(), | ||||
| @@ -795,6 +808,10 @@ LogicEditorResult LogicEditorService::addRung(const std::string &logic_id) | |||||
| { | { | ||||
| return failure(LogicEditorError::LogicNotFound, "未找到控制逻辑"); | return failure(LogicEditorError::LogicNotFound, "未找到控制逻辑"); | ||||
| } | } | ||||
| if (logic->rungs.size() >= ProjectLimits::kMaximumRungsPerLogic) | |||||
| { | |||||
| return failure(LogicEditorError::InvalidOperation, "单组控制逻辑最多包含 1024 个网络"); | |||||
| } | |||||
| LadderRung rung; | LadderRung rung; | ||||
| rung.id = makeUniqueRungId(*logic); | rung.id = makeUniqueRungId(*logic); | ||||
| rung.name = "网络 " + std::to_string(logic->rungs.size() + 1U); | rung.name = "网络 " + std::to_string(logic->rungs.size() + 1U); | ||||
| @@ -848,6 +865,12 @@ LogicEditorResult LogicEditorService::updateRungComment( | |||||
| { | { | ||||
| return failure(LogicEditorError::RungNotFound, "未找到梯形图网络"); | return failure(LogicEditorError::RungNotFound, "未找到梯形图网络"); | ||||
| } | } | ||||
| if (comment.size() > ProjectLimits::kMaximumTextBytes) | |||||
| { | |||||
| return failure( | |||||
| LogicEditorError::InvalidOperation, | |||||
| "梯形图网络注释不能超过 4096 个 UTF-8 字节"); | |||||
| } | |||||
| if (rung->comment == comment) | if (rung->comment == comment) | ||||
| { | { | ||||
| return {true, LogicEditorError::None, {}, rung_id}; | return {true, LogicEditorError::None, {}, rung_id}; | ||||
| @@ -897,6 +920,12 @@ LogicEditorResult LogicEditorService::appendCondition( | |||||
| std::move(*rung->condition), | std::move(*rung->condition), | ||||
| std::move(leaf)); | 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)); | recordHistory(std::move(before)); | ||||
| return {true, LogicEditorError::None, {}, node_id}; | return {true, LogicEditorError::None, {}, node_id}; | ||||
| } | } | ||||
| @@ -941,6 +970,12 @@ LogicEditorResult LogicEditorService::appendWire( | |||||
| std::move(*rung->condition), | std::move(*rung->condition), | ||||
| std::move(leaf)); | 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)); | recordHistory(std::move(before)); | ||||
| return {true, LogicEditorError::None, {}, wire_id}; | return {true, LogicEditorError::None, {}, wire_id}; | ||||
| } | } | ||||
| @@ -990,6 +1025,12 @@ LogicEditorResult LogicEditorService::insertConditionAfter( | |||||
| std::move(original), | std::move(original), | ||||
| std::move(leaf)); | 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)); | recordHistory(std::move(before)); | ||||
| return {true, LogicEditorError::None, {}, node_id}; | return {true, LogicEditorError::None, {}, node_id}; | ||||
| } | } | ||||
| @@ -1046,6 +1087,12 @@ LogicEditorResult LogicEditorService::insertWireAfter( | |||||
| std::move(original), | std::move(original), | ||||
| std::move(leaf)); | 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)); | recordHistory(std::move(before)); | ||||
| return {true, LogicEditorError::None, {}, wire_id}; | return {true, LogicEditorError::None, {}, wire_id}; | ||||
| } | } | ||||
| @@ -1075,6 +1122,12 @@ LogicEditorResult LogicEditorService::replaceWireWithCondition( | |||||
| ConditionExpression *editable = findConditionExpression( | ConditionExpression *editable = findConditionExpression( | ||||
| *rung->condition, wire_expression_id); | *rung->condition, wire_expression_id); | ||||
| *editable = ConditionExpression::fromNode(makeNode(node_id, config)); | *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)); | recordHistory(std::move(before)); | ||||
| return {true, LogicEditorError::None, {}, node_id}; | return {true, LogicEditorError::None, {}, node_id}; | ||||
| } | } | ||||
| @@ -1131,10 +1184,17 @@ LogicEditorResult LogicEditorService::addParallelBranch( | |||||
| parallel_id, | parallel_id, | ||||
| series_id)) | series_id)) | ||||
| { | { | ||||
| project.controlLogics = before.logics; | |||||
| return failure( | return failure( | ||||
| LogicEditorError::InvalidOperation, | 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)); | recordHistory(std::move(before)); | ||||
| return {true, LogicEditorError::None, {}, node_id}; | return {true, LogicEditorError::None, {}, node_id}; | ||||
| } | } | ||||
| @@ -1197,10 +1257,17 @@ LogicEditorResult LogicEditorService::addParallelWireBranch( | |||||
| parallel_id, | parallel_id, | ||||
| series_id)) | series_id)) | ||||
| { | { | ||||
| project_service_.editProject().controlLogics = before.logics; | |||||
| return failure( | return failure( | ||||
| LogicEditorError::InvalidOperation, | 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)); | recordHistory(std::move(before)); | ||||
| return {true, LogicEditorError::None, {}, wire_id}; | return {true, LogicEditorError::None, {}, wire_id}; | ||||
| } | } | ||||
| @@ -1223,6 +1290,11 @@ LogicEditorResult LogicEditorService::setOutput( | |||||
| ? existing_rung->output->id : makeUniqueNodeId(*logic, nodePrefix(config)); | ? existing_rung->output->id : makeUniqueNodeId(*logic, nodePrefix(config)); | ||||
| LogicNode node = makeNode(node_id, config); | LogicNode node = makeNode(node_id, config); | ||||
| node.configured = configured; | node.configured = configured; | ||||
| std::string validation_error; | |||||
| if (!node.validate(&validation_error)) | |||||
| { | |||||
| return failure(LogicEditorError::InvalidNode, validation_error); | |||||
| } | |||||
| if (existing_rung->output.has_value() | if (existing_rung->output.has_value() | ||||
| && nodesEqual(*existing_rung->output, node)) | && nodesEqual(*existing_rung->output, node)) | ||||
| { | { | ||||
| @@ -1,7 +1,10 @@ | |||||
| #pragma once | #pragma once | ||||
| #include "domain/register_address.h" | #include "domain/register_address.h" | ||||
| #include "domain/project_limits.h" | |||||
| #include <algorithm> | |||||
| #include <cctype> | |||||
| #include <functional> | #include <functional> | ||||
| #include <string> | #include <string> | ||||
| #include <vector> | #include <vector> | ||||
| @@ -50,6 +53,61 @@ struct PlcCommunicationResult | |||||
| std::string message; | 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 | class PlcCommunicationGateway | ||||
| { | { | ||||
| public: | public: | ||||
| @@ -58,7 +116,7 @@ public: | |||||
| virtual PlcCommunicationResult connectDevice( | virtual PlcCommunicationResult connectDevice( | ||||
| const PlcSerialConfiguration &configuration) = 0; | const PlcSerialConfiguration &configuration) = 0; | ||||
| virtual void disconnectDevice() = 0; | virtual void disconnectDevice() = 0; | ||||
| virtual void setPollAddresses( | |||||
| virtual PlcCommunicationResult setPollAddresses( | |||||
| const std::vector<RegisterAddress> &addresses) = 0; | const std::vector<RegisterAddress> &addresses) = 0; | ||||
| virtual PlcConnectionState state() const = 0; | virtual PlcConnectionState state() const = 0; | ||||
| virtual bool initialReadCompleted() const = 0; | virtual bool initialReadCompleted() const = 0; | ||||
| @@ -10,6 +10,7 @@ | |||||
| #include "project_service.h" | #include "project_service.h" | ||||
| #include "domain/active_register_repository.h" | #include "domain/active_register_repository.h" | ||||
| #include "domain/project_limits.h" | |||||
| #include <algorithm> | #include <algorithm> | ||||
| @@ -189,7 +190,11 @@ PlcCommunicationResult RuntimeModeService::connectPlc( | |||||
| { | { | ||||
| plc_gateway_->disconnectDevice(); | plc_gateway_->disconnectDevice(); | ||||
| } | } | ||||
| refreshPlcPollAddresses(); | |||||
| const PlcCommunicationResult poll_result = refreshPlcPollAddresses(); | |||||
| if (!poll_result.succeeded) | |||||
| { | |||||
| return poll_result; | |||||
| } | |||||
| setInitialPlcReadCompleted(false); | setInitialPlcReadCompleted(false); | ||||
| return plc_gateway_->connectDevice(configuration); | return plc_gateway_->connectDevice(configuration); | ||||
| } | } | ||||
| @@ -201,11 +206,11 @@ void RuntimeModeService::setMonitorAddresses( | |||||
| refreshPlcPollAddresses(); | refreshPlcPollAddresses(); | ||||
| } | } | ||||
| void RuntimeModeService::refreshPlcPollAddresses() | |||||
| PlcCommunicationResult RuntimeModeService::refreshPlcPollAddresses() | |||||
| { | { | ||||
| if (plc_gateway_ == nullptr) | if (plc_gateway_ == nullptr) | ||||
| { | { | ||||
| return; | |||||
| return {false, "PLC 通信服务尚未配置"}; | |||||
| } | } | ||||
| std::vector<RegisterAddress> addresses = monitor_addresses_; | std::vector<RegisterAddress> addresses = monitor_addresses_; | ||||
| const Project &project = project_service_.project(); | const Project &project = project_service_.project(); | ||||
| @@ -258,7 +263,11 @@ void RuntimeModeService::refreshPlcPollAddresses() | |||||
| return left.index() < right.index(); | return left.index() < right.index(); | ||||
| }); | }); | ||||
| addresses.erase(std::unique(addresses.begin(), addresses.end()), addresses.end()); | addresses.erase(std::unique(addresses.begin(), addresses.end()), addresses.end()); | ||||
| plc_gateway_->setPollAddresses(addresses); | |||||
| if (addresses.size() > ProjectLimits::kMaximumPollAddresses) | |||||
| { | |||||
| return {false, "PLC 轮询的去重 M/D 地址最多为 1024 个"}; | |||||
| } | |||||
| return plc_gateway_->setPollAddresses(addresses); | |||||
| } | } | ||||
| void RuntimeModeService::disconnectPlc() | void RuntimeModeService::disconnectPlc() | ||||
| @@ -76,7 +76,7 @@ public: | |||||
| RegisterRepository &plc_repository); | RegisterRepository &plc_repository); | ||||
| PlcCommunicationResult connectPlc(const PlcSerialConfiguration &configuration); | PlcCommunicationResult connectPlc(const PlcSerialConfiguration &configuration); | ||||
| void setMonitorAddresses(const std::vector<RegisterAddress> &addresses); | void setMonitorAddresses(const std::vector<RegisterAddress> &addresses); | ||||
| void refreshPlcPollAddresses(); | |||||
| PlcCommunicationResult refreshPlcPollAddresses(); | |||||
| void disconnectPlc(); | void disconnectPlc(); | ||||
| PlcConnectionState plcConnectionState() const; | PlcConnectionState plcConnectionState() const; | ||||
| const std::string &plcError() const; | const std::string &plcError() const; | ||||
| @@ -228,14 +228,19 @@ LogicScanResult SoftwareLogicExecutor::executeScanAt( | |||||
| continue; | 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) | if (!result.succeeded) | ||||
| { | { | ||||
| result.logicId = logic.id; | result.logicId = logic.id; | ||||
| @@ -2,12 +2,14 @@ | |||||
| #include "domain/hmi_control_registry.h" | #include "domain/hmi_control_registry.h" | ||||
| #include "domain/hmi_model.h" | #include "domain/hmi_model.h" | ||||
| #include "domain/project_model.h" | #include "domain/project_model.h" | ||||
| #include "domain/project_limits.h" | |||||
| #include "domain/register_address.h" | #include "domain/register_address.h" | ||||
| #include "domain/register_repository.h" | #include "domain/register_repository.h" | ||||
| #include "domain/runtime_state.h" | #include "domain/runtime_state.h" | ||||
| #include <cstdint> | #include <cstdint> | ||||
| #include <exception> | #include <exception> | ||||
| #include <functional> | |||||
| #include <iostream> | #include <iostream> | ||||
| #include <set> | #include <set> | ||||
| #include <stdexcept> | #include <stdexcept> | ||||
| @@ -311,6 +313,102 @@ void testMultiPageAndLogicDomainRules() | |||||
| require(!project.validate(), "unknown HMI control types must be rejected"); | 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() | void testLogicNodeConfigurationBoundaries() | ||||
| { | { | ||||
| // 触点只能绑定 M 区,数值比较只能绑定 D 区 | // 触点只能绑定 M 区,数值比较只能绑定 D 区 | ||||
| @@ -613,6 +711,31 @@ void testLadderLogicBoundaries() | |||||
| "wire-too-wide", WireSegment::kMaximumColumnSpan + 1); | "wire-too-wide", WireSegment::kMaximumColumnSpan + 1); | ||||
| require(!invalid_wire.validate(), "an oversized horizontal wire must be rejected"); | 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; | ConditionExpression wired_series; | ||||
| wired_series.id = "wired-series"; | wired_series.id = "wired-series"; | ||||
| wired_series.kind = ConditionExpressionKind::Series; | wired_series.kind = ConditionExpressionKind::Series; | ||||
| @@ -641,8 +764,8 @@ void testLadderLogicBoundaries() | |||||
| empty_rung.output = coil; | empty_rung.output = coil; | ||||
| require(empty_rung.validate(), "output-only network may remain in an editable draft"); | 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().output = coil; | ||||
| logic.rungs.front().condition = root; | logic.rungs.front().condition = root; | ||||
| @@ -740,6 +863,7 @@ int main() | |||||
| testLadderLogicBoundaries(); | testLadderLogicBoundaries(); | ||||
| testModelsValidateBindingsAndIdentifiers(); | testModelsValidateBindingsAndIdentifiers(); | ||||
| testMultiPageAndLogicDomainRules(); | testMultiPageAndLogicDomainRules(); | ||||
| testQuantityBoundaries(); | |||||
| testRuntimeStateBoundaries(); | testRuntimeStateBoundaries(); | ||||
| } | } | ||||
| catch (const std::exception &error) | catch (const std::exception &error) | ||||
| @@ -24,5 +24,6 @@ HEADERS += \ | |||||
| ../src/domain/hmi_model.h \ | ../src/domain/hmi_model.h \ | ||||
| ../src/domain/hmi_control_registry.h \ | ../src/domain/hmi_control_registry.h \ | ||||
| ../src/domain/control_logic_model.h \ | ../src/domain/control_logic_model.h \ | ||||
| ../src/domain/project_limits.h \ | |||||
| ../src/domain/project_model.h \ | ../src/domain/project_model.h \ | ||||
| ../src/domain/runtime_state.h | ../src/domain/runtime_state.h | ||||
| @@ -192,6 +192,55 @@ void testStructuredWireEditing() | |||||
| "wire branch deletion must participate in ladder undo history"); | "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() | void testLogicLifecycleAndOrdering() | ||||
| { | { | ||||
| TestProjectStorage storage; | TestProjectStorage storage; | ||||
| @@ -366,6 +415,8 @@ int main() | |||||
| testStructuredEditingAndNormalization(); | testStructuredEditingAndNormalization(); | ||||
| testRangeParallelInsertion(); | testRangeParallelInsertion(); | ||||
| testStructuredWireEditing(); | testStructuredWireEditing(); | ||||
| testConditionColumnLimit(); | |||||
| testUnconditionalOutputEditing(); | |||||
| testLogicLifecycleAndOrdering(); | testLogicLifecycleAndOrdering(); | ||||
| testEdgeTimerNodesAndRungComments(); | testEdgeTimerNodesAndRungComments(); | ||||
| testHistoryAndAtomicBatchDelete(); | testHistoryAndAtomicBatchDelete(); | ||||
| @@ -257,6 +257,28 @@ void testNestedSeriesParallelExpression() | |||||
| require(readBit(repository, 10), "A branch must independently energize output"); | 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() | void testWirePassThroughAndPowerTrace() | ||||
| { | { | ||||
| VirtualRegisterRepository repository; | VirtualRegisterRepository repository; | ||||
| @@ -900,6 +922,7 @@ int main(int argc, char *argv[]) | |||||
| { | { | ||||
| testSeriesParallelContactsAndSequentialVisibility(); | testSeriesParallelContactsAndSequentialVisibility(); | ||||
| testNestedSeriesParallelExpression(); | testNestedSeriesParallelExpression(); | ||||
| testUnconditionalCoil(); | |||||
| testWirePassThroughAndPowerTrace(); | testWirePassThroughAndPowerTrace(); | ||||
| testAllComparisons(); | testAllComparisons(); | ||||
| testSetResetAndDisabledLogic(); | testSetResetAndDisabledLogic(); | ||||
| @@ -1,6 +1,8 @@ | |||||
| #include "domain/active_register_repository.h" | #include "domain/active_register_repository.h" | ||||
| #include "domain/project_limits.h" | |||||
| #include "domain/project_storage.h" | #include "domain/project_storage.h" | ||||
| #include "infrastructure/plc_communication_error_classifier.h" | #include "infrastructure/plc_communication_error_classifier.h" | ||||
| #include "infrastructure/plc_communication_service.h" | |||||
| #include "infrastructure/plc_register_repository.h" | #include "infrastructure/plc_register_repository.h" | ||||
| #include "services/offline_simulation_service.h" | #include "services/offline_simulation_service.h" | ||||
| #include "services/plc_communication_gateway.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; | poll_addresses = addresses; | ||||
| return {true, {}}; | |||||
| } | } | ||||
| PlcConnectionState state() const override { return connection_state; } | PlcConnectionState state() const override { return connection_state; } | ||||
| @@ -311,6 +315,68 @@ void testPlcCommunicationErrorClassification() | |||||
| "protocol errors must remain distinct from timeouts and disconnections"); | "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 | } // namespace | ||||
| int main() | int main() | ||||
| @@ -321,6 +387,8 @@ int main() | |||||
| testRuntimeRepositorySwitchingAndDisconnect(); | testRuntimeRepositorySwitchingAndDisconnect(); | ||||
| testRuntimeFaultRevokesOnlineReadinessAndAllowsReconnect(); | testRuntimeFaultRevokesOnlineReadinessAndAllowsReconnect(); | ||||
| testPlcCommunicationErrorClassification(); | testPlcCommunicationErrorClassification(); | ||||
| testPlcConfigurationBoundaries(); | |||||
| testPlcPollQuantityBoundaries(); | |||||
| } | } | ||||
| catch (const std::exception &error) | catch (const std::exception &error) | ||||
| { | { | ||||
| @@ -1,4 +1,4 @@ | |||||
| QT += core serialbus | |||||
| QT += core serialbus serialport | |||||
| TEMPLATE = app | TEMPLATE = app | ||||
| TARGET = plc_runtime_tests | TARGET = plc_runtime_tests | ||||
| @@ -25,7 +25,8 @@ SOURCES += \ | |||||
| ../src/services/offline_simulation_service.cpp \ | ../src/services/offline_simulation_service.cpp \ | ||||
| ../src/services/runtime_mode_service.cpp \ | ../src/services/runtime_mode_service.cpp \ | ||||
| ../src/infrastructure/plc_communication_error_classifier.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 += \ | HEADERS += \ | ||||
| ../src/domain/register_address.h \ | ../src/domain/register_address.h \ | ||||
| @@ -36,6 +37,7 @@ HEADERS += \ | |||||
| ../src/domain/hmi_model.h \ | ../src/domain/hmi_model.h \ | ||||
| ../src/domain/hmi_control_registry.h \ | ../src/domain/hmi_control_registry.h \ | ||||
| ../src/domain/control_logic_model.h \ | ../src/domain/control_logic_model.h \ | ||||
| ../src/domain/project_limits.h \ | |||||
| ../src/domain/project_model.h \ | ../src/domain/project_model.h \ | ||||
| ../src/domain/project_storage.h \ | ../src/domain/project_storage.h \ | ||||
| ../src/domain/runtime_state.h \ | ../src/domain/runtime_state.h \ | ||||
| @@ -45,4 +47,5 @@ HEADERS += \ | |||||
| ../src/services/runtime_mode_service.h \ | ../src/services/runtime_mode_service.h \ | ||||
| ../src/services/plc_communication_gateway.h \ | ../src/services/plc_communication_gateway.h \ | ||||
| ../src/infrastructure/plc_communication_error_classifier.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 "infrastructure/json_project_storage.h" | ||||
| #include "services/register_comment_service.h" | #include "services/register_comment_service.h" | ||||
| #include "services/project_service.h" | #include "services/project_service.h" | ||||
| #include "domain/project_limits.h" | |||||
| #include <QFile> | #include <QFile> | ||||
| #include <QJsonArray> | #include <QJsonArray> | ||||
| @@ -374,6 +375,39 @@ void testEmptyProjectRoundTrip() | |||||
| "empty project must have no alarm definitions"); | "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() | void testExampleProjectRoundTrip() | ||||
| { | { | ||||
| // 验证各层嵌套字段往返后保持不变且序列化结果稳定 | // 验证各层嵌套字段往返后保持不变且序列化结果稳定 | ||||
| @@ -694,6 +728,96 @@ void testInvalidFiles() | |||||
| "unsupported versions must be rejected"); | "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() | void testServiceStateAndSaveErrors() | ||||
| { | { | ||||
| // 保存路径和修改标记只在成功持久化后更新 | // 保存路径和修改标记只在成功持久化后更新 | ||||
| @@ -764,9 +888,11 @@ int main() | |||||
| { | { | ||||
| // 工程服务和 JSON 存储在同一测试进程中验证完整闭环 | // 工程服务和 JSON 存储在同一测试进程中验证完整闭环 | ||||
| testEmptyProjectRoundTrip(); | testEmptyProjectRoundTrip(); | ||||
| testUnconditionalOutputRoundTrip(); | |||||
| testExampleProjectRoundTrip(); | testExampleProjectRoundTrip(); | ||||
| testRegisterCommentService(); | testRegisterCommentService(); | ||||
| testInvalidFiles(); | testInvalidFiles(); | ||||
| testQuantityFileLimits(); | |||||
| testServiceStateAndSaveErrors(); | testServiceStateAndSaveErrors(); | ||||
| } | } | ||||
| catch (const std::exception &error) | catch (const std::exception &error) | ||||
| @@ -29,6 +29,7 @@ HEADERS += \ | |||||
| ../src/domain/hmi_model.h \ | ../src/domain/hmi_model.h \ | ||||
| ../src/domain/hmi_control_registry.h \ | ../src/domain/hmi_control_registry.h \ | ||||
| ../src/domain/control_logic_model.h \ | ../src/domain/control_logic_model.h \ | ||||
| ../src/domain/project_limits.h \ | |||||
| ../src/domain/project_model.h \ | ../src/domain/project_model.h \ | ||||
| ../src/domain/runtime_state.h \ | ../src/domain/runtime_state.h \ | ||||
| ../src/domain/project_storage.h \ | ../src/domain/project_storage.h \ | ||||
| @@ -46,10 +46,11 @@ public: | |||||
| initial_read = false; | initial_read = false; | ||||
| } | } | ||||
| void setPollAddresses( | |||||
| PlcCommunicationResult setPollAddresses( | |||||
| const std::vector<RegisterAddress> &addresses) override | const std::vector<RegisterAddress> &addresses) override | ||||
| { | { | ||||
| poll_addresses = addresses; | poll_addresses = addresses; | ||||
| return {true, {}}; | |||||
| } | } | ||||
| PlcConnectionState state() const override { return connection_state; } | PlcConnectionState state() const override { return connection_state; } | ||||
| bool initialReadCompleted() const override { return initial_read; } | bool initialReadCompleted() const override { return initial_read; } | ||||