diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/ModbusDriverPage.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/ModbusDriverPage.razor index bc852c06..94e44026 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/ModbusDriverPage.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/ModbusDriverPage.razor @@ -273,16 +273,44 @@ else - @* Tags — read-only JSON view *@ -
-
Tags
-
-

- Tag list — full list-editor coming in a follow-up phase. Edit tags via the Tag editor pages or by exporting/importing the driver config JSON. -

-
@_tagsJson
-
-
+ + + NameRegionAddressTypeWritable + + + @t.Name@t.Region@t.Address + @t.DataType@(t.Writable ? "yes" : "no") + + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
@@ -318,9 +346,8 @@ else private void OnAddressPicked(string address) => _pickedAddress = address; - // Held separately because Tags is a collection — rendered as read-only JSON. - private IReadOnlyList _tags = []; - private string _tagsJson = "[]"; + // Held separately because Tags is a collection — edited via the CollectionEditor modal. + private List _tags = []; protected override async Task OnInitializedAsync() { @@ -360,10 +387,9 @@ else _form = FormModel.FromOptions(opts); _form.ResilienceConfig = _existing.ResilienceConfig; _form.RowVersion = _existing.RowVersion; - _tags = opts.Tags; + _tags = opts.Tags.Select(ModbusTagRow.FromDefinition).ToList(); } } - _tagsJson = System.Text.Json.JsonSerializer.Serialize(_tags, _jsonOpts); _loaded = true; } @@ -372,7 +398,7 @@ else _busy = true; _error = null; try { - var configJson = System.Text.Json.JsonSerializer.Serialize(_form.ToOptions(_tags), _jsonOpts); + var configJson = System.Text.Json.JsonSerializer.Serialize(_form.ToOptions(_tags.Select(r => r.ToDefinition()).ToList()), _jsonOpts); await using var db = await DbFactory.CreateDbContextAsync(); if (IsNew) { @@ -443,7 +469,7 @@ else } private string SerializeCurrentConfig() - => System.Text.Json.JsonSerializer.Serialize(_form.ToOptions(_tags), _jsonOpts); + => System.Text.Json.JsonSerializer.Serialize(_form.ToOptions(_tags.Select(r => r.ToDefinition()).ToList()), _jsonOpts); private static ModbusDriverOptions? TryDeserialize(string json) { @@ -451,6 +477,44 @@ else catch { return null; } } + // Mutable VM for the modal editor — ModbusTagDefinition is an immutable record. + public sealed class ModbusTagRow + { + public string Name { get; set; } = ""; + public ModbusRegion Region { get; set; } = ModbusRegion.HoldingRegisters; + public int Address { get; set; } + public ModbusDataType DataType { get; set; } = ModbusDataType.Int16; + public bool Writable { get; set; } = true; + public ModbusByteOrder ByteOrder { get; set; } = ModbusByteOrder.BigEndian; + public int BitIndex { get; set; } + public int StringLength { get; set; } + public bool WriteIdempotent { get; set; } + + public ModbusTagRow Clone() => (ModbusTagRow)MemberwiseClone(); + + public static ModbusTagRow FromDefinition(ModbusTagDefinition d) => new() + { + Name = d.Name, Region = d.Region, Address = d.Address, DataType = d.DataType, + Writable = d.Writable, ByteOrder = d.ByteOrder, BitIndex = d.BitIndex, + StringLength = d.StringLength, WriteIdempotent = d.WriteIdempotent, + }; + + public ModbusTagDefinition ToDefinition() => new( + Name: Name.Trim(), Region: Region, Address: (ushort)Math.Clamp(Address, 0, 65535), + DataType: DataType, Writable: Writable, ByteOrder: ByteOrder, + BitIndex: (byte)Math.Clamp(BitIndex, 0, 255), StringLength: (ushort)Math.Clamp(StringLength, 0, 65535), + WriteIdempotent: WriteIdempotent); + + public static string? ValidateRow(ModbusTagRow 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 scalars exposed as settable properties so Blazor @bind-Value works. // Collection (Tags) is kept on the component (_tags) and passed in when building the final Options. public sealed class FormModel diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ModbusDriverPageFormSerializationTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ModbusDriverPageFormSerializationTests.cs index abc2f5eb..542e6a6a 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ModbusDriverPageFormSerializationTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ModbusDriverPageFormSerializationTests.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.Modbus; namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests; @@ -14,6 +15,12 @@ public sealed class ModbusDriverPageFormSerializationTests WriteIndented = false, }; + private static readonly JsonSerializerOptions TestJsonOpts = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip, + }; + [Fact] public void RoundTrip_PreservesKnownFields() { @@ -104,4 +111,44 @@ public sealed class ModbusDriverPageFormSerializationTests back.ShouldNotBeNull(); back.ProbeTimeoutSeconds.ShouldBe(10); } + + [Fact] + public void TagRow_round_trips_through_definition() + { + var row = new ModbusDriverPage.ModbusTagRow + { + Name = "Pump1_Speed", Region = ModbusRegion.HoldingRegisters, Address = 40001, + DataType = ModbusDataType.Int16, Writable = true, + }; + var def = row.ToDefinition(); + var back = ModbusDriverPage.ModbusTagRow.FromDefinition(def); + + back.Name.ShouldBe("Pump1_Speed"); + back.Address.ShouldBe(40001); + back.DataType.ShouldBe(ModbusDataType.Int16); + back.Writable.ShouldBeTrue(); + } + + [Fact] + public void Tag_list_survives_options_serialize_round_trip() + { + var tags = new List + { + new("A", ModbusRegion.HoldingRegisters, 1, ModbusDataType.Int16), + new("B", ModbusRegion.Coils, 2, ModbusDataType.Bool), + }; + var opts = new ModbusDriverPage.FormModel().ToOptions(tags); + var json = JsonSerializer.Serialize(opts, TestJsonOpts); + var back = JsonSerializer.Deserialize(json, TestJsonOpts)!; + back.Tags.Count.ShouldBe(2); + back.Tags[0].Name.ShouldBe("A"); + } + + [Fact] + public void ValidateRow_rejects_duplicate_name() + { + var rows = new List { new() { Name = "A" } }; + ModbusDriverPage.ModbusTagRow.ValidateRow(new() { Name = "A" }, rows, null) + .ShouldNotBeNull(); + } }