diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs index bdc97367..ccb2eb85 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs @@ -189,7 +189,16 @@ public sealed class Phase7Applier ? tag.EquipmentId : EquipmentNodeIds.SubFolder(tag.EquipmentId, tag.FolderPath); var nodeId = EquipmentNodeIds.Variable(tag.EquipmentId, tag.FolderPath, tag.Name); - SafeEnsureVariable(nodeId, parent, tag.Name, tag.DataType, tag.Writable); + if (tag.Alarm is not null) + { + // Native alarm tag → a real Part 9 condition node (reuses the scripted-alarm path), + // NOT a value variable. Parent is the sub-folder when set, else the equipment folder. + SafeMaterialiseAlarmCondition(nodeId, parent, tag.Name, tag.Alarm.AlarmType, tag.Alarm.Severity); + } + else + { + SafeEnsureVariable(nodeId, parent, tag.Name, tag.DataType, tag.Writable); + } } _logger.LogInformation( diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierTests.cs index 4dbb9503..d14992ae 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierTests.cs @@ -197,6 +197,71 @@ public sealed class Phase7ApplierTests sink.VariableCalls.ShouldContain(("eq-2/Speed", "eq-2", "Speed", "Float", false)); } + /// Phase B WS-3 — an alarm-bearing equipment tag (Alarm is not null) materialises a + /// real OPC UA Part 9 condition node (via the same path scripted alarms use) instead of a value + /// variable; a plain tag (Alarm == null) stays a value variable. The alarm tag's condition + /// uses the tag's folder-scoped NodeId, the equipment folder as parent, and carries the tag's + /// AlarmType/Severity. Proves BOTH branches in one composition. + [Fact] + public void MaterialiseEquipmentTags_alarm_bearing_tag_becomes_condition_plain_tag_stays_variable() + { + var sink = new RecordingSink(); + var applier = new Phase7Applier(sink, NullLogger.Instance); + + var composition = new Phase7CompositionResult( + Array.Empty(), Array.Empty(), Array.Empty()) + { + EquipmentTags = new[] + { + new EquipmentTagPlan("tag-plain", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: true, Alarm: null), + new EquipmentTagPlan("tag-alarm", "eq-1", "drv", FolderPath: "", Name: "OverTemp", DataType: "Boolean", FullName: "00001", Writable: false, Alarm: new EquipmentTagAlarmInfo("OffNormalAlarm", 700)), + }, + }; + + applier.MaterialiseEquipmentTags(composition); + + // The plain tag drove EnsureVariable at its folder-scoped NodeId, and NOT a condition. + var plainNodeId = EquipmentNodeIds.Variable("eq-1", "", "Speed"); + sink.VariableCalls.ShouldHaveSingleItem().ShouldBe((plainNodeId, "eq-1", "Speed", "Float", true)); + sink.AlarmConditionCalls.ShouldNotContain(c => c.AlarmNodeId == plainNodeId); + + // The alarm tag drove MaterialiseAlarmCondition (folder-scoped NodeId, equipment parent, + // matching display/type/severity) and did NOT drive EnsureVariable. + var alarmNodeId = EquipmentNodeIds.Variable("eq-1", "", "OverTemp"); + sink.AlarmConditionCalls.ShouldHaveSingleItem() + .ShouldBe((alarmNodeId, "eq-1", "OverTemp", "OffNormalAlarm", 700)); + sink.VariableCalls.ShouldNotContain(v => v.NodeId == alarmNodeId); + } + + /// Phase B WS-3 — an alarm-bearing equipment tag WITH a FolderPath still gets its + /// sub-folder created, and its condition is parented to that sub-folder (not the equipment folder), + /// using the folder-scoped NodeId. + [Fact] + public void MaterialiseEquipmentTags_alarm_bearing_tag_with_FolderPath_conditions_under_subfolder() + { + var sink = new RecordingSink(); + var applier = new Phase7Applier(sink, NullLogger.Instance); + + var composition = new Phase7CompositionResult( + Array.Empty(), Array.Empty(), Array.Empty()) + { + EquipmentTags = new[] + { + new EquipmentTagPlan("tag-alarm", "eq-1", "drv", FolderPath: "Diagnostics", Name: "OverTemp", DataType: "Boolean", FullName: "00001", Writable: false, Alarm: new EquipmentTagAlarmInfo("OffNormalAlarm", 500)), + }, + }; + + applier.MaterialiseEquipmentTags(composition); + + // The sub-folder is still created for an alarm tag with a FolderPath. + sink.FolderCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/Diagnostics", "eq-1", "Diagnostics")); + // Condition is parented to the sub-folder, with the folder-scoped NodeId. No value variable. + var alarmNodeId = EquipmentNodeIds.Variable("eq-1", "Diagnostics", "OverTemp"); + sink.AlarmConditionCalls.ShouldHaveSingleItem() + .ShouldBe((alarmNodeId, "eq-1/Diagnostics", "OverTemp", "OffNormalAlarm", 500)); + sink.VariableCalls.ShouldBeEmpty(); + } + /// Verifies MaterialiseEquipmentVirtualTags creates one Variable per VirtualTag directly /// under its existing equipment folder, with a folder-scoped NodeId (EquipmentId/Name — NOT the /// VirtualTagId or Expression), parent == EquipmentId, displayName == Name, and does NOT re-create