Compare commits

..

8 Commits

Author SHA1 Message Date
dohertj2 2cae4c8f01 Merge pull request 'feat(alarms): scripted condition Quality from worst-of-input tag quality (#478)' (#480) from feat/scripted-alarm-quality-478 into master
v2-ci / build (push) Successful in 5m30s
v2-ci / unit-tests (push) Failing after 12m49s
2026-07-17 16:08:06 -04:00
Joseph Doherty 043e237dba docs(alarms): state #478 coverage boundary + file Layer-4 comms-loss follow-up (#481)
v2-ci / build (pull_request) Successful in 5m42s
v2-ci / unit-tests (pull_request) Failing after 13m11s
Post-implementation review (HIGH finding) noted #478's mux-delivered
input-quality path does 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, so a scripted alarm keeps the last Good value.
The code as shipped faithfully implements #478's written scope (worst of input
tags' qualities via the dependency mux). The comms-loss bridge for scripted
alarms (symmetric of native #477-L2, plus the null-value/cold-start asymmetry
and its VT-quality ripple) is tracked as #481. Docs updated in
AlarmTracking.md + the design doc.
2026-07-17 16:07:55 -04:00
Joseph Doherty 8c5e2be92e feat(alarms): scripted condition Quality from worst-of-input tag quality (#478)
v2-ci / build (pull_request) Successful in 3m48s
v2-ci / unit-tests (pull_request) Failing after 11m0s
Layer 3 of #477: a scripted alarm's condition Quality now reflects the WORST
quality across its input tags, mirroring the native OT semantic (#477 L2).

Plumbing (quality was silently discarded twice on the live path):
- VirtualTagActor.DependencyValueChanged gains Quality (defaulted Good); the
  DependencyMuxActor forwards the published AttributeValuePublished.Quality it
  already carried; ScriptedAlarmHostActor.OnDependencyChanged pushes the real
  quality into the engine (was hardcoded 0u/Good).

Engine (Core.ScriptedAlarms):
- ScriptedAlarmEngine computes worst-of-input quality each eval (skipping
  not-yet-published inputs, which are a readiness concern, not a quality signal)
  and carries it on ScriptedAlarmEvent.WorstInputStatusCode.
- A real transition carries the current worst quality so ToSnapshot's full
  snapshot doesn't clobber quality back to Good (e.g. transition while Uncertain).
- A Bad input freezes the condition (no transition), like a comms-lost native
  driver; a quality-bucket change with no transition emits the new
  EmissionKind.QualityChanged, routed to the existing #477-L2
  AlarmQualityUpdate -> WriteAlarmQuality node path (quality only, no /alerts
  row, no historian write). ScriptedAlarmSource skips QualityChanged so it never
  fabricates a phantom IAlarmSource event.

Host: ToSnapshot maps WorstInputStatusCode -> OpcUaQuality; OnEngineEmission
routes QualityChanged out of band.

Tests (TDD, RED-first): engine worst-carry + Bad/restore QualityChanged +
unchanged-bucket-no-emit; source swallows QualityChanged; mux forwards quality;
host Bad-dep -> AlarmQualityUpdate(no alerts) + transition snapshot carries worst.
Docs: AlarmTracking.md Layer-3 section + design doc.

Closes #478
2026-07-17 15:56:06 -04:00
dohertj2 6dda0549e2 Merge pull request 'feat(alarms): condition Quality tracks source connectivity (#477)' (#479) from fix/alarm-condition-quality-477 into master
v2-ci / build (push) Successful in 4m53s
v2-ci / unit-tests (push) Failing after 12m49s
2026-07-17 15:18:34 -04:00
Joseph Doherty db751d12a5 feat(alarms): condition Quality tracks source connectivity (#477)
v2-ci / build (pull_request) Successful in 4m49s
v2-ci / unit-tests (pull_request) Failing after 3h11m25s
Part 9 ConditionType.Quality was never assigned; default(StatusCode)==Good
so every native + scripted condition reported Good unconditionally — a
comms-lost device still showed a healthy, inactive, Good condition (a
wrong-VALUE bug, distinct from the null-value #473/#475). Clients (and HMIs
bucketing on IsGood) could not tell "genuinely inactive" from "lost contact".

Layer 1 — make Quality a real, plumbed field:
- AlarmConditionSnapshot gains OpcUaQuality Quality (default Good).
- MaterialiseAlarmCondition sets it (native BadWaitingForInitialData, scripted Good).
- WriteAlarmCondition projects snapshot.Quality; the delta-gate gains a Quality
  member so a quality-bucket change fires a Part 9 event.

Layer 2 — drive native quality from driver connectivity (a comms-lost driver
emits no alarm transitions, and an alarm-bearing raw tag has no value variable,
so quality can't come from either existing channel):
- DriverInstanceActor Tells parent ConnectivityChanged on Connected/Reconnecting.
- DriverHostActor fans it to every native condition the driver owns as
  OpcUaPublishActor.AlarmQualityUpdate (Good on connect, Bad on disconnect).
- New dedicated IOpcUaAddressSpaceSink.WriteAlarmQuality sets ONLY Quality and
  fires only on a bucket change — never touches Active/Acked/Retain (an active
  alarm that loses comms stays active). Not a full-snapshot re-projection, so it
  can't clobber severity/message and works for a never-fired condition.
  Forwarded through DeferredAddressSpaceSink (F10b trap; auto-verified by the
  reflection forwarding guard). Ungated by redundancy role; no /alerts row.

Scripted conditions stay Good; worst-of-input quality deferred to #478 (Layer 3).

Tests: node-level (materialise/project/no-clobber/unknown-node no-op),
NativeAlarmProjector, DriverInstanceActor connectivity emission, DriverHostActor
fan-out, OpcUaPublishActor routing, and the wire-level guard
(Condition_event_Quality_tracks_source_connectivity_on_the_wire) — RED-verified
against a simulated pre-fix always-Good server. Existing DriverInstanceActor
parent probes ignore the new ConnectivityChanged.

Docs: docs/AlarmTracking.md §"Condition source-data Quality (#477)";
design doc docs/plans/2026-07-17-alarm-condition-quality-477-design.md.
2026-07-17 15:10:04 -04:00
dohertj2 f6a3c31b60 Merge pull request 'fix(alarms): populate ConditionClassId/ConditionClassName on condition events (#475)' (#476) from fix/alarm-condition-class-fields into master
v2-ci / build (push) Successful in 3m41s
v2-ci / unit-tests (push) Failing after 9m56s
2026-07-17 13:58:39 -04:00
Joseph Doherty e08b6b0e69 fix(alarms): populate ConditionClassId/ConditionClassName on conditions (#475)
MaterialiseAlarmCondition never assigned the mandatory Part 9 ConditionType
classification fields, so every condition event — native and scripted — shipped
ConditionClassId = NodeId.Null (i=0) and ConditionClassName = empty text. Same
mechanism as #473: Create() builds the mandatory children from the type's
embedded definition but leaves them unset, and nothing downstream synthesises
them (ReportEvent / InstanceStateSnapshot copy children verbatim). An HMI
bucketing alarms by condition class dropped every OtOpcUa alarm as unclassified.

Report BaseConditionClassType — Part 9's "no condition class modelled" value.
This is the honest report: we hold no classification at the materialise seam.
Deliberately NOT ProcessConditionClassType (the SDK sample's pick), which would
assert a classification we cannot back and would be actively wrong for a Galaxy
alarm whose upstream category is Safety/Diagnostics — trading a detectable null
for an undetectable lie. Real per-alarm classification needs the driver's
AlarmCategory carried to this deploy-time seam (it lives only on the runtime
AlarmEventArgs transition today) and is a separate feature.

Guards, both observed RED against the pre-fix server:
- NativeAlarmEventIdentityFieldDeliveryTests: wire-level, its own select clause
  (the #473 test's clause mirrors ScadaBridge's exactly and its indices are
  load-bearing, so it is left untouched). The class fields are declared on
  ConditionType, not BaseEventType, so they are selected against that type.
- NodeManagerAlarmSourceFieldsTests: node-level, native (Raw) + scripted (Uns).

Stacked on #473 (PR #474) — merge after it.
2026-07-17 00:49:43 -04:00
dohertj2 50426d4790 Merge pull request 'fix(alarms): populate EventType/SourceNode/SourceName on native + scripted conditions (#473)' (#474) from fix/alarm-condition-source-fields into master
v2-ci / build (push) Successful in 3m55s
v2-ci / unit-tests (push) Failing after 10m4s
2026-07-17 00:45:51 -04:00
39 changed files with 1381 additions and 33 deletions
+92 -5
View File
@@ -57,7 +57,8 @@ Every condition event — native and scripted — carries the mandatory `BaseEve
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 issue #473.
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 |
|---|---|---|
@@ -65,6 +66,21 @@ are set explicitly. Leaving them unset shipped them as **null** on every event
| `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
@@ -79,10 +95,12 @@ identifier and is unique, so it is safe to key on *by itself* — but do **not**
`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 fields arrive
populated on a real subscription using the standard `[EventType, SourceNode, SourceName, Time,
Message, Severity]` select clause; `NodeManagerAlarmSourceFieldsTests` guards the node itself
across both realms.
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
@@ -94,6 +112,75 @@ across both realms.
> 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 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.
**Coverage boundary (#478 as shipped).** Scripted quality tracks 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 does **not** yet 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 scripted engine keeps the last-known Good value and the
condition stays `Good`. Bridging driver connectivity into scripted inputs — the symmetric of the native
`OnDriverConnectivityChanged` path above, plus resolving the null-value/cold-start asymmetry (a runtime
`Bad` with a null value is currently indistinguishable from cold start and contributes `Good`) — is tracked
as the Layer-4 follow-up (#481).
Guards: `ScriptedAlarmEngineTests` (transition carries `Uncertain`; `Bad` input with no transition emits
`QualityChanged(Bad)`; restore emits `QualityChanged(Good)`; unchanged bucket emits nothing),
`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
@@ -0,0 +1,187 @@
# Alarm condition Quality (issue #477) — design
**Status:** implemented (L1+L2) · **Date:** 2026-07-17 · **Issue:** #477 (follow-up chain #473#475#477)
**Scope decision:** Layer 1 + Layer 2, Bad-direct, annotate-only. Layer 3 (scripted worst-of-input) deferred → **#478**.
## Problem
`AlarmConditionState.Quality` is never assigned anywhere in `src/` — neither by
`OtOpcUaNodeManager.MaterialiseAlarmCondition` nor by the `WriteAlarmCondition` transition path.
Because `StatusCodes.Good == 0x00000000`, `default(StatusCode)` **is** `Good`, so the field is
*accidentally valid* — clients parse it, but it reports **`Good` unconditionally regardless of the
backing tag's real quality**.
This is a wrong-*value* bug, not the null-value bug class of #473/#475. Part 9 defines
`ConditionType.Quality` as "the quality of the Condition's source data". OT impact: when a native
alarm's device goes offline (comms lost) the condition still reports `Quality = Good`, so an operator
(or an HMI bucketing on `IsGood`) cannot distinguish *"genuinely not active"* from *"we have lost
contact and do not know"*.
## Why it isn't a 2-line default (confirmed by code)
1. **Alarm-bearing raw tags have no value variable.** `AddressSpaceApplier` materialises a raw tag as
*either* a condition node (`tag.Alarm is not null`) *or* a value variable (`else`) — never both,
since they'd share the same `s=<RawPath>` NodeId. So `WriteValue` (the only path carrying
`OpcUaQuality`) is never invoked for an alarm node. Quality has nowhere to land today.
2. **The alarm channel is quality-blind.** `AlarmEventArgs` (driver → host) and `AlarmConditionSnapshot`
(host → SDK sink) both carry no quality field.
3. **On comms-loss the alarm feed goes silent.** `DriverInstanceActor` on `DisconnectObserved` detaches
the alarm subscription and re-enters `Reconnecting` — no transition event ever arrives to carry Bad.
So the "device offline" signal must come from **driver connectivity**, independently of alarm
transitions.
## Decisions (the issue's open questions)
| # | Question | Decision | Rationale |
|---|----------|----------|-----------|
| 1 | Does an alarm tag get quality today? | No | Confirmed above — new plumbing required. |
| 2 | Direct status code vs. policy map | **Direct Bad** on comms-loss; Good on reconnect | Matches how a value variable would read; unambiguous for `IsGood` bucketing. |
| 3 | Does Bad also suppress transitions / touch Retain? | **No — annotate only** | A comms-lost *active* condition must stay active + retained. Silently clearing an active alarm on comms-loss is the unsafe direction. Quality is a pure annotation; the Active/Ack/Retain state machine is untouched. |
| 4 | Scripted alarms: worst-of-inputs quality? | **Deferred (Layer 3)** | Scripted conditions stay `Good`. Filed as a follow-up issue. |
## Architecture — reuse the existing publish path, add no sink method
The key move: **do not add a new `IOpcUaAddressSpaceSink` method.** A new sink-interface surface would
have to be forwarded through `DeferredAddressSpaceSink` or it is inert on driver hosts (the F10b
prod-inertness trap). Instead the `NativeAlarmProjector` becomes the single owner of per-condition
state *and* quality, and a connectivity change re-projects the *last* snapshot with a swapped quality
through the **existing** `AlarmStateUpdate → OpcUaPublishActor → WriteAlarmCondition` path.
### Layer 1 — make Quality a real, plumbed field
- `AlarmConditionSnapshot` (Commons) gains `OpcUaQuality Quality` (last positional param, default
`OpcUaQuality.Good` so scripted callers and existing tests keep compiling; Commons already knows
`OpcUaQuality` via `IOpcUaAddressSpaceSink`).
- `MaterialiseAlarmCondition` sets `alarm.Quality.Value` at build time:
**native → `BadWaitingForInitialData`** (honest until connectivity confirms Good, matching the
value-variable "waiting for initial data" convention), **scripted → `Good`** (script-computed, always
live in this scope).
- `WriteAlarmCondition` projects `StatusFromQuality(state.Quality)` onto `condition.Quality.Value`
(+ `SourceTimestamp`).
- The delta-gate (`AlarmConditionDelta` / `ReadConditionDelta` / `ToConditionDelta`) gains a `Quality`
member, so a Good→Bad bucket change is a genuine delta and **fires a Part 9 condition event**.
### Layer 2 — drive native quality from driver connectivity
- `DriverInstanceActor`: new `public sealed record ConnectivityChanged(string DriverInstanceId, bool Connected)`.
`Context.Parent.Tell` it on `Become(Connected)` entry (`true`) and on the transitions into
`Reconnecting` (`DisconnectObserved` / `ForceReconnect`) (`false`). Fire-and-forget, mirrors
`DeltaApplied`.
- `NativeAlarmProjector`: per-node state becomes `(bool Active, bool Acked, OpcUaQuality Quality)`.
`Project(transition)` preserves the current quality; new `ProjectQuality(nodeId, quality)` preserves
Active/Acked and swaps only the quality, returning a full snapshot.
- `DriverHostActor`: `Receive<ConnectivityChanged>` iterates `_alarmNodeIdByDriverRef` for that driver
instance and Tells one `AlarmStateUpdate` per condition with the re-projected snapshot
(`connected ? Good : Bad`). **Ungated** — both redundancy nodes track their own driver's comms, matching
the existing "condition write stays ungated (Secondary keeps its address space warm)" rule.
**No `/alerts` row** for a quality-only change — driver health already has its own status/alerts surface
via `IDriverHealthPublisher`; a row here would be alarm-fatigue.
Scripted alarms are unaffected: they are not driver instances, receive no `ConnectivityChanged`, and
their snapshot quality stays `Good`.
## Files
**Layer 1**
- `src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/AlarmConditionSnapshot.cs`
- `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs` (`MaterialiseAlarmCondition`,
`WriteAlarmCondition`, `AlarmConditionDelta`/`ReadConditionDelta`/`ToConditionDelta`)
- `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/ScriptedAlarmHostActor.cs` (`ToSnapshot` — Quality=Good, or rely on default)
**Layer 2**
- `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs`
- `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/NativeAlarmProjector.cs`
- `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs`
## Tests (TDD, RED-first)
1. **Wire-level (the issue's suggested guard)** — extend `NativeAlarmEventIdentityFieldDeliveryTests`
(OpcUaServer.IntegrationTests): active alarm → event `Quality.IsGood`; driver disconnect → condition
event `Quality.IsGood == false`; reconnect → Good. Verify RED against pre-fix.
2. **Node-level**`NodeManagerAlarmSourceFieldsTests`: materialise sets Quality (native
`BadWaitingForInitialData`, scripted `Good`); `WriteAlarmCondition` projects snapshot quality and
fires on a quality-bucket change only.
3. **`NativeAlarmProjector`** unit: `ProjectQuality` keeps Active/Acked + swaps quality; `Project`
preserves quality.
4. **`DriverInstanceActor`**: `Connected` entry Tells `ConnectivityChanged(true)`; `DisconnectObserved`
Tells `ConnectivityChanged(false)`.
5. **`DriverHostActor`**: `ConnectivityChanged(false)` pushes a Bad-quality `AlarmStateUpdate` to every
condition of that driver instance.
## Deferred / notes
- **Layer 3** (scripted worst-of-input quality) → **Gitea #478**.
- **Implementation note:** L2 uses a **dedicated `IOpcUaAddressSpaceSink.WriteAlarmQuality`** path (not a
full-snapshot re-projection). Rationale: a connectivity change must set *only* Quality; re-projecting a full
snapshot would clobber a cold condition's severity/message and can't annotate a condition that never fired a
transition. The new sink method is forwarded through `DeferredAddressSpaceSink` (the F10b inertness trap) —
auto-verified by `DeferredSinkForwardingReflectionTests` (reflection guard) + its realm-discriminator guard.
- **Test-harness note:** the new `DriverInstanceActor → parent` `ConnectivityChanged` Tell polluted existing
parent-`TestProbe` assertions in 3 `DriverInstanceActor*Tests` files; those tests now
`parent.IgnoreMessages(m => m is ConnectivityChanged)` since they assert on data/alarm/discovery forwards,
not connectivity.
- `Bad_NoCommunication` vs generic `Bad`: v1 maps `OpcUaQuality.Bad → StatusCodes.Bad`; refining
`StatusFromQuality` to emit `BadNoCommunication` for the comms-loss case is a one-line nicety, noted in
the issue.
- `docs/AlarmTracking.md` §"Condition event identity fields" gains a Quality subsection (Good/Bad
semantics, annotation-not-state-change, quality-bucket change fires an event).
## Layer 3 — scripted worst-of-input quality (Gitea #478, implemented 2026-07-17)
**Problem.** A scripted alarm is computed from one or more input tags. Its condition should report the
**worst** quality of those inputs ("can I trust this condition's state?"), not the hardcoded `Good` Layer 1
left at `ScriptedAlarmHostActor.ToSnapshot`.
**Two blockers discovered in the live path (both silently discard quality):**
1. `DependencyMuxActor.OnAttributeValuePublished` builds `VirtualTagActor.DependencyValueChanged` **without**
the `AttributeValuePublished.Quality` it already carries.
2. `ScriptedAlarmHostActor.OnDependencyChanged` pushes each mux value into the engine's upstream with a
**hardcoded `0u` (Good)** StatusCode.
So even a `Bad` driver value reached the scripted engine as Good — Layer 3 has to plumb quality first.
**Design (mirrors Layer 2's native OT semantic through the scripted channel):**
- **Plumb quality end-to-end.** `DependencyValueChanged` gains `OpcUaQuality Quality` (defaulted `Good`, so the
virtual-tag engine's calls are unchanged); the mux forwards `msg.Quality`; `OnDependencyChanged` maps it to a
StatusCode (`Good→0`, `Uncertain→0x40000000`, `Bad→0x80000000`) on the pushed `DataValueSnapshot`.
- **Engine computes the worst input quality** each evaluation (over the refilled read cache, **before** the
`AreInputsReady` short-circuit so a `Bad` input is still observed) and carries it as
`ScriptedAlarmEvent.WorstInputStatusCode` (a raw `uint` StatusCode — `Core.ScriptedAlarms` doesn't reference
Commons, so it stays in the engine's existing StatusCode vocabulary; the host maps it to `OpcUaQuality`).
- **Transitions carry the current worst quality** → `ToSnapshot` projects it (no clobber-back-to-Good when a
transition fires while an input is `Uncertain`).
- **Quality-only changes emit out of band.** A `Bad` input freezes the condition (`AreInputsReady` returns
false → no transition), exactly like a comms-lost native driver — so quality can't ride a transition. The
engine tracks the last worst-quality **bucket** per alarm and, when the bucket changes with **no** transition
emission, emits a new `EmissionKind.QualityChanged` event. The host routes that to the **existing** Layer 2
`OpcUaPublishActor.AlarmQualityUpdate → IOpcUaAddressSpaceSink.WriteAlarmQuality` path (sets ONLY Quality,
one Part 9 event on a bucket change, **no `/alerts` row**, no historian write). No new sink surface.
- **`ScriptedAlarmSource` (the `IAlarmSource` fan-out adapter) skips `QualityChanged`** — quality is delivered
through the dedicated node path, never as a phantom `AlarmEventArgs` (which would materialize/historize a
native condition).
**Files (Layer 3):**
- `src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/Part9StateMachine.cs``EmissionKind.QualityChanged`.
- `src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmEngine.cs` — worst-of-input, bucket tracking,
`WorstInputStatusCode` on the event, quality-only emission.
- `src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmSource.cs` — skip `QualityChanged`.
- `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagActor.cs``DependencyValueChanged.Quality`.
- `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/DependencyMuxActor.cs` — forward `msg.Quality`.
- `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/ScriptedAlarmHostActor.cs` — push real quality;
`ToSnapshot` maps `WorstInputStatusCode`; `OnEngineEmission` routes `QualityChanged → AlarmQualityUpdate`.
**Tests (RED-first):** engine — transition carries `Uncertain` worst; `Bad` input with no transition emits
`QualityChanged(Bad)`; restore emits `QualityChanged(Good)`; no spurious emit when the bucket is unchanged.
`ScriptedAlarmSource``QualityChanged` raises no `OnAlarmEvent`. Mux — `DependencyValueChanged` carries the
published quality. Host — `Bad` dependency → `AlarmQualityUpdate(Bad)`, no `/alerts` publish; `ToSnapshot`
maps the event's worst quality.
**Coverage boundary → Layer 4 (#481).** L3 covers inputs whose driver **publishes a Bad/Uncertain-status
data change** (the mux quality path). It does **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
(`DriverInstanceActor.Reconnecting`), so the scripted engine keeps the last-known Good value and the
condition stays Good — the same silent-feed problem native solved in L2, but native's `OnDriverConnectivityChanged`
bridge fans only to **native** condition nodes (`_alarmNodeIdByDriverRef`), not into the mux the scripted
engine reads. Bridging connectivity into scripted inputs — plus resolving the null-value/cold-start
asymmetry (a runtime `Bad` with a null value is currently indistinguishable from cold start and contributes
`Good`) and its ripple into virtual-tag quality — is **Gitea #481 (Layer 4)**. Found by the post-implementation
code review; the code as shipped faithfully implements #478's written scope (mux-delivered input quality).
@@ -16,6 +16,12 @@ namespace ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
/// <param name="Shelving">The shelving mode (ShelvingState): unshelved, one-shot, or timed.</param>
/// <param name="Severity">OPC UA severity on the 1..1000 scale (the SDK <c>SetSeverity</c> input).</param>
/// <param name="Message">The human-readable condition message (LocalizedText payload).</param>
/// <param name="Quality">
/// Quality of the condition's source data (Part 9 <c>ConditionType.Quality</c>). Carried so a
/// comms-lost native source can report a non-<c>Good</c> condition instead of the accidentally-Good
/// default (issue #477). It is a pure annotation — it never alters Active/Acked/Retain. Defaults to
/// <see cref="OpcUaQuality.Good"/> so scripted-alarm callers (which stay Good in v1) need not supply it.
/// </param>
public sealed record AlarmConditionSnapshot(
bool Active,
bool Acknowledged,
@@ -23,7 +29,8 @@ public sealed record AlarmConditionSnapshot(
bool Enabled,
AlarmShelvingKind Shelving,
ushort Severity,
string Message);
string Message,
OpcUaQuality Quality = OpcUaQuality.Good);
/// <summary>
/// Commons-local mirror of the Core <c>ShelvingKind</c> enum so this assembly carries no
@@ -30,6 +30,9 @@ public sealed class DeferredAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgical
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm)
=> _inner.WriteAlarmCondition(alarmNodeId, state, sourceTimestampUtc, realm);
public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm)
=> _inner.WriteAlarmQuality(alarmNodeId, quality, sourceTimestampUtc, realm);
/// <inheritdoc />
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false)
=> _inner.MaterialiseAlarmCondition(alarmNodeId, equipmentNodeId, displayName, alarmType, severity, realm, isNative);
@@ -33,6 +33,17 @@ public interface IOpcUaAddressSpaceSink
/// the condition was materialised under).</param>
void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm);
/// <summary>#477 — annotate a materialised condition's source-data quality OUT OF BAND from any alarm
/// transition (used by the driver-connectivity path: comms lost → <see cref="OpcUaQuality.Bad"/>,
/// restored → <see cref="OpcUaQuality.Good"/>). Sets ONLY the condition's Quality — never
/// Active/Acked/Severity/Retain (a comms-lost active alarm stays active) — and fires one Part 9 event
/// only on a quality-bucket change. A no-op for an unmaterialised / non-condition node.</summary>
/// <param name="alarmNodeId">The condition node id (RawPath for a native alarm).</param>
/// <param name="quality">The source-data quality to annotate.</param>
/// <param name="sourceTimestampUtc">The connectivity transition timestamp in UTC.</param>
/// <param name="realm">The namespace realm the condition was materialised under.</param>
void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm);
/// <summary>
/// Materialise a real OPC UA Part 9 alarm-condition node under its equipment folder so clients
/// can browse it as a proper condition (with basic Active/Ack state). The node id equals the
@@ -165,6 +176,7 @@ public sealed class NullOpcUaAddressSpaceSink : IOpcUaAddressSpaceSink
/// <inheritdoc />
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
/// <inheritdoc />
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { }
@@ -377,4 +377,9 @@ public enum EmissionKind
Enabled,
Disabled,
CommentAdded,
/// <summary>#478 — the worst-of-input source quality changed bucket (Good/Uncertain/Bad) with no
/// accompanying Part 9 state transition. Delivered out of band via the dedicated
/// <c>WriteAlarmQuality</c> node path, never through the <c>IAlarmSource</c> fan-out (it is not a
/// state change and must not materialize or historize a condition).</summary>
QualityChanged,
}
@@ -62,6 +62,17 @@ public sealed class ScriptedAlarmEngine : IDisposable
private readonly ConcurrentDictionary<string, AlarmScratch> _scratchByAlarmId =
new(StringComparer.Ordinal);
/// <summary>
/// #478 — last emitted worst-of-input quality bucket per alarm (0 = Good, 1 = Uncertain,
/// 2 = Bad), computed each evaluation over the refilled read cache. A change in this bucket
/// with no accompanying Part 9 state transition drives a standalone
/// <see cref="EmissionKind.QualityChanged"/> emission (a Bad input freezes the condition — no
/// transition — so quality can't ride one, exactly like a comms-lost native driver). Only ever
/// mutated under <c>_evalGate</c>; cleared alongside <see cref="_alarms"/> on load/dispose.
/// </summary>
private readonly ConcurrentDictionary<string, int> _lastQualityBucketByAlarmId =
new(StringComparer.Ordinal);
/// <summary>
/// Compile cache for every alarm predicate. Routes <see cref="LoadAsync"/>'s
/// <see cref="ScriptEvaluator{TContext, TResult}.Compile"/> calls through the
@@ -203,6 +214,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
// have changed (different Inputs, different Logger), so any reuse would be
// unsafe.
_scratchByAlarmId.Clear();
_lastQualityBucketByAlarmId.Clear();
// Dispose every compiled-predicate ALC from the prior generation BEFORE we
// recompile this one. Skipping this is what made the earlier fix a
// no-op in production.
@@ -412,7 +424,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
// OnEvent dispatch until after Release() so a slow subscriber or a
// subscriber that re-enters the engine doesn't block / deadlock.
if (result.Emission != EmissionKind.None)
pending = BuildEmission(state, result.State, result.Emission);
pending = BuildEmission(state, result.State, result.Emission, LastWorstStatus(alarmId));
else if (result.NoOpReason is { } reason)
{
// The Part9StateMachine remarks promise a diagnostic log line for
@@ -513,13 +525,27 @@ public sealed class ScriptedAlarmEngine : IDisposable
_ => new AlarmScratch(state.Inputs, state.Logger, _clock));
RefillReadCache(scratch.ReadCache, state.Inputs);
// #478 — worst OPC UA quality across the alarm's inputs, computed BEFORE the readiness
// short-circuit so an outright-Bad input is still observed. A bucket change with no state
// transition is delivered out of band as a QualityChanged emission (see below).
var worstStatus = WorstInputStatus(scratch.ReadCache);
var qualityBucketChanged = TrackQualityBucket(state.Definition.AlarmId, worstStatus);
// Cold-start guard — skip the predicate when any referenced upstream tag has no
// cached value yet (the upstream subscription hasn't delivered its first push).
// Without this, predicates that cast `(double)ctx.GetTag(path).Value` throw NRE on
// every tick until the cache fills, spamming the log with identical stack traces.
// Bad quality is treated the same: the input isn't available at the predicate's
// expected type, so the only defensible move is to hold the prior condition state.
if (!AreInputsReady(scratch.ReadCache)) return seed;
if (!AreInputsReady(scratch.ReadCache))
{
// The condition is frozen (can't trust its state), but its source quality just changed
// bucket — annotate it out of band so a comms-lost / Bad-input scripted condition reports
// Bad, mirroring the native OT path.
if (qualityBucketChanged)
pendingEmissions.Add(BuildQualityEmission(state, seed, worstStatus));
return seed;
}
var context = scratch.Context;
@@ -544,10 +570,19 @@ public sealed class ScriptedAlarmEngine : IDisposable
}
var result = Part9StateMachine.ApplyPredicate(seed, predicateTrue, nowUtc);
if (result.Emission != EmissionKind.None)
var transition = result.Emission != EmissionKind.None
? BuildEmission(state, result.State, result.Emission, worstStatus)
: null;
if (transition is not null)
{
var evt = BuildEmission(state, result.State, result.Emission);
if (evt is not null) pendingEmissions.Add(evt);
// A real transition carries the current worst quality so the projected full-snapshot
// write doesn't clobber quality back to Good (e.g. a transition while an input is Uncertain).
pendingEmissions.Add(transition);
}
else if (qualityBucketChanged)
{
// No transition (or a Suppressed one) but the quality bucket moved — annotate out of band.
pendingEmissions.Add(BuildQualityEmission(state, result.State, worstStatus));
}
return result.State;
}
@@ -599,7 +634,8 @@ public sealed class ScriptedAlarmEngine : IDisposable
/// done by <see cref="FireEvent(ScriptedAlarmEvent)"/> AFTER the gate is
/// released.
/// </summary>
private ScriptedAlarmEvent? BuildEmission(AlarmState state, AlarmConditionState condition, EmissionKind kind)
private ScriptedAlarmEvent? BuildEmission(
AlarmState state, AlarmConditionState condition, EmissionKind kind, uint worstInputStatus)
{
// Suppressed kind means shelving ate the emission — we don't fire for subscribers
// but the state record still advanced so startup recovery reflects reality.
@@ -629,9 +665,89 @@ public sealed class ScriptedAlarmEngine : IDisposable
// Carry the per-alarm durable-historization opt-out through to subscribers. The historian
// adapter honors it to suppress ONLY the durable sink write; the live alerts fan-out is
// unaffected (it is not gated on this flag).
HistorizeToAveva: state.Definition.HistorizeToAveva);
HistorizeToAveva: state.Definition.HistorizeToAveva,
// #478 — the worst input quality at evaluation time rides the transition so the projected
// full snapshot keeps quality consistent (no clobber-to-Good).
WorstInputStatusCode: worstInputStatus);
}
/// <summary>
/// #478 — build a standalone <see cref="EmissionKind.QualityChanged"/> event carrying the new
/// worst-of-input quality. Emitted when the quality bucket moved but no Part 9 transition fired
/// (a Bad input freezes the condition; a Suppressed/None transition also leaves state unchanged).
/// The host routes it to the dedicated <c>WriteAlarmQuality</c> node path (annotate quality only,
/// no <c>/alerts</c> row, no historian write); the <see cref="IAlarmSource"/> fan-out skips it.
/// </summary>
private ScriptedAlarmEvent BuildQualityEmission(
AlarmState state, AlarmConditionState condition, uint worstInputStatus)
=> new(
AlarmId: state.Definition.AlarmId,
EquipmentPath: state.Definition.EquipmentPath,
AlarmName: state.Definition.AlarmName,
Kind: state.Definition.Kind,
Severity: state.Definition.Severity,
Message: MessageTemplate.Resolve(state.Definition.MessageTemplate, TryLookup),
Condition: condition,
Emission: EmissionKind.QualityChanged,
TimestampUtc: _clock(),
HistorizeToAveva: state.Definition.HistorizeToAveva,
WorstInputStatusCode: worstInputStatus);
/// <summary>Worst OPC UA StatusCode across a refilled read cache — the entry with the highest severity
/// bits (top 2). An input with no value yet (null snapshot/value — the cold-start placeholder, or a
/// not-yet-published upstream) is NOT a quality signal: it means "no data", which the
/// <see cref="AreInputsReady"/> guard already handles by holding the condition. Counting it as Bad here
/// would flash every scripted condition Bad at deploy until the first push and would flood the quality
/// path with load-time annotations, so unread inputs are skipped (contribute Good). Empty / all-unread
/// cache ⇒ Good (0).</summary>
private static uint WorstInputStatus(IReadOnlyDictionary<string, DataValueSnapshot> cache)
{
uint worst = 0u;
var worstSeverity = 0u;
foreach (var kv in cache)
{
if (kv.Value is null || kv.Value.Value is null) continue; // no data yet — not a quality signal
var status = kv.Value.StatusCode;
var severity = status >> 30;
if (severity > worstSeverity)
{
worstSeverity = severity;
worst = status;
}
}
return worst;
}
/// <summary>Update the tracked worst-quality bucket for an alarm; return true iff the 3-state bucket
/// (0 = Good, 1 = Uncertain, 2 = Bad) changed from the last observed value. Only called under
/// <c>_evalGate</c>.</summary>
private bool TrackQualityBucket(string alarmId, uint worstStatus)
{
var bucket = QualityBucket(worstStatus);
var prior = _lastQualityBucketByAlarmId.TryGetValue(alarmId, out var b) ? b : 0; // default Good
_lastQualityBucketByAlarmId[alarmId] = bucket;
return bucket != prior;
}
/// <summary>Collapse an OPC UA StatusCode's 2 severity bits (00/01/10/11) to a 3-state quality bucket
/// (0 = Good, 1 = Uncertain, 2 = Bad).</summary>
private static int QualityBucket(uint statusCode)
{
var severity = statusCode >> 30;
return severity >= 2 ? 2 : (int)severity;
}
/// <summary>#478 — a canonical worst StatusCode for an alarm's last-observed quality bucket, used by
/// the operator-command + shelving-timer emission paths (which don't re-read inputs) so an ack /
/// shelve while an input is Bad still carries Bad rather than resetting the condition to Good.</summary>
private uint LastWorstStatus(string alarmId)
=> (_lastQualityBucketByAlarmId.TryGetValue(alarmId, out var bucket) ? bucket : 0) switch
{
2 => 0x80000000u, // Bad
1 => 0x40000000u, // Uncertain
_ => 0u, // Good
};
/// <summary>
/// Invoke the <see cref="OnEvent"/> handler for a built emission. Must be
/// called OUTSIDE <c>_evalGate</c>: a slow subscriber would otherwise
@@ -708,7 +824,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
_alarms[id] = state with { Condition = result.State };
if (result.Emission != EmissionKind.None)
{
var evt = BuildEmission(state, result.State, result.Emission);
var evt = BuildEmission(state, result.State, result.Emission, LastWorstStatus(id));
if (evt is not null) pending.Add(evt);
}
}
@@ -780,6 +896,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
_alarms.Clear();
_alarmsReferencing.Clear();
_scratchByAlarmId.Clear();
_lastQualityBucketByAlarmId.Clear();
// Dispose every compiled-predicate ALC so the engine's shutdown actually
// releases the emitted assemblies. The drain above ensures no evaluator is
// mid-call; CompiledScriptCache.Dispose internally guards against use-after-
@@ -851,7 +968,11 @@ public sealed record ScriptedAlarmEvent(
EmissionKind Emission,
DateTime TimestampUtc,
string? Comment = null,
bool HistorizeToAveva = true);
bool HistorizeToAveva = true,
// #478 — the worst OPC UA StatusCode across the alarm's input tags at evaluation time. A raw uint
// (Core.ScriptedAlarms does not reference Commons/OpcUaQuality); the host maps it to OpcUaQuality by
// the top-2 severity bits. Default 0u == Good keeps every existing constructor call unchanged.
uint WorstInputStatusCode = 0u);
/// <summary>
/// Upstream source abstraction — intentionally identical shape to the virtual-tag
@@ -81,6 +81,11 @@ public sealed class ScriptedAlarmSource : IAlarmSource, IDisposable
{
if (_disposed) return;
// #478 — QualityChanged is a source-quality annotation, not a Part 9 state change. It is delivered
// out of band via the dedicated WriteAlarmQuality node path; surfacing it here would fabricate a
// phantom AlarmEventArgs that materializes / historizes a condition. Swallow it.
if (evt.Emission == EmissionKind.QualityChanged) return;
foreach (var sub in _subscriptions.Values)
{
if (!Matches(sub, evt)) continue;
@@ -497,6 +497,16 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
condition.SetSeverity(SystemContext, MapSeverity(state.Severity));
condition.Message.Value = new LocalizedText(state.Message);
// #477 — project the source-data quality. A Good→Bad bucket change is a genuine condition
// change (it's in the delta-gate above), so it fires a Part 9 event carrying the new Quality;
// it never touches Active/Acked/Retain (annotation only). Quality is an optional Part 9 child —
// null-guard it like Confirmed/Shelving in case a leaner SDK child set omits it.
if (condition.Quality is not null)
{
condition.Quality.Value = StatusFromQuality(state.Quality);
if (condition.Quality.SourceTimestamp is not null) condition.Quality.SourceTimestamp.Value = sourceTimestampUtc;
}
// Part 9: retain the condition while it is active OR unacknowledged so a client's
// ConditionRefresh replays it. The event firing below also depends on this Retain being
// correct (a non-retained inactive+acked condition still fires its transition event, but
@@ -535,6 +545,50 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
}
}
/// <summary>
/// #477 Layer 2 — apply a source-data quality annotation to a materialised condition, OUT OF BAND
/// from any alarm transition. Used by the driver-connectivity path (comms lost → Bad, restored →
/// Good) so a native condition whose device is unreachable stops reporting the accidentally-Good
/// default. This sets ONLY <see cref="ConditionState.Quality"/> — it never touches
/// Active / Acked / Severity / Retain (a comms-lost active alarm must stay active). A change in the
/// quality bucket is a genuine Part 9 condition change, so it fires one condition event carrying the
/// new Quality; an unchanged bucket suppresses (no spurious event). Unknown / unmaterialised node ⇒
/// safe no-op (a mid-rebuild race must not fault a connectivity update), mirroring the other writes.
/// </summary>
/// <param name="alarmNodeId">The condition node id (RawPath for a native alarm).</param>
/// <param name="quality">The source-data quality to annotate.</param>
/// <param name="sourceTimestampUtc">Timestamp of the connectivity transition in UTC.</param>
/// <param name="realm">The condition's address-space realm.</param>
public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm)
{
ArgumentException.ThrowIfNullOrEmpty(alarmNodeId);
EnsureAddressSpaceCreated();
var key = MapKey(realm, alarmNodeId);
lock (Lock)
{
// Only materialised conditions carry a Quality child; a bare value variable or a missing node is
// a no-op (the connectivity fan-out visits every condition the driver owns, some of which a
// concurrent rebuild may have just cleared).
if (!_alarmConditions.TryGetValue(key, out var condition) || condition.Quality is null)
return;
var newCode = StatusFromQuality(quality);
var changed = condition.Quality.Value.Code != newCode.Code;
condition.Quality.Value = newCode;
if (condition.Quality.SourceTimestamp is not null) condition.Quality.SourceTimestamp.Value = sourceTimestampUtc;
// Fire ONLY on a real bucket change so a steady-state connectivity re-assert doesn't spam events.
if (changed)
{
condition.Time.Value = sourceTimestampUtc;
condition.ReceiveTime.Value = sourceTimestampUtc;
ReportConditionEvent(condition, sourceTimestampUtc);
}
}
}
/// <summary>
/// Fire a real OPC UA Part 9 condition event for one engine-driven state transition on a
/// materialised <see cref="AlarmConditionState"/>. The caller MUST already hold <c>Lock</c> and
@@ -650,7 +704,11 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
bool Enabled,
AlarmShelvingKind Shelving,
ushort MappedSeverity,
string Message);
string Message,
// #477 — the source-data quality bucket. Included so a quality-only transition (e.g. a device
// going comms-lost: Good→Bad with no state change) is a genuine delta and fires a Part 9 event.
// StatusCode has value equality, so the record struct's == still holds.
StatusCode Quality);
/// <summary>Decide whether a <see cref="WriteAlarmCondition"/> projection is a genuine state change
/// (and so should fire a Part 9 condition event) by comparing the node's pre-projection state to the
@@ -676,7 +734,10 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
Enabled: condition.EnabledState?.Id?.Value ?? true,
Shelving: ReadShelvingKind(condition),
MappedSeverity: condition.Severity?.Value ?? (ushort)0,
Message: condition.Message?.Value?.Text ?? string.Empty);
Message: condition.Message?.Value?.Text ?? string.Empty,
// Optional Part 9 child; a node without it reads as Good (matching the accidentally-Good default
// and ToConditionDelta's fold), so a snapshot Quality can't create a phantom delta against it.
Quality: condition.Quality?.Value ?? StatusCodes.Good);
/// <summary>Build the gate-relevant slice from the incoming snapshot, normalising the two fields that
/// the node stores in a derived form: Severity is run through <see cref="MapSeverity"/> so it matches
@@ -694,7 +755,10 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// node's read-back default (Unshelved).
Shelving: condition.ShelvingState is not null ? state.Shelving : AlarmShelvingKind.Unshelved,
MappedSeverity: (ushort)MapSeverity(state.Severity),
Message: state.Message ?? string.Empty);
Message: state.Message ?? string.Empty,
// If the node has no Quality child, WriteAlarmCondition's projection is a no-op there; fold to the
// node's read-back default (Good) so a snapshot Quality can't register a spurious delta.
Quality: condition.Quality is not null ? StatusFromQuality(state.Quality) : StatusCodes.Good);
/// <summary>Map the live shelving state machine's CurrentState back to our 3-way
/// <see cref="AlarmShelvingKind"/> by matching its well-known Part 9 state object id. Any node without
@@ -815,6 +879,32 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
alarm.SourceNode.Value = alarm.NodeId; // Create() assigned this above; do not rebuild it
alarm.SourceName.Value = alarmNodeId;
// #475 — the mandatory ConditionType classification fields, unset by Create() for the same reason as
// the fields above (mandatory, no default, nothing downstream synthesises them) ⇒ NodeId.Null + empty
// text on the wire, which buckets every alarm as unclassified in a Part 9 HMI.
// BaseConditionClassType is Part 9's "no class modelled" value and is the honest report: we hold no
// classification at this seam. Deliberately NOT ProcessConditionClassType (the SDK sample's pick) — it
// would assert a classification we cannot back, and would be actively wrong for a Galaxy alarm whose
// upstream category is Safety/Diagnostics. Real per-alarm classification needs the driver's
// AlarmCategory, which today exists only on the runtime AlarmEventArgs transition and not on the
// authored composition this deploy-time seam sees — a separate feature, not a default picked here.
if (alarm.ConditionClassId is not null) alarm.ConditionClassId.Value = ObjectTypeIds.BaseConditionClassType;
if (alarm.ConditionClassName is not null) alarm.ConditionClassName.Value = new LocalizedText("BaseConditionClass");
// #477 — ConditionType.Quality (the quality of the condition's source data). Create() leaves it
// UNSET, and default(StatusCode) == StatusCodes.Good (0x0), so an unassigned Quality reports Good
// unconditionally — a wrong VALUE (not a null), which hides a comms-lost source. A NATIVE condition
// has no data yet at materialise (its driver hasn't confirmed connectivity), so it starts
// BadWaitingForInitialData — the same "no driver data yet" convention value variables use — and is
// driven Good by the driver-connectivity path (DriverHostActor → ProjectQuality) once Connected. A
// SCRIPTED condition is script-computed and always live in v1, so it starts Good. Quality is a pure
// annotation: it NEVER alters Active/Acked/Retain (a comms-lost active alarm must stay active).
if (alarm.Quality is not null)
{
alarm.Quality.Value = isNative ? StatusCodes.BadWaitingForInitialData : StatusCodes.Good;
if (alarm.Quality.SourceTimestamp is not null) alarm.Quality.SourceTimestamp.Value = DateTime.UtcNow;
}
// Initial state via the SDK setters (T14: basic state only, NO event firing).
alarm.SetEnableState(SystemContext, true);
alarm.SetActiveState(SystemContext, false);
@@ -28,6 +28,9 @@ public sealed class SdkAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgicalAddre
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm)
=> _nodeManager.WriteAlarmCondition(alarmNodeId, state, sourceTimestampUtc, realm);
public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm)
=> _nodeManager.WriteAlarmQuality(alarmNodeId, quality, sourceTimestampUtc, realm);
/// <inheritdoc />
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false)
=> _nodeManager.MaterialiseAlarmCondition(alarmNodeId, equipmentNodeId, displayName, alarmType, severity, realm, isNative);
@@ -570,6 +570,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
Receive<GetDiagnostics>(HandleGetDiagnostics);
Receive<DriverInstanceActor.AttributeValuePublished>(ForwardToMux);
Receive<DriverInstanceActor.AttributeAlarmPublished>(ForwardNativeAlarm);
Receive<DriverInstanceActor.ConnectivityChanged>(OnDriverConnectivityChanged);
Receive<DriverInstanceActor.DiscoveredNodesReady>(HandleDiscoveredNodes);
Receive<DriverInstanceActor.DeltaApplied>(HandleDeltaApplied);
Receive<RestartDriver>(HandleRestartDriver);
@@ -600,6 +601,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
Receive<GetDiagnostics>(HandleGetDiagnostics);
Receive<DriverInstanceActor.AttributeValuePublished>(ForwardToMux);
Receive<DriverInstanceActor.AttributeAlarmPublished>(ForwardNativeAlarm);
Receive<DriverInstanceActor.ConnectivityChanged>(OnDriverConnectivityChanged);
Receive<DriverInstanceActor.DiscoveredNodesReady>(HandleDiscoveredNodes);
Receive<DriverInstanceActor.DeltaApplied>(HandleDeltaApplied);
Receive<RestartDriver>(HandleRestartDriver);
@@ -1010,6 +1012,46 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
/// signal the inbound-write gate uses — only the Primary publishes the single fleet-wide copy.
/// </para>
/// </summary>
/// <summary>
/// #477 — a child driver's connectivity transition. Annotates the source-data Quality of EVERY native
/// alarm condition the driver owns: comms lost → <see cref="OpcUaQuality.Bad"/>, restored →
/// <see cref="OpcUaQuality.Good"/>. This is the ONLY signal for a comms-lost native source, because a
/// disconnected driver emits no alarm transitions — the alarm feed goes silent, so without this a
/// comms-lost condition would keep reporting the accidentally-Good default forever.
/// <para>
/// UNGATED by redundancy role (like the condition write in <see cref="ForwardNativeAlarm"/>): a
/// Secondary keeps its address space — including condition quality — warm for failover. Quality is
/// a pure annotation: <c>WriteAlarmQuality</c> touches ONLY the condition's Quality, never its
/// Active/Acked/Retain, and fires a Part 9 event only on a real quality-bucket change. No cluster
/// <c>alerts</c> row is published here — driver comms health has its own status surface
/// (<see cref="IDriverHealthPublisher"/>); a row per condition would be alarm-fatigue.
/// </para>
/// </summary>
private void OnDriverConnectivityChanged(DriverInstanceActor.ConnectivityChanged msg)
{
if (_opcUaPublishActor is null) return;
var quality = msg.Connected ? OpcUaQuality.Good : OpcUaQuality.Bad;
var ts = DateTime.UtcNow;
var annotated = 0;
// Fan out to every condition this driver owns. _alarmNodeIdByDriverRef is keyed by
// (DriverInstanceId, RawPath); one driver ref can back several condition NodeIds (identical machines).
foreach (var ((driverId, _), nodeIds) in _alarmNodeIdByDriverRef)
{
if (driverId != msg.DriverInstanceId) continue;
foreach (var n in nodeIds)
{
_opcUaPublishActor.Tell(new ZB.MOM.WW.OtOpcUa.Runtime.OpcUa.OpcUaPublishActor.AlarmQualityUpdate(
n.NodeId, quality, ts, n.Realm));
annotated++;
}
}
if (annotated > 0)
_log.Debug("DriverHost {Node}: driver {Driver} {State} — annotated {Count} native condition(s) {Quality}",
_localNode, msg.DriverInstanceId, msg.Connected ? "connected" : "disconnected", annotated, quality);
}
private void ForwardNativeAlarm(DriverInstanceActor.AttributeAlarmPublished msg)
{
if (_opcUaPublishActor is null) return;
@@ -1326,6 +1368,9 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
// applied yet, so the equipment can't be resolved). Drop it — the re-discovery loop re-sends it
// and the post-recovery re-apply self-heals it once an apply runs (matches the no-op drops above).
Receive<DriverInstanceActor.DiscoveredNodesReady>(_ => { });
// A child connectivity transition while the host is Stale has no live address space to annotate — drop
// it (the post-recovery rebuild re-materialises conditions, and the child re-announces on its next entry).
Receive<DriverInstanceActor.ConnectivityChanged>(_ => { });
// A late DeltaApplied (an apply completed just before the DB went Stale) — re-register the driver's
// mux adapter anyway; it simply re-reads the driver's current refs (harmless, no DB access).
Receive<DriverInstanceActor.DeltaApplied>(HandleDeltaApplied);
@@ -46,6 +46,12 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
public sealed record InitializeSucceeded(int Generation);
public sealed record InitializeFailed(string Reason, int Generation);
public sealed record DisconnectObserved(string Reason);
/// <summary>#477 — sent to the parent (<see cref="DriverHostActor"/>) on every connectivity transition:
/// <c>Connected=true</c> on entering the Connected state, <c>false</c> on entering Reconnecting. The host
/// annotates this driver's native alarm conditions' source-data Quality from it (comms lost → Bad,
/// restored → Good) — independently of alarm transitions, since a comms-lost driver emits no alarm
/// events. Fire-and-forget, mirroring <see cref="DeltaApplied"/>.</summary>
public sealed record ConnectivityChanged(string DriverInstanceId, bool Connected);
public sealed record ApplyDelta(string DriverConfigJson, CorrelationId Correlation);
public sealed record ApplyResult(bool Success, string? Reason, CorrelationId Correlation);
/// <summary>
@@ -413,6 +419,11 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
private void Connected()
{
// #477 — announce connectivity to the host so it can clear any comms-lost Quality annotation on this
// driver's native alarm conditions (Bad → Good). Fire-and-forget; the host defaults conditions to a
// non-Good "waiting for initial data" quality at materialise, and this is what confirms them Good.
Context.Parent.Tell(new ConnectivityChanged(_driverInstanceId, Connected: true));
ReceiveAsync<ApplyDelta>(HandleApplyDeltaAsync);
Receive<DisconnectObserved>(msg =>
{
@@ -483,6 +494,10 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
private void Reconnecting()
{
// #477 — announce comms loss to the host so it annotates this driver's native alarm conditions Bad
// (a comms-lost driver emits no alarm events, so this is the ONLY signal that the source is unreachable).
Context.Parent.Tell(new ConnectivityChanged(_driverInstanceId, Connected: false));
Receive<RetryConnect>(_ => InitializeAsync(_currentConfigJson ?? "{}"));
// Fast-fail writes while reconnecting (same reason as Connecting — avoids the 8s host Ask
// timeout on an inbound write to a transiently-down driver). Synchronous Receive.
@@ -58,6 +58,15 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
/// <param name="Realm">The namespace realm the condition lives in — <see cref="AddressSpaceRealm.Uns"/> for
/// scripted alarms (default), <see cref="AddressSpaceRealm.Raw"/> for v3 native raw conditions.</param>
public sealed record AlarmStateUpdate(string AlarmNodeId, AlarmConditionSnapshot State, DateTime TimestampUtc, AddressSpaceRealm Realm);
/// <summary>#477 — annotate a materialised condition's source-data Quality out of band from any alarm
/// transition (the driver-connectivity path: comms lost → <see cref="OpcUaQuality.Bad"/>, restored →
/// <see cref="OpcUaQuality.Good"/>). Routed to <see cref="IOpcUaAddressSpaceSink.WriteAlarmQuality"/>,
/// which sets ONLY Quality and fires one Part 9 event on a quality-bucket change.</summary>
/// <param name="AlarmNodeId">The condition node id (RawPath for a native alarm).</param>
/// <param name="Quality">The source-data quality to annotate.</param>
/// <param name="TimestampUtc">The connectivity transition timestamp in UTC.</param>
/// <param name="Realm">The namespace realm the condition lives in (<see cref="AddressSpaceRealm.Raw"/> for native).</param>
public sealed record AlarmQualityUpdate(string AlarmNodeId, OpcUaQuality Quality, DateTime TimestampUtc, AddressSpaceRealm Realm);
/// <summary>
/// Triggers an address-space rebuild. <paramref name="DeploymentId"/> is the deployment
/// just applied by the host; the rebuild loads THAT artifact so materialisation matches the
@@ -239,6 +248,7 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
Receive<AttributeValueUpdate>(HandleAttributeUpdate);
Receive<AlarmStateUpdate>(HandleAlarmUpdate);
Receive<AlarmQualityUpdate>(HandleAlarmQualityUpdate);
Receive<RebuildAddressSpace>(HandleRebuild);
Receive<MaterialiseDiscoveredNodes>(HandleMaterialiseDiscovered);
Receive<ServiceLevelChanged>(HandleServiceLevelChanged);
@@ -296,6 +306,20 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
}
}
private void HandleAlarmQualityUpdate(AlarmQualityUpdate msg)
{
try
{
_sink.WriteAlarmQuality(msg.AlarmNodeId, msg.Quality, msg.TimestampUtc, msg.Realm);
Interlocked.Increment(ref _writes);
OtOpcUaTelemetry.OpcUaSinkWrite.Add(1, new KeyValuePair<string, object?>("kind", "alarm-quality"));
}
catch (Exception ex)
{
_log.Warning(ex, "OpcUaPublish: sink.WriteAlarmQuality threw for {Node}", msg.AlarmNodeId);
}
}
private void HandleRebuild(RebuildAddressSpace msg)
{
using var span = OtOpcUaTelemetry.StartAddressSpaceRebuildSpan();
@@ -278,11 +278,31 @@ public sealed class ScriptedAlarmHostActor : ReceiveActor
private void OnDependencyChanged(VirtualTagActor.DependencyValueChanged msg)
{
// Feed the live value into the upstream the engine subscribes from. StatusCode 0 = Good; the
// mux only forwards values it received from a driver publish, so we treat them as Good-quality.
_upstream.Push(msg.TagId, new DataValueSnapshot(msg.Value, 0u, msg.TimestampUtc, msg.TimestampUtc));
// Feed the live value into the upstream the engine subscribes from. #478 — carry the source
// driver's quality (mapped to an OPC UA StatusCode) so the engine can derive a scripted
// condition's worst-of-input quality; a Bad/Uncertain input is no longer silently treated as Good.
_upstream.Push(msg.TagId,
new DataValueSnapshot(msg.Value, StatusFromQuality(msg.Quality), msg.TimestampUtc, msg.TimestampUtc));
}
/// <summary>#478 — map the 3-state <see cref="OpcUaQuality"/> to an OPC UA StatusCode (severity bits)
/// for the engine's read cache. The inverse of <see cref="QualityFromStatus"/>.</summary>
private static uint StatusFromQuality(OpcUaQuality quality) => quality switch
{
OpcUaQuality.Bad => 0x80000000u,
OpcUaQuality.Uncertain => 0x40000000u,
_ => 0u, // Good
};
/// <summary>#478 — collapse an engine <see cref="ScriptedAlarmEvent.WorstInputStatusCode"/> (top-2
/// severity bits) back to the 3-state <see cref="OpcUaQuality"/> the Commons snapshot / node path use.</summary>
private static OpcUaQuality QualityFromStatus(uint statusCode) => (statusCode >> 30) switch
{
0 => OpcUaQuality.Good,
1 => OpcUaQuality.Uncertain,
_ => OpcUaQuality.Bad,
};
private void OnEngineEmission(EngineEmission msg)
{
var e = msg.Event;
@@ -294,6 +314,21 @@ public sealed class ScriptedAlarmHostActor : ReceiveActor
return;
}
// #478 — QualityChanged is a source-quality annotation (worst-of-input bucket moved with no Part 9
// state transition — e.g. an input went Bad, freezing the condition). Route it to the dedicated
// WriteAlarmQuality node path (sets ONLY Quality, one Part 9 event on a bucket change): NO full-state
// projection (would clobber severity/message) and NO /alerts row (it is not a state transition, so it
// must not historize). Same out-of-band quality path native comms-loss uses (#477 Layer 2).
if (e.Emission == EmissionKind.QualityChanged)
{
_publishActor.Tell(new OpcUaPublishActor.AlarmQualityUpdate(
AlarmNodeId: e.AlarmId,
Quality: QualityFromStatus(e.WorstInputStatusCode),
TimestampUtc: e.TimestampUtc,
Realm: AddressSpaceRealm.Uns));
return;
}
// Bridge to OPC UA: project the FULL Part 9 condition state (enabled/active/acked/confirmed/
// shelving/severity/message) onto the materialised condition node via the Commons snapshot.
// e.AlarmId is the materialised condition's NodeId (T14 aligned it to the ScriptedAlarmId).
@@ -539,7 +574,11 @@ public sealed class ScriptedAlarmHostActor : ReceiveActor
Enabled: e.Condition.Enabled == AlarmEnabledState.Enabled,
Shelving: MapShelving(e.Condition.Shelving.Kind),
Severity: (ushort)SeverityToInt(e.Severity),
Message: e.Message);
Message: e.Message,
// #478 — the condition's Quality is the worst quality across the script's input tags at evaluation
// time (carried on the event by the engine). A transition fired while an input is Uncertain projects
// Uncertain here so the full-snapshot write doesn't clobber quality back to Good.
Quality: QualityFromStatus(e.WorstInputStatusCode));
/// <summary>Maps the Core <see cref="ShelvingKind"/> onto the Commons <see cref="AlarmShelvingKind"/>
/// mirror (the Commons assembly can't see the Core enum).</summary>
@@ -100,7 +100,7 @@ public sealed class DependencyMuxActor : ReceiveActor
// space carries thousands of tags and only a fraction feed virtual-tag expressions.
return;
}
var dep = new VirtualTagActor.DependencyValueChanged(msg.FullReference, msg.Value, msg.TimestampUtc);
var dep = new VirtualTagActor.DependencyValueChanged(msg.FullReference, msg.Value, msg.TimestampUtc, msg.Quality);
foreach (var sub in subscribers)
{
sub.Tell(dep);
@@ -29,7 +29,11 @@ public sealed class VirtualTagActor : ReceiveActor
{
public const string ScriptLogsTopic = "script-logs";
public sealed record DependencyValueChanged(string TagId, object? Value, DateTime TimestampUtc);
/// <summary>A dependency's value changed. <paramref name="Quality"/> (#478) carries the source
/// driver's OPC UA quality so the scripted-alarm host can derive a condition's worst-of-input quality;
/// it defaults to <see cref="OpcUaQuality.Good"/>, and the virtual-tag engine ignores it.</summary>
public sealed record DependencyValueChanged(
string TagId, object? Value, DateTime TimestampUtc, OpcUaQuality Quality = OpcUaQuality.Good);
/// <summary>Re-assert the last emitted value/quality to the parent bridge, bypassing the value dedup.
/// Sent by <see cref="VirtualTagHostActor"/> on every apply (deploy) to a surviving child: a deploy
@@ -209,6 +209,8 @@ public class DeferredAddressSpaceSinkTests
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
=> WriteValueCalled = true;
public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { }
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
@@ -226,6 +228,7 @@ public class DeferredAddressSpaceSinkTests
public bool FolderRenameCalled { get; private set; }
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { }
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
@@ -1005,6 +1005,104 @@ public sealed class ScriptedAlarmEngineTests
// If Dispose threw or hung, the WaitAsync would surface it.
}
// -------------------------------------------------------------------------
// #478 — scripted condition Quality from worst-of-input tag quality (Layer 3)
// -------------------------------------------------------------------------
private const uint StatusUncertain = 0x40000000u;
private const uint StatusBad = 0x80000000u;
/// <summary>A transition that fires while an input is Uncertain carries that worst input quality on the
/// emitted event (so the projected snapshot doesn't clobber quality back to Good).</summary>
[Fact]
public async Task Transition_carries_worst_input_quality()
{
var up = new FakeUpstream();
up.Set("Temp", 50);
using var eng = Build(up, out _);
await eng.LoadAsync([Alarm("HighTemp", """return (int)ctx.GetTag("Temp").Value > 100;""")],
TestContext.Current.CancellationToken);
var events = new List<ScriptedAlarmEvent>();
eng.OnEvent += (_, e) => events.Add(e);
// Uncertain is "ready" (only outright Bad short-circuits), so the predicate runs and fires Activated.
up.Push("Temp", 150, StatusUncertain);
await WaitForAsync(() => events.Any(e => e.Emission == EmissionKind.Activated));
var activated = events.First(e => e.Emission == EmissionKind.Activated);
(activated.WorstInputStatusCode >> 30).ShouldBe(1u); // Uncertain severity bucket
}
/// <summary>A Bad input freezes the condition (no transition) but the worst-quality bucket changed
/// Good→Bad, so the engine emits a standalone QualityChanged carrying Bad.</summary>
[Fact]
public async Task Bad_input_without_transition_emits_QualityChanged_Bad()
{
var up = new FakeUpstream();
up.Set("Temp", 50); // predicate false → inactive
using var eng = Build(up, out _);
await eng.LoadAsync([Alarm("HighTemp", """return (int)ctx.GetTag("Temp").Value > 100;""")],
TestContext.Current.CancellationToken);
var events = new List<ScriptedAlarmEvent>();
eng.OnEvent += (_, e) => events.Add(e);
up.Push("Temp", 50, StatusBad);
await WaitForAsync(() => events.Any(e => e.Emission == EmissionKind.QualityChanged));
var q = events.First(e => e.Emission == EmissionKind.QualityChanged);
(q.WorstInputStatusCode >> 30).ShouldBeGreaterThanOrEqualTo(2u); // Bad severity bucket
// No phantom state transition accompanied the quality change.
events.Any(e => e.Emission == EmissionKind.Activated || e.Emission == EmissionKind.Cleared)
.ShouldBeFalse();
eng.GetState("HighTemp")!.Active.ShouldBe(AlarmActiveState.Inactive);
}
/// <summary>Restoring a Good input after a Bad one flips the bucket back and emits QualityChanged(Good).</summary>
[Fact]
public async Task Restoring_good_input_emits_QualityChanged_Good()
{
var up = new FakeUpstream();
up.Set("Temp", 50);
using var eng = Build(up, out _);
await eng.LoadAsync([Alarm("HighTemp", """return (int)ctx.GetTag("Temp").Value > 100;""")],
TestContext.Current.CancellationToken);
var events = new List<ScriptedAlarmEvent>();
eng.OnEvent += (_, e) => events.Add(e);
up.Push("Temp", 50, StatusBad);
await WaitForAsync(() => events.Any(e => e.Emission == EmissionKind.QualityChanged
&& (e.WorstInputStatusCode >> 30) >= 2u));
events.Clear();
up.Push("Temp", 50, 0u); // Good again, still below threshold
await WaitForAsync(() => events.Any(e => e.Emission == EmissionKind.QualityChanged));
var q = events.First(e => e.Emission == EmissionKind.QualityChanged);
(q.WorstInputStatusCode >> 30).ShouldBe(0u); // Good bucket
}
/// <summary>A value change that keeps the same quality bucket (Good→Good) emits no QualityChanged.</summary>
[Fact]
public async Task No_QualityChanged_when_bucket_unchanged()
{
var up = new FakeUpstream();
up.Set("Temp", 50);
using var eng = Build(up, out _);
await eng.LoadAsync([Alarm("HighTemp", """return (int)ctx.GetTag("Temp").Value > 100;""")],
TestContext.Current.CancellationToken);
var events = new List<ScriptedAlarmEvent>();
eng.OnEvent += (_, e) => events.Add(e);
up.Push("Temp", 60, 0u); // Good→Good, still below threshold → no transition, no quality change
await Task.Delay(150, TestContext.Current.CancellationToken);
events.ShouldNotContain(e => e.Emission == EmissionKind.QualityChanged);
}
private static async Task WaitForAsync(Func<bool> cond, int timeoutMs = 2000)
{
var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs);
@@ -107,6 +107,28 @@ public sealed class ScriptedAlarmSourceTests
events.Count.ShouldBe(0);
}
/// <summary>#478 — a QualityChanged engine emission (source-quality bucket change, no state transition)
/// must NOT surface through the IAlarmSource fan-out: quality is delivered out of band via the
/// dedicated node path, never as an AlarmEventArgs (which would materialize / historize a condition).</summary>
[Fact]
public async Task QualityChanged_emission_raises_no_alarm_event()
{
var (engine, source, up) = await BuildAsync();
using var _e = engine;
using var _s = source;
var events = new List<AlarmEventArgs>();
source.OnAlarmEvent += (_, e) => events.Add(e);
await source.SubscribeAlarmsAsync([], TestContext.Current.CancellationToken);
// Drive HighTemp's input Bad: the predicate freezes (no transition), but the worst-quality bucket
// moves Good→Bad → the engine emits QualityChanged. The source must swallow it.
up.Push("Temp", 50, 0x80000000u);
await Task.Delay(200);
events.ShouldBeEmpty();
}
/// <summary>Verifies that AcknowledgeAsync routes to the engine with a default user.</summary>
[Fact]
public async Task AcknowledgeAsync_routes_to_engine_with_default_user()
@@ -30,6 +30,8 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
public sealed class NativeAlarmEventIdentityFieldDeliveryTests
{
private const string ServerUri = "urn:OtOpcUa.AlarmEventIdentityFields";
private const string ConditionClassServerUri = "urn:OtOpcUa.AlarmEventConditionClassFields";
private const string QualityServerUri = "urn:OtOpcUa.AlarmEventQualityField";
private const string RawDeviceFolder = "pymodbus/plc";
private const string RawAlarmPath = "pymodbus/plc/HR200";
@@ -45,6 +47,18 @@ public sealed class NativeAlarmEventIdentityFieldDeliveryTests
private const int MessageIndex = 4;
private const int SeverityIndex = 5;
// Field indices in BuildConditionClassEventFilter's clause (#475). A SEPARATE clause on purpose: the
// ScadaBridge one above is mirrored field-for-field from the real consumer and its indices are load-bearing,
// so appending to it would silently shift them. Message is re-selected here only as the collector's filter key.
private const int ConditionClassIdIndex = 0;
private const int ConditionClassNameIndex = 1;
private const int ConditionClassMessageIndex = 2;
// #477 — indices in BuildQualityEventFilter's clause [Quality, Message]. Again SEPARATE from the load-bearing
// ScadaBridge clause so its indices don't shift.
private const int QualityIndex = 0;
private const int QualityMessageIndex = 1;
/// <summary>A live native condition event delivers a populated EventType / SourceNode / SourceName to a
/// Server-object subscriber using the standard BaseEventType select clause.</summary>
[Fact]
@@ -67,7 +81,7 @@ public sealed class NativeAlarmEventIdentityFieldDeliveryTests
var rawNs = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri);
rawNs.ShouldBeGreaterThan((ushort)0);
var collector = new EventCollector();
var collector = new EventCollector(MessageIndex);
var subscription = new Subscription(session.DefaultSubscription) { PublishingInterval = 100 };
subscription.FastEventCallback = collector.OnEvents;
session.AddSubscription(subscription);
@@ -75,7 +89,7 @@ public sealed class NativeAlarmEventIdentityFieldDeliveryTests
// The collector filters by our unique Message, so the item's ClientHandle is not needed here
// (unlike the sibling multi-notifier test, which tallies delivery per monitored item).
AddEventItem(subscription, ObjectIds.Server);
AddEventItem(subscription, ObjectIds.Server, BuildEventFilter());
await subscription.ApplyChangesAsync(ct);
sink.WriteAlarmCondition(RawAlarmPath, ActiveSnapshot(), DateTime.UtcNow, AddressSpaceRealm.Raw);
@@ -113,6 +127,114 @@ public sealed class NativeAlarmEventIdentityFieldDeliveryTests
}
}
/// <summary>Issue #475 — a live condition event delivers a populated ConditionClassId / ConditionClassName.
/// Both previously arrived unset (NodeId.Null / empty text) via the same mechanism as the #473 fields, so an
/// HMI bucketing alarms by condition class dropped every OtOpcUa alarm into an unclassified bin.</summary>
[Fact]
public async Task Condition_event_carries_populated_ConditionClass_fields_on_the_wire()
{
var pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-alarm-condclass-{Guid.NewGuid():N}");
var port = AllocateFreePort();
var ct = TestContext.Current.CancellationToken;
try
{
var (server, host) = await BootServerAsync(port, pkiRoot + "-srv", ConditionClassServerUri, ct);
await using var _ = host;
var sink = new SdkAddressSpaceSink(server.NodeManager!);
WireCondition(sink);
using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct);
var collector = new EventCollector(ConditionClassMessageIndex);
var subscription = new Subscription(session.DefaultSubscription) { PublishingInterval = 100 };
subscription.FastEventCallback = collector.OnEvents;
session.AddSubscription(subscription);
await subscription.CreateAsync(ct);
AddEventItem(subscription, ObjectIds.Server, BuildConditionClassEventFilter());
await subscription.ApplyChangesAsync(ct);
sink.WriteAlarmCondition(RawAlarmPath, ActiveSnapshot(), DateTime.UtcNow, AddressSpaceRealm.Raw);
await WaitUntilAsync(() => collector.Fields.Count >= 1, TimeSpan.FromSeconds(5));
var fields = collector.Fields.ShouldHaveSingleItem();
// ConditionClassId — a resolvable class NodeId, NOT NodeId.Null. We model no condition classes, so
// Part 9's "no class modelled" value (BaseConditionClassType) is the conformant report.
fields[ConditionClassIdIndex].Value.ShouldBe(ObjectTypeIds.BaseConditionClassType,
"ConditionClassId must carry a resolvable condition class, not null");
// ConditionClassName — the matching human-readable name, NOT empty text.
var className = fields[ConditionClassNameIndex].Value.ShouldBeOfType<LocalizedText>();
className.Text.ShouldBe("BaseConditionClass",
"ConditionClassName must name the reported condition class, not be empty");
await subscription.DeleteAsync(true, ct);
}
finally
{
SafeDelete(pkiRoot + "-srv");
}
}
/// <summary>Issue #477 — the over-the-wire proof that a native condition's source-data Quality tracks its
/// driver's connectivity. A comms-lost source (<c>WriteAlarmQuality(Bad)</c>) delivers a condition event
/// whose <c>Quality</c> is non-Good, and recovery (<c>WriteAlarmQuality(Good)</c>) delivers Good again — so a
/// client can distinguish "genuinely inactive" from "we have lost contact". Before the fix the field was the
/// accidentally-Good default (<c>default(StatusCode) == Good</c>) on every event regardless of the source.</summary>
[Fact]
public async Task Condition_event_Quality_tracks_source_connectivity_on_the_wire()
{
var pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-alarm-quality-{Guid.NewGuid():N}");
var port = AllocateFreePort();
var ct = TestContext.Current.CancellationToken;
try
{
var (server, host) = await BootServerAsync(port, pkiRoot + "-srv", QualityServerUri, ct);
await using var _ = host;
var sink = new SdkAddressSpaceSink(server.NodeManager!);
WireCondition(sink);
using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct);
var collector = new EventCollector(QualityMessageIndex);
var subscription = new Subscription(session.DefaultSubscription) { PublishingInterval = 100 };
subscription.FastEventCallback = collector.OnEvents;
session.AddSubscription(subscription);
await subscription.CreateAsync(ct);
AddEventItem(subscription, ObjectIds.Server, BuildQualityEventFilter());
await subscription.ApplyChangesAsync(ct);
// Raise the alarm while the source is healthy → Quality Good.
sink.WriteAlarmCondition(RawAlarmPath, ActiveSnapshot(), DateTime.UtcNow, AddressSpaceRealm.Raw);
await WaitUntilAsync(() => collector.Fields.Count >= 1, TimeSpan.FromSeconds(5));
StatusCode.IsGood((StatusCode)collector.Fields[^1][QualityIndex].Value)
.ShouldBeTrue("a healthy source's condition reports Good quality");
// Device goes comms-lost (connectivity path) → the next condition event carries a Bad Quality, while
// the alarm stays active (annotation only — not asserted here, covered by the node-level test).
sink.WriteAlarmQuality(RawAlarmPath, OpcUaQuality.Bad, DateTime.UtcNow, AddressSpaceRealm.Raw);
await WaitUntilAsync(() => collector.Fields.Count >= 2, TimeSpan.FromSeconds(5));
StatusCode.IsBad((StatusCode)collector.Fields[^1][QualityIndex].Value)
.ShouldBeTrue("a comms-lost source's condition must report non-Good quality on the wire");
// Device recovers → Quality returns to Good.
sink.WriteAlarmQuality(RawAlarmPath, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
await WaitUntilAsync(() => collector.Fields.Count >= 3, TimeSpan.FromSeconds(5));
StatusCode.IsGood((StatusCode)collector.Fields[^1][QualityIndex].Value)
.ShouldBeTrue("a recovered source's condition reports Good quality again");
await subscription.DeleteAsync(true, ct);
}
finally
{
SafeDelete(pkiRoot + "-srv");
}
}
/// <summary>Materialise the raw device folder + the native condition at its RawPath, plus one referencing
/// equipment folder wired as a notifier (the production topology).</summary>
private static void WireCondition(SdkAddressSpaceSink sink)
@@ -127,13 +249,13 @@ public sealed class NativeAlarmEventIdentityFieldDeliveryTests
new(Active: true, Acknowledged: false, Confirmed: true, Enabled: true,
Shelving: AlarmShelvingKind.Unshelved, Severity: 700, Message: AlarmMessage);
private static MonitoredItem AddEventItem(Subscription subscription, NodeId source)
private static MonitoredItem AddEventItem(Subscription subscription, NodeId source, EventFilter filter)
{
var item = new MonitoredItem(subscription.DefaultItem)
{
StartNodeId = source,
AttributeId = Attributes.EventNotifier,
Filter = BuildEventFilter(),
Filter = filter,
QueueSize = 100,
SamplingInterval = 0,
};
@@ -154,9 +276,34 @@ public sealed class NativeAlarmEventIdentityFieldDeliveryTests
return filter;
}
/// <summary>#475's clause: [ConditionClassId, ConditionClassName, Message]. The two class fields are declared on
/// <c>ConditionType</c>, not BaseEventType, so they must be selected against that type or the server returns no
/// value for them regardless of the fix. Message rides along solely as the collector's filter key.</summary>
private static EventFilter BuildConditionClassEventFilter()
{
var filter = new EventFilter();
filter.AddSelectClause(ObjectTypes.ConditionType, BrowseNames.ConditionClassId);
filter.AddSelectClause(ObjectTypes.ConditionType, BrowseNames.ConditionClassName);
filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.Message);
return filter;
}
/// <summary>#477's clause: [Quality, Message]. Quality is declared on <c>ConditionType</c>, so it must be
/// selected against that type. Message rides along as the collector's filter key (a quality-only change still
/// snapshots the unchanged Message).</summary>
private static EventFilter BuildQualityEventFilter()
{
var filter = new EventFilter();
filter.AddSelectClause(ObjectTypes.ConditionType, BrowseNames.Quality);
filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.Message);
return filter;
}
/// <summary>Captures the delivered event field lists, filtered to our unique alarm Message so unrelated
/// server events / refresh brackets never count.</summary>
private sealed class EventCollector
/// <param name="messageIndex">Position of the Message field in the select clause this collector is paired
/// with — the two clauses here place it differently, so it cannot be a shared constant.</param>
private sealed class EventCollector(int messageIndex)
{
private readonly object _gate = new();
private readonly List<VariantCollection> _fields = new();
@@ -172,8 +319,8 @@ public sealed class NativeAlarmEventIdentityFieldDeliveryTests
{
foreach (var e in notification.Events)
{
if (e.EventFields.Count > MessageIndex &&
e.EventFields[MessageIndex].Value is LocalizedText lt &&
if (e.EventFields.Count > messageIndex &&
e.EventFields[messageIndex].Value is LocalizedText lt &&
lt.Text == AlarmMessage)
{
_fields.Add(e.EventFields);
@@ -163,6 +163,8 @@ public sealed class AddressSpaceApplierFailureSurfaceTests
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
{
if (ThrowOnAlarmWrite) throw new InvalidOperationException("simulated WriteAlarmCondition fault");
@@ -312,6 +312,7 @@ public sealed class AddressSpaceApplierHierarchyTests : IDisposable
/// <param name="alarmNodeId">The node ID of the alarm condition.</param>
/// <param name="state">The full condition state snapshot.</param>
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
/// <summary>Materialises an alarm condition (stub implementation for testing).</summary>
/// <param name="alarmNodeId">The alarm node ID (== ScriptedAlarmId).</param>
@@ -301,6 +301,7 @@ public sealed class AddressSpaceApplierRawUnsTests
=> References.Add((sourceNodeId, sourceRealm, targetNodeId, targetRealm, referenceType));
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
public void RebuildAddressSpace() { }
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm) { }
@@ -2268,6 +2268,7 @@ public sealed class AddressSpaceApplierTests
/// <param name="alarmNodeId">The alarm node ID.</param>
/// <param name="state">The full condition state snapshot.</param>
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
=> AlarmQueue.Enqueue((alarmNodeId, state));
/// <summary>Records an alarm-condition materialise call.</summary>
@@ -2322,6 +2323,7 @@ public sealed class AddressSpaceApplierTests
/// <summary>Records a value write (no-op in this sink).</summary>
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
/// <summary>No-op alarm condition write call.</summary>
public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
/// <summary>No-op alarm-condition materialise call.</summary>
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { }
@@ -2355,6 +2357,7 @@ public sealed class AddressSpaceApplierTests
/// <param name="state">The full condition state snapshot.</param>
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
/// <exception cref="InvalidOperationException">Thrown when configured to throw on alarm write.</exception>
public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
{
if (_throwOnAlarmWrite) throw new InvalidOperationException("simulated sink fault");
@@ -231,6 +231,7 @@ public sealed class DeferredAddressSpaceSinkTests
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
=> CallQueue.Enqueue($"WV:{nodeId}");
/// <inheritdoc />
public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
=> CallQueue.Enqueue($"WA:{alarmNodeId}");
/// <inheritdoc />
@@ -289,6 +290,7 @@ public sealed class DeferredAddressSpaceSinkTests
/// <inheritdoc />
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
/// <inheritdoc />
public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
/// <inheritdoc />
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { }
@@ -23,6 +23,12 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
/// scripted), matching <c>ConditionId</c>. The leaf/display name stays on <c>ConditionName</c>, so
/// no information is lost by SourceName carrying the unique id rather than the ambiguous leaf.
/// </para>
/// <para>
/// Issue #475 — the same mechanism leaves the mandatory <c>ConditionType</c> classification fields
/// <c>ConditionClassId</c> / <c>ConditionClassName</c> unset. The contract locked in here: a server that
/// does not model condition classes must still report <c>BaseConditionClassType</c> (Part 9's
/// "no class modelled" value) rather than null. See <c>docs/AlarmTracking.md</c>.
/// </para>
/// </summary>
public sealed class NodeManagerAlarmSourceFieldsTests : IDisposable
{
@@ -144,6 +150,166 @@ public sealed class NodeManagerAlarmSourceFieldsTests : IDisposable
}
/// <summary>#475 — a NATIVE condition (Raw realm) carries the mandatory ConditionType classification fields.
/// We do not model condition classes, so Part 9's "no class modelled" value — BaseConditionClassType — is the
/// conformant answer; the point is that it is a resolvable class NodeId a client can bucket on rather than a
/// null that drops the alarm into an unclassified bin.</summary>
[Trait("Category", "Unit")]
[Fact]
public async Task Native_condition_carries_ConditionClassId_and_ConditionClassName()
{
await using var host = await BootAsync();
var nm = host.Nm;
nm.EnsureFolder(RawDeviceFolder, parentNodeId: null, displayName: "dev1", realm: AddressSpaceRealm.Raw);
nm.MaterialiseAlarmCondition(RawAlarmPath, RawDeviceFolder, "HR200", "OffNormalAlarm", 700,
realm: AddressSpaceRealm.Raw, isNative: true);
var condition = nm.TryGetAlarmCondition(RawAlarmPath, AddressSpaceRealm.Raw);
condition.ShouldNotBeNull();
condition.ConditionClassId.ShouldNotBeNull();
condition.ConditionClassId.Value.ShouldBe(ObjectTypeIds.BaseConditionClassType);
condition.ConditionClassName.ShouldNotBeNull();
condition.ConditionClassName.Value.ShouldNotBeNull();
condition.ConditionClassName.Value.Text.ShouldBe("BaseConditionClass");
}
/// <summary>#475 — a SCRIPTED condition (UNS realm) carries the SAME classification fields: native and scripted
/// share the materialise path, so neither may regress independently.</summary>
[Trait("Category", "Unit")]
[Fact]
public async Task Scripted_condition_carries_ConditionClassId_and_ConditionClassName()
{
await using var host = await BootAsync();
var nm = host.Nm;
nm.EnsureFolder("eq-4", parentNodeId: null, displayName: "Station 4", realm: AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("tank-dry", "eq-4", "Tank Dry", "OffNormalAlarm", 700,
realm: AddressSpaceRealm.Uns, isNative: false);
var condition = nm.TryGetAlarmCondition("tank-dry", AddressSpaceRealm.Uns);
condition.ShouldNotBeNull();
condition.ConditionClassId!.Value.ShouldBe(ObjectTypeIds.BaseConditionClassType);
condition.ConditionClassName!.Value.Text.ShouldBe("BaseConditionClass");
}
/// <summary>#477 — a freshly materialised NATIVE condition reports BadWaitingForInitialData quality (not the
/// accidentally-Good default), matching the value-variable "no driver data yet" convention. Its quality only
/// becomes Good once its driver's connectivity confirms it (Layer 2).</summary>
[Trait("Category", "Unit")]
[Fact]
public async Task Native_condition_materialises_with_waiting_for_initial_data_quality()
{
await using var host = await BootAsync();
var nm = host.Nm;
nm.EnsureFolder(RawDeviceFolder, parentNodeId: null, displayName: "dev1", realm: AddressSpaceRealm.Raw);
nm.MaterialiseAlarmCondition(RawAlarmPath, RawDeviceFolder, "HR200", "OffNormalAlarm", 700,
realm: AddressSpaceRealm.Raw, isNative: true);
var condition = nm.TryGetAlarmCondition(RawAlarmPath, AddressSpaceRealm.Raw);
condition.ShouldNotBeNull();
condition.Quality.ShouldNotBeNull();
condition.Quality.Value.ShouldBe((StatusCode)StatusCodes.BadWaitingForInitialData);
StatusCode.IsGood(condition.Quality.Value).ShouldBeFalse();
}
/// <summary>#477 — a SCRIPTED condition is script-computed and always "live" in v1, so it materialises Good.
/// (Scripted worst-of-input quality is deferred to Layer 3.)</summary>
[Trait("Category", "Unit")]
[Fact]
public async Task Scripted_condition_materialises_with_good_quality()
{
await using var host = await BootAsync();
var nm = host.Nm;
nm.EnsureFolder("eq-q1", parentNodeId: null, displayName: "Station Q1", realm: AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("scripted-q", "eq-q1", "Scripted Q", "OffNormalAlarm", 700,
realm: AddressSpaceRealm.Uns, isNative: false);
var condition = nm.TryGetAlarmCondition("scripted-q", AddressSpaceRealm.Uns);
condition.ShouldNotBeNull();
condition.Quality.ShouldNotBeNull();
StatusCode.IsGood(condition.Quality.Value).ShouldBeTrue();
}
/// <summary>#477 — WriteAlarmCondition projects the snapshot's Quality onto the live condition node, so a
/// Bad-quality snapshot (comms-lost source) makes the condition report non-Good.</summary>
[Trait("Category", "Unit")]
[Fact]
public async Task WriteAlarmCondition_projects_snapshot_quality_onto_the_condition()
{
await using var host = await BootAsync();
var nm = host.Nm;
nm.EnsureFolder(RawDeviceFolder, parentNodeId: null, displayName: "dev1", realm: AddressSpaceRealm.Raw);
nm.MaterialiseAlarmCondition(RawAlarmPath, RawDeviceFolder, "HR200", "OffNormalAlarm", 700,
realm: AddressSpaceRealm.Raw, isNative: true);
// A comms-lost snapshot: the alarm state is unchanged but Quality is Bad.
var badSnapshot = new AlarmConditionSnapshot(
Active: false, Acknowledged: true, Confirmed: true, Enabled: true,
Shelving: AlarmShelvingKind.Unshelved, Severity: 700, Message: "HR200",
Quality: OpcUaQuality.Bad);
nm.WriteAlarmCondition(RawAlarmPath, badSnapshot, DateTime.UtcNow, AddressSpaceRealm.Raw);
var condition = nm.TryGetAlarmCondition(RawAlarmPath, AddressSpaceRealm.Raw);
condition.ShouldNotBeNull();
StatusCode.IsBad(condition.Quality.Value).ShouldBeTrue();
// Recover: a Good snapshot restores Good quality.
var goodSnapshot = badSnapshot with { Quality = OpcUaQuality.Good };
nm.WriteAlarmCondition(RawAlarmPath, goodSnapshot, DateTime.UtcNow, AddressSpaceRealm.Raw);
StatusCode.IsGood(condition.Quality.Value).ShouldBeTrue();
}
/// <summary>#477 Layer 2 — the dedicated connectivity-quality path sets ONLY the condition's Quality
/// (comms lost → Bad, restored → Good) and never clobbers the node's severity / message / alarm state:
/// it is a pure annotation applied out-of-band from any alarm transition.</summary>
[Trait("Category", "Unit")]
[Fact]
public async Task WriteAlarmQuality_sets_only_quality_without_touching_state_or_severity()
{
await using var host = await BootAsync();
var nm = host.Nm;
nm.EnsureFolder(RawDeviceFolder, parentNodeId: null, displayName: "dev1", realm: AddressSpaceRealm.Raw);
nm.MaterialiseAlarmCondition(RawAlarmPath, RawDeviceFolder, "HR200", "OffNormalAlarm", 700,
realm: AddressSpaceRealm.Raw, isNative: true);
var condition = nm.TryGetAlarmCondition(RawAlarmPath, AddressSpaceRealm.Raw);
condition.ShouldNotBeNull();
var severityBefore = condition.Severity!.Value;
var messageBefore = condition.Message!.Value?.Text;
// Device comes online → Good.
nm.WriteAlarmQuality(RawAlarmPath, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
StatusCode.IsGood(condition.Quality!.Value).ShouldBeTrue();
// Device goes comms-lost → Bad, but severity / message / active-ack state are untouched.
nm.WriteAlarmQuality(RawAlarmPath, OpcUaQuality.Bad, DateTime.UtcNow, AddressSpaceRealm.Raw);
StatusCode.IsBad(condition.Quality.Value).ShouldBeTrue();
condition.Severity.Value.ShouldBe(severityBefore);
condition.Message.Value?.Text.ShouldBe(messageBefore);
condition.ActiveState!.Id!.Value.ShouldBeFalse(); // unchanged: annotation only
}
/// <summary>#477 Layer 2 — WriteAlarmQuality on an unknown / unmaterialised node is a safe no-op (never
/// throws), mirroring the other write paths: a mid-rebuild race must not fault a connectivity update.</summary>
[Trait("Category", "Unit")]
[Fact]
public async Task WriteAlarmQuality_is_a_noop_for_an_unknown_condition()
{
await using var host = await BootAsync();
var nm = host.Nm;
Should.NotThrow(() =>
nm.WriteAlarmQuality("no/such/node", OpcUaQuality.Bad, DateTime.UtcNow, AddressSpaceRealm.Raw));
}
/// <summary>A booted server + its node manager, disposed via <c>await using</c> so an assertion failure
/// cannot leak a live server (and its bound port) into the rest of the test run.</summary>
private sealed class BootedServer(OpcUaApplicationHost host, OtOpcUaNodeManager nm) : IAsyncDisposable
@@ -523,7 +523,8 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
{
var baseState = new OtOpcUaNodeManager.AlarmConditionDelta(
Active: true, Acknowledged: false, Confirmed: true, Enabled: true,
Shelving: AlarmShelvingKind.Unshelved, MappedSeverity: 100, Message: "m");
Shelving: AlarmShelvingKind.Unshelved, MappedSeverity: 100, Message: "m",
Quality: StatusCodes.Good);
// Equal ⇒ suppress (this is the inbound double-emit case in pure form).
OtOpcUaNodeManager.ShouldFireConditionEvent(baseState, baseState).ShouldBeFalse();
@@ -537,6 +538,8 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
OtOpcUaNodeManager.ShouldFireConditionEvent(baseState, baseState with { Shelving = AlarmShelvingKind.Timed }).ShouldBeTrue();
OtOpcUaNodeManager.ShouldFireConditionEvent(baseState, baseState with { MappedSeverity = 900 }).ShouldBeTrue();
OtOpcUaNodeManager.ShouldFireConditionEvent(baseState, baseState with { Message = "other" }).ShouldBeTrue();
// #477 — a quality-only change (e.g. source going comms-lost, no state change) is a genuine delta.
OtOpcUaNodeManager.ShouldFireConditionEvent(baseState, baseState with { Quality = StatusCodes.Bad }).ShouldBeTrue();
}
/// <summary>T20 — null-vs-empty Message normalization. Both snapshot.Message = null and a live node
@@ -552,7 +555,8 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
// Both null and "" collapse to string.Empty in AlarmConditionDelta — they are the same delta value.
var withNull = new OtOpcUaNodeManager.AlarmConditionDelta(
Active: false, Acknowledged: true, Confirmed: true, Enabled: true,
Shelving: AlarmShelvingKind.Unshelved, MappedSeverity: 100, Message: string.Empty);
Shelving: AlarmShelvingKind.Unshelved, MappedSeverity: 100, Message: string.Empty,
Quality: StatusCodes.Good);
var withEmpty = withNull with { Message = string.Empty };
@@ -328,6 +328,7 @@ public sealed class DiscoveryInjectionEndToEndTests : RuntimeActorTestBase
=> _values.Enqueue((nodeId, value, quality, sourceTimestampUtc));
/// <summary>No-op: alarm writes are not exercised by this suite.</summary>
public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
/// <summary>No-op: alarm materialise is not exercised by this suite.</summary>
@@ -271,6 +271,46 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
evt.User.ShouldBe("device");
}
/// <summary>#477 Layer 2 — a driver <see cref="DriverInstanceActor.ConnectivityChanged"/> annotates the
/// Quality of the driver's native conditions via <see cref="OpcUaPublishActor.AlarmQualityUpdate"/>: comms
/// lost → Bad, restored → Good, at the raw condition NodeId + realm. No alarm transition is involved — this
/// is the ONLY signal for a comms-lost native source.</summary>
[Fact]
public void Driver_disconnect_and_reconnect_annotate_native_condition_quality()
{
var db = NewInMemoryDbFactory();
var deploymentId = SeedV3AlarmDeployment(db, RevA, Driver: "drv-1", Tag: "temp_hi");
var (actor, publish) = SpawnHostAndApply(db, deploymentId);
// Comms lost → the raw condition is annotated Bad.
actor.Tell(new DriverInstanceActor.ConnectivityChanged("drv-1", Connected: false));
var bad = publish.ExpectMsg<OpcUaPublishActor.AlarmQualityUpdate>(TimeSpan.FromSeconds(5));
bad.AlarmNodeId.ShouldBe(AlarmRawPath);
bad.Realm.ShouldBe(AddressSpaceRealm.Raw);
bad.Quality.ShouldBe(OpcUaQuality.Bad);
// Comms restored → annotated Good.
actor.Tell(new DriverInstanceActor.ConnectivityChanged("drv-1", Connected: true));
var good = publish.ExpectMsg<OpcUaPublishActor.AlarmQualityUpdate>(TimeSpan.FromSeconds(5));
good.AlarmNodeId.ShouldBe(AlarmRawPath);
good.Quality.ShouldBe(OpcUaQuality.Good);
}
/// <summary>#477 Layer 2 — connectivity for a DIFFERENT driver instance annotates none of this driver's
/// conditions (the fan-out is scoped by DriverInstanceId).</summary>
[Fact]
public void Connectivity_for_another_driver_annotates_nothing()
{
var db = NewInMemoryDbFactory();
var deploymentId = SeedV3AlarmDeployment(db, RevA, Driver: "drv-1", Tag: "temp_hi");
var (actor, publish) = SpawnHostAndApply(db, deploymentId);
actor.Tell(new DriverInstanceActor.ConnectivityChanged("drv-OTHER", Connected: false));
publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
}
/// <summary>Subscribe <paramref name="probe"/> to the <c>alerts</c> DPS topic and wait for the ack.</summary>
private void SubscribeToAlerts(TestProbe probe)
{
@@ -30,6 +30,9 @@ public sealed class DriverInstanceActorDiscoveryTests : RuntimeActorTestBase
{
var driver = new DiscoverableStubDriver();
var parent = CreateTestProbe();
// #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/
// disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it.
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
// Tiny interval so the bounded retry runs in well under a second (no real-time waits).
var actor = parent.ChildActorOf(DriverInstanceActor.Props(
driver, rediscoverInterval: TimeSpan.FromMilliseconds(20)));
@@ -68,6 +71,9 @@ public sealed class DriverInstanceActorDiscoveryTests : RuntimeActorTestBase
{
var driver = new SubscribableStubDriver(); // IDriver + ISubscribable, NOT ITagDiscovery
var parent = CreateTestProbe();
// #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/
// disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it.
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
var actor = parent.ChildActorOf(DriverInstanceActor.Props(
driver, rediscoverInterval: TimeSpan.FromMilliseconds(20)));
@@ -92,6 +98,9 @@ public sealed class DriverInstanceActorDiscoveryTests : RuntimeActorTestBase
{
var driver = new DiscoverableStubDriver();
var parent = CreateTestProbe();
// #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/
// disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it.
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
// Tiny reconnect + rediscover intervals so the whole reconnect-then-rediscover cycle runs fast.
var actor = parent.ChildActorOf(DriverInstanceActor.Props(
driver,
@@ -133,6 +142,9 @@ public sealed class DriverInstanceActorDiscoveryTests : RuntimeActorTestBase
{
var driver = new YieldingDiscoverableStubDriver();
var parent = CreateTestProbe();
// #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/
// disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it.
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
var actor = parent.ChildActorOf(DriverInstanceActor.Props(
driver, rediscoverInterval: TimeSpan.FromMilliseconds(20)));
@@ -167,6 +179,9 @@ public sealed class DriverInstanceActorDiscoveryTests : RuntimeActorTestBase
driverType: "Stub");
var driver = new YieldingDiscoverableStubDriver();
var parent = CreateTestProbe();
// #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/
// disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it.
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
var actor = parent.ChildActorOf(DriverInstanceActor.Props(
driver, rediscoverInterval: TimeSpan.FromMilliseconds(20), invoker: invoker));
@@ -187,6 +202,9 @@ public sealed class DriverInstanceActorDiscoveryTests : RuntimeActorTestBase
{
var driver = new GrowingDiscoverableStubDriver();
var parent = CreateTestProbe();
// #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/
// disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it.
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
var actor = parent.ChildActorOf(DriverInstanceActor.Props(
driver, rediscoverInterval: TimeSpan.FromMilliseconds(20), rediscoverMaxAttempts: 3));
@@ -215,6 +233,9 @@ public sealed class DriverInstanceActorDiscoveryTests : RuntimeActorTestBase
{
var driver = new DiscoverableStubDriver(DiscoveryRediscoverPolicy.Never);
var parent = CreateTestProbe();
// #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/
// disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it.
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
var actor = parent.ChildActorOf(DriverInstanceActor.Props(
driver, rediscoverInterval: TimeSpan.FromMilliseconds(20)));
@@ -239,6 +260,9 @@ public sealed class DriverInstanceActorDiscoveryTests : RuntimeActorTestBase
{
var driver = new GrowingDiscoverableStubDriver(DiscoveryRediscoverPolicy.Once);
var parent = CreateTestProbe();
// #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/
// disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it.
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
var actor = parent.ChildActorOf(DriverInstanceActor.Props(
driver, rediscoverInterval: TimeSpan.FromMilliseconds(20)));
@@ -269,6 +293,9 @@ public sealed class DriverInstanceActorDiscoveryTests : RuntimeActorTestBase
{
var driver = new GrowingDiscoverableStubDriver(DiscoveryRediscoverPolicy.Once);
var parent = CreateTestProbe();
// #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/
// disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it.
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
// Small reconnect + rediscover intervals so the cycle runs fast; cap 3 so a (wrong) full loop is
// visibly more than the one pass Once must run per (re)connect.
var actor = parent.ChildActorOf(DriverInstanceActor.Props(
@@ -314,6 +341,9 @@ public sealed class DriverInstanceActorDiscoveryTests : RuntimeActorTestBase
// Props must accept the new optional parameter — no throw and actor starts normally.
var driver = new DiscoverableStubDriver();
var parent = CreateTestProbe();
// #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/
// disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it.
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
var actor = parent.ChildActorOf(DriverInstanceActor.Props(
driver,
rediscoverInterval: TimeSpan.FromMilliseconds(20),
@@ -340,6 +370,9 @@ public sealed class DriverInstanceActorDiscoveryTests : RuntimeActorTestBase
// (no second settling pass to drain, and no stale-tick double pass alongside the fresh one).
var driver = new GrowingDiscoverableStubDriver(DiscoveryRediscoverPolicy.Once);
var parent = CreateTestProbe();
// #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/
// disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it.
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
var actor = parent.ChildActorOf(DriverInstanceActor.Props(
driver, rediscoverInterval: TimeSpan.FromMilliseconds(20)));
@@ -373,6 +406,9 @@ public sealed class DriverInstanceActorDiscoveryTests : RuntimeActorTestBase
{
var driver = new DiscoverableStubDriver(DiscoveryRediscoverPolicy.Never);
var parent = CreateTestProbe();
// #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/
// disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it.
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
var actor = parent.ChildActorOf(DriverInstanceActor.Props(
driver, rediscoverInterval: TimeSpan.FromMilliseconds(20)));
@@ -400,6 +436,9 @@ public sealed class DriverInstanceActorDiscoveryTests : RuntimeActorTestBase
// a long reconnect interval so the actor parks in Reconnecting deterministically within the test window.
var driver = new GrowingDiscoverableStubDriver(DiscoveryRediscoverPolicy.Once);
var parent = CreateTestProbe();
// #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/
// disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it.
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
var actor = parent.ChildActorOf(DriverInstanceActor.Props(
driver,
reconnectInterval: TimeSpan.FromSeconds(30),
@@ -30,6 +30,9 @@ public sealed class DriverInstanceActorNativeAlarmTests : RuntimeActorTestBase
{
var driver = new AlarmSourceStubDriver();
var parent = CreateTestProbe();
// #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/
// disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it.
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver));
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
@@ -77,6 +80,9 @@ public sealed class DriverInstanceActorNativeAlarmTests : RuntimeActorTestBase
{
var driver = new AlarmSourceStubDriver();
var parent = CreateTestProbe();
// #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/
// disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it.
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver));
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
@@ -110,6 +116,9 @@ public sealed class DriverInstanceActorNativeAlarmTests : RuntimeActorTestBase
{
var driver = new SubscribableOnlyStubDriver();
var parent = CreateTestProbe();
// #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/
// disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it.
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver));
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
@@ -170,6 +179,9 @@ public sealed class DriverInstanceActorNativeAlarmTests : RuntimeActorTestBase
// Long reconnect interval (default 10 s) so the retry doesn't fire during the assertion window.
var driver = new AlarmSourceStubDriver();
var parent = CreateTestProbe();
// #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/
// disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it.
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver));
// Drive to Connected; confirm the alarm handler is attached.
@@ -217,6 +229,9 @@ public sealed class DriverInstanceActorNativeAlarmTests : RuntimeActorTestBase
{
var driver = new AlarmSourceStubDriver();
var parent = CreateTestProbe();
// #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/
// disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it.
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver, reconnectInterval: TimeSpan.FromMilliseconds(50)));
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
@@ -245,6 +260,33 @@ public sealed class DriverInstanceActorNativeAlarmTests : RuntimeActorTestBase
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
}
/// <summary>#477 Layer 2 — the actor announces connectivity to its parent (the host): Connected on
/// reaching the Connected state, then Connected=false on a <see cref="DriverInstanceActor.DisconnectObserved"/>,
/// then Connected=true again on the reconnect. This is the signal the host uses to annotate the driver's
/// native alarm conditions' Quality (comms lost → Bad, restored → Good).</summary>
[Fact]
public void ConnectivityChanged_is_announced_to_parent_on_connect_disconnect_and_reconnect()
{
var driver = new AlarmSourceStubDriver();
var parent = CreateTestProbe(); // deliberately does NOT ignore ConnectivityChanged — it's the subject.
var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver, reconnectInterval: TimeSpan.FromMilliseconds(50)));
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
// Initial connect → Connected=true.
parent.FishForMessage<DriverInstanceActor.ConnectivityChanged>(m => true, TimeSpan.FromSeconds(3))
.Connected.ShouldBeTrue();
// Disconnect → Connected=false (this is what drives the conditions Bad).
actor.Tell(new DriverInstanceActor.DisconnectObserved("backend blip"));
parent.FishForMessage<DriverInstanceActor.ConnectivityChanged>(m => true, TimeSpan.FromSeconds(3))
.Connected.ShouldBeFalse();
// Reconnect → Connected=true again (drives them back Good).
parent.FishForMessage<DriverInstanceActor.ConnectivityChanged>(m => true, TimeSpan.FromSeconds(3))
.Connected.ShouldBeTrue();
}
// --- stub drivers ----------------------------------------------------------------------------
private class StubDriver : IDriver
@@ -109,6 +109,9 @@ public sealed class DriverInstanceActorTests : RuntimeActorTestBase
{
var driver = new SubscribableStubDriver();
var parent = CreateTestProbe();
// #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/
// disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it.
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver));
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
@@ -134,6 +137,9 @@ public sealed class DriverInstanceActorTests : RuntimeActorTestBase
{
var driver = new SubscribableStubDriver();
var parent = CreateTestProbe();
// #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/
// disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it.
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver));
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
@@ -162,6 +168,9 @@ public sealed class DriverInstanceActorTests : RuntimeActorTestBase
{
var driver = new SubscribableStubDriver();
var parent = CreateTestProbe();
// #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/
// disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it.
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver, reconnectInterval: TimeSpan.FromMilliseconds(50)));
// Desired set arrives BEFORE connect — retained, not yet applied.
@@ -197,6 +206,9 @@ public sealed class DriverInstanceActorTests : RuntimeActorTestBase
// is used — the exact condition that throws NotSupportedException on the subsequent Sender read.
var driver = new SubscribableStubDriver { UnsubscribeYields = true };
var parent = CreateTestProbe();
// #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/
// disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it.
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver));
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
@@ -242,6 +254,9 @@ public sealed class DriverInstanceActorTests : RuntimeActorTestBase
{
var driver = new SubscribableStubDriver();
var parent = CreateTestProbe();
// #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/
// disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it.
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver, reconnectInterval: TimeSpan.FromSeconds(30)));
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
@@ -271,6 +286,9 @@ public sealed class DriverInstanceActorTests : RuntimeActorTestBase
InitBehavior = cfg => cfg == good ? Task.CompletedTask : throw new InvalidOperationException("bad-cfg"),
};
var parent = CreateTestProbe();
// #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/
// disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it.
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver, reconnectInterval: TimeSpan.FromMilliseconds(50)));
actor.Tell(new DriverInstanceActor.SetDesiredSubscriptions(new[] { "tag-a" }, TimeSpan.FromMilliseconds(100)));
@@ -304,6 +322,9 @@ public sealed class DriverInstanceActorTests : RuntimeActorTestBase
InitBehavior = cfg => cfg == v1 ? gate1.Task : gate2.Task,
};
var parent = CreateTestProbe();
// #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/
// disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it.
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver, reconnectInterval: TimeSpan.FromSeconds(30)));
actor.Tell(new DriverInstanceActor.SetDesiredSubscriptions(new[] { "tag-a" }, TimeSpan.FromMilliseconds(100)));
@@ -153,6 +153,19 @@ public class NativeAlarmProjectorTests
snap.Acknowledged.ShouldBeTrue();
}
// #477 — native transitions carry Good quality; comms-loss quality is applied out-of-band via the
// dedicated WriteAlarmQuality path (connectivity → DriverHostActor), NOT through the projector, so the
// projector stays quality-agnostic and its transition snapshots default to Good.
[Fact]
public void Transition_projection_defaults_to_good_quality()
{
var sut = new NativeAlarmProjector();
var snap = sut.Project("n1", Evt(AlarmTransitionKind.Raise));
snap.Quality.ShouldBe(OpcUaQuality.Good);
}
private static AlarmEventArgs Evt(
AlarmTransitionKind kind,
AlarmSeverity sev = AlarmSeverity.High,
@@ -199,6 +199,7 @@ public sealed class OtOpcUaTelemetryHookTests : RuntimeActorTestBase
/// <param name="alarmNodeId">The alarm node identifier.</param>
/// <param name="state">The full condition state snapshot.</param>
/// <param name="occurredUtc">The time the alarm occurred in UTC.</param>
public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime occurredUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => Writes++;
/// <summary>Materialises an alarm condition (stub implementation).</summary>
/// <param name="alarmNodeId">The alarm node identifier.</param>
@@ -119,6 +119,7 @@ public sealed class OpcUaPublishActorApplyFailureTests : RuntimeActorTestBase
private sealed class ThrowOnRebuildSink : IOpcUaAddressSpaceSink
{
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime occurredUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { }
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
@@ -133,6 +134,7 @@ public sealed class OpcUaPublishActorApplyFailureTests : RuntimeActorTestBase
private sealed class NoopSink : IOpcUaAddressSpaceSink
{
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime occurredUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { }
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
@@ -443,6 +443,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
/// <param name="alarmNodeId">The alarm node ID.</param>
/// <param name="state">The full condition state snapshot.</param>
/// <param name="ts">The timestamp of the state change.</param>
public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime ts, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
=> Calls.Enqueue($"WA:{alarmNodeId}");
/// <summary>Records a materialise-alarm-condition call.</summary>
@@ -76,6 +76,26 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
}, duration: TimeSpan.FromMilliseconds(500));
}
/// <summary>#477 — AlarmQualityUpdate routes to sink.WriteAlarmQuality with the quality + realm.</summary>
[Fact]
public void AlarmQualityUpdate_routes_to_sink_WriteAlarmQuality()
{
var sink = new RecordingSink();
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink));
actor.Tell(new OpcUaPublishActor.AlarmQualityUpdate(
"Plant/Modbus/dev1/temp_hi", OpcUaQuality.Bad, DateTime.UtcNow, Realm: AddressSpaceRealm.Raw));
AwaitAssert(() =>
{
sink.AlarmQualityQueue.Count.ShouldBe(1);
sink.AlarmQualityQueue.TryPeek(out var q).ShouldBeTrue();
q.AlarmNodeId.ShouldBe("Plant/Modbus/dev1/temp_hi");
q.Quality.ShouldBe(OpcUaQuality.Bad);
q.Realm.ShouldBe(AddressSpaceRealm.Raw);
}, duration: TimeSpan.FromMilliseconds(500));
}
/// <summary>Builds a test <see cref="AlarmConditionSnapshot"/> with sensible defaults so each test
/// only specifies the fields it cares about.</summary>
private static AlarmConditionSnapshot Snapshot(
@@ -577,6 +597,8 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
public ConcurrentQueue<(string NodeId, object? Value, OpcUaQuality Quality, DateTime Ts)> ValueQueue { get; } = new();
/// <summary>Gets the queue of recorded alarm condition updates.</summary>
public ConcurrentQueue<(string AlarmNodeId, AlarmConditionSnapshot State, DateTime Ts)> AlarmQueue { get; } = new();
/// <summary>Gets the queue of recorded alarm-quality annotations (#477).</summary>
public ConcurrentQueue<(string AlarmNodeId, OpcUaQuality Quality, DateTime Ts, AddressSpaceRealm Realm)> AlarmQualityQueue { get; } = new();
/// <summary>Count of rebuild calls.</summary>
public int RebuildCalls;
/// <summary>Gets the queue of recorded EnsureFolder calls.</summary>
@@ -616,6 +638,10 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime ts, AddressSpaceRealm realm = AddressSpaceRealm.Uns) =>
AlarmQueue.Enqueue((alarmNodeId, state, ts));
/// <summary>Records an alarm-quality annotation (#477).</summary>
public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime ts, AddressSpaceRealm realm = AddressSpaceRealm.Uns) =>
AlarmQualityQueue.Enqueue((alarmNodeId, quality, ts, realm));
/// <summary>Materialises an alarm condition (no-op in test).</summary>
/// <param name="alarmNodeId">The alarm node ID.</param>
/// <param name="equipmentNodeId">The equipment folder node ID.</param>
@@ -664,6 +664,57 @@ public sealed class ScriptedAlarmHostActorTests : RuntimeActorTestBase
evtFalse.HistorizeToAveva.ShouldBe(false);
}
/// <summary>#478 — a Bad-quality dependency (no threshold crossing → no state transition) drives the
/// scripted condition's Quality out of band: the host publishes an <see cref="OpcUaPublishActor.AlarmQualityUpdate"/>
/// (Bad, Uns realm) and NO <c>/alerts</c> transition — quality is an annotation, not a state change.</summary>
[Fact]
public void Bad_quality_dependency_publishes_AlarmQualityUpdate_and_no_alerts()
{
var publish = CreateTestProbe();
var mux = CreateTestProbe();
var alerts = CreateTestProbe();
SubscribeToAlerts(alerts);
var (host, _) = Spawn(publish, mux);
host.Tell(new ScriptedAlarmHostActor.ApplyScriptedAlarms(new[] { Plan(id: "alm-1", depRef: "M.T", threshold: 90) }));
mux.ExpectMsg<DependencyMuxActor.RegisterInterest>(Timeout); // load completed
// Baseline is Good (unread inputs are not a quality signal — no load-time annotation). Drive the
// input Bad with a value below threshold: the predicate freezes (no transition), but the worst-input
// quality bucket moves Good→Bad → a QualityChanged annotation flows out of band.
host.Tell(new VirtualTagActor.DependencyValueChanged("M.T", 10, DateTime.UtcNow, OpcUaQuality.Bad));
var q = publish.FishForMessage<OpcUaPublishActor.AlarmQualityUpdate>(m => m.Quality == OpcUaQuality.Bad, Timeout);
q.AlarmNodeId.ShouldBe("alm-1");
q.Realm.ShouldBe(AddressSpaceRealm.Uns);
// A pure quality change is NOT a state transition: no /alerts row.
alerts.ExpectNoMsg(TimeSpan.FromMilliseconds(400));
}
/// <summary>#478 — when a transition fires while an input is Uncertain, the projected full snapshot
/// carries that worst-of-input quality (not a clobbered Good), so the condition node reflects that its
/// state is derived from untrustworthy inputs.</summary>
[Fact]
public void Transition_snapshot_carries_worst_input_quality()
{
var publish = CreateTestProbe();
var mux = CreateTestProbe();
var alerts = CreateTestProbe();
SubscribeToAlerts(alerts);
var (host, _) = Spawn(publish, mux);
host.Tell(new ScriptedAlarmHostActor.ApplyScriptedAlarms(new[] { Plan(id: "alm-1", depRef: "M.T", threshold: 90) }));
mux.ExpectMsg<DependencyMuxActor.RegisterInterest>(Timeout); // load completed
// Above threshold (activates) but Uncertain quality — Uncertain is "ready", so the predicate runs.
host.Tell(new VirtualTagActor.DependencyValueChanged("M.T", 99, DateTime.UtcNow, OpcUaQuality.Uncertain));
var state = publish.FishForMessage<OpcUaPublishActor.AlarmStateUpdate>(m => m.State.Active, Timeout);
state.AlarmNodeId.ShouldBe("alm-1");
state.State.Quality.ShouldBe(OpcUaQuality.Uncertain);
}
/// <summary>OneShotShelve transition carries the operator's identity: an operator-driven OneShotShelve
/// drives <c>OneShotShelveAsync</c> — the resulting <see cref="AlarmTransitionEvent"/>(<c>"Shelved"</c>)
/// on the alerts topic must carry <c>User == cmd.User</c> (the acting operator), NOT the generic
@@ -149,6 +149,21 @@ public sealed class DependencyMuxActorTests : RuntimeActorTestBase
subscriber.ExpectMsg<VirtualTagActor.DependencyValueChanged>().TagId.ShouldBe("ref-1");
}
/// <summary>#478 — the mux forwards the published source quality onto DependencyValueChanged so the
/// scripted-alarm host can derive a condition's worst-of-input quality.</summary>
[Fact]
public void Publish_quality_is_forwarded_on_DependencyValueChanged()
{
var mux = Sys.ActorOf(DependencyMuxActor.Props());
var sub = CreateTestProbe();
mux.Tell(new DependencyMuxActor.RegisterInterest(new[] { "tag-1" }, sub.Ref));
mux.Tell(new DriverInstanceActor.AttributeValuePublished(
"driver-1", "tag-1", 10, OpcUaQuality.Bad, DateTime.UtcNow));
sub.ExpectMsg<VirtualTagActor.DependencyValueChanged>().Quality.ShouldBe(OpcUaQuality.Bad);
}
private sealed class EchoSumEvaluator : ZB.MOM.WW.OtOpcUa.Commons.Engines.IVirtualTagEvaluator
{
/// <summary>Evaluates the expression by summing all dependency values as integers.</summary>