| @@ -21,6 +21,7 @@ SOURCES += \ | |||||
| src/ui/alarm_configuration_dialog.cpp \ | src/ui/alarm_configuration_dialog.cpp \ | ||||
| src/ui/register_comment_dialog.cpp \ | src/ui/register_comment_dialog.cpp \ | ||||
| src/ui/status_text_dialog.cpp \ | src/ui/status_text_dialog.cpp \ | ||||
| src/ui/button_extension_dialog.cpp \ | |||||
| src/ui/plc_connection_dialog.cpp \ | src/ui/plc_connection_dialog.cpp \ | ||||
| src/ui/free_monitor_widget.cpp \ | src/ui/free_monitor_widget.cpp \ | ||||
| src/ui/runtime_monitor_window.cpp \ | src/ui/runtime_monitor_window.cpp \ | ||||
| @@ -71,6 +72,7 @@ HEADERS += \ | |||||
| src/ui/alarm_configuration_dialog.h \ | src/ui/alarm_configuration_dialog.h \ | ||||
| src/ui/register_comment_dialog.h \ | src/ui/register_comment_dialog.h \ | ||||
| src/ui/status_text_dialog.h \ | src/ui/status_text_dialog.h \ | ||||
| src/ui/button_extension_dialog.h \ | |||||
| src/ui/plc_connection_dialog.h \ | src/ui/plc_connection_dialog.h \ | ||||
| src/ui/free_monitor_widget.h \ | src/ui/free_monitor_widget.h \ | ||||
| src/ui/runtime_monitor_window.h \ | src/ui/runtime_monitor_window.h \ | ||||
| @@ -127,6 +129,7 @@ FORMS += \ | |||||
| src/ui/alarm_configuration_dialog.ui \ | src/ui/alarm_configuration_dialog.ui \ | ||||
| src/ui/register_comment_dialog.ui \ | src/ui/register_comment_dialog.ui \ | ||||
| src/ui/status_text_dialog.ui \ | src/ui/status_text_dialog.ui \ | ||||
| src/ui/button_extension_dialog.ui \ | |||||
| src/ui/plc_connection_dialog.ui \ | src/ui/plc_connection_dialog.ui \ | ||||
| src/ui/free_monitor_widget.ui \ | src/ui/free_monitor_widget.ui \ | ||||
| src/ui/runtime_monitor_window.ui \ | src/ui/runtime_monitor_window.ui \ | ||||
| @@ -7,6 +7,7 @@ | |||||
| #include <cctype> | #include <cctype> | ||||
| #include <charconv> | #include <charconv> | ||||
| #include <cmath> | #include <cmath> | ||||
| #include <limits> | |||||
| namespace { | namespace { | ||||
| @@ -183,6 +184,77 @@ bool validateAppearanceProperty( | |||||
| return true; | return true; | ||||
| } | } | ||||
| bool validateButtonEnableCondition( | |||||
| const HmiButtonEnableCondition &condition, std::string *error) | |||||
| { | |||||
| if (const auto *bit = std::get_if<HmiButtonBitEnableCondition>(&condition)) | |||||
| { | |||||
| if (!bit->address.isValid() || bit->address.area() != RegisterArea::M) | |||||
| { | |||||
| setError(error, "按钮 M 启用条件必须使用有效的 M 区地址"); | |||||
| return false; | |||||
| } | |||||
| return true; | |||||
| } | |||||
| const auto *word = std::get_if<HmiButtonWordEnableCondition>(&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<std::int16_t>::min() | |||||
| || word->value > std::numeric_limits<std::int16_t>::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<std::int32_t>::min() | |||||
| || word->value > std::numeric_limits<std::int32_t>::max()) | |||||
| { | |||||
| setError(error, "Int32 按钮启用条件比较值必须是有效的 32 位整数"); | |||||
| return false; | |||||
| } | |||||
| } | |||||
| else if (word->dataType == RegisterDataType::Float32 | |||||
| && !std::isfinite(static_cast<float>(word->value))) | |||||
| { | |||||
| setError(error, "Float32 按钮启用条件比较值超出范围"); | |||||
| return false; | |||||
| } | |||||
| return true; | |||||
| } | |||||
| } // namespace | } // namespace | ||||
| bool HmiControl::validate(std::string *error) const | bool HmiControl::validate(std::string *error) const | ||||
| @@ -359,6 +431,19 @@ bool HmiControl::validate(std::string *error) const | |||||
| setError(error, "非页面跳转控件不能包含跳转配置"); | setError(error, "非页面跳转控件不能包含跳转配置"); | ||||
| return false; | 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; | return true; | ||||
| } | } | ||||
| @@ -72,6 +72,48 @@ enum class HmiButtonOperation | |||||
| MomentaryOn // 按下时写入 1,释放时写入 0 | 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 | struct HmiPageJumpConfig | ||||
| { | { | ||||
| std::string targetPageId; | std::string targetPageId; | ||||
| @@ -131,6 +173,7 @@ struct HmiControl | |||||
| RegisterDataType dataType = RegisterDataType::Int16; | RegisterDataType dataType = RegisterDataType::Int16; | ||||
| std::map<std::string, std::string> properties; | std::map<std::string, std::string> properties; | ||||
| HmiButtonOperation buttonOperation = HmiButtonOperation::MomentaryOn; | HmiButtonOperation buttonOperation = HmiButtonOperation::MomentaryOn; | ||||
| std::optional<HmiButtonEnableCondition> buttonEnableCondition; | |||||
| std::optional<HmiPageJumpConfig> pageJump; | std::optional<HmiPageJumpConfig> pageJump; | ||||
| std::optional<HmiStatusTextConfig> statusText; | std::optional<HmiStatusTextConfig> statusText; | ||||
| @@ -26,7 +26,7 @@ struct ProjectMetadata | |||||
| // 工程显示名称 | // 工程显示名称 | ||||
| std::string name; | std::string name; | ||||
| // 工程文件格式版本 | // 工程文件格式版本 | ||||
| std::string formatVersion = "3.0"; | |||||
| std::string formatVersion = "4.0"; | |||||
| }; | }; | ||||
| // 聚合工程中的 HMI 页面、报警、寄存器注释和控制逻辑 | // 聚合工程中的 HMI 页面、报警、寄存器注释和控制逻辑 | ||||
| @@ -18,7 +18,7 @@ | |||||
| namespace { | namespace { | ||||
| // 当前读写实现支持的工程文件格式版本 | // 当前读写实现支持的工程文件格式版本 | ||||
| constexpr const char *kCurrentFormatVersion = "3.0"; | |||||
| constexpr const char *kCurrentFormatVersion = "4.0"; | |||||
| // 保存解析过程中遇到的第一个错误,避免后续失败覆盖根因 | // 保存解析过程中遇到的第一个错误,避免后续失败覆盖根因 | ||||
| struct ParseState | struct ParseState | ||||
| @@ -426,6 +426,156 @@ bool parseHmiButtonOperation( | |||||
| return true; | 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<HmiButtonBitEnableCondition>(&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<HmiButtonWordEnableCondition>(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) | QString alarmConditionName(AlarmCondition condition) | ||||
| { | { | ||||
| switch (condition) | switch (condition) | ||||
| @@ -810,6 +960,11 @@ QJsonObject serializeHmiControl(const HmiControl &control) | |||||
| object.insert( | object.insert( | ||||
| QStringLiteral("buttonOperation"), | QStringLiteral("buttonOperation"), | ||||
| hmiButtonOperationName(control.buttonOperation)); | hmiButtonOperationName(control.buttonOperation)); | ||||
| object.insert( | |||||
| QStringLiteral("buttonEnableCondition"), | |||||
| control.buttonEnableCondition.has_value() | |||||
| ? serializeHmiButtonEnableCondition(*control.buttonEnableCondition) | |||||
| : QJsonValue(QJsonValue::Null)); | |||||
| } | } | ||||
| if (control.type == HmiControlType::PageJump) | if (control.type == HmiControlType::PageJump) | ||||
| { | { | ||||
| @@ -889,6 +1044,7 @@ bool parseHmiControl( | |||||
| if (control->type == HmiControlType::Button) | if (control->type == HmiControlType::Button) | ||||
| { | { | ||||
| std::string operation_text; | std::string operation_text; | ||||
| QJsonValue enable_condition_value; | |||||
| if (!readString( | if (!readString( | ||||
| object, | object, | ||||
| "buttonOperation", | "buttonOperation", | ||||
| @@ -900,6 +1056,38 @@ bool parseHmiControl( | |||||
| { | { | ||||
| return false; | 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) | if (control->type == HmiControlType::PageJump) | ||||
| { | { | ||||
| @@ -2,7 +2,7 @@ | |||||
| #include "domain/project_storage.h" | #include "domain/project_storage.h" | ||||
| // 严格读写当前 3.0 JSON 工程格式 | |||||
| // 严格读写当前 4.0 JSON 工程格式 | |||||
| class JsonProjectStorage final : public ProjectStorage | class JsonProjectStorage final : public ProjectStorage | ||||
| { | { | ||||
| public: | public: | ||||
| @@ -137,6 +137,7 @@ bool HmiEditorService::controlsEqual( | |||||
| && left.dataType == right.dataType | && left.dataType == right.dataType | ||||
| && left.properties == right.properties | && left.properties == right.properties | ||||
| && left.buttonOperation == right.buttonOperation | && left.buttonOperation == right.buttonOperation | ||||
| && left.buttonEnableCondition == right.buttonEnableCondition | |||||
| && left.statusText == right.statusText | && left.statusText == right.statusText | ||||
| && page_jump_equal; | && page_jump_equal; | ||||
| } | } | ||||
| @@ -29,6 +29,28 @@ double numericValueAsDouble(const RegisterNumericValue &value) | |||||
| 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 | } // namespace | ||||
| HmiRuntimeService::HmiRuntimeService(RegisterRepository &repository) | HmiRuntimeService::HmiRuntimeService(RegisterRepository &repository) | ||||
| @@ -177,6 +199,74 @@ HmiStatusTextReadResult HmiRuntimeService::readStatusText( | |||||
| true, HmiRuntimeError::None, match->text}; | 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<HmiButtonBitEnableCondition>( | |||||
| &*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<HmiButtonWordEnableCondition>( | |||||
| &*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<RegisterNumericValue> 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( | HmiRuntimeWriteResult HmiRuntimeService::operateButton( | ||||
| const HmiControl &control, HmiButtonEvent event) | const HmiControl &control, HmiButtonEvent event) | ||||
| { | { | ||||
| @@ -193,6 +283,19 @@ HmiRuntimeWriteResult HmiRuntimeService::operateButton( | |||||
| return writeFailure(HmiRuntimeError::InvalidBinding); | 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; | bool value = false; | ||||
| switch (control.buttonOperation) | switch (control.buttonOperation) | ||||
| { | { | ||||
| @@ -21,7 +21,9 @@ enum class HmiRuntimeError | |||||
| UnsupportedControl, // 控件类型不支持寄存器读写或写入操作 | UnsupportedControl, // 控件类型不支持寄存器读写或写入操作 | ||||
| MissingBinding, // 控件未配置寄存器绑定 | MissingBinding, // 控件未配置寄存器绑定 | ||||
| InvalidBinding, // 控件绑定地址无效或地址区域不匹配 | InvalidBinding, // 控件绑定地址无效或地址区域不匹配 | ||||
| RepositoryFailure // 寄存器仓库不可用或拒绝读写 | |||||
| RepositoryFailure, // 寄存器仓库不可用或拒绝读写 | |||||
| ConditionNotMet, // 按钮启用条件不满足 | |||||
| ConditionUnavailable // 按钮启用条件无法读取或配置无效 | |||||
| }; | }; | ||||
| /** | /** | ||||
| @@ -46,6 +48,13 @@ struct HmiRuntimeWriteResult | |||||
| HmiRuntimeError error = HmiRuntimeError::None; // 失败时的 HMI 错误分类 | HmiRuntimeError error = HmiRuntimeError::None; // 失败时的 HMI 错误分类 | ||||
| }; | }; | ||||
| struct HmiButtonEnabledResult | |||||
| { | |||||
| bool succeeded = false; | |||||
| bool enabled = false; | |||||
| HmiRuntimeError error = HmiRuntimeError::ConditionUnavailable; | |||||
| }; | |||||
| struct HmiStatusTextReadResult | struct HmiStatusTextReadResult | ||||
| { | { | ||||
| bool succeeded = false; | bool succeeded = false; | ||||
| @@ -86,6 +95,9 @@ public: | |||||
| */ | */ | ||||
| HmiRuntimeReadResult readControl(const HmiControl &control) const; | HmiRuntimeReadResult readControl(const HmiControl &control) const; | ||||
| HmiStatusTextReadResult readStatusText(const HmiControl &control) const; | HmiStatusTextReadResult readStatusText(const HmiControl &control) const; | ||||
| /** @brief 读取并评估按钮启用条件;无条件按钮直接返回可用 */ | |||||
| HmiButtonEnabledResult evaluateButtonEnabled( | |||||
| const HmiControl &control) const; | |||||
| /** | /** | ||||
| * @brief 根据按钮配置处理按下或释放事件 | * @brief 根据按钮配置处理按下或释放事件 | ||||
| * @param control 按钮控件,必须绑定有效 M 地址 | * @param control 按钮控件,必须绑定有效 M 地址 | ||||
| @@ -192,7 +192,7 @@ Project ProjectService::makeNewProject(const std::string &name) | |||||
| project.metadata.id = generateProjectId(); | project.metadata.id = generateProjectId(); | ||||
| project.metadata.name = name; | project.metadata.name = name; | ||||
| // 新工程固定使用当前存储格式版本 | // 新工程固定使用当前存储格式版本 | ||||
| project.metadata.formatVersion = "3.0"; | |||||
| project.metadata.formatVersion = "4.0"; | |||||
| return project; | return project; | ||||
| } | } | ||||
| @@ -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) | for (const AlarmDefinition &definition : project.alarmDefinitions) | ||||
| @@ -0,0 +1,124 @@ | |||||
| #include "button_extension_dialog.h" | |||||
| #include "domain/project_limits.h" | |||||
| #include "ui_button_extension_dialog.h" | |||||
| #include <QComboBox> | |||||
| #include <QLineEdit> | |||||
| #include <QLocale> | |||||
| #include <QMessageBox> | |||||
| namespace { | |||||
| QString fromUtf8(const std::string &value) | |||||
| { | |||||
| return QString::fromUtf8(value.data(), static_cast<int>(value.size())); | |||||
| } | |||||
| } // namespace | |||||
| ButtonExtensionDialog::ButtonExtensionDialog( | |||||
| const HmiControl &control, QWidget *parent) | |||||
| : QDialog(parent), | |||||
| ui_(std::make_unique<Ui::ButtonExtensionDialog>()), | |||||
| 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<int>(RegisterDataType::Int16)); | |||||
| ui_->dataTypeComboBox->setItemData(1, static_cast<int>(RegisterDataType::Int32)); | |||||
| ui_->dataTypeComboBox->setItemData(2, static_cast<int>(RegisterDataType::Float32)); | |||||
| ui_->dataTypeComboBox->setItemData(3, static_cast<int>(RegisterDataType::Float64)); | |||||
| ui_->operatorComboBox->setItemData(0, static_cast<int>(HmiButtonConditionOperator::Equal)); | |||||
| ui_->operatorComboBox->setItemData(1, static_cast<int>(HmiButtonConditionOperator::NotEqual)); | |||||
| ui_->operatorComboBox->setItemData(2, static_cast<int>(HmiButtonConditionOperator::LessThan)); | |||||
| ui_->operatorComboBox->setItemData(3, static_cast<int>(HmiButtonConditionOperator::LessThanOrEqual)); | |||||
| ui_->operatorComboBox->setItemData(4, static_cast<int>(HmiButtonConditionOperator::GreaterThan)); | |||||
| ui_->operatorComboBox->setItemData(5, static_cast<int>(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<HmiButtonBitEnableCondition>( | |||||
| &*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<HmiButtonWordEnableCondition>( | |||||
| &*control.buttonEnableCondition)) | |||||
| { | |||||
| ui_->conditionTypeComboBox->setCurrentIndex(2); | |||||
| ui_->dAddressSpinBox->setValue(word->address.index()); | |||||
| ui_->dataTypeComboBox->setCurrentIndex( | |||||
| ui_->dataTypeComboBox->findData(static_cast<int>(word->dataType))); | |||||
| ui_->operatorComboBox->setCurrentIndex( | |||||
| ui_->operatorComboBox->findData(static_cast<int>(word->operation))); | |||||
| ui_->valueEdit->setText(QLocale::c().toString(word->value, 'g', 17)); | |||||
| } | |||||
| } | |||||
| connect(ui_->conditionTypeComboBox, | |||||
| QOverload<int>::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<RegisterDataType>(ui_->dataTypeComboBox->currentData().toInt()), | |||||
| static_cast<HmiButtonConditionOperator>( | |||||
| ui_->operatorComboBox->currentData().toInt()), | |||||
| value}; | |||||
| } | |||||
| std::string error; | |||||
| if (!control_.validate(&error)) | |||||
| { | |||||
| QMessageBox::warning(this, tr("按钮扩展配置"), fromUtf8(error)); | |||||
| return; | |||||
| } | |||||
| QDialog::accept(); | |||||
| } | |||||
| @@ -0,0 +1,30 @@ | |||||
| #pragma once | |||||
| #include "domain/hmi_model.h" | |||||
| #include <QDialog> | |||||
| #include <memory> | |||||
| 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::ButtonExtensionDialog> ui_; | |||||
| HmiControl control_; | |||||
| }; | |||||
| @@ -0,0 +1,36 @@ | |||||
| <?xml version="1.0" encoding="UTF-8"?> | |||||
| <ui version="4.0"> | |||||
| <class>ButtonExtensionDialog</class> | |||||
| <widget class="QDialog" name="ButtonExtensionDialog"> | |||||
| <property name="windowTitle"><string>按钮扩展配置</string></property> | |||||
| <property name="minimumSize"><size><width>460</width><height>260</height></size></property> | |||||
| <layout class="QVBoxLayout" name="verticalLayout"> | |||||
| <item> | |||||
| <layout class="QFormLayout" name="conditionForm"> | |||||
| <item row="0" column="0"><widget class="QLabel" name="conditionTypeLabel"><property name="text"><string>启用条件</string></property></widget></item> | |||||
| <item row="0" column="1"><widget class="QComboBox" name="conditionTypeComboBox"><item><property name="text"><string>未设置</string></property></item><item><property name="text"><string>M 位条件</string></property></item><item><property name="text"><string>D 数值条件</string></property></item></widget></item> | |||||
| </layout> | |||||
| </item> | |||||
| <item><widget class="QStackedWidget" name="configurationStack"> | |||||
| <widget class="QWidget" name="bitPage"><layout class="QFormLayout" name="bitForm"> | |||||
| <item row="0" column="0"><widget class="QLabel" name="addressLabel"><property name="text"><string>M 地址</string></property></widget></item> | |||||
| <item row="0" column="1"><widget class="QSpinBox" name="addressSpinBox"><property name="maximum"><number>4000</number></property></widget></item> | |||||
| <item row="1" column="0"><widget class="QLabel" name="expectedLabel"><property name="text"><string>要求状态</string></property></widget></item> | |||||
| <item row="1" column="1"><widget class="QComboBox" name="expectedComboBox"><item><property name="text"><string>OFF</string></property><property name="userData"><bool>false</bool></property></item><item><property name="text"><string>ON</string></property><property name="userData"><bool>true</bool></property></item></widget></item> | |||||
| </layout></widget> | |||||
| <widget class="QWidget" name="wordPage"><layout class="QFormLayout" name="wordForm"> | |||||
| <item row="0" column="0"><widget class="QLabel" name="dAddressLabel"><property name="text"><string>D 地址</string></property></widget></item> | |||||
| <item row="0" column="1"><widget class="QSpinBox" name="dAddressSpinBox"><property name="maximum"><number>4000</number></property></widget></item> | |||||
| <item row="1" column="0"><widget class="QLabel" name="dataTypeLabel"><property name="text"><string>数值类型</string></property></widget></item> | |||||
| <item row="1" column="1"><widget class="QComboBox" name="dataTypeComboBox"><item><property name="text"><string>Int16</string></property></item><item><property name="text"><string>Int32</string></property></item><item><property name="text"><string>Float32</string></property></item><item><property name="text"><string>Double (Float64)</string></property></item></widget></item> | |||||
| <item row="2" column="0"><widget class="QLabel" name="operatorLabel"><property name="text"><string>比较方式</string></property></widget></item> | |||||
| <item row="2" column="1"><widget class="QComboBox" name="operatorComboBox"><item><property name="text"><string>等于</string></property></item><item><property name="text"><string>不等于</string></property></item><item><property name="text"><string>小于</string></property></item><item><property name="text"><string>小于等于</string></property></item><item><property name="text"><string>大于</string></property></item><item><property name="text"><string>大于等于</string></property></item></widget></item> | |||||
| <item row="3" column="0"><widget class="QLabel" name="valueLabel"><property name="text"><string>比较值</string></property></widget></item> | |||||
| <item row="3" column="1"><widget class="QLineEdit" name="valueEdit"><property name="placeholderText"><string>请输入数值</string></property></widget></item> | |||||
| </layout></widget> | |||||
| </widget></item> | |||||
| <item><widget class="QDialogButtonBox" name="buttonBox"><property name="standardButtons"><set>QDialogButtonBox::Cancel|QDialogButtonBox::Ok</set></property></widget></item> | |||||
| </layout> | |||||
| </widget> | |||||
| <connections><connection><sender>buttonBox</sender><signal>accepted()</signal><receiver>ButtonExtensionDialog</receiver><slot>accept()</slot></connection><connection><sender>buttonBox</sender><signal>rejected()</signal><receiver>ButtonExtensionDialog</receiver><slot>reject()</slot></connection></connections> | |||||
| </ui> | |||||
| @@ -199,7 +199,7 @@ public: | |||||
| case HmiControlType::Button: | case HmiControlType::Button: | ||||
| { | { | ||||
| const bool disabled = !editing_enabled_ && runtime_active_ | const bool disabled = !editing_enabled_ && runtime_active_ | ||||
| && !runtime_write_enabled_; | |||||
| && (!runtime_write_enabled_ || !button_condition_enabled_); | |||||
| const QColor fill = disabled | const QColor fill = disabled | ||||
| ? QColor(QStringLiteral("#e2e6e4")) | ? QColor(QStringLiteral("#e2e6e4")) | ||||
| : button_pressed_ ? QColor(QStringLiteral("#a9cbb6")) | : button_pressed_ ? QColor(QStringLiteral("#a9cbb6")) | ||||
| @@ -454,6 +454,7 @@ public: | |||||
| void setInteractionState( | void setInteractionState( | ||||
| bool editable, bool runtime_active, bool runtime_write_enabled) | bool editable, bool runtime_active, bool runtime_write_enabled) | ||||
| { | { | ||||
| const bool runtime_was_active = runtime_active_; | |||||
| if (control_.type == HmiControlType::AlarmList && !runtime_active) | if (control_.type == HmiControlType::AlarmList && !runtime_active) | ||||
| { | { | ||||
| alarm_page_ = 0U; | alarm_page_ = 0U; | ||||
| @@ -461,6 +462,14 @@ public: | |||||
| editing_enabled_ = editable; | editing_enabled_ = editable; | ||||
| runtime_active_ = runtime_active; | runtime_active_ = runtime_active; | ||||
| runtime_write_enabled_ = runtime_active && runtime_write_enabled; | 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(ItemIsMovable, editable); | ||||
| setFlag(ItemIsSelectable, editable); | setFlag(ItemIsSelectable, editable); | ||||
| if (!editable) | if (!editable) | ||||
| @@ -473,15 +482,20 @@ public: | |||||
| || control_.type == HmiControlType::NumericInput | || control_.type == HmiControlType::NumericInput | ||||
| || control_.type == HmiControlType::PageJump | || control_.type == HmiControlType::PageJump | ||||
| || control_.type == HmiControlType::AlarmList); | || 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 | const bool runtime_input_enabled = runtime_input | ||||
| && (control_.type == HmiControlType::PageJump | && (control_.type == HmiControlType::PageJump | ||||
| || control_.type == HmiControlType::AlarmList | || control_.type == HmiControlType::AlarmList | ||||
| || runtime_write_enabled_); | |||||
| || runtime_write_input_enabled); | |||||
| setAcceptedMouseButtons( | setAcceptedMouseButtons( | ||||
| editable || runtime_input_enabled ? Qt::LeftButton : Qt::NoButton); | editable || runtime_input_enabled ? Qt::LeftButton : Qt::NoButton); | ||||
| const bool runtime_button_enabled = runtime_active_ | 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_ | const bool runtime_page_jump_enabled = runtime_active_ | ||||
| && control_.type == HmiControlType::PageJump; | && control_.type == HmiControlType::PageJump; | ||||
| const bool runtime_alarm_enabled = runtime_active_ | const bool runtime_alarm_enabled = runtime_active_ | ||||
| @@ -497,7 +511,10 @@ public: | |||||
| unsetCursor(); | unsetCursor(); | ||||
| button_hovered_ = false; | button_hovered_ = false; | ||||
| page_hovered_ = false; | page_hovered_ = false; | ||||
| button_pressed_ = false; | |||||
| if (!runtime_active_ || !runtime_write_enabled_) | |||||
| { | |||||
| button_pressed_ = false; | |||||
| } | |||||
| } | } | ||||
| update(); | update(); | ||||
| } | } | ||||
| @@ -512,6 +529,13 @@ public: | |||||
| update(); | 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) | void setStatusText(const std::string &text, bool available) | ||||
| { | { | ||||
| status_text_ = QString::fromUtf8( | status_text_ = QString::fromUtf8( | ||||
| @@ -606,6 +630,7 @@ protected: | |||||
| return; | return; | ||||
| } | } | ||||
| if (runtime_active_ && runtime_write_enabled_ | if (runtime_active_ && runtime_write_enabled_ | ||||
| && button_condition_enabled_ | |||||
| && control_.type == HmiControlType::Button && button_event_) | && control_.type == HmiControlType::Button && button_event_) | ||||
| { | { | ||||
| button_pressed_ = true; | button_pressed_ = true; | ||||
| @@ -887,6 +912,7 @@ private: | |||||
| bool editing_enabled_ = true; | bool editing_enabled_ = true; | ||||
| bool runtime_active_ = false; | bool runtime_active_ = false; | ||||
| bool runtime_write_enabled_ = false; | bool runtime_write_enabled_ = false; | ||||
| bool button_condition_enabled_ = true; | |||||
| bool button_hovered_ = false; | bool button_hovered_ = false; | ||||
| bool button_pressed_ = false; | bool button_pressed_ = false; | ||||
| bool page_hovered_ = false; | bool page_hovered_ = false; | ||||
| @@ -1135,6 +1161,13 @@ void HmiEditorWidget::refreshRuntimeValues() | |||||
| control_item->setAlarmRecords(alarm_service_.records()); | control_item->setAlarmRecords(alarm_service_.records()); | ||||
| continue; | 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) | if (control->type == HmiControlType::StatusText) | ||||
| { | { | ||||
| const HmiStatusTextReadResult value = | const HmiStatusTextReadResult value = | ||||
| @@ -1229,6 +1262,12 @@ void HmiEditorWidget::handleButtonEvent( | |||||
| const HmiRuntimeWriteResult result = runtime_service_.operateButton(*control, event); | const HmiRuntimeWriteResult result = runtime_service_.operateButton(*control, event); | ||||
| if (!result.succeeded) | if (!result.succeeded) | ||||
| { | { | ||||
| if (result.error == HmiRuntimeError::ConditionNotMet | |||||
| || result.error == HmiRuntimeError::ConditionUnavailable) | |||||
| { | |||||
| refreshRuntimeValues(); | |||||
| return; | |||||
| } | |||||
| emit editorError(tr("寄存器操作失败")); | emit editorError(tr("寄存器操作失败")); | ||||
| return; | return; | ||||
| } | } | ||||
| @@ -706,13 +706,23 @@ | |||||
| </widget> | </widget> | ||||
| </item> | </item> | ||||
| <item row="10" column="0"> | <item row="10" column="0"> | ||||
| <widget class="QLabel" name="buttonEnableConditionLabel"> | |||||
| <property name="text"><string>启用条件</string></property> | |||||
| </widget> | |||||
| </item> | |||||
| <item row="10" column="1"> | |||||
| <widget class="QPushButton" name="configureButtonExtensionButton"> | |||||
| <property name="text"><string>未设置</string></property> | |||||
| </widget> | |||||
| </item> | |||||
| <item row="11" column="0"> | |||||
| <widget class="QLabel" name="textColorLabel"> | <widget class="QLabel" name="textColorLabel"> | ||||
| <property name="text"> | <property name="text"> | ||||
| <string>字体颜色</string> | <string>字体颜色</string> | ||||
| </property> | </property> | ||||
| </widget> | </widget> | ||||
| </item> | </item> | ||||
| <item row="10" column="1"> | |||||
| <item row="11" column="1"> | |||||
| <widget class="QWidget" name="textColorContainer"> | <widget class="QWidget" name="textColorContainer"> | ||||
| <layout class="QHBoxLayout" name="textColorLayout"> | <layout class="QHBoxLayout" name="textColorLayout"> | ||||
| <property name="leftMargin"><number>0</number></property> | <property name="leftMargin"><number>0</number></property> | ||||
| @@ -736,24 +746,24 @@ | |||||
| </layout> | </layout> | ||||
| </widget> | </widget> | ||||
| </item> | </item> | ||||
| <item row="11" column="0"> | |||||
| <item row="12" column="0"> | |||||
| <widget class="QLabel" name="fontSizeLabel"> | <widget class="QLabel" name="fontSizeLabel"> | ||||
| <property name="text"> | <property name="text"> | ||||
| <string>字号</string> | <string>字号</string> | ||||
| </property> | </property> | ||||
| </widget> | </widget> | ||||
| </item> | </item> | ||||
| <item row="11" column="1"> | |||||
| <item row="12" column="1"> | |||||
| <widget class="QSpinBox" name="fontSizeSpinBox"/> | <widget class="QSpinBox" name="fontSizeSpinBox"/> | ||||
| </item> | </item> | ||||
| <item row="12" column="0"> | |||||
| <item row="13" column="0"> | |||||
| <widget class="QLabel" name="fontBoldLabel"> | <widget class="QLabel" name="fontBoldLabel"> | ||||
| <property name="text"> | <property name="text"> | ||||
| <string>字体样式</string> | <string>字体样式</string> | ||||
| </property> | </property> | ||||
| </widget> | </widget> | ||||
| </item> | </item> | ||||
| <item row="12" column="1"> | |||||
| <item row="13" column="1"> | |||||
| <widget class="QWidget" name="fontStyleContainer"> | <widget class="QWidget" name="fontStyleContainer"> | ||||
| <layout class="QHBoxLayout" name="fontStyleLayout"> | <layout class="QHBoxLayout" name="fontStyleLayout"> | ||||
| <property name="leftMargin"><number>0</number></property> | <property name="leftMargin"><number>0</number></property> | ||||
| @@ -773,12 +783,12 @@ | |||||
| </layout> | </layout> | ||||
| </widget> | </widget> | ||||
| </item> | </item> | ||||
| <item row="13" column="0"> | |||||
| <item row="14" column="0"> | |||||
| <widget class="QLabel" name="dataTypeLabel"> | <widget class="QLabel" name="dataTypeLabel"> | ||||
| <property name="text"><string>数值类型</string></property> | <property name="text"><string>数值类型</string></property> | ||||
| </widget> | </widget> | ||||
| </item> | </item> | ||||
| <item row="13" column="1"> | |||||
| <item row="14" column="1"> | |||||
| <widget class="QComboBox" name="dataTypeComboBox"> | <widget class="QComboBox" name="dataTypeComboBox"> | ||||
| <item><property name="text"><string>Int16</string></property></item> | <item><property name="text"><string>Int16</string></property></item> | ||||
| <item><property name="text"><string>Int32</string></property></item> | <item><property name="text"><string>Int32</string></property></item> | ||||
| @@ -786,17 +796,17 @@ | |||||
| <item><property name="text"><string>Double (Float64)</string></property></item> | <item><property name="text"><string>Double (Float64)</string></property></item> | ||||
| </widget> | </widget> | ||||
| </item> | </item> | ||||
| <item row="14" column="0"> | |||||
| <item row="15" column="0"> | |||||
| <widget class="QLabel" name="statusTextConfigLabel"> | <widget class="QLabel" name="statusTextConfigLabel"> | ||||
| <property name="text"><string>状态映射</string></property> | <property name="text"><string>状态映射</string></property> | ||||
| </widget> | </widget> | ||||
| </item> | </item> | ||||
| <item row="14" column="1"> | |||||
| <item row="15" column="1"> | |||||
| <widget class="QPushButton" name="configureStatusTextButton"> | <widget class="QPushButton" name="configureStatusTextButton"> | ||||
| <property name="text"><string>配置</string></property> | <property name="text"><string>配置</string></property> | ||||
| </widget> | </widget> | ||||
| </item> | </item> | ||||
| <item row="15" column="0" colspan="2"> | |||||
| <item row="16" column="0" colspan="2"> | |||||
| <widget class="QPushButton" name="applyPropertiesButton"> | <widget class="QPushButton" name="applyPropertiesButton"> | ||||
| <property name="text"> | <property name="text"> | ||||
| <string>应用属性</string> | <string>应用属性</string> | ||||
| @@ -6,6 +6,7 @@ | |||||
| #include "logic_editor_widget.h" | #include "logic_editor_widget.h" | ||||
| #include "logic_instruction_dialog.h" | #include "logic_instruction_dialog.h" | ||||
| #include "status_text_dialog.h" | #include "status_text_dialog.h" | ||||
| #include "button_extension_dialog.h" | |||||
| #include "services/hmi_editor_service.h" | #include "services/hmi_editor_service.h" | ||||
| #include "services/logic_editor_service.h" | #include "services/logic_editor_service.h" | ||||
| #include "services/project_service.h" | #include "services/project_service.h" | ||||
| @@ -131,6 +132,8 @@ void PropertyPanelController::configure() | |||||
| &parent_, [this] { chooseTextColor(); }); | &parent_, [this] { chooseTextColor(); }); | ||||
| QObject::connect(ui_.configureStatusTextButton, &QPushButton::clicked, | QObject::connect(ui_.configureStatusTextButton, &QPushButton::clicked, | ||||
| &parent_, [this] { configureStatusText(); }); | &parent_, [this] { configureStatusText(); }); | ||||
| QObject::connect(ui_.configureButtonExtensionButton, &QPushButton::clicked, | |||||
| &parent_, [this] { configureButtonExtension(); }); | |||||
| ui_.targetPageLabel->setVisible(false); | ui_.targetPageLabel->setVisible(false); | ||||
| ui_.targetPageComboBox->setVisible(false); | ui_.targetPageComboBox->setVisible(false); | ||||
| showControlProperties({}); | showControlProperties({}); | ||||
| @@ -206,6 +209,7 @@ void PropertyPanelController::showControlProperties(const std::string &control_i | |||||
| static_cast<QWidget *>(ui_.bindingIndexSpinBox), | static_cast<QWidget *>(ui_.bindingIndexSpinBox), | ||||
| static_cast<QWidget *>(ui_.targetPageComboBox), | static_cast<QWidget *>(ui_.targetPageComboBox), | ||||
| static_cast<QWidget *>(ui_.buttonOperationComboBox), | static_cast<QWidget *>(ui_.buttonOperationComboBox), | ||||
| static_cast<QWidget *>(ui_.configureButtonExtensionButton), | |||||
| static_cast<QWidget *>(ui_.dataTypeComboBox), | static_cast<QWidget *>(ui_.dataTypeComboBox), | ||||
| static_cast<QWidget *>(ui_.configureStatusTextButton), | static_cast<QWidget *>(ui_.configureStatusTextButton), | ||||
| static_cast<QWidget *>(ui_.textColorEdit), | static_cast<QWidget *>(ui_.textColorEdit), | ||||
| @@ -230,6 +234,8 @@ void PropertyPanelController::showControlProperties(const std::string &control_i | |||||
| ui_.targetPageComboBox->setVisible(false); | ui_.targetPageComboBox->setVisible(false); | ||||
| ui_.buttonOperationLabel->setVisible(false); | ui_.buttonOperationLabel->setVisible(false); | ||||
| ui_.buttonOperationComboBox->setVisible(false); | ui_.buttonOperationComboBox->setVisible(false); | ||||
| ui_.buttonEnableConditionLabel->setVisible(false); | |||||
| ui_.configureButtonExtensionButton->setVisible(false); | |||||
| ui_.dataTypeLabel->setVisible(false); | ui_.dataTypeLabel->setVisible(false); | ||||
| ui_.dataTypeComboBox->setVisible(false); | ui_.dataTypeComboBox->setVisible(false); | ||||
| ui_.statusTextConfigLabel->setVisible(false); | ui_.statusTextConfigLabel->setVisible(false); | ||||
| @@ -276,6 +282,49 @@ void PropertyPanelController::showControlProperties(const std::string &control_i | |||||
| ui_.buttonOperationComboBox->setCurrentIndex( | ui_.buttonOperationComboBox->setCurrentIndex( | ||||
| ui_.buttonOperationComboBox->findData( | ui_.buttonOperationComboBox->findData( | ||||
| static_cast<int>(control->buttonOperation))); | static_cast<int>(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<HmiButtonBitEnableCondition>( | |||||
| *control->buttonEnableCondition)) | |||||
| { | |||||
| const auto &condition = std::get<HmiButtonBitEnableCondition>( | |||||
| *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<HmiButtonWordEnableCondition>( | |||||
| *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 | const bool is_numeric = control->type == HmiControlType::NumericDisplay | ||||
| || control->type == HmiControlType::NumericInput; | || control->type == HmiControlType::NumericInput; | ||||
| @@ -700,6 +749,35 @@ void PropertyPanelController::configureStatusText() | |||||
| status_reporter_(QObject::tr("状态文本配置已更新"), 3000); | 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() | void PropertyPanelController::applySelectedLogicNodeProperties() | ||||
| { | { | ||||
| const LogicNode *node = logic_editor_service_.findNode( | const LogicNode *node = logic_editor_service_.findNode( | ||||
| @@ -91,6 +91,8 @@ private: | |||||
| void chooseTextColor(); | void chooseTextColor(); | ||||
| /** 打开状态文本专用配置并原子更新控件 */ | /** 打开状态文本专用配置并原子更新控件 */ | ||||
| void configureStatusText(); | void configureStatusText(); | ||||
| /** 打开按钮启用条件专用配置并原子更新控件 */ | |||||
| void configureButtonExtension(); | |||||
| /** 统一报告属性操作失败 */ | /** 统一报告属性操作失败 */ | ||||
| void reportFailure( | void reportFailure( | ||||
| const QString &action, const std::string &message) const; | const QString &action, const std::string &message) const; | ||||
| @@ -502,6 +502,61 @@ void testHmiAppearancePropertyBoundaries() | |||||
| require(project.validate(defaultProjectLimitSettings()), "unknown HMI extension properties must remain supported"); | 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<HmiButtonConditionOperator>(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() | Project makeValidProject() | ||||
| { | { | ||||
| // 构造包含 HMI 绑定和完整梯形图网络的最小合法工程作为测试基线 | // 构造包含 HMI 绑定和完整梯形图网络的最小合法工程作为测试基线 | ||||
| @@ -547,7 +602,7 @@ Project makeValidProject() | |||||
| logic.rungs.push_back(rung); | logic.rungs.push_back(rung); | ||||
| Project project; | Project project; | ||||
| project.metadata = {"sample-project", "Sample project", "3.0"}; | |||||
| project.metadata = {"sample-project", "Sample project", "4.0"}; | |||||
| project.hmiPages.push_back(page); | project.hmiPages.push_back(page); | ||||
| project.initialHmiPageId = page.id; | project.initialHmiPageId = page.id; | ||||
| project.controlLogics.push_back(logic); | project.controlLogics.push_back(logic); | ||||
| @@ -1076,6 +1131,7 @@ int main() | |||||
| testStatusTextDomainRules(); | testStatusTextDomainRules(); | ||||
| testMultiWordHmiBoundaries(); | testMultiWordHmiBoundaries(); | ||||
| testHmiAppearancePropertyBoundaries(); | testHmiAppearancePropertyBoundaries(); | ||||
| testButtonEnableConditionBoundaries(); | |||||
| testLogicNodeConfigurationBoundaries(); | testLogicNodeConfigurationBoundaries(); | ||||
| testEdgeAndCommentBoundaries(); | testEdgeAndCommentBoundaries(); | ||||
| testDataInstructionBoundaries(); | testDataInstructionBoundaries(); | ||||
| @@ -235,6 +235,45 @@ void testRuntimeUsesRegisterRepository() | |||||
| require(repository.readBit(*button.binding).value, | require(repository.readBit(*button.binding).value, | ||||
| "toggle button press must invert the current 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; | HmiControl indicator; | ||||
| indicator.id = "running"; | indicator.id = "running"; | ||||
| indicator.type = HmiControlType::Indicator; | indicator.type = HmiControlType::Indicator; | ||||
| @@ -75,7 +75,7 @@ LadderRung makeRung( | |||||
| Project makeExampleProject() | Project makeExampleProject() | ||||
| { | { | ||||
| Project project; | Project project; | ||||
| project.metadata = {"example-project", "Example project", "3.0"}; | |||||
| project.metadata = {"example-project", "Example project", "4.0"}; | |||||
| HmiPage page; | HmiPage page; | ||||
| page.id = "main-page"; | page.id = "main-page"; | ||||
| @@ -87,6 +87,11 @@ Project makeExampleProject() | |||||
| button.text = "Start"; | button.text = "Start"; | ||||
| button.binding = RegisterAddress{RegisterArea::M, 0}; | button.binding = RegisterAddress{RegisterArea::M, 0}; | ||||
| button.buttonOperation = HmiButtonOperation::SetOn; | button.buttonOperation = HmiButtonOperation::SetOn; | ||||
| button.buttonEnableCondition = HmiButtonWordEnableCondition{ | |||||
| RegisterAddress{RegisterArea::D, 100}, | |||||
| RegisterDataType::Int16, | |||||
| HmiButtonConditionOperator::GreaterThanOrEqual, | |||||
| 10.0}; | |||||
| page.controls.push_back(button); | page.controls.push_back(button); | ||||
| project.hmiPages.push_back(page); | project.hmiPages.push_back(page); | ||||
| project.initialHmiPageId = page.id; | project.initialHmiPageId = page.id; | ||||
| @@ -168,7 +173,7 @@ void testEmptyProjectRoundTrip() | |||||
| ProjectService service(storage, defaultProjectLimitSettings()); | ProjectService service(storage, defaultProjectLimitSettings()); | ||||
| require(service.createNewProject("Empty project").succeeded, | require(service.createNewProject("Empty project").succeeded, | ||||
| "empty project creation must succeed"); | "empty project creation must succeed"); | ||||
| require(service.project().metadata.formatVersion == "3.0", | |||||
| require(service.project().metadata.formatVersion == "4.0", | |||||
| "new projects must use the strict grid format"); | "new projects must use the strict grid format"); | ||||
| const QString path = directory.filePath("empty.json"); | const QString path = directory.filePath("empty.json"); | ||||
| @@ -193,15 +198,15 @@ void testGridProjectRoundTrip() | |||||
| "the grid project must save"); | "the grid project must save"); | ||||
| const QByteArray json = readBytes(path); | const QByteArray json = readBytes(path); | ||||
| require(json.contains("\"formatVersion\": \"3.0\"") | |||||
| require(json.contains("\"formatVersion\": \"4.0\"") | |||||
| && json.contains("\"cells\"") | && json.contains("\"cells\"") | ||||
| && json.contains("\"verticalConnections\"") | && json.contains("\"verticalConnections\"") | ||||
| && json.contains("\"kind\": \"node\"") | && json.contains("\"kind\": \"node\"") | ||||
| && json.contains("\"kind\": \"wire\""), | && json.contains("\"kind\": \"wire\""), | ||||
| "3.0 JSON must persist cells and independent vertical edges"); | |||||
| "4.0 JSON must persist cells and independent vertical edges"); | |||||
| require(!json.contains("\"condition\"") | require(!json.contains("\"condition\"") | ||||
| && !json.contains("\"children\""), | && !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()); | const ProjectLoadResult loaded = storage.load(path.toStdString()); | ||||
| require(loaded.succeeded, "the grid project must load"); | require(loaded.succeeded, "the grid project must load"); | ||||
| @@ -278,12 +283,12 @@ void testMultiWordHmiDataTypeRoundTrip() | |||||
| project.hmiPages.front().controls.push_back(double_input); | project.hmiPages.front().controls.push_back(double_input); | ||||
| require(storage.save(project, path.toStdString()).succeeded, | require(storage.save(project, path.toStdString()).succeeded, | ||||
| "Int32 and Double HMI controls must save in a 3.0 project"); | |||||
| "Int32 and Double HMI controls must save in a 4.0 project"); | |||||
| const QByteArray json = readBytes(path); | const QByteArray json = readBytes(path); | ||||
| require(json.contains("\"dataType\": \"int32\"") | require(json.contains("\"dataType\": \"int32\"") | ||||
| && json.contains("\"dataType\": \"float64\"") | && json.contains("\"dataType\": \"float64\"") | ||||
| && json.contains("\"formatVersion\": \"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()); | const ProjectLoadResult loaded = storage.load(path.toStdString()); | ||||
| require(loaded.succeeded | require(loaded.succeeded | ||||
| @@ -314,6 +319,40 @@ void testMultiWordHmiDataTypeRoundTrip() | |||||
| "unknown HMI data types must be rejected by strict JSON loading"); | "unknown HMI data types must be rejected by strict JSON loading"); | ||||
| } | } | ||||
| void 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<HmiButtonWordEnableCondition>( | |||||
| *loaded_button.buttonEnableCondition).address | |||||
| == RegisterAddress{RegisterArea::D, 120} | |||||
| && std::get<HmiButtonWordEnableCondition>( | |||||
| *loaded_button.buttonEnableCondition).value == 12.5, | |||||
| "button enable condition type, address and value must survive round trip"); | |||||
| } | |||||
| void testStatusTextRoundTrip() | void testStatusTextRoundTrip() | ||||
| { | { | ||||
| QTemporaryDir directory; | QTemporaryDir directory; | ||||
| @@ -343,7 +382,7 @@ void testStatusTextRoundTrip() | |||||
| project.hmiPages.front().controls.push_back(word_status); | project.hmiPages.front().controls.push_back(word_status); | ||||
| require(storage.save(project, path.toStdString()).succeeded, | 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); | const QByteArray json = readBytes(path); | ||||
| require(json.contains("\"type\": \"statusText\"") | require(json.contains("\"type\": \"statusText\"") | ||||
| && json.contains("\"source\": \"m\"") | && json.contains("\"source\": \"m\"") | ||||
| @@ -351,7 +390,7 @@ void testStatusTextRoundTrip() | |||||
| && json.contains("\"lower\": null") | && json.contains("\"lower\": null") | ||||
| && json.contains("\"upper\": null") | && json.contains("\"upper\": null") | ||||
| && json.contains("\"dataType\": \"float64\""), | && 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()); | const ProjectLoadResult loaded = storage.load(path.toStdString()); | ||||
| require(loaded.succeeded | require(loaded.succeeded | ||||
| @@ -383,6 +422,13 @@ void testStrictVersionAndRequiredFields() | |||||
| == ProjectStorageError::UnsupportedVersion, | == ProjectStorageError::UnsupportedVersion, | ||||
| "old 2.0 files must be rejected without migration code"); | "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(); | root = QJsonDocument::fromJson(readBytes(fixture_path)).object(); | ||||
| QJsonArray logics = root.value(QStringLiteral("controlLogics")).toArray(); | QJsonArray logics = root.value(QStringLiteral("controlLogics")).toArray(); | ||||
| QJsonObject logic = logics.at(0).toObject(); | QJsonObject logic = logics.at(0).toObject(); | ||||
| @@ -393,7 +439,7 @@ void testStrictVersionAndRequiredFields() | |||||
| writeBytes(missing_path, QJsonDocument(root).toJson()); | writeBytes(missing_path, QJsonDocument(root).toJson()); | ||||
| require(storage.load(missing_path.toStdString()).error | require(storage.load(missing_path.toStdString()).error | ||||
| == ProjectStorageError::MissingField, | == ProjectStorageError::MissingField, | ||||
| "3.0 must require the vertical connection array explicitly"); | |||||
| "4.0 must require the vertical connection array explicitly"); | |||||
| } | } | ||||
| void testInvalidGridAndConnectionsAreRejected() | void testInvalidGridAndConnectionsAreRejected() | ||||
| @@ -463,7 +509,7 @@ void testInvalidGridAndConnectionsAreRejected() | |||||
| writeBytes(hidden_comment_path, QJsonDocument(hidden_comment).toJson()); | writeBytes(hidden_comment_path, QJsonDocument(hidden_comment).toJson()); | ||||
| require(storage.load(hidden_comment_path.toStdString()).error | require(storage.load(hidden_comment_path.toStdString()).error | ||||
| == ProjectStorageError::InvalidProject, | == ProjectStorageError::InvalidProject, | ||||
| "a 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; | QJsonObject non_adjacent = original; | ||||
| logic = firstLogic(&non_adjacent); | logic = firstLogic(&non_adjacent); | ||||
| @@ -554,6 +600,7 @@ int main() | |||||
| testMOffAlarmRoundTrip(); | testMOffAlarmRoundTrip(); | ||||
| testMultiWordHmiDataTypeRoundTrip(); | testMultiWordHmiDataTypeRoundTrip(); | ||||
| testStatusTextRoundTrip(); | testStatusTextRoundTrip(); | ||||
| testButtonEnableConditionRoundTrip(); | |||||
| testStrictVersionAndRequiredFields(); | testStrictVersionAndRequiredFields(); | ||||
| testInvalidGridAndConnectionsAreRejected(); | testInvalidGridAndConnectionsAreRejected(); | ||||
| testProjectServiceStateAndConfiguredLimits(); | testProjectServiceStateAndConfiguredLimits(); | ||||
| @@ -181,6 +181,17 @@ void testModeTransitions() | |||||
| {std::nullopt, 0.0, "Low"}, | {std::nullopt, 0.0, "Low"}, | ||||
| {0.0, std::nullopt, "High"}}}; | {0.0, std::nullopt, "High"}}}; | ||||
| page.controls.push_back(double_status); | 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.initialHmiPageId = page.id; | ||||
| project.hmiPages.push_back(page); | project.hmiPages.push_back(page); | ||||
| project.alarmDefinitions.push_back( | project.alarmDefinitions.push_back( | ||||
| @@ -296,6 +307,7 @@ void testModeTransitions() | |||||
| {RegisterArea::M, 12}, {RegisterArea::M, 20}, | {RegisterArea::M, 12}, {RegisterArea::M, 20}, | ||||
| {RegisterArea::M, 21}, {RegisterArea::M, 22}, | {RegisterArea::M, 21}, {RegisterArea::M, 22}, | ||||
| {RegisterArea::M, 27}, {RegisterArea::M, 28}, | {RegisterArea::M, 27}, {RegisterArea::M, 28}, | ||||
| {RegisterArea::M, 30}, | |||||
| {RegisterArea::D, 34}, {RegisterArea::D, 35}, | {RegisterArea::D, 34}, {RegisterArea::D, 35}, | ||||
| {RegisterArea::D, 38}, {RegisterArea::D, 39}, | {RegisterArea::D, 38}, {RegisterArea::D, 39}, | ||||
| {RegisterArea::D, 40}, {RegisterArea::D, 41}, | {RegisterArea::D, 40}, {RegisterArea::D, 41}, | ||||
| @@ -303,12 +315,14 @@ void testModeTransitions() | |||||
| {RegisterArea::D, 60}, {RegisterArea::D, 61}, | {RegisterArea::D, 60}, {RegisterArea::D, 61}, | ||||
| {RegisterArea::D, 62}, {RegisterArea::D, 63}, | {RegisterArea::D, 62}, {RegisterArea::D, 63}, | ||||
| {RegisterArea::D, 70}, {RegisterArea::D, 71}, | {RegisterArea::D, 70}, {RegisterArea::D, 71}, | ||||
| {RegisterArea::D, 72}, {RegisterArea::D, 73}}) | |||||
| {RegisterArea::D, 72}, {RegisterArea::D, 73}, | |||||
| {RegisterArea::D, 80}, {RegisterArea::D, 81}}) | |||||
| && gateway.pollRanges() | && gateway.pollRanges() | ||||
| == std::vector<RegisterWordRange>({ | == std::vector<RegisterWordRange>({ | ||||
| {{RegisterArea::D, 50}, 2}, | {{RegisterArea::D, 50}, 2}, | ||||
| {{RegisterArea::D, 60}, 4}, | {{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"); | "instructions and multi-word status text must be fully polled while comments stay metadata-only"); | ||||
| gateway.completeInitialRead(); | gateway.completeInitialRead(); | ||||
| require(service.initialPlcReadCompleted(), | require(service.initialPlcReadCompleted(), | ||||