diff --git a/app/integrated_platform.pro b/app/integrated_platform.pro index 64b77b0..c960e66 100644 --- a/app/integrated_platform.pro +++ b/app/integrated_platform.pro @@ -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 \ diff --git a/app/src/domain/hmi_control_registry.cpp b/app/src/domain/hmi_control_registry.cpp index 02c1a9c..df8c6a4 100644 --- a/app/src/domain/hmi_control_registry.cpp +++ b/app/src/domain/hmi_control_registry.cpp @@ -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 hmiBindingArea(HmiBindingKind binding_kind) return RegisterArea::D; } case HmiBindingKind::None: + case HmiBindingKind::BitOrWord: default: { return std::nullopt; diff --git a/app/src/domain/hmi_control_registry.h b/app/src/domain/hmi_control_registry.h index 86ca98e..41dbac8 100644 --- a/app/src/domain/hmi_control_registry.h +++ b/app/src/domain/hmi_control_registry.h @@ -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 字 }; /** diff --git a/app/src/domain/hmi_model.cpp b/app/src/domain/hmi_model.cpp index 8661362..38621ae 100644 --- a/app/src/domain/hmi_model.cpp +++ b/app/src/domain/hmi_model.cpp @@ -6,6 +6,7 @@ #include #include #include +#include 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 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(*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(&*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(*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 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; } diff --git a/app/src/domain/hmi_model.h b/app/src/domain/hmi_model.h index 20de819..7a746a9 100644 --- a/app/src/domain/hmi_model.h +++ b/app/src/domain/hmi_model.h @@ -14,6 +14,7 @@ #include #include #include +#include #include 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 lowerBound; + std::optional upperBound; + std::string text; + + bool operator==(const HmiStatusValueRange &other) const + { + return lowerBound == other.lowerBound + && upperBound == other.upperBound + && text == other.text; + } +}; + +struct HmiStatusWordTextConfig +{ + std::vector 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 properties; HmiButtonOperation buttonOperation = HmiButtonOperation::MomentaryOn; std::optional pageJump; + std::optional statusText; /** * @brief 校验控件的标识、尺寸、扩展属性和寄存器绑定 diff --git a/app/src/domain/project_limits.h b/app/src/domain/project_limits.h index 0e61d51..443e842 100644 --- a/app/src/domain/project_limits.h +++ b/app/src/domain/project_limits.h @@ -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 个字符 diff --git a/app/src/domain/project_model.cpp b/app/src/domain/project_model.cpp index bb400ff..2cf6b74 100644 --- a/app/src/domain/project_model.cpp +++ b/app/src/domain/project_model.cpp @@ -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) diff --git a/app/src/domain/project_model.h b/app/src/domain/project_model.h index 6be5715..70edfb6 100644 --- a/app/src/domain/project_model.h +++ b/app/src/domain/project_model.h @@ -26,7 +26,7 @@ struct ProjectMetadata // 工程显示名称 std::string name; // 工程文件格式版本 - std::string formatVersion = "2.0"; + std::string formatVersion = "3.0"; }; // 聚合工程中的 HMI 页面、报警、寄存器注释和控制逻辑 diff --git a/app/src/infrastructure/json_project_storage.cpp b/app/src/infrastructure/json_project_storage.cpp index 7fb8a51..49fdfad 100644 --- a/app/src/infrastructure/json_project_storage.cpp +++ b/app/src/infrastructure/json_project_storage.cpp @@ -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 &value) +{ + return value.has_value() ? QJsonValue(*value) : QJsonValue(QJsonValue::Null); +} + +QJsonObject serializeStatusTextConfig(const HmiStatusTextConfig &config) +{ + QJsonObject object; + if (const auto *bit = std::get_if(&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(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 *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(ProjectLimits::kMaximumStatusTextRanges)) + { + return state->fail( + ProjectStorageError::InvalidField, + context + ".ranges 必须包含 1~16 个区间"); + } + HmiStatusWordTextConfig word; + word.ranges.reserve(static_cast(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(*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(*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) diff --git a/app/src/infrastructure/json_project_storage.h b/app/src/infrastructure/json_project_storage.h index df65132..b6d9c64 100644 --- a/app/src/infrastructure/json_project_storage.h +++ b/app/src/infrastructure/json_project_storage.h @@ -2,7 +2,7 @@ #include "domain/project_storage.h" -// 严格读写当前 2.0 JSON 工程格式 +// 严格读写当前 3.0 JSON 工程格式 class JsonProjectStorage final : public ProjectStorage { public: diff --git a/app/src/services/hmi_editor_service.cpp b/app/src/services/hmi_editor_service.cpp index 467d41c..fac09cf 100644 --- a/app/src/services/hmi_editor_service.cpp +++ b/app/src/services/hmi_editor_service.cpp @@ -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(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); diff --git a/app/src/services/hmi_runtime_service.cpp b/app/src/services/hmi_runtime_service.cpp index 0060b22..bdb4798 100644 --- a/app/src/services/hmi_runtime_service.cpp +++ b/app/src/services/hmi_runtime_service.cpp @@ -2,8 +2,10 @@ #include "domain/hmi_control_registry.h" +#include #include #include +#include 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(typed_value); + }, + value); +} + } // namespace HmiRuntimeService::HmiRuntimeService(RegisterRepository &repository) @@ -43,7 +55,10 @@ HmiRuntimeReadResult HmiRuntimeService::readControl(const HmiControl &control) c } const std::optional 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 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( + *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( + &*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( + *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) { diff --git a/app/src/services/hmi_runtime_service.h b/app/src/services/hmi_runtime_service.h index 8c6fc76..8a01f49 100644 --- a/app/src/services/hmi_runtime_service.h +++ b/app/src/services/hmi_runtime_service.h @@ -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 地址 diff --git a/app/src/services/project_service.cpp b/app/src/services/project_service.cpp index 0fb5089..efd7b0f 100644 --- a/app/src/services/project_service.cpp +++ b/app/src/services/project_service.cpp @@ -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; } diff --git a/app/src/services/runtime_mode_service.cpp b/app/src/services/runtime_mode_service.cpp index a84521e..f6f1a8b 100644 --- a/app/src/services/runtime_mode_service.cpp +++ b/app/src/services/runtime_mode_service.cpp @@ -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); diff --git a/app/src/ui/hmi_editor_widget.cpp b/app/src/ui/hmi_editor_widget.cpp index c980a2b..7cfe007 100644 --- a/app/src/ui/hmi_editor_widget.cpp +++ b/app/src/ui/hmi_editor_widget.cpp @@ -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(text.size())); + status_text_available_ = available; + update(); + } + void setAlarmRecords(const std::vector &records) { alarm_records_ = records; @@ -834,6 +851,12 @@ private: { const QString text = QString::fromUtf8( control_.text.data(), static_cast(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( diff --git a/app/src/ui/main_window.cpp b/app/src/ui/main_window.cpp index 66add13..626ca65 100644 --- a/app/src/ui/main_window.cpp +++ b/app/src/ui/main_window.cpp @@ -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); diff --git a/app/src/ui/main_window.ui b/app/src/ui/main_window.ui index d96b8d8..05bd0d0 100644 --- a/app/src/ui/main_window.ui +++ b/app/src/ui/main_window.ui @@ -334,6 +334,7 @@ + @@ -785,7 +786,17 @@ Double (Float64) - + + + 状态映射 + + + + + 配置 + + + 应用属性 @@ -1123,6 +1134,10 @@ 添加页面跳转控件 + + 状态文本 + 添加状态文本控件 + 报警列表 diff --git a/app/src/ui/property_panel_controller.cpp b/app/src/ui/property_panel_controller.cpp index 1bcf7a3..4890758 100644 --- a/app/src/ui/property_panel_controller.cpp +++ b/app/src/ui/property_panel_controller.cpp @@ -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(ui_.targetPageComboBox), static_cast(ui_.buttonOperationComboBox), static_cast(ui_.dataTypeComboBox), + static_cast(ui_.configureStatusTextButton), static_cast(ui_.textColorEdit), static_cast(ui_.textColorButton), static_cast(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(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(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(*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( - ui_.dataTypeComboBox->currentData().toInt()); + if (control.type != HmiControlType::StatusText) + { + control.dataType = static_cast( + 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( diff --git a/app/src/ui/property_panel_controller.h b/app/src/ui/property_panel_controller.h index 0261f21..c25c686 100644 --- a/app/src/ui/property_panel_controller.h +++ b/app/src/ui/property_panel_controller.h @@ -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; diff --git a/app/src/ui/status_text_dialog.cpp b/app/src/ui/status_text_dialog.cpp new file mode 100644 index 0000000..a70baa8 --- /dev/null +++ b/app/src/ui/status_text_dialog.cpp @@ -0,0 +1,214 @@ +#include "status_text_dialog.h" + +#include "domain/project_limits.h" +#include "ui_status_text_dialog.h" + +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace { + +QString fromUtf8(const std::string &value) +{ + return QString::fromUtf8(value.data(), static_cast(value.size())); +} + +std::string toUtf8(const QString &value) +{ + const QByteArray bytes = value.toUtf8(); + return std::string(bytes.constData(), static_cast(bytes.size())); +} + +QString boundaryText(const std::optional &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()), + control_(control) +{ + ui_->setupUi(this); + ui_->sourceComboBox->setItemData(0, 0); + ui_->sourceComboBox->setItemData(1, 1); + ui_->dataTypeComboBox->setItemData(0, static_cast(RegisterDataType::Int16)); + ui_->dataTypeComboBox->setItemData(1, static_cast(RegisterDataType::Int32)); + ui_->dataTypeComboBox->setItemData(2, static_cast(RegisterDataType::Float32)); + ui_->dataTypeComboBox->setItemData(3, static_cast(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::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(control.dataType))); + ui_->offTextEdit->setText(QStringLiteral("设备停止")); + ui_->onTextEdit->setText(QStringLiteral("设备运行")); + if (control.statusText.has_value() + && std::holds_alternative(*control.statusText)) + { + ui_->sourceComboBox->setCurrentIndex(1); + for (const HmiStatusValueRange &range : + std::get(*control.statusText).ranges) + { + appendRangeRow(range); + } + } + else + { + ui_->sourceComboBox->setCurrentIndex(0); + const HmiStatusBitTextConfig bit = control.statusText.has_value() + ? std::get(*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(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 *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( + ui_->dataTypeComboBox->currentData().toInt()); + HmiStatusWordTextConfig config; + config.ranges.reserve( + static_cast(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(); +} diff --git a/app/src/ui/status_text_dialog.h b/app/src/ui/status_text_dialog.h new file mode 100644 index 0000000..ab0e0a2 --- /dev/null +++ b/app/src/ui/status_text_dialog.h @@ -0,0 +1,36 @@ +#pragma once + +#include "domain/hmi_model.h" + +#include + +#include + +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 *value); + + std::unique_ptr ui_; + HmiControl control_; +}; diff --git a/app/src/ui/status_text_dialog.ui b/app/src/ui/status_text_dialog.ui new file mode 100644 index 0000000..9214e7e --- /dev/null +++ b/app/src/ui/status_text_dialog.ui @@ -0,0 +1,74 @@ + + + StatusTextDialog + + 状态文本配置 + 560420 + + + + 数据源 + + + M 位状态 + D 数值区间 + + + 绑定地址 + 4000 + 数值类型 + + + Int16 + Int32 + Float32 + Double (Float64) + + + + + + + + + OFF 显示 + 256 + ON 显示 + 256 + + + + + + + 3 + 0 + 下限(含) + 上限(不含) + 显示文本 + + + + + Qt::Horizontal4020 + 添加区间 + 删除区间 + + + + + + + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + buttonBoxaccepted()StatusTextDialogaccept() + buttonBoxrejected()StatusTextDialogreject() + + diff --git a/app/src/ui/toolbar_icon_factory.cpp b/app/src/ui/toolbar_icon_factory.cpp index 693b3c1..12f7998 100644 --- a/app/src/ui/toolbar_icon_factory.cpp +++ b/app/src/ui/toolbar_icon_factory.cpp @@ -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)); diff --git a/app/src/ui/toolbar_icon_factory.h b/app/src/ui/toolbar_icon_factory.h index 2bbe05f..2b6d119 100644 --- a/app/src/ui/toolbar_icon_factory.h +++ b/app/src/ui/toolbar_icon_factory.h @@ -31,6 +31,7 @@ enum class UiIcon NumericDisplay, // HMI 数值显示 NumericInput, // HMI 数值输入 Text, // HMI 文本 + StatusText, // HMI 状态文本 PageJump, // HMI 页面跳转 AlarmList, // HMI 报警列表 AlarmSettings, // 报警配置 diff --git a/app/tests/domain_tests.cpp b/app/tests/domain_tests.cpp index eccb4e7..2e202c3 100644 --- a/app/tests/domain_tests.cpp +++ b/app/tests/domain_tests.cpp @@ -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 result; + result.reserve(count); + for (std::size_t index = 0; index < count; ++index) + { + result.push_back({ + index == 0U + ? std::optional{} + : std::optional{static_cast(index)}, + index + 1U == count + ? std::optional{} + : std::optional{static_cast(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(*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(); diff --git a/app/tests/hmi_editor_service_tests.cpp b/app/tests/hmi_editor_service_tests.cpp index a48b016..342f1ec 100644 --- a/app/tests/hmi_editor_service_tests.cpp +++ b/app/tests/hmi_editor_service_tests.cpp @@ -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(0), static_cast(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(); diff --git a/app/tests/project_management_tests.cpp b/app/tests/project_management_tests.cpp index cfa3785..b3b2991 100644 --- a/app/tests/project_management_tests.cpp +++ b/app/tests/project_management_tests.cpp @@ -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(*loaded_bit.statusText).onText + == "Running" + && std::get(*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(); diff --git a/app/tests/runtime_mode_service_tests.cpp b/app/tests/runtime_mode_service_tests.cpp index 5e78c4d..d11fff7 100644 --- a/app/tests/runtime_mode_service_tests.cpp +++ b/app/tests/runtime_mode_service_tests.cpp @@ -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({ {{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");