You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

356 lines
12 KiB
C#

#region << 版 本 注 释 >>
/*--------------------------------------------------------------------
* (c) 2025 WenJY
* CLR4.0.30319.42000
* Mr.Wen's MacBook Pro
* Sln.Imm.Daemon.Opc.Impl
* AAD72EB2-53B5-43B2-AD71-77FE72AC816E
*
* WenJY
*
* 2025-09-22 17:24:43
* V1.0.0
*
*
*--------------------------------------------------------------------
*
*
*
*
* V1.0.0
*--------------------------------------------------------------------*/
#endregion << 版 本 注 释 >>
using Opc.Ua;
using Opc.Ua.Client;
using Sln.Imm.Daemon.Model.dto;
using System.Threading;
namespace Sln.Imm.Daemon.Opc.Impl;
public class OpcUaService : IOpcService, IDisposable
{
private ApplicationConfiguration _config;
private Session _session;
private bool _disposed = false;
private static readonly TimeSpan DefaultDisconnectTimeout = TimeSpan.FromSeconds(5);
// 全局限制:同时对 OPC UA 服务器建立的 Session 数量(避免 BadMaxConnectionsReached
// 如果你的 OPC UA 服务器限制更低/更高,可以调整这个数(建议小于服务器 MaxConnections/MaxSessions
private static readonly SemaphoreSlim UaConnectionSemaphore = new SemaphoreSlim(8, 8);
private bool _holdsUaConnectionSlot;
public OpcUaService()
{
_config = new ApplicationConfiguration()
{
ApplicationName = "OPC UA Client",
ApplicationType = ApplicationType.Client,
SecurityConfiguration = new SecurityConfiguration
{
// 使用最小化的证书配置
ApplicationCertificate = new CertificateIdentifier
{
StoreType = "Directory",
StorePath = "./pki/own" // 使用相对路径
},
TrustedPeerCertificates = new CertificateTrustList
{
StoreType = "Directory",
StorePath = "./pki/trusted" // 使用相对路径
},
TrustedIssuerCertificates = new CertificateTrustList
{
StoreType = "Directory",
StorePath = "./pki/issuers" // 使用相对路径
},
RejectedCertificateStore = new CertificateTrustList
{
StoreType = "Directory",
StorePath = "./pki/rejected" // 使用相对路径
},
AutoAcceptUntrustedCertificates = true // 自动接受证书(仅测试环境)
},
TransportConfigurations = new TransportConfigurationCollection(),
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60000 }
};
_config.Validate(ApplicationType.Client).Wait();
}
public async Task<bool> ConnectAsync(string serverUrl)
{
// 先拿到“连接名额”,避免瞬时并发把服务器连接打满
await UaConnectionSemaphore.WaitAsync().ConfigureAwait(false);
_holdsUaConnectionSlot = true;
try
{
const int maxAttempts = 2;
for (int attempt = 1; attempt <= maxAttempts; attempt++)
{
try
{
var endpointDescription = CoreClientUtils.SelectEndpoint(serverUrl, false);
var endpointConfiguration = EndpointConfiguration.Create(_config);
var endpoint = new ConfiguredEndpoint(null, endpointDescription, endpointConfiguration);
_session = await Session.Create(
_config,
endpoint,
false,
false,
_config.ApplicationName,
60000,
new UserIdentity(),
null).ConfigureAwait(false);
return _session != null && _session.Connected;
}
catch (ServiceResultException sre) when (sre.StatusCode == StatusCodes.BadMaxConnectionsReached)
{
// 服务器连接数达到上限:做一次短暂退避重试
if (attempt >= maxAttempts)
throw;
await Task.Delay(500 * attempt).ConfigureAwait(false);
}
catch (Exception ex) when (ex.Message != null && ex.Message.Contains("BadMaxConnectionsReached", StringComparison.OrdinalIgnoreCase))
{
if (attempt >= maxAttempts)
throw;
await Task.Delay(500 * attempt).ConfigureAwait(false);
}
}
return false;
}
catch (Exception ex)
{
// 连接失败:释放名额
ReleaseUaConnectionSlotIfHeld();
throw new InvalidOperationException($"连接到 OPC UA 服务器失败: {ex.Message}");
}
}
public async Task DisconnectAsync()
{
var session = _session;
_session = null;
if (session == null)
{
ReleaseUaConnectionSlotIfHeld();
return;
}
try
{
// 断开时可能会卡在 Close()/Dispose()(网络/通道问题),放到线程池并加超时保护
var closeTask = Task.Run(() =>
{
try
{
if (session.Connected)
{
// 使用带超时的 Close毫秒
session.Close((int)DefaultDisconnectTimeout.TotalMilliseconds);
}
}
finally
{
session.Dispose();
}
});
var finished = await Task.WhenAny(closeTask, Task.Delay(DefaultDisconnectTimeout)).ConfigureAwait(false);
if (finished != closeTask)
{
// 超时:不阻塞调用方,避免采集线程被永久卡死
// closeTask 仍可能在后台继续清理(或被底层卡住)
return;
}
// 传播 closeTask 的异常(如果有)
await closeTask.ConfigureAwait(false);
}
catch
{
// 断开失败不应影响上层循环
//(上层会记录日志/继续)
}
finally
{
ReleaseUaConnectionSlotIfHeld();
}
}
private void ReleaseUaConnectionSlotIfHeld()
{
if (_holdsUaConnectionSlot)
{
_holdsUaConnectionSlot = false;
try { UaConnectionSemaphore.Release(); } catch { /* ignore */ }
}
}
public async Task<List<OpcNode>> ReadNodeAsync(List<string> nodeId)
{
if (_session == null || !_session.Connected)
throw new Exception("未连接到 OPC UA 服务器");
try
{
ReadValueIdCollection nodesToRead = new ReadValueIdCollection();
nodeId.ForEach(x =>
{
ReadValueId nodeToRead = new ReadValueId
{
NodeId = new NodeId(x),
AttributeId = Attributes.Value
};
nodesToRead.Add(nodeToRead);
});
_session.Read(
null,
0,
TimestampsToReturn.Both,
nodesToRead,
out DataValueCollection results,
out DiagnosticInfoCollection diagnosticInfos);
if (results != null && results.Count > 0 && StatusCode.IsGood(results[0].StatusCode))
{
var indexedNodeIds = nodeId.Select((value,index)=>new {Index = index, Value = value});
List<OpcNode> result = new List<OpcNode>();
foreach (var item in indexedNodeIds)
{
var dataValue = results[item.Index];
result.Add(new OpcNode()
{
NodeId = item.Value,
DisplayName = item.Value,
Value = dataValue.Value,
SourceTimestamp = dataValue.SourceTimestamp,
DataType = dataValue.WrappedValue.TypeInfo?.BuiltInType.ToString() ?? "Unknown"
});
}
return result;
}
throw new Exception($"读取节点失败: {StatusCode.LookupSymbolicId((uint)results[0].StatusCode)}");
}
catch (Exception ex)
{
throw new InvalidOperationException($"OPC UA 读取节点时出错:{ex.Message}");
}
}
public async Task WriteNodeAsync(string nodeId, object value)
{
if (_session == null || !_session.Connected)
throw new Exception("未连接到 OPC UA 服务器");
try
{
WriteValueCollection nodesToWrite = new WriteValueCollection();
WriteValue nodeToWrite = new WriteValue
{
NodeId = new NodeId(nodeId),
AttributeId = Attributes.Value,
Value = new DataValue(new Variant(value))
};
nodesToWrite.Add(nodeToWrite);
_session.Write(
null,
nodesToWrite,
out StatusCodeCollection results,
out DiagnosticInfoCollection diagnosticInfos);
if (results == null || results.Count == 0 || !StatusCode.IsGood(results[0]))
{
throw new Exception($"写入节点失败: {StatusCode.LookupSymbolicId((uint)results[0])}");
}
}
catch (Exception ex)
{
throw new InvalidOperationException($"写入节点时出错:{ex.Message}");
}
}
public async Task<List<OpcNode>> BrowseNodesAsync(string startingNodeId = null)
{
if (_session == null || !_session.Connected)
throw new Exception("未连接到 OPC UA 服务器");
var nodes = new List<OpcNode>();
NodeId startingNode = startingNodeId != null ? new NodeId(startingNodeId) : ObjectIds.ObjectsFolder;
try
{
BrowseDescriptionCollection nodesToBrowse = new BrowseDescriptionCollection();
BrowseDescription nodeToBrowse = new BrowseDescription
{
NodeId = startingNode,
BrowseDirection = BrowseDirection.Forward,
ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences,
IncludeSubtypes = true,
NodeClassMask = (uint)(NodeClass.Variable | NodeClass.Object),
ResultMask = (uint)BrowseResultMask.All
};
nodesToBrowse.Add(nodeToBrowse);
_session.Browse(
null,
null,
0,
nodesToBrowse,
out BrowseResultCollection results,
out DiagnosticInfoCollection diagnosticInfos);
if (results != null && results.Count > 0)
{
foreach (var reference in results[0].References)
{
nodes.Add(new OpcNode
{
NodeId = reference.NodeId.ToString(),
DisplayName = reference.DisplayName.Text,
DataType = reference.NodeClass.ToString()
});
}
}
return nodes;
}
catch (Exception ex)
{
Console.WriteLine($"浏览节点时出错: {ex.Message}");
throw;
}
}
public void Dispose()
{
if (!_disposed)
{
try
{
// Dispose 里不要 .Wait() 无限阻塞;最多等待一次默认超时
DisconnectAsync().GetAwaiter().GetResult();
}
catch
{
// swallow
}
_disposed = true;
}
}
}