fix(opcua,drivers): carry driver status sub-codes to clients (#519) + health-write ordering (#517)

#519 — a driver's OPC UA status code now reaches a client with its sub-code intact.
DriverInstanceActor projected the driver's uint onto a 3-state Good/Uncertain/Bad enum
and OtOpcUaNodeManager re-expanded Bad to a bare 0x80000000, so BadNoCommunication,
BadTypeMismatch and BadNotSupported were indistinguishable on the wire and every
driver's status constants (incl. the 16 #497 had just corrected) were diagnostic in
logs only. Resolved by option (b): the raw uint rides AttributeValuePublished ->
AttributeValueUpdate -> IOpcUaAddressSpaceSink.WriteValue, OpcUaQuality is deleted, and
severity classification is a helper (OpcUaStatus.IsGood/IsUncertain/IsBad).

The surface was wider than the issue costed (41 files, not "the sink and 3
implementations"): OpcUaQuality had non-driver producers with no uint to carry.
Each got its natural code — BadInternalError for a failed virtual-tag script,
BadNoCommunication for driver comms loss — and ScriptedAlarmHostActor's pair of
mutually-inverse uint<->enum mappers is deleted, having round-tripped a code the
engine already held. AddressSpaceApplier's removal writes deliberately keep the
generic Bad: not losing a code the driver had is this issue; inventing one for the
deploy path is a separate call.

Behaviour change: WriteAlarmQuality fires its Part 9 event on a status-code change
rather than a severity-bucket change. While the annotation was 3-state those were the
same predicate; now a move between two Bad reasons changes what a subscriber reads.

New SDK-free named constants live on Commons.OpcUa.OpcUaStatus, and
StatusCodeParityTests is widened to cover them — that guard is reflection-driven, so
it can only see a named constant, never an inline literal.

#517 — the symptom is real but the issue named the wrong path and its fix was unsafe.
ShutdownAsync cannot race: DriverInstanceActor calls it from PostStop, after the
mailbox drains. (Core/Hosting/DriverHost, the one class with a concurrent GetHealth,
has no production call site.) InitializeAsync can: it runs on a fire-and-forget
Task.Run, and MTConnectDriver writes Healthy then starts loops that can throw into a
catch which disposes the client before writing Faulted.

Moving the write earlier, as suggested, inverts the terminal state — teardown drains
in-flight loops that write health as they unwind, so a late loop could leave Healthy at
rest with every connection closed. The fix is a bracket (conservative before, terminal
after), applied to MTConnect's init-failure path only; shutdown keeps its single
trailing write, and the rule plus the reachability table are documented on
IDriver.ShutdownAsync.

CalculationDriver.GetHealth returned a hard-coded Healthy that consulted nothing —
Healthy before initialize, after shutdown and after dispose. Now derived from _running.
Its test, named Health_is_always_Connected, asserted the defect and is replaced.

Every new guard was falsified by breaking the production code and watching it go red.
The two sub-code tests each assert two different Bad codes, since a single-code
assertion could pass against a hard-coded constant under the old projection.

Pre-existing failures, verified unchanged on a stashed tree and not chased: 4 in
Driver.AbLegacy.IntegrationTests (slc500/micrologix/plc5 fixtures not up) and 3 in
Host.IntegrationTests.

Closes #517
Closes #519

Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
This commit is contained in:
Joseph Doherty
2026-07-31 05:25:27 -04:00
parent ba85e871df
commit 2d20aeb779
53 changed files with 721 additions and 311 deletions
+80 -6
View File
@@ -64,13 +64,14 @@ change:
| **#507** | **Superseded, not migrated** — the requested migration was impossible, since both of the path's inputs are structurally empty in v3. |
| **#523** | Done, with the consequence carried forward: **AbCip and FOCAS now have no tag-change signal at all.** |
**17 issues remain open** (verified against the tracker API, not derived): #519, #520 (two judgement
calls), #524 + #525 (both split out of the #521/#522 work rather than folded in), #517, the eight MQTT
follow-ups (#508–#515), #491, #489, #481, #468.
**14 issues remain open** (verified against the tracker API 2026-07-31, not derived): #520 and #525
(the two remaining judgement calls), the eight MQTT follow-ups (#508–#515), #491, #489, #481, #468.
The count is unchanged at 17 but the *membership* is not — **#521 and #522 closed, #524 and #525 opened.**
Both new ones are residuals their parent issue's decision did not cover: a second dead table
(`DriverInstanceResilienceStatus`) and two now-consumerless `IDriver` members.
Membership moved across three rounds: **#521 and #522 closed, #524 and #525 opened** (both residuals
their parent issue's decision did not cover — a second dead table and two consumerless `IDriver`
members), then **#524 closed** with the table dropped, then **#517 and #519 closed** — leaving 14.
What is left is no longer a dead-surface cluster: #520 and #525 are keep-or-delete calls, and the
rest are MQTT follow-ups plus four infrastructure-gated items.
---
@@ -1067,6 +1068,79 @@ action**, so a broken mutation produced a clean-looking verification. The cheap
artifact rather than the exit code — `wc -l deferment.md` after an edit, `git rev-parse` after the
commit is final rather than before.
### 2026-07-31 — #517 driver health ordering + #519 status sub-codes
Both closed. Each issue's *symptom* held up; each issue's *stated mechanism* was partly wrong, and in
one case its suggested fix would have made things worse.
**#519 — the sub-code collapse.** Exactly as filed: `DriverInstanceActor.QualityFromStatus` projected
the driver's `uint` onto a 3-state enum, `OtOpcUaNodeManager.StatusFromQuality` re-expanded `Bad` to
`0x80000000`, and no driver's sub-code ever reached a client. Resolved by **option (b)** — the raw
`uint` now rides the whole value path — chosen by the user over "accept and document".
The issue costed option (b) as "the sink interface and its three implementations". The real surface
was **41 files / ~205 references**, because `OpcUaQuality` had producers that are not drivers and
therefore had no `uint` to carry: `AddressSpaceApplier`'s four removal writes, `VirtualTagActor`,
`AlarmConditionSnapshot`, and the alarm-quality path. Most had a natural code (`BadInternalError` for
a failed script, `BadNoCommunication` for comms loss) and **two already computed one and threw it
away** — `ScriptedAlarmHostActor` held a pair of mutually-inverse mappers converting `uint`→enum→`uint`
around the engine, both now deleted.
Two behaviour changes worth knowing, both deliberate:
- `WriteAlarmQuality` fires its Part 9 event on a **status-code** change, where it used to fire on a
severity-bucket change. While the annotation was 3-state those were the same predicate; now a move
between two Bad reasons genuinely changes what a subscriber reads, so suppressing it would leave
clients holding a stale reason.
- `AddressSpaceApplier`'s removal writes **kept the generic `Bad`**. A removed node could say something
truer (`BadNodeIdUnknown`), but #519 was about not *losing* a code the driver already had; inventing
a new one for the deploy path is a separate call, so that stayed behaviour-identical.
**#517 — health published after teardown.** The symptom is real but the issue named the wrong path,
and its fix was unsafe:
- **`ShutdownAsync` cannot race.** `DriverInstanceActor` calls it from `PostStop` — actor thread, after
the mailbox drains — so the health poll is serialised against it. The one class with a lock-protected
concurrent `GetHealth`, `Core/Hosting/DriverHost`, **has no production call site at all** and removes
the driver from its registry before shutting it down anyway.
- **`InitializeAsync` can.** It runs on a fire-and-forget `Task.Run` (`DriverInstanceActor.cs:567`), so
the health tick keeps firing throughout. `MTConnectDriver` writes `Healthy`, then starts its stream
and probe loops — either can throw into a `catch` that disposes the client and only *then* writes
`Faulted`. That window is the issue's claim, made concrete, and it is the only reachable one.
- **The suggested fix — "write the transitional value before disposing" — inverts the terminal state
if applied as a move.** Teardown drains in-flight loops that write health as they unwind, so a
late loop can overwrite the shutdown value with `Healthy` and leave *that* at rest, connections
closed. The correct shape is a **bracket**: conservative before, terminal after. Applied to
MTConnect's init-failure path; the shutdown paths keep their single trailing write, and the rule +
the reachability table are documented on `IDriver.ShutdownAsync` so nobody "fixes" the ordering on
the wrong premise later.
- **One unambiguous defect, deterministic rather than racy:** `CalculationDriver.GetHealth()` returned
a hard-coded `Healthy` that consulted nothing — Healthy before initialize, after shutdown, and after
dispose. Its existing test was literally named `Health_is_always_Connected` and asserted the defect;
removed, and replaced with a lifecycle test.
**Falsification.** Every new guard was proved capable of failing by breaking the production code and
watching it go red: the Commons parity guard (corrupt a constant), the node-manager verbatim write
(restore the collapse), and the MTConnect ordering test (delete the pre-teardown write). The two
sub-code tests each assert **two different** Bad codes, because under the old projection a
single-code assertion could pass against a hard-coded constant.
**One self-inflicted defect, caught by the suite.** The bulk `.Quality` → `.StatusCode` rename ran with
a negative lookbehind for `condition` to protect the SDK's `condition.Quality`, which missed
`BrowseNames.Quality` — an SDK browse-name constant. The event filter then selected a field that does
not exist, and the wire test failed with an `NullReferenceException` on the cast rather than a readable
assertion. Same lesson as the round-3 entries above: a mechanical rewrite needs its output audited by
receiver, not just compiled — `git diff | grep -o '[A-Za-z_]*\.StatusCode' | sort -u` would have shown
it in one line. It hit **twice**: `BrowseNames.Quality` (caught by the wire test) and a `.Quality`
inside a script-source string literal in `EquipmentScriptPathsTests`' `InlineData` — which compiled
and passed, so only reading the diff by hand found it.
**Pre-existing, not chased:** the four `Driver.AbLegacy.IntegrationTests` failures need the
slc500/micrologix/plc5 compose profiles, which are not up on `10.100.0.35` (only the AbCip
controllogix fixture is). Reproduced on a stashed tree before dismissing them.
---
---
*§1–§8 generated 2026-07-27 against `master` @ `90bdaa44` by five parallel source-verification agents.
+27
View File
@@ -176,6 +176,33 @@ calls `Publish` when a driver's health transitions. The published snapshot is
consumed AdminUI-side and surfaced through the driver-status panel (read
in-process by the AdminUI bridge rather than dialing its own hub).
### Writing health around a teardown (Gitea #517)
Two rules, and the second is the counter-intuitive one:
1. **`GetHealth()` must report actual state, never a constant.** A hard-coded `Healthy` reports a
healthy driver before initialize and after shutdown alike. `CalculationDriver` did exactly this —
it was the fleet's one driver reporting Healthy with its timers stopped, deterministically rather
than as a race. A driver with no transport still has a running / not-running distinction.
2. **Land the terminal health value *after* the teardown — and, where the window is observable, write
it *before* as well.** #517's original suggestion was to move the write earlier so the race window
leans conservative. Moving it is wrong: teardown drains in-flight poll/probe loops, and those loops
write health as they unwind, so a driver that only wrote first could have a late loop overwrite the
shutdown state with `Healthy` and leave *that* as the resting value — with every connection closed.
The safe shape is a **bracket**: conservative value before, terminal value after.
Where the window is actually observable is worth knowing before adding brackets everywhere:
| Path | Caller | Can `GetHealth()` land mid-teardown? |
|---|---|---|
| `ShutdownAsync` | `DriverInstanceActor.PostStop` | **No** — actor thread, after the mailbox drains, so the health poll is serialised against it. |
| `InitializeAsync` failure | `DriverInstanceActor.InitializeAsync` | **Yes** — invoked on a fire-and-forget `Task.Run`, so the health-poll tick keeps calling `GetHealth()` throughout. |
So a driver whose `InitializeAsync` disposes anything on its failure path must bracket; only
`MTConnectDriver` does today, because it is the only one that tears down inside a `catch`. The
shutdown paths deliberately keep the single trailing write.
---
## IDriverConfigEditor — custom AdminUI config editor (plug-point)
+19
View File
@@ -28,6 +28,25 @@ invoker is real, but it lives on the **driver-actor** side wrapping the poll/sub
1. The SDK resolves the NodeId in the Raw (`ns=2`) or UNS (`ns=3`) namespace.
2. It returns the cached `DataValue` — value, `StatusCode` and source timestamp as last pushed.
### Status codes reach the client intact (Gitea #519)
The driver's OPC UA status code — **sub-code included** — is what a client reads. A driver that maps
an unreachable device to `BadNoCommunication` surfaces exactly that, distinguishable from
`BadTypeMismatch` or `BadNotSupported`.
This was not always true. Until #519 the publish path projected the driver's `uint` onto a 3-state
`Good`/`Uncertain`/`Bad` enum in `DriverInstanceActor`, and `OtOpcUaNodeManager` re-expanded `Bad` to
a bare `StatusCodes.Bad` (`0x80000000`). Every driver's diagnostic intent survived in logs only —
including the 16 status constants #497 had just corrected across six drivers. The value path now
carries the raw `uint` end to end (`AttributeValuePublished` → `AttributeValueUpdate` →
`IOpcUaAddressSpaceSink.WriteValue`), and severity classification is a helper
(`OpcUaStatus.IsGood`/`IsUncertain`/`IsBad`) rather than a lossy projection.
**Writing a status code in engine code:** use a named constant on
`ZB.MOM.WW.OtOpcUa.Commons.OpcUa.OpcUaStatus`, never an inline literal. Commons and Runtime are
deliberately SDK-free, so nothing but `StatusCodeParityTests` checks a literal against the name it is
commented with — and that guard works by reflection, so it can only see a **named** constant.
3. **No authorization check runs.** Not a per-node ACL, not a role check, nothing. Any admitted
session — including an Anonymous one — can read every node in the address space. The only gating is
the `AccessLevels` bitmask set at materialization, which is a per-node *capability* declaration, not
@@ -16,11 +16,13 @@ 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
/// <param name="StatusCode">
/// Status code 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.
/// <see cref="OpcUaStatus.Good"/> so scripted-alarm callers (which stay Good in v1) need not supply it.
/// Carries the full sub-code since #519, so a comms-lost condition reports
/// <see cref="OpcUaStatus.BadNoCommunication"/> rather than a bare <see cref="OpcUaStatus.Bad"/>.
/// </param>
public sealed record AlarmConditionSnapshot(
bool Active,
@@ -30,7 +32,7 @@ public sealed record AlarmConditionSnapshot(
AlarmShelvingKind Shelving,
ushort Severity,
string Message,
OpcUaQuality Quality = OpcUaQuality.Good);
uint StatusCode = OpcUaStatus.Good);
/// <summary>
/// Commons-local mirror of the Core <c>ShelvingKind</c> enum so this assembly carries no
@@ -23,15 +23,15 @@ public sealed class DeferredAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgical
_inner = sink ?? NullOpcUaAddressSpaceSink.Instance;
/// <inheritdoc />
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm)
=> _inner.WriteValue(nodeId, value, quality, sourceTimestampUtc, realm);
public void WriteValue(string nodeId, object? value, uint statusCode, DateTime sourceTimestampUtc, AddressSpaceRealm realm)
=> _inner.WriteValue(nodeId, value, statusCode, sourceTimestampUtc, realm);
/// <inheritdoc />
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 WriteAlarmQuality(string alarmNodeId, uint statusCode, DateTime sourceTimestampUtc, AddressSpaceRealm realm)
=> _inner.WriteAlarmQuality(alarmNodeId, statusCode, sourceTimestampUtc, realm);
/// <inheritdoc />
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false)
@@ -8,16 +8,19 @@ namespace ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
/// </summary>
public interface IOpcUaAddressSpaceSink
{
/// <summary>Write a Variable node's current value + quality + source timestamp.</summary>
/// <summary>Write a Variable node's current value + status code + source timestamp.</summary>
/// <param name="nodeId">The OPC UA node ID of the variable.</param>
/// <param name="value">The value to write.</param>
/// <param name="quality">The quality status of the value.</param>
/// <param name="statusCode">The full OPC UA status code, sub-code included. Passed to the SDK verbatim
/// (#519): a driver's <see cref="OpcUaStatus.BadNoCommunication"/> reaches the client as
/// <c>BadNoCommunication</c>, not as a generic <see cref="OpcUaStatus.Bad"/>. Use
/// <see cref="OpcUaStatus"/>' named constants rather than a literal.</param>
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
/// <param name="realm">Which of the two v3 namespaces (<see cref="AddressSpaceRealm.Raw"/> device tree or
/// <see cref="AddressSpaceRealm.Uns"/> equipment tree) the <paramref name="nodeId"/> lives in — the sink
/// resolves the namespace index from the realm rather than parsing it out of the id string. WP3's fan-out
/// calls this once per NodeId (raw + every referencing UNS node) with the matching realm.</param>
void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm);
void WriteValue(string nodeId, object? value, uint statusCode, DateTime sourceTimestampUtc, AddressSpaceRealm realm);
/// <summary>Write an alarm-condition's full Part 9 state. When a real condition node has been
/// materialised for <paramref name="alarmNodeId"/> via <see cref="MaterialiseAlarmCondition"/>,
@@ -34,15 +37,17 @@ public interface IOpcUaAddressSpaceSink
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
/// transition (used by the driver-connectivity path: comms lost → <see cref="OpcUaStatus.BadNoCommunication"/>,
/// restored → <see cref="OpcUaStatus.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>
/// only on a change to the status code itself — since #519 that includes a move between two Bad
/// sub-codes, which genuinely changes what a subscriber reads. 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="statusCode">The source-data status code to annotate, sub-code included.</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);
void WriteAlarmQuality(string alarmNodeId, uint statusCode, DateTime sourceTimestampUtc, AddressSpaceRealm realm);
/// <summary>
/// Materialise a real OPC UA Part 9 alarm-condition node under its equipment folder so clients
@@ -160,10 +165,6 @@ public interface IOpcUaAddressSpaceSink
string referenceType = "Organizes");
}
/// <summary>OPC UA status code projection — Good / Uncertain / Bad. Real SDK has finer-grained
/// codes; the engine actors only need this 3-state classification.</summary>
public enum OpcUaQuality { Good, Uncertain, Bad }
/// <summary>No-op sink. Bound by default so the actors are safe to run in dev / Mac /
/// integration tests without a real SDK behind them.</summary>
public sealed class NullOpcUaAddressSpaceSink : IOpcUaAddressSpaceSink
@@ -172,11 +173,11 @@ public sealed class NullOpcUaAddressSpaceSink : IOpcUaAddressSpaceSink
private NullOpcUaAddressSpaceSink() { }
/// <inheritdoc />
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
public void WriteValue(string nodeId, object? value, uint statusCode, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
/// <inheritdoc />
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
public void WriteAlarmQuality(string alarmNodeId, uint statusCode, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
/// <inheritdoc />
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { }
@@ -0,0 +1,68 @@
namespace ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
/// <summary>
/// The handful of OPC UA status codes the engine layer needs to spell, plus the Part 4 severity
/// classification helpers that replaced the retired <c>OpcUaQuality</c> 3-state enum (Gitea #519).
///
/// <para><b>Why these are bare <c>uint</c> literals.</b> Commons and Runtime deliberately do not
/// reference the OPC UA SDK — <see cref="IOpcUaAddressSpaceSink"/> exists precisely so the Runtime
/// project can publish values without dragging in <c>Opc.Ua.Server</c>. That is the same layering
/// decision the driver assemblies make, and it carries the same single failure mode: nothing checks
/// the literal against the name. <c>StatusCodeParityTests</c> closes it by reflection, and it can
/// only see a <b>named</b> constant — so add codes here rather than writing a literal at a call
/// site, exactly as Gitea #497 concluded for the drivers.</para>
///
/// <para><b>Severity, not equality, is what callers should branch on.</b> A status code's top two
/// bits carry its Part 4 severity (00 = Good, 01 = Uncertain, 10/11 = Bad); everything below is the
/// sub-code. Before #519 the engine collapsed every value onto a 3-state enum here and the node
/// manager re-expanded <c>Bad</c> to a bare <c>0x80000000</c>, so no driver's sub-code ever reached
/// a client. The sub-code now rides through to the SDK verbatim, and any code that only needs the
/// bucket uses <see cref="IsGood"/> / <see cref="IsUncertain"/> / <see cref="IsBad"/> rather than
/// comparing against <see cref="Good"/> or <see cref="Bad"/>.</para>
/// </summary>
public static class OpcUaStatus
{
/// <summary>The value is good. Part 4 severity <c>00</c>; the whole code is zero.</summary>
public const uint Good = 0x00000000;
/// <summary>Generic uncertain. Prefer a specific <c>Uncertain*</c> sub-code where one applies.</summary>
public const uint Uncertain = 0x40000000;
/// <summary>Generic bad, carrying no diagnostic beyond the severity. This is the value the retired
/// 3-state projection produced for <em>every</em> bad status; a caller that has something more
/// specific should say so instead.</summary>
public const uint Bad = 0x80000000;
/// <summary>No value has been received from the device yet. Written at materialisation and on every
/// address-space reset, so a freshly-deployed or just-rebuilt node reads as "not yet polled" rather
/// than as a device fault.</summary>
public const uint BadWaitingForInitialData = 0x80320000;
/// <summary>Communication with the source has been lost. The driver-connectivity annotation for a
/// native alarm condition, and the sentinel several drivers map an explicit "unavailable" reading to.</summary>
public const uint BadNoCommunication = 0x80310000;
/// <summary>An internal failure produced no usable value — the virtual-tag path's code for a script
/// that threw or timed out.</summary>
public const uint BadInternalError = 0x80020000;
/// <summary>Part 4 severity bucket of <paramref name="statusCode"/>: the top two bits.</summary>
/// <param name="statusCode">The status code to classify.</param>
/// <returns>0 for Good, 1 for Uncertain, 2 or 3 for Bad.</returns>
public static uint Severity(uint statusCode) => statusCode >> 30;
/// <summary>True when <paramref name="statusCode"/> is in the Good severity bucket.</summary>
/// <param name="statusCode">The status code to classify.</param>
/// <returns><see langword="true"/> when the value is usable.</returns>
public static bool IsGood(uint statusCode) => Severity(statusCode) == 0;
/// <summary>True when <paramref name="statusCode"/> is in the Uncertain severity bucket.</summary>
/// <param name="statusCode">The status code to classify.</param>
/// <returns><see langword="true"/> when the value is questionable but present.</returns>
public static bool IsUncertain(uint statusCode) => Severity(statusCode) == 1;
/// <summary>True when <paramref name="statusCode"/> is in the Bad severity bucket.</summary>
/// <param name="statusCode">The status code to classify.</param>
/// <returns><see langword="true"/> when the value must not be trusted.</returns>
public static bool IsBad(uint statusCode) => Severity(statusCode) >= 2;
}
@@ -38,11 +38,30 @@ public interface IDriver
Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken);
/// <summary>Stop the driver, close connections, release resources. Called on shutdown or driver removal.</summary>
/// <remarks>
/// <para><b>Land the terminal health value AFTER the teardown, never only before it</b> (Gitea #517).
/// Teardown drains in-flight poll / probe loops, and those loops write health as they unwind — so a
/// driver that wrote <see cref="DriverState.Unknown"/> first and then tore down could have a late
/// loop overwrite it with <see cref="DriverState.Healthy"/> and leave THAT as the resting state,
/// with every connection already closed. This is why the fleet writes last, and it is the reason
/// #517's original suggestion — "write the transitional value before disposing" — is only safe as an
/// ADDITIONAL write, bracketing the teardown, rather than as a move.</para>
/// <para>Bracketing matters wherever a <see cref="GetHealth"/> can land mid-teardown. It cannot on
/// this path today: <c>DriverInstanceActor</c> calls this from <c>PostStop</c>, on the actor thread,
/// after the mailbox has drained, so its health poll is serialised against it. It CAN on the
/// initialize path, which the same actor runs on a fire-and-forget <c>Task.Run</c> while the health
/// tick keeps firing — so a driver whose <see cref="InitializeAsync"/> disposes anything on its
/// failure path must write the failed state before disposing as well as after.</para>
/// </remarks>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
Task ShutdownAsync(CancellationToken cancellationToken);
/// <summary>Current health snapshot, polled by Core for the status dashboard and ServiceLevel.</summary>
/// <summary>Current health snapshot, polled by Core for the status dashboard and ServiceLevel.
/// <para>Must reflect the driver's ACTUAL state rather than a constant — a hard-coded
/// <see cref="DriverState.Healthy"/> reports a healthy driver after shutdown and before initialize
/// alike, which is what Gitea #517 found in <c>CalculationDriver</c>. A driver with no transport still
/// has a running / not-running distinction worth reporting.</para></summary>
/// <returns>The driver's current health snapshot.</returns>
DriverHealth GetHealth();
@@ -1011,9 +1011,10 @@ public sealed record ScriptedAlarmEvent(
DateTime TimestampUtc,
string? Comment = null,
bool HistorizeToAveva = true,
// #478 — the worst OPC UA StatusCode across the alarm's input tags at evaluation time. A raw uint
// (Core.ScriptedAlarms does not reference Commons/OpcUaQuality); the host maps it to OpcUaQuality by
// the top-2 severity bits. Default 0u == Good keeps every existing constructor call unchanged.
// #478 — the worst OPC UA StatusCode across the alarm's input tags at evaluation time. A raw uint,
// since Core.ScriptedAlarms does not reference Commons. Since #519 the host carries it to the condition
// node UNCHANGED — it used to be collapsed to a 3-state bucket and re-expanded, losing the sub-code.
// Default 0u == Good keeps every existing constructor call unchanged.
uint WorstInputStatusCode = 0u);
/// <summary>
@@ -262,8 +262,22 @@ public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscriba
return Task.CompletedTask;
}
/// <inheritdoc />
public DriverHealth GetHealth() => new(DriverState.Healthy, _lastComputeUtc, null);
/// <summary>
/// Current health, derived from the driver's own lifecycle state.
/// <para><b>Gitea #517.</b> This used to return a hard-coded <see cref="DriverState.Healthy"/> that
/// consulted nothing. That made it the fleet's one driver whose <c>GetHealth()</c> reported Healthy
/// <em>with its timers stopped</em> — not as a race window, but permanently and deterministically:
/// before <see cref="InitializeAsync"/>, after <see cref="ShutdownAsync"/>, and after
/// <see cref="Dispose"/>. Every other driver reads a <c>_health</c> field that shutdown lands on
/// <see cref="DriverState.Unknown"/>.</para>
/// <para>There is no transport to be Degraded about — this driver computes from the dependency mux,
/// so its only meaningful states are "running" and "not running". <c>_running</c> is the flag the
/// compute paths themselves gate on, which is what makes it the honest input here.</para>
/// </summary>
/// <returns>Healthy while running; otherwise Unknown, retaining the last compute time.</returns>
public DriverHealth GetHealth() => _running
? new DriverHealth(DriverState.Healthy, _lastComputeUtc, null)
: new DriverHealth(DriverState.Unknown, _lastComputeUtc, null);
/// <inheritdoc />
public long GetMemoryFootprint() => 0;
@@ -2453,6 +2453,20 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
}
catch (Exception ex)
{
// Gitea #517 — land Faulted BEFORE anything is disposed, not after.
//
// A successful connect writes Healthy above, and the steps that follow it (stream start, probe
// loop) can still throw. Without this first write the driver would report Healthy for the whole
// duration of the teardown below, with its client already disposed. That window is genuinely
// observable: DriverInstanceActor invokes InitializeAsync on a fire-and-forget Task.Run, so its
// health-poll tick keeps calling GetHealth() on the actor thread throughout.
//
// The write is then REPEATED after the teardown, and both are load-bearing. Teardown drains
// in-flight loops, and those loops write health too — so a driver that only moved the write
// earlier could have a late loop overwrite Faulted with something rosier and leave THAT as the
// terminal state. Conservative during the window, correct at the end.
WriteHealth(new DriverHealth(DriverState.Faulted, lastRead, ex.Message));
// A client this attempt built is unreachable from anywhere else; one the caller handed in
// is disposed by the teardown below. Either way nothing survives a failed start.
SafeDispose(built);
@@ -279,7 +279,7 @@ public sealed class AddressSpaceApplier
else
{
// Plain value variable → terminal Bad so an in-flight MonitoredItem sees the removal.
SafeWriteValue(nodeId, null, OpcUaQuality.Bad, ts, AddressSpaceRealm.Uns);
SafeWriteValue(nodeId, null, OpcUaStatus.Bad, ts, AddressSpaceRealm.Uns);
if (!SafeRemoveVariable(surgical, nodeId, AddressSpaceRealm.Uns)) return false;
}
}
@@ -288,7 +288,7 @@ public sealed class AddressSpaceApplier
foreach (var v in plan.RemovedEquipmentVirtualTags.Where(t => NotSubsumed(t.EquipmentId)))
{
var nodeId = UnsEquipmentVar(v.EquipmentId, v.FolderPath, v.Name);
SafeWriteValue(nodeId, null, OpcUaQuality.Bad, ts, AddressSpaceRealm.Uns);
SafeWriteValue(nodeId, null, OpcUaStatus.Bad, ts, AddressSpaceRealm.Uns);
if (!SafeRemoveVariable(surgical, nodeId, AddressSpaceRealm.Uns)) return false;
}
@@ -311,7 +311,7 @@ public sealed class AddressSpaceApplier
// ref only reaches here on a genuine dereference (the equipment dropped the reference).
foreach (var v in plan.RemovedUnsReferenceVariables)
{
SafeWriteValue(v.NodeId, null, OpcUaQuality.Bad, ts, AddressSpaceRealm.Uns);
SafeWriteValue(v.NodeId, null, OpcUaStatus.Bad, ts, AddressSpaceRealm.Uns);
if (!SafeRemoveVariable(surgical, v.NodeId, AddressSpaceRealm.Uns)) return false;
}
@@ -327,7 +327,7 @@ public sealed class AddressSpaceApplier
}
else
{
SafeWriteValue(t.NodeId, null, OpcUaQuality.Bad, ts, AddressSpaceRealm.Raw);
SafeWriteValue(t.NodeId, null, OpcUaStatus.Bad, ts, AddressSpaceRealm.Raw);
if (!SafeRemoveVariable(surgical, t.NodeId, AddressSpaceRealm.Raw)) return false;
}
}
@@ -365,11 +365,16 @@ public sealed class AddressSpaceApplier
}
/// <summary>Write a value, swallowing (and Warning-logging) any sink fault — used for the terminal Bad
/// on a removed variable so an in-flight MonitoredItem observes the removal.</summary>
/// on a removed variable so an in-flight MonitoredItem observes the removal.
/// <para>Every caller passes the generic <see cref="OpcUaStatus.Bad"/>, which is exactly what the
/// retired 3-state projection produced here. A removal could say something more specific (the node is
/// about to stop existing, so <c>BadNodeIdUnknown</c> would be truer), but #519 was about not LOSING a
/// sub-code the driver already had — inventing a new one for the deploy path is a separate call, so
/// this deliberately stays behaviour-identical.</para></summary>
/// <returns><c>true</c> when the write landed; <c>false</c> when the sink threw.</returns>
private bool SafeWriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime ts, AddressSpaceRealm realm)
private bool SafeWriteValue(string nodeId, object? value, uint statusCode, DateTime ts, AddressSpaceRealm realm)
{
try { _sink.WriteValue(nodeId, value, quality, ts, realm); return true; }
try { _sink.WriteValue(nodeId, value, statusCode, ts, realm); return true; }
catch (Exception ex) { _logger.LogWarning(ex, "AddressSpaceApplier: WriteValue threw for {Node}", nodeId); return false; }
}
@@ -408,7 +408,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// <param name="value">The new value to write.</param>
/// <param name="quality">The OPC UA quality status code.</param>
/// <param name="sourceTimestampUtc">The timestamp of the value in UTC.</param>
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm)
public void WriteValue(string nodeId, object? value, uint statusCode, DateTime sourceTimestampUtc, AddressSpaceRealm realm)
{
ArgumentException.ThrowIfNullOrEmpty(nodeId);
EnsureAddressSpaceCreated();
@@ -426,7 +426,10 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
}
variable.Value = value;
variable.StatusCode = StatusFromQuality(quality);
// #519 — the caller's status code reaches the SDK verbatim, sub-code included. This used to
// re-expand a 3-state enum, so every Bad became a bare StatusCodes.Bad (0x80000000) and a
// driver's BadNoCommunication was indistinguishable from BadTypeMismatch at the client.
variable.StatusCode = new StatusCode(statusCode);
variable.Timestamp = sourceTimestampUtc;
variable.ClearChangeMasks(SystemContext, includeChildren: false);
}
@@ -503,7 +506,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// 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);
condition.Quality.Value = new StatusCode(state.StatusCode);
if (condition.Quality.SourceTimestamp is not null) condition.Quality.SourceTimestamp.Value = sourceTimestampUtc;
}
@@ -547,19 +550,23 @@ 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
/// from any alarm transition. Used by the driver-connectivity path (comms lost → BadNoCommunication,
/// 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 ⇒
/// status code is a genuine Part 9 condition change, so it fires one condition event carrying the
/// new Quality; an unchanged code suppresses (no spurious event). Unknown / unmaterialised node ⇒
/// safe no-op (a mid-rebuild race must not fault a connectivity update), mirroring the other writes.
/// <para><b>#519 changed the fire test from bucket to full code.</b> While the annotation was a
/// 3-state enum, "the bucket moved" and "the value changed" were the same predicate. Now that the
/// sub-code survives, a move from one Bad reason to another Bad reason genuinely changes what a
/// client reads, so it fires — suppressing it would leave subscribers holding a stale reason.</para>
/// </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="statusCode">The source-data status code to annotate, sub-code included.</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)
public void WriteAlarmQuality(string alarmNodeId, uint statusCode, DateTime sourceTimestampUtc, AddressSpaceRealm realm)
{
ArgumentException.ThrowIfNullOrEmpty(alarmNodeId);
EnsureAddressSpaceCreated();
@@ -573,13 +580,13 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
if (!_alarmConditions.TryGetValue(key, out var condition) || condition.Quality is null)
return;
var newCode = StatusFromQuality(quality);
var newCode = new StatusCode(statusCode);
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.
// Fire ONLY on a real change so a steady-state connectivity re-assert doesn't spam events.
if (changed)
{
condition.Time.Value = sourceTimestampUtc;
@@ -758,7 +765,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
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);
Quality: condition.Quality is not null ? new StatusCode(state.StatusCode) : 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
@@ -3315,10 +3322,4 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
return v;
}
private static StatusCode StatusFromQuality(OpcUaQuality quality) => quality switch
{
OpcUaQuality.Good => StatusCodes.Good,
OpcUaQuality.Uncertain => StatusCodes.Uncertain,
_ => StatusCodes.Bad,
};
}
@@ -21,15 +21,15 @@ public sealed class SdkAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgicalAddre
}
/// <inheritdoc />
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm)
=> _nodeManager.WriteValue(nodeId, value, quality, sourceTimestampUtc, realm);
public void WriteValue(string nodeId, object? value, uint statusCode, DateTime sourceTimestampUtc, AddressSpaceRealm realm)
=> _nodeManager.WriteValue(nodeId, value, statusCode, sourceTimestampUtc, realm);
/// <inheritdoc />
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 WriteAlarmQuality(string alarmNodeId, uint statusCode, DateTime sourceTimestampUtc, AddressSpaceRealm realm)
=> _nodeManager.WriteAlarmQuality(alarmNodeId, statusCode, sourceTimestampUtc, realm);
/// <inheritdoc />
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false)
@@ -939,10 +939,10 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
if (_nodeIdByDriverRef.TryGetValue((msg.DriverInstanceId, msg.FullReference), out var nodeIds))
{
// v3 Batch 4 single-source fan-out: one driver publish for a RawPath lands on the raw NodeId AND
// every referencing UNS NodeId, each with its realm, carrying IDENTICAL value/quality/timestamp.
// every referencing UNS NodeId, each with its realm, carrying IDENTICAL value/status/timestamp.
foreach (var n in nodeIds)
_opcUaPublishActor.Tell(new ZB.MOM.WW.OtOpcUa.Runtime.OpcUa.OpcUaPublishActor.AttributeValueUpdate(
n.NodeId, msg.Value, msg.Quality, msg.TimestampUtc, n.Realm));
n.NodeId, msg.Value, msg.StatusCode, msg.TimestampUtc, n.Realm));
}
else
{
@@ -975,8 +975,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
/// </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
/// alarm condition the driver owns: comms lost → <see cref="OpcUaStatus.BadNoCommunication"/>, restored →
/// <see cref="OpcUaStatus.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>
@@ -992,7 +992,10 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
{
if (_opcUaPublishActor is null) return;
var quality = msg.Connected ? OpcUaQuality.Good : OpcUaQuality.Bad;
// #519: name the actual cause. This is a connectivity transition, so the Bad case is
// BadNoCommunication — the bare OpcUaQuality.Bad it used to send reached clients as 0x80000000,
// indistinguishable from any other fault.
var statusCode = msg.Connected ? OpcUaStatus.Good : OpcUaStatus.BadNoCommunication;
var ts = DateTime.UtcNow;
var annotated = 0;
// Fan out to every condition this driver owns. _alarmNodeIdByDriverRef is keyed by
@@ -1003,14 +1006,14 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
foreach (var n in nodeIds)
{
_opcUaPublishActor.Tell(new ZB.MOM.WW.OtOpcUa.Runtime.OpcUa.OpcUaPublishActor.AlarmQualityUpdate(
n.NodeId, quality, ts, n.Realm));
n.NodeId, statusCode, 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);
_log.Debug("DriverHost {Node}: driver {Driver} {State} — annotated {Count} native condition(s) 0x{Status:X8}",
_localNode, msg.DriverInstanceId, msg.Connected ? "connected" : "disconnected", annotated, statusCode);
}
private void ForwardNativeAlarm(DriverInstanceActor.AttributeAlarmPublished msg)
@@ -102,8 +102,11 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
/// </summary>
public sealed record ForceReconnect;
/// <summary>Published to the actor's parent whenever the subscribed IDriver fires
/// <see cref="ISubscribable.OnDataChange"/>. The parent forwards to OpcUaPublishActor.</summary>
public sealed record AttributeValuePublished(string DriverInstanceId, string FullReference, object? Value, OpcUaQuality Quality, DateTime TimestampUtc);
/// <see cref="ISubscribable.OnDataChange"/>. The parent forwards to OpcUaPublishActor.
/// <para><paramref name="StatusCode"/> is the driver's full OPC UA status code including its sub-code
/// (#519) — not a severity bucket. Classify it with <see cref="OpcUaStatus.IsGood"/> and friends rather
/// than comparing it to <see cref="OpcUaStatus.Good"/>.</para></summary>
public sealed record AttributeValuePublished(string DriverInstanceId, string FullReference, object? Value, uint StatusCode, DateTime TimestampUtc);
private sealed record DataChangeForward(string FullReference, DataValueSnapshot Snapshot);
/// <summary>Published to the parent whenever the subscribed driver (an <see cref="IAlarmSource"/>) fires
/// <see cref="IAlarmSource.OnAlarmEvent"/>. The parent (<see cref="DriverHostActor"/>) projects + routes it
@@ -957,27 +960,19 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
}
}
/// <summary>Forward a driver's data change to the parent for OPC UA publication.
/// <para>The driver's status code rides through UNCHANGED (#519). This used to project it onto a
/// 3-state Good/Uncertain/Bad enum here, which the node manager then re-expanded to a bare
/// <c>0x80000000</c> — so no driver's sub-code ever reached a client, and the status constants each
/// driver maintains were diagnostic in logs only.</para></summary>
private void OnDataChangeForward(DataChangeForward msg)
{
var quality = QualityFromStatus(msg.Snapshot.StatusCode);
var ts = msg.Snapshot.SourceTimestampUtc ?? msg.Snapshot.ServerTimestampUtc;
Context.Parent.Tell(new AttributeValuePublished(_driverInstanceId, msg.FullReference, msg.Snapshot.Value, quality, ts));
Context.Parent.Tell(new AttributeValuePublished(
_driverInstanceId, msg.FullReference, msg.Snapshot.Value, msg.Snapshot.StatusCode, ts));
}
/// <summary>Translate an OPC UA status code to the 3-state <see cref="OpcUaQuality"/> projection
/// the publish actor consumes. Severity bits (top 2): 00 = Good, 01 = Uncertain, 10/11 = Bad.</summary>
private static OpcUaQuality QualityFromStatus(uint statusCode)
{
var severity = statusCode >> 30;
return severity switch
{
0 => OpcUaQuality.Good,
1 => OpcUaQuality.Uncertain,
_ => OpcUaQuality.Bad,
};
}
private static bool IsGoodStatus(uint statusCode) => (statusCode >> 30) == 0;
private static bool IsGoodStatus(uint statusCode) => OpcUaStatus.IsGood(statusCode);
/// <summary>Resolve the resilience-pipeline host key for a single driver-side reference. Multi-host
/// drivers (e.g. Modbus across N PLCs) implement <see cref="IPerCallHostResolver"/> so one dead host
@@ -42,10 +42,12 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
/// <summary>A driver/vtag value update for a single materialised Variable node. v3 Batch 4: carries the
/// node's <see cref="AddressSpaceRealm"/> so the sink resolves the right namespace — the driver fan-out
/// posts one of these per registered NodeId (the raw node in <see cref="AddressSpaceRealm.Raw"/> and each
/// referencing UNS node in <see cref="AddressSpaceRealm.Uns"/>) with identical value/quality/timestamp.
/// referencing UNS node in <see cref="AddressSpaceRealm.Uns"/>) with identical value/status/timestamp.
/// <see cref="Realm"/> defaults to <see cref="AddressSpaceRealm.Uns"/> so pre-v3 callers (VirtualTag
/// equipment nodes) keep compiling; the driver fan-out passes it EXPLICITLY per node.</summary>
public sealed record AttributeValueUpdate(string NodeId, object? Value, OpcUaQuality Quality, DateTime TimestampUtc, AddressSpaceRealm Realm);
/// equipment nodes) keep compiling; the driver fan-out passes it EXPLICITLY per node.
/// <para><see cref="StatusCode"/> is the full OPC UA status code, sub-code included (#519); the sink
/// hands it to the SDK verbatim.</para></summary>
public sealed record AttributeValueUpdate(string NodeId, object? Value, uint StatusCode, DateTime TimestampUtc, AddressSpaceRealm Realm);
/// <summary>Carries the full Part 9 condition state for a scripted alarm to the sink. The
/// <paramref name="State"/> snapshot is the Commons projection the Runtime host maps from the engine's
@@ -59,14 +61,14 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
/// 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>
/// transition (the driver-connectivity path: comms lost → <see cref="OpcUaStatus.BadNoCommunication"/>,
/// restored → <see cref="OpcUaStatus.Good"/>). Routed to <see cref="IOpcUaAddressSpaceSink.WriteAlarmQuality"/>,
/// which sets ONLY Quality and fires one Part 9 event on a status-code 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="StatusCode">The source-data status code to annotate, sub-code included.</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);
public sealed record AlarmQualityUpdate(string AlarmNodeId, uint StatusCode, 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
@@ -306,7 +308,7 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
{
try
{
_sink.WriteValue(msg.NodeId, msg.Value, msg.Quality, msg.TimestampUtc, msg.Realm);
_sink.WriteValue(msg.NodeId, msg.Value, msg.StatusCode, msg.TimestampUtc, msg.Realm);
Interlocked.Increment(ref _writes);
OtOpcUaTelemetry.OpcUaSinkWrite.Add(1, new KeyValuePair<string, object?>("kind", "value"));
}
@@ -334,7 +336,7 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
{
try
{
_sink.WriteAlarmQuality(msg.AlarmNodeId, msg.Quality, msg.TimestampUtc, msg.Realm);
_sink.WriteAlarmQuality(msg.AlarmNodeId, msg.StatusCode, msg.TimestampUtc, msg.Realm);
Interlocked.Increment(ref _writes);
OtOpcUaTelemetry.OpcUaSinkWrite.Add(1, new KeyValuePair<string, object?>("kind", "alarm-quality"));
}
@@ -388,28 +388,13 @@ public sealed class ScriptedAlarmHostActor : ReceiveActor
// Feed the live value into the upstream the engine subscribes from. #478 — carry the source
// driver's quality (mapped to an OPC UA StatusCode) so the engine can derive a scripted
// condition's worst-of-input quality; a Bad/Uncertain input is no longer silently treated as Good.
// #519 — the status code rides through verbatim. This used to convert the incoming value onto a
// 3-state enum and straight back again through a pair of mutually-inverse mappers, which is where
// the input's sub-code was lost before the engine ever cached it.
_upstream.Push(msg.TagId,
new DataValueSnapshot(msg.Value, StatusFromQuality(msg.Quality), msg.TimestampUtc, msg.TimestampUtc));
new DataValueSnapshot(msg.Value, msg.StatusCode, msg.TimestampUtc, msg.TimestampUtc));
}
/// <summary>#478 — map the 3-state <see cref="OpcUaQuality"/> to an OPC UA StatusCode (severity bits)
/// for the engine's read cache. The inverse of <see cref="QualityFromStatus"/>.</summary>
private static uint StatusFromQuality(OpcUaQuality quality) => quality switch
{
OpcUaQuality.Bad => 0x80000000u,
OpcUaQuality.Uncertain => 0x40000000u,
_ => 0u, // Good
};
/// <summary>#478 — collapse an engine <see cref="ScriptedAlarmEvent.WorstInputStatusCode"/> (top-2
/// severity bits) back to the 3-state <see cref="OpcUaQuality"/> the Commons snapshot / node path use.</summary>
private static OpcUaQuality QualityFromStatus(uint statusCode) => (statusCode >> 30) switch
{
0 => OpcUaQuality.Good,
1 => OpcUaQuality.Uncertain,
_ => OpcUaQuality.Bad,
};
private void OnEngineEmission(EngineEmission msg)
{
var e = msg.Event;
@@ -430,7 +415,7 @@ public sealed class ScriptedAlarmHostActor : ReceiveActor
{
_publishActor.Tell(new OpcUaPublishActor.AlarmQualityUpdate(
AlarmNodeId: e.AlarmId,
Quality: QualityFromStatus(e.WorstInputStatusCode),
StatusCode: e.WorstInputStatusCode,
TimestampUtc: e.TimestampUtc,
Realm: AddressSpaceRealm.Uns));
return;
@@ -715,10 +700,10 @@ public sealed class ScriptedAlarmHostActor : ReceiveActor
Shelving: MapShelving(condition.Shelving.Kind),
Severity: (ushort)SeverityToInt(severity),
Message: message,
// #478 — the condition's Quality is the worst quality across the script's input tags at evaluation
// #478 — the condition's status is the worst status across the script's input tags at evaluation
// time (carried on the event by the engine). A transition fired while an input is Uncertain projects
// Uncertain here so the full-snapshot write doesn't clobber quality back to Good.
Quality: QualityFromStatus(worstInputStatusCode));
StatusCode: worstInputStatusCode);
/// <summary>Maps the Core <see cref="ShelvingKind"/> onto the Commons <see cref="AlarmShelvingKind"/>
/// mirror (the Commons assembly can't see the Core enum).</summary>
@@ -100,7 +100,7 @@ public sealed class DependencyMuxActor : ReceiveActor
// space carries thousands of tags and only a fraction feed virtual-tag expressions.
return;
}
var dep = new VirtualTagActor.DependencyValueChanged(msg.FullReference, msg.Value, msg.TimestampUtc, msg.Quality);
var dep = new VirtualTagActor.DependencyValueChanged(msg.FullReference, msg.Value, msg.TimestampUtc, msg.StatusCode);
foreach (var sub in subscribers)
{
sub.Tell(dep);
@@ -18,8 +18,8 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags;
/// <c>script-logs</c> DPS topic so operators see the diagnostic in the live tail.
///
/// <para>
/// 02/S13 — a persistent script failure/timeout also degrades the node's OPC UA quality to
/// <see cref="OpcUaQuality.Bad"/> (carrying the last-known value), published <b>once per
/// 02/S13 — a persistent script failure/timeout also degrades the node's OPC UA status to
/// <see cref="OpcUaStatus.BadInternalError"/> (carrying the last-known value), published <b>once per
/// transition</b> (Good→Bad) and gated on every declared dependency having arrived at least once
/// — so a broken script no longer serves its last Good value forever, and a fresh/respawned
/// multi-dep script does not flash Bad during deploy warm-up. Recovery to a Good value clears the
@@ -30,11 +30,12 @@ public sealed class VirtualTagActor : ReceiveActor
{
public const string ScriptLogsTopic = "script-logs";
/// <summary>A dependency's value changed. <paramref name="Quality"/> (#478) carries the source
/// driver's OPC UA quality so the scripted-alarm host can derive a condition's worst-of-input quality;
/// it defaults to <see cref="OpcUaQuality.Good"/>, and the virtual-tag engine ignores it.</summary>
/// <summary>A dependency's value changed. <paramref name="StatusCode"/> (#478) carries the source
/// driver's OPC UA status code so the scripted-alarm host can derive a condition's worst-of-input
/// quality; it defaults to <see cref="OpcUaStatus.Good"/>, and the virtual-tag engine ignores it.
/// Since #519 it carries the driver's sub-code, so a worst-of-input winner keeps the reason it won.</summary>
public sealed record DependencyValueChanged(
string TagId, object? Value, DateTime TimestampUtc, OpcUaQuality Quality = OpcUaQuality.Good);
string TagId, object? Value, DateTime TimestampUtc, uint StatusCode = OpcUaStatus.Good);
/// <summary>Re-assert the last emitted value/quality to the parent bridge, bypassing the value dedup.
/// Sent by <see cref="VirtualTagHostActor"/> on every apply (deploy) to a surviving child: a deploy
@@ -43,9 +44,9 @@ public sealed class VirtualTagActor : ReceiveActor
/// node would stay Bad forever. Re-asserting the last state recovers it. Singleton — carries no data.</summary>
public sealed class ReassertValue { public static readonly ReassertValue Instance = new(); private ReassertValue() { } }
/// <summary>Result emitted to the parent (the host bridge). <paramref name="Quality"/> carries the
/// OPC UA quality the node should read: <see cref="OpcUaQuality.Good"/> for a fresh computed value,
/// <see cref="OpcUaQuality.Bad"/> when the script failed/timed out (02/S13) — in which case
/// <summary>Result emitted to the parent (the host bridge). <paramref name="StatusCode"/> carries the
/// OPC UA status code the node should read: <see cref="OpcUaStatus.Good"/> for a fresh computed value,
/// <see cref="OpcUaStatus.BadInternalError"/> when the script failed/timed out (02/S13) — in which case
/// <paramref name="Value"/> is the last-known value (or null if none). Defaulting to Good keeps
/// every existing construction site source-compatible.</summary>
/// <param name="IsReassert"><c>true</c> when this result is a deploy-time re-assertion of the last
@@ -53,7 +54,7 @@ public sealed class VirtualTagActor : ReceiveActor
/// publishes it (to repair the reset OPC UA node) but must NOT re-record it to the historian — it is a
/// stale value stamped with a fresh deploy-time timestamp, never a real evaluation sample. Defaults to
/// <c>false</c> so every existing construction site stays source-compatible.</param>
public sealed record EvaluationResult(string VirtualTagId, object? Value, DateTime TimestampUtc, CorrelationId Correlation, OpcUaQuality Quality = OpcUaQuality.Good, bool IsReassert = false);
public sealed record EvaluationResult(string VirtualTagId, object? Value, DateTime TimestampUtc, CorrelationId Correlation, uint StatusCode = OpcUaStatus.Good, bool IsReassert = false);
private readonly string _virtualTagId;
private readonly string _scriptId;
@@ -170,9 +171,9 @@ public sealed class VirtualTagActor : ReceiveActor
private void OnReassertValue()
{
if (!_hasLastValue && !_lastPublishedBad) return;
var quality = _lastPublishedBad ? OpcUaQuality.Bad : OpcUaQuality.Good;
var statusCode = _lastPublishedBad ? OpcUaStatus.BadInternalError : OpcUaStatus.Good;
Context.Parent.Tell(new EvaluationResult(
_virtualTagId, _lastValue, DateTime.UtcNow, CorrelationId.NewId(), quality, IsReassert: true));
_virtualTagId, _lastValue, DateTime.UtcNow, CorrelationId.NewId(), statusCode, IsReassert: true));
}
private void OnDependencyChanged(DependencyValueChanged msg)
@@ -242,7 +243,7 @@ public sealed class VirtualTagActor : ReceiveActor
_lastPublishedBad = true;
Context.Parent.Tell(new EvaluationResult(
_virtualTagId, _hasLastValue ? _lastValue : null, timestampUtc, CorrelationId.NewId(), OpcUaQuality.Bad));
_virtualTagId, _hasLastValue ? _lastValue : null, timestampUtc, CorrelationId.NewId(), OpcUaStatus.BadInternalError));
}
/// <summary>Inputs-ready gate (mirrors the dormant <c>VirtualTagEngine.AreInputsReady</c>): true once
@@ -221,11 +221,11 @@ public sealed class VirtualTagHostActor : ReceiveActor
}
// 02/S13: the child now expresses quality — a Good fresh value or a Bad degradation (script
// failure/timeout) carrying the last-known value. Bridge result.Quality through verbatim; the
// failure/timeout) carrying the last-known value. Bridge result.StatusCode through verbatim; the
// sink maps it to StatusCodes.Good/Bad on the node so a broken script no longer freezes Good.
// VirtualTags materialise as equipment nodes in the UNS realm — publish there.
_publishActor.Tell(new OpcUaPublishActor.AttributeValueUpdate(
nodeId, result.Value, result.Quality, result.TimestampUtc, AddressSpaceRealm.Uns));
nodeId, result.Value, result.StatusCode, result.TimestampUtc, AddressSpaceRealm.Uns));
// Historize iff the plan opted in — but NEVER for a re-assert (M1). A re-assert re-publishes a
// stale last value stamped with a fresh deploy-time timestamp purely to repair the reset OPC UA
@@ -233,14 +233,12 @@ public sealed class VirtualTagHostActor : ReceiveActor
// evaluation (and, if the last state was Bad, a fabricated BadInternalError point). Reuses
// _planByVtag (kept in lock-step with _children), so no parallel map. The historian path key is the
// SAME folder-scoped NodeId we just published to. For a computed value source == server, so both
// timestamps are the evaluation time. Bad results record BadInternalError (0x80020000u) — the
// dormant VirtualTagEngine's Bad-snapshot status — so the historian trend can distinguish a degraded
// sample from a Good one.
// timestamps are the evaluation time. Since #519 the result's status code IS the historian status —
// the two used to be derived separately here, which is why this line existed at all.
if (!result.IsReassert && _planByVtag.TryGetValue(result.VirtualTagId, out var plan) && plan.Historize)
{
var status = result.Quality == OpcUaQuality.Good ? 0u : 0x80020000u /* BadInternalError — dormant-engine parity */;
_history.Record(nodeId, new DataValueSnapshot(
result.Value, status, result.TimestampUtc, result.TimestampUtc));
result.Value, result.StatusCode, result.TimestampUtc, result.TimestampUtc));
}
}
@@ -19,7 +19,7 @@ public class DeferredAddressSpaceSinkTests
{
var sink = new DeferredAddressSpaceSink();
// Must not throw.
sink.WriteValue("ns=2;s=x", 42, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
sink.WriteValue("ns=2;s=x", 42, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
}
[Fact]
@@ -46,7 +46,7 @@ public class DeferredAddressSpaceSinkTests
var sink = new DeferredAddressSpaceSink();
sink.SetSink(inner);
sink.WriteValue("ns=2;s=x", 99, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
sink.WriteValue("ns=2;s=x", 99, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
inner.WriteValueCalled.ShouldBeTrue();
}
@@ -193,7 +193,7 @@ public class DeferredAddressSpaceSinkTests
sink.SetSink(inner);
sink.SetSink(null); // revert
sink.WriteValue("ns=2;s=x", 1, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
sink.WriteValue("ns=2;s=x", 1, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
inner.WriteValueCalled.ShouldBeFalse("write should be no-op after reverting to null sink");
}
@@ -206,10 +206,10 @@ public class DeferredAddressSpaceSinkTests
public bool RebuildCalled { get; private set; }
public (string AlarmNodeId, AddressSpaceRealm AlarmRealm, IReadOnlyList<string> Folders, AddressSpaceRealm FolderRealm)? WireAlarmNotifiersArgs { get; private set; }
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
public void WriteValue(string nodeId, object? value, uint statusCode, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
=> WriteValueCalled = true;
public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
public void WriteAlarmQuality(string alarmNodeId, uint statusCode, 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) { }
@@ -227,8 +227,8 @@ public class DeferredAddressSpaceSinkTests
public bool UpdateCalled { get; private set; }
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 WriteValue(string nodeId, object? value, uint statusCode, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void WriteAlarmQuality(string alarmNodeId, uint statusCode, 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) { }
@@ -1,6 +1,7 @@
using System.Reflection;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests;
@@ -18,6 +19,10 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests;
/// <c>ZB.MOM.WW.OtOpcUa.Driver.*.dll</c> deployed to its output directory for <c>const uint</c> fields
/// whose name reads as a status code, so a brand-new driver assembly referenced by this project is
/// covered automatically, with no edit here.</para>
/// <para><b>Commons is in scope too</b> (Gitea #519). <c>Commons.OpcUa.OpcUaStatus</c> is SDK-free for
/// the same layering reason the drivers are — <c>IOpcUaAddressSpaceSink</c> exists so Runtime need not
/// reference <c>Opc.Ua.Server</c> — so its constants carry the identical name-vs-value risk. Commons
/// arrives here transitively through <c>Core</c>.</para>
/// <para><b>An inline literal is invisible to this guard.</b> Reflection can only see a <em>named</em>
/// constant, so <c>StatusCode: 0x800B0000u, // BadTimeout</c> written at a call site is unguarded —
/// which is how <c>GalaxyDriver</c> shipped <c>BadServiceUnsupported</c> under a <c>BadTimeout</c>
@@ -60,7 +65,11 @@ public sealed class StatusCodeParityTests
var data = new TheoryData<string, string, string, string, uint>();
var binDir = Path.GetDirectoryName(typeof(StatusCodeParityTests).Assembly.Location)!;
foreach (var dll in Directory.GetFiles(binDir, "ZB.MOM.WW.OtOpcUa.Driver.*.dll").OrderBy(p => p, StringComparer.Ordinal))
var scanned = Directory.GetFiles(binDir, "ZB.MOM.WW.OtOpcUa.Driver.*.dll")
.Concat(Directory.GetFiles(binDir, "ZB.MOM.WW.OtOpcUa.Commons.dll"))
.OrderBy(p => p, StringComparer.Ordinal);
foreach (var dll in scanned)
{
Assembly asm;
try { asm = Assembly.LoadFrom(dll); }
@@ -157,4 +166,25 @@ public sealed class StatusCodeParityTests
assemblies.Length.ShouldBeGreaterThan(3,
"status constants were found in fewer than four driver assemblies: " + string.Join(", ", assemblies));
}
/// <summary>
/// Positive control for the Commons half of the scan (#519). Widening the glob is worthless if
/// <c>Commons.dll</c> does not actually deploy to this project's bin — the theory would simply run
/// over the driver rows and pass, reporting nothing. Naming the type explicitly means a broken
/// transitive reference fails here instead of silently un-guarding the engine's status constants.
/// </summary>
[Fact]
public void Discovery_covers_the_SDK_free_Commons_status_constants()
{
var commonsRows = DeclaredStatusConstants()
.Select(row => row.Data)
.Where(r => r.Item2 == "ZB.MOM.WW.OtOpcUa.Commons.OpcUa.OpcUaStatus")
.ToArray();
commonsRows.Length.ShouldBeGreaterThan(3,
"OpcUaStatus' constants were not discovered — ZB.MOM.WW.OtOpcUa.Commons.dll did not deploy to " +
"this project's bin, so the engine-layer status codes are unguarded despite the widened scan.");
commonsRows.Select(r => r.Item3).ShouldContain(nameof(OpcUaStatus.BadNoCommunication));
}
}
@@ -158,12 +158,10 @@ public sealed class CalculationDriverTests
after[0].Value.ShouldBe(6.0);
}
[Fact]
public void Health_is_always_Connected()
{
var (driver, _, _) = Build(CalcTag("calc/e/x", "return 1.0;"));
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
}
// REMOVED (Gitea #517): `Health_is_always_Connected` asserted that a driver which had never been
// initialized still reported Healthy — a characterization test of the defect, not of a requirement.
// Its valid half (a running Calculation driver has no transport to be Degraded about, so it is simply
// Healthy) is covered by GetHealth_tracks_the_lifecycle_rather_than_reporting_a_constant below.
private static async Task<bool> WaitUntil(Func<bool> predicate, TimeSpan timeout)
{
@@ -176,6 +174,41 @@ public sealed class CalculationDriverTests
return predicate();
}
// ── health lifecycle (Gitea #517) ─────────────────────────────────────────────────────────
/// <summary>
/// Gitea #517 — <c>GetHealth()</c> must not report Healthy for a driver that is not running.
/// <para>This driver returned a hard-coded <see cref="DriverState.Healthy"/> that consulted
/// nothing, which made it the fleet's only driver reporting Healthy with its timers stopped —
/// deterministically, not as a race window. Every other driver reads a <c>_health</c> field that
/// shutdown lands on <see cref="DriverState.Unknown"/>.</para>
/// <para>The pre-initialize leg is what makes this falsifiable against the old code: a test that
/// only checked post-shutdown could be satisfied by a driver that happened to write Unknown once,
/// whereas asserting BOTH ends pins the value to the lifecycle flag.</para>
/// </summary>
[Fact]
public async Task GetHealth_tracks_the_lifecycle_rather_than_reporting_a_constant()
{
var (driver, _, _) = Build(CalcTag("calc/e/x", "return 1.0;"));
// Constructed but never initialized — nothing is computing, so Healthy would be a lie.
driver.GetHealth().State.ShouldBe(DriverState.Unknown);
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
await driver.ShutdownAsync(TestContext.Current.CancellationToken);
driver.GetHealth().State.ShouldBe(DriverState.Unknown,
"timers are stopped and _running is false — the driver computes nothing");
// Re-initialize brings it back, so the flag is genuinely read each call rather than latched once.
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
driver.Dispose();
driver.GetHealth().State.ShouldBe(DriverState.Unknown);
}
private sealed class CapturingPublisher(List<ScriptLogEntry> sink) : IScriptLogPublisher
{
private readonly object _lock = new();
@@ -434,6 +434,13 @@ internal sealed class CannedAgentClient : IMTConnectAgentClient, IDisposable
/// to release anything a test cares about.
/// </para>
/// </remarks>
/// <summary>
/// Invoked on the first <see cref="Dispose"/> only. Lets a test observe driver state at the exact
/// instant the client becomes unusable — the window Gitea #517 is about, which is otherwise
/// invisible because it has closed again by the time <c>InitializeAsync</c> returns.
/// </summary>
public Action? OnFirstDispose { get; set; }
public void Dispose()
{
if (Interlocked.Increment(ref _disposeCount) > 1)
@@ -441,6 +448,7 @@ internal sealed class CannedAgentClient : IMTConnectAgentClient, IDisposable
return;
}
OnFirstDispose?.Invoke();
_disposeCts.Cancel();
Volatile.Read(ref _chunks).Writer.TryComplete();
}
@@ -161,6 +161,41 @@ public sealed class MTConnectDriverLifecycleTests
client.IsDisposed.ShouldBeTrue();
}
/// <summary>
/// Gitea #517 — the failed state must be published BEFORE the client is disposed, not after.
/// <para>Every other test in this file asserts the state <em>once InitializeAsync has returned</em>,
/// by which time the window has closed. That is why the defect survived: the terminal value was
/// always right, and only the ordering was wrong. This samples <c>GetHealth()</c> from inside the
/// client's Dispose, which is the moment the driver stops being able to serve anything.</para>
/// <para>The window is reachable in production, which is what makes this worth pinning rather than
/// a theoretical tidy-up: <c>DriverInstanceActor</c> runs <c>InitializeAsync</c> on a
/// fire-and-forget <c>Task.Run</c>, so its health-poll tick keeps calling <c>GetHealth()</c> on the
/// actor thread for the whole duration of the teardown.</para>
/// <para>Falsifiable: remove the pre-teardown <c>WriteHealth</c> and the observed state reverts to
/// <see cref="DriverState.Initializing"/> — the stale value written on entry.</para>
/// </summary>
[Fact]
public async Task Initialize_publishes_the_failed_state_before_disposing_the_client()
{
var (driver, client) = NewDriver();
client.ProbeFailure = new HttpRequestException("agent unreachable");
DriverState? stateAtDisposal = null;
client.OnFirstDispose = () => stateAtDisposal = driver.GetHealth().State;
await Should.ThrowAsync<HttpRequestException>(
() => driver.InitializeAsync("{}", CancellationToken.None));
client.IsDisposed.ShouldBeTrue("the test is vacuous unless the disposal actually happened");
stateAtDisposal.ShouldNotBeNull();
stateAtDisposal.ShouldBe(DriverState.Faulted,
"health must already report the failure when the client goes away — a caller polling " +
"GetHealth() during the teardown must never see a state that outlives the transport");
// And the terminal write still lands, so the resting state is right too.
driver.GetHealth().State.ShouldBe(DriverState.Faulted);
}
[Fact]
public async Task Initialize_faults_when_probe_succeeds_but_the_priming_current_fails()
{
@@ -65,8 +65,8 @@ public sealed class DualNamespaceAddressSpaceTests
MaterialiseDualTree(sink);
// One driver publish per NodeId (the fan-out the DriverHostActor performs) — identical payload.
var ts = DateTime.UtcNow;
sink.WriteValue(RawTagPath, 42.5f, OpcUaQuality.Good, ts, AddressSpaceRealm.Raw);
sink.WriteValue(UnsVarPath, 42.5f, OpcUaQuality.Good, ts, AddressSpaceRealm.Uns);
sink.WriteValue(RawTagPath, 42.5f, OpcUaStatus.Good, ts, AddressSpaceRealm.Raw);
sink.WriteValue(UnsVarPath, 42.5f, OpcUaStatus.Good, ts, AddressSpaceRealm.Uns);
using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct);
@@ -125,8 +125,8 @@ public sealed class DualNamespaceAddressSpaceTests
// The single source (one RawPath) fans to the raw NodeId AND every referencing UNS NodeId with
// identical value/quality/timestamp — the fan-out drift invariant.
var ts = new DateTime(2026, 7, 16, 8, 30, 0, DateTimeKind.Utc);
sink.WriteValue(RawTagPath, 123.75f, OpcUaQuality.Good, ts, AddressSpaceRealm.Raw);
sink.WriteValue(UnsVarPath, 123.75f, OpcUaQuality.Good, ts, AddressSpaceRealm.Uns);
sink.WriteValue(RawTagPath, 123.75f, OpcUaStatus.Good, ts, AddressSpaceRealm.Raw);
sink.WriteValue(UnsVarPath, 123.75f, OpcUaStatus.Good, ts, AddressSpaceRealm.Uns);
var rawValue = await session.ReadValueAsync(new NodeId(RawTagPath, rawNs), ct);
var unsValue = await session.ReadValueAsync(new NodeId(UnsVarPath, unsNs), ct);
@@ -216,13 +216,13 @@ public sealed class NativeAlarmEventIdentityFieldDeliveryTests
// 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);
sink.WriteAlarmQuality(RawAlarmPath, OpcUaStatus.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);
sink.WriteAlarmQuality(RawAlarmPath, OpcUaStatus.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");
@@ -294,6 +294,9 @@ public sealed class NativeAlarmEventIdentityFieldDeliveryTests
private static EventFilter BuildQualityEventFilter()
{
var filter = new EventFilter();
// BrowseNames.Quality is the SDK's Part 9 ConditionType.Quality browse name — NOT our snapshot's
// renamed StatusCode member. Selecting the wrong clause here yields a null field and an NRE at the
// cast, rather than a readable assertion failure.
filter.AddSelectClause(ObjectTypes.ConditionType, BrowseNames.Quality);
filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.Message);
return filter;
@@ -67,7 +67,7 @@ public sealed class SubscriptionSurvivalTests
applier.MaterialiseHierarchy(withA);
applier.MaterialiseEquipmentTags(withA);
var nodeIdAString = V3NodeIds.Uns("eq-1", "A");
sink.WriteValue(nodeIdAString, 41, Commons.OpcUa.OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
sink.WriteValue(nodeIdAString, 41, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
// --- Open a client session + subscription + monitored item on A. ---
using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct);
@@ -116,7 +116,7 @@ public sealed class SubscriptionSurvivalTests
// --- A's monitored item must still be alive: a new write to A is delivered. ---
lock (gate) received.Clear();
sink.WriteValue(nodeIdAString, 42, Commons.OpcUa.OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
sink.WriteValue(nodeIdAString, 42, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
await WaitUntilAsync(() => { lock (gate) return received.Any(v => Equals(v.Value, 42)); }, TimeSpan.FromSeconds(5));
lock (gate)
@@ -127,7 +127,7 @@ public sealed class SubscriptionSurvivalTests
// --- B is browsable + readable (the add landed). ---
var nodeIdB = new NodeId(V3NodeIds.Uns("eq-1", "B"), ns);
sink.WriteValue(nodeIdB.Identifier.ToString()!, 7, Commons.OpcUa.OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
sink.WriteValue(nodeIdB.Identifier.ToString()!, 7, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
var bValue = await session.ReadValueAsync(nodeIdB, ct);
bValue.StatusCode.ShouldNotBe(StatusCodes.BadNodeIdUnknown);
bValue.Value.ShouldBe(7);
@@ -176,8 +176,8 @@ public sealed class SubscriptionSurvivalTests
applier.MaterialiseEquipmentTags(withAB);
var nodeIdAString = V3NodeIds.Uns("eq-1", "A");
var nodeIdBString = V3NodeIds.Uns("eq-1", "B");
sink.WriteValue(nodeIdAString, 10, Commons.OpcUa.OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
sink.WriteValue(nodeIdBString, 20, Commons.OpcUa.OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
sink.WriteValue(nodeIdAString, 10, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
sink.WriteValue(nodeIdBString, 20, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct);
var ns = (ushort)session.NamespaceUris.GetIndex(OtOpcUaNodeManager.DefaultNamespaceUri);
@@ -213,7 +213,7 @@ public sealed class SubscriptionSurvivalTests
await WaitUntilAsync(() => { lock (gate) return receivedB.Any(v => StatusCode.IsBad(v.StatusCode)); }, TimeSpan.FromSeconds(5));
// A stays alive: a fresh write to A is delivered.
sink.WriteValue(nodeIdAString, 11, Commons.OpcUa.OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
sink.WriteValue(nodeIdAString, 11, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
await WaitUntilAsync(() => { lock (gate) return receivedA.Any(v => Equals(v.Value, 11)); }, TimeSpan.FromSeconds(5));
// B is gone: a re-read surfaces BadNodeIdUnknown. The 1.5.378 node-cache read path
@@ -263,7 +263,7 @@ public sealed class SubscriptionSurvivalTests
applier.MaterialiseHierarchy(withAB);
applier.MaterialiseEquipmentTags(withAB);
var nodeIdAString = V3NodeIds.Uns("eq-1", "A");
sink.WriteValue(nodeIdAString, 10, Commons.OpcUa.OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
sink.WriteValue(nodeIdAString, 10, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct);
var ns = (ushort)session.NamespaceUris.GetIndex(OtOpcUaNodeManager.DefaultNamespaceUri);
@@ -296,7 +296,7 @@ public sealed class SubscriptionSurvivalTests
applier.AnnounceAddedNodes(plan);
// A stays alive.
sink.WriteValue(nodeIdAString, 11, Commons.OpcUa.OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
sink.WriteValue(nodeIdAString, 11, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
await WaitUntilAsync(() => { lock (gate) return receivedA.Any(v => Equals(v.Value, 11)); }, TimeSpan.FromSeconds(5));
// B is gone. (1.5.378 node-cache read throws for an unknown node — see above.)
@@ -307,7 +307,7 @@ public sealed class SubscriptionSurvivalTests
// C is browsable + readable.
var nodeIdC = new NodeId(V3NodeIds.Uns("eq-1", "C"), ns);
sink.WriteValue(nodeIdC.Identifier.ToString()!, 9, Commons.OpcUa.OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
sink.WriteValue(nodeIdC.Identifier.ToString()!, 9, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
var cRead = await session.ReadValueAsync(nodeIdC, ct);
cRead.StatusCode.ShouldNotBe((StatusCode)StatusCodes.BadNodeIdUnknown);
cRead.Value.ShouldBe(9);
@@ -156,9 +156,9 @@ public sealed class AddressSpaceApplierFailureSurfaceTests
public bool ThrowOnAlarmWrite { get; init; }
public bool ThrowOnMaterialiseAlarm { get; init; }
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void WriteValue(string nodeId, object? value, uint statusCode, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
public void WriteAlarmQuality(string alarmNodeId, uint statusCode, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
{
@@ -323,12 +323,12 @@ public sealed class AddressSpaceApplierHierarchyTests : IDisposable
/// <param name="value">The value to write.</param>
/// <param name="quality">The OPC UA quality value.</param>
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void WriteValue(string nodeId, object? value, uint statusCode, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
/// <summary>Records an alarm condition write (stub implementation for testing).</summary>
/// <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 WriteAlarmQuality(string alarmNodeId, uint statusCode, 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>
@@ -300,8 +300,8 @@ public sealed class AddressSpaceApplierRawUnsTests
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes")
=> 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 WriteValue(string nodeId, object? value, uint statusCode, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
public void WriteAlarmQuality(string alarmNodeId, uint statusCode, 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) { }
@@ -147,7 +147,7 @@ public sealed class AddressSpaceApplierTests
sink.RebuildCalls.ShouldBe(0);
var nodeId = V3NodeIds.Uns("eq-1", "Speed");
// Terminal Bad written to the removed variable BEFORE the removal.
sink.ValueWrites.ShouldContain((nodeId, OpcUaQuality.Bad));
sink.ValueWrites.ShouldContain((nodeId, OpcUaStatus.Bad));
sink.RemoveCalls.ShouldHaveSingleItem().ShouldBe(("var", nodeId));
}
@@ -1583,22 +1583,22 @@ public sealed class AddressSpaceApplierTests
public List<(string AlarmNodeId, string EquipmentNodeId, string DisplayName, string AlarmType, int Severity, bool IsNative)> AlarmConditionCalls => AlarmConditionQueue.ToList();
/// <summary>Gets the queue of value writes (NodeId, quality) — used to assert the PureRemove terminal Bad.</summary>
public ConcurrentQueue<(string NodeId, OpcUaQuality Quality)> ValueWriteQueue { get; } = new();
public ConcurrentQueue<(string NodeId, uint Quality)> ValueWriteQueue { get; } = new();
/// <summary>Gets the list of recorded value writes.</summary>
public List<(string NodeId, OpcUaQuality Quality)> ValueWrites => ValueWriteQueue.ToList();
public List<(string NodeId, uint Quality)> ValueWrites => ValueWriteQueue.ToList();
/// <summary>Records a value write (NodeId + quality).</summary>
/// <param name="nodeId">The node ID.</param>
/// <param name="value">The value to write.</param>
/// <param name="quality">The OPC UA quality.</param>
/// <param name="statusCode">The OPC UA status code, sub-code included.</param>
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
=> ValueWriteQueue.Enqueue((nodeId, quality));
public void WriteValue(string nodeId, object? value, uint statusCode, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
=> ValueWriteQueue.Enqueue((nodeId, statusCode));
/// <summary>Records an alarm condition write call.</summary>
/// <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 WriteAlarmQuality(string alarmNodeId, uint statusCode, 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>
@@ -1651,9 +1651,9 @@ public sealed class AddressSpaceApplierTests
public int RebuildCalls;
/// <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) { }
public void WriteValue(string nodeId, object? value, uint statusCode, 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 WriteAlarmQuality(string alarmNodeId, uint statusCode, 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) { }
@@ -1679,15 +1679,15 @@ public sealed class AddressSpaceApplierTests
/// <summary>Records a value write (no-op in this sink).</summary>
/// <param name="nodeId">The node ID.</param>
/// <param name="value">The value to write.</param>
/// <param name="quality">The OPC UA quality.</param>
/// <param name="statusCode">The OPC UA status code, sub-code included.</param>
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void WriteValue(string nodeId, object? value, uint statusCode, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
/// <summary>Throws an exception if configured to do so.</summary>
/// <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>
/// <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 WriteAlarmQuality(string alarmNodeId, uint statusCode, 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");
@@ -14,7 +14,7 @@ public sealed class DeferredAddressSpaceSinkTests
var deferred = new DeferredAddressSpaceSink();
// No throw, no observable side effect.
deferred.WriteValue("x", 1, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
deferred.WriteValue("x", 1, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
deferred.WriteAlarmCondition("a", Snapshot(active: true), DateTime.UtcNow, AddressSpaceRealm.Uns);
deferred.RebuildAddressSpace();
}
@@ -27,7 +27,7 @@ public sealed class DeferredAddressSpaceSinkTests
var inner = new RecordingSink();
deferred.SetSink(inner);
deferred.WriteValue("x", 42, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
deferred.WriteValue("x", 42, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
deferred.WriteAlarmCondition("a-1", Snapshot(active: true), DateTime.UtcNow, AddressSpaceRealm.Uns);
deferred.RebuildAddressSpace();
deferred.RaiseNodesAddedModelChange("eq-1", AddressSpaceRealm.Uns);
@@ -42,11 +42,11 @@ public sealed class DeferredAddressSpaceSinkTests
var deferred = new DeferredAddressSpaceSink();
var inner = new RecordingSink();
deferred.SetSink(inner);
deferred.WriteValue("x", 1, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
deferred.WriteValue("x", 1, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
inner.Calls.Count.ShouldBe(1);
deferred.SetSink(null);
deferred.WriteValue("y", 2, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns); // dropped
deferred.WriteValue("y", 2, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns); // dropped
inner.Calls.Count.ShouldBe(1);
}
@@ -59,10 +59,10 @@ public sealed class DeferredAddressSpaceSinkTests
var second = new RecordingSink();
deferred.SetSink(first);
deferred.WriteValue("a", 1, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
deferred.WriteValue("a", 1, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
deferred.SetSink(second);
deferred.WriteValue("b", 2, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
deferred.WriteValue("b", 2, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
first.Calls.Single().ShouldBe("WV:a");
second.Calls.Single().ShouldBe("WV:b");
@@ -228,10 +228,10 @@ public sealed class DeferredAddressSpaceSinkTests
public List<(string NodeId, string? HistorianTagname)> HistorianCalls => HistorianQueue.ToList();
/// <inheritdoc />
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
public void WriteValue(string nodeId, object? value, uint statusCode, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
=> CallQueue.Enqueue($"WV:{nodeId}");
/// <inheritdoc />
public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
public void WriteAlarmQuality(string alarmNodeId, uint statusCode, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
=> CallQueue.Enqueue($"WA:{alarmNodeId}");
/// <inheritdoc />
@@ -288,9 +288,9 @@ public sealed class DeferredAddressSpaceSinkTests
public bool RemoveEquipmentSubtree(string equipmentNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { RemoveCalls.Add(("equipment", equipmentNodeId)); return Result; }
/// <inheritdoc />
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void WriteValue(string nodeId, object? value, uint statusCode, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
/// <inheritdoc />
public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
public void WriteAlarmQuality(string alarmNodeId, uint statusCode, 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) { }
@@ -253,7 +253,7 @@ public sealed class NodeManagerAlarmSourceFieldsTests : IDisposable
var badSnapshot = new AlarmConditionSnapshot(
Active: false, Acknowledged: true, Confirmed: true, Enabled: true,
Shelving: AlarmShelvingKind.Unshelved, Severity: 700, Message: "HR200",
Quality: OpcUaQuality.Bad);
StatusCode: OpcUaStatus.Bad);
nm.WriteAlarmCondition(RawAlarmPath, badSnapshot, DateTime.UtcNow, AddressSpaceRealm.Raw);
var condition = nm.TryGetAlarmCondition(RawAlarmPath, AddressSpaceRealm.Raw);
@@ -261,7 +261,7 @@ public sealed class NodeManagerAlarmSourceFieldsTests : IDisposable
StatusCode.IsBad(condition.Quality.Value).ShouldBeTrue();
// Recover: a Good snapshot restores Good quality.
var goodSnapshot = badSnapshot with { Quality = OpcUaQuality.Good };
var goodSnapshot = badSnapshot with { StatusCode = OpcUaStatus.Good };
nm.WriteAlarmCondition(RawAlarmPath, goodSnapshot, DateTime.UtcNow, AddressSpaceRealm.Raw);
StatusCode.IsGood(condition.Quality.Value).ShouldBeTrue();
}
@@ -286,11 +286,11 @@ public sealed class NodeManagerAlarmSourceFieldsTests : IDisposable
var messageBefore = condition.Message!.Value?.Text;
// Device comes online → Good.
nm.WriteAlarmQuality(RawAlarmPath, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
nm.WriteAlarmQuality(RawAlarmPath, OpcUaStatus.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);
nm.WriteAlarmQuality(RawAlarmPath, OpcUaStatus.Bad, DateTime.UtcNow, AddressSpaceRealm.Raw);
StatusCode.IsBad(condition.Quality.Value).ShouldBeTrue();
condition.Severity.Value.ShouldBe(severityBefore);
condition.Message.Value?.Text.ShouldBe(messageBefore);
@@ -307,7 +307,7 @@ public sealed class NodeManagerAlarmSourceFieldsTests : IDisposable
var nm = host.Nm;
Should.NotThrow(() =>
nm.WriteAlarmQuality("no/such/node", OpcUaQuality.Bad, DateTime.UtcNow, AddressSpaceRealm.Raw));
nm.WriteAlarmQuality("no/such/node", OpcUaStatus.Bad, DateTime.UtcNow, AddressSpaceRealm.Raw));
}
/// <summary>A booted server + its node manager, disposed via <c>await using</c> so an assertion failure
@@ -94,7 +94,7 @@ public sealed class NodeManagerArrayTests : IDisposable
writable: false, historianTagname: null, isArray: true, arrayLength: 3, realm: AddressSpaceRealm.Uns);
var payload = new[] { 1, 2, 3 };
nm.WriteValue("eq-1/arrwrite", payload, OpcUaQuality.Good, DateTime.UtcNow, realm: AddressSpaceRealm.Uns);
nm.WriteValue("eq-1/arrwrite", payload, OpcUaStatus.Good, DateTime.UtcNow, realm: AddressSpaceRealm.Uns);
var variable = nm.TryGetVariable("eq-1/arrwrite");
variable.ShouldNotBeNull();
@@ -104,6 +104,47 @@ public sealed class NodeManagerArrayTests : IDisposable
await host.DisposeAsync();
}
/// <summary>
/// Gitea #519, second hop — the node manager hands the caller's status code to the SDK
/// <b>verbatim</b>, sub-code included.
/// <para>It used to re-expand a 3-state enum here (<c>Bad =&gt; StatusCodes.Bad</c>), so every Bad
/// sub-code a driver produced landed on the wire as <c>0x80000000</c>. Boots a real SDK server and
/// reads the created <see cref="BaseDataVariableState"/> back, which is the value an OPC UA client
/// would receive.</para>
/// <para>Asserting two DIFFERENT Bad sub-codes is what makes this falsifiable: under the old
/// projection both collapsed to the same value, so a single-code assertion could pass on a
/// hard-coded constant.</para>
/// </summary>
[Fact]
public async Task WriteValue_hands_the_status_sub_code_to_the_SDK_verbatim()
{
var (host, server) = await BootAsync();
var nm = server.NodeManager!;
nm.EnsureVariable("eq-1/subcode", parentFolderNodeId: null, displayName: "subcode", dataType: "Double",
writable: false, historianTagname: null, realm: AddressSpaceRealm.Uns);
nm.WriteValue("eq-1/subcode", null, OpcUaStatus.BadNoCommunication, DateTime.UtcNow,
realm: AddressSpaceRealm.Uns);
var variable = nm.TryGetVariable("eq-1/subcode");
variable.ShouldNotBeNull();
variable!.StatusCode.ShouldBe((StatusCode)StatusCodes.BadNoCommunication);
variable.StatusCode.ShouldNotBe((StatusCode)StatusCodes.Bad,
"the sub-code was collapsed to a generic Bad — this is exactly the #519 defect");
// A different Bad sub-code must land differently.
nm.WriteValue("eq-1/subcode", null, 0x80740000u /* BadTypeMismatch */, DateTime.UtcNow,
realm: AddressSpaceRealm.Uns);
nm.TryGetVariable("eq-1/subcode")!.StatusCode.ShouldBe((StatusCode)StatusCodes.BadTypeMismatch);
// Uncertain and Good still round-trip.
nm.WriteValue("eq-1/subcode", 1.0, OpcUaStatus.Good, DateTime.UtcNow, realm: AddressSpaceRealm.Uns);
nm.TryGetVariable("eq-1/subcode")!.StatusCode.ShouldBe((StatusCode)StatusCodes.Good);
await host.DisposeAsync();
}
private async Task<(OpcUaApplicationHost Host, OtOpcUaSdkServer Server)> BootAsync()
{
var host = new OpcUaApplicationHost(
@@ -70,7 +70,7 @@ public sealed class NodeManagerPreStartGuardTests : IDisposable
try
{
Should.Throw<InvalidOperationException>(() =>
nm.WriteValue("eq-1/temp", 1.0, OpcUaQuality.Good, DateTime.UtcNow, realm: AddressSpaceRealm.Uns));
nm.WriteValue("eq-1/temp", 1.0, OpcUaStatus.Good, DateTime.UtcNow, realm: AddressSpaceRealm.Uns));
}
finally
{
@@ -45,7 +45,7 @@ public sealed class NodeManagerSurgicalShapeUpdateTests : IDisposable
var nm = server.NodeManager!;
nm.EnsureVariable("eq-1/sp", parentFolderNodeId: null, displayName: "Sp", dataType: "Float", writable: false, realm: AddressSpaceRealm.Uns);
nm.WriteValue("eq-1/sp", 3.5f, OpcUaQuality.Good, DateTime.UtcNow, realm: AddressSpaceRealm.Uns);
nm.WriteValue("eq-1/sp", 3.5f, OpcUaStatus.Good, DateTime.UtcNow, realm: AddressSpaceRealm.Uns);
var node = nm.TryGetVariable("eq-1/sp")!;
node.DataType.ShouldBe(DataTypeIds.Float); // arrange guard
@@ -70,7 +70,7 @@ public sealed class NodeManagerSurgicalShapeUpdateTests : IDisposable
var nm = server.NodeManager!;
nm.EnsureVariable("eq-1/buf", parentFolderNodeId: null, displayName: "Buf", dataType: "Int16", writable: false, realm: AddressSpaceRealm.Uns);
nm.WriteValue("eq-1/buf", (short)42, OpcUaQuality.Good, DateTime.UtcNow, realm: AddressSpaceRealm.Uns);
nm.WriteValue("eq-1/buf", (short)42, OpcUaStatus.Good, DateTime.UtcNow, realm: AddressSpaceRealm.Uns);
var node = nm.TryGetVariable("eq-1/buf")!;
node.ValueRank.ShouldBe(ValueRanks.Scalar); // arrange guard
@@ -97,7 +97,7 @@ public sealed class NodeManagerSurgicalShapeUpdateTests : IDisposable
nm.EnsureVariable("eq-1/buf", parentFolderNodeId: null, displayName: "Buf", dataType: "Int16",
writable: false, historianTagname: null, isArray: true, arrayLength: 4u, realm: AddressSpaceRealm.Uns);
nm.WriteValue("eq-1/buf", new short[] { 1, 2, 3, 4 }, OpcUaQuality.Good, DateTime.UtcNow, realm: AddressSpaceRealm.Uns);
nm.WriteValue("eq-1/buf", new short[] { 1, 2, 3, 4 }, OpcUaStatus.Good, DateTime.UtcNow, realm: AddressSpaceRealm.Uns);
var node = nm.TryGetVariable("eq-1/buf")!;
node.ArrayDimensions![0].ShouldBe(4u); // arrange guard
@@ -122,7 +122,7 @@ public sealed class NodeManagerSurgicalShapeUpdateTests : IDisposable
nm.EnsureVariable("eq-1/buf", parentFolderNodeId: null, displayName: "Buf", dataType: "Int16",
writable: false, historianTagname: null, isArray: true, arrayLength: 4u, realm: AddressSpaceRealm.Uns);
nm.WriteValue("eq-1/buf", new short[] { 1, 2, 3, 4 }, OpcUaQuality.Good, DateTime.UtcNow, realm: AddressSpaceRealm.Uns);
nm.WriteValue("eq-1/buf", new short[] { 1, 2, 3, 4 }, OpcUaStatus.Good, DateTime.UtcNow, realm: AddressSpaceRealm.Uns);
var node = nm.TryGetVariable("eq-1/buf")!;
node.ValueRank.ShouldBe(ValueRanks.OneDimension); // arrange guard
@@ -172,7 +172,7 @@ public sealed class NodeManagerSurgicalShapeUpdateTests : IDisposable
var nm = server.NodeManager!;
nm.EnsureVariable("eq-1/sp", parentFolderNodeId: null, displayName: "Sp", dataType: "Float", writable: false, realm: AddressSpaceRealm.Uns);
nm.WriteValue("eq-1/sp", 7.0f, OpcUaQuality.Good, DateTime.UtcNow, realm: AddressSpaceRealm.Uns);
nm.WriteValue("eq-1/sp", 7.0f, OpcUaStatus.Good, DateTime.UtcNow, realm: AddressSpaceRealm.Uns);
var node = nm.TryGetVariable("eq-1/sp")!;
// Same DataType ("Float") + same scalar shape — only Writable flips false → true.
@@ -52,7 +52,7 @@ public sealed class NodeManagerWriteRevertTests : IDisposable
nm.EnsureVariable("eq-1/sp", parentFolderNodeId: null, displayName: "Sp", dataType: "Int32", writable: true, realm: AddressSpaceRealm.Uns);
// Prior state = Good 7; the SDK then optimistically applied 42 (the write the device will reject).
nm.WriteValue("eq-1/sp", 7, OpcUaQuality.Good, DateTime.UtcNow, realm: AddressSpaceRealm.Uns);
nm.WriteValue("eq-1/sp", 7, OpcUaStatus.Good, DateTime.UtcNow, realm: AddressSpaceRealm.Uns);
var node = nm.TryGetVariable("eq-1/sp")!;
node.Value = 42; // simulate the SDK's optimistic apply of the rejected write
@@ -79,7 +79,7 @@ public sealed class NodeManagerWriteRevertTests : IDisposable
var nm = server.NodeManager!;
nm.EnsureVariable("eq-1/sp", parentFolderNodeId: null, displayName: "Sp", dataType: "Int32", writable: true, realm: AddressSpaceRealm.Uns);
nm.WriteValue("eq-1/sp", 7, OpcUaQuality.Good, DateTime.UtcNow, realm: AddressSpaceRealm.Uns);
nm.WriteValue("eq-1/sp", 7, OpcUaStatus.Good, DateTime.UtcNow, realm: AddressSpaceRealm.Uns);
var node = nm.TryGetVariable("eq-1/sp")!;
node.Value = 42;
@@ -102,7 +102,7 @@ public sealed class NodeManagerWriteRevertTests : IDisposable
var nm = server.NodeManager!;
nm.EnsureVariable("eq-1/sp", parentFolderNodeId: null, displayName: "Sp", dataType: "Int32", writable: true, realm: AddressSpaceRealm.Uns);
nm.WriteValue("eq-1/sp", 7, OpcUaQuality.Good, DateTime.UtcNow, realm: AddressSpaceRealm.Uns);
nm.WriteValue("eq-1/sp", 7, OpcUaStatus.Good, DateTime.UtcNow, realm: AddressSpaceRealm.Uns);
var node = nm.TryGetVariable("eq-1/sp")!;
node.Value = 99; // a fresh poll already republished the confirmed register value (NOT the optimistic 42)
@@ -147,8 +147,8 @@ public sealed class NodeManagerWriteRevertTests : IDisposable
nm.EnsureVariable("A/B/C/D", parentFolderNodeId: null, displayName: "Raw", dataType: "Int32", writable: true, realm: AddressSpaceRealm.Raw);
nm.EnsureVariable("A/B/C/D", parentFolderNodeId: null, displayName: "Uns", dataType: "Int32", writable: true, realm: AddressSpaceRealm.Uns);
var now = DateTime.UtcNow;
nm.WriteValue("A/B/C/D", 10, OpcUaQuality.Good, now, AddressSpaceRealm.Raw); // raw driver value
nm.WriteValue("A/B/C/D", 10, OpcUaQuality.Good, now, AddressSpaceRealm.Uns); // fanned UNS value
nm.WriteValue("A/B/C/D", 10, OpcUaStatus.Good, now, AddressSpaceRealm.Raw); // raw driver value
nm.WriteValue("A/B/C/D", 10, OpcUaStatus.Good, now, AddressSpaceRealm.Uns); // fanned UNS value
var raw = nm.TryGetVariable("A/B/C/D", AddressSpaceRealm.Raw)!;
var uns = nm.TryGetVariable("A/B/C/D", AddressSpaceRealm.Uns)!;
@@ -178,8 +178,8 @@ public sealed class NodeManagerWriteRevertTests : IDisposable
nm.EnsureVariable("A/B/C/D", parentFolderNodeId: null, displayName: "Raw", dataType: "Int32", writable: true, realm: AddressSpaceRealm.Raw);
nm.EnsureVariable("A/B/C/D", parentFolderNodeId: null, displayName: "Uns", dataType: "Int32", writable: true, realm: AddressSpaceRealm.Uns);
var now = DateTime.UtcNow;
nm.WriteValue("A/B/C/D", 10, OpcUaQuality.Good, now, AddressSpaceRealm.Raw);
nm.WriteValue("A/B/C/D", 10, OpcUaQuality.Good, now, AddressSpaceRealm.Uns);
nm.WriteValue("A/B/C/D", 10, OpcUaStatus.Good, now, AddressSpaceRealm.Raw);
nm.WriteValue("A/B/C/D", 10, OpcUaStatus.Good, now, AddressSpaceRealm.Uns);
var raw = nm.TryGetVariable("A/B/C/D", AddressSpaceRealm.Raw)!;
var uns = nm.TryGetVariable("A/B/C/D", AddressSpaceRealm.Uns)!;
@@ -208,7 +208,7 @@ public sealed class NodeManagerWriteRevertTests : IDisposable
var nm = server.NodeManager!;
nm.EnsureVariable("eq-1/sp", parentFolderNodeId: null, displayName: "Sp", dataType: "Int32", writable: true, realm: AddressSpaceRealm.Uns);
nm.WriteValue("eq-1/sp", 7, OpcUaQuality.Good, DateTime.UtcNow, realm: AddressSpaceRealm.Uns);
nm.WriteValue("eq-1/sp", 7, OpcUaStatus.Good, DateTime.UtcNow, realm: AddressSpaceRealm.Uns);
var node = nm.TryGetVariable("eq-1/sp")!;
var audit = nm.BuildWriteFailureAuditEvent(
@@ -239,7 +239,7 @@ public sealed class NodeManagerWriteRevertTests : IDisposable
var nm = server.NodeManager!;
nm.EnsureVariable("eq-1/sp", parentFolderNodeId: null, displayName: "Sp", dataType: "Int32", writable: true, realm: AddressSpaceRealm.Uns);
nm.WriteValue("eq-1/sp", 7, OpcUaQuality.Good, DateTime.UtcNow, realm: AddressSpaceRealm.Uns);
nm.WriteValue("eq-1/sp", 7, OpcUaStatus.Good, DateTime.UtcNow, realm: AddressSpaceRealm.Uns);
var node = nm.TryGetVariable("eq-1/sp")!;
var audit = nm.BuildWriteFailureAuditEvent(
@@ -27,9 +27,9 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
var (host, server) = await BootAsync();
var sink = new SdkAddressSpaceSink(server.NodeManager!);
sink.WriteValue("eq-1/temp", 22.5, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
sink.WriteValue("eq-1/temp", 23.1, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
sink.WriteValue("eq-1/pressure", 100, OpcUaQuality.Uncertain, DateTime.UtcNow, AddressSpaceRealm.Uns);
sink.WriteValue("eq-1/temp", 22.5, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
sink.WriteValue("eq-1/temp", 23.1, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
sink.WriteValue("eq-1/pressure", 100, OpcUaStatus.Uncertain, DateTime.UtcNow, AddressSpaceRealm.Uns);
server.NodeManager!.VariableCount.ShouldBe(2);
@@ -45,7 +45,7 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
var sink = new SdkAddressSpaceSink(server.NodeManager!);
sink.WriteAlarmCondition("alarm-7", Snapshot(active: true, acknowledged: false), DateTime.UtcNow, AddressSpaceRealm.Uns);
sink.WriteValue("eq-1/temp", 22.5, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
sink.WriteValue("eq-1/temp", 22.5, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
server.NodeManager!.VariableCount.ShouldBe(2);
@@ -120,8 +120,8 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
var (host, server) = await BootAsync();
var sink = new SdkAddressSpaceSink(server.NodeManager!);
sink.WriteValue("a", 1, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
sink.WriteValue("b", 2, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
sink.WriteValue("a", 1, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
sink.WriteValue("b", 2, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
sink.WriteAlarmCondition("alarm-c", Snapshot(active: true), DateTime.UtcNow, AddressSpaceRealm.Uns);
server.NodeManager!.VariableCount.ShouldBe(3);
@@ -129,7 +129,7 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
server.NodeManager.VariableCount.ShouldBe(0);
// After rebuild, subsequent writes start fresh.
sink.WriteValue("a", 99, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
sink.WriteValue("a", 99, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
server.NodeManager.VariableCount.ShouldBe(1);
await host.DisposeAsync();
@@ -142,7 +142,7 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
// Sanity check that the F10 fallback still works — production callers default to
// NullOpcUaAddressSpaceSink when no SDK NodeManager is wired.
var sink = NullOpcUaAddressSpaceSink.Instance;
sink.WriteValue("x", 1, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
sink.WriteValue("x", 1, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
sink.WriteAlarmCondition("a", Snapshot(active: true), DateTime.UtcNow, AddressSpaceRealm.Uns);
sink.RebuildAddressSpace();
await Task.CompletedTask;
@@ -63,7 +63,7 @@ public sealed class CalculationDependencyFlowTests : RuntimeActorTestBase
driver.OnDataChange += (_, e) =>
{
published.Enqueue(e);
var quality = e.Snapshot.StatusCode == 0u ? OpcUaQuality.Good : OpcUaQuality.Bad;
var quality = e.Snapshot.StatusCode == 0u ? OpcUaStatus.Good : OpcUaStatus.Bad;
mux.Tell(new DriverInstanceActor.AttributeValuePublished(
"calc-drv", e.FullReference, e.Snapshot.Value, quality, Ts));
};
@@ -76,7 +76,7 @@ public sealed class CalculationDependencyFlowTests : RuntimeActorTestBase
AwaitAssert(() =>
{
mux.Tell(new DriverInstanceActor.AttributeValuePublished(
"src-drv", "src/a", 21.0, OpcUaQuality.Good, Ts));
"src-drv", "src/a", 21.0, OpcUaStatus.Good, Ts));
// Leg 1 — dep flow: src/a=21 → A computes 42 and publishes on calc/engine/out.
published.ShouldContain(e => e.FullReference == "calc/engine/out" && Equals(e.Snapshot.Value, 42.0));
@@ -117,7 +117,7 @@ public sealed class CalculationDependencyFlowTests : RuntimeActorTestBase
driver.OnDataChange += (_, e) =>
{
published.Enqueue(e);
var quality = e.Snapshot.StatusCode == 0u ? OpcUaQuality.Good : OpcUaQuality.Bad;
var quality = e.Snapshot.StatusCode == 0u ? OpcUaStatus.Good : OpcUaStatus.Bad;
mux.Tell(new DriverInstanceActor.AttributeValuePublished("calc-drv", e.FullReference, e.Snapshot.Value, quality, Ts));
};
var adapter = Sys.ActorOf(DependencyConsumerMuxAdapter.Props(driver, mux), "dep-adapter2");
@@ -125,7 +125,7 @@ public sealed class CalculationDependencyFlowTests : RuntimeActorTestBase
// Leg 1 — before redeploy: src/a=2 → keep=20, remove=7.
AwaitAssert(() =>
{
mux.Tell(new DriverInstanceActor.AttributeValuePublished("src-drv", "src/a", 2.0, OpcUaQuality.Good, Ts));
mux.Tell(new DriverInstanceActor.AttributeValuePublished("src-drv", "src/a", 2.0, OpcUaStatus.Good, Ts));
published.ShouldContain(e => e.FullReference == "calc/keep" && Equals(e.Snapshot.Value, 20.0));
published.ShouldContain(e => e.FullReference == "calc/remove" && Equals(e.Snapshot.Value, 7.0));
}, TimeSpan.FromSeconds(5), TimeSpan.FromMilliseconds(100));
@@ -149,8 +149,8 @@ public sealed class CalculationDependencyFlowTests : RuntimeActorTestBase
// new result; the REMOVED tag never publishes a value derived from the post-redeploy input.
AwaitAssert(() =>
{
mux.Tell(new DriverInstanceActor.AttributeValuePublished("src-drv", "src/b", 50.0, OpcUaQuality.Good, Ts));
mux.Tell(new DriverInstanceActor.AttributeValuePublished("src-drv", "src/a", 3.0, OpcUaQuality.Good, Ts));
mux.Tell(new DriverInstanceActor.AttributeValuePublished("src-drv", "src/b", 50.0, OpcUaStatus.Good, Ts));
mux.Tell(new DriverInstanceActor.AttributeValuePublished("src-drv", "src/a", 3.0, OpcUaStatus.Good, Ts));
// (a) new dep B flows through the RE-REGISTERED interest → new tag computes: 50 + 100 = 150.
published.ShouldContain(e => e.FullReference == "calc/add" && Equals(e.Snapshot.Value, 150.0));
@@ -54,7 +54,7 @@ public sealed class DriverHostActorLiveValueTests : RuntimeActorTestBase
// The driver publishes by its wire-ref RawPath.
actor.Tell(new DriverInstanceActor.AttributeValuePublished(
"drv-1", "Plant/Modbus/dev1/speed", 42.0, OpcUaQuality.Good, Ts));
"drv-1", "Plant/Modbus/dev1/speed", 42.0, OpcUaStatus.Good, Ts));
var a = publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>(TimeSpan.FromSeconds(5));
var b = publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>(TimeSpan.FromSeconds(5));
@@ -68,7 +68,7 @@ public sealed class DriverHostActorLiveValueTests : RuntimeActorTestBase
foreach (var u in byId.Values)
{
u.Value.ShouldBe(42.0);
u.Quality.ShouldBe(OpcUaQuality.Good);
u.StatusCode.ShouldBe(OpcUaStatus.Good);
u.TimestampUtc.ShouldBe(Ts);
}
@@ -89,7 +89,7 @@ public sealed class DriverHostActorLiveValueTests : RuntimeActorTestBase
var (actor, publish, _) = SpawnHostAndApply(db, deploymentId);
actor.Tell(new DriverInstanceActor.AttributeValuePublished(
"drv-1", "Plant/Modbus/dev1/speed", 5.0, OpcUaQuality.Good, Ts));
"drv-1", "Plant/Modbus/dev1/speed", 5.0, OpcUaStatus.Good, Ts));
var only = publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>(TimeSpan.FromSeconds(5));
only.NodeId.ShouldBe("Plant/Modbus/dev1/speed");
@@ -114,7 +114,7 @@ public sealed class DriverHostActorLiveValueTests : RuntimeActorTestBase
var (actor, publish, _) = SpawnHostAndApply(db, deploymentId);
actor.Tell(new DriverInstanceActor.AttributeValuePublished(
"drv-1", "Plant/Modbus/dev1/speed", 7.0, OpcUaQuality.Good, Ts));
"drv-1", "Plant/Modbus/dev1/speed", 7.0, OpcUaStatus.Good, Ts));
var updates = new[]
{
@@ -144,7 +144,7 @@ public sealed class DriverHostActorLiveValueTests : RuntimeActorTestBase
var (actor, publish, mux) = SpawnHostAndApply(db, deploymentId);
actor.Tell(new DriverInstanceActor.AttributeValuePublished(
"drv-1", "Plant/Modbus/dev1/nope", 99.0, OpcUaQuality.Good, Ts));
"drv-1", "Plant/Modbus/dev1/nope", 99.0, OpcUaStatus.Good, Ts));
publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
mux.ExpectMsg<DriverInstanceActor.AttributeValuePublished>(TimeSpan.FromSeconds(5))
@@ -283,18 +283,19 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
var (actor, publish) = SpawnHostAndApply(db, deploymentId);
// Comms lost → the raw condition is annotated Bad.
// Comms lost → the raw condition is annotated BadNoCommunication (#519: the reason survives to
// the client; this used to be a bare Bad indistinguishable from any other fault).
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);
bad.StatusCode.ShouldBe(OpcUaStatus.BadNoCommunication);
// 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);
good.StatusCode.ShouldBe(OpcUaStatus.Good);
}
/// <summary>#477 Layer 2 — connectivity for a DIFFERENT driver instance annotates none of this driver's
@@ -122,18 +122,29 @@ public sealed class DriverInstanceActorTests : RuntimeActorTestBase
TimeSpan.FromSeconds(3));
// Driver fires an OnDataChange — actor should forward it to its parent as
// AttributeValuePublished with Quality mapped from StatusCode.
// AttributeValuePublished carrying the driver's status code.
driver.FireDataChange("tag-a", value: 3.14, statusCode: 0u);
var published = parent.ExpectMsg<DriverInstanceActor.AttributeValuePublished>(TimeSpan.FromSeconds(2));
published.FullReference.ShouldBe("tag-a");
published.Value.ShouldBe(3.14);
published.Quality.ShouldBe(OpcUaQuality.Good);
published.StatusCode.ShouldBe(OpcUaStatus.Good);
}
/// <summary>Verifies that subscribe translates OPC UA status severity bits to OpcUaQuality.</summary>
/// <summary>
/// Gitea #519 — a driver's status code reaches the parent <b>with its sub-code intact</b>.
/// <para>This is the first of the two hops that used to destroy it: the actor projected the
/// driver's <c>uint</c> onto a 3-state Good/Uncertain/Bad enum, and the node manager then
/// re-expanded Bad to a bare <c>0x80000000</c>. The net effect was that no driver's sub-code ever
/// reached an OPC UA client — <c>BadNoCommunication</c>, <c>BadTypeMismatch</c> and
/// <c>BadNotSupported</c> were indistinguishable — while every driver maintained its own named
/// constants (16 of which #497 had just corrected) as if they mattered end to end.</para>
/// <para>The severity buckets are asserted too, since the bucket is still what
/// <see cref="OpcUaStatus.IsGood"/> and friends classify on; what changed is that the bucket is no
/// longer <em>all</em> that survives.</para>
/// </summary>
[Fact]
public async Task Subscribe_translates_OPC_UA_status_severity_bits_to_OpcUaQuality()
public async Task Subscribe_forwards_the_drivers_status_sub_code_without_collapsing_it()
{
var driver = new SubscribableStubDriver();
var parent = CreateTestProbe();
@@ -151,11 +162,25 @@ public sealed class DriverInstanceActorTests : RuntimeActorTestBase
// Uncertain — severity bits 01 (top 2 bits = 01).
driver.FireDataChange("tag-1", value: 1, statusCode: 0x40000000u);
parent.ExpectMsg<DriverInstanceActor.AttributeValuePublished>().Quality.ShouldBe(OpcUaQuality.Uncertain);
parent.ExpectMsg<DriverInstanceActor.AttributeValuePublished>().StatusCode.ShouldBe(OpcUaStatus.Uncertain);
// Bad — severity bits 10.
driver.FireDataChange("tag-1", value: 2, statusCode: 0x80000000u);
parent.ExpectMsg<DriverInstanceActor.AttributeValuePublished>().Quality.ShouldBe(OpcUaQuality.Bad);
parent.ExpectMsg<DriverInstanceActor.AttributeValuePublished>().StatusCode.ShouldBe(OpcUaStatus.Bad);
// The regression itself: a Bad SUB-code must arrive intact, not flattened to OpcUaStatus.Bad.
// This is the exact code MTConnect maps the Agent's UNAVAILABLE sentinel to, and the live gate
// that opened #519 observed it reaching a client as 0x80000000.
driver.FireDataChange("tag-1", value: null, statusCode: OpcUaStatus.BadNoCommunication);
var noComms = parent.ExpectMsg<DriverInstanceActor.AttributeValuePublished>();
noComms.StatusCode.ShouldBe(OpcUaStatus.BadNoCommunication);
noComms.StatusCode.ShouldNotBe(OpcUaStatus.Bad, "the sub-code was collapsed to a generic Bad");
OpcUaStatus.IsBad(noComms.StatusCode).ShouldBeTrue("it must still classify as Bad");
// A second, DIFFERENT Bad sub-code — the two were indistinguishable before the fix, so asserting
// one alone would not prove the sub-code is carried rather than hard-coded.
driver.FireDataChange("tag-1", value: null, statusCode: 0x80740000u /* BadTypeMismatch */);
parent.ExpectMsg<DriverInstanceActor.AttributeValuePublished>().StatusCode.ShouldBe(0x80740000u);
}
/// <summary>
@@ -163,7 +163,7 @@ public class NativeAlarmProjectorTests
var snap = sut.Project("n1", Evt(AlarmTransitionKind.Raise));
snap.Quality.ShouldBe(OpcUaQuality.Good);
snap.StatusCode.ShouldBe(OpcUaStatus.Good);
}
private static AlarmEventArgs Evt(
@@ -50,7 +50,7 @@ public sealed class OtOpcUaTelemetryHookTests : RuntimeActorTestBase
actor.Tell(new OpcUaPublishActor.AttributeValueUpdate(
NodeId: "ns=2;s=tag-1",
Value: 42,
Quality: OpcUaQuality.Good,
StatusCode: OpcUaStatus.Good,
TimestampUtc: DateTime.UtcNow, Realm: AddressSpaceRealm.Uns));
AwaitAssertion(() =>
@@ -194,12 +194,12 @@ public sealed class OtOpcUaTelemetryHookTests : RuntimeActorTestBase
/// <param name="value">The value being written.</param>
/// <param name="quality">The OPC UA quality status.</param>
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => Writes++;
public void WriteValue(string nodeId, object? value, uint statusCode, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => Writes++;
/// <summary>Records an alarm condition write.</summary>
/// <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 WriteAlarmQuality(string alarmNodeId, uint statusCode, 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>
@@ -123,8 +123,8 @@ public sealed class OpcUaPublishActorApplyFailureTests : RuntimeActorTestBase
/// <summary>A sink whose RebuildAddressSpace throws (drives the applier's SafeRebuild catch → RebuildFailed).</summary>
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 WriteValue(string nodeId, object? value, uint statusCode, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void WriteAlarmQuality(string alarmNodeId, uint statusCode, 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) { }
@@ -138,8 +138,8 @@ public sealed class OpcUaPublishActorApplyFailureTests : RuntimeActorTestBase
/// <summary>A no-op sink — a clean apply (no failures).</summary>
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 WriteValue(string nodeId, object? value, uint statusCode, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void WriteAlarmQuality(string alarmNodeId, uint statusCode, 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) { }
@@ -622,13 +622,13 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
/// <param name="value">The value to write.</param>
/// <param name="quality">The OPC UA quality code.</param>
/// <param name="ts">The timestamp of the write.</param>
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime ts, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
public void WriteValue(string nodeId, object? value, uint statusCode, DateTime ts, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
=> Calls.Enqueue($"WV:{nodeId}");
/// <summary>Records an alarm condition write call.</summary>
/// <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 WriteAlarmQuality(string alarmNodeId, uint statusCode, 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>
@@ -20,7 +20,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
public void Accepts_message_contracts_without_pinned_dispatcher_in_tests()
{
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests());
actor.Tell(new OpcUaPublishActor.AttributeValueUpdate("ns=2;s=Tag1", 42.0, OpcUaQuality.Good, DateTime.UtcNow, Realm: AddressSpaceRealm.Uns));
actor.Tell(new OpcUaPublishActor.AttributeValueUpdate("ns=2;s=Tag1", 42.0, OpcUaStatus.Good, DateTime.UtcNow, Realm: AddressSpaceRealm.Uns));
actor.Tell(new OpcUaPublishActor.AlarmStateUpdate("ns=2;s=Alarm1", Snapshot(active: true), DateTime.UtcNow, Realm: AddressSpaceRealm.Uns));
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId()));
actor.Tell(new OpcUaPublishActor.ServiceLevelChanged(240));
@@ -43,16 +43,16 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
var sink = new RecordingSink();
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink));
actor.Tell(new OpcUaPublishActor.AttributeValueUpdate("ns=2;s=T1", 3.14, OpcUaQuality.Good, DateTime.UtcNow, Realm: AddressSpaceRealm.Uns));
actor.Tell(new OpcUaPublishActor.AttributeValueUpdate("ns=2;s=T2", "abc", OpcUaQuality.Uncertain, DateTime.UtcNow, Realm: AddressSpaceRealm.Uns));
actor.Tell(new OpcUaPublishActor.AttributeValueUpdate("ns=2;s=T1", 3.14, OpcUaStatus.Good, DateTime.UtcNow, Realm: AddressSpaceRealm.Uns));
actor.Tell(new OpcUaPublishActor.AttributeValueUpdate("ns=2;s=T2", "abc", OpcUaStatus.Uncertain, DateTime.UtcNow, Realm: AddressSpaceRealm.Uns));
AwaitAssert(() =>
{
sink.Values.Count.ShouldBe(2);
sink.Values[0].NodeId.ShouldBe("ns=2;s=T1");
sink.Values[0].Value.ShouldBe(3.14);
sink.Values[0].Quality.ShouldBe(OpcUaQuality.Good);
sink.Values[1].Quality.ShouldBe(OpcUaQuality.Uncertain);
sink.Values[0].StatusCode.ShouldBe(OpcUaStatus.Good);
sink.Values[1].StatusCode.ShouldBe(OpcUaStatus.Uncertain);
}, duration: PresenceBudget);
}
@@ -84,14 +84,14 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
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));
"Plant/Modbus/dev1/temp_hi", OpcUaStatus.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.StatusCode.ShouldBe(OpcUaStatus.Bad);
q.Realm.ShouldBe(AddressSpaceRealm.Raw);
}, duration: PresenceBudget);
}
@@ -702,11 +702,11 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
private sealed class RecordingSink : IOpcUaAddressSpaceSink
{
/// <summary>Gets the queue of recorded value updates.</summary>
public ConcurrentQueue<(string NodeId, object? Value, OpcUaQuality Quality, DateTime Ts)> ValueQueue { get; } = new();
public ConcurrentQueue<(string NodeId, object? Value, uint StatusCode, 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();
public ConcurrentQueue<(string AlarmNodeId, uint StatusCode, 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>
@@ -717,7 +717,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
public ConcurrentQueue<string> ModelChangeQueue { get; } = new();
/// <summary>Gets the list of recorded value updates.</summary>
public List<(string NodeId, object? Value, OpcUaQuality Quality, DateTime Ts)> Values =>
public List<(string NodeId, object? Value, uint StatusCode, DateTime Ts)> Values =>
ValueQueue.ToList();
/// <summary>Gets the list of recorded alarm condition updates.</summary>
public List<(string AlarmNodeId, AlarmConditionSnapshot State, DateTime Ts)> Alarms =>
@@ -734,10 +734,10 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
/// <summary>Records a value update.</summary>
/// <param name="nodeId">The OPC UA node identifier.</param>
/// <param name="value">The attribute value.</param>
/// <param name="quality">The OPC UA quality code.</param>
/// <param name="statusCode">The OPC UA status code, sub-code included.</param>
/// <param name="ts">The timestamp of the update.</param>
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime ts, AddressSpaceRealm realm = AddressSpaceRealm.Uns) =>
ValueQueue.Enqueue((nodeId, value, quality, ts));
public void WriteValue(string nodeId, object? value, uint statusCode, DateTime ts, AddressSpaceRealm realm = AddressSpaceRealm.Uns) =>
ValueQueue.Enqueue((nodeId, value, statusCode, ts));
/// <summary>Records an alarm condition update.</summary>
/// <param name="alarmNodeId">The OPC UA alarm node identifier.</param>
@@ -747,8 +747,8 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
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));
public void WriteAlarmQuality(string alarmNodeId, uint statusCode, DateTime ts, AddressSpaceRealm realm = AddressSpaceRealm.Uns) =>
AlarmQualityQueue.Enqueue((alarmNodeId, statusCode, ts, realm));
/// <summary>Materialises an alarm condition (no-op in test).</summary>
/// <param name="alarmNodeId">The alarm node ID.</param>
@@ -700,9 +700,9 @@ public sealed class ScriptedAlarmHostActorTests : RuntimeActorTestBase
// Baseline is Good (unread inputs are not a quality signal — no load-time annotation). Drive the
// input Bad with a value below threshold: the predicate freezes (no transition), but the worst-input
// quality bucket moves Good→Bad → a QualityChanged annotation flows out of band.
host.Tell(new VirtualTagActor.DependencyValueChanged("M.T", 10, DateTime.UtcNow, OpcUaQuality.Bad));
host.Tell(new VirtualTagActor.DependencyValueChanged("M.T", 10, DateTime.UtcNow, OpcUaStatus.Bad));
var q = publish.FishForMessage<OpcUaPublishActor.AlarmQualityUpdate>(m => m.Quality == OpcUaQuality.Bad, Timeout);
var q = publish.FishForMessage<OpcUaPublishActor.AlarmQualityUpdate>(m => m.StatusCode == OpcUaStatus.Bad, Timeout);
q.AlarmNodeId.ShouldBe("alm-1");
q.Realm.ShouldBe(AddressSpaceRealm.Uns);
@@ -726,11 +726,11 @@ public sealed class ScriptedAlarmHostActorTests : RuntimeActorTestBase
mux.ExpectMsg<DependencyMuxActor.RegisterInterest>(Timeout); // load completed
// Above threshold (activates) but Uncertain quality — Uncertain is "ready", so the predicate runs.
host.Tell(new VirtualTagActor.DependencyValueChanged("M.T", 99, DateTime.UtcNow, OpcUaQuality.Uncertain));
host.Tell(new VirtualTagActor.DependencyValueChanged("M.T", 99, DateTime.UtcNow, OpcUaStatus.Uncertain));
var state = publish.FishForMessage<OpcUaPublishActor.AlarmStateUpdate>(m => m.State.Active, Timeout);
state.AlarmNodeId.ShouldBe("alm-1");
state.State.Quality.ShouldBe(OpcUaQuality.Uncertain);
state.State.StatusCode.ShouldBe(OpcUaStatus.Uncertain);
}
/// <summary>OneShotShelve transition carries the operator's identity: an operator-driven OneShotShelve
@@ -21,9 +21,9 @@ public sealed class DependencyMuxActorTests : RuntimeActorTestBase
mux.Tell(new DependencyMuxActor.RegisterInterest(new[] { "tag-1", "tag-2" }, subA.Ref));
mux.Tell(new DependencyMuxActor.RegisterInterest(new[] { "tag-2", "tag-3" }, subB.Ref));
mux.Tell(new DriverInstanceActor.AttributeValuePublished("driver-1", "tag-1", 10, OpcUaQuality.Good, DateTime.UtcNow));
mux.Tell(new DriverInstanceActor.AttributeValuePublished("driver-1", "tag-3", 30, OpcUaQuality.Good, DateTime.UtcNow));
mux.Tell(new DriverInstanceActor.AttributeValuePublished("driver-1", "tag-2", 20, OpcUaQuality.Good, DateTime.UtcNow));
mux.Tell(new DriverInstanceActor.AttributeValuePublished("driver-1", "tag-1", 10, OpcUaStatus.Good, DateTime.UtcNow));
mux.Tell(new DriverInstanceActor.AttributeValuePublished("driver-1", "tag-3", 30, OpcUaStatus.Good, DateTime.UtcNow));
mux.Tell(new DriverInstanceActor.AttributeValuePublished("driver-1", "tag-2", 20, OpcUaStatus.Good, DateTime.UtcNow));
// subA hears tag-1 + tag-2.
var aMsgs = new[]
@@ -53,7 +53,7 @@ public sealed class DependencyMuxActorTests : RuntimeActorTestBase
var sub = CreateTestProbe();
mux.Tell(new DependencyMuxActor.RegisterInterest(new[] { "tag-1" }, sub.Ref));
mux.Tell(new DriverInstanceActor.AttributeValuePublished("driver-1", "nobody-cares", 99, OpcUaQuality.Good, DateTime.UtcNow));
mux.Tell(new DriverInstanceActor.AttributeValuePublished("driver-1", "nobody-cares", 99, OpcUaStatus.Good, DateTime.UtcNow));
sub.ExpectNoMsg(TimeSpan.FromMilliseconds(200));
}
@@ -66,11 +66,11 @@ public sealed class DependencyMuxActorTests : RuntimeActorTestBase
var sub = CreateTestProbe();
mux.Tell(new DependencyMuxActor.RegisterInterest(new[] { "tag-1" }, sub.Ref));
mux.Tell(new DriverInstanceActor.AttributeValuePublished("driver-1", "tag-1", 10, OpcUaQuality.Good, DateTime.UtcNow));
mux.Tell(new DriverInstanceActor.AttributeValuePublished("driver-1", "tag-1", 10, OpcUaStatus.Good, DateTime.UtcNow));
sub.ExpectMsg<VirtualTagActor.DependencyValueChanged>();
mux.Tell(new DependencyMuxActor.UnregisterInterest(sub.Ref));
mux.Tell(new DriverInstanceActor.AttributeValuePublished("driver-1", "tag-1", 20, OpcUaQuality.Good, DateTime.UtcNow));
mux.Tell(new DriverInstanceActor.AttributeValuePublished("driver-1", "tag-1", 20, OpcUaStatus.Good, DateTime.UtcNow));
sub.ExpectNoMsg(TimeSpan.FromMilliseconds(200));
}
@@ -84,10 +84,10 @@ public sealed class DependencyMuxActorTests : RuntimeActorTestBase
mux.Tell(new DependencyMuxActor.RegisterInterest(new[] { "tag-1" }, sub.Ref));
mux.Tell(new DependencyMuxActor.RegisterInterest(new[] { "tag-2" }, sub.Ref)); // replaces tag-1
mux.Tell(new DriverInstanceActor.AttributeValuePublished("driver-1", "tag-1", 10, OpcUaQuality.Good, DateTime.UtcNow));
mux.Tell(new DriverInstanceActor.AttributeValuePublished("driver-1", "tag-1", 10, OpcUaStatus.Good, DateTime.UtcNow));
sub.ExpectNoMsg(TimeSpan.FromMilliseconds(200));
mux.Tell(new DriverInstanceActor.AttributeValuePublished("driver-1", "tag-2", 20, OpcUaQuality.Good, DateTime.UtcNow));
mux.Tell(new DriverInstanceActor.AttributeValuePublished("driver-1", "tag-2", 20, OpcUaStatus.Good, DateTime.UtcNow));
sub.ExpectMsg<VirtualTagActor.DependencyValueChanged>().TagId.ShouldBe("tag-2");
}
@@ -110,17 +110,17 @@ public sealed class DependencyMuxActorTests : RuntimeActorTestBase
AwaitAssert(() =>
{
mux.Tell(new DriverInstanceActor.AttributeValuePublished(
"driver-1", "ref-a", 10, OpcUaQuality.Good, DateTime.UtcNow));
"driver-1", "ref-a", 10, OpcUaStatus.Good, DateTime.UtcNow));
parent.ExpectMsg<VirtualTagActor.EvaluationResult>(TimeSpan.FromMilliseconds(200))
.Value.ShouldBe(10);
}, duration: TimeSpan.FromSeconds(3));
// From here the actor is wired — second publish drives the sum.
mux.Tell(new DriverInstanceActor.AttributeValuePublished("driver-1", "ref-b", 32, OpcUaQuality.Good, DateTime.UtcNow));
mux.Tell(new DriverInstanceActor.AttributeValuePublished("driver-1", "ref-b", 32, OpcUaStatus.Good, DateTime.UtcNow));
parent.ExpectMsg<VirtualTagActor.EvaluationResult>(TimeSpan.FromSeconds(2)).Value.ShouldBe(42);
// Unrelated ref shouldn't fire eval.
mux.Tell(new DriverInstanceActor.AttributeValuePublished("driver-1", "ref-unrelated", 99, OpcUaQuality.Good, DateTime.UtcNow));
mux.Tell(new DriverInstanceActor.AttributeValuePublished("driver-1", "ref-unrelated", 99, OpcUaStatus.Good, DateTime.UtcNow));
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(200));
}
@@ -144,7 +144,7 @@ public sealed class DependencyMuxActorTests : RuntimeActorTestBase
// Tell the host an AttributeValuePublished — it should fan out to the mux + subscriber.
hostActor.Tell(new DriverInstanceActor.AttributeValuePublished(
"driver-1", "ref-1", 42, OpcUaQuality.Good, DateTime.UtcNow));
"driver-1", "ref-1", 42, OpcUaStatus.Good, DateTime.UtcNow));
subscriber.ExpectMsg<VirtualTagActor.DependencyValueChanged>().TagId.ShouldBe("ref-1");
}
@@ -159,9 +159,9 @@ public sealed class DependencyMuxActorTests : RuntimeActorTestBase
mux.Tell(new DependencyMuxActor.RegisterInterest(new[] { "tag-1" }, sub.Ref));
mux.Tell(new DriverInstanceActor.AttributeValuePublished(
"driver-1", "tag-1", 10, OpcUaQuality.Bad, DateTime.UtcNow));
"driver-1", "tag-1", 10, OpcUaStatus.Bad, DateTime.UtcNow));
sub.ExpectMsg<VirtualTagActor.DependencyValueChanged>().Quality.ShouldBe(OpcUaQuality.Bad);
sub.ExpectMsg<VirtualTagActor.DependencyValueChanged>().StatusCode.ShouldBe(OpcUaStatus.Bad);
}
private sealed class EchoSumEvaluator : ZB.MOM.WW.OtOpcUa.Commons.Engines.IVirtualTagEvaluator
@@ -84,7 +84,7 @@ public sealed class VirtualTagActorTests : RuntimeActorTestBase
// 02/S13: the failure now ALSO degrades the node — with no declared dependencyRefs the
// inputs-ready gate is vacuously satisfied, so a Bad EvaluationResult reaches the parent (this
// replaces the old ExpectNoMsg, which encoded the stale-Good bug).
parent.ExpectMsg<VirtualTagActor.EvaluationResult>().Quality.ShouldBe(OpcUaQuality.Bad);
parent.ExpectMsg<VirtualTagActor.EvaluationResult>().StatusCode.ShouldBe(OpcUaStatus.BadInternalError);
}
/// <summary>02/S13 once-per-transition: two consecutive failures publish exactly ONE Bad
@@ -98,7 +98,7 @@ public sealed class VirtualTagActorTests : RuntimeActorTestBase
var actor = parent.ChildActorOf(VirtualTagActor.Props("vt-1", "expr", evaluator: evaluator));
actor.Tell(new VirtualTagActor.DependencyValueChanged("a", 1, DateTime.UtcNow));
parent.ExpectMsg<VirtualTagActor.EvaluationResult>().Quality.ShouldBe(OpcUaQuality.Bad);
parent.ExpectMsg<VirtualTagActor.EvaluationResult>().StatusCode.ShouldBe(OpcUaStatus.BadInternalError);
actor.Tell(new VirtualTagActor.DependencyValueChanged("a", 2, DateTime.UtcNow));
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
@@ -116,15 +116,15 @@ public sealed class VirtualTagActorTests : RuntimeActorTestBase
actor.Tell(new VirtualTagActor.DependencyValueChanged("a", 1, DateTime.UtcNow));
var first = parent.ExpectMsg<VirtualTagActor.EvaluationResult>();
first.Quality.ShouldBe(OpcUaQuality.Good);
first.StatusCode.ShouldBe(OpcUaStatus.Good);
first.Value.ShouldBe(42);
actor.Tell(new VirtualTagActor.DependencyValueChanged("a", 2, DateTime.UtcNow));
parent.ExpectMsg<VirtualTagActor.EvaluationResult>().Quality.ShouldBe(OpcUaQuality.Bad);
parent.ExpectMsg<VirtualTagActor.EvaluationResult>().StatusCode.ShouldBe(OpcUaStatus.BadInternalError);
actor.Tell(new VirtualTagActor.DependencyValueChanged("a", 3, DateTime.UtcNow));
var recovered = parent.ExpectMsg<VirtualTagActor.EvaluationResult>();
recovered.Quality.ShouldBe(OpcUaQuality.Good);
recovered.StatusCode.ShouldBe(OpcUaStatus.Good);
recovered.Value.ShouldBe(42);
}
@@ -139,7 +139,7 @@ public sealed class VirtualTagActorTests : RuntimeActorTestBase
actor.Tell(new VirtualTagActor.DependencyValueChanged("a", 1, DateTime.UtcNow));
parent.ExpectMsg<VirtualTagActor.EvaluationResult>().Quality.ShouldBe(OpcUaQuality.Bad);
parent.ExpectMsg<VirtualTagActor.EvaluationResult>().StatusCode.ShouldBe(OpcUaStatus.BadInternalError);
}
/// <summary>02/S13 inputs-ready gate: a multi-dep script that fails before all its declared
@@ -177,7 +177,7 @@ public sealed class VirtualTagActorTests : RuntimeActorTestBase
// "b" arrives — all refs now seen, so the failure degrades to Bad.
actor.Tell(new VirtualTagActor.DependencyValueChanged("b", 2, DateTime.UtcNow));
parent.ExpectMsg<VirtualTagActor.EvaluationResult>().Quality.ShouldBe(OpcUaQuality.Bad);
parent.ExpectMsg<VirtualTagActor.EvaluationResult>().StatusCode.ShouldBe(OpcUaStatus.BadInternalError);
}
/// <summary>Test evaluator returning a fixed sequence of results, one per Evaluate call; the last
@@ -67,7 +67,7 @@ public sealed class VirtualTagHostActorTests : RuntimeActorTestBase
var update = publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>();
update.NodeId.ShouldBe("eq-1/speed-rpm");
update.Value.ShouldBe(42.0);
update.Quality.ShouldBe(OpcUaQuality.Good);
update.StatusCode.ShouldBe(OpcUaStatus.Good);
update.TimestampUtc.ShouldBe(ts);
}
@@ -433,19 +433,21 @@ public sealed class VirtualTagHostActorTests : RuntimeActorTestBase
// First dep change → evaluator Ok(42) → Good publish.
child.Tell(new VirtualTagActor.DependencyValueChanged("a", 1, ts1));
var good = publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>();
good.Quality.ShouldBe(OpcUaQuality.Good);
good.StatusCode.ShouldBe(OpcUaStatus.Good);
good.Value.ShouldBe(42.0);
// Second dep change → evaluator Failure → Bad publish carrying the last-known value 42.
// #519: the child names the reason (BadInternalError) instead of a bare Bad, and the host bridges
// it verbatim — so this asserts the sub-code a client would actually read.
child.Tell(new VirtualTagActor.DependencyValueChanged("a", 2, ts2));
var bad = publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>();
bad.Quality.ShouldBe(OpcUaQuality.Bad);
bad.StatusCode.ShouldBe(OpcUaStatus.BadInternalError);
bad.Value.ShouldBe(42.0);
bad.TimestampUtc.ShouldBe(ts2);
}
/// <summary>02/S13 bridge: a Bad-quality <see cref="VirtualTagActor.EvaluationResult"/> must bridge to
/// an <see cref="OpcUaPublishActor.AttributeValueUpdate"/> carrying <see cref="OpcUaQuality.Bad"/>
/// an <see cref="OpcUaPublishActor.AttributeValueUpdate"/> carrying <see cref="OpcUaStatus.Bad"/>
/// (not the old hard-coded Good).</summary>
[Fact]
public void Bad_quality_EvaluationResult_is_bridged_as_Bad()
@@ -457,15 +459,18 @@ public sealed class VirtualTagHostActorTests : RuntimeActorTestBase
var ts = new DateTime(2026, 7, 12, 12, 0, 0, DateTimeKind.Utc);
host.Tell(new VirtualTagActor.EvaluationResult(
"vt-1", 42.0, ts, CorrelationId.NewId(), OpcUaQuality.Bad));
"vt-1", 42.0, ts, CorrelationId.NewId(), OpcUaStatus.Bad));
var update = publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>();
update.Quality.ShouldBe(OpcUaQuality.Bad);
update.StatusCode.ShouldBe(OpcUaStatus.Bad);
update.Value.ShouldBe(42.0);
}
/// <summary>02/S13 historize parity: a Bad result on a historized plan must record the dormant
/// engine's BadInternalError status (0x80020000u) in the snapshot, not the Good 0u.</summary>
/// <summary>02/S13 historize parity: a failed result on a historized plan must record BadInternalError
/// (0x80020000u) in the snapshot, not the Good 0u.
/// <para>#519 — the host no longer DERIVES this from a Bad bucket; it records the result's status code
/// verbatim, and <see cref="VirtualTagActor"/> is what stamps BadInternalError. So this constructs the
/// result the real child emits, and the assertion now proves pass-through rather than re-derivation.</para></summary>
[Fact]
public void Historized_Bad_result_is_recorded_with_BadInternalError_status()
{
@@ -478,12 +483,12 @@ public sealed class VirtualTagHostActorTests : RuntimeActorTestBase
var ts = new DateTime(2026, 7, 12, 12, 0, 0, DateTimeKind.Utc);
host.Tell(new VirtualTagActor.EvaluationResult(
"vt-1", 42.0, ts, CorrelationId.NewId(), OpcUaQuality.Bad));
"vt-1", 42.0, ts, CorrelationId.NewId(), OpcUaStatus.BadInternalError));
publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>();
AwaitAssert(() => writer.Calls.Count.ShouldBe(1));
writer.Calls.TryPeek(out var captured).ShouldBeTrue();
captured.Value.StatusCode.ShouldBe(0x80020000u); // BadInternalError — dormant-engine parity
captured.Value.StatusCode.ShouldBe(OpcUaStatus.BadInternalError);
}
/// <summary>
@@ -514,7 +519,7 @@ public sealed class VirtualTagHostActorTests : RuntimeActorTestBase
child.Tell(new VirtualTagActor.DependencyValueChanged("a", 1, DateTime.UtcNow));
var first = publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>();
first.Value.ShouldBe(7.0);
first.Quality.ShouldBe(OpcUaQuality.Good);
first.StatusCode.ShouldBe(OpcUaStatus.Good);
// The redeploy re-materialises the node to BadWaitingForInitialData and re-applies the UNCHANGED
// plan — the child survives with its dedup state. The fix re-asserts the last value; without it the
@@ -523,7 +528,7 @@ public sealed class VirtualTagHostActorTests : RuntimeActorTestBase
var reasserted = publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>();
reasserted.NodeId.ShouldBe("eq-1/speed-rpm");
reasserted.Value.ShouldBe(7.0);
reasserted.Quality.ShouldBe(OpcUaQuality.Good);
reasserted.StatusCode.ShouldBe(OpcUaStatus.Good);
// No child churn on the unchanged redeploy — the re-assert must not respawn or re-register.
mux.ExpectNoMsg(TimeSpan.FromMilliseconds(200));