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 @@ -