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 new file mode 100644 index 00000000..031a28e1 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/ModbusDriverPage.razor @@ -0,0 +1,555 @@ +@page "/clusters/{ClusterId}/drivers/new/modbustcp" +@attribute [Microsoft.AspNetCore.Authorization.Authorize] +@rendermode RenderMode.InteractiveServer +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.EntityFrameworkCore +@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers +@using ZB.MOM.WW.OtOpcUa.Configuration +@using ZB.MOM.WW.OtOpcUa.Configuration.Entities +@using ZB.MOM.WW.OtOpcUa.Driver.Modbus +@inject IDbContextFactory DbFactory +@inject NavigationManager Nav + +
+

@(IsNew ? "New Modbus/TCP driver" : "Edit Modbus/TCP driver") · @ClusterId

+ Cancel +
+ + +@if (!_loaded) +{ +

Loading…

+} +else if (!IsNew && _existing is null) +{ +
+ Driver instance @DriverInstanceId was not found in cluster @ClusterId. +
+} +else +{ + + + + + + + @* Transport *@ +
+
Transport
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
Default 2 s.
+
+
+ + + @foreach (var e in Enum.GetValues()) + { + + } + +
+
+ + + @foreach (var e in Enum.GetValues()) + { + + } + +
Only used when Family = MELSEC.
+
+
+
+ + +
+
+
+
+
+ + @* Batch sizes *@ +
+
Batch sizes
+
+
+
+ + +
Spec max 125. Reduce for limited devices.
+
+
+ + +
Spec max 123.
+
+
+ + +
Spec max 2000.
+
+
+ + +
0 = no coalescing. Typical 5–32.
+
+
+
+
+ + @* Write options *@ +
+
Write options
+
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+
+ + @* Keep-alive *@ +
+
TCP keep-alive
+
+
+
+
+ + +
+
+
+ + +
Idle time before first probe. Default 30 s.
+
+
+ + +
Between probes once started. Default 10 s.
+
+
+ + +
Probes before declaring socket dead. Default 3.
+
+
+
+
+ + @* Reconnect backoff *@ +
+
Reconnect backoff
+
+
+
+ + +
0 = immediate retry.
+
+
+ + +
Default 30 s.
+
+
+ + +
Default 2.0 (doubles each step).
+
+
+ + +
Proactive close after idle period. 0 = disabled.
+
+
+
+
+ + @* Probe *@ +
+
Connectivity probe
+
+
+
+
+ + +
+
+
+ + +
Default 5 s.
+
+
+ + +
Default 2 s.
+
+
+ + +
Zero-based. Default 0 (FC03 at register 0).
+
+
+ + +
Max 60. Used by Test Connect. Default 5.
+
+
+ + +
Interval to retry auto-prohibited coalesced ranges.
+
+
+
+
+ + @* 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
+
+
+ + +
+
+} + +@code { + [Parameter] public string ClusterId { get; set; } = ""; + [Parameter] public string? DriverInstanceId { get; set; } + + private const string DriverTypeKey = "ModbusTcp"; + + private bool IsNew => string.IsNullOrEmpty(DriverInstanceId); + + private static readonly System.Text.Json.JsonSerializerOptions _jsonOpts = new() + { + PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase, + UnmappedMemberHandling = System.Text.Json.Serialization.JsonUnmappedMemberHandling.Skip, + WriteIndented = false, + }; + + private FormModel _form = new(); + private DriverIdentitySection.DriverIdentityModel _identityModel = new() { DriverType = DriverTypeKey }; + private DriverInstance? _existing; + private List _namespaces = new(); + private bool _loaded; + private bool _busy; + private string? _error; + // Held separately because Tags is a collection — rendered as read-only JSON. + private IReadOnlyList _tags = []; + private string _tagsJson = "[]"; + + protected override async Task OnInitializedAsync() + { + await using var db = await DbFactory.CreateDbContextAsync(); + _namespaces = await db.Namespaces.AsNoTracking() + .Where(n => n.ClusterId == ClusterId) + .OrderBy(n => n.NamespaceId) + .ToListAsync(); + + if (IsNew) + { + _identityModel = new() + { + DriverInstanceId = "", + Name = "", + DriverType = DriverTypeKey, + NamespaceId = _namespaces.FirstOrDefault()?.NamespaceId ?? "", + Enabled = true, + }; + _form = new FormModel(); + } + else + { + _existing = await db.DriverInstances.AsNoTracking() + .FirstOrDefaultAsync(d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId); + if (_existing is not null) + { + _identityModel = new() + { + DriverInstanceId = _existing.DriverInstanceId, + Name = _existing.Name, + DriverType = _existing.DriverType, + NamespaceId = _existing.NamespaceId, + Enabled = _existing.Enabled, + }; + var opts = TryDeserialize(_existing.DriverConfig) ?? new ModbusDriverOptions(); + _form = FormModel.FromOptions(opts); + _form.ResilienceConfig = _existing.ResilienceConfig; + _form.RowVersion = _existing.RowVersion; + _tags = opts.Tags; + } + } + _tagsJson = System.Text.Json.JsonSerializer.Serialize(_tags, _jsonOpts); + _loaded = true; + } + + private async Task SubmitAsync() + { + _busy = true; _error = null; + try + { + var configJson = System.Text.Json.JsonSerializer.Serialize(_form.ToOptions(_tags), _jsonOpts); + await using var db = await DbFactory.CreateDbContextAsync(); + if (IsNew) + { + if (await db.DriverInstances.AnyAsync(d => d.DriverInstanceId == _identityModel.DriverInstanceId)) + { + _error = $"Driver instance '{_identityModel.DriverInstanceId}' already exists."; + return; + } + db.DriverInstances.Add(new DriverInstance + { + DriverInstanceId = _identityModel.DriverInstanceId, + ClusterId = ClusterId, + NamespaceId = _identityModel.NamespaceId, + Name = _identityModel.Name, + DriverType = DriverTypeKey, + Enabled = _identityModel.Enabled, + DriverConfig = configJson, + ResilienceConfig = string.IsNullOrWhiteSpace(_form.ResilienceConfig) ? null : _form.ResilienceConfig, + }); + } + else + { + var entity = await db.DriverInstances.FirstOrDefaultAsync( + d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId); + if (entity is null) { _error = "Row no longer exists."; return; } + db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion; + entity.NamespaceId = _identityModel.NamespaceId; + entity.Name = _identityModel.Name; + entity.Enabled = _identityModel.Enabled; + entity.DriverConfig = configJson; + entity.ResilienceConfig = string.IsNullOrWhiteSpace(_form.ResilienceConfig) ? null : _form.ResilienceConfig; + } + await db.SaveChangesAsync(); + Nav.NavigateTo($"/clusters/{ClusterId}/drivers"); + } + catch (DbUpdateConcurrencyException) + { + _error = "Another user changed this driver instance while you were editing. Reload to see the latest values, then re-apply your changes."; + } + catch (Exception ex) { _error = ex.Message; } + finally { _busy = false; } + } + + private async Task DeleteAsync() + { + if (IsNew) return; + _busy = true; _error = null; + try + { + await using var db = await DbFactory.CreateDbContextAsync(); + var entity = await db.DriverInstances.FirstOrDefaultAsync( + d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId); + if (entity is null) { Nav.NavigateTo($"/clusters/{ClusterId}/drivers"); return; } + db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion; + db.DriverInstances.Remove(entity); + await db.SaveChangesAsync(); + Nav.NavigateTo($"/clusters/{ClusterId}/drivers"); + } + catch (DbUpdateConcurrencyException) + { + _error = "Another user changed this driver instance while you were viewing it. Reload before deleting."; + } + catch (Exception ex) + { + _error = $"Delete failed: {ex.Message}. (Likely because equipment/tags still reference this driver — remove them first.)"; + } + finally { _busy = false; } + } + + private static ModbusDriverOptions? TryDeserialize(string json) + { + try { return System.Text.Json.JsonSerializer.Deserialize(json, _jsonOpts); } + catch { 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 + { + // Transport + public string Host { get; set; } = "127.0.0.1"; + public int Port { get; set; } = 502; + public int UnitId { get; set; } = 1; + public int TimeoutSeconds { get; set; } = 2; + + // Family + public ModbusFamily Family { get; set; } = ModbusFamily.Generic; + public MelsecFamily MelsecSubFamily { get; set; } = MelsecFamily.Q_L_iQR; + + // Transport flags + public bool AutoReconnect { get; set; } = true; + public int IdleDisconnectTimeoutSeconds { get; set; } = 0; + + // Batch sizes (using int; clamped to ushort on ToOptions) + public int MaxRegistersPerRead { get; set; } = 125; + public int MaxRegistersPerWrite { get; set; } = 123; + public int MaxCoilsPerRead { get; set; } = 2000; + public int MaxReadGap { get; set; } = 0; + + // Write options + public bool UseFC15ForSingleCoilWrites { get; set; } = false; + public bool UseFC16ForSingleRegisterWrites { get; set; } = false; + public bool DisableFC23 { get; set; } = false; + public bool WriteOnChangeOnly { get; set; } = false; + + // Keep-alive + public bool KeepAliveEnabled { get; set; } = true; + public int KeepAliveTimeSeconds { get; set; } = 30; + public int KeepAliveIntervalSeconds { get; set; } = 10; + public int KeepAliveRetryCount { get; set; } = 3; + + // Reconnect backoff + public int ReconnectInitialDelaySeconds { get; set; } = 0; + public int ReconnectMaxDelaySeconds { get; set; } = 30; + public double ReconnectBackoffMultiplier { get; set; } = 2.0; + + // Probe + public bool ProbeEnabled { get; set; } = true; + public int ProbeIntervalSeconds { get; set; } = 5; + public int ProbeTimeoutSeconds { get; set; } = 2; + public int ProbeAddress { get; set; } = 0; + + // Auto-prohibit re-probe (0 = disabled) + public int AutoProhibitReprobeIntervalSeconds { get; set; } = 0; + + // Admin UI probe timeout + public int AdminProbeTimeoutSeconds { get; set; } = 5; + + // Persistence + public string? ResilienceConfig { get; set; } + public byte[] RowVersion { get; set; } = []; + + public static FormModel FromOptions(ModbusDriverOptions o) => new() + { + Host = o.Host, + Port = o.Port, + UnitId = o.UnitId, + TimeoutSeconds = (int)o.Timeout.TotalSeconds, + Family = o.Family, + MelsecSubFamily = o.MelsecSubFamily, + AutoReconnect = o.AutoReconnect, + IdleDisconnectTimeoutSeconds = o.IdleDisconnectTimeout.HasValue ? (int)o.IdleDisconnectTimeout.Value.TotalSeconds : 0, + MaxRegistersPerRead = o.MaxRegistersPerRead, + MaxRegistersPerWrite = o.MaxRegistersPerWrite, + MaxCoilsPerRead = o.MaxCoilsPerRead, + MaxReadGap = o.MaxReadGap, + UseFC15ForSingleCoilWrites = o.UseFC15ForSingleCoilWrites, + UseFC16ForSingleRegisterWrites = o.UseFC16ForSingleRegisterWrites, + DisableFC23 = o.DisableFC23, + WriteOnChangeOnly = o.WriteOnChangeOnly, + KeepAliveEnabled = o.KeepAlive.Enabled, + KeepAliveTimeSeconds = (int)o.KeepAlive.Time.TotalSeconds, + KeepAliveIntervalSeconds = (int)o.KeepAlive.Interval.TotalSeconds, + KeepAliveRetryCount = o.KeepAlive.RetryCount, + ReconnectInitialDelaySeconds = (int)o.Reconnect.InitialDelay.TotalSeconds, + ReconnectMaxDelaySeconds = (int)o.Reconnect.MaxDelay.TotalSeconds, + ReconnectBackoffMultiplier = o.Reconnect.BackoffMultiplier, + ProbeEnabled = o.Probe.Enabled, + ProbeIntervalSeconds = (int)o.Probe.Interval.TotalSeconds, + ProbeTimeoutSeconds = (int)o.Probe.Timeout.TotalSeconds, + ProbeAddress = o.Probe.ProbeAddress, + AutoProhibitReprobeIntervalSeconds = o.AutoProhibitReprobeInterval.HasValue + ? (int)o.AutoProhibitReprobeInterval.Value.TotalSeconds : 0, + AdminProbeTimeoutSeconds = o.ProbeTimeoutSeconds, + }; + + public ModbusDriverOptions ToOptions(IReadOnlyList tags) => new() + { + Host = Host, + Port = Port, + UnitId = (byte)Math.Clamp(UnitId, 0, 255), + Timeout = TimeSpan.FromSeconds(TimeoutSeconds), + Tags = tags, + Probe = new ModbusProbeOptions + { + Enabled = ProbeEnabled, + Interval = TimeSpan.FromSeconds(ProbeIntervalSeconds), + Timeout = TimeSpan.FromSeconds(ProbeTimeoutSeconds), + ProbeAddress = (ushort)Math.Clamp(ProbeAddress, 0, 65535), + }, + MaxRegistersPerRead = (ushort)Math.Clamp(MaxRegistersPerRead, 0, 65535), + MaxRegistersPerWrite = (ushort)Math.Clamp(MaxRegistersPerWrite, 0, 65535), + MaxCoilsPerRead = (ushort)Math.Clamp(MaxCoilsPerRead, 0, 65535), + UseFC15ForSingleCoilWrites = UseFC15ForSingleCoilWrites, + UseFC16ForSingleRegisterWrites = UseFC16ForSingleRegisterWrites, + DisableFC23 = DisableFC23, + AutoProhibitReprobeInterval = AutoProhibitReprobeIntervalSeconds > 0 + ? TimeSpan.FromSeconds(AutoProhibitReprobeIntervalSeconds) : null, + MaxReadGap = (ushort)Math.Clamp(MaxReadGap, 0, 65535), + Family = Family, + MelsecSubFamily = MelsecSubFamily, + WriteOnChangeOnly = WriteOnChangeOnly, + AutoReconnect = AutoReconnect, + KeepAlive = new ModbusKeepAliveOptions + { + Enabled = KeepAliveEnabled, + Time = TimeSpan.FromSeconds(KeepAliveTimeSeconds), + Interval = TimeSpan.FromSeconds(KeepAliveIntervalSeconds), + RetryCount = KeepAliveRetryCount, + }, + IdleDisconnectTimeout = IdleDisconnectTimeoutSeconds > 0 + ? TimeSpan.FromSeconds(IdleDisconnectTimeoutSeconds) : null, + Reconnect = new ModbusReconnectOptions + { + InitialDelay = TimeSpan.FromSeconds(ReconnectInitialDelaySeconds), + MaxDelay = TimeSpan.FromSeconds(ReconnectMaxDelaySeconds), + BackoffMultiplier = ReconnectBackoffMultiplier, + }, + ProbeTimeoutSeconds = AdminProbeTimeoutSeconds, + }; + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ModbusDriverPageFormSerializationTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ModbusDriverPageFormSerializationTests.cs new file mode 100644 index 00000000..abc2f5eb --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ModbusDriverPageFormSerializationTests.cs @@ -0,0 +1,107 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.Modbus; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests; + +public sealed class ModbusDriverPageFormSerializationTests +{ + private static readonly JsonSerializerOptions _opts = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = false, + }; + + [Fact] + public void RoundTrip_PreservesKnownFields() + { + var original = new ModbusDriverOptions + { + Host = "10.0.0.42", + Port = 5020, + UnitId = 3, + Timeout = TimeSpan.FromSeconds(5), + MaxRegistersPerRead = 64, + MaxRegistersPerWrite = 60, + MaxCoilsPerRead = 500, + MaxReadGap = 8, + UseFC15ForSingleCoilWrites = true, + UseFC16ForSingleRegisterWrites = true, + DisableFC23 = true, + WriteOnChangeOnly = true, + AutoReconnect = false, + Family = ModbusFamily.DL205, + MelsecSubFamily = MelsecFamily.F_iQF, + Probe = new ModbusProbeOptions + { + Enabled = false, + Interval = TimeSpan.FromSeconds(10), + Timeout = TimeSpan.FromSeconds(3), + ProbeAddress = 7, + }, + KeepAlive = new ModbusKeepAliveOptions + { + Enabled = false, + Time = TimeSpan.FromSeconds(60), + Interval = TimeSpan.FromSeconds(15), + RetryCount = 5, + }, + Reconnect = new ModbusReconnectOptions + { + InitialDelay = TimeSpan.FromSeconds(1), + MaxDelay = TimeSpan.FromSeconds(60), + BackoffMultiplier = 1.5, + }, + ProbeTimeoutSeconds = 10, + }; + + var json = JsonSerializer.Serialize(original, _opts); + var back = JsonSerializer.Deserialize(json, _opts); + + back.ShouldNotBeNull(); + back.Host.ShouldBe("10.0.0.42"); + back.Port.ShouldBe(5020); + back.UnitId.ShouldBe((byte)3); + back.Timeout.ShouldBe(TimeSpan.FromSeconds(5)); + back.MaxRegistersPerRead.ShouldBe((ushort)64); + back.MaxRegistersPerWrite.ShouldBe((ushort)60); + back.MaxCoilsPerRead.ShouldBe((ushort)500); + back.MaxReadGap.ShouldBe((ushort)8); + back.UseFC15ForSingleCoilWrites.ShouldBeTrue(); + back.UseFC16ForSingleRegisterWrites.ShouldBeTrue(); + back.DisableFC23.ShouldBeTrue(); + back.WriteOnChangeOnly.ShouldBeTrue(); + back.AutoReconnect.ShouldBeFalse(); + back.Family.ShouldBe(ModbusFamily.DL205); + back.MelsecSubFamily.ShouldBe(MelsecFamily.F_iQF); + back.Probe.Enabled.ShouldBeFalse(); + back.Probe.Interval.ShouldBe(TimeSpan.FromSeconds(10)); + back.Probe.Timeout.ShouldBe(TimeSpan.FromSeconds(3)); + back.Probe.ProbeAddress.ShouldBe((ushort)7); + back.KeepAlive.Enabled.ShouldBeFalse(); + back.KeepAlive.Time.ShouldBe(TimeSpan.FromSeconds(60)); + back.KeepAlive.Interval.ShouldBe(TimeSpan.FromSeconds(15)); + back.KeepAlive.RetryCount.ShouldBe(5); + back.Reconnect.InitialDelay.ShouldBe(TimeSpan.FromSeconds(1)); + back.Reconnect.MaxDelay.ShouldBe(TimeSpan.FromSeconds(60)); + back.Reconnect.BackoffMultiplier.ShouldBe(1.5); + back.ProbeTimeoutSeconds.ShouldBe(10); + } + + [Fact] + public void Deserialize_DropsUnknownFields() + { + var jsonWithExtra = """{"unknownField":"old-value","probeTimeoutSeconds":10}"""; + + var optsWithSkip = new JsonSerializerOptions(_opts) + { + UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip, + }; + + var back = JsonSerializer.Deserialize(jsonWithExtra, optsWithSkip); + back.ShouldNotBeNull(); + back.ProbeTimeoutSeconds.ShouldBe(10); + } +}