Skip to content

About

A modern, high-performance .NET library for industrial communication

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Latest commit

 

History

173 Commits

Folders and files

Repository files navigation

Junevy.Communication.Modbus

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.

Target Frameworks

  • .NET Framework 4.7.2 (net472)
  • .NET 8 (net8.0)

Project path:

Junevy.Communication.Modbus/Junevy.Communication.Modbus.csproj

Supported Function Codes

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

Minimal Usage: Manual new

Modbus TCP

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();

Modbus RTU

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();

Recommended Usage: Factory + DI

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");

Manual Construction Without Microsoft DI (Prism, etc.)

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).

Custom Transports

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()

Custom response validation

Implement IModbusPduValidator and pass it to new TcpProtocolParser(validator) / new RtuProtocolParser(validator). Pass the parser to ModbusFactoryBuilder.WithTcpParser / WithRtuParser.

Raw Request API

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.

Transaction ID (TCP)

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).

Error Classification

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.

Reconnect and Retry

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:

  • RetryCount is the number of retries after the first attempt. For TCP, a timeout or a closed connection can only be retried when Reconnect = true; with Reconnect = false the 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 explicit Disconnect() — the next request silently reconnects. Set Reconnect = false if you want Disconnect() 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 (ArgumentException etc.).

Notes

  • One request at a time is serialized per connection with SemaphoreSlim.
  • ConnectAsync(CancellationToken) throws OperationCanceledException when cancelled and returns false when 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.

Communication Channels (preview)

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.

Packages

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

TCP client

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);
}

TCP server

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();

TLS

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.

UDP

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?"));

Serial

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();

Behaviour at a glance

  • ConnectAsync returns a CommResult. It throws OperationCanceledException only 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.
  • DisconnectAsync disconnects gracefully and stops reconnection. Dispose releases the channel at once, without draining received frames.
  • Sending or requesting while not connected returns NotConnected immediately; nothing is queued.
  • Events (StateChanged, FrameReceived) are raised on thread-pool threads. Marshal to the UI thread yourself (for example WPF's Dispatcher).
  • 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 at Debug.

The skill Skills/using-junevy-channels/SKILL.md describes the full contract, including late replies, heartbeat timing and the serial hot-plug checklist.

About

A modern, high-performance .NET library for industrial communication

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages