前言

最近接手了一个项目,做一个 OPC-UA 服务端?刚听到这个消息我是一脸懵,发自灵魂的三问“OPC-UA是什么?”、“要怎么做?”、“有什么用?”。
我之前都是做互联网相关的东西,这种物联网的还真是第一次接触。没办法只能打开我的浏览器四处搜索,结果百度了一圈下来发现都是要么是介绍OPC-UA是什么的,要么就是OPC-UA客户端,反正服务端相关的内容是找了半天都没找到,但这是领导们安排的任务啊,我总不能回复网上没有教程吧,于是只能把目光投向了最后的希望:GitHub,好在最后找到了OPC基金会的源码。
源码地址:https://github.com/OPCFoundation/UA-.NETStandard
不过这个源码对于我这种刚接触工业物联网的人来说,太过于复杂,而且网上相关的技术说明文档太少,觉得非常有必要动手记录一下我的OPC-UA服务端实现过程,方便以后回过头来巩固。
关于什么是OPC-UA、OPCFoundation是什么我就不多说了,百度以下,一大堆说这些理论东西的,咱们还是更喜欢动手干起来。
以下就是我实现OPC-UA服务端的记录,分享出来,大家一起探讨以下。由于我也是第一次接触这种工业物联网,所以有什么说的不对的,请大家多多指点,共同学习共同进步!

引入Nuget包
Nuget包管理器中搜索 OPCFoundation.NetStandard.Opc.Ua 安装即可;
关于OPCFoundation.NetStandard.Opc.Ua的源码就是我上面所说的OPC基金会的源码,感兴趣的请自行前往GitHub查看;

初始化节点树
重写CustomNodeManager2类的CreateAddressSpace()方法,在服务启动时会调用CreateAddressSpace()方法创建我们自己定义的各个节点。在我的代码中,我主要用到两种创建节点方式:
1、创建目录

private FolderState CreateFolder(NodeState parent, string path, string name)
{
FolderState folder = new FolderState(parent); folder.SymbolicName = name;
folder.ReferenceTypeId = ReferenceTypes.Organizes;
folder.TypeDefinitionId = ObjectTypeIds.FolderType;
folder.NodeId = new NodeId(path, NamespaceIndex);
folder.BrowseName = new QualifiedName(path, NamespaceIndex);
folder.DisplayName = new LocalizedText("en", name);
folder.WriteMask = AttributeWriteMask.None;
folder.UserWriteMask = AttributeWriteMask.None;
folder.EventNotifier = EventNotifiers.None; if (parent != null)
{
parent.AddChild(folder);
} return folder;
}

2、创建子节点

private BaseDataVariableState CreateVariable(NodeState parent, string path, string name, NodeId dataType, int valueRank)
{
BaseDataVariableState variable = new BaseDataVariableState(parent); variable.SymbolicName = name;
variable.ReferenceTypeId = ReferenceTypes.Organizes;
variable.TypeDefinitionId = VariableTypeIds.BaseDataVariableType;
variable.NodeId = new NodeId(path, NamespaceIndex);
variable.BrowseName = new QualifiedName(path, NamespaceIndex);
variable.DisplayName = new LocalizedText("en", name);
variable.WriteMask = AttributeWriteMask.DisplayName | AttributeWriteMask.Description;
variable.UserWriteMask = AttributeWriteMask.DisplayName | AttributeWriteMask.Description;
variable.DataType = dataType;
variable.ValueRank = valueRank;
variable.AccessLevel = AccessLevels.CurrentReadOrWrite;
variable.UserAccessLevel = AccessLevels.CurrentReadOrWrite;
variable.Historizing = false;
//variable.Value = GetNewValue(variable);
variable.StatusCode = StatusCodes.Good;
variable.Timestamp = DateTime.Now;
//此处绑定节点的写入事件
variable.OnWriteValue = OnWriteDataValue; if (valueRank == ValueRanks.OneDimension)
{
variable.ArrayDimensions = new ReadOnlyList<uint>(new List<uint> { });
}
else if (valueRank == ValueRanks.TwoDimensions)
{
variable.ArrayDimensions = new ReadOnlyList<uint>(new List<uint> { , });
} if (parent != null)
{
parent.AddChild(variable);
} return variable;
}

简单的理解,我创建出来的节点树,类似于文件系统,从根节点开始向下是一级级的‘目录’,只有最后在‘目录’下的‘文件’才有值。

实时刷新数据
仅仅创建节点树还不够,他们的值都是固定的并不会变动,而实际的应用场景中,这些数据肯定是随时在变化的;所以,我们需要新开一个线程,去循环刷新我们各个节点的值。

Task.Run(() =>
{
while (true)
{
try
{
//模拟获取实时数据
BaseDataVariableState node = null;
/*
* 在实际业务中应该是根据对应的标识来更新固定节点的数据
* 这里 我偷个懒 全部测点都更新为一个新的随机数
*/
// _nodeDic:保存所有最子节点的字典Dictionary<string, BaseDataVariableState>
foreach (var item in _nodeDic)
{
node = item.Value;
node.Value = RandomLibrary.GetRandomInt(, );
node.Timestamp = DateTime.Now;
//变更标识 只有执行了这一步,订阅的客户端才会收到新的数据
node.ClearChangeMasks(SystemContext, false);
}
//休息1秒
Thread.Sleep( * );
}
catch (Exception ex)
{
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine("更新OPC-UA节点数据触发异常:" + ex.Message);
Console.ResetColor();
}
}
});

动态添加节点
在实际的应用中,很有可能我们临时需要添加一个节点,或者由于某些业务的变动,我需要删除掉某些节点;这就好比我把电脑借给朋友之前,总是会先删掉E盘里的学习资料文件夹和里面的文件,等电脑还回来之后我再重新加上。

//nodes:包含所有节点及其从属关系的列表
public void UpdateNodesAttribute(List<OpcuaNode> nodes)
{
/*
* 此处有想过删除整个菜单树,然后重建 保证各个NodeId仍与原来的一直
* 但是 后来发现这样会导致原来的客户端订阅信息丢失 无法获取订阅数据
* 所以 只能一级级的检查节点 然后修改属性
*/
//修改或创建根节点
var scadas = nodes.Where(d => d.NodeType == NodeType.Scada);
foreach (var item in scadas)
{
FolderState scadaNode = null;
if (!_folderDic.TryGetValue(item.NodePath, out scadaNode))
{
//如果根节点都不存在 那么整个树都需要创建
FolderState root = CreateFolder(null, item.NodePath, item.NodeName);
root.AddReference(ReferenceTypes.Organizes, true, ObjectIds.ObjectsFolder);
_references.Add(new NodeStateReference(ReferenceTypes.Organizes, false, root.NodeId));
root.EventNotifier = EventNotifiers.SubscribeToEvents;
AddRootNotifier(root);
CreateNodes(nodes, root, item.NodePath);
_folderDic.Add(item.NodePath, root);
AddPredefinedNode(SystemContext, root);
continue;
}
else
{
scadaNode.DisplayName = item.NodeName;
scadaNode.ClearChangeMasks(SystemContext, false);
}
}
//修改或创建目录(此处设计为可以有多级目录,上面是演示数据,所以我只写了三级,事实上更多级也是可以的)
var folders = nodes.Where(d => d.NodeType != NodeType.Scada && !d.IsTerminal);
foreach (var item in folders)
{
FolderState folder = null;
if (!_folderDic.TryGetValue(item.NodePath, out folder))
{
var par = GetParentFolderState(nodes, item);
folder = CreateFolder(par, item.NodePath, item.NodeName);
AddPredefinedNode(SystemContext, folder);
par.ClearChangeMasks(SystemContext, false);
_folderDic.Add(item.NodePath, folder);
}
else
{
folder.DisplayName = item.NodeName;
folder.ClearChangeMasks(SystemContext, false);
}
}
//修改或创建测点
//这里我的数据结构采用IsTerminal来代表是否是测点 实际业务中可能需要根据自身需要调整
var paras = nodes.Where(d => d.IsTerminal);
foreach (var item in paras)
{
BaseDataVariableState node = null;
if (_nodeDic.TryGetValue(item.NodeId.ToString(), out node))
{
node.DisplayName = item.NodeName;
node.Timestamp = DateTime.Now;
node.ClearChangeMasks(SystemContext, false);
}
else
{
FolderState folder = null;
if (_folderDic.TryGetValue(item.ParentPath, out folder))
{
node = CreateVariable(folder, item.NodePath, item.NodeName, DataTypeIds.Double, ValueRanks.Scalar);
AddPredefinedNode(SystemContext, node);
folder.ClearChangeMasks(SystemContext, false);
_nodeDic.Add(item.NodeId.ToString(), node);
}
}
} /*
* 将新获取到的菜单列表与原列表对比
* 如果新菜单列表中不包含原有的菜单
* 则说明这个菜单被删除了 这里也需要删除
*/
List<string> folderPath = _folderDic.Keys.ToList();
List<string> nodePath = _nodeDic.Keys.ToList();
var remNode = nodePath.Except(nodes.Where(d => d.IsTerminal).Select(d => d.NodeId.ToString()));
foreach (var str in remNode)
{
BaseDataVariableState node = null;
if (_nodeDic.TryGetValue(str, out node))
{
var parent = node.Parent;
parent.RemoveChild(node);
_nodeDic.Remove(str);
}
}
var remFolder = folderPath.Except(nodes.Where(d => !d.IsTerminal).Select(d => d.NodePath));
foreach (string str in remFolder)
{
FolderState folder = null;
if (_folderDic.TryGetValue(str, out folder))
{
var parent = folder.Parent;
if (parent != null)
{
parent.RemoveChild(folder);
_folderDic.Remove(str);
}
else
{
RemoveRootNotifier(folder);
RemovePredefinedNode(SystemContext, folder, new List<LocalReference>());
}
}
}
}

需要特别说明的是:OpcuaNode类的属性可能需要根据你们自己的业务数据来定,只要确保一点:你能根据OpcuaNode对象的集合组成对应的节点树即可,下面给出OpcuaNode类的代码,但也只能作为一个参考。

public class OpcuaNode
{
//节点路径(逐级拼接)
public string NodePath { get; set; }
//父节点路径(逐级拼接)
public string ParentPath { get; set; }
//节点编号 (在我的业务系统中的节点编号并不完全唯一,但是所有测点Id都是不同的)
public int NodeId { get; set; }
//是否端点(最底端子节点)
public string NodeName { get; set; }
//是否端点(最底端子节点)
public bool IsTerminal { get; set; }
//节点类型
public NodeType NodeType { get; set; }
}
public enum NodeType
{
//根节点
Scada = ,
//目录
Channel = ,
//目录
Device = ,
//测点
Measure =
}

客户端读取历史数据

这个部分我也没有见到实际的应用,也不太清楚具体应该是怎么实现的,仅凭我的想象,我做如下的理解:
这些历史数据也是需要我们根据条件从数据源中查询出来,查询历史数据,就必然需要限定一个时间范围,所以我的实现代码如下:

public override void HistoryRead(OperationContext context, HistoryReadDetails details,
TimestampsToReturn timestampsToReturn, bool releaseContinuationPoints,
IList<HistoryReadValueId> nodesToRead, IList<HistoryReadResult> results, IList<ServiceResult> errors)
{
ReadProcessedDetails readDetail = details as ReadProcessedDetails;
//假设查询历史数据 都是带上时间范围的
if (readDetail == null || readDetail.StartTime == DateTime.MinValue || readDetail.EndTime == DateTime.MinValue)
{
errors[] = StatusCodes.BadHistoryOperationUnsupported;
return;
}
for (int ii = ; ii < nodesToRead.Count; ii++)
{
int sss = readDetail.StartTime.Millisecond;
double res = sss + DateTime.Now.Millisecond;
//这里 返回的历史数据可以是多种数据类型 请根据实际的业务来选择
Opc.Ua.KeyValuePair keyValue = new Opc.Ua.KeyValuePair()
{
Key = new QualifiedName(nodesToRead[ii].NodeId.Identifier.ToString()),
Value = res
};
results[ii] = new HistoryReadResult()
{
StatusCode = StatusCodes.Good,
HistoryData = new ExtensionObject(keyValue)
};
errors[ii] = StatusCodes.Good;
//切记,如果你已处理完了读取历史数据的操作,请将Processed设为true,这样OPC-UA类库就知道你已经处理过了 不需要再进行检查了
nodesToRead[ii].Processed = true;
}
}

客户端写入数据
在创建节点时,绑定节点的数据写入事件就可以实现客户端向服务端写入数据。当然,关于这些数据要怎么保存,需要根据实际的业务来做具体的实现。

private ServiceResult OnWriteDataValue(ISystemContext context, NodeState node,
NumericRange indexRange, QualifiedName dataEncoding,
ref object value, ref StatusCode statusCode, ref DateTime timestamp)
{
BaseDataVariableState variable = node as BaseDataVariableState;
try
{
//验证数据类型
TypeInfo typeInfo = TypeInfo.IsInstanceOfDataType(
value,
variable.DataType,
variable.ValueRank,
context.NamespaceUris,
context.TypeTable); if (typeInfo == null || typeInfo == TypeInfo.Unknown)
{
return StatusCodes.BadTypeMismatch;
}
if (typeInfo.BuiltInType == BuiltInType.Double)
{
double number = Convert.ToDouble(value);
value = TypeInfo.Cast(number, typeInfo.BuiltInType);
}
return ServiceResult.Good;
}
catch (Exception)
{
return StatusCodes.BadTypeMismatch;
}
}

启动服务端

当我们把OPC-UA服务端需要的功能都准备完成后,那就剩最后一步了:启动你的服务端。

var config = new ApplicationConfiguration()
{
ApplicationName = "AxiuOpcua",
ApplicationUri = Utils.Format(@"urn:{0}:AxiuOpcua", System.Net.Dns.GetHostName()),
ApplicationType = ApplicationType.Server,
ServerConfiguration = new ServerConfiguration()
{
BaseAddresses = { "opc.tcp://localhost:8020/AxiuOpcua/DemoServer", "https://localhost:8021/AxiuOpcua/DemoServer" },
MinRequestThreadCount = ,
MaxRequestThreadCount = ,
MaxQueuedRequestCount =
},
SecurityConfiguration = new SecurityConfiguration
{
ApplicationCertificate = new CertificateIdentifier { StoreType = @"Directory", StorePath = @"%CommonApplicationData%\OPC Foundation\CertificateStores\MachineDefault", SubjectName = Utils.Format(@"CN={0}, DC={1}", "AxiuOpcua", System.Net.Dns.GetHostName()) },
TrustedIssuerCertificates = new CertificateTrustList { StoreType = @"Directory", StorePath = @"%CommonApplicationData%\OPC Foundation\CertificateStores\UA Certificate Authorities" },
TrustedPeerCertificates = new CertificateTrustList { StoreType = @"Directory", StorePath = @"%CommonApplicationData%\OPC Foundation\CertificateStores\UA Applications" },
RejectedCertificateStore = new CertificateTrustList { StoreType = @"Directory", StorePath = @"%CommonApplicationData%\OPC Foundation\CertificateStores\RejectedCertificates" },
AutoAcceptUntrustedCertificates = true,
AddAppCertToTrustedStore = true
},
TransportConfigurations = new TransportConfigurationCollection(),
TransportQuotas = new TransportQuotas { OperationTimeout = },
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = },
TraceConfiguration = new TraceConfiguration()
};
config.Validate(ApplicationType.Server).GetAwaiter().GetResult();
if (config.SecurityConfiguration.AutoAcceptUntrustedCertificates)
{
config.CertificateValidator.CertificateValidation += (s, e) => { e.Accept = (e.Error.StatusCode == StatusCodes.BadCertificateUntrusted); };
} var application = new ApplicationInstance
{
ApplicationName = "AxiuOpcua",
ApplicationType = ApplicationType.Server,
ApplicationConfiguration = config
};
//application.CheckApplicationInstanceCertificate(false, 2048).GetAwaiter().GetResult();
bool certOk = application.CheckApplicationInstanceCertificate(false, ).Result;
if (!certOk)
{
Console.WriteLine("证书验证失败!");
} // start the server.
application.Start(new AxiuOpcuaServer()).Wait();

总结
我也是第一次接触OPC-UA,所做的这个服务端并不完善,只是提出来希望大家一起讨论,互相学习一下。毕竟我觉得C#在物联网方面的内容还是太少了。
关于示例程序的源码地址如下:
https://github.com/axiu233/AxiuOpcua.ServerDemo

利用C#实现OPC-UA服务端的更多相关文章

  1. winform客户端利用webClient实现与Web服务端的数据传输

    由于项目需要,最近研究了下WebClient的数据传输.关于WebClient介绍网上有很多详细介绍,大概就是利用WebClient可以实现对Internet资源的访问.无外乎客户端发送请求,服务端处 ...

  2. 如何利用cURL和python对服务端和web端进行接口测试

    工具描述 cURL是利用URL语法在命令行方式下工作的文件传输工具,是开源爱好者编写维护的免费工具,支持包括Windows.Linux.Mac等数十个操作系统,最新版本为7.27.0,但是我推荐大家使 ...

  3. 利用控制台承载SignalR作为服务端、及第三方推送信息

    一 首先建立一个控制台需要引用一些组件 特别要注意引用Microsoft.Owin.Host.HttpListener别忘了这个组件,不引用他可能程序正常运行不会报错,但服务器一直开启失败(我之前就是 ...

  4. 利用IDEA创建Web Service服务端和客户端的详细过程

    创建服务端 一.file–>new–>project 二.点击next后输入服务端名,点击finish,生成目录如下 三.在 HelloWorld.Java 文件中右击,选 WebServ ...

  5. 利用WebSocket和EventSource实现服务端推送

    可能有很多的同学有用 setInterval 控制 ajax 不断向服务端请求最新数据的经历(轮询)看下面的代码: setInterval(function() { $.get('/get/data- ...

  6. 利用sorket实现聊天功能-服务端实现

    工具包 package loaderman.im.util; public class Constants { public static final String SERVER_IP = " ...

  7. 用“MEAN”技术栈开发web应用(二)express搭建服务端框架

    上一篇我们讲了如何使用angular搭建起项目的前端框架,前端抽象出一个service层来向后端发送请求,后端则返回相应的json数据.本篇我们来介绍一下,如何在nodejs环境下利用express来 ...

  8. 远程控制服务(SSH)之Linux环境下客户端与服务端的远程连接

    本篇blog将讲述sshd服务提供的两种安全验证的方法,并且通过这两种方法进行两台Linux虚拟机之间的远程登陆. 准备工作: (1)     准备两台安装有Linux系统的虚拟机,虚拟机软件采用VM ...

  9. Python中的Tcp协议应用之TCP服务端-线程版

    利用线程实现,一个服务端同时服务多个客户端的需求. TCP服务端-线程版代码实现: import socket import threading def handle_client_socket(ne ...

随机推荐

  1. 将一个Linux系统中的文件或文件夹复制到另一台Linux服务器上(scp的使用)

    一.复制文件: (1)将本地文件拷贝到远程scp 文件名 用户名@计算机IP或者计算机名称:远程路径(2)从远程将文件拷回本地scp 用户名@计算机IP或者计算机名称:文件名 本地路径 二.复制文件夹 ...

  2. .Net Core in Docker极简入门(上篇)

    目录 前言 开始 环境准备 Docker基础概念 Docker基础命令 Docker命令实践 构建Docker镜像 Dockerfile bulid & run 前言 Docker 是一个开源 ...

  3. Qt-操作xml文件

    1  简介 参考视频:https://www.bilibili.com/video/BV1XW411x7AB?p=12 xml简介:可扩展标记语言,标准通用标记语言的子集,简称XML.是一种用于标记电 ...

  4. 设备管理的数据库路径是/storage/sdcard0/data/devuce-db

    设备管理的数据库路径是/storage/sdcard0/data/devuce-db 数据库文件名全路径是/storage/sdcard0/data/devuce-db/device.db 数据库文件 ...

  5. linux虚拟机正常安装完成后获取不到IP的解决办法-网卡

    通常正常情况下安装完linux虚拟机,只需要使用桥接并修改配置文件/etc/sysconfig/network-scripts/ifcfg-eth0,将如下参数值改为如下: ONBOOT=yes NM ...

  6. 《Python编程第4版 上》高清PDF|百度网盘免费下载|Python基础编程

    <Python编程第4版 上>高清PDF|百度网盘免费下载|Python基础编程 提取码:8qbi  当掌握Python的基础知识后,你要如何使用Python?Python编程(第四版)为 ...

  7. 友好城市dp

    // // Created by Arc on 2020/4/27. //对了,这篇题解的代码是小白自己写的.有啥错误还请各位大佬多多包涵. /* * 某国有一条大河(一条大河~~~~,波浪宽~~~~ ...

  8. clion 如何执行外部文件

    https://blog.csdn.net/he_yang_/article/details/96644480 这里这里

  9. springboot 使用 dev tool 导致 CastException

    1.背景 项目使用了 Spring + shiro 实现 权限控制, 使用AOP 对 每个 Controller 进行 log 记录时,需要从 shiro 中 获取 username字段, 问题就这样 ...

  10. Python访问、修改、删除字典中的值

    Python访问字典中的值: # 使用字典 ['键'] 获取字典中的元素 dic = {'a':123,'b':456,'c':789} print(dic['a']) # print(dic['c' ...