|
|
|
|
|
#region << 版 本 注 释 >>
|
|
|
|
|
|
|
|
|
|
|
|
/*--------------------------------------------------------------------
|
|
|
|
|
|
* 版权所有 (c) 2025 WenJY 保留所有权利。
|
|
|
|
|
|
* CLR版本:4.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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|