Skip to content

Advanced Topics

Custom Numeric Conversions

For advanced scenarios where you need explicit control over register conversion, use NumericConverter directly:

csharp
using Voltimax.Edge.Modbus.Client;

// Read raw registers
var registers = await client.ReadHoldingRegistersAsync(
    startAddress: 0,
    numberOfPoints: 2,
    slaveAddress: 1
);

// Convert with explicit packing
var value = NumericConverter.ConvertRegistersTo<float>(
    registers,
    RegisterPacking.WordLowFirstByteBigEndian
);

Converting from Values to Registers

csharp
// Convert value to registers
var registers = NumericConverter.ConvertToRegisters(
    123.45f,
    RegisterPacking.WordHighFirstByteBigEndian
);

// Write registers
await client.WriteMultipleRegistersAsync(
    startAddress: 40001,
    data: registers,
    slaveAddress: 1
);

Supported Types

  • short, ushort (1 register)
  • int, uint, float (2 registers)
  • long, ulong, double (4 registers)

Multiple Devices

Use a single factory to manage multiple device connections:

csharp
public class DeviceManager
{
    private readonly IModbusClientFactory _factory;
    private readonly ILogger<DeviceManager> _logger;
    
    private IVoltimaxModbusClient? _meter;
    private IVoltimaxModbusClient? _plc;
    private IVoltimaxModbusClient? _inverter;

    public async Task InitializeAsync(CancellationToken ct)
    {
        // Energy meter
        _meter = await _factory.CreateClientAsync(options =>
        {
            options.Host = "meter.local";
            options.Port = 502;
            options.Packing = RegisterPacking.WordHighFirstByteBigEndian;
        }, ct);

        // PLC
        _plc = await _factory.CreateClientAsync(options =>
        {
            options.Host = "plc.local";
            options.Port = 503;
            options.Packing = RegisterPacking.WordLowFirstByteBigEndian;
        }, ct);

        // Solar inverter
        _inverter = await _factory.CreateClientAsync(options =>
        {
            options.Host = "inverter.local";
            options.Port = 502;
        }, ct);
    }

    public async Task<DeviceStatus> GetStatusAsync()
    {
        var voltage = await _meter!.ReadHoldingRegistersAsync<float>(0, 1);
        var production = await _inverter!.ReadHoldingRegistersAsync<float>(40, 1);
        var controlMode = await _plc!.ReadCoilsAsync(0, 1, 1);
        
        return new DeviceStatus(voltage, production, controlMode[0]);
    }
}

Connection Pooling

Each client maintains its own connection. For many devices, consider:

csharp
public class ModbusClientPool
{
    private readonly IModbusClientFactory _factory;
    private readonly ConcurrentDictionary<string, IVoltimaxModbusClient> _clients = new();

    public async Task<IVoltimaxModbusClient> GetOrCreateAsync(
        string host,
        int port = 502,
        CancellationToken ct = default)
    {
        var key = $"{host}:{port}";
        
        return await _clients.GetOrAdd(key, async _ =>
        {
            return await _factory.CreateClientAsync(options =>
            {
                options.Host = host;
                options.Port = port;
            }, ct);
        });
    }
}

Connection Health Monitoring

Monitor connection state for health checks:

csharp
public class ModbusHealthCheck : IHealthCheck
{
    private readonly IVoltimaxModbusClient _client;

    public async Task<HealthCheckResult> CheckHealthAsync(
        HealthCheckContext context,
        CancellationToken ct = default)
    {
        if (!_client.IsConnected)
        {
            return HealthCheckResult.Unhealthy("Modbus client disconnected");
        }

        try
        {
            // Test read
            await _client.ReadHoldingRegistersAsync<ushort>(0, 1);
            return HealthCheckResult.Healthy("Modbus connection OK");
        }
        catch (Exception ex)
        {
            return HealthCheckResult.Degraded("Modbus read failed", ex);
        }
    }
}

Register with ASP.NET Core health checks:

csharp
builder.Services.AddHealthChecks()
    .AddCheck<ModbusHealthCheck>("modbus", tags: new[] { "ready" });

Background Polling

Implement periodic device polling as a background service:

csharp
public class ModbusPollingService : BackgroundService
{
    private readonly IVoltimaxModbusClient _client;
    private readonly ILogger<ModbusPollingService> _logger;

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        // Wait for startup
        await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);

        while (!stoppingToken.IsCancellationRequested)
        {
            try
            {
                var voltage = await _client.ReadHoldingRegistersAsync<float>(0, 1);
                var current = await _client.ReadHoldingRegistersAsync<float>(6, 1);
                var power = await _client.ReadHoldingRegistersAsync<float>(12, 1);

                _logger.LogInformation(
                    "Poll: V={Voltage:F2}V I={Current:F2}A P={Power:F2}W",
                    voltage, current, power);
            }
            catch (Exception ex) when (ex is TimeoutException or IOException)
            {
                _logger.LogWarning(ex, "Poll failed, will retry");
                // Client will auto-reconnect
            }

            await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
        }
    }
}

Testing

Unit Testing with Mocks

Use NSubstitute or Moq for unit tests:

csharp
[Test]
public async Task ProcessVoltage_ReadsFromModbus()
{
    // Arrange
    var mockClient = Substitute.For<IVoltimaxModbusClient>();
    mockClient.ReadHoldingRegistersAsync<float>(0, 1)
        .Returns(230.5f);

    var service = new VoltageMonitor(mockClient);

    // Act
    var result = await service.GetCurrentVoltageAsync();

    // Assert
    result.ShouldBe(230.5f);
    await mockClient.Received(1).ReadHoldingRegistersAsync<float>(0, 1);
}

Integration Testing

For integration tests, use a real Modbus simulator:

csharp
[Test]
public async Task CanConnectToModbusSimulator()
{
    // Start Modbus simulator (e.g., ModRSsim2)
    var factory = new ServiceCollection()
        .AddLogging()
        .AddModbusClient()
        .BuildServiceProvider()
        .GetRequiredService<IModbusClientFactory>();

    await using var client = await factory.CreateClientAsync(options =>
    {
        options.Host = "localhost";
        options.Port = 502;
    });

    // Test basic operations
    var registers = await client.ReadHoldingRegistersAsync(0, 10, 1);
    registers.ShouldNotBeEmpty();
}

Testcontainers

Use Docker containers for realistic integration tests:

csharp
[Test]
public async Task ModbusIntegrationTest()
{
    // Start Modbus TCP server container
    var container = new ContainerBuilder()
        .WithImage("oitc/modbus-server")
        .WithPortBinding(5020, 502)
        .Build();

    await container.StartAsync();

    try
    {
        var factory = CreateFactory();
        await using var client = await factory.CreateClientAsync(options =>
        {
            options.Host = container.Hostname;
            options.Port = 5020;
        });

        // Test operations...
    }
    finally
    {
        await container.StopAsync();
    }
}

Custom Error Handling

Implement custom error handling strategies:

csharp
public class ResilientModbusReader
{
    private readonly IVoltimaxModbusClient _client;
    private readonly ILogger _logger;

    public async Task<float?> ReadWithRetryAsync(
        ushort address,
        int maxAttempts = 3)
    {
        for (var attempt = 1; attempt <= maxAttempts; attempt++)
        {
            try
            {
                return await _client.ReadHoldingRegistersAsync<float>(address, 1);
            }
            catch (TimeoutException) when (attempt < maxAttempts)
            {
                _logger.LogWarning("Timeout on attempt {Attempt}, retrying", attempt);
                await Task.Delay(TimeSpan.FromSeconds(attempt)); // Exponential backoff
            }
            catch (IOException) when (attempt < maxAttempts)
            {
                _logger.LogWarning("Connection lost on attempt {Attempt}", attempt);
                await Task.Delay(TimeSpan.FromSeconds(attempt));
            }
        }

        _logger.LogError("Failed after {MaxAttempts} attempts", maxAttempts);
        return null;
    }
}

Performance Optimization

Batch Reads

Read multiple consecutive registers in one operation:

csharp
// Instead of multiple single reads:
// var v1 = await client.ReadHoldingRegistersAsync<float>(0, 1);
// var v2 = await client.ReadHoldingRegistersAsync<float>(2, 1);
// var v3 = await client.ReadHoldingRegistersAsync<float>(4, 1);

// Read all at once:
var registers = await client.ReadHoldingRegistersAsync(0, 6, 1);
var v1 = NumericConverter.ConvertRegistersTo<float>(registers[0..2], packing);
var v2 = NumericConverter.ConvertRegistersTo<float>(registers[2..4], packing);
var v3 = NumericConverter.ConvertRegistersTo<float>(registers[4..6], packing);

Parallel Reads from Multiple Devices

csharp
var tasks = new[]
{
    client1.ReadHoldingRegistersAsync<float>(0, 1),
    client2.ReadHoldingRegistersAsync<float>(0, 1),
    client3.ReadHoldingRegistersAsync<float>(0, 1)
};

var results = await Task.WhenAll(tasks);

WARNING

Don't parallelize reads from the same client - operations are serialized internally for thread safety.

Best Practices

1. Client Reuse

Do: Create one client per device and reuse it

csharp
public class DeviceService
{
    private readonly IVoltimaxModbusClient _client;
    
    public DeviceService(IModbusClientFactory factory)
    {
        _client = factory.CreateClient(/* options */);
    }
}

Don't: Create new clients for each operation

csharp
public async Task ReadAsync()
{
    // BAD: Creates new connection every time
    using var client = _factory.CreateClient(/* options */);
    await client.ConnectAsync();
    return await client.ReadHoldingRegistersAsync<float>(0, 1);
}

2. Resource Disposal

Do: Properly dispose clients

csharp
await using var client = await factory.CreateClientAsync(options);
// Client disposed automatically

Don't: Forget disposal (leaks connections)

csharp
var client = await factory.CreateClientAsync(options);
// Missing disposal!

3. Configuration Validation

Do: Verify register packing with known values

csharp
// Read known voltage of 230V
var voltage = await client.ReadHoldingRegistersAsync<float>(0, 1);
if (voltage < 0 || voltage > 500)
{
    logger.LogError("Invalid voltage {Voltage}, check register packing", voltage);
}

4. Error Handling

Do: Handle expected exceptions

csharp
try
{
    return await client.ReadHoldingRegistersAsync<float>(0, 1);
}
catch (TimeoutException)
{
    // Expected, client will recover
    return null;
}

Don't: Catch and ignore all exceptions

csharp
try
{
    return await client.ReadHoldingRegistersAsync<float>(0, 1);
}
catch
{
    // BAD: Hides configuration errors
    return 0;
}

5. Monitoring

Do: Monitor metrics in production

csharp
builder.Services.AddOpenTelemetry()
    .WithMetrics(m => m.AddModbusInstrumentation());

6. Timeouts

Do: Use cancellation tokens

csharp
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var value = await client.ReadHoldingRegistersAsync<float>(0, 1);

7. Factory Usage

Do: Use the factory pattern

csharp
var client = await factory.CreateClientAsync(options);

Don't: Instantiate VoltimaxModbusClient directly

csharp
// BAD: Bypasses DI and instrumentation
var client = new VoltimaxModbusClient(logger, options, modbusFactory);