feat(dcl): implement BrowseChildrenAsync on RealOpcUaClient

This commit is contained in:
Joseph Doherty
2026-05-28 11:59:03 -04:00
parent 1d2e2c1614
commit 6999aedc60
3 changed files with 143 additions and 3 deletions
@@ -327,10 +327,70 @@ public class RealOpcUaClient : IOpcUaClient
: configured;
/// <inheritdoc />
// Real implementation lands in Task 8 of the OPC UA tag browser plan.
public Task<Commons.Interfaces.Protocol.BrowseChildrenResult> BrowseChildrenAsync(
public async Task<Commons.Interfaces.Protocol.BrowseChildrenResult> 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<Commons.Interfaces.Protocol.BrowseNode>(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
};
}
/// <summary>
@@ -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;
/// <summary>
/// Tests for <see cref="RealOpcUaClient.BrowseChildrenAsync"/>.
///
/// Two shapes here:
///
/// 1. A live round-trip against the infra OPC UA server
/// (<c>opc.tcp://localhost:50000</c> — see <c>infra/docker-compose.yml</c>,
/// started via <c>cd infra &amp;&amp; docker compose up -d opcua</c>).
/// Marked <c>[SkippableFact]</c> 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 (<c>ns=0;i=85</c>) 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.
/// </summary>
[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<ConnectionNotConnectedException>(
() => client.BrowseChildrenAsync(parentNodeId: null));
}
}
@@ -15,6 +15,13 @@
<PackageReference Include="NSubstitute" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
<!--
Xunit.SkippableFact lets the live-OPC-UA browse test report Skipped when
the infra OPC UA server (opc.tcp://localhost:4840) is unreachable, so a
CI run without the infra stack stays green. The not-connected unit test
uses a plain [Fact] — it never needs the server.
-->
<PackageReference Include="Xunit.SkippableFact" />
</ItemGroup>
<ItemGroup>