feat(probe): Modbus Test-Connect does a real FC03 handshake
Replace the bare TCP-connect probe in ModbusDriverProbe with a two-phase check: TCP connect via ModbusTcpTransport (keeps the same SocketException / timeout / generic error paths and messages), then a one-shot FC03 Read Holding Registers (qty 1 @ addr 0). A normal response → Ok=true "Modbus FC03 OK"; a Modbus exception PDU → Ok=true "Modbus FC03 OK (device returned exception PDU)"; any other failure after TCP succeeds → Ok=false "Reachable at host:port but Modbus FC03 handshake failed: …". Add ModbusDriverProbeTests (6 tests) covering invalid JSON, missing host/port, closed port, TCP-accept-then-close, canned MBAP happy path, and Modbus exception PDU path. All 277 Modbus tests green.
This commit is contained in:
@@ -7,11 +7,12 @@ using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||
|
||||
/// <summary>
|
||||
/// Cheap TCP-connect probe for the <see cref="ModbusDriverOptions"/>-shaped driver config.
|
||||
/// Opens a socket to the configured endpoint 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 protocol bytes — richer per-driver
|
||||
/// handshakes are a documented follow-up.
|
||||
/// Modbus FC03 probe for the <see cref="ModbusDriverOptions"/>-shaped driver config.
|
||||
/// Opens a TCP connection to the configured endpoint and sends a one-shot FC03
|
||||
/// (Read Holding Registers, qty 1 @ address 0) handshake. A normal FC03 response
|
||||
/// or a Modbus exception PDU both confirm a live Modbus device (green + latency);
|
||||
/// TCP failure surfaces the SocketError; a Modbus-level handshake failure after
|
||||
/// TCP succeeds surfaces a targeted message; timeout surfaces "timed out after Ns."
|
||||
/// </summary>
|
||||
public sealed class ModbusDriverProbe : IDriverProbe
|
||||
{
|
||||
@@ -21,6 +22,9 @@ public sealed class ModbusDriverProbe : IDriverProbe
|
||||
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
|
||||
};
|
||||
|
||||
// FC03 Read Holding Registers: function=0x03, addr-hi=0, addr-lo=0, qty-hi=0, qty-lo=1
|
||||
private static readonly byte[] Fc03Pdu = [0x03, 0x00, 0x00, 0x00, 0x01];
|
||||
|
||||
/// <inheritdoc />
|
||||
public string DriverType => "ModbusTcp";
|
||||
|
||||
@@ -36,25 +40,56 @@ public sealed class ModbusDriverProbe : IDriverProbe
|
||||
if (string.IsNullOrWhiteSpace(host) || port <= 0)
|
||||
return new(false, "Config has no host/port to probe.", null);
|
||||
|
||||
var unitId = opts.UnitId;
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
try
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
cts.CancelAfter(timeout);
|
||||
|
||||
// Phase 1 — TCP connect (using ModbusTcpTransport which handles IPv4 preference).
|
||||
// autoReconnect=false: this is a one-shot probe, no retry loops.
|
||||
var transport = new ModbusTcpTransport(host, port, timeout, autoReconnect: false);
|
||||
await using (transport.ConfigureAwait(false))
|
||||
{
|
||||
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)
|
||||
{
|
||||
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);
|
||||
try
|
||||
{
|
||||
await transport.ConnectAsync(cts.Token).ConfigureAwait(false);
|
||||
}
|
||||
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 — FC03 handshake. TCP is up; now prove a Modbus device is answering.
|
||||
try
|
||||
{
|
||||
await transport.SendAsync(unitId, Fc03Pdu, cts.Token).ConfigureAwait(false);
|
||||
sw.Stop();
|
||||
return new(true, "Modbus FC03 OK", sw.Elapsed);
|
||||
}
|
||||
catch (ModbusException)
|
||||
{
|
||||
// Device replied with an exception PDU — it IS a real Modbus device.
|
||||
sw.Stop();
|
||||
return new(true, "Modbus FC03 OK (device returned exception PDU)", sw.Elapsed);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return new(false, $"Probe timed out after {timeout.TotalSeconds:F0}s.", null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
sw.Stop();
|
||||
return new(false, $"Reachable at {host}:{port} but Modbus FC03 handshake failed: {ex.Message}", null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="ModbusDriverProbe"/>. Each test spins up a real
|
||||
/// <see cref="TcpListener"/> on 127.0.0.1 port 0 (OS-assigned) so the port is
|
||||
/// guaranteed available. Server-side behaviour is driven by a background task so
|
||||
/// the accept is always awaited before the probe fires.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ModbusDriverProbeTests
|
||||
{
|
||||
private readonly ModbusDriverProbe _probe = new();
|
||||
private static readonly TimeSpan QuickTimeout = TimeSpan.FromSeconds(3);
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Allocates a TcpListener on an OS-assigned port and returns it started.</summary>
|
||||
private static TcpListener StartListener()
|
||||
{
|
||||
var l = new TcpListener(IPAddress.Loopback, 0);
|
||||
l.Start();
|
||||
return l;
|
||||
}
|
||||
|
||||
/// <summary>Returns the port assigned to <paramref name="l"/>.</summary>
|
||||
private static int ListenerPort(TcpListener l)
|
||||
=> ((IPEndPoint)l.LocalEndpoint).Port;
|
||||
|
||||
/// <summary>
|
||||
/// Builds a config JSON for 127.0.0.1 at <paramref name="port"/> with optional unitId.
|
||||
/// </summary>
|
||||
private static string Config(int port, byte unitId = 1)
|
||||
=> $@"{{""host"":""127.0.0.1"",""port"":{port},""unitId"":{unitId}}}";
|
||||
|
||||
/// <summary>
|
||||
/// Writes a well-formed MBAP FC03 response to <paramref name="stream"/>, echoing the
|
||||
/// transaction-id from the request's first two bytes. Reads and discards the full
|
||||
/// request first so the client's send doesn't block.
|
||||
/// </summary>
|
||||
private static async Task WriteHappyFc03ResponseAsync(NetworkStream stream, CancellationToken ct)
|
||||
{
|
||||
// Read the MBAP header (7 bytes) + PDU (5 bytes for FC03 qty=1) = 12 bytes total.
|
||||
var req = new byte[12];
|
||||
var read = 0;
|
||||
while (read < req.Length)
|
||||
{
|
||||
var n = await stream.ReadAsync(req.AsMemory(read), ct);
|
||||
if (n == 0) return; // client closed early
|
||||
read += n;
|
||||
}
|
||||
|
||||
// Echo TxId from request bytes [0..1].
|
||||
var txHi = req[0];
|
||||
var txLo = req[1];
|
||||
var unitId = req[6];
|
||||
|
||||
// Response PDU: FC=0x03, ByteCount=2, Data=0x00 0x00
|
||||
var pdu = new byte[] { 0x03, 0x02, 0x00, 0x00 };
|
||||
|
||||
// MBAP: TxId(2) + Proto=0(2) + Len(2) + UnitId(1) + PDU
|
||||
// Length field = unitId(1) + pdu.Length
|
||||
var respLen = (ushort)(1 + pdu.Length);
|
||||
var resp = new byte[7 + pdu.Length];
|
||||
resp[0] = txHi;
|
||||
resp[1] = txLo;
|
||||
// resp[2..3] = 0 (protocol id)
|
||||
resp[4] = (byte)(respLen >> 8);
|
||||
resp[5] = (byte)(respLen & 0xFF);
|
||||
resp[6] = unitId;
|
||||
Buffer.BlockCopy(pdu, 0, resp, 7, pdu.Length);
|
||||
|
||||
await stream.WriteAsync(resp.AsMemory(), ct);
|
||||
await stream.FlushAsync(ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a Modbus exception PDU response (function 0x83 + exception code 0x02)
|
||||
/// echoing the TxId from the request.
|
||||
/// </summary>
|
||||
private static async Task WriteExceptionPduResponseAsync(NetworkStream stream, CancellationToken ct)
|
||||
{
|
||||
// Read request
|
||||
var req = new byte[12];
|
||||
var read = 0;
|
||||
while (read < req.Length)
|
||||
{
|
||||
var n = await stream.ReadAsync(req.AsMemory(read), ct);
|
||||
if (n == 0) return;
|
||||
read += n;
|
||||
}
|
||||
|
||||
var txHi = req[0];
|
||||
var txLo = req[1];
|
||||
var unitId = req[6];
|
||||
|
||||
// Exception PDU: error function = 0x83 (0x03 | 0x80), exception code = 0x02
|
||||
var pdu = new byte[] { 0x83, 0x02 };
|
||||
var respLen = (ushort)(1 + pdu.Length);
|
||||
var resp = new byte[7 + pdu.Length];
|
||||
resp[0] = txHi;
|
||||
resp[1] = txLo;
|
||||
resp[4] = (byte)(respLen >> 8);
|
||||
resp[5] = (byte)(respLen & 0xFF);
|
||||
resp[6] = unitId;
|
||||
Buffer.BlockCopy(pdu, 0, resp, 7, pdu.Length);
|
||||
|
||||
await stream.WriteAsync(resp.AsMemory(), ct);
|
||||
await stream.FlushAsync(ct);
|
||||
}
|
||||
|
||||
// ── Test 1: invalid JSON ──────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Invalid config JSON returns Ok=false with a message containing "invalid".</summary>
|
||||
[Fact]
|
||||
public async Task InvalidJson_returns_false_with_invalid_message()
|
||||
{
|
||||
var result = await _probe.ProbeAsync("not valid json {{", QuickTimeout, CancellationToken.None);
|
||||
|
||||
result.Ok.ShouldBeFalse();
|
||||
result.Message.ShouldNotBeNull();
|
||||
result.Message.ShouldContain("invalid", Case.Insensitive);
|
||||
}
|
||||
|
||||
// ── Test 2: no host/port ─────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Config with empty host returns Ok=false with a "no host/port" message.</summary>
|
||||
[Fact]
|
||||
public async Task Config_with_no_host_returns_false_with_no_host_port_message()
|
||||
{
|
||||
// host defaults to "127.0.0.1" in ModbusDriverOptions, but port=0 is treated as invalid.
|
||||
var result = await _probe.ProbeAsync(@"{""host"":"""",""port"":0}", QuickTimeout, CancellationToken.None);
|
||||
|
||||
result.Ok.ShouldBeFalse();
|
||||
result.Message.ShouldNotBeNull();
|
||||
result.Message.ShouldContain("no host/port", Case.Insensitive);
|
||||
}
|
||||
|
||||
// ── Test 3: unreachable (closed port) ────────────────────────────────────
|
||||
|
||||
/// <summary>A probe against a closed port returns Ok=false (Connect failed / refused).</summary>
|
||||
[Fact]
|
||||
public async Task Unreachable_closed_port_returns_false()
|
||||
{
|
||||
// Grab an ephemeral port then immediately stop the listener so nothing is listening.
|
||||
var listener = StartListener();
|
||||
var port = ListenerPort(listener);
|
||||
listener.Stop();
|
||||
|
||||
var result = await _probe.ProbeAsync(Config(port), QuickTimeout, CancellationToken.None);
|
||||
|
||||
result.Ok.ShouldBeFalse();
|
||||
result.Latency.ShouldBeNull();
|
||||
}
|
||||
|
||||
// ── Test 4: TCP accepts then immediately closes (no Modbus reply) ─────────
|
||||
|
||||
/// <summary>
|
||||
/// When the server accepts the TCP connection and then closes it without sending any
|
||||
/// Modbus bytes, ProbeAsync returns Ok=false with a "handshake failed" message.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Tcp_connect_then_immediate_close_returns_handshake_failed()
|
||||
{
|
||||
using var listener = StartListener();
|
||||
var port = ListenerPort(listener);
|
||||
using var cts = new CancellationTokenSource(QuickTimeout);
|
||||
|
||||
// Background: accept the TCP client and immediately close it without replying.
|
||||
var serverTask = Task.Run(async () =>
|
||||
{
|
||||
var client = await listener.AcceptTcpClientAsync(cts.Token);
|
||||
client.Close(); // close without sending any bytes
|
||||
}, cts.Token);
|
||||
|
||||
var result = await _probe.ProbeAsync(Config(port), QuickTimeout, CancellationToken.None);
|
||||
|
||||
// The server task should have completed (or close enough).
|
||||
await serverTask.WaitAsync(TimeSpan.FromSeconds(2)).ConfigureAwait(false);
|
||||
|
||||
result.Ok.ShouldBeFalse();
|
||||
result.Message.ShouldNotBeNull();
|
||||
result.Message.ShouldContain("handshake failed", Case.Insensitive);
|
||||
result.Latency.ShouldBeNull();
|
||||
}
|
||||
|
||||
// ── Test 5: canned MBAP happy path ───────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// A listener that accepts, reads the FC03 request, and replies with a valid MBAP
|
||||
/// FC03 response causes ProbeAsync to return Ok=true with message "Modbus FC03 OK".
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Happy_path_returns_ok_true_with_FC03_OK_message()
|
||||
{
|
||||
using var listener = StartListener();
|
||||
var port = ListenerPort(listener);
|
||||
using var cts = new CancellationTokenSource(QuickTimeout);
|
||||
|
||||
var serverTask = Task.Run(async () =>
|
||||
{
|
||||
var client = await listener.AcceptTcpClientAsync(cts.Token);
|
||||
await using var stream = client.GetStream();
|
||||
await WriteHappyFc03ResponseAsync(stream, cts.Token);
|
||||
}, cts.Token);
|
||||
|
||||
var result = await _probe.ProbeAsync(Config(port), QuickTimeout, CancellationToken.None);
|
||||
|
||||
await serverTask.WaitAsync(TimeSpan.FromSeconds(3)).ConfigureAwait(false);
|
||||
|
||||
result.Ok.ShouldBeTrue();
|
||||
result.Message.ShouldBe("Modbus FC03 OK");
|
||||
result.Latency.ShouldNotBeNull();
|
||||
result.Latency!.Value.ShouldBeGreaterThanOrEqualTo(TimeSpan.Zero);
|
||||
}
|
||||
|
||||
// ── Test 6: Modbus exception PDU ─────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// When the server replies with a Modbus exception PDU (function 0x83 + exception code),
|
||||
/// the probe still returns Ok=true because a real Modbus device answered.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Exception_pdu_response_returns_ok_true_with_exception_pdu_message()
|
||||
{
|
||||
using var listener = StartListener();
|
||||
var port = ListenerPort(listener);
|
||||
using var cts = new CancellationTokenSource(QuickTimeout);
|
||||
|
||||
var serverTask = Task.Run(async () =>
|
||||
{
|
||||
var client = await listener.AcceptTcpClientAsync(cts.Token);
|
||||
await using var stream = client.GetStream();
|
||||
await WriteExceptionPduResponseAsync(stream, cts.Token);
|
||||
}, cts.Token);
|
||||
|
||||
var result = await _probe.ProbeAsync(Config(port), QuickTimeout, CancellationToken.None);
|
||||
|
||||
await serverTask.WaitAsync(TimeSpan.FromSeconds(3)).ConfigureAwait(false);
|
||||
|
||||
result.Ok.ShouldBeTrue();
|
||||
result.Message.ShouldNotBeNull();
|
||||
result.Message.ShouldContain("exception PDU", Case.Insensitive);
|
||||
result.Latency.ShouldNotBeNull();
|
||||
result.Latency!.Value.ShouldBeGreaterThanOrEqualTo(TimeSpan.Zero);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user