From 8ac3ac5be966cdde3058bbedcd597c98b38c0f5a Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 11 Jun 2026 11:03:00 -0400 Subject: [PATCH] feat(alarms): carry AlarmTypeName + operator Comment on AlarmTransitionEvent (historian feed prep) --- .../Messages/Alerts/AlarmTransitionEvent.cs | 6 +++- .../ScriptedAlarmEngine.cs | 15 +++++++-- .../ScriptedAlarms/ScriptedAlarmHostActor.cs | 6 +++- .../ScriptedAlarmEngineTests.cs | 31 +++++++++++++++++++ .../ScriptedAlarmHostActorTests.cs | 7 +++++ 5 files changed, 61 insertions(+), 4 deletions(-) diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Alerts/AlarmTransitionEvent.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Alerts/AlarmTransitionEvent.cs index 53bb6ed4..ba1a8f71 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Alerts/AlarmTransitionEvent.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Alerts/AlarmTransitionEvent.cs @@ -14,6 +14,8 @@ namespace ZB.MOM.WW.OtOpcUa.Commons.Messages.Alerts; /// Fully-rendered message text — template tokens already resolved. /// Operator who triggered the transition. "system" for engine-driven events. /// When the transition occurred. +/// OPC UA Part 9 condition subtype name — one of LimitAlarm / DiscreteAlarm / OffNormalAlarm / AlarmCondition (the base type, used as the default). The historian feed maps this onto the durable alarm-type column. +/// Operator-supplied comment on ack / confirm / comment transitions; null for engine-driven transitions (Activated / Cleared / Shelved / …) that carry no comment. public sealed record AlarmTransitionEvent( string AlarmId, string EquipmentPath, @@ -22,4 +24,6 @@ public sealed record AlarmTransitionEvent( int Severity, string Message, string User, - DateTime TimestampUtc); + DateTime TimestampUtc, + string AlarmTypeName = "AlarmCondition", + string? Comment = null); diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmEngine.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmEngine.cs index 700ec0bc..0067f03f 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmEngine.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmEngine.cs @@ -598,7 +598,17 @@ public sealed class ScriptedAlarmEngine : IDisposable Message: message, Condition: condition, Emission: kind, - TimestampUtc: _clock()); + TimestampUtc: _clock(), + // Operator comment rides along on comment-bearing transitions — the condition + // state already carries it. Engine-driven transitions (Activated/Cleared/Shelved/…) + // and shelve ops (no comment param) leave it null. + Comment: kind switch + { + EmissionKind.Acknowledged => condition.LastAckComment, + EmissionKind.Confirmed => condition.LastConfirmComment, + EmissionKind.CommentAdded => condition.Comments.Count == 0 ? null : condition.Comments[^1].Text, + _ => null, + }); } /// @@ -823,7 +833,8 @@ public sealed record ScriptedAlarmEvent( string Message, AlarmConditionState Condition, EmissionKind Emission, - DateTime TimestampUtc); + DateTime TimestampUtc, + string? Comment = null); /// /// Upstream source abstraction — intentionally identical shape to the virtual-tag diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/ScriptedAlarmHostActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/ScriptedAlarmHostActor.cs index 94e49d51..44091b70 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/ScriptedAlarmHostActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/ScriptedAlarmHostActor.cs @@ -294,7 +294,11 @@ public sealed class ScriptedAlarmHostActor : ReceiveActor Severity: SeverityToInt(e.Severity), Message: e.Message, User: TransitionUser(e), - TimestampUtc: e.TimestampUtc); + TimestampUtc: e.TimestampUtc, + // Historian feed prep: carry the Part-9 subtype name (e.Kind.ToString() yields + // LimitAlarm/DiscreteAlarm/OffNormalAlarm/AlarmCondition) + any operator comment. + AlarmTypeName: e.Kind.ToString(), + Comment: e.Comment); // Warm-standby dedup: only the Primary (driver-role leader) publishes the cluster-wide // transition. Default-emit until told we are Secondary/Detached so single-node deploys + the diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests/ScriptedAlarmEngineTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests/ScriptedAlarmEngineTests.cs index 217e7944..2236e7bf 100644 --- a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests/ScriptedAlarmEngineTests.cs +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests/ScriptedAlarmEngineTests.cs @@ -448,6 +448,37 @@ public sealed class ScriptedAlarmEngineTests persisted!.Confirmed.ShouldBe(AlarmConfirmedState.Confirmed); } + /// Verifies the emitted carries the operator's + /// ack comment on the Acknowledged emission while the earlier Activated emission has no comment, + /// and that an explicit AddComment emission carries the comment text. (historian feed prep) + [Fact] + public async Task Emission_carries_operator_Comment_on_ack_and_add_comment() + { + var up = new FakeUpstream(); + up.Set("Temp", 50); + using var eng = Build(up, out _); + await eng.LoadAsync([Alarm("HighTemp", """return (int)ctx.GetTag("Temp").Value > 100;""")], + TestContext.Current.CancellationToken); + + var events = new List(); + eng.OnEvent += (_, e) => events.Add(e); + + // Activate → Acknowledge(comment) → AddComment(text). + up.Push("Temp", 150); + await WaitForAsync(() => events.Any(e => e.Emission == EmissionKind.Activated)); + await eng.AcknowledgeAsync("HighTemp", "op1", "looking into it", TestContext.Current.CancellationToken); + await WaitForAsync(() => events.Any(e => e.Emission == EmissionKind.Acknowledged)); + await eng.AddCommentAsync("HighTemp", "op2", "second look", TestContext.Current.CancellationToken); + await WaitForAsync(() => events.Any(e => e.Emission == EmissionKind.CommentAdded)); + + // Engine-driven Activated emission carries no operator comment. + events.First(e => e.Emission == EmissionKind.Activated).Comment.ShouldBeNull(); + // Acknowledged emission carries the ack comment. + events.First(e => e.Emission == EmissionKind.Acknowledged).Comment.ShouldBe("looking into it"); + // CommentAdded emission carries the latest appended comment text. + events.First(e => e.Emission == EmissionKind.CommentAdded).Comment.ShouldBe("second look"); + } + // (2b) TimedShelveAsync / UnshelveAsync end-to-end through the engine. /// Verifies that TimedShelveAsync shelves with a deadline and UnshelveAsync removes the shelve before the timer expires. [Fact] diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/ScriptedAlarmHostActorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/ScriptedAlarmHostActorTests.cs index 64c373ce..34bee4ca 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/ScriptedAlarmHostActorTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/ScriptedAlarmHostActorTests.cs @@ -173,6 +173,10 @@ public sealed class ScriptedAlarmHostActorTests : RuntimeActorTestBase evt.TransitionKind.ShouldBe("Activated"); evt.Severity.ShouldBe(1000); // 800 → Critical bucket → 1000 evt.User.ShouldBe("system"); + // Historian feed prep: the Part-9 subtype rides along (Plan's AlarmType "AlarmCondition" + // parses to AlarmKind.AlarmCondition); an engine-driven Activated transition has no comment. + evt.AlarmTypeName.ShouldBe("AlarmCondition"); + evt.Comment.ShouldBeNull(); } /// Clear path: after activating, pushing a value below the threshold drives Active→Inactive @@ -306,6 +310,9 @@ public sealed class ScriptedAlarmHostActorTests : RuntimeActorTestBase var evt = alerts.FishForMessage(e => e.TransitionKind == "Acknowledged", Timeout); evt.AlarmId.ShouldBe("alm-1"); evt.User.ShouldBe("alice"); + // Historian feed prep: the operator's ack comment threads through to the transition event. + evt.Comment.ShouldBe("ack-note"); + evt.AlarmTypeName.ShouldBe("AlarmCondition"); } /// Ownership filter: an AlarmCommand for an AlarmId this host's engine does NOT own is ignored