Merge pull request 'feat(alarms): condition Quality tracks source connectivity (#477)' (#479) from fix/alarm-condition-quality-477 into master
This commit was merged in pull request #479.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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=<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).
|
||||
@@ -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) { }
|
||||
|
||||
@@ -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
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
/// <summary>Maps the Core <see cref="ShelvingKind"/> onto the Commons <see cref="AlarmShelvingKind"/>
|
||||
/// mirror (the Commons assembly can't see the Core enum).</summary>
|
||||
|
||||
@@ -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) { }
|
||||
|
||||
+74
@@ -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;
|
||||
|
||||
/// <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]
|
||||
@@ -172,6 +178,63 @@ public sealed class NativeAlarmEventIdentityFieldDeliveryTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <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)
|
||||
@@ -225,6 +288,17 @@ public sealed class NativeAlarmEventIdentityFieldDeliveryTests
|
||||
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>
|
||||
/// <param name="messageIndex">Position of the Message field in the select clause this collector is paired
|
||||
|
||||
+2
@@ -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) { }
|
||||
|
||||
+114
@@ -196,6 +196,120 @@ public sealed class NodeManagerAlarmSourceFieldsTests : IDisposable
|
||||
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 };
|
||||
|
||||
|
||||
+1
@@ -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>
|
||||
|
||||
+40
@@ -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)
|
||||
{
|
||||
|
||||
+39
@@ -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),
|
||||
|
||||
+42
@@ -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,
|
||||
|
||||
+1
@@ -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>
|
||||
|
||||
+2
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user