using System; using System.Collections.Generic; using System.IO.Ports; using System.Linq; using System.Text; using System.Threading; using System.Threading.Tasks; using AMWD.Protocols.Modbus.Common; using AMWD.Protocols.Modbus.Common.Contracts; using AMWD.Protocols.Modbus.Common.Protocols; using AMWD.Protocols.Modbus.Serial.Utils; namespace AMWD.Protocols.Modbus.Serial { /// /// Implements a Modbus serial line RTU server proxying all requests to a Modbus client of choice. /// public class ModbusRtuProxy : IModbusProxy { #region Fields private bool _isDisposed; private readonly SerialPortWrapper _serialPort; private CancellationTokenSource _stopCts; #endregion Fields #region Constructors /// /// Initializes a new instance of the class. /// /// The used to request the remote device, that should be proxied. /// The name of the serial port to use. public ModbusRtuProxy(ModbusClientBase client, string portName) { Client = client ?? throw new ArgumentNullException(nameof(client)); if (string.IsNullOrWhiteSpace(portName)) throw new ArgumentNullException(nameof(portName)); _serialPort = new SerialPortWrapper { PortName = portName, BaudRate = (int)BaudRate.Baud19200, DataBits = 8, StopBits = StopBits.One, Parity = Parity.Even, Handshake = Handshake.None, ReadTimeout = 1000, WriteTimeout = 1000, RtsEnable = false, }; } #endregion Constructors #region Properties /// /// Gets the Modbus client used to request the remote device, that should be proxied. /// public ModbusClientBase Client { get; } #region SerialPort Properties /// public virtual string PortName { get => _serialPort.PortName; set => _serialPort.PortName = value; } /// public virtual BaudRate BaudRate { get => (BaudRate)_serialPort.BaudRate; set => _serialPort.BaudRate = (int)value; } /// /// /// From the Specs: ///
/// On it can be 7 or 8. ///
/// On it has to be 8. ///
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 . ///
/// Should be for . ///
public virtual StopBits StopBits { get => _serialPort.StopBits; set => _serialPort.StopBits = value; } /// public bool IsOpen => _serialPort.IsOpen; /// /// Gets or sets the before a time-out occurs when a read/receive operation does not finish. /// public virtual TimeSpan ReadTimeout { get => TimeSpan.FromMilliseconds(_serialPort.ReadTimeout); set => _serialPort.ReadTimeout = (int)value.TotalMilliseconds; } /// /// Gets or sets the before a time-out occurs when a write/send operation does not finish. /// public virtual TimeSpan WriteTimeout { get => TimeSpan.FromMilliseconds(_serialPort.WriteTimeout); set => _serialPort.WriteTimeout = (int)value.TotalMilliseconds; } #endregion SerialPort Properties #endregion Properties #region Control Methods /// /// Starts the proxy. /// /// A cancellation token used to propagate notification that this operation should be canceled. public Task StartAsync(CancellationToken cancellationToken = default) { Assertions(); _stopCts?.Cancel(); _serialPort.Close(); _serialPort.DataReceived -= OnDataReceived; _stopCts?.Dispose(); _stopCts = new CancellationTokenSource(); _serialPort.DataReceived += OnDataReceived; _serialPort.Open(); return Task.CompletedTask; } /// /// Stops the proxy. /// /// A cancellation token used to propagate notification that this operation should be canceled. public Task StopAsync(CancellationToken cancellationToken = default) { Assertions(); StopAsyncInternal(); return Task.CompletedTask; } private void StopAsyncInternal() { _stopCts?.Cancel(); _serialPort.Close(); _serialPort.DataReceived -= OnDataReceived; } /// /// Releases all managed and unmanaged resources used by the . /// public void Dispose() { if (_isDisposed) return; _isDisposed = true; StopAsyncInternal(); _serialPort.Dispose(); _stopCts?.Dispose(); } private void Assertions() { #if NET8_0_OR_GREATER ObjectDisposedException.ThrowIf(_isDisposed, this); #else if (_isDisposed) throw new ObjectDisposedException(GetType().FullName); #endif if (string.IsNullOrWhiteSpace(PortName)) throw new ArgumentNullException(nameof(PortName), "The serial port name cannot be empty."); } #endregion Control Methods #region Client Handling private void OnDataReceived(object _, SerialDataReceivedEventArgs __) { try { var requestBytes = new List(); do { byte[] buffer = new byte[RtuProtocol.MAX_ADU_LENGTH]; int count = _serialPort.Read(buffer, 0, buffer.Length); requestBytes.AddRange(buffer.Take(count)); _stopCts.Token.ThrowIfCancellationRequested(); } while (_serialPort.BytesToRead > 0); _stopCts.Token.ThrowIfCancellationRequested(); byte[] responseBytes = HandleRequest([.. requestBytes]); if (responseBytes == null) return; _stopCts.Token.ThrowIfCancellationRequested(); _serialPort.Write(responseBytes, 0, responseBytes.Length); } catch { /* keep it quiet */ } } #endregion Client Handling #region Request Handling private byte[] HandleRequest(byte[] requestBytes) { byte[] recvCrc = requestBytes.Skip(requestBytes.Length - 2).ToArray(); byte[] calcCrc = RtuProtocol.CRC16(requestBytes, 0, requestBytes.Length - 2); if (!recvCrc.SequenceEqual(calcCrc)) return null; switch ((ModbusFunctionCode)requestBytes[1]) { case ModbusFunctionCode.ReadCoils: return HandleReadCoilsAsync(requestBytes, _stopCts.Token).Result; case ModbusFunctionCode.ReadDiscreteInputs: return HandleReadDiscreteInputsAsync(requestBytes, _stopCts.Token).Result; case ModbusFunctionCode.ReadHoldingRegisters: return HandleReadHoldingRegistersAsync(requestBytes, _stopCts.Token).Result; case ModbusFunctionCode.ReadInputRegisters: return HandleReadInputRegistersAsync(requestBytes, _stopCts.Token).Result; case ModbusFunctionCode.WriteSingleCoil: return HandleWriteSingleCoilAsync(requestBytes, _stopCts.Token).Result; case ModbusFunctionCode.WriteSingleRegister: return HandleWriteSingleRegisterAsync(requestBytes, _stopCts.Token).Result; case ModbusFunctionCode.WriteMultipleCoils: return HandleWriteMultipleCoilsAsync(requestBytes, _stopCts.Token).Result; case ModbusFunctionCode.WriteMultipleRegisters: return HandleWriteMultipleRegistersAsync(requestBytes, _stopCts.Token).Result; case ModbusFunctionCode.EncapsulatedInterface: return HandleEncapsulatedInterfaceAsync(requestBytes, _stopCts.Token).Result; default: // unknown function { var responseBytes = new List(); responseBytes.AddRange(requestBytes.Take(2)); responseBytes.Add((byte)ModbusErrorCode.IllegalFunction); // Mark as error responseBytes[1] |= 0x80; return ReturnResponse(responseBytes); } } } private async Task HandleReadCoilsAsync(byte[] requestBytes, CancellationToken cancellationToken) { if (requestBytes.Length < 8) return null; byte unitId = requestBytes[0]; ushort firstAddress = requestBytes.GetBigEndianUInt16(2); ushort count = requestBytes.GetBigEndianUInt16(4); var responseBytes = new List(); responseBytes.AddRange(requestBytes.Take(2)); try { var coils = await Client.ReadCoilsAsync(unitId, firstAddress, count, cancellationToken).ConfigureAwait(continueOnCapturedContext: false); byte[] values = new byte[(int)Math.Ceiling(coils.Count / 8.0)]; for (int i = 0; i < coils.Count; i++) { if (coils[i].Value) { int byteIndex = i / 8; int bitIndex = i % 8; values[byteIndex] |= (byte)(1 << bitIndex); } } responseBytes.Add((byte)values.Length); responseBytes.AddRange(values); } catch { responseBytes[1] |= 0x80; responseBytes.Add((byte)ModbusErrorCode.SlaveDeviceFailure); } return ReturnResponse(responseBytes); } private async Task HandleReadDiscreteInputsAsync(byte[] requestBytes, CancellationToken cancellationToken) { if (requestBytes.Length < 8) return null; byte unitId = requestBytes[0]; ushort firstAddress = requestBytes.GetBigEndianUInt16(2); ushort count = requestBytes.GetBigEndianUInt16(4); var responseBytes = new List(); responseBytes.AddRange(requestBytes.Take(2)); try { var discreteInputs = await Client.ReadDiscreteInputsAsync(unitId, firstAddress, count, cancellationToken).ConfigureAwait(continueOnCapturedContext: false); byte[] values = new byte[(int)Math.Ceiling(discreteInputs.Count / 8.0)]; for (int i = 0; i < discreteInputs.Count; i++) { if (discreteInputs[i].Value) { int byteIndex = i / 8; int bitIndex = i % 8; values[byteIndex] |= (byte)(1 << bitIndex); } } responseBytes.Add((byte)values.Length); responseBytes.AddRange(values); } catch { responseBytes[1] |= 0x80; responseBytes.Add((byte)ModbusErrorCode.SlaveDeviceFailure); } return ReturnResponse(responseBytes); } private async Task HandleReadHoldingRegistersAsync(byte[] requestBytes, CancellationToken cancellationToken) { if (requestBytes.Length < 8) return null; byte unitId = requestBytes[0]; ushort firstAddress = requestBytes.GetBigEndianUInt16(2); ushort count = requestBytes.GetBigEndianUInt16(4); var responseBytes = new List(); responseBytes.AddRange(requestBytes.Take(2)); try { var holdingRegisters = await Client.ReadHoldingRegistersAsync(unitId, firstAddress, count, cancellationToken).ConfigureAwait(continueOnCapturedContext: false); byte[] values = new byte[holdingRegisters.Count * 2]; for (int i = 0; i < holdingRegisters.Count; i++) { values[i * 2] = holdingRegisters[i].HighByte; values[i * 2 + 1] = holdingRegisters[i].LowByte; } responseBytes.Add((byte)values.Length); responseBytes.AddRange(values); } catch { responseBytes[1] |= 0x80; responseBytes.Add((byte)ModbusErrorCode.SlaveDeviceFailure); } return ReturnResponse(responseBytes); } private async Task HandleReadInputRegistersAsync(byte[] requestBytes, CancellationToken cancellationToken) { if (requestBytes.Length < 8) return null; byte unitId = requestBytes[0]; ushort firstAddress = requestBytes.GetBigEndianUInt16(2); ushort count = requestBytes.GetBigEndianUInt16(4); var responseBytes = new List(); responseBytes.AddRange(requestBytes.Take(2)); try { var inputRegisters = await Client.ReadInputRegistersAsync(unitId, firstAddress, count, cancellationToken).ConfigureAwait(continueOnCapturedContext: false); byte[] values = new byte[count * 2]; for (int i = 0; i < count; i++) { values[i * 2] = inputRegisters[i].HighByte; values[i * 2 + 1] = inputRegisters[i].LowByte; } responseBytes.Add((byte)values.Length); responseBytes.AddRange(values); } catch { responseBytes[1] |= 0x80; responseBytes.Add((byte)ModbusErrorCode.SlaveDeviceFailure); } return ReturnResponse(responseBytes); } private async Task HandleWriteSingleCoilAsync(byte[] requestBytes, CancellationToken cancellationToken) { if (requestBytes.Length < 8) return null; var responseBytes = new List(); responseBytes.AddRange(requestBytes.Take(2)); ushort address = requestBytes.GetBigEndianUInt16(2); if (requestBytes[4] != 0x00 && requestBytes[4] != 0xFF) { responseBytes[1] |= 0x80; responseBytes.Add((byte)ModbusErrorCode.IllegalDataValue); return ReturnResponse(responseBytes); } try { var coil = new Coil { Address = address, HighByte = requestBytes[4], LowByte = requestBytes[5], }; bool isSuccess = await Client.WriteSingleCoilAsync(requestBytes[0], coil, cancellationToken).ConfigureAwait(continueOnCapturedContext: false); if (isSuccess) { // Response is an echo of the request responseBytes.AddRange(requestBytes.Skip(2).Take(4)); } else { responseBytes[1] |= 0x80; responseBytes.Add((byte)ModbusErrorCode.SlaveDeviceFailure); } } catch { responseBytes[1] |= 0x80; responseBytes.Add((byte)ModbusErrorCode.SlaveDeviceFailure); } return ReturnResponse(responseBytes); } private async Task HandleWriteSingleRegisterAsync(byte[] requestBytes, CancellationToken cancellationToken) { if (requestBytes.Length < 8) return null; var responseBytes = new List(); responseBytes.AddRange(requestBytes.Take(2)); ushort address = requestBytes.GetBigEndianUInt16(2); try { var register = new HoldingRegister { Address = address, HighByte = requestBytes[4], LowByte = requestBytes[5] }; bool isSuccess = await Client.WriteSingleHoldingRegisterAsync(requestBytes[0], register, cancellationToken).ConfigureAwait(continueOnCapturedContext: false); if (isSuccess) { // Response is an echo of the request responseBytes.AddRange(requestBytes.Skip(2).Take(4)); } else { responseBytes[1] |= 0x80; responseBytes.Add((byte)ModbusErrorCode.SlaveDeviceFailure); } } catch { responseBytes[1] |= 0x80; responseBytes.Add((byte)ModbusErrorCode.SlaveDeviceFailure); } return ReturnResponse(responseBytes); } private async Task HandleWriteMultipleCoilsAsync(byte[] requestBytes, CancellationToken cancellationToken) { if (requestBytes.Length < 9) return null; var responseBytes = new List(); responseBytes.AddRange(requestBytes.Take(2)); ushort firstAddress = requestBytes.GetBigEndianUInt16(2); ushort count = requestBytes.GetBigEndianUInt16(4); int byteCount = (int)Math.Ceiling(count / 8.0); if (requestBytes.Length < 9 + byteCount) { responseBytes[1] |= 0x80; responseBytes.Add((byte)ModbusErrorCode.IllegalDataValue); return ReturnResponse(responseBytes); } try { int baseOffset = 7; var coils = new List(); for (int i = 0; i < count; i++) { int bytePosition = i / 8; int bitPosition = i % 8; ushort address = (ushort)(firstAddress + i); bool value = (requestBytes[baseOffset + bytePosition] & (1 << bitPosition)) > 0; coils.Add(new Coil { Address = address, HighByte = value ? (byte)0xFF : (byte)0x00 }); } bool isSuccess = await Client.WriteMultipleCoilsAsync(requestBytes[0], coils, cancellationToken).ConfigureAwait(continueOnCapturedContext: false); if (isSuccess) { // Response is an echo of the request responseBytes.AddRange(requestBytes.Skip(2).Take(4)); } else { responseBytes[1] |= 0x80; responseBytes.Add((byte)ModbusErrorCode.SlaveDeviceFailure); } } catch { responseBytes[1] |= 0x80; responseBytes.Add((byte)ModbusErrorCode.SlaveDeviceFailure); } return ReturnResponse(responseBytes); } private async Task HandleWriteMultipleRegistersAsync(byte[] requestBytes, CancellationToken cancellationToken) { if (requestBytes.Length < 9) return null; var responseBytes = new List(); responseBytes.AddRange(requestBytes.Take(2)); ushort firstAddress = requestBytes.GetBigEndianUInt16(2); ushort count = requestBytes.GetBigEndianUInt16(4); int byteCount = count * 2; if (requestBytes.Length < 9 + byteCount) { responseBytes[1] |= 0x80; responseBytes.Add((byte)ModbusErrorCode.IllegalDataValue); return ReturnResponse(responseBytes); } try { int baseOffset = 7; var list = new List(); for (int i = 0; i < count; i++) { ushort address = (ushort)(firstAddress + i); list.Add(new HoldingRegister { Address = address, HighByte = requestBytes[baseOffset + i * 2], LowByte = requestBytes[baseOffset + i * 2 + 1] }); } bool isSuccess = await Client.WriteMultipleHoldingRegistersAsync(requestBytes[0], list, cancellationToken).ConfigureAwait(continueOnCapturedContext: false); if (isSuccess) { // Response is an echo of the request responseBytes.AddRange(requestBytes.Skip(2).Take(4)); } else { responseBytes[1] |= 0x80; responseBytes.Add((byte)ModbusErrorCode.SlaveDeviceFailure); } } catch { responseBytes[1] |= 0x80; responseBytes.Add((byte)ModbusErrorCode.SlaveDeviceFailure); } return ReturnResponse(responseBytes); } private async Task HandleEncapsulatedInterfaceAsync(byte[] requestBytes, CancellationToken cancellationToken) { if (requestBytes.Length < 7) return null; var responseBytes = new List(); responseBytes.AddRange(requestBytes.Take(2)); if (requestBytes[2] != 0x0E) { responseBytes[1] |= 0x80; responseBytes.Add((byte)ModbusErrorCode.IllegalFunction); return ReturnResponse(responseBytes); } var firstObject = (ModbusDeviceIdentificationObject)requestBytes[4]; if (0x06 < requestBytes[4] && requestBytes[4] < 0x80) { responseBytes[1] |= 0x80; responseBytes.Add((byte)ModbusErrorCode.IllegalDataAddress); return ReturnResponse(responseBytes); } var category = (ModbusDeviceIdentificationCategory)requestBytes[3]; if (!Enum.IsDefined(typeof(ModbusDeviceIdentificationCategory), category)) { responseBytes[1] |= 0x80; responseBytes.Add((byte)ModbusErrorCode.IllegalDataValue); return ReturnResponse(responseBytes); } try { var deviceInfo = await Client.ReadDeviceIdentificationAsync(requestBytes[0], category, firstObject, cancellationToken).ConfigureAwait(continueOnCapturedContext: false); var bodyBytes = new List(); // MEI, Category bodyBytes.AddRange(requestBytes.Skip(2).Take(2)); // Conformity bodyBytes.Add((byte)category); if (deviceInfo.IsIndividualAccessAllowed) bodyBytes[2] |= 0x80; // More, NextId, NumberOfObjects bodyBytes.AddRange(new byte[3]); int maxObjectId = category switch { ModbusDeviceIdentificationCategory.Basic => 0x02, ModbusDeviceIdentificationCategory.Regular => 0x06, ModbusDeviceIdentificationCategory.Extended => 0xFF, // Individual _ => requestBytes[4], }; byte numberOfObjects = 0; for (int i = requestBytes[4]; i <= maxObjectId; i++) { // Reserved if (0x07 <= i && i <= 0x7F) continue; byte[] objBytes = GetDeviceObject((byte)i, deviceInfo); // We need to split the response if it would exceed the max ADU size. // 2 bytes of CRC have to be added. if (responseBytes.Count + bodyBytes.Count + objBytes.Length + 2 > RtuProtocol.MAX_ADU_LENGTH) { bodyBytes[3] = 0xFF; bodyBytes[4] = (byte)i; bodyBytes[5] = numberOfObjects; responseBytes.AddRange(bodyBytes); return ReturnResponse(responseBytes); } bodyBytes.AddRange(objBytes); numberOfObjects++; } bodyBytes[5] = numberOfObjects; responseBytes.AddRange(bodyBytes); return ReturnResponse(responseBytes); } catch { responseBytes[1] |= 0x80; responseBytes.Add((byte)ModbusErrorCode.SlaveDeviceFailure); return ReturnResponse(responseBytes); } } private byte[] GetDeviceObject(byte objectId, DeviceIdentification deviceIdentification) { var result = new List { objectId }; switch ((ModbusDeviceIdentificationObject)objectId) { case ModbusDeviceIdentificationObject.VendorName: { byte[] bytes = Encoding.UTF8.GetBytes(deviceIdentification.VendorName ?? ""); result.Add((byte)bytes.Length); result.AddRange(bytes); } break; case ModbusDeviceIdentificationObject.ProductCode: { byte[] bytes = Encoding.UTF8.GetBytes(deviceIdentification.ProductCode ?? ""); result.Add((byte)bytes.Length); result.AddRange(bytes); } break; case ModbusDeviceIdentificationObject.MajorMinorRevision: { byte[] bytes = Encoding.UTF8.GetBytes(deviceIdentification.MajorMinorRevision ?? ""); result.Add((byte)bytes.Length); result.AddRange(bytes); } break; case ModbusDeviceIdentificationObject.VendorUrl: { byte[] bytes = Encoding.UTF8.GetBytes(deviceIdentification.VendorUrl ?? ""); result.Add((byte)bytes.Length); result.AddRange(bytes); } break; case ModbusDeviceIdentificationObject.ProductName: { byte[] bytes = Encoding.UTF8.GetBytes(deviceIdentification.ProductName ?? ""); result.Add((byte)bytes.Length); result.AddRange(bytes); } break; case ModbusDeviceIdentificationObject.ModelName: { byte[] bytes = Encoding.UTF8.GetBytes(deviceIdentification.ModelName ?? ""); result.Add((byte)bytes.Length); result.AddRange(bytes); } break; case ModbusDeviceIdentificationObject.UserApplicationName: { byte[] bytes = Encoding.UTF8.GetBytes(deviceIdentification.UserApplicationName ?? ""); result.Add((byte)bytes.Length); result.AddRange(bytes); } break; default: { if (deviceIdentification.ExtendedObjects.TryGetValue(objectId, out byte[] bytes)) { result.Add((byte)bytes.Length); result.AddRange(bytes); } else { result.Add(0x00); } } break; } return [.. result]; } private static byte[] ReturnResponse(List response) { response.AddRange(RtuProtocol.CRC16(response)); return [.. response]; } #endregion Request Handling /// public override string ToString() { var sb = new StringBuilder(); sb.AppendLine($"RTU Proxy"); sb.AppendLine($" {nameof(PortName)}: {PortName}"); sb.AppendLine($" {nameof(BaudRate)}: {(int)BaudRate}"); sb.AppendLine($" {nameof(DataBits)}: {DataBits}"); sb.AppendLine($" {nameof(StopBits)}: {StopBits}"); sb.AppendLine($" {nameof(Parity)}: {Parity}"); sb.AppendLine($" {nameof(Client)}: {Client.GetType().Name}"); return sb.ToString(); } } }