Просмотр исходного кода

feat: 优化 HMI 运行版导出与 PLC 重连

main
suyu 3 недель назад
Родитель
Сommit
48c62d62c8
53 измененных файлов: 1651 добавлений и 347 удалений
  1. +2
    -0
      app/integrated_platform.pro
  2. +9
    -0
      app/src/domain/active_register_repository.cpp
  3. +7
    -1
      app/src/domain/active_register_repository.h
  4. +5
    -0
      app/src/domain/alarm_model.cpp
  5. +3
    -3
      app/src/domain/alarm_model.h
  6. +44
    -0
      app/src/domain/control_logic_model.cpp
  7. +82
    -58
      app/src/domain/control_logic_model.h
  8. +8
    -0
      app/src/domain/hmi_control_registry.cpp
  9. +18
    -1
      app/src/domain/hmi_model.cpp
  10. +60
    -45
      app/src/domain/hmi_model.h
  11. +19
    -3
      app/src/domain/project_model.cpp
  12. +10
    -0
      app/src/domain/register_monitor_model.cpp
  13. +15
    -13
      app/src/domain/register_monitor_model.h
  14. +10
    -3
      app/src/domain/register_repository.h
  15. +19
    -0
      app/src/domain/register_value_type.cpp
  16. +29
    -11
      app/src/domain/register_value_type.h
  17. +5
    -0
      app/src/domain/runtime_state.cpp
  18. +1
    -0
      app/src/domain/runtime_state.h
  19. +12
    -0
      app/src/domain/virtual_register_repository.cpp
  20. +3
    -0
      app/src/domain/virtual_register_repository.h
  21. +22
    -2
      app/src/infrastructure/application_settings_loader.cpp
  22. +2
    -0
      app/src/infrastructure/application_settings_loader.h
  23. +30
    -0
      app/src/infrastructure/json_project_storage.cpp
  24. +3
    -1
      app/src/infrastructure/json_project_storage.h
  25. +4
    -0
      app/src/infrastructure/plc_communication_error_classifier.cpp
  26. +6
    -5
      app/src/infrastructure/plc_communication_error_classifier.h
  27. +31
    -0
      app/src/infrastructure/plc_communication_service.cpp
  28. +4
    -3
      app/src/infrastructure/plc_communication_service.h
  29. +16
    -0
      app/src/infrastructure/plc_discovery_service.cpp
  30. +19
    -15
      app/src/infrastructure/plc_discovery_service.h
  31. +416
    -0
      app/src/infrastructure/runtime_settings_loader.cpp
  32. +40
    -0
      app/src/infrastructure/runtime_settings_loader.h
  33. +16
    -2
      app/src/main.cpp
  34. +31
    -0
      app/src/services/project_service.cpp
  35. +2
    -0
      app/src/services/project_service.h
  36. +49
    -16
      app/src/services/runtime_mode_service.cpp
  37. +3
    -0
      app/src/services/runtime_mode_service.h
  38. +215
    -78
      app/src/ui/main_window.cpp
  39. +8
    -2
      app/src/ui/main_window.h
  40. +53
    -22
      app/src/ui/runtime_monitor_widget.cpp
  41. +5
    -9
      app/src/ui/runtime_monitor_widget.h
  42. +0
    -2
      app/src/ui/runtime_monitor_widget.ui
  43. +19
    -1
      app/src/ui/runtime_monitor_window.cpp
  44. +9
    -1
      app/src/ui/runtime_monitor_window.h
  45. +18
    -40
      app/src/ui/runtime_panel_controller.cpp
  46. +2
    -9
      app/src/ui/runtime_panel_controller.h
  47. +32
    -0
      app/tests/project_management_tests.cpp
  48. +75
    -0
      app/tests/runtime_mode_service_tests.cpp
  49. +3
    -1
      app/tests/runtime_mode_service_tests.pro
  50. +139
    -0
      app/tests/runtime_settings_tests.cpp
  51. +15
    -0
      app/tests/runtime_settings_tests.pro
  52. +2
    -0
      app/tests/tests.pro
  53. +1
    -0
      scripts/run_qt_tests.ps1

+ 2
- 0
app/integrated_platform.pro Просмотреть файл

@@ -61,6 +61,7 @@ SOURCES += \
src/infrastructure/plc_communication_service.cpp \
src/infrastructure/plc_discovery_service.cpp \
src/infrastructure/application_settings_loader.cpp \
src/infrastructure/runtime_settings_loader.cpp \
src/infrastructure/runtime_project_bundle.cpp \
src/infrastructure/json_project_storage.cpp \
src/ui/hmi_editor_widget.cpp \
@@ -118,6 +119,7 @@ HEADERS += \
src/infrastructure/plc_communication_service.h \
src/infrastructure/plc_discovery_service.h \
src/infrastructure/application_settings_loader.h \
src/infrastructure/runtime_settings_loader.h \
src/infrastructure/runtime_project_bundle.h \
src/infrastructure/json_project_storage.h \
src/ui/hmi_editor_widget.h \


+ 9
- 0
app/src/domain/active_register_repository.cpp Просмотреть файл

@@ -1,44 +1,53 @@
#include "active_register_repository.h"

// 使用初始仓库作为当前寄存器访问目标
ActiveRegisterRepository::ActiveRegisterRepository(RegisterRepository &initial_repository)
: repository_(&initial_repository)
{
}

// 切换后续读写请求的转发目标,不在仓库之间复制寄存器值
void ActiveRegisterRepository::use(RegisterRepository &repository)
{
// 只替换转发目标,不复制寄存器值;切换模式必须使用各自数据源
repository_ = &repository;
}

// 将 M 位读取请求转发给当前活动仓库
BitReadResult ActiveRegisterRepository::readBit(const RegisterAddress &address) const
{
// 代理本身不保存寄存器数据,结果由实际仓库负责提供
return repository_->readBit(address);
}

// 将 M 位写入请求转发给当前活动仓库
RegisterWriteResult ActiveRegisterRepository::writeBit(
const RegisterAddress &address, bool value)
{
return repository_->writeBit(address, value);
}

// 将单个 D 字读取请求转发给当前活动仓库
WordReadResult ActiveRegisterRepository::readWord(const RegisterAddress &address) const
{
return repository_->readWord(address);
}

// 将单个 D 字写入请求转发给当前活动仓库
RegisterWriteResult ActiveRegisterRepository::writeWord(
const RegisterAddress &address, std::int16_t value)
{
return repository_->writeWord(address, value);
}

// 将连续 D 字读取请求转发给当前活动仓库
WordsReadResult ActiveRegisterRepository::readWords(
const RegisterAddress &address, int count) const
{
return repository_->readWords(address, count);
}

// 将连续 D 字写入请求转发给当前活动仓库
RegisterWriteResult ActiveRegisterRepository::writeWords(
const RegisterAddress &address,
const std::vector<std::int16_t> &values)


+ 7
- 1
app/src/domain/active_register_repository.h Просмотреть файл

@@ -10,17 +10,23 @@ public:

// 切换活动仓库;运行模式服务负责保证切换时机和生命周期
void use(RegisterRepository &repository);
// 从当前活动仓库读取一个 M 区位值
BitReadResult readBit(const RegisterAddress &address) const override;
// 向当前活动仓库写入一个 M 区位值
RegisterWriteResult writeBit(const RegisterAddress &address, bool value) override;
// 从当前活动仓库读取一个 D 区字值
WordReadResult readWord(const RegisterAddress &address) const override;
// 向当前活动仓库写入一个 D 区字值
RegisterWriteResult writeWord(
const RegisterAddress &address, std::int16_t value) override;
// 从当前活动仓库连续读取多个 D 区字值
WordsReadResult readWords(
const RegisterAddress &address, int count) const override;
// 向当前活动仓库连续写入多个 D 区字值
RegisterWriteResult writeWords(
const RegisterAddress &address,
const std::vector<std::int16_t> &values) override;

private:
RegisterRepository *repository_ = nullptr;
RegisterRepository *repository_ = nullptr; // 当前接收寄存器读写请求的仓库
};

+ 5
- 0
app/src/domain/alarm_model.cpp Просмотреть файл

@@ -4,8 +4,10 @@

namespace {

// 在调用方提供错误字符串时保存校验失败原因
void setError(std::string *error, const std::string &message)
{
// 错误字符串是可选输出,不提供时只返回校验结果
if (error != nullptr)
{
*error = message;
@@ -14,6 +16,7 @@ void setError(std::string *error, const std::string &message)

} // namespace

// 校验报警定义的文本、地址和触发条件是否匹配
bool AlarmDefinition::validate(std::string *error) const
{
if (id.empty())
@@ -41,6 +44,7 @@ bool AlarmDefinition::validate(std::string *error) const
setError(error, "报警定义使用了无效地址");
return false;
}
// M ON/OFF 只能监控 M 区,避免把位条件用于 D 字地址
if (condition == AlarmCondition::MOn
|| condition == AlarmCondition::MOff)
{
@@ -51,6 +55,7 @@ bool AlarmDefinition::validate(std::string *error) const
}
return true;
}
// D 高低限只能监控 D 区,阈值字段由运行时服务解释
if (condition == AlarmCondition::DHigh
|| condition == AlarmCondition::DLow)
{


+ 3
- 3
app/src/domain/alarm_model.h Просмотреть файл

@@ -5,7 +5,7 @@
#include <cstdint>
#include <string>

// 报警触发条件:M 位为 1/0,或 D 字高于/低于阈值
// 报警触发条件的类型
enum class AlarmCondition
{
MOn, // M 位为 1 时触发
@@ -14,7 +14,7 @@ enum class AlarmCondition
DLow // D 寄存器值低于阈值时触发
};

// 可保存的报警定义;运行时 AlarmService 根据此定义生成报警记录
// 可保存的报警定义,运行时服务根据它生成报警记录
struct AlarmDefinition
{
std::string id; // 报警定义的唯一 ID
@@ -23,6 +23,6 @@ struct AlarmDefinition
std::int16_t threshold = 0; // D 地址比较时使用的阈值
std::string message; // 报警触发后显示的提示文字

// 校验标识、地址区域、消息长度和条件之间的组合是否有效
// 校验标识、地址区域、消息长度和触发条件是否匹配
bool validate(std::string *error = nullptr) const;
};

+ 44
- 0
app/src/domain/control_logic_model.cpp Просмотреть файл

@@ -8,14 +8,17 @@

namespace {

// 在调用方提供错误字符串时保存校验失败原因
void setError(std::string *error, const std::string &message)
{
// 错误字符串是可选输出,不提供时只返回校验结果
if (error != nullptr)
{
*error = message;
}
}

// 判断字符串是否为空或只包含空白字符
bool isBlank(const std::string &value)
{
return value.empty()
@@ -24,12 +27,14 @@ bool isBlank(const std::string &value)
[](unsigned char character) { return character <= 0x20U; });
}

// 判断文本中是否包含换行符
bool containsLineBreak(const std::string &value)
{
return value.find('\r') != std::string::npos
|| value.find('\n') != std::string::npos;
}

// 检查所有带输出的梯形图行是否能通过导线和竖线连到左母线
bool validateOutputConnectivity(
const ControlLogic &logic,
std::string *error)
@@ -47,6 +52,7 @@ bool validateOutputConnectivity(
row_indices.emplace(logic.rungs[row].id, row);
}

// power 表示当前列边界是否仍能从左母线获得电源
std::vector<bool> power(row_count, true);
std::vector<int> last_reachable_boundary(row_count, 0);
for (int boundary = 0;
@@ -55,6 +61,7 @@ bool validateOutputConnectivity(
{
std::vector<std::size_t> parent(row_count);
std::iota(parent.begin(), parent.end(), 0U);
// 每个列边界重新合并竖线连接的行,计算该边界的连通分量
const auto root = [&parent](std::size_t row)
{
while (parent[row] != row)
@@ -130,6 +137,7 @@ bool validateOutputConnectivity(
return true;
}

// 校验字操作使用的地址必须是有效的 D 区地址
bool validateWordAddress(
const RegisterAddress &address,
const std::string &description,
@@ -143,6 +151,7 @@ bool validateWordAddress(
return true;
}

// 校验普通触点配置
bool validateConfig(const ContactNodeConfig &config, std::string *error)
{
if (!config.address.isValid() || config.address.area() != RegisterArea::M)
@@ -159,6 +168,7 @@ bool validateConfig(const ContactNodeConfig &config, std::string *error)
return true;
}

// 校验边沿触点配置
bool validateConfig(const EdgeContactNodeConfig &config, std::string *error)
{
if (!config.address.isValid() || config.address.area() != RegisterArea::M)
@@ -174,6 +184,7 @@ bool validateConfig(const EdgeContactNodeConfig &config, std::string *error)
return true;
}

// 校验线圈配置
bool validateConfig(const CoilNodeConfig &config, std::string *error)
{
if (!config.address.isValid() || config.address.area() != RegisterArea::M)
@@ -191,6 +202,7 @@ bool validateConfig(const CoilNodeConfig &config, std::string *error)
return true;
}

// 校验比较节点配置
bool validateConfig(const CompareNodeConfig &config, std::string *error)
{
if (!config.address.isValid() || config.address.area() != RegisterArea::D)
@@ -211,12 +223,14 @@ bool validateConfig(const CompareNodeConfig &config, std::string *error)
return true;
}

// 校验 MOVE 节点配置
bool validateConfig(const MoveNodeConfig &config, std::string *error)
{
return config.source.validate(error)
&& validateWordAddress(config.destination, "MOVE 目标", error);
}

// 校验算术节点配置
bool validateConfig(const ArithmeticNodeConfig &config, std::string *error)
{
if (config.operation != ArithmeticOperation::Add
@@ -230,6 +244,7 @@ bool validateConfig(const ArithmeticNodeConfig &config, std::string *error)
&& validateWordAddress(config.destination, "算术指令目标", error);
}

// 将有效地址追加到收集结果中
void appendValidAddress(
std::vector<RegisterAddress> *addresses,
const RegisterAddress &address)
@@ -240,6 +255,7 @@ void appendValidAddress(
}
}

// 把 ID 加入集合并检查当前作用域内是否重复
bool addUniqueId(
const std::string &id,
const std::string &description,
@@ -256,6 +272,7 @@ bool addUniqueId(

} // namespace

// 校验字操作数的来源类型和地址
bool WordOperand::validate(std::string *error) const
{
if (kind == WordOperandKind::Constant)
@@ -270,9 +287,11 @@ bool WordOperand::validate(std::string *error) const
return validateWordAddress(address, "字操作数", error);
}

// 根据节点类型返回它的主地址:条件节点返回监控地址,输出节点返回目标地址
std::optional<RegisterAddress> registerAddressForLogicNode(
const LogicNodeConfig &config)
{
// std::visit 根据 variant 当前保存的配置类型选择对应分支
return std::visit(
[](const auto &value) -> std::optional<RegisterAddress>
{
@@ -292,6 +311,7 @@ std::optional<RegisterAddress> registerAddressForLogicNode(
config);
}

// 收集节点读写涉及的全部有效寄存器地址
void collectRegisterAddressesForLogicNode(
const LogicNodeConfig &config,
std::vector<RegisterAddress> *addresses)
@@ -300,6 +320,7 @@ void collectRegisterAddressesForLogicNode(
{
return;
}
// if constexpr 让每种 variant 配置只编译属于自己的字段访问
std::visit(
[addresses](const auto &value)
{
@@ -335,6 +356,7 @@ void collectRegisterAddressesForLogicNode(
config);
}

// 校验逻辑节点的 ID 和具体配置
bool LogicNode::validate(std::string *error) const
{
if (id.empty())
@@ -352,11 +374,13 @@ bool LogicNode::validate(std::string *error) const
config);
}

// 返回节点是否已完成运行配置
bool LogicNode::isConfigured() const
{
return configured;
}

// 判断节点是否为条件节点
bool LogicNode::isCondition() const
{
return std::holds_alternative<ContactNodeConfig>(config)
@@ -364,6 +388,7 @@ bool LogicNode::isCondition() const
|| std::holds_alternative<CompareNodeConfig>(config);
}

// 判断节点是否为输出节点
bool LogicNode::isOutput() const
{
return std::holds_alternative<CoilNodeConfig>(config)
@@ -371,6 +396,7 @@ bool LogicNode::isOutput() const
|| std::holds_alternative<ArithmeticNodeConfig>(config);
}

// 校验梯形图网格的 ID、类型和节点内容
bool LadderCell::validate(std::string *error) const
{
if (id.empty())
@@ -405,6 +431,7 @@ bool LadderCell::validate(std::string *error) const
return true;
}

// 校验竖线的关联行和列边界
bool VerticalConnection::validate(std::string *error) const
{
if (id.empty() || upperRungId.empty() || lowerRungId.empty())
@@ -433,11 +460,13 @@ bool VerticalConnection::validate(std::string *error) const
return true;
}

// 使用结构校验规则校验梯形图行
bool LadderRung::validate(std::string *error) const
{
return validateStructure(error);
}

// 校验梯形图行的保存结构和 ID 唯一性
bool LadderRung::validateStructure(std::string *error) const
{
if (id.empty() || isBlank(name))
@@ -503,6 +532,7 @@ bool LadderRung::validateStructure(std::string *error) const
return true;
}

// 校验梯形图行是否具备运行所需的完整节点配置
bool LadderRung::validateForRunning(std::string *error) const
{
if (!validateStructure(error))
@@ -531,6 +561,7 @@ bool LadderRung::validateForRunning(std::string *error) const
return true;
}

// 向上查找指定行所在连续网络的首行
std::size_t ControlLogic::networkHeadIndex(std::size_t rung_index) const
{
if (rung_index >= rungs.size())
@@ -558,17 +589,20 @@ std::size_t ControlLogic::networkHeadIndex(std::size_t rung_index) const
return rung_index;
}

// 使用指定工程限制校验控制逻辑结构
bool ControlLogic::validate(
const ProjectLimitSettings &limits, std::string *error) const
{
return validateStructure(limits, error);
}

// 使用默认工程限制校验控制逻辑结构
bool ControlLogic::validateStructure(std::string *error) const
{
return validateStructure(defaultProjectLimitSettings(), error);
}

// 校验控制逻辑的基本信息、行、竖线和 ID 唯一性
bool ControlLogic::validateStructure(
const ProjectLimitSettings &limits, std::string *error) const
{
@@ -682,11 +716,13 @@ bool ControlLogic::validateStructure(
return true;
}

// 使用默认工程限制校验控制逻辑是否可以运行
bool ControlLogic::validateForRunning(std::string *error) const
{
return validateForRunning(defaultProjectLimitSettings(), error);
}

// 校验控制逻辑的结构、节点配置和输出连通性
bool ControlLogic::validateForRunning(
const ProjectLimitSettings &limits, std::string *error) const
{
@@ -704,6 +740,7 @@ bool ControlLogic::validateForRunning(
return validateOutputConnectivity(*this, error);
}

// 按 ID 查找梯形图行中的只读网格
const LadderCell *findLadderCell(
const LadderRung &rung, const std::string &cell_id)
{
@@ -713,12 +750,15 @@ const LadderCell *findLadderCell(
return found == rung.cells.cend() ? nullptr : &*found;
}

// 按 ID 查找梯形图行中的可写网格
LadderCell *findLadderCell(LadderRung &rung, const std::string &cell_id)
{
// 复用 const 版本,保证两种查找路径的判断规则一致
return const_cast<LadderCell *>(findLadderCell(
static_cast<const LadderRung &>(rung), cell_id));
}

// 按 ID 查找控制逻辑中的只读竖线
const VerticalConnection *findVerticalConnection(
const ControlLogic &logic, const std::string &connection_id)
{
@@ -731,13 +771,16 @@ const VerticalConnection *findVerticalConnection(
return found == logic.verticalConnections.cend() ? nullptr : &*found;
}

// 按 ID 查找控制逻辑中的可写竖线
VerticalConnection *findVerticalConnection(
ControlLogic &logic, const std::string &connection_id)
{
// 这里先复用 const 查找,再移除 const 是因为传入对象本身可写
return const_cast<VerticalConnection *>(findVerticalConnection(
static_cast<const ControlLogic &>(logic), connection_id));
}

// 收集梯形图行中的条件节点
void collectConditionNodes(
const LadderRung &rung, std::vector<const LogicNode *> *nodes)
{
@@ -754,6 +797,7 @@ void collectConditionNodes(
}
}

// 收集控制逻辑中的全部条件节点和输出节点
void collectLogicNodes(
const ControlLogic &logic, std::vector<const LogicNode *> *nodes)
{


+ 82
- 58
app/src/domain/control_logic_model.h Просмотреть файл

@@ -11,91 +11,92 @@

enum class ContactMode
{
NormallyOpen,
NormallyClosed
NormallyOpen, // 常开触点,M 为 1 时条件成立
NormallyClosed // 常闭触点,M 为 0 时条件成立
};

enum class CoilMode
{
Normal,
Set,
Reset
Normal, // 普通线圈,直接写入条件结果
Set, // 条件成立时置位 M
Reset // 条件成立时复位 M
};

enum class EdgeMode
{
Rising,
Falling
Rising, // 检测 M 从 0 变为 1
Falling // 检测 M 从 1 变为 0
};

enum class ComparisonOperator
{
Equal,
NotEqual,
LessThan,
LessThanOrEqual,
GreaterThan,
GreaterThanOrEqual
Equal, // 等于
NotEqual, // 不等于
LessThan, // 小于
LessThanOrEqual, // 小于或等于
GreaterThan, // 大于
GreaterThanOrEqual // 大于或等于
};

enum class WordOperandKind
{
Constant,
Register
Constant, // 使用固定数值
Register // 使用 D 区寄存器的值
};

struct WordOperand
{
WordOperandKind kind = WordOperandKind::Constant;
RegisterAddress address{RegisterArea::D, 0};
std::int16_t constant = 0;
WordOperandKind kind = WordOperandKind::Constant; // 操作数来源类型
RegisterAddress address{RegisterArea::D, 0}; // 寄存器操作数使用的 D 地址
std::int16_t constant = 0; // 常量操作数的数值

// 校验操作数类型和寄存器地址是否有效
bool validate(std::string *error = nullptr) const;
};

struct ContactNodeConfig
{
RegisterAddress address{RegisterArea::M, 0};
ContactMode mode = ContactMode::NormallyOpen;
RegisterAddress address{RegisterArea::M, 0}; // 触点监控的 M 地址
ContactMode mode = ContactMode::NormallyOpen; // 触点的常开或常闭模式
};

struct EdgeContactNodeConfig
{
RegisterAddress address{RegisterArea::M, 0};
EdgeMode mode = EdgeMode::Rising;
RegisterAddress address{RegisterArea::M, 0}; // 边沿触点监控的 M 地址
EdgeMode mode = EdgeMode::Rising; // 上升沿或下降沿模式
};

struct CoilNodeConfig
{
RegisterAddress address{RegisterArea::M, 0};
CoilMode mode = CoilMode::Normal;
RegisterAddress address{RegisterArea::M, 0}; // 线圈写入的 M 地址
CoilMode mode = CoilMode::Normal; // 普通、置位或复位模式
};

struct CompareNodeConfig
{
RegisterAddress address{RegisterArea::D, 0};
ComparisonOperator comparison = ComparisonOperator::Equal;
std::int16_t value = 0;
RegisterAddress address{RegisterArea::D, 0}; // 参与比较的 D 地址
ComparisonOperator comparison = ComparisonOperator::Equal; // 比较方式
std::int16_t value = 0; // 与 D 值比较的常量
};

struct MoveNodeConfig
{
WordOperand source;
RegisterAddress destination{RegisterArea::D, 0};
WordOperand source; // MOVE 的源操作数
RegisterAddress destination{RegisterArea::D, 0}; // MOVE 的目标 D 地址
};

enum class ArithmeticOperation
{
Add,
Subtract
Add, // 加法
Subtract // 减法
};

struct ArithmeticNodeConfig
{
ArithmeticOperation operation = ArithmeticOperation::Add;
WordOperand left;
WordOperand right;
RegisterAddress destination{RegisterArea::D, 0};
ArithmeticOperation operation = ArithmeticOperation::Add; // 算术运算类型
WordOperand left; // 左操作数
WordOperand right; // 右操作数
RegisterAddress destination{RegisterArea::D, 0}; // 运算结果写入的 D 地址
};

using LogicNodeConfig = std::variant<
@@ -106,98 +107,121 @@ using LogicNodeConfig = std::variant<
MoveNodeConfig,
ArithmeticNodeConfig>;

// 返回逻辑节点主要使用的地址
std::optional<RegisterAddress> registerAddressForLogicNode(
const LogicNodeConfig &config);
// 收集逻辑节点涉及的全部有效寄存器地址
void collectRegisterAddressesForLogicNode(
const LogicNodeConfig &config,
std::vector<RegisterAddress> *addresses);

struct LogicNode
{
std::string id;
LogicNodeConfig config;
bool configured = true;
std::string id; // 逻辑节点的唯一 ID
LogicNodeConfig config; // 节点类型和具体参数
bool configured = true; // 是否已完成运行所需的配置

// 校验节点 ID 和节点参数
bool validate(std::string *error = nullptr) const;
// 返回节点是否已完成运行配置
bool isConfigured() const;
// 返回节点是否属于条件节点
bool isCondition() const;
// 返回节点是否属于输出节点
bool isOutput() const;
};

enum class LadderCellKind
{
Gap,
Wire,
Node
Gap, // 空白网格
Wire, // 横向导线网格
Node // 条件节点网格
};

// 条件区的一个固定网格,横线和空白与条件节点具有同等持久化地位
struct LadderCell
{
std::string id;
LadderCellKind kind = LadderCellKind::Gap;
std::optional<LogicNode> node;
std::string id; // 网格的唯一 ID
LadderCellKind kind = LadderCellKind::Gap; // 网格类型
std::optional<LogicNode> node; // 网格中的条件节点

// 校验网格 ID、类型和节点内容是否匹配
bool validate(std::string *error = nullptr) const;
};

// 两个相邻视觉行之间、指定列边界上的一段竖线
struct VerticalConnection
{
std::string id;
std::string upperRungId;
std::string lowerRungId;
int columnBoundary = 0;
std::string id; // 竖线的唯一 ID
std::string upperRungId; // 竖线连接的上方行 ID
std::string lowerRungId; // 竖线连接的下方行 ID
int columnBoundary = 0; // 竖线所在的列边界

// 校验竖线的关联行和列边界
bool validate(std::string *error = nullptr) const;
};

// 连续梯形图的一条视觉行,不再是隔离的网络容器
struct LadderRung
{
std::string id;
std::string name;
std::string comment;
std::optional<LogicNode> output;
std::vector<LadderCell> cells;
std::string id; // 梯形图行的唯一 ID
std::string name; // 梯形图行名称
std::string comment; // 网络首行上的注释
std::optional<LogicNode> output; // 行末输出节点
std::vector<LadderCell> cells; // 固定数量的条件网格

// 校验梯形图行的完整结构
bool validate(std::string *error = nullptr) const;
// 校验保存时需要满足的结构规则
bool validateStructure(std::string *error = nullptr) const;
// 校验运行前的结构和节点配置
bool validateForRunning(std::string *error = nullptr) const;
};

// 一张连续梯形图,网络由横竖连接关系自然形成
struct ControlLogic
{
std::string id;
std::string name;
std::vector<LadderRung> rungs;
bool enabled = true;
std::vector<VerticalConnection> verticalConnections;
std::string id; // 控制逻辑的唯一 ID
std::string name; // 控制逻辑名称
std::vector<LadderRung> rungs; // 按视觉顺序保存的梯形图行
bool enabled = true; // 是否参与运行扫描
std::vector<VerticalConnection> verticalConnections; // 行之间的竖向连接

// 返回指定行所在连续网络的首行下标
std::size_t networkHeadIndex(std::size_t rung_index) const;
// 使用指定工程限制校验控制逻辑
bool validate(
const ProjectLimitSettings &limits,
std::string *error = nullptr) const;
// 使用默认工程限制校验控制逻辑结构
bool validateStructure(std::string *error = nullptr) const;
// 使用指定工程限制校验控制逻辑结构
bool validateStructure(
const ProjectLimitSettings &limits,
std::string *error = nullptr) const;
// 使用默认工程限制校验运行前的控制逻辑
bool validateForRunning(std::string *error = nullptr) const;
// 使用指定工程限制校验运行前的控制逻辑
bool validateForRunning(
const ProjectLimitSettings &limits,
std::string *error = nullptr) const;
};

// 在梯形图行中查找指定 ID 的只读网格
const LadderCell *findLadderCell(
const LadderRung &rung, const std::string &cell_id);
// 在梯形图行中查找指定 ID 的可写网格
LadderCell *findLadderCell(
LadderRung &rung, const std::string &cell_id);
// 在控制逻辑中查找指定 ID 的只读竖线
const VerticalConnection *findVerticalConnection(
const ControlLogic &logic, const std::string &connection_id);
// 在控制逻辑中查找指定 ID 的可写竖线
VerticalConnection *findVerticalConnection(
ControlLogic &logic, const std::string &connection_id);
// 收集一行中的全部条件节点
void collectConditionNodes(
const LadderRung &rung, std::vector<const LogicNode *> *nodes);
// 收集控制逻辑中的全部条件节点和输出节点
void collectLogicNodes(
const ControlLogic &logic, std::vector<const LogicNode *> *nodes);

+ 8
- 0
app/src/domain/hmi_control_registry.cpp Просмотреть файл

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

namespace {

// 集中保存所有 HMI 控件的稳定类型名、默认外观和寄存器能力
constexpr std::array kControlDescriptors = {
HmiControlDescriptor{HmiControlType::Button,
"button",
@@ -81,6 +82,7 @@ constexpr std::array kControlDescriptors = {
false}
};

// 新增控件类型时必须同步加入描述表,否则在编译阶段直接报错
static_assert(
kControlDescriptors.size()
== static_cast<std::size_t>(HmiControlType::Count),
@@ -88,8 +90,10 @@ static_assert(

} // namespace

// 按领域控件类型查找对应的公共描述
const HmiControlDescriptor *findHmiControlDescriptor(HmiControlType type)
{
// find_if 返回第一个匹配项,找不到时返回 end 迭代器
const auto descriptor = std::find_if(
kControlDescriptors.cbegin(),
kControlDescriptors.cend(),
@@ -100,9 +104,11 @@ const HmiControlDescriptor *findHmiControlDescriptor(HmiControlType type)
return descriptor == kControlDescriptors.cend() ? nullptr : &*descriptor;
}

// 按工程文件保存的稳定名称查找对应的公共描述
const HmiControlDescriptor *findHmiControlDescriptor(
std::string_view storage_name)
{
// string_view 只借用调用方字符串,不会额外复制名称
const auto descriptor = std::find_if(
kControlDescriptors.cbegin(),
kControlDescriptors.cend(),
@@ -113,6 +119,7 @@ const HmiControlDescriptor *findHmiControlDescriptor(
return descriptor == kControlDescriptors.cend() ? nullptr : &*descriptor;
}

// 将控件绑定类型转换为具体的 M/D 寄存器区域
std::optional<RegisterArea> hmiBindingArea(HmiBindingKind binding_kind)
{
switch (binding_kind)
@@ -129,6 +136,7 @@ std::optional<RegisterArea> hmiBindingArea(HmiBindingKind binding_kind)
case HmiBindingKind::BitOrWord:
default:
{
// None 和 BitOrWord 不能在这里确定唯一的寄存器区域
return std::nullopt;
}
}


+ 18
- 1
app/src/domain/hmi_model.cpp Просмотреть файл

@@ -11,20 +11,23 @@

namespace {

// 在提供错误字符串时写入校验失败原因
// 在调用方提供错误字符串时保存校验失败原因
void setError(std::string *error, const std::string &message)
{
// 错误字符串是可选输出,不提供时只返回校验结果
if (error != nullptr)
{
*error = message;
}
}

// 判断字符是否为十六进制数字
bool isHexDigit(char value)
{
return std::isxdigit(static_cast<unsigned char>(value)) != 0;
}

// 判断颜色是否符合 #RRGGBB 格式
bool isValidTextColor(const std::string &value)
{
return value.size() == 7U
@@ -32,6 +35,7 @@ bool isValidTextColor(const std::string &value)
&& std::all_of(value.cbegin() + 1, value.cend(), isHexDigit);
}

// 将完整字符串解析为整数,拒绝前后带有其他字符的输入
bool parseInteger(const std::string &value, int *result)
{
if (value.empty() || result == nullptr)
@@ -40,15 +44,18 @@ bool parseInteger(const std::string &value, int *result)
}
const char *begin = value.data();
const char *end = begin + value.size();
// from_chars 不受本地化设置影响,适合解析工程中保存的简单整数
const auto parsed = std::from_chars(begin, end, *result);
return parsed.ec == std::errc{} && parsed.ptr == end;
}

// 判断字符串是否为允许保存的布尔值
bool isBooleanValue(const std::string &value)
{
return value == "true" || value == "false";
}

// 判断文本是否为空或只包含空白字符
bool isBlank(const std::string &value)
{
return value.empty()
@@ -60,6 +67,7 @@ bool isBlank(const std::string &value)
});
}

// 校验状态文本内容的长度、换行和空白规则
bool validateStatusTextValue(
const std::string &value, const char *name, std::string *error)
{
@@ -82,6 +90,7 @@ bool validateStatusTextValue(
return true;
}

// 校验 D 状态文本的区间数量、边界和连续性
bool validateStatusWordConfig(
const HmiStatusWordTextConfig &config,
RegisterDataType data_type,
@@ -99,6 +108,7 @@ bool validateStatusWordConfig(
setError(error, "D 状态文本的首个下限和最后一个上限必须为不限");
return false;
}
// 整数类型的区间边界不能出现小数
const bool integer_type = data_type == RegisterDataType::Int16
|| data_type == RegisterDataType::Int32;
for (std::size_t index = 0; index < config.ranges.size(); ++index)
@@ -144,6 +154,7 @@ bool validateStatusWordConfig(
return true;
}

// 校验 HMI 外观扩展属性的格式和取值范围
bool validateAppearanceProperty(
const std::string &key, const std::string &value, std::string *error)
{
@@ -184,6 +195,7 @@ bool validateAppearanceProperty(
return true;
}

// 校验按钮启用条件的地址、类型、运算符和比较值
bool validateButtonEnableCondition(
const HmiButtonEnableCondition &condition, std::string *error)
{
@@ -257,6 +269,7 @@ bool validateButtonEnableCondition(

} // namespace

// 校验 HMI 控件的基本属性、绑定和专用配置
bool HmiControl::validate(std::string *error) const
{
const HmiControlDescriptor *descriptor = findHmiControlDescriptor(type);
@@ -341,6 +354,7 @@ bool HmiControl::validate(std::string *error) const
return false;
}
const bool status_text = type == HmiControlType::StatusText;
// 状态文本使用 D 区映射时也需要按数值控件检查地址跨度
const bool numeric = type == HmiControlType::NumericDisplay
|| type == HmiControlType::NumericInput
|| (status_text && statusText.has_value()
@@ -447,6 +461,7 @@ bool HmiControl::validate(std::string *error) const
return true;
}

// 判断控件是否已经具备运行所需的配置
bool HmiControl::isConfigured() const
{
const HmiControlDescriptor *descriptor = findHmiControlDescriptor(type);
@@ -485,6 +500,7 @@ bool HmiControl::isConfigured() const
return binding_area.has_value() && binding->area() == *binding_area;
}

// 使用指定工程限制校验页面和全部控件
bool HmiPage::validate(
const ProjectLimitSettings &limits, std::string *error) const
{
@@ -549,6 +565,7 @@ bool HmiPage::validate(
return true;
}

// 校验页面结构,并确认所有控件都可以在运行态使用
bool HmiPage::validateForRunning(
const ProjectLimitSettings &limits, std::string *error) const
{


+ 60
- 45
app/src/domain/hmi_model.h Просмотреть файл

@@ -20,10 +20,10 @@
namespace HmiAppearanceProperty
{

inline constexpr const char kTextColor[] = "textColor";
inline constexpr const char kFontSize[] = "fontSize";
inline constexpr const char kFontBold[] = "fontBold";
inline constexpr const char kFontItalic[] = "fontItalic";
inline constexpr const char kTextColor[] = "textColor"; // 字体颜色属性名
inline constexpr const char kFontSize[] = "fontSize"; // 字号属性名
inline constexpr const char kFontBold[] = "fontBold"; // 粗体属性名
inline constexpr const char kFontItalic[] = "fontItalic"; // 斜体属性名

} // namespace HmiAppearanceProperty

@@ -34,13 +34,13 @@ inline constexpr const char kFontItalic[] = "fontItalic";
*/
struct HmiRect
{
static constexpr int kDefaultWidth = 80;
static constexpr int kDefaultHeight = 32;
static constexpr int kDefaultWidth = 80; // 新建控件的默认宽度
static constexpr int kDefaultHeight = 32; // 新建控件的默认高度

int x = 0;
int y = 0;
int width = kDefaultWidth;
int height = kDefaultHeight;
int x = 0; // 矩形左上角的横坐标
int y = 0; // 矩形左上角的纵坐标
int width = kDefaultWidth; // 控件宽度
int height = kDefaultHeight; // 控件高度
};

/**
@@ -75,32 +75,36 @@ enum class HmiButtonOperation
/** @brief HMI 按钮 D 数值启用条件的比较方式 */
enum class HmiButtonConditionOperator
{
Equal,
NotEqual,
LessThan,
LessThanOrEqual,
GreaterThan,
GreaterThanOrEqual
Equal, // 等于
NotEqual, // 不等于
LessThan, // 小于
LessThanOrEqual, // 小于或等于
GreaterThan, // 大于
GreaterThanOrEqual // 大于或等于
};

// 按钮使用 M 位判断是否允许操作的条件
struct HmiButtonBitEnableCondition
{
RegisterAddress address{RegisterArea::M, 0};
bool expected = false;
RegisterAddress address{RegisterArea::M, 0}; // 条件读取的 M 地址
bool expected = false; // 允许按钮操作时期望的 M 值

// 比较两个 M 位启用条件是否完全相同
bool operator==(const HmiButtonBitEnableCondition &other) const
{
return address == other.address && expected == other.expected;
}
};

// 按钮使用 D 数值判断是否允许操作的条件
struct HmiButtonWordEnableCondition
{
RegisterAddress address{RegisterArea::D, 0};
RegisterDataType dataType = RegisterDataType::Int16;
HmiButtonConditionOperator operation = HmiButtonConditionOperator::Equal;
double value = 0.0;
RegisterAddress address{RegisterArea::D, 0}; // 条件读取的 D 地址
RegisterDataType dataType = RegisterDataType::Int16; // D 值的数据类型
HmiButtonConditionOperator operation = HmiButtonConditionOperator::Equal; // 比较方式
double value = 0.0; // 参与比较的数值

// 比较两个 D 数值启用条件是否完全相同
bool operator==(const HmiButtonWordEnableCondition &other) const
{
return address == other.address
@@ -110,32 +114,38 @@ struct HmiButtonWordEnableCondition
}
};

// 按钮启用条件可以使用 M 位或 D 数值
using HmiButtonEnableCondition = std::variant<
HmiButtonBitEnableCondition,
HmiButtonWordEnableCondition>;

// 页面跳转控件的目标配置
struct HmiPageJumpConfig
{
std::string targetPageId;
std::string targetPageId; // 页面跳转目标的 ID
};

// M 状态文本的 OFF/ON 映射配置
struct HmiStatusBitTextConfig
{
std::string offText;
std::string onText;
std::string offText; // M 为 0 时显示的文本
std::string onText; // M 为 1 时显示的文本

// 比较两个 M 状态文本配置是否完全相同
bool operator==(const HmiStatusBitTextConfig &other) const
{
return offText == other.offText && onText == other.onText;
}
};

// D 状态文本使用的一个左闭右开数值区间
struct HmiStatusValueRange
{
std::optional<double> lowerBound;
std::optional<double> upperBound;
std::string text;
std::optional<double> lowerBound; // 区间下限,不填写表示负无穷
std::optional<double> upperBound; // 区间上限,不填写表示正无穷
std::string text; // 命中区间时显示的文本

// 比较两个状态值区间是否完全相同
bool operator==(const HmiStatusValueRange &other) const
{
return lowerBound == other.lowerBound
@@ -144,16 +154,19 @@ struct HmiStatusValueRange
}
};

// D 状态文本的连续区间配置
struct HmiStatusWordTextConfig
{
std::vector<HmiStatusValueRange> ranges;
std::vector<HmiStatusValueRange> ranges; // 按顺序排列的连续数值区间

// 比较两个 D 状态文本配置是否完全相同
bool operator==(const HmiStatusWordTextConfig &other) const
{
return ranges == other.ranges;
}
};

// 状态文本可以使用 M 位映射或 D 数值区间映射
using HmiStatusTextConfig = std::variant<
HmiStatusBitTextConfig,
HmiStatusWordTextConfig>;
@@ -165,17 +178,17 @@ using HmiStatusTextConfig = std::variant<
*/
struct HmiControl
{
std::string id;
HmiControlType type = HmiControlType::Label;
HmiRect bounds;
std::string text;
std::optional<RegisterAddress> binding;
RegisterDataType dataType = RegisterDataType::Int16;
std::map<std::string, std::string> properties;
HmiButtonOperation buttonOperation = HmiButtonOperation::MomentaryOn;
std::optional<HmiButtonEnableCondition> buttonEnableCondition;
std::optional<HmiPageJumpConfig> pageJump;
std::optional<HmiStatusTextConfig> statusText;
std::string id; // 控件的唯一 ID
HmiControlType type = HmiControlType::Label; // 控件类型
HmiRect bounds; // 控件在页面中的位置和尺寸
std::string text; // 控件显示的文本
std::optional<RegisterAddress> binding; // 控件绑定的 M 或 D 地址
RegisterDataType dataType = RegisterDataType::Int16; // 绑定 D 值的数据类型
std::map<std::string, std::string> properties; // 控件的扩展外观属性
HmiButtonOperation buttonOperation = HmiButtonOperation::MomentaryOn; // 按钮写入操作
std::optional<HmiButtonEnableCondition> buttonEnableCondition; // 按钮启用条件
std::optional<HmiPageJumpConfig> pageJump; // 页面跳转配置
std::optional<HmiStatusTextConfig> statusText; // 状态文本映射配置

/**
* @brief 校验控件的标识、尺寸、扩展属性和寄存器绑定
@@ -185,6 +198,7 @@ struct HmiControl
* 位控件必须绑定有效 M 地址,字控件必须绑定有效 D 地址
*/
bool validate(std::string *error = nullptr) const;
// 返回控件是否已具备运行所需的绑定和专用配置
bool isConfigured() const;
};

@@ -195,11 +209,11 @@ struct HmiControl
*/
struct HmiPage
{
std::string id;
std::string name;
int width = ProjectLimits::kDefaultHmiPageWidth;
int height = ProjectLimits::kDefaultHmiPageHeight;
std::vector<HmiControl> controls;
std::string id; // 页面唯一 ID
std::string name; // 页面名称
int width = ProjectLimits::kDefaultHmiPageWidth; // 页面宽度
int height = ProjectLimits::kDefaultHmiPageHeight; // 页面高度
std::vector<HmiControl> controls; // 页面上的控件集合

/**
* @brief 校验页面尺寸、控件标识唯一性和全部控件配置
@@ -209,6 +223,7 @@ struct HmiPage
bool validate(
const ProjectLimitSettings &limits,
std::string *error = nullptr) const;
// 校验页面结构并确认全部控件已完成运行配置
bool validateForRunning(
const ProjectLimitSettings &limits,
std::string *error = nullptr) const;


+ 19
- 3
app/src/domain/project_model.cpp Просмотреть файл

@@ -9,6 +9,7 @@

namespace {

// 在调用方提供错误字符串时保存工程校验失败原因
void setError(std::string *error, const std::string &message)
{
if (error != nullptr)
@@ -18,6 +19,7 @@ void setError(std::string *error, const std::string &message)
}

// 通过相邻区间查找避免为不同领域对象重复实现标识唯一性校验
// 检查一组对象中的 ID 是否重复
template <typename TItem>
bool containsDuplicateId(const std::vector<TItem> &items)
{
@@ -38,6 +40,7 @@ bool containsDuplicateId(const std::vector<TItem> &items)
return false;
}

// 检查一组对象中的名称是否重复
template <typename TItem>
bool containsDuplicateName(const std::vector<TItem> &items)
{
@@ -58,6 +61,7 @@ bool containsDuplicateName(const std::vector<TItem> &items)
return false;
}

// 判断文本是否为空或只包含空白字符
bool isBlank(const std::string &value)
{
return value.empty()
@@ -66,6 +70,7 @@ bool isBlank(const std::string &value)
[](unsigned char character) { return std::isspace(character) != 0; });
}

// 判断文本中是否包含换行符
bool containsLineBreak(const std::string &value)
{
return value.find('\r') != std::string::npos
@@ -74,11 +79,12 @@ bool containsLineBreak(const std::string &value)

struct HmiDataBinding
{
RegisterAddress address{RegisterArea::D, 0};
RegisterDataType type = RegisterDataType::Int16;
std::string controlId;
RegisterAddress address{RegisterArea::D, 0}; // HMI 控件占用的起始地址
RegisterDataType type = RegisterDataType::Int16; // HMI 控件使用的数据类型
std::string controlId; // 发生冲突时用于提示的控件 ID
};

// 判断 HMI 控件是否会占用 D 区数值地址
bool isNumericControl(const HmiControl &control)
{
return control.type == HmiControlType::NumericDisplay
@@ -88,6 +94,7 @@ bool isNumericControl(const HmiControl &control)
&& control.binding->area() == RegisterArea::D);
}

// 判断两个 HMI D 区绑定占用的地址范围是否相交
bool rangesIntersect(const HmiDataBinding &left, const HmiDataBinding &right)
{
const int leftEnd = left.address.index()
@@ -99,12 +106,14 @@ bool rangesIntersect(const HmiDataBinding &left, const HmiDataBinding &right)
&& right.address.index() <= leftEnd;
}

// 提取 MOVE 或算术指令写入的 D 区目标地址
bool isWordWriteDestination(const LogicNodeConfig &config, RegisterAddress *address)
{
if (address == nullptr)
{
return false;
}
// variant 只对 MOVE 和算术配置写入目标地址,其他节点没有字写入目标
return std::visit(
[address](const auto &value)
{
@@ -122,6 +131,7 @@ bool isWordWriteDestination(const LogicNodeConfig &config, RegisterAddress *addr

} // namespace

// 校验工程级软元件注释的地址和文本内容
bool RegisterComment::validate(std::string *error) const
{
if (!address.isValid())
@@ -147,6 +157,7 @@ bool RegisterComment::validate(std::string *error) const
return true;
}

// 按 M/D 地址查找工程中保存的软元件注释
const RegisterComment *Project::findRegisterComment(
const RegisterAddress &address) const
{
@@ -159,6 +170,7 @@ const RegisterComment *Project::findRegisterComment(
return comment == registerComments.cend() ? nullptr : &*comment;
}

// 校验工程元数据、数量边界、对象关系和寄存器占用冲突
bool Project::validate(
const ProjectLimitSettings &limits, std::string *error) const
{
@@ -335,6 +347,7 @@ bool Project::validate(
}
}
}
// 先收集 HMI 数值控件的 D 占用范围,后面统一检查重叠
std::vector<HmiDataBinding> hmi_bindings;
for (const HmiPage &page : hmiPages)
{
@@ -372,6 +385,7 @@ bool Project::validate(
}

// 同时收集所有显式 M/D 引用,提前检查 PLC 轮询集合不会超过上限
// 收集所有需要轮询的地址,最后排序并去重后检查总量
std::vector<RegisterAddress> poll_addresses;
for (const HmiPage &page : hmiPages)
{
@@ -435,6 +449,7 @@ bool Project::validate(
}
}
}
// 先按区域和地址排序,再用 unique 删除相邻重复项
std::sort(
poll_addresses.begin(), poll_addresses.end(),
[](const RegisterAddress &left, const RegisterAddress &right)
@@ -454,6 +469,7 @@ bool Project::validate(
return true;
}

// 在工程结构校验通过后,继续检查运行态对象是否全部配置完成
bool Project::validateForRunning(
const ProjectLimitSettings &limits, std::string *error) const
{


+ 10
- 0
app/src/domain/register_monitor_model.cpp Просмотреть файл

@@ -2,8 +2,10 @@

#include <algorithm>

// 根据监控点列表生成按添加顺序排列的地址列表
const std::vector<RegisterAddress> &RegisterMonitorModel::addresses() const
{
// 地址列表是兼容视图,每次访问时从完整监控点列表重新生成
addresses_.clear();
addresses_.reserve(points_.size());
for (const MonitorPoint &point : points_)
@@ -13,13 +15,16 @@ const std::vector<RegisterAddress> &RegisterMonitorModel::addresses() const
return addresses_;
}

// 返回当前保存的完整监控点列表
const std::vector<MonitorPoint> &RegisterMonitorModel::points() const
{
return points_;
}

// 判断相同地址和数据类型的监控点是否已经存在
bool RegisterMonitorModel::contains(const MonitorPoint &point) const
{
// 地址相同但数据类型不同,可以作为不同监控点同时存在
return std::find_if(
points_.cbegin(), points_.cend(), [&point](const MonitorPoint &candidate)
{
@@ -28,8 +33,10 @@ bool RegisterMonitorModel::contains(const MonitorPoint &point) const
}) != points_.cend();
}

// 校验并添加一个监控点
bool RegisterMonitorModel::add(const MonitorPoint &point)
{
// 添加前一次性检查地址、类型、范围、重复项和数量上限
if (!point.address.isValid()
|| (point.address.area() == RegisterArea::M
&& point.dataType != RegisterDataType::Int16)
@@ -44,6 +51,7 @@ bool RegisterMonitorModel::add(const MonitorPoint &point)
return true;
}

// 删除指定地址的第一个监控点
bool RegisterMonitorModel::remove(const RegisterAddress &address)
{
const auto position = std::find_if(
@@ -59,6 +67,7 @@ bool RegisterMonitorModel::remove(const RegisterAddress &address)
return true;
}

// 删除地址和数据类型都匹配的监控点
bool RegisterMonitorModel::remove(const MonitorPoint &point)
{
const auto position = std::find_if(
@@ -75,6 +84,7 @@ bool RegisterMonitorModel::remove(const MonitorPoint &point)
return true;
}

// 清空当前会话中的全部监控点和地址视图
void RegisterMonitorModel::clear()
{
addresses_.clear();


+ 15
- 13
app/src/domain/register_monitor_model.h Просмотреть файл

@@ -10,26 +10,27 @@
// 监控读数的可用状态
enum class MonitorValueState
{
Valid,
Unavailable,
CommunicationFault
Valid, // 已成功读取并可使用
Unavailable, // 当前没有可用的寄存器值
CommunicationFault // 读取时发生通信故障
};

// 一个监控行的地址、可用性和当前值
struct MonitorValue
{
RegisterAddress address{RegisterArea::M, 0};
RegisterDataType dataType = RegisterDataType::Int16;
int endAddress = 0;
MonitorValueState state = MonitorValueState::Unavailable;
bool bitValue = false;
RegisterNumericValue numericValue = std::int16_t{0};
RegisterAddress address{RegisterArea::M, 0}; // 监控点起始地址
RegisterDataType dataType = RegisterDataType::Int16; // 监控点的数据类型
int endAddress = 0; // 多字监控点占用的结束地址
MonitorValueState state = MonitorValueState::Unavailable; // 当前读数状态
bool bitValue = false; // M 位监控点的当前值
RegisterNumericValue numericValue = std::int16_t{0}; // D 数值监控点的当前值
};

// 监控列表中的一个地址和数据类型组合
struct MonitorPoint
{
RegisterAddress address{RegisterArea::M, 0};
RegisterDataType dataType = RegisterDataType::Int16;
RegisterAddress address{RegisterArea::M, 0}; // 监控点起始地址
RegisterDataType dataType = RegisterDataType::Int16; // 监控点的数据类型
};

// 运行期间由用户临时维护的监控地址列表,不写入工程文件
@@ -40,6 +41,7 @@ public:

// 返回当前按添加顺序保存的地址
const std::vector<RegisterAddress> &addresses() const;
// 返回当前按添加顺序保存的监控点
const std::vector<MonitorPoint> &points() const;
// 判断地址是否已经在监控列表中
bool contains(const MonitorPoint &point) const;
@@ -53,6 +55,6 @@ public:
void clear();

private:
mutable std::vector<RegisterAddress> addresses_;
std::vector<MonitorPoint> points_;
mutable std::vector<RegisterAddress> addresses_; // 由监控点生成的地址兼容列表
std::vector<MonitorPoint> points_; // 当前会话中的监控点列表
};

+ 10
- 3
app/src/domain/register_repository.h Просмотреть файл

@@ -39,11 +39,12 @@ struct RegisterWriteResult
RegisterError error = RegisterError::Unavailable; // 写入失败原因
};

// 连续 D 区字寄存器读取结果
struct WordsReadResult
{
bool succeeded = false;
std::vector<std::int16_t> values;
RegisterError error = RegisterError::Unavailable;
bool succeeded = false; // 读取是否成功
std::vector<std::int16_t> values; // 按地址顺序返回的字值
RegisterError error = RegisterError::Unavailable; // 读取失败原因
};

// M/D 寄存器访问边界
@@ -62,9 +63,11 @@ public:
// 写入 D 区 16 位字寄存器
virtual RegisterWriteResult writeWord(
const RegisterAddress &address, std::int16_t value) = 0;
// 连续读取多个 D 区字;默认实现逐个调用 readWord
virtual WordsReadResult readWords(
const RegisterAddress &address, int count) const
{
// 批量读取要求地址为 D 区且整个范围不能超出 0~4000
if (!address.isValid() || address.area() != RegisterArea::D
|| count < 1
|| address.index() > RegisterAddress::kMaximumIndex - count + 1)
@@ -76,6 +79,7 @@ public:
values.reserve(static_cast<std::size_t>(count));
for (int offset = 0; offset < count; ++offset)
{
// 默认实现逐字读取,子类可按通信协议覆盖为一次批量读取
const WordReadResult result = readWord(
RegisterAddress{RegisterArea::D, address.index() + offset});
if (!result.succeeded)
@@ -86,11 +90,13 @@ public:
}
return {true, std::move(values), RegisterError::None};
}
// 连续写入多个 D 区字;默认实现逐个调用 writeWord
virtual RegisterWriteResult writeWords(
const RegisterAddress &address,
const std::vector<std::int16_t> &values)
{
const int count = static_cast<int>(values.size());
// 空数组和越界范围都不执行任何写入,避免留下部分结果
if (!address.isValid() || address.area() != RegisterArea::D
|| count < 1
|| address.index() > RegisterAddress::kMaximumIndex - count + 1)
@@ -100,6 +106,7 @@ public:
}
for (int offset = 0; offset < count; ++offset)
{
// 某一个字写入失败时立即返回该错误,不继续写后面的字
const RegisterWriteResult result = writeWord(
RegisterAddress{RegisterArea::D, address.index() + offset},
values[static_cast<std::size_t>(offset)]);


+ 19
- 0
app/src/domain/register_value_type.cpp Просмотреть файл

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

namespace {

// 各数据类型的固定描述,供校验、JSON 和界面统一使用
constexpr RegisterDataTypeDescriptor kInt16Descriptor{
"int16", "Int16", 1, 1, false};
constexpr RegisterDataTypeDescriptor kInt32Descriptor{
@@ -17,6 +18,7 @@ constexpr RegisterDataTypeDescriptor kFloat64Descriptor{
constexpr RegisterDataTypeDescriptor kInvalidDescriptor{
"", "Unknown", 0, 1, false};

// 将固定长度数组转换为动态数组,便于统一返回编码结果
template <std::size_t Size>
std::vector<std::int16_t> toVector(const std::array<std::int16_t, Size> &words)
{
@@ -25,6 +27,7 @@ std::vector<std::int16_t> toVector(const std::array<std::int16_t, Size> &words)

} // namespace

// 根据枚举值返回对应的数据类型描述
const RegisterDataTypeDescriptor &registerDataTypeDescriptor(RegisterDataType type)
{
switch (type)
@@ -42,16 +45,19 @@ const RegisterDataTypeDescriptor &registerDataTypeDescriptor(RegisterDataType ty
}
}

// 判断枚举值是否对应受支持的数据类型
bool registerDataTypeIsSupported(RegisterDataType type)
{
return registerDataTypeDescriptor(type).wordCount > 0;
}

// 返回数据类型用于 JSON 的稳定名称
const char *registerDataTypeName(RegisterDataType type)
{
return registerDataTypeDescriptor(type).jsonName;
}

// 将 JSON 中的数据类型名称解析为枚举值
bool parseRegisterDataType(const std::string &text, RegisterDataType *type)
{
if (type == nullptr)
@@ -81,11 +87,13 @@ bool parseRegisterDataType(const std::string &text, RegisterDataType *type)
return false;
}

// 返回数据类型需要连续读取的 D 字数量
int registerDataTypeWordCount(RegisterDataType type)
{
return registerDataTypeDescriptor(type).wordCount;
}

// 检查数据类型的起始地址是否满足 D 区范围和对齐规则
bool registerDataTypeAddressIsValid(
RegisterDataType type, const RegisterAddress &address)
{
@@ -97,14 +105,17 @@ bool registerDataTypeAddressIsValid(
&& address.index() % descriptor.addressAlignment == 0;
}

// 按低字在前的顺序编码 32 位整数
std::array<std::int16_t, 2> Int32Codec::encode(std::int32_t value)
{
// memcpy 只复制位模式,避免直接把整数指针强转后读取造成未定义行为
std::uint32_t bits = 0;
std::memcpy(&bits, &value, sizeof(bits));
return {static_cast<std::int16_t>(bits & 0xffffU),
static_cast<std::int16_t>(bits >> 16U)};
}

// 按低字在前的顺序解码 32 位整数
std::int32_t Int32Codec::decode(
std::int16_t low_word, std::int16_t high_word)
{
@@ -115,6 +126,7 @@ std::int32_t Int32Codec::decode(
return value;
}

// 按低字在前的顺序编码 32 位浮点数
std::array<std::int16_t, 2> Float32Codec::encode(float value)
{
std::uint32_t bits = 0;
@@ -123,6 +135,7 @@ std::array<std::int16_t, 2> Float32Codec::encode(float value)
static_cast<std::int16_t>(bits >> 16U)};
}

// 按低字在前的顺序解码 32 位浮点数
std::optional<float> Float32Codec::decode(
std::int16_t low_word, std::int16_t high_word)
{
@@ -133,6 +146,7 @@ std::optional<float> Float32Codec::decode(
return std::isfinite(value) ? std::optional<float>(value) : std::nullopt;
}

// 按低字在前的顺序编码 64 位浮点数
std::array<std::int16_t, 4> Float64Codec::encode(double value)
{
std::uint64_t bits = 0;
@@ -144,6 +158,7 @@ std::array<std::int16_t, 4> Float64Codec::encode(double value)
static_cast<std::int16_t>(bits >> 48U)};
}

// 按低字在前的顺序解码 64 位浮点数
std::optional<double> Float64Codec::decode(
const std::array<std::int16_t, 4> &words)
{
@@ -156,6 +171,7 @@ std::optional<double> Float64Codec::decode(
return std::isfinite(value) ? std::optional<double>(value) : std::nullopt;
}

// 根据数据类型和字数解码寄存器数值
std::optional<RegisterNumericValue> decodeRegisterNumericValue(
RegisterDataType type, const std::vector<std::int16_t> &words)
{
@@ -163,6 +179,7 @@ std::optional<RegisterNumericValue> decodeRegisterNumericValue(
{
return std::nullopt;
}
// 不同类型的字数不同,先拒绝长度不匹配的数据
switch (type)
{
case RegisterDataType::Int16:
@@ -189,6 +206,7 @@ std::optional<RegisterNumericValue> decodeRegisterNumericValue(
}
}

// 根据数据类型和数值范围编码寄存器字序列
std::optional<std::vector<std::int16_t>> encodeRegisterNumericValue(
RegisterDataType type, double value)
{
@@ -196,6 +214,7 @@ std::optional<std::vector<std::int16_t>> encodeRegisterNumericValue(
{
return std::nullopt;
}
// 整数类型需要先检查整数性和目标类型的上下限
switch (type)
{
case RegisterDataType::Int16:


+ 29
- 11
app/src/domain/register_value_type.h Просмотреть файл

@@ -11,62 +11,80 @@

enum class RegisterDataType
{
Int16,
Int32,
Float32,
Float64
Int16, // 一个 16 位有符号整数,占 1 个 D 字
Int32, // 一个 32 位有符号整数,占 2 个 D 字
Float32, // 一个 32 位浮点数,占 2 个 D 字
Float64 // 一个 64 位浮点数,占 4 个 D 字
};

// 描述数据类型对应的工程名称、显示名称、字数和地址要求
struct RegisterDataTypeDescriptor
{
const char *jsonName = "int16";
const char *displayName = "Int16";
int wordCount = 1;
int addressAlignment = 1;
bool floatingPoint = false;
const char *jsonName = "int16"; // JSON 中保存的稳定名称
const char *displayName = "Int16"; // 界面显示名称
int wordCount = 1; // 占用的连续 D 字数量
int addressAlignment = 1; // 起始地址必须满足的对齐倍数
bool floatingPoint = false; // 是否为浮点类型
};

// 支持在不同数值类型之间保存寄存器解码结果
using RegisterNumericValue = std::variant<std::int16_t, std::int32_t, float, double>;

// 描述一段连续的 D 区字地址范围
struct RegisterWordRange
{
RegisterAddress start{RegisterArea::D, 0};
int wordCount = 1;
RegisterAddress start{RegisterArea::D, 0}; // 连续范围的起始地址
int wordCount = 1; // 连续范围包含的 D 字数量

// 比较两个 D 区字范围是否完全相同
bool operator==(const RegisterWordRange &other) const
{
return start == other.start && wordCount == other.wordCount;
}
};

// 返回数据类型的静态描述,非法类型返回 Unknown 描述
const RegisterDataTypeDescriptor &registerDataTypeDescriptor(RegisterDataType type);
// 判断数据类型是否受当前实现支持
bool registerDataTypeIsSupported(RegisterDataType type);
// 返回数据类型对应的 JSON 稳定名称
const char *registerDataTypeName(RegisterDataType type);
// 将 JSON 文本解析为数据类型
bool parseRegisterDataType(const std::string &text, RegisterDataType *type);
// 返回数据类型占用的 D 字数量
int registerDataTypeWordCount(RegisterDataType type);
// 校验数据类型和起始地址是否满足区域、范围及对齐要求
bool registerDataTypeAddressIsValid(
RegisterDataType type, const RegisterAddress &address);

struct Int32Codec
{
// 将 32 位整数拆成低字在前的两个 D 字
static std::array<std::int16_t, 2> encode(std::int32_t value);
// 将低字在前的两个 D 字还原为 32 位整数
static std::int32_t decode(std::int16_t low_word, std::int16_t high_word);
};

struct Float32Codec
{
// 将 32 位浮点数拆成低字在前的两个 D 字
static std::array<std::int16_t, 2> encode(float value);
// 解码浮点数,遇到 NaN 或 Inf 时返回空值
static std::optional<float> decode(std::int16_t low_word, std::int16_t high_word);
};

struct Float64Codec
{
// 将 64 位浮点数拆成低字在前的四个 D 字
static std::array<std::int16_t, 4> encode(double value);
// 解码四个 D 字,遇到 NaN 或 Inf 时返回空值
static std::optional<double> decode(
const std::array<std::int16_t, 4> &words);
};

// 按指定数据类型把 D 字序列解码为数值
std::optional<RegisterNumericValue> decodeRegisterNumericValue(
RegisterDataType type, const std::vector<std::int16_t> &words);
// 按指定数据类型把数值编码为 D 字序列
std::optional<std::vector<std::int16_t>> encodeRegisterNumericValue(
RegisterDataType type, double value);

+ 5
- 0
app/src/domain/runtime_state.cpp Просмотреть файл

@@ -1,15 +1,18 @@
#include "runtime_state.h"

// 返回状态机当前保存的运行模式
ApplicationMode RuntimeState::mode() const
{
return mode_;
}

// 根据当前运行模式返回对应的能力策略
ModePolicy RuntimeState::policy() const
{
return policyForMode(mode_);
}

// 尝试返回编辑态,编辑态是两个运行态之间的共同中转状态
ModeTransitionResult RuntimeState::enterEditing()
{
// 两种运行态都必须先回到编辑态,作为后续模式切换的唯一中转点
@@ -21,6 +24,7 @@ ModeTransitionResult RuntimeState::enterEditing()
return {true, ModeTransitionError::None, {}};
}

// 尝试从编辑态进入离线仿真运行态
ModeTransitionResult RuntimeState::enterOfflineRunning()
{
if (mode_ == ApplicationMode::OfflineRunning)
@@ -35,6 +39,7 @@ ModeTransitionResult RuntimeState::enterOfflineRunning()
return {true, ModeTransitionError::None, {}};
}

// 尝试从编辑态进入真机联机运行态,并检查 PLC 初次读取状态
ModeTransitionResult RuntimeState::enterOnlineRunning(bool initial_plc_read_completed)
{
if (mode_ == ApplicationMode::OnlineRunning)


+ 1
- 0
app/src/domain/runtime_state.h Просмотреть файл

@@ -65,6 +65,7 @@ constexpr ModePolicy policyForMode(ApplicationMode mode)
}
default:
{
// 非法枚举值没有任何运行能力,调用方应将其视为无效状态
return {};
}
}


+ 12
- 0
app/src/domain/virtual_register_repository.cpp Просмотреть файл

@@ -2,12 +2,14 @@

#include <algorithm>

// 构造虚拟仓库并将所有寄存器初始化为默认值
VirtualRegisterRepository::VirtualRegisterRepository()
{
// 构造时清空离线寄存器,确保初始值确定
clear();
}

// 校验地址后读取指定的 M 区位值
BitReadResult VirtualRegisterRepository::readBit(const RegisterAddress &address) const
{
// 先校验地址和区域,再访问对应的内存槽位
@@ -22,6 +24,7 @@ BitReadResult VirtualRegisterRepository::readBit(const RegisterAddress &address)
return {true, bits_[static_cast<std::size_t>(address.index())], RegisterError::None};
}

// 校验地址后更新指定的 M 区位值
RegisterWriteResult VirtualRegisterRepository::writeBit(
const RegisterAddress &address, bool value)
{
@@ -38,6 +41,7 @@ RegisterWriteResult VirtualRegisterRepository::writeBit(
return {true, RegisterError::None};
}

// 校验地址后读取指定的 D 区字值
WordReadResult VirtualRegisterRepository::readWord(const RegisterAddress &address) const
{
// 字操作只允许访问 D 区
@@ -52,6 +56,7 @@ WordReadResult VirtualRegisterRepository::readWord(const RegisterAddress &addres
return {true, words_[static_cast<std::size_t>(address.index())], RegisterError::None};
}

// 校验地址后更新指定的 D 区字值
RegisterWriteResult VirtualRegisterRepository::writeWord(
const RegisterAddress &address, std::int16_t value)
{
@@ -68,6 +73,7 @@ RegisterWriteResult VirtualRegisterRepository::writeWord(
return {true, RegisterError::None};
}

// 校验连续范围后读取多个 D 区字值
WordsReadResult VirtualRegisterRepository::readWords(
const RegisterAddress &address, int count) const
{
@@ -78,10 +84,12 @@ WordsReadResult VirtualRegisterRepository::readWords(
return {false, {}, address.isValid() && address.area() != RegisterArea::D
? RegisterError::AreaMismatch : RegisterError::InvalidAddress};
}
// 地址范围已校验,因此可以直接把地址作为数组偏移量
const auto begin = words_.cbegin() + address.index();
return {true, std::vector<std::int16_t>(begin, begin + count), RegisterError::None};
}

// 校验连续范围后更新多个 D 区字值
RegisterWriteResult VirtualRegisterRepository::writeWords(
const RegisterAddress &address,
const std::vector<std::int16_t> &values)
@@ -94,11 +102,13 @@ RegisterWriteResult VirtualRegisterRepository::writeWords(
return {false, address.isValid() && address.area() != RegisterArea::D
? RegisterError::AreaMismatch : RegisterError::InvalidAddress};
}
// 范围已校验,copy 可以一次完成连续字值写入
std::copy(
values.cbegin(), values.cend(), words_.begin() + address.index());
return {true, RegisterError::None};
}

// 将 M 区和 D 区全部恢复为离线默认值
void VirtualRegisterRepository::clear()
{
// 离线运行复位时同时清空 M 区和 D 区
@@ -106,8 +116,10 @@ void VirtualRegisterRepository::clear()
words_.fill(0);
}

// 复制另一份虚拟仓库的完整 M/D 快照
void VirtualRegisterRepository::copyFrom(const VirtualRegisterRepository &source)
{
// array 赋值会复制全部槽位,不会让两个仓库共享存储
bits_ = source.bits_;
words_ = source.words_;
}

+ 3
- 0
app/src/domain/virtual_register_repository.h Просмотреть файл

@@ -20,8 +20,10 @@ public:
// 更新内存中的 D 区字值
RegisterWriteResult writeWord(
const RegisterAddress &address, std::int16_t value) override;
// 连续读取内存中的多个 D 区字值
WordsReadResult readWords(
const RegisterAddress &address, int count) const override;
// 连续更新内存中的多个 D 区字值
RegisterWriteResult writeWords(
const RegisterAddress &address,
const std::vector<std::int16_t> &values) override;
@@ -32,6 +34,7 @@ public:
void copyFrom(const VirtualRegisterRepository &source);

private:
// M/D 数组均按 0~4000 的寄存器地址直接建立下标
static constexpr std::size_t kRegisterCount =
static_cast<std::size_t>(RegisterAddress::kMaximumIndex + 1);
std::array<bool, kRegisterCount> bits_{}; // M 区位寄存器内存


+ 22
- 2
app/src/infrastructure/application_settings_loader.cpp Просмотреть файл

@@ -16,20 +16,24 @@

namespace {

// 当前应用配置文件格式版本
constexpr int kCurrentConfigurationVersion = 1;

// 保存字段值及其所在行号,便于生成准确的错误提示
struct ParsedEntry
{
QString value;
int line = 0;
QString value; // 配置字段值
int line = 0; // 字段在文件中的行号
};

// 将 Qt 字符串转换为 UTF-8 标准字符串
std::string toUtf8(const QString &value)
{
const QByteArray bytes = value.toUtf8();
return std::string(bytes.constData(), static_cast<std::size_t>(bytes.size()));
}

// 生成首次运行时写入的默认 INI 内容
QString defaultFileContents()
{
return QStringLiteral(
@@ -57,6 +61,7 @@ QString defaultFileContents()
"StopBits=1\n");
}

// 创建统一的配置失败结果,表示整份配置回退到代码默认值
ApplicationSettingsLoadResult failureResult(
const QString &file_path, const QString &reason)
{
@@ -73,6 +78,7 @@ ApplicationSettingsLoadResult failureResult(
return result;
}

// 判断整数是否在允许值列表中
bool containsValue(std::initializer_list<int> values, int value)
{
for (int candidate : values)
@@ -85,6 +91,7 @@ bool containsValue(std::initializer_list<int> values, int value)
return false;
}

// 读取并校验一个整数配置项,缺失时按 required 决定报错或使用默认值
bool readInteger(
const QMap<QString, ParsedEntry> &entries,
const QString &path,
@@ -96,6 +103,7 @@ bool readInteger(
QStringList *errors,
bool required = false)
{
// constFind 只读取已解析的字段,不会修改配置表
const auto entry = entries.constFind(path);
if (entry == entries.cend())
{
@@ -130,12 +138,14 @@ bool readInteger(
return true;
}

// 为枚举式整数配置生成统一的允许值错误提示
void addUnsupportedValueError(
const QMap<QString, ParsedEntry> &entries,
const QString &path,
const QString &allowed,
QStringList *errors)
{
// 该函数只在字段已经存在时调用,因此可以直接取得对应记录
const ParsedEntry &entry = entries[path];
errors->append(
QStringLiteral("第 %1 行字段 %2 的值“%3”无效,只允许 %4")
@@ -145,12 +155,14 @@ void addUnsupportedValueError(

} // namespace

// 返回程序目录下的应用配置路径
QString ApplicationSettingsLoader::defaultFilePath()
{
return QDir(QCoreApplication::applicationDirPath()).filePath(
QStringLiteral("config/application.ini"));
}

// 创建或读取配置文件,并把解析结果转换为应用设置
ApplicationSettingsLoadResult ApplicationSettingsLoader::load(
const QString &file_path)
{
@@ -165,6 +177,7 @@ ApplicationSettingsLoadResult ApplicationSettingsLoader::load(
.arg(QDir::toNativeSeparators(directory)));
}

// QSaveFile 通过临时文件提交,避免程序中断时留下半份配置
QSaveFile file(file_path);
if (!file.open(QIODevice::WriteOnly))
{
@@ -191,6 +204,7 @@ ApplicationSettingsLoadResult ApplicationSettingsLoader::load(
return result;
}

// 已存在的配置按只读方式读取,任何读错误都会让整份配置回退
QFile file(file_path);
if (!file.open(QIODevice::ReadOnly))
{
@@ -202,6 +216,7 @@ ApplicationSettingsLoadResult ApplicationSettingsLoader::load(
return failureResult(file_path, file.errorString());
}

// 使用 UTF-8 解码并检查非法字节,避免中文配置被错误解析
QTextCodec::ConverterState converter_state;
const QString text = QTextCodec::codecForName("UTF-8")->toUnicode(
data.constData(), data.size(), &converter_state);
@@ -210,6 +225,7 @@ ApplicationSettingsLoadResult ApplicationSettingsLoader::load(
return failureResult(file_path, QStringLiteral("配置文件不是有效的 UTF-8 文本"));
}

// 未知字段只提示不报错,便于未来版本增加字段后旧程序仍可读取
const QSet<QString> known_fields{
QStringLiteral("Config.Version"),
QStringLiteral("ProjectLimits.MaxHmiPages"),
@@ -237,6 +253,7 @@ ApplicationSettingsLoadResult ApplicationSettingsLoader::load(
{
normalized_text.remove(0, 1);
}
// 按行解析 INI,保留行号用于定位格式和取值错误
const QStringList lines = normalized_text.split(QLatin1Char('\n'));
for (int index = 0; index < lines.size(); ++index)
{
@@ -247,6 +264,7 @@ ApplicationSettingsLoadResult ApplicationSettingsLoader::load(
}
const QString trimmed = line.trimmed();
const int line_number = index + 1;
// 空行、分号注释和井号注释不参与字段解析
if (trimmed.isEmpty() || trimmed.startsWith(QLatin1Char(';'))
|| trimmed.startsWith(QLatin1Char('#')))
{
@@ -307,6 +325,7 @@ ApplicationSettingsLoadResult ApplicationSettingsLoader::load(
}
}

// 先写入候选设置,只有全部字段无严重错误时才作为最终结果返回
ApplicationSettings candidate;
qint64 parsed = 0;
if (readInteger(
@@ -477,6 +496,7 @@ ApplicationSettingsLoadResult ApplicationSettingsLoader::load(
candidate.plcDefaults.stopBits = static_cast<int>(parsed);
}

// 任一严重错误都会使整份配置失效,避免混用部分外部值和默认值
if (!errors.isEmpty())
{
ApplicationSettingsLoadResult result;


+ 2
- 0
app/src/infrastructure/application_settings_loader.h Просмотреть файл

@@ -8,6 +8,8 @@
class ApplicationSettingsLoader final
{
public:
// 返回程序目录下应用配置文件的默认路径
static QString defaultFilePath();
// 读取指定 INI 文件,不存在时创建默认文件
static ApplicationSettingsLoadResult load(const QString &file_path);
};

+ 30
- 0
app/src/infrastructure/json_project_storage.cpp Просмотреть файл

@@ -272,12 +272,14 @@ bool parseAddress(
return true;
}

// 将字操作数类型转换为工程文件中的稳定字符串
QString wordOperandKindName(WordOperandKind kind)
{
return kind == WordOperandKind::Constant
? QStringLiteral("constant") : QStringLiteral("register");
}

// 将字操作数序列化为常量或寄存器对象
QJsonObject serializeWordOperand(const WordOperand &operand)
{
QJsonObject object;
@@ -293,6 +295,7 @@ QJsonObject serializeWordOperand(const WordOperand &operand)
return object;
}

// 从 JSON 对象解析字操作数并校验其地址类型
bool parseWordOperand(
const QJsonObject &object,
const std::string &context,
@@ -374,6 +377,7 @@ bool parseHmiControlType(
return true;
}

// 将按钮操作转换为工程文件中的稳定字符串
QString hmiButtonOperationName(HmiButtonOperation operation)
{
switch (operation)
@@ -398,6 +402,7 @@ QString hmiButtonOperationName(HmiButtonOperation operation)
}
}

// 将工程文件中的按钮操作字符串解析为枚举值
bool parseHmiButtonOperation(
const std::string &value, HmiButtonOperation *operation, ParseState *state)
{
@@ -426,6 +431,7 @@ bool parseHmiButtonOperation(
return true;
}

// 将按钮启用条件比较方式转换为稳定字符串
QString hmiButtonConditionOperatorName(HmiButtonConditionOperator operation)
{
switch (operation)
@@ -447,6 +453,7 @@ QString hmiButtonConditionOperatorName(HmiButtonConditionOperator operation)
}
}

// 将按钮启用条件比较方式字符串解析为枚举值
bool parseHmiButtonConditionOperator(
const std::string &value,
HmiButtonConditionOperator *operation,
@@ -485,6 +492,7 @@ bool parseHmiButtonConditionOperator(
return true;
}

// 将按钮 M/D 启用条件序列化为 JSON 对象
QJsonObject serializeHmiButtonEnableCondition(
const HmiButtonEnableCondition &condition)
{
@@ -510,6 +518,7 @@ QJsonObject serializeHmiButtonEnableCondition(
return object;
}

// 从 JSON 对象解析按钮 M/D 启用条件
bool parseHmiButtonEnableCondition(
const QJsonObject &object,
const std::string &context,
@@ -576,6 +585,7 @@ bool parseHmiButtonEnableCondition(
return true;
}

// 将报警触发条件转换为工程文件中的稳定字符串
QString alarmConditionName(AlarmCondition condition)
{
switch (condition)
@@ -603,6 +613,7 @@ QString alarmConditionName(AlarmCondition condition)
}
}

// 将报警触发条件字符串解析为枚举值
bool parseAlarmCondition(
const std::string &value, AlarmCondition *condition, ParseState *state)
{
@@ -631,6 +642,7 @@ bool parseAlarmCondition(
return true;
}

// 将报警定义序列化为 JSON 对象
QJsonObject serializeAlarmDefinition(const AlarmDefinition &definition)
{
QJsonObject object;
@@ -643,6 +655,7 @@ QJsonObject serializeAlarmDefinition(const AlarmDefinition &definition)
return object;
}

// 从 JSON 对象解析报警定义
bool parseAlarmDefinition(
const QJsonObject &object,
const std::string &context,
@@ -678,6 +691,7 @@ bool parseAlarmDefinition(
return true;
}

// 将工程级软元件注释序列化为 JSON 对象
QJsonObject serializeRegisterComment(const RegisterComment &comment)
{
QJsonObject object;
@@ -686,6 +700,7 @@ QJsonObject serializeRegisterComment(const RegisterComment &comment)
return object;
}

// 从 JSON 对象解析工程级软元件注释
bool parseRegisterComment(
const QJsonObject &object,
const std::string &context,
@@ -798,11 +813,13 @@ bool parseProperties(
return true;
}

// 将有值或无值的数值转换为 JSON 数值或 null
QJsonValue serializeOptionalNumber(const std::optional<double> &value)
{
return value.has_value() ? QJsonValue(*value) : QJsonValue(QJsonValue::Null);
}

// 将 HMI 状态文本映射配置序列化为 JSON 对象
QJsonObject serializeStatusTextConfig(const HmiStatusTextConfig &config)
{
QJsonObject object;
@@ -830,6 +847,7 @@ QJsonObject serializeStatusTextConfig(const HmiStatusTextConfig &config)
return object;
}

// 读取可为空的有限数值,用于状态文本区间边界
bool readNullableFiniteNumber(
const QJsonObject &object,
const char *field,
@@ -857,6 +875,7 @@ bool readNullableFiniteNumber(
return true;
}

// 从 JSON 对象解析 M 或 D 状态文本映射配置
bool parseStatusTextConfig(
const QJsonObject &object,
const std::string &context,
@@ -1221,6 +1240,7 @@ bool parseContactMode(
return true;
}

// 将边沿模式转换为工程文件中的稳定字符串
QString edgeModeName(EdgeMode mode)
{
return mode == EdgeMode::Rising
@@ -1228,6 +1248,7 @@ QString edgeModeName(EdgeMode mode)
: QStringLiteral("falling");
}

// 将边沿模式字符串解析为枚举值
bool parseEdgeMode(
const std::string &value, EdgeMode *mode, ParseState *state)
{
@@ -1381,6 +1402,7 @@ QJsonObject serializeNodeConfig(const ContactNodeConfig &config)
return object;
}

// 将边沿触点配置序列化为 JSON 对象
QJsonObject serializeNodeConfig(const EdgeContactNodeConfig &config)
{
QJsonObject object;
@@ -1411,6 +1433,7 @@ QJsonObject serializeNodeConfig(const CompareNodeConfig &config)
return object;
}

// 将 MOVE 节点配置序列化为 JSON 对象
QJsonObject serializeNodeConfig(const MoveNodeConfig &config)
{
QJsonObject object;
@@ -1420,12 +1443,14 @@ QJsonObject serializeNodeConfig(const MoveNodeConfig &config)
return object;
}

// 将算术操作转换为工程文件中的稳定字符串
QString arithmeticOperationName(ArithmeticOperation operation)
{
return operation == ArithmeticOperation::Add
? QStringLiteral("add") : QStringLiteral("subtract");
}

// 将算术节点配置序列化为 JSON 对象
QJsonObject serializeNodeConfig(const ArithmeticNodeConfig &config)
{
QJsonObject object;
@@ -1635,6 +1660,7 @@ bool parseLogicNode(
return true;
}

// 将梯形图行、条件网格和输出节点序列化为 JSON 对象
QJsonObject serializeLadderRung(const LadderRung &rung)
{
QJsonObject object;
@@ -1666,6 +1692,7 @@ QJsonObject serializeLadderRung(const LadderRung &rung)
return object;
}

// 从 JSON 对象解析梯形图行及其网格
bool parseLadderRung(
const QJsonObject &object,
const std::string &context,
@@ -1758,6 +1785,7 @@ bool parseLadderRung(
return true;
}

// 将控制逻辑及其行、竖线连接序列化为 JSON 对象
QJsonObject serializeControlLogic(const ControlLogic &logic)
{
QJsonArray rungs;
@@ -1784,6 +1812,7 @@ QJsonObject serializeControlLogic(const ControlLogic &logic)
return object;
}

// 从 JSON 对象解析控制逻辑及其行、竖线连接
bool parseControlLogic(
const QJsonObject &object,
const std::string &context,
@@ -2050,6 +2079,7 @@ ProjectSaveResult saveFailure(

} // namespace

// 保存工程级数量限制,供后续保存和加载校验使用
JsonProjectStorage::JsonProjectStorage(
const ProjectLimitSettings &project_limits)
: project_limits_(project_limits)


+ 3
- 1
app/src/infrastructure/json_project_storage.h Просмотреть файл

@@ -8,10 +8,12 @@ class JsonProjectStorage final : public ProjectStorage
public:
explicit JsonProjectStorage(
const ProjectLimitSettings &project_limits);
// 校验并以 JSON 4.0 格式原子保存工程
ProjectSaveResult save(
const Project &project, const std::string &file_path) override;
// 从 JSON 4.0 文件读取并校验工程
ProjectLoadResult load(const std::string &file_path) override;

private:
const ProjectLimitSettings &project_limits_;
const ProjectLimitSettings &project_limits_; // 工程校验和数组解析使用的数量限制
};

+ 4
- 0
app/src/infrastructure/plc_communication_error_classifier.cpp Просмотреть файл

@@ -2,6 +2,7 @@

namespace {

// 生成适合显示的串口名称,空名称时使用通用提示
QString displayPortName(const QString &port_name)
{
const QString trimmed = port_name.trimmed();
@@ -10,12 +11,14 @@ QString displayPortName(const QString &port_name)

} // namespace

// 将 Qt Modbus 错误码转换为项目统一的通信故障类型和提示文本
PlcCommunicationFailure classifyPlcCommunicationError(
QModbusDevice::Error error,
const PlcCommunicationErrorContext &context)
{
const QString port_name = displayPortName(context.portName);

// Qt 只提供底层错误码,连接上下文用于进一步区分打开失败和断线
switch (error)
{
case QModbusDevice::ConnectionError:
@@ -36,6 +39,7 @@ PlcCommunicationFailure classifyPlcCommunicationError(
}
case QModbusDevice::TimeoutError:
{
// 从未收到有效响应通常表示站号、串口参数或接线不匹配
if (!context.receivedValidResponse)
{
return {


+ 6
- 5
app/src/infrastructure/plc_communication_error_classifier.h Просмотреть файл

@@ -8,19 +8,20 @@
// Qt 错误码之外的通信现场信息,用于区分“未打开串口”和“已断线”
struct PlcCommunicationErrorContext
{
QString portName;
bool serialSessionOpened = false;
bool receivedValidResponse = false;
QString portName; // 当前使用的串口名称
bool serialSessionOpened = false; // 串口会话是否已经成功打开
bool receivedValidResponse = false; // 是否曾收到 PLC 的有效响应
};

// 分类后的用户可读通信故障
struct PlcCommunicationFailure
{
PlcCommunicationError type = PlcCommunicationError::Unknown;
QString message;
PlcCommunicationError type = PlcCommunicationError::Unknown; // 统一的错误类型
QString message; // 面向用户显示的错误说明
};

// 将 Qt Modbus 错误和上下文转换为项目统一错误类型
// 通过串口状态和 PLC 响应状态区分具体通信故障
PlcCommunicationFailure classifyPlcCommunicationError(
QModbusDevice::Error error,
const PlcCommunicationErrorContext &context);

+ 31
- 0
app/src/infrastructure/plc_communication_service.cpp Просмотреть файл

@@ -46,6 +46,7 @@ bool isReadingState(PlcConnectionState state)
|| state == PlcConnectionState::Recovering;
}

// 排序并去重轮询地址,同时检查地址总量上限
bool normalizePollAddresses(
const std::vector<RegisterAddress> &addresses,
std::vector<RegisterAddress> *normalized,
@@ -78,6 +79,7 @@ bool normalizePollAddresses(
return true;
}

// 判断读块边界是否落在多字范围内部,避免拆开一次多字读取
bool boundaryBelongsToMultiWordRange(
RegisterArea area,
int boundary,
@@ -93,6 +95,7 @@ bool boundaryBelongsToMultiWordRange(
});
}

// 将地址集合拆成符合 Modbus 单次读取上限的连续读块
bool calculatePollBlocks(
const std::vector<RegisterAddress> &addresses,
const std::vector<RegisterWordRange> &multi_word_ranges,
@@ -106,6 +109,7 @@ bool calculatePollBlocks(
return true;
}

// 先处理同一区域内连续地址,再按单次读取上限切分
std::size_t run_start = 0U;
while (run_start < addresses.size())
{
@@ -144,6 +148,7 @@ bool calculatePollBlocks(

} // namespace

// 创建 Qt Modbus 主站,绑定异步信号和 PLC 缓存写入回调
PlcCommunicationService::PlcCommunicationService(
PlcRegisterRepository &repository,
QObject *parent)
@@ -221,8 +226,10 @@ PlcCommunicationService::PlcCommunicationService(
});
}

// 释放通信服务持有的 Qt 资源
PlcCommunicationService::~PlcCommunicationService() = default;

// 校验串口配置并启动一次新的 PLC 异步连接
PlcCommunicationResult PlcCommunicationService::connectDevice(
const PlcSerialConfiguration &configuration)
{
@@ -284,6 +291,7 @@ PlcCommunicationResult PlcCommunicationService::connectDevice(
return {true, {}};
}

// 停止定时器、关闭串口并清除本次连接的缓存有效状态
void PlcCommunicationService::disconnectDevice()
{
// 关闭串口、清除缓存有效标记,防止断开后继续使用旧值
@@ -295,12 +303,14 @@ void PlcCommunicationService::disconnectDevice()
setState(PlcConnectionState::Disconnected);
}

// 使用默认的单字范围设置轮询地址
PlcCommunicationResult PlcCommunicationService::setPollAddresses(
const std::vector<RegisterAddress> &addresses)
{
return setPollAddresses(addresses, {});
}

// 校验并应用地址及多字范围,必要时等待当前异步读请求结束
PlcCommunicationResult PlcCommunicationService::setPollAddresses(
const std::vector<RegisterAddress> &addresses,
const std::vector<RegisterWordRange> &multi_word_ranges)
@@ -381,26 +391,31 @@ PlcCommunicationResult PlcCommunicationService::setPollAddresses(
return {true, {}};
}

// 返回当前 PLC 连接状态
PlcConnectionState PlcCommunicationService::state() const
{
return state_;
}

// 返回当前连接是否已完成全部轮询块的首次读取
bool PlcCommunicationService::initialReadCompleted() const
{
return initial_read_completed_;
}

// 返回最近一次通信错误的统一类型
PlcCommunicationError PlcCommunicationService::lastErrorType() const
{
return last_error_type_;
}

// 返回最近一次通信错误的可读文字
const std::string &PlcCommunicationService::lastError() const
{
return last_error_;
}

// 保存外部回调,在对应通信事件发生时通知调用方
void PlcCommunicationService::setCallbacks(
std::function<void()> state_changed,
std::function<void(bool)> initial_read_changed,
@@ -416,6 +431,7 @@ void PlcCommunicationService::setCallbacks(
error_reported_callback_ = std::move(error_reported); // 发生通信错误时通知外部
}

// 根据当前地址集合重新生成连续轮询读块
void PlcCommunicationService::rebuildPollBlocks()
{
std::vector<RegisterAddress> addresses = poll_addresses_;
@@ -457,6 +473,7 @@ void PlcCommunicationService::rebuildPollBlocks()
}
}

// 在没有读请求占用时正式切换到新的轮询地址集合
void PlcCommunicationService::applyPollAddresses(
const std::vector<RegisterAddress> &addresses)
{
@@ -472,6 +489,7 @@ void PlcCommunicationService::applyPollAddresses(
}
}

// 发送一个异步轮询读请求,并由完成回调推进后续读块
void PlcCommunicationService::pollNextBlock()
{
if (!isReadingState(state_)
@@ -552,6 +570,7 @@ void PlcCommunicationService::pollNextBlock()
});
}

// 对可恢复通信故障发送一个轻量读取请求探测链路
void PlcCommunicationService::probeRecovery()
{
// 只在可恢复的超时故障中探测;串口拔出等故障不会反复探测
@@ -621,6 +640,7 @@ void PlcCommunicationService::probeRecovery()
});
}

// 处理恢复探测错误,并决定立即上报或继续等待探测
void PlcCommunicationService::handleRecoveryProbeFailure(QModbusDevice::Error error)
{
if (state_ != PlcConnectionState::Faulted
@@ -640,6 +660,7 @@ void PlcCommunicationService::handleRecoveryProbeFailure(QModbusDevice::Error er
recovery_timer_.start(kRecoveryProbeIntervalMs);
}

// 探测成功后恢复轮询,并重新开始完整首读
void PlcCommunicationService::restoreCommunication()
{
// 探测成功只说明链路恢复,仍要重新读取全部读块才能恢复真机资格
@@ -652,6 +673,7 @@ void PlcCommunicationService::restoreCommunication()
pollNextBlock();
}

// 处理轮询读回复并更新 PLC 缓存
void PlcCommunicationService::handleReadFinished(
QModbusReply *reply, PlcPollBlock block)
{
@@ -688,6 +710,7 @@ void PlcCommunicationService::handleReadFinished(
}
}

// 发送一个 M 位异步写请求,不直接修改缓存
RegisterWriteResult PlcCommunicationService::sendBitWrite(
const RegisterAddress &address, bool value)
{
@@ -733,6 +756,7 @@ RegisterWriteResult PlcCommunicationService::sendBitWrite(
return {true, RegisterError::None};
}

// 发送一个 D 字异步写请求,不直接修改缓存
RegisterWriteResult PlcCommunicationService::sendWordWrite(
const RegisterAddress &address, std::int16_t value)
{
@@ -777,6 +801,7 @@ RegisterWriteResult PlcCommunicationService::sendWordWrite(
return {true, RegisterError::None};
}

// 发送一组连续 D 字异步写请求,不直接修改缓存
RegisterWriteResult PlcCommunicationService::sendWordsWrite(
const RegisterAddress &address,
const std::vector<std::int16_t> &values)
@@ -829,6 +854,7 @@ RegisterWriteResult PlcCommunicationService::sendWordsWrite(
return {true, RegisterError::None};
}

// 更新首读完成标记,并在需要时通知外部观察者
void PlcCommunicationService::updateInitialReadCompleted(
bool completed, bool force_notification)
{
@@ -845,6 +871,7 @@ void PlcCommunicationService::updateInitialReadCompleted(
}
}

// 处理未主动断开时发生的串口连接丢失
void PlcCommunicationService::handleUnexpectedDisconnect()
{
// 这里表示设备原本连上过,后来串口意外断开
@@ -857,6 +884,7 @@ void PlcCommunicationService::handleUnexpectedDisconnect()
.arg(port_name)});
}

// 过滤重复错误后,分类并保存 Qt Modbus 错误
void PlcCommunicationService::handleModbusError(QModbusDevice::Error error)
{
// 主动断开、已处理的故障和旧回复错误都不重复上报
@@ -876,6 +904,7 @@ void PlcCommunicationService::handleModbusError(QModbusDevice::Error error)
setError(classifyPlcCommunicationError(error, context));
}

// 使旧异步请求失效并关闭当前串口会话
void PlcCommunicationService::closeSerialSession()
{
// 先递增代次并停止定时器,再断开串口;旧异步回调会因此失效
@@ -897,6 +926,7 @@ void PlcCommunicationService::closeSerialSession()
disconnecting_ = false;
}

// 修改连接状态并同步发出 Qt 信号和外部回调
void PlcCommunicationService::setState(PlcConnectionState state)
{
if (state_ == state)
@@ -912,6 +942,7 @@ void PlcCommunicationService::setState(PlcConnectionState state)
}
}

// 保存通信故障、撤销首读资格并通知外部观察者
void PlcCommunicationService::setError(const PlcCommunicationFailure &failure)
{
// 统一保存错误、停止正常轮询、撤销首读资格并通知 UI


+ 4
- 3
app/src/infrastructure/plc_communication_service.h Просмотреть файл

@@ -19,9 +19,9 @@ struct PlcCommunicationFailure;

struct PlcPollBlock
{
RegisterArea area = RegisterArea::M;
int startAddress = 0;
int count = 1;
RegisterArea area = RegisterArea::M; // 读块所在的寄存器区域
int startAddress = 0; // 读块的起始原始地址
int count = 1; // 本次连续读取的字或位数量
};

// 基于 Qt Modbus RTU 的异步 PLC 通信实现
@@ -46,6 +46,7 @@ public:
// 设置需要周期性读取的 M/D 地址集合
PlcCommunicationResult setPollAddresses(
const std::vector<RegisterAddress> &addresses) override;
// 设置轮询地址及需要保持完整边界的多字数据范围
PlcCommunicationResult setPollAddresses(
const std::vector<RegisterAddress> &addresses,
const std::vector<RegisterWordRange> &multi_word_ranges) override;


+ 16
- 0
app/src/infrastructure/plc_discovery_service.cpp Просмотреть файл

@@ -19,6 +19,7 @@ constexpr int kDiscoveryResponseTimeoutMs = 200;
// 正式连接可能刚被配置窗口断开,先留出时间让操作系统完全释放串口句柄
constexpr int kDiscoveryStartDelayMs = 100;

// 将 Qt 字符串转换为项目内部使用的 UTF-8 标准字符串
std::string toUtf8(const QString &value)
{
const QByteArray bytes = value.toUtf8();
@@ -27,6 +28,7 @@ std::string toUtf8(const QString &value)

} // namespace

// 创建独立的 Modbus 探测主站并绑定状态变化处理
PlcDiscoveryService::PlcDiscoveryService(QObject *parent)
: QObject(parent),
master_(std::make_unique<QModbusRtuSerialMaster>())
@@ -36,6 +38,7 @@ PlcDiscoveryService::PlcDiscoveryService(QObject *parent)
this, &PlcDiscoveryService::handleDeviceStateChanged);
}

// 清理回调并释放仍可能占用串口的探测主站
PlcDiscoveryService::~PlcDiscoveryService()
{
progress_changed_callback_ = {};
@@ -46,6 +49,7 @@ PlcDiscoveryService::~PlcDiscoveryService()
}
}

// 收集可用串口并启动候选参数的异步逐项探测
PlcCommunicationResult PlcDiscoveryService::startDiscovery(
const PlcSerialConfiguration &preferred)
{
@@ -84,6 +88,7 @@ PlcCommunicationResult PlcDiscoveryService::startDiscovery(
cancel_requested_ = false;
found_pending_ = false;
++attempt_generation_;
// 延迟启动,给刚关闭的正式通信服务留出释放串口句柄的时间
QTimer::singleShot(
kDiscoveryStartDelayMs,
this,
@@ -91,6 +96,7 @@ PlcCommunicationResult PlcDiscoveryService::startDiscovery(
return {true, {}};
}

// 标记取消并释放当前候选串口
void PlcDiscoveryService::cancelDiscovery()
{
if (!discovering_ || cancel_requested_)
@@ -103,11 +109,13 @@ void PlcDiscoveryService::cancelDiscovery()
disconnectCurrent(false);
}

// 返回搜索状态
bool PlcDiscoveryService::isDiscovering() const
{
return discovering_;
}

// 保存搜索进度和结束回调
void PlcDiscoveryService::setCallbacks(
std::function<void(const PlcDiscoveryProgress &)> progress_changed,
std::function<void(const PlcDiscoveryOutcome &)> discovery_finished)
@@ -116,6 +124,7 @@ void PlcDiscoveryService::setCallbacks(
discovery_finished_callback_ = std::move(discovery_finished);
}

// 应用当前候选串口参数并发起异步连接
void PlcDiscoveryService::tryCurrentCandidate()
{
if (!discovering_)
@@ -173,6 +182,7 @@ void PlcDiscoveryService::tryCurrentCandidate()
}
}

// 连接成功后读取 D0,使用只读请求判断 PLC 是否响应
void PlcDiscoveryService::sendProbe()
{
if (!discovering_ || cancel_requested_
@@ -203,6 +213,7 @@ void PlcDiscoveryService::sendProbe()
});
}

// 处理 D0 探测回复,并确认它是否属于当前搜索尝试
void PlcDiscoveryService::handleProbeFinished(
QModbusReply *reply,
std::uint64_t attempt_generation)
@@ -215,6 +226,7 @@ void PlcDiscoveryService::handleProbeFinished(
{
pending_reply_.clear();
}
// 正常响应和 Modbus 异常响应都说明串口链路上确实有设备回应
const bool valid_response = reply->error() == QModbusDevice::NoError
|| reply->rawResult().isException();
reply->deleteLater();
@@ -224,6 +236,7 @@ void PlcDiscoveryService::handleProbeFinished(
}
}

// 释放当前候选串口,并记录是否已探测到 PLC
void PlcDiscoveryService::disconnectCurrent(bool found)
{
if (!discovering_)
@@ -240,6 +253,7 @@ void PlcDiscoveryService::disconnectCurrent(bool found)
master_->disconnectDevice();
}

// 串口释放后决定报告成功、取消,或继续下一个候选
void PlcDiscoveryService::continueAfterDisconnect()
{
if (!discovering_ || phase_ != Phase::Disconnecting)
@@ -263,6 +277,7 @@ void PlcDiscoveryService::continueAfterDisconnect()
QTimer::singleShot(0, this, &PlcDiscoveryService::tryCurrentCandidate);
}

// 清理搜索状态并报告最终搜索结果
void PlcDiscoveryService::finishDiscovery(const PlcDiscoveryOutcome &outcome)
{
discovering_ = false;
@@ -279,6 +294,7 @@ void PlcDiscoveryService::finishDiscovery(const PlcDiscoveryOutcome &outcome)
current_candidate_ = 0;
}

// 根据 Qt Modbus 状态变化推进连接、探测和断开流程
void PlcDiscoveryService::handleDeviceStateChanged(QModbusDevice::State state)
{
if (!discovering_)


+ 19
- 15
app/src/infrastructure/plc_discovery_service.h Просмотреть файл

@@ -22,10 +22,14 @@ public:
explicit PlcDiscoveryService(QObject *parent = nullptr);
~PlcDiscoveryService() override;

// 枚举串口和候选参数,并异步逐项探测 PLC
PlcCommunicationResult startDiscovery(
const PlcSerialConfiguration &preferred) override;
// 取消当前自动搜索并释放探测串口
void cancelDiscovery() override;
// 返回当前是否正在自动搜索
bool isDiscovering() const override;
// 设置搜索进度和最终结果回调
void setCallbacks(
std::function<void(const PlcDiscoveryProgress &)> progress_changed,
std::function<void(const PlcDiscoveryOutcome &)> discovery_finished) override;
@@ -33,10 +37,10 @@ public:
private:
enum class Phase
{
Idle,
Connecting,
Probing,
Disconnecting
Idle, // 等待尝试候选参数
Connecting, // 正在异步打开候选串口
Probing, // 已打开串口,正在读取 D0
Disconnecting // 正在释放当前候选串口
};

// 尝试当前候选参数并等待串口异步打开
@@ -54,15 +58,15 @@ private:
// 处理 Qt Modbus 主站连接状态变化
void handleDeviceStateChanged(QModbusDevice::State state);

std::unique_ptr<QModbusRtuSerialMaster> master_;
std::vector<PlcSerialConfiguration> candidates_;
std::size_t current_candidate_ = 0;
QPointer<QModbusReply> pending_reply_;
Phase phase_ = Phase::Idle;
bool discovering_ = false;
bool cancel_requested_ = false;
bool found_pending_ = false;
std::uint64_t attempt_generation_ = 0;
std::function<void(const PlcDiscoveryProgress &)> progress_changed_callback_;
std::function<void(const PlcDiscoveryOutcome &)> discovery_finished_callback_;
std::unique_ptr<QModbusRtuSerialMaster> master_; // 独立的探测主站对象
std::vector<PlcSerialConfiguration> candidates_; // 待依次尝试的串口参数
std::size_t current_candidate_ = 0; // 当前候选参数下标
QPointer<QModbusReply> pending_reply_; // 当前未完成的 D0 探测回复
Phase phase_ = Phase::Idle; // 当前搜索阶段
bool discovering_ = false; // 是否正在执行搜索
bool cancel_requested_ = false; // 是否已收到取消请求
bool found_pending_ = false; // 当前候选是否探测到 PLC
std::uint64_t attempt_generation_ = 0; // 尝试代次,用于丢弃旧异步回复
std::function<void(const PlcDiscoveryProgress &)> progress_changed_callback_; // 搜索进度回调
std::function<void(const PlcDiscoveryOutcome &)> discovery_finished_callback_; // 搜索结束回调
};

+ 416
- 0
app/src/infrastructure/runtime_settings_loader.cpp Просмотреть файл

@@ -0,0 +1,416 @@
/**
* @file runtime_settings_loader.cpp
* @brief 实现 HMI 专用运行版的串口配置读写服务
* @version 1.0.0
* @author suyu
* @date 2026-08-28
*/

#include "runtime_settings_loader.h"

#include <QCoreApplication>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QSaveFile>
#include <QSet>
#include <QStringList>
#include <QTextCodec>
#include <QMap>

#include <initializer_list>

namespace {

struct ParsedEntry
{
QString value;
int line = 0;
};

QString defaultFileContents(const PlcSerialConfiguration &configuration)
{
return QStringLiteral(
"[PlcDefaults]\n"
"PortName=%1\n"
"ServerAddress=%2\n"
"BaudRate=%3\n"
"DataBits=%4\n"
"Parity=%5\n"
"StopBits=%6\n")
.arg(QString::fromUtf8(configuration.portName.c_str()))
.arg(configuration.serverAddress)
.arg(configuration.baudRate)
.arg(configuration.dataBits)
.arg(configuration.parity)
.arg(configuration.stopBits);
}

PlcSerialConfiguration defaultConfiguration()
{
PlcSerialConfiguration configuration;
configuration.portName = "COM3";
return configuration;
}

std::string toUtf8(const QString &value)
{
const QByteArray bytes = value.toUtf8();
return std::string(bytes.constData(), static_cast<std::size_t>(bytes.size()));
}

void addMessage(RuntimeSettingsLoadResult *result, const QString &message)
{
result->messages.push_back(toUtf8(message));
}

RuntimeSettingsLoadResult failureResult(
const QString &file_path, const QString &reason)
{
RuntimeSettingsLoadResult result;
result.plcDefaults = defaultConfiguration();
result.warningRequired = true;
result.warningMessage = toUtf8(
QStringLiteral("运行版串口配置无法使用,本次采用代码默认值。\n%1")
.arg(reason));
addMessage(
&result,
QStringLiteral("运行版串口配置错误:%1(%2)")
.arg(reason, QDir::toNativeSeparators(file_path)));
return result;
}

bool containsValue(std::initializer_list<int> values, int value)
{
for (const int candidate : values)
{
if (candidate == value)
{
return true;
}
}
return false;
}

bool readInteger(
const QMap<QString, ParsedEntry> &entries,
const QString &path,
qint64 minimum,
qint64 maximum,
qint64 default_value,
qint64 *value,
QStringList *errors,
QStringList *notices)
{
const auto entry = entries.constFind(path);
if (entry == entries.cend())
{
*value = default_value;
notices->append(
QStringLiteral("配置字段 %1 缺失,使用代码默认值 %2")
.arg(path)
.arg(default_value));
return true;
}
bool converted = false;
const qint64 parsed = entry->value.toLongLong(&converted, 10);
if (!converted || parsed < minimum || parsed > maximum)
{
errors->append(
QStringLiteral("第 %1 行字段 %2 的值“%3”无效,允许范围为 %4~%5")
.arg(entry->line)
.arg(path, entry->value)
.arg(minimum)
.arg(maximum));
return false;
}
*value = parsed;
return true;
}

void addUnsupportedValueError(
const QMap<QString, ParsedEntry> &entries,
const QString &path,
const QString &allowed,
QStringList *errors)
{
const ParsedEntry &entry = entries[path];
errors->append(
QStringLiteral("第 %1 行字段 %2 的值“%3”无效,只允许 %4")
.arg(entry.line)
.arg(path, entry.value, allowed));
}

} // namespace

QString RuntimeSettingsLoader::defaultFilePath()
{
return QDir(QCoreApplication::applicationDirPath()).filePath(
QStringLiteral("config/runtime.ini"));
}

RuntimeSettingsLoadResult RuntimeSettingsLoader::load(const QString &file_path)
{
if (!QFileInfo::exists(file_path))
{
const QString directory = QFileInfo(file_path).absolutePath();
if (!QDir().mkpath(directory))
{
return failureResult(
file_path,
QStringLiteral("无法创建配置目录 %1")
.arg(QDir::toNativeSeparators(directory)));
}
PlcSerialConfiguration configuration = defaultConfiguration();
QSaveFile file(file_path);
const QByteArray data = defaultFileContents(configuration).toUtf8();
if (!file.open(QIODevice::WriteOnly)
|| file.write(data) != data.size()
|| !file.commit())
{
return failureResult(file_path, QStringLiteral("无法创建默认串口配置文件"));
}
RuntimeSettingsLoadResult result;
result.plcDefaults = configuration;
addMessage(
&result,
QStringLiteral("运行版串口配置:已创建默认配置文件 %1")
.arg(QDir::toNativeSeparators(file_path)));
return result;
}

QFile file(file_path);
if (!file.open(QIODevice::ReadOnly))
{
return failureResult(file_path, file.errorString());
}
const QByteArray data = file.readAll();
if (file.error() != QFileDevice::NoError)
{
return failureResult(file_path, file.errorString());
}
QTextCodec::ConverterState converter_state;
QString text = QTextCodec::codecForName("UTF-8")->toUnicode(
data.constData(), data.size(), &converter_state);
if (converter_state.invalidChars > 0)
{
return failureResult(file_path, QStringLiteral("配置文件不是有效的 UTF-8 文本"));
}
if (!text.isEmpty() && text.front() == QChar::ByteOrderMark)
{
text.remove(0, 1);
}

const QSet<QString> known_fields{
QStringLiteral("PlcDefaults.PortName"),
QStringLiteral("PlcDefaults.ServerAddress"),
QStringLiteral("PlcDefaults.BaudRate"),
QStringLiteral("PlcDefaults.DataBits"),
QStringLiteral("PlcDefaults.Parity"),
QStringLiteral("PlcDefaults.StopBits")};
QMap<QString, ParsedEntry> entries;
QStringList notices;
QStringList errors;
QString current_section;
const QStringList lines = text.split(QLatin1Char('\n'));
for (int index = 0; index < lines.size(); ++index)
{
QString line = lines.at(index);
if (line.endsWith(QLatin1Char('\r')))
{
line.chop(1);
}
const QString trimmed = line.trimmed();
const int line_number = index + 1;
if (trimmed.isEmpty() || trimmed.startsWith(QLatin1Char(';'))
|| trimmed.startsWith(QLatin1Char('#')))
{
continue;
}
if (trimmed.startsWith(QLatin1Char('[')))
{
if (!trimmed.endsWith(QLatin1Char(']')) || trimmed.size() < 3)
{
errors.append(
QStringLiteral("第 %1 行分区格式无效:%2")
.arg(line_number)
.arg(trimmed));
current_section.clear();
continue;
}
current_section = trimmed.mid(1, trimmed.size() - 2).trimmed();
continue;
}
const int separator = trimmed.indexOf(QLatin1Char('='));
if (separator <= 0 || current_section.isEmpty())
{
errors.append(
QStringLiteral("第 %1 行字段格式无效:%2")
.arg(line_number)
.arg(trimmed));
continue;
}
const QString key = trimmed.left(separator).trimmed();
const QString value = trimmed.mid(separator + 1).trimmed();
const QString path = current_section + QLatin1Char('.') + key;
if (entries.contains(path))
{
errors.append(
QStringLiteral("第 %1 行字段 %2 重复,首次出现在第 %3 行")
.arg(line_number)
.arg(path)
.arg(entries.value(path).line));
continue;
}
entries.insert(path, {value, line_number});
if (!known_fields.contains(path))
{
notices.append(
QStringLiteral("第 %1 行未知字段 %2 已忽略")
.arg(line_number)
.arg(path));
}
}

RuntimeSettingsLoadResult result;
result.plcDefaults = defaultConfiguration();
const auto port_entry = entries.constFind(QStringLiteral("PlcDefaults.PortName"));
if (port_entry == entries.cend())
{
notices.append(QStringLiteral("配置字段 PlcDefaults.PortName 缺失,使用代码默认值 COM3"));
}
else
{
const QByteArray port_bytes = port_entry->value.toUtf8();
if (port_entry->value.trimmed().isEmpty() || port_bytes.size() > 128)
{
errors.append(
QStringLiteral("第 %1 行字段 PlcDefaults.PortName 无效,端口名称不能为空且不能超过 128 个 UTF-8 字节")
.arg(port_entry->line));
}
else
{
result.plcDefaults.portName = toUtf8(port_entry->value.trimmed());
}
}

qint64 parsed = 0;
if (readInteger(entries, QStringLiteral("PlcDefaults.ServerAddress"), 1, 247,
result.plcDefaults.serverAddress, &parsed, &errors, &notices))
{
result.plcDefaults.serverAddress = static_cast<int>(parsed);
}
int baud_rate = result.plcDefaults.baudRate;
if (readInteger(entries, QStringLiteral("PlcDefaults.BaudRate"), 9600, 115200,
result.plcDefaults.baudRate, &parsed, &errors, &notices))
{
baud_rate = static_cast<int>(parsed);
if (!containsValue({9600, 19200, 38400, 57600, 115200}, baud_rate))
{
addUnsupportedValueError(entries, QStringLiteral("PlcDefaults.BaudRate"),
QStringLiteral("9600、19200、38400、57600 或 115200"), &errors);
}
else
{
result.plcDefaults.baudRate = baud_rate;
}
}
if (readInteger(entries, QStringLiteral("PlcDefaults.DataBits"), 7, 8,
result.plcDefaults.dataBits, &parsed, &errors, &notices))
{
result.plcDefaults.dataBits = static_cast<int>(parsed);
}
int parity = result.plcDefaults.parity;
if (readInteger(entries, QStringLiteral("PlcDefaults.Parity"), 0, 3,
result.plcDefaults.parity, &parsed, &errors, &notices))
{
parity = static_cast<int>(parsed);
if (!containsValue({0, 2, 3}, parity))
{
addUnsupportedValueError(entries, QStringLiteral("PlcDefaults.Parity"),
QStringLiteral("0、2 或 3"), &errors);
}
else
{
result.plcDefaults.parity = parity;
}
}
if (readInteger(entries, QStringLiteral("PlcDefaults.StopBits"), 1, 2,
result.plcDefaults.stopBits, &parsed, &errors, &notices))
{
result.plcDefaults.stopBits = static_cast<int>(parsed);
}

const PlcCommunicationResult configuration_result =
validatePlcSerialConfiguration(result.plcDefaults);
if (!configuration_result.succeeded)
{
errors.append(QString::fromUtf8(configuration_result.message.c_str()));
}
if (!errors.isEmpty())
{
result.plcDefaults = defaultConfiguration();
result.warningRequired = true;
result.warningMessage = toUtf8(
QStringLiteral("运行版串口配置存在错误,本次采用代码默认值"));
for (const QString &error : errors)
{
addMessage(&result, QStringLiteral("运行版串口配置错误:") + error);
}
for (const QString &notice : notices)
{
addMessage(&result, QStringLiteral("运行版串口配置提示:") + notice);
}
return result;
}
addMessage(
&result,
QStringLiteral("运行版串口配置:已加载 %1")
.arg(QDir::toNativeSeparators(file_path)));
for (const QString &notice : notices)
{
addMessage(&result, QStringLiteral("运行版串口配置提示:") + notice);
}
return result;
}

bool RuntimeSettingsLoader::write(
const QString &file_path,
const PlcSerialConfiguration &configuration,
std::string *error)
{
const PlcCommunicationResult validation =
validatePlcSerialConfiguration(configuration);
if (!validation.succeeded)
{
if (error != nullptr)
{
*error = validation.message;
}
return false;
}
const QString directory = QFileInfo(file_path).absolutePath();
if (!QDir().mkpath(directory))
{
if (error != nullptr)
{
*error = toUtf8(QStringLiteral("无法创建运行版配置目录:%1")
.arg(QDir::toNativeSeparators(directory)));
}
return false;
}
QSaveFile file(file_path);
const QByteArray data = defaultFileContents(configuration).toUtf8();
if (!file.open(QIODevice::WriteOnly)
|| file.write(data) != data.size()
|| !file.commit())
{
if (error != nullptr)
{
*error = toUtf8(QStringLiteral("无法写入运行版串口配置:%1")
.arg(QDir::toNativeSeparators(file_path)));
}
return false;
}
return true;
}

+ 40
- 0
app/src/infrastructure/runtime_settings_loader.h Просмотреть файл

@@ -0,0 +1,40 @@
/**
* @file runtime_settings_loader.h
* @brief 定义 HMI 专用运行版的串口配置读写服务
* @version 1.0.0
* @author suyu
* @date 2026-08-28
*/

#pragma once

#include "services/plc_communication_gateway.h"

#include <QString>

#include <string>
#include <vector>

/** HMI 专用运行版的串口配置加载结果 */
struct RuntimeSettingsLoadResult
{
PlcSerialConfiguration plcDefaults;
std::vector<std::string> messages;
bool warningRequired = false;
std::string warningMessage;
};

/** HMI 专用运行版只读取和写入 [PlcDefaults] */
class RuntimeSettingsLoader final
{
public:
/** 返回导出运行版目录下的串口配置路径 */
static QString defaultFilePath();
/** 读取只包含 PLC 串口字段的 UTF-8 INI */
static RuntimeSettingsLoadResult load(const QString &file_path);
/** 以原子方式写入导出运行版所需的最小 INI */
static bool write(
const QString &file_path,
const PlcSerialConfiguration &configuration,
std::string *error = nullptr);
};

+ 16
- 2
app/src/main.cpp Просмотреть файл

@@ -14,6 +14,7 @@

#include "infrastructure/json_project_storage.h"
#include "infrastructure/application_settings_loader.h"
#include "infrastructure/runtime_settings_loader.h"
#include "infrastructure/plc_communication_service.h"
#include "infrastructure/plc_discovery_service.h"
#include "infrastructure/plc_register_repository.h"
@@ -61,9 +62,21 @@ int main(int argc, char *argv[])
== RuntimeProjectBundleStatus::Loaded;
const bool user_runtime_mode = bundled_runtime_project;

const ApplicationSettingsLoadResult application_settings =
ApplicationSettingsLoader::load(
ApplicationSettingsLoadResult application_settings;
if (user_runtime_mode)
{
const RuntimeSettingsLoadResult runtime_settings =
RuntimeSettingsLoader::load(RuntimeSettingsLoader::defaultFilePath());
application_settings.settings.plcDefaults = runtime_settings.plcDefaults;
application_settings.messages = runtime_settings.messages;
application_settings.warningRequired = runtime_settings.warningRequired;
application_settings.warningMessage = runtime_settings.warningMessage;
}
else
{
application_settings = ApplicationSettingsLoader::load(
ApplicationSettingsLoader::defaultFilePath());
}

// 负责把工程保存到 JSON 文件,也负责从 JSON 文件读取工程
JsonProjectStorage project_storage(application_settings.settings.projectLimits);
@@ -152,6 +165,7 @@ int main(int argc, char *argv[])
active_register_repository,
virtual_register_repository,
plc_register_repository);
runtime_mode_service.setHmiOnlyRuntime(user_runtime_mode);

// 创建主窗口,并把界面操作需要的各项服务交给它使用
MainWindow main_window(


+ 31
- 0
app/src/services/project_service.cpp Просмотреть файл

@@ -152,6 +152,37 @@ ProjectOperationResult ProjectService::exportAs(const std::string &file_path) co
return {true, ProjectServiceError::None, ProjectStorageError::None, {}};
}

ProjectOperationResult ProjectService::exportHmiRuntimeAs(
const std::string &file_path) const
{
if (isBlank(file_path))
{
return {false,
ProjectServiceError::FilePathRequired,
ProjectStorageError::None,
"必须指定工程文件路径"};
}

// 用户运行版只保存 HMI、报警和页面导航需要的工程内容
Project runtime_project = project_;
runtime_project.controlLogics.clear();
runtime_project.registerComments.clear();
std::string validation_error;
if (!runtime_project.validateForRunning(project_limits_, &validation_error))
{
return {false,
ProjectServiceError::InvalidProject,
ProjectStorageError::InvalidProject,
validation_error};
}
const ProjectSaveResult result = storage_.save(runtime_project, file_path);
if (!result.succeeded)
{
return storageFailure(result.error, result.message);
}
return {true, ProjectServiceError::None, ProjectStorageError::None, {}};
}

ProjectOperationResult ProjectService::load(const std::string &file_path)
{
if (isBlank(file_path))


+ 2
- 0
app/src/services/project_service.h Просмотреть файл

@@ -63,6 +63,8 @@ public:
ProjectOperationResult saveAs(const std::string &file_path);
/** 将当前工程导出到指定 JSON,不改变当前工程文件关联 */
ProjectOperationResult exportAs(const std::string &file_path) const;
/** 将仅包含 HMI 运行所需内容的工程快照导出到指定 JSON */
ProjectOperationResult exportHmiRuntimeAs(const std::string &file_path) const;

/**
* @brief 从指定文件加载工程


+ 49
- 16
app/src/services/runtime_mode_service.cpp Просмотреть файл

@@ -111,12 +111,15 @@ ModeTransitionResult RuntimeModeService::enterOnlineRunning()
{
return preparation;
}
const OnlineLogicMonitorStartResult start_result =
online_logic_monitor_service_.start(
project_service_.project().controlLogics);
if (!start_result.succeeded)
if (!hmi_only_runtime_)
{
return {false, ModeTransitionError::SimulationStartFailed, {}};
const OnlineLogicMonitorStartResult start_result =
online_logic_monitor_service_.start(
project_service_.project().controlLogics);
if (!start_result.succeeded)
{
return {false, ModeTransitionError::SimulationStartFailed, {}};
}
}
const ModeTransitionResult result = state_.enterOnlineRunning(true);
if (result.succeeded && active_repository_ != nullptr && plc_repository_ != nullptr)
@@ -125,11 +128,24 @@ ModeTransitionResult RuntimeModeService::enterOnlineRunning()
}
else if (!result.succeeded)
{
online_logic_monitor_service_.stop();
if (!hmi_only_runtime_)
{
online_logic_monitor_service_.stop();
}
}
return result;
}

void RuntimeModeService::setHmiOnlyRuntime(bool enabled)
{
hmi_only_runtime_ = enabled;
if (enabled && active_repository_ != nullptr && plc_repository_ != nullptr)
{
// 运行版从启动起只读取 PLC 缓存,首读前显示不可用值而不是虚拟值
active_repository_->use(*plc_repository_);
}
}

void RuntimeModeService::setInitialPlcReadCompleted(bool completed)
{
// 通信服务完成有效读回后才允许把此标志设为 true
@@ -176,17 +192,32 @@ const LogicSyntaxCheckResult &RuntimeModeService::lastSyntaxCheck() const

ModeTransitionResult RuntimeModeService::prepareProjectForRunning()
{
last_syntax_check_ = logic_editor_service_.checkEnabledSyntax();
if (!last_syntax_check_.completed || !last_syntax_check_.valid)
if (!hmi_only_runtime_)
{
last_syntax_check_ = logic_editor_service_.checkEnabledSyntax();
if (!last_syntax_check_.completed || !last_syntax_check_.valid)
{
return {
false,
ModeTransitionError::ProjectNotReady,
last_syntax_check_.message};
}
}
else
{
// HMI 运行版没有梯形图,保留成功结果供公共诊断接口读取
last_syntax_check_ = {};
last_syntax_check_.completed = true;
last_syntax_check_.valid = true;
}
Project project = project_service_.project();
if (hmi_only_runtime_)
{
return {
false,
ModeTransitionError::ProjectNotReady,
last_syntax_check_.message};
project.controlLogics.clear();
project.registerComments.clear();
}
std::string error;
if (!project_service_.project().validateForRunning(
project_service_.projectLimits(), &error))
if (!project.validateForRunning(project_service_.projectLimits(), &error))
{
return {false, ModeTransitionError::ProjectNotReady, std::move(error)};
}
@@ -203,7 +234,7 @@ void RuntimeModeService::configurePlc(
active_repository_ = &active_repository;
virtual_repository_ = &virtual_repository;
plc_repository_ = &plc_repository;
// 通信故障撤销首读资格;若正在真机运行,立即回到编辑态
// 通信故障撤销首读资格;普通编程器回到编辑态,HMI 运行版保留 PLC 缓存画面
gateway.setCallbacks(
[this]
{
@@ -214,7 +245,8 @@ void RuntimeModeService::configurePlc(
|| plc_state == PlcConnectionState::Faulted)
{
setInitialPlcReadCompleted(false);
if (state_.mode() == ApplicationMode::OnlineRunning)
if (state_.mode() == ApplicationMode::OnlineRunning
&& !hmi_only_runtime_)
{
enterEditing();
}
@@ -237,6 +269,7 @@ void RuntimeModeService::configurePlc(
[this]
{
if (state_.mode() == ApplicationMode::OnlineRunning
&& !hmi_only_runtime_
&& online_logic_monitor_service_.state()
== OnlineLogicMonitorState::Running)
{


+ 3
- 0
app/src/services/runtime_mode_service.h Просмотреть файл

@@ -101,6 +101,8 @@ public:
ActiveRegisterRepository &active_repository,
RegisterRepository &virtual_repository,
RegisterRepository &plc_repository);
/** 设置导出 HMI 运行版模式,跳过本地梯形图执行器 */
void setHmiOnlyRuntime(bool enabled);
/**
* @brief 连接 PLC 并开始异步通信
* @param configuration 串口、Modbus 和轮询配置
@@ -159,4 +161,5 @@ private:
std::vector<RegisterAddress> monitor_addresses_; // 自由监控额外引用的地址
std::vector<RegisterWordRange> monitor_multi_word_ranges_; // 自由监控中的多字范围
LogicSyntaxCheckResult last_syntax_check_; // 最近一次运行前梯形图检查结果
bool hmi_only_runtime_ = false; // 导出运行版只显示 HMI 并连接真实 PLC
};

+ 215
- 78
app/src/ui/main_window.cpp Просмотреть файл

@@ -25,6 +25,7 @@
#include "ui_main_window.h"

#include "domain/project_limits.h"
#include "infrastructure/runtime_settings_loader.h"
#include "infrastructure/runtime_project_bundle.h"

#include <QActionGroup>
@@ -38,6 +39,7 @@
#include <QGraphicsScene>
#include <QInputDialog>
#include <QIcon>
#include <QLibraryInfo>
#include <QLabel>
#include <QLineEdit>
#include <QListWidget>
@@ -66,6 +68,7 @@ namespace {
constexpr int kSyntaxLogicIdRole = Qt::UserRole + 1;
constexpr int kSyntaxRungIdRole = Qt::UserRole + 2;
constexpr int kSyntaxColumnRole = Qt::UserRole + 3;
constexpr int kUserRuntimeReconnectIntervalMs = 3000;

bool isTextEditingObject(QObject *object)
{
@@ -313,6 +316,80 @@ bool copyDirectoryContents(
return true;
}

QStringList runtimeDependencyDirectories()
{
QStringList directories;
const auto appendDirectory = [&directories](const QString &directory)
{
const QString normalized = QDir(directory).absolutePath();
if (!normalized.isEmpty() && !directories.contains(normalized))
{
directories.append(normalized);
}
};

appendDirectory(QCoreApplication::applicationDirPath());
appendDirectory(QLibraryInfo::location(QLibraryInfo::BinariesPath));
const QString qt_prefix = QLibraryInfo::location(QLibraryInfo::PrefixPath);
if (!qt_prefix.isEmpty())
{
appendDirectory(QDir(qt_prefix).filePath(
QStringLiteral("../../Tools/mingw810_64/bin")));
}
const QStringList path_directories = QString::fromLocal8Bit(
qgetenv("PATH"))
.split(QDir::listSeparator(), Qt::SkipEmptyParts);
for (const QString &directory : path_directories)
{
appendDirectory(directory);
}
return directories;
}

QString findRuntimeDependency(
const QString &file_name, const QStringList &directories)
{
for (const QString &directory : directories)
{
const QString candidate = QDir(directory).filePath(file_name);
if (QFileInfo(candidate).isFile())
{
return candidate;
}
}
return {};
}

QString findRuntimePluginDirectory(
const QString &plugin_directory, const QStringList &directories)
{
const QString application_directory =
QCoreApplication::applicationDirPath();
const QString application_candidate = QDir(application_directory).filePath(
plugin_directory);
if (QFileInfo(application_candidate).isDir())
{
return application_candidate;
}

const QString qt_plugins = QLibraryInfo::location(QLibraryInfo::PluginsPath);
const QString qt_candidate = QDir(qt_plugins).filePath(plugin_directory);
if (QFileInfo(qt_candidate).isDir())
{
return qt_candidate;
}

for (const QString &directory : directories)
{
const QString candidate = QDir(directory).filePath(plugin_directory);
if (QFileInfo(candidate).isDir())
{
return candidate;
}
}
return {};
}

} // namespace

MainWindow::MainWindow(
@@ -456,88 +533,81 @@ void MainWindow::initializeUi()
void MainWindow::initializeUserRuntime()
{
setWindowTitle(fromUtf8(project_service_.project().metadata.name));
runtime_panel_controller_->configureUserRuntimeMode(
true,
[this](int mode)
{
if (mode == static_cast<int>(ApplicationMode::OfflineRunning))
{
requestUserRuntimeMode(ApplicationMode::OfflineRunning);
}
else if (mode == static_cast<int>(ApplicationMode::OnlineRunning))
{
requestUserRuntimeMode(ApplicationMode::OnlineRunning);
}
},
[this] { connectPlc(); },
[this] { disconnectPlc(); });
runtime_panel_controller_->setHmiOnlyRuntime(true);
menuBar()->setVisible(false);
ui_->hmiToolBar->setVisible(false);
ui_->logicToolBar->setVisible(false);
ui_->outputDock->setVisible(false);
ui_->projectDock->setVisible(false);
ui_->propertiesDock->setVisible(false);
user_runtime_reconnect_timer_ = new QTimer(this);
user_runtime_reconnect_timer_->setInterval(kUserRuntimeReconnectIntervalMs);
connect(
user_runtime_reconnect_timer_,
&QTimer::timeout,
this,
&MainWindow::attemptUserRuntimeReconnect);
user_runtime_reconnect_timer_->start();
QTimer::singleShot(
0,
this,
[this]
{
if (requestMode(ApplicationMode::OfflineRunning))
const HmiNavigationResult navigation = hmi_navigation_service_->start();
if (!navigation.succeeded)
{
hide();
QMessageBox::critical(
nullptr,
tr("HMI 运行版启动失败"),
fromUtf8(navigation.message));
qApp->quit();
return;
}
else
runtime_panel_controller_->enterRuntime(
navigation.pageId,
{},
ApplicationMode::Editing,
runtime_mode_service_.plcConnectionState());
hide();

const PlcCommunicationResult connection =
runtime_mode_service_.connectPlc(plc_configuration_);
if (!connection.succeeded)
{
qApp->quit();
QMessageBox::critical(
runtime_monitor_widget_,
tr("PLC 自动连接失败"),
tr("无法使用 config/runtime.ini 中的串口配置连接 PLC:%1")
.arg(fromUtf8(connection.message)));
}
});
}

void MainWindow::requestUserRuntimeMode(ApplicationMode requested_mode)
void MainWindow::enterUserRuntimeAfterInitialRead()
{
if (runtime_mode_service_.mode() == requested_mode)
if (!user_runtime_mode_
|| runtime_mode_service_.mode() != ApplicationMode::Editing
|| !runtime_mode_service_.initialPlcReadCompleted())
{
return;
}
if (requested_mode == ApplicationMode::OnlineRunning
&& !runtime_mode_service_.initialPlcReadCompleted())
requestMode(ApplicationMode::OnlineRunning);
}

void MainWindow::attemptUserRuntimeReconnect()
{
if (!user_runtime_mode_ || runtime_mode_service_.mode()
== ApplicationMode::OfflineRunning)
{
// 用户运行版没有可见的编辑器主窗口,失败前不能先隐藏运行监控窗口
if (runtime_monitor_widget_ != nullptr)
{
runtime_monitor_widget_->setMode(
runtime_mode_service_.mode(),
runtime_mode_service_.plcConnectionState());
const QString message = transitionErrorText(
ModeTransitionError::InitialPlcReadRequired)
+ tr("\n当前状态:%1").arg(
plcStatusText(runtime_mode_service_))
+ tr("\n请先点击“PLC 配置”连接并完成首次读取。");
QMessageBox::warning(
runtime_monitor_widget_,
tr("无法切换到真机运行"),
message);
}
return;
}
if (runtime_mode_service_.mode() != ApplicationMode::Editing)
const PlcConnectionState state = runtime_mode_service_.plcConnectionState();
if (state != PlcConnectionState::Disconnected
&& state != PlcConnectionState::Faulted)
{
const ModeTransitionResult editing = runtime_mode_service_.enterEditing();
if (!editing.succeeded)
{
return;
}
updateModeUi(tr("正在切换运行模式"));
QTimer::singleShot(
0,
this,
[this, requested_mode]
{
requestMode(requested_mode);
});
return;
}
requestMode(requested_mode);
runtime_mode_service_.connectPlc(plc_configuration_);
}

MainWindow::~MainWindow()
@@ -1149,15 +1219,15 @@ void MainWindow::configureRuntimeMonitor()
}
});
runtime_panel_controller_->configure();
runtime_panel_controller_->configureUserRuntimeMode(
false,
runtime_monitor_widget_ = runtime_panel_controller_->runtimeMonitorWidget();
connect(
runtime_monitor_widget_,
&RuntimeMonitorWidget::modeChangeRequested,
this,
[this](int mode)
{
requestMode(static_cast<ApplicationMode>(mode));
},
{},
{});
runtime_monitor_widget_ = runtime_panel_controller_->runtimeMonitorWidget();
});
}

void MainWindow::configureDataMonitor()
@@ -1656,6 +1726,20 @@ void MainWindow::updateProjectTreeActions()

void MainWindow::connectPlc()
{
if (user_runtime_mode_)
{
const PlcCommunicationResult result = runtime_mode_service_.connectPlc(
plc_configuration_);
if (!result.succeeded)
{
QMessageBox::critical(
runtime_monitor_widget_,
tr("PLC 自动连接失败"),
tr("无法使用 config/runtime.ini 中的串口配置连接 PLC:%1")
.arg(fromUtf8(result.message)));
}
return;
}
PlcConnectionDialog dialog(
plc_configuration_,
plc_discovery_gateway_,
@@ -1707,6 +1791,39 @@ void MainWindow::schedulePlcStatusUpdate()
[this]
{
plc_status_update_pending_ = false;
if (user_runtime_mode_)
{
if (runtime_monitor_widget_ != nullptr)
{
runtime_monitor_widget_->setMode(
runtime_mode_service_.mode(),
runtime_mode_service_.plcConnectionState());
runtime_monitor_widget_->setHmiWriteEnabled(
runtime_mode_service_.mode()
== ApplicationMode::OnlineRunning
&& runtime_mode_service_.plcConnectionState()
== PlcConnectionState::Connected
&& runtime_mode_service_.initialPlcReadCompleted());
}
enterUserRuntimeAfterInitialRead();
const std::string error = runtime_mode_service_.plcError();
if (!error.empty() && error != last_user_runtime_plc_error_)
{
last_user_runtime_plc_error_ = error;
QMessageBox::warning(
runtime_monitor_widget_,
tr("PLC 通信异常"),
fromUtf8(error)
+ tr("\n请检查设备、接线和 config/runtime.ini 后重启程序。"));
}
if (error.empty()
&& runtime_mode_service_.plcConnectionState()
== PlcConnectionState::Connected)
{
last_user_runtime_plc_error_.clear();
}
return;
}
updateModeUi(plcStatusText(runtime_mode_service_));
},
Qt::QueuedConnection);
@@ -2168,18 +2285,11 @@ void MainWindow::loadProject()

void MainWindow::exportRuntimeProgram()
{
const LogicSyntaxCheckResult syntax =
logic_editor_service_.checkEnabledSyntax();
if (syntax.changed || !syntax.completed || !syntax.valid)
{
reportLogicSyntaxCheck(syntax, tr("导出前语法检查"));
}
if (!syntax.completed || !syntax.valid)
{
return;
}
std::string validation_error;
if (!project_service_.project().validateForRunning(
Project hmi_runtime_project = project_service_.project();
hmi_runtime_project.controlLogics.clear();
hmi_runtime_project.registerComments.clear();
if (!hmi_runtime_project.validateForRunning(
project_service_.projectLimits(), &validation_error))
{
showProjectResult(
@@ -2251,7 +2361,7 @@ void MainWindow::exportRuntimeProgram()
+ QStringLiteral("/qtproxinje-runtime-")
+ QString::number(QCoreApplication::applicationPid())
+ QStringLiteral(".json");
const ProjectOperationResult save_result = project_service_.exportAs(
const ProjectOperationResult save_result = project_service_.exportHmiRuntimeAs(
temp_project.toUtf8().toStdString());
if (!save_result.succeeded)
{
@@ -2313,7 +2423,7 @@ void MainWindow::exportRuntimeProgram()
progress.setLabelText(tr("正在复制 Qt 运行库和平台插件…"));
progress.setValue(65);
QApplication::processEvents();
const QString application_directory = QFileInfo(template_executable).absolutePath();
const QStringList dependency_directories = runtimeDependencyDirectories();
const QStringList runtime_files = {
QStringLiteral("Qt5Core.dll"),
QStringLiteral("Qt5Gui.dll"),
@@ -2326,21 +2436,33 @@ void MainWindow::exportRuntimeProgram()
QStringLiteral("libwinpthread-1.dll")};
for (const QString &runtime_file : runtime_files)
{
const QString source = QDir(application_directory).filePath(runtime_file);
const QString source = findRuntimeDependency(
runtime_file, dependency_directories);
const QString destination = QDir(destination_directory).filePath(runtime_file);
if (!QFileInfo::exists(source) || !QFile::copy(source, destination))
if (source.isEmpty() || !QFile::copy(source, destination))
{
failExport(tr("复制运行库失败:%1").arg(source));
failExport(
tr("复制运行库失败:%1\n已搜索程序目录、Qt 安装目录和 PATH。")
.arg(source.isEmpty() ? runtime_file : source));
return;
}
}
QString copy_error;
const QString platforms_source = findRuntimePluginDirectory(
QStringLiteral("platforms"), dependency_directories);
const QString styles_source = findRuntimePluginDirectory(
QStringLiteral("styles"), dependency_directories);
if (platforms_source.isEmpty() || styles_source.isEmpty())
{
failExport(tr("未找到 Qt 平台插件或样式目录\n请确认 Qt 运行环境完整。"));
return;
}
if (!copyDirectoryContents(
QDir(application_directory).filePath(QStringLiteral("platforms")),
platforms_source,
QDir(destination_directory).filePath(QStringLiteral("platforms")),
&copy_error)
|| !copyDirectoryContents(
QDir(application_directory).filePath(QStringLiteral("styles")),
styles_source,
QDir(destination_directory).filePath(QStringLiteral("styles")),
&copy_error))
{
@@ -2348,6 +2470,21 @@ void MainWindow::exportRuntimeProgram()
return;
}

progress.setLabelText(tr("正在写入运行版串口配置…"));
progress.setValue(85);
QApplication::processEvents();
std::string settings_error;
const QString runtime_settings_path = QDir(destination_directory).filePath(
QStringLiteral("config/runtime.ini"));
if (!RuntimeSettingsLoader::write(
runtime_settings_path,
application_settings_result_.settings.plcDefaults,
&settings_error))
{
failExport(fromUtf8(settings_error));
return;
}

const RuntimeProjectBundleLoadResult verification =
RuntimeProjectBundleService::load(destination_executable);
if (verification.status != RuntimeProjectBundleStatus::Loaded)


+ 8
- 2
app/src/ui/main_window.h Просмотреть файл

@@ -261,8 +261,10 @@ private:
void initializeUi();
/** 专用用户运行版启动后直接进入运行监控 */
void initializeUserRuntime();
/** 用户运行版允许在离线和真机之间自动经过编辑态切换 */
void requestUserRuntimeMode(ApplicationMode requested_mode);
/** 用户运行版完成 PLC 首读后自动进入真机运行 */
void enterUserRuntimeAfterInitialRead();
/** 用户运行版在串口或 PLC 通信故障后自动尝试重连 */
void attemptUserRuntimeReconnect();
/** 刷新编辑态数据监控的模式、权限和当前值 */
void refreshDataMonitorUi();

@@ -343,6 +345,10 @@ private:
std::string current_logic_id_;
/** 是否已经安排了待处理的 PLC 状态刷新 */
bool plc_status_update_pending_ = false;
/** 运行版最近一次已提示的 PLC 错误,避免同一故障重复弹窗 */
std::string last_user_runtime_plc_error_;
/** 用户运行版自动重连定时器 */
QTimer *user_runtime_reconnect_timer_ = nullptr;
struct HmiClipboardData
{
std::vector<HmiControl> controls;


+ 53
- 22
app/src/ui/runtime_monitor_widget.cpp Просмотреть файл

@@ -11,7 +11,7 @@

#include <QSplitter>
#include <QComboBox>
#include <QPushButton>
#include <QFrame>
#include <QSignalBlocker>
#include <QToolButton>

@@ -69,8 +69,6 @@ RuntimeMonitorWidget::RuntimeMonitorWidget(
ui_->monitorContainerLayout->addWidget(free_monitor_widget_);
ui_->logicNoticeLabel->setVisible(false);
ui_->runtimeModeComboBox->setVisible(false);
ui_->runtimePlcButton->setVisible(false);
ui_->runtimeDisconnectPlcButton->setVisible(false);
ui_->upperSplitter->setSizes({600, 600});
ui_->verticalSplitter->setSizes({440, 260});
connect(hmi_view_, &HmiEditorWidget::pageNavigationRequested,
@@ -111,10 +109,6 @@ RuntimeMonitorWidget::RuntimeMonitorWidget(
: static_cast<int>(ApplicationMode::OnlineRunning));
}
});
connect(ui_->runtimePlcButton, &QPushButton::clicked,
this, &RuntimeMonitorWidget::plcConfigurationRequested);
connect(ui_->runtimeDisconnectPlcButton, &QPushButton::clicked,
this, &RuntimeMonitorWidget::plcDisconnectRequested);
}

RuntimeMonitorWidget::~RuntimeMonitorWidget() = default;
@@ -155,6 +149,19 @@ void RuntimeMonitorWidget::setMode(
{
const bool offline = mode == ApplicationMode::OfflineRunning;
const bool online = mode == ApplicationMode::OnlineRunning;
if (hmi_only_mode_)
{
// 导出运行版只显示 HMI,数据源在进入真机态后固定为 PLC 缓存
ui_->logicFrame->setVisible(false);
ui_->monitorFrame->setVisible(false);
ui_->runtimeModeComboBox->setVisible(false);
ui_->exitRuntimeButton->setVisible(false);
hmi_view_->setRuntimeActive(true);
// 首读资格由控制器按当前 PLC 状态统一恢复,连接刚建立时仍保持只读
setHmiWriteEnabled(false);
setFreeMonitorWriteEnabled(false);
return;
}
ui_->logicFrame->setVisible(offline || online);
hmi_view_->setRuntimeActive(offline || online);
if (!offline && !online)
@@ -181,24 +188,45 @@ void RuntimeMonitorWidget::setMode(
const QSignalBlocker blocker(ui_->runtimeModeComboBox);
ui_->runtimeModeComboBox->setCurrentIndex(offline ? 0 : online ? 1 : -1);
}
if (user_runtime_mode_)
{
const bool plc_configurable = plc_state == PlcConnectionState::Disconnected
|| plc_state == PlcConnectionState::Faulted;
ui_->runtimePlcButton->setEnabled(plc_configurable);
ui_->runtimeDisconnectPlcButton->setEnabled(
plc_state != PlcConnectionState::Disconnected);
}
free_monitor_widget_->refreshValues(mode, plc_state);
}

void RuntimeMonitorWidget::setUserRuntimeMode(bool enabled)
void RuntimeMonitorWidget::setHmiOnlyRuntime(bool enabled)
{
user_runtime_mode_ = enabled;
// 普通编程器和导出的运行程序都允许在运行窗口切换离线/真机
ui_->runtimeModeComboBox->setVisible(true);
ui_->runtimePlcButton->setVisible(enabled);
ui_->runtimeDisconnectPlcButton->setVisible(enabled);
hmi_only_mode_ = enabled;
if (enabled)
{
// 用户运行版没有模式、连接配置和诊断面板,启动后只投影 HMI 页面
ui_->logicFrame->setVisible(false);
ui_->monitorFrame->setVisible(false);
ui_->titleLabel->setVisible(false);
ui_->modeLabel->setVisible(false);
ui_->runtimeModeComboBox->setVisible(false);
ui_->exitRuntimeButton->setVisible(false);
ui_->hmiLabel->setVisible(false);
ui_->runtimePageLabel->setVisible(false);
ui_->hmiFrame->setFrameShape(QFrame::NoFrame);
ui_->hmiLayout->setContentsMargins(0, 0, 0, 0);
ui_->verticalLayout->setContentsMargins(0, 0, 0, 0);
ui_->verticalSplitter->setSizes({1, 0});
ui_->upperSplitter->setSizes({1, 0});
}
else
{
// 普通编程器仍保留完整运行监控区域,运行版模式只在进程启动时启用
ui_->logicFrame->setVisible(false);
ui_->monitorFrame->setVisible(true);
ui_->titleLabel->setVisible(true);
ui_->modeLabel->setVisible(true);
ui_->hmiLabel->setVisible(true);
ui_->runtimePageLabel->setVisible(true);
ui_->exitRuntimeButton->setVisible(true);
ui_->hmiFrame->setFrameShape(QFrame::StyledPanel);
ui_->hmiLayout->setContentsMargins(4, 4, 4, 4);
ui_->verticalLayout->setContentsMargins(8, 8, 8, 8);
}
// 普通编程器和导出的运行程序都允许通过控制器注入模式行为
ui_->runtimeModeComboBox->setVisible(!enabled);
ui_->exitRuntimeButton->setToolTip(
enabled ? tr("退出运行程序") : tr("返回编辑态"));
ui_->exitRuntimeButton->setAccessibleName(
@@ -224,7 +252,10 @@ void RuntimeMonitorWidget::refreshValues(
{
// HMI、报警和自由监控共享同一个活动寄存器仓库
hmi_view_->refreshRuntimeValues();
free_monitor_widget_->refreshValues(mode, plc_state);
if (!hmi_only_mode_)
{
free_monitor_widget_->refreshValues(mode, plc_state);
}
}

void RuntimeMonitorWidget::setLogicTrace(


+ 5
- 9
app/src/ui/runtime_monitor_widget.h Просмотреть файл

@@ -32,7 +32,7 @@ class LogicEditorWidget;
class RegisterMonitorService;
class ProjectService;

/** 唯一的工程师运行监控投影,组合 HMI、梯形图轨迹和自由监控 */
/** 运行监控投影,普通模式组合 HMI/梯形图/自由监控,运行版仅保留 HMI */
class RuntimeMonitorWidget final : public QWidget
{
Q_OBJECT
@@ -57,8 +57,8 @@ public:
void setRuntimePage(const std::string &page_id);
/** 根据模式决定是否显示本地逻辑轨迹以及是否允许写入 */
void setMode(ApplicationMode mode, PlcConnectionState plc_state);
/** 切换专用用户运行版的简化操作栏 */
void setUserRuntimeMode(bool enabled);
/** 切换 HMI 专用用户运行版的界面模式 */
void setHmiOnlyRuntime(bool enabled);
/** 从寄存器仓库刷新 HMI 和自由监控中的当前值 */
void refreshValues(ApplicationMode mode, PlcConnectionState plc_state);
/** 设置 HMI 控件是否允许写入运行值 */
@@ -79,10 +79,6 @@ signals:
void exitRequested();
/** 用户在专用运行版中请求切换模式 */
void modeChangeRequested(int mode);
/** 用户请求打开 PLC 配置窗口 */
void plcConfigurationRequested();
/** 用户请求断开 PLC */
void plcDisconnectRequested();

private:
/** 显示指定的运行监控页面 */
@@ -110,6 +106,6 @@ private:
std::string latest_fault_node_id_;
/** 是否已经收到过可显示的逻辑轨迹 */
bool has_logic_trace_ = false;
/** 是否显示专用用户运行版操作栏 */
bool user_runtime_mode_ = false;
/** 是否只显示 HMI 页面并隐藏所有诊断区域 */
bool hmi_only_mode_ = false;
};

+ 0
- 2
app/src/ui/runtime_monitor_widget.ui Просмотреть файл

@@ -14,8 +14,6 @@
<item><spacer name="headerSpacer"><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="QLabel" name="modeLabel"><property name="text"><string>当前未运行</string></property><property name="alignment"><set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set></property></widget></item>
<item><widget class="QComboBox" name="runtimeModeComboBox"><property name="minimumSize"><size><width>120</width><height>0</height></size></property><item><property name="text"><string>离线仿真</string></property></item><item><property name="text"><string>真机运行</string></property></item></widget></item>
<item><widget class="QPushButton" name="runtimePlcButton"><property name="text"><string>PLC 配置</string></property></widget></item>
<item><widget class="QPushButton" name="runtimeDisconnectPlcButton"><property name="text"><string>断开 PLC</string></property></widget></item>
<item><widget class="QToolButton" name="exitRuntimeButton"><property name="toolTip"><string>返回编辑态</string></property><property name="accessibleName"><string>返回编辑态</string></property><property name="text"><string>返回编辑态</string></property><property name="toolButtonStyle"><enum>Qt::ToolButtonIconOnly</enum></property></widget></item>
</layout>
</item>


+ 19
- 1
app/src/ui/runtime_monitor_window.cpp Просмотреть файл

@@ -3,6 +3,7 @@
#include "ui_runtime_monitor_window.h"

#include <QCloseEvent>
#include <Qt>
#include <QWidget>

RuntimeMonitorWindow::RuntimeMonitorWindow(QWidget *parent)
@@ -22,6 +23,19 @@ void RuntimeMonitorWindow::setMonitorWidget(QWidget &widget)
ui_->runtimeMonitorWindowLayout->addWidget(&widget);
}

void RuntimeMonitorWindow::setHmiOnlyRuntime(bool enabled)
{
hmi_only_runtime_ = enabled;
if (enabled)
{
setWindowFlags(
Qt::Window
| Qt::WindowTitleHint
| Qt::WindowSystemMenuHint
| Qt::WindowCloseButtonHint);
}
}

void RuntimeMonitorWindow::showForRuntime()
{
application_exit_ = false;
@@ -47,10 +61,14 @@ void RuntimeMonitorWindow::closeForApplicationExit()
void RuntimeMonitorWindow::closeEvent(QCloseEvent *event)
{
// 运行期间禁止通过系统关闭路径离开运行界面,只允许应用退出时关闭
if (runtime_active_ && !application_exit_)
if (runtime_active_ && !application_exit_ && !hmi_only_runtime_)
{
event->ignore();
return;
}
if (runtime_active_ && !application_exit_ && hmi_only_runtime_)
{
emit closeRequested();
}
QMainWindow::closeEvent(event);
}

+ 9
- 1
app/src/ui/runtime_monitor_window.h Просмотреть файл

@@ -19,7 +19,7 @@ class RuntimeMonitorWindow;
class QCloseEvent;
class QWidget;

/** 承载唯一工程师运行监控投影的独立顶层窗口 */
/** 承载普通运行监控或 HMI 专用运行投影的独立顶层窗口 */
class RuntimeMonitorWindow final : public QMainWindow
{
Q_OBJECT
@@ -32,6 +32,8 @@ public:

/** 将运行监控控件放入窗口中央区域 */
void setMonitorWidget(QWidget &widget);
/** 切换导出运行版的标准窗口关闭行为 */
void setHmiOnlyRuntime(bool enabled);
/** 进入运行态时显示并最大化窗口 */
void showForRuntime();
/** 返回编辑态时隐藏窗口但保留唯一监控控件 */
@@ -39,6 +41,10 @@ public:
/** 应用退出时允许真正关闭窗口 */
void closeForApplicationExit();

signals:
/** 导出运行版用户关闭窗口时请求退出整个应用 */
void closeRequested();

protected:
/** 仅在应用退出时放行窗口关闭事件 */
void closeEvent(QCloseEvent *event) override;
@@ -50,4 +56,6 @@ private:
bool runtime_active_ = false;
/** 应用是否正在退出,决定关闭事件是否真正销毁窗口 */
bool application_exit_ = false;
/** 导出运行版允许使用系统关闭按钮 */
bool hmi_only_runtime_ = false;
};

+ 18
- 40
app/src/ui/runtime_panel_controller.cpp Просмотреть файл

@@ -90,6 +90,16 @@ void RuntimePanelController::configure()
runtime_monitor_window_.get());
runtime_monitor_widget_->setObjectName(QStringLiteral("runtimeMonitorWidget"));
runtime_monitor_window_->setMonitorWidget(*runtime_monitor_widget_);
QObject::connect(runtime_monitor_window_.get(),
&RuntimeMonitorWindow::closeRequested,
&parent_,
[this]
{
if (runtime_exit_requester_)
{
runtime_exit_requester_();
}
});
QObject::connect(runtime_monitor_widget_, &RuntimeMonitorWidget::exitRequested,
&parent_, [this]
{
@@ -121,36 +131,6 @@ void RuntimePanelController::configure()
status_reporter_(message, 5000);
output_reporter_(QObject::tr("页面导航失败:%1").arg(message));
});
QObject::connect(runtime_monitor_widget_,
&RuntimeMonitorWidget::modeChangeRequested,
&parent_,
[this](int mode)
{
if (mode_requester_)
{
mode_requester_(mode);
}
});
QObject::connect(runtime_monitor_widget_,
&RuntimeMonitorWidget::plcConfigurationRequested,
&parent_,
[this]
{
if (plc_configuration_requester_)
{
plc_configuration_requester_();
}
});
QObject::connect(runtime_monitor_widget_,
&RuntimeMonitorWidget::plcDisconnectRequested,
&parent_,
[this]
{
if (plc_disconnect_requester_)
{
plc_disconnect_requester_();
}
});
runtime_refresh_timer_ = new QTimer(&parent_);
runtime_refresh_timer_->setInterval(150);
QObject::connect(runtime_refresh_timer_, &QTimer::timeout,
@@ -173,18 +153,15 @@ void RuntimePanelController::configure()
Qt::QueuedConnection);
}

void RuntimePanelController::configureUserRuntimeMode(
bool enabled,
std::function<void(int)> mode_requester,
std::function<void()> plc_configuration_requester,
std::function<void()> plc_disconnect_requester)
void RuntimePanelController::setHmiOnlyRuntime(bool enabled)
{
mode_requester_ = std::move(mode_requester);
plc_configuration_requester_ = std::move(plc_configuration_requester);
plc_disconnect_requester_ = std::move(plc_disconnect_requester);
if (runtime_monitor_widget_ != nullptr)
{
runtime_monitor_widget_->setUserRuntimeMode(enabled);
runtime_monitor_widget_->setHmiOnlyRuntime(enabled);
}
if (runtime_monitor_window_ != nullptr)
{
runtime_monitor_window_->setHmiOnlyRuntime(enabled);
}
}

@@ -237,7 +214,8 @@ void RuntimePanelController::updateSimulationUi(bool report_fault)
const bool plc_connected = runtime_mode_service_.plcConnectionState()
== PlcConnectionState::Connected;
const SimulationState state = runtime_mode_service_.simulationState();
const bool write_enabled = (online && plc_connected)
const bool write_enabled = (online && plc_connected
&& runtime_mode_service_.initialPlcReadCompleted())
|| (offline && state == SimulationState::Running);
// 编辑画布始终只读,运行操作统一由运行监控中的 HMI 发出
hmi_editor_widget_.setRuntimeWriteEnabled(false);


+ 2
- 9
app/src/ui/runtime_panel_controller.h Просмотреть файл

@@ -70,12 +70,8 @@ public:

/** 连接运行监控相关信号并完成初始配置 */
void configure();
/** 配置专用用户运行版的精简控制栏行为 */
void configureUserRuntimeMode(
bool enabled,
std::function<void(int)> mode_requester,
std::function<void()> plc_configuration_requester,
std::function<void()> plc_disconnect_requester);
/** 切换 HMI 专用运行版投影 */
void setHmiOnlyRuntime(bool enabled);
/** 创建或复用唯一监控窗口,并把当前工程投影到运行态 */
void enterRuntime(
const std::string &page_id,
@@ -143,7 +139,4 @@ private:
QTimer *runtime_refresh_timer_ = nullptr;
/** 当前是否已经进入运行监控会话 */
bool runtime_session_active_ = false;
std::function<void(int)> mode_requester_;
std::function<void()> plc_configuration_requester_;
std::function<void()> plc_disconnect_requester_;
};

+ 32
- 0
app/tests/project_management_tests.cpp Просмотреть файл

@@ -563,6 +563,37 @@ void testProjectServiceStateAndConfiguredLimits()
"successful save must clear the modified state");
}

void testHmiRuntimeExportStripsNonHmiProjectData()
{
QTemporaryDir directory;
require(directory.isValid(), "HMI runtime export directory must be valid");
JsonProjectStorage storage(defaultProjectLimitSettings());
ProjectService service(storage, defaultProjectLimitSettings());
const Project fixture = makeExampleProject();
require(storage.save(
fixture,
directory.filePath("source.json").toStdString())
.succeeded,
"HMI runtime export fixture must save");
require(service.load(
directory.filePath("source.json").toStdString()).succeeded,
"HMI runtime export fixture must load");

const QString destination = directory.filePath("runtime.json");
require(service.exportHmiRuntimeAs(destination.toStdString()).succeeded,
"HMI runtime export must accept a configured HMI project");
const ProjectLoadResult loaded = storage.load(destination.toStdString());
require(loaded.succeeded
&& loaded.project.hmiPages.size() == fixture.hmiPages.size()
&& loaded.project.hmiPages.front().id
== fixture.hmiPages.front().id
&& loaded.project.alarmDefinitions.size()
== fixture.alarmDefinitions.size()
&& loaded.project.controlLogics.empty()
&& loaded.project.registerComments.empty(),
"HMI runtime export must keep HMI data and strip ladder metadata");
}

void testRegisterCommentService()
{
JsonProjectStorage storage(defaultProjectLimitSettings());
@@ -603,6 +634,7 @@ int main()
{"testStrictVersionAndRequiredFields", testStrictVersionAndRequiredFields},
{"testInvalidGridAndConnectionsAreRejected", testInvalidGridAndConnectionsAreRejected},
{"testProjectServiceStateAndConfiguredLimits", testProjectServiceStateAndConfiguredLimits},
{"testHmiRuntimeExportStripsNonHmiProjectData", testHmiRuntimeExportStripsNonHmiProjectData},
{"testRegisterCommentService", testRegisterCommentService},
});
}

+ 75
- 0
app/tests/runtime_mode_service_tests.cpp Просмотреть файл

@@ -6,6 +6,7 @@
#include "domain/project_storage.h"
#include "domain/register_repository.h"
#include "domain/virtual_register_repository.h"
#include "infrastructure/plc_register_repository.h"
#include "support/test_support.h"

#include <functional>
@@ -94,6 +95,16 @@ public:
}
}

void reportFault()
{
connection_state = PlcConnectionState::Faulted;
initial_read = false;
if (state_changed)
{
state_changed();
}
}

const std::vector<RegisterAddress> &pollAddresses() const
{
return poll_addresses;
@@ -349,6 +360,69 @@ void testModeTransitions()
"a completed PLC poll cycle must trigger one new local trace scan");
}

void testHmiOnlyRuntimeSkipsLogicExecutor()
{
TestProjectStorage storage;
ProjectService project_service(storage, defaultProjectLimitSettings());
VirtualRegisterRepository virtual_repository;
PlcRegisterRepository plc_repository;
ActiveRegisterRepository active_repository(virtual_repository);
OfflineSimulationService simulation_service(virtual_repository);
OnlineLogicMonitorService online_monitor_service(plc_repository);
LogicEditorService logic_editor_service(project_service);
ReadyPlcGateway gateway;
RuntimeModeService service(
project_service,
logic_editor_service,
simulation_service,
online_monitor_service);
service.configurePlc(
gateway, active_repository, virtual_repository, plc_repository);
service.setHmiOnlyRuntime(true);
require(!active_repository.readBit({RegisterArea::M, 0}).succeeded,
"HMI-only runtime must use the unavailable PLC cache before its first read");

HmiPage page;
page.id = "hmi-only-page";
page.name = "HMI only";
HmiControl button;
button.id = "hmi-only-button";
button.type = HmiControlType::Button;
button.bounds = {0, 0, 100, 40};
button.binding = RegisterAddress{RegisterArea::M, 0};
page.controls.push_back(button);
Project &project = project_service.editProject();
project.hmiPages.push_back(page);
project.initialHmiPageId = page.id;
// 该逻辑故意不完整,HMI 专用导出不应受它阻断
project.controlLogics.push_back(ControlLogic{});

require(service.connectPlc(
{"COM9", 1, 9600, 8, 2, 1, 1000, 2, 200}).succeeded,
"HMI-only runtime must accept a PLC connection request");
gateway.completeInitialRead();
plc_repository.updateBit(0, false);
require(service.enterOnlineRunning().succeeded,
"HMI-only runtime must ignore ladder logic readiness");
require(service.policy().usesPlcRegisters
&& online_monitor_service.state()
== OnlineLogicMonitorState::Stopped,
"HMI-only runtime must use PLC registers without starting local logic");
require(active_repository.readBit({RegisterArea::M, 0}).succeeded,
"HMI-only runtime must switch HMI reads to the PLC cache");
plc_repository.updateBit(0, true);
require(virtual_repository.writeBit({RegisterArea::M, 0}, false).succeeded
&& active_repository.readBit({RegisterArea::M, 0}).value,
"HMI-only runtime fault fixture must separate virtual and PLC values");
plc_repository.invalidate();
gateway.reportFault();
require(service.mode() == ApplicationMode::OnlineRunning
&& !service.initialPlcReadCompleted()
&& active_repository.readBit({RegisterArea::M, 0}).error
== RegisterError::Unavailable,
"HMI-only runtime faults must keep the PLC cache active without falling back offline");
}

void testMonitorPollRangeRollback()
{
TestProjectStorage storage;
@@ -483,6 +557,7 @@ int main()
{
return TestSupport::runTestSuite("runtime mode service tests", {
{"testModeTransitions", testModeTransitions},
{"testHmiOnlyRuntimeSkipsLogicExecutor", testHmiOnlyRuntimeSkipsLogicExecutor},
{"testMonitorPollRangeRollback", testMonitorPollRangeRollback},
{"testDisconnectedOutputBlocksOfflineAndOnlineRuntime", testDisconnectedOutputBlocksOfflineAndOnlineRuntime},
});


+ 3
- 1
app/tests/runtime_mode_service_tests.pro Просмотреть файл

@@ -10,7 +10,8 @@ SOURCES += \
$$SERVICE_PROJECT_SOURCES \
$$SERVICE_LOGIC_SOURCES \
$$SERVICE_OFFLINE_SOURCES \
$$SERVICE_RUNTIME_SOURCES
$$SERVICE_RUNTIME_SOURCES \
../src/infrastructure/plc_register_repository.cpp

HEADERS += \
$$DOMAIN_ALL_HEADERS \
@@ -18,4 +19,5 @@ HEADERS += \
$$SERVICE_LOGIC_HEADERS \
$$SERVICE_OFFLINE_HEADERS \
$$SERVICE_RUNTIME_HEADERS \
../src/infrastructure/plc_register_repository.h \
$$TEST_SUPPORT_HEADERS

+ 139
- 0
app/tests/runtime_settings_tests.cpp Просмотреть файл

@@ -0,0 +1,139 @@
#include "infrastructure/runtime_settings_loader.h"
#include "support/test_support.h"

#include <QCoreApplication>
#include <QFile>
#include <QTemporaryDir>

#include <algorithm>
#include <string>

namespace {

using TestSupport::require;

void writeUtf8(const QString &path, const QByteArray &contents)
{
QFile file(path);
require(file.open(QIODevice::WriteOnly), "runtime settings file must open");
require(file.write(contents) == contents.size(),
"runtime settings file must be written completely");
}

bool containsMessage(
const RuntimeSettingsLoadResult &result,
const std::string &fragment)
{
return std::any_of(
result.messages.cbegin(), result.messages.cend(),
[&fragment](const std::string &message)
{
return message.find(fragment) != std::string::npos;
});
}

void testMissingFileCreatesOnlyPlcDefaults()
{
QTemporaryDir directory;
require(directory.isValid(), "runtime settings directory must be valid");
const QString path = directory.filePath("config/runtime.ini");
const RuntimeSettingsLoadResult result = RuntimeSettingsLoader::load(path);
require(QFile::exists(path) && !result.warningRequired,
"missing runtime settings must be created without warning");
QFile file(path);
require(file.open(QIODevice::ReadOnly), "created runtime settings must be readable");
const QByteArray contents = file.readAll();
require(contents.contains("[PlcDefaults]\n")
&& contents.contains("PortName=COM3\n")
&& !contents.contains("[ProjectLimits]")
&& !contents.contains("[HmiDefaults]")
&& !contents.contains("[Config]"),
"runtime settings must contain only PLC defaults");
}

void testRuntimeSettingsLoadsSerialFields()
{
QTemporaryDir directory;
require(directory.isValid(), "runtime settings directory must be valid");
const QString path = directory.filePath("runtime.ini");
writeUtf8(
path,
QByteArrayLiteral(
"[PlcDefaults]\n"
"PortName=COM7\n"
"ServerAddress=2\n"
"BaudRate=19200\n"
"DataBits=7\n"
"Parity=3\n"
"StopBits=2\n"));
const RuntimeSettingsLoadResult result = RuntimeSettingsLoader::load(path);
require(!result.warningRequired
&& result.plcDefaults.portName == "COM7"
&& result.plcDefaults.serverAddress == 2
&& result.plcDefaults.baudRate == 19200
&& result.plcDefaults.dataBits == 7
&& result.plcDefaults.parity == 3
&& result.plcDefaults.stopBits == 2,
"runtime settings must load all six serial fields");
}

void testRuntimeSettingsRejectsInvalidFileAsOneUnit()
{
QTemporaryDir directory;
require(directory.isValid(), "runtime settings directory must be valid");
const QString path = directory.filePath("invalid.ini");
writeUtf8(
path,
QByteArrayLiteral(
"[PlcDefaults]\n"
"PortName=COM7\n"
"ServerAddress=2\n"
"BaudRate=19200\n"
"DataBits=8\n"
"Parity=2\n"
"StopBits=1\n"
"[PlcDefaults]\n"
"PortName=COM8\n"));
const RuntimeSettingsLoadResult result = RuntimeSettingsLoader::load(path);
require(result.warningRequired
&& result.plcDefaults.portName == "COM3"
&& containsMessage(result, "重复"),
"duplicate runtime settings must fall back to code defaults");
}

void testRuntimeSettingsWritesMinimalFile()
{
QTemporaryDir directory;
require(directory.isValid(), "runtime settings directory must be valid");
const QString path = directory.filePath("config/runtime.ini");
PlcSerialConfiguration configuration;
configuration.portName = "COM9";
configuration.serverAddress = 9;
configuration.baudRate = 38400;
configuration.dataBits = 8;
configuration.parity = 0;
configuration.stopBits = 1;
std::string error;
require(RuntimeSettingsLoader::write(path, configuration, &error),
"runtime settings must write successfully");
const RuntimeSettingsLoadResult loaded = RuntimeSettingsLoader::load(path);
require(!loaded.warningRequired
&& loaded.plcDefaults.portName == "COM9"
&& loaded.plcDefaults.serverAddress == 9
&& loaded.plcDefaults.baudRate == 38400
&& loaded.plcDefaults.parity == 0,
"written runtime settings must round trip");
}

} // namespace

int main(int argc, char *argv[])
{
QCoreApplication application(argc, argv);
return TestSupport::runTestSuite("runtime settings tests", {
{"testMissingFileCreatesOnlyPlcDefaults", testMissingFileCreatesOnlyPlcDefaults},
{"testRuntimeSettingsLoadsSerialFields", testRuntimeSettingsLoadsSerialFields},
{"testRuntimeSettingsRejectsInvalidFileAsOneUnit", testRuntimeSettingsRejectsInvalidFileAsOneUnit},
{"testRuntimeSettingsWritesMinimalFile", testRuntimeSettingsWritesMinimalFile},
});
}

+ 15
- 0
app/tests/runtime_settings_tests.pro Просмотреть файл

@@ -0,0 +1,15 @@
include(pri/test_defaults.pri)

TARGET = runtime_settings_tests
QT += core serialport
CONFIG += testcase

SOURCES += \
runtime_settings_tests.cpp \
../src/infrastructure/runtime_settings_loader.cpp

HEADERS += \
../src/infrastructure/runtime_settings_loader.h \
../src/services/plc_communication_gateway.h \
../src/domain/project_limits.h \
$$TEST_SUPPORT_HEADERS

+ 2
- 0
app/tests/tests.pro Просмотреть файл

@@ -12,6 +12,7 @@ SUBDIRS += \
project_management \
register_monitor \
runtime_project_bundle \
runtime_settings \
runtime_mode \
runtime_panel_controller \
plc_connection_dialog \
@@ -26,6 +27,7 @@ offline_simulation.file = offline_simulation_service_tests.pro
project_management.file = project_management_tests.pro
register_monitor.file = register_monitor_service_tests.pro
runtime_project_bundle.file = runtime_project_bundle_tests.pro
runtime_settings.file = runtime_settings_tests.pro
runtime_mode.file = runtime_mode_service_tests.pro
runtime_panel_controller.file = runtime_panel_controller_tests.pro
plc_connection_dialog.file = plc_connection_dialog_tests.pro


+ 1
- 0
scripts/run_qt_tests.ps1 Просмотреть файл

@@ -34,6 +34,7 @@ $functionalTargets = @(
'project_management_tests',
'register_monitor_service_tests',
'runtime_project_bundle_tests',
'runtime_settings_tests',
'runtime_mode_service_tests',
'runtime_panel_controller_tests',
'plc_connection_dialog_tests',


Загрузка…
Отмена
Сохранить