diff --git a/app/integrated_platform.pro b/app/integrated_platform.pro index c873520..9ea3f76 100644 --- a/app/integrated_platform.pro +++ b/app/integrated_platform.pro @@ -61,6 +61,7 @@ SOURCES += \ src/infrastructure/plc_communication_service.cpp \ src/infrastructure/plc_discovery_service.cpp \ src/infrastructure/application_settings_loader.cpp \ + src/infrastructure/runtime_settings_loader.cpp \ src/infrastructure/runtime_project_bundle.cpp \ src/infrastructure/json_project_storage.cpp \ src/ui/hmi_editor_widget.cpp \ @@ -118,6 +119,7 @@ HEADERS += \ src/infrastructure/plc_communication_service.h \ src/infrastructure/plc_discovery_service.h \ src/infrastructure/application_settings_loader.h \ + src/infrastructure/runtime_settings_loader.h \ src/infrastructure/runtime_project_bundle.h \ src/infrastructure/json_project_storage.h \ src/ui/hmi_editor_widget.h \ diff --git a/app/src/domain/active_register_repository.cpp b/app/src/domain/active_register_repository.cpp index 0694033..f9a386f 100644 --- a/app/src/domain/active_register_repository.cpp +++ b/app/src/domain/active_register_repository.cpp @@ -1,44 +1,53 @@ #include "active_register_repository.h" +// 使用初始仓库作为当前寄存器访问目标 ActiveRegisterRepository::ActiveRegisterRepository(RegisterRepository &initial_repository) : repository_(&initial_repository) { } +// 切换后续读写请求的转发目标,不在仓库之间复制寄存器值 void ActiveRegisterRepository::use(RegisterRepository &repository) { // 只替换转发目标,不复制寄存器值;切换模式必须使用各自数据源 repository_ = &repository; } +// 将 M 位读取请求转发给当前活动仓库 BitReadResult ActiveRegisterRepository::readBit(const RegisterAddress &address) const { + // 代理本身不保存寄存器数据,结果由实际仓库负责提供 return repository_->readBit(address); } +// 将 M 位写入请求转发给当前活动仓库 RegisterWriteResult ActiveRegisterRepository::writeBit( const RegisterAddress &address, bool value) { return repository_->writeBit(address, value); } +// 将单个 D 字读取请求转发给当前活动仓库 WordReadResult ActiveRegisterRepository::readWord(const RegisterAddress &address) const { return repository_->readWord(address); } +// 将单个 D 字写入请求转发给当前活动仓库 RegisterWriteResult ActiveRegisterRepository::writeWord( const RegisterAddress &address, std::int16_t value) { return repository_->writeWord(address, value); } +// 将连续 D 字读取请求转发给当前活动仓库 WordsReadResult ActiveRegisterRepository::readWords( const RegisterAddress &address, int count) const { return repository_->readWords(address, count); } +// 将连续 D 字写入请求转发给当前活动仓库 RegisterWriteResult ActiveRegisterRepository::writeWords( const RegisterAddress &address, const std::vector &values) diff --git a/app/src/domain/active_register_repository.h b/app/src/domain/active_register_repository.h index 5c600c7..e04ffb1 100644 --- a/app/src/domain/active_register_repository.h +++ b/app/src/domain/active_register_repository.h @@ -10,17 +10,23 @@ public: // 切换活动仓库;运行模式服务负责保证切换时机和生命周期 void use(RegisterRepository &repository); + // 从当前活动仓库读取一个 M 区位值 BitReadResult readBit(const RegisterAddress &address) const override; + // 向当前活动仓库写入一个 M 区位值 RegisterWriteResult writeBit(const RegisterAddress &address, bool value) override; + // 从当前活动仓库读取一个 D 区字值 WordReadResult readWord(const RegisterAddress &address) const override; + // 向当前活动仓库写入一个 D 区字值 RegisterWriteResult writeWord( const RegisterAddress &address, std::int16_t value) override; + // 从当前活动仓库连续读取多个 D 区字值 WordsReadResult readWords( const RegisterAddress &address, int count) const override; + // 向当前活动仓库连续写入多个 D 区字值 RegisterWriteResult writeWords( const RegisterAddress &address, const std::vector &values) override; private: - RegisterRepository *repository_ = nullptr; + RegisterRepository *repository_ = nullptr; // 当前接收寄存器读写请求的仓库 }; diff --git a/app/src/domain/alarm_model.cpp b/app/src/domain/alarm_model.cpp index 1891ecc..11af3a7 100644 --- a/app/src/domain/alarm_model.cpp +++ b/app/src/domain/alarm_model.cpp @@ -4,8 +4,10 @@ namespace { +// 在调用方提供错误字符串时保存校验失败原因 void setError(std::string *error, const std::string &message) { + // 错误字符串是可选输出,不提供时只返回校验结果 if (error != nullptr) { *error = message; @@ -14,6 +16,7 @@ void setError(std::string *error, const std::string &message) } // namespace +// 校验报警定义的文本、地址和触发条件是否匹配 bool AlarmDefinition::validate(std::string *error) const { if (id.empty()) @@ -41,6 +44,7 @@ bool AlarmDefinition::validate(std::string *error) const setError(error, "报警定义使用了无效地址"); return false; } + // M ON/OFF 只能监控 M 区,避免把位条件用于 D 字地址 if (condition == AlarmCondition::MOn || condition == AlarmCondition::MOff) { @@ -51,6 +55,7 @@ bool AlarmDefinition::validate(std::string *error) const } return true; } + // D 高低限只能监控 D 区,阈值字段由运行时服务解释 if (condition == AlarmCondition::DHigh || condition == AlarmCondition::DLow) { diff --git a/app/src/domain/alarm_model.h b/app/src/domain/alarm_model.h index e4d3f8f..efed330 100644 --- a/app/src/domain/alarm_model.h +++ b/app/src/domain/alarm_model.h @@ -5,7 +5,7 @@ #include #include -// 报警触发条件:M 位为 1/0,或 D 字高于/低于阈值 +// 报警触发条件的类型 enum class AlarmCondition { MOn, // M 位为 1 时触发 @@ -14,7 +14,7 @@ enum class AlarmCondition DLow // D 寄存器值低于阈值时触发 }; -// 可保存的报警定义;运行时 AlarmService 根据此定义生成报警记录 +// 可保存的报警定义,运行时服务根据它生成报警记录 struct AlarmDefinition { std::string id; // 报警定义的唯一 ID @@ -23,6 +23,6 @@ struct AlarmDefinition std::int16_t threshold = 0; // D 地址比较时使用的阈值 std::string message; // 报警触发后显示的提示文字 - // 校验标识、地址区域、消息长度和条件之间的组合是否有效 + // 校验标识、地址区域、消息长度和触发条件是否匹配 bool validate(std::string *error = nullptr) const; }; diff --git a/app/src/domain/control_logic_model.cpp b/app/src/domain/control_logic_model.cpp index 818daee..e4c3a73 100644 --- a/app/src/domain/control_logic_model.cpp +++ b/app/src/domain/control_logic_model.cpp @@ -8,14 +8,17 @@ namespace { +// 在调用方提供错误字符串时保存校验失败原因 void setError(std::string *error, const std::string &message) { + // 错误字符串是可选输出,不提供时只返回校验结果 if (error != nullptr) { *error = message; } } +// 判断字符串是否为空或只包含空白字符 bool isBlank(const std::string &value) { return value.empty() @@ -24,12 +27,14 @@ bool isBlank(const std::string &value) [](unsigned char character) { return character <= 0x20U; }); } +// 判断文本中是否包含换行符 bool containsLineBreak(const std::string &value) { return value.find('\r') != std::string::npos || value.find('\n') != std::string::npos; } +// 检查所有带输出的梯形图行是否能通过导线和竖线连到左母线 bool validateOutputConnectivity( const ControlLogic &logic, std::string *error) @@ -47,6 +52,7 @@ bool validateOutputConnectivity( row_indices.emplace(logic.rungs[row].id, row); } + // power 表示当前列边界是否仍能从左母线获得电源 std::vector power(row_count, true); std::vector last_reachable_boundary(row_count, 0); for (int boundary = 0; @@ -55,6 +61,7 @@ bool validateOutputConnectivity( { std::vector parent(row_count); std::iota(parent.begin(), parent.end(), 0U); + // 每个列边界重新合并竖线连接的行,计算该边界的连通分量 const auto root = [&parent](std::size_t row) { while (parent[row] != row) @@ -130,6 +137,7 @@ bool validateOutputConnectivity( return true; } +// 校验字操作使用的地址必须是有效的 D 区地址 bool validateWordAddress( const RegisterAddress &address, const std::string &description, @@ -143,6 +151,7 @@ bool validateWordAddress( return true; } +// 校验普通触点配置 bool validateConfig(const ContactNodeConfig &config, std::string *error) { if (!config.address.isValid() || config.address.area() != RegisterArea::M) @@ -159,6 +168,7 @@ bool validateConfig(const ContactNodeConfig &config, std::string *error) return true; } +// 校验边沿触点配置 bool validateConfig(const EdgeContactNodeConfig &config, std::string *error) { if (!config.address.isValid() || config.address.area() != RegisterArea::M) @@ -174,6 +184,7 @@ bool validateConfig(const EdgeContactNodeConfig &config, std::string *error) return true; } +// 校验线圈配置 bool validateConfig(const CoilNodeConfig &config, std::string *error) { if (!config.address.isValid() || config.address.area() != RegisterArea::M) @@ -191,6 +202,7 @@ bool validateConfig(const CoilNodeConfig &config, std::string *error) return true; } +// 校验比较节点配置 bool validateConfig(const CompareNodeConfig &config, std::string *error) { if (!config.address.isValid() || config.address.area() != RegisterArea::D) @@ -211,12 +223,14 @@ bool validateConfig(const CompareNodeConfig &config, std::string *error) return true; } +// 校验 MOVE 节点配置 bool validateConfig(const MoveNodeConfig &config, std::string *error) { return config.source.validate(error) && validateWordAddress(config.destination, "MOVE 目标", error); } +// 校验算术节点配置 bool validateConfig(const ArithmeticNodeConfig &config, std::string *error) { if (config.operation != ArithmeticOperation::Add @@ -230,6 +244,7 @@ bool validateConfig(const ArithmeticNodeConfig &config, std::string *error) && validateWordAddress(config.destination, "算术指令目标", error); } +// 将有效地址追加到收集结果中 void appendValidAddress( std::vector *addresses, const RegisterAddress &address) @@ -240,6 +255,7 @@ void appendValidAddress( } } +// 把 ID 加入集合并检查当前作用域内是否重复 bool addUniqueId( const std::string &id, const std::string &description, @@ -256,6 +272,7 @@ bool addUniqueId( } // namespace +// 校验字操作数的来源类型和地址 bool WordOperand::validate(std::string *error) const { if (kind == WordOperandKind::Constant) @@ -270,9 +287,11 @@ bool WordOperand::validate(std::string *error) const return validateWordAddress(address, "字操作数", error); } +// 根据节点类型返回它的主地址:条件节点返回监控地址,输出节点返回目标地址 std::optional registerAddressForLogicNode( const LogicNodeConfig &config) { + // std::visit 根据 variant 当前保存的配置类型选择对应分支 return std::visit( [](const auto &value) -> std::optional { @@ -292,6 +311,7 @@ std::optional registerAddressForLogicNode( config); } +// 收集节点读写涉及的全部有效寄存器地址 void collectRegisterAddressesForLogicNode( const LogicNodeConfig &config, std::vector *addresses) @@ -300,6 +320,7 @@ void collectRegisterAddressesForLogicNode( { return; } + // if constexpr 让每种 variant 配置只编译属于自己的字段访问 std::visit( [addresses](const auto &value) { @@ -335,6 +356,7 @@ void collectRegisterAddressesForLogicNode( config); } +// 校验逻辑节点的 ID 和具体配置 bool LogicNode::validate(std::string *error) const { if (id.empty()) @@ -352,11 +374,13 @@ bool LogicNode::validate(std::string *error) const config); } +// 返回节点是否已完成运行配置 bool LogicNode::isConfigured() const { return configured; } +// 判断节点是否为条件节点 bool LogicNode::isCondition() const { return std::holds_alternative(config) @@ -364,6 +388,7 @@ bool LogicNode::isCondition() const || std::holds_alternative(config); } +// 判断节点是否为输出节点 bool LogicNode::isOutput() const { return std::holds_alternative(config) @@ -371,6 +396,7 @@ bool LogicNode::isOutput() const || std::holds_alternative(config); } +// 校验梯形图网格的 ID、类型和节点内容 bool LadderCell::validate(std::string *error) const { if (id.empty()) @@ -405,6 +431,7 @@ bool LadderCell::validate(std::string *error) const return true; } +// 校验竖线的关联行和列边界 bool VerticalConnection::validate(std::string *error) const { if (id.empty() || upperRungId.empty() || lowerRungId.empty()) @@ -433,11 +460,13 @@ bool VerticalConnection::validate(std::string *error) const return true; } +// 使用结构校验规则校验梯形图行 bool LadderRung::validate(std::string *error) const { return validateStructure(error); } +// 校验梯形图行的保存结构和 ID 唯一性 bool LadderRung::validateStructure(std::string *error) const { if (id.empty() || isBlank(name)) @@ -503,6 +532,7 @@ bool LadderRung::validateStructure(std::string *error) const return true; } +// 校验梯形图行是否具备运行所需的完整节点配置 bool LadderRung::validateForRunning(std::string *error) const { if (!validateStructure(error)) @@ -531,6 +561,7 @@ bool LadderRung::validateForRunning(std::string *error) const return true; } +// 向上查找指定行所在连续网络的首行 std::size_t ControlLogic::networkHeadIndex(std::size_t rung_index) const { if (rung_index >= rungs.size()) @@ -558,17 +589,20 @@ std::size_t ControlLogic::networkHeadIndex(std::size_t rung_index) const return rung_index; } +// 使用指定工程限制校验控制逻辑结构 bool ControlLogic::validate( const ProjectLimitSettings &limits, std::string *error) const { return validateStructure(limits, error); } +// 使用默认工程限制校验控制逻辑结构 bool ControlLogic::validateStructure(std::string *error) const { return validateStructure(defaultProjectLimitSettings(), error); } +// 校验控制逻辑的基本信息、行、竖线和 ID 唯一性 bool ControlLogic::validateStructure( const ProjectLimitSettings &limits, std::string *error) const { @@ -682,11 +716,13 @@ bool ControlLogic::validateStructure( return true; } +// 使用默认工程限制校验控制逻辑是否可以运行 bool ControlLogic::validateForRunning(std::string *error) const { return validateForRunning(defaultProjectLimitSettings(), error); } +// 校验控制逻辑的结构、节点配置和输出连通性 bool ControlLogic::validateForRunning( const ProjectLimitSettings &limits, std::string *error) const { @@ -704,6 +740,7 @@ bool ControlLogic::validateForRunning( return validateOutputConnectivity(*this, error); } +// 按 ID 查找梯形图行中的只读网格 const LadderCell *findLadderCell( const LadderRung &rung, const std::string &cell_id) { @@ -713,12 +750,15 @@ const LadderCell *findLadderCell( return found == rung.cells.cend() ? nullptr : &*found; } +// 按 ID 查找梯形图行中的可写网格 LadderCell *findLadderCell(LadderRung &rung, const std::string &cell_id) { + // 复用 const 版本,保证两种查找路径的判断规则一致 return const_cast(findLadderCell( static_cast(rung), cell_id)); } +// 按 ID 查找控制逻辑中的只读竖线 const VerticalConnection *findVerticalConnection( const ControlLogic &logic, const std::string &connection_id) { @@ -731,13 +771,16 @@ const VerticalConnection *findVerticalConnection( return found == logic.verticalConnections.cend() ? nullptr : &*found; } +// 按 ID 查找控制逻辑中的可写竖线 VerticalConnection *findVerticalConnection( ControlLogic &logic, const std::string &connection_id) { + // 这里先复用 const 查找,再移除 const 是因为传入对象本身可写 return const_cast(findVerticalConnection( static_cast(logic), connection_id)); } +// 收集梯形图行中的条件节点 void collectConditionNodes( const LadderRung &rung, std::vector *nodes) { @@ -754,6 +797,7 @@ void collectConditionNodes( } } +// 收集控制逻辑中的全部条件节点和输出节点 void collectLogicNodes( const ControlLogic &logic, std::vector *nodes) { diff --git a/app/src/domain/control_logic_model.h b/app/src/domain/control_logic_model.h index 4f53b4f..424d25f 100644 --- a/app/src/domain/control_logic_model.h +++ b/app/src/domain/control_logic_model.h @@ -11,91 +11,92 @@ enum class ContactMode { - NormallyOpen, - NormallyClosed + NormallyOpen, // 常开触点,M 为 1 时条件成立 + NormallyClosed // 常闭触点,M 为 0 时条件成立 }; enum class CoilMode { - Normal, - Set, - Reset + Normal, // 普通线圈,直接写入条件结果 + Set, // 条件成立时置位 M + Reset // 条件成立时复位 M }; enum class EdgeMode { - Rising, - Falling + Rising, // 检测 M 从 0 变为 1 + Falling // 检测 M 从 1 变为 0 }; enum class ComparisonOperator { - Equal, - NotEqual, - LessThan, - LessThanOrEqual, - GreaterThan, - GreaterThanOrEqual + Equal, // 等于 + NotEqual, // 不等于 + LessThan, // 小于 + LessThanOrEqual, // 小于或等于 + GreaterThan, // 大于 + GreaterThanOrEqual // 大于或等于 }; enum class WordOperandKind { - Constant, - Register + Constant, // 使用固定数值 + Register // 使用 D 区寄存器的值 }; struct WordOperand { - WordOperandKind kind = WordOperandKind::Constant; - RegisterAddress address{RegisterArea::D, 0}; - std::int16_t constant = 0; + WordOperandKind kind = WordOperandKind::Constant; // 操作数来源类型 + RegisterAddress address{RegisterArea::D, 0}; // 寄存器操作数使用的 D 地址 + std::int16_t constant = 0; // 常量操作数的数值 + // 校验操作数类型和寄存器地址是否有效 bool validate(std::string *error = nullptr) const; }; struct ContactNodeConfig { - RegisterAddress address{RegisterArea::M, 0}; - ContactMode mode = ContactMode::NormallyOpen; + RegisterAddress address{RegisterArea::M, 0}; // 触点监控的 M 地址 + ContactMode mode = ContactMode::NormallyOpen; // 触点的常开或常闭模式 }; struct EdgeContactNodeConfig { - RegisterAddress address{RegisterArea::M, 0}; - EdgeMode mode = EdgeMode::Rising; + RegisterAddress address{RegisterArea::M, 0}; // 边沿触点监控的 M 地址 + EdgeMode mode = EdgeMode::Rising; // 上升沿或下降沿模式 }; struct CoilNodeConfig { - RegisterAddress address{RegisterArea::M, 0}; - CoilMode mode = CoilMode::Normal; + RegisterAddress address{RegisterArea::M, 0}; // 线圈写入的 M 地址 + CoilMode mode = CoilMode::Normal; // 普通、置位或复位模式 }; struct CompareNodeConfig { - RegisterAddress address{RegisterArea::D, 0}; - ComparisonOperator comparison = ComparisonOperator::Equal; - std::int16_t value = 0; + RegisterAddress address{RegisterArea::D, 0}; // 参与比较的 D 地址 + ComparisonOperator comparison = ComparisonOperator::Equal; // 比较方式 + std::int16_t value = 0; // 与 D 值比较的常量 }; struct MoveNodeConfig { - WordOperand source; - RegisterAddress destination{RegisterArea::D, 0}; + WordOperand source; // MOVE 的源操作数 + RegisterAddress destination{RegisterArea::D, 0}; // MOVE 的目标 D 地址 }; enum class ArithmeticOperation { - Add, - Subtract + Add, // 加法 + Subtract // 减法 }; struct ArithmeticNodeConfig { - ArithmeticOperation operation = ArithmeticOperation::Add; - WordOperand left; - WordOperand right; - RegisterAddress destination{RegisterArea::D, 0}; + ArithmeticOperation operation = ArithmeticOperation::Add; // 算术运算类型 + WordOperand left; // 左操作数 + WordOperand right; // 右操作数 + RegisterAddress destination{RegisterArea::D, 0}; // 运算结果写入的 D 地址 }; using LogicNodeConfig = std::variant< @@ -106,98 +107,121 @@ using LogicNodeConfig = std::variant< MoveNodeConfig, ArithmeticNodeConfig>; +// 返回逻辑节点主要使用的地址 std::optional registerAddressForLogicNode( const LogicNodeConfig &config); +// 收集逻辑节点涉及的全部有效寄存器地址 void collectRegisterAddressesForLogicNode( const LogicNodeConfig &config, std::vector *addresses); struct LogicNode { - std::string id; - LogicNodeConfig config; - bool configured = true; + std::string id; // 逻辑节点的唯一 ID + LogicNodeConfig config; // 节点类型和具体参数 + bool configured = true; // 是否已完成运行所需的配置 + // 校验节点 ID 和节点参数 bool validate(std::string *error = nullptr) const; + // 返回节点是否已完成运行配置 bool isConfigured() const; + // 返回节点是否属于条件节点 bool isCondition() const; + // 返回节点是否属于输出节点 bool isOutput() const; }; enum class LadderCellKind { - Gap, - Wire, - Node + Gap, // 空白网格 + Wire, // 横向导线网格 + Node // 条件节点网格 }; // 条件区的一个固定网格,横线和空白与条件节点具有同等持久化地位 struct LadderCell { - std::string id; - LadderCellKind kind = LadderCellKind::Gap; - std::optional node; + std::string id; // 网格的唯一 ID + LadderCellKind kind = LadderCellKind::Gap; // 网格类型 + std::optional node; // 网格中的条件节点 + // 校验网格 ID、类型和节点内容是否匹配 bool validate(std::string *error = nullptr) const; }; // 两个相邻视觉行之间、指定列边界上的一段竖线 struct VerticalConnection { - std::string id; - std::string upperRungId; - std::string lowerRungId; - int columnBoundary = 0; + std::string id; // 竖线的唯一 ID + std::string upperRungId; // 竖线连接的上方行 ID + std::string lowerRungId; // 竖线连接的下方行 ID + int columnBoundary = 0; // 竖线所在的列边界 + // 校验竖线的关联行和列边界 bool validate(std::string *error = nullptr) const; }; // 连续梯形图的一条视觉行,不再是隔离的网络容器 struct LadderRung { - std::string id; - std::string name; - std::string comment; - std::optional output; - std::vector cells; + std::string id; // 梯形图行的唯一 ID + std::string name; // 梯形图行名称 + std::string comment; // 网络首行上的注释 + std::optional output; // 行末输出节点 + std::vector cells; // 固定数量的条件网格 + // 校验梯形图行的完整结构 bool validate(std::string *error = nullptr) const; + // 校验保存时需要满足的结构规则 bool validateStructure(std::string *error = nullptr) const; + // 校验运行前的结构和节点配置 bool validateForRunning(std::string *error = nullptr) const; }; // 一张连续梯形图,网络由横竖连接关系自然形成 struct ControlLogic { - std::string id; - std::string name; - std::vector rungs; - bool enabled = true; - std::vector verticalConnections; + std::string id; // 控制逻辑的唯一 ID + std::string name; // 控制逻辑名称 + std::vector rungs; // 按视觉顺序保存的梯形图行 + bool enabled = true; // 是否参与运行扫描 + std::vector verticalConnections; // 行之间的竖向连接 + // 返回指定行所在连续网络的首行下标 std::size_t networkHeadIndex(std::size_t rung_index) const; + // 使用指定工程限制校验控制逻辑 bool validate( const ProjectLimitSettings &limits, std::string *error = nullptr) const; + // 使用默认工程限制校验控制逻辑结构 bool validateStructure(std::string *error = nullptr) const; + // 使用指定工程限制校验控制逻辑结构 bool validateStructure( const ProjectLimitSettings &limits, std::string *error = nullptr) const; + // 使用默认工程限制校验运行前的控制逻辑 bool validateForRunning(std::string *error = nullptr) const; + // 使用指定工程限制校验运行前的控制逻辑 bool validateForRunning( const ProjectLimitSettings &limits, std::string *error = nullptr) const; }; +// 在梯形图行中查找指定 ID 的只读网格 const LadderCell *findLadderCell( const LadderRung &rung, const std::string &cell_id); +// 在梯形图行中查找指定 ID 的可写网格 LadderCell *findLadderCell( LadderRung &rung, const std::string &cell_id); +// 在控制逻辑中查找指定 ID 的只读竖线 const VerticalConnection *findVerticalConnection( const ControlLogic &logic, const std::string &connection_id); +// 在控制逻辑中查找指定 ID 的可写竖线 VerticalConnection *findVerticalConnection( ControlLogic &logic, const std::string &connection_id); +// 收集一行中的全部条件节点 void collectConditionNodes( const LadderRung &rung, std::vector *nodes); +// 收集控制逻辑中的全部条件节点和输出节点 void collectLogicNodes( const ControlLogic &logic, std::vector *nodes); diff --git a/app/src/domain/hmi_control_registry.cpp b/app/src/domain/hmi_control_registry.cpp index df8c6a4..d142a7c 100644 --- a/app/src/domain/hmi_control_registry.cpp +++ b/app/src/domain/hmi_control_registry.cpp @@ -6,6 +6,7 @@ namespace { +// 集中保存所有 HMI 控件的稳定类型名、默认外观和寄存器能力 constexpr std::array kControlDescriptors = { HmiControlDescriptor{HmiControlType::Button, "button", @@ -81,6 +82,7 @@ constexpr std::array kControlDescriptors = { false} }; +// 新增控件类型时必须同步加入描述表,否则在编译阶段直接报错 static_assert( kControlDescriptors.size() == static_cast(HmiControlType::Count), @@ -88,8 +90,10 @@ static_assert( } // namespace +// 按领域控件类型查找对应的公共描述 const HmiControlDescriptor *findHmiControlDescriptor(HmiControlType type) { + // find_if 返回第一个匹配项,找不到时返回 end 迭代器 const auto descriptor = std::find_if( kControlDescriptors.cbegin(), kControlDescriptors.cend(), @@ -100,9 +104,11 @@ const HmiControlDescriptor *findHmiControlDescriptor(HmiControlType type) return descriptor == kControlDescriptors.cend() ? nullptr : &*descriptor; } +// 按工程文件保存的稳定名称查找对应的公共描述 const HmiControlDescriptor *findHmiControlDescriptor( std::string_view storage_name) { + // string_view 只借用调用方字符串,不会额外复制名称 const auto descriptor = std::find_if( kControlDescriptors.cbegin(), kControlDescriptors.cend(), @@ -113,6 +119,7 @@ const HmiControlDescriptor *findHmiControlDescriptor( return descriptor == kControlDescriptors.cend() ? nullptr : &*descriptor; } +// 将控件绑定类型转换为具体的 M/D 寄存器区域 std::optional hmiBindingArea(HmiBindingKind binding_kind) { switch (binding_kind) @@ -129,6 +136,7 @@ std::optional hmiBindingArea(HmiBindingKind binding_kind) case HmiBindingKind::BitOrWord: default: { + // None 和 BitOrWord 不能在这里确定唯一的寄存器区域 return std::nullopt; } } diff --git a/app/src/domain/hmi_model.cpp b/app/src/domain/hmi_model.cpp index 959ae19..b7a61ea 100644 --- a/app/src/domain/hmi_model.cpp +++ b/app/src/domain/hmi_model.cpp @@ -11,20 +11,23 @@ namespace { -// 在提供错误字符串时写入校验失败原因 +// 在调用方提供错误字符串时保存校验失败原因 void setError(std::string *error, const std::string &message) { + // 错误字符串是可选输出,不提供时只返回校验结果 if (error != nullptr) { *error = message; } } +// 判断字符是否为十六进制数字 bool isHexDigit(char value) { return std::isxdigit(static_cast(value)) != 0; } +// 判断颜色是否符合 #RRGGBB 格式 bool isValidTextColor(const std::string &value) { return value.size() == 7U @@ -32,6 +35,7 @@ bool isValidTextColor(const std::string &value) && std::all_of(value.cbegin() + 1, value.cend(), isHexDigit); } +// 将完整字符串解析为整数,拒绝前后带有其他字符的输入 bool parseInteger(const std::string &value, int *result) { if (value.empty() || result == nullptr) @@ -40,15 +44,18 @@ bool parseInteger(const std::string &value, int *result) } const char *begin = value.data(); const char *end = begin + value.size(); + // from_chars 不受本地化设置影响,适合解析工程中保存的简单整数 const auto parsed = std::from_chars(begin, end, *result); return parsed.ec == std::errc{} && parsed.ptr == end; } +// 判断字符串是否为允许保存的布尔值 bool isBooleanValue(const std::string &value) { return value == "true" || value == "false"; } +// 判断文本是否为空或只包含空白字符 bool isBlank(const std::string &value) { return value.empty() @@ -60,6 +67,7 @@ bool isBlank(const std::string &value) }); } +// 校验状态文本内容的长度、换行和空白规则 bool validateStatusTextValue( const std::string &value, const char *name, std::string *error) { @@ -82,6 +90,7 @@ bool validateStatusTextValue( return true; } +// 校验 D 状态文本的区间数量、边界和连续性 bool validateStatusWordConfig( const HmiStatusWordTextConfig &config, RegisterDataType data_type, @@ -99,6 +108,7 @@ bool validateStatusWordConfig( setError(error, "D 状态文本的首个下限和最后一个上限必须为不限"); return false; } + // 整数类型的区间边界不能出现小数 const bool integer_type = data_type == RegisterDataType::Int16 || data_type == RegisterDataType::Int32; for (std::size_t index = 0; index < config.ranges.size(); ++index) @@ -144,6 +154,7 @@ bool validateStatusWordConfig( return true; } +// 校验 HMI 外观扩展属性的格式和取值范围 bool validateAppearanceProperty( const std::string &key, const std::string &value, std::string *error) { @@ -184,6 +195,7 @@ bool validateAppearanceProperty( return true; } +// 校验按钮启用条件的地址、类型、运算符和比较值 bool validateButtonEnableCondition( const HmiButtonEnableCondition &condition, std::string *error) { @@ -257,6 +269,7 @@ bool validateButtonEnableCondition( } // namespace +// 校验 HMI 控件的基本属性、绑定和专用配置 bool HmiControl::validate(std::string *error) const { const HmiControlDescriptor *descriptor = findHmiControlDescriptor(type); @@ -341,6 +354,7 @@ bool HmiControl::validate(std::string *error) const return false; } const bool status_text = type == HmiControlType::StatusText; + // 状态文本使用 D 区映射时也需要按数值控件检查地址跨度 const bool numeric = type == HmiControlType::NumericDisplay || type == HmiControlType::NumericInput || (status_text && statusText.has_value() @@ -447,6 +461,7 @@ bool HmiControl::validate(std::string *error) const return true; } +// 判断控件是否已经具备运行所需的配置 bool HmiControl::isConfigured() const { const HmiControlDescriptor *descriptor = findHmiControlDescriptor(type); @@ -485,6 +500,7 @@ bool HmiControl::isConfigured() const return binding_area.has_value() && binding->area() == *binding_area; } +// 使用指定工程限制校验页面和全部控件 bool HmiPage::validate( const ProjectLimitSettings &limits, std::string *error) const { @@ -549,6 +565,7 @@ bool HmiPage::validate( return true; } +// 校验页面结构,并确认所有控件都可以在运行态使用 bool HmiPage::validateForRunning( const ProjectLimitSettings &limits, std::string *error) const { diff --git a/app/src/domain/hmi_model.h b/app/src/domain/hmi_model.h index e7547dc..dcdddd4 100644 --- a/app/src/domain/hmi_model.h +++ b/app/src/domain/hmi_model.h @@ -20,10 +20,10 @@ namespace HmiAppearanceProperty { -inline constexpr const char kTextColor[] = "textColor"; -inline constexpr const char kFontSize[] = "fontSize"; -inline constexpr const char kFontBold[] = "fontBold"; -inline constexpr const char kFontItalic[] = "fontItalic"; +inline constexpr const char kTextColor[] = "textColor"; // 字体颜色属性名 +inline constexpr const char kFontSize[] = "fontSize"; // 字号属性名 +inline constexpr const char kFontBold[] = "fontBold"; // 粗体属性名 +inline constexpr const char kFontItalic[] = "fontItalic"; // 斜体属性名 } // namespace HmiAppearanceProperty @@ -34,13 +34,13 @@ inline constexpr const char kFontItalic[] = "fontItalic"; */ struct HmiRect { - static constexpr int kDefaultWidth = 80; - static constexpr int kDefaultHeight = 32; + static constexpr int kDefaultWidth = 80; // 新建控件的默认宽度 + static constexpr int kDefaultHeight = 32; // 新建控件的默认高度 - int x = 0; - int y = 0; - int width = kDefaultWidth; - int height = kDefaultHeight; + int x = 0; // 矩形左上角的横坐标 + int y = 0; // 矩形左上角的纵坐标 + int width = kDefaultWidth; // 控件宽度 + int height = kDefaultHeight; // 控件高度 }; /** @@ -75,32 +75,36 @@ enum class HmiButtonOperation /** @brief HMI 按钮 D 数值启用条件的比较方式 */ enum class HmiButtonConditionOperator { - Equal, - NotEqual, - LessThan, - LessThanOrEqual, - GreaterThan, - GreaterThanOrEqual + Equal, // 等于 + NotEqual, // 不等于 + LessThan, // 小于 + LessThanOrEqual, // 小于或等于 + GreaterThan, // 大于 + GreaterThanOrEqual // 大于或等于 }; +// 按钮使用 M 位判断是否允许操作的条件 struct HmiButtonBitEnableCondition { - RegisterAddress address{RegisterArea::M, 0}; - bool expected = false; + RegisterAddress address{RegisterArea::M, 0}; // 条件读取的 M 地址 + bool expected = false; // 允许按钮操作时期望的 M 值 + // 比较两个 M 位启用条件是否完全相同 bool operator==(const HmiButtonBitEnableCondition &other) const { return address == other.address && expected == other.expected; } }; +// 按钮使用 D 数值判断是否允许操作的条件 struct HmiButtonWordEnableCondition { - RegisterAddress address{RegisterArea::D, 0}; - RegisterDataType dataType = RegisterDataType::Int16; - HmiButtonConditionOperator operation = HmiButtonConditionOperator::Equal; - double value = 0.0; + RegisterAddress address{RegisterArea::D, 0}; // 条件读取的 D 地址 + RegisterDataType dataType = RegisterDataType::Int16; // D 值的数据类型 + HmiButtonConditionOperator operation = HmiButtonConditionOperator::Equal; // 比较方式 + double value = 0.0; // 参与比较的数值 + // 比较两个 D 数值启用条件是否完全相同 bool operator==(const HmiButtonWordEnableCondition &other) const { return address == other.address @@ -110,32 +114,38 @@ struct HmiButtonWordEnableCondition } }; +// 按钮启用条件可以使用 M 位或 D 数值 using HmiButtonEnableCondition = std::variant< HmiButtonBitEnableCondition, HmiButtonWordEnableCondition>; +// 页面跳转控件的目标配置 struct HmiPageJumpConfig { - std::string targetPageId; + std::string targetPageId; // 页面跳转目标的 ID }; +// M 状态文本的 OFF/ON 映射配置 struct HmiStatusBitTextConfig { - std::string offText; - std::string onText; + std::string offText; // M 为 0 时显示的文本 + std::string onText; // M 为 1 时显示的文本 + // 比较两个 M 状态文本配置是否完全相同 bool operator==(const HmiStatusBitTextConfig &other) const { return offText == other.offText && onText == other.onText; } }; +// D 状态文本使用的一个左闭右开数值区间 struct HmiStatusValueRange { - std::optional lowerBound; - std::optional upperBound; - std::string text; + std::optional lowerBound; // 区间下限,不填写表示负无穷 + std::optional upperBound; // 区间上限,不填写表示正无穷 + std::string text; // 命中区间时显示的文本 + // 比较两个状态值区间是否完全相同 bool operator==(const HmiStatusValueRange &other) const { return lowerBound == other.lowerBound @@ -144,16 +154,19 @@ struct HmiStatusValueRange } }; +// D 状态文本的连续区间配置 struct HmiStatusWordTextConfig { - std::vector ranges; + std::vector ranges; // 按顺序排列的连续数值区间 + // 比较两个 D 状态文本配置是否完全相同 bool operator==(const HmiStatusWordTextConfig &other) const { return ranges == other.ranges; } }; +// 状态文本可以使用 M 位映射或 D 数值区间映射 using HmiStatusTextConfig = std::variant< HmiStatusBitTextConfig, HmiStatusWordTextConfig>; @@ -165,17 +178,17 @@ using HmiStatusTextConfig = std::variant< */ struct HmiControl { - std::string id; - HmiControlType type = HmiControlType::Label; - HmiRect bounds; - std::string text; - std::optional binding; - RegisterDataType dataType = RegisterDataType::Int16; - std::map properties; - HmiButtonOperation buttonOperation = HmiButtonOperation::MomentaryOn; - std::optional buttonEnableCondition; - std::optional pageJump; - std::optional statusText; + std::string id; // 控件的唯一 ID + HmiControlType type = HmiControlType::Label; // 控件类型 + HmiRect bounds; // 控件在页面中的位置和尺寸 + std::string text; // 控件显示的文本 + std::optional binding; // 控件绑定的 M 或 D 地址 + RegisterDataType dataType = RegisterDataType::Int16; // 绑定 D 值的数据类型 + std::map properties; // 控件的扩展外观属性 + HmiButtonOperation buttonOperation = HmiButtonOperation::MomentaryOn; // 按钮写入操作 + std::optional buttonEnableCondition; // 按钮启用条件 + std::optional pageJump; // 页面跳转配置 + std::optional statusText; // 状态文本映射配置 /** * @brief 校验控件的标识、尺寸、扩展属性和寄存器绑定 @@ -185,6 +198,7 @@ struct HmiControl * 位控件必须绑定有效 M 地址,字控件必须绑定有效 D 地址 */ bool validate(std::string *error = nullptr) const; + // 返回控件是否已具备运行所需的绑定和专用配置 bool isConfigured() const; }; @@ -195,11 +209,11 @@ struct HmiControl */ struct HmiPage { - std::string id; - std::string name; - int width = ProjectLimits::kDefaultHmiPageWidth; - int height = ProjectLimits::kDefaultHmiPageHeight; - std::vector controls; + std::string id; // 页面唯一 ID + std::string name; // 页面名称 + int width = ProjectLimits::kDefaultHmiPageWidth; // 页面宽度 + int height = ProjectLimits::kDefaultHmiPageHeight; // 页面高度 + std::vector controls; // 页面上的控件集合 /** * @brief 校验页面尺寸、控件标识唯一性和全部控件配置 @@ -209,6 +223,7 @@ struct HmiPage bool validate( const ProjectLimitSettings &limits, std::string *error = nullptr) const; + // 校验页面结构并确认全部控件已完成运行配置 bool validateForRunning( const ProjectLimitSettings &limits, std::string *error = nullptr) const; diff --git a/app/src/domain/project_model.cpp b/app/src/domain/project_model.cpp index 2cf6b74..a74bacf 100644 --- a/app/src/domain/project_model.cpp +++ b/app/src/domain/project_model.cpp @@ -9,6 +9,7 @@ namespace { +// 在调用方提供错误字符串时保存工程校验失败原因 void setError(std::string *error, const std::string &message) { if (error != nullptr) @@ -18,6 +19,7 @@ void setError(std::string *error, const std::string &message) } // 通过相邻区间查找避免为不同领域对象重复实现标识唯一性校验 +// 检查一组对象中的 ID 是否重复 template bool containsDuplicateId(const std::vector &items) { @@ -38,6 +40,7 @@ bool containsDuplicateId(const std::vector &items) return false; } +// 检查一组对象中的名称是否重复 template bool containsDuplicateName(const std::vector &items) { @@ -58,6 +61,7 @@ bool containsDuplicateName(const std::vector &items) return false; } +// 判断文本是否为空或只包含空白字符 bool isBlank(const std::string &value) { return value.empty() @@ -66,6 +70,7 @@ bool isBlank(const std::string &value) [](unsigned char character) { return std::isspace(character) != 0; }); } +// 判断文本中是否包含换行符 bool containsLineBreak(const std::string &value) { return value.find('\r') != std::string::npos @@ -74,11 +79,12 @@ bool containsLineBreak(const std::string &value) struct HmiDataBinding { - RegisterAddress address{RegisterArea::D, 0}; - RegisterDataType type = RegisterDataType::Int16; - std::string controlId; + RegisterAddress address{RegisterArea::D, 0}; // HMI 控件占用的起始地址 + RegisterDataType type = RegisterDataType::Int16; // HMI 控件使用的数据类型 + std::string controlId; // 发生冲突时用于提示的控件 ID }; +// 判断 HMI 控件是否会占用 D 区数值地址 bool isNumericControl(const HmiControl &control) { return control.type == HmiControlType::NumericDisplay @@ -88,6 +94,7 @@ bool isNumericControl(const HmiControl &control) && control.binding->area() == RegisterArea::D); } +// 判断两个 HMI D 区绑定占用的地址范围是否相交 bool rangesIntersect(const HmiDataBinding &left, const HmiDataBinding &right) { const int leftEnd = left.address.index() @@ -99,12 +106,14 @@ bool rangesIntersect(const HmiDataBinding &left, const HmiDataBinding &right) && right.address.index() <= leftEnd; } +// 提取 MOVE 或算术指令写入的 D 区目标地址 bool isWordWriteDestination(const LogicNodeConfig &config, RegisterAddress *address) { if (address == nullptr) { return false; } + // variant 只对 MOVE 和算术配置写入目标地址,其他节点没有字写入目标 return std::visit( [address](const auto &value) { @@ -122,6 +131,7 @@ bool isWordWriteDestination(const LogicNodeConfig &config, RegisterAddress *addr } // namespace +// 校验工程级软元件注释的地址和文本内容 bool RegisterComment::validate(std::string *error) const { if (!address.isValid()) @@ -147,6 +157,7 @@ bool RegisterComment::validate(std::string *error) const return true; } +// 按 M/D 地址查找工程中保存的软元件注释 const RegisterComment *Project::findRegisterComment( const RegisterAddress &address) const { @@ -159,6 +170,7 @@ const RegisterComment *Project::findRegisterComment( return comment == registerComments.cend() ? nullptr : &*comment; } +// 校验工程元数据、数量边界、对象关系和寄存器占用冲突 bool Project::validate( const ProjectLimitSettings &limits, std::string *error) const { @@ -335,6 +347,7 @@ bool Project::validate( } } } + // 先收集 HMI 数值控件的 D 占用范围,后面统一检查重叠 std::vector hmi_bindings; for (const HmiPage &page : hmiPages) { @@ -372,6 +385,7 @@ bool Project::validate( } // 同时收集所有显式 M/D 引用,提前检查 PLC 轮询集合不会超过上限 + // 收集所有需要轮询的地址,最后排序并去重后检查总量 std::vector poll_addresses; for (const HmiPage &page : hmiPages) { @@ -435,6 +449,7 @@ bool Project::validate( } } } + // 先按区域和地址排序,再用 unique 删除相邻重复项 std::sort( poll_addresses.begin(), poll_addresses.end(), [](const RegisterAddress &left, const RegisterAddress &right) @@ -454,6 +469,7 @@ bool Project::validate( return true; } +// 在工程结构校验通过后,继续检查运行态对象是否全部配置完成 bool Project::validateForRunning( const ProjectLimitSettings &limits, std::string *error) const { diff --git a/app/src/domain/register_monitor_model.cpp b/app/src/domain/register_monitor_model.cpp index 732b732..715e8d7 100644 --- a/app/src/domain/register_monitor_model.cpp +++ b/app/src/domain/register_monitor_model.cpp @@ -2,8 +2,10 @@ #include +// 根据监控点列表生成按添加顺序排列的地址列表 const std::vector &RegisterMonitorModel::addresses() const { + // 地址列表是兼容视图,每次访问时从完整监控点列表重新生成 addresses_.clear(); addresses_.reserve(points_.size()); for (const MonitorPoint &point : points_) @@ -13,13 +15,16 @@ const std::vector &RegisterMonitorModel::addresses() const return addresses_; } +// 返回当前保存的完整监控点列表 const std::vector &RegisterMonitorModel::points() const { return points_; } +// 判断相同地址和数据类型的监控点是否已经存在 bool RegisterMonitorModel::contains(const MonitorPoint &point) const { + // 地址相同但数据类型不同,可以作为不同监控点同时存在 return std::find_if( points_.cbegin(), points_.cend(), [&point](const MonitorPoint &candidate) { @@ -28,8 +33,10 @@ bool RegisterMonitorModel::contains(const MonitorPoint &point) const }) != points_.cend(); } +// 校验并添加一个监控点 bool RegisterMonitorModel::add(const MonitorPoint &point) { + // 添加前一次性检查地址、类型、范围、重复项和数量上限 if (!point.address.isValid() || (point.address.area() == RegisterArea::M && point.dataType != RegisterDataType::Int16) @@ -44,6 +51,7 @@ bool RegisterMonitorModel::add(const MonitorPoint &point) return true; } +// 删除指定地址的第一个监控点 bool RegisterMonitorModel::remove(const RegisterAddress &address) { const auto position = std::find_if( @@ -59,6 +67,7 @@ bool RegisterMonitorModel::remove(const RegisterAddress &address) return true; } +// 删除地址和数据类型都匹配的监控点 bool RegisterMonitorModel::remove(const MonitorPoint &point) { const auto position = std::find_if( @@ -75,6 +84,7 @@ bool RegisterMonitorModel::remove(const MonitorPoint &point) return true; } +// 清空当前会话中的全部监控点和地址视图 void RegisterMonitorModel::clear() { addresses_.clear(); diff --git a/app/src/domain/register_monitor_model.h b/app/src/domain/register_monitor_model.h index 696bbdb..d4a90dd 100644 --- a/app/src/domain/register_monitor_model.h +++ b/app/src/domain/register_monitor_model.h @@ -10,26 +10,27 @@ // 监控读数的可用状态 enum class MonitorValueState { - Valid, - Unavailable, - CommunicationFault + Valid, // 已成功读取并可使用 + Unavailable, // 当前没有可用的寄存器值 + CommunicationFault // 读取时发生通信故障 }; // 一个监控行的地址、可用性和当前值 struct MonitorValue { - RegisterAddress address{RegisterArea::M, 0}; - RegisterDataType dataType = RegisterDataType::Int16; - int endAddress = 0; - MonitorValueState state = MonitorValueState::Unavailable; - bool bitValue = false; - RegisterNumericValue numericValue = std::int16_t{0}; + RegisterAddress address{RegisterArea::M, 0}; // 监控点起始地址 + RegisterDataType dataType = RegisterDataType::Int16; // 监控点的数据类型 + int endAddress = 0; // 多字监控点占用的结束地址 + MonitorValueState state = MonitorValueState::Unavailable; // 当前读数状态 + bool bitValue = false; // M 位监控点的当前值 + RegisterNumericValue numericValue = std::int16_t{0}; // D 数值监控点的当前值 }; +// 监控列表中的一个地址和数据类型组合 struct MonitorPoint { - RegisterAddress address{RegisterArea::M, 0}; - RegisterDataType dataType = RegisterDataType::Int16; + RegisterAddress address{RegisterArea::M, 0}; // 监控点起始地址 + RegisterDataType dataType = RegisterDataType::Int16; // 监控点的数据类型 }; // 运行期间由用户临时维护的监控地址列表,不写入工程文件 @@ -40,6 +41,7 @@ public: // 返回当前按添加顺序保存的地址 const std::vector &addresses() const; + // 返回当前按添加顺序保存的监控点 const std::vector &points() const; // 判断地址是否已经在监控列表中 bool contains(const MonitorPoint &point) const; @@ -53,6 +55,6 @@ public: void clear(); private: - mutable std::vector addresses_; - std::vector points_; + mutable std::vector addresses_; // 由监控点生成的地址兼容列表 + std::vector points_; // 当前会话中的监控点列表 }; diff --git a/app/src/domain/register_repository.h b/app/src/domain/register_repository.h index ad39ad5..4391620 100644 --- a/app/src/domain/register_repository.h +++ b/app/src/domain/register_repository.h @@ -39,11 +39,12 @@ struct RegisterWriteResult RegisterError error = RegisterError::Unavailable; // 写入失败原因 }; +// 连续 D 区字寄存器读取结果 struct WordsReadResult { - bool succeeded = false; - std::vector values; - RegisterError error = RegisterError::Unavailable; + bool succeeded = false; // 读取是否成功 + std::vector values; // 按地址顺序返回的字值 + RegisterError error = RegisterError::Unavailable; // 读取失败原因 }; // M/D 寄存器访问边界 @@ -62,9 +63,11 @@ public: // 写入 D 区 16 位字寄存器 virtual RegisterWriteResult writeWord( const RegisterAddress &address, std::int16_t value) = 0; + // 连续读取多个 D 区字;默认实现逐个调用 readWord virtual WordsReadResult readWords( const RegisterAddress &address, int count) const { + // 批量读取要求地址为 D 区且整个范围不能超出 0~4000 if (!address.isValid() || address.area() != RegisterArea::D || count < 1 || address.index() > RegisterAddress::kMaximumIndex - count + 1) @@ -76,6 +79,7 @@ public: values.reserve(static_cast(count)); for (int offset = 0; offset < count; ++offset) { + // 默认实现逐字读取,子类可按通信协议覆盖为一次批量读取 const WordReadResult result = readWord( RegisterAddress{RegisterArea::D, address.index() + offset}); if (!result.succeeded) @@ -86,11 +90,13 @@ public: } return {true, std::move(values), RegisterError::None}; } + // 连续写入多个 D 区字;默认实现逐个调用 writeWord virtual RegisterWriteResult writeWords( const RegisterAddress &address, const std::vector &values) { const int count = static_cast(values.size()); + // 空数组和越界范围都不执行任何写入,避免留下部分结果 if (!address.isValid() || address.area() != RegisterArea::D || count < 1 || address.index() > RegisterAddress::kMaximumIndex - count + 1) @@ -100,6 +106,7 @@ public: } for (int offset = 0; offset < count; ++offset) { + // 某一个字写入失败时立即返回该错误,不继续写后面的字 const RegisterWriteResult result = writeWord( RegisterAddress{RegisterArea::D, address.index() + offset}, values[static_cast(offset)]); diff --git a/app/src/domain/register_value_type.cpp b/app/src/domain/register_value_type.cpp index 5b44811..971c619 100644 --- a/app/src/domain/register_value_type.cpp +++ b/app/src/domain/register_value_type.cpp @@ -6,6 +6,7 @@ namespace { +// 各数据类型的固定描述,供校验、JSON 和界面统一使用 constexpr RegisterDataTypeDescriptor kInt16Descriptor{ "int16", "Int16", 1, 1, false}; constexpr RegisterDataTypeDescriptor kInt32Descriptor{ @@ -17,6 +18,7 @@ constexpr RegisterDataTypeDescriptor kFloat64Descriptor{ constexpr RegisterDataTypeDescriptor kInvalidDescriptor{ "", "Unknown", 0, 1, false}; +// 将固定长度数组转换为动态数组,便于统一返回编码结果 template std::vector toVector(const std::array &words) { @@ -25,6 +27,7 @@ std::vector toVector(const std::array &words) } // namespace +// 根据枚举值返回对应的数据类型描述 const RegisterDataTypeDescriptor ®isterDataTypeDescriptor(RegisterDataType type) { switch (type) @@ -42,16 +45,19 @@ const RegisterDataTypeDescriptor ®isterDataTypeDescriptor(RegisterDataType ty } } +// 判断枚举值是否对应受支持的数据类型 bool registerDataTypeIsSupported(RegisterDataType type) { return registerDataTypeDescriptor(type).wordCount > 0; } +// 返回数据类型用于 JSON 的稳定名称 const char *registerDataTypeName(RegisterDataType type) { return registerDataTypeDescriptor(type).jsonName; } +// 将 JSON 中的数据类型名称解析为枚举值 bool parseRegisterDataType(const std::string &text, RegisterDataType *type) { if (type == nullptr) @@ -81,11 +87,13 @@ bool parseRegisterDataType(const std::string &text, RegisterDataType *type) return false; } +// 返回数据类型需要连续读取的 D 字数量 int registerDataTypeWordCount(RegisterDataType type) { return registerDataTypeDescriptor(type).wordCount; } +// 检查数据类型的起始地址是否满足 D 区范围和对齐规则 bool registerDataTypeAddressIsValid( RegisterDataType type, const RegisterAddress &address) { @@ -97,14 +105,17 @@ bool registerDataTypeAddressIsValid( && address.index() % descriptor.addressAlignment == 0; } +// 按低字在前的顺序编码 32 位整数 std::array Int32Codec::encode(std::int32_t value) { + // memcpy 只复制位模式,避免直接把整数指针强转后读取造成未定义行为 std::uint32_t bits = 0; std::memcpy(&bits, &value, sizeof(bits)); return {static_cast(bits & 0xffffU), static_cast(bits >> 16U)}; } +// 按低字在前的顺序解码 32 位整数 std::int32_t Int32Codec::decode( std::int16_t low_word, std::int16_t high_word) { @@ -115,6 +126,7 @@ std::int32_t Int32Codec::decode( return value; } +// 按低字在前的顺序编码 32 位浮点数 std::array Float32Codec::encode(float value) { std::uint32_t bits = 0; @@ -123,6 +135,7 @@ std::array Float32Codec::encode(float value) static_cast(bits >> 16U)}; } +// 按低字在前的顺序解码 32 位浮点数 std::optional Float32Codec::decode( std::int16_t low_word, std::int16_t high_word) { @@ -133,6 +146,7 @@ std::optional Float32Codec::decode( return std::isfinite(value) ? std::optional(value) : std::nullopt; } +// 按低字在前的顺序编码 64 位浮点数 std::array Float64Codec::encode(double value) { std::uint64_t bits = 0; @@ -144,6 +158,7 @@ std::array Float64Codec::encode(double value) static_cast(bits >> 48U)}; } +// 按低字在前的顺序解码 64 位浮点数 std::optional Float64Codec::decode( const std::array &words) { @@ -156,6 +171,7 @@ std::optional Float64Codec::decode( return std::isfinite(value) ? std::optional(value) : std::nullopt; } +// 根据数据类型和字数解码寄存器数值 std::optional decodeRegisterNumericValue( RegisterDataType type, const std::vector &words) { @@ -163,6 +179,7 @@ std::optional decodeRegisterNumericValue( { return std::nullopt; } + // 不同类型的字数不同,先拒绝长度不匹配的数据 switch (type) { case RegisterDataType::Int16: @@ -189,6 +206,7 @@ std::optional decodeRegisterNumericValue( } } +// 根据数据类型和数值范围编码寄存器字序列 std::optional> encodeRegisterNumericValue( RegisterDataType type, double value) { @@ -196,6 +214,7 @@ std::optional> encodeRegisterNumericValue( { return std::nullopt; } + // 整数类型需要先检查整数性和目标类型的上下限 switch (type) { case RegisterDataType::Int16: diff --git a/app/src/domain/register_value_type.h b/app/src/domain/register_value_type.h index 386e25c..4d065b7 100644 --- a/app/src/domain/register_value_type.h +++ b/app/src/domain/register_value_type.h @@ -11,62 +11,80 @@ enum class RegisterDataType { - Int16, - Int32, - Float32, - Float64 + Int16, // 一个 16 位有符号整数,占 1 个 D 字 + Int32, // 一个 32 位有符号整数,占 2 个 D 字 + Float32, // 一个 32 位浮点数,占 2 个 D 字 + Float64 // 一个 64 位浮点数,占 4 个 D 字 }; +// 描述数据类型对应的工程名称、显示名称、字数和地址要求 struct RegisterDataTypeDescriptor { - const char *jsonName = "int16"; - const char *displayName = "Int16"; - int wordCount = 1; - int addressAlignment = 1; - bool floatingPoint = false; + const char *jsonName = "int16"; // JSON 中保存的稳定名称 + const char *displayName = "Int16"; // 界面显示名称 + int wordCount = 1; // 占用的连续 D 字数量 + int addressAlignment = 1; // 起始地址必须满足的对齐倍数 + bool floatingPoint = false; // 是否为浮点类型 }; +// 支持在不同数值类型之间保存寄存器解码结果 using RegisterNumericValue = std::variant; +// 描述一段连续的 D 区字地址范围 struct RegisterWordRange { - RegisterAddress start{RegisterArea::D, 0}; - int wordCount = 1; + RegisterAddress start{RegisterArea::D, 0}; // 连续范围的起始地址 + int wordCount = 1; // 连续范围包含的 D 字数量 + // 比较两个 D 区字范围是否完全相同 bool operator==(const RegisterWordRange &other) const { return start == other.start && wordCount == other.wordCount; } }; +// 返回数据类型的静态描述,非法类型返回 Unknown 描述 const RegisterDataTypeDescriptor ®isterDataTypeDescriptor(RegisterDataType type); +// 判断数据类型是否受当前实现支持 bool registerDataTypeIsSupported(RegisterDataType type); +// 返回数据类型对应的 JSON 稳定名称 const char *registerDataTypeName(RegisterDataType type); +// 将 JSON 文本解析为数据类型 bool parseRegisterDataType(const std::string &text, RegisterDataType *type); +// 返回数据类型占用的 D 字数量 int registerDataTypeWordCount(RegisterDataType type); +// 校验数据类型和起始地址是否满足区域、范围及对齐要求 bool registerDataTypeAddressIsValid( RegisterDataType type, const RegisterAddress &address); struct Int32Codec { + // 将 32 位整数拆成低字在前的两个 D 字 static std::array encode(std::int32_t value); + // 将低字在前的两个 D 字还原为 32 位整数 static std::int32_t decode(std::int16_t low_word, std::int16_t high_word); }; struct Float32Codec { + // 将 32 位浮点数拆成低字在前的两个 D 字 static std::array encode(float value); + // 解码浮点数,遇到 NaN 或 Inf 时返回空值 static std::optional decode(std::int16_t low_word, std::int16_t high_word); }; struct Float64Codec { + // 将 64 位浮点数拆成低字在前的四个 D 字 static std::array encode(double value); + // 解码四个 D 字,遇到 NaN 或 Inf 时返回空值 static std::optional decode( const std::array &words); }; +// 按指定数据类型把 D 字序列解码为数值 std::optional decodeRegisterNumericValue( RegisterDataType type, const std::vector &words); +// 按指定数据类型把数值编码为 D 字序列 std::optional> encodeRegisterNumericValue( RegisterDataType type, double value); diff --git a/app/src/domain/runtime_state.cpp b/app/src/domain/runtime_state.cpp index 6083131..e5b88f8 100644 --- a/app/src/domain/runtime_state.cpp +++ b/app/src/domain/runtime_state.cpp @@ -1,15 +1,18 @@ #include "runtime_state.h" +// 返回状态机当前保存的运行模式 ApplicationMode RuntimeState::mode() const { return mode_; } +// 根据当前运行模式返回对应的能力策略 ModePolicy RuntimeState::policy() const { return policyForMode(mode_); } +// 尝试返回编辑态,编辑态是两个运行态之间的共同中转状态 ModeTransitionResult RuntimeState::enterEditing() { // 两种运行态都必须先回到编辑态,作为后续模式切换的唯一中转点 @@ -21,6 +24,7 @@ ModeTransitionResult RuntimeState::enterEditing() return {true, ModeTransitionError::None, {}}; } +// 尝试从编辑态进入离线仿真运行态 ModeTransitionResult RuntimeState::enterOfflineRunning() { if (mode_ == ApplicationMode::OfflineRunning) @@ -35,6 +39,7 @@ ModeTransitionResult RuntimeState::enterOfflineRunning() return {true, ModeTransitionError::None, {}}; } +// 尝试从编辑态进入真机联机运行态,并检查 PLC 初次读取状态 ModeTransitionResult RuntimeState::enterOnlineRunning(bool initial_plc_read_completed) { if (mode_ == ApplicationMode::OnlineRunning) diff --git a/app/src/domain/runtime_state.h b/app/src/domain/runtime_state.h index 33ecbdb..eb07d71 100644 --- a/app/src/domain/runtime_state.h +++ b/app/src/domain/runtime_state.h @@ -65,6 +65,7 @@ constexpr ModePolicy policyForMode(ApplicationMode mode) } default: { + // 非法枚举值没有任何运行能力,调用方应将其视为无效状态 return {}; } } diff --git a/app/src/domain/virtual_register_repository.cpp b/app/src/domain/virtual_register_repository.cpp index ecdddb6..7bcfbc5 100644 --- a/app/src/domain/virtual_register_repository.cpp +++ b/app/src/domain/virtual_register_repository.cpp @@ -2,12 +2,14 @@ #include +// 构造虚拟仓库并将所有寄存器初始化为默认值 VirtualRegisterRepository::VirtualRegisterRepository() { // 构造时清空离线寄存器,确保初始值确定 clear(); } +// 校验地址后读取指定的 M 区位值 BitReadResult VirtualRegisterRepository::readBit(const RegisterAddress &address) const { // 先校验地址和区域,再访问对应的内存槽位 @@ -22,6 +24,7 @@ BitReadResult VirtualRegisterRepository::readBit(const RegisterAddress &address) return {true, bits_[static_cast(address.index())], RegisterError::None}; } +// 校验地址后更新指定的 M 区位值 RegisterWriteResult VirtualRegisterRepository::writeBit( const RegisterAddress &address, bool value) { @@ -38,6 +41,7 @@ RegisterWriteResult VirtualRegisterRepository::writeBit( return {true, RegisterError::None}; } +// 校验地址后读取指定的 D 区字值 WordReadResult VirtualRegisterRepository::readWord(const RegisterAddress &address) const { // 字操作只允许访问 D 区 @@ -52,6 +56,7 @@ WordReadResult VirtualRegisterRepository::readWord(const RegisterAddress &addres return {true, words_[static_cast(address.index())], RegisterError::None}; } +// 校验地址后更新指定的 D 区字值 RegisterWriteResult VirtualRegisterRepository::writeWord( const RegisterAddress &address, std::int16_t value) { @@ -68,6 +73,7 @@ RegisterWriteResult VirtualRegisterRepository::writeWord( return {true, RegisterError::None}; } +// 校验连续范围后读取多个 D 区字值 WordsReadResult VirtualRegisterRepository::readWords( const RegisterAddress &address, int count) const { @@ -78,10 +84,12 @@ WordsReadResult VirtualRegisterRepository::readWords( return {false, {}, address.isValid() && address.area() != RegisterArea::D ? RegisterError::AreaMismatch : RegisterError::InvalidAddress}; } + // 地址范围已校验,因此可以直接把地址作为数组偏移量 const auto begin = words_.cbegin() + address.index(); return {true, std::vector(begin, begin + count), RegisterError::None}; } +// 校验连续范围后更新多个 D 区字值 RegisterWriteResult VirtualRegisterRepository::writeWords( const RegisterAddress &address, const std::vector &values) @@ -94,11 +102,13 @@ RegisterWriteResult VirtualRegisterRepository::writeWords( return {false, address.isValid() && address.area() != RegisterArea::D ? RegisterError::AreaMismatch : RegisterError::InvalidAddress}; } + // 范围已校验,copy 可以一次完成连续字值写入 std::copy( values.cbegin(), values.cend(), words_.begin() + address.index()); return {true, RegisterError::None}; } +// 将 M 区和 D 区全部恢复为离线默认值 void VirtualRegisterRepository::clear() { // 离线运行复位时同时清空 M 区和 D 区 @@ -106,8 +116,10 @@ void VirtualRegisterRepository::clear() words_.fill(0); } +// 复制另一份虚拟仓库的完整 M/D 快照 void VirtualRegisterRepository::copyFrom(const VirtualRegisterRepository &source) { + // array 赋值会复制全部槽位,不会让两个仓库共享存储 bits_ = source.bits_; words_ = source.words_; } diff --git a/app/src/domain/virtual_register_repository.h b/app/src/domain/virtual_register_repository.h index fc35719..24f3814 100644 --- a/app/src/domain/virtual_register_repository.h +++ b/app/src/domain/virtual_register_repository.h @@ -20,8 +20,10 @@ public: // 更新内存中的 D 区字值 RegisterWriteResult writeWord( const RegisterAddress &address, std::int16_t value) override; + // 连续读取内存中的多个 D 区字值 WordsReadResult readWords( const RegisterAddress &address, int count) const override; + // 连续更新内存中的多个 D 区字值 RegisterWriteResult writeWords( const RegisterAddress &address, const std::vector &values) override; @@ -32,6 +34,7 @@ public: void copyFrom(const VirtualRegisterRepository &source); private: + // M/D 数组均按 0~4000 的寄存器地址直接建立下标 static constexpr std::size_t kRegisterCount = static_cast(RegisterAddress::kMaximumIndex + 1); std::array bits_{}; // M 区位寄存器内存 diff --git a/app/src/infrastructure/application_settings_loader.cpp b/app/src/infrastructure/application_settings_loader.cpp index 2e63cc7..b875e44 100644 --- a/app/src/infrastructure/application_settings_loader.cpp +++ b/app/src/infrastructure/application_settings_loader.cpp @@ -16,20 +16,24 @@ namespace { +// 当前应用配置文件格式版本 constexpr int kCurrentConfigurationVersion = 1; +// 保存字段值及其所在行号,便于生成准确的错误提示 struct ParsedEntry { - QString value; - int line = 0; + QString value; // 配置字段值 + int line = 0; // 字段在文件中的行号 }; +// 将 Qt 字符串转换为 UTF-8 标准字符串 std::string toUtf8(const QString &value) { const QByteArray bytes = value.toUtf8(); return std::string(bytes.constData(), static_cast(bytes.size())); } +// 生成首次运行时写入的默认 INI 内容 QString defaultFileContents() { return QStringLiteral( @@ -57,6 +61,7 @@ QString defaultFileContents() "StopBits=1\n"); } +// 创建统一的配置失败结果,表示整份配置回退到代码默认值 ApplicationSettingsLoadResult failureResult( const QString &file_path, const QString &reason) { @@ -73,6 +78,7 @@ ApplicationSettingsLoadResult failureResult( return result; } +// 判断整数是否在允许值列表中 bool containsValue(std::initializer_list values, int value) { for (int candidate : values) @@ -85,6 +91,7 @@ bool containsValue(std::initializer_list values, int value) return false; } +// 读取并校验一个整数配置项,缺失时按 required 决定报错或使用默认值 bool readInteger( const QMap &entries, const QString &path, @@ -96,6 +103,7 @@ bool readInteger( QStringList *errors, bool required = false) { + // constFind 只读取已解析的字段,不会修改配置表 const auto entry = entries.constFind(path); if (entry == entries.cend()) { @@ -130,12 +138,14 @@ bool readInteger( return true; } +// 为枚举式整数配置生成统一的允许值错误提示 void addUnsupportedValueError( const QMap &entries, const QString &path, const QString &allowed, QStringList *errors) { + // 该函数只在字段已经存在时调用,因此可以直接取得对应记录 const ParsedEntry &entry = entries[path]; errors->append( QStringLiteral("第 %1 行字段 %2 的值“%3”无效,只允许 %4") @@ -145,12 +155,14 @@ void addUnsupportedValueError( } // namespace +// 返回程序目录下的应用配置路径 QString ApplicationSettingsLoader::defaultFilePath() { return QDir(QCoreApplication::applicationDirPath()).filePath( QStringLiteral("config/application.ini")); } +// 创建或读取配置文件,并把解析结果转换为应用设置 ApplicationSettingsLoadResult ApplicationSettingsLoader::load( const QString &file_path) { @@ -165,6 +177,7 @@ ApplicationSettingsLoadResult ApplicationSettingsLoader::load( .arg(QDir::toNativeSeparators(directory))); } + // QSaveFile 通过临时文件提交,避免程序中断时留下半份配置 QSaveFile file(file_path); if (!file.open(QIODevice::WriteOnly)) { @@ -191,6 +204,7 @@ ApplicationSettingsLoadResult ApplicationSettingsLoader::load( return result; } + // 已存在的配置按只读方式读取,任何读错误都会让整份配置回退 QFile file(file_path); if (!file.open(QIODevice::ReadOnly)) { @@ -202,6 +216,7 @@ ApplicationSettingsLoadResult ApplicationSettingsLoader::load( return failureResult(file_path, file.errorString()); } + // 使用 UTF-8 解码并检查非法字节,避免中文配置被错误解析 QTextCodec::ConverterState converter_state; const QString text = QTextCodec::codecForName("UTF-8")->toUnicode( data.constData(), data.size(), &converter_state); @@ -210,6 +225,7 @@ ApplicationSettingsLoadResult ApplicationSettingsLoader::load( return failureResult(file_path, QStringLiteral("配置文件不是有效的 UTF-8 文本")); } + // 未知字段只提示不报错,便于未来版本增加字段后旧程序仍可读取 const QSet known_fields{ QStringLiteral("Config.Version"), QStringLiteral("ProjectLimits.MaxHmiPages"), @@ -237,6 +253,7 @@ ApplicationSettingsLoadResult ApplicationSettingsLoader::load( { normalized_text.remove(0, 1); } + // 按行解析 INI,保留行号用于定位格式和取值错误 const QStringList lines = normalized_text.split(QLatin1Char('\n')); for (int index = 0; index < lines.size(); ++index) { @@ -247,6 +264,7 @@ ApplicationSettingsLoadResult ApplicationSettingsLoader::load( } const QString trimmed = line.trimmed(); const int line_number = index + 1; + // 空行、分号注释和井号注释不参与字段解析 if (trimmed.isEmpty() || trimmed.startsWith(QLatin1Char(';')) || trimmed.startsWith(QLatin1Char('#'))) { @@ -307,6 +325,7 @@ ApplicationSettingsLoadResult ApplicationSettingsLoader::load( } } + // 先写入候选设置,只有全部字段无严重错误时才作为最终结果返回 ApplicationSettings candidate; qint64 parsed = 0; if (readInteger( @@ -477,6 +496,7 @@ ApplicationSettingsLoadResult ApplicationSettingsLoader::load( candidate.plcDefaults.stopBits = static_cast(parsed); } + // 任一严重错误都会使整份配置失效,避免混用部分外部值和默认值 if (!errors.isEmpty()) { ApplicationSettingsLoadResult result; diff --git a/app/src/infrastructure/application_settings_loader.h b/app/src/infrastructure/application_settings_loader.h index af8d155..c0e8c8a 100644 --- a/app/src/infrastructure/application_settings_loader.h +++ b/app/src/infrastructure/application_settings_loader.h @@ -8,6 +8,8 @@ class ApplicationSettingsLoader final { public: + // 返回程序目录下应用配置文件的默认路径 static QString defaultFilePath(); + // 读取指定 INI 文件,不存在时创建默认文件 static ApplicationSettingsLoadResult load(const QString &file_path); }; diff --git a/app/src/infrastructure/json_project_storage.cpp b/app/src/infrastructure/json_project_storage.cpp index b45b87d..cb32a1f 100644 --- a/app/src/infrastructure/json_project_storage.cpp +++ b/app/src/infrastructure/json_project_storage.cpp @@ -272,12 +272,14 @@ bool parseAddress( return true; } +// 将字操作数类型转换为工程文件中的稳定字符串 QString wordOperandKindName(WordOperandKind kind) { return kind == WordOperandKind::Constant ? QStringLiteral("constant") : QStringLiteral("register"); } +// 将字操作数序列化为常量或寄存器对象 QJsonObject serializeWordOperand(const WordOperand &operand) { QJsonObject object; @@ -293,6 +295,7 @@ QJsonObject serializeWordOperand(const WordOperand &operand) return object; } +// 从 JSON 对象解析字操作数并校验其地址类型 bool parseWordOperand( const QJsonObject &object, const std::string &context, @@ -374,6 +377,7 @@ bool parseHmiControlType( return true; } +// 将按钮操作转换为工程文件中的稳定字符串 QString hmiButtonOperationName(HmiButtonOperation operation) { switch (operation) @@ -398,6 +402,7 @@ QString hmiButtonOperationName(HmiButtonOperation operation) } } +// 将工程文件中的按钮操作字符串解析为枚举值 bool parseHmiButtonOperation( const std::string &value, HmiButtonOperation *operation, ParseState *state) { @@ -426,6 +431,7 @@ bool parseHmiButtonOperation( return true; } +// 将按钮启用条件比较方式转换为稳定字符串 QString hmiButtonConditionOperatorName(HmiButtonConditionOperator operation) { switch (operation) @@ -447,6 +453,7 @@ QString hmiButtonConditionOperatorName(HmiButtonConditionOperator operation) } } +// 将按钮启用条件比较方式字符串解析为枚举值 bool parseHmiButtonConditionOperator( const std::string &value, HmiButtonConditionOperator *operation, @@ -485,6 +492,7 @@ bool parseHmiButtonConditionOperator( return true; } +// 将按钮 M/D 启用条件序列化为 JSON 对象 QJsonObject serializeHmiButtonEnableCondition( const HmiButtonEnableCondition &condition) { @@ -510,6 +518,7 @@ QJsonObject serializeHmiButtonEnableCondition( return object; } +// 从 JSON 对象解析按钮 M/D 启用条件 bool parseHmiButtonEnableCondition( const QJsonObject &object, const std::string &context, @@ -576,6 +585,7 @@ bool parseHmiButtonEnableCondition( return true; } +// 将报警触发条件转换为工程文件中的稳定字符串 QString alarmConditionName(AlarmCondition condition) { switch (condition) @@ -603,6 +613,7 @@ QString alarmConditionName(AlarmCondition condition) } } +// 将报警触发条件字符串解析为枚举值 bool parseAlarmCondition( const std::string &value, AlarmCondition *condition, ParseState *state) { @@ -631,6 +642,7 @@ bool parseAlarmCondition( return true; } +// 将报警定义序列化为 JSON 对象 QJsonObject serializeAlarmDefinition(const AlarmDefinition &definition) { QJsonObject object; @@ -643,6 +655,7 @@ QJsonObject serializeAlarmDefinition(const AlarmDefinition &definition) return object; } +// 从 JSON 对象解析报警定义 bool parseAlarmDefinition( const QJsonObject &object, const std::string &context, @@ -678,6 +691,7 @@ bool parseAlarmDefinition( return true; } +// 将工程级软元件注释序列化为 JSON 对象 QJsonObject serializeRegisterComment(const RegisterComment &comment) { QJsonObject object; @@ -686,6 +700,7 @@ QJsonObject serializeRegisterComment(const RegisterComment &comment) return object; } +// 从 JSON 对象解析工程级软元件注释 bool parseRegisterComment( const QJsonObject &object, const std::string &context, @@ -798,11 +813,13 @@ bool parseProperties( return true; } +// 将有值或无值的数值转换为 JSON 数值或 null QJsonValue serializeOptionalNumber(const std::optional &value) { return value.has_value() ? QJsonValue(*value) : QJsonValue(QJsonValue::Null); } +// 将 HMI 状态文本映射配置序列化为 JSON 对象 QJsonObject serializeStatusTextConfig(const HmiStatusTextConfig &config) { QJsonObject object; @@ -830,6 +847,7 @@ QJsonObject serializeStatusTextConfig(const HmiStatusTextConfig &config) return object; } +// 读取可为空的有限数值,用于状态文本区间边界 bool readNullableFiniteNumber( const QJsonObject &object, const char *field, @@ -857,6 +875,7 @@ bool readNullableFiniteNumber( return true; } +// 从 JSON 对象解析 M 或 D 状态文本映射配置 bool parseStatusTextConfig( const QJsonObject &object, const std::string &context, @@ -1221,6 +1240,7 @@ bool parseContactMode( return true; } +// 将边沿模式转换为工程文件中的稳定字符串 QString edgeModeName(EdgeMode mode) { return mode == EdgeMode::Rising @@ -1228,6 +1248,7 @@ QString edgeModeName(EdgeMode mode) : QStringLiteral("falling"); } +// 将边沿模式字符串解析为枚举值 bool parseEdgeMode( const std::string &value, EdgeMode *mode, ParseState *state) { @@ -1381,6 +1402,7 @@ QJsonObject serializeNodeConfig(const ContactNodeConfig &config) return object; } +// 将边沿触点配置序列化为 JSON 对象 QJsonObject serializeNodeConfig(const EdgeContactNodeConfig &config) { QJsonObject object; @@ -1411,6 +1433,7 @@ QJsonObject serializeNodeConfig(const CompareNodeConfig &config) return object; } +// 将 MOVE 节点配置序列化为 JSON 对象 QJsonObject serializeNodeConfig(const MoveNodeConfig &config) { QJsonObject object; @@ -1420,12 +1443,14 @@ QJsonObject serializeNodeConfig(const MoveNodeConfig &config) return object; } +// 将算术操作转换为工程文件中的稳定字符串 QString arithmeticOperationName(ArithmeticOperation operation) { return operation == ArithmeticOperation::Add ? QStringLiteral("add") : QStringLiteral("subtract"); } +// 将算术节点配置序列化为 JSON 对象 QJsonObject serializeNodeConfig(const ArithmeticNodeConfig &config) { QJsonObject object; @@ -1635,6 +1660,7 @@ bool parseLogicNode( return true; } +// 将梯形图行、条件网格和输出节点序列化为 JSON 对象 QJsonObject serializeLadderRung(const LadderRung &rung) { QJsonObject object; @@ -1666,6 +1692,7 @@ QJsonObject serializeLadderRung(const LadderRung &rung) return object; } +// 从 JSON 对象解析梯形图行及其网格 bool parseLadderRung( const QJsonObject &object, const std::string &context, @@ -1758,6 +1785,7 @@ bool parseLadderRung( return true; } +// 将控制逻辑及其行、竖线连接序列化为 JSON 对象 QJsonObject serializeControlLogic(const ControlLogic &logic) { QJsonArray rungs; @@ -1784,6 +1812,7 @@ QJsonObject serializeControlLogic(const ControlLogic &logic) return object; } +// 从 JSON 对象解析控制逻辑及其行、竖线连接 bool parseControlLogic( const QJsonObject &object, const std::string &context, @@ -2050,6 +2079,7 @@ ProjectSaveResult saveFailure( } // namespace +// 保存工程级数量限制,供后续保存和加载校验使用 JsonProjectStorage::JsonProjectStorage( const ProjectLimitSettings &project_limits) : project_limits_(project_limits) diff --git a/app/src/infrastructure/json_project_storage.h b/app/src/infrastructure/json_project_storage.h index 3339f17..b443fe8 100644 --- a/app/src/infrastructure/json_project_storage.h +++ b/app/src/infrastructure/json_project_storage.h @@ -8,10 +8,12 @@ class JsonProjectStorage final : public ProjectStorage public: explicit JsonProjectStorage( const ProjectLimitSettings &project_limits); + // 校验并以 JSON 4.0 格式原子保存工程 ProjectSaveResult save( const Project &project, const std::string &file_path) override; + // 从 JSON 4.0 文件读取并校验工程 ProjectLoadResult load(const std::string &file_path) override; private: - const ProjectLimitSettings &project_limits_; + const ProjectLimitSettings &project_limits_; // 工程校验和数组解析使用的数量限制 }; diff --git a/app/src/infrastructure/plc_communication_error_classifier.cpp b/app/src/infrastructure/plc_communication_error_classifier.cpp index 93da1e0..c55ea22 100644 --- a/app/src/infrastructure/plc_communication_error_classifier.cpp +++ b/app/src/infrastructure/plc_communication_error_classifier.cpp @@ -2,6 +2,7 @@ namespace { +// 生成适合显示的串口名称,空名称时使用通用提示 QString displayPortName(const QString &port_name) { const QString trimmed = port_name.trimmed(); @@ -10,12 +11,14 @@ QString displayPortName(const QString &port_name) } // namespace +// 将 Qt Modbus 错误码转换为项目统一的通信故障类型和提示文本 PlcCommunicationFailure classifyPlcCommunicationError( QModbusDevice::Error error, const PlcCommunicationErrorContext &context) { const QString port_name = displayPortName(context.portName); + // Qt 只提供底层错误码,连接上下文用于进一步区分打开失败和断线 switch (error) { case QModbusDevice::ConnectionError: @@ -36,6 +39,7 @@ PlcCommunicationFailure classifyPlcCommunicationError( } case QModbusDevice::TimeoutError: { + // 从未收到有效响应通常表示站号、串口参数或接线不匹配 if (!context.receivedValidResponse) { return { diff --git a/app/src/infrastructure/plc_communication_error_classifier.h b/app/src/infrastructure/plc_communication_error_classifier.h index af0ab27..1527adb 100644 --- a/app/src/infrastructure/plc_communication_error_classifier.h +++ b/app/src/infrastructure/plc_communication_error_classifier.h @@ -8,19 +8,20 @@ // Qt 错误码之外的通信现场信息,用于区分“未打开串口”和“已断线” struct PlcCommunicationErrorContext { - QString portName; - bool serialSessionOpened = false; - bool receivedValidResponse = false; + QString portName; // 当前使用的串口名称 + bool serialSessionOpened = false; // 串口会话是否已经成功打开 + bool receivedValidResponse = false; // 是否曾收到 PLC 的有效响应 }; // 分类后的用户可读通信故障 struct PlcCommunicationFailure { - PlcCommunicationError type = PlcCommunicationError::Unknown; - QString message; + PlcCommunicationError type = PlcCommunicationError::Unknown; // 统一的错误类型 + QString message; // 面向用户显示的错误说明 }; // 将 Qt Modbus 错误和上下文转换为项目统一错误类型 +// 通过串口状态和 PLC 响应状态区分具体通信故障 PlcCommunicationFailure classifyPlcCommunicationError( QModbusDevice::Error error, const PlcCommunicationErrorContext &context); diff --git a/app/src/infrastructure/plc_communication_service.cpp b/app/src/infrastructure/plc_communication_service.cpp index 460604b..b6bc574 100644 --- a/app/src/infrastructure/plc_communication_service.cpp +++ b/app/src/infrastructure/plc_communication_service.cpp @@ -46,6 +46,7 @@ bool isReadingState(PlcConnectionState state) || state == PlcConnectionState::Recovering; } +// 排序并去重轮询地址,同时检查地址总量上限 bool normalizePollAddresses( const std::vector &addresses, std::vector *normalized, @@ -78,6 +79,7 @@ bool normalizePollAddresses( return true; } +// 判断读块边界是否落在多字范围内部,避免拆开一次多字读取 bool boundaryBelongsToMultiWordRange( RegisterArea area, int boundary, @@ -93,6 +95,7 @@ bool boundaryBelongsToMultiWordRange( }); } +// 将地址集合拆成符合 Modbus 单次读取上限的连续读块 bool calculatePollBlocks( const std::vector &addresses, const std::vector &multi_word_ranges, @@ -106,6 +109,7 @@ bool calculatePollBlocks( return true; } + // 先处理同一区域内连续地址,再按单次读取上限切分 std::size_t run_start = 0U; while (run_start < addresses.size()) { @@ -144,6 +148,7 @@ bool calculatePollBlocks( } // namespace +// 创建 Qt Modbus 主站,绑定异步信号和 PLC 缓存写入回调 PlcCommunicationService::PlcCommunicationService( PlcRegisterRepository &repository, QObject *parent) @@ -221,8 +226,10 @@ PlcCommunicationService::PlcCommunicationService( }); } +// 释放通信服务持有的 Qt 资源 PlcCommunicationService::~PlcCommunicationService() = default; +// 校验串口配置并启动一次新的 PLC 异步连接 PlcCommunicationResult PlcCommunicationService::connectDevice( const PlcSerialConfiguration &configuration) { @@ -284,6 +291,7 @@ PlcCommunicationResult PlcCommunicationService::connectDevice( return {true, {}}; } +// 停止定时器、关闭串口并清除本次连接的缓存有效状态 void PlcCommunicationService::disconnectDevice() { // 关闭串口、清除缓存有效标记,防止断开后继续使用旧值 @@ -295,12 +303,14 @@ void PlcCommunicationService::disconnectDevice() setState(PlcConnectionState::Disconnected); } +// 使用默认的单字范围设置轮询地址 PlcCommunicationResult PlcCommunicationService::setPollAddresses( const std::vector &addresses) { return setPollAddresses(addresses, {}); } +// 校验并应用地址及多字范围,必要时等待当前异步读请求结束 PlcCommunicationResult PlcCommunicationService::setPollAddresses( const std::vector &addresses, const std::vector &multi_word_ranges) @@ -381,26 +391,31 @@ PlcCommunicationResult PlcCommunicationService::setPollAddresses( return {true, {}}; } +// 返回当前 PLC 连接状态 PlcConnectionState PlcCommunicationService::state() const { return state_; } +// 返回当前连接是否已完成全部轮询块的首次读取 bool PlcCommunicationService::initialReadCompleted() const { return initial_read_completed_; } +// 返回最近一次通信错误的统一类型 PlcCommunicationError PlcCommunicationService::lastErrorType() const { return last_error_type_; } +// 返回最近一次通信错误的可读文字 const std::string &PlcCommunicationService::lastError() const { return last_error_; } +// 保存外部回调,在对应通信事件发生时通知调用方 void PlcCommunicationService::setCallbacks( std::function state_changed, std::function initial_read_changed, @@ -416,6 +431,7 @@ void PlcCommunicationService::setCallbacks( error_reported_callback_ = std::move(error_reported); // 发生通信错误时通知外部 } +// 根据当前地址集合重新生成连续轮询读块 void PlcCommunicationService::rebuildPollBlocks() { std::vector addresses = poll_addresses_; @@ -457,6 +473,7 @@ void PlcCommunicationService::rebuildPollBlocks() } } +// 在没有读请求占用时正式切换到新的轮询地址集合 void PlcCommunicationService::applyPollAddresses( const std::vector &addresses) { @@ -472,6 +489,7 @@ void PlcCommunicationService::applyPollAddresses( } } +// 发送一个异步轮询读请求,并由完成回调推进后续读块 void PlcCommunicationService::pollNextBlock() { if (!isReadingState(state_) @@ -552,6 +570,7 @@ void PlcCommunicationService::pollNextBlock() }); } +// 对可恢复通信故障发送一个轻量读取请求探测链路 void PlcCommunicationService::probeRecovery() { // 只在可恢复的超时故障中探测;串口拔出等故障不会反复探测 @@ -621,6 +640,7 @@ void PlcCommunicationService::probeRecovery() }); } +// 处理恢复探测错误,并决定立即上报或继续等待探测 void PlcCommunicationService::handleRecoveryProbeFailure(QModbusDevice::Error error) { if (state_ != PlcConnectionState::Faulted @@ -640,6 +660,7 @@ void PlcCommunicationService::handleRecoveryProbeFailure(QModbusDevice::Error er recovery_timer_.start(kRecoveryProbeIntervalMs); } +// 探测成功后恢复轮询,并重新开始完整首读 void PlcCommunicationService::restoreCommunication() { // 探测成功只说明链路恢复,仍要重新读取全部读块才能恢复真机资格 @@ -652,6 +673,7 @@ void PlcCommunicationService::restoreCommunication() pollNextBlock(); } +// 处理轮询读回复并更新 PLC 缓存 void PlcCommunicationService::handleReadFinished( QModbusReply *reply, PlcPollBlock block) { @@ -688,6 +710,7 @@ void PlcCommunicationService::handleReadFinished( } } +// 发送一个 M 位异步写请求,不直接修改缓存 RegisterWriteResult PlcCommunicationService::sendBitWrite( const RegisterAddress &address, bool value) { @@ -733,6 +756,7 @@ RegisterWriteResult PlcCommunicationService::sendBitWrite( return {true, RegisterError::None}; } +// 发送一个 D 字异步写请求,不直接修改缓存 RegisterWriteResult PlcCommunicationService::sendWordWrite( const RegisterAddress &address, std::int16_t value) { @@ -777,6 +801,7 @@ RegisterWriteResult PlcCommunicationService::sendWordWrite( return {true, RegisterError::None}; } +// 发送一组连续 D 字异步写请求,不直接修改缓存 RegisterWriteResult PlcCommunicationService::sendWordsWrite( const RegisterAddress &address, const std::vector &values) @@ -829,6 +854,7 @@ RegisterWriteResult PlcCommunicationService::sendWordsWrite( return {true, RegisterError::None}; } +// 更新首读完成标记,并在需要时通知外部观察者 void PlcCommunicationService::updateInitialReadCompleted( bool completed, bool force_notification) { @@ -845,6 +871,7 @@ void PlcCommunicationService::updateInitialReadCompleted( } } +// 处理未主动断开时发生的串口连接丢失 void PlcCommunicationService::handleUnexpectedDisconnect() { // 这里表示设备原本连上过,后来串口意外断开 @@ -857,6 +884,7 @@ void PlcCommunicationService::handleUnexpectedDisconnect() .arg(port_name)}); } +// 过滤重复错误后,分类并保存 Qt Modbus 错误 void PlcCommunicationService::handleModbusError(QModbusDevice::Error error) { // 主动断开、已处理的故障和旧回复错误都不重复上报 @@ -876,6 +904,7 @@ void PlcCommunicationService::handleModbusError(QModbusDevice::Error error) setError(classifyPlcCommunicationError(error, context)); } +// 使旧异步请求失效并关闭当前串口会话 void PlcCommunicationService::closeSerialSession() { // 先递增代次并停止定时器,再断开串口;旧异步回调会因此失效 @@ -897,6 +926,7 @@ void PlcCommunicationService::closeSerialSession() disconnecting_ = false; } +// 修改连接状态并同步发出 Qt 信号和外部回调 void PlcCommunicationService::setState(PlcConnectionState state) { if (state_ == state) @@ -912,6 +942,7 @@ void PlcCommunicationService::setState(PlcConnectionState state) } } +// 保存通信故障、撤销首读资格并通知外部观察者 void PlcCommunicationService::setError(const PlcCommunicationFailure &failure) { // 统一保存错误、停止正常轮询、撤销首读资格并通知 UI diff --git a/app/src/infrastructure/plc_communication_service.h b/app/src/infrastructure/plc_communication_service.h index 420f494..b3c3221 100644 --- a/app/src/infrastructure/plc_communication_service.h +++ b/app/src/infrastructure/plc_communication_service.h @@ -19,9 +19,9 @@ struct PlcCommunicationFailure; struct PlcPollBlock { - RegisterArea area = RegisterArea::M; - int startAddress = 0; - int count = 1; + RegisterArea area = RegisterArea::M; // 读块所在的寄存器区域 + int startAddress = 0; // 读块的起始原始地址 + int count = 1; // 本次连续读取的字或位数量 }; // 基于 Qt Modbus RTU 的异步 PLC 通信实现 @@ -46,6 +46,7 @@ public: // 设置需要周期性读取的 M/D 地址集合 PlcCommunicationResult setPollAddresses( const std::vector &addresses) override; + // 设置轮询地址及需要保持完整边界的多字数据范围 PlcCommunicationResult setPollAddresses( const std::vector &addresses, const std::vector &multi_word_ranges) override; diff --git a/app/src/infrastructure/plc_discovery_service.cpp b/app/src/infrastructure/plc_discovery_service.cpp index 3a8aa4c..856a7f5 100644 --- a/app/src/infrastructure/plc_discovery_service.cpp +++ b/app/src/infrastructure/plc_discovery_service.cpp @@ -19,6 +19,7 @@ constexpr int kDiscoveryResponseTimeoutMs = 200; // 正式连接可能刚被配置窗口断开,先留出时间让操作系统完全释放串口句柄 constexpr int kDiscoveryStartDelayMs = 100; +// 将 Qt 字符串转换为项目内部使用的 UTF-8 标准字符串 std::string toUtf8(const QString &value) { const QByteArray bytes = value.toUtf8(); @@ -27,6 +28,7 @@ std::string toUtf8(const QString &value) } // namespace +// 创建独立的 Modbus 探测主站并绑定状态变化处理 PlcDiscoveryService::PlcDiscoveryService(QObject *parent) : QObject(parent), master_(std::make_unique()) @@ -36,6 +38,7 @@ PlcDiscoveryService::PlcDiscoveryService(QObject *parent) this, &PlcDiscoveryService::handleDeviceStateChanged); } +// 清理回调并释放仍可能占用串口的探测主站 PlcDiscoveryService::~PlcDiscoveryService() { progress_changed_callback_ = {}; @@ -46,6 +49,7 @@ PlcDiscoveryService::~PlcDiscoveryService() } } +// 收集可用串口并启动候选参数的异步逐项探测 PlcCommunicationResult PlcDiscoveryService::startDiscovery( const PlcSerialConfiguration &preferred) { @@ -84,6 +88,7 @@ PlcCommunicationResult PlcDiscoveryService::startDiscovery( cancel_requested_ = false; found_pending_ = false; ++attempt_generation_; + // 延迟启动,给刚关闭的正式通信服务留出释放串口句柄的时间 QTimer::singleShot( kDiscoveryStartDelayMs, this, @@ -91,6 +96,7 @@ PlcCommunicationResult PlcDiscoveryService::startDiscovery( return {true, {}}; } +// 标记取消并释放当前候选串口 void PlcDiscoveryService::cancelDiscovery() { if (!discovering_ || cancel_requested_) @@ -103,11 +109,13 @@ void PlcDiscoveryService::cancelDiscovery() disconnectCurrent(false); } +// 返回搜索状态 bool PlcDiscoveryService::isDiscovering() const { return discovering_; } +// 保存搜索进度和结束回调 void PlcDiscoveryService::setCallbacks( std::function progress_changed, std::function discovery_finished) @@ -116,6 +124,7 @@ void PlcDiscoveryService::setCallbacks( discovery_finished_callback_ = std::move(discovery_finished); } +// 应用当前候选串口参数并发起异步连接 void PlcDiscoveryService::tryCurrentCandidate() { if (!discovering_) @@ -173,6 +182,7 @@ void PlcDiscoveryService::tryCurrentCandidate() } } +// 连接成功后读取 D0,使用只读请求判断 PLC 是否响应 void PlcDiscoveryService::sendProbe() { if (!discovering_ || cancel_requested_ @@ -203,6 +213,7 @@ void PlcDiscoveryService::sendProbe() }); } +// 处理 D0 探测回复,并确认它是否属于当前搜索尝试 void PlcDiscoveryService::handleProbeFinished( QModbusReply *reply, std::uint64_t attempt_generation) @@ -215,6 +226,7 @@ void PlcDiscoveryService::handleProbeFinished( { pending_reply_.clear(); } + // 正常响应和 Modbus 异常响应都说明串口链路上确实有设备回应 const bool valid_response = reply->error() == QModbusDevice::NoError || reply->rawResult().isException(); reply->deleteLater(); @@ -224,6 +236,7 @@ void PlcDiscoveryService::handleProbeFinished( } } +// 释放当前候选串口,并记录是否已探测到 PLC void PlcDiscoveryService::disconnectCurrent(bool found) { if (!discovering_) @@ -240,6 +253,7 @@ void PlcDiscoveryService::disconnectCurrent(bool found) master_->disconnectDevice(); } +// 串口释放后决定报告成功、取消,或继续下一个候选 void PlcDiscoveryService::continueAfterDisconnect() { if (!discovering_ || phase_ != Phase::Disconnecting) @@ -263,6 +277,7 @@ void PlcDiscoveryService::continueAfterDisconnect() QTimer::singleShot(0, this, &PlcDiscoveryService::tryCurrentCandidate); } +// 清理搜索状态并报告最终搜索结果 void PlcDiscoveryService::finishDiscovery(const PlcDiscoveryOutcome &outcome) { discovering_ = false; @@ -279,6 +294,7 @@ void PlcDiscoveryService::finishDiscovery(const PlcDiscoveryOutcome &outcome) current_candidate_ = 0; } +// 根据 Qt Modbus 状态变化推进连接、探测和断开流程 void PlcDiscoveryService::handleDeviceStateChanged(QModbusDevice::State state) { if (!discovering_) diff --git a/app/src/infrastructure/plc_discovery_service.h b/app/src/infrastructure/plc_discovery_service.h index c4ffe55..0769bfc 100644 --- a/app/src/infrastructure/plc_discovery_service.h +++ b/app/src/infrastructure/plc_discovery_service.h @@ -22,10 +22,14 @@ public: explicit PlcDiscoveryService(QObject *parent = nullptr); ~PlcDiscoveryService() override; + // 枚举串口和候选参数,并异步逐项探测 PLC PlcCommunicationResult startDiscovery( const PlcSerialConfiguration &preferred) override; + // 取消当前自动搜索并释放探测串口 void cancelDiscovery() override; + // 返回当前是否正在自动搜索 bool isDiscovering() const override; + // 设置搜索进度和最终结果回调 void setCallbacks( std::function progress_changed, std::function discovery_finished) override; @@ -33,10 +37,10 @@ public: private: enum class Phase { - Idle, - Connecting, - Probing, - Disconnecting + Idle, // 等待尝试候选参数 + Connecting, // 正在异步打开候选串口 + Probing, // 已打开串口,正在读取 D0 + Disconnecting // 正在释放当前候选串口 }; // 尝试当前候选参数并等待串口异步打开 @@ -54,15 +58,15 @@ private: // 处理 Qt Modbus 主站连接状态变化 void handleDeviceStateChanged(QModbusDevice::State state); - std::unique_ptr master_; - std::vector candidates_; - std::size_t current_candidate_ = 0; - QPointer 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 progress_changed_callback_; - std::function discovery_finished_callback_; + std::unique_ptr master_; // 独立的探测主站对象 + std::vector candidates_; // 待依次尝试的串口参数 + std::size_t current_candidate_ = 0; // 当前候选参数下标 + QPointer 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 progress_changed_callback_; // 搜索进度回调 + std::function discovery_finished_callback_; // 搜索结束回调 }; diff --git a/app/src/infrastructure/runtime_settings_loader.cpp b/app/src/infrastructure/runtime_settings_loader.cpp new file mode 100644 index 0000000..0ac2540 --- /dev/null +++ b/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 +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +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(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 values, int value) +{ + for (const int candidate : values) + { + if (candidate == value) + { + return true; + } + } + return false; +} + +bool readInteger( + const QMap &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 &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 known_fields{ + QStringLiteral("PlcDefaults.PortName"), + QStringLiteral("PlcDefaults.ServerAddress"), + QStringLiteral("PlcDefaults.BaudRate"), + QStringLiteral("PlcDefaults.DataBits"), + QStringLiteral("PlcDefaults.Parity"), + QStringLiteral("PlcDefaults.StopBits")}; + QMap 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, ¬ices)) + { + result.plcDefaults.serverAddress = static_cast(parsed); + } + int baud_rate = result.plcDefaults.baudRate; + if (readInteger(entries, QStringLiteral("PlcDefaults.BaudRate"), 9600, 115200, + result.plcDefaults.baudRate, &parsed, &errors, ¬ices)) + { + baud_rate = static_cast(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, ¬ices)) + { + result.plcDefaults.dataBits = static_cast(parsed); + } + int parity = result.plcDefaults.parity; + if (readInteger(entries, QStringLiteral("PlcDefaults.Parity"), 0, 3, + result.plcDefaults.parity, &parsed, &errors, ¬ices)) + { + parity = static_cast(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, ¬ices)) + { + result.plcDefaults.stopBits = static_cast(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 ¬ice : notices) + { + addMessage(&result, QStringLiteral("运行版串口配置提示:") + notice); + } + return result; + } + addMessage( + &result, + QStringLiteral("运行版串口配置:已加载 %1") + .arg(QDir::toNativeSeparators(file_path))); + for (const QString ¬ice : 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; +} diff --git a/app/src/infrastructure/runtime_settings_loader.h b/app/src/infrastructure/runtime_settings_loader.h new file mode 100644 index 0000000..018f270 --- /dev/null +++ b/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 + +#include +#include + +/** HMI 专用运行版的串口配置加载结果 */ +struct RuntimeSettingsLoadResult +{ + PlcSerialConfiguration plcDefaults; + std::vector 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); +}; diff --git a/app/src/main.cpp b/app/src/main.cpp index 4db6289..e909433 100644 --- a/app/src/main.cpp +++ b/app/src/main.cpp @@ -14,6 +14,7 @@ #include "infrastructure/json_project_storage.h" #include "infrastructure/application_settings_loader.h" +#include "infrastructure/runtime_settings_loader.h" #include "infrastructure/plc_communication_service.h" #include "infrastructure/plc_discovery_service.h" #include "infrastructure/plc_register_repository.h" @@ -61,9 +62,21 @@ int main(int argc, char *argv[]) == RuntimeProjectBundleStatus::Loaded; const bool user_runtime_mode = bundled_runtime_project; - const ApplicationSettingsLoadResult application_settings = - ApplicationSettingsLoader::load( + ApplicationSettingsLoadResult application_settings; + if (user_runtime_mode) + { + const RuntimeSettingsLoadResult runtime_settings = + RuntimeSettingsLoader::load(RuntimeSettingsLoader::defaultFilePath()); + application_settings.settings.plcDefaults = runtime_settings.plcDefaults; + application_settings.messages = runtime_settings.messages; + application_settings.warningRequired = runtime_settings.warningRequired; + application_settings.warningMessage = runtime_settings.warningMessage; + } + else + { + application_settings = ApplicationSettingsLoader::load( ApplicationSettingsLoader::defaultFilePath()); + } // 负责把工程保存到 JSON 文件,也负责从 JSON 文件读取工程 JsonProjectStorage project_storage(application_settings.settings.projectLimits); @@ -152,6 +165,7 @@ int main(int argc, char *argv[]) active_register_repository, virtual_register_repository, plc_register_repository); + runtime_mode_service.setHmiOnlyRuntime(user_runtime_mode); // 创建主窗口,并把界面操作需要的各项服务交给它使用 MainWindow main_window( diff --git a/app/src/services/project_service.cpp b/app/src/services/project_service.cpp index 561dc1a..bb43986 100644 --- a/app/src/services/project_service.cpp +++ b/app/src/services/project_service.cpp @@ -152,6 +152,37 @@ ProjectOperationResult ProjectService::exportAs(const std::string &file_path) co return {true, ProjectServiceError::None, ProjectStorageError::None, {}}; } +ProjectOperationResult ProjectService::exportHmiRuntimeAs( + const std::string &file_path) const +{ + if (isBlank(file_path)) + { + return {false, + ProjectServiceError::FilePathRequired, + ProjectStorageError::None, + "必须指定工程文件路径"}; + } + + // 用户运行版只保存 HMI、报警和页面导航需要的工程内容 + Project runtime_project = project_; + runtime_project.controlLogics.clear(); + runtime_project.registerComments.clear(); + std::string validation_error; + if (!runtime_project.validateForRunning(project_limits_, &validation_error)) + { + return {false, + ProjectServiceError::InvalidProject, + ProjectStorageError::InvalidProject, + validation_error}; + } + const ProjectSaveResult result = storage_.save(runtime_project, file_path); + if (!result.succeeded) + { + return storageFailure(result.error, result.message); + } + return {true, ProjectServiceError::None, ProjectStorageError::None, {}}; +} + ProjectOperationResult ProjectService::load(const std::string &file_path) { if (isBlank(file_path)) diff --git a/app/src/services/project_service.h b/app/src/services/project_service.h index f8f4760..6b94c73 100644 --- a/app/src/services/project_service.h +++ b/app/src/services/project_service.h @@ -63,6 +63,8 @@ public: ProjectOperationResult saveAs(const std::string &file_path); /** 将当前工程导出到指定 JSON,不改变当前工程文件关联 */ ProjectOperationResult exportAs(const std::string &file_path) const; + /** 将仅包含 HMI 运行所需内容的工程快照导出到指定 JSON */ + ProjectOperationResult exportHmiRuntimeAs(const std::string &file_path) const; /** * @brief 从指定文件加载工程 diff --git a/app/src/services/runtime_mode_service.cpp b/app/src/services/runtime_mode_service.cpp index 714a510..4fc9b9f 100644 --- a/app/src/services/runtime_mode_service.cpp +++ b/app/src/services/runtime_mode_service.cpp @@ -111,12 +111,15 @@ ModeTransitionResult RuntimeModeService::enterOnlineRunning() { return preparation; } - const OnlineLogicMonitorStartResult start_result = - online_logic_monitor_service_.start( - project_service_.project().controlLogics); - if (!start_result.succeeded) + if (!hmi_only_runtime_) { - return {false, ModeTransitionError::SimulationStartFailed, {}}; + const OnlineLogicMonitorStartResult start_result = + online_logic_monitor_service_.start( + project_service_.project().controlLogics); + if (!start_result.succeeded) + { + return {false, ModeTransitionError::SimulationStartFailed, {}}; + } } const ModeTransitionResult result = state_.enterOnlineRunning(true); if (result.succeeded && active_repository_ != nullptr && plc_repository_ != nullptr) @@ -125,11 +128,24 @@ ModeTransitionResult RuntimeModeService::enterOnlineRunning() } else if (!result.succeeded) { - online_logic_monitor_service_.stop(); + if (!hmi_only_runtime_) + { + online_logic_monitor_service_.stop(); + } } return result; } +void RuntimeModeService::setHmiOnlyRuntime(bool enabled) +{ + hmi_only_runtime_ = enabled; + if (enabled && active_repository_ != nullptr && plc_repository_ != nullptr) + { + // 运行版从启动起只读取 PLC 缓存,首读前显示不可用值而不是虚拟值 + active_repository_->use(*plc_repository_); + } +} + void RuntimeModeService::setInitialPlcReadCompleted(bool completed) { // 通信服务完成有效读回后才允许把此标志设为 true @@ -176,17 +192,32 @@ const LogicSyntaxCheckResult &RuntimeModeService::lastSyntaxCheck() const ModeTransitionResult RuntimeModeService::prepareProjectForRunning() { - last_syntax_check_ = logic_editor_service_.checkEnabledSyntax(); - if (!last_syntax_check_.completed || !last_syntax_check_.valid) + if (!hmi_only_runtime_) + { + last_syntax_check_ = logic_editor_service_.checkEnabledSyntax(); + if (!last_syntax_check_.completed || !last_syntax_check_.valid) + { + return { + false, + ModeTransitionError::ProjectNotReady, + last_syntax_check_.message}; + } + } + else + { + // HMI 运行版没有梯形图,保留成功结果供公共诊断接口读取 + last_syntax_check_ = {}; + last_syntax_check_.completed = true; + last_syntax_check_.valid = true; + } + Project project = project_service_.project(); + if (hmi_only_runtime_) { - return { - false, - ModeTransitionError::ProjectNotReady, - last_syntax_check_.message}; + project.controlLogics.clear(); + project.registerComments.clear(); } std::string error; - if (!project_service_.project().validateForRunning( - project_service_.projectLimits(), &error)) + if (!project.validateForRunning(project_service_.projectLimits(), &error)) { return {false, ModeTransitionError::ProjectNotReady, std::move(error)}; } @@ -203,7 +234,7 @@ void RuntimeModeService::configurePlc( active_repository_ = &active_repository; virtual_repository_ = &virtual_repository; plc_repository_ = &plc_repository; - // 通信故障撤销首读资格;若正在真机运行,立即回到编辑态 + // 通信故障撤销首读资格;普通编程器回到编辑态,HMI 运行版保留 PLC 缓存画面 gateway.setCallbacks( [this] { @@ -214,7 +245,8 @@ void RuntimeModeService::configurePlc( || plc_state == PlcConnectionState::Faulted) { setInitialPlcReadCompleted(false); - if (state_.mode() == ApplicationMode::OnlineRunning) + if (state_.mode() == ApplicationMode::OnlineRunning + && !hmi_only_runtime_) { enterEditing(); } @@ -237,6 +269,7 @@ void RuntimeModeService::configurePlc( [this] { if (state_.mode() == ApplicationMode::OnlineRunning + && !hmi_only_runtime_ && online_logic_monitor_service_.state() == OnlineLogicMonitorState::Running) { diff --git a/app/src/services/runtime_mode_service.h b/app/src/services/runtime_mode_service.h index 5ffe42e..99ef406 100644 --- a/app/src/services/runtime_mode_service.h +++ b/app/src/services/runtime_mode_service.h @@ -101,6 +101,8 @@ public: ActiveRegisterRepository &active_repository, RegisterRepository &virtual_repository, RegisterRepository &plc_repository); + /** 设置导出 HMI 运行版模式,跳过本地梯形图执行器 */ + void setHmiOnlyRuntime(bool enabled); /** * @brief 连接 PLC 并开始异步通信 * @param configuration 串口、Modbus 和轮询配置 @@ -159,4 +161,5 @@ private: std::vector monitor_addresses_; // 自由监控额外引用的地址 std::vector monitor_multi_word_ranges_; // 自由监控中的多字范围 LogicSyntaxCheckResult last_syntax_check_; // 最近一次运行前梯形图检查结果 + bool hmi_only_runtime_ = false; // 导出运行版只显示 HMI 并连接真实 PLC }; diff --git a/app/src/ui/main_window.cpp b/app/src/ui/main_window.cpp index 42c628b..83d5dfe 100644 --- a/app/src/ui/main_window.cpp +++ b/app/src/ui/main_window.cpp @@ -25,6 +25,7 @@ #include "ui_main_window.h" #include "domain/project_limits.h" +#include "infrastructure/runtime_settings_loader.h" #include "infrastructure/runtime_project_bundle.h" #include @@ -38,6 +39,7 @@ #include #include #include +#include #include #include #include @@ -66,6 +68,7 @@ namespace { constexpr int kSyntaxLogicIdRole = Qt::UserRole + 1; constexpr int kSyntaxRungIdRole = Qt::UserRole + 2; constexpr int kSyntaxColumnRole = Qt::UserRole + 3; +constexpr int kUserRuntimeReconnectIntervalMs = 3000; bool isTextEditingObject(QObject *object) { @@ -313,6 +316,80 @@ bool copyDirectoryContents( return true; } +QStringList runtimeDependencyDirectories() +{ + QStringList directories; + const auto appendDirectory = [&directories](const QString &directory) + { + const QString normalized = QDir(directory).absolutePath(); + if (!normalized.isEmpty() && !directories.contains(normalized)) + { + directories.append(normalized); + } + }; + + appendDirectory(QCoreApplication::applicationDirPath()); + appendDirectory(QLibraryInfo::location(QLibraryInfo::BinariesPath)); + const QString qt_prefix = QLibraryInfo::location(QLibraryInfo::PrefixPath); + if (!qt_prefix.isEmpty()) + { + appendDirectory(QDir(qt_prefix).filePath( + QStringLiteral("../../Tools/mingw810_64/bin"))); + } + const QStringList path_directories = QString::fromLocal8Bit( + qgetenv("PATH")) + .split(QDir::listSeparator(), Qt::SkipEmptyParts); + for (const QString &directory : path_directories) + { + appendDirectory(directory); + } + return directories; +} + +QString findRuntimeDependency( + const QString &file_name, const QStringList &directories) +{ + for (const QString &directory : directories) + { + const QString candidate = QDir(directory).filePath(file_name); + if (QFileInfo(candidate).isFile()) + { + return candidate; + } + } + return {}; +} + +QString findRuntimePluginDirectory( + const QString &plugin_directory, const QStringList &directories) +{ + const QString application_directory = + QCoreApplication::applicationDirPath(); + const QString application_candidate = QDir(application_directory).filePath( + plugin_directory); + if (QFileInfo(application_candidate).isDir()) + { + return application_candidate; + } + + const QString qt_plugins = QLibraryInfo::location(QLibraryInfo::PluginsPath); + const QString qt_candidate = QDir(qt_plugins).filePath(plugin_directory); + if (QFileInfo(qt_candidate).isDir()) + { + return qt_candidate; + } + + for (const QString &directory : directories) + { + const QString candidate = QDir(directory).filePath(plugin_directory); + if (QFileInfo(candidate).isDir()) + { + return candidate; + } + } + return {}; +} + } // namespace MainWindow::MainWindow( @@ -456,88 +533,81 @@ void MainWindow::initializeUi() void MainWindow::initializeUserRuntime() { setWindowTitle(fromUtf8(project_service_.project().metadata.name)); - runtime_panel_controller_->configureUserRuntimeMode( - true, - [this](int mode) - { - if (mode == static_cast(ApplicationMode::OfflineRunning)) - { - requestUserRuntimeMode(ApplicationMode::OfflineRunning); - } - else if (mode == static_cast(ApplicationMode::OnlineRunning)) - { - requestUserRuntimeMode(ApplicationMode::OnlineRunning); - } - }, - [this] { connectPlc(); }, - [this] { disconnectPlc(); }); + runtime_panel_controller_->setHmiOnlyRuntime(true); menuBar()->setVisible(false); ui_->hmiToolBar->setVisible(false); ui_->logicToolBar->setVisible(false); ui_->outputDock->setVisible(false); ui_->projectDock->setVisible(false); ui_->propertiesDock->setVisible(false); + user_runtime_reconnect_timer_ = new QTimer(this); + user_runtime_reconnect_timer_->setInterval(kUserRuntimeReconnectIntervalMs); + connect( + user_runtime_reconnect_timer_, + &QTimer::timeout, + this, + &MainWindow::attemptUserRuntimeReconnect); + user_runtime_reconnect_timer_->start(); QTimer::singleShot( 0, this, [this] { - if (requestMode(ApplicationMode::OfflineRunning)) + const HmiNavigationResult navigation = hmi_navigation_service_->start(); + if (!navigation.succeeded) { - hide(); + QMessageBox::critical( + nullptr, + tr("HMI 运行版启动失败"), + fromUtf8(navigation.message)); + qApp->quit(); + return; } - else + runtime_panel_controller_->enterRuntime( + navigation.pageId, + {}, + ApplicationMode::Editing, + runtime_mode_service_.plcConnectionState()); + hide(); + + const PlcCommunicationResult connection = + runtime_mode_service_.connectPlc(plc_configuration_); + if (!connection.succeeded) { - qApp->quit(); + QMessageBox::critical( + runtime_monitor_widget_, + tr("PLC 自动连接失败"), + tr("无法使用 config/runtime.ini 中的串口配置连接 PLC:%1") + .arg(fromUtf8(connection.message))); } }); } -void MainWindow::requestUserRuntimeMode(ApplicationMode requested_mode) +void MainWindow::enterUserRuntimeAfterInitialRead() { - if (runtime_mode_service_.mode() == requested_mode) + if (!user_runtime_mode_ + || runtime_mode_service_.mode() != ApplicationMode::Editing + || !runtime_mode_service_.initialPlcReadCompleted()) { return; } - if (requested_mode == ApplicationMode::OnlineRunning - && !runtime_mode_service_.initialPlcReadCompleted()) + requestMode(ApplicationMode::OnlineRunning); +} + +void MainWindow::attemptUserRuntimeReconnect() +{ + if (!user_runtime_mode_ || runtime_mode_service_.mode() + == ApplicationMode::OfflineRunning) { - // 用户运行版没有可见的编辑器主窗口,失败前不能先隐藏运行监控窗口 - if (runtime_monitor_widget_ != nullptr) - { - runtime_monitor_widget_->setMode( - runtime_mode_service_.mode(), - runtime_mode_service_.plcConnectionState()); - const QString message = transitionErrorText( - ModeTransitionError::InitialPlcReadRequired) - + tr("\n当前状态:%1").arg( - plcStatusText(runtime_mode_service_)) - + tr("\n请先点击“PLC 配置”连接并完成首次读取。"); - QMessageBox::warning( - runtime_monitor_widget_, - tr("无法切换到真机运行"), - message); - } return; } - if (runtime_mode_service_.mode() != ApplicationMode::Editing) + const PlcConnectionState state = runtime_mode_service_.plcConnectionState(); + if (state != PlcConnectionState::Disconnected + && state != PlcConnectionState::Faulted) { - const ModeTransitionResult editing = runtime_mode_service_.enterEditing(); - if (!editing.succeeded) - { - return; - } - updateModeUi(tr("正在切换运行模式")); - QTimer::singleShot( - 0, - this, - [this, requested_mode] - { - requestMode(requested_mode); - }); return; } - requestMode(requested_mode); + runtime_mode_service_.connectPlc(plc_configuration_); } MainWindow::~MainWindow() @@ -1149,15 +1219,15 @@ void MainWindow::configureRuntimeMonitor() } }); runtime_panel_controller_->configure(); - runtime_panel_controller_->configureUserRuntimeMode( - false, + runtime_monitor_widget_ = runtime_panel_controller_->runtimeMonitorWidget(); + connect( + runtime_monitor_widget_, + &RuntimeMonitorWidget::modeChangeRequested, + this, [this](int mode) { requestMode(static_cast(mode)); - }, - {}, - {}); - runtime_monitor_widget_ = runtime_panel_controller_->runtimeMonitorWidget(); + }); } void MainWindow::configureDataMonitor() @@ -1656,6 +1726,20 @@ void MainWindow::updateProjectTreeActions() void MainWindow::connectPlc() { + if (user_runtime_mode_) + { + const PlcCommunicationResult result = runtime_mode_service_.connectPlc( + plc_configuration_); + if (!result.succeeded) + { + QMessageBox::critical( + runtime_monitor_widget_, + tr("PLC 自动连接失败"), + tr("无法使用 config/runtime.ini 中的串口配置连接 PLC:%1") + .arg(fromUtf8(result.message))); + } + return; + } PlcConnectionDialog dialog( plc_configuration_, plc_discovery_gateway_, @@ -1707,6 +1791,39 @@ void MainWindow::schedulePlcStatusUpdate() [this] { plc_status_update_pending_ = false; + if (user_runtime_mode_) + { + if (runtime_monitor_widget_ != nullptr) + { + runtime_monitor_widget_->setMode( + runtime_mode_service_.mode(), + runtime_mode_service_.plcConnectionState()); + runtime_monitor_widget_->setHmiWriteEnabled( + runtime_mode_service_.mode() + == ApplicationMode::OnlineRunning + && runtime_mode_service_.plcConnectionState() + == PlcConnectionState::Connected + && runtime_mode_service_.initialPlcReadCompleted()); + } + enterUserRuntimeAfterInitialRead(); + const std::string error = runtime_mode_service_.plcError(); + if (!error.empty() && error != last_user_runtime_plc_error_) + { + last_user_runtime_plc_error_ = error; + QMessageBox::warning( + runtime_monitor_widget_, + tr("PLC 通信异常"), + fromUtf8(error) + + tr("\n请检查设备、接线和 config/runtime.ini 后重启程序。")); + } + if (error.empty() + && runtime_mode_service_.plcConnectionState() + == PlcConnectionState::Connected) + { + last_user_runtime_plc_error_.clear(); + } + return; + } updateModeUi(plcStatusText(runtime_mode_service_)); }, Qt::QueuedConnection); @@ -2168,18 +2285,11 @@ void MainWindow::loadProject() void MainWindow::exportRuntimeProgram() { - const LogicSyntaxCheckResult syntax = - logic_editor_service_.checkEnabledSyntax(); - if (syntax.changed || !syntax.completed || !syntax.valid) - { - reportLogicSyntaxCheck(syntax, tr("导出前语法检查")); - } - if (!syntax.completed || !syntax.valid) - { - return; - } std::string validation_error; - if (!project_service_.project().validateForRunning( + Project hmi_runtime_project = project_service_.project(); + hmi_runtime_project.controlLogics.clear(); + hmi_runtime_project.registerComments.clear(); + if (!hmi_runtime_project.validateForRunning( project_service_.projectLimits(), &validation_error)) { showProjectResult( @@ -2251,7 +2361,7 @@ void MainWindow::exportRuntimeProgram() + QStringLiteral("/qtproxinje-runtime-") + QString::number(QCoreApplication::applicationPid()) + QStringLiteral(".json"); - const ProjectOperationResult save_result = project_service_.exportAs( + const ProjectOperationResult save_result = project_service_.exportHmiRuntimeAs( temp_project.toUtf8().toStdString()); if (!save_result.succeeded) { @@ -2313,7 +2423,7 @@ void MainWindow::exportRuntimeProgram() progress.setLabelText(tr("正在复制 Qt 运行库和平台插件…")); progress.setValue(65); QApplication::processEvents(); - const QString application_directory = QFileInfo(template_executable).absolutePath(); + const QStringList dependency_directories = runtimeDependencyDirectories(); const QStringList runtime_files = { QStringLiteral("Qt5Core.dll"), QStringLiteral("Qt5Gui.dll"), @@ -2326,21 +2436,33 @@ void MainWindow::exportRuntimeProgram() QStringLiteral("libwinpthread-1.dll")}; for (const QString &runtime_file : runtime_files) { - const QString source = QDir(application_directory).filePath(runtime_file); + const QString source = findRuntimeDependency( + runtime_file, dependency_directories); const QString destination = QDir(destination_directory).filePath(runtime_file); - if (!QFileInfo::exists(source) || !QFile::copy(source, destination)) + if (source.isEmpty() || !QFile::copy(source, destination)) { - failExport(tr("复制运行库失败:%1").arg(source)); + failExport( + tr("复制运行库失败:%1\n已搜索程序目录、Qt 安装目录和 PATH。") + .arg(source.isEmpty() ? runtime_file : source)); return; } } QString copy_error; + const QString platforms_source = findRuntimePluginDirectory( + QStringLiteral("platforms"), dependency_directories); + const QString styles_source = findRuntimePluginDirectory( + QStringLiteral("styles"), dependency_directories); + if (platforms_source.isEmpty() || styles_source.isEmpty()) + { + failExport(tr("未找到 Qt 平台插件或样式目录\n请确认 Qt 运行环境完整。")); + return; + } if (!copyDirectoryContents( - QDir(application_directory).filePath(QStringLiteral("platforms")), + platforms_source, QDir(destination_directory).filePath(QStringLiteral("platforms")), ©_error) || !copyDirectoryContents( - QDir(application_directory).filePath(QStringLiteral("styles")), + styles_source, QDir(destination_directory).filePath(QStringLiteral("styles")), ©_error)) { @@ -2348,6 +2470,21 @@ void MainWindow::exportRuntimeProgram() return; } + progress.setLabelText(tr("正在写入运行版串口配置…")); + progress.setValue(85); + QApplication::processEvents(); + std::string settings_error; + const QString runtime_settings_path = QDir(destination_directory).filePath( + QStringLiteral("config/runtime.ini")); + if (!RuntimeSettingsLoader::write( + runtime_settings_path, + application_settings_result_.settings.plcDefaults, + &settings_error)) + { + failExport(fromUtf8(settings_error)); + return; + } + const RuntimeProjectBundleLoadResult verification = RuntimeProjectBundleService::load(destination_executable); if (verification.status != RuntimeProjectBundleStatus::Loaded) diff --git a/app/src/ui/main_window.h b/app/src/ui/main_window.h index 985a00b..d4e7925 100644 --- a/app/src/ui/main_window.h +++ b/app/src/ui/main_window.h @@ -261,8 +261,10 @@ private: void initializeUi(); /** 专用用户运行版启动后直接进入运行监控 */ void initializeUserRuntime(); - /** 用户运行版允许在离线和真机之间自动经过编辑态切换 */ - void requestUserRuntimeMode(ApplicationMode requested_mode); + /** 用户运行版完成 PLC 首读后自动进入真机运行 */ + void enterUserRuntimeAfterInitialRead(); + /** 用户运行版在串口或 PLC 通信故障后自动尝试重连 */ + void attemptUserRuntimeReconnect(); /** 刷新编辑态数据监控的模式、权限和当前值 */ void refreshDataMonitorUi(); @@ -343,6 +345,10 @@ private: std::string current_logic_id_; /** 是否已经安排了待处理的 PLC 状态刷新 */ bool plc_status_update_pending_ = false; + /** 运行版最近一次已提示的 PLC 错误,避免同一故障重复弹窗 */ + std::string last_user_runtime_plc_error_; + /** 用户运行版自动重连定时器 */ + QTimer *user_runtime_reconnect_timer_ = nullptr; struct HmiClipboardData { std::vector controls; diff --git a/app/src/ui/runtime_monitor_widget.cpp b/app/src/ui/runtime_monitor_widget.cpp index 713efbc..8986077 100644 --- a/app/src/ui/runtime_monitor_widget.cpp +++ b/app/src/ui/runtime_monitor_widget.cpp @@ -11,7 +11,7 @@ #include #include -#include +#include #include #include @@ -69,8 +69,6 @@ RuntimeMonitorWidget::RuntimeMonitorWidget( ui_->monitorContainerLayout->addWidget(free_monitor_widget_); ui_->logicNoticeLabel->setVisible(false); ui_->runtimeModeComboBox->setVisible(false); - ui_->runtimePlcButton->setVisible(false); - ui_->runtimeDisconnectPlcButton->setVisible(false); ui_->upperSplitter->setSizes({600, 600}); ui_->verticalSplitter->setSizes({440, 260}); connect(hmi_view_, &HmiEditorWidget::pageNavigationRequested, @@ -111,10 +109,6 @@ RuntimeMonitorWidget::RuntimeMonitorWidget( : static_cast(ApplicationMode::OnlineRunning)); } }); - connect(ui_->runtimePlcButton, &QPushButton::clicked, - this, &RuntimeMonitorWidget::plcConfigurationRequested); - connect(ui_->runtimeDisconnectPlcButton, &QPushButton::clicked, - this, &RuntimeMonitorWidget::plcDisconnectRequested); } RuntimeMonitorWidget::~RuntimeMonitorWidget() = default; @@ -155,6 +149,19 @@ void RuntimeMonitorWidget::setMode( { const bool offline = mode == ApplicationMode::OfflineRunning; const bool online = mode == ApplicationMode::OnlineRunning; + if (hmi_only_mode_) + { + // 导出运行版只显示 HMI,数据源在进入真机态后固定为 PLC 缓存 + ui_->logicFrame->setVisible(false); + ui_->monitorFrame->setVisible(false); + ui_->runtimeModeComboBox->setVisible(false); + ui_->exitRuntimeButton->setVisible(false); + hmi_view_->setRuntimeActive(true); + // 首读资格由控制器按当前 PLC 状态统一恢复,连接刚建立时仍保持只读 + setHmiWriteEnabled(false); + setFreeMonitorWriteEnabled(false); + return; + } ui_->logicFrame->setVisible(offline || online); hmi_view_->setRuntimeActive(offline || online); if (!offline && !online) @@ -181,24 +188,45 @@ void RuntimeMonitorWidget::setMode( const QSignalBlocker blocker(ui_->runtimeModeComboBox); ui_->runtimeModeComboBox->setCurrentIndex(offline ? 0 : online ? 1 : -1); } - if (user_runtime_mode_) - { - const bool plc_configurable = plc_state == PlcConnectionState::Disconnected - || plc_state == PlcConnectionState::Faulted; - ui_->runtimePlcButton->setEnabled(plc_configurable); - ui_->runtimeDisconnectPlcButton->setEnabled( - plc_state != PlcConnectionState::Disconnected); - } free_monitor_widget_->refreshValues(mode, plc_state); } -void RuntimeMonitorWidget::setUserRuntimeMode(bool enabled) +void RuntimeMonitorWidget::setHmiOnlyRuntime(bool enabled) { - user_runtime_mode_ = enabled; - // 普通编程器和导出的运行程序都允许在运行窗口切换离线/真机 - ui_->runtimeModeComboBox->setVisible(true); - ui_->runtimePlcButton->setVisible(enabled); - ui_->runtimeDisconnectPlcButton->setVisible(enabled); + hmi_only_mode_ = enabled; + if (enabled) + { + // 用户运行版没有模式、连接配置和诊断面板,启动后只投影 HMI 页面 + ui_->logicFrame->setVisible(false); + ui_->monitorFrame->setVisible(false); + ui_->titleLabel->setVisible(false); + ui_->modeLabel->setVisible(false); + ui_->runtimeModeComboBox->setVisible(false); + ui_->exitRuntimeButton->setVisible(false); + ui_->hmiLabel->setVisible(false); + ui_->runtimePageLabel->setVisible(false); + ui_->hmiFrame->setFrameShape(QFrame::NoFrame); + ui_->hmiLayout->setContentsMargins(0, 0, 0, 0); + ui_->verticalLayout->setContentsMargins(0, 0, 0, 0); + ui_->verticalSplitter->setSizes({1, 0}); + ui_->upperSplitter->setSizes({1, 0}); + } + else + { + // 普通编程器仍保留完整运行监控区域,运行版模式只在进程启动时启用 + ui_->logicFrame->setVisible(false); + ui_->monitorFrame->setVisible(true); + ui_->titleLabel->setVisible(true); + ui_->modeLabel->setVisible(true); + ui_->hmiLabel->setVisible(true); + ui_->runtimePageLabel->setVisible(true); + ui_->exitRuntimeButton->setVisible(true); + ui_->hmiFrame->setFrameShape(QFrame::StyledPanel); + ui_->hmiLayout->setContentsMargins(4, 4, 4, 4); + ui_->verticalLayout->setContentsMargins(8, 8, 8, 8); + } + // 普通编程器和导出的运行程序都允许通过控制器注入模式行为 + ui_->runtimeModeComboBox->setVisible(!enabled); ui_->exitRuntimeButton->setToolTip( enabled ? tr("退出运行程序") : tr("返回编辑态")); ui_->exitRuntimeButton->setAccessibleName( @@ -224,7 +252,10 @@ void RuntimeMonitorWidget::refreshValues( { // HMI、报警和自由监控共享同一个活动寄存器仓库 hmi_view_->refreshRuntimeValues(); - free_monitor_widget_->refreshValues(mode, plc_state); + if (!hmi_only_mode_) + { + free_monitor_widget_->refreshValues(mode, plc_state); + } } void RuntimeMonitorWidget::setLogicTrace( diff --git a/app/src/ui/runtime_monitor_widget.h b/app/src/ui/runtime_monitor_widget.h index 1c9bd72..beb97a9 100644 --- a/app/src/ui/runtime_monitor_widget.h +++ b/app/src/ui/runtime_monitor_widget.h @@ -32,7 +32,7 @@ class LogicEditorWidget; class RegisterMonitorService; class ProjectService; -/** 唯一的工程师运行监控投影,组合 HMI、梯形图轨迹和自由监控 */ +/** 运行监控投影,普通模式组合 HMI/梯形图/自由监控,运行版仅保留 HMI */ class RuntimeMonitorWidget final : public QWidget { Q_OBJECT @@ -57,8 +57,8 @@ public: void setRuntimePage(const std::string &page_id); /** 根据模式决定是否显示本地逻辑轨迹以及是否允许写入 */ void setMode(ApplicationMode mode, PlcConnectionState plc_state); - /** 切换专用用户运行版的简化操作栏 */ - void setUserRuntimeMode(bool enabled); + /** 切换 HMI 专用用户运行版的界面模式 */ + void setHmiOnlyRuntime(bool enabled); /** 从寄存器仓库刷新 HMI 和自由监控中的当前值 */ void refreshValues(ApplicationMode mode, PlcConnectionState plc_state); /** 设置 HMI 控件是否允许写入运行值 */ @@ -79,10 +79,6 @@ signals: void exitRequested(); /** 用户在专用运行版中请求切换模式 */ void modeChangeRequested(int mode); - /** 用户请求打开 PLC 配置窗口 */ - void plcConfigurationRequested(); - /** 用户请求断开 PLC */ - void plcDisconnectRequested(); private: /** 显示指定的运行监控页面 */ @@ -110,6 +106,6 @@ private: std::string latest_fault_node_id_; /** 是否已经收到过可显示的逻辑轨迹 */ bool has_logic_trace_ = false; - /** 是否显示专用用户运行版操作栏 */ - bool user_runtime_mode_ = false; + /** 是否只显示 HMI 页面并隐藏所有诊断区域 */ + bool hmi_only_mode_ = false; }; diff --git a/app/src/ui/runtime_monitor_widget.ui b/app/src/ui/runtime_monitor_widget.ui index f102990..dda6e92 100644 --- a/app/src/ui/runtime_monitor_widget.ui +++ b/app/src/ui/runtime_monitor_widget.ui @@ -14,8 +14,6 @@ Qt::Horizontal4020 当前未运行Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter 1200离线仿真真机运行 - PLC 配置 - 断开 PLC 返回编辑态返回编辑态返回编辑态Qt::ToolButtonIconOnly diff --git a/app/src/ui/runtime_monitor_window.cpp b/app/src/ui/runtime_monitor_window.cpp index 5dda6ea..01a6e4a 100644 --- a/app/src/ui/runtime_monitor_window.cpp +++ b/app/src/ui/runtime_monitor_window.cpp @@ -3,6 +3,7 @@ #include "ui_runtime_monitor_window.h" #include +#include #include RuntimeMonitorWindow::RuntimeMonitorWindow(QWidget *parent) @@ -22,6 +23,19 @@ void RuntimeMonitorWindow::setMonitorWidget(QWidget &widget) ui_->runtimeMonitorWindowLayout->addWidget(&widget); } +void RuntimeMonitorWindow::setHmiOnlyRuntime(bool enabled) +{ + hmi_only_runtime_ = enabled; + if (enabled) + { + setWindowFlags( + Qt::Window + | Qt::WindowTitleHint + | Qt::WindowSystemMenuHint + | Qt::WindowCloseButtonHint); + } +} + void RuntimeMonitorWindow::showForRuntime() { application_exit_ = false; @@ -47,10 +61,14 @@ void RuntimeMonitorWindow::closeForApplicationExit() void RuntimeMonitorWindow::closeEvent(QCloseEvent *event) { // 运行期间禁止通过系统关闭路径离开运行界面,只允许应用退出时关闭 - if (runtime_active_ && !application_exit_) + if (runtime_active_ && !application_exit_ && !hmi_only_runtime_) { event->ignore(); return; } + if (runtime_active_ && !application_exit_ && hmi_only_runtime_) + { + emit closeRequested(); + } QMainWindow::closeEvent(event); } diff --git a/app/src/ui/runtime_monitor_window.h b/app/src/ui/runtime_monitor_window.h index cc7450b..a7cea40 100644 --- a/app/src/ui/runtime_monitor_window.h +++ b/app/src/ui/runtime_monitor_window.h @@ -19,7 +19,7 @@ class RuntimeMonitorWindow; class QCloseEvent; class QWidget; -/** 承载唯一工程师运行监控投影的独立顶层窗口 */ +/** 承载普通运行监控或 HMI 专用运行投影的独立顶层窗口 */ class RuntimeMonitorWindow final : public QMainWindow { Q_OBJECT @@ -32,6 +32,8 @@ public: /** 将运行监控控件放入窗口中央区域 */ void setMonitorWidget(QWidget &widget); + /** 切换导出运行版的标准窗口关闭行为 */ + void setHmiOnlyRuntime(bool enabled); /** 进入运行态时显示并最大化窗口 */ void showForRuntime(); /** 返回编辑态时隐藏窗口但保留唯一监控控件 */ @@ -39,6 +41,10 @@ public: /** 应用退出时允许真正关闭窗口 */ void closeForApplicationExit(); +signals: + /** 导出运行版用户关闭窗口时请求退出整个应用 */ + void closeRequested(); + protected: /** 仅在应用退出时放行窗口关闭事件 */ void closeEvent(QCloseEvent *event) override; @@ -50,4 +56,6 @@ private: bool runtime_active_ = false; /** 应用是否正在退出,决定关闭事件是否真正销毁窗口 */ bool application_exit_ = false; + /** 导出运行版允许使用系统关闭按钮 */ + bool hmi_only_runtime_ = false; }; diff --git a/app/src/ui/runtime_panel_controller.cpp b/app/src/ui/runtime_panel_controller.cpp index 42f7b43..ed55c73 100644 --- a/app/src/ui/runtime_panel_controller.cpp +++ b/app/src/ui/runtime_panel_controller.cpp @@ -90,6 +90,16 @@ void RuntimePanelController::configure() runtime_monitor_window_.get()); runtime_monitor_widget_->setObjectName(QStringLiteral("runtimeMonitorWidget")); runtime_monitor_window_->setMonitorWidget(*runtime_monitor_widget_); + QObject::connect(runtime_monitor_window_.get(), + &RuntimeMonitorWindow::closeRequested, + &parent_, + [this] + { + if (runtime_exit_requester_) + { + runtime_exit_requester_(); + } + }); QObject::connect(runtime_monitor_widget_, &RuntimeMonitorWidget::exitRequested, &parent_, [this] { @@ -121,36 +131,6 @@ void RuntimePanelController::configure() status_reporter_(message, 5000); output_reporter_(QObject::tr("页面导航失败:%1").arg(message)); }); - QObject::connect(runtime_monitor_widget_, - &RuntimeMonitorWidget::modeChangeRequested, - &parent_, - [this](int mode) - { - if (mode_requester_) - { - mode_requester_(mode); - } - }); - QObject::connect(runtime_monitor_widget_, - &RuntimeMonitorWidget::plcConfigurationRequested, - &parent_, - [this] - { - if (plc_configuration_requester_) - { - plc_configuration_requester_(); - } - }); - QObject::connect(runtime_monitor_widget_, - &RuntimeMonitorWidget::plcDisconnectRequested, - &parent_, - [this] - { - if (plc_disconnect_requester_) - { - plc_disconnect_requester_(); - } - }); runtime_refresh_timer_ = new QTimer(&parent_); runtime_refresh_timer_->setInterval(150); QObject::connect(runtime_refresh_timer_, &QTimer::timeout, @@ -173,18 +153,15 @@ void RuntimePanelController::configure() Qt::QueuedConnection); } -void RuntimePanelController::configureUserRuntimeMode( - bool enabled, - std::function mode_requester, - std::function plc_configuration_requester, - std::function plc_disconnect_requester) +void RuntimePanelController::setHmiOnlyRuntime(bool enabled) { - mode_requester_ = std::move(mode_requester); - plc_configuration_requester_ = std::move(plc_configuration_requester); - plc_disconnect_requester_ = std::move(plc_disconnect_requester); if (runtime_monitor_widget_ != nullptr) { - runtime_monitor_widget_->setUserRuntimeMode(enabled); + runtime_monitor_widget_->setHmiOnlyRuntime(enabled); + } + if (runtime_monitor_window_ != nullptr) + { + runtime_monitor_window_->setHmiOnlyRuntime(enabled); } } @@ -237,7 +214,8 @@ void RuntimePanelController::updateSimulationUi(bool report_fault) const bool plc_connected = runtime_mode_service_.plcConnectionState() == PlcConnectionState::Connected; const SimulationState state = runtime_mode_service_.simulationState(); - const bool write_enabled = (online && plc_connected) + const bool write_enabled = (online && plc_connected + && runtime_mode_service_.initialPlcReadCompleted()) || (offline && state == SimulationState::Running); // 编辑画布始终只读,运行操作统一由运行监控中的 HMI 发出 hmi_editor_widget_.setRuntimeWriteEnabled(false); diff --git a/app/src/ui/runtime_panel_controller.h b/app/src/ui/runtime_panel_controller.h index 98505dc..be3ac54 100644 --- a/app/src/ui/runtime_panel_controller.h +++ b/app/src/ui/runtime_panel_controller.h @@ -70,12 +70,8 @@ public: /** 连接运行监控相关信号并完成初始配置 */ void configure(); - /** 配置专用用户运行版的精简控制栏行为 */ - void configureUserRuntimeMode( - bool enabled, - std::function mode_requester, - std::function plc_configuration_requester, - std::function plc_disconnect_requester); + /** 切换 HMI 专用运行版投影 */ + void setHmiOnlyRuntime(bool enabled); /** 创建或复用唯一监控窗口,并把当前工程投影到运行态 */ void enterRuntime( const std::string &page_id, @@ -143,7 +139,4 @@ private: QTimer *runtime_refresh_timer_ = nullptr; /** 当前是否已经进入运行监控会话 */ bool runtime_session_active_ = false; - std::function mode_requester_; - std::function plc_configuration_requester_; - std::function plc_disconnect_requester_; }; diff --git a/app/tests/project_management_tests.cpp b/app/tests/project_management_tests.cpp index 2bc828e..5c8102e 100644 --- a/app/tests/project_management_tests.cpp +++ b/app/tests/project_management_tests.cpp @@ -563,6 +563,37 @@ void testProjectServiceStateAndConfiguredLimits() "successful save must clear the modified state"); } +void testHmiRuntimeExportStripsNonHmiProjectData() +{ + QTemporaryDir directory; + require(directory.isValid(), "HMI runtime export directory must be valid"); + JsonProjectStorage storage(defaultProjectLimitSettings()); + ProjectService service(storage, defaultProjectLimitSettings()); + const Project fixture = makeExampleProject(); + require(storage.save( + fixture, + directory.filePath("source.json").toStdString()) + .succeeded, + "HMI runtime export fixture must save"); + require(service.load( + directory.filePath("source.json").toStdString()).succeeded, + "HMI runtime export fixture must load"); + + const QString destination = directory.filePath("runtime.json"); + require(service.exportHmiRuntimeAs(destination.toStdString()).succeeded, + "HMI runtime export must accept a configured HMI project"); + const ProjectLoadResult loaded = storage.load(destination.toStdString()); + require(loaded.succeeded + && loaded.project.hmiPages.size() == fixture.hmiPages.size() + && loaded.project.hmiPages.front().id + == fixture.hmiPages.front().id + && loaded.project.alarmDefinitions.size() + == fixture.alarmDefinitions.size() + && loaded.project.controlLogics.empty() + && loaded.project.registerComments.empty(), + "HMI runtime export must keep HMI data and strip ladder metadata"); +} + void testRegisterCommentService() { JsonProjectStorage storage(defaultProjectLimitSettings()); @@ -603,6 +634,7 @@ int main() {"testStrictVersionAndRequiredFields", testStrictVersionAndRequiredFields}, {"testInvalidGridAndConnectionsAreRejected", testInvalidGridAndConnectionsAreRejected}, {"testProjectServiceStateAndConfiguredLimits", testProjectServiceStateAndConfiguredLimits}, + {"testHmiRuntimeExportStripsNonHmiProjectData", testHmiRuntimeExportStripsNonHmiProjectData}, {"testRegisterCommentService", testRegisterCommentService}, }); } diff --git a/app/tests/runtime_mode_service_tests.cpp b/app/tests/runtime_mode_service_tests.cpp index 1d0163d..3f9d1b7 100644 --- a/app/tests/runtime_mode_service_tests.cpp +++ b/app/tests/runtime_mode_service_tests.cpp @@ -6,6 +6,7 @@ #include "domain/project_storage.h" #include "domain/register_repository.h" #include "domain/virtual_register_repository.h" +#include "infrastructure/plc_register_repository.h" #include "support/test_support.h" #include @@ -94,6 +95,16 @@ public: } } + void reportFault() + { + connection_state = PlcConnectionState::Faulted; + initial_read = false; + if (state_changed) + { + state_changed(); + } + } + const std::vector &pollAddresses() const { return poll_addresses; @@ -349,6 +360,69 @@ void testModeTransitions() "a completed PLC poll cycle must trigger one new local trace scan"); } +void testHmiOnlyRuntimeSkipsLogicExecutor() +{ + TestProjectStorage storage; + ProjectService project_service(storage, defaultProjectLimitSettings()); + VirtualRegisterRepository virtual_repository; + PlcRegisterRepository plc_repository; + ActiveRegisterRepository active_repository(virtual_repository); + OfflineSimulationService simulation_service(virtual_repository); + OnlineLogicMonitorService online_monitor_service(plc_repository); + LogicEditorService logic_editor_service(project_service); + ReadyPlcGateway gateway; + RuntimeModeService service( + project_service, + logic_editor_service, + simulation_service, + online_monitor_service); + service.configurePlc( + gateway, active_repository, virtual_repository, plc_repository); + service.setHmiOnlyRuntime(true); + require(!active_repository.readBit({RegisterArea::M, 0}).succeeded, + "HMI-only runtime must use the unavailable PLC cache before its first read"); + + HmiPage page; + page.id = "hmi-only-page"; + page.name = "HMI only"; + HmiControl button; + button.id = "hmi-only-button"; + button.type = HmiControlType::Button; + button.bounds = {0, 0, 100, 40}; + button.binding = RegisterAddress{RegisterArea::M, 0}; + page.controls.push_back(button); + Project &project = project_service.editProject(); + project.hmiPages.push_back(page); + project.initialHmiPageId = page.id; + // 该逻辑故意不完整,HMI 专用导出不应受它阻断 + project.controlLogics.push_back(ControlLogic{}); + + require(service.connectPlc( + {"COM9", 1, 9600, 8, 2, 1, 1000, 2, 200}).succeeded, + "HMI-only runtime must accept a PLC connection request"); + gateway.completeInitialRead(); + plc_repository.updateBit(0, false); + require(service.enterOnlineRunning().succeeded, + "HMI-only runtime must ignore ladder logic readiness"); + require(service.policy().usesPlcRegisters + && online_monitor_service.state() + == OnlineLogicMonitorState::Stopped, + "HMI-only runtime must use PLC registers without starting local logic"); + require(active_repository.readBit({RegisterArea::M, 0}).succeeded, + "HMI-only runtime must switch HMI reads to the PLC cache"); + plc_repository.updateBit(0, true); + require(virtual_repository.writeBit({RegisterArea::M, 0}, false).succeeded + && active_repository.readBit({RegisterArea::M, 0}).value, + "HMI-only runtime fault fixture must separate virtual and PLC values"); + plc_repository.invalidate(); + gateway.reportFault(); + require(service.mode() == ApplicationMode::OnlineRunning + && !service.initialPlcReadCompleted() + && active_repository.readBit({RegisterArea::M, 0}).error + == RegisterError::Unavailable, + "HMI-only runtime faults must keep the PLC cache active without falling back offline"); +} + void testMonitorPollRangeRollback() { TestProjectStorage storage; @@ -483,6 +557,7 @@ int main() { return TestSupport::runTestSuite("runtime mode service tests", { {"testModeTransitions", testModeTransitions}, + {"testHmiOnlyRuntimeSkipsLogicExecutor", testHmiOnlyRuntimeSkipsLogicExecutor}, {"testMonitorPollRangeRollback", testMonitorPollRangeRollback}, {"testDisconnectedOutputBlocksOfflineAndOnlineRuntime", testDisconnectedOutputBlocksOfflineAndOnlineRuntime}, }); diff --git a/app/tests/runtime_mode_service_tests.pro b/app/tests/runtime_mode_service_tests.pro index 9b98437..b8c2d15 100644 --- a/app/tests/runtime_mode_service_tests.pro +++ b/app/tests/runtime_mode_service_tests.pro @@ -10,7 +10,8 @@ SOURCES += \ $$SERVICE_PROJECT_SOURCES \ $$SERVICE_LOGIC_SOURCES \ $$SERVICE_OFFLINE_SOURCES \ - $$SERVICE_RUNTIME_SOURCES + $$SERVICE_RUNTIME_SOURCES \ + ../src/infrastructure/plc_register_repository.cpp HEADERS += \ $$DOMAIN_ALL_HEADERS \ @@ -18,4 +19,5 @@ HEADERS += \ $$SERVICE_LOGIC_HEADERS \ $$SERVICE_OFFLINE_HEADERS \ $$SERVICE_RUNTIME_HEADERS \ + ../src/infrastructure/plc_register_repository.h \ $$TEST_SUPPORT_HEADERS diff --git a/app/tests/runtime_settings_tests.cpp b/app/tests/runtime_settings_tests.cpp new file mode 100644 index 0000000..02bd6f6 --- /dev/null +++ b/app/tests/runtime_settings_tests.cpp @@ -0,0 +1,139 @@ +#include "infrastructure/runtime_settings_loader.h" +#include "support/test_support.h" + +#include +#include +#include + +#include +#include + +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}, + }); +} diff --git a/app/tests/runtime_settings_tests.pro b/app/tests/runtime_settings_tests.pro new file mode 100644 index 0000000..e23f783 --- /dev/null +++ b/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 diff --git a/app/tests/tests.pro b/app/tests/tests.pro index ed3cdb9..fe9c233 100644 --- a/app/tests/tests.pro +++ b/app/tests/tests.pro @@ -12,6 +12,7 @@ SUBDIRS += \ project_management \ register_monitor \ runtime_project_bundle \ + runtime_settings \ runtime_mode \ runtime_panel_controller \ plc_connection_dialog \ @@ -26,6 +27,7 @@ offline_simulation.file = offline_simulation_service_tests.pro project_management.file = project_management_tests.pro register_monitor.file = register_monitor_service_tests.pro runtime_project_bundle.file = runtime_project_bundle_tests.pro +runtime_settings.file = runtime_settings_tests.pro runtime_mode.file = runtime_mode_service_tests.pro runtime_panel_controller.file = runtime_panel_controller_tests.pro plc_connection_dialog.file = plc_connection_dialog_tests.pro diff --git a/scripts/run_qt_tests.ps1 b/scripts/run_qt_tests.ps1 index a41ce5b..ab7e5a8 100644 --- a/scripts/run_qt_tests.ps1 +++ b/scripts/run_qt_tests.ps1 @@ -34,6 +34,7 @@ $functionalTargets = @( 'project_management_tests', 'register_monitor_service_tests', 'runtime_project_bundle_tests', + 'runtime_settings_tests', 'runtime_mode_service_tests', 'runtime_panel_controller_tests', 'plc_connection_dialog_tests',