Files
lmxopcua/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/ModbusDriverPage.razor
T
Joseph Doherty b351a81c8f fix(adminui): preserve un-edited Modbus tag fields across edit (review)
Capture the original ModbusTagDefinition as _source in ModbusTagRow and
rewrite ToDefinition() to use 'with {}', so StringByteOrder, ArrayCount,
Deadband, UnitId, and CoalesceProhibited survive a load→edit→save cycle.
2026-05-29 09:18:36 -04:00

671 lines
34 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
@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<OtOpcUaConfigDbContext> DbFactory
@inject NavigationManager Nav
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">@(IsNew ? "New Modbus/TCP driver" : "Edit Modbus/TCP driver") &middot; <span class="mono">@ClusterId</span></h4>
<a href="/clusters/@ClusterId/drivers" class="btn btn-outline-secondary btn-sm">Cancel</a>
</div>
<ClusterNav ClusterId="@ClusterId" ActiveTab="drivers" />
@if (!_loaded)
{
<p>Loading&hellip;</p>
}
else if (!IsNew && _existing is null)
{
<section class="panel notice rise" style="animation-delay:.02s">
Driver instance <span class="mono">@DriverInstanceId</span> was not found in cluster <span class="mono">@ClusterId</span>.
</section>
}
else
{
<EditForm Model="_form" OnValidSubmit="SubmitAsync" FormName="modbustcpDriverEdit">
<DataAnnotationsValidator />
<DriverFormShell IsNew="IsNew" Busy="_busy" Error="_error"
CancelHref="@($"/clusters/{ClusterId}/drivers")"
OnDelete="@(IsNew ? null : (EventCallback?)EventCallback.Factory.Create(this, DeleteAsync))">
<DriverIdentitySection Model="_identityModel" Namespaces="_namespaces" IsNew="IsNew" ShowDriverType="false" />
@if (!IsNew && !string.IsNullOrEmpty(DriverInstanceId))
{
<DriverStatusPanel DriverInstanceId="@DriverInstanceId" Enabled="@_identityModel.Enabled" />
}
<div class="mt-2 mb-3">
<DriverTestConnectButton DriverType="@DriverTypeKey"
GetConfigJson="@SerializeCurrentConfig"
TimeoutSeconds="@_form.AdminProbeTimeoutSeconds" />
<button type="button" class="btn btn-sm btn-outline-secondary mt-2"
@onclick="@(() => _showPicker = true)">
Pick address
</button>
</div>
<DriverTagPicker @bind-Visible="_showPicker"
Title="Modbus address"
CurrentAddress="@_pickedAddress"
OnPickAddress="@OnAddressPicked">
<ModbusAddressPickerBody CurrentAddress="@_pickedAddress"
CurrentAddressChanged="@((s) => _pickedAddress = s)" />
</DriverTagPicker>
@* Transport *@
<section class="panel rise mt-3" style="animation-delay:.06s">
<div class="panel-head">Transport</div>
<div style="padding:1rem">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Host</label>
<InputText @bind-Value="_form.Host" class="form-control form-control-sm" placeholder="127.0.0.1" />
</div>
<div class="col-md-3">
<label class="form-label">Port</label>
<InputNumber @bind-Value="_form.Port" class="form-control form-control-sm" />
</div>
<div class="col-md-3">
<label class="form-label">Unit ID (slave ID)</label>
<InputNumber @bind-Value="_form.UnitId" class="form-control form-control-sm" />
</div>
<div class="col-md-3">
<label class="form-label">Timeout (seconds)</label>
<InputNumber @bind-Value="_form.TimeoutSeconds" class="form-control form-control-sm" />
<div class="form-text">Default 2 s.</div>
</div>
<div class="col-md-3">
<label class="form-label">PLC family</label>
<InputSelect @bind-Value="_form.Family" class="form-select form-select-sm">
@foreach (var e in Enum.GetValues<ModbusFamily>())
{
<option value="@e">@e</option>
}
</InputSelect>
</div>
<div class="col-md-3">
<label class="form-label">MELSEC sub-family</label>
<InputSelect @bind-Value="_form.MelsecSubFamily" class="form-select form-select-sm">
@foreach (var e in Enum.GetValues<MelsecFamily>())
{
<option value="@e">@e</option>
}
</InputSelect>
<div class="form-text">Only used when Family = MELSEC.</div>
</div>
<div class="col-md-3">
<div class="form-check form-switch mt-4">
<InputCheckbox @bind-Value="_form.AutoReconnect" class="form-check-input" id="autoReconnect" />
<label class="form-check-label" for="autoReconnect">Auto-reconnect</label>
</div>
</div>
</div>
</div>
</section>
@* Batch sizes *@
<section class="panel rise mt-3" style="animation-delay:.08s">
<div class="panel-head">Batch sizes</div>
<div style="padding:1rem">
<div class="row g-3">
<div class="col-md-3">
<label class="form-label">Max registers per read</label>
<InputNumber @bind-Value="_form.MaxRegistersPerRead" class="form-control form-control-sm" />
<div class="form-text">Spec max 125. Reduce for limited devices.</div>
</div>
<div class="col-md-3">
<label class="form-label">Max registers per write</label>
<InputNumber @bind-Value="_form.MaxRegistersPerWrite" class="form-control form-control-sm" />
<div class="form-text">Spec max 123.</div>
</div>
<div class="col-md-3">
<label class="form-label">Max coils per read</label>
<InputNumber @bind-Value="_form.MaxCoilsPerRead" class="form-control form-control-sm" />
<div class="form-text">Spec max 2000.</div>
</div>
<div class="col-md-3">
<label class="form-label">Max read gap (coalescing)</label>
<InputNumber @bind-Value="_form.MaxReadGap" class="form-control form-control-sm" />
<div class="form-text">0 = no coalescing. Typical 532.</div>
</div>
</div>
</div>
</section>
@* Write options *@
<section class="panel rise mt-3" style="animation-delay:.10s">
<div class="panel-head">Write options</div>
<div style="padding:1rem">
<div class="row g-3">
<div class="col-md-4">
<div class="form-check form-switch">
<InputCheckbox @bind-Value="_form.UseFC15ForSingleCoilWrites" class="form-check-input" id="useFC15" />
<label class="form-check-label" for="useFC15">Use FC15 for single coil writes</label>
</div>
</div>
<div class="col-md-4">
<div class="form-check form-switch">
<InputCheckbox @bind-Value="_form.UseFC16ForSingleRegisterWrites" class="form-check-input" id="useFC16" />
<label class="form-check-label" for="useFC16">Use FC16 for single register writes</label>
</div>
</div>
<div class="col-md-4">
<div class="form-check form-switch">
<InputCheckbox @bind-Value="_form.DisableFC23" class="form-check-input" id="disableFC23" />
<label class="form-check-label" for="disableFC23">Disable FC23 (reserved — no effect today)</label>
</div>
</div>
<div class="col-md-4">
<div class="form-check form-switch">
<InputCheckbox @bind-Value="_form.WriteOnChangeOnly" class="form-check-input" id="writeOnChangeOnly" />
<label class="form-check-label" for="writeOnChangeOnly">Write on change only</label>
</div>
</div>
</div>
</div>
</section>
@* Keep-alive *@
<section class="panel rise mt-3" style="animation-delay:.12s">
<div class="panel-head">TCP keep-alive</div>
<div style="padding:1rem">
<div class="row g-3">
<div class="col-md-3">
<div class="form-check form-switch mt-4">
<InputCheckbox @bind-Value="_form.KeepAliveEnabled" class="form-check-input" id="kaEnabled" />
<label class="form-check-label" for="kaEnabled">Enabled</label>
</div>
</div>
<div class="col-md-3">
<label class="form-label">Time (seconds)</label>
<InputNumber @bind-Value="_form.KeepAliveTimeSeconds" class="form-control form-control-sm" />
<div class="form-text">Idle time before first probe. Default 30 s.</div>
</div>
<div class="col-md-3">
<label class="form-label">Interval (seconds)</label>
<InputNumber @bind-Value="_form.KeepAliveIntervalSeconds" class="form-control form-control-sm" />
<div class="form-text">Between probes once started. Default 10 s.</div>
</div>
<div class="col-md-3">
<label class="form-label">Retry count</label>
<InputNumber @bind-Value="_form.KeepAliveRetryCount" class="form-control form-control-sm" />
<div class="form-text">Probes before declaring socket dead. Default 3.</div>
</div>
</div>
</div>
</section>
@* Reconnect backoff *@
<section class="panel rise mt-3" style="animation-delay:.14s">
<div class="panel-head">Reconnect backoff</div>
<div style="padding:1rem">
<div class="row g-3">
<div class="col-md-3">
<label class="form-label">Initial delay (seconds)</label>
<InputNumber @bind-Value="_form.ReconnectInitialDelaySeconds" class="form-control form-control-sm" />
<div class="form-text">0 = immediate retry.</div>
</div>
<div class="col-md-3">
<label class="form-label">Max delay (seconds)</label>
<InputNumber @bind-Value="_form.ReconnectMaxDelaySeconds" class="form-control form-control-sm" />
<div class="form-text">Default 30 s.</div>
</div>
<div class="col-md-3">
<label class="form-label">Backoff multiplier</label>
<InputNumber @bind-Value="_form.ReconnectBackoffMultiplier" class="form-control form-control-sm" />
<div class="form-text">Default 2.0 (doubles each step).</div>
</div>
<div class="col-md-3">
<label class="form-label">Idle disconnect (seconds, 0 = off)</label>
<InputNumber @bind-Value="_form.IdleDisconnectTimeoutSeconds" class="form-control form-control-sm" />
<div class="form-text">Proactive close after idle period. 0 = disabled.</div>
</div>
</div>
</div>
</section>
@* Probe *@
<section class="panel rise mt-3" style="animation-delay:.16s">
<div class="panel-head">Connectivity probe</div>
<div style="padding:1rem">
<div class="row g-3">
<div class="col-md-3">
<div class="form-check form-switch mt-4">
<InputCheckbox @bind-Value="_form.ProbeEnabled" class="form-check-input" id="probeEnabled" />
<label class="form-check-label" for="probeEnabled">Enabled</label>
</div>
</div>
<div class="col-md-3">
<label class="form-label">Probe interval (seconds)</label>
<InputNumber @bind-Value="_form.ProbeIntervalSeconds" class="form-control form-control-sm" />
<div class="form-text">Default 5 s.</div>
</div>
<div class="col-md-3">
<label class="form-label">Probe timeout (seconds)</label>
<InputNumber @bind-Value="_form.ProbeTimeoutSeconds" class="form-control form-control-sm" />
<div class="form-text">Default 2 s.</div>
</div>
<div class="col-md-3">
<label class="form-label">Probe register address</label>
<InputNumber @bind-Value="_form.ProbeAddress" class="form-control form-control-sm" />
<div class="form-text">Zero-based. Default 0 (FC03 at register 0).</div>
</div>
<div class="col-md-3">
<label class="form-label">Admin UI probe timeout (seconds)</label>
<InputNumber @bind-Value="_form.AdminProbeTimeoutSeconds" class="form-control form-control-sm" />
<div class="form-text">Max 60. Used by Test Connect. Default 5.</div>
</div>
<div class="col-md-3">
<label class="form-label">Auto-prohibit re-probe interval (seconds, 0 = off)</label>
<InputNumber @bind-Value="_form.AutoProhibitReprobeIntervalSeconds" class="form-control form-control-sm" />
<div class="form-text">Interval to retry auto-prohibited coalesced ranges.</div>
</div>
</div>
</div>
</section>
<CollectionEditor TRow="ModbusTagRow" Items="_tags" Title="Tags" ItemNoun="tag"
NewRow="@(() => new ModbusTagRow())" Clone="@(r => r.Clone())"
Validate="ModbusTagRow.ValidateRow">
<HeaderTemplate>
<tr><th>Name</th><th>Region</th><th>Address</th><th>Type</th><th>Writable</th><th></th></tr>
</HeaderTemplate>
<RowTemplate Context="t">
<td class="mono">@t.Name</td><td>@t.Region</td><td class="mono">@t.Address</td>
<td>@t.DataType</td><td>@(t.Writable ? "yes" : "no")</td>
</RowTemplate>
<EditTemplate Context="t">
<div class="row g-3">
<div class="col-md-6"><label class="form-label">Name</label>
<input class="form-control form-control-sm" @bind="t.Name" /></div>
<div class="col-md-3"><label class="form-label">Region</label>
<select class="form-select form-select-sm" @bind="t.Region">
@foreach (var e in Enum.GetValues<ModbusRegion>()) { <option value="@e">@e</option> }
</select></div>
<div class="col-md-3"><label class="form-label">Address</label>
<input type="number" class="form-control form-control-sm" @bind="t.Address" /></div>
<div class="col-md-3"><label class="form-label">Data type</label>
<select class="form-select form-select-sm" @bind="t.DataType">
@foreach (var e in Enum.GetValues<ModbusDataType>()) { <option value="@e">@e</option> }
</select></div>
<div class="col-md-3"><label class="form-label">Byte order</label>
<select class="form-select form-select-sm" @bind="t.ByteOrder">
@foreach (var e in Enum.GetValues<ModbusByteOrder>()) { <option value="@e">@e</option> }
</select></div>
<div class="col-md-2"><label class="form-label">Bit index</label>
<input type="number" class="form-control form-control-sm" @bind="t.BitIndex" /></div>
<div class="col-md-2"><label class="form-label">String len</label>
<input type="number" class="form-control form-control-sm" @bind="t.StringLength" /></div>
<div class="col-md-2"><div class="form-check form-switch mt-4">
<input type="checkbox" class="form-check-input" @bind="t.Writable" id="tagWritable" />
<label class="form-check-label" for="tagWritable">Writable</label></div></div>
</div>
</EditTemplate>
</CollectionEditor>
<DriverResilienceSection @bind-ResilienceConfig="_form.ResilienceConfig" />
</DriverFormShell>
</EditForm>
}
@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<Namespace> _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<ModbusTagRow> _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<ModbusDriverOptions>(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<ModbusTagRow> 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<ModbusTagDefinition> 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,
};
}
}