diff --git a/clients/dotnet/ZB.MOM.WW.MxGateway.Client.Tests/FakeGalaxyRepositoryTransport.cs b/clients/dotnet/ZB.MOM.WW.MxGateway.Client.Tests/FakeGalaxyRepositoryTransport.cs
index ac12537..1ec00d1 100644
--- a/clients/dotnet/ZB.MOM.WW.MxGateway.Client.Tests/FakeGalaxyRepositoryTransport.cs
+++ b/clients/dotnet/ZB.MOM.WW.MxGateway.Client.Tests/FakeGalaxyRepositoryTransport.cs
@@ -123,6 +123,39 @@ internal sealed class FakeGalaxyRepositoryTransport(MxGatewayClientOptions optio
: DiscoverHierarchyReply);
}
+ /// Records BrowseChildren RPC calls made by the client.
+ public List<(BrowseChildrenRequest Request, CallOptions CallOptions)> BrowseChildrenCalls { get; } = [];
+
+ /// Default reply returned from BrowseChildren when the queue is empty.
+ public BrowseChildrenReply BrowseChildrenReply { get; set; } = new();
+
+ /// Queue of replies returned from BrowseChildren; dequeued in FIFO order.
+ public Queue BrowseChildrenReplies { get; } = new();
+
+ /// Queue of exceptions to throw from BrowseChildren; dequeued in FIFO order.
+ public Queue BrowseChildrenExceptions { get; } = new();
+
+ ///
+ /// Records the request and either throws a queued exception or returns the configured reply.
+ ///
+ /// The BrowseChildrenRequest to process.
+ /// Call options specifying RPC behavior.
+ public Task BrowseChildrenAsync(
+ BrowseChildrenRequest request,
+ CallOptions callOptions)
+ {
+ BrowseChildrenCalls.Add((request, callOptions));
+ if (BrowseChildrenExceptions.TryDequeue(out Exception? exception))
+ {
+ return Task.FromException(exception);
+ }
+
+ return Task.FromResult(
+ BrowseChildrenReplies.TryDequeue(out BrowseChildrenReply? reply)
+ ? reply
+ : BrowseChildrenReply);
+ }
+
///
/// Gets the list of WatchDeployEvents RPC calls made by the client.
///
diff --git a/clients/dotnet/ZB.MOM.WW.MxGateway.Client.Tests/LazyBrowseNodeTests.cs b/clients/dotnet/ZB.MOM.WW.MxGateway.Client.Tests/LazyBrowseNodeTests.cs
new file mode 100644
index 0000000..96060b9
--- /dev/null
+++ b/clients/dotnet/ZB.MOM.WW.MxGateway.Client.Tests/LazyBrowseNodeTests.cs
@@ -0,0 +1,188 @@
+using Grpc.Core;
+using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
+
+namespace ZB.MOM.WW.MxGateway.Client.Tests;
+
+///
+/// Tests for the walker over the BrowseChildren RPC.
+///
+public sealed class LazyBrowseNodeTests
+{
+ ///
+ /// Verifies that calling BrowseAsync with no parent returns the root nodes
+ /// from the first BrowseChildren reply and surfaces the per-child has-children hint.
+ ///
+ [Fact]
+ public async Task Browse_NoParent_ReturnsRoots()
+ {
+ FakeGalaxyRepositoryTransport transport = CreateTransport();
+ transport.BrowseChildrenReplies.Enqueue(BuildReply(
+ children: [BuildObject(1, "Plant", isArea: true), BuildObject(2, "Other")],
+ childHasChildren: [true, false],
+ cacheSequence: 1));
+ await using GalaxyRepositoryClient client = CreateClient(transport);
+
+ IReadOnlyList roots = await client.BrowseAsync();
+
+ Assert.Equal(2, roots.Count);
+ Assert.Equal("Plant", roots[0].Object.TagName);
+ Assert.True(roots[0].HasChildrenHint);
+ Assert.False(roots[0].IsExpanded);
+ Assert.Equal("Other", roots[1].Object.TagName);
+ Assert.False(roots[1].HasChildrenHint);
+ Assert.False(roots[1].IsExpanded);
+ }
+
+ ///
+ /// Verifies that ExpandAsync populates Children and marks the node expanded after one RPC.
+ ///
+ [Fact]
+ public async Task Expand_PopulatesChildrenAndMarksExpanded()
+ {
+ FakeGalaxyRepositoryTransport transport = CreateTransport();
+ transport.BrowseChildrenReplies.Enqueue(BuildReply(
+ children: [BuildObject(1, "Plant", isArea: true)],
+ childHasChildren: [true],
+ cacheSequence: 1));
+ transport.BrowseChildrenReplies.Enqueue(BuildReply(
+ children: [BuildObject(10, "Line1")],
+ childHasChildren: [false],
+ cacheSequence: 1));
+ await using GalaxyRepositoryClient client = CreateClient(transport);
+
+ IReadOnlyList roots = await client.BrowseAsync();
+ await roots[0].ExpandAsync();
+
+ Assert.True(roots[0].IsExpanded);
+ Assert.Single(roots[0].Children);
+ Assert.Equal("Line1", roots[0].Children[0].Object.TagName);
+ Assert.Equal(2, transport.BrowseChildrenCalls.Count);
+ }
+
+ ///
+ /// Verifies that a second ExpandAsync call is a no-op and issues no additional RPC.
+ ///
+ [Fact]
+ public async Task Expand_CalledTwice_NoSecondRpc()
+ {
+ FakeGalaxyRepositoryTransport transport = CreateTransport();
+ transport.BrowseChildrenReplies.Enqueue(BuildReply(
+ children: [BuildObject(1, "Plant", isArea: true)],
+ childHasChildren: [true],
+ cacheSequence: 1));
+ transport.BrowseChildrenReplies.Enqueue(BuildReply(
+ children: [BuildObject(10, "Line1")],
+ childHasChildren: [false],
+ cacheSequence: 1));
+ await using GalaxyRepositoryClient client = CreateClient(transport);
+
+ IReadOnlyList roots = await client.BrowseAsync();
+ await roots[0].ExpandAsync();
+ await roots[0].ExpandAsync();
+
+ Assert.Equal(2, transport.BrowseChildrenCalls.Count);
+ }
+
+ ///
+ /// Verifies that an RPC failure (NotFound) during expand is wrapped in MxGatewayException.
+ ///
+ [Fact]
+ public async Task Expand_UnknownParent_ThrowsMxGatewayException()
+ {
+ FakeGalaxyRepositoryTransport transport = CreateTransport();
+ transport.BrowseChildrenReplies.Enqueue(BuildReply(
+ children: [BuildObject(1, "Plant", isArea: true)],
+ childHasChildren: [true],
+ cacheSequence: 1));
+ await using GalaxyRepositoryClient client = CreateClient(transport);
+
+ IReadOnlyList roots = await client.BrowseAsync();
+
+ // Queue the failure for the upcoming ExpandAsync call so it consumes
+ // the exception on its first RPC rather than the BrowseAsync above.
+ transport.BrowseChildrenExceptions.Enqueue(
+ new MxGatewayException(
+ "Parent not found",
+ new RpcException(new Status(StatusCode.NotFound, "Parent not found"))));
+
+ await Assert.ThrowsAsync(async () => await roots[0].ExpandAsync());
+ }
+
+ ///
+ /// Verifies that ExpandAsync drains multi-page sibling replies and forwards the page token.
+ ///
+ [Fact]
+ public async Task Expand_MultiPageSiblings_GathersAllPages()
+ {
+ FakeGalaxyRepositoryTransport transport = CreateTransport();
+ // Roots
+ transport.BrowseChildrenReplies.Enqueue(BuildReply(
+ children: [BuildObject(7, "Plant", isArea: true)],
+ childHasChildren: [true],
+ cacheSequence: 1));
+ // First child page (2 children) with a next token
+ BrowseChildrenReply childPage1 = BuildReply(
+ children: [BuildObject(70, "ChildA"), BuildObject(71, "ChildB")],
+ childHasChildren: [false, false],
+ cacheSequence: 1);
+ childPage1.NextPageToken = "7:abc:2";
+ transport.BrowseChildrenReplies.Enqueue(childPage1);
+ // Second child page (1 child) with no next token
+ transport.BrowseChildrenReplies.Enqueue(BuildReply(
+ children: [BuildObject(72, "ChildC")],
+ childHasChildren: [false],
+ cacheSequence: 1));
+ await using GalaxyRepositoryClient client = CreateClient(transport);
+
+ IReadOnlyList roots = await client.BrowseAsync();
+ await roots[0].ExpandAsync();
+
+ Assert.Equal(3, roots[0].Children.Count);
+ Assert.Equal(3, transport.BrowseChildrenCalls.Count);
+ Assert.Equal("7:abc:2", transport.BrowseChildrenCalls[2].Request.PageToken);
+ }
+
+ ///
+ /// Verifies that BrowseChildrenOptions filter fields are forwarded to the BrowseChildren request.
+ ///
+ [Fact]
+ public async Task Browse_WithFilter_ForwardsToRequest()
+ {
+ FakeGalaxyRepositoryTransport transport = CreateTransport();
+ await using GalaxyRepositoryClient client = CreateClient(transport);
+
+ await client.BrowseAsync(new BrowseChildrenOptions
+ {
+ TagNameGlob = "Mixer*",
+ AlarmBearingOnly = true,
+ });
+
+ BrowseChildrenRequest request = Assert.Single(transport.BrowseChildrenCalls).Request;
+ Assert.Equal("Mixer*", request.TagNameGlob);
+ Assert.True(request.AlarmBearingOnly);
+ }
+
+ private static GalaxyObject BuildObject(int id, string tag, bool isArea = false)
+ => new() { GobjectId = id, TagName = tag, BrowseName = tag, IsArea = isArea };
+
+ private static BrowseChildrenReply BuildReply(
+ IReadOnlyList children,
+ IReadOnlyList childHasChildren,
+ ulong cacheSequence)
+ {
+ BrowseChildrenReply reply = new() { TotalChildCount = children.Count, CacheSequence = cacheSequence };
+ reply.Children.AddRange(children);
+ reply.ChildHasChildren.AddRange(childHasChildren);
+ return reply;
+ }
+
+ private static GalaxyRepositoryClient CreateClient(FakeGalaxyRepositoryTransport transport)
+ => new(transport.Options, transport);
+
+ private static FakeGalaxyRepositoryTransport CreateTransport()
+ => new(new MxGatewayClientOptions
+ {
+ Endpoint = new Uri("http://localhost:5000"),
+ ApiKey = "test-api-key",
+ });
+}
diff --git a/clients/dotnet/ZB.MOM.WW.MxGateway.Client/BrowseChildrenOptions.cs b/clients/dotnet/ZB.MOM.WW.MxGateway.Client/BrowseChildrenOptions.cs
new file mode 100644
index 0000000..ea0b01c
--- /dev/null
+++ b/clients/dotnet/ZB.MOM.WW.MxGateway.Client/BrowseChildrenOptions.cs
@@ -0,0 +1,26 @@
+namespace ZB.MOM.WW.MxGateway.Client;
+
+///
+/// Filters and shape options for .
+/// Mirror of for the lazy-browse path.
+///
+public sealed class BrowseChildrenOptions
+{
+ /// Restrict to children whose Galaxy category is in this set.
+ public IReadOnlyList CategoryIds { get; init; } = [];
+
+ /// Restrict to children whose template chain contains any of these tokens.
+ public IReadOnlyList TemplateChainContains { get; init; } = [];
+
+ /// Optional glob-style filter on tag_name.
+ public string? TagNameGlob { get; init; }
+
+ /// Whether to populate each GalaxyObject.Attributes. Null leaves the server default.
+ public bool? IncludeAttributes { get; init; }
+
+ /// Restrict to children that bear at least one alarm attribute.
+ public bool AlarmBearingOnly { get; init; }
+
+ /// Restrict to children that have at least one historized attribute.
+ public bool HistorizedOnly { get; init; }
+}
diff --git a/clients/dotnet/ZB.MOM.WW.MxGateway.Client/GalaxyRepositoryClient.cs b/clients/dotnet/ZB.MOM.WW.MxGateway.Client/GalaxyRepositoryClient.cs
index 37eb422..ac5029b 100644
--- a/clients/dotnet/ZB.MOM.WW.MxGateway.Client/GalaxyRepositoryClient.cs
+++ b/clients/dotnet/ZB.MOM.WW.MxGateway.Client/GalaxyRepositoryClient.cs
@@ -19,6 +19,7 @@ namespace ZB.MOM.WW.MxGateway.Client;
public sealed class GalaxyRepositoryClient : IAsyncDisposable
{
private const int DiscoverHierarchyPageSize = 5000;
+ private const int BrowseChildrenPageSize = 500;
private readonly GrpcChannel? _channel;
private readonly IGalaxyRepositoryClientTransport _transport;
@@ -278,6 +279,89 @@ public sealed class GalaxyRepositoryClient : IAsyncDisposable
cancellationToken);
}
+ /// Returns root-level browse nodes (objects with no parent).
+ /// Cancellation token.
+ /// The list of root instances.
+ public Task> BrowseAsync(CancellationToken cancellationToken = default)
+ => BrowseAsync(null, cancellationToken);
+
+ /// Returns root-level browse nodes filtered by the given options.
+ /// Browse filter options. Null applies no filter.
+ /// Cancellation token.
+ /// The list of root instances.
+ public async Task> BrowseAsync(
+ BrowseChildrenOptions? options,
+ CancellationToken cancellationToken = default)
+ {
+ BrowseChildrenOptions effective = options ?? new BrowseChildrenOptions();
+ List roots = [];
+ string pageToken = string.Empty;
+ HashSet seenPageTokens = new(StringComparer.Ordinal);
+ do
+ {
+ BrowseChildrenRequest request = BuildBrowseChildrenRequest(effective);
+ request.PageToken = pageToken;
+ BrowseChildrenReply reply = await BrowseChildrenRawAsync(request, cancellationToken).ConfigureAwait(false);
+
+ for (int i = 0; i < reply.Children.Count; i++)
+ {
+ bool hint = i < reply.ChildHasChildren.Count && reply.ChildHasChildren[i];
+ roots.Add(new LazyBrowseNode(this, reply.Children[i], hint, effective));
+ }
+
+ pageToken = reply.NextPageToken;
+ if (!string.IsNullOrWhiteSpace(pageToken) && !seenPageTokens.Add(pageToken))
+ {
+ throw new MxGatewayException(
+ $"Galaxy BrowseChildren returned a repeated page token '{pageToken}'.");
+ }
+ }
+ while (!string.IsNullOrWhiteSpace(pageToken));
+
+ return roots;
+ }
+
+ /// Issues a raw BrowseChildren RPC without result wrapping.
+ /// The browse-children request.
+ /// Cancellation token.
+ /// The raw server reply.
+ public Task BrowseChildrenRawAsync(
+ BrowseChildrenRequest request,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(request);
+ ThrowIfDisposed();
+
+ return ExecuteSafeUnaryAsync(
+ token => _transport.BrowseChildrenAsync(request, CreateCallOptions(token)),
+ cancellationToken);
+ }
+
+ internal static BrowseChildrenRequest BuildBrowseChildrenRequest(BrowseChildrenOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+
+ BrowseChildrenRequest request = new()
+ {
+ PageSize = BrowseChildrenPageSize,
+ AlarmBearingOnly = options.AlarmBearingOnly,
+ HistorizedOnly = options.HistorizedOnly,
+ };
+ request.CategoryIds.Add(options.CategoryIds);
+ request.TemplateChainContains.Add(options.TemplateChainContains);
+ if (!string.IsNullOrWhiteSpace(options.TagNameGlob))
+ {
+ request.TagNameGlob = options.TagNameGlob;
+ }
+
+ if (options.IncludeAttributes.HasValue)
+ {
+ request.IncludeAttributes = options.IncludeAttributes.Value;
+ }
+
+ return request;
+ }
+
///
/// Subscribes to Galaxy deploy events. The server emits a bootstrap event with the
/// current state on subscribe so callers can prime their cache, then emits one event
diff --git a/clients/dotnet/ZB.MOM.WW.MxGateway.Client/GrpcGalaxyRepositoryClientTransport.cs b/clients/dotnet/ZB.MOM.WW.MxGateway.Client/GrpcGalaxyRepositoryClientTransport.cs
index 066ab71..e51337e 100644
--- a/clients/dotnet/ZB.MOM.WW.MxGateway.Client/GrpcGalaxyRepositoryClientTransport.cs
+++ b/clients/dotnet/ZB.MOM.WW.MxGateway.Client/GrpcGalaxyRepositoryClientTransport.cs
@@ -74,6 +74,23 @@ internal sealed class GrpcGalaxyRepositoryClientTransport(
}
}
+ ///
+ public async Task BrowseChildrenAsync(
+ BrowseChildrenRequest request,
+ CallOptions callOptions)
+ {
+ try
+ {
+ return await RawClient.BrowseChildrenAsync(request, callOptions)
+ .ResponseAsync
+ .ConfigureAwait(false);
+ }
+ catch (RpcException exception)
+ {
+ throw MapRpcException(exception, callOptions.CancellationToken);
+ }
+ }
+
///
public async IAsyncEnumerable WatchDeployEventsAsync(
WatchDeployEventsRequest request,
diff --git a/clients/dotnet/ZB.MOM.WW.MxGateway.Client/IGalaxyRepositoryClientTransport.cs b/clients/dotnet/ZB.MOM.WW.MxGateway.Client/IGalaxyRepositoryClientTransport.cs
index 4c7a42a..ddc5dcc 100644
--- a/clients/dotnet/ZB.MOM.WW.MxGateway.Client/IGalaxyRepositoryClientTransport.cs
+++ b/clients/dotnet/ZB.MOM.WW.MxGateway.Client/IGalaxyRepositoryClientTransport.cs
@@ -33,6 +33,13 @@ internal interface IGalaxyRepositoryClientTransport
DiscoverHierarchyRequest request,
CallOptions callOptions);
+ /// Returns direct children of a parent in the Galaxy hierarchy.
+ /// The browse children request.
+ /// gRPC call options (timeout, cancellation, etc.).
+ Task BrowseChildrenAsync(
+ BrowseChildrenRequest request,
+ CallOptions callOptions);
+
/// Watches for deployment events from the Galaxy Repository server.
/// The watch deploy events request.
/// gRPC call options (timeout, cancellation, etc.).
diff --git a/clients/dotnet/ZB.MOM.WW.MxGateway.Client/LazyBrowseNode.cs b/clients/dotnet/ZB.MOM.WW.MxGateway.Client/LazyBrowseNode.cs
new file mode 100644
index 0000000..35e3bb9
--- /dev/null
+++ b/clients/dotnet/ZB.MOM.WW.MxGateway.Client/LazyBrowseNode.cs
@@ -0,0 +1,83 @@
+using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
+
+namespace ZB.MOM.WW.MxGateway.Client;
+
+///
+/// One node in a lazy-loaded Galaxy browse tree. Holds the underlying
+/// and exposes to fetch
+/// its direct children on demand. Expansion is one-shot: a second call is a
+/// no-op. Pagination of large sibling sets is handled internally.
+///
+public sealed class LazyBrowseNode
+{
+ private readonly GalaxyRepositoryClient _client;
+ private readonly BrowseChildrenOptions _options;
+ private readonly List _children = [];
+ private bool _isExpanded;
+
+ internal LazyBrowseNode(
+ GalaxyRepositoryClient client,
+ GalaxyObject @object,
+ bool hasChildrenHint,
+ BrowseChildrenOptions options)
+ {
+ _client = client;
+ Object = @object;
+ HasChildrenHint = hasChildrenHint;
+ _options = options;
+ }
+
+ /// The underlying Galaxy object for this node.
+ public GalaxyObject Object { get; }
+
+ /// True when the server reports this node has at least one matching descendant.
+ public bool HasChildrenHint { get; }
+
+ /// Direct children loaded by ; empty until then.
+ public IReadOnlyList Children => _children;
+
+ /// True after the first call completes.
+ public bool IsExpanded => _isExpanded;
+
+ ///
+ /// Fetches direct children from the gateway and populates .
+ /// Idempotent: subsequent calls are no-ops.
+ ///
+ /// Token to observe for cancellation.
+ public async Task ExpandAsync(CancellationToken cancellationToken = default)
+ {
+ if (_isExpanded)
+ {
+ return;
+ }
+
+ string pageToken = string.Empty;
+ HashSet seenPageTokens = new(StringComparer.Ordinal);
+ do
+ {
+ BrowseChildrenRequest request = GalaxyRepositoryClient.BuildBrowseChildrenRequest(_options);
+ request.ParentGobjectId = Object.GobjectId;
+ request.PageToken = pageToken;
+
+ BrowseChildrenReply reply = await _client
+ .BrowseChildrenRawAsync(request, cancellationToken)
+ .ConfigureAwait(false);
+
+ for (int i = 0; i < reply.Children.Count; i++)
+ {
+ bool hint = i < reply.ChildHasChildren.Count && reply.ChildHasChildren[i];
+ _children.Add(new LazyBrowseNode(_client, reply.Children[i], hint, _options));
+ }
+
+ pageToken = reply.NextPageToken;
+ if (!string.IsNullOrWhiteSpace(pageToken) && !seenPageTokens.Add(pageToken))
+ {
+ throw new MxGatewayException(
+ $"Galaxy BrowseChildren returned a repeated page token '{pageToken}'.");
+ }
+ }
+ while (!string.IsNullOrWhiteSpace(pageToken));
+
+ _isExpanded = true;
+ }
+}