From c00c8241b30423a54871384f8d97d8067d903659 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 18 Jun 2026 02:45:01 -0400 Subject: [PATCH] feat(dcl): bounded recursive OPC UA address-space search adapter (T15) --- .../Protocol/IAddressSpaceSearchable.cs | 43 +++++++ .../Adapters/IOpcUaClient.cs | 117 ++++++++++++++++++ .../Adapters/OpcUaDataConnection.cs | 15 ++- .../Adapters/RealOpcUaClient.cs | 22 ++++ .../StubOpcUaClientSearchTests.cs | 112 +++++++++++++++++ 5 files changed, 308 insertions(+), 1 deletion(-) create mode 100644 src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Protocol/IAddressSpaceSearchable.cs create mode 100644 tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/StubOpcUaClientSearchTests.cs diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Protocol/IAddressSpaceSearchable.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Protocol/IAddressSpaceSearchable.cs new file mode 100644 index 00000000..13f75c12 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Protocol/IAddressSpaceSearchable.cs @@ -0,0 +1,43 @@ +namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol; + +/// +/// M7-B4 (T15): optional capability for an +/// implementation that supports a bounded recursive search of the server's +/// address space. A complement to : +/// 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. +/// +public interface IAddressSpaceSearchable +{ + /// + /// Walks the server's address space breadth-first from the root, bounded by + /// and , returning + /// the nodes whose DisplayName or root-relative path contains + /// (case-insensitive substring match). + /// + /// 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). + /// Maximum number of levels below the root to descend (Object nodes deeper than this are not expanded). Must be non-negative. + /// Maximum number of matches to return; when reached the walk stops early and is set true. + /// Cancellation token; on cancellation the implementation should throw . + /// 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. + Task SearchAddressSpaceAsync( + string query, + int maxDepth, + int maxResults, + CancellationToken cancellationToken = default); +} + +/// The matched address-space node, carrying its NodeId, DisplayName, NodeClass and (B1) type info. +/// The matched node's root-relative path: the slash-joined DisplayName chain from the root down to the node (e.g. "/Folder1/Tag1"). +public record AddressSpaceMatch(BrowseNode Node, string Path); + +/// The matched nodes, in breadth-first discovery order. +/// True when a bound cut the walk short — either maxResults matches were collected or the implementation's node-visit ceiling was hit — so more matches may exist than were returned. +public record AddressSpaceSearchResult( + IReadOnlyList Matches, + bool CapReached); diff --git a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/IOpcUaClient.cs b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/IOpcUaClient.cs index eda70ce8..c57c2d51 100644 --- a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/IOpcUaClient.cs +++ b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/IOpcUaClient.cs @@ -148,6 +148,113 @@ public interface IOpcUaClient : IAsyncDisposable string? parentNodeId, string? continuationToken = null, CancellationToken cancellationToken = default); + + /// + /// M7-B4 (T15): walks the server's address space breadth-first from the + /// root (ObjectsFolder), bounded by and + /// , returning the nodes whose DisplayName or + /// root-relative path contains (case-insensitive + /// substring). Implemented over this client's own + /// (paging the continuation per node so + /// no children are missed). Throws + /// when the session is not + /// currently up. An empty/whitespace query matches nothing. + /// + /// Case-insensitive substring matched against each node's DisplayName and root-relative path; empty/whitespace matches nothing. + /// Maximum number of levels below the root to descend. + /// Maximum matches to return; when reached the walk stops early and the result's CapReached flag is set. + /// A cancellation token that can be used to cancel the operation. + /// A task that completes with the matches found and a flag indicating whether a bound cut the walk short. + Task SearchAddressSpaceAsync( + string query, + int maxDepth, + int maxResults, + CancellationToken cancellationToken = default); +} + +/// +/// 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 +/// . +/// +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; + + /// + /// Runs the bounded BFS. mirrors + /// + /// (parentNodeId, continuationToken, ct) → page of children. + /// + internal static async Task SearchAsync( + Func> 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(), CapReached: false); + + var matches = new List(); + 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); } /// @@ -284,6 +391,16 @@ internal class StubOpcUaClient : IOpcUaClient return Task.FromResult(new BrowseChildrenResult(Array.Empty(), Truncated: false)); } + /// + public Task 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); + /// Disposes this stub client and marks the connection as closed. /// A completed . public ValueTask DisposeAsync() diff --git a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/OpcUaDataConnection.cs b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/OpcUaDataConnection.cs index cf580725..3136ad03 100644 --- a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/OpcUaDataConnection.cs +++ b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/OpcUaDataConnection.cs @@ -17,7 +17,7 @@ namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters; /// - Read/Write → Read/Write service calls /// - Quality → OPC UA StatusCode mapping /// -public class OpcUaDataConnection : IDataConnection, IBrowsableDataConnection, IAlarmSubscribableConnection +public class OpcUaDataConnection : IDataConnection, IBrowsableDataConnection, IAlarmSubscribableConnection, IAddressSpaceSearchable { private readonly IOpcUaClientFactory _clientFactory; private readonly ILogger _logger; @@ -302,6 +302,19 @@ public class OpcUaDataConnection : IDataConnection, IBrowsableDataConnection, IA CancellationToken cancellationToken = default) => _client!.BrowseChildrenAsync(parentNodeId, continuationToken, cancellationToken); + /// + public Task 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); + } + /// public async Task WriteBatchAndWaitAsync( IDictionary values, string flagPath, object? flagValue, diff --git a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/RealOpcUaClient.cs b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/RealOpcUaClient.cs index c0a781e1..322e118f 100644 --- a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/RealOpcUaClient.cs +++ b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/RealOpcUaClient.cs @@ -776,6 +776,28 @@ public class RealOpcUaClient : IOpcUaClient } } + /// + public Task 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); + } + /// /// Issues a fresh of /// and returns its first page, surfacing any diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/StubOpcUaClientSearchTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/StubOpcUaClientSearchTests.cs new file mode 100644 index 00000000..51e8f263 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/StubOpcUaClientSearchTests.cs @@ -0,0 +1,112 @@ +using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol; +using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters; + +namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests; + +/// +/// M7-B4 (T15): searches its canned address-space +/// tree (root → Folder1/Folder2; Folder1 → Tag1/Tag2) so DCL address-space +/// search flows can be exercised without a live OPC UA server. The stub fakes +/// BrowseNext continuation paging on Folder1, so finding both Tag1 and +/// Tag2 also verifies that the BFS loops the continuation per node. +/// +public class StubOpcUaClientSearchTests +{ + // StubOpcUaClient is internal; reached here via InternalsVisibleTo on the + // DataConnectionLayer assembly (mirrors StubOpcUaClientBrowseTests). + private static async Task ConnectedStubAsync() + { + var client = new StubOpcUaClient(); + await client.ConnectAsync("opc.tcp://stub", null, CancellationToken.None); + return client; + } + + [Fact] + public async Task Search_Tag_FindsBothTags_WithRootRelativePaths() + { + await using var client = await ConnectedStubAsync(); + + var result = await client.SearchAddressSpaceAsync("Tag", maxDepth: 5, maxResults: 50); + + Assert.False(result.CapReached); + // Tag2 lives on page 2 of Folder1's children, so finding it proves the + // BFS loops the stub's continuation token rather than stopping at page 1. + var paths = result.Matches.Select(m => m.Path).OrderBy(p => p).ToArray(); + Assert.Equal(new[] { "/Folder1/Tag1", "/Folder1/Tag2" }, paths); + Assert.All(result.Matches, m => Assert.Contains("Tag", m.Node.DisplayName)); + } + + [Fact] + public async Task Search_Folder_MatchesOnDisplayNameAndPath() + { + await using var client = await ConnectedStubAsync(); + + var result = await client.SearchAddressSpaceAsync("Folder", maxDepth: 5, maxResults: 50); + + Assert.False(result.CapReached); + // The contract matches DisplayName OR root-relative path. "Folder" hits + // both folders by DisplayName AND both tags by path (they live under + // /Folder1), so all four nodes match. + var paths = result.Matches.Select(m => m.Path).OrderBy(p => p).ToArray(); + Assert.Equal(new[] { "/Folder1", "/Folder1/Tag1", "/Folder1/Tag2", "/Folder2" }, paths); + } + + [Fact] + public async Task Search_Folder2_MatchesOnlyTheFolderByDisplayName() + { + await using var client = await ConnectedStubAsync(); + + // "Folder2" appears in no other node's DisplayName or path, so it isolates + // a single match — proving the substring match is not over-broad. + var result = await client.SearchAddressSpaceAsync("Folder2", maxDepth: 5, maxResults: 50); + + Assert.False(result.CapReached); + var only = Assert.Single(result.Matches); + Assert.Equal("/Folder2", only.Path); + Assert.Equal("Folder2", only.Node.DisplayName); + } + + [Fact] + public async Task Search_IsCaseInsensitive() + { + await using var client = await ConnectedStubAsync(); + + var result = await client.SearchAddressSpaceAsync("tag", maxDepth: 5, maxResults: 50); + + Assert.Equal(2, result.Matches.Count); + } + + [Fact] + public async Task Search_MaxResultsOne_StopsEarly_WithCapReached() + { + await using var client = await ConnectedStubAsync(); + + var result = await client.SearchAddressSpaceAsync("Tag", maxDepth: 5, maxResults: 1); + + Assert.True(result.CapReached); + var only = Assert.Single(result.Matches); + Assert.Contains("Tag", only.Node.DisplayName); + } + + [Fact] + public async Task Search_EmptyQuery_ReturnsNoMatches() + { + await using var client = await ConnectedStubAsync(); + + var result = await client.SearchAddressSpaceAsync("", maxDepth: 5, maxResults: 50); + + Assert.Empty(result.Matches); + Assert.False(result.CapReached); + } + + [Fact] + public async Task Search_WhitespaceQuery_ReturnsNoMatches() + { + await using var client = await ConnectedStubAsync(); + + var result = await client.SearchAddressSpaceAsync(" ", maxDepth: 5, maxResults: 50); + + Assert.Empty(result.Matches); + Assert.False(result.CapReached); + } +}