| @@ -20,6 +20,7 @@ SOURCES += \ | |||||
| src/ui/logic_instruction_dialog.cpp \ | src/ui/logic_instruction_dialog.cpp \ | ||||
| src/ui/alarm_configuration_dialog.cpp \ | src/ui/alarm_configuration_dialog.cpp \ | ||||
| src/ui/register_comment_dialog.cpp \ | src/ui/register_comment_dialog.cpp \ | ||||
| src/ui/status_text_dialog.cpp \ | |||||
| src/ui/plc_connection_dialog.cpp \ | src/ui/plc_connection_dialog.cpp \ | ||||
| src/ui/free_monitor_widget.cpp \ | src/ui/free_monitor_widget.cpp \ | ||||
| src/ui/runtime_monitor_window.cpp \ | src/ui/runtime_monitor_window.cpp \ | ||||
| @@ -69,6 +70,7 @@ HEADERS += \ | |||||
| src/ui/logic_instruction_dialog.h \ | src/ui/logic_instruction_dialog.h \ | ||||
| src/ui/alarm_configuration_dialog.h \ | src/ui/alarm_configuration_dialog.h \ | ||||
| src/ui/register_comment_dialog.h \ | src/ui/register_comment_dialog.h \ | ||||
| src/ui/status_text_dialog.h \ | |||||
| src/ui/plc_connection_dialog.h \ | src/ui/plc_connection_dialog.h \ | ||||
| src/ui/free_monitor_widget.h \ | src/ui/free_monitor_widget.h \ | ||||
| src/ui/runtime_monitor_window.h \ | src/ui/runtime_monitor_window.h \ | ||||
| @@ -124,6 +126,7 @@ FORMS += \ | |||||
| src/ui/logic_instruction_dialog.ui \ | src/ui/logic_instruction_dialog.ui \ | ||||
| src/ui/alarm_configuration_dialog.ui \ | src/ui/alarm_configuration_dialog.ui \ | ||||
| src/ui/register_comment_dialog.ui \ | src/ui/register_comment_dialog.ui \ | ||||
| src/ui/status_text_dialog.ui \ | |||||
| src/ui/plc_connection_dialog.ui \ | src/ui/plc_connection_dialog.ui \ | ||||
| src/ui/free_monitor_widget.ui \ | src/ui/free_monitor_widget.ui \ | ||||
| src/ui/runtime_monitor_window.ui \ | src/ui/runtime_monitor_window.ui \ | ||||
| @@ -52,6 +52,15 @@ constexpr std::array kControlDescriptors = { | |||||
| HmiBindingKind::None, | HmiBindingKind::None, | ||||
| HmiRuntimeValueKind::None, | HmiRuntimeValueKind::None, | ||||
| false}, | false}, | ||||
| HmiControlDescriptor{HmiControlType::StatusText, | |||||
| "statusText", | |||||
| "状态文本", | |||||
| "status-text", | |||||
| "状态文本", | |||||
| {0, 0, 140, 40}, | |||||
| HmiBindingKind::BitOrWord, | |||||
| HmiRuntimeValueKind::BitOrWord, | |||||
| true}, | |||||
| HmiControlDescriptor{HmiControlType::PageJump, | HmiControlDescriptor{HmiControlType::PageJump, | ||||
| "pageJump", | "pageJump", | ||||
| "页面跳转", | "页面跳转", | ||||
| @@ -117,6 +126,7 @@ std::optional<RegisterArea> hmiBindingArea(HmiBindingKind binding_kind) | |||||
| return RegisterArea::D; | return RegisterArea::D; | ||||
| } | } | ||||
| case HmiBindingKind::None: | case HmiBindingKind::None: | ||||
| case HmiBindingKind::BitOrWord: | |||||
| default: | default: | ||||
| { | { | ||||
| return std::nullopt; | return std::nullopt; | ||||
| @@ -19,7 +19,8 @@ enum class HmiBindingKind | |||||
| { | { | ||||
| None, // 不支持寄存器绑定 | None, // 不支持寄存器绑定 | ||||
| Bit, // 绑定 M 区位地址 | Bit, // 绑定 M 区位地址 | ||||
| Word // 绑定 D 区字地址 | |||||
| Word, // 绑定 D 区字地址 | |||||
| BitOrWord // 根据控件配置绑定 M 位或 D 字 | |||||
| }; | }; | ||||
| /** | /** | ||||
| @@ -29,7 +30,8 @@ enum class HmiRuntimeValueKind | |||||
| { | { | ||||
| None, // 运行态不读取寄存器值 | None, // 运行态不读取寄存器值 | ||||
| Bit, // 运行态读取绑定的 M 位值 | Bit, // 运行态读取绑定的 M 位值 | ||||
| Word // 运行态读取绑定的 D 字值 | |||||
| Word, // 运行态读取绑定的 D 字值 | |||||
| BitOrWord // 运行态按绑定区域读取 M 位或 D 字 | |||||
| }; | }; | ||||
| /** | /** | ||||
| @@ -6,6 +6,7 @@ | |||||
| #include <algorithm> | #include <algorithm> | ||||
| #include <cctype> | #include <cctype> | ||||
| #include <charconv> | #include <charconv> | ||||
| #include <cmath> | |||||
| namespace { | namespace { | ||||
| @@ -47,6 +48,101 @@ bool isBooleanValue(const std::string &value) | |||||
| return value == "true" || value == "false"; | return value == "true" || value == "false"; | ||||
| } | } | ||||
| bool isBlank(const std::string &value) | |||||
| { | |||||
| return value.empty() | |||||
| || std::all_of( | |||||
| value.cbegin(), value.cend(), | |||||
| [](unsigned char character) | |||||
| { | |||||
| return std::isspace(character) != 0; | |||||
| }); | |||||
| } | |||||
| bool validateStatusTextValue( | |||||
| const std::string &value, const char *name, std::string *error) | |||||
| { | |||||
| if (isBlank(value)) | |||||
| { | |||||
| setError(error, std::string(name) + "不能为空"); | |||||
| return false; | |||||
| } | |||||
| if (value.size() > ProjectLimits::kMaximumTextBytes) | |||||
| { | |||||
| setError(error, std::string(name) + "不能超过 256 个 UTF-8 字节"); | |||||
| return false; | |||||
| } | |||||
| if (value.find('\r') != std::string::npos | |||||
| || value.find('\n') != std::string::npos) | |||||
| { | |||||
| setError(error, std::string(name) + "只能使用单行文本"); | |||||
| return false; | |||||
| } | |||||
| return true; | |||||
| } | |||||
| bool validateStatusWordConfig( | |||||
| const HmiStatusWordTextConfig &config, | |||||
| RegisterDataType data_type, | |||||
| std::string *error) | |||||
| { | |||||
| if (config.ranges.empty() | |||||
| || config.ranges.size() > ProjectLimits::kMaximumStatusTextRanges) | |||||
| { | |||||
| setError(error, "D 状态文本必须配置 1~16 个连续区间"); | |||||
| return false; | |||||
| } | |||||
| if (config.ranges.front().lowerBound.has_value() | |||||
| || config.ranges.back().upperBound.has_value()) | |||||
| { | |||||
| setError(error, "D 状态文本的首个下限和最后一个上限必须为不限"); | |||||
| return false; | |||||
| } | |||||
| const bool integer_type = data_type == RegisterDataType::Int16 | |||||
| || data_type == RegisterDataType::Int32; | |||||
| for (std::size_t index = 0; index < config.ranges.size(); ++index) | |||||
| { | |||||
| const HmiStatusValueRange &range = config.ranges[index]; | |||||
| if (!validateStatusTextValue(range.text, "区间显示文本", error)) | |||||
| { | |||||
| return false; | |||||
| } | |||||
| if ((range.lowerBound.has_value() && !std::isfinite(*range.lowerBound)) | |||||
| || (range.upperBound.has_value() && !std::isfinite(*range.upperBound))) | |||||
| { | |||||
| setError(error, "D 状态文本区间边界必须是有限数值"); | |||||
| return false; | |||||
| } | |||||
| if (integer_type | |||||
| && ((range.lowerBound.has_value() | |||||
| && std::trunc(*range.lowerBound) != *range.lowerBound) | |||||
| || (range.upperBound.has_value() | |||||
| && std::trunc(*range.upperBound) != *range.upperBound))) | |||||
| { | |||||
| setError(error, "Int16/Int32 状态文本的区间边界必须是整数"); | |||||
| return false; | |||||
| } | |||||
| if (range.lowerBound.has_value() && range.upperBound.has_value() | |||||
| && *range.lowerBound >= *range.upperBound) | |||||
| { | |||||
| setError(error, "D 状态文本区间必须满足下限小于上限"); | |||||
| return false; | |||||
| } | |||||
| if (index > 0U) | |||||
| { | |||||
| const HmiStatusValueRange &previous = config.ranges[index - 1U]; | |||||
| if (!previous.upperBound.has_value() | |||||
| || !range.lowerBound.has_value() | |||||
| || *previous.upperBound != *range.lowerBound) | |||||
| { | |||||
| setError(error, "D 状态文本相邻区间必须首尾相接"); | |||||
| return false; | |||||
| } | |||||
| } | |||||
| } | |||||
| return true; | |||||
| } | |||||
| bool validateAppearanceProperty( | bool validateAppearanceProperty( | ||||
| const std::string &key, const std::string &value, std::string *error) | const std::string &key, const std::string &value, std::string *error) | ||||
| { | { | ||||
| @@ -148,7 +244,8 @@ bool HmiControl::validate(std::string *error) const | |||||
| } | } | ||||
| const std::optional<RegisterArea> binding_area = | const std::optional<RegisterArea> binding_area = | ||||
| hmiBindingArea(descriptor->bindingKind); | hmiBindingArea(descriptor->bindingKind); | ||||
| if (binding.has_value() && !binding_area.has_value()) | |||||
| if (binding.has_value() && !binding_area.has_value() | |||||
| && descriptor->bindingKind != HmiBindingKind::BitOrWord) | |||||
| { | { | ||||
| setError(error, "该 HMI 控件不能绑定寄存器"); | setError(error, "该 HMI 控件不能绑定寄存器"); | ||||
| return false; | return false; | ||||
| @@ -171,8 +268,11 @@ bool HmiControl::validate(std::string *error) const | |||||
| setError(error, "HMI 控件绑定了无效地址"); | setError(error, "HMI 控件绑定了无效地址"); | ||||
| return false; | return false; | ||||
| } | } | ||||
| const bool status_text = type == HmiControlType::StatusText; | |||||
| const bool numeric = type == HmiControlType::NumericDisplay | const bool numeric = type == HmiControlType::NumericDisplay | ||||
| || type == HmiControlType::NumericInput; | |||||
| || type == HmiControlType::NumericInput | |||||
| || (status_text && statusText.has_value() | |||||
| && std::holds_alternative<HmiStatusWordTextConfig>(*statusText)); | |||||
| if (!registerDataTypeIsSupported(dataType)) | if (!registerDataTypeIsSupported(dataType)) | ||||
| { | { | ||||
| setError(error, "HMI 数值类型无效"); | setError(error, "HMI 数值类型无效"); | ||||
| @@ -199,6 +299,48 @@ bool HmiControl::validate(std::string *error) const | |||||
| } | } | ||||
| return false; | return false; | ||||
| } | } | ||||
| if (status_text) | |||||
| { | |||||
| if (!statusText.has_value()) | |||||
| { | |||||
| setError(error, "状态文本控件缺少状态映射配置"); | |||||
| return false; | |||||
| } | |||||
| if (const auto *bit = std::get_if<HmiStatusBitTextConfig>(&*statusText)) | |||||
| { | |||||
| if (binding.has_value() && binding->area() != RegisterArea::M) | |||||
| { | |||||
| setError(error, "M 状态文本必须绑定 M 区地址"); | |||||
| return false; | |||||
| } | |||||
| if (dataType != RegisterDataType::Int16 | |||||
| || !validateStatusTextValue(bit->offText, "OFF 显示文本", error) | |||||
| || !validateStatusTextValue(bit->onText, "ON 显示文本", error)) | |||||
| { | |||||
| return false; | |||||
| } | |||||
| } | |||||
| else | |||||
| { | |||||
| if (binding.has_value() && binding->area() != RegisterArea::D) | |||||
| { | |||||
| setError(error, "D 状态文本必须绑定 D 区地址"); | |||||
| return false; | |||||
| } | |||||
| if (!validateStatusWordConfig( | |||||
| std::get<HmiStatusWordTextConfig>(*statusText), | |||||
| dataType, | |||||
| error)) | |||||
| { | |||||
| return false; | |||||
| } | |||||
| } | |||||
| } | |||||
| else if (statusText.has_value()) | |||||
| { | |||||
| setError(error, "非状态文本控件不能包含状态映射配置"); | |||||
| return false; | |||||
| } | |||||
| if (type == HmiControlType::PageJump) | if (type == HmiControlType::PageJump) | ||||
| { | { | ||||
| if (!pageJump.has_value()) | if (!pageJump.has_value()) | ||||
| @@ -244,6 +386,17 @@ bool HmiControl::isConfigured() const | |||||
| } | } | ||||
| const std::optional<RegisterArea> binding_area = | const std::optional<RegisterArea> binding_area = | ||||
| hmiBindingArea(descriptor->bindingKind); | hmiBindingArea(descriptor->bindingKind); | ||||
| if (type == HmiControlType::StatusText) | |||||
| { | |||||
| if (!statusText.has_value()) | |||||
| { | |||||
| return false; | |||||
| } | |||||
| const RegisterArea expected = std::holds_alternative< | |||||
| HmiStatusBitTextConfig>(*statusText) | |||||
| ? RegisterArea::M : RegisterArea::D; | |||||
| return binding->area() == expected; | |||||
| } | |||||
| return binding_area.has_value() && binding->area() == *binding_area; | return binding_area.has_value() && binding->area() == *binding_area; | ||||
| } | } | ||||
| @@ -14,6 +14,7 @@ | |||||
| #include <map> | #include <map> | ||||
| #include <optional> | #include <optional> | ||||
| #include <string> | #include <string> | ||||
| #include <variant> | |||||
| #include <vector> | #include <vector> | ||||
| namespace HmiAppearanceProperty | namespace HmiAppearanceProperty | ||||
| @@ -54,6 +55,7 @@ enum class HmiControlType | |||||
| NumericDisplay, // 数值显示 | NumericDisplay, // 数值显示 | ||||
| NumericInput, // 数值输入 | NumericInput, // 数值输入 | ||||
| Label, // 标签 | Label, // 标签 | ||||
| StatusText, // 状态文本 | |||||
| PageJump, // 页面跳转 | PageJump, // 页面跳转 | ||||
| AlarmList, // 报警列表 | AlarmList, // 报警列表 | ||||
| Count // 已注册控件类型数量,不作为实际控件使用 | Count // 已注册控件类型数量,不作为实际控件使用 | ||||
| @@ -75,6 +77,45 @@ struct HmiPageJumpConfig | |||||
| std::string targetPageId; | std::string targetPageId; | ||||
| }; | }; | ||||
| struct HmiStatusBitTextConfig | |||||
| { | |||||
| std::string offText; | |||||
| std::string onText; | |||||
| bool operator==(const HmiStatusBitTextConfig &other) const | |||||
| { | |||||
| return offText == other.offText && onText == other.onText; | |||||
| } | |||||
| }; | |||||
| struct HmiStatusValueRange | |||||
| { | |||||
| std::optional<double> lowerBound; | |||||
| std::optional<double> upperBound; | |||||
| std::string text; | |||||
| bool operator==(const HmiStatusValueRange &other) const | |||||
| { | |||||
| return lowerBound == other.lowerBound | |||||
| && upperBound == other.upperBound | |||||
| && text == other.text; | |||||
| } | |||||
| }; | |||||
| struct HmiStatusWordTextConfig | |||||
| { | |||||
| std::vector<HmiStatusValueRange> ranges; | |||||
| bool operator==(const HmiStatusWordTextConfig &other) const | |||||
| { | |||||
| return ranges == other.ranges; | |||||
| } | |||||
| }; | |||||
| using HmiStatusTextConfig = std::variant< | |||||
| HmiStatusBitTextConfig, | |||||
| HmiStatusWordTextConfig>; | |||||
| /** | /** | ||||
| * @brief 描述一个可保存的 HMI 控件及其显示和寄存器配置 | * @brief 描述一个可保存的 HMI 控件及其显示和寄存器配置 | ||||
| * | * | ||||
| @@ -91,6 +132,7 @@ struct HmiControl | |||||
| std::map<std::string, std::string> properties; | std::map<std::string, std::string> properties; | ||||
| HmiButtonOperation buttonOperation = HmiButtonOperation::MomentaryOn; | HmiButtonOperation buttonOperation = HmiButtonOperation::MomentaryOn; | ||||
| std::optional<HmiPageJumpConfig> pageJump; | std::optional<HmiPageJumpConfig> pageJump; | ||||
| std::optional<HmiStatusTextConfig> statusText; | |||||
| /** | /** | ||||
| * @brief 校验控件的标识、尺寸、扩展属性和寄存器绑定 | * @brief 校验控件的标识、尺寸、扩展属性和寄存器绑定 | ||||
| @@ -25,6 +25,7 @@ constexpr std::size_t kMaximumVerticalConnectionsPerLogic = | |||||
| static_assert(kMaximumLadderColumns == kMaximumConditionColumns + 1); // 确保总列数始终等于条件列加输出列 | static_assert(kMaximumLadderColumns == kMaximumConditionColumns + 1); // 确保总列数始终等于条件列加输出列 | ||||
| constexpr std::size_t kMaximumHmiProperties = 64U; // 一个 HMI 控件最多保存 64 对扩展属性 | constexpr std::size_t kMaximumHmiProperties = 64U; // 一个 HMI 控件最多保存 64 对扩展属性 | ||||
| constexpr std::size_t kMaximumStatusTextRanges = 16U; // 一个状态文本控件最多配置 16 个连续数值区间 | |||||
| constexpr std::size_t kMaximumIdBytes = 128U; // ID 最多 128 个 UTF-8 字节,够用来做稳定标识 | constexpr std::size_t kMaximumIdBytes = 128U; // ID 最多 128 个 UTF-8 字节,够用来做稳定标识 | ||||
| constexpr std::size_t kMaximumTextBytes = 256U; // 工程名称等普通文本最多 256 个 UTF-8 字节 | constexpr std::size_t kMaximumTextBytes = 256U; // 工程名称等普通文本最多 256 个 UTF-8 字节 | ||||
| constexpr std::size_t kMaximumHmiControlTextCharacters = 12U; // HMI 控件显示文字最多输入 12 个字符 | constexpr std::size_t kMaximumHmiControlTextCharacters = 12U; // HMI 控件显示文字最多输入 12 个字符 | ||||
| @@ -82,7 +82,10 @@ struct HmiDataBinding | |||||
| bool isNumericControl(const HmiControl &control) | bool isNumericControl(const HmiControl &control) | ||||
| { | { | ||||
| return control.type == HmiControlType::NumericDisplay | return control.type == HmiControlType::NumericDisplay | ||||
| || control.type == HmiControlType::NumericInput; | |||||
| || control.type == HmiControlType::NumericInput | |||||
| || (control.type == HmiControlType::StatusText | |||||
| && control.binding.has_value() | |||||
| && control.binding->area() == RegisterArea::D); | |||||
| } | } | ||||
| bool rangesIntersect(const HmiDataBinding &left, const HmiDataBinding &right) | bool rangesIntersect(const HmiDataBinding &left, const HmiDataBinding &right) | ||||
| @@ -26,7 +26,7 @@ struct ProjectMetadata | |||||
| // 工程显示名称 | // 工程显示名称 | ||||
| std::string name; | std::string name; | ||||
| // 工程文件格式版本 | // 工程文件格式版本 | ||||
| std::string formatVersion = "2.0"; | |||||
| std::string formatVersion = "3.0"; | |||||
| }; | }; | ||||
| // 聚合工程中的 HMI 页面、报警、寄存器注释和控制逻辑 | // 聚合工程中的 HMI 页面、报警、寄存器注释和控制逻辑 | ||||
| @@ -18,7 +18,7 @@ | |||||
| namespace { | namespace { | ||||
| // 当前读写实现支持的工程文件格式版本 | // 当前读写实现支持的工程文件格式版本 | ||||
| constexpr const char *kCurrentFormatVersion = "2.0"; | |||||
| constexpr const char *kCurrentFormatVersion = "3.0"; | |||||
| // 保存解析过程中遇到的第一个错误,避免后续失败覆盖根因 | // 保存解析过程中遇到的第一个错误,避免后续失败覆盖根因 | ||||
| struct ParseState | struct ParseState | ||||
| @@ -648,6 +648,134 @@ bool parseProperties( | |||||
| return true; | return true; | ||||
| } | } | ||||
| QJsonValue serializeOptionalNumber(const std::optional<double> &value) | |||||
| { | |||||
| return value.has_value() ? QJsonValue(*value) : QJsonValue(QJsonValue::Null); | |||||
| } | |||||
| QJsonObject serializeStatusTextConfig(const HmiStatusTextConfig &config) | |||||
| { | |||||
| QJsonObject object; | |||||
| if (const auto *bit = std::get_if<HmiStatusBitTextConfig>(&config)) | |||||
| { | |||||
| object.insert(QStringLiteral("source"), QStringLiteral("m")); | |||||
| object.insert(QStringLiteral("offText"), fromUtf8(bit->offText)); | |||||
| object.insert(QStringLiteral("onText"), fromUtf8(bit->onText)); | |||||
| return object; | |||||
| } | |||||
| object.insert(QStringLiteral("source"), QStringLiteral("d")); | |||||
| QJsonArray ranges; | |||||
| for (const HmiStatusValueRange &range : | |||||
| std::get<HmiStatusWordTextConfig>(config).ranges) | |||||
| { | |||||
| QJsonObject range_object; | |||||
| range_object.insert( | |||||
| QStringLiteral("lower"), serializeOptionalNumber(range.lowerBound)); | |||||
| range_object.insert( | |||||
| QStringLiteral("upper"), serializeOptionalNumber(range.upperBound)); | |||||
| range_object.insert(QStringLiteral("text"), fromUtf8(range.text)); | |||||
| ranges.append(range_object); | |||||
| } | |||||
| object.insert(QStringLiteral("ranges"), ranges); | |||||
| return object; | |||||
| } | |||||
| bool readNullableFiniteNumber( | |||||
| const QJsonObject &object, | |||||
| const char *field, | |||||
| const std::string &context, | |||||
| std::optional<double> *value, | |||||
| ParseState *state) | |||||
| { | |||||
| QJsonValue json_value; | |||||
| if (!readValue(object, field, context, &json_value, state)) | |||||
| { | |||||
| return false; | |||||
| } | |||||
| if (json_value.isNull()) | |||||
| { | |||||
| value->reset(); | |||||
| return true; | |||||
| } | |||||
| if (!json_value.isDouble() || !std::isfinite(json_value.toDouble())) | |||||
| { | |||||
| return state->fail( | |||||
| ProjectStorageError::InvalidField, | |||||
| fieldPath(context, field) + " 必须是有限数值或 null"); | |||||
| } | |||||
| *value = json_value.toDouble(); | |||||
| return true; | |||||
| } | |||||
| bool parseStatusTextConfig( | |||||
| const QJsonObject &object, | |||||
| const std::string &context, | |||||
| HmiStatusTextConfig *config, | |||||
| ParseState *state) | |||||
| { | |||||
| std::string source; | |||||
| if (!readString(object, "source", context, &source, state)) | |||||
| { | |||||
| return false; | |||||
| } | |||||
| if (source == "m") | |||||
| { | |||||
| HmiStatusBitTextConfig bit; | |||||
| if (!readString(object, "offText", context, &bit.offText, state) | |||||
| || !readString(object, "onText", context, &bit.onText, state)) | |||||
| { | |||||
| return false; | |||||
| } | |||||
| *config = std::move(bit); | |||||
| return true; | |||||
| } | |||||
| if (source != "d") | |||||
| { | |||||
| return state->fail( | |||||
| ProjectStorageError::InvalidField, | |||||
| context + ".source 必须是 m 或 d"); | |||||
| } | |||||
| QJsonArray range_array; | |||||
| if (!readArray(object, "ranges", context, &range_array, state) | |||||
| || range_array.isEmpty() | |||||
| || range_array.size() | |||||
| > static_cast<int>(ProjectLimits::kMaximumStatusTextRanges)) | |||||
| { | |||||
| return state->fail( | |||||
| ProjectStorageError::InvalidField, | |||||
| context + ".ranges 必须包含 1~16 个区间"); | |||||
| } | |||||
| HmiStatusWordTextConfig word; | |||||
| word.ranges.reserve(static_cast<std::size_t>(range_array.size())); | |||||
| for (int index = 0; index < range_array.size(); ++index) | |||||
| { | |||||
| if (!range_array.at(index).isObject()) | |||||
| { | |||||
| return state->fail( | |||||
| ProjectStorageError::InvalidField, | |||||
| context + ".ranges 的元素必须是对象"); | |||||
| } | |||||
| const QJsonObject range_object = range_array.at(index).toObject(); | |||||
| const std::string range_context = context + ".ranges[" | |||||
| + std::to_string(index) + ']'; | |||||
| HmiStatusValueRange range; | |||||
| if (!readNullableFiniteNumber( | |||||
| range_object, "lower", range_context, | |||||
| &range.lowerBound, state) | |||||
| || !readNullableFiniteNumber( | |||||
| range_object, "upper", range_context, | |||||
| &range.upperBound, state) | |||||
| || !readString( | |||||
| range_object, "text", range_context, &range.text, state)) | |||||
| { | |||||
| return false; | |||||
| } | |||||
| word.ranges.push_back(std::move(range)); | |||||
| } | |||||
| *config = std::move(word); | |||||
| return true; | |||||
| } | |||||
| // 将单个 HMI 控件及其可选寄存器绑定序列化为 JSON 对象 | // 将单个 HMI 控件及其可选寄存器绑定序列化为 JSON 对象 | ||||
| QJsonObject serializeHmiControl(const HmiControl &control) | QJsonObject serializeHmiControl(const HmiControl &control) | ||||
| { | { | ||||
| @@ -666,8 +794,12 @@ QJsonObject serializeHmiControl(const HmiControl &control) | |||||
| object.insert(QStringLiteral("binding"), QJsonValue::Null); | object.insert(QStringLiteral("binding"), QJsonValue::Null); | ||||
| } | } | ||||
| object.insert(QStringLiteral("properties"), serializeProperties(control.properties)); | object.insert(QStringLiteral("properties"), serializeProperties(control.properties)); | ||||
| const bool status_word = control.type == HmiControlType::StatusText | |||||
| && control.statusText.has_value() | |||||
| && std::holds_alternative<HmiStatusWordTextConfig>(*control.statusText); | |||||
| if (control.type == HmiControlType::NumericDisplay | if (control.type == HmiControlType::NumericDisplay | ||||
| || control.type == HmiControlType::NumericInput) | |||||
| || control.type == HmiControlType::NumericInput | |||||
| || status_word) | |||||
| { | { | ||||
| object.insert( | object.insert( | ||||
| QStringLiteral("dataType"), | QStringLiteral("dataType"), | ||||
| @@ -687,6 +819,14 @@ QJsonObject serializeHmiControl(const HmiControl &control) | |||||
| ? control.pageJump->targetPageId | ? control.pageJump->targetPageId | ||||
| : std::string{})); | : std::string{})); | ||||
| } | } | ||||
| if (control.type == HmiControlType::StatusText) | |||||
| { | |||||
| object.insert( | |||||
| QStringLiteral("statusText"), | |||||
| control.statusText.has_value() | |||||
| ? serializeStatusTextConfig(*control.statusText) | |||||
| : QJsonObject{}); | |||||
| } | |||||
| return object; | return object; | ||||
| } | } | ||||
| @@ -718,8 +858,24 @@ bool parseHmiControl( | |||||
| { | { | ||||
| return false; | return false; | ||||
| } | } | ||||
| if (control->type == HmiControlType::StatusText) | |||||
| { | |||||
| QJsonObject status_object; | |||||
| HmiStatusTextConfig status_config = HmiStatusBitTextConfig{}; | |||||
| if (!readObject(object, "statusText", context, &status_object, state) | |||||
| || !parseStatusTextConfig( | |||||
| status_object, context + ".statusText", &status_config, state)) | |||||
| { | |||||
| return false; | |||||
| } | |||||
| control->statusText = std::move(status_config); | |||||
| } | |||||
| const bool status_word = control->type == HmiControlType::StatusText | |||||
| && control->statusText.has_value() | |||||
| && std::holds_alternative<HmiStatusWordTextConfig>(*control->statusText); | |||||
| if (control->type == HmiControlType::NumericDisplay | if (control->type == HmiControlType::NumericDisplay | ||||
| || control->type == HmiControlType::NumericInput) | |||||
| || control->type == HmiControlType::NumericInput | |||||
| || status_word) | |||||
| { | { | ||||
| std::string data_type_text; | std::string data_type_text; | ||||
| if (!readString(object, "dataType", context, &data_type_text, state) | if (!readString(object, "dataType", context, &data_type_text, state) | ||||
| @@ -2,7 +2,7 @@ | |||||
| #include "domain/project_storage.h" | #include "domain/project_storage.h" | ||||
| // 严格读写当前 2.0 JSON 工程格式 | |||||
| // 严格读写当前 3.0 JSON 工程格式 | |||||
| class JsonProjectStorage final : public ProjectStorage | class JsonProjectStorage final : public ProjectStorage | ||||
| { | { | ||||
| public: | public: | ||||
| @@ -137,6 +137,7 @@ bool HmiEditorService::controlsEqual( | |||||
| && left.dataType == right.dataType | && left.dataType == right.dataType | ||||
| && left.properties == right.properties | && left.properties == right.properties | ||||
| && left.buttonOperation == right.buttonOperation | && left.buttonOperation == right.buttonOperation | ||||
| && left.statusText == right.statusText | |||||
| && page_jump_equal; | && page_jump_equal; | ||||
| } | } | ||||
| @@ -824,6 +825,11 @@ HmiControl HmiEditorService::makeControl( | |||||
| { | { | ||||
| control.pageJump = HmiPageJumpConfig{}; | control.pageJump = HmiPageJumpConfig{}; | ||||
| } | } | ||||
| if (descriptor.type == HmiControlType::StatusText) | |||||
| { | |||||
| control.statusText = HmiStatusBitTextConfig{ | |||||
| "设备停止", "设备运行"}; | |||||
| } | |||||
| const int offset = static_cast<int>(page.controls.size()) * 16; | const int offset = static_cast<int>(page.controls.size()) * 16; | ||||
| control.bounds.x = std::min(20 + offset, page.width - control.bounds.width); | control.bounds.x = std::min(20 + offset, page.width - control.bounds.width); | ||||
| control.bounds.y = std::min(20 + offset, page.height - control.bounds.height); | control.bounds.y = std::min(20 + offset, page.height - control.bounds.height); | ||||
| @@ -2,8 +2,10 @@ | |||||
| #include "domain/hmi_control_registry.h" | #include "domain/hmi_control_registry.h" | ||||
| #include <algorithm> | |||||
| #include <cmath> | #include <cmath> | ||||
| #include <limits> | #include <limits> | ||||
| #include <type_traits> | |||||
| namespace { | namespace { | ||||
| @@ -17,6 +19,16 @@ HmiRuntimeWriteResult writeFailure(HmiRuntimeError error) | |||||
| return {false, error}; | return {false, error}; | ||||
| } | } | ||||
| double numericValueAsDouble(const RegisterNumericValue &value) | |||||
| { | |||||
| return std::visit( | |||||
| [](const auto &typed_value) | |||||
| { | |||||
| return static_cast<double>(typed_value); | |||||
| }, | |||||
| value); | |||||
| } | |||||
| } // namespace | } // namespace | ||||
| HmiRuntimeService::HmiRuntimeService(RegisterRepository &repository) | HmiRuntimeService::HmiRuntimeService(RegisterRepository &repository) | ||||
| @@ -43,7 +55,10 @@ HmiRuntimeReadResult HmiRuntimeService::readControl(const HmiControl &control) c | |||||
| } | } | ||||
| const std::optional<RegisterArea> binding_area = | const std::optional<RegisterArea> binding_area = | ||||
| hmiBindingArea(descriptor->bindingKind); | hmiBindingArea(descriptor->bindingKind); | ||||
| if (!binding_area.has_value() || control.binding->area() != *binding_area) | |||||
| const bool flexible_binding = descriptor->bindingKind | |||||
| == HmiBindingKind::BitOrWord; | |||||
| if ((!binding_area.has_value() && !flexible_binding) | |||||
| || (binding_area.has_value() && control.binding->area() != *binding_area)) | |||||
| { | { | ||||
| return readFailure(HmiRuntimeError::InvalidBinding); | return readFailure(HmiRuntimeError::InvalidBinding); | ||||
| } | } | ||||
| @@ -79,6 +94,34 @@ HmiRuntimeReadResult HmiRuntimeService::readControl(const HmiControl &control) c | |||||
| true, HmiRuntimeError::None, false, *value} | true, HmiRuntimeError::None, false, *value} | ||||
| : readFailure(HmiRuntimeError::RepositoryFailure); | : readFailure(HmiRuntimeError::RepositoryFailure); | ||||
| } | } | ||||
| case HmiRuntimeValueKind::BitOrWord: | |||||
| { | |||||
| if (control.binding->area() == RegisterArea::M) | |||||
| { | |||||
| const BitReadResult result = repository_.readBit(*control.binding); | |||||
| return result.succeeded | |||||
| ? HmiRuntimeReadResult{ | |||||
| true, HmiRuntimeError::None, result.value, std::int16_t{0}} | |||||
| : readFailure(repositoryError(result.error)); | |||||
| } | |||||
| if (control.binding->area() != RegisterArea::D | |||||
| || !registerDataTypeAddressIsValid(control.dataType, *control.binding)) | |||||
| { | |||||
| return readFailure(HmiRuntimeError::InvalidBinding); | |||||
| } | |||||
| const WordsReadResult result = repository_.readWords( | |||||
| *control.binding, registerDataTypeWordCount(control.dataType)); | |||||
| if (!result.succeeded) | |||||
| { | |||||
| return readFailure(repositoryError(result.error)); | |||||
| } | |||||
| const std::optional<RegisterNumericValue> value = | |||||
| decodeRegisterNumericValue(control.dataType, result.values); | |||||
| return value.has_value() | |||||
| ? HmiRuntimeReadResult{ | |||||
| true, HmiRuntimeError::None, false, *value} | |||||
| : readFailure(HmiRuntimeError::RepositoryFailure); | |||||
| } | |||||
| case HmiRuntimeValueKind::None: | case HmiRuntimeValueKind::None: | ||||
| default: | default: | ||||
| { | { | ||||
| @@ -87,6 +130,53 @@ HmiRuntimeReadResult HmiRuntimeService::readControl(const HmiControl &control) c | |||||
| } | } | ||||
| } | } | ||||
| HmiStatusTextReadResult HmiRuntimeService::readStatusText( | |||||
| const HmiControl &control) const | |||||
| { | |||||
| if (control.type != HmiControlType::StatusText | |||||
| || !control.statusText.has_value()) | |||||
| { | |||||
| return {false, HmiRuntimeError::UnsupportedControl, {}}; | |||||
| } | |||||
| const bool bit_config = std::holds_alternative<HmiStatusBitTextConfig>( | |||||
| *control.statusText); | |||||
| if (!control.binding.has_value() | |||||
| || control.binding->area() | |||||
| != (bit_config ? RegisterArea::M : RegisterArea::D)) | |||||
| { | |||||
| return {false, HmiRuntimeError::InvalidBinding, {}}; | |||||
| } | |||||
| const HmiRuntimeReadResult value = readControl(control); | |||||
| if (!value.succeeded) | |||||
| { | |||||
| return {false, value.error, {}}; | |||||
| } | |||||
| if (const auto *bit = std::get_if<HmiStatusBitTextConfig>( | |||||
| &*control.statusText)) | |||||
| { | |||||
| return {true, | |||||
| HmiRuntimeError::None, | |||||
| value.bit_value ? bit->onText : bit->offText}; | |||||
| } | |||||
| const double number = numericValueAsDouble(value.numeric_value); | |||||
| const auto &ranges = std::get<HmiStatusWordTextConfig>( | |||||
| *control.statusText).ranges; | |||||
| const auto match = std::find_if( | |||||
| ranges.cbegin(), ranges.cend(), | |||||
| [number](const HmiStatusValueRange &range) | |||||
| { | |||||
| return (!range.lowerBound.has_value() | |||||
| || number >= *range.lowerBound) | |||||
| && (!range.upperBound.has_value() | |||||
| || number < *range.upperBound); | |||||
| }); | |||||
| return match == ranges.cend() | |||||
| ? HmiStatusTextReadResult{ | |||||
| false, HmiRuntimeError::InvalidBinding, {}} | |||||
| : HmiStatusTextReadResult{ | |||||
| true, HmiRuntimeError::None, match->text}; | |||||
| } | |||||
| HmiRuntimeWriteResult HmiRuntimeService::operateButton( | HmiRuntimeWriteResult HmiRuntimeService::operateButton( | ||||
| const HmiControl &control, HmiButtonEvent event) | const HmiControl &control, HmiButtonEvent event) | ||||
| { | { | ||||
| @@ -46,6 +46,13 @@ struct HmiRuntimeWriteResult | |||||
| HmiRuntimeError error = HmiRuntimeError::None; // 失败时的 HMI 错误分类 | HmiRuntimeError error = HmiRuntimeError::None; // 失败时的 HMI 错误分类 | ||||
| }; | }; | ||||
| struct HmiStatusTextReadResult | |||||
| { | |||||
| bool succeeded = false; | |||||
| HmiRuntimeError error = HmiRuntimeError::None; | |||||
| std::string text; | |||||
| }; | |||||
| /** | /** | ||||
| * @brief HMI 按钮在运行态产生的输入事件 | * @brief HMI 按钮在运行态产生的输入事件 | ||||
| */ | */ | ||||
| @@ -78,6 +85,7 @@ public: | |||||
| * 控件未绑定、绑定区域错误或仓库不可用时不返回有效值 | * 控件未绑定、绑定区域错误或仓库不可用时不返回有效值 | ||||
| */ | */ | ||||
| HmiRuntimeReadResult readControl(const HmiControl &control) const; | HmiRuntimeReadResult readControl(const HmiControl &control) const; | ||||
| HmiStatusTextReadResult readStatusText(const HmiControl &control) const; | |||||
| /** | /** | ||||
| * @brief 根据按钮配置处理按下或释放事件 | * @brief 根据按钮配置处理按下或释放事件 | ||||
| * @param control 按钮控件,必须绑定有效 M 地址 | * @param control 按钮控件,必须绑定有效 M 地址 | ||||
| @@ -192,7 +192,7 @@ Project ProjectService::makeNewProject(const std::string &name) | |||||
| project.metadata.id = generateProjectId(); | project.metadata.id = generateProjectId(); | ||||
| project.metadata.name = name; | project.metadata.name = name; | ||||
| // 新工程固定使用当前存储格式版本 | // 新工程固定使用当前存储格式版本 | ||||
| project.metadata.formatVersion = "2.0"; | |||||
| project.metadata.formatVersion = "3.0"; | |||||
| return project; | return project; | ||||
| } | } | ||||
| @@ -323,7 +323,8 @@ PlcCommunicationResult RuntimeModeService::refreshPlcPollAddresses() | |||||
| { | { | ||||
| addresses.push_back(*control.binding); | addresses.push_back(*control.binding); | ||||
| if ((control.type == HmiControlType::NumericDisplay | if ((control.type == HmiControlType::NumericDisplay | ||||
| || control.type == HmiControlType::NumericInput) | |||||
| || control.type == HmiControlType::NumericInput | |||||
| || control.type == HmiControlType::StatusText) | |||||
| && control.binding->area() == RegisterArea::D) | && control.binding->area() == RegisterArea::D) | ||||
| { | { | ||||
| const int word_count = registerDataTypeWordCount(control.dataType); | const int word_count = registerDataTypeWordCount(control.dataType); | ||||
| @@ -276,6 +276,15 @@ public: | |||||
| textWithValue()); | textWithValue()); | ||||
| break; | break; | ||||
| } | } | ||||
| case HmiControlType::StatusText: | |||||
| { | |||||
| painter->setPen(configuredTextColor(QColor(QStringLiteral("#24313b")))); | |||||
| painter->drawText( | |||||
| rect.adjusted(4, 0, -4, 0), | |||||
| Qt::AlignCenter | Qt::TextWordWrap, | |||||
| textWithValue()); | |||||
| break; | |||||
| } | |||||
| case HmiControlType::PageJump: | case HmiControlType::PageJump: | ||||
| { | { | ||||
| const QColor fill = runtime_active_ && page_hovered_ | const QColor fill = runtime_active_ && page_hovered_ | ||||
| @@ -503,6 +512,14 @@ public: | |||||
| update(); | update(); | ||||
| } | } | ||||
| void setStatusText(const std::string &text, bool available) | |||||
| { | |||||
| status_text_ = QString::fromUtf8( | |||||
| text.data(), static_cast<int>(text.size())); | |||||
| status_text_available_ = available; | |||||
| update(); | |||||
| } | |||||
| void setAlarmRecords(const std::vector<AlarmRecord> &records) | void setAlarmRecords(const std::vector<AlarmRecord> &records) | ||||
| { | { | ||||
| alarm_records_ = records; | alarm_records_ = records; | ||||
| @@ -834,6 +851,12 @@ private: | |||||
| { | { | ||||
| const QString text = QString::fromUtf8( | const QString text = QString::fromUtf8( | ||||
| control_.text.data(), static_cast<int>(control_.text.size())); | control_.text.data(), static_cast<int>(control_.text.size())); | ||||
| if (control_.type == HmiControlType::StatusText) | |||||
| { | |||||
| return runtime_active_ | |||||
| ? (status_text_available_ ? status_text_ : QStringLiteral("--")) | |||||
| : QStringLiteral("状态文本"); | |||||
| } | |||||
| if (!has_runtime_value_ || control_.type == HmiControlType::Button) | if (!has_runtime_value_ || control_.type == HmiControlType::Button) | ||||
| { | { | ||||
| return text; | return text; | ||||
| @@ -874,6 +897,8 @@ private: | |||||
| RegisterNumericValue numeric_value_ = std::int16_t{0}; | RegisterNumericValue numeric_value_ = std::int16_t{0}; | ||||
| // 标记当前缓存值是否来自一次成功的运行时读取 | // 标记当前缓存值是否来自一次成功的运行时读取 | ||||
| bool has_runtime_value_ = false; | bool has_runtime_value_ = false; | ||||
| QString status_text_; | |||||
| bool status_text_available_ = false; | |||||
| }; | }; | ||||
| // 将场景通用图元安全转换为本文件定义的 HMI 控件图元 | // 将场景通用图元安全转换为本文件定义的 HMI 控件图元 | ||||
| @@ -935,6 +960,7 @@ void HmiEditorWidget::setRuntimeActive(bool active) | |||||
| if (control_item != nullptr) | if (control_item != nullptr) | ||||
| { | { | ||||
| control_item->setRuntimeValue(false, std::int16_t{0}, false); | control_item->setRuntimeValue(false, std::int16_t{0}, false); | ||||
| control_item->setStatusText({}, false); | |||||
| control_item->setAlarmRecords({}); | control_item->setAlarmRecords({}); | ||||
| } | } | ||||
| } | } | ||||
| @@ -1082,6 +1108,13 @@ void HmiEditorWidget::refreshRuntimeValues() | |||||
| control_item->setAlarmRecords(alarm_service_.records()); | control_item->setAlarmRecords(alarm_service_.records()); | ||||
| continue; | continue; | ||||
| } | } | ||||
| if (control->type == HmiControlType::StatusText) | |||||
| { | |||||
| const HmiStatusTextReadResult value = | |||||
| runtime_service_.readStatusText(*control); | |||||
| control_item->setStatusText(value.text, value.succeeded); | |||||
| continue; | |||||
| } | |||||
| // 运行值通过服务读取,图元不直接接触寄存器仓库 | // 运行值通过服务读取,图元不直接接触寄存器仓库 | ||||
| const HmiRuntimeReadResult value = runtime_service_.readControl(*control); | const HmiRuntimeReadResult value = runtime_service_.readControl(*control); | ||||
| control_item->setRuntimeValue( | control_item->setRuntimeValue( | ||||
| @@ -647,6 +647,8 @@ void MainWindow::configureActions() | |||||
| [this] { addHmiControl(HmiControlType::NumericInput); }); | [this] { addHmiControl(HmiControlType::NumericInput); }); | ||||
| connect(ui_->addLabelAction, &QAction::triggered, this, | connect(ui_->addLabelAction, &QAction::triggered, this, | ||||
| [this] { addHmiControl(HmiControlType::Label); }); | [this] { addHmiControl(HmiControlType::Label); }); | ||||
| connect(ui_->addStatusTextAction, &QAction::triggered, this, | |||||
| [this] { addHmiControl(HmiControlType::StatusText); }); | |||||
| connect(ui_->addPageJumpAction, &QAction::triggered, this, | connect(ui_->addPageJumpAction, &QAction::triggered, this, | ||||
| [this] { addHmiControl(HmiControlType::PageJump); }); | [this] { addHmiControl(HmiControlType::PageJump); }); | ||||
| connect(ui_->addAlarmListAction, &QAction::triggered, this, | connect(ui_->addAlarmListAction, &QAction::triggered, this, | ||||
| @@ -968,6 +970,7 @@ void MainWindow::configureAppearance() | |||||
| ui_->addNumericDisplayAction->setIcon(makeUiIcon(UiIcon::NumericDisplay)); | ui_->addNumericDisplayAction->setIcon(makeUiIcon(UiIcon::NumericDisplay)); | ||||
| ui_->addNumericInputAction->setIcon(makeUiIcon(UiIcon::NumericInput)); | ui_->addNumericInputAction->setIcon(makeUiIcon(UiIcon::NumericInput)); | ||||
| ui_->addLabelAction->setIcon(makeUiIcon(UiIcon::Text)); | ui_->addLabelAction->setIcon(makeUiIcon(UiIcon::Text)); | ||||
| ui_->addStatusTextAction->setIcon(makeUiIcon(UiIcon::StatusText)); | |||||
| ui_->addPageJumpAction->setIcon(makeUiIcon(UiIcon::PageJump)); | ui_->addPageJumpAction->setIcon(makeUiIcon(UiIcon::PageJump)); | ||||
| ui_->addAlarmListAction->setIcon(makeUiIcon(UiIcon::AlarmList)); | ui_->addAlarmListAction->setIcon(makeUiIcon(UiIcon::AlarmList)); | ||||
| ui_->configureAlarmsAction->setIcon(makeUiIcon(UiIcon::AlarmSettings)); | ui_->configureAlarmsAction->setIcon(makeUiIcon(UiIcon::AlarmSettings)); | ||||
| @@ -2437,6 +2440,7 @@ void MainWindow::updateModeUi(const QString &message) | |||||
| ui_->addNumericDisplayAction->setEnabled(policy.allowsProjectEditing); | ui_->addNumericDisplayAction->setEnabled(policy.allowsProjectEditing); | ||||
| ui_->addNumericInputAction->setEnabled(policy.allowsProjectEditing); | ui_->addNumericInputAction->setEnabled(policy.allowsProjectEditing); | ||||
| ui_->addLabelAction->setEnabled(policy.allowsProjectEditing); | ui_->addLabelAction->setEnabled(policy.allowsProjectEditing); | ||||
| ui_->addStatusTextAction->setEnabled(policy.allowsProjectEditing); | |||||
| ui_->addPageJumpAction->setEnabled(policy.allowsProjectEditing); | ui_->addPageJumpAction->setEnabled(policy.allowsProjectEditing); | ||||
| ui_->addAlarmListAction->setEnabled(policy.allowsProjectEditing); | ui_->addAlarmListAction->setEnabled(policy.allowsProjectEditing); | ||||
| ui_->configureAlarmsAction->setEnabled(policy.allowsProjectEditing); | ui_->configureAlarmsAction->setEnabled(policy.allowsProjectEditing); | ||||
| @@ -334,6 +334,7 @@ | |||||
| <addaction name="addNumericDisplayAction"/> | <addaction name="addNumericDisplayAction"/> | ||||
| <addaction name="addNumericInputAction"/> | <addaction name="addNumericInputAction"/> | ||||
| <addaction name="addLabelAction"/> | <addaction name="addLabelAction"/> | ||||
| <addaction name="addStatusTextAction"/> | |||||
| <addaction name="deleteControlAction"/> | <addaction name="deleteControlAction"/> | ||||
| </widget> | </widget> | ||||
| <widget class="QToolBar" name="logicToolBar"> | <widget class="QToolBar" name="logicToolBar"> | ||||
| @@ -785,7 +786,17 @@ | |||||
| <item><property name="text"><string>Double (Float64)</string></property></item> | <item><property name="text"><string>Double (Float64)</string></property></item> | ||||
| </widget> | </widget> | ||||
| </item> | </item> | ||||
| <item row="14" column="0" colspan="2"> | |||||
| <item row="14" column="0"> | |||||
| <widget class="QLabel" name="statusTextConfigLabel"> | |||||
| <property name="text"><string>状态映射</string></property> | |||||
| </widget> | |||||
| </item> | |||||
| <item row="14" column="1"> | |||||
| <widget class="QPushButton" name="configureStatusTextButton"> | |||||
| <property name="text"><string>配置</string></property> | |||||
| </widget> | |||||
| </item> | |||||
| <item row="15" column="0" colspan="2"> | |||||
| <widget class="QPushButton" name="applyPropertiesButton"> | <widget class="QPushButton" name="applyPropertiesButton"> | ||||
| <property name="text"> | <property name="text"> | ||||
| <string>应用属性</string> | <string>应用属性</string> | ||||
| @@ -1123,6 +1134,10 @@ | |||||
| <string>添加页面跳转控件</string> | <string>添加页面跳转控件</string> | ||||
| </property> | </property> | ||||
| </action> | </action> | ||||
| <action name="addStatusTextAction"> | |||||
| <property name="text"><string>状态文本</string></property> | |||||
| <property name="toolTip"><string>添加状态文本控件</string></property> | |||||
| </action> | |||||
| <action name="addAlarmListAction"> | <action name="addAlarmListAction"> | ||||
| <property name="text"> | <property name="text"> | ||||
| <string>报警列表</string> | <string>报警列表</string> | ||||
| @@ -5,6 +5,7 @@ | |||||
| #include "hmi_editor_widget.h" | #include "hmi_editor_widget.h" | ||||
| #include "logic_editor_widget.h" | #include "logic_editor_widget.h" | ||||
| #include "logic_instruction_dialog.h" | #include "logic_instruction_dialog.h" | ||||
| #include "status_text_dialog.h" | |||||
| #include "services/hmi_editor_service.h" | #include "services/hmi_editor_service.h" | ||||
| #include "services/logic_editor_service.h" | #include "services/logic_editor_service.h" | ||||
| #include "services/project_service.h" | #include "services/project_service.h" | ||||
| @@ -128,6 +129,8 @@ void PropertyPanelController::configure() | |||||
| &parent_, [this] { applySelectedLogicNodeProperties(); }); | &parent_, [this] { applySelectedLogicNodeProperties(); }); | ||||
| QObject::connect(ui_.textColorButton, &QPushButton::clicked, | QObject::connect(ui_.textColorButton, &QPushButton::clicked, | ||||
| &parent_, [this] { chooseTextColor(); }); | &parent_, [this] { chooseTextColor(); }); | ||||
| QObject::connect(ui_.configureStatusTextButton, &QPushButton::clicked, | |||||
| &parent_, [this] { configureStatusText(); }); | |||||
| ui_.targetPageLabel->setVisible(false); | ui_.targetPageLabel->setVisible(false); | ||||
| ui_.targetPageComboBox->setVisible(false); | ui_.targetPageComboBox->setVisible(false); | ||||
| showControlProperties({}); | showControlProperties({}); | ||||
| @@ -204,6 +207,7 @@ void PropertyPanelController::showControlProperties(const std::string &control_i | |||||
| static_cast<QWidget *>(ui_.targetPageComboBox), | static_cast<QWidget *>(ui_.targetPageComboBox), | ||||
| static_cast<QWidget *>(ui_.buttonOperationComboBox), | static_cast<QWidget *>(ui_.buttonOperationComboBox), | ||||
| static_cast<QWidget *>(ui_.dataTypeComboBox), | static_cast<QWidget *>(ui_.dataTypeComboBox), | ||||
| static_cast<QWidget *>(ui_.configureStatusTextButton), | |||||
| static_cast<QWidget *>(ui_.textColorEdit), | static_cast<QWidget *>(ui_.textColorEdit), | ||||
| static_cast<QWidget *>(ui_.textColorButton), | static_cast<QWidget *>(ui_.textColorButton), | ||||
| static_cast<QWidget *>(ui_.fontSizeSpinBox), | static_cast<QWidget *>(ui_.fontSizeSpinBox), | ||||
| @@ -228,6 +232,8 @@ void PropertyPanelController::showControlProperties(const std::string &control_i | |||||
| ui_.buttonOperationComboBox->setVisible(false); | ui_.buttonOperationComboBox->setVisible(false); | ||||
| ui_.dataTypeLabel->setVisible(false); | ui_.dataTypeLabel->setVisible(false); | ||||
| ui_.dataTypeComboBox->setVisible(false); | ui_.dataTypeComboBox->setVisible(false); | ||||
| ui_.statusTextConfigLabel->setVisible(false); | |||||
| ui_.configureStatusTextButton->setVisible(false); | |||||
| ui_.textColorEdit->clear(); | ui_.textColorEdit->clear(); | ||||
| ui_.textColorButton->setStyleSheet(QString{}); | ui_.textColorButton->setStyleSheet(QString{}); | ||||
| ui_.fontSizeSpinBox->setValue( | ui_.fontSizeSpinBox->setValue( | ||||
| @@ -242,8 +248,11 @@ void PropertyPanelController::showControlProperties(const std::string &control_i | |||||
| ui_.controlTextEdit->setMaxLength( | ui_.controlTextEdit->setMaxLength( | ||||
| static_cast<int>(ProjectLimits::kMaximumHmiControlTextCharacters)); | static_cast<int>(ProjectLimits::kMaximumHmiControlTextCharacters)); | ||||
| const bool is_status_text = control->type == HmiControlType::StatusText; | |||||
| ui_.controlIdEdit->setText(fromUtf8(control->id)); | ui_.controlIdEdit->setText(fromUtf8(control->id)); | ||||
| ui_.controlTextEdit->setText(fromUtf8(control->text)); | ui_.controlTextEdit->setText(fromUtf8(control->text)); | ||||
| ui_.controlTextEdit->setVisible(!is_status_text); | |||||
| ui_.controlTextLabel->setVisible(!is_status_text); | |||||
| ui_.controlXSpinBox->setValue(control->bounds.x); | ui_.controlXSpinBox->setValue(control->bounds.x); | ||||
| ui_.controlYSpinBox->setValue(control->bounds.y); | ui_.controlYSpinBox->setValue(control->bounds.y); | ||||
| ui_.controlWidthSpinBox->setValue(control->bounds.width); | ui_.controlWidthSpinBox->setValue(control->bounds.width); | ||||
| @@ -253,7 +262,8 @@ void PropertyPanelController::showControlProperties(const std::string &control_i | |||||
| ui_.bindingIndexSpinBox->setValue( | ui_.bindingIndexSpinBox->setValue( | ||||
| control->binding.has_value() ? control->binding->index() : 0); | control->binding.has_value() ? control->binding->index() : 0); | ||||
| const bool has_binding = descriptor != nullptr | const bool has_binding = descriptor != nullptr | ||||
| && descriptor->bindingKind != HmiBindingKind::None; | |||||
| && descriptor->bindingKind != HmiBindingKind::None | |||||
| && !is_status_text; | |||||
| ui_.bindingAreaLabel->setVisible(has_binding); | ui_.bindingAreaLabel->setVisible(has_binding); | ||||
| ui_.bindingAreaComboBox->setVisible(has_binding); | ui_.bindingAreaComboBox->setVisible(has_binding); | ||||
| ui_.bindingIndexLabel->setVisible(has_binding); | ui_.bindingIndexLabel->setVisible(has_binding); | ||||
| @@ -274,6 +284,21 @@ void PropertyPanelController::showControlProperties(const std::string &control_i | |||||
| ui_.dataTypeComboBox->setEnabled(is_numeric); | ui_.dataTypeComboBox->setEnabled(is_numeric); | ||||
| ui_.dataTypeComboBox->setCurrentIndex( | ui_.dataTypeComboBox->setCurrentIndex( | ||||
| ui_.dataTypeComboBox->findData(static_cast<int>(control->dataType))); | ui_.dataTypeComboBox->findData(static_cast<int>(control->dataType))); | ||||
| ui_.statusTextConfigLabel->setVisible(is_status_text); | |||||
| ui_.configureStatusTextButton->setVisible(is_status_text); | |||||
| ui_.configureStatusTextButton->setEnabled(is_status_text); | |||||
| if (is_status_text) | |||||
| { | |||||
| const QString source = control->statusText.has_value() | |||||
| && std::holds_alternative<HmiStatusWordTextConfig>(*control->statusText) | |||||
| ? QStringLiteral("D") : QStringLiteral("M"); | |||||
| ui_.configureStatusTextButton->setText( | |||||
| control->binding.has_value() | |||||
| ? QObject::tr("配置(%1%2)") | |||||
| .arg(source) | |||||
| .arg(control->binding->index()) | |||||
| : QObject::tr("配置")); | |||||
| } | |||||
| const bool is_page_jump = control->type == HmiControlType::PageJump; | const bool is_page_jump = control->type == HmiControlType::PageJump; | ||||
| ui_.targetPageLabel->setVisible(is_page_jump); | ui_.targetPageLabel->setVisible(is_page_jump); | ||||
| @@ -536,16 +561,20 @@ void PropertyPanelController::applySelectedControlProperties() | |||||
| control.bounds.y = ui_.controlYSpinBox->value(); | control.bounds.y = ui_.controlYSpinBox->value(); | ||||
| control.bounds.width = ui_.controlWidthSpinBox->value(); | control.bounds.width = ui_.controlWidthSpinBox->value(); | ||||
| control.bounds.height = ui_.controlHeightSpinBox->value(); | control.bounds.height = ui_.controlHeightSpinBox->value(); | ||||
| control.dataType = static_cast<RegisterDataType>( | |||||
| ui_.dataTypeComboBox->currentData().toInt()); | |||||
| if (control.type != HmiControlType::StatusText) | |||||
| { | |||||
| control.dataType = static_cast<RegisterDataType>( | |||||
| ui_.dataTypeComboBox->currentData().toInt()); | |||||
| } | |||||
| const HmiControlDescriptor *descriptor = | const HmiControlDescriptor *descriptor = | ||||
| findHmiControlDescriptor(control.type); | findHmiControlDescriptor(control.type); | ||||
| if (descriptor == nullptr | |||||
| || descriptor->bindingKind == HmiBindingKind::None) | |||||
| if (control.type != HmiControlType::StatusText | |||||
| && (descriptor == nullptr | |||||
| || descriptor->bindingKind == HmiBindingKind::None)) | |||||
| { | { | ||||
| control.binding.reset(); | control.binding.reset(); | ||||
| } | } | ||||
| else | |||||
| else if (control.type != HmiControlType::StatusText) | |||||
| { | { | ||||
| const int binding_area = ui_.bindingAreaComboBox->currentData().toInt(); | const int binding_area = ui_.bindingAreaComboBox->currentData().toInt(); | ||||
| if (binding_area < 0) | if (binding_area < 0) | ||||
| @@ -644,6 +673,33 @@ void PropertyPanelController::chooseTextColor() | |||||
| } | } | ||||
| } | } | ||||
| void PropertyPanelController::configureStatusText() | |||||
| { | |||||
| const HmiControl *old_control = hmi_editor_service_.findControl( | |||||
| current_page_id_(), selected_control_id_); | |||||
| if (old_control == nullptr || old_control->type != HmiControlType::StatusText) | |||||
| { | |||||
| return; | |||||
| } | |||||
| StatusTextDialog dialog(*old_control, &parent_); | |||||
| if (dialog.exec() != QDialog::Accepted) | |||||
| { | |||||
| return; | |||||
| } | |||||
| const HmiEditorResult result = hmi_editor_service_.updateControl( | |||||
| current_page_id_(), selected_control_id_, dialog.control()); | |||||
| if (!result.succeeded) | |||||
| { | |||||
| reportFailure(QObject::tr("配置状态文本"), result.message); | |||||
| return; | |||||
| } | |||||
| hmi_editor_widget_->reloadPage(); | |||||
| hmi_editor_widget_->selectControl(result.id); | |||||
| showControlProperties(result.id); | |||||
| refresh_project_ui_(); | |||||
| status_reporter_(QObject::tr("状态文本配置已更新"), 3000); | |||||
| } | |||||
| void PropertyPanelController::applySelectedLogicNodeProperties() | void PropertyPanelController::applySelectedLogicNodeProperties() | ||||
| { | { | ||||
| const LogicNode *node = logic_editor_service_.findNode( | const LogicNode *node = logic_editor_service_.findNode( | ||||
| @@ -89,6 +89,8 @@ private: | |||||
| void handleLogicEditorError(const QString &message) const; | void handleLogicEditorError(const QString &message) const; | ||||
| /** 打开颜色选择器并写回属性输入框 */ | /** 打开颜色选择器并写回属性输入框 */ | ||||
| void chooseTextColor(); | void chooseTextColor(); | ||||
| /** 打开状态文本专用配置并原子更新控件 */ | |||||
| void configureStatusText(); | |||||
| /** 统一报告属性操作失败 */ | /** 统一报告属性操作失败 */ | ||||
| void reportFailure( | void reportFailure( | ||||
| const QString &action, const std::string &message) const; | const QString &action, const std::string &message) const; | ||||
| @@ -0,0 +1,214 @@ | |||||
| #include "status_text_dialog.h" | |||||
| #include "domain/project_limits.h" | |||||
| #include "ui_status_text_dialog.h" | |||||
| #include <QComboBox> | |||||
| #include <QHeaderView> | |||||
| #include <QLineEdit> | |||||
| #include <QLocale> | |||||
| #include <QMessageBox> | |||||
| #include <QPushButton> | |||||
| #include <QTableWidget> | |||||
| #include <cmath> | |||||
| namespace { | |||||
| QString fromUtf8(const std::string &value) | |||||
| { | |||||
| return QString::fromUtf8(value.data(), static_cast<int>(value.size())); | |||||
| } | |||||
| std::string toUtf8(const QString &value) | |||||
| { | |||||
| const QByteArray bytes = value.toUtf8(); | |||||
| return std::string(bytes.constData(), static_cast<std::size_t>(bytes.size())); | |||||
| } | |||||
| QString boundaryText(const std::optional<double> &value) | |||||
| { | |||||
| return value.has_value() | |||||
| ? QString::number(*value, 'g', 17) : QStringLiteral("不限"); | |||||
| } | |||||
| } // namespace | |||||
| StatusTextDialog::StatusTextDialog(const HmiControl &control, QWidget *parent) | |||||
| : QDialog(parent), | |||||
| ui_(std::make_unique<Ui::StatusTextDialog>()), | |||||
| control_(control) | |||||
| { | |||||
| ui_->setupUi(this); | |||||
| ui_->sourceComboBox->setItemData(0, 0); | |||||
| ui_->sourceComboBox->setItemData(1, 1); | |||||
| ui_->dataTypeComboBox->setItemData(0, static_cast<int>(RegisterDataType::Int16)); | |||||
| ui_->dataTypeComboBox->setItemData(1, static_cast<int>(RegisterDataType::Int32)); | |||||
| ui_->dataTypeComboBox->setItemData(2, static_cast<int>(RegisterDataType::Float32)); | |||||
| ui_->dataTypeComboBox->setItemData(3, static_cast<int>(RegisterDataType::Float64)); | |||||
| ui_->addressSpinBox->setRange(0, RegisterAddress::kMaximumIndex); | |||||
| ui_->rangeTableWidget->horizontalHeader()->setStretchLastSection(true); | |||||
| ui_->rangeTableWidget->setSelectionBehavior(QAbstractItemView::SelectRows); | |||||
| ui_->rangeTableWidget->setSelectionMode(QAbstractItemView::SingleSelection); | |||||
| connect(ui_->sourceComboBox, | |||||
| QOverload<int>::of(&QComboBox::currentIndexChanged), | |||||
| this, | |||||
| &StatusTextDialog::updateSourcePage); | |||||
| connect(ui_->addRangeButton, &QPushButton::clicked, | |||||
| this, &StatusTextDialog::addRange); | |||||
| connect(ui_->removeRangeButton, &QPushButton::clicked, | |||||
| this, &StatusTextDialog::removeSelectedRange); | |||||
| ui_->addressSpinBox->setValue( | |||||
| control.binding.has_value() ? control.binding->index() : 0); | |||||
| ui_->dataTypeComboBox->setCurrentIndex( | |||||
| ui_->dataTypeComboBox->findData(static_cast<int>(control.dataType))); | |||||
| ui_->offTextEdit->setText(QStringLiteral("设备停止")); | |||||
| ui_->onTextEdit->setText(QStringLiteral("设备运行")); | |||||
| if (control.statusText.has_value() | |||||
| && std::holds_alternative<HmiStatusWordTextConfig>(*control.statusText)) | |||||
| { | |||||
| ui_->sourceComboBox->setCurrentIndex(1); | |||||
| for (const HmiStatusValueRange &range : | |||||
| std::get<HmiStatusWordTextConfig>(*control.statusText).ranges) | |||||
| { | |||||
| appendRangeRow(range); | |||||
| } | |||||
| } | |||||
| else | |||||
| { | |||||
| ui_->sourceComboBox->setCurrentIndex(0); | |||||
| const HmiStatusBitTextConfig bit = control.statusText.has_value() | |||||
| ? std::get<HmiStatusBitTextConfig>(*control.statusText) | |||||
| : HmiStatusBitTextConfig{"设备停止", "设备运行"}; | |||||
| ui_->offTextEdit->setText(fromUtf8(bit.offText)); | |||||
| ui_->onTextEdit->setText(fromUtf8(bit.onText)); | |||||
| } | |||||
| if (ui_->rangeTableWidget->rowCount() == 0) | |||||
| { | |||||
| appendRangeRow({std::nullopt, 30.0, "低温"}); | |||||
| appendRangeRow({30.0, 80.0, "温度正常"}); | |||||
| appendRangeRow({80.0, std::nullopt, "高温"}); | |||||
| } | |||||
| updateSourcePage(); | |||||
| } | |||||
| StatusTextDialog::~StatusTextDialog() = default; | |||||
| const HmiControl &StatusTextDialog::control() const | |||||
| { | |||||
| return control_; | |||||
| } | |||||
| void StatusTextDialog::updateSourcePage() | |||||
| { | |||||
| const bool word = ui_->sourceComboBox->currentData().toInt() == 1; | |||||
| ui_->configurationStack->setCurrentIndex(word ? 1 : 0); | |||||
| ui_->dataTypeLabel->setVisible(word); | |||||
| ui_->dataTypeComboBox->setVisible(word); | |||||
| ui_->addressLabel->setText(word ? tr("绑定地址(D)") : tr("绑定地址(M)")); | |||||
| } | |||||
| void StatusTextDialog::appendRangeRow(const HmiStatusValueRange &range) | |||||
| { | |||||
| const int row = ui_->rangeTableWidget->rowCount(); | |||||
| ui_->rangeTableWidget->insertRow(row); | |||||
| ui_->rangeTableWidget->setItem( | |||||
| row, 0, new QTableWidgetItem(boundaryText(range.lowerBound))); | |||||
| ui_->rangeTableWidget->setItem( | |||||
| row, 1, new QTableWidgetItem(boundaryText(range.upperBound))); | |||||
| ui_->rangeTableWidget->setItem( | |||||
| row, 2, new QTableWidgetItem(fromUtf8(range.text))); | |||||
| } | |||||
| void StatusTextDialog::addRange() | |||||
| { | |||||
| if (ui_->rangeTableWidget->rowCount() | |||||
| >= static_cast<int>(ProjectLimits::kMaximumStatusTextRanges)) | |||||
| { | |||||
| return; | |||||
| } | |||||
| appendRangeRow({std::nullopt, std::nullopt, "状态"}); | |||||
| ui_->rangeTableWidget->setCurrentCell( | |||||
| ui_->rangeTableWidget->rowCount() - 1, 0); | |||||
| } | |||||
| void StatusTextDialog::removeSelectedRange() | |||||
| { | |||||
| const int row = ui_->rangeTableWidget->currentRow(); | |||||
| if (row >= 0) | |||||
| { | |||||
| ui_->rangeTableWidget->removeRow(row); | |||||
| } | |||||
| } | |||||
| bool StatusTextDialog::readOptionalBoundary( | |||||
| int row, int column, std::optional<double> *value) | |||||
| { | |||||
| const QTableWidgetItem *item = ui_->rangeTableWidget->item(row, column); | |||||
| const QString text = item == nullptr ? QString{} : item->text().trimmed(); | |||||
| if (text.isEmpty() || text == QStringLiteral("不限")) | |||||
| { | |||||
| value->reset(); | |||||
| return true; | |||||
| } | |||||
| bool converted = false; | |||||
| const double number = QLocale::c().toDouble(text, &converted); | |||||
| if (!converted || !std::isfinite(number)) | |||||
| { | |||||
| QMessageBox::warning( | |||||
| this, | |||||
| tr("状态文本配置"), | |||||
| tr("第 %1 行的区间边界必须是有限数值或“不限”").arg(row + 1)); | |||||
| return false; | |||||
| } | |||||
| *value = number; | |||||
| return true; | |||||
| } | |||||
| void StatusTextDialog::accept() | |||||
| { | |||||
| const bool word = ui_->sourceComboBox->currentData().toInt() == 1; | |||||
| control_.binding = RegisterAddress{ | |||||
| word ? RegisterArea::D : RegisterArea::M, | |||||
| ui_->addressSpinBox->value()}; | |||||
| if (!word) | |||||
| { | |||||
| control_.dataType = RegisterDataType::Int16; | |||||
| control_.statusText = HmiStatusBitTextConfig{ | |||||
| toUtf8(ui_->offTextEdit->text()), | |||||
| toUtf8(ui_->onTextEdit->text())}; | |||||
| } | |||||
| else | |||||
| { | |||||
| control_.dataType = static_cast<RegisterDataType>( | |||||
| ui_->dataTypeComboBox->currentData().toInt()); | |||||
| HmiStatusWordTextConfig config; | |||||
| config.ranges.reserve( | |||||
| static_cast<std::size_t>(ui_->rangeTableWidget->rowCount())); | |||||
| for (int row = 0; row < ui_->rangeTableWidget->rowCount(); ++row) | |||||
| { | |||||
| HmiStatusValueRange range; | |||||
| if (!readOptionalBoundary(row, 0, &range.lowerBound) | |||||
| || !readOptionalBoundary(row, 1, &range.upperBound)) | |||||
| { | |||||
| return; | |||||
| } | |||||
| const QTableWidgetItem *text_item = ui_->rangeTableWidget->item(row, 2); | |||||
| range.text = toUtf8( | |||||
| text_item == nullptr ? QString{} : text_item->text()); | |||||
| config.ranges.push_back(std::move(range)); | |||||
| } | |||||
| control_.statusText = std::move(config); | |||||
| } | |||||
| std::string error; | |||||
| if (!control_.validate(&error)) | |||||
| { | |||||
| QMessageBox::warning( | |||||
| this, tr("状态文本配置"), fromUtf8(error)); | |||||
| return; | |||||
| } | |||||
| QDialog::accept(); | |||||
| } | |||||
| @@ -0,0 +1,36 @@ | |||||
| #pragma once | |||||
| #include "domain/hmi_model.h" | |||||
| #include <QDialog> | |||||
| #include <memory> | |||||
| namespace Ui { | |||||
| class StatusTextDialog; | |||||
| } | |||||
| class StatusTextDialog final : public QDialog | |||||
| { | |||||
| Q_OBJECT | |||||
| public: | |||||
| explicit StatusTextDialog(const HmiControl &control, QWidget *parent = nullptr); | |||||
| ~StatusTextDialog() override; | |||||
| const HmiControl &control() const; | |||||
| private slots: | |||||
| void updateSourcePage(); | |||||
| void addRange(); | |||||
| void removeSelectedRange(); | |||||
| void accept() override; | |||||
| private: | |||||
| void appendRangeRow(const HmiStatusValueRange &range); | |||||
| bool readOptionalBoundary( | |||||
| int row, int column, std::optional<double> *value); | |||||
| std::unique_ptr<Ui::StatusTextDialog> ui_; | |||||
| HmiControl control_; | |||||
| }; | |||||
| @@ -0,0 +1,74 @@ | |||||
| <?xml version="1.0" encoding="UTF-8"?> | |||||
| <ui version="4.0"> | |||||
| <class>StatusTextDialog</class> | |||||
| <widget class="QDialog" name="StatusTextDialog"> | |||||
| <property name="windowTitle"><string>状态文本配置</string></property> | |||||
| <property name="minimumSize"><size><width>560</width><height>420</height></size></property> | |||||
| <layout class="QVBoxLayout" name="verticalLayout"> | |||||
| <item> | |||||
| <layout class="QFormLayout" name="sourceForm"> | |||||
| <item row="0" column="0"><widget class="QLabel" name="sourceLabel"><property name="text"><string>数据源</string></property></widget></item> | |||||
| <item row="0" column="1"> | |||||
| <widget class="QComboBox" name="sourceComboBox"> | |||||
| <item><property name="text"><string>M 位状态</string></property></item> | |||||
| <item><property name="text"><string>D 数值区间</string></property></item> | |||||
| </widget> | |||||
| </item> | |||||
| <item row="1" column="0"><widget class="QLabel" name="addressLabel"><property name="text"><string>绑定地址</string></property></widget></item> | |||||
| <item row="1" column="1"><widget class="QSpinBox" name="addressSpinBox"><property name="maximum"><number>4000</number></property></widget></item> | |||||
| <item row="2" column="0"><widget class="QLabel" name="dataTypeLabel"><property name="text"><string>数值类型</string></property></widget></item> | |||||
| <item row="2" column="1"> | |||||
| <widget class="QComboBox" name="dataTypeComboBox"> | |||||
| <item><property name="text"><string>Int16</string></property></item> | |||||
| <item><property name="text"><string>Int32</string></property></item> | |||||
| <item><property name="text"><string>Float32</string></property></item> | |||||
| <item><property name="text"><string>Double (Float64)</string></property></item> | |||||
| </widget> | |||||
| </item> | |||||
| </layout> | |||||
| </item> | |||||
| <item> | |||||
| <widget class="QStackedWidget" name="configurationStack"> | |||||
| <widget class="QWidget" name="bitPage"> | |||||
| <layout class="QFormLayout" name="bitForm"> | |||||
| <item row="0" column="0"><widget class="QLabel" name="offTextLabel"><property name="text"><string>OFF 显示</string></property></widget></item> | |||||
| <item row="0" column="1"><widget class="QLineEdit" name="offTextEdit"><property name="maxLength"><number>256</number></property></widget></item> | |||||
| <item row="1" column="0"><widget class="QLabel" name="onTextLabel"><property name="text"><string>ON 显示</string></property></widget></item> | |||||
| <item row="1" column="1"><widget class="QLineEdit" name="onTextEdit"><property name="maxLength"><number>256</number></property></widget></item> | |||||
| </layout> | |||||
| </widget> | |||||
| <widget class="QWidget" name="wordPage"> | |||||
| <layout class="QVBoxLayout" name="wordLayout"> | |||||
| <item> | |||||
| <widget class="QTableWidget" name="rangeTableWidget"> | |||||
| <property name="columnCount"><number>3</number></property> | |||||
| <property name="rowCount"><number>0</number></property> | |||||
| <column><property name="text"><string>下限(含)</string></property></column> | |||||
| <column><property name="text"><string>上限(不含)</string></property></column> | |||||
| <column><property name="text"><string>显示文本</string></property></column> | |||||
| </widget> | |||||
| </item> | |||||
| <item> | |||||
| <layout class="QHBoxLayout" name="rangeButtonLayout"> | |||||
| <item><spacer name="rangeButtonSpacer"><property name="orientation"><enum>Qt::Horizontal</enum></property><property name="sizeHint" stdset="0"><size><width>40</width><height>20</height></size></property></spacer></item> | |||||
| <item><widget class="QPushButton" name="addRangeButton"><property name="text"><string>添加区间</string></property></widget></item> | |||||
| <item><widget class="QPushButton" name="removeRangeButton"><property name="text"><string>删除区间</string></property></widget></item> | |||||
| </layout> | |||||
| </item> | |||||
| </layout> | |||||
| </widget> | |||||
| </widget> | |||||
| </item> | |||||
| <item> | |||||
| <widget class="QDialogButtonBox" name="buttonBox"> | |||||
| <property name="standardButtons"><set>QDialogButtonBox::Cancel|QDialogButtonBox::Ok</set></property> | |||||
| </widget> | |||||
| </item> | |||||
| </layout> | |||||
| </widget> | |||||
| <resources/> | |||||
| <connections> | |||||
| <connection><sender>buttonBox</sender><signal>accepted()</signal><receiver>StatusTextDialog</receiver><slot>accept()</slot></connection> | |||||
| <connection><sender>buttonBox</sender><signal>rejected()</signal><receiver>StatusTextDialog</receiver><slot>reject()</slot></connection> | |||||
| </connections> | |||||
| </ui> | |||||
| @@ -298,6 +298,13 @@ QPixmap renderIcon(UiIcon icon, int size) | |||||
| drawCenteredText(painter, QRectF(7, 6, 10, 14), QStringLiteral("T"), 9.0); | drawCenteredText(painter, QRectF(7, 6, 10, 14), QStringLiteral("T"), 9.0); | ||||
| break; | break; | ||||
| } | } | ||||
| case UiIcon::StatusText: | |||||
| { | |||||
| painter.drawRoundedRect(QRectF(2, 5, 20, 14), 2, 2); | |||||
| drawCenteredText( | |||||
| painter, QRectF(3, 6, 18, 12), QStringLiteral("ON"), 6.0); | |||||
| break; | |||||
| } | |||||
| case UiIcon::PageJump: | case UiIcon::PageJump: | ||||
| { | { | ||||
| painter.drawRect(QRectF(3, 3, 14, 18)); | painter.drawRect(QRectF(3, 3, 14, 18)); | ||||
| @@ -31,6 +31,7 @@ enum class UiIcon | |||||
| NumericDisplay, // HMI 数值显示 | NumericDisplay, // HMI 数值显示 | ||||
| NumericInput, // HMI 数值输入 | NumericInput, // HMI 数值输入 | ||||
| Text, // HMI 文本 | Text, // HMI 文本 | ||||
| StatusText, // HMI 状态文本 | |||||
| PageJump, // HMI 页面跳转 | PageJump, // HMI 页面跳转 | ||||
| AlarmList, // HMI 报警列表 | AlarmList, // HMI 报警列表 | ||||
| AlarmSettings, // 报警配置 | AlarmSettings, // 报警配置 | ||||
| @@ -255,6 +255,13 @@ void testHmiControlRegistryCompleteness() | |||||
| "word runtime controls must bind to the D area"); | "word runtime controls must bind to the D area"); | ||||
| break; | break; | ||||
| } | } | ||||
| case HmiRuntimeValueKind::BitOrWord: | |||||
| { | |||||
| require(descriptor->bindingKind == HmiBindingKind::BitOrWord | |||||
| && !binding_area.has_value(), | |||||
| "flexible runtime controls must choose M or D from their config"); | |||||
| break; | |||||
| } | |||||
| case HmiRuntimeValueKind::None: | case HmiRuntimeValueKind::None: | ||||
| { | { | ||||
| require(descriptor->bindingKind == HmiBindingKind::None | require(descriptor->bindingKind == HmiBindingKind::None | ||||
| @@ -286,6 +293,89 @@ void testHmiControlRegistryCompleteness() | |||||
| Project makeValidProject(); | Project makeValidProject(); | ||||
| void testStatusTextDomainRules() | |||||
| { | |||||
| HmiControl status; | |||||
| status.id = "status"; | |||||
| status.type = HmiControlType::StatusText; | |||||
| status.text = "Status"; | |||||
| status.binding = RegisterAddress{RegisterArea::M, 0}; | |||||
| status.statusText = HmiStatusBitTextConfig{"Stopped", "Running"}; | |||||
| require(status.validate(), "M status text with OFF and ON labels must be valid"); | |||||
| status.binding = RegisterAddress{RegisterArea::D, 0}; | |||||
| require(!status.validate(), "M status text configuration must reject a D binding"); | |||||
| status.binding = RegisterAddress{RegisterArea::D, 20}; | |||||
| status.dataType = RegisterDataType::Float32; | |||||
| status.statusText = HmiStatusWordTextConfig{{ | |||||
| {std::nullopt, 30.0, "Low"}, | |||||
| {30.0, 80.0, "Normal"}, | |||||
| {80.0, std::nullopt, "High"}}}; | |||||
| require(status.validate(), | |||||
| "D status text must accept complete adjacent half-open ranges"); | |||||
| status.binding.reset(); | |||||
| require(status.validate(), | |||||
| "an unbound D status text draft must keep its selected data type"); | |||||
| status.binding = RegisterAddress{RegisterArea::D, 20}; | |||||
| const auto consecutive_ranges = [](std::size_t count) | |||||
| { | |||||
| std::vector<HmiStatusValueRange> result; | |||||
| result.reserve(count); | |||||
| for (std::size_t index = 0; index < count; ++index) | |||||
| { | |||||
| result.push_back({ | |||||
| index == 0U | |||||
| ? std::optional<double>{} | |||||
| : std::optional<double>{static_cast<double>(index)}, | |||||
| index + 1U == count | |||||
| ? std::optional<double>{} | |||||
| : std::optional<double>{static_cast<double>(index + 1U)}, | |||||
| "State"}); | |||||
| } | |||||
| return result; | |||||
| }; | |||||
| status.statusText = HmiStatusWordTextConfig{ | |||||
| consecutive_ranges(ProjectLimits::kMaximumStatusTextRanges)}; | |||||
| require(status.validate(), "a status text control must accept 16 ranges"); | |||||
| status.statusText = HmiStatusWordTextConfig{ | |||||
| consecutive_ranges(ProjectLimits::kMaximumStatusTextRanges + 1U)}; | |||||
| require(!status.validate(), "a status text control must reject a 17th range"); | |||||
| status.statusText = HmiStatusWordTextConfig{{ | |||||
| {std::nullopt, 30.0, "Low"}, | |||||
| {30.0, 80.0, "Normal"}, | |||||
| {80.0, std::nullopt, "High"}}}; | |||||
| auto &ranges = std::get<HmiStatusWordTextConfig>(*status.statusText).ranges; | |||||
| ranges[1].lowerBound = 31.0; | |||||
| require(!status.validate(), "D status text must reject range gaps"); | |||||
| ranges[1].lowerBound = 29.0; | |||||
| require(!status.validate(), "D status text must reject overlapping ranges"); | |||||
| ranges[1].lowerBound = 30.0; | |||||
| ranges.front().lowerBound = -100.0; | |||||
| require(!status.validate(), "D status text must start with an unlimited lower bound"); | |||||
| ranges.front().lowerBound.reset(); | |||||
| status.dataType = RegisterDataType::Int32; | |||||
| ranges[1].upperBound = 80.5; | |||||
| require(!status.validate(), "integer status text boundaries must be integers"); | |||||
| Project project = makeValidProject(); | |||||
| status.dataType = RegisterDataType::Float64; | |||||
| status.binding = RegisterAddress{RegisterArea::D, 100}; | |||||
| ranges[1].upperBound = 80.0; | |||||
| project.hmiPages.front().controls.push_back(status); | |||||
| HmiControl overlapping; | |||||
| overlapping.id = "overlapping"; | |||||
| overlapping.type = HmiControlType::NumericDisplay; | |||||
| overlapping.text = "Overlap"; | |||||
| overlapping.binding = RegisterAddress{RegisterArea::D, 102}; | |||||
| project.hmiPages.front().controls.push_back(overlapping); | |||||
| require(!project.validate(defaultProjectLimitSettings()), | |||||
| "D status text must participate in multi-word HMI overlap checks"); | |||||
| } | |||||
| void testMultiWordHmiBoundaries() | void testMultiWordHmiBoundaries() | ||||
| { | { | ||||
| Project project = makeValidProject(); | Project project = makeValidProject(); | ||||
| @@ -457,7 +547,7 @@ Project makeValidProject() | |||||
| logic.rungs.push_back(rung); | logic.rungs.push_back(rung); | ||||
| Project project; | Project project; | ||||
| project.metadata = {"sample-project", "Sample project", "2.0"}; | |||||
| project.metadata = {"sample-project", "Sample project", "3.0"}; | |||||
| project.hmiPages.push_back(page); | project.hmiPages.push_back(page); | ||||
| project.initialHmiPageId = page.id; | project.initialHmiPageId = page.id; | ||||
| project.controlLogics.push_back(logic); | project.controlLogics.push_back(logic); | ||||
| @@ -983,6 +1073,7 @@ int main() | |||||
| testRegisterRepositorySeparatesAreas(); | testRegisterRepositorySeparatesAreas(); | ||||
| testMultiWordCodecsAndBlockAccess(); | testMultiWordCodecsAndBlockAccess(); | ||||
| testHmiControlRegistryCompleteness(); | testHmiControlRegistryCompleteness(); | ||||
| testStatusTextDomainRules(); | |||||
| testMultiWordHmiBoundaries(); | testMultiWordHmiBoundaries(); | ||||
| testHmiAppearancePropertyBoundaries(); | testHmiAppearancePropertyBoundaries(); | ||||
| testLogicNodeConfigurationBoundaries(); | testLogicNodeConfigurationBoundaries(); | ||||
| @@ -187,6 +187,54 @@ void testRuntimeUsesRegisterRepository() | |||||
| } | } | ||||
| void testStatusTextRuntimeMapping() | |||||
| { | |||||
| VirtualRegisterRepository repository; | |||||
| HmiRuntimeService runtime(repository); | |||||
| HmiControl status; | |||||
| status.id = "machine-status"; | |||||
| status.type = HmiControlType::StatusText; | |||||
| status.text = "Status"; | |||||
| status.binding = RegisterAddress{RegisterArea::M, 5}; | |||||
| status.statusText = HmiStatusBitTextConfig{"Stopped", "Running"}; | |||||
| require(runtime.readStatusText(status).text == "Stopped", | |||||
| "an OFF M bit must resolve to the configured OFF text"); | |||||
| repository.writeBit(*status.binding, true); | |||||
| require(runtime.readStatusText(status).text == "Running", | |||||
| "an ON M bit must resolve to the configured ON text"); | |||||
| require(runtime.writeNumericInput(status, 1).error | |||||
| == HmiRuntimeError::UnsupportedControl, | |||||
| "status text must remain read-only"); | |||||
| status.binding = RegisterAddress{RegisterArea::D, 20}; | |||||
| status.dataType = RegisterDataType::Float32; | |||||
| status.statusText = HmiStatusWordTextConfig{{ | |||||
| {std::nullopt, 30.0, "Low"}, | |||||
| {30.0, 80.0, "Normal"}, | |||||
| {80.0, std::nullopt, "High"}}}; | |||||
| const auto write_float = [&repository, &status](float value) | |||||
| { | |||||
| const auto words = Float32Codec::encode(value); | |||||
| repository.writeWords(*status.binding, {words[0], words[1]}); | |||||
| }; | |||||
| write_float(29.5f); | |||||
| require(runtime.readStatusText(status).text == "Low", | |||||
| "values below the first upper bound must resolve to the first text"); | |||||
| write_float(30.0f); | |||||
| require(runtime.readStatusText(status).text == "Normal", | |||||
| "a shared boundary must belong to the range starting at that boundary"); | |||||
| write_float(80.0f); | |||||
| require(runtime.readStatusText(status).text == "High", | |||||
| "the final boundary must belong to the unlimited upper range"); | |||||
| repository.writeWords( | |||||
| *status.binding, | |||||
| {static_cast<std::int16_t>(0), static_cast<std::int16_t>(0x7fc0)}); | |||||
| require(!runtime.readStatusText(status).succeeded, | |||||
| "NaN register bits must make status text unavailable"); | |||||
| } | |||||
| void testHistoryAndAtomicBatchDelete() | void testHistoryAndAtomicBatchDelete() | ||||
| { | { | ||||
| TestProjectStorage storage; | TestProjectStorage storage; | ||||
| @@ -545,6 +593,7 @@ int main() | |||||
| testRuntimeValidationAndPasteBoundaries(); | testRuntimeValidationAndPasteBoundaries(); | ||||
| testAppearanceEditing(); | testAppearanceEditing(); | ||||
| testRuntimeUsesRegisterRepository(); | testRuntimeUsesRegisterRepository(); | ||||
| testStatusTextRuntimeMapping(); | |||||
| testPageLifecycleAndNavigation(); | testPageLifecycleAndNavigation(); | ||||
| testPageResizeIsAtomicAndUndoable(); | testPageResizeIsAtomicAndUndoable(); | ||||
| testConfiguredPageDefaultsAndLimits(); | testConfiguredPageDefaultsAndLimits(); | ||||
| @@ -75,7 +75,7 @@ LadderRung makeRung( | |||||
| Project makeExampleProject() | Project makeExampleProject() | ||||
| { | { | ||||
| Project project; | Project project; | ||||
| project.metadata = {"example-project", "Example project", "2.0"}; | |||||
| project.metadata = {"example-project", "Example project", "3.0"}; | |||||
| HmiPage page; | HmiPage page; | ||||
| page.id = "main-page"; | page.id = "main-page"; | ||||
| @@ -168,7 +168,7 @@ void testEmptyProjectRoundTrip() | |||||
| ProjectService service(storage, defaultProjectLimitSettings()); | ProjectService service(storage, defaultProjectLimitSettings()); | ||||
| require(service.createNewProject("Empty project").succeeded, | require(service.createNewProject("Empty project").succeeded, | ||||
| "empty project creation must succeed"); | "empty project creation must succeed"); | ||||
| require(service.project().metadata.formatVersion == "2.0", | |||||
| require(service.project().metadata.formatVersion == "3.0", | |||||
| "new projects must use the strict grid format"); | "new projects must use the strict grid format"); | ||||
| const QString path = directory.filePath("empty.json"); | const QString path = directory.filePath("empty.json"); | ||||
| @@ -193,15 +193,15 @@ void testGridProjectRoundTrip() | |||||
| "the grid project must save"); | "the grid project must save"); | ||||
| const QByteArray json = readBytes(path); | const QByteArray json = readBytes(path); | ||||
| require(json.contains("\"formatVersion\": \"2.0\"") | |||||
| require(json.contains("\"formatVersion\": \"3.0\"") | |||||
| && json.contains("\"cells\"") | && json.contains("\"cells\"") | ||||
| && json.contains("\"verticalConnections\"") | && json.contains("\"verticalConnections\"") | ||||
| && json.contains("\"kind\": \"node\"") | && json.contains("\"kind\": \"node\"") | ||||
| && json.contains("\"kind\": \"wire\""), | && json.contains("\"kind\": \"wire\""), | ||||
| "2.0 JSON must persist cells and independent vertical edges"); | |||||
| "3.0 JSON must persist cells and independent vertical edges"); | |||||
| require(!json.contains("\"condition\"") | require(!json.contains("\"condition\"") | ||||
| && !json.contains("\"children\""), | && !json.contains("\"children\""), | ||||
| "2.0 JSON must not serialize the removed expression tree"); | |||||
| "3.0 JSON must not serialize the removed expression tree"); | |||||
| const ProjectLoadResult loaded = storage.load(path.toStdString()); | const ProjectLoadResult loaded = storage.load(path.toStdString()); | ||||
| require(loaded.succeeded, "the grid project must load"); | require(loaded.succeeded, "the grid project must load"); | ||||
| @@ -278,12 +278,12 @@ void testMultiWordHmiDataTypeRoundTrip() | |||||
| project.hmiPages.front().controls.push_back(double_input); | project.hmiPages.front().controls.push_back(double_input); | ||||
| require(storage.save(project, path.toStdString()).succeeded, | require(storage.save(project, path.toStdString()).succeeded, | ||||
| "Int32 and Double HMI controls must save in a 2.0 project"); | |||||
| "Int32 and Double HMI controls must save in a 3.0 project"); | |||||
| const QByteArray json = readBytes(path); | const QByteArray json = readBytes(path); | ||||
| require(json.contains("\"dataType\": \"int32\"") | require(json.contains("\"dataType\": \"int32\"") | ||||
| && json.contains("\"dataType\": \"float64\"") | && json.contains("\"dataType\": \"float64\"") | ||||
| && json.contains("\"formatVersion\": \"2.0\""), | |||||
| "multi-word HMI types must use stable JSON names without a version bump"); | |||||
| && json.contains("\"formatVersion\": \"3.0\""), | |||||
| "multi-word HMI types must use stable JSON names in 3.0"); | |||||
| const ProjectLoadResult loaded = storage.load(path.toStdString()); | const ProjectLoadResult loaded = storage.load(path.toStdString()); | ||||
| require(loaded.succeeded | require(loaded.succeeded | ||||
| @@ -314,6 +314,59 @@ void testMultiWordHmiDataTypeRoundTrip() | |||||
| "unknown HMI data types must be rejected by strict JSON loading"); | "unknown HMI data types must be rejected by strict JSON loading"); | ||||
| } | } | ||||
| void testStatusTextRoundTrip() | |||||
| { | |||||
| QTemporaryDir directory; | |||||
| require(directory.isValid(), "temporary directory must be valid"); | |||||
| JsonProjectStorage storage(defaultProjectLimitSettings()); | |||||
| const QString path = directory.filePath("status-text.json"); | |||||
| Project project = makeExampleProject(); | |||||
| HmiControl bit_status; | |||||
| bit_status.id = "bit-status"; | |||||
| bit_status.type = HmiControlType::StatusText; | |||||
| bit_status.bounds = {20, 80, 140, 40}; | |||||
| bit_status.text = "Status"; | |||||
| bit_status.binding = RegisterAddress{RegisterArea::M, 10}; | |||||
| bit_status.statusText = HmiStatusBitTextConfig{"Stopped", "Running"}; | |||||
| project.hmiPages.front().controls.push_back(bit_status); | |||||
| HmiControl word_status = bit_status; | |||||
| word_status.id = "word-status"; | |||||
| word_status.bounds.x = 180; | |||||
| word_status.binding = RegisterAddress{RegisterArea::D, 100}; | |||||
| word_status.dataType = RegisterDataType::Float64; | |||||
| word_status.statusText = HmiStatusWordTextConfig{{ | |||||
| {std::nullopt, 30.0, "Low"}, | |||||
| {30.0, 80.0, "Normal"}, | |||||
| {80.0, std::nullopt, "High"}}}; | |||||
| project.hmiPages.front().controls.push_back(word_status); | |||||
| require(storage.save(project, path.toStdString()).succeeded, | |||||
| "M and D status text controls must save in 3.0"); | |||||
| const QByteArray json = readBytes(path); | |||||
| require(json.contains("\"type\": \"statusText\"") | |||||
| && json.contains("\"source\": \"m\"") | |||||
| && json.contains("\"source\": \"d\"") | |||||
| && json.contains("\"lower\": null") | |||||
| && json.contains("\"upper\": null") | |||||
| && json.contains("\"dataType\": \"float64\""), | |||||
| "3.0 JSON must store explicit status text modes and range bounds"); | |||||
| const ProjectLoadResult loaded = storage.load(path.toStdString()); | |||||
| require(loaded.succeeded | |||||
| && loaded.project.hmiPages.front().controls.size() == 3U, | |||||
| "status text controls must load after a JSON round trip"); | |||||
| const HmiControl &loaded_bit = loaded.project.hmiPages.front().controls[1]; | |||||
| const HmiControl &loaded_word = loaded.project.hmiPages.front().controls[2]; | |||||
| require(std::get<HmiStatusBitTextConfig>(*loaded_bit.statusText).onText | |||||
| == "Running" | |||||
| && std::get<HmiStatusWordTextConfig>(*loaded_word.statusText) | |||||
| .ranges[1].lowerBound == 30.0 | |||||
| && loaded_word.dataType == RegisterDataType::Float64, | |||||
| "status text labels, ranges and D type must survive round trip"); | |||||
| } | |||||
| void testStrictVersionAndRequiredFields() | void testStrictVersionAndRequiredFields() | ||||
| { | { | ||||
| QTemporaryDir directory; | QTemporaryDir directory; | ||||
| @@ -323,12 +376,12 @@ void testStrictVersionAndRequiredFields() | |||||
| QJsonObject root = savedFixture( | QJsonObject root = savedFixture( | ||||
| storage, makeExampleProject(), fixture_path); | storage, makeExampleProject(), fixture_path); | ||||
| root.insert(QStringLiteral("formatVersion"), QStringLiteral("1.0")); | |||||
| root.insert(QStringLiteral("formatVersion"), QStringLiteral("2.0")); | |||||
| const QString old_path = directory.filePath("old.json"); | const QString old_path = directory.filePath("old.json"); | ||||
| writeBytes(old_path, QJsonDocument(root).toJson()); | writeBytes(old_path, QJsonDocument(root).toJson()); | ||||
| require(storage.load(old_path.toStdString()).error | require(storage.load(old_path.toStdString()).error | ||||
| == ProjectStorageError::UnsupportedVersion, | == ProjectStorageError::UnsupportedVersion, | ||||
| "old 1.0 files must be rejected without migration code"); | |||||
| "old 2.0 files must be rejected without migration code"); | |||||
| root = QJsonDocument::fromJson(readBytes(fixture_path)).object(); | root = QJsonDocument::fromJson(readBytes(fixture_path)).object(); | ||||
| QJsonArray logics = root.value(QStringLiteral("controlLogics")).toArray(); | QJsonArray logics = root.value(QStringLiteral("controlLogics")).toArray(); | ||||
| @@ -340,7 +393,7 @@ void testStrictVersionAndRequiredFields() | |||||
| writeBytes(missing_path, QJsonDocument(root).toJson()); | writeBytes(missing_path, QJsonDocument(root).toJson()); | ||||
| require(storage.load(missing_path.toStdString()).error | require(storage.load(missing_path.toStdString()).error | ||||
| == ProjectStorageError::MissingField, | == ProjectStorageError::MissingField, | ||||
| "2.0 must require the vertical connection array explicitly"); | |||||
| "3.0 must require the vertical connection array explicitly"); | |||||
| } | } | ||||
| void testInvalidGridAndConnectionsAreRejected() | void testInvalidGridAndConnectionsAreRejected() | ||||
| @@ -410,7 +463,7 @@ void testInvalidGridAndConnectionsAreRejected() | |||||
| writeBytes(hidden_comment_path, QJsonDocument(hidden_comment).toJson()); | writeBytes(hidden_comment_path, QJsonDocument(hidden_comment).toJson()); | ||||
| require(storage.load(hidden_comment_path.toStdString()).error | require(storage.load(hidden_comment_path.toStdString()).error | ||||
| == ProjectStorageError::InvalidProject, | == ProjectStorageError::InvalidProject, | ||||
| "a 2.0 project must reject comments stored on connected branch rows"); | |||||
| "a 3.0 project must reject comments stored on connected branch rows"); | |||||
| QJsonObject non_adjacent = original; | QJsonObject non_adjacent = original; | ||||
| logic = firstLogic(&non_adjacent); | logic = firstLogic(&non_adjacent); | ||||
| @@ -500,6 +553,7 @@ int main() | |||||
| testGridProjectRoundTrip(); | testGridProjectRoundTrip(); | ||||
| testMOffAlarmRoundTrip(); | testMOffAlarmRoundTrip(); | ||||
| testMultiWordHmiDataTypeRoundTrip(); | testMultiWordHmiDataTypeRoundTrip(); | ||||
| testStatusTextRoundTrip(); | |||||
| testStrictVersionAndRequiredFields(); | testStrictVersionAndRequiredFields(); | ||||
| testInvalidGridAndConnectionsAreRejected(); | testInvalidGridAndConnectionsAreRejected(); | ||||
| testProjectServiceStateAndConfiguredLimits(); | testProjectServiceStateAndConfiguredLimits(); | ||||
| @@ -171,6 +171,16 @@ void testModeTransitions() | |||||
| double_display.binding = RegisterAddress{RegisterArea::D, 60}; | double_display.binding = RegisterAddress{RegisterArea::D, 60}; | ||||
| double_display.dataType = RegisterDataType::Float64; | double_display.dataType = RegisterDataType::Float64; | ||||
| page.controls.push_back(double_display); | page.controls.push_back(double_display); | ||||
| HmiControl double_status = int32_display; | |||||
| double_status.id = "runtime-double-status"; | |||||
| double_status.type = HmiControlType::StatusText; | |||||
| double_status.bounds = {240, 0, 140, 40}; | |||||
| double_status.binding = RegisterAddress{RegisterArea::D, 70}; | |||||
| double_status.dataType = RegisterDataType::Float64; | |||||
| double_status.statusText = HmiStatusWordTextConfig{{ | |||||
| {std::nullopt, 0.0, "Low"}, | |||||
| {0.0, std::nullopt, "High"}}}; | |||||
| page.controls.push_back(double_status); | |||||
| project.initialHmiPageId = page.id; | project.initialHmiPageId = page.id; | ||||
| project.hmiPages.push_back(page); | project.hmiPages.push_back(page); | ||||
| project.alarmDefinitions.push_back( | project.alarmDefinitions.push_back( | ||||
| @@ -291,12 +301,15 @@ void testModeTransitions() | |||||
| {RegisterArea::D, 40}, {RegisterArea::D, 41}, | {RegisterArea::D, 40}, {RegisterArea::D, 41}, | ||||
| {RegisterArea::D, 50}, {RegisterArea::D, 51}, | {RegisterArea::D, 50}, {RegisterArea::D, 51}, | ||||
| {RegisterArea::D, 60}, {RegisterArea::D, 61}, | {RegisterArea::D, 60}, {RegisterArea::D, 61}, | ||||
| {RegisterArea::D, 62}, {RegisterArea::D, 63}}) | |||||
| {RegisterArea::D, 62}, {RegisterArea::D, 63}, | |||||
| {RegisterArea::D, 70}, {RegisterArea::D, 71}, | |||||
| {RegisterArea::D, 72}, {RegisterArea::D, 73}}) | |||||
| && gateway.pollRanges() | && gateway.pollRanges() | ||||
| == std::vector<RegisterWordRange>({ | == std::vector<RegisterWordRange>({ | ||||
| {{RegisterArea::D, 50}, 2}, | {{RegisterArea::D, 50}, 2}, | ||||
| {{RegisterArea::D, 60}, 4}}), | |||||
| "all instruction M/D references must be polled while comments stay metadata-only"); | |||||
| {{RegisterArea::D, 60}, 4}, | |||||
| {{RegisterArea::D, 70}, 4}}), | |||||
| "instructions and multi-word status text must be fully polled while comments stay metadata-only"); | |||||
| gateway.completeInitialRead(); | gateway.completeInitialRead(); | ||||
| require(service.initialPlcReadCompleted(), | require(service.initialPlcReadCompleted(), | ||||
| "service must retain the initial PLC read state"); | "service must retain the initial PLC read state"); | ||||