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:
Joseph Doherty
2026-10-08 23:35:50 -04:00
parent ca17d2cf71
commit b39bb6794d
15 changed files with 973 additions and 81 deletions
@@ -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>