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);
}
}
}