using System.Net.Sockets; namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus; /// /// Concrete Modbus TCP transport. Wraps a single socket (owned by ) /// and serializes requests so at most one transaction is in-flight at a time — Modbus servers /// typically support concurrent transactions, but the single-flight model keeps the wire trace /// easy to diagnose and avoids interleaved-response correlation bugs. /// /// /// /// Owns the MBAP framing (: 7-byte header + transaction-id /// pairing) and composes for the connect / reconnect / /// keep-alive / idle-disconnect machinery — the same lifecycle the RTU-over-TCP transport /// reuses with different framing. /// /// /// Survives mid-transaction socket drops: when a send/read fails with a socket-level /// error (, , ) /// the transport disposes the dead socket, reconnects, and retries the PDU exactly /// once. Deliberately limited to a single retry — further failures bubble up so the /// driver's health surface reflects the real state instead of masking a dead PLC. /// /// /// Why this matters for DL205/DL260: the AutomationDirect H2-ECOM100 does NOT send /// TCP keepalives per docs/v2/dl205.md §behavioral-oddities, so any NAT/firewall /// between the gateway and PLC can silently close an idle socket after 2-5 minutes. /// Also enables OS-level SO_KEEPALIVE so the driver's own side detects a stuck /// socket in reasonable time even when the application is mostly idle. /// /// public sealed class ModbusTcpTransport : IModbusTransport { private readonly ModbusSocketLifecycle _life; private readonly TimeSpan _timeout; private readonly SemaphoreSlim _gate = new(1, 1); private ushort _nextTx; private bool _disposed; /// Initializes a new instance of the class. /// The host address or hostname of the Modbus server. /// The TCP port of the Modbus server. /// The timeout for socket operations. /// 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 ModbusTcpTransport( 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); } /// public Task ConnectAsync(CancellationToken ct) => _life.ConnectAsync(ct); /// public async Task SendAsync(byte unitId, byte[] pdu, CancellationToken ct) { if (_disposed) throw new ObjectDisposedException(nameof(ModbusTcpTransport)); if (_life.Stream is null) throw new InvalidOperationException("Transport not connected"); await _gate.WaitAsync(ct).ConfigureAwait(false); try { // Proactive idle-disconnect: 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 where the socket looks alive locally but the upstream side // dropped it minutes ago. if (_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.AutoReconnect && ModbusSocketLifecycle.IsSocketLevelFailure(ex)) { // Mid-transaction drop: tear down the dead socket, reconnect (with backoff if // configured), resend. Single retry — if it fails again, let it propagate so // health/status reflect reality. 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(); } } /// /// Executes exactly one Modbus transaction on the current socket. /// /// /// /// A per-op deadline (linked ) /// and framing violations (TxId mismatch / truncated MBAP length) are treated as /// connection-fatal: the socket may have a half-read or still-in-flight response, /// so reusing it would read stale bytes and desynchronize the stream permanently. Such /// failures tear the socket down here (before propagating) and surface as /// — which, being an /// , feeds 's existing single reconnect /// retry with no extra plumbing. /// /// /// The timeout-vs-caller-cancel distinction is load-bearing: a caller cancellation /// (ct.IsCancellationRequested) is a legitimate shutdown and propagates as a plain /// with no teardown — only the linked /// per-op timeout (the caller token is NOT cancelled) is a desync. /// /// private async Task SendOnceAsync(byte unitId, byte[] pdu, CancellationToken ct) { var stream = _life.Stream; if (stream is null) throw new InvalidOperationException("Transport not connected"); var txId = ++_nextTx; // MBAP: [TxId(2)][Proto=0(2)][Length(2)][UnitId(1)] + PDU var adu = new byte[7 + pdu.Length]; adu[0] = (byte)(txId >> 8); adu[1] = (byte)(txId & 0xFF); // protocol id already zero var len = (ushort)(1 + pdu.Length); // unit id + pdu adu[4] = (byte)(len >> 8); adu[5] = (byte)(len & 0xFF); adu[6] = unitId; Buffer.BlockCopy(pdu, 0, adu, 7, pdu.Length); 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); var header = new byte[7]; await ReadExactlyAsync(stream, header, cts.Token).ConfigureAwait(false); var respTxId = (ushort)((header[0] << 8) | header[1]); if (respTxId != txId) throw new ModbusTransportDesyncException( ModbusTransportDesyncException.DesyncReason.TxIdMismatch, $"Modbus TxId mismatch: expected {txId} got {respTxId}"); var respLen = (ushort)((header[4] << 8) | header[5]); if (respLen < 1) throw new ModbusTransportDesyncException( ModbusTransportDesyncException.DesyncReason.TruncatedFrame, $"Modbus response length too small: {respLen}"); var respPdu = new byte[respLen - 1]; await ReadExactlyAsync(stream, respPdu, cts.Token).ConfigureAwait(false); // Exception PDU: function code has high bit set. This is a well-formed protocol-level // error — the socket is still coherent, so it MUST propagate without teardown. if ((respPdu[0] & 0x80) != 0) { var fc = (byte)(respPdu[0] & 0x7F); var ex = respPdu[1]; throw new ModbusException(fc, ex, $"Modbus exception fc={fc} code={ex}"); } return respPdu; } catch (ModbusTransportDesyncException) { // Framing violation: the socket is desynchronized — never reuse it. 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 normalize as a desync so the // reconnect retry / status mapping engages. await _life.TearDownAsync().ConfigureAwait(false); throw new ModbusTransportDesyncException( ModbusTransportDesyncException.DesyncReason.Timeout, $"Modbus per-op response timeout after {_timeout.TotalMilliseconds:0}ms"); } } private static async Task ReadExactlyAsync(Stream s, byte[] buf, CancellationToken ct) { var read = 0; while (read < buf.Length) { var n = await s.ReadAsync(buf.AsMemory(read), ct).ConfigureAwait(false); if (n == 0) throw new EndOfStreamException("Modbus socket closed mid-response"); read += n; } } /// Asynchronously disposes the transport and underlying socket resources. /// A task that represents the asynchronous operation. public async ValueTask DisposeAsync() { if (_disposed) return; _disposed = true; await _life.DisposeAsync().ConfigureAwait(false); _gate.Dispose(); } }