diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverProbe.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverProbe.cs
index 665b8439..f115dd25 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverProbe.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverProbe.cs
@@ -7,11 +7,12 @@ using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
///
-/// Cheap TCP-connect probe for the -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 -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."
///
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];
+
///
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);
+ }
}
}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusDriverProbeTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusDriverProbeTests.cs
new file mode 100644
index 00000000..6cd2f1fc
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusDriverProbeTests.cs
@@ -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;
+
+///
+/// Tests for . Each test spins up a real
+/// 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.
+///
+[Trait("Category", "Unit")]
+public sealed class ModbusDriverProbeTests
+{
+ private readonly ModbusDriverProbe _probe = new();
+ private static readonly TimeSpan QuickTimeout = TimeSpan.FromSeconds(3);
+
+ // ── helpers ──────────────────────────────────────────────────────────────
+
+ /// Allocates a TcpListener on an OS-assigned port and returns it started.
+ private static TcpListener StartListener()
+ {
+ var l = new TcpListener(IPAddress.Loopback, 0);
+ l.Start();
+ return l;
+ }
+
+ /// Returns the port assigned to .
+ private static int ListenerPort(TcpListener l)
+ => ((IPEndPoint)l.LocalEndpoint).Port;
+
+ ///
+ /// Builds a config JSON for 127.0.0.1 at with optional unitId.
+ ///
+ private static string Config(int port, byte unitId = 1)
+ => $@"{{""host"":""127.0.0.1"",""port"":{port},""unitId"":{unitId}}}";
+
+ ///
+ /// Writes a well-formed MBAP FC03 response to , 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.
+ ///
+ 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);
+ }
+
+ ///
+ /// Writes a Modbus exception PDU response (function 0x83 + exception code 0x02)
+ /// echoing the TxId from the request.
+ ///
+ 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 ──────────────────────────────────────────────────
+
+ /// Invalid config JSON returns Ok=false with a message containing "invalid".
+ [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 ─────────────────────────────────────────────────
+
+ /// Config with empty host returns Ok=false with a "no host/port" message.
+ [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) ────────────────────────────────────
+
+ /// A probe against a closed port returns Ok=false (Connect failed / refused).
+ [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) ─────────
+
+ ///
+ /// 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.
+ ///
+ [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 ───────────────────────────────────────
+
+ ///
+ /// 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".
+ ///
+ [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 ─────────────────────────────────────────
+
+ ///
+ /// 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.
+ ///
+ [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);
+ }
+}