Auto: opcuaclient-8 — type definition mirroring
Adds an opt-in pass-3 walk of the upstream TypesFolder (i=86) so the OPC UA Client driver can mirror upstream type definitions into the local address space. Honours the curation rules from PR-7 (#359). Structural mirror only — binary-encoding priming via LoadDataTypeSystem is tracked as a follow-up because that helper was removed from the public ISession surface in OPCFoundation.NetStandard 1.5.378+. - IAddressSpaceBuilder.RegisterTypeNode (default no-op for back-compat) - MirroredTypeNodeInfo + MirroredTypeKind in Core.Abstractions - OpcUaClientDriverOptions.MirrorTypeDefinitions (default false) - DiscoverAsync pass-3: FetchTypeTreeAsync + recursive HasSubtype walk per branch (ObjectTypes/VariableTypes/DataTypes/ReferenceTypes), best-effort IsAbstract read, IncludePaths/ExcludePaths still applied - 6 new unit tests; all 153 OpcUaClient unit tests pass Closes #280
This commit is contained in:
@@ -1038,10 +1038,237 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d
|
||||
// still a couple of hundred ms total since the SDK chunks ReadAsync automatically.
|
||||
await EnrichAndRegisterVariablesAsync(session, pendingVariables, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
// Pass 3 (opt-in): mirror upstream type definitions under the four standard type
|
||||
// sub-folders (ObjectTypes / VariableTypes / DataTypes / ReferenceTypes). Off by
|
||||
// default so existing deployments don't suddenly see a flood of type nodes; enable
|
||||
// via OpcUaClientDriverOptions.MirrorTypeDefinitions when downstream clients need
|
||||
// the upstream type system to render structured values or decode custom events.
|
||||
if (_options.MirrorTypeDefinitions)
|
||||
{
|
||||
await MirrorTypeDefinitionsAsync(session, builder, includeRegex, excludeRegex,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
finally { _gate.Release(); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pass 3 of discovery: walk the upstream <c>TypesFolder</c> (<c>i=86</c>) and project
|
||||
/// the four standard type sub-folders into the local address space via
|
||||
/// <see cref="IAddressSpaceBuilder.RegisterTypeNode"/>. Honours the same curation rules
|
||||
/// as pass-1 — paths are slash-joined under each type-folder root (e.g.
|
||||
/// <c>"ObjectTypes/BaseObjectType/MyType"</c>).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Uses <c>Session.FetchTypeTreeAsync</c> on each of the four root type nodes so the
|
||||
/// SDK's TypeTree cache is populated in one batched call per root rather than
|
||||
/// per-node round trips during the recursion. This PR ships the structural mirror
|
||||
/// only — binary-encoding priming via <c>LoadDataTypeSystem</c> is tracked as a
|
||||
/// follow-up because the public SDK surface for that helper was removed in
|
||||
/// OPCFoundation.NetStandard 1.5.378+.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <c>RegisterTypeNode</c> has a default no-op implementation on the interface so
|
||||
/// most builders (Galaxy, Modbus, FOCAS, S7, TwinCAT, AB-CIP) ignore the projection
|
||||
/// entirely — only the OPC UA server-side <c>DriverNodeManager</c> needs to override
|
||||
/// it for the client driver's mirror pass to surface in the OPC UA server's address
|
||||
/// space.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private async Task MirrorTypeDefinitionsAsync(
|
||||
ISession session, IAddressSpaceBuilder builder,
|
||||
Regex? includeRegex, Regex? excludeRegex, CancellationToken ct)
|
||||
{
|
||||
// FetchTypeTreeAsync populates the SDK-side TypeTree cache rooted at the four standard
|
||||
// type folders. This isn't free (it's a hierarchical browse) but it's the canonical way
|
||||
// to prime the cache so subsequent NodeCache.FetchNode calls hit memory rather than the
|
||||
// wire on every type. Failures are caught + logged-via-health-surface — the structural
|
||||
// mirror still proceeds with an empty cache.
|
||||
try
|
||||
{
|
||||
var typeRoots = new ExpandedNodeIdCollection
|
||||
{
|
||||
new ExpandedNodeId(ObjectIds.ObjectTypesFolder),
|
||||
new ExpandedNodeId(ObjectIds.VariableTypesFolder),
|
||||
new ExpandedNodeId(ObjectIds.DataTypesFolder),
|
||||
new ExpandedNodeId(ObjectIds.ReferenceTypesFolder),
|
||||
};
|
||||
await session.FetchTypeTreeAsync(typeRoots, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Non-fatal — the structural mirror still works without a primed TypeTree cache;
|
||||
// we just don't get the in-memory super-type chain shortcuts.
|
||||
}
|
||||
|
||||
// Note: this PR ships the structural mirror only. A previous SDK version exposed
|
||||
// ISession.LoadDataTypeSystem(NodeId, CancellationToken) for priming structured-type
|
||||
// encodings; that method was removed from the public surface in OPCFoundation.NetStandard
|
||||
// 1.5.378+. Loading the binary type system now requires per-node walks of the encoding
|
||||
// dictionaries via NodeCache helpers, which is significant additional scope. Tracked as
|
||||
// a follow-up; existing deployments that need structured-type decoding can mirror the
|
||||
// raw type tree today and consume Variant<ExtensionObject> on the client side.
|
||||
|
||||
await MirrorTypeBranchAsync(session, builder, ObjectIds.ObjectTypesFolder,
|
||||
MirroredTypeKind.ObjectType, "ObjectTypes", includeRegex, excludeRegex, ct)
|
||||
.ConfigureAwait(false);
|
||||
await MirrorTypeBranchAsync(session, builder, ObjectIds.VariableTypesFolder,
|
||||
MirroredTypeKind.VariableType, "VariableTypes", includeRegex, excludeRegex, ct)
|
||||
.ConfigureAwait(false);
|
||||
await MirrorTypeBranchAsync(session, builder, ObjectIds.DataTypesFolder,
|
||||
MirroredTypeKind.DataType, "DataTypes", includeRegex, excludeRegex, ct)
|
||||
.ConfigureAwait(false);
|
||||
await MirrorTypeBranchAsync(session, builder, ObjectIds.ReferenceTypesFolder,
|
||||
MirroredTypeKind.ReferenceType, "ReferenceTypes", includeRegex, excludeRegex, ct)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Recursive walk of a single type-folder branch (ObjectType / VariableType / DataType /
|
||||
/// ReferenceType). Uses HasSubtype reference walking (the canonical OPC UA way to
|
||||
/// enumerate type hierarchies) — IncludeSubtypes=false so the recursion controls depth
|
||||
/// itself rather than the server bulk-returning the full subtree at the root.
|
||||
/// </summary>
|
||||
private async Task MirrorTypeBranchAsync(
|
||||
ISession session, IAddressSpaceBuilder builder,
|
||||
NodeId rootNode, MirroredTypeKind kind, string rootSegmentName,
|
||||
Regex? includeRegex, Regex? excludeRegex, CancellationToken ct)
|
||||
{
|
||||
var visited = new HashSet<NodeId>();
|
||||
var discovered = 0;
|
||||
await WalkTypeNodeAsync(session, builder, rootNode, kind, rootSegmentName,
|
||||
superTypeNodeId: null, depth: 0, visited, includeRegex, excludeRegex,
|
||||
() => discovered, () => discovered++, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task WalkTypeNodeAsync(
|
||||
ISession session, IAddressSpaceBuilder builder,
|
||||
NodeId node, MirroredTypeKind kind, string pathPrefix,
|
||||
string? superTypeNodeId, int depth,
|
||||
HashSet<NodeId> visited, Regex? includeRegex, Regex? excludeRegex,
|
||||
Func<int> discovered, Action increment, CancellationToken ct)
|
||||
{
|
||||
if (depth >= _options.MaxBrowseDepth) return;
|
||||
if (discovered() >= _options.MaxDiscoveredNodes) return;
|
||||
if (!visited.Add(node)) return;
|
||||
|
||||
// Browse subtypes only (HasSubtype): for an Object/Variable/ReferenceType the children
|
||||
// we care about are subtypes. We don't need to enumerate property nodes / instance
|
||||
// children of types at this layer — RegisterTypeNode is purely for the type identity.
|
||||
var browseDescriptions = new BrowseDescriptionCollection
|
||||
{
|
||||
new()
|
||||
{
|
||||
NodeId = node,
|
||||
BrowseDirection = BrowseDirection.Forward,
|
||||
ReferenceTypeId = ReferenceTypeIds.HasSubtype,
|
||||
IncludeSubtypes = false,
|
||||
NodeClassMask = (uint)(NodeClass.ObjectType | NodeClass.VariableType
|
||||
| NodeClass.DataType | NodeClass.ReferenceType),
|
||||
ResultMask = (uint)(BrowseResultMask.BrowseName | BrowseResultMask.DisplayName
|
||||
| BrowseResultMask.NodeClass),
|
||||
}
|
||||
};
|
||||
|
||||
BrowseResponse resp;
|
||||
try
|
||||
{
|
||||
resp = await session.BrowseAsync(
|
||||
requestHeader: null,
|
||||
view: null,
|
||||
requestedMaxReferencesPerNode: 0,
|
||||
nodesToBrowse: browseDescriptions,
|
||||
ct: ct).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Transient browse failure — skip this branch, keep the rest of the mirror going.
|
||||
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;
|
||||
var childPath = pathPrefix + "/" + browseName;
|
||||
|
||||
// Curation rules apply to the type walk too — operators with very tight servers
|
||||
// can scope the type mirror via "ObjectTypes/MyVendor/*" globs.
|
||||
if (!ShouldInclude(childPath, includeRegex, excludeRegex))
|
||||
continue;
|
||||
|
||||
// Read IsAbstract for the type. Treated as best-effort — if the upstream returns
|
||||
// Bad we default to false so the mirror still ships rather than dropping the node.
|
||||
var isAbstract = await TryReadIsAbstractAsync(session, childId, ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var upstreamId = BuildRemappedFullName(childId, session.NamespaceUris,
|
||||
_options.Curation.NamespaceRemap);
|
||||
var parentId = BuildRemappedFullName(node, session.NamespaceUris,
|
||||
_options.Curation.NamespaceRemap);
|
||||
|
||||
builder.RegisterTypeNode(new MirroredTypeNodeInfo(
|
||||
Kind: kind,
|
||||
UpstreamNodeId: upstreamId,
|
||||
BrowseName: browseName,
|
||||
DisplayName: displayName,
|
||||
SuperTypeNodeId: superTypeNodeId is null ? null : parentId,
|
||||
IsAbstract: isAbstract));
|
||||
increment();
|
||||
|
||||
// Recurse — depth+1 because each level of HasSubtype is real depth in the type tree.
|
||||
// Pass childId-as-supertype string so descendants can record their super-type chain.
|
||||
await WalkTypeNodeAsync(session, builder, childId, kind, childPath,
|
||||
superTypeNodeId: upstreamId, depth + 1, visited,
|
||||
includeRegex, excludeRegex, discovered, increment, ct)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Best-effort read of the <c>IsAbstract</c> attribute for a type node. Falls back to
|
||||
/// <c>false</c> on any read failure so a single bad upstream attribute doesn't drop the
|
||||
/// entire type from the mirror.
|
||||
/// </summary>
|
||||
private static async Task<bool> TryReadIsAbstractAsync(
|
||||
ISession session, NodeId node, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var nodesToRead = new ReadValueIdCollection
|
||||
{
|
||||
new ReadValueId { NodeId = node, AttributeId = Attributes.IsAbstract },
|
||||
};
|
||||
var resp = await session.ReadAsync(
|
||||
requestHeader: null,
|
||||
maxAge: 0,
|
||||
timestampsToReturn: TimestampsToReturn.Neither,
|
||||
nodesToRead: nodesToRead,
|
||||
ct: ct).ConfigureAwait(false);
|
||||
if (resp.Results.Count > 0
|
||||
&& StatusCode.IsGood(resp.Results[0].StatusCode)
|
||||
&& resp.Results[0].Value is bool b)
|
||||
{
|
||||
return b;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <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
|
||||
|
||||
@@ -191,6 +191,40 @@ public sealed class OpcUaClientDriverOptions
|
||||
/// pre-curation behaviour exactly — empty include = include all.
|
||||
/// </summary>
|
||||
public OpcUaClientCurationOptions Curation { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// When <c>true</c>, <c>DiscoverAsync</c> runs an additional pass that walks the upstream
|
||||
/// <c>TypesFolder</c> (<c>i=86</c>) — ObjectTypes (<c>i=88</c>), VariableTypes
|
||||
/// (<c>i=89</c>), DataTypes (<c>i=90</c>), ReferenceTypes (<c>i=91</c>) — and projects the
|
||||
/// discovered type-definition nodes into the local address space via
|
||||
/// <c>IAddressSpaceBuilder.RegisterTypeNode</c>. Default <c>false</c> — opt-in so
|
||||
/// existing deployments don't suddenly see a flood of type nodes after upgrade. Enable
|
||||
/// when downstream clients need the upstream type system to render structured values or
|
||||
/// decode custom event fields.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The type-mirror pass uses <c>Session.FetchTypeTreeAsync</c> on each of the four
|
||||
/// root type nodes so the SDK's local TypeTree cache is populated efficiently (one
|
||||
/// batched browse per root rather than per-node round trips). This PR ships the
|
||||
/// <i>structural</i> mirror only — every type node is registered with its identity,
|
||||
/// super-type chain, and IsAbstract flag, but structured-type binary encodings are
|
||||
/// NOT primed. (The OPCFoundation SDK removed
|
||||
/// <c>ISession.LoadDataTypeSystem(NodeId, CancellationToken)</c> from the public
|
||||
/// surface in 1.5.378+; loading binary encodings now requires per-node walks of
|
||||
/// <c>HasEncoding</c> + dictionary nodes which is tracked as a follow-up.) Clients
|
||||
/// that need structured-type decoding can still consume
|
||||
/// <c>Variant<ExtensionObject></c> on the wire.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <see cref="OpcUaClientCurationOptions.IncludePaths"/> +
|
||||
/// <see cref="OpcUaClientCurationOptions.ExcludePaths"/> still apply to the type
|
||||
/// walk; paths are slash-joined under their root (e.g.
|
||||
/// <c>"ObjectTypes/BaseObjectType/SomeType"</c>). Most operators want all types so
|
||||
/// empty include = include all is the right default.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public bool MirrorTypeDefinitions { get; init; } = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
Reference in New Issue
Block a user