From e1ccd99ea20f5db2ea8fcf715c6b4e33675251da Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 14 Jun 2026 03:12:48 -0400 Subject: [PATCH] feat(alarms): EquipmentTagPlan.Alarm parsed byte-parity from TagConfig (Phase B WS-2) --- .../Phase7Composer.cs | 35 +++++++++++++++++-- .../Drivers/DeploymentArtifact.cs | 23 +++++++++++- .../ExtractTagAlarmTests.cs | 22 ++++++++++++ .../Phase7ApplierHierarchyTests.cs | 2 +- .../Phase7ApplierTests.cs | 14 ++++---- .../Phase7PlannerTests.cs | 2 +- .../DeploymentArtifactAliasParityTests.cs | 14 +++++++- 7 files changed, 98 insertions(+), 14 deletions(-) create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/ExtractTagAlarmTests.cs 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.