Added modbus-proxy CLI tool
This commit is contained in:
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);
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ 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
|
||||
|
||||
@@ -165,7 +165,7 @@ namespace AMWD.Protocols.Modbus.Serial
|
||||
#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)
|
||||
@@ -186,7 +186,7 @@ namespace AMWD.Protocols.Modbus.Serial
|
||||
}
|
||||
|
||||
/// <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)
|
||||
|
||||
@@ -17,7 +17,7 @@ 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
|
||||
|
||||
@@ -41,7 +41,7 @@ namespace AMWD.Protocols.Modbus.Tcp
|
||||
/// 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="listenAddress">An <see cref="IPAddress"/> to listen on.</param>
|
||||
public ModbusTcpProxy(ModbusClientBase client, IPAddress listenAddress)
|
||||
{
|
||||
Client = client ?? throw new ArgumentNullException(nameof(client));
|
||||
|
||||
@@ -12,6 +12,10 @@ namespace AMWD.Protocols.Modbus.Tcp
|
||||
[System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
|
||||
public class ModbusTcpServer : ModbusTcpProxy
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ModbusTcpServer"/> class.
|
||||
/// </summary>
|
||||
/// <param name="listenAddress">The <see cref="IPAddress"/> to listen on.</param>
|
||||
public ModbusTcpServer(IPAddress listenAddress)
|
||||
: base(new VirtualModbusClient(), listenAddress)
|
||||
{
|
||||
|
||||
@@ -37,6 +37,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AMWD.Protocols.Modbus.Seria
|
||||
EndProject
|
||||
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
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
@@ -63,6 +65,10 @@ Global
|
||||
{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
|
||||
|
||||
@@ -9,7 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Added
|
||||
|
||||
- Small CLI tool to test Modbus client communication.
|
||||
- Small CLI client for Modbus communication.
|
||||
- Small CLI proxy to forward messages.
|
||||
- `VirtualModbusClient` added to `AMWD.Protocols.Modbus.Common`.
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
<IsPackable>false</IsPackable>
|
||||
<SignAssembly>false</SignAssembly>
|
||||
<GeneratePackageOnBuild>false</GeneratePackageOnBuild>
|
||||
|
||||
<InvariantGlobalization>true</InvariantGlobalization>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
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/
|
||||
Reference in New Issue
Block a user