[s7] S7 — Pre-flight PUT/GET enablement test #378

Merged
dohertj2 merged 1 commits from auto/s7/PR-S7-C5 into auto/driver-gaps 2026-04-26 01:34:29 -04:00
9 changed files with 625 additions and 3 deletions

View File

@@ -34,6 +34,28 @@ Enable it in TIA Portal: *Device config → Protection & Security → Connection
mechanisms → "Permit access with PUT/GET communication from remote partner"*. mechanisms → "Permit access with PUT/GET communication from remote partner"*.
Without it the CLI's first read will surface `BadNotSupported`. Without it the CLI's first read will surface `BadNotSupported`.
### Pre-flight PUT/GET enablement (PR-S7-C5)
The driver issues a tiny 2-byte read against `Probe.ProbeAddress` (default
`MW0`) immediately after `OpenAsync` and **fails `InitializeAsync` with a
typed `S7PutGetDisabledException`** when the PLC rejects the read with the
wire-level "function not allowed" response. The exception message names the
exact TIA Portal toggle to flip — operators see the configuration fix at
init time, not after the first per-tag read produces `BadDeviceFailure`.
Two opt-out knobs on the JSON `Probe` block:
- `ProbeAddress` — set to `""` (empty string) to skip the pre-flight read
entirely. Useful when no fingerprint address has been wired.
- `SkipPreflight` — set to `true` to defer the check to runtime while
keeping the background liveness loop. Per-tag reads still surface
`BadDeviceFailure` until PUT/GET is enabled, but Init succeeds and the
driver becomes visible in the Admin UI.
See [s7.md "Pre-flight PUT/GET enablement"](v2/s7.md#pre-flight-putget-enablement)
for the full rationale, classifier behaviour, and the wire-level
`ErrorCode` matching.
## S7 address grammar cheat sheet ## S7 address grammar cheat sheet
| Form | Meaning | | Form | Meaning |

View File

@@ -113,6 +113,18 @@ arrays of structs — not covered.
lab rig but not CI. lab rig but not CI.
3. **Real S7 lab rig** — cheapest physical PLC (CPU 1212C) on a dedicated 3. **Real S7 lab rig** — cheapest physical PLC (CPU 1212C) on a dedicated
network port, wired via self-hosted runner. network port, wired via self-hosted runner.
4. **PR-S7-C5 — PUT/GET-disabled pre-flight rejection.** Snap7 does *not*
model the hardened-CPU PUT/GET response (it accepts every read once the
COTP handshake completes), so the **failure** path of the pre-flight
probe — `S7PutGetDisabledException` thrown from `InitializeAsync` when
the PLC rejects the probe read with `ErrorCode.WrongCPU_Type` /
`ErrorCode.ReadData` — needs a real S7-1500 with PUT/GET disabled in TIA
Portal. The integration suite covers the *happy* path
(`Driver_preflight_passes_when_probe_address_seeded`); the failure path
should be added as a `--with-real-plc` opt-in test that the self-hosted
runner with the lab rig executes. The classifier branch
(`S7PreflightClassifier.IsPutGetDisabled`) is unit-tested without a
network in `S7PreflightTests.Classifier_matches_only_PUT_GET_disabled_error_codes`.
Without any of these, S7 driver correctness against real hardware is trusted Without any of these, S7 driver correctness against real hardware is trusted
from field deployments, not from the test suite. from field deployments, not from the test suite.

View File

@@ -768,6 +768,98 @@ is satisfied.
whether to invoke `OnDataChange`. The mailbox / PDU / coalescing path whether to invoke `OnDataChange`. The mailbox / PDU / coalescing path
is untouched. is untouched.
## Pre-flight PUT/GET enablement
S7-1200 / S7-1500 CPUs ship with **PUT/GET communication disabled by
default**. The COTP / S7comm handshake itself succeeds against these
locked-down CPUs (you can `OpenAsync` / negotiate PDU size cleanly), so
the failure surfaces only on the *first* `Plc.ReadAsync` — at which
point the driver is already past `InitializeAsync`, has flipped to
`DriverState.Healthy`, and dependent code (subscriptions, Admin UI) is
binding against a connection it can't actually use. Operators see
`BadDeviceFailure` per tag instead of a single, actionable
configuration error.
PR-S7-C5 adds a **post-`OpenAsync` pre-flight probe**: a tiny 2-byte
read against `Probe.ProbeAddress` (default `MW0`). If the PLC rejects
that read with the wire-level "function not allowed in current
operating state" response (S7 error family `D6 05` / `85 00`),
S7netplus surfaces the rejection as `PlcException` with one of
`ErrorCode.WrongCPU_Type` (CPU drops the connection mid-response) or
`ErrorCode.ReadData` (CPU sends an S7-level error byte). The driver
classifies that pair as "PUT/GET disabled" and throws a typed
`S7PutGetDisabledException` from `InitializeAsync` so the operator sees
the TIA-Portal fix path immediately:
> PUT/GET communication is disabled on the PLC. Enable it in TIA Portal:
> *Device → Properties → Protection & Security → Connection mechanisms →
> "Permit access with PUT/GET communication from remote partner"*.
> Re-deploy the hardware config and restart the S7 driver.
`S7PreflightClassifier.IsPutGetDisabled(PlcException)` is the pure
function that decides whether a given `PlcException` qualifies; it
matches **only** `WrongCPU_Type` and `ReadData`. Other error codes
(`ConnectionError`, `IPAddressNotAvailable`, `WrongVarFormat`, …)
indicate transport / framing faults rather than PUT/GET gating, so the
driver re-throws the original `PlcException` unchanged and the existing
`DriverState.Faulted` path takes over with the original message.
### Knobs
Two opt-out knobs on `S7ProbeOptions`:
- `ProbeAddress` (`string?`, default `"MW0"`) — address probed by both
the background liveness loop and the pre-flight read. Set to `null`
(or empty string in JSON) to skip the pre-flight entirely. Useful
for sites where no fingerprint address has been wired and an arbitrary
read at `MW0` would itself be misleading.
- `SkipPreflight` (`bool`, default `false`) — opt out of the pre-flight
read while keeping the background probe. Init succeeds against a
PUT/GET-disabled CPU; per-tag reads still surface `BadDeviceFailure`
at runtime. Useful for staged deployments where the operator hasn't
enabled PUT/GET yet but wants the driver visible in the Admin UI.
### Why `MW0`?
The convention from `Driver.S7.Cli.md`'s `probe` command. `MW0` exists
on every S7 CPU regardless of project — Merker memory is universal —
so it's a safe default that doesn't require a per-site DB to be wired.
Sites with a dedicated fingerprint DB can override to e.g.
`DB1.DBW0`.
### JSON config example
```json
{
"Host": "10.0.0.50",
"Probe": {
"Enabled": true,
"IntervalMs": 5000,
"TimeoutMs": 2000,
"ProbeAddress": "DB1.DBW0",
"SkipPreflight": false
}
}
```
To skip the pre-flight (defer the check to first read):
```json
{
"Host": "10.0.0.50",
"Probe": { "SkipPreflight": true }
}
```
To skip the probe entirely (no pre-flight, no liveness loop):
```json
{
"Host": "10.0.0.50",
"Probe": { "Enabled": false, "ProbeAddress": "" }
}
```
## TSAP / Connection Type ## TSAP / Connection Type
S7comm runs on top of ISO-on-TCP (RFC 1006), and the COTP connection-request S7comm runs on top of ISO-on-TCP (RFC 1006), and the COTP connection-request

View File

@@ -181,6 +181,18 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
// CPUs negotiate 240 bytes; CPUs running the extended PDU advertise 480 or 960. // CPUs negotiate 240 bytes; CPUs running the extended PDU advertise 480 or 960.
_negotiatedPduSize = plc.MaxPDUSize; _negotiatedPduSize = plc.MaxPDUSize;
// PR-S7-C5 — pre-flight PUT/GET enablement probe. After a clean OpenAsync,
// issue a tiny 2-byte read against Probe.ProbeAddress (default MW0). Hardened
// S7-1200 / S7-1500 CPUs that have PUT/GET communication disabled in TIA
// Portal accept the COTP/S7comm handshake but reject every read PDU with an
// S7-level "function not allowed" error (D6 05 / 85 00 family); S7netplus
// surfaces that as PlcException with ErrorCode in {WrongCPU_Type, ReadData}.
// Surface the typed exception now so operators see the configuration-fix
// hint at Init time, not on first per-tag read. Skipping the probe is opt-in
// via SkipPreflight or by setting ProbeAddress = null for sites without a
// wired fingerprint address.
await RunPreflightAsync(plc, cts.Token).ConfigureAwait(false);
_health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null, BuildDiagnostics()); _health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null, BuildDiagnostics());
// Kick off the probe loop once the connection is up. Initial HostState stays // Kick off the probe loop once the connection is up. Initial HostState stays
@@ -894,6 +906,61 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
private global::S7.Net.Plc RequirePlc() => private global::S7.Net.Plc RequirePlc() =>
Plc ?? throw new InvalidOperationException("S7Driver not initialized"); Plc ?? throw new InvalidOperationException("S7Driver not initialized");
/// <summary>
/// PR-S7-C5 — issue the post-<c>OpenAsync</c> pre-flight probe read against
/// <see cref="S7ProbeOptions.ProbeAddress"/> and translate a "PUT/GET disabled"
/// wire response into a typed <see cref="S7PutGetDisabledException"/>. Skips the
/// probe entirely when <see cref="S7ProbeOptions.SkipPreflight"/> is set or when
/// <see cref="S7ProbeOptions.ProbeAddress"/> is null/empty (sites that haven't
/// wired a fingerprint address). Other <see cref="global::S7.Net.PlcException"/>
/// surfaces (transport drop, IP unavailable, bad var format) rethrow unchanged so
/// the caller doesn't lose the original failure shape.
/// </summary>
private async Task RunPreflightAsync(global::S7.Net.Plc plc, CancellationToken ct)
{
if (_options.Probe.SkipPreflight) return;
var probeAddr = _options.Probe.ProbeAddress;
if (string.IsNullOrWhiteSpace(probeAddr)) return;
S7ParsedAddress parsed;
try
{
parsed = S7AddressParser.Parse(probeAddr, _options.CpuType);
}
catch (FormatException)
{
// Bad probe address is a config bug — let the FormatException bubble out so
// the caller's DriverState.Faulted message names the offending address.
throw;
}
// 2-byte read covers MW0 (the default), DBn.DBW0 fingerprints, and any other
// word-shaped probe address. Bit-addressed probes round up to 1 byte; bytes
// float to 2 to match the typical "fingerprint" word convention.
int byteCount = parsed.Size switch
{
S7Size.Bit => 1,
S7Size.Byte => 1,
S7Size.Word => 2,
S7Size.DWord => 4,
S7Size.LWord => 8,
_ => 2,
};
try
{
await plc.ReadBytesAsync(MapArea(parsed.Area), parsed.DbNumber, parsed.ByteOffset, byteCount, ct)
.ConfigureAwait(false);
}
catch (global::S7.Net.PlcException pex) when (S7PreflightClassifier.IsPutGetDisabled(pex))
{
// Map the S7-level "function not allowed" rejection into our typed exception
// so the operator sees the TIA-Portal fix path instead of a generic
// PlcException stack trace. Inner exception preserved for diagnostics.
throw new S7PutGetDisabledException(probeAddr!, pex);
}
}
/// <summary> /// <summary>
/// Construct the underlying S7netplus <see cref="Plc"/> honouring /// Construct the underlying S7netplus <see cref="Plc"/> honouring
/// <see cref="S7DriverOptions.TsapMode"/>, <see cref="S7DriverOptions.LocalTsap"/>, /// <see cref="S7DriverOptions.TsapMode"/>, <see cref="S7DriverOptions.LocalTsap"/>,

View File

@@ -67,7 +67,15 @@ public static class S7DriverFactoryExtensions
Enabled = dto.Probe?.Enabled ?? true, Enabled = dto.Probe?.Enabled ?? true,
Interval = TimeSpan.FromMilliseconds(dto.Probe?.IntervalMs ?? 5_000), Interval = TimeSpan.FromMilliseconds(dto.Probe?.IntervalMs ?? 5_000),
Timeout = TimeSpan.FromMilliseconds(dto.Probe?.TimeoutMs ?? 2_000), Timeout = TimeSpan.FromMilliseconds(dto.Probe?.TimeoutMs ?? 2_000),
ProbeAddress = dto.Probe?.ProbeAddress ?? "MW0", // PR-S7-C5 — explicit empty-string in JSON skips the probe entirely
// (sites without a fingerprint address); a missing field falls back to
// the MW0 convention so existing configs keep working unchanged.
ProbeAddress = dto.Probe is null
? "MW0"
: (dto.Probe.ProbeAddress is null
? "MW0"
: (string.IsNullOrWhiteSpace(dto.Probe.ProbeAddress) ? null : dto.Probe.ProbeAddress)),
SkipPreflight = dto.Probe?.SkipPreflight ?? false,
}, },
TsapMode = ParseEnum<TsapMode>(dto.TsapMode, driverInstanceId, "TsapMode", TsapMode = ParseEnum<TsapMode>(dto.TsapMode, driverInstanceId, "TsapMode",
fallback: TsapMode.Auto), fallback: TsapMode.Auto),
@@ -193,6 +201,22 @@ public static class S7DriverFactoryExtensions
public bool? Enabled { get; init; } public bool? Enabled { get; init; }
public int? IntervalMs { get; init; } public int? IntervalMs { get; init; }
public int? TimeoutMs { get; init; } public int? TimeoutMs { get; init; }
/// <summary>
/// Address probed by the background liveness loop and (PR-S7-C5) by the
/// post-<c>OpenAsync</c> pre-flight check. Default <c>MW0</c> when the
/// <c>Probe</c> object is omitted entirely; explicit <c>null</c> /
/// whitespace skips both the background probe and the pre-flight read.
/// </summary>
public string? ProbeAddress { get; init; } public string? ProbeAddress { get; init; }
/// <summary>
/// PR-S7-C5 — opt out of the pre-flight PUT/GET enablement read at
/// <see cref="S7Driver.InitializeAsync"/> time. Default <c>false</c> =
/// pre-flight runs and a hardened CPU with PUT/GET disabled fails Init
/// with <see cref="S7PutGetDisabledException"/>. See
/// <c>docs/v2/s7.md</c> "Pre-flight PUT/GET enablement" section.
/// </summary>
public bool? SkipPreflight { get; init; }
} }
} }

View File

@@ -214,9 +214,26 @@ public sealed class S7ProbeOptions
/// <summary> /// <summary>
/// Address to probe for liveness. DB1.DBW0 is the convention if the PLC project /// Address to probe for liveness. DB1.DBW0 is the convention if the PLC project
/// reserves a small fingerprint DB for health checks (per <c>docs/v2/s7.md</c>); /// reserves a small fingerprint DB for health checks (per <c>docs/v2/s7.md</c>);
/// if not, pick any valid Merker word like <c>MW0</c>. /// if not, pick any valid Merker word like <c>MW0</c>. Set to <c>null</c> to
/// skip the pre-flight probe at <see cref="S7Driver.InitializeAsync"/> time
/// for sites that haven't wired a fingerprint address.
/// </summary> /// </summary>
public string ProbeAddress { get; init; } = "MW0"; public string? ProbeAddress { get; init; } = "MW0";
/// <summary>
/// PR-S7-C5 — skip the pre-flight PUT/GET enablement probe that
/// <see cref="S7Driver.InitializeAsync"/> issues immediately after
/// <c>OpenAsync</c>. Default <c>false</c> = pre-flight runs and a hardened
/// CPU with PUT/GET disabled fails Init with
/// <see cref="S7PutGetDisabledException"/>. Set <c>true</c> to defer the
/// check to first per-tag read — the driver will still surface
/// <c>BadDeviceFailure</c> per tag, but Init succeeds and dependent code
/// paths (subscriptions, Admin UI binding) come up. Useful for staged
/// deployments where the operator hasn't enabled PUT/GET yet but wants
/// the driver visible in the Admin UI. See <c>docs/v2/s7.md</c>
/// "Pre-flight PUT/GET enablement" section.
/// </summary>
public bool SkipPreflight { get; init; } = false;
} }
/// <summary> /// <summary>

View File

@@ -0,0 +1,84 @@
using S7NetErrorCode = global::S7.Net.ErrorCode;
using S7NetPlcException = global::S7.Net.PlcException;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7;
/// <summary>
/// Thrown by <see cref="S7Driver.InitializeAsync"/> when the post-<c>OpenAsync</c>
/// pre-flight probe receives a response that the driver classifies as
/// "PUT/GET communication disabled on the PLC". Surfaces a typed exception so
/// operators see the configuration-fix instructions immediately at init time
/// instead of waiting for the first per-tag read to fail with
/// <c>BadDeviceFailure</c>. See <c>docs/v2/s7.md</c> "Pre-flight PUT/GET enablement"
/// section for the full rationale.
/// </summary>
public sealed class S7PutGetDisabledException : Exception
{
/// <summary>The probe address that triggered the typed classification (e.g. <c>MW0</c>).</summary>
public string ProbeAddress { get; }
/// <summary>
/// Construct a typed exception. <paramref name="inner"/> is the raw
/// <see cref="S7NetPlcException"/> from S7netplus that
/// <see cref="S7PreflightClassifier.IsPutGetDisabled"/> classified positive.
/// </summary>
public S7PutGetDisabledException(string probeAddress, Exception? inner = null)
: base(BuildMessage(probeAddress), inner)
{
ProbeAddress = probeAddress;
}
internal static string BuildMessage(string probeAddress) =>
"S7 pre-flight probe to '" + probeAddress + "' was rejected by the PLC. " +
"PUT/GET communication is disabled on the PLC. " +
"Enable it in TIA Portal: Device → Properties → Protection & Security → " +
"Connection mechanisms → 'Permit access with PUT/GET communication from " +
"remote partner'. Re-deploy the hardware config and restart the S7 driver. " +
"Alternatively set 'Probe.SkipPreflight = true' to defer the check to runtime " +
"(driver will still surface BadDeviceFailure on every read until PUT/GET is enabled).";
}
/// <summary>
/// Classifies an <see cref="S7NetPlcException"/> coming back from the pre-flight
/// probe read into "PUT/GET disabled" vs "everything else". Pulled out as a static
/// helper so unit tests can drive every branch (matching ErrorCode, non-matching
/// ErrorCode, null exception) without spinning up an S7 server. Mirrors the
/// <c>ModbusPreflight</c> pattern from the Modbus driver.
/// </summary>
public static class S7PreflightClassifier
{
/// <summary>
/// Returns <c>true</c> when the exception's <c>ErrorCode</c> matches the
/// S7.Net surface for "PLC refused the read" — which on hardened S7-1200 /
/// S7-1500 firmware is the wire-level signal for PUT/GET disabled. We match
/// <see cref="S7NetErrorCode.WrongCPU_Type"/> (S7.Net's primary classification
/// when the response framing isn't a valid PDU because the CPU rejected the
/// request) and <see cref="S7NetErrorCode.ReadData"/> (the generic
/// "couldn't read" code S7.Net falls back to when the PLC sends an S7-level
/// error byte instead of a normal response).
/// </summary>
/// <remarks>
/// <para>
/// The S7-1200 / S7-1500 wire response when PUT/GET is disabled is an S7
/// "Function not allowed in current protection level" header byte
/// (0xD605 / 0x8500 family). S7netplus surfaces that as PlcException with
/// <see cref="S7NetErrorCode.ReadData"/> on the response path or
/// <see cref="S7NetErrorCode.WrongCPU_Type"/> when the CPU drops the
/// connection before sending a valid response. Matching both ensures the
/// driver flags the config bug regardless of which path the firmware takes.
/// </para>
/// <para>
/// A non-matching <see cref="S7NetErrorCode"/> (e.g.
/// <see cref="S7NetErrorCode.ConnectionError"/>,
/// <see cref="S7NetErrorCode.IPAddressNotAvailable"/>) means "transport
/// broke" — let the caller rethrow as-is so other failure shapes don't
/// get silently masked as "PUT/GET disabled".
/// </para>
/// </remarks>
public static bool IsPutGetDisabled(S7NetPlcException? exception)
{
if (exception is null) return false;
return exception.ErrorCode is S7NetErrorCode.WrongCPU_Type
or S7NetErrorCode.ReadData;
}
}

View File

@@ -0,0 +1,84 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests.S7_1500;
/// <summary>
/// PR-S7-C5 — integration coverage for the post-<c>OpenAsync</c> pre-flight
/// PUT/GET enablement probe. Snap7 always allows reads (no PUT/GET gating
/// in the simulator), so the integration scope is limited to the happy
/// path: pre-flight succeeds against the seeded MW0/DBW0 fingerprint and
/// the driver reaches <see cref="DriverState.Healthy"/>. The "PUT/GET
/// disabled" failure path is unit-tested via
/// <c>S7PreflightClassifier</c> and documented as a follow-up live-firmware
/// test in <c>docs/drivers/S7-Test-Fixture.md</c>.
/// </summary>
[Collection(Snap7ServerCollection.Name)]
[Trait("Category", "Integration")]
[Trait("Device", "S7_1500")]
public sealed class S7_1500PreflightTests(Snap7ServerFixture sim)
{
[Fact]
public async Task Driver_preflight_passes_when_probe_address_seeded()
{
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
// Use the standard S7-1500 profile — DB1.DBW0 / DB1.DBW10 / etc are seeded
// by the snap7 profile so the default MW0 probe (or DB1.DBW0 fallback)
// exists at read time.
var options = S7_1500Profile.BuildOptions(sim.Host, sim.Port);
// Override the probe loop knob set by S7_1500Profile: the loop stays
// disabled, but Probe.ProbeAddress + SkipPreflight are what RunPreflightAsync
// consults. ProbeAddress defaults to MW0 which python-snap7 zeros at startup;
// any successful read (zeros included) satisfies the pre-flight.
var preflightOptions = new S7DriverOptions
{
Host = options.Host,
Port = options.Port,
CpuType = options.CpuType,
Rack = options.Rack,
Slot = options.Slot,
Timeout = options.Timeout,
Tags = options.Tags,
// ProbeAddress = "MW0" (default); SkipPreflight = false (default).
// Background probe loop disabled to avoid mailbox contention with the test.
Probe = new S7ProbeOptions { Enabled = false },
};
await using var drv = new S7Driver(preflightOptions, driverInstanceId: "s7-preflight-pass");
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
// If pre-flight tripped the typed exception, InitializeAsync would have thrown
// before reaching this line. Healthy state proves the probe succeeded.
drv.GetHealth().State.ShouldBe(DriverState.Healthy,
"pre-flight probe must succeed against the seeded snap7 fingerprint");
}
[Fact]
public async Task Driver_preflight_skipped_when_SkipPreflight_set()
{
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
// Skipping the pre-flight is opt-in. The driver must still reach Healthy state
// because the connect path itself succeeds; the only thing different is that no
// probe read fires before _health flips.
var options = S7_1500Profile.BuildOptions(sim.Host, sim.Port);
var skipOptions = new S7DriverOptions
{
Host = options.Host,
Port = options.Port,
CpuType = options.CpuType,
Rack = options.Rack,
Slot = options.Slot,
Timeout = options.Timeout,
Tags = options.Tags,
Probe = new S7ProbeOptions { Enabled = false, SkipPreflight = true },
};
await using var drv = new S7Driver(skipOptions, driverInstanceId: "s7-preflight-skipped");
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
}

View File

@@ -0,0 +1,220 @@
using System.Reflection;
using System.Text.Json;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using S7NetErrorCode = global::S7.Net.ErrorCode;
using S7NetPlcException = global::S7.Net.PlcException;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
/// <summary>
/// PR-S7-C5 — unit coverage for the post-<c>OpenAsync</c> pre-flight PUT/GET
/// enablement probe and its typed exception. The classifier branch tests run
/// without any network. Driver-level tests reach for unreachable IPs to drive
/// the "pre-flight skipped because OpenAsync failed" lifecycle path.
/// </summary>
[Trait("Category", "Unit")]
public sealed class S7PreflightTests
{
// ---- S7PutGetDisabledException ----
[Fact]
public void Typed_exception_carries_the_probe_address()
{
var ex = new S7PutGetDisabledException("MW0");
ex.ProbeAddress.ShouldBe("MW0");
}
[Fact]
public void Typed_exception_message_names_TIA_Portal_fix_path()
{
var ex = new S7PutGetDisabledException("DB1.DBW0");
ex.Message.ShouldContain("DB1.DBW0");
ex.Message.ShouldContain("PUT/GET");
ex.Message.ShouldContain("TIA Portal");
ex.Message.ShouldContain("Permit access");
}
[Fact]
public void Typed_exception_preserves_inner_for_diagnostics()
{
var inner = new InvalidOperationException("simulated");
var ex = new S7PutGetDisabledException("MW0", inner);
ex.InnerException.ShouldBeSameAs(inner);
}
// ---- S7PreflightClassifier.IsPutGetDisabled ----
[Theory]
[InlineData(S7NetErrorCode.WrongCPU_Type, true)]
[InlineData(S7NetErrorCode.ReadData, true)]
[InlineData(S7NetErrorCode.NoError, false)]
[InlineData(S7NetErrorCode.ConnectionError, false)]
[InlineData(S7NetErrorCode.IPAddressNotAvailable, false)]
[InlineData(S7NetErrorCode.WrongVarFormat, false)]
[InlineData(S7NetErrorCode.WrongNumberReceivedBytes, false)]
[InlineData(S7NetErrorCode.SendData, false)]
[InlineData(S7NetErrorCode.WriteData, false)]
public void Classifier_matches_only_PUT_GET_disabled_error_codes(S7NetErrorCode code, bool expected)
{
var pex = new S7NetPlcException(code);
S7PreflightClassifier.IsPutGetDisabled(pex).ShouldBe(expected);
}
[Fact]
public void Classifier_returns_false_for_null_exception()
{
S7PreflightClassifier.IsPutGetDisabled(null).ShouldBeFalse();
}
// ---- S7ProbeOptions defaults ----
[Fact]
public void Default_probe_address_is_MW0()
{
// Documented default — the convention all the docs / CLI examples reference.
new S7ProbeOptions().ProbeAddress.ShouldBe("MW0");
}
[Fact]
public void Default_skip_preflight_is_false()
{
// Pre-flight runs by default. Operators must opt in to skip.
new S7ProbeOptions().SkipPreflight.ShouldBeFalse();
}
[Fact]
public void Probe_address_can_be_null_to_skip_preflight()
{
var probe = new S7ProbeOptions { ProbeAddress = null };
probe.ProbeAddress.ShouldBeNull();
}
// ---- DTO JSON round-trip preserves SkipPreflight ----
[Fact]
public void JSON_round_trip_preserves_SkipPreflight_true()
{
const string json = """
{
"Host": "10.0.0.50",
"Probe": { "Enabled": false, "ProbeAddress": "DB1.DBW0", "SkipPreflight": true }
}
""";
// Hit the CreateInstance flow exactly as the bootstrapper does.
// Use reflection because the DTO + entry method are internal.
var ext = typeof(S7DriverFactoryExtensions);
var create = ext.GetMethod("CreateInstance", BindingFlags.Static | BindingFlags.NonPublic)
.ShouldNotBeNull("S7DriverFactoryExtensions.CreateInstance entry method must exist");
var driver = create.Invoke(null, ["s7-skipflight-test", json]).ShouldBeOfType<S7Driver>();
// Read the resolved options off the driver via reflection; SkipPreflight must
// have flowed through the JSON → DTO → S7ProbeOptions path unchanged.
var optsField = typeof(S7Driver).GetField("_options", BindingFlags.Instance | BindingFlags.NonPublic)
.ShouldNotBeNull();
var opts = optsField.GetValue(driver).ShouldBeOfType<S7DriverOptions>();
opts.Probe.SkipPreflight.ShouldBeTrue("DTO -> options must round-trip SkipPreflight=true");
opts.Probe.ProbeAddress.ShouldBe("DB1.DBW0");
}
[Fact]
public void JSON_round_trip_defaults_SkipPreflight_to_false_when_omitted()
{
const string json = """
{
"Host": "10.0.0.50",
"Probe": { "ProbeAddress": "MW0" }
}
""";
var ext = typeof(S7DriverFactoryExtensions);
var create = ext.GetMethod("CreateInstance", BindingFlags.Static | BindingFlags.NonPublic)
.ShouldNotBeNull();
var driver = create.Invoke(null, ["s7-default-test", json]).ShouldBeOfType<S7Driver>();
var optsField = typeof(S7Driver).GetField("_options", BindingFlags.Instance | BindingFlags.NonPublic)
.ShouldNotBeNull();
var opts = optsField.GetValue(driver).ShouldBeOfType<S7DriverOptions>();
opts.Probe.SkipPreflight.ShouldBeFalse();
}
[Fact]
public void JSON_empty_probe_address_means_skip()
{
// Explicit empty string → no probe wire-up; null in the runtime options means
// "no probe address configured" so RunPreflightAsync skips.
const string json = """
{
"Host": "10.0.0.50",
"Probe": { "ProbeAddress": "" }
}
""";
var ext = typeof(S7DriverFactoryExtensions);
var create = ext.GetMethod("CreateInstance", BindingFlags.Static | BindingFlags.NonPublic)
.ShouldNotBeNull();
var driver = create.Invoke(null, ["s7-noprobe-test", json]).ShouldBeOfType<S7Driver>();
var optsField = typeof(S7Driver).GetField("_options", BindingFlags.Instance | BindingFlags.NonPublic)
.ShouldNotBeNull();
var opts = optsField.GetValue(driver).ShouldBeOfType<S7DriverOptions>();
opts.Probe.ProbeAddress.ShouldBeNull();
}
[Fact]
public void JSON_omitted_probe_object_keeps_default_MW0_address()
{
// No Probe object at all — existing configs from S7-A/B PRs must keep working.
const string json = """
{ "Host": "10.0.0.50" }
""";
var ext = typeof(S7DriverFactoryExtensions);
var create = ext.GetMethod("CreateInstance", BindingFlags.Static | BindingFlags.NonPublic)
.ShouldNotBeNull();
var driver = create.Invoke(null, ["s7-omittedprobe", json]).ShouldBeOfType<S7Driver>();
var optsField = typeof(S7Driver).GetField("_options", BindingFlags.Instance | BindingFlags.NonPublic)
.ShouldNotBeNull();
var opts = optsField.GetValue(driver).ShouldBeOfType<S7DriverOptions>();
opts.Probe.ProbeAddress.ShouldBe("MW0");
opts.Probe.SkipPreflight.ShouldBeFalse();
}
// ---- Driver-level lifecycle around the pre-flight probe ----
//
// We can't drive the "PUT/GET disabled" wire path without a fake S7 server, so
// these tests verify that the SkipPreflight / null-ProbeAddress short-circuits
// are honoured by checking the lifecycle never reaches OpenAsync against an
// unreachable host (otherwise we'd see a Connection Error before the probe
// path runs at all). The classifier branch above is the unit coverage for the
// exception-mapping decision.
[Fact]
public async Task Driver_with_SkipPreflight_still_throws_on_unreachable_host()
{
// Sanity: skipping the pre-flight does NOT skip OpenAsync. An unreachable host
// still flips the driver to Faulted via the existing Connection-Error path.
var opts = new S7DriverOptions
{
Host = "192.0.2.1",
Timeout = TimeSpan.FromMilliseconds(250),
Probe = new S7ProbeOptions { Enabled = false, SkipPreflight = true },
};
await using var drv = new S7Driver(opts, "s7-skip-unreach");
await Should.ThrowAsync<Exception>(async () =>
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
drv.GetHealth().State.ShouldBe(DriverState.Faulted);
}
[Fact]
public async Task Driver_with_null_ProbeAddress_still_throws_on_unreachable_host()
{
// Same sanity check for the "ProbeAddress = null" path.
var opts = new S7DriverOptions
{
Host = "192.0.2.1",
Timeout = TimeSpan.FromMilliseconds(250),
Probe = new S7ProbeOptions { Enabled = false, ProbeAddress = null },
};
await using var drv = new S7Driver(opts, "s7-null-addr-unreach");
await Should.ThrowAsync<Exception>(async () =>
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
drv.GetHealth().State.ShouldBe(DriverState.Faulted);
}
}