diff --git a/docs/drivers/S7.md b/docs/drivers/S7.md
index 889d08be..39788f8c 100644
--- a/docs/drivers/S7.md
+++ b/docs/drivers/S7.md
@@ -314,7 +314,7 @@ Portal under *Protection & Security* for the CPU.
| Tag not in config | `BadNodeIdUnknown` | unchanged |
| Read-only tag written | `BadNotWritable` | unchanged |
| Unimplemented data type | `BadNotSupported` | unchanged |
-| PUT/GET denied | `BadNotSupported` | `Faulted` (config alert) |
+| PUT/GET denied | `BadNotSupported` | `Faulted` (config alert) — also counts as *answered*, advances `LastSuccessfulRead` (no watchdog reconnect loop) |
| CPU refusal of the item (object does not exist, address out of range, type inconsistent, hardware fault) | `BadDeviceFailure` | unchanged — counts as *answered*, advances `LastSuccessfulRead`; one Warning per tag |
| Other device-layer fault (`PlcException` that is neither a refusal nor connection-fatal) | `BadDeviceFailure` | `Degraded` |
| Socket / timeout | `BadCommunicationError` | `Degraded` |
@@ -333,7 +333,11 @@ answers without a failure.
**A refusal is an answer (R07:H4).** S7.Net's read paths raise the CPU's item error as a bare
`Exception` ("Received error from PLC: Object does not exist." — live-probed on the snap7 fixture), its
write paths as a `PlcException(WriteData)`; both are classified over the whole exception chain, and the
-access-denied text inside either is the `Faulted` alert. A refusal is deterministic, so it neither
+access-denied text inside either (S7.Net's exact "Accessing object not allowed", and never when the chain
+also carries a transport fault) is the `Faulted` alert — which is ALSO an answer: it advances
+`LastSuccessfulRead`, so a factory-default CPU with PUT/GET disabled stays `Faulted` instead of being
+reconnected by the comms-loss watchdog every 90 s. A multi-var chunk that falls back per tag memoises the
+members refused on their own, like a refused span. A refusal is deterministic, so it neither
degrades health nor reopens the connection, and because it proves the CPU is reachable it advances
`LastSuccessfulRead` and restores `Healthy` after an earlier comms fault. Before this, a driver whose
every tag was refused (a wrong DB number) sat `Degraded` with a frozen timestamp and the comms-loss
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 9f0e98ae..cbf94e6b 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs
@@ -201,6 +201,12 @@ public sealed class S7Driver
/// Clock the connect backoff is measured on. Internal so tests step it deterministically.
internal Func BackoffClock { get; set; } = static () => DateTime.UtcNow;
+ ///
+ /// Clock the read/write passes and the init publish stamp timestamps and
+ /// LastSuccessfulRead with. Internal so a test can step it and assert a STRICT advance.
+ ///
+ internal Func UtcNow { get; set; } = static () => DateTime.UtcNow;
+
///
/// The sticky write-side config fault (access denied / PUT-GET disabled), or null. A read
/// pass that finds everything readable must NOT erase it (reads can succeed while writes are
@@ -364,7 +370,7 @@ public sealed class S7Driver
_connectBackoff.RecordSuccess();
Volatile.Write(ref _negotiatedPduSize, Plc.MaxPduSize);
_initialized = true;
- TryWriteHealth(gen, new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null));
+ TryWriteHealth(gen, new DriverHealth(DriverState.Healthy, UtcNow(), null));
// Kick off the probe loop once the connection is up. Initial HostState stays Unknown
// until the first probe tick succeeds — avoids broadcasting a premature Running
@@ -509,9 +515,18 @@ public sealed class S7Driver
if (ReferenceEquals(_probeCts, probeCts))
{
probeCts?.Dispose();
- _probeCts = null;
- _probeTask = null;
}
+ else
+ {
+ // An initialize published (and started a probe loop) after this release took its
+ // snapshot — a teardown whose generation bump raced the publish block. Cancel that
+ // loop too; it exits on its next gate wait / delay. Not joined: it needs _gate, which
+ // we hold. Its CTS is left to the GC rather than disposed under a running loop.
+ try { _probeCts?.Cancel(); } catch { /* best-effort */ }
+ probeCts?.Dispose();
+ }
+ _probeCts = null;
+ _probeTask = null;
_initialized = false;
_plcDead = false;
try { Plc?.Dispose(); } catch { /* best-effort — tearing down anyway */ }
@@ -693,7 +708,7 @@ public sealed class S7Driver
// InvalidOperationException from the not-initialized check.
ArgumentNullException.ThrowIfNull(fullReferences);
RequireInitialized();
- var now = DateTime.UtcNow;
+ var now = UtcNow();
var results = new DataValueSnapshot[fullReferences.Count];
// Health is decided ONCE, at the end of the pass, from every outcome the pass observed —
// never per item. See ReadPassOutcome.
@@ -749,6 +764,8 @@ public sealed class S7Driver
List? forcedPerTag = null;
// Members of each span refused this pass, and which of them were then refused on their own.
List>? refusedSpans = null;
+ // Members of each multi-var chunk that fell back per tag this pass (R07:P2's multi-var sibling).
+ List>? multiVarFallbacks = null;
HashSet? refusedAlone = null;
if (_options.SpanReadMaxGapBytes >= 0)
@@ -820,7 +837,8 @@ public sealed class S7Driver
if (_options.SpanReadMaxGapBytes >= 0)
{
perTag = await ReadLeftoversByMultiVarAsync(
- plc, resolved, perTag, results, now, outcome, cancellationToken).ConfigureAwait(false);
+ plc, resolved, perTag, results, now, outcome, cancellationToken,
+ fallback => (multiVarFallbacks ??= []).Add(fallback)).ConfigureAwait(false);
}
if (forcedPerTag is not null)
{
@@ -856,13 +874,28 @@ public sealed class S7Driver
}
catch (Exception ex)
{
- if (refusedSpans is not null && IsRefusal(ex)) (refusedAlone ??= []).Add(i);
+ if ((refusedSpans is not null || multiVarFallbacks is not null) && IsRefusal(ex)) (refusedAlone ??= []).Add(i);
results[i] = new DataValueSnapshot(null, RecordReadFailure(ex, outcome, [resolved[i]!.Name]), null, now);
}
}
// Remember what must not be grouped again (R07:P2). Skipped when the handle died mid-pass:
// the per-member reads then proved nothing about which member the CPU refuses.
+ // A multi-var chunk that fell back: memo only the members refused ON THEIR OWN — without one,
+ // the chunk's failure explains nothing about its members, and they keep batching.
+ if (multiVarFallbacks is not null && refusedAlone is not null && !_plcDead)
+ {
+ foreach (var members in multiVarFallbacks)
+ {
+ var bad = members.Where(refusedAlone.Contains).ToList();
+ if (bad.Count == 0) continue;
+ foreach (var m in bad) _noGroup.Add(resolved[m]!.Name);
+ _logger.LogInformation(
+ "S7 CPU refused a multi-var read of {MemberCount} tag(s); reading {Tags} individually from now on. Driver={DriverInstanceId}",
+ members.Count, string.Join(", ", bad.Select(m => resolved[m]!.Name)), _driverInstanceId);
+ }
+ }
+
if (refusedSpans is not null && !_plcDead)
{
foreach (var members in refusedSpans)
@@ -1092,6 +1125,8 @@ public sealed class S7Driver
/// The batch timestamp, shared by every tag in the batch.
/// The pass's outcome accumulator; every decoded item records into it.
/// Cancellation token for the reads.
+ /// Receives the member indexes of each chunk that fell back per tag, so
+ /// the caller can memo the members then refused on their own (the multi-var sibling of R07:P2).
/// The indexes still owed an individual read, ascending.
private async Task> ReadLeftoversByMultiVarAsync(
IS7Plc plc,
@@ -1100,7 +1135,8 @@ public sealed class S7Driver
DataValueSnapshot[] results,
DateTime now,
ReadPassOutcome outcome,
- CancellationToken ct)
+ CancellationToken ct,
+ Action> onChunkFallback)
{
var stillPerTag = new List();
List<(int Index, S7TagDefinition Tag, S7ParsedAddress Addr)>? batchable = null;
@@ -1180,6 +1216,7 @@ public sealed class S7Driver
"S7 driver {DriverInstanceId}: multi-var read of {ItemCount} leftover tag(s) failed; " +
"falling back to per-tag reads for that chunk", _driverInstanceId, chunk.Count);
foreach (var (index, _, _) in chunk) stillPerTag.Add(index);
+ onChunkFallback([.. chunk.Select(c => c.Index)]);
continue;
}
@@ -1429,6 +1466,11 @@ public sealed class S7Driver
// BadNotSupported and flag the driver as a config alert (Faulted) rather than
// Degraded — per driver-specs.md §5. Matched over the whole chain: S7.Net's READ
// paths raise the CPU's item error as a bare Exception, not a PlcException.
+ // It is ALSO an answer from a reachable CPU: LastSuccessfulRead must advance, or a
+ // factory-default CPU (every read denied) trips the comms-loss watchdog into a
+ // reconnect + re-subscribe every 90 s that can never fix a PUT/GET setting.
+ // Faulted still wins the pass (worst state), so the config alert is kept.
+ outcome.RecordAnswered();
outcome.RecordFailure(DriverState.Faulted,
"S7 access denied — enable PUT/GET communication in TIA Portal " +
$"(Protection & Security) for this CPU. PLC reported: {ex.Message}");
@@ -2115,6 +2157,9 @@ public sealed class S7Driver
// configuration fault as on read. BadNotSupported +
// a config-alert health state, not a transient device failure.
results[i] = new WriteResult(StatusBadNotSupported);
+ // An answer (see the read ladder): advances LastSuccessfulRead. RecordAnswered sets
+ // AnyAnswered only, so the sticky _writeFault still clears only on a real success.
+ outcome.RecordAnswered();
outcome.RecordFailure(DriverState.Faulted,
"S7 access denied — enable PUT/GET communication in TIA Portal " +
$"(Protection & Security) for this CPU. PLC reported: {ex.Message}");
@@ -2177,7 +2222,7 @@ public sealed class S7Driver
else if (outcome.AnySuccess)
_writeFault = null; // a write got through — the protection fault is gone
- var answeredAt = outcome.AnyAnswered ? DateTime.UtcNow : (DateTime?)null;
+ var answeredAt = outcome.AnyAnswered ? UtcNow() : (DateTime?)null;
if (worst == DriverState.Healthy)
{
// Clean batch (successes and/or refusals): the CPU answered, so LastSuccessfulRead
@@ -2678,10 +2723,14 @@ public sealed class S7Driver
internal static bool IsAccessDenied(Exception ex)
{
if (ex is not PlcException && !IsS7ItemError(ex)) return false;
+ // A transport fault wins over any wording in the chain: a socket error whose text happens to
+ // contain the phrase must reopen (Degraded), not latch a sticky config alert.
+ if (IsS7ConnectionFatal(ex) || HasCancellationInChain(ex)) return false;
for (Exception? e = ex; e is not null; e = e.InnerException)
{
- if (e.Message.Contains("Accessing object not allowed", StringComparison.OrdinalIgnoreCase)
- || e.Message.Contains("not allowed", StringComparison.OrdinalIgnoreCase))
+ // S7.Net's exact wording for the S7 AccessingObjectNotAllowed status — not the loose "not
+ // allowed", which any unrelated exception text can contain.
+ if (e.Message.Contains("Accessing object not allowed", StringComparison.OrdinalIgnoreCase))
{
return true;
}
@@ -2766,6 +2815,16 @@ public sealed class S7Driver
/// The exception caught by the poll engine.
private void HandlePollError(Exception ex)
{
+ // A retained poll that ran into a teardown / re-initialize IN PROGRESS ("not initialized" while a
+ // lifecycle operation holds _lifecycleGate) belongs to no live generation: that operation owns
+ // health, and writing Degraded over its Initializing (or its final Unknown) would break the "old
+ // work never overwrites newer health" rule. A poll against a driver nobody is initializing (a
+ // subscribe before init, a failed init) still surfaces Degraded (Driver.S7-009).
+ if (!_initialized && _lifecycleGate.CurrentCount == 0)
+ {
+ _logger.LogDebug(ex, "S7 poll reader failed during a lifecycle operation; no health write. Driver={DriverInstanceId}", _driverInstanceId);
+ return;
+ }
_logger.LogWarning(ex, "S7 poll reader failed. Driver={DriverInstanceId}", _driverInstanceId);
TryUpdateHealth(CurrentGeneration, h => h.State == DriverState.Faulted
? null
@@ -2957,6 +3016,7 @@ public sealed class S7Driver
_initialized = false;
_plcDead = false;
_writeFault = null;
+ _writeOwnedError = null;
_connectBackoff.RecordSuccess();
try { Plc?.Dispose(); } catch { /* best-effort — tearing down anyway */ }
Plc = null;
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/S7TagConfigModel.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/S7TagConfigModel.cs
index 723cd369..d02db4d9 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/S7TagConfigModel.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/S7TagConfigModel.cs
@@ -120,10 +120,7 @@ public sealed class S7TagConfigModel
if (TagConfigJson.GetBool(_bag, "isArray"))
{
var dt = DataType;
- if (dt is S7DataType.Int64 or S7DataType.UInt64 or S7DataType.Float64
- or S7DataType.String or S7DataType.DateTime or S7DataType.WString or S7DataType.Char
- or S7DataType.WChar or S7DataType.Dtl or S7DataType.Date or S7DataType.Tod
- or S7DataType.Time or S7DataType.S5Time)
+ if (S7TagShapeRules.IsByteAnchored(dt))
{
return $"S7 does not support {dt} arrays — use Bool (read-only), Byte, Int16, UInt16, Int32, "
+ "UInt32 or Float32 for an array tag, or a scalar tag.";
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/S7_1500/S7_1500RefusalTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/S7_1500/S7_1500RefusalTests.cs
index 709be4c2..79015983 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/S7_1500/S7_1500RefusalTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/S7_1500/S7_1500RefusalTests.cs
@@ -34,6 +34,8 @@ public sealed class S7_1500RefusalTests(Snap7ServerFixture sim)
await using var drv = new S7Driver(options, driverInstanceId: "s7-refusal-live");
await drv.InitializeAsync("{}", ct);
var before = drv.GetHealth().LastSuccessfulRead!.Value;
+ // Let the wall clock move past the init stamp so "advanced" is asserted strictly (review m-1).
+ while (DateTime.UtcNow <= before) await Task.Yield();
// Read the refused tag ALONE first: an all-refused pass is exactly the case that used to
// freeze LastSuccessfulRead and latch Degraded.
@@ -42,7 +44,7 @@ public sealed class S7_1500RefusalTests(Snap7ServerFixture sim)
refused[0].Value.ShouldBeNull();
var health = drv.GetHealth();
health.State.ShouldBe(DriverState.Healthy, "a CPU refusal is not a comms fault");
- health.LastSuccessfulRead!.Value.ShouldBeGreaterThanOrEqualTo(before);
+ health.LastSuccessfulRead!.Value.ShouldBeGreaterThan(before, "the refusal is an answer: the stamp advances");
// Positive control: the same connection still serves the seeded neighbour.
var good = await drv.ReadAsync([S7_1500Profile.ProbeTag], ct);
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7LifecycleGateTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7LifecycleGateTests.cs
index d856e307..1c99a61e 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7LifecycleGateTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7LifecycleGateTests.cs
@@ -115,6 +115,69 @@ public sealed class S7LifecycleGateTests
(await drv.ReadAsync(["W0"], ct))[0].StatusCode.ShouldBe(0u);
}
+ ///
+ /// Review m-4: a retained poll that runs into a re-initialize in progress ("not initialized")
+ /// must not write Degraded over the new attempt's Initializing. Waits on the poll's own log line
+ /// (a counted signal), then asserts; the control is that a successful init then reports Healthy.
+ ///
+ [Fact]
+ public async Task A_retained_poll_does_not_overwrite_a_reinitialize_in_progress()
+ {
+ var ct = TestContext.Current.CancellationToken;
+ var factory = new ScriptedS7PlcFactory();
+ var secondOpenEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var releaseSecondOpen = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ factory.Open = async (index, token) =>
+ {
+ if (index != 1) return;
+ secondOpenEntered.TrySetResult();
+ await releaseSecondOpen.Task.WaitAsync(token);
+ };
+ var log = new CapturingS7Logger();
+ using var drv = new S7Driver(Options(), "s7-retained-poll", factory, log);
+ await drv.InitializeAsync("{}", ct);
+ await drv.SubscribeAsync(["W0"], TimeSpan.FromMilliseconds(100), ct);
+
+ var reinit = drv.InitializeAsync("{}", ct);
+ await secondOpenEntered.Task.WaitAsync(Bound, ct);
+
+ using (var cts = CancellationTokenSource.CreateLinkedTokenSource(ct))
+ {
+ cts.CancelAfter(Bound);
+ while (log.Entries.Count(e => e.Message.Contains("during a lifecycle operation")) < 2)
+ await Task.Delay(20, cts.Token); // condition wait on the poll's counted log line
+ }
+ drv.GetHealth().State.ShouldBe(DriverState.Initializing, "a retained poll must not write over the new attempt");
+
+ releaseSecondOpen.SetResult();
+ await reinit.WaitAsync(Bound, ct);
+ drv.GetHealth().State.ShouldBe(DriverState.Healthy);
+ }
+
+ ///
+ /// Review m-8: the SYNCHRONOUS Dispose() racing a still-running ShutdownAsync is safe too
+ /// — neither throws, the superseded init publishes nothing, and the driver ends torn down.
+ ///
+ [Fact]
+ public async Task Sync_dispose_while_a_shutdown_is_still_running_is_safe()
+ {
+ var ct = TestContext.Current.CancellationToken;
+ var f = new FirstOpenBlocks();
+ var drv = new S7Driver(Options(), "s7-sync-dispose-race", f.Factory) { LifecycleGateTimeout = TimeSpan.FromSeconds(1) };
+
+ var init = drv.InitializeAsync("{}", ct);
+ await f.FirstOpenEntered.Task.WaitAsync(Bound, ct);
+
+ var shutdown = drv.ShutdownAsync(ct); // waits on the gate the blocked init holds
+ Should.NotThrow(() => drv.Dispose());
+ await shutdown.WaitAsync(Bound, ct);
+
+ f.ReleaseFirstOpen.SetResult();
+ await Should.ThrowAsync(() => init.WaitAsync(Bound, ct));
+ f.Factory.Created.ShouldHaveSingleItem().Disposed.ShouldBeTrue();
+ drv.GetHealth().State.ShouldBe(DriverState.Unknown);
+ }
+
///
/// Dispose racing a shutdown that is still running is safe: neither throws, and the driver ends
/// torn down. The semaphores are no longer disposed, so the running shutdown cannot hit an
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7RefusalHealthTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7RefusalHealthTests.cs
index 06aa3277..8c7c5182 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7RefusalHealthTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7RefusalHealthTests.cs
@@ -38,13 +38,24 @@ public sealed class S7RefusalHealthTests
new S7TagDefinition("Good", "DB1.DBW0", S7DataType.UInt16)),
};
- private static async Task<(S7Driver Driver, ScriptedS7PlcFactory Factory, CapturingS7Logger Log)> StartAsync()
+ /// A stepped pass clock, so "LastSuccessfulRead advanced" is asserted STRICTLY.
+ private sealed class PassClock
+ {
+ public DateTime Now { get; private set; } = new(2026, 10, 22, 12, 0, 0, DateTimeKind.Utc);
+
+ /// Moves the clock one second on and returns the new time.
+ public DateTime Step() => Now = Now.AddSeconds(1);
+ }
+
+ private static async Task<(S7Driver Driver, ScriptedS7PlcFactory Factory, CapturingS7Logger Log, PassClock Clock)> StartAsync()
{
var factory = new ScriptedS7PlcFactory();
var log = new CapturingS7Logger();
- var drv = new S7Driver(Options(), "s7-refusal", factory, log);
+ var clock = new PassClock();
+ var drv = new S7Driver(Options(), "s7-refusal", factory, log) { UtcNow = () => clock.Now };
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
- return (drv, factory, log);
+ drv.GetHealth().LastSuccessfulRead.ShouldBe(clock.Now, "init stamps the pass clock");
+ return (drv, factory, log, clock);
}
/// A refused tag is Bad, health stays Healthy, LastSuccessfulRead advances, and it warns once.
@@ -52,20 +63,19 @@ public sealed class S7RefusalHealthTests
public async Task A_read_refusal_is_per_tag_bad_with_no_health_impact_and_one_warning()
{
var ct = TestContext.Current.CancellationToken;
- var (drv, factory, log) = await StartAsync();
+ var (drv, factory, log, clock) = await StartAsync();
using var _ = drv;
factory.Read = (addr, _) => addr == "DB99.DBW0" ? throw ReadRefusal() : Task.FromResult