diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverProbe.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverProbe.cs
index 172e1dea..ac46d8a7 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverProbe.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverProbe.cs
@@ -55,6 +55,10 @@ public sealed class AbLegacyDriverProbe : IDriverProbe
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);
+ // The authored "Admin UI probe timeout" (probeTimeoutSeconds) tightens the caller's budget. It was parsed by
+ // nothing before: Test Connect always ran on the caller's budget alone.
+ timeout = EffectiveTimeout(timeout, opts.ProbeTimeoutSeconds);
+
var (parsed, firstDevice) = ExtractTarget(opts);
if (parsed is null || string.IsNullOrWhiteSpace(parsed.Gateway) || parsed.Port <= 0)
return new(false, "Config has no host/port to probe.", null);
@@ -169,6 +173,22 @@ public sealed class AbLegacyDriverProbe : IDriverProbe
/// Whether the status proves the controller answered.
internal static bool IsReachableStatus(AbStatus s) => s is AbStatus.Ok or AbStatus.NotFound;
+ ///
+ /// The deadline the probe runs under: the caller's budget, tightened by the config's
+ /// when that is positive and shorter (same rule as the
+ /// Galaxy probe). A non-positive caller budget is floored to 1 s first.
+ ///
+ /// The budget the host handed the probe.
+ /// The config's probeTimeoutSeconds; ignored when not positive.
+ /// The effective probe deadline.
+ internal static TimeSpan EffectiveTimeout(TimeSpan callerTimeout, int configuredSeconds)
+ {
+ var caller = callerTimeout > TimeSpan.Zero ? callerTimeout : TimeSpan.FromSeconds(1);
+ if (configuredSeconds <= 0) return caller;
+ var configured = TimeSpan.FromSeconds(configuredSeconds);
+ return configured < caller ? configured : caller;
+ }
+
private static (AbLegacyHostAddress? parsed, AbLegacyDeviceOptions? device) ExtractTarget(AbLegacyDriverOptions opts)
{
// Parse the first device's ab:// host address to extract the gateway IP + EIP port.
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyDriverProbeTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyDriverProbeTests.cs
index f58536d7..c9c3aa51 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyDriverProbeTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyDriverProbeTests.cs
@@ -192,6 +192,40 @@ public sealed class AbLegacyDriverProbeTests
LibplctagLegacyName.Translate(status!).ShouldBe("S2:0");
}
+ private sealed class StallingTag(AbLegacyTagCreateParams p) : FakeAbLegacyTag(p)
+ {
+ public override Task InitializeAsync(CancellationToken ct) => Task.Delay(Timeout.Infinite, ct);
+ }
+
+ ///
+ /// The authored probeTimeoutSeconds ("Admin UI probe timeout") was parsed by nothing — Test Connect ran
+ /// on the caller's budget alone. It now tightens that budget (never raises it); absent or non-positive keeps
+ /// the caller's budget (the positive control).
+ ///
+ [Theory]
+ [InlineData(",\"probeTimeoutSeconds\":1", "1s")]
+ [InlineData(",\"probeTimeoutSeconds\":30", "2s")]
+ [InlineData("", "2s")]
+ [InlineData(",\"probeTimeoutSeconds\":0", "2s")]
+ public async Task Configured_probe_timeout_tightens_the_caller_budget(string key, string expectedBudget)
+ {
+ var listener = new TcpListener(IPAddress.Loopback, 0);
+ listener.Start();
+ try
+ {
+ var port = ((IPEndPoint)listener.LocalEndpoint).Port;
+ var factory = new FakeAbLegacyTagFactory { Customise = p => new StallingTag(p) };
+ var json = $"{{\"devices\":[{{\"hostAddress\":\"ab://127.0.0.1:{port}/1,0\"}}]{key}}}";
+
+ var result = await new AbLegacyDriverProbe(factory)
+ .ProbeAsync(json, TimeSpan.FromSeconds(2), TestContext.Current.CancellationToken);
+
+ result.Ok.ShouldBeFalse();
+ result.Message.ShouldBe($"Probe timed out after {expectedBudget}.");
+ }
+ finally { listener.Stop(); }
+ }
+
[Fact]
public async Task Unparseable_probe_address_is_a_clean_failure_not_an_exception()
{