OpcUaHelper.Team 2.1.4

OpcUaHelper.Team

基于 OPC Foundation 官方 .NET Standard 栈(2.0.1,MIT 许可)二次封装的 OPC UA 客户端通讯库。 v2.0 完成官方栈 2.0 的适配:全异步 API、线程安全的会话管理、自动重连与订阅恢复,同时支持 .NET Framework 4.8 与 .NET 8/9/10,以标准 NuGet 包形式发布。

v2.1.4 将客户端应用证书初始化推迟到端点选择之后:仅当所选端点使用签名/加密(或配置 IdentityFactory)时才创建并要求证书,SecurityPolicy None 明文端点连接完全不生成证书文件,修复无证书无密码连接也被强制要求客户端证书的问题。UseSecurity 默认 true 时只要服务器提供安全端点就会选择加密连接,并要求服务器信任自动生成的客户端证书;需要真正的无证书连接请显式设置 UseSecurity = false。

特性

  • 开箱即连:一次 ConnectAsync 完成端点发现、安全协商、证书引导(安全端点首次运行自动生成自签名客户端证书)
  • 全异步 API:读写、浏览、订阅、历史、方法调用全部基于 async/await,可并发调用
  • 自动重连:心跳或服务调用检测到连接关闭后,通过 SessionReconnectHandler 恢复;原生订阅重建后再清理旧订阅,轮询订阅继续读取新会话
  • 双模式订阅:原生推送(Native)与客户端轮询(Polling,应对服务器扫描慢导致推送不及时的场景)可在同一客户端并存,按节点及时性要求分别选用
  • 线程安全:重连期间会话对象的替换在内部保证一致,业务代码无感知
  • 批量操作:批量读、批量写(逐项返回成功标志)、浏览自动分页
  • 按需节点树:CreateBrowseTree 创建树,展开时 LoadChildrenAsync 只读取直接子节点,缓存已加载分支
  • 全量扫描:BrowseAllNodesAsync 递归扫描整棵节点树(深度限制、进度回调、分支容错、自动去重)
  • 事件通知:连接状态机(Disconnected/Connecting/Connected/Reconnecting)+ 通讯状态事件
  • 可选日志:构造函数注入 ILogger 即可接入任意日志体系,默认关闭日志,同时官方栈的遥测也使用同一个日志器
  • 双框架:单包多目标 net48 + net8.0(net8.0 资产向上兼容 .NET 9/10)

安装

NuGet 包 ID 与显示名称为 OpcUaHelper.Team。程序集仍为 OpcUaHelper.dll,命名空间仍为 OpcUaHelper,现有 using 和 API 调用无需修改。从旧包迁移时,请将 NuGet 引用由 OpcUaHelper 替换为 OpcUaHelper.Team。

# 从你们的私有 NuGet 源安装
dotnet add package OpcUaHelper.Team --version 2.1.4 -s http://your-nuget-server/v3/index.json

或 PackageReference:

<PackageReference Include="OpcUaHelper.Team" Version="2.1.4" />

快速上手

using Opc.Ua;
using OpcUaHelper;

await using var client = new OpcUaClient(new OpcUaClientOptions
{
    ServerUrl = "opc.tcp://192.168.1.88:4840",
    ApplicationName = "MyApp",           // 决定证书主题与 pki 目录名
});

client.ConnectionStateChanged += (_, e) => Console.WriteLine($"[状态] {e}");
client.StatusChanged          += (_, e) => Console.WriteLine($"[通讯] {e}");

await client.ConnectAsync();

// 读:原始 DataValue / 强类型 / 批量
var dv        = await client.ReadNodeAsync("ns=2;s=Device1.Tag1");
var value     = await client.ReadNodeAsync<float>("ns=2;s=Device1.Tag1");
var values    = await client.ReadNodesAsync<double>(new[] { "ns=2;s=T1", "ns=2;s=T2", "ns=2;s=T3" });

// 写:单点 / 批量(逐项返回 bool)
await client.WriteNodeAsync("ns=2;s=Device1.Tag1", 123.4f);
var ok = await client.WriteNodesAsync(new[] { "ns=2;s=T1", "ns=2;s=T2" }, new object[] { 1, 2 });

// 浏览
var nodes = await client.BrowseAsync("i=85");   // Objects 根
foreach (var node in nodes)
{
    Console.WriteLine(node);                     // 形如 "Device1 [ns=2;i=5001] (Object)"
}

// 节点选择界面:只加载第一层,之后按展开加载子节点
var tree = client.CreateBrowseTree();
var firstLevel = await tree.LoadChildrenAsync();
foreach (var node in firstLevel)
    Console.WriteLine($"{node.Info.DisplayName} [{node.Info.NodeId}]");
// 用户展开 node 时调用 await node.LoadChildrenAsync(),重复展开复用缓存。

// 订阅(一条语句完成创建 + 监视 + 下发)
await client.SubscribeAsync("heart", new[] { "ns=2;s=Device1.Tag1" }, n =>
    Console.WriteLine($"{n.NodeId} = {n.Value.WrappedValue} [{n.Value.StatusCode}]"));

// 历史 / 方法 / 属性
var history = await client.ReadHistoryRawAsync<float>("ns=2;s=T1", DateTime.Now.AddHours(-1), DateTime.Now);
var outputs = await client.CallMethodAsync("ns=2;s=Device1", "ns=2;s=Device1.Method1", arg1, arg2);
var attrs   = await client.ReadNodeAttributesAsync("ns=2;s=T1");

await client.DisconnectAsync();

完整可运行示例见 samples/OpcUaHelper.Samples(.NET 8) 与 samples/OpcUaHelper.NetFrameworkSample(.NET Framework 4.8)。

登录身份

// 匿名(默认)
new OpcUaClientOptions { ServerUrl = "...", Identity = OpcUaIdentity.Anonymous() }
// 用户名密码
new OpcUaClientOptions { ServerUrl = "...", Identity = OpcUaIdentity.UserName("admin", "123456") }

// 证书:2.0 的私钥由 CertificateManager.CertificateProvider 解析,标识只保存元数据。
// 应用证书配置在 ConnectAsync 内初始化,因此用 IdentityFactory 延迟构造身份。
new OpcUaClientOptions
{
    ServerUrl = "opc.tcp://localhost:4840",
    IdentityFactory = (configuration, ct) => OpcUaIdentity.CertificateAsync(
        new Opc.Ua.CertificateIdentifier
        {
            StoreType = Opc.Ua.CertificateStoreType.Directory,
            StorePath = @"C:\MyApp\pki\users",
            Thumbprint = "用户证书指纹",
        },
        configuration.CertificateManager.CertificateProvider,
        cancellationToken: ct),
}
// 加密私钥可传入 ICertificatePasswordProvider;外部提供者须保持可用直到客户端释放。

无证书明文连接

UseSecurity 默认为 true:只要服务器提供安全端点(Basic256Sha256 等),客户端就会选择加密连接, 首次运行自动生成自签名应用证书(%LocalAppData%\<应用名>\pki\own),并且要求服务器信任该证书, 否则连接被服务器拒绝。不配置任何证书 ≠ 不使用证书。

若要建立真正的无证书连接(SecurityPolicy None + 匿名身份),必须显式设置 UseSecurity = false:

new OpcUaClientOptions
{
    ServerUrl = "opc.tcp://192.168.1.88:4840",
    UseSecurity = false,   // 强制 None 明文端点:不创建、不使用、不要求任何客户端证书
}

明文连接时库会跳过应用证书初始化,pki 目录不会产生任何证书文件; 服务器只提供安全端点时将报 BadSecurityPolicyRejected(不会自动降级到明文)。

常用配置项(OpcUaClientOptions)

属性 默认值 说明
ServerUrl opc.tcp://localhost:4840 服务器地址
ApplicationName OpcUaHelper 应用名(证书 CN、pki 目录名、会话名)
UseSecurity true 优先选择加密端点(要求服务器信任自动生成的客户端证书);false 强制 None 明文端点,不使用任何证书
AllowInsecureFallback true 没有安全端点时允许回退 None;设为 false 要求安全连接
IdentityFactory null 配置初始化后的异步身份工厂,设置后优先于 Identity
AutoAcceptUntrustedCertificates true 自动信任服务器自签名证书(受控工业网络便捷项;高安全场景请改 false 并手工管理信任列表)
RejectSha1SignedCertificates false 是否拒绝 SHA-1 证书(为兼容老 PLC 默认不拒绝)
CertificateStoreRoot %LocalAppData%\<应用名>\pki DirectoryStore 根目录(own/issuer/trusted/rejected);仅安全端点连接时写入,明文连接不创建证书
SessionTimeoutMilliseconds 60000 会话超时
KeepAliveIntervalMilliseconds 5000 心跳间隔,0 禁用
OperationTimeoutMilliseconds 120000 底层传输通道服务调用超时,独立于会话超时
SecurityTokenLifetimeMilliseconds null(SDK 默认) 请求的安全令牌生存期;服务器可以修订
ChannelLifetimeMilliseconds null(SDK 默认) 底层通道生存期配额,含义由传输实现决定,不等同于令牌有效期
ReconnectPeriodMilliseconds 10000 断线重连间隔,0 禁用自动重连
MaxMessageSizeBytes 4194304 单报文上限,大批量读写可调大
DefaultSubscriptionPublishingIntervalMilliseconds 1000 原生订阅默认发布间隔(可被单次订阅参数覆盖)
DefaultSubscriptionKeepAliveCount 10 原生订阅默认心跳计数(× 发布间隔 = 心跳周期)
DefaultSubscriptionLifetimeCount 30 原生订阅默认生存期计数(× 发布间隔 = 订阅存活时长),须 ≥ 3 × KeepAlive
DefaultSubscriptionMaxNotificationsPerPublish 0 原生订阅默认单次发布最大通知数,0 不限
DefaultSubscriptionPriority 0 原生订阅默认优先级 0-255

订阅模式:原生推送 vs 客户端轮询

订阅时通过 OpcUaSubscriptionOptions.Mode 指定策略,同一个客户端上两种模式可以并存:

// 原生订阅(默认):服务器推送,网络流量最小,适合对及时性要求不高的节点
await client.SubscribeAsync("slow", tags, OnChange);

// 轮询订阅:客户端以短间隔批量读取、变化即通知
// 适合小 PLC 等服务器端扫描周期长、原生推送不及时的信号
await client.SubscribeAsync("fast", criticalTags, OnChange, new OpcUaSubscriptionOptions
{
    Mode = OpcUaSubscriptionMode.Polling,
    PollingIntervalMilliseconds = 50,   // 实际最小 10ms,应大于一次读取往返耗时
    NotifyOnFirstScan = false,          // 首扫是否立即通知当前值(默认仅变化时通知)
});
原生(Native) 轮询(Polling)
及时性 受服务器采样/发布间隔限制 由客户端轮询间隔决定
网络流量 仅变更时报文 每间隔一次批量读(N 个节点一个报文)
重连恢复 重新创建受管理的订阅与监视项 继续读取当前会话
首次通知 服务器默认推送初始值 默认不通知,NotifyOnFirstScan 可开启
变化判定 服务器端(含死区等过滤器) 值或状态码变化(时间戳不参与比较)

轮询的节奏是"读完再等待":实际周期 = 轮询间隔 + 一轮读取耗时;未连接时自动跳过本轮,连接恢复后继续。

订阅的自定义与增量构建

订阅时长参数支持两级配置:客户端级默认(实例化 OpcUaClient 时一次性设置,对所有原生订阅生效)与单次订阅覆盖(OpcUaSubscriptionOptions,未设置的项继续继承客户端默认):

// 客户端级:一次性设置,之后所有原生订阅自动使用
OpcUaClient = new OpcUaClient(new OpcUaClientOptions
{
    ServerUrl = "opc.tcp://192.168.1.88:4840",
    ApplicationName = "TeamAAS-VM",
    DefaultSubscriptionPublishingIntervalMilliseconds = 1000,
    DefaultSubscriptionKeepAliveCount = 10,
    DefaultSubscriptionLifetimeCount = 300,
});

// 直接订阅——不传参数,时长自动继承上面的默认
await client.SubscribeAsync("main", tags, OnChange);

// 个别订阅覆盖:只改发布间隔,其余仍继承客户端默认
await client.SubscribeAsync("fast", tags, OnChange,
    new OpcUaSubscriptionOptions { PublishingIntervalMilliseconds = 100 });

// 兜底回调:在一等属性之后执行,可覆盖任意设置(自定义过滤器等高级用法)
await client.SubscribeAsync("evt", tags, OnChange,
    new OpcUaSubscriptionOptions { ConfigureSubscription = sub => sub.Priority = 1 });

// 增量构建
var sub = await client.CreateSubscriptionAsync("main");
sub.AddMonitoredItem("ns=2;s=T1", OnChange, samplingIntervalMilliseconds: 200);
sub.AddMonitoredItems(new[] { "ns=2;s=T2", "ns=2;s=T3" }, OnChange);
await sub.ApplyChangesAsync();          // 原生模式统一下发;轮询模式立即生效,无需调用
// ...
await client.RemoveSubscriptionAsync("main");

// 高级用法:拿到底层对象(仅原生订阅;轮询订阅没有底层 Subscription)
Subscription raw = ((IOpcUaNativeSubscription)sub).Subscription;
ISession session = client.Session;

OpcUaSubscriptionOptions 全部属性(时长类默认 null = 继承 OpcUaClientOptions.DefaultSubscription*):

属性 默认值 说明
Mode Native 订阅模式(原生推送 / 客户端轮询)
PublishingIntervalMilliseconds null(客户端默认 1000) 发布间隔,仅原生模式
KeepAliveCount null(客户端默认 10) 心跳计数,心跳周期 = 计数 × 发布间隔,仅原生模式
LifetimeCount null(客户端默认 30) 生存期计数,订阅存活 = 计数 × 发布间隔,仅原生模式;显式值与继承值组合后须 ≥ 3 × KeepAliveCount(规范要求,不满足抛 ArgumentException)
MaxNotificationsPerPublish null(客户端默认 0) 单次发布最大通知数,0 不限
Priority null(客户端默认 0) 订阅优先级 0-255,服务器拥塞时高优先级先发
PollingIntervalMilliseconds 100 轮询间隔,仅轮询模式(实际最小 10ms)
NotifyOnFirstScan false 轮询首扫是否立即通知当前值
ConfigureSubscription null 底层 Subscription 兜底自定义回调,最后执行可覆盖一切

服务器可能修订请求值,实际生效值可随时查询(重连重建后自动更新):

var native = (IOpcUaNativeSubscription)sub;
native.CurrentPublishingIntervalMilliseconds;  // 服务器实际生效的发布间隔
native.CurrentKeepAliveCount;                  // 实际 KeepAlive 计数
native.CurrentLifetimeCount;                   // 实际生存期计数

client.SessionTimeoutMilliseconds;             // 会话协商生效的超时(毫秒),未连接时为 null

断点调试与超时

常规的 Visual Studio 托管断点会暂停客户端进程,心跳、Publish 请求和安全令牌续期也随之暂停,服务器仍继续计时。仅调大会话超时和订阅生存期,无法覆盖所有超时:

机制 默认值/判断方式 控制方式
订阅生存期(服务器) 请求值为 LifetimeCount × 发布间隔 = 30 秒;无可用 Publish 请求时开始累计 DefaultSubscriptionLifetimeCount,可被单次订阅覆盖
会话超时(服务器) 请求值为 60 秒,无服务请求时过期 SessionTimeoutMilliseconds
会话心跳检测(客户端) 当前 SDK 按心跳间隔 + 约 1 秒保护时间判断,默认约 6 秒无响应即可报告 BadNoCommunication KeepAliveIntervalMilliseconds,与会话超时独立
服务调用(客户端/请求) 120 秒,恢复时挂起请求可能已超时;SDK 心跳读另有约 2 × 心跳间隔的期限 OperationTimeoutMilliseconds 及心跳间隔;业务 CancellationToken 仍可提前取消
安全令牌(通道层) SDK 默认,服务器可修订;断点暂停也会阻止续期 SecurityTokenLifetimeMilliseconds

本次修复将应用配额在创建传输通道之前传入 EndpointConfiguration。此前只设置 ApplicationConfiguration.TransportQuotas,会话公开的端点配置随后虽已更新,已创建通道的调用超时仍可能保留 SDK 默认 120 秒;MaxMessageSizeBytes 等配额也一起修正了传递路径。

以下为预计单次暂停不超过约 5 分钟的调试示例,留出恢复和下一轮心跳的余量。所有设置在连接前指定:

var debugging = Debugger.IsAttached;
OpcUaClient = new OpcUaClient(new OpcUaClientOptions
{
    ServerUrl = EndpointUrl,
    ApplicationName = "TeamAAS-VM",
    Identity = OpcUaIdentity.Anonymous(),

    SessionTimeoutMilliseconds = debugging ? 1_200_000 : 60_000,
    OperationTimeoutMilliseconds = debugging ? 1_200_000 : 120_000,
    KeepAliveIntervalMilliseconds = debugging ? 600_000 : 5_000,
    DefaultSubscriptionPublishingIntervalMilliseconds = 1000,
    DefaultSubscriptionLifetimeCount = debugging ? 1200u : 30u,
    SecurityTokenLifetimeMilliseconds = debugging ? 3_600_000 : (int?)null,
});

await OpcUaClient.ConnectAsync();
// 确认服务器是否接受了足够长的会话超时
Console.WriteLine($"实际会话超时: {OpcUaClient.SessionTimeoutMilliseconds}ms");
Console.WriteLine($"实际通道调用超时: {OpcUaClient.Session!.OperationTimeout}ms");

// 原生订阅继承客户端默认,单次订阅显式指定的参数仍会覆盖默认
await OpcUaClient.SubscribeAsync("main", tags, OnChange);

会话心跳间隔须小于服务器实际协商的会话超时,否则没有订阅或业务流量时,下一次心跳之前会话就可能被服务器关闭。不要只把心跳调大,也不要将 KeepAliveIntervalMilliseconds = 0 当作保持连接的办法:它会停止定时心跳及基于心跳的断线检测。

即使调大了会话超时,断点暂停期间传输通道仍可能关闭。此前自动重连只由心跳异常触发,心跳设为 10 分钟时,业务读写已经连续报 BadSecureChannelClosed / BadConnectionClosed,也可能一直等到下一轮心跳才恢复。本次补充了服务调用的恢复入口:读写、浏览、属性、历史数据、方法调用及轮询读取抛出明确的通道/会话关闭异常时,直接启动自动重连,并继续遵守 ReconnectPeriodMilliseconds(0 仍禁用)。原调用保留原异常,库不自动重试写入或方法调用,避免重复执行;节点值 Bad、类型错误、一般调用超时和调用方取消不会走此恢复入口。没有业务请求时,故障检测仍受心跳间隔限制。

原生订阅重建期间,新旧订阅及其监视项会短暂并存,服务器需留出相应配额。替代订阅创建失败时清理替代对象、保留旧对象,并通过状态事件报告重建失败;旧对象是否仍有效取决于服务器状态。

示例保留了长心跳供对比测试,并输出“调试器已附加”和“放大超时配置”两个独立状态。SDK 日志、异常堆栈和内层 ServiceResult 可用于继续定位通道关闭原因。日志中的 BadSecureChannelClosed 本身不足以认定服务器主动关闭、令牌到期或某个 30 秒计时器触发;必要时结合服务器日志或网络抓包判断。

已按当前依赖 OPC Foundation 2.0.1 的源码核对原生参数:

原生 SDK 参数/状态 实际作用与排查要点
TransportQuotas.OperationTimeout → EndpointConfiguration.OperationTimeout → Session.OperationTimeout 普通服务调用的客户端等待期限;本库已修复创建通道前的传递路径
ClientBase.DefaultTimeoutHint / RequestHeader.TimeoutHint 默认 Hint 为 0 时,普通请求使用 OperationTimeout;单次请求显式指定时优先使用该 Hint。这是发送给服务器的请求期限提示,不能延长安全令牌或会话
Session.KeepAliveInterval 当前 SDK 的心跳读显式使用约 2 × KeepAliveInterval 的 Hint 和本地取消期限;它不只由普通调用超时控制
TransportQuotas.SecurityTokenLifetime 发送的是 RequestedLifetime;处理 OpenSecureChannel 响应时,SDK 以 SecurityToken.RevisedLifetime 更新实际令牌生存期,并按实际生存期约 75% 加抖动安排续期
ISecureChannel.CurrentToken.Lifetime 当前令牌实际生存期;标准 opc.tcp 的 Session.TransportChannel 可转换为 ISecureChannel 读取。示例现在在连接及重连完成后直接输出此值,而不仅是请求值
TransportQuotas.ChannelLifetime SDK 默认 30000ms 的注释是服务器对故障通道的保留时间;在当前 opc.tcp 客户端路径中,它不是普通连接的空闲计时器。客户端配置它不能修改 PLC 的通道管理规则

目前没有在这条默认客户端路径中发现“附加调试器后暂停所有协议期限”的配置开关;计时使用真实经过时间,服务器也继续计时。debugging 是示例用于选择参数的变量,不会让原生 SDK 自动进入特殊调试模式。下一轮应先比较“请求令牌生存期”和“服务器签发令牌生存期”,再结合最早的通道关闭原因验证,不能仅凭约 30 秒的暂停长度认定命中了 ChannelLifetime。

若内层原因是 Remote side closed the connection.,说明 SDK 的 Socket 读取观察到了连接结束;这条日志并未给出远端关闭的具体策略。示例支持无断点的业务空闲对照:使用 debugging=true,运行 dotnet run --project samples/OpcUaHelper.Samples -- opc.tcp://192.168.1.88:4840 --idle-test,全程不打断点。成功读取 5 次后只暂停业务读取 30 秒,SDK 后台线程照常运行,暂停前后打印通道/令牌信息。若此时也被关闭,优先查服务器或链路的空闲连接策略;若只有冻结整个进程时才出现,继续结合断点位置与抓包,检查暂停时是否有未完成的传输。此对照只用于缩小范围,不能单独确定某个 PLC 配置项。

源码依据:请求头期限、实际令牌与续期、SDK 默认常量与注释。

服务器可以限制会话、订阅和令牌的请求值。通过 SessionTimeoutMilliseconds 以及原生订阅的 CurrentPublishingIntervalMilliseconds × CurrentLifetimeCount 确认协商结果;端点配置中的 SecurityTokenLifetime 是请求值,不代表服务器签发令牌的实际有效期。ChannelLifetimeMilliseconds 只是传给 SDK 的另一项配额,不能延长服务器的故障通道保留时间。调大超时不会消除已经过期的请求、令牌或会话,也无法保证在任意位置断点暂停后完全没有 Bad 状态。

排查时请保留完整状态码、堆栈和暂停时长:BadNoCommunication 可来自本地心跳检测;BadTimeout / BadRequestTimeout 需结合堆栈区分调用与订阅过期;BadSessionIdInvalid / BadSessionClosed 表示会话失效;BadSecureChannelClosed / BadSecureChannelIdInvalid / BadSecureChannelTokenUnknown 应检查通道和令牌。恢复后用连接状态事件和订阅回调确认重连结果。Visual Studio 的“引发时中断”也可能停在 SDK 正在处理的异常处,不能仅凭该提示判断最终恢复失败。

上述大超时仅用于调试,会延迟真实故障检测。需要业务长时间断点而 OPC 通讯持续运行时,将 OPC 客户端放到不附加调试器的独立进程,通过 IPC 与业务进程通讯。

依据:OPC UA 会话创建与协商超时、订阅生存期、本项目使用的 SDK 会话心跳实现。

订阅管理与重复订阅防护

订阅在客户端内以唯一 key 登记,可通过以下 API 查询与管理:

client.SubscriptionCount;                     // 当前订阅数量(含原生与轮询)
client.GetSubscription("main");               // 按键获取订阅,不存在时返回 null
client.GetSubscriptions();                    // 全部订阅快照(键 → 订阅)
client.IsNodeSubscribed("ns=2;s=T1");         // 该节点是否已被任一订阅监视(全局,含等价写法判定)
sub.GetMonitoredTags();                       // 该订阅正在监视的节点标识快照
await client.RemoveSubscriptionAsync("main"); // 移除单个订阅
await client.RemoveAllSubscriptionsAsync();   // 移除全部订阅

重复订阅在三个层面受到防护:

  1. 订阅键:用已存在的 key 再次 SubscribeAsync/CreateSubscriptionAsync 会抛出 ArgumentException;并发注册同名键时恰好一个成功,败者自动清理(不覆盖、不泄漏服务器端订阅)。
  2. 订阅内节点:AddMonitoredItem(s) 按 NodeId 去重(i=2258 与 ns=0;i=2258 视为同一节点),同一订阅内已监视的节点静默跳过,以首次添加时的配置与回调为准。
  3. 跨订阅节点:同一个客户端内,一个节点只允许被一个订阅监视——无论两个订阅是不同键的原生订阅,还是原生与轮询混用,向第二个订阅添加已被占用的节点都会抛出 ArgumentException(指出冲突节点与持有它的订阅键),且整批全部不添加;移除持有方订阅后节点即可被其他订阅接管。

注意:通过 IOpcUaNativeSubscription.Subscription 底层逃生口直接向官方栈添加的监视项不经过上述去重。

重连行为

  1. 心跳发现服务中断 → 状态变为 Reconnecting(ReconnectPeriodMilliseconds 为 0 时直接变为 Disconnected)
  2. 官方 SessionReconnectHandler 周期性重连;恢复会话后,删除旧的受管理原生订阅并按原始定义重新创建(回调保持不变)
  3. 完成恢复后 → Connected;轮询订阅继续读取新会话。重建会触发新的首值通知,底层 Subscription/MonitoredItem 对象会变化
  4. 服务器长期下线时持续重试,直到连接恢复或调用 DisconnectAsync

提示:事件回调可能来自后台线程,WinForms/WPF 中需要 Invoke/Dispatcher 切换到 UI 线程。

构建 / 测试 / 打包

dotnet build OpcUaHelper.sln          # 编译全部(库 + 示例 + 测试)
dotnet test                           # 单元测试 + 集成测试(环境变量 OPCUA_TEST_SERVER 指定服务器,默认 opc.tcp://192.168.1.88:4840,不可达真正报告为跳过)
dotnet test --filter Category=Integration --environment OPCUA_TEST_REQUIRED=1 --environment OPCUA_TEST_SERVER=opc.tcp://192.168.1.88:4840

# 打包(推荐):产出 .nupkg + .snupkg 到 artifacts/packages/local-feed,可直接作为本地包源
powershell -File publish-nuget.ps1                # 可选参数:-Version 2.0.1 -Configuration Release

# 推送到私有 V3 源
dotnet nuget push artifacts\packages\local-feed\OpcUaHelper.Team.2.1.4.nupkg \
     -s http://your-nuget-server/v3/index.json -k YOUR_API_KEY

包内已包含:128×128 图标(OPC UA 官方字标)、内嵌 README(私有源包页直接展示)、 XML 文档注释、snupkg 符号包、MIT 许可与仓库信息。

按需浏览节点树(2.0.2)

var root = client.CreateBrowseTree("i=85"); // 创建根,不发 Browse 请求
var firstLevel = await root.LoadChildrenAsync(cancellationToken);
// 用户展开其中一个节点:
var children = await firstLevel[0].LoadChildrenAsync(cancellationToken);

OpcBrowseNode.Info 保留节点标识、类别、父标识和深度,Parent / Children 提供层级结构。ChildrenLoaded 为 false 表示尚未成功浏览,为 true 且 Children 为空表示确认没有子节点。每次加载只取得直接子节点,自动处理该层的分页;同一节点并发加载复用成功结果,重复展开不再请求。失败、取消可重试。过滤重复引用、当前祖先引用及外部服务器引用,不提前探测每个子节点是否有下级。

树没有 UI 依赖,调用方在 await 后更新界面绑定。连接/重连后创建新树;刷新时也创建新树并取消旧树正在进行的加载。图标和条件编辑的数据类型可在用户选中变量后读取。

全量扫描节点树(独立用途)

BrowseAsync 只返回单层子节点;需要枚举服务器全部变量(做完整点位清单、批量建模)时用 BrowseAllNodesAsync:

var nodes = await client.BrowseAllNodesAsync(
    rootTag: "i=85",          // 扫描根(默认整个 Objects 地址空间)
    maxDepth: 0,              // 0 = 不限层
    progress: p => { /* 每个节点浏览前及扫描完成时回调;UI 刷新应节流 */ });

// 结果特性:
// - 每个元素带 ParentNodeId / Depth,可直接重建整棵树
// - NodeId 自动去重,交叉引用不会死循环
// - 单个分支无权限/受限时仅告警跳过,不影响整体扫描
// - Variable 节点带 TypeDefinitionId,可区分数据变量与属性

大服务器可能有数万节点,扫描耗时与节点数成正比,节点选择界面建议使用上述按需节点树,全量扫描只用于明确需要完整点位清单的场景。

从 OpcUaHelper 1.0 升级至 2.0

  • 官方 Client/Configuration/Core/Types/证书依赖均解析为 2.0.0;日志抽象升至 10.0.12。消费项目如直接引用官方栈或 Microsoft.Extensions.*,需同步检查版本,避免降级冲突。
  • 泛型读写继续接受 CLR 数值、枚举、DateTime、Guid、数组和多维数组。byte[] 保持 ByteString 语义;UA Byte 数组可显式传 ArrayOf。需要 SDK 新类型时也可以读取 DateTimeUtc、Uuid、ByteString 或 ArrayOf。
  • 原始 DataValue、NodeId、StatusCode 等 SDK 类型已变为值类型。订阅回调使用 n.Value.WrappedValue / n.Value.StatusCode,不能再写 n.Value?.Value。日期可用 n.Value.WrappedValue.GetDateTime().ToDateTime();优先采用 ReadNodeAsync 等封装接口。
  • BrowseReferencesAsync 现在返回 IList,替代已移除的 ReferenceDescriptionCollection;原始服务调用使用 ArrayOf 和 Variant。
  • 原来的 OpcUaIdentity.Certificate(X509Certificate2) 改为 CertificateAsync(CertificateIdentifier, ICertificateProvider, ...),通过 IdentityFactory 在配置初始化后创建,见上方示例。
  • 2.0 官方端点筛选不再自动降级。封装层用 AllowInsecureFallback 显式保留 1.x 回退行为并写入日志;设为 false 时无安全端点会报错。
  • 历史读取会自动续页,numValuesPerNode 仍作为总返回上限;达到上限或中途失败时释放续页标识。原生订阅在重连后重新创建,底层逃生口对象须重新获取。

官方 2.0.0 包包含 net48 资产,本项目继续提供 net48 与 net8.0。但其 Microsoft.Extensions.Http.Resilience / Telemetry 等 10.10.0 传递依赖会发出 net48 未支持/未测试警告,未隐藏这些警告。net48 示例已在真实服务器验证连接、日期读取、订阅和断开;这不等于上游所有功能对 Framework 的支持承诺。

测试分为离线单元测试、回环地址上的真实 UA 2.0 服务器测试、现场服务器集成测试。本机服务器验证安全端点、证书用户登录、CLR 值与数组读写、方法调用、历史分页、重连恢复;现场测试验证连接、浏览、读值、订阅和客户端通道中断恢复。设置 OPCUA_TEST_REQUIRED=1 时,服务器连接失败会使测试失败,不会被当作通过。

从旧版(dathlin 2.x)迁移

2.x 1.0
client.ConnectServer(url) await client.ConnectAsync(url)
client.ReadNode<T>(tag) await client.ReadNodeAsync<T>(tag)(Bad 状态码现在抛异常)
client.ReadNodeAsync<T>(tag)(假异步) 同名方法现在是真异步
client.WriteNode(tag, v) await client.WriteNodeAsync(tag, v)
client.WriteNodes(tags, values) → bool await client.WriteNodesAsync(tags, values) → bool[] 逐项结果
client.AddSubscription(key, tag, cb) await client.SubscribeAsync(key, tags, cb)
client.RemoveSubscription(key) await client.RemoveSubscriptionAsync(key)
client.BrowseNodeReference(tag) await client.BrowseReferencesAsync(tag) 或 BrowseAsync(轻量 DTO)
client.ReadHistoryRawDataValues(...) await client.ReadHistoryRawAsync(...)
client.OpcStatusChange 事件 StatusChanged + 新增 ConnectionStateChanged
WinForms 浏览窗体 FormBrowseServer 已移除(后续按需作为独立工具项目提供)

主要破坏性变化:库不再包含任何 WinForms UI;API 全异步;OpcUaClient 构造函数要求传入 OpcUaClientOptions。

注:v1.0 是参考旧版使用习惯的全新实现,未复制旧版代码,因此不再受旧版 LGPL 约束。

目标框架

包资产 消费者
net48 .NET Framework 4.8+(SDK 风格与旧式 csproj 均可)
net8.0 .NET 8 / 9 / 10

直接运行依赖: OPCFoundation.NetStandard.Opc.Ua.Client(2.0.0,传递引入 Configuration/Core/Types)+ Microsoft.Extensions.Logging.Abstractions。

许可

本项目采用 MIT 许可(见 LICENSE)。整个依赖链均为宽松许可,可放心在闭源商业项目中使用:

组件 许可
OpcUaHelper(本库,全新实现) MIT
OPCFoundation.NetStandard.Opc.Ua.* 2.0.0 MIT(以 2.0.0 NuGet 包内 LICENSE.txt 为准)
Microsoft.Extensions.Logging.Abstractions MIT
BitFaster.Caching(官方栈传递依赖) MIT

2.0.1 浏览修正

  • 进度增加 CurrentNodeId、BrowsedNodeCount、PendingNodeCount;浏览叶节点没有发现新节点时也会回调,保留原进度构造函数。
  • 连续 16 页没有引用但仍带续页标识时,抛出明确异常并释放续页资源;少量空页仍正常处理。
  • 只跳过受限或已删除的子节点,根节点失败、超时、通讯中断及分页错误会上报;取消统一为 OperationCanceledException,不会把未完成的扫描返回为成功。
  • maxDepth: 0 扫描 Objects 下全部层级,不是耗时上限。大量数组元素也需逐个 Browse,某些 PLC 会耗时数分钟。应用应让连接完成后在后台扫描、观察已浏览数和待浏览数,避免把扫描完成作为设备初始化的前置条件。此修正保留全量节点,不通过截断深度省略节点。

No packages depend on OpcUaHelper.Team.

2.1.4:客户端应用证书初始化推迟到端点选择之后,仅当所选端点使用签名/加密(或配置 IdentityFactory)时才创建并要求证书;SecurityPolicy None 明文端点连接完全不生成证书文件,修复无证书无密码连接也被强制要求客户端证书的问题。AutoAcceptUntrustedCertificates 警告仅在安全连接时输出。文档补充 UseSecurity 默认选择安全端点并要求服务器信任自动生成客户端证书的说明,示例改为显式演示无证书明文连接;新增明文/安全端点证书行为回归测试。

Version Downloads Last updated
2.1.4 11 10/28/2026
2.1.3 7 10/21/2026
2.1.2 7 10/19/2026
2.1.1 7 10/17/2026
2.1.0 8 10/08/2026
2.0.2 5 10/08/2026