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(Quiet, m => m).OfType() + .ShouldBeEmpty("one update, not a re-statement followed by the clear"); + } + + /// + /// #657 item 2 (#658) — upstream cleared AND acknowledged a condition while the driver was down, and the host + /// retained it Active+acked across the respawn. Pre-fix the first sight was a flagged Unspecified: the host + /// kept it ACTIVE (nothing cleared it) and wrote nothing. Now it is a flagged Clear + Acknowledge: the + /// condition goes inactive+acked with exactly one real "Cleared" row (the ack changed nothing — no row). + /// + [Fact] + public void OpcUaClient_respawn_seeing_inactive_acked_clears_a_retained_active_condition() + { + var (host, publish, coordinator, alerts, db, blob) = StartOpcUaHost(); + var handle = new Handle(); + + using (var a = NewOpcUaDriver(host, "a")) + { + a.RouteAlarmEvent(ConditionFields(active: true, acked: true), SourceIndex, handle); + NextAlarmUpdate(publish).State.Active.ShouldBeTrue(); + alerts.ExpectMsg(Wait).TransitionKind.ShouldBe("Activated"); + alerts.ExpectMsg(Wait).TransitionKind.ShouldBe("Acknowledged"); + } + + Respawn(host, publish, coordinator, db, blob); + + using var b = NewOpcUaDriver(host, "b"); + b.RouteAlarmEvent(ConditionFields(active: false, acked: true), SourceIndex, handle); + var cleared = NextAlarmUpdate(publish); + cleared.State.Active.ShouldBeFalse("a retained Active condition must be cleared"); + cleared.State.Acknowledged.ShouldBeTrue(); + alerts.ExpectMsg(Wait).TransitionKind.ShouldBe("Cleared"); + var reAcked = NextAlarmUpdate(publish); + reAcked.State.Active.ShouldBeFalse(); + reAcked.State.Acknowledged.ShouldBeTrue(); + alerts.ExpectNoMsg(Quiet); + } + + /// + /// #657 item 2 (#658) — a host that already shows the condition inactive+acked re-reads it on a respawn. Pre-fix + /// the Unspecified first sight wrote an "Activated" row for an inactive alarm. Now: no row, the node stays + /// inactive+acked; a later genuine raise still writes its row. + /// + [Fact] + public void OpcUaClient_respawn_restating_an_inactive_acked_alarm_writes_no_Activated_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: true), SourceIndex, handle); + NextAlarmUpdate(publish); + a.RouteAlarmEvent(ConditionFields(active: false, acked: true), SourceIndex, handle); + NextAlarmUpdate(publish).State.Active.ShouldBeFalse(); + alerts.ExpectMsg(Wait).TransitionKind.ShouldBe("Activated"); + alerts.ExpectMsg(Wait).TransitionKind.ShouldBe("Acknowledged"); + alerts.ExpectMsg(Wait).TransitionKind.ShouldBe("Cleared"); + } + + Respawn(host, publish, coordinator, db, blob); + + using var b = NewOpcUaDriver(host, "b"); + b.RouteAlarmEvent(ConditionFields(active: false, acked: true), SourceIndex, handle); + var restated = NextAlarmUpdate(publish); + restated.State.Active.ShouldBeFalse(); + restated.State.Acknowledged.ShouldBeTrue(); + alerts.ExpectNoMsg(Quiet); + + // Positive control: a genuine re-activation MUST write its row. + b.RouteAlarmEvent(ConditionFields(active: true, acked: false), SourceIndex, handle); + publish.FishForMessage(u => u.State.Active, Wait); + alerts.ExpectMsg(Wait).TransitionKind.ShouldBe("Activated"); + } + + private (IActorRef Host, TestProbe Publish, TestProbe Coordinator, TestProbe Alerts, + IDbContextFactory Db, byte[] Blob) StartOpcUaHost() + { + var db = NewInMemoryDbFactory(); + var blob = BuildOpcUaArtifact(); + var alerts = SubscribeAlerts(); + var (host, publish, coordinator) = SpawnHostAndApply(db, Seed(db, blob, Rev)); + return (host, publish, coordinator, alerts, db, blob); + } + + /// A config-changing deploy: the host keeps the still-deployed condition's state, the driver respawns. + private static void Respawn( + IActorRef host, TestProbe publish, TestProbe coordinator, IDbContextFactory db, byte[] blob) + { + var revB = RevisionHash.Parse(new string('b', 64)); + host.Tell(new DispatchDeployment(Seed(db, blob, revB), revB, CorrelationId.NewId())); + coordinator.FishForMessage(x => x.Outcome == ApplyAckOutcome.Applied, Wait); + publish.FishForMessage(_ => true, Wait); + } + private static readonly Dictionary SourceIndex = new() { [UpstreamSource] = OpcRawPath }; private static OpcUaClientDriver NewOpcUaDriver(IActorRef host, string suffix)