From 9b909002be9b6e376271d7abfcf19a9d00a8372d Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 16 Jun 2026 06:36:48 -0400 Subject: [PATCH] feat(probe): Modbus Test-Connect does a real FC03 handshake MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../ModbusDriverProbe.cs | 79 ++++-- .../ModbusDriverProbeTests.cs | 252 ++++++++++++++++++ 2 files changed, 309 insertions(+), 22 deletions(-) create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusDriverProbeTests.cs 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); + } +}