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; private readonly IAsyncDisposable? _ownedApplication; /// /// 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. /// The SDK application whose certificate manager this session owns. internal OpcUaClientBrowseSession(ISession session, NamespaceMap nsMap, NodeId rootNodeId, IAsyncDisposable? ownedApplication = null) { _ownedApplication = ownedApplication; _session = session; _nsMap = nsMap; _rootNodeId = rootNodeId; } /// public Guid Token { get; } = Guid.NewGuid(); /// public DateTime LastUsedUtc { get; private set; } = DateTime.UtcNow; /// public Task> RootAsync(CancellationToken cancellationToken) => BrowseOneLevelAsync(_rootNodeId, cancellationToken); /// // Throws ArgumentException when nodeId 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); } /// // R06:O-5 — reads the node's NodeClass / DisplayName / DataType / ValueRank / UserAccessLevel in one request and // maps them through the same UpstreamAttributeMapping the runtime driver's discovery registers with, so the // side-panel shows what a browse-commit would author. An Object (folder) has no value attributes → empty. // Throws ArgumentException when nodeId cannot be resolved against the live session's namespace table. public async Task> AttributesAsync(string nodeId, CancellationToken cancellationToken) { ObjectDisposedException.ThrowIf(_disposed, this); if (!NamespaceMap.TryResolve(_session, nodeId, out var resolved)) throw new ArgumentException( $"Cannot resolve NodeId '{nodeId}' against the live session.", nameof(nodeId)); uint[] attributeIds = [ Attributes.NodeClass, Attributes.DisplayName, Attributes.DataType, Attributes.ValueRank, Attributes.UserAccessLevel, ]; var toRead = new List(attributeIds.Length); foreach (var id in attributeIds) toRead.Add(new ReadValueId { NodeId = resolved, AttributeId = id }); ArrayOf values; await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); try { var resp = await _session.ReadAsync( requestHeader: null, maxAge: 0, timestampsToReturn: TimestampsToReturn.Neither, nodesToRead: toRead, ct: cancellationToken).ConfigureAwait(false); values = resp?.Results ?? []; } finally { _gate.Release(); } LastUsedUtc = DateTime.UtcNow; DataValue? At(int i) => i < values.Count && StatusCode.IsGood(values[i].StatusCode) ? values[i] : null; var nodeClass = At(0)?.WrappedValue.AsBoxedObject() is int nc ? (NodeClass)nc : NodeClass.Unspecified; if (nodeClass != NodeClass.Variable) return Array.Empty(); var name = (At(1)?.WrappedValue.AsBoxedObject() as LocalizedText?)?.Text ?? resolved.ToString() ?? nodeId; var dataType = At(2)?.WrappedValue.AsBoxedObject() is NodeId dt ? UpstreamAttributeMapping.MapDataType(dt) : ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.String; var valueRank = At(3)?.WrappedValue.AsBoxedObject() is int vr ? vr : ValueRanks.Scalar; var access = At(4)?.WrappedValue.AsBoxedObject() is byte ab ? ab : (byte)0; return [ new AttributeInfo( Name: name, DriverDataType: dataType.ToString(), IsArray: UpstreamAttributeMapping.IsArray(valueRank), SecurityClass: UpstreamAttributeMapping.MapAccessLevel(access).ToString()), ]; } /// 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 List { 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.ToList(); // Follow browse continuation points so folders larger than the server's per-call // cap aren't silently truncated. var cp = result.ContinuationPoint; while (cp is { Length: > 0 }) { BrowseNextResponse next; try { next = await _session.BrowseNextAsync( requestHeader: null, releaseContinuationPoints: false, continuationPoints: [cp], ct: ct).ConfigureAwait(false); } catch (OperationCanceledException) { // Caller cancelled mid-pagination: release the // server-side cursor on a fresh token (the original is already cancelled). // Best-effort — mirrors OpcUaClientDriver's pagination loop. try { using var release = new CancellationTokenSource(TimeSpan.FromSeconds(5)); await _session.BrowseNextAsync( requestHeader: null, releaseContinuationPoints: true, continuationPoints: [cp], ct: release.Token).AsTask().WaitAsync(release.Token).ConfigureAwait(false); } catch { /* best-effort — server may have already cleaned up */ } throw; } if (next.Results.Count == 0) break; var nextResult = next.Results[0]; if (nextResult.References is { Count: > 0 }) refs.AddRange(nextResult.References.ToList()); 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. /// A task that represents the asynchronous operation. 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 */ } if (_ownedApplication is not null) { try { await _ownedApplication.DisposeAsync().ConfigureAwait(false); } catch { /* best-effort */ } } } }