diff --git a/CLAUDE.md b/CLAUDE.md
index 230fc1f4..cda3152a 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1136,7 +1136,13 @@ host logger factory (wired in `DriverFactoryBootstrap`; the FOCAS and TwinCAT fa
`ForwardNativeAlarm` writes no second "Activated" `/alerts` row — every native driver's told-record dies with its
instance, and a deploy respawns the driver. Only `Retrigger` (or inactive → active) un-acks, so a source whose
UPSTREAM is authoritative for the ack sends Retrigger for an unacked re-statement: OpcUaClient on first sight of a
- condition (no prior told), Galaxy for an unacked active snapshot entry.
+ condition (no prior told), Galaxy for an unacked active snapshot entry. **Those two also flag
+ `AlarmEventArgs.IsRestatement` (#656)** — history-only: a flagged Retrigger on an Active+unacked condition with the
+ same message AND severity, or a flagged Clear on an inactive one, writes no `/alerts` row (every Galaxy reconnect used
+ to duplicate them). Active+acked is the atomic **`AlarmTransitionKind.ActiveAcknowledged`** (Galaxy snapshot
+ `ActiveAcked`, OpcUaClient inactive→acked-active) — never Acknowledge (kept a cold host's `Active=false`: the alarm was
+ invisible) nor Raise+Acknowledge (an unacked intermediate). `NativeAlarmProjector` returns the rows to write; unflagged
+ sources (FOCAS/MTConnect/AbCip, live Galaxy) are unchanged. E2E: `Runtime.Tests/NativeAlarmRestatementEndToEndTests`.
- **Re-Initialize without teardown (#630):** FOCAS, AbLegacy, AbCip, TwinCAT, S7 and OpcUaClient `InitializeAsync`
first release the prior device/session state (cancel, ≤ 5 s join, dispose — incl. the AbCip alarm projection;
FOCAS's is PAUSED, its told record kept, #647) — the device-state half of `ShutdownAsync` only; the poll engine is kept and the actor re-subscribes
diff --git a/docs/AlarmTracking.md b/docs/AlarmTracking.md
index 294476d1..bc46e7c3 100644
--- a/docs/AlarmTracking.md
+++ b/docs/AlarmTracking.md
@@ -67,6 +67,25 @@ same condition is wired as an **event notifier of every referencing equipment fo
counter) would un-ack on every respawn — conservative, never hiding an alarm — and a source whose message omits
what changed (a new alarm under identical text) would keep a stale ack across a respawn. A native source must also set `Kind`: an
`Unspecified` kind projects no state change.
+- **Snapshot / first-sight re-statements (#656).** A source that RE-STATES upstream's current state — Galaxy's
+ reconnect snapshot entries, OpcUaClient's first event per condition after a start or deploy respawn — flags
+ `AlarmEventArgs.IsRestatement` (optional, default `false`; FOCAS, MTConnect, AbCip and live Galaxy transitions never
+ set it). The flag governs **history only, never node state**: the projector still projects, but a flagged `Retrigger`
+ on a condition already Active+unacked with the **same message AND severity**, or a flagged `Clear` on an
+ already-inactive condition, writes no `/alerts` row. Before this every gateway reconnect wrote a duplicate
+ "Activated" (and "Cleared") row per alarm while clients saw no event. A **live** `Retrigger` is a genuine
+ re-occurrence and keeps its row; a changed severity (an MTConnect Warning→Fault that keeps its text) does too.
+ Residual: a Galaxy alarm that genuinely re-occurred during an outage and is still Active+unacked with identical
+ text/severity at reconnect loses that row (indistinguishable upstream; the node shows no change either). A process
+ restart still writes a fresh "Activated" for every already-active alarm — the projector has no memory, correctly.
+- **`AlarmTransitionKind.ActiveAcknowledged` (#656)** — "active AND acknowledged" in ONE projection (one node write,
+ at most one client event). `NativeAlarmProjector.Project(..., out historyKinds)` expands it into the rows that
+ actually changed: "Activated" (stamped `OriginalRaiseTimestampUtc`, no operator comment) only if the condition
+ was not already showing that alarm active, then "Acknowledged" (carrying the comment) only if it was not acked.
+ Galaxy maps a snapshot `ActiveAcked` entry to it — it used to map to `Acknowledge`, which kept the host's prior
+ `Active = false` on a cold host or an inactive prior, so an alarm active in AVEVA projected **inactive** and was
+ invisible to clients. OpcUaClient emits it for inactive → (active, acked) instead of `Raise` + `Acknowledge`,
+ which showed clients an Active+**unacked** intermediate event.
### Native condition state survives a full rebuild (Gitea #641)
diff --git a/docs/ScriptedAlarms.md b/docs/ScriptedAlarms.md
index 18d257f8..38c0d69d 100644
--- a/docs/ScriptedAlarms.md
+++ b/docs/ScriptedAlarms.md
@@ -297,7 +297,7 @@ engine graded 1000 published 900 while its `/alerts` row said 1000. That re-buck
### Runtime flow
The driver's live `IAlarmSource.OnAlarmEvent` is the sole input. Transition kinds
-(`Raise`, `Clear`, `Acknowledge`, `Retrigger`) drive the condition's Active /
+(`Raise`, `Clear`, `Acknowledge`, `Retrigger`, `ActiveAcknowledged`) drive the condition's Active /
Acknowledged / Severity / Message fields via `NativeAlarmProjector`, a pure
per-condition state projector. The projected `AlarmConditionSnapshot` is forwarded
through `DriverHostActor` → `OpcUaPublishActor.AlarmStateUpdate` →
diff --git a/docs/drivers/Galaxy.md b/docs/drivers/Galaxy.md
index ed0f3174..49206d59 100644
--- a/docs/drivers/Galaxy.md
+++ b/docs/drivers/Galaxy.md
@@ -94,6 +94,8 @@ Full per-field descriptions live in `Config/GalaxyDriverOptions.cs`. The full JS
The session-less alarm feed (`GatewayGalaxyAlarmFeed`) and alarm acknowledger (`GatewayGalaxyAlarmAcknowledger`) run on a separate always-on `_ownedMxClient` that is intentionally **not** recreated when `ReconnectSupervisor` rebuilds the worker session. The feed has its own re-invoke loop (~5 s backoff) that reopens `StreamAlarms` after any stream fault, and gRPC.NET's channel auto-reconnect recovers the underlying HTTP/2 connection after a gateway restart — so both the alarm stream and individual ack calls recover without client replacement. The acknowledger is completely stateless between calls: each `AcknowledgeAsync` issues a single unary RPC and returns; no dead-client state is latched on failure. Channel-level keepalive hardening (TCP keep-alive intervals, gRPC ping frames) would require exposing additional knobs on `MxGatewayClientOptions` in the sibling `mxaccessgw` repo — a future option if flaky long-lived connections are observed in production.
+Every (re)open of `StreamAlarms` replays the gateway's active-alarm snapshot. `GatewayGalaxyAlarmFeed` maps each entry to a transition flagged `IsRestatement` (#656): `Active` → `Retrigger` (upstream's unack is authoritative), `ActiveAcked` → `ActiveAcknowledged` (one atomic active+acked projection — the old `Acknowledge` mapping projected an acked alarm **inactive** on a cold host), `Inactive` → `Clear`. The host writes no `/alerts` row for a replayed entry that changes nothing the condition shows, so a gateway reconnect no longer duplicates history; live `Transition` messages are never flagged. The gateway folds UnackRtn and AckRtn into `Inactive`, so an inactive-unacked alarm cannot be distinguished at reconnect (gateway contract limit).
+
## Testing
- **Unit tests**: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/` — fakes the gateway gRPC surface; covers Browse, Runtime, Health, and Config in isolation.
diff --git a/docs/drivers/OpcUaClient.md b/docs/drivers/OpcUaClient.md
index f1d4144e..ab977241 100644
--- a/docs/drivers/OpcUaClient.md
+++ b/docs/drivers/OpcUaClient.md
@@ -100,7 +100,10 @@ initialise (the AdminUI form drops them on the next save). Pinned by
> EventId** (Part 9 §5.7.3). The event's `ActiveState/Id` + `AckedState/Id` are selected and turned into the
> `Raise`/`Acknowledge`/`Clear`/`Retrigger` kinds the host projector consumes (without a kind no upstream alarm could ever
> show active); the kinds are emitted from the state **last told to the host** per RawPath, so an identical re-delivery
-> emits nothing (no re-raise, which would un-ack). Condition **branch** events (non-null `BranchId`) are ignored.
+> emits nothing (no re-raise, which would un-ack). An inactive → active+acked move is ONE `ActiveAcknowledged` (#656; it
+> used to be `Raise` + `Acknowledge`, a client-visible unacked intermediate). The first event per condition after a
+> start or deploy respawn is flagged `IsRestatement`, so the host writes no duplicate "Activated" row when its
+> condition already shows that state (see `docs/AlarmTracking.md`). Condition **branch** events (non-null `BranchId`) are ignored.
> **`ConditionRefresh`** is called after the event item is created, after every in-place reconnect, and on a
> `RefreshRequired` event, so a condition already active upstream is reported at subscribe; `RefreshStart`/`RefreshEnd`
> markers are dropped. A server without it (`BadNotSupported`/`BadMethodInvalid`/…) is logged once and tolerated.
diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IAlarmSource.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IAlarmSource.cs
index cf87783c..b95f4167 100644
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IAlarmSource.cs
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IAlarmSource.cs
@@ -102,6 +102,14 @@ public sealed record AlarmAcknowledgeRequest(
/// quality-only annotation (): the host updates (via its Quality-only node path) the condition's
/// Quality without touching its Active/Acked state and publishes no alerts row for it.
///
+///
+/// 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 history only, never node state: the host still projects the
+/// state, but writes no alerts 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).
+/// false (the default, every source that does not opt in) keeps every transition's row.
+///
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)
{
///
/// 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 , letting a
/// consumer derive OPC UA Part 9 active/ack state. Mirrors the driver-side
/// transition-kind enums (e.g. Galaxy's GalaxyAlarmTransitionKind).
+/// (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.
///
-public enum AlarmTransitionKind { Unspecified = 0, Raise, Acknowledge, Clear, Retrigger }
+public enum AlarmTransitionKind { Unspecified = 0, Raise, Acknowledge, Clear, Retrigger, ActiveAcknowledged }
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs
index bc461bdf..f464a816 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs
@@ -1415,8 +1415,11 @@ public sealed class GalaxyDriver
GalaxyAlarmTransitionKind.Acknowledge => Core.Abstractions.AlarmTransitionKind.Acknowledge,
GalaxyAlarmTransitionKind.Clear => Core.Abstractions.AlarmTransitionKind.Clear,
GalaxyAlarmTransitionKind.Retrigger => Core.Abstractions.AlarmTransitionKind.Retrigger,
+ GalaxyAlarmTransitionKind.ActiveAcknowledged => Core.Abstractions.AlarmTransitionKind.ActiveAcknowledged,
_ => Core.Abstractions.AlarmTransitionKind.Unspecified,
- });
+ },
+ // #656: a snapshot entry re-states upstream state — history-only signal for the host.
+ IsRestatement: transition.IsRestatement);
try
{
OnAlarmEvent?.Invoke(this, args);
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/GalaxyAlarmTransition.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/GalaxyAlarmTransition.cs
index 3323133a..1456dcaf 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/GalaxyAlarmTransition.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/GalaxyAlarmTransition.cs
@@ -10,6 +10,11 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
/// on the OPC UA Part 9 condition once AlarmEventArgs is extended in
/// the client-surface refresh PR (E.7).
///
+///
+/// IsRestatement (Gitea #656) is true for an active-alarm SNAPSHOT entry — upstream re-stating its current
+/// state on (re)connect, not a transition the gateway watched happen. It rides to
+/// so the host writes no duplicate history row for it.
+///
internal sealed record GalaxyAlarmTransition(
string AlarmFullReference,
string SourceObjectReference,
@@ -23,7 +28,8 @@ internal sealed record GalaxyAlarmTransition(
string OperatorUser,
string OperatorComment,
string Category,
- string Description);
+ string Description,
+ bool IsRestatement = false);
/// Kind of alarm state change observed by .
internal enum GalaxyAlarmTransitionKind
@@ -33,4 +39,10 @@ internal enum GalaxyAlarmTransitionKind
Acknowledge = 2,
Clear = 3,
Retrigger = 4,
+
+ ///
+ /// Gitea #656 — the alarm is active AND acknowledged (a snapshot ActiveAcked entry). Maps to
+ /// , which the host applies as one projection.
+ ///
+ ActiveAcknowledged = 5,
}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/GatewayGalaxyAlarmFeed.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/GatewayGalaxyAlarmFeed.cs
index c8c6c969..86caa008 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/GatewayGalaxyAlarmFeed.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/GatewayGalaxyAlarmFeed.cs
@@ -172,18 +172,26 @@ internal sealed class GatewayGalaxyAlarmFeed : IGalaxyAlarmFeed
/// alarm is surfaced as a transition so the OPC UA Part 9 condition layer sees
/// the alarm's present state on (re)connect: an unacknowledged active alarm as
/// a , an acknowledged one as a
- /// .
+ /// , an inactive one as a
+ /// . Every entry is flagged
+ /// .
/// Retrigger, not Raise (Gitea #647 review): the host treats a Raise on a condition it already
/// shows Active as a re-statement that KEEPS the acknowledgement. A snapshot re-states upstream state and
/// the upstream's ack state is authoritative, so an unacked entry must un-ack a host condition still
/// holding an ack from before the reconnect.
+ /// ActiveAcknowledged, not Acknowledge (Gitea #656): an Acknowledge keeps the host's prior Active
+ /// state, so an acked active alarm the host did not already show active (a cold start, or one that went
+ /// active while the gateway was unreachable) projected INACTIVE+acked — invisible to clients.
+ /// IsRestatement (Gitea #656): every gateway reconnect replays the snapshot, so without the flag each
+ /// replay wrote a duplicate "Activated" (or "Cleared") history row for an alarm whose node did not change.
+ /// Live events are never flagged — a live Retrigger is a real re-occurrence.
///
private void DispatchSnapshotEntry(ActiveAlarmSnapshot snapshot)
{
var kind = snapshot.CurrentState switch
{
AlarmConditionState.Active => GalaxyAlarmTransitionKind.Retrigger,
- AlarmConditionState.ActiveAcked => GalaxyAlarmTransitionKind.Acknowledge,
+ AlarmConditionState.ActiveAcked => GalaxyAlarmTransitionKind.ActiveAcknowledged,
AlarmConditionState.Inactive => GalaxyAlarmTransitionKind.Clear,
_ => GalaxyAlarmTransitionKind.Unspecified,
};
@@ -210,7 +218,8 @@ internal sealed class GatewayGalaxyAlarmFeed : IGalaxyAlarmFeed
OperatorUser: snapshot.OperatorUser,
OperatorComment: snapshot.OperatorComment,
Category: snapshot.Category,
- Description: snapshot.Description));
+ Description: snapshot.Description,
+ IsRestatement: true));
}
private void DispatchTransition(OnAlarmTransitionEvent body)
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.csproj b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.csproj
index 3a87c9f9..43303d6f 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.csproj
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.csproj
@@ -30,6 +30,8 @@
+
+
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs
index cf34709b..79131492 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs
@@ -1953,8 +1953,11 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
if (prior == told) return;
kinds = [AlarmTransitionKind.Unspecified];
}
+ // #656: on first sight (a start or deploy respawn) this instance is RE-STATING upstream's current state,
+ // not reporting a transition it watched — flagged so the host writes no duplicate history row when its
+ // condition already shows exactly this. History only; the host still projects the state.
foreach (var kind in kinds)
- OnAlarmEvent?.Invoke(this, e with { Kind = kind });
+ OnAlarmEvent?.Invoke(this, e with { Kind = kind, IsRestatement = !priorKnown });
}
}
@@ -2066,7 +2069,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
///
/// #619 review — the sequence that moves the host's
/// NativeAlarmProjector (which knows only kinds: Raise ⇒ active+unacked — or, on an already-active condition, a re-statement keeping the ack (#647) — Acknowledge ⇒ acked,
- /// Clear ⇒ inactive keeping acked) from to . A single
+ /// ActiveAcknowledged ⇒ active+acked in one step (#656), Clear ⇒ inactive keeping acked) from to . A single
/// upstream event can need two (an alarm that went active and was acknowledged between two notifications).
/// No state change ⇒ empty (the caller decides whether a message/severity change warrants an Unspecified).
///
@@ -2097,10 +2100,11 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
{
// Active+unacked: a fresh raise, or a re-arm of an active-acked condition.
(true, false) => [prior.Active ? AlarmTransitionKind.Retrigger : AlarmTransitionKind.Raise],
- // Active+acked: raise first when it was inactive (Raise always lands unacked).
+ // Active+acked from inactive: ONE atomic ActiveAcknowledged (Gitea #656) — Raise-then-Acknowledge showed
+ // clients an Active+UNacked intermediate event before the ack.
(true, true) => prior.Active
? [AlarmTransitionKind.Acknowledge]
- : [AlarmTransitionKind.Raise, AlarmTransitionKind.Acknowledge],
+ : [AlarmTransitionKind.ActiveAcknowledged],
// Inactive+acked: Clear keeps the prior ack, so ack after it when that was unacked.
(false, true) => prior.Active
? prior.Acked ? [AlarmTransitionKind.Clear] : [AlarmTransitionKind.Clear, AlarmTransitionKind.Acknowledge]
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.csproj b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.csproj
index 7c2369d4..cfba9159 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.csproj
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.csproj
@@ -29,6 +29,8 @@
+
+
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs
index cc286208..dfe8ca70 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs
@@ -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 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)Array.Empty());
- 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);
}
}
+ ///
+ /// Publish one native-alarm alerts row (DPS + the node-local telemetry hub). Primary-gated by the caller.
+ ///
+ /// The native transition.
+ /// The condition node id.
+ /// The projected snapshot (its severity is the row's).
+ /// The condition's authored metadata.
+ /// The row's transition kind (one of the projector's history kinds).
+ private void PublishNativeAlertRow(
+ AlarmEventArgs args, string nodeId, AlarmConditionSnapshot snapshot,
+ (string EquipmentId, string Name, string AlarmType, bool? HistorizeToAveva, IReadOnlyList 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));
+ }
+
/// Maps a native onto the canonical alarm event-kind
/// vocabulary scripted alarms emit (the EmissionKind names) so a native row renders with the
/// correct chip on the /alerts page and historizes into the same EventKind 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",
};
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/NativeAlarmProjector.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/NativeAlarmProjector.cs
index 4b91fead..1fba649f 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/NativeAlarmProjector.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/NativeAlarmProjector.cs
@@ -12,48 +12,110 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
///
public sealed class NativeAlarmProjector
{
- private readonly Dictionary _prior = new(StringComparer.Ordinal);
+ private readonly Dictionary _prior =
+ new(StringComparer.Ordinal);
/// Project an alarm transition onto the full condition snapshot for .
/// The materialised condition node's id (the projection's state key).
/// The native alarm transition.
/// The full Part 9 condition snapshot to write to the node.
- public AlarmConditionSnapshot Project(string nodeId, AlarmEventArgs e) => Project(nodeId, e, out _);
+ public AlarmConditionSnapshot Project(string nodeId, AlarmEventArgs e) => Project(nodeId, e, out IReadOnlyList _);
///
- /// Project an alarm transition, reporting whether it was a re-statement (Gitea #647 review): a
- /// 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 (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 re-statement — a transition that writes no
+ /// alerts row (see ).
///
/// The materialised condition node's id (the projection's state key).
/// The native alarm transition.
- /// True when re-stated an already-active condition.
+ /// True when produced no history row.
/// The full Part 9 condition snapshot to write to the node.
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 historyKinds);
+ restatement = historyKinds.Count == 0;
+ return snapshot;
+ }
+
+ ///
+ /// Project an alarm transition and report the alerts 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.
+ ///
+ /// Gitea #647 review — a 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
+ /// (or inactive → active) un-acknowledges.
+ /// Gitea #656 — 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.
+ /// Gitea #656 — a 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.
+ ///
+ ///
+ /// The materialised condition node's id (the projection's state key).
+ /// The native alarm transition.
+ ///
+ /// The alerts rows to write, in order — each a /
+ /// ("Activated"), , or
+ /// . Empty when the transition is a re-statement.
+ ///
+ /// The full Part 9 condition snapshot to write to the node.
+ public AlarmConditionSnapshot Project(string nodeId, AlarmEventArgs e, out IReadOnlyList 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
/// The acknowledged condition node id.
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 };
}
/// Clears all tracked per-node state.
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/GalaxyDriverAlarmEventArgsExtensionTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/GalaxyDriverAlarmEventArgsExtensionTests.cs
index 8f92166a..bd85fc93 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/GalaxyDriverAlarmEventArgsExtensionTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/GalaxyDriverAlarmEventArgsExtensionTests.cs
@@ -84,6 +84,45 @@ public sealed class GalaxyDriverAlarmEventArgsExtensionTests
observed[0].AlarmCategory.ShouldBeNull();
}
+ ///
+ /// Gitea #656 — a snapshot ActiveAcknowledged transition reaches as
+ /// (not Unspecified, which would keep the host's prior
+ /// state) with carried through; an unflagged transition stays false.
+ ///
+ [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();
+ 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(
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/GatewayGalaxyAlarmFeedTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/GatewayGalaxyAlarmFeedTests.cs
index 090f7228..ab0c2795 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/GatewayGalaxyAlarmFeedTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/GatewayGalaxyAlarmFeedTests.cs
@@ -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();
+ }
+
+ ///
+ /// 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.
+ ///
+ [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();
+ var got2 = new TaskCompletionSource(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();
}
/// Verifies that the feed drops transitions with unspecified kind and empty messages.
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientAlarmTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientAlarmTests.cs
index f87b90f9..e841f2e9 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientAlarmTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientAlarmTests.cs
@@ -218,7 +218,8 @@ public sealed class OpcUaClientAlarmTests
/// The transition sequence drives a projector from the driver's last-told state to the event's.
[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).
///
[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");
}
+ ///
+ /// 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.
+ ///
+ [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),
+ ]);
+ }
+
/// The exact upstream severity rides OpcUaSeverity (#626) so the host does not re-bucket it.
[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]);
}
/// A server without ConditionRefresh is tolerated; anything else is a real failure.
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/NativeAlarmProjectorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/NativeAlarmProjectorTests.cs
index 66ccd2a9..49f79b03 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/NativeAlarmProjectorTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/NativeAlarmProjectorTests.cs
@@ -233,16 +233,16 @@ public class NativeAlarmProjectorTests
public void A_Raise_on_an_already_active_condition_is_a_restatement_that_keeps_the_ack()
{
var sut = new NativeAlarmProjector();
- sut.Project("n1", Evt(AlarmTransitionKind.Raise), out var first).Active.ShouldBeTrue();
+ sut.Project("n1", Evt(AlarmTransitionKind.Raise), out bool first).Active.ShouldBeTrue();
first.ShouldBeFalse("inactive → active is a real transition");
sut.MarkAcknowledged("n1");
- var restated = sut.Project("n1", Evt(AlarmTransitionKind.Raise), out var restatement);
+ var restated = sut.Project("n1", Evt(AlarmTransitionKind.Raise), out bool restatement);
restatement.ShouldBeTrue();
restated.Active.ShouldBeTrue();
restated.Acknowledged.ShouldBeTrue("a re-statement must not un-acknowledge");
- var retriggered = sut.Project("n1", Evt(AlarmTransitionKind.Retrigger), out var retrigger);
+ var retriggered = sut.Project("n1", Evt(AlarmTransitionKind.Retrigger), out bool retrigger);
retrigger.ShouldBeFalse();
retriggered.Acknowledged.ShouldBeFalse("a Retrigger is a new statement and un-acks");
}
@@ -259,20 +259,209 @@ public class NativeAlarmProjectorTests
sut.Project("n1", Evt(AlarmTransitionKind.Raise, msg: "401 SERVO (type 4 Servo, axis 1)"));
sut.MarkAcknowledged("n1");
- var same = sut.Project("n1", Evt(AlarmTransitionKind.Raise, msg: "401 SERVO (type 4 Servo, axis 1)"), out var r1);
+ var same = sut.Project("n1", Evt(AlarmTransitionKind.Raise, msg: "401 SERVO (type 4 Servo, axis 1)"), out bool r1);
r1.ShouldBeTrue();
same.Acknowledged.ShouldBeTrue();
- var other = sut.Project("n1", Evt(AlarmTransitionKind.Raise, msg: "506 OVER TRAVEL (type 2 Overtravel, axis 2)"), out var r2);
+ var other = sut.Project("n1", Evt(AlarmTransitionKind.Raise, msg: "506 OVER TRAVEL (type 2 Overtravel, axis 2)"), out bool r2);
r2.ShouldBeFalse("a different alarm is not a re-statement");
other.Acknowledged.ShouldBeFalse("the operator has not seen this alarm");
}
+ // ---------------------------------------------------------------------------------------------------------
+ // Gitea #656 — ActiveAcknowledged + IsRestatement. Every "no row" test is paired with a control that MUST write.
+ // ---------------------------------------------------------------------------------------------------------
+
+ /// #656 claim 1 — an acked active alarm on a condition the host has never seen (a cold start) projects
+ /// ACTIVE+acked in one update, with an Activated row then an Acknowledged row. Pre-fix Galaxy sent Acknowledge,
+ /// which projected inactive+acked: an alarm active in AVEVA was invisible to OPC UA clients.
+ [Fact]
+ public void ActiveAcknowledged_on_an_unknown_condition_projects_active_and_acked_with_Activated_then_Acknowledged_rows()
+ {
+ var sut = new NativeAlarmProjector();
+
+ var snap = sut.Project("n1", Evt(AlarmTransitionKind.ActiveAcknowledged), out IReadOnlyList rows);
+
+ snap.Active.ShouldBeTrue();
+ snap.Acknowledged.ShouldBeTrue();
+ rows.ShouldBe([AlarmTransitionKind.Raise, AlarmTransitionKind.Acknowledge]);
+ }
+
+ /// #656 — the wider half of claim 1: a KNOWN but inactive prior (cleared before a gateway outage, then went
+ /// active and acked during it) must also activate.
+ [Fact]
+ public void ActiveAcknowledged_on_an_inactive_known_prior_activates()
+ {
+ var sut = new NativeAlarmProjector();
+ sut.Project("n1", Evt(AlarmTransitionKind.Raise));
+ sut.Project("n1", Evt(AlarmTransitionKind.Clear)).Active.ShouldBeFalse();
+
+ var snap = sut.Project("n1", Evt(AlarmTransitionKind.ActiveAcknowledged), out IReadOnlyList rows);
+
+ snap.Active.ShouldBeTrue();
+ snap.Acknowledged.ShouldBeTrue();
+ rows.ShouldBe([AlarmTransitionKind.Raise, AlarmTransitionKind.Acknowledge]);
+ }
+
+ [Fact]
+ public void ActiveAcknowledged_on_an_active_unacked_condition_writes_only_an_Acknowledged_row()
+ {
+ var sut = new NativeAlarmProjector();
+ sut.Project("n1", Evt(AlarmTransitionKind.Raise));
+
+ var snap = sut.Project("n1", Evt(AlarmTransitionKind.ActiveAcknowledged), out IReadOnlyList rows);
+
+ snap.Active.ShouldBeTrue();
+ snap.Acknowledged.ShouldBeTrue();
+ rows.ShouldBe([AlarmTransitionKind.Acknowledge]);
+ }
+
+ /// #656 related item 2 — a Galaxy reconnect replays an ActiveAcked entry the host already shows; that
+ /// is no row. Control: the same entry with a different message is a different alarm and writes both rows.
+ [Fact]
+ public void ActiveAcknowledged_on_an_active_acked_condition_with_the_same_message_writes_no_row()
+ {
+ var sut = new NativeAlarmProjector();
+ sut.Project("n1", Evt(AlarmTransitionKind.ActiveAcknowledged, msg: "hi"));
+
+ var same = sut.Project("n1", Evt(AlarmTransitionKind.ActiveAcknowledged, msg: "hi"), out IReadOnlyList sameRows);
+ sameRows.ShouldBeEmpty();
+ same.Active.ShouldBeTrue();
+ same.Acknowledged.ShouldBeTrue();
+
+ // Control: a different message is a different alarm.
+ sut.Project("n1", Evt(AlarmTransitionKind.ActiveAcknowledged, msg: "hihi"), out IReadOnlyList otherRows);
+ otherRows.ShouldBe([AlarmTransitionKind.Raise, AlarmTransitionKind.Acknowledge]);
+ }
+
+ /// #656 claim 2 — a re-stated Retrigger (Galaxy snapshot replay, OpcUaClient first sight) on a condition
+ /// already Active+unacked with the same message and severity writes no row; the node does not change.
+ [Fact]
+ public void A_restated_Retrigger_on_an_active_unacked_condition_with_same_message_and_severity_writes_no_row()
+ {
+ var sut = new NativeAlarmProjector();
+ sut.Project("n1", Evt(AlarmTransitionKind.Retrigger, restated: true), out IReadOnlyList first);
+ first.ShouldBe([AlarmTransitionKind.Retrigger], "the first statement on an unknown condition is a real activation");
+
+ var snap = sut.Project("n1", Evt(AlarmTransitionKind.Retrigger, restated: true), out IReadOnlyList rows);
+
+ rows.ShouldBeEmpty();
+ snap.Active.ShouldBeTrue();
+ snap.Acknowledged.ShouldBeFalse();
+ }
+
+ /// Control (a): an UNFLAGGED Retrigger — a live Galaxy re-occurrence, a FOCAS key swap — with the same
+ /// everything is a genuine re-occurrence and keeps its row. FOCAS/MTConnect/AbCip behaviour is unchanged.
+ [Fact]
+ public void An_unflagged_Retrigger_on_an_active_unacked_condition_still_writes_an_Activated_row()
+ {
+ var sut = new NativeAlarmProjector();
+ sut.Project("n1", Evt(AlarmTransitionKind.Retrigger));
+
+ sut.Project("n1", Evt(AlarmTransitionKind.Retrigger), out IReadOnlyList rows);
+
+ rows.ShouldBe([AlarmTransitionKind.Retrigger]);
+ }
+
+ /// Control (b): a restated Retrigger whose SEVERITY changed (MTConnect Warning→Fault with a detail text
+ /// keeps the message) is a new statement.
+ [Fact]
+ public void A_restated_Retrigger_with_a_different_severity_writes_an_Activated_row()
+ {
+ var sut = new NativeAlarmProjector();
+ sut.Project("n1", Evt(AlarmTransitionKind.Retrigger, AlarmSeverity.Medium, restated: true));
+
+ sut.Project("n1", Evt(AlarmTransitionKind.Retrigger, AlarmSeverity.High, restated: true), out IReadOnlyList rows);
+
+ rows.ShouldBe([AlarmTransitionKind.Retrigger]);
+ }
+
+ /// Control (b'): the exact-severity path () is compared too.
+ [Fact]
+ public void A_restated_Retrigger_with_a_different_exact_severity_writes_an_Activated_row()
+ {
+ var sut = new NativeAlarmProjector();
+ sut.Project("n1", Evt(AlarmTransitionKind.Retrigger, restated: true) with { OpcUaSeverity = 500 });
+
+ sut.Project("n1", Evt(AlarmTransitionKind.Retrigger, restated: true) with { OpcUaSeverity = 800 },
+ out IReadOnlyList rows);
+
+ rows.ShouldBe([AlarmTransitionKind.Retrigger]);
+ }
+
+ /// Control (c): a restated Retrigger with a different message is a different alarm.
+ [Fact]
+ public void A_restated_Retrigger_with_a_different_message_writes_an_Activated_row()
+ {
+ var sut = new NativeAlarmProjector();
+ sut.Project("n1", Evt(AlarmTransitionKind.Retrigger, msg: "a", restated: true));
+
+ sut.Project("n1", Evt(AlarmTransitionKind.Retrigger, msg: "b", restated: true), out IReadOnlyList rows);
+
+ rows.ShouldBe([AlarmTransitionKind.Retrigger]);
+ }
+
+ /// Control (d): a restated Retrigger on an Active+ACKED condition un-acks (upstream's ack state is
+ /// authoritative), so the node changes and the row is written.
+ [Fact]
+ public void A_restated_Retrigger_on_an_active_acked_condition_unacks_and_writes_an_Activated_row()
+ {
+ var sut = new NativeAlarmProjector();
+ sut.Project("n1", Evt(AlarmTransitionKind.Retrigger));
+ sut.MarkAcknowledged("n1");
+
+ var snap = sut.Project("n1", Evt(AlarmTransitionKind.Retrigger, restated: true), out IReadOnlyList rows);
+
+ snap.Acknowledged.ShouldBeFalse();
+ rows.ShouldBe([AlarmTransitionKind.Retrigger]);
+ }
+
+ /// #656 related item 3 — a restated Clear (Galaxy Inactive snapshot entry) on a condition that is already
+ /// inactive writes no row; the unflagged control and a restated Clear of an ACTIVE condition both write one.
+ [Fact]
+ public void A_restated_Clear_on_an_inactive_condition_writes_no_row()
+ {
+ var sut = new NativeAlarmProjector();
+
+ sut.Project("unknown", Evt(AlarmTransitionKind.Clear, restated: true), out IReadOnlyList unknownRows);
+ unknownRows.ShouldBeEmpty();
+
+ sut.Project("n1", Evt(AlarmTransitionKind.Raise));
+ sut.Project("n1", Evt(AlarmTransitionKind.Clear));
+ sut.Project("n1", Evt(AlarmTransitionKind.Clear, restated: true), out IReadOnlyList rows);
+ rows.ShouldBeEmpty();
+
+ // Controls: unflagged on inactive keeps its row (unchanged behaviour); restated on ACTIVE is a real clear.
+ sut.Project("n1", Evt(AlarmTransitionKind.Clear), out IReadOnlyList unflagged);
+ unflagged.ShouldBe([AlarmTransitionKind.Clear]);
+ sut.Project("n2", Evt(AlarmTransitionKind.Raise));
+ sut.Project("n2", Evt(AlarmTransitionKind.Clear, restated: true), out IReadOnlyList active);
+ active.ShouldBe([AlarmTransitionKind.Clear]);
+ }
+
+ /// #656 — the existing per-kind rows for unflagged sources are exactly one row of the event's own kind
+ /// (what the host wrote before, byte-identical for FOCAS/MTConnect/AbCip).
+ [Theory]
+ [InlineData(AlarmTransitionKind.Raise)]
+ [InlineData(AlarmTransitionKind.Retrigger)]
+ [InlineData(AlarmTransitionKind.Acknowledge)]
+ [InlineData(AlarmTransitionKind.Clear)]
+ [InlineData(AlarmTransitionKind.Unspecified)]
+ public void An_unflagged_transition_writes_one_row_of_its_own_kind(AlarmTransitionKind kind)
+ {
+ var sut = new NativeAlarmProjector();
+
+ sut.Project("n1", Evt(kind), out IReadOnlyList rows);
+
+ rows.ShouldBe([kind]);
+ }
+
private static AlarmEventArgs Evt(
AlarmTransitionKind kind,
AlarmSeverity sev = AlarmSeverity.High,
- string msg = "m")
- => new(new H(), "Tank1.Hi", "c1", "LimitAlarm.Hi", msg, sev, DateTime.UnixEpoch, Kind: kind);
+ string msg = "m",
+ bool restated = false)
+ => new(new H(), "Tank1.Hi", "c1", "LimitAlarm.Hi", msg, sev, DateTime.UnixEpoch, Kind: kind, IsRestatement: restated);
private sealed class H : IAlarmSubscriptionHandle
{
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/NativeAlarmRestatementEndToEndTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/NativeAlarmRestatementEndToEndTests.cs
new file mode 100644
index 00000000..a3cfb899
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/NativeAlarmRestatementEndToEndTests.cs
@@ -0,0 +1,456 @@
+using System.Runtime.CompilerServices;
+using System.Text.Json;
+using System.Threading.Channels;
+using Akka.Actor;
+using Akka.Cluster.Tools.PublishSubscribe;
+using Akka.TestKit;
+using Google.Protobuf.WellKnownTypes;
+using Microsoft.EntityFrameworkCore;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Commons.Engines;
+using ZB.MOM.WW.OtOpcUa.Commons.Messages.Alerts;
+using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy;
+using ZB.MOM.WW.OtOpcUa.Commons.Types;
+using ZB.MOM.WW.OtOpcUa.Configuration;
+using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
+using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Driver.Galaxy;
+using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
+using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
+using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
+using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
+using ZB.MOM.WW.OtOpcUa.Runtime.OpcUa;
+using ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms;
+using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
+using LocalizedText = Opc.Ua.LocalizedText;
+using Proto = ZB.MOM.WW.MxGateway.Contracts.Proto;
+using UaNodeId = Opc.Ua.NodeId;
+using Variant = Opc.Ua.Variant;
+using VariantCollection = Opc.Ua.VariantCollection;
+
+namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
+
+///
+/// Gitea #656 — native-alarm RE-STATEMENTS end to end: the REAL Galaxy driver (over the REAL
+/// reading a scripted gateway stream) and the REAL OpcUaClient driver raise
+/// into the REAL , and the test reads what its ForwardNativeAlarm +
+/// NativeAlarmProjector turned them into: the Part 9 condition updates and the /alerts rows.
+///
+///
+/// Every "no row" assertion is followed by a transition that MUST write one — an absence check on its own passes
+/// against a host that never publishes anything.
+///
+public sealed class NativeAlarmRestatementEndToEndTests : RuntimeActorTestBase
+{
+ private static readonly NodeId TestNode = NodeId.Parse("alarm-restatement-e2e");
+ private static readonly RevisionHash Rev = RevisionHash.Parse(new string('a', 64));
+ private static readonly TimeSpan Wait = TimeSpan.FromSeconds(5);
+ private static readonly TimeSpan Quiet = TimeSpan.FromMilliseconds(600);
+
+ // ---------------------------------------------------------------------------------------------------------
+ // Galaxy
+ // ---------------------------------------------------------------------------------------------------------
+
+ private const string GalaxyDriverId = "drv-galaxy";
+ private const string GalaxyAlarmRef = "Tank01.Level.HiHi";
+ private const string GalaxyRawPath = "Plant/Galaxy/gw/tank01_hihi";
+
+ ///
+ /// #656 claim 1 — a cold host receives an ActiveAcked snapshot entry (an alarm active AND acknowledged in
+ /// AVEVA before this process started). Pre-fix the feed sent Acknowledge, which the projector applied to its
+ /// inactive default: the condition was INACTIVE+acked — invisible to clients — with an "Acknowledged" row for
+ /// an alarm the server showed as inactive. Now: ONE Active+acked update, "Activated" (at the original raise
+ /// time, no operator comment) then "Acknowledged" (carrying the comment).
+ ///
+ [Fact]
+ public async Task Galaxy_cold_host_ActiveAcked_snapshot_projects_active_and_acked_with_both_rows()
+ {
+ var rig = await GalaxyRig.StartAsync(this);
+ try
+ {
+ var raisedAt = new DateTime(2026, 10, 8, 9, 0, 0, DateTimeKind.Utc);
+ var ackedAt = raisedAt.AddMinutes(2);
+ rig.Send(Snapshot(Proto.AlarmConditionState.ActiveAcked, raisedAt, ackedAt, "alice", "investigating"));
+
+ var update = NextAlarmUpdate(rig.Publish);
+ update.AlarmNodeId.ShouldBe(GalaxyRawPath);
+ update.State.Active.ShouldBeTrue("an acked active alarm must project ACTIVE, not keep the cold default");
+ update.State.Acknowledged.ShouldBeTrue();
+
+ var activated = rig.Alerts.ExpectMsg(Wait);
+ activated.TransitionKind.ShouldBe("Activated");
+ activated.Comment.ShouldBeNull("the operator comment belongs to the acknowledgement");
+ activated.TimestampUtc.ShouldBe(raisedAt);
+ var acked = rig.Alerts.ExpectMsg(Wait);
+ acked.TransitionKind.ShouldBe("Acknowledged");
+ acked.Comment.ShouldBe("investigating");
+ rig.Alerts.ExpectNoMsg(Quiet);
+
+ // Positive control: a live Clear MUST write its row — and the NEXT condition update is the clear, so no
+ // unacked intermediate update sat between the snapshot's projection and it.
+ rig.Send(Live(Proto.AlarmTransitionKind.Clear, ackedAt.AddMinutes(1)));
+ var cleared = NextAlarmUpdate(rig.Publish);
+ cleared.State.Active.ShouldBeFalse();
+ cleared.State.Acknowledged.ShouldBeTrue();
+ rig.Alerts.ExpectMsg(Wait).TransitionKind.ShouldBe("Cleared");
+ }
+ finally
+ {
+ await rig.DisposeAsync();
+ }
+ }
+
+ ///
+ /// #656 claim 2 — every gateway reconnect replays the active-alarm snapshot. Pre-fix an unacked active entry
+ /// (a Retrigger) wrote a second "Activated" row on EVERY reconnect, for a condition whose node did not change
+ /// (so clients saw no event: history and the node disagreed). Likewise an Inactive entry wrote a duplicate
+ /// "Cleared". Now a replay re-projects the condition but writes no row; a live transition still does.
+ ///
+ [Fact]
+ public async Task Galaxy_reconnect_snapshot_replay_writes_no_duplicate_rows()
+ {
+ var rig = await GalaxyRig.StartAsync(this);
+ try
+ {
+ var raisedAt = new DateTime(2026, 10, 8, 9, 0, 0, DateTimeKind.Utc);
+ rig.Send(Snapshot(Proto.AlarmConditionState.Active, raisedAt, raisedAt));
+ NextAlarmUpdate(rig.Publish).State.Active.ShouldBeTrue();
+ rig.Alerts.ExpectMsg(Wait).TransitionKind.ShouldBe("Activated",
+ "the first statement on a cold host is a real activation");
+
+ // The gateway stream faults; the feed reopens and the gateway replays the same snapshot.
+ rig.Fault();
+ rig.Send(Snapshot(Proto.AlarmConditionState.Active, raisedAt, raisedAt));
+ var replayed = NextAlarmUpdate(rig.Publish);
+ replayed.State.Active.ShouldBeTrue();
+ replayed.State.Acknowledged.ShouldBeFalse();
+ rig.Alerts.ExpectNoMsg(Quiet);
+
+ // Positive control: a live Clear MUST write its row.
+ rig.Send(Live(Proto.AlarmTransitionKind.Clear, raisedAt.AddMinutes(1)));
+ NextAlarmUpdate(rig.Publish).State.Active.ShouldBeFalse();
+ rig.Alerts.ExpectMsg(Wait).TransitionKind.ShouldBe("Cleared");
+
+ // Reconnect again; the snapshot now carries the alarm as Inactive — a re-stated Clear of an inactive
+ // condition, no row.
+ rig.Fault();
+ rig.Send(Snapshot(Proto.AlarmConditionState.Inactive, raisedAt, raisedAt.AddMinutes(1)));
+ NextAlarmUpdate(rig.Publish).State.Active.ShouldBeFalse();
+ rig.Alerts.ExpectNoMsg(Quiet);
+
+ // Positive control: a LIVE Retrigger (a genuine MXAccess re-occurrence) is never a re-statement.
+ rig.Send(Live(Proto.AlarmTransitionKind.Retrigger, raisedAt.AddMinutes(3)));
+ NextAlarmUpdate(rig.Publish).State.Active.ShouldBeTrue();
+ rig.Alerts.ExpectMsg(Wait).TransitionKind.ShouldBe("Activated");
+ }
+ finally
+ {
+ await rig.DisposeAsync();
+ }
+ }
+
+ private static Proto.AlarmFeedMessage Snapshot(
+ Proto.AlarmConditionState state, DateTime raisedAt, DateTime lastTransition, string user = "", string comment = "")
+ => new()
+ {
+ ActiveAlarm = new Proto.ActiveAlarmSnapshot
+ {
+ AlarmFullReference = GalaxyAlarmRef,
+ SourceObjectReference = "Tank01",
+ AlarmTypeName = "AnalogLimitAlarm.HiHi",
+ Severity = 750,
+ CurrentState = state,
+ Category = "Process",
+ Description = "Tank 01 high-high level",
+ OriginalRaiseTimestamp = Timestamp.FromDateTime(raisedAt),
+ LastTransitionTimestamp = Timestamp.FromDateTime(lastTransition),
+ OperatorUser = user,
+ OperatorComment = comment,
+ },
+ };
+
+ private static Proto.AlarmFeedMessage Live(Proto.AlarmTransitionKind kind, DateTime at)
+ => new()
+ {
+ Transition = new Proto.OnAlarmTransitionEvent
+ {
+ AlarmFullReference = GalaxyAlarmRef,
+ SourceObjectReference = "Tank01",
+ AlarmTypeName = "AnalogLimitAlarm.HiHi",
+ TransitionKind = kind,
+ Severity = 750,
+ Category = "Process",
+ Description = "Tank 01 high-high level",
+ TransitionTimestamp = Timestamp.FromDateTime(at),
+ },
+ };
+
+ /// The real Galaxy driver over the real alarm feed, reading a scripted gateway stream.
+ private sealed class GalaxyRig : IAsyncDisposable
+ {
+ private readonly Channel