Merge lane S7: S7 driver remediation (#742)

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
Joseph Doherty
2026-10-22 16:11:24 -04:00
36 changed files with 2933 additions and 582 deletions
+50 -8
View File
@@ -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 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 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. `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 ## 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) | | Timer | `T0` / `T15` | Timer area (read-only — see Wide types section) |
| Counter | `C0` / `C10` | Counter 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 Parsing is strict. Bit offsets must be 0-7, byte offsets non-negative, DB numbers >= 1.
at load instead of surfacing as `BadInternalError` on every read. 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 ## 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` | | `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 | | `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) | | `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 ### Single-connection policy
@@ -302,8 +314,9 @@ Portal under *Protection & Security* for the CPU.
| Tag not in config | `BadNodeIdUnknown` | unchanged | | Tag not in config | `BadNodeIdUnknown` | unchanged |
| Read-only tag written | `BadNotWritable` | unchanged | | Read-only tag written | `BadNotWritable` | unchanged |
| Unimplemented data type | `BadNotSupported` | 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 / hardware fault | `BadDeviceFailure` | `Degraded` | | 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` | | Socket / timeout | `BadCommunicationError` | `Degraded` |
| Reconnect suppressed (backoff window) | `BadCommunicationError` | `Degraded` | | Reconnect suppressed (backoff window) | `BadCommunicationError` | `Degraded` |
| Array write of the wrong shape / length / element type | `BadTypeMismatch` | unchanged | | 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 `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 **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 `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 **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 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 **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 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 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 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) 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 rather than coerced to 0. An array write larger than one PDU is split by S7.Net into several writes and is
@@ -48,8 +48,23 @@ public static class S7TagDefinitionFactory
/// <param name="def">The mapped definition when this returns <see langword="true"/>.</param> /// <param name="def">The mapped definition when this returns <see langword="true"/>.</param>
/// <returns><see langword="true"/> when <paramref name="tagConfig"/> is a valid S7 address object.</returns> /// <returns><see langword="true"/> when <paramref name="tagConfig"/> is a valid S7 address object.</returns>
public static bool FromTagConfig(string tagConfig, string rawPath, out S7TagDefinition def) public static bool FromTagConfig(string tagConfig, string rawPath, out S7TagDefinition def)
=> FromTagConfig(tagConfig, rawPath, out def, out _);
/// <summary>
/// <see cref="FromTagConfig(string, string, out S7TagDefinition)"/>, also reporting WHY a structurally
/// valid blob was refused by <see cref="S7TagShapeRules"/> (an unparseable address, or a shape the
/// driver cannot serve). <paramref name="shapeRejection"/> stays <see langword="null"/> for a JSON-level
/// refusal (missing keys, bad enum), which <see cref="Inspect"/> explains on its own.
/// </summary>
/// <param name="tagConfig">The authored equipment-tag TagConfig JSON.</param>
/// <param name="rawPath">The tag's RawPath — becomes the definition's identity (<c>Name</c>).</param>
/// <param name="def">The mapped definition when this returns <see langword="true"/>.</param>
/// <param name="shapeRejection">The shape/address rejection reason, when that is why it refused.</param>
/// <returns><see langword="true"/> when the driver can serve the tag.</returns>
public static bool FromTagConfig(string tagConfig, string rawPath, out S7TagDefinition def, out string? shapeRejection)
{ {
def = null!; def = null!;
shapeRejection = null;
if (string.IsNullOrWhiteSpace(tagConfig)) return false; if (string.IsNullOrWhiteSpace(tagConfig)) return false;
try try
{ {
@@ -78,7 +93,7 @@ public static class S7TagDefinitionFactory
// "writable" defaults to true when absent (today's value); node-level authz still governs // "writable" defaults to true when absent (today's value); node-level authz still governs
// writes. Honouring the key makes read-only equipment tags authorable. // writes. Honouring the key makes read-only equipment tags authorable.
var writable = TagConfigJson.ReadWritable(root); var writable = TagConfigJson.ReadWritable(root);
def = new S7TagDefinition( var mapped = new S7TagDefinition(
Name: rawPath, Name: rawPath,
Address: address, Address: address,
DataType: dataType, DataType: dataType,
@@ -86,6 +101,15 @@ public static class S7TagDefinitionFactory
StringLength: stringLength == 0 ? MaxStringLength : stringLength, StringLength: stringLength == 0 ? MaxStringLength : stringLength,
WriteIdempotent: false, WriteIdempotent: false,
ArrayCount: arrayCount); 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; return true;
} }
catch (JsonException) { return false; } catch (JsonException) { return false; }
@@ -162,7 +186,9 @@ public static class S7TagDefinitionFactory
/// <b>reject</b> — a structurally unparseable or non-object document, an absent / non-string / /// <b>reject</b> — a structurally unparseable or non-object document, an absent / non-string /
/// blank <c>address</c>, an <b>absent</b> <c>dataType</c> (see <see cref="DeclaresDataType"/>), a /// blank <c>address</c>, an <b>absent</b> <c>dataType</c> (see <see cref="DeclaresDataType"/>), a
/// present-but-invalid <c>dataType</c> (the runtime enum read is STRICT, so a /// present-but-invalid <c>dataType</c> (the runtime enum read is STRICT, so a
/// typo rejects the tag), or an out-of-range <c>stringLength</c> on a <c>String</c> tag. Such a tag /// typo rejects the tag), an out-of-range <c>stringLength</c> on a <c>String</c> tag, or a shape
/// <see cref="S7TagShapeRules"/> 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 <c>BadNodeIdUnknown</c> forever. Empty when the mapper accepts /// is skipped by the driver and reads <c>BadNodeIdUnknown</c> forever. Empty when the mapper accepts
/// the blob, when it is blank, or when it is not an equipment-tag object. Never throws. /// the blob, when it is blank, or when it is not an equipment-tag object. Never throws.
/// </summary> /// </summary>
@@ -184,7 +210,13 @@ public static class S7TagDefinitionFactory
// The accept gate: whatever the driver reads draws nothing. The RAW reference is handed to the // 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. // 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 try
{ {
@@ -0,0 +1,149 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.S7;
/// <summary>
/// <b>The single authority on which S7 tag shapes the driver can serve.</b> Consulted by
/// <see cref="S7TagDefinitionFactory.FromTagConfig"/>, so the AdminUI editor, the deploy gate
/// (<see cref="S7TagDefinitionFactory.Inspect"/>) and the driver's own init all refuse the same
/// tags with the same words.
/// </summary>
/// <remarks>
/// <para>
/// These rules used to be init-time guards in <c>S7Driver</c> that THREW. One mis-authored tag
/// (<c>DB1.DBZ0</c>, <c>DB1.DBW0</c> typed <c>Float64</c>, <c>T5</c> typed <c>Int32</c>) 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
/// <c>BadNodeIdUnknown</c>) and the deploy gate warns about it.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
public static class S7TagShapeRules
{
/// <summary>
/// S7 data types addressed by START BYTE (the <c>B</c> 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.
/// </summary>
private static readonly HashSet<S7DataType> 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,
];
/// <summary>True when <paramref name="dataType"/> is a wide/structured type read from a byte-anchored block.</summary>
/// <param name="dataType">The declared data type.</param>
/// <returns><see langword="true"/> for the byte-anchored types.</returns>
public static bool IsByteAnchored(S7DataType dataType) => ByteAnchoredDataTypes.Contains(dataType);
/// <summary>
/// Judges one tag definition. Returns <see langword="null"/> when the driver can serve it, or
/// the operator-facing reason it cannot.
/// </summary>
/// <param name="def">The mapped tag definition.</param>
/// <param name="parsed">The parsed address when this returns <see langword="null"/>.</param>
/// <returns>The rejection reason, or <see langword="null"/> when the tag is servable.</returns>
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)",
};
}
File diff suppressed because it is too large Load Diff
@@ -42,6 +42,14 @@ public sealed class S7DriverProbe : IDriverProbe
if (string.IsNullOrWhiteSpace(host) || port <= 0) if (string.IsNullOrWhiteSpace(host) || port <= 0)
return new(false, "Config has no host/port to probe.", null); return new(false, "Config has no host/port to probe.", null);
// The S7 handshake + test read are bounded by the driver's own per-operation timeout
// (config "timeoutMs", what the deployed driver will wait), capped by the caller's overall
// Test Connect timeout. The options type carries it as a TimeSpan the JSON never names, so it
// used to be ignored and every handshake waited the full Test Connect timeout (R07:H7).
var opTimeout = ReadTimeoutMs(configJson) is { } ms && TimeSpan.FromMilliseconds(ms) < timeout
? TimeSpan.FromMilliseconds(ms)
: timeout;
// Phase 1: bare TCP preflight — fast rejection for unreachable hosts. // Phase 1: bare TCP preflight — fast rejection for unreachable hosts.
var sw = Stopwatch.StartNew(); var sw = Stopwatch.StartNew();
try try
@@ -71,9 +79,9 @@ public sealed class S7DriverProbe : IDriverProbe
var plc = S7Tsap.Resolve(opts) is { } tsap var plc = S7Tsap.Resolve(opts) is { } tsap
? new Plc(host, port, tsap) ? new Plc(host, port, tsap)
: new Plc(S7CpuTypeMap.ToS7Net(opts.CpuType), host, port, opts.Rack, opts.Slot); : new Plc(S7CpuTypeMap.ToS7Net(opts.CpuType), host, port, opts.Rack, opts.Slot);
plc.ReadTimeout = (int)timeout.TotalMilliseconds; plc.ReadTimeout = (int)opTimeout.TotalMilliseconds;
using var handshakeCts = CancellationTokenSource.CreateLinkedTokenSource(ct); using var handshakeCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
handshakeCts.CancelAfter(timeout); handshakeCts.CancelAfter(opTimeout);
try try
{ {
await plc.OpenAsync(handshakeCts.Token); await plc.OpenAsync(handshakeCts.Token);
@@ -128,15 +136,17 @@ public sealed class S7DriverProbe : IDriverProbe
return null; return null;
} }
catch (OperationCanceledException) { throw; } catch (OperationCanceledException) { throw; }
catch (PlcException pex) when (S7Driver.IsAccessDenied(pex)) // S7.Net's read paths raise the CPU's item error as a BARE Exception ("Received error from
// PLC: …"), not a PlcException, so both classifiers walk the whole chain (the driver's own).
catch (Exception ex) when (S7Driver.IsAccessDenied(ex))
{ {
return new(false, return new(false,
"S7 connected but data access is denied — enable PUT/GET communication in TIA Portal " + "S7 connected but data access is denied — enable PUT/GET communication in TIA Portal " +
$"(Protection & Security) for this CPU. PLC reported: {pex.Message}", null); $"(Protection & Security) for this CPU. PLC reported: {ex.Message}", null);
} }
catch (PlcException pex) when (pex.ErrorCode == ErrorCode.ReadData) catch (Exception ex) when (S7Driver.IsRefusal(ex))
{ {
return null; // address not available on this CPU — not a connection problem return null; // address not available on this CPU — the CPU answered; not a connection problem
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -146,4 +156,31 @@ public sealed class S7DriverProbe : IDriverProbe
private static (string host, int port) ExtractTarget(S7DriverOptions opts) private static (string host, int port) ExtractTarget(S7DriverOptions opts)
=> (opts.Host, opts.Port); => (opts.Host, opts.Port);
/// <summary>
/// The config's <c>timeoutMs</c> (any casing, as the driver factory binds it), or null when absent
/// or not a positive integer.
/// </summary>
/// <param name="configJson">The driver config.</param>
/// <returns>The configured per-operation timeout in milliseconds, or null.</returns>
internal static int? ReadTimeoutMs(string configJson)
{
try
{
using var doc = JsonDocument.Parse(configJson);
if (doc.RootElement.ValueKind != JsonValueKind.Object) return null;
foreach (var prop in doc.RootElement.EnumerateObject())
{
if (!string.Equals(prop.Name, "timeoutMs", StringComparison.OrdinalIgnoreCase)) continue;
return prop.Value.ValueKind == JsonValueKind.Number && prop.Value.TryGetInt32(out var ms) && ms > 0
? ms
: null;
}
return null;
}
catch (JsonException)
{
return null;
}
}
} }
@@ -11,8 +11,14 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.S7;
/// <b>The safety invariant is that a span never extends past its last member's end.</b> A /// <b>The safety invariant is that a span never extends past its last member's end.</b> A
/// span may deliberately read bytes nobody asked for BETWEEN its members (that is what the /// span may deliberately read bytes nobody asked for BETWEEN its members (that is what the
/// gap budget buys), but every byte it requests lies inside a range some member was going /// gap budget buys), but every byte it requests lies inside a range some member was going
/// to read anyway. A grouped read therefore cannot fault (area overrun, DB overrun) where /// to read anyway.
/// the individual per-tag reads would have succeeded. /// </para>
/// <para>
/// <b>That does NOT make a grouped read as safe as the per-tag reads</b> (R07:P2): the block is
/// the union of its members' ranges, so ONE member the CPU refuses (running past the DB's real
/// end, an over-declared STRING) makes it refuse the whole block, neighbours included.
/// <see cref="S7Driver.ReadAsync"/> owns that case: a refused multi-member span is re-read one
/// member at a time, and the members that must not be grouped are left out of later plans.
/// </para> /// </para>
/// <para> /// <para>
/// <b>Timer and Counter tags are never grouped.</b> Their /// <b>Timer and Counter tags are never grouped.</b> Their
@@ -112,17 +112,15 @@ public sealed class S7TagConfigModel
+ string.Join(", ", Enum.GetNames<S7DataType>()) + "."; + string.Join(", ", Enum.GetNames<S7DataType>()) + ".";
} }
// Array shape is judged against the driver's INIT guards, which THROW (failing the whole // Array shape: the friendlier editor wording for two of the S7TagShapeRules refusals (a wide-type
// driver's init, not just this tag) for a wide-type array and for a writable Bool array. The // array, a writable Bool array). The reader below refuses them too (and the rest of the shape
// rules), so this is wording, not a second authority. The
// isArray / arrayLength keys themselves belong to the TagModal's generic array controls // isArray / arrayLength keys themselves belong to the TagModal's generic array controls
// (TagArrayConfig) and are intentionally not fields of this model, so read them off the key bag. // (TagArrayConfig) and are intentionally not fields of this model, so read them off the key bag.
if (TagConfigJson.GetBool(_bag, "isArray")) if (TagConfigJson.GetBool(_bag, "isArray"))
{ {
var dt = DataType; var dt = DataType;
if (dt is S7DataType.Int64 or S7DataType.UInt64 or S7DataType.Float64 if (S7TagShapeRules.IsByteAnchored(dt))
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)
{ {
return $"S7 does not support {dt} arrays — use Bool (read-only), Byte, Int16, UInt16, Int32, " 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."; + "UInt32 or Float32 for an array tag, or a scalar tag.";
@@ -0,0 +1,104 @@
using System.Net;
using System.Net.Sockets;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests;
/// <summary>
/// An in-test TCP forwarder in front of the S7 fixture that can be FROZEN: while frozen it keeps
/// every socket open but forwards nothing in either direction — the frozen-but-established peer
/// (cable pull, firewall DROP) that only a wire-op deadline can detect, produced without touching
/// the shared fixture. It counts the ISO-on-TCP (TPKT) request frames the client sends while frozen,
/// so a test can assert how many wire operations went out without timing anything.
/// </summary>
internal sealed class FreezableTcpProxy : IAsyncDisposable
{
private readonly string _targetHost;
private readonly int _targetPort;
private readonly TcpListener _listener = new(IPAddress.Loopback, 0);
private readonly CancellationTokenSource _cts = new();
private readonly List<TcpClient> _sockets = [];
private volatile bool _frozen;
private int _framesWhileFrozen;
/// <summary>Starts a proxy forwarding to <paramref name="targetHost"/>:<paramref name="targetPort"/>.</summary>
/// <param name="targetHost">The fixture host.</param>
/// <param name="targetPort">The fixture port.</param>
public FreezableTcpProxy(string targetHost, int targetPort)
{
_targetHost = targetHost;
_targetPort = targetPort;
_listener.Start();
_ = AcceptLoopAsync();
}
/// <summary>The local port clients connect to.</summary>
public int Port => ((IPEndPoint)_listener.LocalEndpoint).Port;
/// <summary>TPKT request frames the client sent while the proxy was frozen.</summary>
public int FramesWhileFrozen => Volatile.Read(ref _framesWhileFrozen);
/// <summary>Stops forwarding (sockets stay open).</summary>
public void Freeze() => _frozen = true;
/// <summary>Resumes forwarding for connections made from now on.</summary>
public void Unfreeze() => _frozen = false;
private async Task AcceptLoopAsync()
{
try
{
while (!_cts.IsCancellationRequested)
{
var client = await _listener.AcceptTcpClientAsync(_cts.Token).ConfigureAwait(false);
var upstream = new TcpClient();
await upstream.ConnectAsync(_targetHost, _targetPort, _cts.Token).ConfigureAwait(false);
lock (_sockets) { _sockets.Add(client); _sockets.Add(upstream); }
// A connection accepted while frozen stays frozen for its whole life (its peer is "gone").
var frozenAtBirth = _frozen;
_ = PumpAsync(client.GetStream(), upstream.GetStream(), countFrames: true, frozenAtBirth);
_ = PumpAsync(upstream.GetStream(), client.GetStream(), countFrames: false, frozenAtBirth);
}
}
catch { /* disposed */ }
}
private async Task PumpAsync(NetworkStream from, NetworkStream to, bool countFrames, bool frozenAtBirth)
{
var buffer = new byte[8192];
var pending = new List<byte>();
try
{
while (!_cts.IsCancellationRequested)
{
var n = await from.ReadAsync(buffer, _cts.Token).ConfigureAwait(false);
if (n == 0) return;
var frozen = _frozen || frozenAtBirth;
if (countFrames && frozen)
{
// Count complete TPKT frames: 0x03 0x00 then a big-endian total length.
pending.AddRange(buffer.AsSpan(0, n).ToArray());
while (pending.Count >= 4)
{
var len = (pending[2] << 8) | pending[3];
if (len < 4 || pending.Count < len) break;
pending.RemoveRange(0, len);
Interlocked.Increment(ref _framesWhileFrozen);
}
}
if (frozen) continue; // swallow — the peer never answers
await to.WriteAsync(buffer.AsMemory(0, n), _cts.Token).ConfigureAwait(false);
}
}
catch { /* socket closed / disposed */ }
}
/// <inheritdoc />
public ValueTask DisposeAsync()
{
_cts.Cancel();
_listener.Stop();
lock (_sockets) foreach (var s in _sockets) s.Dispose();
_cts.Dispose();
return ValueTask.CompletedTask;
}
}
@@ -0,0 +1,71 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests.S7_1500;
/// <summary>
/// Live gate for R07:P1 over real S7comm: a frozen-but-established peer (produced by an in-test
/// <see cref="FreezableTcpProxy"/>, so the shared fixture is never touched). The first wire op of
/// the pass blows its deadline and marks the connection dead; the rest of the pass must NOT go to
/// the wire. The assertion counts request frames, not seconds.
/// </summary>
/// <remarks>
/// <b>What this gate does and does not prove.</b> Verified by mutation (2026-10-22): on the real
/// stack this test also passes with the driver's dead-handle guard removed, because S7.Net 0.20
/// honours the deadline's cancellation token and closes its own socket, so the next op fails at once
/// with <c>ConnectionError</c> and sends nothing. The driver guard (proven by
/// <c>S7DeadHandleShortCircuitTests</c> against a fake that does not self-close) is what covers an
/// op that ignores its token — the <c>WaitAsync</c> leg of <c>S7OperationDeadline</c>, where the
/// abandoned socket stays open and every further op would pay the full deadline. This test pins the
/// end-to-end property (one request per frozen pass, then recovery) against S7.Net upgrades.
/// </remarks>
[Collection(Snap7ServerCollection.Name)]
[Trait("Category", "Integration")]
[Trait("Device", "S7_1500")]
public sealed class S7_1500FrozenPeerTests(Snap7ServerFixture sim)
{
/// <summary>A pass against a frozen peer issues ONE request, then fails the rest fast; it recovers after.</summary>
[Fact]
public async Task A_frozen_peer_costs_one_wire_op_per_pass_not_one_per_tag()
{
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
var ct = TestContext.Current.CancellationToken;
await using var proxy = new FreezableTcpProxy(sim.Host, sim.Port);
var baseOptions = S7_1500Profile.BuildOptions("127.0.0.1", proxy.Port);
var options = new S7DriverOptions
{
Host = baseOptions.Host, Port = baseOptions.Port, CpuType = baseOptions.CpuType,
Rack = baseOptions.Rack, Slot = baseOptions.Slot,
Timeout = TimeSpan.FromSeconds(1), // the per-op deadline that detects the frozen peer
Probe = baseOptions.Probe,
SpanReadMaxGapBytes = -1, // one wire request per tag, so "one per tag" is countable
RawTags = baseOptions.RawTags,
};
string[] refs =
[
S7_1500Profile.ProbeTag, S7_1500Profile.SmokeI16Tag, S7_1500Profile.SmokeI32Tag,
S7_1500Profile.SmokeF32Tag, S7_1500Profile.SmokeBoolTag, S7_1500Profile.WriteScratchTag,
];
await using var drv = new S7Driver(options, driverInstanceId: "s7-frozen-peer-live");
await drv.InitializeAsync("{}", ct);
// Positive control: through the proxy, every tag reads Good.
(await drv.ReadAsync(refs, ct)).ShouldAllBe(s => s.StatusCode == 0u);
proxy.Freeze();
var frozen = await drv.ReadAsync(refs, ct);
frozen.ShouldAllBe(s => s.StatusCode != 0u);
proxy.FramesWhileFrozen.ShouldBe(1, "only the request that discovered the dead connection may go out");
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
// Recovery: the next pass reopens (a fresh proxy connection) and reads Good again.
proxy.Unfreeze();
var recovered = await drv.ReadAsync(refs, ct);
recovered.ShouldAllBe(s => s.StatusCode == 0u);
Convert.ToInt32(recovered[0].Value).ShouldBe(S7_1500Profile.ProbeSeedValue);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
}
@@ -0,0 +1,85 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests.S7_1500;
/// <summary>
/// Live gate for R07:H4 — a real CPU refusal over real S7comm. The snap7 server has no DB99, so it
/// answers a read of <c>DB99.DBW0</c> with an S7 item error. That is a per-tag Bad with no health
/// impact (and LastSuccessfulRead advances), not a Degraded that would trip the comms-loss watchdog
/// against a reachable PLC.
/// </summary>
[Collection(Snap7ServerCollection.Name)]
[Trait("Category", "Integration")]
[Trait("Device", "S7_1500")]
public sealed class S7_1500RefusalTests(Snap7ServerFixture sim)
{
/// <summary>A refused DB read is per-tag Bad, keeps the driver Healthy, and its neighbour reads Good.</summary>
[Fact]
public async Task A_read_of_a_missing_DB_is_refused_per_tag_without_degrading()
{
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
var ct = TestContext.Current.CancellationToken;
var baseOptions = S7_1500Profile.BuildOptions(sim.Host, sim.Port);
var options = new S7DriverOptions
{
Host = baseOptions.Host, Port = baseOptions.Port, CpuType = baseOptions.CpuType,
Rack = baseOptions.Rack, Slot = baseOptions.Slot, Timeout = baseOptions.Timeout,
Probe = baseOptions.Probe,
RawTags = [.. baseOptions.RawTags, .. S7RawTags.Entries(
new S7TagDefinition("Missing/Db99", "DB99.DBW0", S7DataType.UInt16, Writable: false))],
};
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.
var refused = await drv.ReadAsync(["Missing/Db99"], ct);
refused[0].StatusCode.ShouldNotBe(0u);
refused[0].Value.ShouldBeNull();
var health = drv.GetHealth();
health.State.ShouldBe(DriverState.Healthy, "a CPU refusal is not a comms fault");
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);
good[0].StatusCode.ShouldBe(0u);
Convert.ToInt32(good[0].Value).ShouldBe(S7_1500Profile.ProbeSeedValue);
}
/// <summary>A refused write is per-tag Bad and keeps the driver Healthy; a good write still lands.</summary>
[Fact]
public async Task A_write_to_a_missing_DB_is_refused_per_tag_without_degrading()
{
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
var ct = TestContext.Current.CancellationToken;
var baseOptions = S7_1500Profile.BuildOptions(sim.Host, sim.Port);
var options = new S7DriverOptions
{
Host = baseOptions.Host, Port = baseOptions.Port, CpuType = baseOptions.CpuType,
Rack = baseOptions.Rack, Slot = baseOptions.Slot, Timeout = baseOptions.Timeout,
Probe = baseOptions.Probe,
RawTags = [.. baseOptions.RawTags, .. S7RawTags.Entries(
new S7TagDefinition("Missing/Db99W", "DB99.DBW0", S7DataType.UInt16, Writable: true))],
};
await using var drv = new S7Driver(options, driverInstanceId: "s7-refusal-write-live");
await drv.InitializeAsync("{}", ct);
var refused = await drv.WriteAsync([new WriteRequest("Missing/Db99W", (ushort)5)], ct);
refused[0].StatusCode.ShouldNotBe(0u);
drv.GetHealth().State.ShouldBe(DriverState.Healthy, "a CPU write refusal is not a comms fault");
// Positive control: a write the CPU accepts still lands (and reads back).
var ok = await drv.WriteAsync([new WriteRequest(S7_1500Profile.WriteScratchTag, (ushort)4711)], ct);
ok[0].StatusCode.ShouldBe(0u);
var back = await drv.ReadAsync([S7_1500Profile.WriteScratchTag], ct);
Convert.ToInt32(back[0].Value).ShouldBe(4711);
await drv.WriteAsync([new WriteRequest(S7_1500Profile.WriteScratchTag, (ushort)0)], ct);
}
}
@@ -0,0 +1,49 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests.S7_1500;
/// <summary>
/// Live gate for R07:P2 over real S7comm. The fixture's M area is 256 bytes. <c>MW10</c> is seeded
/// (4097); <c>MD254</c> runs two bytes past the end of the area. With the gap budget opened wide
/// the planner merges them into ONE block <c>MB10..258</c>, which the CPU refuses — before the fix
/// that made the perfectly readable <c>MW10</c> Bad (and the driver Degraded) on every poll.
/// </summary>
[Collection(Snap7ServerCollection.Name)]
[Trait("Category", "Integration")]
[Trait("Device", "S7_1500")]
public sealed class S7_1500SpanRefusalTests(Snap7ServerFixture sim)
{
/// <summary>The good neighbour of an over-running member keeps reading its seeded value, every pass.</summary>
[Fact]
public async Task A_member_past_the_area_end_does_not_spoil_its_span_neighbour()
{
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
var ct = TestContext.Current.CancellationToken;
var options = new S7DriverOptions
{
Host = sim.Host, Port = sim.Port, CpuType = S7CpuType.S71500,
Timeout = TimeSpan.FromSeconds(5),
Probe = new S7ProbeOptions { Enabled = false },
// Wide enough that MW10 and MD254 land in one planned span.
SpanReadMaxGapBytes = 256,
SpanReadMaxSpanBytes = 256,
RawTags = S7RawTags.Entries(
new S7TagDefinition("Mk/Word10", "MW10", S7DataType.UInt16, Writable: false),
new S7TagDefinition("Mk/PastEnd", "MD254", S7DataType.Int32, Writable: false)),
};
await using var drv = new S7Driver(options, driverInstanceId: "s7-span-refusal-live");
await drv.InitializeAsync("{}", ct);
for (var pass = 0; pass < 3; pass++)
{
var r = await drv.ReadAsync(["Mk/Word10", "Mk/PastEnd"], ct);
r[0].StatusCode.ShouldBe(0u, $"pass {pass}: the in-range neighbour must be served");
Convert.ToInt32(r[0].Value).ShouldBe(4097);
r[1].StatusCode.ShouldNotBe(0u, "the over-running member is refused on its own");
drv.GetHealth().State.ShouldBe(DriverState.Healthy, "a refusal is not a comms fault");
}
}
}
@@ -0,0 +1,57 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests.S7_1500;
/// <summary>
/// Live gate for R07:H3 — one mis-authored tag must not darken the PLC. Before the fix each of
/// these shapes threw at init, so the driver Faulted and served nothing (and the actor retried the
/// same init every 10 s forever). Now each is skipped on its own and the good tags keep reading
/// their seeded values over real S7comm.
/// </summary>
[Collection(Snap7ServerCollection.Name)]
[Trait("Category", "Integration")]
[Trait("Device", "S7_1500")]
public sealed class S7_1500TagShapeSkipTests(Snap7ServerFixture sim)
{
/// <summary>Unservable tags are skipped per tag; the seeded neighbours still read Good.</summary>
[Fact]
public async Task Mis_authored_tags_are_skipped_and_the_rest_of_the_PLC_still_reads()
{
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
var ct = TestContext.Current.CancellationToken;
var good = S7_1500Profile.BuildOptions(sim.Host, sim.Port);
var bad = new[]
{
new S7TagDefinition("Bad/Typo", "DB1.DBZ0", S7DataType.Int16),
new S7TagDefinition("Bad/WideOnWord", "DB1.DBW0", S7DataType.Float64),
new S7TagDefinition("Bad/TimerAsInt", "T5", S7DataType.Int32, Writable: false),
new S7TagDefinition("Bad/OffsetBoolArray", "DB1.DBX50.3", S7DataType.Bool, Writable: false, ArrayCount: 8),
};
var options = new S7DriverOptions
{
Host = good.Host, Port = good.Port, CpuType = good.CpuType, Rack = good.Rack, Slot = good.Slot,
Timeout = good.Timeout, Probe = good.Probe,
RawTags = [.. good.RawTags, .. S7RawTags.Entries(bad)],
};
await using var drv = new S7Driver(options, driverInstanceId: "s7-shape-skip-live");
await drv.InitializeAsync("{}", ct);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
drv.GetSkippedTags().ShouldNotBeNull().Sample.Select(s => s.RawPath)
.ShouldBe(bad.Select(b => b.Name), ignoreOrder: true);
var r = await drv.ReadAsync(
[S7_1500Profile.ProbeTag, S7_1500Profile.SmokeI16Tag, S7_1500Profile.SmokeBoolTag, bad[0].Name], ct);
r[0].StatusCode.ShouldBe(0u);
Convert.ToInt32(r[0].Value).ShouldBe(S7_1500Profile.ProbeSeedValue);
r[1].StatusCode.ShouldBe(0u);
Convert.ToInt32(r[1].Value).ShouldBe((int)S7_1500Profile.SmokeI16SeedValue);
r[2].StatusCode.ShouldBe(0u);
Convert.ToBoolean(r[2].Value).ShouldBeTrue();
r[3].StatusCode.ShouldBe(0x80340000u, "a skipped tag reads BadNodeIdUnknown");
}
}
@@ -0,0 +1,32 @@
using Microsoft.Extensions.Logging;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
/// <summary>An <see cref="ILogger{T}"/> that records (level, message) pairs.</summary>
internal sealed class CapturingS7Logger : ILogger<S7Driver>
{
private readonly List<(LogLevel Level, string Message)> _entries = [];
/// <summary>A snapshot of every entry logged so far.</summary>
public IReadOnlyList<(LogLevel Level, string Message)> Entries
{
get { lock (_entries) return [.. _entries]; }
}
/// <summary>The Warning messages logged so far.</summary>
public IReadOnlyList<string> Warnings
=> [.. Entries.Where(e => e.Level == LogLevel.Warning).Select(e => e.Message)];
/// <inheritdoc />
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
/// <inheritdoc />
public bool IsEnabled(LogLevel logLevel) => true;
/// <inheritdoc />
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
Func<TState, Exception?, string> formatter)
{
lock (_entries) _entries.Add((logLevel, formatter(state, exception)));
}
}
@@ -243,7 +243,9 @@ public sealed class S7ArrayWriteAndHealthTests
var (drv, factory) = await TwoTagDriver(); var (drv, factory) = await TwoTagDriver();
using var _ = drv; using var _ = drv;
factory.Created[0].WriteFaults["DB1.DBW0"] = new PlcException(ErrorCode.WriteData, "Accessing object not allowed"); factory.Created[0].WriteFaults["DB1.DBW0"] = new PlcException(ErrorCode.WriteData, "Accessing object not allowed");
factory.Created[0].WriteFaults["DB1.DBW2"] = new IOException("socket glitch"); // A Degraded failure that is NOT connection-fatal: since R07:P1 a fatal one (an IOException)
// would fail the rest of the batch without I/O, and C must reach the wire to be the success.
factory.Created[0].WriteFaults["DB1.DBW2"] = new PlcException(ErrorCode.SendData, "device glitch");
var res = await drv.WriteAsync( var res = await drv.WriteAsync(
[new WriteRequest("A", (ushort)1), new WriteRequest("B", (ushort)2), new WriteRequest("C", (ushort)3)], [new WriteRequest("A", (ushort)1), new WriteRequest("B", (ushort)2), new WriteRequest("C", (ushort)3)],
@@ -0,0 +1,167 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
/// <summary>
/// R07:P1 — once a pass has marked the connection dead (a wire op blew its deadline on a frozen
/// peer), the rest of that pass must not go to the wire: each further op would pay the full
/// timeout under the gate, so a 20-span pass took ~100 s and the comms-loss watchdog's clock only
/// started after it. The remaining items are stamped <c>BadCommunicationError</c> without I/O.
/// Every assertion counts wire operations; nothing is timed.
/// </summary>
[Trait("Category", "Unit")]
public sealed class S7DeadHandleShortCircuitTests
{
private const uint BadCommunicationError = 0x80050000u;
private static S7DriverOptions Options(int maxGap, params S7TagDefinition[] tags) => new()
{
Host = "192.0.2.1",
Timeout = TimeSpan.FromSeconds(5),
Probe = new S7ProbeOptions { Enabled = false },
SpanReadMaxGapBytes = maxGap,
RawTags = S7RawTags.Entries(tags),
};
/// <summary>Five isolated words — far enough apart that each is its own span.</summary>
private static S7TagDefinition[] FarApartWords() =>
[.. Enumerable.Range(0, 5).Select(i => new S7TagDefinition($"W{i}", $"DB{i + 1}.DBW0", S7DataType.UInt16, Writable: true))];
private static string[] Refs(int n) => [.. Enumerable.Range(0, n).Select(i => $"W{i}")];
private static async Task<(S7Driver, ScriptedS7PlcFactory)> StartAsync(S7DriverOptions options)
{
var factory = new ScriptedS7PlcFactory();
var drv = new S7Driver(options, "s7-dead-short-circuit", factory);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
return (drv, factory);
}
/// <summary>Per-tag path: the first timeout ends the pass's wire traffic.</summary>
[Fact]
public async Task Per_tag_pass_stops_issuing_wire_ops_once_the_handle_is_dead()
{
var ct = TestContext.Current.CancellationToken;
var (drv, factory) = await StartAsync(Options(maxGap: -1, FarApartWords()));
using var _ = drv;
factory.Read = (_, _) => throw new TimeoutException("frozen peer");
var before = factory.WireOps;
var r = await drv.ReadAsync(Refs(5), ct);
(factory.WireOps - before).ShouldBe(1, "only the op that discovered the dead handle may go to the wire");
r.ShouldAllBe(s => s.StatusCode == BadCommunicationError);
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
// Positive control: the next pass reopens and serves every tag.
factory.Read = (_, _) => Task.FromResult<object?>((ushort)1);
var recovered = await drv.ReadAsync(Refs(5), ct);
recovered.ShouldAllBe(s => s.StatusCode == 0u);
factory.Created.Count.ShouldBe(2);
}
/// <summary>Span path: arrays are block reads even alone, so five arrays are five spans.</summary>
[Fact]
public async Task Span_pass_stops_issuing_wire_ops_once_the_handle_is_dead()
{
var ct = TestContext.Current.CancellationToken;
S7TagDefinition[] arrays =
[.. Enumerable.Range(0, 5).Select(i => new S7TagDefinition($"W{i}", $"DB{i + 1}.DBW0", S7DataType.UInt16, Writable: false, ArrayCount: 2))];
var (drv, factory) = await StartAsync(Options(maxGap: 16, arrays));
using var _ = drv;
factory.ReadBytes = (_, _, _, _, _) => throw new TimeoutException("frozen peer");
var before = factory.WireOps;
var r = await drv.ReadAsync(Refs(5), ct);
(factory.WireOps - before).ShouldBe(1);
r.ShouldAllBe(s => s.StatusCode == BadCommunicationError);
}
/// <summary>
/// Multi-var path: a chunk that dies on a transport loss used to fall back to one per-tag read
/// per member — each paying the timeout again. Now it marks the handle dead and the fallback is
/// short-circuited.
/// </summary>
[Fact]
public async Task Multi_var_transport_loss_does_not_fall_back_to_per_tag_wire_reads()
{
var ct = TestContext.Current.CancellationToken;
var (drv, factory) = await StartAsync(Options(maxGap: 0, FarApartWords()));
using var _ = drv;
factory.ReadMulti = (_, _) => throw new TimeoutException("frozen peer");
factory.Read = (_, _) => throw new TimeoutException("frozen peer");
var before = factory.WireOps;
var r = await drv.ReadAsync(Refs(5), ct);
(factory.WireOps - before).ShouldBe(1);
r.ShouldAllBe(s => s.StatusCode == BadCommunicationError);
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
}
/// <summary>
/// Positive control for the classification: a multi-var REFUSAL (not a transport loss) still
/// falls back per tag, so the members the CPU will serve are served.
/// </summary>
[Fact]
public async Task Multi_var_refusal_still_falls_back_per_tag()
{
var ct = TestContext.Current.CancellationToken;
var (drv, factory) = await StartAsync(Options(maxGap: 0, FarApartWords()));
using var _ = drv;
// The live snap7 shape of a multi-var with one refused item.
factory.ReadMulti = (_, _) => throw new global::S7.Net.PlcException(global::S7.Net.ErrorCode.ReadData,
new global::S7.Net.PlcException(global::S7.Net.ErrorCode.WrongNumberReceivedBytes));
factory.Read = (_, _) => Task.FromResult<object?>((ushort)9);
var before = factory.WireOps;
var r = await drv.ReadAsync(Refs(5), ct);
(factory.WireOps - before).ShouldBe(6, "one multi-var request, then one per-tag read each");
r.ShouldAllBe(s => s.StatusCode == 0u);
factory.Created.Count.ShouldBe(1, "a refusal is not a reason to reopen");
}
/// <summary>A refusal does not short-circuit the pass: the CPU answered, the handle is fine.</summary>
[Fact]
public async Task A_refusal_does_not_short_circuit_the_pass()
{
var ct = TestContext.Current.CancellationToken;
var (drv, factory) = await StartAsync(Options(maxGap: -1, FarApartWords()));
using var _ = drv;
factory.Read = (addr, _) => addr == "DB1.DBW0"
? throw new Exception("Received error from PLC: Object does not exist.")
: Task.FromResult<object?>((ushort)3);
var before = factory.WireOps;
var r = await drv.ReadAsync(Refs(5), ct);
(factory.WireOps - before).ShouldBe(5);
r.Skip(1).ShouldAllBe(s => s.StatusCode == 0u);
}
/// <summary>Write batch: the first transport loss fails the rest of the batch without I/O.</summary>
[Fact]
public async Task Write_batch_stops_issuing_wire_ops_once_the_handle_is_dead()
{
var ct = TestContext.Current.CancellationToken;
var (drv, factory) = await StartAsync(Options(maxGap: -1, FarApartWords()));
using var _ = drv;
factory.Write = (_, _, _) => throw new System.IO.IOException("connection reset");
var before = factory.WireOps;
var w = await drv.WriteAsync([.. Refs(5).Select(r => new WriteRequest(r, (ushort)1))], ct);
(factory.WireOps - before).ShouldBe(1);
w.ShouldAllBe(x => x.StatusCode == BadCommunicationError);
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
// Positive control: the next batch reopens and writes.
factory.Write = (_, _, _) => Task.CompletedTask;
(await drv.WriteAsync([.. Refs(5).Select(r => new WriteRequest(r, (ushort)1))], ct))
.ShouldAllBe(x => x.StatusCode == 0u);
}
}
@@ -25,26 +25,10 @@ public sealed class S7DriverCodeReviewFixTests
[InlineData("C10")] [InlineData("C10")]
public async Task Initialize_rejects_timer_or_counter_tag_with_NotSupportedException(string address) public async Task Initialize_rejects_timer_or_counter_tag_with_NotSupportedException(string address)
{ {
// A Timer/Counter address parses cleanly but the read path has no decode case for it, // A Timer/Counter address typed anything but Float64 (Timer) / Int32 (Counter) cannot be decoded.
// so it must fail fast at init rather than throw a misleading type-mismatch on every // It is refused PER TAG at every gate; it used to throw at init and fault every tag on the PLC.
// read. The host is reserved-for-documentation so the TCP connect can never succeed — await S7TagSkipAssert.RefusedPerTagAsync(
// the unsupported-address guard runs before the connect, so the NotSupportedException new S7TagDefinition("Quirk", address, S7DataType.Int16, Writable: false), address);
// is what surfaces.
var opts = new S7DriverOptions
{
Host = "192.0.2.1",
Timeout = TimeSpan.FromMilliseconds(250),
RawTags = S7RawTags.Entries(new S7TagDefinition("Quirk", address, S7DataType.Int16)),
};
using var drv = new S7Driver(opts, "s7-tc");
var ex = await Should.ThrowAsync<NotSupportedException>(async () =>
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
ex.Message.ShouldContain(address);
var health = drv.GetHealth();
health.State.ShouldBe(DriverState.Faulted, "an unsupported-address config error must Fault the driver");
health.LastError.ShouldNotBeNull();
} }
/// <summary>Verifies that initialize accepts DB and MIQ addresses without triggering the unsupported guard.</summary> /// <summary>Verifies that initialize accepts DB and MIQ addresses without triggering the unsupported guard.</summary>
@@ -102,19 +86,21 @@ public sealed class S7DriverCodeReviewFixTests
[Fact] [Fact]
public async Task Initialize_rejects_a_timer_tag_supplied_only_through_driverConfigJson() public async Task Initialize_rejects_a_timer_tag_supplied_only_through_driverConfigJson()
{ {
// The Timer/Counter guard must run against the *re-parsed* config, not just the // The shape gate must run against the *re-parsed* config, not just the constructor options —
// constructor options — proves Driver.S7-001 and Driver.S7-011 compose correctly. // proves the per-tag skip and Driver.S7-011 compose correctly.
var ctorOpts = new S7DriverOptions { Host = "192.0.2.1", Timeout = TimeSpan.FromMilliseconds(250) }; var ctorOpts = new S7DriverOptions { Host = "192.0.2.1", Timeout = TimeSpan.FromMilliseconds(250) };
using var drv = new S7Driver(ctorOpts, "s7-cfg-tc"); using var drv = new S7Driver(ctorOpts, "s7-cfg-tc", new ScriptedS7PlcFactory());
const string json = """ const string json = """
{ "Host": "192.0.2.1", "TimeoutMs": 250, { "Host": "192.0.2.1", "TimeoutMs": 250, "Probe": { "Enabled": false },
"RawTags": [ { "RawPath": "TimerTag", "TagConfig": "{\"address\":\"T5\",\"dataType\":\"Int16\"}", "WriteIdempotent": false } ] } "RawTags": [ { "RawPath": "TimerTag", "TagConfig": "{\"address\":\"T5\",\"dataType\":\"Int16\"}", "WriteIdempotent": false } ] }
"""; """;
var ex = await Should.ThrowAsync<NotSupportedException>(async () => await drv.InitializeAsync(json, TestContext.Current.CancellationToken);
await drv.InitializeAsync(json, TestContext.Current.CancellationToken)); drv.GetHealth().State.ShouldBe(DriverState.Healthy);
ex.Message.ShouldContain("T5"); var skipped = drv.GetSkippedTags().ShouldNotBeNull().Sample.ShouldHaveSingleItem();
skipped.RawPath.ShouldBe("TimerTag");
skipped.Reason.ShouldContain("T5");
} }
/// <summary>Verifies that reinitialize applies a changed driverConfigJson.</summary> /// <summary>Verifies that reinitialize applies a changed driverConfigJson.</summary>
@@ -158,23 +158,9 @@ public sealed class S7DriverCodeReviewFixTests2
[InlineData(S7DataType.DateTime)] [InlineData(S7DataType.DateTime)]
public async Task Initialize_rejects_wide_type_at_non_byte_address_with_NotSupportedException(S7DataType dt) public async Task Initialize_rejects_wide_type_at_non_byte_address_with_NotSupportedException(S7DataType dt)
{ {
var opts = new S7DriverOptions // DB1.DBD0 is a DWord (non-byte) address — a wide type there would mis-frame its value. (At a
{ // byte address like DB1.DBB0 the 8-byte numerics round-trip; see S7ScalarBlockTests.)
Host = "192.0.2.1", await S7TagSkipAssert.RefusedPerTagAsync(new S7TagDefinition("X", "DB1.DBD0", dt), dt.ToString(), "byte-addressed");
Timeout = TimeSpan.FromMilliseconds(250),
// DB1.DBD0 is a DWord (non-byte) address — guard-(b) must fault on the address shape
// for a wide type. (At a byte address like DB1.DBB0 the 8-byte numerics round-trip;
// see S7ScalarBlockTests.)
RawTags = S7RawTags.Entries(new S7TagDefinition("X", "DB1.DBD0", dt)),
};
using var drv = new S7Driver(opts, $"s7-wide-nonbyte-{dt}");
var ex = await Should.ThrowAsync<NotSupportedException>(async () =>
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
ex.Message.ShouldContain(dt.ToString());
var health = drv.GetHealth();
health.State.ShouldBe(DriverState.Faulted);
} }
/// <summary>Verifies that Initialize accepts implemented data types.</summary> /// <summary>Verifies that Initialize accepts implemented data types.</summary>
@@ -17,22 +17,11 @@ public sealed class S7DriverReadWriteTests
[Fact] [Fact]
public async Task Initialize_rejects_invalid_tag_address_and_fails_fast() public async Task Initialize_rejects_invalid_tag_address_and_fails_fast()
{ {
// Bad address at init time must throw; the alternative (deferring the parse to the // A bad address is refused PER TAG at every gate (mapper/editor, deploy gate, driver init):
// first read) would surface the config bug as BadInternalError on every subsequent // it reads BadNodeIdUnknown while its neighbours keep working. It used to throw at init and
// Read which is impossible for an operator to diagnose from the OPC UA client. // fault every tag on the PLC, with the actor retrying the same init every 10 s forever.
var opts = new S7DriverOptions await S7TagSkipAssert.RefusedPerTagAsync(
{ new S7TagDefinition("BadTag", "NOT-AN-S7-ADDRESS", S7DataType.Int16), "does not parse");
Host = "192.0.2.1", // reserved — will never complete TCP handshake
Timeout = TimeSpan.FromMilliseconds(250),
RawTags = S7RawTags.Entries(new S7TagDefinition("BadTag", "NOT-AN-S7-ADDRESS", S7DataType.Int16)),
};
using var drv = new S7Driver(opts, "s7-bad-tag");
// Either the TCP connect fails first (Exception) or the parser fails (FormatException)
// — both are acceptable since both are init-time fail-fast. What matters is that we
// don't return a "healthy" driver with a latent bad tag.
await Should.ThrowAsync<Exception>(async () =>
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
} }
/// <summary>Verifies that ReadAsync without initialize throws InvalidOperationException.</summary> /// <summary>Verifies that ReadAsync without initialize throws InvalidOperationException.</summary>
@@ -101,80 +101,32 @@ public sealed class S7DriverScaffoldTests
[Fact] [Fact]
public async Task Initialize_rejects_wide_type_array_with_clear_message() public async Task Initialize_rejects_wide_type_array_with_clear_message()
{ {
var opts = new S7DriverOptions // Refused PER TAG at every gate (it used to throw at init and fault the whole driver).
{ await S7TagSkipAssert.RefusedPerTagAsync(new S7TagDefinition("Batch64", "DB1.DBB0", S7DataType.Int64, ArrayCount: 4), "Int64", "array");
Host = "192.0.2.1",
Timeout = TimeSpan.FromMilliseconds(250),
RawTags = S7RawTags.Entries(new S7TagDefinition("Batch64", "DB1.DBB0", S7DataType.Int64, ArrayCount: 4)),
};
using var drv = new S7Driver(opts, "s7-wide-array");
var ex = await Should.ThrowAsync<NotSupportedException>(async () =>
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
ex.Message.ShouldContain("Batch64");
ex.Message.ShouldContain("array", Case.Insensitive);
drv.GetHealth().State.ShouldBe(DriverState.Faulted);
} }
/// <summary>Verifies that a wide scalar with a non-byte address (Float64 at DB1.DBW0) is rejected with a byte-address message.</summary> /// <summary>Verifies that a wide scalar with a non-byte address (Float64 at DB1.DBW0) is rejected with a byte-address message.</summary>
[Fact] [Fact]
public async Task Initialize_rejects_wide_scalar_with_non_byte_address() public async Task Initialize_rejects_wide_scalar_with_non_byte_address()
{ {
var opts = new S7DriverOptions // Refused PER TAG at every gate (it used to throw at init and fault the whole driver).
{ await S7TagSkipAssert.RefusedPerTagAsync(new S7TagDefinition("WideWord", "DB1.DBW0", S7DataType.Float64), "Float64", "byte-addressed");
Host = "192.0.2.1",
Timeout = TimeSpan.FromMilliseconds(250),
RawTags = S7RawTags.Entries(new S7TagDefinition("WideWord", "DB1.DBW0", S7DataType.Float64)),
};
using var drv = new S7Driver(opts, "s7-wide-nonbyte");
var ex = await Should.ThrowAsync<NotSupportedException>(async () =>
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
ex.Message.ShouldContain("WideWord");
ex.Message.ShouldContain("byte", Case.Insensitive);
drv.GetHealth().State.ShouldBe(DriverState.Faulted);
} }
/// <summary>Verifies that a Timer tag with the wrong DataType (Int32, not Float64) is rejected.</summary> /// <summary>Verifies that a Timer tag with the wrong DataType (Int32, not Float64) is rejected.</summary>
[Fact] [Fact]
public async Task Initialize_rejects_Timer_tag_with_wrong_data_type() public async Task Initialize_rejects_Timer_tag_with_wrong_data_type()
{ {
var opts = new S7DriverOptions // Refused PER TAG at every gate (it used to throw at init and fault the whole driver).
{ await S7TagSkipAssert.RefusedPerTagAsync(new S7TagDefinition("Timer5", "T5", S7DataType.Int32, Writable: false), "T5", "Float64");
Host = "192.0.2.1",
Timeout = TimeSpan.FromMilliseconds(250),
RawTags = S7RawTags.Entries(new S7TagDefinition("Timer5", "T5", S7DataType.Int32)),
};
using var drv = new S7Driver(opts, "s7-timer-bad");
var ex = await Should.ThrowAsync<NotSupportedException>(async () =>
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
ex.Message.ShouldContain("Timer5");
ex.Message.ShouldContain("Float64");
drv.GetHealth().State.ShouldBe(DriverState.Faulted);
} }
/// <summary>Verifies that a Counter tag with the wrong DataType (Float32, not Int32) is rejected.</summary> /// <summary>Verifies that a Counter tag with the wrong DataType (Float32, not Int32) is rejected.</summary>
[Fact] [Fact]
public async Task Initialize_rejects_Counter_tag_with_wrong_data_type() public async Task Initialize_rejects_Counter_tag_with_wrong_data_type()
{ {
var opts = new S7DriverOptions // Refused PER TAG at every gate (it used to throw at init and fault the whole driver).
{ await S7TagSkipAssert.RefusedPerTagAsync(new S7TagDefinition("Counter3", "C3", S7DataType.Float32, Writable: false), "C3", "Int32");
Host = "192.0.2.1",
Timeout = TimeSpan.FromMilliseconds(250),
RawTags = S7RawTags.Entries(new S7TagDefinition("Counter3", "C3", S7DataType.Float32)),
};
using var drv = new S7Driver(opts, "s7-counter-bad");
var ex = await Should.ThrowAsync<NotSupportedException>(async () =>
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
ex.Message.ShouldContain("Counter3");
ex.Message.ShouldContain("Int32");
drv.GetHealth().State.ShouldBe(DriverState.Faulted);
} }
// ── Phase 4d bundle-review fix — Writable Timer/Counter guard ─────────────────────── // ── Phase 4d bundle-review fix — Writable Timer/Counter guard ───────────────────────
@@ -188,42 +140,16 @@ public sealed class S7DriverScaffoldTests
[Fact] [Fact]
public async Task Initialize_rejects_Timer_tag_declared_Writable() public async Task Initialize_rejects_Timer_tag_declared_Writable()
{ {
var opts = new S7DriverOptions // Refused PER TAG at every gate (it used to throw at init and fault the whole driver).
{ await S7TagSkipAssert.RefusedPerTagAsync(new S7TagDefinition("Timer5", "T5", S7DataType.Float64, Writable: true), "T5", "writable", "read-only");
Host = "192.0.2.1",
Timeout = TimeSpan.FromMilliseconds(250),
RawTags = S7RawTags.Entries(new S7TagDefinition("Timer5", "T5", S7DataType.Float64, Writable: true)),
};
using var drv = new S7Driver(opts, "s7-timer-writable");
var ex = await Should.ThrowAsync<NotSupportedException>(async () =>
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
ex.Message.ShouldContain("Timer5");
ex.Message.ShouldContain("Writable", Case.Insensitive);
ex.Message.ShouldContain("read-only", Case.Insensitive);
drv.GetHealth().State.ShouldBe(DriverState.Faulted);
} }
/// <summary>Verifies that a Counter tag declared Writable=true is rejected at init with a Writable message.</summary> /// <summary>Verifies that a Counter tag declared Writable=true is rejected at init with a Writable message.</summary>
[Fact] [Fact]
public async Task Initialize_rejects_Counter_tag_declared_Writable() public async Task Initialize_rejects_Counter_tag_declared_Writable()
{ {
var opts = new S7DriverOptions // Refused PER TAG at every gate (it used to throw at init and fault the whole driver).
{ await S7TagSkipAssert.RefusedPerTagAsync(new S7TagDefinition("Counter3", "C3", S7DataType.Int32, Writable: true), "C3", "writable", "read-only");
Host = "192.0.2.1",
Timeout = TimeSpan.FromMilliseconds(250),
RawTags = S7RawTags.Entries(new S7TagDefinition("Counter3", "C3", S7DataType.Int32, Writable: true)),
};
using var drv = new S7Driver(opts, "s7-counter-writable");
var ex = await Should.ThrowAsync<NotSupportedException>(async () =>
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
ex.Message.ShouldContain("Counter3");
ex.Message.ShouldContain("Writable", Case.Insensitive);
ex.Message.ShouldContain("read-only", Case.Insensitive);
drv.GetHealth().State.ShouldBe(DriverState.Faulted);
} }
// ── Driver.S7-015 / S7-016 — writable Bool array must fail fast at init ───────────── // ── Driver.S7-015 / S7-016 — writable Bool array must fail fast at init ─────────────
@@ -236,20 +162,8 @@ public sealed class S7DriverScaffoldTests
[Fact] [Fact]
public async Task Initialize_rejects_writable_bool_array_tag_with_NotSupportedException() public async Task Initialize_rejects_writable_bool_array_tag_with_NotSupportedException()
{ {
var opts = new S7DriverOptions // Refused PER TAG at every gate (it used to throw at init and fault the whole driver).
{ await S7TagSkipAssert.RefusedPerTagAsync(new S7TagDefinition("ArrTag", "DB1.DBX0.0", S7DataType.Bool, Writable: true, ArrayCount: 4), "Bool array", "writable");
Host = "192.0.2.1",
Timeout = TimeSpan.FromMilliseconds(250),
RawTags = S7RawTags.Entries(new S7TagDefinition("ArrTag", "DB1.DBX0.0", S7DataType.Bool, Writable: true, ArrayCount: 4)),
};
using var drv = new S7Driver(opts, "s7-writable-bool-arr");
var ex = await Should.ThrowAsync<NotSupportedException>(async () =>
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
ex.Message.ShouldContain("ArrTag");
ex.Message.ShouldContain("Bool array", Case.Insensitive);
drv.GetHealth().State.ShouldBe(DriverState.Faulted);
} }
/// <summary>Verifies that writable non-bool arrays pass the init guard — the failure /// <summary>Verifies that writable non-bool arrays pass the init guard — the failure
@@ -0,0 +1,206 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
/// <summary>
/// R07:H5 — overlapping lifecycle calls. <c>DriverInstanceActor</c> dispatches initialize on a
/// fire-and-forget task and a reconnect retry can start a second attempt while the first is still in
/// <c>OpenAsync</c>; an abandoned initialize can also still be running when the next shutdown starts.
/// Every wait here is a condition wait on the fake's own signals, never a sleep.
/// </summary>
[Trait("Category", "Unit")]
public sealed class S7LifecycleGateTests
{
private static readonly TimeSpan Bound = TimeSpan.FromSeconds(10);
private static S7DriverOptions Options(bool probe = false) => new()
{
Host = "192.0.2.1",
Timeout = TimeSpan.FromSeconds(30), // the fake's gated open is released by the test, never timed out
Probe = new S7ProbeOptions { Enabled = probe, Interval = TimeSpan.FromSeconds(30) },
RawTags = S7RawTags.Entries(new S7TagDefinition("W0", "DB1.DBW0", S7DataType.UInt16, Writable: false)),
};
/// <summary>A factory whose FIRST open blocks until released; later opens complete immediately.</summary>
private sealed class FirstOpenBlocks
{
public readonly ScriptedS7PlcFactory Factory = new();
public readonly TaskCompletionSource FirstOpenEntered = new(TaskCreationOptions.RunContinuationsAsynchronously);
public readonly TaskCompletionSource ReleaseFirstOpen = new(TaskCreationOptions.RunContinuationsAsynchronously);
public FirstOpenBlocks()
{
Factory.Open = async (index, ct) =>
{
if (index != 0) return;
FirstOpenEntered.TrySetResult();
await ReleaseFirstOpen.Task.WaitAsync(ct).ConfigureAwait(false);
};
}
}
/// <summary>
/// A second initialize waits for the first instead of running beside it, then releases the first
/// attempt's connection: exactly one live connection remains. Before the gate, attempt B opened
/// its own connection while A was still in <c>OpenAsync</c>, and A's published connection (and
/// probe loop) was overwritten and never released.
/// </summary>
[Fact]
public async Task Overlapping_initializes_are_serialised_and_leave_one_live_connection()
{
var ct = TestContext.Current.CancellationToken;
var f = new FirstOpenBlocks();
using var drv = new S7Driver(Options(), "s7-overlap", f.Factory);
var a = drv.InitializeAsync("{}", ct);
await f.FirstOpenEntered.Task.WaitAsync(Bound, ct);
var b = drv.InitializeAsync("{}", ct);
// Positive control first: B is genuinely pending (not finished, not faulted)...
b.IsCompleted.ShouldBeFalse();
// ...and while A holds the gate B has not created a connection of its own. B can only reach
// its open after A completes, so this is not a race the test can lose by being slow.
f.Factory.Created.Count.ShouldBe(1, "a second initialize must not open beside the first");
f.ReleaseFirstOpen.SetResult();
await a.WaitAsync(Bound, ct);
await b.WaitAsync(Bound, ct);
f.Factory.Created.Count.ShouldBe(2);
f.Factory.Created[0].Disposed.ShouldBeTrue("B releases A's published connection before replacing it");
f.Factory.Created[1].Disposed.ShouldBeFalse();
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
(await drv.ReadAsync(["W0"], ct))[0].StatusCode.ShouldBe(0u);
}
/// <summary>
/// An initialize still running when a shutdown gives up waiting for it must not publish its
/// connection, its health or its probe loop over the shut-down driver; it fails and disposes what
/// it opened. Before the fix the late attempt published Healthy + a live connection after the
/// shutdown returned.
/// </summary>
[Fact]
public async Task An_initialize_superseded_by_shutdown_publishes_nothing()
{
var ct = TestContext.Current.CancellationToken;
var f = new FirstOpenBlocks();
using var drv = new S7Driver(Options(probe: true), "s7-superseded", f.Factory)
{
LifecycleGateTimeout = TimeSpan.FromSeconds(1),
};
var init = drv.InitializeAsync("{}", ct);
await f.FirstOpenEntered.Task.WaitAsync(Bound, ct);
// The shutdown cannot get the lifecycle gate (the attempt holds it), gives up after its bound
// and tears down anyway.
await drv.ShutdownAsync(ct).WaitAsync(Bound, ct);
drv.GetHealth().State.ShouldBe(DriverState.Unknown);
// The abandoned attempt now completes its open...
f.ReleaseFirstOpen.SetResult();
var ex = await Should.ThrowAsync<InvalidOperationException>(() => init.WaitAsync(Bound, ct));
ex.Message.ShouldContain("superseded");
// ...and published nothing: no connection kept, health untouched, the driver still shut down.
f.Factory.Created.ShouldHaveSingleItem().Disposed.ShouldBeTrue();
drv.GetHealth().State.ShouldBe(DriverState.Unknown, "a superseded attempt must not write Healthy or Faulted");
await Should.ThrowAsync<InvalidOperationException>(() => drv.ReadAsync(["W0"], ct));
// Positive control: the driver is not wedged — a fresh initialize publishes normally.
await drv.InitializeAsync("{}", ct).WaitAsync(Bound, ct);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
(await drv.ReadAsync(["W0"], ct))[0].StatusCode.ShouldBe(0u);
}
/// <summary>
/// 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.
/// </summary>
[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);
}
/// <summary>
/// Review m-8: the SYNCHRONOUS <c>Dispose()</c> racing a still-running <c>ShutdownAsync</c> is safe too
/// — neither throws, the superseded init publishes nothing, and the driver ends torn down.
/// </summary>
[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<InvalidOperationException>(() => init.WaitAsync(Bound, ct));
f.Factory.Created.ShouldHaveSingleItem().Disposed.ShouldBeTrue();
drv.GetHealth().State.ShouldBe(DriverState.Unknown);
}
/// <summary>
/// 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
/// ObjectDisposedException.
/// </summary>
[Fact]
public async Task Dispose_while_a_shutdown_is_still_running_is_safe()
{
var ct = TestContext.Current.CancellationToken;
var f = new FirstOpenBlocks();
var drv = new S7Driver(Options(), "s7-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
var dispose = drv.DisposeAsync().AsTask();
await shutdown.WaitAsync(Bound, ct);
await dispose.WaitAsync(Bound, ct);
f.ReleaseFirstOpen.SetResult();
await Should.ThrowAsync<InvalidOperationException>(() => init.WaitAsync(Bound, ct));
f.Factory.Created.ShouldHaveSingleItem().Disposed.ShouldBeTrue();
drv.GetHealth().State.ShouldBe(DriverState.Unknown);
}
}
@@ -0,0 +1,121 @@
using System.Net;
using System.Net.Sockets;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
/// <summary>
/// R07:H7 — (a) the background probe used to report the host <c>Stopped</c> whenever a long read
/// pass held the gate past the probe deadline: contention is the connection being USED, not lost;
/// (b) Test Connect ignored the config's <c>timeoutMs</c>. Waits are condition waits on counters and
/// events; bounds are generous.
/// </summary>
[Trait("Category", "Unit")]
public sealed class S7ProbeContentionAndTimeoutTests
{
private static readonly TimeSpan Bound = TimeSpan.FromSeconds(15);
private static async Task WaitUntilAsync(Func<bool> condition, CancellationToken ct)
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(Bound);
while (!condition())
await Task.Delay(20, cts.Token); // polling a condition, not asserting on elapsed time
}
/// <summary>A read pass holding the gate past the probe deadline does not make the host Stopped.</summary>
[Fact]
public async Task Gate_contention_is_no_verdict_but_a_real_probe_failure_still_is()
{
var ct = TestContext.Current.CancellationToken;
var factory = new ScriptedS7PlcFactory();
var readEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var releaseRead = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
using var drv = new S7Driver(new S7DriverOptions
{
Host = "192.0.2.1",
Timeout = TimeSpan.FromSeconds(30),
Probe = new S7ProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(50), Timeout = TimeSpan.FromSeconds(1) },
RawTags = S7RawTags.Entries(new S7TagDefinition("W0", "DB1.DBW0", S7DataType.UInt16, Writable: false)),
}, "s7-probe-contention", factory);
var transitions = new List<HostState>();
drv.OnHostStatusChanged += (_, e) => { lock (transitions) transitions.Add(e.NewState); };
await drv.InitializeAsync("{}", ct);
await WaitUntilAsync(() => drv.GetHostStatuses()[0].State == HostState.Running, ct);
// A read that holds the gate until released.
factory.Read = async (_, token) =>
{
readEntered.TrySetResult();
await releaseRead.Task.WaitAsync(token);
return (ushort)1;
};
var read = drv.ReadAsync(["W0"], ct);
await readEntered.Task.WaitAsync(Bound, ct);
// At least two probe ticks give up on the gate...
var contentionsBefore = drv.ProbeGateContentions;
await WaitUntilAsync(() => drv.ProbeGateContentions >= contentionsBefore + 2, ct);
// ...and the host is still Running — no Stopped transition was published for them.
drv.GetHostStatuses()[0].State.ShouldBe(HostState.Running);
lock (transitions) transitions.ShouldNotContain(HostState.Stopped);
releaseRead.SetResult();
(await read.WaitAsync(Bound, ct))[0].StatusCode.ShouldBe(0u);
// Positive control: a genuine probe failure still reports Stopped.
factory.ReadStatus = _ => throw new System.IO.IOException("connection reset");
await WaitUntilAsync(() => drv.GetHostStatuses()[0].State == HostState.Stopped, ct);
}
/// <summary>Test Connect's handshake is bounded by the config's timeoutMs, not the caller's whole budget.</summary>
[Fact]
public async Task Test_connect_honours_the_config_timeoutMs()
{
var ct = TestContext.Current.CancellationToken;
// A TCP endpoint that accepts and then never answers the ISO-on-TCP handshake.
var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var held = new List<TcpClient>();
_ = Task.Run(async () =>
{
try { while (true) { var c = await listener.AcceptTcpClientAsync(ct); lock (held) held.Add(c); } }
catch { /* stopped */ }
}, ct);
try
{
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
var json = $$"""{"host":"127.0.0.1","port":{{port}},"timeoutMs":1000}""";
// The caller's budget (60 s) is far beyond the safety token (20 s): only a probe that honours
// timeoutMs returns its own "handshake failed: timed out"; one that waits the caller's budget
// is cut by the safety token and reports the caller-timeout message instead.
using var safety = CancellationTokenSource.CreateLinkedTokenSource(ct);
safety.CancelAfter(TimeSpan.FromSeconds(20));
var r = await new S7DriverProbe().ProbeAsync(json, TimeSpan.FromSeconds(60), safety.Token);
r.Ok.ShouldBeFalse();
r.Message!.ShouldContain("handshake failed: timed out");
safety.IsCancellationRequested.ShouldBeFalse();
}
finally
{
listener.Stop();
lock (held) foreach (var c in held) c.Dispose();
}
}
/// <summary>The config reader: any casing, positive integers only.</summary>
[Fact]
public void ReadTimeoutMs_reads_the_driver_key()
{
S7DriverProbe.ReadTimeoutMs("""{"TimeoutMs":2500}""").ShouldBe(2500);
S7DriverProbe.ReadTimeoutMs("""{"timeoutms":10}""").ShouldBe(10);
S7DriverProbe.ReadTimeoutMs("""{"timeoutMs":0}""").ShouldBeNull();
S7DriverProbe.ReadTimeoutMs("""{"host":"x"}""").ShouldBeNull();
S7DriverProbe.ReadTimeoutMs("not json").ShouldBeNull();
}
}
@@ -0,0 +1,293 @@
using S7.Net;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
/// <summary>
/// R07:H4 — a deterministic PLC refusal (S7.Net <c>ReadData</c> / <c>WriteData</c> carrying an S7
/// item error such as "object does not exist", not a socket/timeout/framing fault) proves the CPU
/// answered. It is per-tag Bad with a once-per-tag Warning and NO Degraded; it advances
/// <c>LastSuccessfulRead</c>, so a driver whose every tag is refused (wrong DB number) no longer
/// trips the 90 s comms-loss watchdog against a reachable PLC.
/// </summary>
[Trait("Category", "Unit")]
public sealed class S7RefusalHealthTests
{
private const uint BadDeviceFailure = 0x808B0000u;
/// <summary>
/// The shape S7.Net's READ paths really raise for a CPU item error — a BARE
/// <see cref="Exception"/>, live-probed against the snap7 fixture (not a PlcException).
/// </summary>
private static Exception ReadRefusal() => new("Received error from PLC: Object does not exist.");
private static PlcException WriteRefusal() =>
new(ErrorCode.WriteData, new Exception("Received error from PLC: Address out of range."));
private static S7DriverOptions Options() => new()
{
Host = "192.0.2.1",
Timeout = TimeSpan.FromSeconds(5),
Probe = new S7ProbeOptions { Enabled = false },
// Grouping off: one wire request per tag keeps each tag's outcome its own.
SpanReadMaxGapBytes = -1,
RawTags = S7RawTags.Entries(
new S7TagDefinition("Missing", "DB99.DBW0", S7DataType.UInt16),
new S7TagDefinition("Good", "DB1.DBW0", S7DataType.UInt16)),
};
/// <summary>A stepped pass clock, so "LastSuccessfulRead advanced" is asserted STRICTLY.</summary>
private sealed class PassClock
{
public DateTime Now { get; private set; } = new(2026, 10, 22, 12, 0, 0, DateTimeKind.Utc);
/// <summary>Moves the clock one second on and returns the new time.</summary>
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 clock = new PassClock();
var drv = new S7Driver(Options(), "s7-refusal", factory, log) { UtcNow = () => clock.Now };
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
drv.GetHealth().LastSuccessfulRead.ShouldBe(clock.Now, "init stamps the pass clock");
return (drv, factory, log, clock);
}
/// <summary>A refused tag is Bad, health stays Healthy, LastSuccessfulRead advances, and it warns once.</summary>
[Fact]
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, clock) = await StartAsync();
using var _ = drv;
factory.Read = (addr, _) => addr == "DB99.DBW0" ? throw ReadRefusal() : Task.FromResult<object?>((ushort)7);
for (var pass = 0; pass < 3; pass++)
{
var at = clock.Step();
var r = await drv.ReadAsync(["Missing"], ct);
r[0].StatusCode.ShouldBe(BadDeviceFailure);
drv.GetHealth().State.ShouldBe(DriverState.Healthy, "a refusal is not a comms fault");
drv.GetHealth().LastSuccessfulRead.ShouldBe(at,
"the CPU answered, so the all-refused pass advances LastSuccessfulRead to the pass time");
}
log.Warnings.Count(w => w.Contains("Missing")).ShouldBe(1, "one Warning per refused tag, not one per poll");
// Positive control: the good tag still reads, and nothing reopened the connection.
(await drv.ReadAsync(["Good"], ct))[0].StatusCode.ShouldBe(0u);
factory.Created.Count.ShouldBe(1);
}
/// <summary>
/// A refusal after a comms fault restores Healthy — the CPU is back and answering. Before the
/// fix the refusal latched Degraded with LastSuccessfulRead frozen, which is exactly the
/// watchdog's trip condition.
/// </summary>
[Fact]
public async Task A_refusal_after_a_comms_fault_restores_healthy()
{
var ct = TestContext.Current.CancellationToken;
var (drv, factory, _, clock) = await StartAsync();
using var __ = drv;
// Positive control: a genuine comms fault DOES degrade, and leaves LastSuccessfulRead frozen.
var initStamp = clock.Now;
clock.Step();
factory.Read = (_, _) => throw new TimeoutException("frozen peer");
await drv.ReadAsync(["Missing"], ct);
var degraded = drv.GetHealth();
degraded.State.ShouldBe(DriverState.Degraded);
degraded.LastSuccessfulRead.ShouldBe(initStamp);
var at = clock.Step();
factory.Read = (addr, _) => addr == "DB99.DBW0" ? throw ReadRefusal() : Task.FromResult<object?>((ushort)7);
var r = await drv.ReadAsync(["Missing"], ct);
r[0].StatusCode.ShouldBe(BadDeviceFailure);
var after = drv.GetHealth();
after.State.ShouldBe(DriverState.Healthy);
after.LastSuccessfulRead.ShouldBe(at);
}
/// <summary>The ReadData-wrapped shape of a refusal is classified the same way.</summary>
[Fact]
public async Task A_read_data_wrapped_refusal_is_also_a_refusal()
{
var ct = TestContext.Current.CancellationToken;
var (drv, factory, _, clock) = await StartAsync();
using var __ = drv;
factory.Read = (_, _) => throw new PlcException(ErrorCode.ReadData, ReadRefusal());
(await drv.ReadAsync(["Missing"], ct))[0].StatusCode.ShouldBe(BadDeviceFailure);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
/// <summary>
/// The bare read-path shape of access denied (PUT/GET disabled) is the Faulted config alert.
/// Before, only a PlcException-wrapped "not allowed" was recognised, so a real CPU's read
/// refusal fell through to BadCommunicationError + Degraded.
/// </summary>
[Fact]
public async Task A_bare_access_denied_read_is_the_faulted_config_alert()
{
var ct = TestContext.Current.CancellationToken;
var (drv, factory, _, clock) = await StartAsync();
using var __ = drv;
factory.Read = (_, _) => throw new Exception("Received error from PLC: Accessing object not allowed.");
(await drv.ReadAsync(["Good"], ct))[0].StatusCode.ShouldBe(0x803D0000u); // BadNotSupported
drv.GetHealth().State.ShouldBe(DriverState.Faulted);
drv.GetHealth().LastError!.ShouldContain("PUT/GET");
}
/// <summary>
/// Review I-1: an access-denied CPU (PUT/GET left at its factory default — EVERY read denied) is
/// reachable and answering, so each pass advances LastSuccessfulRead while staying Faulted.
/// Before, the pass stayed Faulted with the stamp frozen — the comms-loss watchdog's trip
/// condition, so the actor reconnected + re-subscribed every 90 s, forever, which can never fix
/// a PUT/GET setting. The control: a comms fault afterwards leaves the stamp frozen.
/// </summary>
[Fact]
public async Task An_all_access_denied_pass_stays_faulted_but_advances_last_successful_read()
{
var ct = TestContext.Current.CancellationToken;
var (drv, factory, _, clock) = await StartAsync();
using var __ = drv;
factory.Read = (_, _) => throw new Exception("Received error from PLC: Accessing object not allowed.");
DateTime last = default;
for (var pass = 0; pass < 2; pass++)
{
last = clock.Step();
await drv.ReadAsync(["Good", "Missing"], ct);
var h = drv.GetHealth();
h.State.ShouldBe(DriverState.Faulted, "the config alert is kept");
h.LastSuccessfulRead.ShouldBe(last, "the CPU answered, so the stamp advances every pass");
}
// Positive control: a real comms loss does NOT advance it (and the sticky Faulted outranks it).
clock.Step();
factory.Read = (_, _) => throw new TimeoutException("frozen peer");
await drv.ReadAsync(["Good"], ct);
drv.GetHealth().LastSuccessfulRead.ShouldBe(last);
}
/// <summary>The write-side access-denied is an answer too, and its sticky Faulted still clears only on a real success.</summary>
[Fact]
public async Task A_write_access_denied_advances_last_successful_read_and_stays_sticky()
{
var ct = TestContext.Current.CancellationToken;
var (drv, factory, _, clock) = await StartAsync();
using var __ = drv;
factory.Write = (_, _, _) => throw new PlcException(ErrorCode.WriteData,
new Exception("Received error from PLC: Accessing object not allowed."));
var at = clock.Step();
(await drv.WriteAsync([new WriteRequest("Good", (ushort)1)], ct))[0].StatusCode.ShouldBe(0x803D0000u);
drv.GetHealth().State.ShouldBe(DriverState.Faulted);
drv.GetHealth().LastSuccessfulRead.ShouldBe(at);
// A clean read leaves the write-side Faulted in place (sticky) …
clock.Step();
await drv.ReadAsync(["Good"], ct);
drv.GetHealth().State.ShouldBe(DriverState.Faulted);
// … and a real write success clears it.
factory.Write = (_, _, _) => Task.CompletedTask;
clock.Step();
await drv.WriteAsync([new WriteRequest("Good", (ushort)1)], ct);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
/// <summary>
/// Review m-7: a transport fault whose text happens to contain "not allowed" reopens (Degraded),
/// and only S7.Net's exact "Accessing object not allowed" is the access-denied alert.
/// </summary>
[Fact]
public async Task Only_the_exact_access_denied_wording_without_a_transport_fault_is_access_denied()
{
var ct = TestContext.Current.CancellationToken;
var (drv, factory, _, clock) = await StartAsync();
using var __ = drv;
clock.Step();
factory.Write = (_, _, _) => throw new PlcException(ErrorCode.WriteData,
new System.IO.IOException("Accessing object not allowed: socket closed by peer"));
(await drv.WriteAsync([new WriteRequest("Good", (ushort)1)], ct))[0].StatusCode
.ShouldNotBe(0x803D0000u, "not the access-denied BadNotSupported");
drv.GetHealth().State.ShouldBe(DriverState.Degraded, "a transport fault is not a config alert");
S7Driver.IsAccessDenied(new PlcException(ErrorCode.WriteData, new Exception("operation not allowed"))).ShouldBeFalse();
S7Driver.IsAccessDenied(new Exception("Received error from PLC: Accessing object not allowed.")).ShouldBeTrue();
}
/// <summary>A ReadData wrapping a socket fault is still a comms fault (Degraded), not a refusal.</summary>
[Fact]
public async Task A_read_data_wrapping_a_socket_fault_still_degrades()
{
var ct = TestContext.Current.CancellationToken;
var (drv, factory, _, clock) = await StartAsync();
using var __ = drv;
factory.Read = (_, _) => throw new PlcException(ErrorCode.ReadData, new System.IO.IOException("connection reset"));
await drv.ReadAsync(["Good"], ct);
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
}
/// <summary>A write refusal is per-tag Bad, counts as answered and leaves health alone.</summary>
[Fact]
public async Task A_write_refusal_is_per_tag_bad_and_counts_as_answered()
{
var ct = TestContext.Current.CancellationToken;
var (drv, factory, log, clock) = await StartAsync();
using var _ = drv;
factory.Write = (addr, _, _) => addr == "DB99.DBW0" ? throw WriteRefusal() : Task.CompletedTask;
// Put the driver into a comms-fault Degraded first; the refusal must lift it.
factory.Read = (_, _) => throw new TimeoutException("frozen peer");
await drv.ReadAsync(["Good"], ct);
var degraded = drv.GetHealth();
degraded.State.ShouldBe(DriverState.Degraded);
for (var i = 0; i < 2; i++)
{
var at = clock.Step();
var w = await drv.WriteAsync([new WriteRequest("Missing", (ushort)1)], ct);
w[0].StatusCode.ShouldBe(BadDeviceFailure);
var h = drv.GetHealth();
h.LastSuccessfulRead.ShouldBe(at, "a write refusal is an answer: the stamp advances strictly");
// The Degraded was written by the READ side (its own message), so it is the read pass's to clear.
h.State.ShouldBe(DriverState.Degraded);
}
var after = drv.GetHealth();
after.LastSuccessfulRead!.Value.ShouldBeGreaterThan(degraded.LastSuccessfulRead!.Value);
log.Warnings.Count(m => m.Contains("Missing")).ShouldBe(1);
}
/// <summary>
/// A good write clears a Degraded the write side wrote. Before the fix a success-only batch
/// never wrote health, so a transient write fault latched Degraded until some read pass ran.
/// </summary>
[Fact]
public async Task A_good_write_clears_a_write_side_degraded()
{
var ct = TestContext.Current.CancellationToken;
var (drv, factory, _, clock) = await StartAsync();
using var _ = drv;
factory.Write = (_, _, _) => throw new System.IO.IOException("connection reset");
var bad = await drv.WriteAsync([new WriteRequest("Good", (ushort)1)], ct);
bad[0].StatusCode.ShouldNotBe(0u);
drv.GetHealth().State.ShouldBe(DriverState.Degraded, "positive control: a comms fault on write degrades");
factory.Write = (_, _, _) => Task.CompletedTask;
var good = await drv.WriteAsync([new WriteRequest("Good", (ushort)2)], ct);
good[0].StatusCode.ShouldBe(0u);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
}
@@ -202,9 +202,10 @@ public sealed class S7SpanReadParityTests
public async Task A_span_read_failure_degrades_every_member_like_the_per_tag_path() public async Task A_span_read_failure_degrades_every_member_like_the_per_tag_path()
{ {
var ct = TestContext.Current.CancellationToken; var ct = TestContext.Current.CancellationToken;
// ErrorCode.ReadData with a message that is NOT access-denied ("not allowed") — a genuine // A device-layer fault that is neither a refusal (a ReadData item error is per-tag Bad with
// device-layer fault, which the driver maps to BadDeviceFailure + Degraded. // no health impact since R07:H4, and a refused span falls back per member since R07:P2) nor
var fault = new PlcException(ErrorCode.ReadData, "S7 CPU reported a device fault"); // connection-fatal — the driver maps it to BadDeviceFailure + Degraded on every member.
var fault = new PlcException(ErrorCode.SendData, "S7 CPU reported a device fault");
var groupingFactory = new ImageS7PlcFactory(Images()) { ReadFault = fault }; var groupingFactory = new ImageS7PlcFactory(Images()) { ReadFault = fault };
var perTagFactory = new ImageS7PlcFactory(Images()) { ReadFault = fault }; var perTagFactory = new ImageS7PlcFactory(Images()) { ReadFault = fault };
@@ -468,8 +469,11 @@ public sealed class S7SpanReadParityTests
} }
/// <summary> /// <summary>
/// The SPAN path obeys the same rule: one member that fails to decode leaves the pass /// The SPAN path guards each member's decode: one member that fails to decode is Bad on its
/// <c>Degraded</c> even though a later member of the same block decoded fine. /// own while a later member of the same block decodes fine. S7.Net rejects the malformed
/// STRING with a <c>ReadData</c> <see cref="PlcException"/> — the CPU answered, the buffer is
/// what is wrong — so since R07:H4 it is a per-tag refusal with no Degraded (like the
/// codec's own <c>BadOutOfRange</c>), and LastSuccessfulRead still advances.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// The block read itself succeeds — this is the per-member decode guard, the span-path /// The block read itself succeeds — this is the per-member decode guard, the span-path
@@ -480,7 +484,7 @@ public sealed class S7SpanReadParityTests
/// last-writer-wins used to erase. /// last-writer-wins used to erase.
/// </remarks> /// </remarks>
[Fact] [Fact]
public async Task One_undecodable_span_member_leaves_the_pass_Degraded() public async Task One_undecodable_span_member_is_bad_alone_without_degrading()
{ {
var ct = TestContext.Current.CancellationToken; var ct = TestContext.Current.CancellationToken;
// The STRING occupies DB1 bytes 100..111 (StringLength + 2), so the word at 112 is // The STRING occupies DB1 bytes 100..111 (StringLength + 2), so the word at 112 is
@@ -516,7 +520,7 @@ public sealed class S7SpanReadParityTests
results[1].Value.ShouldNotBeNull(); results[1].Value.ShouldNotBeNull();
var health = driver.GetHealth(); var health = driver.GetHealth();
health.State.ShouldBe(DriverState.Degraded, "a span member that failed to decode must not be erased"); health.State.ShouldBe(DriverState.Healthy, "an undecodable buffer is a data problem, not a comms fault");
health.LastSuccessfulRead!.Value.ShouldBeGreaterThan(before!.Value); health.LastSuccessfulRead!.Value.ShouldBeGreaterThan(before!.Value);
} }
@@ -542,7 +546,7 @@ public sealed class S7SpanReadParityTests
// which is the Faulted config alert. B: an ordinary device-layer fault, merely Degraded, // which is the Faulted config alert. B: an ordinary device-layer fault, merely Degraded,
// observed AFTER it. C: no fault at all. // observed AFTER it. C: no fault at all.
factory.PerTagFaults["DB1.DBW0"] = new PlcException(ErrorCode.ReadData, "Accessing object not allowed"); factory.PerTagFaults["DB1.DBW0"] = new PlcException(ErrorCode.ReadData, "Accessing object not allowed");
factory.PerTagFaults["DB1.DBW2"] = new PlcException(ErrorCode.ReadData, "S7 CPU reported a device fault"); factory.PerTagFaults["DB1.DBW2"] = new PlcException(ErrorCode.SendData, "S7 CPU reported a device fault");
// maxGap -1 turns grouping off entirely, so every tag takes the per-tag read path and the // maxGap -1 turns grouping off entirely, so every tag takes the per-tag read path and the
// faults can be aimed at individual addresses. // faults can be aimed at individual addresses.
using var driver = new S7Driver(Options(NarrowTags(), maxGap: -1), "s7-access-denied", factory); using var driver = new S7Driver(Options(NarrowTags(), maxGap: -1), "s7-access-denied", factory);
@@ -584,7 +588,7 @@ public sealed class S7SpanReadParityTests
var factory = new ImageS7PlcFactory(Images()); var factory = new ImageS7PlcFactory(Images());
// A: an ordinary device-layer fault (Degraded) FIRST. B: the access-denied config alert // A: an ordinary device-layer fault (Degraded) FIRST. B: the access-denied config alert
// (Faulted) after it. C: no fault at all. // (Faulted) after it. C: no fault at all.
factory.PerTagFaults["DB1.DBW0"] = new PlcException(ErrorCode.ReadData, "S7 CPU reported a device fault"); factory.PerTagFaults["DB1.DBW0"] = new PlcException(ErrorCode.SendData, "S7 CPU reported a device fault");
factory.PerTagFaults["DB1.DBW2"] = new PlcException(ErrorCode.ReadData, "Accessing object not allowed"); factory.PerTagFaults["DB1.DBW2"] = new PlcException(ErrorCode.ReadData, "Accessing object not allowed");
using var driver = new S7Driver(Options(NarrowTags(), maxGap: -1), "s7-escalation-message", factory); using var driver = new S7Driver(Options(NarrowTags(), maxGap: -1), "s7-escalation-message", factory);
await driver.InitializeAsync("{}", ct); await driver.InitializeAsync("{}", ct);
@@ -0,0 +1,213 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
/// <summary>
/// R07:P2 — a span's block is the union of its members' ranges, so ONE member that runs past the
/// DB's real end (or over-declares a STRING) made the CPU refuse the whole block and every
/// neighbour inside it went Bad on every poll. A refused span now re-reads its members one at a
/// time for that pass and remembers which tags must not be grouped, so later passes group only the
/// members that can be grouped.
/// </summary>
[Trait("Category", "Unit")]
public sealed class S7SpanRefusalFallbackTests
{
private const uint BadDeviceFailure = 0x808B0000u;
/// <summary>DB1 is 4 bytes long on this fake CPU: anything reaching past byte 4 is refused.</summary>
private const int DbLength = 4;
private static Exception OutOfRange() => new("Received error from PLC: Address out of range.");
private static (S7Driver Driver, ScriptedS7PlcFactory Factory, Func<int> BlockReads, Func<int> TagReads) Start(
params S7TagDefinition[] tags)
{
var factory = new ScriptedS7PlcFactory();
var blockReads = 0;
var tagReads = 0;
factory.ReadBytes = (_, _, start, count, _) =>
{
Interlocked.Increment(ref blockReads);
if (start + count > DbLength) throw OutOfRange();
var b = new byte[count];
for (var i = 0; i < count; i++) b[i] = (byte)(start + i + 1);
return Task.FromResult(b);
};
factory.Read = (addr, _) =>
{
Interlocked.Increment(ref tagReads);
var offset = int.Parse(addr[(addr.LastIndexOf('W') + 1)..], System.Globalization.CultureInfo.InvariantCulture);
if (offset + 2 > DbLength) throw OutOfRange();
return Task.FromResult<object?>((ushort)(((offset + 1) << 8) | (offset + 2)));
};
var drv = new S7Driver(new S7DriverOptions
{
Host = "192.0.2.1",
Probe = new S7ProbeOptions { Enabled = false },
RawTags = S7RawTags.Entries(tags),
}, "s7-span-fallback", factory);
return (drv, factory, () => Volatile.Read(ref blockReads), () => Volatile.Read(ref tagReads));
}
private static S7TagDefinition Word(string name, int offset) =>
new(name, $"DB1.DBW{offset}", S7DataType.UInt16, Writable: false);
/// <summary>
/// The over-running member is refused on its own; its neighbours are served from the very first
/// pass, and from the second pass on the neighbours are grouped again WITHOUT the bad member.
/// </summary>
[Fact]
public async Task A_refused_span_falls_back_per_member_and_stops_grouping_the_bad_member()
{
var ct = TestContext.Current.CancellationToken;
var (drv, _, blockReads, tagReads) = Start(Word("A", 0), Word("B", 2), Word("C", 4));
using var _ = drv;
await drv.InitializeAsync("{}", ct);
string[] refs = ["A", "B", "C"];
// Pass 1: the 6-byte span is refused, then each member is read on its own.
var r1 = await drv.ReadAsync(refs, ct);
r1[0].StatusCode.ShouldBe(0u);
r1[0].Value.ShouldBe((ushort)0x0102);
r1[1].StatusCode.ShouldBe(0u);
r1[1].Value.ShouldBe((ushort)0x0304);
r1[2].StatusCode.ShouldBe(BadDeviceFailure);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
blockReads().ShouldBe(1);
tagReads().ShouldBe(3);
// Pass 2: A+B ride ONE block read again (the planner no longer sees C); C reads alone.
var r2 = await drv.ReadAsync(refs, ct);
r2[0].Value.ShouldBe((ushort)0x0102);
r2[1].Value.ShouldBe((ushort)0x0304);
r2[2].StatusCode.ShouldBe(BadDeviceFailure);
blockReads().ShouldBe(2, "A+B grouped again, without the refused member");
tagReads().ShouldBe(4, "only C took the per-tag path on pass 2");
}
/// <summary>
/// When the block is refused but every member reads fine alone, the span itself is the problem:
/// all of its members stop being grouped (and stop re-trying the refused block every pass).
/// </summary>
[Fact]
public async Task A_refused_span_whose_members_all_read_alone_is_not_grouped_again()
{
var ct = TestContext.Current.CancellationToken;
var factory = new ScriptedS7PlcFactory();
var blockReads = 0;
factory.ReadBytes = (_, _, _, _, _) => { Interlocked.Increment(ref blockReads); throw OutOfRange(); };
factory.Read = (_, _) => Task.FromResult<object?>((ushort)7);
using var drv = new S7Driver(new S7DriverOptions
{
Host = "192.0.2.1",
Probe = new S7ProbeOptions { Enabled = false },
RawTags = S7RawTags.Entries(Word("A", 0), Word("B", 2)),
}, "s7-span-fallback-all", factory);
await drv.InitializeAsync("{}", ct);
(await drv.ReadAsync(["A", "B"], ct)).ShouldAllBe(s => s.StatusCode == 0u);
(await drv.ReadAsync(["A", "B"], ct)).ShouldAllBe(s => s.StatusCode == 0u);
Volatile.Read(ref blockReads).ShouldBe(1, "the refused block is not re-issued on later passes");
}
/// <summary>
/// Positive control: a span failure that is NOT a refusal (a device-layer fault) does not fall
/// back — re-reading N members one by one against a failing device would only multiply the fault.
/// </summary>
[Fact]
public async Task A_non_refusal_span_failure_does_not_fall_back()
{
var ct = TestContext.Current.CancellationToken;
var factory = new ScriptedS7PlcFactory
{
ReadBytes = (_, _, _, _, _) => throw new global::S7.Net.PlcException(global::S7.Net.ErrorCode.SendData, "device fault"),
};
using var drv = new S7Driver(new S7DriverOptions
{
Host = "192.0.2.1",
Probe = new S7ProbeOptions { Enabled = false },
RawTags = S7RawTags.Entries(Word("A", 0), Word("B", 2)),
}, "s7-span-nofallback", factory);
await drv.InitializeAsync("{}", ct);
var before = factory.WireOps;
var r = await drv.ReadAsync(["A", "B"], ct);
r.ShouldAllBe(s => s.StatusCode == BadDeviceFailure);
(factory.WireOps - before).ShouldBe(1);
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
}
/// <summary>
/// Review m-5, the multi-var sibling: a leftover chunk holding one refused scalar used to fall back
/// per tag on EVERY poll (1 + N requests). The member refused on its own is now memoised, so the
/// next pass batches the rest and reads only the bad one alone.
/// </summary>
[Fact]
public async Task A_refused_multi_var_chunk_memoises_only_the_member_refused_alone()
{
var ct = TestContext.Current.CancellationToken;
var factory = new ScriptedS7PlcFactory();
var multiCalls = 0;
factory.ReadMulti = (items, _) =>
{
Interlocked.Increment(ref multiCalls);
if (items.Any(i => i.DB == 3))
// The live snap7 shape of a multi-var request with one refused item.
throw new global::S7.Net.PlcException(global::S7.Net.ErrorCode.ReadData,
new global::S7.Net.PlcException(global::S7.Net.ErrorCode.WrongNumberReceivedBytes));
foreach (var i in items) i.Value = (ushort)5;
return Task.FromResult<IReadOnlyList<global::S7.Net.Types.DataItem>>(items);
};
factory.Read = (addr, _) => addr.StartsWith("DB3.", StringComparison.Ordinal)
? throw new Exception("Received error from PLC: Object does not exist.")
: Task.FromResult<object?>((ushort)5);
using var drv = new S7Driver(new S7DriverOptions
{
Host = "192.0.2.1",
Probe = new S7ProbeOptions { Enabled = false },
RawTags = S7RawTags.Entries(
new S7TagDefinition("A", "DB1.DBW0", S7DataType.UInt16, Writable: false),
new S7TagDefinition("B", "DB2.DBW0", S7DataType.UInt16, Writable: false),
new S7TagDefinition("Bad", "DB3.DBW0", S7DataType.UInt16, Writable: false)),
}, "s7-mv-memo", factory);
await drv.InitializeAsync("{}", ct);
string[] refs = ["A", "B", "Bad"];
var before = factory.WireOps;
var r1 = await drv.ReadAsync(refs, ct);
r1[0].StatusCode.ShouldBe(0u);
r1[1].StatusCode.ShouldBe(0u);
r1[2].StatusCode.ShouldBe(BadDeviceFailure);
(factory.WireOps - before).ShouldBe(4, "pass 1: one refused multi-var, then one per-tag read each");
before = factory.WireOps;
var r2 = await drv.ReadAsync(refs, ct);
r2[0].StatusCode.ShouldBe(0u);
r2[1].StatusCode.ShouldBe(0u);
r2[2].StatusCode.ShouldBe(BadDeviceFailure);
(factory.WireOps - before).ShouldBe(2, "pass 2: A+B ride one multi-var, Bad reads alone");
Volatile.Read(ref multiCalls).ShouldBe(2);
}
/// <summary>The memo is per generation: a re-initialize (e.g. a corrected DB) groups everything again.</summary>
[Fact]
public async Task Reinitialize_clears_the_do_not_group_memo()
{
var ct = TestContext.Current.CancellationToken;
var (drv, _, blockReads, tagReads) = Start(Word("A", 0), Word("B", 2), Word("C", 4));
using var _ = drv;
await drv.InitializeAsync("{}", ct);
await drv.ReadAsync(["A", "B", "C"], ct);
blockReads().ShouldBe(1);
tagReads().ShouldBe(3);
await drv.InitializeAsync("{}", ct);
await drv.ReadAsync(["A", "B", "C"], ct);
// With the memo still in force pass 2 would be one A+B block read plus ONE per-tag read (C).
// Cleared, the full A..C span is tried again, refused again, and all three re-read alone.
blockReads().ShouldBe(2);
tagReads().ShouldBe(6, "after re-init the full span is tried again (and refused again)");
}
}
@@ -0,0 +1,66 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
/// <summary>
/// R07:H3 / R07:U4 — the tag shapes the driver cannot serve are refused per tag at the mapper,
/// the deploy gate and the driver's init (see <see cref="S7TagShapeRules"/>), instead of faulting
/// the whole driver at init or being silently mis-read.
/// </summary>
[Trait("Category", "Unit")]
public sealed class S7TagShapeGateTests
{
/// <summary>A typo'd area/size letter is refused per tag (it used to throw FormatException at init).</summary>
[Fact]
public Task Unparseable_address_is_refused_per_tag()
=> S7TagSkipAssert.RefusedPerTagAsync(new S7TagDefinition("Typo", "DB1.DBZ0", S7DataType.Int16), "DB1.DBZ0", "does not parse");
/// <summary>
/// R07:U4 — a Bool array reads bit 0 of each byte, so a non-zero start bit was silently ignored
/// (<c>DB1.DBX0.3</c> returned bit 0 of bytes 0..7). It is refused at authoring now.
/// </summary>
[Fact]
public Task Bool_array_starting_at_a_nonzero_bit_is_refused()
=> S7TagSkipAssert.RefusedPerTagAsync(
new S7TagDefinition("OffsetBits", "DB1.DBX0.3", S7DataType.Bool, Writable: false, ArrayCount: 8),
"Bool array", "bit 3");
/// <summary>A narrow type at the wrong access width fails every read with a type mismatch; refused up front.</summary>
/// <param name="address">The address.</param>
/// <param name="dt">The declared type, which does not match the address's access width.</param>
[Theory]
[InlineData("DB1.DBW0", S7DataType.Int32)]
[InlineData("DB1.DBD0", S7DataType.Int16)]
[InlineData("DB1.DBB0", S7DataType.Bool)]
[InlineData("M0.0", S7DataType.Byte)]
[InlineData("MW0", S7DataType.Float32)]
public Task Narrow_type_at_the_wrong_access_width_is_refused(string address, S7DataType dt)
=> S7TagSkipAssert.RefusedPerTagAsync(new S7TagDefinition("Mismatch", address, dt), "access width");
/// <summary>Positive control: the shapes the driver serves are all still accepted by the mapper and the gate.</summary>
/// <param name="address">The address.</param>
/// <param name="dt">The declared type.</param>
/// <param name="arrayCount">The array count, or 0 for a scalar.</param>
/// <param name="writable">Whether the tag is writable.</param>
[Theory]
[InlineData("DB1.DBX0.0", S7DataType.Bool, 8, false)]
[InlineData("DB1.DBX50.3", S7DataType.Bool, 0, true)]
[InlineData("DB1.DBB0", S7DataType.Byte, 4, true)]
[InlineData("DB1.DBW0", S7DataType.Int16, 0, true)]
[InlineData("MW2", S7DataType.UInt16, 3, true)]
[InlineData("DB1.DBD0", S7DataType.Float32, 0, true)]
[InlineData("QD4", S7DataType.Int32, 2, true)]
[InlineData("DB1.DBB0", S7DataType.Float64, 0, true)]
[InlineData("DB1.DBB0", S7DataType.String, 0, true)]
[InlineData("T5", S7DataType.Float64, 0, false)]
[InlineData("C3", S7DataType.Int32, 0, false)]
public void Servable_shapes_are_accepted(string address, S7DataType dt, int arrayCount, bool writable)
{
var def = new S7TagDefinition("Ok", address, dt, Writable: writable, ArrayCount: arrayCount == 0 ? null : arrayCount);
var blob = S7TagDefinitionFactory.ToTagConfig(def);
S7TagDefinitionFactory.FromTagConfig(blob, "Ok", out var mapped, out var reason).ShouldBeTrue(reason);
mapped.Address.ShouldBe(address);
S7TagDefinitionFactory.Inspect(blob).ShouldBeEmpty();
}
}
@@ -0,0 +1,60 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
/// <summary>
/// Shared assertion for an S7 tag shape the driver cannot serve. It must be refused PER TAG at
/// every gate, never fault the whole driver: the mapper (and so the AdminUI editor) refuses it with
/// the reason, the deploy gate (<see cref="S7TagDefinitionFactory.Inspect"/>) warns, and a driver
/// carrying it next to a good tag initializes Healthy, reports the bad one through
/// <see cref="ISkippedTagReporter"/>, and still serves the good one (the positive control).
/// </summary>
internal static class S7TagSkipAssert
{
private const string GoodName = "Good";
/// <summary>Asserts <paramref name="bad"/> is refused per tag at the mapper, the deploy gate and the driver.</summary>
/// <param name="bad">The unservable tag.</param>
/// <param name="reasonFragments">Fragments the operator-facing rejection reason must contain.</param>
/// <returns>A task that completes when every gate has been checked.</returns>
public static async Task RefusedPerTagAsync(S7TagDefinition bad, params string[] reasonFragments)
{
var ct = TestContext.Current.CancellationToken;
var blob = S7TagDefinitionFactory.ToTagConfig(bad);
// Mapper (and therefore the AdminUI editor, which asks it) — refused, with the reason.
S7TagDefinitionFactory.FromTagConfig(blob, bad.Name, out _, out var reason).ShouldBeFalse();
reason.ShouldNotBeNull();
foreach (var f in reasonFragments) reason.ShouldContain(f, Case.Insensitive);
// Deploy gate — warns with the same reason.
var warnings = S7TagDefinitionFactory.Inspect(blob);
warnings.ShouldHaveSingleItem().ShouldContain(reason);
// Driver — initializes Healthy with the bad tag skipped and the good tag served.
var opts = new S7DriverOptions
{
Host = "192.0.2.1",
Timeout = TimeSpan.FromMilliseconds(250),
Probe = new S7ProbeOptions { Enabled = false },
RawTags = S7RawTags.Entries(bad, new S7TagDefinition(GoodName, "DB1.DBW0", S7DataType.UInt16, Writable: false)),
};
using var drv = new S7Driver(opts, "s7-skip-" + bad.Name, new ScriptedS7PlcFactory());
await drv.InitializeAsync("{}", ct);
drv.GetHealth().State.ShouldBe(DriverState.Healthy, "one unservable tag must not fault the driver");
var skipped = drv.GetSkippedTags().ShouldNotBeNull();
skipped.Sample.ShouldHaveSingleItem().RawPath.ShouldBe(bad.Name);
skipped.Sample[0].Reason.ShouldBe(reason);
var builder = new RecordingAddressSpaceBuilder();
await drv.DiscoverAsync(builder, ct);
builder.Variables.Select(v => v.Name).ShouldBe([GoodName]);
var results = await drv.ReadAsync([bad.Name, GoodName], ct);
results[0].StatusCode.ShouldBe(0x80340000u, "the skipped tag reads BadNodeIdUnknown");
results[1].StatusCode.ShouldBe(0u, "the good neighbour is still served");
}
}
@@ -224,6 +224,23 @@ public sealed class S7TsapAndPduTests
(await S7DriverProbe.CheckDataAccessAsync(_ => throw missing, TestContext.Current.CancellationToken)).ShouldBeNull(); (await S7DriverProbe.CheckDataAccessAsync(_ => throw missing, TestContext.Current.CancellationToken)).ShouldBeNull();
} }
/// <summary>
/// The shapes a real read raises — S7.Net's read paths throw the CPU's item error as a BARE
/// Exception (live-probed on the snap7 fixture). Before, the probe only matched PlcException, so a
/// PUT/GET-disabled CPU got the generic "test read failed" text and a missing address failed the probe.
/// </summary>
[Fact]
public async Task Probe_classifies_the_bare_read_path_shapes()
{
var ct = TestContext.Current.CancellationToken;
var denied = await S7DriverProbe.CheckDataAccessAsync(
_ => throw new Exception("Received error from PLC: Accessing object not allowed."), ct);
denied.ShouldNotBeNull().Message!.ShouldContain("PUT/GET");
(await S7DriverProbe.CheckDataAccessAsync(
_ => throw new Exception("Received error from PLC: Object does not exist."), ct)).ShouldBeNull();
}
[Fact] [Fact]
public async Task Probe_reports_any_other_read_fault_as_a_failure() public async Task Probe_reports_any_other_read_fault_as_a_failure()
{ {
@@ -318,16 +318,9 @@ public sealed class S7ValueCodecTests
[Fact] [Fact]
public async Task New_types_declared_as_arrays_or_off_byte_addresses_fail_init() public async Task New_types_declared_as_arrays_or_off_byte_addresses_fail_init()
{ {
S7DriverOptions With(S7TagDefinition t) => new() // Refused PER TAG at every gate (they used to throw at init and fault the whole driver).
{ await S7TagSkipAssert.RefusedPerTagAsync(Tag(S7DataType.Dtl) with { Name = "DtlArr", ArrayCount = 2 }, "Dtl", "array");
Host = "192.0.2.1", await S7TagSkipAssert.RefusedPerTagAsync(Tag(S7DataType.Tod, address: "DB1.DBW0") with { Name = "TodWord" }, "Tod", "byte-addressed");
Probe = new S7ProbeOptions { Enabled = false },
RawTags = S7RawTags.Entries(t),
};
using var arr = new S7Driver(With(Tag(S7DataType.Dtl) with { ArrayCount = 2 }), "a", new ConnectingFakeS7PlcFactory());
await Should.ThrowAsync<NotSupportedException>(() => arr.InitializeAsync("{}", TestContext.Current.CancellationToken));
using var word = new S7Driver(With(Tag(S7DataType.Tod, address: "DB1.DBW0")), "b", new ConnectingFakeS7PlcFactory());
await Should.ThrowAsync<NotSupportedException>(() => word.InitializeAsync("{}", TestContext.Current.CancellationToken));
} }
// ---- TagConfig ---- // ---- TagConfig ----
@@ -0,0 +1,142 @@
using S7DataItem = global::S7.Net.Types.DataItem;
using S7NetDataType = global::S7.Net.DataType;
using VarType = global::S7.Net.VarType;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
/// <summary>
/// A scriptable <see cref="IS7PlcFactory"/>: every connection it creates delegates its wire
/// operations to the factory's handlers, so a test scripts the device once and observes every
/// connection (including reopened ones). Counts wire operations so a test can prove what did
/// NOT go to the wire without timing anything.
/// </summary>
internal sealed class ScriptedS7PlcFactory : IS7PlcFactory
{
private int _wireOps;
/// <summary>Every connection created, in order.</summary>
public List<ScriptedS7Plc> Created { get; } = [];
/// <summary>Wire operations issued (reads, multi-var reads, writes; not opens or status probes).</summary>
public int WireOps => Volatile.Read(ref _wireOps);
/// <summary>Hook run inside every <see cref="IS7Plc.OpenAsync"/>; the default opens immediately.</summary>
public Func<int, CancellationToken, Task> Open { get; set; } = (_, _) => Task.CompletedTask;
/// <summary>Per-address string read; the default returns <c>(ushort)42</c>.</summary>
public Func<string, CancellationToken, Task<object?>> Read { get; set; } = (_, _) => Task.FromResult<object?>((ushort)42);
/// <summary>Block read; the default returns <c>count</c> zero bytes.</summary>
public Func<S7NetDataType, int, int, int, CancellationToken, Task<byte[]>> ReadBytes { get; set; }
= (_, _, _, count, _) => Task.FromResult(new byte[count]);
/// <summary>Multi-var read; the default answers every item with a zero of its var type.</summary>
public Func<List<S7DataItem>, CancellationToken, Task<IReadOnlyList<S7DataItem>>> ReadMulti { get; set; }
= (items, _) =>
{
foreach (var i in items)
i.Value = i.VarType switch
{
VarType.Bit => false,
VarType.Byte => (byte)0,
VarType.Word => (ushort)0,
_ => (uint)0,
};
return Task.FromResult<IReadOnlyList<S7DataItem>>(items);
};
/// <summary>Per-address string write; the default succeeds.</summary>
public Func<string, object, CancellationToken, Task> Write { get; set; } = (_, _, _) => Task.CompletedTask;
/// <summary>Block write; the default succeeds.</summary>
public Func<S7NetDataType, int, int, byte[], CancellationToken, Task> WriteBytes { get; set; }
= (_, _, _, _, _) => Task.CompletedTask;
/// <summary>CPU-status probe; the default answers.</summary>
public Func<CancellationToken, Task> ReadStatus { get; set; } = _ => Task.CompletedTask;
/// <inheritdoc />
public IS7Plc Create(S7CpuType cpuType, string host, int port, short rack, short slot, TimeSpan timeout)
{
lock (Created)
{
var plc = new ScriptedS7Plc(this, Created.Count);
Created.Add(plc);
return plc;
}
}
/// <summary>Counts one wire operation.</summary>
internal void CountWireOp() => Interlocked.Increment(ref _wireOps);
}
/// <summary>One connection created by <see cref="ScriptedS7PlcFactory"/>.</summary>
internal sealed class ScriptedS7Plc(ScriptedS7PlcFactory factory, int index) : IS7Plc
{
/// <summary>Zero-based creation index.</summary>
public int Index { get; } = index;
/// <inheritdoc />
public bool IsConnected { get; private set; }
/// <summary>True once disposed.</summary>
public bool Disposed { get; private set; }
/// <inheritdoc />
public int MaxPduSize => 240;
/// <inheritdoc />
public async Task OpenAsync(CancellationToken cancellationToken)
{
await factory.Open(Index, cancellationToken).ConfigureAwait(false);
IsConnected = true;
}
/// <inheritdoc />
public Task<object?> ReadAsync(string address, CancellationToken cancellationToken)
{
factory.CountWireOp();
return factory.Read(address, cancellationToken);
}
/// <inheritdoc />
public Task<byte[]> ReadBytesAsync(S7NetDataType area, int db, int startByteAdr, int count, CancellationToken cancellationToken)
{
factory.CountWireOp();
return factory.ReadBytes(area, db, startByteAdr, count, cancellationToken);
}
/// <inheritdoc />
public Task<IReadOnlyList<S7DataItem>> ReadMultipleVarsAsync(List<S7DataItem> items, CancellationToken cancellationToken)
{
factory.CountWireOp();
return factory.ReadMulti(items, cancellationToken);
}
/// <inheritdoc />
public Task WriteAsync(string address, object value, CancellationToken cancellationToken)
{
factory.CountWireOp();
return factory.Write(address, value, cancellationToken);
}
/// <inheritdoc />
public Task WriteBytesAsync(S7NetDataType area, int db, int startByteAdr, byte[] value, CancellationToken cancellationToken)
{
factory.CountWireOp();
return factory.WriteBytes(area, db, startByteAdr, value, cancellationToken);
}
/// <inheritdoc />
public Task ReadStatusAsync(CancellationToken cancellationToken) => factory.ReadStatus(cancellationToken);
/// <inheritdoc />
public void Close() => IsConnected = false;
/// <inheritdoc />
public void Dispose()
{
Disposed = true;
IsConnected = false;
}
}
@@ -142,7 +142,7 @@ public sealed class CsvMergeEvidenceTests
var row = SingleRow( var row = SingleRow(
DriverTypeNames.S7, DriverTypeNames.S7,
"Name,DataType,Address,S7DataType,TagConfigJson", "Name,DataType,Address,S7DataType,TagConfigJson",
"t1,Int16,DB1.DBW0,Int32,\"{\"\"pollHint\"\":\"\"fast\"\"}\""); "t1,Int16,DB1.DBD0,Int32,\"{\"\"pollHint\"\":\"\"fast\"\"}\"");
var blob = Blob(row); var blob = Blob(row);
blob["dataType"]!.GetValue<string>().ShouldBe("Int32"); blob["dataType"]!.GetValue<string>().ShouldBe("Int32");
@@ -346,7 +346,11 @@ public sealed class RawTagConfigGateTests
public void A_browse_committed_config_passes_the_gate(string driverType) public void A_browse_committed_config_passes_the_gate(string driverType)
{ {
var tagConfig = RawBrowseCommitMapper.BuildTagConfig( var tagConfig = RawBrowseCommitMapper.BuildTagConfig(
driverType, driverType == DriverTypeNames.AbLegacy ? "N7:0" : "SomeAddress", BrowseFieldsFor(driverType)); driverType,
// Drivers whose address grammar is validated by their reader need a real address: AbLegacy's
// file grammar, and S7's (S7TagShapeRules — an unparseable or wrong-width address is refused).
driverType == DriverTypeNames.AbLegacy ? "N7:0" : driverType == DriverTypeNames.S7 ? "DB1.DBW0" : "SomeAddress",
BrowseFieldsFor(driverType));
TagConfigValidator.Validate(driverType, tagConfig).ShouldBeNull( TagConfigValidator.Validate(driverType, tagConfig).ShouldBeNull(
$"browse-commit for {driverType} must not author a config the import refuses"); $"browse-commit for {driverType} must not author a config the import refuses");
@@ -98,13 +98,13 @@ public sealed class S7TagConfigModelTests
/// driver serves. (An absent key is deliberately not asserted: S7 alone hard-requires the key, an /// driver serves. (An absent key is deliberately not asserted: S7 alone hard-requires the key, an
/// asymmetry that predates this change and that its own <c>Inspect</c> does not report either.)</summary> /// asymmetry that predates this change and that its own <c>Inspect</c> does not report either.)</summary>
[Theory] [Theory]
[InlineData("""{"address":"DB1.DBW0","dataType":"float32"}""")] [InlineData("""{"address":"DB1.DBD0","dataType":"float32"}""")]
[InlineData("""{"address":"DB1.DBW0","dataType":"FLOAT32"}""")] [InlineData("""{"address":"DB1.DBD0","dataType":"FLOAT32"}""")]
[InlineData("""{"address":"DB1.DBW0","dataType":7}""")] [InlineData("""{"address":"DB1.DBW0","dataType":7}""")]
public void Validate_accepts_every_dataType_the_driver_accepts(string json) public void Validate_accepts_every_dataType_the_driver_accepts(string json)
=> S7TagConfigModel.FromJson(json).Validate().ShouldBeNull(); => S7TagConfigModel.FromJson(json).Validate().ShouldBeNull();
// ---- S7-016: array shape vs the driver's init guards (which fail the WHOLE driver init) ---- // ---- S7-016: array shape vs S7TagShapeRules (refused per tag at every gate) ----
[Theory] [Theory]
[InlineData("Int64")] [InlineData("Int64")]
@@ -160,9 +160,9 @@ public sealed class TagConfigNonEnumParityTests
/// <summary>S7's remaining non-enum refusal: a <c>String</c> tag whose <c>stringLength</c> falls outside /// <summary>S7's remaining non-enum refusal: a <c>String</c> tag whose <c>stringLength</c> falls outside
/// 0–254. The reader REJECTS it (it does not clamp), so the tag would never resolve.</summary> /// 0–254. The reader REJECTS it (it does not clamp), so the tag would never resolve.</summary>
[Theory] [Theory]
[InlineData("""{"address":"DB1.DBS0","dataType":"String","stringLength":255}""")] [InlineData("""{"address":"DB1.DBB0","dataType":"String","stringLength":255}""")]
[InlineData("""{"address":"DB1.DBS0","dataType":"String","stringLength":300}""")] [InlineData("""{"address":"DB1.DBB0","dataType":"String","stringLength":300}""")]
[InlineData("""{"address":"DB1.DBS0","dataType":"String","stringLength":-1}""")] [InlineData("""{"address":"DB1.DBB0","dataType":"String","stringLength":-1}""")]
public void S7_refuses_a_string_length_its_reader_refuses(string blob) public void S7_refuses_a_string_length_its_reader_refuses(string blob)
{ {
RuntimeAccepts(DriverTypeNames.S7, blob).ShouldBeFalse( RuntimeAccepts(DriverTypeNames.S7, blob).ShouldBeFalse(
@@ -174,9 +174,9 @@ public sealed class TagConfigNonEnumParityTests
/// stops this becoming an over-rejection — an out-of-range <c>stringLength</c> on a tag that is not a /// stops this becoming an over-rejection — an out-of-range <c>stringLength</c> on a tag that is not a
/// String at all, which the reader ignores entirely.</summary> /// String at all, which the reader ignores entirely.</summary>
[Theory] [Theory]
[InlineData("""{"address":"DB1.DBS0","dataType":"String","stringLength":10}""")] [InlineData("""{"address":"DB1.DBB0","dataType":"String","stringLength":10}""")]
[InlineData("""{"address":"DB1.DBS0","dataType":"String","stringLength":254}""")] [InlineData("""{"address":"DB1.DBB0","dataType":"String","stringLength":254}""")]
[InlineData("""{"address":"DB1.DBS0","dataType":"String","stringLength":0}""")] [InlineData("""{"address":"DB1.DBB0","dataType":"String","stringLength":0}""")]
[InlineData("""{"address":"DB1.DBW0","dataType":"Int16","stringLength":300}""")] [InlineData("""{"address":"DB1.DBW0","dataType":"Int16","stringLength":300}""")]
public void S7_accepts_a_string_length_its_reader_accepts(string blob) public void S7_accepts_a_string_length_its_reader_accepts(string blob)
{ {
@@ -360,8 +360,8 @@ public sealed class TagConfigNonEnumParityTests
(DriverTypeNames.S7, """{"address":"DB1.DBW0","dataType":"Int16"}"""), (DriverTypeNames.S7, """{"address":"DB1.DBW0","dataType":"Int16"}"""),
(DriverTypeNames.S7, """{"address":"DB1.DBW0"}"""), (DriverTypeNames.S7, """{"address":"DB1.DBW0"}"""),
(DriverTypeNames.S7, """{"address":" ","dataType":"Int16"}"""), (DriverTypeNames.S7, """{"address":" ","dataType":"Int16"}"""),
(DriverTypeNames.S7, """{"address":"DB1.DBS0","dataType":"String","stringLength":300}"""), (DriverTypeNames.S7, """{"address":"DB1.DBB0","dataType":"String","stringLength":300}"""),
(DriverTypeNames.S7, """{"address":"DB1.DBS0","dataType":"String","stringLength":24}"""), (DriverTypeNames.S7, """{"address":"DB1.DBB0","dataType":"String","stringLength":24}"""),
(DriverTypeNames.AbCip, "{}"), (DriverTypeNames.AbCip, "{}"),
(DriverTypeNames.AbCip, """{"tagPath":"Program:Main.Speed"}"""), (DriverTypeNames.AbCip, """{"tagPath":"Program:Main.Speed"}"""),
@@ -576,7 +576,7 @@ public sealed class TagConfigNonEnumParityTests
data.Add(DriverTypeNames.Modbus, new ModbusTagConfigModel { Address = 1 }.ToJson()); data.Add(DriverTypeNames.Modbus, new ModbusTagConfigModel { Address = 1 }.ToJson());
data.Add(DriverTypeNames.S7, new S7TagConfigModel { Address = "DB1.DBW0" }.ToJson()); data.Add(DriverTypeNames.S7, new S7TagConfigModel { Address = "DB1.DBW0" }.ToJson());
data.Add(DriverTypeNames.S7, data.Add(DriverTypeNames.S7,
new S7TagConfigModel { Address = "DB1.DBS0", DataType = S7DataType.String, StringLength = 24 }.ToJson()); new S7TagConfigModel { Address = "DB1.DBB0", DataType = S7DataType.String, StringLength = 24 }.ToJson());
data.Add(DriverTypeNames.AbCip, new AbCipTagConfigModel { TagPath = "Program:Main.Speed" }.ToJson()); data.Add(DriverTypeNames.AbCip, new AbCipTagConfigModel { TagPath = "Program:Main.Speed" }.ToJson());
data.Add(DriverTypeNames.AbLegacy, new AbLegacyTagConfigModel { Address = "N7:0" }.ToJson()); data.Add(DriverTypeNames.AbLegacy, new AbLegacyTagConfigModel { Address = "N7:0" }.ToJson());
data.Add(DriverTypeNames.TwinCAT, new TwinCATTagConfigModel { SymbolPath = "MAIN.bStart" }.ToJson()); data.Add(DriverTypeNames.TwinCAT, new TwinCATTagConfigModel { SymbolPath = "MAIN.bStart" }.ToJson());
@@ -0,0 +1,123 @@
using Akka.Actor;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.S7;
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
using S7DataItem = global::S7.Net.Types.DataItem;
using S7NetDataType = global::S7.Net.DataType;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
/// <summary>
/// Lane S7 review I-1, end to end: the REAL <see cref="S7Driver"/> under the REAL
/// <see cref="DriverInstanceActor"/> comms-loss watchdog. A CPU with PUT/GET disabled denies every
/// read but is reachable and answering; the driver reports <c>Faulted</c> (the config alert) and must
/// still advance <c>LastSuccessfulRead</c>, so the watchdog's "non-Healthy AND silent for the
/// threshold" conjunction is never met and the actor does NOT reconnect + re-subscribe. The
/// positive control is the same rig with a real comms loss, which DOES trip it.
/// </summary>
public sealed class S7AccessDeniedWatchdogTests : RuntimeActorTestBase
{
private static readonly TimeSpan FastPoll = TimeSpan.FromMilliseconds(50);
/// <summary>The watchdog threshold — an order of magnitude above the poll so the window is a decision, not a race.</summary>
private static readonly TimeSpan Threshold = TimeSpan.FromMilliseconds(1500);
private sealed class ScriptedPlcFactory(Func<Exception> readFault) : IS7PlcFactory
{
public IS7Plc Create(S7CpuType cpuType, string host, int port, short rack, short slot, TimeSpan timeout)
=> new ScriptedPlc(readFault);
}
private sealed class ScriptedPlc(Func<Exception> readFault) : IS7Plc
{
public bool IsConnected { get; private set; }
public Task OpenAsync(CancellationToken cancellationToken) { IsConnected = true; return Task.CompletedTask; }
public void Close() => IsConnected = false;
public Task<object?> ReadAsync(string address, CancellationToken cancellationToken) => Task.FromException<object?>(readFault());
public Task<byte[]> ReadBytesAsync(S7NetDataType area, int db, int startByteAdr, int count, CancellationToken cancellationToken)
=> Task.FromException<byte[]>(readFault());
public Task<IReadOnlyList<S7DataItem>> ReadMultipleVarsAsync(List<S7DataItem> items, CancellationToken cancellationToken)
=> Task.FromException<IReadOnlyList<S7DataItem>>(readFault());
public Task WriteAsync(string address, object value, CancellationToken cancellationToken) => Task.CompletedTask;
public Task WriteBytesAsync(S7NetDataType area, int db, int startByteAdr, byte[] value, CancellationToken cancellationToken) => Task.CompletedTask;
public Task ReadStatusAsync(CancellationToken cancellationToken) => Task.CompletedTask;
public void Dispose() => IsConnected = false;
}
private (S7Driver Driver, Akka.TestKit.TestProbe Parent) Connect(Func<Exception> readFault)
{
var driver = new S7Driver(new S7DriverOptions
{
Host = "192.0.2.1",
Probe = new S7ProbeOptions { Enabled = false },
SpanReadMaxGapBytes = -1,
RawTags =
[
new RawTagEntry("W0", S7TagDefinitionFactory.ToTagConfig(new S7TagDefinition("W0", "DB1.DBW0", S7DataType.UInt16, Writable: false)), false),
new RawTagEntry("W2", S7TagDefinitionFactory.ToTagConfig(new S7TagDefinition("W2", "DB1.DBW2", S7DataType.UInt16, Writable: false)), false),
],
}, "s7-watchdog", new ScriptedPlcFactory(readFault));
var parent = CreateTestProbe();
var actor = parent.ChildActorOf(DriverInstanceActor.Props(
driver,
reconnectInterval: TimeSpan.FromSeconds(30),
healthPollInterval: FastPoll,
commsLossTimeout: Threshold));
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
parent.ExpectMsg<DriverInstanceActor.ConnectivityChanged>(TimeSpan.FromSeconds(5)).Connected.ShouldBeTrue();
return (driver, parent);
}
/// <summary>Drives read passes (the poll the actor would schedule) until <paramref name="until"/>.</summary>
private static async Task<(int Passes, int FaultedPasses)> PollUntilAsync(S7Driver driver, DateTime until)
{
var passes = 0;
var faulted = 0;
while (DateTime.UtcNow < until)
{
try
{
#pragma warning disable OTOPCUA0001 // the watchdog's reading of driver health is under test, not resilience wrapping
await driver.ReadAsync(["W0", "W2"], CancellationToken.None);
#pragma warning restore OTOPCUA0001
passes++;
if (driver.GetHealth().State == DriverState.Faulted) faulted++;
}
catch (InvalidOperationException) { /* the actor tore the driver down (control case) */ }
await Task.Delay(100); // pacing the simulated poll, not an assertion
}
return (passes, faulted);
}
/// <summary>An all-access-denied CPU is left connected across more than twice the watchdog threshold.</summary>
[Fact]
public async Task An_access_denied_cpu_is_not_reconnected_by_the_watchdog()
{
var (driver, parent) = Connect(() => new Exception("Received error from PLC: Accessing object not allowed."));
var (passes, faulted) = await PollUntilAsync(driver, DateTime.UtcNow + Threshold + Threshold + TimeSpan.FromMilliseconds(500));
// Positive controls: the window really ran past the threshold with the driver Faulted throughout,
// so "no reconnect" below cannot pass merely because nothing happened.
passes.ShouldBeGreaterThanOrEqualTo(10);
faulted.ShouldBe(passes, "every pass reports the PUT/GET config alert");
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
driver.GetHealth().State.ShouldBe(DriverState.Faulted);
}
/// <summary>The control: a real comms loss (every read times out) on the same rig IS reconnected.</summary>
[Fact]
public async Task A_real_comms_loss_is_reconnected_by_the_watchdog()
{
var (driver, parent) = Connect(() => new TimeoutException("frozen peer"));
var poll = PollUntilAsync(driver, DateTime.UtcNow + Threshold + Threshold + TimeSpan.FromMilliseconds(500));
parent.ExpectMsg<DriverInstanceActor.ConnectivityChanged>(PresenceBudget).Connected.ShouldBeFalse();
await poll;
}
}
@@ -39,6 +39,9 @@
re-statement end-to-end tests prove what reaches the host's /alerts rows and Part 9 conditions. --> re-statement end-to-end tests prove what reaches the host's /alerts rows and Part 9 conditions. -->
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Galaxy\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.csproj"/> <ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Galaxy\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.csproj"/>
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.csproj"/> <ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.csproj"/>
<!-- Lane S7 review I-1: the real S7 driver (over a scripted IS7Plc), so the comms-loss watchdog test proves an
access-denied (PUT/GET disabled) CPU is NOT reconnected every 90 s, against the real actor. -->
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.S7\ZB.MOM.WW.OtOpcUa.Driver.S7.csproj"/>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>