Moving structure as preparation for docs
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<PackageId>AMWD.Protocols.Modbus.Tcp</PackageId>
|
||||
<AssemblyName>amwd-modbus-tcp</AssemblyName>
|
||||
<RootNamespace>AMWD.Protocols.Modbus.Tcp</RootNamespace>
|
||||
|
||||
<Product>Modbus TCP Protocol</Product>
|
||||
<Description>Implementation of the Modbus protocol communicating via TCP.</Description>
|
||||
<PackageTags>Modbus Protocol Network TCP LAN</PackageTags>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Include="../AMWD.Protocols.Modbus.Common/Extensions/ArrayExtensions.cs" Link="Extensions/ArrayExtensions.cs" />
|
||||
<Compile Include="../AMWD.Protocols.Modbus.Common/Extensions/ReaderWriterLockSlimExtensions.cs" Link="Extensions/ReaderWriterLockSlimExtensions.cs" />
|
||||
<Compile Include="../AMWD.Protocols.Modbus.Common/Utils/AsyncQueue.cs" Link="Utils/AsyncQueue.cs" />
|
||||
<Compile Include="../AMWD.Protocols.Modbus.Common/Utils/RequestQueueItem.cs" Link="Utils/RequestQueueItem.cs" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\AMWD.Protocols.Modbus.Common\AMWD.Protocols.Modbus.Common.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
45
src/AMWD.Protocols.Modbus.Tcp/Extensions/StreamExtensions.cs
Normal file
45
src/AMWD.Protocols.Modbus.Tcp/Extensions/StreamExtensions.cs
Normal file
@@ -0,0 +1,45 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AMWD.Protocols.Modbus.Tcp.Utils;
|
||||
|
||||
namespace System.IO
|
||||
{
|
||||
internal static class StreamExtensions
|
||||
{
|
||||
public static async Task<byte[]> ReadExpectedBytesAsync(this Stream stream, int expectedBytes, CancellationToken cancellationToken = default)
|
||||
{
|
||||
byte[] buffer = new byte[expectedBytes];
|
||||
int offset = 0;
|
||||
do
|
||||
{
|
||||
int count = await stream.ReadAsync(buffer, offset, expectedBytes - offset, cancellationToken).ConfigureAwait(continueOnCapturedContext: false);
|
||||
if (count < 1)
|
||||
throw new EndOfStreamException();
|
||||
|
||||
offset += count;
|
||||
}
|
||||
while (offset < expectedBytes && !cancellationToken.IsCancellationRequested);
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
return buffer;
|
||||
}
|
||||
|
||||
public static async Task<byte[]> ReadExpectedBytesAsync(this NetworkStreamWrapper stream, int expectedBytes, CancellationToken cancellationToken = default)
|
||||
{
|
||||
byte[] buffer = new byte[expectedBytes];
|
||||
int offset = 0;
|
||||
do
|
||||
{
|
||||
int count = await stream.ReadAsync(buffer, offset, expectedBytes - offset, cancellationToken).ConfigureAwait(continueOnCapturedContext: false);
|
||||
if (count < 1)
|
||||
throw new EndOfStreamException();
|
||||
|
||||
offset += count;
|
||||
}
|
||||
while (offset < expectedBytes && !cancellationToken.IsCancellationRequested);
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
return buffer;
|
||||
}
|
||||
}
|
||||
}
|
||||
17
src/AMWD.Protocols.Modbus.Tcp/Extensions/TaskExtensions.cs
Normal file
17
src/AMWD.Protocols.Modbus.Tcp/Extensions/TaskExtensions.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace AMWD.Protocols.Modbus.Tcp.Extensions
|
||||
{
|
||||
internal static class TaskExtensions
|
||||
{
|
||||
public static async void Forget(this Task task)
|
||||
{
|
||||
try
|
||||
{
|
||||
await task;
|
||||
}
|
||||
catch
|
||||
{ /* keep it quiet */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
117
src/AMWD.Protocols.Modbus.Tcp/ModbusTcpClient.cs
Normal file
117
src/AMWD.Protocols.Modbus.Tcp/ModbusTcpClient.cs
Normal file
@@ -0,0 +1,117 @@
|
||||
using System;
|
||||
using System.Text;
|
||||
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="ModbusTcpClient"/> 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 cref="IModbusConnection.IdleTimeout"/>
|
||||
public TimeSpan IdleTimeout
|
||||
{
|
||||
get => connection.IdleTimeout;
|
||||
set => connection.IdleTimeout = value;
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="IModbusConnection.ConnectTimeout"/>
|
||||
public TimeSpan ConnectTimeout
|
||||
{
|
||||
get => connection.ConnectTimeout;
|
||||
set => connection.ConnectTimeout = value;
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="IModbusConnection.ReadTimeout"/>
|
||||
public TimeSpan ReadTimeout
|
||||
{
|
||||
get => connection.ReadTimeout;
|
||||
set => connection.ReadTimeout = value;
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="IModbusConnection.WriteTimeout"/>
|
||||
public TimeSpan WriteTimeout
|
||||
{
|
||||
get => connection.WriteTimeout;
|
||||
set => connection.WriteTimeout = value;
|
||||
}
|
||||
|
||||
/// <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/>
|
||||
public override string ToString()
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
|
||||
sb.AppendLine($"TCP Client {Hostname}");
|
||||
sb.AppendLine($" {nameof(Port)}: {Port}");
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
399
src/AMWD.Protocols.Modbus.Tcp/ModbusTcpConnection.cs
Normal file
399
src/AMWD.Protocols.Modbus.Tcp/ModbusTcpConnection.cs
Normal file
@@ -0,0 +1,399 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AMWD.Protocols.Modbus.Common.Contracts;
|
||||
using AMWD.Protocols.Modbus.Common.Protocols;
|
||||
using AMWD.Protocols.Modbus.Common.Utils;
|
||||
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 readonly CancellationTokenSource _disposeCts = new();
|
||||
|
||||
private readonly TcpClientWrapperFactory _tcpClientFactory = new();
|
||||
private readonly SemaphoreSlim _clientLock = new(1, 1);
|
||||
private TcpClientWrapper _tcpClient = null;
|
||||
private readonly Timer _idleTimer;
|
||||
|
||||
private readonly Task _processingTask;
|
||||
private readonly AsyncQueue<RequestQueueItem> _requestQueue = new();
|
||||
|
||||
private TimeSpan _readTimeout = TimeSpan.FromSeconds(1);
|
||||
private TimeSpan _writeTimeout = TimeSpan.FromSeconds(1);
|
||||
|
||||
#endregion Fields
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ModbusTcpConnection"/> class.
|
||||
/// </summary>
|
||||
public ModbusTcpConnection()
|
||||
{
|
||||
_idleTimer = new Timer(OnIdleTimer);
|
||||
_processingTask = ProcessAsync(_disposeCts.Token);
|
||||
}
|
||||
|
||||
#region Properties
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string Name => "TCP";
|
||||
|
||||
/// <inheritdoc/>
|
||||
public virtual TimeSpan IdleTimeout { get; set; } = TimeSpan.FromSeconds(6);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public virtual TimeSpan ConnectTimeout { get; set; } = TimeSpan.MaxValue;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public virtual TimeSpan ReadTimeout
|
||||
{
|
||||
get => _readTimeout;
|
||||
set
|
||||
{
|
||||
#if NET8_0_OR_GREATER
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(value, TimeSpan.Zero);
|
||||
#else
|
||||
if (value < TimeSpan.Zero)
|
||||
throw new ArgumentOutOfRangeException(nameof(value));
|
||||
#endif
|
||||
|
||||
_readTimeout = value;
|
||||
|
||||
if (_tcpClient != null)
|
||||
_tcpClient.ReceiveTimeout = (int)value.TotalMilliseconds;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public virtual TimeSpan WriteTimeout
|
||||
{
|
||||
get => _writeTimeout;
|
||||
set
|
||||
{
|
||||
#if NET8_0_OR_GREATER
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(value, TimeSpan.Zero);
|
||||
#else
|
||||
if (value < TimeSpan.Zero)
|
||||
throw new ArgumentOutOfRangeException(nameof(value));
|
||||
#endif
|
||||
|
||||
_writeTimeout = value;
|
||||
|
||||
if (_tcpClient != null)
|
||||
_tcpClient.SendTimeout = (int)value.TotalMilliseconds;
|
||||
}
|
||||
}
|
||||
|
||||
/// <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;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion Properties
|
||||
|
||||
/// <summary>
|
||||
/// Releases all managed and unmanaged resources used by the <see cref="ModbusTcpConnection"/>.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (_isDisposed)
|
||||
return;
|
||||
|
||||
_isDisposed = true;
|
||||
_disposeCts.Cancel();
|
||||
|
||||
_idleTimer.Dispose();
|
||||
|
||||
try
|
||||
{
|
||||
_processingTask.Dispose();
|
||||
}
|
||||
catch
|
||||
{ /* keep it quiet */ }
|
||||
|
||||
OnIdleTimer(null);
|
||||
|
||||
_tcpClient?.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
|
||||
|
||||
/// <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 (request == null || request.Count < 1)
|
||||
throw new ArgumentNullException(nameof(request));
|
||||
|
||||
#if NET8_0_OR_GREATER
|
||||
ArgumentNullException.ThrowIfNull(validateResponseComplete);
|
||||
#else
|
||||
if (validateResponseComplete == null)
|
||||
throw new ArgumentNullException(nameof(validateResponseComplete));
|
||||
#endif
|
||||
|
||||
var item = new RequestQueueItem
|
||||
{
|
||||
Request = [.. request],
|
||||
ValidateResponseComplete = validateResponseComplete,
|
||||
TaskCompletionSource = new TaskCompletionSource<IReadOnlyList<byte>>(),
|
||||
CancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken)
|
||||
};
|
||||
|
||||
item.CancellationTokenRegistration = item.CancellationTokenSource.Token.Register(() =>
|
||||
{
|
||||
_requestQueue.Remove(item);
|
||||
item.CancellationTokenSource.Dispose();
|
||||
item.TaskCompletionSource.TrySetCanceled(cancellationToken);
|
||||
item.CancellationTokenRegistration.Dispose();
|
||||
});
|
||||
|
||||
_requestQueue.Enqueue(item);
|
||||
return item.TaskCompletionSource.Task;
|
||||
}
|
||||
|
||||
private async Task ProcessAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Get next request to process
|
||||
var item = await _requestQueue.DequeueAsync(cancellationToken).ConfigureAwait(continueOnCapturedContext: false);
|
||||
|
||||
// Remove registration => already removed from queue
|
||||
item.CancellationTokenRegistration.Dispose();
|
||||
|
||||
// Build combined cancellation token
|
||||
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, item.CancellationTokenSource.Token);
|
||||
// Wait for exclusive access
|
||||
await _clientLock.WaitAsync(linkedCts.Token).ConfigureAwait(continueOnCapturedContext: false);
|
||||
try
|
||||
{
|
||||
// Ensure connection is up
|
||||
await AssertConnection(linkedCts.Token).ConfigureAwait(continueOnCapturedContext: false);
|
||||
|
||||
var stream = _tcpClient.GetStream();
|
||||
await stream.FlushAsync(linkedCts.Token);
|
||||
|
||||
#if NET6_0_OR_GREATER
|
||||
await stream.WriteAsync(item.Request, linkedCts.Token).ConfigureAwait(continueOnCapturedContext: false);
|
||||
#else
|
||||
await stream.WriteAsync(item.Request, 0, item.Request.Length, linkedCts.Token).ConfigureAwait(continueOnCapturedContext: false);
|
||||
#endif
|
||||
|
||||
linkedCts.Token.ThrowIfCancellationRequested();
|
||||
|
||||
var bytes = new List<byte>();
|
||||
byte[] buffer = new byte[TcpProtocol.MAX_ADU_LENGTH];
|
||||
|
||||
do
|
||||
{
|
||||
#if NET6_0_OR_GREATER
|
||||
int readCount = await stream.ReadAsync(buffer, linkedCts.Token).ConfigureAwait(continueOnCapturedContext: false);
|
||||
#else
|
||||
int readCount = await stream.ReadAsync(buffer, 0, buffer.Length, linkedCts.Token).ConfigureAwait(continueOnCapturedContext: 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)
|
||||
{
|
||||
// Dispose() called while waiting for request item
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endregion Request processing
|
||||
|
||||
#region Connection handling
|
||||
|
||||
// Has to be called within _clientLock!
|
||||
private async Task AssertConnection(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_tcpClient?.Connected == true)
|
||||
return;
|
||||
|
||||
int delay = 1;
|
||||
int maxDelay = 60;
|
||||
|
||||
var ipAddresses = Resolve(Hostname);
|
||||
if (ipAddresses.Length == 0)
|
||||
throw new ApplicationException($"Could not resolve hostname '{Hostname}'");
|
||||
|
||||
var startTime = DateTime.UtcNow;
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
foreach (var ipAddress in ipAddresses)
|
||||
{
|
||||
_tcpClient?.Close();
|
||||
_tcpClient?.Dispose();
|
||||
|
||||
_tcpClient = _tcpClientFactory.Create(ipAddress.AddressFamily, _readTimeout, _writeTimeout);
|
||||
#if NET6_0_OR_GREATER
|
||||
var connectTask = _tcpClient.ConnectAsync(ipAddress, Port, cancellationToken);
|
||||
#else
|
||||
var connectTask = _tcpClient.ConnectAsync(ipAddress, Port);
|
||||
#endif
|
||||
if (await Task.WhenAny(connectTask, Task.Delay(_readTimeout, cancellationToken)) == connectTask)
|
||||
{
|
||||
await connectTask;
|
||||
if (_tcpClient.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(continueOnCapturedContext: false);
|
||||
}
|
||||
catch
|
||||
{ /* keep it quiet */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void OnIdleTimer(object _)
|
||||
{
|
||||
try
|
||||
{
|
||||
_clientLock.Wait(_disposeCts.Token);
|
||||
try
|
||||
{
|
||||
if (!_tcpClient.Connected)
|
||||
return;
|
||||
|
||||
_tcpClient.Close();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_clientLock.Release();
|
||||
}
|
||||
}
|
||||
catch
|
||||
{ /* keep it quiet */ }
|
||||
}
|
||||
|
||||
#endregion Connection handling
|
||||
|
||||
#region Helpers
|
||||
|
||||
[System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
|
||||
private static IPAddress[] Resolve(string hostname)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(hostname))
|
||||
return [];
|
||||
|
||||
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
|
||||
}
|
||||
catch
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
#endregion Helpers
|
||||
}
|
||||
}
|
||||
866
src/AMWD.Protocols.Modbus.Tcp/ModbusTcpProxy.cs
Normal file
866
src/AMWD.Protocols.Modbus.Tcp/ModbusTcpProxy.cs
Normal file
@@ -0,0 +1,866 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AMWD.Protocols.Modbus.Common;
|
||||
using AMWD.Protocols.Modbus.Common.Contracts;
|
||||
using AMWD.Protocols.Modbus.Common.Protocols;
|
||||
using AMWD.Protocols.Modbus.Tcp.Extensions;
|
||||
using AMWD.Protocols.Modbus.Tcp.Utils;
|
||||
|
||||
namespace AMWD.Protocols.Modbus.Tcp
|
||||
{
|
||||
/// <summary>
|
||||
/// Implements a Modbus TCP server proxying all requests to a Modbus client of choice.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Initializes a new instance of the <see cref="ModbusTcpProxy"/> class.
|
||||
/// </remarks>
|
||||
/// <param name="client">The <see cref="ModbusClientBase"/> used to request the remote device, that should be proxied.</param>
|
||||
/// <param name="listenAddress">An <see cref="IPAddress"/> to listen on.</param>
|
||||
public class ModbusTcpProxy(ModbusClientBase client, IPAddress listenAddress) : IModbusProxy
|
||||
{
|
||||
#region Fields
|
||||
|
||||
private bool _isDisposed;
|
||||
|
||||
private TimeSpan _readWriteTimeout = TimeSpan.FromSeconds(100);
|
||||
|
||||
private readonly TcpListenerWrapper _tcpListener = new(listenAddress, 502);
|
||||
private CancellationTokenSource _stopCts;
|
||||
private Task _clientConnectTask = Task.CompletedTask;
|
||||
|
||||
private readonly SemaphoreSlim _clientListLock = new(1, 1);
|
||||
private readonly List<TcpClientWrapper> _clients = [];
|
||||
|
||||
#endregion Fields
|
||||
|
||||
#region Constructors
|
||||
|
||||
#endregion Constructors
|
||||
|
||||
#region Properties
|
||||
|
||||
/// <summary>
|
||||
/// Gets the Modbus client used to request the remote device, that should be proxied.
|
||||
/// </summary>
|
||||
public ModbusClientBase Client { get; } = client ?? throw new ArgumentNullException(nameof(client));
|
||||
|
||||
/// <summary>
|
||||
/// Gets the <see cref="IPAddress"/> to listen on.
|
||||
/// </summary>
|
||||
public IPAddress ListenAddress
|
||||
{
|
||||
get => _tcpListener.LocalIPEndPoint.Address;
|
||||
set => _tcpListener.LocalIPEndPoint.Address = value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get the port to listen on.
|
||||
/// </summary>
|
||||
public int ListenPort
|
||||
{
|
||||
get => _tcpListener.LocalIPEndPoint.Port;
|
||||
set => _tcpListener.LocalIPEndPoint.Port = value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the server is running.
|
||||
/// </summary>
|
||||
public bool IsRunning => _tcpListener.Socket.IsBound;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the read/write timeout for the incoming connections (not the <see cref="Client"/>!).
|
||||
/// Default: 100 seconds.
|
||||
/// </summary>
|
||||
public TimeSpan ReadWriteTimeout
|
||||
{
|
||||
get => _readWriteTimeout;
|
||||
set
|
||||
{
|
||||
if (value != Timeout.InfiniteTimeSpan && value < TimeSpan.Zero)
|
||||
throw new ArgumentOutOfRangeException(nameof(value));
|
||||
|
||||
_readWriteTimeout = value;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion Properties
|
||||
|
||||
#region Control Methods
|
||||
|
||||
/// <summary>
|
||||
/// Starts the server.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">A cancellation token used to propagate notification that this operation should be canceled.</param>
|
||||
public Task StartAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
Assertions();
|
||||
|
||||
_stopCts?.Cancel();
|
||||
_tcpListener.Stop();
|
||||
|
||||
_stopCts?.Dispose();
|
||||
_stopCts = new CancellationTokenSource();
|
||||
|
||||
// Only allowed to set, if the socket is in the InterNetworkV6 address family.
|
||||
// See: https://learn.microsoft.com/en-us/dotnet/api/system.net.sockets.socket.dualmode?view=netstandard-2.0#exceptions
|
||||
if (ListenAddress.AddressFamily == AddressFamily.InterNetworkV6)
|
||||
_tcpListener.Socket.DualMode = true;
|
||||
|
||||
_tcpListener.Start();
|
||||
_clientConnectTask = WaitForClientAsync(_stopCts.Token);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stops the server.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">A cancellation token used to propagate notification that this operation should be canceled.</param>
|
||||
public Task StopAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
Assertions();
|
||||
return StopAsyncInternal(cancellationToken);
|
||||
}
|
||||
|
||||
private async Task StopAsyncInternal(CancellationToken cancellationToken = default)
|
||||
{
|
||||
_stopCts?.Cancel();
|
||||
_tcpListener.Stop();
|
||||
|
||||
try
|
||||
{
|
||||
await Task.WhenAny(_clientConnectTask, Task.Delay(Timeout.Infinite, cancellationToken)).ConfigureAwait(continueOnCapturedContext: false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Terminated
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Releases all managed and unmanaged resources used by the <see cref="ModbusTcpProxy"/>.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (_isDisposed)
|
||||
return;
|
||||
|
||||
_isDisposed = true;
|
||||
|
||||
StopAsyncInternal(CancellationToken.None).Wait();
|
||||
|
||||
_clientListLock.Dispose();
|
||||
_clients.Clear();
|
||||
_tcpListener.Dispose();
|
||||
|
||||
_stopCts?.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
private void Assertions()
|
||||
{
|
||||
#if NET8_0_OR_GREATER
|
||||
ObjectDisposedException.ThrowIf(_isDisposed, this);
|
||||
#else
|
||||
if (_isDisposed)
|
||||
throw new ObjectDisposedException(GetType().FullName);
|
||||
#endif
|
||||
}
|
||||
|
||||
#endregion Control Methods
|
||||
|
||||
#region Client Handling
|
||||
|
||||
private async Task WaitForClientAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
var client = await _tcpListener.AcceptTcpClientAsync(cancellationToken).ConfigureAwait(continueOnCapturedContext: false);
|
||||
await _clientListLock.WaitAsync(cancellationToken).ConfigureAwait(continueOnCapturedContext: false);
|
||||
try
|
||||
{
|
||||
_clients.Add(client);
|
||||
// Can be ignored as it will terminate by itself on cancellation
|
||||
HandleClientAsync(client, cancellationToken).Forget();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_clientListLock.Release();
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// There might be a failure here, that's ok, just keep it quiet
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleClientAsync(TcpClientWrapper client, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var stream = client.GetStream();
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
var requestBytes = new List<byte>();
|
||||
|
||||
// Waiting for next request
|
||||
byte[] headerBytes = await stream.ReadExpectedBytesAsync(6, cancellationToken).ConfigureAwait(continueOnCapturedContext: false);
|
||||
requestBytes.AddRange(headerBytes);
|
||||
|
||||
ushort length = headerBytes
|
||||
.Skip(4).Take(2).ToArray()
|
||||
.GetBigEndianUInt16();
|
||||
|
||||
// Waiting for the remaining required data
|
||||
using (var cts = new CancellationTokenSource(ReadWriteTimeout))
|
||||
using (cancellationToken.Register(cts.Cancel))
|
||||
{
|
||||
byte[] bodyBytes = await stream.ReadExpectedBytesAsync(length, cts.Token).ConfigureAwait(continueOnCapturedContext: false);
|
||||
requestBytes.AddRange(bodyBytes);
|
||||
}
|
||||
|
||||
byte[] responseBytes = await HandleRequestAsync([.. requestBytes], cancellationToken).ConfigureAwait(continueOnCapturedContext: false);
|
||||
if (responseBytes != null)
|
||||
{
|
||||
// Write response when available
|
||||
using (var cts = new CancellationTokenSource(ReadWriteTimeout))
|
||||
using (cancellationToken.Register(cts.Cancel))
|
||||
{
|
||||
await stream.WriteAsync(responseBytes, 0, responseBytes.Length, cts.Token).ConfigureAwait(continueOnCapturedContext: false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Keep client processing quiet
|
||||
}
|
||||
finally
|
||||
{
|
||||
await _clientListLock.WaitAsync(cancellationToken).ConfigureAwait(continueOnCapturedContext: false);
|
||||
try
|
||||
{
|
||||
_clients.Remove(client);
|
||||
client.Dispose();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_clientListLock.Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endregion Client Handling
|
||||
|
||||
#region Request Handling
|
||||
|
||||
private Task<byte[]> HandleRequestAsync(byte[] requestBytes, CancellationToken cancellationToken)
|
||||
{
|
||||
switch ((ModbusFunctionCode)requestBytes[7])
|
||||
{
|
||||
case ModbusFunctionCode.ReadCoils:
|
||||
return HandleReadCoilsAsync(requestBytes, cancellationToken);
|
||||
|
||||
case ModbusFunctionCode.ReadDiscreteInputs:
|
||||
return HandleReadDiscreteInputsAsync(requestBytes, cancellationToken);
|
||||
|
||||
case ModbusFunctionCode.ReadHoldingRegisters:
|
||||
return HandleReadHoldingRegistersAsync(requestBytes, cancellationToken);
|
||||
|
||||
case ModbusFunctionCode.ReadInputRegisters:
|
||||
return HandleReadInputRegistersAsync(requestBytes, cancellationToken);
|
||||
|
||||
case ModbusFunctionCode.WriteSingleCoil:
|
||||
return HandleWriteSingleCoilAsync(requestBytes, cancellationToken);
|
||||
|
||||
case ModbusFunctionCode.WriteSingleRegister:
|
||||
return HandleWriteSingleRegisterAsync(requestBytes, cancellationToken);
|
||||
|
||||
case ModbusFunctionCode.WriteMultipleCoils:
|
||||
return HandleWriteMultipleCoilsAsync(requestBytes, cancellationToken);
|
||||
|
||||
case ModbusFunctionCode.WriteMultipleRegisters:
|
||||
return HandleWriteMultipleRegistersAsync(requestBytes, cancellationToken);
|
||||
|
||||
case ModbusFunctionCode.EncapsulatedInterface:
|
||||
return HandleEncapsulatedInterfaceAsync(requestBytes, cancellationToken);
|
||||
|
||||
default: // unknown function
|
||||
{
|
||||
var responseBytes = new List<byte>();
|
||||
responseBytes.AddRange(requestBytes.Take(8));
|
||||
responseBytes.Add((byte)ModbusErrorCode.IllegalFunction);
|
||||
|
||||
// Mark as error
|
||||
responseBytes[7] |= 0x80;
|
||||
|
||||
return Task.FromResult(ReturnResponse(responseBytes));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<byte[]> HandleReadCoilsAsync(byte[] requestBytes, CancellationToken cancellationToken)
|
||||
{
|
||||
if (requestBytes.Length < 12)
|
||||
return null;
|
||||
|
||||
byte unitId = requestBytes[6];
|
||||
ushort firstAddress = requestBytes.GetBigEndianUInt16(8);
|
||||
ushort count = requestBytes.GetBigEndianUInt16(10);
|
||||
|
||||
var responseBytes = new List<byte>();
|
||||
responseBytes.AddRange(requestBytes.Take(8));
|
||||
try
|
||||
{
|
||||
var coils = await Client.ReadCoilsAsync(unitId, firstAddress, count, cancellationToken).ConfigureAwait(continueOnCapturedContext: false);
|
||||
|
||||
byte[] values = new byte[(int)Math.Ceiling(coils.Count / 8.0)];
|
||||
for (int i = 0; i < coils.Count; i++)
|
||||
{
|
||||
if (coils[i].Value)
|
||||
{
|
||||
int byteIndex = i / 8;
|
||||
int bitIndex = i % 8;
|
||||
|
||||
values[byteIndex] |= (byte)(1 << bitIndex);
|
||||
}
|
||||
}
|
||||
|
||||
responseBytes.Add((byte)values.Length);
|
||||
responseBytes.AddRange(values);
|
||||
}
|
||||
catch
|
||||
{
|
||||
responseBytes[7] |= 0x80;
|
||||
responseBytes.Add((byte)ModbusErrorCode.SlaveDeviceFailure);
|
||||
}
|
||||
|
||||
return ReturnResponse(responseBytes);
|
||||
}
|
||||
|
||||
private async Task<byte[]> HandleReadDiscreteInputsAsync(byte[] requestBytes, CancellationToken cancellationToken)
|
||||
{
|
||||
if (requestBytes.Length < 12)
|
||||
return null;
|
||||
|
||||
byte unitId = requestBytes[6];
|
||||
ushort firstAddress = requestBytes.GetBigEndianUInt16(8);
|
||||
ushort count = requestBytes.GetBigEndianUInt16(10);
|
||||
|
||||
var responseBytes = new List<byte>();
|
||||
responseBytes.AddRange(requestBytes.Take(8));
|
||||
try
|
||||
{
|
||||
var discreteInputs = await Client.ReadDiscreteInputsAsync(unitId, firstAddress, count, cancellationToken).ConfigureAwait(continueOnCapturedContext: false);
|
||||
|
||||
byte[] values = new byte[(int)Math.Ceiling(discreteInputs.Count / 8.0)];
|
||||
for (int i = 0; i < discreteInputs.Count; i++)
|
||||
{
|
||||
if (discreteInputs[i].Value)
|
||||
{
|
||||
int byteIndex = i / 8;
|
||||
int bitIndex = i % 8;
|
||||
|
||||
values[byteIndex] |= (byte)(1 << bitIndex);
|
||||
}
|
||||
}
|
||||
|
||||
responseBytes.Add((byte)values.Length);
|
||||
responseBytes.AddRange(values);
|
||||
}
|
||||
catch
|
||||
{
|
||||
responseBytes[7] |= 0x80;
|
||||
responseBytes.Add((byte)ModbusErrorCode.SlaveDeviceFailure);
|
||||
}
|
||||
|
||||
return ReturnResponse(responseBytes);
|
||||
}
|
||||
|
||||
private async Task<byte[]> HandleReadHoldingRegistersAsync(byte[] requestBytes, CancellationToken cancellationToken)
|
||||
{
|
||||
if (requestBytes.Length < 12)
|
||||
return null;
|
||||
|
||||
byte unitId = requestBytes[6];
|
||||
ushort firstAddress = requestBytes.GetBigEndianUInt16(8);
|
||||
ushort count = requestBytes.GetBigEndianUInt16(10);
|
||||
|
||||
var responseBytes = new List<byte>();
|
||||
responseBytes.AddRange(requestBytes.Take(8));
|
||||
try
|
||||
{
|
||||
var holdingRegisters = await Client.ReadHoldingRegistersAsync(unitId, firstAddress, count, cancellationToken).ConfigureAwait(continueOnCapturedContext: false);
|
||||
|
||||
byte[] values = new byte[holdingRegisters.Count * 2];
|
||||
for (int i = 0; i < holdingRegisters.Count; i++)
|
||||
{
|
||||
values[i * 2] = holdingRegisters[i].HighByte;
|
||||
values[i * 2 + 1] = holdingRegisters[i].LowByte;
|
||||
}
|
||||
|
||||
responseBytes.Add((byte)values.Length);
|
||||
responseBytes.AddRange(values);
|
||||
}
|
||||
catch
|
||||
{
|
||||
responseBytes[7] |= 0x80;
|
||||
responseBytes.Add((byte)ModbusErrorCode.SlaveDeviceFailure);
|
||||
}
|
||||
|
||||
return ReturnResponse(responseBytes);
|
||||
}
|
||||
|
||||
private async Task<byte[]> HandleReadInputRegistersAsync(byte[] requestBytes, CancellationToken cancellationToken)
|
||||
{
|
||||
if (requestBytes.Length < 12)
|
||||
return null;
|
||||
|
||||
byte unitId = requestBytes[6];
|
||||
ushort firstAddress = requestBytes.GetBigEndianUInt16(8);
|
||||
ushort count = requestBytes.GetBigEndianUInt16(10);
|
||||
|
||||
var responseBytes = new List<byte>();
|
||||
responseBytes.AddRange(requestBytes.Take(8));
|
||||
try
|
||||
{
|
||||
var inputRegisters = await Client.ReadInputRegistersAsync(unitId, firstAddress, count, cancellationToken).ConfigureAwait(continueOnCapturedContext: false);
|
||||
|
||||
byte[] values = new byte[count * 2];
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
values[i * 2] = inputRegisters[i].HighByte;
|
||||
values[i * 2 + 1] = inputRegisters[i].LowByte;
|
||||
}
|
||||
|
||||
responseBytes.Add((byte)values.Length);
|
||||
responseBytes.AddRange(values);
|
||||
}
|
||||
catch
|
||||
{
|
||||
responseBytes[7] |= 0x80;
|
||||
responseBytes.Add((byte)ModbusErrorCode.SlaveDeviceFailure);
|
||||
}
|
||||
|
||||
return ReturnResponse(responseBytes);
|
||||
}
|
||||
|
||||
private async Task<byte[]> HandleWriteSingleCoilAsync(byte[] requestBytes, CancellationToken cancellationToken)
|
||||
{
|
||||
if (requestBytes.Length < 12)
|
||||
return null;
|
||||
|
||||
var responseBytes = new List<byte>();
|
||||
responseBytes.AddRange(requestBytes.Take(8));
|
||||
|
||||
ushort address = requestBytes.GetBigEndianUInt16(8);
|
||||
|
||||
if (requestBytes[10] != 0x00 && requestBytes[10] != 0xFF)
|
||||
{
|
||||
responseBytes[7] |= 0x80;
|
||||
responseBytes.Add((byte)ModbusErrorCode.IllegalDataValue);
|
||||
|
||||
return ReturnResponse(responseBytes);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var coil = new Coil
|
||||
{
|
||||
Address = address,
|
||||
HighByte = requestBytes[10],
|
||||
LowByte = requestBytes[11],
|
||||
};
|
||||
|
||||
bool isSuccess = await Client.WriteSingleCoilAsync(requestBytes[6], coil, cancellationToken).ConfigureAwait(continueOnCapturedContext: false);
|
||||
if (isSuccess)
|
||||
{
|
||||
// Response is an echo of the request
|
||||
responseBytes.AddRange(requestBytes.Skip(8).Take(4));
|
||||
}
|
||||
else
|
||||
{
|
||||
responseBytes[7] |= 0x80;
|
||||
responseBytes.Add((byte)ModbusErrorCode.SlaveDeviceFailure);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
responseBytes[7] |= 0x80;
|
||||
responseBytes.Add((byte)ModbusErrorCode.SlaveDeviceFailure);
|
||||
}
|
||||
|
||||
return ReturnResponse(responseBytes);
|
||||
}
|
||||
|
||||
private async Task<byte[]> HandleWriteSingleRegisterAsync(byte[] requestBytes, CancellationToken cancellationToken)
|
||||
{
|
||||
if (requestBytes.Length < 12)
|
||||
return null;
|
||||
|
||||
var responseBytes = new List<byte>();
|
||||
responseBytes.AddRange(requestBytes.Take(8));
|
||||
|
||||
ushort address = requestBytes.GetBigEndianUInt16(8);
|
||||
|
||||
try
|
||||
{
|
||||
var register = new HoldingRegister
|
||||
{
|
||||
Address = address,
|
||||
HighByte = requestBytes[10],
|
||||
LowByte = requestBytes[11]
|
||||
};
|
||||
|
||||
bool isSuccess = await Client.WriteSingleHoldingRegisterAsync(requestBytes[6], register, cancellationToken).ConfigureAwait(continueOnCapturedContext: false);
|
||||
if (isSuccess)
|
||||
{
|
||||
// Response is an echo of the request
|
||||
responseBytes.AddRange(requestBytes.Skip(8).Take(4));
|
||||
}
|
||||
else
|
||||
{
|
||||
responseBytes[7] |= 0x80;
|
||||
responseBytes.Add((byte)ModbusErrorCode.SlaveDeviceFailure);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
responseBytes[7] |= 0x80;
|
||||
responseBytes.Add((byte)ModbusErrorCode.SlaveDeviceFailure);
|
||||
}
|
||||
|
||||
return ReturnResponse(responseBytes);
|
||||
}
|
||||
|
||||
private async Task<byte[]> HandleWriteMultipleCoilsAsync(byte[] requestBytes, CancellationToken cancellationToken)
|
||||
{
|
||||
if (requestBytes.Length < 13)
|
||||
return null;
|
||||
|
||||
var responseBytes = new List<byte>();
|
||||
responseBytes.AddRange(requestBytes.Take(8));
|
||||
|
||||
ushort firstAddress = requestBytes.GetBigEndianUInt16(8);
|
||||
ushort count = requestBytes.GetBigEndianUInt16(10);
|
||||
|
||||
int byteCount = (int)Math.Ceiling(count / 8.0);
|
||||
if (requestBytes.Length < 13 + byteCount)
|
||||
{
|
||||
responseBytes[7] |= 0x80;
|
||||
responseBytes.Add((byte)ModbusErrorCode.IllegalDataValue);
|
||||
|
||||
return ReturnResponse(responseBytes);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
int baseOffset = 13;
|
||||
var coils = new List<Coil>();
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
int bytePosition = i / 8;
|
||||
int bitPosition = i % 8;
|
||||
|
||||
ushort address = (ushort)(firstAddress + i);
|
||||
bool value = (requestBytes[baseOffset + bytePosition] & (1 << bitPosition)) > 0;
|
||||
|
||||
coils.Add(new Coil
|
||||
{
|
||||
Address = address,
|
||||
HighByte = value ? (byte)0xFF : (byte)0x00
|
||||
});
|
||||
}
|
||||
|
||||
bool isSuccess = await Client.WriteMultipleCoilsAsync(requestBytes[6], coils, cancellationToken).ConfigureAwait(continueOnCapturedContext: false);
|
||||
if (isSuccess)
|
||||
{
|
||||
// Response is an echo of the request
|
||||
responseBytes.AddRange(requestBytes.Skip(8).Take(4));
|
||||
}
|
||||
else
|
||||
{
|
||||
responseBytes[7] |= 0x80;
|
||||
responseBytes.Add((byte)ModbusErrorCode.SlaveDeviceFailure);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
responseBytes[7] |= 0x80;
|
||||
responseBytes.Add((byte)ModbusErrorCode.SlaveDeviceFailure);
|
||||
}
|
||||
|
||||
return ReturnResponse(responseBytes);
|
||||
}
|
||||
|
||||
private async Task<byte[]> HandleWriteMultipleRegistersAsync(byte[] requestBytes, CancellationToken cancellationToken)
|
||||
{
|
||||
if (requestBytes.Length < 13)
|
||||
return null;
|
||||
|
||||
var responseBytes = new List<byte>();
|
||||
responseBytes.AddRange(requestBytes.Take(8));
|
||||
|
||||
ushort firstAddress = requestBytes.GetBigEndianUInt16(8);
|
||||
ushort count = requestBytes.GetBigEndianUInt16(10);
|
||||
|
||||
int byteCount = count * 2;
|
||||
if (requestBytes.Length < 13 + byteCount)
|
||||
{
|
||||
responseBytes[7] |= 0x80;
|
||||
responseBytes.Add((byte)ModbusErrorCode.IllegalDataValue);
|
||||
|
||||
return ReturnResponse(responseBytes);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
int baseOffset = 13;
|
||||
var list = new List<HoldingRegister>();
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
ushort address = (ushort)(firstAddress + i);
|
||||
|
||||
list.Add(new HoldingRegister
|
||||
{
|
||||
Address = address,
|
||||
HighByte = requestBytes[baseOffset + i * 2],
|
||||
LowByte = requestBytes[baseOffset + i * 2 + 1]
|
||||
});
|
||||
}
|
||||
|
||||
bool isSuccess = await Client.WriteMultipleHoldingRegistersAsync(requestBytes[6], list, cancellationToken).ConfigureAwait(continueOnCapturedContext: false);
|
||||
if (isSuccess)
|
||||
{
|
||||
// Response is an echo of the request
|
||||
responseBytes.AddRange(requestBytes.Skip(8).Take(4));
|
||||
}
|
||||
else
|
||||
{
|
||||
responseBytes[7] |= 0x80;
|
||||
responseBytes.Add((byte)ModbusErrorCode.SlaveDeviceFailure);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
responseBytes[7] |= 0x80;
|
||||
responseBytes.Add((byte)ModbusErrorCode.SlaveDeviceFailure);
|
||||
}
|
||||
|
||||
return ReturnResponse(responseBytes);
|
||||
}
|
||||
|
||||
private async Task<byte[]> HandleEncapsulatedInterfaceAsync(byte[] requestBytes, CancellationToken cancellationToken)
|
||||
{
|
||||
if (requestBytes.Length < 11)
|
||||
return null;
|
||||
|
||||
var responseBytes = new List<byte>();
|
||||
responseBytes.AddRange(requestBytes.Take(8));
|
||||
|
||||
if (requestBytes[8] != 0x0E)
|
||||
{
|
||||
responseBytes[7] |= 0x80;
|
||||
responseBytes.Add((byte)ModbusErrorCode.IllegalFunction);
|
||||
|
||||
return ReturnResponse(responseBytes);
|
||||
}
|
||||
|
||||
var firstObject = (ModbusDeviceIdentificationObject)requestBytes[10];
|
||||
if (0x06 < requestBytes[10] && requestBytes[10] < 0x80)
|
||||
{
|
||||
responseBytes[7] |= 0x80;
|
||||
responseBytes.Add((byte)ModbusErrorCode.IllegalDataAddress);
|
||||
|
||||
return ReturnResponse(responseBytes);
|
||||
}
|
||||
|
||||
var category = (ModbusDeviceIdentificationCategory)requestBytes[9];
|
||||
if (!Enum.IsDefined(typeof(ModbusDeviceIdentificationCategory), category))
|
||||
{
|
||||
responseBytes[7] |= 0x80;
|
||||
responseBytes.Add((byte)ModbusErrorCode.IllegalDataValue);
|
||||
|
||||
return ReturnResponse(responseBytes);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var deviceInfo = await Client.ReadDeviceIdentificationAsync(requestBytes[6], category, firstObject, cancellationToken).ConfigureAwait(continueOnCapturedContext: false);
|
||||
|
||||
var bodyBytes = new List<byte>();
|
||||
|
||||
// MEI, Category
|
||||
bodyBytes.AddRange(requestBytes.Skip(8).Take(2));
|
||||
|
||||
// Conformity
|
||||
bodyBytes.Add((byte)category);
|
||||
if (deviceInfo.IsIndividualAccessAllowed)
|
||||
bodyBytes[2] |= 0x80;
|
||||
|
||||
// More, NextId, NumberOfObjects
|
||||
bodyBytes.AddRange(new byte[3]);
|
||||
|
||||
int maxObjectId = category switch
|
||||
{
|
||||
ModbusDeviceIdentificationCategory.Basic => 0x02,
|
||||
ModbusDeviceIdentificationCategory.Regular => 0x06,
|
||||
ModbusDeviceIdentificationCategory.Extended => 0xFF,
|
||||
// Individual
|
||||
_ => requestBytes[10],
|
||||
};
|
||||
|
||||
byte numberOfObjects = 0;
|
||||
for (int i = requestBytes[10]; i <= maxObjectId; i++)
|
||||
{
|
||||
// Reserved
|
||||
if (0x07 <= i && i <= 0x7F)
|
||||
continue;
|
||||
|
||||
byte[] objBytes = GetDeviceObject((byte)i, deviceInfo);
|
||||
|
||||
// We need to split the response if it would exceed the max ADU size
|
||||
if (responseBytes.Count + bodyBytes.Count + objBytes.Length > TcpProtocol.MAX_ADU_LENGTH)
|
||||
{
|
||||
bodyBytes[3] = 0xFF;
|
||||
bodyBytes[4] = (byte)i;
|
||||
|
||||
bodyBytes[5] = numberOfObjects;
|
||||
responseBytes.AddRange(bodyBytes);
|
||||
|
||||
return ReturnResponse(responseBytes);
|
||||
}
|
||||
|
||||
bodyBytes.AddRange(objBytes);
|
||||
numberOfObjects++;
|
||||
}
|
||||
|
||||
bodyBytes[5] = numberOfObjects;
|
||||
responseBytes.AddRange(bodyBytes);
|
||||
|
||||
return ReturnResponse(responseBytes);
|
||||
}
|
||||
catch
|
||||
{
|
||||
responseBytes[7] |= 0x80;
|
||||
responseBytes.Add((byte)ModbusErrorCode.SlaveDeviceFailure);
|
||||
|
||||
return ReturnResponse(responseBytes);
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] GetDeviceObject(byte objectId, DeviceIdentification deviceIdentification)
|
||||
{
|
||||
var result = new List<byte> { objectId };
|
||||
switch ((ModbusDeviceIdentificationObject)objectId)
|
||||
{
|
||||
case ModbusDeviceIdentificationObject.VendorName:
|
||||
{
|
||||
byte[] bytes = Encoding.UTF8.GetBytes(deviceIdentification.VendorName ?? "");
|
||||
result.Add((byte)bytes.Length);
|
||||
result.AddRange(bytes);
|
||||
}
|
||||
break;
|
||||
|
||||
case ModbusDeviceIdentificationObject.ProductCode:
|
||||
{
|
||||
byte[] bytes = Encoding.UTF8.GetBytes(deviceIdentification.ProductCode ?? "");
|
||||
result.Add((byte)bytes.Length);
|
||||
result.AddRange(bytes);
|
||||
}
|
||||
break;
|
||||
|
||||
case ModbusDeviceIdentificationObject.MajorMinorRevision:
|
||||
{
|
||||
byte[] bytes = Encoding.UTF8.GetBytes(deviceIdentification.MajorMinorRevision ?? "");
|
||||
result.Add((byte)bytes.Length);
|
||||
result.AddRange(bytes);
|
||||
}
|
||||
break;
|
||||
|
||||
case ModbusDeviceIdentificationObject.VendorUrl:
|
||||
{
|
||||
byte[] bytes = Encoding.UTF8.GetBytes(deviceIdentification.VendorUrl ?? "");
|
||||
result.Add((byte)bytes.Length);
|
||||
result.AddRange(bytes);
|
||||
}
|
||||
break;
|
||||
|
||||
case ModbusDeviceIdentificationObject.ProductName:
|
||||
{
|
||||
byte[] bytes = Encoding.UTF8.GetBytes(deviceIdentification.ProductName ?? "");
|
||||
result.Add((byte)bytes.Length);
|
||||
result.AddRange(bytes);
|
||||
}
|
||||
break;
|
||||
|
||||
case ModbusDeviceIdentificationObject.ModelName:
|
||||
{
|
||||
byte[] bytes = Encoding.UTF8.GetBytes(deviceIdentification.ModelName ?? "");
|
||||
result.Add((byte)bytes.Length);
|
||||
result.AddRange(bytes);
|
||||
}
|
||||
break;
|
||||
|
||||
case ModbusDeviceIdentificationObject.UserApplicationName:
|
||||
{
|
||||
byte[] bytes = Encoding.UTF8.GetBytes(deviceIdentification.UserApplicationName ?? "");
|
||||
result.Add((byte)bytes.Length);
|
||||
result.AddRange(bytes);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
{
|
||||
if (deviceIdentification.ExtendedObjects.TryGetValue(objectId, out byte[] bytes))
|
||||
{
|
||||
result.Add((byte)bytes.Length);
|
||||
result.AddRange(bytes);
|
||||
}
|
||||
else
|
||||
{
|
||||
result.Add(0x00);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return [.. result];
|
||||
}
|
||||
|
||||
private static byte[] ReturnResponse(List<byte> response)
|
||||
{
|
||||
ushort followingBytes = (ushort)(response.Count - 6);
|
||||
var bytes = followingBytes.ToBigEndianBytes();
|
||||
response[4] = bytes[0];
|
||||
response[5] = bytes[1];
|
||||
|
||||
return [.. response];
|
||||
}
|
||||
|
||||
#endregion Request Handling
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string ToString()
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
|
||||
sb.AppendLine($"TCP Proxy");
|
||||
sb.AppendLine($" {nameof(ListenAddress)}: {ListenAddress}");
|
||||
sb.AppendLine($" {nameof(ListenPort)}: {ListenPort}");
|
||||
sb.AppendLine($" {nameof(Client)}: {Client.GetType().Name}");
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
95
src/AMWD.Protocols.Modbus.Tcp/ModbusTcpServer.cs
Normal file
95
src/AMWD.Protocols.Modbus.Tcp/ModbusTcpServer.cs
Normal file
@@ -0,0 +1,95 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
using AMWD.Protocols.Modbus.Common;
|
||||
using AMWD.Protocols.Modbus.Common.Events;
|
||||
using AMWD.Protocols.Modbus.Common.Utils;
|
||||
|
||||
namespace AMWD.Protocols.Modbus.Tcp
|
||||
{
|
||||
/// <summary>
|
||||
/// Implements a Modbus TCP server proxying all requests to a virtual Modbus client.
|
||||
/// </summary>
|
||||
[System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
|
||||
public class ModbusTcpServer : ModbusTcpProxy
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ModbusTcpServer"/> class.
|
||||
/// </summary>
|
||||
/// <param name="listenAddress">The <see cref="IPAddress"/> to listen on.</param>
|
||||
public ModbusTcpServer(IPAddress listenAddress)
|
||||
: base(new VirtualModbusClient(), listenAddress)
|
||||
{
|
||||
TypedClient.CoilWritten += (sender, e) => CoilWritten?.Invoke(this, e);
|
||||
TypedClient.RegisterWritten += (sender, e) => RegisterWritten?.Invoke(this, e);
|
||||
}
|
||||
|
||||
#region Events
|
||||
|
||||
/// <summary>
|
||||
/// Indicates that a <see cref="Coil"/>-value received through a remote client has been written.
|
||||
/// </summary>
|
||||
public event EventHandler<CoilWrittenEventArgs> CoilWritten;
|
||||
|
||||
/// <summary>
|
||||
/// Indicates that a <see cref="HoldingRegister"/>-value received from a remote client has been written.
|
||||
/// </summary>
|
||||
public event EventHandler<RegisterWrittenEventArgs> RegisterWritten;
|
||||
|
||||
#endregion Events
|
||||
|
||||
#region Properties
|
||||
|
||||
internal VirtualModbusClient TypedClient
|
||||
=> Client as VirtualModbusClient;
|
||||
|
||||
#endregion Properties
|
||||
|
||||
#region Device Handling
|
||||
|
||||
/// <inheritdoc cref="VirtualModbusClient.AddDevice(byte)"/>
|
||||
public bool AddDevice(byte unitId)
|
||||
=> TypedClient.AddDevice(unitId);
|
||||
|
||||
/// <inheritdoc cref="VirtualModbusClient.RemoveDevice(byte)"/>
|
||||
public bool RemoveDevice(byte unitId)
|
||||
=> TypedClient.RemoveDevice(unitId);
|
||||
|
||||
#endregion Device Handling
|
||||
|
||||
#region Entity Handling
|
||||
|
||||
/// <inheritdoc cref="VirtualModbusClient.GetCoil(byte, ushort)"/>
|
||||
public Coil GetCoil(byte unitId, ushort address)
|
||||
=> TypedClient.GetCoil(unitId, address);
|
||||
|
||||
/// <inheritdoc cref="VirtualModbusClient.SetCoil(byte, Coil)"/>
|
||||
public void SetCoil(byte unitId, Coil coil)
|
||||
=> TypedClient.SetCoil(unitId, coil);
|
||||
|
||||
/// <inheritdoc cref="VirtualModbusClient.GetDiscreteInput(byte, ushort)"/>
|
||||
public DiscreteInput GetDiscreteInput(byte unitId, ushort address)
|
||||
=> TypedClient.GetDiscreteInput(unitId, address);
|
||||
|
||||
/// <inheritdoc cref="VirtualModbusClient.SetDiscreteInput(byte, DiscreteInput)"/>
|
||||
public void SetDiscreteInput(byte unitId, DiscreteInput discreteInput)
|
||||
=> TypedClient.SetDiscreteInput(unitId, discreteInput);
|
||||
|
||||
/// <inheritdoc cref="VirtualModbusClient.GetHoldingRegister(byte, ushort)"/>
|
||||
public HoldingRegister GetHoldingRegister(byte unitId, ushort address)
|
||||
=> TypedClient.GetHoldingRegister(unitId, address);
|
||||
|
||||
/// <inheritdoc cref="VirtualModbusClient.SetHoldingRegister(byte, HoldingRegister)"/>
|
||||
public void SetHoldingRegister(byte unitId, HoldingRegister holdingRegister)
|
||||
=> TypedClient.SetHoldingRegister(unitId, holdingRegister);
|
||||
|
||||
/// <inheritdoc cref="VirtualModbusClient.GetInputRegister(byte, ushort)"/>
|
||||
public InputRegister GetInputRegister(byte unitId, ushort address)
|
||||
=> TypedClient.GetInputRegister(unitId, address);
|
||||
|
||||
/// <inheritdoc cref="VirtualModbusClient.SetInputRegister(byte, InputRegister)"/>
|
||||
public void SetInputRegister(byte unitId, InputRegister inputRegister)
|
||||
=> TypedClient.SetInputRegister(unitId, inputRegister);
|
||||
|
||||
#endregion Entity Handling
|
||||
}
|
||||
}
|
||||
53
src/AMWD.Protocols.Modbus.Tcp/README.md
Normal file
53
src/AMWD.Protocols.Modbus.Tcp/README.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# Modbus Protocol for .NET | TCP
|
||||
|
||||
The Modbus TCP protocol implementation.
|
||||
|
||||
## Example
|
||||
|
||||
A simple example which reads the voltage between L1 and N of a Janitza device.
|
||||
|
||||
```csharp
|
||||
string host = "modbus-device.internal";
|
||||
int port = 502;
|
||||
|
||||
using var client = new ModbusTcpClient(host, port);
|
||||
await client.ConnectAsync(CancellationToken.None);
|
||||
|
||||
byte unitId = 5;
|
||||
ushort startAddress = 19000;
|
||||
ushort count = 2;
|
||||
|
||||
var registers = await client.ReadHoldingRegistersAsync(unitId, startAddress, count);
|
||||
float voltage = registers.GetSingle();
|
||||
|
||||
Console.WriteLine($"The voltage of device #{unitId} between L1 and N is: {voltage:N2}V");
|
||||
```
|
||||
|
||||
If you have a device speaking `RTU` connected over `TCP`, you can use it as followed:
|
||||
|
||||
```csharp
|
||||
// [...]
|
||||
|
||||
using var client = new ModbusTcpClient(host, port)
|
||||
{
|
||||
Protocol = new RtuProtocol()
|
||||
};
|
||||
|
||||
// [...]
|
||||
```
|
||||
|
||||
## Sources
|
||||
|
||||
- Protocol Specification: [v1.1b3]
|
||||
- Modbus TCP/IP: [v1.0b]
|
||||
|
||||
|
||||
---
|
||||
|
||||
Published under MIT License (see [choose a license])
|
||||
|
||||
|
||||
|
||||
[v1.1b3]: https://modbus.org/docs/Modbus_Application_Protocol_V1_1b3.pdf
|
||||
[v1.0b]: https://modbus.org/docs/Modbus_Messaging_Implementation_Guide_V1_0b.pdf
|
||||
[choose a license]: https://choosealicense.com/licenses/mit/
|
||||
30
src/AMWD.Protocols.Modbus.Tcp/Utils/IPEndPointWrapper.cs
Normal file
30
src/AMWD.Protocols.Modbus.Tcp/Utils/IPEndPointWrapper.cs
Normal file
@@ -0,0 +1,30 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
|
||||
namespace AMWD.Protocols.Modbus.Tcp.Utils
|
||||
{
|
||||
/// <inheritdoc cref="IPEndPoint" />
|
||||
[System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
|
||||
internal class IPEndPointWrapper(EndPoint endPoint)
|
||||
{
|
||||
private readonly IPEndPoint _ipEndPoint = (IPEndPoint)endPoint;
|
||||
|
||||
#region Properties
|
||||
|
||||
/// <inheritdoc cref="IPEndPoint.Address"/>
|
||||
public virtual IPAddress Address
|
||||
{
|
||||
get => _ipEndPoint.Address;
|
||||
set => _ipEndPoint.Address = value;
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="IPEndPoint.Port"/>
|
||||
public virtual int Port
|
||||
{
|
||||
get => _ipEndPoint.Port;
|
||||
set => _ipEndPoint.Port = value;
|
||||
}
|
||||
|
||||
#endregion Properties
|
||||
}
|
||||
}
|
||||
43
src/AMWD.Protocols.Modbus.Tcp/Utils/NetworkStreamWrapper.cs
Normal file
43
src/AMWD.Protocols.Modbus.Tcp/Utils/NetworkStreamWrapper.cs
Normal file
@@ -0,0 +1,43 @@
|
||||
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(NetworkStream stream) : IDisposable
|
||||
{
|
||||
private readonly NetworkStream _stream = stream;
|
||||
|
||||
/// <inheritdoc cref="NetworkStream.Dispose" />
|
||||
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);
|
||||
}
|
||||
}
|
||||
26
src/AMWD.Protocols.Modbus.Tcp/Utils/SocketWrapper.cs
Normal file
26
src/AMWD.Protocols.Modbus.Tcp/Utils/SocketWrapper.cs
Normal file
@@ -0,0 +1,26 @@
|
||||
using System;
|
||||
using System.Net.Sockets;
|
||||
|
||||
namespace AMWD.Protocols.Modbus.Tcp.Utils
|
||||
{
|
||||
/// <inheritdoc cref="Socket" />
|
||||
[System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
|
||||
internal class SocketWrapper(Socket socket) : IDisposable
|
||||
{
|
||||
private readonly Socket _socket = socket;
|
||||
|
||||
/// <inheritdoc cref="Socket.DualMode" />
|
||||
public virtual bool DualMode
|
||||
{
|
||||
get => _socket.DualMode;
|
||||
set => _socket.DualMode = value;
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="Socket.IsBound" />
|
||||
public virtual bool IsBound
|
||||
=> _socket.IsBound;
|
||||
|
||||
public virtual void Dispose()
|
||||
=> _socket.Dispose();
|
||||
}
|
||||
}
|
||||
83
src/AMWD.Protocols.Modbus.Tcp/Utils/TcpClientWrapper.cs
Normal file
83
src/AMWD.Protocols.Modbus.Tcp/Utils/TcpClientWrapper.cs
Normal file
@@ -0,0 +1,83 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.Transactions;
|
||||
|
||||
namespace AMWD.Protocols.Modbus.Tcp.Utils
|
||||
{
|
||||
/// <inheritdoc cref="TcpClient" />
|
||||
[System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
|
||||
internal class TcpClientWrapper : IDisposable
|
||||
{
|
||||
#region Fields
|
||||
|
||||
private readonly TcpClient _client;
|
||||
|
||||
#endregion Fields
|
||||
|
||||
public TcpClientWrapper(AddressFamily addressFamily)
|
||||
{
|
||||
_client = new TcpClient(addressFamily);
|
||||
}
|
||||
|
||||
public TcpClientWrapper(TcpClient client)
|
||||
{
|
||||
_client = client ?? throw new ArgumentNullException(nameof(client));
|
||||
}
|
||||
|
||||
#region Properties
|
||||
|
||||
/// <inheritdoc cref="TcpClient.Connected" />
|
||||
public virtual bool Connected => _client.Connected;
|
||||
|
||||
/// <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;
|
||||
}
|
||||
|
||||
#endregion Properties
|
||||
|
||||
#region Methods
|
||||
|
||||
/// <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.GetStream" />
|
||||
public virtual NetworkStreamWrapper GetStream()
|
||||
=> new(_client.GetStream());
|
||||
|
||||
#endregion Methods
|
||||
|
||||
#region IDisposable
|
||||
|
||||
/// <inheritdoc cref="TcpClient.Dispose()" />
|
||||
public virtual void Dispose()
|
||||
=> _client.Dispose();
|
||||
|
||||
#endregion IDisposable
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using System;
|
||||
using System.Net.Sockets;
|
||||
|
||||
namespace AMWD.Protocols.Modbus.Tcp.Utils
|
||||
{
|
||||
/// <summary>
|
||||
/// Factory for creating <see cref="TcpClientWrapper"/> instances.
|
||||
/// </summary>
|
||||
[System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
|
||||
internal class TcpClientWrapperFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new instance of <see cref="TcpClientWrapper"/>.
|
||||
/// </summary>
|
||||
/// <param name="addressFamily">The <see cref="AddressFamily"/> of the <see cref="TcpClient"/> to use.</param>
|
||||
/// <param name="readTimeout">The read timeout.</param>
|
||||
/// <param name="writeTimeout">The write timeout.</param>
|
||||
/// <returns>A new <see cref="TcpClientWrapper"/> instance.</returns>
|
||||
public virtual TcpClientWrapper Create(AddressFamily addressFamily, TimeSpan readTimeout, TimeSpan writeTimeout)
|
||||
{
|
||||
var client = new TcpClientWrapper(addressFamily)
|
||||
{
|
||||
ReceiveTimeout = (int)readTimeout.TotalMilliseconds,
|
||||
SendTimeout = (int)writeTimeout.TotalMilliseconds
|
||||
};
|
||||
return client;
|
||||
}
|
||||
}
|
||||
}
|
||||
87
src/AMWD.Protocols.Modbus.Tcp/Utils/TcpListenerWrapper.cs
Normal file
87
src/AMWD.Protocols.Modbus.Tcp/Utils/TcpListenerWrapper.cs
Normal file
@@ -0,0 +1,87 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace AMWD.Protocols.Modbus.Tcp.Utils
|
||||
{
|
||||
/// <inheritdoc cref="TcpListener" />
|
||||
[System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
|
||||
internal class TcpListenerWrapper(IPAddress localaddr, int port) : IDisposable
|
||||
{
|
||||
#region Fields
|
||||
|
||||
private readonly TcpListener _tcpListener = new(localaddr, port);
|
||||
|
||||
#endregion Fields
|
||||
|
||||
#region Constructor
|
||||
|
||||
#endregion Constructor
|
||||
|
||||
#region Properties
|
||||
|
||||
/// <inheritdoc cref="TcpListener.LocalEndpoint"/>
|
||||
public virtual IPEndPointWrapper LocalIPEndPoint
|
||||
=> new(_tcpListener.LocalEndpoint);
|
||||
|
||||
public virtual SocketWrapper Socket
|
||||
=> new(_tcpListener.Server);
|
||||
|
||||
#endregion Properties
|
||||
|
||||
#region Methods
|
||||
|
||||
/// <summary>
|
||||
/// Accepts a pending connection request as a cancellable asynchronous operation.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This operation will not block. The returned <see cref="Task{TResult}"/> object will complete after the TCP connection has been accepted.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Use the <see cref="TcpClientWrapper.GetStream"/> method to obtain the underlying <see cref="NetworkStreamWrapper"/> of the returned <see cref="TcpClientWrapper"/> in the <see cref="Task{TResult}"/>.
|
||||
/// The <see cref="NetworkStreamWrapper"/> will provide you with methods for sending and receiving with the remote host.
|
||||
/// When you are through with the <see cref="TcpClientWrapper"/>, be sure to call its <see cref="TcpClientWrapper.Close"/> method.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="cancellationToken">A cancellation token that can be used to cancel the asynchronous operation</param>
|
||||
/// <returns>
|
||||
/// The task object representing the asynchronous operation.
|
||||
/// The <see cref="Task{TResult}.Result"/> property on the task object returns a <see cref="TcpClientWrapper"/> used to send and receive data.
|
||||
/// </returns>
|
||||
/// <exception cref="InvalidOperationException">The listener has not been started with a call to <see cref="Start"/>.</exception>
|
||||
/// <exception cref="SocketException">
|
||||
/// Use the <see cref="SocketException.ErrorCode"/> property to obtain the specific error code.
|
||||
/// When you have obtained this code, you can refer to the
|
||||
/// <see href="https://learn.microsoft.com/en-us/windows/desktop/winsock/windows-sockets-error-codes-2">Windows Sockets version 2 API error code</see>
|
||||
/// documentation for a detailed description of the error.
|
||||
/// </exception>
|
||||
/// <exception cref="OperationCanceledException">The cancellation token was canceled. This exception is stored into the returned task.</exception>
|
||||
public virtual async Task<TcpClientWrapper> AcceptTcpClientAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
#if NET8_0_OR_GREATER
|
||||
var tcpClient = await _tcpListener.AcceptTcpClientAsync(cancellationToken);
|
||||
#else
|
||||
var tcpClient = await _tcpListener.AcceptTcpClientAsync();
|
||||
#endif
|
||||
return new TcpClientWrapper(tcpClient);
|
||||
}
|
||||
|
||||
public virtual void Start()
|
||||
=> _tcpListener.Start();
|
||||
|
||||
public virtual void Stop()
|
||||
=> _tcpListener.Stop();
|
||||
|
||||
public virtual void Dispose()
|
||||
{
|
||||
#if NET8_0_OR_GREATER
|
||||
_tcpListener.Dispose();
|
||||
#endif
|
||||
}
|
||||
|
||||
#endregion Methods
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user