66c47c3ddd
New() ran FindNodeInAddressSpace under Lock, taking other node managers' locks (lock-order risk); the three AlarmConditionType_ShelvingState_* ids are now a static set. Adds wire tests: TimedShelve/Unshelve by the condition's own instance method ids (object = condition or its ShelvingState) succeed and report, and a ShelvedStateMachineType_* id call is pinned as BadMethodInvalid with a positive control. Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
641 lines
43 KiB
Markdown
641 lines
43 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` |
|
|
| **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.
|
|
|
|
## 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.
|
|
|
|
## 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`.
|
|
**Fire-and-forget** — a failed upstream ack is not surfaced back to the
|
|
OPC UA client (mirrors the Galaxy write-outcome limitation; the local
|
|
`AlarmConditionState` SDK update already committed at step 1).
|
|
|
|
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 dropped during a reconnect leaves
|
|
exactly that state — 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)
|