diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/AbCipDriverPage.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/AbCipDriverPage.razor new file mode 100644 index 00000000..6198b0a1 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/AbCipDriverPage.razor @@ -0,0 +1,369 @@ +@page "/clusters/{ClusterId}/drivers/new/abcip" +@attribute [Microsoft.AspNetCore.Authorization.Authorize] +@rendermode RenderMode.InteractiveServer +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.EntityFrameworkCore +@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers +@using ZB.MOM.WW.OtOpcUa.Configuration +@using ZB.MOM.WW.OtOpcUa.Configuration.Entities +@using ZB.MOM.WW.OtOpcUa.Driver.AbCip +@inject IDbContextFactory DbFactory +@inject NavigationManager Nav + +
+

@(IsNew ? "New AB CIP driver" : "Edit AB CIP driver") · @ClusterId

+ Cancel +
+ + +@if (!_loaded) +{ +

Loading…

+} +else if (!IsNew && _existing is null) +{ +
+ Driver instance @DriverInstanceId was not found in cluster @ClusterId. +
+} +else +{ + + + + + + + @* Operation timeout *@ +
+
Operation settings
+
+
+
+ + +
Default libplctag call timeout. Default 2 s.
+
+
+
+ + +
+
Walk the Logix symbol table and surface globals under Discovered/.
+
+
+
+ + +
+
Only enable when UDT member declaration order matches controller compiled layout.
+
+
+
+
+ + @* Alarm projection *@ +
+
Alarm projection
+
+
+
+
+ + +
+
Surfaces ALMD tags as alarm conditions via IAlarmSource. Default off.
+
+
+ + +
Default 1 s.
+
+
+
+
+ + @* Connectivity probe *@ +
+
Connectivity probe
+
+
+
+
+ + +
+
+
+ + +
Default 5 s.
+
+
+ + +
Default 2 s.
+
+
+ + +
Required when probe is enabled. Leave blank and an operator warning is logged.
+
+
+ + +
Max 60. Used by Test Connect. Default 5.
+
+
+
+
+ + @* Devices — read-only JSON view *@ +
+
Devices
+
+

+ Device list (host addresses, PLC family, packing overrides) — full list-editor coming in a follow-up phase. Each entry: { "hostAddress": "ab://gateway/1,0", "plcFamily": "ControlLogix" }. +

+
@_devicesJson
+
+
+ + @* Tags — read-only JSON view *@ +
+
Tags
+
+

+ Tag list — full list-editor coming in a follow-up phase. Edit via the Tag editor pages or export/import the driver config JSON. +

+
@_tagsJson
+
+
+ + +
+
+} + +@code { + [Parameter] public string ClusterId { get; set; } = ""; + [Parameter] public string? DriverInstanceId { get; set; } + + private const string DriverTypeKey = "AbCip"; + + private bool IsNew => string.IsNullOrEmpty(DriverInstanceId); + + private static readonly System.Text.Json.JsonSerializerOptions _jsonOpts = new() + { + PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase, + UnmappedMemberHandling = System.Text.Json.Serialization.JsonUnmappedMemberHandling.Skip, + WriteIndented = false, + }; + + private FormModel _form = new(); + private DriverIdentitySection.DriverIdentityModel _identityModel = new() { DriverType = DriverTypeKey }; + private DriverInstance? _existing; + private List _namespaces = new(); + private bool _loaded; + private bool _busy; + private string? _error; + + // Collections are preserved through round-trip and shown as read-only JSON. + private IReadOnlyList _devices = []; + private IReadOnlyList _tags = []; + private string _devicesJson = "[]"; + private string _tagsJson = "[]"; + + protected override async Task OnInitializedAsync() + { + await using var db = await DbFactory.CreateDbContextAsync(); + _namespaces = await db.Namespaces.AsNoTracking() + .Where(n => n.ClusterId == ClusterId) + .OrderBy(n => n.NamespaceId) + .ToListAsync(); + + if (IsNew) + { + _identityModel = new() + { + DriverInstanceId = "", + Name = "", + DriverType = DriverTypeKey, + NamespaceId = _namespaces.FirstOrDefault()?.NamespaceId ?? "", + Enabled = true, + }; + _form = new FormModel(); + } + else + { + _existing = await db.DriverInstances.AsNoTracking() + .FirstOrDefaultAsync(d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId); + if (_existing is not null) + { + _identityModel = new() + { + DriverInstanceId = _existing.DriverInstanceId, + Name = _existing.Name, + DriverType = _existing.DriverType, + NamespaceId = _existing.NamespaceId, + Enabled = _existing.Enabled, + }; + var opts = TryDeserialize(_existing.DriverConfig) ?? new AbCipDriverOptions(); + _form = FormModel.FromOptions(opts); + _form.ResilienceConfig = _existing.ResilienceConfig; + _form.RowVersion = _existing.RowVersion; + _devices = opts.Devices; + _tags = opts.Tags; + } + } + _devicesJson = System.Text.Json.JsonSerializer.Serialize(_devices, _jsonOpts); + _tagsJson = System.Text.Json.JsonSerializer.Serialize(_tags, _jsonOpts); + _loaded = true; + } + + private async Task SubmitAsync() + { + _busy = true; _error = null; + try + { + var configJson = System.Text.Json.JsonSerializer.Serialize(_form.ToOptions(_devices, _tags), _jsonOpts); + await using var db = await DbFactory.CreateDbContextAsync(); + if (IsNew) + { + if (await db.DriverInstances.AnyAsync(d => d.DriverInstanceId == _identityModel.DriverInstanceId)) + { + _error = $"Driver instance '{_identityModel.DriverInstanceId}' already exists."; + return; + } + db.DriverInstances.Add(new DriverInstance + { + DriverInstanceId = _identityModel.DriverInstanceId, + ClusterId = ClusterId, + NamespaceId = _identityModel.NamespaceId, + Name = _identityModel.Name, + DriverType = DriverTypeKey, + Enabled = _identityModel.Enabled, + DriverConfig = configJson, + ResilienceConfig = string.IsNullOrWhiteSpace(_form.ResilienceConfig) ? null : _form.ResilienceConfig, + }); + } + else + { + var entity = await db.DriverInstances.FirstOrDefaultAsync( + d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId); + if (entity is null) { _error = "Row no longer exists."; return; } + db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion; + entity.NamespaceId = _identityModel.NamespaceId; + entity.Name = _identityModel.Name; + entity.Enabled = _identityModel.Enabled; + entity.DriverConfig = configJson; + entity.ResilienceConfig = string.IsNullOrWhiteSpace(_form.ResilienceConfig) ? null : _form.ResilienceConfig; + } + await db.SaveChangesAsync(); + Nav.NavigateTo($"/clusters/{ClusterId}/drivers"); + } + catch (DbUpdateConcurrencyException) + { + _error = "Another user changed this driver instance while you were editing. Reload to see the latest values, then re-apply your changes."; + } + catch (Exception ex) { _error = ex.Message; } + finally { _busy = false; } + } + + private async Task DeleteAsync() + { + if (IsNew) return; + _busy = true; _error = null; + try + { + await using var db = await DbFactory.CreateDbContextAsync(); + var entity = await db.DriverInstances.FirstOrDefaultAsync( + d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId); + if (entity is null) { Nav.NavigateTo($"/clusters/{ClusterId}/drivers"); return; } + db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion; + db.DriverInstances.Remove(entity); + await db.SaveChangesAsync(); + Nav.NavigateTo($"/clusters/{ClusterId}/drivers"); + } + catch (DbUpdateConcurrencyException) + { + _error = "Another user changed this driver instance while you were viewing it. Reload before deleting."; + } + catch (Exception ex) + { + _error = $"Delete failed: {ex.Message}. (Likely because equipment/tags still reference this driver — remove them first.)"; + } + finally { _busy = false; } + } + + private static AbCipDriverOptions? TryDeserialize(string json) + { + try { return System.Text.Json.JsonSerializer.Deserialize(json, _jsonOpts); } + catch { return null; } + } + + // Flat mutable model — all scalar properties settable for Blazor @bind-Value. + // Collections (Devices, Tags) are kept on the component and passed in on ToOptions(). + public sealed class FormModel + { + // Operation + public int TimeoutSeconds { get; set; } = 2; + public bool EnableControllerBrowse { get; set; } = false; + public bool EnableDeclarationOnlyUdtGrouping { get; set; } = false; + + // Alarm projection + public bool EnableAlarmProjection { get; set; } = false; + public int AlarmPollIntervalSeconds { get; set; } = 1; + + // Probe + public bool ProbeEnabled { get; set; } = true; + public int ProbeIntervalSeconds { get; set; } = 5; + public int ProbeTimeoutSeconds { get; set; } = 2; + public string? ProbeTagPath { get; set; } + + // Admin UI probe timeout + public int AdminProbeTimeoutSeconds { get; set; } = 5; + + // Persistence + public string? ResilienceConfig { get; set; } + public byte[] RowVersion { get; set; } = []; + + public static FormModel FromOptions(AbCipDriverOptions o) => new() + { + TimeoutSeconds = (int)o.Timeout.TotalSeconds, + EnableControllerBrowse = o.EnableControllerBrowse, + EnableDeclarationOnlyUdtGrouping = o.EnableDeclarationOnlyUdtGrouping, + EnableAlarmProjection = o.EnableAlarmProjection, + AlarmPollIntervalSeconds = (int)o.AlarmPollInterval.TotalSeconds, + ProbeEnabled = o.Probe.Enabled, + ProbeIntervalSeconds = (int)o.Probe.Interval.TotalSeconds, + ProbeTimeoutSeconds = (int)o.Probe.Timeout.TotalSeconds, + ProbeTagPath = o.Probe.ProbeTagPath, + AdminProbeTimeoutSeconds = o.ProbeTimeoutSeconds, + }; + + public AbCipDriverOptions ToOptions( + IReadOnlyList devices, + IReadOnlyList tags) => new() + { + Devices = devices, + Tags = tags, + Probe = new AbCipProbeOptions + { + Enabled = ProbeEnabled, + Interval = TimeSpan.FromSeconds(ProbeIntervalSeconds), + Timeout = TimeSpan.FromSeconds(ProbeTimeoutSeconds), + ProbeTagPath = string.IsNullOrWhiteSpace(ProbeTagPath) ? null : ProbeTagPath, + }, + Timeout = TimeSpan.FromSeconds(TimeoutSeconds), + EnableControllerBrowse = EnableControllerBrowse, + EnableAlarmProjection = EnableAlarmProjection, + AlarmPollInterval = TimeSpan.FromSeconds(AlarmPollIntervalSeconds), + EnableDeclarationOnlyUdtGrouping = EnableDeclarationOnlyUdtGrouping, + ProbeTimeoutSeconds = AdminProbeTimeoutSeconds, + }; + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/TwinCATDriverPage.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/TwinCATDriverPage.razor new file mode 100644 index 00000000..0c45af66 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/TwinCATDriverPage.razor @@ -0,0 +1,361 @@ +@page "/clusters/{ClusterId}/drivers/new/twincat" +@attribute [Microsoft.AspNetCore.Authorization.Authorize] +@rendermode RenderMode.InteractiveServer +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.EntityFrameworkCore +@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers +@using ZB.MOM.WW.OtOpcUa.Configuration +@using ZB.MOM.WW.OtOpcUa.Configuration.Entities +@using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT +@inject IDbContextFactory DbFactory +@inject NavigationManager Nav + +
+

@(IsNew ? "New Beckhoff TwinCAT driver" : "Edit Beckhoff TwinCAT driver") · @ClusterId

+ Cancel +
+ + +@if (!_loaded) +{ +

Loading…

+} +else if (!IsNew && _existing is null) +{ +
+ Driver instance @DriverInstanceId was not found in cluster @ClusterId. +
+} +else +{ + + + + + + + @* Options *@ +
+
Options
+
+
+
+ + +
Default 2 s per operation.
+
+
+
+ + +
+
Recommended. Disable for AMS routers with notification limits.
+
+
+
+ + +
+
Walks the symbol table via SymbolLoaderFactory at discovery. Default off.
+
+
+
+
+ + +
0 = push immediately. Increase to coalesce high-churn signals.
+
+
+
+
+ + @* Probe *@ +
+
Connectivity probe
+
+
+
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
Test Connect timeout (1–60 s).
+
+
+
+
+ + @* Devices — read-only JSON view *@ +
+
Devices
+
+
+ Each device is identified by AMS Net Id + port. Device list editor coming in a follow-up phase. + Format: [{"hostAddress":"192.168.0.1.1.1:851","deviceName":"PLC1"}] +
+ @if (_form.DevicesJson is not null) + { +
@_form.DevicesJson
+ } + else + { +

No devices configured.

+ } +
+
+ + @* Tags — read-only JSON view *@ +
+
Tags
+
+
+ Tag list editor coming in a follow-up phase. Tags reference device host addresses and TwinCAT symbol paths. +
+ @if (_form.TagsJson is not null) + { +
@_form.TagsJson
+ } + else + { +

No tags configured.

+ } +
+
+ + +
+
+} + +@code { + [Parameter] public string ClusterId { get; set; } = ""; + [Parameter] public string? DriverInstanceId { get; set; } + + private const string DriverTypeKey = "TwinCat"; + + private bool IsNew => string.IsNullOrEmpty(DriverInstanceId); + + private static readonly System.Text.Json.JsonSerializerOptions _jsonOpts = new() + { + PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase, + UnmappedMemberHandling = System.Text.Json.Serialization.JsonUnmappedMemberHandling.Skip, + WriteIndented = false, + }; + + private FormModel _form = new(); + private DriverIdentitySection.DriverIdentityModel _identityModel = new() { DriverType = DriverTypeKey }; + private DriverInstance? _existing; + private List _namespaces = new(); + private bool _loaded, _busy; + private string? _error; + + protected override async Task OnInitializedAsync() + { + await using var db = await DbFactory.CreateDbContextAsync(); + _namespaces = await db.Namespaces.AsNoTracking() + .Where(n => n.ClusterId == ClusterId) + .OrderBy(n => n.NamespaceId).ToListAsync(); + + if (IsNew) + { + _identityModel = new() { DriverType = DriverTypeKey, NamespaceId = _namespaces.FirstOrDefault()?.NamespaceId ?? "", Enabled = true }; + _form = FormModel.FromOptions(new TwinCATDriverOptions()); + } + else + { + _existing = await db.DriverInstances.AsNoTracking() + .FirstOrDefaultAsync(d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId); + if (_existing is not null) + { + _identityModel = new() + { + DriverInstanceId = _existing.DriverInstanceId, + Name = _existing.Name, + DriverType = _existing.DriverType, + NamespaceId = _existing.NamespaceId, + Enabled = _existing.Enabled, + }; + var opts = TryDeserialize(_existing.DriverConfig) ?? new TwinCATDriverOptions(); + _form = FormModel.FromOptions(opts); + _form.ResilienceConfig = _existing.ResilienceConfig; + _form.RowVersion = _existing.RowVersion; + } + } + _loaded = true; + } + + private async Task SubmitAsync() + { + _busy = true; _error = null; + try + { + var opts = _form.ToOptions(); + var configJson = System.Text.Json.JsonSerializer.Serialize(opts, _jsonOpts); + await using var db = await DbFactory.CreateDbContextAsync(); + if (IsNew) + { + if (await db.DriverInstances.AnyAsync(d => d.DriverInstanceId == _identityModel.DriverInstanceId)) + { + _error = $"Driver instance '{_identityModel.DriverInstanceId}' already exists."; return; + } + db.DriverInstances.Add(new DriverInstance + { + DriverInstanceId = _identityModel.DriverInstanceId, + ClusterId = ClusterId, + NamespaceId = _identityModel.NamespaceId, + Name = _identityModel.Name, + DriverType = DriverTypeKey, + Enabled = _identityModel.Enabled, + DriverConfig = configJson, + ResilienceConfig = string.IsNullOrWhiteSpace(_form.ResilienceConfig) ? null : _form.ResilienceConfig, + }); + } + else + { + var entity = await db.DriverInstances.FirstOrDefaultAsync( + d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId); + if (entity is null) { _error = "Row no longer exists."; return; } + db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion; + entity.NamespaceId = _identityModel.NamespaceId; + entity.Name = _identityModel.Name; + entity.Enabled = _identityModel.Enabled; + entity.DriverConfig = configJson; + entity.ResilienceConfig = string.IsNullOrWhiteSpace(_form.ResilienceConfig) ? null : _form.ResilienceConfig; + } + await db.SaveChangesAsync(); + Nav.NavigateTo($"/clusters/{ClusterId}/drivers"); + } + catch (DbUpdateConcurrencyException) + { + _error = "Another user changed this driver instance while you were editing. Reload to see the latest values, then re-apply your changes."; + } + catch (Exception ex) { _error = ex.Message; } + finally { _busy = false; } + } + + private async Task DeleteAsync() + { + if (IsNew) return; + _busy = true; _error = null; + try + { + await using var db = await DbFactory.CreateDbContextAsync(); + var entity = await db.DriverInstances.FirstOrDefaultAsync( + d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId); + if (entity is null) { Nav.NavigateTo($"/clusters/{ClusterId}/drivers"); return; } + db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion; + db.DriverInstances.Remove(entity); + await db.SaveChangesAsync(); + Nav.NavigateTo($"/clusters/{ClusterId}/drivers"); + } + catch (DbUpdateConcurrencyException) + { + _error = "Another user changed this driver instance while you were viewing it. Reload before deleting."; + } + catch (Exception ex) + { + _error = $"Delete failed: {ex.Message}. (Likely because equipment/tags still reference this driver — remove them first.)"; + } + finally { _busy = false; } + } + + private static TwinCATDriverOptions? TryDeserialize(string json) + { + try { return System.Text.Json.JsonSerializer.Deserialize(json, _jsonOpts); } + catch { return null; } + } + + public sealed class FormModel + { + // Options + public int TimeoutSeconds { get; set; } = 2; + public bool UseNativeNotifications { get; set; } = true; + public bool EnableControllerBrowse { get; set; } = false; + public int NotificationMaxDelayMs { get; set; } = 0; + + // Probe + public bool ProbeEnabled { get; set; } = true; + public int ProbeIntervalSeconds { get; set; } = 5; + public int ProbeTimeoutSeconds { get; set; } = 2; + public int AdminProbeTimeoutSeconds { get; set; } = 10; + + // Collections JSON view (read-only) + public string? DevicesJson { get; set; } + public string? TagsJson { get; set; } + + // Preserved originals (round-tripped unchanged) + private IReadOnlyList _devices = []; + private IReadOnlyList _tags = []; + + // Common + public string? ResilienceConfig { get; set; } + public byte[] RowVersion { get; set; } = []; + + private static readonly System.Text.Json.JsonSerializerOptions _displayOpts = new() + { + WriteIndented = true, + PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase, + }; + + public static FormModel FromOptions(TwinCATDriverOptions o) + { + var m = new FormModel + { + TimeoutSeconds = (int)o.Timeout.TotalSeconds, + UseNativeNotifications = o.UseNativeNotifications, + EnableControllerBrowse = o.EnableControllerBrowse, + NotificationMaxDelayMs = o.NotificationMaxDelayMs, + ProbeEnabled = o.Probe.Enabled, + ProbeIntervalSeconds = (int)o.Probe.Interval.TotalSeconds, + ProbeTimeoutSeconds = (int)o.Probe.Timeout.TotalSeconds, + AdminProbeTimeoutSeconds = o.ProbeTimeoutSeconds, + _devices = o.Devices, + _tags = o.Tags, + }; + m.DevicesJson = o.Devices.Count == 0 ? null + : System.Text.Json.JsonSerializer.Serialize(o.Devices, _displayOpts); + m.TagsJson = o.Tags.Count == 0 ? null + : System.Text.Json.JsonSerializer.Serialize(o.Tags, _displayOpts); + return m; + } + + public TwinCATDriverOptions ToOptions() => new() + { + Timeout = TimeSpan.FromSeconds(TimeoutSeconds), + UseNativeNotifications = UseNativeNotifications, + EnableControllerBrowse = EnableControllerBrowse, + NotificationMaxDelayMs = NotificationMaxDelayMs, + Probe = new TwinCATProbeOptions + { + Enabled = ProbeEnabled, + Interval = TimeSpan.FromSeconds(ProbeIntervalSeconds), + Timeout = TimeSpan.FromSeconds(ProbeTimeoutSeconds), + }, + ProbeTimeoutSeconds = AdminProbeTimeoutSeconds, + Devices = _devices, + Tags = _tags, + }; + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/TwinCATDriverPageFormSerializationTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/TwinCATDriverPageFormSerializationTests.cs new file mode 100644 index 00000000..f9379441 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/TwinCATDriverPageFormSerializationTests.cs @@ -0,0 +1,98 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests; + +public sealed class TwinCATDriverPageFormSerializationTests +{ + private static readonly JsonSerializerOptions _opts = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = false, + }; + + [Fact] + public void RoundTrip_PreservesKnownFields() + { + var original = new TwinCATDriverOptions + { + Timeout = TimeSpan.FromSeconds(4), + UseNativeNotifications = false, + EnableControllerBrowse = true, + NotificationMaxDelayMs = 50, + Probe = new TwinCATProbeOptions + { + Enabled = false, + Interval = TimeSpan.FromSeconds(10), + Timeout = TimeSpan.FromSeconds(3), + }, + ProbeTimeoutSeconds = 20, + Devices = [], + Tags = [], + }; + + var json = JsonSerializer.Serialize(original, _opts); + var back = JsonSerializer.Deserialize(json, _opts); + + back.ShouldNotBeNull(); + back.Timeout.ShouldBe(TimeSpan.FromSeconds(4)); + back.UseNativeNotifications.ShouldBeFalse(); + back.EnableControllerBrowse.ShouldBeTrue(); + back.NotificationMaxDelayMs.ShouldBe(50); + back.Probe.ShouldNotBeNull(); + back.Probe.Enabled.ShouldBeFalse(); + back.Probe.Interval.ShouldBe(TimeSpan.FromSeconds(10)); + back.Probe.Timeout.ShouldBe(TimeSpan.FromSeconds(3)); + back.ProbeTimeoutSeconds.ShouldBe(20); + back.Devices.ShouldBeEmpty(); + back.Tags.ShouldBeEmpty(); + } + + [Fact] + public void Deserialize_DropsUnknownFields() + { + var jsonWithExtra = """{"unknownField":"old-value","probeTimeoutSeconds":25}"""; + var optsSkip = new JsonSerializerOptions(_opts) + { + UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip, + }; + var back = JsonSerializer.Deserialize(jsonWithExtra, optsSkip); + back.ShouldNotBeNull(); + back.ProbeTimeoutSeconds.ShouldBe(25); + } + + [Fact] + public void FormModel_RoundTrip_PreservesEditableFields() + { + var opts = new TwinCATDriverOptions + { + Timeout = TimeSpan.FromSeconds(3), + UseNativeNotifications = true, + EnableControllerBrowse = false, + NotificationMaxDelayMs = 100, + Probe = new TwinCATProbeOptions + { + Enabled = true, + Interval = TimeSpan.FromSeconds(6), + Timeout = TimeSpan.FromSeconds(2), + }, + ProbeTimeoutSeconds = 15, + }; + + var form = ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers + .TwinCATDriverPage.FormModel.FromOptions(opts); + var roundTripped = form.ToOptions(); + + roundTripped.Timeout.ShouldBe(TimeSpan.FromSeconds(3)); + roundTripped.UseNativeNotifications.ShouldBeTrue(); + roundTripped.EnableControllerBrowse.ShouldBeFalse(); + roundTripped.NotificationMaxDelayMs.ShouldBe(100); + roundTripped.Probe.Enabled.ShouldBeTrue(); + roundTripped.Probe.Interval.ShouldBe(TimeSpan.FromSeconds(6)); + roundTripped.Probe.Timeout.ShouldBe(TimeSpan.FromSeconds(2)); + roundTripped.ProbeTimeoutSeconds.ShouldBe(15); + } +}