diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/DriversTab.razor b/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/DriversTab.razor index 3bb2631..ba30127 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/DriversTab.razor +++ b/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/DriversTab.razor @@ -1,3 +1,5 @@ +@using System.Text.Json +@using ZB.MOM.WW.OtOpcUa.Admin.Components.Pages.Modbus @using ZB.MOM.WW.OtOpcUa.Admin.Services @using ZB.MOM.WW.OtOpcUa.Configuration.Entities @inject DriverInstanceService DriverSvc @@ -50,13 +52,14 @@ else +
Type string must match the driver's registered factory name; this dropdown wraps the canonical names.
@@ -65,9 +68,19 @@ else
- - -
Phase 1: generic JSON editor — per-driver schema validation arrives in each driver's phase (decision #94).
+ @if (string.Equals(_type, "Modbus", StringComparison.OrdinalIgnoreCase)) + { + @* #147 — typed editor for Modbus drivers. The generic textarea is a fall-back + for driver types that haven't yet shipped a typed editor. *@ + + + } + else + { + + +
Phase 1: generic JSON editor — per-driver schema validation arrives in each driver's phase (decision #94).
+ }
@if (_error is not null) {
@_error
} @@ -87,11 +100,17 @@ else private List? _namespaces; private bool _showForm; private string _name = string.Empty; - private string _type = "ModbusTcp"; + private string _type = "Modbus"; private string _nsId = string.Empty; private string _config = "{}"; private string? _error; + // #147 — typed editor model for Modbus drivers. Defaults match ModbusDriverOptions + // defaults so an unedited form produces config equivalent to the historical + // pre-typed-editor wire output. Serialised to _config on Save when type=Modbus. + private ModbusOptionsEditor.ModbusOptionsViewModel _modbusOptions = new(); + private static readonly JsonSerializerOptions ModbusJsonOptions = new() { WriteIndented = true }; + protected override async Task OnParametersSetAsync() => await ReloadAsync(); private async Task ReloadAsync() @@ -111,11 +130,57 @@ else } try { - await DriverSvc.AddAsync(GenerationId, ClusterId, _nsId, _name, _type, _config, CancellationToken.None); + // #147 — for Modbus drivers serialize the typed editor model into the DriverConfig + // JSON column. Other driver types still use the raw textarea contents until each + // ships its own typed editor (decision #94 — per-driver schema validation arrives + // per driver phase). + var configJson = string.Equals(_type, "Modbus", StringComparison.OrdinalIgnoreCase) + ? SerializeModbusOptions(_modbusOptions) + : _config; + + await DriverSvc.AddAsync(GenerationId, ClusterId, _nsId, _name, _type, configJson, CancellationToken.None); _name = string.Empty; _config = "{}"; + _modbusOptions = new(); _showForm = false; await ReloadAsync(); } catch (Exception ex) { _error = ex.Message; } } + + /// + /// Maps the view-model field names onto the JSON shape ModbusDriverFactoryExtensions + /// consumes. Hand-rolled because the DTO uses millisecond / byte field flavours that the + /// view model exposes as TimeSpan-derived integers; a System.Text.Json round-trip would + /// emit the .NET-native names instead. + /// + private static string SerializeModbusOptions(ModbusOptionsEditor.ModbusOptionsViewModel m) => + JsonSerializer.Serialize(new + { + host = m.Host, + port = m.Port, + unitId = m.UnitId, + family = m.Family.ToString(), + melsecSubFamily = m.MelsecSubFamily.ToString(), + keepAlive = new + { + enabled = m.KeepAliveEnabled, + timeMs = m.KeepAliveTimeSec * 1000, + intervalMs = m.KeepAliveIntervalSec * 1000, + retryCount = m.KeepAliveRetryCount, + }, + reconnect = new + { + initialDelayMs = m.ReconnectInitialDelayMs, + maxDelayMs = m.ReconnectMaxDelayMs, + backoffMultiplier = m.ReconnectBackoffMultiplier, + }, + maxRegistersPerRead = m.MaxRegistersPerRead, + maxRegistersPerWrite = m.MaxRegistersPerWrite, + maxCoilsPerRead = m.MaxCoilsPerRead, + maxReadGap = m.MaxReadGap, + useFC15ForSingleCoilWrites = m.UseFC15ForSingleCoilWrites, + useFC16ForSingleRegisterWrites = m.UseFC16ForSingleRegisterWrites, + writeOnChangeOnly = m.WriteOnChangeOnly, + tags = Array.Empty(), + }, ModbusJsonOptions); } diff --git a/tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/ModbusOptionsViewModelTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/ModbusOptionsViewModelTests.cs index 1079b61..59d3b92 100644 --- a/tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/ModbusOptionsViewModelTests.cs +++ b/tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/ModbusOptionsViewModelTests.cs @@ -14,6 +14,96 @@ namespace ZB.MOM.WW.OtOpcUa.Admin.Tests; [Trait("Category", "Unit")] public sealed class ModbusOptionsViewModelTests { + [Fact] + public void DriversTab_Serialized_Defaults_RoundTrip_Through_Factory() + { + // #147 — the form's SaveAsync serialises ModbusOptionsViewModel to the JSON DTO + // shape ModbusDriverFactoryExtensions consumes. This test pins the round-trip: + // unedited form → JSON → driver instance → options match defaults. + var vm = new ModbusOptionsEditor.ModbusOptionsViewModel(); + var json = SerializeForRoundTrip(vm); + + var driver = ModbusDriverFactoryExtensions.CreateInstance("modbus-roundtrip", json); + var opts = (ModbusDriverOptions)typeof(ModbusDriver) + .GetField("_options", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)! + .GetValue(driver)!; + + opts.Host.ShouldBe(vm.Host); + opts.Port.ShouldBe(vm.Port); + opts.UnitId.ShouldBe(vm.UnitId); + opts.Family.ShouldBe(vm.Family); + opts.MelsecSubFamily.ShouldBe(vm.MelsecSubFamily); + opts.KeepAlive.Enabled.ShouldBe(vm.KeepAliveEnabled); + opts.MaxRegistersPerRead.ShouldBe((ushort)vm.MaxRegistersPerRead); + opts.MaxCoilsPerRead.ShouldBe((ushort)vm.MaxCoilsPerRead); + opts.MaxReadGap.ShouldBe((ushort)vm.MaxReadGap); + opts.WriteOnChangeOnly.ShouldBe(vm.WriteOnChangeOnly); + } + + [Fact] + public void DriversTab_Serializes_Edited_Values_Correctly() + { + // Sanity: changing a few fields in the view model produces a JSON the factory + // accepts and the resulting driver carries the edited values. + var vm = new ModbusOptionsEditor.ModbusOptionsViewModel + { + Host = "10.5.5.5", + Port = 1502, + UnitId = 7, + Family = ModbusFamily.DL205, + MaxReadGap = 12, + WriteOnChangeOnly = true, + }; + var json = SerializeForRoundTrip(vm); + var driver = ModbusDriverFactoryExtensions.CreateInstance("modbus-edited", json); + var opts = (ModbusDriverOptions)typeof(ModbusDriver) + .GetField("_options", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)! + .GetValue(driver)!; + + opts.Host.ShouldBe("10.5.5.5"); + opts.Port.ShouldBe(1502); + opts.UnitId.ShouldBe((byte)7); + opts.Family.ShouldBe(ModbusFamily.DL205); + opts.MaxReadGap.ShouldBe((ushort)12); + opts.WriteOnChangeOnly.ShouldBeTrue(); + } + + /// + /// Mirror of DriversTab.razor's SerializeModbusOptions — kept here so the test + /// doesn't have to reach through Blazor component plumbing to invoke it. If the + /// component method signature drifts, update both. + /// + private static string SerializeForRoundTrip(ModbusOptionsEditor.ModbusOptionsViewModel m) => + System.Text.Json.JsonSerializer.Serialize(new + { + host = m.Host, + port = m.Port, + unitId = m.UnitId, + family = m.Family.ToString(), + melsecSubFamily = m.MelsecSubFamily.ToString(), + keepAlive = new + { + enabled = m.KeepAliveEnabled, + timeMs = m.KeepAliveTimeSec * 1000, + intervalMs = m.KeepAliveIntervalSec * 1000, + retryCount = m.KeepAliveRetryCount, + }, + reconnect = new + { + initialDelayMs = m.ReconnectInitialDelayMs, + maxDelayMs = m.ReconnectMaxDelayMs, + backoffMultiplier = m.ReconnectBackoffMultiplier, + }, + maxRegistersPerRead = m.MaxRegistersPerRead, + maxRegistersPerWrite = m.MaxRegistersPerWrite, + maxCoilsPerRead = m.MaxCoilsPerRead, + maxReadGap = m.MaxReadGap, + useFC15ForSingleCoilWrites = m.UseFC15ForSingleCoilWrites, + useFC16ForSingleRegisterWrites = m.UseFC16ForSingleRegisterWrites, + writeOnChangeOnly = m.WriteOnChangeOnly, + tags = Array.Empty(), + }); + [Fact] public void Defaults_Match_DriverOption_Defaults() {