陆迎 發表於 2024-6-19 22:14:00

使用C#开发OPC UA服务器

<p>OPC基金会提供了OPC UA .NET标准库以及示例程序,但官方文档过于简单,光看官方文档和示例程序很难弄懂OPC UA .NET标准库怎么用,花了不少时间摸索才略微弄懂如何使用,以下记录如何从一个控制台程序开发一个OPC UA服务器。</p>
<h2 id="安装nuget包">安装Nuget包</h2>
<p>安装<code>OPCFoundation.NetStandard.Opc.Ua</code><br>
<img src="https://img2024.cnblogs.com/blog/1025133/202406/1025133-20240619220954858-1885697633.png" alt="image" loading="lazy"></p>
<h2 id="主程序">主程序</h2>
<p>修改<code>Program.cs</code>代码如下:</p>
<pre><code class="language-c#">using Opc.Ua;
using Opc.Ua.Configuration;
using Opc.Ua.Server;

namespace SampleOpcUaServer
{
    internal class Program
    {
      static void Main(string[] args)
      {
            // 启动OPC UA服务器
            ApplicationInstance application = new ApplicationInstance();
            application.ConfigSectionName = "OpcUaServer";
            application.LoadApplicationConfiguration(false).Wait();
            application.CheckApplicationInstanceCertificate(false, 0).Wait();

            var server = new StandardServer();
            var nodeManagerFactory = new NodeManagerFactory();
            server.AddNodeManager(nodeManagerFactory);
            application.Start(server).Wait();

            // 模拟数据
            var nodeManager = nodeManagerFactory.NodeManager;
            var simulationTimer = new System.Timers.Timer(1000);
            var random = new Random();
            simulationTimer.Elapsed += (sender, EventArgs) =&gt;
            {
                nodeManager?.UpdateValue("ns=2;s=Root_Test", random.NextInt64());
            };
            simulationTimer.Start();

            // 输出OPC UA Endpoint
            Console.WriteLine("Endpoints:");
            foreach (var endpoint in server.GetEndpoints().DistinctBy(x =&gt; x.EndpointUrl))
            {
                Console.WriteLine(endpoint.EndpointUrl);
            }

            Console.WriteLine("按Enter添加新变量");
            Console.ReadLine();

            // 添加新变量
            nodeManager?.AddVariable("ns=2;s=Root", null, "Test2", (int)BuiltInType.Int16, ValueRanks.Scalar);
            Console.WriteLine("已添加变量");
            Console.ReadLine();
      }
    }
}
</code></pre>
<p>上述代码中:</p>
<ul>
<li><code>ApplicationInstance</code>是OPC UA标准库中用于配置并承载OPC UA Server和检查证书的类。</li>
<li><code>application.ConfigSectionName</code>指定了配置文件的名称,配置文件是xml文件,将会在程序文件夹查找名为<code>OpcUaServer.Config.xml</code>的配置文件。配置文件内容见后文。</li>
<li><code>application.LoadApplicationConfiguration</code>加载前面指定的配置文件。如果不想使用配置文件,也可通过代码给<code>application.ApplicationConfiguration</code>赋值。</li>
<li>有<code>StandardServer</code>和<code>ReverseConnectServer</code>两种作为OPC UA服务器的类,<code>ReverseConnectServer</code>派生于<code>StandardServer</code>,这两种类的区别未深入研究,用<code>StandardServer</code>可满足基本的需求。</li>
<li>OPC UA的地址空间由节点组成,简单理解节点就是提供给OPC UA客户端访问的变量和文件夹。通过<code>server.AddNodeManager</code>方法添加节点管理工厂类,<code>NodeManagerFactory</code>类定义见后文。</li>
<li>调用<code>application.Start(server)</code>方法后,OPC UA Server就会开始运行,并不会阻塞代码,为了保持在控制台程序中运行,所以使用<code>Console.ReadLine()</code>阻塞程序。</li>
<li><code>nodeManager?.UpdateValue</code>是自定义的更新OPC UA地址空间中变量值的方法。</li>
<li><code>nodeManager?.AddVariable</code>在此演示动态添加一个新的变量。</li>
</ul>
<h2 id="opc-ua配置文件">OPC UA配置文件</h2>
<p>新建<code>OpcUaServer.Config.xml</code>文件。</p>
<p><img src="https://img2024.cnblogs.com/blog/1025133/202406/1025133-20240619221016756-1892060509.png" alt="image" loading="lazy"></p>
<p>在属性中设为“始终赋值”。</p>
<p><img src="https://img2024.cnblogs.com/blog/1025133/202406/1025133-20240619221023465-135999481.png" alt="image" loading="lazy"></p>
<p>内容如下:</p>
<pre><code class="language-xml">&lt;?xml version="1.0" encoding="utf-8"?&gt;
&lt;ApplicationConfiguration
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:ua="http://opcfoundation.org/UA/2008/02/Types.xsd"
xmlns="http://opcfoundation.org/UA/SDK/Configuration.xsd"
&gt;
        &lt;ApplicationName&gt;Sample OPC UA Server&lt;/ApplicationName&gt;
        &lt;ApplicationUri&gt;urn:localhost:UA:OpcUaServer&lt;/ApplicationUri&gt;
        &lt;ProductUri&gt;uri:opcfoundation.org:OpcUaServer&lt;/ProductUri&gt;
        &lt;ApplicationType&gt;Server_0&lt;/ApplicationType&gt;

        &lt;SecurityConfiguration&gt;

                &lt;!-- Where the application instance certificate is stored (MachineDefault) --&gt;
                &lt;ApplicationCertificate&gt;
                        &lt;StoreType&gt;Directory&lt;/StoreType&gt;
                        &lt;StorePath&gt;%CommonApplicationData%\OPC Foundation\pki\own&lt;/StorePath&gt;
                        &lt;SubjectName&gt;CN=Sample Opc Ua Server, C=US, S=Arizona, O=SomeCompany, DC=localhost&lt;/SubjectName&gt;
                &lt;/ApplicationCertificate&gt;

                &lt;!-- Where the issuer certificate are stored (certificate authorities) --&gt;
                &lt;TrustedIssuerCertificates&gt;
                        &lt;StoreType&gt;Directory&lt;/StoreType&gt;
                        &lt;StorePath&gt;%CommonApplicationData%\OPC Foundation\pki\issuer&lt;/StorePath&gt;
                &lt;/TrustedIssuerCertificates&gt;

                &lt;!-- Where the trust list is stored --&gt;
                &lt;TrustedPeerCertificates&gt;
                        &lt;StoreType&gt;Directory&lt;/StoreType&gt;
                        &lt;StorePath&gt;%CommonApplicationData%\OPC Foundation\pki\trusted&lt;/StorePath&gt;
                &lt;/TrustedPeerCertificates&gt;

                &lt;!-- The directory used to store invalid certficates for later review by the administrator. --&gt;
                &lt;RejectedCertificateStore&gt;
                        &lt;StoreType&gt;Directory&lt;/StoreType&gt;
                        &lt;StorePath&gt;%CommonApplicationData%\OPC Foundation\pki\rejected&lt;/StorePath&gt;
                &lt;/RejectedCertificateStore&gt;
        &lt;/SecurityConfiguration&gt;

        &lt;TransportConfigurations&gt;&lt;/TransportConfigurations&gt;

        &lt;TransportQuotas&gt;
                &lt;OperationTimeout&gt;600000&lt;/OperationTimeout&gt;
                &lt;MaxStringLength&gt;1048576&lt;/MaxStringLength&gt;
                &lt;MaxByteStringLength&gt;1048576&lt;/MaxByteStringLength&gt;
                &lt;MaxArrayLength&gt;65535&lt;/MaxArrayLength&gt;
                &lt;MaxMessageSize&gt;4194304&lt;/MaxMessageSize&gt;
                &lt;MaxBufferSize&gt;65535&lt;/MaxBufferSize&gt;
                &lt;ChannelLifetime&gt;300000&lt;/ChannelLifetime&gt;
                &lt;SecurityTokenLifetime&gt;3600000&lt;/SecurityTokenLifetime&gt;
        &lt;/TransportQuotas&gt;
        &lt;ServerConfiguration&gt;
                &lt;BaseAddresses&gt;
                        &lt;ua:String&gt;https://localhost:62545/OpcUaServer/&lt;/ua:String&gt;
                        &lt;ua:String&gt;opc.tcp://localhost:62546/OpcUaServer&lt;/ua:String&gt;
                &lt;/BaseAddresses&gt;
                &lt;SecurityPolicies&gt;
                        &lt;ServerSecurityPolicy&gt;
                                &lt;SecurityMode&gt;SignAndEncrypt_3&lt;/SecurityMode&gt;
                                &lt;SecurityPolicyUri&gt;http://opcfoundation.org/UA/SecurityPolicy#Basic256Sha256&lt;/SecurityPolicyUri&gt;
                        &lt;/ServerSecurityPolicy&gt;
                        &lt;ServerSecurityPolicy&gt;
                                &lt;SecurityMode&gt;None_1&lt;/SecurityMode&gt;
                                &lt;SecurityPolicyUri&gt;http://opcfoundation.org/UA/SecurityPolicy#None&lt;/SecurityPolicyUri&gt;
                        &lt;/ServerSecurityPolicy&gt;
                        &lt;ServerSecurityPolicy&gt;
                                &lt;SecurityMode&gt;Sign_2&lt;/SecurityMode&gt;
                                &lt;SecurityPolicyUri&gt;&lt;/SecurityPolicyUri&gt;
                        &lt;/ServerSecurityPolicy&gt;
                        &lt;ServerSecurityPolicy&gt;
                                &lt;SecurityMode&gt;SignAndEncrypt_3&lt;/SecurityMode&gt;
                                &lt;SecurityPolicyUri&gt;&lt;/SecurityPolicyUri&gt;
                        &lt;/ServerSecurityPolicy&gt;
                &lt;/SecurityPolicies&gt;
                &lt;UserTokenPolicies&gt;
                        &lt;ua:UserTokenPolicy&gt;
                                &lt;ua:TokenType&gt;Anonymous_0&lt;/ua:TokenType&gt;
                        &lt;/ua:UserTokenPolicy&gt;
                        &lt;ua:UserTokenPolicy&gt;
                                &lt;ua:TokenType&gt;UserName_1&lt;/ua:TokenType&gt;
                        &lt;/ua:UserTokenPolicy&gt;
                        &lt;ua:UserTokenPolicy&gt;
                                &lt;ua:TokenType&gt;Certificate_2&lt;/ua:TokenType&gt;
                        &lt;/ua:UserTokenPolicy&gt;
                        &lt;!--
      &lt;ua:UserTokenPolicy&gt;
      &lt;ua:TokenType&gt;IssuedToken_3&lt;/ua:TokenType&gt;
      &lt;ua:IssuedTokenType&gt;urn:oasis:names:tc:SAML:1.0:assertion:Assertion&lt;/ua:IssuedTokenType&gt;
      &lt;/ua:UserTokenPolicy&gt;
      --&gt;
                &lt;/UserTokenPolicies&gt;
                &lt;DiagnosticsEnabled&gt;false&lt;/DiagnosticsEnabled&gt;
                &lt;MaxSessionCount&gt;100&lt;/MaxSessionCount&gt;
                &lt;MinSessionTimeout&gt;10000&lt;/MinSessionTimeout&gt;
                &lt;MaxSessionTimeout&gt;3600000&lt;/MaxSessionTimeout&gt;
                &lt;MaxBrowseContinuationPoints&gt;10&lt;/MaxBrowseContinuationPoints&gt;
                &lt;MaxQueryContinuationPoints&gt;10&lt;/MaxQueryContinuationPoints&gt;
                &lt;MaxHistoryContinuationPoints&gt;100&lt;/MaxHistoryContinuationPoints&gt;
                &lt;MaxRequestAge&gt;600000&lt;/MaxRequestAge&gt;
                &lt;MinPublishingInterval&gt;100&lt;/MinPublishingInterval&gt;
                &lt;MaxPublishingInterval&gt;3600000&lt;/MaxPublishingInterval&gt;
                &lt;PublishingResolution&gt;50&lt;/PublishingResolution&gt;
                &lt;MaxSubscriptionLifetime&gt;3600000&lt;/MaxSubscriptionLifetime&gt;
                &lt;MaxMessageQueueSize&gt;10&lt;/MaxMessageQueueSize&gt;
                &lt;MaxNotificationQueueSize&gt;100&lt;/MaxNotificationQueueSize&gt;
                &lt;MaxNotificationsPerPublish&gt;1000&lt;/MaxNotificationsPerPublish&gt;
                &lt;MinMetadataSamplingInterval&gt;1000&lt;/MinMetadataSamplingInterval&gt;
                &lt;AvailableSamplingRates&gt;
                        &lt;SamplingRateGroup&gt;
                                &lt;Start&gt;5&lt;/Start&gt;
                                &lt;Increment&gt;5&lt;/Increment&gt;
                                &lt;Count&gt;20&lt;/Count&gt;
                        &lt;/SamplingRateGroup&gt;
                        &lt;SamplingRateGroup&gt;
                                &lt;Start&gt;100&lt;/Start&gt;
                                &lt;Increment&gt;100&lt;/Increment&gt;
                                &lt;Count&gt;4&lt;/Count&gt;
                        &lt;/SamplingRateGroup&gt;
                        &lt;SamplingRateGroup&gt;
                                &lt;Start&gt;500&lt;/Start&gt;
                                &lt;Increment&gt;250&lt;/Increment&gt;
                                &lt;Count&gt;2&lt;/Count&gt;
                        &lt;/SamplingRateGroup&gt;
                        &lt;SamplingRateGroup&gt;
                                &lt;Start&gt;1000&lt;/Start&gt;
                                &lt;Increment&gt;500&lt;/Increment&gt;
                                &lt;Count&gt;20&lt;/Count&gt;
                        &lt;/SamplingRateGroup&gt;
                &lt;/AvailableSamplingRates&gt;
                &lt;MaxRegistrationInterval&gt;30000&lt;/MaxRegistrationInterval&gt;
                &lt;NodeManagerSaveFile&gt;OpcUaServer.nodes.xml&lt;/NodeManagerSaveFile&gt;
        &lt;/ServerConfiguration&gt;

        &lt;TraceConfiguration&gt;
                &lt;OutputFilePath&gt;Logs\SampleOpcUaServer.log&lt;/OutputFilePath&gt;
                &lt;DeleteOnLoad&gt;true&lt;/DeleteOnLoad&gt;
                &lt;!-- Show Only Errors --&gt;
                &lt;!-- &lt;TraceMasks&gt;1&lt;/TraceMasks&gt; --&gt;
                &lt;!-- Show Only Security and Errors --&gt;
                &lt;!-- &lt;TraceMasks&gt;513&lt;/TraceMasks&gt; --&gt;
                &lt;!-- Show Only Security, Errors and Trace --&gt;
                &lt;TraceMasks&gt;515&lt;/TraceMasks&gt;
                &lt;!-- Show Only Security, COM Calls, Errors and Trace --&gt;
                &lt;!-- &lt;TraceMasks&gt;771&lt;/TraceMasks&gt; --&gt;
                &lt;!-- Show Only Security, Service Calls, Errors and Trace --&gt;
                &lt;!-- &lt;TraceMasks&gt;523&lt;/TraceMasks&gt; --&gt;
                &lt;!-- Show Only Security, ServiceResultExceptions, Errors and Trace --&gt;
                &lt;!-- &lt;TraceMasks&gt;519&lt;/TraceMasks&gt; --&gt;
        &lt;/TraceConfiguration&gt;

&lt;/ApplicationConfiguration&gt;
</code></pre>
<p>需要关注的内容有:</p>
<ul>
<li>
<p>ApplicationName:在通过OPC UA工具连接此服务器时,显示的服务器名称就是该值。</p>
</li>
<li>
<p>ApplicationType:应用类型,可用的值有:</p>
<ul>
<li>Server_0:服务器</li>
<li>Client_1:客户端</li>
<li>ClientAndServer_2:客户机和服务器</li>
<li>DisconveryServer_3:发现服务器。发现服务器用于注册OPC UA服务器,然后提供OPC UA客户端搜索到服务器。</li>
</ul>
</li>
<li>
<p>SecurityConfiguration:该节点中指定了OPC UA的证书存储路径,一般保持默认,不需修改。</p>
</li>
<li>
<p>ServerConfiguration.BaseAddresses:该节点指定OPC UA服务器的url地址。</p>
</li>
<li>
<p>ServerConfiguration.SecurityPolicies:该节点配置允许的服务器安全策略,配置通讯是否要签名和加密。</p>
</li>
<li>
<p>ServerConfiguration.UserTokenPolicies:该节点配置允许的用户Token策略,例如是否允许匿名访问。</p>
</li>
<li>
<p>AvailableSamplingRates:配置支持的变量采样率。</p>
</li>
<li>
<p>TraceConfiguration:配置OPC UA服务器的日志记录,设定日志记录路径,配置的路径是在系统临时文件夹下的路径,日志文件的完整路径是在<code>%TEMP%\Logs\SampleOpcUaServer.log</code>。</p>
</li>
</ul>
<h2 id="nodemanagerfactory">NodeManagerFactory</h2>
<p>新建<code>NodeManagerFactory</code>类,OPC UA server将调用该类的<code>Create</code>方法创建<code>INodeManager</code>实现类,而<code>INodeManager</code>实现类用于管理OPC UA地址空间。内容如下:</p>
<pre><code class="language-c#">using Opc.Ua;
using Opc.Ua.Server;

namespace SampleOpcUaServer
{
    internal class NodeManagerFactory : INodeManagerFactory
    {
      public NodeManager? NodeManager { get; private set; }
      public StringCollection NamespacesUris =&gt; new StringCollection() { "http://opcfoundation.org/OpcUaServer" };

      public INodeManager Create(IServerInternal server, ApplicationConfiguration configuration)
      {
            if (NodeManager != null)
                return NodeManager;

            NodeManager = new NodeManager(server, configuration, NamespacesUris.ToArray());
            return NodeManager;
      }
    }
}
</code></pre>
<ul>
<li>实现<code>INodeManagerFactory</code>接口,需实现<code>NamespacesUris</code>属性和<code>Create</code>方法。</li>
<li><code>NodeManager</code>类是自定义的类,定义见后文。</li>
<li>为了获取<code>Create</code>方法返回的<code>NodeManager</code>类,定义了<code>NodeManager</code>属性。</li>
</ul>
<h2 id="nodemanager">NodeManager</h2>
<p>新建<code>NodeManager</code>类:</p>
<pre><code class="language-c#">using Opc.Ua;
using Opc.Ua.Server;

namespace SampleOpcUaServer
{
    internal class NodeManager : CustomNodeManager2
    {
      public NodeManager(IServerInternal server, params string[] namespaceUris)
            : base(server, namespaceUris)
      {
      }

      public NodeManager(IServerInternal server, ApplicationConfiguration configuration, params string[] namespaceUris)
            : base(server, configuration, namespaceUris)
      {
      }

      protected override NodeStateCollection LoadPredefinedNodes(ISystemContext context)
      {
            FolderState root = CreateFolder(null, null, "Root");
            root.AddReference(ReferenceTypes.Organizes, true, ObjectIds.ObjectsFolder); // 将节点添加到服务器根节点
            root.EventNotifier = EventNotifiers.SubscribeToEvents;
            AddRootNotifier(root);

            CreateVariable(root, null, "Test", BuiltInType.Int64, ValueRanks.Scalar);

            return new NodeStateCollection(new List&lt;NodeState&gt; { root });
      }

      protected virtual FolderState CreateFolder(NodeState? parent, string? path, string name)
      {
            if (string.IsNullOrWhiteSpace(path))
                path = parent?.NodeId.Identifier is string id ? id + "_" + name : 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;
      }

      protected virtual BaseDataVariableState CreateVariable(NodeState? parent, string? path, string name, BuiltInType dataType, int valueRank)
      {
            return CreateVariable(parent, path, name, (uint)dataType, valueRank);
      }

      protected virtual BaseDataVariableState CreateVariable(NodeState? parent, string? path, string name, NodeId dataType, int valueRank)
      {
            if (string.IsNullOrWhiteSpace(path))
                path = parent?.NodeId.Identifier is string id ? id + "_" + name : name;

            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.None;
            variable.UserWriteMask = AttributeWriteMask.None;
            variable.DataType = dataType;
            variable.ValueRank = valueRank;
            variable.AccessLevel = AccessLevels.CurrentReadOrWrite;
            variable.UserAccessLevel = AccessLevels.CurrentReadOrWrite;
            variable.Historizing = false;
            variable.Value = Opc.Ua.TypeInfo.GetDefaultValue(dataType, valueRank, Server.TypeTree);
            variable.StatusCode = StatusCodes.Good;
            variable.Timestamp = DateTime.UtcNow;

            if (valueRank == ValueRanks.OneDimension)
            {
                variable.ArrayDimensions = new ReadOnlyList&lt;uint&gt;(new List&lt;uint&gt; { 0 });
            }
            else if (valueRank == ValueRanks.TwoDimensions)
            {
                variable.ArrayDimensions = new ReadOnlyList&lt;uint&gt;(new List&lt;uint&gt; { 0, 0 });
            }

            if (parent != null)
            {
                parent.AddChild(variable);
            }

            return variable;
      }

      public void UpdateValue(NodeId nodeId, object value)
      {
            var variable = (BaseDataVariableState)FindPredefinedNode(nodeId, typeof(BaseDataVariableState));
            if (variable != null)
            {
                variable.Value = value;
                variable.Timestamp = DateTime.UtcNow;
                variable.ClearChangeMasks(SystemContext, false);
            }
      }

      public NodeId AddFolder(NodeId parentId, string? path, string name)
      {
            var node = Find(parentId);
            var newNode = CreateFolder(node, path, name);
            AddPredefinedNode(SystemContext, node);
            return newNode.NodeId;
      }

      public NodeId AddVariable(NodeId parentId, string? path, string name, BuiltInType dataType, int valueRank)
      {
            return AddVariable(parentId, path, name, (uint)dataType, valueRank);
      }

      public NodeId AddVariable(NodeId parentId, string? path, string name, NodeId dataType, int valueRank)
      {
            var node = Find(parentId);
            var newNode = CreateVariable(node, path, name, dataType, valueRank);
            AddPredefinedNode(SystemContext, node);
            return newNode.NodeId;
      }
    }
}
</code></pre>
<p>上述代码中:</p>
<ul>
<li>需继承<code>CustomNodeManager2</code>,这是OPC UA标准库中提供的类。</li>
<li>重写<code>LoadPredefinedNodes</code>方法,在该方法中配置预定义节点。其中创建了一个Root文件夹,Root文件夹中添加了Test变量。</li>
<li><code>root.AddReference(ReferenceTypes.Organizes, true, ObjectIds.ObjectsFolder)</code>该语句将节点添加到OPC UA服务器根节点,如果不使用该语句,可在<code>Server</code>节点下看到添加的节点。</li>
<li><code>CreateFolder</code>是自定义的方法,用于简化创建文件夹节点。</li>
<li><code>CreateVariable</code>是自定义的方法,用于简化创建变量节点。</li>
<li><code>UpdateValue</code>是用于更新变量节点值的方法。其中修改值后,需调用<code>ClearChangeMasks</code>方法,才能通知客户端更新值。</li>
<li><code>AddFolder</code>用于启动服务器后添加新的文件夹。</li>
<li><code>AddVariable</code>用于启动服务器后添加新的变量。</li>
</ul>
<h2 id="测试服务器">测试服务器</h2>
<p>比较好用的测试工具有:</p>
<ul>
<li>
<p>UaExpert:Unified Automation公司提供的测试工具,需安装,能用于连接OPC UA。</p>
</li>
<li>
<p>OpcExpert:opcti公司提供的免费测试工具,绿色版,能连接OPC和OPC UA。</p>
</li>
</ul>
<p>以下用OpcExpert测试。</p>
<p>浏览本地计算机可发现OPC UA服务器,可看到添加的Root节点和Test变量,Test变量的值会每秒更新。</p>
<p><img src="https://img2024.cnblogs.com/blog/1025133/202406/1025133-20240619221127925-1404613106.png" alt="image" loading="lazy"></p>
<p>源码地址:https://github.com/Yada-Yang/SampleOpcUaServer</p>


</div>
<div id="MySignature" role="contentinfo">
    <p>本文来自博客园,作者:星墨,转载请注明原文链接:https://www.cnblogs.com/yada/p/18257593</p><br><br>
来源:https://www.cnblogs.com/yada/p/18257593
頁: [1]
查看完整版本: 使用C#开发OPC UA服务器