Advanced Topics
Custom Numeric Conversions
For advanced scenarios where you need explicit control over register conversion, use NumericConverter directly:
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
// 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:
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:
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:
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:
builder.Services.AddHealthChecks()
.AddCheck<ModbusHealthCheck>("modbus", tags: new[] { "ready" });Background Polling
Implement periodic device polling as a background service:
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:
[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:
[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:
[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:
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:
// 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
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
public class DeviceService
{
private readonly IVoltimaxModbusClient _client;
public DeviceService(IModbusClientFactory factory)
{
_client = factory.CreateClient(/* options */);
}
}❌ Don't: Create new clients for each operation
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
await using var client = await factory.CreateClientAsync(options);
// Client disposed automatically❌ Don't: Forget disposal (leaks connections)
var client = await factory.CreateClientAsync(options);
// Missing disposal!3. Configuration Validation
✅ Do: Verify register packing with known values
// 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
try
{
return await client.ReadHoldingRegistersAsync<float>(0, 1);
}
catch (TimeoutException)
{
// Expected, client will recover
return null;
}❌ Don't: Catch and ignore all exceptions
try
{
return await client.ReadHoldingRegistersAsync<float>(0, 1);
}
catch
{
// BAD: Hides configuration errors
return 0;
}5. Monitoring
✅ Do: Monitor metrics in production
builder.Services.AddOpenTelemetry()
.WithMetrics(m => m.AddModbusInstrumentation());6. Timeouts
✅ Do: Use cancellation tokens
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var value = await client.ReadHoldingRegistersAsync<float>(0, 1);7. Factory Usage
✅ Do: Use the factory pattern
var client = await factory.CreateClientAsync(options);❌ Don't: Instantiate VoltimaxModbusClient directly
// BAD: Bypasses DI and instrumentation
var client = new VoltimaxModbusClient(logger, options, modbusFactory);