diff --git a/src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IAddressSpaceBuilder.cs b/src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IAddressSpaceBuilder.cs
index 96075c8..7254b8c 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IAddressSpaceBuilder.cs
+++ b/src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IAddressSpaceBuilder.cs
@@ -35,8 +35,77 @@ public interface IAddressSpaceBuilder
/// _base equipment-class template).
///
void AddProperty(string browseName, DriverDataType dataType, object? value);
+
+ ///
+ /// Register a type-definition node (ObjectType / VariableType / DataType / ReferenceType)
+ /// mirrored from an upstream OPC UA server. Optional surface — drivers that don't mirror
+ /// types simply never call it; address-space builders that don't materialise upstream
+ /// types can leave the default no-op in place. Default implementation drops the call so
+ /// adding this method doesn't break existing
+ /// implementations.
+ ///
+ /// Metadata describing the type-definition node to mirror.
+ ///
+ ///
+ /// The OPC UA Client driver is the primary caller — it walks i=86
+ /// (TypesFolder) during DiscoverAsync when
+ /// OpcUaClientDriverOptions.MirrorTypeDefinitions is set so downstream clients
+ /// see the upstream type system instead of rendering structured-type values as opaque
+ /// strings.
+ ///
+ ///
+ /// The default no-op is intentional — most builders (Galaxy, Modbus, FOCAS, S7,
+ /// TwinCAT, AB-CIP) don't have a meaningful type folder to project into and would
+ /// otherwise need empty-stub overrides.
+ ///
+ ///
+ void RegisterTypeNode(MirroredTypeNodeInfo info) { /* default: no-op */ }
}
+///
+/// Categorises a mirrored type-definition node so the receiving builder can route it into
+/// the right OPC UA standard subtree (ObjectTypesFolder, VariableTypesFolder,
+/// DataTypesFolder, ReferenceTypesFolder) when projecting upstream types into
+/// the local address space.
+///
+public enum MirroredTypeKind
+{
+ ObjectType,
+ VariableType,
+ DataType,
+ ReferenceType,
+}
+
+///
+/// Metadata describing a single type-definition node mirrored from an upstream OPC UA
+/// server. Built by the OPC UA Client driver during type-mirror pass and consumed by
+/// .
+///
+/// Type category — drives which standard sub-folder the node lives under.
+///
+/// Stringified upstream NodeId (e.g. "ns=2;i=1234") — preserves the original identity
+/// so a builder that wants to project the type with a stable cross-namespace reference can do
+/// so. The driver applies any configured namespace remap before stamping this field.
+///
+/// OPC UA BrowseName segment from the upstream BrowseName.
+/// Human-readable display name; falls back to .
+///
+/// Stringified upstream NodeId of the super-type (parent type), or null when the node
+/// sits directly under the root (e.g. BaseObjectType, BaseVariableType). Lets
+/// the builder reconstruct the inheritance chain.
+///
+///
+/// true when the upstream node has the IsAbstract flag set (Object / Variable /
+/// ReferenceType). DataTypes also expose this — the driver passes it through verbatim.
+///
+public sealed record MirroredTypeNodeInfo(
+ MirroredTypeKind Kind,
+ string UpstreamNodeId,
+ string BrowseName,
+ string DisplayName,
+ string? SuperTypeNodeId,
+ bool IsAbstract);
+
/// Opaque handle for a registered variable. Used by Core for subscription routing.
public interface IVariableHandle
{
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs
index 78bc0c5..4c365b6 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs
@@ -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(); }
}
+ ///
+ /// Pass 3 of discovery: walk the upstream TypesFolder (i=86) and project
+ /// the four standard type sub-folders into the local address space via
+ /// . Honours the same curation rules
+ /// as pass-1 — paths are slash-joined under each type-folder root (e.g.
+ /// "ObjectTypes/BaseObjectType/MyType").
+ ///
+ ///
+ ///
+ /// Uses Session.FetchTypeTreeAsync 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 LoadDataTypeSystem is tracked as a
+ /// follow-up because the public SDK surface for that helper was removed in
+ /// OPCFoundation.NetStandard 1.5.378+.
+ ///
+ ///
+ /// RegisterTypeNode 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 DriverNodeManager needs to override
+ /// it for the client driver's mirror pass to surface in the OPC UA server's address
+ /// space.
+ ///
+ ///
+ 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 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);
+ }
+
+ ///
+ /// 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.
+ ///
+ private async Task MirrorTypeBranchAsync(
+ ISession session, IAddressSpaceBuilder builder,
+ NodeId rootNode, MirroredTypeKind kind, string rootSegmentName,
+ Regex? includeRegex, Regex? excludeRegex, CancellationToken ct)
+ {
+ var visited = new HashSet();
+ 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 visited, Regex? includeRegex, Regex? excludeRegex,
+ Func 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);
+ }
+ }
+
+ ///
+ /// Best-effort read of the IsAbstract attribute for a type node. Falls back to
+ /// false on any read failure so a single bad upstream attribute doesn't drop the
+ /// entire type from the mirror.
+ ///
+ private static async Task 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;
+ }
+ }
+
///
/// 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
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriverOptions.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriverOptions.cs
index 6021456..2404437 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriverOptions.cs
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriverOptions.cs
@@ -191,6 +191,40 @@ public sealed class OpcUaClientDriverOptions
/// pre-curation behaviour exactly — empty include = include all.
///
public OpcUaClientCurationOptions Curation { get; init; } = new();
+
+ ///
+ /// When true, DiscoverAsync runs an additional pass that walks the upstream
+ /// TypesFolder (i=86) — ObjectTypes (i=88), VariableTypes
+ /// (i=89), DataTypes (i=90), ReferenceTypes (i=91) — and projects the
+ /// discovered type-definition nodes into the local address space via
+ /// IAddressSpaceBuilder.RegisterTypeNode. Default false — 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.
+ ///
+ ///
+ ///
+ /// The type-mirror pass uses Session.FetchTypeTreeAsync 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
+ /// structural 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
+ /// ISession.LoadDataTypeSystem(NodeId, CancellationToken) from the public
+ /// surface in 1.5.378+; loading binary encodings now requires per-node walks of
+ /// HasEncoding + dictionary nodes which is tracked as a follow-up.) Clients
+ /// that need structured-type decoding can still consume
+ /// Variant<ExtensionObject> on the wire.
+ ///
+ ///
+ /// +
+ /// still apply to the type
+ /// walk; paths are slash-joined under their root (e.g.
+ /// "ObjectTypes/BaseObjectType/SomeType"). Most operators want all types so
+ /// empty include = include all is the right default.
+ ///
+ ///
+ public bool MirrorTypeDefinitions { get; init; } = false;
}
///
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientTypeMirrorTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientTypeMirrorTests.cs
new file mode 100644
index 0000000..a914f58
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientTypeMirrorTests.cs
@@ -0,0 +1,127 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
+
+///
+/// Unit tests for the type-mirror surface added in PR-8 (Issue #280). Live-browse
+/// coverage of the Pass-3 walk against an upstream server lands in integration tests
+/// against opc-plc; these unit assertions cover the option default, the new
+/// shape, and the back-compat default no-op for
+/// .
+///
+[Trait("Category", "Unit")]
+public sealed class OpcUaClientTypeMirrorTests
+{
+ [Fact]
+ public void MirrorTypeDefinitions_default_is_false_so_existing_deployments_are_unchanged()
+ {
+ var opts = new OpcUaClientDriverOptions();
+ opts.MirrorTypeDefinitions.ShouldBeFalse(
+ "opt-in flag — existing deployments shouldn't suddenly see a flood of type nodes");
+ }
+
+ [Fact]
+ public void MirrorTypeDefinitions_can_be_enabled_via_init()
+ {
+ var opts = new OpcUaClientDriverOptions { MirrorTypeDefinitions = true };
+ opts.MirrorTypeDefinitions.ShouldBeTrue();
+ }
+
+ [Fact]
+ public void MirroredTypeNodeInfo_carries_kind_identity_supertype_and_abstract_flag()
+ {
+ var info = new MirroredTypeNodeInfo(
+ Kind: MirroredTypeKind.ObjectType,
+ UpstreamNodeId: "ns=2;i=1234",
+ BrowseName: "MyMachine",
+ DisplayName: "My Machine",
+ SuperTypeNodeId: "i=58", // BaseObjectType
+ IsAbstract: false);
+
+ info.Kind.ShouldBe(MirroredTypeKind.ObjectType);
+ info.UpstreamNodeId.ShouldBe("ns=2;i=1234");
+ info.BrowseName.ShouldBe("MyMachine");
+ info.DisplayName.ShouldBe("My Machine");
+ info.SuperTypeNodeId.ShouldBe("i=58");
+ info.IsAbstract.ShouldBeFalse();
+ }
+
+ [Fact]
+ public void MirroredTypeKind_covers_all_four_OPC_UA_type_categories()
+ {
+ // The four standard OPC UA type-folder children — ObjectTypes (i=88),
+ // VariableTypes (i=89), DataTypes (i=90), ReferenceTypes (i=91). Asserts the enum
+ // shape rather than the values so the test catches accidental category renames.
+ Enum.GetValues().Length.ShouldBe(4);
+ Enum.IsDefined(MirroredTypeKind.ObjectType).ShouldBeTrue();
+ Enum.IsDefined(MirroredTypeKind.VariableType).ShouldBeTrue();
+ Enum.IsDefined(MirroredTypeKind.DataType).ShouldBeTrue();
+ Enum.IsDefined(MirroredTypeKind.ReferenceType).ShouldBeTrue();
+ }
+
+ [Fact]
+ public void RegisterTypeNode_default_implementation_is_no_op_so_existing_builders_dont_break()
+ {
+ // Builders that only implement Folder + Variable + AddProperty (i.e. the pre-PR-8
+ // contract) should continue to work — the default no-op on RegisterTypeNode means
+ // calling it on a minimal builder doesn't throw.
+ IAddressSpaceBuilder builder = new MinimalBuilder();
+ var info = new MirroredTypeNodeInfo(MirroredTypeKind.DataType,
+ UpstreamNodeId: "i=290", BrowseName: "Duration", DisplayName: "Duration",
+ SuperTypeNodeId: "i=290", IsAbstract: false);
+
+ Should.NotThrow(() => builder.RegisterTypeNode(info));
+ }
+
+ [Fact]
+ public void RegisterTypeNode_can_be_overridden_so_OPC_UA_server_builders_record_the_call()
+ {
+ // The OPC UA server-side DriverNodeManager will override RegisterTypeNode to actually
+ // project the type into the local address space. This test models that contract with
+ // a recording stub.
+ var recorder = new RecordingTypeBuilder();
+ IAddressSpaceBuilder builder = recorder;
+
+ builder.RegisterTypeNode(new MirroredTypeNodeInfo(
+ MirroredTypeKind.ObjectType, "ns=2;i=1", "T1", "T1", null, false));
+ builder.RegisterTypeNode(new MirroredTypeNodeInfo(
+ MirroredTypeKind.VariableType, "ns=2;i=2", "V1", "V1", "i=63", true));
+
+ recorder.Calls.Count.ShouldBe(2);
+ recorder.Calls[0].Kind.ShouldBe(MirroredTypeKind.ObjectType);
+ recorder.Calls[1].IsAbstract.ShouldBeTrue();
+ recorder.Calls[1].SuperTypeNodeId.ShouldBe("i=63");
+ }
+
+ ///
+ /// Minimum back-compat implementation — only the pre-PR-8 surface is overridden.
+ /// Verifies the default-implementation no-op for RegisterTypeNode doesn't break
+ /// existing builders.
+ ///
+ private sealed class MinimalBuilder : 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) { }
+
+ private sealed class StubHandle : IVariableHandle
+ {
+ public string FullReference => "stub";
+ public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info)
+ => throw new NotSupportedException();
+ }
+ }
+
+ private sealed class RecordingTypeBuilder : IAddressSpaceBuilder
+ {
+ public List Calls { get; } = new();
+ public IAddressSpaceBuilder Folder(string browseName, string displayName) => this;
+ public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
+ => throw new NotSupportedException();
+ public void AddProperty(string browseName, DriverDataType dataType, object? value) { }
+ public void RegisterTypeNode(MirroredTypeNodeInfo info) => Calls.Add(info);
+ }
+}