浏览代码

chore: 建立 Qt 开发基线

main
suyu 1 个月前
当前提交
eb2b1f0589
共有 19 个文件被更改,包括 1462 次插入0 次删除
  1. +15
    -0
      .gitattributes
  2. +56
    -0
      .gitignore
  3. +47
    -0
      AGENTS.md
  4. +25
    -0
      app/integrated_platform.pro
  5. +23
    -0
      app/src/main.cpp
  6. +25
    -0
      app/src/ui/main_window.cpp
  7. +49
    -0
      app/src/ui/main_window.h
  8. +77
    -0
      app/src/ui/main_window.ui
  9. +188
    -0
      docs/0_综合平台编程器_修改.md
  10. +111
    -0
      docs/C++代码规范.md
  11. +133
    -0
      docs/XDH-60T4-E指令与Modbus要点.md
  12. +83
    -0
      docs/XDH-60T4-E硬件与接线要点.md
  13. +23
    -0
      docs/ai/handoff.md
  14. +0
    -0
      docs/architecture.md
  15. 二进制
      docs/pdf/0_综合平台编程器_修改.pdf
  16. 二进制
      docs/pdf/XD、XL系列可编程序控制器用户手册(硬件篇)(PD 01 20260410 1.6).pdf
  17. 二进制
      docs/pdf/XD、XL系列可编程控制器用户手册(基本指令篇)(PD05 20260610 1.6.1).pdf
  18. +114
    -0
      docs/开发顺序.md
  19. +493
    -0
      scripts/plc_modbus_rtu_tool.py

+ 15
- 0
.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

+ 56
- 0
.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

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

+ 25
- 0
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

+ 23
- 0
app/src/main.cpp 查看文件

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

#include <QApplication>

#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();
}

+ 25
- 0
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::MainWindow>())
{
ui_->setupUi(this);
statusBar()->showMessage(tr("开发基线已建立"));
}

MainWindow::~MainWindow() = default;

} // namespace integrated_platform

+ 49
- 0
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 <QMainWindow>

#include <memory>

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::MainWindow> ui_;
};

} // namespace integrated_platform

+ 77
- 0
app/src/ui/main_window.ui 查看文件

@@ -0,0 +1,77 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>MainWindow</class>
<widget class="QMainWindow" name="MainWindow">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>960</width>
<height>640</height>
</rect>
</property>
<property name="windowTitle">
<string>综合平台编程器</string>
</property>
<widget class="QWidget" name="centralWidget">
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<spacer name="topSpacer">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>180</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QLabel" name="titleLabel">
<property name="font">
<font>
<pointsize>20</pointsize>
<bold>true</bold>
</font>
</property>
<property name="text">
<string>综合平台编程器</string>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="statusLabel">
<property name="text">
<string>Qt Widgets 开发基线已建立</string>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
</widget>
</item>
<item>
<spacer name="bottomSpacer">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>180</height>
</size>
</property>
</spacer>
</item>
</layout>
</widget>
<widget class="QMenuBar" name="menuBar"/>
<widget class="QStatusBar" name="statusBar"/>
</widget>
<resources/>
<connections/>
</ui>

+ 188
- 0
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 分 |

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

+ 133
- 0
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。

+ 83
- 0
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。

+ 23
- 0
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`。

+ 0
- 0
docs/architecture.md 查看文件


二进制
docs/pdf/0_综合平台编程器_修改.pdf 查看文件


二进制
docs/pdf/XD、XL系列可编程序控制器用户手册(硬件篇)(PD 01 20260410 1.6).pdf 查看文件


二进制
docs/pdf/XD、XL系列可编程控制器用户手册(基本指令篇)(PD05 20260610 1.6.1).pdf 查看文件


+ 114
- 0
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`。
- 核心验收功能完成前,不优先开发插件系统、复杂动画和非必要控件。

+ 493
- 0
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("<H", crc16_modbus(payload))


def validate_frame(frame: bytes, station: int, function_code: int) -> None:
if len(frame) < 5:
raise ModbusError("响应帧长度不足")
received_crc = struct.unpack("<H", frame[-2:])[0]
if crc16_modbus(frame[:-2]) != received_crc:
raise ModbusError("响应 CRC 校验失败")
if frame[0] != station:
raise ModbusError(f"响应站号错误:期望 {station},实际 {frame[0]}")
if frame[1] == (function_code | 0x80):
code = frame[2]
raise ModbusError(f"PLC 返回 Modbus 异常 0x{code:02X}")
if frame[1] != function_code:
raise ModbusError(f"响应功能码错误:期望 0x{function_code:02X},实际 0x{frame[1]:02X}")


class ModbusRtuClient:
"""只提供本工具所需的 Modbus RTU 读写能力。"""

def __init__(self) -> 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()

正在加载...
取消
保存