Modbus RTU/TCP master (client) communication library for .NET. It provides low-level request APIs, convenient extension methods for common function codes, and a factory for managing multiple Modbus connections.
- .NET Framework 4.7.2 (
net472) - .NET 8 (
net8.0)
Project path:
Junevy.Communication.Modbus/Junevy.Communication.Modbus.csproj
| Code | Name | Extension method |
|---|---|---|
0x01 |
Read Coils | ReadCoils / ReadCoilsAsync |
0x02 |
Read Discrete Inputs | ReadDiscreteInputs / ReadDiscreteInputsAsync |
0x03 |
Read Holding Registers | ReadHoldingRegisters / ReadHoldingRegistersAsync |
0x04 |
Read Input Registers | ReadInputRegisters / ReadInputRegistersAsync |
0x05 |
Write Single Coil | WriteSingleCoil / WriteSingleCoilAsync |
0x06 |
Write Single Register | WriteSingleRegister / WriteSingleRegisterAsync |
0x07 |
Read Exception Status | ReadExceptionStatus / ReadExceptionStatusAsync |
0x08 |
Diagnostics | Diagnostics / DiagnosticsAsync |
0x0B |
Get Comm Event Counter | GetCommEventCounter / GetCommEventCounterAsync |
0x0C |
Get Comm Event Log | GetCommEventLog / GetCommEventLogAsync |
0x0F |
Write Multiple Coils | WriteMultipleCoils / WriteMultipleCoilsAsync |
0x10 |
Write Multiple Registers | WriteMultipleRegisters / WriteMultipleRegistersAsync |
0x11 |
Report Server ID | ReportServerId / ReportServerIdAsync |
0x16 |
Mask Write Register | MaskWriteRegister / MaskWriteRegisterAsync |
0x17 |
Read/Write Multiple Registers | ReadWriteMultipleRegisters / ReadWriteMultipleRegistersAsync |
using Junevy.Communication.Modbus.Extensions;
using Junevy.Communication.Modbus.Tcp;
using var modbus = new ModbusTcpClient(new ModbusTcpClientConfig
{
Address = "192.168.1.100",
Port = 502,
ConnectTimeout = 2000,
ReadTimeout = 2000,
WriteTimeout = 2000
});
if (!modbus.Connect())
{
Console.WriteLine("Connect failed.");
return;
}
var result = modbus.ReadHoldingRegisters(slaveId: 1, start: 0, length: 4);
Console.WriteLine(result.IsSuccess
? string.Join(", ", result.Data!)
: result.ErrorMessage);
modbus.Disconnect();using System.IO.Ports;
using Junevy.Communication.Modbus.Extensions;
using Junevy.Communication.Modbus.Rtu;
using var modbus = new ModbusRtuClient(new ModbusRtuClientConfig
{
PortName = "COM3",
BaudRate = 9600,
Parity = Parity.None,
DataBits = 8,
StopBits = StopBits.One,
ReadTimeout = 2000,
WriteTimeout = 2000
});
modbus.Connect();
var write = modbus.WriteSingleRegister(slaveId: 1, start: 100, value: 1234);
Console.WriteLine(write.IsSuccess ? "Write OK" : write.ErrorMessage);
modbus.Disconnect();using Junevy.Communication.Modbus.DependencyInjection;
using Junevy.Communication.Modbus.Extensions;
using Junevy.Communication.Modbus.Factory;
using Junevy.Communication.Modbus.Tcp;
using Microsoft.Extensions.DependencyInjection;
var services = new ServiceCollection();
services.AddLogging();
services.AddModbusFactory();
using var provider = services.BuildServiceProvider();
var factory = provider.GetRequiredService<IModbusFactory>();
var plc = factory.GetOrAdd("plc-1", new ModbusTcpClientConfig
{
Address = "192.168.1.100",
Port = 502,
Reconnect = true,
RetryCount = 3,
RetryInterval = 200
});
plc.Connect();
var coils = await plc.ReadCoilsAsync(slaveId: 1, start: 0, length: 8);
Console.WriteLine(coils.IsSuccess
? string.Join(", ", coils.Data!)
: coils.ErrorMessage);
plc.Disconnect();For RS-485 multi-drop RTU scenarios, register one physical RTU connection and add aliases for logical slave names:
factory.GetOrAdd("rs485-bus", new ModbusRtuClientConfig { PortName = "COM3" });
factory.RegisterAlias("slave-1", "rs485-bus");
factory.RegisterAlias("slave-2", "rs485-bus");For host containers that do not use IServiceCollection (Prism, DryIoc, Unity, ...), build the factory with ModbusFactoryBuilder and register the instance directly. Unset options mirror the AddModbusFactory defaults; no Microsoft DI package is required:
using Junevy.Communication.Modbus.Factory;
using Microsoft.Extensions.Logging;
// e.g. inside Prism's RegisterTypes(IContainerRegistry containerRegistry):
var loggerFactory = LoggerFactory.Create(b => b.AddSerilog(Log.Logger)); // or NullLoggerFactory.Instance
var factory = ModbusFactoryBuilder.Create()
.WithLoggerFactory(loggerFactory) // optional: used by the factory and every client it creates
.WithConnectionManager(manager) // optional: share the registry with the host container
.Build();
containerRegistry.RegisterInstance<IModbusFactory>(factory);Build() returns a new, independent factory per call. Dispose the factory on application shutdown (it owns the connections).
IModbusFactory creates clients through IModbusClientCreator strategies resolved by configuration type, so a new transport needs no change to the factory:
public sealed class MyClientCreator : IModbusClientCreator
{
public Type ConfigType => typeof(MyConfig); // exact config type
public void Normalize(IModbusConfig c) { /* fill defaults */ }
public IModbus Create(IModbusConfig c) => new MyClient((MyConfig)c);
public string Describe(IModbusConfig c) => "gateway-1";
}Register it after AddModbusFactory(): services.AddSingleton<IModbusClientCreator, MyClientCreator>();
Without a DI container: ModbusFactoryBuilder.Create().WithCreator(new MyClientCreator()).Build()
Implement IModbusPduValidator and pass it to new TcpProtocolParser(validator) / new RtuProtocolParser(validator).
Pass the parser to ModbusFactoryBuilder.WithTcpParser / WithRtuParser.
When a device uses custom behavior, call Request / RequestAsync directly:
using Junevy.Communication.Modbus.Core.Models;
var raw = plc.Request(new ModbusRequest
{
SlaveId = 1,
FunctionCode = ModbusFunctionCode.Diagnostics,
Data = new byte[] { 0x00, 0x00, 0x12, 0x34 }
});ModbusRequest is a pure data class. Data has a per-function-code layout (documented on the property); the transport never mutates a request you pass in.
You do not need to manage TransactionId for TCP requests. The client assigns an auto-incrementing transaction id per logical request (wrapping at ushort.MaxValue) before sending, and matches responses by exact transaction id — a late response from a previous request can never be paired with a new one. Manual assignment only affects the bare frame-building API (ModbusHelper.BuildRequestFrame).
Every failed ModbusResult carries a machine-readable ErrorKind (ModbusErrorKind) in addition to ErrorMessage:
| Kind | Meaning |
|---|---|
Unspecified |
Default for legacy/uncategorized failures |
InvalidRequest |
The request failed local validation before being sent |
ConnectionClosed |
The connection was down, dropped, or could not be (re)established |
Timeout |
Connect/read/write timeout |
ProtocolViolation |
Malformed frame, invalid length, or CRC failure (TCP: the connection is discarded) |
ModbusException |
The slave answered with a Modbus exception response (the exception code is in the message, e.g. Code=0x02) |
Cancelled |
The CancellationToken fired before completion |
Extension methods live in the namespace Junevy.Communication.Modbus.Extensions and are grouped into ModbusBitExtensions (0x01 0x02 0x05 0x0F), ModbusRegisterExtensions (0x03 0x04 0x06 0x10 0x16 0x17) and ModbusDiagnosticsExtensions (0x07 0x08 0x0B 0x0C 0x11); always call them with extension-method syntax.
Modbus exception responses are terminal: they are returned immediately as failed results and are never retried.
Both TCP and RTU transports support request-level retry and optional reconnect:
var tcp = new ModbusTcpClient(new ModbusTcpClientConfig
{
Address = "192.168.1.100",
Port = 502,
Reconnect = true,
RetryCount = 3,
RetryInterval = 200,
ConnectTimeout = 2000,
ReadTimeout = 2000,
WriteTimeout = 2000
});
var rtu = new ModbusRtuClient(new ModbusRtuClientConfig
{
PortName = "COM3",
Reconnect = true,
RetryCount = 3,
RetryInterval = 200
});Behavior:
RetryCountis the number of retries after the first attempt. For TCP, a timeout or a closed connection can only be retried whenReconnect = true; withReconnect = falsethe request returns immediately with the real error (Timeout/ConnectionClosed).- When
Reconnect = true, a failed or closed TCP socket is recreated before the next retry. Note: this also applies after an explicitDisconnect()— the next request silently reconnects. SetReconnect = falseif you wantDisconnect()to stay disconnected. - For RTU, a faulted or closed serial port is closed and reopened before the next retry.
- Modbus exception responses are never retried (terminal failures).
- Communication failures are returned as
ModbusResult.Fail(...)where possible instead of escaping as unhandled exceptions; parameter-validation errors throw standard exceptions (ArgumentExceptionetc.).
- One request at a time is serialized per connection with
SemaphoreSlim. ConnectAsync(CancellationToken)throwsOperationCanceledExceptionwhen cancelled and returnsfalsewhen the connection fails or times out.- Dispose() aborts in-flight I/O and waits for the in-flight request to exit. Requests that were running or queued return ErrorKind.ConnectionClosed; calling Request/Connect after Dispose throws ObjectDisposedException; Disconnect after Dispose is a no-op.
- RTU frames use CRC16 verification.
- TCP responses validate MBAP protocol id, transaction id, and unit id.
- Register addresses are protocol-level zero-based addresses.
- For industrial field use, keep polling intervals larger than the slave response time, set explicit timeouts, enable reconnect for long-running services, and log failed requests with enough device context to diagnose wiring/network faults.
The repository also contains a family of byte-channel libraries for TCP, UDP and serial communication. They share one lifecycle, framing, request/response correlation and heartbeat implementation.
Preview: the family ships as
1.0.0-preview.1. The public API may change before P2 (the PLC protocol suite and the MELSEC validation). Pin the version if you depend on it.
| Package | Purpose | Depends on |
|---|---|---|
Junevy.Communication.Core |
Results and error kinds, backoff policies, timeout scope, named registry | Microsoft.Extensions.*.Abstractions only; no Pipelines, no Channels |
Junevy.Communication.Channels |
Abstractions, framing, correlation, lifecycle, ChannelFactory |
Core, System.IO.Pipelines, System.Threading.Channels |
Junevy.Communication.Tcp |
TcpClientChannel, TcpServer, optional TLS |
Channels |
Junevy.Communication.Udp |
UdpChannel (directed and undirected) |
Channels |
Junevy.Communication.Serial |
SerialChannel |
Channels, System.IO.Ports |
Core does not depend on Pipelines or Channels, so a protocol package that does not use byte channels can reference Core alone. Each package has its own README with examples, timeout tables and platform notes.
Install the transport you need (preview packages need --prerelease):
dotnet add package Junevy.Communication.Tcp --prerelease
using System.Text;
using Junevy.Communication.Channels;
using Junevy.Communication.Tcp;
await using var client = new TcpClientChannel(new TcpClientChannelConfig
{
Host = "192.168.1.100",
Port = 5000,
Framing = new FramingOptions { Mode = FramingMode.Delimiter, Delimiters = new[] { "\r\n" } },
});
if (await client.ConnectAsync() is { IsSuccess: true })
{
var reply = await client.RequestAsync(Encoding.ASCII.GetBytes("READ 100"));
Console.WriteLine(reply.IsSuccess ? Encoding.ASCII.GetString(reply.Data!) : reply.ErrorMessage);
}using Junevy.Communication.Channels;
using Junevy.Communication.Tcp;
var server = new TcpServer(new TcpServerConfig
{
Port = 5000,
Framing = new FramingOptions { Mode = FramingMode.Delimiter, Delimiters = new[] { "\r\n" } },
});
server.FrameReceived += async (sender, e) => await e.Session.SendAsync(e.Data); // echo
await server.StartAsync();
Console.ReadLine();
await server.StopAsync();
server.Dispose();using System.Security.Authentication;
using Junevy.Communication.Tcp;
await using var secure = new TcpClientChannel(new TcpClientChannelConfig
{
Host = "plc.example.local",
Port = 8443,
Tls = new TcpClientTlsOptions { Enabled = true, Protocols = SslProtocols.Tls12 },
});
await secure.ConnectAsync();Certificates come from the Windows certificate store (by thumbprint) or from a PFX file whose password is read from an environment variable. Passwords are never written in configuration.
using System.Text;
using Junevy.Communication.Channels;
using Junevy.Communication.Udp;
await using var udp = new UdpChannel(new UdpChannelConfig
{
RemoteHost = "192.168.1.50",
RemotePort = 4000,
Heartbeat = new HeartbeatOptions { Enabled = true, Payload = "hex:00", ExpectedReply = "hex:01" },
});
await udp.ConnectAsync();
var reply = await udp.RequestAsync(Encoding.ASCII.GetBytes("STATUS?"));using Junevy.Communication.Channels;
using Junevy.Communication.Serial;
await using var serial = new SerialChannel(new SerialChannelConfig
{
PortName = "COM3",
BaudRate = 115200,
Framing = new FramingOptions { Mode = FramingMode.Delimiter, Delimiters = new[] { "\r\n" } },
});
await serial.ConnectAsync();ConnectAsyncreturns aCommResult. It throwsOperationCanceledExceptiononly when the caller cancels.- Reconnect is off by default. Enable it with
Reconnect = new ReconnectOptions { Enabled = true }; the channel then reconnects in the background, without a request to trigger it. DisconnectAsyncdisconnects gracefully and stops reconnection.Disposereleases the channel at once, without draining received frames.- Sending or requesting while not connected returns
NotConnectedimmediately; nothing is queued. - Events (
StateChanged,FrameReceived) are raised on thread-pool threads. Marshal to the UI thread yourself (for example WPF'sDispatcher). - TLS is available for TCP. DTLS is not supported.
- Channels log through an
ILogger<T>passed at construction. Without one they log nothing; frames are logged in hex atDebug.
The skill Skills/using-junevy-channels/SKILL.md describes the full contract, including late replies, heartbeat timing and the serial hot-plug checklist.