feat(dcl): bounded recursive OPC UA address-space search adapter (T15)
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;
|
||||
|
||||
/// <summary>
|
||||
/// M7-B4 (T15): optional capability for an <see cref="IDataConnection"/>
|
||||
/// implementation that supports a bounded recursive search of the server's
|
||||
/// address space. A complement to <see cref="IBrowsableDataConnection"/>:
|
||||
/// where browse walks one level at a time on user demand, search walks the
|
||||
/// tree itself (bounded by depth + result caps) and returns the nodes whose
|
||||
/// DisplayName or root-relative path contains a query substring.
|
||||
///
|
||||
/// Consumed only by management/UI flows (e.g. the OPC UA tag picker's
|
||||
/// "find a tag" box on the instance config page) — never by Instance Actors
|
||||
/// on the hot path.
|
||||
/// </summary>
|
||||
public interface IAddressSpaceSearchable
|
||||
{
|
||||
/// <summary>
|
||||
/// Walks the server's address space breadth-first from the root, bounded by
|
||||
/// <paramref name="maxDepth"/> and <paramref name="maxResults"/>, returning
|
||||
/// the nodes whose DisplayName or root-relative path contains
|
||||
/// <paramref name="query"/> (case-insensitive substring match).
|
||||
/// </summary>
|
||||
/// <param name="query">Case-insensitive substring to match against each node's DisplayName and its root-relative path. An empty/whitespace query matches nothing (the walk is skipped).</param>
|
||||
/// <param name="maxDepth">Maximum number of levels below the root to descend (Object nodes deeper than this are not expanded). Must be non-negative.</param>
|
||||
/// <param name="maxResults">Maximum number of matches to return; when reached the walk stops early and <see cref="AddressSpaceSearchResult.CapReached"/> is set true.</param>
|
||||
/// <param name="cancellationToken">Cancellation token; on cancellation the implementation should throw <see cref="OperationCanceledException"/>.</param>
|
||||
/// <returns>A task that resolves to the matches found and a flag indicating whether a bound (result cap or node-visit ceiling) cut the walk short.</returns>
|
||||
Task<AddressSpaceSearchResult> SearchAddressSpaceAsync(
|
||||
string query,
|
||||
int maxDepth,
|
||||
int maxResults,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <param name="Node">The matched address-space node, carrying its NodeId, DisplayName, NodeClass and (B1) type info.</param>
|
||||
/// <param name="Path">The matched node's root-relative path: the slash-joined DisplayName chain from the root down to the node (e.g. <c>"/Folder1/Tag1"</c>).</param>
|
||||
public record AddressSpaceMatch(BrowseNode Node, string Path);
|
||||
|
||||
/// <param name="Matches">The matched nodes, in breadth-first discovery order.</param>
|
||||
/// <param name="CapReached">True when a bound cut the walk short — either <c>maxResults</c> matches were collected or the implementation's node-visit ceiling was hit — so more matches may exist than were returned.</param>
|
||||
public record AddressSpaceSearchResult(
|
||||
IReadOnlyList<AddressSpaceMatch> Matches,
|
||||
bool CapReached);
|
||||
@@ -148,6 +148,113 @@ public interface IOpcUaClient : IAsyncDisposable
|
||||
string? parentNodeId,
|
||||
string? continuationToken = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// M7-B4 (T15): walks the server's address space breadth-first from the
|
||||
/// root (ObjectsFolder), bounded by <paramref name="maxDepth"/> and
|
||||
/// <paramref name="maxResults"/>, returning the nodes whose DisplayName or
|
||||
/// root-relative path contains <paramref name="query"/> (case-insensitive
|
||||
/// substring). Implemented over this client's own
|
||||
/// <see cref="BrowseChildrenAsync"/> (paging the continuation per node so
|
||||
/// no children are missed). Throws
|
||||
/// <see cref="ConnectionNotConnectedException"/> when the session is not
|
||||
/// currently up. An empty/whitespace query matches nothing.
|
||||
/// </summary>
|
||||
/// <param name="query">Case-insensitive substring matched against each node's DisplayName and root-relative path; empty/whitespace matches nothing.</param>
|
||||
/// <param name="maxDepth">Maximum number of levels below the root to descend.</param>
|
||||
/// <param name="maxResults">Maximum matches to return; when reached the walk stops early and the result's CapReached flag is set.</param>
|
||||
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
|
||||
/// <returns>A task that completes with the matches found and a flag indicating whether a bound cut the walk short.</returns>
|
||||
Task<AddressSpaceSearchResult> SearchAddressSpaceAsync(
|
||||
string query,
|
||||
int maxDepth,
|
||||
int maxResults,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// M7-B4 (T15): shared bounded breadth-first address-space search, expressed
|
||||
/// purely over an injected browse delegate so the stub and real OPC UA clients
|
||||
/// run the IDENTICAL walk (matching, continuation-paging, depth bound, result
|
||||
/// cap, node-visit ceiling, cancellation). The delegate is each client's own
|
||||
/// <see cref="IOpcUaClient.BrowseChildrenAsync"/>.
|
||||
/// </summary>
|
||||
internal static class AddressSpaceSearch
|
||||
{
|
||||
// Total nodes visited before the walk gives up and reports CapReached, even
|
||||
// if maxResults was not hit. Guards against a pathological / cyclic address
|
||||
// space (v1 keeps no visited-set) and runaway breadth. Generous relative to
|
||||
// any realistic depth+result bound a UI search would request.
|
||||
internal const int VisitedNodeCeiling = 10_000;
|
||||
|
||||
/// <summary>
|
||||
/// Runs the bounded BFS. <paramref name="browse"/> mirrors
|
||||
/// <see cref="IBrowsableDataConnection.BrowseChildrenAsync"/>
|
||||
/// (parentNodeId, continuationToken, ct) → page of children.
|
||||
/// </summary>
|
||||
internal static async Task<AddressSpaceSearchResult> SearchAsync(
|
||||
Func<string?, string?, CancellationToken, Task<BrowseChildrenResult>> browse,
|
||||
string query,
|
||||
int maxDepth,
|
||||
int maxResults,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Empty/whitespace query → no walk, no matches (matches nothing).
|
||||
if (string.IsNullOrWhiteSpace(query) || maxResults <= 0)
|
||||
return new AddressSpaceSearchResult(Array.Empty<AddressSpaceMatch>(), CapReached: false);
|
||||
|
||||
var matches = new List<AddressSpaceMatch>();
|
||||
var capReached = false;
|
||||
var visited = 0;
|
||||
|
||||
// Queue of (nodeId, pathPrefix, depth). Root = null nodeId, "" prefix,
|
||||
// depth 0; its children sit at depth 1.
|
||||
var queue = new Queue<(string? NodeId, string PathPrefix, int Depth)>();
|
||||
queue.Enqueue((null, string.Empty, 0));
|
||||
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var (nodeId, pathPrefix, depth) = queue.Dequeue();
|
||||
|
||||
// Page the continuation per node so no children are missed: the
|
||||
// first call starts a fresh browse; while a continuation token is
|
||||
// returned, fetch the next page and keep processing.
|
||||
string? continuationToken = null;
|
||||
do
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var page = await browse(nodeId, continuationToken, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
foreach (var child in page.Children)
|
||||
{
|
||||
if (++visited > VisitedNodeCeiling)
|
||||
return new AddressSpaceSearchResult(matches, CapReached: true);
|
||||
|
||||
var path = pathPrefix + "/" + child.DisplayName;
|
||||
if (Contains(child.DisplayName, query) || Contains(path, query))
|
||||
{
|
||||
matches.Add(new AddressSpaceMatch(child, path));
|
||||
if (matches.Count >= maxResults)
|
||||
return new AddressSpaceSearchResult(matches, CapReached: true);
|
||||
}
|
||||
|
||||
// Descend into Object nodes within the depth budget. depth is
|
||||
// the parent's depth; children sit at depth + 1.
|
||||
if (child.NodeClass == BrowseNodeClass.Object && depth < maxDepth)
|
||||
queue.Enqueue((child.NodeId, path, depth + 1));
|
||||
}
|
||||
|
||||
continuationToken = page.Truncated ? page.ContinuationToken : null;
|
||||
}
|
||||
while (!string.IsNullOrEmpty(continuationToken));
|
||||
}
|
||||
|
||||
return new AddressSpaceSearchResult(matches, capReached);
|
||||
}
|
||||
|
||||
private static bool Contains(string haystack, string needle) =>
|
||||
haystack.Contains(needle, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -284,6 +391,16 @@ internal class StubOpcUaClient : IOpcUaClient
|
||||
return Task.FromResult(new BrowseChildrenResult(Array.Empty<BrowseNode>(), Truncated: false));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<AddressSpaceSearchResult> SearchAddressSpaceAsync(
|
||||
string query, int maxDepth, int maxResults, CancellationToken cancellationToken = default)
|
||||
// Search the canned tree by walking the stub's OWN BrowseChildrenAsync via
|
||||
// the shared BFS — identical algorithm to RealOpcUaClient. Because the
|
||||
// helper pages Folder1's continuation, a "Tag" query finds both Tag1 (page 1)
|
||||
// and Tag2 (page 2).
|
||||
=> AddressSpaceSearch.SearchAsync(
|
||||
BrowseChildrenAsync, query, maxDepth, maxResults, cancellationToken);
|
||||
|
||||
/// <summary>Disposes this stub client and marks the connection as closed.</summary>
|
||||
/// <returns>A completed <see cref="ValueTask"/>.</returns>
|
||||
public ValueTask DisposeAsync()
|
||||
|
||||
@@ -17,7 +17,7 @@ namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
|
||||
/// - Read/Write → Read/Write service calls
|
||||
/// - Quality → OPC UA StatusCode mapping
|
||||
/// </summary>
|
||||
public class OpcUaDataConnection : IDataConnection, IBrowsableDataConnection, IAlarmSubscribableConnection
|
||||
public class OpcUaDataConnection : IDataConnection, IBrowsableDataConnection, IAlarmSubscribableConnection, IAddressSpaceSearchable
|
||||
{
|
||||
private readonly IOpcUaClientFactory _clientFactory;
|
||||
private readonly ILogger<OpcUaDataConnection> _logger;
|
||||
@@ -302,6 +302,19 @@ public class OpcUaDataConnection : IDataConnection, IBrowsableDataConnection, IA
|
||||
CancellationToken cancellationToken = default)
|
||||
=> _client!.BrowseChildrenAsync(parentNodeId, continuationToken, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<AddressSpaceSearchResult> SearchAddressSpaceAsync(
|
||||
string query, int maxDepth, int maxResults, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Guard connection state with the typed ConnectionNotConnectedException so
|
||||
// the site-side handler can map it uniformly (mirrors BrowseChildrenAsync,
|
||||
// whose underlying client throws the same type when the session is down).
|
||||
if (_client == null || !_client.IsConnected)
|
||||
throw new ConnectionNotConnectedException("OPC UA client is not connected.");
|
||||
|
||||
return _client.SearchAddressSpaceAsync(query, maxDepth, maxResults, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> WriteBatchAndWaitAsync(
|
||||
IDictionary<string, object?> values, string flagPath, object? flagValue,
|
||||
|
||||
@@ -776,6 +776,28 @@ public class RealOpcUaClient : IOpcUaClient
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<Commons.Interfaces.Protocol.AddressSpaceSearchResult> SearchAddressSpaceAsync(
|
||||
string query, int maxDepth, int maxResults, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Fail fast with the typed exception when the link is down — mirrors the
|
||||
// BrowseChildrenAsync guard. (BrowseChildrenAsync would also throw on the
|
||||
// first page, but guarding here keeps the empty-query / cap-0 short-circuit
|
||||
// from masking a disconnected session.)
|
||||
var session = _session;
|
||||
if (session is null || !session.Connected)
|
||||
{
|
||||
throw new Commons.Interfaces.Protocol.ConnectionNotConnectedException(
|
||||
"OPC UA session is not connected.");
|
||||
}
|
||||
|
||||
// Bounded BFS over this client's OWN BrowseChildrenAsync — the shared
|
||||
// helper pages each node's continuation (BrowseNext) to the end, so a
|
||||
// big folder's later pages are searched too, not just the first page.
|
||||
return AddressSpaceSearch.SearchAsync(
|
||||
BrowseChildrenAsync, query, maxDepth, maxResults, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Issues a fresh <see cref="SessionClientExtensions.BrowseAsync"/> of
|
||||
/// <paramref name="nodeToBrowse"/> and returns its first page, surfacing any
|
||||
|
||||
Reference in New Issue
Block a user