@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.Clients @using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers @using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers @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 { @if (!IsNew && !string.IsNullOrEmpty(DriverInstanceId)) { }
@* 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.
NameRegionAddressTypeWritable @t.Name@t.Region@t.Address @t.DataType@(t.Writable ? "yes" : "no")
} @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; // Address picker state private bool _showPicker; private string _pickedAddress = ""; private void OnAddressPicked(string address) => _pickedAddress = address; // Held separately because Tags is a collection — edited via the CollectionEditor modal. private List _tags = []; 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.Select(ModbusTagRow.FromDefinition).ToList(); } } _loaded = true; } private async Task SubmitAsync() { _busy = true; _error = null; try { 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) { 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 string SerializeCurrentConfig() => System.Text.Json.JsonSerializer.Serialize(_form.ToOptions(_tags.Select(r => r.ToDefinition()).ToList()), _jsonOpts); private static ModbusDriverOptions? TryDeserialize(string json) { try { return System.Text.Json.JsonSerializer.Deserialize(json, _jsonOpts); } 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; } // Original record (null for newly-added rows). Preserves fields the editor doesn't expose // (StringByteOrder, ArrayCount, Deadband, UnitId, CoalesceProhibited) across a load→save. private ModbusTagDefinition? _source; public ModbusTagRow Clone() => (ModbusTagRow)MemberwiseClone(); // _source is an immutable record ref — safe to share 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, _source = d, }; public ModbusTagDefinition ToDefinition() { var baseDef = _source ?? new ModbusTagDefinition(Name.Trim(), Region, 0, DataType); return baseDef with { 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 { // 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, }; } }