@@ -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 |
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"/>,
|
||||||
|
|||||||
@@ -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; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
84
src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7PutGetDisabledException.cs
Normal file
84
src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7PutGetDisabledException.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
220
tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7PreflightTests.cs
Normal file
220
tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7PreflightTests.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user