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 */ }
+ }
+ }
+}