f0d807e7b7
- m1: the form hint now says ProbeTimeoutSeconds can only shorten Test Connect's 10 s budget (passing the configured value as the button's budget is shared code - follow-up). - m2: a superseded init no longer overwrites _options (generation-checked under _probeLock) or the cert-validation handler (handed back and published with the session; detached when not installed; a previous one detached on replace). - m3: an ack none of whose condition ids resolves throws BadNodeIdUnknown + Warning. - m4: an ack with no reply (transport failure / non-caller timeout) degrades. - m5: no session before the gate is the same ServiceResultException(BadNotConnected). - m6: the caller-cancelled-write deviation is documented. - m7: a reply during a published outage advances the stamp but does not write Healthy; the keep-alive recovery ends the outage. - m8: test for a non-caller OperationCanceledException write -> Degraded. - m9: the browse session owns and disposes the user certificate it loaded (OpcUaUserIdentityFactory.LoadCertificate; a key-less cert is disposed before the throw); AdminUI-host certificate path documented. - m10: a subscribe overtaken by a shutdown / re-init removes its half-built subscription instead of registering it (data and alarm). - Sync Dispose drains each gate for 500 ms (was 2 s). Refs #743 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
253 lines
10 KiB
C#
253 lines
10 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 IDisposable? _ownedCredential;
|
|
|
|
/// <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="ownedCredential">A credential (the user certificate) this session owns and disposes with itself.</param>
|
|
internal OpcUaClientBrowseSession(ISession session, NamespaceMap nsMap, NodeId rootNodeId, IDisposable? ownedCredential = null)
|
|
{
|
|
_ownedCredential = ownedCredential;
|
|
_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 ReadValueIdCollection(attributeIds.Length);
|
|
foreach (var id in attributeIds) toRead.Add(new ReadValueId { NodeId = resolved, AttributeId = id });
|
|
|
|
DataValueCollection 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)?.Value is int nc ? (NodeClass)nc : NodeClass.Unspecified;
|
|
if (nodeClass != NodeClass.Variable) return Array.Empty<AttributeInfo>();
|
|
|
|
var name = (At(1)?.Value as LocalizedText)?.Text ?? resolved.ToString() ?? nodeId;
|
|
var dataType = At(2)?.Value is NodeId dt
|
|
? UpstreamAttributeMapping.MapDataType(dt)
|
|
: ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.String;
|
|
var valueRank = At(3)?.Value is int vr ? vr : ValueRanks.Scalar;
|
|
var access = At(4)?.Value 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 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<BrowseNode>();
|
|
}
|
|
|
|
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.
|
|
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
|
|
{
|
|
await _session.BrowseNextAsync(
|
|
requestHeader: null,
|
|
releaseContinuationPoints: true,
|
|
continuationPoints: [cp],
|
|
ct: CancellationToken.None).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);
|
|
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 */ }
|
|
|
|
try { _ownedCredential?.Dispose(); }
|
|
catch { /* best-effort */ }
|
|
}
|
|
}
|