fix(opcua): restore alarm component reference after SDK creation
This commit is contained in:
@@ -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
|
||||
|
||||
+71
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user