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 volatile 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. /// is still refreshed to honour the contract and prevent /// the reaper from evicting an active session that only receives attribute calls. /// Ignored. /// Ignored. public Task> AttributesAsync(string nodeId, CancellationToken cancellationToken) { LastUsedUtc = DateTime.UtcNow; return 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 */ } } }