| @@ -20,6 +20,7 @@ SOURCES += \ | |||
| src/ui/logic_instruction_dialog.cpp \ | |||
| src/ui/alarm_configuration_dialog.cpp \ | |||
| src/ui/register_comment_dialog.cpp \ | |||
| src/ui/status_text_dialog.cpp \ | |||
| src/ui/plc_connection_dialog.cpp \ | |||
| src/ui/free_monitor_widget.cpp \ | |||
| src/ui/runtime_monitor_window.cpp \ | |||
| @@ -69,6 +70,7 @@ HEADERS += \ | |||
| src/ui/logic_instruction_dialog.h \ | |||
| src/ui/alarm_configuration_dialog.h \ | |||
| src/ui/register_comment_dialog.h \ | |||
| src/ui/status_text_dialog.h \ | |||
| src/ui/plc_connection_dialog.h \ | |||
| src/ui/free_monitor_widget.h \ | |||
| src/ui/runtime_monitor_window.h \ | |||
| @@ -124,6 +126,7 @@ FORMS += \ | |||
| src/ui/logic_instruction_dialog.ui \ | |||
| src/ui/alarm_configuration_dialog.ui \ | |||
| src/ui/register_comment_dialog.ui \ | |||
| src/ui/status_text_dialog.ui \ | |||
| src/ui/plc_connection_dialog.ui \ | |||
| src/ui/free_monitor_widget.ui \ | |||
| src/ui/runtime_monitor_window.ui \ | |||
| @@ -52,6 +52,15 @@ constexpr std::array kControlDescriptors = { | |||
| HmiBindingKind::None, | |||
| HmiRuntimeValueKind::None, | |||
| false}, | |||
| HmiControlDescriptor{HmiControlType::StatusText, | |||
| "statusText", | |||
| "状态文本", | |||
| "status-text", | |||
| "状态文本", | |||
| {0, 0, 140, 40}, | |||
| HmiBindingKind::BitOrWord, | |||
| HmiRuntimeValueKind::BitOrWord, | |||
| true}, | |||
| HmiControlDescriptor{HmiControlType::PageJump, | |||
| "pageJump", | |||
| "页面跳转", | |||
| @@ -117,6 +126,7 @@ std::optional<RegisterArea> hmiBindingArea(HmiBindingKind binding_kind) | |||
| return RegisterArea::D; | |||
| } | |||
| case HmiBindingKind::None: | |||
| case HmiBindingKind::BitOrWord: | |||
| default: | |||
| { | |||
| return std::nullopt; | |||
| @@ -19,7 +19,8 @@ enum class HmiBindingKind | |||
| { | |||
| None, // 不支持寄存器绑定 | |||
| Bit, // 绑定 M 区位地址 | |||
| Word // 绑定 D 区字地址 | |||
| Word, // 绑定 D 区字地址 | |||
| BitOrWord // 根据控件配置绑定 M 位或 D 字 | |||
| }; | |||
| /** | |||
| @@ -29,7 +30,8 @@ enum class HmiRuntimeValueKind | |||
| { | |||
| None, // 运行态不读取寄存器值 | |||
| Bit, // 运行态读取绑定的 M 位值 | |||
| Word // 运行态读取绑定的 D 字值 | |||
| Word, // 运行态读取绑定的 D 字值 | |||
| BitOrWord // 运行态按绑定区域读取 M 位或 D 字 | |||
| }; | |||
| /** | |||
| @@ -6,6 +6,7 @@ | |||
| #include <algorithm> | |||
| #include <cctype> | |||
| #include <charconv> | |||
| #include <cmath> | |||
| namespace { | |||
| @@ -47,6 +48,101 @@ bool isBooleanValue(const std::string &value) | |||
| 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( | |||
| 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 = | |||
| 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 控件不能绑定寄存器"); | |||
| return false; | |||
| @@ -171,8 +268,11 @@ bool HmiControl::validate(std::string *error) const | |||
| setError(error, "HMI 控件绑定了无效地址"); | |||
| return false; | |||
| } | |||
| const bool status_text = type == HmiControlType::StatusText; | |||
| 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)) | |||
| { | |||
| setError(error, "HMI 数值类型无效"); | |||
| @@ -199,6 +299,48 @@ bool HmiControl::validate(std::string *error) const | |||
| } | |||
| 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 (!pageJump.has_value()) | |||
| @@ -244,6 +386,17 @@ bool HmiControl::isConfigured() const | |||
| } | |||
| const std::optional<RegisterArea> binding_area = | |||
| 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; | |||
| } | |||
| @@ -14,6 +14,7 @@ | |||
| #include <map> | |||
| #include <optional> | |||
| #include <string> | |||
| #include <variant> | |||
| #include <vector> | |||
| namespace HmiAppearanceProperty | |||
| @@ -54,6 +55,7 @@ enum class HmiControlType | |||
| NumericDisplay, // 数值显示 | |||
| NumericInput, // 数值输入 | |||
| Label, // 标签 | |||
| StatusText, // 状态文本 | |||
| PageJump, // 页面跳转 | |||
| AlarmList, // 报警列表 | |||
| Count // 已注册控件类型数量,不作为实际控件使用 | |||
| @@ -75,6 +77,45 @@ struct HmiPageJumpConfig | |||
| 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 控件及其显示和寄存器配置 | |||
| * | |||
| @@ -91,6 +132,7 @@ struct HmiControl | |||
| std::map<std::string, std::string> properties; | |||
| HmiButtonOperation buttonOperation = HmiButtonOperation::MomentaryOn; | |||
| std::optional<HmiPageJumpConfig> pageJump; | |||
| std::optional<HmiStatusTextConfig> statusText; | |||
| /** | |||
| * @brief 校验控件的标识、尺寸、扩展属性和寄存器绑定 | |||
| @@ -25,6 +25,7 @@ constexpr std::size_t kMaximumVerticalConnectionsPerLogic = | |||
| static_assert(kMaximumLadderColumns == kMaximumConditionColumns + 1); // 确保总列数始终等于条件列加输出列 | |||
| 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 kMaximumTextBytes = 256U; // 工程名称等普通文本最多 256 个 UTF-8 字节 | |||
| constexpr std::size_t kMaximumHmiControlTextCharacters = 12U; // HMI 控件显示文字最多输入 12 个字符 | |||
| @@ -82,7 +82,10 @@ struct HmiDataBinding | |||
| bool isNumericControl(const HmiControl &control) | |||
| { | |||
| 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) | |||
| @@ -26,7 +26,7 @@ struct ProjectMetadata | |||
| // 工程显示名称 | |||
| std::string name; | |||
| // 工程文件格式版本 | |||
| std::string formatVersion = "2.0"; | |||
| std::string formatVersion = "3.0"; | |||
| }; | |||
| // 聚合工程中的 HMI 页面、报警、寄存器注释和控制逻辑 | |||
| @@ -18,7 +18,7 @@ | |||
| namespace { | |||
| // 当前读写实现支持的工程文件格式版本 | |||
| constexpr const char *kCurrentFormatVersion = "2.0"; | |||
| constexpr const char *kCurrentFormatVersion = "3.0"; | |||
| // 保存解析过程中遇到的第一个错误,避免后续失败覆盖根因 | |||
| struct ParseState | |||
| @@ -648,6 +648,134 @@ bool parseProperties( | |||
| 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 对象 | |||
| QJsonObject serializeHmiControl(const HmiControl &control) | |||
| { | |||
| @@ -666,8 +794,12 @@ QJsonObject serializeHmiControl(const HmiControl &control) | |||
| object.insert(QStringLiteral("binding"), QJsonValue::Null); | |||
| } | |||
| 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 | |||
| || control.type == HmiControlType::NumericInput) | |||
| || control.type == HmiControlType::NumericInput | |||
| || status_word) | |||
| { | |||
| object.insert( | |||
| QStringLiteral("dataType"), | |||
| @@ -687,6 +819,14 @@ QJsonObject serializeHmiControl(const HmiControl &control) | |||
| ? control.pageJump->targetPageId | |||
| : std::string{})); | |||
| } | |||
| if (control.type == HmiControlType::StatusText) | |||
| { | |||
| object.insert( | |||
| QStringLiteral("statusText"), | |||
| control.statusText.has_value() | |||
| ? serializeStatusTextConfig(*control.statusText) | |||
| : QJsonObject{}); | |||
| } | |||
| return object; | |||
| } | |||
| @@ -718,8 +858,24 @@ bool parseHmiControl( | |||
| { | |||
| 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 | |||
| || control->type == HmiControlType::NumericInput) | |||
| || control->type == HmiControlType::NumericInput | |||
| || status_word) | |||
| { | |||
| std::string data_type_text; | |||
| if (!readString(object, "dataType", context, &data_type_text, state) | |||
| @@ -2,7 +2,7 @@ | |||
| #include "domain/project_storage.h" | |||
| // 严格读写当前 2.0 JSON 工程格式 | |||
| // 严格读写当前 3.0 JSON 工程格式 | |||
| class JsonProjectStorage final : public ProjectStorage | |||
| { | |||
| public: | |||
| @@ -137,6 +137,7 @@ bool HmiEditorService::controlsEqual( | |||
| && left.dataType == right.dataType | |||
| && left.properties == right.properties | |||
| && left.buttonOperation == right.buttonOperation | |||
| && left.statusText == right.statusText | |||
| && page_jump_equal; | |||
| } | |||
| @@ -824,6 +825,11 @@ HmiControl HmiEditorService::makeControl( | |||
| { | |||
| control.pageJump = HmiPageJumpConfig{}; | |||
| } | |||
| if (descriptor.type == HmiControlType::StatusText) | |||
| { | |||
| control.statusText = HmiStatusBitTextConfig{ | |||
| "设备停止", "设备运行"}; | |||
| } | |||
| const int offset = static_cast<int>(page.controls.size()) * 16; | |||
| control.bounds.x = std::min(20 + offset, page.width - control.bounds.width); | |||
| control.bounds.y = std::min(20 + offset, page.height - control.bounds.height); | |||
| @@ -2,8 +2,10 @@ | |||
| #include "domain/hmi_control_registry.h" | |||
| #include <algorithm> | |||
| #include <cmath> | |||
| #include <limits> | |||
| #include <type_traits> | |||
| namespace { | |||
| @@ -17,6 +19,16 @@ HmiRuntimeWriteResult writeFailure(HmiRuntimeError error) | |||
| return {false, error}; | |||
| } | |||
| double numericValueAsDouble(const RegisterNumericValue &value) | |||
| { | |||
| return std::visit( | |||
| [](const auto &typed_value) | |||
| { | |||
| return static_cast<double>(typed_value); | |||
| }, | |||
| value); | |||
| } | |||
| } // namespace | |||
| HmiRuntimeService::HmiRuntimeService(RegisterRepository &repository) | |||
| @@ -43,7 +55,10 @@ HmiRuntimeReadResult HmiRuntimeService::readControl(const HmiControl &control) c | |||
| } | |||
| const std::optional<RegisterArea> binding_area = | |||
| 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); | |||
| } | |||
| @@ -79,6 +94,34 @@ HmiRuntimeReadResult HmiRuntimeService::readControl(const HmiControl &control) c | |||
| true, HmiRuntimeError::None, false, *value} | |||
| : 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: | |||
| 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( | |||
| const HmiControl &control, HmiButtonEvent event) | |||
| { | |||
| @@ -46,6 +46,13 @@ struct HmiRuntimeWriteResult | |||
| HmiRuntimeError error = HmiRuntimeError::None; // 失败时的 HMI 错误分类 | |||
| }; | |||
| struct HmiStatusTextReadResult | |||
| { | |||
| bool succeeded = false; | |||
| HmiRuntimeError error = HmiRuntimeError::None; | |||
| std::string text; | |||
| }; | |||
| /** | |||
| * @brief HMI 按钮在运行态产生的输入事件 | |||
| */ | |||
| @@ -78,6 +85,7 @@ public: | |||
| * 控件未绑定、绑定区域错误或仓库不可用时不返回有效值 | |||
| */ | |||
| HmiRuntimeReadResult readControl(const HmiControl &control) const; | |||
| HmiStatusTextReadResult readStatusText(const HmiControl &control) const; | |||
| /** | |||
| * @brief 根据按钮配置处理按下或释放事件 | |||
| * @param control 按钮控件,必须绑定有效 M 地址 | |||
| @@ -192,7 +192,7 @@ Project ProjectService::makeNewProject(const std::string &name) | |||
| project.metadata.id = generateProjectId(); | |||
| project.metadata.name = name; | |||
| // 新工程固定使用当前存储格式版本 | |||
| project.metadata.formatVersion = "2.0"; | |||
| project.metadata.formatVersion = "3.0"; | |||
| return project; | |||
| } | |||
| @@ -323,7 +323,8 @@ PlcCommunicationResult RuntimeModeService::refreshPlcPollAddresses() | |||
| { | |||
| addresses.push_back(*control.binding); | |||
| if ((control.type == HmiControlType::NumericDisplay | |||
| || control.type == HmiControlType::NumericInput) | |||
| || control.type == HmiControlType::NumericInput | |||
| || control.type == HmiControlType::StatusText) | |||
| && control.binding->area() == RegisterArea::D) | |||
| { | |||
| const int word_count = registerDataTypeWordCount(control.dataType); | |||
| @@ -276,6 +276,15 @@ public: | |||
| textWithValue()); | |||
| 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: | |||
| { | |||
| const QColor fill = runtime_active_ && page_hovered_ | |||
| @@ -503,6 +512,14 @@ public: | |||
| 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) | |||
| { | |||
| alarm_records_ = records; | |||
| @@ -834,6 +851,12 @@ private: | |||
| { | |||
| const QString text = QString::fromUtf8( | |||
| 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) | |||
| { | |||
| return text; | |||
| @@ -874,6 +897,8 @@ private: | |||
| RegisterNumericValue numeric_value_ = std::int16_t{0}; | |||
| // 标记当前缓存值是否来自一次成功的运行时读取 | |||
| bool has_runtime_value_ = false; | |||
| QString status_text_; | |||
| bool status_text_available_ = false; | |||
| }; | |||
| // 将场景通用图元安全转换为本文件定义的 HMI 控件图元 | |||
| @@ -935,6 +960,7 @@ void HmiEditorWidget::setRuntimeActive(bool active) | |||
| if (control_item != nullptr) | |||
| { | |||
| control_item->setRuntimeValue(false, std::int16_t{0}, false); | |||
| control_item->setStatusText({}, false); | |||
| control_item->setAlarmRecords({}); | |||
| } | |||
| } | |||
| @@ -1082,6 +1108,13 @@ void HmiEditorWidget::refreshRuntimeValues() | |||
| control_item->setAlarmRecords(alarm_service_.records()); | |||
| 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); | |||
| control_item->setRuntimeValue( | |||
| @@ -647,6 +647,8 @@ void MainWindow::configureActions() | |||
| [this] { addHmiControl(HmiControlType::NumericInput); }); | |||
| connect(ui_->addLabelAction, &QAction::triggered, this, | |||
| [this] { addHmiControl(HmiControlType::Label); }); | |||
| connect(ui_->addStatusTextAction, &QAction::triggered, this, | |||
| [this] { addHmiControl(HmiControlType::StatusText); }); | |||
| connect(ui_->addPageJumpAction, &QAction::triggered, this, | |||
| [this] { addHmiControl(HmiControlType::PageJump); }); | |||
| connect(ui_->addAlarmListAction, &QAction::triggered, this, | |||
| @@ -968,6 +970,7 @@ void MainWindow::configureAppearance() | |||
| ui_->addNumericDisplayAction->setIcon(makeUiIcon(UiIcon::NumericDisplay)); | |||
| ui_->addNumericInputAction->setIcon(makeUiIcon(UiIcon::NumericInput)); | |||
| ui_->addLabelAction->setIcon(makeUiIcon(UiIcon::Text)); | |||
| ui_->addStatusTextAction->setIcon(makeUiIcon(UiIcon::StatusText)); | |||
| ui_->addPageJumpAction->setIcon(makeUiIcon(UiIcon::PageJump)); | |||
| ui_->addAlarmListAction->setIcon(makeUiIcon(UiIcon::AlarmList)); | |||
| ui_->configureAlarmsAction->setIcon(makeUiIcon(UiIcon::AlarmSettings)); | |||
| @@ -2437,6 +2440,7 @@ void MainWindow::updateModeUi(const QString &message) | |||
| ui_->addNumericDisplayAction->setEnabled(policy.allowsProjectEditing); | |||
| ui_->addNumericInputAction->setEnabled(policy.allowsProjectEditing); | |||
| ui_->addLabelAction->setEnabled(policy.allowsProjectEditing); | |||
| ui_->addStatusTextAction->setEnabled(policy.allowsProjectEditing); | |||
| ui_->addPageJumpAction->setEnabled(policy.allowsProjectEditing); | |||
| ui_->addAlarmListAction->setEnabled(policy.allowsProjectEditing); | |||
| ui_->configureAlarmsAction->setEnabled(policy.allowsProjectEditing); | |||
| @@ -334,6 +334,7 @@ | |||
| <addaction name="addNumericDisplayAction"/> | |||
| <addaction name="addNumericInputAction"/> | |||
| <addaction name="addLabelAction"/> | |||
| <addaction name="addStatusTextAction"/> | |||
| <addaction name="deleteControlAction"/> | |||
| </widget> | |||
| <widget class="QToolBar" name="logicToolBar"> | |||
| @@ -785,7 +786,17 @@ | |||
| <item><property name="text"><string>Double (Float64)</string></property></item> | |||
| </widget> | |||
| </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"> | |||
| <property name="text"> | |||
| <string>应用属性</string> | |||
| @@ -1123,6 +1134,10 @@ | |||
| <string>添加页面跳转控件</string> | |||
| </property> | |||
| </action> | |||
| <action name="addStatusTextAction"> | |||
| <property name="text"><string>状态文本</string></property> | |||
| <property name="toolTip"><string>添加状态文本控件</string></property> | |||
| </action> | |||
| <action name="addAlarmListAction"> | |||
| <property name="text"> | |||
| <string>报警列表</string> | |||
| @@ -5,6 +5,7 @@ | |||
| #include "hmi_editor_widget.h" | |||
| #include "logic_editor_widget.h" | |||
| #include "logic_instruction_dialog.h" | |||
| #include "status_text_dialog.h" | |||
| #include "services/hmi_editor_service.h" | |||
| #include "services/logic_editor_service.h" | |||
| #include "services/project_service.h" | |||
| @@ -128,6 +129,8 @@ void PropertyPanelController::configure() | |||
| &parent_, [this] { applySelectedLogicNodeProperties(); }); | |||
| QObject::connect(ui_.textColorButton, &QPushButton::clicked, | |||
| &parent_, [this] { chooseTextColor(); }); | |||
| QObject::connect(ui_.configureStatusTextButton, &QPushButton::clicked, | |||
| &parent_, [this] { configureStatusText(); }); | |||
| ui_.targetPageLabel->setVisible(false); | |||
| ui_.targetPageComboBox->setVisible(false); | |||
| showControlProperties({}); | |||
| @@ -204,6 +207,7 @@ void PropertyPanelController::showControlProperties(const std::string &control_i | |||
| static_cast<QWidget *>(ui_.targetPageComboBox), | |||
| static_cast<QWidget *>(ui_.buttonOperationComboBox), | |||
| static_cast<QWidget *>(ui_.dataTypeComboBox), | |||
| static_cast<QWidget *>(ui_.configureStatusTextButton), | |||
| static_cast<QWidget *>(ui_.textColorEdit), | |||
| static_cast<QWidget *>(ui_.textColorButton), | |||
| static_cast<QWidget *>(ui_.fontSizeSpinBox), | |||
| @@ -228,6 +232,8 @@ void PropertyPanelController::showControlProperties(const std::string &control_i | |||
| ui_.buttonOperationComboBox->setVisible(false); | |||
| ui_.dataTypeLabel->setVisible(false); | |||
| ui_.dataTypeComboBox->setVisible(false); | |||
| ui_.statusTextConfigLabel->setVisible(false); | |||
| ui_.configureStatusTextButton->setVisible(false); | |||
| ui_.textColorEdit->clear(); | |||
| ui_.textColorButton->setStyleSheet(QString{}); | |||
| ui_.fontSizeSpinBox->setValue( | |||
| @@ -242,8 +248,11 @@ void PropertyPanelController::showControlProperties(const std::string &control_i | |||
| ui_.controlTextEdit->setMaxLength( | |||
| static_cast<int>(ProjectLimits::kMaximumHmiControlTextCharacters)); | |||
| const bool is_status_text = control->type == HmiControlType::StatusText; | |||
| ui_.controlIdEdit->setText(fromUtf8(control->id)); | |||
| 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_.controlYSpinBox->setValue(control->bounds.y); | |||
| ui_.controlWidthSpinBox->setValue(control->bounds.width); | |||
| @@ -253,7 +262,8 @@ void PropertyPanelController::showControlProperties(const std::string &control_i | |||
| ui_.bindingIndexSpinBox->setValue( | |||
| control->binding.has_value() ? control->binding->index() : 0); | |||
| const bool has_binding = descriptor != nullptr | |||
| && descriptor->bindingKind != HmiBindingKind::None; | |||
| && descriptor->bindingKind != HmiBindingKind::None | |||
| && !is_status_text; | |||
| ui_.bindingAreaLabel->setVisible(has_binding); | |||
| ui_.bindingAreaComboBox->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->setCurrentIndex( | |||
| 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; | |||
| ui_.targetPageLabel->setVisible(is_page_jump); | |||
| @@ -536,16 +561,20 @@ void PropertyPanelController::applySelectedControlProperties() | |||
| control.bounds.y = ui_.controlYSpinBox->value(); | |||
| control.bounds.width = ui_.controlWidthSpinBox->value(); | |||
| control.bounds.height = ui_.controlHeightSpinBox->value(); | |||
| control.dataType = static_cast<RegisterDataType>( | |||
| ui_.dataTypeComboBox->currentData().toInt()); | |||
| if (control.type != HmiControlType::StatusText) | |||
| { | |||
| control.dataType = static_cast<RegisterDataType>( | |||
| ui_.dataTypeComboBox->currentData().toInt()); | |||
| } | |||
| const HmiControlDescriptor *descriptor = | |||
| findHmiControlDescriptor(control.type); | |||
| if (descriptor == nullptr | |||
| || descriptor->bindingKind == HmiBindingKind::None) | |||
| if (control.type != HmiControlType::StatusText | |||
| && (descriptor == nullptr | |||
| || descriptor->bindingKind == HmiBindingKind::None)) | |||
| { | |||
| control.binding.reset(); | |||
| } | |||
| else | |||
| else if (control.type != HmiControlType::StatusText) | |||
| { | |||
| const int binding_area = ui_.bindingAreaComboBox->currentData().toInt(); | |||
| 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() | |||
| { | |||
| const LogicNode *node = logic_editor_service_.findNode( | |||
| @@ -89,6 +89,8 @@ private: | |||
| void handleLogicEditorError(const QString &message) const; | |||
| /** 打开颜色选择器并写回属性输入框 */ | |||
| void chooseTextColor(); | |||
| /** 打开状态文本专用配置并原子更新控件 */ | |||
| void configureStatusText(); | |||
| /** 统一报告属性操作失败 */ | |||
| void reportFailure( | |||
| 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); | |||
| 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: | |||
| { | |||
| painter.drawRect(QRectF(3, 3, 14, 18)); | |||
| @@ -31,6 +31,7 @@ enum class UiIcon | |||
| NumericDisplay, // HMI 数值显示 | |||
| NumericInput, // HMI 数值输入 | |||
| Text, // HMI 文本 | |||
| StatusText, // HMI 状态文本 | |||
| PageJump, // HMI 页面跳转 | |||
| AlarmList, // HMI 报警列表 | |||
| AlarmSettings, // 报警配置 | |||
| @@ -255,6 +255,13 @@ void testHmiControlRegistryCompleteness() | |||
| "word runtime controls must bind to the D area"); | |||
| 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: | |||
| { | |||
| require(descriptor->bindingKind == HmiBindingKind::None | |||
| @@ -286,6 +293,89 @@ void testHmiControlRegistryCompleteness() | |||
| 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() | |||
| { | |||
| Project project = makeValidProject(); | |||
| @@ -457,7 +547,7 @@ Project makeValidProject() | |||
| logic.rungs.push_back(rung); | |||
| Project project; | |||
| project.metadata = {"sample-project", "Sample project", "2.0"}; | |||
| project.metadata = {"sample-project", "Sample project", "3.0"}; | |||
| project.hmiPages.push_back(page); | |||
| project.initialHmiPageId = page.id; | |||
| project.controlLogics.push_back(logic); | |||
| @@ -983,6 +1073,7 @@ int main() | |||
| testRegisterRepositorySeparatesAreas(); | |||
| testMultiWordCodecsAndBlockAccess(); | |||
| testHmiControlRegistryCompleteness(); | |||
| testStatusTextDomainRules(); | |||
| testMultiWordHmiBoundaries(); | |||
| testHmiAppearancePropertyBoundaries(); | |||
| 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() | |||
| { | |||
| TestProjectStorage storage; | |||
| @@ -545,6 +593,7 @@ int main() | |||
| testRuntimeValidationAndPasteBoundaries(); | |||
| testAppearanceEditing(); | |||
| testRuntimeUsesRegisterRepository(); | |||
| testStatusTextRuntimeMapping(); | |||
| testPageLifecycleAndNavigation(); | |||
| testPageResizeIsAtomicAndUndoable(); | |||
| testConfiguredPageDefaultsAndLimits(); | |||
| @@ -75,7 +75,7 @@ LadderRung makeRung( | |||
| Project makeExampleProject() | |||
| { | |||
| Project project; | |||
| project.metadata = {"example-project", "Example project", "2.0"}; | |||
| project.metadata = {"example-project", "Example project", "3.0"}; | |||
| HmiPage page; | |||
| page.id = "main-page"; | |||
| @@ -168,7 +168,7 @@ void testEmptyProjectRoundTrip() | |||
| ProjectService service(storage, defaultProjectLimitSettings()); | |||
| require(service.createNewProject("Empty project").succeeded, | |||
| "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"); | |||
| const QString path = directory.filePath("empty.json"); | |||
| @@ -193,15 +193,15 @@ void testGridProjectRoundTrip() | |||
| "the grid project must save"); | |||
| const QByteArray json = readBytes(path); | |||
| require(json.contains("\"formatVersion\": \"2.0\"") | |||
| require(json.contains("\"formatVersion\": \"3.0\"") | |||
| && json.contains("\"cells\"") | |||
| && json.contains("\"verticalConnections\"") | |||
| && json.contains("\"kind\": \"node\"") | |||
| && 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\"") | |||
| && !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()); | |||
| require(loaded.succeeded, "the grid project must load"); | |||
| @@ -278,12 +278,12 @@ void testMultiWordHmiDataTypeRoundTrip() | |||
| project.hmiPages.front().controls.push_back(double_input); | |||
| 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); | |||
| require(json.contains("\"dataType\": \"int32\"") | |||
| && 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()); | |||
| require(loaded.succeeded | |||
| @@ -314,6 +314,59 @@ void testMultiWordHmiDataTypeRoundTrip() | |||
| "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() | |||
| { | |||
| QTemporaryDir directory; | |||
| @@ -323,12 +376,12 @@ void testStrictVersionAndRequiredFields() | |||
| QJsonObject root = savedFixture( | |||
| 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"); | |||
| writeBytes(old_path, QJsonDocument(root).toJson()); | |||
| require(storage.load(old_path.toStdString()).error | |||
| == 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(); | |||
| QJsonArray logics = root.value(QStringLiteral("controlLogics")).toArray(); | |||
| @@ -340,7 +393,7 @@ void testStrictVersionAndRequiredFields() | |||
| writeBytes(missing_path, QJsonDocument(root).toJson()); | |||
| require(storage.load(missing_path.toStdString()).error | |||
| == ProjectStorageError::MissingField, | |||
| "2.0 must require the vertical connection array explicitly"); | |||
| "3.0 must require the vertical connection array explicitly"); | |||
| } | |||
| void testInvalidGridAndConnectionsAreRejected() | |||
| @@ -410,7 +463,7 @@ void testInvalidGridAndConnectionsAreRejected() | |||
| writeBytes(hidden_comment_path, QJsonDocument(hidden_comment).toJson()); | |||
| require(storage.load(hidden_comment_path.toStdString()).error | |||
| == 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; | |||
| logic = firstLogic(&non_adjacent); | |||
| @@ -500,6 +553,7 @@ int main() | |||
| testGridProjectRoundTrip(); | |||
| testMOffAlarmRoundTrip(); | |||
| testMultiWordHmiDataTypeRoundTrip(); | |||
| testStatusTextRoundTrip(); | |||
| testStrictVersionAndRequiredFields(); | |||
| testInvalidGridAndConnectionsAreRejected(); | |||
| testProjectServiceStateAndConfiguredLimits(); | |||
| @@ -171,6 +171,16 @@ void testModeTransitions() | |||
| double_display.binding = RegisterAddress{RegisterArea::D, 60}; | |||
| double_display.dataType = RegisterDataType::Float64; | |||
| 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.hmiPages.push_back(page); | |||
| project.alarmDefinitions.push_back( | |||
| @@ -291,12 +301,15 @@ void testModeTransitions() | |||
| {RegisterArea::D, 40}, {RegisterArea::D, 41}, | |||
| {RegisterArea::D, 50}, {RegisterArea::D, 51}, | |||
| {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() | |||
| == std::vector<RegisterWordRange>({ | |||
| {{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(); | |||
| require(service.initialPlcReadCompleted(), | |||
| "service must retain the initial PLC read state"); | |||