fix(alarms): native-alarm re-statements no longer duplicate history or hide acked alarms (#656)
- AlarmTransitionKind.ActiveAcknowledged (appended): active AND acked applied as ONE projection. Galaxy ActiveAcked snapshot entries and OpcUaClient inactive->(active,acked) use it; the projector writes 'Activated' only if not already active, 'Acknowledged' only if not acked. - AlarmEventArgs.IsRestatement (optional, default false): set only by Galaxy snapshot entries and OpcUaClient first sight. A flagged Retrigger on an Active+unacked condition with the same message and severity, or a flagged Clear on an inactive condition, writes no /alerts row. - NativeAlarmProjector reports the history rows per transition; ForwardNativeAlarm writes one row per entry. Unflagged sources (FOCAS/MTConnect/AbCip, live Galaxy) are unchanged. - Tests: projector rules each with a must-write control; OpcUaClient transition table; Galaxy feed/driver mapping; Galaxy + OpcUaClient end-to-end through DriverHostActor. Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
+39
@@ -84,6 +84,45 @@ public sealed class GalaxyDriverAlarmEventArgsExtensionTests
|
||||
observed[0].AlarmCategory.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #656 — a snapshot ActiveAcknowledged transition reaches <see cref="AlarmEventArgs"/> as
|
||||
/// <see cref="AlarmTransitionKind.ActiveAcknowledged"/> (not Unspecified, which would keep the host's prior
|
||||
/// state) with <see cref="AlarmEventArgs.IsRestatement"/> carried through; an unflagged transition stays false.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ActiveAcknowledged_snapshot_transition_maps_kind_and_restatement_flag()
|
||||
{
|
||||
var feed = new FakeAlarmFeed();
|
||||
using var driver = NewDriver(feed);
|
||||
|
||||
await driver.SubscribeAlarmsAsync(["Tank01"], CancellationToken.None);
|
||||
var observed = new List<AlarmEventArgs>();
|
||||
driver.OnAlarmEvent += (_, args) => observed.Add(args);
|
||||
|
||||
var t = new GalaxyAlarmTransition(
|
||||
AlarmFullReference: "Tank01.Level.HiHi",
|
||||
SourceObjectReference: "Tank01",
|
||||
AlarmTypeName: "AnalogLimitAlarm.HiHi",
|
||||
TransitionKind: GalaxyAlarmTransitionKind.ActiveAcknowledged,
|
||||
SeverityBucket: AlarmSeverity.Critical,
|
||||
OpcUaSeverity: 800,
|
||||
RawMxAccessSeverity: 750,
|
||||
OriginalRaiseTimestampUtc: null,
|
||||
TransitionTimestampUtc: DateTime.UtcNow,
|
||||
OperatorUser: "alice",
|
||||
OperatorComment: "seen",
|
||||
Category: "Process",
|
||||
Description: "Tank 01 high-high level",
|
||||
IsRestatement: true);
|
||||
feed.Emit(t);
|
||||
feed.Emit(t with { TransitionKind = GalaxyAlarmTransitionKind.Retrigger, IsRestatement = false });
|
||||
|
||||
observed.Select(e => (e.Kind, e.IsRestatement)).ShouldBe([
|
||||
(AlarmTransitionKind.ActiveAcknowledged, true),
|
||||
(AlarmTransitionKind.Retrigger, false),
|
||||
]);
|
||||
}
|
||||
|
||||
private static GalaxyDriver NewDriver(IGalaxyAlarmFeed feed)
|
||||
{
|
||||
var options = new GalaxyDriverOptions(
|
||||
|
||||
+45
-2
@@ -64,14 +64,57 @@ public sealed class GatewayGalaxyAlarmFeedTests
|
||||
observed[0].SeverityBucket.ShouldBe(AlarmSeverity.Critical);
|
||||
observed[0].RawMxAccessSeverity.ShouldBe(750);
|
||||
|
||||
// Acknowledged snapshot entry → Acknowledge, operator fields preserved.
|
||||
observed[1].TransitionKind.ShouldBe(GalaxyAlarmTransitionKind.Acknowledge);
|
||||
// Acknowledged snapshot entry → ActiveAcknowledged (#656 — an Acknowledge kept the host's prior Active, so a
|
||||
// cold host projected it INACTIVE), operator fields preserved.
|
||||
observed[1].TransitionKind.ShouldBe(GalaxyAlarmTransitionKind.ActiveAcknowledged);
|
||||
observed[1].OperatorUser.ShouldBe("alice");
|
||||
observed[1].OperatorComment.ShouldBe("investigating");
|
||||
|
||||
// #656: snapshot entries re-state upstream state; the live transition is unflagged.
|
||||
observed[0].IsRestatement.ShouldBeTrue();
|
||||
observed[1].IsRestatement.ShouldBeTrue();
|
||||
|
||||
// Live transition after snapshot_complete.
|
||||
observed[2].TransitionKind.ShouldBe(GalaxyAlarmTransitionKind.Clear);
|
||||
observed[2].OriginalRaiseTimestampUtc.ShouldBe(raise);
|
||||
observed[2].IsRestatement.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #656 — an Inactive snapshot entry is a re-stated Clear; a LIVE Retrigger (a genuine MXAccess
|
||||
/// re-occurrence) is never flagged, so the host keeps its history row.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Inactive_snapshot_entry_is_a_restated_clear_and_a_live_retrigger_is_unflagged()
|
||||
{
|
||||
var at = new DateTime(2026, 5, 1, 12, 0, 0, DateTimeKind.Utc);
|
||||
var messages = new[]
|
||||
{
|
||||
SnapshotMessage("Tank01.Level.HiHi", AlarmConditionState.Inactive, severity: 750, lastTransition: at),
|
||||
new AlarmFeedMessage { SnapshotComplete = true },
|
||||
TransitionMessage("Tank01.Level.HiHi", AlarmTransitionKind.Retrigger, severity: 750,
|
||||
transitionTime: at.AddMinutes(1)),
|
||||
};
|
||||
|
||||
var observed = new List<GalaxyAlarmTransition>();
|
||||
var got2 = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
await using var feed = new GatewayGalaxyAlarmFeed((_, ct) => OpenStream(messages, ct), clientName: "FeedTest656");
|
||||
feed.OnAlarmTransition += (_, t) =>
|
||||
{
|
||||
lock (observed)
|
||||
{
|
||||
observed.Add(t);
|
||||
if (observed.Count == 2) got2.TrySetResult(true);
|
||||
}
|
||||
};
|
||||
feed.Start();
|
||||
|
||||
(await Task.WhenAny(got2.Task, Task.Delay(TimeSpan.FromSeconds(2)))).ShouldBe(got2.Task);
|
||||
|
||||
observed[0].TransitionKind.ShouldBe(GalaxyAlarmTransitionKind.Clear);
|
||||
observed[0].IsRestatement.ShouldBeTrue();
|
||||
observed[1].TransitionKind.ShouldBe(GalaxyAlarmTransitionKind.Retrigger);
|
||||
observed[1].IsRestatement.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the feed drops transitions with unspecified kind and empty messages.</summary>
|
||||
|
||||
@@ -218,7 +218,8 @@ public sealed class OpcUaClientAlarmTests
|
||||
/// <summary>The transition sequence drives a projector from the driver's last-told state to the event's.</summary>
|
||||
[Theory]
|
||||
[InlineData(false, true, true, false, new[] { AlarmTransitionKind.Raise })]
|
||||
[InlineData(false, true, true, true, new[] { AlarmTransitionKind.Raise, AlarmTransitionKind.Acknowledge })]
|
||||
[InlineData(false, true, true, true, new[] { AlarmTransitionKind.ActiveAcknowledged })]
|
||||
[InlineData(false, false, true, true, new[] { AlarmTransitionKind.ActiveAcknowledged })]
|
||||
[InlineData(true, false, true, true, new[] { AlarmTransitionKind.Acknowledge })]
|
||||
[InlineData(true, false, false, false, new[] { AlarmTransitionKind.Clear })]
|
||||
[InlineData(true, true, false, true, new[] { AlarmTransitionKind.Clear })]
|
||||
@@ -236,12 +237,13 @@ public sealed class OpcUaClientAlarmTests
|
||||
/// Gitea #647 review — with no prior this driver told the host (first sight after start or a deploy respawn),
|
||||
/// an unacknowledged upstream target goes out as Retrigger, never Raise: the host keeps an existing ack across
|
||||
/// a Raise on an already-active condition, so only a Retrigger makes the host match the upstream's unack.
|
||||
/// An acknowledged target keeps its Raise (a re-statement there is exactly right).
|
||||
/// An acknowledged active target is ONE atomic ActiveAcknowledged (Gitea #656 — Raise-then-Acknowledge showed
|
||||
/// clients an Active+unacked intermediate).
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(true, false, new[] { AlarmTransitionKind.Retrigger })]
|
||||
[InlineData(false, false, new[] { AlarmTransitionKind.Retrigger, AlarmTransitionKind.Clear })]
|
||||
[InlineData(true, true, new[] { AlarmTransitionKind.Raise, AlarmTransitionKind.Acknowledge })]
|
||||
[InlineData(true, true, new[] { AlarmTransitionKind.ActiveAcknowledged })]
|
||||
public void An_unknown_prior_tells_an_unacked_upstream_with_Retrigger(bool active, bool acked, AlarmTransitionKind[] expected)
|
||||
{
|
||||
OpcUaClientDriver.AlarmTransitions((false, true), (active, acked), priorKnown: false).ShouldBe(expected);
|
||||
@@ -253,6 +255,7 @@ public sealed class OpcUaClientAlarmTests
|
||||
private static (bool Active, bool Acked) Simulate((bool Active, bool Acked) s, AlarmTransitionKind k) => k switch
|
||||
{
|
||||
AlarmTransitionKind.Raise or AlarmTransitionKind.Retrigger => (true, false),
|
||||
AlarmTransitionKind.ActiveAcknowledged => (true, true),
|
||||
AlarmTransitionKind.Acknowledge => (s.Active, true),
|
||||
AlarmTransitionKind.Clear => (false, s.Acked),
|
||||
_ => s,
|
||||
@@ -375,6 +378,8 @@ public sealed class OpcUaClientAlarmTests
|
||||
// First sight of an UNACKED upstream condition is a Retrigger (#647 review: the host keeps an ack across a
|
||||
// Raise on an already-active condition, and only the upstream knows it is unacknowledged).
|
||||
seen.Select(e => e.Kind).ShouldBe([AlarmTransitionKind.Retrigger, AlarmTransitionKind.Acknowledge]);
|
||||
// #656: only the first-sight event re-states; the ack after it is a transition this instance watched.
|
||||
seen.Select(e => e.IsRestatement).ShouldBe([true, false]);
|
||||
|
||||
drv.RouteAlarmEvent(ConditionFields(true, true), TripIndex, handle); // the refresh copy
|
||||
seen.Count.ShouldBe(2);
|
||||
@@ -386,6 +391,29 @@ public sealed class OpcUaClientAlarmTests
|
||||
seen[2].Message.ShouldBe("worse");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #656 — an upstream condition first seen Active+acked goes out as ONE ActiveAcknowledged flagged as a
|
||||
/// re-statement (never Raise-then-Acknowledge, whose first event showed clients an unacked alarm); every
|
||||
/// event after first sight is unflagged, including a later re-activation.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void First_sight_of_an_acked_active_condition_is_one_restated_ActiveAcknowledged()
|
||||
{
|
||||
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-alarm-656");
|
||||
var seen = Record(drv);
|
||||
var handle = new FakeAlarmHandle();
|
||||
|
||||
drv.RouteAlarmEvent(ConditionFields(true, true), TripIndex, handle);
|
||||
seen.Select(e => (e.Kind, e.IsRestatement)).ShouldBe([(AlarmTransitionKind.ActiveAcknowledged, true)]);
|
||||
|
||||
drv.RouteAlarmEvent(ConditionFields(false, true), TripIndex, handle);
|
||||
drv.RouteAlarmEvent(ConditionFields(true, true, message: "again"), TripIndex, handle);
|
||||
seen.Skip(1).Select(e => (e.Kind, e.IsRestatement)).ShouldBe([
|
||||
(AlarmTransitionKind.Clear, false),
|
||||
(AlarmTransitionKind.ActiveAcknowledged, false),
|
||||
]);
|
||||
}
|
||||
|
||||
/// <summary>The exact upstream severity rides OpcUaSeverity (#626) so the host does not re-bucket it.</summary>
|
||||
[Fact]
|
||||
public void The_exact_upstream_severity_is_carried()
|
||||
@@ -410,16 +438,16 @@ public sealed class OpcUaClientAlarmTests
|
||||
var handle = new FakeAlarmHandle();
|
||||
drv.BeginAlarmSubscription(["Plant/opc/plc/Trip1"]);
|
||||
drv.RouteAlarmEvent(ConditionFields(true, true), TripIndex, handle);
|
||||
seen.Count.ShouldBe(2);
|
||||
seen.Count.ShouldBe(1);
|
||||
|
||||
await drv.ShutdownAsync(TestContext.Current.CancellationToken);
|
||||
drv.BeginAlarmSubscription(["Plant/opc/plc/Trip1"]);
|
||||
drv.RouteAlarmEvent(ConditionFields(true, true), TripIndex, handle);
|
||||
seen.Count.ShouldBe(2, "same set after a reinit: the refresh copy is a duplicate");
|
||||
seen.Count.ShouldBe(1, "same set after a reinit: the refresh copy is a duplicate");
|
||||
|
||||
drv.BeginAlarmSubscription(["Plant/opc/plc/Trip1", "Plant/opc/plc/Trip2"]);
|
||||
drv.RouteAlarmEvent(ConditionFields(true, true), TripIndex, handle);
|
||||
seen.Skip(2).Select(e => e.Kind).ShouldBe([AlarmTransitionKind.Raise, AlarmTransitionKind.Acknowledge]);
|
||||
seen.Skip(1).Select(e => e.Kind).ShouldBe([AlarmTransitionKind.ActiveAcknowledged]);
|
||||
}
|
||||
|
||||
/// <summary>A server without ConditionRefresh is tolerated; anything else is a real failure.</summary>
|
||||
|
||||
Reference in New Issue
Block a user