|
@@ -0,0 +1,1861 @@
|
|
|
|
|
+# 西门子 S7 PLC 通信指南
|
|
|
|
|
+
|
|
|
|
|
+> 基于 HslCommunication 库,提取自 YZWater3 项目,可直接复用于其他 .NET 项目。
|
|
|
|
|
+
|
|
|
|
|
+## 目录
|
|
|
|
|
+
|
|
|
|
|
+- [概述](#概述)
|
|
|
|
|
+- [授权配置](#授权配置)
|
|
|
|
|
+- [依赖安装](#依赖安装)
|
|
|
|
|
+- [连接建立](#连接建立)
|
|
|
|
|
+- [数据读取](#数据读取)
|
|
|
|
|
+- [数据写入](#数据写入)
|
|
|
|
|
+- [字节序处理](#字节序处理)
|
|
|
|
|
+- [地址配置管理 PlcConfig](#地址配置管理-plcconfig)
|
|
|
|
|
+- [S7 地址解析器(批量读取优化)](#s7-地址解析器批量读取优化)
|
|
|
|
|
+- [数据模型 PlcDataModel](#数据模型-plcdatamodel)
|
|
|
|
|
+- [轮询服务模板(完整版)](#轮询服务模板完整版)
|
|
|
|
|
+- [地址格式参考](#地址格式参考)
|
|
|
|
|
+- [配置文件结构](#配置文件结构)
|
|
|
|
|
+- [项目文件结构](#项目文件结构)
|
|
|
|
|
+- [使用示例](#使用示例)
|
|
|
|
|
+- [支持的 PLC 系列](#支持的-plc-系列)
|
|
|
|
|
+- [常见问题](#常见问题)
|
|
|
|
|
+- [许可证](#许可证)
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 概述
|
|
|
|
|
+
|
|
|
|
|
+本项目使用 **HslCommunication** 工业通信库与西门子 S7 系列 PLC 进行通信。
|
|
|
|
|
+
|
|
|
|
|
+### 核心特性
|
|
|
|
|
+
|
|
|
|
|
+- 支持 S7-200 / S7-200Smart / S7-300 / S7-400 / S7-1200 / S7-1500
|
|
|
|
|
+- 异步读写,支持 Float / Bool / byte[] 等数据类型
|
|
|
|
|
+- 批量读取优化(连续地址合并)
|
|
|
|
|
+- 自动重连机制
|
|
|
|
|
+- 大端序/小端序自动转换
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 授权配置
|
|
|
|
|
+
|
|
|
|
|
+HslCommunication 是商业授权库,使用前需要设置授权码。
|
|
|
|
|
+
|
|
|
|
|
+### 授权方式
|
|
|
|
|
+
|
|
|
|
|
+| 方式 | 说明 |
|
|
|
|
|
+|------|------|
|
|
|
|
|
+| 免费试用 | 不设置授权码,功能受限(随机断开、部分 API 不可用) |
|
|
|
|
|
+| 正式授权 | 购买授权码后通过 `SetAuthorizationCode()` 激活 |
|
|
|
|
|
+
|
|
|
|
|
+### 设置授权码
|
|
|
|
|
+
|
|
|
|
|
+**必须在创建 PLC 通信对象之前调用**,建议在程序启动入口(如 `Main()` 或 `App.OnStartup()`)中调用:
|
|
|
|
|
+
|
|
|
|
|
+```csharp
|
|
|
|
|
+using HslCommunication;
|
|
|
|
|
+
|
|
|
|
|
+// 在程序启动时设置授权码(必须在使用任何 PLC 功能之前调用)
|
|
|
|
|
+HslCommunication.Authorization.SetAuthorizationCode("你的授权码");
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### 在 Avalonia 中的应用示例
|
|
|
|
|
+
|
|
|
|
|
+```csharp
|
|
|
|
|
+// App.axaml.cs
|
|
|
|
|
+public class App : Application
|
|
|
|
|
+{
|
|
|
|
|
+ public override void OnFrameworkInitializationCompleted()
|
|
|
|
|
+ {
|
|
|
|
|
+ // 激活 HslCommunication 授权(在所有 PLC 操作之前)
|
|
|
|
|
+ HslCommunication.Authorization.SetAuthorizationCode(
|
|
|
|
|
+ "c765bac7-2caf-446d-9619-f67e19d6624e");
|
|
|
|
|
+
|
|
|
|
|
+ // 后续初始化...
|
|
|
|
|
+ base.OnFrameworkInitializationCompleted();
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### 在 WPF / WinForms 中的应用示例
|
|
|
|
|
+
|
|
|
|
|
+```csharp
|
|
|
|
|
+// Program.cs 或 App.xaml.cs
|
|
|
|
|
+[STAThread]
|
|
|
|
|
+static void Main()
|
|
|
|
|
+{
|
|
|
|
|
+ // 在 Application 启动前设置
|
|
|
|
|
+ HslCommunication.Authorization.SetAuthorizationCode("你的授权码");
|
|
|
|
|
+
|
|
|
|
|
+ App domain = new App();
|
|
|
|
|
+ domain.InitializeComponent();
|
|
|
|
|
+ domain.Run();
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### 授权相关 API
|
|
|
|
|
+
|
|
|
|
|
+```csharp
|
|
|
|
|
+// 检查授权状态
|
|
|
|
|
+bool isAuthorized = HslCommunication.Authorization.IsAuthorized();
|
|
|
|
|
+
|
|
|
|
|
+// 获取授权到期时间
|
|
|
|
|
+DateTime expireDate = HslCommunication.Authorization.GetExpireDate();
|
|
|
|
|
+
|
|
|
|
|
+// 获取授权设备数量限制
|
|
|
|
|
+int deviceCount = HslCommunication.Authorization.GetDeviceCount();
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### 未授权时的表现
|
|
|
|
|
+
|
|
|
|
|
+- 连接会随机断开(不定时丢失连接)
|
|
|
|
|
+- 部分高级功能不可用
|
|
|
|
|
+- 数据读取可能出现异常
|
|
|
|
|
+
|
|
|
|
|
+> **注意**:本项目使用的授权码 `c765bac7-2caf-446d-9619-f67e19d6624e` 仅限本项目使用。
|
|
|
|
|
+> 其他项目请前往 [HslCommunication 官网](https://www.hslcommunication.cn/) 购买授权。
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 依赖安装
|
|
|
|
|
+
|
|
|
|
|
+### NuGet 包
|
|
|
|
|
+
|
|
|
|
|
+```xml
|
|
|
|
|
+<PackageReference Include="HslCommunication" Version="11.8.2" />
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### 安装命令
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+# Package Manager
|
|
|
|
|
+Install-Package HslCommunication
|
|
|
|
|
+
|
|
|
|
|
+# .NET CLI
|
|
|
|
|
+dotnet add package HslCommunication
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 连接建立
|
|
|
|
|
+
|
|
|
|
|
+### 基础连接类
|
|
|
|
|
+
|
|
|
|
|
+```csharp
|
|
|
|
|
+using HslCommunication;
|
|
|
|
|
+using HslCommunication.Profinet.Siemens;
|
|
|
|
|
+
|
|
|
|
|
+public class SiemensPlcHelper
|
|
|
|
|
+{
|
|
|
|
|
+ private SiemensS7Net? _plc;
|
|
|
|
|
+ private bool _isConnected;
|
|
|
|
|
+
|
|
|
|
|
+ public bool IsConnected => _isConnected;
|
|
|
|
|
+
|
|
|
|
|
+ /// <summary>
|
|
|
|
|
+ /// 初始化并连接 PLC
|
|
|
|
|
+ /// </summary>
|
|
|
|
|
+ public async Task<bool> ConnectAsync(string ip, int port = 102,
|
|
|
|
|
+ SiemensPLCS series = SiemensPLCS.S200Smart)
|
|
|
|
|
+ {
|
|
|
|
|
+ _plc = new SiemensS7Net(series, ip)
|
|
|
|
|
+ {
|
|
|
|
|
+ Port = port,
|
|
|
|
|
+ ConnectTimeOut = 3000, // 连接超时 3秒
|
|
|
|
|
+ ReceiveTimeOut = 3000 // 接收超时 3秒
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ var result = await _plc.ConnectServerAsync();
|
|
|
|
|
+ _isConnected = result.IsSuccess;
|
|
|
|
|
+ return _isConnected;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// <summary>
|
|
|
|
|
+ /// 断开连接
|
|
|
|
|
+ /// </summary>
|
|
|
|
|
+ public void Disconnect()
|
|
|
|
|
+ {
|
|
|
|
|
+ _plc?.ConnectClose();
|
|
|
|
|
+ _isConnected = false;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// <summary>
|
|
|
|
|
+ /// 释放资源
|
|
|
|
|
+ /// </summary>
|
|
|
|
|
+ public void Dispose()
|
|
|
|
|
+ {
|
|
|
|
|
+ Disconnect();
|
|
|
|
|
+ _plc?.Dispose();
|
|
|
|
|
+ _plc = null;
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### 连接参数说明
|
|
|
|
|
+
|
|
|
|
|
+| 参数 | 说明 | 默认值 |
|
|
|
|
|
+|------|------|--------|
|
|
|
|
|
+| `ip` | PLC 的 IP 地址 | 必填 |
|
|
|
|
|
+| `port` | 端口号 | `102` |
|
|
|
|
|
+| `series` | PLC 系列 | `SiemensPLCS.S200Smart` |
|
|
|
|
|
+| `ConnectTimeOut` | 连接超时(毫秒) | `3000` |
|
|
|
|
|
+| `ReceiveTimeOut` | 接收超时(毫秒) | `3000` |
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 数据读取
|
|
|
|
|
+
|
|
|
|
|
+### 读取浮点数(REAL)
|
|
|
|
|
+
|
|
|
|
|
+```csharp
|
|
|
|
|
+/// <summary>
|
|
|
|
|
+/// 读取浮点数 (4字节 REAL)
|
|
|
|
|
+/// </summary>
|
|
|
|
|
+public async Task<float> ReadFloatAsync(string address)
|
|
|
|
|
+{
|
|
|
|
|
+ if (!_isConnected || _plc == null) return 0f;
|
|
|
|
|
+
|
|
|
|
|
+ var result = await _plc.ReadFloatAsync(address); // 如 "VD100"
|
|
|
|
|
+ return result.IsSuccess ? result.Content : 0f;
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### 读取布尔值(位)
|
|
|
|
|
+
|
|
|
|
|
+```csharp
|
|
|
|
|
+/// <summary>
|
|
|
|
|
+/// 读取布尔值 (单个位)
|
|
|
|
|
+/// </summary>
|
|
|
|
|
+public async Task<bool> ReadBoolAsync(string address)
|
|
|
|
|
+{
|
|
|
|
|
+ if (!_isConnected || _plc == null) return false;
|
|
|
|
|
+
|
|
|
|
|
+ var result = await _plc.ReadBoolAsync(address); // 如 "M10.0"
|
|
|
|
|
+ return result.IsSuccess ? result.Content : false;
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### 批量读取字节(高性能)
|
|
|
|
|
+
|
|
|
|
|
+```csharp
|
|
|
|
|
+/// <summary>
|
|
|
|
|
+/// 批量读取字节数组
|
|
|
|
|
+/// </summary>
|
|
|
|
|
+public async Task<byte[]?> ReadBytesAsync(string address, ushort length)
|
|
|
|
|
+{
|
|
|
|
|
+ if (!_isConnected || _plc == null) return null;
|
|
|
|
|
+
|
|
|
|
|
+ var result = await _plc.ReadAsync(address, length); // 如 "V100", 20
|
|
|
|
|
+ return result.IsSuccess ? result.Content : null;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 使用示例:一次读取 5 个连续的浮点数
|
|
|
|
|
+var bytes = await ReadBytesAsync("V100", 20); // 从 V100 开始读 20 字节
|
|
|
|
|
+if (bytes != null)
|
|
|
|
|
+{
|
|
|
|
|
+ var value1 = ParseFloat(bytes, 0); // VD100
|
|
|
|
|
+ var value2 = ParseFloat(bytes, 4); // VD104
|
|
|
|
|
+ var value3 = ParseFloat(bytes, 8); // VD108
|
|
|
|
|
+ var value4 = ParseFloat(bytes, 12); // VD112
|
|
|
|
|
+ var value5 = ParseFloat(bytes, 16); // VD116
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### 读取整数类型
|
|
|
|
|
+
|
|
|
|
|
+```csharp
|
|
|
|
|
+/// <summary>
|
|
|
|
|
+/// 读取整数(INT/WORD/DINT/DWORD)
|
|
|
|
|
+/// </summary>
|
|
|
|
|
+public async Task<int> ReadIntAsync(string address, string dataType = "INT")
|
|
|
|
|
+{
|
|
|
|
|
+ if (!_isConnected || _plc == null) return 0;
|
|
|
|
|
+
|
|
|
|
|
+ ushort length = dataType switch
|
|
|
|
|
+ {
|
|
|
|
|
+ "INT" or "WORD" => 2,
|
|
|
|
|
+ "DINT" or "DWORD" => 4,
|
|
|
|
|
+ _ => 2
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ var result = await _plc.ReadAsync(address, length);
|
|
|
|
|
+ if (!result.IsSuccess || result.Content == null) return 0;
|
|
|
|
|
+
|
|
|
|
|
+ var bytes = result.Content;
|
|
|
|
|
+ if (BitConverter.IsLittleEndian)
|
|
|
|
|
+ {
|
|
|
|
|
+ Array.Reverse(bytes);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return dataType switch
|
|
|
|
|
+ {
|
|
|
|
|
+ "INT" => BitConverter.ToInt16(bytes, 0),
|
|
|
|
|
+ "WORD" => BitConverter.ToUInt16(bytes, 0),
|
|
|
|
|
+ "DINT" => BitConverter.ToInt32(bytes, 0),
|
|
|
|
|
+ "DWORD" => (int)BitConverter.ToUInt32(bytes, 0),
|
|
|
|
|
+ _ => 0
|
|
|
|
|
+ };
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 数据写入
|
|
|
|
|
+
|
|
|
|
|
+### 写入浮点数
|
|
|
|
|
+
|
|
|
|
|
+```csharp
|
|
|
|
|
+/// <summary>
|
|
|
|
|
+/// 写入浮点数
|
|
|
|
|
+/// </summary>
|
|
|
|
|
+public async Task<bool> WriteFloatAsync(string address, float value)
|
|
|
|
|
+{
|
|
|
|
|
+ if (!_isConnected || _plc == null) return false;
|
|
|
|
|
+
|
|
|
|
|
+ var result = await _plc.WriteAsync(address, value); // 如 "VD100", 123.45f
|
|
|
|
|
+ return result.IsSuccess;
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### 写入布尔值
|
|
|
|
|
+
|
|
|
|
|
+```csharp
|
|
|
|
|
+/// <summary>
|
|
|
|
|
+/// 写入布尔值
|
|
|
|
|
+/// </summary>
|
|
|
|
|
+public async Task<bool> WriteBoolAsync(string address, bool value)
|
|
|
|
|
+{
|
|
|
|
|
+ if (!_isConnected || _plc == null) return false;
|
|
|
|
|
+
|
|
|
|
|
+ var result = await _plc.WriteAsync(address, value); // 如 "M10.0", true
|
|
|
|
|
+ return result.IsSuccess;
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### 写入整数
|
|
|
|
|
+
|
|
|
|
|
+```csharp
|
|
|
|
|
+/// <summary>
|
|
|
|
|
+/// 写入整数
|
|
|
|
|
+/// </summary>
|
|
|
|
|
+public async Task<bool> WriteIntAsync(string address, int value, string dataType = "INT")
|
|
|
|
|
+{
|
|
|
|
|
+ if (!_isConnected || _plc == null) return false;
|
|
|
|
|
+
|
|
|
|
|
+ object data = dataType switch
|
|
|
|
|
+ {
|
|
|
|
|
+ "INT" => (short)value,
|
|
|
|
|
+ "WORD" => (ushort)value,
|
|
|
|
|
+ "DINT" => value,
|
|
|
|
|
+ "DWORD" => (uint)value,
|
|
|
|
|
+ _ => (short)value
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ var result = await _plc.WriteAsync(address, data);
|
|
|
|
|
+ return result.IsSuccess;
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 字节序处理
|
|
|
|
|
+
|
|
|
|
|
+**重要**:西门子 S7 PLC 使用 **大端序(Big-Endian)**,而 Windows 使用 **小端序(Little-Endian)**,读取字节后需要进行转换。
|
|
|
|
|
+
|
|
|
|
|
+### 浮点数解析
|
|
|
|
|
+
|
|
|
|
|
+```csharp
|
|
|
|
|
+/// <summary>
|
|
|
|
|
+/// 从字节数组解析浮点数(S7 大端序 → 本地小端序)
|
|
|
|
|
+/// </summary>
|
|
|
|
|
+public static float ParseFloat(byte[] data, int offset)
|
|
|
|
|
+{
|
|
|
|
|
+ if (data == null || offset + 4 > data.Length) return 0f;
|
|
|
|
|
+
|
|
|
|
|
+ if (BitConverter.IsLittleEndian)
|
|
|
|
|
+ {
|
|
|
|
|
+ // S7 是大端序,需要翻转字节
|
|
|
|
|
+ var bytes = new byte[] {
|
|
|
|
|
+ data[offset + 3], data[offset + 2],
|
|
|
|
|
+ data[offset + 1], data[offset]
|
|
|
|
|
+ };
|
|
|
|
|
+ return BitConverter.ToSingle(bytes, 0);
|
|
|
|
|
+ }
|
|
|
|
|
+ return BitConverter.ToSingle(data, offset);
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### 位解析
|
|
|
|
|
+
|
|
|
|
|
+```csharp
|
|
|
|
|
+/// <summary>
|
|
|
|
|
+/// 从字节中提取特定位
|
|
|
|
|
+/// </summary>
|
|
|
|
|
+public static bool ParseBit(byte[] data, int byteOffset, int bitOffset)
|
|
|
|
|
+{
|
|
|
|
|
+ if (data == null || byteOffset >= data.Length) return false;
|
|
|
|
|
+ return (data[byteOffset] & (1 << bitOffset)) != 0;
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 地址配置管理 PlcConfig
|
|
|
|
|
+
|
|
|
|
|
+将所有 PLC 地址集中管理,支持从配置文件覆盖,避免硬编码散落各处。
|
|
|
|
|
+
|
|
|
|
|
+```csharp
|
|
|
|
|
+using System.Text.Json;
|
|
|
|
|
+
|
|
|
|
|
+/// <summary>
|
|
|
|
|
+/// PLC 地址配置 - 所有地址映射集中管理,支持配置文件覆盖
|
|
|
|
|
+/// </summary>
|
|
|
|
|
+public static class PlcConfig
|
|
|
|
|
+{
|
|
|
|
|
+ // 地址覆盖表(从配置文件加载)
|
|
|
|
|
+ private static Dictionary<string, string> _overrides = new();
|
|
|
|
|
+
|
|
|
|
|
+ /// <summary>
|
|
|
|
|
+ /// 从配置文件加载地址覆盖
|
|
|
|
|
+ /// </summary>
|
|
|
|
|
+ public static void LoadFromConfig(string configPath = "plc-addresses.json")
|
|
|
|
|
+ {
|
|
|
|
|
+ if (File.Exists(configPath))
|
|
|
|
|
+ {
|
|
|
|
|
+ var json = File.ReadAllText(configPath);
|
|
|
|
|
+ _overrides = JsonSerializer.Deserialize<Dictionary<string, string>>(json) ?? new();
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// <summary>
|
|
|
|
|
+ /// 获取地址(优先使用配置覆盖值)
|
|
|
|
|
+ /// </summary>
|
|
|
|
|
+ private static string Addr(string tagName, string defaultAddr)
|
|
|
|
|
+ {
|
|
|
|
|
+ return _overrides.TryGetValue(tagName, out var addr) ? addr : defaultAddr;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ═══ 读取地址 ═══
|
|
|
|
|
+
|
|
|
|
|
+ // 水箱液位 (float, VD)
|
|
|
|
|
+ public static string Tank1Level => Addr(nameof(Tank1Level), "VD100");
|
|
|
|
|
+ public static string Tank2Level => Addr(nameof(Tank2Level), "VD104");
|
|
|
|
|
+ public static string Tank3Level => Addr(nameof(Tank3Level), "VD108");
|
|
|
|
|
+ public static string Tank4Level => Addr(nameof(Tank4Level), "VD112");
|
|
|
|
|
+
|
|
|
|
|
+ // 流量 (float, VD)
|
|
|
|
|
+ public static string InflowRate => Addr(nameof(InflowRate), "VD200");
|
|
|
|
|
+ public static string OutflowRate => Addr(nameof(OutflowRate), "VD204");
|
|
|
|
|
+
|
|
|
|
|
+ // 泵运行状态 (bool, Q)
|
|
|
|
|
+ public const string Pump1Run = "Q0.0";
|
|
|
|
|
+ public const string Pump2Run = "Q0.1";
|
|
|
|
|
+ public const string Pump3Run = "Q0.2";
|
|
|
|
|
+ public const string Pump4Run = "Q0.3";
|
|
|
|
|
+ public const string Pump5Run = "Q0.4";
|
|
|
|
|
+
|
|
|
|
|
+ // 泵故障 (bool, I)
|
|
|
|
|
+ public const string Pump1Fault = "I0.0";
|
|
|
|
|
+ public const string Pump2Fault = "I0.1";
|
|
|
|
|
+ public const string Pump3Fault = "I0.2";
|
|
|
|
|
+ public const string Pump4Fault = "I0.3";
|
|
|
|
|
+ public const string Pump5Fault = "I0.4";
|
|
|
|
|
+
|
|
|
|
|
+ // 泵频率 (float, VD)
|
|
|
|
|
+ public static string Pump1Freq => Addr(nameof(Pump1Freq), "VD300");
|
|
|
|
|
+ public static string Pump2Freq => Addr(nameof(Pump2Freq), "VD304");
|
|
|
|
|
+ public static string Pump3Freq => Addr(nameof(Pump3Freq), "VD308");
|
|
|
|
|
+ public static string Pump4Freq => Addr(nameof(Pump4Freq), "VD312");
|
|
|
|
|
+ public static string Pump5Freq => Addr(nameof(Pump5Freq), "VD316");
|
|
|
|
|
+
|
|
|
|
|
+ // 泵电流 (float, VD)
|
|
|
|
|
+ public static string Pump1Current => Addr(nameof(Pump1Current), "VD320");
|
|
|
|
|
+ public static string Pump2Current => Addr(nameof(Pump2Current), "VD324");
|
|
|
|
|
+ public static string Pump3Current => Addr(nameof(Pump3Current), "VD328");
|
|
|
|
|
+ public static string Pump4Current => Addr(nameof(Pump4Current), "VD332");
|
|
|
|
|
+ public static string Pump5Current => Addr(nameof(Pump5Current), "VD336");
|
|
|
|
|
+
|
|
|
|
|
+ // 风机 (bool)
|
|
|
|
|
+ public const string Fan1Run = "Q0.5";
|
|
|
|
|
+ public const string Fan2Run = "Q0.6";
|
|
|
|
|
+ public const string Fan1Fault = "I0.5";
|
|
|
|
|
+ public const string Fan2Fault = "I0.6";
|
|
|
|
|
+
|
|
|
|
|
+ // 阀门位置 (int, VD)
|
|
|
|
|
+ public static string Valve1Position => Addr(nameof(Valve1Position), "VD500");
|
|
|
|
|
+ public static string Valve2Position => Addr(nameof(Valve2Position), "VD504");
|
|
|
|
|
+ public static string Valve3Position => Addr(nameof(Valve3Position), "VD508");
|
|
|
|
|
+ public static string Valve4Position => Addr(nameof(Valve4Position), "VD512");
|
|
|
|
|
+
|
|
|
|
|
+ // 系统模式 (bool, M)
|
|
|
|
|
+ public const string SystemMode = "M0.0";
|
|
|
|
|
+
|
|
|
|
|
+ // ═══ 写入地址 ═══
|
|
|
|
|
+
|
|
|
|
|
+ // 泵启停控制 (bool, Q)
|
|
|
|
|
+ public const string Pump1Start = "Q0.0";
|
|
|
|
|
+ public const string Pump2Start = "Q0.1";
|
|
|
|
|
+ public const string Pump3Start = "Q0.2";
|
|
|
|
|
+ public const string Pump4Start = "Q0.3";
|
|
|
|
|
+ public const string Pump5Start = "Q0.4";
|
|
|
|
|
+
|
|
|
|
|
+ // 风机启停 (bool, Q)
|
|
|
|
|
+ public const string Fan1Start = "Q0.5";
|
|
|
|
|
+ public const string Fan2Start = "Q0.6";
|
|
|
|
|
+
|
|
|
|
|
+ // 阀门控制 (bool, Q)
|
|
|
|
|
+ public const string Valve1Control = "Q1.0";
|
|
|
|
|
+ public const string Valve2Control = "Q1.1";
|
|
|
|
|
+ public const string Valve3Control = "Q1.2";
|
|
|
|
|
+ public const string Valve4Control = "Q1.3";
|
|
|
|
|
+
|
|
|
|
|
+ // 泵频率设定 (float, VD)
|
|
|
|
|
+ public const string PumpFreqSetpoint = "VD600";
|
|
|
|
|
+
|
|
|
|
|
+ // ═══ 辅助方法 ═══
|
|
|
|
|
+
|
|
|
|
|
+ public static string GetPumpRunAddress(int index) => index switch
|
|
|
|
|
+ {
|
|
|
|
|
+ 0 => Pump1Run, 1 => Pump2Run, 2 => Pump3Run, 3 => Pump4Run, 4 => Pump5Run,
|
|
|
|
|
+ _ => throw new ArgumentOutOfRangeException(nameof(index))
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ public static string GetPumpFaultAddress(int index) => index switch
|
|
|
|
|
+ {
|
|
|
|
|
+ 0 => Pump1Fault, 1 => Pump2Fault, 2 => Pump3Fault, 3 => Pump4Fault, 4 => Pump5Fault,
|
|
|
|
|
+ _ => throw new ArgumentOutOfRangeException(nameof(index))
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ public static string GetPumpFreqAddress(int index) => index switch
|
|
|
|
|
+ {
|
|
|
|
|
+ 0 => Pump1Freq, 1 => Pump2Freq, 2 => Pump3Freq, 3 => Pump4Freq, 4 => Pump5Freq,
|
|
|
|
|
+ _ => throw new ArgumentOutOfRangeException(nameof(index))
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ public static string GetPumpCurrentAddress(int index) => index switch
|
|
|
|
|
+ {
|
|
|
|
|
+ 0 => Pump1Current, 1 => Pump2Current, 2 => Pump3Current, 3 => Pump4Current, 4 => Pump5Current,
|
|
|
|
|
+ _ => throw new ArgumentOutOfRangeException(nameof(index))
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ public static string GetValveControlAddress(int index) => index switch
|
|
|
|
|
+ {
|
|
|
|
|
+ 0 => Valve1Control, 1 => Valve2Control, 2 => Valve3Control, 3 => Valve4Control,
|
|
|
|
|
+ _ => throw new ArgumentOutOfRangeException(nameof(index))
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ public static string GetValvePositionAddress(int index) => index switch
|
|
|
|
|
+ {
|
|
|
|
|
+ 0 => Valve1Position, 1 => Valve2Position, 2 => Valve3Position, 3 => Valve4Position,
|
|
|
|
|
+ _ => throw new ArgumentOutOfRangeException(nameof(index))
|
|
|
|
|
+ };
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### 地址覆盖配置文件(plc-addresses.json)
|
|
|
|
|
+
|
|
|
|
|
+```json
|
|
|
|
|
+{
|
|
|
|
|
+ "Tank1Level": "VD100",
|
|
|
|
|
+ "Tank2Level": "VD104",
|
|
|
|
|
+ "InflowRate": "VD200",
|
|
|
|
|
+ "OutflowRate": "VD204",
|
|
|
|
|
+ "Pump1Freq": "VD300",
|
|
|
|
|
+ "Pump2Freq": "VD304",
|
|
|
|
|
+ "Pump1Current": "VD320",
|
|
|
|
|
+ "Pump2Current": "VD324",
|
|
|
|
|
+ "Valve1Position": "VD500",
|
|
|
|
|
+ "Valve2Position": "VD504"
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## S7 地址解析器(批量读取优化)
|
|
|
|
|
+
|
|
|
|
|
+将分散的地址自动分组为连续区间,减少 PLC 通信次数(从 N 次降为 G 次)。
|
|
|
|
|
+
|
|
|
|
|
+```csharp
|
|
|
|
|
+using System.Text.RegularExpressions;
|
|
|
|
|
+
|
|
|
|
|
+/// <summary>
|
|
|
|
|
+/// 西门子 S7 地址解析工具
|
|
|
|
|
+/// 支持格式: V/VB/VW/VD/M/MB/MW/MD/I/Q/IB/QB/IW/QW
|
|
|
|
|
+/// </summary>
|
|
|
|
|
+public static class S7AddressParser
|
|
|
|
|
+{
|
|
|
|
|
+ public enum AreaType
|
|
|
|
|
+ {
|
|
|
|
|
+ Input, // I - 输入
|
|
|
|
|
+ Output, // Q - 输出
|
|
|
|
|
+ Marker, // M - 标志位
|
|
|
|
|
+ Variable // V - 变量存储区
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public record ParsedAddress(
|
|
|
|
|
+ AreaType Area,
|
|
|
|
|
+ int ByteOffset,
|
|
|
|
|
+ int BitIndex,
|
|
|
|
|
+ int ByteLength,
|
|
|
|
|
+ string OriginalAddress
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ /// <summary>
|
|
|
|
|
+ /// 解析 S7 地址
|
|
|
|
|
+ /// </summary>
|
|
|
|
|
+ public static ParsedAddress? Parse(string address)
|
|
|
|
|
+ {
|
|
|
|
|
+ if (string.IsNullOrEmpty(address)) return null;
|
|
|
|
|
+
|
|
|
|
|
+ address = address.Trim().ToUpper();
|
|
|
|
|
+
|
|
|
|
|
+ var match = Regex.Match(address,
|
|
|
|
|
+ @"^(I|Q|M|V|IB|QB|MB|VB|IW|QW|MW|VW|ID|QD|MD|VD)(\d+)(?:\.(\d))?$");
|
|
|
|
|
+ if (!match.Success) return null;
|
|
|
|
|
+
|
|
|
|
|
+ var prefix = match.Groups[1].Value;
|
|
|
|
|
+ var byteOffset = int.Parse(match.Groups[2].Value);
|
|
|
|
|
+ var bitIndex = match.Groups[3].Success ? int.Parse(match.Groups[3].Value) : -1;
|
|
|
|
|
+
|
|
|
|
|
+ AreaType area;
|
|
|
|
|
+ int length;
|
|
|
|
|
+
|
|
|
|
|
+ switch (prefix)
|
|
|
|
|
+ {
|
|
|
|
|
+ case "I": case "IB": area = AreaType.Input; length = 1; break;
|
|
|
|
|
+ case "IW": area = AreaType.Input; length = 2; break;
|
|
|
|
|
+ case "ID": area = AreaType.Input; length = 4; break;
|
|
|
|
|
+ case "Q": case "QB": area = AreaType.Output; length = 1; break;
|
|
|
|
|
+ case "QW": area = AreaType.Output; length = 2; break;
|
|
|
|
|
+ case "QD": area = AreaType.Output; length = 4; break;
|
|
|
|
|
+ case "M": case "MB": area = AreaType.Marker; length = 1; break;
|
|
|
|
|
+ case "MW": area = AreaType.Marker; length = 2; break;
|
|
|
|
|
+ case "MD": area = AreaType.Marker; length = 4; break;
|
|
|
|
|
+ case "V": case "VB": area = AreaType.Variable; length = 1; break;
|
|
|
|
|
+ case "VW": area = AreaType.Variable; length = 2; break;
|
|
|
|
|
+ case "VD": area = AreaType.Variable; length = 4; break;
|
|
|
|
|
+ default: return null;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return new ParsedAddress(area, byteOffset, bitIndex, length, address);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// <summary>
|
|
|
|
|
+ /// 地址组(用于批量读取)
|
|
|
|
|
+ /// </summary>
|
|
|
|
|
+ public record AddressGroup(
|
|
|
|
|
+ AreaType Area,
|
|
|
|
|
+ int StartByte,
|
|
|
|
|
+ int TotalBytes,
|
|
|
|
|
+ List<(string Key, int Offset)> Items
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ /// <summary>
|
|
|
|
|
+ /// 将地址列表分组为连续的批量读取组
|
|
|
|
|
+ /// </summary>
|
|
|
|
|
+ /// <param name="addresses">地址列表 (key, address)</param>
|
|
|
|
|
+ /// <param name="maxGroupBytes">单个批次的最大字节数(默认 200)</param>
|
|
|
|
|
+ public static List<AddressGroup> GroupConsecutiveAddresses(
|
|
|
|
|
+ List<(string Key, string Address)> addresses,
|
|
|
|
|
+ int maxGroupBytes = 200)
|
|
|
|
|
+ {
|
|
|
|
|
+ var groups = new List<AddressGroup>();
|
|
|
|
|
+
|
|
|
|
|
+ // 按区域分组
|
|
|
|
|
+ var byArea = addresses
|
|
|
|
|
+ .Select(a => (a.Key, Parsed: Parse(a.Address)))
|
|
|
|
|
+ .Where(a => a.Parsed != null)
|
|
|
|
|
+ .GroupBy(a => a.Parsed!.Area);
|
|
|
|
|
+
|
|
|
|
|
+ foreach (var areaGroup in byArea)
|
|
|
|
|
+ {
|
|
|
|
|
+ var sorted = areaGroup
|
|
|
|
|
+ .OrderBy(a => a.Parsed!.ByteOffset)
|
|
|
|
|
+ .ToList();
|
|
|
|
|
+
|
|
|
|
|
+ var currentGroup = new List<(string Key, int Offset)>();
|
|
|
|
|
+ int groupStart = 0;
|
|
|
|
|
+ int groupEnd = 0;
|
|
|
|
|
+
|
|
|
|
|
+ foreach (var item in sorted)
|
|
|
|
|
+ {
|
|
|
|
|
+ var offset = item.Parsed!.ByteOffset;
|
|
|
|
|
+ var length = item.Parsed.ByteLength;
|
|
|
|
|
+
|
|
|
|
|
+ if (currentGroup.Count == 0)
|
|
|
|
|
+ {
|
|
|
|
|
+ groupStart = offset;
|
|
|
|
|
+ groupEnd = offset + length;
|
|
|
|
|
+ currentGroup.Add((item.Key, offset - groupStart));
|
|
|
|
|
+ }
|
|
|
|
|
+ else if (offset + length <= groupStart + maxGroupBytes &&
|
|
|
|
|
+ offset <= groupEnd + 4) // 允许最多 4 字节间隔
|
|
|
|
|
+ {
|
|
|
|
|
+ groupEnd = Math.Max(groupEnd, offset + length);
|
|
|
|
|
+ currentGroup.Add((item.Key, offset - groupStart));
|
|
|
|
|
+ }
|
|
|
|
|
+ else
|
|
|
|
|
+ {
|
|
|
|
|
+ // 保存当前组,开始新组
|
|
|
|
|
+ if (currentGroup.Count > 0)
|
|
|
|
|
+ {
|
|
|
|
|
+ groups.Add(new AddressGroup(
|
|
|
|
|
+ areaGroup.Key,
|
|
|
|
|
+ groupStart,
|
|
|
|
|
+ groupEnd - groupStart,
|
|
|
|
|
+ currentGroup
|
|
|
|
|
+ ));
|
|
|
|
|
+ }
|
|
|
|
|
+ groupStart = offset;
|
|
|
|
|
+ groupEnd = offset + length;
|
|
|
|
|
+ currentGroup = new List<(string Key, int Offset)> { (item.Key, 0) };
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (currentGroup.Count > 0)
|
|
|
|
|
+ {
|
|
|
|
|
+ groups.Add(new AddressGroup(
|
|
|
|
|
+ areaGroup.Key,
|
|
|
|
|
+ groupStart,
|
|
|
|
|
+ groupEnd - groupStart,
|
|
|
|
|
+ currentGroup
|
|
|
|
|
+ ));
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return groups;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// <summary>
|
|
|
|
|
+ /// 将区域类型转换为可识别的地址前缀
|
|
|
|
|
+ /// </summary>
|
|
|
|
|
+ public static string GetAddressPrefix(AreaType area, int byteLength)
|
|
|
|
|
+ {
|
|
|
|
|
+ var prefix = area switch
|
|
|
|
|
+ {
|
|
|
|
|
+ AreaType.Input => "I",
|
|
|
|
|
+ AreaType.Output => "Q",
|
|
|
|
|
+ AreaType.Marker => "M",
|
|
|
|
|
+ AreaType.Variable => "V",
|
|
|
|
|
+ _ => "V"
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ return byteLength switch
|
|
|
|
|
+ {
|
|
|
|
|
+ 1 => prefix,
|
|
|
|
|
+ 2 => $"{prefix}W",
|
|
|
|
|
+ 4 => $"{prefix}D",
|
|
|
|
|
+ _ => prefix
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### 批量读取使用示例
|
|
|
|
|
+
|
|
|
|
|
+```csharp
|
|
|
|
|
+// 待读取的地址列表
|
|
|
|
|
+var addresses = new List<(string key, string addr)>
|
|
|
|
|
+{
|
|
|
|
|
+ ("Tank1Level", "VD100"),
|
|
|
|
|
+ ("Tank2Level", "VD104"),
|
|
|
|
|
+ ("Tank3Level", "VD108"),
|
|
|
|
|
+ ("Tank4Level", "VD112"),
|
|
|
|
|
+ ("InflowRate", "VD200"),
|
|
|
|
|
+ ("OutflowRate", "VD204"),
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+// 自动分组
|
|
|
|
|
+var groups = S7AddressParser.GroupConsecutiveAddresses(addresses);
|
|
|
|
|
+// 结果:
|
|
|
|
|
+// 组1: VD100-VD112 (4个地址, 16字节) → 一次读取
|
|
|
|
|
+// 组2: VD200-VD204 (2个地址, 8字节) → 一次读取
|
|
|
|
|
+// 原需 6 次通信 → 优化为 2 次
|
|
|
|
|
+
|
|
|
|
|
+// 逐组批量读取
|
|
|
|
|
+var results = new Dictionary<string, float>();
|
|
|
|
|
+foreach (var group in groups)
|
|
|
|
|
+{
|
|
|
|
|
+ var prefix = S7AddressParser.GetAddressPrefix(group.Area, group.TotalBytes switch
|
|
|
|
|
+ {
|
|
|
|
|
+ >= 4 => 4, >= 2 => 2, _ => 1
|
|
|
|
|
+ });
|
|
|
|
|
+ var batchAddr = $"{prefix}{group.StartByte}";
|
|
|
|
|
+
|
|
|
|
|
+ var bytes = await plc.ReadBytesAsync(batchAddr, (ushort)group.TotalBytes);
|
|
|
|
|
+ if (bytes == null) continue;
|
|
|
|
|
+
|
|
|
|
|
+ foreach (var (key, offset) in group.Items)
|
|
|
|
|
+ {
|
|
|
|
|
+ if (offset + 4 <= bytes.Length)
|
|
|
|
|
+ results[key] = ParseFloat(bytes, offset);
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 数据模型 PlcDataModel
|
|
|
|
|
+
|
|
|
|
|
+内存数据快照,UI 通过此模型绑定显示数据。
|
|
|
|
|
+
|
|
|
|
|
+```csharp
|
|
|
|
|
+using System.Collections.Concurrent;
|
|
|
|
|
+using CommunityToolkit.Mvvm.ComponentModel;
|
|
|
|
|
+
|
|
|
|
|
+/// <summary>
|
|
|
|
|
+/// PLC 数据模型 - 内存数据快照
|
|
|
|
|
+/// </summary>
|
|
|
|
|
+public partial class PlcDataModel : ObservableObject
|
|
|
|
|
+{
|
|
|
|
|
+ // ═══ 水箱液位 ═══
|
|
|
|
|
+ [ObservableProperty] private float _tank1Level;
|
|
|
|
|
+ [ObservableProperty] private float _tank2Level;
|
|
|
|
|
+ [ObservableProperty] private float _tank3Level;
|
|
|
|
|
+ [ObservableProperty] private float _tank4Level;
|
|
|
|
|
+
|
|
|
|
|
+ // ═══ 流量 ═══
|
|
|
|
|
+ [ObservableProperty] private float _inflowRate;
|
|
|
|
|
+ [ObservableProperty] private float _outflowRate;
|
|
|
|
|
+ [ObservableProperty] private float _flowDelta;
|
|
|
|
|
+
|
|
|
|
|
+ // ═══ 泵状态 (0-4) ═══
|
|
|
|
|
+ [ObservableProperty] private bool _pump1Running;
|
|
|
|
|
+ [ObservableProperty] private bool _pump2Running;
|
|
|
|
|
+ [ObservableProperty] private bool _pump3Running;
|
|
|
|
|
+ [ObservableProperty] private bool _pump4Running;
|
|
|
|
|
+ [ObservableProperty] private bool _pump5Running;
|
|
|
|
|
+
|
|
|
|
|
+ // 泵故障
|
|
|
|
|
+ [ObservableProperty] private bool _pump1Fault;
|
|
|
|
|
+ [ObservableProperty] private bool _pump2Fault;
|
|
|
|
|
+ [ObservableProperty] private bool _pump3Fault;
|
|
|
|
|
+ [ObservableProperty] private bool _pump4Fault;
|
|
|
|
|
+ [ObservableProperty] private bool _pump5Fault;
|
|
|
|
|
+
|
|
|
|
|
+ // 泵频率
|
|
|
|
|
+ [ObservableProperty] private float _pump1Freq;
|
|
|
|
|
+ [ObservableProperty] private float _pump2Freq;
|
|
|
|
|
+ [ObservableProperty] private float _pump3Freq;
|
|
|
|
|
+ [ObservableProperty] private float _pump4Freq;
|
|
|
|
|
+ [ObservableProperty] private float _pump5Freq;
|
|
|
|
|
+
|
|
|
|
|
+ // 泵电流
|
|
|
|
|
+ [ObservableProperty] private float _pump1Current;
|
|
|
|
|
+ [ObservableProperty] private float _pump2Current;
|
|
|
|
|
+ [ObservableProperty] private float _pump3Current;
|
|
|
|
|
+ [ObservableProperty] private float _pump4Current;
|
|
|
|
|
+ [ObservableProperty] private float _pump5Current;
|
|
|
|
|
+
|
|
|
|
|
+ // ═══ 风机状态 ═══
|
|
|
|
|
+ [ObservableProperty] private bool _fan1Running;
|
|
|
|
|
+ [ObservableProperty] private bool _fan2Running;
|
|
|
|
|
+ [ObservableProperty] private bool _fan1Fault;
|
|
|
|
|
+ [ObservableProperty] private bool _fan2Fault;
|
|
|
|
|
+
|
|
|
|
|
+ // ═══ 阀门位置 (0-100, 0=全关, 100=全开) ═══
|
|
|
|
|
+ [ObservableProperty] private int _valve1Position;
|
|
|
|
|
+ [ObservableProperty] private int _valve2Position;
|
|
|
|
|
+ [ObservableProperty] private int _valve3Position;
|
|
|
|
|
+ [ObservableProperty] private int _valve4Position;
|
|
|
|
|
+
|
|
|
|
|
+ // ═══ 系统状态 ═══
|
|
|
|
|
+ [ObservableProperty] private bool _isAutoMode;
|
|
|
|
|
+
|
|
|
|
|
+ // ═══ 数据质量 ═══
|
|
|
|
|
+ [ObservableProperty] private DateTime _lastReadTime;
|
|
|
|
|
+ [ObservableProperty] private bool _isDataValid;
|
|
|
|
|
+ [ObservableProperty] private bool _isPlcConnected;
|
|
|
|
|
+
|
|
|
|
|
+ // ═══ 动态寄存器(由项目配置驱动) ═══
|
|
|
|
|
+ /// <summary>模拟量动态寄存器(名称 → 值)</summary>
|
|
|
|
|
+ public ConcurrentDictionary<string, float> AnalogRegisters { get; } = new();
|
|
|
|
|
+
|
|
|
|
|
+ /// <summary>数字量动态寄存器(名称 → 值)</summary>
|
|
|
|
|
+ public ConcurrentDictionary<string, bool> DigitalRegisters { get; } = new();
|
|
|
|
|
+
|
|
|
|
|
+ public float GetAnalogRegister(string key, float defaultValue = 0)
|
|
|
|
|
+ => AnalogRegisters.TryGetValue(key, out var value) ? value : defaultValue;
|
|
|
|
|
+
|
|
|
|
|
+ public bool GetDigitalRegister(string key, bool defaultValue = false)
|
|
|
|
|
+ => DigitalRegisters.TryGetValue(key, out var value) ? value : defaultValue;
|
|
|
|
|
+
|
|
|
|
|
+ // ═══ 计算属性 ═══
|
|
|
|
|
+ public int RunningPumpCount => (Pump1Running ? 1 : 0) + (Pump2Running ? 1 : 0) +
|
|
|
|
|
+ (Pump3Running ? 1 : 0) + (Pump4Running ? 1 : 0) +
|
|
|
|
|
+ (Pump5Running ? 1 : 0);
|
|
|
|
|
+
|
|
|
|
|
+ public bool HasAnyFault => Pump1Fault || Pump2Fault || Pump3Fault || Pump4Fault ||
|
|
|
|
|
+ Pump5Fault || Fan1Fault || Fan2Fault;
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 轮询服务模板(完整版)
|
|
|
|
|
+
|
|
|
|
|
+包含:**并发控制**、**死区过滤**、**降级模式**、**指数退避重连**、**批量读取优化**。
|
|
|
|
|
+
|
|
|
|
|
+```csharp
|
|
|
|
|
+using System.Collections.Concurrent;
|
|
|
|
|
+using HslCommunication;
|
|
|
|
|
+using HslCommunication.Profinet.Siemens;
|
|
|
|
|
+using Microsoft.Extensions.Logging;
|
|
|
|
|
+
|
|
|
|
|
+public class PlcPollingService : IDisposable
|
|
|
|
|
+{
|
|
|
|
|
+ private SiemensS7Net? _plc;
|
|
|
|
|
+ private bool _isConnected;
|
|
|
|
|
+ private CancellationTokenSource? _cts;
|
|
|
|
|
+ private Task? _pollingTask;
|
|
|
|
|
+ private readonly PlcDataModel _data = new();
|
|
|
|
|
+
|
|
|
|
|
+ // PLC 读取并发限制(防止同时发起过多请求导致 PLC 过载)
|
|
|
|
|
+ private static readonly SemaphoreSlim _plcReadSemaphore = new(4, 4);
|
|
|
|
|
+
|
|
|
|
|
+ private readonly ILogger? _logger;
|
|
|
|
|
+
|
|
|
|
|
+ // ═══ 配置 ═══
|
|
|
|
|
+ public int PollingIntervalMs { get; set; } = 1000;
|
|
|
|
|
+ public float DeadbandPercent { get; set; } = 0.5f;
|
|
|
|
|
+ public int MaxReconnectAttempts { get; set; } = 10;
|
|
|
|
|
+ public int ReconnectBaseDelayMs { get; set; } = 1000;
|
|
|
|
|
+
|
|
|
|
|
+ // ═══ 事件 ═══
|
|
|
|
|
+ public event Action<PlcDataModel>? DataUpdated;
|
|
|
|
|
+ public event Action<bool>? ConnectionStateChanged;
|
|
|
|
|
+ public event Action<string>? ErrorOccurred;
|
|
|
|
|
+
|
|
|
|
|
+ public PlcDataModel Data => _data;
|
|
|
|
|
+ public bool IsConnected => _isConnected;
|
|
|
|
|
+
|
|
|
|
|
+ public PlcPollingService(ILogger? logger = null)
|
|
|
|
|
+ {
|
|
|
|
|
+ _logger = logger;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// <summary>启动轮询</summary>
|
|
|
|
|
+ public void Start(string ip, int port = 102, SiemensPLCS series = SiemensPLCS.S200Smart)
|
|
|
|
|
+ {
|
|
|
|
|
+ if (_pollingTask != null && !_pollingTask.IsCompleted) return;
|
|
|
|
|
+
|
|
|
|
|
+ _cts = new CancellationTokenSource();
|
|
|
|
|
+ _pollingTask = Task.Run(() => PollingLoopAsync(ip, port, series, _cts.Token));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// <summary>停止轮询</summary>
|
|
|
|
|
+ public void Stop()
|
|
|
|
|
+ {
|
|
|
|
|
+ _cts?.Cancel();
|
|
|
|
|
+ _pollingTask?.Wait(TimeSpan.FromSeconds(5));
|
|
|
|
|
+ _cts?.Dispose();
|
|
|
|
|
+ _cts = null;
|
|
|
|
|
+ _pollingTask = null;
|
|
|
|
|
+ Disconnect();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private void Disconnect()
|
|
|
|
|
+ {
|
|
|
|
|
+ _plc?.ConnectClose();
|
|
|
|
|
+ _plc?.Dispose();
|
|
|
|
|
+ _plc = null;
|
|
|
|
|
+ _isConnected = false;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// <summary>轮询主循环</summary>
|
|
|
|
|
+ private async Task PollingLoopAsync(string ip, int port, SiemensPLCS series, CancellationToken ct)
|
|
|
|
|
+ {
|
|
|
|
|
+ var reconnectAttempts = 0;
|
|
|
|
|
+ var isInDegradedMode = false;
|
|
|
|
|
+ const int degradedModeRetryIntervalMs = 30000;
|
|
|
|
|
+
|
|
|
|
|
+ while (!ct.IsCancellationRequested)
|
|
|
|
|
+ {
|
|
|
|
|
+ try
|
|
|
|
|
+ {
|
|
|
|
|
+ // 1. 确保连接
|
|
|
|
|
+ if (!_isConnected)
|
|
|
|
|
+ {
|
|
|
|
|
+ var connected = await TryConnectWithRetryAsync(ip, port, series, reconnectAttempts, ct);
|
|
|
|
|
+
|
|
|
|
|
+ if (!connected)
|
|
|
|
|
+ {
|
|
|
|
|
+ reconnectAttempts++;
|
|
|
|
|
+
|
|
|
|
|
+ // 进入或保持在降级模式
|
|
|
|
|
+ if (reconnectAttempts >= MaxReconnectAttempts)
|
|
|
|
|
+ {
|
|
|
|
|
+ if (!isInDegradedMode)
|
|
|
|
|
+ {
|
|
|
|
|
+ isInDegradedMode = true;
|
|
|
|
|
+ ErrorOccurred?.Invoke(
|
|
|
|
|
+ $"PLC 连接失败,已重试 {MaxReconnectAttempts} 次,进入降级模式(每 30 秒重试)");
|
|
|
|
|
+ }
|
|
|
|
|
+ await Task.Delay(degradedModeRetryIntervalMs, ct);
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 正常模式:指数退避
|
|
|
|
|
+ var delayMs = ReconnectBaseDelayMs * Math.Min(reconnectAttempts, 5);
|
|
|
|
|
+ await Task.Delay(delayMs, ct);
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 连接成功
|
|
|
|
|
+ if (isInDegradedMode)
|
|
|
|
|
+ {
|
|
|
|
|
+ isInDegradedMode = false;
|
|
|
|
|
+ ErrorOccurred?.Invoke("PLC 连接已恢复");
|
|
|
|
|
+ }
|
|
|
|
|
+ reconnectAttempts = 0;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 2. 读取数据
|
|
|
|
|
+ await ReadAllDataAsync(ct);
|
|
|
|
|
+
|
|
|
|
|
+ // 3. 检测连接状态变化
|
|
|
|
|
+ ConnectionStateChanged?.Invoke(_isConnected);
|
|
|
|
|
+
|
|
|
|
|
+ // 4. 数据更新事件
|
|
|
|
|
+ DataUpdated?.Invoke(_data);
|
|
|
|
|
+
|
|
|
|
|
+ // 5. 等待下一周期
|
|
|
|
|
+ var nextDelay = isInDegradedMode ? degradedModeRetryIntervalMs : PollingIntervalMs;
|
|
|
|
|
+ await Task.Delay(nextDelay, ct);
|
|
|
|
|
+ }
|
|
|
|
|
+ catch (OperationCanceledException) { break; }
|
|
|
|
|
+ catch (Exception ex)
|
|
|
|
|
+ {
|
|
|
|
|
+ _logger?.LogError(ex, "轮询异常");
|
|
|
|
|
+ ErrorOccurred?.Invoke($"轮询异常: {ex.Message}");
|
|
|
|
|
+ if (!_isConnected) _data.IsPlcConnected = false;
|
|
|
|
|
+ await Task.Delay(PollingIntervalMs * 2, ct);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// <summary>尝试连接(带重试)</summary>
|
|
|
|
|
+ private async Task<bool> TryConnectWithRetryAsync(
|
|
|
|
|
+ string ip, int port, SiemensPLCS series, int attempt, CancellationToken ct)
|
|
|
|
|
+ {
|
|
|
|
|
+ for (int i = 0; i <= attempt && i < 3; i++)
|
|
|
|
|
+ {
|
|
|
|
|
+ if (ct.IsCancellationRequested) return false;
|
|
|
|
|
+
|
|
|
|
|
+ var connected = await ConnectAsync(ip, port, series);
|
|
|
|
|
+ if (connected)
|
|
|
|
|
+ {
|
|
|
|
|
+ _data.IsPlcConnected = true;
|
|
|
|
|
+ ConnectionStateChanged?.Invoke(true);
|
|
|
|
|
+ return true;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (i < attempt)
|
|
|
|
|
+ await Task.Delay(ReconnectBaseDelayMs * (i + 1), ct);
|
|
|
|
|
+ }
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private async Task<bool> ConnectAsync(string ip, int port, SiemensPLCS series)
|
|
|
|
|
+ {
|
|
|
|
|
+ try
|
|
|
|
|
+ {
|
|
|
|
|
+ _plc = new SiemensS7Net(series, ip)
|
|
|
|
|
+ {
|
|
|
|
|
+ Port = port,
|
|
|
|
|
+ ConnectTimeOut = 3000,
|
|
|
|
|
+ ReceiveTimeOut = 3000
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ var result = await _plc.ConnectServerAsync();
|
|
|
|
|
+ _isConnected = result.IsSuccess;
|
|
|
|
|
+
|
|
|
|
|
+ if (_isConnected)
|
|
|
|
|
+ _logger?.LogInformation("PLC 连接成功: {Ip}:{Port}", ip, port);
|
|
|
|
|
+ else
|
|
|
|
|
+ _logger?.LogWarning("PLC 连接失败: {Message}", result.Message);
|
|
|
|
|
+
|
|
|
|
|
+ return _isConnected;
|
|
|
|
|
+ }
|
|
|
|
|
+ catch (Exception ex)
|
|
|
|
|
+ {
|
|
|
|
|
+ _logger?.LogError(ex, "PLC 连接异常");
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// <summary>批量读取所有 PLC 数据</summary>
|
|
|
|
|
+ private async Task ReadAllDataAsync(CancellationToken ct)
|
|
|
|
|
+ {
|
|
|
|
|
+ if (ct.IsCancellationRequested) return;
|
|
|
|
|
+
|
|
|
|
|
+ // 并行发起所有批量读取
|
|
|
|
|
+ var tankTask = ReadBytesAsync(PlcConfig.Tank1Level, 16); // 4 floats: 水箱液位
|
|
|
|
|
+ var flowTask = ReadBytesAsync(PlcConfig.InflowRate, 8); // 2 floats: 流量
|
|
|
|
|
+ var freqTask = ReadBytesAsync(PlcConfig.Pump1Freq, 20); // 5 floats: 泵频率
|
|
|
|
|
+ var qTask = ReadBytesAsync("Q0", 1); // 1 byte: 泵/风机运行状态
|
|
|
|
|
+ var iTask = ReadBytesAsync("I0", 1); // 1 byte: 泵/风机故障状态
|
|
|
|
|
+ var mTask = ReadBytesAsync("M0", 1); // 1 byte: 系统模式
|
|
|
|
|
+
|
|
|
|
|
+ await Task.WhenAll(tankTask, flowTask, freqTask, qTask, iTask, mTask);
|
|
|
|
|
+
|
|
|
|
|
+ if (ct.IsCancellationRequested) return;
|
|
|
|
|
+
|
|
|
|
|
+ var tankData = tankTask.Result;
|
|
|
|
|
+ var flowData = flowTask.Result;
|
|
|
|
|
+ var freqData = freqTask.Result;
|
|
|
|
|
+ var qData = qTask.Result;
|
|
|
|
|
+ var iData = iTask.Result;
|
|
|
|
|
+ var mData = mTask.Result;
|
|
|
|
|
+
|
|
|
|
|
+ // 任一批量读取失败则跳过本周期
|
|
|
|
|
+ if (tankData == null || flowData == null || freqData == null ||
|
|
|
|
|
+ qData == null || iData == null || mData == null)
|
|
|
|
|
+ {
|
|
|
|
|
+ _data.IsDataValid = false;
|
|
|
|
|
+ _data.IsPlcConnected = _isConnected;
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 解析水箱液位 (VD100-VD112, 4 floats)
|
|
|
|
|
+ var tank1 = ParseFloat(tankData, 0);
|
|
|
|
|
+ var tank2 = ParseFloat(tankData, 4);
|
|
|
|
|
+ var tank3 = ParseFloat(tankData, 8);
|
|
|
|
|
+ var tank4 = ParseFloat(tankData, 12);
|
|
|
|
|
+
|
|
|
|
|
+ // 解析流量 (VD200-VD204, 2 floats)
|
|
|
|
|
+ var inflow = ParseFloat(flowData, 0);
|
|
|
|
|
+ var outflow = ParseFloat(flowData, 4);
|
|
|
|
|
+
|
|
|
|
|
+ // 解析泵频率 (VD300-VD316, 5 floats)
|
|
|
|
|
+ var f1 = ParseFloat(freqData, 0);
|
|
|
|
|
+ var f2 = ParseFloat(freqData, 4);
|
|
|
|
|
+ var f3 = ParseFloat(freqData, 8);
|
|
|
|
|
+ var f4 = ParseFloat(freqData, 12);
|
|
|
|
|
+ var f5 = ParseFloat(freqData, 16);
|
|
|
|
|
+
|
|
|
|
|
+ // 解析 Q0 字节: bit 0-4 = 泵运行, bit 5-6 = 风机运行
|
|
|
|
|
+ var p1 = ParseBit(qData, 0, 0);
|
|
|
|
|
+ var p2 = ParseBit(qData, 0, 1);
|
|
|
|
|
+ var p3 = ParseBit(qData, 0, 2);
|
|
|
|
|
+ var p4 = ParseBit(qData, 0, 3);
|
|
|
|
|
+ var p5 = ParseBit(qData, 0, 4);
|
|
|
|
|
+ var fan1 = ParseBit(qData, 0, 5);
|
|
|
|
|
+ var fan2 = ParseBit(qData, 0, 6);
|
|
|
|
|
+
|
|
|
|
|
+ // 解析 I0 字节: bit 0-4 = 泵故障, bit 5-6 = 风机故障
|
|
|
|
|
+ var pf1 = ParseBit(iData, 0, 0);
|
|
|
|
|
+ var pf2 = ParseBit(iData, 0, 1);
|
|
|
|
|
+ var pf3 = ParseBit(iData, 0, 2);
|
|
|
|
|
+ var pf4 = ParseBit(iData, 0, 3);
|
|
|
|
|
+ var pf5 = ParseBit(iData, 0, 4);
|
|
|
|
|
+ var fanf1 = ParseBit(iData, 0, 5);
|
|
|
|
|
+ var fanf2 = ParseBit(iData, 0, 6);
|
|
|
|
|
+
|
|
|
|
|
+ // 解析 M0 字节: bit 0 = 系统模式
|
|
|
|
|
+ var autoMode = ParseBit(mData, 0, 0);
|
|
|
|
|
+
|
|
|
|
|
+ // 应用死区过滤后更新数据
|
|
|
|
|
+ ApplyWithDeadband(_data.Tank1Level, tank1, v => _data.Tank1Level = v);
|
|
|
|
|
+ ApplyWithDeadband(_data.Tank2Level, tank2, v => _data.Tank2Level = v);
|
|
|
|
|
+ ApplyWithDeadband(_data.Tank3Level, tank3, v => _data.Tank3Level = v);
|
|
|
|
|
+ ApplyWithDeadband(_data.Tank4Level, tank4, v => _data.Tank4Level = v);
|
|
|
|
|
+ ApplyWithDeadband(_data.InflowRate, inflow, v => _data.InflowRate = v);
|
|
|
|
|
+ ApplyWithDeadband(_data.OutflowRate, outflow, v => _data.OutflowRate = v);
|
|
|
|
|
+
|
|
|
|
|
+ _data.Pump1Running = p1; _data.Pump2Running = p2; _data.Pump3Running = p3;
|
|
|
|
|
+ _data.Pump4Running = p4; _data.Pump5Running = p5;
|
|
|
|
|
+
|
|
|
|
|
+ _data.Pump1Fault = pf1; _data.Pump2Fault = pf2; _data.Pump3Fault = pf3;
|
|
|
|
|
+ _data.Pump4Fault = pf4; _data.Pump5Fault = pf5;
|
|
|
|
|
+
|
|
|
|
|
+ _data.Pump1Freq = f1; _data.Pump2Freq = f2; _data.Pump3Freq = f3;
|
|
|
|
|
+ _data.Pump4Freq = f4; _data.Pump5Freq = f5;
|
|
|
|
|
+
|
|
|
|
|
+ _data.Fan1Running = fan1; _data.Fan2Running = fan2;
|
|
|
|
|
+ _data.Fan1Fault = fanf1; _data.Fan2Fault = fanf2;
|
|
|
|
|
+
|
|
|
|
|
+ _data.IsAutoMode = autoMode;
|
|
|
|
|
+ _data.LastReadTime = DateTime.Now;
|
|
|
|
|
+ _data.IsDataValid = true;
|
|
|
|
|
+ _data.IsPlcConnected = _isConnected;
|
|
|
|
|
+ _data.FlowDelta = _data.InflowRate - _data.OutflowRate;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// <summary>
|
|
|
|
|
+ /// 带并发控制的字节读取
|
|
|
|
|
+ /// </summary>
|
|
|
|
|
+ private async Task<byte[]?> ReadBytesAsync(string address, ushort length)
|
|
|
|
|
+ {
|
|
|
|
|
+ if (!_isConnected || _plc == null) return null;
|
|
|
|
|
+
|
|
|
|
|
+ await _plcReadSemaphore.WaitAsync();
|
|
|
|
|
+ try
|
|
|
|
|
+ {
|
|
|
|
|
+ var result = await _plc.ReadAsync(address, length);
|
|
|
|
|
+ if (!result.IsSuccess)
|
|
|
|
|
+ {
|
|
|
|
|
+ _isConnected = false;
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ return result.Content;
|
|
|
|
|
+ }
|
|
|
|
|
+ catch
|
|
|
|
|
+ {
|
|
|
|
|
+ _isConnected = false;
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ finally
|
|
|
|
|
+ {
|
|
|
|
|
+ _plcReadSemaphore.Release();
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// <summary>
|
|
|
|
|
+ /// 死区过滤:只有变化超过阈值才更新(减少 UI 无效刷新)
|
|
|
|
|
+ /// </summary>
|
|
|
|
|
+ private void ApplyWithDeadband(float currentValue, float newValue, Action<float> setter)
|
|
|
|
|
+ {
|
|
|
|
|
+ if (Math.Abs(currentValue) < 0.001f && Math.Abs(newValue) < 0.001f)
|
|
|
|
|
+ return; // 都是零值,跳过
|
|
|
|
|
+
|
|
|
|
|
+ var threshold = Math.Abs(currentValue) * DeadbandPercent / 100.0f;
|
|
|
|
|
+ if (Math.Abs(newValue - currentValue) > threshold || currentValue == 0)
|
|
|
|
|
+ {
|
|
|
|
|
+ setter(newValue);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static float ParseFloat(byte[] data, int offset)
|
|
|
|
|
+ {
|
|
|
|
|
+ if (data == null || offset + 4 > data.Length) return 0f;
|
|
|
|
|
+ if (BitConverter.IsLittleEndian)
|
|
|
|
|
+ {
|
|
|
|
|
+ var bytes = new byte[] {
|
|
|
|
|
+ data[offset + 3], data[offset + 2],
|
|
|
|
|
+ data[offset + 1], data[offset]
|
|
|
|
|
+ };
|
|
|
|
|
+ return BitConverter.ToSingle(bytes, 0);
|
|
|
|
|
+ }
|
|
|
|
|
+ return BitConverter.ToSingle(data, offset);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static bool ParseBit(byte[] data, int byteOffset, int bitOffset)
|
|
|
|
|
+ {
|
|
|
|
|
+ if (data == null || byteOffset >= data.Length) return false;
|
|
|
|
|
+ return (data[byteOffset] & (1 << bitOffset)) != 0;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public void Dispose() => Stop();
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### 关键机制说明
|
|
|
|
|
+
|
|
|
|
|
+| 机制 | 实现方式 | 说明 |
|
|
|
|
|
+|------|----------|------|
|
|
|
|
|
+| **并发控制** | `SemaphoreSlim(4, 4)` | 限制同时发起的 PLC 读取请求数,防止 PLC 过载 |
|
|
|
|
|
+| **死区过滤** | `ApplyWithDeadband()` | 数据变化小于阈值(默认 0.5%)时不更新,减少 UI 无效刷新 |
|
|
|
|
|
+| **降级模式** | `isInDegradedMode` | 连接失败超过 `MaxReconnectAttempts` 次后进入,重试间隔从 1s 延长到 30s |
|
|
|
|
|
+| **指数退避** | `ReconnectBaseDelayMs * attempts` | 重连间隔逐步增加,上限 30s,避免频繁冲击 PLC |
|
|
|
|
|
+| **并行批量读取** | `Task.WhenAll()` | 多个地址段并行发起读取,充分利用网络带宽 |
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 地址格式参考
|
|
|
|
|
+
|
|
|
|
|
+### 存储区域
|
|
|
|
|
+
|
|
|
|
|
+| 区域 | 说明 | 地址前缀 |
|
|
|
|
|
+|------|------|----------|
|
|
|
|
|
+| V | 变量存储区(Variable) | `V` / `VB` / `VW` / `VD` |
|
|
|
|
|
+| M | 标志位(Marker) | `M` / `MB` / `MW` / `MD` |
|
|
|
|
|
+| I | 输入映像区(Input) | `I` / `IB` / `IW` / `ID` |
|
|
|
|
|
+| Q | 输出映像区(Output) | `Q` / `QB` / `QW` / `QD` |
|
|
|
|
|
+
|
|
|
|
|
+### 数据类型与地址格式
|
|
|
|
|
+
|
|
|
|
|
+| 数据类型 | 字节长度 | 地址格式示例 | 说明 |
|
|
|
|
|
+|----------|----------|--------------|------|
|
|
|
|
|
+| Bool(位) | 1 bit | `M10.0`, `Q0.5` | 字节偏移.位索引 |
|
|
|
|
|
+| Byte | 1 | `VB100`, `MB10` | 单字节 |
|
|
|
|
|
+| Word/INT | 2 | `VW100`, `MW10` | 16位整数 |
|
|
|
|
|
+| DWord/REAL | 4 | `VD100`, `MD10` | 32位浮点数 |
|
|
|
|
|
+| DWord/DINT | 4 | `VD100`, `MD10` | 32位整数 |
|
|
|
|
|
+
|
|
|
|
|
+### 常用地址示例
|
|
|
|
|
+
|
|
|
|
|
+```csharp
|
|
|
|
|
+// 浮点数读取
|
|
|
|
|
+"VD100" // V区第100字节开始的双字(4字节浮点数)
|
|
|
|
|
+"MD50" // M区第50字节开始的双字
|
|
|
|
|
+
|
|
|
|
|
+// 整数读取
|
|
|
|
|
+"VW100" // V区第100字节开始的字(2字节)
|
|
|
|
|
+"MW10" // M区第10字节开始的字
|
|
|
|
|
+
|
|
|
|
|
+// 布尔值读取
|
|
|
|
|
+"M10.0" // M区第10字节的第0位
|
|
|
|
|
+"Q0.5" // Q区第0字节的第5位
|
|
|
|
|
+"V100.3" // V区第100字节的第3位
|
|
|
|
|
+
|
|
|
|
|
+// 字节读取
|
|
|
|
|
+"VB100" // V区第100字节
|
|
|
|
|
+"MB10" // M区第10字节
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 配置文件结构
|
|
|
|
|
+
|
|
|
|
|
+项目使用 JSON 配置文件管理 PLC 连接参数,支持运行时修改。
|
|
|
|
|
+
|
|
|
|
|
+### 配置文件示例(yzwater-config.json)
|
|
|
|
|
+
|
|
|
|
|
+```json
|
|
|
|
|
+{
|
|
|
|
|
+ "PlcIp": "192.168.2.100",
|
|
|
|
|
+ "PlcPort": 102,
|
|
|
|
|
+ "AutoConnect": true,
|
|
|
|
|
+ "PlcProtocol": "SiemensS7",
|
|
|
|
|
+
|
|
|
|
|
+ "LevelHighAlarm": 80.0,
|
|
|
|
|
+ "LevelLowAlarm": 20.0,
|
|
|
|
|
+ "FlowHighAlarm": 100.0,
|
|
|
|
|
+ "FlowLowAlarm": 5.0,
|
|
|
|
|
+
|
|
|
|
|
+ "PlcAddresses": {
|
|
|
|
|
+ "Tank1Level": "VD100",
|
|
|
|
|
+ "Tank2Level": "VD104",
|
|
|
|
|
+ "InflowRate": "VD200",
|
|
|
|
|
+ "OutflowRate": "VD204",
|
|
|
|
|
+ "Pump1Freq": "VD300",
|
|
|
|
|
+ "Pump1Current": "VD320"
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### 配置模型类
|
|
|
|
|
+
|
|
|
|
|
+```csharp
|
|
|
|
|
+using System.Text.Json;
|
|
|
|
|
+using System.Text.Json.Serialization;
|
|
|
|
|
+
|
|
|
|
|
+public class SystemConfig
|
|
|
|
|
+{
|
|
|
|
|
+ // PLC 连接
|
|
|
|
|
+ public string PlcIp { get; set; } = "192.168.2.100";
|
|
|
|
|
+ public int PlcPort { get; set; } = 102;
|
|
|
|
|
+ public bool AutoConnect { get; set; } = true;
|
|
|
|
|
+ public string PlcProtocol { get; set; } = "SiemensS7";
|
|
|
|
|
+
|
|
|
|
|
+ // 报警阈值
|
|
|
|
|
+ public float LevelHighAlarm { get; set; } = 80f;
|
|
|
|
|
+ public float LevelLowAlarm { get; set; } = 20f;
|
|
|
|
|
+ public float FlowHighAlarm { get; set; } = 100f;
|
|
|
|
|
+ public float FlowLowAlarm { get; set; } = 5f;
|
|
|
|
|
+
|
|
|
|
|
+ // 地址覆盖
|
|
|
|
|
+ public Dictionary<string, string>? PlcAddresses { get; set; }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+public static class ConfigService
|
|
|
|
|
+{
|
|
|
|
|
+ private static SystemConfig? _config;
|
|
|
|
|
+ private static readonly string ConfigPath = "yzwater-config.json";
|
|
|
|
|
+
|
|
|
|
|
+ public static SystemConfig GetConfig()
|
|
|
|
|
+ {
|
|
|
|
|
+ if (_config == null) Load();
|
|
|
|
|
+ return _config ?? new SystemConfig();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public static void Load()
|
|
|
|
|
+ {
|
|
|
|
|
+ if (File.Exists(ConfigPath))
|
|
|
|
|
+ {
|
|
|
|
|
+ var json = File.ReadAllText(ConfigPath);
|
|
|
|
|
+ _config = JsonSerializer.Deserialize<SystemConfig>(json);
|
|
|
|
|
+ }
|
|
|
|
|
+ _config ??= new SystemConfig();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public static void Save()
|
|
|
|
|
+ {
|
|
|
|
|
+ if (_config == null) return;
|
|
|
|
|
+ var options = new JsonSerializerOptions { WriteIndented = true };
|
|
|
|
|
+ var json = JsonSerializer.Serialize(_config, options);
|
|
|
|
|
+ File.WriteAllText(ConfigPath, json);
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### 配置加载顺序
|
|
|
|
|
+
|
|
|
|
|
+```csharp
|
|
|
|
|
+// 在程序启动时按顺序加载:
|
|
|
|
|
+
|
|
|
|
|
+// 1. 加载主配置文件
|
|
|
|
|
+ConfigService.Load();
|
|
|
|
|
+
|
|
|
|
|
+// 2. 加载地址覆盖(覆盖默认硬编码地址)
|
|
|
|
|
+PlcConfig.LoadFromConfig();
|
|
|
|
|
+
|
|
|
|
|
+// 3. 初始化 PLC 服务
|
|
|
|
|
+PlcService.Initialize();
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 项目文件结构
|
|
|
|
|
+
|
|
|
|
|
+迁移时建议按以下结构组织文件:
|
|
|
|
|
+
|
|
|
|
|
+```
|
|
|
|
|
+YourProject/
|
|
|
|
|
+├── Services/
|
|
|
|
|
+│ ├── Plc/
|
|
|
|
|
+│ │ ├── SiemensPlcHelper.cs # 基础连接类
|
|
|
|
|
+│ │ ├── PlcPollingService.cs # 轮询服务(完整版)
|
|
|
|
|
+│ │ ├── PlcConfig.cs # 地址配置管理
|
|
|
|
|
+│ │ └── S7AddressParser.cs # 地址解析器
|
|
|
|
|
+│ │
|
|
|
|
|
+│ ├── ConfigService.cs # 配置服务
|
|
|
|
|
+│ └── NotificationService.cs # 通知服务(可选)
|
|
|
|
|
+│
|
|
|
|
|
+├── Models/
|
|
|
|
|
+│ ├── SystemConfig.cs # 配置模型
|
|
|
|
|
+│ └── PlcDataModel.cs # PLC 数据模型
|
|
|
|
|
+│
|
|
|
|
|
+├── ViewModels/ # MVVM 项目
|
|
|
|
|
+│ └── MainViewModel.cs
|
|
|
|
|
+│
|
|
|
|
|
+├── Views/ # MVVM 项目
|
|
|
|
|
+│ └── MainWindow.axaml
|
|
|
|
|
+│
|
|
|
|
|
+├── yzwater-config.json # 运行时配置
|
|
|
|
|
+├── plc-addresses.json # 地址覆盖(可选)
|
|
|
|
|
+└── YourProject.csproj
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### NuGet 依赖(csproj)
|
|
|
|
|
+
|
|
|
|
|
+```xml
|
|
|
|
|
+<Project Sdk="Microsoft.NET.Sdk">
|
|
|
|
|
+ <PropertyGroup>
|
|
|
|
|
+ <TargetFramework>net8.0</TargetFramework>
|
|
|
|
|
+ <Nullable>enable</Nullable>
|
|
|
|
|
+ <ImplicitUsings>enable</ImplicitUsings>
|
|
|
|
|
+ </PropertyGroup>
|
|
|
|
|
+
|
|
|
|
|
+ <ItemGroup>
|
|
|
|
|
+ <!-- PLC 通信 -->
|
|
|
|
|
+ <PackageReference Include="HslCommunication" Version="11.8.2" />
|
|
|
|
|
+
|
|
|
|
|
+ <!-- MVVM(可选,WPF/Avalonia 项目) -->
|
|
|
|
|
+ <PackageReference Include="CommunityToolkit.Mvvm" Version="8.2.2" />
|
|
|
|
|
+
|
|
|
|
|
+ <!-- 日志 -->
|
|
|
|
|
+ <PackageReference Include="Serilog" Version="4.0.1" />
|
|
|
|
|
+ <PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
|
|
|
|
|
+ <PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
|
|
|
|
|
+
|
|
|
|
|
+ <!-- JSON 配置 -->
|
|
|
|
|
+ <PackageReference Include="System.Text.Json" Version="8.0.4" />
|
|
|
|
|
+ </ItemGroup>
|
|
|
|
|
+</Project>
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 使用示例
|
|
|
|
|
+
|
|
|
|
|
+### 基础使用
|
|
|
|
|
+
|
|
|
|
|
+```csharp
|
|
|
|
|
+// 创建连接
|
|
|
|
|
+var plc = new SiemensPlcHelper();
|
|
|
|
|
+var connected = await plc.ConnectAsync("192.168.0.150", 102);
|
|
|
|
|
+
|
|
|
|
|
+if (connected)
|
|
|
|
|
+{
|
|
|
|
|
+ // 读取浮点数
|
|
|
|
|
+ var level = await plc.ReadFloatAsync("VD100");
|
|
|
|
|
+ Console.WriteLine($"液位: {level:F2}");
|
|
|
|
|
+
|
|
|
|
|
+ // 读取布尔值
|
|
|
|
|
+ var pumpRun = await plc.ReadBoolAsync("M10.0");
|
|
|
|
|
+ Console.WriteLine($"泵运行: {pumpRun}");
|
|
|
|
|
+
|
|
|
|
|
+ // 写入浮点数
|
|
|
|
|
+ await plc.WriteFloatAsync("VD200", 123.45f);
|
|
|
|
|
+
|
|
|
|
|
+ // 写入布尔值
|
|
|
|
|
+ await plc.WriteBoolAsync("M20.0", true);
|
|
|
|
|
+
|
|
|
|
|
+ // 批量读取
|
|
|
|
|
+ var bytes = await plc.ReadBytesAsync("V100", 20);
|
|
|
|
|
+ if (bytes != null)
|
|
|
|
|
+ {
|
|
|
|
|
+ for (int i = 0; i < 5; i++)
|
|
|
|
|
+ {
|
|
|
|
|
+ var value = ParseFloat(bytes, i * 4);
|
|
|
|
|
+ Console.WriteLine($"VD{100 + i * 4}: {value:F2}");
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 断开连接
|
|
|
|
|
+plc.Disconnect();
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### 使用轮询服务
|
|
|
|
|
+
|
|
|
|
|
+```csharp
|
|
|
|
|
+// 创建轮询服务
|
|
|
|
|
+var polling = new PlcPollingService();
|
|
|
|
|
+
|
|
|
|
|
+// 订阅事件
|
|
|
|
|
+polling.DataUpdated += data =>
|
|
|
|
|
+{
|
|
|
|
|
+ Console.WriteLine($"液位: {data.GetValueOrDefault("TankLevel"):F2}");
|
|
|
|
|
+ Console.WriteLine($"流量: {data.GetValueOrDefault("FlowRate"):F2}");
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+polling.ConnectionStateChanged += connected =>
|
|
|
|
|
+{
|
|
|
|
|
+ Console.WriteLine(connected ? "PLC 已连接" : "PLC 已断开");
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+polling.ErrorOccurred += error =>
|
|
|
|
|
+{
|
|
|
|
|
+ Console.WriteLine($"错误: {error}");
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+// 启动(IP: 192.168.0.150, 端口: 102, S7-200Smart)
|
|
|
|
|
+polling.Start("192.168.0.150", 102, SiemensPLCS.S200Smart);
|
|
|
|
|
+
|
|
|
|
|
+// 程序退出时停止
|
|
|
|
|
+Console.WriteLine("按任意键停止...");
|
|
|
|
|
+Console.ReadKey();
|
|
|
|
|
+polling.Stop();
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 多协议驱动抽象(可选扩展)
|
|
|
|
|
+
|
|
|
|
|
+如果项目未来可能需要切换 PLC 品牌(三菱/欧姆龙/Modbus 等),可以使用统一的驱动接口抽象。
|
|
|
|
|
+
|
|
|
|
|
+### IPlcDriver 接口
|
|
|
|
|
+
|
|
|
|
|
+```csharp
|
|
|
|
|
+using HslCommunication;
|
|
|
|
|
+
|
|
|
|
|
+/// <summary>
|
|
|
|
|
+/// PLC 驱动接口 - 统一的 PLC 通信抽象
|
|
|
|
|
+/// </summary>
|
|
|
|
|
+public interface IPlcDriver : IDisposable
|
|
|
|
|
+{
|
|
|
|
|
+ string Name { get; }
|
|
|
|
|
+ bool IsConnected { get; }
|
|
|
|
|
+
|
|
|
|
|
+ Task<OperateResult> ConnectAsync();
|
|
|
|
|
+ void Disconnect();
|
|
|
|
|
+
|
|
|
|
|
+ Task<OperateResult<byte[]>> ReadBytesAsync(string address, ushort length);
|
|
|
|
|
+ Task<OperateResult<float>> ReadFloatAsync(string address);
|
|
|
|
|
+ Task<OperateResult<bool>> ReadBoolAsync(string address);
|
|
|
|
|
+
|
|
|
|
|
+ Task<OperateResult> WriteAsync(string address, byte[] value);
|
|
|
|
|
+ Task<OperateResult> WriteFloatAsync(string address, float value);
|
|
|
|
|
+ Task<OperateResult> WriteBoolAsync(string address, bool value);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/// <summary>
|
|
|
|
|
+/// PLC 协议类型
|
|
|
|
|
+/// </summary>
|
|
|
|
|
+public enum PlcProtocol
|
|
|
|
|
+{
|
|
|
|
|
+ SiemensS7, // 西门子 S7
|
|
|
|
|
+ MitsubishiMC, // 三菱 MC
|
|
|
|
|
+ OmronFins, // 欧姆龙 Fins
|
|
|
|
|
+ ModbusTcp, // Modbus TCP
|
|
|
|
|
+ AllenBradley // AB EtherNet/IP
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### SiemensS7Driver 实现
|
|
|
|
|
+
|
|
|
|
|
+```csharp
|
|
|
|
|
+using HslCommunication;
|
|
|
|
|
+using HslCommunication.Profinet.Siemens;
|
|
|
|
|
+
|
|
|
|
|
+public class SiemensS7Driver : IPlcDriver
|
|
|
|
|
+{
|
|
|
|
|
+ private readonly SiemensS7Net _plc;
|
|
|
|
|
+ private bool _disposed;
|
|
|
|
|
+ private bool _isConnected;
|
|
|
|
|
+
|
|
|
|
|
+ public string Name => "Siemens S7";
|
|
|
|
|
+ public bool IsConnected => _isConnected && !_disposed;
|
|
|
|
|
+
|
|
|
|
|
+ public SiemensS7Driver(SiemensPLCS plcSeries = SiemensPLCS.S200Smart)
|
|
|
|
|
+ {
|
|
|
|
|
+ _plc = new SiemensS7Net(plcSeries);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public void SetConnection(string ipAddress, int port = 102)
|
|
|
|
|
+ {
|
|
|
|
|
+ _plc.IpAddress = ipAddress;
|
|
|
|
|
+ _plc.Port = port;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public async Task<OperateResult> ConnectAsync()
|
|
|
|
|
+ {
|
|
|
|
|
+ var result = await _plc.ConnectServerAsync();
|
|
|
|
|
+ _isConnected = result.IsSuccess;
|
|
|
|
|
+ return result;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public void Disconnect()
|
|
|
|
|
+ {
|
|
|
|
|
+ _plc.ConnectClose();
|
|
|
|
|
+ _isConnected = false;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public async Task<OperateResult<byte[]>> ReadBytesAsync(string address, ushort length)
|
|
|
|
|
+ => await _plc.ReadAsync(address, length);
|
|
|
|
|
+
|
|
|
|
|
+ public async Task<OperateResult<float>> ReadFloatAsync(string address)
|
|
|
|
|
+ => await _plc.ReadFloatAsync(address);
|
|
|
|
|
+
|
|
|
|
|
+ public async Task<OperateResult<bool>> ReadBoolAsync(string address)
|
|
|
|
|
+ => await _plc.ReadBoolAsync(address);
|
|
|
|
|
+
|
|
|
|
|
+ public async Task<OperateResult> WriteAsync(string address, byte[] value)
|
|
|
|
|
+ => await _plc.WriteAsync(address, value);
|
|
|
|
|
+
|
|
|
|
|
+ public async Task<OperateResult> WriteFloatAsync(string address, float value)
|
|
|
|
|
+ => await _plc.WriteAsync(address, value);
|
|
|
|
|
+
|
|
|
|
|
+ public async Task<OperateResult> WriteBoolAsync(string address, bool value)
|
|
|
|
|
+ => await _plc.WriteAsync(address, value);
|
|
|
|
|
+
|
|
|
|
|
+ public void Dispose()
|
|
|
|
|
+ {
|
|
|
|
|
+ if (_disposed) return;
|
|
|
|
|
+ _disposed = true;
|
|
|
|
|
+ Disconnect();
|
|
|
|
|
+ _plc.Dispose();
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### PlcDriverFactory 工厂
|
|
|
|
|
+
|
|
|
|
|
+```csharp
|
|
|
|
|
+public static class PlcDriverFactory
|
|
|
|
|
+{
|
|
|
|
|
+ public static IPlcDriver Create(PlcProtocol protocol, string ipAddress, int port)
|
|
|
|
|
+ {
|
|
|
|
|
+ IPlcDriver driver = protocol switch
|
|
|
|
|
+ {
|
|
|
|
|
+ PlcProtocol.SiemensS7 => CreateSiemensS7(ipAddress, port),
|
|
|
|
|
+ // 可扩展其他品牌...
|
|
|
|
|
+ _ => throw new NotSupportedException($"不支持的 PLC 协议: {protocol}")
|
|
|
|
|
+ };
|
|
|
|
|
+ return driver;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static SiemensS7Driver CreateSiemensS7(string ipAddress, int port)
|
|
|
|
|
+ {
|
|
|
|
|
+ var driver = new SiemensS7Driver(SiemensPLCS.S200Smart);
|
|
|
|
|
+ driver.SetConnection(ipAddress, port);
|
|
|
|
|
+ return driver;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public static int GetDefaultPort(PlcProtocol protocol) => protocol switch
|
|
|
|
|
+ {
|
|
|
|
|
+ PlcProtocol.SiemensS7 => 102,
|
|
|
|
|
+ PlcProtocol.MitsubishiMC => 6000,
|
|
|
|
|
+ PlcProtocol.OmronFins => 9600,
|
|
|
|
|
+ PlcProtocol.ModbusTcp => 502,
|
|
|
|
|
+ PlcProtocol.AllenBradley => 44818,
|
|
|
|
|
+ _ => throw new NotSupportedException($"不支持的 PLC 协议: {protocol}")
|
|
|
|
|
+ };
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 支持的 PLC 系列
|
|
|
|
|
+
|
|
|
|
|
+| 枚举值 | PLC 型号 | 说明 |
|
|
|
|
|
+|--------|----------|------|
|
|
|
|
|
+| `SiemensPLCS.S200` | S7-200 | 老型号 |
|
|
|
|
|
+| `SiemensPLCS.S200Smart` | S7-200 Smart | 默认,常用 |
|
|
|
|
|
+| `SiemensPLCS.S300` | S7-300 | 中大型 |
|
|
|
|
|
+| `SiemensPLCS.S400` | S7-400 | 大型 |
|
|
|
|
|
+| `SiemensPLCS.S1200` | S7-1200 | 新型紧凑型 |
|
|
|
|
|
+| `SiemensPLCS.S1500` | S7-1500 | 新型高性能 |
|
|
|
|
|
+
|
|
|
|
|
+### 指定 PLC 系列
|
|
|
|
|
+
|
|
|
|
|
+```csharp
|
|
|
|
|
+// S7-1200
|
|
|
|
|
+await plc.ConnectAsync("192.168.0.1", 102, SiemensPLCS.S1200);
|
|
|
|
|
+
|
|
|
|
|
+// S7-1500
|
|
|
|
|
+await plc.ConnectAsync("192.168.0.1", 102, SiemensPLCS.S1500);
|
|
|
|
|
+
|
|
|
|
|
+// S7-300
|
|
|
|
|
+await plc.ConnectAsync("192.168.0.1", 102, SiemensPLCS.S300);
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 常见问题
|
|
|
|
|
+
|
|
|
|
|
+### 连接失败
|
|
|
|
|
+
|
|
|
|
|
+1. 检查 IP 地址和端口是否正确
|
|
|
|
|
+2. 确认 PLC 与 PC 网络连通(ping 测试)
|
|
|
|
|
+3. 检查 PLC 是否已启用 PUT/GET 通信
|
|
|
|
|
+4. 确认防火墙未阻止 102 端口
|
|
|
|
|
+
|
|
|
|
|
+### 读取数据为 0
|
|
|
|
|
+
|
|
|
|
|
+1. 确认地址格式正确(如 VD100 而非 V100)
|
|
|
|
|
+2. 检查数据类型是否匹配(浮点数用 VD,整数用 VW)
|
|
|
|
|
+3. 检查字节序是否正确(使用 ParseFloat 方法)
|
|
|
|
|
+
|
|
|
|
|
+### 读取数据异常
|
|
|
|
|
+
|
|
|
|
|
+1. 确认 PLC 系列选择正确
|
|
|
|
|
+2. 检查地址范围是否超出 PLC 实际配置
|
|
|
|
|
+3. 使用 Wireshark 抓包分析通信数据
|
|
|
|
|
+
|
|
|
|
|
+### 批量读取失败
|
|
|
|
|
+
|
|
|
|
|
+1. 确认地址在同一区域内连续(不能跨区)
|
|
|
|
|
+2. 读取长度不要超过 PLC 限制(S7-200 最大 240 字节)
|
|
|
|
|
+3. 使用 `S7AddressParser.GroupConsecutiveAddresses()` 自动分组
|
|
|
|
|
+
|
|
|
|
|
+### 轮询服务 CPU 占用高
|
|
|
|
|
+
|
|
|
|
|
+1. 适当增大 `PollingIntervalMs`(默认 1000ms)
|
|
|
|
|
+2. 增大 `DeadbandPercent` 减少 UI 刷新次数
|
|
|
|
|
+3. 使用批量读取替代多次单独读取
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 日志集成(Serilog)
|
|
|
|
|
+
|
|
|
|
|
+```csharp
|
|
|
|
|
+using Serilog;
|
|
|
|
|
+
|
|
|
|
|
+// 初始化日志(程序启动时调用一次)
|
|
|
|
|
+public static void InitializeLogging()
|
|
|
|
|
+{
|
|
|
|
|
+ Log.Logger = new LoggerConfiguration()
|
|
|
|
|
+ .MinimumLevel.Information()
|
|
|
|
|
+ .WriteTo.Console()
|
|
|
|
|
+ .WriteTo.File(
|
|
|
|
|
+ path: "Logs/log-.txt",
|
|
|
|
|
+ rollingInterval: RollingInterval.Day,
|
|
|
|
|
+ outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff} [{Level:u3}] {Message:lj}{NewLine}{Exception}")
|
|
|
|
|
+ .CreateLogger();
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 在程序退出时关闭
|
|
|
|
|
+public static void CloseLogging()
|
|
|
|
|
+{
|
|
|
|
|
+ Log.CloseAndFlush();
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### 日志使用示例
|
|
|
|
|
+
|
|
|
|
|
+```csharp
|
|
|
|
|
+Log.Information("PLC 连接成功: {Ip}:{Port}", ip, port);
|
|
|
|
|
+Log.Warning("PLC 连接失败: {Message}", result.Message);
|
|
|
|
|
+Log.Error(ex, "读取 PLC 数据失败,地址: {Address}", address);
|
|
|
|
|
+Log.Debug("批量读取优化: {Count} 个地址合并为 {Groups} 次读取", count, groups);
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 快速迁移清单
|
|
|
|
|
+
|
|
|
|
|
+将以下文件复制到新项目即可完成迁移:
|
|
|
|
|
+
|
|
|
|
|
+### 必需文件(6 个)
|
|
|
|
|
+
|
|
|
|
|
+| 文件 | 功能 | 位置 |
|
|
|
|
|
+|------|------|------|
|
|
|
|
|
+| `SiemensPlcHelper.cs` | 基础连接类 | 连接建立章节 |
|
|
|
|
|
+| `PlcConfig.cs` | 地址配置管理 | 地址配置管理章节 |
|
|
|
|
|
+| `S7AddressParser.cs` | 地址解析 + 批量优化 | S7 地址解析器章节 |
|
|
|
|
|
+| `PlcDataModel.cs` | 数据模型 | 数据模型章节 |
|
|
|
|
|
+| `PlcPollingService.cs` | 轮询服务(完整版) | 轮询服务模板章节 |
|
|
|
|
|
+| `ConfigService.cs` + `SystemConfig.cs` | 配置服务 | 配置文件结构章节 |
|
|
|
|
|
+
|
|
|
|
|
+### 可选文件
|
|
|
|
|
+
|
|
|
|
|
+| 文件 | 功能 |
|
|
|
|
|
+|------|------|
|
|
|
|
|
+| `IPlcDriver.cs` | 多协议驱动接口 |
|
|
|
|
|
+| `SiemensS7Driver.cs` | S7 驱动封装 |
|
|
|
|
|
+| `PlcDriverFactory.cs` | 驱动工厂 |
|
|
|
|
|
+| `plc-addresses.json` | 地址覆盖配置 |
|
|
|
|
|
+
|
|
|
|
|
+### NuGet 依赖
|
|
|
|
|
+
|
|
|
|
|
+```xml
|
|
|
|
|
+<PackageReference Include="HslCommunication" Version="11.8.2" />
|
|
|
|
|
+<PackageReference Include="CommunityToolkit.Mvvm" Version="8.2.2" />
|
|
|
|
|
+<PackageReference Include="Serilog" Version="4.0.1" />
|
|
|
|
|
+<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
|
|
|
|
|
+<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### 启动代码
|
|
|
|
|
+
|
|
|
|
|
+```csharp
|
|
|
|
|
+// 程序入口(Program.cs 或 App.OnStartup)
|
|
|
|
|
+public static async Task Main(string[] args)
|
|
|
|
|
+{
|
|
|
|
|
+ // 1. 初始化日志
|
|
|
|
|
+ InitializeLogging();
|
|
|
|
|
+
|
|
|
|
|
+ // 2. 设置 HslCommunication 授权
|
|
|
|
|
+ HslCommunication.Authorization.SetAuthorizationCode("你的授权码");
|
|
|
|
|
+
|
|
|
|
|
+ // 3. 加载配置
|
|
|
|
|
+ ConfigService.Load();
|
|
|
|
|
+ PlcConfig.LoadFromConfig();
|
|
|
|
|
+
|
|
|
|
|
+ // 4. 启动轮询
|
|
|
|
|
+ var polling = new PlcPollingService(Log.ForContext<PlcPollingService>());
|
|
|
|
|
+ polling.DataUpdated += data => { /* 处理数据更新 */ };
|
|
|
|
|
+ polling.ConnectionStateChanged += connected => { /* 处理连接状态 */ };
|
|
|
|
|
+ polling.ErrorOccurred += error => { /* 处理错误 */ };
|
|
|
|
|
+
|
|
|
|
|
+ var config = ConfigService.GetConfig();
|
|
|
|
|
+ polling.Start(config.PlcIp, config.PlcPort);
|
|
|
|
|
+
|
|
|
|
|
+ // 5. 程序退出时
|
|
|
|
|
+ // polling.Stop();
|
|
|
|
|
+ // ConfigService.Save();
|
|
|
|
|
+ // Log.CloseAndFlush();
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 许可证
|
|
|
|
|
+
|
|
|
|
|
+本文档基于 YZWater3 项目提取,仅供参考使用。HslCommunication 库请遵循其官方许可证。
|