diff --git a/app/integrated_platform.pro b/app/integrated_platform.pro index c960e66..c873520 100644 --- a/app/integrated_platform.pro +++ b/app/integrated_platform.pro @@ -21,6 +21,7 @@ SOURCES += \ src/ui/alarm_configuration_dialog.cpp \ src/ui/register_comment_dialog.cpp \ src/ui/status_text_dialog.cpp \ + src/ui/button_extension_dialog.cpp \ src/ui/plc_connection_dialog.cpp \ src/ui/free_monitor_widget.cpp \ src/ui/runtime_monitor_window.cpp \ @@ -71,6 +72,7 @@ HEADERS += \ src/ui/alarm_configuration_dialog.h \ src/ui/register_comment_dialog.h \ src/ui/status_text_dialog.h \ + src/ui/button_extension_dialog.h \ src/ui/plc_connection_dialog.h \ src/ui/free_monitor_widget.h \ src/ui/runtime_monitor_window.h \ @@ -127,6 +129,7 @@ FORMS += \ src/ui/alarm_configuration_dialog.ui \ src/ui/register_comment_dialog.ui \ src/ui/status_text_dialog.ui \ + src/ui/button_extension_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_model.cpp b/app/src/domain/hmi_model.cpp index 38621ae..959ae19 100644 --- a/app/src/domain/hmi_model.cpp +++ b/app/src/domain/hmi_model.cpp @@ -7,6 +7,7 @@ #include #include #include +#include namespace { @@ -183,6 +184,77 @@ bool validateAppearanceProperty( return true; } +bool validateButtonEnableCondition( + const HmiButtonEnableCondition &condition, std::string *error) +{ + if (const auto *bit = std::get_if(&condition)) + { + if (!bit->address.isValid() || bit->address.area() != RegisterArea::M) + { + setError(error, "按钮 M 启用条件必须使用有效的 M 区地址"); + return false; + } + return true; + } + + const auto *word = std::get_if(&condition); + if (word == nullptr || !registerDataTypeIsSupported(word->dataType)) + { + setError(error, "按钮 D 启用条件的数据类型无效"); + return false; + } + switch (word->operation) + { + case HmiButtonConditionOperator::Equal: + case HmiButtonConditionOperator::NotEqual: + case HmiButtonConditionOperator::LessThan: + case HmiButtonConditionOperator::LessThanOrEqual: + case HmiButtonConditionOperator::GreaterThan: + case HmiButtonConditionOperator::GreaterThanOrEqual: + break; + default: + setError(error, "按钮 D 启用条件的比较方式无效"); + return false; + } + if (!registerDataTypeAddressIsValid(word->dataType, word->address)) + { + setError(error, "按钮 D 启用条件地址超出可用范围"); + return false; + } + if (!std::isfinite(word->value)) + { + setError(error, "按钮 D 启用条件比较值必须是有限数值"); + return false; + } + if (word->dataType == RegisterDataType::Int16) + { + if (std::trunc(word->value) != word->value + || word->value < std::numeric_limits::min() + || word->value > std::numeric_limits::max()) + { + setError(error, "Int16 按钮启用条件比较值必须是 -32768~32767 的整数"); + return false; + } + } + else if (word->dataType == RegisterDataType::Int32) + { + if (std::trunc(word->value) != word->value + || word->value < std::numeric_limits::min() + || word->value > std::numeric_limits::max()) + { + setError(error, "Int32 按钮启用条件比较值必须是有效的 32 位整数"); + return false; + } + } + else if (word->dataType == RegisterDataType::Float32 + && !std::isfinite(static_cast(word->value))) + { + setError(error, "Float32 按钮启用条件比较值超出范围"); + return false; + } + return true; +} + } // namespace bool HmiControl::validate(std::string *error) const @@ -359,6 +431,19 @@ bool HmiControl::validate(std::string *error) const setError(error, "非页面跳转控件不能包含跳转配置"); return false; } + if (type == HmiControlType::Button) + { + if (buttonEnableCondition.has_value() + && !validateButtonEnableCondition(*buttonEnableCondition, error)) + { + return false; + } + } + else if (buttonEnableCondition.has_value()) + { + setError(error, "非按钮控件不能包含按钮启用条件"); + return false; + } return true; } diff --git a/app/src/domain/hmi_model.h b/app/src/domain/hmi_model.h index 7a746a9..e7547dc 100644 --- a/app/src/domain/hmi_model.h +++ b/app/src/domain/hmi_model.h @@ -72,6 +72,48 @@ enum class HmiButtonOperation MomentaryOn // 按下时写入 1,释放时写入 0 }; +/** @brief HMI 按钮 D 数值启用条件的比较方式 */ +enum class HmiButtonConditionOperator +{ + Equal, + NotEqual, + LessThan, + LessThanOrEqual, + GreaterThan, + GreaterThanOrEqual +}; + +struct HmiButtonBitEnableCondition +{ + RegisterAddress address{RegisterArea::M, 0}; + bool expected = false; + + bool operator==(const HmiButtonBitEnableCondition &other) const + { + return address == other.address && expected == other.expected; + } +}; + +struct HmiButtonWordEnableCondition +{ + RegisterAddress address{RegisterArea::D, 0}; + RegisterDataType dataType = RegisterDataType::Int16; + HmiButtonConditionOperator operation = HmiButtonConditionOperator::Equal; + double value = 0.0; + + bool operator==(const HmiButtonWordEnableCondition &other) const + { + return address == other.address + && dataType == other.dataType + && operation == other.operation + && value == other.value; + } +}; + +using HmiButtonEnableCondition = std::variant< + HmiButtonBitEnableCondition, + HmiButtonWordEnableCondition>; + struct HmiPageJumpConfig { std::string targetPageId; @@ -131,6 +173,7 @@ struct HmiControl RegisterDataType dataType = RegisterDataType::Int16; std::map properties; HmiButtonOperation buttonOperation = HmiButtonOperation::MomentaryOn; + std::optional buttonEnableCondition; std::optional pageJump; std::optional statusText; diff --git a/app/src/domain/project_model.h b/app/src/domain/project_model.h index 70edfb6..04f3f67 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 = "3.0"; + std::string formatVersion = "4.0"; }; // 聚合工程中的 HMI 页面、报警、寄存器注释和控制逻辑 diff --git a/app/src/infrastructure/json_project_storage.cpp b/app/src/infrastructure/json_project_storage.cpp index 49fdfad..b45b87d 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 = "3.0"; +constexpr const char *kCurrentFormatVersion = "4.0"; // 保存解析过程中遇到的第一个错误,避免后续失败覆盖根因 struct ParseState @@ -426,6 +426,156 @@ bool parseHmiButtonOperation( return true; } +QString hmiButtonConditionOperatorName(HmiButtonConditionOperator operation) +{ + switch (operation) + { + case HmiButtonConditionOperator::Equal: + return QStringLiteral("equal"); + case HmiButtonConditionOperator::NotEqual: + return QStringLiteral("notEqual"); + case HmiButtonConditionOperator::LessThan: + return QStringLiteral("lessThan"); + case HmiButtonConditionOperator::LessThanOrEqual: + return QStringLiteral("lessThanOrEqual"); + case HmiButtonConditionOperator::GreaterThan: + return QStringLiteral("greaterThan"); + case HmiButtonConditionOperator::GreaterThanOrEqual: + return QStringLiteral("greaterThanOrEqual"); + default: + return {}; + } +} + +bool parseHmiButtonConditionOperator( + const std::string &value, + HmiButtonConditionOperator *operation, + ParseState *state) +{ + if (value == "equal") + { + *operation = HmiButtonConditionOperator::Equal; + } + else if (value == "notEqual") + { + *operation = HmiButtonConditionOperator::NotEqual; + } + else if (value == "lessThan") + { + *operation = HmiButtonConditionOperator::LessThan; + } + else if (value == "lessThanOrEqual") + { + *operation = HmiButtonConditionOperator::LessThanOrEqual; + } + else if (value == "greaterThan") + { + *operation = HmiButtonConditionOperator::GreaterThan; + } + else if (value == "greaterThanOrEqual") + { + *operation = HmiButtonConditionOperator::GreaterThanOrEqual; + } + else + { + return state->fail( + ProjectStorageError::InvalidField, + "不支持的按钮 D 启用条件比较方式:" + value); + } + return true; +} + +QJsonObject serializeHmiButtonEnableCondition( + const HmiButtonEnableCondition &condition) +{ + QJsonObject object; + if (const auto *bit = std::get_if(&condition)) + { + object.insert(QStringLiteral("type"), QStringLiteral("mBit")); + object.insert(QStringLiteral("address"), serializeAddress(bit->address)); + object.insert(QStringLiteral("expected"), bit->expected); + return object; + } + + const auto &word = std::get(condition); + object.insert(QStringLiteral("type"), QStringLiteral("dValue")); + object.insert(QStringLiteral("address"), serializeAddress(word.address)); + object.insert( + QStringLiteral("dataType"), + QString::fromLatin1(registerDataTypeName(word.dataType))); + object.insert( + QStringLiteral("operation"), + hmiButtonConditionOperatorName(word.operation)); + object.insert(QStringLiteral("value"), word.value); + return object; +} + +bool parseHmiButtonEnableCondition( + const QJsonObject &object, + const std::string &context, + HmiButtonEnableCondition *condition, + ParseState *state) +{ + std::string type; + QJsonObject address_object; + if (!readString(object, "type", context, &type, state) + || !readObject(object, "address", context, &address_object, state)) + { + return false; + } + RegisterAddress address{RegisterArea::M, 0}; + if (!parseAddress(address_object, context + ".address", &address, state)) + { + return false; + } + if (type == "mBit") + { + bool expected = false; + if (!readBool(object, "expected", context, &expected, state)) + { + return false; + } + *condition = HmiButtonBitEnableCondition{address, expected}; + return true; + } + if (type != "dValue") + { + return state->fail( + ProjectStorageError::InvalidField, + context + ".type 必须是 mBit 或 dValue"); + } + std::string data_type_text; + std::string operation_text; + QJsonValue value_json; + if (!readString(object, "dataType", context, &data_type_text, state) + || !readString(object, "operation", context, &operation_text, state) + || !readValue(object, "value", context, &value_json, state)) + { + return false; + } + RegisterDataType data_type = RegisterDataType::Int16; + if (!parseRegisterDataType(data_type_text, &data_type)) + { + return state->fail( + ProjectStorageError::InvalidField, + context + ".dataType 必须是 int16、int32、float32 或 float64"); + } + if (!value_json.isDouble() || !std::isfinite(value_json.toDouble())) + { + return state->fail( + ProjectStorageError::InvalidField, + context + ".value 必须是有限数值"); + } + HmiButtonConditionOperator operation = HmiButtonConditionOperator::Equal; + if (!parseHmiButtonConditionOperator(operation_text, &operation, state)) + { + return false; + } + *condition = HmiButtonWordEnableCondition{ + address, data_type, operation, value_json.toDouble()}; + return true; +} + QString alarmConditionName(AlarmCondition condition) { switch (condition) @@ -810,6 +960,11 @@ QJsonObject serializeHmiControl(const HmiControl &control) object.insert( QStringLiteral("buttonOperation"), hmiButtonOperationName(control.buttonOperation)); + object.insert( + QStringLiteral("buttonEnableCondition"), + control.buttonEnableCondition.has_value() + ? serializeHmiButtonEnableCondition(*control.buttonEnableCondition) + : QJsonValue(QJsonValue::Null)); } if (control.type == HmiControlType::PageJump) { @@ -889,6 +1044,7 @@ bool parseHmiControl( if (control->type == HmiControlType::Button) { std::string operation_text; + QJsonValue enable_condition_value; if (!readString( object, "buttonOperation", @@ -900,6 +1056,38 @@ bool parseHmiControl( { return false; } + if (!readValue( + object, + "buttonEnableCondition", + context, + &enable_condition_value, + state)) + { + return false; + } + if (enable_condition_value.isNull()) + { + control->buttonEnableCondition.reset(); + } + else if (!enable_condition_value.isObject()) + { + return state->fail( + ProjectStorageError::InvalidField, + context + ".buttonEnableCondition 必须是对象或 null"); + } + else + { + HmiButtonEnableCondition condition; + if (!parseHmiButtonEnableCondition( + enable_condition_value.toObject(), + context + ".buttonEnableCondition", + &condition, + state)) + { + return false; + } + control->buttonEnableCondition = std::move(condition); + } } if (control->type == HmiControlType::PageJump) { diff --git a/app/src/infrastructure/json_project_storage.h b/app/src/infrastructure/json_project_storage.h index b6d9c64..3339f17 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" -// 严格读写当前 3.0 JSON 工程格式 +// 严格读写当前 4.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 197ba20..67b2a04 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.buttonEnableCondition == right.buttonEnableCondition && left.statusText == right.statusText && page_jump_equal; } diff --git a/app/src/services/hmi_runtime_service.cpp b/app/src/services/hmi_runtime_service.cpp index bdb4798..bddf976 100644 --- a/app/src/services/hmi_runtime_service.cpp +++ b/app/src/services/hmi_runtime_service.cpp @@ -29,6 +29,28 @@ double numericValueAsDouble(const RegisterNumericValue &value) value); } +bool compareButtonValue( + double actual, HmiButtonConditionOperator operation, double expected) +{ + switch (operation) + { + case HmiButtonConditionOperator::Equal: + return actual == expected; + case HmiButtonConditionOperator::NotEqual: + return actual != expected; + case HmiButtonConditionOperator::LessThan: + return actual < expected; + case HmiButtonConditionOperator::LessThanOrEqual: + return actual <= expected; + case HmiButtonConditionOperator::GreaterThan: + return actual > expected; + case HmiButtonConditionOperator::GreaterThanOrEqual: + return actual >= expected; + default: + return false; + } +} + } // namespace HmiRuntimeService::HmiRuntimeService(RegisterRepository &repository) @@ -177,6 +199,74 @@ HmiStatusTextReadResult HmiRuntimeService::readStatusText( true, HmiRuntimeError::None, match->text}; } +HmiButtonEnabledResult HmiRuntimeService::evaluateButtonEnabled( + const HmiControl &control) const +{ + if (control.type != HmiControlType::Button) + { + return {false, false, HmiRuntimeError::UnsupportedControl}; + } + if (!control.buttonEnableCondition.has_value()) + { + return {true, true, HmiRuntimeError::None}; + } + if (const auto *bit = std::get_if( + &*control.buttonEnableCondition)) + { + if (!bit->address.isValid() || bit->address.area() != RegisterArea::M) + { + return {false, false, HmiRuntimeError::ConditionUnavailable}; + } + const BitReadResult result = repository_.readBit(bit->address); + if (!result.succeeded) + { + return {false, false, HmiRuntimeError::ConditionUnavailable}; + } + return {true, + result.value == bit->expected, + result.value == bit->expected + ? HmiRuntimeError::None : HmiRuntimeError::ConditionNotMet}; + } + + const auto *word = std::get_if( + &*control.buttonEnableCondition); + if (word == nullptr + || !registerDataTypeAddressIsValid(word->dataType, word->address) + || !std::isfinite(word->value)) + { + return {false, false, HmiRuntimeError::ConditionUnavailable}; + } + switch (word->operation) + { + case HmiButtonConditionOperator::Equal: + case HmiButtonConditionOperator::NotEqual: + case HmiButtonConditionOperator::LessThan: + case HmiButtonConditionOperator::LessThanOrEqual: + case HmiButtonConditionOperator::GreaterThan: + case HmiButtonConditionOperator::GreaterThanOrEqual: + break; + default: + return {false, false, HmiRuntimeError::ConditionUnavailable}; + } + const WordsReadResult words = repository_.readWords( + word->address, registerDataTypeWordCount(word->dataType)); + if (!words.succeeded) + { + return {false, false, HmiRuntimeError::ConditionUnavailable}; + } + const std::optional value = + decodeRegisterNumericValue(word->dataType, words.values); + if (!value.has_value()) + { + return {false, false, HmiRuntimeError::ConditionUnavailable}; + } + const bool enabled = compareButtonValue( + numericValueAsDouble(*value), word->operation, word->value); + return {true, + enabled, + enabled ? HmiRuntimeError::None : HmiRuntimeError::ConditionNotMet}; +} + HmiRuntimeWriteResult HmiRuntimeService::operateButton( const HmiControl &control, HmiButtonEvent event) { @@ -193,6 +283,19 @@ HmiRuntimeWriteResult HmiRuntimeService::operateButton( return writeFailure(HmiRuntimeError::InvalidBinding); } + if (event == HmiButtonEvent::Pressed) + { + const HmiButtonEnabledResult enabled = evaluateButtonEnabled(control); + if (!enabled.succeeded) + { + return writeFailure(enabled.error); + } + if (!enabled.enabled) + { + return writeFailure(HmiRuntimeError::ConditionNotMet); + } + } + bool value = false; switch (control.buttonOperation) { diff --git a/app/src/services/hmi_runtime_service.h b/app/src/services/hmi_runtime_service.h index 8a01f49..f3f499b 100644 --- a/app/src/services/hmi_runtime_service.h +++ b/app/src/services/hmi_runtime_service.h @@ -21,7 +21,9 @@ enum class HmiRuntimeError UnsupportedControl, // 控件类型不支持寄存器读写或写入操作 MissingBinding, // 控件未配置寄存器绑定 InvalidBinding, // 控件绑定地址无效或地址区域不匹配 - RepositoryFailure // 寄存器仓库不可用或拒绝读写 + RepositoryFailure, // 寄存器仓库不可用或拒绝读写 + ConditionNotMet, // 按钮启用条件不满足 + ConditionUnavailable // 按钮启用条件无法读取或配置无效 }; /** @@ -46,6 +48,13 @@ struct HmiRuntimeWriteResult HmiRuntimeError error = HmiRuntimeError::None; // 失败时的 HMI 错误分类 }; +struct HmiButtonEnabledResult +{ + bool succeeded = false; + bool enabled = false; + HmiRuntimeError error = HmiRuntimeError::ConditionUnavailable; +}; + struct HmiStatusTextReadResult { bool succeeded = false; @@ -86,6 +95,9 @@ public: */ HmiRuntimeReadResult readControl(const HmiControl &control) const; HmiStatusTextReadResult readStatusText(const HmiControl &control) const; + /** @brief 读取并评估按钮启用条件;无条件按钮直接返回可用 */ + HmiButtonEnabledResult evaluateButtonEnabled( + 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 efd7b0f..561dc1a 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 = "3.0"; + project.metadata.formatVersion = "4.0"; return project; } diff --git a/app/src/services/runtime_mode_service.cpp b/app/src/services/runtime_mode_service.cpp index f6f1a8b..714a510 100644 --- a/app/src/services/runtime_mode_service.cpp +++ b/app/src/services/runtime_mode_service.cpp @@ -339,6 +339,33 @@ PlcCommunicationResult RuntimeModeService::refreshPlcPollAddresses() } } } + if (control.type == HmiControlType::Button + && control.buttonEnableCondition.has_value()) + { + if (const auto *bit = std::get_if< + HmiButtonBitEnableCondition>( + &*control.buttonEnableCondition)) + { + addresses.push_back(bit->address); + } + else if (const auto *word = std::get_if< + HmiButtonWordEnableCondition>( + &*control.buttonEnableCondition)) + { + addresses.push_back(word->address); + const int word_count = registerDataTypeWordCount( + word->dataType); + for (int offset = 1; offset < word_count; ++offset) + { + addresses.push_back(RegisterAddress{ + RegisterArea::D, word->address.index() + offset}); + } + if (word_count > 1) + { + multi_word_ranges.push_back({word->address, word_count}); + } + } + } } } for (const AlarmDefinition &definition : project.alarmDefinitions) diff --git a/app/src/ui/button_extension_dialog.cpp b/app/src/ui/button_extension_dialog.cpp new file mode 100644 index 0000000..61ed8b7 --- /dev/null +++ b/app/src/ui/button_extension_dialog.cpp @@ -0,0 +1,124 @@ +#include "button_extension_dialog.h" + +#include "domain/project_limits.h" +#include "ui_button_extension_dialog.h" + +#include +#include +#include +#include + +namespace { + +QString fromUtf8(const std::string &value) +{ + return QString::fromUtf8(value.data(), static_cast(value.size())); +} + +} // namespace + +ButtonExtensionDialog::ButtonExtensionDialog( + const HmiControl &control, QWidget *parent) + : QDialog(parent), + ui_(std::make_unique()), + control_(control) +{ + ui_->setupUi(this); + ui_->conditionTypeComboBox->setItemData(0, 0); + ui_->conditionTypeComboBox->setItemData(1, 1); + ui_->conditionTypeComboBox->setItemData(2, 2); + 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_->operatorComboBox->setItemData(0, static_cast(HmiButtonConditionOperator::Equal)); + ui_->operatorComboBox->setItemData(1, static_cast(HmiButtonConditionOperator::NotEqual)); + ui_->operatorComboBox->setItemData(2, static_cast(HmiButtonConditionOperator::LessThan)); + ui_->operatorComboBox->setItemData(3, static_cast(HmiButtonConditionOperator::LessThanOrEqual)); + ui_->operatorComboBox->setItemData(4, static_cast(HmiButtonConditionOperator::GreaterThan)); + ui_->operatorComboBox->setItemData(5, static_cast(HmiButtonConditionOperator::GreaterThanOrEqual)); + ui_->addressSpinBox->setRange(0, RegisterAddress::kMaximumIndex); + ui_->dAddressSpinBox->setRange(0, RegisterAddress::kMaximumIndex); + + if (control.buttonEnableCondition.has_value()) + { + if (const auto *bit = std::get_if( + &*control.buttonEnableCondition)) + { + ui_->conditionTypeComboBox->setCurrentIndex(1); + ui_->addressSpinBox->setValue(bit->address.index()); + ui_->expectedComboBox->setCurrentIndex(bit->expected ? 1 : 0); + } + else if (const auto *word = std::get_if( + &*control.buttonEnableCondition)) + { + ui_->conditionTypeComboBox->setCurrentIndex(2); + ui_->dAddressSpinBox->setValue(word->address.index()); + ui_->dataTypeComboBox->setCurrentIndex( + ui_->dataTypeComboBox->findData(static_cast(word->dataType))); + ui_->operatorComboBox->setCurrentIndex( + ui_->operatorComboBox->findData(static_cast(word->operation))); + ui_->valueEdit->setText(QLocale::c().toString(word->value, 'g', 17)); + } + } + connect(ui_->conditionTypeComboBox, + QOverload::of(&QComboBox::currentIndexChanged), + this, &ButtonExtensionDialog::updateConditionPage); + updateConditionPage(); +} + +ButtonExtensionDialog::~ButtonExtensionDialog() = default; + +const HmiControl &ButtonExtensionDialog::control() const +{ + return control_; +} + +void ButtonExtensionDialog::updateConditionPage() +{ + const int type = ui_->conditionTypeComboBox->currentData().toInt(); + ui_->configurationStack->setCurrentIndex(type == 2 ? 1 : 0); + ui_->bitPage->setEnabled(type == 1); + ui_->wordPage->setEnabled(type == 2); +} + +void ButtonExtensionDialog::accept() +{ + const int type = ui_->conditionTypeComboBox->currentData().toInt(); + if (type == 0) + { + control_.buttonEnableCondition.reset(); + } + else if (type == 1) + { + control_.buttonEnableCondition = HmiButtonBitEnableCondition{ + RegisterAddress{RegisterArea::M, ui_->addressSpinBox->value()}, + ui_->expectedComboBox->currentData().toInt() != 0}; + } + else + { + bool converted = false; + const double value = QLocale::c().toDouble( + ui_->valueEdit->text().trimmed(), &converted); + if (!converted) + { + QMessageBox::warning(this, tr("按钮扩展配置"), + tr("D 条件比较值必须是有限数值")); + return; + } + control_.buttonEnableCondition = HmiButtonWordEnableCondition{ + RegisterAddress{RegisterArea::D, ui_->dAddressSpinBox->value()}, + static_cast(ui_->dataTypeComboBox->currentData().toInt()), + static_cast( + ui_->operatorComboBox->currentData().toInt()), + value}; + } + + std::string error; + if (!control_.validate(&error)) + { + QMessageBox::warning(this, tr("按钮扩展配置"), fromUtf8(error)); + return; + } + QDialog::accept(); +} diff --git a/app/src/ui/button_extension_dialog.h b/app/src/ui/button_extension_dialog.h new file mode 100644 index 0000000..ab9844c --- /dev/null +++ b/app/src/ui/button_extension_dialog.h @@ -0,0 +1,30 @@ +#pragma once + +#include "domain/hmi_model.h" + +#include + +#include + +namespace Ui { +class ButtonExtensionDialog; +} + +class ButtonExtensionDialog final : public QDialog +{ + Q_OBJECT + +public: + explicit ButtonExtensionDialog(const HmiControl &control, QWidget *parent = nullptr); + ~ButtonExtensionDialog() override; + + const HmiControl &control() const; + +private slots: + void updateConditionPage(); + void accept() override; + +private: + std::unique_ptr ui_; + HmiControl control_; +}; diff --git a/app/src/ui/button_extension_dialog.ui b/app/src/ui/button_extension_dialog.ui new file mode 100644 index 0000000..5c94f19 --- /dev/null +++ b/app/src/ui/button_extension_dialog.ui @@ -0,0 +1,36 @@ + + + ButtonExtensionDialog + + 按钮扩展配置 + 460260 + + + + 启用条件 + 未设置M 位条件D 数值条件 + + + + + M 地址 + 4000 + 要求状态 + OFFfalseONtrue + + + D 地址 + 4000 + 数值类型 + Int16Int32Float32Double (Float64) + 比较方式 + 等于不等于小于小于等于大于大于等于 + 比较值 + 请输入数值 + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + buttonBoxaccepted()ButtonExtensionDialogaccept()buttonBoxrejected()ButtonExtensionDialogreject() + diff --git a/app/src/ui/hmi_editor_widget.cpp b/app/src/ui/hmi_editor_widget.cpp index d1adc47..982f126 100644 --- a/app/src/ui/hmi_editor_widget.cpp +++ b/app/src/ui/hmi_editor_widget.cpp @@ -199,7 +199,7 @@ public: case HmiControlType::Button: { const bool disabled = !editing_enabled_ && runtime_active_ - && !runtime_write_enabled_; + && (!runtime_write_enabled_ || !button_condition_enabled_); const QColor fill = disabled ? QColor(QStringLiteral("#e2e6e4")) : button_pressed_ ? QColor(QStringLiteral("#a9cbb6")) @@ -454,6 +454,7 @@ public: void setInteractionState( bool editable, bool runtime_active, bool runtime_write_enabled) { + const bool runtime_was_active = runtime_active_; if (control_.type == HmiControlType::AlarmList && !runtime_active) { alarm_page_ = 0U; @@ -461,6 +462,14 @@ public: editing_enabled_ = editable; runtime_active_ = runtime_active; runtime_write_enabled_ = runtime_active && runtime_write_enabled; + if (runtime_active_ && !runtime_was_active) + { + button_condition_enabled_ = false; + } + else if (!runtime_active_) + { + button_condition_enabled_ = false; + } setFlag(ItemIsMovable, editable); setFlag(ItemIsSelectable, editable); if (!editable) @@ -473,15 +482,20 @@ public: || control_.type == HmiControlType::NumericInput || control_.type == HmiControlType::PageJump || control_.type == HmiControlType::AlarmList); + const bool runtime_write_input_enabled = control_.type + == HmiControlType::Button + ? runtime_write_enabled_ && button_condition_enabled_ + : runtime_write_enabled_; const bool runtime_input_enabled = runtime_input && (control_.type == HmiControlType::PageJump || control_.type == HmiControlType::AlarmList - || runtime_write_enabled_); + || runtime_write_input_enabled); setAcceptedMouseButtons( editable || runtime_input_enabled ? Qt::LeftButton : Qt::NoButton); const bool runtime_button_enabled = runtime_active_ - && control_.type == HmiControlType::Button && runtime_write_enabled_; + && control_.type == HmiControlType::Button && runtime_write_enabled_ + && button_condition_enabled_; const bool runtime_page_jump_enabled = runtime_active_ && control_.type == HmiControlType::PageJump; const bool runtime_alarm_enabled = runtime_active_ @@ -497,7 +511,10 @@ public: unsetCursor(); button_hovered_ = false; page_hovered_ = false; - button_pressed_ = false; + if (!runtime_active_ || !runtime_write_enabled_) + { + button_pressed_ = false; + } } update(); } @@ -512,6 +529,13 @@ public: update(); } + void setButtonConditionEnabled(bool enabled) + { + button_condition_enabled_ = enabled; + setInteractionState( + editing_enabled_, runtime_active_, runtime_write_enabled_); + } + void setStatusText(const std::string &text, bool available) { status_text_ = QString::fromUtf8( @@ -606,6 +630,7 @@ protected: return; } if (runtime_active_ && runtime_write_enabled_ + && button_condition_enabled_ && control_.type == HmiControlType::Button && button_event_) { button_pressed_ = true; @@ -887,6 +912,7 @@ private: bool editing_enabled_ = true; bool runtime_active_ = false; bool runtime_write_enabled_ = false; + bool button_condition_enabled_ = true; bool button_hovered_ = false; bool button_pressed_ = false; bool page_hovered_ = false; @@ -1135,6 +1161,13 @@ void HmiEditorWidget::refreshRuntimeValues() control_item->setAlarmRecords(alarm_service_.records()); continue; } + if (control->type == HmiControlType::Button) + { + const HmiButtonEnabledResult enabled = + runtime_service_.evaluateButtonEnabled(*control); + control_item->setButtonConditionEnabled( + enabled.succeeded && enabled.enabled); + } if (control->type == HmiControlType::StatusText) { const HmiStatusTextReadResult value = @@ -1229,6 +1262,12 @@ void HmiEditorWidget::handleButtonEvent( const HmiRuntimeWriteResult result = runtime_service_.operateButton(*control, event); if (!result.succeeded) { + if (result.error == HmiRuntimeError::ConditionNotMet + || result.error == HmiRuntimeError::ConditionUnavailable) + { + refreshRuntimeValues(); + return; + } emit editorError(tr("寄存器操作失败")); return; } diff --git a/app/src/ui/main_window.ui b/app/src/ui/main_window.ui index 05bd0d0..28e4cae 100644 --- a/app/src/ui/main_window.ui +++ b/app/src/ui/main_window.ui @@ -706,13 +706,23 @@ + + 启用条件 + + + + + 未设置 + + + 字体颜色 - + 0 @@ -736,24 +746,24 @@ - + 字号 - + - + 字体样式 - + 0 @@ -773,12 +783,12 @@ - + 数值类型 - + Int16 Int32 @@ -786,17 +796,17 @@ Double (Float64) - + 状态映射 - + 配置 - + 应用属性 diff --git a/app/src/ui/property_panel_controller.cpp b/app/src/ui/property_panel_controller.cpp index 4890758..1e6c3bc 100644 --- a/app/src/ui/property_panel_controller.cpp +++ b/app/src/ui/property_panel_controller.cpp @@ -6,6 +6,7 @@ #include "logic_editor_widget.h" #include "logic_instruction_dialog.h" #include "status_text_dialog.h" +#include "button_extension_dialog.h" #include "services/hmi_editor_service.h" #include "services/logic_editor_service.h" #include "services/project_service.h" @@ -131,6 +132,8 @@ void PropertyPanelController::configure() &parent_, [this] { chooseTextColor(); }); QObject::connect(ui_.configureStatusTextButton, &QPushButton::clicked, &parent_, [this] { configureStatusText(); }); + QObject::connect(ui_.configureButtonExtensionButton, &QPushButton::clicked, + &parent_, [this] { configureButtonExtension(); }); ui_.targetPageLabel->setVisible(false); ui_.targetPageComboBox->setVisible(false); showControlProperties({}); @@ -206,6 +209,7 @@ void PropertyPanelController::showControlProperties(const std::string &control_i static_cast(ui_.bindingIndexSpinBox), static_cast(ui_.targetPageComboBox), static_cast(ui_.buttonOperationComboBox), + static_cast(ui_.configureButtonExtensionButton), static_cast(ui_.dataTypeComboBox), static_cast(ui_.configureStatusTextButton), static_cast(ui_.textColorEdit), @@ -230,6 +234,8 @@ void PropertyPanelController::showControlProperties(const std::string &control_i ui_.targetPageComboBox->setVisible(false); ui_.buttonOperationLabel->setVisible(false); ui_.buttonOperationComboBox->setVisible(false); + ui_.buttonEnableConditionLabel->setVisible(false); + ui_.configureButtonExtensionButton->setVisible(false); ui_.dataTypeLabel->setVisible(false); ui_.dataTypeComboBox->setVisible(false); ui_.statusTextConfigLabel->setVisible(false); @@ -276,6 +282,49 @@ void PropertyPanelController::showControlProperties(const std::string &control_i ui_.buttonOperationComboBox->setCurrentIndex( ui_.buttonOperationComboBox->findData( static_cast(control->buttonOperation))); + ui_.buttonEnableConditionLabel->setVisible(is_button); + ui_.configureButtonExtensionButton->setVisible(is_button); + ui_.configureButtonExtensionButton->setEnabled(is_button); + if (is_button) + { + if (!control->buttonEnableCondition.has_value()) + { + ui_.configureButtonExtensionButton->setText(QObject::tr("未设置")); + } + else if (std::holds_alternative( + *control->buttonEnableCondition)) + { + const auto &condition = std::get( + *control->buttonEnableCondition); + ui_.configureButtonExtensionButton->setText( + QObject::tr("M%1 = %2") + .arg(condition.address.index()) + .arg(condition.expected ? QObject::tr("ON") : QObject::tr("OFF"))); + } + else + { + const auto &condition = std::get( + *control->buttonEnableCondition); + const QString operation = [&condition] + { + switch (condition.operation) + { + case HmiButtonConditionOperator::Equal: return QObject::tr("="); + case HmiButtonConditionOperator::NotEqual: return QObject::tr("!="); + case HmiButtonConditionOperator::LessThan: return QObject::tr("<"); + case HmiButtonConditionOperator::LessThanOrEqual: return QObject::tr("<="); + case HmiButtonConditionOperator::GreaterThan: return QObject::tr(">"); + case HmiButtonConditionOperator::GreaterThanOrEqual: return QObject::tr(">="); + default: return QObject::tr("?"); + } + }(); + ui_.configureButtonExtensionButton->setText( + QObject::tr("D%1 %2 %3") + .arg(condition.address.index()) + .arg(operation) + .arg(QString::number(condition.value, 'g', 8))); + } + } const bool is_numeric = control->type == HmiControlType::NumericDisplay || control->type == HmiControlType::NumericInput; @@ -700,6 +749,35 @@ void PropertyPanelController::configureStatusText() status_reporter_(QObject::tr("状态文本配置已更新"), 3000); } +void PropertyPanelController::configureButtonExtension() +{ + const HmiControl *old_control = hmi_editor_service_.findControl( + current_page_id_(), selected_control_id_); + if (old_control == nullptr || old_control->type != HmiControlType::Button) + { + return; + } + ButtonExtensionDialog 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; + } + const std::string updated_control_id = result.id; + hmi_editor_widget_->reloadPage(); + selected_control_id_ = updated_control_id; + hmi_editor_widget_->selectControl(updated_control_id); + showControlProperties(updated_control_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 c25c686..5e0e9ba 100644 --- a/app/src/ui/property_panel_controller.h +++ b/app/src/ui/property_panel_controller.h @@ -91,6 +91,8 @@ private: void chooseTextColor(); /** 打开状态文本专用配置并原子更新控件 */ void configureStatusText(); + /** 打开按钮启用条件专用配置并原子更新控件 */ + void configureButtonExtension(); /** 统一报告属性操作失败 */ void reportFailure( const QString &action, const std::string &message) const; diff --git a/app/tests/domain_tests.cpp b/app/tests/domain_tests.cpp index 2e202c3..78d0c4f 100644 --- a/app/tests/domain_tests.cpp +++ b/app/tests/domain_tests.cpp @@ -502,6 +502,61 @@ void testHmiAppearancePropertyBoundaries() require(project.validate(defaultProjectLimitSettings()), "unknown HMI extension properties must remain supported"); } +void testButtonEnableConditionBoundaries() +{ + Project project = makeValidProject(); + HmiControl &button = project.hmiPages.front().controls.front(); + button.buttonEnableCondition = HmiButtonBitEnableCondition{ + RegisterAddress{RegisterArea::M, 5}, false}; + require(project.validate(defaultProjectLimitSettings()), + "valid M button enable conditions must pass validation"); + + button.buttonEnableCondition = HmiButtonWordEnableCondition{ + RegisterAddress{RegisterArea::D, 20}, + RegisterDataType::Float64, + HmiButtonConditionOperator::LessThan, + 12.5}; + require(project.validate(defaultProjectLimitSettings()), + "valid D button enable conditions must pass validation"); + + button.buttonEnableCondition = HmiButtonBitEnableCondition{ + RegisterAddress{RegisterArea::D, 5}, true}; + require(!project.validate(defaultProjectLimitSettings()), + "M button conditions must reject D addresses"); + + project = makeValidProject(); + HmiControl &d_button = project.hmiPages.front().controls.front(); + d_button.buttonEnableCondition = HmiButtonWordEnableCondition{ + RegisterAddress{RegisterArea::D, 20}, + RegisterDataType::Int16, + HmiButtonConditionOperator::Equal, + 1.5}; + require(!project.validate(defaultProjectLimitSettings()), + "Int16 button conditions must reject fractional comparison values"); + + d_button.buttonEnableCondition = HmiButtonWordEnableCondition{ + RegisterAddress{RegisterArea::D, 3997}, + RegisterDataType::Float64, + HmiButtonConditionOperator::Equal, + 1.0}; + require(!project.validate(defaultProjectLimitSettings()), + "Float64 button conditions must reject overflowing start addresses"); + + d_button.buttonEnableCondition = HmiButtonWordEnableCondition{ + RegisterAddress{RegisterArea::D, 20}, + RegisterDataType::Int16, + static_cast(99), + 1.0}; + require(!project.validate(defaultProjectLimitSettings()), + "button conditions must reject unknown comparison operators"); + + HmiControl label = d_button; + label.type = HmiControlType::Label; + label.binding.reset(); + require(!label.validate(), + "non-button controls must reject button enable conditions"); +} + Project makeValidProject() { // 构造包含 HMI 绑定和完整梯形图网络的最小合法工程作为测试基线 @@ -547,7 +602,7 @@ Project makeValidProject() logic.rungs.push_back(rung); Project project; - project.metadata = {"sample-project", "Sample project", "3.0"}; + project.metadata = {"sample-project", "Sample project", "4.0"}; project.hmiPages.push_back(page); project.initialHmiPageId = page.id; project.controlLogics.push_back(logic); @@ -1076,6 +1131,7 @@ int main() testStatusTextDomainRules(); testMultiWordHmiBoundaries(); testHmiAppearancePropertyBoundaries(); + testButtonEnableConditionBoundaries(); testLogicNodeConfigurationBoundaries(); testEdgeAndCommentBoundaries(); testDataInstructionBoundaries(); diff --git a/app/tests/hmi_editor_service_tests.cpp b/app/tests/hmi_editor_service_tests.cpp index c3baada..f1a7a92 100644 --- a/app/tests/hmi_editor_service_tests.cpp +++ b/app/tests/hmi_editor_service_tests.cpp @@ -235,6 +235,45 @@ void testRuntimeUsesRegisterRepository() require(repository.readBit(*button.binding).value, "toggle button press must invert the current value"); + button.buttonOperation = HmiButtonOperation::MomentaryOn; + button.buttonEnableCondition = HmiButtonBitEnableCondition{ + RegisterAddress{RegisterArea::M, 13}, true}; + repository.writeBit({RegisterArea::M, 13}, false); + HmiButtonEnabledResult condition = runtime_service.evaluateButtonEnabled(button); + require(condition.succeeded && !condition.enabled + && condition.error == HmiRuntimeError::ConditionNotMet, + "an M button condition must disable the button when it is false"); + require(runtime_service.operateButton(button, HmiButtonEvent::Pressed).error + == HmiRuntimeError::ConditionNotMet, + "a false M button condition must reject the write at the service boundary"); + repository.writeBit({RegisterArea::M, 13}, true); + condition = runtime_service.evaluateButtonEnabled(button); + require(condition.succeeded && condition.enabled, + "an M button condition must enable the button when it is true"); + require(runtime_service.operateButton(button, HmiButtonEvent::Pressed).succeeded + && repository.readBit(*button.binding).value, + "a satisfied M button condition must allow the configured operation"); + repository.writeBit({RegisterArea::M, 13}, false); + require(runtime_service.operateButton(button, HmiButtonEvent::Released).succeeded + && !repository.readBit(*button.binding).value, + "a momentary button release must reset safely after its condition changes"); + repository.writeBit({RegisterArea::M, 12}, true); + + HmiControl d_button = button; + d_button.buttonEnableCondition = HmiButtonWordEnableCondition{ + RegisterAddress{RegisterArea::D, 100}, + RegisterDataType::Int16, + HmiButtonConditionOperator::GreaterThanOrEqual, + 50.0}; + repository.writeWord({RegisterArea::D, 100}, 50); + condition = runtime_service.evaluateButtonEnabled(d_button); + require(condition.succeeded && condition.enabled, + "a satisfied D button condition must enable the button"); + repository.writeWord({RegisterArea::D, 100}, 49); + condition = runtime_service.evaluateButtonEnabled(d_button); + require(condition.succeeded && !condition.enabled, + "a D button condition must compare the current repository value"); + HmiControl indicator; indicator.id = "running"; indicator.type = HmiControlType::Indicator; diff --git a/app/tests/project_management_tests.cpp b/app/tests/project_management_tests.cpp index b3b2991..0f41d77 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", "3.0"}; + project.metadata = {"example-project", "Example project", "4.0"}; HmiPage page; page.id = "main-page"; @@ -87,6 +87,11 @@ Project makeExampleProject() button.text = "Start"; button.binding = RegisterAddress{RegisterArea::M, 0}; button.buttonOperation = HmiButtonOperation::SetOn; + button.buttonEnableCondition = HmiButtonWordEnableCondition{ + RegisterAddress{RegisterArea::D, 100}, + RegisterDataType::Int16, + HmiButtonConditionOperator::GreaterThanOrEqual, + 10.0}; page.controls.push_back(button); project.hmiPages.push_back(page); project.initialHmiPageId = page.id; @@ -168,7 +173,7 @@ void testEmptyProjectRoundTrip() ProjectService service(storage, defaultProjectLimitSettings()); require(service.createNewProject("Empty project").succeeded, "empty project creation must succeed"); - require(service.project().metadata.formatVersion == "3.0", + require(service.project().metadata.formatVersion == "4.0", "new projects must use the strict grid format"); const QString path = directory.filePath("empty.json"); @@ -193,15 +198,15 @@ void testGridProjectRoundTrip() "the grid project must save"); const QByteArray json = readBytes(path); - require(json.contains("\"formatVersion\": \"3.0\"") + require(json.contains("\"formatVersion\": \"4.0\"") && json.contains("\"cells\"") && json.contains("\"verticalConnections\"") && json.contains("\"kind\": \"node\"") && json.contains("\"kind\": \"wire\""), - "3.0 JSON must persist cells and independent vertical edges"); + "4.0 JSON must persist cells and independent vertical edges"); require(!json.contains("\"condition\"") && !json.contains("\"children\""), - "3.0 JSON must not serialize the removed expression tree"); + "4.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 +283,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 3.0 project"); + "Int32 and Double HMI controls must save in a 4.0 project"); const QByteArray json = readBytes(path); require(json.contains("\"dataType\": \"int32\"") && json.contains("\"dataType\": \"float64\"") - && json.contains("\"formatVersion\": \"3.0\""), - "multi-word HMI types must use stable JSON names in 3.0"); + && json.contains("\"formatVersion\": \"4.0\""), + "multi-word HMI types must use stable JSON names in 4.0"); const ProjectLoadResult loaded = storage.load(path.toStdString()); require(loaded.succeeded @@ -314,6 +319,40 @@ void testMultiWordHmiDataTypeRoundTrip() "unknown HMI data types must be rejected by strict JSON loading"); } +void testButtonEnableConditionRoundTrip() +{ + QTemporaryDir directory; + require(directory.isValid(), "temporary directory must be valid"); + JsonProjectStorage storage(defaultProjectLimitSettings()); + const QString path = directory.filePath("button-condition.json"); + Project project = makeExampleProject(); + HmiControl &button = project.hmiPages.front().controls.front(); + button.buttonEnableCondition = HmiButtonWordEnableCondition{ + RegisterAddress{RegisterArea::D, 120}, + RegisterDataType::Float32, + HmiButtonConditionOperator::LessThanOrEqual, + 12.5}; + require(storage.save(project, path.toStdString()).succeeded, + "button enable condition project must save"); + const QByteArray json = readBytes(path); + require(json.contains("\"buttonEnableCondition\"") + && json.contains("\"type\": \"dValue\"") + && json.contains("\"operation\": \"lessThanOrEqual\"") + && json.contains("\"dataType\": \"float32\""), + "button enable conditions must use explicit 4.0 JSON fields"); + const ProjectLoadResult loaded = storage.load(path.toStdString()); + require(loaded.succeeded, + "button enable condition project must load after a JSON round trip"); + const HmiControl &loaded_button = loaded.project.hmiPages.front().controls.front(); + require(loaded_button.buttonEnableCondition.has_value() + && std::get( + *loaded_button.buttonEnableCondition).address + == RegisterAddress{RegisterArea::D, 120} + && std::get( + *loaded_button.buttonEnableCondition).value == 12.5, + "button enable condition type, address and value must survive round trip"); +} + void testStatusTextRoundTrip() { QTemporaryDir directory; @@ -343,7 +382,7 @@ void testStatusTextRoundTrip() 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"); + "M and D status text controls must save in 4.0"); const QByteArray json = readBytes(path); require(json.contains("\"type\": \"statusText\"") && json.contains("\"source\": \"m\"") @@ -351,7 +390,7 @@ void testStatusTextRoundTrip() && json.contains("\"lower\": null") && json.contains("\"upper\": null") && json.contains("\"dataType\": \"float64\""), - "3.0 JSON must store explicit status text modes and range bounds"); + "4.0 JSON must store explicit status text modes and range bounds"); const ProjectLoadResult loaded = storage.load(path.toStdString()); require(loaded.succeeded @@ -383,6 +422,13 @@ void testStrictVersionAndRequiredFields() == ProjectStorageError::UnsupportedVersion, "old 2.0 files must be rejected without migration code"); + root.insert(QStringLiteral("formatVersion"), QStringLiteral("3.0")); + const QString old_grid_path = directory.filePath("old-grid.json"); + writeBytes(old_grid_path, QJsonDocument(root).toJson()); + require(storage.load(old_grid_path.toStdString()).error + == ProjectStorageError::UnsupportedVersion, + "old 3.0 files must be rejected without migration code"); + root = QJsonDocument::fromJson(readBytes(fixture_path)).object(); QJsonArray logics = root.value(QStringLiteral("controlLogics")).toArray(); QJsonObject logic = logics.at(0).toObject(); @@ -393,7 +439,7 @@ void testStrictVersionAndRequiredFields() writeBytes(missing_path, QJsonDocument(root).toJson()); require(storage.load(missing_path.toStdString()).error == ProjectStorageError::MissingField, - "3.0 must require the vertical connection array explicitly"); + "4.0 must require the vertical connection array explicitly"); } void testInvalidGridAndConnectionsAreRejected() @@ -463,7 +509,7 @@ void testInvalidGridAndConnectionsAreRejected() writeBytes(hidden_comment_path, QJsonDocument(hidden_comment).toJson()); require(storage.load(hidden_comment_path.toStdString()).error == ProjectStorageError::InvalidProject, - "a 3.0 project must reject comments stored on connected branch rows"); + "a 4.0 project must reject comments stored on connected branch rows"); QJsonObject non_adjacent = original; logic = firstLogic(&non_adjacent); @@ -554,6 +600,7 @@ int main() testMOffAlarmRoundTrip(); testMultiWordHmiDataTypeRoundTrip(); testStatusTextRoundTrip(); + testButtonEnableConditionRoundTrip(); testStrictVersionAndRequiredFields(); testInvalidGridAndConnectionsAreRejected(); testProjectServiceStateAndConfiguredLimits(); diff --git a/app/tests/runtime_mode_service_tests.cpp b/app/tests/runtime_mode_service_tests.cpp index d11fff7..fa5d2bb 100644 --- a/app/tests/runtime_mode_service_tests.cpp +++ b/app/tests/runtime_mode_service_tests.cpp @@ -181,6 +181,17 @@ void testModeTransitions() {std::nullopt, 0.0, "Low"}, {0.0, std::nullopt, "High"}}}; page.controls.push_back(double_status); + HmiControl gated_button; + gated_button.id = "runtime-gated-button"; + gated_button.type = HmiControlType::Button; + gated_button.bounds = {400, 0, 120, 40}; + gated_button.binding = RegisterAddress{RegisterArea::M, 30}; + gated_button.buttonEnableCondition = HmiButtonWordEnableCondition{ + RegisterAddress{RegisterArea::D, 80}, + RegisterDataType::Int32, + HmiButtonConditionOperator::GreaterThanOrEqual, + 1.0}; + page.controls.push_back(gated_button); project.initialHmiPageId = page.id; project.hmiPages.push_back(page); project.alarmDefinitions.push_back( @@ -296,6 +307,7 @@ void testModeTransitions() {RegisterArea::M, 12}, {RegisterArea::M, 20}, {RegisterArea::M, 21}, {RegisterArea::M, 22}, {RegisterArea::M, 27}, {RegisterArea::M, 28}, + {RegisterArea::M, 30}, {RegisterArea::D, 34}, {RegisterArea::D, 35}, {RegisterArea::D, 38}, {RegisterArea::D, 39}, {RegisterArea::D, 40}, {RegisterArea::D, 41}, @@ -303,12 +315,14 @@ void testModeTransitions() {RegisterArea::D, 60}, {RegisterArea::D, 61}, {RegisterArea::D, 62}, {RegisterArea::D, 63}, {RegisterArea::D, 70}, {RegisterArea::D, 71}, - {RegisterArea::D, 72}, {RegisterArea::D, 73}}) + {RegisterArea::D, 72}, {RegisterArea::D, 73}, + {RegisterArea::D, 80}, {RegisterArea::D, 81}}) && gateway.pollRanges() == std::vector({ {{RegisterArea::D, 50}, 2}, {{RegisterArea::D, 60}, 4}, - {{RegisterArea::D, 70}, 4}}), + {{RegisterArea::D, 70}, 4}, + {{RegisterArea::D, 80}, 2}}), "instructions and multi-word status text must be fully polled while comments stay metadata-only"); gateway.completeInitialRead(); require(service.initialPlcReadCompleted(),