diff --git a/CLAUDE.md b/CLAUDE.md
index 0d1e228f..b6705700 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1151,7 +1151,11 @@ host logger factory (wired in `DriverFactoryBootstrap`; the FOCAS and TwinCAT fa
to duplicate them); a flagged `Unspecified` on an already-active condition (AbCip ALMA governing-limit drop-out)
moves the node's text/severity, keeps active/acked and writes no row. 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
+ invisible) nor Raise+Acknowledge (an unacked intermediate). Its mirror **`InactiveUnacknowledged`** (#657, OpcUaClient)
+ replaces Raise/Retrigger+Clear (an Active flicker; rows only for what changed). OpcUaClient's first sight of an inactive
+ condition is a flagged InactiveUnacknowledged, or flagged Clear+Acknowledge for inactive+acked (a flagged Acknowledge on an
+ acked condition writes no row) — never `Unspecified`, which wrote "Activated" for an inactive alarm and left a
+ retained-Active condition Active. `NativeAlarmProjector` returns the rows to write; unflagged
sources (FOCAS/MTConnect, 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;
diff --git a/docs/AlarmTracking.md b/docs/AlarmTracking.md
index 2fdff8c6..00c5e37d 100644
--- a/docs/AlarmTracking.md
+++ b/docs/AlarmTracking.md
@@ -71,10 +71,10 @@ same condition is wired as an **event notifier of every referencing equipment fo
reconnect snapshot entries, OpcUaClient's first event per condition after a start or deploy respawn — flags
`AlarmEventArgs.IsRestatement` (optional, default `false`; FOCAS, MTConnect and live Galaxy transitions never
set it; AbCip ALMA sets it for the drop-out update below). 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; so does a flagged `Unspecified` update on an already-active
- condition, which moves the node's message/severity (one condition event, active/acked kept) — AbCip ALMA's
- governing limit dropping out (HH→H), and OpcUaClient's first sight of an inactive condition after a respawn. Before this every gateway reconnect wrote a duplicate
+ on a condition already Active+unacked with the **same message AND severity**, a flagged `Clear` on an
+ already-inactive condition, or a flagged `Acknowledge` on an already-acknowledged one (#657), writes no `/alerts` row;
+ so does a flagged `Unspecified` update on an already-active condition, which moves the node's message/severity (one
+ condition event, active/acked kept) — AbCip ALMA's governing limit dropping out (HH→H). 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
@@ -88,6 +88,17 @@ same condition is wired as an **event notifier of every referencing equipment fo
`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.
+- **`AlarmTransitionKind.InactiveUnacknowledged` (#657)** — its mirror: "inactive AND unacknowledged" (went active and
+ cleared unacked) in ONE projection, never `Raise`/`Retrigger` + `Clear` (which showed clients the condition Active
+ again for an instant). Rows only for what changed, flag or not: "Activated" (raise time when known) + "Cleared" when
+ the condition was acknowledged (an un-ack needs a new occurrence), "Cleared" alone when it was Active+unacked, none
+ when it already showed inactive+unacked. OpcUaClient emits it for every move into inactive+unacked except from
+ Active+unacked (a plain `Clear`). Its **first sight** of an inactive condition (after a start or deploy respawn, when
+ the host may still hold ANY state) is `InactiveUnacknowledged`, or `Clear` + `Acknowledge` for inactive+acked — both
+ flagged — so a host already showing that state writes nothing, and a host that retained the condition Active gets a
+ real "Cleared". Pre-#657 a respawn wrote a duplicate "Activated" + "Cleared" pair per inactive+unacked condition, and
+ an inactive+acked first sight went out as `Unspecified` (an "Activated" row for an inactive alarm; a retained-Active
+ condition was never cleared). E2E: `Runtime.Tests/NativeAlarmRestatementEndToEndTests`.
### Native condition state survives a full rebuild (Gitea #641)
diff --git a/docs/drivers/OpcUaClient.md b/docs/drivers/OpcUaClient.md
index ab977241..376a265e 100644
--- a/docs/drivers/OpcUaClient.md
+++ b/docs/drivers/OpcUaClient.md
@@ -101,9 +101,12 @@ initialise (the AdminUI form drops them on the next save). Pinned by
> `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). 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.
+> used to be `Raise` + `Acknowledge`, a client-visible unacked intermediate); a move into inactive+unacked is ONE
+> `InactiveUnacknowledged` (#657; it used to be `Raise`/`Retrigger` + `Clear`, an Active flicker). The first event per
+> condition after a start or deploy respawn is flagged `IsRestatement` and states the upstream condition so it lands
+> from whatever the host retained: an inactive+acked one goes out as `Clear` + `Acknowledge` (#657; it used to be an
+> `Unspecified` the host wrote as "Activated", and a retained-Active host condition stayed Active). So the host writes no
+> duplicate 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 f63011dc..71e47c72 100644
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IAlarmSource.cs
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IAlarmSource.cs
@@ -107,11 +107,13 @@ public sealed record AlarmAcknowledgeRequest(
/// 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) — or
+/// an Active+unacked condition with the same message and severity; a Clear on an already-inactive condition; an
+/// Acknowledge on an already-acknowledged condition) — or
/// when a flagged update moves the message/severity of an
/// already-active condition (node updated, one condition event, active/acked kept, no row). AbCip ALMA sets the
-/// flag for the last shape (the governing limit changed, no new occurrence); OpcUaClient's first sight of an
-/// inactive condition after a respawn can reach it too. false (the default, every source that does not
+/// flag for the last shape (the governing limit changed, no new occurrence). OpcUaClient (Gitea #657) re-states a
+/// first-seen inactive+acked condition as a flagged Clear then a flagged Acknowledge, so a host already showing
+/// that writes nothing and a host retaining it Active gets a real "Cleared". false (the default, every source that does not
/// opt in) keeps every transition's row.
///
public sealed record AlarmEventArgs(
@@ -147,6 +149,11 @@ public enum AlarmSeverity { Low, Medium, High, Critical }
/// 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.
+/// (Gitea #657) is its mirror: "the condition is inactive AND unacknowledged"
+/// (it went active and cleared unacknowledged), applied as ONE projection — never Raise-then-Clear, which showed
+/// clients the condition Active again for an instant. The host writes rows only for what actually changed:
+/// "Activated" + "Cleared" when the condition was acknowledged (a new occurrence must have happened), "Cleared"
+/// alone when it was Active+unacked, none when it already showed inactive+unacked.
/// Members are append-only: their numeric values must not move.
///
-public enum AlarmTransitionKind { Unspecified = 0, Raise, Acknowledge, Clear, Retrigger, ActiveAcknowledged }
+public enum AlarmTransitionKind { Unspecified = 0, Raise, Acknowledge, Clear, Retrigger, ActiveAcknowledged, InactiveUnacknowledged }
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 79131492..4197e7eb 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs
@@ -2069,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,
- /// ActiveAcknowledged ⇒ active+acked in one step (#656), Clear ⇒ inactive keeping acked) from to . A single
+ /// ActiveAcknowledged ⇒ active+acked in one step (#656), InactiveUnacknowledged ⇒ inactive+unacked in one step (#657), 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).
///
@@ -2086,6 +2086,16 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
internal static IReadOnlyList AlarmTransitions(
(bool Active, bool Acked) prior, (bool Active, bool Acked) target, bool priorKnown = true)
{
+ // Gitea #657: on first sight the host may hold ANY state (it retains a still-deployed condition across a
+ // respawn), so an inactive target is stated in kinds that land on it from every host state: inactive+unacked
+ // as ONE InactiveUnacknowledged; inactive+acked as Clear then Acknowledge (flagged by the caller, so a host
+ // already showing it writes no row, and a host retaining it Active gets a real "Cleared"). Pre-fix an
+ // inactive+acked first sight equalled the default prior and went out as Unspecified — an "Activated" row for
+ // an inactive alarm, and a retained-Active host condition was never cleared.
+ if (!priorKnown && !target.Active)
+ return target.Acked
+ ? [AlarmTransitionKind.Clear, AlarmTransitionKind.Acknowledge]
+ : [AlarmTransitionKind.InactiveUnacknowledged];
if (prior == target) return [];
var kinds = Transitions(prior, target);
return priorKnown || target.Acked
@@ -2109,11 +2119,12 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
(false, true) => prior.Active
? prior.Acked ? [AlarmTransitionKind.Clear] : [AlarmTransitionKind.Clear, AlarmTransitionKind.Acknowledge]
: [AlarmTransitionKind.Acknowledge],
- // Inactive+UNacked (went active and cleared unacknowledged — e.g. first seen by a ConditionRefresh):
- // the only kinds that un-ack are Raise/Retrigger, so pass through active, then Clear.
- (false, false) => prior.Active
- ? prior.Acked ? [AlarmTransitionKind.Retrigger, AlarmTransitionKind.Clear] : [AlarmTransitionKind.Clear]
- : [AlarmTransitionKind.Raise, AlarmTransitionKind.Clear],
+ // Inactive+UNacked (went active and cleared unacknowledged — e.g. first seen by a ConditionRefresh): ONE
+ // atomic InactiveUnacknowledged (Gitea #657) — Raise/Retrigger-then-Clear showed clients the condition
+ // Active again for an instant. From Active+unacked a plain Clear already lands there.
+ (false, false) => prior is (true, false)
+ ? [AlarmTransitionKind.Clear]
+ : [AlarmTransitionKind.InactiveUnacknowledged],
};
}
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 dfe8ca70..2c5e6260 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs
@@ -1885,7 +1885,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
// 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).
+ // "Activated" only if it was not already active, "Acknowledged" only if it was not already acked) or an
+ // InactiveUnacknowledged (#657: "Activated" + "Cleared" only if it was acked, "Cleared" if it was active).
if (!serviceAlertsAsPrimary) continue;
var meta = _alarmMetaByNodeId.TryGetValue(nodeId, out var m)
@@ -1911,8 +1912,10 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
{
// #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;
+ // #657: an InactiveUnacknowledged's "Activated" row is likewise the occurrence, at its raise time when known.
+ var expandedActivation = (args.Kind == AlarmTransitionKind.ActiveAcknowledged
+ && historyKind != AlarmTransitionKind.Acknowledge)
+ || (args.Kind == AlarmTransitionKind.InactiveUnacknowledged && historyKind == AlarmTransitionKind.Raise);
var comment = expandedActivation ? null : args.OperatorComment;
var timestamp = expandedActivation ? args.OriginalRaiseTimestampUtc ?? args.SourceTimestampUtc : args.SourceTimestampUtc;
var alertEvent = new AlarmTransitionEvent(
@@ -1957,6 +1960,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
AlarmTransitionKind.Acknowledge => "Acknowledged",
// #656: never reached in practice — the projector expands ActiveAcknowledged into Raise + Acknowledge rows.
AlarmTransitionKind.ActiveAcknowledged => "Acknowledged",
+ // #657: likewise never reached — expanded into Raise + Clear (or Clear alone) rows.
+ AlarmTransitionKind.InactiveUnacknowledged => "Cleared",
_ => "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 c21bc591..3a13c9a6 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/NativeAlarmProjector.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/NativeAlarmProjector.cs
@@ -49,10 +49,13 @@ public sealed class NativeAlarmProjector
/// 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 #657 — projects inactive+unacked in ONE
+ /// update and writes "Activated" + "Cleared" only when the condition was acknowledged (an un-ack needs a new
+ /// occurrence), "Cleared" alone when it was Active+unacked, nothing when it already showed exactly that.
/// 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; and a flagged Unspecified update of an already-active
+ /// the condition shows: a Retrigger on an Active+unacked condition with the same message AND severity,
+ /// a Clear on an already-inactive condition, or an Acknowledge on an already-acknowledged one (#657); and a flagged Unspecified update of an already-active
/// condition moves its message/severity (node + one condition event) with active/acked kept and no row
/// (AbCip ALMA's governing-limit change). Unflagged sources (FOCAS, MTConnect, live Galaxy transitions) are
/// unaffected — a live Retrigger is a genuine re-occurrence and keeps its row.
@@ -99,6 +102,21 @@ public sealed class NativeAlarmProjector
? [AlarmTransitionKind.Raise, AlarmTransitionKind.Acknowledge]
: !prev.Acked ? [AlarmTransitionKind.Acknowledge] : [];
break;
+ // #657: the mirror of ActiveAcknowledged — inactive AND unacked in ONE update (never Raise-then-Clear, which
+ // showed clients the condition Active again for an instant). Rows only for what changed: an acknowledged
+ // condition can only have become unacked through a new occurrence ("Activated" + "Cleared"); an
+ // Active+unacked one just cleared; one already inactive+unacked changed nothing.
+ case AlarmTransitionKind.InactiveUnacknowledged:
+ (active, acked) = (false, false);
+ historyKinds = prev.Acked
+ ? [AlarmTransitionKind.Raise, AlarmTransitionKind.Clear]
+ : prev.Active ? [AlarmTransitionKind.Clear] : [];
+ break;
+ // #657: a source re-stating an acknowledgement the condition already shows (OpcUaClient's first sight of
+ // an inactive+acked condition is a flagged Clear + Acknowledge) changes nothing.
+ case AlarmTransitionKind.Acknowledge when e.IsRestatement && prev.Acked:
+ (active, acked, historyKinds) = (prev.Active, true, []);
+ break;
case AlarmTransitionKind.Acknowledge:
(active, acked, historyKinds) = (prev.Active, true, [AlarmTransitionKind.Acknowledge]);
break;
@@ -112,9 +130,8 @@ public sealed class NativeAlarmProjector
break;
// A message/severity-only update of an ALREADY-ACTIVE condition that the source flags as a re-statement
// (AbCip ALMA: the governing limit changed but no new occurrence started, e.g. HH dropped back to H).
- // OpcUaClient can reach it too: first sight after a respawn of an upstream-inactive condition is told
- // Unspecified+flagged; with the host still Active the node stays Active exactly as before (a pre-existing
- // gap) and the false "Activated" row it used to write is now gone.
+ // OpcUaClient no longer reaches it (#657: its first sight of an inactive condition is a flagged Clear +
+ // Acknowledge, or an InactiveUnacknowledged — never a flagged Unspecified).
// The node's text/severity move; active/acked are kept; NO row (an "Activated" row would be false).
case AlarmTransitionKind.Unspecified when e.IsRestatement && prev.Active:
(active, acked, historyKinds) = (true, prev.Acked, []);
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 e841f2e9..07c65f1b 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
@@ -227,6 +227,10 @@ public sealed class OpcUaClientAlarmTests
[InlineData(true, false, false, true, new[] { AlarmTransitionKind.Clear, AlarmTransitionKind.Acknowledge })]
[InlineData(true, true, true, false, new[] { AlarmTransitionKind.Retrigger })]
[InlineData(true, false, true, false, new AlarmTransitionKind[0])]
+ // Gitea #657: inactive+unacked is ONE atomic InactiveUnacknowledged (never an Active flicker), except from
+ // Active+unacked, where a plain Clear already lands there.
+ [InlineData(false, true, false, false, new[] { AlarmTransitionKind.InactiveUnacknowledged })]
+ [InlineData(true, true, false, false, new[] { AlarmTransitionKind.InactiveUnacknowledged })]
public void AlarmTransitions_move_prior_to_target(
bool priorActive, bool priorAcked, bool active, bool acked, AlarmTransitionKind[] expected)
{
@@ -242,13 +246,34 @@ public sealed class OpcUaClientAlarmTests
///
[Theory]
[InlineData(true, false, new[] { AlarmTransitionKind.Retrigger })]
- [InlineData(false, false, new[] { AlarmTransitionKind.Retrigger, AlarmTransitionKind.Clear })]
+ [InlineData(false, false, new[] { AlarmTransitionKind.InactiveUnacknowledged })]
[InlineData(true, true, new[] { AlarmTransitionKind.ActiveAcknowledged })]
+ [InlineData(false, true, new[] { AlarmTransitionKind.Clear, AlarmTransitionKind.Acknowledge })]
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);
}
+ ///
+ /// Gitea #657 — on first sight the HOST may hold any state (it retains a still-deployed condition across a
+ /// respawn), so the first-sight kinds must land on the upstream's state from EVERY host state. Pre-fix an
+ /// inactive+acked target emitted nothing state-bearing, leaving a retained-Active host condition Active.
+ ///
+ [Fact]
+ public void First_sight_kinds_reach_the_target_from_every_host_state()
+ {
+ bool[] b = [false, true];
+ foreach (var ha in b) foreach (var hk in b) foreach (var ta in b) foreach (var tk in b)
+ {
+ var kinds = OpcUaClientDriver.AlarmTransitions((false, true), (ta, tk), priorKnown: false);
+ var s = (ha, hk);
+ foreach (var k in kinds) s = Simulate(s, k);
+ s.ShouldBe((ta, tk), $"host ({ha},{hk}) -> upstream ({ta},{tk}) via [{string.Join(",", kinds)}]");
+ kinds.ShouldNotContain(AlarmTransitionKind.Unspecified);
+ kinds.ShouldNotContain(AlarmTransitionKind.Raise, "a first sight must not keep a stale host ack");
+ }
+ }
+
// ---- #619 re-review: the transition table must reach EVERY target from EVERY prior. ----
/// The host projector's semantics, restated: the only thing the driver's kinds act upon.
@@ -256,6 +281,7 @@ public sealed class OpcUaClientAlarmTests
{
AlarmTransitionKind.Raise or AlarmTransitionKind.Retrigger => (true, false),
AlarmTransitionKind.ActiveAcknowledged => (true, true),
+ AlarmTransitionKind.InactiveUnacknowledged => (false, false),
AlarmTransitionKind.Acknowledge => (s.Active, true),
AlarmTransitionKind.Clear => (false, s.Acked),
_ => s,
@@ -414,6 +440,32 @@ public sealed class OpcUaClientAlarmTests
]);
}
+ ///
+ /// Gitea #657 — first sight of an INACTIVE upstream condition: inactive+unacked is ONE flagged
+ /// InactiveUnacknowledged (pre-fix a Retrigger then a Clear — clients saw it Active again); inactive+acked is
+ /// a flagged Clear then a flagged Acknowledge (pre-fix an Unspecified the host wrote as "Activated").
+ ///
+ [Fact]
+ public void First_sight_of_an_inactive_condition_is_restated_atomically()
+ {
+ using var unacked = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-alarm-657a");
+ var seenA = Record(unacked);
+ unacked.RouteAlarmEvent(ConditionFields(false, false), TripIndex, new FakeAlarmHandle());
+ seenA.Select(e => (e.Kind, e.IsRestatement)).ShouldBe([(AlarmTransitionKind.InactiveUnacknowledged, true)]);
+
+ using var acked = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-alarm-657b");
+ var seenB = Record(acked);
+ acked.RouteAlarmEvent(ConditionFields(false, true), TripIndex, new FakeAlarmHandle());
+ seenB.Select(e => (e.Kind, e.IsRestatement)).ShouldBe([
+ (AlarmTransitionKind.Clear, true),
+ (AlarmTransitionKind.Acknowledge, true),
+ ]);
+
+ // Positive control: once known, the identical refresh copy emits nothing.
+ acked.RouteAlarmEvent(ConditionFields(false, true), TripIndex, new FakeAlarmHandle());
+ seenB.Count.ShouldBe(2);
+ }
+
/// The exact upstream severity rides OpcUaSeverity (#626) so the host does not re-bucket it.
[Fact]
public void The_exact_upstream_severity_is_carried()
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 55cd4903..876ad68c 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
@@ -478,6 +478,58 @@ public class NativeAlarmProjectorTests
legacy.ShouldBe([AlarmTransitionKind.Unspecified]);
}
+ ///
+ /// Gitea #657 — InactiveUnacknowledged projects inactive+unacked in ONE update from every prior, and writes rows
+ /// only for what changed: "Activated"+"Cleared" from an acknowledged prior (an un-ack needs a new occurrence),
+ /// "Cleared" from Active+unacked, nothing when the condition already shows inactive+unacked. The flag does not
+ /// change it — the rows already describe only real changes.
+ ///
+ [Theory]
+ [InlineData(false, true, new[] { AlarmTransitionKind.Raise, AlarmTransitionKind.Clear })]
+ [InlineData(true, true, new[] { AlarmTransitionKind.Raise, AlarmTransitionKind.Clear })]
+ [InlineData(true, false, new[] { AlarmTransitionKind.Clear })]
+ [InlineData(false, false, new AlarmTransitionKind[0])]
+ public void InactiveUnacknowledged_projects_inactive_unacked_with_rows_only_for_what_changed(
+ bool priorActive, bool priorAcked, AlarmTransitionKind[] expected)
+ {
+ foreach (var restated in new[] { false, true })
+ {
+ var sut = new NativeAlarmProjector();
+ if (priorActive) sut.Project("n1", Evt(AlarmTransitionKind.Raise));
+ if (priorActive && priorAcked) sut.Project("n1", Evt(AlarmTransitionKind.Acknowledge));
+ if (!priorActive && !priorAcked) sut.Project("n1", Evt(AlarmTransitionKind.InactiveUnacknowledged));
+
+ var snap = sut.Project("n1", Evt(AlarmTransitionKind.InactiveUnacknowledged, restated: restated),
+ out IReadOnlyList rows);
+
+ snap.Active.ShouldBeFalse();
+ snap.Acknowledged.ShouldBeFalse();
+ rows.ShouldBe(expected);
+ }
+ }
+
+ ///
+ /// Gitea #657 — a flagged Acknowledge on an already-acknowledged condition (OpcUaClient's first-sight Clear +
+ /// Acknowledge of an inactive+acked condition) writes no row; the unflagged control and a flagged Acknowledge
+ /// of an UNACKED condition both write one.
+ ///
+ [Fact]
+ public void A_restated_Acknowledge_on_an_acked_condition_writes_no_row()
+ {
+ var sut = new NativeAlarmProjector();
+ sut.Project("cold", Evt(AlarmTransitionKind.Acknowledge, restated: true), out IReadOnlyList cold);
+ cold.ShouldBeEmpty();
+
+ sut.Project("cold", Evt(AlarmTransitionKind.Acknowledge), out IReadOnlyList unflagged);
+ unflagged.ShouldBe([AlarmTransitionKind.Acknowledge]);
+
+ sut.Project("n2", Evt(AlarmTransitionKind.Raise));
+ var snap = sut.Project("n2", Evt(AlarmTransitionKind.Acknowledge, restated: true), out IReadOnlyList real);
+ real.ShouldBe([AlarmTransitionKind.Acknowledge]);
+ snap.Acknowledged.ShouldBeTrue();
+ snap.Active.ShouldBeTrue();
+ }
+
private static AlarmEventArgs Evt(
AlarmTransitionKind kind,
AlarmSeverity sev = AlarmSeverity.High,
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
index a3cfb899..b463be49 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/NativeAlarmRestatementEndToEndTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/NativeAlarmRestatementEndToEndTests.cs
@@ -330,6 +330,171 @@ public sealed class NativeAlarmRestatementEndToEndTests : RuntimeActorTestBase
alerts.ExpectMsg(Wait).TransitionKind.ShouldBe("Cleared");
}
+ ///
+ /// #657 item 1 — the respawned instance's ConditionRefresh re-reads an upstream condition that went active and
+ /// cleared UNACKNOWLEDGED, which the host already shows inactive+unacked. Pre-fix the first sight went out as
+ /// Retrigger then Clear: two condition updates (clients saw it Active again) and a duplicate "Activated" +
+ /// "Cleared" pair. Now it is ONE InactiveUnacknowledged: one update, already inactive, and no row.
+ ///
+ [Fact]
+ public void OpcUaClient_respawn_restating_an_inactive_unacked_alarm_does_not_flicker_or_write_rows()
+ {
+ var (host, publish, coordinator, alerts, db, blob) = StartOpcUaHost();
+ var handle = new Handle();
+
+ using (var a = NewOpcUaDriver(host, "a"))
+ {
+ a.RouteAlarmEvent(ConditionFields(active: true, acked: false), SourceIndex, handle);
+ NextAlarmUpdate(publish).State.Active.ShouldBeTrue();
+ alerts.ExpectMsg(Wait).TransitionKind.ShouldBe("Activated");
+ a.RouteAlarmEvent(ConditionFields(active: false, acked: false), SourceIndex, handle);
+ var cleared = NextAlarmUpdate(publish);
+ cleared.State.Active.ShouldBeFalse();
+ cleared.State.Acknowledged.ShouldBeFalse();
+ alerts.ExpectMsg(Wait).TransitionKind.ShouldBe("Cleared");
+ }
+
+ Respawn(host, publish, coordinator, db, blob);
+
+ using var b = NewOpcUaDriver(host, "b");
+ b.RouteAlarmEvent(ConditionFields(active: false, acked: false), SourceIndex, handle); // ConditionRefresh copy
+ var restated = NextAlarmUpdate(publish);
+ restated.State.Active.ShouldBeFalse("no Active flicker may reach clients");
+ restated.State.Acknowledged.ShouldBeFalse("upstream says unacknowledged");
+ alerts.ExpectNoMsg(Quiet);
+
+ // Positive control: the upstream operator acknowledges — the NEXT update is that ack (no leftover second
+ // update of the re-statement), and it writes its row.
+ b.RouteAlarmEvent(ConditionFields(active: false, acked: true), SourceIndex, handle);
+ var acked = NextAlarmUpdate(publish);
+ acked.State.Active.ShouldBeFalse();
+ acked.State.Acknowledged.ShouldBeTrue();
+ alerts.ExpectMsg(Wait).TransitionKind.ShouldBe("Acknowledged");
+ }
+
+ ///
+ /// #657 item 1, retained-Active variant — the host retained the condition Active+unacked across the respawn and
+ /// upstream has since cleared it (still unacked). That is ONE real "Cleared" — one update straight to
+ /// inactive+unacked (pre-fix the first update re-stated it Active, then a second cleared it) and no
+ /// "Activated" row.
+ ///
+ [Fact]
+ public void OpcUaClient_respawn_clearing_a_retained_active_unacked_alarm_is_one_update_and_one_Cleared_row()
+ {
+ var (host, publish, coordinator, alerts, db, blob) = StartOpcUaHost();
+ var handle = new Handle();
+
+ using (var a = NewOpcUaDriver(host, "a"))
+ {
+ a.RouteAlarmEvent(ConditionFields(active: true, acked: false), SourceIndex, handle);
+ NextAlarmUpdate(publish).State.Active.ShouldBeTrue();
+ alerts.ExpectMsg(Wait).TransitionKind.ShouldBe("Activated");
+ }
+
+ Respawn(host, publish, coordinator, db, blob);
+
+ using var b = NewOpcUaDriver(host, "b");
+ b.RouteAlarmEvent(ConditionFields(active: false, acked: false), SourceIndex, handle);
+ var cleared = NextAlarmUpdate(publish);
+ cleared.State.Active.ShouldBeFalse("the first update must already be the clear");
+ cleared.State.Acknowledged.ShouldBeFalse();
+ alerts.ExpectMsg(Wait).TransitionKind.ShouldBe("Cleared");
+ alerts.ExpectNoMsg(Quiet);
+ publish.ReceiveWhile