diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/FocasDriverPage.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/FocasDriverPage.razor index 4b1b1d01..c933bc5b 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/FocasDriverPage.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/FocasDriverPage.razor @@ -189,43 +189,65 @@ else - @* Devices — read-only JSON view *@ -
-
Devices
-
-
- Each device represents one CNC. Device list editor (with CNC series selector) coming in a follow-up phase. - Format: [{"hostAddress":"192.168.0.10:8193","deviceName":"CNC1","series":"Thirty_i"}] + @* Devices *@ + + + Host addressCNC seriesDevice name + + + @d.HostAddress@d.Series + @(string.IsNullOrWhiteSpace(d.DeviceName) ? "—" : d.DeviceName) + + +
+
+
+
+
+
+
- @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 FOCAS address strings - (e.g. X0.0, R100, PARAM:1815/0, MACRO:500). + @* Tags *@ + + + NameDeviceAddressTypeWritable + + + @t.Name@t.DeviceHostAddress + @t.Address@t.DataType@(t.Writable ? "yes" : "no") + + +
+
+
+
+
+
+
+
+
+
+ +
- @if (_form.TagsJson is not null) - { -
@_form.TagsJson
- } - else - { -

No tags configured.

- } -
-
+ + @@ -260,6 +282,10 @@ else private void OnAddressPicked(string address) => _pickedAddress = address; + // Held separately because Devices/Tags are collections — edited via the CollectionEditor modal. + private List _devices = []; + private List _tags = []; + protected override async Task OnInitializedAsync() { await using var db = await DbFactory.CreateDbContextAsync(); @@ -290,6 +316,8 @@ else _form = FormModel.FromOptions(opts); _form.ResilienceConfig = _existing.ResilienceConfig; _form.RowVersion = _existing.RowVersion; + _devices = opts.Devices.Select(FocasDeviceRow.FromDefinition).ToList(); + _tags = opts.Tags.Select(FocasTagRow.FromDefinition).ToList(); } } _loaded = true; @@ -300,7 +328,9 @@ else _busy = true; _error = null; try { - var opts = _form.ToOptions(); + var opts = _form.ToOptions( + _devices.Select(r => r.ToDefinition()).ToList(), + _tags.Select(r => r.ToDefinition()).ToList()); var configJson = System.Text.Json.JsonSerializer.Serialize(opts, _jsonOpts); await using var db = await DbFactory.CreateDbContextAsync(); if (IsNew) @@ -371,7 +401,11 @@ else } private string SerializeCurrentConfig() - => System.Text.Json.JsonSerializer.Serialize(_form.ToOptions(), _jsonOpts); + => System.Text.Json.JsonSerializer.Serialize( + _form.ToOptions( + _devices.Select(r => r.ToDefinition()).ToList(), + _tags.Select(r => r.ToDefinition()).ToList()), + _jsonOpts); private static FocasDriverOptions? TryDeserialize(string json) { @@ -379,6 +413,93 @@ else catch { return null; } } + // Mutable VM for the modal editor — FocasDeviceOptions is an immutable record. + public sealed class FocasDeviceRow + { + public string HostAddress { get; set; } = ""; + public FocasCncSeries Series { get; set; } = FocasCncSeries.Unknown; + public string? DeviceName { get; set; } + + // Original record (null for newly-added rows). Preserves any fields the editor doesn't + // expose across a load→save. + private FocasDeviceOptions? _source; + + public FocasDeviceRow Clone() => (FocasDeviceRow)MemberwiseClone(); // _source is an immutable record ref — safe to share + + public static FocasDeviceRow FromDefinition(FocasDeviceOptions d) => new() + { + HostAddress = d.HostAddress, Series = d.Series, DeviceName = d.DeviceName, + _source = d, + }; + + public FocasDeviceOptions ToDefinition() + { + var baseDef = _source ?? new FocasDeviceOptions(HostAddress.Trim()); + return baseDef with + { + HostAddress = HostAddress.Trim(), + Series = Series, + DeviceName = string.IsNullOrWhiteSpace(DeviceName) ? null : DeviceName.Trim(), + }; + } + + public static string? ValidateRow(FocasDeviceRow row, IReadOnlyList all, int? editIndex) + { + if (string.IsNullOrWhiteSpace(row.HostAddress)) return "Host address is required."; + for (var i = 0; i < all.Count; i++) + if (i != editIndex && string.Equals(all[i].HostAddress, row.HostAddress, StringComparison.OrdinalIgnoreCase)) + return $"Duplicate device host address '{row.HostAddress}'."; + return null; + } + } + + // Mutable VM for the modal editor — FocasTagDefinition is an immutable record. + public sealed class FocasTagRow + { + public string Name { get; set; } = ""; + public string DeviceHostAddress { get; set; } = ""; + public string Address { get; set; } = ""; + public FocasDataType DataType { get; set; } = FocasDataType.Int32; + public bool Writable { get; set; } = true; + + // Original record (null for newly-added rows). Preserves fields the editor doesn't expose + // (WriteIdempotent) across a load→save. + private FocasTagDefinition? _source; + + public FocasTagRow Clone() => (FocasTagRow)MemberwiseClone(); // _source is an immutable record ref — safe to share + + public static FocasTagRow FromDefinition(FocasTagDefinition d) => new() + { + Name = d.Name, DeviceHostAddress = d.DeviceHostAddress, Address = d.Address, + DataType = d.DataType, Writable = d.Writable, + _source = d, + }; + + public FocasTagDefinition ToDefinition() + { + var baseDef = _source ?? new FocasTagDefinition(Name.Trim(), DeviceHostAddress.Trim(), Address.Trim(), DataType); + return baseDef with + { + Name = Name.Trim(), + DeviceHostAddress = DeviceHostAddress.Trim(), + Address = Address.Trim(), + DataType = DataType, + Writable = Writable, + }; + } + + public static string? ValidateRow(FocasTagRow row, IReadOnlyList all, int? editIndex) + { + if (string.IsNullOrWhiteSpace(row.Name)) return "Name is required."; + for (var i = 0; i < all.Count; i++) + if (i != editIndex && string.Equals(all[i].Name, row.Name, StringComparison.OrdinalIgnoreCase)) + return $"Duplicate tag name '{row.Name}'."; + 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 { // Connection @@ -404,52 +525,30 @@ else public int FixedTreeProgramPollIntervalSeconds { get; set; } = 1; public int FixedTreeTimerPollIntervalSeconds { get; set; } = 30; - // 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() + public static FormModel FromOptions(FocasDriverOptions o) => new() { - WriteIndented = true, - PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase, + TimeoutSeconds = (int)o.Timeout.TotalSeconds, + ProbeEnabled = o.Probe.Enabled, + ProbeIntervalSeconds = (int)o.Probe.Interval.TotalSeconds, + ProbeTimeoutSeconds = (int)o.Probe.Timeout.TotalSeconds, + AdminProbeTimeoutSeconds = o.ProbeTimeoutSeconds, + AlarmProjectionEnabled = o.AlarmProjection.Enabled, + AlarmProjectionPollIntervalSeconds = (int)o.AlarmProjection.PollInterval.TotalSeconds, + HandleRecycleEnabled = o.HandleRecycle.Enabled, + HandleRecycleIntervalMinutes = (int)o.HandleRecycle.Interval.TotalMinutes, + FixedTreeEnabled = o.FixedTree.Enabled, + FixedTreePollIntervalMs = (int)o.FixedTree.PollInterval.TotalMilliseconds, + FixedTreeProgramPollIntervalSeconds = (int)o.FixedTree.ProgramPollInterval.TotalSeconds, + FixedTreeTimerPollIntervalSeconds = (int)o.FixedTree.TimerPollInterval.TotalSeconds, }; - public static FormModel FromOptions(FocasDriverOptions o) - { - var m = new FormModel - { - TimeoutSeconds = (int)o.Timeout.TotalSeconds, - ProbeEnabled = o.Probe.Enabled, - ProbeIntervalSeconds = (int)o.Probe.Interval.TotalSeconds, - ProbeTimeoutSeconds = (int)o.Probe.Timeout.TotalSeconds, - AdminProbeTimeoutSeconds = o.ProbeTimeoutSeconds, - AlarmProjectionEnabled = o.AlarmProjection.Enabled, - AlarmProjectionPollIntervalSeconds = (int)o.AlarmProjection.PollInterval.TotalSeconds, - HandleRecycleEnabled = o.HandleRecycle.Enabled, - HandleRecycleIntervalMinutes = (int)o.HandleRecycle.Interval.TotalMinutes, - FixedTreeEnabled = o.FixedTree.Enabled, - FixedTreePollIntervalMs = (int)o.FixedTree.PollInterval.TotalMilliseconds, - FixedTreeProgramPollIntervalSeconds = (int)o.FixedTree.ProgramPollInterval.TotalSeconds, - FixedTreeTimerPollIntervalSeconds = (int)o.FixedTree.TimerPollInterval.TotalSeconds, - _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 FocasDriverOptions ToOptions() => new() + public FocasDriverOptions ToOptions( + IReadOnlyList devices, + IReadOnlyList tags) => new() { Timeout = TimeSpan.FromSeconds(TimeoutSeconds), Probe = new FocasProbeOptions @@ -476,8 +575,8 @@ else ProgramPollInterval = TimeSpan.FromSeconds(FixedTreeProgramPollIntervalSeconds), TimerPollInterval = TimeSpan.FromSeconds(FixedTreeTimerPollIntervalSeconds), }, - Devices = _devices, - Tags = _tags, + Devices = devices, + Tags = tags, }; } } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/FocasDriverPageFormSerializationTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/FocasDriverPageFormSerializationTests.cs index 847f8bbb..2f057cc6 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/FocasDriverPageFormSerializationTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/FocasDriverPageFormSerializationTests.cs @@ -2,6 +2,7 @@ using System.Text.Json; using System.Text.Json.Serialization; using Shouldly; using Xunit; +using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers; using ZB.MOM.WW.OtOpcUa.Driver.FOCAS; namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests; @@ -14,6 +15,12 @@ public sealed class FocasDriverPageFormSerializationTests WriteIndented = false, }; + private static readonly JsonSerializerOptions TestJsonOpts = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip, + }; + [Fact] public void RoundTrip_PreservesKnownFields() { @@ -116,7 +123,7 @@ public sealed class FocasDriverPageFormSerializationTests var form = ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers .FocasDriverPage.FormModel.FromOptions(opts); - var roundTripped = form.ToOptions(); + var roundTripped = form.ToOptions([], []); roundTripped.Timeout.ShouldBe(TimeSpan.FromSeconds(4)); roundTripped.Probe.Enabled.ShouldBeTrue(); @@ -132,4 +139,104 @@ public sealed class FocasDriverPageFormSerializationTests roundTripped.FixedTree.ProgramPollInterval.ShouldBe(TimeSpan.FromSeconds(5)); roundTripped.FixedTree.TimerPollInterval.ShouldBe(TimeSpan.FromSeconds(45)); } + + [Fact] + public void DeviceRow_round_trips_through_definition() + { + var row = new FocasDriverPage.FocasDeviceRow + { + HostAddress = "192.168.0.10:8193", Series = FocasCncSeries.Thirty_i, DeviceName = "CNC1", + }; + var def = row.ToDefinition(); + var back = FocasDriverPage.FocasDeviceRow.FromDefinition(def); + + back.HostAddress.ShouldBe("192.168.0.10:8193"); + back.Series.ShouldBe(FocasCncSeries.Thirty_i); + back.DeviceName.ShouldBe("CNC1"); + } + + [Fact] + public void DeviceRow_preserves_unedited_fields() + { + var original = new FocasDeviceOptions("192.168.0.10:8193", "CNC1", FocasCncSeries.Thirty_i); + var row = FocasDriverPage.FocasDeviceRow.FromDefinition(original); + row.HostAddress = "192.168.0.20:8193"; + + var back = row.ToDefinition(); + back.HostAddress.ShouldBe("192.168.0.20:8193"); + back.DeviceName.ShouldBe("CNC1"); + back.Series.ShouldBe(FocasCncSeries.Thirty_i); + } + + [Fact] + public void TagRow_round_trips_through_definition() + { + var row = new FocasDriverPage.FocasTagRow + { + Name = "MacroVar", DeviceHostAddress = "192.168.0.10:8193", Address = "MACRO:500", + DataType = FocasDataType.Float64, Writable = true, + }; + var def = row.ToDefinition(); + var back = FocasDriverPage.FocasTagRow.FromDefinition(def); + + back.Name.ShouldBe("MacroVar"); + back.DeviceHostAddress.ShouldBe("192.168.0.10:8193"); + back.Address.ShouldBe("MACRO:500"); + back.DataType.ShouldBe(FocasDataType.Float64); + back.Writable.ShouldBeTrue(); + } + + [Fact] + public void TagRow_preserves_unedited_fields() + { + var original = new FocasTagDefinition( + "MacroVar", "192.168.0.10:8193", "MACRO:500", FocasDataType.Float64, + Writable: true, WriteIdempotent: true); + var row = FocasDriverPage.FocasTagRow.FromDefinition(original); + row.Name = "Renamed"; + + var back = row.ToDefinition(); + back.Name.ShouldBe("Renamed"); + back.WriteIdempotent.ShouldBeTrue(); + } + + [Fact] + public void ValidateDeviceRow_rejects_duplicate_host() + { + var rows = new List { new() { HostAddress = "192.168.0.10:8193" } }; + FocasDriverPage.FocasDeviceRow.ValidateRow(new() { HostAddress = "192.168.0.10:8193" }, rows, null) + .ShouldNotBeNull(); + } + + [Fact] + public void ValidateTagRow_rejects_duplicate_name() + { + var rows = new List { new() { Name = "MacroVar" } }; + FocasDriverPage.FocasTagRow.ValidateRow(new() { Name = "MacroVar" }, rows, null) + .ShouldNotBeNull(); + } + + [Fact] + public void Device_and_tag_lists_survive_options_serialize_round_trip() + { + var devices = new List + { + new("192.168.0.10:8193", "CNC1", FocasCncSeries.Thirty_i), + new("192.168.0.20:8193", "CNC2", FocasCncSeries.Zero_i_F), + }; + var tags = new List + { + new("MacroVar", "192.168.0.10:8193", "MACRO:500", FocasDataType.Float64), + new("Flag", "192.168.0.20:8193", "X0.0", FocasDataType.Bit), + }; + var opts = new FocasDriverPage.FormModel().ToOptions(devices, tags); + var json = JsonSerializer.Serialize(opts, TestJsonOpts); + var back = JsonSerializer.Deserialize(json, TestJsonOpts)!; + back.Devices.Count.ShouldBe(2); + back.Devices[0].HostAddress.ShouldBe("192.168.0.10:8193"); + back.Devices[0].Series.ShouldBe(FocasCncSeries.Thirty_i); + back.Tags.Count.ShouldBe(2); + back.Tags[0].Name.ShouldBe("MacroVar"); + back.Tags[0].Address.ShouldBe("MACRO:500"); + } }