浏览代码

feat: 增加HMI报警管理与运行显示

main
suyu 1 个月前
父节点
当前提交
8ed43bfedb
共有 38 个文件被更改,包括 1494 次插入23 次删除
  1. +9
    -0
      app/integrated_platform.pro
  2. +53
    -0
      app/src/domain/alarm_model.cpp
  3. +24
    -0
      app/src/domain/alarm_model.h
  4. +3
    -2
      app/src/domain/hmi_model.cpp
  5. +2
    -1
      app/src/domain/hmi_model.h
  6. +12
    -0
      app/src/domain/project_model.cpp
  7. +3
    -0
      app/src/domain/project_model.h
  8. +130
    -0
      app/src/infrastructure/json_project_storage.cpp
  9. +6
    -0
      app/src/main.cpp
  10. +103
    -0
      app/src/services/alarm_editor_service.cpp
  11. +42
    -0
      app/src/services/alarm_editor_service.h
  12. +114
    -0
      app/src/services/alarm_service.cpp
  13. +44
    -0
      app/src/services/alarm_service.h
  14. +22
    -4
      app/src/services/hmi_editor_service.cpp
  15. +4
    -0
      app/src/services/runtime_mode_service.cpp
  16. +245
    -0
      app/src/ui/alarm_configuration_dialog.cpp
  17. +35
    -0
      app/src/ui/alarm_configuration_dialog.h
  18. +49
    -0
      app/src/ui/alarm_configuration_dialog.ui
  19. +142
    -5
      app/src/ui/hmi_editor_widget.cpp
  20. +3
    -0
      app/src/ui/hmi_editor_widget.h
  21. +47
    -3
      app/src/ui/main_window.cpp
  22. +9
    -0
      app/src/ui/main_window.h
  23. +18
    -0
      app/src/ui/main_window.ui
  24. +6
    -1
      app/src/ui/runtime_monitor_widget.cpp
  25. +2
    -0
      app/src/ui/runtime_monitor_widget.h
  26. +140
    -0
      app/tests/alarm_service_tests.cpp
  27. +33
    -0
      app/tests/alarm_service_tests.pro
  28. +2
    -0
      app/tests/domain_tests.pro
  29. +11
    -0
      app/tests/hmi_editor_service_tests.cpp
  30. +2
    -0
      app/tests/hmi_editor_service_tests.pro
  31. +2
    -0
      app/tests/logic_editor_service_tests.pro
  32. +95
    -2
      app/tests/main_window_tests.cpp
  33. +9
    -0
      app/tests/main_window_tests.pro
  34. +2
    -0
      app/tests/plc_runtime_tests.pro
  35. +46
    -4
      app/tests/project_management_tests.cpp
  36. +2
    -0
      app/tests/project_management_tests.pro
  37. +21
    -1
      app/tests/runtime_mode_service_tests.cpp
  38. +2
    -0
      app/tests/runtime_mode_service_tests.pro

+ 9
- 0
app/integrated_platform.pro 查看文件

@@ -17,6 +17,7 @@ INCLUDEPATH += src
SOURCES += \
src/main.cpp \
src/ui/main_window.cpp \
src/ui/alarm_configuration_dialog.cpp \
src/ui/plc_connection_dialog.cpp \
src/ui/free_monitor_widget.cpp \
src/ui/runtime_monitor_widget.cpp \
@@ -24,11 +25,14 @@ SOURCES += \
src/domain/register_repository.cpp \
src/domain/active_register_repository.cpp \
src/domain/register_monitor_model.cpp \
src/domain/alarm_model.cpp \
src/domain/hmi_model.cpp \
src/domain/control_logic_model.cpp \
src/domain/project_model.cpp \
src/domain/runtime_state.cpp \
src/services/project_service.cpp \
src/services/alarm_editor_service.cpp \
src/services/alarm_service.cpp \
src/services/hmi_editor_service.cpp \
src/services/hmi_navigation_service.cpp \
src/services/logic_editor_service.cpp \
@@ -46,6 +50,7 @@ SOURCES += \

HEADERS += \
src/ui/main_window.h \
src/ui/alarm_configuration_dialog.h \
src/ui/plc_connection_dialog.h \
src/ui/free_monitor_widget.h \
src/ui/runtime_monitor_widget.h \
@@ -53,12 +58,15 @@ HEADERS += \
src/domain/register_repository.h \
src/domain/active_register_repository.h \
src/domain/register_monitor_model.h \
src/domain/alarm_model.h \
src/domain/hmi_model.h \
src/domain/control_logic_model.h \
src/domain/project_model.h \
src/domain/runtime_state.h \
src/domain/project_storage.h \
src/services/project_service.h \
src/services/alarm_editor_service.h \
src/services/alarm_service.h \
src/services/hmi_editor_service.h \
src/services/hmi_navigation_service.h \
src/services/logic_editor_service.h \
@@ -77,6 +85,7 @@ HEADERS += \

FORMS += \
src/ui/main_window.ui \
src/ui/alarm_configuration_dialog.ui \
src/ui/plc_connection_dialog.ui \
src/ui/free_monitor_widget.ui \
src/ui/runtime_monitor_widget.ui

+ 53
- 0
app/src/domain/alarm_model.cpp 查看文件

@@ -0,0 +1,53 @@
#include "alarm_model.h"

namespace {

void setError(std::string *error, const std::string &message)
{
if (error != nullptr)
{
*error = message;
}
}

} // namespace

bool AlarmDefinition::validate(std::string *error) const
{
if (id.empty())
{
setError(error, "报警定义 ID 不能为空");
return false;
}
if (message.empty())
{
setError(error, "报警文本不能为空");
return false;
}
if (!address.isValid())
{
setError(error, "报警定义使用了无效地址");
return false;
}
if (condition == AlarmCondition::MOn)
{
if (address.area() != RegisterArea::M)
{
setError(error, "M ON 报警必须使用 M 地址");
return false;
}
return true;
}
if (condition == AlarmCondition::DHigh
|| condition == AlarmCondition::DLow)
{
if (address.area() != RegisterArea::D)
{
setError(error, "D 高限和低限报警必须使用 D 地址");
return false;
}
return true;
}
setError(error, "不支持的报警触发条件");
return false;
}

+ 24
- 0
app/src/domain/alarm_model.h 查看文件

@@ -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;
};

+ 3
- 2
app/src/domain/hmi_model.cpp 查看文件

@@ -40,6 +40,7 @@ bool isSupportedControlType(HmiControlType type)
case HmiControlType::NumericInput:
case HmiControlType::Label:
case HmiControlType::PageJump:
case HmiControlType::AlarmList:
return true;
}
return false;
@@ -95,7 +96,7 @@ bool HmiControl::validate(std::string *error) const
}
if (!supportsRegisterBinding(type) && binding.has_value())
{
setError(error, "文本和页面跳转控件不能绑定寄存器");
setError(error, "文本、页面跳转和报警列表控件不能绑定寄存器");
return false;
}
if (type == HmiControlType::PageJump)
@@ -116,7 +117,7 @@ bool HmiControl::validate(std::string *error) const

bool HmiControl::isConfigured() const
{
if (type == HmiControlType::Label)
if (type == HmiControlType::Label || type == HmiControlType::AlarmList)
{
return true;
}


+ 2
- 1
app/src/domain/hmi_model.h 查看文件

@@ -42,7 +42,8 @@ enum class HmiControlType
NumericDisplay, // 数值显示
NumericInput, // 数值输入
Label, // 标签
PageJump // 页面跳转
PageJump, // 页面跳转
AlarmList // 报警列表
};

/**


+ 12
- 0
app/src/domain/project_model.cpp 查看文件

@@ -101,6 +101,18 @@ bool Project::validate(std::string *error) const
setError(error, "工程内的控制逻辑名称必须唯一");
return false;
}
if (containsDuplicateId(alarmDefinitions))
{
setError(error, "工程内的报警定义 ID 必须唯一");
return false;
}
for (const AlarmDefinition &definition : alarmDefinitions)
{
if (!definition.validate(error))
{
return false;
}
}
for (const HmiPage &page : hmiPages)
{
// 工程聚合校验会向下委托页面和控件的完整规则


+ 3
- 0
app/src/domain/project_model.h 查看文件

@@ -1,5 +1,6 @@
#pragma once

#include "alarm_model.h"
#include "control_logic_model.h"
#include "hmi_model.h"

@@ -26,6 +27,8 @@ struct Project
std::vector<HmiPage> hmiPages;
// 进入运行态时显示的初始 HMI 页面
std::string initialHmiPageId;
// 工程统一管理的报警定义
std::vector<AlarmDefinition> alarmDefinitions;
// 工程包含的控制逻辑集合
std::vector<ControlLogic> controlLogics;



+ 130
- 0
app/src/infrastructure/json_project_storage.cpp 查看文件

@@ -282,6 +282,10 @@ QString hmiControlTypeName(HmiControlType type)
{
return QStringLiteral("pageJump");
}
case HmiControlType::AlarmList:
{
return QStringLiteral("alarmList");
}
default:
{
return {};
@@ -317,6 +321,10 @@ bool parseHmiControlType(
{
*type = HmiControlType::PageJump;
}
else if (value == "alarmList")
{
*type = HmiControlType::AlarmList;
}
else
{
return state->fail(
@@ -378,6 +386,98 @@ bool parseHmiButtonOperation(
return true;
}

QString alarmConditionName(AlarmCondition condition)
{
switch (condition)
{
case AlarmCondition::MOn:
{
return QStringLiteral("mOn");
}
case AlarmCondition::DHigh:
{
return QStringLiteral("dHigh");
}
case AlarmCondition::DLow:
{
return QStringLiteral("dLow");
}
default:
{
return {};
}
}
}

bool parseAlarmCondition(
const std::string &value, AlarmCondition *condition, ParseState *state)
{
if (value == "mOn")
{
*condition = AlarmCondition::MOn;
}
else if (value == "dHigh")
{
*condition = AlarmCondition::DHigh;
}
else if (value == "dLow")
{
*condition = AlarmCondition::DLow;
}
else
{
return state->fail(
ProjectStorageError::InvalidField,
"不支持的报警触发条件:" + value);
}
return true;
}

QJsonObject serializeAlarmDefinition(const AlarmDefinition &definition)
{
QJsonObject object;
object.insert(QStringLiteral("id"), fromUtf8(definition.id));
object.insert(QStringLiteral("address"), serializeAddress(definition.address));
object.insert(
QStringLiteral("condition"), alarmConditionName(definition.condition));
object.insert(QStringLiteral("threshold"), definition.threshold);
object.insert(QStringLiteral("message"), fromUtf8(definition.message));
return object;
}

bool parseAlarmDefinition(
const QJsonObject &object,
const std::string &context,
AlarmDefinition *definition,
ParseState *state)
{
QJsonObject address;
std::string condition;
int threshold = 0;
if (!readString(object, "id", context, &definition->id, state)
|| !readObject(object, "address", context, &address, state)
|| !readString(object, "condition", context, &condition, state)
|| !readInt(
object,
"threshold",
context,
std::numeric_limits<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)
{


+ 6
- 0
app/src/main.cpp 查看文件

@@ -14,6 +14,8 @@
#include "domain/active_register_repository.h"
#include "services/hmi_editor_service.h"
#include "services/hmi_runtime_service.h"
#include "services/alarm_editor_service.h"
#include "services/alarm_service.h"
#include "services/hmi_navigation_service.h"
#include "services/logic_editor_service.h"
#include "services/offline_simulation_service.h"
@@ -40,6 +42,8 @@ int main(int argc, char *argv[])
ActiveRegisterRepository active_register_repository(virtual_register_repository);
PlcCommunicationService plc_communication_service(plc_register_repository);
HmiRuntimeService hmi_runtime_service(active_register_repository);
AlarmEditorService alarm_editor_service(project_service);
AlarmService alarm_service(project_service, active_register_repository);
RegisterMonitorService register_monitor_service(active_register_repository);
OfflineSimulationService offline_simulation_service(virtual_register_repository);
RuntimeModeService runtime_mode_service(
@@ -55,6 +59,8 @@ int main(int argc, char *argv[])
hmi_editor_service,
logic_editor_service,
hmi_runtime_service,
alarm_editor_service,
alarm_service,
hmi_navigation_service,
register_monitor_service);
main_window.show();


+ 103
- 0
app/src/services/alarm_editor_service.cpp 查看文件

@@ -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;
}
}

+ 42
- 0
app/src/services/alarm_editor_service.h 查看文件

@@ -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_;
};

+ 114
- 0
app/src/services/alarm_service.cpp 查看文件

@@ -0,0 +1,114 @@
#include "alarm_service.h"

#include "domain/alarm_model.h"
#include "domain/register_repository.h"
#include "project_service.h"

#include <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;
}
}
}

+ 44
- 0
app/src/services/alarm_service.h 查看文件

@@ -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_;
};

+ 22
- 4
app/src/services/hmi_editor_service.cpp 查看文件

@@ -45,6 +45,10 @@ std::string controlPrefix(HmiControlType type)
{
return "page-jump";
}
case HmiControlType::AlarmList:
{
return "alarm-list";
}
default:
{
return "control";
@@ -80,6 +84,10 @@ std::string defaultText(HmiControlType type)
{
return "页面跳转";
}
case HmiControlType::AlarmList:
{
return "报警信息";
}
default:
{
return "控件";
@@ -484,6 +492,7 @@ bool HmiEditorService::validateEditableControl(
case HmiControlType::NumericInput:
case HmiControlType::Label:
case HmiControlType::PageJump:
case HmiControlType::AlarmList:
break;
default:
setError(error, "不支持的 HMI 控件类型");
@@ -533,10 +542,11 @@ bool HmiEditorService::validateEditableControl(
return false;
}
if ((control.type == HmiControlType::Label
|| control.type == HmiControlType::PageJump)
|| control.type == HmiControlType::PageJump
|| control.type == HmiControlType::AlarmList)
&& control.binding.has_value())
{
setError(error, "文本和页面跳转控件不能绑定寄存器");
setError(error, "文本、页面跳转和报警列表控件不能绑定寄存器");
return false;
}
if (control.type == HmiControlType::PageJump)
@@ -582,8 +592,16 @@ HmiControl HmiEditorService::makeControl(
control.id = makeUniqueId(page, controlPrefix(type));
control.type = type;
control.text = defaultText(type);
control.bounds.width = type == HmiControlType::Indicator ? 64 : 120;
control.bounds.height = type == HmiControlType::Indicator ? 64 : 40;
if (type == HmiControlType::AlarmList)
{
control.bounds.width = std::min(360, page.width);
control.bounds.height = std::min(180, page.height);
}
else
{
control.bounds.width = type == HmiControlType::Indicator ? 64 : 120;
control.bounds.height = type == HmiControlType::Indicator ? 64 : 40;
}
if (type == HmiControlType::PageJump)
{
control.pageJump = HmiPageJumpConfig{};


+ 4
- 0
app/src/services/runtime_mode_service.cpp 查看文件

@@ -219,6 +219,10 @@ void RuntimeModeService::refreshPlcPollAddresses()
}
}
}
for (const AlarmDefinition &definition : project.alarmDefinitions)
{
addresses.push_back(definition.address);
}
for (const ControlLogic &logic : project.controlLogics)
{
for (const LadderRung &rung : logic.rungs)


+ 245
- 0
app/src/ui/alarm_configuration_dialog.cpp 查看文件

@@ -0,0 +1,245 @@
#include "alarm_configuration_dialog.h"

#include "services/alarm_editor_service.h"
#include "ui_alarm_configuration_dialog.h"

#include <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());
}

+ 35
- 0
app/src/ui/alarm_configuration_dialog.h 查看文件

@@ -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_;
};

+ 49
- 0
app/src/ui/alarm_configuration_dialog.ui 查看文件

@@ -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>

+ 142
- 5
app/src/ui/hmi_editor_widget.cpp 查看文件

@@ -2,7 +2,9 @@

#include "services/hmi_editor_service.h"
#include "services/hmi_runtime_service.h"
#include "services/alarm_service.h"

#include <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);


+ 3
- 0
app/src/ui/hmi_editor_widget.h 查看文件

@@ -13,6 +13,7 @@

class HmiEditorService;
class HmiRuntimeService;
class AlarmService;
class QGraphicsScene;
class QResizeEvent;
enum class HmiButtonEvent;
@@ -36,6 +37,7 @@ public:
explicit HmiEditorWidget(
HmiEditorService &editor_service,
HmiRuntimeService &runtime_service,
AlarmService &alarm_service,
QWidget *parent = nullptr);

/**
@@ -114,6 +116,7 @@ private:
HmiEditorService &editor_service_;
// 提供运行态寄存器读写能力,画布不直接访问寄存器仓库
HmiRuntimeService &runtime_service_;
AlarmService &alarm_service_;
// 持有所有页面图元和页面边框
QGraphicsScene *scene_ = nullptr;
// 当前画布投影的页面标识


+ 47
- 3
app/src/ui/main_window.cpp 查看文件

@@ -1,6 +1,7 @@
#include "main_window.h"

#include "hmi_editor_widget.h"
#include "alarm_configuration_dialog.h"
#include "logic_editor_widget.h"
#include "plc_connection_dialog.h"
#include "runtime_monitor_widget.h"
@@ -8,6 +9,8 @@
#include "services/hmi_editor_service.h"
#include "services/hmi_navigation_service.h"
#include "services/hmi_runtime_service.h"
#include "services/alarm_editor_service.h"
#include "services/alarm_service.h"
#include "services/logic_editor_service.h"
#include "services/project_service.h"
#include "services/runtime_mode_service.h"
@@ -176,6 +179,10 @@ QString controlTypeText(HmiControlType type)
{
return MainWindow::tr("页面跳转");
}
case HmiControlType::AlarmList:
{
return MainWindow::tr("报警列表");
}
default:
{
return MainWindow::tr("未知控件");
@@ -191,6 +198,8 @@ MainWindow::MainWindow(
HmiEditorService &hmi_editor_service,
LogicEditorService &logic_editor_service,
HmiRuntimeService &hmi_runtime_service,
AlarmEditorService &alarm_editor_service,
AlarmService &alarm_service,
RegisterMonitorService &register_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 &register_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);


+ 9
- 0
app/src/ui/main_window.h 查看文件

@@ -32,6 +32,8 @@ class RuntimeModeService;
class ProjectService;
class HmiEditorService;
class HmiRuntimeService;
class AlarmEditorService;
class AlarmService;
class HmiNavigationService;
class HmiEditorWidget;
class LogicEditorService;
@@ -63,6 +65,8 @@ public:
HmiEditorService &hmi_editor_service,
LogicEditorService &logic_editor_service,
HmiRuntimeService &hmi_runtime_service,
AlarmEditorService &alarm_editor_service,
AlarmService &alarm_service,
RegisterMonitorService &register_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 &register_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 &register_monitor_service_;
std::unique_ptr<HmiNavigationService> owned_hmi_navigation_service_;
HmiNavigationService *hmi_navigation_service_ = nullptr;


+ 18
- 0
app/src/ui/main_window.ui 查看文件

@@ -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>


+ 6
- 1
app/src/ui/runtime_monitor_widget.cpp 查看文件

@@ -4,6 +4,7 @@
#include "hmi_editor_widget.h"
#include "logic_editor_widget.h"
#include "services/hmi_navigation_service.h"
#include "services/alarm_service.h"
#include "services/project_service.h"
#include "ui_runtime_monitor_widget.h"

@@ -34,6 +35,7 @@ RuntimeMonitorWidget::RuntimeMonitorWidget(
LogicEditorService &logic_editor_service,
ProjectService &project_service,
HmiNavigationService &hmi_navigation_service,
AlarmService &alarm_service,
RegisterMonitorService &register_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);


+ 2
- 0
app/src/ui/runtime_monitor_widget.h 查看文件

@@ -17,6 +17,7 @@ class FreeMonitorWidget;
class HmiEditorService;
class HmiEditorWidget;
class HmiRuntimeService;
class AlarmService;
class HmiNavigationService;
class LogicEditorService;
class LogicEditorWidget;
@@ -34,6 +35,7 @@ public:
LogicEditorService &logic_editor_service,
ProjectService &project_service,
HmiNavigationService &hmi_navigation_service,
AlarmService &alarm_service,
RegisterMonitorService &register_monitor_service,
QWidget *parent = nullptr);
~RuntimeMonitorWidget() override;


+ 140
- 0
app/tests/alarm_service_tests.cpp 查看文件

@@ -0,0 +1,140 @@
#include "domain/project_storage.h"
#include "domain/register_repository.h"
#include "services/alarm_editor_service.h"
#include "services/alarm_service.h"
#include "services/project_service.h"

#include <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;
}

+ 33
- 0
app/tests/alarm_service_tests.pro 查看文件

@@ -0,0 +1,33 @@
TEMPLATE = app
TARGET = alarm_service_tests

CONFIG += console c++17 testcase warn_on
CONFIG -= app_bundle qt

INCLUDEPATH += ../src

SOURCES += \
alarm_service_tests.cpp \
../src/domain/register_address.cpp \
../src/domain/register_repository.cpp \
../src/domain/alarm_model.cpp \
../src/domain/hmi_model.cpp \
../src/domain/control_logic_model.cpp \
../src/domain/project_model.cpp \
../src/services/project_service.cpp \
../src/services/alarm_editor_service.cpp \
../src/services/alarm_service.cpp

HEADERS += \
../src/domain/register_address.h \
../src/domain/register_repository.h \
../src/domain/alarm_model.h \
../src/domain/hmi_model.h \
../src/domain/control_logic_model.h \
../src/domain/project_model.h \
../src/domain/project_storage.h \
../src/services/project_service.h \
../src/services/alarm_editor_service.h \
../src/services/alarm_service.h

QT -= gui

+ 2
- 0
app/tests/domain_tests.pro 查看文件

@@ -10,6 +10,7 @@ SOURCES += \
domain_tests.cpp \
../src/domain/register_address.cpp \
../src/domain/register_repository.cpp \
../src/domain/alarm_model.cpp \
../src/domain/hmi_model.cpp \
../src/domain/control_logic_model.cpp \
../src/domain/project_model.cpp \
@@ -18,6 +19,7 @@ SOURCES += \
HEADERS += \
../src/domain/register_address.h \
../src/domain/register_repository.h \
../src/domain/alarm_model.h \
../src/domain/hmi_model.h \
../src/domain/control_logic_model.h \
../src/domain/project_model.h \


+ 11
- 0
app/tests/hmi_editor_service_tests.cpp 查看文件

@@ -175,6 +175,17 @@ void testPageLifecycleAndNavigation()
require(label.succeeded
&& service.findControl(main_id, label.id)->isConfigured(),
"the editor service must expose a configured static Label control");
const HmiEditorResult alarm_list = service.addControl(
main_id, HmiControlType::AlarmList);
require(alarm_list.succeeded
&& service.findControl(main_id, alarm_list.id)->isConfigured()
&& !service.findControl(main_id, alarm_list.id)->binding.has_value(),
"AlarmList must be configured without a register binding");
HmiControl bound_alarm_list = *service.findControl(main_id, alarm_list.id);
bound_alarm_list.binding = RegisterAddress{RegisterArea::M, 20};
require(!service.updateControl(
main_id, alarm_list.id, bound_alarm_list).succeeded,
"AlarmList must reject direct register bindings");
const HmiEditorResult jump = service.addControl(main_id, HmiControlType::PageJump);
require(jump.succeeded, "the editor service must expose PageJump creation");
HmiControl jump_control = *service.findControl(main_id, jump.id);


+ 2
- 0
app/tests/hmi_editor_service_tests.pro 查看文件

@@ -10,6 +10,7 @@ SOURCES += \
hmi_editor_service_tests.cpp \
../src/domain/register_address.cpp \
../src/domain/register_repository.cpp \
../src/domain/alarm_model.cpp \
../src/domain/hmi_model.cpp \
../src/domain/control_logic_model.cpp \
../src/domain/project_model.cpp \
@@ -21,6 +22,7 @@ SOURCES += \
HEADERS += \
../src/domain/register_address.h \
../src/domain/register_repository.h \
../src/domain/alarm_model.h \
../src/domain/hmi_model.h \
../src/domain/control_logic_model.h \
../src/domain/project_model.h \


+ 2
- 0
app/tests/logic_editor_service_tests.pro 查看文件

@@ -13,6 +13,7 @@ SOURCES += \
../src/domain/project_model.cpp \
../src/domain/hmi_model.cpp \
../src/domain/register_repository.cpp \
../src/domain/alarm_model.cpp \
../src/services/project_service.cpp \
../src/services/logic_editor_service.cpp

@@ -22,6 +23,7 @@ HEADERS += \
../src/domain/project_model.h \
../src/domain/hmi_model.h \
../src/domain/register_repository.h \
../src/domain/alarm_model.h \
../src/domain/project_storage.h \
../src/services/project_service.h \
../src/services/logic_editor_service.h

+ 95
- 2
app/tests/main_window_tests.cpp 查看文件

@@ -1,6 +1,8 @@
#include "domain/active_register_repository.h"
#include "domain/project_storage.h"
#include "domain/register_repository.h"
#include "services/alarm_editor_service.h"
#include "services/alarm_service.h"
#include "services/hmi_editor_service.h"
#include "services/hmi_runtime_service.h"
#include "services/hmi_navigation_service.h"
@@ -278,6 +280,7 @@ void testRuntimeButtonMouseInteraction()
HmiEditorService editor_service(project_service);
VirtualRegisterRepository repository;
HmiRuntimeService runtime_service(repository);
AlarmService alarm_service(project_service, repository);
const HmiEditorResult page_result = editor_service.ensureDefaultPage();
require(page_result.succeeded, "runtime button test must create an HMI page");
const HmiEditorResult button_result = editor_service.addControl(
@@ -290,7 +293,7 @@ void testRuntimeButtonMouseInteraction()
require(editor_service.updateControl(page_result.id, button.id, button).succeeded,
"runtime button test must bind the button to M0");

HmiEditorWidget view(editor_service, runtime_service);
HmiEditorWidget view(editor_service, runtime_service, alarm_service);
view.resize(900, 600);
view.setPageId(page_result.id);
view.setEditingEnabled(false);
@@ -361,11 +364,13 @@ void testRuntimePageJumpDoesNotRequireRegisterWritePermission()

VirtualRegisterRepository repository;
HmiRuntimeService runtime_service(repository);
HmiEditorWidget view(editor_service, runtime_service);
AlarmService alarm_service(project_service, repository);
HmiEditorWidget view(editor_service, runtime_service, alarm_service);
view.setPageId(source_page);
view.setEditingEnabled(false);
view.setRuntimeActive(true);
view.setRuntimeWriteEnabled(false);
view.refreshRuntimeValues();
view.resize(900, 560);
view.show();
QApplication::processEvents();
@@ -394,6 +399,73 @@ void testRuntimePageJumpDoesNotRequireRegisterWritePermission()
"PageJump must remain active when register writes are disabled");
}

void testRuntimeAlarmListInteraction()
{
TestProjectStorage storage;
ProjectService project_service(storage);
HmiEditorService editor_service(project_service);
AlarmEditorService alarm_editor_service(project_service);
VirtualRegisterRepository repository;
HmiRuntimeService runtime_service(repository);
AlarmService alarm_service(project_service, repository);

const std::string page_id = editor_service.ensureDefaultPage().id;
const HmiEditorResult list_result = editor_service.addControl(
page_id, HmiControlType::AlarmList);
require(list_result.succeeded,
"runtime alarm test must create an AlarmList control");
const HmiControl *alarm_list = editor_service.findControl(
page_id, list_result.id);
require(alarm_list != nullptr && !alarm_list->binding.has_value(),
"AlarmList must not require a register binding");

AlarmDefinition definition;
definition.address = RegisterAddress{RegisterArea::M, 0};
definition.condition = AlarmCondition::MOn;
definition.message = "Emergency stop";
const AlarmEditorResult alarm_result =
alarm_editor_service.addDefinition(definition);
require(alarm_result.succeeded,
"runtime alarm test must create an M alarm definition");

repository.writeBit(RegisterAddress{RegisterArea::M, 0}, true);
alarm_service.refresh();
require(alarm_service.records().size() == 1
&& alarm_service.records().front().active,
"an active M alarm must be available to AlarmList");

HmiEditorWidget view(editor_service, runtime_service, alarm_service);
view.setPageId(page_id);
view.setEditingEnabled(false);
view.setRuntimeActive(true);
view.setRuntimeWriteEnabled(false);
view.refreshRuntimeValues();
view.resize(900, 560);
view.show();
QApplication::processEvents();

const QPoint header_position = view.mapFromScene(QPointF(
alarm_list->bounds.x + 20.0, alarm_list->bounds.y + 10.0));
QTest::mouseClick(
view.viewport(), Qt::LeftButton, Qt::NoModifier, header_position);
require(!alarm_service.records().front().acknowledged,
"clicking the AlarmList header must not acknowledge an alarm");

const QPoint first_row_position = view.mapFromScene(QPointF(
alarm_list->bounds.x + 20.0, alarm_list->bounds.y + 34.0));
QTest::mouseClick(
view.viewport(), Qt::LeftButton, Qt::NoModifier, first_row_position);
require(alarm_service.records().front().acknowledged,
"clicking an active AlarmList row must acknowledge the alarm");

repository.writeBit(RegisterAddress{RegisterArea::M, 0}, false);
alarm_service.refresh();
view.refreshRuntimeValues();
require(!alarm_service.records().front().active
&& alarm_service.records().front().clearedAt.has_value(),
"clearing the M condition must retain a cleared history record");
}

void testModeActionsControlEditingAvailability()
{
// 验证模式动作会同步禁用编辑入口,并在失败时恢复当前选择
@@ -403,6 +475,8 @@ void testModeActionsControlEditingAvailability()
LogicEditorService logic_editor_service(project_service);
VirtualRegisterRepository repository;
HmiRuntimeService runtime_service(repository);
AlarmEditorService alarm_editor_service(project_service);
AlarmService alarm_service(project_service, repository);
OfflineSimulationService simulation_service(repository);
RuntimeModeService mode_service(project_service, simulation_service);
RegisterMonitorService monitor_service(repository);
@@ -412,6 +486,8 @@ void testModeActionsControlEditingAvailability()
editor_service,
logic_editor_service,
runtime_service,
alarm_editor_service,
alarm_service,
monitor_service);
window.resize(1000, 640);
window.show();
@@ -644,6 +720,8 @@ void testPlcConfigurationUsesDialog()
LogicEditorService logic_editor_service(project_service);
VirtualRegisterRepository repository;
HmiRuntimeService runtime_service(repository);
AlarmEditorService alarm_editor_service(project_service);
AlarmService alarm_service(project_service, repository);
OfflineSimulationService simulation_service(repository);
RuntimeModeService mode_service(project_service, simulation_service);
RegisterMonitorService monitor_service(repository);
@@ -653,6 +731,8 @@ void testPlcConfigurationUsesDialog()
editor_service,
logic_editor_service,
runtime_service,
alarm_editor_service,
alarm_service,
monitor_service);
require(window.findChild<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();


+ 9
- 0
app/tests/main_window_tests.pro 查看文件

@@ -11,6 +11,7 @@ INCLUDEPATH += ../src
SOURCES += \
main_window_tests.cpp \
../src/ui/main_window.cpp \
../src/ui/alarm_configuration_dialog.cpp \
../src/ui/plc_connection_dialog.cpp \
../src/ui/free_monitor_widget.cpp \
../src/ui/runtime_monitor_widget.cpp \
@@ -20,11 +21,14 @@ SOURCES += \
../src/domain/register_repository.cpp \
../src/domain/active_register_repository.cpp \
../src/domain/register_monitor_model.cpp \
../src/domain/alarm_model.cpp \
../src/domain/hmi_model.cpp \
../src/domain/control_logic_model.cpp \
../src/domain/project_model.cpp \
../src/domain/runtime_state.cpp \
../src/services/project_service.cpp \
../src/services/alarm_editor_service.cpp \
../src/services/alarm_service.cpp \
../src/services/hmi_editor_service.cpp \
../src/services/hmi_navigation_service.cpp \
../src/services/logic_editor_service.cpp \
@@ -36,6 +40,7 @@ SOURCES += \

HEADERS += \
../src/ui/main_window.h \
../src/ui/alarm_configuration_dialog.h \
../src/ui/plc_connection_dialog.h \
../src/ui/free_monitor_widget.h \
../src/ui/runtime_monitor_widget.h \
@@ -45,12 +50,15 @@ HEADERS += \
../src/domain/register_repository.h \
../src/domain/active_register_repository.h \
../src/domain/register_monitor_model.h \
../src/domain/alarm_model.h \
../src/domain/hmi_model.h \
../src/domain/control_logic_model.h \
../src/domain/project_model.h \
../src/domain/project_storage.h \
../src/domain/runtime_state.h \
../src/services/project_service.h \
../src/services/alarm_editor_service.h \
../src/services/alarm_service.h \
../src/services/hmi_editor_service.h \
../src/services/hmi_navigation_service.h \
../src/services/logic_editor_service.h \
@@ -65,6 +73,7 @@ HEADERS += \

FORMS += \
../src/ui/main_window.ui \
../src/ui/alarm_configuration_dialog.ui \
../src/ui/plc_connection_dialog.ui \
../src/ui/free_monitor_widget.ui \
../src/ui/runtime_monitor_widget.ui

+ 2
- 0
app/tests/plc_runtime_tests.pro 查看文件

@@ -14,6 +14,7 @@ SOURCES += \
../src/domain/register_repository.cpp \
../src/domain/active_register_repository.cpp \
../src/domain/register_monitor_model.cpp \
../src/domain/alarm_model.cpp \
../src/domain/hmi_model.cpp \
../src/domain/control_logic_model.cpp \
../src/domain/project_model.cpp \
@@ -30,6 +31,7 @@ HEADERS += \
../src/domain/register_repository.h \
../src/domain/active_register_repository.h \
../src/domain/register_monitor_model.h \
../src/domain/alarm_model.h \
../src/domain/hmi_model.h \
../src/domain/control_logic_model.h \
../src/domain/project_model.h \


+ 46
- 4
app/tests/project_management_tests.cpp 查看文件

@@ -72,6 +72,12 @@ Project makeExampleProject()
settings_jump.text = "Settings";
settings_jump.pageJump = HmiPageJumpConfig{"settings-page"};

HmiControl alarm_list;
alarm_list.id = "alarm-list";
alarm_list.type = HmiControlType::AlarmList;
alarm_list.bounds = {10, 200, 360, 180};
alarm_list.text = "Alarms";

HmiPage page;
page.id = "main-page";
page.name = "Main";
@@ -81,6 +87,7 @@ Project makeExampleProject()
page.controls.push_back(target_input);
page.controls.push_back(title_label);
page.controls.push_back(settings_jump);
page.controls.push_back(alarm_list);

HmiPage settings_page;
settings_page.id = "settings-page";
@@ -139,6 +146,18 @@ Project makeExampleProject()
project.hmiPages.push_back(page);
project.hmiPages.push_back(settings_page);
project.initialHmiPageId = page.id;
project.alarmDefinitions.push_back(
{"alarm-emergency",
RegisterAddress{RegisterArea::M, 10},
AlarmCondition::MOn,
0,
"Emergency stop"});
project.alarmDefinitions.push_back(
{"alarm-temperature",
RegisterAddress{RegisterArea::D, 2},
AlarmCondition::DHigh,
80,
"Temperature high"});
project.controlLogics.push_back(logic);
ControlLogic draft_logic;
draft_logic.id = "draft-logic";
@@ -188,6 +207,8 @@ void testEmptyProjectRoundTrip()
require(service.project().hmiPages.empty(), "empty project must have no HMI pages");
require(service.project().controlLogics.empty(),
"empty project must have no control logics");
require(service.project().alarmDefinitions.empty(),
"empty project must have no alarm definitions");
}

void testExampleProjectRoundTrip()
@@ -205,6 +226,7 @@ void testExampleProjectRoundTrip()
const QString invalid_operation_path = directory.filePath("invalid-operation.json");
const QString missing_initial_path = directory.filePath("missing-initial.json");
const QString missing_target_path = directory.filePath("missing-target.json");
const QString missing_alarms_path = directory.filePath("missing-alarms.json");
require(service.saveAs(first_path.toStdString()).succeeded,
"example project save must succeed");
const QByteArray saved_json = readBytes(first_path);
@@ -218,8 +240,10 @@ void testExampleProjectRoundTrip()
require(saved_json.contains("\"formatVersion\": \"1.0\"")
&& saved_json.contains("\"buttonOperation\": \"setOn\"")
&& saved_json.contains("\"initialHmiPageId\": \"main-page\"")
&& saved_json.contains("\"targetPageId\": \"settings-page\""),
"version 1.0 projects must persist the current multi-page schema");
&& saved_json.contains("\"targetPageId\": \"settings-page\"")
&& saved_json.contains("\"alarmDefinitions\"")
&& saved_json.contains("\"type\": \"alarmList\""),
"version 1.0 projects must persist pages and alarm definitions");
require(!saved_json.contains("\"stages\"")
&& !saved_json.contains("\"branches\""),
"current project format must not contain the removed stage model");
@@ -234,8 +258,8 @@ void testExampleProjectRoundTrip()
require(project.hmiPages.size() == 2, "HMI page count must survive round trip");
require(project.initialHmiPageId == "main-page",
"the initial HMI page id must survive round trip");
require(project.hmiPages.front().controls.size() == 6,
"register, Label and PageJump controls must survive round trip");
require(project.hmiPages.front().controls.size() == 7,
"register, navigation and AlarmList controls must survive round trip");
require(project.hmiPages.front().controls.front().binding->area()
== RegisterArea::M,
"HMI M binding must survive round trip");
@@ -254,6 +278,14 @@ void testExampleProjectRoundTrip()
&& project.hmiPages.front().controls.at(5).pageJump->targetPageId
== "settings-page",
"Label and PageJump typed data must survive round trip");
require(project.hmiPages.front().controls.at(6).type
== HmiControlType::AlarmList,
"AlarmList control type must survive round trip");
require(project.alarmDefinitions.size() == 2
&& project.alarmDefinitions.front().condition
== AlarmCondition::MOn
&& project.alarmDefinitions.at(1).threshold == 80,
"M and D alarm definitions must survive round trip");
require(project.controlLogics.size() == 2,
"control logic count must survive round trip");
require(!project.controlLogics.front().enabled,
@@ -297,6 +329,16 @@ void testExampleProjectRoundTrip()
&& missing_result.storageError == ProjectStorageError::MissingField,
"the 1.0 schema must require initialHmiPageId without migration defaults");

QJsonObject missing_alarms = QJsonDocument::fromJson(saved_json).object();
missing_alarms.remove(QStringLiteral("alarmDefinitions"));
writeText(
missing_alarms_path,
QJsonDocument(missing_alarms).toJson(QJsonDocument::Compact));
missing_result = service.load(missing_alarms_path.toStdString());
require(!missing_result.succeeded
&& missing_result.storageError == ProjectStorageError::MissingField,
"the 1.0 schema must require alarmDefinitions without migration defaults");

QJsonObject missing_target = QJsonDocument::fromJson(saved_json).object();
QJsonArray pages = missing_target.value(QStringLiteral("hmiPages")).toArray();
QJsonObject main_page = pages.at(0).toObject();


+ 2
- 0
app/tests/project_management_tests.pro 查看文件

@@ -12,6 +12,7 @@ SOURCES += \
project_management_tests.cpp \
../src/domain/register_address.cpp \
../src/domain/register_repository.cpp \
../src/domain/alarm_model.cpp \
../src/domain/hmi_model.cpp \
../src/domain/control_logic_model.cpp \
../src/domain/project_model.cpp \
@@ -22,6 +23,7 @@ SOURCES += \
HEADERS += \
../src/domain/register_address.h \
../src/domain/register_repository.h \
../src/domain/alarm_model.h \
../src/domain/hmi_model.h \
../src/domain/control_logic_model.h \
../src/domain/project_model.h \


+ 21
- 1
app/tests/runtime_mode_service_tests.cpp 查看文件

@@ -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");


+ 2
- 0
app/tests/runtime_mode_service_tests.pro 查看文件

@@ -14,6 +14,7 @@ SOURCES += \
../src/domain/register_repository.cpp \
../src/domain/active_register_repository.cpp \
../src/domain/register_monitor_model.cpp \
../src/domain/alarm_model.cpp \
../src/domain/hmi_model.cpp \
../src/domain/control_logic_model.cpp \
../src/domain/project_model.cpp \
@@ -28,6 +29,7 @@ HEADERS += \
../src/domain/register_repository.h \
../src/domain/active_register_repository.h \
../src/domain/register_monitor_model.h \
../src/domain/alarm_model.h \
../src/domain/hmi_model.h \
../src/domain/control_logic_model.h \
../src/domain/project_model.h \


正在加载...
取消
保存