csharp/AndreasAmMueller/Modbus/src/Modbus.Tcp/Client/ModbusClient.cs

ModbusClient.cs
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Sockets;
using System.Runtime.InteropServices;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using AMWD.Modbus.Common;
using AMWD.Modbus.Common.Interfaces;
using AMWD.Modbus.Common.Structures;
using AMWD.Modbus.Common.Util;
using AMWD.Modbus.Tcp.Protocol;
using AMWD.Modbus.Tcp.Util;
using Microsoft.Extensions.Logging;

namespace AMWD.Modbus.Tcp.Client
{
	/// 
	/// A client to communicate with modbus devices via TCP.
	/// 
	public clast ModbusClient : IModbusClient
	{
		#region Fields

		private readonly ILogger logger;
		private readonly object reconnectLock = new();

		private readonly object trxIdLock = new();
		private readonly SemapreplacedSlim sendLock = new(1, 1);
		private readonly SemapreplacedSlim queueLock = new(1, 1);
		private readonly List awaitingResponses = new();

		private TimeSpan keepAlive = TimeSpan.FromSeconds(30);

		private CancellationTokenSource stopCts;
		private CancellationTokenSource receiveCts;
		private TaskCompletionSource reconnectTcs;

		private Task receiveTask = Task.CompletedTask;

		private TcpClient tcpClient;
		private NetworkStream stream;

		private bool isStarted;
		private bool isReconnecting;
		private bool wasConnected;

		private ushort transactionId;

		#endregion Fields

		#region Constructors

		/// 
		/// Initializes a new instance of the  clast.
		/// 
		/// The remote host name or ip address.
		/// The remote port (Default: 502).
		/// A logger (optional).
		public ModbusClient(string host, int port = 502, ILogger logger = null)
		{
			if (string.IsNullOrWhiteSpace(host))
				throw new ArgumentNullException(nameof(host), "A hostname is required.");

			if (port < 1 || ushort.MaxValue < port)
				throw new ArgumentOutOfRangeException(nameof(port), $"The port should be between 1 and {ushort.MaxValue}.");

			this.logger = logger;
			Host = host;
			Port = port;
		}

		/// 
		/// Initializes a new instance of the  clast.
		/// 
		/// The remote host name of ip address.
		/// A logger.
		public ModbusClient(string host, ILogger logger)
			: this(host, 502, logger)
		{ }

		/// 
		/// Initializes a new instance of the  clast.
		/// 
		/// The ip address of the remote host.
		/// The remote port (Default: 502).
		/// A logger (optional).
		public ModbusClient(IPAddress address, int port = 502, ILogger logger = null)
			: this(address.ToString(), port, logger)
		{ }

		/// 
		/// Initializes a new instance of the  clast.
		/// 
		/// The ip address of the remote host.
		/// A logger.
		public ModbusClient(IPAddress address, ILogger logger)
			: this(address, 502, logger)
		{ }

		#endregion Constructors

		#region Events

		/// 
		/// Raised when the client has the connection successfully established.
		/// 
		public event EventHandler Connected;

		/// 
		/// Raised when the client has closed the connection.
		/// 
		public event EventHandler Disconnected;

		#endregion Events

		#region Properties

		/// 
		/// Gets or sets the host name.
		/// 
		public string Host { get; }

		/// 
		/// Gets or sets the port.
		/// 
		public int Port { get; }

		/// 
		/// Gets the result of the asynchronous initialization of this instance.
		/// 
		public Task ConnectingTask { get; private set; } = Task.CompletedTask;

		/// 
		/// Gets a value indicating whether the connection is established.
		/// 
		public bool IsConnected { get; private set; }

		/// 
		/// Gets or sets the max. reconnect timespan until the reconnect is aborted.
		/// 
		public TimeSpan ReconnectTimeSpan { get; set; } = TimeSpan.MaxValue;

		/// 
		/// Gets or sets the max. timeout per try to connect.
		/// 
		/// 
		/// The connect timeout starts with 2 seconds and on each try another 2 seconds
		/// will be added until this maximum is reached.
		/// 
		public TimeSpan MaxConnectTimeout { get; set; } = TimeSpan.FromSeconds(30);

		/// 
		/// Gets or sets the send timeout. Default: 1 second.
		/// 
		public TimeSpan SendTimeout { get; set; } = TimeSpan.FromSeconds(1);

		/// 
		/// Gets ors sets the receive timeout. Default: 1 second.
		/// 
		public TimeSpan ReceiveTimeout { get; set; } = TimeSpan.FromSeconds(1);

		/// 
		/// WINDOWS ONLY: Gets or sets the timespan when a keep alive package should be sent.
		/// 
		/// 
		/// Set it to  to disable keep-alive packages.
		/// 
		public TimeSpan KeepAliveTimeSpan
		{
			get => keepAlive;
			set
			{
				if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
					logger?.LogWarning("You're setting a windows-only property. See: https://github.com/AndreasAmMueller/Modbus/issues/17#issuecomment-867412794");

				keepAlive = value.TotalMilliseconds < 0 ? TimeSpan.Zero : value;
				SetKeepAlive();
			}
		}

		/// 
		/// Gets or sets a value indicating whether to disable the transaction id check.  NOT RECOMMENDED
		/// 
		public bool DisableTransactionId { get; set; }

		#endregion Properties

		#region Public methods

		#region Control

		/// 
		/// Connects the client to the remote host.
		/// 
		/// An awaitable task.
		public async Task Connect(CancellationToken cancellationToken = default)
		{
			var cancelTask = ReconnectTimeSpan == TimeSpan.MaxValue
				? Task.Delay(Timeout.Infinite, cancellationToken)
				: Task.Delay(ReconnectTimeSpan, cancellationToken);

			try
			{
				logger?.LogTrace("ModbusClient.Connect enter");
				CheckDisposed();

				if (isStarted)
				{
					await Task.WhenAny(ConnectingTask, cancelTask);
					return;
				}
				isStarted = true;
				stopCts = new CancellationTokenSource();

				logger?.LogInformation("Modbus client starting.");

				lock (reconnectLock)
				{
					transactionId = 0;
					IsConnected = false;
					wasConnected = false;
					ConnectingTask = GetReconnectTask(true);
				}

				logger?.LogInformation("Modbus client started.");
				await Task.WhenAny(ConnectingTask, cancelTask);
			}
			catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
			{ }
			finally
			{
				if (cancelTask.Status != TaskStatus.WaitingForActivation)
					cancelTask.Dispose();
				logger?.LogTrace("ModbusClient.Connect leave");
			}
		}

		/// 
		/// Disconnects the client.
		/// 
		/// An awaitable task.
		public async Task Disconnect(CancellationToken cancellationToken = default)
		{
			try
			{
				logger?.LogTrace("ModbusClient.Disconnect enter");
				CheckDisposed();

				if (!isStarted)
					return;

				logger?.LogInformation("Modbus client stopping.");

				stopCts.Cancel();
				receiveCts?.Cancel();

				bool connected = false;
				lock (reconnectLock)
				{
					connected = IsConnected;
					IsConnected = false;
					wasConnected = false;
				}

				await Task.WhenAny(ConnectingTask, Task.Delay(Timeout.Infinite, cancellationToken));

				stream?.Dispose();
				tcpClient?.Dispose();

				isStarted = false;
				logger?.LogInformation("Modbus client stopped.");

				if (connected)
					Task.Run(() => Disconnected?.Invoke(this, EventArgs.Empty)).Forget();
			}
			catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
			{ }
			finally
			{
				logger?.LogTrace("ModbusClient.Disconnect leave");
			}
		}

		#endregion Control

		#region Read Methods

		/// 
		/// Reads one or more coils of a device. (Modbus function 1).
		/// 
		/// The id to address the device (slave).
		/// The first coil number to read.
		/// The number of coils to read.
		/// A cancellation token to abort the action.
		/// A list of coils or null on error.
		public async Task ReadCoils(byte deviceId, ushort startAddress, ushort count, CancellationToken cancellationToken = default)
		{
			try
			{
				logger?.LogTrace("ModbusClient.ReadCoils enter");

				if (deviceId < Consts.MinDeviceIdTcp || Consts.MaxDeviceId < deviceId)
					throw new ArgumentOutOfRangeException(nameof(deviceId));

				if (startAddress < Consts.MinAddress || Consts.MaxAddress < startAddress + count)
					throw new ArgumentOutOfRangeException(nameof(startAddress));

				if (count < Consts.MinCount || Consts.MaxCoilCountRead < count)
					throw new ArgumentOutOfRangeException(nameof(count));

				logger?.LogDebug($"Read coils from device #{deviceId} starting on {startAddress} for {count} coils.");

				List list = null;
				try
				{
					var request = new Request
					{
						DeviceId = deviceId,
						Function = FunctionCode.ReadCoils,
						Address = startAddress,
						Count = count
					};
					var response = await SendRequest(request, cancellationToken);
					if (response.IsTimeout)
						throw new SocketException((int)SocketError.TimedOut);

					if (response.IsError)
					{
						throw new ModbusException(response.ErrorMessage)
						{
							ErrorCode = response.ErrorCode
						};
					}
					if (request.TransactionId != response.TransactionId)
						throw new ModbusException(nameof(response.TransactionId) + " does not match");

					list = new List();
					for (int i = 0; i < count; i++)
					{
						int posByte = i / 8;
						int posBit = i % 8;

						int val = response.Data[posByte] & (byte)Math.Pow(2, posBit);

						list.Add(new Coil
						{
							Address = (ushort)(startAddress + i),
							BoolValue = val > 0
						});
					}
				}
				catch (SocketException ex)
				{
					logger?.LogWarning(ex, $"Reading coils failed: {ex.GetMessage()}, reconnecting.");
					if (!isReconnecting)
						ConnectingTask = GetReconnectTask();
				}
				catch (IOException ex)
				{
					logger?.LogWarning(ex, $"Reading coils failed: {ex.GetMessage()}, reconnecting.");
					if (!isReconnecting)
						ConnectingTask = GetReconnectTask();
				}

				return list;
			}
			finally
			{
				logger?.LogTrace("ModbusClient.ReadCoils leave");
			}
		}

		/// 
		/// Reads one or more discrete inputs of a device. (Modbus function 2).
		/// 
		/// The id to address the device (slave).
		/// The first discrete input number to read.
		/// The number of discrete inputs to read.
		/// A cancellation token to abort the action.
		/// A list of discrete inputs or null on error.
		public async Task ReadDiscreteInputs(byte deviceId, ushort startAddress, ushort count, CancellationToken cancellationToken = default)
		{
			try
			{
				logger?.LogTrace("ModbusClient.ReadDiscreteInputs enter");

				if (deviceId < Consts.MinDeviceIdTcp || Consts.MaxDeviceId < deviceId)
					throw new ArgumentOutOfRangeException(nameof(deviceId));

				if (startAddress < Consts.MinAddress || Consts.MaxAddress < startAddress + count)
					throw new ArgumentOutOfRangeException(nameof(startAddress));

				if (count < Consts.MinCount || Consts.MaxCoilCountRead < count)
					throw new ArgumentOutOfRangeException(nameof(count));

				logger?.LogDebug($"Reading discrete inputs from device #{deviceId} starting on {startAddress} for {count} inputs.");

				List list = null;
				try
				{
					var request = new Request
					{
						DeviceId = deviceId,
						Function = FunctionCode.ReadDiscreteInputs,
						Address = startAddress,
						Count = count
					};
					var response = await SendRequest(request, cancellationToken);
					if (response.IsTimeout)
						throw new SocketException((int)SocketError.TimedOut);

					if (response.IsError)
					{
						throw new ModbusException(response.ErrorMessage)
						{
							ErrorCode = response.ErrorCode
						};
					}
					if (request.TransactionId != response.TransactionId)
						throw new ModbusException(nameof(response.TransactionId) + " does not match");

					list = new List();
					for (int i = 0; i < count; i++)
					{
						int posByte = i / 8;
						int posBit = i % 8;

						int val = response.Data[posByte] & (byte)Math.Pow(2, posBit);

						list.Add(new DiscreteInput
						{
							Address = (ushort)(startAddress + i),
							BoolValue = val > 0
						});
					}
				}
				catch (SocketException ex)
				{
					logger?.LogWarning(ex, $"Reading discrete inputs failed: {ex.GetMessage()}, reconnecting.");
					if (!isReconnecting)
						ConnectingTask = GetReconnectTask();
				}
				catch (IOException ex)
				{
					logger?.LogWarning(ex, $"Reading discrete inputs failed: {ex.GetMessage()}, reconnecting.");
					if (!isReconnecting)
						ConnectingTask = GetReconnectTask();
				}

				return list;
			}
			finally
			{
				logger?.LogTrace("ModbusClient.ReadDiscreteInputs leave");
			}
		}

		/// 
		/// Reads one or more holding registers of a device. (Modbus function 3).
		/// 
		/// The id to address the device (slave).
		/// The first register number to read.
		/// The number of registers to read.
		/// A cancellation token to abort the action.
		/// A list of registers or null on error.
		public async Task ReadHoldingRegisters(byte deviceId, ushort startAddress, ushort count, CancellationToken cancellationToken = default)
		{
			try
			{
				logger?.LogTrace("ModbusClient.ReadHoldingRegisters enter");

				if (deviceId < Consts.MinDeviceIdTcp || Consts.MaxDeviceId < deviceId)
					throw new ArgumentOutOfRangeException(nameof(deviceId));

				if (startAddress < Consts.MinAddress || Consts.MaxAddress < startAddress + count)
					throw new ArgumentOutOfRangeException(nameof(startAddress));

				if (count < Consts.MinCount || Consts.MaxRegisterCountRead < count)
					throw new ArgumentOutOfRangeException(nameof(count));

				logger?.LogDebug($"Reading holding registers from device #{deviceId} starting on {startAddress} for {count} registers.");

				List list = null;
				try
				{
					var request = new Request
					{
						DeviceId = deviceId,
						Function = FunctionCode.ReadHoldingRegisters,
						Address = startAddress,
						Count = count
					};
					var response = await SendRequest(request, cancellationToken);
					if (response.IsTimeout)
						throw new SocketException((int)SocketError.TimedOut);

					if (response.IsError)
					{
						throw new ModbusException(response.ErrorMessage)
						{
							ErrorCode = response.ErrorCode
						};
					}
					if (request.TransactionId != response.TransactionId)
						throw new ModbusException(nameof(response.TransactionId) + " does not match");

					list = new List();
					for (int i = 0; i < count; i++)
					{
						list.Add(new Register
						{
							Type = ModbusObjectType.HoldingRegister,
							Address = (ushort)(startAddress + i),
							HiByte = response.Data[i * 2],
							LoByte = response.Data[i * 2 + 1]
						});
					}
				}
				catch (SocketException ex)
				{
					logger?.LogWarning(ex, $"Reading holding registers failed: {ex.GetMessage()}, reconnecting.");
					if (!isReconnecting)
						ConnectingTask = GetReconnectTask();
				}
				catch (IOException ex)
				{
					logger?.LogWarning(ex, $"Reading holding registers failed: {ex.GetMessage()}, reconnecting.");
					if (!isReconnecting)
						ConnectingTask = GetReconnectTask();
				}

				return list;
			}
			finally
			{
				logger?.LogTrace("ModbusClient.ReadHoldingRegisters leave");
			}
		}

		/// 
		/// Reads one or more input registers of a device. (Modbus function 4).
		/// 
		/// The id to address the device (slave).
		/// The first register number to read.
		/// The number of registers to read.
		/// A cancellation token to abort the action.
		/// A list of registers or null on error.
		public async Task ReadInputRegisters(byte deviceId, ushort startAddress, ushort count, CancellationToken cancellationToken = default)
		{
			try
			{
				logger?.LogTrace("ModbusClient.ReadInputRegisters enter");

				if (deviceId < Consts.MinDeviceIdTcp || Consts.MaxDeviceId < deviceId)
					throw new ArgumentOutOfRangeException(nameof(deviceId));

				if (startAddress < Consts.MinAddress || Consts.MaxAddress < startAddress + count)
					throw new ArgumentOutOfRangeException(nameof(startAddress));

				if (count < Consts.MinCount || Consts.MaxRegisterCountRead < count)
					throw new ArgumentOutOfRangeException(nameof(count));

				logger?.LogDebug($"Reading input registers from device #{deviceId} starting on {startAddress} for {count} registers.");

				List list = null;
				try
				{
					var request = new Request
					{
						DeviceId = deviceId,
						Function = FunctionCode.ReadInputRegisters,
						Address = startAddress,
						Count = count
					};
					var response = await SendRequest(request, cancellationToken);
					if (response.IsTimeout)
						throw new SocketException((int)SocketError.TimedOut);

					if (response.IsError)
					{
						throw new ModbusException(response.ErrorMessage)
						{
							ErrorCode = response.ErrorCode
						};
					}
					if (request.TransactionId != response.TransactionId)
						throw new ModbusException(nameof(response.TransactionId) + " does not match");

					list = new List();
					for (int i = 0; i < count; i++)
					{
						list.Add(new Register
						{
							Type = ModbusObjectType.InputRegister,
							Address = (ushort)(startAddress + i),
							HiByte = response.Data[i * 2],
							LoByte = response.Data[i * 2 + 1]
						});
					}
				}
				catch (SocketException ex)
				{
					logger?.LogWarning(ex, $"Reading input registers failed: {ex.GetMessage()}, reconnecting.");
					if (!isReconnecting)
						ConnectingTask = GetReconnectTask();
				}
				catch (IOException ex)
				{
					logger?.LogWarning(ex, $"Reading input registers failed: {ex.GetMessage()}, reconnecting.");
					if (!isReconnecting)
						ConnectingTask = GetReconnectTask();
				}

				return list;
			}
			finally
			{
				logger?.LogTrace("ModbusClient.ReadInputRegisters leave");
			}
		}

		/// 
		/// Reads device information. (Modbus function 43).
		/// 
		/// The id to address the device (slave).
		/// The category to read (basic, regular, extended, individual).
		/// The first object id to read.
		/// A cancellation token to abort the action.
		/// A map of device information and their content as string.
		public async Task ReadDeviceInformation(byte deviceId, DeviceIDCategory categoryId, DeviceIDObject objectId = DeviceIDObject.VendorName, CancellationToken cancellationToken = default)
		{
			try
			{
				logger?.LogTrace("ModbusClient.ReadDeviceInformation enter");

				var raw = await ReadDeviceInformationRaw(deviceId, categoryId, objectId, cancellationToken);
				if (raw == null)
					return null;

				var dict = new Dictionary();
				foreach (var kvp in raw)
				{
					dict.Add((DeviceIDObject)kvp.Key, Encoding.ASCII.GetString(kvp.Value));
				}
				return dict;
			}
			finally
			{
				logger?.LogTrace("ModbusClient.ReadDeviceInformation leave");
			}
		}

		/// 
		/// Reads device information. (Modbus function 43).
		/// 
		/// The id to address the device (slave).
		/// The category to read (basic, regular, extended, individual).
		/// The first object id to read.
		/// A cancellation token to abort the action.
		/// A map of device information and their content as raw bytes.
		public async Task ReadDeviceInformationRaw(byte deviceId, DeviceIDCategory categoryId, DeviceIDObject objectId = DeviceIDObject.VendorName, CancellationToken cancellationToken = default)
		{
			try
			{
				logger?.LogTrace("ModbusClient.ReadDeviceInformationRaw enter");

				if (deviceId < Consts.MinDeviceIdTcp || Consts.MaxDeviceId < deviceId)
					throw new ArgumentOutOfRangeException(nameof(deviceId));

				logger?.LogDebug($"Reading device information from device #{deviceId}. category: {categoryId}, object: {objectId}");

				try
				{
					var request = new Request
					{
						DeviceId = deviceId,
						Function = FunctionCode.EncapsulatedInterface,
						MEIType = MEIType.ReadDeviceInformation,
						MEICategory = categoryId,
						MEIObject = objectId
					};
					var response = await SendRequest(request, cancellationToken);
					if (response.IsTimeout)
						throw new SocketException((int)SocketError.TimedOut);

					if (response.IsError)
					{
						throw new ModbusException(response.ErrorMessage)
						{
							ErrorCode = response.ErrorCode
						};
					}
					if (request.TransactionId != response.TransactionId)
						throw new ModbusException(nameof(response.TransactionId) + " does not match");

					var dict = new Dictionary();
					for (int i = 0, idx = 0; i < response.ObjectCount && idx < response.Data.Length; i++)
					{
						byte objId = response.Data.GetByte(idx);
						idx++;
						byte len = response.Data.GetByte(idx);
						idx++;
						byte[] bytes = response.Data.GetBytes(idx, len);
						idx += len;

						dict.Add(objId, bytes);
					}

					if (response.MoreRequestsNeeded)
					{
						var transDict = await ReadDeviceInformationRaw(deviceId, categoryId, (DeviceIDObject)response.NextObjectId);
						foreach (var kvp in transDict)
						{
							dict.Add(kvp.Key, kvp.Value);
						}
					}

					return dict;
				}
				catch (SocketException ex)
				{
					logger?.LogWarning(ex, $"Reading device information failed: {ex.GetMessage()}, reconnecting.");
					if (!isReconnecting)
						ConnectingTask = GetReconnectTask();
				}
				catch (IOException ex)
				{
					logger?.LogWarning(ex, $"Reading device information failed: {ex.GetMessage()}, reconnecting.");
					if (!isReconnecting)
						ConnectingTask = GetReconnectTask();
				}

				return null;
			}
			finally
			{
				logger?.LogTrace("ModbusClient.ReadDeviceInformationRaw leave");
			}
		}

		#endregion Read Methods

		#region Write Methods

		/// 
		/// Writes a single coil status to the Modbus device. (Modbus function 5)
		/// 
		/// The id to address the device (slave).
		/// The coil to write.
		/// A cancellation token to abort the action.
		/// true on success, otherwise false.
		public async Task WriteSingleCoil(byte deviceId, ModbusObject coil, CancellationToken cancellationToken = default)
		{
			try
			{
				logger?.LogTrace("ModbusClient.WriteSingleCoil enter");

				if (coil == null)
					throw new ArgumentNullException(nameof(coil));

				if (coil.Type != ModbusObjectType.Coil)
					throw new ArgumentException("Invalid coil type set");

				if (deviceId < Consts.MinDeviceIdTcp || Consts.MaxDeviceId < deviceId)
					throw new ArgumentOutOfRangeException(nameof(deviceId));

				if (coil.Address < Consts.MinAddress || Consts.MaxAddress < coil.Address)
					throw new ArgumentOutOfRangeException(nameof(coil.Address));

				logger?.LogDebug($"Writing a coil to device #{deviceId}: {coil}.");

				try
				{
					var request = new Request
					{
						DeviceId = deviceId,
						Function = FunctionCode.WriteSingleCoil,
						Address = coil.Address,
						Data = new DataBuffer(2)
					};
					ushort value = (ushort)(coil.BoolValue ? 0xFF00 : 0x0000);
					request.Data.SetUInt16(0, value);
					var response = await SendRequest(request, cancellationToken);
					if (response.IsTimeout)
						throw new SocketException((int)SocketError.TimedOut);

					if (response.IsError)
					{
						throw new ModbusException(response.ErrorMessage)
						{
							ErrorCode = response.ErrorCode
						};
					}
					if (request.TransactionId != response.TransactionId)
						throw new ModbusException(nameof(response.TransactionId) + " does not match");

					return request.TransactionId == response.TransactionId &&
						request.DeviceId == response.DeviceId &&
						request.Function == response.Function &&
						request.Address == response.Address &&
						request.Data.Equals(response.Data);
				}
				catch (SocketException ex)
				{
					logger?.LogWarning(ex, $"Writing a coil failed: {ex.GetMessage()}, reconnecting.");
					if (!isReconnecting)
						ConnectingTask = GetReconnectTask();
				}
				catch (IOException ex)
				{
					logger?.LogWarning(ex, $"Writing a coil failed: {ex.GetMessage()}, reconnecting.");
					if (!isReconnecting)
						ConnectingTask = GetReconnectTask();
				}

				return false;
			}
			finally
			{
				logger?.LogTrace("ModbusClient.WriteSingleCoil leave");
			}
		}

		/// 
		/// Writes a single register to the Modbus device. (Modbus function 6)
		/// 
		/// The id to address the device (slave).
		/// The register to write.
		/// A cancellation token to abort the action.
		/// true on success, otherwise false.
		public async Task WriteSingleRegister(byte deviceId, ModbusObject register, CancellationToken cancellationToken = default)
		{
			try
			{
				logger?.LogTrace("ModbusClient.WriteSingleRegister enter");

				if (register == null)
					throw new ArgumentNullException(nameof(register));

				if (register.Type != ModbusObjectType.HoldingRegister)
					throw new ArgumentException("Invalid register type set");

				if (deviceId < Consts.MinDeviceIdTcp || Consts.MaxDeviceId < deviceId)
					throw new ArgumentOutOfRangeException(nameof(deviceId));

				if (register.Address < Consts.MinAddress || Consts.MaxAddress < register.Address)
					throw new ArgumentOutOfRangeException(nameof(register.Address));

				logger?.LogDebug($"Writing a register to device #{deviceId}: {register}.");

				try
				{
					var request = new Request
					{
						DeviceId = deviceId,
						Function = FunctionCode.WriteSingleRegister,
						Address = register.Address,
						Data = new DataBuffer(new[] { register.HiByte, register.LoByte })
					};
					var response = await SendRequest(request, cancellationToken);
					if (response.IsTimeout)
						throw new SocketException((int)SocketError.TimedOut);

					if (response.IsError)
					{
						throw new ModbusException(response.ErrorMessage)
						{
							ErrorCode = response.ErrorCode
						};
					}
					if (request.TransactionId != response.TransactionId)
						throw new ModbusException(nameof(response.TransactionId) + " does not match");

					return request.TransactionId == response.TransactionId &&
						request.DeviceId == response.DeviceId &&
						request.Function == response.Function &&
						request.Address == response.Address &&
						request.Data.Equals(response.Data);
				}
				catch (SocketException ex)
				{
					logger?.LogWarning(ex, $"Writing a register failed: {ex.GetMessage()}, reconnecting.");
					if (!isReconnecting)
						ConnectingTask = GetReconnectTask();
				}
				catch (IOException ex)
				{
					logger?.LogWarning(ex, $"Writing a register failed: {ex.GetMessage()}, reconnecting.");
					if (!isReconnecting)
						ConnectingTask = GetReconnectTask();
				}

				return false;
			}
			finally
			{
				logger?.LogTrace("ModbusClient.WriteSingleRegister leave");
			}
		}

		/// 
		/// Writes multiple coil status to the Modbus device. (Modbus function 15)
		/// 
		/// The id to address the device (slave).
		/// A list of coils to write.
		/// A cancellation token to abort the action.
		/// true on success, otherwise false.
		public async Task WriteCoils(byte deviceId, IEnumerable coils, CancellationToken cancellationToken = default)
		{
			try
			{
				logger?.LogTrace("ModbusClient.WriteCoils enter");

				if (coils?.Any() != true)
					throw new ArgumentNullException(nameof(coils));

				if (coils.Any(c => c.Type != ModbusObjectType.Coil))
					throw new ArgumentException("Invalid coil type set");

				if (deviceId < Consts.MinDeviceIdTcp || Consts.MaxDeviceId < deviceId)
					throw new ArgumentOutOfRangeException(nameof(deviceId));

				var orderedList = coils.OrderBy(c => c.Address).ToList();
				if (orderedList.Count < Consts.MinCount || Consts.MaxCoilCountWrite < orderedList.Count)
					throw new ArgumentOutOfRangeException("Count");

				ushort firstAddress = orderedList.First().Address;
				ushort lastAddress = orderedList.Last().Address;

				if (firstAddress + orderedList.Count - 1 != lastAddress)
					throw new ArgumentException("No address gabs allowed within a request");

				if (firstAddress < Consts.MinAddress || Consts.MaxAddress < lastAddress)
					throw new ArgumentOutOfRangeException("Address");

				logger?.LogDebug($"Writing coils to device #{deviceId} starting on {firstAddress} for {orderedList.Count} coils.");

				int numBytes = (int)Math.Ceiling(orderedList.Count / 8.0);
				byte[] coilBytes = new byte[numBytes];
				for (int i = 0; i < orderedList.Count; i++)
				{
					if (orderedList[i].BoolValue)
					{
						int posByte = i / 8;
						int posBit = i % 8;

						byte mask = (byte)Math.Pow(2, posBit);
						coilBytes[posByte] = (byte)(coilBytes[posByte] | mask);
					}
				}

				try
				{
					var request = new Request
					{
						DeviceId = deviceId,
						Function = FunctionCode.WriteMultipleCoils,
						Address = firstAddress,
						Count = (ushort)orderedList.Count,
						Data = new DataBuffer(coilBytes)
					};
					var response = await SendRequest(request, cancellationToken);
					if (response.IsTimeout)
						throw new SocketException((int)SocketError.TimedOut);

					if (response.IsError)
					{
						throw new ModbusException(response.ErrorMessage)
						{
							ErrorCode = response.ErrorCode
						};
					}
					if (request.TransactionId != response.TransactionId)
						throw new ModbusException(nameof(response.TransactionId) + " does not match");

					return request.TransactionId == response.TransactionId &&
						request.Address == response.Address &&
						request.Count == response.Count;
				}
				catch (SocketException ex)
				{
					logger?.LogWarning(ex, $"Writing coils failed: {ex.GetMessage()}, reconnecting.");
					if (!isReconnecting)
						ConnectingTask = GetReconnectTask();
				}
				catch (IOException ex)
				{
					logger?.LogWarning(ex, $"Writing coils failed: {ex.GetMessage()}, reconnecting.");
					if (!isReconnecting)
						ConnectingTask = GetReconnectTask();
				}

				return false;
			}
			finally
			{
				logger?.LogTrace("ModbusClient.WriteCoils leave");
			}
		}

		/// 
		/// Writes multiple registers to the Modbus device. (Modbus function 16)
		/// 
		/// The id to address the device (slave).
		/// A list of registers to write.
		/// A cancellation token to abort the action.
		/// true on success, otherwise false.
		public async Task WriteRegisters(byte deviceId, IEnumerable registers, CancellationToken cancellationToken = default)
		{
			try
			{
				logger?.LogTrace("ModbusClient.WriteRegisters enter");

				if (registers?.Any() != true)
					throw new ArgumentNullException(nameof(registers));

				if (registers.Any(r => r.Type != ModbusObjectType.HoldingRegister))
					throw new ArgumentException("Invalid register type set");

				if (deviceId < Consts.MinDeviceIdTcp || Consts.MaxDeviceId < deviceId)
					throw new ArgumentOutOfRangeException(nameof(deviceId));

				var orderedList = registers.OrderBy(c => c.Address).ToList();
				if (orderedList.Count < Consts.MinCount || Consts.MaxRegisterCountWrite < orderedList.Count)
					throw new ArgumentOutOfRangeException("Count");

				ushort firstAddress = orderedList.First().Address;
				ushort lastAddress = orderedList.Last().Address;

				if (firstAddress + orderedList.Count - 1 != lastAddress)
					throw new ArgumentException("No address gabs allowed within a request");

				if (firstAddress < Consts.MinAddress || Consts.MaxAddress < lastAddress)
					throw new ArgumentOutOfRangeException("Address");

				logger?.LogDebug($"Writing registers to device #{deviceId} starting on {firstAddress} for {orderedList.Count} registers.");

				var data = new DataBuffer(orderedList.Count * 2);
				for (int i = 0; i < orderedList.Count; i++)
				{
					data.SetUInt16(i * 2, orderedList[i].RegisterValue);
				}

				try
				{
					var request = new Request
					{
						DeviceId = deviceId,
						Function = FunctionCode.WriteMultipleRegisters,
						Address = firstAddress,
						Count = (ushort)orderedList.Count,
						Data = data
					};
					var response = await SendRequest(request, cancellationToken);
					if (response.IsTimeout)
						throw new SocketException((int)SocketError.TimedOut);

					if (response.IsError)
					{
						throw new ModbusException(response.ErrorMessage)
						{
							ErrorCode = response.ErrorCode
						};
					}
					if (request.TransactionId != response.TransactionId)
						throw new ModbusException(nameof(response.TransactionId) + " does not match");

					return request.TransactionId == response.TransactionId &&
						request.Address == response.Address &&
						request.Count == response.Count;
				}
				catch (SocketException ex)
				{
					logger?.LogWarning(ex, $"Writing registers failed: {ex.GetMessage()}, reconnecting.");
					if (!isReconnecting)
						ConnectingTask = GetReconnectTask();
				}
				catch (IOException ex)
				{
					logger?.LogWarning(ex, $"Writing registers failed: {ex.GetMessage()}, reconnecting.");
					if (!isReconnecting)
						ConnectingTask = GetReconnectTask();
				}

				return false;
			}
			finally
			{
				logger?.LogTrace("ModbusClient.WriteRegisters leave");
			}
		}

		#endregion Write Methods

		#endregion Public methods

		#region Private methods

		private async Task Reconnect()
		{
			try
			{
				logger?.LogTrace("ModbusClient.Reconnect enter");
				lock (reconnectLock)
				{
					if (isReconnecting || stopCts.IsCancellationRequested)
						return;

					isReconnecting = true;
					IsConnected = false;
				}

				logger?.LogInformation($"{(wasConnected ? "Rec" : "C")}onnect starting.");
				if (wasConnected)
				{
					receiveCts?.Cancel();
					await receiveTask;
					receiveCts = null;
					receiveTask = Task.CompletedTask;
					Task.Run(() => Disconnected?.Invoke(this, EventArgs.Empty)).Forget();
				}

				var timeout = TimeSpan.FromSeconds(2);
				var startTime = DateTime.UtcNow;

				var address = ResolveHost(Host);
				while (!stopCts.IsCancellationRequested)
				{
					try
					{
						stream?.Dispose();
						stream = null;

						tcpClient?.Dispose();
						tcpClient = new TcpClient(address.AddressFamily);

						var connectTask = tcpClient.ConnectAsync(address, Port);
						if (await Task.WhenAny(connectTask, Task.Delay(timeout, stopCts.Token)) == connectTask && tcpClient.Connected)
						{
							SetKeepAlive();
							stream = tcpClient.GetStream();

							receiveCts = new CancellationTokenSource();
							receiveTask = Task.Run(async () => await ReceiveLoop());

							lock (reconnectLock)
							{
								IsConnected = true;
								wasConnected = true;

								reconnectTcs?.TrySetResult(true);
								Task.Run(() => Connected?.Invoke(this, EventArgs.Empty)).Forget();
							}
							logger?.LogInformation($"{(wasConnected ? "Rec" : "C")}onnected successfully.");
							return;
						}
						else
						{
							if (timeout < MaxConnectTimeout)
							{
								logger?.LogWarning($"{(wasConnected ? "Rec" : "C")}onnect failed within {timeout}.");
								timeout = timeout.Add(TimeSpan.FromSeconds(2));
								if (timeout > MaxConnectTimeout)
									timeout = MaxConnectTimeout;
							}
							throw new SocketException((int)SocketError.TimedOut);
						}
					}
					catch (SocketException) when (ReconnectTimeSpan == TimeSpan.MaxValue || DateTime.UtcNow - startTime  i.TransactionId == response.TransactionId)
											.FirstOrDefault();
										if (queueItem == null)
											logger?.LogWarning($"Received response for transaction #{response.TransactionId}. The matching request could not be resolved.");
									}

									if (queueItem != null)
									{
										queueItem.Registration.Dispose();
										awaitingResponses.Remove(queueItem);
									}
								}
								finally
								{
									queueLock.Release();
								}

								if (queueItem != null)
								{
									if (!DisableTransactionId)
										logger?.LogDebug($"Received response for transaction #{response.TransactionId}.");

									queueItem.CancellationTokenSource.Dispose();
									queueItem.TaskCompletionSource.TrySetResult(response);
									queueItem.TimeoutCancellationTokenSource.Dispose();
								}
							}
							catch (ArgumentException ex)
							{
								logger?.LogError(ex, $"Invalid data received: {ex.Message}");
							}
							catch (NotImplementedException ex)
							{
								logger?.LogError(ex, $"Invalid data received: {ex.Message}");
							}
						}
					}
					catch (OperationCanceledException) when (receiveCts.IsCancellationRequested)
					{
						// Receive loop stopping
						throw;
					}
					catch (IOException)
					{
						if (!isReconnecting)
							ConnectingTask = GetReconnectTask();

						await Task.Delay(1, receiveCts.Token);   // make sure the reconnect task has time to start.
					}
					catch (Exception ex)
					{
						logger?.LogError(ex, $"Unexpected error ({ex.GetType().Name}) on receive: {ex.GetMessage()}");
					}
				}
			}
			catch (OperationCanceledException) when (receiveCts.IsCancellationRequested)
			{
				// Receive loop stopping
				var ex = new SocketException((int)SocketError.TimedOut);

				await queueLock.WaitAsync(stopCts.Token);
				try
				{
					foreach (var queuedItem in awaitingResponses)
					{
						queuedItem.Registration.Dispose();
						queuedItem.CancellationTokenSource.Dispose();
						queuedItem.TaskCompletionSource.TrySetCanceled();
						queuedItem.TimeoutCancellationTokenSource.Dispose();
					}
					awaitingResponses.Clear();
				}
				catch
				{ }
				finally
				{
					queueLock.Release();
				}
				logger?.LogInformation("Receiving responses stopped.");
			}
			catch (Exception ex)
			{
				logger?.LogError(ex, $"Unexpected error ({ex.GetType().Name}) on receive: {ex.GetMessage()}");
			}
			finally
			{
				logger?.LogTrace("ModbusClient.ReceiveLoop leave");
			}
		}

		private async Task SendRequest(Request request, CancellationToken cancellationToken)
		{
			try
			{
				logger?.LogTrace("ModbusClient.SendRequest enter");
				CheckDisposed();

				lock (reconnectLock)
				{
					if (!IsConnected)
					{
						if (!isReconnecting)
							ConnectingTask = GetReconnectTask(true);

						throw new InvalidOperationException("Modbus client is not connected");
					}
				}

				if (stream == null)
					throw new InvalidOperationException("Modbus client failed to open stream");

				using var sendCts = CancellationTokenSource.CreateLinkedTokenSource(stopCts.Token, cancellationToken);
				
				var queueItem = new QueuedRequest
				{
					TaskCompletionSource = new TaskCompletionSource()
				};

				lock (trxIdLock)
				{
					queueItem.TransactionId = transactionId++;
				}

				await queueLock.WaitAsync(sendCts.Token);
				try
				{
					awaitingResponses.Add(queueItem);
					logger?.LogDebug($"Added transaction #{queueItem.TransactionId} to receive queue");
				}
				finally
				{
					queueLock.Release();
				}

				await sendLock.WaitAsync(sendCts.Token);
				try
				{
					request.TransactionId = queueItem.TransactionId;
					logger?.LogDebug($"Sending {request}");

					byte[] bytes = request.Serialize();
					using var timeCts = new CancellationTokenSource(SendTimeout);
					using var cts = CancellationTokenSource.CreateLinkedTokenSource(stopCts.Token, timeCts.Token, cancellationToken);
					try
					{
						await stream.WriteAsync(bytes, 0, bytes.Length, cts.Token);
						logger?.LogDebug($"Request for transaction #{request.TransactionId} sent");

						queueItem.TimeoutCancellationTokenSource = new CancellationTokenSource(ReceiveTimeout);
						queueItem.CancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(stopCts.Token, cancellationToken, queueItem.TimeoutCancellationTokenSource.Token);
						queueItem.Registration = queueItem.CancellationTokenSource.Token.Register(() => RemoveQueuedItem(queueItem));
					}
					catch (OperationCanceledException) when (timeCts.IsCancellationRequested)
					{
						queueItem.TaskCompletionSource.TrySetCanceled();
						await queueLock.WaitAsync(stopCts.Token);
						try
						{
							awaitingResponses.Remove(queueItem);
						}
						finally
						{
							queueLock.Release();
						}
					}
				}
				catch (Exception ex)
				{
					logger?.LogError(ex, $"Unexpected error ({ex.GetType().Name}) on send: {ex.GetMessage()}");
					queueItem.TaskCompletionSource.TrySetException(ex);
					await queueLock.WaitAsync(stopCts.Token);
					try
					{
						awaitingResponses.Remove(queueItem);
					}
					finally
					{
						queueLock.Release();
					}
				}
				finally
				{
					sendLock.Release();
				}

				return await queueItem.TaskCompletionSource.Task;
			}
			finally
			{
				logger?.LogTrace("ModbusClient.SendRequest leave");
			}
		}

		private async void RemoveQueuedItem(QueuedRequest item)
		{
			try
			{
				await queueLock.WaitAsync(stopCts.Token);
				try
				{
					awaitingResponses.Remove(item);
					item.CancellationTokenSource?.Dispose();
					item.Registration.Dispose();
					item.TaskCompletionSource?.TrySetCanceled();
					item.TimeoutCancellationTokenSource?.Dispose();
				}
				finally
				{
					queueLock.Release();
				}
			}
			catch
			{ }
		}

		private Task GetReconnectTask(bool isAlreadyLocked = false)
		{
			Task task = Task.CompletedTask;
			if (isAlreadyLocked)
			{
				if (reconnectTcs == null)
					reconnectTcs = new TaskCompletionSource();

				task = reconnectTcs.Task;
			}
			else
			{
				lock (reconnectLock)
				{
					if (reconnectTcs == null)
						reconnectTcs = new TaskCompletionSource();

					task = reconnectTcs.Task;
				}
			}

			Task.Run(async () => await Reconnect()).Forget();
			return task;
		}

		private IPAddress ResolveHost(string host)
		{
			if (IPAddress.TryParse(host, out var address))
				return address;

			return Dns.GetHostAddresses(host)
				.OrderBy(ip => ip.AddressFamily)
				.Where(ip => ip.AddressFamily == AddressFamily.InterNetwork || ip.AddressFamily == AddressFamily.InterNetworkV6)
				.FirstOrDefault() ?? throw new ArgumentException(nameof(Host), "Host could not be resolved.");
		}

		private void SetKeepAlive()
		{
			if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
				return;

			bool isEnabled = keepAlive.TotalMilliseconds > 0;
			uint interval = keepAlive.TotalMilliseconds > uint.MaxValue ? uint.MaxValue : (uint)keepAlive.TotalMilliseconds;

			int uintSize = sizeof(uint);
			byte[] config = new byte[uintSize * 3];

			Array.Copy(BitConverter.GetBytes(isEnabled ? 1U : 0U), 0, config, 0, uintSize);
			Array.Copy(BitConverter.GetBytes(interval), 0, config, uintSize * 1, uintSize);
			Array.Copy(BitConverter.GetBytes(interval), 0, config, uintSize * 2, uintSize);
			tcpClient?.Client?.IOControl(IOControlCode.KeepAliveValues, config, null);
		}

		#endregion Private methods

		#region IDisposable implementation

		private bool isDisposed;

		/// 
		public void Dispose()
		{
			if (isDisposed)
				return;

			Disconnect()
				.ConfigureAwait(false)
				.GetAwaiter()
				.GetResult();

			isDisposed = true;
		}

		private void CheckDisposed()
		{
			if (isDisposed)
				throw new ObjectDisposedException(GetType().FullName);
		}

		#endregion IDisposable implementation

		#region Overrides

		/// 
		public override string ToString()
		{
			CheckDisposed();
			return $"Modbus TCP {Host}:{Port} - Connected: {IsConnected}";
		}

		#endregion Overrides
	}
}