Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8b3441f6dd | |||
| 63c88f5da7 | |||
| e7300bfbde | |||
| 38dd94471d | |||
| 56664cdac5 | |||
| 4ef7500c3b | |||
| 05759f8e12 | |||
| 6fc7cfda9a | |||
| fb67e0b77e | |||
| ce3d873cd0 | |||
| 1cf49f74ea | |||
| 39863880d5 | |||
| ec0ba31b86 | |||
| 96b5ee21c8 | |||
| 6a231e02cb | |||
| c1a70de6bb | |||
| 6bf011d53f |
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
24
AMWD.Protocols.Modbus.Common/Contracts/IModbusProxy.cs
Normal file
24
AMWD.Protocols.Modbus.Common/Contracts/IModbusProxy.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("AMWD.Protocols.Modbus.Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100adcc4f9f5bb3ac73cb30661f6f35772b8f90a74412925764a960af06ef125bdcec05ed1d139503d5203fb72aa3fa74bab58e82ac2a6cd4b650f8cbf7086a71bc2dfc67e95b8d26d776d60856acf3121f831529b1a4dee91b34ac84f95f71a1165b7783edb591929ba2a684100c92bbed8859c7266fb507f6f55bb6f7fcac80b4")]
|
||||
[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2, PublicKey=0024000004800000940000000602000000240000525341310004000001000100c547cac37abd99c8db225ef2f6c8a3602f3b3606cc9891605d02baa56104f4cfc0734aa39b93bf7852f7d9266654753cc297e7d2edfe0bac1cdcf9f717241550e0a7b191195b7667bb4f64bcb8e2121380fd1d9d46ad2d92d2d15605093924cceaf74c4861eff62abf69b9291ed0a340e113be11e6a7d3113e92484cf7045cc7")]
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using System;
|
||||
#if NET6_0_OR_GREATER
|
||||
using System;
|
||||
#endif
|
||||
|
||||
namespace AMWD.Protocols.Modbus.Common
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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];
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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];
|
||||
|
||||
|
||||
478
AMWD.Protocols.Modbus.Common/Protocols/VirtualProtocol.cs
Normal file
478
AMWD.Protocols.Modbus.Common/Protocols/VirtualProtocol.cs
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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/
|
||||
|
||||
|
||||
180
AMWD.Protocols.Modbus.Common/Utils/VirtualModbusClient.cs
Normal file
180
AMWD.Protocols.Modbus.Common/Utils/VirtualModbusClient.cs
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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))
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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/
|
||||
|
||||
32
AMWD.Protocols.Modbus.Tcp/Utils/IPEndPointWrapper.cs
Normal file
32
AMWD.Protocols.Modbus.Tcp/Utils/IPEndPointWrapper.cs
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
29
AMWD.Protocols.Modbus.Tcp/Utils/SocketWrapper.cs
Normal file
29
AMWD.Protocols.Modbus.Tcp/Utils/SocketWrapper.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
91
AMWD.Protocols.Modbus.Tcp/Utils/TcpListenerWrapper.cs
Normal file
91
AMWD.Protocols.Modbus.Tcp/Utils/TcpListenerWrapper.cs
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
23
AMWD.Protocols.Modbus.Tests/Helper.cs
Normal file
23
AMWD.Protocols.Modbus.Tests/Helper.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
2013
AMWD.Protocols.Modbus.Tests/Serial/ModbusRtuProxyTest.cs
Normal file
2013
AMWD.Protocols.Modbus.Tests/Serial/ModbusRtuProxyTest.cs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
DeviceIdentification
|
||||
VendorName: VendorName
|
||||
ProductCode: ProductCode
|
||||
MajorMinorRevision: MajorMinorRevision
|
||||
VendorUrl:
|
||||
ProductName:
|
||||
ModelName:
|
||||
UserApplicationName:
|
||||
IsIndividualAccessAllowed: False
|
||||
@@ -0,0 +1,8 @@
|
||||
Serial Client COM-42
|
||||
BaudRate: 2400
|
||||
DataBits: 7
|
||||
StopBits: 1.5
|
||||
Parity: space
|
||||
Handshake: xonxoff
|
||||
RtsEnable: true
|
||||
DriverEnabledRS485: true
|
||||
83
AMWD.Protocols.Modbus.Tests/SnapshotAssert.cs
Normal file
83
AMWD.Protocols.Modbus.Tests/SnapshotAssert.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()))
|
||||
|
||||
2220
AMWD.Protocols.Modbus.Tests/Tcp/ModbusTcpProxyTest.cs
Normal file
2220
AMWD.Protocols.Modbus.Tests/Tcp/ModbusTcpProxyTest.cs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
42
CHANGELOG.md
42
CHANGELOG.md
@@ -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
35
CliClient/Cli/Argument.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
366
CliClient/Cli/CommandLineParser.cs
Normal file
366
CliClient/Cli/CommandLineParser.cs
Normal 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
|
||||
}
|
||||
}
|
||||
53
CliClient/Cli/EnumerableWalker.cs
Normal file
53
CliClient/Cli/EnumerableWalker.cs
Normal 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
112
CliClient/Cli/Option.cs
Normal 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
CliClient/CliClient.csproj
Normal file
33
CliClient/CliClient.csproj
Normal 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
628
CliClient/Program.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
9
CliClient/Properties/launchSettings.json
Normal file
9
CliClient/Properties/launchSettings.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"profiles": {
|
||||
"ConsoleApp": {
|
||||
"commandName": "Project",
|
||||
"commandLineArgs": "--debug COM1",
|
||||
"remoteDebugEnabled": false
|
||||
}
|
||||
}
|
||||
}
|
||||
95
CliClient/README.md
Normal file
95
CliClient/README.md
Normal 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
35
CliProxy/Cli/Argument.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
366
CliProxy/Cli/CommandLineParser.cs
Normal file
366
CliProxy/Cli/CommandLineParser.cs
Normal 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
|
||||
}
|
||||
}
|
||||
53
CliProxy/Cli/EnumerableWalker.cs
Normal file
53
CliProxy/Cli/EnumerableWalker.cs
Normal 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
112
CliProxy/Cli/Option.cs
Normal 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
33
CliProxy/CliProxy.csproj
Normal 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
374
CliProxy/Program.cs
Normal 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
81
CliProxy/README.md
Normal 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/
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
20
README.md
20
README.md
@@ -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])
|
||||
[](https://link.am-wd.de/donate)
|
||||
[](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/
|
||||
|
||||
Reference in New Issue
Block a user