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