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