From 02d1c851903146dc15beda078a50176df863c9b1 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 25 Apr 2026 20:20:47 -0400 Subject: [PATCH] =?UTF-8?q?Auto:=20opcuaclient-7=20=E2=80=94=20selective?= =?UTF-8?q?=20import=20+=20namespace=20remap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds OpcUaClientCurationOptions on OpcUaClientDriverOptions.Curation with: - IncludePaths/ExcludePaths globs (* and ? only) matched against the slash-joined BrowsePath segments collected during BrowseRecursiveAsync. Empty Include = include all so existing deployments are unaffected; Exclude wins over Include. Pruned folders are skipped wholesale, so descendants don't reach the wire. - NamespaceRemap (URI -> URI) applied to DriverAttributeInfo.FullName when registering variables. Index-0 / standard nodes round-trip unchanged; remapped nodes serialise via ExpandedNodeId so downstream clients see the local-side URI. - RootAlias replaces the hard-coded "Remote" folder name at the top of the mirrored tree. Closes #279 --- .../OpcUaClientDriver.cs | 112 ++++++++++++- .../OpcUaClientDriverOptions.cs | 58 +++++++ .../OpcUaClientCurationTests.cs | 155 ++++++++++++++++++ 3 files changed, 318 insertions(+), 7 deletions(-) create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientCurationTests.cs diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs index cd289b7..78bc0c5 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs @@ -1,4 +1,5 @@ using System.Security.Cryptography.X509Certificates; +using System.Text.RegularExpressions; using Opc.Ua; using Opc.Ua.Client; using Opc.Ua.Configuration; @@ -1001,11 +1002,19 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d ? NodeId.Parse(session.MessageContext, _options.BrowseRoot) : ObjectIds.ObjectsFolder; - var rootFolder = builder.Folder("Remote", "Remote"); + var rootName = string.IsNullOrWhiteSpace(_options.Curation.RootAlias) + ? "Remote" + : _options.Curation.RootAlias!; + var rootFolder = builder.Folder(rootName, rootName); var visited = new HashSet(); var discovered = 0; var pendingVariables = new List(); + // Compile curation globs once per Discover so the recursion's hot path is a regex + // match rather than a per-segment string-walk. Empty include = include all. + var includeRegex = CompileGlobs(_options.Curation.IncludePaths); + var excludeRegex = CompileGlobs(_options.Curation.ExcludePaths); + await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); try { @@ -1017,6 +1026,9 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d // IAddressSpaceBuilder contract doesn't expose. await BrowseRecursiveAsync(session, root, rootFolder, visited, depth: 0, + pathPrefix: string.Empty, + includeRegex: includeRegex, + excludeRegex: excludeRegex, discovered: () => discovered, increment: () => discovered++, pendingVariables: pendingVariables, ct: cancellationToken).ConfigureAwait(false); @@ -1030,6 +1042,57 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d finally { _gate.Release(); } } + /// + /// Translate the curation glob list into a single regex that matches if any pattern + /// matches. Returns null for null/empty input so the call site can short-circuit + /// without allocating. + /// + /// + /// Glob semantics — see remarks. Only + /// * (any sequence) and ? (single char) are honoured; every other + /// character is regex-escaped. + /// + internal static Regex? CompileGlobs(IReadOnlyList? patterns) + { + if (patterns is null || patterns.Count == 0) return null; + var alternatives = new List(patterns.Count); + foreach (var p in patterns) + { + if (string.IsNullOrEmpty(p)) continue; + alternatives.Add(GlobToRegex(p)); + } + if (alternatives.Count == 0) return null; + var combined = "^(?:" + string.Join("|", alternatives) + ")$"; + return new Regex(combined, RegexOptions.Compiled | RegexOptions.CultureInvariant); + } + + private static string GlobToRegex(string glob) + { + var sb = new System.Text.StringBuilder(glob.Length * 2); + foreach (var ch in glob) + { + switch (ch) + { + case '*': sb.Append(".*"); break; + case '?': sb.Append('.'); break; + default: sb.Append(Regex.Escape(ch.ToString())); break; + } + } + return sb.ToString(); + } + + /// + /// Apply the configured curation rules to a candidate BrowsePath. Returns + /// true when the node should be included. Empty include = include all; + /// exclude wins over include. + /// + internal static bool ShouldInclude(string path, Regex? include, Regex? exclude) + { + if (exclude is not null && exclude.IsMatch(path)) return false; + if (include is null) return true; + return include.IsMatch(path); + } + /// /// A variable collected during the browse pass, waiting for attribute enrichment /// before being registered on the address-space builder. @@ -1038,11 +1101,13 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d IAddressSpaceBuilder ParentFolder, string BrowseName, string DisplayName, - NodeId NodeId); + NodeId NodeId, + string FullName); private async Task BrowseRecursiveAsync( ISession session, NodeId node, IAddressSpaceBuilder folder, HashSet visited, - int depth, Func discovered, Action increment, + int depth, string pathPrefix, Regex? includeRegex, Regex? excludeRegex, + Func discovered, Action increment, List pendingVariables, CancellationToken ct) { if (depth >= _options.MaxBrowseDepth) return; @@ -1093,22 +1158,55 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d var browseName = rf.BrowseName?.Name ?? childId.ToString(); var displayName = rf.DisplayName?.Text ?? browseName; + var childPath = string.IsNullOrEmpty(pathPrefix) ? browseName : pathPrefix + "/" + browseName; + + // Apply curation: exclude wins over include; empty include = include all. + // Folders pruned here aren't browsed, so descendants don't reach the wire — keeps + // the cost down on large servers. + if (!ShouldInclude(childPath, includeRegex, excludeRegex)) + continue; if (rf.NodeClass == NodeClass.Object) { var subFolder = folder.Folder(browseName, displayName); increment(); await BrowseRecursiveAsync(session, childId, subFolder, visited, - depth + 1, discovered, increment, pendingVariables, ct).ConfigureAwait(false); + depth + 1, childPath, includeRegex, excludeRegex, + discovered, increment, pendingVariables, ct).ConfigureAwait(false); } else if (rf.NodeClass == NodeClass.Variable) { - pendingVariables.Add(new PendingVariable(folder, browseName, displayName, childId)); + var fullName = BuildRemappedFullName(childId, session.NamespaceUris, + _options.Curation.NamespaceRemap); + pendingVariables.Add(new PendingVariable(folder, browseName, displayName, childId, fullName)); increment(); } } } + /// + /// Render a NodeId as the canonical nsu=<uri>;… string, applying the + /// configured upstream→local namespace-URI remap. Index-namespace nodes (ns=0, + /// standard OPC UA nodes) bypass remap and use the legacy index-form so the + /// base-namespace round-trips unchanged. When remap is null/empty the result is the + /// SDK's default NodeId.ToString(). + /// + internal static string BuildRemappedFullName(NodeId nodeId, NamespaceTable? table, + IReadOnlyDictionary? remap) + { + if (nodeId is null) return string.Empty; + var defaultName = nodeId.ToString() ?? string.Empty; + if (remap is null || remap.Count == 0) return defaultName; + if (nodeId.NamespaceIndex == 0 || table is null) return defaultName; + var upstreamUri = table.GetString(nodeId.NamespaceIndex); + if (string.IsNullOrEmpty(upstreamUri)) return defaultName; + if (!remap.TryGetValue(upstreamUri, out var localUri) || string.IsNullOrEmpty(localUri)) + return defaultName; + // ExpandedNodeId.Format with an explicit URI emits "nsu=;=" form. + var expanded = new ExpandedNodeId(nodeId.Identifier, 0, localUri, 0); + return expanded.ToString() ?? defaultName; + } + /// /// Pass 2 of discovery: batch-read DataType + ValueRank + AccessLevel + Historizing /// for every collected variable in one Session.ReadAsync (the SDK chunks internally @@ -1186,7 +1284,7 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d var historizing = StatusCode.IsGood(histDv.StatusCode) && histDv.Value is bool b && b; pv.ParentFolder.Variable(pv.BrowseName, pv.DisplayName, new DriverAttributeInfo( - FullName: pv.NodeId.ToString() ?? string.Empty, + FullName: pv.FullName, DriverDataType: dataType, IsArray: isArray, ArrayDim: null, @@ -1198,7 +1296,7 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d void RegisterFallback(PendingVariable pv) { pv.ParentFolder.Variable(pv.BrowseName, pv.DisplayName, new DriverAttributeInfo( - FullName: pv.NodeId.ToString() ?? string.Empty, + FullName: pv.FullName, DriverDataType: DriverDataType.Int32, IsArray: false, ArrayDim: null, diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriverOptions.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriverOptions.cs index f571c83..6021456 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriverOptions.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriverOptions.cs @@ -181,8 +181,66 @@ public sealed class OpcUaClientDriverOptions /// since SHA-1 is spec-deprecated for OPC UA. /// public OpcUaCertificateValidationOptions CertificateValidation { get; init; } = new(); + + /// + /// Curation rules applied to the upstream address space during + /// DiscoverAsync. Lets operators trim the mirrored tree to the subset their + /// downstream clients actually need, rename namespace URIs so the local-side metadata + /// stays consistent across upstream-server swaps, and override the default + /// "Remote" root folder name. Defaults are empty / null which preserves the + /// pre-curation behaviour exactly — empty include = include all. + /// + public OpcUaClientCurationOptions Curation { get; init; } = new(); } +/// +/// Selective import + namespace remap rules for the OPC UA Client driver. Pure local +/// filtering inside BrowseRecursiveAsync + EnrichAndRegisterVariablesAsync; +/// no new SDK calls. +/// +/// +/// +/// Glob semantics: patterns are matched against the slash-joined BrowseName +/// segments accumulated during the browse pass (e.g. "Server/Diagnostics/SessionsDiagnosticsArray"). +/// Two wildcards are supported — * matches any sequence of characters +/// (including empty / slashes) and ? matches exactly one character. No +/// character classes, no **, no escapes — keep the surface tight so the doc +/// + behaviour stay simple. +/// +/// +/// Empty = include all (existing behaviour). +/// wins over when both match. +/// Folders pruned by the rules are skipped wholesale — their descendants don't get +/// browsed, which keeps the wire cost down on large servers. +/// +/// +/// +/// Glob patterns matched against the BrowsePath segment list. Empty = include all +/// (default — preserves pre-curation behaviour). +/// +/// +/// Glob patterns matched against the BrowsePath segment list. Wins over +/// — useful for "include everything under Plant/* +/// except Plant/Diagnostics" rules. +/// +/// +/// Upstream-namespace-URI → local-namespace-URI translation table applied to the +/// FullName field of DriverAttributeInfo when registering variables. +/// The driver's stored FullName swaps the prefix before persisting so downstream +/// clients see the remapped URI. Lookup is case-sensitive — match the upstream URI +/// exactly. Defaults to empty (no remap). +/// +/// +/// Replaces the default "Remote" folder name at the top of the mirrored tree. +/// Useful when multiple OPC UA Client drivers are aggregated and operators need to +/// distinguish them in the local browse tree. Default null = use "Remote". +/// +public sealed record OpcUaClientCurationOptions( + IReadOnlyList? IncludePaths = null, + IReadOnlyList? ExcludePaths = null, + IReadOnlyDictionary? NamespaceRemap = null, + string? RootAlias = null); + /// /// Knobs governing the server-certificate validation callback. Plumbed onto /// rather than the top-level diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientCurationTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientCurationTests.cs new file mode 100644 index 0000000..ff1082c --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientCurationTests.cs @@ -0,0 +1,155 @@ +using Opc.Ua; +using Shouldly; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests; + +/// +/// Unit tests for the curation surface added in PR-7 (selective import + namespace +/// remap + root-alias). Focused on the pure-function helpers +/// (, +/// , +/// ) so the assertions don't need a +/// live upstream server. End-to-end browse + filter coverage lands in the integration +/// tests against opc-plc. +/// +[Trait("Category", "Unit")] +public sealed class OpcUaClientCurationTests +{ + [Fact] + public void CurationOptions_default_is_empty_so_existing_behaviour_is_preserved() + { + var opts = new OpcUaClientDriverOptions(); + opts.Curation.IncludePaths.ShouldBeNull(); + opts.Curation.ExcludePaths.ShouldBeNull(); + opts.Curation.NamespaceRemap.ShouldBeNull(); + opts.Curation.RootAlias.ShouldBeNull(); + } + + [Fact] + public void ShouldInclude_empty_include_means_include_all() + { + OpcUaClientDriver.ShouldInclude("Server/Status", include: null, exclude: null).ShouldBeTrue(); + OpcUaClientDriver.ShouldInclude("anything", include: null, exclude: null).ShouldBeTrue(); + } + + [Fact] + public void ShouldInclude_star_glob_matches_any_segment_chars() + { + var include = OpcUaClientDriver.CompileGlobs(new[] { "Plant/*" }); + OpcUaClientDriver.ShouldInclude("Plant/Tank1", include, null).ShouldBeTrue(); + OpcUaClientDriver.ShouldInclude("Plant/Tank1/Level", include, null).ShouldBeTrue("'*' matches across slashes — single-glob simple semantics"); + OpcUaClientDriver.ShouldInclude("Other/Tank1", include, null).ShouldBeFalse(); + } + + [Fact] + public void ShouldInclude_question_mark_matches_exactly_one_char() + { + var include = OpcUaClientDriver.CompileGlobs(new[] { "Tank?" }); + OpcUaClientDriver.ShouldInclude("Tank1", include, null).ShouldBeTrue(); + OpcUaClientDriver.ShouldInclude("Tank12", include, null).ShouldBeFalse(); + OpcUaClientDriver.ShouldInclude("Tank", include, null).ShouldBeFalse(); + } + + [Fact] + public void ShouldInclude_exclude_wins_over_include() + { + var include = OpcUaClientDriver.CompileGlobs(new[] { "Plant/*" }); + var exclude = OpcUaClientDriver.CompileGlobs(new[] { "Plant/Diagnostics*" }); + OpcUaClientDriver.ShouldInclude("Plant/Tank1", include, exclude).ShouldBeTrue(); + OpcUaClientDriver.ShouldInclude("Plant/Diagnostics", include, exclude).ShouldBeFalse(); + OpcUaClientDriver.ShouldInclude("Plant/DiagnosticsTree/Sub", include, exclude).ShouldBeFalse(); + } + + [Fact] + public void ShouldInclude_multiple_includes_are_OR() + { + var include = OpcUaClientDriver.CompileGlobs(new[] { "PlantA/*", "PlantB/*" }); + OpcUaClientDriver.ShouldInclude("PlantA/Tank", include, null).ShouldBeTrue(); + OpcUaClientDriver.ShouldInclude("PlantB/Tank", include, null).ShouldBeTrue(); + OpcUaClientDriver.ShouldInclude("PlantC/Tank", include, null).ShouldBeFalse(); + } + + [Fact] + public void CompileGlobs_returns_null_for_empty_or_null_input() + { + OpcUaClientDriver.CompileGlobs(null).ShouldBeNull(); + OpcUaClientDriver.CompileGlobs(System.Array.Empty()).ShouldBeNull(); + OpcUaClientDriver.CompileGlobs(new[] { string.Empty }).ShouldBeNull(); + } + + [Fact] + public void CompileGlobs_escapes_regex_metacharacters_in_literal_segments() + { + // A bare "." in the glob must NOT match arbitrary chars — it's escaped to a literal dot. + var rx = OpcUaClientDriver.CompileGlobs(new[] { "App.exe" }); + rx.ShouldNotBeNull(); + rx!.IsMatch("App.exe").ShouldBeTrue(); + rx.IsMatch("AppXexe").ShouldBeFalse(); + } + + [Fact] + public void BuildRemappedFullName_returns_default_string_when_remap_is_empty() + { + var node = new NodeId("Tank/Level", 2); + OpcUaClientDriver.BuildRemappedFullName(node, table: null, remap: null) + .ShouldBe(node.ToString()); + } + + [Fact] + public void BuildRemappedFullName_passes_through_namespace_zero_unchanged() + { + // Standard OPC UA nodes (ns=0) bypass remap so the base namespace round-trips. + var node = new NodeId(2253u); // Server + var remap = new Dictionary { ["http://opcfoundation.org/UA/"] = "urn:local:base" }; + OpcUaClientDriver.BuildRemappedFullName(node, table: null, remap: remap) + .ShouldBe(node.ToString()); + } + + [Fact] + public void BuildRemappedFullName_translates_upstream_uri_when_remap_hits() + { + var table = new NamespaceTable(); + table.Append("urn:upstream:plc-vendor"); // index 1 + var node = new NodeId("Tank/Level", 1); + var remap = new Dictionary + { + ["urn:upstream:plc-vendor"] = "urn:local:plant", + }; + + var result = OpcUaClientDriver.BuildRemappedFullName(node, table, remap); + + result.ShouldContain("urn:local:plant", Case.Sensitive); + result.ShouldNotContain("urn:upstream:plc-vendor"); + } + + [Fact] + public void BuildRemappedFullName_no_op_when_upstream_uri_not_in_remap_table() + { + var table = new NamespaceTable(); + table.Append("urn:upstream:other"); // index 1 + var node = new NodeId("Some/Item", 1); + var remap = new Dictionary + { + ["urn:not-matching"] = "urn:local:something", + }; + OpcUaClientDriver.BuildRemappedFullName(node, table, remap).ShouldBe(node.ToString()); + } + + [Fact] + public void RootAlias_default_null_means_Remote() + { + var opts = new OpcUaClientDriverOptions(); + opts.Curation.RootAlias.ShouldBeNull(); + } + + [Fact] + public void RootAlias_can_be_set_via_Curation_record() + { + var opts = new OpcUaClientDriverOptions + { + Curation = new OpcUaClientCurationOptions(RootAlias: "Plant"), + }; + opts.Curation.RootAlias.ShouldBe("Plant"); + } +} -- 2.49.1