diff --git a/CLAUDE.md b/CLAUDE.md index 25397bac..67a72a3a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1132,7 +1132,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 0de6653c..df9ffe3d 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 97a82664..b2883d82 100644 --- a/docs/drivers/OpcUaClient.md +++ b/docs/drivers/OpcUaClient.md @@ -101,7 +101,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.