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 */ }
}
}