#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:
+6
-6
@@ -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));
|
||||
|
||||
+5
-5
@@ -54,7 +54,7 @@ public sealed class DriverHostActorLiveValueTests : RuntimeActorTestBase
|
||||
|
||||
// The driver publishes by its wire-ref RawPath.
|
||||
actor.Tell(new DriverInstanceActor.AttributeValuePublished(
|
||||
"drv-1", "Plant/Modbus/dev1/speed", 42.0, OpcUaQuality.Good, Ts));
|
||||
"drv-1", "Plant/Modbus/dev1/speed", 42.0, OpcUaStatus.Good, Ts));
|
||||
|
||||
var a = publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>(TimeSpan.FromSeconds(5));
|
||||
var b = publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>(TimeSpan.FromSeconds(5));
|
||||
@@ -68,7 +68,7 @@ public sealed class DriverHostActorLiveValueTests : RuntimeActorTestBase
|
||||
foreach (var u in byId.Values)
|
||||
{
|
||||
u.Value.ShouldBe(42.0);
|
||||
u.Quality.ShouldBe(OpcUaQuality.Good);
|
||||
u.StatusCode.ShouldBe(OpcUaStatus.Good);
|
||||
u.TimestampUtc.ShouldBe(Ts);
|
||||
}
|
||||
|
||||
@@ -89,7 +89,7 @@ public sealed class DriverHostActorLiveValueTests : RuntimeActorTestBase
|
||||
var (actor, publish, _) = SpawnHostAndApply(db, deploymentId);
|
||||
|
||||
actor.Tell(new DriverInstanceActor.AttributeValuePublished(
|
||||
"drv-1", "Plant/Modbus/dev1/speed", 5.0, OpcUaQuality.Good, Ts));
|
||||
"drv-1", "Plant/Modbus/dev1/speed", 5.0, OpcUaStatus.Good, Ts));
|
||||
|
||||
var only = publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>(TimeSpan.FromSeconds(5));
|
||||
only.NodeId.ShouldBe("Plant/Modbus/dev1/speed");
|
||||
@@ -114,7 +114,7 @@ public sealed class DriverHostActorLiveValueTests : RuntimeActorTestBase
|
||||
var (actor, publish, _) = SpawnHostAndApply(db, deploymentId);
|
||||
|
||||
actor.Tell(new DriverInstanceActor.AttributeValuePublished(
|
||||
"drv-1", "Plant/Modbus/dev1/speed", 7.0, OpcUaQuality.Good, Ts));
|
||||
"drv-1", "Plant/Modbus/dev1/speed", 7.0, OpcUaStatus.Good, Ts));
|
||||
|
||||
var updates = new[]
|
||||
{
|
||||
@@ -144,7 +144,7 @@ public sealed class DriverHostActorLiveValueTests : RuntimeActorTestBase
|
||||
var (actor, publish, mux) = SpawnHostAndApply(db, deploymentId);
|
||||
|
||||
actor.Tell(new DriverInstanceActor.AttributeValuePublished(
|
||||
"drv-1", "Plant/Modbus/dev1/nope", 99.0, OpcUaQuality.Good, Ts));
|
||||
"drv-1", "Plant/Modbus/dev1/nope", 99.0, OpcUaStatus.Good, Ts));
|
||||
|
||||
publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
||||
mux.ExpectMsg<DriverInstanceActor.AttributeValuePublished>(TimeSpan.FromSeconds(5))
|
||||
|
||||
+4
-3
@@ -283,18 +283,19 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
|
||||
|
||||
var (actor, publish) = SpawnHostAndApply(db, deploymentId);
|
||||
|
||||
// Comms lost → the raw condition is annotated Bad.
|
||||
// Comms lost → the raw condition is annotated BadNoCommunication (#519: the reason survives to
|
||||
// the client; this used to be a bare Bad indistinguishable from any other fault).
|
||||
actor.Tell(new DriverInstanceActor.ConnectivityChanged("drv-1", Connected: false));
|
||||
var bad = publish.ExpectMsg<OpcUaPublishActor.AlarmQualityUpdate>(TimeSpan.FromSeconds(5));
|
||||
bad.AlarmNodeId.ShouldBe(AlarmRawPath);
|
||||
bad.Realm.ShouldBe(AddressSpaceRealm.Raw);
|
||||
bad.Quality.ShouldBe(OpcUaQuality.Bad);
|
||||
bad.StatusCode.ShouldBe(OpcUaStatus.BadNoCommunication);
|
||||
|
||||
// Comms restored → annotated Good.
|
||||
actor.Tell(new DriverInstanceActor.ConnectivityChanged("drv-1", Connected: true));
|
||||
var good = publish.ExpectMsg<OpcUaPublishActor.AlarmQualityUpdate>(TimeSpan.FromSeconds(5));
|
||||
good.AlarmNodeId.ShouldBe(AlarmRawPath);
|
||||
good.Quality.ShouldBe(OpcUaQuality.Good);
|
||||
good.StatusCode.ShouldBe(OpcUaStatus.Good);
|
||||
}
|
||||
|
||||
/// <summary>#477 Layer 2 — connectivity for a DIFFERENT driver instance annotates none of this driver's
|
||||
|
||||
@@ -122,18 +122,29 @@ public sealed class DriverInstanceActorTests : RuntimeActorTestBase
|
||||
TimeSpan.FromSeconds(3));
|
||||
|
||||
// Driver fires an OnDataChange — actor should forward it to its parent as
|
||||
// AttributeValuePublished with Quality mapped from StatusCode.
|
||||
// AttributeValuePublished carrying the driver's status code.
|
||||
driver.FireDataChange("tag-a", value: 3.14, statusCode: 0u);
|
||||
|
||||
var published = parent.ExpectMsg<DriverInstanceActor.AttributeValuePublished>(TimeSpan.FromSeconds(2));
|
||||
published.FullReference.ShouldBe("tag-a");
|
||||
published.Value.ShouldBe(3.14);
|
||||
published.Quality.ShouldBe(OpcUaQuality.Good);
|
||||
published.StatusCode.ShouldBe(OpcUaStatus.Good);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that subscribe translates OPC UA status severity bits to OpcUaQuality.</summary>
|
||||
/// <summary>
|
||||
/// Gitea #519 — a driver's status code reaches the parent <b>with its sub-code intact</b>.
|
||||
/// <para>This is the first of the two hops that used to destroy it: the actor projected the
|
||||
/// driver's <c>uint</c> onto a 3-state Good/Uncertain/Bad enum, and the node manager then
|
||||
/// re-expanded Bad to a bare <c>0x80000000</c>. The net effect was that no driver's sub-code ever
|
||||
/// reached an OPC UA client — <c>BadNoCommunication</c>, <c>BadTypeMismatch</c> and
|
||||
/// <c>BadNotSupported</c> were indistinguishable — while every driver maintained its own named
|
||||
/// constants (16 of which #497 had just corrected) as if they mattered end to end.</para>
|
||||
/// <para>The severity buckets are asserted too, since the bucket is still what
|
||||
/// <see cref="OpcUaStatus.IsGood"/> and friends classify on; what changed is that the bucket is no
|
||||
/// longer <em>all</em> that survives.</para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Subscribe_translates_OPC_UA_status_severity_bits_to_OpcUaQuality()
|
||||
public async Task Subscribe_forwards_the_drivers_status_sub_code_without_collapsing_it()
|
||||
{
|
||||
var driver = new SubscribableStubDriver();
|
||||
var parent = CreateTestProbe();
|
||||
@@ -151,11 +162,25 @@ public sealed class DriverInstanceActorTests : RuntimeActorTestBase
|
||||
|
||||
// Uncertain — severity bits 01 (top 2 bits = 01).
|
||||
driver.FireDataChange("tag-1", value: 1, statusCode: 0x40000000u);
|
||||
parent.ExpectMsg<DriverInstanceActor.AttributeValuePublished>().Quality.ShouldBe(OpcUaQuality.Uncertain);
|
||||
parent.ExpectMsg<DriverInstanceActor.AttributeValuePublished>().StatusCode.ShouldBe(OpcUaStatus.Uncertain);
|
||||
|
||||
// Bad — severity bits 10.
|
||||
driver.FireDataChange("tag-1", value: 2, statusCode: 0x80000000u);
|
||||
parent.ExpectMsg<DriverInstanceActor.AttributeValuePublished>().Quality.ShouldBe(OpcUaQuality.Bad);
|
||||
parent.ExpectMsg<DriverInstanceActor.AttributeValuePublished>().StatusCode.ShouldBe(OpcUaStatus.Bad);
|
||||
|
||||
// The regression itself: a Bad SUB-code must arrive intact, not flattened to OpcUaStatus.Bad.
|
||||
// This is the exact code MTConnect maps the Agent's UNAVAILABLE sentinel to, and the live gate
|
||||
// that opened #519 observed it reaching a client as 0x80000000.
|
||||
driver.FireDataChange("tag-1", value: null, statusCode: OpcUaStatus.BadNoCommunication);
|
||||
var noComms = parent.ExpectMsg<DriverInstanceActor.AttributeValuePublished>();
|
||||
noComms.StatusCode.ShouldBe(OpcUaStatus.BadNoCommunication);
|
||||
noComms.StatusCode.ShouldNotBe(OpcUaStatus.Bad, "the sub-code was collapsed to a generic Bad");
|
||||
OpcUaStatus.IsBad(noComms.StatusCode).ShouldBeTrue("it must still classify as Bad");
|
||||
|
||||
// A second, DIFFERENT Bad sub-code — the two were indistinguishable before the fix, so asserting
|
||||
// one alone would not prove the sub-code is carried rather than hard-coded.
|
||||
driver.FireDataChange("tag-1", value: null, statusCode: 0x80740000u /* BadTypeMismatch */);
|
||||
parent.ExpectMsg<DriverInstanceActor.AttributeValuePublished>().StatusCode.ShouldBe(0x80740000u);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -163,7 +163,7 @@ public class NativeAlarmProjectorTests
|
||||
|
||||
var snap = sut.Project("n1", Evt(AlarmTransitionKind.Raise));
|
||||
|
||||
snap.Quality.ShouldBe(OpcUaQuality.Good);
|
||||
snap.StatusCode.ShouldBe(OpcUaStatus.Good);
|
||||
}
|
||||
|
||||
private static AlarmEventArgs Evt(
|
||||
|
||||
Reference in New Issue
Block a user