Caps Test Connect: it can only shorten the 10 s Test Connect budget, never extend it.
+
The Test Connect probe budget (1–60 s). Default 15.
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/TestConnectBudget.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/TestConnectBudget.cs
new file mode 100644
index 00000000..b6c00635
--- /dev/null
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/TestConnectBudget.cs
@@ -0,0 +1,64 @@
+using System.Text.Json;
+
+namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers;
+
+///
+/// Resolves the Test Connect probe budget (G1-F5 / F-FU2). Every driver's options document
+/// ProbeTimeoutSeconds as "Timeout for the AdminUI Test Connect probe", but the button always sent
+/// a fixed 10 s, and the Galaxy and OpcUaClient probes take min(caller budget, configured) — so the
+/// knob could shorten a probe but never lengthen it (Galaxy's default of 30 s was silently 10 s). The
+/// button now sends the configured value, read from the root of the config JSON it is about to probe,
+/// clamped to the probe actor's own [1, 60] s range.
+///
+public static class TestConnectBudget
+{
+ /// The budget used when the config carries no (valid) ProbeTimeoutSeconds.
+ public const int DefaultSeconds = 10;
+
+ /// The probe actor's lower clamp.
+ public const int MinSeconds = 1;
+
+ /// The probe actor's upper clamp.
+ public const int MaxSeconds = 60;
+
+ /// The config property that carries the budget (matched case-insensitively at the JSON root).
+ public const string PropertyName = "ProbeTimeoutSeconds";
+
+ /// Returns the configured budget, or when there is none.
+ /// The (merged) config JSON the probe will run against.
+ /// The budget when the config has no positive ProbeTimeoutSeconds.
+ /// The budget in seconds, within [, ].
+ public static int Resolve(string? configJson, int fallbackSeconds = DefaultSeconds)
+ {
+ var configured = TryReadConfigured(configJson);
+ var seconds = configured is > 0 ? configured.Value : fallbackSeconds;
+ return Math.Clamp(seconds, MinSeconds, MaxSeconds);
+ }
+
+ private static int? TryReadConfigured(string? configJson)
+ {
+ if (string.IsNullOrWhiteSpace(configJson)) return null;
+ try
+ {
+ using var doc = JsonDocument.Parse(configJson);
+ if (doc.RootElement.ValueKind != JsonValueKind.Object) return null;
+ foreach (var prop in doc.RootElement.EnumerateObject())
+ {
+ if (!string.Equals(prop.Name, PropertyName, StringComparison.OrdinalIgnoreCase)) continue;
+ return prop.Value.ValueKind switch
+ {
+ JsonValueKind.Number when prop.Value.TryGetInt32(out var i) => i,
+ JsonValueKind.Number when prop.Value.TryGetDouble(out var d) => (int)Math.Min(Math.Ceiling(d), int.MaxValue),
+ JsonValueKind.String when int.TryParse(prop.Value.GetString(), out var s) => s,
+ _ => null,
+ };
+ }
+ }
+ catch (JsonException)
+ {
+ // An unparseable config is the probe's problem to report; the budget just falls back.
+ }
+
+ return null;
+ }
+}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Drivers/TestConnectBudgetTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Drivers/TestConnectBudgetTests.cs
new file mode 100644
index 00000000..e0f6a271
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Drivers/TestConnectBudgetTests.cs
@@ -0,0 +1,55 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers;
+
+namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Drivers;
+
+///
+/// G1-F5 / F-FU2: Test Connect honours the driver's configured ProbeTimeoutSeconds (so it can
+/// lengthen the budget, not only shorten it), aligned across Galaxy and OpcUaClient, and the button
+/// drains its clear timer (R04 L2).
+///
+[Trait("Category", "Unit")]
+public sealed class TestConnectBudgetTests
+{
+ [Theory]
+ [InlineData("{\"ProbeTimeoutSeconds\":30}", 30)] // Galaxy default — was silently 10 s
+ [InlineData("{\"probeTimeoutSeconds\":15}", 15)] // OpcUaClient default, camelCase
+ [InlineData("{\"ProbeTimeoutSeconds\":\"25\"}", 25)]
+ [InlineData("{\"ProbeTimeoutSeconds\":2.5}", 3)]
+ [InlineData("{\"ProbeTimeoutSeconds\":600}", 60)] // clamped to the actor's max
+ public void A_configured_timeout_is_the_budget(string json, int expected)
+ => TestConnectBudget.Resolve(json).ShouldBe(expected);
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData("{}")]
+ [InlineData("{\"ProbeTimeoutSeconds\":0}")]
+ [InlineData("{\"ProbeTimeoutSeconds\":-5}")]
+ [InlineData("{\"ProbeTimeoutSeconds\":null}")]
+ [InlineData("not json")]
+ [InlineData("[1,2]")]
+ [InlineData("{\"Nested\":{\"ProbeTimeoutSeconds\":40}}")] // only the root key counts
+ public void No_valid_configured_timeout_falls_back(string? json)
+ => TestConnectBudget.Resolve(json, fallbackSeconds: 10).ShouldBe(10);
+
+ [Fact]
+ public void The_fallback_is_clamped_too()
+ {
+ TestConnectBudget.Resolve("{}", fallbackSeconds: 0).ShouldBe(TestConnectBudget.MinSeconds);
+ TestConnectBudget.Resolve("{}", fallbackSeconds: 999).ShouldBe(TestConnectBudget.MaxSeconds);
+ }
+
+ [Fact]
+ public void Button_resolves_its_budget_from_the_config_and_drains_a_threading_timer()
+ {
+ var src = AdminUiSource.Read("Components", "Shared", "Drivers", "DriverTestConnectButton.razor");
+
+ src.ShouldContain("TestConnectBudget.Resolve(json, TimeoutSeconds)");
+ src.ShouldContain("@implements IAsyncDisposable");
+ src.ShouldContain("System.Threading.Timer");
+ src.ShouldNotContain("System.Timers.Timer");
+ src.ShouldNotContain("async (_, _)");
+ }
+}