Ver código fonte

feat: 完成核心领域模型与接口

main
suyu 1 mês atrás
pai
commit
90c3299fd4
30 arquivos alterados com 1323 adições e 35 exclusões
  1. +1
    -0
      .gitattributes
  2. +5
    -0
      .gitignore
  3. +8
    -0
      AGENTS.md
  4. +15
    -3
      app/integrated_platform.pro
  5. +163
    -0
      app/src/domain/control_logic_model.cpp
  6. +112
    -0
      app/src/domain/control_logic_model.h
  7. +100
    -0
      app/src/domain/hmi_model.cpp
  8. +61
    -0
      app/src/domain/hmi_model.h
  9. +70
    -0
      app/src/domain/project_model.cpp
  10. +36
    -0
      app/src/domain/project_model.h
  11. +64
    -0
      app/src/domain/register_address.cpp
  12. +34
    -0
      app/src/domain/register_address.h
  13. +72
    -0
      app/src/domain/register_repository.cpp
  14. +72
    -0
      app/src/domain/register_repository.h
  15. +57
    -0
      app/src/domain/runtime_state.cpp
  16. +95
    -0
      app/src/domain/runtime_state.h
  17. +1
    -0
      app/src/infrastructure/.gitkeep
  18. +1
    -1
      app/src/main.cpp
  19. +1
    -0
      app/src/services/.gitkeep
  20. +1
    -1
      app/src/ui/main_window.cpp
  21. +1
    -14
      app/src/ui/main_window.h
  22. +1
    -1
      app/src/ui/main_window.ui
  23. +1
    -0
      app/tests/.gitkeep
  24. +174
    -0
      app/tests/domain_tests.cpp
  25. +24
    -0
      app/tests/domain_tests.pro
  26. +5
    -5
      docs/C++代码规范.md
  27. +3
    -2
      docs/XDH-60T4-E指令与Modbus要点.md
  28. +40
    -4
      docs/ai/handoff.md
  29. +97
    -0
      docs/architecture.md
  30. +8
    -4
      docs/开发顺序.md

+ 1
- 0
.gitattributes Ver arquivo

@@ -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


+ 5
- 0
.gitignore Ver arquivo

@@ -54,3 +54,8 @@ tmp/
.DS_Store
Thumbs.db
Desktop.ini

/scripts
/docs/images
/docs/类图Mermaid代码.md
/other

+ 8
- 0
AGENTS.md Ver arquivo

@@ -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 仅用于安全通信验证。

## 注意
- 生成注释的文本结尾别加逗号 `。`。

+ 15
- 3
app/integrated_platform.pro Ver arquivo

@@ -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

+ 163
- 0
app/src/domain/control_logic_model.cpp Ver arquivo

@@ -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

+ 112
- 0
app/src/domain/control_logic_model.h Ver arquivo

@@ -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

+ 100
- 0
app/src/domain/hmi_model.cpp Ver arquivo

@@ -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

+ 61
- 0
app/src/domain/hmi_model.h Ver arquivo

@@ -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

+ 70
- 0
app/src/domain/project_model.cpp Ver arquivo

@@ -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(),
[&current](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

+ 36
- 0
app/src/domain/project_model.h Ver arquivo

@@ -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

+ 64
- 0
app/src/domain/register_address.cpp Ver arquivo

@@ -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

+ 34
- 0
app/src/domain/register_address.h Ver arquivo

@@ -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

+ 72
- 0
app/src/domain/register_repository.cpp Ver arquivo

@@ -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

+ 72
- 0
app/src/domain/register_repository.h Ver arquivo

@@ -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

+ 57
- 0
app/src/domain/runtime_state.cpp Ver arquivo

@@ -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

+ 95
- 0
app/src/domain/runtime_state.h Ver arquivo

@@ -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

+ 1
- 0
app/src/infrastructure/.gitkeep Ver arquivo

@@ -0,0 +1 @@


+ 1
- 1
app/src/main.cpp Ver arquivo

@@ -2,7 +2,7 @@
* @file main.cpp
* @brief 综合平台编程器应用程序入口。
* @version 0.1.0
* @author QtProXinJe
* @author suyu
* @date 2026-08-05
*/



+ 1
- 0
app/src/services/.gitkeep Ver arquivo

@@ -0,0 +1 @@


+ 1
- 1
app/src/ui/main_window.cpp Ver arquivo

@@ -2,7 +2,7 @@
* @file main_window.cpp
* @brief 实现综合平台编程器的主窗口。
* @version 0.1.0
* @author QtProXinJe
* @author suyu
* @date 2026-08-05
*/



+ 1
- 14
app/src/ui/main_window.h Ver arquivo

@@ -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:


+ 1
- 1
app/src/ui/main_window.ui Ver arquivo

@@ -10,7 +10,7 @@
<height>640</height>
</rect>
</property>
<property name="windowTitle">
<property name="windowTitle">
<string>综合平台编程器</string>
</property>
<widget class="QWidget" name="centralWidget">


+ 1
- 0
app/tests/.gitkeep Ver arquivo

@@ -0,0 +1 @@


+ 174
- 0
app/tests/domain_tests.cpp Ver arquivo

@@ -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;
}

+ 24
- 0
app/tests/domain_tests.pro Ver arquivo

@@ -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

+ 5
- 5
docs/C++代码规范.md Ver arquivo

@@ -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. 应合理使用断言尽早发现不符合预期的软件状态。

+ 3
- 2
docs/XDH-60T4-E指令与Modbus要点.md Ver arquivo

@@ -30,11 +30,12 @@ M 是普通辅助继电器,不可直接驱动外部负载;真机设备是否
| 常闭触点 | 绑定位为 `false` 时导通。 | 停止、互锁、故障条件。 |
| 普通线圈 | 网络导通时写入目标 M 位。 | 运行状态。 |
| 置位/复位线圈 | 将目标 M 位锁存为开或关。 | 启动保持、故障复位。 |
| 数值比较 | 比较 D 值与常量或另一 D 值。 | 水位、温度、剩余时间判断。 |
| 延时定时器 | 条件连续满足指定时长后输出。 | 洗涤等待、报警延时。 |
| 数值比较 | 比较 D 值与常量。 | 水位、温度、剩余量判断。 |

离线仿真按固定扫描周期执行:读取当前 M/D 值,计算每条网络,提交写入,再刷新 HMI。该语义用于验证项目示例逻辑,不承诺与 XDH 的全部指令、扫描细节和固件行为完全一致。

定时器以及 D 寄存器之间的比较属于可选扩展,不在当前原始需求范围内。后续只有在需求明确时才扩展领域模型、编辑器和逻辑执行器,不能因为厂商手册提供相关指令就默认纳入项目范围。

## 3. XDH 的 Modbus 地址映射

### 3.1 项目可直接使用的映射


+ 40
- 4
docs/ai/handoff.md Ver arquivo

@@ -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 与存储接口

+ 97
- 0
docs/architecture.md Ver arquivo

@@ -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。基础设施优先使用模拟串口
和测试文件,真机测试只覆盖无法在模拟环境确认的通信和联动行为。

+ 8
- 4
docs/开发顺序.md Ver arquivo

@@ -53,7 +53,9 @@
## 7. 实现控制逻辑编辑器

- 建立图形逻辑模型,支持基本连接、删除和属性配置。
- 优先实现常开、常闭、普通线圈、置位/复位、数值比较和延时定时器。
- 初版实现常开、常闭、普通线圈、置位/复位以及 D 值与常量比较。
- 定时器不属于当前原始需求范围,只有在后续需求明确时才新增对应配置和执行逻辑。
- 新增节点类型时使用独立配置类型,不向通用节点结构持续堆叠无关字段。
- 编辑器只生成逻辑模型,不直接修改 HMI 或 PLC。

完成标准:能够配置一套简单的启动、停止和状态保持逻辑。
@@ -84,9 +86,11 @@

## 11. 完成稳定性与测试

- 为地址校验、工程序列化、逻辑执行和模式切换编写自动化测试。
- 验证断线、超时、CRC/协议异常、错误工程文件和重复切换模式等边界场景。
- 完成一次离线仿真流程和一次真机 RUN 联动流程测试。
- 优先为地址边界、M/D 区域约束、HMI 和逻辑配置校验、工程校验以及模式切换规则编写自动化测试。
- 工程文件实现后测试保存加载往返和错误文件处理
- 逻辑执行器实现后测试启动停止保持等代表性场景 不为每个简单访问器单独编写测试
- 通信层验证断线、超时、CRC/协议异常和关键地址读写 真机只做少量安全冒烟测试
- 完成一次离线仿真闭环和一次真机 RUN 联动流程测试

完成标准:核心流程可重复执行,异常有反馈,程序不会崩溃或误写 PLC。



Carregando…
Cancelar
Salvar