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 index 1aea1c31..4abf9dc2 100644 --- 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 @@ -132,42 +132,61 @@ else - @* 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"}] + @* Devices *@ + + + Host addressDevice name + + + @d.HostAddress + @(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 TwinCAT symbol paths. + @* Tags *@ + + + NameDeviceSymbol pathTypeWritable + + + @t.Name@t.DeviceHostAddress + @t.SymbolPath@t.DataType@(t.Writable ? "yes" : "no") + + +
+
+
+
+
+
+
+
+
+
+ +
- @if (_form.TagsJson is not null) - { -
@_form.TagsJson
- } - else - { -

No tags configured.

- } -
-
+ + @@ -202,6 +221,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(); @@ -232,6 +255,8 @@ else _form = FormModel.FromOptions(opts); _form.ResilienceConfig = _existing.ResilienceConfig; _form.RowVersion = _existing.RowVersion; + _devices = opts.Devices.Select(TwinCATDeviceRow.FromDefinition).ToList(); + _tags = opts.Tags.Select(TwinCATTagRow.FromDefinition).ToList(); } } _loaded = true; @@ -242,8 +267,11 @@ else _busy = true; _error = null; try { - var opts = _form.ToOptions(); - var configJson = System.Text.Json.JsonSerializer.Serialize(opts, _jsonOpts); + var configJson = System.Text.Json.JsonSerializer.Serialize( + _form.ToOptions( + _devices.Select(r => r.ToDefinition()).ToList(), + _tags.Select(r => r.ToDefinition()).ToList()), + _jsonOpts); await using var db = await DbFactory.CreateDbContextAsync(); if (IsNew) { @@ -313,7 +341,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 TwinCATDriverOptions? TryDeserialize(string json) { @@ -321,6 +353,91 @@ else catch { return null; } } + // Mutable VM for the modal editor — TwinCATDeviceOptions is an immutable record. + public sealed class TwinCATDeviceRow + { + public string HostAddress { get; set; } = ""; + 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 TwinCATDeviceOptions? _source; + + public TwinCATDeviceRow Clone() => (TwinCATDeviceRow)MemberwiseClone(); // _source is an immutable record ref — safe to share + + public static TwinCATDeviceRow FromDefinition(TwinCATDeviceOptions d) => new() + { + HostAddress = d.HostAddress, DeviceName = d.DeviceName, + _source = d, + }; + + public TwinCATDeviceOptions ToDefinition() + { + var baseDef = _source ?? new TwinCATDeviceOptions(HostAddress.Trim()); + return baseDef with + { + HostAddress = HostAddress.Trim(), + DeviceName = string.IsNullOrWhiteSpace(DeviceName) ? null : DeviceName.Trim(), + }; + } + + public static string? ValidateRow(TwinCATDeviceRow 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 — TwinCATTagDefinition is an immutable record. + public sealed class TwinCATTagRow + { + public string Name { get; set; } = ""; + public string DeviceHostAddress { get; set; } = ""; + public string SymbolPath { get; set; } = ""; + public TwinCATDataType DataType { get; set; } = TwinCATDataType.DInt; + 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 TwinCATTagDefinition? _source; + + public TwinCATTagRow Clone() => (TwinCATTagRow)MemberwiseClone(); // _source is an immutable record ref — safe to share + + public static TwinCATTagRow FromDefinition(TwinCATTagDefinition d) => new() + { + Name = d.Name, DeviceHostAddress = d.DeviceHostAddress, SymbolPath = d.SymbolPath, + DataType = d.DataType, Writable = d.Writable, + _source = d, + }; + + public TwinCATTagDefinition ToDefinition() + { + var baseDef = _source ?? new TwinCATTagDefinition(Name.Trim(), DeviceHostAddress.Trim(), SymbolPath.Trim(), DataType); + return baseDef with + { + Name = Name.Trim(), + DeviceHostAddress = DeviceHostAddress.Trim(), + SymbolPath = SymbolPath.Trim(), + DataType = DataType, + Writable = Writable, + }; + } + + public static string? ValidateRow(TwinCATTagRow 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 { // Options @@ -335,47 +452,25 @@ else 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() + public static FormModel FromOptions(TwinCATDriverOptions o) => new() { - WriteIndented = true, - PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase, + 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, }; - 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() + public TwinCATDriverOptions ToOptions( + IReadOnlyList devices, + IReadOnlyList tags) => new() { Timeout = TimeSpan.FromSeconds(TimeoutSeconds), UseNativeNotifications = UseNativeNotifications, @@ -388,8 +483,8 @@ else Timeout = TimeSpan.FromSeconds(ProbeTimeoutSeconds), }, ProbeTimeoutSeconds = AdminProbeTimeoutSeconds, - Devices = _devices, - Tags = _tags, + 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 index f9379441..67f41ca3 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/TwinCATDriverPageFormSerializationTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/TwinCATDriverPageFormSerializationTests.cs @@ -84,7 +84,7 @@ public sealed class TwinCATDriverPageFormSerializationTests var form = ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers .TwinCATDriverPage.FormModel.FromOptions(opts); - var roundTripped = form.ToOptions(); + var roundTripped = form.ToOptions([], []); roundTripped.Timeout.ShouldBe(TimeSpan.FromSeconds(3)); roundTripped.UseNativeNotifications.ShouldBeTrue(); @@ -95,4 +95,127 @@ public sealed class TwinCATDriverPageFormSerializationTests roundTripped.Probe.Timeout.ShouldBe(TimeSpan.FromSeconds(2)); roundTripped.ProbeTimeoutSeconds.ShouldBe(15); } + + [Fact] + public void DeviceRow_RoundTrip_PreservesEditableFields() + { + var def = new TwinCATDeviceOptions("192.168.0.1.1.1:851", "PLC1"); + + var row = ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers + .TwinCATDriverPage.TwinCATDeviceRow.FromDefinition(def); + var back = row.ToDefinition(); + + back.HostAddress.ShouldBe("192.168.0.1.1.1:851"); + back.DeviceName.ShouldBe("PLC1"); + } + + [Fact] + public void DeviceRow_CarriesThroughUneditedSourceFields() + { + // Edit only DeviceName; HostAddress on the source must survive the round-trip via _source. + var def = new TwinCATDeviceOptions("10.0.0.5.1.1:851", "Original"); + + var row = ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers + .TwinCATDriverPage.TwinCATDeviceRow.FromDefinition(def); + row.DeviceName = "Renamed"; + var back = row.ToDefinition(); + + back.HostAddress.ShouldBe("10.0.0.5.1.1:851"); + back.DeviceName.ShouldBe("Renamed"); + } + + [Fact] + public void DeviceRow_ValidateRow_RejectsDuplicateHostAddress() + { + var existing = ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers + .TwinCATDriverPage.TwinCATDeviceRow.FromDefinition(new TwinCATDeviceOptions("192.168.0.1.1.1:851")); + var dup = new ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers + .TwinCATDriverPage.TwinCATDeviceRow { HostAddress = "192.168.0.1.1.1:851" }; + + var all = new[] { existing, dup }; + var error = ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers + .TwinCATDriverPage.TwinCATDeviceRow.ValidateRow(dup, all, editIndex: 1); + + error.ShouldNotBeNull(); + error.ShouldContain("Duplicate"); + } + + [Fact] + public void TagRow_RoundTrip_PreservesEditableFields() + { + var def = new TwinCATTagDefinition("Speed", "192.168.0.1.1.1:851", "MAIN.rSpeed", TwinCATDataType.Real, Writable: false); + + var row = ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers + .TwinCATDriverPage.TwinCATTagRow.FromDefinition(def); + var back = row.ToDefinition(); + + back.Name.ShouldBe("Speed"); + back.DeviceHostAddress.ShouldBe("192.168.0.1.1.1:851"); + back.SymbolPath.ShouldBe("MAIN.rSpeed"); + back.DataType.ShouldBe(TwinCATDataType.Real); + back.Writable.ShouldBeFalse(); + } + + [Fact] + public void TagRow_CarriesThroughUneditedWriteIdempotent() + { + // WriteIdempotent is not exposed by the editor; it must survive a load→edit→save via _source. + var def = new TwinCATTagDefinition("Cmd", "192.168.0.1.1.1:851", "GVL.Start", TwinCATDataType.Bool, + Writable: true, WriteIdempotent: true); + + var row = ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers + .TwinCATDriverPage.TwinCATTagRow.FromDefinition(def); + row.Name = "CmdRenamed"; // touch an edited field + var back = row.ToDefinition(); + + back.Name.ShouldBe("CmdRenamed"); + back.WriteIdempotent.ShouldBeTrue(); + } + + [Fact] + public void TagRow_ValidateRow_RejectsDuplicateName() + { + var existing = ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers + .TwinCATDriverPage.TwinCATTagRow.FromDefinition( + new TwinCATTagDefinition("Speed", "192.168.0.1.1.1:851", "MAIN.rSpeed", TwinCATDataType.Real)); + var dup = new ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers + .TwinCATDriverPage.TwinCATTagRow { Name = "SPEED" }; // case-insensitive collision + + var all = new[] { existing, dup }; + var error = ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers + .TwinCATDriverPage.TwinCATTagRow.ValidateRow(dup, all, editIndex: 1); + + error.ShouldNotBeNull(); + error.ShouldContain("Duplicate"); + } + + [Fact] + public void FormModel_ToOptions_SerializesDeviceAndTagLists() + { + var form = ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers + .TwinCATDriverPage.FormModel.FromOptions(new TwinCATDriverOptions()); + + var devices = new[] { new TwinCATDeviceOptions("192.168.0.1.1.1:851", "PLC1") }; + var tags = new[] + { + new TwinCATTagDefinition("Speed", "192.168.0.1.1.1:851", "MAIN.rSpeed", TwinCATDataType.Real, + Writable: true, WriteIdempotent: true), + }; + + var opts = form.ToOptions(devices, tags); + var json = JsonSerializer.Serialize(opts, _opts); + var back = JsonSerializer.Deserialize(json, _opts); + + back.ShouldNotBeNull(); + back.Devices.Count.ShouldBe(1); + back.Devices[0].HostAddress.ShouldBe("192.168.0.1.1.1:851"); + back.Devices[0].DeviceName.ShouldBe("PLC1"); + back.Tags.Count.ShouldBe(1); + back.Tags[0].Name.ShouldBe("Speed"); + back.Tags[0].DeviceHostAddress.ShouldBe("192.168.0.1.1.1:851"); + back.Tags[0].SymbolPath.ShouldBe("MAIN.rSpeed"); + back.Tags[0].DataType.ShouldBe(TwinCATDataType.Real); + back.Tags[0].Writable.ShouldBeTrue(); + back.Tags[0].WriteIdempotent.ShouldBeTrue(); + } }