diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs index 82f8516b..60926e7c 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs @@ -672,10 +672,17 @@ public sealed class S7Driver // arms abandon the batch mid-pass, and a partially-observed pass must not stamp a // verdict. LastSuccessfulRead advances whenever ANYTHING read, even into a // Degraded/Faulted state — see ReadPassOutcome for why that is load-bearing. - _health = new DriverHealth( - outcome.WorstFailure, - outcome.AnySuccess ? now : _health.LastSuccessfulRead, - outcome.WorstFailure == DriverState.Healthy ? null : outcome.FirstError); + // + // A pass that observed NOTHING — an empty batch, or one whose every reference was + // unresolvable — reports nothing: it writes no health at all, preserving a prior + // Faulted (e.g. the access-denied config alert) that a Healthy write would have erased. + if (outcome.AnySuccess || outcome.WorstFailure != DriverState.Healthy) + { + _health = new DriverHealth( + outcome.WorstFailure, + outcome.AnySuccess ? now : _health.LastSuccessfulRead, + outcome.WorstFailure == DriverState.Healthy ? null : outcome.FirstError); + } } finally { _gate.Release(); } return results; diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7SpanReadParityTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7SpanReadParityTests.cs index b55761b5..454a2798 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7SpanReadParityTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7SpanReadParityTests.cs @@ -555,6 +555,44 @@ public sealed class S7SpanReadParityTests before!.Value, "one tag read fine, so the watchdog's evidence must still advance"); } + /// + /// A pass that observes NOTHING — every reference unresolvable, so not one tag was read and + /// not one failed — reports nothing: it must leave the driver's prior Faulted config + /// alert (and its LastSuccessfulRead) exactly as it found them. + /// + /// + /// Deciding health once per pass means the pass has to answer "what did I observe?", and a + /// degenerate pass observed nothing at all — writing Healthy for it would invent + /// evidence and silently clear a PUT/GET config alert an operator still has to act on. The + /// per-item health writes this replaced had that property for free (no item, no write); the + /// end-of-pass guard is what preserves it. + /// + [Fact] + public async Task A_pass_that_observes_nothing_leaves_a_prior_Faulted_untouched() + { + var ct = TestContext.Current.CancellationToken; + var factory = new ImageS7PlcFactory(Images()); + factory.PerTagFaults["DB1.DBW0"] = new PlcException(ErrorCode.ReadData, "Accessing object not allowed"); + using var driver = new S7Driver(Options(NarrowTags(), maxGap: -1), "s7-observes-nothing", factory); + await driver.InitializeAsync("{}", ct); + + // Drive the driver into the access-denied config alert first. + _ = await driver.ReadAsync(["A"], ct); + var faulted = driver.GetHealth(); + faulted.State.ShouldBe(DriverState.Faulted); + + // Now a pass whose every reference is unresolvable: they are stamped BadNodeIdUnknown + // before the planner ever sees them, so no span, no multi-var item and no per-tag read + // happens — the pass observes neither a success nor a failure. + var results = await driver.ReadAsync(["NoSuchTag", "AlsoMissing"], ct); + results.ShouldAllBe(s => s.StatusCode == StatusBadNodeIdUnknown); + + var after = driver.GetHealth(); + after.State.ShouldBe(DriverState.Faulted, "an observe-nothing pass must not clear the config alert"); + after.LastError.ShouldBe(faulted.LastError); + after.LastSuccessfulRead.ShouldBe(faulted.LastSuccessfulRead, "nothing was read, so nothing advances"); + } + /// /// An answer of the wrong SHAPE — one item short — cannot be mapped back onto the request's /// indexes, so the driver refuses to guess which tag got which value and falls the whole