using System.Net.Sockets;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
///
/// Concrete Modbus RTU-over-TCP transport. Composes for the
/// connect / reconnect / keep-alive / idle machinery and for the
/// wire layout — the same lifecycle uses, but with CRC framing
/// instead of MBAP.
///
///
///
/// Delta from : an RTU ADU is [unitId][PDU][CRC] with
/// no MBAP header and no transaction id. Because there is no TxId to correlate
/// interleaved responses, at most one transaction may be on the bus at a time — the
/// single-flight is therefore mandatory, not merely a
/// diagnostics convenience.
///
///
/// Survives mid-transaction socket drops the same way the TCP transport does: a socket-level
/// failure ( / /
/// , and the
/// that derives from ) triggers a single reconnect-then-retry; a
/// second failure bubbles up so the driver's health surface reflects the real state.
///
///
/// Every transaction runs under a per-op deadline (a linked
/// , design §7 / R2-01) so a
/// frozen gateway can never wedge a poll: the deadline firing is normalised to a
/// and tears the socket down so the next attempt
/// reconnects. A caller cancellation is distinguished from that timeout and propagates
/// as a plain with no teardown.
///
///
/// The constructor is connection-free; all socket I/O happens in /
/// . The seam injects a pre-connected
/// (an in-memory duplex fake) so the send/receive orchestration,
/// single-flight, and deadline can be exercised with no socket — in that path
/// is and the idle / reconnect machinery is
/// skipped (a raw injected stream cannot reconnect).
///
///
public sealed class ModbusRtuOverTcpTransport : IModbusTransport
{
private readonly ModbusSocketLifecycle? _life;
private readonly Stream? _testStream;
private readonly TimeSpan _timeout;
private readonly SemaphoreSlim _gate = new(1, 1);
private bool _disposed;
/// Initializes a new instance of the class.
/// The host address or hostname of the Modbus gateway.
/// The TCP port of the Modbus gateway.
/// The timeout for socket operations and the per-op response deadline.
/// Whether to automatically reconnect on socket failures.
/// Optional keep-alive configuration for the socket.
/// Optional duration after which an idle socket is disconnected.
/// Optional reconnect backoff configuration.
public ModbusRtuOverTcpTransport(
string host, int port, TimeSpan timeout, bool autoReconnect = true,
ModbusKeepAliveOptions? keepAlive = null,
TimeSpan? idleDisconnect = null,
ModbusReconnectOptions? reconnect = null)
{
_timeout = timeout;
_life = new ModbusSocketLifecycle(host, port, timeout, autoReconnect, keepAlive, idleDisconnect, reconnect);
}
private ModbusRtuOverTcpTransport(Stream testStream, TimeSpan timeout)
{
_timeout = timeout;
_testStream = testStream;
}
///
/// Test seam: builds a transport over a pre-connected, in-memory duplex ,
/// bypassing and the idle / reconnect machinery so the send/receive
/// orchestration, single-flight, and per-op deadline can be exercised with no real socket.
///
/// A pre-connected duplex stream standing in for the socket.
/// The per-op response deadline.
/// A transport driving directly.
internal static ModbusRtuOverTcpTransport ForTest(Stream stream, TimeSpan timeout) => new(stream, timeout);
///
public Task ConnectAsync(CancellationToken ct) =>
// The ForTest seam is handed a pre-connected stream — nothing to dial.
_life is null ? Task.CompletedTask : _life.ConnectAsync(ct);
///
public async Task SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
{
if (_disposed) throw new ObjectDisposedException(nameof(ModbusRtuOverTcpTransport));
if (CurrentStream is null) throw new InvalidOperationException("Transport not connected");
// Single-flight: RTU carries no transaction id, so at most one transaction may be on the bus
// at a time — serialise every send/receive under the gate.
await _gate.WaitAsync(ct).ConfigureAwait(false);
try
{
// Proactive idle-disconnect (real socket only): if the socket has been quiet longer than
// the configured threshold, tear it down + reconnect before this PDU lands. Defends
// against silent NAT / firewall reaping.
if (_life is not null && _life.ShouldReconnectForIdle())
{
await _life.TearDownAsync().ConfigureAwait(false);
await _life.ConnectWithBackoffAsync(ct).ConfigureAwait(false);
}
try
{
var result = await SendOnceAsync(unitId, pdu, ct).ConfigureAwait(false);
_life?.MarkSuccess();
return result;
}
catch (Exception ex) when (_life is not null && _life.AutoReconnect && ModbusSocketLifecycle.IsSocketLevelFailure(ex))
{
// Mid-transaction drop: tear down the dead socket, reconnect (with backoff if
// configured), resend. Single retry — a second failure propagates so health/status
// reflect reality. Never reached in the ForTest seam (a raw injected stream has no
// lifecycle to reconnect).
await _life.TearDownAsync().ConfigureAwait(false);
await _life.ConnectWithBackoffAsync(ct).ConfigureAwait(false);
var result = await SendOnceAsync(unitId, pdu, ct).ConfigureAwait(false);
_life.MarkSuccess();
return result;
}
}
finally
{
_gate.Release();
}
}
/// The live stream: the lifecycle's , or the injected test stream.
private Stream? CurrentStream => _life?.Stream ?? _testStream;
///
/// Executes exactly one RTU transaction on the current stream: build the ADU, write + flush,
/// read back one deframed response PDU. See the class remarks for the desync / timeout /
/// caller-cancel handling.
///
private async Task SendOnceAsync(byte unitId, byte[] pdu, CancellationToken ct)
{
var stream = CurrentStream;
if (stream is null) throw new InvalidOperationException("Transport not connected");
// RTU ADU: [unitId] + PDU + CRC — no MBAP header, no TxId.
var adu = ModbusRtuFraming.BuildAdu(unitId, pdu);
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(_timeout);
try
{
await stream.WriteAsync(adu.AsMemory(), cts.Token).ConfigureAwait(false);
await stream.FlushAsync(cts.Token).ConfigureAwait(false);
// Framing owns the length-less RTU deframe + CRC + unit-address validation. A CRC-valid
// exception PDU throws ModbusException (socket still coherent — propagates, no teardown);
// truncation / CRC / unit-mismatch throw ModbusTransportDesyncException.
return await ModbusRtuFraming.ReadResponsePduAsync(stream, unitId, cts.Token).ConfigureAwait(false);
}
catch (ModbusTransportDesyncException)
{
// Framing violation: the stream is desynchronized — never reuse it.
if (_life is not null) await _life.TearDownAsync().ConfigureAwait(false);
throw;
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
// Per-op timeout fired (NOT the caller). The response is unknown / may still land later
// and corrupt the next read — tear the socket down and normalise as a desync so the
// reconnect retry / status mapping engages.
if (_life is not null) await _life.TearDownAsync().ConfigureAwait(false);
throw new ModbusTransportDesyncException(
ModbusTransportDesyncException.DesyncReason.Timeout,
$"Modbus per-op response timeout after {_timeout.TotalMilliseconds:0}ms");
}
}
/// Asynchronously disposes the transport and underlying socket resources.
/// A task that represents the asynchronous operation.
public async ValueTask DisposeAsync()
{
if (_disposed) return;
_disposed = true;
if (_life is not null) await _life.DisposeAsync().ConfigureAwait(false);
_gate.Dispose();
}
}