diff --git a/deferment.md b/deferment.md index ee539e06..dd01aa57 100644 --- a/deferment.md +++ b/deferment.md @@ -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. diff --git a/docs/DriverLifecycle.md b/docs/DriverLifecycle.md index fbbf8c27..16e9c496 100644 --- a/docs/DriverLifecycle.md +++ b/docs/DriverLifecycle.md @@ -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) diff --git a/docs/ReadWriteOperations.md b/docs/ReadWriteOperations.md index 2aea0c28..6c6cc5d4 100644 --- a/docs/ReadWriteOperations.md +++ b/docs/ReadWriteOperations.md @@ -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 diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/AlarmConditionSnapshot.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/AlarmConditionSnapshot.cs index 8069f34c..5fd1b276 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/AlarmConditionSnapshot.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/AlarmConditionSnapshot.cs @@ -16,11 +16,13 @@ namespace ZB.MOM.WW.OtOpcUa.Commons.OpcUa; /// The shelving mode (ShelvingState): unshelved, one-shot, or timed. /// OPC UA severity on the 1..1000 scale (the SDK SetSeverity input). /// The human-readable condition message (LocalizedText payload). -/// -/// Quality of the condition's source data (Part 9 ConditionType.Quality). Carried so a +/// +/// Status code of the condition's source data (Part 9 ConditionType.Quality). Carried so a /// comms-lost native source can report a non-Good condition instead of the accidentally-Good /// default (issue #477). It is a pure annotation — it never alters Active/Acked/Retain. Defaults to -/// so scripted-alarm callers (which stay Good in v1) need not supply it. +/// 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 +/// rather than a bare . /// 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); /// /// Commons-local mirror of the Core ShelvingKind enum so this assembly carries no diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/DeferredAddressSpaceSink.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/DeferredAddressSpaceSink.cs index bd1b0f3c..b0fd6325 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/DeferredAddressSpaceSink.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/DeferredAddressSpaceSink.cs @@ -23,15 +23,15 @@ public sealed class DeferredAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgical _inner = sink ?? NullOpcUaAddressSpaceSink.Instance; /// - 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); /// 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); /// public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/IOpcUaAddressSpaceSink.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/IOpcUaAddressSpaceSink.cs index 7a611a4f..78d2f720 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/IOpcUaAddressSpaceSink.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/IOpcUaAddressSpaceSink.cs @@ -8,16 +8,19 @@ namespace ZB.MOM.WW.OtOpcUa.Commons.OpcUa; /// public interface IOpcUaAddressSpaceSink { - /// Write a Variable node's current value + quality + source timestamp. + /// Write a Variable node's current value + status code + source timestamp. /// The OPC UA node ID of the variable. /// The value to write. - /// The quality status of the value. + /// The full OPC UA status code, sub-code included. Passed to the SDK verbatim + /// (#519): a driver's reaches the client as + /// BadNoCommunication, not as a generic . Use + /// ' named constants rather than a literal. /// The source timestamp in UTC. /// Which of the two v3 namespaces ( device tree or /// equipment tree) the 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. - void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm); + void WriteValue(string nodeId, object? value, uint statusCode, DateTime sourceTimestampUtc, AddressSpaceRealm realm); /// Write an alarm-condition's full Part 9 state. When a real condition node has been /// materialised for via , @@ -34,15 +37,17 @@ public interface IOpcUaAddressSpaceSink void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm); /// #477 — annotate a materialised condition's source-data quality OUT OF BAND from any alarm - /// transition (used by the driver-connectivity path: comms lost → , - /// restored → ). Sets ONLY the condition's Quality — never + /// transition (used by the driver-connectivity path: comms lost → , + /// restored → ). Sets ONLY the condition's Quality — never /// Active/Acked/Severity/Retain (a comms-lost active alarm stays active) — and fires one Part 9 event - /// only on a quality-bucket change. A no-op for an unmaterialised / non-condition node. + /// 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. /// The condition node id (RawPath for a native alarm). - /// The source-data quality to annotate. + /// The source-data status code to annotate, sub-code included. /// The connectivity transition timestamp in UTC. /// The namespace realm the condition was materialised under. - void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm); + void WriteAlarmQuality(string alarmNodeId, uint statusCode, DateTime sourceTimestampUtc, AddressSpaceRealm realm); /// /// 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"); } -/// OPC UA status code projection — Good / Uncertain / Bad. Real SDK has finer-grained -/// codes; the engine actors only need this 3-state classification. -public enum OpcUaQuality { Good, Uncertain, Bad } - /// No-op sink. Bound by default so the actors are safe to run in dev / Mac / /// integration tests without a real SDK behind them. public sealed class NullOpcUaAddressSpaceSink : IOpcUaAddressSpaceSink @@ -172,11 +173,11 @@ public sealed class NullOpcUaAddressSpaceSink : IOpcUaAddressSpaceSink private NullOpcUaAddressSpaceSink() { } /// - 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) { } /// 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) { } /// public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { } diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/OpcUaStatus.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/OpcUaStatus.cs new file mode 100644 index 00000000..f2797331 --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/OpcUaStatus.cs @@ -0,0 +1,68 @@ +namespace ZB.MOM.WW.OtOpcUa.Commons.OpcUa; + +/// +/// The handful of OPC UA status codes the engine layer needs to spell, plus the Part 4 severity +/// classification helpers that replaced the retired OpcUaQuality 3-state enum (Gitea #519). +/// +/// Why these are bare uint literals. Commons and Runtime deliberately do not +/// reference the OPC UA SDK — exists precisely so the Runtime +/// project can publish values without dragging in Opc.Ua.Server. 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. StatusCodeParityTests closes it by reflection, and it can +/// only see a named constant — so add codes here rather than writing a literal at a call +/// site, exactly as Gitea #497 concluded for the drivers. +/// +/// Severity, not equality, is what callers should branch on. 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 Bad to a bare 0x80000000, 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 / / rather than +/// comparing against or . +/// +public static class OpcUaStatus +{ + /// The value is good. Part 4 severity 00; the whole code is zero. + public const uint Good = 0x00000000; + + /// Generic uncertain. Prefer a specific Uncertain* sub-code where one applies. + public const uint Uncertain = 0x40000000; + + /// Generic bad, carrying no diagnostic beyond the severity. This is the value the retired + /// 3-state projection produced for every bad status; a caller that has something more + /// specific should say so instead. + public const uint Bad = 0x80000000; + + /// 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. + public const uint BadWaitingForInitialData = 0x80320000; + + /// 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. + public const uint BadNoCommunication = 0x80310000; + + /// An internal failure produced no usable value — the virtual-tag path's code for a script + /// that threw or timed out. + public const uint BadInternalError = 0x80020000; + + /// Part 4 severity bucket of : the top two bits. + /// The status code to classify. + /// 0 for Good, 1 for Uncertain, 2 or 3 for Bad. + public static uint Severity(uint statusCode) => statusCode >> 30; + + /// True when is in the Good severity bucket. + /// The status code to classify. + /// when the value is usable. + public static bool IsGood(uint statusCode) => Severity(statusCode) == 0; + + /// True when is in the Uncertain severity bucket. + /// The status code to classify. + /// when the value is questionable but present. + public static bool IsUncertain(uint statusCode) => Severity(statusCode) == 1; + + /// True when is in the Bad severity bucket. + /// The status code to classify. + /// when the value must not be trusted. + public static bool IsBad(uint statusCode) => Severity(statusCode) >= 2; +} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriver.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriver.cs index 566a68f3..193b2381 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriver.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriver.cs @@ -38,11 +38,30 @@ public interface IDriver Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken); /// Stop the driver, close connections, release resources. Called on shutdown or driver removal. + /// + /// Land the terminal health value AFTER the teardown, never only before it (Gitea #517). + /// Teardown drains in-flight poll / probe loops, and those loops write health as they unwind — so a + /// driver that wrote first and then tore down could have a late + /// loop overwrite it with 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. + /// Bracketing matters wherever a can land mid-teardown. It cannot on + /// this path today: DriverInstanceActor calls this from PostStop, 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 Task.Run while the health + /// tick keeps firing — so a driver whose disposes anything on its + /// failure path must write the failed state before disposing as well as after. + /// /// Cancellation token for the operation. /// A task that represents the asynchronous operation. Task ShutdownAsync(CancellationToken cancellationToken); - /// Current health snapshot, polled by Core for the status dashboard and ServiceLevel. + /// Current health snapshot, polled by Core for the status dashboard and ServiceLevel. + /// Must reflect the driver's ACTUAL state rather than a constant — a hard-coded + /// reports a healthy driver after shutdown and before initialize + /// alike, which is what Gitea #517 found in CalculationDriver. A driver with no transport still + /// has a running / not-running distinction worth reporting. /// The driver's current health snapshot. DriverHealth GetHealth(); diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmEngine.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmEngine.cs index 97262626..250953d9 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmEngine.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmEngine.cs @@ -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); /// diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/CalculationDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/CalculationDriver.cs index b48ad942..2d94ea25 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/CalculationDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/CalculationDriver.cs @@ -262,8 +262,22 @@ public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscriba return Task.CompletedTask; } - /// - public DriverHealth GetHealth() => new(DriverState.Healthy, _lastComputeUtc, null); + /// + /// Current health, derived from the driver's own lifecycle state. + /// Gitea #517. This used to return a hard-coded that + /// consulted nothing. That made it the fleet's one driver whose GetHealth() reported Healthy + /// with its timers stopped — not as a race window, but permanently and deterministically: + /// before , after , and after + /// . Every other driver reads a _health field that shutdown lands on + /// . + /// 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". _running is the flag the + /// compute paths themselves gate on, which is what makes it the honest input here. + /// + /// Healthy while running; otherwise Unknown, retaining the last compute time. + public DriverHealth GetHealth() => _running + ? new DriverHealth(DriverState.Healthy, _lastComputeUtc, null) + : new DriverHealth(DriverState.Unknown, _lastComputeUtc, null); /// public long GetMemoryFootprint() => 0; diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs index 9c60197c..53df61f5 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs @@ -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); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs index bb776ad2..2438c3c6 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs @@ -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 } /// 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. + /// on a removed variable so an in-flight MonitoredItem observes the removal. + /// Every caller passes the generic , 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 BadNodeIdUnknown 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. /// true when the write landed; false when the sink threw. - 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; } } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs index 68b2a43a..c9c1b7c2 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs @@ -408,7 +408,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// The new value to write. /// The OPC UA quality status code. /// The timestamp of the value in UTC. - 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 /// /// #477 Layer 2 — apply a source-data quality annotation to a materialised condition, OUT OF BAND - /// from any alarm transition. Used by the driver-connectivity path (comms lost → Bad, restored → - /// Good) so a native condition whose device is unreachable stops reporting the accidentally-Good - /// default. This sets ONLY — it never touches + /// 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 — 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. + /// #519 changed the fire test from bucket to full code. 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. /// /// The condition node id (RawPath for a native alarm). - /// The source-data quality to annotate. + /// The source-data status code to annotate, sub-code included. /// Timestamp of the connectivity transition in UTC. /// The condition's address-space realm. - 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); /// Map the live shelving state machine's CurrentState back to our 3-way /// by matching its well-known Part 9 state object id. Any node without @@ -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, - }; } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs index f6a458d8..b2df9615 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs @@ -21,15 +21,15 @@ public sealed class SdkAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgicalAddre } /// - 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); /// 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); /// public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs index 82d5267a..5359cfa5 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs @@ -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 /// /// /// #477 — a child driver's connectivity transition. Annotates the source-data Quality of EVERY native - /// alarm condition the driver owns: comms lost → , restored → - /// . This is the ONLY signal for a comms-lost native source, because a + /// alarm condition the driver owns: comms lost → , restored → + /// . This is the ONLY signal for a comms-lost native source, because a /// disconnected driver emits no alarm transitions — the alarm feed goes silent, so without this a /// comms-lost condition would keep reporting the accidentally-Good default forever. /// @@ -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) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs index b13216c5..7a2c60ce 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs @@ -102,8 +102,11 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers /// public sealed record ForceReconnect; /// Published to the actor's parent whenever the subscribed IDriver fires - /// . The parent forwards to OpcUaPublishActor. - public sealed record AttributeValuePublished(string DriverInstanceId, string FullReference, object? Value, OpcUaQuality Quality, DateTime TimestampUtc); + /// . The parent forwards to OpcUaPublishActor. + /// is the driver's full OPC UA status code including its sub-code + /// (#519) — not a severity bucket. Classify it with and friends rather + /// than comparing it to . + public sealed record AttributeValuePublished(string DriverInstanceId, string FullReference, object? Value, uint StatusCode, DateTime TimestampUtc); private sealed record DataChangeForward(string FullReference, DataValueSnapshot Snapshot); /// Published to the parent whenever the subscribed driver (an ) fires /// . The parent () projects + routes it @@ -957,27 +960,19 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers } } + /// Forward a driver's data change to the parent for OPC UA publication. + /// 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 + /// 0x80000000 — so no driver's sub-code ever reached a client, and the status constants each + /// driver maintains were diagnostic in logs only. 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)); } - /// Translate an OPC UA status code to the 3-state projection - /// the publish actor consumes. Severity bits (top 2): 00 = Good, 01 = Uncertain, 10/11 = Bad. - 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); /// Resolve the resilience-pipeline host key for a single driver-side reference. Multi-host /// drivers (e.g. Modbus across N PLCs) implement so one dead host diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs index e3125339..e2e0e174 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs @@ -42,10 +42,12 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers /// A driver/vtag value update for a single materialised Variable node. v3 Batch 4: carries the /// node's so the sink resolves the right namespace — the driver fan-out /// posts one of these per registered NodeId (the raw node in and each - /// referencing UNS node in ) with identical value/quality/timestamp. + /// referencing UNS node in ) with identical value/status/timestamp. /// defaults to so pre-v3 callers (VirtualTag - /// equipment nodes) keep compiling; the driver fan-out passes it EXPLICITLY per node. - 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. + /// is the full OPC UA status code, sub-code included (#519); the sink + /// hands it to the SDK verbatim. + public sealed record AttributeValueUpdate(string NodeId, object? Value, uint StatusCode, DateTime TimestampUtc, AddressSpaceRealm Realm); /// Carries the full Part 9 condition state for a scripted alarm to the sink. The /// 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), for v3 native raw conditions. public sealed record AlarmStateUpdate(string AlarmNodeId, AlarmConditionSnapshot State, DateTime TimestampUtc, AddressSpaceRealm Realm); /// #477 — annotate a materialised condition's source-data Quality out of band from any alarm - /// transition (the driver-connectivity path: comms lost → , restored → - /// ). Routed to , - /// which sets ONLY Quality and fires one Part 9 event on a quality-bucket change. + /// transition (the driver-connectivity path: comms lost → , + /// restored → ). Routed to , + /// which sets ONLY Quality and fires one Part 9 event on a status-code change. /// The condition node id (RawPath for a native alarm). - /// The source-data quality to annotate. + /// The source-data status code to annotate, sub-code included. /// The connectivity transition timestamp in UTC. /// The namespace realm the condition lives in ( for native). - public sealed record AlarmQualityUpdate(string AlarmNodeId, OpcUaQuality Quality, DateTime TimestampUtc, AddressSpaceRealm Realm); + public sealed record AlarmQualityUpdate(string AlarmNodeId, uint StatusCode, DateTime TimestampUtc, AddressSpaceRealm Realm); /// /// Triggers an address-space rebuild. is the deployment /// just applied by the host; the rebuild loads THAT artifact so materialisation matches the @@ -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("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("kind", "alarm-quality")); } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/ScriptedAlarmHostActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/ScriptedAlarmHostActor.cs index e00a2ef2..6d18058c 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/ScriptedAlarmHostActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/ScriptedAlarmHostActor.cs @@ -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)); } - /// #478 — map the 3-state to an OPC UA StatusCode (severity bits) - /// for the engine's read cache. The inverse of . - private static uint StatusFromQuality(OpcUaQuality quality) => quality switch - { - OpcUaQuality.Bad => 0x80000000u, - OpcUaQuality.Uncertain => 0x40000000u, - _ => 0u, // Good - }; - - /// #478 — collapse an engine (top-2 - /// severity bits) back to the 3-state the Commons snapshot / node path use. - 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); /// Maps the Core onto the Commons /// mirror (the Commons assembly can't see the Core enum). diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/DependencyMuxActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/DependencyMuxActor.cs index 5badbfdf..2820df66 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/DependencyMuxActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/DependencyMuxActor.cs @@ -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); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagActor.cs index ebc658ed..c57e684d 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagActor.cs @@ -18,8 +18,8 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags; /// script-logs DPS topic so operators see the diagnostic in the live tail. /// /// -/// 02/S13 — a persistent script failure/timeout also degrades the node's OPC UA quality to -/// (carrying the last-known value), published once per +/// 02/S13 — a persistent script failure/timeout also degrades the node's OPC UA status to +/// (carrying the last-known value), published once per /// transition (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"; - /// A dependency's value changed. (#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 , and the virtual-tag engine ignores it. + /// A dependency's value changed. (#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 , 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. public sealed record DependencyValueChanged( - string TagId, object? Value, DateTime TimestampUtc, OpcUaQuality Quality = OpcUaQuality.Good); + string TagId, object? Value, DateTime TimestampUtc, uint StatusCode = OpcUaStatus.Good); /// Re-assert the last emitted value/quality to the parent bridge, bypassing the value dedup. /// Sent by 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. public sealed class ReassertValue { public static readonly ReassertValue Instance = new(); private ReassertValue() { } } - /// Result emitted to the parent (the host bridge). carries the - /// OPC UA quality the node should read: for a fresh computed value, - /// when the script failed/timed out (02/S13) — in which case + /// Result emitted to the parent (the host bridge). carries the + /// OPC UA status code the node should read: for a fresh computed value, + /// when the script failed/timed out (02/S13) — in which case /// is the last-known value (or null if none). Defaulting to Good keeps /// every existing construction site source-compatible. /// true 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 /// false so every existing construction site stays source-compatible. - 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)); } /// Inputs-ready gate (mirrors the dormant VirtualTagEngine.AreInputsReady): true once diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagHostActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagHostActor.cs index 7005dfdc..adc6a061 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagHostActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagHostActor.cs @@ -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)); } } diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/OpcUa/DeferredAddressSpaceSinkTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/OpcUa/DeferredAddressSpaceSinkTests.cs index 861a351b..ac9e11c0 100644 --- a/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/OpcUa/DeferredAddressSpaceSinkTests.cs +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/OpcUa/DeferredAddressSpaceSinkTests.cs @@ -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 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) { } diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/StatusCodeParityTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/StatusCodeParityTests.cs index eab14140..02122ad1 100644 --- a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/StatusCodeParityTests.cs +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/StatusCodeParityTests.cs @@ -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; /// ZB.MOM.WW.OtOpcUa.Driver.*.dll deployed to its output directory for const uint 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. +/// Commons is in scope too (Gitea #519). Commons.OpcUa.OpcUaStatus is SDK-free for +/// the same layering reason the drivers are — IOpcUaAddressSpaceSink exists so Runtime need not +/// reference Opc.Ua.Server — so its constants carry the identical name-vs-value risk. Commons +/// arrives here transitively through Core. /// An inline literal is invisible to this guard. Reflection can only see a named /// constant, so StatusCode: 0x800B0000u, // BadTimeout written at a call site is unguarded — /// which is how GalaxyDriver shipped BadServiceUnsupported under a BadTimeout @@ -60,7 +65,11 @@ public sealed class StatusCodeParityTests var data = new TheoryData(); 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)); } + + /// + /// Positive control for the Commons half of the scan (#519). Widening the glob is worthless if + /// Commons.dll 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. + /// + [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)); + } } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation.Tests/CalculationDriverTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation.Tests/CalculationDriverTests.cs index 8deca370..6846d8f4 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation.Tests/CalculationDriverTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation.Tests/CalculationDriverTests.cs @@ -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 WaitUntil(Func predicate, TimeSpan timeout) { @@ -176,6 +174,41 @@ public sealed class CalculationDriverTests return predicate(); } + // ── health lifecycle (Gitea #517) ───────────────────────────────────────────────────────── + + /// + /// Gitea #517 — GetHealth() must not report Healthy for a driver that is not running. + /// This driver returned a hard-coded 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 _health field that + /// shutdown lands on . + /// 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. + /// + [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 sink) : IScriptLogPublisher { private readonly object _lock = new(); diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/CannedAgentClient.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/CannedAgentClient.cs index 47617451..521e0e98 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/CannedAgentClient.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/CannedAgentClient.cs @@ -434,6 +434,13 @@ internal sealed class CannedAgentClient : IMTConnectAgentClient, IDisposable /// to release anything a test cares about. /// /// + /// + /// Invoked on the first 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 InitializeAsync returns. + /// + 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(); } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectDriverLifecycleTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectDriverLifecycleTests.cs index 197f429e..b413261d 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectDriverLifecycleTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectDriverLifecycleTests.cs @@ -161,6 +161,41 @@ public sealed class MTConnectDriverLifecycleTests client.IsDisposed.ShouldBeTrue(); } + /// + /// Gitea #517 — the failed state must be published BEFORE the client is disposed, not after. + /// Every other test in this file asserts the state once InitializeAsync has returned, + /// 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 GetHealth() from inside the + /// client's Dispose, which is the moment the driver stops being able to serve anything. + /// The window is reachable in production, which is what makes this worth pinning rather than + /// a theoretical tidy-up: DriverInstanceActor runs InitializeAsync on a + /// fire-and-forget Task.Run, so its health-poll tick keeps calling GetHealth() on the + /// actor thread for the whole duration of the teardown. + /// Falsifiable: remove the pre-teardown WriteHealth and the observed state reverts to + /// — the stale value written on entry. + /// + [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( + () => 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() { diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/DualNamespaceAddressSpaceTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/DualNamespaceAddressSpaceTests.cs index dbf9d759..fc0fbde9 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/DualNamespaceAddressSpaceTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/DualNamespaceAddressSpaceTests.cs @@ -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); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/NativeAlarmEventIdentityFieldDeliveryTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/NativeAlarmEventIdentityFieldDeliveryTests.cs index 9de4aab5..e53d3611 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/NativeAlarmEventIdentityFieldDeliveryTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/NativeAlarmEventIdentityFieldDeliveryTests.cs @@ -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; diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/SubscriptionSurvivalTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/SubscriptionSurvivalTests.cs index 02b8236d..01722a5c 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/SubscriptionSurvivalTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/SubscriptionSurvivalTests.cs @@ -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); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierFailureSurfaceTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierFailureSurfaceTests.cs index b2b970e3..d4b97578 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierFailureSurfaceTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierFailureSurfaceTests.cs @@ -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) { diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierHierarchyTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierHierarchyTests.cs index aaa96f10..10577cac 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierHierarchyTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierHierarchyTests.cs @@ -323,12 +323,12 @@ public sealed class AddressSpaceApplierHierarchyTests : IDisposable /// The value to write. /// The OPC UA quality value. /// The source timestamp in UTC. - 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) { } /// Records an alarm condition write (stub implementation for testing). /// The node ID of the alarm condition. /// The full condition state snapshot. /// The source timestamp in UTC. - public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { } + public void WriteAlarmQuality(string alarmNodeId, uint statusCode, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { } public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } /// Materialises an alarm condition (stub implementation for testing). /// The alarm node ID (== ScriptedAlarmId). diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierRawUnsTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierRawUnsTests.cs index 4492afb9..2f4715a3 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierRawUnsTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierRawUnsTests.cs @@ -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) { } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierTests.cs index a25562e0..7151a280 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierTests.cs @@ -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(); /// Gets the queue of value writes (NodeId, quality) — used to assert the PureRemove terminal Bad. - public ConcurrentQueue<(string NodeId, OpcUaQuality Quality)> ValueWriteQueue { get; } = new(); + public ConcurrentQueue<(string NodeId, uint Quality)> ValueWriteQueue { get; } = new(); /// Gets the list of recorded value writes. - public List<(string NodeId, OpcUaQuality Quality)> ValueWrites => ValueWriteQueue.ToList(); + public List<(string NodeId, uint Quality)> ValueWrites => ValueWriteQueue.ToList(); /// Records a value write (NodeId + quality). /// The node ID. /// The value to write. - /// The OPC UA quality. + /// The OPC UA status code, sub-code included. /// The source timestamp in UTC. - 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)); /// Records an alarm condition write call. /// The alarm node ID. /// The full condition state snapshot. /// The source timestamp in UTC. - public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { } + public void 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)); /// Records an alarm-condition materialise call. @@ -1651,9 +1651,9 @@ public sealed class AddressSpaceApplierTests public int RebuildCalls; /// Records a value write (no-op in this sink). - 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) { } /// No-op alarm condition write call. - 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) { } /// No-op alarm-condition materialise call. 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 /// Records a value write (no-op in this sink). /// The node ID. /// The value to write. - /// The OPC UA quality. + /// The OPC UA status code, sub-code included. /// The source timestamp in UTC. - 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) { } /// Throws an exception if configured to do so. /// The alarm node ID. /// The full condition state snapshot. /// The source timestamp in UTC. /// Thrown when configured to throw on alarm write. - public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { } + public void 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"); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/DeferredAddressSpaceSinkTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/DeferredAddressSpaceSinkTests.cs index 61807893..9a119938 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/DeferredAddressSpaceSinkTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/DeferredAddressSpaceSinkTests.cs @@ -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(); /// - 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}"); /// - 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}"); /// @@ -288,9 +288,9 @@ public sealed class DeferredAddressSpaceSinkTests public bool RemoveEquipmentSubtree(string equipmentNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { RemoveCalls.Add(("equipment", equipmentNodeId)); return Result; } /// - 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) { } /// public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerAlarmSourceFieldsTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerAlarmSourceFieldsTests.cs index 3c91eea3..0d733ec4 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerAlarmSourceFieldsTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerAlarmSourceFieldsTests.cs @@ -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)); } /// A booted server + its node manager, disposed via await using so an assertion failure diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerArrayTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerArrayTests.cs index 642fa81f..c98d6593 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerArrayTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerArrayTests.cs @@ -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(); } + /// + /// Gitea #519, second hop — the node manager hands the caller's status code to the SDK + /// verbatim, sub-code included. + /// It used to re-expand a 3-state enum here (Bad => StatusCodes.Bad), so every Bad + /// sub-code a driver produced landed on the wire as 0x80000000. Boots a real SDK server and + /// reads the created back, which is the value an OPC UA client + /// would receive. + /// 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. + /// + [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( diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerPreStartGuardTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerPreStartGuardTests.cs index 2c8e5bcf..718aa73e 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerPreStartGuardTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerPreStartGuardTests.cs @@ -70,7 +70,7 @@ public sealed class NodeManagerPreStartGuardTests : IDisposable try { Should.Throw(() => - 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 { diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerSurgicalShapeUpdateTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerSurgicalShapeUpdateTests.cs index 9205bf10..2f11adf7 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerSurgicalShapeUpdateTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerSurgicalShapeUpdateTests.cs @@ -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. diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerWriteRevertTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerWriteRevertTests.cs index 303b8827..16767614 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerWriteRevertTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerWriteRevertTests.cs @@ -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( diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/SdkAddressSpaceSinkTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/SdkAddressSpaceSinkTests.cs index 859f7902..bd3f8b85 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/SdkAddressSpaceSinkTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/SdkAddressSpaceSinkTests.cs @@ -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; diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/CalculationDependencyFlowTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/CalculationDependencyFlowTests.cs index 2363048f..a76c00a5 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/CalculationDependencyFlowTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/CalculationDependencyFlowTests.cs @@ -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)); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorLiveValueTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorLiveValueTests.cs index d18f88a6..a445fe22 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorLiveValueTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorLiveValueTests.cs @@ -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(TimeSpan.FromSeconds(5)); var b = publish.ExpectMsg(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(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(TimeSpan.FromSeconds(5)) diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorNativeAlarmTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorNativeAlarmTests.cs index a5d07989..e14b57aa 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorNativeAlarmTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorNativeAlarmTests.cs @@ -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(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(TimeSpan.FromSeconds(5)); good.AlarmNodeId.ShouldBe(AlarmRawPath); - good.Quality.ShouldBe(OpcUaQuality.Good); + good.StatusCode.ShouldBe(OpcUaStatus.Good); } /// #477 Layer 2 — connectivity for a DIFFERENT driver instance annotates none of this driver's diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorTests.cs index 59794e08..7cc4e9b3 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorTests.cs @@ -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(TimeSpan.FromSeconds(2)); published.FullReference.ShouldBe("tag-a"); published.Value.ShouldBe(3.14); - published.Quality.ShouldBe(OpcUaQuality.Good); + published.StatusCode.ShouldBe(OpcUaStatus.Good); } - /// Verifies that subscribe translates OPC UA status severity bits to OpcUaQuality. + /// + /// Gitea #519 — a driver's status code reaches the parent with its sub-code intact. + /// This is the first of the two hops that used to destroy it: the actor projected the + /// driver's uint onto a 3-state Good/Uncertain/Bad enum, and the node manager then + /// re-expanded Bad to a bare 0x80000000. The net effect was that no driver's sub-code ever + /// reached an OPC UA client — BadNoCommunication, BadTypeMismatch and + /// BadNotSupported were indistinguishable — while every driver maintained its own named + /// constants (16 of which #497 had just corrected) as if they mattered end to end. + /// The severity buckets are asserted too, since the bucket is still what + /// and friends classify on; what changed is that the bucket is no + /// longer all that survives. + /// [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().Quality.ShouldBe(OpcUaQuality.Uncertain); + parent.ExpectMsg().StatusCode.ShouldBe(OpcUaStatus.Uncertain); // Bad — severity bits 10. driver.FireDataChange("tag-1", value: 2, statusCode: 0x80000000u); - parent.ExpectMsg().Quality.ShouldBe(OpcUaQuality.Bad); + parent.ExpectMsg().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(); + 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().StatusCode.ShouldBe(0x80740000u); } /// diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/NativeAlarmProjectorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/NativeAlarmProjectorTests.cs index f98d0eea..b7df4648 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/NativeAlarmProjectorTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/NativeAlarmProjectorTests.cs @@ -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( diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Observability/OtOpcUaTelemetryHookTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Observability/OtOpcUaTelemetryHookTests.cs index 8a414896..a822db4a 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Observability/OtOpcUaTelemetryHookTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Observability/OtOpcUaTelemetryHookTests.cs @@ -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 /// The value being written. /// The OPC UA quality status. /// The source timestamp in UTC. - 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++; /// Records an alarm condition write. /// The alarm node identifier. /// The full condition state snapshot. /// The time the alarm occurred in UTC. - public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { } + public void WriteAlarmQuality(string alarmNodeId, uint statusCode, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { } public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime occurredUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => Writes++; /// Materialises an alarm condition (stub implementation). /// The alarm node identifier. diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorApplyFailureTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorApplyFailureTests.cs index 4bd67911..af17654b 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorApplyFailureTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorApplyFailureTests.cs @@ -123,8 +123,8 @@ public sealed class OpcUaPublishActorApplyFailureTests : RuntimeActorTestBase /// A sink whose RebuildAddressSpace throws (drives the applier's SafeRebuild catch → RebuildFailed). 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 /// A no-op sink — a clean apply (no failures). 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) { } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorRebuildTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorRebuildTests.cs index 5895c429..227b683b 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorRebuildTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorRebuildTests.cs @@ -622,13 +622,13 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase /// The value to write. /// The OPC UA quality code. /// The timestamp of the write. - 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}"); /// Records an alarm condition write call. /// The alarm node ID. /// The full condition state snapshot. /// The timestamp of the state change. - public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { } + public void 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}"); /// Records a materialise-alarm-condition call. diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorTests.cs index 3190713e..6f05839a 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorTests.cs @@ -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 { /// Gets the queue of recorded value updates. - 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(); /// Gets the queue of recorded alarm condition updates. public ConcurrentQueue<(string AlarmNodeId, AlarmConditionSnapshot State, DateTime Ts)> AlarmQueue { get; } = new(); /// Gets the queue of recorded alarm-quality annotations (#477). - public ConcurrentQueue<(string AlarmNodeId, OpcUaQuality Quality, DateTime Ts, AddressSpaceRealm Realm)> AlarmQualityQueue { get; } = new(); + public ConcurrentQueue<(string AlarmNodeId, uint StatusCode, DateTime Ts, AddressSpaceRealm Realm)> AlarmQualityQueue { get; } = new(); /// Count of rebuild calls. public int RebuildCalls; /// Gets the queue of recorded EnsureFolder calls. @@ -717,7 +717,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase public ConcurrentQueue ModelChangeQueue { get; } = new(); /// Gets the list of recorded value updates. - public List<(string NodeId, object? Value, OpcUaQuality Quality, DateTime Ts)> Values => + public List<(string NodeId, object? Value, uint StatusCode, DateTime Ts)> Values => ValueQueue.ToList(); /// Gets the list of recorded alarm condition updates. public List<(string AlarmNodeId, AlarmConditionSnapshot State, DateTime Ts)> Alarms => @@ -734,10 +734,10 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase /// Records a value update. /// The OPC UA node identifier. /// The attribute value. - /// The OPC UA quality code. + /// The OPC UA status code, sub-code included. /// The timestamp of the update. - 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)); /// Records an alarm condition update. /// The OPC UA alarm node identifier. @@ -747,8 +747,8 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase AlarmQueue.Enqueue((alarmNodeId, state, ts)); /// Records an alarm-quality annotation (#477). - public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime ts, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => - AlarmQualityQueue.Enqueue((alarmNodeId, quality, ts, realm)); + public void WriteAlarmQuality(string alarmNodeId, uint statusCode, DateTime ts, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => + AlarmQualityQueue.Enqueue((alarmNodeId, statusCode, ts, realm)); /// Materialises an alarm condition (no-op in test). /// The alarm node ID. diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/ScriptedAlarmHostActorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/ScriptedAlarmHostActorTests.cs index d3ee455b..02cded5d 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/ScriptedAlarmHostActorTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/ScriptedAlarmHostActorTests.cs @@ -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(m => m.Quality == OpcUaQuality.Bad, Timeout); + var q = publish.FishForMessage(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(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(m => m.State.Active, Timeout); state.AlarmNodeId.ShouldBe("alm-1"); - state.State.Quality.ShouldBe(OpcUaQuality.Uncertain); + state.State.StatusCode.ShouldBe(OpcUaStatus.Uncertain); } /// OneShotShelve transition carries the operator's identity: an operator-driven OneShotShelve diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/DependencyMuxActorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/DependencyMuxActorTests.cs index be6dd343..c5960c68 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/DependencyMuxActorTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/DependencyMuxActorTests.cs @@ -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(); 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().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(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(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().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().Quality.ShouldBe(OpcUaQuality.Bad); + sub.ExpectMsg().StatusCode.ShouldBe(OpcUaStatus.Bad); } private sealed class EchoSumEvaluator : ZB.MOM.WW.OtOpcUa.Commons.Engines.IVirtualTagEvaluator diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/VirtualTagActorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/VirtualTagActorTests.cs index 38fb64bd..69a3617f 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/VirtualTagActorTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/VirtualTagActorTests.cs @@ -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().Quality.ShouldBe(OpcUaQuality.Bad); + parent.ExpectMsg().StatusCode.ShouldBe(OpcUaStatus.BadInternalError); } /// 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().Quality.ShouldBe(OpcUaQuality.Bad); + parent.ExpectMsg().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(); - 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().Quality.ShouldBe(OpcUaQuality.Bad); + parent.ExpectMsg().StatusCode.ShouldBe(OpcUaStatus.BadInternalError); actor.Tell(new VirtualTagActor.DependencyValueChanged("a", 3, DateTime.UtcNow)); var recovered = parent.ExpectMsg(); - 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().Quality.ShouldBe(OpcUaQuality.Bad); + parent.ExpectMsg().StatusCode.ShouldBe(OpcUaStatus.BadInternalError); } /// 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().Quality.ShouldBe(OpcUaQuality.Bad); + parent.ExpectMsg().StatusCode.ShouldBe(OpcUaStatus.BadInternalError); } /// Test evaluator returning a fixed sequence of results, one per Evaluate call; the last diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/VirtualTagHostActorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/VirtualTagHostActorTests.cs index e61de11a..bb235899 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/VirtualTagHostActorTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/VirtualTagHostActorTests.cs @@ -67,7 +67,7 @@ public sealed class VirtualTagHostActorTests : RuntimeActorTestBase var update = publish.ExpectMsg(); 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(); - 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(); - bad.Quality.ShouldBe(OpcUaQuality.Bad); + bad.StatusCode.ShouldBe(OpcUaStatus.BadInternalError); bad.Value.ShouldBe(42.0); bad.TimestampUtc.ShouldBe(ts2); } /// 02/S13 bridge: a Bad-quality must bridge to - /// an carrying + /// an carrying /// (not the old hard-coded Good). [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(); - update.Quality.ShouldBe(OpcUaQuality.Bad); + update.StatusCode.ShouldBe(OpcUaStatus.Bad); update.Value.ShouldBe(42.0); } - /// 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. + /// 02/S13 historize parity: a failed result on a historized plan must record BadInternalError + /// (0x80020000u) in the snapshot, not the Good 0u. + /// #519 — the host no longer DERIVES this from a Bad bucket; it records the result's status code + /// verbatim, and is what stamps BadInternalError. So this constructs the + /// result the real child emits, and the assertion now proves pass-through rather than re-derivation. [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(); 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); } /// @@ -514,7 +519,7 @@ public sealed class VirtualTagHostActorTests : RuntimeActorTestBase child.Tell(new VirtualTagActor.DependencyValueChanged("a", 1, DateTime.UtcNow)); var first = publish.ExpectMsg(); 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(); 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));