From 64a11ef285e7b23556be661818d63d16aee3548c Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 26 Apr 2026 01:31:48 -0400 Subject: [PATCH] =?UTF-8?q?Auto:=20s7-c5=20=E2=80=94=20pre-flight=20PUT/GE?= =?UTF-8?q?T=20enablement=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #298 --- docs/Driver.S7.Cli.md | 22 ++ docs/drivers/S7-Test-Fixture.md | 12 + docs/v2/s7.md | 92 ++++++++ src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs | 67 ++++++ .../S7DriverFactoryExtensions.cs | 26 ++- .../S7DriverOptions.cs | 21 +- .../S7PutGetDisabledException.cs | 84 +++++++ .../S7_1500/S7_1500PreflightTests.cs | 84 +++++++ .../S7PreflightTests.cs | 220 ++++++++++++++++++ 9 files changed, 625 insertions(+), 3 deletions(-) create mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7PutGetDisabledException.cs create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/S7_1500/S7_1500PreflightTests.cs create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7PreflightTests.cs 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); + } +} -- 2.49.1