MES过站采集构建之OPCHelper构建(C#通过KEPServer对PLC进行读写)
前言(前置帮助理解):
基于C#开发OPCDA客户端
相关教学视频:玩转上位机视频专辑-玩转上位机视频合集-哔哩哔哩视频
共8集。
ServerNode:计算机名称
ServerName:选定某个计算机后,该计算机安装的能承担OPCServer职责的应用软件名称(比如:KEPware.KEPServerEx.V4)
Item(或ItemID):指的是在kepserver软件上配置的通道名.设备名.标签名,即Channel.Device.Tag,这个标签名就是采集的点位名称标识
value:Tag对应的采集结果值,它是一个可能是某种数值类型的值
通常还会涉及到数据类型、 时间戳、质量等列信息。
其他涉猎补充了解知识:
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 - 博客园



走读源码:
//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 }
问题:以下这段代码难以理解:
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 标签加入组

浙公网安备 33010602011771号