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]) -[![built with Codeium](https://codeium.com/badges/main)](https://codeium.com/profile/andreasmueller) +[![Buy me a Coffee](https://shields.am-wd.de/badge/PayPal-Buy_me_a_Coffee-yellow?style=flat&logo=paypal)](https://link.am-wd.de/donate) +[![built with Codeium](https://codeium.com/badges/main)](https://link.am-wd.de/codeium)