利用C#實現OPC-UA服務端


前言

最近接手了一個項目,做一個 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> { 0 });
    }
    else if (valueRank == ValueRanks.TwoDimensions)
    {
        variable.ArrayDimensions = new ReadOnlyList<uint>(new List<uint> { 0, 0 });
    }

    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(0, 99);
                node.Timestamp = DateTime.Now;
                //變更標識  只有執行了這一步,訂閱的客戶端才會收到新的數據
                node.ClearChangeMasks(SystemContext, false);
            }
            //休息1秒
            Thread.Sleep(1000 * 1);
        }
        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 = 1,
    //目錄
    Channel = 2,
    //目錄
    Device = 3,
    //測點
    Measure = 4
}

 

客戶端讀取歷史數據

這個部分我也沒有見到實際的應用,也不太清楚具體應該是怎么實現的,僅憑我的想象,我做如下的理解:
這些歷史數據也是需要我們根據條件從數據源中查詢出來,查詢歷史數據,就必然需要限定一個時間范圍,所以我的實現代碼如下:

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[0] = StatusCodes.BadHistoryOperationUnsupported;
        return;
    }
    for (int ii = 0; 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 = 5,
        MaxRequestThreadCount = 100,
        MaxQueuedRequestCount = 200
    },
    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 = 15000 },
    ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60000 },
    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, 0).Result;
if (!certOk)
{
    Console.WriteLine("證書驗證失敗!");
}

// start the server.
application.Start(new AxiuOpcuaServer()).Wait();

 

總結
我也是第一次接觸OPC-UA,所做的這個服務端並不完善,只是提出來希望大家一起討論,互相學習一下。畢竟我覺得C#在物聯網方面的內容還是太少了。
關於示例程序的源碼地址如下:
https://github.com/axiu233/AxiuOpcua.ServerDemo


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM