diff --git a/AMWD.Protocols.Modbus.Common/InternalsVisibleTo.cs b/AMWD.Protocols.Modbus.Common/InternalsVisibleTo.cs index c89850e..82a8ede 100644 --- a/AMWD.Protocols.Modbus.Common/InternalsVisibleTo.cs +++ b/AMWD.Protocols.Modbus.Common/InternalsVisibleTo.cs @@ -1,3 +1,4 @@ using System.Runtime.CompilerServices; [assembly: InternalsVisibleTo("AMWD.Protocols.Modbus.Tests")] +[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")] diff --git a/AMWD.Protocols.Modbus.Common/Protocols/TcpProtocol.cs b/AMWD.Protocols.Modbus.Common/Protocols/TcpProtocol.cs index 868f8c8..c87480c 100644 --- a/AMWD.Protocols.Modbus.Common/Protocols/TcpProtocol.cs +++ b/AMWD.Protocols.Modbus.Common/Protocols/TcpProtocol.cs @@ -59,6 +59,15 @@ namespace AMWD.Protocols.Modbus.Common.Protocols /// 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 = 260; // bytes + #endregion Constants /// diff --git a/AMWD.Protocols.Modbus.Tcp/AMWD.Protocols.Modbus.Tcp.csproj b/AMWD.Protocols.Modbus.Tcp/AMWD.Protocols.Modbus.Tcp.csproj index e8b7175..15fedd4 100644 --- a/AMWD.Protocols.Modbus.Tcp/AMWD.Protocols.Modbus.Tcp.csproj +++ b/AMWD.Protocols.Modbus.Tcp/AMWD.Protocols.Modbus.Tcp.csproj @@ -13,6 +13,12 @@ Modbus Protocol Network TCP LAN + + + + + + diff --git a/AMWD.Protocols.Modbus.Tcp/Events/CoilWrittenEventArgs.cs b/AMWD.Protocols.Modbus.Tcp/Events/CoilWrittenEventArgs.cs new file mode 100644 index 0000000..256299f --- /dev/null +++ b/AMWD.Protocols.Modbus.Tcp/Events/CoilWrittenEventArgs.cs @@ -0,0 +1,25 @@ +using System; + +namespace AMWD.Protocols.Modbus.Tcp.Events +{ + /// + /// Represents the coil written event arguments. + /// + public class CoilWrittenEventArgs : EventArgs + { + /// + /// Gets or sets the unit id. + /// + public byte UnitId { get; internal set; } + + /// + /// Gets or sets the coil address. + /// + public ushort Address { get; internal set; } + + /// + /// Gets or sets the coil value. + /// + public bool Value { get; internal set; } + } +} diff --git a/AMWD.Protocols.Modbus.Tcp/Events/RegisterWrittenEventArgs.cs b/AMWD.Protocols.Modbus.Tcp/Events/RegisterWrittenEventArgs.cs new file mode 100644 index 0000000..fe5da73 --- /dev/null +++ b/AMWD.Protocols.Modbus.Tcp/Events/RegisterWrittenEventArgs.cs @@ -0,0 +1,35 @@ +using System; + +namespace AMWD.Protocols.Modbus.Tcp.Events +{ + /// + /// Represents the register written event arguments. + /// + public class RegisterWrittenEventArgs : EventArgs + { + /// + /// Gets or sets the unit id. + /// + public byte UnitId { get; internal set; } + + /// + /// Gets or sets the address of the register. + /// + public ushort Address { get; internal set; } + + /// + /// Gets or sets the value of the register. + /// + public ushort Value { get; internal set; } + + /// + /// Gets or sets the high byte of the register. + /// + public byte HighByte { get; internal set; } + + /// + /// Gets or sets the low byte of the register. + /// + public byte LowByte { get; internal set; } + } +} diff --git a/AMWD.Protocols.Modbus.Tcp/Extensions/StreamExtensions.cs b/AMWD.Protocols.Modbus.Tcp/Extensions/StreamExtensions.cs new file mode 100644 index 0000000..545ce63 --- /dev/null +++ b/AMWD.Protocols.Modbus.Tcp/Extensions/StreamExtensions.cs @@ -0,0 +1,26 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace System.IO +{ + internal static class StreamExtensions + { + public static async Task ReadExpectedBytesAsync(this Stream stream, int expectedBytes, CancellationToken cancellationToken = default) + { + byte[] buffer = new byte[expectedBytes]; + int offset = 0; + do + { + int count = await stream.ReadAsync(buffer, offset, expectedBytes - offset, cancellationToken).ConfigureAwait(false); + if (count < 1) + throw new EndOfStreamException(); + + offset += count; + } + while (offset < expectedBytes && !cancellationToken.IsCancellationRequested); + + cancellationToken.ThrowIfCancellationRequested(); + return buffer; + } + } +} diff --git a/AMWD.Protocols.Modbus.Tcp/InternalsVisibleTo.cs b/AMWD.Protocols.Modbus.Tcp/InternalsVisibleTo.cs deleted file mode 100644 index 82a8ede..0000000 --- a/AMWD.Protocols.Modbus.Tcp/InternalsVisibleTo.cs +++ /dev/null @@ -1,4 +0,0 @@ -using System.Runtime.CompilerServices; - -[assembly: InternalsVisibleTo("AMWD.Protocols.Modbus.Tests")] -[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")] diff --git a/AMWD.Protocols.Modbus.Tcp/ModbusTcpServer.cs b/AMWD.Protocols.Modbus.Tcp/ModbusTcpServer.cs new file mode 100644 index 0000000..8cc66a1 --- /dev/null +++ b/AMWD.Protocols.Modbus.Tcp/ModbusTcpServer.cs @@ -0,0 +1,1149 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Sockets; +using System.Reflection; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using AMWD.Protocols.Modbus.Common; +using AMWD.Protocols.Modbus.Common.Models; +using AMWD.Protocols.Modbus.Common.Protocols; +using AMWD.Protocols.Modbus.Tcp.Events; + +namespace AMWD.Protocols.Modbus.Tcp +{ + /// + /// A basic implementation of a Modbus TCP server. + /// + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + public class ModbusTcpServer : IDisposable + { + #region Fields + + private bool _isDisposed; + + private TcpListener _listener; + private CancellationTokenSource _stopCts; + private Task _clientConnectTask = Task.CompletedTask; + + private readonly SemaphoreSlim _clientListLock = new(1, 1); + private readonly List _clients = []; + private readonly List _clientTasks = []; + + private readonly ReaderWriterLockSlim _deviceListLock = new(); + private readonly Dictionary _devices = []; + + #endregion Fields + + #region Constructors + + /// + /// Initializes a new instance of the class. + /// + /// An to listen on (Default: ). + /// A port to listen on (Default: 502). + /// + /// + public ModbusTcpServer(IPAddress listenAddress = null, int listenPort = 502) + { + ListenAddress = listenAddress ?? IPAddress.Loopback; + + if (ushort.MinValue < listenPort || listenPort < ushort.MaxValue) + throw new ArgumentOutOfRangeException(nameof(listenPort)); + + try + { +#if NET8_0_OR_GREATER + using var testListener = new TcpListener(ListenAddress, listenPort); +#else + var testListener = new TcpListener(ListenAddress, listenPort); +#endif + testListener.Start(1); + ListenPort = (testListener.LocalEndpoint as IPEndPoint).Port; + testListener.Stop(); + } + catch (Exception ex) + { + throw new ArgumentException($"{nameof(ListenPort)} ({listenPort}) is already in use.", ex); + } + } + + #endregion Constructors + + #region Events + + /// + /// Occurs when a is written. + /// + public event EventHandler CoilWritten; + + /// + /// Occurs when a is written. + /// + public event EventHandler RegisterWritten; + + #endregion Events + + #region Properties + + /// + /// Gets the to listen on. + /// + public IPAddress ListenAddress { get; } + + /// + /// Get the port to listen on. + /// + public int ListenPort { get; } + + /// + /// Gets a value indicating whether the server is running. + /// + public bool IsRunning => _listener?.Server.IsBound ?? false; + + /// + /// Gets or sets the read/write timeout. + /// + public TimeSpan ReadWriteTimeout { get; set; } + + #endregion Properties + + #region Control Methods + + /// + /// Starts the server. + /// + /// A cancellation token used to propagate notification that this operation should be canceled. + public Task StartAsync(CancellationToken cancellationToken = default) + { + Assertions(); + + _stopCts?.Cancel(); + + _listener?.Stop(); +#if NET8_0_OR_GREATER + _listener?.Dispose(); +#endif + + _stopCts?.Dispose(); + _stopCts = new CancellationTokenSource(); + + _listener = new TcpListener(ListenAddress, ListenPort); + if (ListenAddress.AddressFamily == AddressFamily.InterNetworkV6) + _listener.Server.DualMode = true; + + _listener.Start(); + _clientConnectTask = WaitForClientAsync(_stopCts.Token); + + return Task.CompletedTask; + } + + /// + /// Stops the server. + /// + /// A cancellation token used to propagate notification that this operation should be canceled. + public Task StopAsync(CancellationToken cancellationToken = default) + { + Assertions(); + return StopAsyncInternal(cancellationToken); + } + + private async Task StopAsyncInternal(CancellationToken cancellationToken = default) + { + _stopCts.Cancel(); + + _listener.Stop(); +#if NET8_0_OR_GREATER + _listener.Dispose(); +#endif + try + { + await Task.WhenAny(_clientConnectTask, Task.Delay(Timeout.Infinite, cancellationToken)); + } + catch (OperationCanceledException) + { + // Terminated + } + + try + { + await Task.WhenAny(Task.WhenAll(_clientTasks), Task.Delay(Timeout.Infinite, cancellationToken)); + } + catch (OperationCanceledException) + { + // Terminated + } + } + + /// + /// Releases all managed and unmanaged resources used by the . + /// + public void Dispose() + { + if (_isDisposed) + return; + + _isDisposed = true; + + StopAsyncInternal(CancellationToken.None).Wait(); + + _clientListLock.Dispose(); + _deviceListLock.Dispose(); + + _clients.Clear(); + _devices.Clear(); + } + + private void Assertions() + { +#if NET8_0_OR_GREATER + ObjectDisposedException.ThrowIf(_isDisposed, this); +#else + if (_isDisposed) + throw new ObjectDisposedException(GetType().FullName); +#endif + } + + #endregion Control Methods + + #region Client Handling + + private async Task WaitForClientAsync(CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) + { + try + { +#if NET8_0_OR_GREATER + var client = await _listener.AcceptTcpClientAsync(cancellationToken).ConfigureAwait(false); +#else + var client = await _listener.AcceptTcpClientAsync().ConfigureAwait(false); +#endif + await _clientListLock.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + _clients.Add(client); + _clientTasks.Add(HandleClientAsync(client, cancellationToken)); + } + finally + { + _clientListLock.Release(); + } + } + catch + { + // There might be a failure here, that's ok, just keep it quiet + } + } + } + + private async Task HandleClientAsync(TcpClient client, CancellationToken cancellationToken) + { + try + { + var stream = client.GetStream(); + while (!cancellationToken.IsCancellationRequested) + { + var requestBytes = new List(); + + using (var cts = new CancellationTokenSource(ReadWriteTimeout)) + using (cancellationToken.Register(cts.Cancel)) + { + byte[] headerBytes = await stream.ReadExpectedBytesAsync(6, cts.Token).ConfigureAwait(false); + requestBytes.AddRange(headerBytes); + + byte[] followingCountBytes = headerBytes.Skip(4).Take(2).ToArray(); + followingCountBytes.SwapNetworkOrder(); + int followingCount = BitConverter.ToUInt16(followingCountBytes, 0); + + byte[] bodyBytes = await stream.ReadExpectedBytesAsync(followingCount, cts.Token).ConfigureAwait(false); + requestBytes.AddRange(bodyBytes); + } + + byte[] responseBytes = HandleRequest([.. requestBytes]); + if (responseBytes != null) + await stream.WriteAsync(responseBytes, 0, responseBytes.Length, cancellationToken).ConfigureAwait(false); + } + } + catch + { + // Keep client processing quiet + } + finally + { + await _clientListLock.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + _clients.Remove(client); + client.Dispose(); + } + finally + { + _clientListLock.Release(); + } + } + } + + #endregion Client Handling + + #region Request Handling + + private byte[] HandleRequest(byte[] requestBytes) + { + using (_deviceListLock.GetReadLock()) + { + // No response is sent, if the device is not known + if (!_devices.TryGetValue(requestBytes[6], out var device)) + return null; + + switch ((ModbusFunctionCode)requestBytes[7]) + { + case ModbusFunctionCode.ReadCoils: + return HandleReadCoils(device, requestBytes); + + case ModbusFunctionCode.ReadDiscreteInputs: + return HandleReadDiscreteInputs(device, requestBytes); + + case ModbusFunctionCode.ReadHoldingRegisters: + return HandleReadHoldingRegisters(device, requestBytes); + + case ModbusFunctionCode.ReadInputRegisters: + return HandleReadInputRegisters(device, requestBytes); + + case ModbusFunctionCode.WriteSingleCoil: + return HandleWriteSingleCoil(device, requestBytes); + + case ModbusFunctionCode.WriteSingleRegister: + return HandleWriteSingleRegister(device, requestBytes); + + case ModbusFunctionCode.WriteMultipleCoils: + return HandleWriteMultipleCoils(device, requestBytes); + + case ModbusFunctionCode.WriteMultipleRegisters: + return HandleWriteMultipleRegisters(device, requestBytes); + + case ModbusFunctionCode.EncapsulatedInterface: + return HandleEncapsulatedInterface(requestBytes); + + default: // unknown function + { + byte[] responseBytes = new byte[9]; + Array.Copy(requestBytes, 0, responseBytes, 0, 8); + + // Mark as error + responseBytes[7] |= 0x80; + + responseBytes[8] = (byte)ModbusErrorCode.IllegalFunction; + return responseBytes; + } + } + } + } + + private static byte[] HandleReadCoils(ModbusDevice device, byte[] requestBytes) + { + if (requestBytes.Length < 12) + return null; + + var responseBytes = new List(); + responseBytes.AddRange(requestBytes.Take(8)); + + ushort firstAddress = requestBytes.NetworkUInt16(8); + ushort count = requestBytes.NetworkUInt16(10); + + if (TcpProtocol.MIN_READ_COUNT < count || count < TcpProtocol.MAX_DISCRETE_READ_COUNT) + { + responseBytes[7] |= 0x80; + responseBytes.Add((byte)ModbusErrorCode.IllegalDataValue); + return [.. responseBytes]; + } + + if (firstAddress + count > ushort.MaxValue) + { + responseBytes[7] |= 0x80; + responseBytes.Add((byte)ModbusErrorCode.IllegalDataAddress); + return [.. responseBytes]; + } + + try + { + byte[] values = new byte[(int)Math.Ceiling(count / 8.0)]; + for (int i = 0; i < count; i++) + { + ushort address = (ushort)(firstAddress + i); + if (device.GetCoil(address).Value) + { + int byteIndex = i / 8; + int bitIndex = i % 8; + + values[byteIndex] |= (byte)(1 << bitIndex); + } + } + + responseBytes.Add((byte)values.Length); + responseBytes.AddRange(values); + } + catch + { + responseBytes[7] |= 0x80; + responseBytes.Add((byte)ModbusErrorCode.SlaveDeviceFailure); + } + + return [.. responseBytes]; + } + + private static byte[] HandleReadDiscreteInputs(ModbusDevice device, byte[] requestBytes) + { + if (requestBytes.Length < 12) + return null; + + var responseBytes = new List(); + responseBytes.AddRange(requestBytes.Take(8)); + + ushort firstAddress = requestBytes.NetworkUInt16(8); + ushort count = requestBytes.NetworkUInt16(10); + + if (TcpProtocol.MIN_READ_COUNT < count || count < TcpProtocol.MAX_DISCRETE_READ_COUNT) + { + responseBytes[7] |= 0x80; + responseBytes.Add((byte)ModbusErrorCode.IllegalDataValue); + return [.. responseBytes]; + } + + if (firstAddress + count > ushort.MaxValue) + { + responseBytes[7] |= 0x80; + responseBytes.Add((byte)ModbusErrorCode.IllegalDataAddress); + return [.. responseBytes]; + } + + try + { + byte[] values = new byte[(int)Math.Ceiling(count / 8.0)]; + for (int i = 0; i < count; i++) + { + ushort address = (ushort)(firstAddress + i); + if (device.GetDiscreteInput(address).Value) + { + int byteIndex = i / 8; + int bitIndex = i % 8; + + values[byteIndex] |= (byte)(1 << bitIndex); + } + } + + responseBytes.Add((byte)values.Length); + responseBytes.AddRange(values); + } + catch + { + responseBytes[7] |= 0x80; + responseBytes.Add((byte)ModbusErrorCode.SlaveDeviceFailure); + } + + return [.. responseBytes]; + } + + private static byte[] HandleReadHoldingRegisters(ModbusDevice device, byte[] requestBytes) + { + if (requestBytes.Length < 12) + return null; + + var responseBytes = new List(); + responseBytes.AddRange(requestBytes.Take(8)); + + ushort firstAddress = requestBytes.NetworkUInt16(8); + ushort count = requestBytes.NetworkUInt16(10); + + if (TcpProtocol.MIN_READ_COUNT < count || count < TcpProtocol.MAX_REGISTER_READ_COUNT) + { + responseBytes[7] |= 0x80; + responseBytes.Add((byte)ModbusErrorCode.IllegalDataValue); + return [.. responseBytes]; + } + + if (firstAddress + count > ushort.MaxValue) + { + responseBytes[7] |= 0x80; + responseBytes.Add((byte)ModbusErrorCode.IllegalDataAddress); + return [.. responseBytes]; + } + + try + { + byte[] values = new byte[count * 2]; + for (int i = 0; i < count; i++) + { + ushort address = (ushort)(firstAddress + i); + var register = device.GetHoldingRegister(address); + + values[i * 2] = register.HighByte; + values[i * 2 + 1] = register.LowByte; + } + + responseBytes.Add((byte)values.Length); + responseBytes.AddRange(values); + } + catch + { + responseBytes[7] |= 0x80; + responseBytes.Add((byte)ModbusErrorCode.SlaveDeviceFailure); + } + + return [.. responseBytes]; + } + + private static byte[] HandleReadInputRegisters(ModbusDevice device, byte[] requestBytes) + { + if (requestBytes.Length < 12) + return null; + + var responseBytes = new List(); + responseBytes.AddRange(requestBytes.Take(8)); + + ushort firstAddress = requestBytes.NetworkUInt16(8); + ushort count = requestBytes.NetworkUInt16(10); + + if (TcpProtocol.MIN_READ_COUNT < count || count < TcpProtocol.MAX_REGISTER_READ_COUNT) + { + responseBytes[7] |= 0x80; + responseBytes.Add((byte)ModbusErrorCode.IllegalDataValue); + return [.. responseBytes]; + } + + if (firstAddress + count > ushort.MaxValue) + { + responseBytes[7] |= 0x80; + responseBytes.Add((byte)ModbusErrorCode.IllegalDataAddress); + return [.. responseBytes]; + } + + try + { + byte[] values = new byte[count * 2]; + for (int i = 0; i < count; i++) + { + ushort address = (ushort)(firstAddress + i); + var register = device.GetInputRegister(address); + + values[i * 2] = register.HighByte; + values[i * 2 + 1] = register.LowByte; + } + + responseBytes.Add((byte)values.Length); + responseBytes.AddRange(values); + } + catch + { + responseBytes[7] |= 0x80; + responseBytes.Add((byte)ModbusErrorCode.SlaveDeviceFailure); + } + + return [.. responseBytes]; + } + + private byte[] HandleWriteSingleCoil(ModbusDevice device, byte[] requestBytes) + { + if (requestBytes.Length < 12) + return null; + + var responseBytes = new List(); + responseBytes.AddRange(requestBytes.Take(8)); + + ushort address = requestBytes.NetworkUInt16(8); + + if (requestBytes[10] != 0x00 && requestBytes[10] != 0xFF) + { + responseBytes[7] |= 0x80; + responseBytes.Add((byte)ModbusErrorCode.IllegalDataValue); + return [.. responseBytes]; + } + + try + { + device.SetCoil(new Coil + { + Address = address, + HighByte = requestBytes[10] + }); + + // Response is an echo of the request + responseBytes.AddRange(requestBytes.Skip(8).Take(4)); + + // Notify that the coil was written + Task.Run(() => + { + try + { + CoilWritten?.Invoke(this, new CoilWrittenEventArgs + { + UnitId = device.Id, + Address = address, + Value = requestBytes[10] == 0xFF + }); + } + catch + { + // keep everything quiet + } + }); + } + catch + { + responseBytes[7] |= 0x80; + responseBytes.Add((byte)ModbusErrorCode.SlaveDeviceFailure); + } + + return [.. responseBytes]; + } + + private byte[] HandleWriteSingleRegister(ModbusDevice device, byte[] requestBytes) + { + if (requestBytes.Length < 12) + return null; + + var responseBytes = new List(); + responseBytes.AddRange(requestBytes.Take(8)); + + ushort address = requestBytes.NetworkUInt16(8); + ushort value = requestBytes.NetworkUInt16(10); + + try + { + device.SetHoldingRegister(new HoldingRegister + { + Address = address, + HighByte = requestBytes[10], + LowByte = requestBytes[11] + }); + + // Response is an echo of the request + responseBytes.AddRange(requestBytes.Skip(8).Take(4)); + + // Notify that the register was written + Task.Run(() => + { + try + { + RegisterWritten?.Invoke(this, new RegisterWrittenEventArgs + { + UnitId = device.Id, + Address = address, + Value = value, + HighByte = requestBytes[10], + LowByte = requestBytes[11] + }); + } + catch + { + // keep everything quiet + } + }); + } + catch + { + responseBytes[7] |= 0x80; + responseBytes.Add((byte)ModbusErrorCode.SlaveDeviceFailure); + } + + return [.. responseBytes]; + } + + private byte[] HandleWriteMultipleCoils(ModbusDevice device, byte[] requestBytes) + { + if (requestBytes.Length < 13) + return null; + + var responseBytes = new List(); + responseBytes.AddRange(requestBytes.Take(8)); + + ushort firstAddress = requestBytes.NetworkUInt16(8); + ushort count = requestBytes.NetworkUInt16(10); + + int byteCount = (int)Math.Ceiling(count / 8.0); + if (requestBytes.Length < 13 + byteCount) + { + responseBytes[7] |= 0x80; + responseBytes.Add((byte)ModbusErrorCode.IllegalDataValue); + return [.. responseBytes]; + } + + try + { + int baseOffset = 13; + 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; + + device.SetCoil(new Coil + { + Address = address, + HighByte = value ? (byte)0xFF : (byte)0x00 + }); + + // Notify that the coil was written + Task.Run(() => + { + try + { + CoilWritten?.Invoke(this, new CoilWrittenEventArgs + { + UnitId = device.Id, + Address = address, + Value = value + }); + } + catch + { + // keep everything quiet + } + }); + } + + responseBytes.AddRange(requestBytes.Skip(8).Take(4)); + } + catch + { + responseBytes[7] |= 0x80; + responseBytes.Add((byte)ModbusErrorCode.SlaveDeviceFailure); + } + + return [.. responseBytes]; + } + + private byte[] HandleWriteMultipleRegisters(ModbusDevice device, byte[] requestBytes) + { + if (requestBytes.Length < 13) + return null; + + var responseBytes = new List(); + responseBytes.AddRange(requestBytes.Take(8)); + + ushort firstAddress = requestBytes.NetworkUInt16(8); + ushort count = requestBytes.NetworkUInt16(10); + + int byteCount = count * 2; + if (requestBytes.Length < 13 + byteCount) + { + responseBytes[7] |= 0x80; + responseBytes.Add((byte)ModbusErrorCode.IllegalDataValue); + return [.. responseBytes]; + } + + try + { + int baseOffset = 13; + for (int i = 0; i < count; i++) + { + ushort address = (ushort)(firstAddress + i); + + device.SetHoldingRegister(new HoldingRegister + { + Address = address, + HighByte = requestBytes[baseOffset + i * 2], + LowByte = requestBytes[baseOffset + i * 2 + 1] + }); + + // Notify that the coil was written + Task.Run(() => + { + try + { + RegisterWritten?.Invoke(this, new RegisterWrittenEventArgs + { + UnitId = device.Id, + Address = address, + Value = requestBytes.NetworkUInt16(baseOffset + i * 2), + HighByte = requestBytes[baseOffset + i * 2], + LowByte = requestBytes[baseOffset + i * 2 + 1] + }); + } + catch + { + // keep everything quiet + } + }); + } + + responseBytes.AddRange(requestBytes.Skip(8).Take(4)); + } + catch + { + responseBytes[7] |= 0x80; + responseBytes.Add((byte)ModbusErrorCode.SlaveDeviceFailure); + } + + return [.. responseBytes]; + } + + private byte[] HandleEncapsulatedInterface(byte[] requestBytes) + { + var responseBytes = new List(); + responseBytes.AddRange(requestBytes.Take(8)); + + if (requestBytes[8] != 0x0E) + { + responseBytes[7] |= 0x80; + responseBytes.Add((byte)ModbusErrorCode.IllegalFunction); + return [.. responseBytes]; + } + + if (0x06 < requestBytes[10] && requestBytes[10] < 0x80) + { + responseBytes[7] |= 0x80; + responseBytes.Add((byte)ModbusErrorCode.IllegalDataAddress); + return [.. responseBytes]; + } + + var category = (ModbusDeviceIdentificationCategory)requestBytes[9]; + if (!Enum.IsDefined(typeof(ModbusDeviceIdentificationCategory), category)) + { + responseBytes[7] |= 0x80; + responseBytes.Add((byte)ModbusErrorCode.IllegalDataValue); + return [.. responseBytes]; + } + + try + { + var bodyBytes = new List(); + // MEI, Category + bodyBytes.AddRange(requestBytes.Skip(8).Take(2)); + // Conformity + bodyBytes.Add((byte)(category + 0x80)); + // More, NextId, NumberOfObjects + bodyBytes.AddRange(new byte[3]); + + int maxObjectId; + switch (category) + { + case ModbusDeviceIdentificationCategory.Basic: + maxObjectId = 0x02; + break; + + case ModbusDeviceIdentificationCategory.Regular: + maxObjectId = 0x06; + break; + + case ModbusDeviceIdentificationCategory.Extended: + maxObjectId = 0xFF; + break; + + default: // Individual + { + if (requestBytes[10] < 0x03) + bodyBytes[2] = 0x81; + else if (requestBytes[10] < 0x80) + bodyBytes[2] = 0x82; + else + bodyBytes[2] = 0x83; + + maxObjectId = requestBytes[10]; + } + + break; + } + + byte numberOfObjects = 0; + for (int i = requestBytes[10]; i <= maxObjectId; i++) + { + // Reserved + if (0x07 <= i && i <= 0x7F) + continue; + + byte[] objBytes = GetDeviceObject((byte)i); + + // We need to split the response if it would exceed the max ADU size + if (responseBytes.Count + bodyBytes.Count + objBytes.Length > TcpProtocol.MAX_ADU_LENGTH) + { + bodyBytes[3] = 0xFF; + bodyBytes[4] = (byte)i; + + bodyBytes[5] = numberOfObjects; + responseBytes.AddRange(bodyBytes); + return [.. responseBytes]; + } + + bodyBytes.AddRange(objBytes); + numberOfObjects++; + } + + bodyBytes[5] = numberOfObjects; + responseBytes.AddRange(bodyBytes); + return [.. responseBytes]; + } + catch + { + responseBytes[7] |= 0x80; + responseBytes.Add((byte)ModbusErrorCode.SlaveDeviceFailure); + return [.. responseBytes]; + } + } + + private byte[] GetDeviceObject(byte objectId) + { + var result = new List { objectId }; + switch ((ModbusDeviceIdentificationObject)objectId) + { + case ModbusDeviceIdentificationObject.VendorName: + { + byte[] bytes = Encoding.UTF8.GetBytes("AMWD"); + result.Add((byte)bytes.Length); + result.AddRange(bytes); + } + break; + + case ModbusDeviceIdentificationObject.ProductCode: + { + byte[] bytes = Encoding.UTF8.GetBytes("AMWD-MBS-TCP"); + result.Add((byte)bytes.Length); + result.AddRange(bytes); + } + break; + + case ModbusDeviceIdentificationObject.MajorMinorRevision: + { + string version = GetType().Assembly + .GetCustomAttribute() + .InformationalVersion; + + byte[] bytes = Encoding.UTF8.GetBytes(version); + result.Add((byte)bytes.Length); + result.AddRange(bytes); + } + break; + + case ModbusDeviceIdentificationObject.VendorUrl: + { + byte[] bytes = Encoding.UTF8.GetBytes("https://github.com/AM-WD/AMWD.Protocols.Modbus"); + result.Add((byte)bytes.Length); + result.AddRange(bytes); + } + break; + + case ModbusDeviceIdentificationObject.ProductName: + { + byte[] bytes = Encoding.UTF8.GetBytes("AM.WD Modbus Library"); + result.Add((byte)bytes.Length); + result.AddRange(bytes); + } + break; + + case ModbusDeviceIdentificationObject.ModelName: + { + byte[] bytes = Encoding.UTF8.GetBytes("TCP Server"); + result.Add((byte)bytes.Length); + result.AddRange(bytes); + } + break; + + case ModbusDeviceIdentificationObject.UserApplicationName: + { + byte[] bytes = Encoding.UTF8.GetBytes("Modbus TCP Server"); + result.Add((byte)bytes.Length); + result.AddRange(bytes); + } + break; + + default: + result.Add(0x00); + break; + } + + return [.. result]; + } + + #endregion Request Handling + + #region Device Handling + + /// + /// Adds a new device to the server. + /// + /// The unit ID of the device. + /// if the device was added, otherwise. + public bool AddDevice(byte unitId) + { + Assertions(); + + using (_deviceListLock.GetWriteLock()) + { + if (_devices.ContainsKey(unitId)) + return false; + + _devices.Add(unitId, new ModbusDevice(unitId)); + return true; + } + } + + /// + /// Removes a device from the server. + /// + /// The unit ID of the device. + /// if the device was removed, otherwise. + public bool RemoveDevice(byte unitId) + { + Assertions(); + + using (_deviceListLock.GetWriteLock()) + { + if (_devices.TryGetValue(unitId, out var device)) + device.Dispose(); + + return _devices.Remove(unitId); + } + } + + /// + /// Gets a from the specified . + /// + /// The unit ID of the device. + /// The address of the coil. + public Coil GetCoil(byte unitId, ushort address) + { + Assertions(); + + using (_deviceListLock.GetReadLock()) + { + if (!_devices.TryGetValue(unitId, out var device)) + return null; + + return device.GetCoil(address); + } + } + + /// + /// Sets a to the specified . + /// + /// The unit ID of the device. + /// The to set. + public void SetCoil(byte unitId, Coil coil) + { + Assertions(); + + using (_deviceListLock.GetReadLock()) + { + if (!_devices.TryGetValue(unitId, out var device)) + return; + + device.SetCoil(coil); + } + } + + /// + /// Gets a from the specified . + /// + /// The unit ID of the device. + /// The address of the . + public DiscreteInput GetDiscreteInput(byte unitId, ushort address) + { + Assertions(); + + using (_deviceListLock.GetReadLock()) + { + if (!_devices.TryGetValue(unitId, out var device)) + return null; + + return device.GetDiscreteInput(address); + } + } + + /// + /// Sets a to the specified . + /// + /// The unit ID of the device. + /// The to set. + public void SetDiscreteInput(byte unitId, DiscreteInput discreteInput) + { + Assertions(); + + using (_deviceListLock.GetReadLock()) + { + if (!_devices.TryGetValue(unitId, out var device)) + return; + + device.SetDiscreteInput(discreteInput); + } + } + + /// + /// Gets a from the specified . + /// + /// The unit ID of the device. + /// The address of the . + public HoldingRegister GetHoldingRegister(byte unitId, ushort address) + { + Assertions(); + + using (_deviceListLock.GetReadLock()) + { + if (!_devices.TryGetValue(unitId, out var device)) + return null; + + return device.GetHoldingRegister(address); + } + } + + /// + /// Sets a to the specified . + /// + /// The unit ID of the device. + /// The to set. + public void SetHoldingRegister(byte unitId, HoldingRegister holdingRegister) + { + Assertions(); + + using (_deviceListLock.GetReadLock()) + { + if (!_devices.TryGetValue(unitId, out var device)) + return; + + device.SetHoldingRegister(holdingRegister); + } + } + + /// + /// Gets a from the specified . + /// + /// The unit ID of the device. + /// The address of the . + public InputRegister GetInputRegister(byte unitId, ushort address) + { + Assertions(); + + using (_deviceListLock.GetReadLock()) + { + if (!_devices.TryGetValue(unitId, out var device)) + return null; + + return device.GetInputRegister(address); + } + } + + /// + /// Sets a to the specified . + /// + /// The unit ID of the device. + /// The to set. + public void SetInputRegister(byte unitId, InputRegister inputRegister) + { + Assertions(); + + using (_deviceListLock.GetReadLock()) + { + if (!_devices.TryGetValue(unitId, out var device)) + return; + + device.SetInputRegister(inputRegister); + } + } + + #endregion Device Handling + } +} diff --git a/AMWD.Protocols.Modbus.Tests/Tcp/Utils/ModbusTcpConnectionTest.cs b/AMWD.Protocols.Modbus.Tests/Tcp/Utils/ModbusTcpConnectionTest.cs index 690205f..4ca5dda 100644 --- a/AMWD.Protocols.Modbus.Tests/Tcp/Utils/ModbusTcpConnectionTest.cs +++ b/AMWD.Protocols.Modbus.Tests/Tcp/Utils/ModbusTcpConnectionTest.cs @@ -161,8 +161,8 @@ namespace AMWD.Protocols.Modbus.Tests.Tcp.Utils public async Task ShouldThrowApplicationExceptionHostnameNotResolvable() { // Arrange - _hostname = "123.321.123.321"; var connection = GetConnection(); + connection.GetType().GetField("_hostname", BindingFlags.NonPublic | BindingFlags.Instance).SetValue(connection, ""); // Act await connection.ConnectAsync();