From 41e65f6a2297995de4aa325c780619d7d592a0aa Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 10 Nov 2026 12:13:56 -0500 Subject: [PATCH] fix(opcua): restore alarm component reference after SDK creation --- .../OtOpcUaNodeManager.cs | 7 +- .../Sdk2AlarmBrowseCompatibilityTests.cs | 71 +++++++++++++++++++ .../NodeManagerAlarmSourceFieldsTests.cs | 25 +++++++ 3 files changed, 100 insertions(+), 3 deletions(-) create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/Sdk2AlarmBrowseCompatibilityTests.cs diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs index 92117849..12a20bf8 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs @@ -1533,9 +1533,6 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 alarm.ShelvingState = new ShelvedStateMachineState(alarm); alarm.MaxTimeShelved = new PropertyState.Implementation(alarm); alarm.SymbolicName = displayName; - // HasComponent so the parent folder "owns" the condition (matches the T13 notes' pattern). - alarm.ReferenceTypeId = ReferenceTypeIds.HasComponent; - // Create builds the full mandatory Part 9 child set (EnabledState, AckedState, // ActiveState, the Acknowledge/Confirm/AddComment/Enable/Disable methods, ...) from the // type's embedded definition; we do not hand-build them. @@ -1545,6 +1542,10 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 new QualifiedName(displayName, ns), new LocalizedText(displayName), assignNodeIds: true); + // Create initializes the embedded definition and resets ReferenceTypeId in SDK 2.0.1. + // Restore the application's parent ownership before AddChild: hierarchical folder browses + // must expose the alarm as a HasComponent object, as they did before the SDK upgrade. + alarm.ReferenceTypeId = ReferenceTypeIds.HasComponent; // Main-branch id MUST be a concrete (null) NodeId before any Set* call: SetEnableState -> // UpdateRetainState -> GetRetainState -> IsBranch() dereferences BranchId.Value, which diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/Sdk2AlarmBrowseCompatibilityTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/Sdk2AlarmBrowseCompatibilityTests.cs new file mode 100644 index 00000000..da18114d --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/Sdk2AlarmBrowseCompatibilityTests.cs @@ -0,0 +1,71 @@ +using Opc.Ua; +using Opc.Ua.Client; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; + +namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests; + +/// Preserves baseline alarm discovery through hierarchical folder browses in both application realms. +public sealed class Sdk2AlarmBrowseCompatibilityTests +{ + [Theory] + [InlineData("OffNormalAlarm", AddressSpaceRealm.Raw)] + [InlineData("DiscreteAlarm", AddressSpaceRealm.Raw)] + [InlineData("AlarmCondition", AddressSpaceRealm.Raw)] + [InlineData("OffNormalAlarm", AddressSpaceRealm.Uns)] + [InlineData("DiscreteAlarm", AddressSpaceRealm.Uns)] + [InlineData("AlarmCondition", AddressSpaceRealm.Uns)] + public async Task Alarm_is_a_hierarchical_component_with_the_same_inverse_parent_and_object_type( + string type, AddressSpaceRealm realm) + { + var ct = TestContext.Current.CancellationToken; + await using var rig = await ServerEventRig.StartAsync("AlarmBrowseCompatibility", false, ct); + rig.Enforce(false, [ServerEventRig.Key()]); + rig.Sink.EnsureFolder("browse-parent", null, "Parent", realm); + rig.Sink.MaterialiseAlarmCondition("browse-alarm", "browse-parent", "Alarm", type, 500, realm, + isNative: realm == AddressSpaceRealm.Raw); + using var session = await rig.ConnectAsync(true, ct); + var uri = realm == AddressSpaceRealm.Raw ? V3NodeIds.RawNamespaceUri : V3NodeIds.UnsNamespaceUri; + var ns = session.NamespaceUris.GetIndex(uri); + ns.ShouldBeGreaterThan(0); + var parent = new NodeId("browse-parent", (ushort)ns); + var alarm = new NodeId("browse-alarm", (ushort)ns); + var expectedType = type switch + { + "OffNormalAlarm" => ObjectTypeIds.OffNormalAlarmType, + "DiscreteAlarm" => ObjectTypeIds.DiscreteAlarmType, + _ => ObjectTypeIds.AlarmConditionType, + }; + + var children = await BrowseAsync(session, parent, ReferenceTypeIds.HierarchicalReferences, BrowseDirection.Forward, true, ct); + var discovered = children.SingleOrDefault(reference => ExpandedNodeId.ToNodeId(reference.NodeId, session.NamespaceUris) == alarm); + discovered.ShouldNotBeNull("the baseline folder browse exposes every materialised alarm object"); + discovered.ReferenceTypeId.ShouldBe(ReferenceTypeIds.HasComponent); + discovered.IsForward.ShouldBeTrue(); + discovered.NodeClass.ShouldBe(NodeClass.Object); + ExpandedNodeId.ToNodeId(discovered.TypeDefinition, session.NamespaceUris).ShouldBe(expectedType); + + var components = await BrowseAsync(session, parent, ReferenceTypeIds.HasComponent, BrowseDirection.Forward, false, ct); + components.ShouldContain(reference => ExpandedNodeId.ToNodeId(reference.NodeId, session.NamespaceUris) == alarm); + var inverse = await BrowseAsync(session, alarm, ReferenceTypeIds.HasComponent, BrowseDirection.Inverse, false, ct); + inverse.ShouldContain(reference => !reference.IsForward + && ExpandedNodeId.ToNodeId(reference.NodeId, session.NamespaceUris) == parent); + var types = await BrowseAsync(session, alarm, ReferenceTypeIds.HasTypeDefinition, BrowseDirection.Forward, false, ct); + types.ShouldContain(reference => ExpandedNodeId.ToNodeId(reference.NodeId, session.NamespaceUris) == expectedType); + await session.CloseAsync(ct); + } + + private static async Task> BrowseAsync(ISession session, NodeId node, + NodeId reference, BrowseDirection direction, bool subtypes, CancellationToken ct) + { + var result = (await session.BrowseAsync(null, null, 0, [new BrowseDescription + { + NodeId = node, ReferenceTypeId = reference, BrowseDirection = direction, IncludeSubtypes = subtypes, + ResultMask = (uint)BrowseResultMask.All, + }], ct)).Results[0]; + result.StatusCode.ShouldBe(StatusCodes.Good); + result.ContinuationPoint.IsEmpty.ShouldBeTrue(); + return result.References.ToList(); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerAlarmSourceFieldsTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerAlarmSourceFieldsTests.cs index b3cee68e..89ab4f6b 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerAlarmSourceFieldsTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerAlarmSourceFieldsTests.cs @@ -41,6 +41,31 @@ public sealed class NodeManagerAlarmSourceFieldsTests : IDisposable Path.GetTempPath(), $"otopcua-alarm-src-fields-{Guid.NewGuid():N}"); + [Theory] + [InlineData("OffNormalAlarm", AddressSpaceRealm.Raw)] + [InlineData("DiscreteAlarm", AddressSpaceRealm.Raw)] + [InlineData("AlarmCondition", AddressSpaceRealm.Raw)] + [InlineData("OffNormalAlarm", AddressSpaceRealm.Uns)] + [InlineData("DiscreteAlarm", AddressSpaceRealm.Uns)] + [InlineData("AlarmCondition", AddressSpaceRealm.Uns)] + public async Task Created_alarm_retains_the_parent_component_reference(string type, AddressSpaceRealm realm) + { + await using var host = await BootAsync(); + var nm = host.Nm; + nm.EnsureFolder("browse-parent", null, "Parent", realm); + nm.MaterialiseAlarmCondition("browse-alarm", "browse-parent", "Alarm", type, 500, realm, isNative: realm == AddressSpaceRealm.Raw); + var condition = nm.TryGetAlarmCondition("browse-alarm", realm)!; + condition.ReferenceTypeId.ShouldBe(ReferenceTypeIds.HasComponent); + condition.Parent!.NodeId.ShouldBe(new NodeId("browse-parent", nm.NamespaceIndexForRealm(realm))); + condition.TypeDefinitionId.ShouldBe(type switch + { + "OffNormalAlarm" => ObjectTypeIds.OffNormalAlarmType, + "DiscreteAlarm" => ObjectTypeIds.DiscreteAlarmType, + _ => ObjectTypeIds.AlarmConditionType, + }); + condition.NodeClass.ShouldBe(NodeClass.Object); + } + /// A NATIVE condition (Raw realm, ConditionId = RawPath) carries all three identity fields: /// EventType = its type definition, SourceNode = its own NodeId, SourceName = the RawPath. The leaf name /// remains on ConditionName so the short name is still available to clients.