diff --git a/docs/drivers/AbCip.md b/docs/drivers/AbCip.md
index ff97cbb4..d3c6e69b 100644
--- a/docs/drivers/AbCip.md
+++ b/docs/drivers/AbCip.md
@@ -76,7 +76,7 @@ the driver's `DriverConfig` JSON. Key fields:
- **`Devices`** — one `AbCipDeviceOptions` per PLC (`HostAddress`, `PlcFamily`, optional `DeviceName`, per-device `AllowPacking` / `ConnectionSize` overrides).
- **`Tags`** — pre-declared `AbCipTagDefinition` list; `Members` for UDT fan-out, `SafetyTag` for GuardLogix safety-partition tags.
- **`Probe`** — connectivity-probe `Enabled` / `Interval` / `Timeout` / `ProbeTagPath`. A driver created from the AdminUI starts with the probe **off** (an enabled probe with no tag path only logs a warning); an absent `probe` key in a stored config still means enabled to the driver.
-- AdminUI driver forms (AbCip/AbLegacy) overlay only the keys they edit onto the stored JSON, so unmodelled keys survive a save (#576).
+- AdminUI driver forms (AbCip/AbLegacy) overlay only the keys they edit onto the stored JSON, so unmodelled keys survive a save (#576). They also write durations as the integer-millisecond keys the factory binds (`timeoutMs`, `alarmPollIntervalMs`, `probe.intervalMs`, `probe.timeoutMs`); a legacy TimeSpan-string key (`timeout`: "00:00:05") is loaded and migrated on save.
- **`Transport`** — absent (default ⇒ `Native`), `Native`, or `Libplctag` (fallback); see [Transport](#transport-transport-option).
- **Discovery** — `EnableControllerBrowse` (`@tags` walk) and `EnableDeclarationOnlyUdtGrouping` (whole-UDT read fast path).
- **Alarms** — `EnableAlarmProjection` + `AlarmPollInterval` for the ALMD projection.
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj
index 59436b5e..8a42ca70 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj
@@ -28,6 +28,7 @@
+
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.csproj b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.csproj
index b4fe82f3..fa98205f 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.csproj
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.csproj
@@ -28,6 +28,7 @@
+
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverConfigJsonOverlay.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverConfigJsonOverlay.cs
index 09901ccd..ddc10468 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverConfigJsonOverlay.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverConfigJsonOverlay.cs
@@ -16,15 +16,37 @@ internal static class DriverConfigJsonOverlay
/// The stored config as loaded; blank or non-object input yields unchanged.
/// The form's own serialised options.
/// Dot-separated, case-insensitive property paths the form edits (e.g. transport, probe.enabled).
- public static string Apply(string? originalJson, string typedJson, IEnumerable ownedPaths)
+ ///
+ /// (TimeSpan-string path, integer-millisecond path) pairs. The typed options serialise durations as
+ /// "00:00:05" but the driver factories bind *Ms integers (#576); each pair is rewritten
+ /// in the typed output (and the legacy TimeSpan path becomes owned, so a stale one is removed).
+ ///
+ public static string Apply(string? originalJson, string typedJson, IEnumerable ownedPaths,
+ IReadOnlyList<(string TimeSpanPath, string MsPath)>? timeSpanToMs = null)
{
- if (string.IsNullOrWhiteSpace(originalJson)) return typedJson;
- JsonObject? original;
- try { original = JsonNode.Parse(originalJson) as JsonObject; }
- catch (JsonException) { return typedJson; }
- if (original is null || JsonNode.Parse(typedJson) is not JsonObject typed) return typedJson;
+ if (JsonNode.Parse(typedJson) is not JsonObject typed) return typedJson;
+ var owned = ownedPaths.ToList();
+ foreach (var (tsPath, msPath) in timeSpanToMs ?? [])
+ {
+ owned.Add(tsPath);
+ owned.Add(msPath);
+ if (Get(typed, tsPath) is JsonValue v && v.TryGetValue(out var text)
+ && TimeSpan.TryParse(text, System.Globalization.CultureInfo.InvariantCulture, out var ts))
+ {
+ RemovePath(typed, tsPath);
+ Set(typed, msPath, (int)ts.TotalMilliseconds);
+ }
+ }
- foreach (var path in ownedPaths)
+ JsonObject? original = null;
+ if (!string.IsNullOrWhiteSpace(originalJson))
+ {
+ try { original = JsonNode.Parse(originalJson) as JsonObject; }
+ catch (JsonException) { /* unusable original: fall through to the typed output */ }
+ }
+ if (original is null) return typed.ToJsonString();
+
+ foreach (var path in owned)
{
var segments = path.Split('.');
JsonObject? from = typed;
@@ -50,6 +72,35 @@ internal static class DriverConfigJsonOverlay
return original.ToJsonString();
}
+ private static JsonNode? Get(JsonObject root, string path)
+ {
+ JsonNode? cur = root;
+ foreach (var seg in path.Split('.'))
+ cur = cur is JsonObject o ? Find(o, seg) : null;
+ return cur;
+ }
+
+ private static void RemovePath(JsonObject root, string path)
+ {
+ var segs = path.Split('.');
+ JsonObject? cur = root;
+ for (var i = 0; i < segs.Length - 1 && cur is not null; i++)
+ cur = Find(cur, segs[i]) as JsonObject;
+ if (cur is not null) Remove(cur, segs[^1]);
+ }
+
+ private static void Set(JsonObject root, string path, JsonNode value)
+ {
+ var segs = path.Split('.');
+ var cur = root;
+ for (var i = 0; i < segs.Length - 1; i++)
+ {
+ if (Find(cur, segs[i]) is not JsonObject next) { next = []; cur[segs[i]] = next; }
+ cur = next;
+ }
+ cur[segs[^1]] = value;
+ }
+
private static JsonNode? Find(JsonObject o, string key)
{
foreach (var kv in o)
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverConfigMs.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverConfigMs.cs
new file mode 100644
index 00000000..34250323
--- /dev/null
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverConfigMs.cs
@@ -0,0 +1,39 @@
+using System.Text.Json;
+using System.Text.Json.Nodes;
+
+namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers;
+
+///
+/// Shared millisecond helpers for the AB forms (#576): the driver factories bind *Ms integers
+/// while the forms edit whole seconds, so a sub-second stored value must survive an edit of an
+/// unrelated field.
+///
+internal static class DriverConfigMs
+{
+ /// Reads an integer-millisecond key at from , or null.
+ public static int? Read(string? json, string path)
+ {
+ if (string.IsNullOrWhiteSpace(json)) return null;
+ try
+ {
+ JsonNode? cur = JsonNode.Parse(json);
+ foreach (var seg in path.Split('.'))
+ {
+ if (cur is not JsonObject o) return null;
+ cur = o.FirstOrDefault(kv => string.Equals(kv.Key, seg, StringComparison.OrdinalIgnoreCase)).Value;
+ }
+ return cur is JsonValue v && v.TryGetValue(out var ms) ? ms : null;
+ }
+ catch (JsonException) { return null; }
+ }
+
+ /// Whole seconds for display (truncating, like the pre-#576 forms).
+ public static int ToSeconds(TimeSpan t) => (int)t.TotalSeconds;
+
+ ///
+ /// The duration to save: the loaded value when the operator left the seconds field unchanged
+ /// (keeps 500 ms from collapsing to 0 s), else the edited whole seconds.
+ ///
+ public static TimeSpan Save(int seconds, TimeSpan? loaded)
+ => loaded is { } l && ToSeconds(l) == seconds ? l : TimeSpan.FromSeconds(seconds);
+}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/AbCipDriverForm.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/AbCipDriverForm.razor
index e3e765c7..093822d8 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/AbCipDriverForm.razor
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/AbCipDriverForm.razor
@@ -139,9 +139,16 @@
/// Top-level/nested JSON paths this form edits; everything else in the stored config is preserved.
internal static readonly string[] OwnedPaths =
[
- "timeout", "transport", "enableControllerBrowse", "enableDeclarationOnlyUdtGrouping",
- "enableAlarmProjection", "alarmPollInterval", "maxConcurrentReads", "probeTimeoutSeconds",
- "probe.enabled", "probe.interval", "probe.timeout", "probe.probeTagPath",
+ "transport", "enableControllerBrowse", "enableDeclarationOnlyUdtGrouping",
+ "enableAlarmProjection", "maxConcurrentReads", "probeTimeoutSeconds",
+ "probe.enabled", "probe.probeTagPath",
+ ];
+
+ /// The factory binds integer-millisecond keys, not the TimeSpan strings the typed options serialise (#576).
+ internal static readonly (string TimeSpanPath, string MsPath)[] TimeSpanToMs =
+ [
+ ("timeout", "timeoutMs"), ("alarmPollInterval", "alarmPollIntervalMs"),
+ ("probe.interval", "probe.intervalMs"), ("probe.timeout", "probe.timeoutMs"),
];
// Preserve the driver's existing Devices across a load→save (the device form authors the real endpoints;
@@ -154,7 +161,7 @@
if (!string.Equals(_lastParsed, DriverConfigJson, StringComparison.Ordinal))
{
var opts = TryDeserialize(DriverConfigJson) ?? new AbCipDriverOptions();
- _form = FormModel.FromOptions(opts);
+ _form = FormModel.FromJson(DriverConfigJson, opts);
// #577: a brand-new driver (nothing authored yet) must not default the probe ON with no tag path —
// that logs an operator warning per driver and runs no probe. An existing config keeps whatever
// it stores (an absent "probe" key still means "enabled" to the driver, so don't flip it on edit).
@@ -173,7 +180,7 @@
/// Serializes options with the form's JSON settings (camelCase, enums as strings).
internal static string Serialize(AbCipDriverOptions options, string? originalJson = null)
- => DriverConfigJsonOverlay.Apply(originalJson, JsonSerializer.Serialize(options, _jsonOpts), OwnedPaths);
+ => DriverConfigJsonOverlay.Apply(originalJson, JsonSerializer.Serialize(options, _jsonOpts), OwnedPaths, TimeSpanToMs);
private async Task EmitAsync()
{
@@ -222,8 +229,31 @@
// Admin UI probe timeout
public int AdminProbeTimeoutSeconds { get; set; } = 5;
+ // The loaded durations, so an unchanged seconds field saves the exact stored value (sub-second safe).
+ private TimeSpan? _loadedTimeout, _loadedAlarmPoll, _loadedProbeInterval, _loadedProbeTimeout;
+
+ /// Loads from the stored JSON: the factory's *Ms keys win; a legacy TimeSpan-string key is the fallback.
+ public static FormModel FromJson(string? json, AbCipDriverOptions o)
+ {
+ static TimeSpan? Ms(string? j, string path) => DriverConfigMs.Read(j, path) is int ms ? TimeSpan.FromMilliseconds(ms) : null;
+ var f = FromOptions(o);
+ f._loadedTimeout = Ms(json, "timeoutMs") ?? f._loadedTimeout;
+ f._loadedAlarmPoll = Ms(json, "alarmPollIntervalMs") ?? f._loadedAlarmPoll;
+ f._loadedProbeInterval = Ms(json, "probe.intervalMs") ?? f._loadedProbeInterval;
+ f._loadedProbeTimeout = Ms(json, "probe.timeoutMs") ?? f._loadedProbeTimeout;
+ f.TimeoutSeconds = DriverConfigMs.ToSeconds(f._loadedTimeout ?? o.Timeout);
+ f.AlarmPollIntervalSeconds = DriverConfigMs.ToSeconds(f._loadedAlarmPoll ?? o.AlarmPollInterval);
+ f.ProbeIntervalSeconds = DriverConfigMs.ToSeconds(f._loadedProbeInterval ?? o.Probe.Interval);
+ f.ProbeTimeoutSeconds = DriverConfigMs.ToSeconds(f._loadedProbeTimeout ?? o.Probe.Timeout);
+ return f;
+ }
+
public static FormModel FromOptions(AbCipDriverOptions o) => new()
{
+ _loadedTimeout = o.Timeout,
+ _loadedAlarmPoll = o.AlarmPollInterval,
+ _loadedProbeInterval = o.Probe.Interval,
+ _loadedProbeTimeout = o.Probe.Timeout,
TimeoutSeconds = (int)o.Timeout.TotalSeconds,
Transport = o.Transport,
MaxConcurrentReads = o.MaxConcurrentReads,
@@ -245,16 +275,16 @@
Probe = new AbCipProbeOptions
{
Enabled = ProbeEnabled,
- Interval = TimeSpan.FromSeconds(ProbeIntervalSeconds),
- Timeout = TimeSpan.FromSeconds(ProbeTimeoutSeconds),
+ Interval = DriverConfigMs.Save(ProbeIntervalSeconds, _loadedProbeInterval),
+ Timeout = DriverConfigMs.Save(ProbeTimeoutSeconds, _loadedProbeTimeout),
ProbeTagPath = string.IsNullOrWhiteSpace(ProbeTagPath) ? null : ProbeTagPath,
},
- Timeout = TimeSpan.FromSeconds(TimeoutSeconds),
+ Timeout = DriverConfigMs.Save(TimeoutSeconds, _loadedTimeout),
Transport = Transport,
MaxConcurrentReads = MaxConcurrentReads,
EnableControllerBrowse = EnableControllerBrowse,
EnableAlarmProjection = EnableAlarmProjection,
- AlarmPollInterval = TimeSpan.FromSeconds(AlarmPollIntervalSeconds),
+ AlarmPollInterval = DriverConfigMs.Save(AlarmPollIntervalSeconds, _loadedAlarmPoll),
EnableDeclarationOnlyUdtGrouping = EnableDeclarationOnlyUdtGrouping,
ProbeTimeoutSeconds = AdminProbeTimeoutSeconds,
};
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/AbLegacyDriverForm.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/AbLegacyDriverForm.razor
index 972648de..9db2085a 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/AbLegacyDriverForm.razor
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/AbLegacyDriverForm.razor
@@ -97,8 +97,14 @@
/// JSON paths this form edits; everything else in the stored config is preserved.
internal static readonly string[] OwnedPaths =
[
- "timeout", "transport", "probeTimeoutSeconds",
- "probe.enabled", "probe.interval", "probe.timeout", "probe.probeAddress",
+ "transport", "probeTimeoutSeconds",
+ "probe.enabled", "probe.probeAddress",
+ ];
+
+ /// The factory binds integer-millisecond keys, not the TimeSpan strings the typed options serialise (#576).
+ internal static readonly (string TimeSpanPath, string MsPath)[] TimeSpanToMs =
+ [
+ ("timeout", "timeoutMs"), ("probe.interval", "probe.intervalMs"), ("probe.timeout", "probe.timeoutMs"),
];
// Devices are separate v3 entities — this form doesn't edit them, but preserves the driver's existing
@@ -111,7 +117,7 @@
if (!string.Equals(_lastParsed, DriverConfigJson, StringComparison.Ordinal))
{
var opts = TryDeserialize(DriverConfigJson) ?? new AbLegacyDriverOptions();
- _form = FormModel.FromOptions(opts);
+ _form = FormModel.FromJson(DriverConfigJson, opts);
_originalJson = DriverConfigJson;
_preservedDevices = opts.Devices;
_lastParsed = DriverConfigJson;
@@ -125,7 +131,7 @@
/// Serializes options with the form's JSON settings (camelCase, enums as strings).
internal static string Serialize(AbLegacyDriverOptions options, string? originalJson = null)
- => DriverConfigJsonOverlay.Apply(originalJson, JsonSerializer.Serialize(options, _jsonOpts), OwnedPaths);
+ => DriverConfigJsonOverlay.Apply(originalJson, JsonSerializer.Serialize(options, _jsonOpts), OwnedPaths, TimeSpanToMs);
private async Task EmitAsync()
{
@@ -171,8 +177,27 @@
public string? ResilienceConfig { get; set; }
public byte[] RowVersion { get; set; } = [];
+ private TimeSpan? _loadedTimeout, _loadedProbeInterval, _loadedProbeTimeout;
+
+ /// Loads from the stored JSON: the factory's *Ms keys win; a legacy TimeSpan-string key is the fallback.
+ public static FormModel FromJson(string? json, AbLegacyDriverOptions o)
+ {
+ static TimeSpan? Ms(string? j, string path) => DriverConfigMs.Read(j, path) is int ms ? TimeSpan.FromMilliseconds(ms) : null;
+ var f = FromOptions(o);
+ f._loadedTimeout = Ms(json, "timeoutMs") ?? f._loadedTimeout;
+ f._loadedProbeInterval = Ms(json, "probe.intervalMs") ?? f._loadedProbeInterval;
+ f._loadedProbeTimeout = Ms(json, "probe.timeoutMs") ?? f._loadedProbeTimeout;
+ f.TimeoutSeconds = DriverConfigMs.ToSeconds(f._loadedTimeout ?? o.Timeout);
+ f.ProbeIntervalSeconds = DriverConfigMs.ToSeconds(f._loadedProbeInterval ?? o.Probe.Interval);
+ f.ProbeTimeoutSeconds = DriverConfigMs.ToSeconds(f._loadedProbeTimeout ?? o.Probe.Timeout);
+ return f;
+ }
+
public static FormModel FromOptions(AbLegacyDriverOptions o) => new()
{
+ _loadedTimeout = o.Timeout,
+ _loadedProbeInterval = o.Probe.Interval,
+ _loadedProbeTimeout = o.Probe.Timeout,
TimeoutSeconds = (int)o.Timeout.TotalSeconds,
Transport = o.Transport,
ProbeEnabled = o.Probe.Enabled,
@@ -189,11 +214,11 @@
Probe = new AbLegacyProbeOptions
{
Enabled = ProbeEnabled,
- Interval = TimeSpan.FromSeconds(ProbeIntervalSeconds),
- Timeout = TimeSpan.FromSeconds(ProbeTimeoutSeconds),
+ Interval = DriverConfigMs.Save(ProbeIntervalSeconds, _loadedProbeInterval),
+ Timeout = DriverConfigMs.Save(ProbeTimeoutSeconds, _loadedProbeTimeout),
ProbeAddress = string.IsNullOrWhiteSpace(ProbeAddress) ? null : ProbeAddress,
},
- Timeout = TimeSpan.FromSeconds(TimeoutSeconds),
+ Timeout = DriverConfigMs.Save(TimeoutSeconds, _loadedTimeout),
Transport = Transport,
ProbeTimeoutSeconds = AdminProbeTimeoutSeconds,
};
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AbCipDriverPageFormSerializationTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AbCipDriverPageFormSerializationTests.cs
index 426d0bb4..9ac7ee98 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AbCipDriverPageFormSerializationTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AbCipDriverPageFormSerializationTests.cs
@@ -141,7 +141,7 @@ public sealed class AbCipDriverPageFormSerializationTests
var json = AbCipDriverForm.Serialize(form.ToOptions(loaded.Devices));
json.ShouldContain($"\"transport\":\"{savedValue}\"");
- json.ShouldContain("\"timeout\":\"00:00:07\"");
+ json.ShouldContain("\"timeoutMs\":7000");
}
[Fact]
@@ -158,7 +158,7 @@ public sealed class AbCipDriverPageFormSerializationTests
form.TimeoutSeconds = 7;
var json = AbCipDriverForm.Serialize(form.ToOptions(loaded.Devices));
json.ShouldNotContain("transport", Case.Insensitive);
- json.ShouldContain("\"timeout\":\"00:00:07\"");
+ json.ShouldContain("\"timeoutMs\":7000");
}
[Fact]
@@ -195,17 +195,18 @@ public sealed class AbCipDriverPageFormSerializationTests
var root = System.Text.Json.Nodes.JsonNode.Parse(
AbCipDriverForm.Serialize(form.ToOptions(loaded.Devices), FullConfig))!.AsObject();
- root["timeout"]!.GetValue().ShouldBe("00:00:09");
+ root["timeoutMs"]!.GetValue().ShouldBe(9000);
+ root.ContainsKey("timeout").ShouldBeFalse();
root["maxConcurrentReads"]!.GetValue().ShouldBe(3); // was reset to 8 before #576
root["transport"]!.GetValue().ShouldBe("Native");
root["enableControllerBrowse"]!.GetValue().ShouldBeTrue();
root["enableDeclarationOnlyUdtGrouping"]!.GetValue().ShouldBeTrue();
root["enableAlarmProjection"]!.GetValue().ShouldBeTrue();
- root["alarmPollInterval"]!.GetValue().ShouldBe("00:00:02");
+ root["alarmPollIntervalMs"]!.GetValue().ShouldBe(2000);
root["probeTimeoutSeconds"]!.GetValue().ShouldBe(12);
root["probe"]!["enabled"]!.GetValue().ShouldBeTrue();
- root["probe"]!["interval"]!.GetValue().ShouldBe("00:00:10");
- root["probe"]!["timeout"]!.GetValue().ShouldBe("00:00:03");
+ root["probe"]!["intervalMs"]!.GetValue().ShouldBe(10000);
+ root["probe"]!["timeoutMs"]!.GetValue().ShouldBe(3000);
root["probe"]!["probeTagPath"]!.GetValue().ShouldBe("Program:Main.Hb");
// unknown keys at every level survive
root["probe"]!["futureProbeKnob"]!.GetValue().ShouldBe(7);
@@ -245,4 +246,64 @@ public sealed class AbCipDriverPageFormSerializationTests
json.ShouldContain("\"transport\":\"Libplctag\"");
json.ShouldContain("futureOption");
}
+
+ // ---- #576: the saved JSON must be what the driver FACTORY binds (integer-ms keys) ----
+
+ [Fact]
+ public void An_edited_timeout_reaches_the_factory_bound_options()
+ {
+ var original = """{"timeoutMs":5000,"alarmPollIntervalMs":500,"probe":{"enabled":true,"intervalMs":7000,"timeoutMs":1500,"probeTagPath":"Program:Main.Hb"},"devices":[{"hostAddress":"ab://10.0.0.1/1,0"}]}""";
+ var loaded = AbCipDriverForm.TryDeserialize(original)!;
+ var form = AbCipDriverForm.FormModel.FromJson(original, loaded);
+ form.TimeoutSeconds.ShouldBe(5); // the form SEES the factory-keyed value
+ form.TimeoutSeconds = 11;
+
+ var saved = AbCipDriverForm.Serialize(form.ToOptions(loaded.Devices), original);
+ var bound = ZB.MOM.WW.OtOpcUa.Driver.AbCip.AbCipDriverFactoryExtensions.ParseOptions("t", saved);
+
+ bound.Timeout.ShouldBe(TimeSpan.FromSeconds(11));
+ // untouched durations keep their exact stored value, including sub-second ones
+ bound.AlarmPollInterval.ShouldBe(TimeSpan.FromMilliseconds(500));
+ bound.Probe.Interval.ShouldBe(TimeSpan.FromSeconds(7));
+ bound.Probe.Timeout.ShouldBe(TimeSpan.FromMilliseconds(1500));
+ bound.Probe.ProbeTagPath.ShouldBe("Program:Main.Hb");
+ }
+
+ [Fact]
+ public void A_legacy_TimeSpan_string_key_is_loaded_then_migrated_to_ms_and_removed()
+ {
+ var original = """{"timeout":"00:00:05","alarmPollInterval":"00:00:02","probe":{"interval":"00:00:10","timeout":"00:00:03"}}""";
+ var loaded = AbCipDriverForm.TryDeserialize(original)!;
+ var form = AbCipDriverForm.FormModel.FromJson(original, loaded);
+ form.TimeoutSeconds.ShouldBe(5);
+ form.TimeoutSeconds = 6;
+
+ var saved = AbCipDriverForm.Serialize(form.ToOptions(loaded.Devices), original);
+ var root = System.Text.Json.Nodes.JsonNode.Parse(saved)!.AsObject();
+ root.ContainsKey("timeout").ShouldBeFalse();
+ root.ContainsKey("alarmPollInterval").ShouldBeFalse();
+ root["probe"]!.AsObject().ContainsKey("interval").ShouldBeFalse();
+ root["probe"]!.AsObject().ContainsKey("timeout").ShouldBeFalse();
+
+ var bound = ZB.MOM.WW.OtOpcUa.Driver.AbCip.AbCipDriverFactoryExtensions.ParseOptions("t", saved);
+ bound.Timeout.ShouldBe(TimeSpan.FromSeconds(6));
+ bound.AlarmPollInterval.ShouldBe(TimeSpan.FromSeconds(2));
+ bound.Probe.Interval.ShouldBe(TimeSpan.FromSeconds(10));
+ bound.Probe.Timeout.ShouldBe(TimeSpan.FromSeconds(3));
+ }
+
+ [Fact]
+ public void A_form_saved_config_is_still_accepted_by_the_Test_Connect_probe_parse()
+ {
+ // The probe deserialises the typed options and skips unmapped keys, so the ms keys must not break it.
+ var original = """{"timeoutMs":5000,"devices":[{"hostAddress":"ab://10.0.0.1/1,0"}]}""";
+ var loaded = AbCipDriverForm.TryDeserialize(original)!;
+ var form = AbCipDriverForm.FormModel.FromJson(original, loaded);
+ form.TimeoutSeconds = 8;
+ var saved = AbCipDriverForm.Serialize(form.ToOptions(loaded.Devices), original);
+
+ var opts = System.Text.Json.JsonSerializer.Deserialize(saved, TestJsonOpts);
+ opts.ShouldNotBeNull();
+ opts.Devices[0].HostAddress.ShouldBe("ab://10.0.0.1/1,0");
+ }
}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AbLegacyDriverPageFormSerializationTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AbLegacyDriverPageFormSerializationTests.cs
index 2df3c36b..f4297127 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AbLegacyDriverPageFormSerializationTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AbLegacyDriverPageFormSerializationTests.cs
@@ -137,7 +137,7 @@ public sealed class AbLegacyDriverPageFormSerializationTests
var json = AbLegacyDriverForm.Serialize(form.ToOptions(loaded.Devices));
json.ShouldContain($"\"transport\":\"{savedValue}\"");
- json.ShouldContain("\"timeout\":\"00:00:07\"");
+ json.ShouldContain("\"timeoutMs\":7000");
}
[Fact]
@@ -153,7 +153,7 @@ public sealed class AbLegacyDriverPageFormSerializationTests
form.TimeoutSeconds = 7;
var json = AbLegacyDriverForm.Serialize(form.ToOptions(loaded.Devices));
json.ShouldNotContain("transport", Case.Insensitive);
- json.ShouldContain("\"timeout\":\"00:00:07\"");
+ json.ShouldContain("\"timeoutMs\":7000");
}
[Fact]
@@ -208,12 +208,13 @@ public sealed class AbLegacyDriverPageFormSerializationTests
var root = System.Text.Json.Nodes.JsonNode.Parse(
AbLegacyDriverForm.Serialize(form.ToOptions(loaded.Devices), FullConfig))!.AsObject();
- root["timeout"]!.GetValue().ShouldBe("00:00:09");
+ root["timeoutMs"]!.GetValue().ShouldBe(9000);
+ root.ContainsKey("timeout").ShouldBeFalse();
root["transport"]!.GetValue().ShouldBe("Native");
root["probeTimeoutSeconds"]!.GetValue().ShouldBe(12);
root["probe"]!["enabled"]!.GetValue().ShouldBeTrue();
- root["probe"]!["interval"]!.GetValue().ShouldBe("00:00:10");
- root["probe"]!["timeout"]!.GetValue().ShouldBe("00:00:03");
+ root["probe"]!["intervalMs"]!.GetValue().ShouldBe(10000);
+ root["probe"]!["timeoutMs"]!.GetValue().ShouldBe(3000);
root["probe"]!["probeAddress"]!.GetValue().ShouldBe("S:1");
root["probe"]!["futureProbeKnob"]!.GetValue().ShouldBe(7);
root["devices"]![0]!["futureDeviceKey"]!.GetValue().ShouldBe("keep");
@@ -234,4 +235,45 @@ public sealed class AbLegacyDriverPageFormSerializationTests
root.ContainsKey("transport").ShouldBeFalse();
root["futureOption"].ShouldNotBeNull();
}
+
+ // ---- #576: the saved JSON must be what the driver FACTORY binds (integer-ms keys) ----
+
+ [Fact]
+ public void An_edited_timeout_reaches_the_factory_bound_options()
+ {
+ var original = """{"timeoutMs":5000,"probe":{"enabled":true,"intervalMs":7000,"timeoutMs":1500,"probeAddress":"S:1"},"devices":[{"hostAddress":"ab://10.0.0.1/1,0","plcFamily":"Slc500"}]}""";
+ var loaded = AbLegacyDriverForm.TryDeserialize(original)!;
+ var form = AbLegacyDriverForm.FormModel.FromJson(original, loaded);
+ form.TimeoutSeconds.ShouldBe(5);
+ form.TimeoutSeconds = 11;
+
+ var saved = AbLegacyDriverForm.Serialize(form.ToOptions(loaded.Devices), original);
+ var bound = ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.AbLegacyDriverFactoryExtensions.ParseOptions("t", saved);
+
+ bound.Timeout.ShouldBe(TimeSpan.FromSeconds(11));
+ bound.Probe.Interval.ShouldBe(TimeSpan.FromSeconds(7));
+ bound.Probe.Timeout.ShouldBe(TimeSpan.FromMilliseconds(1500));
+ bound.Probe.ProbeAddress.ShouldBe("S:1");
+ }
+
+ [Fact]
+ public void A_legacy_TimeSpan_string_key_is_loaded_then_migrated_to_ms_and_removed()
+ {
+ var original = """{"timeout":"00:00:05","probe":{"interval":"00:00:10","timeout":"00:00:03"}}""";
+ var loaded = AbLegacyDriverForm.TryDeserialize(original)!;
+ var form = AbLegacyDriverForm.FormModel.FromJson(original, loaded);
+ form.TimeoutSeconds.ShouldBe(5);
+ form.TimeoutSeconds = 6;
+
+ var saved = AbLegacyDriverForm.Serialize(form.ToOptions(loaded.Devices), original);
+ var root = System.Text.Json.Nodes.JsonNode.Parse(saved)!.AsObject();
+ root.ContainsKey("timeout").ShouldBeFalse();
+ root["probe"]!.AsObject().ContainsKey("interval").ShouldBeFalse();
+ root["probe"]!.AsObject().ContainsKey("timeout").ShouldBeFalse();
+
+ var bound = ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.AbLegacyDriverFactoryExtensions.ParseOptions("t", saved);
+ bound.Timeout.ShouldBe(TimeSpan.FromSeconds(6));
+ bound.Probe.Interval.ShouldBe(TimeSpan.FromSeconds(10));
+ bound.Probe.Timeout.ShouldBe(TimeSpan.FromSeconds(3));
+ }
}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ZB.MOM.WW.OtOpcUa.AdminUI.Tests.csproj b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ZB.MOM.WW.OtOpcUa.AdminUI.Tests.csproj
index ba5aba32..742ab993 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ZB.MOM.WW.OtOpcUa.AdminUI.Tests.csproj
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ZB.MOM.WW.OtOpcUa.AdminUI.Tests.csproj
@@ -37,6 +37,9 @@
project referencing a runtime driver is fine — tests are not in the AdminUI's deploy
graph, which is the whole thing #526 was about. -->
+
+
+