diff --git a/docs/AlarmTracking.md b/docs/AlarmTracking.md index 67c37038..8bf83275 100644 --- a/docs/AlarmTracking.md +++ b/docs/AlarmTracking.md @@ -68,6 +68,7 @@ are set explicitly. Leaving them unset shipped them as **null** on every event | `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** — tracks the native source's connectivity (`Good` / `Bad`) | 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. @@ -111,6 +112,43 @@ that `ConditionClassId` / `ConditionClassName` do too. The two class fields are > 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** are script-computed and always live in this scope, so they materialize `Good` and are +not connectivity-driven. Deriving a scripted condition's quality from the worst of its input tags' +qualities is a deferred follow-up (Layer 3). + +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 diff --git a/docs/plans/2026-07-17-alarm-condition-quality-477-design.md b/docs/plans/2026-07-17-alarm-condition-quality-477-design.md new file mode 100644 index 00000000..d5700872 --- /dev/null +++ b/docs/plans/2026-07-17-alarm-condition-quality-477-design.md @@ -0,0 +1,127 @@ +# 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=` 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` 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). diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/AlarmConditionSnapshot.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/AlarmConditionSnapshot.cs index 8eddf929..8069f34c 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/AlarmConditionSnapshot.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/AlarmConditionSnapshot.cs @@ -16,6 +16,12 @@ namespace ZB.MOM.WW.OtOpcUa.Commons.OpcUa; /// The shelving mode (ShelvingState): unshelved, one-shot, or timed. /// OPC UA severity on the 1..1000 scale (the SDK SetSeverity input). /// The human-readable condition message (LocalizedText payload). +/// +/// Quality of the condition's source data (Part 9 ConditionType.Quality). Carried so a +/// comms-lost native source can report a non-Good condition instead of the accidentally-Good +/// default (issue #477). It is a pure annotation — it never alters Active/Acked/Retain. Defaults to +/// so scripted-alarm callers (which stay Good in v1) need not supply it. +/// 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); /// /// Commons-local mirror of the Core ShelvingKind enum so this assembly carries no diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/DeferredAddressSpaceSink.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/DeferredAddressSpaceSink.cs index 6b8f2952..bd1b0f3c 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/DeferredAddressSpaceSink.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/DeferredAddressSpaceSink.cs @@ -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); + /// 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); diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/IOpcUaAddressSpaceSink.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/IOpcUaAddressSpaceSink.cs index 54913bd8..7a611a4f 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/IOpcUaAddressSpaceSink.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/IOpcUaAddressSpaceSink.cs @@ -33,6 +33,17 @@ public interface IOpcUaAddressSpaceSink /// the condition was materialised under). void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm); + /// #477 — annotate a materialised condition's source-data quality OUT OF BAND from any alarm + /// transition (used by the driver-connectivity path: comms lost → , + /// restored → ). 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. + /// The condition node id (RawPath for a native alarm). + /// The source-data quality to annotate. + /// The connectivity transition timestamp in UTC. + /// The namespace realm the condition was materialised under. + void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm); + /// /// 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 /// public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { } + public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { } /// public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs index 829ffb40..68b2a43a 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs @@ -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 } } + /// + /// #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 — 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. + /// + /// The condition node id (RawPath for a native alarm). + /// The source-data quality to annotate. + /// Timestamp of the connectivity transition in UTC. + /// The condition's address-space realm. + 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); + } + } + } + /// /// Fire a real OPC UA Part 9 condition event for one engine-driven state transition on a /// materialised . The caller MUST already hold Lock 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); /// Decide whether a 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); /// 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 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); /// Map the live shelving state machine's CurrentState back to our 3-way /// by matching its well-known Part 9 state object id. Any node without @@ -827,6 +891,20 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 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); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs index 814e8304..f6a458d8 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs @@ -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); + /// 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); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs index aa679d8f..4a87e53c 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs @@ -570,6 +570,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers Receive(HandleGetDiagnostics); Receive(ForwardToMux); Receive(ForwardNativeAlarm); + Receive(OnDriverConnectivityChanged); Receive(HandleDiscoveredNodes); Receive(HandleDeltaApplied); Receive(HandleRestartDriver); @@ -600,6 +601,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers Receive(HandleGetDiagnostics); Receive(ForwardToMux); Receive(ForwardNativeAlarm); + Receive(OnDriverConnectivityChanged); Receive(HandleDiscoveredNodes); Receive(HandleDeltaApplied); Receive(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. /// /// + /// + /// #477 — a child driver's connectivity transition. Annotates the source-data Quality of EVERY native + /// alarm condition the driver owns: comms lost → , restored → + /// . 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. + /// + /// UNGATED by redundancy role (like the condition write in ): a + /// Secondary keeps its address space — including condition quality — warm for failover. Quality is + /// a pure annotation: WriteAlarmQuality 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 + /// alerts row is published here — driver comms health has its own status surface + /// (); a row per condition would be alarm-fatigue. + /// + /// + 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(_ => { }); + // 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(_ => { }); // 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(HandleDeltaApplied); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs index 943c8b7d..3373c182 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs @@ -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); + /// #477 — sent to the parent () on every connectivity transition: + /// Connected=true on entering the Connected state, false 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 . + 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); /// @@ -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(HandleApplyDeltaAsync); Receive(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(_ => 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. diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs index 15e0fe51..c8d9d7d3 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs @@ -58,6 +58,15 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers /// The namespace realm the condition lives in — for /// scripted alarms (default), for v3 native raw conditions. public sealed record AlarmStateUpdate(string AlarmNodeId, AlarmConditionSnapshot State, DateTime TimestampUtc, AddressSpaceRealm Realm); + /// #477 — annotate a materialised condition's source-data Quality out of band from any alarm + /// transition (the driver-connectivity path: comms lost → , restored → + /// ). Routed to , + /// which sets ONLY Quality and fires one Part 9 event on a quality-bucket change. + /// The condition node id (RawPath for a native alarm). + /// The source-data quality to annotate. + /// The connectivity transition timestamp in UTC. + /// The namespace realm the condition lives in ( for native). + public sealed record AlarmQualityUpdate(string AlarmNodeId, OpcUaQuality Quality, DateTime TimestampUtc, AddressSpaceRealm Realm); /// /// Triggers an address-space rebuild. 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(HandleAttributeUpdate); Receive(HandleAlarmUpdate); + Receive(HandleAlarmQualityUpdate); Receive(HandleRebuild); Receive(HandleMaterialiseDiscovered); Receive(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("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(); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/ScriptedAlarmHostActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/ScriptedAlarmHostActor.cs index 0edea434..960fb3e9 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/ScriptedAlarmHostActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/ScriptedAlarmHostActor.cs @@ -539,7 +539,10 @@ 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, + // #477 — scripted conditions are script-computed and always live in this scope, so they report Good. + // Deriving quality from the worst of a script's input-tag qualities is a deferred follow-up (Layer 3). + Quality: OpcUaQuality.Good); /// Maps the Core onto the Commons /// mirror (the Commons assembly can't see the Core enum). diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/OpcUa/DeferredAddressSpaceSinkTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/OpcUa/DeferredAddressSpaceSinkTests.cs index 0a63f400..861a351b 100644 --- a/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/OpcUa/DeferredAddressSpaceSinkTests.cs +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/OpcUa/DeferredAddressSpaceSinkTests.cs @@ -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) { } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/NativeAlarmEventIdentityFieldDeliveryTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/NativeAlarmEventIdentityFieldDeliveryTests.cs index 1cc9ec8e..9de4aab5 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/NativeAlarmEventIdentityFieldDeliveryTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/NativeAlarmEventIdentityFieldDeliveryTests.cs @@ -31,6 +31,7 @@ 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"; @@ -53,6 +54,11 @@ public sealed class NativeAlarmEventIdentityFieldDeliveryTests 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; + /// A live native condition event delivers a populated EventType / SourceNode / SourceName to a /// Server-object subscriber using the standard BaseEventType select clause. [Fact] @@ -172,6 +178,63 @@ public sealed class NativeAlarmEventIdentityFieldDeliveryTests } } + /// Issue #477 — the over-the-wire proof that a native condition's source-data Quality tracks its + /// driver's connectivity. A comms-lost source (WriteAlarmQuality(Bad)) delivers a condition event + /// whose Quality is non-Good, and recovery (WriteAlarmQuality(Good)) 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 (default(StatusCode) == Good) on every event regardless of the source. + [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"); + } + } + /// Materialise the raw device folder + the native condition at its RawPath, plus one referencing /// equipment folder wired as a notifier (the production topology). private static void WireCondition(SdkAddressSpaceSink sink) @@ -225,6 +288,17 @@ public sealed class NativeAlarmEventIdentityFieldDeliveryTests return filter; } + /// #477's clause: [Quality, Message]. Quality is declared on ConditionType, 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). + private static EventFilter BuildQualityEventFilter() + { + var filter = new EventFilter(); + filter.AddSelectClause(ObjectTypes.ConditionType, BrowseNames.Quality); + filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.Message); + return filter; + } + /// Captures the delivered event field lists, filtered to our unique alarm Message so unrelated /// server events / refresh brackets never count. /// Position of the Message field in the select clause this collector is paired diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierFailureSurfaceTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierFailureSurfaceTests.cs index 2fcf5470..7bcfe1ee 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierFailureSurfaceTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierFailureSurfaceTests.cs @@ -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"); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierHierarchyTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierHierarchyTests.cs index c84241b6..df57354f 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierHierarchyTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierHierarchyTests.cs @@ -312,6 +312,7 @@ public sealed class AddressSpaceApplierHierarchyTests : IDisposable /// The node ID of the alarm condition. /// The full condition state snapshot. /// The source timestamp in UTC. + public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { } public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } /// Materialises an alarm condition (stub implementation for testing). /// The alarm node ID (== ScriptedAlarmId). diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierRawUnsTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierRawUnsTests.cs index 01f3969c..4492afb9 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierRawUnsTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierRawUnsTests.cs @@ -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) { } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierTests.cs index 150f55f2..ddbf81bd 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierTests.cs @@ -2268,6 +2268,7 @@ public sealed class AddressSpaceApplierTests /// The alarm node ID. /// The full condition state snapshot. /// The source timestamp in UTC. + 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)); /// Records an alarm-condition materialise call. @@ -2322,6 +2323,7 @@ public sealed class AddressSpaceApplierTests /// Records a value write (no-op in this sink). public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } /// No-op alarm condition write call. + public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { } public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } /// No-op alarm-condition materialise call. 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 /// The full condition state snapshot. /// The source timestamp in UTC. /// Thrown when configured to throw on alarm write. + 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"); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/DeferredAddressSpaceSinkTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/DeferredAddressSpaceSinkTests.cs index 680c0f73..61807893 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/DeferredAddressSpaceSinkTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/DeferredAddressSpaceSinkTests.cs @@ -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}"); /// + 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}"); /// @@ -289,6 +290,7 @@ public sealed class DeferredAddressSpaceSinkTests /// 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) { } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerAlarmSourceFieldsTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerAlarmSourceFieldsTests.cs index 3b8f14f7..3c91eea3 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerAlarmSourceFieldsTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerAlarmSourceFieldsTests.cs @@ -196,6 +196,120 @@ public sealed class NodeManagerAlarmSourceFieldsTests : IDisposable condition.ConditionClassName!.Value.Text.ShouldBe("BaseConditionClass"); } + /// #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). + [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(); + } + + /// #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.) + [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(); + } + + /// #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. + [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(); + } + + /// #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. + [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 + } + + /// #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. + [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)); + } + /// A booted server + its node manager, disposed via await using so an assertion failure /// cannot leak a live server (and its bound port) into the rest of the test run. private sealed class BootedServer(OpcUaApplicationHost host, OtOpcUaNodeManager nm) : IAsyncDisposable diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/SdkAddressSpaceSinkTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/SdkAddressSpaceSinkTests.cs index 4dcf68b2..859f7902 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/SdkAddressSpaceSinkTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/SdkAddressSpaceSinkTests.cs @@ -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(); } /// 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 }; diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DiscoveryInjectionEndToEndTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DiscoveryInjectionEndToEndTests.cs index 148154aa..b692f994 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DiscoveryInjectionEndToEndTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DiscoveryInjectionEndToEndTests.cs @@ -328,6 +328,7 @@ public sealed class DiscoveryInjectionEndToEndTests : RuntimeActorTestBase => _values.Enqueue((nodeId, value, quality, sourceTimestampUtc)); /// No-op: alarm writes are not exercised by this suite. + public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { } public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } /// No-op: alarm materialise is not exercised by this suite. diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorNativeAlarmTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorNativeAlarmTests.cs index 311a0f8a..69b54d83 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorNativeAlarmTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorNativeAlarmTests.cs @@ -271,6 +271,46 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase evt.User.ShouldBe("device"); } + /// #477 Layer 2 — a driver annotates the + /// Quality of the driver's native conditions via : 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. + [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(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(TimeSpan.FromSeconds(5)); + good.AlarmNodeId.ShouldBe(AlarmRawPath); + good.Quality.ShouldBe(OpcUaQuality.Good); + } + + /// #477 Layer 2 — connectivity for a DIFFERENT driver instance annotates none of this driver's + /// conditions (the fan-out is scoped by DriverInstanceId). + [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)); + } + /// Subscribe to the alerts DPS topic and wait for the ack. private void SubscribeToAlerts(TestProbe probe) { diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorDiscoveryTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorDiscoveryTests.cs index 2e74a13e..3ae48247 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorDiscoveryTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorDiscoveryTests.cs @@ -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), diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorNativeAlarmTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorNativeAlarmTests.cs index a549e701..450fafee 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorNativeAlarmTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorNativeAlarmTests.cs @@ -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)); } + /// #477 Layer 2 — the actor announces connectivity to its parent (the host): Connected on + /// reaching the Connected state, then Connected=false on a , + /// 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). + [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(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(m => true, TimeSpan.FromSeconds(3)) + .Connected.ShouldBeFalse(); + + // Reconnect → Connected=true again (drives them back Good). + parent.FishForMessage(m => true, TimeSpan.FromSeconds(3)) + .Connected.ShouldBeTrue(); + } + // --- stub drivers ---------------------------------------------------------------------------- private class StubDriver : IDriver diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorTests.cs index 0883ed57..381756e3 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorTests.cs @@ -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))); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/NativeAlarmProjectorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/NativeAlarmProjectorTests.cs index 5492a3f6..f98d0eea 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/NativeAlarmProjectorTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/NativeAlarmProjectorTests.cs @@ -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, diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Observability/OtOpcUaTelemetryHookTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Observability/OtOpcUaTelemetryHookTests.cs index e6001692..8a414896 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Observability/OtOpcUaTelemetryHookTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Observability/OtOpcUaTelemetryHookTests.cs @@ -199,6 +199,7 @@ public sealed class OtOpcUaTelemetryHookTests : RuntimeActorTestBase /// The alarm node identifier. /// The full condition state snapshot. /// The time the alarm occurred in UTC. + 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++; /// Materialises an alarm condition (stub implementation). /// The alarm node identifier. diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorApplyFailureTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorApplyFailureTests.cs index c5e07ecc..33b4153c 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorApplyFailureTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorApplyFailureTests.cs @@ -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) { } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorRebuildTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorRebuildTests.cs index 79845f33..be94b075 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorRebuildTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorRebuildTests.cs @@ -443,6 +443,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase /// The alarm node ID. /// The full condition state snapshot. /// The timestamp of the state change. + 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}"); /// Records a materialise-alarm-condition call. diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorTests.cs index 137b607f..8e1330f4 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorTests.cs @@ -76,6 +76,26 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase }, duration: TimeSpan.FromMilliseconds(500)); } + /// #477 — AlarmQualityUpdate routes to sink.WriteAlarmQuality with the quality + realm. + [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)); + } + /// Builds a test with sensible defaults so each test /// only specifies the fields it cares about. 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(); /// Gets the queue of recorded alarm condition updates. public ConcurrentQueue<(string AlarmNodeId, AlarmConditionSnapshot State, DateTime Ts)> AlarmQueue { get; } = new(); + /// Gets the queue of recorded alarm-quality annotations (#477). + public ConcurrentQueue<(string AlarmNodeId, OpcUaQuality Quality, DateTime Ts, AddressSpaceRealm Realm)> AlarmQualityQueue { get; } = new(); /// Count of rebuild calls. public int RebuildCalls; /// Gets the queue of recorded EnsureFolder calls. @@ -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)); + /// Records an alarm-quality annotation (#477). + public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime ts, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => + AlarmQualityQueue.Enqueue((alarmNodeId, quality, ts, realm)); + /// Materialises an alarm condition (no-op in test). /// The alarm node ID. /// The equipment folder node ID.