feat(dcl): BrowseNext continuation paging + StubOpcUaClient canned browse (T15)
This commit is contained in:
@@ -10,21 +10,28 @@ public interface IBrowsableDataConnection
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns the immediate children of <paramref name="parentNodeId"/>, or
|
||||
/// the server's root-level nodes when null.
|
||||
/// the server's root-level nodes when null. Pageable: when a prior call
|
||||
/// returned a non-null <see cref="BrowseChildrenResult.ContinuationToken"/>,
|
||||
/// pass it back via <paramref name="continuationToken"/> to fetch the next
|
||||
/// page; a null/empty token starts a fresh browse of <paramref name="parentNodeId"/>.
|
||||
/// </summary>
|
||||
/// <param name="parentNodeId">Node id whose children to browse, or null for the server root (OPC UA ObjectsFolder).</param>
|
||||
/// <param name="continuationToken">Opaque token from a prior <see cref="BrowseChildrenResult.ContinuationToken"/> to fetch the next page; null/empty starts a fresh browse. The token is implementation-private (e.g. a Base64 OPC UA continuation point) and must not be interpreted by callers.</param>
|
||||
/// <param name="cancellationToken">Cancellation token; on cancellation the implementation should throw <see cref="OperationCanceledException"/>.</param>
|
||||
/// <returns>A task that resolves to the child nodes and a flag indicating whether results were truncated.</returns>
|
||||
/// <returns>A task that resolves to the child nodes, a flag indicating whether more results remain, and a continuation token to fetch the next page (null when exhausted).</returns>
|
||||
Task<BrowseChildrenResult> BrowseChildrenAsync(
|
||||
string? parentNodeId,
|
||||
string? continuationToken = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <param name="Children">Child nodes returned by the server in browse order.</param>
|
||||
/// <param name="Truncated">True when the server reported more children than the per-call cap; remaining children must be discovered via manual entry.</param>
|
||||
/// <param name="Truncated">True when more children remain beyond this page; fetch them by passing <paramref name="ContinuationToken"/> back to <see cref="IBrowsableDataConnection.BrowseChildrenAsync"/>.</param>
|
||||
/// <param name="ContinuationToken">Opaque token to fetch the next page (echo it back via the <c>continuationToken</c> parameter), or null when the browse is exhausted. Implementation-private — callers must treat it as opaque.</param>
|
||||
public record BrowseChildrenResult(
|
||||
IReadOnlyList<BrowseNode> Children,
|
||||
bool Truncated);
|
||||
bool Truncated,
|
||||
string? ContinuationToken = null);
|
||||
|
||||
/// <param name="NodeId">Server-issued node identifier (e.g. <c>"ns=2;s=Devices.Pump1.Speed"</c>).</param>
|
||||
/// <param name="DisplayName">Human-readable display name from the server's DisplayName attribute.</param>
|
||||
|
||||
@@ -132,15 +132,21 @@ public interface IOpcUaClient : IAsyncDisposable
|
||||
|
||||
/// <summary>
|
||||
/// Enumerates the immediate children of <paramref name="parentNodeId"/>
|
||||
/// (or the server's ObjectsFolder when null). Throws
|
||||
/// (or the server's ObjectsFolder when null). Pageable via the OPC UA
|
||||
/// continuation-point mechanism: when a prior call returned a non-null
|
||||
/// <see cref="BrowseChildrenResult.ContinuationToken"/>, pass it back via
|
||||
/// <paramref name="continuationToken"/> to fetch the next page (BrowseNext);
|
||||
/// a null/empty token starts a fresh browse. Throws
|
||||
/// <see cref="ConnectionNotConnectedException"/> when the session is not
|
||||
/// currently up.
|
||||
/// </summary>
|
||||
/// <param name="parentNodeId">Node id whose children to browse, or null for the server root.</param>
|
||||
/// <param name="continuationToken">Opaque token from a prior <see cref="BrowseChildrenResult.ContinuationToken"/> to fetch the next page via BrowseNext; null/empty starts a fresh browse.</param>
|
||||
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
|
||||
/// <returns>A task that completes with the immediate children of the requested node.</returns>
|
||||
/// <returns>A task that completes with the immediate children of the requested node and a continuation token for the next page (null when exhausted).</returns>
|
||||
Task<BrowseChildrenResult> BrowseChildrenAsync(
|
||||
string? parentNodeId,
|
||||
string? continuationToken = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -235,8 +241,48 @@ internal class StubOpcUaClient : IOpcUaClient
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<BrowseChildrenResult> BrowseChildrenAsync(
|
||||
string? parentNodeId, CancellationToken cancellationToken = default)
|
||||
=> throw new NotImplementedException();
|
||||
string? parentNodeId, string? continuationToken = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Canned address-space tree so DCL browse/paging flows can be exercised
|
||||
// without a live OPC UA server (T15):
|
||||
// (root)
|
||||
// ├─ Folder1 (Object, HasChildren=true)
|
||||
// │ ├─ Tag1 (Variable) ← page 1
|
||||
// │ └─ Tag2 (Variable) ← page 2 (continuation token "STUB_PAGE2")
|
||||
// └─ Folder2 (Object, HasChildren=false, leaf)
|
||||
// Folder1 fakes BrowseNext continuation paging so the continuation-token
|
||||
// round-trip is testable; everything else is a single, exhausted page.
|
||||
if (string.IsNullOrEmpty(parentNodeId))
|
||||
{
|
||||
return Task.FromResult(new BrowseChildrenResult(
|
||||
new[]
|
||||
{
|
||||
new BrowseNode("Folder1", "Folder1", BrowseNodeClass.Object, HasChildren: true),
|
||||
new BrowseNode("Folder2", "Folder2", BrowseNodeClass.Object, HasChildren: false),
|
||||
},
|
||||
Truncated: false));
|
||||
}
|
||||
|
||||
if (parentNodeId == "Folder1")
|
||||
{
|
||||
// Page 2: the caller passed back the continuation token from page 1.
|
||||
if (continuationToken == "STUB_PAGE2")
|
||||
{
|
||||
return Task.FromResult(new BrowseChildrenResult(
|
||||
new[] { new BrowseNode("Tag2", "Tag2", BrowseNodeClass.Variable, HasChildren: false) },
|
||||
Truncated: false));
|
||||
}
|
||||
|
||||
// Page 1: return only Tag1 and a continuation token for the next page.
|
||||
return Task.FromResult(new BrowseChildrenResult(
|
||||
new[] { new BrowseNode("Tag1", "Tag1", BrowseNodeClass.Variable, HasChildren: false) },
|
||||
Truncated: true,
|
||||
ContinuationToken: "STUB_PAGE2"));
|
||||
}
|
||||
|
||||
// Leaves (Folder2, Variable nodes, anything unknown) have no children.
|
||||
return Task.FromResult(new BrowseChildrenResult(Array.Empty<BrowseNode>(), Truncated: false));
|
||||
}
|
||||
|
||||
/// <summary>Disposes this stub client and marks the connection as closed.</summary>
|
||||
/// <returns>A completed <see cref="ValueTask"/>.</returns>
|
||||
|
||||
@@ -254,11 +254,17 @@ public class MxGatewayDataConnection : IDataConnection, IBrowsableDataConnection
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<BrowseChildrenResult> BrowseChildrenAsync(string? parentNodeId, CancellationToken cancellationToken = default)
|
||||
public async Task<BrowseChildrenResult> BrowseChildrenAsync(
|
||||
string? parentNodeId,
|
||||
string? continuationToken = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_status != ConnectionHealth.Connected || _client is null)
|
||||
throw new ConnectionNotConnectedException($"MxGateway connection is not connected (status: {_status}).");
|
||||
|
||||
// MxGateway browse is not pageable — the gateway returns a single,
|
||||
// already-bounded child set per node. Any continuation token is ignored
|
||||
// and no continuation is ever surfaced (ContinuationToken stays null).
|
||||
var (children, truncated) = await _client.BrowseChildrenAsync(parentNodeId, cancellationToken);
|
||||
var nodes = children
|
||||
.Select(c => new BrowseNode(c.NodeId, c.DisplayName, c.NodeClass, c.HasChildren))
|
||||
|
||||
@@ -298,8 +298,9 @@ public class OpcUaDataConnection : IDataConnection, IBrowsableDataConnection, IA
|
||||
/// <inheritdoc />
|
||||
public Task<BrowseChildrenResult> BrowseChildrenAsync(
|
||||
string? parentNodeId,
|
||||
string? continuationToken = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> _client!.BrowseChildrenAsync(parentNodeId, cancellationToken);
|
||||
=> _client!.BrowseChildrenAsync(parentNodeId, continuationToken, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> WriteBatchAndWaitAsync(
|
||||
|
||||
@@ -701,9 +701,22 @@ public class RealOpcUaClient : IOpcUaClient
|
||||
? Path.Combine(Path.GetTempPath(), "ScadaBridge", "pki", fallbackLeaf)
|
||||
: configured;
|
||||
|
||||
// requestedMaxReferencesPerNode: cap the server's per-call references so a
|
||||
// huge flat folder cannot return an unbounded set. 500 leaves headroom for
|
||||
// the downstream frame-size budget (DataConnectionActor.CapBrowseChildren)
|
||||
// even with long string NodeIds; a non-empty continuation point surfaces as
|
||||
// a ContinuationToken so the caller can page via BrowseNext (T15).
|
||||
private const uint BrowseMaxReferencesPerNode = 500u;
|
||||
|
||||
// NodeClassMask intentionally excludes ReferenceType, View, Variable-
|
||||
// Type, ObjectType, DataType. UI only needs Objects (navigable),
|
||||
// Variables (selectable), Methods (display-only).
|
||||
private const uint BrowseNodeClassMask =
|
||||
(uint)(NodeClass.Object | NodeClass.Variable | NodeClass.Method);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Commons.Interfaces.Protocol.BrowseChildrenResult> BrowseChildrenAsync(
|
||||
string? parentNodeId, CancellationToken cancellationToken = default)
|
||||
string? parentNodeId, string? continuationToken = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Mirror the SubscribeAsync/ReadAsync wrap idiom: snapshot the session
|
||||
// reference once, fail fast with a typed exception if the link is
|
||||
@@ -723,27 +736,83 @@ public class RealOpcUaClient : IOpcUaClient
|
||||
? ObjectIds.ObjectsFolder
|
||||
: NodeId.Parse(parentNodeId);
|
||||
|
||||
// NodeClassMask intentionally excludes ReferenceType, View, Variable-
|
||||
// Type, ObjectType, DataType. UI only needs Objects (navigable),
|
||||
// Variables (selectable), Methods (display-only).
|
||||
var nodeClassMask = (uint)(NodeClass.Object | NodeClass.Variable | NodeClass.Method);
|
||||
// No token → fresh browse of the node. A non-empty token → continue a
|
||||
// prior browse via BrowseNext, falling back to a fresh browse if the
|
||||
// server has invalidated the continuation point.
|
||||
if (string.IsNullOrEmpty(continuationToken))
|
||||
{
|
||||
return await FreshBrowseAsync(session, nodeToBrowse, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// requestedMaxReferencesPerNode: cap the server's per-call references so a
|
||||
// huge flat folder cannot return an unbounded set. 500 leaves headroom for
|
||||
// the downstream frame-size budget (DataConnectionActor.CapBrowseChildren)
|
||||
// even with long string NodeIds; a non-empty continuation point surfaces as
|
||||
// Truncated, prompting manual entry rather than auto-paging.
|
||||
try
|
||||
{
|
||||
// SDK overload: BrowseNextAsync(session, requestHeader, ByteStringCollection
|
||||
// continuationPoints, bool releaseContinuationPoint, ct) → returns
|
||||
// (ResponseHeader, ByteStringCollection revisedContinuationPoints,
|
||||
// IList<ReferenceDescriptionCollection> results, IList<ServiceResult> errors).
|
||||
// releaseContinuationPoint:false keeps the point alive so the next page
|
||||
// can be fetched; we pass back exactly the one point we were handed.
|
||||
var (_, revisedPoints, results, _) = await session.BrowseNextAsync(
|
||||
null,
|
||||
new ByteStringCollection { Convert.FromBase64String(continuationToken) },
|
||||
false,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var nextPoint = revisedPoints is { Count: > 0 } ? revisedPoints[0] : null;
|
||||
var refs = results is { Count: > 0 } ? results[0] : null;
|
||||
return await BuildResultAsync(session, refs, nextPoint, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (ServiceResultException ex) when (
|
||||
ex.StatusCode == StatusCodes.BadContinuationPointInvalid ||
|
||||
ex.StatusCode == StatusCodes.BadInvalidArgument)
|
||||
{
|
||||
// The continuation point expired or was rejected (e.g. a fresh
|
||||
// session, or the server timed it out). Recover by re-browsing the
|
||||
// parent from the start and returning its first page.
|
||||
_logger.LogDebug(ex,
|
||||
"OPC UA BrowseNext rejected the continuation point (status {Status:X8}); " +
|
||||
"falling back to a fresh browse of {Node}.", ex.StatusCode, nodeToBrowse);
|
||||
return await FreshBrowseAsync(session, nodeToBrowse, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Issues a fresh <see cref="SessionClientExtensions.BrowseAsync"/> of
|
||||
/// <paramref name="nodeToBrowse"/> and returns its first page, surfacing any
|
||||
/// continuation point as a token for paging.
|
||||
/// </summary>
|
||||
private async Task<Commons.Interfaces.Protocol.BrowseChildrenResult> FreshBrowseAsync(
|
||||
ISession session, NodeId nodeToBrowse, CancellationToken cancellationToken)
|
||||
{
|
||||
var (_, continuationPoint, references) = await session.BrowseAsync(
|
||||
null,
|
||||
null,
|
||||
nodeToBrowse,
|
||||
500u,
|
||||
BrowseMaxReferencesPerNode,
|
||||
BrowseDirection.Forward,
|
||||
ReferenceTypeIds.HierarchicalReferences,
|
||||
true,
|
||||
nodeClassMask,
|
||||
BrowseNodeClassMask,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return await BuildResultAsync(session, references, continuationPoint, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shared "build children + B1 type enrichment + continuation token" logic
|
||||
/// used by both the initial browse and the BrowseNext paths so the
|
||||
/// enrichment is never duplicated (T15). A non-empty
|
||||
/// <paramref name="continuationPoint"/> is surfaced as a Base64
|
||||
/// <c>ContinuationToken</c> with <c>Truncated=true</c>; otherwise the result
|
||||
/// is exhausted (<c>ContinuationToken=null, Truncated=false</c>).
|
||||
/// </summary>
|
||||
private async Task<Commons.Interfaces.Protocol.BrowseChildrenResult> BuildResultAsync(
|
||||
ISession session,
|
||||
ReferenceDescriptionCollection? references,
|
||||
byte[]? continuationPoint,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var refs = references ?? new ReferenceDescriptionCollection();
|
||||
var children = new List<Commons.Interfaces.Protocol.BrowseNode>(refs.Count);
|
||||
foreach (var r in refs)
|
||||
@@ -755,22 +824,22 @@ public class RealOpcUaClient : IOpcUaClient
|
||||
HasChildren: r.NodeClass == NodeClass.Object));
|
||||
}
|
||||
|
||||
// T16 type-info: enrich Variable rows with DataType / ValueRank /
|
||||
// B1 type-info: enrich Variable rows with DataType / ValueRank /
|
||||
// Writable so the node picker can show types. Best-effort: ONE batched
|
||||
// ReadAsync over (DataType, ValueRank, UserAccessLevel) for every
|
||||
// Variable child; on ANY failure we leave the three fields null and
|
||||
// return the children exactly as built above. Non-Variable nodes are
|
||||
// never read and keep null type info.
|
||||
// never read and keep null type info. Runs identically on the browse
|
||||
// and browse-next pages (T15 shares this path).
|
||||
children = await EnrichVariableTypeInfoAsync(session, refs, children, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
// A non-empty continuation point means the server had more refs than
|
||||
// our requestedMaxReferencesPerNode cap. The UI surfaces a "more
|
||||
// children, type the node id manually" hint rather than auto-paging;
|
||||
// BrowseNext is not invoked here. Discarding the continuation point
|
||||
// is acceptable because the server expires it on session close.
|
||||
var truncated = continuationPoint != null && continuationPoint.Length > 0;
|
||||
return new Commons.Interfaces.Protocol.BrowseChildrenResult(children, truncated);
|
||||
// A non-empty continuation point means the server has more refs than
|
||||
// were returned on this page. Surface it as an opaque Base64 token the
|
||||
// caller passes back to fetch the next page via BrowseNext (T15).
|
||||
var hasMore = continuationPoint != null && continuationPoint.Length > 0;
|
||||
var token = hasMore ? Convert.ToBase64String(continuationPoint!) : null;
|
||||
return new Commons.Interfaces.Protocol.BrowseChildrenResult(children, hasMore, token);
|
||||
}
|
||||
|
||||
private static Commons.Interfaces.Protocol.BrowseNodeClass MapNodeClass(NodeClass nc) => nc switch
|
||||
|
||||
Reference in New Issue
Block a user