@@ -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"/>,
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user