fix(alarms): #647 review — Raise on an active condition is a re-statement; FOCAS outage quality + health

MAJOR-1: a deploy that changes a driver's config respawns it, and the fresh instance's empty told
record re-sent Raise for every still-active alarm, un-acking acknowledged conditions and writing a
duplicate "Activated" alerts row. NativeAlarmProjector now treats Raise on an already-active
condition as a re-statement (keeps Acked; ForwardNativeAlarm skips the alerts row); only Retrigger
(or inactive->active) un-acks. Sources whose upstream ack state is authoritative send Retrigger for
an unacked re-statement: OpcUaClient on first sight (no prior told), Galaxy for an unacked active
snapshot entry. Runtime E2E drives a FRESH FOCAS driver after a redeploy and asserts the ack survives.

MAJOR-2: a failed FOCAS alarm read annotates each subscribed condition BadNoCommunication once per
outage (#626 quality-only path) with one Warning (MINOR-6), lifted to Good on recovery; failed alarm
reads degrade driver health with LastSuccessfulRead frozen (successful ones advance it), so an
alarm-only driver trips the #527 comms-loss watchdog.

MINOR-3 CLAUDE.md #630 bullet; MINOR-4 split line; MINOR-5 a non-empty cnc_rdalmmsg2 payload that
fits neither stride throws; MINOR-7 value-tag + parsed-address tables are built and swapped, never
cleared in place under the retained poll engine. Docs corrected (FOCAS.md, AlarmTracking.md, CLAUDE.md).

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-08 20:18:03 -04:00
parent 482bafdfac
commit 89961af6e6
17 changed files with 516 additions and 66 deletions
+3 -1
View File
@@ -57,7 +57,9 @@ same condition is wired as an **event notifier of every referencing equipment fo
- **Routing is an exact `ConditionId == RawPath` match** (`DriverHostActor.ForwardNativeAlarm`). A driver that
decorates the id has every transition dropped as "no alarm condition" — AbCip did (`"{ref}#active"`, fixed
under #641), and so did FOCAS (`"{host}#{Type}:{Number}"`, fixed under #647 — one condition per `ALARM:` tag, see
`drivers/FOCAS.md`). A native source must also set `Kind`: an
`drivers/FOCAS.md`). A `Raise` on a condition already projected Active is a **re-statement** (#647 review): the
ack is kept and no second "Activated" row is published — a respawned driver re-reading a still-active alarm sends
exactly that. Only `Retrigger` (or inactive → active) un-acks. A native source must also set `Kind`: an
`Unspecified` kind projects no state change.
### Native condition state survives a full rebuild (Gitea #641)
+18 -8
View File
@@ -398,14 +398,24 @@ it, then author `ALARM:T<code>`. Number ranges are series-independent.
**Delivery and failure.**
- **A failed read is not "no alarms".** `IFocasClient.ReadAlarmsAsync` throws on a down session, a non-OK
return code or a wire fault; that device is skipped for the tick and its conditions keep their state. (It
used to answer `[]`, which would have cleared every alarm on a comms blip and re-raised them un-acknowledged
on recovery.) Driver-wide comms loss is the host's `BadNoCommunication` quality annotation (#477) — Active
is kept.
- **Diffed against what the server was TOLD**, recorded only while a handler is attached, and held for the
driver's lifetime: a reconnect (same-set re-subscribe), a re-initialise and a shutdown/init cycle re-raise
nothing, so an acknowledgement survives them. A changed subscribed set (an alarm-bearing deploy) prunes only
the RawPaths that left (#641).
return code, a wire fault, or a non-empty reply that fits neither the 76- nor the 80-byte record layout (an
empty reply is "no alarms"). That device is skipped for the tick and its conditions keep their Active/Acked
state. (It used to answer `[]`, which would have cleared every alarm on a comms blip and re-raised them
un-acknowledged on recovery.)
- **…but an unreachable source says so.** The first failed read of an outage annotates every subscribed condition
on that device `BadNoCommunication` through the #626 quality-only path (no state change, no `/alerts` row) and
logs ONE Warning naming `cnc_rdalmmsg2`; the next successful read lifts it to Good (before any transition it
also finds, e.g. Good then Clear for an alarm that cleared during the outage). Failed alarm reads also reach
driver health: `Degraded` with `LastSuccessfulRead` frozen (only a successful read advances it), so a driver
whose only tags are `ALARM:` tags trips the comms-loss watchdog (#527) and the host's driver-wide #477
annotation like any other.
- **Diffed against what the server was TOLD**, recorded only while a handler is attached. The record lives on the
driver instance: a reconnect (same-set re-subscribe) and an in-place re-initialise (Force Reconnect, #630) re-raise
nothing. **A deploy that changes the driver's config respawns it** (the planner stops + recreates the driver, a new
instance with an empty record), and its first poll sends `Raise` for every still-active alarm. The host treats a
`Raise` on a condition it already shows Active as a **re-statement**: the acknowledgement is kept and no second
"Activated" `/alerts` row is written (`NativeAlarmProjector`, #647 review). Only `Retrigger` — or inactive →
active — un-acknowledges, which is why a genuinely new CNC alarm in a matched set is sent as `Retrigger`.
- **Polling:** one `cnc_rdalmmsg2` per `AlarmProjection.PollInterval` (default 2 s) for each device that owns a
**subscribed** alarm tag; devices without one are never polled for alarms.
- **Opt-in is the tag.** `AlarmProjection.Enabled` is retained so old configs bind, and has no effect. Before