From 6345148bb79bda19e1c43177789f1836d14bc246 Mon Sep 17 00:00:00 2001 From: suyu <1643689728@qq.com> Date: Wed, 26 Aug 2026 20:37:51 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E6=8A=A5=E8=AD=A6?= =?UTF-8?q?=E6=98=BE=E7=A4=BA=E5=B9=B6=E6=94=AF=E6=8C=81M=20OFF=E6=9D=A1?= =?UTF-8?q?=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/domain/alarm_model.cpp | 5 +- app/src/domain/alarm_model.h | 3 +- .../infrastructure/json_project_storage.cpp | 8 ++ app/src/services/alarm_service.cpp | 12 +- app/src/services/alarm_service.h | 2 +- app/src/ui/alarm_configuration_dialog.cpp | 9 +- app/src/ui/hmi_editor_widget.cpp | 49 +-------- app/tests/alarm_service_tests.cpp | 24 +++- app/tests/project_management_tests.cpp | 30 +++++ app/tests/runtime_panel_controller_tests.cpp | 103 ++++++++++++++++++ app/tests/runtime_panel_controller_tests.pro | 3 + 11 files changed, 194 insertions(+), 54 deletions(-) diff --git a/app/src/domain/alarm_model.cpp b/app/src/domain/alarm_model.cpp index 2f47164..1891ecc 100644 --- a/app/src/domain/alarm_model.cpp +++ b/app/src/domain/alarm_model.cpp @@ -41,11 +41,12 @@ bool AlarmDefinition::validate(std::string *error) const setError(error, "报警定义使用了无效地址"); return false; } - if (condition == AlarmCondition::MOn) + if (condition == AlarmCondition::MOn + || condition == AlarmCondition::MOff) { if (address.area() != RegisterArea::M) { - setError(error, "M ON 报警必须使用 M 地址"); + setError(error, "M ON/OFF 报警必须使用 M 地址"); return false; } return true; diff --git a/app/src/domain/alarm_model.h b/app/src/domain/alarm_model.h index 570456d..e4d3f8f 100644 --- a/app/src/domain/alarm_model.h +++ b/app/src/domain/alarm_model.h @@ -5,10 +5,11 @@ #include #include -// 报警触发条件:M 位为 1,或 D 字高于/低于阈值 +// 报警触发条件:M 位为 1/0,或 D 字高于/低于阈值 enum class AlarmCondition { MOn, // M 位为 1 时触发 + MOff, // M 位为 0 时触发 DHigh, // D 寄存器值高于阈值时触发 DLow // D 寄存器值低于阈值时触发 }; diff --git a/app/src/infrastructure/json_project_storage.cpp b/app/src/infrastructure/json_project_storage.cpp index a364069..260bb12 100644 --- a/app/src/infrastructure/json_project_storage.cpp +++ b/app/src/infrastructure/json_project_storage.cpp @@ -434,6 +434,10 @@ QString alarmConditionName(AlarmCondition condition) { return QStringLiteral("mOn"); } + case AlarmCondition::MOff: + { + return QStringLiteral("mOff"); + } case AlarmCondition::DHigh: { return QStringLiteral("dHigh"); @@ -456,6 +460,10 @@ bool parseAlarmCondition( { *condition = AlarmCondition::MOn; } + else if (value == "mOff") + { + *condition = AlarmCondition::MOff; + } else if (value == "dHigh") { *condition = AlarmCondition::DHigh; diff --git a/app/src/services/alarm_service.cpp b/app/src/services/alarm_service.cpp index a6ab385..c923b0b 100644 --- a/app/src/services/alarm_service.cpp +++ b/app/src/services/alarm_service.cpp @@ -78,11 +78,17 @@ const std::vector &AlarmService::records() const std::optional AlarmService::evaluate( const AlarmDefinition &definition) const { - if (definition.condition == AlarmCondition::MOn) + if (definition.condition == AlarmCondition::MOn + || definition.condition == AlarmCondition::MOff) { - // MOn 报警直接读取 M 位;读取失败返回空值,交由 refresh 保留旧记录 + // M ON/OFF 共用一次位读取;读取失败时保留旧记录 const BitReadResult value = repository_.readBit(definition.address); - return value.succeeded ? std::optional{value.value} : std::nullopt; + if (!value.succeeded) + { + return std::nullopt; + } + return definition.condition == AlarmCondition::MOn + ? value.value : !value.value; } // DHigh 和 DLow 报警读取 D 字,比较方向由定义中的条件决定 const WordReadResult value = repository_.readWord(definition.address); diff --git a/app/src/services/alarm_service.h b/app/src/services/alarm_service.h index b3dc57c..f66fe81 100644 --- a/app/src/services/alarm_service.h +++ b/app/src/services/alarm_service.h @@ -64,7 +64,7 @@ private: * @param definition 待评估的报警定义 * @return true 表示触发,false 表示未触发;读取失败时返回空值 * - * MOn 比较 M 位是否为 1;DHigh 使用大于等于阈值,DLow 使用小于等于阈值 + * MOn/MOff 比较 M 位是否为 1/0;DHigh 使用大于等于阈值,DLow 使用小于等于阈值 */ std::optional evaluate(const AlarmDefinition &definition) const; diff --git a/app/src/ui/alarm_configuration_dialog.cpp b/app/src/ui/alarm_configuration_dialog.cpp index 168b40b..c94f5ad 100644 --- a/app/src/ui/alarm_configuration_dialog.cpp +++ b/app/src/ui/alarm_configuration_dialog.cpp @@ -29,6 +29,10 @@ QString conditionText(AlarmCondition condition) { return AlarmConfigurationDialog::tr("M 为 ON"); } + case AlarmCondition::MOff: + { + return AlarmConfigurationDialog::tr("M 为 OFF"); + } case AlarmCondition::DHigh: { return AlarmConfigurationDialog::tr("D 大于等于阈值"); @@ -109,7 +113,7 @@ void AlarmConfigurationDialog::reloadDefinitions(const std::string &selected_id) row, 3, new QTableWidgetItem( - definition.condition == AlarmCondition::MOn + definition.address.area() == RegisterArea::M ? QStringLiteral("-") : QString::number(definition.threshold))); ui_->alarmTable->setItem( @@ -162,6 +166,9 @@ void AlarmConfigurationDialog::updateConditionOptions() ui_->conditionComboBox->addItem( conditionText(AlarmCondition::MOn), static_cast(AlarmCondition::MOn)); + ui_->conditionComboBox->addItem( + conditionText(AlarmCondition::MOff), + static_cast(AlarmCondition::MOff)); } else { diff --git a/app/src/ui/hmi_editor_widget.cpp b/app/src/ui/hmi_editor_widget.cpp index dd154bb..0ed1172 100644 --- a/app/src/ui/hmi_editor_widget.cpp +++ b/app/src/ui/hmi_editor_widget.cpp @@ -83,9 +83,9 @@ public: { if (!control_.binding.has_value()) { - return visibleControlRect(); + return controlRect(); } - return visibleControlRect().united(addressRect()); + return controlRect().united(addressRect()); } void paint( @@ -94,7 +94,7 @@ public: QWidget *) override { // 留出一个像素边距,避免描边被图元边界裁剪 - const QRectF rect = visibleControlRect().adjusted(1, 1, -1, -1); + const QRectF rect = controlRect().adjusted(1, 1, -1, -1); painter->setRenderHint(QPainter::Antialiasing, true); painter->setPen(QPen(QColor(QStringLiteral("#47545f")), 1)); @@ -363,13 +363,9 @@ public: void setInteractionState( bool editable, bool runtime_active, bool runtime_write_enabled) { - if (control_.type == HmiControlType::AlarmList) + if (control_.type == HmiControlType::AlarmList && !runtime_active) { - prepareGeometryChange(); - if (!runtime_active) - { - alarm_page_ = 0U; - } + alarm_page_ = 0U; } editing_enabled_ = editable; runtime_active_ = runtime_active; @@ -412,7 +408,6 @@ public: page_hovered_ = false; button_pressed_ = false; } - updateAlarmVisibility(); update(); } @@ -429,15 +424,10 @@ public: void setAlarmRecords(const std::vector &records) { - if (control_.type == HmiControlType::AlarmList) - { - prepareGeometryChange(); - } alarm_records_ = records; const std::size_t page_count = alarmPageCount(); alarm_page_ = page_count == 0U ? 0U : std::min(alarm_page_, page_count - 1U); - updateAlarmVisibility(); update(); } @@ -469,7 +459,7 @@ protected: if (event->pos().y() < kAlarmHeaderHeight) { const QRectF content_rect = - visibleControlRect().adjusted(1, 1, -1, -1); + controlRect().adjusted(1, 1, -1, -1); const QRectF header( content_rect.left(), content_rect.top(), @@ -644,22 +634,6 @@ private: alarmPageSize(), alarm_records_.size() - first_record); } - QRectF visibleControlRect() const - { - const QRectF full_rect = controlRect(); - if (control_.type != HmiControlType::AlarmList || !runtime_active_) - { - return full_rect; - } - const qreal content_height = kAlarmHeaderHeight - + static_cast(visibleAlarmRecordCount()) * kAlarmRowHeight - + 2.0; - return {full_rect.left(), - full_rect.top(), - full_rect.width(), - std::min(full_rect.height(), content_height)}; - } - QRectF alarmNextPageRect(const QRectF &header) const { return { @@ -689,17 +663,6 @@ private: kAlarmPageButtonSize}; } - void updateAlarmVisibility() - { - if (control_.type != HmiControlType::AlarmList) - { - return; - } - const bool has_active_alarm = !alarm_records_.empty(); - setVisible(editing_enabled_ || !runtime_active_ || has_active_alarm); - setZValue(runtime_active_ && has_active_alarm ? 1000.0 : 0.0); - } - // 控件本体范围保持领域坐标和尺寸语义不变 QRectF controlRect() const { diff --git a/app/tests/alarm_service_tests.cpp b/app/tests/alarm_service_tests.cpp index 1f32357..7cec25d 100644 --- a/app/tests/alarm_service_tests.cpp +++ b/app/tests/alarm_service_tests.cpp @@ -44,6 +44,13 @@ void testAlarmDefinitionsAndRuntimeLifecycle() m_alarm.message = "Emergency stop"; const AlarmEditorResult m_result = editor_service.addDefinition(m_alarm); + AlarmDefinition m_off_alarm; + m_off_alarm.address = RegisterAddress{RegisterArea::M, 1}; + m_off_alarm.condition = AlarmCondition::MOff; + m_off_alarm.message = "Safety circuit open"; + const AlarmEditorResult m_off_result = editor_service.addDefinition( + m_off_alarm); + AlarmDefinition high_alarm; high_alarm.address = RegisterAddress{RegisterArea::D, 0}; high_alarm.condition = AlarmCondition::DHigh; @@ -57,8 +64,9 @@ void testAlarmDefinitionsAndRuntimeLifecycle() low_alarm.threshold = 10; low_alarm.message = "Pressure low"; const AlarmEditorResult low_result = editor_service.addDefinition(low_alarm); - require(m_result.succeeded && high_result.succeeded && low_result.succeeded, - "M, D high and D low alarm definitions must be accepted"); + require(m_result.succeeded && m_off_result.succeeded + && high_result.succeeded && low_result.succeeded, + "M ON, M OFF, D high and D low alarms must be accepted"); AlarmDefinition invalid = high_alarm; invalid.address = RegisterAddress{RegisterArea::M, 2}; @@ -66,24 +74,34 @@ void testAlarmDefinitionsAndRuntimeLifecycle() == AlarmEditorError::InvalidDefinition, "D alarm conditions must reject M addresses"); + invalid = m_off_alarm; + invalid.address = RegisterAddress{RegisterArea::D, 2}; + require(editor_service.addDefinition(invalid).error + == AlarmEditorError::InvalidDefinition, + "M OFF alarm conditions must reject D addresses"); + + repository.writeBit(RegisterAddress{RegisterArea::M, 1}, true); repository.writeWord(RegisterAddress{RegisterArea::D, 1}, 20); alarm_service.refresh(); require(alarm_service.records().empty(), "inactive alarm conditions must not create records"); repository.writeBit(RegisterAddress{RegisterArea::M, 0}, true); + repository.writeBit(RegisterAddress{RegisterArea::M, 1}, false); repository.writeWord(RegisterAddress{RegisterArea::D, 0}, 80); repository.writeWord(RegisterAddress{RegisterArea::D, 1}, 10); alarm_service.refresh(); require(findRecord(alarm_service, m_result.id) != nullptr + && findRecord(alarm_service, m_off_result.id) != nullptr && findRecord(alarm_service, high_result.id) != nullptr && findRecord(alarm_service, low_result.id) != nullptr, - "all three alarm conditions must create active records"); + "all four alarm conditions must create active records"); require(alarm_service.acknowledge(m_result.id) && findRecord(alarm_service, m_result.id)->acknowledged, "an active alarm must be locally acknowledged"); repository.writeBit(RegisterAddress{RegisterArea::M, 0}, false); + repository.writeBit(RegisterAddress{RegisterArea::M, 1}, true); repository.writeWord(RegisterAddress{RegisterArea::D, 0}, 79); repository.writeWord(RegisterAddress{RegisterArea::D, 1}, 11); alarm_service.refresh(); diff --git a/app/tests/project_management_tests.cpp b/app/tests/project_management_tests.cpp index 76f88a6..49c9ba2 100644 --- a/app/tests/project_management_tests.cpp +++ b/app/tests/project_management_tests.cpp @@ -221,6 +221,35 @@ void testGridProjectRoundTrip() "typed ladder instructions must survive grid round trip"); } +void testMOffAlarmRoundTrip() +{ + QTemporaryDir directory; + require(directory.isValid(), "temporary directory must be valid"); + JsonProjectStorage storage; + const QString path = directory.filePath("m-off-alarm.json"); + Project project = makeExampleProject(); + project.alarmDefinitions.push_back({ + "alarm-m-off", + RegisterAddress{RegisterArea::M, 5}, + AlarmCondition::MOff, + 0, + "Safety circuit open"}); + + require(storage.save(project, path.toStdString()).succeeded, + "an M OFF alarm project must save"); + require(readBytes(path).contains("\"condition\": \"mOff\""), + "M OFF alarms must serialize with the explicit mOff condition"); + + const ProjectLoadResult loaded = storage.load(path.toStdString()); + require(loaded.succeeded + && loaded.project.alarmDefinitions.size() == 1U + && loaded.project.alarmDefinitions.front().condition + == AlarmCondition::MOff + && loaded.project.alarmDefinitions.front().address + == RegisterAddress{RegisterArea::M, 5}, + "the M OFF condition and address must survive JSON round trip"); +} + void testStrictVersionAndRequiredFields() { QTemporaryDir directory; @@ -390,6 +419,7 @@ int main() { testEmptyProjectRoundTrip(); testGridProjectRoundTrip(); + testMOffAlarmRoundTrip(); testStrictVersionAndRequiredFields(); testInvalidGridAndConnectionsAreRejected(); testProjectServiceStateAndConfiguredLimits(); diff --git a/app/tests/runtime_panel_controller_tests.cpp b/app/tests/runtime_panel_controller_tests.cpp index b274857..30e97ba 100644 --- a/app/tests/runtime_panel_controller_tests.cpp +++ b/app/tests/runtime_panel_controller_tests.cpp @@ -1,4 +1,5 @@ #include "domain/virtual_register_repository.h" +#include "services/alarm_editor_service.h" #include "services/alarm_service.h" #include "services/hmi_editor_service.h" #include "services/hmi_navigation_service.h" @@ -9,6 +10,7 @@ #include "services/register_monitor_service.h" #include "services/runtime_mode_service.h" #include "support/test_support.h" +#include "ui/alarm_configuration_dialog.h" #include "ui/hmi_editor_widget.h" #include "ui/logic_editor_widget.h" #include "ui/runtime_monitor_widget.h" @@ -18,12 +20,14 @@ #include #include #include +#include #include #include #include #include #include #include +#include #include #include #include @@ -66,6 +70,103 @@ ControlLogic makeAlwaysOnLogic() return logic; } +void testAlarmConfigurationOffersMOnAndMOff() +{ + TestProjectStorage storage; + ProjectService project_service(storage); + AlarmEditorService alarm_editor_service(project_service); + AlarmConfigurationDialog dialog(alarm_editor_service); + + QComboBox *area_combo = dialog.findChild( + QStringLiteral("areaComboBox")); + QComboBox *condition_combo = dialog.findChild( + QStringLiteral("conditionComboBox")); + require(area_combo != nullptr && condition_combo != nullptr, + "alarm configuration must expose its area and condition inputs"); + require(area_combo->currentData().toInt() + == static_cast(RegisterArea::M) + && condition_combo->count() == 2 + && condition_combo->findData( + static_cast(AlarmCondition::MOn)) >= 0 + && condition_combo->findData( + static_cast(AlarmCondition::MOff)) >= 0 + && condition_combo->findText(QStringLiteral("M 为 ON")) >= 0 + && condition_combo->findText(QStringLiteral("M 为 OFF")) >= 0, + "M alarm configuration must offer both ON and OFF conditions"); +} + +void testAlarmListKeepsFixedGeometryWhileRecordsChange() +{ + TestProjectStorage storage; + ProjectService project_service(storage); + VirtualRegisterRepository virtual_repository; + HmiEditorService hmi_editor_service(project_service); + HmiRuntimeService hmi_runtime_service(virtual_repository); + AlarmService alarm_service(project_service, virtual_repository); + + const HmiEditorResult page = hmi_editor_service.ensureDefaultPage(); + const HmiEditorResult alarm_list = hmi_editor_service.addControl( + page.id, HmiControlType::AlarmList); + require(page.succeeded && alarm_list.succeeded, + "alarm-list fixture must create a page and control"); + project_service.editProject().alarmDefinitions.push_back({ + "fixed-alarm", + RegisterAddress{RegisterArea::M, 0}, + AlarmCondition::MOn, + 0, + "Fixed alarm"}); + + HmiEditorWidget widget( + hmi_editor_service, hmi_runtime_service, alarm_service); + widget.setPageId(page.id); + widget.setEditingEnabled(false); + widget.setRuntimeActive(true); + widget.refreshRuntimeValues(); + + QGraphicsItem *alarm_item = nullptr; + for (QGraphicsItem *item : widget.scene()->items()) + { + if (dynamic_cast(item) == nullptr) + { + alarm_item = item; + break; + } + } + const HmiControl *control = hmi_editor_service.findControl( + page.id, alarm_list.id); + require(alarm_item != nullptr && control != nullptr, + "runtime HMI scene must contain the alarm-list item"); + const QRectF fixed_bounds = alarm_item->boundingRect(); + require(alarm_item->isVisible() + && qFuzzyCompare( + fixed_bounds.width() + 1.0, + static_cast(control->bounds.width) + 1.0) + && qFuzzyCompare( + fixed_bounds.height() + 1.0, + static_cast(control->bounds.height) + 1.0), + "an empty runtime alarm list must remain visible at its configured size"); + + require(virtual_repository.writeBit( + RegisterAddress{RegisterArea::M, 0}, true).succeeded, + "alarm-list fixture must activate M0"); + alarm_service.refresh(); + widget.refreshRuntimeValues(); + require(alarm_service.records().size() == 1U + && alarm_item->isVisible() + && alarm_item->boundingRect() == fixed_bounds, + "adding an alarm row must not resize or hide the alarm control"); + + require(virtual_repository.writeBit( + RegisterAddress{RegisterArea::M, 0}, false).succeeded, + "alarm-list fixture must restore M0"); + alarm_service.refresh(); + widget.refreshRuntimeValues(); + require(alarm_service.records().empty() + && alarm_item->isVisible() + && alarm_item->boundingRect() == fixed_bounds, + "removing the last alarm row must keep the fixed alarm control visible"); +} + void testQueuedOfflineTraceIsIgnoredAfterReturningToEditing() { TestProjectStorage storage; @@ -856,6 +957,8 @@ int main(int argc, char *argv[]) QApplication application(argc, argv); try { + testAlarmConfigurationOffersMOnAndMOff(); + testAlarmListKeepsFixedGeometryWhileRecordsChange(); testQueuedOfflineTraceIsIgnoredAfterReturningToEditing(); testLogicEditorGridSelectionAndDeletion(); testLadderLayoutAndDragDeletion(); diff --git a/app/tests/runtime_panel_controller_tests.pro b/app/tests/runtime_panel_controller_tests.pro index 862f2c7..c9eaec8 100644 --- a/app/tests/runtime_panel_controller_tests.pro +++ b/app/tests/runtime_panel_controller_tests.pro @@ -15,6 +15,7 @@ SOURCES += \ $$SERVICE_OFFLINE_SOURCES \ $$SERVICE_RUNTIME_SOURCES \ $$SERVICE_MONITOR_SOURCES \ + ../src/ui/alarm_configuration_dialog.cpp \ ../src/ui/hmi_editor_widget.cpp \ ../src/ui/logic_editor_widget.cpp \ ../src/ui/free_monitor_widget.cpp \ @@ -33,6 +34,7 @@ HEADERS += \ $$SERVICE_RUNTIME_HEADERS \ $$SERVICE_MONITOR_HEADERS \ ../src/services/plc_communication_gateway.h \ + ../src/ui/alarm_configuration_dialog.h \ ../src/ui/hmi_editor_widget.h \ ../src/ui/logic_editor_widget.h \ ../src/ui/free_monitor_widget.h \ @@ -43,6 +45,7 @@ HEADERS += \ $$TEST_SUPPORT_HEADERS FORMS += \ + ../src/ui/alarm_configuration_dialog.ui \ ../src/ui/free_monitor_widget.ui \ ../src/ui/runtime_monitor_window.ui \ ../src/ui/runtime_monitor_widget.ui