diff --git a/docs/Driver.S7.Cli.md b/docs/Driver.S7.Cli.md
index 07f6cc6..28a3baf 100644
--- a/docs/Driver.S7.Cli.md
+++ b/docs/Driver.S7.Cli.md
@@ -34,6 +34,28 @@ Enable it in TIA Portal: *Device config → Protection & Security → Connection
mechanisms → "Permit access with PUT/GET communication from remote partner"*.
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
| Form | Meaning |
diff --git a/docs/drivers/S7-Test-Fixture.md b/docs/drivers/S7-Test-Fixture.md
index 321e09d..cbac804 100644
--- a/docs/drivers/S7-Test-Fixture.md
+++ b/docs/drivers/S7-Test-Fixture.md
@@ -113,6 +113,18 @@ arrays of structs — not covered.
lab rig but not CI.
3. **Real S7 lab rig** — cheapest physical PLC (CPU 1212C) on a dedicated
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
from field deployments, not from the test suite.
diff --git a/docs/v2/s7.md b/docs/v2/s7.md
index a5e47d5..de83374 100644
--- a/docs/v2/s7.md
+++ b/docs/v2/s7.md
@@ -768,6 +768,98 @@ is satisfied.
whether to invoke `OnDataChange`. The mailbox / PDU / coalescing path
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
S7comm runs on top of ISO-on-TCP (RFC 1006), and the COTP connection-request
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs
index 1a69d04..bc2f30e 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs
@@ -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.
_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());
// 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() =>
Plc ?? throw new InvalidOperationException("S7Driver not initialized");
+ ///
+ /// PR-S7-C5 — issue the post-OpenAsync pre-flight probe read against
+ /// and translate a "PUT/GET disabled"
+ /// wire response into a typed . Skips the
+ /// probe entirely when is set or when
+ /// is null/empty (sites that haven't
+ /// wired a fingerprint address). Other
+ /// surfaces (transport drop, IP unavailable, bad var format) rethrow unchanged so
+ /// the caller doesn't lose the original failure shape.
+ ///
+ 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);
+ }
+ }
+
///
/// Construct the underlying S7netplus honouring
/// , ,
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverFactoryExtensions.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverFactoryExtensions.cs
index 41dc890..497fd57 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverFactoryExtensions.cs
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverFactoryExtensions.cs
@@ -67,7 +67,15 @@ public static class S7DriverFactoryExtensions
Enabled = dto.Probe?.Enabled ?? true,
Interval = TimeSpan.FromMilliseconds(dto.Probe?.IntervalMs ?? 5_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(dto.TsapMode, driverInstanceId, "TsapMode",
fallback: TsapMode.Auto),
@@ -193,6 +201,22 @@ public static class S7DriverFactoryExtensions
public bool? Enabled { get; init; }
public int? IntervalMs { get; init; }
public int? TimeoutMs { get; init; }
+
+ ///
+ /// Address probed by the background liveness loop and (PR-S7-C5) by the
+ /// post-OpenAsync pre-flight check. Default MW0 when the
+ /// Probe object is omitted entirely; explicit null /
+ /// whitespace skips both the background probe and the pre-flight read.
+ ///
public string? ProbeAddress { get; init; }
+
+ ///
+ /// PR-S7-C5 — opt out of the pre-flight PUT/GET enablement read at
+ /// time. Default false =
+ /// pre-flight runs and a hardened CPU with PUT/GET disabled fails Init
+ /// with . See
+ /// docs/v2/s7.md "Pre-flight PUT/GET enablement" section.
+ ///
+ public bool? SkipPreflight { get; init; }
}
}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverOptions.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverOptions.cs
index b73e2be..63e88ee 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverOptions.cs
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverOptions.cs
@@ -214,9 +214,26 @@ public sealed class S7ProbeOptions
///
/// Address to probe for liveness. DB1.DBW0 is the convention if the PLC project
/// reserves a small fingerprint DB for health checks (per docs/v2/s7.md);
- /// if not, pick any valid Merker word like MW0.
+ /// if not, pick any valid Merker word like MW0. Set to null to
+ /// skip the pre-flight probe at time
+ /// for sites that haven't wired a fingerprint address.
///
- public string ProbeAddress { get; init; } = "MW0";
+ public string? ProbeAddress { get; init; } = "MW0";
+
+ ///
+ /// PR-S7-C5 — skip the pre-flight PUT/GET enablement probe that
+ /// issues immediately after
+ /// OpenAsync. Default false = pre-flight runs and a hardened
+ /// CPU with PUT/GET disabled fails Init with
+ /// . Set true to defer the
+ /// check to first per-tag read — the driver will still surface
+ /// BadDeviceFailure 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 docs/v2/s7.md
+ /// "Pre-flight PUT/GET enablement" section.
+ ///
+ public bool SkipPreflight { get; init; } = false;
}
///
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7PutGetDisabledException.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7PutGetDisabledException.cs
new file mode 100644
index 0000000..3d6c0f9
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7PutGetDisabledException.cs
@@ -0,0 +1,84 @@
+using S7NetErrorCode = global::S7.Net.ErrorCode;
+using S7NetPlcException = global::S7.Net.PlcException;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.S7;
+
+///
+/// Thrown by when the post-OpenAsync
+/// 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
+/// BadDeviceFailure. See docs/v2/s7.md "Pre-flight PUT/GET enablement"
+/// section for the full rationale.
+///
+public sealed class S7PutGetDisabledException : Exception
+{
+ /// The probe address that triggered the typed classification (e.g. MW0).
+ public string ProbeAddress { get; }
+
+ ///
+ /// Construct a typed exception. is the raw
+ /// from S7netplus that
+ /// classified positive.
+ ///
+ 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).";
+}
+
+///
+/// Classifies an 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
+/// ModbusPreflight pattern from the Modbus driver.
+///
+public static class S7PreflightClassifier
+{
+ ///
+ /// Returns true when the exception's ErrorCode 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
+ /// (S7.Net's primary classification
+ /// when the response framing isn't a valid PDU because the CPU rejected the
+ /// request) and (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).
+ ///
+ ///
+ ///
+ /// 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
+ /// on the response path or
+ /// 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.
+ ///
+ ///
+ /// A non-matching (e.g.
+ /// ,
+ /// ) means "transport
+ /// broke" — let the caller rethrow as-is so other failure shapes don't
+ /// get silently masked as "PUT/GET disabled".
+ ///
+ ///
+ public static bool IsPutGetDisabled(S7NetPlcException? exception)
+ {
+ if (exception is null) return false;
+ return exception.ErrorCode is S7NetErrorCode.WrongCPU_Type
+ or S7NetErrorCode.ReadData;
+ }
+}
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/S7_1500/S7_1500PreflightTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/S7_1500/S7_1500PreflightTests.cs
new file mode 100644
index 0000000..d3e7412
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/S7_1500/S7_1500PreflightTests.cs
@@ -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;
+
+///
+/// PR-S7-C5 — integration coverage for the post-OpenAsync 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 . The "PUT/GET
+/// disabled" failure path is unit-tested via
+/// S7PreflightClassifier and documented as a follow-up live-firmware
+/// test in docs/drivers/S7-Test-Fixture.md.
+///
+[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);
+ }
+}
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7PreflightTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7PreflightTests.cs
new file mode 100644
index 0000000..8f2034a
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7PreflightTests.cs
@@ -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;
+
+///
+/// PR-S7-C5 — unit coverage for the post-OpenAsync 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.
+///
+[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();
+
+ // 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();
+ 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();
+ var optsField = typeof(S7Driver).GetField("_options", BindingFlags.Instance | BindingFlags.NonPublic)
+ .ShouldNotBeNull();
+ var opts = optsField.GetValue(driver).ShouldBeOfType();
+ 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();
+ var optsField = typeof(S7Driver).GetField("_options", BindingFlags.Instance | BindingFlags.NonPublic)
+ .ShouldNotBeNull();
+ var opts = optsField.GetValue(driver).ShouldBeOfType();
+ 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();
+ var optsField = typeof(S7Driver).GetField("_options", BindingFlags.Instance | BindingFlags.NonPublic)
+ .ShouldNotBeNull();
+ var opts = optsField.GetValue(driver).ShouldBeOfType();
+ 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(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(async () =>
+ await drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
+ drv.GetHealth().State.ShouldBe(DriverState.Faulted);
+ }
+}