Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
196 changes: 196 additions & 0 deletions docs/content/MAS.Communication.OpcUaProtocol.md
Original file line number Diff line number Diff line change
@@ -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<T>() 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<IOpcUaProtocol>(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<double>(new OpcUaNodeId("ns=2;s=Machine.Speed"));

await opcua.WriteAsync(new OpcUaNodeId("ns=2;s=Machine.Speed"), 1500.0);

// =========================
// 示例 2:批量读写(部分成功、部分失败)
// =========================

IReadOnlyList<OpcUaValue> readResults = await opcua.ReadAsync([
new OpcUaReadItem(new OpcUaNodeId("ns=2;s=Machine.Speed")),
new OpcUaReadItem(new OpcUaNodeId("ns=2;s=Machine.Temp")),
]);

IReadOnlyList<OpcUaWriteResult> 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<OpcUaBrowseNode> 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`)而让整个调用抛异常,而是返回逐项状态码;只有会话中断、请求无法发送等整体性错误才会抛异常。
4 changes: 3 additions & 1 deletion docs/content/toc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,6 @@
- name: MAS.Communication.ModbusProtocol
href: MAS.Communication.ModbusProtocol.md
- name: MAS.Communication.S7Protocol
href: MAS.Communication.S7Protocol.md
href: MAS.Communication.S7Protocol.md
- name: MAS.Communication.OpcUaProtocol
href: MAS.Communication.OpcUaProtocol.md
1 change: 1 addition & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ services.AddCommunication();
| MC(Mitsubishi) | `IMcCommunicationConfig` |
| ModbusTCP | `IModbusCommunicationConfig` |
| S7(Siemens) | `IS7CommunicationConfig` |
| OPC UA(Client) | `IOpcUaCommunicationConfig` |

以上接口均继承自 `ICommunicationConfig`,调用方需继承并实现它

Expand Down
21 changes: 21 additions & 0 deletions src/Extensions/CommunicationConfigExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

using MAS.Communication.McProtocol;
using MAS.Communication.ModbusProtocol;
using MAS.Communication.OpcUaProtocol;
using MAS.Communication.S7Protocol;

namespace MAS.Communication;
Expand All @@ -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();
}
}
4 changes: 0 additions & 4 deletions src/Helpers/StructBinaryHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
17 changes: 12 additions & 5 deletions src/MAS.Communication.csproj
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFrameworks>net10.0;net9.0;net8.0;net481</TargetFrameworks>
Expand All @@ -7,9 +7,9 @@

<GenerateDocumentationFile>True</GenerateDocumentationFile>
<DocumentationFile>../../build/APIDocumentation/MAS.Communication.xml</DocumentationFile>
<Version>1.0.3</Version>
<AssemblyVersion>1.0.3.0</AssemblyVersion>
<FileVersion>1.0.3.0</FileVersion>
<Version>1.1.0</Version>
<AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion>
<Authors>MAS(厦门威光)研发部</Authors>
<Company>MAS(厦门威光)</Company>
<Copyright>Copyright (c) MAS(厦门威光) Corporation. All rights reserved.</Copyright>
Expand All @@ -36,9 +36,16 @@
<LangVersion>latest</LangVersion>
</PropertyGroup>

<PropertyGroup>
<OpcUaVersion>1.5.378.156</OpcUaVersion>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.5" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.8" />
<PackageReference Include="S7netplus" Version="0.20.0" />
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Core" Version="$(OpcUaVersion)" />
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Client" Version="$(OpcUaVersion)" />
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Configuration" Version="$(OpcUaVersion)" />
</ItemGroup>

<ItemGroup>
Expand Down
2 changes: 2 additions & 0 deletions src/Managers/ProtocolManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

using MAS.Communication.McProtocol;
using MAS.Communication.ModbusProtocol;
using MAS.Communication.OpcUaProtocol;
using MAS.Communication.S7Protocol;
using System.Collections.Concurrent;

Expand Down Expand Up @@ -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()}")
};
}
Expand Down
7 changes: 6 additions & 1 deletion src/Models/CommProtocol.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,5 +27,10 @@ public enum CommProtocol {
/// <summary>
/// Modbus TCP 协议
/// </summary>
ModbusTcp
ModbusTcp,

/// <summary>
/// OPC UA 协议(客户端)
/// </summary>
OpcUa
}
28 changes: 28 additions & 0 deletions src/OpcUaProtocol/EventArgs/OpcUaDataChangedEventArgs.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// OPC UA 订阅数据变化事件参数
/// </summary>
/// <param name="subscriptionId">触发变化的订阅标识</param>
/// <param name="value">变化后的值</param>
public sealed class OpcUaDataChangedEventArgs(string subscriptionId, OpcUaValue value) : EventArgs {
/// <summary>
/// 获取触发变化的订阅标识
/// </summary>
public string SubscriptionId { get; } = subscriptionId;

/// <summary>
/// 获取变化后的值
/// </summary>
public OpcUaValue Value { get; } = value;
}
30 changes: 30 additions & 0 deletions src/OpcUaProtocol/Exceptions/OpcUaCertificateException.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// OPC UA 证书相关异常
/// </summary>
/// <remarks>用于应用证书初始化失败、服务端证书校验失败或指定客户端证书不可用等场景</remarks>
public class OpcUaCertificateException : Exception {
/// <summary>
/// 初始化 <see cref="OpcUaCertificateException"/> 新实例
/// </summary>
/// <param name="message">异常消息</param>
public OpcUaCertificateException(string message) : base(message) { }

/// <summary>
/// 初始化 <see cref="OpcUaCertificateException"/> 新实例
/// </summary>
/// <param name="message">异常消息</param>
/// <param name="innerException">内部异常</param>
public OpcUaCertificateException(string message, Exception innerException) : base(message, innerException) { }
}
Loading
Loading