feat(opcuaclient.browser): add lazy browse session impl
This commit is contained in:
@@ -0,0 +1,178 @@
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Client;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser;
|
||||
|
||||
/// <summary>
|
||||
/// Live one-level-per-call browse session over a remote OPC UA server. Created by
|
||||
/// <c>OpcUaClientBrowser</c> on picker open and owned by the AdminUI's
|
||||
/// <c>BrowseSessionRegistry</c>; the registry's TTL reaper disposes idle sessions.
|
||||
/// </summary>
|
||||
internal sealed class OpcUaClientBrowseSession : IBrowseSession
|
||||
{
|
||||
private readonly ISession _session;
|
||||
private readonly NamespaceMap _nsMap;
|
||||
private readonly NodeId _rootNodeId;
|
||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>
|
||||
/// Construct a browse session bound to an already-connected <paramref name="session"/>.
|
||||
/// </summary>
|
||||
/// <param name="session">The OPC UA client session to browse against.</param>
|
||||
/// <param name="nsMap">Namespace snapshot taken at connect time; used to render outbound
|
||||
/// NodeIds in the server-stable <c>nsu=…</c> form.</param>
|
||||
/// <param name="rootNodeId">The node under which <see cref="RootAsync"/> browses one level.</param>
|
||||
internal OpcUaClientBrowseSession(ISession session, NamespaceMap nsMap, NodeId rootNodeId)
|
||||
{
|
||||
_session = session;
|
||||
_nsMap = nsMap;
|
||||
_rootNodeId = rootNodeId;
|
||||
}
|
||||
|
||||
/// <summary>Opaque token identifying this session in the AdminUI registry.</summary>
|
||||
public Guid Token { get; } = Guid.NewGuid();
|
||||
|
||||
/// <summary>Wall-clock time of the most recent successful browse call; the reaper uses
|
||||
/// this for idle eviction.</summary>
|
||||
public DateTime LastUsedUtc { get; private set; } = DateTime.UtcNow;
|
||||
|
||||
/// <summary>Browse one level under the configured root node.</summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
public Task<IReadOnlyList<BrowseNode>> RootAsync(CancellationToken cancellationToken)
|
||||
=> BrowseOneLevelAsync(_rootNodeId, cancellationToken);
|
||||
|
||||
/// <summary>Browse one level under the node identified by <paramref name="nodeId"/>,
|
||||
/// which must be a stable reference produced by <see cref="NamespaceMap.ToStableReference"/>
|
||||
/// (or a plain <c>ns=N;…</c> form).</summary>
|
||||
/// <param name="nodeId">Stable reference string for the parent node.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <exception cref="ArgumentException">Thrown when <paramref name="nodeId"/> cannot be
|
||||
/// resolved against the live session's namespace table.</exception>
|
||||
public Task<IReadOnlyList<BrowseNode>> ExpandAsync(string nodeId, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!NamespaceMap.TryResolve(_session, nodeId, out var resolved))
|
||||
throw new ArgumentException(
|
||||
$"Cannot resolve NodeId '{nodeId}' against the live session.", nameof(nodeId));
|
||||
return BrowseOneLevelAsync(resolved, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>The OPC UA picker treats variables as terminal leaves and does not surface
|
||||
/// a per-attribute side-panel, so this always returns empty.</summary>
|
||||
/// <param name="nodeId">Ignored.</param>
|
||||
/// <param name="cancellationToken">Ignored.</param>
|
||||
public Task<IReadOnlyList<AttributeInfo>> AttributesAsync(string nodeId, CancellationToken cancellationToken)
|
||||
=> Task.FromResult<IReadOnlyList<AttributeInfo>>(Array.Empty<AttributeInfo>());
|
||||
|
||||
/// <summary>Issue a single-level Browse (plus continuation-point follow-ups) under the
|
||||
/// given parent node. <see cref="Session.BrowseAsync"/> is not thread-safe, so all calls
|
||||
/// serialize through <see cref="_gate"/>.</summary>
|
||||
private async Task<IReadOnlyList<BrowseNode>> BrowseOneLevelAsync(NodeId parent, CancellationToken ct)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
await _gate.WaitAsync(ct).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
var descriptions = new BrowseDescriptionCollection
|
||||
{
|
||||
new()
|
||||
{
|
||||
NodeId = parent,
|
||||
BrowseDirection = BrowseDirection.Forward,
|
||||
ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences,
|
||||
IncludeSubtypes = true,
|
||||
NodeClassMask = (uint)(NodeClass.Object | NodeClass.Variable),
|
||||
ResultMask = (uint)(BrowseResultMask.BrowseName | BrowseResultMask.DisplayName
|
||||
| BrowseResultMask.NodeClass),
|
||||
},
|
||||
};
|
||||
|
||||
var resp = await _session.BrowseAsync(
|
||||
requestHeader: null,
|
||||
view: null,
|
||||
requestedMaxReferencesPerNode: 0,
|
||||
nodesToBrowse: descriptions,
|
||||
ct: ct).ConfigureAwait(false);
|
||||
|
||||
if (resp.Results.Count == 0)
|
||||
{
|
||||
LastUsedUtc = DateTime.UtcNow;
|
||||
return Array.Empty<BrowseNode>();
|
||||
}
|
||||
|
||||
var result = resp.Results[0];
|
||||
var refs = result.References;
|
||||
|
||||
// Follow browse continuation points so folders larger than the server's per-call
|
||||
// cap aren't silently truncated (same pattern as runtime
|
||||
// Driver.OpcUaClient-003).
|
||||
var cp = result.ContinuationPoint;
|
||||
while (cp is { Length: > 0 })
|
||||
{
|
||||
var next = await _session.BrowseNextAsync(
|
||||
requestHeader: null,
|
||||
releaseContinuationPoints: false,
|
||||
continuationPoints: [cp],
|
||||
ct: ct).ConfigureAwait(false);
|
||||
|
||||
if (next.Results.Count == 0) break;
|
||||
var nextResult = next.Results[0];
|
||||
if (nextResult.References is { Count: > 0 })
|
||||
refs.AddRange(nextResult.References);
|
||||
cp = nextResult.ContinuationPoint;
|
||||
}
|
||||
|
||||
LastUsedUtc = DateTime.UtcNow;
|
||||
|
||||
var nodes = new List<BrowseNode>(refs.Count);
|
||||
foreach (var rf in refs)
|
||||
nodes.Add(ToBrowseNode(rf));
|
||||
return nodes;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Project a single <see cref="ReferenceDescription"/> into the driver-agnostic
|
||||
/// <see cref="BrowseNode"/> shape, encoding the outbound NodeId in the stable
|
||||
/// <c>nsu=…</c> form so the picker survives a remote namespace-table reorder.</summary>
|
||||
private BrowseNode ToBrowseNode(ReferenceDescription rf)
|
||||
{
|
||||
var childId = ExpandedNodeId.ToNodeId(rf.NodeId, _session.NamespaceUris);
|
||||
var isObject = rf.NodeClass == NodeClass.Object;
|
||||
var displayName = rf.DisplayName?.Text
|
||||
?? rf.BrowseName?.Name
|
||||
?? childId.ToString()
|
||||
?? "(unnamed)";
|
||||
return new BrowseNode(
|
||||
NodeId: _nsMap.ToStableReference(childId),
|
||||
DisplayName: displayName,
|
||||
Kind: isObject ? BrowseNodeKind.Folder : BrowseNodeKind.Leaf,
|
||||
HasChildrenHint: isObject);
|
||||
}
|
||||
|
||||
/// <summary>Idempotent best-effort dispose: closes the underlying session if it's a
|
||||
/// concrete <see cref="Session"/>, disposes it, and disposes the gate. Close errors are
|
||||
/// swallowed because the registry reaper may be racing a remote disconnect.</summary>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
|
||||
if (_session is Session s)
|
||||
{
|
||||
try { await s.CloseAsync().ConfigureAwait(false); }
|
||||
catch { /* best-effort */ }
|
||||
}
|
||||
|
||||
try { _session.Dispose(); }
|
||||
catch { /* best-effort */ }
|
||||
|
||||
try { _gate.Dispose(); }
|
||||
catch { /* best-effort */ }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user