Phase 3 PR 68 -- OPC UA Client ITagDiscovery (Full browse) #67
@@ -27,7 +27,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
|
|||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string driverInstanceId)
|
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
|
// 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" —
|
// 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() =>
|
private ISession RequireSession() =>
|
||||||
Session ?? throw new InvalidOperationException("OpcUaClientDriver not initialized");
|
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<NodeId>();
|
||||||
|
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<NodeId> visited,
|
||||||
|
int depth, Func<int> 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 void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult();
|
||||||
|
|
||||||
public async ValueTask DisposeAsync()
|
public async ValueTask DisposeAsync()
|
||||||
|
|||||||
@@ -62,6 +62,30 @@ public sealed class OpcUaClientDriverOptions
|
|||||||
|
|
||||||
/// <summary>Connect + per-operation timeout.</summary>
|
/// <summary>Connect + per-operation timeout.</summary>
|
||||||
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(10);
|
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(10);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Root NodeId to mirror. Default <c>null</c> = <c>ObjectsFolder</c> (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.
|
||||||
|
/// </summary>
|
||||||
|
public string? BrowseRoot { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Cap on total nodes discovered during <c>DiscoverAsync</c>. 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.
|
||||||
|
/// </summary>
|
||||||
|
public int MaxDiscoveredNodes { get; init; } = 10_000;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public int MaxBrowseDepth { get; init; } = 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>OPC UA message security mode.</summary>
|
/// <summary>OPC UA message security mode.</summary>
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Scaffold tests for <see cref="OpcUaClientDriver"/>'s <see cref="ITagDiscovery"/>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
[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<InvalidOperationException>(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<ArgumentNullException>(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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user