feat(modbus-rtu): route driver + probe transports through ModbusTransportFactory

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
Joseph Doherty
2026-07-24 14:12:12 -04:00
parent 1d90bf3611
commit 13bd9820b0
3 changed files with 63 additions and 14 deletions
@@ -29,15 +29,19 @@ public sealed class ModbusDriverProbe : IDriverProbe
/// <inheritdoc />
public string DriverType => "Modbus";
/// <summary>The parsed probe target — host/port/unitId + the effective connection timeout, all read
/// from the SAME factory DTO shape (<c>ModbusDriverConfigDto</c>) the driver factory parses, so
/// <c>timeoutMs</c> binds identically to the factory (R2-11, 05/CONV-2; the OpcUaClient parity rule).</summary>
internal readonly record struct ProbeTarget(string Host, int Port, byte UnitId, TimeSpan Timeout);
/// <summary>The parsed probe target — host/port/unitId + the effective connection timeout + the wire
/// transport mode, all read from the SAME factory DTO shape (<c>ModbusDriverConfigDto</c>) the driver
/// factory parses, so <c>timeoutMs</c> binds identically to the factory (R2-11, 05/CONV-2; the
/// OpcUaClient parity rule) and an <c>RtuOverTcp</c>-authored config probes over RTU framing —
/// matching how the driver will actually run.</summary>
internal readonly record struct ProbeTarget(string Host, int Port, byte UnitId, TimeSpan Timeout, ModbusTransportMode Transport);
/// <summary>Parse the driver config JSON into a <see cref="ProbeTarget"/> using the factory DTO shape.
/// Returns a null target + an error message when the JSON is invalid or has no host/port. The effective
/// timeout mirrors the factory (<c>TimeSpan.FromMilliseconds(dto.TimeoutMs ?? …)</c>); when the config
/// omits <c>timeoutMs</c> the caller's <paramref name="fallbackTimeout"/> is used.</summary>
/// omits <c>timeoutMs</c> the caller's <paramref name="fallbackTimeout"/> is used. <c>Transport</c>
/// mirrors the factory's null-defaults-to-Tcp convention (<see cref="ModbusDriverFactoryExtensions"/>);
/// an unrecognized value is reported as a probe-target error rather than silently falling back.</summary>
/// <param name="configJson">The driver config JSON (factory DTO shape).</param>
/// <param name="fallbackTimeout">The timeout used when the config omits <c>timeoutMs</c>.</param>
/// <returns>The parsed target, or a null target with an error string.</returns>
@@ -52,8 +56,15 @@ public sealed class ModbusDriverProbe : IDriverProbe
if (string.IsNullOrWhiteSpace(dto.Host) || port <= 0)
return (null, "Config has no host/port to probe.");
var transportMode = ModbusTransportMode.Tcp;
if (dto.Transport is not null && !Enum.TryParse(dto.Transport, ignoreCase: true, out transportMode))
{
return (null, $"Config has unknown Transport '{dto.Transport}'. " +
$"Expected one of {string.Join(", ", Enum.GetNames<ModbusTransportMode>())}");
}
var timeout = dto.TimeoutMs is { } ms && ms > 0 ? TimeSpan.FromMilliseconds(ms) : fallbackTimeout;
return (new ProbeTarget(dto.Host!, port, dto.UnitId ?? 1, timeout), null);
return (new ProbeTarget(dto.Host!, port, dto.UnitId ?? 1, timeout, transportMode), null);
}
/// <inheritdoc />
@@ -72,9 +83,17 @@ public sealed class ModbusDriverProbe : IDriverProbe
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(timeout);
// Phase 1 — TCP connect (using ModbusTcpTransport which handles IPv4 preference).
// Phase 1 — TCP connect, over whichever transport the config's Transport mode selects
// (ModbusTransportFactory is the single mapping point — same switch the live driver uses).
// autoReconnect=false: this is a one-shot probe, no retry loops.
var transport = new ModbusTcpTransport(host, port, timeout, autoReconnect: false);
var transport = ModbusTransportFactory.Create(new ModbusDriverOptions
{
Host = host,
Port = port,
Timeout = timeout,
Transport = t.Transport,
AutoReconnect = false,
});
await using (transport.ConfigureAwait(false))
{
try