feat(alarms): carry AlarmTypeName + operator Comment on AlarmTransitionEvent (historian feed prep)
This commit is contained in:
@@ -14,6 +14,8 @@ namespace ZB.MOM.WW.OtOpcUa.Commons.Messages.Alerts;
|
||||
/// <param name="Message">Fully-rendered message text — template tokens already resolved.</param>
|
||||
/// <param name="User">Operator who triggered the transition. "system" for engine-driven events.</param>
|
||||
/// <param name="TimestampUtc">When the transition occurred.</param>
|
||||
/// <param name="AlarmTypeName">OPC UA Part 9 condition subtype name — one of <c>LimitAlarm</c> / <c>DiscreteAlarm</c> / <c>OffNormalAlarm</c> / <c>AlarmCondition</c> (the base type, used as the default). The historian feed maps this onto the durable alarm-type column.</param>
|
||||
/// <param name="Comment">Operator-supplied comment on ack / confirm / comment transitions; <c>null</c> for engine-driven transitions (Activated / Cleared / Shelved / …) that carry no comment.</param>
|
||||
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);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -823,7 +833,8 @@ public sealed record ScriptedAlarmEvent(
|
||||
string Message,
|
||||
AlarmConditionState Condition,
|
||||
EmissionKind Emission,
|
||||
DateTime TimestampUtc);
|
||||
DateTime TimestampUtc,
|
||||
string? Comment = null);
|
||||
|
||||
/// <summary>
|
||||
/// Upstream source abstraction — intentionally identical shape to the virtual-tag
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -448,6 +448,37 @@ public sealed class ScriptedAlarmEngineTests
|
||||
persisted!.Confirmed.ShouldBe(AlarmConfirmedState.Confirmed);
|
||||
}
|
||||
|
||||
/// <summary>Verifies the emitted <see cref="ScriptedAlarmEvent.Comment"/> 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)</summary>
|
||||
[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<ScriptedAlarmEvent>();
|
||||
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.
|
||||
/// <summary>Verifies that TimedShelveAsync shelves with a deadline and UnshelveAsync removes the shelve before the timer expires.</summary>
|
||||
[Fact]
|
||||
|
||||
+7
@@ -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();
|
||||
}
|
||||
|
||||
/// <summary>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<AlarmTransitionEvent>(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");
|
||||
}
|
||||
|
||||
/// <summary>Ownership filter: an AlarmCommand for an AlarmId this host's engine does NOT own is ignored
|
||||
|
||||
Reference in New Issue
Block a user