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 _stream = Channel.CreateUnbounded(); + private GalaxyDriver? _driver; + + public TestProbe Publish { get; private set; } = null!; + public TestProbe Alerts { get; private set; } = null!; + + public static async Task StartAsync(NativeAlarmRestatementEndToEndTests t) + { + var rig = new GalaxyRig(); + var db = NewInMemoryDbFactory(); + var blob = BuildArtifact(GalaxyDriverId, DriverTypeNames.Galaxy, "Galaxy", "gw", "tank01_hihi", + new { attributeRef = GalaxyAlarmRef, alarm = new { alarmType = "AlarmCondition", severity = 800 } }); + rig.Alerts = t.SubscribeAlerts(); + var (host, publish, _) = t.SpawnHostAndApply(db, Seed(db, blob, Rev)); + rig.Publish = publish; + + var rawTags = RawTagsFor(blob, GalaxyDriverId); + rawTags.Select(r => r.RawPath).ShouldBe([GalaxyRawPath]); + var feed = new GatewayGalaxyAlarmFeed(rig.OpenStream, clientName: "Restatement656", + reconnectDelay: TimeSpan.FromMilliseconds(20)); + var options = new GalaxyDriverOptions( + new GalaxyGatewayOptions("http://localhost:5000", "literal-api-key"), + new GalaxyMxAccessOptions("Restatement656"), + new GalaxyRepositoryOptions(), + new GalaxyReconnectOptions()) { RawTags = rawTags }; + rig._driver = new GalaxyDriver(GalaxyDriverId, options, hierarchySource: null, alarmFeed: feed); + rig._driver.OnAlarmEvent += (_, e) => host.Tell(new DriverInstanceActor.AttributeAlarmPublished(GalaxyDriverId, e)); +#pragma warning disable OTOPCUA0001 // the routing is under test, not resilience wrapping + await rig._driver.SubscribeAlarmsAsync([GalaxyRawPath], CancellationToken.None); +#pragma warning restore OTOPCUA0001 + return rig; + } + + public void Send(Proto.AlarmFeedMessage message) => _stream.Writer.TryWrite(message).ShouldBeTrue(); + + /// Fault the current gateway stream; the feed reopens it (the next open reads on). + public void Fault() => _stream.Writer.TryWrite(new IOException("synthetic gateway stream fault")).ShouldBeTrue(); + + private async IAsyncEnumerable OpenStream( + Proto.StreamAlarmsRequest request, [EnumeratorCancellation] CancellationToken ct) + { + while (true) + { + var item = await _stream.Reader.ReadAsync(ct); + if (item is Exception ex) throw ex; + yield return (Proto.AlarmFeedMessage)item; + } + } + + public async ValueTask DisposeAsync() + { + if (_driver is not null) await _driver.ShutdownAsync(CancellationToken.None); + _driver?.Dispose(); + } + } + + // --------------------------------------------------------------------------------------------------------- + // OpcUaClient + // --------------------------------------------------------------------------------------------------------- + + private const string OpcDriverId = "drv-opcua"; + private const string OpcRawPath = "Plant/Opc/plc/trip1"; + private const string UpstreamSource = "ns=3;s=Alarms.Trip1"; + + /// + /// #656 claim 2 (OpcUaClient) — a deploy that changes the driver's config RESPAWNS it, and the fresh instance's + /// ConditionRefresh re-reads every retained condition with no record of what the host was told. Its first + /// sight of an unacked active alarm is a Retrigger (#647: upstream's unack is authoritative); pre-fix that + /// wrote a second "Activated" row for a condition already showing exactly that. Now it is a flagged + /// re-statement: the condition is re-projected (still unacked) and no row is written. + /// + [Fact] + public async Task OpcUaClient_respawn_restating_an_unacked_active_alarm_writes_no_second_Activated_row() + { + var db = NewInMemoryDbFactory(); + var blob = BuildOpcUaArtifact(); + var alerts = SubscribeAlerts(); + var (host, publish, coordinator) = SpawnHostAndApply(db, Seed(db, blob, Rev)); + var handle = new Handle(); + + using (var a = NewOpcUaDriver(host, "a")) + { + a.RouteAlarmEvent(ConditionFields(active: true, acked: false), SourceIndex, handle); + var raised = NextAlarmUpdate(publish); + raised.AlarmNodeId.ShouldBe(OpcRawPath); + raised.State.Active.ShouldBeTrue(); + raised.State.Acknowledged.ShouldBeFalse(); + alerts.ExpectMsg(Wait).TransitionKind.ShouldBe("Activated"); + } + + // A config-changing deploy: the host keeps the still-deployed condition's state, the driver is respawned. + 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); + + using var b = NewOpcUaDriver(host, "b"); + b.RouteAlarmEvent(ConditionFields(active: true, acked: false), SourceIndex, handle); // ConditionRefresh copy + var restated = NextAlarmUpdate(publish); + restated.State.Active.ShouldBeTrue(); + restated.State.Acknowledged.ShouldBeFalse("upstream says unacknowledged"); + alerts.ExpectNoMsg(Quiet); + + // Positive control: the upstream operator acknowledges — that transition MUST write its row. + b.RouteAlarmEvent(ConditionFields(active: true, acked: true), SourceIndex, handle); + var acked = NextAlarmUpdate(publish); + acked.State.Acknowledged.ShouldBeTrue(); + alerts.ExpectMsg(Wait).TransitionKind.ShouldBe("Acknowledged"); + } + + /// + /// #656 related item 1 (OpcUaClient) — an upstream condition first seen Active+acked used to go out as + /// Raise-then-Acknowledge: TWO condition updates, the first Active+UNacked (a client-visible un-ack window). + /// Now it is ONE ActiveAcknowledged: the first condition update is already acked, with "Activated" then + /// "Acknowledged" rows. + /// + [Fact] + public void OpcUaClient_first_sight_of_an_acked_active_alarm_is_one_active_and_acked_update() + { + var db = NewInMemoryDbFactory(); + var alerts = SubscribeAlerts(); + var (host, publish, _) = SpawnHostAndApply(db, Seed(db, BuildOpcUaArtifact(), Rev)); + var handle = new Handle(); + using var drv = NewOpcUaDriver(host, "cold"); + + drv.RouteAlarmEvent(ConditionFields(active: true, acked: true), SourceIndex, handle); + var first = NextAlarmUpdate(publish); + first.State.Active.ShouldBeTrue(); + first.State.Acknowledged.ShouldBeTrue("no Active+unacked intermediate may reach clients"); + alerts.ExpectMsg(Wait).TransitionKind.ShouldBe("Activated"); + alerts.ExpectMsg(Wait).TransitionKind.ShouldBe("Acknowledged"); + alerts.ExpectNoMsg(Quiet); + + // Positive control: the next update is the clear (not a leftover second update), and it writes its row. + drv.RouteAlarmEvent(ConditionFields(active: false, acked: true), SourceIndex, handle); + var cleared = NextAlarmUpdate(publish); + cleared.State.Active.ShouldBeFalse(); + alerts.ExpectMsg(Wait).TransitionKind.ShouldBe("Cleared"); + } + + private static readonly Dictionary SourceIndex = new() { [UpstreamSource] = OpcRawPath }; + + private static OpcUaClientDriver NewOpcUaDriver(IActorRef host, string suffix) + { + var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), $"{OpcDriverId}-{suffix}"); + drv.OnAlarmEvent += (_, e) => host.Tell(new DriverInstanceActor.AttributeAlarmPublished(OpcDriverId, e)); + return drv; + } + + /// An upstream A&C condition event, in the driver's select-clause order. + private static VariantCollection ConditionFields(bool active, bool acked) => + [ + new Variant(new byte[] { 1 }), // EventId + new Variant(new UaNodeId("TripAlarmType", 3)), // EventType + new Variant(UaNodeId.Parse(UpstreamSource)), // SourceNode + new Variant(new LocalizedText("tripped")), // Message + new Variant((ushort)700), // Severity + new Variant(DateTime.UtcNow), // Time + new Variant(UaNodeId.Parse(UpstreamSource + ".Cond")), // ConditionId + new Variant(active), // ActiveState/Id + new Variant(acked), // AckedState/Id + Variant.Null, // BranchId + ]; + + private static byte[] BuildOpcUaArtifact() => BuildArtifact(OpcDriverId, DriverTypeNames.OpcUaClient, "Opc", "plc", "trip1", + new { nodeId = UpstreamSource, alarm = new { alarmType = "AlarmCondition", severity = 700 } }); + + private sealed class Handle : IAlarmSubscriptionHandle + { + public string DiagnosticId => "e2e"; + } + + // --------------------------------------------------------------------------------------------------------- + // Shared host harness (as FocasNativeAlarmEndToEndTests) + // --------------------------------------------------------------------------------------------------------- + + /// The next condition update the host sent to the publish actor (skipping anything else). + private static OpcUaPublishActor.AlarmStateUpdate NextAlarmUpdate(TestProbe publish) => + publish.FishForMessage(_ => true, Wait); + + private TestProbe SubscribeAlerts() + { + var alerts = CreateTestProbe(); + DistributedPubSub.Get(Sys).Mediator.Tell(new Subscribe(ScriptedAlarmHostActor.AlertsTopic, alerts.Ref), alerts.Ref); + alerts.ExpectMsg(Wait); + return alerts; + } + + private static List RawTagsFor(byte[] blob, string driverInstanceId) + { + using var artifact = DeploymentArtifact.Open(blob); + var spec = artifact.ParseDriverInstances().Single(s => s.DriverInstanceId == driverInstanceId); + using var doc = JsonDocument.Parse(spec.DriverConfig); + return JsonSerializer.Deserialize>( + doc.RootElement.GetProperty("RawTags").GetRawText(), + new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!; + } + + private static byte[] BuildArtifact( + string driverId, string driverType, string driverName, string deviceName, string tagName, object tagConfig) => + JsonSerializer.SerializeToUtf8Bytes(new + { + RawFolders = new[] { new { RawFolderId = "rf-plant", ParentRawFolderId = (string?)null, Name = "Plant", ClusterId = "c1" } }, + DriverInstances = new[] + { + // Enabled=false: the host must not spawn a real wire driver — the test drives its own instance. + new { DriverInstanceId = driverId, RawFolderId = "rf-plant", Name = driverName, DriverType = driverType, DriverConfig = "{}", ClusterId = "c1", Enabled = false }, + }, + Devices = new[] + { + new { DeviceId = "dev-1", DriverInstanceId = driverId, Name = deviceName, DeviceConfig = "{}" }, + }, + TagGroups = Array.Empty(), + Tags = new[] + { + new + { + TagId = "tag-alarm", + DeviceId = "dev-1", + TagGroupId = (string?)null, + Name = tagName, + DataType = "Boolean", + AccessLevel = 0, + TagConfig = JsonSerializer.Serialize(tagConfig), + }, + }, + }); + + private static DeploymentId Seed(IDbContextFactory db, byte[] blob, RevisionHash rev) + { + var id = DeploymentId.NewId(); + using var ctx = db.CreateDbContext(); + ctx.Deployments.Add(new Deployment + { + DeploymentId = id.Value, + RevisionHash = rev.Value, + Status = DeploymentStatus.Sealed, + CreatedBy = "test", + SealedAtUtc = DateTime.UtcNow, + ArtifactBlob = blob, + }); + ctx.SaveChanges(); + return id; + } + + private (IActorRef Actor, TestProbe Publish, TestProbe Coordinator) SpawnHostAndApply( + IDbContextFactory db, DeploymentId deploymentId) + { + var coordinator = CreateTestProbe(); + var publish = this.CreateCleanOutcomePublishProbe(); + var actor = Sys.ActorOf(DriverHostActor.Props( + db, TestNode, coordinator.Ref, + localRoles: new HashSet { "driver" }, + dependencyMux: CreateTestProbe().Ref, + opcUaPublishActor: publish.Ref, + virtualTagEvaluator: NullVirtualTagEvaluator.Instance, + virtualTagHostOverride: CreateTestProbe().Ref)); + + actor.Tell(new DispatchDeployment(deploymentId, Rev, CorrelationId.NewId())); + coordinator.ExpectMsg(Wait).Outcome.ShouldBe(ApplyAckOutcome.Applied); + publish.ExpectMsg(Wait); + return (actor, publish, coordinator); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ZB.MOM.WW.OtOpcUa.Runtime.Tests.csproj b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ZB.MOM.WW.OtOpcUa.Runtime.Tests.csproj index c272a47a..8874ee79 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ZB.MOM.WW.OtOpcUa.Runtime.Tests.csproj +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ZB.MOM.WW.OtOpcUa.Runtime.Tests.csproj @@ -34,6 +34,10 @@ + + +