OpcUaHelper.Team 2.0.2
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;
重连行为
- 心跳发现服务中断 → 状态变为
Reconnecting(ReconnectPeriodMilliseconds为 0 时直接变为Disconnected) - 官方
SessionReconnectHandler周期性重连;恢复会话后,删除旧的受管理原生订阅并按原始定义重新创建(回调保持不变) - 完成恢复后 →
Connected;轮询订阅继续读取新会话。重建会触发新的首值通知,底层 Subscription/MonitoredItem 对象会变化 - 服务器长期下线时持续重试,直到连接恢复或调用
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.0.2:包 ID 和名称调整为 OpcUaHelper.Team,程序集和命名空间保持 OpcUaHelper。新增 CreateBrowseTree / OpcBrowseNode,按展开加载直接子节点,分页、缓存、并发复用及循环引用过滤由库管理;加载失败或取消可重试,失效连接的树需重建。保留单层和全量浏览 API。
.NET Framework 4.8
- Microsoft.Extensions.Logging.Abstractions (>= 10.0.12)
- OPCFoundation.NetStandard.Opc.Ua.Client (>= 2.0.0)
.NET 8.0
- Microsoft.Extensions.Logging.Abstractions (>= 10.0.12)
- OPCFoundation.NetStandard.Opc.Ua.Client (>= 2.0.0)