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); + } +}