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). // 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(); } } /// /// 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. /// /// The minimal read. /// Cancellation token. /// A failure result, or null on success. internal static async Task CheckDataAccessAsync(Func> 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); }