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

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

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

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

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

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

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

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

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

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

Closes #517
Closes #519

Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
This commit is contained in:
Joseph Doherty
2026-07-31 05:25:27 -04:00
parent ba85e871df
commit 2d20aeb779
53 changed files with 721 additions and 311 deletions
@@ -156,9 +156,9 @@ public sealed class AddressSpaceApplierFailureSurfaceTests
public bool ThrowOnAlarmWrite { get; init; }
public bool ThrowOnMaterialiseAlarm { get; init; }
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void WriteValue(string nodeId, object? value, uint statusCode, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
public void WriteAlarmQuality(string alarmNodeId, uint statusCode, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
{
@@ -323,12 +323,12 @@ public sealed class AddressSpaceApplierHierarchyTests : IDisposable
/// <param name="value">The value to write.</param>
/// <param name="quality">The OPC UA quality value.</param>
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void WriteValue(string nodeId, object? value, uint statusCode, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
/// <summary>Records an alarm condition write (stub implementation for testing).</summary>
/// <param name="alarmNodeId">The node ID of the alarm condition.</param>
/// <param name="state">The full condition state snapshot.</param>
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
public void WriteAlarmQuality(string alarmNodeId, uint statusCode, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
/// <summary>Materialises an alarm condition (stub implementation for testing).</summary>
/// <param name="alarmNodeId">The alarm node ID (== ScriptedAlarmId).</param>
@@ -300,8 +300,8 @@ public sealed class AddressSpaceApplierRawUnsTests
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes")
=> References.Add((sourceNodeId, sourceRealm, targetNodeId, targetRealm, referenceType));
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
public void WriteValue(string nodeId, object? value, uint statusCode, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
public void WriteAlarmQuality(string alarmNodeId, uint statusCode, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
public void RebuildAddressSpace() { }
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm) { }
@@ -147,7 +147,7 @@ public sealed class AddressSpaceApplierTests
sink.RebuildCalls.ShouldBe(0);
var nodeId = V3NodeIds.Uns("eq-1", "Speed");
// Terminal Bad written to the removed variable BEFORE the removal.
sink.ValueWrites.ShouldContain((nodeId, OpcUaQuality.Bad));
sink.ValueWrites.ShouldContain((nodeId, OpcUaStatus.Bad));
sink.RemoveCalls.ShouldHaveSingleItem().ShouldBe(("var", nodeId));
}
@@ -1583,22 +1583,22 @@ public sealed class AddressSpaceApplierTests
public List<(string AlarmNodeId, string EquipmentNodeId, string DisplayName, string AlarmType, int Severity, bool IsNative)> AlarmConditionCalls => AlarmConditionQueue.ToList();
/// <summary>Gets the queue of value writes (NodeId, quality) — used to assert the PureRemove terminal Bad.</summary>
public ConcurrentQueue<(string NodeId, OpcUaQuality Quality)> ValueWriteQueue { get; } = new();
public ConcurrentQueue<(string NodeId, uint Quality)> ValueWriteQueue { get; } = new();
/// <summary>Gets the list of recorded value writes.</summary>
public List<(string NodeId, OpcUaQuality Quality)> ValueWrites => ValueWriteQueue.ToList();
public List<(string NodeId, uint Quality)> ValueWrites => ValueWriteQueue.ToList();
/// <summary>Records a value write (NodeId + quality).</summary>
/// <param name="nodeId">The node ID.</param>
/// <param name="value">The value to write.</param>
/// <param name="quality">The OPC UA quality.</param>
/// <param name="statusCode">The OPC UA status code, sub-code included.</param>
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
=> ValueWriteQueue.Enqueue((nodeId, quality));
public void WriteValue(string nodeId, object? value, uint statusCode, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
=> ValueWriteQueue.Enqueue((nodeId, statusCode));
/// <summary>Records an alarm condition write call.</summary>
/// <param name="alarmNodeId">The alarm node ID.</param>
/// <param name="state">The full condition state snapshot.</param>
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
public void WriteAlarmQuality(string alarmNodeId, uint statusCode, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
=> AlarmQueue.Enqueue((alarmNodeId, state));
/// <summary>Records an alarm-condition materialise call.</summary>
@@ -1651,9 +1651,9 @@ public sealed class AddressSpaceApplierTests
public int RebuildCalls;
/// <summary>Records a value write (no-op in this sink).</summary>
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void WriteValue(string nodeId, object? value, uint statusCode, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
/// <summary>No-op alarm condition write call.</summary>
public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
public void WriteAlarmQuality(string alarmNodeId, uint statusCode, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
/// <summary>No-op alarm-condition materialise call.</summary>
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { }
@@ -1679,15 +1679,15 @@ public sealed class AddressSpaceApplierTests
/// <summary>Records a value write (no-op in this sink).</summary>
/// <param name="nodeId">The node ID.</param>
/// <param name="value">The value to write.</param>
/// <param name="quality">The OPC UA quality.</param>
/// <param name="statusCode">The OPC UA status code, sub-code included.</param>
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void WriteValue(string nodeId, object? value, uint statusCode, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
/// <summary>Throws an exception if configured to do so.</summary>
/// <param name="alarmNodeId">The alarm node ID.</param>
/// <param name="state">The full condition state snapshot.</param>
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
/// <exception cref="InvalidOperationException">Thrown when configured to throw on alarm write.</exception>
public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
public void WriteAlarmQuality(string alarmNodeId, uint statusCode, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
{
if (_throwOnAlarmWrite) throw new InvalidOperationException("simulated sink fault");
@@ -14,7 +14,7 @@ public sealed class DeferredAddressSpaceSinkTests
var deferred = new DeferredAddressSpaceSink();
// No throw, no observable side effect.
deferred.WriteValue("x", 1, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
deferred.WriteValue("x", 1, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
deferred.WriteAlarmCondition("a", Snapshot(active: true), DateTime.UtcNow, AddressSpaceRealm.Uns);
deferred.RebuildAddressSpace();
}
@@ -27,7 +27,7 @@ public sealed class DeferredAddressSpaceSinkTests
var inner = new RecordingSink();
deferred.SetSink(inner);
deferred.WriteValue("x", 42, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
deferred.WriteValue("x", 42, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
deferred.WriteAlarmCondition("a-1", Snapshot(active: true), DateTime.UtcNow, AddressSpaceRealm.Uns);
deferred.RebuildAddressSpace();
deferred.RaiseNodesAddedModelChange("eq-1", AddressSpaceRealm.Uns);
@@ -42,11 +42,11 @@ public sealed class DeferredAddressSpaceSinkTests
var deferred = new DeferredAddressSpaceSink();
var inner = new RecordingSink();
deferred.SetSink(inner);
deferred.WriteValue("x", 1, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
deferred.WriteValue("x", 1, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
inner.Calls.Count.ShouldBe(1);
deferred.SetSink(null);
deferred.WriteValue("y", 2, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns); // dropped
deferred.WriteValue("y", 2, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns); // dropped
inner.Calls.Count.ShouldBe(1);
}
@@ -59,10 +59,10 @@ public sealed class DeferredAddressSpaceSinkTests
var second = new RecordingSink();
deferred.SetSink(first);
deferred.WriteValue("a", 1, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
deferred.WriteValue("a", 1, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
deferred.SetSink(second);
deferred.WriteValue("b", 2, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
deferred.WriteValue("b", 2, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
first.Calls.Single().ShouldBe("WV:a");
second.Calls.Single().ShouldBe("WV:b");
@@ -228,10 +228,10 @@ public sealed class DeferredAddressSpaceSinkTests
public List<(string NodeId, string? HistorianTagname)> HistorianCalls => HistorianQueue.ToList();
/// <inheritdoc />
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
public void WriteValue(string nodeId, object? value, uint statusCode, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
=> CallQueue.Enqueue($"WV:{nodeId}");
/// <inheritdoc />
public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
public void WriteAlarmQuality(string alarmNodeId, uint statusCode, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
=> CallQueue.Enqueue($"WA:{alarmNodeId}");
/// <inheritdoc />
@@ -288,9 +288,9 @@ public sealed class DeferredAddressSpaceSinkTests
public bool RemoveEquipmentSubtree(string equipmentNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { RemoveCalls.Add(("equipment", equipmentNodeId)); return Result; }
/// <inheritdoc />
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void WriteValue(string nodeId, object? value, uint statusCode, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
/// <inheritdoc />
public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
public void WriteAlarmQuality(string alarmNodeId, uint statusCode, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
/// <inheritdoc />
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { }
@@ -253,7 +253,7 @@ public sealed class NodeManagerAlarmSourceFieldsTests : IDisposable
var badSnapshot = new AlarmConditionSnapshot(
Active: false, Acknowledged: true, Confirmed: true, Enabled: true,
Shelving: AlarmShelvingKind.Unshelved, Severity: 700, Message: "HR200",
Quality: OpcUaQuality.Bad);
StatusCode: OpcUaStatus.Bad);
nm.WriteAlarmCondition(RawAlarmPath, badSnapshot, DateTime.UtcNow, AddressSpaceRealm.Raw);
var condition = nm.TryGetAlarmCondition(RawAlarmPath, AddressSpaceRealm.Raw);
@@ -261,7 +261,7 @@ public sealed class NodeManagerAlarmSourceFieldsTests : IDisposable
StatusCode.IsBad(condition.Quality.Value).ShouldBeTrue();
// Recover: a Good snapshot restores Good quality.
var goodSnapshot = badSnapshot with { Quality = OpcUaQuality.Good };
var goodSnapshot = badSnapshot with { StatusCode = OpcUaStatus.Good };
nm.WriteAlarmCondition(RawAlarmPath, goodSnapshot, DateTime.UtcNow, AddressSpaceRealm.Raw);
StatusCode.IsGood(condition.Quality.Value).ShouldBeTrue();
}
@@ -286,11 +286,11 @@ public sealed class NodeManagerAlarmSourceFieldsTests : IDisposable
var messageBefore = condition.Message!.Value?.Text;
// Device comes online → Good.
nm.WriteAlarmQuality(RawAlarmPath, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
nm.WriteAlarmQuality(RawAlarmPath, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
StatusCode.IsGood(condition.Quality!.Value).ShouldBeTrue();
// Device goes comms-lost → Bad, but severity / message / active-ack state are untouched.
nm.WriteAlarmQuality(RawAlarmPath, OpcUaQuality.Bad, DateTime.UtcNow, AddressSpaceRealm.Raw);
nm.WriteAlarmQuality(RawAlarmPath, OpcUaStatus.Bad, DateTime.UtcNow, AddressSpaceRealm.Raw);
StatusCode.IsBad(condition.Quality.Value).ShouldBeTrue();
condition.Severity.Value.ShouldBe(severityBefore);
condition.Message.Value?.Text.ShouldBe(messageBefore);
@@ -307,7 +307,7 @@ public sealed class NodeManagerAlarmSourceFieldsTests : IDisposable
var nm = host.Nm;
Should.NotThrow(() =>
nm.WriteAlarmQuality("no/such/node", OpcUaQuality.Bad, DateTime.UtcNow, AddressSpaceRealm.Raw));
nm.WriteAlarmQuality("no/such/node", OpcUaStatus.Bad, DateTime.UtcNow, AddressSpaceRealm.Raw));
}
/// <summary>A booted server + its node manager, disposed via <c>await using</c> so an assertion failure
@@ -94,7 +94,7 @@ public sealed class NodeManagerArrayTests : IDisposable
writable: false, historianTagname: null, isArray: true, arrayLength: 3, realm: AddressSpaceRealm.Uns);
var payload = new[] { 1, 2, 3 };
nm.WriteValue("eq-1/arrwrite", payload, OpcUaQuality.Good, DateTime.UtcNow, realm: AddressSpaceRealm.Uns);
nm.WriteValue("eq-1/arrwrite", payload, OpcUaStatus.Good, DateTime.UtcNow, realm: AddressSpaceRealm.Uns);
var variable = nm.TryGetVariable("eq-1/arrwrite");
variable.ShouldNotBeNull();
@@ -104,6 +104,47 @@ public sealed class NodeManagerArrayTests : IDisposable
await host.DisposeAsync();
}
/// <summary>
/// Gitea #519, second hop — the node manager hands the caller's status code to the SDK
/// <b>verbatim</b>, sub-code included.
/// <para>It used to re-expand a 3-state enum here (<c>Bad =&gt; StatusCodes.Bad</c>), so every Bad
/// sub-code a driver produced landed on the wire as <c>0x80000000</c>. Boots a real SDK server and
/// reads the created <see cref="BaseDataVariableState"/> back, which is the value an OPC UA client
/// would receive.</para>
/// <para>Asserting two DIFFERENT Bad sub-codes is what makes this falsifiable: under the old
/// projection both collapsed to the same value, so a single-code assertion could pass on a
/// hard-coded constant.</para>
/// </summary>
[Fact]
public async Task WriteValue_hands_the_status_sub_code_to_the_SDK_verbatim()
{
var (host, server) = await BootAsync();
var nm = server.NodeManager!;
nm.EnsureVariable("eq-1/subcode", parentFolderNodeId: null, displayName: "subcode", dataType: "Double",
writable: false, historianTagname: null, realm: AddressSpaceRealm.Uns);
nm.WriteValue("eq-1/subcode", null, OpcUaStatus.BadNoCommunication, DateTime.UtcNow,
realm: AddressSpaceRealm.Uns);
var variable = nm.TryGetVariable("eq-1/subcode");
variable.ShouldNotBeNull();
variable!.StatusCode.ShouldBe((StatusCode)StatusCodes.BadNoCommunication);
variable.StatusCode.ShouldNotBe((StatusCode)StatusCodes.Bad,
"the sub-code was collapsed to a generic Bad — this is exactly the #519 defect");
// A different Bad sub-code must land differently.
nm.WriteValue("eq-1/subcode", null, 0x80740000u /* BadTypeMismatch */, DateTime.UtcNow,
realm: AddressSpaceRealm.Uns);
nm.TryGetVariable("eq-1/subcode")!.StatusCode.ShouldBe((StatusCode)StatusCodes.BadTypeMismatch);
// Uncertain and Good still round-trip.
nm.WriteValue("eq-1/subcode", 1.0, OpcUaStatus.Good, DateTime.UtcNow, realm: AddressSpaceRealm.Uns);
nm.TryGetVariable("eq-1/subcode")!.StatusCode.ShouldBe((StatusCode)StatusCodes.Good);
await host.DisposeAsync();
}
private async Task<(OpcUaApplicationHost Host, OtOpcUaSdkServer Server)> BootAsync()
{
var host = new OpcUaApplicationHost(
@@ -70,7 +70,7 @@ public sealed class NodeManagerPreStartGuardTests : IDisposable
try
{
Should.Throw<InvalidOperationException>(() =>
nm.WriteValue("eq-1/temp", 1.0, OpcUaQuality.Good, DateTime.UtcNow, realm: AddressSpaceRealm.Uns));
nm.WriteValue("eq-1/temp", 1.0, OpcUaStatus.Good, DateTime.UtcNow, realm: AddressSpaceRealm.Uns));
}
finally
{
@@ -45,7 +45,7 @@ public sealed class NodeManagerSurgicalShapeUpdateTests : IDisposable
var nm = server.NodeManager!;
nm.EnsureVariable("eq-1/sp", parentFolderNodeId: null, displayName: "Sp", dataType: "Float", writable: false, realm: AddressSpaceRealm.Uns);
nm.WriteValue("eq-1/sp", 3.5f, OpcUaQuality.Good, DateTime.UtcNow, realm: AddressSpaceRealm.Uns);
nm.WriteValue("eq-1/sp", 3.5f, OpcUaStatus.Good, DateTime.UtcNow, realm: AddressSpaceRealm.Uns);
var node = nm.TryGetVariable("eq-1/sp")!;
node.DataType.ShouldBe(DataTypeIds.Float); // arrange guard
@@ -70,7 +70,7 @@ public sealed class NodeManagerSurgicalShapeUpdateTests : IDisposable
var nm = server.NodeManager!;
nm.EnsureVariable("eq-1/buf", parentFolderNodeId: null, displayName: "Buf", dataType: "Int16", writable: false, realm: AddressSpaceRealm.Uns);
nm.WriteValue("eq-1/buf", (short)42, OpcUaQuality.Good, DateTime.UtcNow, realm: AddressSpaceRealm.Uns);
nm.WriteValue("eq-1/buf", (short)42, OpcUaStatus.Good, DateTime.UtcNow, realm: AddressSpaceRealm.Uns);
var node = nm.TryGetVariable("eq-1/buf")!;
node.ValueRank.ShouldBe(ValueRanks.Scalar); // arrange guard
@@ -97,7 +97,7 @@ public sealed class NodeManagerSurgicalShapeUpdateTests : IDisposable
nm.EnsureVariable("eq-1/buf", parentFolderNodeId: null, displayName: "Buf", dataType: "Int16",
writable: false, historianTagname: null, isArray: true, arrayLength: 4u, realm: AddressSpaceRealm.Uns);
nm.WriteValue("eq-1/buf", new short[] { 1, 2, 3, 4 }, OpcUaQuality.Good, DateTime.UtcNow, realm: AddressSpaceRealm.Uns);
nm.WriteValue("eq-1/buf", new short[] { 1, 2, 3, 4 }, OpcUaStatus.Good, DateTime.UtcNow, realm: AddressSpaceRealm.Uns);
var node = nm.TryGetVariable("eq-1/buf")!;
node.ArrayDimensions![0].ShouldBe(4u); // arrange guard
@@ -122,7 +122,7 @@ public sealed class NodeManagerSurgicalShapeUpdateTests : IDisposable
nm.EnsureVariable("eq-1/buf", parentFolderNodeId: null, displayName: "Buf", dataType: "Int16",
writable: false, historianTagname: null, isArray: true, arrayLength: 4u, realm: AddressSpaceRealm.Uns);
nm.WriteValue("eq-1/buf", new short[] { 1, 2, 3, 4 }, OpcUaQuality.Good, DateTime.UtcNow, realm: AddressSpaceRealm.Uns);
nm.WriteValue("eq-1/buf", new short[] { 1, 2, 3, 4 }, OpcUaStatus.Good, DateTime.UtcNow, realm: AddressSpaceRealm.Uns);
var node = nm.TryGetVariable("eq-1/buf")!;
node.ValueRank.ShouldBe(ValueRanks.OneDimension); // arrange guard
@@ -172,7 +172,7 @@ public sealed class NodeManagerSurgicalShapeUpdateTests : IDisposable
var nm = server.NodeManager!;
nm.EnsureVariable("eq-1/sp", parentFolderNodeId: null, displayName: "Sp", dataType: "Float", writable: false, realm: AddressSpaceRealm.Uns);
nm.WriteValue("eq-1/sp", 7.0f, OpcUaQuality.Good, DateTime.UtcNow, realm: AddressSpaceRealm.Uns);
nm.WriteValue("eq-1/sp", 7.0f, OpcUaStatus.Good, DateTime.UtcNow, realm: AddressSpaceRealm.Uns);
var node = nm.TryGetVariable("eq-1/sp")!;
// Same DataType ("Float") + same scalar shape — only Writable flips false → true.
@@ -52,7 +52,7 @@ public sealed class NodeManagerWriteRevertTests : IDisposable
nm.EnsureVariable("eq-1/sp", parentFolderNodeId: null, displayName: "Sp", dataType: "Int32", writable: true, realm: AddressSpaceRealm.Uns);
// Prior state = Good 7; the SDK then optimistically applied 42 (the write the device will reject).
nm.WriteValue("eq-1/sp", 7, OpcUaQuality.Good, DateTime.UtcNow, realm: AddressSpaceRealm.Uns);
nm.WriteValue("eq-1/sp", 7, OpcUaStatus.Good, DateTime.UtcNow, realm: AddressSpaceRealm.Uns);
var node = nm.TryGetVariable("eq-1/sp")!;
node.Value = 42; // simulate the SDK's optimistic apply of the rejected write
@@ -79,7 +79,7 @@ public sealed class NodeManagerWriteRevertTests : IDisposable
var nm = server.NodeManager!;
nm.EnsureVariable("eq-1/sp", parentFolderNodeId: null, displayName: "Sp", dataType: "Int32", writable: true, realm: AddressSpaceRealm.Uns);
nm.WriteValue("eq-1/sp", 7, OpcUaQuality.Good, DateTime.UtcNow, realm: AddressSpaceRealm.Uns);
nm.WriteValue("eq-1/sp", 7, OpcUaStatus.Good, DateTime.UtcNow, realm: AddressSpaceRealm.Uns);
var node = nm.TryGetVariable("eq-1/sp")!;
node.Value = 42;
@@ -102,7 +102,7 @@ public sealed class NodeManagerWriteRevertTests : IDisposable
var nm = server.NodeManager!;
nm.EnsureVariable("eq-1/sp", parentFolderNodeId: null, displayName: "Sp", dataType: "Int32", writable: true, realm: AddressSpaceRealm.Uns);
nm.WriteValue("eq-1/sp", 7, OpcUaQuality.Good, DateTime.UtcNow, realm: AddressSpaceRealm.Uns);
nm.WriteValue("eq-1/sp", 7, OpcUaStatus.Good, DateTime.UtcNow, realm: AddressSpaceRealm.Uns);
var node = nm.TryGetVariable("eq-1/sp")!;
node.Value = 99; // a fresh poll already republished the confirmed register value (NOT the optimistic 42)
@@ -147,8 +147,8 @@ public sealed class NodeManagerWriteRevertTests : IDisposable
nm.EnsureVariable("A/B/C/D", parentFolderNodeId: null, displayName: "Raw", dataType: "Int32", writable: true, realm: AddressSpaceRealm.Raw);
nm.EnsureVariable("A/B/C/D", parentFolderNodeId: null, displayName: "Uns", dataType: "Int32", writable: true, realm: AddressSpaceRealm.Uns);
var now = DateTime.UtcNow;
nm.WriteValue("A/B/C/D", 10, OpcUaQuality.Good, now, AddressSpaceRealm.Raw); // raw driver value
nm.WriteValue("A/B/C/D", 10, OpcUaQuality.Good, now, AddressSpaceRealm.Uns); // fanned UNS value
nm.WriteValue("A/B/C/D", 10, OpcUaStatus.Good, now, AddressSpaceRealm.Raw); // raw driver value
nm.WriteValue("A/B/C/D", 10, OpcUaStatus.Good, now, AddressSpaceRealm.Uns); // fanned UNS value
var raw = nm.TryGetVariable("A/B/C/D", AddressSpaceRealm.Raw)!;
var uns = nm.TryGetVariable("A/B/C/D", AddressSpaceRealm.Uns)!;
@@ -178,8 +178,8 @@ public sealed class NodeManagerWriteRevertTests : IDisposable
nm.EnsureVariable("A/B/C/D", parentFolderNodeId: null, displayName: "Raw", dataType: "Int32", writable: true, realm: AddressSpaceRealm.Raw);
nm.EnsureVariable("A/B/C/D", parentFolderNodeId: null, displayName: "Uns", dataType: "Int32", writable: true, realm: AddressSpaceRealm.Uns);
var now = DateTime.UtcNow;
nm.WriteValue("A/B/C/D", 10, OpcUaQuality.Good, now, AddressSpaceRealm.Raw);
nm.WriteValue("A/B/C/D", 10, OpcUaQuality.Good, now, AddressSpaceRealm.Uns);
nm.WriteValue("A/B/C/D", 10, OpcUaStatus.Good, now, AddressSpaceRealm.Raw);
nm.WriteValue("A/B/C/D", 10, OpcUaStatus.Good, now, AddressSpaceRealm.Uns);
var raw = nm.TryGetVariable("A/B/C/D", AddressSpaceRealm.Raw)!;
var uns = nm.TryGetVariable("A/B/C/D", AddressSpaceRealm.Uns)!;
@@ -208,7 +208,7 @@ public sealed class NodeManagerWriteRevertTests : IDisposable
var nm = server.NodeManager!;
nm.EnsureVariable("eq-1/sp", parentFolderNodeId: null, displayName: "Sp", dataType: "Int32", writable: true, realm: AddressSpaceRealm.Uns);
nm.WriteValue("eq-1/sp", 7, OpcUaQuality.Good, DateTime.UtcNow, realm: AddressSpaceRealm.Uns);
nm.WriteValue("eq-1/sp", 7, OpcUaStatus.Good, DateTime.UtcNow, realm: AddressSpaceRealm.Uns);
var node = nm.TryGetVariable("eq-1/sp")!;
var audit = nm.BuildWriteFailureAuditEvent(
@@ -239,7 +239,7 @@ public sealed class NodeManagerWriteRevertTests : IDisposable
var nm = server.NodeManager!;
nm.EnsureVariable("eq-1/sp", parentFolderNodeId: null, displayName: "Sp", dataType: "Int32", writable: true, realm: AddressSpaceRealm.Uns);
nm.WriteValue("eq-1/sp", 7, OpcUaQuality.Good, DateTime.UtcNow, realm: AddressSpaceRealm.Uns);
nm.WriteValue("eq-1/sp", 7, OpcUaStatus.Good, DateTime.UtcNow, realm: AddressSpaceRealm.Uns);
var node = nm.TryGetVariable("eq-1/sp")!;
var audit = nm.BuildWriteFailureAuditEvent(
@@ -27,9 +27,9 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
var (host, server) = await BootAsync();
var sink = new SdkAddressSpaceSink(server.NodeManager!);
sink.WriteValue("eq-1/temp", 22.5, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
sink.WriteValue("eq-1/temp", 23.1, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
sink.WriteValue("eq-1/pressure", 100, OpcUaQuality.Uncertain, DateTime.UtcNow, AddressSpaceRealm.Uns);
sink.WriteValue("eq-1/temp", 22.5, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
sink.WriteValue("eq-1/temp", 23.1, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
sink.WriteValue("eq-1/pressure", 100, OpcUaStatus.Uncertain, DateTime.UtcNow, AddressSpaceRealm.Uns);
server.NodeManager!.VariableCount.ShouldBe(2);
@@ -45,7 +45,7 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
var sink = new SdkAddressSpaceSink(server.NodeManager!);
sink.WriteAlarmCondition("alarm-7", Snapshot(active: true, acknowledged: false), DateTime.UtcNow, AddressSpaceRealm.Uns);
sink.WriteValue("eq-1/temp", 22.5, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
sink.WriteValue("eq-1/temp", 22.5, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
server.NodeManager!.VariableCount.ShouldBe(2);
@@ -120,8 +120,8 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
var (host, server) = await BootAsync();
var sink = new SdkAddressSpaceSink(server.NodeManager!);
sink.WriteValue("a", 1, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
sink.WriteValue("b", 2, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
sink.WriteValue("a", 1, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
sink.WriteValue("b", 2, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
sink.WriteAlarmCondition("alarm-c", Snapshot(active: true), DateTime.UtcNow, AddressSpaceRealm.Uns);
server.NodeManager!.VariableCount.ShouldBe(3);
@@ -129,7 +129,7 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
server.NodeManager.VariableCount.ShouldBe(0);
// After rebuild, subsequent writes start fresh.
sink.WriteValue("a", 99, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
sink.WriteValue("a", 99, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
server.NodeManager.VariableCount.ShouldBe(1);
await host.DisposeAsync();
@@ -142,7 +142,7 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
// Sanity check that the F10 fallback still works — production callers default to
// NullOpcUaAddressSpaceSink when no SDK NodeManager is wired.
var sink = NullOpcUaAddressSpaceSink.Instance;
sink.WriteValue("x", 1, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
sink.WriteValue("x", 1, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
sink.WriteAlarmCondition("a", Snapshot(active: true), DateTime.UtcNow, AddressSpaceRealm.Uns);
sink.RebuildAddressSpace();
await Task.CompletedTask;