feat(modbus-rtu): route driver + probe transports through ModbusTransportFactory
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
@@ -94,7 +94,8 @@ public sealed class ModbusDriver
|
|||||||
/// <summary>Initializes a new Modbus TCP driver with the specified options and transport factory.</summary>
|
/// <summary>Initializes a new Modbus TCP driver with the specified options and transport factory.</summary>
|
||||||
/// <param name="options">Driver configuration options.</param>
|
/// <param name="options">Driver configuration options.</param>
|
||||||
/// <param name="driverInstanceId">Unique identifier for this driver instance.</param>
|
/// <param name="driverInstanceId">Unique identifier for this driver instance.</param>
|
||||||
/// <param name="transportFactory">Factory to create the Modbus transport; defaults to ModbusTcpTransport.</param>
|
/// <param name="transportFactory">Factory to create the Modbus transport; defaults to
|
||||||
|
/// <see cref="ModbusTransportFactory.Create"/>, which honours <see cref="ModbusDriverOptions.Transport"/>.</param>
|
||||||
/// <param name="logger">Logger instance; defaults to null logger if not provided.</param>
|
/// <param name="logger">Logger instance; defaults to null logger if not provided.</param>
|
||||||
public ModbusDriver(ModbusDriverOptions options, string driverInstanceId,
|
public ModbusDriver(ModbusDriverOptions options, string driverInstanceId,
|
||||||
Func<ModbusDriverOptions, IModbusTransport>? transportFactory = null,
|
Func<ModbusDriverOptions, IModbusTransport>? transportFactory = null,
|
||||||
@@ -107,11 +108,7 @@ public sealed class ModbusDriver
|
|||||||
_resolver = new EquipmentTagRefResolver<ModbusTagDefinition>(
|
_resolver = new EquipmentTagRefResolver<ModbusTagDefinition>(
|
||||||
r => _tagsByRawPath.TryGetValue(r, out var t) ? t : null);
|
r => _tagsByRawPath.TryGetValue(r, out var t) ? t : null);
|
||||||
_transportFactory = transportFactory
|
_transportFactory = transportFactory
|
||||||
?? (o => new ModbusTcpTransport(
|
?? (o => ModbusTransportFactory.Create(o));
|
||||||
o.Host, o.Port, o.Timeout, o.AutoReconnect,
|
|
||||||
keepAlive: o.KeepAlive,
|
|
||||||
idleDisconnect: o.IdleDisconnectTimeout,
|
|
||||||
reconnect: o.Reconnect));
|
|
||||||
_poll = new PollGroupEngine(
|
_poll = new PollGroupEngine(
|
||||||
reader: ReadAsync,
|
reader: ReadAsync,
|
||||||
onChange: (handle, tagRef, snapshot) =>
|
onChange: (handle, tagRef, snapshot) =>
|
||||||
|
|||||||
@@ -29,15 +29,19 @@ public sealed class ModbusDriverProbe : IDriverProbe
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public string DriverType => "Modbus";
|
public string DriverType => "Modbus";
|
||||||
|
|
||||||
/// <summary>The parsed probe target — host/port/unitId + the effective connection timeout, all read
|
/// <summary>The parsed probe target — host/port/unitId + the effective connection timeout + the wire
|
||||||
/// from the SAME factory DTO shape (<c>ModbusDriverConfigDto</c>) the driver factory parses, so
|
/// transport mode, all read from the SAME factory DTO shape (<c>ModbusDriverConfigDto</c>) the driver
|
||||||
/// <c>timeoutMs</c> binds identically to the factory (R2-11, 05/CONV-2; the OpcUaClient parity rule).</summary>
|
/// factory parses, so <c>timeoutMs</c> binds identically to the factory (R2-11, 05/CONV-2; the
|
||||||
internal readonly record struct ProbeTarget(string Host, int Port, byte UnitId, TimeSpan Timeout);
|
/// 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.
|
/// <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
|
/// 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
|
/// 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="configJson">The driver config JSON (factory DTO shape).</param>
|
||||||
/// <param name="fallbackTimeout">The timeout used when the config omits <c>timeoutMs</c>.</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>
|
/// <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)
|
if (string.IsNullOrWhiteSpace(dto.Host) || port <= 0)
|
||||||
return (null, "Config has no host/port to probe.");
|
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;
|
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 />
|
/// <inheritdoc />
|
||||||
@@ -72,9 +83,17 @@ public sealed class ModbusDriverProbe : IDriverProbe
|
|||||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||||
cts.CancelAfter(timeout);
|
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.
|
// 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))
|
await using (transport.ConfigureAwait(false))
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Confirms the driver's default transport-factory closure — and, by extension, the
|
||||||
|
/// probe's transport construction — routes through <see cref="ModbusTransportFactory"/>
|
||||||
|
/// instead of hardcoding <see cref="ModbusTcpTransport"/>, so an <c>RtuOverTcp</c>-authored
|
||||||
|
/// config actually gets RTU framing rather than silently running as TCP/MBAP.
|
||||||
|
/// </summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class ModbusTransportWiringTests
|
||||||
|
{
|
||||||
|
private static IModbusTransport BuildDefaultTransport(ModbusDriverOptions opts)
|
||||||
|
{
|
||||||
|
using var driver = new ModbusDriver(opts, "wire-test");
|
||||||
|
var factory = (Func<ModbusDriverOptions, IModbusTransport>)typeof(ModbusDriver)
|
||||||
|
.GetField("_transportFactory", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!
|
||||||
|
.GetValue(driver)!;
|
||||||
|
return factory(opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Default_closure_builds_rtu_transport_for_RtuOverTcp()
|
||||||
|
=> BuildDefaultTransport(new ModbusDriverOptions { Transport = ModbusTransportMode.RtuOverTcp })
|
||||||
|
.ShouldBeOfType<ModbusRtuOverTcpTransport>();
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Default_closure_builds_tcp_transport_for_Tcp()
|
||||||
|
=> BuildDefaultTransport(new ModbusDriverOptions())
|
||||||
|
.ShouldBeOfType<ModbusTcpTransport>();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user