diff --git a/AMWD.Protocols.Modbus.Common/Protocols/RtuOverTcpProtocol.cs b/AMWD.Protocols.Modbus.Common/Protocols/RtuOverTcpProtocol.cs new file mode 100644 index 0000000..00dd00b --- /dev/null +++ b/AMWD.Protocols.Modbus.Common/Protocols/RtuOverTcpProtocol.cs @@ -0,0 +1,764 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using AMWD.Protocols.Modbus.Common.Contracts; + +namespace AMWD.Protocols.Modbus.Common.Protocols +{ + /// + /// Implementation of the Modbus RTU over TCP protocol. + /// + /// + /// The Modbus RTU over Modbus TCP is rarely used. + /// It is a non-standard variant of Modbus TCP that includes the Modbus RTU CRC at the end of the message. + /// + public class RtuOverTcpProtocol : IModbusProtocol + { + #region Fields + + private readonly object _lock = new(); + private ushort _transactionId = 0x0000; + + #endregion Fields + + #region Constants + + /// + /// The minimum allowed unit id specified by the Modbus SerialLine protocol. + /// + /// + /// INFORMATION: + ///
+ /// Reading the specification, the minimum allowed unit ID would be 1. + ///
+ /// As of other implementations seen, this limit is not enforced! + ///
+ public const byte MIN_UNIT_ID = 0x01; + + /// + /// The maximum allowed unit id specified by the Modbus SerialLine protocol. + /// + /// + /// Reading the specification, the max allowed unit id would be 247! + /// + public const byte MAX_UNIT_ID = 0xFF; + + /// + /// The minimum allowed read count specified by the Modbus SerialLine protocol. + /// + public const ushort MIN_READ_COUNT = 0x01; + + /// + /// The minimum allowed write count specified by the Modbus SerialLine protocol. + /// + public const ushort MIN_WRITE_COUNT = 0x01; + + /// + /// The maximum allowed read count for discrete values specified by the Modbus SerialLine protocol. + /// + public const ushort MAX_DISCRETE_READ_COUNT = 0x07D0; // 2000 + + /// + /// The maximum allowed write count for discrete values specified by the Modbus SerialLine protocol. + /// + public const ushort MAX_DISCRETE_WRITE_COUNT = 0x07B0; // 1968 + + /// + /// The maximum allowed read count for registers specified by the Modbus SerialLine protocol. + /// + public const ushort MAX_REGISTER_READ_COUNT = 0x007D; // 125 + + /// + /// The maximum allowed write count for registers specified by the Modbus SerialLine protocol. + /// + public const ushort MAX_REGISTER_WRITE_COUNT = 0x007B; // 123 + + /// + /// The maximum allowed ADU length in bytes. + /// + /// + /// A Modbus frame consists of a PDU (protcol data unit) and additional protocol addressing / error checks. + /// The whole data frame is called ADU (application data unit). + /// + public const int MAX_ADU_LENGTH = 262; // bytes + + #endregion Constants + + /// + public string Name => "RTU over TCP"; + + /// + /// Gets or sets a value indicating whether to disable the transaction id usage. + /// + public bool DisableTransactionId { get; set; } + + #region Read + + /// + public IReadOnlyList SerializeReadCoils(byte unitId, ushort startAddress, ushort count) + { + if (count < MIN_READ_COUNT || MAX_DISCRETE_READ_COUNT < count) + throw new ArgumentOutOfRangeException(nameof(count)); + + if (ushort.MaxValue < (startAddress + count - 1)) + throw new ArgumentOutOfRangeException(nameof(count), $"Combination of {nameof(startAddress)} and {nameof(count)} exceeds the addressation limit of {ushort.MaxValue}"); + + byte[] request = new byte[14]; + + byte[] header = GetHeader(8); + Array.Copy(header, 0, request, 0, header.Length); + + // Unit Id + request[6] = unitId; + + // Function code + request[7] = (byte)ModbusFunctionCode.ReadCoils; + + // Starting address + byte[] addrBytes = startAddress.ToBigEndianBytes(); + request[8] = addrBytes[0]; + request[9] = addrBytes[1]; + + // Quantity + byte[] countBytes = count.ToBigEndianBytes(); + request[10] = countBytes[0]; + request[11] = countBytes[1]; + + // CRC + byte[] crc = RtuProtocol.CRC16(request, 6, 6); + request[12] = crc[0]; + request[13] = crc[1]; + + return request; + } + + /// + public IReadOnlyList DeserializeReadCoils(IReadOnlyList response) + { + int baseOffset = 9; + if (response[8] != response.Count - baseOffset - 2) // -2 for CRC + throw new ModbusException("Coil byte count does not match."); + + int count = response[8] * 8; + var coils = new List(); + for (int i = 0; i < count; i++) + { + int bytePosition = i / 8; + int bitPosition = i % 8; + + int value = response[baseOffset + bytePosition] & (1 << bitPosition); + coils.Add(new Coil + { + Address = (ushort)i, + Value = value > 0 + }); + } + + return coils; + } + + /// + public IReadOnlyList SerializeReadDiscreteInputs(byte unitId, ushort startAddress, ushort count) + { + if (count < MIN_READ_COUNT || MAX_DISCRETE_READ_COUNT < count) + throw new ArgumentOutOfRangeException(nameof(count)); + + if (ushort.MaxValue < (startAddress + count - 1)) + throw new ArgumentOutOfRangeException(nameof(count), $"Combination of {nameof(startAddress)} and {nameof(count)} exceeds the addressation limit of {ushort.MaxValue}"); + + byte[] request = new byte[14]; + + byte[] header = GetHeader(8); + Array.Copy(header, 0, request, 0, header.Length); + + // Unit Id + request[6] = unitId; + + // Function code + request[7] = (byte)ModbusFunctionCode.ReadDiscreteInputs; + + // Starting address + byte[] addrBytes = startAddress.ToBigEndianBytes(); + request[8] = addrBytes[0]; + request[9] = addrBytes[1]; + + // Quantity + byte[] countBytes = count.ToBigEndianBytes(); + request[10] = countBytes[0]; + request[11] = countBytes[1]; + + // CRC + byte[] crc = RtuProtocol.CRC16(request, 6, 6); + request[12] = crc[0]; + request[13] = crc[1]; + + return request; + } + + /// + public IReadOnlyList DeserializeReadDiscreteInputs(IReadOnlyList response) + { + int baseOffset = 9; + if (response[8] != response.Count - baseOffset - 2) // -2 for CRC + throw new ModbusException("Discrete input byte count does not match."); + + int count = response[8] * 8; + var discreteInputs = new List(); + for (int i = 0; i < count; i++) + { + int bytePosition = i / 8; + int bitPosition = i % 8; + + int value = response[baseOffset + bytePosition] & (1 << bitPosition); + discreteInputs.Add(new DiscreteInput + { + Address = (ushort)i, + HighByte = (byte)(value > 0 ? 0xFF : 0x00) + }); + } + + return discreteInputs; + } + + /// + public IReadOnlyList SerializeReadHoldingRegisters(byte unitId, ushort startAddress, ushort count) + { + if (count < MIN_READ_COUNT || MAX_REGISTER_READ_COUNT < count) + throw new ArgumentOutOfRangeException(nameof(count)); + + if (ushort.MaxValue < (startAddress + count - 1)) + throw new ArgumentOutOfRangeException(nameof(count), $"Combination of {nameof(startAddress)} and {nameof(count)} exceeds the addressation limit of {ushort.MaxValue}"); + + byte[] request = new byte[14]; + + byte[] header = GetHeader(8); + Array.Copy(header, 0, request, 0, header.Length); + + // Unit Id + request[6] = unitId; + + // Function code + request[7] = (byte)ModbusFunctionCode.ReadHoldingRegisters; + + // Starting address + byte[] addrBytes = startAddress.ToBigEndianBytes(); + request[8] = addrBytes[0]; + request[9] = addrBytes[1]; + + // Quantity + byte[] countBytes = count.ToBigEndianBytes(); + request[10] = countBytes[0]; + request[11] = countBytes[1]; + + // CRC + byte[] crc = RtuProtocol.CRC16(request, 6, 6); + request[12] = crc[0]; + request[13] = crc[1]; + + return request; + } + + /// + public IReadOnlyList DeserializeReadHoldingRegisters(IReadOnlyList response) + { + int baseOffset = 9; + if (response[8] != response.Count - baseOffset - 2) + throw new ModbusException("Holding register byte count does not match."); + + int count = response[8] / 2; + var holdingRegisters = new List(); + for (int i = 0; i < count; i++) + { + holdingRegisters.Add(new HoldingRegister + { + Address = (ushort)i, + HighByte = response[baseOffset + i * 2], + LowByte = response[baseOffset + i * 2 + 1] + }); + } + + return holdingRegisters; + } + + /// + public IReadOnlyList SerializeReadInputRegisters(byte unitId, ushort startAddress, ushort count) + { + if (count < MIN_READ_COUNT || MAX_REGISTER_READ_COUNT < count) + throw new ArgumentOutOfRangeException(nameof(count)); + + if (ushort.MaxValue < (startAddress + count - 1)) + throw new ArgumentOutOfRangeException(nameof(count), $"Combination of {nameof(startAddress)} and {nameof(count)} exceeds the addressation limit of {ushort.MaxValue}"); + + byte[] request = new byte[14]; + + byte[] header = GetHeader(8); + Array.Copy(header, 0, request, 0, header.Length); + + // Unit Id + request[6] = unitId; + + // Function code + request[7] = (byte)ModbusFunctionCode.ReadInputRegisters; + + // Starting address + byte[] addrBytes = startAddress.ToBigEndianBytes(); + request[8] = addrBytes[0]; + request[9] = addrBytes[1]; + + // Quantity + byte[] countBytes = count.ToBigEndianBytes(); + request[10] = countBytes[0]; + request[11] = countBytes[1]; + + // CRC + byte[] crc = RtuProtocol.CRC16(request, 6, 6); + request[12] = crc[0]; + request[13] = crc[1]; + + return request; + } + + /// + public IReadOnlyList DeserializeReadInputRegisters(IReadOnlyList response) + { + int baseOffset = 9; + if (response[8] != response.Count - baseOffset - 2) + throw new ModbusException("Input register byte count does not match."); + + int count = response[8] / 2; + var inputRegisters = new List(); + for (int i = 0; i < count; i++) + { + inputRegisters.Add(new InputRegister + { + Address = (ushort)i, + HighByte = response[baseOffset + i * 2], + LowByte = response[baseOffset + i * 2 + 1] + }); + } + + return inputRegisters; + } + + /// + public IReadOnlyList SerializeReadDeviceIdentification(byte unitId, ModbusDeviceIdentificationCategory category, ModbusDeviceIdentificationObject objectId) + { + if (!Enum.IsDefined(typeof(ModbusDeviceIdentificationCategory), category)) + throw new ArgumentOutOfRangeException(nameof(category)); + + byte[] request = new byte[13]; + + byte[] header = GetHeader(7); + Array.Copy(header, 0, request, 0, header.Length); + + // Unit Id + request[6] = unitId; + + // Function code + request[7] = (byte)ModbusFunctionCode.EncapsulatedInterface; + + // Modbus Encapsulated Interface: Read Device Identification (MEI Type) + request[8] = 0x0E; + + // The category type (basic, regular, extended, individual) + request[9] = (byte)category; + request[10] = (byte)objectId; + + // CRC + byte[] crc = RtuProtocol.CRC16(request, 6, 5); + request[11] = crc[0]; + request[12] = crc[1]; + + return request; + } + + /// + public DeviceIdentificationRaw DeserializeReadDeviceIdentification(IReadOnlyList response) + { + if (response[8] != 0x0E) + throw new ModbusException("The MEI type does not match"); + + if (!Enum.IsDefined(typeof(ModbusDeviceIdentificationCategory), response[9])) + throw new ModbusException("The category type does not match"); + + var deviceIdentification = new DeviceIdentificationRaw + { + AllowsIndividualAccess = (response[10] & 0x80) == 0x80, + MoreRequestsNeeded = response[11] == 0xFF, + NextObjectIdToRequest = response[12], + }; + + int baseOffset = 14; + while (baseOffset < response.Count - 2) // -2 for CRC + { + byte objectId = response[baseOffset]; + byte length = response[baseOffset + 1]; + + byte[] data = response.Skip(baseOffset + 2).Take(length).ToArray(); + + deviceIdentification.Objects.Add(objectId, data); + baseOffset += 2 + length; + } + + return deviceIdentification; + } + + #endregion Read + + #region Write + + /// + public IReadOnlyList SerializeWriteSingleCoil(byte unitId, Coil coil) + { +#if NET8_0_OR_GREATER + ArgumentNullException.ThrowIfNull(coil); +#else + if (coil == null) + throw new ArgumentNullException(nameof(coil)); +#endif + + byte[] request = new byte[14]; + + byte[] header = GetHeader(8); + Array.Copy(header, 0, request, 0, header.Length); + + // Unit ID + request[6] = unitId; + + // Function code + request[7] = (byte)ModbusFunctionCode.WriteSingleCoil; + + byte[] addrBytes = coil.Address.ToBigEndianBytes(); + request[8] = addrBytes[0]; + request[9] = addrBytes[1]; + + request[10] = coil.HighByte; + request[11] = coil.LowByte; + + // CRC + byte[] crc = RtuProtocol.CRC16(request, 6, 6); + request[12] = crc[0]; + request[13] = crc[1]; + + return request; + } + + /// + public Coil DeserializeWriteSingleCoil(IReadOnlyList response) + { + return new Coil + { + Address = response.ToArray().GetBigEndianUInt16(8), + HighByte = response[10], + LowByte = response[11] + }; + } + + /// + public IReadOnlyList SerializeWriteSingleHoldingRegister(byte unitId, HoldingRegister register) + { +#if NET8_0_OR_GREATER + ArgumentNullException.ThrowIfNull(register); +#else + if (register == null) + throw new ArgumentNullException(nameof(register)); +#endif + + byte[] request = new byte[14]; + + byte[] header = GetHeader(8); + Array.Copy(header, 0, request, 0, header.Length); + + // Unit Id + request[6] = unitId; + + // Function code + request[7] = (byte)ModbusFunctionCode.WriteSingleRegister; + + byte[] addrBytes = register.Address.ToBigEndianBytes(); + request[8] = addrBytes[0]; + request[9] = addrBytes[1]; + + request[10] = register.HighByte; + request[11] = register.LowByte; + + // CRC + byte[] crc = RtuProtocol.CRC16(request, 6, 6); + request[12] = crc[0]; + request[13] = crc[1]; + + return request; + } + + /// + public HoldingRegister DeserializeWriteSingleHoldingRegister(IReadOnlyList response) + { + return new HoldingRegister + { + Address = response.ToArray().GetBigEndianUInt16(8), + HighByte = response[10], + LowByte = response[11] + }; + } + + /// + public IReadOnlyList SerializeWriteMultipleCoils(byte unitId, IReadOnlyList coils) + { +#if NET8_0_OR_GREATER + ArgumentNullException.ThrowIfNull(coils); +#else + if (coils == null) + throw new ArgumentNullException(nameof(coils)); +#endif + + var orderedList = coils.OrderBy(c => c.Address).ToList(); + if (orderedList.Count < MIN_WRITE_COUNT || MAX_DISCRETE_WRITE_COUNT < orderedList.Count) + throw new ArgumentOutOfRangeException(nameof(coils), $"At least {MIN_WRITE_COUNT} or max. {MAX_DISCRETE_WRITE_COUNT} coils can be written at once."); + + int addrCount = coils.Select(c => c.Address).Distinct().Count(); + if (orderedList.Count != addrCount) + throw new ArgumentException("One or more duplicate coils found.", nameof(coils)); + + ushort firstAddress = orderedList.First().Address; + ushort lastAddress = orderedList.Last().Address; + + if (firstAddress + orderedList.Count - 1 != lastAddress) + throw new ArgumentException("Gap in coil list found.", nameof(coils)); + + byte byteCount = (byte)Math.Ceiling(orderedList.Count / 8.0); + byte[] request = new byte[15 + byteCount]; + + byte[] header = GetHeader(byteCount + 9); + Array.Copy(header, 0, request, 0, header.Length); + + // Unit id + request[6] = unitId; + + // Function code + request[7] = (byte)ModbusFunctionCode.WriteMultipleCoils; + + // Starting address + byte[] addrBytes = firstAddress.ToBigEndianBytes(); + request[8] = addrBytes[0]; + request[9] = addrBytes[1]; + + // Quantity + byte[] countBytes = ((ushort)orderedList.Count).ToBigEndianBytes(); + request[10] = countBytes[0]; + request[11] = countBytes[1]; + + // Byte count + request[12] = byteCount; + + // Coils + int baseOffset = 13; + for (int i = 0; i < orderedList.Count; i++) + { + int bytePosition = i / 8; + int bitPosition = i % 8; + + if (orderedList[i].Value) + { + byte bitMask = (byte)(1 << bitPosition); + request[baseOffset + bytePosition] |= bitMask; + } + } + + // CRC + byte[] crc = RtuProtocol.CRC16(request, 6, request.Length - 8); + request[request.Length - 2] = crc[0]; + request[request.Length - 1] = crc[1]; + + return request; + } + + /// + public (ushort FirstAddress, ushort NumberOfCoils) DeserializeWriteMultipleCoils(IReadOnlyList response) + { + ushort firstAddress = response.ToArray().GetBigEndianUInt16(8); + ushort numberOfCoils = response.ToArray().GetBigEndianUInt16(10); + + return (firstAddress, numberOfCoils); + } + + /// + public IReadOnlyList SerializeWriteMultipleHoldingRegisters(byte unitId, IReadOnlyList registers) + { +#if NET8_0_OR_GREATER + ArgumentNullException.ThrowIfNull(registers); +#else + if (registers == null) + throw new ArgumentNullException(nameof(registers)); +#endif + + var orderedList = registers.OrderBy(c => c.Address).ToList(); + if (orderedList.Count < MIN_WRITE_COUNT || MAX_REGISTER_WRITE_COUNT < orderedList.Count) + throw new ArgumentOutOfRangeException(nameof(registers), $"At least {MIN_WRITE_COUNT} or max. {MAX_REGISTER_WRITE_COUNT} holding registers can be written at once."); + + int addrCount = registers.Select(c => c.Address).Distinct().Count(); + if (orderedList.Count != addrCount) + throw new ArgumentException("One or more duplicate holding registers found.", nameof(registers)); + + ushort firstAddress = orderedList.First().Address; + ushort lastAddress = orderedList.Last().Address; + + if (firstAddress + orderedList.Count - 1 != lastAddress) + throw new ArgumentException("Gap in holding register list found.", nameof(registers)); + + byte byteCount = (byte)(orderedList.Count * 2); + byte[] request = new byte[15 + byteCount]; + + byte[] header = GetHeader(byteCount + 9); + Array.Copy(header, 0, request, 0, header.Length); + + // Unit id + request[6] = unitId; + + // Function code + request[7] = (byte)ModbusFunctionCode.WriteMultipleRegisters; + + // Starting address + byte[] addrBytes = firstAddress.ToBigEndianBytes(); + request[8] = addrBytes[0]; + request[9] = addrBytes[1]; + + // Quantity + byte[] countBytes = ((ushort)orderedList.Count).ToBigEndianBytes(); + request[10] = countBytes[0]; + request[11] = countBytes[1]; + + // Byte count + request[12] = byteCount; + + // Registers + int baseOffset = 13; + for (int i = 0; i < orderedList.Count; i++) + { + request[baseOffset + 2 * i] = orderedList[i].HighByte; + request[baseOffset + 2 * i + 1] = orderedList[i].LowByte; + } + + // CRC + byte[] crc = RtuProtocol.CRC16(request, 6, request.Length - 8); + request[request.Length - 2] = crc[0]; + request[request.Length - 1] = crc[1]; + + return request; + } + + /// + public (ushort FirstAddress, ushort NumberOfRegisters) DeserializeWriteMultipleHoldingRegisters(IReadOnlyList response) + { + ushort firstAddress = response.ToArray().GetBigEndianUInt16(8); + ushort numberOfRegisters = response.ToArray().GetBigEndianUInt16(10); + + return (firstAddress, numberOfRegisters); + } + + #endregion Write + + #region Validation + + /// + public bool CheckResponseComplete(IReadOnlyList responseBytes) + { + // 2x Transaction Id + // 2x Protocol Identifier + // 2x Number of following bytes + if (responseBytes.Count < 6) + return false; + + ushort followingBytes = responseBytes.ToArray().GetBigEndianUInt16(4); + if (responseBytes.Count < followingBytes + 6) + return false; + + return true; + } + + /// + public void ValidateResponse(IReadOnlyList request, IReadOnlyList response) + { + if (!DisableTransactionId) + { + if (request[0] != response[0] || request[1] != response[1]) + throw new ModbusException("Transaction Id does not match."); + } + + if (request[2] != response[2] || request[3] != response[3]) + throw new ModbusException("Protocol Identifier does not match."); + + ushort count = response.ToArray().GetBigEndianUInt16(4); + if (count != response.Count - 6) + throw new ModbusException("Number of following bytes does not match."); + + byte[] calculatedCrc16 = RtuProtocol.CRC16(response, 6, response.Count - 8); + byte[] receivedCrc16 = [response[response.Count - 2], response[response.Count - 1]]; + + if (calculatedCrc16[0] != receivedCrc16[0] || calculatedCrc16[1] != receivedCrc16[1]) + throw new ModbusException("CRC16 check failed."); + + if (request[6] != response[6]) + throw new ModbusException("Unit Identifier does not match."); + + byte fnCode = response[7]; + bool isError = (fnCode & 0x80) == 0x80; + if (isError) + fnCode = (byte)(fnCode ^ 0x80); // === fnCode & 0x7F + + if (request[7] != fnCode) + throw new ModbusException("Function code does not match."); + + if (isError) + throw new ModbusException("Remote Error") { ErrorCode = (ModbusErrorCode)response[8] }; + } + + #endregion Validation + + #region Private Helpers + + private ushort GetNextTransacitonId() + { + if (DisableTransactionId) + return 0x0000; + + lock (_lock) + { + if (_transactionId == ushort.MaxValue) + _transactionId = 0x0000; + else + _transactionId++; + + return _transactionId; + } + } + + /// + /// Generates the header for a Modbus request. + /// + /// The number of following bytes. + /// The header ready to copy to the request bytes (6 bytes). + private byte[] GetHeader(int followingBytes) + { + byte[] header = new byte[6]; + + // Transaction id + ushort txId = GetNextTransacitonId(); + byte[] txBytes = txId.ToBigEndianBytes(); + header[0] = txBytes[0]; + header[1] = txBytes[1]; + + // Protocol identifier + header[2] = 0x00; + header[3] = 0x00; + + // Number of following bytes + byte[] countBytes = ((ushort)followingBytes).ToBigEndianBytes(); + header[4] = countBytes[0]; + header[5] = countBytes[1]; + + return header; + } + + #endregion Private Helpers + } +} diff --git a/AMWD.Protocols.Modbus.Common/README.md b/AMWD.Protocols.Modbus.Common/README.md index 35c7aff..1f7679f 100644 --- a/AMWD.Protocols.Modbus.Common/README.md +++ b/AMWD.Protocols.Modbus.Common/README.md @@ -58,7 +58,7 @@ Here you have the specific default implementations for the Modbus Protocol. - ASCII - RTU -- RTU over TCP _(in progress)_ +- RTU over TCP - TCP **NOTE:** diff --git a/AMWD.Protocols.Modbus.Tests/Common/Protocols/RtuOverTcpProtocolTest.cs b/AMWD.Protocols.Modbus.Tests/Common/Protocols/RtuOverTcpProtocolTest.cs new file mode 100644 index 0000000..debda2f --- /dev/null +++ b/AMWD.Protocols.Modbus.Tests/Common/Protocols/RtuOverTcpProtocolTest.cs @@ -0,0 +1,1259 @@ +using System.Collections.Generic; +using System.Reflection; +using AMWD.Protocols.Modbus.Common.Protocols; + +namespace AMWD.Protocols.Modbus.Tests.Common.Protocols +{ + [TestClass] + public class RtuOverTcpProtocolTest + { + private const byte UNIT_ID = 0x2A; // 42 + + #region Read Coils + + [TestMethod] + public void ShouldSerializeReadCoils() + { + // Arrange + var protocol = new RtuOverTcpProtocol(); + + // Act + var bytes = protocol.SerializeReadCoils(UNIT_ID, 19, 19); + + // Assert + Assert.IsNotNull(bytes); + Assert.AreEqual(14, bytes.Count); + + // Transaction id + Assert.AreEqual(0x00, bytes[0]); + Assert.AreEqual(0x01, bytes[1]); + + // Protocol identifier + Assert.AreEqual(0x00, bytes[2]); + Assert.AreEqual(0x00, bytes[3]); + + // Following bytes + Assert.AreEqual(0x00, bytes[4]); + Assert.AreEqual(0x08, bytes[5]); + + // Unit id + Assert.AreEqual(UNIT_ID, bytes[6]); + + // Function code + Assert.AreEqual(0x01, bytes[7]); + + // Starting address + Assert.AreEqual(0x00, bytes[8]); + Assert.AreEqual(0x13, bytes[9]); + // Quantity + Assert.AreEqual(0x00, bytes[10]); + Assert.AreEqual(0x13, bytes[11]); + + // CRC check will be ignored + } + + [DataTestMethod] + [DataRow(0)] + [DataRow(2001)] + [ExpectedException(typeof(ArgumentOutOfRangeException))] + public void ShouldThrowOutOfRangeForCountOnSerializeReadCoils(int count) + { + // Arrange + var protocol = new RtuOverTcpProtocol(); + + // Act + protocol.SerializeReadCoils(UNIT_ID, 19, (ushort)count); + + // Assert - ArgumentOutOfRangeException + } + + [TestMethod] + [ExpectedException(typeof(ArgumentOutOfRangeException))] + public void ShouldThrowOutOfRangeForStartingAddressOnSerializeReadCoils() + { + // Arrange + var protocol = new RtuOverTcpProtocol(); + + // Act + protocol.SerializeReadCoils(UNIT_ID, ushort.MaxValue, 2); + + // Assert - ArgumentOutOfRangeException + } + + [TestMethod] + public void ShouldDeserializeReadCoils() + { + // Arrange + int[] setValues = [0, 2, 3, 6, 7, 8, 9, 11, 13, 14, 16, 18]; + var protocol = new RtuOverTcpProtocol(); + + // Act + var coils = protocol.DeserializeReadCoils([0x00, 0x01, 0x00, 0x00, 0x00, 0x08, UNIT_ID, 0x01, 0x03, 0xCD, 0x6B, 0x05, 0x00, 0x00]); + + // Assert + Assert.IsNotNull(coils); + Assert.AreEqual(24, coils.Count); + + for (int i = 0; i < 24; i++) + { + Assert.AreEqual(i, coils[i].Address); + + if (setValues.Contains(i)) + Assert.IsTrue(coils[i].Value); + else + Assert.IsFalse(coils[i].Value); + } + } + + [TestMethod] + [ExpectedException(typeof(ModbusException))] + public void ShouldThrowExceptionOnDeserializeReadCoils() + { + // Arrange + var protocol = new RtuOverTcpProtocol(); + + // Act + protocol.DeserializeReadCoils([0x00, 0x01, 0x00, 0x00, 0x00, 0x08, UNIT_ID, 0x01, 0x02, 0xCD, 0x6B, 0x05, 0x00, 0x00]); + + // Assert - ModbusException + } + + #endregion Read Coils + + #region Read Discrete Inputs + + [TestMethod] + public void ShouldSerializeReadDiscreteInputs() + { + // Arrange + var protocol = new RtuOverTcpProtocol(); + + // Act + var bytes = protocol.SerializeReadDiscreteInputs(UNIT_ID, 19, 19); + + // Assert + Assert.IsNotNull(bytes); + Assert.AreEqual(14, bytes.Count); + + // Transaction id + Assert.AreEqual(0x00, bytes[0]); + Assert.AreEqual(0x01, bytes[1]); + + // Protocol identifier + Assert.AreEqual(0x00, bytes[2]); + Assert.AreEqual(0x00, bytes[3]); + + // Following bytes + Assert.AreEqual(0x00, bytes[4]); + Assert.AreEqual(0x08, bytes[5]); + + // Unit id + Assert.AreEqual(UNIT_ID, bytes[6]); + + // Function code + Assert.AreEqual(0x02, bytes[7]); + + // Starting address + Assert.AreEqual(0x00, bytes[8]); + Assert.AreEqual(0x13, bytes[9]); + // Quantity + Assert.AreEqual(0x00, bytes[10]); + Assert.AreEqual(0x13, bytes[11]); + + // CRC check will be ignored + } + + [DataTestMethod] + [DataRow(0)] + [DataRow(2001)] + [ExpectedException(typeof(ArgumentOutOfRangeException))] + public void ShouldThrowOutOfRangeForCountOnSerializeReadDiscreteInputs(int count) + { + // Arrange + var protocol = new RtuOverTcpProtocol(); + + // Act + protocol.SerializeReadDiscreteInputs(UNIT_ID, 19, (ushort)count); + + // Assert - ArgumentOutOfRangeException + } + + [TestMethod] + [ExpectedException(typeof(ArgumentOutOfRangeException))] + public void ShouldThrowOutOfRangeForStartingAddressOnSerializeReadDiscreteInputs() + { + // Arrange + var protocol = new RtuOverTcpProtocol(); + + // Act + protocol.SerializeReadDiscreteInputs(UNIT_ID, ushort.MaxValue, 2); + + // Assert - ArgumentOutOfRangeException + } + + [TestMethod] + public void ShouldDeserializeReadDiscreteInputs() + { + // Arrange + int[] setValues = [0, 2, 3, 6, 7, 8, 9, 11, 13, 14, 16, 18]; + var protocol = new RtuOverTcpProtocol(); + + // Act + var discreteInputs = protocol.DeserializeReadDiscreteInputs([0x00, 0x01, 0x00, 0x00, 0x00, 0x08, UNIT_ID, 0x02, 0x03, 0xCD, 0x6B, 0x05, 0x00, 0x00]); + + // Assert + Assert.IsNotNull(discreteInputs); + Assert.AreEqual(24, discreteInputs.Count); + + for (int i = 0; i < 24; i++) + { + Assert.AreEqual(i, discreteInputs[i].Address); + + if (setValues.Contains(i)) + Assert.IsTrue(discreteInputs[i].Value); + else + Assert.IsFalse(discreteInputs[i].Value); + } + } + + [TestMethod] + [ExpectedException(typeof(ModbusException))] + public void ShouldThrowExceptionOnDeserializeReadDiscreteInputs() + { + // Arrange + var protocol = new RtuOverTcpProtocol(); + + // Act + protocol.DeserializeReadDiscreteInputs([0x00, 0x01, 0x00, 0x00, 0x00, 0x08, UNIT_ID, 0x02, 0x02, 0xCD, 0x6B, 0x05, 0x00, 0x00]); + + // Assert - ModbusException + } + + #endregion Read Discrete Inputs + + #region Read Holding Registers + + [TestMethod] + public void ShouldSerializeReadHoldingRegisters() + { + // Arrange + var protocol = new RtuOverTcpProtocol(); + + // Act + var bytes = protocol.SerializeReadHoldingRegisters(UNIT_ID, 107, 2); + + // Assert + Assert.IsNotNull(bytes); + Assert.AreEqual(14, bytes.Count); + + // Transaction id + Assert.AreEqual(0x00, bytes[0]); + Assert.AreEqual(0x01, bytes[1]); + + // Protocol identifier + Assert.AreEqual(0x00, bytes[2]); + Assert.AreEqual(0x00, bytes[3]); + + // Following bytes + Assert.AreEqual(0x00, bytes[4]); + Assert.AreEqual(0x08, bytes[5]); + + // Unit id + Assert.AreEqual(UNIT_ID, bytes[6]); + + // Function code + Assert.AreEqual(0x03, bytes[7]); + + // Starting address + Assert.AreEqual(0x00, bytes[8]); + Assert.AreEqual(0x6B, bytes[9]); + // Quantity + Assert.AreEqual(0x00, bytes[10]); + Assert.AreEqual(0x02, bytes[11]); + + // CRC check will be ignored + } + + [DataTestMethod] + [DataRow(0)] + [DataRow(126)] + [ExpectedException(typeof(ArgumentOutOfRangeException))] + public void ShouldThrowOutOfRangeForCountOnSerializeReadHoldingRegisters(int count) + { + // Arrange + var protocol = new RtuOverTcpProtocol(); + + // Act + protocol.SerializeReadHoldingRegisters(UNIT_ID, 19, (ushort)count); + + // Assert - ArgumentOutOfRangeException + } + + [TestMethod] + [ExpectedException(typeof(ArgumentOutOfRangeException))] + public void ShouldThrowOutOfRangeForStartingAddressOnSerializeReadHoldingRegisters() + { + // Arrange + var protocol = new RtuOverTcpProtocol(); + + // Act + protocol.SerializeReadHoldingRegisters(UNIT_ID, ushort.MaxValue, 2); + + // Assert - ArgumentOutOfRangeException + } + + [TestMethod] + public void ShouldDeserializeReadHoldingRegisters() + { + // Arrange + var protocol = new RtuOverTcpProtocol(); + + // Act + var registers = protocol.DeserializeReadHoldingRegisters([0x00, 0x01, 0x00, 0x00, 0x00, 0x09, UNIT_ID, 0x03, 0x04, 0x02, 0x2B, 0x00, 0x64, 0x00, 0x00]); + + // Assert + Assert.IsNotNull(registers); + Assert.AreEqual(2, registers.Count); + + Assert.AreEqual(0, registers[0].Address); + Assert.AreEqual(555, registers[0].Value); + + Assert.AreEqual(1, registers[1].Address); + Assert.AreEqual(100, registers[1].Value); + } + + [TestMethod] + [ExpectedException(typeof(ModbusException))] + public void ShouldThrowExceptionOnDeserializeReadHoldingRegisters() + { + // Arrange + var protocol = new RtuOverTcpProtocol(); + + // Act + protocol.DeserializeReadHoldingRegisters([0x00, 0x01, 0x00, 0x00, 0x00, 0x07, UNIT_ID, 0x03, 0x04, 0x02, 0x2B, 0x00, 0x00]); + + // Assert - ModbusException + } + + #endregion Read Holding Registers + + #region Read Input Registers + + [TestMethod] + public void ShouldSerializeReadInputRegisters() + { + // Arrange + var protocol = new RtuOverTcpProtocol(); + + // Act + var bytes = protocol.SerializeReadInputRegisters(UNIT_ID, 107, 2); + + // Assert + Assert.IsNotNull(bytes); + Assert.AreEqual(14, bytes.Count); + + // Transaction id + Assert.AreEqual(0x00, bytes[0]); + Assert.AreEqual(0x01, bytes[1]); + + // Protocol identifier + Assert.AreEqual(0x00, bytes[2]); + Assert.AreEqual(0x00, bytes[3]); + + // Following bytes + Assert.AreEqual(0x00, bytes[4]); + Assert.AreEqual(0x08, bytes[5]); + + // Unit id + Assert.AreEqual(UNIT_ID, bytes[6]); + + // Function code + Assert.AreEqual(0x04, bytes[7]); + + // Starting address + Assert.AreEqual(0x00, bytes[8]); + Assert.AreEqual(0x6B, bytes[9]); + // Quantity + Assert.AreEqual(0x00, bytes[10]); + Assert.AreEqual(0x02, bytes[11]); + + // CRC check will be ignored + } + + [DataTestMethod] + [DataRow(0)] + [DataRow(126)] + [ExpectedException(typeof(ArgumentOutOfRangeException))] + public void ShouldThrowOutOfRangeForCountOnSerializeReadInputRegisters(int count) + { + // Arrange + var protocol = new RtuOverTcpProtocol(); + + // Act + protocol.SerializeReadInputRegisters(UNIT_ID, 19, (ushort)count); + + // Assert - ArgumentOutOfRangeException + } + + [TestMethod] + [ExpectedException(typeof(ArgumentOutOfRangeException))] + public void ShouldThrowOutOfRangeForStartingAddressOnSerializeReadInputRegisters() + { + // Arrange + var protocol = new RtuOverTcpProtocol(); + + // Act + protocol.SerializeReadInputRegisters(UNIT_ID, ushort.MaxValue, 2); + + // Assert - ArgumentOutOfRangeException + } + + [TestMethod] + public void ShouldDeserializeReadInputRegisters() + { + // Arrange + var protocol = new RtuOverTcpProtocol(); + + // Act + var registers = protocol.DeserializeReadInputRegisters([0x00, 0x01, 0x00, 0x00, 0x00, 0x09, UNIT_ID, 0x04, 0x04, 0x02, 0x2B, 0x00, 0x64, 0x00, 0x00]); + + // Assert + Assert.IsNotNull(registers); + Assert.AreEqual(2, registers.Count); + + Assert.AreEqual(0, registers[0].Address); + Assert.AreEqual(555, registers[0].Value); + + Assert.AreEqual(1, registers[1].Address); + Assert.AreEqual(100, registers[1].Value); + } + + [TestMethod] + [ExpectedException(typeof(ModbusException))] + public void ShouldThrowExceptionOnDeserializeReadInputRegisters() + { + // Arrange + var protocol = new RtuOverTcpProtocol(); + + // Act + protocol.DeserializeReadInputRegisters([0x00, 0x01, 0x00, 0x00, 0x00, 0x07, UNIT_ID, 0x04, 0x04, 0x02, 0x2B, 0x00, 0x00]); + + // Assert - ModbusException + } + + #endregion Read Input Registers + + #region Read Device Identification + + [DataTestMethod] + [DataRow(ModbusDeviceIdentificationCategory.Basic)] + [DataRow(ModbusDeviceIdentificationCategory.Regular)] + [DataRow(ModbusDeviceIdentificationCategory.Extended)] + [DataRow(ModbusDeviceIdentificationCategory.Individual)] + public void ShouldSerializeReadDeviceIdentification(ModbusDeviceIdentificationCategory category) + { + // Arrange + var protocol = new RtuOverTcpProtocol(); + + // Act + var bytes = protocol.SerializeReadDeviceIdentification(UNIT_ID, category, ModbusDeviceIdentificationObject.ProductCode); + + // Assert + Assert.IsNotNull(bytes); + Assert.AreEqual(13, bytes.Count); + + // Transaction id + Assert.AreEqual(0x00, bytes[0]); + Assert.AreEqual(0x01, bytes[1]); + + // Protocol identifier + Assert.AreEqual(0x00, bytes[2]); + Assert.AreEqual(0x00, bytes[3]); + + // Following bytes + Assert.AreEqual(0x00, bytes[4]); + Assert.AreEqual(0x07, bytes[5]); + + // Unit id + Assert.AreEqual(UNIT_ID, bytes[6]); + + // Function code + Assert.AreEqual(0x2B, bytes[7]); + + // MEI Type + Assert.AreEqual(0x0E, bytes[8]); + + // Category + Assert.AreEqual((byte)category, bytes[9]); + + // Object Id + Assert.AreEqual((byte)ModbusDeviceIdentificationObject.ProductCode, bytes[10]); + + // CRC check will be ignored + } + + [TestMethod] + [ExpectedException(typeof(ArgumentOutOfRangeException))] + public void ShouldThrowOutOfRangeExceptionForCategoryOnSerializeReadDeviceIdentification() + { + // Arrange + var protocol = new RtuOverTcpProtocol(); + + // Act + protocol.SerializeReadDeviceIdentification(UNIT_ID, (ModbusDeviceIdentificationCategory)10, ModbusDeviceIdentificationObject.ProductCode); + + // Assert - ArgumentOutOfRangeException + } + + [DataTestMethod] + [DataRow(false)] + [DataRow(true)] + public void ShouldDeserializeReadDeviceIdentification(bool moreAndIndividual) + { + // Arrange + byte[] response = [0x00, 0x01, 0x00, 0x00, 0x00, 0x0E, UNIT_ID, 0x2B, 0x0E, 0x02, (byte)(moreAndIndividual ? 0x82 : 0x02), (byte)(moreAndIndividual ? 0xFF : 0x00), (byte)(moreAndIndividual ? 0x05 : 0x00), 0x01, 0x04, 0x02, 0x41, 0x4D, 0x00, 0x00]; + var protocol = new RtuOverTcpProtocol(); + + // Act + var result = protocol.DeserializeReadDeviceIdentification(response); + + // Assert + Assert.IsNotNull(result); + Assert.AreEqual(moreAndIndividual, result.AllowsIndividualAccess); + Assert.AreEqual(moreAndIndividual, result.MoreRequestsNeeded); + Assert.AreEqual(moreAndIndividual ? 0x05 : 0x00, result.NextObjectIdToRequest); + + Assert.AreEqual(1, result.Objects.Count); + Assert.AreEqual(4, result.Objects.First().Key); + CollectionAssert.AreEqual("AM"u8.ToArray(), result.Objects.First().Value); + } + + [TestMethod] + [ExpectedException(typeof(ModbusException))] + public void ShouldThrowExceptionOnDeserializeReadDeviceIdentificationForMeiType() + { + // Arrange + byte[] response = [0x00, 0x01, 0x00, 0x00, 0x00, 0x05, UNIT_ID, 0x2B, 0x0D, 0x00, 0x00]; + var protocol = new RtuOverTcpProtocol(); + + // Act + protocol.DeserializeReadDeviceIdentification(response); + } + + [TestMethod] + [ExpectedException(typeof(ModbusException))] + public void ShouldThrowExceptionOnDeserializeReadDeviceIdentificationForCategory() + { + // Arrange + byte[] response = [0x00, 0x01, 0x00, 0x00, 0x00, 0x06, UNIT_ID, 0x2B, 0x0E, 0x08, 0x00, 0x00]; + var protocol = new RtuOverTcpProtocol(); + + // Act + protocol.DeserializeReadDeviceIdentification(response); + } + + #endregion Read Device Identification + + #region Write Single Coil + + [TestMethod] + public void ShouldSerializeWriteSingleCoil() + { + // Arrange + var coil = new Coil { Address = 109, Value = true }; + var protocol = new RtuOverTcpProtocol(); + + // Act + var result = protocol.SerializeWriteSingleCoil(UNIT_ID, coil); + + // Assert + Assert.IsNotNull(result); + Assert.AreEqual(14, result.Count); + + // Transaction id + Assert.AreEqual(0x00, result[0]); + Assert.AreEqual(0x01, result[1]); + + // Protocol identifier + Assert.AreEqual(0x00, result[2]); + Assert.AreEqual(0x00, result[3]); + + // Following bytes + Assert.AreEqual(0x00, result[4]); + Assert.AreEqual(0x08, result[5]); + + // Unit id + Assert.AreEqual(UNIT_ID, result[6]); + + // Function code + Assert.AreEqual(0x05, result[7]); + + // Starting address + Assert.AreEqual(0x00, result[8]); + Assert.AreEqual(0x6D, result[9]); + + // Value + Assert.AreEqual(0xFF, result[10]); + Assert.AreEqual(0x00, result[11]); + + // CRC check will be ignored + } + + [TestMethod] + [ExpectedException(typeof(ArgumentNullException))] + public void ShouldThrowArgumentNullOnSerializeWriteSingleCoil() + { + // Arrange + var protocol = new RtuOverTcpProtocol(); + + // Act + protocol.SerializeWriteSingleCoil(UNIT_ID, null); + + // Assert - ArgumentNullException + } + + [TestMethod] + public void ShouldDeserializeWriteSingleCoil() + { + // Arrange + byte[] bytes = [0x00, 0x01, 0x00, 0x00, 0x00, 0x08, UNIT_ID, 0x05, 0x01, 0x0A, 0xFF, 0x00, 0x00, 0x00]; + var protocol = new RtuOverTcpProtocol(); + + // Act + var coil = protocol.DeserializeWriteSingleCoil(bytes); + + // Assert + Assert.IsNotNull(coil); + Assert.AreEqual(266, coil.Address); + Assert.IsTrue(coil.Value); + } + + #endregion Write Single Coil + + #region Write Single Register + + [TestMethod] + public void ShouldSerializeWriteSingleHoldingRegister() + { + // Arrange + var register = new HoldingRegister { Address = 109, Value = 123 }; + var protocol = new RtuOverTcpProtocol(); + + // Act + var result = protocol.SerializeWriteSingleHoldingRegister(UNIT_ID, register); + + // Assert + Assert.IsNotNull(result); + Assert.AreEqual(14, result.Count); + + // Transaction id + Assert.AreEqual(0x00, result[0]); + Assert.AreEqual(0x01, result[1]); + + // Protocol identifier + Assert.AreEqual(0x00, result[2]); + Assert.AreEqual(0x00, result[3]); + + // Following bytes + Assert.AreEqual(0x00, result[4]); + Assert.AreEqual(0x08, result[5]); + + // Unit id + Assert.AreEqual(UNIT_ID, result[6]); + + // Function code + Assert.AreEqual(0x06, result[7]); + + // Starting address + Assert.AreEqual(0x00, result[8]); + Assert.AreEqual(0x6D, result[9]); + + // Value + Assert.AreEqual(0x00, result[10]); + Assert.AreEqual(0x7B, result[11]); + + // CRC check will be ignored + } + + [TestMethod] + [ExpectedException(typeof(ArgumentNullException))] + public void ShouldThrowArgumentNullOnSerializeWriteSingleHoldingRegister() + { + // Arrange + var protocol = new RtuOverTcpProtocol(); + + // Act + protocol.SerializeWriteSingleHoldingRegister(UNIT_ID, null); + + // Assert - ArgumentNullException + } + + [TestMethod] + public void ShouldDeserializeWriteSingleHoldingRegister() + { + // Arrange + byte[] bytes = [0x00, 0x01, 0x00, 0x00, 0x00, 0x06, 0x2A, 0x06, 0x02, 0x02, 0x01, 0x23, 0x00, 0x00]; + var protocol = new RtuOverTcpProtocol(); + + // Act + var register = protocol.DeserializeWriteSingleHoldingRegister(bytes); + + // Assert + Assert.IsNotNull(register); + Assert.AreEqual(514, register.Address); + Assert.AreEqual(291, register.Value); + } + + #endregion Write Single Register + + #region Write Multiple Coils + + [TestMethod] + public void ShouldSerializeWriteMultipleCoils() + { + // Arrange + var coils = new Coil[] + { + new() { Address = 10, Value = true }, + new() { Address = 11, Value = false }, + new() { Address = 12, Value = true }, + new() { Address = 13, Value = false }, + new() { Address = 14, Value = true }, + }; + var protocol = new RtuOverTcpProtocol(); + + // Act + var result = protocol.SerializeWriteMultipleCoils(UNIT_ID, coils); + + // Assert + Assert.IsNotNull(result); + Assert.AreEqual(16, result.Count); + + // Transaction id + Assert.AreEqual(0x00, result[0]); + Assert.AreEqual(0x01, result[1]); + + // Protocol identifier + Assert.AreEqual(0x00, result[2]); + Assert.AreEqual(0x00, result[3]); + + // Following bytes + Assert.AreEqual(0x00, result[4]); + Assert.AreEqual(0x0A, result[5]); + + // Unit id + Assert.AreEqual(UNIT_ID, result[6]); + + // Function code + Assert.AreEqual(0x0F, result[7]); + + // Starting address + Assert.AreEqual(0x00, result[8]); + Assert.AreEqual(0x0A, result[9]); + + // Quantity + Assert.AreEqual(0x00, result[10]); + Assert.AreEqual(0x05, result[11]); + + // Byte count + Assert.AreEqual(0x01, result[12]); + + // Values + Assert.AreEqual(0x15, result[13]); + + // CRC check will be ignored + } + + [TestMethod] + [ExpectedException(typeof(ArgumentNullException))] + public void ShouldThrowArgumentNullOnSerializeWriteMultipleCoils() + { + // Arrange + var protocol = new RtuOverTcpProtocol(); + + // Act + protocol.SerializeWriteMultipleCoils(UNIT_ID, null); + + // Assert - ArgumentNullException + } + + [DataTestMethod] + [DataRow(0)] + [DataRow(1969)] + [ExpectedException(typeof(ArgumentOutOfRangeException))] + public void ShouldThrowOutOfRangeForCountOnSerializeWriteMultipleCoils(int count) + { + // Arrange + var coils = new List(); + for (int i = 0; i < count; i++) + coils.Add(new() { Address = (ushort)i }); + + var protocol = new RtuOverTcpProtocol(); + + // Act + protocol.SerializeWriteMultipleCoils(UNIT_ID, coils); + + // Assert - ArgumentOutOfRangeException + } + + [TestMethod] + [ExpectedException(typeof(ArgumentException))] + public void ShouldThrowArgumentExceptionForDuplicateEntryOnSerializeMultipleCoils() + { + // Arrange + var coils = new Coil[] + { + new() { Address = 10, Value = true }, + new() { Address = 10, Value = false }, + }; + var protocol = new RtuOverTcpProtocol(); + + // Act + protocol.SerializeWriteMultipleCoils(UNIT_ID, coils); + + // Assert - ArgumentException + } + + [TestMethod] + [ExpectedException(typeof(ArgumentException))] + public void ShouldThrowArgumentExceptionForGapInAddressOnSerializeMultipleCoils() + { + // Arrange + var coils = new Coil[] + { + new() { Address = 10, Value = true }, + new() { Address = 12, Value = false }, + }; + var protocol = new RtuOverTcpProtocol(); + + // Act + protocol.SerializeWriteMultipleCoils(UNIT_ID, coils); + + // Assert - ArgumentException + } + + [TestMethod] + public void ShouldDeserializeWriteMultipleCoils() + { + // Arrange + byte[] bytes = [0x00, 0x01, 0x00, 0x00, 0x00, 0x06, 0x2A, 0x0F, 0x01, 0x0A, 0x00, 0x0B, 0x00, 0x00]; + var protocol = new RtuOverTcpProtocol(); + + // Act + var (firstAddress, numberOfCoils) = protocol.DeserializeWriteMultipleCoils(bytes); + + // Assert + Assert.AreEqual(266, firstAddress); + Assert.AreEqual(11, numberOfCoils); + } + + #endregion Write Multiple Coils + + #region Write Multiple Holding Registers + + [TestMethod] + public void ShouldSerializeWriteMultipleHoldingRegisters() + { + // Arrange + var registers = new HoldingRegister[] + { + new() { Address = 10, Value = 10 }, + new() { Address = 11, Value = 11 } + }; + var protocol = new RtuOverTcpProtocol(); + + // Act + var result = protocol.SerializeWriteMultipleHoldingRegisters(UNIT_ID, registers); + + // Assert + Assert.IsNotNull(result); + Assert.AreEqual(19, result.Count); + + // Transaction id + Assert.AreEqual(0x00, result[0]); + Assert.AreEqual(0x01, result[1]); + + // Protocol identifier + Assert.AreEqual(0x00, result[2]); + Assert.AreEqual(0x00, result[3]); + + // Following bytes + Assert.AreEqual(0x00, result[4]); + Assert.AreEqual(0x0D, result[5]); + + // Unit id + Assert.AreEqual(UNIT_ID, result[6]); + + // Function code + Assert.AreEqual(0x10, result[7]); + + // Starting address + Assert.AreEqual(0x00, result[8]); + Assert.AreEqual(0x0A, result[9]); + + // Quantity + Assert.AreEqual(0x00, result[10]); + Assert.AreEqual(0x02, result[11]); + + // Byte count + Assert.AreEqual(0x04, result[12]); + + // Values + Assert.AreEqual(0x00, result[13]); + Assert.AreEqual(0x0A, result[14]); + Assert.AreEqual(0x00, result[15]); + Assert.AreEqual(0x0B, result[16]); + + // CRC check will be ignored + } + + [TestMethod] + [ExpectedException(typeof(ArgumentNullException))] + public void ShouldThrowArgumentNullOnSerializeWriteMultipleHoldingRegisters() + { + // Arrange + var protocol = new RtuOverTcpProtocol(); + + // Act + protocol.SerializeWriteMultipleHoldingRegisters(UNIT_ID, null); + + // Assert - ArgumentNullException + } + + [DataTestMethod] + [DataRow(0)] + [DataRow(124)] + [ExpectedException(typeof(ArgumentOutOfRangeException))] + public void ShouldThrowOutOfRangeForCountOnSerializeWriteMultipleHoldingRegisters(int count) + { + // Arrange + var registers = new List(); + for (int i = 0; i < count; i++) + registers.Add(new() { Address = (ushort)i }); + + var protocol = new RtuOverTcpProtocol(); + + // Act + protocol.SerializeWriteMultipleHoldingRegisters(UNIT_ID, registers); + + // Assert - ArgumentOutOfRangeException + } + + [TestMethod] + [ExpectedException(typeof(ArgumentException))] + public void ShouldThrowArgumentExceptionForDuplicateEntryOnSerializeMultipleHoldingRegisters() + { + // Arrange + var registers = new HoldingRegister[] + { + new() { Address = 10 }, + new() { Address = 10 }, + }; + var protocol = new RtuOverTcpProtocol(); + + // Act + protocol.SerializeWriteMultipleHoldingRegisters(UNIT_ID, registers); + + // Assert - ArgumentException + } + + [TestMethod] + [ExpectedException(typeof(ArgumentException))] + public void ShouldThrowArgumentExceptionForGapInAddressOnSerializeMultipleHoldingRegisters() + { + // Arrange + var registers = new HoldingRegister[] + { + new() { Address = 10 }, + new() { Address = 12 }, + }; + var protocol = new RtuOverTcpProtocol(); + + // Act + protocol.SerializeWriteMultipleHoldingRegisters(UNIT_ID, registers); + + // Assert - ArgumentException + } + + [TestMethod] + public void ShouldDeserializeWriteMultipleHoldingRegisters() + { + // Arrange + byte[] bytes = [0x00, 0x01, 0x00, 0x00, 0x00, 0x08, UNIT_ID, 0x10, 0x02, 0x0A, 0x00, 0x0A, 0x00, 0x00]; + var protocol = new RtuOverTcpProtocol(); + + // Act + var (firstAddress, numberOfCoils) = protocol.DeserializeWriteMultipleHoldingRegisters(bytes); + + // Assert + Assert.AreEqual(522, firstAddress); + Assert.AreEqual(10, numberOfCoils); + } + + #endregion Write Multiple Holding Registers + + #region Validation + + [TestMethod] + public void ShouldReturnFalseForMinLengthOnCheckResponseComplete() + { + // Arrange + byte[] bytes = [0x00, 0x01, 0x00]; + var protocol = new RtuOverTcpProtocol(); + + // Act + bool complete = protocol.CheckResponseComplete(bytes); + + // Assert + Assert.IsFalse(complete); + } + + [TestMethod] + public void ShouldReturnFalseForFollowingBytesOnCheckResponseComplete() + { + // Arrange + byte[] bytes = [0x00, 0x01, 0x00, 0x00, 0x00, 0x02, 0x81]; + var protocol = new RtuOverTcpProtocol(); + + // Act + bool complete = protocol.CheckResponseComplete(bytes); + + // Assert + Assert.IsFalse(complete); + } + + [TestMethod] + public void ShouldReturnTrueOnCheckResponseComplete() + { + // Arrange + byte[] bytes = [0x00, 0x01, 0x00, 0x00, 0x00, 0x02, 0x81, 0x01]; + var protocol = new RtuOverTcpProtocol(); + + // Act + bool complete = protocol.CheckResponseComplete(bytes); + + // Assert + Assert.IsTrue(complete); + } + + [TestMethod] + public void ShouldValidateResponse() + { + // Arrange + byte[] request = [0x00, 0x01, 0x00, 0x00, 0x00, 0x08, UNIT_ID, 0x01, 0x00, 0x01, 0x00, 0x02, 0x00, 0x00]; + byte[] response = [0x00, 0x01, 0x00, 0x00, 0x00, 0x06, UNIT_ID, 0x01, 0x01, 0x00, 0x00, 0x00]; + SetCrc(response); + var protocol = new RtuOverTcpProtocol(); + + // Act + protocol.ValidateResponse(request, response); + } + + [TestMethod] + public void ShouldValidateResponseIgnoringTransactionId() + { + // Arrange + byte[] request = [0x00, 0x01, 0x00, 0x00, 0x00, 0x08, UNIT_ID, 0x01, 0x00, 0x01, 0x00, 0x02, 0x00, 0x00]; + byte[] response = [0x00, 0x00, 0x00, 0x00, 0x00, 0x06, UNIT_ID, 0x01, 0x01, 0x00, 0x00, 0x00]; + SetCrc(response); + var protocol = new RtuOverTcpProtocol { DisableTransactionId = true }; + + // Act + protocol.ValidateResponse(request, response); + } + + [DataTestMethod] + [DataRow(0x00, 0x00)] + [DataRow(0x01, 0x01)] + [ExpectedException(typeof(ModbusException))] + public void ShouldThrowForTransactionIdOnValidateResponse(int hi, int lo) + { + // Arrange + byte[] request = [(byte)hi, (byte)lo, 0x00, 0x00, 0x00, 0x08, UNIT_ID, 0x01, 0x00, 0x01, 0x00, 0x02, 0x00, 0x00]; + byte[] response = [0x00, 0x01, 0x00, 0x00, 0x00, 0x06, UNIT_ID, 0x01, 0x01, 0x00, 0x00, 0x00]; + SetCrc(response); + var protocol = new RtuOverTcpProtocol(); + + // Act + protocol.ValidateResponse(request, response); + } + + [DataTestMethod] + [DataRow(0x00, 0x01)] + [DataRow(0x01, 0x00)] + [ExpectedException(typeof(ModbusException))] + public void ShouldThrowForProtocolIdOnValidateResponse(int hi, int lo) + { + // Arrange + byte[] request = [0x00, 0x01, (byte)hi, (byte)lo, 0x00, 0x08, UNIT_ID, 0x01, 0x00, 0x01, 0x00, 0x02, 0x00, 0x00]; + byte[] response = [0x00, 0x01, 0x00, 0x00, 0x00, 0x06, UNIT_ID, 0x01, 0x01, 0x00, 0x00, 0x00]; + SetCrc(response); + var protocol = new RtuOverTcpProtocol(); + + // Act + protocol.ValidateResponse(request, response); + } + + [TestMethod] + [ExpectedException(typeof(ModbusException))] + public void ShouldThrowForFollowingBytesOnValidateResponse() + { + // Arrange + byte[] request = [0x00, 0x01, 0x00, 0x00, 0x00, 0x08, UNIT_ID, 0x01, 0x00, 0x01, 0x00, 0x02, 0x00, 0x00]; + byte[] response = [0x00, 0x01, 0x00, 0x00, 0x00, 0x07, UNIT_ID, 0x01, 0x01, 0x00, 0x00, 0x00]; + SetCrc(response); + var protocol = new RtuOverTcpProtocol(); + + // Act + protocol.ValidateResponse(request, response); + } + + [TestMethod] + [ExpectedException(typeof(ModbusException))] + public void ShouldThrowForUnitIdOnValidateResponse() + { + // Arrange + byte[] request = [0x00, 0x01, 0x00, 0x00, 0x00, 0x08, UNIT_ID, 0x01, 0x00, 0x01, 0x00, 0x02, 0x00, 0x00]; + byte[] response = [0x00, 0x01, 0x00, 0x00, 0x00, 0x06, UNIT_ID + 1, 0x01, 0x01, 0x00, 0x00, 0x00]; + SetCrc(response); + var protocol = new RtuOverTcpProtocol(); + + // Act + protocol.ValidateResponse(request, response); + } + + [TestMethod] + [ExpectedException(typeof(ModbusException))] + public void ShouldThrowForFunctionCodeOnValidateResponse() + { + // Arrange + byte[] request = [0x00, 0x01, 0x00, 0x00, 0x00, 0x08, UNIT_ID, 0x01, 0x00, 0x01, 0x00, 0x02, 0x00, 0x00]; + byte[] response = [0x00, 0x01, 0x00, 0x00, 0x00, 0x06, UNIT_ID, 0x02, 0x01, 0x00, 0x00, 0x00]; + SetCrc(response); + var protocol = new RtuOverTcpProtocol(); + + // Act + protocol.ValidateResponse(request, response); + } + + [TestMethod] + [ExpectedException(typeof(ModbusException))] + public void ShouldThrowForModbusErrorOnValidateResponse() + { + // Arrange + byte[] request = [0x00, 0x01, 0x00, 0x00, 0x00, 0x08, UNIT_ID, 0x01, 0x00, 0x01, 0x00, 0x02, 0x00, 0x00]; + byte[] response = [0x00, 0x01, 0x00, 0x00, 0x00, 0x05, UNIT_ID, 0x81, 0x01, 0x00, 0x00]; + SetCrc(response); + var protocol = new RtuOverTcpProtocol(); + + // Act + protocol.ValidateResponse(request, response); + } + + [DataTestMethod] + [DataRow(0x59, 0x6C)] + [DataRow(0x58, 0x6B)] + [ExpectedException(typeof(ModbusException))] + public void ShouldThrowForCrcOnValidateResponse(int hi, int lo) + { + // Arrange + byte[] request = [0x00, 0x01, 0x00, 0x00, 0x00, 0x08, UNIT_ID, 0x01, 0x00, 0x01, 0x00, 0x02, 0x00, 0x00]; + byte[] response = [0x00, 0x01, 0x00, 0x00, 0x00, 0x06, UNIT_ID, 0x01, 0x01, 0x00, (byte)hi, (byte)lo]; + var protocol = new RtuOverTcpProtocol(); + + // Act + protocol.ValidateResponse(request, response); + } + + #endregion Validation + + #region Helper + + [TestMethod] + public void ShouldIncreaseTransactionId() + { + // Arrange + var list = new List(); + var protocol = new RtuOverTcpProtocol(); + + // Act + list.Add([.. protocol.SerializeReadCoils(UNIT_ID, 10, 10)]); + list.Add([.. protocol.SerializeReadCoils(UNIT_ID, 10, 10)]); + list.Add([.. protocol.SerializeReadCoils(UNIT_ID, 10, 10)]); + + // Assert + for (int i = 0; i < list.Count; i++) + { + Assert.AreEqual(0x00, list[i][0]); + Assert.AreEqual((byte)(i + 1), list[i][1]); + + // Other asserts already done + } + } + + [TestMethod] + public void ShouldNotIncreaseTransactionId() + { + // Arrange + var list = new List(); + var protocol = new RtuOverTcpProtocol { DisableTransactionId = true }; + + // Act + list.Add([.. protocol.SerializeReadCoils(UNIT_ID, 10, 10)]); + list.Add([.. protocol.SerializeReadCoils(UNIT_ID, 10, 10)]); + list.Add([.. protocol.SerializeReadCoils(UNIT_ID, 10, 10)]); + + // Assert + for (int i = 0; i < list.Count; i++) + { + Assert.AreEqual(0x00, list[i][0]); + Assert.AreEqual(0x00, list[i][1]); + + // Other asserts already done + } + } + + [TestMethod] + public void ShouldResetTransactionIdOnMaxValue() + { + // Arrange + var protocol = new RtuOverTcpProtocol(); + protocol.GetType() + .GetField("_transactionId", BindingFlags.NonPublic | BindingFlags.Instance) + .SetValue(protocol, (ushort)(ushort.MaxValue - 1)); + + // Act + var result1 = protocol.SerializeReadCoils(UNIT_ID, 10, 10); + var result2 = protocol.SerializeReadCoils(UNIT_ID, 10, 10); + + // Assert + Assert.AreEqual(0xFF, result1[0]); + Assert.AreEqual(0xFF, result1[1]); + + Assert.AreEqual(0x00, result2[0]); + Assert.AreEqual(0x00, result2[1]); + } + + #endregion Helper + + [TestMethod] + public void ShouldNameRtuOverTcp() + { + // Arrange + var protocol = new RtuOverTcpProtocol(); + + // Act + string result = protocol.Name; + + // Assert + Assert.AreEqual("RTU over TCP", result); + } + + private static void SetCrc(byte[] bytes) + { + byte[] crc = RtuProtocol.CRC16(bytes, 6, bytes.Length - 8); + bytes[^2] = crc[0]; + bytes[^1] = crc[1]; + } + } +}