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