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