#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:
+2
-2
@@ -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)
|
||||
{
|
||||
|
||||
+2
-2
@@ -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) { }
|
||||
|
||||
+5
-5
@@ -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 => 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
|
||||
{
|
||||
|
||||
+5
-5
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user