OpcUaHelper.Team 2.1.1

OpcUaHelper.Team

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

特性

  • 开箱即连:一次 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.0.2 -s http://your-nuget-server/v3/index.json

或 PackageReference:

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

快速上手

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;外部提供者须保持可用直到客户端释放。

常用配置项(OpcUaClientOptions)

属性 默认值 说明
ServerUrl opc.tcp://localhost:4840 服务器地址
ApplicationName OpcUaHelper 应用名(证书 CN、pki 目录名、会话名)
UseSecurity true 优先选择加密端点
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 禁用
ReconnectPeriodMilliseconds 10000 断线重连间隔,0 禁用自动重连
MaxMessageSizeBytes 4194304 单报文上限,大批量读写可调大

订阅模式:原生推送 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 可开启
变化判定 服务器端(含死区等过滤器) 值或状态码变化(时间戳不参与比较)

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

订阅的自定义与增量构建

// 方式一:自定义原生订阅参数(发布间隔 100ms)
await client.SubscribeAsync("fast", tags, OnChange,
    new OpcUaSubscriptionOptions { ConfigureSubscription = sub => sub.PublishingInterval = 100 });

// 方式二:增量构建
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;

订阅管理与重复订阅防护

订阅在客户端内以唯一 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.0.2.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.0:订阅管理增强。新增 GetSubscription/GetSubscriptions/IsNodeSubscribed/GetMonitoredTags 查询 API;同名订阅键并发注册改为原子登记(TryAdd),败者自动清理不再覆盖或泄漏;同一客户端内节点全局去重——订阅内按 NodeId 静默去重(等价写法视为同一节点),跨订阅键、跨原生/轮询策略重复添加同一节点抛出 ArgumentException 且整批拒绝,移除持有方订阅后节点可被其他订阅接管。

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