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
@@ -102,6 +102,14 @@ public sealed record AlarmAcknowledgeRequest(
/// <b>quality-only annotation</b> (<see cref="IsQualityOnly"/>): the host updates (via its Quality-only node path) the condition's
/// Quality without touching its Active/Acked state and publishes no <c>alerts</c> row for it.
/// </param>
/// <param name="IsRestatement">
/// Gitea #656 — true when the source is RE-STATING upstream's current state rather than reporting a transition it
/// watched happen: a reconnect snapshot entry (Galaxy) or the first event for a condition after a driver start or
/// deploy respawn (OpcUaClient). It governs <b>history only, never node state</b>: the host still projects the
/// state, but writes no <c>alerts</c> row when the re-statement changes nothing the condition shows (a Retrigger on
/// an Active+unacked condition with the same message and severity; a Clear on an already-inactive condition).
/// <c>false</c> (the default, every source that does not opt in) keeps every transition's row.
/// </param>
public sealed record AlarmEventArgs(
IAlarmSubscriptionHandle SubscriptionHandle,
string SourceNodeId,
@@ -115,7 +123,8 @@ public sealed record AlarmEventArgs(
string? AlarmCategory = null,
AlarmTransitionKind Kind = AlarmTransitionKind.Unspecified,
int? OpcUaSeverity = null,
uint? QualityStatusCode = null)
uint? QualityStatusCode = null,
bool IsRestatement = false)
{
/// <summary>
/// True when this event only re-annotates the condition's source-data quality — a
@@ -132,5 +141,8 @@ public enum AlarmSeverity { Low, Medium, High, Critical }
/// Kind of alarm state change carried by <see cref="AlarmEventArgs.Kind"/>, letting a
/// consumer derive OPC UA Part 9 active/ack state. Mirrors the driver-side
/// transition-kind enums (e.g. Galaxy's <c>GalaxyAlarmTransitionKind</c>).
/// <see cref="ActiveAcknowledged"/> (Gitea #656) means "the condition is active AND acknowledged", applied by the
/// host as ONE projection — never Raise-then-Acknowledge, which would show clients an Active+unacked intermediate.
/// Members are append-only: their numeric values must not move.
/// </summary>
public enum AlarmTransitionKind { Unspecified = 0, Raise, Acknowledge, Clear, Retrigger }
public enum AlarmTransitionKind { Unspecified = 0, Raise, Acknowledge, Clear, Retrigger, ActiveAcknowledged }