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);
+ }
+}