ff055ebd79
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
150 lines
6.6 KiB
C#
150 lines
6.6 KiB
C#
using System.Diagnostics;
|
|
using System.Net.Sockets;
|
|
using System.Text.Json;
|
|
using System.Text.Json.Serialization;
|
|
using S7.Net;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.S7;
|
|
|
|
/// <summary>
|
|
/// Test-Connect probe for the <see cref="S7DriverOptions"/>-shaped driver config.
|
|
/// Performs a two-phase check: (1) a bare TCP connect to verify the host is reachable,
|
|
/// then (2) a full ISO-on-TCP COTP CR/CC + S7 setup-communication handshake via
|
|
/// <see cref="Plc.OpenAsync"/> to confirm the remote endpoint actually speaks S7comm.
|
|
/// A device that accepts the TCP connection but is not an S7 PLC (wrong rack/slot,
|
|
/// non-S7 server) returns <c>Ok = false</c> with a "handshake failed" message instead
|
|
/// of a false-positive green tick.
|
|
/// Surfaces a green tick + latency on full success; red chip + detail on any failure;
|
|
/// "timed out" on the caller's cancellation.
|
|
/// </summary>
|
|
public sealed class S7DriverProbe : IDriverProbe
|
|
{
|
|
private static readonly JsonSerializerOptions _opts = new()
|
|
{
|
|
PropertyNameCaseInsensitive = true,
|
|
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
|
|
Converters = { new JsonStringEnumConverter() },
|
|
};
|
|
|
|
/// <inheritdoc />
|
|
public string DriverType => "S7";
|
|
|
|
/// <inheritdoc />
|
|
public async Task<DriverProbeResult> ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct)
|
|
{
|
|
S7DriverOptions? opts;
|
|
try { opts = JsonSerializer.Deserialize<S7DriverOptions>(configJson, _opts); }
|
|
catch (Exception ex) { return new(false, $"Config JSON is invalid: {ex.Message}", null); }
|
|
if (opts is null) return new(false, "Config JSON deserialized to null.", null);
|
|
|
|
var (host, port) = ExtractTarget(opts);
|
|
if (string.IsNullOrWhiteSpace(host) || port <= 0)
|
|
return new(false, "Config has no host/port to probe.", null);
|
|
|
|
// Phase 1: bare TCP preflight — fast rejection for unreachable hosts.
|
|
var sw = Stopwatch.StartNew();
|
|
try
|
|
{
|
|
using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
|
await socket.ConnectAsync(host, port, ct);
|
|
}
|
|
catch (SocketException ex)
|
|
{
|
|
return new(false, $"Connect failed: {ex.SocketErrorCode}", null);
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
return new(false, $"Probe timed out after {timeout.TotalSeconds:F0}s.", null);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
return new(false, ex.Message, null);
|
|
}
|
|
|
|
// Phase 2: S7 ISO-on-TCP handshake (COTP CR/CC + S7 setup-communication).
|
|
// The Plc is opened and immediately closed — no reads or writes are performed.
|
|
// Use a linked CTS so we can distinguish a real caller cancellation from
|
|
// S7netplus's internal socket-read timeout (which also surfaces as OCE).
|
|
// An authored TSAP / connection-type override applies to Test Connect too — otherwise the probe
|
|
// would succeed (or fail) on a different handshake than the driver will use.
|
|
var plc = S7Tsap.Resolve(opts) is { } tsap
|
|
? new Plc(host, port, tsap)
|
|
: new Plc(S7CpuTypeMap.ToS7Net(opts.CpuType), host, port, opts.Rack, opts.Slot);
|
|
plc.ReadTimeout = (int)timeout.TotalMilliseconds;
|
|
using var handshakeCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
|
handshakeCts.CancelAfter(timeout);
|
|
try
|
|
{
|
|
await plc.OpenAsync(handshakeCts.Token);
|
|
sw.Stop();
|
|
|
|
if (plc.IsConnected)
|
|
{
|
|
// A handshake alone passes on a CPU with PUT/GET disabled (S7-1200/1500 default) — every
|
|
// later read would fail. One minimal read proves data access.
|
|
if (await CheckDataAccessAsync(
|
|
t => plc.ReadBytesAsync(global::S7.Net.DataType.Memory, 0, 0, 2, t), handshakeCts.Token)
|
|
is { } denied)
|
|
return denied;
|
|
return new(true, $"S7 connected (CPU {opts.CpuType}, PDU {plc.MaxPDUSize} bytes)", sw.Elapsed);
|
|
}
|
|
|
|
return new(false, $"Reachable at {host}:{port} but S7 handshake failed: not connected", null);
|
|
}
|
|
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
|
{
|
|
// Caller cancelled (e.g. user navigated away or server-side wall-clock guard fired).
|
|
return new(false, $"Probe timed out after {timeout.TotalSeconds:F0}s.", null);
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
// Our own handshakeCts fired the timeout — the host is reachable but S7 is not responding.
|
|
return new(false, $"Reachable at {host}:{port} but S7 handshake failed: timed out", null);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
return new(false, $"Reachable at {host}:{port} but S7 handshake failed: {ex.Message}", null);
|
|
}
|
|
finally
|
|
{
|
|
plc.Close();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Runs one minimal read after the handshake. Returns null when data access works — including when the
|
|
/// CPU merely rejects that particular address (MB0 not present / out of range: the connection is fine).
|
|
/// PUT/GET-disabled yields the actionable message; any other fault is a probe failure with its message.
|
|
/// </summary>
|
|
/// <param name="read">The minimal read.</param>
|
|
/// <param name="ct">Cancellation token.</param>
|
|
/// <returns>A failure result, or null on success.</returns>
|
|
internal static async Task<DriverProbeResult?> CheckDataAccessAsync(Func<CancellationToken, Task<byte[]>> read, CancellationToken ct)
|
|
{
|
|
try
|
|
{
|
|
await read(ct).ConfigureAwait(false);
|
|
return null;
|
|
}
|
|
catch (OperationCanceledException) { throw; }
|
|
catch (PlcException pex) when (S7Driver.IsAccessDenied(pex))
|
|
{
|
|
return new(false,
|
|
"S7 connected but data access is denied — enable PUT/GET communication in TIA Portal " +
|
|
$"(Protection & Security) for this CPU. PLC reported: {pex.Message}", null);
|
|
}
|
|
catch (PlcException pex) when (pex.ErrorCode == ErrorCode.ReadData)
|
|
{
|
|
return null; // address not available on this CPU — not a connection problem
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
return new(false, $"S7 connected but a test read failed: {ex.Message}", null);
|
|
}
|
|
}
|
|
|
|
private static (string host, int port) ExtractTarget(S7DriverOptions opts)
|
|
=> (opts.Host, opts.Port);
|
|
}
|