From 8ed43bfedb691f992aa1b8bd3b82856a6afc9ea6 Mon Sep 17 00:00:00 2001 From: suyu <1643689728@qq.com> Date: Mon, 17 Aug 2026 08:46:51 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0HMI=E6=8A=A5=E8=AD=A6?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E4=B8=8E=E8=BF=90=E8=A1=8C=E6=98=BE=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/integrated_platform.pro | 9 + app/src/domain/alarm_model.cpp | 53 ++++ app/src/domain/alarm_model.h | 24 ++ app/src/domain/hmi_model.cpp | 5 +- app/src/domain/hmi_model.h | 3 +- app/src/domain/project_model.cpp | 12 + app/src/domain/project_model.h | 3 + .../infrastructure/json_project_storage.cpp | 130 ++++++++++ app/src/main.cpp | 6 + app/src/services/alarm_editor_service.cpp | 103 ++++++++ app/src/services/alarm_editor_service.h | 42 +++ app/src/services/alarm_service.cpp | 114 ++++++++ app/src/services/alarm_service.h | 44 ++++ app/src/services/hmi_editor_service.cpp | 26 +- app/src/services/runtime_mode_service.cpp | 4 + app/src/ui/alarm_configuration_dialog.cpp | 245 ++++++++++++++++++ app/src/ui/alarm_configuration_dialog.h | 35 +++ app/src/ui/alarm_configuration_dialog.ui | 49 ++++ app/src/ui/hmi_editor_widget.cpp | 147 ++++++++++- app/src/ui/hmi_editor_widget.h | 3 + app/src/ui/main_window.cpp | 50 +++- app/src/ui/main_window.h | 9 + app/src/ui/main_window.ui | 18 ++ app/src/ui/runtime_monitor_widget.cpp | 7 +- app/src/ui/runtime_monitor_widget.h | 2 + app/tests/alarm_service_tests.cpp | 140 ++++++++++ app/tests/alarm_service_tests.pro | 33 +++ app/tests/domain_tests.pro | 2 + app/tests/hmi_editor_service_tests.cpp | 11 + app/tests/hmi_editor_service_tests.pro | 2 + app/tests/logic_editor_service_tests.pro | 2 + app/tests/main_window_tests.cpp | 97 ++++++- app/tests/main_window_tests.pro | 9 + app/tests/plc_runtime_tests.pro | 2 + app/tests/project_management_tests.cpp | 50 +++- app/tests/project_management_tests.pro | 2 + app/tests/runtime_mode_service_tests.cpp | 22 +- app/tests/runtime_mode_service_tests.pro | 2 + 38 files changed, 1494 insertions(+), 23 deletions(-) create mode 100644 app/src/domain/alarm_model.cpp create mode 100644 app/src/domain/alarm_model.h create mode 100644 app/src/services/alarm_editor_service.cpp create mode 100644 app/src/services/alarm_editor_service.h create mode 100644 app/src/services/alarm_service.cpp create mode 100644 app/src/services/alarm_service.h create mode 100644 app/src/ui/alarm_configuration_dialog.cpp create mode 100644 app/src/ui/alarm_configuration_dialog.h create mode 100644 app/src/ui/alarm_configuration_dialog.ui create mode 100644 app/tests/alarm_service_tests.cpp create mode 100644 app/tests/alarm_service_tests.pro diff --git a/app/integrated_platform.pro b/app/integrated_platform.pro index e01a980..275de21 100644 --- a/app/integrated_platform.pro +++ b/app/integrated_platform.pro @@ -17,6 +17,7 @@ INCLUDEPATH += src SOURCES += \ src/main.cpp \ src/ui/main_window.cpp \ + src/ui/alarm_configuration_dialog.cpp \ src/ui/plc_connection_dialog.cpp \ src/ui/free_monitor_widget.cpp \ src/ui/runtime_monitor_widget.cpp \ @@ -24,11 +25,14 @@ SOURCES += \ src/domain/register_repository.cpp \ src/domain/active_register_repository.cpp \ src/domain/register_monitor_model.cpp \ + src/domain/alarm_model.cpp \ src/domain/hmi_model.cpp \ src/domain/control_logic_model.cpp \ src/domain/project_model.cpp \ src/domain/runtime_state.cpp \ src/services/project_service.cpp \ + src/services/alarm_editor_service.cpp \ + src/services/alarm_service.cpp \ src/services/hmi_editor_service.cpp \ src/services/hmi_navigation_service.cpp \ src/services/logic_editor_service.cpp \ @@ -46,6 +50,7 @@ SOURCES += \ HEADERS += \ src/ui/main_window.h \ + src/ui/alarm_configuration_dialog.h \ src/ui/plc_connection_dialog.h \ src/ui/free_monitor_widget.h \ src/ui/runtime_monitor_widget.h \ @@ -53,12 +58,15 @@ HEADERS += \ src/domain/register_repository.h \ src/domain/active_register_repository.h \ src/domain/register_monitor_model.h \ + src/domain/alarm_model.h \ src/domain/hmi_model.h \ src/domain/control_logic_model.h \ src/domain/project_model.h \ src/domain/runtime_state.h \ src/domain/project_storage.h \ src/services/project_service.h \ + src/services/alarm_editor_service.h \ + src/services/alarm_service.h \ src/services/hmi_editor_service.h \ src/services/hmi_navigation_service.h \ src/services/logic_editor_service.h \ @@ -77,6 +85,7 @@ HEADERS += \ FORMS += \ src/ui/main_window.ui \ + src/ui/alarm_configuration_dialog.ui \ src/ui/plc_connection_dialog.ui \ src/ui/free_monitor_widget.ui \ src/ui/runtime_monitor_widget.ui diff --git a/app/src/domain/alarm_model.cpp b/app/src/domain/alarm_model.cpp new file mode 100644 index 0000000..536836f --- /dev/null +++ b/app/src/domain/alarm_model.cpp @@ -0,0 +1,53 @@ +#include "alarm_model.h" + +namespace { + +void setError(std::string *error, const std::string &message) +{ + if (error != nullptr) + { + *error = message; + } +} + +} // namespace + +bool AlarmDefinition::validate(std::string *error) const +{ + if (id.empty()) + { + setError(error, "报警定义 ID 不能为空"); + return false; + } + if (message.empty()) + { + setError(error, "报警文本不能为空"); + return false; + } + if (!address.isValid()) + { + setError(error, "报警定义使用了无效地址"); + return false; + } + if (condition == AlarmCondition::MOn) + { + if (address.area() != RegisterArea::M) + { + setError(error, "M ON 报警必须使用 M 地址"); + return false; + } + return true; + } + if (condition == AlarmCondition::DHigh + || condition == AlarmCondition::DLow) + { + if (address.area() != RegisterArea::D) + { + setError(error, "D 高限和低限报警必须使用 D 地址"); + return false; + } + return true; + } + setError(error, "不支持的报警触发条件"); + return false; +} diff --git a/app/src/domain/alarm_model.h b/app/src/domain/alarm_model.h new file mode 100644 index 0000000..c0859e3 --- /dev/null +++ b/app/src/domain/alarm_model.h @@ -0,0 +1,24 @@ +#pragma once + +#include "register_address.h" + +#include +#include + +enum class AlarmCondition +{ + MOn, + DHigh, + DLow +}; + +struct AlarmDefinition +{ + std::string id; + RegisterAddress address{RegisterArea::M, 0}; + AlarmCondition condition = AlarmCondition::MOn; + std::int16_t threshold = 0; + std::string message; + + bool validate(std::string *error = nullptr) const; +}; diff --git a/app/src/domain/hmi_model.cpp b/app/src/domain/hmi_model.cpp index 37b2663..202f5a2 100644 --- a/app/src/domain/hmi_model.cpp +++ b/app/src/domain/hmi_model.cpp @@ -40,6 +40,7 @@ bool isSupportedControlType(HmiControlType type) case HmiControlType::NumericInput: case HmiControlType::Label: case HmiControlType::PageJump: + case HmiControlType::AlarmList: return true; } return false; @@ -95,7 +96,7 @@ bool HmiControl::validate(std::string *error) const } if (!supportsRegisterBinding(type) && binding.has_value()) { - setError(error, "文本和页面跳转控件不能绑定寄存器"); + setError(error, "文本、页面跳转和报警列表控件不能绑定寄存器"); return false; } if (type == HmiControlType::PageJump) @@ -116,7 +117,7 @@ bool HmiControl::validate(std::string *error) const bool HmiControl::isConfigured() const { - if (type == HmiControlType::Label) + if (type == HmiControlType::Label || type == HmiControlType::AlarmList) { return true; } diff --git a/app/src/domain/hmi_model.h b/app/src/domain/hmi_model.h index f3f66bb..395aa3f 100644 --- a/app/src/domain/hmi_model.h +++ b/app/src/domain/hmi_model.h @@ -42,7 +42,8 @@ enum class HmiControlType NumericDisplay, // 数值显示 NumericInput, // 数值输入 Label, // 标签 - PageJump // 页面跳转 + PageJump, // 页面跳转 + AlarmList // 报警列表 }; /** diff --git a/app/src/domain/project_model.cpp b/app/src/domain/project_model.cpp index 956969d..ccf4ad9 100644 --- a/app/src/domain/project_model.cpp +++ b/app/src/domain/project_model.cpp @@ -101,6 +101,18 @@ bool Project::validate(std::string *error) const setError(error, "工程内的控制逻辑名称必须唯一"); return false; } + if (containsDuplicateId(alarmDefinitions)) + { + setError(error, "工程内的报警定义 ID 必须唯一"); + return false; + } + for (const AlarmDefinition &definition : alarmDefinitions) + { + if (!definition.validate(error)) + { + return false; + } + } for (const HmiPage &page : hmiPages) { // 工程聚合校验会向下委托页面和控件的完整规则 diff --git a/app/src/domain/project_model.h b/app/src/domain/project_model.h index 748314c..992527b 100644 --- a/app/src/domain/project_model.h +++ b/app/src/domain/project_model.h @@ -1,5 +1,6 @@ #pragma once +#include "alarm_model.h" #include "control_logic_model.h" #include "hmi_model.h" @@ -26,6 +27,8 @@ struct Project std::vector hmiPages; // 进入运行态时显示的初始 HMI 页面 std::string initialHmiPageId; + // 工程统一管理的报警定义 + std::vector alarmDefinitions; // 工程包含的控制逻辑集合 std::vector controlLogics; diff --git a/app/src/infrastructure/json_project_storage.cpp b/app/src/infrastructure/json_project_storage.cpp index 674d0d6..fdf677a 100644 --- a/app/src/infrastructure/json_project_storage.cpp +++ b/app/src/infrastructure/json_project_storage.cpp @@ -282,6 +282,10 @@ QString hmiControlTypeName(HmiControlType type) { return QStringLiteral("pageJump"); } + case HmiControlType::AlarmList: + { + return QStringLiteral("alarmList"); + } default: { return {}; @@ -317,6 +321,10 @@ bool parseHmiControlType( { *type = HmiControlType::PageJump; } + else if (value == "alarmList") + { + *type = HmiControlType::AlarmList; + } else { return state->fail( @@ -378,6 +386,98 @@ bool parseHmiButtonOperation( return true; } +QString alarmConditionName(AlarmCondition condition) +{ + switch (condition) + { + case AlarmCondition::MOn: + { + return QStringLiteral("mOn"); + } + case AlarmCondition::DHigh: + { + return QStringLiteral("dHigh"); + } + case AlarmCondition::DLow: + { + return QStringLiteral("dLow"); + } + default: + { + return {}; + } + } +} + +bool parseAlarmCondition( + const std::string &value, AlarmCondition *condition, ParseState *state) +{ + if (value == "mOn") + { + *condition = AlarmCondition::MOn; + } + else if (value == "dHigh") + { + *condition = AlarmCondition::DHigh; + } + else if (value == "dLow") + { + *condition = AlarmCondition::DLow; + } + else + { + return state->fail( + ProjectStorageError::InvalidField, + "不支持的报警触发条件:" + value); + } + return true; +} + +QJsonObject serializeAlarmDefinition(const AlarmDefinition &definition) +{ + QJsonObject object; + object.insert(QStringLiteral("id"), fromUtf8(definition.id)); + object.insert(QStringLiteral("address"), serializeAddress(definition.address)); + object.insert( + QStringLiteral("condition"), alarmConditionName(definition.condition)); + object.insert(QStringLiteral("threshold"), definition.threshold); + object.insert(QStringLiteral("message"), fromUtf8(definition.message)); + return object; +} + +bool parseAlarmDefinition( + const QJsonObject &object, + const std::string &context, + AlarmDefinition *definition, + ParseState *state) +{ + QJsonObject address; + std::string condition; + int threshold = 0; + if (!readString(object, "id", context, &definition->id, state) + || !readObject(object, "address", context, &address, state) + || !readString(object, "condition", context, &condition, state) + || !readInt( + object, + "threshold", + context, + std::numeric_limits::min(), + std::numeric_limits::max(), + &threshold, + state) + || !readString(object, "message", context, &definition->message, state)) + { + return false; + } + if (!parseAddress(address, context + ".address", &definition->address, state) + || !parseAlarmCondition(condition, &definition->condition, state)) + { + return false; + } + definition->threshold = static_cast(threshold); + return true; +} + // 将 HMI 控件矩形区域序列化为 JSON 对象 QJsonObject serializeBounds(const HmiRect &bounds) { @@ -1164,6 +1264,12 @@ QJsonObject serializeProject(const Project &project) logics.append(serializeControlLogic(logic)); } + QJsonArray alarms; + for (const AlarmDefinition &definition : project.alarmDefinitions) + { + alarms.append(serializeAlarmDefinition(definition)); + } + QJsonObject object; object.insert(QStringLiteral("formatVersion"), fromUtf8(project.metadata.formatVersion)); object.insert(QStringLiteral("id"), fromUtf8(project.metadata.id)); @@ -1171,6 +1277,7 @@ QJsonObject serializeProject(const Project &project) object.insert(QStringLiteral("hmiPages"), pages); object.insert(QStringLiteral("initialHmiPageId"), fromUtf8(project.initialHmiPageId)); + object.insert(QStringLiteral("alarmDefinitions"), alarms); object.insert(QStringLiteral("controlLogics"), logics); return object; } @@ -1180,6 +1287,7 @@ bool parseProject( const QJsonObject &object, Project *project, ParseState *state) { QJsonArray pages; + QJsonArray alarms; QJsonArray logics; if (!readString( object, @@ -1203,6 +1311,7 @@ bool parseProject( || !readArray(object, "hmiPages", "project", &pages, state) || !readString(object, "initialHmiPageId", "project", &project->initialHmiPageId, state) + || !readArray(object, "alarmDefinitions", "project", &alarms, state) || !readArray(object, "controlLogics", "project", &logics, state)) { return false; @@ -1230,6 +1339,27 @@ bool parseProject( project->hmiPages.push_back(std::move(page)); } + project->alarmDefinitions.reserve(static_cast(alarms.size())); + for (int index = 0; index < alarms.size(); ++index) + { + if (!alarms.at(index).isObject()) + { + return state->fail( + ProjectStorageError::InvalidField, + "project.alarmDefinitions 的元素必须是对象"); + } + AlarmDefinition definition; + if (!parseAlarmDefinition( + alarms.at(index).toObject(), + "project.alarmDefinitions[" + std::to_string(index) + ']', + &definition, + state)) + { + return false; + } + project->alarmDefinitions.push_back(std::move(definition)); + } + project->controlLogics.reserve(static_cast(logics.size())); for (int index = 0; index < logics.size(); ++index) { diff --git a/app/src/main.cpp b/app/src/main.cpp index 60a323b..33c4b57 100644 --- a/app/src/main.cpp +++ b/app/src/main.cpp @@ -14,6 +14,8 @@ #include "domain/active_register_repository.h" #include "services/hmi_editor_service.h" #include "services/hmi_runtime_service.h" +#include "services/alarm_editor_service.h" +#include "services/alarm_service.h" #include "services/hmi_navigation_service.h" #include "services/logic_editor_service.h" #include "services/offline_simulation_service.h" @@ -40,6 +42,8 @@ int main(int argc, char *argv[]) ActiveRegisterRepository active_register_repository(virtual_register_repository); PlcCommunicationService plc_communication_service(plc_register_repository); HmiRuntimeService hmi_runtime_service(active_register_repository); + AlarmEditorService alarm_editor_service(project_service); + AlarmService alarm_service(project_service, active_register_repository); RegisterMonitorService register_monitor_service(active_register_repository); OfflineSimulationService offline_simulation_service(virtual_register_repository); RuntimeModeService runtime_mode_service( @@ -55,6 +59,8 @@ int main(int argc, char *argv[]) hmi_editor_service, logic_editor_service, hmi_runtime_service, + alarm_editor_service, + alarm_service, hmi_navigation_service, register_monitor_service); main_window.show(); diff --git a/app/src/services/alarm_editor_service.cpp b/app/src/services/alarm_editor_service.cpp new file mode 100644 index 0000000..a19471f --- /dev/null +++ b/app/src/services/alarm_editor_service.cpp @@ -0,0 +1,103 @@ +#include "alarm_editor_service.h" + +#include "project_service.h" + +#include + +namespace { + +AlarmEditorResult failure(AlarmEditorError error, const std::string &message) +{ + return {false, error, message, {}}; +} + +} // namespace + +AlarmEditorService::AlarmEditorService(ProjectService &project_service) + : project_service_(project_service) +{ +} + +const std::vector &AlarmEditorService::definitions() const +{ + return project_service_.project().alarmDefinitions; +} + +const AlarmDefinition *AlarmEditorService::findDefinition( + const std::string &id) const +{ + const auto &definitions = project_service_.project().alarmDefinitions; + const auto definition = std::find_if( + definitions.cbegin(), definitions.cend(), + [&id](const AlarmDefinition &candidate) { return candidate.id == id; }); + return definition == definitions.cend() ? nullptr : &*definition; +} + +AlarmEditorResult AlarmEditorService::addDefinition( + const AlarmDefinition &definition) +{ + AlarmDefinition candidate = definition; + candidate.id = makeUniqueId(); + std::string error; + if (!candidate.validate(&error)) + { + return failure(AlarmEditorError::InvalidDefinition, error); + } + project_service_.editProject().alarmDefinitions.push_back(candidate); + return {true, AlarmEditorError::None, {}, candidate.id}; +} + +AlarmEditorResult AlarmEditorService::updateDefinition( + const std::string &id, const AlarmDefinition &definition) +{ + if (findDefinition(id) == nullptr) + { + return failure(AlarmEditorError::NotFound, "未找到报警定义"); + } + AlarmDefinition candidate = definition; + candidate.id = id; + std::string error; + if (!candidate.validate(&error)) + { + return failure(AlarmEditorError::InvalidDefinition, error); + } + + Project &project = project_service_.editProject(); + auto editable = std::find_if( + project.alarmDefinitions.begin(), project.alarmDefinitions.end(), + [&id](const AlarmDefinition &item) { return item.id == id; }); + *editable = std::move(candidate); + return {true, AlarmEditorError::None, {}, id}; +} + +AlarmEditorResult AlarmEditorService::removeDefinition(const std::string &id) +{ + if (findDefinition(id) == nullptr) + { + return failure(AlarmEditorError::NotFound, "未找到报警定义"); + } + Project &project = project_service_.editProject(); + project.alarmDefinitions.erase( + std::remove_if( + project.alarmDefinitions.begin(), project.alarmDefinitions.end(), + [&id](const AlarmDefinition &definition) + { + return definition.id == id; + }), + project.alarmDefinitions.end()); + return {true, AlarmEditorError::None, {}, id}; +} + +std::string AlarmEditorService::makeUniqueId() const +{ + int suffix = 1; + while (true) + { + const std::string candidate = "alarm-" + std::to_string(suffix); + if (findDefinition(candidate) == nullptr) + { + return candidate; + } + ++suffix; + } +} diff --git a/app/src/services/alarm_editor_service.h b/app/src/services/alarm_editor_service.h new file mode 100644 index 0000000..376bbe3 --- /dev/null +++ b/app/src/services/alarm_editor_service.h @@ -0,0 +1,42 @@ +#pragma once + +#include "domain/alarm_model.h" + +#include +#include + +class ProjectService; + +enum class AlarmEditorError +{ + None, + NotFound, + DuplicateId, + InvalidDefinition +}; + +struct AlarmEditorResult +{ + bool succeeded = false; + AlarmEditorError error = AlarmEditorError::None; + std::string message; + std::string id; +}; + +class AlarmEditorService +{ +public: + explicit AlarmEditorService(ProjectService &project_service); + + const std::vector &definitions() const; + const AlarmDefinition *findDefinition(const std::string &id) const; + AlarmEditorResult addDefinition(const AlarmDefinition &definition); + AlarmEditorResult updateDefinition( + const std::string &id, const AlarmDefinition &definition); + AlarmEditorResult removeDefinition(const std::string &id); + +private: + std::string makeUniqueId() const; + + ProjectService &project_service_; +}; diff --git a/app/src/services/alarm_service.cpp b/app/src/services/alarm_service.cpp new file mode 100644 index 0000000..b04ef82 --- /dev/null +++ b/app/src/services/alarm_service.cpp @@ -0,0 +1,114 @@ +#include "alarm_service.h" + +#include "domain/alarm_model.h" +#include "domain/register_repository.h" +#include "project_service.h" + +#include + +AlarmService::AlarmService( + const ProjectService &project_service, + RegisterRepository &repository) + : project_service_(project_service), + repository_(repository) +{ +} + +void AlarmService::refresh() +{ + const auto now = std::chrono::system_clock::now(); + for (const AlarmDefinition &definition + : project_service_.project().alarmDefinitions) + { + const std::optional active = evaluate(definition); + if (!active.has_value()) + { + continue; + } + const auto current = std::find_if( + records_.begin(), records_.end(), + [&definition](const AlarmRecord &record) + { + return record.definitionId == definition.id && record.active; + }); + if (*active && current == records_.end()) + { + records_.insert( + records_.begin(), + AlarmRecord{definition.id, definition.message, true, false, now, {}}); + } + else if (!*active && current != records_.end()) + { + current->active = false; + current->clearedAt = now; + } + } + trimHistory(); +} + +bool AlarmService::acknowledge(const std::string &definition_id) +{ + const auto record = std::find_if( + records_.begin(), records_.end(), + [&definition_id](const AlarmRecord &candidate) + { + return candidate.definitionId == definition_id && candidate.active; + }); + if (record == records_.end()) + { + return false; + } + record->acknowledged = true; + return true; +} + +void AlarmService::reset() +{ + records_.clear(); +} + +const std::vector &AlarmService::records() const +{ + return records_; +} + +std::optional AlarmService::evaluate( + const AlarmDefinition &definition) const +{ + if (definition.condition == AlarmCondition::MOn) + { + const BitReadResult value = repository_.readBit(definition.address); + return value.succeeded ? std::optional{value.value} : std::nullopt; + } + const WordReadResult value = repository_.readWord(definition.address); + if (!value.succeeded) + { + return std::nullopt; + } + if (definition.condition == AlarmCondition::DHigh) + { + return value.value >= definition.threshold; + } + if (definition.condition == AlarmCondition::DLow) + { + return value.value <= definition.threshold; + } + return std::nullopt; +} + +void AlarmService::trimHistory() +{ + std::size_t history_count = static_cast(std::count_if( + records_.cbegin(), records_.cend(), + [](const AlarmRecord &record) { return !record.active; })); + for (auto record = records_.end(); + record != records_.begin() && history_count > kHistoryLimit;) + { + --record; + if (!record->active) + { + record = records_.erase(record); + --history_count; + } + } +} diff --git a/app/src/services/alarm_service.h b/app/src/services/alarm_service.h new file mode 100644 index 0000000..e51ceb7 --- /dev/null +++ b/app/src/services/alarm_service.h @@ -0,0 +1,44 @@ +#pragma once + +#include +#include +#include +#include +#include + +class ProjectService; +class RegisterRepository; +struct AlarmDefinition; + +struct AlarmRecord +{ + std::string definitionId; + std::string message; + bool active = true; + bool acknowledged = false; + std::chrono::system_clock::time_point occurredAt; + std::optional clearedAt; +}; + +class AlarmService +{ +public: + AlarmService( + const ProjectService &project_service, + RegisterRepository &repository); + + void refresh(); + bool acknowledge(const std::string &definition_id); + void reset(); + const std::vector &records() const; + +private: + std::optional evaluate(const AlarmDefinition &definition) const; + void trimHistory(); + + static constexpr std::size_t kHistoryLimit = 20; + + const ProjectService &project_service_; + RegisterRepository &repository_; + std::vector records_; +}; diff --git a/app/src/services/hmi_editor_service.cpp b/app/src/services/hmi_editor_service.cpp index aa0e8aa..0ab8c6f 100644 --- a/app/src/services/hmi_editor_service.cpp +++ b/app/src/services/hmi_editor_service.cpp @@ -45,6 +45,10 @@ std::string controlPrefix(HmiControlType type) { return "page-jump"; } + case HmiControlType::AlarmList: + { + return "alarm-list"; + } default: { return "control"; @@ -80,6 +84,10 @@ std::string defaultText(HmiControlType type) { return "页面跳转"; } + case HmiControlType::AlarmList: + { + return "报警信息"; + } default: { return "控件"; @@ -484,6 +492,7 @@ bool HmiEditorService::validateEditableControl( case HmiControlType::NumericInput: case HmiControlType::Label: case HmiControlType::PageJump: + case HmiControlType::AlarmList: break; default: setError(error, "不支持的 HMI 控件类型"); @@ -533,10 +542,11 @@ bool HmiEditorService::validateEditableControl( return false; } if ((control.type == HmiControlType::Label - || control.type == HmiControlType::PageJump) + || control.type == HmiControlType::PageJump + || control.type == HmiControlType::AlarmList) && control.binding.has_value()) { - setError(error, "文本和页面跳转控件不能绑定寄存器"); + setError(error, "文本、页面跳转和报警列表控件不能绑定寄存器"); return false; } if (control.type == HmiControlType::PageJump) @@ -582,8 +592,16 @@ HmiControl HmiEditorService::makeControl( control.id = makeUniqueId(page, controlPrefix(type)); control.type = type; control.text = defaultText(type); - control.bounds.width = type == HmiControlType::Indicator ? 64 : 120; - control.bounds.height = type == HmiControlType::Indicator ? 64 : 40; + if (type == HmiControlType::AlarmList) + { + control.bounds.width = std::min(360, page.width); + control.bounds.height = std::min(180, page.height); + } + else + { + control.bounds.width = type == HmiControlType::Indicator ? 64 : 120; + control.bounds.height = type == HmiControlType::Indicator ? 64 : 40; + } if (type == HmiControlType::PageJump) { control.pageJump = HmiPageJumpConfig{}; diff --git a/app/src/services/runtime_mode_service.cpp b/app/src/services/runtime_mode_service.cpp index 6e9dcd4..ae19a0a 100644 --- a/app/src/services/runtime_mode_service.cpp +++ b/app/src/services/runtime_mode_service.cpp @@ -219,6 +219,10 @@ void RuntimeModeService::refreshPlcPollAddresses() } } } + for (const AlarmDefinition &definition : project.alarmDefinitions) + { + addresses.push_back(definition.address); + } for (const ControlLogic &logic : project.controlLogics) { for (const LadderRung &rung : logic.rungs) diff --git a/app/src/ui/alarm_configuration_dialog.cpp b/app/src/ui/alarm_configuration_dialog.cpp new file mode 100644 index 0000000..d7a5169 --- /dev/null +++ b/app/src/ui/alarm_configuration_dialog.cpp @@ -0,0 +1,245 @@ +#include "alarm_configuration_dialog.h" + +#include "services/alarm_editor_service.h" +#include "ui_alarm_configuration_dialog.h" + +#include +#include +#include + +namespace { + +QString fromUtf8(const std::string &value) +{ + return QString::fromUtf8(value.data(), static_cast(value.size())); +} + +std::string toUtf8(const QString &value) +{ + const QByteArray bytes = value.toUtf8(); + return std::string(bytes.constData(), static_cast(bytes.size())); +} + +QString conditionText(AlarmCondition condition) +{ + switch (condition) + { + case AlarmCondition::MOn: + { + return AlarmConfigurationDialog::tr("M 为 ON"); + } + case AlarmCondition::DHigh: + { + return AlarmConfigurationDialog::tr("D 大于等于阈值"); + } + case AlarmCondition::DLow: + { + return AlarmConfigurationDialog::tr("D 小于等于阈值"); + } + default: + { + return {}; + } + } +} + +} // namespace + +AlarmConfigurationDialog::AlarmConfigurationDialog( + AlarmEditorService &service, + QWidget *parent) + : QDialog(parent), + ui_(std::make_unique()), + service_(service) +{ + ui_->setupUi(this); + ui_->alarmTable->horizontalHeader()->setSectionResizeMode( + 0, QHeaderView::ResizeToContents); + ui_->alarmTable->horizontalHeader()->setSectionResizeMode( + 1, QHeaderView::ResizeToContents); + ui_->alarmTable->horizontalHeader()->setSectionResizeMode( + 2, QHeaderView::ResizeToContents); + ui_->alarmTable->horizontalHeader()->setSectionResizeMode( + 3, QHeaderView::ResizeToContents); + ui_->alarmTable->horizontalHeader()->setSectionResizeMode( + 4, QHeaderView::Stretch); + ui_->areaComboBox->setItemData(0, static_cast(RegisterArea::M)); + ui_->areaComboBox->setItemData(1, static_cast(RegisterArea::D)); + connect(ui_->areaComboBox, + QOverload::of(&QComboBox::currentIndexChanged), + this, + [this] { updateConditionOptions(); }); + connect(ui_->alarmTable, &QTableWidget::itemSelectionChanged, + this, [this] { loadSelectedDefinition(); }); + connect(ui_->addButton, &QPushButton::clicked, + this, [this] { addDefinition(); }); + connect(ui_->updateButton, &QPushButton::clicked, + this, [this] { updateDefinition(); }); + connect(ui_->removeButton, &QPushButton::clicked, + this, [this] { removeDefinition(); }); + connect(ui_->buttonBox, &QDialogButtonBox::rejected, + this, &QDialog::reject); + updateConditionOptions(); + reloadDefinitions(); +} + +AlarmConfigurationDialog::~AlarmConfigurationDialog() = default; + +void AlarmConfigurationDialog::reloadDefinitions(const std::string &selected_id) +{ + const auto &definitions = service_.definitions(); + ui_->alarmTable->setRowCount(static_cast(definitions.size())); + int selected_row = -1; + for (std::size_t index = 0; index < definitions.size(); ++index) + { + const AlarmDefinition &definition = definitions[index]; + const int row = static_cast(index); + auto *id_item = new QTableWidgetItem(fromUtf8(definition.id)); + id_item->setData(Qt::UserRole, fromUtf8(definition.id)); + ui_->alarmTable->setItem(row, 0, id_item); + ui_->alarmTable->setItem( + row, 1, new QTableWidgetItem(QString::fromStdString( + definition.address.toString()))); + ui_->alarmTable->setItem( + row, 2, new QTableWidgetItem(conditionText(definition.condition))); + ui_->alarmTable->setItem( + row, + 3, + new QTableWidgetItem( + definition.condition == AlarmCondition::MOn + ? QStringLiteral("-") + : QString::number(definition.threshold))); + ui_->alarmTable->setItem( + row, 4, new QTableWidgetItem(fromUtf8(definition.message))); + if (definition.id == selected_id) + { + selected_row = row; + } + } + if (selected_row >= 0) + { + ui_->alarmTable->selectRow(selected_row); + } + else + { + ui_->alarmTable->clearSelection(); + ui_->updateButton->setEnabled(false); + ui_->removeButton->setEnabled(false); + } +} + +void AlarmConfigurationDialog::loadSelectedDefinition() +{ + const AlarmDefinition *definition = service_.findDefinition(selectedId()); + const bool selected = definition != nullptr; + ui_->updateButton->setEnabled(selected); + ui_->removeButton->setEnabled(selected); + if (!selected) + { + return; + } + ui_->areaComboBox->setCurrentIndex( + definition->address.area() == RegisterArea::M ? 0 : 1); + updateConditionOptions(); + ui_->conditionComboBox->setCurrentIndex( + ui_->conditionComboBox->findData( + static_cast(definition->condition))); + ui_->indexSpinBox->setValue(definition->address.index()); + ui_->thresholdSpinBox->setValue(definition->threshold); + ui_->messageEdit->setText(fromUtf8(definition->message)); +} + +void AlarmConfigurationDialog::updateConditionOptions() +{ + const RegisterArea area = static_cast( + ui_->areaComboBox->currentData().toInt()); + ui_->conditionComboBox->clear(); + if (area == RegisterArea::M) + { + ui_->conditionComboBox->addItem( + conditionText(AlarmCondition::MOn), + static_cast(AlarmCondition::MOn)); + } + else + { + ui_->conditionComboBox->addItem( + conditionText(AlarmCondition::DHigh), + static_cast(AlarmCondition::DHigh)); + ui_->conditionComboBox->addItem( + conditionText(AlarmCondition::DLow), + static_cast(AlarmCondition::DLow)); + } + ui_->thresholdLabel->setEnabled(area == RegisterArea::D); + ui_->thresholdSpinBox->setEnabled(area == RegisterArea::D); +} + +void AlarmConfigurationDialog::addDefinition() +{ + const AlarmEditorResult result = service_.addDefinition(definitionFromInputs()); + if (!result.succeeded) + { + QMessageBox::warning(this, tr("新增报警"), fromUtf8(result.message)); + return; + } + reloadDefinitions(result.id); +} + +void AlarmConfigurationDialog::updateDefinition() +{ + const std::string id = selectedId(); + if (id.empty()) + { + return; + } + const AlarmEditorResult result = service_.updateDefinition( + id, definitionFromInputs()); + if (!result.succeeded) + { + QMessageBox::warning(this, tr("更新报警"), fromUtf8(result.message)); + return; + } + reloadDefinitions(result.id); +} + +void AlarmConfigurationDialog::removeDefinition() +{ + const std::string id = selectedId(); + if (id.empty()) + { + return; + } + if (QMessageBox::question( + this, tr("删除报警"), tr("确定删除当前报警定义吗?")) + != QMessageBox::Yes) + { + return; + } + const AlarmEditorResult result = service_.removeDefinition(id); + if (!result.succeeded) + { + QMessageBox::warning(this, tr("删除报警"), fromUtf8(result.message)); + return; + } + reloadDefinitions(); +} + +AlarmDefinition AlarmConfigurationDialog::definitionFromInputs() const +{ + AlarmDefinition definition; + const RegisterArea area = static_cast( + ui_->areaComboBox->currentData().toInt()); + definition.address = RegisterAddress{area, ui_->indexSpinBox->value()}; + definition.condition = static_cast( + ui_->conditionComboBox->currentData().toInt()); + definition.threshold = static_cast( + ui_->thresholdSpinBox->value()); + definition.message = toUtf8(ui_->messageEdit->text().trimmed()); + return definition; +} + +std::string AlarmConfigurationDialog::selectedId() const +{ + const int row = ui_->alarmTable->currentRow(); + const QTableWidgetItem *item = row >= 0 ? ui_->alarmTable->item(row, 0) : nullptr; + return item == nullptr ? std::string{} : toUtf8(item->data(Qt::UserRole).toString()); +} diff --git a/app/src/ui/alarm_configuration_dialog.h b/app/src/ui/alarm_configuration_dialog.h new file mode 100644 index 0000000..c0df271 --- /dev/null +++ b/app/src/ui/alarm_configuration_dialog.h @@ -0,0 +1,35 @@ +#pragma once + +#include + +#include +#include + +namespace Ui { +class AlarmConfigurationDialog; +} + +class AlarmEditorService; +struct AlarmDefinition; + +class AlarmConfigurationDialog final : public QDialog +{ +public: + explicit AlarmConfigurationDialog( + AlarmEditorService &service, + QWidget *parent = nullptr); + ~AlarmConfigurationDialog() override; + +private: + void reloadDefinitions(const std::string &selected_id = {}); + void loadSelectedDefinition(); + void updateConditionOptions(); + void addDefinition(); + void updateDefinition(); + void removeDefinition(); + AlarmDefinition definitionFromInputs() const; + std::string selectedId() const; + + std::unique_ptr ui_; + AlarmEditorService &service_; +}; diff --git a/app/src/ui/alarm_configuration_dialog.ui b/app/src/ui/alarm_configuration_dialog.ui new file mode 100644 index 0000000..138db34 --- /dev/null +++ b/app/src/ui/alarm_configuration_dialog.ui @@ -0,0 +1,49 @@ + + + AlarmConfigurationDialog + + 报警配置 + 720430 + + + + QAbstractItemView::SelectRows + QAbstractItemView::SingleSelection + QAbstractItemView::NoEditTriggers + 5 + ID + 地址 + 条件 + 阈值 + 报警文本 + + + + + QFormLayout::AllNonFixedFieldsGrow + 地址区域 + MD + 地址 + 4000 + 触发条件 + + 阈值 + -3276832767 + 报警文本 + 例如:温度过高 + + + + + 新增 + 更新 + 删除 + Qt::Horizontal4020 + QDialogButtonBox::Close + + + + + + + diff --git a/app/src/ui/hmi_editor_widget.cpp b/app/src/ui/hmi_editor_widget.cpp index e0f862b..5afa64a 100644 --- a/app/src/ui/hmi_editor_widget.cpp +++ b/app/src/ui/hmi_editor_widget.cpp @@ -2,7 +2,9 @@ #include "services/hmi_editor_service.h" #include "services/hmi_runtime_service.h" +#include "services/alarm_service.h" +#include #include #include #include @@ -21,6 +23,16 @@ namespace { +constexpr qreal kAlarmHeaderHeight = 24.0; +constexpr qreal kAlarmRowHeight = 22.0; + +QString alarmTimeText(const std::chrono::system_clock::time_point &time) +{ + const auto seconds = std::chrono::duration_cast( + time.time_since_epoch()).count(); + return QDateTime::fromSecsSinceEpoch(seconds).toString(QStringLiteral("HH:mm:ss")); +} + // 将领域层 HMI 控件投影为可绘制、可选择和可交互的场景图元 class HmiGraphicsItem final : public QGraphicsItem { @@ -33,14 +45,16 @@ public: std::function moved, std::function button_event, std::function numeric_input_activated, - std::function page_navigation) + std::function page_navigation, + std::function alarm_acknowledge) : control_(control), page_width_(page_width), page_height_(page_height), moved_(std::move(moved)), button_event_(std::move(button_event)), numeric_input_activated_(std::move(numeric_input_activated)), - page_navigation_(std::move(page_navigation)) + page_navigation_(std::move(page_navigation)), + alarm_acknowledge_(std::move(alarm_acknowledge)) { // 领域坐标直接作为图元在场景中的初始位置 setPos(control_.bounds.x, control_.bounds.y); @@ -178,6 +192,83 @@ public: painter->drawText(rect, Qt::AlignCenter, textWithValue()); break; } + case HmiControlType::AlarmList: + { + painter->setPen(QPen(QColor(QStringLiteral("#9b3a3a")), 1)); + painter->setBrush(QColor(QStringLiteral("#ffffff"))); + painter->drawRect(rect); + const QRectF header( + rect.left(), rect.top(), rect.width(), kAlarmHeaderHeight); + painter->fillRect(header, QColor(QStringLiteral("#a63f3f"))); + painter->setPen(Qt::white); + painter->drawText( + header.adjusted(7, 0, -7, 0), + Qt::AlignVCenter | Qt::AlignLeft, + QString::fromUtf8( + control_.text.data(), static_cast(control_.text.size()))); + + const int maximum_rows = std::max( + 0, + static_cast((rect.height() - kAlarmHeaderHeight) + / kAlarmRowHeight)); + const int visible_rows = std::min( + maximum_rows, static_cast(alarm_records_.size())); + if (visible_rows == 0) + { + painter->setPen(QColor(QStringLiteral("#6f7a82"))); + painter->drawText( + QRectF( + rect.left(), + header.bottom(), + rect.width(), + rect.height() - kAlarmHeaderHeight), + Qt::AlignCenter, + runtime_active_ ? QObject::tr("暂无报警") + : QObject::tr("运行时显示报警记录")); + break; + } + for (int row = 0; row < visible_rows; ++row) + { + const AlarmRecord &record = alarm_records_[static_cast(row)]; + const QRectF row_rect( + rect.left(), + header.bottom() + row * kAlarmRowHeight, + rect.width(), + kAlarmRowHeight); + painter->fillRect( + row_rect, + record.active + ? QColor(QStringLiteral("#fde8e8")) + : QColor(QStringLiteral("#f0f2f3"))); + painter->setPen(QColor(QStringLiteral("#d3d8dc"))); + painter->drawLine(row_rect.bottomLeft(), row_rect.bottomRight()); + painter->setPen(record.active + ? QColor(QStringLiteral("#8d1f1f")) + : QColor(QStringLiteral("#667078"))); + const QString state = record.active + ? (record.acknowledged ? QObject::tr("已确认") + : QObject::tr("未确认")) + : QObject::tr("已清除"); + painter->drawText( + QRectF(row_rect.left() + 5, row_rect.top(), 58, row_rect.height()), + Qt::AlignVCenter | Qt::AlignLeft, + alarmTimeText(record.occurredAt)); + painter->drawText( + QRectF(row_rect.left() + 66, row_rect.top(), 52, row_rect.height()), + Qt::AlignVCenter | Qt::AlignLeft, + state); + painter->drawText( + QRectF( + row_rect.left() + 120, + row_rect.top(), + std::max(0.0, row_rect.width() - 125), + row_rect.height()), + Qt::AlignVCenter | Qt::AlignLeft, + QString::fromUtf8( + record.message.data(), static_cast(record.message.size()))); + } + break; + } case HmiControlType::Label: default: { @@ -222,9 +313,12 @@ public: const bool runtime_input = runtime_active_ && (control_.type == HmiControlType::Button || control_.type == HmiControlType::NumericInput - || control_.type == HmiControlType::PageJump); + || control_.type == HmiControlType::PageJump + || control_.type == HmiControlType::AlarmList); const bool runtime_input_enabled = runtime_input - && (control_.type == HmiControlType::PageJump || runtime_write_enabled_); + && (control_.type == HmiControlType::PageJump + || control_.type == HmiControlType::AlarmList + || runtime_write_enabled_); setAcceptedMouseButtons( editable || runtime_input_enabled ? Qt::LeftButton : Qt::NoButton); @@ -232,8 +326,11 @@ public: && control_.type == HmiControlType::Button && runtime_write_enabled_; const bool runtime_page_jump_enabled = runtime_active_ && control_.type == HmiControlType::PageJump; + const bool runtime_alarm_enabled = runtime_active_ + && control_.type == HmiControlType::AlarmList; setAcceptHoverEvents(runtime_button_enabled || runtime_page_jump_enabled); - if (runtime_button_enabled || runtime_page_jump_enabled) + if (runtime_button_enabled || runtime_page_jump_enabled + || runtime_alarm_enabled) { setCursor(Qt::PointingHandCursor); } @@ -256,6 +353,12 @@ public: update(); } + void setAlarmRecords(const std::vector &records) + { + alarm_records_ = records; + update(); + } + protected: // 拖拽过程中将新位置限制在页面可见边界内 QVariant itemChange(GraphicsItemChange change, const QVariant &value) override @@ -279,6 +382,26 @@ protected: // 运行态按钮按下时通知外层执行配置的 M 位操作 void mousePressEvent(QGraphicsSceneMouseEvent *event) override { + if (runtime_active_ && control_.type == HmiControlType::AlarmList) + { + if (event->pos().y() < kAlarmHeaderHeight) + { + event->accept(); + return; + } + const int row = static_cast( + (event->pos().y() - kAlarmHeaderHeight) / kAlarmRowHeight); + if (row >= 0 && row < static_cast(alarm_records_.size())) + { + const AlarmRecord &record = alarm_records_[static_cast(row)]; + if (record.active && !record.acknowledged && alarm_acknowledge_) + { + alarm_acknowledge_(record.definitionId); + } + } + event->accept(); + return; + } if (runtime_active_ && control_.type == HmiControlType::PageJump) { page_pressed_ = true; @@ -433,6 +556,8 @@ private: std::function button_event_; std::function numeric_input_activated_; std::function page_navigation_; + std::function alarm_acknowledge_; + std::vector alarm_records_; bool editing_enabled_ = true; bool runtime_active_ = false; bool runtime_write_enabled_ = false; @@ -459,10 +584,12 @@ HmiGraphicsItem *asHmiItem(QGraphicsItem *item) HmiEditorWidget::HmiEditorWidget( HmiEditorService &editor_service, HmiRuntimeService &runtime_service, + AlarmService &alarm_service, QWidget *parent) : QGraphicsView(parent), editor_service_(editor_service), runtime_service_(runtime_service), + alarm_service_(alarm_service), scene_(new QGraphicsScene(this)) { setObjectName(QStringLiteral("hmiEditorWidget")); @@ -557,6 +684,11 @@ void HmiEditorWidget::reloadPage() { emit pageNavigationRequested( QString::fromStdString(target_page_id)); + }, + [this](const std::string &definition_id) + { + alarm_service_.acknowledge(definition_id); + refreshRuntimeValues(); }); scene_->addItem(item); item->setInteractionState( @@ -614,6 +746,11 @@ void HmiEditorWidget::refreshRuntimeValues() { continue; } + if (control->type == HmiControlType::AlarmList) + { + control_item->setAlarmRecords(alarm_service_.records()); + continue; + } // 运行值通过服务读取,图元不直接接触寄存器仓库 const HmiRuntimeReadResult value = runtime_service_.readControl(*control); control_item->setRuntimeValue(value.bit_value, value.word_value, value.succeeded); diff --git a/app/src/ui/hmi_editor_widget.h b/app/src/ui/hmi_editor_widget.h index 15b41a5..0c8fe48 100644 --- a/app/src/ui/hmi_editor_widget.h +++ b/app/src/ui/hmi_editor_widget.h @@ -13,6 +13,7 @@ class HmiEditorService; class HmiRuntimeService; +class AlarmService; class QGraphicsScene; class QResizeEvent; enum class HmiButtonEvent; @@ -36,6 +37,7 @@ public: explicit HmiEditorWidget( HmiEditorService &editor_service, HmiRuntimeService &runtime_service, + AlarmService &alarm_service, QWidget *parent = nullptr); /** @@ -114,6 +116,7 @@ private: HmiEditorService &editor_service_; // 提供运行态寄存器读写能力,画布不直接访问寄存器仓库 HmiRuntimeService &runtime_service_; + AlarmService &alarm_service_; // 持有所有页面图元和页面边框 QGraphicsScene *scene_ = nullptr; // 当前画布投影的页面标识 diff --git a/app/src/ui/main_window.cpp b/app/src/ui/main_window.cpp index 5504ef4..05c7621 100644 --- a/app/src/ui/main_window.cpp +++ b/app/src/ui/main_window.cpp @@ -1,6 +1,7 @@ #include "main_window.h" #include "hmi_editor_widget.h" +#include "alarm_configuration_dialog.h" #include "logic_editor_widget.h" #include "plc_connection_dialog.h" #include "runtime_monitor_widget.h" @@ -8,6 +9,8 @@ #include "services/hmi_editor_service.h" #include "services/hmi_navigation_service.h" #include "services/hmi_runtime_service.h" +#include "services/alarm_editor_service.h" +#include "services/alarm_service.h" #include "services/logic_editor_service.h" #include "services/project_service.h" #include "services/runtime_mode_service.h" @@ -176,6 +179,10 @@ QString controlTypeText(HmiControlType type) { return MainWindow::tr("页面跳转"); } + case HmiControlType::AlarmList: + { + return MainWindow::tr("报警列表"); + } default: { return MainWindow::tr("未知控件"); @@ -191,6 +198,8 @@ MainWindow::MainWindow( HmiEditorService &hmi_editor_service, LogicEditorService &logic_editor_service, HmiRuntimeService &hmi_runtime_service, + AlarmEditorService &alarm_editor_service, + AlarmService &alarm_service, RegisterMonitorService ®ister_monitor_service, QWidget *parent) : QMainWindow(parent), @@ -200,6 +209,8 @@ MainWindow::MainWindow( hmi_editor_service_(hmi_editor_service), logic_editor_service_(logic_editor_service), hmi_runtime_service_(hmi_runtime_service), + alarm_editor_service_(alarm_editor_service), + alarm_service_(alarm_service), register_monitor_service_(register_monitor_service), owned_hmi_navigation_service_( std::make_unique(project_service)), @@ -214,6 +225,8 @@ MainWindow::MainWindow( HmiEditorService &hmi_editor_service, LogicEditorService &logic_editor_service, HmiRuntimeService &hmi_runtime_service, + AlarmEditorService &alarm_editor_service, + AlarmService &alarm_service, HmiNavigationService &hmi_navigation_service, RegisterMonitorService ®ister_monitor_service, QWidget *parent) @@ -224,6 +237,8 @@ MainWindow::MainWindow( hmi_editor_service_(hmi_editor_service), logic_editor_service_(logic_editor_service), hmi_runtime_service_(hmi_runtime_service), + alarm_editor_service_(alarm_editor_service), + alarm_service_(alarm_service), register_monitor_service_(register_monitor_service), hmi_navigation_service_(&hmi_navigation_service) { @@ -237,6 +252,7 @@ void MainWindow::initializeUi() configureActions(); configurePropertyEditor(); configurePlcConnection(); + configureAlarms(); configureHmiEditor(); configureLogicEditor(); configureRuntimeMonitor(); @@ -298,6 +314,8 @@ void MainWindow::configureActions() [this] { addHmiControl(HmiControlType::Label); }); connect(ui_->addPageJumpAction, &QAction::triggered, this, [this] { addHmiControl(HmiControlType::PageJump); }); + connect(ui_->addAlarmListAction, &QAction::triggered, this, + [this] { addHmiControl(HmiControlType::AlarmList); }); connect(ui_->deleteControlAction, &QAction::triggered, this, &MainWindow::deleteSelectedControl); @@ -454,6 +472,10 @@ void MainWindow::configureAppearance() style()->standardIcon(QStyle::SP_FileDialogInfoView)); ui_->addPageJumpAction->setIcon( style()->standardIcon(QStyle::SP_ArrowForward)); + ui_->addAlarmListAction->setIcon( + style()->standardIcon(QStyle::SP_MessageBoxWarning)); + ui_->configureAlarmsAction->setIcon( + style()->standardIcon(QStyle::SP_FileDialogDetailedView)); ui_->deleteControlAction->setIcon(style()->standardIcon(QStyle::SP_TrashIcon)); ui_->addRungAction->setIcon(style()->standardIcon(QStyle::SP_FileIcon)); ui_->parallelInsertAction->setIcon(style()->standardIcon(QStyle::SP_ArrowDown)); @@ -509,7 +531,10 @@ void MainWindow::configureHmiEditor() QLayout *layout = ui_->hmiCanvasPlaceholder->layout(); delete ui_->hmiEmptyLabel; hmi_editor_widget_ = new HmiEditorWidget( - hmi_editor_service_, hmi_runtime_service_, ui_->hmiCanvasPlaceholder); + hmi_editor_service_, + hmi_runtime_service_, + alarm_service_, + ui_->hmiCanvasPlaceholder); hmi_editor_widget_->setMinimumHeight(320); layout->addWidget(hmi_editor_widget_); connect(hmi_editor_widget_, &HmiEditorWidget::controlSelected, @@ -535,6 +560,7 @@ void MainWindow::configureHmiEditor() // 离线 / 联机运行模式 if (runtime_mode_service_.mode() != ApplicationMode::Editing) { + alarm_service_.refresh(); hmi_editor_widget_->refreshRuntimeValues(); runtime_monitor_widget_->refreshValues( runtime_mode_service_.mode(), @@ -595,6 +621,7 @@ void MainWindow::configureRuntimeMonitor() logic_editor_service_, project_service_, *hmi_navigation_service_, + alarm_service_, register_monitor_service_, ui_->runtimeMonitorTab); runtime_monitor_widget_->setObjectName(QStringLiteral("runtimeMonitorWidget")); @@ -719,6 +746,18 @@ void MainWindow::configurePlcConnection() connect(ui_->disconnectPlcAction, &QAction::triggered, this, &MainWindow::disconnectPlc); } +void MainWindow::configureAlarms() +{ + connect(ui_->configureAlarmsAction, &QAction::triggered, + this, + [this] + { + AlarmConfigurationDialog dialog(alarm_editor_service_, this); + dialog.exec(); + refreshProjectUi(); + }); +} + void MainWindow::refreshProjectUi() { // 仅在当前对象消失时回退,普通刷新必须保留用户的会话选择 @@ -1215,7 +1254,8 @@ void MainWindow::showControlProperties(const std::string &control_id) ui_->bindingIndexSpinBox->setValue( control->binding.has_value() ? control->binding->index() : 0); const bool has_binding = control->type != HmiControlType::Label - && control->type != HmiControlType::PageJump; + && control->type != HmiControlType::PageJump + && control->type != HmiControlType::AlarmList; ui_->bindingAreaLabel->setVisible(has_binding); ui_->bindingAreaComboBox->setVisible(has_binding); ui_->bindingIndexLabel->setVisible(has_binding); @@ -1383,7 +1423,8 @@ void MainWindow::applySelectedControlProperties() control.bounds.width = ui_->controlWidthSpinBox->value(); control.bounds.height = ui_->controlHeightSpinBox->value(); if (control.type == HmiControlType::Label - || control.type == HmiControlType::PageJump) + || control.type == HmiControlType::PageJump + || control.type == HmiControlType::AlarmList) { control.binding.reset(); } @@ -1721,6 +1762,7 @@ void MainWindow::updateModeUi(const QString &message) logic_editor_widget_->setEditingEnabled(policy.allowsProjectEditing); if (mode == ApplicationMode::Editing) { + alarm_service_.reset(); logic_editor_widget_->clearRuntimeTrace(); } ui_->hmiToolBar->setEnabled(policy.allowsProjectEditing); @@ -1731,6 +1773,8 @@ void MainWindow::updateModeUi(const QString &message) ui_->addNumericInputAction->setEnabled(policy.allowsProjectEditing); ui_->addLabelAction->setEnabled(policy.allowsProjectEditing); ui_->addPageJumpAction->setEnabled(policy.allowsProjectEditing); + ui_->addAlarmListAction->setEnabled(policy.allowsProjectEditing); + ui_->configureAlarmsAction->setEnabled(policy.allowsProjectEditing); ui_->deleteControlAction->setEnabled(policy.allowsProjectEditing); ui_->addNormallyOpenAction->setEnabled(policy.allowsProjectEditing); ui_->addNormallyClosedAction->setEnabled(policy.allowsProjectEditing); diff --git a/app/src/ui/main_window.h b/app/src/ui/main_window.h index 2219308..2c22c40 100644 --- a/app/src/ui/main_window.h +++ b/app/src/ui/main_window.h @@ -32,6 +32,8 @@ class RuntimeModeService; class ProjectService; class HmiEditorService; class HmiRuntimeService; +class AlarmEditorService; +class AlarmService; class HmiNavigationService; class HmiEditorWidget; class LogicEditorService; @@ -63,6 +65,8 @@ public: HmiEditorService &hmi_editor_service, LogicEditorService &logic_editor_service, HmiRuntimeService &hmi_runtime_service, + AlarmEditorService &alarm_editor_service, + AlarmService &alarm_service, RegisterMonitorService ®ister_monitor_service, QWidget *parent = nullptr); MainWindow( @@ -71,6 +75,8 @@ public: HmiEditorService &hmi_editor_service, LogicEditorService &logic_editor_service, HmiRuntimeService &hmi_runtime_service, + AlarmEditorService &alarm_editor_service, + AlarmService &alarm_service, HmiNavigationService &hmi_navigation_service, RegisterMonitorService ®ister_monitor_service, QWidget *parent = nullptr); @@ -94,6 +100,7 @@ private: // 创建并连接控件属性编辑表单 void configurePropertyEditor(); void configurePlcConnection(); + void configureAlarms(); // 刷新工程树和当前 HMI 页面信息 void refreshProjectUi(); void handleProjectTreeSelection(); @@ -176,6 +183,8 @@ private: HmiEditorService &hmi_editor_service_; LogicEditorService &logic_editor_service_; HmiRuntimeService &hmi_runtime_service_; + AlarmEditorService &alarm_editor_service_; + AlarmService &alarm_service_; RegisterMonitorService ®ister_monitor_service_; std::unique_ptr owned_hmi_navigation_service_; HmiNavigationService *hmi_navigation_service_ = nullptr; diff --git a/app/src/ui/main_window.ui b/app/src/ui/main_window.ui index 13aa4c8..44d54f2 100644 --- a/app/src/ui/main_window.ui +++ b/app/src/ui/main_window.ui @@ -304,6 +304,8 @@ + + @@ -902,6 +904,22 @@ 添加页面跳转控件 + + + 报警列表 + + + 添加报警列表控件 + + + + + 报警配置 + + + 配置 M 和 D 报警条件 + + 删除 diff --git a/app/src/ui/runtime_monitor_widget.cpp b/app/src/ui/runtime_monitor_widget.cpp index bafcaa2..f5294dc 100644 --- a/app/src/ui/runtime_monitor_widget.cpp +++ b/app/src/ui/runtime_monitor_widget.cpp @@ -4,6 +4,7 @@ #include "hmi_editor_widget.h" #include "logic_editor_widget.h" #include "services/hmi_navigation_service.h" +#include "services/alarm_service.h" #include "services/project_service.h" #include "ui_runtime_monitor_widget.h" @@ -34,6 +35,7 @@ RuntimeMonitorWidget::RuntimeMonitorWidget( LogicEditorService &logic_editor_service, ProjectService &project_service, HmiNavigationService &hmi_navigation_service, + AlarmService &alarm_service, RegisterMonitorService ®ister_monitor_service, QWidget *parent) : QWidget(parent), @@ -43,7 +45,10 @@ RuntimeMonitorWidget::RuntimeMonitorWidget( { ui_->setupUi(this); hmi_view_ = new HmiEditorWidget( - hmi_editor_service, hmi_runtime_service, ui_->hmiViewContainer); + hmi_editor_service, + hmi_runtime_service, + alarm_service, + ui_->hmiViewContainer); hmi_view_->setObjectName(QStringLiteral("runtimeHmiView")); hmi_view_->setEditingEnabled(false); hmi_view_->setRuntimeActive(true); diff --git a/app/src/ui/runtime_monitor_widget.h b/app/src/ui/runtime_monitor_widget.h index a6ac8ea..19c1c23 100644 --- a/app/src/ui/runtime_monitor_widget.h +++ b/app/src/ui/runtime_monitor_widget.h @@ -17,6 +17,7 @@ class FreeMonitorWidget; class HmiEditorService; class HmiEditorWidget; class HmiRuntimeService; +class AlarmService; class HmiNavigationService; class LogicEditorService; class LogicEditorWidget; @@ -34,6 +35,7 @@ public: LogicEditorService &logic_editor_service, ProjectService &project_service, HmiNavigationService &hmi_navigation_service, + AlarmService &alarm_service, RegisterMonitorService ®ister_monitor_service, QWidget *parent = nullptr); ~RuntimeMonitorWidget() override; diff --git a/app/tests/alarm_service_tests.cpp b/app/tests/alarm_service_tests.cpp new file mode 100644 index 0000000..fbf679a --- /dev/null +++ b/app/tests/alarm_service_tests.cpp @@ -0,0 +1,140 @@ +#include "domain/project_storage.h" +#include "domain/register_repository.h" +#include "services/alarm_editor_service.h" +#include "services/alarm_service.h" +#include "services/project_service.h" + +#include +#include +#include +#include +#include + +namespace { + +class TestProjectStorage final : public ProjectStorage +{ +public: + ProjectSaveResult save(const Project &, const std::string &) override + { + return {true, ProjectStorageError::None, {}}; + } + + ProjectLoadResult load(const std::string &) override + { + return {false, {}, ProjectStorageError::FileReadFailed, {}}; + } +}; + +void require(bool condition, const std::string &message) +{ + if (!condition) + { + throw std::runtime_error(message); + } +} + +const AlarmRecord *findActiveRecord( + const AlarmService &service, const std::string &definition_id) +{ + const auto &records = service.records(); + const auto record = std::find_if( + records.cbegin(), records.cend(), + [&definition_id](const AlarmRecord &candidate) + { + return candidate.definitionId == definition_id && candidate.active; + }); + return record == records.cend() ? nullptr : &*record; +} + +void testAlarmDefinitionsAndRuntimeLifecycle() +{ + TestProjectStorage storage; + ProjectService project_service(storage); + AlarmEditorService editor_service(project_service); + VirtualRegisterRepository repository; + AlarmService alarm_service(project_service, repository); + + AlarmDefinition m_alarm; + m_alarm.address = RegisterAddress{RegisterArea::M, 0}; + m_alarm.condition = AlarmCondition::MOn; + m_alarm.message = "Emergency stop"; + const AlarmEditorResult m_result = editor_service.addDefinition(m_alarm); + + AlarmDefinition high_alarm; + high_alarm.address = RegisterAddress{RegisterArea::D, 0}; + high_alarm.condition = AlarmCondition::DHigh; + high_alarm.threshold = 80; + high_alarm.message = "Temperature high"; + const AlarmEditorResult high_result = editor_service.addDefinition(high_alarm); + + AlarmDefinition low_alarm; + low_alarm.address = RegisterAddress{RegisterArea::D, 1}; + low_alarm.condition = AlarmCondition::DLow; + 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"); + + AlarmDefinition invalid = high_alarm; + invalid.address = RegisterAddress{RegisterArea::M, 2}; + require(editor_service.addDefinition(invalid).error + == AlarmEditorError::InvalidDefinition, + "D alarm conditions must reject M addresses"); + + 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.writeWord(RegisterAddress{RegisterArea::D, 0}, 80); + repository.writeWord(RegisterAddress{RegisterArea::D, 1}, 10); + alarm_service.refresh(); + require(findActiveRecord(alarm_service, m_result.id) != nullptr + && findActiveRecord(alarm_service, high_result.id) != nullptr + && findActiveRecord(alarm_service, low_result.id) != nullptr, + "all three alarm conditions must create active records"); + require(alarm_service.acknowledge(m_result.id) + && findActiveRecord(alarm_service, m_result.id)->acknowledged, + "an active alarm must be locally acknowledged"); + + repository.writeBit(RegisterAddress{RegisterArea::M, 0}, false); + repository.writeWord(RegisterAddress{RegisterArea::D, 0}, 79); + repository.writeWord(RegisterAddress{RegisterArea::D, 1}, 11); + alarm_service.refresh(); + require(std::none_of( + alarm_service.records().cbegin(), + alarm_service.records().cend(), + [](const AlarmRecord &record) { return record.active; }), + "cleared conditions must move active alarms into history"); + + repository.writeBit(RegisterAddress{RegisterArea::M, 0}, true); + alarm_service.refresh(); + require(findActiveRecord(alarm_service, m_result.id) != nullptr + && alarm_service.records().size() == 4, + "a repeated alarm must create a new occurrence and keep history"); + + alarm_service.reset(); + require(alarm_service.records().empty(), + "leaving runtime must clear session alarm records"); +} + +} // namespace + +int main() +{ + try + { + testAlarmDefinitionsAndRuntimeLifecycle(); + } + catch (const std::exception &error) + { + std::cerr << "alarm service tests failed: " << error.what() << '\n'; + return 1; + } + + std::cout << "alarm service tests passed\n"; + return 0; +} diff --git a/app/tests/alarm_service_tests.pro b/app/tests/alarm_service_tests.pro new file mode 100644 index 0000000..88e78e7 --- /dev/null +++ b/app/tests/alarm_service_tests.pro @@ -0,0 +1,33 @@ +TEMPLATE = app +TARGET = alarm_service_tests + +CONFIG += console c++17 testcase warn_on +CONFIG -= app_bundle qt + +INCLUDEPATH += ../src + +SOURCES += \ + alarm_service_tests.cpp \ + ../src/domain/register_address.cpp \ + ../src/domain/register_repository.cpp \ + ../src/domain/alarm_model.cpp \ + ../src/domain/hmi_model.cpp \ + ../src/domain/control_logic_model.cpp \ + ../src/domain/project_model.cpp \ + ../src/services/project_service.cpp \ + ../src/services/alarm_editor_service.cpp \ + ../src/services/alarm_service.cpp + +HEADERS += \ + ../src/domain/register_address.h \ + ../src/domain/register_repository.h \ + ../src/domain/alarm_model.h \ + ../src/domain/hmi_model.h \ + ../src/domain/control_logic_model.h \ + ../src/domain/project_model.h \ + ../src/domain/project_storage.h \ + ../src/services/project_service.h \ + ../src/services/alarm_editor_service.h \ + ../src/services/alarm_service.h + +QT -= gui diff --git a/app/tests/domain_tests.pro b/app/tests/domain_tests.pro index a7fca1b..c0e4421 100644 --- a/app/tests/domain_tests.pro +++ b/app/tests/domain_tests.pro @@ -10,6 +10,7 @@ SOURCES += \ domain_tests.cpp \ ../src/domain/register_address.cpp \ ../src/domain/register_repository.cpp \ + ../src/domain/alarm_model.cpp \ ../src/domain/hmi_model.cpp \ ../src/domain/control_logic_model.cpp \ ../src/domain/project_model.cpp \ @@ -18,6 +19,7 @@ SOURCES += \ HEADERS += \ ../src/domain/register_address.h \ ../src/domain/register_repository.h \ + ../src/domain/alarm_model.h \ ../src/domain/hmi_model.h \ ../src/domain/control_logic_model.h \ ../src/domain/project_model.h \ diff --git a/app/tests/hmi_editor_service_tests.cpp b/app/tests/hmi_editor_service_tests.cpp index 2ae9961..920c0fd 100644 --- a/app/tests/hmi_editor_service_tests.cpp +++ b/app/tests/hmi_editor_service_tests.cpp @@ -175,6 +175,17 @@ void testPageLifecycleAndNavigation() require(label.succeeded && service.findControl(main_id, label.id)->isConfigured(), "the editor service must expose a configured static Label control"); + const HmiEditorResult alarm_list = service.addControl( + main_id, HmiControlType::AlarmList); + require(alarm_list.succeeded + && service.findControl(main_id, alarm_list.id)->isConfigured() + && !service.findControl(main_id, alarm_list.id)->binding.has_value(), + "AlarmList must be configured without a register binding"); + HmiControl bound_alarm_list = *service.findControl(main_id, alarm_list.id); + bound_alarm_list.binding = RegisterAddress{RegisterArea::M, 20}; + require(!service.updateControl( + main_id, alarm_list.id, bound_alarm_list).succeeded, + "AlarmList must reject direct register bindings"); const HmiEditorResult jump = service.addControl(main_id, HmiControlType::PageJump); require(jump.succeeded, "the editor service must expose PageJump creation"); HmiControl jump_control = *service.findControl(main_id, jump.id); diff --git a/app/tests/hmi_editor_service_tests.pro b/app/tests/hmi_editor_service_tests.pro index 59c928a..02b8730 100644 --- a/app/tests/hmi_editor_service_tests.pro +++ b/app/tests/hmi_editor_service_tests.pro @@ -10,6 +10,7 @@ SOURCES += \ hmi_editor_service_tests.cpp \ ../src/domain/register_address.cpp \ ../src/domain/register_repository.cpp \ + ../src/domain/alarm_model.cpp \ ../src/domain/hmi_model.cpp \ ../src/domain/control_logic_model.cpp \ ../src/domain/project_model.cpp \ @@ -21,6 +22,7 @@ SOURCES += \ HEADERS += \ ../src/domain/register_address.h \ ../src/domain/register_repository.h \ + ../src/domain/alarm_model.h \ ../src/domain/hmi_model.h \ ../src/domain/control_logic_model.h \ ../src/domain/project_model.h \ diff --git a/app/tests/logic_editor_service_tests.pro b/app/tests/logic_editor_service_tests.pro index a37ee79..21cdeb2 100644 --- a/app/tests/logic_editor_service_tests.pro +++ b/app/tests/logic_editor_service_tests.pro @@ -13,6 +13,7 @@ SOURCES += \ ../src/domain/project_model.cpp \ ../src/domain/hmi_model.cpp \ ../src/domain/register_repository.cpp \ + ../src/domain/alarm_model.cpp \ ../src/services/project_service.cpp \ ../src/services/logic_editor_service.cpp @@ -22,6 +23,7 @@ HEADERS += \ ../src/domain/project_model.h \ ../src/domain/hmi_model.h \ ../src/domain/register_repository.h \ + ../src/domain/alarm_model.h \ ../src/domain/project_storage.h \ ../src/services/project_service.h \ ../src/services/logic_editor_service.h diff --git a/app/tests/main_window_tests.cpp b/app/tests/main_window_tests.cpp index e37bb80..02e983a 100644 --- a/app/tests/main_window_tests.cpp +++ b/app/tests/main_window_tests.cpp @@ -1,6 +1,8 @@ #include "domain/active_register_repository.h" #include "domain/project_storage.h" #include "domain/register_repository.h" +#include "services/alarm_editor_service.h" +#include "services/alarm_service.h" #include "services/hmi_editor_service.h" #include "services/hmi_runtime_service.h" #include "services/hmi_navigation_service.h" @@ -278,6 +280,7 @@ void testRuntimeButtonMouseInteraction() HmiEditorService editor_service(project_service); VirtualRegisterRepository repository; HmiRuntimeService runtime_service(repository); + AlarmService alarm_service(project_service, repository); const HmiEditorResult page_result = editor_service.ensureDefaultPage(); require(page_result.succeeded, "runtime button test must create an HMI page"); const HmiEditorResult button_result = editor_service.addControl( @@ -290,7 +293,7 @@ void testRuntimeButtonMouseInteraction() require(editor_service.updateControl(page_result.id, button.id, button).succeeded, "runtime button test must bind the button to M0"); - HmiEditorWidget view(editor_service, runtime_service); + HmiEditorWidget view(editor_service, runtime_service, alarm_service); view.resize(900, 600); view.setPageId(page_result.id); view.setEditingEnabled(false); @@ -361,11 +364,13 @@ void testRuntimePageJumpDoesNotRequireRegisterWritePermission() VirtualRegisterRepository repository; HmiRuntimeService runtime_service(repository); - HmiEditorWidget view(editor_service, runtime_service); + AlarmService alarm_service(project_service, repository); + HmiEditorWidget view(editor_service, runtime_service, alarm_service); view.setPageId(source_page); view.setEditingEnabled(false); view.setRuntimeActive(true); view.setRuntimeWriteEnabled(false); + view.refreshRuntimeValues(); view.resize(900, 560); view.show(); QApplication::processEvents(); @@ -394,6 +399,73 @@ void testRuntimePageJumpDoesNotRequireRegisterWritePermission() "PageJump must remain active when register writes are disabled"); } +void testRuntimeAlarmListInteraction() +{ + TestProjectStorage storage; + ProjectService project_service(storage); + HmiEditorService editor_service(project_service); + AlarmEditorService alarm_editor_service(project_service); + VirtualRegisterRepository repository; + HmiRuntimeService runtime_service(repository); + AlarmService alarm_service(project_service, repository); + + const std::string page_id = editor_service.ensureDefaultPage().id; + const HmiEditorResult list_result = editor_service.addControl( + page_id, HmiControlType::AlarmList); + require(list_result.succeeded, + "runtime alarm test must create an AlarmList control"); + const HmiControl *alarm_list = editor_service.findControl( + page_id, list_result.id); + require(alarm_list != nullptr && !alarm_list->binding.has_value(), + "AlarmList must not require a register binding"); + + AlarmDefinition definition; + definition.address = RegisterAddress{RegisterArea::M, 0}; + definition.condition = AlarmCondition::MOn; + definition.message = "Emergency stop"; + const AlarmEditorResult alarm_result = + alarm_editor_service.addDefinition(definition); + require(alarm_result.succeeded, + "runtime alarm test must create an M alarm definition"); + + repository.writeBit(RegisterAddress{RegisterArea::M, 0}, true); + alarm_service.refresh(); + require(alarm_service.records().size() == 1 + && alarm_service.records().front().active, + "an active M alarm must be available to AlarmList"); + + HmiEditorWidget view(editor_service, runtime_service, alarm_service); + view.setPageId(page_id); + view.setEditingEnabled(false); + view.setRuntimeActive(true); + view.setRuntimeWriteEnabled(false); + view.refreshRuntimeValues(); + view.resize(900, 560); + view.show(); + QApplication::processEvents(); + + const QPoint header_position = view.mapFromScene(QPointF( + alarm_list->bounds.x + 20.0, alarm_list->bounds.y + 10.0)); + QTest::mouseClick( + view.viewport(), Qt::LeftButton, Qt::NoModifier, header_position); + require(!alarm_service.records().front().acknowledged, + "clicking the AlarmList header must not acknowledge an alarm"); + + const QPoint first_row_position = view.mapFromScene(QPointF( + alarm_list->bounds.x + 20.0, alarm_list->bounds.y + 34.0)); + QTest::mouseClick( + view.viewport(), Qt::LeftButton, Qt::NoModifier, first_row_position); + require(alarm_service.records().front().acknowledged, + "clicking an active AlarmList row must acknowledge the alarm"); + + repository.writeBit(RegisterAddress{RegisterArea::M, 0}, false); + alarm_service.refresh(); + view.refreshRuntimeValues(); + require(!alarm_service.records().front().active + && alarm_service.records().front().clearedAt.has_value(), + "clearing the M condition must retain a cleared history record"); +} + void testModeActionsControlEditingAvailability() { // 验证模式动作会同步禁用编辑入口,并在失败时恢复当前选择 @@ -403,6 +475,8 @@ void testModeActionsControlEditingAvailability() LogicEditorService logic_editor_service(project_service); VirtualRegisterRepository repository; HmiRuntimeService runtime_service(repository); + AlarmEditorService alarm_editor_service(project_service); + AlarmService alarm_service(project_service, repository); OfflineSimulationService simulation_service(repository); RuntimeModeService mode_service(project_service, simulation_service); RegisterMonitorService monitor_service(repository); @@ -412,6 +486,8 @@ void testModeActionsControlEditingAvailability() editor_service, logic_editor_service, runtime_service, + alarm_editor_service, + alarm_service, monitor_service); window.resize(1000, 640); window.show(); @@ -644,6 +720,8 @@ void testPlcConfigurationUsesDialog() LogicEditorService logic_editor_service(project_service); VirtualRegisterRepository repository; HmiRuntimeService runtime_service(repository); + AlarmEditorService alarm_editor_service(project_service); + AlarmService alarm_service(project_service, repository); OfflineSimulationService simulation_service(repository); RuntimeModeService mode_service(project_service, simulation_service); RegisterMonitorService monitor_service(repository); @@ -653,6 +731,8 @@ void testPlcConfigurationUsesDialog() editor_service, logic_editor_service, runtime_service, + alarm_editor_service, + alarm_service, monitor_service); require(window.findChild(QStringLiteral("serialPortComboBox")) == nullptr, "serial configuration controls must not be embedded in the main window"); @@ -685,6 +765,8 @@ void testRepeatedPlcStatusNotificationsAreCoalesced() VirtualRegisterRepository plc_repository; ActiveRegisterRepository active_repository(virtual_repository); HmiRuntimeService runtime_service(active_repository); + AlarmEditorService alarm_editor_service(project_service); + AlarmService alarm_service(project_service, active_repository); OfflineSimulationService simulation_service(virtual_repository); RepeatedStatusGateway gateway; RuntimeModeService mode_service(project_service, simulation_service); @@ -697,6 +779,8 @@ void testRepeatedPlcStatusNotificationsAreCoalesced() editor_service, logic_editor_service, runtime_service, + alarm_editor_service, + alarm_service, monitor_service); QListWidget *output = requiredChild(window, "outputList"); const int initial_count = output->count(); @@ -729,6 +813,8 @@ void testOnlineWorkspaceShowsHmiAndFreeMonitorOnly() VirtualRegisterRepository plc_repository; ActiveRegisterRepository active_repository(virtual_repository); HmiRuntimeService runtime_service(active_repository); + AlarmEditorService alarm_editor_service(project_service); + AlarmService alarm_service(project_service, active_repository); OfflineSimulationService simulation_service(virtual_repository); OnlineReadyGateway gateway; RuntimeModeService mode_service(project_service, simulation_service); @@ -741,6 +827,8 @@ void testOnlineWorkspaceShowsHmiAndFreeMonitorOnly() editor_service, logic_editor_service, runtime_service, + alarm_editor_service, + alarm_service, monitor_service); window.resize(1200, 760); window.show(); @@ -851,6 +939,8 @@ void testMultiPageAndLogicMainWindowIntegration() VirtualRegisterRepository repository; HmiRuntimeService runtime_service(repository); + AlarmEditorService alarm_editor_service(project_service); + AlarmService alarm_service(project_service, repository); OfflineSimulationService simulation_service(repository); RuntimeModeService mode_service(project_service, simulation_service); RegisterMonitorService monitor_service(repository); @@ -861,6 +951,8 @@ void testMultiPageAndLogicMainWindowIntegration() editor_service, logic_editor_service, runtime_service, + alarm_editor_service, + alarm_service, navigation_service, monitor_service); window.resize(1400, 820); @@ -960,6 +1052,7 @@ int main(int argc, char *argv[]) { testRuntimeButtonMouseInteraction(); testRuntimePageJumpDoesNotRequireRegisterWritePermission(); + testRuntimeAlarmListInteraction(); testModeActionsControlEditingAvailability(); testPlcConfigurationUsesDialog(); testRepeatedPlcStatusNotificationsAreCoalesced(); diff --git a/app/tests/main_window_tests.pro b/app/tests/main_window_tests.pro index dd90cb4..7bbe5e5 100644 --- a/app/tests/main_window_tests.pro +++ b/app/tests/main_window_tests.pro @@ -11,6 +11,7 @@ INCLUDEPATH += ../src SOURCES += \ main_window_tests.cpp \ ../src/ui/main_window.cpp \ + ../src/ui/alarm_configuration_dialog.cpp \ ../src/ui/plc_connection_dialog.cpp \ ../src/ui/free_monitor_widget.cpp \ ../src/ui/runtime_monitor_widget.cpp \ @@ -20,11 +21,14 @@ SOURCES += \ ../src/domain/register_repository.cpp \ ../src/domain/active_register_repository.cpp \ ../src/domain/register_monitor_model.cpp \ + ../src/domain/alarm_model.cpp \ ../src/domain/hmi_model.cpp \ ../src/domain/control_logic_model.cpp \ ../src/domain/project_model.cpp \ ../src/domain/runtime_state.cpp \ ../src/services/project_service.cpp \ + ../src/services/alarm_editor_service.cpp \ + ../src/services/alarm_service.cpp \ ../src/services/hmi_editor_service.cpp \ ../src/services/hmi_navigation_service.cpp \ ../src/services/logic_editor_service.cpp \ @@ -36,6 +40,7 @@ SOURCES += \ HEADERS += \ ../src/ui/main_window.h \ + ../src/ui/alarm_configuration_dialog.h \ ../src/ui/plc_connection_dialog.h \ ../src/ui/free_monitor_widget.h \ ../src/ui/runtime_monitor_widget.h \ @@ -45,12 +50,15 @@ HEADERS += \ ../src/domain/register_repository.h \ ../src/domain/active_register_repository.h \ ../src/domain/register_monitor_model.h \ + ../src/domain/alarm_model.h \ ../src/domain/hmi_model.h \ ../src/domain/control_logic_model.h \ ../src/domain/project_model.h \ ../src/domain/project_storage.h \ ../src/domain/runtime_state.h \ ../src/services/project_service.h \ + ../src/services/alarm_editor_service.h \ + ../src/services/alarm_service.h \ ../src/services/hmi_editor_service.h \ ../src/services/hmi_navigation_service.h \ ../src/services/logic_editor_service.h \ @@ -65,6 +73,7 @@ HEADERS += \ FORMS += \ ../src/ui/main_window.ui \ + ../src/ui/alarm_configuration_dialog.ui \ ../src/ui/plc_connection_dialog.ui \ ../src/ui/free_monitor_widget.ui \ ../src/ui/runtime_monitor_widget.ui diff --git a/app/tests/plc_runtime_tests.pro b/app/tests/plc_runtime_tests.pro index 4da19a7..9617525 100644 --- a/app/tests/plc_runtime_tests.pro +++ b/app/tests/plc_runtime_tests.pro @@ -14,6 +14,7 @@ SOURCES += \ ../src/domain/register_repository.cpp \ ../src/domain/active_register_repository.cpp \ ../src/domain/register_monitor_model.cpp \ + ../src/domain/alarm_model.cpp \ ../src/domain/hmi_model.cpp \ ../src/domain/control_logic_model.cpp \ ../src/domain/project_model.cpp \ @@ -30,6 +31,7 @@ HEADERS += \ ../src/domain/register_repository.h \ ../src/domain/active_register_repository.h \ ../src/domain/register_monitor_model.h \ + ../src/domain/alarm_model.h \ ../src/domain/hmi_model.h \ ../src/domain/control_logic_model.h \ ../src/domain/project_model.h \ diff --git a/app/tests/project_management_tests.cpp b/app/tests/project_management_tests.cpp index 6a37919..f538d05 100644 --- a/app/tests/project_management_tests.cpp +++ b/app/tests/project_management_tests.cpp @@ -72,6 +72,12 @@ Project makeExampleProject() settings_jump.text = "Settings"; settings_jump.pageJump = HmiPageJumpConfig{"settings-page"}; + HmiControl alarm_list; + alarm_list.id = "alarm-list"; + alarm_list.type = HmiControlType::AlarmList; + alarm_list.bounds = {10, 200, 360, 180}; + alarm_list.text = "Alarms"; + HmiPage page; page.id = "main-page"; page.name = "Main"; @@ -81,6 +87,7 @@ Project makeExampleProject() page.controls.push_back(target_input); page.controls.push_back(title_label); page.controls.push_back(settings_jump); + page.controls.push_back(alarm_list); HmiPage settings_page; settings_page.id = "settings-page"; @@ -139,6 +146,18 @@ Project makeExampleProject() project.hmiPages.push_back(page); project.hmiPages.push_back(settings_page); project.initialHmiPageId = page.id; + project.alarmDefinitions.push_back( + {"alarm-emergency", + RegisterAddress{RegisterArea::M, 10}, + AlarmCondition::MOn, + 0, + "Emergency stop"}); + project.alarmDefinitions.push_back( + {"alarm-temperature", + RegisterAddress{RegisterArea::D, 2}, + AlarmCondition::DHigh, + 80, + "Temperature high"}); project.controlLogics.push_back(logic); ControlLogic draft_logic; draft_logic.id = "draft-logic"; @@ -188,6 +207,8 @@ void testEmptyProjectRoundTrip() require(service.project().hmiPages.empty(), "empty project must have no HMI pages"); require(service.project().controlLogics.empty(), "empty project must have no control logics"); + require(service.project().alarmDefinitions.empty(), + "empty project must have no alarm definitions"); } void testExampleProjectRoundTrip() @@ -205,6 +226,7 @@ void testExampleProjectRoundTrip() const QString invalid_operation_path = directory.filePath("invalid-operation.json"); const QString missing_initial_path = directory.filePath("missing-initial.json"); const QString missing_target_path = directory.filePath("missing-target.json"); + const QString missing_alarms_path = directory.filePath("missing-alarms.json"); require(service.saveAs(first_path.toStdString()).succeeded, "example project save must succeed"); const QByteArray saved_json = readBytes(first_path); @@ -218,8 +240,10 @@ void testExampleProjectRoundTrip() require(saved_json.contains("\"formatVersion\": \"1.0\"") && saved_json.contains("\"buttonOperation\": \"setOn\"") && saved_json.contains("\"initialHmiPageId\": \"main-page\"") - && saved_json.contains("\"targetPageId\": \"settings-page\""), - "version 1.0 projects must persist the current multi-page schema"); + && saved_json.contains("\"targetPageId\": \"settings-page\"") + && saved_json.contains("\"alarmDefinitions\"") + && saved_json.contains("\"type\": \"alarmList\""), + "version 1.0 projects must persist pages and alarm definitions"); require(!saved_json.contains("\"stages\"") && !saved_json.contains("\"branches\""), "current project format must not contain the removed stage model"); @@ -234,8 +258,8 @@ void testExampleProjectRoundTrip() require(project.hmiPages.size() == 2, "HMI page count must survive round trip"); require(project.initialHmiPageId == "main-page", "the initial HMI page id must survive round trip"); - require(project.hmiPages.front().controls.size() == 6, - "register, Label and PageJump controls must survive round trip"); + require(project.hmiPages.front().controls.size() == 7, + "register, navigation and AlarmList controls must survive round trip"); require(project.hmiPages.front().controls.front().binding->area() == RegisterArea::M, "HMI M binding must survive round trip"); @@ -254,6 +278,14 @@ void testExampleProjectRoundTrip() && project.hmiPages.front().controls.at(5).pageJump->targetPageId == "settings-page", "Label and PageJump typed data must survive round trip"); + require(project.hmiPages.front().controls.at(6).type + == HmiControlType::AlarmList, + "AlarmList control type must survive round trip"); + require(project.alarmDefinitions.size() == 2 + && project.alarmDefinitions.front().condition + == AlarmCondition::MOn + && project.alarmDefinitions.at(1).threshold == 80, + "M and D alarm definitions must survive round trip"); require(project.controlLogics.size() == 2, "control logic count must survive round trip"); require(!project.controlLogics.front().enabled, @@ -297,6 +329,16 @@ void testExampleProjectRoundTrip() && missing_result.storageError == ProjectStorageError::MissingField, "the 1.0 schema must require initialHmiPageId without migration defaults"); + QJsonObject missing_alarms = QJsonDocument::fromJson(saved_json).object(); + missing_alarms.remove(QStringLiteral("alarmDefinitions")); + writeText( + missing_alarms_path, + QJsonDocument(missing_alarms).toJson(QJsonDocument::Compact)); + missing_result = service.load(missing_alarms_path.toStdString()); + require(!missing_result.succeeded + && missing_result.storageError == ProjectStorageError::MissingField, + "the 1.0 schema must require alarmDefinitions without migration defaults"); + QJsonObject missing_target = QJsonDocument::fromJson(saved_json).object(); QJsonArray pages = missing_target.value(QStringLiteral("hmiPages")).toArray(); QJsonObject main_page = pages.at(0).toObject(); diff --git a/app/tests/project_management_tests.pro b/app/tests/project_management_tests.pro index dccd1c9..5ffac5e 100644 --- a/app/tests/project_management_tests.pro +++ b/app/tests/project_management_tests.pro @@ -12,6 +12,7 @@ SOURCES += \ project_management_tests.cpp \ ../src/domain/register_address.cpp \ ../src/domain/register_repository.cpp \ + ../src/domain/alarm_model.cpp \ ../src/domain/hmi_model.cpp \ ../src/domain/control_logic_model.cpp \ ../src/domain/project_model.cpp \ @@ -22,6 +23,7 @@ SOURCES += \ HEADERS += \ ../src/domain/register_address.h \ ../src/domain/register_repository.h \ + ../src/domain/alarm_model.h \ ../src/domain/hmi_model.h \ ../src/domain/control_logic_model.h \ ../src/domain/project_model.h \ diff --git a/app/tests/runtime_mode_service_tests.cpp b/app/tests/runtime_mode_service_tests.cpp index 1fe57df..7f12215 100644 --- a/app/tests/runtime_mode_service_tests.cpp +++ b/app/tests/runtime_mode_service_tests.cpp @@ -46,7 +46,11 @@ public: initial_read = false; } - void setPollAddresses(const std::vector &) override {} + void setPollAddresses( + const std::vector &addresses) override + { + poll_addresses = addresses; + } PlcConnectionState state() const override { return connection_state; } bool initialReadCompleted() const override { return initial_read; } PlcCommunicationError lastErrorType() const override @@ -76,6 +80,11 @@ public: } } + const std::vector &pollAddresses() const + { + return poll_addresses; + } + private: PlcConnectionState connection_state = PlcConnectionState::Disconnected; bool initial_read = false; @@ -84,6 +93,7 @@ private: std::function initial_read_changed; std::function cache_updated; std::function error_reported; + std::vector poll_addresses; }; void require(bool condition, const std::string &message) @@ -108,6 +118,12 @@ void testModeTransitions() service.configurePlc( gateway, active_repository, virtual_repository, plc_repository); + Project &project = project_service.editProject(); + project.alarmDefinitions.push_back( + {"alarm-m", {RegisterArea::M, 12}, AlarmCondition::MOn, 0, "M alarm"}); + project.alarmDefinitions.push_back( + {"alarm-d", {RegisterArea::D, 34}, AlarmCondition::DHigh, 100, "D alarm"}); + require(service.mode() == ApplicationMode::Editing, "service must start in editing mode"); require(service.policy().allowsProjectEditing, @@ -134,6 +150,10 @@ void testModeTransitions() require(service.connectPlc( {"COM9", 1, 9600, 8, 2, 1, 1000, 2, 200}).succeeded, "PLC connection must be established before its initial read can complete"); + require(gateway.pollAddresses() + == std::vector({ + {RegisterArea::M, 12}, {RegisterArea::D, 34}}), + "M and D alarm addresses must be included in PLC polling"); gateway.completeInitialRead(); require(service.initialPlcReadCompleted(), "service must retain the initial PLC read state"); diff --git a/app/tests/runtime_mode_service_tests.pro b/app/tests/runtime_mode_service_tests.pro index 2bb8996..6ce7603 100644 --- a/app/tests/runtime_mode_service_tests.pro +++ b/app/tests/runtime_mode_service_tests.pro @@ -14,6 +14,7 @@ SOURCES += \ ../src/domain/register_repository.cpp \ ../src/domain/active_register_repository.cpp \ ../src/domain/register_monitor_model.cpp \ + ../src/domain/alarm_model.cpp \ ../src/domain/hmi_model.cpp \ ../src/domain/control_logic_model.cpp \ ../src/domain/project_model.cpp \ @@ -28,6 +29,7 @@ HEADERS += \ ../src/domain/register_repository.h \ ../src/domain/active_register_repository.h \ ../src/domain/register_monitor_model.h \ + ../src/domain/alarm_model.h \ ../src/domain/hmi_model.h \ ../src/domain/control_logic_model.h \ ../src/domain/project_model.h \