fix(driver-modbus): probe parses the factory DTO shape — timeoutMs no longer silently dropped (R2-11, 05/CONV-2)
This commit is contained in:
@@ -20,7 +20,7 @@
|
||||
{ "id": "T16", "subject": "TwinCAT equipment-tag parser: freeze test, shared readers, honour writable key, Inspect warnings", "status": "completed", "blockedBy": ["T11"] },
|
||||
{ "id": "T17", "subject": "FOCAS equipment-tag parser: force Writable:false (05/UNDER-1 correction), shared readers, Inspect warnings (+ amend Contracts banner)", "status": "completed", "blockedBy": ["T11"] },
|
||||
{ "id": "T18", "subject": "FOCAS capability-matrix pre-flight on the equipment-tag _parseRef resolve path (BadNodeIdUnknown on rejection)", "status": "completed", "blockedBy": ["T17"] },
|
||||
{ "id": "T19", "subject": "Modbus probe parses the factory DTO shape (timeoutMs round-trip; OpcUaClient parity rule)", "status": "pending", "blockedBy": [] },
|
||||
{ "id": "T19", "subject": "Modbus probe parses the factory DTO shape (timeoutMs round-trip; OpcUaClient parity rule)", "status": "completed", "blockedBy": [] },
|
||||
{ "id": "T20", "subject": "EquipmentTagConfigInspector DriverType-dispatch map in ControlPlane (+6 Contracts references)", "status": "pending", "blockedBy": ["T12", "T13", "T14", "T15", "T16", "T17"] },
|
||||
{ "id": "T21", "subject": "AdminOperationsActor deploy-gate wiring + Deployment:TagConfigValidationMode (Warn default | Error opt-in); actor-level consuming test", "status": "pending", "blockedBy": ["T20"] },
|
||||
{ "id": "T22", "subject": "AdminUI writable checkbox in the six driver-typed tag editors (models + razor; live /run verify at execution)", "status": "pending", "blockedBy": [] },
|
||||
|
||||
@@ -29,19 +29,44 @@ 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>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>
|
||||
/// <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>
|
||||
internal static (ProbeTarget? Target, string? Error) TryParseProbeTarget(string configJson, TimeSpan fallbackTimeout)
|
||||
{
|
||||
ModbusDriverFactoryExtensions.ModbusDriverConfigDto? dto;
|
||||
try { dto = JsonSerializer.Deserialize<ModbusDriverFactoryExtensions.ModbusDriverConfigDto>(configJson, _opts); }
|
||||
catch (Exception ex) { return (null, $"Config JSON is invalid: {ex.Message}"); }
|
||||
if (dto is null) return (null, "Config JSON deserialized to null.");
|
||||
|
||||
var port = dto.Port ?? 502;
|
||||
if (string.IsNullOrWhiteSpace(dto.Host) || port <= 0)
|
||||
return (null, "Config has no host/port to probe.");
|
||||
|
||||
var timeout = dto.TimeoutMs is { } ms && ms > 0 ? TimeSpan.FromMilliseconds(ms) : fallbackTimeout;
|
||||
return (new ProbeTarget(dto.Host!, port, dto.UnitId ?? 1, timeout), null);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<DriverProbeResult> ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct)
|
||||
{
|
||||
ModbusDriverOptions? opts;
|
||||
try { opts = JsonSerializer.Deserialize<ModbusDriverOptions>(configJson, _opts); }
|
||||
catch (Exception ex) { return new(false, $"Config JSON is invalid: {ex.Message}", null); }
|
||||
if (opts is null) return new(false, "Config JSON deserialized to null.", null);
|
||||
var (target, error) = TryParseProbeTarget(configJson, timeout);
|
||||
if (target is not { } t) return new(false, error!, null);
|
||||
|
||||
var (host, port) = ExtractTarget(opts);
|
||||
if (string.IsNullOrWhiteSpace(host) || port <= 0)
|
||||
return new(false, "Config has no host/port to probe.", null);
|
||||
|
||||
var unitId = opts.UnitId;
|
||||
var host = t.Host;
|
||||
var port = t.Port;
|
||||
var unitId = t.UnitId;
|
||||
// Honour the config's timeoutMs (factory parity) — the caller's timeout is the fallback.
|
||||
timeout = t.Timeout;
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
@@ -93,7 +118,4 @@ public sealed class ModbusDriverProbe : IDriverProbe
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static (string host, int port) ExtractTarget(ModbusDriverOptions opts)
|
||||
=> (opts.Host, opts.Port);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// R2-11 (05/CONV-2): the Modbus probe parses the SAME factory DTO shape the driver factory parses, so
|
||||
/// <c>timeoutMs</c> binds identically instead of being silently dropped (it never bound to the runtime
|
||||
/// <c>ModbusDriverOptions.Timeout</c> TimeSpan). The OpcUaClient probe/factory parse-parity rule.
|
||||
/// </summary>
|
||||
public sealed class ModbusDriverProbeParityTests
|
||||
{
|
||||
[Fact]
|
||||
public void Config_timeoutMs_binds_as_the_effective_probe_timeout()
|
||||
{
|
||||
var (target, error) = ModbusDriverProbe.TryParseProbeTarget(
|
||||
"{\"host\":\"10.0.0.5\",\"port\":502,\"unitId\":3,\"timeoutMs\":250}",
|
||||
fallbackTimeout: TimeSpan.FromSeconds(5));
|
||||
|
||||
error.ShouldBeNull();
|
||||
target.ShouldNotBeNull();
|
||||
target!.Value.Host.ShouldBe("10.0.0.5");
|
||||
target.Value.Port.ShouldBe(502);
|
||||
target.Value.UnitId.ShouldBe((byte)3);
|
||||
target.Value.Timeout.ShouldBe(TimeSpan.FromMilliseconds(250));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Absent_timeoutMs_falls_back_to_the_caller_timeout()
|
||||
{
|
||||
var (target, _) = ModbusDriverProbe.TryParseProbeTarget(
|
||||
"{\"host\":\"10.0.0.5\",\"port\":502}", fallbackTimeout: TimeSpan.FromSeconds(5));
|
||||
|
||||
target!.Value.Timeout.ShouldBe(TimeSpan.FromSeconds(5));
|
||||
target.Value.UnitId.ShouldBe((byte)1); // DTO default
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Missing_host_is_an_error()
|
||||
{
|
||||
var (target, error) = ModbusDriverProbe.TryParseProbeTarget(
|
||||
"{\"port\":0}", fallbackTimeout: TimeSpan.FromSeconds(5));
|
||||
target.ShouldBeNull();
|
||||
error.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Invalid_json_is_an_error()
|
||||
{
|
||||
var (target, error) = ModbusDriverProbe.TryParseProbeTarget(
|
||||
"not valid json {{", fallbackTimeout: TimeSpan.FromSeconds(5));
|
||||
target.ShouldBeNull();
|
||||
error.ShouldContain("invalid");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user