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;
///
/// Test-Connect probe for the -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
/// 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 Ok = false 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.
///
public sealed class S7DriverProbe : IDriverProbe
{
private static readonly JsonSerializerOptions _opts = new()
{
PropertyNameCaseInsensitive = true,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
Converters = { new JsonStringEnumConverter() },
};
///
public string DriverType => "S7";
///
public async Task ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct)
{
S7DriverOptions? opts;
try { opts = JsonSerializer.Deserialize(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).
var plc = 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)
return new(true, $"S7 connected (CPU {opts.CpuType})", 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();
}
}
private static (string host, int port) ExtractTarget(S7DriverOptions opts)
=> (opts.Host, opts.Port);
}