| @@ -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 | |||
| @@ -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; | |||
| } | |||
| @@ -0,0 +1,24 @@ | |||
| #pragma once | |||
| #include "register_address.h" | |||
| #include <cstdint> | |||
| #include <string> | |||
| 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; | |||
| }; | |||
| @@ -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; | |||
| } | |||
| @@ -42,7 +42,8 @@ enum class HmiControlType | |||
| NumericDisplay, // 数值显示 | |||
| NumericInput, // 数值输入 | |||
| Label, // 标签 | |||
| PageJump // 页面跳转 | |||
| PageJump, // 页面跳转 | |||
| AlarmList // 报警列表 | |||
| }; | |||
| /** | |||
| @@ -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) | |||
| { | |||
| // 工程聚合校验会向下委托页面和控件的完整规则 | |||
| @@ -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<HmiPage> hmiPages; | |||
| // 进入运行态时显示的初始 HMI 页面 | |||
| std::string initialHmiPageId; | |||
| // 工程统一管理的报警定义 | |||
| std::vector<AlarmDefinition> alarmDefinitions; | |||
| // 工程包含的控制逻辑集合 | |||
| std::vector<ControlLogic> controlLogics; | |||
| @@ -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<std::int16_t>::min(), | |||
| std::numeric_limits<std::int16_t>::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<std::int16_t>(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<std::size_t>(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<std::size_t>(logics.size())); | |||
| for (int index = 0; index < logics.size(); ++index) | |||
| { | |||
| @@ -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(); | |||
| @@ -0,0 +1,103 @@ | |||
| #include "alarm_editor_service.h" | |||
| #include "project_service.h" | |||
| #include <algorithm> | |||
| 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<AlarmDefinition> &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; | |||
| } | |||
| } | |||
| @@ -0,0 +1,42 @@ | |||
| #pragma once | |||
| #include "domain/alarm_model.h" | |||
| #include <string> | |||
| #include <vector> | |||
| 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<AlarmDefinition> &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_; | |||
| }; | |||
| @@ -0,0 +1,114 @@ | |||
| #include "alarm_service.h" | |||
| #include "domain/alarm_model.h" | |||
| #include "domain/register_repository.h" | |||
| #include "project_service.h" | |||
| #include <algorithm> | |||
| 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<bool> 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<AlarmRecord> &AlarmService::records() const | |||
| { | |||
| return records_; | |||
| } | |||
| std::optional<bool> AlarmService::evaluate( | |||
| const AlarmDefinition &definition) const | |||
| { | |||
| if (definition.condition == AlarmCondition::MOn) | |||
| { | |||
| const BitReadResult value = repository_.readBit(definition.address); | |||
| return value.succeeded ? std::optional<bool>{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::size_t>(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; | |||
| } | |||
| } | |||
| } | |||
| @@ -0,0 +1,44 @@ | |||
| #pragma once | |||
| #include <chrono> | |||
| #include <cstddef> | |||
| #include <optional> | |||
| #include <string> | |||
| #include <vector> | |||
| 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<std::chrono::system_clock::time_point> 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<AlarmRecord> &records() const; | |||
| private: | |||
| std::optional<bool> evaluate(const AlarmDefinition &definition) const; | |||
| void trimHistory(); | |||
| static constexpr std::size_t kHistoryLimit = 20; | |||
| const ProjectService &project_service_; | |||
| RegisterRepository &repository_; | |||
| std::vector<AlarmRecord> records_; | |||
| }; | |||
| @@ -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{}; | |||
| @@ -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) | |||
| @@ -0,0 +1,245 @@ | |||
| #include "alarm_configuration_dialog.h" | |||
| #include "services/alarm_editor_service.h" | |||
| #include "ui_alarm_configuration_dialog.h" | |||
| #include <QHeaderView> | |||
| #include <QMessageBox> | |||
| #include <QTableWidgetItem> | |||
| namespace { | |||
| QString fromUtf8(const std::string &value) | |||
| { | |||
| return QString::fromUtf8(value.data(), static_cast<int>(value.size())); | |||
| } | |||
| std::string toUtf8(const QString &value) | |||
| { | |||
| const QByteArray bytes = value.toUtf8(); | |||
| return std::string(bytes.constData(), static_cast<std::size_t>(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<Ui::AlarmConfigurationDialog>()), | |||
| 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<int>(RegisterArea::M)); | |||
| ui_->areaComboBox->setItemData(1, static_cast<int>(RegisterArea::D)); | |||
| connect(ui_->areaComboBox, | |||
| QOverload<int>::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<int>(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<int>(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<int>(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<RegisterArea>( | |||
| ui_->areaComboBox->currentData().toInt()); | |||
| ui_->conditionComboBox->clear(); | |||
| if (area == RegisterArea::M) | |||
| { | |||
| ui_->conditionComboBox->addItem( | |||
| conditionText(AlarmCondition::MOn), | |||
| static_cast<int>(AlarmCondition::MOn)); | |||
| } | |||
| else | |||
| { | |||
| ui_->conditionComboBox->addItem( | |||
| conditionText(AlarmCondition::DHigh), | |||
| static_cast<int>(AlarmCondition::DHigh)); | |||
| ui_->conditionComboBox->addItem( | |||
| conditionText(AlarmCondition::DLow), | |||
| static_cast<int>(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<RegisterArea>( | |||
| ui_->areaComboBox->currentData().toInt()); | |||
| definition.address = RegisterAddress{area, ui_->indexSpinBox->value()}; | |||
| definition.condition = static_cast<AlarmCondition>( | |||
| ui_->conditionComboBox->currentData().toInt()); | |||
| definition.threshold = static_cast<std::int16_t>( | |||
| 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()); | |||
| } | |||
| @@ -0,0 +1,35 @@ | |||
| #pragma once | |||
| #include <QDialog> | |||
| #include <memory> | |||
| #include <string> | |||
| 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::AlarmConfigurationDialog> ui_; | |||
| AlarmEditorService &service_; | |||
| }; | |||
| @@ -0,0 +1,49 @@ | |||
| <?xml version="1.0" encoding="UTF-8"?> | |||
| <ui version="4.0"> | |||
| <class>AlarmConfigurationDialog</class> | |||
| <widget class="QDialog" name="AlarmConfigurationDialog"> | |||
| <property name="windowTitle"><string>报警配置</string></property> | |||
| <property name="minimumSize"><size><width>720</width><height>430</height></size></property> | |||
| <layout class="QVBoxLayout" name="verticalLayout"> | |||
| <item> | |||
| <widget class="QTableWidget" name="alarmTable"> | |||
| <property name="selectionBehavior"><enum>QAbstractItemView::SelectRows</enum></property> | |||
| <property name="selectionMode"><enum>QAbstractItemView::SingleSelection</enum></property> | |||
| <property name="editTriggers"><set>QAbstractItemView::NoEditTriggers</set></property> | |||
| <property name="columnCount"><number>5</number></property> | |||
| <column><property name="text"><string>ID</string></property></column> | |||
| <column><property name="text"><string>地址</string></property></column> | |||
| <column><property name="text"><string>条件</string></property></column> | |||
| <column><property name="text"><string>阈值</string></property></column> | |||
| <column><property name="text"><string>报警文本</string></property></column> | |||
| </widget> | |||
| </item> | |||
| <item> | |||
| <layout class="QFormLayout" name="alarmForm"> | |||
| <property name="fieldGrowthPolicy"><enum>QFormLayout::AllNonFixedFieldsGrow</enum></property> | |||
| <item row="0" column="0"><widget class="QLabel" name="areaLabel"><property name="text"><string>地址区域</string></property></widget></item> | |||
| <item row="0" column="1"><widget class="QComboBox" name="areaComboBox"><item><property name="text"><string>M</string></property></item><item><property name="text"><string>D</string></property></item></widget></item> | |||
| <item row="1" column="0"><widget class="QLabel" name="indexLabel"><property name="text"><string>地址</string></property></widget></item> | |||
| <item row="1" column="1"><widget class="QSpinBox" name="indexSpinBox"><property name="maximum"><number>4000</number></property></widget></item> | |||
| <item row="2" column="0"><widget class="QLabel" name="conditionLabel"><property name="text"><string>触发条件</string></property></widget></item> | |||
| <item row="2" column="1"><widget class="QComboBox" name="conditionComboBox"/></item> | |||
| <item row="3" column="0"><widget class="QLabel" name="thresholdLabel"><property name="text"><string>阈值</string></property></widget></item> | |||
| <item row="3" column="1"><widget class="QSpinBox" name="thresholdSpinBox"><property name="minimum"><number>-32768</number></property><property name="maximum"><number>32767</number></property></widget></item> | |||
| <item row="4" column="0"><widget class="QLabel" name="messageLabel"><property name="text"><string>报警文本</string></property></widget></item> | |||
| <item row="4" column="1"><widget class="QLineEdit" name="messageEdit"><property name="placeholderText"><string>例如:温度过高</string></property></widget></item> | |||
| </layout> | |||
| </item> | |||
| <item> | |||
| <layout class="QHBoxLayout" name="operationLayout"> | |||
| <item><widget class="QPushButton" name="addButton"><property name="text"><string>新增</string></property></widget></item> | |||
| <item><widget class="QPushButton" name="updateButton"><property name="text"><string>更新</string></property></widget></item> | |||
| <item><widget class="QPushButton" name="removeButton"><property name="text"><string>删除</string></property></widget></item> | |||
| <item><spacer name="operationSpacer"><property name="orientation"><enum>Qt::Horizontal</enum></property><property name="sizeHint" stdset="0"><size><width>40</width><height>20</height></size></property></spacer></item> | |||
| <item><widget class="QDialogButtonBox" name="buttonBox"><property name="standardButtons"><set>QDialogButtonBox::Close</set></property></widget></item> | |||
| </layout> | |||
| </item> | |||
| </layout> | |||
| </widget> | |||
| <resources/> | |||
| <connections/> | |||
| </ui> | |||
| @@ -2,7 +2,9 @@ | |||
| #include "services/hmi_editor_service.h" | |||
| #include "services/hmi_runtime_service.h" | |||
| #include "services/alarm_service.h" | |||
| #include <QDateTime> | |||
| #include <QGraphicsItem> | |||
| #include <QGraphicsRectItem> | |||
| #include <QGraphicsScene> | |||
| @@ -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<std::chrono::seconds>( | |||
| 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<void(const std::string &, const QPointF &)> moved, | |||
| std::function<void(const std::string &, HmiButtonEvent)> button_event, | |||
| std::function<void(const std::string &)> numeric_input_activated, | |||
| std::function<void(const std::string &)> page_navigation) | |||
| std::function<void(const std::string &)> page_navigation, | |||
| std::function<void(const std::string &)> 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<int>(control_.text.size()))); | |||
| const int maximum_rows = std::max( | |||
| 0, | |||
| static_cast<int>((rect.height() - kAlarmHeaderHeight) | |||
| / kAlarmRowHeight)); | |||
| const int visible_rows = std::min( | |||
| maximum_rows, static_cast<int>(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<std::size_t>(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<int>(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<AlarmRecord> &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<int>( | |||
| (event->pos().y() - kAlarmHeaderHeight) / kAlarmRowHeight); | |||
| if (row >= 0 && row < static_cast<int>(alarm_records_.size())) | |||
| { | |||
| const AlarmRecord &record = alarm_records_[static_cast<std::size_t>(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<void(const std::string &, HmiButtonEvent)> button_event_; | |||
| std::function<void(const std::string &)> numeric_input_activated_; | |||
| std::function<void(const std::string &)> page_navigation_; | |||
| std::function<void(const std::string &)> alarm_acknowledge_; | |||
| std::vector<AlarmRecord> 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); | |||
| @@ -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; | |||
| // 当前画布投影的页面标识 | |||
| @@ -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<HmiNavigationService>(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); | |||
| @@ -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<HmiNavigationService> owned_hmi_navigation_service_; | |||
| HmiNavigationService *hmi_navigation_service_ = nullptr; | |||
| @@ -304,6 +304,8 @@ | |||
| <addaction name="addNumericInputAction"/> | |||
| <addaction name="addLabelAction"/> | |||
| <addaction name="addPageJumpAction"/> | |||
| <addaction name="addAlarmListAction"/> | |||
| <addaction name="configureAlarmsAction"/> | |||
| <addaction name="deleteControlAction"/> | |||
| </widget> | |||
| <widget class="QToolBar" name="logicToolBar"> | |||
| @@ -902,6 +904,22 @@ | |||
| <string>添加页面跳转控件</string> | |||
| </property> | |||
| </action> | |||
| <action name="addAlarmListAction"> | |||
| <property name="text"> | |||
| <string>报警列表</string> | |||
| </property> | |||
| <property name="toolTip"> | |||
| <string>添加报警列表控件</string> | |||
| </property> | |||
| </action> | |||
| <action name="configureAlarmsAction"> | |||
| <property name="text"> | |||
| <string>报警配置</string> | |||
| </property> | |||
| <property name="toolTip"> | |||
| <string>配置 M 和 D 报警条件</string> | |||
| </property> | |||
| </action> | |||
| <action name="deleteControlAction"> | |||
| <property name="text"> | |||
| <string>删除</string> | |||
| @@ -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); | |||
| @@ -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; | |||
| @@ -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 <algorithm> | |||
| #include <exception> | |||
| #include <iostream> | |||
| #include <stdexcept> | |||
| #include <string> | |||
| 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; | |||
| } | |||
| @@ -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 | |||
| @@ -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 \ | |||
| @@ -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); | |||
| @@ -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 \ | |||
| @@ -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 | |||
| @@ -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<QComboBox *>(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<QListWidget>(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(); | |||
| @@ -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 | |||
| @@ -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 \ | |||
| @@ -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(); | |||
| @@ -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 \ | |||
| @@ -46,7 +46,11 @@ public: | |||
| initial_read = false; | |||
| } | |||
| void setPollAddresses(const std::vector<RegisterAddress> &) override {} | |||
| void setPollAddresses( | |||
| const std::vector<RegisterAddress> &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<RegisterAddress> &pollAddresses() const | |||
| { | |||
| return poll_addresses; | |||
| } | |||
| private: | |||
| PlcConnectionState connection_state = PlcConnectionState::Disconnected; | |||
| bool initial_read = false; | |||
| @@ -84,6 +93,7 @@ private: | |||
| std::function<void(bool)> initial_read_changed; | |||
| std::function<void()> cache_updated; | |||
| std::function<void(const std::string &)> error_reported; | |||
| std::vector<RegisterAddress> 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<RegisterAddress>({ | |||
| {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"); | |||
| @@ -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 \ | |||