diff --git a/AMWD.Protocols.Modbus.Common/Contracts/IModbusConnection.cs b/AMWD.Protocols.Modbus.Common/Contracts/IModbusConnection.cs
index ad9bd3e..ed87905 100644
--- a/AMWD.Protocols.Modbus.Common/Contracts/IModbusConnection.cs
+++ b/AMWD.Protocols.Modbus.Common/Contracts/IModbusConnection.cs
@@ -31,12 +31,12 @@ namespace AMWD.Protocols.Modbus.Common.Contracts
TimeSpan ConnectTimeout { get; set; }
///
- /// Gets or sets the receive time out value of the connection.
+ /// Gets or sets the before a time-out occurs when a read/receive operation does not finish.
///
TimeSpan ReadTimeout { get; set; }
///
- /// Gets or sets the send time out value of the connection.
+ /// Gets or sets the before a time-out occurs when a write/send operation does not finish.
///
TimeSpan WriteTimeout { get; set; }
diff --git a/AMWD.Protocols.Modbus.Tcp/Events/CoilWrittenEventArgs.cs b/AMWD.Protocols.Modbus.Common/Events/CoilWrittenEventArgs.cs
similarity index 60%
rename from AMWD.Protocols.Modbus.Tcp/Events/CoilWrittenEventArgs.cs
rename to AMWD.Protocols.Modbus.Common/Events/CoilWrittenEventArgs.cs
index 256299f..a60b154 100644
--- a/AMWD.Protocols.Modbus.Tcp/Events/CoilWrittenEventArgs.cs
+++ b/AMWD.Protocols.Modbus.Common/Events/CoilWrittenEventArgs.cs
@@ -1,25 +1,26 @@
using System;
-namespace AMWD.Protocols.Modbus.Tcp.Events
+namespace AMWD.Protocols.Modbus.Common.Events
{
///
/// Represents the coil written event arguments.
///
+ [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
public class CoilWrittenEventArgs : EventArgs
{
///
/// Gets or sets the unit id.
///
- public byte UnitId { get; internal set; }
+ public byte UnitId { get; set; }
///
/// Gets or sets the coil address.
///
- public ushort Address { get; internal set; }
+ public ushort Address { get; set; }
///
/// Gets or sets the coil value.
///
- public bool Value { get; internal set; }
+ public bool Value { get; set; }
}
}
diff --git a/AMWD.Protocols.Modbus.Tcp/Events/RegisterWrittenEventArgs.cs b/AMWD.Protocols.Modbus.Common/Events/RegisterWrittenEventArgs.cs
similarity index 63%
rename from AMWD.Protocols.Modbus.Tcp/Events/RegisterWrittenEventArgs.cs
rename to AMWD.Protocols.Modbus.Common/Events/RegisterWrittenEventArgs.cs
index fe5da73..51ea589 100644
--- a/AMWD.Protocols.Modbus.Tcp/Events/RegisterWrittenEventArgs.cs
+++ b/AMWD.Protocols.Modbus.Common/Events/RegisterWrittenEventArgs.cs
@@ -1,35 +1,36 @@
using System;
-namespace AMWD.Protocols.Modbus.Tcp.Events
+namespace AMWD.Protocols.Modbus.Common.Events
{
///
/// Represents the register written event arguments.
///
+ [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
public class RegisterWrittenEventArgs : EventArgs
{
///
/// Gets or sets the unit id.
///
- public byte UnitId { get; internal set; }
+ public byte UnitId { get; set; }
///
/// Gets or sets the address of the register.
///
- public ushort Address { get; internal set; }
+ public ushort Address { get; set; }
///
/// Gets or sets the value of the register.
///
- public ushort Value { get; internal set; }
+ public ushort Value { get; set; }
///
/// Gets or sets the high byte of the register.
///
- public byte HighByte { get; internal set; }
+ public byte HighByte { get; set; }
///
/// Gets or sets the low byte of the register.
///
- public byte LowByte { get; internal set; }
+ public byte LowByte { get; set; }
}
}
diff --git a/AMWD.Protocols.Modbus.Serial/AMWD.Protocols.Modbus.Serial.csproj b/AMWD.Protocols.Modbus.Serial/AMWD.Protocols.Modbus.Serial.csproj
index 55bd208..9cc5e96 100644
--- a/AMWD.Protocols.Modbus.Serial/AMWD.Protocols.Modbus.Serial.csproj
+++ b/AMWD.Protocols.Modbus.Serial/AMWD.Protocols.Modbus.Serial.csproj
@@ -10,15 +10,39 @@
Modbus RTU/ASCII Protocol
Implementation of the Modbus protocol communicating via serial line using RTU or ASCII encoding.
- Modbus Protocol Serial Line RTU ASCII COM TTY
+ Modbus Protocol Serial Line RTU ASCII COM TTY USB
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/AMWD.Protocols.Modbus.Serial/Enums/BaudRate.cs b/AMWD.Protocols.Modbus.Serial/Enums/BaudRate.cs
new file mode 100644
index 0000000..eaf51f5
--- /dev/null
+++ b/AMWD.Protocols.Modbus.Serial/Enums/BaudRate.cs
@@ -0,0 +1,43 @@
+namespace AMWD.Protocols.Modbus.Serial
+{
+ ///
+ /// Defines the baud rates for a serial connection.
+ ///
+ public enum BaudRate : int
+ {
+ ///
+ /// 2400 Baud.
+ ///
+ Baud2400 = 2400,
+
+ ///
+ /// 4800 Baud.
+ ///
+ Baud4800 = 4800,
+
+ ///
+ /// 9600 Baud.
+ ///
+ Baud9600 = 9600,
+
+ ///
+ /// 19200 Baud.
+ ///
+ Baud19200 = 19200,
+
+ ///
+ /// 38400 Baud.
+ ///
+ Baud38400 = 38400,
+
+ ///
+ /// 57600 Baud.
+ ///
+ Baud57600 = 57600,
+
+ ///
+ /// 115200 Baud.
+ ///
+ Baud115200 = 115200
+ }
+}
diff --git a/AMWD.Protocols.Modbus.Serial/Enums/RS485Flags.cs b/AMWD.Protocols.Modbus.Serial/Enums/RS485Flags.cs
new file mode 100644
index 0000000..299537f
--- /dev/null
+++ b/AMWD.Protocols.Modbus.Serial/Enums/RS485Flags.cs
@@ -0,0 +1,31 @@
+using System;
+
+namespace AMWD.Protocols.Modbus.Serial.Enums
+{
+ ///
+ /// Defines the flags for the RS485 driver state.
+ ///
+ [Flags]
+ internal enum RS485Flags
+ {
+ ///
+ /// RS485 is enabled.
+ ///
+ Enabled = 1,
+
+ ///
+ /// RS485 uses RTS on send.
+ ///
+ RtsOnSend = 2,
+
+ ///
+ /// RS485 uses RTS after send.
+ ///
+ RtsAfterSend = 4,
+
+ ///
+ /// Receive during send (duplex).
+ ///
+ RxDuringTx = 16
+ }
+}
diff --git a/AMWD.Protocols.Modbus.Serial/Exceptions/UnixIOException.cs b/AMWD.Protocols.Modbus.Serial/Exceptions/UnixIOException.cs
new file mode 100644
index 0000000..80e04e5
--- /dev/null
+++ b/AMWD.Protocols.Modbus.Serial/Exceptions/UnixIOException.cs
@@ -0,0 +1,83 @@
+using System;
+using System.Runtime.InteropServices;
+using System.Runtime.Serialization;
+using AMWD.Protocols.Modbus.Serial.Utils;
+#if NETSTANDARD
+using System.Security.Permissions;
+#endif
+
+namespace AMWD.Protocols.Modbus.Serial
+{
+ ///
+ /// Represents a unix specific IO exception.
+ ///
+ ///
+ /// See StackOverflow answer: .
+ ///
+ [Serializable]
+ [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
+ public class UnixIOException : ExternalException
+ {
+ ///
+ /// Initializes a new instance of the class.
+ ///
+#if NETSTANDARD
+ [SecurityPermission(SecurityAction.Demand, UnmanagedCode = true)]
+#endif
+ public UnixIOException()
+ : this(Marshal.GetLastWin32Error())
+ { }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The native error code.
+#if NETSTANDARD
+ [SecurityPermission(SecurityAction.Demand, UnmanagedCode = true)]
+#endif
+ public UnixIOException(int errorCode)
+ : this(GetErrorMessage(errorCode), errorCode)
+ { }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The message that describes the error.
+#if NETSTANDARD
+ [SecurityPermission(SecurityAction.Demand, UnmanagedCode = true)]
+#endif
+ public UnixIOException(string message)
+ : base(message)
+ { }
+
+ ///
+ public UnixIOException(string message, Exception inner)
+ : base(message, inner)
+ { }
+
+ ///
+ public UnixIOException(string message, int errorCode)
+ : base(message, errorCode)
+ { }
+
+#if ! NET8_0_OR_GREATER
+ ///
+ protected UnixIOException(SerializationInfo info, StreamingContext context)
+ : base(info, context)
+ { }
+#endif
+
+ private static string GetErrorMessage(int errorCode)
+ {
+ try
+ {
+ nint ptr = UnsafeNativeMethods.StrError(errorCode);
+ return Marshal.PtrToStringAnsi(ptr);
+ }
+ catch
+ {
+ return $"Unknown error: 0x{errorCode:x}";
+ }
+ }
+ }
+}
diff --git a/AMWD.Protocols.Modbus.Serial/ModbusSerialClient.cs b/AMWD.Protocols.Modbus.Serial/ModbusSerialClient.cs
new file mode 100644
index 0000000..7a095bb
--- /dev/null
+++ b/AMWD.Protocols.Modbus.Serial/ModbusSerialClient.cs
@@ -0,0 +1,230 @@
+using System;
+using System.IO.Ports;
+using AMWD.Protocols.Modbus.Common.Contracts;
+using AMWD.Protocols.Modbus.Common.Protocols;
+
+namespace AMWD.Protocols.Modbus.Serial
+{
+ ///
+ /// Default implementation of a Modbus serial line client.
+ ///
+ public class ModbusSerialClient : ModbusClientBase
+ {
+ ///
+ /// Initializes a new instance of the class with a port name.
+ ///
+ /// The name of the serial port to use.
+ public ModbusSerialClient(string portName)
+ : this(new ModbusSerialConnection { PortName = portName })
+ { }
+
+ ///
+ /// Initializes a new instance of the class with a specific .
+ ///
+ /// The responsible for invoking the requests.
+ public ModbusSerialClient(IModbusConnection connection)
+ : this(connection, true)
+ { }
+
+ ///
+ /// Initializes a new instance of the class with a specific .
+ ///
+ /// The responsible for invoking the requests.
+ ///
+ /// if the connection should be disposed of by Dispose(),
+ /// otherwise if you inted to reuse the connection.
+ ///
+ public ModbusSerialClient(IModbusConnection connection, bool disposeConnection)
+ : base(connection, disposeConnection)
+ {
+ Protocol = new RtuProtocol();
+ }
+
+ ///
+ public static string[] AvailablePortNames => SerialPort.GetPortNames();
+
+ ///
+ public override IModbusProtocol Protocol { get; set; }
+
+ ///
+ public TimeSpan IdleTimeout
+ {
+ get => connection.IdleTimeout;
+ set => connection.IdleTimeout = value;
+ }
+
+ ///
+ public TimeSpan ConnectTimeout
+ {
+ get => connection.ConnectTimeout;
+ set => connection.ConnectTimeout = value;
+ }
+
+ ///
+ public TimeSpan ReadTimeout
+ {
+ get => connection.ReadTimeout;
+ set => connection.ReadTimeout = value;
+ }
+
+ ///
+ public TimeSpan WriteTimeout
+ {
+ get => connection.WriteTimeout;
+ set => connection.WriteTimeout = value;
+ }
+
+ ///
+ public bool DriverEnabledRS485
+ {
+ get
+ {
+ if (connection is ModbusSerialConnection serialConnection)
+ return serialConnection.DriverEnabledRS485;
+
+ return default;
+ }
+ set
+ {
+ if (connection is ModbusSerialConnection serialConnection)
+ serialConnection.DriverEnabledRS485 = value;
+ }
+ }
+
+ ///
+ public TimeSpan InterRequestDelay
+ {
+ get
+ {
+ if (connection is ModbusSerialConnection serialConnection)
+ return serialConnection.InterRequestDelay;
+
+ return default;
+ }
+ set
+ {
+ if (connection is ModbusSerialConnection serialConnection)
+ serialConnection.InterRequestDelay = value;
+ }
+ }
+
+ ///
+ public string PortName
+ {
+ get
+ {
+ if (connection is ModbusSerialConnection serialConnection)
+ return serialConnection.PortName;
+
+ return default;
+ }
+ set
+ {
+ if (connection is ModbusSerialConnection serialConnection)
+ serialConnection.PortName = value;
+ }
+ }
+
+ ///
+ public BaudRate BaudRate
+ {
+ get
+ {
+ if (connection is ModbusSerialConnection serialConnection)
+ return serialConnection.BaudRate;
+
+ return default;
+ }
+ set
+ {
+ if (connection is ModbusSerialConnection serialConnection)
+ serialConnection.BaudRate = value;
+ }
+ }
+
+ ///
+ public int DataBits
+ {
+ get
+ {
+ if (connection is ModbusSerialConnection serialConnection)
+ return serialConnection.DataBits;
+
+ return default;
+ }
+ set
+ {
+ if (connection is ModbusSerialConnection serialConnection)
+ serialConnection.DataBits = value;
+ }
+ }
+
+ ///
+ public Handshake Handshake
+ {
+ get
+ {
+ if (connection is ModbusSerialConnection serialConnection)
+ return serialConnection.Handshake;
+
+ return default;
+ }
+ set
+ {
+ if (connection is ModbusSerialConnection serialConnection)
+ serialConnection.Handshake = value;
+ }
+ }
+
+ ///
+ public Parity Parity
+ {
+ get
+ {
+ if (connection is ModbusSerialConnection serialConnection)
+ return serialConnection.Parity;
+
+ return default;
+ }
+ set
+ {
+ if (connection is ModbusSerialConnection serialConnection)
+ serialConnection.Parity = value;
+ }
+ }
+
+ ///
+ public bool RtsEnable
+ {
+ get
+ {
+ if (connection is ModbusSerialConnection serialConnection)
+ return serialConnection.RtsEnable;
+
+ return default;
+ }
+ set
+ {
+ if (connection is ModbusSerialConnection serialConnection)
+ serialConnection.RtsEnable = value;
+ }
+ }
+
+ ///
+ public StopBits StopBits
+ {
+ get
+ {
+ if (connection is ModbusSerialConnection serialConnection)
+ return serialConnection.StopBits;
+
+ return default;
+ }
+ set
+ {
+ if (connection is ModbusSerialConnection serialConnection)
+ serialConnection.StopBits = value;
+ }
+ }
+ }
+}
diff --git a/AMWD.Protocols.Modbus.Serial/ModbusSerialConnection.cs b/AMWD.Protocols.Modbus.Serial/ModbusSerialConnection.cs
new file mode 100644
index 0000000..35b1873
--- /dev/null
+++ b/AMWD.Protocols.Modbus.Serial/ModbusSerialConnection.cs
@@ -0,0 +1,399 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.IO.Ports;
+using System.Linq;
+using System.Runtime.InteropServices;
+using System.Threading;
+using System.Threading.Tasks;
+using AMWD.Protocols.Modbus.Common.Contracts;
+using AMWD.Protocols.Modbus.Common.Protocols;
+using AMWD.Protocols.Modbus.Common.Utils;
+using AMWD.Protocols.Modbus.Serial.Enums;
+using AMWD.Protocols.Modbus.Serial.Utils;
+
+namespace AMWD.Protocols.Modbus.Serial
+{
+ ///
+ /// The default Modbus Serial connection.
+ ///
+ public class ModbusSerialConnection : IModbusConnection
+ {
+ #region Fields
+
+ private bool _isDisposed;
+ private readonly CancellationTokenSource _disposeCts = new();
+
+ private readonly SemaphoreSlim _portLock = new(1, 1);
+ private readonly SerialPortWrapper _serialPort;
+ private readonly Timer _idleTimer;
+
+ private readonly Task _processingTask;
+ private readonly AsyncQueue _requestQueue = new();
+
+ // Only required to cover all logic branches on unit tests.
+ private bool _isUnitTest = false;
+
+ #endregion Fields
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public ModbusSerialConnection()
+ {
+ _serialPort = new SerialPortWrapper
+ {
+ BaudRate = (int)BaudRate.Baud19200,
+ DataBits = 8,
+ Handshake = Handshake.None,
+ Parity = Parity.Even,
+ ReadTimeout = 1000,
+ RtsEnable = false,
+ StopBits = StopBits.One,
+ WriteTimeout = 1000,
+ };
+
+ _idleTimer = new Timer(OnIdleTimer);
+ _processingTask = ProcessAsync(_disposeCts.Token);
+ }
+
+ #region Properties
+
+ ///
+ public string Name => "Serial";
+
+ ///
+ public virtual TimeSpan IdleTimeout { get; set; } = TimeSpan.FromSeconds(6);
+
+ ///
+ public virtual TimeSpan ConnectTimeout { get; set; } = TimeSpan.MaxValue;
+
+ ///
+ public virtual TimeSpan ReadTimeout
+ {
+ get => TimeSpan.FromMilliseconds(_serialPort.ReadTimeout);
+ set => _serialPort.ReadTimeout = (int)value.TotalMilliseconds;
+ }
+
+ ///
+ public virtual TimeSpan WriteTimeout
+ {
+ get => TimeSpan.FromMilliseconds(_serialPort.WriteTimeout);
+ set => _serialPort.WriteTimeout = (int)value.TotalMilliseconds;
+ }
+
+ ///
+ /// Gets or sets a value indicating whether the RS485 driver has to be enabled via software switch.
+ ///
+ public virtual bool DriverEnabledRS485 { get; set; }
+
+ ///
+ /// Gets or sets a wait-time between requests.
+ ///
+ public virtual TimeSpan InterRequestDelay { get; set; } = TimeSpan.Zero;
+
+ #region SerialPort Properties
+
+ ///
+ public virtual string PortName
+ {
+ get => _serialPort.PortName;
+ set => _serialPort.PortName = value;
+ }
+
+ ///
+ /// Gets or sets the serial baud rate.
+ ///
+ public virtual BaudRate BaudRate
+ {
+ get => (BaudRate)_serialPort.BaudRate;
+ set => _serialPort.BaudRate = (int)value;
+ }
+
+ ///
+ ///
+ /// Should be 7 for ASCII mode and 8 for RTU mode.
+ ///
+ public virtual int DataBits
+ {
+ get => _serialPort.DataBits;
+ set => _serialPort.DataBits = value;
+ }
+
+ ///
+ public virtual Handshake Handshake
+ {
+ get => _serialPort.Handshake;
+ set => _serialPort.Handshake = value;
+ }
+
+ ///
+ ///
+ /// From the Specs:
+ ///
+ /// is recommended and therefore the default value.
+ ///
+ /// If you use , is required,
+ /// otherwise should work fine.
+ ///
+ public virtual Parity Parity
+ {
+ get => _serialPort.Parity;
+ set => _serialPort.Parity = value;
+ }
+
+ ///
+ public virtual bool RtsEnable
+ {
+ get => _serialPort.RtsEnable;
+ set => _serialPort.RtsEnable = value;
+ }
+
+ ///
+ ///
+ /// From the Specs:
+ ///
+ /// Should be for or and
+ ///
+ /// should be for .
+ ///
+ public virtual StopBits StopBits
+ {
+ get => _serialPort.StopBits;
+ set => _serialPort.StopBits = value;
+ }
+
+ #endregion SerialPort Properties
+
+ #endregion Properties
+
+ ///
+ /// Releases all managed and unmanaged resources used by the .
+ ///
+ public void Dispose()
+ {
+ if (_isDisposed)
+ return;
+
+ _isDisposed = true;
+ _disposeCts.Cancel();
+
+ _idleTimer.Dispose();
+
+ try
+ {
+ _processingTask.Wait();
+ _processingTask.Dispose();
+ }
+ catch
+ { /* keep it quiet */ }
+
+ OnIdleTimer(null);
+
+ _serialPort.Dispose();
+ _portLock.Dispose();
+
+ while (_requestQueue.TryDequeue(out var item))
+ {
+ item.CancellationTokenRegistration.Dispose();
+ item.CancellationTokenSource.Dispose();
+ item.TaskCompletionSource.TrySetException(new ObjectDisposedException(GetType().FullName));
+ }
+
+ _disposeCts.Dispose();
+ GC.SuppressFinalize(this);
+ }
+
+ #region Request processing
+
+ ///
+ public Task> InvokeAsync(IReadOnlyList request, Func, bool> validateResponseComplete, CancellationToken cancellationToken = default)
+ {
+#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(),
+ 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(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 _portLock.WaitAsync(linkedCts.Token).ConfigureAwait(false);
+ try
+ {
+ // Ensure connection is up
+ await AssertConnection(linkedCts.Token).ConfigureAwait(false);
+
+ await _serialPort.WriteAsync(item.Request, linkedCts.Token).ConfigureAwait(false);
+
+ linkedCts.Token.ThrowIfCancellationRequested();
+
+ var bytes = new List();
+ byte[] buffer = new byte[RtuProtocol.MAX_ADU_LENGTH];
+
+ do
+ {
+ int readCount = await _serialPort.ReadAsync(buffer, 0, buffer.Length, linkedCts.Token).ConfigureAwait(false);
+ 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
+ {
+ _portLock.Release();
+ _idleTimer.Change(IdleTimeout, Timeout.InfiniteTimeSpan);
+
+ await Task.Delay(InterRequestDelay, cancellationToken).ConfigureAwait(false);
+ }
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ // Dispose() called while waiting for request item
+ }
+ }
+ }
+
+ #endregion Request processing
+
+ #region Connection handling
+
+ // Has to be called within _portLock!
+ private async Task AssertConnection(CancellationToken cancellationToken)
+ {
+ if (_serialPort.IsOpen)
+ return;
+
+ int delay = 1;
+ int maxDelay = 60;
+
+ var startTime = DateTime.UtcNow;
+ while (!cancellationToken.IsCancellationRequested)
+ {
+ try
+ {
+ _serialPort.Close();
+ _serialPort.ResetRS485DriverStateFlags();
+
+ if (DriverEnabledRS485 && (RuntimeInformation.IsOSPlatform(OSPlatform.Linux) || _isUnitTest))
+ {
+ var flags = _serialPort.GetRS485DriverStateFlags();
+ flags |= RS485Flags.Enabled;
+ flags &= ~RS485Flags.RxDuringTx;
+ _serialPort.ChangeRS485DriverStateFlags(flags);
+ }
+
+ using var connectTask = Task.Run(_serialPort.Open);
+ if (await Task.WhenAny(connectTask, Task.Delay(ReadTimeout, cancellationToken)) == connectTask)
+ {
+ await connectTask;
+ if (_serialPort.IsOpen)
+ return;
+ }
+
+ throw new IOException();
+ }
+ catch (IOException) when (ConnectTimeout == TimeSpan.MaxValue || DateTime.UtcNow.Subtract(startTime) < ConnectTimeout)
+ {
+ delay *= 2;
+ if (delay > maxDelay)
+ delay = maxDelay;
+
+ try
+ {
+ await Task.Delay(TimeSpan.FromSeconds(delay), cancellationToken).ConfigureAwait(false);
+ }
+ catch
+ { /* keep it quiet */ }
+ }
+ }
+ }
+
+ private void OnIdleTimer(object _)
+ {
+ try
+ {
+ _portLock.Wait(_disposeCts.Token);
+ try
+ {
+ if (!_serialPort.IsOpen)
+ return;
+
+ _serialPort.Close();
+ _serialPort.ResetRS485DriverStateFlags();
+ }
+ finally
+ {
+ _portLock.Release();
+ }
+ }
+ catch
+ { /* keep it quiet */ }
+ }
+
+ #endregion Connection handling
+ }
+}
diff --git a/AMWD.Protocols.Modbus.Serial/Utils/SafeUnixHandle.cs b/AMWD.Protocols.Modbus.Serial/Utils/SafeUnixHandle.cs
new file mode 100644
index 0000000..d99ac76
--- /dev/null
+++ b/AMWD.Protocols.Modbus.Serial/Utils/SafeUnixHandle.cs
@@ -0,0 +1,38 @@
+using System;
+using System.Runtime.InteropServices;
+#if NETSTANDARD
+using System.Runtime.ConstrainedExecution;
+using System.Security.Permissions;
+#endif
+
+namespace AMWD.Protocols.Modbus.Serial.Utils
+{
+ ///
+ /// Implements a safe handle for unix systems.
+ ///
+ /// Found on https://stackoverflow.com/a/10388107
+ ///
+#if NETSTANDARD
+ [SecurityPermission(SecurityAction.InheritanceDemand, UnmanagedCode = true)]
+ [SecurityPermission(SecurityAction.Demand, UnmanagedCode = true)]
+#endif
+ [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
+ internal sealed class SafeUnixHandle : SafeHandle
+ {
+ ///
+ /// Initializes a new instance of the class.
+ ///
+#if NETSTANDARD
+ [ReliabilityContract(Consistency.WillNotCorruptState, Cer.MayFail)]
+#endif
+ private SafeUnixHandle()
+ : base(new IntPtr(-1), true)
+ { }
+
+ public override bool IsInvalid
+ => handle == new IntPtr(-1);
+
+ protected override bool ReleaseHandle()
+ => UnsafeNativeMethods.Close(handle) != -1;
+ }
+}
diff --git a/AMWD.Protocols.Modbus.Serial/Utils/SerialPortWrapper.cs b/AMWD.Protocols.Modbus.Serial/Utils/SerialPortWrapper.cs
new file mode 100644
index 0000000..b1157c8
--- /dev/null
+++ b/AMWD.Protocols.Modbus.Serial/Utils/SerialPortWrapper.cs
@@ -0,0 +1,291 @@
+using System;
+using System.IO;
+using System.IO.Ports;
+using System.Runtime.InteropServices;
+using System.Threading.Tasks;
+using System.Threading;
+using AMWD.Protocols.Modbus.Serial.Enums;
+
+namespace AMWD.Protocols.Modbus.Serial.Utils
+{
+ [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
+ internal class SerialPortWrapper : IDisposable
+ {
+ #region Fields
+
+ private readonly SerialPort _serialPort = new();
+
+ private bool _driverStateChanged = false;
+ private RS485Flags _initialFlags = 0;
+
+ #endregion Fields
+
+ #region Properties
+
+ ///
+ public virtual Handshake Handshake
+ {
+ get => _serialPort.Handshake;
+ set => _serialPort.Handshake = value;
+ }
+
+ ///
+ public virtual int DataBits
+ {
+ get => _serialPort.DataBits;
+ set => _serialPort.DataBits = value;
+ }
+
+ ///
+ public virtual bool IsOpen
+ => _serialPort.IsOpen;
+
+ ///
+ public virtual string PortName
+ {
+ get => _serialPort.PortName;
+ set => _serialPort.PortName = value;
+ }
+
+ ///
+ public virtual int ReadTimeout
+ {
+ get => _serialPort.ReadTimeout;
+ set => _serialPort.ReadTimeout = value;
+ }
+
+ ///
+ public virtual bool RtsEnable
+ {
+ get => _serialPort.RtsEnable;
+ set => _serialPort.RtsEnable = value;
+ }
+
+ ///
+ public virtual StopBits StopBits
+ {
+ get => _serialPort.StopBits;
+ set => _serialPort.StopBits = value;
+ }
+
+ ///
+ public virtual int WriteTimeout
+ {
+ get => _serialPort.WriteTimeout;
+ set => _serialPort.WriteTimeout = value;
+ }
+
+ ///
+ public virtual Parity Parity
+ {
+ get => _serialPort.Parity;
+ set => _serialPort.Parity = value;
+ }
+
+ ///
+ public virtual int BaudRate
+ {
+ get => _serialPort.BaudRate;
+ set => _serialPort.BaudRate = value;
+ }
+
+ #endregion Properties
+
+ #region Methods
+
+ ///
+ public virtual void Close()
+ => _serialPort.Close();
+
+ ///
+ public virtual void Open()
+ => _serialPort.Open();
+
+ ///
+ public virtual void Dispose()
+ => _serialPort.Dispose();
+
+ #endregion Methods
+
+ #region Extensions
+
+ ///
+ /// Asynchronously reads a sequence of bytes from the current serial port, advances the
+ /// position within the stream by the number of bytes read, and monitors cancellation
+ /// requests.
+ ///
+ ///
+ /// There seems to be a bug with the async stream implementation on Windows.
+ ///
+ /// See this StackOverflow answer:
+ ///
+ /// The buffer to write the data into.
+ /// The byte offset in buffer at which to begin writing data from the serial port.
+ /// The maximum number of bytes to read.
+ /// The token to monitor for cancellation requests. The default value is .
+ ///
+ /// A task that represents the asynchronous read operation. The value of the TResult
+ /// parameter contains the total number of bytes read into the buffer. The result
+ /// value can be less than the number of bytes requested if the number of bytes currently
+ /// available is less than the requested number, or it can be 0 (zero) if the end
+ /// of the stream has been reached.
+ ///
+ public virtual async Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken = default)
+ {
+ using var cts = new CancellationTokenSource(_serialPort.ReadTimeout);
+ using var reg = cancellationToken.Register(cts.Cancel);
+
+ var ctr = default(CancellationTokenRegistration);
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+ {
+ // The async stream implementation on windows seems a bit broken.
+ // So this will ensure the task to return to the caller.
+ ctr = cts.Token.Register(_serialPort.DiscardInBuffer);
+ }
+
+ try
+ {
+ return await _serialPort.BaseStream.ReadAsync(buffer, offset, count, cts.Token).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ return 0;
+ }
+ catch (OperationCanceledException) when (cts.IsCancellationRequested)
+ {
+ throw new TimeoutException("No bytes read within the ReadTimeout.");
+ }
+ catch (IOException) when (cts.IsCancellationRequested && !cancellationToken.IsCancellationRequested)
+ {
+ throw new TimeoutException("No bytes read within the ReadTimeout.");
+ }
+ finally
+ {
+ ctr.Dispose();
+ }
+ }
+
+ ///
+ /// Asynchronously writes a sequence of bytes to the current serial port, advances the
+ /// current position within this stream by the number of bytes written, and monitors
+ /// cancellation requests.
+ ///
+ ///
+ /// There seems to be a bug with the async stream implementation on Windows.
+ ///
+ /// See this StackOverflow answer:
+ ///
+ /// The buffer to write the data from.
+ /// The token to monitor for cancellation requests. The default value is .
+ ///
+ public virtual async Task WriteAsync(byte[] buffer, CancellationToken cancellationToken = default)
+ {
+ using var cts = new CancellationTokenSource(_serialPort.WriteTimeout);
+ using var reg = cancellationToken.Register(cts.Cancel);
+
+ var ctr = default(CancellationTokenRegistration);
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+ {
+ // The async stream implementation on windows seems a bit broken.
+ // So this will ensure the task to return to the caller.
+ ctr = cts.Token.Register(_serialPort.DiscardOutBuffer);
+ }
+
+ try
+ {
+#if NET6_0_OR_GREATER
+ await _serialPort.BaseStream.WriteAsync(buffer, cts.Token).ConfigureAwait(false);
+#else
+ await _serialPort.BaseStream.WriteAsync(buffer, 0, buffer.Length, cts.Token).ConfigureAwait(false);
+#endif
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ }
+ catch (OperationCanceledException) when (cts.IsCancellationRequested)
+ {
+ throw new TimeoutException("No bytes written within the WriteTimeout.");
+ }
+ catch (IOException) when (cts.IsCancellationRequested && !cancellationToken.IsCancellationRequested)
+ {
+ throw new TimeoutException("No bytes written within the WriteTimeout.");
+ }
+ finally
+ {
+ ctr.Dispose();
+ }
+ }
+
+ internal virtual void ChangeRS485DriverStateFlags(RS485Flags flags)
+ {
+ if (_driverStateChanged)
+ throw new InvalidOperationException("The RS485 driver state has already been changed.");
+
+ _driverStateChanged = true;
+ _initialFlags = GetRS485DriverStateFlags();
+ ChangeRS485DriverStateFlagsInternal(flags);
+ }
+
+ internal virtual void ResetRS485DriverStateFlags()
+ {
+ if (!_driverStateChanged)
+ return;
+
+ ChangeRS485DriverStateFlagsInternal(_initialFlags);
+ _driverStateChanged = false;
+ _initialFlags = 0;
+ }
+
+ internal virtual RS485Flags GetRS485DriverStateFlags()
+ {
+ var rs485 = new SerialRS485();
+ SafeUnixHandle handle = null;
+
+ try
+ {
+ handle = UnsafeNativeMethods.Open(PortName, UnsafeNativeMethods.O_RDWR | UnsafeNativeMethods.O_NOCTTY);
+ if (UnsafeNativeMethods.IoCtl(handle, UnsafeNativeMethods.TIOCGRS485, ref rs485) == -1)
+ throw new UnixIOException();
+ }
+ finally
+ {
+ handle?.Dispose();
+ }
+
+ return rs485.Flags;
+ }
+
+ private void ChangeRS485DriverStateFlagsInternal(RS485Flags flags)
+ {
+ var rs485 = new SerialRS485();
+ SafeUnixHandle handle = null;
+
+ try
+ {
+ handle = UnsafeNativeMethods.Open(PortName, UnsafeNativeMethods.O_RDWR | UnsafeNativeMethods.O_NOCTTY);
+ if (UnsafeNativeMethods.IoCtl(handle, UnsafeNativeMethods.TIOCGRS485, ref rs485) == -1)
+ throw new UnixIOException();
+ }
+ finally
+ {
+ handle?.Dispose();
+ }
+
+ rs485.Flags = flags;
+ try
+ {
+ handle = UnsafeNativeMethods.Open(PortName, UnsafeNativeMethods.O_RDWR | UnsafeNativeMethods.O_NOCTTY);
+ if (UnsafeNativeMethods.IoCtl(handle, UnsafeNativeMethods.TIOCSRS485, ref rs485) == -1)
+ throw new UnixIOException();
+ }
+ finally
+ {
+ handle?.Dispose();
+ }
+ }
+
+ #endregion Extensions
+ }
+}
diff --git a/AMWD.Protocols.Modbus.Serial/Utils/SerialRS485.cs b/AMWD.Protocols.Modbus.Serial/Utils/SerialRS485.cs
new file mode 100644
index 0000000..ce8da48
--- /dev/null
+++ b/AMWD.Protocols.Modbus.Serial/Utils/SerialRS485.cs
@@ -0,0 +1,28 @@
+using System.Runtime.InteropServices;
+using AMWD.Protocols.Modbus.Serial.Enums;
+
+namespace AMWD.Protocols.Modbus.Serial.Utils
+{
+ ///
+ /// Represents the structure of the driver settings for RS485.
+ ///
+ [StructLayout(LayoutKind.Sequential, Size = 32)]
+ [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
+ internal struct SerialRS485
+ {
+ ///
+ /// The flags to change the driver state.
+ ///
+ public RS485Flags Flags;
+
+ ///
+ /// The delay in milliseconds before send.
+ ///
+ public uint RtsDelayBeforeSend;
+
+ ///
+ /// The delay in milliseconds after send.
+ ///
+ public uint RtsDelayAfterSend;
+ }
+}
diff --git a/AMWD.Protocols.Modbus.Serial/Utils/UnsafeNativeMethods.cs b/AMWD.Protocols.Modbus.Serial/Utils/UnsafeNativeMethods.cs
new file mode 100644
index 0000000..c6dfb19
--- /dev/null
+++ b/AMWD.Protocols.Modbus.Serial/Utils/UnsafeNativeMethods.cs
@@ -0,0 +1,71 @@
+using System;
+using System.Runtime.InteropServices;
+#if NETSTANDARD
+using System.Runtime.ConstrainedExecution;
+#endif
+
+namespace AMWD.Protocols.Modbus.Serial.Utils
+{
+ ///
+ /// Definitions of the unsafe system methods.
+ ///
+ /// Found on https://stackoverflow.com/a/10388107
+ ///
+ [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
+ internal static class UnsafeNativeMethods
+ {
+ ///
+ /// A flag for .
+ ///
+ internal const int O_RDWR = 2;
+
+ ///
+ /// A flag for .
+ ///
+ internal const int O_NOCTTY = 256;
+
+ ///
+ /// A flag for .
+ ///
+ internal const uint TIOCGRS485 = 0x542E;
+
+ ///
+ /// A flag for .
+ ///
+ internal const uint TIOCSRS485 = 0x542F;
+
+ ///
+ /// Opens a handle to a defined path (serial port).
+ ///
+ /// The path to open the handle.
+ /// The flags for the handle.
+ [DllImport("libc", EntryPoint = "open", SetLastError = true)]
+ internal static extern SafeUnixHandle Open(string path, uint flag);
+
+ ///
+ /// Performs an ioctl request to the open handle.
+ ///
+ /// The handle.
+ /// The request.
+ /// The serial rs485 data structure to use.
+ [DllImport("libc", EntryPoint = "ioctl", SetLastError = true)]
+ internal static extern int IoCtl(SafeUnixHandle handle, uint request, ref SerialRS485 serialRs485);
+
+ ///
+ /// Closes an open handle.
+ ///
+ /// The handle.
+#if NETSTANDARD
+ [ReliabilityContract(Consistency.WillNotCorruptState, Cer.MayFail)]
+#endif
+ [DllImport("libc", EntryPoint = "close", SetLastError = true)]
+ internal static extern int Close(IntPtr handle);
+
+ ///
+ /// Converts the given error number (errno) into a readable string.
+ ///
+ /// The error number.
+ [DllImport("libc", EntryPoint = "strerror", SetLastError = true, CallingConvention = CallingConvention.Cdecl)]
+ internal static extern IntPtr StrError(int errno);
+ }
+}
diff --git a/AMWD.Protocols.Modbus.Tcp/ModbusTcpServer.cs b/AMWD.Protocols.Modbus.Tcp/ModbusTcpServer.cs
index 5ddca31..4b59329 100644
--- a/AMWD.Protocols.Modbus.Tcp/ModbusTcpServer.cs
+++ b/AMWD.Protocols.Modbus.Tcp/ModbusTcpServer.cs
@@ -9,9 +9,9 @@ using System.Text;
using System.Threading;
using System.Threading.Tasks;
using AMWD.Protocols.Modbus.Common;
+using AMWD.Protocols.Modbus.Common.Events;
using AMWD.Protocols.Modbus.Common.Models;
using AMWD.Protocols.Modbus.Common.Protocols;
-using AMWD.Protocols.Modbus.Tcp.Events;
namespace AMWD.Protocols.Modbus.Tcp
{
@@ -45,8 +45,6 @@ namespace AMWD.Protocols.Modbus.Tcp
///
/// An to listen on (Default: ).
/// A port to listen on (Default: 502).
- ///
- ///
public ModbusTcpServer(IPAddress listenAddress = null, int listenPort = 502)
{
ListenAddress = listenAddress ?? IPAddress.Loopback;
diff --git a/AMWD.Protocols.Modbus.Tests/AMWD.Protocols.Modbus.Tests.csproj b/AMWD.Protocols.Modbus.Tests/AMWD.Protocols.Modbus.Tests.csproj
index 3f873f2..43c92a4 100644
--- a/AMWD.Protocols.Modbus.Tests/AMWD.Protocols.Modbus.Tests.csproj
+++ b/AMWD.Protocols.Modbus.Tests/AMWD.Protocols.Modbus.Tests.csproj
@@ -25,6 +25,7 @@
+
diff --git a/AMWD.Protocols.Modbus.Tests/Serial/ModbusSerialClientTest.cs b/AMWD.Protocols.Modbus.Tests/Serial/ModbusSerialClientTest.cs
new file mode 100644
index 0000000..e8ba84c
--- /dev/null
+++ b/AMWD.Protocols.Modbus.Tests/Serial/ModbusSerialClientTest.cs
@@ -0,0 +1,235 @@
+using System.IO.Ports;
+using AMWD.Protocols.Modbus.Serial;
+using Moq;
+
+namespace AMWD.Protocols.Modbus.Tests.Serial
+{
+ [TestClass]
+ public class ModbusSerialClientTest
+ {
+ private Mock _genericConnectionMock;
+ private Mock _serialConnectionMock;
+
+ [TestInitialize]
+ public void Initialize()
+ {
+ _genericConnectionMock = new Mock();
+ _genericConnectionMock.Setup(c => c.IdleTimeout).Returns(TimeSpan.FromSeconds(40));
+ _genericConnectionMock.Setup(c => c.ConnectTimeout).Returns(TimeSpan.FromSeconds(30));
+ _genericConnectionMock.Setup(c => c.ReadTimeout).Returns(TimeSpan.FromSeconds(20));
+ _genericConnectionMock.Setup(c => c.WriteTimeout).Returns(TimeSpan.FromSeconds(10));
+
+ _serialConnectionMock = new Mock();
+
+ _serialConnectionMock.Setup(c => c.IdleTimeout).Returns(TimeSpan.FromSeconds(10));
+ _serialConnectionMock.Setup(c => c.ConnectTimeout).Returns(TimeSpan.FromSeconds(20));
+ _serialConnectionMock.Setup(c => c.ReadTimeout).Returns(TimeSpan.FromSeconds(30));
+ _serialConnectionMock.Setup(c => c.WriteTimeout).Returns(TimeSpan.FromSeconds(40));
+
+ _serialConnectionMock.Setup(c => c.DriverEnabledRS485).Returns(true);
+ _serialConnectionMock.Setup(c => c.InterRequestDelay).Returns(TimeSpan.FromSeconds(50));
+ _serialConnectionMock.Setup(c => c.PortName).Returns("COM-42");
+ _serialConnectionMock.Setup(c => c.BaudRate).Returns(BaudRate.Baud2400);
+ _serialConnectionMock.Setup(c => c.DataBits).Returns(7);
+ _serialConnectionMock.Setup(c => c.Handshake).Returns(Handshake.XOnXOff);
+ _serialConnectionMock.Setup(c => c.Parity).Returns(Parity.Space);
+ _serialConnectionMock.Setup(c => c.RtsEnable).Returns(true);
+ _serialConnectionMock.Setup(c => c.StopBits).Returns(StopBits.OnePointFive);
+ }
+
+ [TestMethod]
+ public void ShouldReturnDefaultValuesForGenericConnection()
+ {
+ // Arrange
+ var client = new ModbusSerialClient(_genericConnectionMock.Object);
+
+ // Act
+ bool driverEnabled = client.DriverEnabledRS485;
+ var requestDelay = client.InterRequestDelay;
+ string portName = client.PortName;
+ var baudRate = client.BaudRate;
+ int dataBits = client.DataBits;
+ var handshake = client.Handshake;
+ var parity = client.Parity;
+ bool rtsEnable = client.RtsEnable;
+ var stopBits = client.StopBits;
+
+ // Assert
+ Assert.IsFalse(driverEnabled);
+ Assert.AreEqual(TimeSpan.Zero, requestDelay);
+ Assert.IsNull(portName);
+ Assert.AreEqual(0, (int)baudRate);
+ Assert.AreEqual(0, dataBits);
+ Assert.AreEqual(0, (int)handshake);
+ Assert.AreEqual(0, (int)parity);
+ Assert.IsFalse(rtsEnable);
+ Assert.AreEqual(0, (int)stopBits);
+
+ _genericConnectionMock.VerifyNoOtherCalls();
+ }
+
+ [TestMethod]
+ public void ShouldNotSetValuesForGenericConnection()
+ {
+ // Arrange
+ var client = new ModbusSerialClient(_genericConnectionMock.Object);
+
+ // Act
+ client.DriverEnabledRS485 = true;
+ client.InterRequestDelay = TimeSpan.FromSeconds(123);
+ client.PortName = "COM-42";
+ client.BaudRate = BaudRate.Baud2400;
+ client.DataBits = 7;
+ client.Handshake = Handshake.XOnXOff;
+ client.Parity = Parity.Space;
+ client.RtsEnable = true;
+ client.StopBits = StopBits.OnePointFive;
+
+ // Assert
+ _genericConnectionMock.VerifyNoOtherCalls();
+ }
+
+ [TestMethod]
+ public void ShouldReturnValuesForGenericConnection()
+ {
+ // Arrange
+ var client = new ModbusSerialClient(_genericConnectionMock.Object);
+
+ // Act
+ var idleTimeout = client.IdleTimeout;
+ var connectTimeout = client.ConnectTimeout;
+ var readTimeout = client.ReadTimeout;
+ var writeTimeout = client.WriteTimeout;
+
+ // Assert
+ Assert.AreEqual(TimeSpan.FromSeconds(40), idleTimeout);
+ Assert.AreEqual(TimeSpan.FromSeconds(30), connectTimeout);
+ Assert.AreEqual(TimeSpan.FromSeconds(20), readTimeout);
+ Assert.AreEqual(TimeSpan.FromSeconds(10), writeTimeout);
+
+ _genericConnectionMock.VerifyGet(c => c.IdleTimeout, Times.Once);
+ _genericConnectionMock.VerifyGet(c => c.ConnectTimeout, Times.Once);
+ _genericConnectionMock.VerifyGet(c => c.ReadTimeout, Times.Once);
+ _genericConnectionMock.VerifyGet(c => c.WriteTimeout, Times.Once);
+ _genericConnectionMock.VerifyNoOtherCalls();
+ }
+
+ [TestMethod]
+ public void ShouldSetValuesForGenericConnection()
+ {
+ // Arrange
+ var client = new ModbusSerialClient(_genericConnectionMock.Object);
+
+ // Act
+ client.IdleTimeout = TimeSpan.FromSeconds(10);
+ client.ConnectTimeout = TimeSpan.FromSeconds(20);
+ client.ReadTimeout = TimeSpan.FromSeconds(30);
+ client.WriteTimeout = TimeSpan.FromSeconds(40);
+
+ // Assert
+ _genericConnectionMock.VerifySet(c => c.IdleTimeout = TimeSpan.FromSeconds(10), Times.Once);
+ _genericConnectionMock.VerifySet(c => c.ConnectTimeout = TimeSpan.FromSeconds(20), Times.Once);
+ _genericConnectionMock.VerifySet(c => c.ReadTimeout = TimeSpan.FromSeconds(30), Times.Once);
+ _genericConnectionMock.VerifySet(c => c.WriteTimeout = TimeSpan.FromSeconds(40), Times.Once);
+
+ _genericConnectionMock.VerifyNoOtherCalls();
+ }
+
+ [TestMethod]
+ public void ShouldGetValuesForSerialConnection()
+ {
+ // Arrange
+ var client = new ModbusSerialClient(_serialConnectionMock.Object);
+
+ // Act
+ bool driverEnabled = client.DriverEnabledRS485;
+ var requestDelay = client.InterRequestDelay;
+ string portName = client.PortName;
+ var baudRate = client.BaudRate;
+ int dataBits = client.DataBits;
+ var handshake = client.Handshake;
+ var parity = client.Parity;
+ bool rtsEnable = client.RtsEnable;
+ var stopBits = client.StopBits;
+
+ var idleTimeout = client.IdleTimeout;
+ var connectTimeout = client.ConnectTimeout;
+ var readTimeout = client.ReadTimeout;
+ var writeTimeout = client.WriteTimeout;
+
+ // Assert
+ Assert.IsTrue(driverEnabled);
+ Assert.AreEqual(TimeSpan.FromSeconds(50), requestDelay);
+ Assert.AreEqual("COM-42", portName);
+ Assert.AreEqual(BaudRate.Baud2400, baudRate);
+ Assert.AreEqual(7, dataBits);
+ Assert.AreEqual(Handshake.XOnXOff, handshake);
+ Assert.AreEqual(Parity.Space, parity);
+ Assert.IsTrue(rtsEnable);
+ Assert.AreEqual(StopBits.OnePointFive, stopBits);
+
+ Assert.AreEqual(TimeSpan.FromSeconds(10), idleTimeout);
+ Assert.AreEqual(TimeSpan.FromSeconds(20), connectTimeout);
+ Assert.AreEqual(TimeSpan.FromSeconds(30), readTimeout);
+ Assert.AreEqual(TimeSpan.FromSeconds(40), writeTimeout);
+
+ _serialConnectionMock.VerifyGet(c => c.DriverEnabledRS485, Times.Once);
+ _serialConnectionMock.VerifyGet(c => c.InterRequestDelay, Times.Once);
+ _serialConnectionMock.VerifyGet(c => c.PortName, Times.Once);
+ _serialConnectionMock.VerifyGet(c => c.BaudRate, Times.Once);
+ _serialConnectionMock.VerifyGet(c => c.DataBits, Times.Once);
+ _serialConnectionMock.VerifyGet(c => c.Handshake, Times.Once);
+ _serialConnectionMock.VerifyGet(c => c.Parity, Times.Once);
+ _serialConnectionMock.VerifyGet(c => c.RtsEnable, Times.Once);
+ _serialConnectionMock.VerifyGet(c => c.StopBits, Times.Once);
+
+ _serialConnectionMock.VerifyGet(c => c.IdleTimeout, Times.Once);
+ _serialConnectionMock.VerifyGet(c => c.ConnectTimeout, Times.Once);
+ _serialConnectionMock.VerifyGet(c => c.ReadTimeout, Times.Once);
+ _serialConnectionMock.VerifyGet(c => c.WriteTimeout, Times.Once);
+
+ _serialConnectionMock.VerifyNoOtherCalls();
+ }
+
+ [TestMethod]
+ public void ShouldSetValuesForSerialConnection()
+ {
+ // Arrange
+ var client = new ModbusSerialClient(_serialConnectionMock.Object);
+
+ // Act
+ client.DriverEnabledRS485 = true;
+ client.InterRequestDelay = TimeSpan.FromSeconds(123);
+ client.PortName = "COM-42";
+ client.BaudRate = BaudRate.Baud2400;
+ client.DataBits = 7;
+ client.Handshake = Handshake.XOnXOff;
+ client.Parity = Parity.Space;
+ client.RtsEnable = true;
+ client.StopBits = StopBits.OnePointFive;
+
+ client.IdleTimeout = TimeSpan.FromSeconds(40);
+ client.ConnectTimeout = TimeSpan.FromSeconds(30);
+ client.ReadTimeout = TimeSpan.FromSeconds(20);
+ client.WriteTimeout = TimeSpan.FromSeconds(10);
+
+ // Assert
+ _serialConnectionMock.VerifySet(c => c.DriverEnabledRS485 = true, Times.Once);
+ _serialConnectionMock.VerifySet(c => c.InterRequestDelay = TimeSpan.FromSeconds(123), Times.Once);
+ _serialConnectionMock.VerifySet(c => c.PortName = "COM-42", Times.Once);
+ _serialConnectionMock.VerifySet(c => c.BaudRate = BaudRate.Baud2400, Times.Once);
+ _serialConnectionMock.VerifySet(c => c.DataBits = 7, Times.Once);
+ _serialConnectionMock.VerifySet(c => c.Handshake = Handshake.XOnXOff, Times.Once);
+ _serialConnectionMock.VerifySet(c => c.Parity = Parity.Space, Times.Once);
+ _serialConnectionMock.VerifySet(c => c.RtsEnable = true, Times.Once);
+ _serialConnectionMock.VerifySet(c => c.StopBits = StopBits.OnePointFive, Times.Once);
+
+ _serialConnectionMock.VerifySet(c => c.IdleTimeout = TimeSpan.FromSeconds(40), Times.Once);
+ _serialConnectionMock.VerifySet(c => c.ConnectTimeout = TimeSpan.FromSeconds(30), Times.Once);
+ _serialConnectionMock.VerifySet(c => c.ReadTimeout = TimeSpan.FromSeconds(20), Times.Once);
+ _serialConnectionMock.VerifySet(c => c.WriteTimeout = TimeSpan.FromSeconds(10), Times.Once);
+
+ _serialConnectionMock.VerifyNoOtherCalls();
+ }
+ }
+}
diff --git a/AMWD.Protocols.Modbus.Tests/Serial/ModbusSerialConnectionTest.cs b/AMWD.Protocols.Modbus.Tests/Serial/ModbusSerialConnectionTest.cs
new file mode 100644
index 0000000..c847fd0
--- /dev/null
+++ b/AMWD.Protocols.Modbus.Tests/Serial/ModbusSerialConnectionTest.cs
@@ -0,0 +1,483 @@
+using System.Collections.Generic;
+using System.IO;
+using System.IO.Ports;
+using System.Reflection;
+using System.Threading;
+using System.Threading.Tasks;
+using AMWD.Protocols.Modbus.Serial;
+using AMWD.Protocols.Modbus.Serial.Enums;
+using AMWD.Protocols.Modbus.Serial.Utils;
+using Moq;
+
+namespace AMWD.Protocols.Modbus.Tests.Serial
+{
+ [TestClass]
+ public class ModbusSerialConnectionTest
+ {
+ private Mock _serialPortMock;
+
+ private bool _alwaysOpen;
+ private Queue _isOpenQueue;
+
+ private readonly int _serialPortReadTimeout = 1000;
+ private readonly int _serialPortWriteTimeout = 1000;
+
+ private List _serialLineRequestCallbacks;
+
+ private Queue _serialLineResponseQueue;
+
+ [TestInitialize]
+ public void Initialize()
+ {
+ _alwaysOpen = true;
+ _isOpenQueue = new Queue();
+
+ _serialLineRequestCallbacks = [];
+ _serialLineResponseQueue = new Queue();
+ }
+
+ [TestMethod]
+ public void ShouldGetAndSetPropertiesOfBaseClient()
+ {
+ // Arrange
+ var connection = GetSerialConnection();
+
+ // Act
+ connection.PortName = "SerialPort";
+ connection.BaudRate = BaudRate.Baud2400;
+ connection.DataBits = 5;
+ connection.Handshake = Handshake.XOnXOff;
+ connection.Parity = Parity.None;
+ connection.ReadTimeout = TimeSpan.FromSeconds(123);
+ connection.RtsEnable = true;
+ connection.StopBits = StopBits.OnePointFive;
+ connection.WriteTimeout = TimeSpan.FromSeconds(456);
+
+ // Assert - part 1
+ _serialPortMock.VerifySet(p => p.PortName = "SerialPort", Times.Once);
+ _serialPortMock.VerifySet(p => p.BaudRate = 2400, Times.Once);
+ _serialPortMock.VerifySet(p => p.DataBits = 5, Times.Once);
+ _serialPortMock.VerifySet(p => p.Handshake = Handshake.XOnXOff, Times.Once);
+ _serialPortMock.VerifySet(p => p.Parity = Parity.None, Times.Once);
+ _serialPortMock.VerifySet(p => p.ReadTimeout = 123000, Times.Once);
+ _serialPortMock.VerifySet(p => p.RtsEnable = true, Times.Once);
+ _serialPortMock.VerifySet(p => p.StopBits = StopBits.OnePointFive, Times.Once);
+ _serialPortMock.VerifySet(p => p.WriteTimeout = 456000, Times.Once);
+
+ _serialPortMock.VerifyNoOtherCalls();
+
+ // Assert - part 2
+ Assert.AreEqual("Serial", connection.Name);
+ Assert.IsNull(connection.PortName);
+ Assert.AreEqual(0, (int)connection.BaudRate);
+ Assert.AreEqual(0, connection.DataBits);
+ Assert.AreEqual(0, (int)connection.Handshake);
+ Assert.AreEqual(0, (int)connection.Parity);
+ Assert.AreEqual(1, connection.ReadTimeout.TotalSeconds);
+ Assert.IsFalse(connection.RtsEnable);
+ Assert.AreEqual(0, (int)connection.StopBits);
+ Assert.AreEqual(1, connection.WriteTimeout.TotalSeconds);
+ }
+
+ [TestMethod]
+ public void ShouldBeAbleToDisposeMultipleTimes()
+ {
+ // Arrange
+ var connection = GetConnection();
+
+ // Act
+ connection.Dispose();
+ connection.Dispose();
+ }
+
+ [TestMethod]
+ [ExpectedException(typeof(ObjectDisposedException))]
+ public async Task ShouldThrowDisposedExceptionOnInvokeAsync()
+ {
+ // Arrange
+ var connection = GetConnection();
+ connection.Dispose();
+
+ // Act
+ await connection.InvokeAsync(null, null);
+
+ // Assert - OjbectDisposedException
+ }
+
+ [DataTestMethod]
+ [DataRow(null)]
+ [DataRow(new byte[0])]
+ [ExpectedException(typeof(ArgumentNullException))]
+ public async Task ShouldThrowArgumentNullExceptionForMissingRequestOnInvokeAsync(byte[] request)
+ {
+ // Arrange
+ var connection = GetConnection();
+
+ // Act
+ await connection.InvokeAsync(request, null);
+
+ // Assert - ArgumentNullException
+ }
+
+ [TestMethod]
+ [ExpectedException(typeof(ArgumentNullException))]
+ public async Task ShouldThrowArgumentNullExceptionForMissingValidationOnInvokeAsync()
+ {
+ // Arrange
+ byte[] request = new byte[1];
+ var connection = GetConnection();
+
+ // Act
+ await connection.InvokeAsync(request, null);
+
+ // Assert - ArgumentNullException
+ }
+
+ [TestMethod]
+ public async Task ShouldInvokeAsync()
+ {
+ // Arrange
+ byte[] request = [1, 2, 3];
+ byte[] expectedResponse = [9, 8, 7];
+ var validation = new Func, bool>(_ => true);
+ _serialLineResponseQueue.Enqueue(expectedResponse);
+
+ var connection = GetConnection();
+
+ // Act
+ var response = await connection.InvokeAsync(request, validation);
+
+ // Assert
+ Assert.IsNotNull(response);
+
+ CollectionAssert.AreEqual(expectedResponse, response.ToArray());
+ CollectionAssert.AreEqual(request, _serialLineRequestCallbacks.First());
+
+ _serialPortMock.Verify(c => c.IsOpen, Times.Once);
+
+ _serialPortMock.Verify(ns => ns.WriteAsync(It.IsAny(), It.IsAny()), Times.Once);
+ _serialPortMock.Verify(ns => ns.ReadAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Once);
+
+ _serialPortMock.VerifyNoOtherCalls();
+ }
+
+ [DataTestMethod]
+ [DataRow(false)]
+ [DataRow(true)]
+ public async Task ShouldOpenAndCloseOnInvokeAsync(bool modifyDriver)
+ {
+ // Arrange
+ _alwaysOpen = false;
+ _isOpenQueue.Enqueue(false);
+ _isOpenQueue.Enqueue(true);
+ _isOpenQueue.Enqueue(true);
+
+ byte[] request = [1, 2, 3];
+ byte[] expectedResponse = [9, 8, 7];
+ var validation = new Func, bool>(_ => true);
+ _serialLineResponseQueue.Enqueue(expectedResponse);
+
+ var connection = GetSerialConnection();
+ connection.IdleTimeout = TimeSpan.FromMilliseconds(200);
+ connection.DriverEnabledRS485 = modifyDriver;
+
+ // Act
+ var response = await connection.InvokeAsync(request, validation);
+ await Task.Delay(500);
+
+ // Assert
+ Assert.IsNotNull(response);
+
+ CollectionAssert.AreEqual(expectedResponse, response.ToArray());
+ CollectionAssert.AreEqual(request, _serialLineRequestCallbacks.First());
+
+ _serialPortMock.VerifyGet(c => c.ReadTimeout, Times.Once);
+
+ _serialPortMock.Verify(c => c.IsOpen, Times.Exactly(3));
+ _serialPortMock.Verify(c => c.Close(), Times.Exactly(2));
+ _serialPortMock.Verify(c => c.ResetRS485DriverStateFlags(), Times.Exactly(2));
+ _serialPortMock.Verify(c => c.Open(), Times.Once);
+
+ if (modifyDriver)
+ {
+ _serialPortMock.Verify(c => c.GetRS485DriverStateFlags(), Times.Once);
+ _serialPortMock.Verify(c => c.ChangeRS485DriverStateFlags(It.IsAny()), Times.Once);
+ }
+
+ _serialPortMock.Verify(ns => ns.WriteAsync(It.IsAny(), It.IsAny()), Times.Once);
+ _serialPortMock.Verify(ns => ns.ReadAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Once);
+
+ _serialPortMock.VerifyNoOtherCalls();
+ }
+
+ [TestMethod]
+ [ExpectedException(typeof(EndOfStreamException))]
+ public async Task ShouldThrowEndOfStreamExceptionOnInvokeAsync()
+ {
+ // Arrange
+ byte[] request = [1, 2, 3];
+ var validation = new Func, bool>(_ => true);
+
+ var connection = GetConnection();
+
+ // Act
+ var response = await connection.InvokeAsync(request, validation);
+
+ // Assert - EndOfStreamException
+ }
+
+ [TestMethod]
+ public async Task ShouldSkipCloseOnTimeoutOnInvokeAsync()
+ {
+ // Arrange
+ _alwaysOpen = false;
+ _isOpenQueue.Enqueue(false);
+ _isOpenQueue.Enqueue(true);
+ _isOpenQueue.Enqueue(false);
+
+ byte[] request = [1, 2, 3];
+ byte[] expectedResponse = [9, 8, 7];
+ var validation = new Func, bool>(_ => true);
+ _serialLineResponseQueue.Enqueue(expectedResponse);
+
+ var connection = GetConnection();
+ connection.IdleTimeout = TimeSpan.FromMilliseconds(200);
+
+ // Act
+ var response = await connection.InvokeAsync(request, validation);
+ await Task.Delay(500);
+
+ // Assert
+ Assert.IsNotNull(response);
+
+ CollectionAssert.AreEqual(expectedResponse, response.ToArray());
+ CollectionAssert.AreEqual(request, _serialLineRequestCallbacks.First());
+
+ _serialPortMock.VerifyGet(c => c.ReadTimeout, Times.Once);
+
+ _serialPortMock.Verify(c => c.IsOpen, Times.Exactly(3));
+ _serialPortMock.Verify(c => c.Close(), Times.Once);
+ _serialPortMock.Verify(c => c.ResetRS485DriverStateFlags(), Times.Once);
+ _serialPortMock.Verify(c => c.Open(), Times.Once);
+
+ _serialPortMock.Verify(ns => ns.WriteAsync(It.IsAny(), It.IsAny()), Times.Once);
+ _serialPortMock.Verify(ns => ns.ReadAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Once);
+
+ _serialPortMock.VerifyNoOtherCalls();
+ }
+
+ [TestMethod]
+ public async Task ShouldRetryToConnectOnInvokeAsync()
+ {
+ // Arrange
+ _alwaysOpen = false;
+ _isOpenQueue.Enqueue(false);
+ _isOpenQueue.Enqueue(false);
+ _isOpenQueue.Enqueue(true);
+
+ byte[] request = [1, 2, 3];
+ byte[] expectedResponse = [9, 8, 7];
+ var validation = new Func, bool>(_ => true);
+ _serialLineResponseQueue.Enqueue(expectedResponse);
+
+ var connection = GetConnection();
+
+ // Act
+ var response = await connection.InvokeAsync(request, validation);
+
+ // Assert
+ Assert.IsNotNull(response);
+
+ CollectionAssert.AreEqual(expectedResponse, response.ToArray());
+ CollectionAssert.AreEqual(request, _serialLineRequestCallbacks.First());
+
+ _serialPortMock.VerifyGet(c => c.ReadTimeout, Times.Exactly(2));
+
+ _serialPortMock.Verify(c => c.IsOpen, Times.Exactly(3));
+ _serialPortMock.Verify(c => c.Close(), Times.Exactly(2));
+ _serialPortMock.Verify(c => c.ResetRS485DriverStateFlags(), Times.Exactly(2));
+ _serialPortMock.Verify(c => c.Open(), Times.Exactly(2));
+
+ _serialPortMock.Verify(ns => ns.WriteAsync(It.IsAny(), It.IsAny()), Times.Once);
+ _serialPortMock.Verify(ns => ns.ReadAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Once);
+
+ _serialPortMock.VerifyNoOtherCalls();
+ }
+
+ [TestMethod]
+ [ExpectedException(typeof(TaskCanceledException))]
+ public async Task ShouldThrowTaskCancelledExceptionForDisposeOnInvokeAsync()
+ {
+ // Arrange
+ byte[] request = [1, 2, 3];
+ var validation = new Func, bool>(_ => true);
+
+ var connection = GetConnection();
+ _serialPortMock
+ .Setup(ns => ns.WriteAsync(It.IsAny(), It.IsAny()))
+ .Returns(Task.Delay(100));
+
+ // Act
+ var task = connection.InvokeAsync(request, validation);
+ connection.Dispose();
+ await task;
+
+ // Assert - TaskCancelledException
+ }
+
+ [TestMethod]
+ [ExpectedException(typeof(TaskCanceledException))]
+ public async Task ShouldThrowTaskCancelledExceptionForCancelOnInvokeAsync()
+ {
+ // Arrange
+ byte[] request = [1, 2, 3];
+ var validation = new Func, bool>(_ => true);
+ using var cts = new CancellationTokenSource();
+
+ var connection = GetConnection();
+ _serialPortMock
+ .Setup(ns => ns.WriteAsync(It.IsAny(), It.IsAny()))
+ .Returns(Task.Delay(100));
+
+ // Act
+ var task = connection.InvokeAsync(request, validation, cts.Token);
+ cts.Cancel();
+ await task;
+
+ // Assert - TaskCancelledException
+ }
+
+ [TestMethod]
+ public async Task ShouldRemoveRequestFromQueueOnInvokeAsync()
+ {
+ // Arrange
+ byte[] request = [1, 2, 3];
+ byte[] expectedResponse = [9, 8, 7];
+ var validation = new Func, bool>(_ => true);
+ _serialLineResponseQueue.Enqueue(expectedResponse);
+ using var cts = new CancellationTokenSource();
+
+ var connection = GetConnection();
+ _serialPortMock
+ .Setup(ns => ns.WriteAsync(It.IsAny(), It.IsAny()))
+ .Callback((req, _) => _serialLineRequestCallbacks.Add(req.ToArray()))
+ .Returns(Task.Delay(100));
+
+ // Act
+ var taskToComplete = connection.InvokeAsync(request, validation);
+
+ var taskToCancel = connection.InvokeAsync(request, validation, cts.Token);
+ cts.Cancel();
+
+ var response = await taskToComplete;
+
+ // Assert - Part 1
+ try
+ {
+ await taskToCancel;
+ Assert.Fail();
+ }
+ catch (TaskCanceledException)
+ { /* expected exception */ }
+
+ // Assert - Part 2
+ Assert.AreEqual(1, _serialLineRequestCallbacks.Count);
+ CollectionAssert.AreEqual(request, _serialLineRequestCallbacks.First());
+ CollectionAssert.AreEqual(expectedResponse, response.ToArray());
+
+ _serialPortMock.Verify(c => c.IsOpen, Times.Once);
+
+ _serialPortMock.Verify(ns => ns.WriteAsync(It.IsAny(), It.IsAny()), Times.Once);
+ _serialPortMock.Verify(ns => ns.ReadAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Once);
+
+ _serialPortMock.VerifyNoOtherCalls();
+ }
+
+ [TestMethod]
+ public async Task ShouldRemoveRequestFromQueueOnDispose()
+ {
+ // Arrange
+ byte[] request = [1, 2, 3];
+ var validation = new Func, bool>(_ => true);
+
+ var connection = GetConnection();
+ _serialPortMock
+ .Setup(ns => ns.WriteAsync(It.IsAny(), It.IsAny()))
+ .Callback((req, _) => _serialLineRequestCallbacks.Add(req.ToArray()))
+ .Returns(Task.Delay(100));
+
+ // Act
+ var taskToCancel = connection.InvokeAsync(request, validation);
+ var taskToDequeue = connection.InvokeAsync(request, validation);
+ connection.Dispose();
+
+ // Assert
+ try
+ {
+ await taskToCancel;
+ Assert.Fail();
+ }
+ catch (TaskCanceledException)
+ { /* expected exception */ }
+
+ try
+ {
+ await taskToDequeue;
+ Assert.Fail();
+ }
+ catch (ObjectDisposedException)
+ { /* expected exception */ }
+
+ Assert.AreEqual(1, _serialLineRequestCallbacks.Count);
+ CollectionAssert.AreEqual(request, _serialLineRequestCallbacks.First());
+
+ _serialPortMock.Verify(c => c.IsOpen, Times.Once);
+ _serialPortMock.Verify(c => c.Dispose(), Times.Once);
+
+ _serialPortMock.Verify(ns => ns.WriteAsync(It.IsAny(), It.IsAny()), Times.Once);
+
+ _serialPortMock.VerifyNoOtherCalls();
+ }
+
+ private IModbusConnection GetConnection()
+ => GetSerialConnection();
+
+ private ModbusSerialConnection GetSerialConnection()
+ {
+ _serialPortMock = new Mock();
+ _serialPortMock.Setup(p => p.IsOpen).Returns(() => _alwaysOpen || _isOpenQueue.Dequeue());
+ _serialPortMock.Setup(p => p.ReadTimeout).Returns(() => _serialPortReadTimeout);
+ _serialPortMock.Setup(p => p.WriteTimeout).Returns(() => _serialPortWriteTimeout);
+
+ _serialPortMock
+ .Setup(p => p.WriteAsync(It.IsAny(), It.IsAny()))
+ .Callback((req, _) => _serialLineRequestCallbacks.Add(req))
+ .Returns(Task.CompletedTask);
+ _serialPortMock
+ .Setup(p => p.ReadAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()))
+ .Returns((buffer, offset, count, _) =>
+ {
+ if (_serialLineResponseQueue.TryDequeue(out byte[] bytes))
+ {
+ int len = bytes.Length < count ? bytes.Length : count;
+ Array.Copy(bytes, 0, buffer, offset, len);
+ return Task.FromResult(len);
+ }
+
+ return Task.FromResult(0);
+ });
+
+ var connection = new ModbusSerialConnection();
+
+ // Replace real connection with mock
+ var connectionField = connection.GetType().GetField("_serialPort", BindingFlags.NonPublic | BindingFlags.Instance);
+ (connectionField.GetValue(connection) as SerialPortWrapper)?.Dispose();
+ connectionField.SetValue(connection, _serialPortMock.Object);
+
+ // Set unit test mode
+ connection.GetType().GetField("_isUnitTest", BindingFlags.NonPublic | BindingFlags.Instance).SetValue(connection, true);
+
+ return connection;
+ }
+ }
+}
diff --git a/README.md b/README.md
index ea228b2..e203a0d 100644
--- a/README.md
+++ b/README.md
@@ -35,7 +35,8 @@ It uses a specific TCP connection implementation and plugs all things from the C
---
Published under [MIT License] (see [**tl;dr**Legal])
-[](https://codeium.com/profile/andreasmueller)
+[](https://link.am-wd.de/donate)
+[](https://link.am-wd.de/codeium)