Auto: s7-c5 — pre-flight PUT/GET enablement test

Closes #298
This commit is contained in:
Joseph Doherty
2026-04-26 01:31:48 -04:00
parent 4bc8aa2478
commit 64a11ef285
9 changed files with 625 additions and 3 deletions

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