diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs
index db7f45f1..b64db8e0 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs
@@ -75,7 +75,9 @@ public sealed record ScriptedAlarmPlan(string ScriptedAlarmId, string EquipmentI
/// mirrors the authored Tag.AccessLevel == ReadWrite so the materialised node is created
/// CurrentReadWrite (the prerequisite for the inbound-write pipeline); a Read tag
/// stays read-only. This flag is derived identically on the artifact-decode side
-/// (DeploymentArtifact.BuildEquipmentTagPlans) for byte-parity.
+/// (DeploymentArtifact.BuildEquipmentTagPlans) for byte-parity. carries
+/// the optional native-alarm intent parsed from Tag.TagConfig's alarm object (null ⇒
+/// a plain value variable); it too is parsed identically on the artifact-decode side for byte-parity.
///
public sealed record EquipmentTagPlan(
string TagId,
@@ -85,7 +87,13 @@ public sealed record EquipmentTagPlan(
string Name,
string DataType,
string FullName,
- bool Writable);
+ bool Writable,
+ EquipmentTagAlarmInfo? Alarm);
+
+/// Native-alarm intent parsed from an equipment tag's TagConfig.alarm object. Null ⇒
+/// the tag is a plain value variable. is an OPC UA Part 9 subtype string
+/// (OffNormalAlarm/DiscreteAlarm/LimitAlarm/AlarmCondition); is the 1..1000 scale.
+public sealed record EquipmentTagAlarmInfo(string AlarmType, int Severity);
///
/// One Equipment-namespace VirtualTag from a row (joined to its
@@ -328,7 +336,8 @@ public static class Phase7Composer
Name: t.Name,
DataType: t.DataType,
FullName: ExtractTagFullName(t.TagConfig),
- Writable: t.AccessLevel == TagAccessLevel.ReadWrite))
+ Writable: t.AccessLevel == TagAccessLevel.ReadWrite,
+ Alarm: ExtractTagAlarm(t.TagConfig)))
.ToList();
// Per-equipment tag base = the shared substring-before-first-dot across each equipment's
@@ -445,4 +454,24 @@ public static class Phase7Composer
catch (JsonException) { /* fall through to raw blob */ }
return tagConfig;
}
+
+ /// Parses the optional alarm object from a tag's TagConfig JSON. Returns null
+ /// when absent, non-object, or non-JSON (the tag is then a plain variable). Never throws. The
+ /// artifact-decode side (DeploymentArtifact.ExtractTagAlarm) MUST parse identically (byte-parity).
+ internal static EquipmentTagAlarmInfo? ExtractTagAlarm(string? tagConfig)
+ {
+ if (string.IsNullOrWhiteSpace(tagConfig)) return null;
+ try
+ {
+ using var doc = JsonDocument.Parse(tagConfig);
+ if (doc.RootElement.ValueKind != JsonValueKind.Object) return null;
+ if (!doc.RootElement.TryGetProperty("alarm", out var a) || a.ValueKind != JsonValueKind.Object) return null;
+ var type = a.TryGetProperty("alarmType", out var tEl) && tEl.ValueKind == JsonValueKind.String
+ ? (tEl.GetString() ?? "AlarmCondition") : "AlarmCondition";
+ var sev = a.TryGetProperty("severity", out var sEl) && sEl.ValueKind == JsonValueKind.Number
+ ? sEl.GetInt32() : 500;
+ return new EquipmentTagAlarmInfo(type, sev);
+ }
+ catch (JsonException) { return null; }
+ }
}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs
index 8b3f89ad..1407bbc0 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs
@@ -445,7 +445,8 @@ public static class DeploymentArtifact
Name: name!,
DataType: dataType ?? "BaseDataType",
FullName: ExtractTagFullName(tagConfig),
- Writable: writable));
+ Writable: writable,
+ Alarm: ExtractTagAlarm(tagConfig)));
}
result.Sort((a, b) =>
@@ -651,6 +652,26 @@ public static class DeploymentArtifact
return tagConfig;
}
+ /// Parses the optional alarm object from a tag's TagConfig JSON. Returns null
+ /// when absent, non-object, or non-JSON (the tag is then a plain variable). Never throws. The
+ /// live-edit side (Phase7Composer.ExtractTagAlarm) MUST parse identically (byte-parity).
+ private static EquipmentTagAlarmInfo? ExtractTagAlarm(string? tagConfig)
+ {
+ if (string.IsNullOrWhiteSpace(tagConfig)) return null;
+ try
+ {
+ using var doc = JsonDocument.Parse(tagConfig);
+ if (doc.RootElement.ValueKind != JsonValueKind.Object) return null;
+ if (!doc.RootElement.TryGetProperty("alarm", out var a) || a.ValueKind != JsonValueKind.Object) return null;
+ var type = a.TryGetProperty("alarmType", out var tEl) && tEl.ValueKind == JsonValueKind.String
+ ? (tEl.GetString() ?? "AlarmCondition") : "AlarmCondition";
+ var sev = a.TryGetProperty("severity", out var sEl) && sEl.ValueKind == JsonValueKind.Number
+ ? sEl.GetInt32() : 500;
+ return new EquipmentTagAlarmInfo(type, sev);
+ }
+ catch (JsonException) { return null; }
+ }
+
private static IReadOnlyList ReadArray(JsonElement root, string propertyName, Func reader)
where T : class
{
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/ExtractTagAlarmTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/ExtractTagAlarmTests.cs
new file mode 100644
index 00000000..e031f6b3
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/ExtractTagAlarmTests.cs
@@ -0,0 +1,22 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.OpcUaServer;
+
+namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
+
+public class ExtractTagAlarmTests
+{
+ [Theory]
+ [InlineData("{\"FullName\":\"X.Y\"}", false, null, 0)]
+ [InlineData("{\"FullName\":\"X.Y\",\"alarm\":{}}", true, "AlarmCondition", 500)]
+ [InlineData("{\"FullName\":\"X.Y\",\"alarm\":{\"alarmType\":\"OffNormalAlarm\",\"severity\":700}}", true, "OffNormalAlarm", 700)]
+ [InlineData("not json", false, null, 0)]
+ [InlineData("{\"FullName\":\"X.Y\",\"alarm\":\"oops\"}", false, null, 0)]
+ public void ExtractTagAlarm_parses_or_returns_null(string cfg, bool present, string? type, int sev)
+ {
+ var info = Phase7Composer.ExtractTagAlarm(cfg);
+ if (!present) { info.ShouldBeNull(); return; }
+ info!.AlarmType.ShouldBe(type);
+ info.Severity.ShouldBe(sev);
+ }
+}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierHierarchyTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierHierarchyTests.cs
index 61885d76..085612c0 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierHierarchyTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierHierarchyTests.cs
@@ -143,7 +143,7 @@ public sealed class Phase7ApplierHierarchyTests : IDisposable
{
EquipmentTags = new[]
{
- new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false),
+ new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null),
},
};
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 7dd2ad81..4dbb9503 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierTests.cs
@@ -131,7 +131,7 @@ public sealed class Phase7ApplierTests
{
EquipmentTags = new[]
{
- new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: true),
+ new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: true, Alarm: null),
},
};
@@ -158,7 +158,7 @@ public sealed class Phase7ApplierTests
{
EquipmentTags = new[]
{
- new EquipmentTagPlan("tag-2", "eq-1", "drv", FolderPath: "Diagnostics", Name: "Temp", DataType: "Float", FullName: "40002", Writable: false),
+ new EquipmentTagPlan("tag-2", "eq-1", "drv", FolderPath: "Diagnostics", Name: "Temp", DataType: "Float", FullName: "40002", Writable: false, Alarm: null),
},
};
@@ -185,8 +185,8 @@ public sealed class Phase7ApplierTests
{
EquipmentTags = new[]
{
- new EquipmentTagPlan("tag-a", "eq-1", "drv-1", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false),
- new EquipmentTagPlan("tag-b", "eq-2", "drv-2", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false),
+ new EquipmentTagPlan("tag-a", "eq-1", "drv-1", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null),
+ new EquipmentTagPlan("tag-b", "eq-2", "drv-2", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null),
},
};
@@ -243,8 +243,8 @@ public sealed class Phase7ApplierTests
{
EquipmentTags = new[]
{
- new EquipmentTagPlan("tag-flat", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false),
- new EquipmentTagPlan("tag-nested", "eq-1", "drv", FolderPath: "Diagnostics", Name: "Temp", DataType: "Float", FullName: "40002", Writable: false),
+ new EquipmentTagPlan("tag-flat", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null),
+ new EquipmentTagPlan("tag-nested", "eq-1", "drv", FolderPath: "Diagnostics", Name: "Temp", DataType: "Float", FullName: "40002", Writable: false, Alarm: null),
},
EquipmentVirtualTags = new[]
{
@@ -338,7 +338,7 @@ public sealed class Phase7ApplierTests
{
AddedEquipmentTags = new[]
{
- new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false),
+ new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null),
},
};
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7PlannerTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7PlannerTests.cs
index 8890d636..cd7d1452 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7PlannerTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7PlannerTests.cs
@@ -43,7 +43,7 @@ public sealed class Phase7PlannerTests
{
EquipmentTags = new[]
{
- new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false),
+ new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null),
},
};
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactAliasParityTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactAliasParityTests.cs
index fbeba6f1..80c0c4d7 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactAliasParityTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactAliasParityTests.cs
@@ -241,6 +241,8 @@ public sealed class DeploymentArtifactAliasParityTests
};
// The Galaxy equipment tag — FullName is the Galaxy ref "tag_name.AttributeName".
+ // It also carries a native-alarm intent in TagConfig.alarm, so this draft proves the
+ // optional Alarm field is parsed byte-identically on both producers (Phase B WS-2).
var galaxyTag = new Tag
{
TagId = "tag-galaxy",
@@ -250,7 +252,7 @@ public sealed class DeploymentArtifactAliasParityTests
Name = "DownloadPath",
DataType = "String",
AccessLevel = TagAccessLevel.Read,
- TagConfig = "{\"FullName\":\"DelmiaReceiver_001.DownloadPath\"}",
+ TagConfig = "{\"FullName\":\"DelmiaReceiver_001.DownloadPath\",\"alarm\":{\"alarmType\":\"OffNormalAlarm\",\"severity\":700}}",
};
// A non-Galaxy (Modbus) equipment tag — proves the parity holds across drivers, not just Galaxy.
var modbusTag = new Tag
@@ -315,6 +317,7 @@ public sealed class DeploymentArtifactAliasParityTests
d.DataType.ShouldBe(x.DataType);
d.FullName.ShouldBe(x.FullName);
d.Writable.ShouldBe(x.Writable);
+ d.Alarm.ShouldBe(x.Alarm); // EquipmentTagAlarmInfo is a positional record ⇒ value equality
}
var galaxyPlan = decoded.EquipmentTags.Single(t => t.TagId == "tag-galaxy");
@@ -323,6 +326,15 @@ public sealed class DeploymentArtifactAliasParityTests
galaxyPlan.DriverInstanceId.ShouldBe("drv-galaxy");
galaxyPlan.FolderPath.ShouldBe(string.Empty); // null FolderPath coalesced identically on both sides
+ // The native-alarm intent in the Galaxy tag's TagConfig.alarm is parsed byte-identically on both
+ // producers (Phase B WS-2). The Modbus tag has no alarm object ⇒ null Alarm on both sides.
+ galaxyPlan.Alarm.ShouldNotBeNull();
+ galaxyPlan.Alarm!.AlarmType.ShouldBe("OffNormalAlarm");
+ galaxyPlan.Alarm.Severity.ShouldBe(700);
+ composed.EquipmentTags.Single(t => t.TagId == "tag-galaxy").Alarm.ShouldBe(galaxyPlan.Alarm);
+ decoded.EquipmentTags.Single(t => t.TagId == "tag-modbus").Alarm.ShouldBeNull();
+ composed.EquipmentTags.Single(t => t.TagId == "tag-modbus").Alarm.ShouldBeNull();
+
// Writability flows from Tag.AccessLevel: the Galaxy tag is Read (read-only node), the Modbus
// tag is ReadWrite (writable node). Both producers must derive the same Writable flag, and the
// SequenceEqual above already proves they agree element-wise.