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:
Joseph Doherty
2026-06-16 06:36:48 -04:00
parent 2f7c6bf963
commit 9b909002be
2 changed files with 309 additions and 22 deletions
@@ -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);
}
}