소스 검색

feat: 优化报警显示并支持M OFF条件

main
suyu 3 주 전
부모
커밋
6345148bb7
11개의 변경된 파일194개의 추가작업 그리고 54개의 파일을 삭제
  1. +3
    -2
      app/src/domain/alarm_model.cpp
  2. +2
    -1
      app/src/domain/alarm_model.h
  3. +8
    -0
      app/src/infrastructure/json_project_storage.cpp
  4. +9
    -3
      app/src/services/alarm_service.cpp
  5. +1
    -1
      app/src/services/alarm_service.h
  6. +8
    -1
      app/src/ui/alarm_configuration_dialog.cpp
  7. +6
    -43
      app/src/ui/hmi_editor_widget.cpp
  8. +21
    -3
      app/tests/alarm_service_tests.cpp
  9. +30
    -0
      app/tests/project_management_tests.cpp
  10. +103
    -0
      app/tests/runtime_panel_controller_tests.cpp
  11. +3
    -0
      app/tests/runtime_panel_controller_tests.pro

+ 3
- 2
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;


+ 2
- 1
app/src/domain/alarm_model.h 파일 보기

@@ -5,10 +5,11 @@
#include <cstdint>
#include <string>

// 报警触发条件:M 位为 1,或 D 字高于/低于阈值
// 报警触发条件:M 位为 1/0,或 D 字高于/低于阈值
enum class AlarmCondition
{
MOn, // M 位为 1 时触发
MOff, // M 位为 0 时触发
DHigh, // D 寄存器值高于阈值时触发
DLow // D 寄存器值低于阈值时触发
};


+ 8
- 0
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;


+ 9
- 3
app/src/services/alarm_service.cpp 파일 보기

@@ -78,11 +78,17 @@ const std::vector<AlarmRecord> &AlarmService::records() const
std::optional<bool> 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<bool>{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);


+ 1
- 1
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<bool> evaluate(const AlarmDefinition &definition) const;



+ 8
- 1
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<int>(AlarmCondition::MOn));
ui_->conditionComboBox->addItem(
conditionText(AlarmCondition::MOff),
static_cast<int>(AlarmCondition::MOff));
}
else
{


+ 6
- 43
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<AlarmRecord> &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<qreal>(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
{


+ 21
- 3
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();


+ 30
- 0
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();


+ 103
- 0
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 <QCoreApplication>
#include <QEventLoop>
#include <QGraphicsLineItem>
#include <QGraphicsRectItem>
#include <QGraphicsScene>
#include <QGraphicsSimpleTextItem>
#include <QImage>
#include <QKeyEvent>
#include <QLabel>
#include <QLineEdit>
#include <QComboBox>
#include <QMouseEvent>
#include <QPainter>
#include <QWidget>
@@ -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<QComboBox *>(
QStringLiteral("areaComboBox"));
QComboBox *condition_combo = dialog.findChild<QComboBox *>(
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<int>(RegisterArea::M)
&& condition_combo->count() == 2
&& condition_combo->findData(
static_cast<int>(AlarmCondition::MOn)) >= 0
&& condition_combo->findData(
static_cast<int>(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<QGraphicsRectItem *>(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<qreal>(control->bounds.width) + 1.0)
&& qFuzzyCompare(
fixed_bounds.height() + 1.0,
static_cast<qreal>(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();


+ 3
- 0
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

불러오는 중...
취소
저장