Files
lmxopcua/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/OpcUaClientBrowseSession.cs
T
2026-11-10 10:17:38 -05:00

256 lines
11 KiB
C#

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 volatile bool _disposed;
private readonly IAsyncDisposable? _ownedApplication;
/// <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>
/// <param name="ownedApplication">The SDK application whose certificate manager this session owns.</param>
internal OpcUaClientBrowseSession(ISession session, NamespaceMap nsMap, NodeId rootNodeId, IAsyncDisposable? ownedApplication = null)
{
_ownedApplication = ownedApplication;
_session = session;
_nsMap = nsMap;
_rootNodeId = rootNodeId;
}
/// <inheritdoc />
public Guid Token { get; } = Guid.NewGuid();
/// <inheritdoc />
public DateTime LastUsedUtc { get; private set; } = DateTime.UtcNow;
/// <inheritdoc />
public Task<IReadOnlyList<BrowseNode>> RootAsync(CancellationToken cancellationToken)
=> BrowseOneLevelAsync(_rootNodeId, cancellationToken);
/// <inheritdoc />
// Throws ArgumentException when nodeId cannot be resolved against the live session's
// namespace table.
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);
}
/// <inheritdoc />
// 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<IReadOnlyList<AttributeInfo>> 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<ReadValueId>(attributeIds.Length);
foreach (var id in attributeIds) toRead.Add(new ReadValueId { NodeId = resolved, AttributeId = id });
ArrayOf<DataValue> 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<AttributeInfo>();
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()),
];
}
/// <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 List<BrowseDescription>
{
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.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<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>
/// <returns>A task that represents the asynchronous operation.</returns>
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 */ }
}
}
}