| @@ -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 | |||
| @@ -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 | |||
| @@ -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 仅用于安全通信验证。 | |||
| @@ -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 | |||
| @@ -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(); | |||
| } | |||
| @@ -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 | |||
| @@ -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 | |||
| @@ -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> | |||
| @@ -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 分 | | |||
| @@ -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. 应合理使用断言尽早发现不符合预期的软件状态。 | |||
| @@ -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。 | |||
| @@ -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。 | |||
| @@ -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 +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`。 | |||
| - 核心验收功能完成前,不优先开发插件系统、复杂动画和非必要控件。 | |||
| @@ -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() | |||