diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/OpcUaClientBrowseSession.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/OpcUaClientBrowseSession.cs new file mode 100644 index 00000000..9d6d5279 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/OpcUaClientBrowseSession.cs @@ -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; + +/// +/// Live one-level-per-call browse session over a remote OPC UA server. Created by +/// OpcUaClientBrowser on picker open and owned by the AdminUI's +/// BrowseSessionRegistry; the registry's TTL reaper disposes idle sessions. +/// +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; + + /// + /// Construct a browse session bound to an already-connected . + /// + /// The OPC UA client session to browse against. + /// Namespace snapshot taken at connect time; used to render outbound + /// NodeIds in the server-stable nsu=… form. + /// The node under which browses one level. + internal OpcUaClientBrowseSession(ISession session, NamespaceMap nsMap, NodeId rootNodeId) + { + _session = session; + _nsMap = nsMap; + _rootNodeId = rootNodeId; + } + + /// Opaque token identifying this session in the AdminUI registry. + public Guid Token { get; } = Guid.NewGuid(); + + /// Wall-clock time of the most recent successful browse call; the reaper uses + /// this for idle eviction. + public DateTime LastUsedUtc { get; private set; } = DateTime.UtcNow; + + /// Browse one level under the configured root node. + /// Cancellation token. + public Task> RootAsync(CancellationToken cancellationToken) + => BrowseOneLevelAsync(_rootNodeId, cancellationToken); + + /// Browse one level under the node identified by , + /// which must be a stable reference produced by + /// (or a plain ns=N;… form). + /// Stable reference string for the parent node. + /// Cancellation token. + /// Thrown when cannot be + /// resolved against the live session's namespace table. + public Task> 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); + } + + /// The OPC UA picker treats variables as terminal leaves and does not surface + /// a per-attribute side-panel, so this always returns empty. + /// Ignored. + /// Ignored. + public Task> AttributesAsync(string nodeId, CancellationToken cancellationToken) + => Task.FromResult>(Array.Empty()); + + /// Issue a single-level Browse (plus continuation-point follow-ups) under the + /// given parent node. is not thread-safe, so all calls + /// serialize through . + private async Task> 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(); + } + + 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(refs.Count); + foreach (var rf in refs) + nodes.Add(ToBrowseNode(rf)); + return nodes; + } + finally + { + _gate.Release(); + } + } + + /// Project a single into the driver-agnostic + /// shape, encoding the outbound NodeId in the stable + /// nsu=… form so the picker survives a remote namespace-table reorder. + 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); + } + + /// Idempotent best-effort dispose: closes the underlying session if it's a + /// concrete , disposes it, and disposes the gate. Close errors are + /// swallowed because the registry reaper may be racing a remote disconnect. + 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 */ } + } +}