From b0bc6afbf749942f188fd7aa6b8f7259844ba2ab Mon Sep 17 00:00:00 2001 From: Keeleycenc <844123814@qq.com> Date: Tue, 14 Jul 2026 21:52:33 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20=E6=96=B0=E5=A2=9E=20OPC=20UA=20Cli?= =?UTF-8?q?ent=20=E9=80=9A=E4=BF=A1=E5=8D=8F=E8=AE=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 OPC UA 客户端,复用统一的 IProtocol 生命周期与 IProtocolManager 实例管理, 但保持自己的“节点、会话、订阅、安全”模型,不抽象成地址读写接口,公共 API 也不泄漏 官方 SDK 类型。 主要内容: - 新增 IOpcUaProtocol / IOpcUaCommunicationConfig / IOpcUaSubscription / IOpcUaCredentialProvider 接口,以及 OpcUaProtocolClient 实现 - 轻量模型:OpcUaNodeId、OpcUaValue、OpcUaWriteResult、OpcUaBrowseNode、 OpcUaMethodResult、OpcUaMonitoredItem、OpcUaSubscriptionOptions 及相关枚举 - 单/批量读写(批量返回逐项状态)、浏览、方法调用、数据变化订阅、KeepAlive 与自动重连 - 安全:三种身份(匿名/用户名密码/客户端证书)与 None/Sign/SignAndEncrypt, 严格证书校验;密码不进入实例键/日志/异常,经 CredentialKey + CredentialProvider 解析 - 配置在创建时克隆为快照;订阅通知经内部队列派发,不阻塞 SDK Publish 流程 - 接入 ProtocolManager 与 GetInstanceKey;依赖 OPC Foundation 官方客户端并锁定统一版本 - 为 net481 补充 IsExternalInit 占位类型以支持 record/init - 补充根目录与 docs 文档,以及单元测试与集成测试 Co-Authored-By: Claude Fable 5 --- README.md | 2 +- .../MAS.Communication.OpcUaProtocol.md | 196 +++++ docs/content/toc.yml | 4 +- docs/index.md | 1 + .../CommunicationConfigExtensions.cs | 21 + src/Helpers/StructBinaryHelper.cs | 4 - src/MAS.Communication.csproj | 17 +- src/Managers/ProtocolManager.cs | 2 + src/Models/CommProtocol.cs | 7 +- .../EventArgs/OpcUaDataChangedEventArgs.cs | 28 + .../Exceptions/OpcUaCertificateException.cs | 30 + .../Exceptions/OpcUaServiceException.cs | 59 ++ .../Helpers/OpcUaCertificateManager.cs | 123 ++++ .../Helpers/OpcUaEndpointSelector.cs | 92 +++ .../Helpers/OpcUaValueConverter.cs | 95 +++ .../Interfaces/IOpcUaCommunicationConfig.cs | 120 ++++ .../Interfaces/IOpcUaCredentialProvider.cs | 31 + .../Interfaces/IOpcUaProtocol.cs | 127 ++++ .../Interfaces/IOpcUaSubscription.cs | 64 ++ src/OpcUaProtocol/Models/OpcUaBrowseNode.cs | 26 + src/OpcUaProtocol/Models/OpcUaIdentityType.cs | 31 + src/OpcUaProtocol/Models/OpcUaMethodResult.cs | 30 + .../Models/OpcUaMonitoredItem.cs | 41 ++ src/OpcUaProtocol/Models/OpcUaNodeClass.cs | 62 ++ src/OpcUaProtocol/Models/OpcUaNodeId.cs | 44 ++ src/OpcUaProtocol/Models/OpcUaReadItem.cs | 17 + src/OpcUaProtocol/Models/OpcUaSecurityMode.cs | 32 + .../Models/OpcUaSubscriptionOptions.cs | 41 ++ .../Models/OpcUaUserCredential.cs | 22 + src/OpcUaProtocol/Models/OpcUaValue.cs | 40 ++ src/OpcUaProtocol/Models/OpcUaWriteItem.cs | 18 + src/OpcUaProtocol/Models/OpcUaWriteResult.cs | 29 + src/OpcUaProtocol/OpcUaProtocolClient.cs | 672 ++++++++++++++++++ src/OpcUaProtocol/OpcUaSubscription.cs | 238 +++++++ src/Polyfills/CompilerPolyfills.cs | 21 + src/README.md | 6 +- tests/OpcUaProtocol/Models/OpcUaTestConfig.cs | 78 ++ tests/OpcUaProtocol/OpcUaConnectionTest.cs | 150 ++++ tests/OpcUaProtocol/OpcUaInstanceKeyTest.cs | 85 +++ tests/OpcUaProtocol/OpcUaManagerTest.cs | 92 +++ tests/OpcUaProtocol/OpcUaModelsTest.cs | 96 +++ .../OpcUaProtocol/OpcUaValueConverterTest.cs | 91 +++ 42 files changed, 2972 insertions(+), 13 deletions(-) create mode 100644 docs/content/MAS.Communication.OpcUaProtocol.md create mode 100644 src/OpcUaProtocol/EventArgs/OpcUaDataChangedEventArgs.cs create mode 100644 src/OpcUaProtocol/Exceptions/OpcUaCertificateException.cs create mode 100644 src/OpcUaProtocol/Exceptions/OpcUaServiceException.cs create mode 100644 src/OpcUaProtocol/Helpers/OpcUaCertificateManager.cs create mode 100644 src/OpcUaProtocol/Helpers/OpcUaEndpointSelector.cs create mode 100644 src/OpcUaProtocol/Helpers/OpcUaValueConverter.cs create mode 100644 src/OpcUaProtocol/Interfaces/IOpcUaCommunicationConfig.cs create mode 100644 src/OpcUaProtocol/Interfaces/IOpcUaCredentialProvider.cs create mode 100644 src/OpcUaProtocol/Interfaces/IOpcUaProtocol.cs create mode 100644 src/OpcUaProtocol/Interfaces/IOpcUaSubscription.cs create mode 100644 src/OpcUaProtocol/Models/OpcUaBrowseNode.cs create mode 100644 src/OpcUaProtocol/Models/OpcUaIdentityType.cs create mode 100644 src/OpcUaProtocol/Models/OpcUaMethodResult.cs create mode 100644 src/OpcUaProtocol/Models/OpcUaMonitoredItem.cs create mode 100644 src/OpcUaProtocol/Models/OpcUaNodeClass.cs create mode 100644 src/OpcUaProtocol/Models/OpcUaNodeId.cs create mode 100644 src/OpcUaProtocol/Models/OpcUaReadItem.cs create mode 100644 src/OpcUaProtocol/Models/OpcUaSecurityMode.cs create mode 100644 src/OpcUaProtocol/Models/OpcUaSubscriptionOptions.cs create mode 100644 src/OpcUaProtocol/Models/OpcUaUserCredential.cs create mode 100644 src/OpcUaProtocol/Models/OpcUaValue.cs create mode 100644 src/OpcUaProtocol/Models/OpcUaWriteItem.cs create mode 100644 src/OpcUaProtocol/Models/OpcUaWriteResult.cs create mode 100644 src/OpcUaProtocol/OpcUaProtocolClient.cs create mode 100644 src/OpcUaProtocol/OpcUaSubscription.cs create mode 100644 src/Polyfills/CompilerPolyfills.cs create mode 100644 tests/OpcUaProtocol/Models/OpcUaTestConfig.cs create mode 100644 tests/OpcUaProtocol/OpcUaConnectionTest.cs create mode 100644 tests/OpcUaProtocol/OpcUaInstanceKeyTest.cs create mode 100644 tests/OpcUaProtocol/OpcUaManagerTest.cs create mode 100644 tests/OpcUaProtocol/OpcUaModelsTest.cs create mode 100644 tests/OpcUaProtocol/OpcUaValueConverterTest.cs diff --git a/README.md b/README.md index bc1ee2e..71343cb 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ - [x] Modbus TCP([Sockets](https://github.com/dotnet/runtime/tree/main/src/libraries/System.Net.Sockets)) - [x] MC Protocol([Sockets](https://github.com/dotnet/runtime/tree/main/src/libraries/System.Net.Sockets)) - [x] S7 Protocol([S7.Net](https://github.com/S7NetPlus/s7netplus)) -- [ ] OPC UA +- [x] OPC UA(Client,[UA-.NETStandard](https://github.com/OPCFoundation/UA-.NETStandard)) - [ ] EtherNet/IP - [ ] MQTT - [ ] CANopen diff --git a/docs/content/MAS.Communication.OpcUaProtocol.md b/docs/content/MAS.Communication.OpcUaProtocol.md new file mode 100644 index 0000000..4361f44 --- /dev/null +++ b/docs/content/MAS.Communication.OpcUaProtocol.md @@ -0,0 +1,196 @@ +# MAS.Communication.OpcUaProtocol + +OPC UA 复用统一的 `IProtocol` 生命周期与 `IProtocolManager` 实例管理,但保持自己的“节点、会话、订阅、安全”模型,不会被抽象成地址读写接口。业务能力通过专属接口 `IOpcUaProtocol` 暴露,公共 API 只使用库自有的轻量模型(如 `OpcUaNodeId`、`OpcUaValue`),不会泄漏官方 SDK 类型。 + +> 底层基于 OPC Foundation 官方客户端 [UA-.NETStandard](https://github.com/OPCFoundation/UA-.NETStandard),`Core`/`Client`/`Configuration` 三个包锁定统一版本。 + +## 通信配置 + +调用方需实现 `IOpcUaCommunicationConfig`(继承自 `ICommunicationConfig`)。 + +| 字段 | 说明 | +| ---- | ---- | +| `EndpointUrl` | 连接主键,例如 `opc.tcp://192.168.1.10:4840/Server` | +| `ApplicationName` / `ApplicationUri` | 客户端应用名称与 URI | +| `SecurityMode` / `SecurityPolicyUri` | 参与端点选择的安全模式与安全策略 | +| `IdentityType` / `UserName` / `CredentialKey` / `ClientCertificateThumbprint` | 身份类型及对应参数 | +| `CredentialProvider` | 凭据提供者;用于按 `CredentialKey` 解析密码,避免密码进入配置 | +| `CertificateStorePath` | PKI 证书存储根路径 | +| `AutoAcceptUntrustedCertificates` | 是否自动接受不受信任的服务端证书(默认 `false`) | +| `SessionTimeout` / `KeepAliveInterval` / `ReconnectDelay` | 会话与保活/重连参数 | +| `UseEndpointDiscovery` | 是否在找不到精确匹配端点时回退到端点发现 | + +> **安全提示**:密码不会进入实例键、日志或异常信息。推荐通过 `CredentialKey` + `IOpcUaCredentialProvider` 在建立会话时按需解析。`AutoAcceptUntrustedCertificates` 默认必须为 `false`,仅建议在受控开发环境临时开启。 +> +> 配置会在客户端创建时被克隆为快照。请确保 `Clone` 实现完整复制所有字段(包括 `CredentialProvider` 引用),否则实例可能无法从管理器缓存中正确移除。 + +## 使用示例 + +以下示例演示如何通过 `IProtocolManager` 获取 `IOpcUaProtocol` 实例,并完成连接、读写、浏览、方法调用与订阅。 + +```csharp +using MAS.Communication; +using MAS.Communication.OpcUaProtocol; + +// 一个最小的配置实现 +public sealed class OpcUaConfig : IOpcUaCommunicationConfig { + public string ProtocolName => "OpcUa"; + + public string Ip { get; set; } = "192.168.1.10"; + public int MaxRetries { get; set; } = 3; + public int ReadTimeout { get; set; } = 5000; + public int WriteTimeout { get; set; } = 5000; + + public string EndpointUrl { get; set; } = "opc.tcp://192.168.1.10:4840/Server"; + public string ApplicationName { get; set; } = "MAS.Communication.Client"; + public string ApplicationUri { get; set; } = "urn:localhost:MAS:Communication:Client"; + + public OpcUaSecurityMode SecurityMode { get; set; } = OpcUaSecurityMode.SignAndEncrypt; + public string SecurityPolicyUri { get; set; } = "http://opcfoundation.org/UA/SecurityPolicy#Basic256Sha256"; + + public OpcUaIdentityType IdentityType { get; set; } = OpcUaIdentityType.Anonymous; + public string? UserName { get; set; } + public string? CredentialKey { get; set; } + public IOpcUaCredentialProvider? CredentialProvider { get; set; } + + public string? ClientCertificateThumbprint { get; set; } + public string CertificateStorePath { get; set; } = "%LocalApplicationData%/MAS.Communication/pki"; + + public bool AutoAcceptUntrustedCertificates { get; set; } + + public int SessionTimeout { get; set; } = 60000; + public int KeepAliveInterval { get; set; } = 5000; + public int ReconnectDelay { get; set; } = 1000; + public bool UseEndpointDiscovery { get; set; } = true; + + public T Clone() where T : ICommunicationConfig => (T)Clone(); + + public object Clone() => new OpcUaConfig { + Ip = Ip, MaxRetries = MaxRetries, ReadTimeout = ReadTimeout, WriteTimeout = WriteTimeout, + EndpointUrl = EndpointUrl, ApplicationName = ApplicationName, ApplicationUri = ApplicationUri, + SecurityMode = SecurityMode, SecurityPolicyUri = SecurityPolicyUri, + IdentityType = IdentityType, UserName = UserName, CredentialKey = CredentialKey, + CredentialProvider = CredentialProvider, ClientCertificateThumbprint = ClientCertificateThumbprint, + CertificateStorePath = CertificateStorePath, AutoAcceptUntrustedCertificates = AutoAcceptUntrustedCertificates, + SessionTimeout = SessionTimeout, KeepAliveInterval = KeepAliveInterval, + ReconnectDelay = ReconnectDelay, UseEndpointDiscovery = UseEndpointDiscovery + }; +} + +public sealed class MainViewModel { + private readonly IProtocolManager _protocolManager; + + public MainViewModel(IProtocolManager protocolManager) { + _protocolManager = protocolManager; + } + + public async Task RunAsync() { + // 1. 获取强类型协议实例 + IOpcUaProtocol opcua = _protocolManager.GetOrCreate(new OpcUaConfig()); + + // 2. 建立连接(初始化证书、选择安全端点、校验服务端证书、建立会话、启动 KeepAlive 与重连) + await opcua.ConnectAsync(); + + // ========================= + // 示例 1:单节点读写 + // ========================= + + OpcUaValue value = await opcua.ReadAsync(new OpcUaNodeId("ns=2;s=Machine.Speed")); + if (value.IsGood) { + Console.WriteLine($"Speed = {value.Value},SourceTimestamp = {value.SourceTimestamp}"); + } + + double speed = await opcua.ReadAsync(new OpcUaNodeId("ns=2;s=Machine.Speed")); + + await opcua.WriteAsync(new OpcUaNodeId("ns=2;s=Machine.Speed"), 1500.0); + + // ========================= + // 示例 2:批量读写(部分成功、部分失败) + // ========================= + + IReadOnlyList readResults = await opcua.ReadAsync([ + new OpcUaReadItem(new OpcUaNodeId("ns=2;s=Machine.Speed")), + new OpcUaReadItem(new OpcUaNodeId("ns=2;s=Machine.Temp")), + ]); + + IReadOnlyList writeResults = await opcua.WriteAsync([ + new OpcUaWriteItem(new OpcUaNodeId("ns=2;s=Machine.Speed"), 1500.0), + new OpcUaWriteItem(new OpcUaNodeId("ns=2;s=Machine.Mode"), 1), + ]); + + foreach (OpcUaWriteResult result in writeResults) { + Console.WriteLine($"{result.NodeId}: {(result.IsGood ? "OK" : "FAILED")}"); + } + + // ========================= + // 示例 3:浏览与方法调用 + // ========================= + + IReadOnlyList children = await opcua.BrowseAsync(); + foreach (OpcUaBrowseNode node in children) { + Console.WriteLine($"{node.DisplayName} ({node.NodeClass}) -> {node.NodeId}"); + } + + OpcUaMethodResult methodResult = await opcua.CallMethodAsync( + objectNodeId: new OpcUaNodeId("ns=2;s=Machine"), + methodNodeId: new OpcUaNodeId("ns=2;s=Machine.Reset"), + inputArguments: [true]); + + // ========================= + // 示例 4:数据变化订阅 + // ========================= + + IOpcUaSubscription subscription = await opcua.SubscribeAsync( + items: [ + new OpcUaMonitoredItem(new OpcUaNodeId("ns=2;s=Machine.Speed")) { + SamplingInterval = 500, + QueueSize = 1, + DiscardOldest = true + } + ], + options: new OpcUaSubscriptionOptions { + PublishingInterval = 500, + KeepAliveCount = 10, + LifetimeCount = 30, + PublishingEnabled = true + }); + + // 事件在内部派发线程触发,不会阻塞 SDK 的 Publish 流程 + subscription.DataChanged += (_, e) => { + Console.WriteLine($"{e.Value.NodeId}: {e.Value.Value} @ {e.Value.SourceTimestamp}"); + }; + + // 动态增删监控项 + await subscription.AddItemsAsync([new OpcUaMonitoredItem(new OpcUaNodeId("ns=2;s=Machine.Temp"))]); + await subscription.RemoveItemsAsync([new OpcUaNodeId("ns=2;s=Machine.Temp")]); + + // 释放订阅的服务端资源 + await subscription.DeleteAsync(); + + // 3. 关闭连接(对象仍可再次连接) + opcua.Disconnect(); + } +} +``` + +## 生命周期语义 + +| 方法 | OPC UA 行为 | +| ---- | ---- | +| `ConnectAsync` | 初始化应用证书、发现并选择安全端点、校验服务端证书、建立会话、启动 KeepAlive 与重连 | +| `CheckConnection` | 会话已建立、状态正常且最近 KeepAlive 未超时 | +| `ProbeConnectionAsync` | 创建临时会话完成握手/证书/身份验证后立即关闭,不影响当前正式会话 | +| `TryReconnectAsync` | 串行执行,优先恢复会话与订阅,失败后重建会话并恢复订阅 | +| `Disconnect` | 停止重连、关闭会话(订阅随会话释放),对象仍可再次连接 | +| `Dispose` | 关闭会话、订阅、证书资源与后台任务,并触发 `Disposed` 事件供管理器移除缓存 | + +## 异常与返回策略 + +- 建立会话、认证失败:`ConnectionException` +- 单节点读取失败:`ReadErrorException` +- 单节点写入失败:`WriteErrorException` +- 取消:原样抛出 `OperationCanceledException` +- 服务级错误(浏览、方法调用、订阅、会话中断):`OpcUaServiceException`(保留 `StatusCode` 与诊断信息) +- 证书相关错误:`OpcUaCertificateException` + +> 批量读写不会因为单个节点错误(如 `BadNodeIdUnknown`)而让整个调用抛异常,而是返回逐项状态码;只有会话中断、请求无法发送等整体性错误才会抛异常。 diff --git a/docs/content/toc.yml b/docs/content/toc.yml index 29fa026..f44a78a 100644 --- a/docs/content/toc.yml +++ b/docs/content/toc.yml @@ -3,4 +3,6 @@ - name: MAS.Communication.ModbusProtocol href: MAS.Communication.ModbusProtocol.md - name: MAS.Communication.S7Protocol - href: MAS.Communication.S7Protocol.md \ No newline at end of file + href: MAS.Communication.S7Protocol.md +- name: MAS.Communication.OpcUaProtocol + href: MAS.Communication.OpcUaProtocol.md \ No newline at end of file diff --git a/docs/index.md b/docs/index.md index 87dd97d..de49463 100644 --- a/docs/index.md +++ b/docs/index.md @@ -41,6 +41,7 @@ services.AddCommunication(); | MC(Mitsubishi) | `IMcCommunicationConfig` | | ModbusTCP | `IModbusCommunicationConfig` | | S7(Siemens) | `IS7CommunicationConfig` | +| OPC UA(Client) | `IOpcUaCommunicationConfig` | 以上接口均继承自 `ICommunicationConfig`,调用方需继承并实现它 diff --git a/src/Extensions/CommunicationConfigExtensions.cs b/src/Extensions/CommunicationConfigExtensions.cs index 6d4cc2d..d10de9b 100644 --- a/src/Extensions/CommunicationConfigExtensions.cs +++ b/src/Extensions/CommunicationConfigExtensions.cs @@ -10,6 +10,7 @@ using MAS.Communication.McProtocol; using MAS.Communication.ModbusProtocol; +using MAS.Communication.OpcUaProtocol; using MAS.Communication.S7Protocol; namespace MAS.Communication; @@ -29,7 +30,27 @@ public static string GetInstanceKey(this ICommunicationConfig config) { IS7CommunicationConfig s7 => $"{s7.Type}-{s7.Ip}-{s7.Rack}-{s7.Slot}", IMcCommunicationConfig mc => $"{mc.ProtocolName}-{mc.ProtocolFrame}-{mc.Ip}-{mc.Port}", IModbusCommunicationConfig mb => $"{mb.ProtocolName}-{mb.Ip}-{mb.Port}-{mb.UnitId}", + IOpcUaCommunicationConfig opcUa => BuildOpcUaInstanceKey(opcUa), _ => throw new NotSupportedException($"Unsupported communication config type: {config.GetType().Name}") }; } + + private static string BuildOpcUaInstanceKey(IOpcUaCommunicationConfig config) { + // 身份标识只使用非敏感字段;密码不参与实例键 + string identityId = config.IdentityType switch { + OpcUaIdentityType.UserName => config.UserName ?? string.Empty, + OpcUaIdentityType.Certificate => config.ClientCertificateThumbprint ?? string.Empty, + _ => string.Empty + }; + + return $"OpcUa|{NormalizeEndpointUrl(config.EndpointUrl)}|{config.SecurityMode}|{config.SecurityPolicyUri}|{config.IdentityType}|{identityId}"; + } + + private static string NormalizeEndpointUrl(string endpointUrl) { + if (string.IsNullOrEmpty(endpointUrl)) { + return string.Empty; + } + + return endpointUrl.Trim().TrimEnd('/').ToLowerInvariant(); + } } diff --git a/src/Helpers/StructBinaryHelper.cs b/src/Helpers/StructBinaryHelper.cs index 93a67d2..99264b7 100644 --- a/src/Helpers/StructBinaryHelper.cs +++ b/src/Helpers/StructBinaryHelper.cs @@ -469,11 +469,7 @@ private static void WriteString(FieldInfo field, object structValue, byte[] byte string s = field.GetValue(structValue) as string ?? string.Empty; if (s.Length > attr.Length) { -#if !NETFRAMEWORK s = s[..attr.Length]; -#else - s = s.Substring(0, attr.Length); -#endif } s = s.PadRight(attr.Length, attr.PaddingChar); diff --git a/src/MAS.Communication.csproj b/src/MAS.Communication.csproj index 417cdad..3dc8356 100644 --- a/src/MAS.Communication.csproj +++ b/src/MAS.Communication.csproj @@ -1,4 +1,4 @@ - + net10.0;net9.0;net8.0;net481 @@ -7,9 +7,9 @@ True ../../build/APIDocumentation/MAS.Communication.xml - 1.0.3 - 1.0.3.0 - 1.0.3.0 + 1.1.0 + 1.1.0.0 + 1.1.0.0 MAS(厦门威光)研发部 MAS(厦门威光) Copyright (c) MAS(厦门威光) Corporation. All rights reserved. @@ -36,9 +36,16 @@ latest + + 1.5.378.156 + + - + + + + diff --git a/src/Managers/ProtocolManager.cs b/src/Managers/ProtocolManager.cs index b20dbbc..b719493 100644 --- a/src/Managers/ProtocolManager.cs +++ b/src/Managers/ProtocolManager.cs @@ -10,6 +10,7 @@ using MAS.Communication.McProtocol; using MAS.Communication.ModbusProtocol; +using MAS.Communication.OpcUaProtocol; using MAS.Communication.S7Protocol; using System.Collections.Concurrent; @@ -150,6 +151,7 @@ private static IProtocol CreateProtocol(ICommunicationConfig config) { IMcCommunicationConfig mc => new McProtocolClient(mc), IModbusCommunicationConfig md => new ModbusProtocolTcp(md), IS7CommunicationConfig s7 => new S7ProtocolClient(s7), + IOpcUaCommunicationConfig opcUa => new OpcUaProtocolClient(opcUa), _ => throw new NotSupportedException($"不支持的通讯配置类型:{config.GetInstanceKey()}") }; } diff --git a/src/Models/CommProtocol.cs b/src/Models/CommProtocol.cs index 07b5e63..03a7aab 100644 --- a/src/Models/CommProtocol.cs +++ b/src/Models/CommProtocol.cs @@ -27,5 +27,10 @@ public enum CommProtocol { /// /// Modbus TCP 协议 /// - ModbusTcp + ModbusTcp, + + /// + /// OPC UA 协议(客户端) + /// + OpcUa } diff --git a/src/OpcUaProtocol/EventArgs/OpcUaDataChangedEventArgs.cs b/src/OpcUaProtocol/EventArgs/OpcUaDataChangedEventArgs.cs new file mode 100644 index 0000000..358904b --- /dev/null +++ b/src/OpcUaProtocol/EventArgs/OpcUaDataChangedEventArgs.cs @@ -0,0 +1,28 @@ +// ============================================================================= +// MAS.Communication +// https://www.mas-automation.com/ +// +// Copyright 2026 MAS (厦门威光) Corporation +// +// Licensed under the Apache License, Version 2.0 +// See LICENSE file in the project root for full license information. +// ============================================================================= + +namespace MAS.Communication.OpcUaProtocol; + +/// +/// OPC UA 订阅数据变化事件参数 +/// +/// 触发变化的订阅标识 +/// 变化后的值 +public sealed class OpcUaDataChangedEventArgs(string subscriptionId, OpcUaValue value) : EventArgs { + /// + /// 获取触发变化的订阅标识 + /// + public string SubscriptionId { get; } = subscriptionId; + + /// + /// 获取变化后的值 + /// + public OpcUaValue Value { get; } = value; +} diff --git a/src/OpcUaProtocol/Exceptions/OpcUaCertificateException.cs b/src/OpcUaProtocol/Exceptions/OpcUaCertificateException.cs new file mode 100644 index 0000000..febbfc4 --- /dev/null +++ b/src/OpcUaProtocol/Exceptions/OpcUaCertificateException.cs @@ -0,0 +1,30 @@ +// ============================================================================= +// MAS.Communication +// https://www.mas-automation.com/ +// +// Copyright 2026 MAS (厦门威光) Corporation +// +// Licensed under the Apache License, Version 2.0 +// See LICENSE file in the project root for full license information. +// ============================================================================= + +namespace MAS.Communication.OpcUaProtocol; + +/// +/// OPC UA 证书相关异常 +/// +/// 用于应用证书初始化失败、服务端证书校验失败或指定客户端证书不可用等场景 +public class OpcUaCertificateException : Exception { + /// + /// 初始化 新实例 + /// + /// 异常消息 + public OpcUaCertificateException(string message) : base(message) { } + + /// + /// 初始化 新实例 + /// + /// 异常消息 + /// 内部异常 + public OpcUaCertificateException(string message, Exception innerException) : base(message, innerException) { } +} diff --git a/src/OpcUaProtocol/Exceptions/OpcUaServiceException.cs b/src/OpcUaProtocol/Exceptions/OpcUaServiceException.cs new file mode 100644 index 0000000..4f14371 --- /dev/null +++ b/src/OpcUaProtocol/Exceptions/OpcUaServiceException.cs @@ -0,0 +1,59 @@ +// ============================================================================= +// MAS.Communication +// https://www.mas-automation.com/ +// +// Copyright 2026 MAS (厦门威光) Corporation +// +// Licensed under the Apache License, Version 2.0 +// See LICENSE file in the project root for full license information. +// ============================================================================= + +namespace MAS.Communication.OpcUaProtocol; + +/// +/// OPC UA 服务调用异常 +/// +/// +/// 用于承载整体性服务错误(例如会话中断、请求无法发送)时的 OPC UA 诊断信息; +/// 批量读写中的单节点错误应通过逐项状态码返回,而非抛出此异常 +/// +public class OpcUaServiceException : Exception { + /// + /// 获取 OPC UA 状态码(原始 32 位无符号整数) + /// + public uint StatusCode { get; } + + /// + /// 获取诊断信息 + /// + public string? DiagnosticInfo { get; } + + /// + /// 初始化 新实例 + /// + /// 异常消息 + public OpcUaServiceException(string message) : base(message) { } + + /// + /// 初始化 新实例 + /// + /// 异常消息 + /// 内部异常 + public OpcUaServiceException(string message, Exception innerException) : base(message, innerException) { } + + /// + /// 初始化 新实例 + /// + /// 异常消息 + /// OPC UA 状态码 + /// 诊断信息 + /// 内部异常 + public OpcUaServiceException( + string message, + uint statusCode, + string? diagnosticInfo = null, + Exception? innerException = null) : base(message, innerException) { + StatusCode = statusCode; + DiagnosticInfo = diagnosticInfo; + } +} diff --git a/src/OpcUaProtocol/Helpers/OpcUaCertificateManager.cs b/src/OpcUaProtocol/Helpers/OpcUaCertificateManager.cs new file mode 100644 index 0000000..b6c2af4 --- /dev/null +++ b/src/OpcUaProtocol/Helpers/OpcUaCertificateManager.cs @@ -0,0 +1,123 @@ +// ============================================================================= +// MAS.Communication +// https://www.mas-automation.com/ +// +// Copyright 2026 MAS (厦门威光) Corporation +// +// Licensed under the Apache License, Version 2.0 +// See LICENSE file in the project root for full license information. +// ============================================================================= + +using System.Security.Cryptography.X509Certificates; +using Opc.Ua; +using Opc.Ua.Configuration; + +// 使用官方客户端的稳定同步友好重载;对应的遥测(ITelemetryContext)新 API 暂不在首版范围内 +#pragma warning disable CS0618 + +namespace MAS.Communication.OpcUaProtocol; + +/// +/// 负责 OPC UA 应用证书初始化与客户端证书加载的内部帮助类 +/// +internal static class OpcUaCertificateManager { + private const string PRODUCT_URI = "urn:MAS:Communication:OpcUaClient"; + private const string DEFAULT_CERTIFICATE_STORE_PATH = "%LocalApplicationData%/MAS.Communication/pki"; + + /// + /// 创建 OPC UA 应用实例与应用配置,并确保应用证书就绪 + /// + /// OPC UA 通信配置 + /// 应用实例与对应的应用配置 + /// 应用证书初始化失败时抛出 + public static async Task<(ApplicationInstance Application, ApplicationConfiguration Configuration)> CreateApplicationAsync( + IOpcUaCommunicationConfig config) { + string applicationUri = string.IsNullOrEmpty(config.ApplicationUri) + ? $"urn:{Utils.GetHostName()}:MAS:Communication:OpcUaClient" + : config.ApplicationUri; + + string pkiRoot = string.IsNullOrEmpty(config.CertificateStorePath) + ? DEFAULT_CERTIFICATE_STORE_PATH + : config.CertificateStorePath; + + ApplicationInstance application = new() { + ApplicationName = config.ApplicationName, + ApplicationType = ApplicationType.Client + }; + + ApplicationConfiguration appConfig; + try { + appConfig = await application + .Build(applicationUri, PRODUCT_URI) + .AsClient() + .AddSecurityConfiguration($"CN={config.ApplicationName}", pkiRoot) + .SetAutoAcceptUntrustedCertificates(config.AutoAcceptUntrustedCertificates) + .CreateAsync() + .ConfigureAwait(false); + } catch (Exception ex) { + throw new OpcUaCertificateException($"OPC UA 应用配置构建失败:{ex.Message}", ex); + } + + bool canAutoAccept = config.AutoAcceptUntrustedCertificates; + appConfig.CertificateValidator.CertificateValidation += (_, e) => { + if (canAutoAccept && + e.Error is not null && + e.Error.StatusCode == StatusCodes.BadCertificateUntrusted) { + e.Accept = true; + } + }; + + try { + bool hasValidCertificate = await application + .CheckApplicationInstanceCertificatesAsync(true) + .ConfigureAwait(false); + + if (!hasValidCertificate) { + throw new OpcUaCertificateException("OPC UA 应用实例证书无效或缺失,且无法自动创建。"); + } + } catch (OpcUaCertificateException) { + throw; + } catch (Exception ex) { + throw new OpcUaCertificateException($"OPC UA 应用实例证书初始化失败:{ex.Message}", ex); + } + + return (application, appConfig); + } + + /// + /// 根据指纹加载用于身份认证的客户端证书 + /// + /// 应用配置 + /// OPC UA 通信配置 + /// 客户端证书 + /// 找不到指定证书或证书不可用时抛出 + public static async Task LoadClientCertificateAsync( + ApplicationConfiguration appConfig, + IOpcUaCommunicationConfig config) { + if (string.IsNullOrEmpty(config.ClientCertificateThumbprint)) { + throw new OpcUaCertificateException("身份类型为证书时必须提供 ClientCertificateThumbprint。"); + } + + CertificateIdentifier appCertificate = appConfig.SecurityConfiguration.ApplicationCertificate; + CertificateIdentifier identifier = new() { + StoreType = appCertificate.StoreType, + StorePath = appCertificate.StorePath, + Thumbprint = config.ClientCertificateThumbprint + }; + + X509Certificate2? certificate; + try { + certificate = await identifier.Find(true, appConfig.ApplicationUri).ConfigureAwait(false); + } catch (Exception ex) { + throw new OpcUaCertificateException( + $"加载客户端证书失败。Thumbprint={config.ClientCertificateThumbprint}:{ex.Message}", ex); + } + + if (certificate is null) { + throw new OpcUaCertificateException( + $"未找到指定的客户端证书。Thumbprint={config.ClientCertificateThumbprint},StorePath={identifier.StorePath}。"); + } + + return certificate; + } +} diff --git a/src/OpcUaProtocol/Helpers/OpcUaEndpointSelector.cs b/src/OpcUaProtocol/Helpers/OpcUaEndpointSelector.cs new file mode 100644 index 0000000..c89d17d --- /dev/null +++ b/src/OpcUaProtocol/Helpers/OpcUaEndpointSelector.cs @@ -0,0 +1,92 @@ +// ============================================================================= +// MAS.Communication +// https://www.mas-automation.com/ +// +// Copyright 2026 MAS (厦门威光) Corporation +// +// Licensed under the Apache License, Version 2.0 +// See LICENSE file in the project root for full license information. +// ============================================================================= + +using Opc.Ua; +using Opc.Ua.Client; + +// 使用官方客户端的稳定同步友好重载;对应的遥测(ITelemetryContext)新 API 暂不在首版范围内 +#pragma warning disable CS0618 + +namespace MAS.Communication.OpcUaProtocol; + +/// +/// 根据配置的安全模式与安全策略选择 OPC UA 端点的内部帮助类 +/// +internal static class OpcUaEndpointSelector { + /// + /// 发现并选择满足安全模式与安全策略要求的端点 + /// + /// 应用配置 + /// OPC UA 通信配置 + /// 取消令牌 + /// 已配置的端点 + /// 找不到满足要求的端点时抛出 + public static async Task SelectAsync( + ApplicationConfiguration appConfig, + IOpcUaCommunicationConfig config, + CancellationToken cancellationToken = default) { + MessageSecurityMode desiredMode = MapSecurityMode(config.SecurityMode); + EndpointConfiguration endpointConfiguration = EndpointConfiguration.Create(appConfig); + + EndpointDescription? selected = await DiscoverMatchingEndpointAsync( + appConfig, config, desiredMode, endpointConfiguration, cancellationToken).ConfigureAwait(false); + + if (selected is null && config.UseEndpointDiscovery) { + bool useSecurity = config.SecurityMode != OpcUaSecurityMode.None; + selected = CoreClientUtils.SelectEndpoint(appConfig, config.EndpointUrl, useSecurity); + } + + if (selected is null) { + throw new OpcUaServiceException( + $"未找到匹配的端点。EndpointUrl={config.EndpointUrl}, SecurityMode={config.SecurityMode}, SecurityPolicyUri={config.SecurityPolicyUri}。"); + } + + return new ConfiguredEndpoint(null, selected, endpointConfiguration); + } + + /// + /// 将库自有安全模式映射为 SDK 消息安全模式 + /// + public static MessageSecurityMode MapSecurityMode(OpcUaSecurityMode mode) { + return mode switch { + OpcUaSecurityMode.None => MessageSecurityMode.None, + OpcUaSecurityMode.Sign => MessageSecurityMode.Sign, + OpcUaSecurityMode.SignAndEncrypt => MessageSecurityMode.SignAndEncrypt, + _ => MessageSecurityMode.None + }; + } + + private static async Task DiscoverMatchingEndpointAsync( + ApplicationConfiguration appConfig, + IOpcUaCommunicationConfig config, + MessageSecurityMode desiredMode, + EndpointConfiguration endpointConfiguration, + CancellationToken cancellationToken) { + DiscoveryClient client = await DiscoveryClient.CreateAsync(appConfig, new Uri(config.EndpointUrl), endpointConfiguration, ct: cancellationToken); + try { + EndpointDescriptionCollection endpoints = + await client.GetEndpointsAsync(null, cancellationToken).ConfigureAwait(false); + + IEnumerable candidates = endpoints + .Where(e => e.SecurityMode == desiredMode); + + if (!string.IsNullOrEmpty(config.SecurityPolicyUri)) { + candidates = candidates.Where(e => + string.Equals(e.SecurityPolicyUri, config.SecurityPolicyUri, StringComparison.Ordinal)); + } + + return candidates + .OrderByDescending(e => e.SecurityLevel) + .FirstOrDefault(); + } finally { + _ = await client.CloseAsync(cancellationToken).ConfigureAwait(false); + } + } +} diff --git a/src/OpcUaProtocol/Helpers/OpcUaValueConverter.cs b/src/OpcUaProtocol/Helpers/OpcUaValueConverter.cs new file mode 100644 index 0000000..aa84f44 --- /dev/null +++ b/src/OpcUaProtocol/Helpers/OpcUaValueConverter.cs @@ -0,0 +1,95 @@ +// ============================================================================= +// MAS.Communication +// https://www.mas-automation.com/ +// +// Copyright 2026 MAS (厦门威光) Corporation +// +// Licensed under the Apache License, Version 2.0 +// See LICENSE file in the project root for full license information. +// ============================================================================= + +using System.Globalization; +using Opc.Ua; + +namespace MAS.Communication.OpcUaProtocol; + +/// +/// 在库自有模型与官方 SDK 类型之间进行转换的内部帮助类 +/// +internal static class OpcUaValueConverter { + /// + /// 将库自有节点标识解析为 SDK + /// + public static NodeId ToNodeId(OpcUaNodeId nodeId) { + if (string.IsNullOrEmpty(nodeId.Value)) { + throw new ArgumentException("节点标识不能为空。", nameof(nodeId)); + } + + return NodeId.Parse(nodeId.Value); + } + + /// + /// 将 SDK 转换为库自有读取结果 + /// + public static OpcUaValue ToOpcUaValue(OpcUaNodeId nodeId, DataValue dataValue) { + return new OpcUaValue( + nodeId, + dataValue.Value, + dataValue.StatusCode.Code, + ToNullableTimestamp(dataValue.SourceTimestamp), + ToNullableTimestamp(dataValue.ServerTimestamp)); + } + + /// + /// 将 SDK 映射为库自有节点类别 + /// + public static OpcUaNodeClass MapNodeClass(NodeClass nodeClass) { + return nodeClass switch { + NodeClass.Object => OpcUaNodeClass.Object, + NodeClass.Variable => OpcUaNodeClass.Variable, + NodeClass.Method => OpcUaNodeClass.Method, + NodeClass.ObjectType => OpcUaNodeClass.ObjectType, + NodeClass.VariableType => OpcUaNodeClass.VariableType, + NodeClass.ReferenceType => OpcUaNodeClass.ReferenceType, + NodeClass.DataType => OpcUaNodeClass.DataType, + NodeClass.View => OpcUaNodeClass.View, + _ => OpcUaNodeClass.Unspecified + }; + } + + /// + /// 将读取到的原始值转换为指定的目标类型 + /// + public static T? ConvertValue(object? value) { + if (value is null) { + return default; + } + + if (value is T typed) { + return typed; + } + + Type target = Nullable.GetUnderlyingType(typeof(T)) ?? typeof(T); + + if (target.IsEnum) { + return (T)Enum.ToObject(target, value); + } + + if (value is IConvertible) { + return (T)Convert.ChangeType(value, target, CultureInfo.InvariantCulture); + } + + return (T)value; + } + + /// + /// 判断 OPC UA 状态码是否表示“错误(Bad)” + /// + public static bool IsBad(uint statusCode) { + return (statusCode & 0x80000000u) != 0u; + } + + private static DateTime? ToNullableTimestamp(DateTime timestamp) { + return timestamp == DateTime.MinValue ? null : timestamp; + } +} diff --git a/src/OpcUaProtocol/Interfaces/IOpcUaCommunicationConfig.cs b/src/OpcUaProtocol/Interfaces/IOpcUaCommunicationConfig.cs new file mode 100644 index 0000000..28f90d2 --- /dev/null +++ b/src/OpcUaProtocol/Interfaces/IOpcUaCommunicationConfig.cs @@ -0,0 +1,120 @@ +// ============================================================================= +// MAS.Communication +// https://www.mas-automation.com/ +// +// Copyright 2026 MAS (厦门威光) Corporation +// +// Licensed under the Apache License, Version 2.0 +// See LICENSE file in the project root for full license information. +// ============================================================================= + +namespace MAS.Communication.OpcUaProtocol; + +/// +/// OPC UA 通信配置接口 +/// +/// +/// +/// 是连接主键(例如 opc.tcp://192.168.1.10:4840/Server)。 +/// 基类继承的 仅为兼容现有框架,OPC UA 地址包含协议、 +/// 端口与路径,不能仅依赖 。 +/// +/// +/// 出于安全考虑,密码不应进入配置对象、实例键、日志或异常信息,推荐通过 +/// 配合 在建立会话时按需解析。 +/// +/// +/// 配置会在客户端创建时克隆为快照。请确保 Clone 实现完整复制以下所有字段 +/// (包括 引用),否则可能导致实例无法从管理器缓存中正确移除。 +/// +/// +public interface IOpcUaCommunicationConfig : ICommunicationConfig { + /// + /// 获取或设置端点地址,是 OPC UA 的连接主键 + /// + /// 例如 opc.tcp://192.168.1.10:4840/Server + string EndpointUrl { get; set; } + + /// + /// 获取或设置客户端应用名称 + /// + string ApplicationName { get; set; } + + /// + /// 获取或设置客户端应用 URI + /// + /// 应保证在证书中一致,通常形如 urn:HOST:MAS:Communication:Client + string ApplicationUri { get; set; } + + /// + /// 获取或设置消息安全模式,参与端点选择 + /// + OpcUaSecurityMode SecurityMode { get; set; } + + /// + /// 获取或设置安全策略 URI,参与端点选择 + /// + /// 例如 http://opcfoundation.org/UA/SecurityPolicy#Basic256Sha256 + string SecurityPolicyUri { get; set; } + + /// + /// 获取或设置客户端身份类型 + /// + OpcUaIdentityType IdentityType { get; set; } + + /// + /// 获取或设置用户名(仅当 为 时使用) + /// + string? UserName { get; set; } + + /// + /// 获取或设置凭据引用键 + /// + /// 保存凭据引用而非密码本身,配合 解析实际密码 + string? CredentialKey { get; set; } + + /// + /// 获取凭据提供者 + /// + /// + /// 该属性承载的是服务引用而非配置数据,不参与实例键计算; + /// 当 为 时用于解析密码 + /// + IOpcUaCredentialProvider? CredentialProvider { get; } + + /// + /// 获取或设置客户端证书指纹(仅当 为 时使用) + /// + string? ClientCertificateThumbprint { get; set; } + + /// + /// 获取或设置证书存储根路径(PKI 目录) + /// + string CertificateStorePath { get; set; } + + /// + /// 获取或设置一个值,指示是否自动接受不受信任的服务端证书 + /// + /// 默认必须为 ,仅建议在受控的开发环境中临时开启 + bool AutoAcceptUntrustedCertificates { get; set; } + + /// + /// 获取或设置会话超时时间(毫秒) + /// + int SessionTimeout { get; set; } + + /// + /// 获取或设置保活间隔(毫秒) + /// + int KeepAliveInterval { get; set; } + + /// + /// 获取或设置重连等待时间(毫秒) + /// + int ReconnectDelay { get; set; } + + /// + /// 获取或设置一个值,指示是否使用端点发现服务 + /// + bool UseEndpointDiscovery { get; set; } +} diff --git a/src/OpcUaProtocol/Interfaces/IOpcUaCredentialProvider.cs b/src/OpcUaProtocol/Interfaces/IOpcUaCredentialProvider.cs new file mode 100644 index 0000000..f3481c7 --- /dev/null +++ b/src/OpcUaProtocol/Interfaces/IOpcUaCredentialProvider.cs @@ -0,0 +1,31 @@ +// ============================================================================= +// MAS.Communication +// https://www.mas-automation.com/ +// +// Copyright 2026 MAS (厦门威光) Corporation +// +// Licensed under the Apache License, Version 2.0 +// See LICENSE file in the project root for full license information. +// ============================================================================= + +namespace MAS.Communication.OpcUaProtocol; + +/// +/// OPC UA 凭据提供者 +/// +/// +/// 用于在建立会话时按 解析用户名/密码, +/// 从而避免将密码保存进配置对象、实例键、日志或异常信息 +/// +public interface IOpcUaCredentialProvider { + /// + /// 根据凭据引用键异步解析用户名/密码凭据 + /// + /// 凭据引用键 + /// 取消令牌 + /// 解析出的凭据;无法解析时返回 + /// 取消此操作时抛出此异常 + ValueTask GetCredentialAsync( + string credentialKey, + CancellationToken cancellationToken = default); +} diff --git a/src/OpcUaProtocol/Interfaces/IOpcUaProtocol.cs b/src/OpcUaProtocol/Interfaces/IOpcUaProtocol.cs new file mode 100644 index 0000000..5e70cc2 --- /dev/null +++ b/src/OpcUaProtocol/Interfaces/IOpcUaProtocol.cs @@ -0,0 +1,127 @@ +// ============================================================================= +// MAS.Communication +// https://www.mas-automation.com/ +// +// Copyright 2026 MAS (厦门威光) Corporation +// +// Licensed under the Apache License, Version 2.0 +// See LICENSE file in the project root for full license information. +// ============================================================================= + +namespace MAS.Communication.OpcUaProtocol; + +/// +/// 与 OPC UA 服务端进行通信的客户端接口 +/// +/// +/// OPC UA 保持自己的“节点、会话、订阅、安全”模型,不抽象成地址读写接口。 +/// 接口只暴露库自有的轻量模型,不泄漏官方 SDK 类型 +/// +public interface IOpcUaProtocol : IProtocol { + /// + /// 异步读取单个节点的值 + /// + /// 节点标识 + /// 取消令牌 + /// 读取结果,包含值、状态码与时间戳 + /// 取消此操作时抛出此异常 + /// 读取失败时抛出此异常 + Task ReadAsync( + OpcUaNodeId nodeId, + CancellationToken cancellationToken = default); + + /// + /// 异步读取单个节点的值并转换为指定类型 + /// + /// 目标类型 + /// 节点标识 + /// 取消令牌 + /// 转换后的值;节点值为空时返回默认值 + /// 取消此操作时抛出此异常 + /// 读取失败或类型转换失败时抛出此异常 + Task ReadAsync( + OpcUaNodeId nodeId, + CancellationToken cancellationToken = default); + + /// + /// 异步批量读取多个节点的值 + /// + /// 批量读取允许部分成功、部分失败,逐项状态通过 返回 + /// 要读取的节点项 + /// 取消令牌 + /// 与请求顺序一致的读取结果集合 + /// 取消此操作时抛出此异常 + /// 整体性请求失败时抛出此异常 + Task> ReadAsync( + IReadOnlyList items, + CancellationToken cancellationToken = default); + + /// + /// 异步写入单个节点的值 + /// + /// 节点标识 + /// 要写入的值 + /// 取消令牌 + /// 表示一个异步操作任务 + /// 取消此操作时抛出此异常 + /// 写入失败时抛出此异常 + Task WriteAsync( + OpcUaNodeId nodeId, + object? value, + CancellationToken cancellationToken = default); + + /// + /// 异步批量写入多个节点的值 + /// + /// 批量写入允许部分成功、部分失败,逐项状态通过返回结果的状态码返回 + /// 要写入的节点项 + /// 取消令牌 + /// 与请求顺序一致的写入结果集合 + /// 取消此操作时抛出此异常 + /// 整体性请求失败时抛出此异常 + Task> WriteAsync( + IReadOnlyList items, + CancellationToken cancellationToken = default); + + /// + /// 异步浏览节点的子节点 + /// + /// 父节点标识;为 时从 Objects 文件夹开始浏览 + /// 取消令牌 + /// 子节点集合 + /// 取消此操作时抛出此异常 + /// 服务调用失败时抛出此异常 + Task> BrowseAsync( + OpcUaNodeId? parentNodeId = null, + CancellationToken cancellationToken = default); + + /// + /// 异步调用方法 + /// + /// 拥有该方法的对象节点标识 + /// 方法节点标识 + /// 输入参数 + /// 取消令牌 + /// 方法调用结果,包含状态码与输出参数 + /// 取消此操作时抛出此异常 + /// 服务调用失败时抛出此异常 + Task CallMethodAsync( + OpcUaNodeId objectNodeId, + OpcUaNodeId methodNodeId, + IReadOnlyList inputArguments, + CancellationToken cancellationToken = default); + + /// + /// 异步创建数据变化订阅 + /// + /// 初始监控项 + /// 订阅参数 + /// 取消令牌 + /// 订阅实例,可用于动态增删监控项及释放服务端资源 + /// 取消此操作时抛出此异常 + /// 服务调用失败时抛出此异常 + Task SubscribeAsync( + IReadOnlyList items, + OpcUaSubscriptionOptions options, + CancellationToken cancellationToken = default); +} diff --git a/src/OpcUaProtocol/Interfaces/IOpcUaSubscription.cs b/src/OpcUaProtocol/Interfaces/IOpcUaSubscription.cs new file mode 100644 index 0000000..291d800 --- /dev/null +++ b/src/OpcUaProtocol/Interfaces/IOpcUaSubscription.cs @@ -0,0 +1,64 @@ +// ============================================================================= +// MAS.Communication +// https://www.mas-automation.com/ +// +// Copyright 2026 MAS (厦门威光) Corporation +// +// Licensed under the Apache License, Version 2.0 +// See LICENSE file in the project root for full license information. +// ============================================================================= + +namespace MAS.Communication.OpcUaProtocol; + +/// +/// OPC UA 订阅 +/// +/// +/// 订阅是一个独立的、有服务端资源的抽象,除数据变化事件外,还支持动态添加/删除监控项 +/// 以及显式释放服务端资源 +/// +public interface IOpcUaSubscription : IDisposable { + /// + /// 获取订阅标识 + /// + string Id { get; } + + /// + /// 数据变化事件 + /// + /// 事件在内部派发线程上触发,而非 SDK 的发布回调线程,避免业务处理阻塞发布流程 + event EventHandler? DataChanged; + + /// + /// 异步向订阅添加监控项 + /// + /// 要添加的监控项 + /// 取消令牌 + /// 表示一个异步操作任务 + /// 取消此操作时抛出此异常 + /// 服务调用失败时抛出此异常 + Task AddItemsAsync( + IReadOnlyList items, + CancellationToken cancellationToken = default); + + /// + /// 异步从订阅移除监控项 + /// + /// 要移除的节点标识 + /// 取消令牌 + /// 表示一个异步操作任务 + /// 取消此操作时抛出此异常 + /// 服务调用失败时抛出此异常 + Task RemoveItemsAsync( + IReadOnlyList nodeIds, + CancellationToken cancellationToken = default); + + /// + /// 异步删除订阅并释放服务端资源 + /// + /// 取消令牌 + /// 表示一个异步操作任务 + /// 取消此操作时抛出此异常 + /// 服务调用失败时抛出此异常 + Task DeleteAsync(CancellationToken cancellationToken = default); +} diff --git a/src/OpcUaProtocol/Models/OpcUaBrowseNode.cs b/src/OpcUaProtocol/Models/OpcUaBrowseNode.cs new file mode 100644 index 0000000..9f2b10e --- /dev/null +++ b/src/OpcUaProtocol/Models/OpcUaBrowseNode.cs @@ -0,0 +1,26 @@ +// ============================================================================= +// MAS.Communication +// https://www.mas-automation.com/ +// +// Copyright 2026 MAS (厦门威光) Corporation +// +// Licensed under the Apache License, Version 2.0 +// See LICENSE file in the project root for full license information. +// ============================================================================= + +namespace MAS.Communication.OpcUaProtocol; + +/// +/// OPC UA 浏览结果节点 +/// +/// 目标节点标识 +/// 浏览名称 +/// 显示名称 +/// 节点类别 +/// 引用方向是否为正向 +public sealed record OpcUaBrowseNode( + OpcUaNodeId NodeId, + string BrowseName, + string DisplayName, + OpcUaNodeClass NodeClass, + bool IsForward); diff --git a/src/OpcUaProtocol/Models/OpcUaIdentityType.cs b/src/OpcUaProtocol/Models/OpcUaIdentityType.cs new file mode 100644 index 0000000..15eac84 --- /dev/null +++ b/src/OpcUaProtocol/Models/OpcUaIdentityType.cs @@ -0,0 +1,31 @@ +// ============================================================================= +// MAS.Communication +// https://www.mas-automation.com/ +// +// Copyright 2026 MAS (厦门威光) Corporation +// +// Licensed under the Apache License, Version 2.0 +// See LICENSE file in the project root for full license information. +// ============================================================================= + +namespace MAS.Communication.OpcUaProtocol; + +/// +/// OPC UA 客户端身份类型 +/// +public enum OpcUaIdentityType { + /// + /// 匿名身份 + /// + Anonymous, + + /// + /// 用户名/密码身份,密码通过 解析 + /// + UserName, + + /// + /// 客户端证书身份,使用 指定证书 + /// + Certificate +} diff --git a/src/OpcUaProtocol/Models/OpcUaMethodResult.cs b/src/OpcUaProtocol/Models/OpcUaMethodResult.cs new file mode 100644 index 0000000..cbdce66 --- /dev/null +++ b/src/OpcUaProtocol/Models/OpcUaMethodResult.cs @@ -0,0 +1,30 @@ +// ============================================================================= +// MAS.Communication +// https://www.mas-automation.com/ +// +// Copyright 2026 MAS (厦门威光) Corporation +// +// Licensed under the Apache License, Version 2.0 +// See LICENSE file in the project root for full license information. +// ============================================================================= + +namespace MAS.Communication.OpcUaProtocol; + +/// +/// OPC UA 方法调用结果 +/// +/// OPC UA 状态码(原始 32 位无符号整数) +/// 方法输出参数 +public sealed record OpcUaMethodResult( + uint StatusCode, + IReadOnlyList OutputArguments) { + /// + /// 获取一个值,指示该状态码是否表示“良好(Good)” + /// + public bool IsGood => (StatusCode & 0xC0000000u) == 0u; + + /// + /// 获取一个值,指示该状态码是否表示“错误(Bad)” + /// + public bool IsBad => (StatusCode & 0x80000000u) != 0u; +} diff --git a/src/OpcUaProtocol/Models/OpcUaMonitoredItem.cs b/src/OpcUaProtocol/Models/OpcUaMonitoredItem.cs new file mode 100644 index 0000000..358168d --- /dev/null +++ b/src/OpcUaProtocol/Models/OpcUaMonitoredItem.cs @@ -0,0 +1,41 @@ +// ============================================================================= +// MAS.Communication +// https://www.mas-automation.com/ +// +// Copyright 2026 MAS (厦门威光) Corporation +// +// Licensed under the Apache License, Version 2.0 +// See LICENSE file in the project root for full license information. +// ============================================================================= + +namespace MAS.Communication.OpcUaProtocol; + +/// +/// OPC UA 订阅监控项 +/// +/// +/// 初始化 新实例 +/// +/// 要监控的节点标识 +public sealed class OpcUaMonitoredItem(OpcUaNodeId nodeId) { + + /// + /// 获取要监控的节点标识 + /// + public OpcUaNodeId NodeId { get; } = nodeId; + + /// + /// 获取采样间隔(毫秒),默认 1000 + /// + public double SamplingInterval { get; init; } = 1000; + + /// + /// 获取服务端队列深度,默认 1 + /// + public uint QueueSize { get; init; } = 1; + + /// + /// 获取一个值,指示队列满时是否丢弃最旧的数据,默认 + /// + public bool DiscardOldest { get; init; } = true; +} diff --git a/src/OpcUaProtocol/Models/OpcUaNodeClass.cs b/src/OpcUaProtocol/Models/OpcUaNodeClass.cs new file mode 100644 index 0000000..ae794de --- /dev/null +++ b/src/OpcUaProtocol/Models/OpcUaNodeClass.cs @@ -0,0 +1,62 @@ +// ============================================================================= +// MAS.Communication +// https://www.mas-automation.com/ +// +// Copyright 2026 MAS (厦门威光) Corporation +// +// Licensed under the Apache License, Version 2.0 +// See LICENSE file in the project root for full license information. +// ============================================================================= + +namespace MAS.Communication.OpcUaProtocol; + +/// +/// OPC UA 节点类别 +/// +/// 这是对 OPC UA 规范中 NodeClass 的轻量映射,避免向公共 API 泄漏官方 SDK 类型 +public enum OpcUaNodeClass { + /// + /// 未指定 + /// + Unspecified = 0, + + /// + /// 对象节点 + /// + Object = 1, + + /// + /// 变量节点 + /// + Variable = 2, + + /// + /// 方法节点 + /// + Method = 4, + + /// + /// 对象类型节点 + /// + ObjectType = 8, + + /// + /// 变量类型节点 + /// + VariableType = 16, + + /// + /// 引用类型节点 + /// + ReferenceType = 32, + + /// + /// 数据类型节点 + /// + DataType = 64, + + /// + /// 视图节点 + /// + View = 128 +} diff --git a/src/OpcUaProtocol/Models/OpcUaNodeId.cs b/src/OpcUaProtocol/Models/OpcUaNodeId.cs new file mode 100644 index 0000000..b1b2297 --- /dev/null +++ b/src/OpcUaProtocol/Models/OpcUaNodeId.cs @@ -0,0 +1,44 @@ +// ============================================================================= +// MAS.Communication +// https://www.mas-automation.com/ +// +// Copyright 2026 MAS (厦门威光) Corporation +// +// Licensed under the Apache License, Version 2.0 +// See LICENSE file in the project root for full license information. +// ============================================================================= + +namespace MAS.Communication.OpcUaProtocol; + +/// +/// OPC UA 节点标识 +/// +/// +/// 使用标准文本格式表示节点,例如: +/// +/// ns=2;s=Machine.Speed(字符串标识) +/// ns=3;i=1001(数值标识) +/// i=2258(默认命名空间 0) +/// +/// 该类型是对官方 SDK NodeId 的轻量封装,避免向公共 API 泄漏 SDK 类型 +/// +/// 节点标识文本 +public readonly record struct OpcUaNodeId(string Value) { + /// + /// 返回节点标识文本 + /// + /// 节点标识文本 + public override string ToString() { + return Value; + } + + /// + /// 将字符串隐式转换为 + /// + /// 节点标识文本 +#pragma warning disable MAS201 // 隐式转换运算符由编译器生成 op_Implicit 方法名,无法遵循 PascalCase 命名 + public static implicit operator OpcUaNodeId(string value) { + return new OpcUaNodeId(value); + } +#pragma warning restore MAS201 +} diff --git a/src/OpcUaProtocol/Models/OpcUaReadItem.cs b/src/OpcUaProtocol/Models/OpcUaReadItem.cs new file mode 100644 index 0000000..e8f30ec --- /dev/null +++ b/src/OpcUaProtocol/Models/OpcUaReadItem.cs @@ -0,0 +1,17 @@ +// ============================================================================= +// MAS.Communication +// https://www.mas-automation.com/ +// +// Copyright 2026 MAS (厦门威光) Corporation +// +// Licensed under the Apache License, Version 2.0 +// See LICENSE file in the project root for full license information. +// ============================================================================= + +namespace MAS.Communication.OpcUaProtocol; + +/// +/// OPC UA 批量读取项 +/// +/// 要读取的节点标识 +public sealed record OpcUaReadItem(OpcUaNodeId NodeId); diff --git a/src/OpcUaProtocol/Models/OpcUaSecurityMode.cs b/src/OpcUaProtocol/Models/OpcUaSecurityMode.cs new file mode 100644 index 0000000..cf61f3b --- /dev/null +++ b/src/OpcUaProtocol/Models/OpcUaSecurityMode.cs @@ -0,0 +1,32 @@ +// ============================================================================= +// MAS.Communication +// https://www.mas-automation.com/ +// +// Copyright 2026 MAS (厦门威光) Corporation +// +// Licensed under the Apache License, Version 2.0 +// See LICENSE file in the project root for full license information. +// ============================================================================= + +namespace MAS.Communication.OpcUaProtocol; + +/// +/// OPC UA 消息安全模式 +/// +/// 与端点选择配合使用,决定客户端与服务端之间的消息保护级别 +public enum OpcUaSecurityMode { + /// + /// 不进行任何签名或加密(不推荐用于生产环境) + /// + None, + + /// + /// 仅对消息进行签名,保证完整性,但不加密 + /// + Sign, + + /// + /// 对消息进行签名并加密,同时保证完整性与机密性 + /// + SignAndEncrypt +} diff --git a/src/OpcUaProtocol/Models/OpcUaSubscriptionOptions.cs b/src/OpcUaProtocol/Models/OpcUaSubscriptionOptions.cs new file mode 100644 index 0000000..d1b14a6 --- /dev/null +++ b/src/OpcUaProtocol/Models/OpcUaSubscriptionOptions.cs @@ -0,0 +1,41 @@ +// ============================================================================= +// MAS.Communication +// https://www.mas-automation.com/ +// +// Copyright 2026 MAS (厦门威光) Corporation +// +// Licensed under the Apache License, Version 2.0 +// See LICENSE file in the project root for full license information. +// ============================================================================= + +namespace MAS.Communication.OpcUaProtocol; + +/// +/// OPC UA 订阅参数 +/// +public sealed class OpcUaSubscriptionOptions { + /// + /// 获取或设置发布间隔(毫秒),默认 1000 + /// + public double PublishingInterval { get; set; } = 1000; + + /// + /// 获取或设置保活计数,默认 10 + /// + public uint KeepAliveCount { get; set; } = 10; + + /// + /// 获取或设置生命周期计数,默认 30 + /// + public uint LifetimeCount { get; set; } = 30; + + /// + /// 获取或设置单次发布的最大通知数量,0 表示不限制 + /// + public uint MaxNotificationsPerPublish { get; set; } + + /// + /// 获取或设置一个值,指示订阅创建后是否立即启用发布,默认 + /// + public bool PublishingEnabled { get; set; } = true; +} diff --git a/src/OpcUaProtocol/Models/OpcUaUserCredential.cs b/src/OpcUaProtocol/Models/OpcUaUserCredential.cs new file mode 100644 index 0000000..5f47d58 --- /dev/null +++ b/src/OpcUaProtocol/Models/OpcUaUserCredential.cs @@ -0,0 +1,22 @@ +// ============================================================================= +// MAS.Communication +// https://www.mas-automation.com/ +// +// Copyright 2026 MAS (厦门威光) Corporation +// +// Licensed under the Apache License, Version 2.0 +// See LICENSE file in the project root for full license information. +// ============================================================================= + +namespace MAS.Communication.OpcUaProtocol; + +/// +/// OPC UA 用户名/密码凭据 +/// +/// +/// 由 在建立会话时按需解析,凭据本身不会进入配置对象、 +/// 实例键、日志或异常信息 +/// +/// 用户名 +/// 密码;可为 表示空密码 +public sealed record OpcUaUserCredential(string UserName, string? Password); diff --git a/src/OpcUaProtocol/Models/OpcUaValue.cs b/src/OpcUaProtocol/Models/OpcUaValue.cs new file mode 100644 index 0000000..efc9e16 --- /dev/null +++ b/src/OpcUaProtocol/Models/OpcUaValue.cs @@ -0,0 +1,40 @@ +// ============================================================================= +// MAS.Communication +// https://www.mas-automation.com/ +// +// Copyright 2026 MAS (厦门威光) Corporation +// +// Licensed under the Apache License, Version 2.0 +// See LICENSE file in the project root for full license information. +// ============================================================================= + +namespace MAS.Communication.OpcUaProtocol; + +/// +/// OPC UA 读取结果 +/// +/// +/// OPC UA 中“请求成功”不代表每个节点都读取成功,因此结果必须携带逐项状态码与时间戳, +/// 以便调用方在批量读取时区分部分成功、部分失败的情况 +/// +/// 节点标识 +/// 读取到的值;读取失败时通常为 +/// OPC UA 状态码(原始 32 位无符号整数) +/// 数据源时间戳 +/// 服务器时间戳 +public sealed record OpcUaValue( + OpcUaNodeId NodeId, + object? Value, + uint StatusCode, + DateTime? SourceTimestamp, + DateTime? ServerTimestamp) { + /// + /// 获取一个值,指示该状态码是否表示“良好(Good)” + /// + public bool IsGood => (StatusCode & 0xC0000000u) == 0u; + + /// + /// 获取一个值,指示该状态码是否表示“错误(Bad)” + /// + public bool IsBad => (StatusCode & 0x80000000u) != 0u; +} diff --git a/src/OpcUaProtocol/Models/OpcUaWriteItem.cs b/src/OpcUaProtocol/Models/OpcUaWriteItem.cs new file mode 100644 index 0000000..a8f567f --- /dev/null +++ b/src/OpcUaProtocol/Models/OpcUaWriteItem.cs @@ -0,0 +1,18 @@ +// ============================================================================= +// MAS.Communication +// https://www.mas-automation.com/ +// +// Copyright 2026 MAS (厦门威光) Corporation +// +// Licensed under the Apache License, Version 2.0 +// See LICENSE file in the project root for full license information. +// ============================================================================= + +namespace MAS.Communication.OpcUaProtocol; + +/// +/// OPC UA 批量写入项 +/// +/// 要写入的节点标识 +/// 要写入的值 +public sealed record OpcUaWriteItem(OpcUaNodeId NodeId, object? Value); diff --git a/src/OpcUaProtocol/Models/OpcUaWriteResult.cs b/src/OpcUaProtocol/Models/OpcUaWriteResult.cs new file mode 100644 index 0000000..7571c5e --- /dev/null +++ b/src/OpcUaProtocol/Models/OpcUaWriteResult.cs @@ -0,0 +1,29 @@ +// ============================================================================= +// MAS.Communication +// https://www.mas-automation.com/ +// +// Copyright 2026 MAS (厦门威光) Corporation +// +// Licensed under the Apache License, Version 2.0 +// See LICENSE file in the project root for full license information. +// ============================================================================= + +namespace MAS.Communication.OpcUaProtocol; + +/// +/// OPC UA 批量写入的逐项结果 +/// +/// 批量写入允许部分成功、部分失败,因此每一项都会返回独立的状态码 +/// 写入的节点标识 +/// OPC UA 状态码(原始 32 位无符号整数) +public sealed record OpcUaWriteResult(OpcUaNodeId NodeId, uint StatusCode) { + /// + /// 获取一个值,指示该状态码是否表示“良好(Good)” + /// + public bool IsGood => (StatusCode & 0xC0000000u) == 0u; + + /// + /// 获取一个值,指示该状态码是否表示“错误(Bad)” + /// + public bool IsBad => (StatusCode & 0x80000000u) != 0u; +} diff --git a/src/OpcUaProtocol/OpcUaProtocolClient.cs b/src/OpcUaProtocol/OpcUaProtocolClient.cs new file mode 100644 index 0000000..ca32b32 --- /dev/null +++ b/src/OpcUaProtocol/OpcUaProtocolClient.cs @@ -0,0 +1,672 @@ +// ============================================================================= +// MAS.Communication +// https://www.mas-automation.com/ +// +// Copyright 2026 MAS (厦门威光) Corporation +// +// Licensed under the Apache License, Version 2.0 +// See LICENSE file in the project root for full license information. +// ============================================================================= + +using Opc.Ua; +using Opc.Ua.Client; +using System.Text; + +// TODO: 使用官方客户端的稳定同步友好重载;对应的遥测(ITelemetryContext)新 API 暂不在首版范围内 +#pragma warning disable CS0618 + +namespace MAS.Communication.OpcUaProtocol; + +/// +/// OPC UA 客户端实现,将官方 SDK 的会话/订阅/安全模型映射到统一的 生命周期 +/// +internal sealed class OpcUaProtocolClient(IOpcUaCommunicationConfig config) : IOpcUaProtocol { + private readonly IOpcUaCommunicationConfig _config = config.Clone(); + private readonly SemaphoreSlim _connectLock = new(1, 1); + private readonly object _reconnectLock = new(); + private readonly List _subscriptions = []; + + private ApplicationConfiguration? _appConfig; + private Session? _session; + private SessionReconnectHandler? _reconnectHandler; + private volatile bool _keepAliveOk; + private bool _disposed; + + public event EventHandler? Disposed; + + public CommProtocol ProtocolType => CommProtocol.OpcUa; + + public ICommunicationConfig Configuration => _config; + + private string InstanceKey => _config.GetInstanceKey(); + + public async Task ConnectAsync(CancellationToken cts = default) { + ThrowIfDisposed(); + + await _connectLock.WaitAsync(cts).ConfigureAwait(false); + try { + await EnsureApplicationAsync().ConfigureAwait(false); + await EstablishSessionAsync(cts).ConfigureAwait(false); + } catch (OperationCanceledException) { + throw; + } catch (ConnectionException) { + throw; + } catch (Exception ex) { + throw new ConnectionException($"{InstanceKey}: OPC UA 连接失败:{ex.Message}", ex); + } finally { + _ = _connectLock.Release(); + } + } + + public async Task ProbeConnectionAsync(CancellationToken cts = default) { + ThrowIfDisposed(); + + Session? probe = null; + try { + await _connectLock.WaitAsync(cts).ConfigureAwait(false); + try { + await EnsureApplicationAsync().ConfigureAwait(false); + } finally { + _ = _connectLock.Release(); + } + + ConfiguredEndpoint endpoint = await OpcUaEndpointSelector + .SelectAsync(_appConfig!, _config, cts).ConfigureAwait(false); + IUserIdentity identity = await BuildIdentityAsync(cts).ConfigureAwait(false); + + probe = await Session.Create( + _appConfig!, + endpoint, + false, + false, + _config.ApplicationName + "-probe", + (uint)_config.SessionTimeout, + identity, + null, + cts).ConfigureAwait(false); + + return true; + } catch (OperationCanceledException) { + throw; + } catch { + return false; + } finally { + if (probe is not null) { + try { + _ = await probe.CloseAsync(cts).ConfigureAwait(false); + } catch { + // 探测阶段忽略关闭异常 + } + + probe.Dispose(); + } + } + } + + public bool CheckConnection() { + Session? session = _session; + return session is { Connected: true, KeepAliveStopped: false } && _keepAliveOk; + } + + public async Task TryReconnectAsync(int maxAttempts, int retryDelay = 1000, CancellationToken cts = default) { + ThrowIfDisposed(); + + await _connectLock.WaitAsync(cts).ConfigureAwait(false); + try { + for (int attempt = 0; attempt < maxAttempts; attempt++) { + cts.ThrowIfCancellationRequested(); + + try { + await EnsureApplicationAsync().ConfigureAwait(false); + await EstablishSessionAsync(cts).ConfigureAwait(false); + + if (CheckConnection()) { + return true; + } + } catch (OperationCanceledException) { + throw; + } catch { + // 单次尝试失败,等待后重试 + } + + await Task.Delay(retryDelay, cts).ConfigureAwait(false); + } + + return false; + } finally { + _ = _connectLock.Release(); + } + } + + public void Disconnect() { + StopReconnect(); + + _connectLock.Wait(); + try { + CloseSessionInternal(); + } finally { + _ = _connectLock.Release(); + } + } + + public void Dispose() { + if (_disposed) { + return; + } + + _disposed = true; + StopReconnect(); + + List subscriptions; + lock (_subscriptions) { + subscriptions = [.. _subscriptions]; + _subscriptions.Clear(); + } + + foreach (OpcUaSubscription subscription in subscriptions) { + subscription.Closed -= OnSubscriptionClosed; + try { + subscription.Dispose(); + } catch { + // 释放阶段忽略订阅释放异常 + } + } + + CloseSessionInternal(); + _connectLock.Dispose(); + + Disposed?.Invoke(this, EventArgs.Empty); + } + + public async Task ReadAsync(OpcUaNodeId nodeId, CancellationToken cancellationToken = default) { + ThrowIfDisposed(); + Session session = GetActiveSession(); + + try { + ReadValueIdCollection toRead = [ + new ReadValueId { NodeId = OpcUaValueConverter.ToNodeId(nodeId), AttributeId = Attributes.Value } + ]; + + ReadResponse response = await session + .ReadAsync(null, 0, TimestampsToReturn.Both, toRead, cancellationToken) + .ConfigureAwait(false); + + OpcUaValue value = OpcUaValueConverter.ToOpcUaValue(nodeId, response.Results[0]); + if (value.IsBad) { + throw new ReadErrorException( + $"{InstanceKey}: 读取节点失败。NodeId={nodeId.Value}, StatusCode=0x{value.StatusCode:X8}。"); + } + + return value; + } catch (OperationCanceledException) { + throw; + } catch (ReadErrorException) { + throw; + } catch (Exception ex) { + throw new ReadErrorException($"{InstanceKey}: 读取节点失败。NodeId={nodeId.Value}:{ex.Message}", ex); + } + } + + public async Task ReadAsync(OpcUaNodeId nodeId, CancellationToken cancellationToken = default) { + OpcUaValue value = await ReadAsync(nodeId, cancellationToken).ConfigureAwait(false); + + try { + return OpcUaValueConverter.ConvertValue(value.Value); + } catch (Exception ex) { + throw new ReadErrorException( + $"{InstanceKey}: 读取节点值类型转换失败。NodeId={nodeId.Value}, TargetType={typeof(T).Name}:{ex.Message}", ex); + } + } + + public async Task> ReadAsync(IReadOnlyList items, CancellationToken cancellationToken = default) { + ThrowIfDisposed(); + Session session = GetActiveSession(); + + if (items.Count == 0) { + return []; + } + + try { + ReadValueIdCollection toRead = []; + foreach (OpcUaReadItem item in items) { + toRead.Add(new ReadValueId { NodeId = OpcUaValueConverter.ToNodeId(item.NodeId), AttributeId = Attributes.Value }); + } + + ReadResponse response = await session + .ReadAsync(null, 0, TimestampsToReturn.Both, toRead, cancellationToken) + .ConfigureAwait(false); + + DataValueCollection results = response.Results; + if (results is null || results.Count != items.Count) { + throw new ReadErrorException( + $"{InstanceKey}: 批量读取返回结果数量不匹配。Expected={items.Count}, Actual={results?.Count ?? 0}。"); + } + + List values = new(items.Count); + for (int i = 0; i < items.Count; i++) { + values.Add(OpcUaValueConverter.ToOpcUaValue(items[i].NodeId, results[i])); + } + + return values; + } catch (OperationCanceledException) { + throw; + } catch (ReadErrorException) { + throw; + } catch (Exception ex) { + throw new ReadErrorException($"{InstanceKey}: 批量读取失败。Count={items.Count}:{ex.Message}", ex); + } + } + + public async Task WriteAsync(OpcUaNodeId nodeId, object? value, CancellationToken cancellationToken = default) { + ThrowIfDisposed(); + Session session = GetActiveSession(); + + try { + WriteValueCollection toWrite = [ + new WriteValue { + NodeId = OpcUaValueConverter.ToNodeId(nodeId), + AttributeId = Attributes.Value, + Value = new DataValue(value is null ? Variant.Null : new Variant(value)) + } + ]; + + WriteResponse response = await session + .WriteAsync(null, toWrite, cancellationToken) + .ConfigureAwait(false); + + OpcUaWriteResult result = new(nodeId, response.Results[0].Code); + if (result.IsBad) { + throw new WriteErrorException( + $"{InstanceKey}: 写入节点失败。NodeId={nodeId.Value}, StatusCode=0x{result.StatusCode:X8}。"); + } + } catch (OperationCanceledException) { + throw; + } catch (WriteErrorException) { + throw; + } catch (Exception ex) { + throw new WriteErrorException($"{InstanceKey}: 写入节点失败。NodeId={nodeId.Value}:{ex.Message}", ex); + } + } + + public async Task> WriteAsync(IReadOnlyList items, CancellationToken cancellationToken = default) { + ThrowIfDisposed(); + Session session = GetActiveSession(); + + if (items.Count == 0) { + return []; + } + + try { + WriteValueCollection toWrite = []; + foreach (OpcUaWriteItem item in items) { + toWrite.Add(new WriteValue { + NodeId = OpcUaValueConverter.ToNodeId(item.NodeId), + AttributeId = Attributes.Value, + Value = new DataValue(item.Value is null ? Variant.Null : new Variant(item.Value)) + }); + } + + WriteResponse response = await session + .WriteAsync(null, toWrite, cancellationToken) + .ConfigureAwait(false); + + StatusCodeCollection results = response.Results; + if (results is null || results.Count != items.Count) { + throw new WriteErrorException( + $"{InstanceKey}: 批量写入返回结果数量不匹配。Expected={items.Count}, Actual={results?.Count ?? 0}。"); + } + + List writeResults = new(items.Count); + for (int i = 0; i < items.Count; i++) { + writeResults.Add(new OpcUaWriteResult(items[i].NodeId, results[i].Code)); + } + + return writeResults; + } catch (OperationCanceledException) { + throw; + } catch (WriteErrorException) { + throw; + } catch (Exception ex) { + throw new WriteErrorException($"{InstanceKey}: 批量写入失败。Count={items.Count}:{ex.Message}", ex); + } + } + + public async Task> BrowseAsync(OpcUaNodeId? parentNodeId = null, CancellationToken cancellationToken = default) { + ThrowIfDisposed(); + Session session = GetActiveSession(); + + try { + NodeId startNode = parentNodeId.HasValue + ? OpcUaValueConverter.ToNodeId(parentNodeId.Value) + : ObjectIds.ObjectsFolder; + + BrowseDescriptionCollection nodesToBrowse = [ + new BrowseDescription { + NodeId = startNode, + BrowseDirection = BrowseDirection.Forward, + ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences, + IncludeSubtypes = true, + NodeClassMask = 0, + ResultMask = (uint)BrowseResultMask.All + } + ]; + + BrowseResponse response = await session + .BrowseAsync(null, null, 0, nodesToBrowse, cancellationToken) + .ConfigureAwait(false); + + BrowseResult result = response.Results[0]; + if (OpcUaValueConverter.IsBad(result.StatusCode.Code)) { + throw new OpcUaServiceException( + $"{InstanceKey}: 浏览失败。StatusCode=0x{result.StatusCode.Code:X8}。", result.StatusCode.Code); + } + + List nodes = []; + foreach (ReferenceDescription reference in result.References) { + NodeId? targetNode = ExpandedNodeId.ToNodeId(reference.NodeId, session.NamespaceUris); + nodes.Add(new OpcUaBrowseNode( + new OpcUaNodeId(targetNode?.ToString() ?? reference.NodeId.ToString()), + reference.BrowseName?.Name ?? string.Empty, + reference.DisplayName?.Text ?? string.Empty, + OpcUaValueConverter.MapNodeClass(reference.NodeClass), + reference.IsForward)); + } + + return nodes; + } catch (OperationCanceledException) { + throw; + } catch (OpcUaServiceException) { + throw; + } catch (Exception ex) { + throw new OpcUaServiceException($"{InstanceKey}: 浏览失败:{ex.Message}", ex); + } + } + + public async Task CallMethodAsync( + OpcUaNodeId objectNodeId, + OpcUaNodeId methodNodeId, + IReadOnlyList inputArguments, + CancellationToken cancellationToken = default) { + ThrowIfDisposed(); + Session session = GetActiveSession(); + + try { + VariantCollection arguments = []; + foreach (object? argument in inputArguments) { + arguments.Add(argument is null ? Variant.Null : new Variant(argument)); + } + + CallMethodRequestCollection requests = [ + new CallMethodRequest { + ObjectId = OpcUaValueConverter.ToNodeId(objectNodeId), + MethodId = OpcUaValueConverter.ToNodeId(methodNodeId), + InputArguments = arguments + } + ]; + + CallResponse response = await session + .CallAsync(null, requests, cancellationToken) + .ConfigureAwait(false); + + CallMethodResult result = response.Results[0]; + + List outputs = []; + if (result.OutputArguments is not null) { + foreach (Variant output in result.OutputArguments) { + outputs.Add(output.Value); + } + } + + return new OpcUaMethodResult(result.StatusCode.Code, outputs); + } catch (OperationCanceledException) { + throw; + } catch (Exception ex) { + throw new OpcUaServiceException( + $"{InstanceKey}: 方法调用失败。Object={objectNodeId.Value}, Method={methodNodeId.Value}:{ex.Message}", ex); + } + } + + public async Task SubscribeAsync( + IReadOnlyList items, + OpcUaSubscriptionOptions options, + CancellationToken cancellationToken = default) { + ThrowIfDisposed(); + Session session = GetActiveSession(); + + OpcUaSubscription subscription = new(options, items); + try { + await subscription.AttachAsync(session, cancellationToken).ConfigureAwait(false); + } catch (OperationCanceledException) { + subscription.Dispose(); + throw; + } catch (Exception ex) { + subscription.Dispose(); + throw new OpcUaServiceException($"{InstanceKey}: 创建订阅失败:{ex.Message}", ex); + } + + subscription.Closed += OnSubscriptionClosed; + lock (_subscriptions) { + _subscriptions.Add(subscription); + } + + return subscription; + } + + #region 私有方法 + + private async Task EnsureApplicationAsync() { + if (_appConfig is not null) { + return; + } + + (_, _appConfig) = await OpcUaCertificateManager.CreateApplicationAsync(_config).ConfigureAwait(false); + } + + private async Task EstablishSessionAsync(CancellationToken cts) { + CloseSessionInternal(); + + ConfiguredEndpoint endpoint = await OpcUaEndpointSelector + .SelectAsync(_appConfig!, _config, cts).ConfigureAwait(false); + IUserIdentity identity = await BuildIdentityAsync(cts).ConfigureAwait(false); + + Session session = await Session.Create( + _appConfig!, + endpoint, + false, + false, + _config.ApplicationName, + (uint)_config.SessionTimeout, + identity, + null, + cts).ConfigureAwait(false); + + if (_config.KeepAliveInterval > 0) { + session.KeepAliveInterval = _config.KeepAliveInterval; + } + + session.KeepAlive += OnKeepAlive; + _session = session; + _keepAliveOk = true; + + List subscriptions; + lock (_subscriptions) { + subscriptions = [.. _subscriptions]; + } + + foreach (OpcUaSubscription subscription in subscriptions) { + await subscription.AttachAsync(session, cts).ConfigureAwait(false); + } + } + + private async Task BuildIdentityAsync(CancellationToken cts) { + switch (_config.IdentityType) { + case OpcUaIdentityType.UserName: + return await BuildUserNameIdentityAsync(cts).ConfigureAwait(false); + + case OpcUaIdentityType.Certificate: { + var certificate = await OpcUaCertificateManager + .LoadClientCertificateAsync(_appConfig!, _config).ConfigureAwait(false); + return new UserIdentity(certificate); + } + + case OpcUaIdentityType.Anonymous: + default: + return new UserIdentity(); + } + } + + private async Task BuildUserNameIdentityAsync(CancellationToken cts) { + string user = _config.UserName ?? string.Empty; + string password = string.Empty; + + bool canResolveCredential = _config.CredentialProvider is not null && !string.IsNullOrEmpty(_config.CredentialKey); + if (canResolveCredential) { + OpcUaUserCredential? credential = await _config.CredentialProvider! + .GetCredentialAsync(_config.CredentialKey!, cts).ConfigureAwait(false); + + if (credential is not null) { + user = string.IsNullOrEmpty(credential.UserName) ? user : credential.UserName; + password = credential.Password ?? string.Empty; + } + } + + return new UserIdentity(user, Encoding.UTF8.GetBytes(password)); + } + + private Session GetActiveSession() { + Session? session = _session; + if (session is null || !session.Connected) { + throw new ConnectionException($"{InstanceKey}: 会话未建立或已断开。"); + } + + return session; + } + + private void CloseSessionInternal() { + Session? session = _session; + _session = null; + _keepAliveOk = false; + + if (session is null) { + return; + } + + try { + session.KeepAlive -= OnKeepAlive; + } catch { + // 忽略事件解绑异常 + } + + try { + _ = session.Close(); + } catch { + // 忽略关闭异常 + } + + try { + session.Dispose(); + } catch { + // 忽略释放异常 + } + } + + private void OnKeepAlive(ISession session, KeepAliveEventArgs e) { + if (_disposed || !ReferenceEquals(session, _session)) { + return; + } + + if (ServiceResult.IsBad(e.Status)) { + _keepAliveOk = false; + BeginAutoReconnect(); + } else { + _keepAliveOk = true; + } + } + + private void BeginAutoReconnect() { + lock (_reconnectLock) { + if (_disposed || _reconnectHandler is not null || _session is null) { + return; + } + + _reconnectHandler = new SessionReconnectHandler(true); + int period = _config.ReconnectDelay > 0 ? _config.ReconnectDelay : 1000; + _ = _reconnectHandler.BeginReconnect(_session, period, OnReconnectComplete); + } + } + + private void OnReconnectComplete(object? sender, EventArgs e) { + lock (_reconnectLock) { + if (!ReferenceEquals(sender, _reconnectHandler) || _reconnectHandler is null) { + return; + } + + if (_reconnectHandler.Session is Session newSession && !ReferenceEquals(newSession, _session)) { + Session? old = _session; + if (old is not null) { + try { + old.KeepAlive -= OnKeepAlive; + } catch { + // 忽略事件解绑异常 + } + } + + if (_config.KeepAliveInterval > 0) { + newSession.KeepAliveInterval = _config.KeepAliveInterval; + } + + newSession.KeepAlive += OnKeepAlive; + _session = newSession; + } + + _reconnectHandler.Dispose(); + _reconnectHandler = null; + _keepAliveOk = true; + } + } + + private void StopReconnect() { + lock (_reconnectLock) { + if (_reconnectHandler is null) { + return; + } + + try { + _reconnectHandler.CancelReconnect(); + } catch { + // 忽略取消重连异常 + } + + try { + _reconnectHandler.Dispose(); + } catch { + // 忽略释放异常 + } + + _reconnectHandler = null; + } + } + + private void OnSubscriptionClosed(object? sender, EventArgs e) { + if (sender is not OpcUaSubscription subscription) { + return; + } + + subscription.Closed -= OnSubscriptionClosed; + lock (_subscriptions) { + _ = _subscriptions.Remove(subscription); + } + } + + private void ThrowIfDisposed() { +#if !NETFRAMEWORK + ObjectDisposedException.ThrowIf(_disposed, this); +#else + if (_disposed) { + throw new ObjectDisposedException(GetType().FullName); + } +#endif + } + + #endregion +} diff --git a/src/OpcUaProtocol/OpcUaSubscription.cs b/src/OpcUaProtocol/OpcUaSubscription.cs new file mode 100644 index 0000000..bc8959f --- /dev/null +++ b/src/OpcUaProtocol/OpcUaSubscription.cs @@ -0,0 +1,238 @@ +// ============================================================================= +// MAS.Communication +// https://www.mas-automation.com/ +// +// Copyright 2026 MAS (厦门威光) Corporation +// +// Licensed under the Apache License, Version 2.0 +// See LICENSE file in the project root for full license information. +// ============================================================================= + +using System.Collections.Concurrent; +using Opc.Ua; +using Opc.Ua.Client; + +// 使用官方客户端的稳定同步友好重载;对应的遥测(ITelemetryContext)新 API 暂不在首版范围内 +#pragma warning disable CS0618 + +namespace MAS.Communication.OpcUaProtocol; + +/// +/// 的内部实现 +/// +/// +/// SDK 的发布回调线程只负责把数据变化入队,实际的 事件由内部的 +/// 派发线程触发,避免用户业务处理阻塞 OPC UA 的 Publish 流程 +/// +internal sealed class OpcUaSubscription : IOpcUaSubscription { + private readonly OpcUaSubscriptionOptions _options; + private readonly Dictionary _specs = new(StringComparer.Ordinal); + private readonly BlockingCollection _queue = []; + private readonly Task _dispatchTask; + private readonly SemaphoreSlim _lock = new(1, 1); + + private Subscription? _subscription; + private bool _disposed; + + public string Id { get; } + + public event EventHandler? DataChanged; + + /// + /// 当订阅被删除或释放时触发,供客户端从跟踪列表中移除该订阅 + /// + internal event EventHandler? Closed; + + public OpcUaSubscription(OpcUaSubscriptionOptions options, IReadOnlyList items) { + _options = options; + Id = "opcua-sub-" + Guid.NewGuid().ToString("N"); + + foreach (OpcUaMonitoredItem item in items) { + _specs[item.NodeId.Value] = item; + } + + _dispatchTask = Task.Factory.StartNew( + DispatchLoop, + CancellationToken.None, + TaskCreationOptions.LongRunning, + TaskScheduler.Default); + } + + /// + /// 在指定会话上创建(或在重连后重建)服务端订阅及其监控项 + /// + internal async Task AttachAsync(Session session, CancellationToken cancellationToken) { + await _lock.WaitAsync(cancellationToken).ConfigureAwait(false); + try { + Subscription subscription = new(session.DefaultSubscription) { + DisplayName = Id, + PublishingInterval = (int)_options.PublishingInterval, + KeepAliveCount = _options.KeepAliveCount, + LifetimeCount = _options.LifetimeCount, + MaxNotificationsPerPublish = _options.MaxNotificationsPerPublish, + PublishingEnabled = _options.PublishingEnabled + }; + + _ = session.AddSubscription(subscription); + await subscription.CreateAsync(cancellationToken).ConfigureAwait(false); + + foreach (OpcUaMonitoredItem spec in _specs.Values) { + subscription.AddItem(CreateMonitoredItem(spec)); + } + + await subscription.ApplyChangesAsync(cancellationToken).ConfigureAwait(false); + _subscription = subscription; + } finally { + _ = _lock.Release(); + } + } + + public async Task AddItemsAsync(IReadOnlyList items, CancellationToken cancellationToken = default) { + await _lock.WaitAsync(cancellationToken).ConfigureAwait(false); + try { + EnsureUsable(); + + foreach (OpcUaMonitoredItem spec in items) { + _specs[spec.NodeId.Value] = spec; + _subscription!.AddItem(CreateMonitoredItem(spec)); + } + + await _subscription!.ApplyChangesAsync(cancellationToken).ConfigureAwait(false); + } catch (OperationCanceledException) { + throw; + } catch (Exception ex) when (ex is not ObjectDisposedException) { + throw new OpcUaServiceException($"{Id}: 添加监控项失败:{ex.Message}", ex); + } finally { + _ = _lock.Release(); + } + } + + public async Task RemoveItemsAsync(IReadOnlyList nodeIds, CancellationToken cancellationToken = default) { + await _lock.WaitAsync(cancellationToken).ConfigureAwait(false); + try { + EnsureUsable(); + + HashSet targets = [.. nodeIds.Select(n => n.Value)]; + List toRemove = [.. _subscription!.MonitoredItems.Where(mi => targets.Contains(mi.DisplayName))]; + + foreach (MonitoredItem item in toRemove) { + item.Notification -= OnNotification; + _subscription.RemoveItem(item); + } + + foreach (OpcUaNodeId nodeId in nodeIds) { + _ = _specs.Remove(nodeId.Value); + } + + await _subscription.ApplyChangesAsync(cancellationToken).ConfigureAwait(false); + } catch (OperationCanceledException) { + throw; + } catch (Exception ex) when (ex is not ObjectDisposedException) { + throw new OpcUaServiceException($"{Id}: 移除监控项失败:{ex.Message}", ex); + } finally { + _ = _lock.Release(); + } + } + + public async Task DeleteAsync(CancellationToken cancellationToken = default) { + await _lock.WaitAsync(cancellationToken).ConfigureAwait(false); + try { + if (_disposed) { + return; + } + + Subscription? subscription = _subscription; + ISession? session = subscription?.Session; + if (subscription is not null && session is not null) { + _ = await session.RemoveSubscriptionAsync(subscription, cancellationToken).ConfigureAwait(false); + } + } catch (OperationCanceledException) { + throw; + } catch (Exception ex) { + throw new OpcUaServiceException($"{Id}: 删除订阅失败:{ex.Message}", ex); + } finally { + _ = _lock.Release(); + } + + Dispose(); + } + + public void Dispose() { + if (_disposed) { + return; + } + + _disposed = true; + + try { + Subscription? subscription = _subscription; + subscription?.DeleteAsync(true).GetAwaiter().GetResult(); + } catch { + // 释放阶段忽略删除失败 + } + + _queue.CompleteAdding(); + try { + _ = _dispatchTask.Wait(TimeSpan.FromSeconds(2)); + } catch { + // 忽略派发线程等待异常 + } + + _queue.Dispose(); + _lock.Dispose(); + + Closed?.Invoke(this, EventArgs.Empty); + } + + private MonitoredItem CreateMonitoredItem(OpcUaMonitoredItem spec) { + MonitoredItem item = new() { + StartNodeId = OpcUaValueConverter.ToNodeId(spec.NodeId), + AttributeId = Attributes.Value, + DisplayName = spec.NodeId.Value, + SamplingInterval = (int)spec.SamplingInterval, + QueueSize = spec.QueueSize, + DiscardOldest = spec.DiscardOldest + }; + + item.Notification += OnNotification; + return item; + } + + private void OnNotification(MonitoredItem monitoredItem, MonitoredItemNotificationEventArgs e) { + OpcUaNodeId nodeId = new(monitoredItem.DisplayName); + + foreach (DataValue value in monitoredItem.DequeueValues()) { + OpcUaValue opcValue = OpcUaValueConverter.ToOpcUaValue(nodeId, value); + if (!_queue.IsAddingCompleted) { + try { + _queue.Add(new OpcUaDataChangedEventArgs(Id, opcValue)); + } catch (InvalidOperationException) { + // 队列已关闭,忽略 + } + } + } + } + + private void DispatchLoop() { + foreach (OpcUaDataChangedEventArgs args in _queue.GetConsumingEnumerable()) { + try { + DataChanged?.Invoke(this, args); + } catch { + // 用户事件处理异常不应影响派发线程 + } + } + } + + private void EnsureUsable() { +#if !NETFRAMEWORK + ObjectDisposedException.ThrowIf(_disposed, this); +#else + if (_disposed) { + throw new ObjectDisposedException(GetType().FullName); + } +#endif + if (_subscription is null) { + throw new OpcUaServiceException($"{Id}: 订阅尚未创建。"); + } + } +} diff --git a/src/Polyfills/CompilerPolyfills.cs b/src/Polyfills/CompilerPolyfills.cs new file mode 100644 index 0000000..67e4763 --- /dev/null +++ b/src/Polyfills/CompilerPolyfills.cs @@ -0,0 +1,21 @@ +// ============================================================================= +// MAS.Communication +// https://www.mas-automation.com/ +// +// Copyright 2026 MAS (厦门威光) Corporation +// +// Licensed under the Apache License, Version 2.0 +// See LICENSE file in the project root for full license information. +// ============================================================================= + +#if NETFRAMEWORK + +namespace System.Runtime.CompilerServices; + +/// +/// 为 .NET Framework 目标提供 init 访问器与 record 类型所需的编译器占位类型 +/// +/// 该类型在现代 .NET 运行时中已由 BCL 提供,仅在缺失时补齐 +internal static class IsExternalInit { } + +#endif diff --git a/src/README.md b/src/README.md index ae9e132..084b2ba 100644 --- a/src/README.md +++ b/src/README.md @@ -4,7 +4,7 @@ 一个面向工业自动化场景的 **多协议通信管理框架** -- **多协议统一抽象**:Modbus TCP、MC Protocol、等协议采用一致的调用模型 +- **多协议统一抽象**:Modbus TCP、MC Protocol、S7、OPC UA 等协议采用一致的生命周期与实例管理模型 - **多实例管理**:支持同协议多实例并存,适用于一机多 PLC / 多设备通信场景 - **统一生命周期控制**:统一创建、复用、查询与释放通信实例,避免资源失控 - **依赖注入集成**:基于 `Microsoft.Extensions.DependencyInjection`,便于工程化接入 @@ -47,9 +47,13 @@ services.AddCommunication(); - MC 协议使用:`IMcCommunicationConfig` - Modbus 协议使用:`IModbusCommunicationConfig` +- S7 协议使用:`IS7CommunicationConfig` +- OPC UA(Client)使用:`IOpcUaCommunicationConfig` 以上接口均继承自 `ICommunicationConfig`,调用方实现对应接口后,即可将配置对象传入 `IProtocolManager`,用于创建或获取协议实例 +> OPC UA 保持自己的“节点、会话、订阅、安全”模型,业务能力通过专属接口 `IOpcUaProtocol` 暴露,而非地址读写接口 + ### 使用方式 通信框架通过`IProtocolManager`提供统一入口,推荐通过依赖注入方式获取并使用: diff --git a/tests/OpcUaProtocol/Models/OpcUaTestConfig.cs b/tests/OpcUaProtocol/Models/OpcUaTestConfig.cs new file mode 100644 index 0000000..f8c9bd5 --- /dev/null +++ b/tests/OpcUaProtocol/Models/OpcUaTestConfig.cs @@ -0,0 +1,78 @@ +// ============================================================================= +// MAS.Communication +// https://www.mas-automation.com/ +// +// Copyright 2026 MAS (厦门威光) Corporation +// +// Licensed under the Apache License, Version 2.0 +// See LICENSE file in the project root for full license information. +// ============================================================================= + +using MAS.Communication; +using MAS.Communication.OpcUaProtocol; + +namespace MAS.CommunicationUnitTest.OpcUaProtocol.Models; + +/// +/// 用于测试的 OPC UA 配置实现,同时演示调用方应如何实现 与 Clone +/// +public sealed class OpcUaTestConfig : IOpcUaCommunicationConfig { + public string ProtocolName => "OpcUa"; + + public string Ip { get; set; } = "127.0.0.1"; + public int MaxRetries { get; set; } = 3; + public int ReadTimeout { get; set; } = 5000; + public int WriteTimeout { get; set; } = 5000; + + public string EndpointUrl { get; set; } = "opc.tcp://127.0.0.1:4840"; + public string ApplicationName { get; set; } = "MAS.Communication.Tests"; + public string ApplicationUri { get; set; } = "urn:localhost:MAS:Communication:Tests"; + + public OpcUaSecurityMode SecurityMode { get; set; } = OpcUaSecurityMode.None; + public string SecurityPolicyUri { get; set; } = string.Empty; + + public OpcUaIdentityType IdentityType { get; set; } = OpcUaIdentityType.Anonymous; + public string? UserName { get; set; } + public string? CredentialKey { get; set; } + public IOpcUaCredentialProvider? CredentialProvider { get; set; } + + public string? ClientCertificateThumbprint { get; set; } + public string CertificateStorePath { get; set; } = string.Empty; + + public bool AutoAcceptUntrustedCertificates { get; set; } + + public int SessionTimeout { get; set; } = 60000; + public int KeepAliveInterval { get; set; } = 5000; + public int ReconnectDelay { get; set; } = 1000; + + public bool UseEndpointDiscovery { get; set; } = true; + + public T Clone() where T : ICommunicationConfig { + return (T)Clone(); + } + + public object Clone() { + return new OpcUaTestConfig { + Ip = Ip, + MaxRetries = MaxRetries, + ReadTimeout = ReadTimeout, + WriteTimeout = WriteTimeout, + EndpointUrl = EndpointUrl, + ApplicationName = ApplicationName, + ApplicationUri = ApplicationUri, + SecurityMode = SecurityMode, + SecurityPolicyUri = SecurityPolicyUri, + IdentityType = IdentityType, + UserName = UserName, + CredentialKey = CredentialKey, + CredentialProvider = CredentialProvider, + ClientCertificateThumbprint = ClientCertificateThumbprint, + CertificateStorePath = CertificateStorePath, + AutoAcceptUntrustedCertificates = AutoAcceptUntrustedCertificates, + SessionTimeout = SessionTimeout, + KeepAliveInterval = KeepAliveInterval, + ReconnectDelay = ReconnectDelay, + UseEndpointDiscovery = UseEndpointDiscovery + }; + } +} diff --git a/tests/OpcUaProtocol/OpcUaConnectionTest.cs b/tests/OpcUaProtocol/OpcUaConnectionTest.cs new file mode 100644 index 0000000..9f7d9fa --- /dev/null +++ b/tests/OpcUaProtocol/OpcUaConnectionTest.cs @@ -0,0 +1,150 @@ +// ============================================================================= +// MAS.Communication +// https://www.mas-automation.com/ +// +// Copyright 2026 MAS (厦门威光) Corporation +// +// Licensed under the Apache License, Version 2.0 +// See LICENSE file in the project root for full license information. +// ============================================================================= + +using MAS.Communication; +using MAS.Communication.OpcUaProtocol; +using MAS.CommunicationUnitTest.OpcUaProtocol.Models; + +namespace MAS.CommunicationUnitTest.OpcUaProtocol; + +/// +/// 需要真实 OPC UA 服务端的集成测试;未配置或无法连接时将标记为 Inconclusive。 +/// 通过环境变量 MAS_OPCUA_ENDPOINT 指定端点地址,默认 opc.tcp://localhost:4840。 +/// +[TestClass] +[DoNotParallelize] +public sealed class OpcUaConnectionTest : IDisposable { + // OPC UA 标准节点:Server_ServerStatus_CurrentTime,命名空间 0,任意服务端均存在 + private const string CURRENT_TIME_NODE = "i=2258"; + + private readonly OpcUaTestConfig _config; + private readonly OpcUaProtocolClient _protocol; + private bool _disposed; + + public TestContext TestContext { get; set; } + + public OpcUaConnectionTest() { + string endpoint = Environment.GetEnvironmentVariable("MAS_OPCUA_ENDPOINT") ?? "opc.tcp://localhost:4840"; + + _config = new OpcUaTestConfig { + EndpointUrl = endpoint, + SecurityMode = OpcUaSecurityMode.None, + IdentityType = OpcUaIdentityType.Anonymous, + AutoAcceptUntrustedCertificates = true, + UseEndpointDiscovery = true + }; + + _protocol = new OpcUaProtocolClient(_config); + } + + [TestInitialize] + public async Task Initialize() { + TestContext.WriteLine($"尝试连接 OPC UA 服务端:{_config.EndpointUrl}"); + + try { + await _protocol.ConnectAsync(TestContext.CancellationTokenSource.Token).ConfigureAwait(false); + } catch (ConnectionException ex) { + Assert.Inconclusive($"{ex.Message},测试结束"); + } + + if (!_protocol.CheckConnection()) { + Assert.Inconclusive("未能连接到 OPC UA 服务端,测试结束"); + } + } + + [TestMethod] + public async Task 读取标准节点当前时间() { + OpcUaValue value = await _protocol + .ReadAsync(new OpcUaNodeId(CURRENT_TIME_NODE), TestContext.CancellationTokenSource.Token) + .ConfigureAwait(false); + + TestContext.WriteLine($"读取到 CurrentTime = {value.Value},StatusCode = 0x{value.StatusCode:X8}"); + Assert.IsTrue(value.IsGood, "读取标准节点应返回良好状态码"); + Assert.IsInstanceOfType(value.Value); + } + + [TestMethod] + public async Task 泛型读取转换为DateTime() { + DateTime time = await _protocol + .ReadAsync(new OpcUaNodeId(CURRENT_TIME_NODE), TestContext.CancellationTokenSource.Token) + .ConfigureAwait(false); + + Assert.AreNotEqual(default, time); + } + + [TestMethod] + public async Task 批量读取返回逐项状态() { + IReadOnlyList items = [ + new OpcUaReadItem(new OpcUaNodeId(CURRENT_TIME_NODE)), + new OpcUaReadItem(new OpcUaNodeId("ns=0;s=不存在的节点")) + ]; + + IReadOnlyList results = await _protocol + .ReadAsync(items, TestContext.CancellationTokenSource.Token) + .ConfigureAwait(false); + + Assert.HasCount(2, results); + Assert.IsTrue(results[0].IsGood, "有效节点应返回良好状态"); + Assert.IsTrue(results[1].IsBad, "无效节点应返回错误状态,而不是让整个请求失败"); + } + + [TestMethod] + public async Task 浏览根对象文件夹() { + IReadOnlyList nodes = await _protocol + .BrowseAsync(cancellationToken: TestContext.CancellationTokenSource.Token) + .ConfigureAwait(false); + + TestContext.WriteLine($"浏览到 {nodes.Count} 个子节点"); + Assert.IsGreaterThan(0, nodes.Count, "Objects 文件夹下应至少存在一个子节点(例如 Server)"); + } + + [TestMethod] + public async Task 订阅数据变化() { + using SemaphoreSlim received = new(0, 1); + OpcUaValue? latest = null; + + IReadOnlyList items = [ + new OpcUaMonitoredItem(new OpcUaNodeId(CURRENT_TIME_NODE)) { SamplingInterval = 500 } + ]; + + IOpcUaSubscription subscription = await _protocol + .SubscribeAsync(items, new OpcUaSubscriptionOptions { PublishingInterval = 500 }, TestContext.CancellationTokenSource.Token) + .ConfigureAwait(false); + + try { + subscription.DataChanged += (_, e) => { + latest = e.Value; + try { + _ = received.Release(); + } catch (SemaphoreFullException) { + // 已收到首个通知,忽略后续 + } + }; + + bool signaled = await received.WaitAsync(TimeSpan.FromSeconds(10), TestContext.CancellationTokenSource.Token).ConfigureAwait(false); + + Assert.IsTrue(signaled, "应在超时前收到至少一次数据变化通知"); + Assert.IsNotNull(latest); + Assert.IsTrue(latest!.IsGood); + } finally { + await subscription.DeleteAsync(TestContext.CancellationTokenSource.Token).ConfigureAwait(false); + } + } + + public void Dispose() { + if (_disposed) { + return; + } + + _protocol.Dispose(); + _disposed = true; + GC.SuppressFinalize(this); + } +} diff --git a/tests/OpcUaProtocol/OpcUaInstanceKeyTest.cs b/tests/OpcUaProtocol/OpcUaInstanceKeyTest.cs new file mode 100644 index 0000000..209080f --- /dev/null +++ b/tests/OpcUaProtocol/OpcUaInstanceKeyTest.cs @@ -0,0 +1,85 @@ +// ============================================================================= +// MAS.Communication +// https://www.mas-automation.com/ +// +// Copyright 2026 MAS (厦门威光) Corporation +// +// Licensed under the Apache License, Version 2.0 +// See LICENSE file in the project root for full license information. +// ============================================================================= + +using MAS.Communication; +using MAS.Communication.OpcUaProtocol; +using MAS.CommunicationUnitTest.OpcUaProtocol.Models; + +namespace MAS.CommunicationUnitTest.OpcUaProtocol; + +[TestClass] +public sealed class OpcUaInstanceKeyTest { + [TestMethod] + public void 匿名身份实例键格式() { + OpcUaTestConfig config = new() { + EndpointUrl = "opc.tcp://192.168.1.10:4840/Server", + SecurityMode = OpcUaSecurityMode.None, + SecurityPolicyUri = string.Empty, + IdentityType = OpcUaIdentityType.Anonymous + }; + + string key = config.GetInstanceKey(); + + Assert.AreEqual("OpcUa|opc.tcp://192.168.1.10:4840/server|None||Anonymous|", key); + } + + [TestMethod] + public void 用户名身份使用用户名作为身份标识() { + OpcUaTestConfig config = new() { + EndpointUrl = "opc.tcp://192.168.1.10:4840/server", + SecurityMode = OpcUaSecurityMode.SignAndEncrypt, + SecurityPolicyUri = "Basic256Sha256", + IdentityType = OpcUaIdentityType.UserName, + UserName = "operator", + CredentialKey = "vault://opcua/secret", + ReadTimeout = 3000 + }; + + string key = config.GetInstanceKey(); + + Assert.AreEqual("OpcUa|opc.tcp://192.168.1.10:4840/server|SignAndEncrypt|Basic256Sha256|UserName|operator", key); + } + + [TestMethod] + public void 证书身份使用指纹作为身份标识() { + OpcUaTestConfig config = new() { + EndpointUrl = "opc.tcp://plc:4840", + SecurityMode = OpcUaSecurityMode.Sign, + SecurityPolicyUri = "Basic256Sha256", + IdentityType = OpcUaIdentityType.Certificate, + ClientCertificateThumbprint = "ABCDEF0123456789" + }; + + string key = config.GetInstanceKey(); + + Assert.AreEqual("OpcUa|opc.tcp://plc:4840|Sign|Basic256Sha256|Certificate|ABCDEF0123456789", key); + } + + [TestMethod] + public void 实例键不包含凭据引用键() { + OpcUaTestConfig config = new() { + IdentityType = OpcUaIdentityType.UserName, + UserName = "operator", + CredentialKey = "super-secret-credential-key" + }; + + string key = config.GetInstanceKey(); + + Assert.IsFalse(key.Contains("super-secret-credential-key", StringComparison.Ordinal)); + } + + [TestMethod] + public void 端点地址规范化_去除尾部斜杠并转小写() { + OpcUaTestConfig withSlash = new() { EndpointUrl = "OPC.TCP://Server:4840/Path/" }; + OpcUaTestConfig withoutSlash = new() { EndpointUrl = "opc.tcp://server:4840/path" }; + + Assert.AreEqual(withoutSlash.GetInstanceKey(), withSlash.GetInstanceKey()); + } +} diff --git a/tests/OpcUaProtocol/OpcUaManagerTest.cs b/tests/OpcUaProtocol/OpcUaManagerTest.cs new file mode 100644 index 0000000..14d3c05 --- /dev/null +++ b/tests/OpcUaProtocol/OpcUaManagerTest.cs @@ -0,0 +1,92 @@ +// ============================================================================= +// MAS.Communication +// https://www.mas-automation.com/ +// +// Copyright 2026 MAS (厦门威光) Corporation +// +// Licensed under the Apache License, Version 2.0 +// See LICENSE file in the project root for full license information. +// ============================================================================= + +using MAS.Communication; +using MAS.Communication.OpcUaProtocol; +using MAS.CommunicationUnitTest.OpcUaProtocol.Models; +using Microsoft.Extensions.DependencyInjection; + +namespace MAS.CommunicationUnitTest.OpcUaProtocol; + +[TestClass] +public sealed class OpcUaManagerTest { + private static IProtocolManager CreateManager() { + ServiceCollection services = new(); + _ = services.AddCommunication(); + return services.BuildServiceProvider().GetRequiredService(); + } + + [TestMethod] + public void 管理器为OPC_UA配置创建强类型实例() { + using IProtocolManager manager = CreateManager(); + OpcUaTestConfig config = new(); + + IOpcUaProtocol protocol = manager.GetOrCreate(config); + + Assert.IsNotNull(protocol); + Assert.AreEqual(CommProtocol.OpcUa, protocol.ProtocolType); + } + + [TestMethod] + public void 相同配置复用同一实例() { + using IProtocolManager manager = CreateManager(); + OpcUaTestConfig config = new(); + + IProtocol first = manager.GetOrCreate(config); + IProtocol second = manager.GetOrCreate(config); + + Assert.AreSame(first, second); + Assert.AreEqual(1, manager.Count); + } + + [TestMethod] + public void 不同端点创建不同实例() { + using IProtocolManager manager = CreateManager(); + OpcUaTestConfig first = new() { EndpointUrl = "opc.tcp://server-a:4840" }; + OpcUaTestConfig second = new() { EndpointUrl = "opc.tcp://server-b:4840" }; + + _ = manager.GetOrCreate(first); + _ = manager.GetOrCreate(second); + + Assert.AreEqual(2, manager.Count); + } + + [TestMethod] + public void 配置在创建后被克隆为快照() { + using IProtocolManager manager = CreateManager(); + OpcUaTestConfig config = new() { EndpointUrl = "opc.tcp://server:4840" }; + + IProtocol protocol = manager.GetOrCreate(config); + string keyBefore = protocol.Configuration.GetInstanceKey(); + + // 调用方在创建后修改原始配置,不应影响已缓存实例的实例键 + config.EndpointUrl = "opc.tcp://another-server:9999"; + + string keyAfter = protocol.Configuration.GetInstanceKey(); + Assert.AreEqual(keyBefore, keyAfter); + + // 由于实例持有快照,仍可通过原始配置正确移除 + Assert.IsTrue(manager.Remove(protocol)); + Assert.AreEqual(0, manager.Count); + } + + [TestMethod] + public void 释放实例后从管理器缓存移除() { + using IProtocolManager manager = CreateManager(); + OpcUaTestConfig config = new(); + + IProtocol protocol = manager.GetOrCreate(config); + Assert.AreEqual(1, manager.Count); + + protocol.Dispose(); + + Assert.AreEqual(0, manager.Count); + } +} diff --git a/tests/OpcUaProtocol/OpcUaModelsTest.cs b/tests/OpcUaProtocol/OpcUaModelsTest.cs new file mode 100644 index 0000000..f88bbc3 --- /dev/null +++ b/tests/OpcUaProtocol/OpcUaModelsTest.cs @@ -0,0 +1,96 @@ +// ============================================================================= +// MAS.Communication +// https://www.mas-automation.com/ +// +// Copyright 2026 MAS (厦门威光) Corporation +// +// Licensed under the Apache License, Version 2.0 +// See LICENSE file in the project root for full license information. +// ============================================================================= + +using MAS.Communication.OpcUaProtocol; + +namespace MAS.CommunicationUnitTest.OpcUaProtocol; + +[TestClass] +public sealed class OpcUaModelsTest { + private const uint GOOD = 0x00000000; + private const uint UNCERTAIN = 0x40000000; + private const uint BAD_NODE_ID_UNKNOWN = 0x80340000; + + [TestMethod] + public void OpcUaValue_状态码判定() { + OpcUaValue good = new(new OpcUaNodeId("ns=2;s=A"), 1, GOOD, null, null); + OpcUaValue uncertain = new(new OpcUaNodeId("ns=2;s=B"), 1, UNCERTAIN, null, null); + OpcUaValue bad = new(new OpcUaNodeId("ns=2;s=C"), null, BAD_NODE_ID_UNKNOWN, null, null); + + Assert.IsTrue(good.IsGood); + Assert.IsFalse(good.IsBad); + + Assert.IsFalse(uncertain.IsGood); + Assert.IsFalse(uncertain.IsBad); + + Assert.IsFalse(bad.IsGood); + Assert.IsTrue(bad.IsBad); + } + + [TestMethod] + public void OpcUaWriteResult_状态码判定() { + OpcUaWriteResult good = new(new OpcUaNodeId("ns=2;s=A"), GOOD); + OpcUaWriteResult bad = new(new OpcUaNodeId("ns=2;s=B"), BAD_NODE_ID_UNKNOWN); + + Assert.IsTrue(good.IsGood); + Assert.IsTrue(bad.IsBad); + Assert.IsFalse(bad.IsGood); + } + + [TestMethod] + public void OpcUaMethodResult_状态码判定() { + OpcUaMethodResult result = new(GOOD, [1, "abc"]); + + Assert.IsTrue(result.IsGood); + Assert.HasCount(2, result.OutputArguments); + Assert.AreEqual(1, result.OutputArguments[0]); + Assert.AreEqual("abc", result.OutputArguments[1]); + } + + [TestMethod] + public void OpcUaNodeId_文本与隐式转换() { + OpcUaNodeId explicitId = new("ns=3;i=1001"); + OpcUaNodeId implicitId = "ns=3;i=1001"; + + Assert.AreEqual("ns=3;i=1001", explicitId.Value); + Assert.AreEqual("ns=3;i=1001", explicitId.ToString()); + Assert.AreEqual(explicitId, implicitId); + } + + [TestMethod] + public void OpcUaSubscriptionOptions_默认值() { + OpcUaSubscriptionOptions options = new(); + + Assert.AreEqual(1000, options.PublishingInterval); + Assert.AreEqual(10u, options.KeepAliveCount); + Assert.AreEqual(30u, options.LifetimeCount); + Assert.AreEqual(0u, options.MaxNotificationsPerPublish); + Assert.IsTrue(options.PublishingEnabled); + } + + [TestMethod] + public void OpcUaMonitoredItem_默认值与节点标识() { + OpcUaMonitoredItem item = new("ns=2;s=Machine.Speed") { + SamplingInterval = 500, + QueueSize = 5, + DiscardOldest = false + }; + + Assert.AreEqual("ns=2;s=Machine.Speed", item.NodeId.Value); + Assert.AreEqual(500, item.SamplingInterval); + Assert.AreEqual(5u, item.QueueSize); + Assert.IsFalse(item.DiscardOldest); + + OpcUaMonitoredItem defaults = new("ns=2;s=X"); + Assert.AreEqual(1000, defaults.SamplingInterval); + Assert.AreEqual(1u, defaults.QueueSize); + Assert.IsTrue(defaults.DiscardOldest); + } +} diff --git a/tests/OpcUaProtocol/OpcUaValueConverterTest.cs b/tests/OpcUaProtocol/OpcUaValueConverterTest.cs new file mode 100644 index 0000000..1383d1f --- /dev/null +++ b/tests/OpcUaProtocol/OpcUaValueConverterTest.cs @@ -0,0 +1,91 @@ +// ============================================================================= +// MAS.Communication +// https://www.mas-automation.com/ +// +// Copyright 2026 MAS (厦门威光) Corporation +// +// Licensed under the Apache License, Version 2.0 +// See LICENSE file in the project root for full license information. +// ============================================================================= + +using MAS.Communication.OpcUaProtocol; +using Opc.Ua; + +namespace MAS.CommunicationUnitTest.OpcUaProtocol; + +[TestClass] +public sealed class OpcUaValueConverterTest { + [TestMethod] + public void ToNodeId_解析标准文本格式() { + NodeId stringNode = OpcUaValueConverter.ToNodeId(new OpcUaNodeId("ns=2;s=Machine.Speed")); + Assert.AreEqual(2, stringNode.NamespaceIndex); + Assert.AreEqual("Machine.Speed", stringNode.Identifier); + + NodeId numericNode = OpcUaValueConverter.ToNodeId(new OpcUaNodeId("ns=3;i=1001")); + Assert.AreEqual(3, numericNode.NamespaceIndex); + Assert.AreEqual((uint)1001, numericNode.Identifier); + } + + [TestMethod] + public void ToNodeId_空标识抛出异常() { + _ = Assert.ThrowsExactly(() => _ = OpcUaValueConverter.ToNodeId(new OpcUaNodeId(string.Empty))); + } + + [TestMethod] + public void ToOpcUaValue_携带状态码与时间戳() { + DateTime source = new(2026, 7, 14, 8, 0, 0, DateTimeKind.Utc); + DateTime server = new(2026, 7, 14, 8, 0, 1, DateTimeKind.Utc); + DataValue dataValue = new(new Variant(123)) { + StatusCode = StatusCodes.Good, + SourceTimestamp = source, + ServerTimestamp = server + }; + + OpcUaValue value = OpcUaValueConverter.ToOpcUaValue(new OpcUaNodeId("ns=2;s=A"), dataValue); + + Assert.AreEqual(123, value.Value); + Assert.AreEqual(StatusCodes.Good, value.StatusCode); + Assert.IsTrue(value.IsGood); + Assert.AreEqual(source, value.SourceTimestamp); + Assert.AreEqual(server, value.ServerTimestamp); + } + + [TestMethod] + public void ToOpcUaValue_默认时间戳映射为Null() { + DataValue dataValue = new(new Variant(1)) { StatusCode = StatusCodes.Good }; + + OpcUaValue value = OpcUaValueConverter.ToOpcUaValue(new OpcUaNodeId("ns=2;s=A"), dataValue); + + Assert.IsNull(value.SourceTimestamp); + Assert.IsNull(value.ServerTimestamp); + } + + [TestMethod] + public void ConvertValue_基本类型与可空类型() { + Assert.AreEqual(42, OpcUaValueConverter.ConvertValue(42)); + Assert.AreEqual(42, OpcUaValueConverter.ConvertValue((short)42)); + Assert.AreEqual(1.5, OpcUaValueConverter.ConvertValue(1.5f), 0.0001); + Assert.AreEqual("text", OpcUaValueConverter.ConvertValue("text")); + + int? nullableResult = OpcUaValueConverter.ConvertValue(null); + Assert.IsNull(nullableResult); + + int defaultResult = OpcUaValueConverter.ConvertValue(null); + Assert.AreEqual(0, defaultResult); + } + + [TestMethod] + public void MapNodeClass_映射节点类别() { + Assert.AreEqual(OpcUaNodeClass.Object, OpcUaValueConverter.MapNodeClass(NodeClass.Object)); + Assert.AreEqual(OpcUaNodeClass.Variable, OpcUaValueConverter.MapNodeClass(NodeClass.Variable)); + Assert.AreEqual(OpcUaNodeClass.Method, OpcUaValueConverter.MapNodeClass(NodeClass.Method)); + Assert.AreEqual(OpcUaNodeClass.Unspecified, OpcUaValueConverter.MapNodeClass(NodeClass.Unspecified)); + } + + [TestMethod] + public void IsBad_状态码严重级别判定() { + Assert.IsFalse(OpcUaValueConverter.IsBad(0x00000000)); + Assert.IsFalse(OpcUaValueConverter.IsBad(0x40000000)); + Assert.IsTrue(OpcUaValueConverter.IsBad(0x80340000)); + } +}