diff --git a/AMWD.Protocols.Modbus.Common/Contracts/IModbusProxy.cs b/AMWD.Protocols.Modbus.Common/Contracts/IModbusProxy.cs
new file mode 100644
index 0000000..2f3cbd3
--- /dev/null
+++ b/AMWD.Protocols.Modbus.Common/Contracts/IModbusProxy.cs
@@ -0,0 +1,24 @@
+using System;
+using System.Threading.Tasks;
+using System.Threading;
+
+namespace AMWD.Protocols.Modbus.Common.Contracts
+{
+ ///
+ /// Represents a Modbus proxy.
+ ///
+ public interface IModbusProxy : IDisposable
+ {
+ ///
+ /// Starts the proxy.
+ ///
+ /// A cancellation token used to propagate notification that this operation should be canceled.
+ Task StartAsync(CancellationToken cancellationToken = default);
+
+ ///
+ /// Stops the proxy.
+ ///
+ /// A cancellation token used to propagate notification that this operation should be canceled.
+ Task StopAsync(CancellationToken cancellationToken = default);
+ }
+}
diff --git a/AMWD.Protocols.Modbus.Serial/ModbusRtuProxy.cs b/AMWD.Protocols.Modbus.Serial/ModbusRtuProxy.cs
index 3070dc4..192cd23 100644
--- a/AMWD.Protocols.Modbus.Serial/ModbusRtuProxy.cs
+++ b/AMWD.Protocols.Modbus.Serial/ModbusRtuProxy.cs
@@ -15,7 +15,7 @@ namespace AMWD.Protocols.Modbus.Serial
///
/// Implements a Modbus serial line RTU server proxying all requests to a Modbus client of choice.
///
- public class ModbusRtuProxy : IDisposable
+ public class ModbusRtuProxy : IModbusProxy
{
#region Fields
@@ -165,7 +165,7 @@ namespace AMWD.Protocols.Modbus.Serial
#region Control Methods
///
- /// Starts the server.
+ /// Starts the proxy.
///
/// A cancellation token used to propagate notification that this operation should be canceled.
public Task StartAsync(CancellationToken cancellationToken = default)
@@ -186,7 +186,7 @@ namespace AMWD.Protocols.Modbus.Serial
}
///
- /// Stops the server.
+ /// Stops the proxy.
///
/// A cancellation token used to propagate notification that this operation should be canceled.
public Task StopAsync(CancellationToken cancellationToken = default)
diff --git a/AMWD.Protocols.Modbus.Tcp/ModbusTcpProxy.cs b/AMWD.Protocols.Modbus.Tcp/ModbusTcpProxy.cs
index 10a6584..3e985c8 100644
--- a/AMWD.Protocols.Modbus.Tcp/ModbusTcpProxy.cs
+++ b/AMWD.Protocols.Modbus.Tcp/ModbusTcpProxy.cs
@@ -17,7 +17,7 @@ namespace AMWD.Protocols.Modbus.Tcp
///
/// Implements a Modbus TCP server proxying all requests to a Modbus client of choice.
///
- 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 class.
///
/// The used to request the remote device, that should be proxied.
- /// An to listen on (Default: ).
+ /// An to listen on.
public ModbusTcpProxy(ModbusClientBase client, IPAddress listenAddress)
{
Client = client ?? throw new ArgumentNullException(nameof(client));
diff --git a/AMWD.Protocols.Modbus.Tcp/ModbusTcpServer.cs b/AMWD.Protocols.Modbus.Tcp/ModbusTcpServer.cs
index 648ead7..d937b52 100644
--- a/AMWD.Protocols.Modbus.Tcp/ModbusTcpServer.cs
+++ b/AMWD.Protocols.Modbus.Tcp/ModbusTcpServer.cs
@@ -12,6 +12,10 @@ namespace AMWD.Protocols.Modbus.Tcp
[System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
public class ModbusTcpServer : ModbusTcpProxy
{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The to listen on.
public ModbusTcpServer(IPAddress listenAddress)
: base(new VirtualModbusClient(), listenAddress)
{
diff --git a/AMWD.Protocols.Modbus.sln b/AMWD.Protocols.Modbus.sln
index f21ce82..999f0bf 100644
--- a/AMWD.Protocols.Modbus.sln
+++ b/AMWD.Protocols.Modbus.sln
@@ -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
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 89ec91b..d549998 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/CliClient/CliClient.csproj b/CliClient/CliClient.csproj
index 9948614..11b41c0 100644
--- a/CliClient/CliClient.csproj
+++ b/CliClient/CliClient.csproj
@@ -13,6 +13,8 @@
false
false
false
+
+ true
diff --git a/CliProxy/Cli/Argument.cs b/CliProxy/Cli/Argument.cs
new file mode 100644
index 0000000..5e4a76a
--- /dev/null
+++ b/CliProxy/Cli/Argument.cs
@@ -0,0 +1,35 @@
+namespace AMWD.Common.Cli
+{
+ ///
+ /// Represents a logical argument in the command line. Options with their additional
+ /// parameters are combined in one argument.
+ ///
+ internal class Argument
+ {
+ ///
+ /// Initialises a new instance of the class.
+ ///
+ /// The that is set in this argument; or null.
+ /// The additional parameter values for the option; or the argument value.
+ internal Argument(Option option, string[] values)
+ {
+ Option = option;
+ Values = values;
+ }
+
+ ///
+ /// Gets the that is set in this argument; or null.
+ ///
+ public Option Option { get; private set; }
+
+ ///
+ /// Gets the additional parameter values for the option; or the argument value.
+ ///
+ public string[] Values { get; private set; }
+
+ ///
+ /// Gets the first item of ; or null.
+ ///
+ public string Value => Values.Length > 0 ? Values[0] : null;
+ }
+}
diff --git a/CliProxy/Cli/CommandLineParser.cs b/CliProxy/Cli/CommandLineParser.cs
new file mode 100644
index 0000000..0577971
--- /dev/null
+++ b/CliProxy/Cli/CommandLineParser.cs
@@ -0,0 +1,366 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+
+namespace AMWD.Common.Cli
+{
+ ///
+ /// Provides options and arguments parsing from command line arguments or a single string.
+ ///
+ internal class CommandLineParser
+ {
+ #region Private data
+
+ private string[] _args;
+ private List _parsedArguments;
+ private readonly List _options = [];
+
+ #endregion Private data
+
+ #region Configuration properties
+
+ ///
+ /// Gets or sets a value indicating whether the option names are case-sensitive.
+ /// (Default: false)
+ ///
+ public bool IsCaseSensitive { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether incomplete options can be automatically
+ /// completed if there is only a single matching option.
+ /// (Default: true)
+ ///
+ public bool AutoCompleteOptions { get; set; } = true;
+
+ #endregion Configuration properties
+
+ #region Custom arguments line parsing
+
+ // Source: http://stackoverflow.com/a/23961658/143684
+ ///
+ /// Parses a single string into an arguments array.
+ ///
+ /// The string that contains the entire command line.
+ public static string[] ParseArgsString(string argsString)
+ {
+ // Collects the split argument strings
+ var args = new List();
+
+ // 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];
+ }
+
+ ///
+ /// Reads the command line arguments from a single string.
+ ///
+ /// The string that contains the entire command line.
+ public void ReadArgs(string argsString)
+ {
+ _args = ParseArgsString(argsString);
+ }
+
+ #endregion Custom arguments line parsing
+
+ #region Options management
+
+ ///
+ /// Registers a named option without additional parameters.
+ ///
+ /// The option name.
+ /// The option instance.
+ public Option RegisterOption(string name)
+ {
+ return RegisterOption(name, 0);
+ }
+
+ ///
+ /// Registers a named option.
+ ///
+ /// The option name.
+ /// The number of additional parameters for this option.
+ /// The option instance.
+ public Option RegisterOption(string name, int parameterCount)
+ {
+ var option = new Option(name, parameterCount);
+ _options.Add(option);
+ return option;
+ }
+
+ #endregion Options management
+
+ #region Parsing method
+
+ ///
+ /// Parses all command line arguments.
+ ///
+ /// The command line arguments.
+ public void Parse(string[] args)
+ {
+ _args = args ?? throw new ArgumentNullException(nameof(args));
+ Parse();
+ }
+
+ ///
+ /// Parses all command line arguments.
+ ///
+ 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(_args);
+ bool optMode = true;
+ foreach (string arg in argumentWalker.Cast())
+ {
+ 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
+
+ ///
+ /// Gets the parsed arguments.
+ ///
+ ///
+ /// To avoid exceptions thrown, call the method in advance for
+ /// exception handling.
+ ///
+ public Argument[] Arguments
+ {
+ get
+ {
+ if (_parsedArguments == null)
+ Parse();
+
+ return [.. _parsedArguments];
+ }
+ }
+
+ ///
+ /// Gets the options that are set in the command line, including their value.
+ ///
+ ///
+ /// To avoid exceptions thrown, call the method in advance for
+ /// exception handling.
+ ///
+ public Option[] SetOptions
+ {
+ get
+ {
+ if (_parsedArguments == null)
+ Parse();
+
+ return _parsedArguments
+ .Where(a => a.Option != null)
+ .Select(a => a.Option)
+ .ToArray();
+ }
+ }
+
+ ///
+ /// Gets the free arguments that are set in the command line and don't belong to an option.
+ ///
+ ///
+ /// To avoid exceptions thrown, call the method in advance for
+ /// exception handling.
+ ///
+ public string[] FreeArguments
+ {
+ get
+ {
+ if (_parsedArguments == null)
+ Parse();
+
+ return _parsedArguments
+ .Where(a => a.Option == null)
+ .Select(a => a.Value)
+ .ToArray();
+ }
+ }
+
+ #endregion Parsed data properties
+ }
+}
diff --git a/CliProxy/Cli/EnumerableWalker.cs b/CliProxy/Cli/EnumerableWalker.cs
new file mode 100644
index 0000000..818364c
--- /dev/null
+++ b/CliProxy/Cli/EnumerableWalker.cs
@@ -0,0 +1,53 @@
+using System;
+using System.Collections;
+using System.Collections.Generic;
+
+namespace AMWD.Common.Cli
+{
+ ///
+ /// Walks through an and allows retrieving additional items.
+ ///
+ ///
+ ///
+ /// Initialises a new instance of the class.
+ ///
+ /// The array to walk though.
+ internal class EnumerableWalker(IEnumerable array)
+ : IEnumerable where T : class
+ {
+ private readonly IEnumerable _array = array ?? throw new ArgumentNullException(nameof(array));
+ private IEnumerator _enumerator;
+
+ IEnumerator IEnumerable.GetEnumerator()
+ {
+ _enumerator = _array.GetEnumerator();
+ return _enumerator;
+ }
+
+ ///
+ /// Gets the enumerator.
+ ///
+ /// The enumerator.
+ public IEnumerator GetEnumerator()
+ {
+ _enumerator = _array.GetEnumerator();
+ return _enumerator;
+ }
+
+ ///
+ /// Gets the next item.
+ ///
+ /// The next item.
+ public T GetNext()
+ {
+ if (_enumerator.MoveNext())
+ {
+ return _enumerator.Current;
+ }
+ else
+ {
+ return default;
+ }
+ }
+ }
+}
diff --git a/CliProxy/Cli/Option.cs b/CliProxy/Cli/Option.cs
new file mode 100644
index 0000000..2b6765e
--- /dev/null
+++ b/CliProxy/Cli/Option.cs
@@ -0,0 +1,112 @@
+using System;
+using System.Collections.Generic;
+
+namespace AMWD.Common.Cli
+{
+ ///
+ /// Represents a named option.
+ ///
+ internal class Option
+ {
+ ///
+ /// Initialises a new instance of the class.
+ ///
+ /// The primary name of the option.
+ /// The number of additional parameters for this option.
+ internal Option(string name, int parameterCount)
+ {
+ Names = [name];
+ ParameterCount = parameterCount;
+ }
+
+ ///
+ /// Gets the names of this option.
+ ///
+ public List Names { get; private set; }
+
+ ///
+ /// Gets the number of additional parameters for this option.
+ ///
+ public int ParameterCount { get; private set; }
+
+ ///
+ /// Gets a value indicating whether this option is required.
+ ///
+ public bool IsRequired { get; private set; }
+
+ ///
+ /// Gets a value indicating whether this option can only be specified once.
+ ///
+ public bool IsSingle { get; private set; }
+
+ ///
+ /// Gets the action to invoke when the option is set.
+ ///
+ public Action Action { get; private set; }
+
+ ///
+ /// Gets a value indicating whether this option is set in the command line.
+ ///
+ public bool IsSet { get; internal set; }
+
+ ///
+ /// Gets the number of times that this option is set in the command line.
+ ///
+ public int SetCount { get; internal set; }
+
+ ///
+ /// Gets the instance that contains additional parameters set
+ /// for this option.
+ ///
+ public Argument Argument { get; internal set; }
+
+ ///
+ /// Gets the value of the instance for this option.
+ ///
+ public string Value => Argument?.Value;
+
+ ///
+ /// Sets alias names for this option.
+ ///
+ /// The alias names for this option.
+ /// The current instance.
+ public Option Alias(params string[] names)
+ {
+ Names.AddRange(names);
+ return this;
+ }
+
+ ///
+ /// Marks this option as required. If a required option is not set in the command line,
+ /// an exception is thrown on parsing.
+ ///
+ /// The current instance.
+ public Option Required()
+ {
+ IsRequired = true;
+ return this;
+ }
+
+ ///
+ /// Marks this option as single. If a single option is set multiple times in the
+ /// command line, an exception is thrown on parsing.
+ ///
+ /// The current instance.
+ public Option Single()
+ {
+ IsSingle = true;
+ return this;
+ }
+
+ ///
+ /// Sets the action to invoke when the option is set.
+ ///
+ /// The action to invoke when the option is set.
+ /// The current instance.
+ public Option Do(Action action)
+ {
+ Action = action;
+ return this;
+ }
+ }
+}
diff --git a/CliProxy/CliProxy.csproj b/CliProxy/CliProxy.csproj
new file mode 100644
index 0000000..51679ab
--- /dev/null
+++ b/CliProxy/CliProxy.csproj
@@ -0,0 +1,33 @@
+
+
+
+ Exe
+ net8.0
+
+ modbus-proxy
+ AMWD.Protocols.Modbus.CliProxy
+
+ Modbus CLI proxy
+ Small CLI proxy to forward messages.
+
+ false
+ false
+ false
+
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/CliProxy/Program.cs b/CliProxy/Program.cs
new file mode 100644
index 0000000..512a3a1
--- /dev/null
+++ b/CliProxy/Program.cs
@@ -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 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 --client [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 ");
+ 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 ");
+ Console.WriteLine(" The serial port to use (e.g. COM1, /dev/ttyS0).");
+ Console.WriteLine();
+ Console.WriteLine(" --server-parity ");
+ 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 ");
+ 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 ");
+ Console.WriteLine(" Defines whether to use an RTU or an TCP client.");
+ Console.WriteLine();
+ Console.WriteLine(" --client-protocol ");
+ 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 ");
+ Console.WriteLine(" The serial port to use (e.g. COM1, /dev/ttyS0).");
+ Console.WriteLine();
+ Console.WriteLine(" --client-parity ");
+ 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 ");
+ 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}'"),
+ };
+ }
+ }
+}
diff --git a/CliProxy/README.md b/CliProxy/README.md
new file mode 100644
index 0000000..9724ba9
--- /dev/null
+++ b/CliProxy/README.md
@@ -0,0 +1,81 @@
+# Modbus CLI proxy
+
+This project contains a small CLI tool to proxy Modbus connections.
+
+```
+Usage: modbus-proxy --server --client [options]
+
+General options:
+ --help, -h
+ Shows this help message.
+
+ --debug
+ Waits for a debugger to be attached before starting.
+
+
+Server options:
+ --server
+ 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
+ The serial port to use (e.g. COM1, /dev/ttyS0).
+
+ --server-parity
+ The parity to use. Default: even.
+
+ --server-stopbits #
+ The number of stop bits. Default: 1.
+
+ --server-host
+ The IP address to listen on. Default: 127.0.0.1.
+
+ --server-port #
+ The port to listen on. Default: 502.
+
+
+Client options:
+ --client
+ Defines whether to use an RTU or an TCP client.
+
+ --client-protocol
+ 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
+ The serial port to use (e.g. COM1, /dev/ttyS0).
+
+ --client-parity
+ 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
+ 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/