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.
This commit is contained in:
@@ -27,7 +27,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
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<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 async ValueTask DisposeAsync()
|
||||
|
||||
Reference in New Issue
Block a user