From db56a95819bcb87b8eb00df8e47cbb183677996f Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 19 Apr 2026 01:17:21 -0400 Subject: [PATCH] Phase 3 PR 68 -- OPC UA Client ITagDiscovery via recursive browse (Full strategy). Adds ITagDiscovery to OpcUaClientDriver. DiscoverAsync opens a single Remote folder on the IAddressSpaceBuilder and recursively browses from the configured root (default: ObjectsFolder i=85; override via OpcUaClientDriverOptions.BrowseRoot for scoped discovery). Browse uses non-obsolete Session.BrowseAsync(RequestHeader, ViewDescription, uint maxReferences, BrowseDescriptionCollection, ct) with HierarchicalReferences forward, subtypes included, NodeClassMask Object+Variable, ResultMask pulling BrowseName + DisplayName + NodeClass + TypeDefinition. Objects become sub-folders via builder.Folder; Variables become builder.Variable entries with FullName set to the NodeId.ToString() serialization so IReadable/IWritable can round-trip without re-resolving. Three safety caps added to OpcUaClientDriverOptions to bound runaway discovery: (1) MaxBrowseDepth default 10 -- deep enough for realistic OPC UA information models, shallow enough that cyclic graphs can't spin the browse forever. (2) MaxDiscoveredNodes default 10_000 -- caps memory on pathological remote servers. Once the cap is hit, recursion short-circuits and the partially-discovered tree is still projected into the local address space (graceful degradation rather than all-or-nothing). (3) BrowseRoot as an opt-in scope restriction string per driver-specs.md \u00A78 -- defaults to ObjectsFolder but operators with 100k-node servers can point it at a single subtree. Visited-set tracks NodeIds already visited to prevent infinite cycles on graphs with non-strict hierarchy (OPC UA models can have back-references). Transient browse failures on a subtree are swallowed -- the sub-branch stops but the rest of discovery continues, matching the Modbus driver's 'transient poll errors don't kill the loop' pattern. The driver's health surface reflects the network-level cascade via the probe loop (PR 69). Deferred to a follow-up PR: DataType resolution via a batch Session.ReadAsync(Attributes.DataType) after the browse so DriverAttributeInfo.DriverDataType is accurate instead of the current conservative DriverDataType.Int32 default; AccessLevel-derived SecurityClass instead of the current ViewOnly default; array-type detection via Attributes.ValueRank + ArrayDimensions. These need an extra wire round-trip per batch of variables + a NodeId -> DriverDataType mapping table; out of scope for PR 68 to keep browse path landable. Unit tests (OpcUaClientDiscoveryTests, 3 facts): DiscoverAsync_without_initialize_throws_InvalidOperationException (pre-init hits RequireSession); DiscoverAsync_rejects_null_builder (ArgumentNullException); Discovery_caps_are_sensible_defaults (asserts 10000 / 10 / null defaults documented above). NullAddressSpaceBuilder stub implements the full IAddressSpaceBuilder shape including IVariableHandle.MarkAsAlarmCondition (throws NotSupportedException since this PR doesn't wire alarms). Live-browse coverage against a real remote server is deferred to the in-process-server-fixture PR. 10/10 OpcUaClient.Tests pass. dotnet build clean. --- .../OpcUaClientDriver.cs | 106 +++++++++++++++++- .../OpcUaClientDriverOptions.cs | 24 ++++ .../OpcUaClientDiscoveryTests.cs | 55 +++++++++ 3 files changed, 184 insertions(+), 1 deletion(-) create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientDiscoveryTests.cs diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs index 52858a6..0e3dee2 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs @@ -27,7 +27,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient; /// /// public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string driverInstanceId) - : IDriver, IReadable, IWritable, IDisposable, IAsyncDisposable + : IDriver, ITagDiscovery, IReadable, IWritable, IDisposable, IAsyncDisposable { // OPC UA StatusCode constants the driver surfaces for local-side faults. Upstream-server // StatusCodes are passed through verbatim per driver-specs.md §8 "cascading quality" — @@ -380,6 +380,110 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d private ISession RequireSession() => Session ?? throw new InvalidOperationException("OpcUaClientDriver not initialized"); + // ---- ITagDiscovery ---- + + public async Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(builder); + var session = RequireSession(); + + var root = !string.IsNullOrEmpty(_options.BrowseRoot) + ? NodeId.Parse(session.MessageContext, _options.BrowseRoot) + : ObjectIds.ObjectsFolder; + + var rootFolder = builder.Folder("Remote", "Remote"); + var visited = new HashSet(); + var discovered = 0; + + await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + await BrowseRecursiveAsync(session, root, rootFolder, visited, + depth: 0, discovered: () => discovered, increment: () => discovered++, + ct: cancellationToken).ConfigureAwait(false); + } + finally { _gate.Release(); } + } + + private async Task BrowseRecursiveAsync( + ISession session, NodeId node, IAddressSpaceBuilder folder, HashSet visited, + int depth, Func discovered, Action increment, CancellationToken ct) + { + if (depth >= _options.MaxBrowseDepth) return; + if (discovered() >= _options.MaxDiscoveredNodes) return; + if (!visited.Add(node)) return; + + var browseDescriptions = new BrowseDescriptionCollection + { + new() + { + NodeId = node, + BrowseDirection = BrowseDirection.Forward, + ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences, + IncludeSubtypes = true, + NodeClassMask = (uint)(NodeClass.Object | NodeClass.Variable), + ResultMask = (uint)(BrowseResultMask.BrowseName | BrowseResultMask.DisplayName + | BrowseResultMask.NodeClass | BrowseResultMask.TypeDefinition), + } + }; + + BrowseResponse resp; + try + { + resp = await session.BrowseAsync( + requestHeader: null, + view: null, + requestedMaxReferencesPerNode: 0, + nodesToBrowse: browseDescriptions, + ct: ct).ConfigureAwait(false); + } + catch + { + // Transient browse failure on a sub-tree — don't kill the whole discovery, just + // skip this branch. The driver's health surface will reflect the cascade via the + // probe loop (PR 69). + return; + } + + if (resp.Results.Count == 0) return; + var refs = resp.Results[0].References; + + foreach (var rf in refs) + { + if (discovered() >= _options.MaxDiscoveredNodes) break; + + var childId = ExpandedNodeId.ToNodeId(rf.NodeId, session.NamespaceUris); + if (NodeId.IsNull(childId)) continue; + + var browseName = rf.BrowseName?.Name ?? childId.ToString(); + var displayName = rf.DisplayName?.Text ?? browseName; + + if (rf.NodeClass == NodeClass.Object) + { + var subFolder = folder.Folder(browseName, displayName); + increment(); + await BrowseRecursiveAsync(session, childId, subFolder, visited, + depth + 1, discovered, increment, ct).ConfigureAwait(false); + } + else if (rf.NodeClass == NodeClass.Variable) + { + // Serialize the NodeId so the IReadable/IWritable surface receives a + // round-trippable string. Deferring the DataType + AccessLevel fetch to a + // follow-up PR — initial browse uses a conservative ViewOnly + Int32 default. + var nodeIdString = childId.ToString() ?? string.Empty; + folder.Variable(browseName, displayName, new DriverAttributeInfo( + FullName: nodeIdString, + DriverDataType: DriverDataType.Int32, + IsArray: false, + ArrayDim: null, + SecurityClass: SecurityClassification.ViewOnly, + IsHistorized: false, + IsAlarm: false)); + increment(); + } + } + } + public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult(); public async ValueTask DisposeAsync() diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriverOptions.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriverOptions.cs index 000b068..87629eb 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriverOptions.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriverOptions.cs @@ -62,6 +62,30 @@ public sealed class OpcUaClientDriverOptions /// Connect + per-operation timeout. public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(10); + + /// + /// Root NodeId to mirror. Default null = ObjectsFolder (i=85). Set to + /// a scoped root to restrict the address space the driver exposes locally — useful + /// when the remote server has tens of thousands of nodes and only a subset is + /// needed downstream. + /// + public string? BrowseRoot { get; init; } + + /// + /// Cap on total nodes discovered during DiscoverAsync. Default 10_000 — + /// bounds memory on runaway remote servers without being so low that normal + /// deployments hit it. When the cap is reached discovery stops and a warning is + /// written to the driver health surface; the partially-discovered tree is still + /// projected into the local address space. + /// + public int MaxDiscoveredNodes { get; init; } = 10_000; + + /// + /// Max hierarchical depth of the browse. Default 10 — deep enough for realistic + /// OPC UA information models, shallow enough that cyclic graphs can't spin the + /// browse forever. + /// + public int MaxBrowseDepth { get; init; } = 10; } /// OPC UA message security mode. diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientDiscoveryTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientDiscoveryTests.cs new file mode 100644 index 0000000..f20eed3 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientDiscoveryTests.cs @@ -0,0 +1,55 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests; + +/// +/// Scaffold tests for 's +/// surface that don't require a live remote server. Live-browse coverage lands in a +/// follow-up PR once the in-process OPC UA server fixture is scaffolded. +/// +[Trait("Category", "Unit")] +public sealed class OpcUaClientDiscoveryTests +{ + [Fact] + public async Task DiscoverAsync_without_initialize_throws_InvalidOperationException() + { + using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-disco"); + var builder = new NullAddressSpaceBuilder(); + await Should.ThrowAsync(async () => + await drv.DiscoverAsync(builder, TestContext.Current.CancellationToken)); + } + + [Fact] + public void DiscoverAsync_rejects_null_builder() + { + using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-disco"); + Should.ThrowAsync(async () => + await drv.DiscoverAsync(null!, TestContext.Current.CancellationToken)); + } + + [Fact] + public void Discovery_caps_are_sensible_defaults() + { + var opts = new OpcUaClientDriverOptions(); + opts.MaxDiscoveredNodes.ShouldBe(10_000, "bounds memory on runaway servers without clipping normal models"); + opts.MaxBrowseDepth.ShouldBe(10, "deep enough for realistic info models; shallow enough for cycle safety"); + opts.BrowseRoot.ShouldBeNull("null = default to ObjectsFolder i=85"); + } + + private sealed class NullAddressSpaceBuilder : IAddressSpaceBuilder + { + public IAddressSpaceBuilder Folder(string browseName, string displayName) => this; + public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo) + => new StubHandle(); + public void AddProperty(string browseName, DriverDataType dataType, object? value) { } + public void AttachAlarmCondition(IVariableHandle sourceVariable, string alarmName, DriverAttributeInfo alarmInfo) { } + + private sealed class StubHandle : IVariableHandle + { + public string FullReference => "stub"; + public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => throw new NotSupportedException(); + } + } +} -- 2.49.1