fix(driver-modbus): probe parses the factory DTO shape — timeoutMs no longer silently dropped (R2-11, 05/CONV-2)

This commit is contained in:
Joseph Doherty
2026-07-13 11:07:44 -04:00
parent 24435efa8b
commit b2a56ebf3b
3 changed files with 91 additions and 13 deletions
@@ -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");
}
}