MES过站采集构建之OPCHelper构建(C#通过KEPServer对PLC进行读写)

前言(前置帮助理解):

基于C#开发OPCDA客户端

相关教学视频:玩转上位机视频专辑-玩转上位机视频合集-哔哩哔哩视频

共8集。

ServerNode:计算机名称

ServerName:选定某个计算机后,该计算机安装的能承担OPCServer职责的应用软件名称(比如:KEPware.KEPServerEx.V4)

Item(或ItemID):指的是在kepserver软件上配置的通道名.设备名.标签名,即Channel.Device.Tag,这个标签名就是采集的点位名称标识

value:Tag对应的采集结果值,它是一个可能是某种数值类型的值

通常还会涉及到数据类型、 时间戳、质量等列信息。

 

其他涉猎补充了解知识:

OPC应用程序入门_opc教程-CSDN博客

OPCUA,OPCDA与MODBUS学习笔记_modbus和opc ua-CSDN博客

 

本走读项目源码使用的kepserver版本是v4. 参考依据来源是APP.Config里的信息:

<add key="PlcServerIP" value="127.0.0.1"/>

<add key="PlcServerName" value="KEPware.KEPServerEx.V4"/>

一些概念:

- OPC DA:老的“数据访问”标准,基于 Windows 的 COM/DCOM;主要在 Windows 上跑,依赖 DCOM 配置与防火墙放行。
- OPC UA:新的“统一架构”标准,跨平台(Windows/Linux),走 opc.tcp /HTTPS;内置会话、证书、加密与用户认证,模型更丰富。

简单类比

- OPC DA 像老式的“Windows 内网 COM 通信”,只要在同一网络、DCOM配置正确就能读写点位。
- OPC UA 像现代的“带安全的跨平台服务接口”,用地址 opc.tcp://主机:端口 连接,支持证书与加密,适合跨平台与分布式部署。

当前项目里用的哪个?

- 当前项目用的是 OPC DA(Classic)接口:
- 因为引入 OPCAutomation COM 库
- 通过配置的 OPC 服务器 IP 和名称(如 KEPware.KEPServerEx.V4 )进行连接与写入
- 如果改成 OPC UA,需要换用 UA 客户端 SDK,连接 opc.tcp://... 端点,并配置证书与信任。

和 KepServer 的关系:

- Kepserver 通常指 Kepware公司 的 KEPServerEX 工业连接服务器,是一款产品;它实现了多种协议/驱动,并可作为 OPC UA Server 、 OPC DA Server 对外提供数据。
- 同时能充当 OPC DA Server 和 OPC UA Server。
- 你现在接的是它的 DA 接口;它也可以开启 UA 端点给 UA 客户端接入。

连接方式对比:

- OPC DA:
- 连接目标:OPC Server 的 COM ProgID(如 KEPware.KEPServerEx.V4 )。
- 通信基础:Windows COM/DCOM。
- 常见客户端: OPCAutomation 、 OPC.NET 等(COM 封装)。
- OPC UA:
- 连接目标: opc.tcp://host:port (或 HTTPS)。
- 通信基础:TCP/HTTPS,内置证书加密与会话。
- 常见客户端:各语言 UA SDK(.NET、Java、C/C++ 等)。

 

本项目源码来源作者:

源码作者搭配一些概念讲解:OPC工业控制系统标准规范,C#实现同步读和异步读 - 小-小张 - 博客园

(对比源码一摸一样,同时该作者还提供了同步读写操作):OPC取数OPCAutomation.dll的使用以及注意事项-CSDN博客

另外,关于OPCDA/OPCUA搭建:【保姆级教程】教你手把手搭建一个OPCDA/UA服务器_kepserver建立opc服务器-CSDN博客

使用OPCDAAuto.dll编写C# OPC采集程序 - jumahe - 博客园

image

 

image

image

 走读源码:

//OPCHelper 是对 OPC DA 的轻量封装:连接服务器 → 创建组与订阅 → 添加项 → 同步写/一次性读 → 事件回调更新缓存。
//MES业务通过组装规范化的项名来实现对 PLC 信号的读写,OPC 连接参数从配置读取,调用统一走 OPCHelper ,并在每次操作后立即释放资源。

其中,添加项 、同步写、一次性读非常重要!

添加项是写与读的前置条件,因为读写都会依赖ItemID和客户端句柄或服务端句柄,而这些信息只有在添加项步骤才会产生(所以添加时要记录这些信息)。

本项目源码添加项        public void AddItems(string[] itemNamesAdded)为自定义函数,内部调用了官方的AddItem方法;

本项目源码一次性读    public string ReadItem(string ItemID)为自定义函数,内部调用了官方的Read方法;

本项目源码同步写        public void SyncWrite(string[] writeItemNames, string[] writeItemValues)为自定义函数,内部调用了官方的GetOPCItem方法、SyncWrite方法

  1 using OPCAutomation;
  2 using System;
  3 using System.Collections.Generic;
  4 using System.Linq;
  5 using System.Text;
  6 using System.Threading.Tasks;
  7 using System.Windows.Forms;
  8 
  9 namespace XXXXXX
 10 {
 11     //OPCHelper 是对 OPC DA 的轻量封装:连接服务器 → 创建组与订阅 → 添加项(添加监控item,记录相关句柄) → 同步写/一次性读 → 事件回调更新缓存。
 12     //你的业务通过组装规范化的项名来实现对 PLC 信号的读写,OPC 连接参数从配置读取,调用统一走 OPCHelper ,并在每次操作后立即释放资源。
 13     public class OPCHelper : IDisposable
 14     {
 15         private string strHostIP;//KEPServerEx.V4所在服务器ip
 16         private string strHostName;//OPC 服务器的“ProgID”(例如 KEPware.KEPServerEx.V4 ),不是 DNS 的主机名。
 17         private OPCServer opcServer;
 18         private OPCGroups opcGroups;
 19         private OPCGroup opcGroup;
 20         private List<int> itemHandleClient = new List<int>();//客户端句柄 和AddItems有关,OPCDA都是通过句柄去找到一个个监测变量(Item)的,要保证顺序对。
 21         private List<int> itemHandleServer = new List<int>();//服务端句柄 和AddItems有关,OPCDA都是通过句柄去找到一个个监测变量(Item)的,要保证顺序对。
 22 
 23         private OPCItems opcItems;
 24         private OPCItem opcItem;
 25 
 26         private List<string> itemNames = new List<string>(); //用于存储监控的ItemID
 27         private Dictionary<string, string> itemValues = new Dictionary<string, string>(); //用于存储<ItemID,Value>
 28         public bool Connected = false;
 29 
 30         public OPCHelper(string strHostIP, string strHostName, int UpdateRate)
 31         {
 32             this.strHostIP = strHostIP;    //KEPServerEx.V4所在服务器ip
 33             this.strHostName = strHostName;//OPC 服务器的“ProgID”(例如 KEPware.KEPServerEx.V4 ),不是 DNS 的主机名。
 34             //(1)创建服务(实例一个服务对象)
 35             if (!CreateServer())
 36                 return;
 37             //(2)通过服务实例对象调用连接“OPC服务器”(即安装KEPServerEx.V4软件的服务器)
 38             if (!ConnectServer(strHostIP, strHostName))
 39                 return;
 40             //(3)更新连接状态
 41             Connected = true;
 42 
 43             //以下可以认为是固定写法:
 44             //(4)返回当前“此客户端连接”的组集合(初始默认都为空的)
 45             opcGroups = opcServer.OPCGroups;
 46             //(5)为组集合中添加(创建)一个组【组名可任意自定义!】(后面我们添加监控item和读取item都通过组对象,且我们读写item值时都需要依赖AddItem/AddItems产生的句柄)
 47             opcGroup = opcGroups.Add("ZZHOPCGROUP");
 48             //(6)设置该组属性(启用组、死区过滤、采样/刷新周期、开启数据变化订阅等)
 49             SetGroupProperty(opcGroup, UpdateRate);
 50             //(7)绑定数据更新事件
 51             opcGroup.DataChange += new DIOPCGroupEvent_DataChangeEventHandler(opcGroup_DataChange);//订阅事件
 52             opcGroup.AsyncWriteComplete += new DIOPCGroupEvent_AsyncWriteCompleteEventHandler(opcGroup_AsyncWriteComplete);//异步写事件内部代码走读发现并未编写
 53             //(8)拿到该组的“项集合”,用于后续 opcItems.AddItem(tagName, clientHandle) 把具体的 OPC 标签加入组
 54             opcItems = opcGroup.OPCItems;
 55         }
 56 
 57 
 58         /// <summary>  
 59         /// (1)创建服务  
 60         /// </summary>  
 61         /// <returns></returns>  
 62         private bool CreateServer()
 63         {
 64             try
 65             {
 66                 opcServer = new OPCServer();
 67             }
 68             catch (Exception ex)
 69             {
 70                 //MessageBox.Show("实体错误!"+ex.Message);
 71                 return false;
 72             }
 73             return true;
 74         }
 75 
 76         /// <summary>  
 77         /// (2)连接到服务器  
 78         /// </summary>  
 79         /// <param name="strHostIP">OPC应用软件所在服务器  Node </param>  
 80         /// <param name="strHostName">OPC应用软件服务(软件)名  ProgID </param>  
 81         /// <returns></returns>  
 82         private bool ConnectServer(string strHostIP, string strHostName)
 83         {
 84             try
 85             {
 86                 opcServer.Connect(strHostName, strHostIP);
 87             }
 88             catch (Exception ex)
 89             {
 90                 //MessageBox.Show("连接错误!" + ex.Message);
 91                 return false;
 92             }
 93             return true;
 94         }
 95 
 96         /// <summary>  
 97         /// 设置组的属性   
 98         /// </summary>  
 99         /// <param name="opcGroup"></param>  
100         /// <param name="updateRate"></param>  
101         private void SetGroupProperty(OPCGroup opcGroup, int updateRate)
102         {
103             opcGroup.IsActive = true;//启用组
104             opcGroup.DeadBand = 0;  //不做死区过滤
105             opcGroup.UpdateRate = updateRate; //采样/刷新周期(毫秒级)
106             opcGroup.IsSubscribed = true; //开启数据变化订阅
107         }
108 
109         public bool Contains(string itemNameContains)
110         {
111             foreach (string key in itemValues.Keys)
112             {
113                 if (key == itemNameContains)
114                     return true;
115             }
116             return false;
117         }
118 
119         /**
120          * 重要:把标签加入组,建立“项名→客户端句柄→服务端句柄”的映射,并启用订阅缓存与后续写入接口
121          */
122         public void AddItems(string[] itemNamesAdded)
123         {
124             //本轮次添加操作
125             for (int i = 0; i < itemNamesAdded.Length; i++)
126             {
127                 //存储待监控的ItemIDs进List数组
128                 this.itemNames.Add(itemNamesAdded[i]);
129                 //初始化ItemIDs对应的value,存如键值字典,后续用(似乎也没实际用)
130                 itemValues.Add(itemNamesAdded[i], "");
131             }
132 
133             //本轮次添加操作
134             for (int i = 0; i < itemNamesAdded.Length; i++)
135             {
136                 //主要执行:添加监测ItemID、维护客户端句柄线性数组、维护服务端句柄线性数组
137 
138                 //用户自定义维护客户端句柄值: (客户端句柄值初始最小存1(存储的下标从0开始),之后每次添加新客户端句柄值时,在上一个句柄值+1)
139                 itemHandleClient.Add(itemHandleClient.Count != 0 ? itemHandleClient[itemHandleClient.Count - 1] + 1 : 1);
140 
141                 //OPCDA  调用官方AddItem(ItemID,ClientHandle); 添加监控ItemID,并设定取对应自定义的最新客户端句柄
142                 opcItem = opcItems.AddItem(itemNamesAdded[i], itemHandleClient[itemHandleClient.Count - 1]);
143 
144                 //将该监测对象ItemID对应的ServerHandle值追加维护到线性数组itemHandleServer
145                 itemHandleServer.Add(opcItem.ServerHandle);
146             }
147         }
148 
149 
150         public string[] GetItemValues(string[] getValuesItemNames)
151         {
152             string[] getedValues = new string[getValuesItemNames.Length];
153             for (int i = 0; i < getValuesItemNames.Length; i++)
154             {
155                 if (Contains(getValuesItemNames[i]))
156                     itemValues.TryGetValue(getValuesItemNames[i], out getedValues[i]);
157             }
158             return getedValues;
159         }
160 
161 
162         /// <summary>  
163         /// 异步写 ---弃用
164         /// </summary>  
165         /// <param name="writeItemNames"></param>  
166         /// <param name="writeItemValues"></param>  
167         public void AsyncWrite(string[] writeItemNames, string[] writeItemValues)
168         {
169             //构建:待写入信息的ItemIDs(即writeItemNames)的一一对应的OPCItem对象,进行暂存
170             OPCItem[] bItem = new OPCItem[writeItemNames.Length];
171             for (int i = 0; i < writeItemNames.Length; i++)
172             {
173                 for (int j = 0; j < itemNames.Count; j++)
174                 {
175                     if (itemNames[j] == writeItemNames[i])
176                     {
177                         bItem[i] = opcItems.GetOPCItem(itemHandleServer[j]);
178                         break;
179                     }
180                 }
181             }
182 
183             //构建:上一步OPCItem对象的各个对应的服务端句柄进行暂存
184             int[] temp = new int[writeItemNames.Length + 1];
185             temp[0] = 0;
186             for (int i = 1; i < writeItemNames.Length + 1; i++)
187             {
188                 temp[i] = bItem[i - 1].ServerHandle;
189             }
190             //构建服务端句柄Array
191             Array serverHandles = (Array)temp;
192 
193 
194             //构建:将传递过来的"ItemValue"数组(给定待写入的value)存在Array里
195             object[] valueTemp = new object[writeItemNames.Length + 1];
196             valueTemp[0] = "";
197             for (int i = 1; i < writeItemNames.Length + 1; i++)
198             {
199                 valueTemp[i] = writeItemValues[i - 1];
200             }
201             Array values = (Array)valueTemp;
202 
203 
204 
205             Array Errors;
206             int cancelID;
207 
208             //执行OPCAuto
209             opcGroup.AsyncWrite(writeItemNames.Length, ref serverHandles, ref values, out Errors, 2009, out cancelID);
210             GC.Collect();
211         }
212 
213 
214         /// <summary>
215         /// 重点:同步写      他在执行之前,应该事先执行AddItems方法,建立“项名→客户端句柄→服务端句柄”的映射
216         /// 功能作用: 把一组标签名和对应的写入值同步写到 OPC 服务器
217         /// </summary>
218         /// <param name="writeItemNames"></param>
219         /// <param name="writeItemValues"></param>
220         public void SyncWrite(string[] writeItemNames, string[] writeItemValues)
221         {
222             //构建建立:待写入信息的ItemIDs(Names)的一一对应OPCItem对象
223             OPCItem[] bItem = new OPCItem[writeItemNames.Length];
224 
225             for (int i = 0; i < writeItemNames.Length; i++)
226             {
227                 for (int j = 0; j < itemNames.Count; j++)
228                 {
229                     if (itemNames[j] == writeItemNames[i])//待写的ItemID像与存储变量的ItemID匹配上,记住下标
230                     {
231                         bItem[i] = opcItems.GetOPCItem(itemHandleServer[j]);//获取到该标签的OPCItem对象
232                         break;
233                     }
234                 }
235             }
236 
237             //构建建立:上一步OPCItem对象一一对应的服务端句柄
238             int[] temp = new int[writeItemNames.Length + 1];
239             temp[0] = 0;
240             for (int i = 1; i < writeItemNames.Length + 1; i++)
241             {
242                 temp[i] = bItem[i - 1].ServerHandle;
243             }
244             //服务端句柄转Array(作为后续官方写入方法的参数)
245             Array serverHandles = (Array)temp;
246 
247             //构建:将给定的待写入的value转成Array(作为后续官方写入方法的参数)
248             object[] valueTemp = new object[writeItemNames.Length + 1];
249             valueTemp[0] = "";
250             for (int i = 1; i < writeItemNames.Length + 1; i++)
251             {
252                 valueTemp[i] = writeItemValues[i - 1];
253             }
254 
255             Array values = (Array)valueTemp;
256             Array Errors;
257 
258             //调用OPCAuto.dll官方同步写入方法 
259             opcGroup.SyncWrite(writeItemNames.Length, ref serverHandles, ref values, out Errors);
260 
261             GC.Collect(); 
262         }
263 
264         //如果调用,则之前不要执行AddItems方法
265         public string ReadItem(string ItemID)
266         {
267             Object value;
268             Object quality;
269             Object timestamp;
270             //以下这一句代码问题:同一个 OPCHelper 实例作用域里,确实会“重复把同名标签加入组一次”。
271             //功能上能工作,效率上不理想;并且 ReadItem 里硬编码客户端句柄为 1 ,如果组里已经有客户端句柄 1 ,可能引发异常或句柄冲突(规范要求句柄在组内唯一)
272             OPCItem item = opcGroup.OPCItems.AddItem(ItemID, 1);
273             //一次性直读设备
274             item.Read((short)OPCDataSource.OPCDevice, out value, out quality, out timestamp);
275             return value == null ? "" : value.ToString();
276         }
277 
278         //所以上面的代码可以改写为下面:
279         public string ReadItemFast(string itemName)
280         {
281             var idx = itemNames.IndexOf(itemName);
282             if (idx < 0) return ""; // 未先 AddItems 的情况可直接返回或抛错
283             var serverHandle = itemHandleServer[idx];
284             var item = opcItems.GetOPCItem(serverHandle);
285             object value, quality, timestamp;
286             item.Read((short)OPCDataSource.OPCDevice, out value, out quality, out timestamp);
287             return value == null ? "" : value.ToString();
288         }
289 
290 
291 
292         /// <summary>
293         /// 数据变化事件 
294         /// </summary>
295         /// <param name="TransactionID">事务ID,用于标识本次数据变化</param>
296         /// <param name="NumItems">变化的项数量</param>
297         /// <param name="ClientHandles">客户端句柄数组</param>
298         /// <param name="ItemValues">新的数据值数组</param>
299         /// <param name="Qualities">数据质量数组</param>
300         /// <param name="TimeStamps">时间戳数组</param>
301         void opcGroup_DataChange(int TransactionID, int NumItems, ref Array ClientHandles, ref Array ItemValues, ref Array Qualities, ref Array TimeStamps)
302         {
303             //注意:OPC的数组索引从1开始,不是从0开始
304             for (int i = 1; i <= NumItems; i++)
305             {
306                 // 根据客户端句柄找到对应的项名,更新缓存值
307                 // ItemValues是1基Array,取数用GetValue(i),该值代表读取到ItemID对应的Value采集值
308                 // ClientHandles是1基Array,取数用GetValue(i),该值代表读取到ItemID对应的客户端句柄值;itemNames是0基线性数组,客户端句柄值-1可检索在itemNames对应的ItemID
309                 // itemValues<ItemID,value>是键值字典,itemValues[ItemID]=Value执行最终赋值。
310                 itemValues[itemNames[Convert.ToInt32(ClientHandles.GetValue(i)) - 1]] = ItemValues.GetValue(i).ToString();
311             }
312 
313             //以上核心就是取出Value,维护itemValues<ItemID,value>是键值字典!
314 
315         }
316 
317 
318         /// <summary>
319         /// 异步写入完成事件(未使用)
320         /// </summary>
321         /// <param name="TransactionID">事务ID,用于标识本次数据变化</param>
322         /// <param name="NumItems">变化的项数量</param>
323         /// <param name="ClientHandles">客户端句柄数组</param>
324         /// <param name="Errors"></param>
325         void opcGroup_AsyncWriteComplete(int TransactionID, int NumItems, ref Array ClientHandles, ref Array Errors)
326         {
327             //throw new NotImplementedException();
328         }
329 
330 
331         public void Dispose()
332         {
333             //释放资源时对组绑定事件解绑
334             if (opcGroup != null)
335             {
336                 opcGroup.DataChange -= new DIOPCGroupEvent_DataChangeEventHandler(opcGroup_DataChange);
337                 opcGroup.AsyncWriteComplete -= new DIOPCGroupEvent_AsyncWriteCompleteEventHandler(opcGroup_AsyncWriteComplete);
338             }
339             //释放资源时对OPCServer进行连接关闭
340             if (opcServer != null)
341             {
342                 opcServer.Disconnect();
343                 opcServer = null;
344             }
345             //释放资源时将已连接状态置为false
346             Connected = false;
347         }
348     }
349 }
View Code

 

问题:以下这段代码难以理解:

opcGroups = opcServer.OPCGroups;
opcGroup = opcGroups.Add("ZZHOPCGROUP");
SetGroupProperty(opcGroup, UpdateRate);
opcGroup.DataChange += new DIOPCGroupEvent_DataChangeEventHandler(opcGroup_DataChange);
opcGroup.AsyncWriteComplete += new DIOPCGroupEvent_AsyncWriteCompleteEventHandler(opcGroup_AsyncWriteComplete);
opcItems = opcGroup.OPCItems;

这个是规范吗?必须自己自定义建一个组,然后执行自己想要的操作?既然有opcGroups = opcServer.OPCGroups能获取到所有组,为什么不用已有的组呢?这是为什么呢?
还有:当前的OPCHelper类会被多次调用,那岂不是多次建立了ZZHOPCGROUP组?

解答:

核心概念:

- OPC DA 的“组”是客户端会话里的订阅容器,不是服务器上的公共资源。 opcServer.OPCGroups 返回的是“此客户端连接”的组集合,而不是“服务器上所有客户端的组”。

不能直接“用已有组”的原因:

- opcGroups = opcServer.OPCGroups 获取的是当前 OPCServer 实例(也就是当前这个客户端连接)的组集合;首次连接时这个集合为空,需要你自己 Add(...) 创建;
- OPC DA 的组是“每个客户端独享的上下文”,服务端不会暴露其他客户端创建的组给你复用;即便名字相同,也只是你自己的会话里的组。

多次调用会不会重复建组?:

- 每次创建 OPCHelper 都会新建一个 OPCServer 实例并连接,然后 Add("ZZHOPCGROUP") 建组,
- 业务代码用 using (OPCHelper ...) { ... } 的短连接模式;用完立刻 Dispose() ,断开服务器连接并注销事件,组随会话结束一并释放,
- 因此不会在服务器上“积累越来越多的持久组”。确实是“每次会话都创建一个同名组”,但会话结束即释放。

总结:创建组是 OPC DA 客户端侧的通用做法,因为组承载订阅与刷新控制; OPCGroups 返回的是“你这个连接的组”,不是服务器的公共组。

opcItems = opcGroup.OPCItems是拿到该组的“项集合”,用于后续 opcItems.AddItem(tagName, clientHandle) 把具体的 OPC 标签加入组

 

posted @ 2025-12-18 15:17  上清风  阅读(6)  评论(0)    收藏  举报