| @@ -6,6 +6,7 @@ | |||||
| *.pro text eol=lf | *.pro text eol=lf | ||||
| *.ui text eol=lf | *.ui text eol=lf | ||||
| *.py text eol=lf | *.py text eol=lf | ||||
| *.ps1 text eol=lf | |||||
| *.md text eol=lf | *.md text eol=lf | ||||
| *.pdf binary | *.pdf binary | ||||
| @@ -54,3 +54,8 @@ tmp/ | |||||
| .DS_Store | .DS_Store | ||||
| Thumbs.db | Thumbs.db | ||||
| Desktop.ini | Desktop.ini | ||||
| /scripts | |||||
| /docs/images | |||||
| /docs/类图Mermaid代码.md | |||||
| /other | |||||
| @@ -1,5 +1,7 @@ | |||||
| # 项目长期约定 | # 项目长期约定 | ||||
| - 项目作者:`suyu`。 | |||||
| ## 关键文件索引 | ## 关键文件索引 | ||||
| - C++ 代码规范:`docs/C++代码规范.md`。 | - C++ 代码规范:`docs/C++代码规范.md`。 | ||||
| @@ -7,6 +9,7 @@ | |||||
| - 推荐开发顺序:`docs/开发顺序.md`。 | - 推荐开发顺序:`docs/开发顺序.md`。 | ||||
| - Qt 应用源码根目录:`app/`。 | - Qt 应用源码根目录:`app/`。 | ||||
| - 本机构建输出目录:`build/`(不纳入 Git)。 | - 本机构建输出目录:`build/`(不纳入 Git)。 | ||||
| - Qt 一键构建运行脚本:`scripts/build_and_run_qt.ps1`。 | |||||
| - XDH-60T4-E 硬件与接线要点:`docs/XDH-60T4-E硬件与接线要点.md`。 | - XDH-60T4-E 硬件与接线要点:`docs/XDH-60T4-E硬件与接线要点.md`。 | ||||
| - XDH-60T4-E 指令与 Modbus 要点:`docs/XDH-60T4-E指令与Modbus要点.md`。 | - XDH-60T4-E 指令与 Modbus 要点:`docs/XDH-60T4-E指令与Modbus要点.md`。 | ||||
| - PLC 官方手册:`docs/pdf/`。 | - PLC 官方手册:`docs/pdf/`。 | ||||
| @@ -30,6 +33,8 @@ mingw32-make -j2 | |||||
| - 优先在独立构建目录执行 qmake 和 `mingw32-make`,不要将生成物混入源代码目录。 | - 优先在独立构建目录执行 qmake 和 `mingw32-make`,不要将生成物混入源代码目录。 | ||||
| - 文件读写、终端查看和源代码统一使用 UTF-8。 | - 文件读写、终端查看和源代码统一使用 UTF-8。 | ||||
| - 一键构建并运行 Debug 程序:`pwsh -NoLogo -NoProfile -File .\scripts\build_and_run_qt.ps1`。 | |||||
| - 一键构建并运行 Release 程序:`pwsh -NoLogo -NoProfile -File .\scripts\build_and_run_qt.ps1 -Configuration Release`。 | |||||
| ## Git 提交约定 | ## Git 提交约定 | ||||
| @@ -45,3 +50,6 @@ mingw32-make -j2 | |||||
| - 离线模式使用虚拟 M/D,并运行软件逻辑执行器。 | - 离线模式使用虚拟 M/D,并运行软件逻辑执行器。 | ||||
| - 真机模式使用 PLC 的真实 M/D,软件逻辑执行器必须停止;切换时先读取 PLC 数据,不自动写入离线虚拟值。 | - 真机模式使用 PLC 的真实 M/D,软件逻辑执行器必须停止;切换时先读取 PLC 数据,不自动写入离线虚拟值。 | ||||
| - 真机联动设备时 PLC 必须处于 RUN;STOP 仅用于安全通信验证。 | - 真机联动设备时 PLC 必须处于 RUN;STOP 仅用于安全通信验证。 | ||||
| ## 注意 | |||||
| - 生成注释的文本结尾别加逗号 `。`。 | |||||
| @@ -1,7 +1,7 @@ | |||||
| # integrated_platform.pro | # integrated_platform.pro | ||||
| # 综合平台编程器 Qt Widgets 工程配置。 | # 综合平台编程器 Qt Widgets 工程配置。 | ||||
| # Version: 0.1.0 | # Version: 0.1.0 | ||||
| # Author: QtProXinJe | |||||
| # Author: suyu | |||||
| # Date: 2026-08-05 | # Date: 2026-08-05 | ||||
| QT += core gui widgets serialbus serialport | QT += core gui widgets serialbus serialport | ||||
| @@ -16,10 +16,22 @@ INCLUDEPATH += src | |||||
| SOURCES += \ | SOURCES += \ | ||||
| src/main.cpp \ | src/main.cpp \ | ||||
| src/ui/main_window.cpp | |||||
| src/ui/main_window.cpp \ | |||||
| src/domain/register_address.cpp \ | |||||
| src/domain/register_repository.cpp \ | |||||
| src/domain/hmi_model.cpp \ | |||||
| src/domain/control_logic_model.cpp \ | |||||
| src/domain/project_model.cpp \ | |||||
| src/domain/runtime_state.cpp | |||||
| HEADERS += \ | HEADERS += \ | ||||
| src/ui/main_window.h | |||||
| src/ui/main_window.h \ | |||||
| src/domain/register_address.h \ | |||||
| src/domain/register_repository.h \ | |||||
| src/domain/hmi_model.h \ | |||||
| src/domain/control_logic_model.h \ | |||||
| src/domain/project_model.h \ | |||||
| src/domain/runtime_state.h | |||||
| FORMS += \ | FORMS += \ | ||||
| src/ui/main_window.ui | src/ui/main_window.ui | ||||
| @@ -0,0 +1,163 @@ | |||||
| #include "control_logic_model.h" | |||||
| #include <algorithm> | |||||
| namespace integrated_platform::domain { | |||||
| namespace { | |||||
| // 在提供错误字符串时写入校验失败原因 | |||||
| void setError(std::string *error, const std::string &message) | |||||
| { | |||||
| if (error != nullptr) | |||||
| { | |||||
| *error = message; | |||||
| } | |||||
| } | |||||
| // 判断触点工作方式是否受支持 | |||||
| bool isSupportedMode(ContactMode mode) | |||||
| { | |||||
| return mode == ContactMode::NormallyOpen || mode == ContactMode::NormallyClosed; | |||||
| } | |||||
| // 判断线圈工作方式是否受支持 | |||||
| bool isSupportedMode(CoilMode mode) | |||||
| { | |||||
| return mode == CoilMode::Normal || mode == CoilMode::Set || mode == CoilMode::Reset; | |||||
| } | |||||
| // 判断数值比较运算符是否受支持 | |||||
| bool isSupportedComparison(ComparisonOperator comparison) | |||||
| { | |||||
| return comparison == ComparisonOperator::Equal | |||||
| || comparison == ComparisonOperator::NotEqual | |||||
| || comparison == ComparisonOperator::LessThan | |||||
| || comparison == ComparisonOperator::LessThanOrEqual | |||||
| || comparison == ComparisonOperator::GreaterThan | |||||
| || comparison == ComparisonOperator::GreaterThanOrEqual; | |||||
| } | |||||
| // 校验触点节点配置 | |||||
| bool validateConfig(const ContactNodeConfig &config, std::string *error) | |||||
| { | |||||
| if (!config.address.isValid() || config.address.area() != RegisterArea::M) | |||||
| { | |||||
| setError(error, "contact node requires a valid M address"); | |||||
| return false; | |||||
| } | |||||
| if (!isSupportedMode(config.mode)) | |||||
| { | |||||
| setError(error, "contact node has an unsupported mode"); | |||||
| return false; | |||||
| } | |||||
| return true; | |||||
| } | |||||
| // 校验线圈节点配置 | |||||
| bool validateConfig(const CoilNodeConfig &config, std::string *error) | |||||
| { | |||||
| if (!config.address.isValid() || config.address.area() != RegisterArea::M) | |||||
| { | |||||
| setError(error, "coil node requires a valid M address"); | |||||
| return false; | |||||
| } | |||||
| if (!isSupportedMode(config.mode)) | |||||
| { | |||||
| setError(error, "coil node has an unsupported mode"); | |||||
| return false; | |||||
| } | |||||
| return true; | |||||
| } | |||||
| // 校验数值比较节点配置 | |||||
| bool validateConfig(const CompareNodeConfig &config, std::string *error) | |||||
| { | |||||
| if (!config.address.isValid() || config.address.area() != RegisterArea::D) | |||||
| { | |||||
| setError(error, "comparison node requires a valid D address"); | |||||
| return false; | |||||
| } | |||||
| if (!isSupportedComparison(config.comparison)) | |||||
| { | |||||
| setError(error, "comparison node has an unsupported operator"); | |||||
| return false; | |||||
| } | |||||
| return true; | |||||
| } | |||||
| } // namespace | |||||
| bool LogicNode::validate(std::string *error) const | |||||
| { | |||||
| if (id.empty()) | |||||
| { | |||||
| setError(error, "logic node id must not be empty"); | |||||
| return false; | |||||
| } | |||||
| // 根据 variant 中实际保存的配置类型调用对应校验函数 | |||||
| return std::visit( | |||||
| [error](const auto &nodeConfig) | |||||
| { | |||||
| return validateConfig(nodeConfig, error); | |||||
| }, | |||||
| config); | |||||
| } | |||||
| bool LogicConnection::validate(std::string *error) const | |||||
| { | |||||
| // 连接两端必须存在且不能指向同一个节点 | |||||
| if (fromNodeId.empty() || toNodeId.empty() || fromNodeId == toNodeId) | |||||
| { | |||||
| setError(error, "logic connection must connect two different nodes"); | |||||
| return false; | |||||
| } | |||||
| return true; | |||||
| } | |||||
| bool ControlLogic::validate(std::string *error) const | |||||
| { | |||||
| if (id.empty() || name.empty()) | |||||
| { | |||||
| setError(error, "control logic id and name must not be empty"); | |||||
| return false; | |||||
| } | |||||
| // 校验所有节点并收集节点标识用于后续检查连接引用 | |||||
| std::vector<std::string> node_ids; | |||||
| node_ids.reserve(nodes.size()); | |||||
| for (const LogicNode &node : nodes) | |||||
| { | |||||
| if (!node.validate(error)) | |||||
| { | |||||
| return false; | |||||
| } | |||||
| if (std::find(node_ids.cbegin(), node_ids.cend(), node.id) != node_ids.cend()) | |||||
| { | |||||
| setError(error, "logic node ids must be unique within a logic"); | |||||
| return false; | |||||
| } | |||||
| node_ids.push_back(node.id); | |||||
| } | |||||
| // 每条连接只能引用当前控制逻辑中已经定义的节点 | |||||
| for (const LogicConnection &connection : connections) | |||||
| { | |||||
| if (!connection.validate(error)) | |||||
| { | |||||
| return false; | |||||
| } | |||||
| if (std::find(node_ids.cbegin(), node_ids.cend(), connection.fromNodeId) | |||||
| == node_ids.cend() | |||||
| || std::find(node_ids.cbegin(), node_ids.cend(), connection.toNodeId) | |||||
| == node_ids.cend()) | |||||
| { | |||||
| setError(error, "logic connection references an unknown node"); | |||||
| return false; | |||||
| } | |||||
| } | |||||
| return true; | |||||
| } | |||||
| } // namespace integrated_platform::domain | |||||
| @@ -0,0 +1,112 @@ | |||||
| #pragma once | |||||
| #include "register_address.h" | |||||
| #include <cstdint> | |||||
| #include <string> | |||||
| #include <variant> | |||||
| #include <vector> | |||||
| namespace integrated_platform::domain { | |||||
| // 触点工作方式 | |||||
| enum class ContactMode | |||||
| { | |||||
| NormallyOpen, // 常开触点 | |||||
| NormallyClosed // 常闭触点 | |||||
| }; | |||||
| // 线圈工作方式 | |||||
| enum class CoilMode | |||||
| { | |||||
| Normal, // 普通线圈 | |||||
| Set, // 置位线圈 | |||||
| Reset // 复位线圈 | |||||
| }; | |||||
| // 数值比较运算符 | |||||
| enum class ComparisonOperator | |||||
| { | |||||
| Equal, // 等于 | |||||
| NotEqual, // 不等于 | |||||
| LessThan, // 小于 | |||||
| LessThanOrEqual, // 小于等于 | |||||
| GreaterThan, // 大于 | |||||
| GreaterThanOrEqual // 大于等于 | |||||
| }; | |||||
| // 描述读取 M 区位状态的触点节点配置 | |||||
| struct ContactNodeConfig | |||||
| { | |||||
| // 触点绑定的 M 区地址 | |||||
| RegisterAddress address{RegisterArea::M, 0}; | |||||
| // 触点工作方式 | |||||
| ContactMode mode = ContactMode::NormallyOpen; | |||||
| }; | |||||
| // 描述向 M 区写入状态的线圈节点配置 | |||||
| struct CoilNodeConfig | |||||
| { | |||||
| // 线圈绑定的 M 区地址 | |||||
| RegisterAddress address{RegisterArea::M, 0}; | |||||
| // 线圈工作方式 | |||||
| CoilMode mode = CoilMode::Normal; | |||||
| }; | |||||
| // 描述 D 区数值与固定值的比较节点配置 | |||||
| struct CompareNodeConfig | |||||
| { | |||||
| // 被比较的 D 区地址 | |||||
| RegisterAddress address{RegisterArea::D, 0}; | |||||
| // 比较运算符 | |||||
| ComparisonOperator comparison = ComparisonOperator::Equal; | |||||
| // 参与比较的固定值 | |||||
| std::int16_t value = 0; | |||||
| }; | |||||
| // 控制逻辑节点支持的配置类型 | |||||
| using LogicNodeConfig = std::variant<ContactNodeConfig, CoilNodeConfig, CompareNodeConfig>; | |||||
| // 描述控制逻辑中的一个功能节点 | |||||
| struct LogicNode | |||||
| { | |||||
| // 节点唯一标识 | |||||
| std::string id; | |||||
| // 节点自身的业务配置 | |||||
| LogicNodeConfig config; | |||||
| // 校验节点配置并通过 error 返回失败原因 | |||||
| bool validate(std::string *error = nullptr) const; | |||||
| }; | |||||
| // 描述两个控制逻辑节点之间的连接关系 | |||||
| struct LogicConnection | |||||
| { | |||||
| // 连接起点的节点标识 | |||||
| std::string fromNodeId; | |||||
| // 连接终点的节点标识 | |||||
| std::string toNodeId; | |||||
| // 校验连接配置并通过 error 返回失败原因 | |||||
| bool validate(std::string *error = nullptr) const; | |||||
| }; | |||||
| // 描述一组完整的控制逻辑及其节点和连接 | |||||
| struct ControlLogic | |||||
| { | |||||
| // 控制逻辑唯一标识 | |||||
| std::string id; | |||||
| // 控制逻辑显示名称 | |||||
| std::string name; | |||||
| // 控制逻辑包含的节点集合 | |||||
| std::vector<LogicNode> nodes; | |||||
| // 控制逻辑包含的连接集合 | |||||
| std::vector<LogicConnection> connections; | |||||
| // 控制逻辑是否启用 | |||||
| bool enabled = true; | |||||
| // 校验控制逻辑并通过 error 返回失败原因 | |||||
| bool validate(std::string *error = nullptr) const; | |||||
| }; | |||||
| } // namespace integrated_platform::domain | |||||
| @@ -0,0 +1,100 @@ | |||||
| #include "hmi_model.h" | |||||
| #include <algorithm> | |||||
| namespace integrated_platform::domain { | |||||
| namespace { | |||||
| // 在提供错误字符串时写入校验失败原因 | |||||
| void setError(std::string *error, const std::string &message) | |||||
| { | |||||
| if (error != nullptr) | |||||
| { | |||||
| *error = message; | |||||
| } | |||||
| } | |||||
| // 判断控件是否必须绑定 M 区位地址 | |||||
| bool requiresBitBinding(HmiControlType type) | |||||
| { | |||||
| return type == HmiControlType::Button || type == HmiControlType::Indicator; | |||||
| } | |||||
| // 判断控件是否必须绑定 D 区字地址 | |||||
| bool requiresWordBinding(HmiControlType type) | |||||
| { | |||||
| return type == HmiControlType::NumericDisplay || type == HmiControlType::NumericInput; | |||||
| } | |||||
| } // namespace | |||||
| bool HmiControl::validate(std::string *error) const | |||||
| { | |||||
| if (id.empty()) | |||||
| { | |||||
| setError(error, "HMI control id must not be empty"); | |||||
| return false; | |||||
| } | |||||
| if (bounds.width <= 0 || bounds.height <= 0) | |||||
| { | |||||
| setError(error, "HMI control bounds must have positive size"); | |||||
| return false; | |||||
| } | |||||
| if (requiresBitBinding(type)) | |||||
| { | |||||
| if (!binding.has_value() || binding->area() != RegisterArea::M) | |||||
| { | |||||
| setError(error, "button and indicator controls require an M address"); | |||||
| return false; | |||||
| } | |||||
| } | |||||
| if (requiresWordBinding(type)) | |||||
| { | |||||
| if (!binding.has_value() || binding->area() != RegisterArea::D) | |||||
| { | |||||
| setError(error, "numeric controls require a D address"); | |||||
| return false; | |||||
| } | |||||
| } | |||||
| if (binding.has_value() && !binding->isValid()) | |||||
| { | |||||
| setError(error, "HMI control binding has an invalid address"); | |||||
| return false; | |||||
| } | |||||
| return true; | |||||
| } | |||||
| bool HmiPage::validate(std::string *error) const | |||||
| { | |||||
| if (id.empty() || name.empty()) | |||||
| { | |||||
| setError(error, "HMI page id and name must not be empty"); | |||||
| return false; | |||||
| } | |||||
| if (width <= 0 || height <= 0) | |||||
| { | |||||
| setError(error, "HMI page size must be positive"); | |||||
| return false; | |||||
| } | |||||
| std::vector<std::string> ids; | |||||
| ids.reserve(controls.size()); | |||||
| for (const HmiControl &control : controls) | |||||
| { | |||||
| if (!control.validate(error)) | |||||
| { | |||||
| return false; | |||||
| } | |||||
| // 检查控件 id 是否唯一 | |||||
| if (std::find(ids.cbegin(), ids.cend(), control.id) != ids.cend()) | |||||
| { | |||||
| setError(error, "HMI control ids must be unique within a page"); | |||||
| return false; | |||||
| } | |||||
| ids.push_back(control.id); | |||||
| } | |||||
| return true; | |||||
| } | |||||
| } // namespace integrated_platform::domain | |||||
| @@ -0,0 +1,61 @@ | |||||
| #pragma once | |||||
| #include "register_address.h" | |||||
| #include <map> | |||||
| #include <optional> | |||||
| #include <string> | |||||
| #include <vector> | |||||
| namespace integrated_platform::domain { | |||||
| // 描述 HMI 控件在页面中的位置和尺寸 | |||||
| struct HmiRect | |||||
| { | |||||
| static constexpr int kDefaultWidth = 80; | |||||
| static constexpr int kDefaultHeight = 32; | |||||
| int x = 0; | |||||
| int y = 0; | |||||
| int width = kDefaultWidth; | |||||
| int height = kDefaultHeight; | |||||
| }; | |||||
| // HMI 控件类型 | |||||
| enum class HmiControlType | |||||
| { | |||||
| Button, // 按钮控件 | |||||
| Indicator, // 指示灯控件 | |||||
| NumericDisplay, // 数值显示控件 | |||||
| NumericInput, // 数值输入控件 | |||||
| Label // 文本标签控件 | |||||
| }; | |||||
| // 描述一个 HMI 控件及其显示属性和寄存器绑定关系 | |||||
| struct HmiControl | |||||
| { | |||||
| std::string id; | |||||
| HmiControlType type = HmiControlType::Label; | |||||
| HmiRect bounds; | |||||
| std::string text; | |||||
| std::optional<RegisterAddress> binding; // 控件绑定的寄存器地址,可以没有绑定;例如按钮绑定 M100 | |||||
| std::map<std::string, std::string> properties; // 保存控件的扩展属性,例如颜色、字体、最小值等 | |||||
| // 校验控件配置并通过 error 返回失败原因 | |||||
| bool validate(std::string *error = nullptr) const; | |||||
| }; | |||||
| // 描述一个 HMI 页面及其包含的控件集合 | |||||
| struct HmiPage | |||||
| { | |||||
| std::string id; | |||||
| std::string name; | |||||
| int width = 800; | |||||
| int height = 480; | |||||
| std::vector<HmiControl> controls; | |||||
| // 校验页面配置和控件集合并通过 error 返回失败原因 | |||||
| bool validate(std::string *error = nullptr) const; | |||||
| }; | |||||
| } // namespace integrated_platform::domain | |||||
| @@ -0,0 +1,70 @@ | |||||
| #include "project_model.h" | |||||
| #include <algorithm> | |||||
| namespace integrated_platform::domain { | |||||
| namespace { | |||||
| void setError(std::string *error, const std::string &message) | |||||
| { | |||||
| if (error != nullptr) | |||||
| { | |||||
| *error = message; | |||||
| } | |||||
| } | |||||
| template <typename TItem> | |||||
| bool containsDuplicateId(const std::vector<TItem> &items) | |||||
| { | |||||
| for (auto current = items.cbegin(); current != items.cend(); ++current) | |||||
| { | |||||
| const auto duplicate = std::find_if( | |||||
| current + 1, | |||||
| items.cend(), | |||||
| [¤t](const TItem &item) { return item.id == current->id; }); | |||||
| if (duplicate != items.cend()) | |||||
| { | |||||
| return true; | |||||
| } | |||||
| } | |||||
| return false; | |||||
| } | |||||
| } // namespace | |||||
| bool Project::validate(std::string *error) const | |||||
| { | |||||
| if (metadata.id.empty() || metadata.name.empty() || metadata.formatVersion.empty()) | |||||
| { | |||||
| setError(error, "project id, name and format version must not be empty"); | |||||
| return false; | |||||
| } | |||||
| if (containsDuplicateId(hmiPages)) | |||||
| { | |||||
| setError(error, "HMI page ids must be unique within a project"); | |||||
| return false; | |||||
| } | |||||
| if (containsDuplicateId(controlLogics)) | |||||
| { | |||||
| setError(error, "control logic ids must be unique within a project"); | |||||
| return false; | |||||
| } | |||||
| for (const HmiPage &page : hmiPages) | |||||
| { | |||||
| if (!page.validate(error)) | |||||
| { | |||||
| return false; | |||||
| } | |||||
| } | |||||
| for (const ControlLogic &logic : controlLogics) | |||||
| { | |||||
| if (!logic.validate(error)) | |||||
| { | |||||
| return false; | |||||
| } | |||||
| } | |||||
| return true; | |||||
| } | |||||
| } // namespace integrated_platform::domain | |||||
| @@ -0,0 +1,36 @@ | |||||
| #pragma once | |||||
| #include "control_logic_model.h" | |||||
| #include "hmi_model.h" | |||||
| #include <string> | |||||
| #include <vector> | |||||
| namespace integrated_platform::domain { | |||||
| // 描述工程的基本身份和文件格式信息 | |||||
| struct ProjectMetadata | |||||
| { | |||||
| // 工程唯一标识 | |||||
| std::string id; | |||||
| // 工程显示名称 | |||||
| std::string name; | |||||
| // 工程文件格式版本 | |||||
| std::string formatVersion = "1.0"; | |||||
| }; | |||||
| // 聚合工程中的 HMI 页面和控制逻辑 | |||||
| struct Project | |||||
| { | |||||
| // 工程基本信息 | |||||
| ProjectMetadata metadata; | |||||
| // 工程包含的 HMI 页面集合 | |||||
| std::vector<HmiPage> hmiPages; | |||||
| // 工程包含的控制逻辑集合 | |||||
| std::vector<ControlLogic> controlLogics; | |||||
| // 校验工程配置并通过 error 返回失败原因 | |||||
| bool validate(std::string *error = nullptr) const; | |||||
| }; | |||||
| } // namespace integrated_platform::domain | |||||
| @@ -0,0 +1,64 @@ | |||||
| #include "register_address.h" | |||||
| namespace integrated_platform::domain { | |||||
| namespace { | |||||
| bool isSupportedArea(RegisterArea area) | |||||
| { | |||||
| return area == RegisterArea::M || area == RegisterArea::D; | |||||
| } | |||||
| } // namespace | |||||
| RegisterAddress::RegisterAddress(RegisterArea area, int index) | |||||
| : area_(area), | |||||
| index_(index) | |||||
| { | |||||
| } | |||||
| RegisterArea RegisterAddress::area() const | |||||
| { | |||||
| return area_; | |||||
| } | |||||
| int RegisterAddress::index() const | |||||
| { | |||||
| return index_; | |||||
| } | |||||
| bool RegisterAddress::isValid() const | |||||
| { | |||||
| return isSupportedArea(area_) | |||||
| && index_ >= kMinimumIndex && index_ <= kMaximumIndex; | |||||
| } | |||||
| std::string RegisterAddress::toString() const | |||||
| { | |||||
| const char *areaName = nullptr; | |||||
| switch (area_) | |||||
| { | |||||
| case RegisterArea::M: | |||||
| areaName = "M"; | |||||
| break; | |||||
| case RegisterArea::D: | |||||
| areaName = "D"; | |||||
| break; | |||||
| default: | |||||
| return "InvalidRegisterAddress"; | |||||
| } | |||||
| return std::string(areaName) + std::to_string(index_); | |||||
| } | |||||
| bool RegisterAddress::operator==(const RegisterAddress &other) const | |||||
| { | |||||
| return area_ == other.area_ && index_ == other.index_; | |||||
| } | |||||
| bool RegisterAddress::operator!=(const RegisterAddress &other) const | |||||
| { | |||||
| return !(*this == other); | |||||
| } | |||||
| } // namespace integrated_platform::domain | |||||
| @@ -0,0 +1,34 @@ | |||||
| #pragma once | |||||
| #include <string> | |||||
| namespace integrated_platform::domain { | |||||
| enum class RegisterArea | |||||
| { | |||||
| M, | |||||
| D | |||||
| }; | |||||
| class RegisterAddress | |||||
| { | |||||
| public: | |||||
| static constexpr int kMinimumIndex = 0; | |||||
| static constexpr int kMaximumIndex = 4000; | |||||
| RegisterAddress(RegisterArea area, int index); | |||||
| RegisterArea area() const; | |||||
| int index() const; | |||||
| bool isValid() const; | |||||
| std::string toString() const; | |||||
| bool operator==(const RegisterAddress &other) const; | |||||
| bool operator!=(const RegisterAddress &other) const; | |||||
| private: | |||||
| RegisterArea area_; | |||||
| int index_; | |||||
| }; | |||||
| } // namespace integrated_platform::domain | |||||
| @@ -0,0 +1,72 @@ | |||||
| #include "register_repository.h" | |||||
| namespace integrated_platform::domain { | |||||
| VirtualRegisterRepository::VirtualRegisterRepository() | |||||
| { | |||||
| clear(); | |||||
| } | |||||
| BitReadResult VirtualRegisterRepository::readBit(const RegisterAddress &address) const | |||||
| { | |||||
| if (!address.isValid()) | |||||
| { | |||||
| return {false, false, RegisterError::InvalidAddress}; | |||||
| } | |||||
| if (address.area() != RegisterArea::M) | |||||
| { | |||||
| return {false, false, RegisterError::AreaMismatch}; | |||||
| } | |||||
| return {true, bits_[static_cast<std::size_t>(address.index())], RegisterError::None}; | |||||
| } | |||||
| RegisterWriteResult VirtualRegisterRepository::writeBit( | |||||
| const RegisterAddress &address, bool value) | |||||
| { | |||||
| if (!address.isValid()) | |||||
| { | |||||
| return {false, RegisterError::InvalidAddress}; | |||||
| } | |||||
| if (address.area() != RegisterArea::M) | |||||
| { | |||||
| return {false, RegisterError::AreaMismatch}; | |||||
| } | |||||
| bits_[static_cast<std::size_t>(address.index())] = value; | |||||
| return {true, RegisterError::None}; | |||||
| } | |||||
| WordReadResult VirtualRegisterRepository::readWord(const RegisterAddress &address) const | |||||
| { | |||||
| if (!address.isValid()) | |||||
| { | |||||
| return {false, 0, RegisterError::InvalidAddress}; | |||||
| } | |||||
| if (address.area() != RegisterArea::D) | |||||
| { | |||||
| return {false, 0, RegisterError::AreaMismatch}; | |||||
| } | |||||
| return {true, words_[static_cast<std::size_t>(address.index())], RegisterError::None}; | |||||
| } | |||||
| RegisterWriteResult VirtualRegisterRepository::writeWord( | |||||
| const RegisterAddress &address, std::int16_t value) | |||||
| { | |||||
| if (!address.isValid()) | |||||
| { | |||||
| return {false, RegisterError::InvalidAddress}; | |||||
| } | |||||
| if (address.area() != RegisterArea::D) | |||||
| { | |||||
| return {false, RegisterError::AreaMismatch}; | |||||
| } | |||||
| words_[static_cast<std::size_t>(address.index())] = value; | |||||
| return {true, RegisterError::None}; | |||||
| } | |||||
| void VirtualRegisterRepository::clear() | |||||
| { | |||||
| bits_.fill(false); | |||||
| words_.fill(0); | |||||
| } | |||||
| } // namespace integrated_platform::domain | |||||
| @@ -0,0 +1,72 @@ | |||||
| #pragma once | |||||
| #include "register_address.h" | |||||
| #include <array> | |||||
| #include <cstddef> | |||||
| #include <cstdint> | |||||
| namespace integrated_platform::domain { | |||||
| enum class RegisterError | |||||
| { | |||||
| None, // 操作成功 无错误 | |||||
| InvalidAddress, // 地址超出 0~4000 或地址区域无效 | |||||
| AreaMismatch, // 操作类型与地址区域不匹配 例如用位操作访问 D 区 | |||||
| Unavailable, // 寄存器仓库当前不可用 例如尚未连接或没有有效数据 | |||||
| WriteRejected // 仓库拒绝写入请求 | |||||
| }; | |||||
| struct BitReadResult | |||||
| { | |||||
| bool succeeded = false; | |||||
| bool value = false; | |||||
| RegisterError error = RegisterError::Unavailable; | |||||
| }; | |||||
| struct WordReadResult | |||||
| { | |||||
| bool succeeded = false; | |||||
| std::int16_t value = 0; | |||||
| RegisterError error = RegisterError::Unavailable; | |||||
| }; | |||||
| struct RegisterWriteResult | |||||
| { | |||||
| bool succeeded = false; | |||||
| RegisterError error = RegisterError::Unavailable; | |||||
| }; | |||||
| class RegisterRepository | |||||
| { | |||||
| public: | |||||
| virtual ~RegisterRepository() = default; | |||||
| virtual BitReadResult readBit(const RegisterAddress &address) const = 0; | |||||
| virtual RegisterWriteResult writeBit(const RegisterAddress &address, bool value) = 0; | |||||
| virtual WordReadResult readWord(const RegisterAddress &address) const = 0; | |||||
| virtual RegisterWriteResult writeWord( | |||||
| const RegisterAddress &address, std::int16_t value) = 0; | |||||
| }; | |||||
| class VirtualRegisterRepository : public RegisterRepository | |||||
| { | |||||
| public: | |||||
| VirtualRegisterRepository(); | |||||
| BitReadResult readBit(const RegisterAddress &address) const override; | |||||
| RegisterWriteResult writeBit(const RegisterAddress &address, bool value) override; | |||||
| WordReadResult readWord(const RegisterAddress &address) const override; | |||||
| RegisterWriteResult writeWord( | |||||
| const RegisterAddress &address, std::int16_t value) override; | |||||
| void clear(); | |||||
| private: | |||||
| static constexpr std::size_t kRegisterCount = | |||||
| static_cast<std::size_t>(RegisterAddress::kMaximumIndex + 1); | |||||
| std::array<bool, kRegisterCount> bits_{}; | |||||
| std::array<std::int16_t, kRegisterCount> words_{}; | |||||
| }; | |||||
| } // namespace integrated_platform::domain | |||||
| @@ -0,0 +1,57 @@ | |||||
| #include "runtime_state.h" | |||||
| namespace integrated_platform::domain { | |||||
| ApplicationMode RuntimeState::mode() const | |||||
| { | |||||
| return mode_; | |||||
| } | |||||
| ModePolicy RuntimeState::policy() const | |||||
| { | |||||
| return policyForMode(mode_); | |||||
| } | |||||
| ModeTransitionResult RuntimeState::enterEditing() | |||||
| { | |||||
| if (mode_ == ApplicationMode::Editing) | |||||
| { | |||||
| return {false, ModeTransitionError::AlreadyInRequestedMode}; | |||||
| } | |||||
| mode_ = ApplicationMode::Editing; | |||||
| return {true, ModeTransitionError::None}; | |||||
| } | |||||
| ModeTransitionResult RuntimeState::enterOfflineRunning() | |||||
| { | |||||
| if (mode_ == ApplicationMode::OfflineRunning) | |||||
| { | |||||
| return {false, ModeTransitionError::AlreadyInRequestedMode}; | |||||
| } | |||||
| if (mode_ != ApplicationMode::Editing) | |||||
| { | |||||
| return {false, ModeTransitionError::MustReturnToEditing}; | |||||
| } | |||||
| mode_ = ApplicationMode::OfflineRunning; | |||||
| return {true, ModeTransitionError::None}; | |||||
| } | |||||
| ModeTransitionResult RuntimeState::enterOnlineRunning(bool initial_plc_read_completed) | |||||
| { | |||||
| if (mode_ == ApplicationMode::OnlineRunning) | |||||
| { | |||||
| return {false, ModeTransitionError::AlreadyInRequestedMode}; | |||||
| } | |||||
| if (mode_ != ApplicationMode::Editing) | |||||
| { | |||||
| return {false, ModeTransitionError::MustReturnToEditing}; | |||||
| } | |||||
| if (!initial_plc_read_completed) | |||||
| { | |||||
| return {false, ModeTransitionError::InitialPlcReadRequired}; | |||||
| } | |||||
| mode_ = ApplicationMode::OnlineRunning; | |||||
| return {true, ModeTransitionError::None}; | |||||
| } | |||||
| } // namespace integrated_platform::domain | |||||
| @@ -0,0 +1,95 @@ | |||||
| #pragma once | |||||
| namespace integrated_platform::domain { | |||||
| // 应用当前运行模式 | |||||
| enum class ApplicationMode | |||||
| { | |||||
| Editing, // 编辑工程状态 | |||||
| OfflineRunning, // 离线仿真运行状态 | |||||
| OnlineRunning // 真机联机运行状态 | |||||
| }; | |||||
| // 当前运行模式对应的能力策略 | |||||
| struct ModePolicy | |||||
| { | |||||
| // 是否允许编辑工程 | |||||
| bool allowsProjectEditing = false; | |||||
| // 是否使用虚拟寄存器 | |||||
| bool usesVirtualRegisters = false; | |||||
| // 是否使用 PLC 寄存器 | |||||
| bool usesPlcRegisters = false; | |||||
| // 是否运行软件逻辑执行器 | |||||
| bool runsLogicExecutor = false; | |||||
| // 是否要求进入真机前完成 PLC 初次读取 | |||||
| bool requiresInitialPlcRead = false; | |||||
| }; | |||||
| // 根据运行模式返回对应的能力策略 | |||||
| constexpr ModePolicy policyForMode(ApplicationMode mode) | |||||
| { | |||||
| switch (mode) | |||||
| { | |||||
| case ApplicationMode::Editing: | |||||
| { | |||||
| return {true, false, false, false, false}; | |||||
| } | |||||
| case ApplicationMode::OfflineRunning: | |||||
| { | |||||
| return {false, true, false, true, false}; | |||||
| } | |||||
| case ApplicationMode::OnlineRunning: | |||||
| { | |||||
| return {false, false, true, false, true}; | |||||
| } | |||||
| default: | |||||
| { | |||||
| return {}; | |||||
| } | |||||
| } | |||||
| } | |||||
| // 模式切换失败原因 | |||||
| enum class ModeTransitionError | |||||
| { | |||||
| None, // 切换成功 | |||||
| AlreadyInRequestedMode, // 已经处于目标模式 | |||||
| MustReturnToEditing, // 必须先返回编辑态 | |||||
| InitialPlcReadRequired // 进入真机态前必须完成 PLC 初次读取 | |||||
| }; | |||||
| // 模式切换操作结果 | |||||
| struct ModeTransitionResult | |||||
| { | |||||
| // 是否切换成功 | |||||
| bool succeeded = false; | |||||
| // 切换失败原因 | |||||
| ModeTransitionError error = ModeTransitionError::None; | |||||
| }; | |||||
| class RuntimeState | |||||
| { | |||||
| public: | |||||
| // 获取当前运行模式 | |||||
| ApplicationMode mode() const; | |||||
| // 获取当前模式能力策略 | |||||
| ModePolicy policy() const; | |||||
| // 返回编辑态 | |||||
| ModeTransitionResult enterEditing(); | |||||
| // 进入离线仿真运行态 | |||||
| ModeTransitionResult enterOfflineRunning(); | |||||
| /** | |||||
| * @brief 尝试进入真机联机运行态 | |||||
| * @param initial_plc_read_completed 是否已成功完成 PLC 初次读取并建立有效缓存 | |||||
| * @return 模式切换结果 | |||||
| */ | |||||
| ModeTransitionResult enterOnlineRunning(bool initial_plc_read_completed); | |||||
| private: | |||||
| // 当前运行模式 初始为编辑态 | |||||
| ApplicationMode mode_ = ApplicationMode::Editing; | |||||
| }; | |||||
| } // namespace integrated_platform::domain | |||||
| @@ -0,0 +1 @@ | |||||
| @@ -2,7 +2,7 @@ | |||||
| * @file main.cpp | * @file main.cpp | ||||
| * @brief 综合平台编程器应用程序入口。 | * @brief 综合平台编程器应用程序入口。 | ||||
| * @version 0.1.0 | * @version 0.1.0 | ||||
| * @author QtProXinJe | |||||
| * @author suyu | |||||
| * @date 2026-08-05 | * @date 2026-08-05 | ||||
| */ | */ | ||||
| @@ -0,0 +1 @@ | |||||
| @@ -2,7 +2,7 @@ | |||||
| * @file main_window.cpp | * @file main_window.cpp | ||||
| * @brief 实现综合平台编程器的主窗口。 | * @brief 实现综合平台编程器的主窗口。 | ||||
| * @version 0.1.0 | * @version 0.1.0 | ||||
| * @author QtProXinJe | |||||
| * @author suyu | |||||
| * @date 2026-08-05 | * @date 2026-08-05 | ||||
| */ | */ | ||||
| @@ -2,7 +2,7 @@ | |||||
| * @file main_window.h | * @file main_window.h | ||||
| * @brief 定义综合平台编程器的主窗口。 | * @brief 定义综合平台编程器的主窗口。 | ||||
| * @version 0.1.0 | * @version 0.1.0 | ||||
| * @author QtProXinJe | |||||
| * @author suyu | |||||
| * @date 2026-08-05 | * @date 2026-08-05 | ||||
| */ | */ | ||||
| @@ -20,26 +20,13 @@ QT_END_NAMESPACE | |||||
| namespace integrated_platform { | namespace integrated_platform { | ||||
| /** | |||||
| * @brief 提供应用程序的基础窗口与状态栏。 | |||||
| * | |||||
| * 当前阶段只负责加载 Qt Designer 界面。后续业务模块通过独立服务接入, | |||||
| * 不在主窗口中实现 PLC 通信或控制逻辑。 | |||||
| */ | |||||
| class MainWindow final : public QMainWindow | class MainWindow final : public QMainWindow | ||||
| { | { | ||||
| Q_OBJECT | Q_OBJECT | ||||
| public: | public: | ||||
| /** | |||||
| * @brief 创建主窗口。 | |||||
| * @param parent Qt 父对象,可为空。 | |||||
| */ | |||||
| explicit MainWindow(QWidget *parent = nullptr); | explicit MainWindow(QWidget *parent = nullptr); | ||||
| /** | |||||
| * @brief 销毁主窗口及其 Designer 界面对象。 | |||||
| */ | |||||
| ~MainWindow() override; | ~MainWindow() override; | ||||
| private: | private: | ||||
| @@ -10,7 +10,7 @@ | |||||
| <height>640</height> | <height>640</height> | ||||
| </rect> | </rect> | ||||
| </property> | </property> | ||||
| <property name="windowTitle"> | |||||
| <property name="windowTitle"> | |||||
| <string>综合平台编程器</string> | <string>综合平台编程器</string> | ||||
| </property> | </property> | ||||
| <widget class="QWidget" name="centralWidget"> | <widget class="QWidget" name="centralWidget"> | ||||
| @@ -0,0 +1 @@ | |||||
| @@ -0,0 +1,174 @@ | |||||
| #include "domain/control_logic_model.h" | |||||
| #include "domain/hmi_model.h" | |||||
| #include "domain/project_model.h" | |||||
| #include "domain/register_address.h" | |||||
| #include "domain/register_repository.h" | |||||
| #include "domain/runtime_state.h" | |||||
| #include <cstdint> | |||||
| #include <exception> | |||||
| #include <iostream> | |||||
| #include <stdexcept> | |||||
| #include <string> | |||||
| namespace domain = integrated_platform::domain; | |||||
| namespace { | |||||
| void require(bool condition, const std::string &message) | |||||
| { | |||||
| if (!condition) | |||||
| { | |||||
| throw std::runtime_error(message); | |||||
| } | |||||
| } | |||||
| void testRegisterAddressBoundaries() | |||||
| { | |||||
| require(domain::RegisterAddress{domain::RegisterArea::M, 0}.isValid(), | |||||
| "M0 must be valid"); | |||||
| require(domain::RegisterAddress{domain::RegisterArea::D, 4000}.isValid(), | |||||
| "D4000 must be valid"); | |||||
| require(!(domain::RegisterAddress{domain::RegisterArea::M, -1}.isValid()), | |||||
| "negative register index must be rejected"); | |||||
| require(!(domain::RegisterAddress{domain::RegisterArea::D, 4001}.isValid()), | |||||
| "register index above 4000 must be rejected"); | |||||
| require(!(domain::RegisterAddress{static_cast<domain::RegisterArea>(99), 0}.isValid()), | |||||
| "unknown register area must be rejected"); | |||||
| } | |||||
| void testRegisterRepositorySeparatesAreas() | |||||
| { | |||||
| domain::VirtualRegisterRepository repository; | |||||
| const domain::RegisterAddress m0{domain::RegisterArea::M, 0}; | |||||
| const domain::RegisterAddress d0{domain::RegisterArea::D, 0}; | |||||
| require(repository.writeBit(m0, true).succeeded, "M bit write must succeed"); | |||||
| require(repository.readBit(m0).value, "M bit read must return written value"); | |||||
| require(repository.writeWord(d0, static_cast<std::int16_t>(-123)).succeeded, | |||||
| "D word write must succeed"); | |||||
| require(repository.readWord(d0).value == -123, "D word read must return written value"); | |||||
| require(repository.readBit(d0).error == domain::RegisterError::AreaMismatch, | |||||
| "D address must not be read as a bit"); | |||||
| require(repository.readWord(m0).error == domain::RegisterError::AreaMismatch, | |||||
| "M address must not be read as a word"); | |||||
| } | |||||
| domain::Project makeValidProject() | |||||
| { | |||||
| domain::HmiControl start_button; | |||||
| start_button.id = "start-button"; | |||||
| start_button.type = domain::HmiControlType::Button; | |||||
| start_button.text = "Start"; | |||||
| start_button.binding = {domain::RegisterArea::M, 0}; | |||||
| domain::HmiPage page; | |||||
| page.id = "main-page"; | |||||
| page.name = "Main"; | |||||
| page.controls.push_back(start_button); | |||||
| domain::LogicNode contact; | |||||
| contact.id = "start-contact"; | |||||
| contact.config = domain::ContactNodeConfig{ | |||||
| domain::RegisterAddress{domain::RegisterArea::M, 0}, | |||||
| domain::ContactMode::NormallyOpen}; | |||||
| domain::LogicNode coil; | |||||
| coil.id = "run-coil"; | |||||
| coil.config = domain::CoilNodeConfig{ | |||||
| domain::RegisterAddress{domain::RegisterArea::M, 1}, | |||||
| domain::CoilMode::Normal}; | |||||
| domain::ControlLogic logic; | |||||
| logic.id = "start-logic"; | |||||
| logic.name = "Start logic"; | |||||
| logic.nodes = {contact, coil}; | |||||
| logic.connections.push_back({contact.id, coil.id}); | |||||
| domain::Project project; | |||||
| project.metadata = {"sample-project", "Sample project", "1.0"}; | |||||
| project.hmiPages.push_back(page); | |||||
| project.controlLogics.push_back(logic); | |||||
| return project; | |||||
| } | |||||
| void testLogicNodeConfigurationBoundaries() | |||||
| { | |||||
| domain::LogicNode contact; | |||||
| contact.id = "contact"; | |||||
| contact.config = domain::ContactNodeConfig{ | |||||
| domain::RegisterAddress{domain::RegisterArea::M, 0}, | |||||
| domain::ContactMode::NormallyOpen}; | |||||
| require(contact.validate(), "contact node bound to M address must be valid"); | |||||
| contact.config = domain::ContactNodeConfig{ | |||||
| domain::RegisterAddress{domain::RegisterArea::D, 0}, | |||||
| domain::ContactMode::NormallyOpen}; | |||||
| require(!contact.validate(), "contact node bound to D address must be rejected"); | |||||
| domain::LogicNode comparison; | |||||
| comparison.id = "comparison"; | |||||
| comparison.config = domain::CompareNodeConfig{ | |||||
| domain::RegisterAddress{domain::RegisterArea::D, 0}, | |||||
| domain::ComparisonOperator::GreaterThan, | |||||
| static_cast<std::int16_t>(100)}; | |||||
| require(comparison.validate(), "comparison node bound to D address must be valid"); | |||||
| } | |||||
| void testModelsValidateBindingsAndIdentifiers() | |||||
| { | |||||
| domain::Project project = makeValidProject(); | |||||
| require(project.validate(), "valid project model must pass validation"); | |||||
| project.hmiPages.front().controls.front().binding = | |||||
| domain::RegisterAddress{domain::RegisterArea::D, 0}; | |||||
| require(!project.validate(), "button bound to D area must be rejected"); | |||||
| project = makeValidProject(); | |||||
| project.hmiPages.push_back(project.hmiPages.front()); | |||||
| require(!project.validate(), "duplicate HMI page id must be rejected"); | |||||
| } | |||||
| void testRuntimeStateBoundaries() | |||||
| { | |||||
| domain::RuntimeState state; | |||||
| require(state.policy().allowsProjectEditing, "editing mode must allow project editing"); | |||||
| require(state.enterOfflineRunning().succeeded, "editing may enter offline running"); | |||||
| require(state.policy().usesVirtualRegisters, "offline mode must use virtual registers"); | |||||
| require(state.policy().runsLogicExecutor, "offline mode must run logic executor"); | |||||
| require(state.enterOnlineRunning(true).error | |||||
| == domain::ModeTransitionError::MustReturnToEditing, | |||||
| "offline mode must not directly enter online mode"); | |||||
| require(state.enterEditing().succeeded, "offline mode may return to editing"); | |||||
| require(state.enterOnlineRunning(false).error | |||||
| == domain::ModeTransitionError::InitialPlcReadRequired, | |||||
| "online mode must require an initial PLC read"); | |||||
| require(state.enterOnlineRunning(true).succeeded, | |||||
| "editing may enter online mode after initial PLC read"); | |||||
| require(!state.policy().runsLogicExecutor, | |||||
| "online mode must keep the software logic executor stopped"); | |||||
| require(state.policy().usesPlcRegisters, "online mode must use PLC registers"); | |||||
| } | |||||
| } // namespace | |||||
| int main() | |||||
| { | |||||
| try | |||||
| { | |||||
| testRegisterAddressBoundaries(); | |||||
| testRegisterRepositorySeparatesAreas(); | |||||
| testLogicNodeConfigurationBoundaries(); | |||||
| testModelsValidateBindingsAndIdentifiers(); | |||||
| testRuntimeStateBoundaries(); | |||||
| } | |||||
| catch (const std::exception &error) | |||||
| { | |||||
| std::cerr << "domain tests failed: " << error.what() << '\n'; | |||||
| return 1; | |||||
| } | |||||
| std::cout << "domain tests passed\n"; | |||||
| return 0; | |||||
| } | |||||
| @@ -0,0 +1,24 @@ | |||||
| TEMPLATE = app | |||||
| TARGET = domain_tests | |||||
| CONFIG += console c++17 testcase warn_on | |||||
| CONFIG -= app_bundle qt | |||||
| INCLUDEPATH += ../src | |||||
| SOURCES += \ | |||||
| domain_tests.cpp \ | |||||
| ../src/domain/register_address.cpp \ | |||||
| ../src/domain/register_repository.cpp \ | |||||
| ../src/domain/hmi_model.cpp \ | |||||
| ../src/domain/control_logic_model.cpp \ | |||||
| ../src/domain/project_model.cpp \ | |||||
| ../src/domain/runtime_state.cpp | |||||
| HEADERS += \ | |||||
| ../src/domain/register_address.h \ | |||||
| ../src/domain/register_repository.h \ | |||||
| ../src/domain/hmi_model.h \ | |||||
| ../src/domain/control_logic_model.h \ | |||||
| ../src/domain/project_model.h \ | |||||
| ../src/domain/runtime_state.h | |||||
| @@ -54,10 +54,10 @@ | |||||
| 2. 注释应简洁、准确、无歧义,说明代码难以直接表达的意图、约束、风险或实现原因,不得简单重复代码含义。 | 2. 注释应简洁、准确、无歧义,说明代码难以直接表达的意图、约束、风险或实现原因,不得简单重复代码含义。 | ||||
| 3. 修改代码时必须同步更新相关注释。 | 3. 修改代码时必须同步更新相关注释。 | ||||
| 4. 同一类注释必须采用统一风格;单行注释使用 `//`,多行注释使用 `/* */`。 | 4. 同一类注释必须采用统一风格;单行注释使用 `//`,多行注释使用 `/* */`。 | ||||
| 5. 需要生成接口文档时,文件、类、结构体、枚举和对外接口应使用统一的 Doxygen 风格注释。 | |||||
| 6. 文件头注释应包含文件名、功能描述、版本、作者、日期和必要的修改记录。 | |||||
| 7. 类注释应说明类的功能、使用场景、使用方法、注意事项和风险点;功能显而易见的简单类可省略。 | |||||
| 8. 对外接口函数声明前必须说明功能、参数输入输出属性、返回值、异常或错误情况及使用限制。 | |||||
| 5. 需要生成接口文档,或接口契约无法从名称和类型直接理解时,使用统一的 Doxygen 风格注释。 | |||||
| 6. 文件头、类注释和函数注释不是强制项;仅在职责、使用限制或风险不直观时添加。 | |||||
| 7. 简单构造函数、析构函数、访问器、显而易见的私有辅助函数和简单数据成员不写注释。 | |||||
| 8. 对外复杂接口应说明关键参数约束、返回值、错误情况和使用限制。 | |||||
| 9. 函数实现处仅对关键实现细节、复杂逻辑或性能决策进行注释。 | 9. 函数实现处仅对关键实现细节、复杂逻辑或性能决策进行注释。 | ||||
| 10. 无法通过名称表达用途或存在特殊逻辑的数据成员、全局变量和常量必须添加注释。 | 10. 无法通过名称表达用途或存在特殊逻辑的数据成员、全局变量和常量必须添加注释。 | ||||
| 11. 临时方案、已知缺陷或后续优化项使用统一的 `TODO(责任人): 描述` 格式标记。 | 11. 临时方案、已知缺陷或后续优化项使用统一的 `TODO(责任人): 描述` 格式标记。 | ||||
| @@ -106,6 +106,6 @@ | |||||
| 1. 关系紧密的代码应相邻放置;无关语句不得混入同一函数或代码块。 | 1. 关系紧密的代码应相邻放置;无关语句不得混入同一函数或代码块。 | ||||
| 2. 项目或产品内必须统一调试开关和调试输出函数。 | 2. 项目或产品内必须统一调试开关和调试输出函数。 | ||||
| 3. 调试信息格式必须统一,且至少包含模块名或源文件名及行号。 | 3. 调试信息格式必须统一,且至少包含模块名或源文件名及行号。 | ||||
| 4. 编码时应同步设计单元测试点、测试代码和测试用例;测试代码应可通过调试开关独立启用或移除。 | |||||
| 4. 编码时应同步识别高风险业务规则并设计对应测试点;不要求为简单访问器和无分支转发函数重复编写测试,测试代码应可独立构建和运行。 | |||||
| 5. 集成测试或系统联调前必须准备测试环境、测试项目和测试用例,并持续优化测试用例。 | 5. 集成测试或系统联调前必须准备测试环境、测试项目和测试用例,并持续优化测试用例。 | ||||
| 6. 应合理使用断言尽早发现不符合预期的软件状态。 | 6. 应合理使用断言尽早发现不符合预期的软件状态。 | ||||
| @@ -30,11 +30,12 @@ M 是普通辅助继电器,不可直接驱动外部负载;真机设备是否 | |||||
| | 常闭触点 | 绑定位为 `false` 时导通。 | 停止、互锁、故障条件。 | | | 常闭触点 | 绑定位为 `false` 时导通。 | 停止、互锁、故障条件。 | | ||||
| | 普通线圈 | 网络导通时写入目标 M 位。 | 运行状态。 | | | 普通线圈 | 网络导通时写入目标 M 位。 | 运行状态。 | | ||||
| | 置位/复位线圈 | 将目标 M 位锁存为开或关。 | 启动保持、故障复位。 | | | 置位/复位线圈 | 将目标 M 位锁存为开或关。 | 启动保持、故障复位。 | | ||||
| | 数值比较 | 比较 D 值与常量或另一 D 值。 | 水位、温度、剩余时间判断。 | | |||||
| | 延时定时器 | 条件连续满足指定时长后输出。 | 洗涤等待、报警延时。 | | |||||
| | 数值比较 | 比较 D 值与常量。 | 水位、温度、剩余量判断。 | | |||||
| 离线仿真按固定扫描周期执行:读取当前 M/D 值,计算每条网络,提交写入,再刷新 HMI。该语义用于验证项目示例逻辑,不承诺与 XDH 的全部指令、扫描细节和固件行为完全一致。 | 离线仿真按固定扫描周期执行:读取当前 M/D 值,计算每条网络,提交写入,再刷新 HMI。该语义用于验证项目示例逻辑,不承诺与 XDH 的全部指令、扫描细节和固件行为完全一致。 | ||||
| 定时器以及 D 寄存器之间的比较属于可选扩展,不在当前原始需求范围内。后续只有在需求明确时才扩展领域模型、编辑器和逻辑执行器,不能因为厂商手册提供相关指令就默认纳入项目范围。 | |||||
| ## 3. XDH 的 Modbus 地址映射 | ## 3. XDH 的 Modbus 地址映射 | ||||
| ### 3.1 项目可直接使用的映射 | ### 3.1 项目可直接使用的映射 | ||||
| @@ -1,8 +1,8 @@ | |||||
| # Current Handoff | # Current Handoff | ||||
| - Goal: 完成综合平台编程器的 Qt 开发基线。 | |||||
| - Goal: 完成综合平台编程器的核心数据模型与接口。 | |||||
| - Branch: `main`。 | - Branch: `main`。 | ||||
| - Current status: 已初始化 Git,并建立可构建的 Qt Widgets 空工程。 | |||||
| - Current status: 已完成开发顺序第 3 步,核心领域模型、寄存器仓库接口和运行模式边界已定义。 | |||||
| - Changed files: | - Changed files: | ||||
| - `app/integrated_platform.pro` | - `app/integrated_platform.pro` | ||||
| - `app/src/main.cpp` | - `app/src/main.cpp` | ||||
| @@ -10,14 +10,50 @@ | |||||
| - `app/src/ui/main_window.cpp` | - `app/src/ui/main_window.cpp` | ||||
| - `app/src/ui/main_window.ui` | - `app/src/ui/main_window.ui` | ||||
| - `AGENTS.md` | - `AGENTS.md` | ||||
| - `docs/C++代码规范.md` | |||||
| - `docs/architecture.md` | |||||
| - `app/src/domain/.gitkeep` | |||||
| - `app/src/services/.gitkeep` | |||||
| - `app/src/infrastructure/.gitkeep` | |||||
| - `app/tests/.gitkeep` | |||||
| - `scripts/build_and_run_qt.ps1` | |||||
| - `app/src/domain/register_address.h` | |||||
| - `app/src/domain/register_address.cpp` | |||||
| - `app/src/domain/register_repository.h` | |||||
| - `app/src/domain/register_repository.cpp` | |||||
| - `app/src/domain/hmi_model.h` | |||||
| - `app/src/domain/hmi_model.cpp` | |||||
| - `app/src/domain/control_logic_model.h` | |||||
| - `app/src/domain/control_logic_model.cpp` | |||||
| - `app/src/domain/project_model.h` | |||||
| - `app/src/domain/project_model.cpp` | |||||
| - `app/src/domain/runtime_state.h` | |||||
| - `app/src/domain/runtime_state.cpp` | |||||
| - `app/tests/domain_tests.pro` | |||||
| - `app/tests/domain_tests.cpp` | |||||
| - Decisions made: | - Decisions made: | ||||
| - Qt 应用源码位于 `app/`,构建输出位于被忽略的 `build/`。 | - Qt 应用源码位于 `app/`,构建输出位于被忽略的 `build/`。 | ||||
| - 使用 qmake、Qt Widgets、Qt SerialBus 和 MinGW 8.1。 | - 使用 qmake、Qt Widgets、Qt SerialBus 和 MinGW 8.1。 | ||||
| - 主窗口只承载基础界面,不承担 PLC 通信或控制逻辑。 | - 主窗口只承载基础界面,不承担 PLC 通信或控制逻辑。 | ||||
| - 简单函数、构造函数和析构函数不添加重复性注释。 | |||||
| - 生产代码按 `UI -> Services -> Domain` 的方向依赖;基础设施实现领域接口。 | |||||
| - 领域模型仅使用 C++17 标准库,不依赖 Qt UI、串口或 Modbus。 | |||||
| - M 地址只承载布尔值,D 地址只承载带符号 16 位值,地址范围统一为 `0~4000`。 | |||||
| - `RegisterRepository` 统一虚拟寄存器和真实 PLC 缓存的访问契约。 | |||||
| - 控制逻辑节点使用 `std::variant` 组合触点、线圈和数值比较的独立配置类型。 | |||||
| - 当前控制逻辑范围不包含定时器;不得因为设备手册或旧开发计划提前加入未确认功能。 | |||||
| - 新增逻辑节点时增加独立配置类型,不向通用节点结构堆叠无关可选字段。 | |||||
| - 测试按业务风险分级 优先覆盖领域规则 服务闭环 工程存储和通信异常 不为简单访问器堆叠单测。 | |||||
| - 运行态之间不可直接切换;真机运行态必须先完成 PLC 初次读取,且不运行软件逻辑执行器。 | |||||
| - Validation run and results: | - Validation run and results: | ||||
| - 在 `build/baseline/` 执行 qmake 与 `mingw32-make -j2`,构建成功。 | - 在 `build/baseline/` 执行 qmake 与 `mingw32-make -j2`,构建成功。 | ||||
| - 已启动 `integrated_platform.exe` 并正常退出。 | - 已启动 `integrated_platform.exe` 并正常退出。 | ||||
| - 已通过 `scripts/build_and_run_qt.ps1` 完成 Debug 构建和启动验证。 | |||||
| - 新增分层目录后再次完成 Debug 构建和启动验证。 | |||||
| - 已确认构建目录、临时目录和本地草稿文档被 `.gitignore` 忽略。 | - 已确认构建目录、临时目录和本地草稿文档被 `.gitignore` 忽略。 | ||||
| - Remaining work: 进入开发顺序第 2 步,搭建分层目录与架构文档。 | |||||
| - 在 `build/core-model/` 重新执行 qmake 和 `mingw32-make -j2`,应用构建成功。 | |||||
| - 在 `build/domain-tests/` 构建并运行领域测试,输出 `domain tests passed`。 | |||||
| - 控制逻辑节点配置重构后再次构建应用和领域测试,均通过。 | |||||
| - Remaining work: 进入开发顺序第 4 步,实现工程新建、保存、另存为和加载。 | |||||
| - Known risks / blockers: 无。 | - Known risks / blockers: 无。 | ||||
| - Suggested next command: 在 `build/baseline/` 中执行 qmake 与 `mingw32-make`。 | |||||
| - Suggested next command: 基于 `Project` 领域模型设计版本化的工程文件 DTO 与存储接口。 | |||||
| @@ -0,0 +1,97 @@ | |||||
| # 综合平台编程器架构 | |||||
| ## 目标 | |||||
| 项目采用 UI、领域、服务和基础设施分层。HMI 编辑、离线仿真和真机联机使用同一套领域模型,UI 不直接依赖串口或 Modbus 实现。 | |||||
| ## 目录职责 | |||||
| ```text | |||||
| app/ | |||||
| ├── integrated_platform.pro qmake 工程入口 | |||||
| ├── src/ | |||||
| │ ├── main.cpp 应用组合入口 | |||||
| │ ├── ui/ Qt Designer 界面、视图和交互协调 | |||||
| │ ├── domain/ 工程、HMI、逻辑、M/D 地址等纯业务模型 | |||||
| │ ├── services/ 用例编排、运行模式和仿真服务 | |||||
| │ └── infrastructure/ 工程文件、Modbus RTU 等外部实现 | |||||
| └── tests/ 单元测试与集成测试工程 | |||||
| ``` | |||||
| ## 依赖方向 | |||||
| 生产代码必须保持单向依赖: | |||||
| ```text | |||||
| UI -> Services -> Domain | |||||
| Infrastructure -> Domain | |||||
| main.cpp -> UI + Services + Infrastructure | |||||
| ``` | |||||
| - `domain` 不依赖 UI、串口、文件系统或具体 PLC 库。 | |||||
| - `services` 只通过领域定义的接口使用寄存器、工程存储等能力。 | |||||
| - `infrastructure` 实现外部能力,例如 JSON 工程文件和 Qt Modbus RTU。 | |||||
| - `ui` 只调用服务并展示状态;`MainWindow` 只组织界面,不实现业务规则、寄存器读写或通信流程。 | |||||
| - `main.cpp` 负责创建对象并注入依赖,不承载业务逻辑。 | |||||
| ## 核心数据流 | |||||
| ```text | |||||
| 用户操作 HMI | |||||
| -> UI 发起服务请求 | |||||
| -> 服务校验并更新领域模型或寄存器接口 | |||||
| -> 仿真服务 / 真机 Modbus 实现执行 | |||||
| -> 服务发布状态变化 | |||||
| -> UI 刷新控件和状态栏 | |||||
| ``` | |||||
| 离线模式由服务连接虚拟 M/D 寄存器与软件逻辑执行器;真机模式由服务连接 PLC 寄存器缓存与 Modbus RTU 服务。两种模式共用 HMI 绑定模型,且软件逻辑执行器不得在真机模式运行。 | |||||
| ## 核心领域模型 | |||||
| 领域模型位于 `app/src/domain/`,只使用 C++17 标准库,不包含 Qt 界面、串口或 Modbus | |||||
| 类型: | |||||
| | 文件 | 职责 | | |||||
| | --- | --- | | |||||
| | `register_address.*` | 定义 M/D 地址值对象,统一校验项目地址范围 `0~4000`。 | | |||||
| | `register_repository.*` | 定义 M 位和 D 字的统一读写接口,并提供离线内存实现。 | | |||||
| | `hmi_model.*` | 定义 HMI 页面、控件、位置、扩展属性和寄存器绑定。 | | |||||
| | `control_logic_model.*` | 使用独立配置类型定义触点、线圈和数值比较节点,并校验地址区域与连接引用。 | | |||||
| | `project_model.*` | 聚合工程元数据、HMI 页面和控制逻辑,并校验工程内标识唯一性。 | | |||||
| | `runtime_state.*` | 定义编辑态、离线运行态、真机运行态及合法切换规则。 | | |||||
| `RegisterRepository` 是 HMI、逻辑执行器和运行服务唯一可见的寄存器边界。离线运行使用 | |||||
| `VirtualRegisterRepository`;真机运行后续注入由基础设施层实现的 PLC 缓存仓库。仓库读取的是 | |||||
| 最近一次有效值,写入成功只表示请求被仓库接受;真实 PLC 的异步确认由服务层负责反馈。 | |||||
| 控制逻辑节点使用 `std::variant` 组合独立配置类型。初版只包含 M 区触点、M 区线圈和 D 值与 | |||||
| 常量比较。新增节点时应增加独立配置类型及其校验和执行处理,不得向通用 `LogicNode` 持续 | |||||
| 添加只对单一节点有效的可选字段。定时器不是当前原始需求范围,不提前建立模型或执行逻辑。 | |||||
| ## 运行模式边界 | |||||
| | 模式 | 允许编辑工程 | 寄存器来源 | 软件逻辑执行器 | 进入约束 | | |||||
| | --- | --- | --- | --- | --- | | |||||
| | 编辑态 | 是 | 无运行仓库 | 停止 | 运行态必须先回到编辑态。 | | |||||
| | 离线运行态 | 否 | 虚拟 M/D | 运行 | 仅可从编辑态进入。 | | |||||
| | 真机运行态 | 否 | PLC 读回缓存 | 停止 | 仅可从编辑态进入,且必须先完成 PLC 初次读取。 | | |||||
| 离线运行态与真机运行态不得直接互切。进入真机运行态时不复制或写入离线仓库值,避免误写 | |||||
| PLC;退出任一运行态后才能重新编辑工程。 | |||||
| ## 测试边界 | |||||
| 测试按业务风险和模块边界安排,不要求为每个简单访问器或转发函数单独编写测试: | |||||
| | 优先级 | 测试对象 | 重点验证内容 | | |||||
| | --- | --- | --- | | |||||
| | 高 | `domain` | 地址边界、M/D 类型约束、模型校验、工程唯一性和运行模式切换 | | |||||
| | 高 | `services` | HMI、寄存器仓库和逻辑执行器之间的离线闭环 | | |||||
| | 高 | 工程存储 | 保存加载往返、版本字段、缺失字段和非法文件 | | |||||
| | 中 | `infrastructure` | Modbus 帧解析、超时、CRC、断线和错误反馈 | | |||||
| | 中 | `ui` | 新建工程、编辑、运行切换和错误提示等关键工作流 | | |||||
| | 冒烟 | 真机联调 | 少量安全地址的读写和 RUN 联动验证 | | |||||
| `domain` 和 `services` 的自动化测试不依赖 Qt 界面或真实 PLC。基础设施优先使用模拟串口 | |||||
| 和测试文件,真机测试只覆盖无法在模拟环境确认的通信和联动行为。 | |||||
| @@ -53,7 +53,9 @@ | |||||
| ## 7. 实现控制逻辑编辑器 | ## 7. 实现控制逻辑编辑器 | ||||
| - 建立图形逻辑模型,支持基本连接、删除和属性配置。 | - 建立图形逻辑模型,支持基本连接、删除和属性配置。 | ||||
| - 优先实现常开、常闭、普通线圈、置位/复位、数值比较和延时定时器。 | |||||
| - 初版实现常开、常闭、普通线圈、置位/复位以及 D 值与常量比较。 | |||||
| - 定时器不属于当前原始需求范围,只有在后续需求明确时才新增对应配置和执行逻辑。 | |||||
| - 新增节点类型时使用独立配置类型,不向通用节点结构持续堆叠无关字段。 | |||||
| - 编辑器只生成逻辑模型,不直接修改 HMI 或 PLC。 | - 编辑器只生成逻辑模型,不直接修改 HMI 或 PLC。 | ||||
| 完成标准:能够配置一套简单的启动、停止和状态保持逻辑。 | 完成标准:能够配置一套简单的启动、停止和状态保持逻辑。 | ||||
| @@ -84,9 +86,11 @@ | |||||
| ## 11. 完成稳定性与测试 | ## 11. 完成稳定性与测试 | ||||
| - 为地址校验、工程序列化、逻辑执行和模式切换编写自动化测试。 | |||||
| - 验证断线、超时、CRC/协议异常、错误工程文件和重复切换模式等边界场景。 | |||||
| - 完成一次离线仿真流程和一次真机 RUN 联动流程测试。 | |||||
| - 优先为地址边界、M/D 区域约束、HMI 和逻辑配置校验、工程校验以及模式切换规则编写自动化测试。 | |||||
| - 工程文件实现后测试保存加载往返和错误文件处理 | |||||
| - 逻辑执行器实现后测试启动停止保持等代表性场景 不为每个简单访问器单独编写测试 | |||||
| - 通信层验证断线、超时、CRC/协议异常和关键地址读写 真机只做少量安全冒烟测试 | |||||
| - 完成一次离线仿真闭环和一次真机 RUN 联动流程测试 | |||||
| 完成标准:核心流程可重复执行,异常有反馈,程序不会崩溃或误写 PLC。 | 完成标准:核心流程可重复执行,异常有反馈,程序不会崩溃或误写 PLC。 | ||||