OPC UA 客户端 - Snet Docs

📘 OPC UA 客户端

命名空间: Snet.Opc.ua.client | 类: OpcUaClientOperate | 基类: DaqAbstract<OpcUaClientOperate, OpcUaClientData.Basics> | 接口: IDaq + IOu

基于 OPC Foundation UA SDK 实现的完整 OPC UA 客户端。可连接任意标准 OPC UA 服务端,浏览节点树、读写节点值、订阅数据变化。支持匿名、用户名、X.509 证书三种认证方式,会话保活与自动重连,重连后自动恢复订阅。

Snet.Opc.ua.client
├── OpcUaClientOperate        // 客户端(入口)
├── OpcUaClientData           // Basics 配置 + Steps 枚举
├── OpcUaClientController     // 多会话控制器,用于大规模点位
└── unility/                  // 内部工具

快速开始

using Snet.Opc.ua.client;
using Snet.Model.data;
using Snet.Model.@enum;
using Snet.Utility;
using System.Collections.Concurrent;
using Opc.Ua;

var op = await OpcUaClientOperate.InstanceAsync(new OpcUaClientData.Basics
{
    ServerUrl = "opc.tcp://127.0.0.1:6688/Opc.Ua.Service",
    AType = AuType.UserName,
    UserName = "snet",
    Password = "123456"
});

await op.OnAsync();

// 读取多个节点——AddressName 即 UA NodeId 字符串
var address = new Address(new List<AddressDetails>
{
    new("Temperature", DataType.Float),
    new("Pressure", DataType.Float)
});
var result = await op.ReadAsync(address);
if (result.Status)
{
    var data = result.GetSource<ConcurrentDictionary<string, AddressValue>>();
    foreach (var kv in data)
        Console.WriteLine($"{kv.Key} = {kv.Value.ResultValue}");
}

// 写入——字典键为 NodeId 字符串
var writeResult = await op.WriteAsync(new ConcurrentDictionary<string, object>
{
    ["ns=2;s=Temperature"] = 25.6f
});

// 订阅——先绑定事件再订阅
op.OnDataEventAsync += async (sender, e) =>
{
    if (e.Status)
    {
        var data = e.GetSource<ConcurrentDictionary<string, AddressValue>>();
        foreach (var kv in data)
            Console.WriteLine($"事件数据: {kv.Key} = {kv.Value.ResultValue}");
    }
};
await op.SubscribeAsync(new Address(new AddressDetails("Temperature", DataType.Float)));

// OPC UA 专用:浏览节点树、获取节点元数据
object? nodeId = await op.GetNodeIDAsync();               // ObjectsFolder NodeId(未连接时为 null)
var references = await op.GetAllNodeAsync(nodeId!);       // object → GetSource<List<ReferenceDescription>>()
var firstNode = references.GetSource<List<ReferenceDescription>>()[0];
var iconType = await op.GetNodeIconTypeAsync(firstNode, nodeId!);

await op.DisposeAsync();

安装

dotnet add package Snet.Opc

OPC UA 协议栈(OPC Foundation UA SDK)随包内置,无需单独安装。首次连接时应用证书会自动生成到应用证书目录({BaseDir}/cer/app)。

配置(OpcUaClientData.Basics

参数 类型 默认值 描述
SN string Guid.NewGuid().ToUpperNString() 唯一标识符
ServerUrl string "opc.tcp://127.0.0.1:6688/Opc.Ua.Service" 服务端端点地址(opc.tcp://主机:端口/路径
AType AuType AuType.UserName 认证类型:AnonymousUserNameCertificate
UserName string? "snet" 用户名(AType = UserName 时必填)
Password string? "123456" 密码(AType = UserName 时必填)
Cer string? null 客户端证书路径(AType = Certificate 时必填)
SecreKey string? null 证书私钥密码
CustomName string? Guid.NewGuid().ToLowerNString() 客户端应用名称(唯一),显示在服务端
Timeout int 5000 操作超时(毫秒)
SessionLifeTime uint 60000 会话生命周期(毫秒)
InterruptReconnection bool true 断线自动重连(保活机制监控)
ReconnectionInterval int 5000 重连间隔(毫秒);同时作为会话保活间隔
SamplingInterval int 100 监控项采样间隔(毫秒)
PublishingInterval int 100 订阅发布间隔(毫秒)
KeepAliveCount int 10 发布周期内最多可跳过的次数,超过后服务端必须发送空保活消息
LifetimeCount int 30 无新请求时最多允许的发布周期数,超过后服务端判定客户端失活并清除订阅。必须至少为 KeepAliveCount 的 3 倍
QueueSize int 1020byte.MaxValue * 4 监控项队列大小
SubscribeSingleGroupMaxCount int 0 订阅单组最大数;0 = 不分组(单组),如 100 = 1000 个点位自动分成 10 组订阅
TaskNumber int 5 订阅通知处理任务数(并发)

AuTypeSnet.Opc.core.Data.AuType):Anonymous — 匿名;UserName — 使用 UserName/PasswordCertificate — 使用 Cer/SecreKey 指定的 X.509 客户端证书。

API 参考

IDaq 生命周期(继承)

方法 描述
Task<OperateResult> OnAsync(CancellationToken token = default) 创建会话并连接。构建 UA 应用、安全配置与用户身份,选择最佳匹配端点,启动通知任务池
Task<OperateResult> OffAsync(bool hardClose = false, CancellationToken token = default) 关闭会话、移除全部订阅、停止任务并释放遥测资源
Task<OperateResult> ReadAsync(Address address, CancellationToken token = default) 批量读取节点值。ResultData:以 AddressName 为键的 ConcurrentDictionary<string, AddressValue>
Task<OperateResult> WriteAsync(ConcurrentDictionary<string, (object value, EncodingType? encodingType)> values, CancellationToken token = default) 批量写入节点值;键为 NodeId 字符串(支持虚拟地址)
Task<OperateResult> SubscribeAsync(Address address, CancellationToken token = default) 订阅节点变化。设置 SubscribeSingleGroupMaxCount 时自动分组;先绑定 OnDataEventAsync
Task<OperateResult> UnSubscribeAsync(Address address, CancellationToken token = default) 移除指定节点的订阅
Task<OperateResult> GetStatusAsync(CancellationToken token = default) 会话已连接时返回 true("已连接")
Task<OperateResult> GetBaseObjectAsync(CancellationToken token = default) ResultData:底层 ISession
ISessionFactory SessionFactory { get; set; } 会话工厂(默认为 DefaultSessionFactory,可注入以便测试)

IOu — OPC UA 专用方法

IOuSnet.Model.@interface)继承 IDaq,增加 7 个 UA 专用方法——全部为异步(返回 Task;参数与结果以 object 传递,用 GetSource<T>() 解包):

方法 返回 描述
Task<object?> GetNodeIDAsync() NodeIdnull 服务端的 ObjectsFolder NodeId(未连接时返回 null)
Task<object> GetAllNodeAsync(object nodeId) ReferenceDescriptionCollection 浏览 nodeId 的子节点(正向 Aggregates + Organizes 引用,Object/Variable/Method 类)
Task<string> GetNodeIconTypeAsync(object target, object sourceId) string 节点图标类型:"Scalar" / "OneDimension" / "TwoDimensions"(变量)、"ObjectsFolder"(根)、"Method""Default"
Task<object> DetailedReadAllNodeDataAsync(object nodeIds) DataValue[] 批量读取 List<NodeId> 的 NodeClass、Value、AccessLevel、DisplayName、Description
Task<string> GetAccessLevelAsync(object value) string 数据值的访问级别:NoneCurrentReadCurrentWriteCurrentReadOrWriteHistoryReadHistoryWriteHistoryReadOrWriteSemanticChangeStatusWriteTimestampWrite
Task<DataType> TypeConvertAsync(object builtInType) DataType UA BuiltInType → Snet DataType(Boolean→Bool、Byte→Byte、Int16/32/64、UInt16/32/64、Float、Double,其余→String)
Task<object> GetNodeValueTypeAsync(object nodeId) BuiltInType 节点的 UA 内置数据类型(需已连接会话)

OpcUaClientOperate 类还保留同名去掉 Async 后缀的同步包装GetNodeID()GetAllNode(...)GetNodeIconType(...)DetailedReadAllNodeData(...)GetAccessLevel(...)TypeConvert(...)GetNodeValueType(...)),它们转发到异步实现;另有接收真实 UA 类型与 CancellationToken类型化重载——参见 其他公共成员

其他公共成员

成员 描述
Task<OperateResult> AddSubscribeAsync(ConcurrentDictionary<string, Address> param, CancellationToken token = default) / AddSubscribe(...) 批量订阅:字典键为订阅标签(一个标签可容纳多个节点;重复添加会跳过已订阅的启用节点)
Task<OperateResult> RemoveSubscribeAsync(Address nodes, CancellationToken token = default) / RemoveSubscribe(...) 按节点移除订阅;节点不存在也返回成功
Task<OperateResult> DeleteNodeAsync(string key, CancellationToken token = default) / DeleteNode(...) 删除服务端节点(服务端允许时)
Task<object> GetNodeIdValue(NodeId nodeId, CancellationToken ct) 读取单个节点的当前值
Task<object?> GetNodeIDAsync(CancellationToken ct = default) GetNodeIDAsync() 的类型化重载(ObjectsFolder NodeId,未连接时为 null)
Task<List<ReferenceDescription>> GetAllNodeAsync(NodeId nodeId, CancellationToken ct = default) GetAllNodeAsync(object) 的类型化重载:浏览 nodeId 的子节点并返回类型化列表(未连接时抛出 BadServerNotConnected
Task<string> GetNodeIconTypeAsync(ReferenceDescription target, NodeId sourceId, CancellationToken ct = default) GetNodeIconTypeAsync(object, object) 的类型化重载
Task<DataValue[]> DetailedReadAllNodeDataAsync(List<NodeId> nodeIds, CancellationToken ct = default) DetailedReadAllNodeDataAsync(object) 的类型化重载(未连接时抛出 BadServerNotConnected
Task<string> GetAccessLevelAsync(DataValue value, CancellationToken ct = default) GetAccessLevelAsync(object) 的类型化重载
Task<DataType> TypeConvertAsync(BuiltInType builtInType, CancellationToken ct = default) TypeConvertAsync(object) 的类型化重载
Task<object> GetNodeValueTypeAsync(NodeId nodeId, CancellationToken ct = default) GetNodeValueTypeAsync(object) 的类型化重载
dynamic GetValue(string value, BuiltInType builtInType) 将字符串按 UA 内置类型解析为对应 CLR 类型(用于写入准备)
dynamic GetArrayValue(IList<string> values, BuiltInType builtInType) 将字符串列表解析为对应 CLR 数组

OpcUaClientController — 多会话扩展

当单会话无法承载大规模点位时,OpcUaClientController 将读取与订阅节点分摊到多个 OpcUaClientOperate 会话:

var controller = new OpcUaClientController(
    ServerBasics: basics,        // OpcUaClientData.Basics
    ServerMaxSession: 10,        // UA 服务端允许的最大会话数
    Nodes: readAddress,          // Address
    SubscriptionNodes: subAddress); // Address(可选)
ConcurrentDictionary<OpcUaClientOperate, object> clients = controller.Get();
// key = 客户端实例,value = Address 或 (readAddress, subAddress) 元组
foreach (var client in clients.Keys)
    await client.OnAsync();

代码示例

浏览节点树

using Opc.Ua;   // ReferenceDescription、NodeId
using Snet.Utility;   // 对 object 结果调用 GetSource<T> 的扩展方法

object? nodeId = await op.GetNodeIDAsync();
if (nodeId == null) return;

// ObjectsFolder(根)的子节点
var references = await op.GetAllNodeAsync(nodeId);
foreach (var reference in references.GetSource<List<ReferenceDescription>>())
{
    Console.WriteLine($"NodeId={reference.NodeId}  Name={reference.DisplayName.Text}  Class={reference.NodeClass}");

    // 树形控件用的图标类型
    string icon = await op.GetNodeIconTypeAsync(reference, nodeId);
    Console.WriteLine($"  icon: {icon}");
}

节点元数据与数据类型

using Opc.Ua;   // NodeId、DataValue、BuiltInType
using Snet.Utility;   // 对 object 结果调用 GetSource<T> 的扩展方法

var nodeId = new NodeId("ns=2;s=Temperature");

// 详细属性:NodeClass、Value、AccessLevel、DisplayName、Description
DataValue[] values = (await op.DetailedReadAllNodeDataAsync(new List<NodeId> { nodeId }))
    .GetSource<DataValue[]>();
Console.WriteLine($"访问级别: {await op.GetAccessLevelAsync(values[2])}");

// 节点数据类型
BuiltInType type = (BuiltInType)await op.GetNodeValueTypeAsync(nodeId);
DataType dataType = await op.TypeConvertAsync(type);

// 从字符串准备正确类型的写入值
dynamic parsed = op.GetValue("25.6", type);
await op.WriteAsync(new ConcurrentDictionary<string, object> { ["ns=2;s=Temperature"] = parsed });

分组订阅

var op = await OpcUaClientOperate.InstanceAsync(new OpcUaClientData.Basics
{
    ServerUrl = "opc.tcp://127.0.0.1:6688/Opc.Ua.Service",
    AType = AuType.UserName,
    UserName = "snet",
    Password = "123456",
    SubscribeSingleGroupMaxCount = 100   // 自动按 100 个节点一组拆分
});
await op.OnAsync();

op.OnDataEventAsync += async (sender, e) =>
{
    if (e.Status)
    {
        var data = e.GetSource<ConcurrentDictionary<string, AddressValue>>();
        foreach (var kv in data)
            Console.WriteLine($"[{DateTime.Now:HH:mm:ss.fff}] {kv.Key} = {kv.Value.ResultValue}");
    }
};

// 1000 个点位 → 10 个组
await op.SubscribeAsync(new Address(all1000AddressDetails));

混合虚拟地址

AddressName 注册为虚拟地址的节点在本地读写,不访问 UA 服务端;虚拟地址与真实节点可混在同一个 Address / WriteAsync 调用中。参见 地址系统

常见问题

Q: 连接失败,提示"应用实例证书无效"。 A: 应用证书自动生成在 {BaseDir}/cer/app。首次连接时如服务端要求信任其证书请确认信任;确保 AppCerPath 目录可写。

Q: 节点标识符格式是什么? A: AddressName 必须是合法的 UA NodeId 字符串,如 ns=2;s=Temperaturens=2;i=1001 或数值形式 i=2253。可用 GetAllNodeAsync 浏览目标服务端的真实标识符。

Q: 订阅收不到事件。 A: 必须在调用 SubscribeAsync 之前绑定 OnDataEventAsync(或 OnDataEvent)。确认 GetStatusAsync 为连接状态——重连后订阅会自动重建恢复。

Q: 如何以正确类型写入值? A: 用 GetNodeValueTypeAsyncTypeConvertAsync 获取节点类型,或用 GetValue(string, BuiltInType) / GetArrayValue(...) 准备值,客户端会发送类型正确的 Variant。

Q: 服务端失去响应,客户端能恢复吗? A: InterruptReconnection = true(默认)时,保活失败会触发重连循环(每 ReconnectionInterval 毫秒重试);重连成功后自动重建全部订阅并恢复事件。

Q: 点位太多导致读取慢。 A: 提高 TaskNumber,或使用 OpcUaClientController 将读写/订阅分摊到多个会话(ServerMaxSession 不能超过服务端会话上限)。

相关文档