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
PUT/GET communication" message instead of a false green tick. A CPU that merely rejects that address (no
`MB0`) still passes — the connection is fine; any other read fault fails the probe with its message.
Both are recognised in the bare-`Exception` shape S7.Net's read path really raises. The handshake and
test read are bounded by the config's `timeoutMs` (the driver's own per-operation timeout), capped by the
Test Connect timeout — before R07:H7 the config value was ignored.
## Address forms
@@ -103,9 +106,18 @@ Addresses use Siemens TIA-Portal / STEP 7 Classic syntax, parsed by
| Timer | `T0` / `T15` | Timer area (read-only — see Wide types section) |
| Counter | `C0` / `C10` | Counter area (read-only — see Wide types section) |
Parsing is strict and runs once at `InitializeAsync` so a config typo fails fast
at load instead of surfacing as `BadInternalError` on every read. Bit offsets
must be 0-7, byte offsets non-negative, DB numbers >= 1.
Parsing is strict. Bit offsets must be 0-7, byte offsets non-negative, DB numbers >= 1.
**Tag shape rules are judged per tag, at every gate** (`S7TagShapeRules`, called by
`S7TagDefinitionFactory.FromTagConfig`): the AdminUI editor refuses the tag, the deploy gate
(`Inspect`) warns, and the driver's init **skips** it (reported through `ISkippedTagReporter`; it reads
`BadNodeIdUnknown`) while every other tag keeps working. Refused: an address that does not parse, a
wide/structured type that is an array or is not byte-addressed (`DBB`/`MB`/`IB`/`QB`), a narrow type at the
wrong access width (`Int32` on `DBW0`, `Bool` on `DBB0`...), a Timer not typed `Float64` or a Counter not typed
`Int32`, a writable or array Timer/Counter, a writable `Bool` array, and a `Bool` array that does not start
at bit 0 (a Bool array reads bit 0 of each byte, so `DB1.DBX0.3` used to silently return bit 0 of bytes
0..n). These used to be init guards that THREW, so one typo faulted every tag on the PLC and the actor
retried the same init every 10 s forever (R07:H3).
## Data types
@@ -277,7 +289,7 @@ AdminUI `/hosts` page (Gitea #640). See `docs/Telemetry.md` ("Driver diagnostics
| `IWritable` | `WriteAsync` → `S7.Net.Plc.WriteAsync` | Read-only tags (`Writable=false`) return `BadNotWritable` |
| `ITagDiscovery` | `DiscoverAsync` | Emits a flat `S7/` folder of the configured tags — no live browse |
| `ISubscribable` | per-tag poll loop with capped exponential backoff | S7 has no push model; floor is 100 ms (the CPU services the comms mailbox once per scan) |
| `IHostConnectivityProbe` | periodic `S7.Net.Plc.ReadStatusAsync` (CPU-status PDU) | `host:port` host key; `Running`/`Stopped` transitions raise `OnHostStatusChanged` |
| `IHostConnectivityProbe` | periodic `S7.Net.Plc.ReadStatusAsync` (CPU-status PDU) | `host:port` host key; `Running`/`Stopped` transitions raise `OnHostStatusChanged`. A tick that finds the gate held past the probe deadline (a long read pass) gives **no verdict** — it no longer reports `Stopped` for a connection that is busy, not lost (R07:H7) |
### Single-connection policy
@@ -302,8 +314,9 @@ Portal under *Protection & Security* for the CPU.
| Tag not in config | `BadNodeIdUnknown` | unchanged |
| Read-only tag written | `BadNotWritable` | unchanged |
| Unimplemented data type | `BadNotSupported` | unchanged |
| PUT/GET denied | `BadNotSupported` | `Faulted` (config alert) |
| CPU / hardware fault | `BadDeviceFailure` | `Degraded` |
| PUT/GET denied | `BadNotSupported` | `Faulted` (config alert) — also counts as *answered*, advances `LastSuccessfulRead` (no watchdog reconnect loop) |
| CPU refusal of the item (object does not exist, address out of range, type inconsistent, hardware fault) | `BadDeviceFailure` | unchanged — counts as *answered*, advances `LastSuccessfulRead`; one Warning per tag |
| Other device-layer fault (`PlcException` that is neither a refusal nor connection-fatal) | `BadDeviceFailure` | `Degraded` |
| Socket / timeout | `BadCommunicationError` | `Degraded` |
| Reconnect suppressed (backoff window) | `BadCommunicationError` | `Degraded` |
| Array write of the wrong shape / length / element type | `BadTypeMismatch` | unchanged |
@@ -314,7 +327,36 @@ accumulate their outcomes and write health once (`Healthy < Degraded < Faulted`)
`Faulted` item is not erased by a later `Degraded` or successful sibling. The write-side `Faulted` is
**sticky against reads**: a clean read pass (reads often work against a write-protected CPU) leaves
`Faulted` in place and only advances `LastSuccessfulRead`; the next write that succeeds clears it. A
transient `Degraded` write failure is still cleared by a clean read, as before.
transient `Degraded` write failure is cleared by a clean read, or by the next write batch the CPU
answers without a failure.
**A refusal is an answer (R07:H4).** S7.Net's read paths raise the CPU's item error as a bare
`Exception` ("Received error from PLC: Object does not exist." — live-probed on the snap7 fixture), its
write paths as a `PlcException(WriteData)`; both are classified over the whole exception chain, and the
access-denied text inside either (S7.Net's exact "Accessing object not allowed", and never when the chain
also carries a transport fault) is the `Faulted` alert — which is ALSO an answer: it advances
`LastSuccessfulRead`, so a factory-default CPU with PUT/GET disabled stays `Faulted` instead of being
reconnected by the comms-loss watchdog every 90 s. A multi-var chunk that falls back per tag memoises the
members refused on their own, like a refused span. A refusal is deterministic, so it neither
degrades health nor reopens the connection, and because it proves the CPU is reachable it advances
`LastSuccessfulRead` and restores `Healthy` after an earlier comms fault. Before this, a driver whose
every tag was refused (a wrong DB number) sat `Degraded` with a frozen timestamp and the comms-loss
watchdog reconnected it every 90 s against a perfectly reachable PLC.
**A dead handle ends the pass's wire traffic (R07:P1).** Once a read pass or write batch marks the
connection dead (a wire op blew its deadline on a frozen-but-established peer, a socket dropped), the
remaining spans / multi-var chunks / tags / writes of that pass are stamped `BadCommunicationError`
without I/O; the next call reopens. A multi-var chunk that dies on a transport loss no longer falls back
to one doomed per-tag read per member. Without this an op that ignores its cancellation token would cost
one full deadline per remaining item under the gate, and `Degraded` (and so the comms-loss watchdog's
clock) only became visible after the whole pass.
**A refused span falls back per member (R07:P2).** A grouped block read covers the union of its members'
ranges, so one member the CPU refuses (running past the DB's real end, an over-declared `stringLength`)
used to make every neighbour in the block Bad on every poll. When the CPU refuses a multi-member span the
driver re-reads its members one at a time for that pass and remembers which tags must not be grouped —
the members refused on their own, or every member when none was — so later passes group only the rest.
The memo lasts until the next initialize. A span failure that is not a refusal still stamps every member.
**Connect backoff.** A dead PLC costs one connect attempt per backoff window (1 s doubling to a 30 s
cap), not one per read / poll tick / probe tick: inside the window `EnsureConnectedAsync` fails fast
@@ -359,7 +401,7 @@ normal dev (the S7 sim is on the shared Docker host but currently offline).
**Array writes (S7-016)** — a writable `Byte` / `Int16` / `UInt16` / `Int32` / `UInt32` / `Float32` array
is written as ONE contiguous `WriteBytesAsync` of `count x element-bytes` (the inverse of the read
decode), and the value must be an array of exactly `arrayLength` elements (else `BadTypeMismatch`, no
health impact). **Writable `Bool` arrays are refused at init** (`NotSupportedException`): the array
health impact). **Writable `Bool` arrays are refused per tag** (see the tag shape rules above): the array
read is byte-granular, so a matching write would overwrite the 7 neighbouring bits of every byte it
touches, and a safe bit-packed write needs read-modify-write. Mark Bool arrays `writable: false`. A `null` array element is rejected (`BadTypeMismatch`, nothing written)
rather than coerced to 0. An array write larger than one PDU is split by S7.Net into several writes and is
@@ -48,8 +48,23 @@ public static class S7TagDefinitionFactory
/// <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>
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!;
shapeRejection = null;
if (string.IsNullOrWhiteSpace(tagConfig)) return false;
try
{
@@ -78,7 +93,7 @@ public static class S7TagDefinitionFactory
// "writable" defaults to true when absent (today's value); node-level authz still governs
// writes. Honouring the key makes read-only equipment tags authorable.
var writable = TagConfigJson.ReadWritable(root);
def = new S7TagDefinition(
var mapped = new S7TagDefinition(
Name: rawPath,
Address: address,
DataType: dataType,
@@ -86,6 +101,15 @@ public static class S7TagDefinitionFactory
StringLength: stringLength == 0 ? MaxStringLength : stringLength,
WriteIdempotent: false,
ArrayCount: arrayCount);
// The address must parse and the shape must be one the driver can serve. These used to be
// init-time guards that THREW and faulted every tag on the PLC; refusing here skips only
// this tag, and the editor + deploy gate (which ask this method) refuse it too.
if (S7TagShapeRules.Validate(mapped, out _) is { } reason)
{
shapeRejection = reason;
return false;
}
def = mapped;
return true;
}
catch (JsonException) { return false; }
@@ -162,7 +186,9 @@ public static class S7TagDefinitionFactory
/// <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
/// 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
/// the blob, when it is blank, or when it is not an equipment-tag object. Never throws.
/// </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
// 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
{
@@ -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)
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.
var sw = Stopwatch.StartNew();
try
@@ -71,9 +79,9 @@ public sealed class S7DriverProbe : IDriverProbe
var plc = S7Tsap.Resolve(opts) is { } tsap
? new Plc(host, port, tsap)
: 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);
handshakeCts.CancelAfter(timeout);
handshakeCts.CancelAfter(opTimeout);
try
{
await plc.OpenAsync(handshakeCts.Token);
@@ -128,15 +136,17 @@ public sealed class S7DriverProbe : IDriverProbe
return null;
}
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,
"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)
{
@@ -146,4 +156,31 @@ public sealed class S7DriverProbe : IDriverProbe
private static (string host, int port) ExtractTarget(S7DriverOptions opts)
=> (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
/// 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
/// to read anyway. A grouped read therefore cannot fault (area overrun, DB overrun) where
/// the individual per-tag reads would have succeeded.
/// to read anyway.
/// </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>
/// <b>Timer and Counter tags are never grouped.</b> Their
@@ -112,17 +112,15 @@ public sealed class S7TagConfigModel
+ string.Join(", ", Enum.GetNames<S7DataType>()) + ".";
}
// Array shape is judged against the driver's INIT guards, which THROW (failing the whole
// driver's init, not just this tag) for a wide-type array and for a writable Bool array. The
// Array shape: the friendlier editor wording for two of the S7TagShapeRules refusals (a wide-type
// 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
// (TagArrayConfig) and are intentionally not fields of this model, so read them off the key bag.
if (TagConfigJson.GetBool(_bag, "isArray"))
{
var dt = DataType;
if (dt is S7DataType.Int64 or S7DataType.UInt64 or S7DataType.Float64
or S7DataType.String or S7DataType.DateTime or S7DataType.WString or S7DataType.Char
or S7DataType.WChar or S7DataType.Dtl or S7DataType.Date or S7DataType.Tod
or S7DataType.Time or S7DataType.S5Time)
if (S7TagShapeRules.IsByteAnchored(dt))
{
return $"S7 does not support {dt} arrays — use Bool (read-only), Byte, Int16, UInt16, Int32, "
+ "UInt32 or Float32 for an array tag, or a scalar tag.";
@@ -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();
using var _ = drv;
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(
[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")]
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,
// so it must fail fast at init rather than throw a misleading type-mismatch on every
// read. The host is reserved-for-documentation so the TCP connect can never succeed —
// the unsupported-address guard runs before the connect, so the NotSupportedException
// 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();
// A Timer/Counter address typed anything but Float64 (Timer) / Int32 (Counter) cannot be decoded.
// It is refused PER TAG at every gate; it used to throw at init and fault every tag on the PLC.
await S7TagSkipAssert.RefusedPerTagAsync(
new S7TagDefinition("Quirk", address, S7DataType.Int16, Writable: false), address);
}
/// <summary>Verifies that initialize accepts DB and MIQ addresses without triggering the unsupported guard.</summary>
@@ -102,19 +86,21 @@ public sealed class S7DriverCodeReviewFixTests
[Fact]
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
// constructor options — proves Driver.S7-001 and Driver.S7-011 compose correctly.
// The shape gate must run against the *re-parsed* config, not just the constructor options —
// proves the per-tag skip and Driver.S7-011 compose correctly.
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 = """
{ "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 } ] }
""";
var ex = await Should.ThrowAsync<NotSupportedException>(async () =>
await drv.InitializeAsync(json, TestContext.Current.CancellationToken));
ex.Message.ShouldContain("T5");
await drv.InitializeAsync(json, TestContext.Current.CancellationToken);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
var skipped = drv.GetSkippedTags().ShouldNotBeNull().Sample.ShouldHaveSingleItem();
skipped.RawPath.ShouldBe("TimerTag");
skipped.Reason.ShouldContain("T5");
}
/// <summary>Verifies that reinitialize applies a changed driverConfigJson.</summary>
@@ -158,23 +158,9 @@ public sealed class S7DriverCodeReviewFixTests2
[InlineData(S7DataType.DateTime)]
public async Task Initialize_rejects_wide_type_at_non_byte_address_with_NotSupportedException(S7DataType dt)
{
var opts = new S7DriverOptions
{
Host = "192.0.2.1",
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);
// 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.)
await S7TagSkipAssert.RefusedPerTagAsync(new S7TagDefinition("X", "DB1.DBD0", dt), dt.ToString(), "byte-addressed");
}
/// <summary>Verifies that Initialize accepts implemented data types.</summary>
@@ -17,22 +17,11 @@ public sealed class S7DriverReadWriteTests
[Fact]
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
// first read) would surface the config bug as BadInternalError on every subsequent
// Read which is impossible for an operator to diagnose from the OPC UA client.
var opts = new S7DriverOptions
{
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));
// A bad address is refused PER TAG at every gate (mapper/editor, deploy gate, driver init):
// it reads BadNodeIdUnknown while its neighbours keep working. It used to throw at init and
// fault every tag on the PLC, with the actor retrying the same init every 10 s forever.
await S7TagSkipAssert.RefusedPerTagAsync(
new S7TagDefinition("BadTag", "NOT-AN-S7-ADDRESS", S7DataType.Int16), "does not parse");
}
/// <summary>Verifies that ReadAsync without initialize throws InvalidOperationException.</summary>
@@ -101,80 +101,32 @@ public sealed class S7DriverScaffoldTests
[Fact]
public async Task Initialize_rejects_wide_type_array_with_clear_message()
{
var opts = new S7DriverOptions
{
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);
// 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");
}
/// <summary>Verifies that a wide scalar with a non-byte address (Float64 at DB1.DBW0) is rejected with a byte-address message.</summary>
[Fact]
public async Task Initialize_rejects_wide_scalar_with_non_byte_address()
{
var opts = new S7DriverOptions
{
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);
// 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");
}
/// <summary>Verifies that a Timer tag with the wrong DataType (Int32, not Float64) is rejected.</summary>
[Fact]
public async Task Initialize_rejects_Timer_tag_with_wrong_data_type()
{
var opts = new S7DriverOptions
{
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);
// 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");
}
/// <summary>Verifies that a Counter tag with the wrong DataType (Float32, not Int32) is rejected.</summary>
[Fact]
public async Task Initialize_rejects_Counter_tag_with_wrong_data_type()
{
var opts = new S7DriverOptions
{
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);
// 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");
}
// ── Phase 4d bundle-review fix — Writable Timer/Counter guard ───────────────────────
@@ -188,42 +140,16 @@ public sealed class S7DriverScaffoldTests
[Fact]
public async Task Initialize_rejects_Timer_tag_declared_Writable()
{
var opts = new S7DriverOptions
{
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);
// 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");
}
/// <summary>Verifies that a Counter tag declared Writable=true is rejected at init with a Writable message.</summary>
[Fact]
public async Task Initialize_rejects_Counter_tag_declared_Writable()
{
var opts = new S7DriverOptions
{
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);
// 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");
}
// ── Driver.S7-015 / S7-016 — writable Bool array must fail fast at init ─────────────
@@ -236,20 +162,8 @@ public sealed class S7DriverScaffoldTests
[Fact]
public async Task Initialize_rejects_writable_bool_array_tag_with_NotSupportedException()
{
var opts = new S7DriverOptions
{
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);
// 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");
}
/// <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()
{
var ct = TestContext.Current.CancellationToken;
// ErrorCode.ReadData with a message that is NOT access-denied ("not allowed") — a genuine
// device-layer fault, which the driver maps to BadDeviceFailure + Degraded.
var fault = new PlcException(ErrorCode.ReadData, "S7 CPU reported a device fault");
// A device-layer fault that is neither a refusal (a ReadData item error is per-tag Bad with
// no health impact since R07:H4, and a refused span falls back per member since R07:P2) nor
// 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 perTagFactory = new ImageS7PlcFactory(Images()) { ReadFault = fault };
@@ -468,8 +469,11 @@ public sealed class S7SpanReadParityTests
}
/// <summary>
/// The SPAN path obeys the same rule: one member that fails to decode leaves the pass
/// <c>Degraded</c> even though a later member of the same block decoded fine.
/// The SPAN path guards each member's decode: one member that fails to decode is Bad on its
/// 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>
/// <remarks>
/// 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.
/// </remarks>
[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;
// 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();
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);
}
@@ -542,7 +546,7 @@ public sealed class S7SpanReadParityTests
// which is the Faulted config alert. B: an ordinary device-layer fault, merely Degraded,
// observed AFTER it. C: no fault at all.
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
// faults can be aimed at individual addresses.
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());
// A: an ordinary device-layer fault (Degraded) FIRST. B: the access-denied config alert
// (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");
using var driver = new S7Driver(Options(NarrowTags(), maxGap: -1), "s7-escalation-message", factory);
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();
}
/// <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]
public async Task Probe_reports_any_other_read_fault_as_a_failure()
{
@@ -318,16 +318,9 @@ public sealed class S7ValueCodecTests
[Fact]
public async Task New_types_declared_as_arrays_or_off_byte_addresses_fail_init()
{
S7DriverOptions With(S7TagDefinition t) => new()
{
Host = "192.0.2.1",
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));
// 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");
await S7TagSkipAssert.RefusedPerTagAsync(Tag(S7DataType.Tod, address: "DB1.DBW0") with { Name = "TodWord" }, "Tod", "byte-addressed");
}
// ---- 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(
DriverTypeNames.S7,
"Name,DataType,Address,S7DataType,TagConfigJson",
"t1,Int16,DB1.DBW0,Int32,\"{\"\"pollHint\"\":\"\"fast\"\"}\"");
"t1,Int16,DB1.DBD0,Int32,\"{\"\"pollHint\"\":\"\"fast\"\"}\"");
var blob = Blob(row);
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)
{
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(
$"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
/// asymmetry that predates this change and that its own <c>Inspect</c> does not report either.)</summary>
[Theory]
[InlineData("""{"address":"DB1.DBW0","dataType":"float32"}""")]
[InlineData("""{"address":"DB1.DBW0","dataType":"FLOAT32"}""")]
[InlineData("""{"address":"DB1.DBD0","dataType":"float32"}""")]
[InlineData("""{"address":"DB1.DBD0","dataType":"FLOAT32"}""")]
[InlineData("""{"address":"DB1.DBW0","dataType":7}""")]
public void Validate_accepts_every_dataType_the_driver_accepts(string json)
=> 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]
[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
/// 0–254. The reader REJECTS it (it does not clamp), so the tag would never resolve.</summary>
[Theory]
[InlineData("""{"address":"DB1.DBS0","dataType":"String","stringLength":255}""")]
[InlineData("""{"address":"DB1.DBS0","dataType":"String","stringLength":300}""")]
[InlineData("""{"address":"DB1.DBS0","dataType":"String","stringLength":-1}""")]
[InlineData("""{"address":"DB1.DBB0","dataType":"String","stringLength":255}""")]
[InlineData("""{"address":"DB1.DBB0","dataType":"String","stringLength":300}""")]
[InlineData("""{"address":"DB1.DBB0","dataType":"String","stringLength":-1}""")]
public void S7_refuses_a_string_length_its_reader_refuses(string blob)
{
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
/// String at all, which the reader ignores entirely.</summary>
[Theory]
[InlineData("""{"address":"DB1.DBS0","dataType":"String","stringLength":10}""")]
[InlineData("""{"address":"DB1.DBS0","dataType":"String","stringLength":254}""")]
[InlineData("""{"address":"DB1.DBS0","dataType":"String","stringLength":0}""")]
[InlineData("""{"address":"DB1.DBB0","dataType":"String","stringLength":10}""")]
[InlineData("""{"address":"DB1.DBB0","dataType":"String","stringLength":254}""")]
[InlineData("""{"address":"DB1.DBB0","dataType":"String","stringLength":0}""")]
[InlineData("""{"address":"DB1.DBW0","dataType":"Int16","stringLength":300}""")]
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"}"""),
(DriverTypeNames.S7, """{"address":" ","dataType":"Int16"}"""),
(DriverTypeNames.S7, """{"address":"DB1.DBS0","dataType":"String","stringLength":300}"""),
(DriverTypeNames.S7, """{"address":"DB1.DBS0","dataType":"String","stringLength":24}"""),
(DriverTypeNames.S7, """{"address":"DB1.DBB0","dataType":"String","stringLength":300}"""),
(DriverTypeNames.S7, """{"address":"DB1.DBB0","dataType":"String","stringLength":24}"""),
(DriverTypeNames.AbCip, "{}"),
(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.S7, new S7TagConfigModel { Address = "DB1.DBW0" }.ToJson());
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.AbLegacy, new AbLegacyTagConfigModel { Address = "N7:0" }.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. -->
<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"/>
<!-- 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>