Moving structure as preparation for docs

This commit is contained in:
2025-08-06 21:05:47 +02:00
parent 885079ae70
commit 799a014b15
117 changed files with 629 additions and 664 deletions

View File

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

View 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;
}
}
}

View 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 */ }
}
}
}

View 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();
}
}
}

View 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
}
}

View 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();
}
}
}

View 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
}
}

View 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/

View 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
}
}

View 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);
}
}

View 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();
}
}

View 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
}
}

View File

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

View 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
}
}