Merge lane S7: S7 driver remediation (#742)
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
+50
-8
@@ -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;
|
||||
}
|
||||
}
|
||||
+71
@@ -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);
|
||||
}
|
||||
}
|
||||
+85
@@ -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);
|
||||
}
|
||||
}
|
||||
+49
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+57
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user