17 Commits

Author SHA1 Message Date
8b3441f6dd Bump to v0.4.0 2025-01-29 00:17:48 +01:00
63c88f5da7 Added modbus-proxy CLI tool 2025-01-28 23:55:44 +01:00
e7300bfbde Implemented UnitTests for TCP proxy 2025-01-28 21:22:11 +01:00
38dd94471d Fixed wrong ADU calculation on RTU proxy 2025-01-28 14:00:58 +01:00
56664cdac5 Added TCP wrappers to TCP proxy 2025-01-28 14:00:14 +01:00
4ef7500c3b Added some TCP wrapper classes for testability 2025-01-28 13:58:01 +01:00
05759f8e12 Fixes for SerialRtuProxy
- Adding UnitTests
- Fixing some bugs
- Updating UnitTest dependencies
2025-01-27 17:27:23 +01:00
6fc7cfda9a Updated serial implementations 2025-01-23 22:05:44 +01:00
fb67e0b77e Added VirtualModbusClient to Common 2025-01-23 22:01:45 +01:00
ce3d873cd0 Moving proxies to matching projects 2025-01-23 21:59:46 +01:00
1cf49f74ea Added README for cli tool 2025-01-23 12:05:47 +01:00
39863880d5 Added new cli tool for client connections 2025-01-21 19:27:29 +01:00
ec0ba31b86 Fixed wrong 'following bytes' information in TCP proxy 2025-01-21 19:26:13 +01:00
96b5ee21c8 Updated licensing information (still MIT) 2025-01-21 19:22:30 +01:00
6a231e02cb ReadWriteTimeout for ModbusTcpProxy changed
As suggestion of #5 on GitHub, the default value of the timeout changed.
But to the same timeout as on HttpClient and not infinite.
2024-12-13 17:58:34 +01:00
c1a70de6bb Moved InternalsVisibleTo to directory.build.props 2024-11-12 08:26:25 +01:00
6bf011d53f Updating dependencies and CI 2024-09-09 08:15:31 +02:00
71 changed files with 8376 additions and 2796 deletions

View File

@@ -20,17 +20,12 @@ build-debug:
rules:
- if: $CI_COMMIT_TAG == null
script:
- shopt -s globstar
- mkdir ./artifacts
- dotnet restore --no-cache --force
- dotnet build -c Debug --nologo --no-restore --no-incremental
- mkdir ./artifacts
- mv ./AMWD.Protocols.Modbus.Common/bin/Debug/*.nupkg ./artifacts/
- mv ./AMWD.Protocols.Modbus.Common/bin/Debug/*.snupkg ./artifacts/
- mv ./AMWD.Protocols.Modbus.Proxy/bin/Debug/*.nupkg ./artifacts/
- mv ./AMWD.Protocols.Modbus.Proxy/bin/Debug/*.snupkg ./artifacts/
- mv ./AMWD.Protocols.Modbus.Serial/bin/Debug/*.nupkg ./artifacts/
- mv ./AMWD.Protocols.Modbus.Serial/bin/Debug/*.snupkg ./artifacts/
- mv ./AMWD.Protocols.Modbus.Tcp/bin/Debug/*.nupkg ./artifacts/
- mv ./AMWD.Protocols.Modbus.Tcp/bin/Debug/*.snupkg ./artifacts/
- mv ./**/*.nupkg ./artifacts/
- mv ./**/*.snupkg ./artifacts/
artifacts:
paths:
- artifacts/*.nupkg
@@ -47,10 +42,20 @@ test-debug:
- 64bit
rules:
- if: $CI_COMMIT_TAG == null
coverage: '/Total[^|]*\|[^|]*\|\s*([0-9.%]+)/'
coverage: /Branch coverage[\s\S].+%/
before_script:
- dotnet tool install dotnet-reportgenerator-globaltool --tool-path /dotnet-tools
script:
- dotnet restore --no-cache --force
- dotnet test -c Debug --nologo --no-restore
- dotnet test -c Debug --nologo /p:CoverletOutputFormat=Cobertura
- /dotnet-tools/reportgenerator "-reports:${CI_PROJECT_DIR}/**/coverage.cobertura.xml" "-targetdir:/reports" -reportType:TextSummary
after_script:
- cat /reports/Summary.txt
artifacts:
when: always
reports:
coverage_report:
coverage_format: cobertura
path: ./**/coverage.cobertura.xml
deploy-debug:
stage: deploy
@@ -77,22 +82,17 @@ build-release:
rules:
- if: $CI_COMMIT_TAG != null
script:
- shopt -s globstar
- mkdir ./artifacts
- dotnet restore --no-cache --force
- dotnet build -c Release --nologo --no-restore --no-incremental
- mkdir ./artifacts
- mv ./AMWD.Protocols.Modbus.Common/bin/Release/*.nupkg ./artifacts/
- mv ./AMWD.Protocols.Modbus.Common/bin/Release/*.snupkg ./artifacts/
- mv ./AMWD.Protocols.Modbus.Proxy/bin/Release/*.nupkg ./artifacts/
- mv ./AMWD.Protocols.Modbus.Proxy/bin/Release/*.snupkg ./artifacts/
- mv ./AMWD.Protocols.Modbus.Serial/bin/Release/*.nupkg ./artifacts/
- mv ./AMWD.Protocols.Modbus.Serial/bin/Release/*.snupkg ./artifacts/
- mv ./AMWD.Protocols.Modbus.Tcp/bin/Release/*.nupkg ./artifacts/
- mv ./AMWD.Protocols.Modbus.Tcp/bin/Release/*.snupkg ./artifacts/
- mv ./**/*.nupkg ./artifacts/
- mv ./**/*.snupkg ./artifacts/
artifacts:
paths:
- artifacts/*.nupkg
- artifacts/*.snupkg
expire_in: 1 days
expire_in: 7 days
test-release:
stage: test
@@ -104,10 +104,20 @@ test-release:
- amd64
rules:
- if: $CI_COMMIT_TAG != null
coverage: '/Total[^|]*\|[^|]*\|\s*([0-9.%]+)/'
coverage: /Branch coverage[\s\S].+%/
before_script:
- dotnet tool install dotnet-reportgenerator-globaltool --tool-path /dotnet-tools
script:
- dotnet restore --no-cache --force
- dotnet test -c Release --nologo --no-restore
- dotnet test -c Release --nologo /p:CoverletOutputFormat=Cobertura
- /dotnet-tools/reportgenerator "-reports:${CI_PROJECT_DIR}/**/coverage.cobertura.xml" "-targetdir:/reports" -reportType:TextSummary
after_script:
- cat /reports/Summary.txt
artifacts:
when: always
reports:
coverage_report:
coverage_format: cobertura
path: ./**/coverage.cobertura.xml
deploy-release:
stage: deploy

View File

@@ -2,7 +2,6 @@
<PropertyGroup>
<TargetFrameworks>netstandard2.0;net6.0;net8.0</TargetFrameworks>
<LangVersion>12.0</LangVersion>
<PackageId>AMWD.Protocols.Modbus.Common</PackageId>
<AssemblyName>amwd-modbus-common</AssemblyName>

View File

@@ -0,0 +1,24 @@
using System;
using System.Threading.Tasks;
using System.Threading;
namespace AMWD.Protocols.Modbus.Common.Contracts
{
/// <summary>
/// Represents a Modbus proxy.
/// </summary>
public interface IModbusProxy : IDisposable
{
/// <summary>
/// Starts the proxy.
/// </summary>
/// <param name="cancellationToken">A cancellation token used to propagate notification that this operation should be canceled.</param>
Task StartAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Stops the proxy.
/// </summary>
/// <param name="cancellationToken">A cancellation token used to propagate notification that this operation should be canceled.</param>
Task StopAsync(CancellationToken cancellationToken = default);
}
}

View File

@@ -1,9 +1,9 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using System.Threading;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace AMWD.Protocols.Modbus.Common.Contracts
{

View File

@@ -8,19 +8,26 @@ namespace AMWD.Protocols.Modbus.Common.Events
[System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
public class CoilWrittenEventArgs : EventArgs
{
internal CoilWrittenEventArgs(byte unitId, ushort address, bool value)
{
UnitId = unitId;
Address = address;
Value = value;
}
/// <summary>
/// Gets or sets the unit id.
/// </summary>
public byte UnitId { get; set; }
public byte UnitId { get; }
/// <summary>
/// Gets or sets the coil address.
/// </summary>
public ushort Address { get; set; }
public ushort Address { get; }
/// <summary>
/// Gets or sets the coil value.
/// </summary>
public bool Value { get; set; }
public bool Value { get; }
}
}

View File

@@ -8,29 +8,39 @@ namespace AMWD.Protocols.Modbus.Common.Events
[System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
public class RegisterWrittenEventArgs : EventArgs
{
internal RegisterWrittenEventArgs(byte unitId, ushort address, byte highByte, byte lowByte)
{
UnitId = unitId;
Address = address;
HighByte = highByte;
LowByte = lowByte;
Value = new[] { highByte, lowByte }.GetBigEndianUInt16();
}
/// <summary>
/// Gets or sets the unit id.
/// </summary>
public byte UnitId { get; set; }
public byte UnitId { get; }
/// <summary>
/// Gets or sets the address of the register.
/// </summary>
public ushort Address { get; set; }
public ushort Address { get; }
/// <summary>
/// Gets or sets the value of the register.
/// </summary>
public ushort Value { get; set; }
public ushort Value { get; }
/// <summary>
/// Gets or sets the high byte of the register.
/// </summary>
public byte HighByte { get; set; }
public byte HighByte { get; }
/// <summary>
/// Gets or sets the low byte of the register.
/// </summary>
public byte LowByte { get; set; }
public byte LowByte { get; }
}
}

View File

@@ -1,6 +1,8 @@
using System;
using System.Diagnostics.CodeAnalysis;
#if !NET8_0_OR_GREATER
using System.Runtime.Serialization;
#endif
namespace AMWD.Protocols.Modbus.Common
{

View File

@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace AMWD.Protocols.Modbus.Common
@@ -12,14 +13,14 @@ namespace AMWD.Protocols.Modbus.Common
Array.Reverse(bytes);
}
public static ushort GetBigEndianUInt16(this byte[] bytes, int offset = 0)
public static ushort GetBigEndianUInt16(this IReadOnlyList<byte> bytes, int offset = 0)
{
byte[] b = bytes.Skip(offset).Take(2).ToArray();
b.SwapBigEndian();
return BitConverter.ToUInt16(b, 0);
}
public static byte[] ToBigEndianBytes(this ushort value)
public static IReadOnlyList<byte> ToBigEndianBytes(this ushort value)
{
byte[] b = BitConverter.GetBytes(value);
b.SwapBigEndian();

View File

@@ -1,4 +0,0 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("AMWD.Protocols.Modbus.Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100adcc4f9f5bb3ac73cb30661f6f35772b8f90a74412925764a960af06ef125bdcec05ed1d139503d5203fb72aa3fa74bab58e82ac2a6cd4b650f8cbf7086a71bc2dfc67e95b8d26d776d60856acf3121f831529b1a4dee91b34ac84f95f71a1165b7783edb591929ba2a684100c92bbed8859c7266fb507f6f55bb6f7fcac80b4")]
[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2, PublicKey=0024000004800000940000000602000000240000525341310004000001000100c547cac37abd99c8db225ef2f6c8a3602f3b3606cc9891605d02baa56104f4cfc0734aa39b93bf7852f7d9266654753cc297e7d2edfe0bac1cdcf9f717241550e0a7b191195b7667bb4f64bcb8e2121380fd1d9d46ad2d92d2d15605093924cceaf74c4861eff62abf69b9291ed0a340e113be11e6a7d3113e92484cf7045cc7")]

View File

@@ -1,4 +1,5 @@
using System.Collections.Generic;
using System.Text;
namespace AMWD.Protocols.Modbus.Common
{
@@ -91,5 +92,23 @@ namespace AMWD.Protocols.Modbus.Common
/// Gets or sets a value indicating whether individual access (<see cref="ModbusDeviceIdentificationCategory.Individual"/>) is allowed.
/// </summary>
public bool IsIndividualAccessAllowed { get; set; }
/// <inheritdoc/>
public override string ToString()
{
var sb = new StringBuilder();
sb.AppendLine(nameof(DeviceIdentification));
sb.AppendLine($" {nameof(VendorName)}: {VendorName}");
sb.AppendLine($" {nameof(ProductCode)}: {ProductCode}");
sb.AppendLine($" {nameof(MajorMinorRevision)}: {MajorMinorRevision}");
sb.AppendLine($" {nameof(VendorUrl)}: {VendorUrl}");
sb.AppendLine($" {nameof(ProductName)}: {ProductName}");
sb.AppendLine($" {nameof(ModelName)}: {ModelName}");
sb.AppendLine($" {nameof(UserApplicationName)}: {UserApplicationName}");
sb.AppendLine($" {nameof(IsIndividualAccessAllowed)}: {IsIndividualAccessAllowed}");
return sb.ToString();
}
}
}

View File

@@ -17,15 +17,11 @@ namespace AMWD.Protocols.Modbus.Common
{
get
{
byte[] blob = [HighByte, LowByte];
blob.SwapBigEndian();
return BitConverter.ToUInt16(blob, 0);
return new[] { HighByte, LowByte }.GetBigEndianUInt16();
}
set
{
byte[] blob = BitConverter.GetBytes(value);
blob.SwapBigEndian();
var blob = value.ToBigEndianBytes();
HighByte = blob[0];
LowByte = blob[1];
}

View File

@@ -17,9 +17,7 @@ namespace AMWD.Protocols.Modbus.Common
{
get
{
byte[] blob = [HighByte, LowByte];
blob.SwapBigEndian();
return BitConverter.ToUInt16(blob, 0);
return new[] { HighByte, LowByte }.GetBigEndianUInt16();
}
}

View File

@@ -11,7 +11,7 @@ namespace AMWD.Protocols.Modbus.Common.Models
/// Initializes a new instance of the <see cref="ModbusDevice"/> class.
/// </remarks>
/// <param name="id">The <see cref="ModbusDevice"/> ID.</param>
public class ModbusDevice(byte id) : IDisposable
internal class ModbusDevice(byte id) : IDisposable
{
private readonly ReaderWriterLockSlim _rwLockCoils = new();
private readonly ReaderWriterLockSlim _rwLockDiscreteInputs = new();

View File

@@ -1,4 +1,6 @@
using System;
#if NET6_0_OR_GREATER
using System;
#endif
namespace AMWD.Protocols.Modbus.Common
{

View File

@@ -92,11 +92,11 @@ namespace AMWD.Protocols.Modbus.Common.Protocols
string request = $":{unitId:X2}{(byte)ModbusFunctionCode.ReadCoils:X2}";
// Starting address
byte[] addrBytes = startAddress.ToBigEndianBytes();
var addrBytes = startAddress.ToBigEndianBytes();
request += $"{addrBytes[0]:X2}{addrBytes[1]:X2}";
// Quantity
byte[] countBytes = count.ToBigEndianBytes();
var countBytes = count.ToBigEndianBytes();
request += $"{countBytes[0]:X2}{countBytes[1]:X2}";
// LRC
@@ -151,11 +151,11 @@ namespace AMWD.Protocols.Modbus.Common.Protocols
string request = $":{unitId:X2}{(byte)ModbusFunctionCode.ReadDiscreteInputs:X2}";
// Starting address
byte[] addrBytes = startAddress.ToBigEndianBytes();
var addrBytes = startAddress.ToBigEndianBytes();
request += $"{addrBytes[0]:X2}{addrBytes[1]:X2}";
// Quantity
byte[] countBytes = count.ToBigEndianBytes();
var countBytes = count.ToBigEndianBytes();
request += $"{countBytes[0]:X2}{countBytes[1]:X2}";
// LRC
@@ -209,11 +209,11 @@ namespace AMWD.Protocols.Modbus.Common.Protocols
string request = $":{unitId:X2}{(byte)ModbusFunctionCode.ReadHoldingRegisters:X2}";
// Starting address
byte[] addrBytes = startAddress.ToBigEndianBytes();
var addrBytes = startAddress.ToBigEndianBytes();
request += $"{addrBytes[0]:X2}{addrBytes[1]:X2}";
// Quantity
byte[] countBytes = count.ToBigEndianBytes();
var countBytes = count.ToBigEndianBytes();
request += $"{countBytes[0]:X2}{countBytes[1]:X2}";
// LRC
@@ -264,11 +264,11 @@ namespace AMWD.Protocols.Modbus.Common.Protocols
string request = $":{unitId:X2}{(byte)ModbusFunctionCode.ReadInputRegisters:X2}";
// Starting address
byte[] addrBytes = startAddress.ToBigEndianBytes();
var addrBytes = startAddress.ToBigEndianBytes();
request += $"{addrBytes[0]:X2}{addrBytes[1]:X2}";
// Quantity
byte[] countBytes = count.ToBigEndianBytes();
var countBytes = count.ToBigEndianBytes();
request += $"{countBytes[0]:X2}{countBytes[1]:X2}";
// LRC
@@ -383,7 +383,7 @@ namespace AMWD.Protocols.Modbus.Common.Protocols
string request = $":{unitId:X2}{(byte)ModbusFunctionCode.WriteSingleCoil:X2}";
// Starting address
byte[] addrBytes = coil.Address.ToBigEndianBytes();
var addrBytes = coil.Address.ToBigEndianBytes();
request += $"{addrBytes[0]:X2}{addrBytes[1]:X2}";
// Value
@@ -426,7 +426,7 @@ namespace AMWD.Protocols.Modbus.Common.Protocols
string request = $":{unitId:X2}{(byte)ModbusFunctionCode.WriteSingleRegister:X2}";
// Starting address
byte[] addrBytes = register.Address.ToBigEndianBytes();
var addrBytes = register.Address.ToBigEndianBytes();
request += $"{addrBytes[0]:X2}{addrBytes[1]:X2}";
// Value
@@ -497,11 +497,11 @@ namespace AMWD.Protocols.Modbus.Common.Protocols
string request = $":{unitId:X2}{(byte)ModbusFunctionCode.WriteMultipleCoils:X2}";
// Starting address
byte[] addrBytes = firstAddress.ToBigEndianBytes();
var addrBytes = firstAddress.ToBigEndianBytes();
request += $"{addrBytes[0]:X2}{addrBytes[1]:X2}";
// Quantity
byte[] countBytes = ((ushort)orderedList.Count).ToBigEndianBytes();
var countBytes = ((ushort)orderedList.Count).ToBigEndianBytes();
request += $"{countBytes[0]:X2}{countBytes[1]:X2}";
// Byte count
@@ -567,11 +567,11 @@ namespace AMWD.Protocols.Modbus.Common.Protocols
string request = $":{unitId:X2}{(byte)ModbusFunctionCode.WriteMultipleRegisters:X2}";
// Starting address
byte[] addrBytes = firstAddress.ToBigEndianBytes();
var addrBytes = firstAddress.ToBigEndianBytes();
request += $"{addrBytes[0]:X2}{addrBytes[1]:X2}";
// Quantity
byte[] countBytes = ((ushort)orderedList.Count).ToBigEndianBytes();
var countBytes = ((ushort)orderedList.Count).ToBigEndianBytes();
request += $"{countBytes[0]:X2}{countBytes[1]:X2}";
// Byte count
@@ -675,6 +675,10 @@ namespace AMWD.Protocols.Modbus.Common.Protocols
/// <summary>
/// Calculate LRC for Modbus ASCII.
/// </summary>
/// <remarks>
/// The LRC calculation algorithm is defined in the Modbus serial line specification.
/// See <see href="https://modbus.org/docs/Modbus_over_serial_line_V1_02.pdf">Modbus over Serial Line v1.02</see>, Appendix B, page 38.
/// </remarks>
/// <param name="message">The message chars.</param>
/// <param name="start">The start index.</param>
/// <param name="length">The number of bytes to calculate.</param>

View File

@@ -10,7 +10,9 @@ namespace AMWD.Protocols.Modbus.Common.Protocols
/// </summary>
/// <remarks>
/// The Modbus RTU over Modbus TCP is rarely used.
/// It is a non-standard variant of Modbus TCP that includes wrapps a Modbus RTU message within a Modbus TCP message.
/// It is a non-standard variant:
/// You can define it as RTU message with an additional TCP header
/// or as TCP message with an additional CRC16 checksum at the end (header not included!).
/// <br/>
/// Definition found on <see href="https://www.fernhillsoftware.com/help/drivers/modbus/modbus-protocol.html">Fernhill Software</see>.
/// </remarks>
@@ -117,12 +119,12 @@ namespace AMWD.Protocols.Modbus.Common.Protocols
request[7] = (byte)ModbusFunctionCode.ReadCoils;
// Starting address
byte[] addrBytes = startAddress.ToBigEndianBytes();
var addrBytes = startAddress.ToBigEndianBytes();
request[8] = addrBytes[0];
request[9] = addrBytes[1];
// Quantity
byte[] countBytes = count.ToBigEndianBytes();
var countBytes = count.ToBigEndianBytes();
request[10] = countBytes[0];
request[11] = countBytes[1];
@@ -180,12 +182,12 @@ namespace AMWD.Protocols.Modbus.Common.Protocols
request[7] = (byte)ModbusFunctionCode.ReadDiscreteInputs;
// Starting address
byte[] addrBytes = startAddress.ToBigEndianBytes();
var addrBytes = startAddress.ToBigEndianBytes();
request[8] = addrBytes[0];
request[9] = addrBytes[1];
// Quantity
byte[] countBytes = count.ToBigEndianBytes();
var countBytes = count.ToBigEndianBytes();
request[10] = countBytes[0];
request[11] = countBytes[1];
@@ -243,12 +245,12 @@ namespace AMWD.Protocols.Modbus.Common.Protocols
request[7] = (byte)ModbusFunctionCode.ReadHoldingRegisters;
// Starting address
byte[] addrBytes = startAddress.ToBigEndianBytes();
var addrBytes = startAddress.ToBigEndianBytes();
request[8] = addrBytes[0];
request[9] = addrBytes[1];
// Quantity
byte[] countBytes = count.ToBigEndianBytes();
var countBytes = count.ToBigEndianBytes();
request[10] = countBytes[0];
request[11] = countBytes[1];
@@ -303,12 +305,12 @@ namespace AMWD.Protocols.Modbus.Common.Protocols
request[7] = (byte)ModbusFunctionCode.ReadInputRegisters;
// Starting address
byte[] addrBytes = startAddress.ToBigEndianBytes();
var addrBytes = startAddress.ToBigEndianBytes();
request[8] = addrBytes[0];
request[9] = addrBytes[1];
// Quantity
byte[] countBytes = count.ToBigEndianBytes();
var countBytes = count.ToBigEndianBytes();
request[10] = countBytes[0];
request[11] = countBytes[1];
@@ -430,7 +432,7 @@ namespace AMWD.Protocols.Modbus.Common.Protocols
// Function code
request[7] = (byte)ModbusFunctionCode.WriteSingleCoil;
byte[] addrBytes = coil.Address.ToBigEndianBytes();
var addrBytes = coil.Address.ToBigEndianBytes();
request[8] = addrBytes[0];
request[9] = addrBytes[1];
@@ -477,7 +479,7 @@ namespace AMWD.Protocols.Modbus.Common.Protocols
// Function code
request[7] = (byte)ModbusFunctionCode.WriteSingleRegister;
byte[] addrBytes = register.Address.ToBigEndianBytes();
var addrBytes = register.Address.ToBigEndianBytes();
request[8] = addrBytes[0];
request[9] = addrBytes[1];
@@ -540,12 +542,12 @@ namespace AMWD.Protocols.Modbus.Common.Protocols
request[7] = (byte)ModbusFunctionCode.WriteMultipleCoils;
// Starting address
byte[] addrBytes = firstAddress.ToBigEndianBytes();
var addrBytes = firstAddress.ToBigEndianBytes();
request[8] = addrBytes[0];
request[9] = addrBytes[1];
// Quantity
byte[] countBytes = ((ushort)orderedList.Count).ToBigEndianBytes();
var countBytes = ((ushort)orderedList.Count).ToBigEndianBytes();
request[10] = countBytes[0];
request[11] = countBytes[1];
@@ -620,12 +622,12 @@ namespace AMWD.Protocols.Modbus.Common.Protocols
request[7] = (byte)ModbusFunctionCode.WriteMultipleRegisters;
// Starting address
byte[] addrBytes = firstAddress.ToBigEndianBytes();
var addrBytes = firstAddress.ToBigEndianBytes();
request[8] = addrBytes[0];
request[9] = addrBytes[1];
// Quantity
byte[] countBytes = ((ushort)orderedList.Count).ToBigEndianBytes();
var countBytes = ((ushort)orderedList.Count).ToBigEndianBytes();
request[10] = countBytes[0];
request[11] = countBytes[1];
@@ -745,7 +747,7 @@ namespace AMWD.Protocols.Modbus.Common.Protocols
// Transaction id
ushort txId = GetNextTransacitonId();
byte[] txBytes = txId.ToBigEndianBytes();
var txBytes = txId.ToBigEndianBytes();
header[0] = txBytes[0];
header[1] = txBytes[1];
@@ -754,7 +756,7 @@ namespace AMWD.Protocols.Modbus.Common.Protocols
header[3] = 0x00;
// Number of following bytes
byte[] countBytes = ((ushort)followingBytes).ToBigEndianBytes();
var countBytes = ((ushort)followingBytes).ToBigEndianBytes();
header[4] = countBytes[0];
header[5] = countBytes[1];

View File

@@ -96,12 +96,12 @@ namespace AMWD.Protocols.Modbus.Common.Protocols
request[1] = (byte)ModbusFunctionCode.ReadCoils;
// Starting address
byte[] addrBytes = startAddress.ToBigEndianBytes();
var addrBytes = startAddress.ToBigEndianBytes();
request[2] = addrBytes[0];
request[3] = addrBytes[1];
// Quantity
byte[] countBytes = count.ToBigEndianBytes();
var countBytes = count.ToBigEndianBytes();
request[4] = countBytes[0];
request[5] = countBytes[1];
@@ -156,12 +156,12 @@ namespace AMWD.Protocols.Modbus.Common.Protocols
request[1] = (byte)ModbusFunctionCode.ReadDiscreteInputs;
// Starting address
byte[] addrBytes = startAddress.ToBigEndianBytes();
var addrBytes = startAddress.ToBigEndianBytes();
request[2] = addrBytes[0];
request[3] = addrBytes[1];
// Quantity
byte[] countBytes = count.ToBigEndianBytes();
var countBytes = count.ToBigEndianBytes();
request[4] = countBytes[0];
request[5] = countBytes[1];
@@ -216,12 +216,12 @@ namespace AMWD.Protocols.Modbus.Common.Protocols
request[1] = (byte)ModbusFunctionCode.ReadHoldingRegisters;
// Starting address
byte[] addrBytes = startAddress.ToBigEndianBytes();
var addrBytes = startAddress.ToBigEndianBytes();
request[2] = addrBytes[0];
request[3] = addrBytes[1];
// Quantity
byte[] countBytes = count.ToBigEndianBytes();
var countBytes = count.ToBigEndianBytes();
request[4] = countBytes[0];
request[5] = countBytes[1];
@@ -273,12 +273,12 @@ namespace AMWD.Protocols.Modbus.Common.Protocols
request[1] = (byte)ModbusFunctionCode.ReadInputRegisters;
// Starting address
byte[] addrBytes = startAddress.ToBigEndianBytes();
var addrBytes = startAddress.ToBigEndianBytes();
request[2] = addrBytes[0];
request[3] = addrBytes[1];
// Quantity
byte[] countBytes = count.ToBigEndianBytes();
var countBytes = count.ToBigEndianBytes();
request[4] = countBytes[0];
request[5] = countBytes[1];
@@ -394,7 +394,7 @@ namespace AMWD.Protocols.Modbus.Common.Protocols
// Function code
request[1] = (byte)ModbusFunctionCode.WriteSingleCoil;
byte[] addrBytes = coil.Address.ToBigEndianBytes();
var addrBytes = coil.Address.ToBigEndianBytes();
request[2] = addrBytes[0];
request[3] = addrBytes[1];
@@ -438,7 +438,7 @@ namespace AMWD.Protocols.Modbus.Common.Protocols
// Function code
request[1] = (byte)ModbusFunctionCode.WriteSingleRegister;
byte[] addrBytes = register.Address.ToBigEndianBytes();
var addrBytes = register.Address.ToBigEndianBytes();
request[2] = addrBytes[0];
request[3] = addrBytes[1];
@@ -495,11 +495,11 @@ namespace AMWD.Protocols.Modbus.Common.Protocols
request[1] = (byte)ModbusFunctionCode.WriteMultipleCoils;
byte[] addrBytes = firstAddress.ToBigEndianBytes();
var addrBytes = firstAddress.ToBigEndianBytes();
request[2] = addrBytes[0];
request[3] = addrBytes[1];
byte[] countBytes = ((ushort)orderedList.Count).ToBigEndianBytes();
var countBytes = ((ushort)orderedList.Count).ToBigEndianBytes();
request[4] = countBytes[0];
request[5] = countBytes[1];
@@ -565,11 +565,11 @@ namespace AMWD.Protocols.Modbus.Common.Protocols
request[0] = unitId;
request[1] = (byte)ModbusFunctionCode.WriteMultipleRegisters;
byte[] addrBytes = firstAddress.ToBigEndianBytes();
var addrBytes = firstAddress.ToBigEndianBytes();
request[2] = addrBytes[0];
request[3] = addrBytes[1];
byte[] countBytes = ((ushort)orderedList.Count).ToBigEndianBytes();
var countBytes = ((ushort)orderedList.Count).ToBigEndianBytes();
request[4] = countBytes[0];
request[5] = countBytes[1];
@@ -733,6 +733,10 @@ namespace AMWD.Protocols.Modbus.Common.Protocols
/// <summary>
/// Calculate CRC16 for Modbus RTU.
/// </summary>
/// <remarks>
/// The CRC 16 calculation algorithm is defined in the Modbus serial line specification.
/// See <see href="https://modbus.org/docs/Modbus_over_serial_line_V1_02.pdf">Modbus over Serial Line v1.02</see>, Appendix B, page 40.
/// </remarks>
/// <param name="bytes">The message bytes.</param>
/// <param name="start">The start index.</param>
/// <param name="length">The number of bytes to calculate.</param>

View File

@@ -101,12 +101,12 @@ namespace AMWD.Protocols.Modbus.Common.Protocols
request[7] = (byte)ModbusFunctionCode.ReadCoils;
// Starting address
byte[] addrBytes = startAddress.ToBigEndianBytes();
var addrBytes = startAddress.ToBigEndianBytes();
request[8] = addrBytes[0];
request[9] = addrBytes[1];
// Quantity
byte[] countBytes = count.ToBigEndianBytes();
var countBytes = count.ToBigEndianBytes();
request[10] = countBytes[0];
request[11] = countBytes[1];
@@ -159,12 +159,12 @@ namespace AMWD.Protocols.Modbus.Common.Protocols
request[7] = (byte)ModbusFunctionCode.ReadDiscreteInputs;
// Starting address
byte[] addrBytes = startAddress.ToBigEndianBytes();
var addrBytes = startAddress.ToBigEndianBytes();
request[8] = addrBytes[0];
request[9] = addrBytes[1];
// Quantity
byte[] countBytes = count.ToBigEndianBytes();
var countBytes = count.ToBigEndianBytes();
request[10] = countBytes[0];
request[11] = countBytes[1];
@@ -217,12 +217,12 @@ namespace AMWD.Protocols.Modbus.Common.Protocols
request[7] = (byte)ModbusFunctionCode.ReadHoldingRegisters;
// Starting address
byte[] addrBytes = startAddress.ToBigEndianBytes();
var addrBytes = startAddress.ToBigEndianBytes();
request[8] = addrBytes[0];
request[9] = addrBytes[1];
// Quantity
byte[] countBytes = count.ToBigEndianBytes();
var countBytes = count.ToBigEndianBytes();
request[10] = countBytes[0];
request[11] = countBytes[1];
@@ -272,12 +272,12 @@ namespace AMWD.Protocols.Modbus.Common.Protocols
request[7] = (byte)ModbusFunctionCode.ReadInputRegisters;
// Starting address
byte[] addrBytes = startAddress.ToBigEndianBytes();
var addrBytes = startAddress.ToBigEndianBytes();
request[8] = addrBytes[0];
request[9] = addrBytes[1];
// Quantity
byte[] countBytes = count.ToBigEndianBytes();
var countBytes = count.ToBigEndianBytes();
request[10] = countBytes[0];
request[11] = countBytes[1];
@@ -389,7 +389,7 @@ namespace AMWD.Protocols.Modbus.Common.Protocols
// Function code
request[7] = (byte)ModbusFunctionCode.WriteSingleCoil;
byte[] addrBytes = coil.Address.ToBigEndianBytes();
var addrBytes = coil.Address.ToBigEndianBytes();
request[8] = addrBytes[0];
request[9] = addrBytes[1];
@@ -431,7 +431,7 @@ namespace AMWD.Protocols.Modbus.Common.Protocols
// Function code
request[7] = (byte)ModbusFunctionCode.WriteSingleRegister;
byte[] addrBytes = register.Address.ToBigEndianBytes();
var addrBytes = register.Address.ToBigEndianBytes();
request[8] = addrBytes[0];
request[9] = addrBytes[1];
@@ -489,12 +489,12 @@ namespace AMWD.Protocols.Modbus.Common.Protocols
request[7] = (byte)ModbusFunctionCode.WriteMultipleCoils;
// Starting address
byte[] addrBytes = firstAddress.ToBigEndianBytes();
var addrBytes = firstAddress.ToBigEndianBytes();
request[8] = addrBytes[0];
request[9] = addrBytes[1];
// Quantity
byte[] countBytes = ((ushort)orderedList.Count).ToBigEndianBytes();
var countBytes = ((ushort)orderedList.Count).ToBigEndianBytes();
request[10] = countBytes[0];
request[11] = countBytes[1];
@@ -564,12 +564,12 @@ namespace AMWD.Protocols.Modbus.Common.Protocols
request[7] = (byte)ModbusFunctionCode.WriteMultipleRegisters;
// Starting address
byte[] addrBytes = firstAddress.ToBigEndianBytes();
var addrBytes = firstAddress.ToBigEndianBytes();
request[8] = addrBytes[0];
request[9] = addrBytes[1];
// Quantity
byte[] countBytes = ((ushort)orderedList.Count).ToBigEndianBytes();
var countBytes = ((ushort)orderedList.Count).ToBigEndianBytes();
request[10] = countBytes[0];
request[11] = countBytes[1];
@@ -678,7 +678,7 @@ namespace AMWD.Protocols.Modbus.Common.Protocols
// Transaction id
ushort txId = GetNextTransacitonId();
byte[] txBytes = txId.ToBigEndianBytes();
var txBytes = txId.ToBigEndianBytes();
header[0] = txBytes[0];
header[1] = txBytes[1];
@@ -687,7 +687,7 @@ namespace AMWD.Protocols.Modbus.Common.Protocols
header[3] = 0x00;
// Number of following bytes
byte[] countBytes = ((ushort)followingBytes).ToBigEndianBytes();
var countBytes = ((ushort)followingBytes).ToBigEndianBytes();
header[4] = countBytes[0];
header[5] = countBytes[1];

View File

@@ -0,0 +1,478 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using AMWD.Protocols.Modbus.Common.Contracts;
using AMWD.Protocols.Modbus.Common.Events;
using AMWD.Protocols.Modbus.Common.Models;
namespace AMWD.Protocols.Modbus.Common.Protocols
{
[System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
internal class VirtualProtocol : IModbusProtocol, IDisposable
{
#region Fields
private bool _isDisposed;
private readonly ReaderWriterLockSlim _deviceListLock = new();
private readonly Dictionary<byte, ModbusDevice> _devices = [];
#endregion Fields
public void Dispose()
{
if (_isDisposed)
return;
_isDisposed = true;
_deviceListLock.Dispose();
foreach (var device in _devices.Values)
device.Dispose();
_devices.Clear();
}
#region Events
public event EventHandler<CoilWrittenEventArgs> CoilWritten;
public event EventHandler<RegisterWrittenEventArgs> RegisterWritten;
#endregion Events
#region Properties
public string Name => nameof(VirtualProtocol);
#endregion Properties
#region Protocol
public bool CheckResponseComplete(IReadOnlyList<byte> responseBytes) => true;
public IReadOnlyList<Coil> DeserializeReadCoils(IReadOnlyList<byte> response)
{
if (!_devices.TryGetValue(response[0], out var device))
throw new TimeoutException("Device not found.");
ushort start = response.GetBigEndianUInt16(1);
ushort count = response.GetBigEndianUInt16(3);
return Enumerable.Range(0, count)
.Select(i => device.GetCoil((ushort)(start + i)))
.ToList();
}
public DeviceIdentificationRaw DeserializeReadDeviceIdentification(IReadOnlyList<byte> response)
{
if (!_devices.TryGetValue(response[0], out var device))
throw new TimeoutException("Device not found.");
var result = new DeviceIdentificationRaw
{
AllowsIndividualAccess = false,
MoreRequestsNeeded = false,
Objects = []
};
if (response[1] >= 1)
{
string version = GetType().Assembly
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()
.InformationalVersion;
result.Objects.Add(0, Encoding.UTF8.GetBytes("AM.WD"));
result.Objects.Add(1, Encoding.UTF8.GetBytes("AMWD.Protocols.Modbus"));
result.Objects.Add(2, Encoding.UTF8.GetBytes(version));
}
if (response[1] >= 2)
{
result.Objects.Add(3, Encoding.UTF8.GetBytes("https://github.com/AM-WD/AMWD.Protocols.Modbus"));
result.Objects.Add(4, Encoding.UTF8.GetBytes("Modbus Protocol for .NET"));
result.Objects.Add(5, Encoding.UTF8.GetBytes("Virtual Device"));
result.Objects.Add(6, Encoding.UTF8.GetBytes("Virtual Modbus Client"));
}
if (response[1] >= 3)
{
for (int i = 128; i < 256; i++)
result.Objects.Add((byte)i, []);
}
return result;
}
public IReadOnlyList<DiscreteInput> DeserializeReadDiscreteInputs(IReadOnlyList<byte> response)
{
if (!_devices.TryGetValue(response[0], out var device))
throw new TimeoutException("Device not found.");
ushort start = response.GetBigEndianUInt16(1);
ushort count = response.GetBigEndianUInt16(3);
return Enumerable.Range(0, count)
.Select(i => device.GetDiscreteInput((ushort)(start + i)))
.ToList();
}
public IReadOnlyList<HoldingRegister> DeserializeReadHoldingRegisters(IReadOnlyList<byte> response)
{
if (!_devices.TryGetValue(response[0], out var device))
throw new TimeoutException("Device not found.");
ushort start = response.GetBigEndianUInt16(1);
ushort count = response.GetBigEndianUInt16(3);
return Enumerable.Range(0, count)
.Select(i => device.GetHoldingRegister((ushort)(start + i)))
.ToList();
}
public IReadOnlyList<InputRegister> DeserializeReadInputRegisters(IReadOnlyList<byte> response)
{
if (!_devices.TryGetValue(response[0], out var device))
throw new TimeoutException("Device not found.");
ushort start = response.GetBigEndianUInt16(1);
ushort count = response.GetBigEndianUInt16(3);
return Enumerable.Range(0, count)
.Select(i => device.GetInputRegister((ushort)(start + i)))
.ToList();
}
public (ushort FirstAddress, ushort NumberOfCoils) DeserializeWriteMultipleCoils(IReadOnlyList<byte> response)
{
if (!_devices.TryGetValue(response[0], out var device))
throw new TimeoutException("Device not found.");
ushort start = response.GetBigEndianUInt16(1);
ushort count = response.GetBigEndianUInt16(3);
for (int i = 0; i < count; i++)
{
var coil = new Coil
{
Address = (ushort)(start + i),
HighByte = response[5 + i]
};
device.SetCoil(coil);
Task.Run(() =>
{
try
{
CoilWritten?.Invoke(this, new CoilWrittenEventArgs(
unitId: response[0],
address: coil.Address,
value: coil.Value));
}
catch
{
// ignore
}
});
}
return (start, count);
}
public (ushort FirstAddress, ushort NumberOfRegisters) DeserializeWriteMultipleHoldingRegisters(IReadOnlyList<byte> response)
{
if (!_devices.TryGetValue(response[0], out var device))
throw new TimeoutException("Device not found.");
ushort start = response.GetBigEndianUInt16(1);
ushort count = response.GetBigEndianUInt16(3);
for (int i = 0; i < count; i++)
{
var register = new HoldingRegister
{
Address = (ushort)(start + i),
HighByte = response[5 + i * 2],
LowByte = response[5 + i * 2 + 1]
};
device.SetHoldingRegister(register);
Task.Run(() =>
{
try
{
RegisterWritten?.Invoke(this, new RegisterWrittenEventArgs(
unitId: response[0],
address: register.Address,
highByte: register.HighByte,
lowByte: register.LowByte));
}
catch
{
// ignore
}
});
}
return (start, count);
}
public Coil DeserializeWriteSingleCoil(IReadOnlyList<byte> response)
{
if (!_devices.TryGetValue(response[0], out var device))
throw new TimeoutException("Device not found.");
var coil = new Coil
{
Address = response.GetBigEndianUInt16(1),
HighByte = response[3]
};
device.SetCoil(coil);
Task.Run(() =>
{
try
{
CoilWritten?.Invoke(this, new CoilWrittenEventArgs(
unitId: response[0],
address: coil.Address,
value: coil.Value));
}
catch
{
// ignore
}
});
return coil;
}
public HoldingRegister DeserializeWriteSingleHoldingRegister(IReadOnlyList<byte> response)
{
if (!_devices.TryGetValue(response[0], out var device))
throw new TimeoutException("Device not found.");
var register = new HoldingRegister
{
Address = response.GetBigEndianUInt16(1),
HighByte = response[3],
LowByte = response[4]
};
device.SetHoldingRegister(register);
Task.Run(() =>
{
try
{
RegisterWritten?.Invoke(this, new RegisterWrittenEventArgs(
unitId: response[0],
address: register.Address,
highByte: register.HighByte,
lowByte: register.LowByte));
}
catch
{
// ignore
}
});
return register;
}
public IReadOnlyList<byte> SerializeReadCoils(byte unitId, ushort startAddress, ushort count)
{
return [unitId, .. startAddress.ToBigEndianBytes(), .. count.ToBigEndianBytes()];
}
public IReadOnlyList<byte> SerializeReadDeviceIdentification(byte unitId, ModbusDeviceIdentificationCategory category, ModbusDeviceIdentificationObject objectId)
{
if (!Enum.IsDefined(typeof(ModbusDeviceIdentificationCategory), category))
throw new ArgumentOutOfRangeException(nameof(category));
return [unitId, (byte)category];
}
public IReadOnlyList<byte> SerializeReadDiscreteInputs(byte unitId, ushort startAddress, ushort count)
{
return [unitId, .. startAddress.ToBigEndianBytes(), .. count.ToBigEndianBytes()];
}
public IReadOnlyList<byte> SerializeReadHoldingRegisters(byte unitId, ushort startAddress, ushort count)
{
return [unitId, .. startAddress.ToBigEndianBytes(), .. count.ToBigEndianBytes()];
}
public IReadOnlyList<byte> SerializeReadInputRegisters(byte unitId, ushort startAddress, ushort count)
{
return [unitId, .. startAddress.ToBigEndianBytes(), .. count.ToBigEndianBytes()];
}
public IReadOnlyList<byte> SerializeWriteMultipleCoils(byte unitId, IReadOnlyList<Coil> coils)
{
ushort start = coils.OrderBy(c => c.Address).First().Address;
ushort count = (ushort)coils.Count;
byte[] values = coils.Select(c => c.HighByte).ToArray();
return [unitId, .. start.ToBigEndianBytes(), .. count.ToBigEndianBytes(), .. values];
}
public IReadOnlyList<byte> SerializeWriteMultipleHoldingRegisters(byte unitId, IReadOnlyList<HoldingRegister> registers)
{
ushort start = registers.OrderBy(c => c.Address).First().Address;
ushort count = (ushort)registers.Count;
byte[] values = registers.SelectMany(r => new[] { r.HighByte, r.LowByte }).ToArray();
return [unitId, .. start.ToBigEndianBytes(), .. count.ToBigEndianBytes(), .. values];
}
public IReadOnlyList<byte> SerializeWriteSingleCoil(byte unitId, Coil coil)
{
return [unitId, .. coil.Address.ToBigEndianBytes(), coil.HighByte];
}
public IReadOnlyList<byte> SerializeWriteSingleHoldingRegister(byte unitId, HoldingRegister register)
{
return [unitId, .. register.Address.ToBigEndianBytes(), register.HighByte, register.LowByte];
}
public void ValidateResponse(IReadOnlyList<byte> request, IReadOnlyList<byte> response)
{
if (!request.SequenceEqual(response))
throw new InvalidOperationException("Request and response have to be the same on virtual protocol.");
}
#endregion Protocol
#region Device Handling
public bool AddDevice(byte unitId)
{
Assertions();
using (_deviceListLock.GetWriteLock())
{
if (_devices.ContainsKey(unitId))
return false;
_devices.Add(unitId, new ModbusDevice(unitId));
return true;
}
}
public bool RemoveDevice(byte unitId)
{
Assertions();
using (_deviceListLock.GetWriteLock())
{
if (_devices.ContainsKey(unitId))
return false;
return _devices.Remove(unitId);
}
}
#endregion Device Handling
#region Entity Handling
public Coil GetCoil(byte unitId, ushort address)
{
Assertions();
using (_deviceListLock.GetReadLock())
{
return _devices.TryGetValue(unitId, out var device)
? device.GetCoil(address)
: null;
}
}
public void SetCoil(byte unitId, Coil coil)
{
Assertions();
using (_deviceListLock.GetWriteLock())
{
if (_devices.TryGetValue(unitId, out var device))
device.SetCoil(coil);
}
}
public DiscreteInput GetDiscreteInput(byte unitId, ushort address)
{
Assertions();
using (_deviceListLock.GetReadLock())
{
return _devices.TryGetValue(unitId, out var device)
? device.GetDiscreteInput(address)
: null;
}
}
public void SetDiscreteInput(byte unitId, DiscreteInput discreteInput)
{
Assertions();
using (_deviceListLock.GetWriteLock())
{
if (_devices.TryGetValue(unitId, out var device))
device.SetDiscreteInput(discreteInput);
}
}
public HoldingRegister GetHoldingRegister(byte unitId, ushort address)
{
Assertions();
using (_deviceListLock.GetReadLock())
{
return _devices.TryGetValue(unitId, out var device)
? device.GetHoldingRegister(address)
: null;
}
}
public void SetHoldingRegister(byte unitId, HoldingRegister holdingRegister)
{
Assertions();
using (_deviceListLock.GetWriteLock())
{
if (_devices.TryGetValue(unitId, out var device))
device.SetHoldingRegister(holdingRegister);
}
}
public InputRegister GetInputRegister(byte unitId, ushort address)
{
Assertions();
using (_deviceListLock.GetReadLock())
{
return _devices.TryGetValue(unitId, out var device)
? device.GetInputRegister(address)
: null;
}
}
public void SetInputRegister(byte unitId, InputRegister inputRegister)
{
Assertions();
using (_deviceListLock.GetWriteLock())
{
if (_devices.TryGetValue(unitId, out var device))
device.SetInputRegister(inputRegister);
}
}
#endregion Entity Handling
private void Assertions()
{
#if NET8_0_OR_GREATER
ObjectDisposedException.ThrowIf(_isDisposed, this);
#else
if (_isDisposed)
throw new ObjectDisposedException(GetType().Name);
#endif
}
}
}

View File

@@ -50,7 +50,8 @@ The different types handled by the Modbus Protocol.
In addition, you'll find the `DeviceIdentification` there.
It is used for a "special" function called _Read Device Identification_ (0x2B / 43), not supported on all devices.
The `ModbusDevice` is used for the server implementations in the derived packages.
The `ModbusDevice` is used for the `VirtualModbusClient`.
In combination with the *Proxy implementations (in the derived packages) it can be used as server.
### Protocols
@@ -68,9 +69,10 @@ This validation is _not_ implemented here due to real world experience, that som
---
Published under MIT License (see [**tl;dr**Legal])
Published under MIT License (see [choose a license])
[RTU over TCP]: https://www.fernhillsoftware.com/help/drivers/modbus/modbus-protocol.html
[**tl;dr**Legal]: https://www.tldrlegal.com/license/mit-license
[RTU over TCP]: https://www.fernhillsoftware.com/help/drivers/modbus/modbus-protocol.html
[choose a license]: https://choosealicense.com/licenses/mit/

View File

@@ -0,0 +1,180 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using System.Threading;
using AMWD.Protocols.Modbus.Common.Contracts;
using AMWD.Protocols.Modbus.Common.Events;
using AMWD.Protocols.Modbus.Common.Models;
using AMWD.Protocols.Modbus.Common.Protocols;
namespace AMWD.Protocols.Modbus.Common.Utils
{
/// <summary>
/// Implements a virtual Modbus client.
/// </summary>
[System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
public class VirtualModbusClient : ModbusClientBase
{
#region Constructor
/// <summary>
/// Initializes a new instance of the <see cref="VirtualModbusClient"/> class.
/// </summary>
/// <remarks><strong>DO NOT MODIFY</strong> connection or protocol.</remarks>
public VirtualModbusClient()
: base(new VirtualConnection())
{
Protocol = new VirtualProtocol();
TypedProtocol.CoilWritten += (sender, e) => CoilWritten?.Invoke(this, e);
TypedProtocol.RegisterWritten += (sender, e) => RegisterWritten?.Invoke(this, e);
}
#endregion Constructor
#region Events
/// <summary>
/// Indicates that a <see cref="Coil"/>-value received through a remote client has been written.
/// </summary>
public event EventHandler<CoilWrittenEventArgs> CoilWritten;
/// <summary>
/// Indicates that a <see cref="HoldingRegister"/>-value received from a remote client has been written.
/// </summary>
public event EventHandler<RegisterWrittenEventArgs> RegisterWritten;
#endregion Events
#region Properties
internal VirtualProtocol TypedProtocol
=> Protocol as VirtualProtocol;
#endregion Properties
#region Device Handling
/// <summary>
/// Adds a device to the virtual client.
/// </summary>
/// <param name="unitId">The unit id of the device.</param>
/// <returns><see langword="true"/> if the device was added successfully, <see langword="false"/> otherwise.</returns>
public bool AddDevice(byte unitId)
=> TypedProtocol.AddDevice(unitId);
/// <summary>
/// Removes a device from the virtual client.
/// </summary>
/// <param name="unitId">The unit id of the device.</param>
/// <returns><see langword="true"/> if the device was removed successfully, <see langword="false"/> otherwise.</returns>
public bool RemoveDevice(byte unitId)
=> TypedProtocol.RemoveDevice(unitId);
#endregion Device Handling
#region Entity Handling
/// <summary>
/// Gets a <see cref="Coil"/> from the specified <see cref="ModbusDevice"/>.
/// </summary>
/// <param name="unitId">The unit ID of the device.</param>
/// <param name="address">The address of the <see cref="Coil"/>.</param>
public Coil GetCoil(byte unitId, ushort address)
=> TypedProtocol.GetCoil(unitId, address);
/// <summary>
/// Sets a <see cref="Coil"/> to the specified <see cref="ModbusDevice"/>.
/// </summary>
/// <param name="unitId">The unit ID of the device.</param>
/// <param name="coil">The <see cref="Coil"/> to set.</param>
public void SetCoil(byte unitId, Coil coil)
=> TypedProtocol.SetCoil(unitId, coil);
/// <summary>
/// Gets a <see cref="DiscreteInput"/> from the specified <see cref="ModbusDevice"/>.
/// </summary>
/// <param name="unitId">The unit ID of the device.</param>
/// <param name="address">The address of the <see cref="DiscreteInput"/>.</param>
public DiscreteInput GetDiscreteInput(byte unitId, ushort address)
=> TypedProtocol.GetDiscreteInput(unitId, address);
/// <summary>
/// Sets a <see cref="DiscreteInput"/> to the specified <see cref="ModbusDevice"/>.
/// </summary>
/// <param name="unitId">The unit ID of the device.</param>
/// <param name="discreteInput">The <see cref="DiscreteInput"/> to set.</param>
public void SetDiscreteInput(byte unitId, DiscreteInput discreteInput)
=> TypedProtocol.SetDiscreteInput(unitId, discreteInput);
/// <summary>
/// Gets a <see cref="HoldingRegister"/> from the specified <see cref="ModbusDevice"/>.
/// </summary>
/// <param name="unitId">The unit ID of the device.</param>
/// <param name="address">The address of the <see cref="HoldingRegister"/>.</param>
public HoldingRegister GetHoldingRegister(byte unitId, ushort address)
=> TypedProtocol.GetHoldingRegister(unitId, address);
/// <summary>
/// Sets a <see cref="HoldingRegister"/> to the specified <see cref="ModbusDevice"/>.
/// </summary>
/// <param name="unitId">The unit ID of the device.</param>
/// <param name="holdingRegister">The <see cref="HoldingRegister"/> to set.</param>
public void SetHoldingRegister(byte unitId, HoldingRegister holdingRegister)
=> TypedProtocol.SetHoldingRegister(unitId, holdingRegister);
/// <summary>
/// Gets a <see cref="InputRegister"/> from the specified <see cref="ModbusDevice"/>.
/// </summary>
/// <param name="unitId">The unit ID of the device.</param>
/// <param name="address">The address of the <see cref="InputRegister"/>.</param>
public InputRegister GetInputRegister(byte unitId, ushort address)
=> TypedProtocol.GetInputRegister(unitId, address);
/// <summary>
/// Sets a <see cref="InputRegister"/> to the specified <see cref="ModbusDevice"/>.
/// </summary>
/// <param name="unitId">The unit ID of the device.</param>
/// <param name="inputRegister">The <see cref="InputRegister"/> to set.</param>
public void SetInputRegister(byte unitId, InputRegister inputRegister)
=> TypedProtocol.SetInputRegister(unitId, inputRegister);
#endregion Entity Handling
#region Methods
/// <inheritdoc />
protected override void Dispose(bool disposing)
{
TypedProtocol.Dispose();
base.Dispose(disposing);
}
#endregion Methods
#region Connection
internal class VirtualConnection : IModbusConnection
{
public string Name => nameof(VirtualConnection);
public TimeSpan IdleTimeout { get; set; }
public TimeSpan ConnectTimeout { get; set; }
public TimeSpan ReadTimeout { get; set; }
public TimeSpan WriteTimeout { get; set; }
public void Dispose()
{ /* nothing to do */ }
public Task<IReadOnlyList<byte>> InvokeAsync(
IReadOnlyList<byte> request,
Func<IReadOnlyList<byte>, bool> validateResponseComplete,
CancellationToken cancellationToken = default) => Task.FromResult(request);
}
#endregion Connection
}
}

View File

@@ -1,42 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>netstandard2.0;net6.0;net8.0</TargetFrameworks>
<LangVersion>12.0</LangVersion>
<PackageId>AMWD.Protocols.Modbus.Proxy</PackageId>
<AssemblyName>amwd-modbus-proxy</AssemblyName>
<RootNamespace>AMWD.Protocols.Modbus.Proxy</RootNamespace>
<Product>Modbus Proxy Clients</Product>
<Description>Plugging Modbus Servers and Clients together to create Modbus Proxies.</Description>
<PackageTags>Modbus Protocol Proxy</PackageTags>
</PropertyGroup>
<ItemGroup>
<Compile Include="../AMWD.Protocols.Modbus.Common/Extensions/ArrayExtensions.cs" Link="Extensions/ArrayExtensions.cs" />
<Compile Include="../AMWD.Protocols.Modbus.Tcp/Extensions/StreamExtensions.cs" Link="Extensions/StreamExtensions.cs" />
</ItemGroup>
<ItemGroup>
<None Include="README.md" Pack="true" PackagePath="/" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'netstandard2.0'">
<PackageReference Include="System.IO.Ports" Version="4.7.0" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net6.0'">
<PackageReference Include="System.IO.Ports" Version="6.0.0" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net8.0'">
<PackageReference Include="System.IO.Ports" Version="8.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\AMWD.Protocols.Modbus.Common\AMWD.Protocols.Modbus.Common.csproj" />
<ProjectReference Include="..\AMWD.Protocols.Modbus.Serial\AMWD.Protocols.Modbus.Serial.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,10 +0,0 @@
# Modbus Protocol for .NET | Proxy
With this package the server and client implementations will be combined as proxy.
You can use any `ModbusBaseClient` implementation as target client and plug it into the implemented `ModbusTcpProxy` or `ModbusRtuProxy`, which implement the server side.
---
Published under MIT License (see [**tl;dr**Legal](https://www.tldrlegal.com/license/mit-license))

View File

@@ -2,7 +2,6 @@
<PropertyGroup>
<TargetFrameworks>netstandard2.0;net6.0;net8.0</TargetFrameworks>
<LangVersion>12.0</LangVersion>
<PackageId>AMWD.Protocols.Modbus.Serial</PackageId>
<AssemblyName>amwd-modbus-serial</AssemblyName>
@@ -14,11 +13,10 @@
</PropertyGroup>
<ItemGroup>
<Compile Include="../AMWD.Protocols.Modbus.Common/InternalsVisibleTo.cs" Link="InternalsVisibleTo.cs" />
<Compile Include="../AMWD.Protocols.Modbus.Common/Extensions/ArrayExtensions.cs" Link="Extensions/ArrayExtensions.cs" />
<Compile Include="../AMWD.Protocols.Modbus.Common/Extensions/ReaderWriterLockSlimExtensions.cs" Link="Extensions/ReaderWriterLockSlimExtensions.cs" />
<Compile Include="../AMWD.Protocols.Modbus.Common/Utils/AsyncQueue.cs" Link="Utils/AsyncQueue.cs" />
<Compile Include="../AMWD.Protocols.Modbus.Common/Utils/RequestQueueItem.cs" Link="Utils/RequestQueueItem.cs" />
<Compile Include="$(SolutionDir)/AMWD.Protocols.Modbus.Common/Extensions/ArrayExtensions.cs" Link="Extensions/ArrayExtensions.cs" />
<Compile Include="$(SolutionDir)/AMWD.Protocols.Modbus.Common/Extensions/ReaderWriterLockSlimExtensions.cs" Link="Extensions/ReaderWriterLockSlimExtensions.cs" />
<Compile Include="$(SolutionDir)/AMWD.Protocols.Modbus.Common/Utils/AsyncQueue.cs" Link="Utils/AsyncQueue.cs" />
<Compile Include="$(SolutionDir)/AMWD.Protocols.Modbus.Common/Utils/RequestQueueItem.cs" Link="Utils/RequestQueueItem.cs" />
</ItemGroup>
<ItemGroup>
@@ -38,7 +36,7 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\AMWD.Protocols.Modbus.Common\AMWD.Protocols.Modbus.Common.csproj" />
<ProjectReference Include="$(SolutionDir)\AMWD.Protocols.Modbus.Common\AMWD.Protocols.Modbus.Common.csproj" />
</ItemGroup>
</Project>

View File

@@ -8,20 +8,20 @@ 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;
using AMWD.Protocols.Modbus.Serial.Utils;
namespace AMWD.Protocols.Modbus.Proxy
namespace AMWD.Protocols.Modbus.Serial
{
/// <summary>
/// Implements a Modbus serial line RTU server proxying all requests to a Modbus client of choice.
/// </summary>
public class ModbusRtuProxy : IDisposable
public class ModbusRtuProxy : IModbusProxy
{
#region Fields
private bool _isDisposed;
private readonly SerialPort _serialPort;
private readonly SerialPortWrapper _serialPort;
private CancellationTokenSource _stopCts;
#endregion Fields
@@ -33,31 +33,25 @@ namespace AMWD.Protocols.Modbus.Proxy
/// </summary>
/// <param name="client">The <see cref="ModbusClientBase"/> used to request the remote device, that should be proxied.</param>
/// <param name="portName">The name of the serial port to use.</param>
/// <param name="baudRate">The baud rate of the serial port (Default: 19.200).</param>
public ModbusRtuProxy(ModbusClientBase client, string portName, BaudRate baudRate = BaudRate.Baud19200)
public ModbusRtuProxy(ModbusClientBase client, string portName)
{
Client = client ?? throw new ArgumentNullException(nameof(client));
if (string.IsNullOrWhiteSpace(portName))
throw new ArgumentNullException(nameof(portName));
if (!Enum.IsDefined(typeof(BaudRate), baudRate))
throw new ArgumentOutOfRangeException(nameof(baudRate));
if (!ModbusSerialClient.AvailablePortNames.Contains(portName))
throw new ArgumentException($"The serial port ({portName}) is not available.", nameof(portName));
_serialPort = new SerialPort
_serialPort = new SerialPortWrapper
{
PortName = portName,
BaudRate = (int)baudRate,
Handshake = Handshake.None,
BaudRate = (int)BaudRate.Baud19200,
DataBits = 8,
ReadTimeout = 1000,
RtsEnable = false,
StopBits = StopBits.One,
Parity = Parity.Even,
Handshake = Handshake.None,
ReadTimeout = 1000,
WriteTimeout = 1000,
Parity = Parity.Even
RtsEnable = false,
};
}
@@ -70,80 +64,108 @@ namespace AMWD.Protocols.Modbus.Proxy
/// </summary>
public ModbusClientBase Client { get; }
/// <inheritdoc cref="SerialPort.PortName"/>
public string PortName => _serialPort.PortName;
#region SerialPort Properties
/// <summary>
/// Gets or sets the baud rate of the serial port.
/// </summary>
public BaudRate BaudRate
/// <inheritdoc cref="SerialPort.PortName" />
public virtual string PortName
{
get => _serialPort.PortName;
set => _serialPort.PortName = value;
}
/// <inheritdoc cref="SerialPort.BaudRate" />
public virtual BaudRate BaudRate
{
get => (BaudRate)_serialPort.BaudRate;
set => _serialPort.BaudRate = (int)value;
}
/// <inheritdoc cref="SerialPort.Handshake"/>
public Handshake Handshake
{
get => _serialPort.Handshake;
set => _serialPort.Handshake = value;
}
/// <inheritdoc cref="SerialPort.DataBits"/>
public int DataBits
/// <inheritdoc cref="SerialPort.DataBits" />
/// <remarks>
/// From the Specs:
/// <br/>
/// On <see cref="AsciiProtocol"/> it can be 7 or 8.
/// <br/>
/// On <see cref="RtuProtocol"/> it has to be 8.
/// </remarks>
public virtual int DataBits
{
get => _serialPort.DataBits;
set => _serialPort.DataBits = value;
}
/// <inheritdoc cref="SerialPort.IsOpen"/>
public bool IsOpen => _serialPort.IsOpen;
/// <summary>
/// Gets or sets the <see cref="TimeSpan"/> before a time-out occurs when a read operation does not finish.
/// </summary>
public TimeSpan ReadTimeout
/// <inheritdoc cref="SerialPort.Handshake" />
public virtual Handshake Handshake
{
get => TimeSpan.FromMilliseconds(_serialPort.ReadTimeout);
set => _serialPort.ReadTimeout = (int)value.TotalMilliseconds;
get => _serialPort.Handshake;
set => _serialPort.Handshake = value;
}
/// <inheritdoc cref="SerialPort.RtsEnable"/>
public bool RtsEnable
/// <inheritdoc cref="SerialPort.Parity" />
/// <remarks>
/// From the Specs:
/// <br/>
/// <see cref="Parity.Even"/> is recommended and therefore the default value.
/// <br/>
/// If you use <see cref="Parity.None"/>, <see cref="StopBits.Two"/> is required,
/// otherwise <see cref="StopBits.One"/> should work fine.
/// </remarks>
public virtual Parity Parity
{
get => _serialPort.Parity;
set => _serialPort.Parity = value;
}
/// <inheritdoc cref="SerialPort.RtsEnable" />
public virtual bool RtsEnable
{
get => _serialPort.RtsEnable;
set => _serialPort.RtsEnable = value;
}
/// <inheritdoc cref="SerialPort.StopBits"/>
public StopBits StopBits
/// <inheritdoc cref="SerialPort.StopBits" />
/// <remarks>
/// From the Specs:
/// <br/>
/// Should be <see cref="StopBits.One"/> for <see cref="Parity.Even"/> or <see cref="Parity.Odd"/>.
/// <br/>
/// Should be <see cref="StopBits.Two"/> for <see cref="Parity.None"/>.
/// </remarks>
public virtual StopBits StopBits
{
get => _serialPort.StopBits;
set => _serialPort.StopBits = value;
}
/// <inheritdoc cref="SerialPortWrapper.IsOpen"/>
public bool IsOpen => _serialPort.IsOpen;
/// <summary>
/// Gets or sets the <see cref="TimeSpan"/> before a time-out occurs when a write operation does not finish.
/// Gets or sets the <see cref="TimeSpan"/> before a time-out occurs when a read/receive operation does not finish.
/// </summary>
public TimeSpan WriteTimeout
public virtual TimeSpan ReadTimeout
{
get => TimeSpan.FromMilliseconds(_serialPort.ReadTimeout);
set => _serialPort.ReadTimeout = (int)value.TotalMilliseconds;
}
/// <summary>
/// Gets or sets the <see cref="TimeSpan"/> before a time-out occurs when a write/send operation does not finish.
/// </summary>
public virtual TimeSpan WriteTimeout
{
get => TimeSpan.FromMilliseconds(_serialPort.WriteTimeout);
set => _serialPort.WriteTimeout = (int)value.TotalMilliseconds;
}
/// <inheritdoc cref="SerialPort.Parity"/>
public Parity Parity
{
get => _serialPort.Parity;
set => _serialPort.Parity = value;
}
#endregion SerialPort Properties
#endregion Properties
#region Control Methods
/// <summary>
/// Starts the server.
/// Starts the proxy.
/// </summary>
/// <param name="cancellationToken">A cancellation token used to propagate notification that this operation should be canceled.</param>
public Task StartAsync(CancellationToken cancellationToken = default)
@@ -164,7 +186,7 @@ namespace AMWD.Protocols.Modbus.Proxy
}
/// <summary>
/// Stops the server.
/// Stops the proxy.
/// </summary>
/// <param name="cancellationToken">A cancellation token used to propagate notification that this operation should be canceled.</param>
public Task StopAsync(CancellationToken cancellationToken = default)
@@ -175,7 +197,7 @@ namespace AMWD.Protocols.Modbus.Proxy
private Task StopAsyncInternal(CancellationToken cancellationToken)
{
_stopCts.Cancel();
_stopCts?.Cancel();
_serialPort.Close();
_serialPort.DataReceived -= OnDataReceived;
@@ -207,13 +229,16 @@ namespace AMWD.Protocols.Modbus.Proxy
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 evArgs)
private void OnDataReceived(object _, SerialDataReceivedEventArgs __)
{
try
{
@@ -282,16 +307,14 @@ namespace AMWD.Protocols.Modbus.Proxy
default: // unknown function
{
byte[] responseBytes = new byte[5];
Array.Copy(requestBytes, 0, responseBytes, 0, 2);
var responseBytes = new List<byte>();
responseBytes.AddRange(requestBytes.Take(2));
responseBytes.Add((byte)ModbusErrorCode.IllegalFunction);
// Mark as error
responseBytes[1] |= 0x80;
responseBytes[2] = (byte)ModbusErrorCode.IllegalFunction;
SetCrc(responseBytes);
return responseBytes;
return ReturnResponse(responseBytes);
}
}
}
@@ -332,8 +355,7 @@ namespace AMWD.Protocols.Modbus.Proxy
responseBytes.Add((byte)ModbusErrorCode.SlaveDeviceFailure);
}
AddCrc(responseBytes);
return [.. responseBytes];
return ReturnResponse(responseBytes);
}
private async Task<byte[]> HandleReadDiscreteInputsAsync(byte[] requestBytes, CancellationToken cancellationToken)
@@ -372,8 +394,7 @@ namespace AMWD.Protocols.Modbus.Proxy
responseBytes.Add((byte)ModbusErrorCode.SlaveDeviceFailure);
}
AddCrc(responseBytes);
return [.. responseBytes];
return ReturnResponse(responseBytes);
}
private async Task<byte[]> HandleReadHoldingRegistersAsync(byte[] requestBytes, CancellationToken cancellationToken)
@@ -407,8 +428,7 @@ namespace AMWD.Protocols.Modbus.Proxy
responseBytes.Add((byte)ModbusErrorCode.SlaveDeviceFailure);
}
AddCrc(responseBytes);
return [.. responseBytes];
return ReturnResponse(responseBytes);
}
private async Task<byte[]> HandleReadInputRegistersAsync(byte[] requestBytes, CancellationToken cancellationToken)
@@ -442,8 +462,7 @@ namespace AMWD.Protocols.Modbus.Proxy
responseBytes.Add((byte)ModbusErrorCode.SlaveDeviceFailure);
}
AddCrc(responseBytes);
return [.. responseBytes];
return ReturnResponse(responseBytes);
}
private async Task<byte[]> HandleWriteSingleCoilAsync(byte[] requestBytes, CancellationToken cancellationToken)
@@ -461,8 +480,7 @@ namespace AMWD.Protocols.Modbus.Proxy
responseBytes[1] |= 0x80;
responseBytes.Add((byte)ModbusErrorCode.IllegalDataValue);
AddCrc(responseBytes);
return [.. responseBytes];
return ReturnResponse(responseBytes);
}
try
@@ -492,8 +510,7 @@ namespace AMWD.Protocols.Modbus.Proxy
responseBytes.Add((byte)ModbusErrorCode.SlaveDeviceFailure);
}
AddCrc(responseBytes);
return [.. responseBytes];
return ReturnResponse(responseBytes);
}
private async Task<byte[]> HandleWriteSingleRegisterAsync(byte[] requestBytes, CancellationToken cancellationToken)
@@ -532,8 +549,7 @@ namespace AMWD.Protocols.Modbus.Proxy
responseBytes.Add((byte)ModbusErrorCode.SlaveDeviceFailure);
}
AddCrc(responseBytes);
return [.. responseBytes];
return ReturnResponse(responseBytes);
}
private async Task<byte[]> HandleWriteMultipleCoilsAsync(byte[] requestBytes, CancellationToken cancellationToken)
@@ -553,8 +569,7 @@ namespace AMWD.Protocols.Modbus.Proxy
responseBytes[1] |= 0x80;
responseBytes.Add((byte)ModbusErrorCode.IllegalDataValue);
AddCrc(responseBytes);
return [.. responseBytes];
return ReturnResponse(responseBytes);
}
try
@@ -594,8 +609,7 @@ namespace AMWD.Protocols.Modbus.Proxy
responseBytes.Add((byte)ModbusErrorCode.SlaveDeviceFailure);
}
AddCrc(responseBytes);
return [.. responseBytes];
return ReturnResponse(responseBytes);
}
private async Task<byte[]> HandleWriteMultipleRegistersAsync(byte[] requestBytes, CancellationToken cancellationToken)
@@ -604,7 +618,7 @@ namespace AMWD.Protocols.Modbus.Proxy
return null;
var responseBytes = new List<byte>();
responseBytes.AddRange(requestBytes.Take(8));
responseBytes.AddRange(requestBytes.Take(2));
ushort firstAddress = requestBytes.GetBigEndianUInt16(2);
ushort count = requestBytes.GetBigEndianUInt16(4);
@@ -615,8 +629,7 @@ namespace AMWD.Protocols.Modbus.Proxy
responseBytes[1] |= 0x80;
responseBytes.Add((byte)ModbusErrorCode.IllegalDataValue);
AddCrc(responseBytes);
return [.. responseBytes];
return ReturnResponse(responseBytes);
}
try
@@ -633,18 +646,18 @@ namespace AMWD.Protocols.Modbus.Proxy
HighByte = requestBytes[baseOffset + i * 2],
LowByte = requestBytes[baseOffset + i * 2 + 1]
});
}
bool isSuccess = await Client.WriteMultipleHoldingRegistersAsync(requestBytes[0], list, cancellationToken);
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);
}
bool isSuccess = await Client.WriteMultipleHoldingRegistersAsync(requestBytes[0], list, cancellationToken);
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
@@ -653,12 +666,14 @@ namespace AMWD.Protocols.Modbus.Proxy
responseBytes.Add((byte)ModbusErrorCode.SlaveDeviceFailure);
}
AddCrc(responseBytes);
return [.. responseBytes];
return ReturnResponse(responseBytes);
}
private async Task<byte[]> HandleEncapsulatedInterfaceAsync(byte[] requestBytes, CancellationToken cancellationToken)
{
if (requestBytes.Length < 7)
return null;
var responseBytes = new List<byte>();
responseBytes.AddRange(requestBytes.Take(2));
@@ -667,8 +682,7 @@ namespace AMWD.Protocols.Modbus.Proxy
responseBytes[1] |= 0x80;
responseBytes.Add((byte)ModbusErrorCode.IllegalFunction);
AddCrc(responseBytes);
return [.. responseBytes];
return ReturnResponse(responseBytes);
}
var firstObject = (ModbusDeviceIdentificationObject)requestBytes[4];
@@ -677,8 +691,7 @@ namespace AMWD.Protocols.Modbus.Proxy
responseBytes[1] |= 0x80;
responseBytes.Add((byte)ModbusErrorCode.IllegalDataAddress);
AddCrc(responseBytes);
return [.. responseBytes];
return ReturnResponse(responseBytes);
}
var category = (ModbusDeviceIdentificationCategory)requestBytes[3];
@@ -687,13 +700,12 @@ namespace AMWD.Protocols.Modbus.Proxy
responseBytes[1] |= 0x80;
responseBytes.Add((byte)ModbusErrorCode.IllegalDataValue);
AddCrc(responseBytes);
return [.. responseBytes];
return ReturnResponse(responseBytes);
}
try
{
var res = await Client.ReadDeviceIdentificationAsync(requestBytes[6], category, firstObject, cancellationToken);
var deviceInfo = await Client.ReadDeviceIdentificationAsync(requestBytes[0], category, firstObject, cancellationToken);
var bodyBytes = new List<byte>();
@@ -702,31 +714,20 @@ namespace AMWD.Protocols.Modbus.Proxy
// Conformity
bodyBytes.Add((byte)category);
if (res.IsIndividualAccessAllowed)
if (deviceInfo.IsIndividualAccessAllowed)
bodyBytes[2] |= 0x80;
// More, NextId, NumberOfObjects
bodyBytes.AddRange(new byte[3]);
int maxObjectId;
switch (category)
int maxObjectId = category switch
{
case ModbusDeviceIdentificationCategory.Basic:
maxObjectId = 0x02;
break;
case ModbusDeviceIdentificationCategory.Regular:
maxObjectId = 0x06;
break;
case ModbusDeviceIdentificationCategory.Extended:
maxObjectId = 0xFF;
break;
default: // Individual
maxObjectId = requestBytes[4];
break;
}
ModbusDeviceIdentificationCategory.Basic => 0x02,
ModbusDeviceIdentificationCategory.Regular => 0x06,
ModbusDeviceIdentificationCategory.Extended => 0xFF,
// Individual
_ => requestBytes[4],
};
byte numberOfObjects = 0;
for (int i = requestBytes[4]; i <= maxObjectId; i++)
@@ -735,17 +736,19 @@ namespace AMWD.Protocols.Modbus.Proxy
if (0x07 <= i && i <= 0x7F)
continue;
byte[] objBytes = GetDeviceObject((byte)i, res);
byte[] objBytes = GetDeviceObject((byte)i, deviceInfo);
// We need to split the response if it would exceed the max ADU size
if (responseBytes.Count + bodyBytes.Count + objBytes.Length > RtuProtocol.MAX_ADU_LENGTH)
// 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 [.. responseBytes];
return ReturnResponse(responseBytes);
}
bodyBytes.AddRange(objBytes);
@@ -755,16 +758,14 @@ namespace AMWD.Protocols.Modbus.Proxy
bodyBytes[5] = numberOfObjects;
responseBytes.AddRange(bodyBytes);
AddCrc(responseBytes);
return [.. responseBytes];
return ReturnResponse(responseBytes);
}
catch
{
responseBytes[1] |= 0x80;
responseBytes.Add((byte)ModbusErrorCode.SlaveDeviceFailure);
AddCrc(responseBytes);
return [.. responseBytes];
return ReturnResponse(responseBytes);
}
}
@@ -775,7 +776,7 @@ namespace AMWD.Protocols.Modbus.Proxy
{
case ModbusDeviceIdentificationObject.VendorName:
{
byte[] bytes = Encoding.UTF8.GetBytes(deviceIdentification.VendorName);
byte[] bytes = Encoding.UTF8.GetBytes(deviceIdentification.VendorName ?? "");
result.Add((byte)bytes.Length);
result.AddRange(bytes);
}
@@ -783,7 +784,7 @@ namespace AMWD.Protocols.Modbus.Proxy
case ModbusDeviceIdentificationObject.ProductCode:
{
byte[] bytes = Encoding.UTF8.GetBytes(deviceIdentification.ProductCode);
byte[] bytes = Encoding.UTF8.GetBytes(deviceIdentification.ProductCode ?? "");
result.Add((byte)bytes.Length);
result.AddRange(bytes);
}
@@ -791,7 +792,7 @@ namespace AMWD.Protocols.Modbus.Proxy
case ModbusDeviceIdentificationObject.MajorMinorRevision:
{
byte[] bytes = Encoding.UTF8.GetBytes(deviceIdentification.MajorMinorRevision);
byte[] bytes = Encoding.UTF8.GetBytes(deviceIdentification.MajorMinorRevision ?? "");
result.Add((byte)bytes.Length);
result.AddRange(bytes);
}
@@ -799,7 +800,7 @@ namespace AMWD.Protocols.Modbus.Proxy
case ModbusDeviceIdentificationObject.VendorUrl:
{
byte[] bytes = Encoding.UTF8.GetBytes(deviceIdentification.VendorUrl);
byte[] bytes = Encoding.UTF8.GetBytes(deviceIdentification.VendorUrl ?? "");
result.Add((byte)bytes.Length);
result.AddRange(bytes);
}
@@ -807,7 +808,7 @@ namespace AMWD.Protocols.Modbus.Proxy
case ModbusDeviceIdentificationObject.ProductName:
{
byte[] bytes = Encoding.UTF8.GetBytes(deviceIdentification.ProductName);
byte[] bytes = Encoding.UTF8.GetBytes(deviceIdentification.ProductName ?? "");
result.Add((byte)bytes.Length);
result.AddRange(bytes);
}
@@ -815,7 +816,7 @@ namespace AMWD.Protocols.Modbus.Proxy
case ModbusDeviceIdentificationObject.ModelName:
{
byte[] bytes = Encoding.UTF8.GetBytes(deviceIdentification.ModelName);
byte[] bytes = Encoding.UTF8.GetBytes(deviceIdentification.ModelName ?? "");
result.Add((byte)bytes.Length);
result.AddRange(bytes);
}
@@ -823,7 +824,7 @@ namespace AMWD.Protocols.Modbus.Proxy
case ModbusDeviceIdentificationObject.UserApplicationName:
{
byte[] bytes = Encoding.UTF8.GetBytes(deviceIdentification.UserApplicationName);
byte[] bytes = Encoding.UTF8.GetBytes(deviceIdentification.UserApplicationName ?? "");
result.Add((byte)bytes.Length);
result.AddRange(bytes);
}
@@ -831,9 +832,8 @@ namespace AMWD.Protocols.Modbus.Proxy
default:
{
if (deviceIdentification.ExtendedObjects.ContainsKey(objectId))
if (deviceIdentification.ExtendedObjects.TryGetValue(objectId, out byte[] bytes))
{
byte[] bytes = deviceIdentification.ExtendedObjects[objectId];
result.Add((byte)bytes.Length);
result.AddRange(bytes);
}
@@ -848,18 +848,10 @@ namespace AMWD.Protocols.Modbus.Proxy
return [.. result];
}
private static void SetCrc(byte[] bytes)
private static byte[] ReturnResponse(List<byte> response)
{
byte[] crc = RtuProtocol.CRC16(bytes, 0, bytes.Length - 2);
bytes[bytes.Length - 2] = crc[0];
bytes[bytes.Length - 1] = crc[1];
}
private static void AddCrc(List<byte> bytes)
{
byte[] crc = RtuProtocol.CRC16(bytes);
bytes.Add(crc[0]);
bytes.Add(crc[1]);
response.AddRange(RtuProtocol.CRC16(response));
return [.. response];
}
#endregion Request Handling

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,6 @@
using System;
using System.IO.Ports;
using System.Text;
using AMWD.Protocols.Modbus.Common.Contracts;
using AMWD.Protocols.Modbus.Common.Protocols;
@@ -15,7 +16,7 @@ namespace AMWD.Protocols.Modbus.Serial
/// </summary>
/// <param name="portName">The name of the serial port to use.</param>
public ModbusSerialClient(string portName)
: this(new ModbusSerialConnection { PortName = portName })
: this(new ModbusSerialConnection(portName))
{ }
/// <summary>
@@ -40,8 +41,8 @@ namespace AMWD.Protocols.Modbus.Serial
Protocol = new RtuProtocol();
}
/// <inheritdoc cref="SerialPort.GetPortNames" />
public static string[] AvailablePortNames => SerialPort.GetPortNames();
/// <inheritdoc cref="ModbusSerialConnection.AvailablePortNames" />
public static string[] AvailablePortNames => ModbusSerialConnection.AvailablePortNames;
/// <inheritdoc cref="IModbusConnection.IdleTimeout"/>
public TimeSpan IdleTimeout
@@ -223,5 +224,22 @@ namespace AMWD.Protocols.Modbus.Serial
serialConnection.StopBits = value;
}
}
/// <inheritdoc/>
public override string ToString()
{
var sb = new StringBuilder();
sb.AppendLine($"Serial Client {PortName}");
sb.AppendLine($" {nameof(BaudRate)}: {(int)BaudRate}");
sb.AppendLine($" {nameof(DataBits)}: {DataBits}");
sb.AppendLine($" {nameof(StopBits)}: {(StopBits == StopBits.OnePointFive ? "1.5" : ((int)StopBits).ToString())}");
sb.AppendLine($" {nameof(Parity)}: {Parity.ToString().ToLower()}");
sb.AppendLine($" {nameof(Handshake)}: {Handshake.ToString().ToLower()}");
sb.AppendLine($" {nameof(RtsEnable)}: {RtsEnable.ToString().ToLower()}");
sb.AppendLine($" {nameof(DriverEnabledRS485)}: {DriverEnabledRS485.ToString().ToLower()}");
return sb.ToString();
}
}
}

View File

@@ -39,10 +39,15 @@ namespace AMWD.Protocols.Modbus.Serial
/// <summary>
/// Initializes a new instance of the <see cref="ModbusSerialConnection"/> class.
/// </summary>
public ModbusSerialConnection()
public ModbusSerialConnection(string portName)
{
if (string.IsNullOrWhiteSpace(portName))
throw new ArgumentNullException(nameof(portName));
_serialPort = new SerialPortWrapper
{
PortName = portName,
BaudRate = (int)BaudRate.Baud19200,
DataBits = 8,
Handshake = Handshake.None,
@@ -59,6 +64,9 @@ namespace AMWD.Protocols.Modbus.Serial
#region Properties
/// <inheritdoc cref="SerialPort.GetPortNames" />
public static string[] AvailablePortNames => SerialPort.GetPortNames();
/// <inheritdoc/>
public string Name => "Serial";
@@ -68,20 +76,6 @@ namespace AMWD.Protocols.Modbus.Serial
/// <inheritdoc/>
public virtual TimeSpan ConnectTimeout { get; set; } = TimeSpan.MaxValue;
/// <inheritdoc/>
public virtual TimeSpan ReadTimeout
{
get => TimeSpan.FromMilliseconds(_serialPort.ReadTimeout);
set => _serialPort.ReadTimeout = (int)value.TotalMilliseconds;
}
/// <inheritdoc/>
public virtual TimeSpan WriteTimeout
{
get => TimeSpan.FromMilliseconds(_serialPort.WriteTimeout);
set => _serialPort.WriteTimeout = (int)value.TotalMilliseconds;
}
/// <summary>
/// Gets or sets a value indicating whether the RS485 driver has to be enabled via software switch.
/// </summary>
@@ -107,9 +101,7 @@ namespace AMWD.Protocols.Modbus.Serial
set => _serialPort.PortName = value;
}
/// <summary>
/// Gets or sets the serial baud rate.
/// </summary>
/// <inheritdoc cref="SerialPort.BaudRate" />
public virtual BaudRate BaudRate
{
get => (BaudRate)_serialPort.BaudRate;
@@ -118,7 +110,11 @@ namespace AMWD.Protocols.Modbus.Serial
/// <inheritdoc cref="SerialPort.DataBits" />
/// <remarks>
/// Should be 7 for ASCII mode and 8 for RTU mode.
/// From the Specs:
/// <br/>
/// On <see cref="AsciiProtocol"/> it can be 7 or 8.
/// <br/>
/// On <see cref="RtuProtocol"/> it has to be 8.
/// </remarks>
public virtual int DataBits
{
@@ -159,9 +155,9 @@ namespace AMWD.Protocols.Modbus.Serial
/// <remarks>
/// From the Specs:
/// <br/>
/// Should be <see cref="StopBits.One"/> for <see cref="Parity.Even"/> or <see cref="Parity.Odd"/> and
/// Should be <see cref="StopBits.One"/> for <see cref="Parity.Even"/> or <see cref="Parity.Odd"/>.
/// <br/>
/// should be <see cref="StopBits.Two"/> for <see cref="Parity.None"/>.
/// Should be <see cref="StopBits.Two"/> for <see cref="Parity.None"/>.
/// </remarks>
public virtual StopBits StopBits
{
@@ -169,6 +165,20 @@ namespace AMWD.Protocols.Modbus.Serial
set => _serialPort.StopBits = value;
}
/// <inheritdoc/>
public virtual TimeSpan ReadTimeout
{
get => TimeSpan.FromMilliseconds(_serialPort.ReadTimeout);
set => _serialPort.ReadTimeout = (int)value.TotalMilliseconds;
}
/// <inheritdoc/>
public virtual TimeSpan WriteTimeout
{
get => TimeSpan.FromMilliseconds(_serialPort.WriteTimeout);
set => _serialPort.WriteTimeout = (int)value.TotalMilliseconds;
}
#endregion SerialPort Properties
#endregion Properties
@@ -188,7 +198,6 @@ namespace AMWD.Protocols.Modbus.Serial
try
{
_processingTask.Wait();
_processingTask.Dispose();
}
catch

View File

@@ -44,10 +44,10 @@ using var client = new ModbusSerialClient(serialPort)
---
Published under MIT License (see [**tl;dr**Legal])
Published under MIT License (see [choose a license])
[v1.1b3]: https://modbus.org/docs/Modbus_Application_Protocol_V1_1b3.pdf
[v1.02]: https://modbus.org/docs/Modbus_over_serial_line_V1_02.pdf
[**tl;dr**Legal]: https://www.tldrlegal.com/license/mit-license
[v1.1b3]: https://modbus.org/docs/Modbus_Application_Protocol_V1_1b3.pdf
[v1.02]: https://modbus.org/docs/Modbus_over_serial_line_V1_02.pdf
[choose a license]: https://choosealicense.com/licenses/mit/

View File

@@ -20,6 +20,30 @@ namespace AMWD.Protocols.Modbus.Serial.Utils
#endregion Fields
#region Constructor
public SerialPortWrapper()
{
_serialPort.DataReceived += (sender, e) => DataReceived?.Invoke(this, e);
_serialPort.PinChanged += (sender, e) => PinChanged?.Invoke(this, e);
_serialPort.ErrorReceived += (sender, e) => ErrorReceived?.Invoke(this, e);
}
#endregion Constructor
#region Events
/// <inheritdoc cref="SerialPort.DataReceived"/>
public virtual event SerialDataReceivedEventHandler DataReceived;
/// <inheritdoc cref="SerialPort.PinChanged"/>
public virtual event SerialPinChangedEventHandler PinChanged;
/// <inheritdoc cref="SerialPort.ErrorReceived"/>
public virtual event SerialErrorReceivedEventHandler ErrorReceived;
#endregion Events
#region Properties
/// <inheritdoc cref="SerialPort.Handshake"/>
@@ -82,6 +106,10 @@ namespace AMWD.Protocols.Modbus.Serial.Utils
set => _serialPort.Parity = value;
}
/// <inheritdoc cref="SerialPort.BytesToWrite"/>
public virtual int BytesToWrite
=> _serialPort.BytesToWrite;
/// <inheritdoc cref="SerialPort.BaudRate"/>
public virtual int BaudRate
{
@@ -89,6 +117,10 @@ namespace AMWD.Protocols.Modbus.Serial.Utils
set => _serialPort.BaudRate = value;
}
/// <inheritdoc cref="SerialPort.BytesToRead"/>
public virtual int BytesToRead
=> _serialPort.BytesToRead;
#endregion Properties
#region Methods
@@ -101,6 +133,14 @@ namespace AMWD.Protocols.Modbus.Serial.Utils
public virtual void Open()
=> _serialPort.Open();
/// <inheritdoc cref="SerialPort.Read(byte[], int, int)"/>
public virtual int Read(byte[] buffer, int offset, int count)
=> _serialPort.Read(buffer, offset, count);
/// <inheritdoc cref="SerialPort.Write(byte[], int, int)"/>
public virtual void Write(byte[] buffer, int offset, int count)
=> _serialPort.Write(buffer, offset, count);
/// <inheritdoc cref="SerialPort.Dispose"/>
public virtual void Dispose()
=> _serialPort.Dispose();
@@ -117,7 +157,7 @@ namespace AMWD.Protocols.Modbus.Serial.Utils
/// <remarks>
/// There seems to be a bug with the async stream implementation on Windows.
/// <br/>
/// See this StackOverflow answer: <see href="https://stackoverflow.com/a/54610437/11906695" />
/// See this StackOverflow answer: <see href="https://stackoverflow.com/a/54610437/11906695" />.
/// </remarks>
/// <param name="buffer">The buffer to write the data into.</param>
/// <param name="offset">The byte offset in buffer at which to begin writing data from the serial port.</param>

View File

@@ -2,7 +2,6 @@
<PropertyGroup>
<TargetFrameworks>netstandard2.0;net6.0;net8.0</TargetFrameworks>
<LangVersion>12.0</LangVersion>
<PackageId>AMWD.Protocols.Modbus.Tcp</PackageId>
<AssemblyName>amwd-modbus-tcp</AssemblyName>
@@ -14,11 +13,10 @@
</PropertyGroup>
<ItemGroup>
<Compile Include="../AMWD.Protocols.Modbus.Common/InternalsVisibleTo.cs" Link="InternalsVisibleTo.cs" />
<Compile Include="../AMWD.Protocols.Modbus.Common/Extensions/ArrayExtensions.cs" Link="Extensions/ArrayExtensions.cs" />
<Compile Include="../AMWD.Protocols.Modbus.Common/Extensions/ReaderWriterLockSlimExtensions.cs" Link="Extensions/ReaderWriterLockSlimExtensions.cs" />
<Compile Include="../AMWD.Protocols.Modbus.Common/Utils/AsyncQueue.cs" Link="Utils/AsyncQueue.cs" />
<Compile Include="../AMWD.Protocols.Modbus.Common/Utils/RequestQueueItem.cs" Link="Utils/RequestQueueItem.cs" />
<Compile Include="$(SolutionDir)/AMWD.Protocols.Modbus.Common/Extensions/ArrayExtensions.cs" Link="Extensions/ArrayExtensions.cs" />
<Compile Include="$(SolutionDir)/AMWD.Protocols.Modbus.Common/Extensions/ReaderWriterLockSlimExtensions.cs" Link="Extensions/ReaderWriterLockSlimExtensions.cs" />
<Compile Include="$(SolutionDir)/AMWD.Protocols.Modbus.Common/Utils/AsyncQueue.cs" Link="Utils/AsyncQueue.cs" />
<Compile Include="$(SolutionDir)/AMWD.Protocols.Modbus.Common/Utils/RequestQueueItem.cs" Link="Utils/RequestQueueItem.cs" />
</ItemGroup>
<ItemGroup>
@@ -26,7 +24,7 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\AMWD.Protocols.Modbus.Common\AMWD.Protocols.Modbus.Common.csproj" />
<ProjectReference Include="$(SolutionDir)\AMWD.Protocols.Modbus.Common\AMWD.Protocols.Modbus.Common.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,5 +1,6 @@
using System.Threading;
using System.Threading.Tasks;
using AMWD.Protocols.Modbus.Tcp.Utils;
namespace System.IO
{
@@ -22,5 +23,23 @@ namespace System.IO
cancellationToken.ThrowIfCancellationRequested();
return buffer;
}
public static async Task<byte[]> ReadExpectedBytesAsync(this NetworkStreamWrapper 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);
if (count < 1)
throw new EndOfStreamException();
offset += count;
}
while (offset < expectedBytes && !cancellationToken.IsCancellationRequested);
cancellationToken.ThrowIfCancellationRequested();
return buffer;
}
}
}

View File

@@ -1,4 +1,5 @@
using System;
using System.Text;
using AMWD.Protocols.Modbus.Common.Contracts;
using AMWD.Protocols.Modbus.Common.Protocols;
@@ -101,5 +102,16 @@ namespace AMWD.Protocols.Modbus.Tcp
tcpConnection.Port = value;
}
}
/// <inheritdoc/>
public override string ToString()
{
var sb = new StringBuilder();
sb.AppendLine($"TCP Client {Hostname}");
sb.AppendLine($" {nameof(Port)}: {Port}");
return sb.ToString();
}
}
}

View File

@@ -10,24 +10,27 @@ 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.Tcp.Utils;
namespace AMWD.Protocols.Modbus.Proxy
namespace AMWD.Protocols.Modbus.Tcp
{
/// <summary>
/// Implements a Modbus TCP server proxying all requests to a Modbus client of choice.
/// </summary>
public class ModbusTcpProxy : IDisposable
public class ModbusTcpProxy : IModbusProxy
{
#region Fields
private bool _isDisposed;
private TcpListener _listener;
private TimeSpan _readWriteTimeout = TimeSpan.FromSeconds(100);
private TcpListenerWrapper _tcpListener;
private CancellationTokenSource _stopCts;
private Task _clientConnectTask = Task.CompletedTask;
private readonly SemaphoreSlim _clientListLock = new(1, 1);
private readonly List<TcpClient> _clients = [];
private readonly List<TcpClientWrapper> _clients = [];
private readonly List<Task> _clientTasks = [];
#endregion Fields
@@ -38,32 +41,12 @@ namespace AMWD.Protocols.Modbus.Proxy
/// Initializes a new instance of the <see cref="ModbusTcpProxy"/> class.
/// </summary>
/// <param name="client">The <see cref="ModbusClientBase"/> used to request the remote device, that should be proxied.</param>
/// <param name="listenAddress">An <see cref="IPAddress"/> to listen on (Default: <see cref="IPAddress.Loopback"/>).</param>
/// <param name="listenPort">A port to listen on (Default: 502).</param>
public ModbusTcpProxy(ModbusClientBase client, IPAddress listenAddress = null, int listenPort = 502)
/// <param name="listenAddress">An <see cref="IPAddress"/> to listen on.</param>
public ModbusTcpProxy(ModbusClientBase client, IPAddress listenAddress)
{
Client = client ?? throw new ArgumentNullException(nameof(client));
ListenAddress = listenAddress ?? IPAddress.Loopback;
if (listenPort < ushort.MinValue || ushort.MaxValue < listenPort)
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);
}
_tcpListener = new TcpListenerWrapper(listenAddress, 502);
}
#endregion Constructors
@@ -78,22 +61,41 @@ namespace AMWD.Protocols.Modbus.Proxy
/// <summary>
/// Gets the <see cref="IPAddress"/> to listen on.
/// </summary>
public IPAddress ListenAddress { get; }
public IPAddress ListenAddress
{
get => _tcpListener.LocalIPEndPoint.Address;
set => _tcpListener.LocalIPEndPoint.Address = value;
}
/// <summary>
/// Get the port to listen on.
/// </summary>
public int ListenPort { get; }
public int ListenPort
{
get => _tcpListener.LocalIPEndPoint.Port;
set => _tcpListener.LocalIPEndPoint.Port = value;
}
/// <summary>
/// Gets a value indicating whether the server is running.
/// </summary>
public bool IsRunning => _listener?.Server.IsBound ?? false;
public bool IsRunning => _tcpListener.Socket.IsBound;
/// <summary>
/// Gets or sets the read/write timeout for the incoming connections (not the <see cref="Client"/>!).
/// Default: 100 seconds.
/// </summary>
public TimeSpan ReadWriteTimeout { get; set; }
public TimeSpan ReadWriteTimeout
{
get => _readWriteTimeout;
set
{
if (value != Timeout.InfiniteTimeSpan && value < TimeSpan.Zero)
throw new ArgumentOutOfRangeException(nameof(value));
_readWriteTimeout = value;
}
}
#endregion Properties
@@ -108,20 +110,14 @@ namespace AMWD.Protocols.Modbus.Proxy
Assertions();
_stopCts?.Cancel();
_listener?.Stop();
#if NET8_0_OR_GREATER
_listener?.Dispose();
#endif
_tcpListener.Stop();
_stopCts?.Dispose();
_stopCts = new CancellationTokenSource();
_listener = new TcpListener(ListenAddress, ListenPort);
if (ListenAddress.AddressFamily == AddressFamily.InterNetworkV6)
_listener.Server.DualMode = true;
_tcpListener.Socket.DualMode = ListenAddress.AddressFamily == AddressFamily.InterNetworkV6;
_listener.Start();
_tcpListener.Start();
_clientConnectTask = WaitForClientAsync(_stopCts.Token);
return Task.CompletedTask;
@@ -139,12 +135,9 @@ namespace AMWD.Protocols.Modbus.Proxy
private async Task StopAsyncInternal(CancellationToken cancellationToken = default)
{
_stopCts.Cancel();
_stopCts?.Cancel();
_tcpListener.Stop();
_listener.Stop();
#if NET8_0_OR_GREATER
_listener.Dispose();
#endif
try
{
await Task.WhenAny(_clientConnectTask, Task.Delay(Timeout.Infinite, cancellationToken));
@@ -178,6 +171,7 @@ namespace AMWD.Protocols.Modbus.Proxy
_clientListLock.Dispose();
_clients.Clear();
_tcpListener.Dispose();
_stopCts?.Dispose();
}
@@ -202,11 +196,7 @@ namespace AMWD.Protocols.Modbus.Proxy
{
try
{
#if NET8_0_OR_GREATER
var client = await _listener.AcceptTcpClientAsync(cancellationToken);
#else
var client = await _listener.AcceptTcpClientAsync();
#endif
var client = await _tcpListener.AcceptTcpClientAsync(cancellationToken);
await _clientListLock.WaitAsync(cancellationToken);
try
{
@@ -225,7 +215,7 @@ namespace AMWD.Protocols.Modbus.Proxy
}
}
private async Task HandleClientAsync(TcpClient client, CancellationToken cancellationToken)
private async Task HandleClientAsync(TcpClientWrapper client, CancellationToken cancellationToken)
{
try
{
@@ -240,11 +230,11 @@ namespace AMWD.Protocols.Modbus.Proxy
byte[] headerBytes = await stream.ReadExpectedBytesAsync(6, cts.Token);
requestBytes.AddRange(headerBytes);
byte[] followingCountBytes = headerBytes.Skip(4).Take(2).ToArray();
followingCountBytes.SwapBigEndian();
int followingCount = BitConverter.ToUInt16(followingCountBytes, 0);
ushort length = headerBytes
.Skip(4).Take(2).ToArray()
.GetBigEndianUInt16();
byte[] bodyBytes = await stream.ReadExpectedBytesAsync(followingCount, cts.Token);
byte[] bodyBytes = await stream.ReadExpectedBytesAsync(length, cts.Token);
requestBytes.AddRange(bodyBytes);
}
@@ -309,14 +299,14 @@ namespace AMWD.Protocols.Modbus.Proxy
default: // unknown function
{
byte[] responseBytes = new byte[9];
Array.Copy(requestBytes, 0, responseBytes, 0, 8);
var responseBytes = new List<byte>();
responseBytes.AddRange(requestBytes.Take(8));
responseBytes.Add((byte)ModbusErrorCode.IllegalFunction);
// Mark as error
responseBytes[7] |= 0x80;
responseBytes[8] = (byte)ModbusErrorCode.IllegalFunction;
return Task.FromResult(responseBytes);
return Task.FromResult(ReturnResponse(responseBytes));
}
}
}
@@ -357,7 +347,7 @@ namespace AMWD.Protocols.Modbus.Proxy
responseBytes.Add((byte)ModbusErrorCode.SlaveDeviceFailure);
}
return [.. responseBytes];
return ReturnResponse(responseBytes);
}
private async Task<byte[]> HandleReadDiscreteInputsAsync(byte[] requestBytes, CancellationToken cancellationToken)
@@ -396,7 +386,7 @@ namespace AMWD.Protocols.Modbus.Proxy
responseBytes.Add((byte)ModbusErrorCode.SlaveDeviceFailure);
}
return [.. responseBytes];
return ReturnResponse(responseBytes);
}
private async Task<byte[]> HandleReadHoldingRegistersAsync(byte[] requestBytes, CancellationToken cancellationToken)
@@ -430,7 +420,7 @@ namespace AMWD.Protocols.Modbus.Proxy
responseBytes.Add((byte)ModbusErrorCode.SlaveDeviceFailure);
}
return [.. responseBytes];
return ReturnResponse(responseBytes);
}
private async Task<byte[]> HandleReadInputRegistersAsync(byte[] requestBytes, CancellationToken cancellationToken)
@@ -464,7 +454,7 @@ namespace AMWD.Protocols.Modbus.Proxy
responseBytes.Add((byte)ModbusErrorCode.SlaveDeviceFailure);
}
return [.. responseBytes];
return ReturnResponse(responseBytes);
}
private async Task<byte[]> HandleWriteSingleCoilAsync(byte[] requestBytes, CancellationToken cancellationToken)
@@ -481,7 +471,8 @@ namespace AMWD.Protocols.Modbus.Proxy
{
responseBytes[7] |= 0x80;
responseBytes.Add((byte)ModbusErrorCode.IllegalDataValue);
return [.. responseBytes];
return ReturnResponse(responseBytes);
}
try
@@ -511,7 +502,7 @@ namespace AMWD.Protocols.Modbus.Proxy
responseBytes.Add((byte)ModbusErrorCode.SlaveDeviceFailure);
}
return [.. responseBytes];
return ReturnResponse(responseBytes);
}
private async Task<byte[]> HandleWriteSingleRegisterAsync(byte[] requestBytes, CancellationToken cancellationToken)
@@ -551,7 +542,7 @@ namespace AMWD.Protocols.Modbus.Proxy
responseBytes.Add((byte)ModbusErrorCode.SlaveDeviceFailure);
}
return [.. responseBytes];
return ReturnResponse(responseBytes);
}
private async Task<byte[]> HandleWriteMultipleCoilsAsync(byte[] requestBytes, CancellationToken cancellationToken)
@@ -570,7 +561,8 @@ namespace AMWD.Protocols.Modbus.Proxy
{
responseBytes[7] |= 0x80;
responseBytes.Add((byte)ModbusErrorCode.IllegalDataValue);
return [.. responseBytes];
return ReturnResponse(responseBytes);
}
try
@@ -610,7 +602,7 @@ namespace AMWD.Protocols.Modbus.Proxy
responseBytes.Add((byte)ModbusErrorCode.SlaveDeviceFailure);
}
return [.. responseBytes];
return ReturnResponse(responseBytes);
}
private async Task<byte[]> HandleWriteMultipleRegistersAsync(byte[] requestBytes, CancellationToken cancellationToken)
@@ -629,7 +621,8 @@ namespace AMWD.Protocols.Modbus.Proxy
{
responseBytes[7] |= 0x80;
responseBytes.Add((byte)ModbusErrorCode.IllegalDataValue);
return [.. responseBytes];
return ReturnResponse(responseBytes);
}
try
@@ -646,18 +639,18 @@ namespace AMWD.Protocols.Modbus.Proxy
HighByte = requestBytes[baseOffset + i * 2],
LowByte = requestBytes[baseOffset + i * 2 + 1]
});
}
bool isSuccess = await Client.WriteMultipleHoldingRegistersAsync(requestBytes[6], list, cancellationToken);
if (isSuccess)
{
// Response is an echo of the request
responseBytes.AddRange(requestBytes.Skip(8).Take(4));
}
else
{
responseBytes[7] |= 0x80;
responseBytes.Add((byte)ModbusErrorCode.SlaveDeviceFailure);
}
bool isSuccess = await Client.WriteMultipleHoldingRegistersAsync(requestBytes[6], list, cancellationToken);
if (isSuccess)
{
// Response is an echo of the request
responseBytes.AddRange(requestBytes.Skip(8).Take(4));
}
else
{
responseBytes[7] |= 0x80;
responseBytes.Add((byte)ModbusErrorCode.SlaveDeviceFailure);
}
}
catch
@@ -666,11 +659,14 @@ namespace AMWD.Protocols.Modbus.Proxy
responseBytes.Add((byte)ModbusErrorCode.SlaveDeviceFailure);
}
return [.. responseBytes];
return ReturnResponse(responseBytes);
}
private async Task<byte[]> HandleEncapsulatedInterfaceAsync(byte[] requestBytes, CancellationToken cancellationToken)
{
if (requestBytes.Length < 11)
return null;
var responseBytes = new List<byte>();
responseBytes.AddRange(requestBytes.Take(8));
@@ -678,7 +674,8 @@ namespace AMWD.Protocols.Modbus.Proxy
{
responseBytes[7] |= 0x80;
responseBytes.Add((byte)ModbusErrorCode.IllegalFunction);
return [.. responseBytes];
return ReturnResponse(responseBytes);
}
var firstObject = (ModbusDeviceIdentificationObject)requestBytes[10];
@@ -686,7 +683,8 @@ namespace AMWD.Protocols.Modbus.Proxy
{
responseBytes[7] |= 0x80;
responseBytes.Add((byte)ModbusErrorCode.IllegalDataAddress);
return [.. responseBytes];
return ReturnResponse(responseBytes);
}
var category = (ModbusDeviceIdentificationCategory)requestBytes[9];
@@ -694,12 +692,13 @@ namespace AMWD.Protocols.Modbus.Proxy
{
responseBytes[7] |= 0x80;
responseBytes.Add((byte)ModbusErrorCode.IllegalDataValue);
return [.. responseBytes];
return ReturnResponse(responseBytes);
}
try
{
var res = await Client.ReadDeviceIdentificationAsync(requestBytes[6], category, firstObject, cancellationToken);
var deviceInfo = await Client.ReadDeviceIdentificationAsync(requestBytes[6], category, firstObject, cancellationToken);
var bodyBytes = new List<byte>();
@@ -708,31 +707,20 @@ namespace AMWD.Protocols.Modbus.Proxy
// Conformity
bodyBytes.Add((byte)category);
if (res.IsIndividualAccessAllowed)
if (deviceInfo.IsIndividualAccessAllowed)
bodyBytes[2] |= 0x80;
// More, NextId, NumberOfObjects
bodyBytes.AddRange(new byte[3]);
int maxObjectId;
switch (category)
int maxObjectId = category switch
{
case ModbusDeviceIdentificationCategory.Basic:
maxObjectId = 0x02;
break;
case ModbusDeviceIdentificationCategory.Regular:
maxObjectId = 0x06;
break;
case ModbusDeviceIdentificationCategory.Extended:
maxObjectId = 0xFF;
break;
default: // Individual
maxObjectId = requestBytes[10];
break;
}
ModbusDeviceIdentificationCategory.Basic => 0x02,
ModbusDeviceIdentificationCategory.Regular => 0x06,
ModbusDeviceIdentificationCategory.Extended => 0xFF,
// Individual
_ => requestBytes[10],
};
byte numberOfObjects = 0;
for (int i = requestBytes[10]; i <= maxObjectId; i++)
@@ -741,7 +729,7 @@ namespace AMWD.Protocols.Modbus.Proxy
if (0x07 <= i && i <= 0x7F)
continue;
byte[] objBytes = GetDeviceObject((byte)i, res);
byte[] objBytes = GetDeviceObject((byte)i, deviceInfo);
// 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)
@@ -751,7 +739,8 @@ namespace AMWD.Protocols.Modbus.Proxy
bodyBytes[5] = numberOfObjects;
responseBytes.AddRange(bodyBytes);
return [.. responseBytes];
return ReturnResponse(responseBytes);
}
bodyBytes.AddRange(objBytes);
@@ -760,13 +749,15 @@ namespace AMWD.Protocols.Modbus.Proxy
bodyBytes[5] = numberOfObjects;
responseBytes.AddRange(bodyBytes);
return [.. responseBytes];
return ReturnResponse(responseBytes);
}
catch
{
responseBytes[7] |= 0x80;
responseBytes.Add((byte)ModbusErrorCode.SlaveDeviceFailure);
return [.. responseBytes];
return ReturnResponse(responseBytes);
}
}
@@ -777,7 +768,7 @@ namespace AMWD.Protocols.Modbus.Proxy
{
case ModbusDeviceIdentificationObject.VendorName:
{
byte[] bytes = Encoding.UTF8.GetBytes(deviceIdentification.VendorName);
byte[] bytes = Encoding.UTF8.GetBytes(deviceIdentification.VendorName ?? "");
result.Add((byte)bytes.Length);
result.AddRange(bytes);
}
@@ -785,7 +776,7 @@ namespace AMWD.Protocols.Modbus.Proxy
case ModbusDeviceIdentificationObject.ProductCode:
{
byte[] bytes = Encoding.UTF8.GetBytes(deviceIdentification.ProductCode);
byte[] bytes = Encoding.UTF8.GetBytes(deviceIdentification.ProductCode ?? "");
result.Add((byte)bytes.Length);
result.AddRange(bytes);
}
@@ -793,7 +784,7 @@ namespace AMWD.Protocols.Modbus.Proxy
case ModbusDeviceIdentificationObject.MajorMinorRevision:
{
byte[] bytes = Encoding.UTF8.GetBytes(deviceIdentification.MajorMinorRevision);
byte[] bytes = Encoding.UTF8.GetBytes(deviceIdentification.MajorMinorRevision ?? "");
result.Add((byte)bytes.Length);
result.AddRange(bytes);
}
@@ -801,7 +792,7 @@ namespace AMWD.Protocols.Modbus.Proxy
case ModbusDeviceIdentificationObject.VendorUrl:
{
byte[] bytes = Encoding.UTF8.GetBytes(deviceIdentification.VendorUrl);
byte[] bytes = Encoding.UTF8.GetBytes(deviceIdentification.VendorUrl ?? "");
result.Add((byte)bytes.Length);
result.AddRange(bytes);
}
@@ -809,7 +800,7 @@ namespace AMWD.Protocols.Modbus.Proxy
case ModbusDeviceIdentificationObject.ProductName:
{
byte[] bytes = Encoding.UTF8.GetBytes(deviceIdentification.ProductName);
byte[] bytes = Encoding.UTF8.GetBytes(deviceIdentification.ProductName ?? "");
result.Add((byte)bytes.Length);
result.AddRange(bytes);
}
@@ -817,7 +808,7 @@ namespace AMWD.Protocols.Modbus.Proxy
case ModbusDeviceIdentificationObject.ModelName:
{
byte[] bytes = Encoding.UTF8.GetBytes(deviceIdentification.ModelName);
byte[] bytes = Encoding.UTF8.GetBytes(deviceIdentification.ModelName ?? "");
result.Add((byte)bytes.Length);
result.AddRange(bytes);
}
@@ -825,7 +816,7 @@ namespace AMWD.Protocols.Modbus.Proxy
case ModbusDeviceIdentificationObject.UserApplicationName:
{
byte[] bytes = Encoding.UTF8.GetBytes(deviceIdentification.UserApplicationName);
byte[] bytes = Encoding.UTF8.GetBytes(deviceIdentification.UserApplicationName ?? "");
result.Add((byte)bytes.Length);
result.AddRange(bytes);
}
@@ -833,9 +824,8 @@ namespace AMWD.Protocols.Modbus.Proxy
default:
{
if (deviceIdentification.ExtendedObjects.ContainsKey(objectId))
if (deviceIdentification.ExtendedObjects.TryGetValue(objectId, out byte[] bytes))
{
byte[] bytes = deviceIdentification.ExtendedObjects[objectId];
result.Add((byte)bytes.Length);
result.AddRange(bytes);
}
@@ -850,6 +840,16 @@ namespace AMWD.Protocols.Modbus.Proxy
return [.. result];
}
private static byte[] ReturnResponse(List<byte> response)
{
ushort followingBytes = (ushort)(response.Count - 6);
var bytes = followingBytes.ToBigEndianBytes();
response[4] = bytes[0];
response[5] = bytes[1];
return [.. response];
}
#endregion Request Handling
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -44,10 +44,10 @@ using var client = new ModbusTcpClient(host, port)
---
Published under MIT License (see [**tl;dr**Legal])
Published under MIT License (see [choose a license])
[v1.1b3]: https://modbus.org/docs/Modbus_Application_Protocol_V1_1b3.pdf
[v1.0b]: https://modbus.org/docs/Modbus_Messaging_Implementation_Guide_V1_0b.pdf
[**tl;dr**Legal]: https://www.tldrlegal.com/license/mit-license
[v1.1b3]: https://modbus.org/docs/Modbus_Application_Protocol_V1_1b3.pdf
[v1.0b]: https://modbus.org/docs/Modbus_Messaging_Implementation_Guide_V1_0b.pdf
[choose a license]: https://choosealicense.com/licenses/mit/

View File

@@ -0,0 +1,32 @@
using System.Net;
namespace AMWD.Protocols.Modbus.Tcp.Utils
{
internal class IPEndPointWrapper
{
private IPEndPoint _ipEndPoint;
public IPEndPointWrapper(EndPoint endPoint)
{
_ipEndPoint = (IPEndPoint)endPoint;
}
#region Properties
/// <inheritdoc cref="IPEndPoint.Address"/>
public virtual IPAddress Address
{
get => _ipEndPoint.Address;
set => _ipEndPoint.Address = value;
}
/// <inheritdoc cref="IPEndPoint.Port"/>
public virtual int Port
{
get => _ipEndPoint.Port;
set => _ipEndPoint.Port = value;
}
#endregion Properties
}
}

View File

@@ -12,10 +12,6 @@ namespace AMWD.Protocols.Modbus.Tcp.Utils
{
private readonly NetworkStream _stream;
[Obsolete("Constructor only for mocking on UnitTests!", error: true)]
public NetworkStreamWrapper()
{ }
public NetworkStreamWrapper(NetworkStream stream)
{
_stream = stream;

View File

@@ -0,0 +1,29 @@
using System;
using System.Net.Sockets;
namespace AMWD.Protocols.Modbus.Tcp.Utils
{
internal class SocketWrapper : IDisposable
{
private Socket _socket;
public SocketWrapper(Socket socket)
{
_socket = socket;
}
/// <inheritdoc cref="Socket.DualMode" />
public virtual bool DualMode
{
get => _socket.DualMode;
set => _socket.DualMode = value;
}
/// <inheritdoc cref="Socket.IsBound" />
public virtual bool IsBound
=> _socket.IsBound;
public virtual void Dispose()
=> _socket.Dispose();
}
}

View File

@@ -3,19 +3,30 @@ using System.Net;
using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks;
using System.Transactions;
namespace AMWD.Protocols.Modbus.Tcp.Utils
{
/// <inheritdoc cref="TcpClient" />
[System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
internal class TcpClientWrapper(AddressFamily addressFamily) : IDisposable
internal class TcpClientWrapper : IDisposable
{
#region Fields
private readonly TcpClient _client = new(addressFamily);
private readonly TcpClient _client;
#endregion Fields
public TcpClientWrapper(AddressFamily addressFamily)
{
_client = new TcpClient(addressFamily);
}
public TcpClientWrapper(TcpClient client)
{
_client = client ?? throw new ArgumentNullException(nameof(client));
}
#region Properties
/// <inheritdoc cref="TcpClient.Connected" />

View File

@@ -0,0 +1,91 @@
using System;
using System.Net;
using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks;
namespace AMWD.Protocols.Modbus.Tcp.Utils
{
[System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
internal class TcpListenerWrapper : IDisposable
{
#region Fields
private TcpListener _tcpListener;
#endregion Fields
#region Constructor
public TcpListenerWrapper(IPAddress localaddr, int port)
{
_tcpListener = new TcpListener(localaddr, port);
}
#endregion Constructor
#region Properties
/// <inheritdoc cref="TcpListener.LocalEndpoint"/>
public virtual IPEndPointWrapper LocalIPEndPoint
=> new(_tcpListener.LocalEndpoint);
public virtual SocketWrapper Socket
=> new(_tcpListener.Server);
#endregion Properties
#region Methods
/// <summary>
/// Accepts a pending connection request as a cancellable asynchronous operation.
/// </summary>
/// <remarks>
/// <para>
/// This operation will not block. The returned <see cref="Task{TResult}"/> object will complete after the TCP connection has been accepted.
/// </para>
/// <para>
/// Use the <see cref="TcpClientWrapper.GetStream"/> method to obtain the underlying <see cref="NetworkStreamWrapper"/> of the returned <see cref="TcpClientWrapper"/> in the <see cref="Task{TResult}"/>.
/// The <see cref="NetworkStreamWrapper"/> will provide you with methods for sending and receiving with the remote host.
/// When you are through with the <see cref="TcpClientWrapper"/>, be sure to call its <see cref="TcpClientWrapper.Close"/> method.
/// </para>
/// </remarks>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the asynchronous operation</param>
/// <returns>
/// The task object representing the asynchronous operation.
/// The <see cref="Task{TResult}.Result"/> property on the task object returns a <see cref="TcpClientWrapper"/> used to send and receive data.
/// </returns>
/// <exception cref="InvalidOperationException">The listener has not been started with a call to <see cref="Start"/>.</exception>
/// <exception cref="SocketException">
/// Use the <see cref="SocketException.ErrorCode"/> property to obtain the specific error code.
/// When you have obtained this code, you can refer to the
/// <see href="https://learn.microsoft.com/en-us/windows/desktop/winsock/windows-sockets-error-codes-2">Windows Sockets version 2 API error code</see>
/// documentation for a detailed description of the error.
/// </exception>
/// <exception cref="OperationCanceledException">The cancellation token was canceled. This exception is stored into the returned task.</exception>
public virtual async Task<TcpClientWrapper> AcceptTcpClientAsync(CancellationToken cancellationToken = default)
{
#if NET8_0_OR_GREATER
var tcpClient = await _tcpListener.AcceptTcpClientAsync(cancellationToken);
#else
var tcpClient = await _tcpListener.AcceptTcpClientAsync();
#endif
return new TcpClientWrapper(tcpClient);
}
public virtual void Start()
=> _tcpListener.Start();
public virtual void Stop()
=> _tcpListener.Stop();
public virtual void Dispose()
{
#if NET8_0_OR_GREATER
_tcpListener.Dispose();
#endif
}
#endregion Methods
}
}

View File

@@ -2,35 +2,31 @@
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<LangVersion>12.0</LangVersion>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<CollectCoverage>true</CollectCoverage>
<CoverletOutputFormat>Cobertura</CoverletOutputFormat>
<GeneratePackageOnBuild>false</GeneratePackageOnBuild>
<GenerateDocumentationFile>false</GenerateDocumentationFile>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.2">
<PackageReference Include="coverlet.msbuild" Version="6.0.4">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="coverlet.msbuild" Version="6.0.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.10.0" />
<PackageReference Include="Moq" Version="4.20.70" />
<PackageReference Include="MSTest.TestAdapter" Version="3.4.3" />
<PackageReference Include="MSTest.TestFramework" Version="3.4.3" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="MSTest.TestAdapter" Version="3.7.2" />
<PackageReference Include="MSTest.TestFramework" Version="3.7.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\AMWD.Protocols.Modbus.Common\AMWD.Protocols.Modbus.Common.csproj" />
<ProjectReference Include="..\AMWD.Protocols.Modbus.Serial\AMWD.Protocols.Modbus.Serial.csproj" />
<ProjectReference Include="..\AMWD.Protocols.Modbus.Tcp\AMWD.Protocols.Modbus.Tcp.csproj" />
<ProjectReference Include="$(SolutionDir)\AMWD.Protocols.Modbus.Common\AMWD.Protocols.Modbus.Common.csproj" />
<ProjectReference Include="$(SolutionDir)\AMWD.Protocols.Modbus.Serial\AMWD.Protocols.Modbus.Serial.csproj" />
<ProjectReference Include="$(SolutionDir)\AMWD.Protocols.Modbus.Tcp\AMWD.Protocols.Modbus.Tcp.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,23 @@
using System.Reflection;
namespace AMWD.Protocols.Modbus.Tests
{
internal static class Helper
{
public static T CreateInstance<T>(params object[] args)
{
var type = typeof(T);
object instance = type.Assembly.CreateInstance(
typeName: type.FullName,
ignoreCase: false,
bindingAttr: BindingFlags.Instance | BindingFlags.NonPublic,
binder: null,
args: args,
culture: null,
activationAttributes: null);
return (T)instance;
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -13,13 +13,15 @@ namespace AMWD.Protocols.Modbus.Tests.Serial
[TestInitialize]
public void Initialize()
{
string portName = "COM-42";
_genericConnectionMock = new Mock<IModbusConnection>();
_genericConnectionMock.Setup(c => c.IdleTimeout).Returns(TimeSpan.FromSeconds(40));
_genericConnectionMock.Setup(c => c.ConnectTimeout).Returns(TimeSpan.FromSeconds(30));
_genericConnectionMock.Setup(c => c.ReadTimeout).Returns(TimeSpan.FromSeconds(20));
_genericConnectionMock.Setup(c => c.WriteTimeout).Returns(TimeSpan.FromSeconds(10));
_serialConnectionMock = new Mock<ModbusSerialConnection>();
_serialConnectionMock = new Mock<ModbusSerialConnection>(portName);
_serialConnectionMock.Setup(c => c.IdleTimeout).Returns(TimeSpan.FromSeconds(10));
_serialConnectionMock.Setup(c => c.ConnectTimeout).Returns(TimeSpan.FromSeconds(20));
@@ -28,7 +30,7 @@ namespace AMWD.Protocols.Modbus.Tests.Serial
_serialConnectionMock.Setup(c => c.DriverEnabledRS485).Returns(true);
_serialConnectionMock.Setup(c => c.InterRequestDelay).Returns(TimeSpan.FromSeconds(50));
_serialConnectionMock.Setup(c => c.PortName).Returns("COM-42");
_serialConnectionMock.Setup(c => c.PortName).Returns(portName);
_serialConnectionMock.Setup(c => c.BaudRate).Returns(BaudRate.Baud2400);
_serialConnectionMock.Setup(c => c.DataBits).Returns(7);
_serialConnectionMock.Setup(c => c.Handshake).Returns(Handshake.XOnXOff);
@@ -231,5 +233,18 @@ namespace AMWD.Protocols.Modbus.Tests.Serial
_serialConnectionMock.VerifyNoOtherCalls();
}
[TestMethod]
public void ShouldPrintCleanString()
{
// Arrange
using var client = new ModbusSerialClient(_serialConnectionMock.Object);
// Act
string str = client.ToString();
// Assert
SnapshotAssert.AreEqual(str);
}
}
}

View File

@@ -90,6 +90,21 @@ namespace AMWD.Protocols.Modbus.Tests.Serial
connection.Dispose();
}
[DataTestMethod]
[DataRow(null)]
[DataRow("")]
[DataRow(" ")]
[ExpectedException(typeof(ArgumentNullException))]
public void ShouldThrowArgumentNullExceptionOnCreate(string portName)
{
// Arrange
// Act
using var test = new ModbusSerialClient(portName);
// Assert - ArgumentNullException
}
[TestMethod]
[ExpectedException(typeof(ObjectDisposedException))]
public async Task ShouldThrowDisposedExceptionOnInvokeAsync()
@@ -467,7 +482,7 @@ namespace AMWD.Protocols.Modbus.Tests.Serial
return Task.FromResult(0);
});
var connection = new ModbusSerialConnection();
var connection = new ModbusSerialConnection("some-port");
// Replace real connection with mock
var connectionField = connection.GetType().GetField("_serialPort", BindingFlags.NonPublic | BindingFlags.Instance);

View File

@@ -0,0 +1,9 @@
DeviceIdentification
VendorName: VendorName
ProductCode: ProductCode
MajorMinorRevision: MajorMinorRevision
VendorUrl:
ProductName:
ModelName:
UserApplicationName:
IsIndividualAccessAllowed: False

View File

@@ -0,0 +1,8 @@
Serial Client COM-42
BaudRate: 2400
DataBits: 7
StopBits: 1.5
Parity: space
Handshake: xonxoff
RtsEnable: true
DriverEnabledRS485: true

View File

@@ -0,0 +1,83 @@
using System.Collections.Generic;
using System.IO;
using System.Runtime.CompilerServices;
using System.Text;
namespace AMWD.Protocols.Modbus.Tests
{
// ================================================================================================================================ //
// Source: https://git.am-wd.de/am-wd/common/-/blob/fb26e441a48214aaae72003c4a5ac33d5c7b929a/src/AMWD.Common.Test/SnapshotAssert.cs //
// ================================================================================================================================ //
[System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
internal sealed class SnapshotAssert
{
/// <summary>
/// Tests whether the specified string is equal to the saved snapshot.
/// </summary>
/// <param name="actual">The current aggregated content string.</param>
/// <param name="message">An optional message to display if the assertion fails.</param>
/// <param name="callerFilePath">The absolute file path of the calling file (filled automatically on compile time).</param>
/// <param name="callerMemberName">The name of the calling method (filled automatically on compile time).</param>
public static void AreEqual(string actual, string message = null, [CallerFilePath] string callerFilePath = null, [CallerMemberName] string callerMemberName = null)
{
string cleanLineEnding = actual
.Replace("\r\n", "\n") // Windows
.Replace("\r", "\n"); // MacOS
AreEqual(Encoding.UTF8.GetBytes(cleanLineEnding), message, callerFilePath, callerMemberName);
}
/// <summary>
/// Tests whether the specified byte array is equal to the saved snapshot.
/// </summary>
/// <param name="actual">The current aggregated content bytes.</param>
/// <param name="message">An optional message to display if the assertion fails.</param>
/// <param name="callerFilePath">The absolute file path of the calling file (filled automatically on compile time).</param>
/// <param name="callerMemberName">The name of the calling method (filled automatically on compile time).</param>
public static void AreEqual(byte[] actual, string message = null, [CallerFilePath] string callerFilePath = null, [CallerMemberName] string callerMemberName = null)
=> AreEqual(actual, null, message, callerFilePath, callerMemberName);
/// <summary>
/// Tests whether the specified byte array is equal to the saved snapshot.
/// </summary>
/// <remarks>
/// The past has shown, that e.g. wkhtmltopdf prints the current timestamp at the beginning of the PDF file.
/// Therefore you can specify which sequences of bytes should be excluded from the comparison.
/// </remarks>
/// <param name="actual">The current aggregated content bytes.</param>
/// <param name="excludedSequences">The excluded sequences.</param>
/// <param name="message">An optional message to display if the assertion fails.</param>
/// <param name="callerFilePath">The absolute file path of the calling file (filled automatically on compile time).</param>
/// <param name="callerMemberName">The name of the calling method (filled automatically on compile time).</param>
public static void AreEqual(byte[] actual, List<(int Start, int Length)> excludedSequences = null, string message = null, [CallerFilePath] string callerFilePath = null, [CallerMemberName] string callerMemberName = null)
{
string callerDirectory = Path.GetDirectoryName(callerFilePath);
string callerFileName = Path.GetFileNameWithoutExtension(callerFilePath);
string snapshotDirectory = Path.Combine(callerDirectory, "Snapshots", callerFileName);
string snapshotFilePath = Path.Combine(snapshotDirectory, $"{callerMemberName}.snap.bin");
if (File.Exists(snapshotFilePath))
{
byte[] expected = File.ReadAllBytes(snapshotFilePath);
if (actual.Length != expected.Length)
Assert.Fail(message);
for (int i = 0; i < actual.Length; i++)
{
if (excludedSequences?.Any(s => s.Start <= i && i < s.Start + s.Length) == true)
continue;
if (actual[i] != expected[i])
Assert.Fail(message);
}
}
else
{
if (!Directory.Exists(snapshotDirectory))
Directory.CreateDirectory(snapshotDirectory);
File.WriteAllBytes(snapshotFilePath, actual);
}
}
}
}

View File

@@ -498,7 +498,7 @@ namespace AMWD.Protocols.Modbus.Tests.Tcp
private ModbusTcpConnection GetTcpConnection()
{
_networkStreamMock = new Mock<NetworkStreamWrapper>();
_networkStreamMock = new Mock<NetworkStreamWrapper>(null);
_networkStreamMock
.Setup(ns => ns.WriteAsync(It.IsAny<ReadOnlyMemory<byte>>(), It.IsAny<CancellationToken>()))
.Callback<ReadOnlyMemory<byte>, CancellationToken>((req, _) => _networkRequestCallbacks.Add(req.ToArray()))

File diff suppressed because it is too large Load Diff

View File

@@ -35,7 +35,9 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AMWD.Protocols.Modbus.Tcp",
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AMWD.Protocols.Modbus.Serial", "AMWD.Protocols.Modbus.Serial\AMWD.Protocols.Modbus.Serial.csproj", "{D966826F-EE6C-4BC0-9185-C2A9A50FD586}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AMWD.Protocols.Modbus.Proxy", "AMWD.Protocols.Modbus.Proxy\AMWD.Protocols.Modbus.Proxy.csproj", "{C30EBE45-E3B8-4997-95DF-8F94B31C8E1A}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CliClient", "CliClient\CliClient.csproj", "{B0E53462-B0ED-4685-8AA5-948DC160EE27}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CliProxy", "CliProxy\CliProxy.csproj", "{AC922E80-E9B6-493D-B1D1-752527E883ED}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
@@ -59,10 +61,14 @@ Global
{D966826F-EE6C-4BC0-9185-C2A9A50FD586}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D966826F-EE6C-4BC0-9185-C2A9A50FD586}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D966826F-EE6C-4BC0-9185-C2A9A50FD586}.Release|Any CPU.Build.0 = Release|Any CPU
{C30EBE45-E3B8-4997-95DF-8F94B31C8E1A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C30EBE45-E3B8-4997-95DF-8F94B31C8E1A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C30EBE45-E3B8-4997-95DF-8F94B31C8E1A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C30EBE45-E3B8-4997-95DF-8F94B31C8E1A}.Release|Any CPU.Build.0 = Release|Any CPU
{B0E53462-B0ED-4685-8AA5-948DC160EE27}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B0E53462-B0ED-4685-8AA5-948DC160EE27}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B0E53462-B0ED-4685-8AA5-948DC160EE27}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B0E53462-B0ED-4685-8AA5-948DC160EE27}.Release|Any CPU.Build.0 = Release|Any CPU
{AC922E80-E9B6-493D-B1D1-752527E883ED}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{AC922E80-E9B6-493D-B1D1-752527E883ED}.Debug|Any CPU.Build.0 = Debug|Any CPU
{AC922E80-E9B6-493D-B1D1-752527E883ED}.Release|Any CPU.ActiveCfg = Release|Any CPU
{AC922E80-E9B6-493D-B1D1-752527E883ED}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE

View File

@@ -2,12 +2,37 @@
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
_no changes_
_nothing changed yet_
## [v0.4.0] (2025-01-29)
### Added
- Small CLI client for Modbus communication.
- Small CLI proxy to forward messages.
- `VirtualModbusClient` added to `AMWD.Protocols.Modbus.Common`.
### Changed
- The `ModbusTcpProxy.ReadWriteTimeout` has a default value of 100 seconds (same default as a `HttpClient` has).
- The `ModbusRtuProxy` moved from `AMWD.Protocols.Modbus.Proxy` to `AMWD.Protocols.Modbus.Serial`.
- The `ModbusTcpProxy` moved from `AMWD.Protocols.Modbus.Proxy` to `AMWD.Protocols.Modbus.Tcp`.
- Server implementations are proxies with a virtual Modbus client.
### Removed
- Discontinue the `AMWD.Protocols.Modbus.Proxy` package (introduced in [v0.3.0]).
### Fixed
- Wrong _following bytes_ calculation in `ModbusTcpProxy`.
- Wrong processing of `WriteMultipleHoldingRegisters` for proxies.
## [v0.3.2] (2024-09-04)
@@ -28,21 +53,21 @@ _no changes_
### Added
- New `AMWD.Protocols.Modbus.Proxy` package, that contains the server implementations as proxies
- New `AMWD.Protocols.Modbus.Proxy` package, that contains the server implementations as proxies.
### Changed
- Renamed `ModbusSerialServer` to `ModbusRtuServer` to clearify the protocol that is used
- Made `Protocol` property of `ModbusClientBase` non-abstract
- Renamed `ModbusSerialServer` to `ModbusRtuServer` to clearify the protocol that is used.
- Made `Protocol` property of `ModbusClientBase` non-abstract.
### Fixed
- Issue with missing client on TCP connection when using default constructor (seems that `AddressFamily.Unknown` caused the problem)
- Issue with missing client on TCP connection when using default constructor (seems that `AddressFamily.Unknown` caused the problem).
## [v0.2.0] (2024-04-02)
First "final" re-implementation
First "final" re-implementation.
## v0.1.0 (2022-08-28)
@@ -52,7 +77,8 @@ So this tag is only here for documentation purposes of the NuGet Gallery.
[Unreleased]: https://github.com/AM-WD/AMWD.Protocols.Modbus/compare/v0.3.2...HEAD
[Unreleased]: https://github.com/AM-WD/AMWD.Protocols.Modbus/compare/v0.4.0...HEAD
[v0.4.0]: https://github.com/AM-WD/AMWD.Protocols.Modbus/compare/v0.3.2...v0.4.0
[v0.3.2]: https://github.com/AM-WD/AMWD.Protocols.Modbus/compare/v0.3.1...v0.3.2
[v0.3.1]: https://github.com/AM-WD/AMWD.Protocols.Modbus/compare/v0.3.0...v0.3.1
[v0.3.0]: https://github.com/AM-WD/AMWD.Protocols.Modbus/compare/v0.2.0...v0.3.0

35
CliClient/Cli/Argument.cs Normal file
View File

@@ -0,0 +1,35 @@
namespace AMWD.Common.Cli
{
/// <summary>
/// Represents a logical argument in the command line. Options with their additional
/// parameters are combined in one argument.
/// </summary>
internal class Argument
{
/// <summary>
/// Initialises a new instance of the <see cref="Argument"/> class.
/// </summary>
/// <param name="option">The <see cref="Option"/> that is set in this argument; or null.</param>
/// <param name="values">The additional parameter values for the option; or the argument value.</param>
internal Argument(Option option, string[] values)
{
Option = option;
Values = values;
}
/// <summary>
/// Gets the <see cref="Option"/> that is set in this argument; or null.
/// </summary>
public Option Option { get; private set; }
/// <summary>
/// Gets the additional parameter values for the option; or the argument value.
/// </summary>
public string[] Values { get; private set; }
/// <summary>
/// Gets the first item of <see cref="Values"/>; or null.
/// </summary>
public string Value => Values.Length > 0 ? Values[0] : null;
}
}

View File

@@ -0,0 +1,366 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
namespace AMWD.Common.Cli
{
/// <summary>
/// Provides options and arguments parsing from command line arguments or a single string.
/// </summary>
internal class CommandLineParser
{
#region Private data
private string[] _args;
private List<Argument> _parsedArguments;
private readonly List<Option> _options = [];
#endregion Private data
#region Configuration properties
/// <summary>
/// Gets or sets a value indicating whether the option names are case-sensitive.
/// (Default: false)
/// </summary>
public bool IsCaseSensitive { get; set; }
/// <summary>
/// Gets or sets a value indicating whether incomplete options can be automatically
/// completed if there is only a single matching option.
/// (Default: true)
/// </summary>
public bool AutoCompleteOptions { get; set; } = true;
#endregion Configuration properties
#region Custom arguments line parsing
// Source: http://stackoverflow.com/a/23961658/143684
/// <summary>
/// Parses a single string into an arguments array.
/// </summary>
/// <param name="argsString">The string that contains the entire command line.</param>
public static string[] ParseArgsString(string argsString)
{
// Collects the split argument strings
var args = new List<string>();
// Builds the current argument
var currentArg = new StringBuilder();
// Indicates whether the last character was a backslash escape character
bool escape = false;
// Indicates whether we're in a quoted range
bool inQuote = false;
// Indicates whether there were quotes in the current arguments
bool hadQuote = false;
// Remembers the previous character
char prevCh = '\0';
// Iterate all characters from the input string
for (int i = 0; i < argsString.Length; i++)
{
char ch = argsString[i];
if (ch == '\\' && !escape)
{
// Beginning of a backslash-escape sequence
escape = true;
}
else if (ch == '\\' && escape)
{
// Double backslash, keep one
currentArg.Append(ch);
escape = false;
}
else if (ch == '"' && !escape)
{
// Toggle quoted range
inQuote = !inQuote;
hadQuote = true;
if (inQuote && prevCh == '"')
{
// Doubled quote within a quoted range is like escaping
currentArg.Append(ch);
}
}
else if (ch == '"' && escape)
{
// Backslash-escaped quote, keep it
currentArg.Append(ch);
escape = false;
}
else if (char.IsWhiteSpace(ch) && !inQuote)
{
if (escape)
{
// Add pending escape char
currentArg.Append('\\');
escape = false;
}
// Accept empty arguments only if they are quoted
if (currentArg.Length > 0 || hadQuote)
{
args.Add(currentArg.ToString());
}
// Reset for next argument
currentArg.Clear();
hadQuote = false;
}
else
{
if (escape)
{
// Add pending escape char
currentArg.Append('\\');
escape = false;
}
// Copy character from input, no special meaning
currentArg.Append(ch);
}
prevCh = ch;
}
// Save last argument
if (currentArg.Length > 0 || hadQuote)
{
args.Add(currentArg.ToString());
}
return [.. args];
}
/// <summary>
/// Reads the command line arguments from a single string.
/// </summary>
/// <param name="argsString">The string that contains the entire command line.</param>
public void ReadArgs(string argsString)
{
_args = ParseArgsString(argsString);
}
#endregion Custom arguments line parsing
#region Options management
/// <summary>
/// Registers a named option without additional parameters.
/// </summary>
/// <param name="name">The option name.</param>
/// <returns>The option instance.</returns>
public Option RegisterOption(string name)
{
return RegisterOption(name, 0);
}
/// <summary>
/// Registers a named option.
/// </summary>
/// <param name="name">The option name.</param>
/// <param name="parameterCount">The number of additional parameters for this option.</param>
/// <returns>The option instance.</returns>
public Option RegisterOption(string name, int parameterCount)
{
var option = new Option(name, parameterCount);
_options.Add(option);
return option;
}
#endregion Options management
#region Parsing method
/// <summary>
/// Parses all command line arguments.
/// </summary>
/// <param name="args">The command line arguments.</param>
public void Parse(string[] args)
{
_args = args ?? throw new ArgumentNullException(nameof(args));
Parse();
}
/// <summary>
/// Parses all command line arguments.
/// </summary>
public void Parse()
{
// Use args of the current process if no other source was given
if (_args == null)
{
_args = Environment.GetCommandLineArgs();
if (_args.Length > 0)
{
// Skip myself (args[0])
_args = _args.Skip(1).ToArray();
}
}
// Clear/reset data
_parsedArguments = [];
foreach (var option in _options)
{
option.IsSet = false;
option.SetCount = 0;
option.Argument = null;
}
var comparison = IsCaseSensitive
? StringComparison.Ordinal
: StringComparison.OrdinalIgnoreCase;
var argumentWalker = new EnumerableWalker<string>(_args);
bool optMode = true;
foreach (string arg in argumentWalker.Cast<string>())
{
if (arg == "--")
{
optMode = false;
}
else if (optMode && (arg.StartsWith("/") || arg.StartsWith("-")))
{
string optName = arg.Substring(arg.StartsWith("--") ? 2 : 1);
// Split option value if separated with : or = instead of whitespace
int separatorIndex = optName.IndexOfAny([':', '=']);
string optValue = null;
if (separatorIndex != -1)
{
optValue = optName.Substring(separatorIndex + 1);
optName = optName.Substring(0, separatorIndex);
}
// Find the option with complete name match
var option = _options.FirstOrDefault(o => o.Names.Any(n => n.Equals(optName, comparison)));
if (option == null)
{
// Try to complete the name to a unique registered option
var matchingOptions = _options.Where(o => o.Names.Any(n => n.StartsWith(optName, comparison))).ToList();
if (AutoCompleteOptions && matchingOptions.Count > 1)
throw new Exception("Invalid option, completion is not unique: " + arg);
if (!AutoCompleteOptions || matchingOptions.Count == 0)
throw new Exception("Unknown option: " + arg);
// Accept the single auto-completed option
option = matchingOptions[0];
}
// Check for single usage
if (option.IsSingle && option.IsSet)
throw new Exception("Option cannot be set multiple times: " + arg);
// Collect option values from next argument strings
string[] values = new string[option.ParameterCount];
for (int i = 0; i < option.ParameterCount; i++)
{
if (optValue != null)
{
// The first value was included in this argument string
values[i] = optValue;
optValue = null;
}
else
{
// Fetch another argument string
values[i] = argumentWalker.GetNext();
}
if (values[i] == null)
throw new Exception("Missing argument " + (i + 1) + " for option: " + arg);
}
var argument = new Argument(option, values);
// Set usage data on the option instance for quick access
option.IsSet = true;
option.SetCount++;
option.Argument = argument;
if (option.Action != null)
{
option.Action(argument);
}
else
{
_parsedArguments.Add(argument);
}
}
else
{
_parsedArguments.Add(new Argument(null, [arg]));
}
}
var missingOption = _options.FirstOrDefault(o => o.IsRequired && !o.IsSet);
if (missingOption != null)
throw new Exception("Missing required option: /" + missingOption.Names[0]);
}
#endregion Parsing method
#region Parsed data properties
/// <summary>
/// Gets the parsed arguments.
/// </summary>
/// <remarks>
/// To avoid exceptions thrown, call the <see cref="Parse()"/> method in advance for
/// exception handling.
/// </remarks>
public Argument[] Arguments
{
get
{
if (_parsedArguments == null)
Parse();
return [.. _parsedArguments];
}
}
/// <summary>
/// Gets the options that are set in the command line, including their value.
/// </summary>
/// <remarks>
/// To avoid exceptions thrown, call the <see cref="Parse()"/> method in advance for
/// exception handling.
/// </remarks>
public Option[] SetOptions
{
get
{
if (_parsedArguments == null)
Parse();
return _parsedArguments
.Where(a => a.Option != null)
.Select(a => a.Option)
.ToArray();
}
}
/// <summary>
/// Gets the free arguments that are set in the command line and don't belong to an option.
/// </summary>
/// <remarks>
/// To avoid exceptions thrown, call the <see cref="Parse()"/> method in advance for
/// exception handling.
/// </remarks>
public string[] FreeArguments
{
get
{
if (_parsedArguments == null)
Parse();
return _parsedArguments
.Where(a => a.Option == null)
.Select(a => a.Value)
.ToArray();
}
}
#endregion Parsed data properties
}
}

View File

@@ -0,0 +1,53 @@
using System;
using System.Collections;
using System.Collections.Generic;
namespace AMWD.Common.Cli
{
/// <summary>
/// Walks through an <see cref="IEnumerable{T}"/> and allows retrieving additional items.
/// </summary>
/// <typeparam name="T"></typeparam>
/// <remarks>
/// Initialises a new instance of the <see cref="EnumerableWalker{T}"/> class.
/// </remarks>
/// <param name="array">The array to walk though.</param>
internal class EnumerableWalker<T>(IEnumerable<T> array)
: IEnumerable<T> where T : class
{
private readonly IEnumerable<T> _array = array ?? throw new ArgumentNullException(nameof(array));
private IEnumerator<T> _enumerator;
IEnumerator<T> IEnumerable<T>.GetEnumerator()
{
_enumerator = _array.GetEnumerator();
return _enumerator;
}
/// <summary>
/// Gets the enumerator.
/// </summary>
/// <returns>The enumerator.</returns>
public IEnumerator GetEnumerator()
{
_enumerator = _array.GetEnumerator();
return _enumerator;
}
/// <summary>
/// Gets the next item.
/// </summary>
/// <returns>The next item.</returns>
public T GetNext()
{
if (_enumerator.MoveNext())
{
return _enumerator.Current;
}
else
{
return default;
}
}
}
}

112
CliClient/Cli/Option.cs Normal file
View File

@@ -0,0 +1,112 @@
using System;
using System.Collections.Generic;
namespace AMWD.Common.Cli
{
/// <summary>
/// Represents a named option.
/// </summary>
internal class Option
{
/// <summary>
/// Initialises a new instance of the <see cref="Option"/> class.
/// </summary>
/// <param name="name">The primary name of the option.</param>
/// <param name="parameterCount">The number of additional parameters for this option.</param>
internal Option(string name, int parameterCount)
{
Names = [name];
ParameterCount = parameterCount;
}
/// <summary>
/// Gets the names of this option.
/// </summary>
public List<string> Names { get; private set; }
/// <summary>
/// Gets the number of additional parameters for this option.
/// </summary>
public int ParameterCount { get; private set; }
/// <summary>
/// Gets a value indicating whether this option is required.
/// </summary>
public bool IsRequired { get; private set; }
/// <summary>
/// Gets a value indicating whether this option can only be specified once.
/// </summary>
public bool IsSingle { get; private set; }
/// <summary>
/// Gets the action to invoke when the option is set.
/// </summary>
public Action<Argument> Action { get; private set; }
/// <summary>
/// Gets a value indicating whether this option is set in the command line.
/// </summary>
public bool IsSet { get; internal set; }
/// <summary>
/// Gets the number of times that this option is set in the command line.
/// </summary>
public int SetCount { get; internal set; }
/// <summary>
/// Gets the <see cref="Argument"/> instance that contains additional parameters set
/// for this option.
/// </summary>
public Argument Argument { get; internal set; }
/// <summary>
/// Gets the value of the <see cref="Argument"/> instance for this option.
/// </summary>
public string Value => Argument?.Value;
/// <summary>
/// Sets alias names for this option.
/// </summary>
/// <param name="names">The alias names for this option.</param>
/// <returns>The current <see cref="Option"/> instance.</returns>
public Option Alias(params string[] names)
{
Names.AddRange(names);
return this;
}
/// <summary>
/// Marks this option as required. If a required option is not set in the command line,
/// an exception is thrown on parsing.
/// </summary>
/// <returns>The current <see cref="Option"/> instance.</returns>
public Option Required()
{
IsRequired = true;
return this;
}
/// <summary>
/// Marks this option as single. If a single option is set multiple times in the
/// command line, an exception is thrown on parsing.
/// </summary>
/// <returns>The current <see cref="Option"/> instance.</returns>
public Option Single()
{
IsSingle = true;
return this;
}
/// <summary>
/// Sets the action to invoke when the option is set.
/// </summary>
/// <param name="action">The action to invoke when the option is set.</param>
/// <returns>The current <see cref="Option"/> instance.</returns>
public Option Do(Action<Argument> action)
{
Action = action;
return this;
}
}
}

View File

@@ -0,0 +1,33 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<AssemblyName>modbus-client</AssemblyName>
<RootNamespace>AMWD.Protocols.Modbus.CliClient</RootNamespace>
<Product>Modbus CLI client</Product>
<Description>Small CLI client for Modbus communication.</Description>
<IsPackable>false</IsPackable>
<SignAssembly>false</SignAssembly>
<GeneratePackageOnBuild>false</GeneratePackageOnBuild>
<InvariantGlobalization>true</InvariantGlobalization>
</PropertyGroup>
<ItemGroup>
<None Remove="$(SolutionDir)/package-icon.png" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="System.IO.Ports" Version="8.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="$(SolutionDir)\AMWD.Protocols.Modbus.Serial\AMWD.Protocols.Modbus.Serial.csproj" />
<ProjectReference Include="$(SolutionDir)\AMWD.Protocols.Modbus.Tcp\AMWD.Protocols.Modbus.Tcp.csproj" />
</ItemGroup>
</Project>

628
CliClient/Program.cs Normal file
View File

@@ -0,0 +1,628 @@
using System;
using System.Diagnostics;
using System.IO.Ports;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using AMWD.Common.Cli;
using AMWD.Protocols.Modbus.Common;
using AMWD.Protocols.Modbus.Common.Contracts;
using AMWD.Protocols.Modbus.Common.Protocols;
using AMWD.Protocols.Modbus.Serial;
using AMWD.Protocols.Modbus.Tcp;
namespace AMWD.Protocols.Modbus.CliClient
{
internal class Program
{
// General
private static string _target;
private static Option _helpOption;
private static Option _debugOption;
private static Option _protocolOption;
private static Option _addressOption;
private static Option _referenceOption;
private static Option _countOption;
private static Option _typeOption;
private static Option _intervalOption;
private static Option _timeoutOption;
private static Option _onceOption;
// Serial
private static Option _baudOption;
private static Option _dataBitsOption;
private static Option _stopBitsOption;
private static Option _parityOption;
private static Option _softSwitchOption;
// TCP
private static Option _portOption;
private static async Task<int> Main(string[] args)
{
if (!ParseArguments(args))
{
Console.WriteLine("Could not parse arguments.");
return 1;
}
if (_helpOption.IsSet)
{
PrintHelp();
return 0;
}
if (string.IsNullOrWhiteSpace(_target))
{
Console.WriteLine("No serial port or tcp host specified.");
return 1;
}
if (!_typeOption.IsSet)
{
Console.WriteLine("No type specified.");
return 1;
}
using var cts = new CancellationTokenSource();
Console.CancelKeyPress += (_, e) =>
{
cts.Cancel();
e.Cancel = true;
};
if (_debugOption.IsSet)
{
Console.Error.Write("Waiting for debugger ");
while (!Debugger.IsAttached)
{
try
{
Console.Write(".");
await Task.Delay(1000, cts.Token);
}
catch (OperationCanceledException)
{
return 0;
}
}
Console.WriteLine();
}
using var client = CreateClient();
if (_protocolOption.IsSet)
{
switch (_protocolOption.Value.ToLower())
{
case "ascii": client.Protocol = new AsciiProtocol(); break;
case "rtu": client.Protocol = new RtuProtocol(); break;
case "tcp": client.Protocol = new TcpProtocol(); break;
}
}
byte deviceAddress = 1;
if (_addressOption.IsSet && byte.TryParse(_addressOption.Value, out byte addressValue))
deviceAddress = addressValue;
ushort reference = 0;
if (_referenceOption.IsSet && ushort.TryParse(_referenceOption.Value, out ushort referenceValue))
reference = referenceValue;
ushort count = 1;
if (_countOption.IsSet && ushort.TryParse(_countOption.Value, out ushort countValue))
count = countValue;
int interval = 1000;
if (_intervalOption.IsSet && int.TryParse(_intervalOption.Value, out int intervalValue))
interval = intervalValue;
bool runOnce = _onceOption.IsSet;
do
{
try
{
if (_typeOption.Value.ToLower() == "id")
{
runOnce = true;
var deviceIdentification = await client.ReadDeviceIdentificationAsync(deviceAddress, ModbusDeviceIdentificationCategory.Regular, cancellationToken: cts.Token);
Console.WriteLine(deviceIdentification);
}
else if (_typeOption.Value.ToLower() == "coil")
{
var coils = await client.ReadCoilsAsync(deviceAddress, reference, count, cts.Token);
foreach (var coil in coils)
Console.WriteLine($" Coil {coil.Address}: {coil.Value}");
}
else if (_typeOption.Value.ToLower() == "discrete")
{
var discreteInputs = await client.ReadDiscreteInputsAsync(deviceAddress, reference, count, cts.Token);
foreach (var discreteInput in discreteInputs)
Console.WriteLine($" Discrete Input {discreteInput.Address}: {discreteInput.Value}");
}
else if (_typeOption.Value.StartsWith("input", StringComparison.OrdinalIgnoreCase))
{
string type = _typeOption.Value.ToLower().Split(':').Last();
switch (type)
{
case "hex":
{
var registers = await client.ReadInputRegistersAsync(deviceAddress, reference, count, cts.Token);
for (int i = 0; i < count; i++)
{
var register = registers[i];
Console.WriteLine($" Input Register {register.Address}: {register.HighByte:X2} {register.LowByte:X2}");
}
}
break;
case "i8":
{
var registers = await client.ReadInputRegistersAsync(deviceAddress, reference, count, cts.Token);
for (int i = 0; i < count; i++)
{
var register = registers[i];
Console.WriteLine($" Input Register {register.Address}: {register.GetSByte()}");
}
}
break;
case "i16":
{
var registers = await client.ReadInputRegistersAsync(deviceAddress, reference, count, cts.Token);
for (int i = 0; i < count; i++)
{
var register = registers[i];
Console.WriteLine($" Input Register {register.Address}: {register.GetInt16()}");
}
}
break;
case "i32":
{
int cnt = count * 2;
var registers = await client.ReadInputRegistersAsync(deviceAddress, reference, (ushort)cnt, cts.Token);
for (int i = 0; i < cnt; i += 2)
{
var subRegisters = registers.Skip(i).Take(2);
Console.WriteLine($" Input Register {subRegisters.First().Address}: {subRegisters.GetInt32()}");
}
}
break;
case "i64":
{
int cnt = count * 4;
var registers = await client.ReadInputRegistersAsync(deviceAddress, reference, (ushort)cnt, cts.Token);
for (int i = 0; i < cnt; i += 4)
{
var subRegisters = registers.Skip(i).Take(4);
Console.WriteLine($" Input Register {subRegisters.First().Address}: {subRegisters.GetInt64()}");
}
}
break;
case "u8":
{
var registers = await client.ReadInputRegistersAsync(deviceAddress, reference, count, cts.Token);
for (int i = 0; i < count; i++)
{
var register = registers[i];
Console.WriteLine($" Input Register {register.Address}: {register.GetByte()}");
}
}
break;
case "u16":
{
var registers = await client.ReadInputRegistersAsync(deviceAddress, reference, count, cts.Token);
for (int i = 0; i < count; i++)
{
var register = registers[i];
Console.WriteLine($" Input Register {register.Address}: {register.GetUInt16()}");
}
}
break;
case "u32":
{
int cnt = count * 2;
var registers = await client.ReadInputRegistersAsync(deviceAddress, reference, (ushort)cnt, cts.Token);
for (int i = 0; i < cnt; i += 2)
{
var subRegisters = registers.Skip(i).Take(2);
Console.WriteLine($" Input Register {subRegisters.First().Address}: {subRegisters.GetUInt32()}");
}
}
break;
case "u64":
{
int cnt = count * 4;
var registers = await client.ReadInputRegistersAsync(deviceAddress, reference, (ushort)cnt, cts.Token);
for (int i = 0; i < cnt; i += 4)
{
var subRegisters = registers.Skip(i).Take(4);
Console.WriteLine($" Input Register {subRegisters.First().Address}: {subRegisters.GetUInt64()}");
}
}
break;
case "f32":
{
int cnt = count * 2;
var registers = await client.ReadInputRegistersAsync(deviceAddress, reference, (ushort)cnt, cts.Token);
for (int i = 0; i < cnt; i += 2)
{
var subRegisters = registers.Skip(i).Take(2);
Console.WriteLine($" Input Register {subRegisters.First().Address}: {subRegisters.GetSingle()}");
}
}
break;
case "f64":
{
int cnt = count * 4;
var registers = await client.ReadInputRegistersAsync(deviceAddress, reference, (ushort)cnt, cts.Token);
for (int i = 0; i < cnt; i += 4)
{
var subRegisters = registers.Skip(i).Take(4);
Console.WriteLine($" Input Register {subRegisters.First().Address}: {subRegisters.GetDouble()}");
}
}
break;
}
}
else if (_typeOption.Value.StartsWith("holding", StringComparison.OrdinalIgnoreCase))
{
string type = _typeOption.Value.ToLower().Split(':').Last();
switch (type)
{
case "hex":
{
var registers = await client.ReadHoldingRegistersAsync(deviceAddress, reference, count, cts.Token);
for (int i = 0; i < count; i++)
{
var register = registers[i];
Console.WriteLine($" Holding Register {register.Address}: {register.HighByte:X2} {register.LowByte:X2}");
}
}
break;
case "i8":
{
var registers = await client.ReadHoldingRegistersAsync(deviceAddress, reference, count, cts.Token);
for (int i = 0; i < count; i++)
{
var register = registers[i];
Console.WriteLine($" Holding Register {register.Address}: {register.GetSByte()}");
}
}
break;
case "i16":
{
var registers = await client.ReadHoldingRegistersAsync(deviceAddress, reference, count, cts.Token);
for (int i = 0; i < count; i++)
{
var register = registers[i];
Console.WriteLine($" Holding Register {register.Address}: {register.GetInt16()}");
}
}
break;
case "i32":
{
int cnt = count * 2;
var registers = await client.ReadHoldingRegistersAsync(deviceAddress, reference, (ushort)cnt, cts.Token);
for (int i = 0; i < cnt; i += 2)
{
var subRegisters = registers.Skip(i).Take(2);
Console.WriteLine($" Holding Register {subRegisters.First().Address}: {subRegisters.GetInt32()}");
}
}
break;
case "i64":
{
int cnt = count * 4;
var registers = await client.ReadHoldingRegistersAsync(deviceAddress, reference, (ushort)cnt, cts.Token);
for (int i = 0; i < cnt; i += 4)
{
var subRegisters = registers.Skip(i).Take(4);
Console.WriteLine($" Holding Register {subRegisters.First().Address}: {subRegisters.GetInt64()}");
}
}
break;
case "u8":
{
var registers = await client.ReadHoldingRegistersAsync(deviceAddress, reference, count, cts.Token);
for (int i = 0; i < count; i++)
{
var register = registers[i];
Console.WriteLine($" Holding Register {register.Address}: {register.GetByte()}");
}
}
break;
case "u16":
{
var registers = await client.ReadHoldingRegistersAsync(deviceAddress, reference, count, cts.Token);
for (int i = 0; i < count; i++)
{
var register = registers[i];
Console.WriteLine($" Holding Register {register.Address}: {register.GetUInt16()}");
}
}
break;
case "u32":
{
int cnt = count * 2;
var registers = await client.ReadHoldingRegistersAsync(deviceAddress, reference, (ushort)cnt, cts.Token);
for (int i = 0; i < cnt; i += 2)
{
var subRegisters = registers.Skip(i).Take(2);
Console.WriteLine($" Holding Register {subRegisters.First().Address}: {subRegisters.GetUInt32()}");
}
}
break;
case "u64":
{
int cnt = count * 4;
var registers = await client.ReadHoldingRegistersAsync(deviceAddress, reference, (ushort)cnt, cts.Token);
for (int i = 0; i < cnt; i += 4)
{
var subRegisters = registers.Skip(i).Take(4);
Console.WriteLine($" Holding Register {subRegisters.First().Address}: {subRegisters.GetUInt64()}");
}
}
break;
case "f32":
{
int cnt = count * 2;
var registers = await client.ReadHoldingRegistersAsync(deviceAddress, reference, (ushort)cnt, cts.Token);
for (int i = 0; i < cnt; i += 2)
{
var subRegisters = registers.Skip(i).Take(2);
Console.WriteLine($" Holding Register {subRegisters.First().Address}: {subRegisters.GetSingle()}");
}
}
break;
case "f64":
{
int cnt = count * 4;
var registers = await client.ReadHoldingRegistersAsync(deviceAddress, reference, (ushort)cnt, cts.Token);
for (int i = 0; i < cnt; i += 4)
{
var subRegisters = registers.Skip(i).Take(4);
Console.WriteLine($" Holding Register {subRegisters.First().Address}: {subRegisters.GetDouble()}");
}
}
break;
}
}
else
{
Console.Error.WriteLine($"Unknown type: {_typeOption.Value}");
return 1;
}
Console.WriteLine();
await Task.Delay(interval, cts.Token);
}
catch (OperationCanceledException) when (cts.Token.IsCancellationRequested)
{
return 0;
}
catch (Exception ex)
{
Console.WriteLine($" {ex.GetType().Name}: {ex.Message}");
}
}
while (!runOnce && !cts.Token.IsCancellationRequested);
return 0;
}
private static bool ParseArguments(string[] args)
{
var cmdLine = new CommandLineParser();
_helpOption = cmdLine.RegisterOption("help").Alias("h");
_debugOption = cmdLine.RegisterOption("debug");
// General Options
_protocolOption = cmdLine.RegisterOption("protocol", 1).Alias("m");
_addressOption = cmdLine.RegisterOption("address", 1).Alias("a");
_referenceOption = cmdLine.RegisterOption("reference", 1).Alias("r");
_countOption = cmdLine.RegisterOption("count", 1).Alias("c");
_typeOption = cmdLine.RegisterOption("type", 1).Alias("t");
_intervalOption = cmdLine.RegisterOption("interval", 1).Alias("i");
_timeoutOption = cmdLine.RegisterOption("timeout", 1).Alias("o");
_onceOption = cmdLine.RegisterOption("once").Alias("1");
// Serial Options
_baudOption = cmdLine.RegisterOption("baud", 1).Alias("b");
_dataBitsOption = cmdLine.RegisterOption("data-bits", 1).Alias("d");
_stopBitsOption = cmdLine.RegisterOption("stop-bits", 1).Alias("s");
_parityOption = cmdLine.RegisterOption("parity", 1).Alias("p");
_softSwitchOption = cmdLine.RegisterOption("enable-rs485");
// TCP Options
_portOption = cmdLine.RegisterOption("port", 1).Alias("p");
try
{
cmdLine.Parse(args);
_target = cmdLine.FreeArguments.FirstOrDefault();
return true;
}
catch (Exception ex)
{
Console.Error.WriteLine(ex.Message);
return false;
}
}
private static void PrintHelp()
{
Console.WriteLine($"Usage: {typeof(Program).Assembly.GetName().Name} [OPTIONS] <serial-port>|<tcp-host>");
Console.WriteLine();
Console.WriteLine("Serial Port:");
Console.WriteLine(" COM1, COM2, ... on Windows");
Console.WriteLine(" /dev/ttyS0, /dev/ttyUSB0, ... on Linux");
Console.WriteLine();
Console.WriteLine("TCP Host:");
Console.WriteLine(" 192.168.x.y as IPv4");
Console.WriteLine(" fd00:1234:x:y::z as IPv6");
Console.WriteLine();
Console.WriteLine("General Options:");
Console.WriteLine(" -h, --help");
Console.WriteLine(" Shows this help message.");
Console.WriteLine();
Console.WriteLine(" --debug");
Console.WriteLine(" Waits for a debugger to attach before starting.");
Console.WriteLine();
Console.WriteLine(" -m, --protocol <ascii|rtu|tcp>");
Console.WriteLine(" Select which protocol to use.");
Console.WriteLine();
Console.WriteLine(" -a, --address #");
Console.WriteLine(" The slave/device address. 1-247 for serial, 0-255 for TCP. Default: 1");
Console.WriteLine();
Console.WriteLine(" -r, --reference #");
Console.WriteLine(" The start reference to read from. 0-65535. Default: 0");
Console.WriteLine();
Console.WriteLine(" -c, --count #");
Console.WriteLine(" The number of values to read. Default: 1");
Console.WriteLine();
Console.WriteLine(" -t, --type <coil|discrete>");
Console.WriteLine(" Reads a discrete value (bool): Coil or Discrete Input.");
Console.WriteLine();
Console.WriteLine(" -t, --type input:<kind>");
Console.WriteLine(" Reads an input register. Kind: (e.g. i32)");
Console.WriteLine(" hex = print as HEX representation");
Console.WriteLine(" i = signed integer (8, 16, 32, 64)");
Console.WriteLine(" u = unsigned integer (8, 16, 32, 64)");
Console.WriteLine(" f = floating point (32, 64)");
Console.WriteLine();
Console.WriteLine(" -t, --type holding:<kind>");
Console.WriteLine(" Reads a holding register. Kind: (e.g. i32)");
Console.WriteLine(" hex = print as HEX representation");
Console.WriteLine(" i = signed integer (8, 16, 32, 64)");
Console.WriteLine(" u = unsigned integer (8, 16, 32, 64)");
Console.WriteLine(" f = floating point (32, 64)");
Console.WriteLine();
Console.WriteLine(" -t, --type id");
Console.WriteLine(" Tries to read the device identification (Fn 43, Regular).");
Console.WriteLine(" This option implies --once.");
Console.WriteLine();
Console.WriteLine(" -i, --interval #");
Console.WriteLine(" The polling interval in milliseconds. Default: 1000");
Console.WriteLine();
Console.WriteLine(" -o, --timeout #");
Console.WriteLine(" The timeout in milliseconds. Default: 1000");
Console.WriteLine();
Console.WriteLine(" -1, --once");
Console.WriteLine(" Just query once, no interval polling.");
Console.WriteLine();
Console.WriteLine();
Console.WriteLine("Serial Options:");
Console.WriteLine(" -b, --baud #");
Console.WriteLine(" The baud rate (e.g. 9600). Default: 19200");
Console.WriteLine();
Console.WriteLine(" -d, --databits #");
Console.WriteLine(" The number of data bits (7/8 for ASCII, otherwise 8). Default: 8");
Console.WriteLine();
Console.WriteLine(" -s, --stopbits #");
Console.WriteLine(" The number of stop bits (1/2). Default: 1");
Console.WriteLine();
Console.WriteLine(" -p, --parity <none|odd|even>");
Console.WriteLine(" The kind of parity. Default: even");
Console.WriteLine();
Console.WriteLine(" --enable-rs485");
Console.WriteLine(" Enables the RS485 software switch for serial adapters capable of RS232 and RS485.");
Console.WriteLine();
Console.WriteLine();
Console.WriteLine("TCP Options:");
Console.WriteLine(" -p, --port #");
Console.WriteLine(" The TCP port of the remote device. Default: 502");
Console.WriteLine();
}
private static bool IsSerialTarget()
{
return OperatingSystem.IsWindows()
? _target.StartsWith("COM", StringComparison.OrdinalIgnoreCase)
: _target.StartsWith("/dev/", StringComparison.OrdinalIgnoreCase);
}
private static ModbusClientBase CreateClient()
{
int timeout = 1000;
if (_timeoutOption.IsSet && int.TryParse(_timeoutOption.Value, out int timeoutValue))
timeout = timeoutValue;
if (IsSerialTarget())
{
BaudRate baudRate = BaudRate.Baud19200;
if (_baudOption.IsSet && int.TryParse(_baudOption.Value, out int baudRateValue))
baudRate = (BaudRate)baudRateValue;
int dataBits = 8;
if (_dataBitsOption.IsSet && int.TryParse(_dataBitsOption.Value, out int dataBitsValue))
dataBits = dataBitsValue;
StopBits stopBits = StopBits.One;
if (_stopBitsOption.IsSet && float.TryParse(_stopBitsOption.Value, out float stopBitsValue))
{
switch (stopBitsValue)
{
case 1.0f: stopBits = StopBits.One; break;
case 1.5f: stopBits = StopBits.OnePointFive; break;
case 2.0f: stopBits = StopBits.Two; break;
}
}
Parity parity = Parity.Even;
if (_parityOption.IsSet)
{
switch (_parityOption.Value.ToLower())
{
case "none": parity = Parity.None; break;
case "odd": parity = Parity.Odd; break;
case "even": parity = Parity.Even; break;
}
}
bool enableRs485 = _softSwitchOption.IsSet;
var client = new ModbusSerialClient(_target)
{
BaudRate = baudRate,
DataBits = dataBits,
StopBits = stopBits,
Parity = parity,
ReadTimeout = TimeSpan.FromMilliseconds(timeout),
WriteTimeout = TimeSpan.FromMilliseconds(timeout),
DriverEnabledRS485 = enableRs485
};
Console.WriteLine(client);
return client;
}
else
{
int port = 502;
if (_portOption.IsSet && int.TryParse(_portOption.Value, out int portValue))
port = portValue;
var client = new ModbusTcpClient(_target)
{
Port = port,
ReadTimeout = TimeSpan.FromMilliseconds(timeout),
WriteTimeout = TimeSpan.FromMilliseconds(timeout),
};
Console.WriteLine(client);
return client;
}
}
}
}

View File

@@ -0,0 +1,9 @@
{
"profiles": {
"ConsoleApp": {
"commandName": "Project",
"commandLineArgs": "--debug COM1",
"remoteDebugEnabled": false
}
}
}

95
CliClient/README.md Normal file
View File

@@ -0,0 +1,95 @@
# Modbus CLI client
This project contains a small CLI tool to test Modbus connections.
```
Usage: modbus-client [OPTIONS] <serial-port>|<tcp-host>
Serial Port:
COM1, COM2, ... on Windows
/dev/ttyS0, /dev/ttyUSB0, ... on Linux
TCP Host:
192.168.x.y as IPv4
fd00:1234:x:y::z as IPv6
General Options:
-h, --help
Shows this help message.
--debug
Waits for a debugger to attach before starting.
-m, --protocol <ascii|rtu|tcp>
Select which protocol to use.
-a, --address #
The slave/device address. 1-247 for serial, 0-255 for TCP. Default: 1
-r, --reference #
The start reference to read from. 0-65535. Default: 0
-c, --count #
The number of values to read. Default: 1
-t, --type <coil|discrete>
Reads a discrete value (bool): Coil or Discrete Input.
-t, --type input:<kind>
Reads an input register. Kind: (e.g. i32)
hex = print as HEX representation
i = signed integer (8, 16, 32, 64)
u = unsigned integer (8, 16, 32, 64)
f = floating point (32, 64)
-t, --type holding:<kind>
Reads a holding register. Kind: (e.g. i32)
hex = print as HEX representation
i = signed integer (8, 16, 32, 64)
u = unsigned integer (8, 16, 32, 64)
f = floating point (32, 64)
-t, --type id
Tries to read the device identification (Fn 43, Regular).
This option implies --once.
-i, --interval #
The polling interval in milliseconds. Default: 1000
-o, --timeout #
The timeout in milliseconds. Default: 1000
-1, --once
Just query once, no interval polling.
Serial Options:
-b, --baud #
The baud rate (e.g. 9600). Default: 19200
-d, --databits #
The number of data bits (7/8 for ASCII, otherwise 8). Default: 8
-s, --stopbits #
The number of stop bits (1/2). Default: 1
-p, --parity <none|odd|even>
The kind of parity. Default: even
--enable-rs485
Enables the RS485 software switch for serial adapters capable of RS232 and RS485.
TCP Options:
-p, --port #
The TCP port of the remote device. Default: 502
```
---
Published under MIT License (see [choose a license])
[choose a license]: https://choosealicense.com/licenses/mit/

35
CliProxy/Cli/Argument.cs Normal file
View File

@@ -0,0 +1,35 @@
namespace AMWD.Common.Cli
{
/// <summary>
/// Represents a logical argument in the command line. Options with their additional
/// parameters are combined in one argument.
/// </summary>
internal class Argument
{
/// <summary>
/// Initialises a new instance of the <see cref="Argument"/> class.
/// </summary>
/// <param name="option">The <see cref="Option"/> that is set in this argument; or null.</param>
/// <param name="values">The additional parameter values for the option; or the argument value.</param>
internal Argument(Option option, string[] values)
{
Option = option;
Values = values;
}
/// <summary>
/// Gets the <see cref="Option"/> that is set in this argument; or null.
/// </summary>
public Option Option { get; private set; }
/// <summary>
/// Gets the additional parameter values for the option; or the argument value.
/// </summary>
public string[] Values { get; private set; }
/// <summary>
/// Gets the first item of <see cref="Values"/>; or null.
/// </summary>
public string Value => Values.Length > 0 ? Values[0] : null;
}
}

View File

@@ -0,0 +1,366 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
namespace AMWD.Common.Cli
{
/// <summary>
/// Provides options and arguments parsing from command line arguments or a single string.
/// </summary>
internal class CommandLineParser
{
#region Private data
private string[] _args;
private List<Argument> _parsedArguments;
private readonly List<Option> _options = [];
#endregion Private data
#region Configuration properties
/// <summary>
/// Gets or sets a value indicating whether the option names are case-sensitive.
/// (Default: false)
/// </summary>
public bool IsCaseSensitive { get; set; }
/// <summary>
/// Gets or sets a value indicating whether incomplete options can be automatically
/// completed if there is only a single matching option.
/// (Default: true)
/// </summary>
public bool AutoCompleteOptions { get; set; } = true;
#endregion Configuration properties
#region Custom arguments line parsing
// Source: http://stackoverflow.com/a/23961658/143684
/// <summary>
/// Parses a single string into an arguments array.
/// </summary>
/// <param name="argsString">The string that contains the entire command line.</param>
public static string[] ParseArgsString(string argsString)
{
// Collects the split argument strings
var args = new List<string>();
// Builds the current argument
var currentArg = new StringBuilder();
// Indicates whether the last character was a backslash escape character
bool escape = false;
// Indicates whether we're in a quoted range
bool inQuote = false;
// Indicates whether there were quotes in the current arguments
bool hadQuote = false;
// Remembers the previous character
char prevCh = '\0';
// Iterate all characters from the input string
for (int i = 0; i < argsString.Length; i++)
{
char ch = argsString[i];
if (ch == '\\' && !escape)
{
// Beginning of a backslash-escape sequence
escape = true;
}
else if (ch == '\\' && escape)
{
// Double backslash, keep one
currentArg.Append(ch);
escape = false;
}
else if (ch == '"' && !escape)
{
// Toggle quoted range
inQuote = !inQuote;
hadQuote = true;
if (inQuote && prevCh == '"')
{
// Doubled quote within a quoted range is like escaping
currentArg.Append(ch);
}
}
else if (ch == '"' && escape)
{
// Backslash-escaped quote, keep it
currentArg.Append(ch);
escape = false;
}
else if (char.IsWhiteSpace(ch) && !inQuote)
{
if (escape)
{
// Add pending escape char
currentArg.Append('\\');
escape = false;
}
// Accept empty arguments only if they are quoted
if (currentArg.Length > 0 || hadQuote)
{
args.Add(currentArg.ToString());
}
// Reset for next argument
currentArg.Clear();
hadQuote = false;
}
else
{
if (escape)
{
// Add pending escape char
currentArg.Append('\\');
escape = false;
}
// Copy character from input, no special meaning
currentArg.Append(ch);
}
prevCh = ch;
}
// Save last argument
if (currentArg.Length > 0 || hadQuote)
{
args.Add(currentArg.ToString());
}
return [.. args];
}
/// <summary>
/// Reads the command line arguments from a single string.
/// </summary>
/// <param name="argsString">The string that contains the entire command line.</param>
public void ReadArgs(string argsString)
{
_args = ParseArgsString(argsString);
}
#endregion Custom arguments line parsing
#region Options management
/// <summary>
/// Registers a named option without additional parameters.
/// </summary>
/// <param name="name">The option name.</param>
/// <returns>The option instance.</returns>
public Option RegisterOption(string name)
{
return RegisterOption(name, 0);
}
/// <summary>
/// Registers a named option.
/// </summary>
/// <param name="name">The option name.</param>
/// <param name="parameterCount">The number of additional parameters for this option.</param>
/// <returns>The option instance.</returns>
public Option RegisterOption(string name, int parameterCount)
{
var option = new Option(name, parameterCount);
_options.Add(option);
return option;
}
#endregion Options management
#region Parsing method
/// <summary>
/// Parses all command line arguments.
/// </summary>
/// <param name="args">The command line arguments.</param>
public void Parse(string[] args)
{
_args = args ?? throw new ArgumentNullException(nameof(args));
Parse();
}
/// <summary>
/// Parses all command line arguments.
/// </summary>
public void Parse()
{
// Use args of the current process if no other source was given
if (_args == null)
{
_args = Environment.GetCommandLineArgs();
if (_args.Length > 0)
{
// Skip myself (args[0])
_args = _args.Skip(1).ToArray();
}
}
// Clear/reset data
_parsedArguments = [];
foreach (var option in _options)
{
option.IsSet = false;
option.SetCount = 0;
option.Argument = null;
}
var comparison = IsCaseSensitive
? StringComparison.Ordinal
: StringComparison.OrdinalIgnoreCase;
var argumentWalker = new EnumerableWalker<string>(_args);
bool optMode = true;
foreach (string arg in argumentWalker.Cast<string>())
{
if (arg == "--")
{
optMode = false;
}
else if (optMode && (arg.StartsWith("/") || arg.StartsWith("-")))
{
string optName = arg.Substring(arg.StartsWith("--") ? 2 : 1);
// Split option value if separated with : or = instead of whitespace
int separatorIndex = optName.IndexOfAny([':', '=']);
string optValue = null;
if (separatorIndex != -1)
{
optValue = optName.Substring(separatorIndex + 1);
optName = optName.Substring(0, separatorIndex);
}
// Find the option with complete name match
var option = _options.FirstOrDefault(o => o.Names.Any(n => n.Equals(optName, comparison)));
if (option == null)
{
// Try to complete the name to a unique registered option
var matchingOptions = _options.Where(o => o.Names.Any(n => n.StartsWith(optName, comparison))).ToList();
if (AutoCompleteOptions && matchingOptions.Count > 1)
throw new Exception("Invalid option, completion is not unique: " + arg);
if (!AutoCompleteOptions || matchingOptions.Count == 0)
throw new Exception("Unknown option: " + arg);
// Accept the single auto-completed option
option = matchingOptions[0];
}
// Check for single usage
if (option.IsSingle && option.IsSet)
throw new Exception("Option cannot be set multiple times: " + arg);
// Collect option values from next argument strings
string[] values = new string[option.ParameterCount];
for (int i = 0; i < option.ParameterCount; i++)
{
if (optValue != null)
{
// The first value was included in this argument string
values[i] = optValue;
optValue = null;
}
else
{
// Fetch another argument string
values[i] = argumentWalker.GetNext();
}
if (values[i] == null)
throw new Exception("Missing argument " + (i + 1) + " for option: " + arg);
}
var argument = new Argument(option, values);
// Set usage data on the option instance for quick access
option.IsSet = true;
option.SetCount++;
option.Argument = argument;
if (option.Action != null)
{
option.Action(argument);
}
else
{
_parsedArguments.Add(argument);
}
}
else
{
_parsedArguments.Add(new Argument(null, [arg]));
}
}
var missingOption = _options.FirstOrDefault(o => o.IsRequired && !o.IsSet);
if (missingOption != null)
throw new Exception("Missing required option: /" + missingOption.Names[0]);
}
#endregion Parsing method
#region Parsed data properties
/// <summary>
/// Gets the parsed arguments.
/// </summary>
/// <remarks>
/// To avoid exceptions thrown, call the <see cref="Parse()"/> method in advance for
/// exception handling.
/// </remarks>
public Argument[] Arguments
{
get
{
if (_parsedArguments == null)
Parse();
return [.. _parsedArguments];
}
}
/// <summary>
/// Gets the options that are set in the command line, including their value.
/// </summary>
/// <remarks>
/// To avoid exceptions thrown, call the <see cref="Parse()"/> method in advance for
/// exception handling.
/// </remarks>
public Option[] SetOptions
{
get
{
if (_parsedArguments == null)
Parse();
return _parsedArguments
.Where(a => a.Option != null)
.Select(a => a.Option)
.ToArray();
}
}
/// <summary>
/// Gets the free arguments that are set in the command line and don't belong to an option.
/// </summary>
/// <remarks>
/// To avoid exceptions thrown, call the <see cref="Parse()"/> method in advance for
/// exception handling.
/// </remarks>
public string[] FreeArguments
{
get
{
if (_parsedArguments == null)
Parse();
return _parsedArguments
.Where(a => a.Option == null)
.Select(a => a.Value)
.ToArray();
}
}
#endregion Parsed data properties
}
}

View File

@@ -0,0 +1,53 @@
using System;
using System.Collections;
using System.Collections.Generic;
namespace AMWD.Common.Cli
{
/// <summary>
/// Walks through an <see cref="IEnumerable{T}"/> and allows retrieving additional items.
/// </summary>
/// <typeparam name="T"></typeparam>
/// <remarks>
/// Initialises a new instance of the <see cref="EnumerableWalker{T}"/> class.
/// </remarks>
/// <param name="array">The array to walk though.</param>
internal class EnumerableWalker<T>(IEnumerable<T> array)
: IEnumerable<T> where T : class
{
private readonly IEnumerable<T> _array = array ?? throw new ArgumentNullException(nameof(array));
private IEnumerator<T> _enumerator;
IEnumerator<T> IEnumerable<T>.GetEnumerator()
{
_enumerator = _array.GetEnumerator();
return _enumerator;
}
/// <summary>
/// Gets the enumerator.
/// </summary>
/// <returns>The enumerator.</returns>
public IEnumerator GetEnumerator()
{
_enumerator = _array.GetEnumerator();
return _enumerator;
}
/// <summary>
/// Gets the next item.
/// </summary>
/// <returns>The next item.</returns>
public T GetNext()
{
if (_enumerator.MoveNext())
{
return _enumerator.Current;
}
else
{
return default;
}
}
}
}

112
CliProxy/Cli/Option.cs Normal file
View File

@@ -0,0 +1,112 @@
using System;
using System.Collections.Generic;
namespace AMWD.Common.Cli
{
/// <summary>
/// Represents a named option.
/// </summary>
internal class Option
{
/// <summary>
/// Initialises a new instance of the <see cref="Option"/> class.
/// </summary>
/// <param name="name">The primary name of the option.</param>
/// <param name="parameterCount">The number of additional parameters for this option.</param>
internal Option(string name, int parameterCount)
{
Names = [name];
ParameterCount = parameterCount;
}
/// <summary>
/// Gets the names of this option.
/// </summary>
public List<string> Names { get; private set; }
/// <summary>
/// Gets the number of additional parameters for this option.
/// </summary>
public int ParameterCount { get; private set; }
/// <summary>
/// Gets a value indicating whether this option is required.
/// </summary>
public bool IsRequired { get; private set; }
/// <summary>
/// Gets a value indicating whether this option can only be specified once.
/// </summary>
public bool IsSingle { get; private set; }
/// <summary>
/// Gets the action to invoke when the option is set.
/// </summary>
public Action<Argument> Action { get; private set; }
/// <summary>
/// Gets a value indicating whether this option is set in the command line.
/// </summary>
public bool IsSet { get; internal set; }
/// <summary>
/// Gets the number of times that this option is set in the command line.
/// </summary>
public int SetCount { get; internal set; }
/// <summary>
/// Gets the <see cref="Argument"/> instance that contains additional parameters set
/// for this option.
/// </summary>
public Argument Argument { get; internal set; }
/// <summary>
/// Gets the value of the <see cref="Argument"/> instance for this option.
/// </summary>
public string Value => Argument?.Value;
/// <summary>
/// Sets alias names for this option.
/// </summary>
/// <param name="names">The alias names for this option.</param>
/// <returns>The current <see cref="Option"/> instance.</returns>
public Option Alias(params string[] names)
{
Names.AddRange(names);
return this;
}
/// <summary>
/// Marks this option as required. If a required option is not set in the command line,
/// an exception is thrown on parsing.
/// </summary>
/// <returns>The current <see cref="Option"/> instance.</returns>
public Option Required()
{
IsRequired = true;
return this;
}
/// <summary>
/// Marks this option as single. If a single option is set multiple times in the
/// command line, an exception is thrown on parsing.
/// </summary>
/// <returns>The current <see cref="Option"/> instance.</returns>
public Option Single()
{
IsSingle = true;
return this;
}
/// <summary>
/// Sets the action to invoke when the option is set.
/// </summary>
/// <param name="action">The action to invoke when the option is set.</param>
/// <returns>The current <see cref="Option"/> instance.</returns>
public Option Do(Action<Argument> action)
{
Action = action;
return this;
}
}
}

33
CliProxy/CliProxy.csproj Normal file
View File

@@ -0,0 +1,33 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<AssemblyName>modbus-proxy</AssemblyName>
<RootNamespace>AMWD.Protocols.Modbus.CliProxy</RootNamespace>
<Product>Modbus CLI proxy</Product>
<Description>Small CLI proxy to forward messages.</Description>
<IsPackable>false</IsPackable>
<SignAssembly>false</SignAssembly>
<GeneratePackageOnBuild>false</GeneratePackageOnBuild>
<InvariantGlobalization>true</InvariantGlobalization>
</PropertyGroup>
<ItemGroup>
<None Remove="$(SolutionDir)/package-icon.png" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="System.IO.Ports" Version="8.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="$(SolutionDir)\AMWD.Protocols.Modbus.Serial\AMWD.Protocols.Modbus.Serial.csproj" />
<ProjectReference Include="$(SolutionDir)\AMWD.Protocols.Modbus.Tcp\AMWD.Protocols.Modbus.Tcp.csproj" />
</ItemGroup>
</Project>

374
CliProxy/Program.cs Normal file
View File

@@ -0,0 +1,374 @@
using System;
using System.Diagnostics;
using System.IO.Ports;
using System.Net;
using System.Threading;
using System.Threading.Tasks;
using AMWD.Common.Cli;
using AMWD.Protocols.Modbus.Common.Contracts;
using AMWD.Protocols.Modbus.Common.Protocols;
using AMWD.Protocols.Modbus.Serial;
using AMWD.Protocols.Modbus.Tcp;
namespace AMWD.Protocols.Modbus.CliProxy
{
internal class Program
{
#region General options
private static Option _helpOption;
private static Option _debugOption;
private static Option _serverOption;
private static Option _clientOption;
private static Option _clientProtocolOption;
#endregion General options
#region Server options
private static Option _serverSerialBaudOption;
private static Option _serverSerialDataBitsOption;
private static Option _serverSerialStopBitsOption;
private static Option _serverSerialParityOption;
private static Option _serverSerialDeviceOption;
private static Option _serverTcpHostOption;
private static Option _serverTcpPortOption;
#endregion Server options
#region Client options
private static Option _clientSerialBaudOption;
private static Option _clientSerialDataBitsOption;
private static Option _clientSerialStopBitsOption;
private static Option _clientSerialParityOption;
private static Option _clientSerialDeviceOption;
private static Option _clientSerialSoftEnableOption;
private static Option _clientTcpHostOption;
private static Option _clientTcpPortOption;
#endregion Client options
private static async Task<int> Main(string[] args)
{
if (!ParseArguments(args))
{
Console.WriteLine("Could not parse arguments.");
return 1;
}
if (_helpOption.IsSet)
{
PrintHelp();
return 0;
}
using var cts = new CancellationTokenSource();
Console.CancelKeyPress += (_, e) =>
{
cts.Cancel();
e.Cancel = true;
};
if (_debugOption.IsSet)
{
Console.Error.Write("Waiting for debugger ");
while (!Debugger.IsAttached)
{
try
{
Console.Write(".");
await Task.Delay(1000, cts.Token);
}
catch (OperationCanceledException)
{
return 0;
}
}
Console.WriteLine();
}
try
{
using var client = CreateClient();
if (_clientProtocolOption.IsSet)
{
switch (_clientProtocolOption.Value.ToLower())
{
case "ascii": client.Protocol = new AsciiProtocol(); break;
case "rtu": client.Protocol = new RtuProtocol(); break;
case "tcp": client.Protocol = new TcpProtocol(); break;
}
}
using var proxy = CreateProxy(client);
await proxy.StartAsync(cts.Token);
try
{
await Task.Delay(Timeout.Infinite, cts.Token);
}
finally
{
await proxy.StopAsync();
}
return 0;
}
catch (OperationCanceledException) when (cts.IsCancellationRequested)
{
return 0;
}
catch (Exception ex)
{
Console.Error.WriteLine($"{ex.GetType().Name}: {ex.Message}");
return 1;
}
}
private static bool ParseArguments(string[] args)
{
var cmdLine = new CommandLineParser();
#region General options
_helpOption = cmdLine.RegisterOption("help").Alias("h");
_debugOption = cmdLine.RegisterOption("debug");
_serverOption = cmdLine.RegisterOption("server", 1); // TCP | RTU
_clientOption = cmdLine.RegisterOption("client", 1); // TCP | RTU
_clientProtocolOption = cmdLine.RegisterOption("client-protocol", 1);
#endregion General options
#region Server options
_serverSerialBaudOption = cmdLine.RegisterOption("server-baud", 1);
_serverSerialDataBitsOption = cmdLine.RegisterOption("server-databits", 1);
_serverSerialDeviceOption = cmdLine.RegisterOption("server-device", 1);
_serverSerialStopBitsOption = cmdLine.RegisterOption("server-stopbits", 1);
_serverSerialParityOption = cmdLine.RegisterOption("server-parity", 1);
_serverTcpHostOption = cmdLine.RegisterOption("server-host", 1);
_serverTcpPortOption = cmdLine.RegisterOption("server-port", 1);
#endregion Server options
#region Client options
_clientSerialBaudOption = cmdLine.RegisterOption("client-baud", 1);
_clientSerialDataBitsOption = cmdLine.RegisterOption("client-databits", 1);
_clientSerialDeviceOption = cmdLine.RegisterOption("client-device", 1);
_clientSerialStopBitsOption = cmdLine.RegisterOption("client-stopbits", 1);
_clientSerialParityOption = cmdLine.RegisterOption("client-parity", 1);
_clientSerialSoftEnableOption = cmdLine.RegisterOption("client-enable-rs485");
_clientTcpHostOption = cmdLine.RegisterOption("client-host", 1);
_clientTcpPortOption = cmdLine.RegisterOption("client-port", 1);
#endregion Client options
try
{
cmdLine.Parse(args);
return true;
}
catch (Exception ex)
{
Console.Error.WriteLine(ex.Message);
return false;
}
}
private static void PrintHelp()
{
Console.WriteLine($"Usage: {typeof(Program).Assembly.GetName().Name} --server <rtu|tcp> --client <rtu|tcp> [options]");
Console.WriteLine();
Console.WriteLine("General options:");
Console.WriteLine(" --help, -h");
Console.WriteLine(" Shows this help message.");
Console.WriteLine();
Console.WriteLine(" --debug");
Console.WriteLine(" Waits for a debugger to be attached before starting.");
Console.WriteLine();
Console.WriteLine();
Console.WriteLine("Server options:");
Console.WriteLine(" --server <rtu|tcp>");
Console.WriteLine(" Defines whether to use an RTU or an TCP proxy.");
Console.WriteLine();
Console.WriteLine(" --server-baud #");
Console.WriteLine(" The baud rate (e.g. 9600) to use for the RTU proxy. Default: 19200.");
Console.WriteLine();
Console.WriteLine(" --server-databits #");
Console.WriteLine(" The number of data bits. Default: 8.");
Console.WriteLine();
Console.WriteLine(" --server-device <device-port>");
Console.WriteLine(" The serial port to use (e.g. COM1, /dev/ttyS0).");
Console.WriteLine();
Console.WriteLine(" --server-parity <none|odd|even>");
Console.WriteLine(" The parity to use. Default: even.");
Console.WriteLine();
Console.WriteLine(" --server-stopbits #");
Console.WriteLine(" The number of stop bits. Default: 1.");
Console.WriteLine();
Console.WriteLine(" --server-host <address>");
Console.WriteLine(" The IP address to listen on. Default: 127.0.0.1.");
Console.WriteLine();
Console.WriteLine(" --server-port #");
Console.WriteLine(" The port to listen on. Default: 502.");
Console.WriteLine();
Console.WriteLine();
Console.WriteLine("Client options:");
Console.WriteLine(" --client <rtu|tcp>");
Console.WriteLine(" Defines whether to use an RTU or an TCP client.");
Console.WriteLine();
Console.WriteLine(" --client-protocol <ascii|rtu|tcp>");
Console.WriteLine(" Select which Modbus protocol to use.");
Console.WriteLine();
Console.WriteLine(" --client-baud #");
Console.WriteLine(" The baud rate (e.g. 9600) to use for the RTU client. Default: 19200.");
Console.WriteLine();
Console.WriteLine(" --client-databits #");
Console.WriteLine(" The number of data bits. Default: 8.");
Console.WriteLine();
Console.WriteLine(" --client-device <device-port>");
Console.WriteLine(" The serial port to use (e.g. COM1, /dev/ttyS0).");
Console.WriteLine();
Console.WriteLine(" --client-parity <none|odd|even>");
Console.WriteLine(" The parity to use. Default: even.");
Console.WriteLine();
Console.WriteLine(" --client-stopbits #");
Console.WriteLine(" The number of stop bits. Default: 1.");
Console.WriteLine();
Console.WriteLine(" --client-enable-rs485");
Console.WriteLine(" Enables the RS485 software switch for serial adapters capable of RS232 and RS485.");
Console.WriteLine();
Console.WriteLine(" --client-host <hostname>");
Console.WriteLine(" The host to connect to.");
Console.WriteLine();
Console.WriteLine(" --client-port #");
Console.WriteLine(" The port to connect to. Default: 502.");
Console.WriteLine();
}
private static ModbusClientBase CreateClient()
{
if (!_clientOption.IsSet)
throw new ApplicationException("No client type specified.");
BaudRate baudRate = BaudRate.Baud19200;
if (_clientSerialBaudOption.IsSet && int.TryParse(_clientSerialBaudOption.Value, out int baudRateValue))
baudRate = (BaudRate)baudRateValue;
int dataBits = 8;
if (_clientSerialDataBitsOption.IsSet && int.TryParse(_clientSerialDataBitsOption.Value, out int dataBitsValue))
dataBits = dataBitsValue;
StopBits stopBits = StopBits.One;
if (_clientSerialStopBitsOption.IsSet && float.TryParse(_clientSerialStopBitsOption.Value, out float stopBitsValue))
{
switch (stopBitsValue)
{
case 1.0f: stopBits = StopBits.One; break;
case 1.5f: stopBits = StopBits.OnePointFive; break;
case 2.0f: stopBits = StopBits.Two; break;
}
}
Parity parity = Parity.Even;
if (_clientSerialParityOption.IsSet)
{
switch (_clientSerialParityOption.Value.ToLower())
{
case "none": parity = Parity.None; break;
case "odd": parity = Parity.Odd; break;
case "even": parity = Parity.Even; break;
}
}
bool enableRs485 = _clientSerialSoftEnableOption.IsSet;
int port = 502;
if (_clientTcpPortOption.IsSet && ushort.TryParse(_clientTcpPortOption.Value, out ushort portValue))
port = portValue;
return _clientOption.Value.ToLower() switch
{
"rtu" => new ModbusSerialClient(_clientSerialDeviceOption.Value)
{
BaudRate = baudRate,
DataBits = dataBits,
StopBits = stopBits,
Parity = parity,
DriverEnabledRS485 = enableRs485
},
"tcp" => new ModbusTcpClient(_clientTcpHostOption.Value)
{
Port = port
},
_ => throw new ApplicationException($"Unknown client type: '{_clientOption.Value}'"),
};
}
private static IModbusProxy CreateProxy(ModbusClientBase client)
{
if (!_serverOption.IsSet)
throw new ApplicationException("No proxy type specified.");
BaudRate baudRate = BaudRate.Baud19200;
if (_serverSerialBaudOption.IsSet && int.TryParse(_serverSerialBaudOption.Value, out int baudRateValue))
baudRate = (BaudRate)baudRateValue;
int dataBits = 8;
if (_serverSerialDataBitsOption.IsSet && int.TryParse(_serverSerialDataBitsOption.Value, out int dataBitsValue))
dataBits = dataBitsValue;
StopBits stopBits = StopBits.One;
if (_serverSerialStopBitsOption.IsSet && float.TryParse(_serverSerialStopBitsOption.Value, out float stopBitsValue))
{
switch (stopBitsValue)
{
case 1.0f: stopBits = StopBits.One; break;
case 1.5f: stopBits = StopBits.OnePointFive; break;
case 2.0f: stopBits = StopBits.Two; break;
}
}
Parity parity = Parity.Even;
if (_serverSerialParityOption.IsSet)
{
switch (_serverSerialParityOption.Value.ToLower())
{
case "none": parity = Parity.None; break;
case "odd": parity = Parity.Odd; break;
case "even": parity = Parity.Even; break;
}
}
int port = 502;
if (_serverTcpPortOption.IsSet && ushort.TryParse(_serverTcpPortOption.Value, out ushort portValue))
port = portValue;
return _serverOption.Value.ToLower() switch
{
"rtu" => new ModbusRtuProxy(client, _serverSerialDeviceOption.Value)
{
BaudRate = baudRate,
DataBits = dataBits,
StopBits = stopBits,
Parity = parity
},
"tcp" => new ModbusTcpProxy(client, IPAddress.Parse(_serverTcpHostOption.Value))
{
ListenPort = port
},
_ => throw new ApplicationException($"Unknown client type: '{_serverOption.Value}'"),
};
}
}
}

81
CliProxy/README.md Normal file
View File

@@ -0,0 +1,81 @@
# Modbus CLI proxy
This project contains a small CLI tool to proxy Modbus connections.
```
Usage: modbus-proxy --server <rtu|tcp> --client <rtu|tcp> [options]
General options:
--help, -h
Shows this help message.
--debug
Waits for a debugger to be attached before starting.
Server options:
--server <rtu|tcp>
Defines whether to use an RTU or an TCP proxy.
--server-baud #
The baud rate (e.g. 9600) to use for the RTU proxy. Default: 19200.
--server-databits #
The number of data bits. Default: 8.
--server-device <device-port>
The serial port to use (e.g. COM1, /dev/ttyS0).
--server-parity <none|odd|even>
The parity to use. Default: even.
--server-stopbits #
The number of stop bits. Default: 1.
--server-host <address>
The IP address to listen on. Default: 127.0.0.1.
--server-port #
The port to listen on. Default: 502.
Client options:
--client <rtu|tcp>
Defines whether to use an RTU or an TCP client.
--client-protocol <ascii|rtu|tcp>
Select which Modbus protocol to use.
--client-baud #
The baud rate (e.g. 9600) to use for the RTU client. Default: 19200.
--client-databits #
The number of data bits. Default: 8.
--client-device <device-port>
The serial port to use (e.g. COM1, /dev/ttyS0).
--client-parity <none|odd|even>
The parity to use. Default: even.
--client-stopbits #
The number of stop bits. Default: 1.
--client-enable-rs485
Enables the RS485 software switch for serial adapters capable of RS232 and RS485.
--client-host <hostname>
The host to connect to.
--client-port #
The port to connect to. Default: 502.
```
---
Published under MIT License (see [choose a license])
[choose a license]: https://choosealicense.com/licenses/mit/

View File

@@ -1,11 +1,11 @@
<Project>
<PropertyGroup>
<LangVersion>12.0</LangVersion>
<NrtRevisionFormat>{semvertag:main}{!:-dev}</NrtRevisionFormat>
<AppendTargetFrameworkToOutputPath>true</AppendTargetFrameworkToOutputPath>
<CopyRefAssembliesToPublishDirectory>false</CopyRefAssembliesToPublishDirectory>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<IncludeSourceRevisionInInformationalVersion>false</IncludeSourceRevisionInInformationalVersion>
<RepositoryType>git</RepositoryType>
<RepositoryUrl>https://github.com/AM-WD/AMWD.Protocols.Modbus.git</RepositoryUrl>
@@ -18,7 +18,6 @@
<PackageIcon>package-icon.png</PackageIcon>
<PackageReadmeFile>README.md</PackageReadmeFile>
<PackageProjectUrl>https://wiki.am-wd.de/libs/modbus</PackageProjectUrl>
<Title>Modbus Protocol for .NET</Title>
<Company>AM.WD</Company>
@@ -27,12 +26,21 @@
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<SignAssembly>true</SignAssembly>
<AssemblyOriginatorKeyFile>../AMWD.Protocols.Modbus.snk</AssemblyOriginatorKeyFile>
<AssemblyOriginatorKeyFile>$(SolutionDir)/AMWD.Protocols.Modbus.snk</AssemblyOriginatorKeyFile>
</PropertyGroup>
<PropertyGroup Condition="'$(GITLAB_CI)' == 'true'">
<ContinuousIntegrationBuild>true</ContinuousIntegrationBuild>
</PropertyGroup>
<ItemGroup Condition="'$(SignAssembly)' != 'true'">
<InternalsVisibleTo Include="AMWD.Protocols.Modbus.Tests" />
<InternalsVisibleTo Include="DynamicProxyGenAssembly2" />
</ItemGroup>
<ItemGroup Condition="'$(SignAssembly)' == 'true'">
<InternalsVisibleTo Include="AMWD.Protocols.Modbus.Tests" PublicKey="0024000004800000940000000602000000240000525341310004000001000100adcc4f9f5bb3ac73cb30661f6f35772b8f90a74412925764a960af06ef125bdcec05ed1d139503d5203fb72aa3fa74bab58e82ac2a6cd4b650f8cbf7086a71bc2dfc67e95b8d26d776d60856acf3121f831529b1a4dee91b34ac84f95f71a1165b7783edb591929ba2a684100c92bbed8859c7266fb507f6f55bb6f7fcac80b4" />
<InternalsVisibleTo Include="DynamicProxyGenAssembly2" PublicKey="0024000004800000940000000602000000240000525341310004000001000100c547cac37abd99c8db225ef2f6c8a3602f3b3606cc9891605d02baa56104f4cfc0734aa39b93bf7852f7d9266654753cc297e7d2edfe0bac1cdcf9f717241550e0a7b191195b7667bb4f64bcb8e2121380fd1d9d46ad2d92d2d15605093924cceaf74c4861eff62abf69b9291ed0a340e113be11e6a7d3113e92484cf7045cc7" />
</ItemGroup>
<ItemGroup Condition="'$(GITLAB_CI)' == 'true'">
<SourceLinkGitLabHost Include="$(CI_SERVER_HOST)" Version="$(CI_SERVER_VERSION)" />
@@ -43,11 +51,11 @@
</ItemGroup>
<ItemGroup>
<None Include="../package-icon.png" Pack="true" PackagePath="/" />
<None Include="$(SolutionDir)/package-icon.png" Pack="true" PackagePath="/" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Unclassified.NetRevisionTask" Version="0.4.3">
<PackageReference Include="AMWD.NetRevisionTask" Version="1.2.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>

View File

@@ -1,4 +1,4 @@
The MIT License
MIT License
Copyright (c) Andreas Müller
@@ -9,8 +9,9 @@ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
The above copyright notice and this permission notice (including the next
paragraph) shall be included in all copies or substantial portions of the
Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,

View File

@@ -20,11 +20,6 @@ For example the default protocol versions: `TCP`, `RTU` and `ASCII`.
With this package you'll have anything you need to create your own client implementations.
### [Proxy]
The package contains a TCP and a RTU server implementation as proxy which contains a client of your choice to connect to.
### [Serial]
This package contains some wrappers and implementations for the serial protocol.
@@ -39,16 +34,15 @@ It uses a specific TCP connection implementation and plugs all things from the C
---
Published under [MIT License] (see [**tl;dr**Legal])
Published under [MIT License] (see [choose a license])
[![Buy me a Coffee](https://shields.am-wd.de/badge/PayPal-Buy_me_a_Coffee-yellow?style=flat&logo=paypal)](https://link.am-wd.de/donate)
[![built with Codeium](https://codeium.com/badges/main)](https://link.am-wd.de/codeium)
[see here]: https://github.com/andreasAMmueller/Modbus
[Common]: AMWD.Protocols.Modbus.Common/README.md
[Proxy]: AMWD.Protocols.Modbus.Proxy/README.md
[Serial]: AMWD.Protocols.Modbus.Serial/README.md
[TCP]: AMWD.Protocols.Modbus.Tcp/README.md
[MIT License]: LICENSE.txt
[**tl;dr**Legal]: https://www.tldrlegal.com/license/mit-license
[see here]: https://github.com/andreasAMmueller/Modbus
[Common]: AMWD.Protocols.Modbus.Common/README.md
[Serial]: AMWD.Protocols.Modbus.Serial/README.md
[TCP]: AMWD.Protocols.Modbus.Tcp/README.md
[MIT License]: LICENSE.txt
[choose a license]: https://choosealicense.com/licenses/mit/