f54922f94c
Residuals of #760: 1. An ack routed to a disabled / not-yet-spawned driver, or to a stubbed child (driver does not run on this node, or its factory rejected the config), was dropped and left the condition Acknowledged. DriverHostActor now reverts it through the same NativeAlarmProjector.TryRevertAcknowledge path as a refused ack. 2. A native ack arriving while the host is Stale was dropped before the projector recorded it, so it could never be reverted. Stale now marks the whole NativeAlarmAckPreflightView unavailable (SetHostUnavailable), so the node manager refuses every native ack synchronously; BecomeSteady clears it. 3. (keyed sessions without a Galaxy identity) is not addressed. Tests: DriverHostActorNativeAlarmTests' client-ack test and the FOCAS respawn theory now start the host's own driver (AckingAlarmDriver; a factory building a real FocasDriver per spawn over per-instance fake CNCs, with a real config-change respawn) - both fail against the fix in their old shape. New: ack to a disabled driver reverts, ack to a stubbed driver reverts, a Stale host refuses through the pre-check view - all three fail with the fix mutated out. Runtime.Tests 943/0/3. Docs: AlarmTracking.md, .claude/rules/alarms.md. Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
771 lines
55 KiB
Markdown
771 lines
55 KiB
Markdown
# Alarm tracking — v2 final architecture
|
|
|
|
This document describes how OtOpcUa surfaces alarms to OPC UA Part 9
|
|
clients after the **alarms-over-gateway** epic
|
|
([docs/plans/alarms-over-gateway.md](plans/alarms-over-gateway.md))
|
|
landed. The v1 architecture (Galaxy.Host's COM-side `GalaxyAlarmTracker`)
|
|
is preserved at [docs/v1/AlarmTracking.md](v1/AlarmTracking.md) for
|
|
historical reference.
|
|
|
|
## Three alarm sources, one OPC UA Part 9 surface
|
|
|
|
| Source | Driver capability | Path |
|
|
|----------------------------------|--------------------------|------|
|
|
| **Galaxy MxAccess (driver-native)** | `GalaxyDriver : IAlarmSource` | gateway → worker → MxAccess alarm sink → `MX_EVENT_FAMILY_ON_ALARM_TRANSITION` → `EventPump` → driver `OnAlarmEvent` → `AlarmConditionService` |
|
|
| **MTConnect CONDITION (driver-native, #626)** | `MTConnectDriver : IAlarmSource` | Agent `/sample` pump → `MTConnectConditionTracker` → `MTConnectConditionAlarmMapper` → driver `OnAlarmEvent` (ConditionId = RawPath) → `DriverHostActor.ForwardNativeAlarm` |
|
|
| **Galaxy sub-attribute fallback** | `IWritable` writes to `$Alarm*` sub-attributes | gateway data subscription → driver `OnDataChange` → `DriverNodeManager` ConditionSink → `AlarmConditionService` |
|
|
| **Scripted alarms** | `Phase7Composer` | server-side script evaluator → `ScriptedAlarmActor` transitions → `HistorianAdapterActor` → `IAlarmHistorianSink` |
|
|
|
|
All three converge on the alarm-state actor — in v2 the OPC UA Part 9 state
|
|
machine lives inside `ScriptedAlarmActor`
|
|
(`src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/ScriptedAlarmActor.cs`),
|
|
which dispatches transitions to the OPC UA condition node managers. Driver-native transitions take
|
|
precedence over sub-attribute synthesis when both arrive for the same
|
|
condition — the dedup logic prefers the richer driver-native record
|
|
because it carries the full operator + raise-time + category metadata
|
|
that the value-driven path collapses.
|
|
|
|
## v3 Batch 4 — multi-notifier delivery (raw + equipment folders)
|
|
|
|
Under the v3 dual-namespace address space, a native driver alarm is authored on a
|
|
**raw tag** (`/raw`) and its Part 9 `AlarmConditionState` materializes **once** at the
|
|
raw tag — `ConditionId` = the tag's **RawPath**, parent notifier = the raw device/group
|
|
folder (`ns=Raw`). Because equipment references raw tags (v3 reference-only UNS), the
|
|
same condition is wired as an **event notifier of every referencing equipment folder**
|
|
(`ns=UNS`) via the SDK `AddNotifier` pattern (`OtOpcUaNodeManager.WireAlarmNotifiers`):
|
|
`alarm.AddNotifier(equipFolder, isInverse:true)` + `equipFolder.AddNotifier(alarm)` +
|
|
`EnsureFolderIsEventNotifier(equipFolder)`.
|
|
|
|
- A **single** `ReportEvent` fans through the SDK notifier graph to the raw device folder,
|
|
every referencing equipment folder, and to the Server object. A subscriber at any one
|
|
root receives **exactly one** copy of the transition — the Part 9 shared-`InstanceStateSnapshot`
|
|
dedup (`MonitoredItem.QueueEvent` → `IsEventContainedInQueue`), **never** one copy per root.
|
|
The Server-object (`i=2253`) copy is delivered by the condition itself, not by the folders'
|
|
forward to the core Server object, so it can be filtered per subscriber by the data-plane ACL
|
|
(Gitea #586 — see `docs/security.md`).
|
|
Duplicating the `ReportEvent` per root is rejected by design (distinct EventIds would break
|
|
Server-object dedup + Part 9 ack correlation).
|
|
- **Teardown is symmetric:** the node manager tracks the wired notifier pairs and calls
|
|
`RemoveNotifier(..., bidirectional:true)` on rebuild / subtree-removal / reference-removal, so
|
|
inverse-notifier entries never leak across redeploys.
|
|
- **Ack/confirm/shelve route on `ConditionId = RawPath`** (never `SourceNodeId`) regardless of
|
|
which notifier root the operator subscribed at — an ack issued from an equipment-folder
|
|
subscription resolves to the same raw condition.
|
|
- The `alerts`-topic `AlarmTransitionEvent` carries the (possibly empty) **list of referencing
|
|
equipment paths**, and the AdminUI `/alerts` page shows **one row per condition** (primary
|
|
identity RawPath + condition NodeId) with the equipment list as display metadata.
|
|
- **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 `Raise` on a condition already projected Active **with the same message** 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. A Raise with a DIFFERENT message is a new statement and un-acks:
|
|
a respawned driver cannot tell "the same alarm" from "a different one that appeared while it was down", so the
|
|
host compares what the condition shows. Only `Retrigger` (or inactive → active, or a changed-message Raise) un-acks.
|
|
⚠️ **Residual:** the comparison is only as good as the source's message determinism. FOCAS and MTConnect messages
|
|
are deterministic for an unchanged statement; a source whose message carries volatile text (a timestamp, a
|
|
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 and live Galaxy transitions never
|
|
set it; AbCip ALMA sets it for the drop-out update below). The flag governs **history only, never node state**: the projector still projects, but a flagged `Retrigger`
|
|
on a condition already Active+unacked with the **same message AND severity**, a flagged `Clear` on an
|
|
already-inactive condition, or a flagged `Acknowledge` on an already-acknowledged one (#657), writes no `/alerts` row;
|
|
so does a flagged `Unspecified` update on an already-active condition, which moves the node's message/severity (one
|
|
condition event, active/acked kept) — AbCip ALMA's governing limit dropping out (HH→H). Before this every gateway reconnect wrote a duplicate
|
|
"Activated" (and "Cleared") row per alarm while clients saw no event. A **live** `Retrigger` is a genuine
|
|
re-occurrence and keeps its row; a changed severity (an MTConnect Warning→Fault that keeps its text) does too.
|
|
Residual: a Galaxy alarm that genuinely re-occurred during an outage and is still Active+unacked with identical
|
|
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.
|
|
- **`AlarmTransitionKind.InactiveUnacknowledged` (#657)** — its mirror: "inactive AND unacknowledged" (went active and
|
|
cleared unacked) in ONE projection, never `Raise`/`Retrigger` + `Clear` (which showed clients the condition Active
|
|
again for an instant). Rows only for what changed, flag or not: "Activated" (raise time when known) + "Cleared" when
|
|
the condition was acknowledged (an un-ack needs a new occurrence), "Cleared" alone when it was Active+unacked, none
|
|
when it already showed inactive+unacked. OpcUaClient emits it for every move into inactive+unacked except from
|
|
Active+unacked (a plain `Clear`). Its **first sight** of an inactive condition (after a start or deploy respawn, when
|
|
the host may still hold ANY state) is `InactiveUnacknowledged`, or `Clear` + `Acknowledge` for inactive+acked — both
|
|
flagged — so a host already showing that state writes nothing, and a host that retained the condition Active gets a
|
|
real "Cleared". Pre-#657 a respawn wrote a duplicate "Activated" + "Cleared" pair per inactive+unacked condition, and
|
|
an inactive+acked first sight went out as `Unspecified` (an "Activated" row for an inactive alarm; a retained-Active
|
|
condition was never cleared). Residual (pre-existing, not a regression): a cold host treats an unseen condition as acked, so after a node restart each upstream inactive+unacked condition still writes "Activated" + "Cleared" once. E2E: `Runtime.Tests/NativeAlarmRestatementEndToEndTests`.
|
|
|
|
### Native condition state survives a full rebuild (Gitea #641)
|
|
|
|
A full `RebuildAddressSpace` recreates every condition inactive + acknowledged. For **scripted** alarms the
|
|
engine re-asserts every non-normal condition after the reload (#487). **Native** conditions had nothing
|
|
equivalent: the host re-subscribes a driver's alarms only when its alarm-ref *set* changes, and a
|
|
transition-only source (Galaxy, OpcUaClient) has nothing to replay anyway — so a rebuild that left the alarm
|
|
set unchanged (adding one `UnsTagReference` to an alarm-bearing raw tag is enough to force one, as are
|
|
`ChangedEquipment`, a scripted-alarm edit, a raw container rename, an alarm severity/type edit, or any
|
|
surgical fallback to `SafeRebuild()`) left every active native alarm reading **normal** until its next device
|
|
transition.
|
|
|
|
The node manager now carries native state across the rebuild itself:
|
|
|
|
1. `RebuildAddressSpace` captures each **native** condition's live state off the **node** — Active, Acked,
|
|
Confirmed, Severity, Message, Comment + ClientUserId, Quality, Time, `ActiveState.TransitionTime` — before clearing. Reading the
|
|
node, not a host-side cache, is what keeps a **client's** acknowledgement: the SDK applies it to the node
|
|
and neither the driver nor `NativeAlarmProjector` ever sees it.
|
|
2. `MaterialiseAlarmCondition(isNative: true)` for the same id in the same apply restores it. **Quality is
|
|
always restored** (it is otherwise re-annotated only on a connectivity edge, so even a normal condition
|
|
would sit at `BadWaitingForInitialData`); the rest only when the condition was off-normal (active or
|
|
unacknowledged). A normal condition keeps what the materialise built — message and authored severity
|
|
included — exactly the #487 rule. Shelving/Enabled are not carried (native conditions refuse them, #587).
|
|
3. `IOpcUaAddressSpaceSink.CompleteAddressSpaceApply`, which `OpcUaPublishActor` calls at the end of
|
|
**every** apply (in a `finally`), reports **one** condition event per restored condition — after the
|
|
notifier wiring, so the raw folder, every referencing equipment folder and `i=2253` items all get it,
|
|
stamped with the original `Time`, not the deploy time (or the node's newer `Time`, if a client method call
|
|
reported in the restore→complete window — that window can yield a second, state-correct event) — and **discards** every capture no materialise
|
|
claimed, so a later deploy re-adding the same RawPath gets a fresh node (no resurrection). A kind swap
|
|
(native ↔ scripted on one id) is never restored. Node-only: no `/alerts` row, no historian record.
|
|
|
|
Two companions keep the host and driver in step with the carried node:
|
|
|
|
- `DriverHostActor` **prunes** `NativeAlarmProjector` to the still-deployed conditions on each apply
|
|
(`RetainOnly`) instead of clearing it. Clearing made the next driver Acknowledge of an active alarm
|
|
project `Active=false`, and the next Clear of an unacknowledged one project `Acked=true` — after *any*
|
|
deploy, rebuild or not.
|
|
- MTConnect's "last told" record is pruned to the conditions that **left** the subscribed set rather than
|
|
cleared on any set change; re-raising a surviving (carried) condition would un-acknowledge it. AbCip's ALMD
|
|
last-seen `InAlarm` record is likewise driver-owned and pruned to the set, so a re-subscribe (every
|
|
reconnect / set change) does not re-raise a still-faulted alarm.
|
|
- A routed **client** acknowledgement marks the projector's prior state acknowledged
|
|
(`NativeAlarmProjector.MarkAcknowledged`), so a later driver Clear does not project it unacknowledged.
|
|
|
|
Accepted residual: a condition that clears at the device while the rebuild runs is restored active until
|
|
the driver's Clear arrives — which it does, because the driver's own record still holds it active. The capture
|
|
is keyed by RawPath only, so a tag deleted and re-created under the same RawPath in one deploy inherits the old
|
|
condition's state until its next transition.
|
|
|
|
## Condition event identity fields (what a client reads on the wire)
|
|
|
|
Every condition event — native and scripted — carries the mandatory `BaseEventType` identity
|
|
fields, assigned at materialize time in `OtOpcUaNodeManager.MaterialiseAlarmCondition`. The SDK
|
|
does **not** synthesize them on this path (`Create` builds the children from the type definition
|
|
but leaves them unset; `ReportEvent` / `InstanceStateSnapshot` copy children verbatim), so they
|
|
are set explicitly. Leaving them unset shipped them as **null** on every event — see issues #473
|
|
(the `BaseEventType` trio) and #475 (the `ConditionType` classification pair).
|
|
|
|
| Field | Value | Notes |
|
|
|---|---|---|
|
|
| `EventType` | the **concrete** materialized type (`TypeDefinitionId`) | e.g. `OffNormalAlarmType`; falls back to `AlarmConditionType` for an unknown authored type. Readable as a *field*, not only via an `OfType` where-clause |
|
|
| `SourceNode` | the condition's **own NodeId** — equal to `ConditionId` | The condition **is** the source: an alarm-bearing raw tag materializes only the condition, with no sibling value variable, so there is no other node to point at |
|
|
| `SourceName` | the same identifying id string: **RawPath** (native) / **ScriptedAlarmId** (scripted) | Deliberately the *unique* id, **not** the leaf name |
|
|
| `ConditionName` | the leaf / display name (e.g. `HR200`) | Where the short human-readable name lives |
|
|
| `ConditionClassId` | always **`BaseConditionClassType`** | Part 9's "no condition class modelled" value. Unset shipped `NodeId.Null` (#475) |
|
|
| `ConditionClassName` | always **`"BaseConditionClass"`** | Matches `ConditionClassId`. Unset shipped empty text (#475) |
|
|
| `Quality` | the condition's **source-data quality** — native tracks the source's connectivity (`Good` / `Bad`); scripted takes the worst of its input tags' qualities (#478) | A pure annotation; never alters Active/Acked/Retain. Unset shipped the accidentally-Good default (#477) — see below |
|
|
|
|
**Why `BaseConditionClassType` and not `ProcessConditionClassType`.** We hold no per-alarm
|
|
classification at the materialize seam, and `ConditionClassId` is a wire contract clients bucket on.
|
|
`BaseConditionClassType` is the honest, spec-conformant report of *"this server does not model
|
|
condition classes"* — it fixes the real defect (a null that breaks conformant clients) without
|
|
asserting a classification we cannot back. `ProcessConditionClassType` — the SDK sample's pick —
|
|
was rejected deliberately: it would be *actively wrong* for a Galaxy alarm whose upstream category is
|
|
Safety or Diagnostics, trading a detectable null for an undetectable lie. Real per-alarm
|
|
classification is a separate future feature: it needs the driver's alarm category, which today lives
|
|
only on the runtime `AlarmEventArgs` transition, carried to the deploy-time authored composition that
|
|
`MaterialiseAlarmCondition` sees. Until then the `IAlarmSource` doc comment claiming the category
|
|
"maps to `ConditionClassName` downstream" describes an intent, not the implementation.
|
|
|
|
**Why `SourceName` is the id, not the leaf name.** The leaf is ambiguous across devices (`HR200` on
|
|
two PLCs collides) and is already carried by `ConditionName`, so the leaf-name option would add no
|
|
identity while costing uniqueness. Carrying the id makes the `SourceNode`/`SourceName`/`ConditionId`
|
|
triple mutually consistent and unique. This diverges from the loose OPC UA convention that
|
|
`SourceName` mirrors the source node's BrowseName; the divergence is intentional.
|
|
|
|
**Client guidance:** key on **`ConditionId`** — the condition node's own NodeId, which equals
|
|
`SourceNode`, and whose identifier is the RawPath for native alarms and the ScriptedAlarmId for
|
|
scripted ones. It is the identity ack/confirm/shelve route on. `SourceName` carries the same
|
|
identifier and is unique, so it is safe to key on *by itself* — but do **not** compose it with
|
|
`ConditionName` (`$"{SourceName}.{ConditionName}"`), because `SourceName` already ends in the
|
|
condition's leaf name and the result stutters (`pymodbus/plc/HR200.HR200`).
|
|
|
|
Wire-level guard: `NativeAlarmEventIdentityFieldDeliveryTests` asserts the three `BaseEventType`
|
|
fields arrive populated on a real subscription using the standard `[EventType, SourceNode,
|
|
SourceName, Time, Message, Severity]` select clause, and — in a second test with its own clause —
|
|
that `ConditionClassId` / `ConditionClassName` do too. The two class fields are declared on
|
|
`ConditionType`, **not** `BaseEventType`, so a client must select them against that type.
|
|
`NodeManagerAlarmSourceFieldsTests` guards the node itself across both realms.
|
|
|
|
> **Do not correlate live events to HistoryRead on `SourceName` — the two paths disagree.**
|
|
> The HistoryRead *events* projection (`OtOpcUaNodeManager.ProjectEventField`) returns
|
|
> `Variant.Null` for `EventType` / `SourceNode` **by design**: it projects from the historian's
|
|
> `HistoricalEvent` rows, which do not carry them. It **does** project `SourceName` — but the
|
|
> alarm-history writer stamps that field with the **EquipmentPath**
|
|
> (`AlarmEventMapper`: `SourceName = alarm.EquipmentPath`), not the RawPath a live event carries.
|
|
> So `SourceName` is the one field populated on both paths **with different values**, and it is not
|
|
> a live↔history join key. Correlate on `ConditionId` / the RawPath instead. (Pre-existing; the
|
|
> live-path fix above does not change the history path.)
|
|
|
|
### Condition child nodes are instance-scoped (#629)
|
|
|
|
Every child of a materialised condition — `ActiveState`, `ActiveState/Id`, `AckedState/Id`,
|
|
`ShelvingState/UnshelveTime`, `Message`, `Severity`, `Retain`, the method nodes, … — carries the NodeId
|
|
**`{ConditionId}/{browse path}`** in the condition's own namespace: e.g.
|
|
`ns=<raw>;s=Plant/Modbus/dev1/temp_hi/ShelvingState/UnshelveTime`, or
|
|
`ns=<uns>;s=<ScriptedAlarmId>/ActiveState/Id`. So `TranslateBrowsePathsToNodeIds` from the condition
|
|
resolves, and a direct Read or data-change subscription on a child returns **this condition's** value.
|
|
|
|
Before #629 the children kept ns=0 ids: `Create(…, assignNodeIds: true)` asks the node manager's
|
|
`INodeIdFactory.New`, and the SDK default returns the node's current id — the condition type's
|
|
instance-declaration id (`i=9189` for UnshelveTime) or an SDK-generated ns=0 number. Every translate of
|
|
a child path returned `BadNoMatch`; a Read of a browsed child id was either `BadNodeIdUnknown` or served
|
|
from the **type** (UnshelveTime read `0` on a condition timed-shelved for a minute). `OtOpcUaNodeManager.New`
|
|
now derives the id. Event delivery was never affected (events copy the instance's fields; clients select
|
|
them by type + browse path), and neither were the Part 9 methods — calling them by the type's method id
|
|
(`MethodIds.AcknowledgeableConditionType_Acknowledge`, …) still works, because the SDK matches a method on
|
|
its declaration id. ⚠️ The ShelvingState methods needed help here: their SDK declaration id is the
|
|
`ShelvedStateMachineType`'s (`i=2948`…), and they were only ever found by the `AlarmConditionType_ShelvingState_*`
|
|
id (`i=9212`…) because that WAS their NodeId. `New` adopts that original id as the method's
|
|
`MethodDeclarationId` (a fixed set of those three ids — no address-space lookup, which would take other
|
|
node managers' locks under ours), so OneShotShelve/TimedShelve/Unshelve by the
|
|
`AlarmConditionType_ShelvingState_*` id keep working, as do calls by the condition's own instance method
|
|
ids (`{ConditionId}/ShelvingState/TimedShelve`, on the condition or its ShelvingState). Calling them by the
|
|
`ShelvedStateMachineType_*` id returns `BadMethodInvalid` — a pinned trade-off.
|
|
|
|
- **ACL:** the slash-joined id makes a child's ACL scope its condition's scope plus the browse path, so
|
|
with `Security:Acl:Enabled` a child is governed exactly like its condition (a grant covering the
|
|
condition covers its children; one that does not, denies them). Before #629 a child was in ns=0 and
|
|
bypassed the data-plane gate entirely.
|
|
- **Removal:** removing or rebuilding a condition forgets its whole subtree, so a removed condition's
|
|
children answer `BadNodeIdUnknown`, not their last value.
|
|
- Guarded over the wire by `ConditionChildNodeIdTests`.
|
|
|
|
### Condition source-data Quality (#477)
|
|
|
|
`ConditionType.Quality` reports the quality of the condition's source data. It was never assigned, and
|
|
because `StatusCodes.Good == 0x00000000` an unassigned `StatusCode` **is** `Good` — so every condition
|
|
reported `Good` unconditionally (a wrong *value*, not a null like #473/#475). A native alarm whose device
|
|
went offline still read `Good`, so an operator could not tell *"genuinely inactive"* from *"we have lost
|
|
contact and do not know"*.
|
|
|
|
**How it is driven now (native alarms).** An alarm-bearing raw tag materializes a condition with **no
|
|
sibling value variable**, so the value/quality path (`WriteValue`) never touches it, and a comms-lost
|
|
driver emits **no alarm transitions** (the feed goes silent). The quality therefore comes from the
|
|
**driver's connectivity**, out of band from alarm transitions:
|
|
|
|
- `DriverInstanceActor` Tells its host `ConnectivityChanged(driverInstanceId, connected)` on every
|
|
transition into `Connected` (`true`) / `Reconnecting` (`false`).
|
|
- `DriverHostActor.OnDriverConnectivityChanged` fans that out to **every** native condition the driver
|
|
owns as an `OpcUaPublishActor.AlarmQualityUpdate` (`Good` on connect, `Bad` on disconnect).
|
|
- `OtOpcUaNodeManager.WriteAlarmQuality` sets **only** the condition's `Quality` and fires a Part 9
|
|
event **only on a quality-bucket change** — it never touches Active/Acked/Severity/Retain (an active
|
|
alarm that loses comms stays active). This is a dedicated path, *not* a full-snapshot re-projection, so
|
|
it cannot clobber a condition's severity/message and works for a condition that never fired a transition.
|
|
- A freshly materialized native condition starts `BadWaitingForInitialData` (the "no driver data yet"
|
|
convention value variables use); the first `Connected` confirms it `Good`.
|
|
- The connectivity annotation is **ungated by redundancy role** (a Secondary keeps its condition quality
|
|
warm for failover) and publishes **no `/alerts` row** — driver comms health already has its own status
|
|
surface (`IDriverHealthPublisher`); a row per condition would be alarm-fatigue.
|
|
|
|
**Scripted alarms (Layer 3, #478).** A scripted condition's state is computed from one or more input tags,
|
|
so its `Quality` is the **worst** quality across those inputs at evaluation time ("can I trust this
|
|
condition's state?") — mirroring the native OT semantic:
|
|
|
|
- The mux now forwards each input's source quality (`DependencyValueChanged.Quality`), and the scripted host
|
|
pushes it into the engine's read cache (previously every mux value was treated as `Good`).
|
|
- The `ScriptedAlarmEngine` computes the worst input quality each evaluation. A **real transition** carries
|
|
it on the emitted event → `ScriptedAlarmHostActor.ToSnapshot` projects it (so a transition fired while an
|
|
input is `Uncertain` does not clobber quality back to `Good`).
|
|
- A **Bad input freezes the condition** (`AreInputsReady` holds its state — no transition), exactly like a
|
|
comms-lost native driver. So a quality-bucket change with no transition is emitted as
|
|
`EmissionKind.QualityChanged` and routed to the **same** dedicated `AlarmQualityUpdate → WriteAlarmQuality`
|
|
node path native uses (quality only, one Part 9 event on a bucket change, **no `/alerts` row**, no
|
|
historian write). `ScriptedAlarmSource` skips `QualityChanged` so it never fabricates a phantom
|
|
`IAlarmSource` event.
|
|
- An input that has **not been published yet** (cold start) is *not* a quality signal (that is the readiness
|
|
guard's job) — it contributes `Good`, so scripted conditions don't flash `Bad` at every deploy. The first
|
|
actually-`Bad` published value flips the bucket and annotates.
|
|
|
|
**Comms loss (Layer 4, #481).** #478 covered input tags whose driver **publishes a data change carrying a
|
|
Bad/Uncertain `StatusCode`** (e.g. an OpcUaClient input forwarding a server's per-item Bad). It did not
|
|
cover a driver **comms loss**: a poll driver (Modbus/S7) whose device goes unreachable emits only
|
|
`ConnectivityChanged` and goes *silent* on the value feed (see `DriverInstanceActor.Reconnecting`), so the
|
|
engine kept the last-known Good value, `WorstInputStatus` stayed Good — and worse, `AreInputsReady` stayed
|
|
true, so the predicate went on evaluating a stale value after the source had died.
|
|
|
|
`DriverHostActor.FanCommsLossToDependencyMux` closes it, symmetrically with the native
|
|
`OnDriverConnectivityChanged` path above: on `ConnectivityChanged(false)` it pushes a synthetic
|
|
`BadNoCommunication` with a **null value** into the dependency mux for every value ref the driver owns,
|
|
resolved from the existing `_nodeIdByDriverRef` map. Alarm-bearing tags are deliberately excluded — a
|
|
native condition is not a mux value, and its comms quality goes through the Part 9 annotation instead.
|
|
|
|
Two things worth knowing before changing this:
|
|
|
|
- **Nothing is pushed on reconnect, deliberately** — publishing Good would assert a value quality the host
|
|
has not observed. Recovery rides the driver's own republish: the `Connected → Reconnecting` edge calls
|
|
`DetachSubscription`, so the reconnect's `ResubscribeDesired` builds a *fresh* subscription whose
|
|
`PollGroupEngine` initial push runs with `forceRaise: true` and re-publishes every tag with its real
|
|
quality. That detach is load-bearing — without it the poll engine would compare against its retained
|
|
`LastValues`, see an unchanged value, raise nothing, and strand those refs Bad forever.
|
|
- **Virtual tags computed from a comms-lost input now go Bad too.** That follows from using the shared mux
|
|
and is the correct reading of "this input's source is unreachable", but it is a behaviour change beyond
|
|
scripted alarms.
|
|
|
|
**The signal this hangs off now fires for a mid-run device loss (#527, closed).** The #481 live gate on
|
|
docker-dev found that `DriverInstanceActor.DisconnectObserved` had **no production sender**: the only paths
|
|
into `Reconnecting` were a failed *initial* connect and an operator Force Reconnect. A Modbus driver whose
|
|
fixture was stopped stayed `Connected` for >10 minutes publishing per-tag Bad, and did not recover when the
|
|
fixture came back until the node was restarted. So the fan-out above — and the native #477 Layer 2
|
|
annotation it mirrors — were correctly wired to a signal that a dying device never raised. #527 added the
|
|
comms-loss watchdog on the Connected health tick, which self-sends `DisconnectObserved` once a driver has
|
|
gone 90 s without a successful read *while* reporting itself non-Healthy. See
|
|
[`DriverLifecycle.md`](DriverLifecycle.md) §"The comms-loss watchdog" for why the trip condition is that
|
|
conjunction and not simply "non-Healthy for N polls" — the naive rule would reconnect a healthy device's
|
|
driver every 90 s forever whenever one authored address is unreadable.
|
|
|
|
Note the partial cover that existed before, because it explains why *some* of this appeared to work: Modbus
|
|
publishes Bad *per tag* rather than going silent, so #478 Layer 3 did carry Bad into scripted input quality
|
|
for that driver. That was a per-driver accident, not a guarantee, and it did nothing for native conditions
|
|
or for recovery.
|
|
|
|
**Cold start vs. a driver reporting Bad (#478 Finding 3, closed by #481).** `WorstInputStatus` used to skip
|
|
every input whose *value* was null as "no data yet". A comms-lost driver produces exactly that shape — Bad
|
|
with no value — so the skip was hiding the case that matters most. The upstream source now stamps a
|
|
never-pushed path `BadWaitingForInitialData` and nothing else does, so the engine skips *that specific
|
|
code* rather than every null. Deleting the null-skip outright would have been the obvious fix and is wrong
|
|
in the other direction: it makes every not-yet-published input drag its condition Bad at deploy, which is
|
|
what the skip existed to prevent. Both failure modes are pinned — see the engine-test pair below, each of
|
|
which goes red under the opposite mistake.
|
|
|
|
Guards: `ScriptedAlarmEngineTests` (transition carries `Uncertain`; `Bad` input with no transition emits
|
|
`QualityChanged(Bad)`; restore emits `QualityChanged(Good)`; unchanged bucket emits nothing; plus the #481
|
|
pair — a pushed Bad carrying no value counts, an input never pushed does not),
|
|
`DriverHostActorLiveValueTests` (comms loss pushes Bad for every ref the driver owns, reconnect pushes
|
|
nothing, another driver's loss pushes nothing),
|
|
`DependencyMuxTagUpstreamSourceTests.The_engine_agrees_with_this_source_on_the_never_pushed_status_code`,
|
|
`ScriptedAlarmSourceTests.QualityChanged_emission_raises_no_alarm_event`,
|
|
`DependencyMuxActorTests.Publish_quality_is_forwarded_on_DependencyValueChanged`, and
|
|
`ScriptedAlarmHostActorTests` (`Bad_quality_dependency_publishes_AlarmQualityUpdate_and_no_alerts`,
|
|
`Transition_snapshot_carries_worst_input_quality`).
|
|
|
|
Wire-level guard: `NativeAlarmEventIdentityFieldDeliveryTests.Condition_event_Quality_tracks_source_connectivity_on_the_wire`
|
|
subscribes with a `[Quality, Message]` clause (Quality is declared on `ConditionType`) and asserts a
|
|
healthy source reports `Good`, a comms-lost source reports non-`Good`, and recovery returns to `Good` — on
|
|
a real client subscription. `NodeManagerAlarmSourceFieldsTests` guards the node itself + the no-clobber /
|
|
unknown-node-no-op invariants.
|
|
|
|
## Galaxy driver path (driver-native)
|
|
|
|
Restored in PR B.2 of the epic. `GalaxyDriver` implements
|
|
`IAlarmSource` with these surfaces:
|
|
|
|
- `SubscribeAlarmsAsync(sourceNodeIds)` → returns a sentinel handle.
|
|
The driver doesn't multiplex per source-node-id today; every
|
|
active handle observes the gateway's alarm-event stream. The
|
|
server-side `AlarmConditionService` filters by source-node before
|
|
raising the OPC UA condition.
|
|
- `UnsubscribeAlarmsAsync(handle)` → symmetric handle removal.
|
|
- `AcknowledgeAsync(requests)` → routes one gateway RPC per
|
|
acknowledgement through `IGalaxyAlarmAcknowledger`. Production
|
|
uses `GatewayGalaxyAlarmAcknowledger` calling
|
|
`MxGatewayClient.AcknowledgeAlarmAsync` (PR E.2 SDK method).
|
|
- `OnAlarmEvent` → bridges `EventPump.OnAlarmTransition` (PR B.1)
|
|
onto `AlarmEventArgs`. Suppressed when no alarm subscription is
|
|
active so untracked transitions don't leak through.
|
|
|
|
The proto contract carries the rich payload — alarm full reference,
|
|
source-object reference, alarm-type-name, transition kind (Raise /
|
|
Acknowledge / Clear / Retrigger), severity (raw MxAccess scale),
|
|
original raise timestamp, transition timestamp, operator user,
|
|
operator comment, alarm category, description. `MxAccessSeverityMapper`
|
|
(PR B.1) translates the raw severity onto the four-bucket
|
|
`AlarmSeverity` ladder — boundaries match v1's `GalaxyAlarmTracker`
|
|
so customers see no surprise re-classification.
|
|
|
|
The richer fields surface on `Core.Abstractions.AlarmEventArgs` via
|
|
the optional properties added in PR E.7 (`OperatorComment`,
|
|
`OriginalRaiseTimestampUtc`, `AlarmCategory`). Consumers that don't
|
|
need them are unaffected; consumers that do (Client.UI, Client.CLI
|
|
verbose mode) read the new fields when present.
|
|
|
|
## MTConnect CONDITION path (driver-native, #626)
|
|
|
|
An MTConnect raw tag bound to a `CONDITION` DataItem becomes a native condition when its `TagConfig`
|
|
carries the standard `alarm` object (opt-in; without it the tag stays the String state word). Normal →
|
|
inactive, Warning → active 500, Fault → active 800, Unavailable → state unchanged (an active condition stays
|
|
active and acked — it is comms loss one hop out, the #477/#481 posture) with `BadNoCommunication`
|
|
quality. Two optional `AlarmEventArgs` fields added for it are generic: **`OpcUaSeverity`** (an exact
|
|
1..1000 severity that supersedes the four-bucket mapping in `NativeAlarmProjector`) and
|
|
**`QualityStatusCode`** (projected onto the condition's `Quality` on a transition; with an `Unspecified`
|
|
kind it is a **quality-only annotation** — `DriverHostActor` routes it through the same
|
|
`AlarmQualityUpdate` → `WriteAlarmQuality` path as the connectivity annotation, which touches Quality and
|
|
nothing else, and publishes no `/alerts` row).
|
|
Acknowledge is server-side only (MTConnect has no ack; the driver's `AcknowledgeAsync` is a no-op).
|
|
The driver diffs against what the server was last told, so a reconnect's replayed `/current` never
|
|
re-raises (and so un-acknowledges) an unchanged condition. Full mapping:
|
|
[`drivers/MTConnect.md` §CONDITION alarms](drivers/MTConnect.md#condition-alarms-626).
|
|
|
|
## Galaxy sub-attribute fallback
|
|
|
|
For Galaxy templates without `$Alarm*` extensions, the value-driven
|
|
path stays in place: `DriverNodeManager` registers an
|
|
`AlarmConditionState` per Galaxy variable that bears alarm-bearing
|
|
sub-attributes (`InAlarm`, `Acked`, `Priority`, `Description`),
|
|
subscribes to those sub-attributes, and synthesizes Part 9 transitions
|
|
when the values change. This path operated as the only Galaxy alarm
|
|
path between PR 7.2 and the alarms-over-gateway epic; it remains the
|
|
fallback today.
|
|
|
|
When both paths report the same condition,
|
|
`AlarmConditionService.AlarmConditionState` keeps the
|
|
driver-native record and discards the duplicate sub-attribute
|
|
synthesis. Driver-native transitions are richer (carry operator
|
|
comment + original raise time) and arrive lower-latency (no
|
|
publishing-interval delay on the sub-attribute reads), so they win
|
|
the dedup.
|
|
|
|
## Acknowledge routing — Galaxy / driver alarms
|
|
|
|
### Native alarm acknowledge → AVEVA
|
|
|
|
When an OPC UA client Acknowledges a **native** (driver-fed, e.g. Galaxy)
|
|
`AlarmConditionState` node, the node manager's `OnAcknowledge` handler
|
|
branches on native-ness and routes through a dedicated path — separate
|
|
from the scripted `AlarmCommandRouter`:
|
|
|
|
1. **`OtOpcUaNodeManager.HandleNativeAlarmAck`** — gates on the caller's
|
|
`AlarmAck` role (fails closed: no role → `BadUserAccessDenied`), then on the
|
|
node's redundancy role (not the Primary → `BadInvalidState`, see
|
|
§"Acknowledge on a non-Primary node" below), then dispatches a `NativeAlarmAck(ConditionNodeId, Comment, OperatorUser)`
|
|
to the `NativeAlarmAckRouter` seam (fire-and-forget, non-blocking under
|
|
the node-manager Lock). `OperatorUser` carries the authenticated
|
|
session principal's display name.
|
|
2. **`DriverHostActor.HandleRouteNativeAlarmAck`** — receives a
|
|
`RouteNativeAlarmAck` message (the host maps `NativeAlarmAck` at the
|
|
wiring boundary to keep Runtime Akka-free of the OPC UA layer). Applied
|
|
**Primary-gate first**: a Secondary or Detached node drops the message
|
|
silently. On Primary, resolves the condition NodeId from the
|
|
`_driverRefByAlarmNodeId` inverse map (NodeId → `(DriverInstanceId,
|
|
FullName)`) and Tells the owning `DriverInstanceActor` a
|
|
`RouteAlarmAck(FullName, Comment, OperatorUser)`.
|
|
3. **Galaxy driver** — `DriverInstanceActor` calls the driver's
|
|
`IAlarmSource.AcknowledgeAsync` with an `AlarmAcknowledgeRequest`
|
|
carrying the authored `FullName` as the `ConditionId` and the
|
|
authenticated `OperatorUser`. The driver forwards this to the Galaxy
|
|
gateway → AVEVA via `GatewayGalaxyAlarmAcknowledger`.
|
|
The Galaxy adapter awaits the correlated `AcknowledgeAlarmAsync` RPC:
|
|
gateway protocol failures throw, and a non-zero native return code is
|
|
warning-logged and thrown as a refusal (#760). **A failed ack is reverted,
|
|
not surfaced on the call** — the OPC UA client's local
|
|
`AlarmConditionState` update already committed at step 1, so when the
|
|
driver refuses or fails the ack (or is still connecting / reconnecting),
|
|
`DriverInstanceActor` replies `AlarmAckFailed` and
|
|
`DriverHostActor` re-projects the condition **unacknowledged** (a fresh
|
|
condition event). An ack whose owning driver is not running (disabled,
|
|
not yet spawned) or is a stub on this node is reverted the same way by
|
|
the host itself (#765). Only an ack still pending in `NativeAlarmProjector` is
|
|
reverted: a driver transition projected since then is authoritative. A
|
|
refusal the driver can decide without I/O (Galaxy: an anonymous session
|
|
and no fallback ack account) is instead returned synchronously as
|
|
`BadUserAccessDenied` by the node manager's `NativeAlarmAckPreflight`
|
|
before anything is applied — as is every native ack while the driver host
|
|
is Stale (configuration database unreachable at boot), when no ack can be
|
|
routed or reverted (#765). Galaxy value writes have a separate correlated
|
|
write-outcome path. An awaited
|
|
ack RPC does not prove the ack reached AVEVA event history: the H6 live
|
|
verification remains [operator-gated](plans/2026-06-15-stillpending-phase-3-opcua-standards-design.md).
|
|
|
|
Only the **Acknowledge** is routed to the driver. **A native condition
|
|
supports Acknowledge and AddComment only** (Gitea #587 review): `Confirm`,
|
|
`OneShotShelve` / `TimedShelve` / `Unshelve` and `Enable` / `Disable` return
|
|
`BadNotSupported` and report no event. There is no upstream path for them and
|
|
no pair replication of a local state, and the driver projection
|
|
(`NativeAlarmProjector`: always `Confirmed`, `Unshelved`) would silently undo
|
|
one at the next transition. `AddComment` stays on the scripted
|
|
`AlarmCommandRouter` path (Phase 3 scope is Acknowledge → AVEVA only) and is
|
|
applied locally.
|
|
|
|
A native condition is **never Unconfirmed**: it starts Confirmed, and because
|
|
the SDK's own Acknowledge sets `ConfirmedState = false` on any condition type
|
|
that supports Confirm, the node manager re-asserts `Confirmed = true` (and the
|
|
projection's Retain rule, `Active || !Acked`) after the SDK handler, before the
|
|
one ack event. ScadaBridge maps `ConfirmedState` into its alarm state, so this
|
|
is client-visible.
|
|
|
|
> **Acknowledge on a non-Primary node is refused** (Gitea #597). On a node
|
|
> that is not the redundancy Primary, an OPC UA Acknowledge of a **native**
|
|
> condition returns **`BadInvalidState`** ("not the redundancy Primary;
|
|
> acknowledge on the Primary") — nothing is routed, applied or reported. The
|
|
> verdict is the same data-plane one the device-write gate and
|
|
> `DriverHostActor`'s ack route use (`PrimaryGatePolicy.ShouldServiceAsPrimary`,
|
|
> published to `IDriverPrimaryRoleSource` on every redundancy snapshot and
|
|
> wired onto the node manager by `OtOpcUaServerHostedService`). It is checked
|
|
> after authorization, so a caller who may not ack learns nothing about the
|
|
> node's role.
|
|
>
|
|
> This replaces a documented flap: the Secondary used to apply and report the
|
|
> ack locally while `DriverHostActor` dropped the route upstream, so the driver
|
|
> never acknowledged, its next transition projected `Acked = false`, and the
|
|
> client saw acked → unacked. An honest refusal is client-visible — a client
|
|
> (ScadaBridge included) that acks against the Secondary now gets a Bad result
|
|
> instead of a Good that later reverts — so ack against the Primary (the higher
|
|
> ServiceLevel). Scripted conditions are **not** gated: their engine runs on
|
|
> both nodes, so an ack on the Secondary is real. The published verdict
|
|
> mirrors `DriverHostActor`'s live check from the moment the host starts, not
|
|
> only from the first redundancy snapshot: the host publishes it once at start
|
|
> and again whenever cluster membership changes, so in the boot window of a
|
|
> pair (role still unknown, a driver peer Up) the gate already denies, exactly
|
|
> as the route does (review R1 m2 — previously the view sat on its single-node
|
|
> "allow" seed until the first snapshot, reopening the flap for that window).
|
|
> It mirrors, it does not deny from start: until this node has **joined** and
|
|
> sees its peer Up, its member count is 0 and both the published verdict and the
|
|
> live route **allow** (seconds normally; up to the bootstrap guard's 25 s
|
|
> partner probe). The two agree there, so the flap cannot occur, but a native
|
|
> ack in that pre-join window is serviced (review R6 MINOR-2).
|
|
|
|
> **Best-effort during reconnect.** If a native ack arrives while the owning
|
|
> driver is `Connecting`/`Reconnecting`, it is dropped with a debug log and **not**
|
|
> retried upstream — the OPC UA Part 9 `AlarmConditionState` was already
|
|
> committed locally at step 1, so the rare outcome is "acked locally but not
|
|
> propagated to AVEVA." Re-issue the ack once the driver is reconnected if
|
|
> upstream propagation is required — a re-ack of a node already showing Acked
|
|
> is still routed to the driver for exactly this reason.
|
|
|
|
### Legacy sub-attribute path
|
|
|
|
`DriverNodeManager` picks the acknowledger when registering each
|
|
condition (PR B.3 logic):
|
|
|
|
- Driver implements `IAlarmSource` →
|
|
`DriverAlarmSourceAcknowledger` routes the operator comment
|
|
through `IAlarmSource.AcknowledgeAsync` via the existing
|
|
`AlarmSurfaceInvoker` (Phase 6.1 resilience pipeline; no-retry
|
|
per decision #143). End-to-end operator-comment fidelity is
|
|
preserved.
|
|
- Driver doesn't implement `IAlarmSource` →
|
|
`DriverWritableAcknowledger` writes the comment into the
|
|
`AckMsgWriteRef` sub-attribute via `IWritable.WriteAsync`. Same
|
|
resilience pipeline; collapses the comment into a single string
|
|
write at the wire level.
|
|
|
|
The OPC UA Part 9 `AlarmConditionState.OnAcknowledge` delegate
|
|
already validates the session's `AlarmAck` role before dispatching,
|
|
so the gateway-side ack RPC only sees authenticated, authorised
|
|
calls.
|
|
|
|
## Inbound operator ack/shelve — scripted alarms
|
|
|
|
Scripted alarms use a separate inbound path that converges on the
|
|
`alarm-commands` DPS topic. Two surfaces route onto this topic:
|
|
|
|
### OPC UA Part 9 method path (external OPC UA clients)
|
|
|
|
`OtOpcUaNodeManager` wires the Part 9 condition methods (Acknowledge /
|
|
Confirm / AddComment / OneShotShelve / TimedShelve / Unshelve) on each
|
|
scripted-alarm `AlarmConditionState` node. Every call is **gated on the
|
|
`AlarmAck` role** — fail-closed: sessions with no role or without
|
|
an `AlarmAck`-conferring grant receive `BadUserAccessDenied` immediately.
|
|
The session's roles come from its API-key principal's `NodeAcl` grants (or the
|
|
reserved `anonymous` principal's; LDAP is AdminUI-only — `docs/security.md`) and are carried past
|
|
`OpcUaApplicationHost` by
|
|
`RoleCarryingUserIdentity` (a `UserIdentity` subclass), making it
|
|
readable inside the method handler at dispatch time.
|
|
|
|
On allow, the handler publishes a `Commons.OpcUa.AlarmCommand` onto the
|
|
`alarm-commands` DPS topic. The node manager is Akka-free; the dispatch
|
|
action is a settable `Action<AlarmCommand>` injected at boot by the
|
|
hosted service.
|
|
|
|
`OnTimedUnshelve` (the SDK's automatic unshelve timer) is a **no-op** — it
|
|
routes nothing (review R1 M2). The engine owns timed-shelve expiry (its 5 s
|
|
shelving check records `AutoUnshelve` by `system`), and its re-projection
|
|
unshelves the node; see §"`UnshelveTime` is the real remaining time" below.
|
|
|
|
### One condition event per method call (Gitea #587)
|
|
|
|
Every accepted Part 9 method call — Acknowledge, Confirm, AddComment,
|
|
OneShotShelve, TimedShelve, Unshelve, Enable, Disable, on a native or a
|
|
scripted condition — reports **exactly one** condition event carrying the
|
|
new state, to folder subscribers and (through the ACL) Server-object
|
|
subscribers alike. A refused call (bad status, e.g. a denied gate, an
|
|
already-acknowledged condition, Enable/Disable on a native condition)
|
|
reports nothing.
|
|
|
|
- **The SDK does not report it.** `ConditionState.ReportStateChange` is
|
|
gated on `AutoReportStateChanges`, which this repo leaves `false` — so
|
|
before #587 a client Acknowledge produced no condition event at all (an
|
|
older comment claimed "the SDK auto-fires its own event"; it never did).
|
|
Instead the node manager wraps each method's SDK handler
|
|
(`WireMethodTransitionReporting`) and, once the handler returns Good,
|
|
reports the condition itself. That report does not depend on the SDK's
|
|
`AreEventsMonitored` bookkeeping, which is imprecise for conditions
|
|
materialised under an already-subscribed folder.
|
|
- **The SDK's shelve methods never apply shelving state** (they only call
|
|
`OnShelve`), so the wrapper applies it — with the same `(shelved, oneShot)`
|
|
mapping the projection uses, and a TimedShelve with its requested duration —
|
|
before reporting.
|
|
- **The re-projection does not duplicate it.** The engine's (or driver's)
|
|
answer to the routed command reaches `WriteAlarmCondition`'s delta-gate,
|
|
which compares against the node's current state — already the new state —
|
|
and suppresses. The whole method call runs under the node-manager lock, so
|
|
a fast re-projection cannot slip in between the SDK applying the state and
|
|
the report.
|
|
- **Only a real change is reported.** The condition's state is compared
|
|
before and after the SDK handler; a call that changed nothing reports
|
|
nothing. The SDK accepts acknowledging an acknowledged condition and sets
|
|
`Confirmed = false` again, so a re-ack also restores the pre-call Confirmed
|
|
and Retain — silent, and a scripted node never drifts from its engine.
|
|
AddComment always reports (the comment is the change), and so does a re-ack
|
|
**carrying a comment** (Gitea #597): the SDK writes that comment to the node,
|
|
so it is reported once, with Acked/Confirmed unchanged. Before #597 the
|
|
comment landed on the node but no subscriber ever saw it. For a **scripted**
|
|
condition the comment is also durable (review R1 m1): the routed Acknowledge
|
|
reaches an engine that is already acknowledged, and
|
|
`Part9StateMachine.ApplyAcknowledge` records a commented re-ack exactly as an
|
|
AddComment (kind `AddComment`, the caller as user) — persisted and replicated
|
|
with the condition state, the original ack's `LastAck*` fields untouched. An
|
|
uncommented re-ack is still a no-op in the engine. The same applies to a
|
|
commented re-ack from the AdminUI `/alerts` page.
|
|
- **A native re-ack is still routed to the driver** (deliberate, #597). The
|
|
route is fire-and-forget, so a node showing Acked does not prove the
|
|
upstream system acknowledged — an ack the driver reports as failed is
|
|
reverted (#760), but one lost without a report (e.g. a node stopped mid-ack)
|
|
is not — and re-acking is the documented retry (§"Best-effort
|
|
during reconnect"). The cost is a duplicate ack upstream for an alarm that
|
|
was already acknowledged there.
|
|
- **Native conditions: Acknowledge and AddComment only** — see §"Native alarm
|
|
acknowledge → AVEVA" above. The other methods are refused
|
|
(`BadNotSupported`, no event), and a native ack keeps the condition
|
|
Confirmed.
|
|
- **TimedShelve works (scripted).** `Create()` left `MaxTimeShelved` at 0, and
|
|
the SDK rejects any shelving time above it, so every client TimedShelve
|
|
failed with `BadShelvingTimeOutOfRange`. It is now `int.MaxValue` ms (≈24.8
|
|
days). The engine owns the expiry and its Unshelved re-projection reports
|
|
the one expiry event.
|
|
- **`UnshelveTime` is the real remaining time** (Gitea #597). Both the method
|
|
path and the projection used to apply a timed shelve with `shelvingTime 0`,
|
|
which the SDK reads as `MaxTimeShelved` — so every timed shelve advertised
|
|
≈24.8 days remaining. The method now applies the requested duration, and the
|
|
engine's projection carries its expiry (`AlarmConditionSnapshot.UnshelveAtUtc`),
|
|
so `ShelvingState/UnshelveTime` counts down to the engine's expiry whichever
|
|
path shelved it (the AdminUI `/alerts` timed shelve included). This arms the
|
|
SDK's own unshelve timer at the real instant, and `OnTimedUnshelve` — the
|
|
timer's callback — is deliberately a **no-op** (review R1 M2): it used to
|
|
route an operator-style `Unshelve` by `system`, which, firing before the
|
|
engine's 5 s expiry sweep, pre-empted the engine's `AutoUnshelve` on every
|
|
expiry, from both pair nodes, rewriting the audit trail as a manual unshelve.
|
|
The SDK's timer changes no shelving state itself, so the node stays
|
|
TimedShelved (with `UnshelveTime` at zero) for up to one sweep after expiry,
|
|
until the engine's re-projection reports the one expiry event.
|
|
- **Accepted, not fixed — the SDK's `EventsMonitored` unit for late conditions**
|
|
(Gitea #597 item 6, from the #586 review). The SDK's `AreEventsMonitored`
|
|
counter never reaches a condition materialised under a folder that already had
|
|
an event subscriber. Our own reporting does not depend on it (above); the one
|
|
thing it still gates is the SDK's method **audit** event, and only while the
|
|
server is auditing — which no configuration in this repo enables. Closed as
|
|
accepted.
|
|
- Method **audit** events (`AuditConditionAcknowledgeEventType`, …) are still
|
|
the SDK's, delivered only while the server is auditing over an encrypted
|
|
channel.
|
|
|
|
Engine-driven transitions are unaffected: `WriteAlarmCondition` still fires
|
|
whenever the incoming state differs from the node's current state.
|
|
|
|
### AdminUI path
|
|
|
|
The `/alerts` page shows per-row **Acknowledge / Shelve / Unshelve**
|
|
buttons gated by the `DriverOperator` AdminUI policy. These route
|
|
through the `AdminOperationsActor` cluster singleton
|
|
(`AcknowledgeAlarmCommand` / `ShelveAlarmCommand`), which publishes onto
|
|
the same `alarm-commands` topic. The singleton handles cross-node
|
|
routing — the command always reaches the driver-role node owning the
|
|
engine regardless of which AdminUI instance the operator is on.
|
|
|
|
### ScriptedAlarmHostActor dispatch
|
|
|
|
`ScriptedAlarmHostActor` subscribes to the `alarm-commands` topic,
|
|
ownership-filters each command (each node only acts on its own alarms),
|
|
and dispatches to the matching `ScriptedAlarmEngine` operation
|
|
(`AcknowledgeAsync` / `ConfirmAsync` / `OneShotShelveAsync` /
|
|
`TimedShelveAsync` / `UnshelveAsync` / `EnableAsync` / `DisableAsync` /
|
|
`AddCommentAsync`). The engine's existing `OnEvent` callback handles
|
|
the OPC UA node update — no explicit re-projection is required.
|
|
|
|
The AdminUI `/alerts` Shelve flow was live-verified on docker-dev
|
|
2026-06-11: singleton → topic → host actor → engine → "Shelved" status
|
|
reflected on `/alerts` with the operator identity threaded through.
|
|
|
|
## Redundancy deduplication
|
|
|
|
Under warm/hot redundancy, both cluster nodes run `ScriptedAlarmHostActor` and the scripted-alarm engine. To prevent duplicate `/alerts` rows and duplicate historian writes, alarm transition publication to the `alerts` topic and `HistorianAdapterActor` historization are **Primary-gated**: only the node whose `RedundancyRole` is `Primary` publishes externally. OPC UA condition-node writes and inbound ack/shelve processing remain ungated on both nodes so the secondary stays warm for failover. See [Redundancy.md §Primary-gated alarm emission and historization](Redundancy.md#primary-gated-alarm-emission-and-historization).
|
|
|
|
## Historian write-back (non-Galaxy alarms)
|
|
|
|
Scripted alarms (and any future non-Galaxy `IAlarmSource` like
|
|
AB CIP ALMD) route to AVEVA Historian via the HistorianGateway:
|
|
|
|
- `IAlarmHistorianSink` is the DI-registered intake contract. The
|
|
default binding is `NullAlarmHistorianSink` (registered in
|
|
`ServiceCollectionExtensions.AddOtOpcUaRuntime`). Production
|
|
deployments override it with `LocalDbStoreAndForwardSink` wrapping
|
|
`GatewayAlarmHistorianWriter` (the HistorianGateway `SendEvent` path)
|
|
— see [ServiceHosting.md](ServiceHosting.md) for the HistorianGateway setup.
|
|
- `LocalDbStoreAndForwardSink` queues each transition into the node's
|
|
consolidated LocalDb — where it **replicates to the redundant pair peer**,
|
|
so undelivered alarm history survives losing the node holding it — and
|
|
drains in the background via an `IAlarmHistorianWriter`. **Only the node
|
|
holding the Primary role drains**, since the peer holds a full replica;
|
|
delivery is consequently at-least-once across a failover, by design. See
|
|
[AlarmHistorian.md](AlarmHistorian.md). **The durability guarantee is bounded**: the
|
|
queue capacity defaults to 1,000,000 rows; under a sustained
|
|
historian outage, older non-dead-lettered rows are evicted (oldest
|
|
first) to make room for new events. The `HistorianSinkStatus.EvictedCount`
|
|
counter surfaces lifetime eviction events so operators can detect
|
|
silent data loss without log scraping. The drain cadence, queue
|
|
capacity, and dead-letter retention are tunable via the `AlarmHistorian`
|
|
config section (`DrainIntervalSeconds`, `Capacity`,
|
|
`DeadLetterRetentionDays`); `AlarmHistorianOptions.Validate()` logs a
|
|
startup warning for a non-positive knob. (It no longer warns about
|
|
`SharedSecret` or `DatabasePath` — both keys are gone: the connection
|
|
comes from `ServerHistorian`, and the queue lives in the LocalDb.)
|
|
- `HistorianAdapterActor`
|
|
(`src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/HistorianAdapterActor.cs`)
|
|
subscribes to the cluster `alerts` DPS topic, translates each
|
|
`AlarmTransitionEvent` → `AlarmHistorianEvent`, and calls
|
|
`EnqueueAsync` fire-and-forget. The durable write is gated two ways:
|
|
(1) **Primary-gated** — only the Primary node historizes, giving
|
|
exactly-once writes across a redundant pair; (2) **per-alarm** — a
|
|
transition whose `HistorizeToAveva` is `false` is skipped (the flag
|
|
rides on `AlarmTransitionEvent` as a nullable bool; missing/`null`/`true`
|
|
historize, only an explicit `false` suppresses, so a cross-version
|
|
rolling restart defaults to historizing rather than dropping an audit
|
|
row). Neither gate touches the live `alerts` publish — the `/alerts`
|
|
UI always sees every transition. See
|
|
[AlarmHistorian.md §Configuration](AlarmHistorian.md#configuration)
|
|
for the `AlarmHistorian` appsettings section that enables the real sink.
|
|
|
|
**Native alarms** (equipment tags carrying an `"alarm"` object in their `TagConfig`)
|
|
support the same `HistorizeToAveva` opt-out. The field is `alarm.historizeToAveva`
|
|
(`bool?`) and is authored via the **"Historize to AVEVA"** checkbox in the Tag modal's
|
|
alarm section. The gate logic is identical (`is not false`): absent or `true` historizes;
|
|
explicit `false` suppresses the AVEVA write while leaving the live `/alerts` feed
|
|
unaffected. See
|
|
[ScriptedAlarms.md §TagConfig alarm fields](ScriptedAlarms.md#tagconfig-alarm-fields)
|
|
for the full field reference.
|
|
|
|
Galaxy-native alarms with `$Alarm*` extensions reach AVEVA Historian
|
|
directly via System Platform's `HistorizeToAveva` toggle on the
|
|
alarm primitive — no involvement from OtOpcUa. This sidecar path is
|
|
exclusively for non-Galaxy alarm producers.
|
|
|
|
## Cross-references
|
|
|
|
- Plan: [docs/plans/alarms-over-gateway.md](plans/alarms-over-gateway.md)
|
|
- v1 archive: [docs/v1/AlarmTracking.md](v1/AlarmTracking.md)
|
|
- Galaxy driver: [docs/drivers/Galaxy.md](drivers/Galaxy.md)
|
|
- Phase 7 scripting + alarming: [docs/v2/implementation/phase-7-scripting-and-alarming.md](v2/implementation/phase-7-scripting-and-alarming.md)
|
|
- Security + ACL: [docs/security.md](security.md)
|