From 5990b673cccd14d415aa4de4ccfa542e5f7ffe63 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 9 Jun 2026 09:31:01 -0400 Subject: [PATCH] feat(uns): Modbus typed tag-config editor (F-uns-1 T3) --- .../TagEditors/ModbusTagConfigEditor.razor | 52 +++++++++++ .../Uns/TagEditors/ModbusTagConfigModel.cs | 62 +++++++++++++ .../Uns/TagEditors/TagConfigEditorMap.cs | 4 +- .../Uns/ModbusTagConfigModelTests.cs | 92 +++++++++++++++++++ 4 files changed, 208 insertions(+), 2 deletions(-) create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/ModbusTagConfigEditor.razor create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/ModbusTagConfigModel.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/ModbusTagConfigModelTests.cs diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/ModbusTagConfigEditor.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/ModbusTagConfigEditor.razor new file mode 100644 index 00000000..bcaa0533 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/ModbusTagConfigEditor.razor @@ -0,0 +1,52 @@ +@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors +@using ZB.MOM.WW.OtOpcUa.Driver.Modbus + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +@code { + [Parameter] public string? ConfigJson { get; set; } + [Parameter] public EventCallback ConfigJsonChanged { get; set; } + + private ModbusTagConfigModel _m = new(); + private string? _lastConfigJson; + + // Re-parse only when the incoming JSON actually changes, so an unrelated parent re-render + // (Blazor Server live-status pushes do this) can't reset the user's in-progress edits. + protected override void OnParametersSet() + { + if (ConfigJson == _lastConfigJson) { return; } + _lastConfigJson = ConfigJson; + _m = ModbusTagConfigModel.FromJson(ConfigJson); + } + + private static int ParseInt(object? v, int fallback = 0) => int.TryParse(v?.ToString(), out var i) ? i : fallback; + + // TryParse so a bad/empty change value can never throw into the Blazor circuit — it falls back. + private static TEnum ParseEnum(object? v, TEnum fallback) where TEnum : struct, Enum + => Enum.TryParse(v?.ToString(), out var r) ? r : fallback; + + private async Task Update(Action apply) + { + apply(); + await ConfigJsonChanged.InvokeAsync(_m.ToJson()); + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/ModbusTagConfigModel.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/ModbusTagConfigModel.cs new file mode 100644 index 00000000..9fb33ded --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/ModbusTagConfigModel.cs @@ -0,0 +1,62 @@ +using System.Text.Json.Nodes; +using ZB.MOM.WW.OtOpcUa.Driver.Modbus; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors; + +/// Typed working model for a Modbus tag's TagConfig JSON (the driver-specific addressing/encoding +/// fields; name/writable live on the Tag entity). Preserves unrecognised JSON keys across a load→save. +public sealed class ModbusTagConfigModel +{ + /// Register region (HoldingRegisters/InputRegisters/Coils/DiscreteInputs). + public ModbusRegion Region { get; set; } = ModbusRegion.HoldingRegisters; + + /// Starting register/coil address. + public int Address { get; set; } + + /// Wire value type the register block decodes to. + public ModbusDataType DataType { get; set; } = ModbusDataType.Int16; + + /// Word/byte ordering for multi-register values. + public ModbusByteOrder ByteOrder { get; set; } = ModbusByteOrder.BigEndian; + + /// Bit index (0-15) for BitInRegister tags. + public int BitIndex { get; set; } + + /// String length in characters for String tags. + public int StringLength { get; set; } + + private JsonObject _bag = new(); + + /// Loads a model from a TagConfig JSON string, defaulting any absent field and retaining + /// every original key (so fields this editor doesn't expose survive a load→save). + public static ModbusTagConfigModel FromJson(string? json) + { + var o = TagConfigJson.ParseOrNew(json); + return new ModbusTagConfigModel + { + Region = TagConfigJson.GetEnum(o, "region", ModbusRegion.HoldingRegisters), + Address = TagConfigJson.GetInt(o, "address"), + DataType = TagConfigJson.GetEnum(o, "dataType", ModbusDataType.Int16), + ByteOrder = TagConfigJson.GetEnum(o, "byteOrder", ModbusByteOrder.BigEndian), + BitIndex = TagConfigJson.GetInt(o, "bitIndex"), + StringLength = TagConfigJson.GetInt(o, "stringLength"), + _bag = o, + }; + } + + /// Serialises this model back to a TagConfig JSON string, writing the six exposed fields + /// (enums as their name strings) over the preserved key bag. + public string ToJson() + { + TagConfigJson.Set(_bag, "region", Region); + TagConfigJson.Set(_bag, "address", Address); + TagConfigJson.Set(_bag, "dataType", DataType); + TagConfigJson.Set(_bag, "byteOrder", ByteOrder); + TagConfigJson.Set(_bag, "bitIndex", BitIndex); + TagConfigJson.Set(_bag, "stringLength", StringLength); + return TagConfigJson.Serialize(_bag); + } + + /// Validation hook; returns an error message or null when the model is valid. + public string? Validate() => null; +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs index e754f649..6ace4b30 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs @@ -10,8 +10,8 @@ public static class TagConfigEditorMap private static readonly IReadOnlyDictionary Map = new Dictionary(StringComparer.OrdinalIgnoreCase) { - // Editors registered by later tasks, e.g.: - // ["ModbusTcp"] = typeof(Components.Shared.Uns.TagEditors.ModbusTagConfigEditor), + ["ModbusTcp"] = typeof(Components.Shared.Uns.TagEditors.ModbusTagConfigEditor), + // Further editors registered by later tasks, e.g. ["AbCip"] = typeof(...). }; /// Returns the editor component type for a driver type, or null if none is registered. diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/ModbusTagConfigModelTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/ModbusTagConfigModelTests.cs new file mode 100644 index 00000000..5fe913c3 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/ModbusTagConfigModelTests.cs @@ -0,0 +1,92 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors; +using ZB.MOM.WW.OtOpcUa.Driver.Modbus; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns; + +public sealed class ModbusTagConfigModelTests +{ + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("{}")] + public void FromJson_returns_defaults_for_empty_input(string? json) + { + var m = ModbusTagConfigModel.FromJson(json); + + m.Region.ShouldBe(ModbusRegion.HoldingRegisters); + m.Address.ShouldBe(0); + m.DataType.ShouldBe(ModbusDataType.Int16); + m.ByteOrder.ShouldBe(ModbusByteOrder.BigEndian); + m.BitIndex.ShouldBe(0); + m.StringLength.ShouldBe(0); + } + + [Fact] + public void Round_trip_preserves_all_six_fields() + { + var m = new ModbusTagConfigModel + { + Region = ModbusRegion.InputRegisters, + Address = 40001, + DataType = ModbusDataType.Float32, + ByteOrder = ModbusByteOrder.WordSwap, + BitIndex = 3, + StringLength = 16, + }; + + var json = m.ToJson(); + var m2 = ModbusTagConfigModel.FromJson(json); + + m2.Region.ShouldBe(ModbusRegion.InputRegisters); + m2.Address.ShouldBe(40001); + m2.DataType.ShouldBe(ModbusDataType.Float32); + m2.ByteOrder.ShouldBe(ModbusByteOrder.WordSwap); + m2.BitIndex.ShouldBe(3); + m2.StringLength.ShouldBe(16); + } + + [Fact] + public void ToJson_emits_camelCase_keys_with_enum_names() + { + var m = new ModbusTagConfigModel + { + Region = ModbusRegion.HoldingRegisters, + Address = 100, + DataType = ModbusDataType.Int16, + ByteOrder = ModbusByteOrder.BigEndian, + BitIndex = 0, + StringLength = 0, + }; + + var json = m.ToJson(); + + json.ShouldContain("\"region\":\"HoldingRegisters\""); + json.ShouldContain("\"dataType\":\"Int16\""); + json.ShouldContain("\"byteOrder\":\"BigEndian\""); + json.ShouldContain("\"address\":100"); + json.ShouldContain("\"bitIndex\":0"); + json.ShouldContain("\"stringLength\":0"); + } + + [Fact] + public void FromJson_then_ToJson_preserves_unknown_keys() + { + var json = ModbusTagConfigModel + .FromJson("""{"region":"InputRegisters","addressString":"40001:F"}""") + .ToJson(); + + json.ShouldContain("addressString"); + json.ShouldContain("40001:F"); + // and the exposed field still round-trips + json.ShouldContain("\"region\":\"InputRegisters\""); + } + + [Fact] + public void Validate_returns_null_for_default_model() + { + new ModbusTagConfigModel().Validate().ShouldBeNull(); + } +}