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