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