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

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_communication_service.cpp \
src/infrastructure/plc_discovery_service.cpp \ src/infrastructure/plc_discovery_service.cpp \
src/infrastructure/application_settings_loader.cpp \ src/infrastructure/application_settings_loader.cpp \
src/infrastructure/runtime_settings_loader.cpp \
src/infrastructure/runtime_project_bundle.cpp \ src/infrastructure/runtime_project_bundle.cpp \
src/infrastructure/json_project_storage.cpp \ src/infrastructure/json_project_storage.cpp \
src/ui/hmi_editor_widget.cpp \ src/ui/hmi_editor_widget.cpp \
@@ -118,6 +119,7 @@ HEADERS += \
src/infrastructure/plc_communication_service.h \ src/infrastructure/plc_communication_service.h \
src/infrastructure/plc_discovery_service.h \ src/infrastructure/plc_discovery_service.h \
src/infrastructure/application_settings_loader.h \ src/infrastructure/application_settings_loader.h \
src/infrastructure/runtime_settings_loader.h \
src/infrastructure/runtime_project_bundle.h \ src/infrastructure/runtime_project_bundle.h \
src/infrastructure/json_project_storage.h \ src/infrastructure/json_project_storage.h \
src/ui/hmi_editor_widget.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" #include "active_register_repository.h"


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


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


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


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


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


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


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


// 将连续 D 字写入请求转发给当前活动仓库
RegisterWriteResult ActiveRegisterRepository::writeWords( RegisterWriteResult ActiveRegisterRepository::writeWords(
const RegisterAddress &address, const RegisterAddress &address,
const std::vector<std::int16_t> &values) 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); void use(RegisterRepository &repository);
// 从当前活动仓库读取一个 M 区位值
BitReadResult readBit(const RegisterAddress &address) const override; BitReadResult readBit(const RegisterAddress &address) const override;
// 向当前活动仓库写入一个 M 区位值
RegisterWriteResult writeBit(const RegisterAddress &address, bool value) override; RegisterWriteResult writeBit(const RegisterAddress &address, bool value) override;
// 从当前活动仓库读取一个 D 区字值
WordReadResult readWord(const RegisterAddress &address) const override; WordReadResult readWord(const RegisterAddress &address) const override;
// 向当前活动仓库写入一个 D 区字值
RegisterWriteResult writeWord( RegisterWriteResult writeWord(
const RegisterAddress &address, std::int16_t value) override; const RegisterAddress &address, std::int16_t value) override;
// 从当前活动仓库连续读取多个 D 区字值
WordsReadResult readWords( WordsReadResult readWords(
const RegisterAddress &address, int count) const override; const RegisterAddress &address, int count) const override;
// 向当前活动仓库连续写入多个 D 区字值
RegisterWriteResult writeWords( RegisterWriteResult writeWords(
const RegisterAddress &address, const RegisterAddress &address,
const std::vector<std::int16_t> &values) override; const std::vector<std::int16_t> &values) override;


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

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

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


namespace { namespace {


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


} // namespace } // namespace


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


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

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


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


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


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

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

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


namespace { namespace {


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


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


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


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


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


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


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


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


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


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


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


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


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


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


} // namespace } // namespace


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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

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


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


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


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


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


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


struct WordOperand 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; bool validate(std::string *error = nullptr) const;
}; };


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


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


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


struct CompareNodeConfig 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 struct MoveNodeConfig
{ {
WordOperand source;
RegisterAddress destination{RegisterArea::D, 0};
WordOperand source; // MOVE 的源操作数
RegisterAddress destination{RegisterArea::D, 0}; // MOVE 的目标 D 地址
}; };


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


struct ArithmeticNodeConfig 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< using LogicNodeConfig = std::variant<
@@ -106,98 +107,121 @@ using LogicNodeConfig = std::variant<
MoveNodeConfig, MoveNodeConfig,
ArithmeticNodeConfig>; ArithmeticNodeConfig>;


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


struct LogicNode 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 validate(std::string *error = nullptr) const;
// 返回节点是否已完成运行配置
bool isConfigured() const; bool isConfigured() const;
// 返回节点是否属于条件节点
bool isCondition() const; bool isCondition() const;
// 返回节点是否属于输出节点
bool isOutput() const; bool isOutput() const;
}; };


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


// 条件区的一个固定网格,横线和空白与条件节点具有同等持久化地位 // 条件区的一个固定网格,横线和空白与条件节点具有同等持久化地位
struct LadderCell 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; bool validate(std::string *error = nullptr) const;
}; };


// 两个相邻视觉行之间、指定列边界上的一段竖线 // 两个相邻视觉行之间、指定列边界上的一段竖线
struct VerticalConnection 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; bool validate(std::string *error = nullptr) const;
}; };


// 连续梯形图的一条视觉行,不再是隔离的网络容器 // 连续梯形图的一条视觉行,不再是隔离的网络容器
struct LadderRung 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 validate(std::string *error = nullptr) const;
// 校验保存时需要满足的结构规则
bool validateStructure(std::string *error = nullptr) const; bool validateStructure(std::string *error = nullptr) const;
// 校验运行前的结构和节点配置
bool validateForRunning(std::string *error = nullptr) const; bool validateForRunning(std::string *error = nullptr) const;
}; };


// 一张连续梯形图,网络由横竖连接关系自然形成 // 一张连续梯形图,网络由横竖连接关系自然形成
struct ControlLogic 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; std::size_t networkHeadIndex(std::size_t rung_index) const;
// 使用指定工程限制校验控制逻辑
bool validate( bool validate(
const ProjectLimitSettings &limits, const ProjectLimitSettings &limits,
std::string *error = nullptr) const; std::string *error = nullptr) const;
// 使用默认工程限制校验控制逻辑结构
bool validateStructure(std::string *error = nullptr) const; bool validateStructure(std::string *error = nullptr) const;
// 使用指定工程限制校验控制逻辑结构
bool validateStructure( bool validateStructure(
const ProjectLimitSettings &limits, const ProjectLimitSettings &limits,
std::string *error = nullptr) const; std::string *error = nullptr) const;
// 使用默认工程限制校验运行前的控制逻辑
bool validateForRunning(std::string *error = nullptr) const; bool validateForRunning(std::string *error = nullptr) const;
// 使用指定工程限制校验运行前的控制逻辑
bool validateForRunning( bool validateForRunning(
const ProjectLimitSettings &limits, const ProjectLimitSettings &limits,
std::string *error = nullptr) const; std::string *error = nullptr) const;
}; };


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

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

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


namespace { namespace {


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


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


} // namespace } // namespace


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


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


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


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

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


namespace { namespace {


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


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


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


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


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


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


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


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


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


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


} // namespace } // namespace


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


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


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


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


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

@@ -20,10 +20,10 @@
namespace HmiAppearanceProperty 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 } // namespace HmiAppearanceProperty


@@ -34,13 +34,13 @@ inline constexpr const char kFontItalic[] = "fontItalic";
*/ */
struct HmiRect 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 数值启用条件的比较方式 */ /** @brief HMI 按钮 D 数值启用条件的比较方式 */
enum class HmiButtonConditionOperator enum class HmiButtonConditionOperator
{ {
Equal,
NotEqual,
LessThan,
LessThanOrEqual,
GreaterThan,
GreaterThanOrEqual
Equal, // 等于
NotEqual, // 不等于
LessThan, // 小于
LessThanOrEqual, // 小于或等于
GreaterThan, // 大于
GreaterThanOrEqual // 大于或等于
}; };


// 按钮使用 M 位判断是否允许操作的条件
struct HmiButtonBitEnableCondition 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 bool operator==(const HmiButtonBitEnableCondition &other) const
{ {
return address == other.address && expected == other.expected; return address == other.address && expected == other.expected;
} }
}; };


// 按钮使用 D 数值判断是否允许操作的条件
struct HmiButtonWordEnableCondition 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 bool operator==(const HmiButtonWordEnableCondition &other) const
{ {
return address == other.address return address == other.address
@@ -110,32 +114,38 @@ struct HmiButtonWordEnableCondition
} }
}; };


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


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


// M 状态文本的 OFF/ON 映射配置
struct HmiStatusBitTextConfig 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 bool operator==(const HmiStatusBitTextConfig &other) const
{ {
return offText == other.offText && onText == other.onText; return offText == other.offText && onText == other.onText;
} }
}; };


// D 状态文本使用的一个左闭右开数值区间
struct HmiStatusValueRange 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 bool operator==(const HmiStatusValueRange &other) const
{ {
return lowerBound == other.lowerBound return lowerBound == other.lowerBound
@@ -144,16 +154,19 @@ struct HmiStatusValueRange
} }
}; };


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


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


// 状态文本可以使用 M 位映射或 D 数值区间映射
using HmiStatusTextConfig = std::variant< using HmiStatusTextConfig = std::variant<
HmiStatusBitTextConfig, HmiStatusBitTextConfig,
HmiStatusWordTextConfig>; HmiStatusWordTextConfig>;
@@ -165,17 +178,17 @@ using HmiStatusTextConfig = std::variant<
*/ */
struct HmiControl 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 校验控件的标识、尺寸、扩展属性和寄存器绑定 * @brief 校验控件的标识、尺寸、扩展属性和寄存器绑定
@@ -185,6 +198,7 @@ struct HmiControl
* 位控件必须绑定有效 M 地址,字控件必须绑定有效 D 地址 * 位控件必须绑定有效 M 地址,字控件必须绑定有效 D 地址
*/ */
bool validate(std::string *error = nullptr) const; bool validate(std::string *error = nullptr) const;
// 返回控件是否已具备运行所需的绑定和专用配置
bool isConfigured() const; bool isConfigured() const;
}; };


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


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

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


namespace { namespace {


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


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


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


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


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


struct HmiDataBinding 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) bool isNumericControl(const HmiControl &control)
{ {
return control.type == HmiControlType::NumericDisplay return control.type == HmiControlType::NumericDisplay
@@ -88,6 +94,7 @@ bool isNumericControl(const HmiControl &control)
&& control.binding->area() == RegisterArea::D); && control.binding->area() == RegisterArea::D);
} }


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


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


} // namespace } // namespace


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


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


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


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


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


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

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


#include <algorithm> #include <algorithm>


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


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


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


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


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


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


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


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

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


// 一个监控行的地址、可用性和当前值 // 一个监控行的地址、可用性和当前值
struct MonitorValue 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 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<RegisterAddress> &addresses() const;
// 返回当前按添加顺序保存的监控点
const std::vector<MonitorPoint> &points() const; const std::vector<MonitorPoint> &points() const;
// 判断地址是否已经在监控列表中 // 判断地址是否已经在监控列表中
bool contains(const MonitorPoint &point) const; bool contains(const MonitorPoint &point) const;
@@ -53,6 +55,6 @@ public:
void clear(); void clear();


private: 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; // 写入失败原因 RegisterError error = RegisterError::Unavailable; // 写入失败原因
}; };


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


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

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


namespace { namespace {


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


// 将固定长度数组转换为动态数组,便于统一返回编码结果
template <std::size_t Size> template <std::size_t Size>
std::vector<std::int16_t> toVector(const std::array<std::int16_t, Size> &words) 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 } // namespace


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


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


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


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


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


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


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


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


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


// 按低字在前的顺序解码 32 位浮点数
std::optional<float> Float32Codec::decode( std::optional<float> Float32Codec::decode(
std::int16_t low_word, std::int16_t high_word) 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; return std::isfinite(value) ? std::optional<float>(value) : std::nullopt;
} }


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


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


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


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


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

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


enum class RegisterDataType 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 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>; using RegisterNumericValue = std::variant<std::int16_t, std::int32_t, float, double>;


// 描述一段连续的 D 区字地址范围
struct RegisterWordRange 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 bool operator==(const RegisterWordRange &other) const
{ {
return start == other.start && wordCount == other.wordCount; return start == other.start && wordCount == other.wordCount;
} }
}; };


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


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


struct Float32Codec struct Float32Codec
{ {
// 将 32 位浮点数拆成低字在前的两个 D 字
static std::array<std::int16_t, 2> encode(float value); 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); static std::optional<float> decode(std::int16_t low_word, std::int16_t high_word);
}; };


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


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

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

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


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


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


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


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


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


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

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


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

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


#include <algorithm> #include <algorithm>


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


// 校验地址后读取指定的 M 区位值
BitReadResult VirtualRegisterRepository::readBit(const RegisterAddress &address) const 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}; return {true, bits_[static_cast<std::size_t>(address.index())], RegisterError::None};
} }


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


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


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


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


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


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


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

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

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


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


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

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


namespace { namespace {


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


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


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


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


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


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


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


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


} // namespace } // namespace


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


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


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


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


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


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


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


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


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

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

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

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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


} // namespace } // namespace


// 保存工程级数量限制,供后续保存和加载校验使用
JsonProjectStorage::JsonProjectStorage( JsonProjectStorage::JsonProjectStorage(
const ProjectLimitSettings &project_limits) const ProjectLimitSettings &project_limits)
: project_limits_(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: public:
explicit JsonProjectStorage( explicit JsonProjectStorage(
const ProjectLimitSettings &project_limits); const ProjectLimitSettings &project_limits);
// 校验并以 JSON 4.0 格式原子保存工程
ProjectSaveResult save( ProjectSaveResult save(
const Project &project, const std::string &file_path) override; const Project &project, const std::string &file_path) override;
// 从 JSON 4.0 文件读取并校验工程
ProjectLoadResult load(const std::string &file_path) override; ProjectLoadResult load(const std::string &file_path) override;


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

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

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


namespace { namespace {


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


} // namespace } // namespace


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


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


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

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


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


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

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

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


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


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


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


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


} // namespace } // namespace


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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

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


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


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


} // namespace } // namespace


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


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


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


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


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


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


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


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


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


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


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


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


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


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

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


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


// 尝试当前候选参数并等待串口异步打开 // 尝试当前候选参数并等待串口异步打开
@@ -54,15 +58,15 @@ private:
// 处理 Qt Modbus 主站连接状态变化 // 处理 Qt Modbus 主站连接状态变化
void handleDeviceStateChanged(QModbusDevice::State state); 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/json_project_storage.h"
#include "infrastructure/application_settings_loader.h" #include "infrastructure/application_settings_loader.h"
#include "infrastructure/runtime_settings_loader.h"
#include "infrastructure/plc_communication_service.h" #include "infrastructure/plc_communication_service.h"
#include "infrastructure/plc_discovery_service.h" #include "infrastructure/plc_discovery_service.h"
#include "infrastructure/plc_register_repository.h" #include "infrastructure/plc_register_repository.h"
@@ -61,9 +62,21 @@ int main(int argc, char *argv[])
== RuntimeProjectBundleStatus::Loaded; == RuntimeProjectBundleStatus::Loaded;
const bool user_runtime_mode = bundled_runtime_project; 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()); ApplicationSettingsLoader::defaultFilePath());
}


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


// 创建主窗口,并把界面操作需要的各项服务交给它使用 // 创建主窗口,并把界面操作需要的各项服务交给它使用
MainWindow main_window( 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, {}}; 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) ProjectOperationResult ProjectService::load(const std::string &file_path)
{ {
if (isBlank(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); ProjectOperationResult saveAs(const std::string &file_path);
/** 将当前工程导出到指定 JSON,不改变当前工程文件关联 */ /** 将当前工程导出到指定 JSON,不改变当前工程文件关联 */
ProjectOperationResult exportAs(const std::string &file_path) const; ProjectOperationResult exportAs(const std::string &file_path) const;
/** 将仅包含 HMI 运行所需内容的工程快照导出到指定 JSON */
ProjectOperationResult exportHmiRuntimeAs(const std::string &file_path) const;


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


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

@@ -111,12 +111,15 @@ ModeTransitionResult RuntimeModeService::enterOnlineRunning()
{ {
return preparation; 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); const ModeTransitionResult result = state_.enterOnlineRunning(true);
if (result.succeeded && active_repository_ != nullptr && plc_repository_ != nullptr) if (result.succeeded && active_repository_ != nullptr && plc_repository_ != nullptr)
@@ -125,11 +128,24 @@ ModeTransitionResult RuntimeModeService::enterOnlineRunning()
} }
else if (!result.succeeded) else if (!result.succeeded)
{ {
online_logic_monitor_service_.stop();
if (!hmi_only_runtime_)
{
online_logic_monitor_service_.stop();
}
} }
return result; 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) void RuntimeModeService::setInitialPlcReadCompleted(bool completed)
{ {
// 通信服务完成有效读回后才允许把此标志设为 true // 通信服务完成有效读回后才允许把此标志设为 true
@@ -176,17 +192,32 @@ const LogicSyntaxCheckResult &RuntimeModeService::lastSyntaxCheck() const


ModeTransitionResult RuntimeModeService::prepareProjectForRunning() 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; 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)}; return {false, ModeTransitionError::ProjectNotReady, std::move(error)};
} }
@@ -203,7 +234,7 @@ void RuntimeModeService::configurePlc(
active_repository_ = &active_repository; active_repository_ = &active_repository;
virtual_repository_ = &virtual_repository; virtual_repository_ = &virtual_repository;
plc_repository_ = &plc_repository; plc_repository_ = &plc_repository;
// 通信故障撤销首读资格;若正在真机运行,立即回到编辑态
// 通信故障撤销首读资格;普通编程器回到编辑态,HMI 运行版保留 PLC 缓存画面
gateway.setCallbacks( gateway.setCallbacks(
[this] [this]
{ {
@@ -214,7 +245,8 @@ void RuntimeModeService::configurePlc(
|| plc_state == PlcConnectionState::Faulted) || plc_state == PlcConnectionState::Faulted)
{ {
setInitialPlcReadCompleted(false); setInitialPlcReadCompleted(false);
if (state_.mode() == ApplicationMode::OnlineRunning)
if (state_.mode() == ApplicationMode::OnlineRunning
&& !hmi_only_runtime_)
{ {
enterEditing(); enterEditing();
} }
@@ -237,6 +269,7 @@ void RuntimeModeService::configurePlc(
[this] [this]
{ {
if (state_.mode() == ApplicationMode::OnlineRunning if (state_.mode() == ApplicationMode::OnlineRunning
&& !hmi_only_runtime_
&& online_logic_monitor_service_.state() && online_logic_monitor_service_.state()
== OnlineLogicMonitorState::Running) == OnlineLogicMonitorState::Running)
{ {


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

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


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


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


bool isTextEditingObject(QObject *object) bool isTextEditingObject(QObject *object)
{ {
@@ -313,6 +316,80 @@ bool copyDirectoryContents(
return true; 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 } // namespace


MainWindow::MainWindow( MainWindow::MainWindow(
@@ -456,88 +533,81 @@ void MainWindow::initializeUi()
void MainWindow::initializeUserRuntime() void MainWindow::initializeUserRuntime()
{ {
setWindowTitle(fromUtf8(project_service_.project().metadata.name)); 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); menuBar()->setVisible(false);
ui_->hmiToolBar->setVisible(false); ui_->hmiToolBar->setVisible(false);
ui_->logicToolBar->setVisible(false); ui_->logicToolBar->setVisible(false);
ui_->outputDock->setVisible(false); ui_->outputDock->setVisible(false);
ui_->projectDock->setVisible(false); ui_->projectDock->setVisible(false);
ui_->propertiesDock->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( QTimer::singleShot(
0, 0,
this, this,
[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; 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; 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; return;
} }
requestMode(requested_mode);
runtime_mode_service_.connectPlc(plc_configuration_);
} }


MainWindow::~MainWindow() MainWindow::~MainWindow()
@@ -1149,15 +1219,15 @@ void MainWindow::configureRuntimeMonitor()
} }
}); });
runtime_panel_controller_->configure(); 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) [this](int mode)
{ {
requestMode(static_cast<ApplicationMode>(mode)); requestMode(static_cast<ApplicationMode>(mode));
},
{},
{});
runtime_monitor_widget_ = runtime_panel_controller_->runtimeMonitorWidget();
});
} }


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


void MainWindow::connectPlc() 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( PlcConnectionDialog dialog(
plc_configuration_, plc_configuration_,
plc_discovery_gateway_, plc_discovery_gateway_,
@@ -1707,6 +1791,39 @@ void MainWindow::schedulePlcStatusUpdate()
[this] [this]
{ {
plc_status_update_pending_ = false; 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_)); updateModeUi(plcStatusText(runtime_mode_service_));
}, },
Qt::QueuedConnection); Qt::QueuedConnection);
@@ -2168,18 +2285,11 @@ void MainWindow::loadProject()


void MainWindow::exportRuntimeProgram() 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; 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)) project_service_.projectLimits(), &validation_error))
{ {
showProjectResult( showProjectResult(
@@ -2251,7 +2361,7 @@ void MainWindow::exportRuntimeProgram()
+ QStringLiteral("/qtproxinje-runtime-") + QStringLiteral("/qtproxinje-runtime-")
+ QString::number(QCoreApplication::applicationPid()) + QString::number(QCoreApplication::applicationPid())
+ QStringLiteral(".json"); + QStringLiteral(".json");
const ProjectOperationResult save_result = project_service_.exportAs(
const ProjectOperationResult save_result = project_service_.exportHmiRuntimeAs(
temp_project.toUtf8().toStdString()); temp_project.toUtf8().toStdString());
if (!save_result.succeeded) if (!save_result.succeeded)
{ {
@@ -2313,7 +2423,7 @@ void MainWindow::exportRuntimeProgram()
progress.setLabelText(tr("正在复制 Qt 运行库和平台插件…")); progress.setLabelText(tr("正在复制 Qt 运行库和平台插件…"));
progress.setValue(65); progress.setValue(65);
QApplication::processEvents(); QApplication::processEvents();
const QString application_directory = QFileInfo(template_executable).absolutePath();
const QStringList dependency_directories = runtimeDependencyDirectories();
const QStringList runtime_files = { const QStringList runtime_files = {
QStringLiteral("Qt5Core.dll"), QStringLiteral("Qt5Core.dll"),
QStringLiteral("Qt5Gui.dll"), QStringLiteral("Qt5Gui.dll"),
@@ -2326,21 +2436,33 @@ void MainWindow::exportRuntimeProgram()
QStringLiteral("libwinpthread-1.dll")}; QStringLiteral("libwinpthread-1.dll")};
for (const QString &runtime_file : runtime_files) 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); 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; return;
} }
} }
QString copy_error; 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( if (!copyDirectoryContents(
QDir(application_directory).filePath(QStringLiteral("platforms")),
platforms_source,
QDir(destination_directory).filePath(QStringLiteral("platforms")), QDir(destination_directory).filePath(QStringLiteral("platforms")),
&copy_error) &copy_error)
|| !copyDirectoryContents( || !copyDirectoryContents(
QDir(application_directory).filePath(QStringLiteral("styles")),
styles_source,
QDir(destination_directory).filePath(QStringLiteral("styles")), QDir(destination_directory).filePath(QStringLiteral("styles")),
&copy_error)) &copy_error))
{ {
@@ -2348,6 +2470,21 @@ void MainWindow::exportRuntimeProgram()
return; 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 = const RuntimeProjectBundleLoadResult verification =
RuntimeProjectBundleService::load(destination_executable); RuntimeProjectBundleService::load(destination_executable);
if (verification.status != RuntimeProjectBundleStatus::Loaded) if (verification.status != RuntimeProjectBundleStatus::Loaded)


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

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


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


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

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


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


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


RuntimeMonitorWidget::~RuntimeMonitorWidget() = default; RuntimeMonitorWidget::~RuntimeMonitorWidget() = default;
@@ -155,6 +149,19 @@ void RuntimeMonitorWidget::setMode(
{ {
const bool offline = mode == ApplicationMode::OfflineRunning; const bool offline = mode == ApplicationMode::OfflineRunning;
const bool online = mode == ApplicationMode::OnlineRunning; 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); ui_->logicFrame->setVisible(offline || online);
hmi_view_->setRuntimeActive(offline || online); hmi_view_->setRuntimeActive(offline || online);
if (!offline && !online) if (!offline && !online)
@@ -181,24 +188,45 @@ void RuntimeMonitorWidget::setMode(
const QSignalBlocker blocker(ui_->runtimeModeComboBox); const QSignalBlocker blocker(ui_->runtimeModeComboBox);
ui_->runtimeModeComboBox->setCurrentIndex(offline ? 0 : online ? 1 : -1); 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); 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( ui_->exitRuntimeButton->setToolTip(
enabled ? tr("退出运行程序") : tr("返回编辑态")); enabled ? tr("退出运行程序") : tr("返回编辑态"));
ui_->exitRuntimeButton->setAccessibleName( ui_->exitRuntimeButton->setAccessibleName(
@@ -224,7 +252,10 @@ void RuntimeMonitorWidget::refreshValues(
{ {
// HMI、报警和自由监控共享同一个活动寄存器仓库 // HMI、报警和自由监控共享同一个活动寄存器仓库
hmi_view_->refreshRuntimeValues(); hmi_view_->refreshRuntimeValues();
free_monitor_widget_->refreshValues(mode, plc_state);
if (!hmi_only_mode_)
{
free_monitor_widget_->refreshValues(mode, plc_state);
}
} }


void RuntimeMonitorWidget::setLogicTrace( void RuntimeMonitorWidget::setLogicTrace(


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

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


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


private: private:
/** 显示指定的运行监控页面 */ /** 显示指定的运行监控页面 */
@@ -110,6 +106,6 @@ private:
std::string latest_fault_node_id_; std::string latest_fault_node_id_;
/** 是否已经收到过可显示的逻辑轨迹 */ /** 是否已经收到过可显示的逻辑轨迹 */
bool has_logic_trace_ = false; 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><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="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="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> <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> </layout>
</item> </item>


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

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


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


RuntimeMonitorWindow::RuntimeMonitorWindow(QWidget *parent) RuntimeMonitorWindow::RuntimeMonitorWindow(QWidget *parent)
@@ -22,6 +23,19 @@ void RuntimeMonitorWindow::setMonitorWidget(QWidget &widget)
ui_->runtimeMonitorWindowLayout->addWidget(&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() void RuntimeMonitorWindow::showForRuntime()
{ {
application_exit_ = false; application_exit_ = false;
@@ -47,10 +61,14 @@ void RuntimeMonitorWindow::closeForApplicationExit()
void RuntimeMonitorWindow::closeEvent(QCloseEvent *event) void RuntimeMonitorWindow::closeEvent(QCloseEvent *event)
{ {
// 运行期间禁止通过系统关闭路径离开运行界面,只允许应用退出时关闭 // 运行期间禁止通过系统关闭路径离开运行界面,只允许应用退出时关闭
if (runtime_active_ && !application_exit_)
if (runtime_active_ && !application_exit_ && !hmi_only_runtime_)
{ {
event->ignore(); event->ignore();
return; return;
} }
if (runtime_active_ && !application_exit_ && hmi_only_runtime_)
{
emit closeRequested();
}
QMainWindow::closeEvent(event); QMainWindow::closeEvent(event);
} }

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

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


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


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


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

protected: protected:
/** 仅在应用退出时放行窗口关闭事件 */ /** 仅在应用退出时放行窗口关闭事件 */
void closeEvent(QCloseEvent *event) override; void closeEvent(QCloseEvent *event) override;
@@ -50,4 +56,6 @@ private:
bool runtime_active_ = false; bool runtime_active_ = false;
/** 应用是否正在退出,决定关闭事件是否真正销毁窗口 */ /** 应用是否正在退出,决定关闭事件是否真正销毁窗口 */
bool application_exit_ = 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_window_.get());
runtime_monitor_widget_->setObjectName(QStringLiteral("runtimeMonitorWidget")); runtime_monitor_widget_->setObjectName(QStringLiteral("runtimeMonitorWidget"));
runtime_monitor_window_->setMonitorWidget(*runtime_monitor_widget_); 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, QObject::connect(runtime_monitor_widget_, &RuntimeMonitorWidget::exitRequested,
&parent_, [this] &parent_, [this]
{ {
@@ -121,36 +131,6 @@ void RuntimePanelController::configure()
status_reporter_(message, 5000); status_reporter_(message, 5000);
output_reporter_(QObject::tr("页面导航失败:%1").arg(message)); 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_ = new QTimer(&parent_);
runtime_refresh_timer_->setInterval(150); runtime_refresh_timer_->setInterval(150);
QObject::connect(runtime_refresh_timer_, &QTimer::timeout, QObject::connect(runtime_refresh_timer_, &QTimer::timeout,
@@ -173,18 +153,15 @@ void RuntimePanelController::configure()
Qt::QueuedConnection); 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) 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() const bool plc_connected = runtime_mode_service_.plcConnectionState()
== PlcConnectionState::Connected; == PlcConnectionState::Connected;
const SimulationState state = runtime_mode_service_.simulationState(); 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); || (offline && state == SimulationState::Running);
// 编辑画布始终只读,运行操作统一由运行监控中的 HMI 发出 // 编辑画布始终只读,运行操作统一由运行监控中的 HMI 发出
hmi_editor_widget_.setRuntimeWriteEnabled(false); hmi_editor_widget_.setRuntimeWriteEnabled(false);


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

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


/** 连接运行监控相关信号并完成初始配置 */ /** 连接运行监控相关信号并完成初始配置 */
void configure(); 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( void enterRuntime(
const std::string &page_id, const std::string &page_id,
@@ -143,7 +139,4 @@ private:
QTimer *runtime_refresh_timer_ = nullptr; QTimer *runtime_refresh_timer_ = nullptr;
/** 当前是否已经进入运行监控会话 */ /** 当前是否已经进入运行监控会话 */
bool runtime_session_active_ = false; 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"); "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() void testRegisterCommentService()
{ {
JsonProjectStorage storage(defaultProjectLimitSettings()); JsonProjectStorage storage(defaultProjectLimitSettings());
@@ -603,6 +634,7 @@ int main()
{"testStrictVersionAndRequiredFields", testStrictVersionAndRequiredFields}, {"testStrictVersionAndRequiredFields", testStrictVersionAndRequiredFields},
{"testInvalidGridAndConnectionsAreRejected", testInvalidGridAndConnectionsAreRejected}, {"testInvalidGridAndConnectionsAreRejected", testInvalidGridAndConnectionsAreRejected},
{"testProjectServiceStateAndConfiguredLimits", testProjectServiceStateAndConfiguredLimits}, {"testProjectServiceStateAndConfiguredLimits", testProjectServiceStateAndConfiguredLimits},
{"testHmiRuntimeExportStripsNonHmiProjectData", testHmiRuntimeExportStripsNonHmiProjectData},
{"testRegisterCommentService", testRegisterCommentService}, {"testRegisterCommentService", testRegisterCommentService},
}); });
} }

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

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


#include <functional> #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 const std::vector<RegisterAddress> &pollAddresses() const
{ {
return poll_addresses; return poll_addresses;
@@ -349,6 +360,69 @@ void testModeTransitions()
"a completed PLC poll cycle must trigger one new local trace scan"); "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() void testMonitorPollRangeRollback()
{ {
TestProjectStorage storage; TestProjectStorage storage;
@@ -483,6 +557,7 @@ int main()
{ {
return TestSupport::runTestSuite("runtime mode service tests", { return TestSupport::runTestSuite("runtime mode service tests", {
{"testModeTransitions", testModeTransitions}, {"testModeTransitions", testModeTransitions},
{"testHmiOnlyRuntimeSkipsLogicExecutor", testHmiOnlyRuntimeSkipsLogicExecutor},
{"testMonitorPollRangeRollback", testMonitorPollRangeRollback}, {"testMonitorPollRangeRollback", testMonitorPollRangeRollback},
{"testDisconnectedOutputBlocksOfflineAndOnlineRuntime", testDisconnectedOutputBlocksOfflineAndOnlineRuntime}, {"testDisconnectedOutputBlocksOfflineAndOnlineRuntime", testDisconnectedOutputBlocksOfflineAndOnlineRuntime},
}); });


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

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


HEADERS += \ HEADERS += \
$$DOMAIN_ALL_HEADERS \ $$DOMAIN_ALL_HEADERS \
@@ -18,4 +19,5 @@ HEADERS += \
$$SERVICE_LOGIC_HEADERS \ $$SERVICE_LOGIC_HEADERS \
$$SERVICE_OFFLINE_HEADERS \ $$SERVICE_OFFLINE_HEADERS \
$$SERVICE_RUNTIME_HEADERS \ $$SERVICE_RUNTIME_HEADERS \
../src/infrastructure/plc_register_repository.h \
$$TEST_SUPPORT_HEADERS $$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 \ project_management \
register_monitor \ register_monitor \
runtime_project_bundle \ runtime_project_bundle \
runtime_settings \
runtime_mode \ runtime_mode \
runtime_panel_controller \ runtime_panel_controller \
plc_connection_dialog \ plc_connection_dialog \
@@ -26,6 +27,7 @@ offline_simulation.file = offline_simulation_service_tests.pro
project_management.file = project_management_tests.pro project_management.file = project_management_tests.pro
register_monitor.file = register_monitor_service_tests.pro register_monitor.file = register_monitor_service_tests.pro
runtime_project_bundle.file = runtime_project_bundle_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_mode.file = runtime_mode_service_tests.pro
runtime_panel_controller.file = runtime_panel_controller_tests.pro runtime_panel_controller.file = runtime_panel_controller_tests.pro
plc_connection_dialog.file = plc_connection_dialog_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', 'project_management_tests',
'register_monitor_service_tests', 'register_monitor_service_tests',
'runtime_project_bundle_tests', 'runtime_project_bundle_tests',
'runtime_settings_tests',
'runtime_mode_service_tests', 'runtime_mode_service_tests',
'runtime_panel_controller_tests', 'runtime_panel_controller_tests',
'plc_connection_dialog_tests', 'plc_connection_dialog_tests',


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