diff --git a/.gitattributes b/.gitattributes index 75e50b2..c3881f4 100644 --- a/.gitattributes +++ b/.gitattributes @@ -6,6 +6,7 @@ *.pro text eol=lf *.ui text eol=lf *.py text eol=lf +*.ps1 text eol=lf *.md text eol=lf *.pdf binary diff --git a/.gitignore b/.gitignore index 3fc598e..563d486 100644 --- a/.gitignore +++ b/.gitignore @@ -54,3 +54,8 @@ tmp/ .DS_Store Thumbs.db Desktop.ini + +/scripts +/docs/images +/docs/类图Mermaid代码.md +/other \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index b0f04b9..78301d2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,7 @@ # 项目长期约定 +- 项目作者:`suyu`。 + ## 关键文件索引 - C++ 代码规范:`docs/C++代码规范.md`。 @@ -7,6 +9,7 @@ - 推荐开发顺序:`docs/开发顺序.md`。 - Qt 应用源码根目录:`app/`。 - 本机构建输出目录:`build/`(不纳入 Git)。 +- Qt 一键构建运行脚本:`scripts/build_and_run_qt.ps1`。 - XDH-60T4-E 硬件与接线要点:`docs/XDH-60T4-E硬件与接线要点.md`。 - XDH-60T4-E 指令与 Modbus 要点:`docs/XDH-60T4-E指令与Modbus要点.md`。 - PLC 官方手册:`docs/pdf/`。 @@ -30,6 +33,8 @@ mingw32-make -j2 - 优先在独立构建目录执行 qmake 和 `mingw32-make`,不要将生成物混入源代码目录。 - 文件读写、终端查看和源代码统一使用 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 提交约定 @@ -45,3 +50,6 @@ mingw32-make -j2 - 离线模式使用虚拟 M/D,并运行软件逻辑执行器。 - 真机模式使用 PLC 的真实 M/D,软件逻辑执行器必须停止;切换时先读取 PLC 数据,不自动写入离线虚拟值。 - 真机联动设备时 PLC 必须处于 RUN;STOP 仅用于安全通信验证。 + +## 注意 +- 生成注释的文本结尾别加逗号 `。`。 diff --git a/app/integrated_platform.pro b/app/integrated_platform.pro index f3066ea..44b6c21 100644 --- a/app/integrated_platform.pro +++ b/app/integrated_platform.pro @@ -1,7 +1,7 @@ # integrated_platform.pro # 综合平台编程器 Qt Widgets 工程配置。 # Version: 0.1.0 -# Author: QtProXinJe +# Author: suyu # Date: 2026-08-05 QT += core gui widgets serialbus serialport @@ -16,10 +16,22 @@ INCLUDEPATH += src SOURCES += \ 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 += \ - 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 += \ src/ui/main_window.ui diff --git a/app/src/domain/control_logic_model.cpp b/app/src/domain/control_logic_model.cpp new file mode 100644 index 0000000..50c8f10 --- /dev/null +++ b/app/src/domain/control_logic_model.cpp @@ -0,0 +1,163 @@ +#include "control_logic_model.h" + +#include + +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 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 diff --git a/app/src/domain/control_logic_model.h b/app/src/domain/control_logic_model.h new file mode 100644 index 0000000..caf303f --- /dev/null +++ b/app/src/domain/control_logic_model.h @@ -0,0 +1,112 @@ +#pragma once + +#include "register_address.h" + +#include +#include +#include +#include + +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; + +// 描述控制逻辑中的一个功能节点 +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 nodes; + // 控制逻辑包含的连接集合 + std::vector connections; + // 控制逻辑是否启用 + bool enabled = true; + + // 校验控制逻辑并通过 error 返回失败原因 + bool validate(std::string *error = nullptr) const; +}; + +} // namespace integrated_platform::domain diff --git a/app/src/domain/hmi_model.cpp b/app/src/domain/hmi_model.cpp new file mode 100644 index 0000000..b43638f --- /dev/null +++ b/app/src/domain/hmi_model.cpp @@ -0,0 +1,100 @@ +#include "hmi_model.h" + +#include + +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 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 diff --git a/app/src/domain/hmi_model.h b/app/src/domain/hmi_model.h new file mode 100644 index 0000000..73a0871 --- /dev/null +++ b/app/src/domain/hmi_model.h @@ -0,0 +1,61 @@ +#pragma once + +#include "register_address.h" + +#include +#include +#include +#include + +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 binding; // 控件绑定的寄存器地址,可以没有绑定;例如按钮绑定 M100 + std::map 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 controls; + + // 校验页面配置和控件集合并通过 error 返回失败原因 + bool validate(std::string *error = nullptr) const; +}; + +} // namespace integrated_platform::domain diff --git a/app/src/domain/project_model.cpp b/app/src/domain/project_model.cpp new file mode 100644 index 0000000..101aa2c --- /dev/null +++ b/app/src/domain/project_model.cpp @@ -0,0 +1,70 @@ +#include "project_model.h" + +#include + +namespace integrated_platform::domain { + +namespace { + +void setError(std::string *error, const std::string &message) +{ + if (error != nullptr) + { + *error = message; + } +} + +template +bool containsDuplicateId(const std::vector &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 diff --git a/app/src/domain/project_model.h b/app/src/domain/project_model.h new file mode 100644 index 0000000..7eb27b8 --- /dev/null +++ b/app/src/domain/project_model.h @@ -0,0 +1,36 @@ +#pragma once + +#include "control_logic_model.h" +#include "hmi_model.h" + +#include +#include + +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 hmiPages; + // 工程包含的控制逻辑集合 + std::vector controlLogics; + + // 校验工程配置并通过 error 返回失败原因 + bool validate(std::string *error = nullptr) const; +}; + +} // namespace integrated_platform::domain diff --git a/app/src/domain/register_address.cpp b/app/src/domain/register_address.cpp new file mode 100644 index 0000000..65969ea --- /dev/null +++ b/app/src/domain/register_address.cpp @@ -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 diff --git a/app/src/domain/register_address.h b/app/src/domain/register_address.h new file mode 100644 index 0000000..3f5cdd2 --- /dev/null +++ b/app/src/domain/register_address.h @@ -0,0 +1,34 @@ +#pragma once + +#include + +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 diff --git a/app/src/domain/register_repository.cpp b/app/src/domain/register_repository.cpp new file mode 100644 index 0000000..38ae9b2 --- /dev/null +++ b/app/src/domain/register_repository.cpp @@ -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(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(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(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(address.index())] = value; + return {true, RegisterError::None}; +} + +void VirtualRegisterRepository::clear() +{ + bits_.fill(false); + words_.fill(0); +} + +} // namespace integrated_platform::domain diff --git a/app/src/domain/register_repository.h b/app/src/domain/register_repository.h new file mode 100644 index 0000000..d382074 --- /dev/null +++ b/app/src/domain/register_repository.h @@ -0,0 +1,72 @@ +#pragma once + +#include "register_address.h" + +#include +#include +#include + +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(RegisterAddress::kMaximumIndex + 1); + std::array bits_{}; + std::array words_{}; +}; + +} // namespace integrated_platform::domain diff --git a/app/src/domain/runtime_state.cpp b/app/src/domain/runtime_state.cpp new file mode 100644 index 0000000..d94f133 --- /dev/null +++ b/app/src/domain/runtime_state.cpp @@ -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 diff --git a/app/src/domain/runtime_state.h b/app/src/domain/runtime_state.h new file mode 100644 index 0000000..5046a73 --- /dev/null +++ b/app/src/domain/runtime_state.h @@ -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 diff --git a/app/src/infrastructure/.gitkeep b/app/src/infrastructure/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/app/src/infrastructure/.gitkeep @@ -0,0 +1 @@ + diff --git a/app/src/main.cpp b/app/src/main.cpp index a5dd5a3..b220e76 100644 --- a/app/src/main.cpp +++ b/app/src/main.cpp @@ -2,7 +2,7 @@ * @file main.cpp * @brief 综合平台编程器应用程序入口。 * @version 0.1.0 - * @author QtProXinJe + * @author suyu * @date 2026-08-05 */ diff --git a/app/src/services/.gitkeep b/app/src/services/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/app/src/services/.gitkeep @@ -0,0 +1 @@ + diff --git a/app/src/ui/main_window.cpp b/app/src/ui/main_window.cpp index 2b0b93e..a7d6bc2 100644 --- a/app/src/ui/main_window.cpp +++ b/app/src/ui/main_window.cpp @@ -2,7 +2,7 @@ * @file main_window.cpp * @brief 实现综合平台编程器的主窗口。 * @version 0.1.0 - * @author QtProXinJe + * @author suyu * @date 2026-08-05 */ diff --git a/app/src/ui/main_window.h b/app/src/ui/main_window.h index c636e8f..0793cdd 100644 --- a/app/src/ui/main_window.h +++ b/app/src/ui/main_window.h @@ -2,7 +2,7 @@ * @file main_window.h * @brief 定义综合平台编程器的主窗口。 * @version 0.1.0 - * @author QtProXinJe + * @author suyu * @date 2026-08-05 */ @@ -20,26 +20,13 @@ QT_END_NAMESPACE namespace integrated_platform { -/** - * @brief 提供应用程序的基础窗口与状态栏。 - * - * 当前阶段只负责加载 Qt Designer 界面。后续业务模块通过独立服务接入, - * 不在主窗口中实现 PLC 通信或控制逻辑。 - */ class MainWindow final : public QMainWindow { Q_OBJECT public: - /** - * @brief 创建主窗口。 - * @param parent Qt 父对象,可为空。 - */ explicit MainWindow(QWidget *parent = nullptr); - /** - * @brief 销毁主窗口及其 Designer 界面对象。 - */ ~MainWindow() override; private: diff --git a/app/src/ui/main_window.ui b/app/src/ui/main_window.ui index 06b6dd9..89ef478 100644 --- a/app/src/ui/main_window.ui +++ b/app/src/ui/main_window.ui @@ -10,7 +10,7 @@ 640 - + 综合平台编程器 diff --git a/app/tests/.gitkeep b/app/tests/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/app/tests/.gitkeep @@ -0,0 +1 @@ + diff --git a/app/tests/domain_tests.cpp b/app/tests/domain_tests.cpp new file mode 100644 index 0000000..b39133e --- /dev/null +++ b/app/tests/domain_tests.cpp @@ -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 +#include +#include +#include +#include + +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(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(-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(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; +} diff --git a/app/tests/domain_tests.pro b/app/tests/domain_tests.pro new file mode 100644 index 0000000..a7fca1b --- /dev/null +++ b/app/tests/domain_tests.pro @@ -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 diff --git a/docs/C++代码规范.md b/docs/C++代码规范.md index e3b4e84..5d7ad18 100644 --- a/docs/C++代码规范.md +++ b/docs/C++代码规范.md @@ -54,10 +54,10 @@ 2. 注释应简洁、准确、无歧义,说明代码难以直接表达的意图、约束、风险或实现原因,不得简单重复代码含义。 3. 修改代码时必须同步更新相关注释。 4. 同一类注释必须采用统一风格;单行注释使用 `//`,多行注释使用 `/* */`。 -5. 需要生成接口文档时,文件、类、结构体、枚举和对外接口应使用统一的 Doxygen 风格注释。 -6. 文件头注释应包含文件名、功能描述、版本、作者、日期和必要的修改记录。 -7. 类注释应说明类的功能、使用场景、使用方法、注意事项和风险点;功能显而易见的简单类可省略。 -8. 对外接口函数声明前必须说明功能、参数输入输出属性、返回值、异常或错误情况及使用限制。 +5. 需要生成接口文档,或接口契约无法从名称和类型直接理解时,使用统一的 Doxygen 风格注释。 +6. 文件头、类注释和函数注释不是强制项;仅在职责、使用限制或风险不直观时添加。 +7. 简单构造函数、析构函数、访问器、显而易见的私有辅助函数和简单数据成员不写注释。 +8. 对外复杂接口应说明关键参数约束、返回值、错误情况和使用限制。 9. 函数实现处仅对关键实现细节、复杂逻辑或性能决策进行注释。 10. 无法通过名称表达用途或存在特殊逻辑的数据成员、全局变量和常量必须添加注释。 11. 临时方案、已知缺陷或后续优化项使用统一的 `TODO(责任人): 描述` 格式标记。 @@ -106,6 +106,6 @@ 1. 关系紧密的代码应相邻放置;无关语句不得混入同一函数或代码块。 2. 项目或产品内必须统一调试开关和调试输出函数。 3. 调试信息格式必须统一,且至少包含模块名或源文件名及行号。 -4. 编码时应同步设计单元测试点、测试代码和测试用例;测试代码应可通过调试开关独立启用或移除。 +4. 编码时应同步识别高风险业务规则并设计对应测试点;不要求为简单访问器和无分支转发函数重复编写测试,测试代码应可独立构建和运行。 5. 集成测试或系统联调前必须准备测试环境、测试项目和测试用例,并持续优化测试用例。 6. 应合理使用断言尽早发现不符合预期的软件状态。 diff --git a/docs/XDH-60T4-E指令与Modbus要点.md b/docs/XDH-60T4-E指令与Modbus要点.md index f6ba1d9..9994198 100644 --- a/docs/XDH-60T4-E指令与Modbus要点.md +++ b/docs/XDH-60T4-E指令与Modbus要点.md @@ -30,11 +30,12 @@ M 是普通辅助继电器,不可直接驱动外部负载;真机设备是否 | 常闭触点 | 绑定位为 `false` 时导通。 | 停止、互锁、故障条件。 | | 普通线圈 | 网络导通时写入目标 M 位。 | 运行状态。 | | 置位/复位线圈 | 将目标 M 位锁存为开或关。 | 启动保持、故障复位。 | -| 数值比较 | 比较 D 值与常量或另一 D 值。 | 水位、温度、剩余时间判断。 | -| 延时定时器 | 条件连续满足指定时长后输出。 | 洗涤等待、报警延时。 | +| 数值比较 | 比较 D 值与常量。 | 水位、温度、剩余量判断。 | 离线仿真按固定扫描周期执行:读取当前 M/D 值,计算每条网络,提交写入,再刷新 HMI。该语义用于验证项目示例逻辑,不承诺与 XDH 的全部指令、扫描细节和固件行为完全一致。 +定时器以及 D 寄存器之间的比较属于可选扩展,不在当前原始需求范围内。后续只有在需求明确时才扩展领域模型、编辑器和逻辑执行器,不能因为厂商手册提供相关指令就默认纳入项目范围。 + ## 3. XDH 的 Modbus 地址映射 ### 3.1 项目可直接使用的映射 diff --git a/docs/ai/handoff.md b/docs/ai/handoff.md index 0d25f95..8fa1ce2 100644 --- a/docs/ai/handoff.md +++ b/docs/ai/handoff.md @@ -1,8 +1,8 @@ # Current Handoff -- Goal: 完成综合平台编程器的 Qt 开发基线。 +- Goal: 完成综合平台编程器的核心数据模型与接口。 - Branch: `main`。 -- Current status: 已初始化 Git,并建立可构建的 Qt Widgets 空工程。 +- Current status: 已完成开发顺序第 3 步,核心领域模型、寄存器仓库接口和运行模式边界已定义。 - Changed files: - `app/integrated_platform.pro` - `app/src/main.cpp` @@ -10,14 +10,50 @@ - `app/src/ui/main_window.cpp` - `app/src/ui/main_window.ui` - `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: - Qt 应用源码位于 `app/`,构建输出位于被忽略的 `build/`。 - 使用 qmake、Qt Widgets、Qt SerialBus 和 MinGW 8.1。 - 主窗口只承载基础界面,不承担 PLC 通信或控制逻辑。 + - 简单函数、构造函数和析构函数不添加重复性注释。 + - 生产代码按 `UI -> Services -> Domain` 的方向依赖;基础设施实现领域接口。 + - 领域模型仅使用 C++17 标准库,不依赖 Qt UI、串口或 Modbus。 + - M 地址只承载布尔值,D 地址只承载带符号 16 位值,地址范围统一为 `0~4000`。 + - `RegisterRepository` 统一虚拟寄存器和真实 PLC 缓存的访问契约。 + - 控制逻辑节点使用 `std::variant` 组合触点、线圈和数值比较的独立配置类型。 + - 当前控制逻辑范围不包含定时器;不得因为设备手册或旧开发计划提前加入未确认功能。 + - 新增逻辑节点时增加独立配置类型,不向通用节点结构堆叠无关可选字段。 + - 测试按业务风险分级 优先覆盖领域规则 服务闭环 工程存储和通信异常 不为简单访问器堆叠单测。 + - 运行态之间不可直接切换;真机运行态必须先完成 PLC 初次读取,且不运行软件逻辑执行器。 - Validation run and results: - 在 `build/baseline/` 执行 qmake 与 `mingw32-make -j2`,构建成功。 - 已启动 `integrated_platform.exe` 并正常退出。 + - 已通过 `scripts/build_and_run_qt.ps1` 完成 Debug 构建和启动验证。 + - 新增分层目录后再次完成 Debug 构建和启动验证。 - 已确认构建目录、临时目录和本地草稿文档被 `.gitignore` 忽略。 -- Remaining work: 进入开发顺序第 2 步,搭建分层目录与架构文档。 + - 在 `build/core-model/` 重新执行 qmake 和 `mingw32-make -j2`,应用构建成功。 + - 在 `build/domain-tests/` 构建并运行领域测试,输出 `domain tests passed`。 + - 控制逻辑节点配置重构后再次构建应用和领域测试,均通过。 +- Remaining work: 进入开发顺序第 4 步,实现工程新建、保存、另存为和加载。 - Known risks / blockers: 无。 -- Suggested next command: 在 `build/baseline/` 中执行 qmake 与 `mingw32-make`。 +- Suggested next command: 基于 `Project` 领域模型设计版本化的工程文件 DTO 与存储接口。 diff --git a/docs/architecture.md b/docs/architecture.md index e69de29..844daeb 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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。基础设施优先使用模拟串口 +和测试文件,真机测试只覆盖无法在模拟环境确认的通信和联动行为。 diff --git a/docs/开发顺序.md b/docs/开发顺序.md index 143aa03..34d26df 100644 --- a/docs/开发顺序.md +++ b/docs/开发顺序.md @@ -53,7 +53,9 @@ ## 7. 实现控制逻辑编辑器 - 建立图形逻辑模型,支持基本连接、删除和属性配置。 -- 优先实现常开、常闭、普通线圈、置位/复位、数值比较和延时定时器。 +- 初版实现常开、常闭、普通线圈、置位/复位以及 D 值与常量比较。 +- 定时器不属于当前原始需求范围,只有在后续需求明确时才新增对应配置和执行逻辑。 +- 新增节点类型时使用独立配置类型,不向通用节点结构持续堆叠无关字段。 - 编辑器只生成逻辑模型,不直接修改 HMI 或 PLC。 完成标准:能够配置一套简单的启动、停止和状态保持逻辑。 @@ -84,9 +86,11 @@ ## 11. 完成稳定性与测试 -- 为地址校验、工程序列化、逻辑执行和模式切换编写自动化测试。 -- 验证断线、超时、CRC/协议异常、错误工程文件和重复切换模式等边界场景。 -- 完成一次离线仿真流程和一次真机 RUN 联动流程测试。 +- 优先为地址边界、M/D 区域约束、HMI 和逻辑配置校验、工程校验以及模式切换规则编写自动化测试。 +- 工程文件实现后测试保存加载往返和错误文件处理 +- 逻辑执行器实现后测试启动停止保持等代表性场景 不为每个简单访问器单独编写测试 +- 通信层验证断线、超时、CRC/协议异常和关键地址读写 真机只做少量安全冒烟测试 +- 完成一次离线仿真闭环和一次真机 RUN 联动流程测试 完成标准:核心流程可重复执行,异常有反馈,程序不会崩溃或误写 PLC。