diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverProbe.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverProbe.cs index c2909f1b..9868b78d 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverProbe.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverProbe.cs @@ -2,16 +2,21 @@ 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; /// -/// Cheap TCP-connect probe for the -shaped driver config. -/// Opens a socket to the configured host + ISO-on-TCP port 102 and closes immediately. -/// Surfaces a green tick + latency on success; red chip + SocketError on failure; "timed -/// out" on the caller's cancellation. Does NOT exchange any S7comm bytes — a richer -/// ISO-on-TCP connection probe is a documented follow-up. +/// 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 { @@ -36,13 +41,12 @@ public sealed class S7DriverProbe : IDriverProbe 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); - sw.Stop(); - return new(true, null, sw.Elapsed); } catch (SocketException ex) { @@ -56,6 +60,43 @@ public sealed class S7DriverProbe : IDriverProbe { 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) diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DriverProbeTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DriverProbeTests.cs new file mode 100644 index 00000000..8ac4b18b --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DriverProbeTests.cs @@ -0,0 +1,142 @@ +using System.Net; +using System.Net.Sockets; +using Shouldly; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests; + +/// +/// Unit tests for . Uses in-process +/// instances on 127.0.0.1 to exercise all failure paths without a real PLC. +/// The happy path (real S7 setup-communication exchange) is covered in live integration +/// tests against a python-snap7 simulator. +/// +[Trait("Category", "Unit")] +public sealed class S7DriverProbeTests +{ + private static readonly S7DriverProbe Probe = new(); + + // ------------------------------------------------------------------------- + // 1. Invalid JSON + // ------------------------------------------------------------------------- + + /// Invalid JSON returns Ok=false with a message containing "invalid". + [Fact] + public async Task InvalidJson_Returns_OkFalse_WithInvalidMessage() + { + var result = await Probe.ProbeAsync( + "not-valid-json{{{", + TimeSpan.FromSeconds(3), + TestContext.Current.CancellationToken); + + result.Ok.ShouldBeFalse(); + result.Message.ShouldNotBeNull(); + result.Message!.ToLowerInvariant().ShouldContain("invalid"); + result.Latency.ShouldBeNull(); + } + + // ------------------------------------------------------------------------- + // 2. Config with no host + // ------------------------------------------------------------------------- + + /// Config that serialises to null host returns Ok=false with a "no host/port" message. + [Fact] + public async Task NoHost_Returns_OkFalse_WithNoHostPortMessage() + { + // Host defaults to "127.0.0.1" in S7DriverOptions, but an empty string + // is what the spec's "no host" path covers. Provide an empty host explicitly. + var result = await Probe.ProbeAsync( + "{\"host\":\"\",\"port\":102}", + TimeSpan.FromSeconds(3), + TestContext.Current.CancellationToken); + + result.Ok.ShouldBeFalse(); + result.Message.ShouldNotBeNull(); + result.Message!.ShouldContain("no host/port"); + result.Latency.ShouldBeNull(); + } + + // ------------------------------------------------------------------------- + // 3. Unreachable / refused port + // ------------------------------------------------------------------------- + + /// A closed port returns Ok=false with a "Connect failed" message. + [Fact] + public async Task ClosedPort_Returns_OkFalse_WithConnectFailedMessage() + { + // Bind a listener, read the OS-assigned port, then stop it so the port + // is guaranteed closed (no process listening) when the probe runs. + var reserved = new TcpListener(IPAddress.Loopback, 0); + reserved.Start(); + var port = ((IPEndPoint)reserved.LocalEndpoint).Port; + reserved.Stop(); + + var configJson = $"{{\"host\":\"127.0.0.1\",\"port\":{port}}}"; + var result = await Probe.ProbeAsync( + configJson, + TimeSpan.FromSeconds(3), + TestContext.Current.CancellationToken); + + result.Ok.ShouldBeFalse(); + result.Message.ShouldNotBeNull(); + // SocketException maps to "Connect failed: " or similar. + result.Message!.ShouldContain("Connect failed"); + result.Latency.ShouldBeNull(); + } + + // ------------------------------------------------------------------------- + // 4. TCP accepts then immediately closes (non-S7 server) + // ------------------------------------------------------------------------- + + /// + /// A listener that accepts the TCP connection and then immediately closes it without + /// exchanging any bytes simulates a non-S7 server (wrong rack/slot, plain TCP echo, + /// etc.). The probe must return Ok=false with a message containing "handshake failed". + /// A short probe timeout (1 s) ensures the internal S7netplus socket-read timeout + /// fires well within the test wall-clock budget of 10 s. + /// + [Fact] + public async Task TcpAcceptsThenCloses_Returns_OkFalse_WithHandshakeFailedMessage() + { + // Use a generous outer CTS so the test itself never races with the probe timeout. + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + + // Accept one connection from the background and immediately close it — + // no S7 bytes exchanged, so OpenAsync must fail. + var serverTask = Task.Run(async () => + { + try + { + using var client = await listener.AcceptTcpClientAsync(cts.Token); + // Immediately dispose → TCP RST/FIN; the S7 handshake receives nothing. + } + catch (OperationCanceledException) { /* test timed out */ } + }, cts.Token); + + try + { + var configJson = $"{{\"host\":\"127.0.0.1\",\"port\":{port}}}"; + // Pass a 1 s probe timeout so S7netplus's internal read-timeout fires quickly, + // and the 10 s outer CTS does NOT cancel first — the OCE will be from the + // internal handshakeCts, routing to the "handshake failed" branch. + var result = await Probe.ProbeAsync( + configJson, + TimeSpan.FromSeconds(1), + cts.Token); + + result.Ok.ShouldBeFalse(); + result.Message.ShouldNotBeNull(); + result.Message!.ShouldContain("handshake failed"); + result.Latency.ShouldBeNull(); + } + finally + { + listener.Stop(); + try { await serverTask; } catch { /* ignore */ } + } + } +}