| @@ -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 \ | |||
| @@ -7,6 +7,7 @@ | |||
| #include <cctype> | |||
| #include <charconv> | |||
| #include <cmath> | |||
| #include <limits> | |||
| namespace { | |||
| @@ -183,6 +184,77 @@ bool validateAppearanceProperty( | |||
| 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 | |||
| 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; | |||
| } | |||
| @@ -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<std::string, std::string> properties; | |||
| HmiButtonOperation buttonOperation = HmiButtonOperation::MomentaryOn; | |||
| std::optional<HmiButtonEnableCondition> buttonEnableCondition; | |||
| std::optional<HmiPageJumpConfig> pageJump; | |||
| std::optional<HmiStatusTextConfig> statusText; | |||
| @@ -26,7 +26,7 @@ struct ProjectMetadata | |||
| // 工程显示名称 | |||
| std::string name; | |||
| // 工程文件格式版本 | |||
| std::string formatVersion = "3.0"; | |||
| std::string formatVersion = "4.0"; | |||
| }; | |||
| // 聚合工程中的 HMI 页面、报警、寄存器注释和控制逻辑 | |||
| @@ -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<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) | |||
| { | |||
| 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) | |||
| { | |||
| @@ -2,7 +2,7 @@ | |||
| #include "domain/project_storage.h" | |||
| // 严格读写当前 3.0 JSON 工程格式 | |||
| // 严格读写当前 4.0 JSON 工程格式 | |||
| class JsonProjectStorage final : public ProjectStorage | |||
| { | |||
| public: | |||
| @@ -137,6 +137,7 @@ bool HmiEditorService::controlsEqual( | |||
| && left.dataType == right.dataType | |||
| && left.properties == right.properties | |||
| && left.buttonOperation == right.buttonOperation | |||
| && left.buttonEnableCondition == right.buttonEnableCondition | |||
| && left.statusText == right.statusText | |||
| && page_jump_equal; | |||
| } | |||
| @@ -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<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( | |||
| 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) | |||
| { | |||
| @@ -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 地址 | |||
| @@ -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; | |||
| } | |||
| @@ -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) | |||
| @@ -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: | |||
| { | |||
| 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; | |||
| } | |||
| @@ -706,13 +706,23 @@ | |||
| </widget> | |||
| </item> | |||
| <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"> | |||
| <property name="text"> | |||
| <string>字体颜色</string> | |||
| </property> | |||
| </widget> | |||
| </item> | |||
| <item row="10" column="1"> | |||
| <item row="11" column="1"> | |||
| <widget class="QWidget" name="textColorContainer"> | |||
| <layout class="QHBoxLayout" name="textColorLayout"> | |||
| <property name="leftMargin"><number>0</number></property> | |||
| @@ -736,24 +746,24 @@ | |||
| </layout> | |||
| </widget> | |||
| </item> | |||
| <item row="11" column="0"> | |||
| <item row="12" column="0"> | |||
| <widget class="QLabel" name="fontSizeLabel"> | |||
| <property name="text"> | |||
| <string>字号</string> | |||
| </property> | |||
| </widget> | |||
| </item> | |||
| <item row="11" column="1"> | |||
| <item row="12" column="1"> | |||
| <widget class="QSpinBox" name="fontSizeSpinBox"/> | |||
| </item> | |||
| <item row="12" column="0"> | |||
| <item row="13" column="0"> | |||
| <widget class="QLabel" name="fontBoldLabel"> | |||
| <property name="text"> | |||
| <string>字体样式</string> | |||
| </property> | |||
| </widget> | |||
| </item> | |||
| <item row="12" column="1"> | |||
| <item row="13" column="1"> | |||
| <widget class="QWidget" name="fontStyleContainer"> | |||
| <layout class="QHBoxLayout" name="fontStyleLayout"> | |||
| <property name="leftMargin"><number>0</number></property> | |||
| @@ -773,12 +783,12 @@ | |||
| </layout> | |||
| </widget> | |||
| </item> | |||
| <item row="13" column="0"> | |||
| <item row="14" column="0"> | |||
| <widget class="QLabel" name="dataTypeLabel"> | |||
| <property name="text"><string>数值类型</string></property> | |||
| </widget> | |||
| </item> | |||
| <item row="13" column="1"> | |||
| <item row="14" 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> | |||
| @@ -786,17 +796,17 @@ | |||
| <item><property name="text"><string>Double (Float64)</string></property></item> | |||
| </widget> | |||
| </item> | |||
| <item row="14" column="0"> | |||
| <item row="15" column="0"> | |||
| <widget class="QLabel" name="statusTextConfigLabel"> | |||
| <property name="text"><string>状态映射</string></property> | |||
| </widget> | |||
| </item> | |||
| <item row="14" column="1"> | |||
| <item row="15" column="1"> | |||
| <widget class="QPushButton" name="configureStatusTextButton"> | |||
| <property name="text"><string>配置</string></property> | |||
| </widget> | |||
| </item> | |||
| <item row="15" column="0" colspan="2"> | |||
| <item row="16" column="0" colspan="2"> | |||
| <widget class="QPushButton" name="applyPropertiesButton"> | |||
| <property name="text"> | |||
| <string>应用属性</string> | |||
| @@ -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<QWidget *>(ui_.bindingIndexSpinBox), | |||
| static_cast<QWidget *>(ui_.targetPageComboBox), | |||
| static_cast<QWidget *>(ui_.buttonOperationComboBox), | |||
| static_cast<QWidget *>(ui_.configureButtonExtensionButton), | |||
| static_cast<QWidget *>(ui_.dataTypeComboBox), | |||
| static_cast<QWidget *>(ui_.configureStatusTextButton), | |||
| static_cast<QWidget *>(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<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 | |||
| || 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( | |||
| @@ -91,6 +91,8 @@ private: | |||
| void chooseTextColor(); | |||
| /** 打开状态文本专用配置并原子更新控件 */ | |||
| void configureStatusText(); | |||
| /** 打开按钮启用条件专用配置并原子更新控件 */ | |||
| void configureButtonExtension(); | |||
| /** 统一报告属性操作失败 */ | |||
| void reportFailure( | |||
| 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"); | |||
| } | |||
| 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() | |||
| { | |||
| // 构造包含 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(); | |||
| @@ -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; | |||
| @@ -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<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() | |||
| { | |||
| 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(); | |||
| @@ -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<RegisterWordRange>({ | |||
| {{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(), | |||