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])
-[](https://codeium.com)
+[](https://codeium.com/profile/andreasmueller)