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
@@ -1877,49 +1877,74 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
foreach (var n in nodeIds)
{
var nodeId = n.NodeId;
var snapshot = _nativeAlarmProjector.Project(nodeId, msg.Args, out var restatement);
var snapshot = _nativeAlarmProjector.Project(nodeId, msg.Args, out IReadOnlyList<AlarmTransitionKind> historyKinds);
_opcUaPublishActor.Tell(new ZB.MOM.WW.OtOpcUa.Runtime.OpcUa.OpcUaPublishActor.AlarmStateUpdate(
nodeId, snapshot, msg.Args.SourceTimestampUtc, n.Realm));
// Only the Primary publishes the cluster-wide alerts transition (see the gate above). A re-statement
// (Gitea #647 review: a Raise on an already-active condition, sent by a driver a deploy respawned) is
// not a transition — the condition was already Active, so a second "Activated" row would be a duplicate.
if (!serviceAlertsAsPrimary || restatement) continue;
// Only the Primary publishes the cluster-wide alerts transition (see the gate above). The projector
// decides WHICH rows the transition warrants: none for a re-statement (Gitea #647 review: a Raise on an
// already-active condition from a respawned driver; #656: a flagged snapshot/first-sight Retrigger or
// Clear that changes nothing the condition shows), and up to two for an ActiveAcknowledged (#656:
// "Activated" only if it was not already active, "Acknowledged" only if it was not already acked).
if (!serviceAlertsAsPrimary) continue;
var meta = _alarmMetaByNodeId.TryGetValue(nodeId, out var m)
? m : (EquipmentId: nodeId, Name: nodeId, AlarmType: "AlarmCondition", HistorizeToAveva: (bool?)null,
ReferencingEquipmentPaths: (IReadOnlyList<string>)Array.Empty<string>());
var alertEvent = new AlarmTransitionEvent(
AlarmId: nodeId,
EquipmentPath: meta.EquipmentId,
AlarmName: meta.Name,
TransitionKind: ToEventKind(msg.Args.Kind),
// The projector mapped the four-bucket AlarmSeverity onto the OPC UA 1..1000 scale already;
// reuse its ushort so the condition node + the alerts row agree on severity.
Severity: snapshot.Severity,
Message: msg.Args.Message,
// Native transitions are device-driven; an operator comment only rides along on an
// acknowledge from the upstream alarm system. "device" marks a non-operator origin.
User: msg.Args.OperatorComment is null ? string.Empty : "device",
TimestampUtc: msg.Args.SourceTimestampUtc,
AlarmTypeName: meta.AlarmType,
Comment: msg.Args.OperatorComment,
// Per-condition opt-out parsed from TagConfig.alarm.historizeToAveva (bool?, null ⇒ absent ⇒
// historize). The HistorianAdapterActor gate (historizeToAveva is not false) historizes null +
// true and suppresses the durable AVEVA row only on an explicit false — the same posture as the
// scripted-alarm opt-out. null here rides through unchanged (the gate treats it as default-on).
HistorizeToAveva: meta.HistorizeToAveva,
// WP4: the Area/Line/Equipment UNS paths that reference this raw condition — the /alerts row
// renders them as the equipment list (empty for a raw condition no equipment references yet).
ReferencingEquipmentPaths: meta.ReferencingEquipmentPaths);
_mediator.Tell(new Publish(ScriptedAlarmHostActor.AlertsTopic, alertEvent));
// Phase 5: fan the same transition into the node-local live-telemetry hub (no-op until a gRPC
// client subscribes). The DPS publish above is unchanged — a strictly additive tap, riding the
// same Primary gate (only reached when serviceAlertsAsPrimary).
_telemetryHub?.Emit(new TelemetryItem.Alarm(alertEvent));
foreach (var historyKind in historyKinds)
PublishNativeAlertRow(msg.Args, nodeId, snapshot, meta, historyKind);
}
}
/// <summary>
/// Publish one native-alarm <c>alerts</c> row (DPS + the node-local telemetry hub). Primary-gated by the caller.
/// </summary>
/// <param name="args">The native transition.</param>
/// <param name="nodeId">The condition node id.</param>
/// <param name="snapshot">The projected snapshot (its severity is the row's).</param>
/// <param name="meta">The condition's authored metadata.</param>
/// <param name="historyKind">The row's transition kind (one of the projector's history kinds).</param>
private void PublishNativeAlertRow(
AlarmEventArgs args, string nodeId, AlarmConditionSnapshot snapshot,
(string EquipmentId, string Name, string AlarmType, bool? HistorizeToAveva, IReadOnlyList<string> ReferencingEquipmentPaths) meta,
AlarmTransitionKind historyKind)
{
// #656: an ActiveAcknowledged expands to "Activated" + "Acknowledged". The operator comment/user belong to
// the acknowledgement, and the activation happened at the original raise time when the source knows it.
var expandedActivation = args.Kind == AlarmTransitionKind.ActiveAcknowledged
&& historyKind != AlarmTransitionKind.Acknowledge;
var comment = expandedActivation ? null : args.OperatorComment;
var timestamp = expandedActivation ? args.OriginalRaiseTimestampUtc ?? args.SourceTimestampUtc : args.SourceTimestampUtc;
var alertEvent = new AlarmTransitionEvent(
AlarmId: nodeId,
EquipmentPath: meta.EquipmentId,
AlarmName: meta.Name,
TransitionKind: ToEventKind(historyKind),
// The projector mapped the four-bucket AlarmSeverity onto the OPC UA 1..1000 scale already;
// reuse its ushort so the condition node + the alerts row agree on severity.
Severity: snapshot.Severity,
Message: args.Message,
// Native transitions are device-driven; an operator comment only rides along on an
// acknowledge from the upstream alarm system. "device" marks a non-operator origin.
User: comment is null ? string.Empty : "device",
TimestampUtc: timestamp,
AlarmTypeName: meta.AlarmType,
Comment: comment,
// Per-condition opt-out parsed from TagConfig.alarm.historizeToAveva (bool?, null ⇒ absent ⇒
// historize). The HistorianAdapterActor gate (historizeToAveva is not false) historizes null +
// true and suppresses the durable AVEVA row only on an explicit false — the same posture as the
// scripted-alarm opt-out. null here rides through unchanged (the gate treats it as default-on).
HistorizeToAveva: meta.HistorizeToAveva,
// WP4: the Area/Line/Equipment UNS paths that reference this raw condition — the /alerts row
// renders them as the equipment list (empty for a raw condition no equipment references yet).
ReferencingEquipmentPaths: meta.ReferencingEquipmentPaths);
_mediator.Tell(new Publish(ScriptedAlarmHostActor.AlertsTopic, alertEvent));
// Phase 5: fan the same transition into the node-local live-telemetry hub (no-op until a gRPC
// client subscribes). The DPS publish above is unchanged — a strictly additive tap, riding the
// same Primary gate (only reached when serviceAlertsAsPrimary).
_telemetryHub?.Emit(new TelemetryItem.Alarm(alertEvent));
}
/// <summary>Maps a native <see cref="AlarmTransitionKind"/> onto the canonical alarm event-kind
/// vocabulary scripted alarms emit (the <c>EmissionKind</c> names) so a native row renders with the
/// correct chip on the <c>/alerts</c> page and historizes into the same <c>EventKind</c> column as
@@ -1930,6 +1955,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
AlarmTransitionKind.Raise or AlarmTransitionKind.Retrigger => "Activated",
AlarmTransitionKind.Clear => "Cleared",
AlarmTransitionKind.Acknowledge => "Acknowledged",
// #656: never reached in practice — the projector expands ActiveAcknowledged into Raise + Acknowledge rows.
AlarmTransitionKind.ActiveAcknowledged => "Acknowledged",
_ => "Activated",
};
@@ -12,48 +12,110 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
/// </summary>
public sealed class NativeAlarmProjector
{
private readonly Dictionary<string, (bool Active, bool Acked, string? Message)> _prior = new(StringComparer.Ordinal);
private readonly Dictionary<string, (bool Active, bool Acked, string? Message, ushort Severity)> _prior =
new(StringComparer.Ordinal);
/// <summary>Project an alarm transition onto the full condition snapshot for <paramref name="nodeId"/>.</summary>
/// <param name="nodeId">The materialised condition node's id (the projection's state key).</param>
/// <param name="e">The native alarm transition.</param>
/// <returns>The full Part 9 condition snapshot to write to the node.</returns>
public AlarmConditionSnapshot Project(string nodeId, AlarmEventArgs e) => Project(nodeId, e, out _);
public AlarmConditionSnapshot Project(string nodeId, AlarmEventArgs e) => Project(nodeId, e, out IReadOnlyList<AlarmTransitionKind> _);
/// <summary>
/// Project an alarm transition, reporting whether it was a <b>re-statement</b> (Gitea #647 review): a
/// <see cref="AlarmTransitionKind.Raise"/> on a condition already projected Active WITH THE SAME MESSAGE
/// (re-review R1 — a different message is a different alarm, and un-acks). That is what a driver
/// whose told-record died with its instance sends after a deploy respawns it — the alarm it re-reads is the
/// one the server already shows, so the acknowledgement is KEPT and the host writes no second "Activated"
/// row. Only <see cref="AlarmTransitionKind.Retrigger"/> (or inactive → active) un-acknowledges; a source
/// that knows the alarm is a genuinely new statement must send Retrigger.
/// Project an alarm transition, reporting whether it was a <b>re-statement</b> — a transition that writes no
/// <c>alerts</c> row (see <see cref="Project(string, AlarmEventArgs, out IReadOnlyList{AlarmTransitionKind})"/>).
/// </summary>
/// <param name="nodeId">The materialised condition node's id (the projection's state key).</param>
/// <param name="e">The native alarm transition.</param>
/// <param name="restatement">True when <paramref name="e"/> re-stated an already-active condition.</param>
/// <param name="restatement">True when <paramref name="e"/> produced no history row.</param>
/// <returns>The full Part 9 condition snapshot to write to the node.</returns>
public AlarmConditionSnapshot Project(string nodeId, AlarmEventArgs e, out bool restatement)
{
var prev = _prior.TryGetValue(nodeId, out var p) ? p : (Active: false, Acked: true, Message: (string?)null);
var snapshot = Project(nodeId, e, out IReadOnlyList<AlarmTransitionKind> historyKinds);
restatement = historyKinds.Count == 0;
return snapshot;
}
/// <summary>
/// Project an alarm transition and report the <c>alerts</c> history rows it warrants, in order. Node state
/// and history are decided separately: a transition always projects a snapshot, but only a transition that
/// actually CHANGED what the condition shows (or that a source reports as genuinely new) writes a row.
/// <list type="bullet">
/// <item>Gitea #647 review — a <see cref="AlarmTransitionKind.Raise"/> on a condition already projected
/// Active WITH THE SAME MESSAGE (re-review R1 — a different message is a different alarm, and un-acks) is a
/// re-statement: what a driver whose told-record died with its instance sends after a deploy respawns
/// it. The acknowledgement is KEPT and no row is written. Only <see cref="AlarmTransitionKind.Retrigger"/>
/// (or inactive → active) un-acknowledges.</item>
/// <item>Gitea #656 — <see cref="AlarmTransitionKind.ActiveAcknowledged"/> projects Active+acked in ONE
/// update (never an Active+unacked intermediate a client could see) and writes "Activated" only when the
/// condition was not already showing that alarm active, then "Acknowledged" only when it was not acked.</item>
/// <item>Gitea #656 — a <see cref="AlarmEventArgs.IsRestatement"/> event (a source re-stating upstream's
/// current state: a reconnect snapshot, first sight after a respawn) writes no row when it changes nothing
/// the condition shows: a Retrigger on an Active+unacked condition with the same message AND severity, or
/// a Clear on an already-inactive condition. Unflagged sources (FOCAS, MTConnect, AbCip, live Galaxy
/// transitions) are unaffected — a live Retrigger is a genuine re-occurrence and keeps its row.</item>
/// </list>
/// </summary>
/// <param name="nodeId">The materialised condition node's id (the projection's state key).</param>
/// <param name="e">The native alarm transition.</param>
/// <param name="historyKinds">
/// The <c>alerts</c> rows to write, in order — each a <see cref="AlarmTransitionKind.Raise"/>/<see cref="AlarmTransitionKind.Retrigger"/>
/// ("Activated"), <see cref="AlarmTransitionKind.Acknowledge"/>, <see cref="AlarmTransitionKind.Clear"/> or
/// <see cref="AlarmTransitionKind.Unspecified"/>. Empty when the transition is a re-statement.
/// </param>
/// <returns>The full Part 9 condition snapshot to write to the node.</returns>
public AlarmConditionSnapshot Project(string nodeId, AlarmEventArgs e, out IReadOnlyList<AlarmTransitionKind> historyKinds)
{
var prev = _prior.TryGetValue(nodeId, out var p)
? p : (Active: false, Acked: true, Message: (string?)null, Severity: (ushort)0);
// #626: a source with a finer scale than the four buckets supplies the exact Part 9 severity.
var sev = e.OpcUaSeverity is { } exact ? (ushort)Math.Clamp(exact, 1, 1000) : MapSeverity(e.Severity);
// Gitea #647 re-review R1: only a Raise saying the SAME thing the condition already shows is a re-statement.
// Gitea #647 re-review R1: only a statement saying the SAME thing the condition already shows is a re-statement.
// A respawned driver cannot tell "the alarm I was showing" from "a different one that appeared while I was
// down", so a different message is treated as a new statement — un-acked, with its alerts row. Comparing the
// message is conservative: any difference (a sources' messages are deterministic for an unchanged statement)
// errs toward showing the operator an unacknowledged alarm, never toward hiding one behind a stale ack.
restatement = e.Kind == AlarmTransitionKind.Raise && prev.Active
var sameMessage = prev.Active
&& string.Equals(prev.Message?.Trim(), e.Message.Trim(), StringComparison.Ordinal);
var (active, acked) = e.Kind switch
bool active, acked;
switch (e.Kind)
{
AlarmTransitionKind.Raise when restatement => (true, prev.Acked),
AlarmTransitionKind.Raise or AlarmTransitionKind.Retrigger => (true, false),
AlarmTransitionKind.Acknowledge => (prev.Active, true),
AlarmTransitionKind.Clear => (false, prev.Acked),
_ => (prev.Active, prev.Acked),
};
_prior[nodeId] = (active, acked, e.Message);
case AlarmTransitionKind.Raise when sameMessage:
(active, acked, historyKinds) = (true, prev.Acked, []);
break;
// #656: a source re-stating an Active+unacked condition that already shows exactly this (message AND
// severity — an MTConnect Warning→Fault escalation can keep the text) changes nothing the node shows.
case AlarmTransitionKind.Retrigger when e.IsRestatement && sameMessage && !prev.Acked && prev.Severity == sev:
(active, acked, historyKinds) = (true, false, []);
break;
case AlarmTransitionKind.Raise or AlarmTransitionKind.Retrigger:
(active, acked, historyKinds) = (true, false, [e.Kind]);
break;
case AlarmTransitionKind.ActiveAcknowledged:
(active, acked) = (true, true);
historyKinds = !sameMessage
? [AlarmTransitionKind.Raise, AlarmTransitionKind.Acknowledge]
: !prev.Acked ? [AlarmTransitionKind.Acknowledge] : [];
break;
case AlarmTransitionKind.Acknowledge:
(active, acked, historyKinds) = (prev.Active, true, [AlarmTransitionKind.Acknowledge]);
break;
// #656: a re-stated Clear of a condition that is already inactive changes nothing (Galaxy replays every
// Inactive snapshot entry on each reconnect).
case AlarmTransitionKind.Clear when e.IsRestatement && !prev.Active:
(active, acked, historyKinds) = (false, prev.Acked, []);
break;
case AlarmTransitionKind.Clear:
(active, acked, historyKinds) = (false, prev.Acked, [AlarmTransitionKind.Clear]);
break;
default:
// Unspecified (a message/severity-only update) or an unknown future kind: keep the state. The row is
// written as before (Unspecified renders "Activated") — unchanged behaviour.
(active, acked, historyKinds) = (prev.Active, prev.Acked, [e.Kind]);
break;
}
_prior[nodeId] = (active, acked, e.Message, sev);
return new AlarmConditionSnapshot(
Active: active, Acknowledged: acked, Confirmed: true, Enabled: true,
Shelving: AlarmShelvingKind.Unshelved, Severity: sev, Message: e.Message,
@@ -71,7 +133,7 @@ public sealed class NativeAlarmProjector
/// <param name="nodeId">The acknowledged condition node id.</param>
public void MarkAcknowledged(string nodeId)
{
if (_prior.TryGetValue(nodeId, out var p)) _prior[nodeId] = (p.Active, true, p.Message);
if (_prior.TryGetValue(nodeId, out var p)) _prior[nodeId] = p with { Acked = true };
}
/// <summary>Clears all tracked per-node state.</summary>