commit eb2b1f058975d00a6a2f35651df2ad3700569ccc Author: suyu <1643689728@qq.com> Date: Wed Aug 5 08:39:29 2026 +0800 chore: 建立 Qt 开发基线 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..75e50b2 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,15 @@ +# Keep source and documentation diffs stable across Windows development environments. +* text=auto eol=lf + +*.cpp text eol=lf +*.h text eol=lf +*.pro text eol=lf +*.ui text eol=lf +*.py text eol=lf +*.md text eol=lf + +*.pdf binary +*.png binary +*.jpg binary +*.jpeg binary +*.ico binary diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3fc598e --- /dev/null +++ b/.gitignore @@ -0,0 +1,56 @@ +# Qt qmake / MinGW build output +build/ +build-*/ +debug/ +release/ +Makefile +Makefile.* +*.qmake.stash +*.qmake.cache +moc_*.cpp +moc_*.h +moc_predefs.h +qrc_*.cpp +ui_*.h +*.o +*.obj +*.a +*.lib +*.dll +*.exe +*.pdb +*.idb +*.ilk +*.exp +*.manifest + +# Qt Creator local user settings +*.pro.user +*.pro.user.* +*.creator.user +*.qtc_clangd/ + +# Python helper scripts +__pycache__/ +*.py[cod] +.venv/ +venv/ + +# Local temporary output and tool state +tmp/ +.playwright-cli/ +*.log + +# Local draft documents, not part of the project source of truth +/docs/需求规格书.md +/docs/设计方案书.md + +# Local secrets and machine-specific configuration +.env +.env.* +!.env.example + +# Operating system files +.DS_Store +Thumbs.db +Desktop.ini diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..b0f04b9 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,47 @@ +# 项目长期约定 + +## 关键文件索引 + +- C++ 代码规范:`docs/C++代码规范.md`。 +- 需求参考文档:`docs/0_综合平台编程器_修改.md`。 +- 推荐开发顺序:`docs/开发顺序.md`。 +- Qt 应用源码根目录:`app/`。 +- 本机构建输出目录:`build/`(不纳入 Git)。 +- XDH-60T4-E 硬件与接线要点:`docs/XDH-60T4-E硬件与接线要点.md`。 +- XDH-60T4-E 指令与 Modbus 要点:`docs/XDH-60T4-E指令与Modbus要点.md`。 +- PLC 官方手册:`docs/pdf/`。 +- 当前开发进度和未完成事项:`docs/ai/handoff.md`(创建后每次新会话优先阅读;不要把动态进度写入本文件)。 +- 架构、模块边界和数据流:`docs/architecture.md`(创建后优先阅读)。 +- 查阅资料时,先读取 UTF-8 编码的 Markdown 文档;只有 Markdown 中找不到所需信息时,才查阅 `docs/pdf/` 内的 PDF 手册。 + +## 技术栈与构建 + +- 使用 C++、Qt 5.15.2 和 Qt Widgets;UI 默认使用 Qt Designer 的 `.ui` 文件。 +- 使用 qmake,不使用 CMake。 +- Qt MinGW 套件根目录:`D:\Qt5.15.2\5.15.2\mingw81_64`。 +- 构建前在 PowerShell 7 中设置 Qt 工具链路径;MinGW 工具目录为常见安装位置,如实际安装位置不同,以本机路径为准: + +```powershell +$env:QTDIR = 'D:\Qt5.15.2\5.15.2\mingw81_64' +$env:Path = "$env:QTDIR\bin;D:\Qt5.15.2\Tools\mingw810_64\bin;$env:Path" +qmake <项目文件>.pro +mingw32-make -j2 +``` + +- 优先在独立构建目录执行 qmake 和 `mingw32-make`,不要将生成物混入源代码目录。 +- 文件读写、终端查看和源代码统一使用 UTF-8。 + +## Git 提交约定 + +- 每次提交只包含一个逻辑完整的改动;提交前必须完成对应的构建或测试验证。 +- 提交信息使用 `类型: 简短说明` 格式,例如 `feat: 建立 Qt 工程基线`。 +- 类型使用 `feat`、`fix`、`docs`、`test`、`refactor` 或 `chore`。 + +## 架构与运行模式 + +- 保持 UI、领域、服务和基础设施分层;业务逻辑不得堆入 `MainWindow` 或 Qt 槽函数。 +- HMI 只绑定 M/D 寄存器仓库,不得直接读写串口;串口通信必须异步,不能阻塞 UI。 +- M 区和 D 区的项目可用地址均为 `0~4000`;Modbus 代码使用从 `0` 开始的原始地址。 +- 离线模式使用虚拟 M/D,并运行软件逻辑执行器。 +- 真机模式使用 PLC 的真实 M/D,软件逻辑执行器必须停止;切换时先读取 PLC 数据,不自动写入离线虚拟值。 +- 真机联动设备时 PLC 必须处于 RUN;STOP 仅用于安全通信验证。 diff --git a/app/integrated_platform.pro b/app/integrated_platform.pro new file mode 100644 index 0000000..f3066ea --- /dev/null +++ b/app/integrated_platform.pro @@ -0,0 +1,25 @@ +# integrated_platform.pro +# 综合平台编程器 Qt Widgets 工程配置。 +# Version: 0.1.0 +# Author: QtProXinJe +# Date: 2026-08-05 + +QT += core gui widgets serialbus serialport + +TEMPLATE = app +TARGET = integrated_platform + +CONFIG += c++17 warn_on +CONFIG -= app_bundle + +INCLUDEPATH += src + +SOURCES += \ + src/main.cpp \ + src/ui/main_window.cpp + +HEADERS += \ + src/ui/main_window.h + +FORMS += \ + src/ui/main_window.ui diff --git a/app/src/main.cpp b/app/src/main.cpp new file mode 100644 index 0000000..a5dd5a3 --- /dev/null +++ b/app/src/main.cpp @@ -0,0 +1,23 @@ +/** + * @file main.cpp + * @brief 综合平台编程器应用程序入口。 + * @version 0.1.0 + * @author QtProXinJe + * @date 2026-08-05 + */ + +#include + +#include "ui/main_window.h" + +int main(int argc, char *argv[]) +{ + QApplication application(argc, argv); + application.setApplicationName(QObject::tr("综合平台编程器")); + application.setOrganizationName(QStringLiteral("QtProXinJe")); + + integrated_platform::MainWindow main_window; + main_window.show(); + + return application.exec(); +} diff --git a/app/src/ui/main_window.cpp b/app/src/ui/main_window.cpp new file mode 100644 index 0000000..2b0b93e --- /dev/null +++ b/app/src/ui/main_window.cpp @@ -0,0 +1,25 @@ +/** + * @file main_window.cpp + * @brief 实现综合平台编程器的主窗口。 + * @version 0.1.0 + * @author QtProXinJe + * @date 2026-08-05 + */ + +#include "main_window.h" + +#include "ui_main_window.h" + +namespace integrated_platform { + +MainWindow::MainWindow(QWidget *parent) + : QMainWindow(parent), + ui_(std::make_unique()) +{ + ui_->setupUi(this); + statusBar()->showMessage(tr("开发基线已建立")); +} + +MainWindow::~MainWindow() = default; + +} // namespace integrated_platform diff --git a/app/src/ui/main_window.h b/app/src/ui/main_window.h new file mode 100644 index 0000000..c636e8f --- /dev/null +++ b/app/src/ui/main_window.h @@ -0,0 +1,49 @@ +/** + * @file main_window.h + * @brief 定义综合平台编程器的主窗口。 + * @version 0.1.0 + * @author QtProXinJe + * @date 2026-08-05 + */ + +#pragma once + +#include + +#include + +QT_BEGIN_NAMESPACE +namespace Ui { +class MainWindow; +} +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: + std::unique_ptr ui_; +}; + +} // namespace integrated_platform diff --git a/app/src/ui/main_window.ui b/app/src/ui/main_window.ui new file mode 100644 index 0000000..06b6dd9 --- /dev/null +++ b/app/src/ui/main_window.ui @@ -0,0 +1,77 @@ + + + MainWindow + + + + 0 + 0 + 960 + 640 + + + + 综合平台编程器 + + + + + + + Qt::Vertical + + + + 20 + 180 + + + + + + + + + 20 + true + + + + 综合平台编程器 + + + Qt::AlignCenter + + + + + + + Qt Widgets 开发基线已建立 + + + Qt::AlignCenter + + + + + + + Qt::Vertical + + + + 20 + 180 + + + + + + + + + + + + diff --git a/docs/0_综合平台编程器_修改.md b/docs/0_综合平台编程器_修改.md new file mode 100644 index 0000000..ee20eee --- /dev/null +++ b/docs/0_综合平台编程器_修改.md @@ -0,0 +1,188 @@ +# 综合平台编程器 + +> 来源:`0_综合平台编程器_修改.pdf` + +## 一、项目背景 + +终端工程师在设备交付、调试与验证阶段的工作中,经常使用 PLC 和 HMI 联调,在这个过程中发现: + +- 需要分别使用多个软件完成界面配置、逻辑调试与通信测试。 +- 工具之间缺乏统一标准,学习成本高。 +- 在没有真实设备的情况下,调试与验证手段不足。 + +公司计划开发一套统一的“综合编程平台”,用于支持设备调试、逻辑验证、交互界面配置以及运行模拟。 + +## 二、需求说明 + +作为一套综合平台编程器,能够在一个软件中完成工程管理、HMI 界面配置、控制逻辑编辑、数据交互以及模拟运行验证。 + +### 1. 工程管理 + +编程器应支持工程项目管理能力。 + +工程文件能够完整保存当前配置结果,便于后续复用和交付。 + +### 2. HMI 界面配置 + +编程器应提供 HMI 界面编辑能力,用于配置设备操作界面和状态显示界面。 + +#### 2.1 界面编辑区域 + +编程器应提供独立的 HMI 编辑区域,用于放置和调整界面元素。 + +#### 2.2 基础控件 + +编程器应支持常见控件。 + +这些控件能够满足常见设备调试场景,例如启动、停止、状态显示、报警提示等。 + +#### 2.3 外观与属性配置 + +控件应支持必要的属性配置。 + +界面配置完成后,工程师能够通过界面直观观察设备状态,并进行简单操作。 + +### 3. 控制逻辑配置 + +编程器应支持控制逻辑的编辑与配置,用于表达设备运行过程中的基本控制规则。 + +#### 3.1 图形化编辑 + +编程器应提供图形化逻辑编辑能力。 + +图形化编辑需支持基本的结构操作与关系表达能力。 + +图形化编辑结果应能够清晰表达控制逻辑之间的先后关系、连接关系或条件关系。 + +#### 3.2 基本逻辑表达 + +编程器应支持常见逻辑表达方式。 + +工程师能够通过图形化方式配置常见控制逻辑。 + +#### 3.3 辅助编辑能力 + +控制逻辑编辑区域应支持必要的辅助编辑操作,例如删除。 + +### 4. 数据点与寄存器管理 + +数据点使用以下区域: + +- D 区 +- M 区 + +使用范围:`0 ~ 4000`。 + +### 5. 数据交互与通信模拟 + +编程器应支持运行时的数据交互能力,用于模拟设备数据变化和验证控制逻辑。 + +编程器能够在没有真实设备的情况下进行模拟运行,并尽量符合常见工业调试软件或同类竞品的使用逻辑。 + +注:针对训练营项目,允许使用实际设备,但仍需要设计相关方案。 + +### 6. 编辑态与运行态 + +编程器应区分编辑态与运行态,支持编辑态与运行态之间的切换。 + +### 7. 系统运行状态与反馈 + +编程器应提供运行状态反馈机制,让用户能够清楚知道当前系统状态。 + +编程器在调试过程中能够给出明确反馈,避免用户不知道当前操作是否生效。 + +### 8. 软件界面结构 + +编程器应具备基本的工具软件界面结构,以支持用户高效使用。 + +整体布局清晰,常用操作容易找到,符合工程师日常使用习惯。 + +## 四、器材与环境 + +| 序号 | 器材 | 数量 | +| --- | --- | --- | +| 1 | XDH-60T4-E / XD5E-60T10-E | 1 台 | +| 2 | 电源线 | 1 根 | +| 3 | 转串 | 1 个 | +| 4 | Xvp | 1 根 | + +系统需支持以下设备环境: + +开发语言: + +- C++ +- C# + +可根据实际技术方案选择其一。 + +## 五、任务要求 + +请基于上述客户需求,完成系统的设计与实现。 + +### 1. 文档交付 + +需提交: + +- 规格书 +- 方案书 +- 测试大纲 + +### 2. 系统实现 + +需完成综合平台编程器的核心功能开发,并能够进行演示。 + +### 3. 代码要求 + +需保证: + +- 代码结构清晰 +- 命名规范 +- 模块划分合理 +- 版本管理规范 + +### 4. 测试与演示 + +需能够演示: + +- 工程创建、保存与加载 +- HMI 界面配置 +- 控制逻辑配置 +- 数据点配置与变化 +- 模拟运行 +- 编辑态与运行态切换 +- 运行状态反馈 + +## 六、时间要求 + +4 周。 + +## 七、验收标准 + +### 1. 整体任务要求(25 分) + +| 序号 | 基本任务 | 分数 | +| --- | --- | --- | +| 1 | 书写项目文档,包含规格书、方案书、测试大纲 | 15 分 | +| 2 | 代码管理清晰 | 5 分 | +| 3 | 代码编写规范 | 5 分 | + +### 2. 综合平台编程器任务要求(100 分) + +| 维度 | 说明 | 分数 | +| --- | --- | --- | +| 需求理解程度 | 能够准确理解客户目标、使用场景与核心业务流程 | 20 分 | +| 方案设计合理性 | 系统架构清晰,模块划分合理,数据流与运行流程设计完整 | 25 分 | +| 功能实现质量 | 核心功能可用,交互合理,功能之间能够形成闭环 | 20 分 | +| 系统完整性与稳定性 | 工程保存加载、编辑、运行、模拟、反馈等流程稳定可演示 | 20 分 | +| 表达与展示能力 | 能够清晰说明设计思路、实现效果与测试结果 | 15 分 | + +### 3. 可选要求(15 分) + +可选要求不限定具体实现方式,根据系统最终效果综合评定。 + +可参考以下方向: + +| 方向 | 说明 | 分数 | +| --- | --- | --- | +| 系统扩展能力 | 系统便于后续扩展新模块、新控件或新设备 | 0~5 分 | +| 工程化能力 | 具备较好的模块化、插件化、跨平台或可维护设计 | 0~10 分 | diff --git a/docs/C++代码规范.md b/docs/C++代码规范.md new file mode 100644 index 0000000..e3b4e84 --- /dev/null +++ b/docs/C++代码规范.md @@ -0,0 +1,111 @@ +# C++ 代码规范 + +## 1. 文件与编码 + +1. 源文件使用 `.cpp` 后缀,头文件使用 `.h` 后缀。 +2. 文件名全部使用小写字母,多个单词之间使用下划线连接。 +3. 文件名应准确表达文件内容,且不得与系统头文件或 C++ 标准库头文件同名。 +4. 同一项目必须统一使用 UTF-8 编码。 +5. 删除行尾空格,避免产生无效的版本控制差异。 + +## 2. 命名 + +1. 项目内应选定并统一命名风格,不得在同一作用域或模块中混用多种风格。 +2. 命名应清晰、准确、有明确含义;使用完整单词或公认缩写,避免单字符、无意义数字和容易误解的标识符。 +3. 命名中使用特殊约定或缩写时,应在注释中说明。 +4. 命名空间使用全小写字母,多个单词之间使用下划线连接,并应与项目名和目录结构对应。 +5. 类、结构体、枚举和类型别名使用大驼峰命名法。 +6. 函数使用项目统一的小驼峰或大驼峰命名法,并应使用准确的动宾词组描述其功能。 +7. 局部变量和普通变量使用项目统一的小驼峰或下划线命名法。 +8. 私有成员变量在名称末尾加下划线。 +9. 全局变量以 `g_` 为前缀;除非确有必要,不得使用全局变量。 +10. 常量、宏和枚举值全部使用大写字母,多个单词之间使用下划线连接。 +11. 具有互斥或相反含义的变量、函数应使用语义明确的反义词组命名。 +12. 避免定义以下划线开头和结尾的标识符,编译开关和头文件保护等特殊场景除外。 +13. 应统一规划接口变量、结构、函数和常量的命名,避免编译或链接冲突。 +14. 不得让局部变量与全局变量同名。 + +## 3. 排版与格式 + +1. 使用 4 个空格缩进,不使用 Tab 对齐。 +2. 一行只写一条语句。 +3. 代码行长度原则上不超过 100 个字符;包含完整 URL 或命令的注释可例外。 +4. 长语句、长表达式和过长的函数参数应换行;换行后保持适当缩进和清晰对齐。 +5. 长表达式应在低优先级运算符处换行;布尔表达式换行时,逻辑运算符置于行尾。 +6. 函数、类、结构体、枚举及控制语句的左、右大括号应各占一行,并与所属语句左对齐。 +7. `if`、`else`、`for`、`while`、`switch` 等控制语句必须使用大括号;`else` 必须另起一行。 +8. `switch` 必须包含 `default` 分支;每个 `case` 应使用代码块并显式结束处理流程。 +9. 空循环体必须使用空代码块或 `continue` 表达,不得仅使用分号。 +10. 预处理指令必须从行首开始,不得缩进。 +11. `public`、`protected`、`private` 的声明顺序应为 `public`、`protected`、`private`。 +12. 命名空间内部不额外增加缩进层级。 +13. 函数定义之间最多保留两行空行;函数体和代码块的首尾不得保留空行。 +14. 相对独立的代码块之间可使用空行分隔,但不得使用无意义的空白。 +15. 对等二元运算符前后应保留空格;成员访问运算符 `.`、`->` 前后以及一元运算符与操作数之间不得留空格。 +16. `if`、`for`、`while`、`switch` 与左圆括号之间保留一个空格;函数调用的左圆括号后和右圆括号前不得留空格。 +17. 函数调用参数优先写在同一行;无法容纳时按第一个参数对齐或每行一个参数。 +18. 指针和引用声明中,`*`、`&` 应与类型或变量名之一紧邻,同一文件内必须保持一致。 +19. 构造函数初始化列表可与函数声明同一行;换行时按 4 个空格缩进并保持对齐。 +20. 模板尖括号内不得添加多余空格。 + +## 4. 注释 + +1. 优先通过清晰的架构、逻辑和命名提高可读性,仅在必要时添加注释。 +2. 注释应简洁、准确、无歧义,说明代码难以直接表达的意图、约束、风险或实现原因,不得简单重复代码含义。 +3. 修改代码时必须同步更新相关注释。 +4. 同一类注释必须采用统一风格;单行注释使用 `//`,多行注释使用 `/* */`。 +5. 需要生成接口文档时,文件、类、结构体、枚举和对外接口应使用统一的 Doxygen 风格注释。 +6. 文件头注释应包含文件名、功能描述、版本、作者、日期和必要的修改记录。 +7. 类注释应说明类的功能、使用场景、使用方法、注意事项和风险点;功能显而易见的简单类可省略。 +8. 对外接口函数声明前必须说明功能、参数输入输出属性、返回值、异常或错误情况及使用限制。 +9. 函数实现处仅对关键实现细节、复杂逻辑或性能决策进行注释。 +10. 无法通过名称表达用途或存在特殊逻辑的数据成员、全局变量和常量必须添加注释。 +11. 临时方案、已知缺陷或后续优化项使用统一的 `TODO(责任人): 描述` 格式标记。 + +## 5. 数据与表达式 + +1. 严禁将未初始化的变量作为右值使用。 +2. 应明确公共或全局数据的含义、作用、取值范围及相互关系;传递数据时必须防止非法值和越界。 +3. 数据结构成员数量应适中;成员过多时,应按职责拆分为子结构或独立类型。 +4. 跨 CPU 或分布式通信的数据结构必须考虑字节序、位域、字节对齐和数据布局。 +5. 不得直接使用难以理解的字面量;具有业务或物理意义的数值应定义为具名常量或枚举值。 +6. 应使用括号明确复杂表达式的计算顺序,不得依赖不易识别的运算符优先级。 +7. 避免使用难懂的技巧性代码;技巧不能替代可读性和可维护性。 + +## 6. 函数与模块 + +1. 函数必须职责单一、功能明确,并精确实现设计要求。 +2. 函数规模原则上不超过 200 行;过长函数应按职责拆分。 +3. 不得设计职责过多或用途不明确的通用函数。 +4. 函数应具有可预测行为:相同输入和相同外部状态下应产生一致结果。 +5. 函数参数应尽量精简;未使用参数必须从接口中移除,避免使用 `bool` 控制参数。 +6. 接口函数必须明确参数合法性检查的责任方;未约定时由调用方负责。 +7. 函数必须校验其负责的参数输入及非参数输入(如文件、外部状态、共享数据)的有效性。 +8. 不得将函数参数直接作为工作变量修改;需要修改时应使用局部变量。 +9. 必须完整处理被调用函数的错误返回值。 +10. 函数返回值应清晰表达执行结果和错误状态;调用提供返回值的函数时应使用其返回值。 +11. 不得依赖隐式或不必要的强制类型转换作为函数返回值或调用参数。 +12. 函数调用点应易于理解,避免隐藏副作用和不必要的默认类型转换。 +13. 重复代码应提取为职责明确的函数;仅被单一上层函数调用且功能过小、不明确的函数可合并到上层函数。 +14. 降低模块和函数之间的耦合,提高函数独立性、可读性、效率和可维护性。 +15. 函数应保持高扇入、合理扇出,扇出原则上小于 7。 +16. 减少函数内部和函数之间的递归调用;使用递归前应评估终止条件、栈空间和性能。 +17. 多任务环境中的函数应具备可重入性;访问共享全局资源时必须使用适当的同步保护。 +18. 禁止在构造函数和析构函数中调用虚函数。 + +## 7. 宏与预处理 + +1. C++ 中应优先使用 `const`、`constexpr`、`enum`、模板或 `inline` 函数替代宏。 +2. 必须使用宏时,宏名使用全大写加下划线命名法。 +3. 函数式宏的参数和整体表达式必须使用完整括号保护。 +4. 多语句宏必须使用 `do { ... } while (0)` 封装。 +5. 宏参数不得包含可能产生副作用的表达式。 + +## 8. 可读性与可测试性 + +1. 关系紧密的代码应相邻放置;无关语句不得混入同一函数或代码块。 +2. 项目或产品内必须统一调试开关和调试输出函数。 +3. 调试信息格式必须统一,且至少包含模块名或源文件名及行号。 +4. 编码时应同步设计单元测试点、测试代码和测试用例;测试代码应可通过调试开关独立启用或移除。 +5. 集成测试或系统联调前必须准备测试环境、测试项目和测试用例,并持续优化测试用例。 +6. 应合理使用断言尽早发现不符合预期的软件状态。 diff --git a/docs/XDH-60T4-E指令与Modbus要点.md b/docs/XDH-60T4-E指令与Modbus要点.md new file mode 100644 index 0000000..f6ba1d9 --- /dev/null +++ b/docs/XDH-60T4-E指令与Modbus要点.md @@ -0,0 +1,133 @@ +# XDH-60T4-E 指令与 Modbus 要点 + +> 用途:本文件是综合平台编程器实现离线逻辑仿真和 XDH-60T4-E Modbus RTU 联机的开发速查资料。 +> 本项目只实现简化梯形图仿真,不生成、编译或下载真实 PLC 程序。 + +## 1. 本项目只使用的软元件范围 + +项目统一使用以下范围: + +| 区域 | 项目范围 | 含义 | 运行时类型 | +| --- | --- | --- | +| M 区 | `M0~M4000` | 普通辅助继电器,用于开关命令和状态。 | 布尔值 | +| D 区 | `D0~D4000` | 普通数据寄存器,用于数值、参数和设备状态。 | 16 位字 | + +XDH 的实际软元件范围远大于项目范围:普通 M 区为 `M0~M199999`,普通 D 区为 `D0~D499999`。本项目固定使用较小的 `0~4000` 范围,足够演示,也便于变量管理和界面绑定。 + +M 是普通辅助继电器,不可直接驱动外部负载;真机设备是否动作由 PLC 内部程序决定。D 是数据寄存器,单个 D 默认按带符号 16 位数处理,范围为 `-32768~32767`。 + +32 位数据由两个相邻 D 寄存器组成:`D0` 为低字,`D1` 为高字,例如 `D1D0` 组成一个 32 位数。初版 HMI 数值控件默认处理单个 16 位 D;32 位和浮点显示作为后续扩展。 + +来源:基本指令篇印刷页 35、44。 + +## 2. 简化梯形图仿真的实现范围 + +厂商手册包含完整指令集,但本项目只需要模拟常见设备控制逻辑。初版逻辑执行器支持以下元素即可: + +| 逻辑元素 | 语义 | 对应应用场景 | +| --- | --- | --- | +| 常开触点 | 绑定位为 `true` 时导通。 | 启动条件、运行条件。 | +| 常闭触点 | 绑定位为 `false` 时导通。 | 停止、互锁、故障条件。 | +| 普通线圈 | 网络导通时写入目标 M 位。 | 运行状态。 | +| 置位/复位线圈 | 将目标 M 位锁存为开或关。 | 启动保持、故障复位。 | +| 数值比较 | 比较 D 值与常量或另一 D 值。 | 水位、温度、剩余时间判断。 | +| 延时定时器 | 条件连续满足指定时长后输出。 | 洗涤等待、报警延时。 | + +离线仿真按固定扫描周期执行:读取当前 M/D 值,计算每条网络,提交写入,再刷新 HMI。该语义用于验证项目示例逻辑,不承诺与 XDH 的全部指令、扫描细节和固件行为完全一致。 + +## 3. XDH 的 Modbus 地址映射 + +### 3.1 项目可直接使用的映射 + +对 XDH 系列 PLC,项目范围内的 M/D 地址与 Modbus 原始地址一一对应: + +| PLC 软元件 | Modbus 对象 | 原始 Modbus 地址 | 项目中的用法 | +| --- | --- | --- | --- | +| `M0~M4000` | 线圈(Coil) | `0~4000` | HMI 按钮写入、指示灯读取。 | +| `D0~D4000` | 保持寄存器(Holding Register) | `0~4000` | HMI 数值显示、数值输入和参数读写。 | + +代码内部必须使用上述 **从 0 开始的原始 Modbus 地址**。某些第三方上位机软件会以 `1` 或 `400001` 形式展示地址,这只是软件显示习惯,不应直接写进通信代码。 + +XDH 的 Modbus 可访问范围为 `M0~M20479` 和 `D0~D20479`,所以项目约定的 `0~4000` 全部在有效范围内。 + +来源:基本指令篇印刷页 255-256。 + +### 3.2 本项目需要实现的功能码 + +| 功能码 | 名称 | 目标数据 | 本项目用途 | +| --- | --- | --- | --- | +| `0x01` | 读线圈 | M 区 | 读取启动、运行、报警等位状态。 | +| `0x05` | 写单个线圈 | M 区 | HMI 按钮写一个 M 位。 | +| `0x0F` | 写多个线圈 | M 区 | 后续批量写入使用,初版可不做 UI 入口。 | +| `0x03` | 读保持寄存器 | D 区 | 读取数值、参数和状态。 | +| `0x06` | 写单个保持寄存器 | D 区 | HMI 数值输入写一个 D。 | +| `0x10` | 写多个保持寄存器 | D 区 | 后续批量参数下发使用。 | + +本期联机不使用 X、Y、S、SM、HD 等其他软元件,也不需要实现 PLC 作为 Modbus 主站的编程指令。 + +来源:基本指令篇印刷页 259-261。 + +## 4. Modbus RTU 报文与串口规则 + +### 4.1 RTU 帧 + +每一帧格式如下: + +```text +站号(1 字节) + 功能码(1 字节) + 数据(0~252 字节) + CRC 低字节 + CRC 高字节 +``` + +RTU 模式使用 CRC 校验。收到响应后必须校验站号、功能码、报文长度和 CRC;异常响应或校验失败不得更新 HMI 的寄存器缓存。 + +### 4.2 串口配置规则 + +PLC 的 `COM2` 选择 Modbus RTU 模式,PC 与 PLC 的波特率、数据位、校验位、停止位必须完全一致。PLC 站号即 RTU 帧的第一个字节。 + +PLC 串口配置写入后需要断电重启。PC 轮询需要设置超时和间隔,不能在前一个请求还未完成时无限追加请求。 + +来源:基本指令篇印刷页 258、273-276。 + +## 5. 联机模块实现约束 + +### 5.1 数据流 + +```text +PC HMI 控件 + <-> 联机寄存器缓存(M/D) + <-> Modbus RTU 通信服务 + <-> XDH-60T4-E COM2 RS-485 + <-> PLC 内部控制程序 +``` + +HMI 控件不直接收发串口数据。通信服务更新联机寄存器缓存,HMI 订阅缓存变化并刷新。这样通信超时不会阻塞界面。 + +### 5.2 读写策略 + +1. 收集当前 HMI 页面绑定的 M/D 地址。 +2. 合并相邻地址,分别使用 `0x01` 批量读 M 区、`0x03` 批量读 D 区。 +3. 用户点击按钮时使用 `0x05` 写 M 区;提交数值时使用 `0x06` 写 D 区。 +4. 写入成功后等待下一次轮询确认实际值,不以本地点击状态代替 PLC 响应。 +5. 超时、CRC 错误或 Modbus 异常响应时保留最后一次有效值,并在状态栏和日志中报告错误。 + +### 5.3 与离线仿真的隔离 + +| 模式 | 变量来源 | 图形逻辑执行器 | +| --- | --- | --- | +| 离线仿真 | PC 内存中的 M/D | 运行。 | +| 真机联机 | PLC 读回的 M/D 缓存 | 停止。 | + +真机联机时,PLC 内部程序是唯一控制源。PC 端不执行仿真梯形图,避免同时写入同一批 M/D 地址。 + +## 6. 首次真机验证步骤 + +1. 用官方工具把 PLC 的 COM2 配置为 Modbus RTU 从站,设置并记录站号与串口参数。 +2. 使用 USB 转 RS-485 将 PC 与 COM2 的 A/B 正确连接。 +3. PC 发送 `0x01` 读取 `M0`,确认能得到合法 RTU 响应。 +4. PC 发送 `0x03` 读取 `D0`,确认寄存器解析正确。 +5. 在安全的测试程序中使用 `0x05` 写 `M0` 和 `0x06` 写 `D0`,通过 PLC 监控或 HMI 页面确认写入结果。 +6. 完成单点验证后,再开启页面轮询和完整 HMI 联动。 + +## 7. 资料出处 + +1. 《XD、XL系列可编程控制器用户手册【基本指令篇】》,PD05 20260610 1.6.1。 +2. 重点章节:第 2 章软元件、第 6-2 节 Modbus 通讯功能,印刷页 250-278。 diff --git a/docs/XDH-60T4-E硬件与接线要点.md b/docs/XDH-60T4-E硬件与接线要点.md new file mode 100644 index 0000000..859b4ef --- /dev/null +++ b/docs/XDH-60T4-E硬件与接线要点.md @@ -0,0 +1,83 @@ +# XDH-60T4-E 硬件与接线要点 + +> 用途:本文件是综合平台编程器进行 XDH-60T4-E 真机联机时的硬件速查资料。 +> 当前项目只实现 PC 上位机对 PLC D/M 寄存器的 Modbus RTU 读写,不实现 PLC 程序下载。 + +## 1. 结论先看 + +真机联机优先使用 PLC 的 `COM2`,即 `RS-485` 通信口。PC 侧需要 USB 转 RS-485 转换器,连接关系为: + +```text +PC USB -> USB 转 RS-485 -> PLC COM2 +转换器 A(485+) -> PLC A(485+) +转换器 B(485-) -> PLC B(485-) +``` + +RS-485 的接线规则是 A 接 A、B 接 B。若通信无响应,先检查 A/B 是否接反,再检查站号和串口参数。 + +图片 1-1 PC 与 XDH-60T4-E 的 RS-485 接线图(待补充) + +## 2. XDH-60 的通信接口 + +| 接口 | 位置/用途 | 本项目使用方式 | +| --- | --- | --- | +| RJ45 口 1、RJ45 口 2 | 以太网接口 | 当前不使用;后续可扩展 Modbus TCP 联机。 | +| COM1 | RS-232 接口 | 当前不推荐作为主联机接口。RS-232 距离较短,且该系列为半双工通信。 | +| COM2 | RS-485 接口,端子标识为 A、B | 本期真机 Modbus RTU 联机接口。 | +| USB 口 | 与官方工具连接、配置设备 | 用于首次配置 PLC 串口参数,不由本项目软件实现下载功能。 | + +XDH-60 的结构图中,RJ45 口位于本体左侧,COM1 为 RS-232 接口,COM2 为输出端子附近的 RS-485 A/B 端子。 + +来源:`pdf/XD、XL系列可编程序控制器用户手册(硬件篇)(PD 01 20260410 1.6).pdf`,印刷页 28。 + +## 3. 串口配置前置条件 + +PLC 必须先使用信捷官方编程软件或 XINJEConfig 配置工具,将 COM2 配置为 Modbus RTU 从站。PC 软件不能假设 PLC 使用某个固定默认参数。 + +需要在 PLC 中确认并记录以下配置: + +| 配置项 | 说明 | +| --- | --- | +| 端口 | `COM2`,对应 RS-485。 | +| 通信模式 | `Modbus RTU`,不使用 ASCII。 | +| 站号 | PLC 作为从站时设置的站号。单台 PLC 也应明确设置。 | +| 波特率 | PLC 与 PC 必须一致。 | +| 数据位、校验位、停止位 | PLC 与 PC 必须一致。 | +| 回复超时、重试次数、发送前延时 | 由 PLC 配置工具设定;PC 侧的轮询间隔不能过短。 | + +写入串口配置后,PLC 需要断电重启,参数才会生效。 + +来源:`pdf/XD、XL系列可编程控制器用户手册(基本指令篇)(PD05 20260610 1.6.1).pdf`,印刷页 273-276。 + +## 4. 本项目的联机边界 + +### 4.1 PC 软件负责的内容 + +- 打开和关闭 PC 串口; +- 按 PLC 配置发送 Modbus RTU 读写报文; +- 轮询绑定到 HMI 控件的 M/D 地址; +- 将 HMI 按钮和数值输入写入真实 PLC; +- 显示连接状态、超时、CRC 错误和 PLC 异常响应。 + +### 4.2 PC 软件不负责的内容 + +- 向 PLC 下载、上传或编译梯形图程序; +- 修改 PLC 的串口配置; +- 在真机模式下执行 PC 内的仿真梯形图; +- 直接驱动 PLC 的 I/O 输出。 + +真机模式的唯一控制逻辑来源是 PLC 内已经存在的程序。PC HMI 只是上位机,通过寄存器发送命令和显示状态。 + +## 5. 联机前检查清单 + +1. PLC 已正确供电,PWR 指示正常。 +2. 使用 `COM2` 的 A/B 端子接入 USB 转 RS-485 转换器。 +3. PLC 已配置为 Modbus RTU 从站,并完成断电重启。 +4. PC 软件中的站号、波特率、数据位、校验位和停止位与 PLC 一致。 +5. 先读取 `M0` 和 `D0` 验证通信,再接入完整 HMI 页面。 +6. 真机模式下关闭 PC 端逻辑执行器,避免双重写入寄存器。 + +## 6. 资料出处 + +1. 《XD、XL系列可编程序控制器用户手册【硬件篇】》,PD 01 20260410 1.6,重点参见印刷页 28。 +2. 《XD、XL系列可编程控制器用户手册【基本指令篇】》,PD05 20260610 1.6.1,重点参见印刷页 273-276。 diff --git a/docs/ai/handoff.md b/docs/ai/handoff.md new file mode 100644 index 0000000..0d25f95 --- /dev/null +++ b/docs/ai/handoff.md @@ -0,0 +1,23 @@ +# Current Handoff + +- Goal: 完成综合平台编程器的 Qt 开发基线。 +- Branch: `main`。 +- Current status: 已初始化 Git,并建立可构建的 Qt Widgets 空工程。 +- Changed files: + - `app/integrated_platform.pro` + - `app/src/main.cpp` + - `app/src/ui/main_window.h` + - `app/src/ui/main_window.cpp` + - `app/src/ui/main_window.ui` + - `AGENTS.md` +- Decisions made: + - Qt 应用源码位于 `app/`,构建输出位于被忽略的 `build/`。 + - 使用 qmake、Qt Widgets、Qt SerialBus 和 MinGW 8.1。 + - 主窗口只承载基础界面,不承担 PLC 通信或控制逻辑。 +- Validation run and results: + - 在 `build/baseline/` 执行 qmake 与 `mingw32-make -j2`,构建成功。 + - 已启动 `integrated_platform.exe` 并正常退出。 + - 已确认构建目录、临时目录和本地草稿文档被 `.gitignore` 忽略。 +- Remaining work: 进入开发顺序第 2 步,搭建分层目录与架构文档。 +- Known risks / blockers: 无。 +- Suggested next command: 在 `build/baseline/` 中执行 qmake 与 `mingw32-make`。 diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..e69de29 diff --git a/docs/pdf/0_综合平台编程器_修改.pdf b/docs/pdf/0_综合平台编程器_修改.pdf new file mode 100644 index 0000000..2fdb33f Binary files /dev/null and b/docs/pdf/0_综合平台编程器_修改.pdf differ diff --git a/docs/pdf/XD、XL系列可编程序控制器用户手册(硬件篇)(PD 01 20260410 1.6).pdf b/docs/pdf/XD、XL系列可编程序控制器用户手册(硬件篇)(PD 01 20260410 1.6).pdf new file mode 100644 index 0000000..eebc233 Binary files /dev/null and b/docs/pdf/XD、XL系列可编程序控制器用户手册(硬件篇)(PD 01 20260410 1.6).pdf differ diff --git a/docs/pdf/XD、XL系列可编程控制器用户手册(基本指令篇)(PD05 20260610 1.6.1).pdf b/docs/pdf/XD、XL系列可编程控制器用户手册(基本指令篇)(PD05 20260610 1.6.1).pdf new file mode 100644 index 0000000..d3c9a18 Binary files /dev/null and b/docs/pdf/XD、XL系列可编程控制器用户手册(基本指令篇)(PD05 20260610 1.6.1).pdf differ diff --git a/docs/开发顺序.md b/docs/开发顺序.md new file mode 100644 index 0000000..143aa03 --- /dev/null +++ b/docs/开发顺序.md @@ -0,0 +1,114 @@ +# 综合平台编程器开发顺序 + +> 本文规定项目的推荐开发顺序。原则是先建立稳定的数据模型和模块边界,再完成编辑功能,最后接入仿真与真实 PLC。每个阶段完成后都应保证项目可以构建、运行并提交一次 Git 记录。 + +## 1. 建立开发基线 + +- 初始化 Git 仓库,确认 `.gitignore`、编码规则和提交规范。 +- 验证 Qt 5.15.2、qmake、MinGW、Qt SerialBus 的构建环境。 +- 建立可启动的 Qt Widgets 空工程和独立构建目录。 + +完成标准:空程序可以通过 qmake 构建并正常启动。 + +## 2. 搭建项目分层骨架 + +- 建立 UI、领域、服务、基础设施和测试目录。 +- 明确模块依赖方向,`MainWindow` 只负责界面组织,不承载业务逻辑。 +- 在 `docs/architecture.md` 中记录目录职责和核心数据流。 + +完成标准:各层具有清晰入口,工程仍可独立构建。 + +## 3. 定义核心数据模型与接口 + +- 定义工程、HMI 页面、HMI 控件、控制逻辑和 M/D 地址模型。 +- 定义统一的寄存器仓库接口,隔离虚拟寄存器与真实 PLC 寄存器。 +- 定义编辑态、离线运行态和真机运行态的状态边界。 + +完成标准:核心模型不依赖具体 UI、串口或 Modbus 实现。 + +## 4. 实现工程管理 + +- 实现新建、保存、另存为和加载工程。 +- 保存 HMI 配置、控制逻辑、M/D 绑定和必要的工程版本信息。 +- 对无效文件、缺失字段和不兼容版本给出明确错误。 + +完成标准:空工程和示例工程可以保存后重新加载,数据保持一致。 + +## 5. 完成主界面与状态管理 + +- 使用 Qt Designer 搭建工具栏、工程区域、HMI 编辑区、逻辑编辑区、属性区和状态反馈区。 +- 实现编辑态与运行态的统一切换入口。 +- 根据当前状态限制不可用操作,避免编辑与运行同时发生。 + +完成标准:主界面结构完整,状态切换行为明确且可见。 + +## 6. 实现 HMI 编辑器 + +- 支持基础控件的添加、选择、移动、删除和属性配置。 +- 支持按钮、指示灯、数值显示和数值输入等基础控件。 +- HMI 控件只绑定统一寄存器仓库,不直接访问仿真器或串口。 + +完成标准:能够设计一个基础操作页面,并随工程保存和加载。 + +## 7. 实现控制逻辑编辑器 + +- 建立图形逻辑模型,支持基本连接、删除和属性配置。 +- 优先实现常开、常闭、普通线圈、置位/复位、数值比较和延时定时器。 +- 编辑器只生成逻辑模型,不直接修改 HMI 或 PLC。 + +完成标准:能够配置一套简单的启动、停止和状态保持逻辑。 + +## 8. 实现离线仿真运行 + +- 实现虚拟 M/D 寄存器和固定周期的软件逻辑执行器。 +- 将 HMI、虚拟寄存器和控制逻辑形成完整闭环。 +- 实现启动、停止、异常和当前运行状态反馈。 + +完成标准:不连接 PLC 时,可以通过 HMI 操作并观察逻辑运行结果。 + +## 9. 实现 Modbus RTU 真机通信 + +- 使用 Qt SerialBus 的 `QModbusRtuSerialMaster` 封装独立通信服务。 +- 实现串口配置、连接、断开、M/D 读写、轮询、超时和错误反馈。 +- 使用从 `0` 开始的原始 Modbus 地址,通信过程不得阻塞 UI。 + +完成标准:先在 PLC STOP 状态下稳定读取和写入 `M0`、`D0`,再扩展到批量轮询。 + +## 10. 完成运行模式集成 + +- 离线模式连接虚拟寄存器并启动软件逻辑执行器。 +- 真机模式连接 PLC 寄存器缓存并停止软件逻辑执行器。 +- 切换到真机模式时先读取 PLC 数据,不自动下发离线仿真值。 + +完成标准:两种运行模式可安全切换,数据来源和界面状态不会混淆。 + +## 11. 完成稳定性与测试 + +- 为地址校验、工程序列化、逻辑执行和模式切换编写自动化测试。 +- 验证断线、超时、CRC/协议异常、错误工程文件和重复切换模式等边界场景。 +- 完成一次离线仿真流程和一次真机 RUN 联动流程测试。 + +完成标准:核心流程可重复执行,异常有反馈,程序不会崩溃或误写 PLC。 + +## 12. 完成交付与演示 + +- 整理规格书、方案书、测试大纲、构建运行说明和演示工程。 +- 准备工程创建、HMI 配置、逻辑配置、离线仿真、真机联机和状态反馈的演示流程。 +- 最后再评估新增控件、设备适配或插件机制等可选加分项。 + +完成标准:在干净环境中可以按文档构建,并完整演示验收流程。 + +## 建议进度 + +| 周期 | 阶段 | +| --- | --- | +| 第 1 周 | 第 1~5 步:基线、架构、模型、工程管理和主界面 | +| 第 2 周 | 第 6~8 步:HMI、控制逻辑和离线仿真 | +| 第 3 周 | 第 9~10 步:Modbus RTU 和运行模式集成 | +| 第 4 周 | 第 11~12 步:测试、修复、文档和演示 | + +## 执行纪律 + +- 不跨阶段提前堆叠功能;发现前置模型不稳定时,先修正前置阶段。 +- 每完成一个阶段,都执行构建和对应测试,并更新 `docs/ai/handoff.md`。 +- 核心验收功能完成前,不优先开发插件系统、复杂动画和非必要控件。 diff --git a/scripts/plc_modbus_rtu_tool.py b/scripts/plc_modbus_rtu_tool.py new file mode 100644 index 0000000..90de91d --- /dev/null +++ b/scripts/plc_modbus_rtu_tool.py @@ -0,0 +1,493 @@ +#!/usr/bin/env python3 +"""XDH-60T4-E Modbus RTU 调试工具。 + +依赖:pyserial(pip install pyserial) +用途:通过 USB 转 RS-485 连接 PLC COM2,读写项目约定的 M0~M4000、D0~D4000。 +""" + +from __future__ import annotations + +import queue +import struct +import threading +import time +import tkinter as tk +from dataclasses import dataclass +from tkinter import messagebox, ttk +from typing import Any + +try: + import serial + from serial.tools import list_ports +except ImportError as error: + raise SystemExit("缺少 pyserial。请执行:python -m pip install pyserial") from error + + +APP_TITLE = "XDH-60T4-E Modbus RTU 联机工具" +MAX_PROJECT_ADDRESS = 4000 + + +class ModbusError(Exception): + """Modbus RTU 通信或协议错误。""" + + +def crc16_modbus(payload: bytes) -> int: + """计算 Modbus RTU CRC-16,返回未交换字节序的 16 位数。""" + crc = 0xFFFF + for byte in payload: + crc ^= byte + for _ in range(8): + crc = (crc >> 1) ^ 0xA001 if crc & 1 else crc >> 1 + return crc + + +def append_crc(payload: bytes) -> bytes: + return payload + struct.pack(" None: + if len(frame) < 5: + raise ModbusError("响应帧长度不足") + received_crc = struct.unpack(" None: + self._serial: serial.Serial | None = None + self.station = 1 + + @property + def is_connected(self) -> bool: + return self._serial is not None and self._serial.is_open + + def connect(self, config: dict[str, Any]) -> None: + self.close() + self.station = int(config["station"]) + self._serial = serial.Serial( + port=config["port"], + baudrate=int(config["baudrate"]), + bytesize=int(config["bytesize"]), + parity=config["parity"], + stopbits=float(config["stopbits"]), + timeout=float(config["timeout"]), + write_timeout=float(config["timeout"]), + ) + self._serial.reset_input_buffer() + self._serial.reset_output_buffer() + + def close(self) -> None: + if self._serial is not None: + self._serial.close() + self._serial = None + + def _read_exact(self, size: int) -> bytes: + if self._serial is None: + raise ModbusError("串口尚未连接") + data = self._serial.read(size) + if len(data) != size: + raise ModbusError(f"通信超时:期望接收 {size} 字节,实际收到 {len(data)} 字节") + return data + + def _transact(self, function_code: int, request_data: bytes, response_kind: str) -> bytes: + if self._serial is None or not self._serial.is_open: + raise ModbusError("串口尚未连接") + + request = append_crc(bytes((self.station, function_code)) + request_data) + self._serial.reset_input_buffer() + self._serial.write(request) + self._serial.flush() + + header = self._read_exact(2) + if header[1] == (function_code | 0x80): + frame = header + self._read_exact(3) + elif response_kind == "read": + byte_count = self._read_exact(1) + frame = header + byte_count + self._read_exact(byte_count[0] + 2) + else: + frame = header + self._read_exact(6) + validate_frame(frame, self.station, function_code) + return frame + + def read_coils(self, address: int, count: int) -> list[bool]: + self._validate_range(address, count, maximum=2000) + frame = self._transact(0x01, struct.pack(">HH", address, count), "read") + byte_count = frame[2] + expected_bytes = (count + 7) // 8 + if byte_count != expected_bytes: + raise ModbusError(f"线圈响应字节数错误:期望 {expected_bytes},实际 {byte_count}") + values: list[bool] = [] + for index in range(count): + values.append(bool(frame[3 + index // 8] & (1 << (index % 8)))) + return values + + def read_holding_registers(self, address: int, count: int) -> list[int]: + self._validate_range(address, count, maximum=125) + frame = self._transact(0x03, struct.pack(">HH", address, count), "read") + byte_count = frame[2] + if byte_count != count * 2: + raise ModbusError(f"寄存器响应字节数错误:期望 {count * 2},实际 {byte_count}") + return [struct.unpack(">h", frame[3 + offset : 5 + offset])[0] for offset in range(0, byte_count, 2)] + + def write_coil(self, address: int, value: bool) -> None: + self._validate_range(address, 1, maximum=1) + raw_value = 0xFF00 if value else 0x0000 + request_data = struct.pack(">HH", address, raw_value) + frame = self._transact(0x05, request_data, "write") + if frame[2:6] != request_data: + raise ModbusError("写线圈响应内容与请求不一致") + + def write_register(self, address: int, value: int) -> None: + self._validate_range(address, 1, maximum=1) + if not -32768 <= value <= 32767: + raise ModbusError("D 寄存器值必须在 -32768 到 32767 之间") + request_data = struct.pack(">Hh", address, value) + frame = self._transact(0x06, request_data, "write") + if frame[2:6] != request_data: + raise ModbusError("写寄存器响应内容与请求不一致") + + @staticmethod + def _validate_range(address: int, count: int, maximum: int) -> None: + if not 0 <= address <= MAX_PROJECT_ADDRESS: + raise ModbusError(f"地址必须在 0 到 {MAX_PROJECT_ADDRESS} 之间") + if not 1 <= count <= maximum: + raise ModbusError(f"读取数量必须在 1 到 {maximum} 之间") + if address + count - 1 > MAX_PROJECT_ADDRESS: + raise ModbusError(f"地址范围不能超过 {MAX_PROJECT_ADDRESS}") + + +@dataclass(frozen=True) +class Command: + request_id: int + action: str + payload: dict[str, Any] + + +class CommunicationWorker: + """独占串口的后台线程,保证 GUI 不因通信超时而冻结。""" + + def __init__(self) -> None: + self.commands: queue.Queue[Command] = queue.Queue() + self.results: queue.Queue[tuple[int, bool, str, Any]] = queue.Queue() + self._stop_event = threading.Event() + self._thread = threading.Thread(target=self._run, name="modbus-rtu-worker", daemon=True) + self._thread.start() + + def submit(self, command: Command) -> None: + self.commands.put(command) + + def shutdown(self) -> None: + self._stop_event.set() + self.commands.put(Command(0, "shutdown", {})) + self._thread.join(timeout=1.5) + + def _run(self) -> None: + client = ModbusRtuClient() + while not self._stop_event.is_set(): + try: + command = self.commands.get(timeout=0.2) + except queue.Empty: + continue + try: + if command.action == "shutdown": + client.close() + return + if command.action == "connect": + client.connect(command.payload) + result = f"已连接 {command.payload['port']},站号 {client.station}" + elif command.action == "disconnect": + client.close() + result = "串口已断开" + elif command.action == "read_m": + result = client.read_coils(command.payload["address"], command.payload["count"]) + elif command.action == "read_d": + result = client.read_holding_registers(command.payload["address"], command.payload["count"]) + elif command.action == "write_m": + client.write_coil(command.payload["address"], command.payload["value"]) + result = None + elif command.action == "write_d": + client.write_register(command.payload["address"], command.payload["value"]) + result = None + else: + raise ModbusError(f"未知操作:{command.action}") + self.results.put((command.request_id, True, command.action, result)) + except (ModbusError, serial.SerialException, ValueError, struct.error) as error: + self.results.put((command.request_id, False, command.action, str(error))) + + +class PlcToolApp(tk.Tk): + def __init__(self) -> None: + super().__init__() + self.title(APP_TITLE) + self.minsize(840, 600) + self.geometry("920x680") + self.worker = CommunicationWorker() + self._next_request_id = 1 + self._request_labels: dict[int, str] = {} + + self.port_var = tk.StringVar() + self.baudrate_var = tk.StringVar(value="9600") + self.bytesize_var = tk.StringVar(value="8") + self.parity_var = tk.StringVar(value="N") + self.stopbits_var = tk.StringVar(value="1") + self.station_var = tk.StringVar(value="1") + self.timeout_var = tk.StringVar(value="1.0") + self.status_var = tk.StringVar(value="未连接") + + self.read_m_address_var = tk.StringVar(value="0") + self.read_m_count_var = tk.StringVar(value="1") + self.read_d_address_var = tk.StringVar(value="0") + self.read_d_count_var = tk.StringVar(value="1") + self.write_m_address_var = tk.StringVar(value="0") + self.write_m_value_var = tk.BooleanVar(value=False) + self.write_d_address_var = tk.StringVar(value="0") + self.write_d_value_var = tk.StringVar(value="0") + + self._build_ui() + self.refresh_ports() + self.protocol("WM_DELETE_WINDOW", self.on_close) + self.after(80, self.process_results) + + def _build_ui(self) -> None: + root = ttk.Frame(self, padding=12) + root.grid(sticky="nsew") + self.columnconfigure(0, weight=1) + self.rowconfigure(0, weight=1) + root.columnconfigure(0, weight=1) + root.rowconfigure(3, weight=1) + + connection = ttk.LabelFrame(root, text="连接参数", padding=10) + connection.grid(row=0, column=0, sticky="ew") + for column in range(8): + connection.columnconfigure(column, weight=1 if column in (1, 3, 5) else 0) + + self._add_label_entry(connection, "串口", self.port_var, 0, 0, width=14, readonly=True) + self.port_combo = connection.grid_slaves(row=0, column=1)[0] + ttk.Button(connection, text="刷新", command=self.refresh_ports).grid(row=0, column=2, padx=(6, 12)) + self._add_label_entry(connection, "波特率", self.baudrate_var, 0, 3, width=10, values=("9600", "19200", "38400", "57600", "115200")) + self._add_label_entry(connection, "站号", self.station_var, 0, 5, width=6) + ttk.Button(connection, text="连接", command=self.connect).grid(row=0, column=7, padx=(12, 4)) + + self._add_label_entry(connection, "数据位", self.bytesize_var, 1, 0, width=8, values=("7", "8")) + self._add_label_entry(connection, "校验", self.parity_var, 1, 2, width=8, values=("N", "E", "O")) + self._add_label_entry(connection, "停止位", self.stopbits_var, 1, 4, width=8, values=("1", "1.5", "2")) + self._add_label_entry(connection, "超时(秒)", self.timeout_var, 1, 6, width=8) + ttk.Button(connection, text="断开", command=lambda: self.submit("disconnect", {}, "断开")).grid(row=1, column=7, padx=(12, 4)) + + operations = ttk.Frame(root) + operations.grid(row=1, column=0, sticky="ew", pady=(12, 0)) + operations.columnconfigure(0, weight=1) + operations.columnconfigure(1, weight=1) + + self._build_read_panel(operations) + self._build_write_panel(operations) + + result_frame = ttk.LabelFrame(root, text="读取结果", padding=8) + result_frame.grid(row=2, column=0, sticky="nsew", pady=(12, 0)) + result_frame.columnconfigure(0, weight=1) + self.result_text = tk.Text(result_frame, height=8, wrap="word", state="disabled", font=("Consolas", 10)) + self.result_text.grid(row=0, column=0, sticky="nsew") + + log_frame = ttk.LabelFrame(root, text="通信日志", padding=8) + log_frame.grid(row=3, column=0, sticky="nsew", pady=(12, 0)) + log_frame.columnconfigure(0, weight=1) + log_frame.rowconfigure(0, weight=1) + self.log_text = tk.Text(log_frame, height=10, wrap="word", state="disabled", font=("Consolas", 10)) + self.log_text.grid(row=0, column=0, sticky="nsew") + ttk.Button(log_frame, text="清空日志", command=lambda: self._set_text(self.log_text, "")).grid(row=1, column=0, sticky="e", pady=(6, 0)) + + status = ttk.Label(root, textvariable=self.status_var, relief="sunken", anchor="w", padding=(7, 3)) + status.grid(row=4, column=0, sticky="ew", pady=(10, 0)) + + @staticmethod + def _add_label_entry( + parent: ttk.Widget, + label: str, + variable: tk.StringVar, + row: int, + column: int, + width: int, + values: tuple[str, ...] | None = None, + readonly: bool = False, + ) -> None: + ttk.Label(parent, text=label).grid(row=row, column=column, sticky="w", padx=(0 if column == 0 else 10, 4), pady=3) + if values is not None or readonly: + state = "readonly" if readonly else "normal" + widget = ttk.Combobox(parent, textvariable=variable, values=values, width=width, state=state) + else: + widget = ttk.Entry(parent, textvariable=variable, width=width) + widget.grid(row=row, column=column + 1, sticky="ew", pady=3) + + def _build_read_panel(self, parent: ttk.Frame) -> None: + panel = ttk.LabelFrame(parent, text="读取 PLC", padding=10) + panel.grid(row=0, column=0, sticky="nsew", padx=(0, 6)) + ttk.Label(panel, text="M 地址").grid(row=0, column=0, sticky="w") + ttk.Entry(panel, textvariable=self.read_m_address_var, width=9).grid(row=0, column=1, padx=5) + ttk.Label(panel, text="数量").grid(row=0, column=2, sticky="w") + ttk.Entry(panel, textvariable=self.read_m_count_var, width=7).grid(row=0, column=3, padx=5) + ttk.Button(panel, text="读取 M", command=self.read_m).grid(row=0, column=4, padx=(8, 0)) + + ttk.Label(panel, text="D 地址").grid(row=1, column=0, sticky="w", pady=(8, 0)) + ttk.Entry(panel, textvariable=self.read_d_address_var, width=9).grid(row=1, column=1, padx=5, pady=(8, 0)) + ttk.Label(panel, text="数量").grid(row=1, column=2, sticky="w", pady=(8, 0)) + ttk.Entry(panel, textvariable=self.read_d_count_var, width=7).grid(row=1, column=3, padx=5, pady=(8, 0)) + ttk.Button(panel, text="读取 D", command=self.read_d).grid(row=1, column=4, padx=(8, 0), pady=(8, 0)) + + def _build_write_panel(self, parent: ttk.Frame) -> None: + panel = ttk.LabelFrame(parent, text="写入 PLC(请先确认测试程序安全)", padding=10) + panel.grid(row=0, column=1, sticky="nsew", padx=(6, 0)) + ttk.Label(panel, text="M 地址").grid(row=0, column=0, sticky="w") + ttk.Entry(panel, textvariable=self.write_m_address_var, width=9).grid(row=0, column=1, padx=5) + ttk.Checkbutton(panel, text="写入 ON", variable=self.write_m_value_var).grid(row=0, column=2, padx=5) + ttk.Button(panel, text="写 M", command=self.write_m).grid(row=0, column=3, padx=(8, 0)) + + ttk.Label(panel, text="D 地址").grid(row=1, column=0, sticky="w", pady=(8, 0)) + ttk.Entry(panel, textvariable=self.write_d_address_var, width=9).grid(row=1, column=1, padx=5, pady=(8, 0)) + ttk.Entry(panel, textvariable=self.write_d_value_var, width=10).grid(row=1, column=2, padx=5, pady=(8, 0)) + ttk.Button(panel, text="写 D", command=self.write_d).grid(row=1, column=3, padx=(8, 0), pady=(8, 0)) + + def refresh_ports(self) -> None: + ports = [port.device for port in list_ports.comports()] + self.port_combo["values"] = ports + if ports and self.port_var.get() not in ports: + self.port_var.set(ports[0]) + self.log(f"检测到串口:{', '.join(ports) if ports else '无'}") + + def connect(self) -> None: + try: + config = { + "port": self.port_var.get().strip(), + "baudrate": int(self.baudrate_var.get()), + "bytesize": int(self.bytesize_var.get()), + "parity": self.parity_var.get().strip().upper(), + "stopbits": float(self.stopbits_var.get()), + "station": int(self.station_var.get()), + "timeout": float(self.timeout_var.get()), + } + if not config["port"]: + raise ValueError("请选择串口") + if not 1 <= config["station"] <= 247: + raise ValueError("站号必须在 1 到 247 之间") + if config["timeout"] <= 0: + raise ValueError("超时必须大于 0") + if config["parity"] not in ("N", "E", "O"): + raise ValueError("校验仅支持 N、E 或 O") + except ValueError as error: + self.show_input_error(str(error)) + return + self.submit("connect", config, "连接") + + def read_m(self) -> None: + payload = self.parse_address_count(self.read_m_address_var, self.read_m_count_var) + if payload is not None: + self.submit("read_m", payload, f"读取 M{payload['address']} 起 {payload['count']} 个") + + def read_d(self) -> None: + payload = self.parse_address_count(self.read_d_address_var, self.read_d_count_var) + if payload is not None: + self.submit("read_d", payload, f"读取 D{payload['address']} 起 {payload['count']} 个") + + def write_m(self) -> None: + try: + address = int(self.write_m_address_var.get()) + except ValueError: + self.show_input_error("M 地址必须是整数") + return + self.submit("write_m", {"address": address, "value": self.write_m_value_var.get()}, f"写入 M{address}") + + def write_d(self) -> None: + try: + address = int(self.write_d_address_var.get()) + value = int(self.write_d_value_var.get()) + except ValueError: + self.show_input_error("D 地址和值必须是整数") + return + self.submit("write_d", {"address": address, "value": value}, f"写入 D{address}={value}") + + def parse_address_count(self, address_var: tk.StringVar, count_var: tk.StringVar) -> dict[str, int] | None: + try: + return {"address": int(address_var.get()), "count": int(count_var.get())} + except ValueError: + self.show_input_error("地址和数量必须是整数") + return None + + def submit(self, action: str, payload: dict[str, Any], label: str) -> None: + request_id = self._next_request_id + self._next_request_id += 1 + self._request_labels[request_id] = label + self.worker.submit(Command(request_id, action, payload)) + self.status_var.set(f"处理中:{label}") + self.log(f"发送请求:{label}") + + def process_results(self) -> None: + while True: + try: + request_id, success, action, result = self.worker.results.get_nowait() + except queue.Empty: + break + label = self._request_labels.pop(request_id, action) + if success: + self.status_var.set(f"成功:{label}") + self.log(f"成功:{label}") + if action == "connect": + self.status_var.set(result) + self.log(result) + elif action == "read_m": + self.show_read_result("M", self.read_m_address_var.get(), result) + elif action == "read_d": + self.show_read_result("D", self.read_d_address_var.get(), result) + elif action == "write_m": + self.log("写入完成;请使用“读取 M”确认 PLC 实际状态。") + elif action == "write_d": + self.log("写入完成;请使用“读取 D”确认 PLC 实际值。") + else: + self.status_var.set(f"失败:{label}") + self.log(f"失败:{label}。原因:{result}") + self.after(80, self.process_results) + + def show_read_result(self, area: str, address_text: str, values: list[bool] | list[int]) -> None: + start_address = int(address_text) + lines = [f"{area} 区读取结果,共 {len(values)} 项:"] + for index, value in enumerate(values): + if area == "M": + rendered = "ON" if value else "OFF" + else: + rendered = str(value) + lines.append(f"{area}{start_address + index} = {rendered}") + self._set_text(self.result_text, "\n".join(lines)) + + def log(self, message: str) -> None: + timestamp = time.strftime("%H:%M:%S") + self.log_text.configure(state="normal") + self.log_text.insert("end", f"[{timestamp}] {message}\n") + self.log_text.see("end") + self.log_text.configure(state="disabled") + + @staticmethod + def _set_text(widget: tk.Text, value: str) -> None: + widget.configure(state="normal") + widget.delete("1.0", "end") + widget.insert("1.0", value) + widget.configure(state="disabled") + + def show_input_error(self, message: str) -> None: + self.status_var.set(f"输入错误:{message}") + messagebox.showerror(APP_TITLE, message, parent=self) + + def on_close(self) -> None: + self.worker.shutdown() + self.destroy() + + +if __name__ == "__main__": + PlcToolApp().mainloop()