Auto: opcuaclient-7 — selective import + namespace remap
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
This commit is contained in:
@@ -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<NodeId>();
|
||||
var discovered = 0;
|
||||
var pendingVariables = new List<PendingVariable>();
|
||||
|
||||
// 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(); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Glob semantics — see <see cref="OpcUaClientCurationOptions"/> remarks. Only
|
||||
/// <c>*</c> (any sequence) and <c>?</c> (single char) are honoured; every other
|
||||
/// character is regex-escaped.
|
||||
/// </remarks>
|
||||
internal static Regex? CompileGlobs(IReadOnlyList<string>? patterns)
|
||||
{
|
||||
if (patterns is null || patterns.Count == 0) return null;
|
||||
var alternatives = new List<string>(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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Apply the configured curation rules to a candidate BrowsePath. Returns
|
||||
/// <c>true</c> when the node should be included. Empty include = include all;
|
||||
/// exclude wins over include.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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<NodeId> visited,
|
||||
int depth, Func<int> discovered, Action increment,
|
||||
int depth, string pathPrefix, Regex? includeRegex, Regex? excludeRegex,
|
||||
Func<int> discovered, Action increment,
|
||||
List<PendingVariable> 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Render a NodeId as the canonical <c>nsu=<uri>;…</c> string, applying the
|
||||
/// configured upstream→local namespace-URI remap. Index-namespace nodes (<c>ns=0</c>,
|
||||
/// 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 <c>NodeId.ToString()</c>.
|
||||
/// </summary>
|
||||
internal static string BuildRemappedFullName(NodeId nodeId, NamespaceTable? table,
|
||||
IReadOnlyDictionary<string, string>? 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=<uri>;<idType>=<value>" form.
|
||||
var expanded = new ExpandedNodeId(nodeId.Identifier, 0, localUri, 0);
|
||||
return expanded.ToString() ?? defaultName;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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,
|
||||
|
||||
@@ -181,8 +181,66 @@ public sealed class OpcUaClientDriverOptions
|
||||
/// since SHA-1 is spec-deprecated for OPC UA.
|
||||
/// </summary>
|
||||
public OpcUaCertificateValidationOptions CertificateValidation { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Curation rules applied to the upstream address space during
|
||||
/// <c>DiscoverAsync</c>. 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
|
||||
/// <c>"Remote"</c> root folder name. Defaults are empty / null which preserves the
|
||||
/// pre-curation behaviour exactly — empty include = include all.
|
||||
/// </summary>
|
||||
public OpcUaClientCurationOptions Curation { get; init; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Selective import + namespace remap rules for the OPC UA Client driver. Pure local
|
||||
/// filtering inside <c>BrowseRecursiveAsync</c> + <c>EnrichAndRegisterVariablesAsync</c>;
|
||||
/// no new SDK calls.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Glob semantics</b>: patterns are matched against the slash-joined BrowseName
|
||||
/// segments accumulated during the browse pass (e.g. <c>"Server/Diagnostics/SessionsDiagnosticsArray"</c>).
|
||||
/// Two wildcards are supported — <c>*</c> matches any sequence of characters
|
||||
/// (including empty / slashes) and <c>?</c> matches exactly one character. No
|
||||
/// character classes, no <c>**</c>, no escapes — keep the surface tight so the doc
|
||||
/// + behaviour stay simple.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Empty <see cref="IncludePaths"/> = include all (existing behaviour).
|
||||
/// <see cref="ExcludePaths"/> wins over <see cref="IncludePaths"/> 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="IncludePaths">
|
||||
/// Glob patterns matched against the BrowsePath segment list. Empty = include all
|
||||
/// (default — preserves pre-curation behaviour).
|
||||
/// </param>
|
||||
/// <param name="ExcludePaths">
|
||||
/// Glob patterns matched against the BrowsePath segment list. Wins over
|
||||
/// <see cref="IncludePaths"/> — useful for "include everything under <c>Plant/*</c>
|
||||
/// except <c>Plant/Diagnostics</c>" rules.
|
||||
/// </param>
|
||||
/// <param name="NamespaceRemap">
|
||||
/// Upstream-namespace-URI → local-namespace-URI translation table applied to the
|
||||
/// <c>FullName</c> field of <c>DriverAttributeInfo</c> when registering variables.
|
||||
/// The driver's stored <c>FullName</c> 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).
|
||||
/// </param>
|
||||
/// <param name="RootAlias">
|
||||
/// Replaces the default <c>"Remote"</c> 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 <c>null</c> = use <c>"Remote"</c>.
|
||||
/// </param>
|
||||
public sealed record OpcUaClientCurationOptions(
|
||||
IReadOnlyList<string>? IncludePaths = null,
|
||||
IReadOnlyList<string>? ExcludePaths = null,
|
||||
IReadOnlyDictionary<string, string>? NamespaceRemap = null,
|
||||
string? RootAlias = null);
|
||||
|
||||
/// <summary>
|
||||
/// Knobs governing the server-certificate validation callback. Plumbed onto
|
||||
/// <see cref="OpcUaClientDriverOptions.CertificateValidation"/> rather than the top-level
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
using Opc.Ua;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the curation surface added in PR-7 (selective import + namespace
|
||||
/// remap + root-alias). Focused on the pure-function helpers
|
||||
/// (<see cref="OpcUaClientDriver.CompileGlobs"/>,
|
||||
/// <see cref="OpcUaClientDriver.ShouldInclude"/>,
|
||||
/// <see cref="OpcUaClientDriver.BuildRemappedFullName"/>) so the assertions don't need a
|
||||
/// live upstream server. End-to-end browse + filter coverage lands in the integration
|
||||
/// tests against opc-plc.
|
||||
/// </summary>
|
||||
[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<string>()).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<string, string> { ["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<string, string>
|
||||
{
|
||||
["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<string, string>
|
||||
{
|
||||
["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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user