2f38b5b285
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
215 lines
10 KiB
C#
215 lines
10 KiB
C#
using System.Net.Sockets;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
|
|
|
/// <summary>
|
|
/// Concrete Modbus TCP transport. Wraps a single socket (owned by <see cref="ModbusSocketLifecycle"/>)
|
|
/// 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.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Owns the MBAP framing (<see cref="SendOnceAsync"/>: 7-byte header + transaction-id
|
|
/// pairing) and composes <see cref="ModbusSocketLifecycle"/> for the connect / reconnect /
|
|
/// keep-alive / idle-disconnect machinery — the same lifecycle the RTU-over-TCP transport
|
|
/// reuses with different framing.
|
|
/// </para>
|
|
/// <para>
|
|
/// Survives mid-transaction socket drops: when a send/read fails with a socket-level
|
|
/// error (<see cref="IOException"/>, <see cref="SocketException"/>, <see cref="EndOfStreamException"/>)
|
|
/// 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.
|
|
/// </para>
|
|
/// <para>
|
|
/// Why this matters for DL205/DL260: the AutomationDirect H2-ECOM100 does NOT send
|
|
/// TCP keepalives per <c>docs/v2/dl205.md</c> §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 <c>SO_KEEPALIVE</c> so the driver's own side detects a stuck
|
|
/// socket in reasonable time even when the application is mostly idle.
|
|
/// </para>
|
|
/// </remarks>
|
|
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;
|
|
|
|
/// <summary>Initializes a new instance of the <see cref="ModbusTcpTransport"/> class.</summary>
|
|
/// <param name="host">The host address or hostname of the Modbus server.</param>
|
|
/// <param name="port">The TCP port of the Modbus server.</param>
|
|
/// <param name="timeout">The timeout for socket operations.</param>
|
|
/// <param name="autoReconnect">Whether to automatically reconnect on socket failures.</param>
|
|
/// <param name="keepAlive">Optional keep-alive configuration for the socket.</param>
|
|
/// <param name="idleDisconnect">Optional duration after which an idle socket is disconnected.</param>
|
|
/// <param name="reconnect">Optional reconnect backoff configuration.</param>
|
|
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);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task ConnectAsync(CancellationToken ct) => _life.ConnectAsync(ct);
|
|
|
|
/// <inheritdoc />
|
|
public async Task<byte[]> 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();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Executes exactly one Modbus transaction on the current socket.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// A per-op deadline (linked <see cref="CancellationTokenSource.CancelAfter(TimeSpan)"/>)
|
|
/// and framing violations (TxId mismatch / truncated MBAP length) are treated as
|
|
/// <b>connection-fatal</b>: 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
|
|
/// <see cref="ModbusTransportDesyncException"/> — which, being an
|
|
/// <see cref="IOException"/>, feeds <see cref="SendAsync"/>'s existing single reconnect
|
|
/// retry with no extra plumbing.
|
|
/// </para>
|
|
/// <para>
|
|
/// The timeout-vs-caller-cancel distinction is load-bearing: a <b>caller</b> cancellation
|
|
/// (<c>ct.IsCancellationRequested</c>) is a legitimate shutdown and propagates as a plain
|
|
/// <see cref="OperationCanceledException"/> with <b>no</b> teardown — only the linked
|
|
/// per-op timeout (the caller token is NOT cancelled) is a desync.
|
|
/// </para>
|
|
/// </remarks>
|
|
private async Task<byte[]> 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;
|
|
}
|
|
}
|
|
|
|
/// <summary>Asynchronously disposes the transport and underlying socket resources.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
if (_disposed) return;
|
|
_disposed = true;
|
|
await _life.DisposeAsync().ConfigureAwait(false);
|
|
_gate.Dispose();
|
|
}
|
|
}
|