[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"*.
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 |

View File

@@ -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.

View File

@@ -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

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.
_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");
/// <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>
/// Construct the underlying S7netplus <see cref="Plc"/> honouring
/// <see cref="S7DriverOptions.TsapMode"/>, <see cref="S7DriverOptions.LocalTsap"/>,

View File

@@ -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<TsapMode>(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; }
/// <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; }
/// <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>
/// 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>);
/// 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>
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>

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);
}
}