fix(opcua): restore alarm component reference after SDK creation

This commit is contained in:
Joseph Doherty
2026-11-10 12:13:56 -05:00
parent 8be3bb1734
commit 41e65f6a22
3 changed files with 100 additions and 3 deletions
@@ -1533,9 +1533,6 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
alarm.ShelvingState = new ShelvedStateMachineState(alarm);
alarm.MaxTimeShelved = new PropertyState<double>.Implementation<VariantBuilder>(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
@@ -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;
/// <summary>Preserves baseline alarm discovery through hierarchical folder browses in both application realms.</summary>
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<IReadOnlyList<ReferenceDescription>> 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();
}
}
@@ -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);
}
/// <summary>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.</summary>