feat(probe): S7 Test-Connect does a real ISO-on-TCP + S7 setup handshake
Replace bare TCP-connect with a two-phase probe: Phase 1 keeps the
existing SocketException / timeout / generic preflight paths unchanged;
Phase 2 runs Plc.OpenAsync (COTP CR/CC + S7 setup-communication) so a
device that accepts TCP but is not an S7 PLC reads red instead of green.
A linked CTS distinguishes caller cancellation ("timed out") from the
S7netplus internal read-timeout OCE ("handshake failed: timed out").
This commit is contained in:
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Cheap TCP-connect probe for the <see cref="S7DriverOptions"/>-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 <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
|
||||
{
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="S7DriverProbe"/>. Uses in-process <see cref="TcpListener"/>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class S7DriverProbeTests
|
||||
{
|
||||
private static readonly S7DriverProbe Probe = new();
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 1. Invalid JSON
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>Invalid JSON returns Ok=false with a message containing "invalid".</summary>
|
||||
[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
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>Config that serialises to null host returns Ok=false with a "no host/port" message.</summary>
|
||||
[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
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>A closed port returns Ok=false with a "Connect failed" message.</summary>
|
||||
[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: <SocketErrorCode>" or similar.
|
||||
result.Message!.ShouldContain("Connect failed");
|
||||
result.Latency.ShouldBeNull();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 4. TCP accepts then immediately closes (non-S7 server)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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 */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user