diff --git a/docs/drivers/S7.md b/docs/drivers/S7.md
index 02d27885..39788f8c 100644
--- a/docs/drivers/S7.md
+++ b/docs/drivers/S7.md
@@ -88,6 +88,9 @@ After the handshake, Test Connect reads 2 bytes from `MB0`. A CPU with PUT/GET d
default) completes the handshake but denies every read, so the probe fails with an actionable "enable
PUT/GET communication" message instead of a false green tick. A CPU that merely rejects that address (no
`MB0`) still passes — the connection is fine; any other read fault fails the probe with its message.
+Both are recognised in the bare-`Exception` shape S7.Net's read path really raises. The handshake and
+test read are bounded by the config's `timeoutMs` (the driver's own per-operation timeout), capped by the
+Test Connect timeout — before R07:H7 the config value was ignored.
## Address forms
@@ -103,9 +106,18 @@ Addresses use Siemens TIA-Portal / STEP 7 Classic syntax, parsed by
| Timer | `T0` / `T15` | Timer area (read-only — see Wide types section) |
| Counter | `C0` / `C10` | Counter area (read-only — see Wide types section) |
-Parsing is strict and runs once at `InitializeAsync` so a config typo fails fast
-at load instead of surfacing as `BadInternalError` on every read. Bit offsets
-must be 0-7, byte offsets non-negative, DB numbers >= 1.
+Parsing is strict. Bit offsets must be 0-7, byte offsets non-negative, DB numbers >= 1.
+
+**Tag shape rules are judged per tag, at every gate** (`S7TagShapeRules`, called by
+`S7TagDefinitionFactory.FromTagConfig`): the AdminUI editor refuses the tag, the deploy gate
+(`Inspect`) warns, and the driver's init **skips** it (reported through `ISkippedTagReporter`; it reads
+`BadNodeIdUnknown`) while every other tag keeps working. Refused: an address that does not parse, a
+wide/structured type that is an array or is not byte-addressed (`DBB`/`MB`/`IB`/`QB`), a narrow type at the
+wrong access width (`Int32` on `DBW0`, `Bool` on `DBB0`...), a Timer not typed `Float64` or a Counter not typed
+`Int32`, a writable or array Timer/Counter, a writable `Bool` array, and a `Bool` array that does not start
+at bit 0 (a Bool array reads bit 0 of each byte, so `DB1.DBX0.3` used to silently return bit 0 of bytes
+0..n). These used to be init guards that THREW, so one typo faulted every tag on the PLC and the actor
+retried the same init every 10 s forever (R07:H3).
## Data types
@@ -277,7 +289,7 @@ AdminUI `/hosts` page (Gitea #640). See `docs/Telemetry.md` ("Driver diagnostics
| `IWritable` | `WriteAsync` → `S7.Net.Plc.WriteAsync` | Read-only tags (`Writable=false`) return `BadNotWritable` |
| `ITagDiscovery` | `DiscoverAsync` | Emits a flat `S7/` folder of the configured tags — no live browse |
| `ISubscribable` | per-tag poll loop with capped exponential backoff | S7 has no push model; floor is 100 ms (the CPU services the comms mailbox once per scan) |
-| `IHostConnectivityProbe` | periodic `S7.Net.Plc.ReadStatusAsync` (CPU-status PDU) | `host:port` host key; `Running`/`Stopped` transitions raise `OnHostStatusChanged` |
+| `IHostConnectivityProbe` | periodic `S7.Net.Plc.ReadStatusAsync` (CPU-status PDU) | `host:port` host key; `Running`/`Stopped` transitions raise `OnHostStatusChanged`. A tick that finds the gate held past the probe deadline (a long read pass) gives **no verdict** — it no longer reports `Stopped` for a connection that is busy, not lost (R07:H7) |
### Single-connection policy
@@ -302,8 +314,9 @@ 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) |
-| CPU / hardware fault | `BadDeviceFailure` | `Degraded` |
+| 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` |
| Reconnect suppressed (backoff window) | `BadCommunicationError` | `Degraded` |
| Array write of the wrong shape / length / element type | `BadTypeMismatch` | unchanged |
@@ -314,7 +327,36 @@ accumulate their outcomes and write health once (`Healthy < Degraded < Faulted`)
`Faulted` item is not erased by a later `Degraded` or successful sibling. The write-side `Faulted` is
**sticky against reads**: a clean read pass (reads often work against a write-protected CPU) leaves
`Faulted` in place and only advances `LastSuccessfulRead`; the next write that succeeds clears it. A
-transient `Degraded` write failure is still cleared by a clean read, as before.
+transient `Degraded` write failure is cleared by a clean read, or by the next write batch the CPU
+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 (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
+watchdog reconnected it every 90 s against a perfectly reachable PLC.
+
+**A dead handle ends the pass's wire traffic (R07:P1).** Once a read pass or write batch marks the
+connection dead (a wire op blew its deadline on a frozen-but-established peer, a socket dropped), the
+remaining spans / multi-var chunks / tags / writes of that pass are stamped `BadCommunicationError`
+without I/O; the next call reopens. A multi-var chunk that dies on a transport loss no longer falls back
+to one doomed per-tag read per member. Without this an op that ignores its cancellation token would cost
+one full deadline per remaining item under the gate, and `Degraded` (and so the comms-loss watchdog's
+clock) only became visible after the whole pass.
+
+**A refused span falls back per member (R07:P2).** A grouped block read covers the union of its members'
+ranges, so one member the CPU refuses (running past the DB's real end, an over-declared `stringLength`)
+used to make every neighbour in the block Bad on every poll. When the CPU refuses a multi-member span the
+driver re-reads its members one at a time for that pass and remembers which tags must not be grouped —
+the members refused on their own, or every member when none was — so later passes group only the rest.
+The memo lasts until the next initialize. A span failure that is not a refusal still stamps every member.
**Connect backoff.** A dead PLC costs one connect attempt per backoff window (1 s doubling to a 30 s
cap), not one per read / poll tick / probe tick: inside the window `EnsureConnectedAsync` fails fast
@@ -359,7 +401,7 @@ normal dev (the S7 sim is on the shared Docker host but currently offline).
**Array writes (S7-016)** — a writable `Byte` / `Int16` / `UInt16` / `Int32` / `UInt32` / `Float32` array
is written as ONE contiguous `WriteBytesAsync` of `count x element-bytes` (the inverse of the read
decode), and the value must be an array of exactly `arrayLength` elements (else `BadTypeMismatch`, no
-health impact). **Writable `Bool` arrays are refused at init** (`NotSupportedException`): the array
+health impact). **Writable `Bool` arrays are refused per tag** (see the tag shape rules above): the array
read is byte-granular, so a matching write would overwrite the 7 neighbouring bits of every byte it
touches, and a safe bit-packed write needs read-modify-write. Mark Bool arrays `writable: false`. A `null` array element is rejected (`BadTypeMismatch`, nothing written)
rather than coerced to 0. An array write larger than one PDU is split by S7.Net into several writes and is
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7AddressParser.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/S7AddressParser.cs
similarity index 100%
rename from src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7AddressParser.cs
rename to src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/S7AddressParser.cs
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/S7TagDefinitionFactory.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/S7TagDefinitionFactory.cs
index a10c2c7d..94457fa5 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/S7TagDefinitionFactory.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/S7TagDefinitionFactory.cs
@@ -48,8 +48,23 @@ public static class S7TagDefinitionFactory
/// The mapped definition when this returns .
/// when is a valid S7 address object.
public static bool FromTagConfig(string tagConfig, string rawPath, out S7TagDefinition def)
+ => FromTagConfig(tagConfig, rawPath, out def, out _);
+
+ ///
+ /// , also reporting WHY a structurally
+ /// valid blob was refused by (an unparseable address, or a shape the
+ /// driver cannot serve). stays for a JSON-level
+ /// refusal (missing keys, bad enum), which explains on its own.
+ ///
+ /// The authored equipment-tag TagConfig JSON.
+ /// The tag's RawPath — becomes the definition's identity (Name).
+ /// The mapped definition when this returns .
+ /// The shape/address rejection reason, when that is why it refused.
+ /// when the driver can serve the tag.
+ public static bool FromTagConfig(string tagConfig, string rawPath, out S7TagDefinition def, out string? shapeRejection)
{
def = null!;
+ shapeRejection = null;
if (string.IsNullOrWhiteSpace(tagConfig)) return false;
try
{
@@ -78,7 +93,7 @@ public static class S7TagDefinitionFactory
// "writable" defaults to true when absent (today's value); node-level authz still governs
// writes. Honouring the key makes read-only equipment tags authorable.
var writable = TagConfigJson.ReadWritable(root);
- def = new S7TagDefinition(
+ var mapped = new S7TagDefinition(
Name: rawPath,
Address: address,
DataType: dataType,
@@ -86,6 +101,15 @@ public static class S7TagDefinitionFactory
StringLength: stringLength == 0 ? MaxStringLength : stringLength,
WriteIdempotent: false,
ArrayCount: arrayCount);
+ // The address must parse and the shape must be one the driver can serve. These used to be
+ // init-time guards that THREW and faulted every tag on the PLC; refusing here skips only
+ // this tag, and the editor + deploy gate (which ask this method) refuse it too.
+ if (S7TagShapeRules.Validate(mapped, out _) is { } reason)
+ {
+ shapeRejection = reason;
+ return false;
+ }
+ def = mapped;
return true;
}
catch (JsonException) { return false; }
@@ -162,7 +186,9 @@ public static class S7TagDefinitionFactory
/// reject — a structurally unparseable or non-object document, an absent / non-string /
/// blank address, an absentdataType (see ), a
/// present-but-invalid dataType (the runtime enum read is STRICT, so a
- /// typo rejects the tag), or an out-of-range stringLength on a String tag. Such a tag
+ /// typo rejects the tag), an out-of-range stringLength on a String tag, or a shape
+ /// refuses (an unparseable address, a wrong access width, a mis-typed
+ /// Timer/Counter, an unsupported array). Such a tag
/// is skipped by the driver and reads BadNodeIdUnknown forever. Empty when the mapper accepts
/// the blob, when it is blank, or when it is not an equipment-tag object. Never throws.
///
@@ -184,7 +210,13 @@ public static class S7TagDefinitionFactory
// The accept gate: whatever the driver reads draws nothing. The RAW reference is handed to the
// mapper — the driver reads the stored blob, not a trimmed copy of it.
- if (FromTagConfig(reference, InspectionRawPath, out _)) return warnings;
+ if (FromTagConfig(reference, InspectionRawPath, out _, out var shapeRejection)) return warnings;
+
+ if (shapeRejection is not null)
+ {
+ warnings.Add(shapeRejection + " The driver will skip this tag and it will read BadNodeIdUnknown.");
+ return warnings;
+ }
try
{
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/S7TagShapeRules.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/S7TagShapeRules.cs
new file mode 100644
index 00000000..17a9bf48
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/S7TagShapeRules.cs
@@ -0,0 +1,149 @@
+namespace ZB.MOM.WW.OtOpcUa.Driver.S7;
+
+///
+/// The single authority on which S7 tag shapes the driver can serve. Consulted by
+/// , so the AdminUI editor, the deploy gate
+/// () and the driver's own init all refuse the same
+/// tags with the same words.
+///
+///
+///
+/// These rules used to be init-time guards in S7Driver that THREW. One mis-authored tag
+/// (DB1.DBZ0, DB1.DBW0 typed Float64, T5 typed Int32) faulted the
+/// whole driver, and the actor retried that same init every 10 s forever, darkening every tag on
+/// the PLC. The editor and the deploy gate checked none of it, and a CSV import or API blob
+/// bypasses the editor entirely. A rejected tag is now skipped on its own (it reads
+/// BadNodeIdUnknown) and the deploy gate warns about it.
+///
+///
+/// Every rule rejects a tag that could never be served correctly. None of them can newly break
+/// a tag that used to work: each one either faulted init, or failed (or silently mis-decoded)
+/// every read of that tag.
+///
+///
+public static class S7TagShapeRules
+{
+ ///
+ /// S7 data types addressed by START BYTE (the B suffix) rather than by an access-width
+ /// suffix. The driver decodes them from a contiguous byte buffer, so a wide tag authored against a
+ /// non-byte suffix (DBW/DBD/...) would mis-frame its value.
+ ///
+ private static readonly HashSet ByteAnchoredDataTypes =
+ [
+ S7DataType.Int64,
+ S7DataType.UInt64,
+ S7DataType.Float64,
+ S7DataType.String,
+ S7DataType.DateTime,
+ S7DataType.WString,
+ S7DataType.Char,
+ S7DataType.WChar,
+ S7DataType.Dtl,
+ S7DataType.Date,
+ S7DataType.Tod,
+ S7DataType.Time,
+ S7DataType.S5Time,
+ ];
+
+ /// True when is a wide/structured type read from a byte-anchored block.
+ /// The declared data type.
+ /// for the byte-anchored types.
+ public static bool IsByteAnchored(S7DataType dataType) => ByteAnchoredDataTypes.Contains(dataType);
+
+ ///
+ /// Judges one tag definition. Returns when the driver can serve it, or
+ /// the operator-facing reason it cannot.
+ ///
+ /// The mapped tag definition.
+ /// The parsed address when this returns .
+ /// The rejection reason, or when the tag is servable.
+ public static string? Validate(S7TagDefinition def, out S7ParsedAddress parsed)
+ {
+ ArgumentNullException.ThrowIfNull(def);
+ parsed = default;
+
+ S7ParsedAddress addr;
+ try
+ {
+ addr = S7AddressParser.Parse(def.Address);
+ }
+ catch (FormatException ex)
+ {
+ return $"S7 address '{def.Address}' does not parse: {ex.Message}";
+ }
+
+ var isWide = IsByteAnchored(def.DataType);
+ var isArray = def.ArrayCount is >= 1;
+
+ // Wide-type arrays have no decoder.
+ if (isWide && isArray)
+ return $"S7 {def.DataType} arrays are not supported. Use a scalar {def.DataType} tag, or an " +
+ "Int16/UInt16/Int32/UInt32/Float32/Byte/Bool element type for an array.";
+
+ // Timer / Counter type constraints: a timer decodes to Float64 seconds, a counter to an Int32 count.
+ if (addr.Area is S7Area.Timer && def.DataType != S7DataType.Float64)
+ return $"S7 Timer address '{def.Address}' is typed {def.DataType}. Timer tags must be " +
+ "DataType=Float64 (decoded to seconds, read-only).";
+ if (addr.Area is S7Area.Counter && def.DataType != S7DataType.Int32)
+ return $"S7 Counter address '{def.Address}' is typed {def.DataType}. Counter tags must be " +
+ "DataType=Int32 (decoded to a count, read-only).";
+ if (addr.Area is S7Area.Timer or S7Area.Counter)
+ {
+ if (isArray)
+ return $"S7 Timer/Counter address '{def.Address}' cannot be an array.";
+ if (def.Writable)
+ return $"S7 Timer/Counter address '{def.Address}' is declared writable. Timer/Counter tags " +
+ "are read-only; set Writable=false.";
+ parsed = addr;
+ return null;
+ }
+
+ // Wide/structured scalars on DB/M/I/Q must be byte-anchored (DBB/MB/IB/QB).
+ if (isWide)
+ {
+ if (addr.Size != S7Size.Byte)
+ return $"S7 {def.DataType} at '{def.Address}' is not byte-addressed. Wide/structured types " +
+ "must be byte-addressed (DBB/MB/IB/QB), e.g. 'DB1.DBB0', 'MB4'.";
+ parsed = addr;
+ return null;
+ }
+
+ // Narrow types: the declared type must match the address's access width, or every read fails
+ // (or decodes garbage) with a type mismatch.
+ var required = def.DataType switch
+ {
+ S7DataType.Bool => S7Size.Bit,
+ S7DataType.Byte => S7Size.Byte,
+ S7DataType.Int16 or S7DataType.UInt16 => S7Size.Word,
+ _ => S7Size.DWord, // Int32 / UInt32 / Float32
+ };
+ if (addr.Size != required)
+ return $"S7 {def.DataType} at '{def.Address}' has the wrong access width ({addr.Size}). " +
+ $"A {def.DataType} tag needs a {SuffixHint(required)} address.";
+
+ if (isArray && def.DataType == S7DataType.Bool)
+ {
+ // Element i of a Bool array is bit 0 of byte (start + i): the array is byte-granular. A
+ // non-zero bit offset used to be silently ignored (DB1.DBX0.3 returned bit 0 of bytes 0..n).
+ if (addr.BitOffset != 0)
+ return $"S7 Bool array at '{def.Address}' starts at bit {addr.BitOffset}. A Bool array reads " +
+ "one byte per element (bit 0 of each byte), so it must start at bit 0 (e.g. 'DB1.DBX0.0').";
+ // A byte-granular write would overwrite the 7 neighbouring bits of every byte it touches.
+ if (def.Writable)
+ return $"S7 Bool array at '{def.Address}' is declared writable. Bool array writes are not " +
+ "supported (a byte-granular write would clobber neighbouring bits); set Writable=false " +
+ "or write the bits as individual scalar Bool tags.";
+ }
+
+ parsed = addr;
+ return null;
+ }
+
+ private static string SuffixHint(S7Size size) => size switch
+ {
+ S7Size.Bit => "bit (DBX/M/I/Q n.b)",
+ S7Size.Byte => "byte (DBB/MB/IB/QB)",
+ S7Size.Word => "word (DBW/MW/IW/QW)",
+ _ => "double-word (DBD/MD/ID/QD)",
+ };
+}
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 8659565a..cbf94e6b 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs
@@ -59,7 +59,7 @@ public sealed class S7Driver
_plcFactory = plcFactory ?? new S7PlcFactory();
_logger = logger ?? NullLogger.Instance;
_resolver = new EquipmentTagRefResolver(
- r => _tagsByRawPath.TryGetValue(r, out var t) ? t : null);
+ r => TagsByRawPath.TryGetValue(r, out var t) ? t : null);
// The polling overlay runs on the shared PollGroupEngine (structural array diff +
// drain-before-dispose + capped-exponential failure backoff + caller-token-filtered OCE),
// retiring the bespoke S7 poll fork. minInterval 100 ms matches the S7 scan-mailbox floor;
@@ -128,13 +128,6 @@ public sealed class S7Driver
/// OPC UA StatusCode used for a genuine device fault (CPU error, hardware fault).
private const uint StatusBadDeviceFailure = 0x808B0000u;
- // v3: the RawPath → definition table. Keyed by RawPath (the v3 tag identity, == def.Name),
- // case-sensitive ordinal. Built in InitializeAsync from _options.RawTags via the pure factory.
- private readonly Dictionary _tagsByRawPath = new(StringComparer.Ordinal);
- // Pre-parsed S7 address per tag, keyed by RawPath (== def.Name). Populated alongside
- // _tagsByRawPath at init so a config typo fails fast here rather than as a per-read fault.
- private readonly Dictionary _parsedByRawPath = new(StringComparer.Ordinal);
-
// Resolves a read/write/subscribe fullReference (a RawPath in v3) to a tag definition by a single
// authored-table hit; a miss is a miss (the driver surfaces BadNodeIdUnknown, never a parse attempt).
private readonly EquipmentTagRefResolver _resolver;
@@ -208,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
@@ -215,6 +214,27 @@ public sealed class S7Driver
/// Guarded by .
///
private (DriverState State, string Message)? _writeFault;
+
+ ///
+ /// The message of the last Degraded the WRITE side published, or null. A clean write batch
+ /// retires a Degraded carrying exactly this message (a good write proves the comms fault it
+ /// reported is gone) and leaves any other Degraded to the read pass. Guarded by .
+ ///
+ private string? _writeOwnedError;
+
+ ///
+ /// Tags whose refusal has already been logged this generation — the once-per-tag Warning. Cleared
+ /// by every initialize publish.
+ ///
+ private readonly ConcurrentDictionary _refusalWarned = new(StringComparer.Ordinal);
+
+ ///
+ /// Tags the span planner must not group (R07:P2): members of a span the CPU refused as a block —
+ /// the ones that were refused on their own, or every member when none was. They are read
+ /// individually for the rest of the generation; a re-initialize (a corrected DB, a fixed tag)
+ /// groups everything again. Guarded by .
+ ///
+ private readonly HashSet _noGroup = new(StringComparer.Ordinal);
private bool _disposed;
///
@@ -225,62 +245,84 @@ public sealed class S7Driver
///
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
- // InitializeAsync is reachable on a live driver with no teardown first (the IDriver contract
- // allows it, and a reconnect retry can overlap a running attempt). Stop the previous probe loop and drop the previous connection
- // before they are overwritten below. Deliberately NOT ShutdownAsync: that also drops _poll and the
- // subscriptions, which are owned by the shutdown path (the actor re-subscribes after every successful init).
- await ReleaseConnectionAsync().ConfigureAwait(false);
- Health = new DriverHealth(DriverState.Initializing, null, null);
+ // Serialised against every other lifecycle operation: DriverInstanceActor dispatches initialize
+ // on a fire-and-forget Task.Run and a reconnect retry can start a second attempt while the
+ // first is still inside OpenAsync. Without the gate attempt A's freshly opened Plc and probe
+ // loop were overwritten by attempt B and never released (R07:H5) — the Modbus pattern.
+ await _lifecycleGate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
- // Re-parse the supplied DriverConfig JSON so a config change delivered through the
- // IDriver contract is honoured. An empty / placeholder document
- // (e.g. the "{}" some unit tests pass) keeps the constructor-supplied options.
- if (HasConfigBody(driverConfigJson))
- _options = S7DriverFactoryExtensions.ParseOptions(_driverInstanceId, driverConfigJson);
+ await InitializeCoreAsync(driverConfigJson, cancellationToken).ConfigureAwait(false);
+ }
+ finally
+ {
+ _lifecycleGate.Release();
+ }
+ }
- // v3: build the RawPath → definition table from the authored raw tags BEFORE the config
- // guards run (they now iterate the mapped table). Each entry's TagConfig blob is mapped by
- // the pure factory; the entry's WriteIdempotent flag is threaded onto the def (it lives on
- // the RawTagEntry, not inside the blob). A mapper miss is skipped (logged), never thrown —
- // one bad tag must not fail the whole driver init.
- _tagsByRawPath.Clear();
- _parsedByRawPath.Clear();
- _resolver.Clear(); // no-op in v3 (the resolver reads the live table by closure); kept for symmetry
- _skippedTags.BeginRebuild(_options.RawTags.Count);
- foreach (var entry in _options.RawTags)
+ /// The lifecycle-gated body of .
+ /// The driver config.
+ /// Caller cancellation.
+ /// A task that completes when the attempt has published, been superseded, or failed.
+ private async Task InitializeCoreAsync(string driverConfigJson, CancellationToken cancellationToken)
+ {
+ // This attempt's generation. Every health write and table/connection publish below is stamped
+ // with it and dropped once the generation has moved on: an attempt abandoned by its caller can
+ // still be running when a ShutdownAsync (which only waits a bounded time for the lifecycle
+ // gate) starts, and it must never publish over it.
+ var gen = BeginGeneration();
+
+ // InitializeAsync is reachable on a live driver with no teardown first (the IDriver contract
+ // allows it). Stop the previous probe loop and drop the previous connection before they are
+ // replaced below. Deliberately NOT a full teardown: that also drops _poll and the subscriptions,
+ // which are owned by the shutdown path (the actor re-subscribes after every successful init).
+ await ReleaseConnectionAsync(gen).ConfigureAwait(false);
+ TryWriteHealth(gen, new DriverHealth(DriverState.Initializing, null, null));
+
+ // Re-parse the supplied DriverConfig JSON so a config change delivered through the IDriver
+ // contract is honoured. An empty / placeholder document (e.g. the "{}" some unit tests pass)
+ // keeps the constructor-supplied options. Local until published: a superseded attempt must
+ // not leave its options behind for the live connection to reopen against.
+ var options = _options;
+ IS7Plc? plc = null;
+ try
+ {
+ if (HasConfigBody(driverConfigJson))
+ options = S7DriverFactoryExtensions.ParseOptions(_driverInstanceId, driverConfigJson);
+
+ // v3: build the RawPath → definition table from the authored raw tags. Each entry's TagConfig
+ // blob is mapped by the pure factory, which also applies S7TagShapeRules (the address must
+ // parse; wide arrays, mis-typed Timer/Counter, wrong access widths, writable Timer/Counter or
+ // Bool arrays and offset Bool arrays are refused). A refused tag is SKIPPED (logged, reported
+ // through ISkippedTagReporter, reads BadNodeIdUnknown), never thrown: these used to be init
+ // guards that faulted every tag on the PLC and made the actor retry the same init forever.
+ // The entry's WriteIdempotent flag is threaded onto the def (it lives on the RawTagEntry).
+ // Built into fresh dictionaries and published by ONE reference swap — a retained poll pass
+ // reading the old table never sees it half-cleared.
+ var tags = new Dictionary(StringComparer.Ordinal);
+ var parsed = new Dictionary(StringComparer.Ordinal);
+ var skipped = new List<(string RawPath, string Reason)>();
+ foreach (var entry in options.RawTags)
{
- if (S7TagDefinitionFactory.FromTagConfig(entry.TagConfig, entry.RawPath, out var def))
- _tagsByRawPath[entry.RawPath] = def with { WriteIdempotent = entry.WriteIdempotent };
+ if (S7TagDefinitionFactory.FromTagConfig(entry.TagConfig, entry.RawPath, out var def, out var shapeRejection)
+ && S7AddressParser.TryParse(def.Address, out var parsedAddress))
+ {
+ tags[entry.RawPath] = def with { WriteIdempotent = entry.WriteIdempotent };
+ parsed[entry.RawPath] = parsedAddress;
+ }
else
{
- _skippedTags.Skip(entry.RawPath, "TagConfig did not map to a definition");
- _logger.LogWarning(
- "S7 tag config did not map to a definition; skipping. Driver={DriverInstanceId} RawPath={RawPath}",
- _driverInstanceId, entry.RawPath);
+ skipped.Add((entry.RawPath, shapeRejection ?? "TagConfig did not map to a definition"));
}
}
- _skippedTags.Commit();
-
- // Phase 4d combined config guard. Timer/Counter addresses and the wide/structured
- // types (Int64/UInt64/Float64/String/DateTime) are now SUPPORTED, but only in
- // specific shapes — this rejects the genuinely-unsupported configurations
- // (wide-type arrays, non-byte-addressed wide scalars, wrong Timer/Counter DataType)
- // so a config typo fails fast at init instead of as a misleading per-read fault.
- RejectUnsupportedTagConfigs();
-
- // Legacy data-type seam. The UnimplementedDataTypes set is now
- // empty (Phase 4d unblocked all five wide types), so this rejects nothing — kept as
- // the single grep target / future seam should a type ever need re-gating at init.
- RejectUnsupportedTagDataTypes();
// Build the connection through the factory (the same seam EnsureConnectedAsync's reopen
// uses) so the read/write timeouts are baked in identically on connect and reconnect.
- var plc = _plcFactory.Create(_options);
+ plc = _plcFactory.Create(options);
try
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
- cts.CancelAfter(_options.Timeout);
+ cts.CancelAfter(options.Timeout);
await plc.OpenAsync(cts.Token).ConfigureAwait(false);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
@@ -288,97 +330,160 @@ public sealed class S7Driver
// The linked CancelAfter fired — a connect TIMEOUT, not a caller cancellation.
// Surface it as a TimeoutException so init-time and reconnect-time timeouts report
// the same exception type (for consistency); the outer catch still faults health.
- try { plc.Dispose(); } catch { }
throw new TimeoutException(
- $"S7 connect to {_options.Host}:{_options.Port} timed out after " +
- $"{(int)_options.Timeout.TotalMilliseconds} ms.");
+ $"S7 connect to {options.Host}:{options.Port} timed out after " +
+ $"{(int)options.Timeout.TotalMilliseconds} ms.");
}
- catch
+
+ // Publish everything this attempt built under _gate (no pass can be mid-flight against a
+ // half-published state) and only while this attempt is still the current generation.
+ await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
+ try
{
- // Dispose the partially-constructed connection so a failed init doesn't leak it
- // before the outer catch nulls Plc (Plc isn't assigned yet, so it can't).
- try { plc.Dispose(); } catch { }
- throw;
+ if (!IsCurrentGeneration(gen))
+ {
+ // The outer finally disposes the unpublished plc; the catch writes no health (the
+ // generation moved on). Thrown, not returned: a caller must never read "initialized"
+ // into an attempt that published nothing.
+ throw new InvalidOperationException(
+ "S7 initialize was superseded by a newer lifecycle operation before it published; " +
+ "its connection was discarded.");
+ }
+
+ _options = options;
+ Volatile.Write(ref _tables, new TagTables(tags, parsed));
+ _refusalWarned.Clear();
+ _noGroup.Clear();
+ _skippedTags.BeginRebuild(options.RawTags.Count);
+ foreach (var (rawPath, reason) in skipped)
+ {
+ _skippedTags.Skip(rawPath, reason);
+ _logger.LogWarning(
+ "S7 tag skipped: {Reason} Driver={DriverInstanceId} RawPath={RawPath}",
+ reason, _driverInstanceId, rawPath);
+ }
+ _skippedTags.Commit();
+
+ Plc = plc;
+ plc = null; // published — no longer this method's to dispose
+ _plcDead = false;
+ _connectBackoff.RecordSuccess();
+ Volatile.Write(ref _negotiatedPduSize, Plc.MaxPduSize);
+ _initialized = true;
+ 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
+ // transition before any PDU round-trip has happened. Tracked (not fire-and-forget) so
+ // a teardown can await it before disposing its CTS; Task.Run gets None so the handle
+ // is always awaitable — the loop's own token check handles cancellation.
+ if (options.Probe.Enabled)
+ {
+ var probeCts = new CancellationTokenSource();
+ _probeCts = probeCts;
+ _probeTask = Task.Run(() => ProbeLoopAsync(probeCts.Token), CancellationToken.None);
+ }
}
-
- Plc = plc;
- _plcDead = false;
- _connectBackoff.RecordSuccess();
- Volatile.Write(ref _negotiatedPduSize, plc.MaxPduSize);
-
- // Parse every tag's address once at init so config typos fail fast here instead
- // of surfacing as BadInternalError on every Read against the bad tag. The parser
- // also rejects bit-offset > 7, DB 0, unknown area letters, etc. Keyed by RawPath
- // (== def.Name) so Read/Write can pick up the pre-parsed address.
- foreach (var t in _tagsByRawPath.Values)
+ finally
{
- _parsedByRawPath[t.Name] = S7AddressParser.Parse(t.Address); // throws FormatException
+ _gate.Release();
}
- _initialized = true;
- Health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
_logger.LogInformation("S7Driver connected. Driver={DriverInstanceId} Host={Host} CPU={CpuType} PduSize={PduSize} Tags={TagCount}",
- _driverInstanceId, _options.Host, _options.CpuType, _negotiatedPduSize, _tagsByRawPath.Count);
-
- // 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 transition before any PDU round-trip has happened.
- if (_options.Probe.Enabled)
- {
- _probeCts = new CancellationTokenSource();
- // Track the probe Task (not fire-and-forget) so ShutdownAsync can await it
- // before disposing _gate / _probeCts. Pass None to Task.Run so
- // the delegate always runs and the handle is always awaitable; the loop's own
- // token check handles cancellation.
- _probeTask = Task.Run(() => ProbeLoopAsync(_probeCts.Token), CancellationToken.None);
- }
+ _driverInstanceId, options.Host, options.CpuType, _negotiatedPduSize, tags.Count);
}
catch (Exception ex)
{
- // Clean up a partially-constructed Plc so a retry from the caller doesn't leak
- // the TcpClient. Dispose is best-effort and idempotent.
- try { Plc?.Dispose(); } catch { }
- Plc = null;
- Volatile.Write(ref _negotiatedPduSize, 0);
- _initialized = false;
- _plcDead = false;
- Health = new DriverHealth(DriverState.Faulted, null, ex.Message);
- _logger.LogError(ex, "S7Driver connect failed. Driver={DriverInstanceId} Host={Host}", _driverInstanceId, _options.Host);
+ // A superseded attempt reports nothing: the generation that replaced it owns health.
+ if (TryWriteHealth(gen, new DriverHealth(DriverState.Faulted, null, ex.Message)))
+ {
+ _options = options;
+ _logger.LogError(ex, "S7Driver connect failed. Driver={DriverInstanceId} Host={Host}", _driverInstanceId, options.Host);
+ }
throw;
}
+ finally
+ {
+ // A connection this attempt opened but did not publish (failed or superseded) is released
+ // here, so a retry from the caller never leaks the TcpClient.
+ if (plc is not null) { try { plc.Dispose(); } catch { /* best-effort */ } }
+ }
}
///
public async Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
- // InitializeAsync re-parses driverConfigJson, so a config change delivered here is
+ // Holds the lifecycle gate across BOTH halves so no other lifecycle operation slips between
+ // them. InitializeCoreAsync re-parses driverConfigJson, so a config change delivered here is
// applied in place rather than silently discarded.
- await ShutdownAsync(cancellationToken).ConfigureAwait(false);
- await InitializeAsync(driverConfigJson, cancellationToken).ConfigureAwait(false);
+ await _lifecycleGate.WaitAsync(cancellationToken).ConfigureAwait(false);
+ try
+ {
+ await ShutdownCoreAsync().ConfigureAwait(false);
+ await InitializeCoreAsync(driverConfigJson, cancellationToken).ConfigureAwait(false);
+ }
+ finally
+ {
+ _lifecycleGate.Release();
+ }
}
///
public async Task ShutdownAsync(CancellationToken cancellationToken)
{
- // Signal cancellation to the probe + poll loops first, collect their Task handles,
- // then await all of them with a bounded timeout BEFORE disposing the shared semaphore
- // and CTS objects. Without the drain, a loop iteration mid-_gate would call Release()
- // on (or WaitAsync against) a disposed semaphore.
+ // Bounded wait for the lifecycle gate: a wedged initialize (a connect that never returns, or an
+ // attempt its caller abandoned) must not turn shutdown into a hang. Past the bound we tear down
+ // anyway — the generation bump makes the still-running attempt discard instead of publish.
+ var gated = false;
+ try { gated = await _lifecycleGate.WaitAsync(LifecycleGateTimeout, cancellationToken).ConfigureAwait(false); }
+ catch (OperationCanceledException) { /* the caller stopped waiting for the gate — still tear down */ }
+ if (!gated)
+ _logger.LogWarning(
+ "S7 lifecycle gate still held at shutdown; tearing down anyway. Driver={DriverInstanceId}",
+ _driverInstanceId);
+ try
+ {
+ await ShutdownCoreAsync().ConfigureAwait(false);
+ }
+ finally
+ {
+ if (gated) _lifecycleGate.Release();
+ }
+ }
+
+ ///
+ /// The complete teardown: the poll engine (every subscription), the probe loop, the connection,
+ /// the sticky write fault and the per-generation memos. It is its own generation, so its final
+ /// health write can never clobber an initialize that started after it.
+ ///
+ /// A task that completes once everything is released.
+ private async Task ShutdownCoreAsync()
+ {
+ var gen = BeginGeneration();
// Drain the polling overlay first — the engine cancels each loop and awaits it before
- // disposing its CTS (drain-before-dispose), so no poll iteration touches _gate
- // after this returns.
- await _poll.DisposeAsync().ConfigureAwait(false);
- await ReleaseConnectionAsync().ConfigureAwait(false);
- _writeFault = null;
- _connectBackoff.RecordSuccess();
- Health = new DriverHealth(DriverState.Unknown, Health.LastSuccessfulRead, null);
+ // disposing its CTS (drain-before-dispose), so no poll iteration touches _gate after this.
+ try { await _poll.DisposeAsync().ConfigureAwait(false); }
+ catch (Exception ex) { _logger.LogWarning(ex, "S7 poll engine drain failed. Driver={DriverInstanceId}", _driverInstanceId); }
+ await ReleaseConnectionAsync(gen).ConfigureAwait(false);
+ lock (_healthLock)
+ {
+ if (_generation != gen) return; // a newer initialize owns the state now
+ _writeFault = null;
+ _writeOwnedError = null;
+ _connectBackoff.RecordSuccess();
+ Health = new DriverHealth(DriverState.Unknown, Health.LastSuccessfulRead, null);
+ }
}
///
/// Cancels + bounded-joins the probe loop, then disposes its CTS and the PLC connection. The
- /// connection part of (everything but the poll engine and health).
+ /// connection part of a teardown (everything but the poll engine and health). Releases only
+ /// while is still current — a teardown that ran past the lifecycle gate
+ /// must not dispose a connection a NEWER initialize already published.
///
- private async Task ReleaseConnectionAsync()
+ /// The generation of the lifecycle operation doing the release.
+ /// A task that completes once the prior state is released.
+ private async Task ReleaseConnectionAsync(long gen)
{
var probeCts = _probeCts;
var probeTask = _probeTask;
@@ -393,11 +498,6 @@ public sealed class S7Driver
catch { /* loop faults are already surfaced via health; teardown continues */ }
}
- // The probe loop has now observed cancellation and released _gate — safe to dispose the CTS.
- probeCts?.Dispose();
- _probeCts = null;
- _probeTask = null;
-
// Swap the connection out under _gate so a Read/Write/poll pass that already holds the gate (they
// re-read Plc inside it) can never see a disposed Plc mid-swap. Bounded: a wedged holder must not hang
// teardown, in which case we proceed exactly as the un-gated path always did.
@@ -409,6 +509,24 @@ public sealed class S7Driver
_driverInstanceId, DrainTimeout);
try
{
+ if (!IsCurrentGeneration(gen)) return; // superseded: the newer generation released (or owns) this state
+
+ // The probe loop has now observed cancellation and released _gate — safe to dispose the CTS.
+ if (ReferenceEquals(_probeCts, probeCts))
+ {
+ probeCts?.Dispose();
+ }
+ 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 */ }
@@ -421,6 +539,101 @@ public sealed class S7Driver
}
}
+ // ---- lifecycle generation ----
+
+ ///
+ /// Guards / /
+ /// / against each other (R07:H5, the
+ /// Modbus _lifecycleGate pattern). Distinct from , which serialises I/O.
+ /// Never disposed: a Dispose racing a still-running ShutdownAsync must not make it throw.
+ ///
+ private readonly SemaphoreSlim _lifecycleGate = new(1, 1);
+
+ ///
+ /// Bounded wait the teardown paths take on before tearing down anyway.
+ /// Internal so a test can shorten it instead of waiting the production 10 s.
+ ///
+ internal TimeSpan LifecycleGateTimeout { get; set; } = TimeSpan.FromSeconds(10);
+
+ /// Serialises generation bumps against generation-stamped health writes.
+ private readonly object _healthLock = new();
+
+ ///
+ /// Lifecycle generation: bumped by every initialize and teardown. Health writes and
+ /// table/connection publishes carry the generation they were computed under and are dropped
+ /// once it has moved on. Written under .
+ ///
+ private long _generation;
+
+ /// Starts a new lifecycle generation.
+ /// The new generation.
+ private long BeginGeneration()
+ {
+ lock (_healthLock) return ++_generation;
+ }
+
+ /// The current lifecycle generation.
+ private long CurrentGeneration => Interlocked.Read(ref _generation);
+
+ /// True while is still the current lifecycle generation.
+ /// The generation to test.
+ /// when nothing has superseded it.
+ private bool IsCurrentGeneration(long gen) => CurrentGeneration == gen;
+
+ /// Writes only while is current.
+ /// The generation the health was computed under.
+ /// The health to publish.
+ /// when written.
+ private bool TryWriteHealth(long gen, DriverHealth health)
+ {
+ lock (_healthLock)
+ {
+ if (_generation != gen) return false;
+ Health = health;
+ return true;
+ }
+ }
+
+ ///
+ /// Derives and writes a new health from the current one, atomically, only while
+ /// is current — for the passes that preserve LastSuccessfulRead.
+ ///
+ /// The generation the update was computed under.
+ /// Maps the current health to the new one; returning null writes nothing.
+ /// when written.
+ private bool TryUpdateHealth(long gen, Func update)
+ {
+ lock (_healthLock)
+ {
+ if (_generation != gen) return false;
+ if (update(Health) is not { } next) return false;
+ Health = next;
+ return true;
+ }
+ }
+
+ /// The published RawPath → definition and RawPath → parsed-address tables, swapped as one.
+ /// RawPath → definition.
+ /// RawPath → pre-parsed address.
+ private sealed record TagTables(
+ IReadOnlyDictionary Tags,
+ IReadOnlyDictionary Parsed)
+ {
+ /// The empty tables a driver starts with.
+ public static readonly TagTables Empty = new(
+ new Dictionary(StringComparer.Ordinal),
+ new Dictionary(StringComparer.Ordinal));
+ }
+
+ /// The live tag tables; replaced by reference at init, never mutated in place.
+ private TagTables _tables = TagTables.Empty;
+
+ /// The live RawPath → definition table.
+ private IReadOnlyDictionary TagsByRawPath => Volatile.Read(ref _tables).Tags;
+
+ /// The live RawPath → pre-parsed address table.
+ private IReadOnlyDictionary ParsedByRawPath => Volatile.Read(ref _tables).Parsed;
+
///
public DriverHealth GetHealth() => Health;
@@ -437,13 +650,6 @@ public sealed class S7Driver
return trimmed is not "{}" and not "[]";
}
- ///
- /// S7 data types addressed by START BYTE (the B suffix) rather than by an
- /// access-width suffix. Phase 4d decodes these from a contiguous byte buffer
- /// (DB{n}.DBB{offset} / MB / IB / QB{offset}), so a wide tag
- /// authored against a non-byte suffix (DBW/DBD/…) is a config error — see
- /// .
- ///
///
/// PDU size the CPU negotiated on the most recent successful connect, in bytes; 0 before the first
/// connect and after shutdown. Snapshotted at connect so it stays readable while the handle is
@@ -474,143 +680,6 @@ public sealed class S7Driver
: new Dictionary();
}
- private static readonly HashSet ByteAnchoredDataTypes = new()
- {
- S7DataType.Int64,
- S7DataType.UInt64,
- S7DataType.Float64,
- S7DataType.String,
- S7DataType.DateTime,
- S7DataType.WString,
- S7DataType.Char,
- S7DataType.WChar,
- S7DataType.Dtl,
- S7DataType.Date,
- S7DataType.Tod,
- S7DataType.Time,
- S7DataType.S5Time,
- };
-
- ///
- /// Combined init-time config guard (Phase 4d, supersedes the old Timer/Counter-only
- /// RejectUnsupportedTagAddresses). Timer/Counter tags and the wide/structured
- /// types (Int64/UInt64/Float64/String/DateTime) are now supported — but only in
- /// specific shapes. This guard fails fast on the genuinely-unsupported configurations so
- /// a config typo throws here at init rather than as a misleading per-read fault:
- ///
- /// (a) a wide/structured type declared as an array — wide-type arrays are out of scope this phase.
- /// (b) a wide/structured type on a DB/M/I/Q area whose address isn't byte-anchored (DBB/MB/IB/QB).
- /// (c) a Timer tag not typed Float64, or a Counter tag not typed Int32.
- ///
- /// Addresses that don't parse are left to the existing
- /// pass in so the parse error isn't double-handled.
- ///
- private void RejectUnsupportedTagConfigs()
- {
- foreach (var t in _tagsByRawPath.Values)
- {
- var isWide = ByteAnchoredDataTypes.Contains(t.DataType);
-
- // (a) wide-type arrays — out of scope this phase.
- if (isWide && t.ArrayCount is >= 1)
- {
- throw new NotSupportedException(
- $"S7 tag '{t.Name}' is a {t.DataType} array, which the S7 driver does not yet " +
- "support. Use a scalar wide tag, or an Int16/UInt16/Int32/UInt32/Float32/Byte/Bool " +
- "element type for arrays.");
- }
-
- // Parse the address; if it doesn't parse, leave it for the InitializeAsync
- // S7AddressParser.Parse pass to throw the FormatException (no double-handling).
- if (!S7AddressParser.TryParse(t.Address, out var parsed))
- continue;
-
- // (b) wide/structured scalar on DB/M/I/Q must be byte-anchored (Size == Byte). The
- // codec decodes from a byte buffer at the start byte; a non-byte suffix (DBW/DBD/…)
- // would mis-frame the value.
- if (isWide
- && parsed.Area is S7Area.DataBlock or S7Area.Memory or S7Area.Input or S7Area.Output
- && parsed.Size != S7Size.Byte)
- {
- throw new NotSupportedException(
- $"S7 tag '{t.Name}' is a {t.DataType} at '{t.Address}', which is not byte-addressed. " +
- "Wide/structured types must be byte-addressed (DBB/MB/IB/QB) — e.g. 'DB1.DBB0', 'MB4'.");
- }
-
- // (c) Timer/Counter type constraints — Timer decodes to a Float64 (seconds), Counter
- // to an Int32 (count); both are read-only.
- if (parsed.Area is S7Area.Timer && t.DataType != S7DataType.Float64)
- {
- throw new NotSupportedException(
- $"S7 tag '{t.Name}' is a Timer address ('{t.Address}') but is typed {t.DataType}. " +
- "Timer tags must be DataType=Float64 (decoded to seconds, read-only).");
- }
- if (parsed.Area is S7Area.Counter && t.DataType != S7DataType.Int32)
- {
- throw new NotSupportedException(
- $"S7 tag '{t.Name}' is a Counter address ('{t.Address}') but is typed {t.DataType}. " +
- "Counter tags must be DataType=Int32 (decoded to count, read-only).");
- }
-
- // (d) Timer/Counter declared Writable — writes are read-only this phase; without this
- // a node is discovered as Operate-writable but every write returns BadNotSupported.
- if (parsed.Area is S7Area.Timer or S7Area.Counter && t.Writable)
- {
- throw new NotSupportedException(
- $"S7 tag '{t.Name}' is a Timer/Counter ('{t.Address}') declared Writable — " +
- "Timer/Counter are read-only this phase; set Writable=false.");
- }
-
- // (e) Bool array declared Writable — element writes are byte-granular (one byte per
- // element, mirroring the read), so a write would overwrite the 7 neighbouring bits of
- // every byte it touches; a safe bit-packed write needs read-modify-write and is not
- // implemented. Every other supported element type (Byte/Int16/UInt16/Int32/UInt32/
- // Float32) writes as ONE contiguous block (EncodeArrayBlock). Wide-type arrays
- // are already rejected above (guard-a).
- if (t.ArrayCount is >= 1 && t.Writable && t.DataType == S7DataType.Bool)
- {
- throw new NotSupportedException(
- $"S7 tag '{t.Name}' is a Bool array (ArrayCount={t.ArrayCount}) declared Writable — " +
- "Bool array writes are not supported (a byte-granular write would clobber the neighbouring " +
- "bits). Set Writable=false, or write the bits as individual scalar Bool tags.");
- }
- }
- }
-
- ///
- /// Rejects tags configured with an still on the
- /// list — types /
- /// throw for, which
- /// would otherwise create live OPC UA nodes via that return
- /// BadNotSupported on every access.
- /// The set is now empty (Phase 4d unblocked all five wide types), so this rejects
- /// nothing — kept as the seam / grep target should a type ever need re-gating at init.
- ///
- private void RejectUnsupportedTagDataTypes()
- {
- foreach (var t in _tagsByRawPath.Values)
- {
- if (UnimplementedDataTypes.Contains(t.DataType))
- {
- throw new NotSupportedException(
- $"S7 tag '{t.Name}' uses data type '{t.DataType}' which is not yet " +
- "supported by the S7 driver — Read/Write would return BadNotSupported. " +
- "Remove the tag or use Bool/Byte/Int16/UInt16/Int32/UInt32/Float32 until " +
- $"{t.DataType} is wired through.");
- }
- }
- }
-
- ///
- /// S7DataType members that the read/write helpers throw NotSupportedException for.
- /// Now empty — Phase 4d (S7 wide types + Timer/Counter) wired Int64, UInt64,
- /// Float64, String, and DateTime through the byte-buffer codec, and Timer/Counter read
- /// through it on AREA. Kept here (rather than reflecting over
- /// ) as the single grep target / future seam should a
- /// data type ever need re-gating at init.
- ///
- private static readonly HashSet UnimplementedDataTypes = new();
-
// ---- IReadable ----
// The batch is served in two passes under ONE gate acquisition. First S7ReadSpanPlanner groups
@@ -639,15 +708,22 @@ 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.
var outcome = new ReadPassOutcome();
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
+ // The lifecycle generation this pass runs in — its health write is dropped if a teardown or
+ // re-initialize has moved on by the time it lands. Captured under _gate, where the init
+ // publish happens, and re-checked against _initialized: a pass that passed RequireInitialized
+ // and then waited out a teardown must not reopen a connection on a shut-down driver.
+ var gen = CurrentGeneration;
try
{
+ RequireInitialized();
+
// (Re)open the connection under the gate. A failed (re)connect degrades the whole
// batch to BadCommunicationError rather than throwing — the poll loop / next read
// retries, and EnsureConnectedAsync repairs on the following call.
@@ -664,7 +740,7 @@ public sealed class S7Driver
{
for (var i = 0; i < fullReferences.Count; i++)
results[i] = new DataValueSnapshot(null, StatusBadCommunicationError, null, now);
- Health = new DriverHealth(DriverState.Degraded, Health.LastSuccessfulRead, ex.Message);
+ TryUpdateHealth(gen, h => new DriverHealth(DriverState.Degraded, h.LastSuccessfulRead, ex.Message));
return results;
}
@@ -682,6 +758,15 @@ public sealed class S7Driver
// Indexes the per-tag path must serve: everything, when grouping is off; otherwise the
// planner's leftovers (Timer/Counter) plus any tag whose address won't parse.
var perTag = new List();
+ // Indexes that must take the per-tag path WITHOUT the multi-var pass: tags the planner may
+ // not group (R07:P2) and the members of a span refused this pass. Batching them into a
+ // multi-var request would just refuse the whole chunk again.
+ 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)
{
@@ -689,6 +774,11 @@ public sealed class S7Driver
for (var i = 0; i < fullReferences.Count; i++)
{
if (resolved[i] is not { } tag) continue;
+ if (_noGroup.Contains(tag.Name))
+ {
+ (forcedPerTag ??= []).Add(i);
+ continue;
+ }
if (!TryGetParsedAddress(tag, out var addr))
{
// Defensive: every tag pre-parses at init. A tag that somehow didn't goes
@@ -712,7 +802,19 @@ public sealed class S7Driver
perTag.Add(span.Members[0].OriginalIndex);
continue;
}
- await ReadSpanAsync(plc, span, results, now, outcome, cancellationToken).ConfigureAwait(false);
+ // Fail fast once an earlier span marked the handle dead (R07:P1): every
+ // further wire op on it would pay the full deadline under the gate.
+ if (_plcDead)
+ {
+ foreach (var member in span.Members) results[member.OriginalIndex] = DeadHandleSnapshot(now);
+ continue;
+ }
+ if (await ReadSpanAsync(plc, span, results, now, outcome, cancellationToken).ConfigureAwait(false)
+ is { } refusedMembers)
+ {
+ (refusedSpans ??= []).Add(refusedMembers);
+ (forcedPerTag ??= []).AddRange(refusedMembers);
+ }
}
perTag.AddRange(plan.LeftoverIndexes);
}
@@ -735,11 +837,23 @@ 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)
+ {
+ perTag.AddRange(forcedPerTag);
+ perTag.Sort();
}
foreach (var i in perTag)
{
+ // Fail fast once the handle is dead (R07:P1) — see the span loop.
+ if (_plcDead)
+ {
+ results[i] = DeadHandleSnapshot(now);
+ continue;
+ }
try
{
var value = await ReadOneAsync(plc, resolved[i]!, cancellationToken).ConfigureAwait(false);
@@ -760,7 +874,38 @@ public sealed class S7Driver
}
catch (Exception ex)
{
- results[i] = new DataValueSnapshot(null, RecordReadFailure(ex, outcome), null, now);
+ 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)
+ {
+ var bad = members.Where(m => refusedAlone?.Contains(m) == true).ToList();
+ var memo = bad.Count > 0 ? bad : [.. members];
+ foreach (var m in memo) _noGroup.Add(resolved[m]!.Name);
+ _logger.LogInformation(
+ "S7 CPU refused a grouped read of {MemberCount} tag(s); reading {Tags} individually from now on. Driver={DriverInstanceId}",
+ members.Count, string.Join(", ", memo.Select(m => resolved[m]!.Name)), _driverInstanceId);
}
}
}
@@ -793,10 +938,10 @@ public sealed class S7Driver
state = wf.State;
error = wf.Message;
}
- Health = new DriverHealth(
+ TryUpdateHealth(gen, h => new DriverHealth(
state,
- outcome.AnySuccess ? now : Health.LastSuccessfulRead,
- error);
+ outcome.AnySuccess ? now : h.LastSuccessfulRead,
+ error));
}
_gate.Release();
@@ -866,6 +1011,13 @@ public sealed class S7Driver
/// Records that one tag read successfully.
public void RecordSuccess() => AnySuccess = true;
+ ///
+ /// Records that the CPU answered for one tag without serving it (a refusal, an undecodable
+ /// buffer). Not a failure — it ranks nothing — but it IS proof of a live device, so it
+ /// advances LastSuccessfulRead exactly like a success.
+ ///
+ public void RecordAnswered() => AnySuccess = true;
+
///
/// Records one failed tag, keeping the worst state and the message that explains it.
///
@@ -973,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,
@@ -981,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;
@@ -1012,6 +1167,13 @@ public sealed class S7Driver
{
var chunk = batchable.GetRange(start, Math.Min(MultiVarReadMaxItems, batchable.Count - start));
+ // Fail fast once the handle is dead (R07:P1) — see ReadAsync's span loop.
+ if (_plcDead)
+ {
+ foreach (var (index, _, _) in chunk) results[index] = DeadHandleSnapshot(now);
+ continue;
+ }
+
IReadOnlyList answered;
try
{
@@ -1037,12 +1199,24 @@ public sealed class S7Driver
_plcDead = true;
throw;
}
+ catch (Exception ex) when (IsS7TransportLoss(ex))
+ {
+ // A transport loss is the connection's fault, not the batch's: falling back would
+ // send one more doomed per-tag read per member, each paying the full deadline
+ // (R07:P1). Mark the handle dead and fail the chunk (and, via the guards, the rest
+ // of the pass) without I/O.
+ _plcDead = true;
+ outcome.RecordFailure(DriverState.Degraded, ex.Message);
+ foreach (var (index, _, _) in chunk) results[index] = DeadHandleSnapshot(now);
+ continue;
+ }
catch (Exception ex)
{
_logger.LogDebug(ex,
"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;
}
@@ -1061,7 +1235,7 @@ public sealed class S7Driver
{
// Decoding is pure and synchronous — no cancellation can surface here, so there
// is deliberately no OperationCanceledException arm to shadow the request one.
- results[index] = new DataValueSnapshot(null, RecordReadFailure(ex, outcome), null, now);
+ results[index] = new DataValueSnapshot(null, RecordReadFailure(ex, outcome, [tag.Name]), null, now);
}
}
}
@@ -1134,8 +1308,12 @@ public sealed class S7Driver
/// The batch timestamp, shared by every tag in the batch.
/// The pass's outcome accumulator; every member records into it.
/// Cancellation token for the read.
- /// A task that completes once every member of the span has been stamped.
- private async Task ReadSpanAsync(
+ ///
+ /// once every member has been stamped; or, when the CPU REFUSED the block
+ /// of a multi-member span (R07:P2 — one member past the DB's end refuses them all), the members'
+ /// original indexes, unstamped, for the caller to read one at a time.
+ ///
+ private async Task?> ReadSpanAsync(
IS7Plc plc, S7ReadSpanPlanner.Span span, DataValueSnapshot[] results, DateTime now,
ReadPassOutcome outcome, CancellationToken ct)
{
@@ -1156,12 +1334,24 @@ public sealed class S7Driver
_plcDead = true;
throw;
}
+ catch (Exception ex) when (span.Members.Count > 1 && IsRefusal(ex))
+ {
+ // The planner's old invariant ("a grouped read cannot fault where the per-tag reads would
+ // have succeeded") is false: the block is the UNION of the members' ranges, so one member
+ // running past the DB's real end, or over-declaring a STRING, makes the CPU refuse every
+ // neighbour with it. Hand the members back for individual reads.
+ _logger.LogDebug(ex,
+ "S7 driver {DriverInstanceId}: CPU refused the block read of {Area} db{Db} @{Start}+{Length}; " +
+ "re-reading its {Count} member(s) individually", _driverInstanceId, span.Area, span.DbNumber,
+ span.StartByte, span.LengthBytes, span.Members.Count);
+ return [.. span.Members.Select(m => m.OriginalIndex)];
+ }
catch (Exception ex)
{
- var status = RecordReadFailure(ex, outcome);
+ var status = RecordReadFailure(ex, outcome, span.Members.Select(m => m.Tag.Name));
foreach (var member in span.Members)
results[member.OriginalIndex] = new DataValueSnapshot(null, status, null, now);
- return;
+ return null;
}
foreach (var member in span.Members)
@@ -1177,9 +1367,10 @@ public sealed class S7Driver
// Decoding is pure and synchronous — no cancellation can surface here, so there is
// deliberately no OperationCanceledException arm to shadow the block-read one above.
results[member.OriginalIndex] =
- new DataValueSnapshot(null, RecordReadFailure(ex, outcome), null, now);
+ new DataValueSnapshot(null, RecordReadFailure(ex, outcome, [member.Tag.Name]), null, now);
}
}
+ return null;
}
///
@@ -1226,7 +1417,7 @@ public sealed class S7Driver
/// The parsed address, valid only when this returns true.
/// true when the address is known or parses.
private bool TryGetParsedAddress(S7TagDefinition tag, out S7ParsedAddress addr)
- => _parsedByRawPath.TryGetValue(tag.Name, out addr) || S7AddressParser.TryParse(tag.Address, out addr);
+ => ParsedByRawPath.TryGetValue(tag.Name, out addr) || S7AddressParser.TryParse(tag.Address, out addr);
///
/// The read-failure classification ladder, shared by the per-tag path and the span path so
@@ -1252,8 +1443,10 @@ public sealed class S7Driver
///
/// The exception observed on a read.
/// The pass's outcome accumulator this failure is recorded into.
+ /// The tags the failure belongs to (every member of a failed span), for the
+ /// once-per-tag refusal Warning.
/// The OPC UA status code every affected tag must be stamped with.
- private uint RecordReadFailure(Exception ex, ReadPassOutcome outcome)
+ private uint RecordReadFailure(Exception ex, ReadPassOutcome outcome, IEnumerable tagNames)
{
switch (ex)
{
@@ -1262,19 +1455,38 @@ public sealed class S7Driver
case S7ValueRangeException:
// A PLC buffer the type codec refuses (uninitialised DTL, non-BCD S5TIME…) is a data
- // problem, not a transport fault — no health change, no connection teardown.
+ // problem, not a transport fault — no health change, no connection teardown. The
+ // bytes came off the wire, so the CPU answered.
+ outcome.RecordAnswered();
return StatusBadOutOfRange;
- case PlcException pex when IsAccessDenied(pex):
+ case var _ when IsAccessDenied(ex):
// PUT/GET-disabled (S7-1200/1500) / access-protection — a permanent configuration
// fault, NOT a transient one. Blind retry is wasted effort, so map it to
// BadNotSupported and flag the driver as a config alert (Faulted) rather than
- // Degraded — per driver-specs.md §5.
+ // 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: {pex.Message}");
+ $"(Protection & Security) for this CPU. PLC reported: {ex.Message}");
return StatusBadNotSupported;
+ case var _ when IsRefusal(ex):
+ // The CPU answered and refused the item (object does not exist, address out of
+ // range, type inconsistent...) — deterministic, so a retry, a reopen or a Degraded
+ // would all be wrong: per-tag Bad, a once-per-tag Warning, no health write, and it
+ // counts as answered so LastSuccessfulRead advances (R07:H4). An all-refused driver
+ // (wrong DB number) used to sit Degraded with a frozen timestamp and trip the
+ // comms-loss watchdog every 90 s against a reachable PLC.
+ outcome.RecordAnswered();
+ foreach (var name in tagNames) WarnRefusalOnce(name, "read", ex);
+ return StatusBadDeviceFailure;
+
case PlcException pex:
// A genuine device-layer fault (CPU error, hardware fault) — transient enough to
// keep retrying; report BadDeviceFailure and degrade health. A connection-fatal
@@ -1295,10 +1507,10 @@ public sealed class S7Driver
private async Task