diff --git a/AMWD.Protocols.Modbus.Common/Contracts/IModbusConnection.cs b/AMWD.Protocols.Modbus.Common/Contracts/IModbusConnection.cs index b412e75..fe609e0 100644 --- a/AMWD.Protocols.Modbus.Common/Contracts/IModbusConnection.cs +++ b/AMWD.Protocols.Modbus.Common/Contracts/IModbusConnection.cs @@ -16,23 +16,14 @@ namespace AMWD.Protocols.Modbus.Common.Contracts string Name { get; } /// - /// Gets a value indicating whether the connection is open. + /// Gets or sets the idle time after that the connection is closed. /// - bool IsConnected { get; } - - /// - /// Opens the connection to the remote device. - /// - /// A cancellation token used to propagate notification that this operation should be canceled. - /// An awaitable . - Task ConnectAsync(CancellationToken cancellationToken = default); - - /// - /// Closes the connection to the remote device. - /// - /// A cancellation token used to propagate notification that this operation should be canceled. - /// An awaitable . - Task DisconnectAsync(CancellationToken cancellationToken = default); + /// + /// Set to to disable idle closing the connection. + ///
+ /// Set to to close the connection immediately after each request. + ///
+ TimeSpan IdleTimeout { get; set; } /// /// Invokes a Modbus request. diff --git a/AMWD.Protocols.Modbus.Common/Contracts/ModbusClientBase.cs b/AMWD.Protocols.Modbus.Common/Contracts/ModbusClientBase.cs index 7abcf4f..7274624 100644 --- a/AMWD.Protocols.Modbus.Common/Contracts/ModbusClientBase.cs +++ b/AMWD.Protocols.Modbus.Common/Contracts/ModbusClientBase.cs @@ -46,11 +46,6 @@ namespace AMWD.Protocols.Modbus.Common.Contracts this.disposeConnection = disposeConnection; } - /// - /// Gets a value indicating whether the client is connected. - /// - public bool IsConnected => connection.IsConnected; - /// /// Gets or sets the protocol type to use. /// @@ -59,28 +54,6 @@ namespace AMWD.Protocols.Modbus.Common.Contracts /// public abstract IModbusProtocol Protocol { get; set; } - /// - /// Starts the connection to the remote endpoint. - /// - /// A cancellation token used to propagate notification that this operation should be canceled. - /// An awaitable . - public virtual Task ConnectAsync(CancellationToken cancellationToken = default) - { - Assertions(false); - return connection.ConnectAsync(cancellationToken); - } - - /// - /// Stops the connection to the remote endpoint. - /// - /// A cancellation token used to propagate notification that this operation should be canceled. - /// An awaitable . - public virtual Task DisconnectAsync(CancellationToken cancellationToken = default) - { - Assertions(false); - return connection.DisconnectAsync(cancellationToken); - } - /// /// Reads multiple s. /// @@ -222,31 +195,31 @@ namespace AMWD.Protocols.Modbus.Common.Contracts switch ((ModbusDeviceIdentificationObject)item.Key) { case ModbusDeviceIdentificationObject.VendorName: - devIdent.VendorName = Encoding.ASCII.GetString(item.Value); + devIdent.VendorName = Encoding.UTF8.GetString(item.Value); break; case ModbusDeviceIdentificationObject.ProductCode: - devIdent.ProductCode = Encoding.ASCII.GetString(item.Value); + devIdent.ProductCode = Encoding.UTF8.GetString(item.Value); break; case ModbusDeviceIdentificationObject.MajorMinorRevision: - devIdent.MajorMinorRevision = Encoding.ASCII.GetString(item.Value); + devIdent.MajorMinorRevision = Encoding.UTF8.GetString(item.Value); break; case ModbusDeviceIdentificationObject.VendorUrl: - devIdent.VendorUrl = Encoding.ASCII.GetString(item.Value); + devIdent.VendorUrl = Encoding.UTF8.GetString(item.Value); break; case ModbusDeviceIdentificationObject.ProductName: - devIdent.ProductName = Encoding.ASCII.GetString(item.Value); + devIdent.ProductName = Encoding.UTF8.GetString(item.Value); break; case ModbusDeviceIdentificationObject.ModelName: - devIdent.ModelName = Encoding.ASCII.GetString(item.Value); + devIdent.ModelName = Encoding.UTF8.GetString(item.Value); break; case ModbusDeviceIdentificationObject.UserApplicationName: - devIdent.UserApplicationName = Encoding.ASCII.GetString(item.Value); + devIdent.UserApplicationName = Encoding.UTF8.GetString(item.Value); break; default: @@ -375,7 +348,7 @@ namespace AMWD.Protocols.Modbus.Common.Contracts /// /// Performs basic assertions. /// - protected virtual void Assertions(bool checkConnected = true) + protected virtual void Assertions() { #if NET8_0_OR_GREATER ObjectDisposedException.ThrowIf(_isDisposed, this); @@ -390,12 +363,6 @@ namespace AMWD.Protocols.Modbus.Common.Contracts if (Protocol == null) throw new ArgumentNullException(nameof(Protocol)); #endif - - if (!checkConnected) - return; - - if (!IsConnected) - throw new ApplicationException($"Connection is not open"); } } } diff --git a/AMWD.Protocols.Modbus.Common/README.md b/AMWD.Protocols.Modbus.Common/README.md index 1f7679f..4f29a1b 100644 --- a/AMWD.Protocols.Modbus.Common/README.md +++ b/AMWD.Protocols.Modbus.Common/README.md @@ -6,7 +6,8 @@ This package contains all basic tools to build your own clients. **IModbusConnection** This is the interface used on the base client to communicate with the remote device. -If you want to use a custom connection type, you should implement this interface yourself. +If you want to use a custom connection type, you should implement this interface yourself. +The `IModbusConnection` is responsible to open and close the data channel in the background. **IModbusProtocol** If you want to speak a custom type of protocol with the clients, you can implement this interface. diff --git a/AMWD.Protocols.Modbus.Tcp/Utils/AsyncQueue.cs b/AMWD.Protocols.Modbus.Common/Utils/AsyncQueue.cs similarity index 100% rename from AMWD.Protocols.Modbus.Tcp/Utils/AsyncQueue.cs rename to AMWD.Protocols.Modbus.Common/Utils/AsyncQueue.cs diff --git a/AMWD.Protocols.Modbus.Tcp/AMWD.Protocols.Modbus.Tcp.csproj b/AMWD.Protocols.Modbus.Tcp/AMWD.Protocols.Modbus.Tcp.csproj index 15fedd4..693f52a 100644 --- a/AMWD.Protocols.Modbus.Tcp/AMWD.Protocols.Modbus.Tcp.csproj +++ b/AMWD.Protocols.Modbus.Tcp/AMWD.Protocols.Modbus.Tcp.csproj @@ -17,6 +17,7 @@ + diff --git a/AMWD.Protocols.Modbus.Tcp/ModbusTcpClient.cs b/AMWD.Protocols.Modbus.Tcp/ModbusTcpClient.cs index ce74c72..f10cef6 100644 --- a/AMWD.Protocols.Modbus.Tcp/ModbusTcpClient.cs +++ b/AMWD.Protocols.Modbus.Tcp/ModbusTcpClient.cs @@ -111,37 +111,37 @@ namespace AMWD.Protocols.Modbus.Tcp } } - /// + /// public TimeSpan ReconnectTimeout { get { if (connection is ModbusTcpConnection tcpConnection) - return tcpConnection.ReconnectTimeout; + return tcpConnection.ConnectTimeout; return default; } set { if (connection is ModbusTcpConnection tcpConnection) - tcpConnection.ReconnectTimeout = value; + tcpConnection.ConnectTimeout = value; } } - /// - public TimeSpan KeepAliveInterval + /// + public TimeSpan IdleTimeout { get { if (connection is ModbusTcpConnection tcpConnection) - return tcpConnection.KeepAliveInterval; + return tcpConnection.IdleTimeout; return default; } set { if (connection is ModbusTcpConnection tcpConnection) - tcpConnection.KeepAliveInterval = value; + tcpConnection.IdleTimeout = value; } } } diff --git a/AMWD.Protocols.Modbus.Tcp/ModbusTcpConnection.cs b/AMWD.Protocols.Modbus.Tcp/ModbusTcpConnection.cs index a951c96..6992b16 100644 --- a/AMWD.Protocols.Modbus.Tcp/ModbusTcpConnection.cs +++ b/AMWD.Protocols.Modbus.Tcp/ModbusTcpConnection.cs @@ -4,10 +4,10 @@ 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.Common.Protocols; using AMWD.Protocols.Modbus.Tcp.Utils; namespace AMWD.Protocols.Modbus.Tcp @@ -23,26 +23,33 @@ namespace AMWD.Protocols.Modbus.Tcp private int _port; private bool _isDisposed; - private bool _isConnected; + private readonly CancellationTokenSource _disposeCts = new(); + + private readonly SemaphoreSlim _clientLock = new(1, 1); private readonly TcpClientWrapper _client = new(); + private readonly Timer _idleTimer; - 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 Task _processingTask; private readonly AsyncQueue _requestQueue = new(); #endregion Fields + /// + /// Initializes a new instance of the class. + /// + public ModbusTcpConnection() + { + _idleTimer = new Timer(OnIdleTimer); + _processingTask = ProcessAsync(_disposeCts.Token); + } + #region Properties /// public string Name => "TCP"; /// - public bool IsConnected => _isConnected && _client.Connected; + public virtual TimeSpan IdleTimeout { get; set; } = TimeSpan.FromSeconds(6); /// /// The DNS name of the remote host to which the connection is intended to. @@ -93,55 +100,12 @@ namespace AMWD.Protocols.Modbus.Tcp } /// - /// Gets or sets the maximum time until the reconnect is given up. + /// Gets or sets the maximum time until the connect attempt 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; + public virtual TimeSpan ConnectTimeout { get; set; } = TimeSpan.MaxValue; #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) - { - await _reconnectTask; - 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() { @@ -149,13 +113,36 @@ namespace AMWD.Protocols.Modbus.Tcp return; _isDisposed = true; - DisconnectInternalAsync(CancellationToken.None).Wait(); + _disposeCts.Cancel(); + + _idleTimer.Dispose(); + + try + { + _processingTask.Wait(); + _processingTask.Dispose(); + } + catch + { /* keep it quiet */ } + + OnIdleTimer(null); _client.Dispose(); + _clientLock.Dispose(); + while (_requestQueue.TryDequeue(out var item)) + { + item.CancellationTokenRegistration.Dispose(); + item.CancellationTokenSource.Dispose(); + item.TaskCompletionSource.TrySetException(new ObjectDisposedException(GetType().FullName)); + } + + _disposeCts.Dispose(); GC.SuppressFinalize(this); } + #region Request processing + /// public Task> InvokeAsync(IReadOnlyList request, Func, bool> validateResponseComplete, CancellationToken cancellationToken = default) { @@ -166,10 +153,7 @@ namespace AMWD.Protocols.Modbus.Tcp throw new ObjectDisposedException(GetType().FullName); #endif - if (!IsConnected) - throw new ApplicationException($"Connection is not open"); - - if (request?.Count < 1) + if (request == null || request.Count < 1) throw new ArgumentNullException(nameof(request)); #if NET8_0_OR_GREATER @@ -184,7 +168,7 @@ namespace AMWD.Protocols.Modbus.Tcp Request = [.. request], ValidateResponseComplete = validateResponseComplete, TaskCompletionSource = new(), - CancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken), + CancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken) }; item.CancellationTokenRegistration = item.CancellationTokenSource.Token.Register(() => @@ -199,231 +183,183 @@ namespace AMWD.Protocols.Modbus.Tcp 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); + // Get next request to process + var item = await _requestQueue.DequeueAsync(cancellationToken).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 + // Remove registration => already removed from queue + item.CancellationTokenRegistration.Dispose(); - linkedCts.Token.ThrowIfCancellationRequested(); - - var bytes = new List(); - byte[] buffer = new byte[260]; - - do + // Build combined cancellation token + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, item.CancellationTokenSource.Token); + // Wait for exclusive access + await _clientLock.WaitAsync(linkedCts.Token).ConfigureAwait(false); + try { -#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(); + // Ensure connection is up + await AssertConnection(linkedCts.Token).ConfigureAwait(false); - bytes.AddRange(buffer.Take(readCount)); + 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(); - } - while (!item.ValidateResponseComplete(bytes)); - item.TaskCompletionSource.TrySetResult(bytes); + var bytes = new List(); + byte[] buffer = new byte[TcpProtocol.MAX_ADU_LENGTH]; + + 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) + { + // Dispose() called + item.TaskCompletionSource.TrySetCanceled(cancellationToken); + } + catch (OperationCanceledException) when (item.CancellationTokenSource.IsCancellationRequested) + { + // Cancellation requested by user + item.TaskCompletionSource.TrySetCanceled(item.CancellationTokenSource.Token); + } + catch (Exception ex) + { + item.TaskCompletionSource.TrySetException(ex); + } + finally + { + _clientLock.Release(); + _idleTimer.Change(IdleTimeout, Timeout.InfiniteTimeSpan); + } } 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); + // Dispose() called while waiting for request item } } } - internal class RequestQueueItem + #endregion Request processing + + #region Connection handling + + // Has to be called within _clientLock! + private async Task AssertConnection(CancellationToken cancellationToken) { - public byte[] Request { get; set; } + if (_client.Connected) + return; - public Func, bool> ValidateResponseComplete { get; set; } + int delay = 1; + int maxDelay = 60; - public TaskCompletionSource> TaskCompletionSource { get; set; } + var ipAddresses = Resolve(Hostname); + if (ipAddresses.Length == 0) + throw new ApplicationException($"Could not resolve hostname '{Hostname}'"); - public CancellationTokenSource CancellationTokenSource { get; set; } + var startTime = DateTime.UtcNow; + while (!cancellationToken.IsCancellationRequested) + { + try + { + foreach (var ipAddress in ipAddresses) + { + _client.Close(); - public CancellationTokenRegistration CancellationTokenRegistration { get; set; } +#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) + return; + } + } + + throw new SocketException((int)SocketError.TimedOut); + } + catch (SocketException) when (ConnectTimeout == TimeSpan.MaxValue || DateTime.UtcNow.Subtract(startTime) < ConnectTimeout) + { + delay *= 2; + if (delay > maxDelay) + delay = maxDelay; + + try + { + await Task.Delay(TimeSpan.FromSeconds(delay), cancellationToken).ConfigureAwait(false); + } + catch + { /* keep it quiet */ } + } + } } - #endregion Processing + private void OnIdleTimer(object _) + { + try + { + _clientLock.Wait(_disposeCts.Token); + try + { + if (!_client.Connected) + return; + + _client.Close(); + } + finally + { + _clientLock.Release(); + } + } + catch + { /* keep it quiet */ } + } + + #endregion Connection handling #region Helpers [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] - private static List Resolve(string hostname) + private static IPAddress[] Resolve(string hostname) { if (string.IsNullOrWhiteSpace(hostname)) return []; - if (IPAddress.TryParse(hostname, out var ipAddress)) - return [ipAddress]; + if (IPAddress.TryParse(hostname, out var address)) + return [address]; try { return Dns.GetHostAddresses(hostname) .Where(a => a.AddressFamily == AddressFamily.InterNetwork || a.AddressFamily == AddressFamily.InterNetworkV6) - .OrderBy(a => a.AddressFamily) // Prefer IPv4 - .ToList(); + .OrderBy(a => a.AddressFamily) // prefer IPv4 + .ToArray(); } catch { @@ -431,32 +367,6 @@ namespace AMWD.Protocols.Modbus.Tcp } } - [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 index 8f70d5d..dfa5c7d 100644 --- a/AMWD.Protocols.Modbus.Tcp/Utils/NetworkStreamWrapper.cs +++ b/AMWD.Protocols.Modbus.Tcp/Utils/NetworkStreamWrapper.cs @@ -21,6 +21,7 @@ namespace AMWD.Protocols.Modbus.Tcp.Utils _stream = stream; } + /// public virtual void Dispose() => _stream.Dispose(); diff --git a/AMWD.Protocols.Modbus.Tcp/Utils/RequestQueueItem.cs b/AMWD.Protocols.Modbus.Tcp/Utils/RequestQueueItem.cs new file mode 100644 index 0000000..fd53bb1 --- /dev/null +++ b/AMWD.Protocols.Modbus.Tcp/Utils/RequestQueueItem.cs @@ -0,0 +1,21 @@ +using System; +using System.Collections.Generic; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace AMWD.Protocols.Modbus.Tcp.Utils +{ + 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; } + } +} diff --git a/AMWD.Protocols.Modbus.Tcp/Utils/SocketWrapper.cs b/AMWD.Protocols.Modbus.Tcp/Utils/SocketWrapper.cs deleted file mode 100644 index 57c5782..0000000 --- a/AMWD.Protocols.Modbus.Tcp/Utils/SocketWrapper.cs +++ /dev/null @@ -1,39 +0,0 @@ -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!", error: true)] - 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 index 1d3f29f..674fa35 100644 --- a/AMWD.Protocols.Modbus.Tcp/Utils/TcpClientWrapper.cs +++ b/AMWD.Protocols.Modbus.Tcp/Utils/TcpClientWrapper.cs @@ -10,8 +10,17 @@ namespace AMWD.Protocols.Modbus.Tcp.Utils [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] internal class TcpClientWrapper : IDisposable { + #region Fields + private readonly TcpClient _client = new(); + #endregion Fields + + #region Properties + + /// + public virtual bool Connected => _client.Connected; + /// public virtual int ReceiveTimeout { @@ -26,15 +35,9 @@ namespace AMWD.Protocols.Modbus.Tcp.Utils set => _client.SendTimeout = value; } - /// - public virtual bool Connected => _client.Connected; + #endregion Properties - /// - public virtual SocketWrapper Client - { - get => new(_client.Client); - set => _client.Client = value.Client; - } + #region Methods /// public virtual void Close() @@ -52,12 +55,18 @@ namespace AMWD.Protocols.Modbus.Tcp.Utils #endif + /// + public virtual NetworkStreamWrapper GetStream() + => new(_client.GetStream()); + + #endregion Methods + + #region IDisposable + /// public virtual void Dispose() => _client.Dispose(); - /// - public virtual NetworkStreamWrapper GetStream() - => new(_client.GetStream()); + #endregion IDisposable } } diff --git a/AMWD.Protocols.Modbus.Tests/Common/Contracts/ModbusClientBaseTest.cs b/AMWD.Protocols.Modbus.Tests/Common/Contracts/ModbusClientBaseTest.cs index 1f22f2b..963089f 100644 --- a/AMWD.Protocols.Modbus.Tests/Common/Contracts/ModbusClientBaseTest.cs +++ b/AMWD.Protocols.Modbus.Tests/Common/Contracts/ModbusClientBaseTest.cs @@ -20,7 +20,6 @@ namespace AMWD.Protocols.Modbus.Tests.Common.Contracts private Mock _protocol; // Responses - private bool _connectionIsConnectecd; private List _readCoilsResponse; private List _readDiscreteInputsResponse; private List _readHoldingRegistersResponse; @@ -35,8 +34,6 @@ namespace AMWD.Protocols.Modbus.Tests.Common.Contracts [TestInitialize] public void Initialize() { - _connectionIsConnectecd = true; - _readCoilsResponse = []; _readDiscreteInputsResponse = []; _readHoldingRegistersResponse = []; @@ -75,13 +72,13 @@ namespace AMWD.Protocols.Modbus.Tests.Common.Contracts MoreRequestsNeeded = false, NextObjectIdToRequest = 0x00, }; - _firstDeviceIdentificationResponse.Objects.Add(0x00, Encoding.ASCII.GetBytes("AM.WD")); - _firstDeviceIdentificationResponse.Objects.Add(0x01, Encoding.ASCII.GetBytes("AMWD-MB")); - _firstDeviceIdentificationResponse.Objects.Add(0x02, Encoding.ASCII.GetBytes("1.2.3")); - _firstDeviceIdentificationResponse.Objects.Add(0x03, Encoding.ASCII.GetBytes("https://github.com/AM-WD/AMWD.Protocols.Modbus")); - _firstDeviceIdentificationResponse.Objects.Add(0x04, Encoding.ASCII.GetBytes("AM.WD Modbus Library")); - _firstDeviceIdentificationResponse.Objects.Add(0x05, Encoding.ASCII.GetBytes("UnitTests")); - _firstDeviceIdentificationResponse.Objects.Add(0x06, Encoding.ASCII.GetBytes("Modbus Client Base Unit Test")); + _firstDeviceIdentificationResponse.Objects.Add(0x00, Encoding.UTF8.GetBytes("AM.WD")); + _firstDeviceIdentificationResponse.Objects.Add(0x01, Encoding.UTF8.GetBytes("AMWD-MB")); + _firstDeviceIdentificationResponse.Objects.Add(0x02, Encoding.UTF8.GetBytes("1.2.3")); + _firstDeviceIdentificationResponse.Objects.Add(0x03, Encoding.UTF8.GetBytes("https://github.com/AM-WD/AMWD.Protocols.Modbus")); + _firstDeviceIdentificationResponse.Objects.Add(0x04, Encoding.UTF8.GetBytes("AM.WD Modbus Library")); + _firstDeviceIdentificationResponse.Objects.Add(0x05, Encoding.UTF8.GetBytes("UnitTests")); + _firstDeviceIdentificationResponse.Objects.Add(0x06, Encoding.UTF8.GetBytes("Modbus Client Base Unit Test")); _deviceIdentificationResponseQueue = new Queue(); _deviceIdentificationResponseQueue.Enqueue(_firstDeviceIdentificationResponse); @@ -121,38 +118,6 @@ namespace AMWD.Protocols.Modbus.Tests.Common.Contracts // Assert - ArgumentNullException } - [TestMethod] - public async Task ShouldConnectSuccessfully() - { - // Arrange - var client = GetClient(); - - // Act - await client.ConnectAsync(); - - // Assert - _connection.Verify(c => c.ConnectAsync(It.IsAny()), Times.Once); - _connection.VerifyNoOtherCalls(); - - _protocol.VerifyNoOtherCalls(); - } - - [TestMethod] - public async Task ShouldDisconnectSuccessfully() - { - // Arrange - var client = GetClient(); - - // Act - await client.DisconnectAsync(); - - // Assert - _connection.Verify(c => c.DisconnectAsync(It.IsAny()), Times.Once); - _connection.VerifyNoOtherCalls(); - - _protocol.VerifyNoOtherCalls(); - } - [DataTestMethod] [DataRow(true)] [DataRow(false)] @@ -218,20 +183,6 @@ namespace AMWD.Protocols.Modbus.Tests.Common.Contracts // Assert - ArgumentNullException } - [TestMethod] - [ExpectedException(typeof(ApplicationException))] - public async Task ShouldAssertConnected() - { - // Arrange - _connectionIsConnectecd = false; - var client = GetClient(); - - // Act - await client.ReadCoilsAsync(UNIT_ID, START_ADDRESS, READ_COUNT); - - // Assert - ApplicationException - } - #endregion Common/Connection/Assertions #region Read @@ -256,7 +207,6 @@ namespace AMWD.Protocols.Modbus.Tests.Common.Contracts Assert.AreEqual(i % 2 == 0, result[i].Value); } - _connection.VerifyGet(c => c.IsConnected, Times.Once); _connection.Verify(c => c.InvokeAsync(It.IsAny>(), It.IsAny, bool>>(), It.IsAny()), Times.Once); _connection.VerifyNoOtherCalls(); @@ -286,7 +236,6 @@ namespace AMWD.Protocols.Modbus.Tests.Common.Contracts Assert.AreEqual(i % 2 == 1, result[i].Value); } - _connection.VerifyGet(c => c.IsConnected, Times.Once); _connection.Verify(c => c.InvokeAsync(It.IsAny>(), It.IsAny, bool>>(), It.IsAny()), Times.Once); _connection.VerifyNoOtherCalls(); @@ -315,7 +264,6 @@ namespace AMWD.Protocols.Modbus.Tests.Common.Contracts Assert.AreEqual(i + 10, result[i].Value); } - _connection.VerifyGet(c => c.IsConnected, Times.Once); _connection.Verify(c => c.InvokeAsync(It.IsAny>(), It.IsAny, bool>>(), It.IsAny()), Times.Once); _connection.VerifyNoOtherCalls(); @@ -344,7 +292,6 @@ namespace AMWD.Protocols.Modbus.Tests.Common.Contracts Assert.AreEqual(i + 15, result[i].Value); } - _connection.VerifyGet(c => c.IsConnected, Times.Once); _connection.Verify(c => c.InvokeAsync(It.IsAny>(), It.IsAny, bool>>(), It.IsAny()), Times.Once); _connection.VerifyNoOtherCalls(); @@ -376,7 +323,6 @@ namespace AMWD.Protocols.Modbus.Tests.Common.Contracts Assert.AreEqual(0, result.ExtendedObjects.Count); - _connection.VerifyGet(c => c.IsConnected, Times.Once); _connection.Verify(c => c.InvokeAsync(It.IsAny>(), It.IsAny, bool>>(), It.IsAny()), Times.Once); _connection.VerifyNoOtherCalls(); @@ -422,7 +368,6 @@ namespace AMWD.Protocols.Modbus.Tests.Common.Contracts Assert.AreEqual(0x07, result.ExtendedObjects.First().Key); CollectionAssert.AreEqual(new byte[] { 0x01, 0x02, 0x03 }, result.ExtendedObjects.First().Value); - _connection.VerifyGet(c => c.IsConnected, Times.Once); _connection.Verify(c => c.InvokeAsync(It.IsAny>(), It.IsAny, bool>>(), It.IsAny()), Times.Exactly(2)); _connection.VerifyNoOtherCalls(); @@ -454,7 +399,6 @@ namespace AMWD.Protocols.Modbus.Tests.Common.Contracts // Assert Assert.IsTrue(result); - _connection.VerifyGet(c => c.IsConnected, Times.Once); _connection.Verify(c => c.InvokeAsync(It.IsAny>(), It.IsAny, bool>>(), It.IsAny()), Times.Once); _connection.VerifyNoOtherCalls(); @@ -481,7 +425,6 @@ namespace AMWD.Protocols.Modbus.Tests.Common.Contracts // Assert Assert.IsFalse(result); - _connection.VerifyGet(c => c.IsConnected, Times.Once); _connection.Verify(c => c.InvokeAsync(It.IsAny>(), It.IsAny, bool>>(), It.IsAny()), Times.Once); _connection.VerifyNoOtherCalls(); @@ -508,7 +451,6 @@ namespace AMWD.Protocols.Modbus.Tests.Common.Contracts // Assert Assert.IsFalse(result); - _connection.VerifyGet(c => c.IsConnected, Times.Once); _connection.Verify(c => c.InvokeAsync(It.IsAny>(), It.IsAny, bool>>(), It.IsAny()), Times.Once); _connection.VerifyNoOtherCalls(); @@ -535,7 +477,6 @@ namespace AMWD.Protocols.Modbus.Tests.Common.Contracts // Assert Assert.IsTrue(result); - _connection.VerifyGet(c => c.IsConnected, Times.Once); _connection.Verify(c => c.InvokeAsync(It.IsAny>(), It.IsAny, bool>>(), It.IsAny()), Times.Once); _connection.VerifyNoOtherCalls(); @@ -562,7 +503,6 @@ namespace AMWD.Protocols.Modbus.Tests.Common.Contracts // Assert Assert.IsFalse(result); - _connection.VerifyGet(c => c.IsConnected, Times.Once); _connection.Verify(c => c.InvokeAsync(It.IsAny>(), It.IsAny, bool>>(), It.IsAny()), Times.Once); _connection.VerifyNoOtherCalls(); @@ -589,7 +529,6 @@ namespace AMWD.Protocols.Modbus.Tests.Common.Contracts // Assert Assert.IsFalse(result); - _connection.VerifyGet(c => c.IsConnected, Times.Once); _connection.Verify(c => c.InvokeAsync(It.IsAny>(), It.IsAny, bool>>(), It.IsAny()), Times.Once); _connection.VerifyNoOtherCalls(); @@ -620,7 +559,6 @@ namespace AMWD.Protocols.Modbus.Tests.Common.Contracts // Assert Assert.IsTrue(result); - _connection.VerifyGet(c => c.IsConnected, Times.Once); _connection.Verify(c => c.InvokeAsync(It.IsAny>(), It.IsAny, bool>>(), It.IsAny()), Times.Once); _connection.VerifyNoOtherCalls(); @@ -652,7 +590,6 @@ namespace AMWD.Protocols.Modbus.Tests.Common.Contracts // Assert Assert.IsFalse(result); - _connection.VerifyGet(c => c.IsConnected, Times.Once); _connection.Verify(c => c.InvokeAsync(It.IsAny>(), It.IsAny, bool>>(), It.IsAny()), Times.Once); _connection.VerifyNoOtherCalls(); @@ -684,7 +621,6 @@ namespace AMWD.Protocols.Modbus.Tests.Common.Contracts // Assert Assert.IsFalse(result); - _connection.VerifyGet(c => c.IsConnected, Times.Once); _connection.Verify(c => c.InvokeAsync(It.IsAny>(), It.IsAny, bool>>(), It.IsAny()), Times.Once); _connection.VerifyNoOtherCalls(); @@ -715,7 +651,6 @@ namespace AMWD.Protocols.Modbus.Tests.Common.Contracts // Assert Assert.IsTrue(result); - _connection.VerifyGet(c => c.IsConnected, Times.Once); _connection.Verify(c => c.InvokeAsync(It.IsAny>(), It.IsAny, bool>>(), It.IsAny()), Times.Once); _connection.VerifyNoOtherCalls(); @@ -747,7 +682,6 @@ namespace AMWD.Protocols.Modbus.Tests.Common.Contracts // Assert Assert.IsFalse(result); - _connection.VerifyGet(c => c.IsConnected, Times.Once); _connection.Verify(c => c.InvokeAsync(It.IsAny>(), It.IsAny, bool>>(), It.IsAny()), Times.Once); _connection.VerifyNoOtherCalls(); @@ -779,7 +713,6 @@ namespace AMWD.Protocols.Modbus.Tests.Common.Contracts // Assert Assert.IsFalse(result); - _connection.VerifyGet(c => c.IsConnected, Times.Once); _connection.Verify(c => c.InvokeAsync(It.IsAny>(), It.IsAny, bool>>(), It.IsAny()), Times.Once); _connection.VerifyNoOtherCalls(); @@ -797,9 +730,6 @@ namespace AMWD.Protocols.Modbus.Tests.Common.Contracts _connection .SetupGet(c => c.Name) .Returns("Mock"); - _connection - .SetupGet(c => c.IsConnected) - .Returns(() => _connectionIsConnectecd); _protocol = new Mock(); _protocol diff --git a/AMWD.Protocols.Modbus.Tests/Tcp/ModbusTcpClientTest.cs b/AMWD.Protocols.Modbus.Tests/Tcp/ModbusTcpClientTest.cs index 762040a..e4bc49c 100644 --- a/AMWD.Protocols.Modbus.Tests/Tcp/ModbusTcpClientTest.cs +++ b/AMWD.Protocols.Modbus.Tests/Tcp/ModbusTcpClientTest.cs @@ -19,8 +19,8 @@ namespace AMWD.Protocols.Modbus.Tests.Tcp _tcpConnectionMock.Setup(c => c.Port).Returns(502); _tcpConnectionMock.Setup(c => c.ReadTimeout).Returns(TimeSpan.FromSeconds(10)); _tcpConnectionMock.Setup(c => c.WriteTimeout).Returns(TimeSpan.FromSeconds(20)); - _tcpConnectionMock.Setup(c => c.ReconnectTimeout).Returns(TimeSpan.FromSeconds(30)); - _tcpConnectionMock.Setup(c => c.KeepAliveInterval).Returns(TimeSpan.FromSeconds(40)); + _tcpConnectionMock.Setup(c => c.ConnectTimeout).Returns(TimeSpan.FromSeconds(30)); + _tcpConnectionMock.Setup(c => c.IdleTimeout).Returns(TimeSpan.FromSeconds(40)); } [TestMethod] @@ -35,7 +35,7 @@ namespace AMWD.Protocols.Modbus.Tests.Tcp TimeSpan readTimeout = client.ReadTimeout; TimeSpan writeTimeout = client.WriteTimeout; TimeSpan reconnectTimeout = client.ReconnectTimeout; - TimeSpan keepAliveInterval = client.KeepAliveInterval; + TimeSpan idleTimeout = client.IdleTimeout; // Assert Assert.IsNull(hostname); @@ -43,7 +43,7 @@ namespace AMWD.Protocols.Modbus.Tests.Tcp Assert.AreEqual(TimeSpan.Zero, readTimeout); Assert.AreEqual(TimeSpan.Zero, writeTimeout); Assert.AreEqual(TimeSpan.Zero, reconnectTimeout); - Assert.AreEqual(TimeSpan.Zero, keepAliveInterval); + Assert.AreEqual(TimeSpan.Zero, idleTimeout); _genericConnectionMock.VerifyNoOtherCalls(); } @@ -60,7 +60,7 @@ namespace AMWD.Protocols.Modbus.Tests.Tcp client.ReadTimeout = TimeSpan.FromSeconds(123); client.WriteTimeout = TimeSpan.FromSeconds(456); client.ReconnectTimeout = TimeSpan.FromSeconds(789); - client.KeepAliveInterval = TimeSpan.FromSeconds(321); + client.IdleTimeout = TimeSpan.FromSeconds(321); // Assert _genericConnectionMock.VerifyNoOtherCalls(); @@ -78,7 +78,7 @@ namespace AMWD.Protocols.Modbus.Tests.Tcp TimeSpan readTimeout = client.ReadTimeout; TimeSpan writeTimeout = client.WriteTimeout; TimeSpan reconnectTimeout = client.ReconnectTimeout; - TimeSpan keepAliveInterval = client.KeepAliveInterval; + TimeSpan keepAliveInterval = client.IdleTimeout; // Assert Assert.AreEqual("127.0.0.1", hostname); @@ -92,8 +92,8 @@ namespace AMWD.Protocols.Modbus.Tests.Tcp _tcpConnectionMock.VerifyGet(c => c.Port, Times.Once); _tcpConnectionMock.VerifyGet(c => c.ReadTimeout, Times.Once); _tcpConnectionMock.VerifyGet(c => c.WriteTimeout, Times.Once); - _tcpConnectionMock.VerifyGet(c => c.ReconnectTimeout, Times.Once); - _tcpConnectionMock.VerifyGet(c => c.KeepAliveInterval, Times.Once); + _tcpConnectionMock.VerifyGet(c => c.ConnectTimeout, Times.Once); + _tcpConnectionMock.VerifyGet(c => c.IdleTimeout, Times.Once); _tcpConnectionMock.VerifyNoOtherCalls(); } @@ -109,15 +109,15 @@ namespace AMWD.Protocols.Modbus.Tests.Tcp client.ReadTimeout = TimeSpan.FromSeconds(123); client.WriteTimeout = TimeSpan.FromSeconds(456); client.ReconnectTimeout = TimeSpan.FromSeconds(789); - client.KeepAliveInterval = TimeSpan.FromSeconds(321); + client.IdleTimeout = TimeSpan.FromSeconds(321); // Assert _tcpConnectionMock.VerifySet(c => c.Hostname = "localhost", Times.Once); _tcpConnectionMock.VerifySet(c => c.Port = 205, Times.Once); _tcpConnectionMock.VerifySet(c => c.ReadTimeout = TimeSpan.FromSeconds(123), Times.Once); _tcpConnectionMock.VerifySet(c => c.WriteTimeout = TimeSpan.FromSeconds(456), Times.Once); - _tcpConnectionMock.VerifySet(c => c.ReconnectTimeout = TimeSpan.FromSeconds(789), Times.Once); - _tcpConnectionMock.VerifySet(c => c.KeepAliveInterval = TimeSpan.FromSeconds(321), Times.Once); + _tcpConnectionMock.VerifySet(c => c.ConnectTimeout = TimeSpan.FromSeconds(789), Times.Once); + _tcpConnectionMock.VerifySet(c => c.IdleTimeout = TimeSpan.FromSeconds(321), Times.Once); _tcpConnectionMock.VerifyNoOtherCalls(); } } diff --git a/AMWD.Protocols.Modbus.Tests/Tcp/ModbusTcpConnectionTest.cs b/AMWD.Protocols.Modbus.Tests/Tcp/ModbusTcpConnectionTest.cs new file mode 100644 index 0000000..5ca6cc4 --- /dev/null +++ b/AMWD.Protocols.Modbus.Tests/Tcp/ModbusTcpConnectionTest.cs @@ -0,0 +1,545 @@ +using System.Collections.Generic; +using System.IO; +using System.Net; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using AMWD.Protocols.Modbus.Common.Contracts; +using AMWD.Protocols.Modbus.Tcp; +using AMWD.Protocols.Modbus.Tcp.Utils; +using Moq; + +namespace AMWD.Protocols.Modbus.Tests.Tcp +{ + [TestClass] + public class ModbusTcpConnectionTest + { + private readonly string _hostname = "127.0.0.1"; + + private Mock _tcpClientMock; + private Mock _networkStreamMock; + + private bool _alwaysConnected; + private Queue _connectedQueue; + + private readonly int _clientReceiveTimeout = 1000; + private readonly int _clientSendTimeout = 1000; + private readonly Task _clientConnectTask = Task.CompletedTask; + + private List _networkRequestCallbacks; + + private Queue _networkResponseQueue; + + [TestInitialize] + public void Initialize() + { + _alwaysConnected = true; + _connectedQueue = new Queue(); + + _networkRequestCallbacks = []; + _networkResponseQueue = new Queue(); + } + + [TestMethod] + public void ShouldGetAndSetPropertiesOfBaseClient() + { + // Arrange + var connection = GetTcpConnection(); + + // Act + connection.ReadTimeout = TimeSpan.FromSeconds(123); + connection.WriteTimeout = TimeSpan.FromSeconds(456); + + // Assert - part 1 + Assert.AreEqual("TCP", connection.Name); + Assert.AreEqual(1, connection.ReadTimeout.TotalSeconds); + Assert.AreEqual(1, connection.WriteTimeout.TotalSeconds); + + Assert.AreEqual(_hostname, connection.Hostname); + Assert.AreEqual(502, connection.Port); + + // Assert - part 2 + _tcpClientMock.VerifySet(c => c.ReceiveTimeout = 123000, Times.Once); + _tcpClientMock.VerifySet(c => c.SendTimeout = 456000, Times.Once); + + _tcpClientMock.VerifyGet(c => c.ReceiveTimeout, Times.Once); + _tcpClientMock.VerifyGet(c => c.SendTimeout, Times.Once); + + _tcpClientMock.VerifyNoOtherCalls(); + _networkStreamMock.VerifyNoOtherCalls(); + } + + [DataTestMethod] + [DataRow(null)] + [DataRow("")] + [DataRow(" ")] + [ExpectedException(typeof(ArgumentNullException))] + public void ShouldThrowArgumentNullExceptionForInvalidHostname(string hostname) + { + // Arrange + var connection = GetTcpConnection(); + + // Act + connection.Hostname = hostname; + + // Assert - ArgumentNullException + } + + [DataTestMethod] + [DataRow(0)] + [DataRow(65536)] + [ExpectedException(typeof(ArgumentOutOfRangeException))] + public void ShouldThrowArgumentOutOfRangeExceptionForInvalidPort(int port) + { + // Arrange + var connection = GetTcpConnection(); + + // Act + connection.Port = port; + + // Assert - ArgumentOutOfRangeException + } + + [TestMethod] + public void ShouldBeAbleToDisposeMultipleTimes() + { + // Arrange + var connection = GetConnection(); + + // Act + connection.Dispose(); + connection.Dispose(); + } + + [TestMethod] + [ExpectedException(typeof(ObjectDisposedException))] + public async Task ShouldThrowDisposedExceptionOnInvokeAsync() + { + // Arrange + var connection = GetConnection(); + connection.Dispose(); + + // Act + await connection.InvokeAsync(null, null); + + // Assert - OjbectDisposedException + } + + [DataTestMethod] + [DataRow(null)] + [DataRow(new byte[0])] + [ExpectedException(typeof(ArgumentNullException))] + public async Task ShouldThrowArgumentNullExceptionForMissingRequestOnInvokeAsync(byte[] request) + { + // Arrange + var connection = GetConnection(); + + // Act + await connection.InvokeAsync(request, null); + + // Assert - ArgumentNullException + } + + [TestMethod] + [ExpectedException(typeof(ArgumentNullException))] + public async Task ShouldThrowArgumentNullExceptionForMissingValidationOnInvokeAsync() + { + // Arrange + byte[] request = new byte[1]; + var connection = GetConnection(); + + // Act + await connection.InvokeAsync(request, null); + + // Assert - ArgumentNullException + } + + [TestMethod] + public async Task ShouldInvokeAsync() + { + // Arrange + byte[] request = [1, 2, 3]; + byte[] expectedResponse = [9, 8, 7]; + var validation = new Func, bool>(_ => true); + _networkResponseQueue.Enqueue(expectedResponse); + + var connection = GetConnection(); + + // Act + var response = await connection.InvokeAsync(request, validation); + + // Assert + Assert.IsNotNull(response); + + CollectionAssert.AreEqual(expectedResponse, response.ToArray()); + CollectionAssert.AreEqual(request, _networkRequestCallbacks.First()); + + _tcpClientMock.Verify(c => c.Connected, Times.Once); + _tcpClientMock.Verify(c => c.GetStream(), Times.Once); + + _networkStreamMock.Verify(ns => ns.FlushAsync(It.IsAny()), Times.Once); + _networkStreamMock.Verify(ns => ns.WriteAsync(It.IsAny>(), It.IsAny()), Times.Once); + _networkStreamMock.Verify(ns => ns.ReadAsync(It.IsAny>(), It.IsAny()), Times.Once); + + _tcpClientMock.VerifyNoOtherCalls(); + _networkStreamMock.VerifyNoOtherCalls(); + } + + [TestMethod] + public async Task ShouldConnectAndDisconnectOnInvokeAsync() + { + // Arrange + _alwaysConnected = false; + _connectedQueue.Enqueue(false); + _connectedQueue.Enqueue(true); + _connectedQueue.Enqueue(true); + + byte[] request = [1, 2, 3]; + byte[] expectedResponse = [9, 8, 7]; + var validation = new Func, bool>(_ => true); + _networkResponseQueue.Enqueue(expectedResponse); + + var connection = GetConnection(); + connection.IdleTimeout = TimeSpan.FromMilliseconds(200); + + // Act + var response = await connection.InvokeAsync(request, validation); + await Task.Delay(500); + + // Assert + Assert.IsNotNull(response); + + CollectionAssert.AreEqual(expectedResponse, response.ToArray()); + CollectionAssert.AreEqual(request, _networkRequestCallbacks.First()); + + _tcpClientMock.VerifyGet(c => c.ReceiveTimeout, Times.Once); + + _tcpClientMock.Verify(c => c.Connected, Times.Exactly(3)); + _tcpClientMock.Verify(c => c.Close(), Times.Exactly(2)); + _tcpClientMock.Verify(c => c.ConnectAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + _tcpClientMock.Verify(c => c.GetStream(), Times.Once); + + _networkStreamMock.Verify(ns => ns.FlushAsync(It.IsAny()), Times.Once); + _networkStreamMock.Verify(ns => ns.WriteAsync(It.IsAny>(), It.IsAny()), Times.Once); + _networkStreamMock.Verify(ns => ns.ReadAsync(It.IsAny>(), It.IsAny()), Times.Once); + + _tcpClientMock.VerifyNoOtherCalls(); + _networkStreamMock.VerifyNoOtherCalls(); + } + + [TestMethod] + [ExpectedException(typeof(EndOfStreamException))] + public async Task ShouldThrowEndOfStreamExceptionOnInvokeAsync() + { + // Arrange + byte[] request = [1, 2, 3]; + var validation = new Func, bool>(_ => true); + + var connection = GetConnection(); + + // Act + var response = await connection.InvokeAsync(request, validation); + + // Assert - EndOfStreamException + } + + [TestMethod] + [ExpectedException(typeof(ApplicationException))] + public async Task ShouldThrowApplicationExceptionWhenHostNotResolvableOnInvokeAsync() + { + // Arrange + _alwaysConnected = false; + _connectedQueue.Enqueue(false); + + byte[] request = [1, 2, 3]; + var validation = new Func, bool>(_ => true); + + var connection = GetConnection(); + connection.GetType().GetField("_hostname", BindingFlags.NonPublic | BindingFlags.Instance).SetValue(connection, ""); + + // Act + var response = await connection.InvokeAsync(request, validation); + + // Assert - ApplicationException + } + + [TestMethod] + public async Task ShouldSkipCloseOnTimeoutOnInvokeAsync() + { + // Arrange + _alwaysConnected = false; + _connectedQueue.Enqueue(false); + _connectedQueue.Enqueue(true); + _connectedQueue.Enqueue(false); + + byte[] request = [1, 2, 3]; + byte[] expectedResponse = [9, 8, 7]; + var validation = new Func, bool>(_ => true); + _networkResponseQueue.Enqueue(expectedResponse); + + var connection = GetConnection(); + connection.IdleTimeout = TimeSpan.FromMilliseconds(200); + + // Act + var response = await connection.InvokeAsync(request, validation); + await Task.Delay(500); + + // Assert + Assert.IsNotNull(response); + + CollectionAssert.AreEqual(expectedResponse, response.ToArray()); + CollectionAssert.AreEqual(request, _networkRequestCallbacks.First()); + + _tcpClientMock.VerifyGet(c => c.ReceiveTimeout, Times.Once); + + _tcpClientMock.Verify(c => c.Connected, Times.Exactly(3)); + _tcpClientMock.Verify(c => c.Close(), Times.Once); + _tcpClientMock.Verify(c => c.ConnectAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + _tcpClientMock.Verify(c => c.GetStream(), Times.Once); + + _networkStreamMock.Verify(ns => ns.FlushAsync(It.IsAny()), Times.Once); + _networkStreamMock.Verify(ns => ns.WriteAsync(It.IsAny>(), It.IsAny()), Times.Once); + _networkStreamMock.Verify(ns => ns.ReadAsync(It.IsAny>(), It.IsAny()), Times.Once); + + _tcpClientMock.VerifyNoOtherCalls(); + _networkStreamMock.VerifyNoOtherCalls(); + } + + [TestMethod] + public async Task ShouldRetryToConnectOnInvokeAsync() + { + // Arrange + _alwaysConnected = false; + _connectedQueue.Enqueue(false); + _connectedQueue.Enqueue(false); + _connectedQueue.Enqueue(true); + + byte[] request = [1, 2, 3]; + byte[] expectedResponse = [9, 8, 7]; + var validation = new Func, bool>(_ => true); + _networkResponseQueue.Enqueue(expectedResponse); + + var connection = GetConnection(); + + // Act + var response = await connection.InvokeAsync(request, validation); + + // Assert + Assert.IsNotNull(response); + + CollectionAssert.AreEqual(expectedResponse, response.ToArray()); + CollectionAssert.AreEqual(request, _networkRequestCallbacks.First()); + + _tcpClientMock.VerifyGet(c => c.ReceiveTimeout, Times.Exactly(2)); + + _tcpClientMock.Verify(c => c.Connected, Times.Exactly(3)); + _tcpClientMock.Verify(c => c.Close(), Times.Exactly(2)); + _tcpClientMock.Verify(c => c.ConnectAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Exactly(2)); + _tcpClientMock.Verify(c => c.GetStream(), Times.Once); + + _networkStreamMock.Verify(ns => ns.FlushAsync(It.IsAny()), Times.Once); + _networkStreamMock.Verify(ns => ns.WriteAsync(It.IsAny>(), It.IsAny()), Times.Once); + _networkStreamMock.Verify(ns => ns.ReadAsync(It.IsAny>(), It.IsAny()), Times.Once); + + _tcpClientMock.VerifyNoOtherCalls(); + _networkStreamMock.VerifyNoOtherCalls(); + } + + [TestMethod] + [ExpectedException(typeof(TaskCanceledException))] + public async Task ShouldThrowTaskCancelledExceptionForDisposeOnInvokeAsync() + { + // Arrange + byte[] request = [1, 2, 3]; + var validation = new Func, bool>(_ => true); + + var connection = GetConnection(); + _networkStreamMock + .Setup(ns => ns.WriteAsync(It.IsAny>(), It.IsAny())) + .Returns(new ValueTask(Task.Delay(100))); + + // Act + var task = connection.InvokeAsync(request, validation); + connection.Dispose(); + await task; + + // Assert - TaskCancelledException + } + + [TestMethod] + [ExpectedException(typeof(TaskCanceledException))] + public async Task ShouldThrowTaskCancelledExceptionForCancelOnInvokeAsync() + { + // Arrange + byte[] request = [1, 2, 3]; + var validation = new Func, bool>(_ => true); + using var cts = new CancellationTokenSource(); + + var connection = GetConnection(); + _networkStreamMock + .Setup(ns => ns.WriteAsync(It.IsAny>(), It.IsAny())) + .Returns(new ValueTask(Task.Delay(100))); + + // Act + var task = connection.InvokeAsync(request, validation, cts.Token); + cts.Cancel(); + await task; + + // Assert - TaskCancelledException + } + + [TestMethod] + public async Task ShouldRemoveRequestFromQueueOnInvokeAsync() + { + // Arrange + byte[] request = [1, 2, 3]; + byte[] expectedResponse = [9, 8, 7]; + var validation = new Func, bool>(_ => true); + _networkResponseQueue.Enqueue(expectedResponse); + using var cts = new CancellationTokenSource(); + + var connection = GetConnection(); + _networkStreamMock + .Setup(ns => ns.WriteAsync(It.IsAny>(), It.IsAny())) + .Callback, CancellationToken>((req, _) => _networkRequestCallbacks.Add(req.ToArray())) + .Returns(new ValueTask(Task.Delay(100))); + + // Act + var taskToComplete = connection.InvokeAsync(request, validation); + + var taskToCancel = connection.InvokeAsync(request, validation, cts.Token); + cts.Cancel(); + + var response = await taskToComplete; + + // Assert - Part 1 + try + { + await taskToCancel; + Assert.Fail(); + } + catch (TaskCanceledException) + { /* expected exception */ } + + // Assert - Part 2 + Assert.AreEqual(1, _networkRequestCallbacks.Count); + CollectionAssert.AreEqual(request, _networkRequestCallbacks.First()); + CollectionAssert.AreEqual(expectedResponse, response.ToArray()); + + _tcpClientMock.Verify(c => c.Connected, Times.Once); + _tcpClientMock.Verify(c => c.GetStream(), Times.Once); + + _networkStreamMock.Verify(ns => ns.FlushAsync(It.IsAny()), Times.Once); + _networkStreamMock.Verify(ns => ns.WriteAsync(It.IsAny>(), It.IsAny()), Times.Once); + _networkStreamMock.Verify(ns => ns.ReadAsync(It.IsAny>(), It.IsAny()), Times.Once); + + _tcpClientMock.VerifyNoOtherCalls(); + _networkStreamMock.VerifyNoOtherCalls(); + } + + [TestMethod] + public async Task ShouldRemoveRequestFromQueueOnDispose() + { + // Arrange + byte[] request = [1, 2, 3]; + var validation = new Func, bool>(_ => true); + + var connection = GetConnection(); + _networkStreamMock + .Setup(ns => ns.WriteAsync(It.IsAny>(), It.IsAny())) + .Callback, CancellationToken>((req, _) => _networkRequestCallbacks.Add(req.ToArray())) + .Returns(new ValueTask(Task.Delay(100))); + + // Act + var taskToCancel = connection.InvokeAsync(request, validation); + var taskToDequeue = connection.InvokeAsync(request, validation); + connection.Dispose(); + + // Assert + try + { + await taskToCancel; + Assert.Fail(); + } + catch (TaskCanceledException) + { /* expected exception */ } + + try + { + await taskToDequeue; + Assert.Fail(); + } + catch (ObjectDisposedException) + { /* expected exception */ } + + Assert.AreEqual(1, _networkRequestCallbacks.Count); + CollectionAssert.AreEqual(request, _networkRequestCallbacks.First()); + + _tcpClientMock.Verify(c => c.Connected, Times.Once); + _tcpClientMock.Verify(c => c.GetStream(), Times.Once); + _tcpClientMock.Verify(c => c.Dispose(), Times.Once); + + _networkStreamMock.Verify(ns => ns.FlushAsync(It.IsAny()), Times.Once); + _networkStreamMock.Verify(ns => ns.WriteAsync(It.IsAny>(), It.IsAny()), Times.Once); + + _tcpClientMock.VerifyNoOtherCalls(); + _networkStreamMock.VerifyNoOtherCalls(); + } + + private IModbusConnection GetConnection() + => GetTcpConnection(); + + private ModbusTcpConnection GetTcpConnection() + { + _networkStreamMock = new Mock(); + _networkStreamMock + .Setup(ns => ns.WriteAsync(It.IsAny>(), It.IsAny())) + .Callback, CancellationToken>((req, _) => _networkRequestCallbacks.Add(req.ToArray())) + .Returns(ValueTask.CompletedTask); + _networkStreamMock + .Setup(ns => ns.ReadAsync(It.IsAny>(), It.IsAny())) + .Returns, CancellationToken>((buffer, _) => + { + if (_networkResponseQueue.TryDequeue(out byte[] bytes)) + { + bytes.CopyTo(buffer); + return ValueTask.FromResult(bytes.Length); + } + + return ValueTask.FromResult(0); + }); + + _tcpClientMock = new Mock(); + _tcpClientMock.Setup(c => c.Connected).Returns(() => _alwaysConnected || _connectedQueue.Dequeue()); + _tcpClientMock.Setup(c => c.ReceiveTimeout).Returns(() => _clientReceiveTimeout); + _tcpClientMock.Setup(c => c.SendTimeout).Returns(() => _clientSendTimeout); + + _tcpClientMock + .Setup(c => c.ConnectAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(() => _clientConnectTask); + + _tcpClientMock + .Setup(c => c.GetStream()) + .Returns(() => _networkStreamMock.Object); + + var connection = new ModbusTcpConnection + { + Hostname = _hostname, + Port = 502 + }; + + // Replace real TCP client with mock + var clientField = connection.GetType().GetField("_client", BindingFlags.NonPublic | BindingFlags.Instance); + (clientField.GetValue(connection) as TcpClientWrapper)?.Dispose(); + clientField.SetValue(connection, _tcpClientMock.Object); + + return connection; + } + + private void ClearInvocations() + { + _networkStreamMock.Invocations.Clear(); + _tcpClientMock.Invocations.Clear(); + } + } +} diff --git a/AMWD.Protocols.Modbus.Tests/Tcp/Utils/ModbusTcpConnectionTest.cs b/AMWD.Protocols.Modbus.Tests/Tcp/Utils/ModbusTcpConnectionTest.cs deleted file mode 100644 index 4ca5dda..0000000 --- a/AMWD.Protocols.Modbus.Tests/Tcp/Utils/ModbusTcpConnectionTest.cs +++ /dev/null @@ -1,723 +0,0 @@ -using System.Collections.Generic; -using System.IO; -using System.Net; -using System.Net.Sockets; -using System.Reflection; -using System.Threading; -using System.Threading.Tasks; -using AMWD.Protocols.Modbus.Common.Contracts; -using AMWD.Protocols.Modbus.Tcp; -using AMWD.Protocols.Modbus.Tcp.Utils; -using Moq; - -namespace AMWD.Protocols.Modbus.Tests.Tcp.Utils -{ - [TestClass] - public class ModbusTcpConnectionTest - { - private string _hostname = "127.0.0.1"; - - private Mock _tcpClientMock; - private Mock _networkStreamMock; - private Mock _socketMock; - - private bool _clientIsAlwaysConnected; - private Queue _clientIsConnectedQueue; - - private int _clientReceiveTimeout = 1000; - private int _clientSendTimeout = 1000; - private Task _clientConnectTask = Task.CompletedTask; - - private List _networkRequestCallbacks; - - private Queue _networkResponseQueue; - - [TestInitialize] - public void Initialize() - { - _clientIsAlwaysConnected = true; - _clientIsConnectedQueue = new Queue(); - - _networkRequestCallbacks = []; - _networkResponseQueue = new Queue(); - } - - [TestMethod] - public void ShouldGetAndSetPropertiesOfBaseClient() - { - // Arrange - _clientIsAlwaysConnected = false; - _clientIsConnectedQueue.Enqueue(true); - var connection = GetTcpConnection(); - connection.GetType().GetField("_isConnected", BindingFlags.NonPublic | BindingFlags.Instance).SetValue(connection, true); - - // Act - connection.ReadTimeout = TimeSpan.FromSeconds(123); - connection.WriteTimeout = TimeSpan.FromSeconds(456); - - // Assert - part 1 - Assert.AreEqual("TCP", connection.Name); - Assert.AreEqual(1, connection.ReadTimeout.TotalSeconds); - Assert.AreEqual(1, connection.WriteTimeout.TotalSeconds); - Assert.IsTrue(connection.IsConnected); - - // Assert - part 2 - _tcpClientMock.VerifySet(c => c.ReceiveTimeout = 123000, Times.Once); - _tcpClientMock.VerifySet(c => c.SendTimeout = 456000, Times.Once); - - _tcpClientMock.VerifyGet(c => c.ReceiveTimeout, Times.Once); - _tcpClientMock.VerifyGet(c => c.SendTimeout, Times.Once); - _tcpClientMock.VerifyGet(c => c.Connected, Times.Once); - - _socketMock.VerifyNoOtherCalls(); - _tcpClientMock.VerifyNoOtherCalls(); - _networkStreamMock.VerifyNoOtherCalls(); - } - - [DataTestMethod] - [DataRow(null)] - [DataRow("")] - [DataRow(" ")] - [ExpectedException(typeof(ArgumentNullException))] - public void ShouldThrowArumentNullExceptionForInvalidHostname(string hostname) - { - // Arrange - var connection = GetTcpConnection(); - - // Act - connection.Hostname = hostname; - - // Assert - ArgumentNullException - } - - [DataTestMethod] - [DataRow(0)] - [DataRow(65536)] - [ExpectedException(typeof(ArgumentOutOfRangeException))] - public void ShouldThrowArumentOutOfRangeExceptionForInvalidPort(int port) - { - // Arrange - var connection = GetTcpConnection(); - - // Act - connection.Port = port; - - // Assert - ArgumentOutOfRangeException - } - - [TestMethod] - public async Task ShouldConnectAsync() - { - // Arrange - var connection = GetConnection(); - - // Act - await connection.ConnectAsync(); - - // Assert - Assert.IsTrue(connection.IsConnected); - - _tcpClientMock.Verify(c => c.Close(), Times.Once); - _tcpClientMock.Verify(c => c.ConnectAsync(IPAddress.Loopback, 502, It.IsAny()), Times.Once); - _tcpClientMock.VerifyGet(c => c.ReceiveTimeout, Times.Once); - _tcpClientMock.VerifyGet(c => c.Connected, Times.Exactly(2)); - _tcpClientMock.VerifyGet(c => c.Client, Times.Exactly(3)); - - _socketMock.Verify(s => s.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.KeepAlive, false), Times.Once); - _socketMock.Verify(s => s.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveTime, 0), Times.Once); - _socketMock.Verify(s => s.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveInterval, 0), Times.Once); - - _socketMock.VerifyNoOtherCalls(); - _tcpClientMock.VerifyNoOtherCalls(); - _networkStreamMock.VerifyNoOtherCalls(); - } - - [TestMethod] - public async Task ShouldOnlyConnectAsyncOnce() - { - // Arrange - var connection = GetConnection(); - - await connection.ConnectAsync(); - ClearInvocations(); - - // Act - await connection.ConnectAsync(); - - // Assert - Assert.IsTrue(connection.IsConnected); - - _tcpClientMock.VerifyGet(c => c.Connected, Times.Once); - - _socketMock.VerifyNoOtherCalls(); - - _socketMock.VerifyNoOtherCalls(); - _tcpClientMock.VerifyNoOtherCalls(); - _networkStreamMock.VerifyNoOtherCalls(); - } - - [TestMethod] - [ExpectedException(typeof(ApplicationException))] - public async Task ShouldThrowApplicationExceptionHostnameNotResolvable() - { - // Arrange - var connection = GetConnection(); - connection.GetType().GetField("_hostname", BindingFlags.NonPublic | BindingFlags.Instance).SetValue(connection, ""); - - // Act - await connection.ConnectAsync(); - - // Assert - ApplicationException - } - - [TestMethod] - public async Task ShouldRetryConnectAsync() - { - // Arrange - _clientIsAlwaysConnected = false; - _clientIsConnectedQueue.Enqueue(false); - _clientIsConnectedQueue.Enqueue(true); - _clientIsConnectedQueue.Enqueue(true); - var connection = GetConnection(); - - // Act - await connection.ConnectAsync(); - - // Assert - Assert.IsTrue(connection.IsConnected); - - _tcpClientMock.Verify(c => c.Close(), Times.Exactly(2)); - _tcpClientMock.Verify(c => c.ConnectAsync(IPAddress.Loopback, 502, It.IsAny()), Times.Exactly(2)); - _tcpClientMock.VerifyGet(c => c.ReceiveTimeout, Times.Exactly(2)); - _tcpClientMock.VerifyGet(c => c.Connected, Times.Exactly(3)); - _tcpClientMock.VerifyGet(c => c.Client, Times.Exactly(3)); - - _socketMock.Verify(s => s.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.KeepAlive, false), Times.Once); - _socketMock.Verify(s => s.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveTime, 0), Times.Once); - _socketMock.Verify(s => s.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveInterval, 0), Times.Once); - - _socketMock.VerifyNoOtherCalls(); - _tcpClientMock.VerifyNoOtherCalls(); - _networkStreamMock.VerifyNoOtherCalls(); - } - - [TestMethod] - [ExpectedException(typeof(SocketException))] - public async Task ShouldThrowSocketExceptionOnConnectAsyncForNoReconnect() - { - // Arrange - _clientIsAlwaysConnected = false; - _clientIsConnectedQueue.Enqueue(false); - var connection = GetTcpConnection(); - connection.ReconnectTimeout = TimeSpan.Zero; - - // Act - await connection.ConnectAsync(); - - // Assert - SocketException - } - - [TestMethod] - public async Task ShouldDisconnectAsync() - { - // Arrange - var connection = GetConnection(); - - await connection.ConnectAsync(); - ClearInvocations(); - - // Act - await connection.DisconnectAsync(); - - // Assert - Assert.IsFalse(connection.IsConnected); - - _tcpClientMock.Verify(c => c.Close(), Times.Once); - _tcpClientMock.VerifyNoOtherCalls(); - - _socketMock.VerifyNoOtherCalls(); - _tcpClientMock.VerifyNoOtherCalls(); - _networkStreamMock.VerifyNoOtherCalls(); - } - - [TestMethod] - public async Task ShouldOnlyDisconnectAsyncOnce() - { - // Arrange - var connection = GetConnection(); - - await connection.ConnectAsync(); - await connection.DisconnectAsync(); - ClearInvocations(); - - // Act - await connection.DisconnectAsync(); - - // Assert - Assert.IsFalse(connection.IsConnected); - - _socketMock.VerifyNoOtherCalls(); - _tcpClientMock.VerifyNoOtherCalls(); - _networkStreamMock.VerifyNoOtherCalls(); - } - - [TestMethod] - public async Task ShouldCallDisconnectOnDispose() - { - // Arrange - var connection = GetConnection(); - - await connection.ConnectAsync(); - ClearInvocations(); - - // Act - connection.Dispose(); - - // Assert - _tcpClientMock.Verify(c => c.Close(), Times.Once); - _tcpClientMock.Verify(c => c.Dispose(), Times.Once); - _tcpClientMock.VerifyNoOtherCalls(); - - _socketMock.VerifyNoOtherCalls(); - _tcpClientMock.VerifyNoOtherCalls(); - _networkStreamMock.VerifyNoOtherCalls(); - } - - [TestMethod] - public void ShouldAllowMultipleDispose() - { - // Arrange - var connection = GetConnection(); - - // Act - connection.Dispose(); - connection.Dispose(); - - // Assert - _tcpClientMock.Verify(c => c.Close(), Times.Once); - _tcpClientMock.Verify(c => c.Dispose(), Times.Once); - _tcpClientMock.VerifyNoOtherCalls(); - - _socketMock.VerifyNoOtherCalls(); - _tcpClientMock.VerifyNoOtherCalls(); - _networkStreamMock.VerifyNoOtherCalls(); - } - - [TestMethod] - [ExpectedException(typeof(ApplicationException))] - public async Task ShouldThrowApplicationExceptionOnInvokeAsyncWhileNotConnected() - { - // Arrange - var connection = GetConnection(); - - // Act - await connection.InvokeAsync(null, null); - - // Assert - ApplicationException - } - - [DataTestMethod] - [DataRow(null)] - [DataRow(new byte[0])] - [ExpectedException(typeof(ArgumentNullException))] - public async Task ShouldThrowArgumentNullExceptionOnInvokeAsyncForRequest(byte[] request) - { - // Arrange - var connection = GetConnection(); - await connection.ConnectAsync(); - - // Act - await connection.InvokeAsync(request, null); - - // Assert - ArgumentNullException - } - - [TestMethod] - [ExpectedException(typeof(ArgumentNullException))] - public async Task ShouldThrowArgumentNullExceptionOnInvokeAsyncForMissingValidation() - { - // Arrange - byte[] request = new byte[1]; - - var connection = GetConnection(); - await connection.ConnectAsync(); - - // Act - await connection.InvokeAsync(request, null); - - // Assert - ArgumentNullException - } - - [TestMethod] - public async Task ShouldInvokeAsync() - { - // Arrange - _networkResponseQueue.Enqueue([9, 8, 7]); - byte[] request = [1, 2, 3]; - var validation = new Func, bool>(_ => true); - - var connection = GetConnection(); - await connection.ConnectAsync(); - ClearInvocations(); - - // Act - var response = await connection.InvokeAsync(request, validation); - - // Assert - Assert.AreEqual(1, _networkRequestCallbacks.Count); - - CollectionAssert.AreEqual(new byte[] { 9, 8, 7 }, response.ToArray()); - CollectionAssert.AreEqual(request, _networkRequestCallbacks[0]); - - _tcpClientMock.Verify(c => c.Connected, Times.Once); - _tcpClientMock.Verify(c => c.GetStream(), Times.Once); - - _networkStreamMock.Verify(ns => ns.FlushAsync(It.IsAny()), Times.Once); - _networkStreamMock.Verify(ns => ns.WriteAsync(It.IsAny>(), It.IsAny()), Times.Once); - _networkStreamMock.Verify(ns => ns.ReadAsync(It.IsAny>(), It.IsAny()), Times.Once); - - _socketMock.VerifyNoOtherCalls(); - _tcpClientMock.VerifyNoOtherCalls(); - _networkStreamMock.VerifyNoOtherCalls(); - } - - [TestMethod] - [ExpectedException(typeof(EndOfStreamException))] - public async Task ShouldThrowEndOfStreamOnInvokeAsync() - { - // Arrange - byte[] request = [1, 2, 3]; - var validation = new Func, bool>(_ => true); - - var connection = GetConnection(); - await connection.ConnectAsync(); - ClearInvocations(); - - // Act - _ = await connection.InvokeAsync(request, validation); - - // Assert - EndOfStreamException - } - - [TestMethod] - [ExpectedException(typeof(TaskCanceledException))] - public async Task ShouldCancelOnInvokeAsyncOnDisconnect() - { - // Arrange - byte[] request = [1, 2, 3]; - var validation = new Func, bool>(_ => true); - - var connection = GetConnection(); - _networkStreamMock - .Setup(ns => ns.WriteAsync(It.IsAny>(), It.IsAny())) - .Returns(new ValueTask(Task.Delay(100))); - - await connection.ConnectAsync(); - ClearInvocations(); - - // Act - var task = connection.InvokeAsync(request, validation); - await connection.DisconnectAsync(); - await task; - - // Assert - TaskCanceledException - } - - [TestMethod] - [ExpectedException(typeof(TaskCanceledException))] - public async Task ShouldCancelOnInvokeAsyncOnAbort() - { - // Arrange - byte[] request = [1, 2, 3]; - var validation = new Func, bool>(_ => true); - var cts = new CancellationTokenSource(); - - var connection = GetConnection(); - _networkStreamMock - .Setup(ns => ns.WriteAsync(It.IsAny>(), It.IsAny())) - .Returns(new ValueTask(Task.Delay(100))); - - await connection.ConnectAsync(); - ClearInvocations(); - - // Act - var task = connection.InvokeAsync(request, validation, cts.Token); - cts.Cancel(); - await task; - - // Assert - TaskCanceledException - } - - [DataTestMethod] - [DataRow(typeof(IOException))] - [DataRow(typeof(SocketException))] - [DataRow(typeof(TimeoutException))] - [DataRow(typeof(InvalidOperationException))] - public async Task ShouldReconnectOnInvokeAsyncForExceptionType(Type exceptionType) - { - // Arrange - _networkResponseQueue.Enqueue([9, 8, 7]); - byte[] request = [1, 2, 3]; - var validation = new Func, bool>(_ => true); - - var connection = GetConnection(); - await connection.ConnectAsync(); - ClearInvocations(); - - _networkStreamMock - .Setup(ns => ns.WriteAsync(It.IsAny>(), It.IsAny())) - .Callback, CancellationToken>((req, _) => _networkRequestCallbacks.Add(req.ToArray())) - .ThrowsAsync((Exception)Activator.CreateInstance(exceptionType)); - - // Act - try - { - await connection.InvokeAsync(request, validation); - } - catch (Exception ex) - { - // Assert - part 1 - Assert.IsInstanceOfType(ex, exceptionType); - } - - // Assert - part 2 - Assert.AreEqual(1, _networkRequestCallbacks.Count); - CollectionAssert.AreEqual(request, _networkRequestCallbacks[0]); - - _tcpClientMock.Verify(c => c.Close(), Times.Once); - _tcpClientMock.Verify(c => c.ConnectAsync(IPAddress.Loopback, 502, It.IsAny()), Times.Once); - _tcpClientMock.VerifyGet(c => c.ReceiveTimeout, Times.Once); - _tcpClientMock.VerifyGet(c => c.Connected, Times.Exactly(2)); - _tcpClientMock.VerifyGet(c => c.Client, Times.Exactly(3)); - _tcpClientMock.Verify(c => c.GetStream(), Times.Once); - - _networkStreamMock.Verify(ns => ns.FlushAsync(It.IsAny()), Times.Once); - _networkStreamMock.Verify(ns => ns.WriteAsync(It.IsAny>(), It.IsAny()), Times.Once); - - _socketMock.Verify(s => s.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.KeepAlive, false), Times.Once); - _socketMock.Verify(s => s.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveTime, 0), Times.Once); - _socketMock.Verify(s => s.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveInterval, 0), Times.Once); - - _socketMock.VerifyNoOtherCalls(); - _tcpClientMock.VerifyNoOtherCalls(); - _networkStreamMock.VerifyNoOtherCalls(); - } - - [TestMethod] - public async Task ShouldReturnWithUnknownExceptionOnInvokeAsync() - { - // Arrange - byte[] request = [1, 2, 3]; - var validation = new Func, bool>(_ => true); - - var connection = GetConnection(); - await connection.ConnectAsync(); - ClearInvocations(); - - _networkStreamMock - .Setup(ns => ns.WriteAsync(It.IsAny>(), It.IsAny())) - .Callback, CancellationToken>((req, _) => _networkRequestCallbacks.Add(req.ToArray())) - .ThrowsAsync(new NotImplementedException()); - - // Act - try - { - await connection.InvokeAsync(request, validation); - } - catch (Exception ex) - { - // Assert - part 1 - Assert.IsInstanceOfType(ex, typeof(NotImplementedException)); - } - - // Assert - part 2 - Assert.AreEqual(1, _networkRequestCallbacks.Count); - CollectionAssert.AreEqual(request, _networkRequestCallbacks[0]); - - _tcpClientMock.Verify(c => c.Connected, Times.Once); - _tcpClientMock.Verify(c => c.GetStream(), Times.Once); - - _networkStreamMock.Verify(ns => ns.FlushAsync(It.IsAny()), Times.Once); - _networkStreamMock.Verify(ns => ns.WriteAsync(It.IsAny>(), It.IsAny()), Times.Once); - - _socketMock.VerifyNoOtherCalls(); - _tcpClientMock.VerifyNoOtherCalls(); - _networkStreamMock.VerifyNoOtherCalls(); - } - - [TestMethod] - public async Task ShouldRemoveRequestFromQueueOnInvokeAsync() - { - // Arrange - _networkResponseQueue.Enqueue([9, 8, 7]); - byte[] request = [1, 2, 3]; - var validation = new Func, bool>(_ => true); - - var connection = GetConnection(); - await connection.ConnectAsync(); - ClearInvocations(); - - _networkStreamMock - .Setup(ns => ns.WriteAsync(It.IsAny>(), It.IsAny())) - .Callback, CancellationToken>((req, _) => _networkRequestCallbacks.Add(req.ToArray())) - .Returns(new ValueTask(Task.Delay(100))); - - var cts = new CancellationTokenSource(); - - // Act - var taskToComplete = connection.InvokeAsync(request, validation); - - var taskToCancel = connection.InvokeAsync(request, validation, cts.Token); - cts.Cancel(); - - var response = await taskToComplete; - - // Assert - try - { - await taskToCancel; - Assert.Fail(); - } - catch (TaskCanceledException) - { /* expected exception */ } - - Assert.AreEqual(1, _networkRequestCallbacks.Count); - - CollectionAssert.AreEqual(new byte[] { 9, 8, 7 }, response.ToArray()); - CollectionAssert.AreEqual(request, _networkRequestCallbacks[0]); - - _tcpClientMock.Verify(c => c.Connected, Times.Exactly(2)); - _tcpClientMock.Verify(c => c.GetStream(), Times.Once); - - _networkStreamMock.Verify(ns => ns.FlushAsync(It.IsAny()), Times.Once); - _networkStreamMock.Verify(ns => ns.WriteAsync(It.IsAny>(), It.IsAny()), Times.Once); - _networkStreamMock.Verify(ns => ns.ReadAsync(It.IsAny>(), It.IsAny()), Times.Once); - - _socketMock.VerifyNoOtherCalls(); - _tcpClientMock.VerifyNoOtherCalls(); - _networkStreamMock.VerifyNoOtherCalls(); - } - - [TestMethod] - public async Task ShouldCancelQueuedRequestOnDisconnect() - { - // Arrange - _networkResponseQueue.Enqueue([9, 8, 7]); - byte[] request = [1, 2, 3]; - var validation = new Func, bool>(_ => true); - - var connection = GetConnection(); - await connection.ConnectAsync(); - ClearInvocations(); - - _networkStreamMock - .Setup(ns => ns.WriteAsync(It.IsAny>(), It.IsAny())) - .Callback, CancellationToken>((req, _) => _networkRequestCallbacks.Add(req.ToArray())) - .Returns(new ValueTask(Task.Delay(100))); - - var cts = new CancellationTokenSource(); - - // Act - var taskToCancel = connection.InvokeAsync(request, validation); - var taskToDequeue = connection.InvokeAsync(request, validation); - await connection.DisconnectAsync(); - - // Assert - try - { - await taskToCancel; - Assert.Fail(); - } - catch (TaskCanceledException ex) - { - /* expected exception */ - Assert.AreNotEqual(CancellationToken.None, ex.CancellationToken); - } - - try - { - await taskToDequeue; - Assert.Fail(); - } - catch (TaskCanceledException ex) - { - /* expected exception */ - Assert.AreEqual(CancellationToken.None, ex.CancellationToken); - } - - Assert.AreEqual(1, _networkRequestCallbacks.Count); - CollectionAssert.AreEqual(request, _networkRequestCallbacks[0]); - - _tcpClientMock.Verify(c => c.Connected, Times.Exactly(2)); - _tcpClientMock.Verify(c => c.GetStream(), Times.Once); - _tcpClientMock.Verify(c => c.Close(), Times.Once); - - _networkStreamMock.Verify(ns => ns.FlushAsync(It.IsAny()), Times.Once); - _networkStreamMock.Verify(ns => ns.WriteAsync(It.IsAny>(), It.IsAny()), Times.Once); - - _socketMock.VerifyNoOtherCalls(); - _tcpClientMock.VerifyNoOtherCalls(); - _networkStreamMock.VerifyNoOtherCalls(); - } - - private IModbusConnection GetConnection() - => GetTcpConnection(); - - private ModbusTcpConnection GetTcpConnection() - { - _networkStreamMock = new Mock(); - _networkStreamMock - .Setup(ns => ns.WriteAsync(It.IsAny>(), It.IsAny())) - .Callback, CancellationToken>((req, _) => _networkRequestCallbacks.Add(req.ToArray())) - .Returns(ValueTask.CompletedTask); - _networkStreamMock - .Setup(ns => ns.ReadAsync(It.IsAny>(), It.IsAny())) - .Returns, CancellationToken>((buffer, _) => - { - if (_networkResponseQueue.TryDequeue(out byte[] bytes)) - { - bytes.CopyTo(buffer); - return ValueTask.FromResult(bytes.Length); - } - - return ValueTask.FromResult(0); - }); - - _socketMock = new Mock(); - - _tcpClientMock = new Mock(); - _tcpClientMock.Setup(c => c.Client).Returns(() => _socketMock.Object); - _tcpClientMock.Setup(c => c.Connected).Returns(() => _clientIsAlwaysConnected || _clientIsConnectedQueue.Dequeue()); - _tcpClientMock.Setup(c => c.ReceiveTimeout).Returns(() => _clientReceiveTimeout); - _tcpClientMock.Setup(c => c.SendTimeout).Returns(() => _clientSendTimeout); - - _tcpClientMock - .Setup(c => c.ConnectAsync(It.IsAny(), It.IsAny(), It.IsAny())) - .Returns(() => _clientConnectTask); - - _tcpClientMock - .Setup(c => c.GetStream()) - .Returns(() => _networkStreamMock.Object); - - var connection = new ModbusTcpConnection - { - Hostname = _hostname, - Port = 502 - }; - - // Replace real TCP client with mock - var clientField = connection.GetType().GetField("_client", BindingFlags.NonPublic | BindingFlags.Instance); - (clientField.GetValue(connection) as TcpClientWrapper)?.Dispose(); - clientField.SetValue(connection, _tcpClientMock.Object); - - return connection; - } - - private void ClearInvocations() - { - _networkStreamMock.Invocations.Clear(); - _socketMock.Invocations.Clear(); - _tcpClientMock.Invocations.Clear(); - } - } -} diff --git a/README.md b/README.md index 62096cc..ea228b2 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ It uses a specific TCP connection implementation and plugs all things from the C --- Published under [MIT License] (see [**tl;dr**Legal]) -[![built with Codeium](https://codeium.com/badges/main)](https://codeium.com) +[![built with Codeium](https://codeium.com/badges/main)](https://codeium.com/profile/andreasmueller)