diff --git a/AMWD.Protocols.Modbus.Common/AMWD.Protocols.Modbus.Common.csproj b/AMWD.Protocols.Modbus.Common/AMWD.Protocols.Modbus.Common.csproj
index 92ec156..d6a5e75 100644
--- a/AMWD.Protocols.Modbus.Common/AMWD.Protocols.Modbus.Common.csproj
+++ b/AMWD.Protocols.Modbus.Common/AMWD.Protocols.Modbus.Common.csproj
@@ -11,4 +11,8 @@
Common data for Modbus protocol.
+
+
+
+
diff --git a/AMWD.Protocols.Modbus.Serial/AMWD.Protocols.Modbus.Serial.csproj b/AMWD.Protocols.Modbus.Serial/AMWD.Protocols.Modbus.Serial.csproj
index 43d012f..9dceb47 100644
--- a/AMWD.Protocols.Modbus.Serial/AMWD.Protocols.Modbus.Serial.csproj
+++ b/AMWD.Protocols.Modbus.Serial/AMWD.Protocols.Modbus.Serial.csproj
@@ -11,6 +11,10 @@
Implementation of the Modbus protocol communicating via serial line using RTU or ASCII encoding.
+
+
+
+
diff --git a/AMWD.Protocols.Modbus.Tcp/AMWD.Protocols.Modbus.Tcp.csproj b/AMWD.Protocols.Modbus.Tcp/AMWD.Protocols.Modbus.Tcp.csproj
index a2808bf..7060266 100644
--- a/AMWD.Protocols.Modbus.Tcp/AMWD.Protocols.Modbus.Tcp.csproj
+++ b/AMWD.Protocols.Modbus.Tcp/AMWD.Protocols.Modbus.Tcp.csproj
@@ -11,6 +11,10 @@
Implementation of the Modbus protocol communicating via TCP.
+
+
+
+
diff --git a/AMWD.Protocols.Modbus.Tcp/InternalsVisibleTo.cs b/AMWD.Protocols.Modbus.Tcp/InternalsVisibleTo.cs
new file mode 100644
index 0000000..82a8ede
--- /dev/null
+++ b/AMWD.Protocols.Modbus.Tcp/InternalsVisibleTo.cs
@@ -0,0 +1,4 @@
+using System.Runtime.CompilerServices;
+
+[assembly: InternalsVisibleTo("AMWD.Protocols.Modbus.Tests")]
+[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")]
diff --git a/AMWD.Protocols.Modbus.Tcp/ModbusTcpClient.cs b/AMWD.Protocols.Modbus.Tcp/ModbusTcpClient.cs
new file mode 100644
index 0000000..ce74c72
--- /dev/null
+++ b/AMWD.Protocols.Modbus.Tcp/ModbusTcpClient.cs
@@ -0,0 +1,148 @@
+using System;
+using AMWD.Protocols.Modbus.Common.Contracts;
+using AMWD.Protocols.Modbus.Common.Protocols;
+
+namespace AMWD.Protocols.Modbus.Tcp
+{
+ ///
+ /// Default implementation of a Modbus TCP client.
+ ///
+ public class ModbusTcpClient : ModbusClientBase
+ {
+ ///
+ /// Initializes a new instance of the class with a hostname and port number.
+ ///
+ /// The DNS name of the remote host to which the connection is intended to.
+ /// The port number of the remote host to which the connection is intended to.
+ public ModbusTcpClient(string hostname, int port = 502)
+ : this(new ModbusTcpConnection { Hostname = hostname, Port = port })
+ { }
+
+ ///
+ /// Initializes a new instance of the class with a specific .
+ ///
+ /// The responsible for invoking the requests.
+ public ModbusTcpClient(IModbusConnection connection)
+ : this(connection, true)
+ { }
+
+ ///
+ /// Initializes a new instance of the class with a specific .
+ ///
+ /// The responsible for invoking the requests.
+ ///
+ /// if the connection should be disposed of by Dispose(),
+ /// otherwise if you inted to reuse the connection.
+ ///
+ public ModbusTcpClient(IModbusConnection connection, bool disposeConnection)
+ : base(connection, disposeConnection)
+ {
+ Protocol = new TcpProtocol();
+ }
+
+ ///
+ public override IModbusProtocol Protocol { get; set; }
+
+ ///
+ public string Hostname
+ {
+ get
+ {
+ if (connection is ModbusTcpConnection tcpConnection)
+ return tcpConnection.Hostname;
+
+ return default;
+ }
+ set
+ {
+ if (connection is ModbusTcpConnection tcpConnection)
+ tcpConnection.Hostname = value;
+ }
+ }
+
+ ///
+ public int Port
+ {
+ get
+ {
+ if (connection is ModbusTcpConnection tcpConnection)
+ return tcpConnection.Port;
+
+ return default;
+ }
+ set
+ {
+ if (connection is ModbusTcpConnection tcpConnection)
+ tcpConnection.Port = value;
+ }
+ }
+
+ ///
+ public TimeSpan ReadTimeout
+ {
+ get
+ {
+ if (connection is ModbusTcpConnection tcpConnection)
+ return tcpConnection.ReadTimeout;
+
+ return default;
+ }
+ set
+ {
+ if (connection is ModbusTcpConnection tcpConnection)
+ tcpConnection.ReadTimeout = value;
+ }
+ }
+
+ ///
+ public TimeSpan WriteTimeout
+ {
+ get
+ {
+ if (connection is ModbusTcpConnection tcpConnection)
+ return tcpConnection.WriteTimeout;
+
+ return default;
+ }
+ set
+ {
+ if (connection is ModbusTcpConnection tcpConnection)
+ tcpConnection.WriteTimeout = value;
+ }
+ }
+
+ ///
+ public TimeSpan ReconnectTimeout
+ {
+ get
+ {
+ if (connection is ModbusTcpConnection tcpConnection)
+ return tcpConnection.ReconnectTimeout;
+
+ return default;
+ }
+ set
+ {
+ if (connection is ModbusTcpConnection tcpConnection)
+ tcpConnection.ReconnectTimeout = value;
+ }
+ }
+
+ ///
+ public TimeSpan KeepAliveInterval
+ {
+ get
+ {
+ if (connection is ModbusTcpConnection tcpConnection)
+ return tcpConnection.KeepAliveInterval;
+
+ return default;
+ }
+ set
+ {
+ if (connection is ModbusTcpConnection tcpConnection)
+ tcpConnection.KeepAliveInterval = value;
+ }
+ }
+ }
+}
diff --git a/AMWD.Protocols.Modbus.Tcp/Utils/AsyncQueue.cs b/AMWD.Protocols.Modbus.Tcp/Utils/AsyncQueue.cs
new file mode 100644
index 0000000..1a40e43
--- /dev/null
+++ b/AMWD.Protocols.Modbus.Tcp/Utils/AsyncQueue.cs
@@ -0,0 +1,123 @@
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace System.Collections.Generic
+{
+ // ============================================================================================================================= //
+ // Source: https://git.am-wd.de/am.wd/common/-/blob/d4b390ad911ce302cc371bb2121fa9c31db1674a/AMWD.Common/Utilities/AsyncQueue.cs //
+ // ============================================================================================================================= //
+ [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
+ internal class AsyncQueue
+ {
+ private readonly Queue _queue = new();
+
+ private TaskCompletionSource _dequeueTcs = new();
+ private readonly TaskCompletionSource _availableTcs = new();
+
+ public T Dequeue()
+ {
+ lock (_queue)
+ {
+ return _queue.Dequeue();
+ }
+ }
+
+ public void Enqueue(T item)
+ {
+ lock (_queue)
+ {
+ _queue.Enqueue(item);
+ SetToken(_dequeueTcs);
+ SetToken(_availableTcs);
+ }
+ }
+
+ public async Task DequeueAsync(CancellationToken cancellationToken = default)
+ {
+ while (true)
+ {
+ TaskCompletionSource internalDequeueTcs;
+ lock (_queue)
+ {
+ if (_queue.Count > 0)
+ return _queue.Dequeue();
+
+ internalDequeueTcs = ResetToken(ref _dequeueTcs);
+ }
+
+ await WaitAsync(internalDequeueTcs, cancellationToken).ConfigureAwait(false);
+ }
+ }
+
+ public bool TryDequeue(out T result)
+ {
+ try
+ {
+ result = Dequeue();
+ return true;
+ }
+ catch
+ {
+ result = default;
+ return false;
+ }
+ }
+
+ public bool Remove(T item)
+ {
+ lock (_queue)
+ {
+ var copy = new Queue(_queue);
+ _queue.Clear();
+
+ bool found = false;
+ int count = copy.Count;
+ for (int i = 0; i < count; i++)
+ {
+ var element = copy.Dequeue();
+ if (found)
+ {
+ _queue.Enqueue(element);
+ continue;
+ }
+
+ if ((element == null && item == null) || element?.Equals(item) == true)
+ {
+ found = true;
+ continue;
+ }
+
+ _queue.Enqueue(element);
+ }
+
+ return found;
+ }
+ }
+
+ private static void SetToken(TaskCompletionSource tcs)
+ {
+ tcs.TrySetResult(true);
+ }
+
+ private static TaskCompletionSource ResetToken(ref TaskCompletionSource tcs)
+ {
+ if (tcs.Task.IsCompleted)
+ {
+ tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ }
+
+ return tcs;
+ }
+
+ private static async Task WaitAsync(TaskCompletionSource tcs, CancellationToken cancellationToken)
+ {
+ if (await Task.WhenAny(tcs.Task, Task.Delay(-1, cancellationToken)) == tcs.Task)
+ {
+ await tcs.Task.ConfigureAwait(false);
+ return;
+ }
+
+ cancellationToken.ThrowIfCancellationRequested();
+ }
+ }
+}
diff --git a/AMWD.Protocols.Modbus.Tcp/Utils/ModbusTcpConnection.cs b/AMWD.Protocols.Modbus.Tcp/Utils/ModbusTcpConnection.cs
new file mode 100644
index 0000000..78df4f4
--- /dev/null
+++ b/AMWD.Protocols.Modbus.Tcp/Utils/ModbusTcpConnection.cs
@@ -0,0 +1,450 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Net;
+using System.Net.Sockets;
+using System.Runtime.InteropServices;
+using System.Threading;
+using System.Threading.Tasks;
+using AMWD.Protocols.Modbus.Common.Contracts;
+using AMWD.Protocols.Modbus.Tcp.Utils;
+
+namespace AMWD.Protocols.Modbus.Tcp
+{
+ ///
+ /// The default Modbus TCP connection.
+ ///
+ public class ModbusTcpConnection : IModbusConnection
+ {
+ #region Fields
+
+ private string _hostname;
+ private int _port;
+
+ private bool _isDisposed;
+ private bool _isConnected;
+ private readonly TcpClientWrapper _client = new();
+
+ private CancellationTokenSource _disconnectCts;
+ private Task _reconnectTask = Task.CompletedTask;
+ private readonly SemaphoreSlim _reconnectLock = new(1, 1);
+
+ private CancellationTokenSource _processingCts;
+ private Task _processingTask = Task.CompletedTask;
+ private readonly AsyncQueue _requestQueue = new();
+
+ #endregion Fields
+
+ #region Properties
+
+ ///
+ public string Name => "TCP";
+
+ ///
+ public bool IsConnected => _isConnected && _client.Connected;
+
+ ///
+ /// The DNS name of the remote host to which the connection is intended to.
+ ///
+ public virtual string Hostname
+ {
+ get => _hostname;
+ set
+ {
+ if (string.IsNullOrWhiteSpace(value))
+ throw new ArgumentNullException(nameof(value));
+
+ _hostname = value;
+ }
+ }
+
+ ///
+ /// The port number of the remote host to which the connection is intended to.
+ ///
+ public virtual int Port
+ {
+ get => _port;
+ set
+ {
+ if (value < 1 || ushort.MaxValue < value)
+ throw new ArgumentOutOfRangeException(nameof(value));
+
+ _port = value;
+ }
+ }
+
+ ///
+ /// Gets or sets the receive time out value of the connection.
+ ///
+ public virtual TimeSpan ReadTimeout
+ {
+ get => TimeSpan.FromMilliseconds(_client.ReceiveTimeout);
+ set => _client.ReceiveTimeout = (int)value.TotalMilliseconds;
+ }
+
+ ///
+ /// Gets or sets the send time out value of the connection.
+ ///
+ public virtual TimeSpan WriteTimeout
+ {
+ get => TimeSpan.FromMilliseconds(_client.SendTimeout);
+ set => _client.SendTimeout = (int)value.TotalMilliseconds;
+ }
+
+ ///
+ /// Gets or sets the maximum time until the reconnect is given up.
+ ///
+ public virtual TimeSpan ReconnectTimeout { get; set; } = TimeSpan.MaxValue;
+
+ ///
+ /// Gets or sets the interval in which a keep alive package should be sent.
+ ///
+ public virtual TimeSpan KeepAliveInterval { get; set; } = TimeSpan.Zero;
+
+ #endregion Properties
+
+ ///
+ public async Task ConnectAsync(CancellationToken cancellationToken = default)
+ {
+#if NET8_0_OR_GREATER
+ ObjectDisposedException.ThrowIf(_isDisposed, this);
+#else
+ if (_isDisposed)
+ throw new ObjectDisposedException(GetType().FullName);
+#endif
+
+ if (_disconnectCts != null)
+ return;
+
+ _disconnectCts = new CancellationTokenSource();
+ using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(_disconnectCts.Token, cancellationToken);
+
+ _reconnectTask = ReconnectInternalAsync(linkedCts.Token);
+ await _reconnectTask.ConfigureAwait(false);
+ }
+
+ ///
+ public Task DisconnectAsync(CancellationToken cancellationToken = default)
+ {
+#if NET8_0_OR_GREATER
+ ObjectDisposedException.ThrowIf(_isDisposed, this);
+#else
+ if (_isDisposed)
+ throw new ObjectDisposedException(GetType().FullName);
+#endif
+ if (_disconnectCts == null)
+ return Task.CompletedTask;
+
+ return DisconnectInternalAsync(cancellationToken);
+ }
+
+ ///
+ public void Dispose()
+ {
+ if (_isDisposed)
+ return;
+
+ _isDisposed = true;
+ DisconnectInternalAsync(CancellationToken.None).Wait();
+
+ _client.Dispose();
+
+ GC.SuppressFinalize(this);
+ }
+
+ ///
+ public Task> InvokeAsync(IReadOnlyList request, Func, bool> validateResponseComplete, CancellationToken cancellationToken = default)
+ {
+#if NET8_0_OR_GREATER
+ ObjectDisposedException.ThrowIf(_isDisposed, this);
+#else
+ if (_isDisposed)
+ throw new ObjectDisposedException(GetType().FullName);
+#endif
+
+ if (!IsConnected)
+ throw new ApplicationException($"Connection is not open");
+
+ var item = new RequestQueueItem
+ {
+ Request = [.. request],
+ ValidateResponseComplete = validateResponseComplete,
+ TaskCompletionSource = new(),
+ CancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken),
+ };
+
+ item.CancellationTokenRegistration = item.CancellationTokenSource.Token.Register(() =>
+ {
+ _requestQueue.Remove(item);
+ item.CancellationTokenSource.Dispose();
+ item.TaskCompletionSource.TrySetCanceled();
+ item.CancellationTokenRegistration.Dispose();
+ });
+
+ _requestQueue.Enqueue(item);
+ return item.TaskCompletionSource.Task;
+ }
+
+ private async Task ReconnectInternalAsync(CancellationToken cancellationToken)
+ {
+ if (!_reconnectLock.Wait(0, cancellationToken))
+ return;
+
+ try
+ {
+ _isConnected = false;
+ _processingCts?.Cancel();
+ await _processingTask.ConfigureAwait(false);
+
+ int delay = 1;
+ int maxDelay = 60;
+
+ var ipAddresses = Resolve(Hostname);
+ if (ipAddresses.Count == 0)
+ throw new ApplicationException($"Could not resolve hostname '{Hostname}'");
+
+ var startTime = DateTime.UtcNow;
+ while (!cancellationToken.IsCancellationRequested)
+ {
+ try
+ {
+ foreach (var ipAddress in ipAddresses)
+ {
+ _client.Close();
+
+#if NET6_0_OR_GREATER
+ using var connectTask = _client.ConnectAsync(ipAddress, Port, cancellationToken);
+#else
+ using var connectTask = _client.ConnectAsync(ipAddress, Port);
+#endif
+ if (await Task.WhenAny(connectTask, Task.Delay(ReadTimeout, cancellationToken)) == connectTask)
+ {
+ await connectTask;
+ if (_client.Connected)
+ {
+ _isConnected = true;
+
+ _processingCts?.Dispose();
+ _processingCts = new();
+ _processingTask = ProcessAsync(_processingCts.Token);
+
+ SetKeepAlive();
+ return;
+ }
+ }
+ }
+
+ throw new SocketException((int)SocketError.TimedOut);
+ }
+ catch (SocketException) when (ReconnectTimeout == TimeSpan.MaxValue || DateTime.UtcNow.Subtract(startTime) < ReconnectTimeout)
+ {
+ delay *= 2;
+ if (delay > maxDelay)
+ delay = maxDelay;
+
+ try
+ {
+ await Task.Delay(TimeSpan.FromSeconds(delay), cancellationToken).ConfigureAwait(false);
+ }
+ catch
+ { /* keep it quiet */ }
+ }
+ }
+ }
+ finally
+ {
+ _reconnectLock.Release();
+ }
+ }
+
+ private async Task DisconnectInternalAsync(CancellationToken cancellationToken)
+ {
+ _disconnectCts?.Cancel();
+ _processingCts?.Cancel();
+
+ try
+ {
+ await _reconnectTask.ConfigureAwait(false);
+ await _processingTask.ConfigureAwait(false);
+ }
+ catch
+ { /* keep it quiet */ }
+
+ // Ensure that the client is closed
+ await _reconnectLock.WaitAsync(cancellationToken).ConfigureAwait(false);
+ try
+ {
+ _isConnected = false;
+ _client.Close();
+ }
+ finally
+ {
+ _reconnectLock.Release();
+ }
+
+ _disconnectCts?.Dispose();
+ _disconnectCts = null;
+
+ _processingCts?.Dispose();
+ _processingCts = null;
+
+ while (_requestQueue.TryDequeue(out var item))
+ {
+ item.CancellationTokenRegistration.Dispose();
+ item.CancellationTokenSource.Dispose();
+ item.TaskCompletionSource.TrySetCanceled(CancellationToken.None);
+ }
+ }
+
+ #region Processing
+
+ private async Task ProcessAsync(CancellationToken cancellationToken)
+ {
+ while (!cancellationToken.IsCancellationRequested)
+ {
+ var item = await _requestQueue.DequeueAsync(cancellationToken).ConfigureAwait(false);
+ item.CancellationTokenRegistration.Dispose();
+
+ using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, item.CancellationTokenSource.Token);
+ try
+ {
+ var stream = _client.GetStream();
+ await stream.FlushAsync(linkedCts.Token).ConfigureAwait(false);
+
+#if NET6_0_OR_GREATER
+ await stream.WriteAsync(item.Request, linkedCts.Token).ConfigureAwait(false);
+#else
+ await stream.WriteAsync(item.Request, 0, item.Request.Length, linkedCts.Token).ConfigureAwait(false);
+#endif
+
+ linkedCts.Token.ThrowIfCancellationRequested();
+
+ var bytes = new List();
+ byte[] buffer = new byte[260];
+
+ do
+ {
+#if NET6_0_OR_GREATER
+ int readCount = await stream.ReadAsync(buffer, linkedCts.Token).ConfigureAwait(false);
+#else
+ int readCount = await stream.ReadAsync(buffer, 0, buffer.Length, linkedCts.Token).ConfigureAwait(false);
+#endif
+ if (readCount < 1)
+ throw new EndOfStreamException();
+
+ bytes.AddRange(buffer.Take(readCount));
+
+ linkedCts.Token.ThrowIfCancellationRequested();
+ }
+ while (!item.ValidateResponseComplete(bytes));
+
+ item.TaskCompletionSource.TrySetResult(bytes);
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ // DisconnectAsync() called
+ item.TaskCompletionSource.TrySetCanceled(cancellationToken);
+ return;
+ }
+ catch (OperationCanceledException) when (item.CancellationTokenSource.IsCancellationRequested)
+ {
+ item.TaskCompletionSource.TrySetCanceled(item.CancellationTokenSource.Token);
+ continue;
+ }
+ catch (IOException ex)
+ {
+ item.TaskCompletionSource.TrySetException(ex);
+ _reconnectTask = ReconnectInternalAsync(_disconnectCts.Token);
+ }
+ catch (SocketException ex)
+ {
+ item.TaskCompletionSource.TrySetException(ex);
+ _reconnectTask = ReconnectInternalAsync(_disconnectCts.Token);
+ }
+ catch (TimeoutException ex)
+ {
+ item.TaskCompletionSource.TrySetException(ex);
+ _reconnectTask = ReconnectInternalAsync(_disconnectCts.Token);
+ }
+ catch (InvalidOperationException ex)
+ {
+ item.TaskCompletionSource.TrySetException(ex);
+ _reconnectTask = ReconnectInternalAsync(_disconnectCts.Token);
+ }
+ catch (Exception ex)
+ {
+ item.TaskCompletionSource.TrySetException(ex);
+ continue;
+ }
+ }
+ }
+
+ internal class RequestQueueItem
+ {
+ public byte[] Request { get; set; }
+
+ public Func, bool> ValidateResponseComplete { get; set; }
+
+ public TaskCompletionSource> TaskCompletionSource { get; set; }
+
+ public CancellationTokenSource CancellationTokenSource { get; set; }
+
+ public CancellationTokenRegistration CancellationTokenRegistration { get; set; }
+ }
+
+ #endregion Processing
+
+ #region Helpers
+
+ [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
+ private static List Resolve(string hostname)
+ {
+ if (string.IsNullOrWhiteSpace(hostname))
+ return [];
+
+ if (IPAddress.TryParse(hostname, out var ipAddress))
+ return [ipAddress];
+
+ try
+ {
+ return Dns.GetHostAddresses(hostname)
+ .Where(a => a.AddressFamily == AddressFamily.InterNetwork || a.AddressFamily == AddressFamily.InterNetworkV6)
+ .OrderBy(a => a.AddressFamily) // Prefer IPv4
+ .ToList();
+ }
+ catch
+ {
+ return [];
+ }
+ }
+
+ [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
+ private void SetKeepAlive()
+ {
+#if NET6_0_OR_GREATER
+ _client.Client?.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.KeepAlive, KeepAliveInterval.TotalMilliseconds > 0);
+ _client.Client?.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveTime, (int)KeepAliveInterval.TotalSeconds);
+ _client.Client?.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveInterval, (int)KeepAliveInterval.TotalSeconds);
+#else
+ // See: https://github.com/dotnet/runtime/issues/25555
+ if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+ return;
+
+ bool isEnabled = KeepAliveInterval.TotalMilliseconds > 0;
+ uint interval = KeepAliveInterval.TotalMilliseconds > uint.MaxValue
+ ? uint.MaxValue
+ : (uint)KeepAliveInterval.TotalMilliseconds;
+ int uIntSize = sizeof(uint);
+ byte[] config = new byte[uIntSize * 3];
+
+ Array.Copy(BitConverter.GetBytes(isEnabled ? 1U : 0U), 0, config, uIntSize * 0, uIntSize);
+ Array.Copy(BitConverter.GetBytes(interval), 0, config, uIntSize * 1, uIntSize);
+ Array.Copy(BitConverter.GetBytes(interval), 0, config, uIntSize * 2, uIntSize);
+ _client.Client?.IOControl(IOControlCode.KeepAliveValues, config, null);
+#endif
+ }
+
+ #endregion Helpers
+ }
+}
diff --git a/AMWD.Protocols.Modbus.Tcp/Utils/NetworkStreamWrapper.cs b/AMWD.Protocols.Modbus.Tcp/Utils/NetworkStreamWrapper.cs
new file mode 100644
index 0000000..e6cd4ce
--- /dev/null
+++ b/AMWD.Protocols.Modbus.Tcp/Utils/NetworkStreamWrapper.cs
@@ -0,0 +1,51 @@
+using System;
+using System.IO;
+using System.Net.Sockets;
+using System.Threading.Tasks;
+using System.Threading;
+
+namespace AMWD.Protocols.Modbus.Tcp.Utils
+{
+ ///
+ [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
+ internal class NetworkStreamWrapper : IDisposable
+ {
+ private readonly NetworkStream _stream;
+
+ [Obsolete("Constructor only for mocking on UnitTests!")]
+ public NetworkStreamWrapper()
+ { }
+
+ public NetworkStreamWrapper(NetworkStream stream)
+ {
+ _stream = stream;
+ }
+
+ public virtual void Dispose()
+ => _stream.Dispose();
+
+ ///
+ public virtual Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken = default)
+ => _stream.ReadAsync(buffer, offset, count, cancellationToken);
+
+#if NET6_0_OR_GREATER
+ ///
+ public virtual ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default)
+ => _stream.ReadAsync(buffer, cancellationToken);
+#endif
+
+ ///
+ public virtual Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken = default)
+ => _stream.WriteAsync(buffer, offset, count, cancellationToken);
+
+#if NET6_0_OR_GREATER
+ ///
+ public virtual ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default)
+ => _stream.WriteAsync(buffer, cancellationToken);
+#endif
+
+ ///
+ public virtual Task FlushAsync(CancellationToken cancellationToken = default)
+ => _stream.FlushAsync(cancellationToken);
+ }
+}
diff --git a/AMWD.Protocols.Modbus.Tcp/Utils/SocketWrapper.cs b/AMWD.Protocols.Modbus.Tcp/Utils/SocketWrapper.cs
new file mode 100644
index 0000000..2647599
--- /dev/null
+++ b/AMWD.Protocols.Modbus.Tcp/Utils/SocketWrapper.cs
@@ -0,0 +1,39 @@
+using System;
+using System.Net.Sockets;
+
+namespace AMWD.Protocols.Modbus.Tcp.Utils
+{
+ ///
+ [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
+ internal class SocketWrapper : IDisposable
+ {
+ [Obsolete("Constructor only for mocking on UnitTests!")]
+ public SocketWrapper()
+ { }
+
+ public SocketWrapper(Socket socket)
+ {
+ Client = socket;
+ }
+
+ public virtual Socket Client { get; }
+
+ ///
+ public virtual void Dispose()
+ => Client.Dispose();
+
+ ///
+ public virtual int IOControl(IOControlCode ioControlCode, byte[] optionInValue, byte[] optionOutValue)
+ => Client.IOControl(ioControlCode, optionInValue, optionOutValue);
+
+#if NET6_0_OR_GREATER
+ ///
+ public virtual void SetSocketOption(SocketOptionLevel optionLevel, SocketOptionName optionName, bool optionValue)
+ => Client.SetSocketOption(optionLevel, optionName, optionValue);
+
+ ///
+ public virtual void SetSocketOption(SocketOptionLevel optionLevel, SocketOptionName optionName, int optionValue)
+ => Client.SetSocketOption(optionLevel, optionName, optionValue);
+#endif
+ }
+}
diff --git a/AMWD.Protocols.Modbus.Tcp/Utils/TcpClientWrapper.cs b/AMWD.Protocols.Modbus.Tcp/Utils/TcpClientWrapper.cs
new file mode 100644
index 0000000..1d3f29f
--- /dev/null
+++ b/AMWD.Protocols.Modbus.Tcp/Utils/TcpClientWrapper.cs
@@ -0,0 +1,63 @@
+using System;
+using System.Net;
+using System.Net.Sockets;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace AMWD.Protocols.Modbus.Tcp.Utils
+{
+ ///
+ [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
+ internal class TcpClientWrapper : IDisposable
+ {
+ private readonly TcpClient _client = new();
+
+ ///
+ public virtual int ReceiveTimeout
+ {
+ get => _client.ReceiveTimeout;
+ set => _client.ReceiveTimeout = value;
+ }
+
+ ///
+ public virtual int SendTimeout
+ {
+ get => _client.SendTimeout;
+ set => _client.SendTimeout = value;
+ }
+
+ ///
+ public virtual bool Connected => _client.Connected;
+
+ ///
+ public virtual SocketWrapper Client
+ {
+ get => new(_client.Client);
+ set => _client.Client = value.Client;
+ }
+
+ ///
+ public virtual void Close()
+ => _client.Close();
+
+#if NET6_0_OR_GREATER
+ ///
+ public virtual Task ConnectAsync(IPAddress address, int port, CancellationToken cancellationToken)
+ => _client.ConnectAsync(address, port, cancellationToken).AsTask();
+#else
+
+ ///
+ public virtual Task ConnectAsync(IPAddress address, int port)
+ => _client.ConnectAsync(address, port);
+
+#endif
+
+ ///
+ public virtual void Dispose()
+ => _client.Dispose();
+
+ ///
+ public virtual NetworkStreamWrapper GetStream()
+ => new(_client.GetStream());
+ }
+}
diff --git a/Directory.Build.props b/Directory.Build.props
index ef9df87..0a18424 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -41,7 +41,6 @@
-