diff --git a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/RealOpcUaClient.cs b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/RealOpcUaClient.cs
index 86b5f077..27d6b1e0 100644
--- a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/RealOpcUaClient.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/RealOpcUaClient.cs
@@ -327,10 +327,70 @@ public class RealOpcUaClient : IOpcUaClient
: configured;
///
- // Real implementation lands in Task 8 of the OPC UA tag browser plan.
- public Task BrowseChildrenAsync(
+ public async Task BrowseChildrenAsync(
string? parentNodeId, CancellationToken cancellationToken = default)
- => throw new NotImplementedException();
+ {
+ // Mirror the SubscribeAsync/ReadAsync wrap idiom: snapshot the session
+ // reference once, fail fast with a typed exception if the link is
+ // down, then call the SDK's async API directly (no Task.Run wrap —
+ // the OPC Foundation SDK already provides true async I/O).
+ var session = _session;
+ if (session is null || !session.Connected)
+ {
+ throw new Commons.Interfaces.Protocol.ConnectionNotConnectedException(
+ "OPC UA session is not connected.");
+ }
+
+ // ObjectsFolder = ns=0;i=85 — the OPC UA standard server root. Empty
+ // / null input means "browse the root"; anything else is parsed as
+ // an absolute NodeId expression.
+ var nodeToBrowse = string.IsNullOrEmpty(parentNodeId)
+ ? 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);
+
+ var (_, continuationPoint, references) = await session.BrowseAsync(
+ null,
+ null,
+ nodeToBrowse,
+ 1000u,
+ BrowseDirection.Forward,
+ ReferenceTypeIds.HierarchicalReferences,
+ true,
+ nodeClassMask,
+ cancellationToken).ConfigureAwait(false);
+
+ var refs = references ?? new ReferenceDescriptionCollection();
+ var children = new List(refs.Count);
+ foreach (var r in refs)
+ {
+ children.Add(new Commons.Interfaces.Protocol.BrowseNode(
+ NodeId: r.NodeId.ToString(),
+ DisplayName: r.DisplayName?.Text ?? r.BrowseName?.Name ?? "(unnamed)",
+ NodeClass: MapNodeClass(r.NodeClass),
+ HasChildren: r.NodeClass == NodeClass.Object));
+ }
+
+ // 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);
+ }
+
+ private static Commons.Interfaces.Protocol.BrowseNodeClass MapNodeClass(NodeClass nc) => nc switch
+ {
+ NodeClass.Object => Commons.Interfaces.Protocol.BrowseNodeClass.Object,
+ NodeClass.Variable => Commons.Interfaces.Protocol.BrowseNodeClass.Variable,
+ NodeClass.Method => Commons.Interfaces.Protocol.BrowseNodeClass.Method,
+ _ => Commons.Interfaces.Protocol.BrowseNodeClass.Other
+ };
}
///
diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/RealOpcUaClientBrowseTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/RealOpcUaClientBrowseTests.cs
new file mode 100644
index 00000000..1b6c66eb
--- /dev/null
+++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/RealOpcUaClientBrowseTests.cs
@@ -0,0 +1,73 @@
+using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;
+using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
+
+namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Adapters;
+
+///
+/// Tests for .
+///
+/// Two shapes here:
+///
+/// 1. A live round-trip against the infra OPC UA server
+/// (opc.tcp://localhost:50000 — see infra/docker-compose.yml,
+/// started via cd infra && docker compose up -d opcua).
+/// Marked [SkippableFact] so it reports Skipped — not failed — on
+/// machines that don't have the infra stack running. The live test asserts
+/// that the server root browse returns the standard "Server" node, which
+/// proves we targeted ObjectsFolder (ns=0;i=85) and that the
+/// response mapping survived the round trip.
+///
+/// 2. A pure unit test that exercises the not-connected guard — no infra
+/// needed, runs in every build.
+///
+[Trait("Category", "RequiresOpcUa")]
+public class RealOpcUaClientBrowseTests
+{
+ // The infra/docker-compose.yml opcua container maps the OPC PLC simulator
+ // on host port 50000 (not the OPC UA default 4840). Matches what the
+ // existing docker/ and docker-env2/ topologies dial into.
+ private const string EndpointUrl = "opc.tcp://localhost:50000";
+
+ [SkippableFact]
+ public async Task BrowseChildren_at_root_returns_ObjectsFolder_with_Server_node()
+ {
+ await using var client = new RealOpcUaClient();
+
+ // Probe the endpoint before asserting anything. If the infra OPC UA
+ // server isn't up, ConnectAsync surfaces a socket/timeout error from
+ // deep inside the OPC Foundation SDK — we treat that as "infra not
+ // available" and skip rather than fail, mirroring the SkippableFact
+ // pattern already used in ConfigurationDatabase/AuditLog tests.
+ try
+ {
+ await client.ConnectAsync(
+ EndpointUrl,
+ new OpcUaConnectionOptions(AutoAcceptUntrustedCerts: true));
+ }
+ catch (Exception ex)
+ {
+ Skip.If(true, $"OPC UA test server not reachable on {EndpointUrl}: {ex.Message}");
+ return; // Skip.If throws; this is unreachable but keeps the compiler happy.
+ }
+
+ var result = await client.BrowseChildrenAsync(parentNodeId: null);
+
+ // Under ObjectsFolder (ns=0;i=85) every OPC UA-compliant server
+ // exposes a 'Server' object at ns=0;i=2253 — its presence confirms
+ // we hit the right root and that DisplayName mapping survives the
+ // round trip.
+ Assert.NotEmpty(result.Children);
+ Assert.Contains(result.Children, n => n.DisplayName == "Server");
+ }
+
+ [Fact]
+ public async Task BrowseChildren_throws_ConnectionNotConnected_when_session_is_null()
+ {
+ // No ConnectAsync — _session is still null, so the typed guard at the
+ // top of BrowseChildrenAsync should fire before any SDK call.
+ await using var client = new RealOpcUaClient();
+
+ await Assert.ThrowsAsync(
+ () => client.BrowseChildrenAsync(parentNodeId: null));
+ }
+}
diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.csproj b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.csproj
index c55f29ed..207b1e85 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.csproj
+++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.csproj
@@ -15,6 +15,13 @@
+
+