feat(adminui): B2-WP3 driver/device config modals + page-to-form refactor

Extract the 8 typed driver pages into embeddable <Driver>DriverForm bodies
(channel/protocol config) and add per-driver <Driver>DeviceForm bodies
(connection endpoint -> Device.DeviceConfig, v3 endpoint split). New
DriverConfigModal + DeviceModal dispatch by DriverType (DriverTypeNames) for
the /raw tree; Test-connect inside DeviceModal builds the merged Driver+Device
config transiently via DriverDeviceConfigMerger. Routed pages now host the
form bodies and keep working. Round-trip + enum-serialization guard tests for
the Modbus driver form + Modbus device model; retargeted the existing
page-form serialization tests + the _jsonOpts converter guard to the forms.

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
This commit is contained in:
Joseph Doherty
2026-07-16 03:26:24 -04:00
parent 54ab413396
commit 844f93f64f
44 changed files with 3728 additions and 2474 deletions
@@ -5,6 +5,7 @@
@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.Forms
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers
@using ZB.MOM.WW.OtOpcUa.Configuration
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@@ -30,7 +31,7 @@ else if (!IsNew && _existing is null)
}
else
{
<EditForm Model="_form" OnValidSubmit="SubmitAsync" FormName="abcipDriverEdit">
<EditForm Model="_identityModel" OnValidSubmit="SubmitAsync" FormName="abcipDriverEdit">
<DataAnnotationsValidator />
<DriverFormShell IsNew="IsNew" Busy="_busy" Error="@_error"
CancelHref="@($"/clusters/{ClusterId}/drivers")"
@@ -44,9 +45,7 @@ else
}
<div class="mt-2 mb-3">
<DriverTestConnectButton DriverType="@DriverTypeKey"
GetConfigJson="@SerializeCurrentConfig"
TimeoutSeconds="@_form.AdminProbeTimeoutSeconds" />
<DriverTestConnectButton DriverType="@DriverTypeKey" GetConfigJson="@(() => _driverConfigJson)" />
<button type="button" class="btn btn-sm btn-outline-secondary mt-2"
@onclick="@(() => _showPicker = true)">
Pick address
@@ -59,122 +58,12 @@ else
OnPickAddress="@OnAddressPicked">
<AbCipAddressPickerBody CurrentAddress="@_pickedAddress"
CurrentAddressChanged="@((s) => _pickedAddress = s)"
GetConfigJson="@SerializeCurrentConfig" />
GetConfigJson="@(() => _driverConfigJson)" />
</DriverTagPicker>
@* Operation timeout *@
<section class="panel rise mt-3" style="animation-delay:.06s">
<div class="panel-head">Operation settings</div>
<div style="padding:1rem">
<div class="row g-3">
<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 libplctag call timeout. Default 2 s.</div>
</div>
<div class="col-md-3">
<div class="form-check form-switch mt-4">
<InputCheckbox @bind-Value="_form.EnableControllerBrowse" class="form-check-input" id="enableBrowse" />
<label class="form-check-label" for="enableBrowse">Enable controller browse</label>
</div>
<div class="form-text">Walk the Logix symbol table and surface globals under Discovered/.</div>
</div>
<div class="col-md-3">
<div class="form-check form-switch mt-4">
<InputCheckbox @bind-Value="_form.EnableDeclarationOnlyUdtGrouping" class="form-check-input" id="enableUdtGroup" />
<label class="form-check-label" for="enableUdtGroup">Enable declaration-only UDT grouping</label>
</div>
<div class="form-text">Only enable when UDT member declaration order matches controller compiled layout.</div>
</div>
</div>
</div>
</section>
<AbCipDriverForm @bind-DriverConfigJson="_driverConfigJson"
@bind-ResilienceConfig="_resilienceConfig" />
@* Alarm projection *@
<section class="panel rise mt-3" style="animation-delay:.08s">
<div class="panel-head">Alarm projection</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.EnableAlarmProjection" class="form-check-input" id="enableAlarm" />
<label class="form-check-label" for="enableAlarm">Enable ALMD alarm projection</label>
</div>
<div class="form-text">Surfaces ALMD tags as alarm conditions via IAlarmSource. Default off.</div>
</div>
<div class="col-md-3">
<label class="form-label">Alarm poll interval (seconds)</label>
<InputNumber @bind-Value="_form.AlarmPollIntervalSeconds" class="form-control form-control-sm" />
<div class="form-text">Default 1 s.</div>
</div>
</div>
</div>
</section>
@* Connectivity probe *@
<section class="panel rise mt-3" style="animation-delay:.10s">
<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">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">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 tag path</label>
<InputText @bind-Value="_form.ProbeTagPath" class="form-control form-control-sm mono"
placeholder="e.g. Program:Main.SomeTag" />
<div class="form-text">Required when probe is enabled. Leave blank and an operator warning is logged.</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>
</div>
</section>
@* Devices *@
<CollectionEditor TRow="AbCipDeviceRow" Items="_devices" Title="Devices" ItemNoun="device"
AnimationDelay=".12s"
NewRow="@(() => new AbCipDeviceRow())" Clone="@(r => r.Clone())"
Validate="AbCipDeviceRow.ValidateRow">
<HeaderTemplate>
<tr><th>Host address</th><th>PLC family</th><th>Device name</th><th></th></tr>
</HeaderTemplate>
<RowTemplate Context="d">
<td class="mono">@d.HostAddress</td><td>@d.PlcFamily</td>
<td>@(string.IsNullOrWhiteSpace(d.DeviceName) ? "—" : d.DeviceName)</td>
</RowTemplate>
<EditTemplate Context="d">
<div class="row g-3">
<div class="col-md-6"><label class="form-label">Host address</label>
<input class="form-control form-control-sm mono" @bind="d.HostAddress"
placeholder="ab://gateway/1,0" /></div>
<div class="col-md-3"><label class="form-label">PLC family</label>
<select class="form-select form-select-sm" @bind="d.PlcFamily">
@foreach (var e in Enum.GetValues<AbCipPlcFamily>()) { <option value="@e">@e</option> }
</select></div>
<div class="col-md-3"><label class="form-label">Device name</label>
<input class="form-control form-control-sm" @bind="d.DeviceName" /></div>
</div>
</EditTemplate>
</CollectionEditor>
@* Tags *@
<section class="panel notice rise mt-3" style="animation-delay:.18s">
<div class="panel-head">Tags</div>
<div style="padding:1rem" class="text-muted">
@@ -182,8 +71,6 @@ else
Pre-declared driver tags have been retired.
</div>
</section>
<DriverResilienceSection @bind-ResilienceConfig="_form.ResilienceConfig" />
</DriverFormShell>
</EditForm>
}
@@ -196,16 +83,10 @@ else
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,
Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() },
};
private FormModel _form = new();
private DriverIdentitySection.DriverIdentityModel _identityModel = new() { DriverType = DriverTypeKey };
private string _driverConfigJson = "{}";
private string? _resilienceConfig;
private byte[] _rowVersion = [];
private DriverInstance? _existing;
private bool _loaded;
private bool _busy;
@@ -217,9 +98,6 @@ else
private void OnAddressPicked(string address) => _pickedAddress = address;
// Held separately because Devices is a collection — edited via the CollectionEditor modal.
private List<AbCipDeviceRow> _devices = [];
protected override async Task OnInitializedAsync()
{
await using var db = await DbFactory.CreateDbContextAsync();
@@ -233,7 +111,7 @@ else
DriverType = DriverTypeKey,
Enabled = true,
};
_form = new FormModel();
_driverConfigJson = "{}";
}
else
{
@@ -248,11 +126,9 @@ else
DriverType = _existing.DriverType,
Enabled = _existing.Enabled,
};
var opts = TryDeserialize(_existing.DriverConfig) ?? new AbCipDriverOptions();
_form = FormModel.FromOptions(opts);
_form.ResilienceConfig = _existing.ResilienceConfig;
_form.RowVersion = _existing.RowVersion;
_devices = opts.Devices.Select(AbCipDeviceRow.FromDefinition).ToList();
_driverConfigJson = string.IsNullOrWhiteSpace(_existing.DriverConfig) ? "{}" : _existing.DriverConfig;
_resilienceConfig = _existing.ResilienceConfig;
_rowVersion = _existing.RowVersion;
}
}
_loaded = true;
@@ -263,10 +139,6 @@ else
_busy = true; _error = null;
try
{
var configJson = System.Text.Json.JsonSerializer.Serialize(
_form.ToOptions(
_devices.Select(r => r.ToDefinition()).ToList()),
_jsonOpts);
await using var db = await DbFactory.CreateDbContextAsync();
if (IsNew)
{
@@ -282,8 +154,8 @@ else
Name = _identityModel.Name,
DriverType = DriverTypeKey,
Enabled = _identityModel.Enabled,
DriverConfig = configJson,
ResilienceConfig = string.IsNullOrWhiteSpace(_form.ResilienceConfig) ? null : _form.ResilienceConfig,
DriverConfig = _driverConfigJson,
ResilienceConfig = string.IsNullOrWhiteSpace(_resilienceConfig) ? null : _resilienceConfig,
});
}
else
@@ -291,11 +163,11 @@ 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;
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _rowVersion;
entity.Name = _identityModel.Name;
entity.Enabled = _identityModel.Enabled;
entity.DriverConfig = configJson;
entity.ResilienceConfig = string.IsNullOrWhiteSpace(_form.ResilienceConfig) ? null : _form.ResilienceConfig;
entity.DriverConfig = _driverConfigJson;
entity.ResilienceConfig = string.IsNullOrWhiteSpace(_resilienceConfig) ? null : _resilienceConfig;
}
await db.SaveChangesAsync();
Nav.NavigateTo($"/clusters/{ClusterId}/drivers");
@@ -318,7 +190,7 @@ else
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.Entry(entity).Property(e => e.RowVersion).OriginalValue = _rowVersion;
db.DriverInstances.Remove(entity);
await db.SaveChangesAsync();
Nav.NavigateTo($"/clusters/{ClusterId}/drivers");
@@ -333,116 +205,4 @@ else
}
finally { _busy = false; }
}
private string SerializeCurrentConfig()
=> System.Text.Json.JsonSerializer.Serialize(
_form.ToOptions(
_devices.Select(r => r.ToDefinition()).ToList()),
_jsonOpts);
private static AbCipDriverOptions? TryDeserialize(string json)
{
try { return System.Text.Json.JsonSerializer.Deserialize<AbCipDriverOptions>(json, _jsonOpts); }
catch { return null; }
}
// Mutable VM for the modal editor — AbCipDeviceOptions is an immutable record.
public sealed class AbCipDeviceRow
{
public string HostAddress { get; set; } = "";
public AbCipPlcFamily PlcFamily { get; set; } = AbCipPlcFamily.ControlLogix;
public string? DeviceName { get; set; }
// Original record (null for newly-added rows). Preserves fields the editor doesn't expose
// (AllowPacking, ConnectionSize) across a load→save.
private AbCipDeviceOptions? _source;
public AbCipDeviceRow Clone() => (AbCipDeviceRow)MemberwiseClone(); // _source is an immutable record ref — safe to share
public static AbCipDeviceRow FromDefinition(AbCipDeviceOptions d) => new()
{
HostAddress = d.HostAddress, PlcFamily = d.PlcFamily, DeviceName = d.DeviceName,
_source = d,
};
public AbCipDeviceOptions ToDefinition()
{
var baseDef = _source ?? new AbCipDeviceOptions(HostAddress.Trim(), PlcFamily);
return baseDef with
{
HostAddress = HostAddress.Trim(),
PlcFamily = PlcFamily,
DeviceName = string.IsNullOrWhiteSpace(DeviceName) ? null : DeviceName.Trim(),
};
}
public static string? ValidateRow(AbCipDeviceRow row, IReadOnlyList<AbCipDeviceRow> all, int? editIndex)
{
if (string.IsNullOrWhiteSpace(row.HostAddress)) return "Host address is required.";
for (var i = 0; i < all.Count; i++)
if (i != editIndex && string.Equals(all[i].HostAddress, row.HostAddress, StringComparison.OrdinalIgnoreCase))
return $"Duplicate device host address '{row.HostAddress}'.";
return null;
}
}
// Flat mutable model — all scalar properties settable for Blazor @bind-Value.
// Collections (Devices) are kept on the component and passed in on ToOptions().
public sealed class FormModel
{
// Operation
public int TimeoutSeconds { get; set; } = 2;
public bool EnableControllerBrowse { get; set; } = false;
public bool EnableDeclarationOnlyUdtGrouping { get; set; } = false;
// Alarm projection
public bool EnableAlarmProjection { get; set; } = false;
public int AlarmPollIntervalSeconds { get; set; } = 1;
// Probe
public bool ProbeEnabled { get; set; } = true;
public int ProbeIntervalSeconds { get; set; } = 5;
public int ProbeTimeoutSeconds { get; set; } = 2;
public string? ProbeTagPath { get; set; }
// 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(AbCipDriverOptions o) => new()
{
TimeoutSeconds = (int)o.Timeout.TotalSeconds,
EnableControllerBrowse = o.EnableControllerBrowse,
EnableDeclarationOnlyUdtGrouping = o.EnableDeclarationOnlyUdtGrouping,
EnableAlarmProjection = o.EnableAlarmProjection,
AlarmPollIntervalSeconds = (int)o.AlarmPollInterval.TotalSeconds,
ProbeEnabled = o.Probe.Enabled,
ProbeIntervalSeconds = (int)o.Probe.Interval.TotalSeconds,
ProbeTimeoutSeconds = (int)o.Probe.Timeout.TotalSeconds,
ProbeTagPath = o.Probe.ProbeTagPath,
AdminProbeTimeoutSeconds = o.ProbeTimeoutSeconds,
};
public AbCipDriverOptions ToOptions(
IReadOnlyList<AbCipDeviceOptions> devices) => new()
{
Devices = devices,
Probe = new AbCipProbeOptions
{
Enabled = ProbeEnabled,
Interval = TimeSpan.FromSeconds(ProbeIntervalSeconds),
Timeout = TimeSpan.FromSeconds(ProbeTimeoutSeconds),
ProbeTagPath = string.IsNullOrWhiteSpace(ProbeTagPath) ? null : ProbeTagPath,
},
Timeout = TimeSpan.FromSeconds(TimeoutSeconds),
EnableControllerBrowse = EnableControllerBrowse,
EnableAlarmProjection = EnableAlarmProjection,
AlarmPollInterval = TimeSpan.FromSeconds(AlarmPollIntervalSeconds),
EnableDeclarationOnlyUdtGrouping = EnableDeclarationOnlyUdtGrouping,
ProbeTimeoutSeconds = AdminProbeTimeoutSeconds,
};
}
}
@@ -5,11 +5,10 @@
@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.Forms
@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.AbLegacy
@using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
@inject NavigationManager Nav
@@ -31,7 +30,7 @@ else if (!IsNew && _existing is null)
}
else
{
<EditForm Model="_form" OnValidSubmit="SubmitAsync" FormName="ablegacyDriverEdit">
<EditForm Model="_identityModel" OnValidSubmit="SubmitAsync" FormName="ablegacyDriverEdit">
<DataAnnotationsValidator />
<DriverFormShell IsNew="IsNew" Busy="_busy" Error="@_error"
CancelHref="@($"/clusters/{ClusterId}/drivers")"
@@ -45,9 +44,7 @@ else
}
<div class="mt-2 mb-3">
<DriverTestConnectButton DriverType="@DriverTypeKey"
GetConfigJson="@SerializeCurrentConfig"
TimeoutSeconds="@_form.AdminProbeTimeoutSeconds" />
<DriverTestConnectButton DriverType="@DriverTypeKey" GetConfigJson="@(() => _driverConfigJson)" />
<button type="button" class="btn btn-sm btn-outline-secondary mt-2"
@onclick="@(() => _showPicker = true)">
Pick address
@@ -62,84 +59,9 @@ else
CurrentAddressChanged="@((s) => _pickedAddress = s)" />
</DriverTagPicker>
@* Operation settings *@
<section class="panel rise mt-3" style="animation-delay:.06s">
<div class="panel-head">Operation settings</div>
<div style="padding:1rem">
<div class="row g-3">
<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 read/write timeout. Default 2 s.</div>
</div>
</div>
</div>
</section>
<AbLegacyDriverForm @bind-DriverConfigJson="_driverConfigJson"
@bind-ResilienceConfig="_resilienceConfig" />
@* Connectivity probe *@
<section class="panel rise mt-3" style="animation-delay:.08s">
<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">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">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 address</label>
<InputText @bind-Value="_form.ProbeAddress" class="form-control form-control-sm mono"
placeholder="S:0" />
<div class="form-text">PCCC file address to read for probe. Default S:0 (status file word 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>
</div>
</section>
@* Devices *@
<CollectionEditor TRow="AbLegacyDeviceRow" Items="_devices" Title="Devices" ItemNoun="device"
AnimationDelay=".10s"
NewRow="@(() => new AbLegacyDeviceRow())" Clone="@(r => r.Clone())"
Validate="AbLegacyDeviceRow.ValidateRow">
<HeaderTemplate>
<tr><th>Host address</th><th>PLC family</th><th>Device name</th><th></th></tr>
</HeaderTemplate>
<RowTemplate Context="d">
<td class="mono">@d.HostAddress</td><td>@d.PlcFamily</td>
<td>@(string.IsNullOrWhiteSpace(d.DeviceName) ? "—" : d.DeviceName)</td>
</RowTemplate>
<EditTemplate Context="d">
<div class="row g-3">
<div class="col-md-6"><label class="form-label">Host address</label>
<input class="form-control form-control-sm mono" @bind="d.HostAddress"
placeholder="10.0.0.10" /></div>
<div class="col-md-3"><label class="form-label">PLC family</label>
<select class="form-select form-select-sm" @bind="d.PlcFamily">
@foreach (var e in Enum.GetValues<AbLegacyPlcFamily>()) { <option value="@e">@e</option> }
</select></div>
<div class="col-md-3"><label class="form-label">Device name</label>
<input class="form-control form-control-sm" @bind="d.DeviceName" /></div>
</div>
</EditTemplate>
</CollectionEditor>
@* Tags *@
<section class="panel notice rise mt-3" style="animation-delay:.18s">
<div class="panel-head">Tags</div>
<div style="padding:1rem" class="text-muted">
@@ -147,8 +69,6 @@ else
Pre-declared driver tags have been retired.
</div>
</section>
<DriverResilienceSection @bind-ResilienceConfig="_form.ResilienceConfig" />
</DriverFormShell>
</EditForm>
}
@@ -161,16 +81,10 @@ else
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,
Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() },
};
private FormModel _form = new();
private DriverIdentitySection.DriverIdentityModel _identityModel = new() { DriverType = DriverTypeKey };
private string _driverConfigJson = "{}";
private string? _resilienceConfig;
private byte[] _rowVersion = [];
private DriverInstance? _existing;
private bool _loaded;
private bool _busy;
@@ -182,9 +96,6 @@ else
private void OnAddressPicked(string address) => _pickedAddress = address;
// Held separately because Devices/Tags are collections — edited via the CollectionEditor modal.
private List<AbLegacyDeviceRow> _devices = [];
protected override async Task OnInitializedAsync()
{
await using var db = await DbFactory.CreateDbContextAsync();
@@ -198,7 +109,7 @@ else
DriverType = DriverTypeKey,
Enabled = true,
};
_form = new FormModel();
_driverConfigJson = "{}";
}
else
{
@@ -213,11 +124,9 @@ else
DriverType = _existing.DriverType,
Enabled = _existing.Enabled,
};
var opts = TryDeserialize(_existing.DriverConfig) ?? new AbLegacyDriverOptions();
_form = FormModel.FromOptions(opts);
_form.ResilienceConfig = _existing.ResilienceConfig;
_form.RowVersion = _existing.RowVersion;
_devices = opts.Devices.Select(AbLegacyDeviceRow.FromDefinition).ToList();
_driverConfigJson = string.IsNullOrWhiteSpace(_existing.DriverConfig) ? "{}" : _existing.DriverConfig;
_resilienceConfig = _existing.ResilienceConfig;
_rowVersion = _existing.RowVersion;
}
}
_loaded = true;
@@ -228,10 +137,6 @@ else
_busy = true; _error = null;
try
{
var configJson = System.Text.Json.JsonSerializer.Serialize(
_form.ToOptions(
_devices.Select(r => r.ToDefinition()).ToList()),
_jsonOpts);
await using var db = await DbFactory.CreateDbContextAsync();
if (IsNew)
{
@@ -247,8 +152,8 @@ else
Name = _identityModel.Name,
DriverType = DriverTypeKey,
Enabled = _identityModel.Enabled,
DriverConfig = configJson,
ResilienceConfig = string.IsNullOrWhiteSpace(_form.ResilienceConfig) ? null : _form.ResilienceConfig,
DriverConfig = _driverConfigJson,
ResilienceConfig = string.IsNullOrWhiteSpace(_resilienceConfig) ? null : _resilienceConfig,
});
}
else
@@ -256,11 +161,11 @@ 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;
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _rowVersion;
entity.Name = _identityModel.Name;
entity.Enabled = _identityModel.Enabled;
entity.DriverConfig = configJson;
entity.ResilienceConfig = string.IsNullOrWhiteSpace(_form.ResilienceConfig) ? null : _form.ResilienceConfig;
entity.DriverConfig = _driverConfigJson;
entity.ResilienceConfig = string.IsNullOrWhiteSpace(_resilienceConfig) ? null : _resilienceConfig;
}
await db.SaveChangesAsync();
Nav.NavigateTo($"/clusters/{ClusterId}/drivers");
@@ -283,7 +188,7 @@ else
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.Entry(entity).Property(e => e.RowVersion).OriginalValue = _rowVersion;
db.DriverInstances.Remove(entity);
await db.SaveChangesAsync();
Nav.NavigateTo($"/clusters/{ClusterId}/drivers");
@@ -298,102 +203,4 @@ else
}
finally { _busy = false; }
}
private string SerializeCurrentConfig()
=> System.Text.Json.JsonSerializer.Serialize(
_form.ToOptions(
_devices.Select(r => r.ToDefinition()).ToList()),
_jsonOpts);
private static AbLegacyDriverOptions? TryDeserialize(string json)
{
try { return System.Text.Json.JsonSerializer.Deserialize<AbLegacyDriverOptions>(json, _jsonOpts); }
catch { return null; }
}
// Mutable VM for the modal editor — AbLegacyDeviceOptions is an immutable record.
public sealed class AbLegacyDeviceRow
{
public string HostAddress { get; set; } = "";
public AbLegacyPlcFamily PlcFamily { get; set; } = AbLegacyPlcFamily.Slc500;
public string? DeviceName { get; set; }
// Original record (null for newly-added rows). Preserves fields the editor doesn't expose
// across a load→save.
private AbLegacyDeviceOptions? _source;
public AbLegacyDeviceRow Clone() => (AbLegacyDeviceRow)MemberwiseClone(); // _source is an immutable record ref — safe to share
public static AbLegacyDeviceRow FromDefinition(AbLegacyDeviceOptions d) => new()
{
HostAddress = d.HostAddress, PlcFamily = d.PlcFamily, DeviceName = d.DeviceName,
_source = d,
};
public AbLegacyDeviceOptions ToDefinition()
{
var baseDef = _source ?? new AbLegacyDeviceOptions(HostAddress.Trim(), PlcFamily);
return baseDef with
{
HostAddress = HostAddress.Trim(),
PlcFamily = PlcFamily,
DeviceName = string.IsNullOrWhiteSpace(DeviceName) ? null : DeviceName.Trim(),
};
}
public static string? ValidateRow(AbLegacyDeviceRow row, IReadOnlyList<AbLegacyDeviceRow> all, int? editIndex)
{
if (string.IsNullOrWhiteSpace(row.HostAddress)) return "Host address is required.";
for (var i = 0; i < all.Count; i++)
if (i != editIndex && string.Equals(all[i].HostAddress, row.HostAddress, StringComparison.OrdinalIgnoreCase))
return $"Duplicate device host address '{row.HostAddress}'.";
return null;
}
}
// Flat mutable model — all scalar properties settable for Blazor @bind-Value.
// Collections (Devices, Tags) are kept on the component and passed in on ToOptions().
public sealed class FormModel
{
// Operation
public int TimeoutSeconds { get; set; } = 2;
// Probe
public bool ProbeEnabled { get; set; } = true;
public int ProbeIntervalSeconds { get; set; } = 5;
public int ProbeTimeoutSeconds { get; set; } = 2;
public string? ProbeAddress { get; set; } = "S: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(AbLegacyDriverOptions o) => new()
{
TimeoutSeconds = (int)o.Timeout.TotalSeconds,
ProbeEnabled = o.Probe.Enabled,
ProbeIntervalSeconds = (int)o.Probe.Interval.TotalSeconds,
ProbeTimeoutSeconds = (int)o.Probe.Timeout.TotalSeconds,
ProbeAddress = o.Probe.ProbeAddress,
AdminProbeTimeoutSeconds = o.ProbeTimeoutSeconds,
};
public AbLegacyDriverOptions ToOptions(
IReadOnlyList<AbLegacyDeviceOptions> devices) => new()
{
Devices = devices,
Probe = new AbLegacyProbeOptions
{
Enabled = ProbeEnabled,
Interval = TimeSpan.FromSeconds(ProbeIntervalSeconds),
Timeout = TimeSpan.FromSeconds(ProbeTimeoutSeconds),
ProbeAddress = string.IsNullOrWhiteSpace(ProbeAddress) ? null : ProbeAddress,
},
Timeout = TimeSpan.FromSeconds(TimeoutSeconds),
ProbeTimeoutSeconds = AdminProbeTimeoutSeconds,
};
}
}
@@ -5,10 +5,10 @@
@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.Forms
@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.FOCAS
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
@inject NavigationManager Nav
@@ -30,7 +30,7 @@ else if (!IsNew && _existing is null)
}
else
{
<EditForm Model="_form" OnValidSubmit="SubmitAsync" FormName="focasDriverEdit">
<EditForm Model="_identityModel" OnValidSubmit="SubmitAsync" FormName="focasDriverEdit">
<DataAnnotationsValidator />
<DriverFormShell IsNew="IsNew" Busy="_busy" Error="@_error"
CancelHref="@($"/clusters/{ClusterId}/drivers")"
@@ -45,8 +45,7 @@ else
<div class="mt-2 mb-3">
<DriverTestConnectButton DriverType="@DriverTypeKey"
GetConfigJson="@SerializeCurrentConfig"
TimeoutSeconds="@_form.AdminProbeTimeoutSeconds" />
GetConfigJson="@(() => _driverConfigJson)" />
<button type="button" class="btn btn-sm btn-outline-secondary mt-2"
@onclick="@(() => _showPicker = true)">
Pick address
@@ -59,163 +58,11 @@ else
OnPickAddress="@OnAddressPicked">
<FOCASAddressPickerBody CurrentAddress="@_pickedAddress"
CurrentAddressChanged="@((s) => _pickedAddress = s)"
GetConfigJson="@SerializeCurrentConfig" />
GetConfigJson="@(() => _driverConfigJson)" />
</DriverTagPicker>
@* Connection *@
<section class="panel rise mt-3" style="animation-delay:.05s">
<div class="panel-head">Connection</div>
<div style="padding:1rem">
<div class="row">
<div class="col-md-3 mb-3">
<label class="form-label" for="focasTimeoutSec">Timeout (seconds)</label>
<InputNumber id="focasTimeoutSec" @bind-Value="_form.TimeoutSeconds"
class="form-control form-control-sm" />
<div class="form-text">Per-operation timeout. Default 2 s.</div>
</div>
</div>
</div>
</section>
@* Probe *@
<section class="panel rise mt-3" style="animation-delay:.08s">
<div class="panel-head">Connectivity probe</div>
<div style="padding:1rem">
<div class="row">
<div class="col-md-3 mb-3">
<div class="form-check form-switch mt-2">
<InputCheckbox id="focasProbeEnabled" @bind-Value="_form.ProbeEnabled"
class="form-check-input" />
<label class="form-check-label" for="focasProbeEnabled">Probe enabled</label>
</div>
</div>
<div class="col-md-3 mb-3">
<label class="form-label" for="focasProbeInterval">Probe interval (s)</label>
<InputNumber id="focasProbeInterval" @bind-Value="_form.ProbeIntervalSeconds"
class="form-control form-control-sm" />
</div>
<div class="col-md-3 mb-3">
<label class="form-label" for="focasProbeTimeout">Probe timeout (s)</label>
<InputNumber id="focasProbeTimeout" @bind-Value="_form.ProbeTimeoutSeconds"
class="form-control form-control-sm" />
</div>
<div class="col-md-3 mb-3">
<label class="form-label" for="focasAdminProbe">Admin probe timeout (s)</label>
<InputNumber id="focasAdminProbe" @bind-Value="_form.AdminProbeTimeoutSeconds"
class="form-control form-control-sm" />
<div class="form-text">Test Connect timeout (160 s).</div>
</div>
</div>
</div>
</section>
@* Alarm projection *@
<section class="panel rise mt-3" style="animation-delay:.11s">
<div class="panel-head">Alarm projection</div>
<div style="padding:1rem">
<div class="row">
<div class="col-md-3 mb-3">
<div class="form-check form-switch mt-2">
<InputCheckbox id="focasAlarmEnabled" @bind-Value="_form.AlarmProjectionEnabled"
class="form-check-input" />
<label class="form-check-label" for="focasAlarmEnabled">Alarm projection enabled</label>
</div>
<div class="form-text">Surfaces FOCAS alarms via IAlarmSource.</div>
</div>
<div class="col-md-3 mb-3">
<label class="form-label" for="focasAlarmPoll">Alarm poll interval (s)</label>
<InputNumber id="focasAlarmPoll" @bind-Value="_form.AlarmProjectionPollIntervalSeconds"
class="form-control form-control-sm" />
<div class="form-text">One cnc_rdalmmsg2 call per device per tick. Default 2 s.</div>
</div>
</div>
</div>
</section>
@* Handle recycle *@
<section class="panel rise mt-3" style="animation-delay:.14s">
<div class="panel-head">Handle recycle</div>
<div style="padding:1rem">
<div class="row">
<div class="col-md-3 mb-3">
<div class="form-check form-switch mt-2">
<InputCheckbox id="focasRecycleEnabled" @bind-Value="_form.HandleRecycleEnabled"
class="form-check-input" />
<label class="form-check-label" for="focasRecycleEnabled">Handle recycle enabled</label>
</div>
<div class="form-text">Proactive FWLIB session recycle to prevent handle pool exhaustion. Default off.</div>
</div>
<div class="col-md-3 mb-3">
<label class="form-label" for="focasRecycleInterval">Recycle interval (minutes)</label>
<InputNumber id="focasRecycleInterval" @bind-Value="_form.HandleRecycleIntervalMinutes"
class="form-control form-control-sm" />
<div class="form-text">Typical: 30 min (shared pool) or 360 min (single client).</div>
</div>
</div>
</div>
</section>
@* Fixed tree *@
<section class="panel rise mt-3" style="animation-delay:.17s">
<div class="panel-head">Fixed-node tree</div>
<div style="padding:1rem">
<div class="row">
<div class="col-md-3 mb-3">
<div class="form-check form-switch mt-2">
<InputCheckbox id="focasFixedTree" @bind-Value="_form.FixedTreeEnabled"
class="form-check-input" />
<label class="form-check-label" for="focasFixedTree">Fixed tree enabled</label>
</div>
<div class="form-text">Exposes Identity/, Axes/, etc. from cnc_sysinfo/cnc_rdaxisname/cnc_rddynamic2. Default off.</div>
</div>
<div class="col-md-3 mb-3">
<label class="form-label" for="focasAxisPoll">Axis poll interval (ms)</label>
<InputNumber id="focasAxisPoll" @bind-Value="_form.FixedTreePollIntervalMs"
class="form-control form-control-sm" />
<div class="form-text">cnc_rddynamic2 cadence per axis. Default 250 ms.</div>
</div>
<div class="col-md-3 mb-3">
<label class="form-label" for="focasProgramPoll">Program poll interval (s)</label>
<InputNumber id="focasProgramPoll" @bind-Value="_form.FixedTreeProgramPollIntervalSeconds"
class="form-control form-control-sm" />
<div class="form-text">Program/mode info cadence. 0 = disabled. Default 1 s.</div>
</div>
<div class="col-md-3 mb-3">
<label class="form-label" for="focasTimerPoll">Timer poll interval (s)</label>
<InputNumber id="focasTimerPoll" @bind-Value="_form.FixedTreeTimerPollIntervalSeconds"
class="form-control form-control-sm" />
<div class="form-text">Power-on/cutting/cycle timer cadence. 0 = disabled. Default 30 s.</div>
</div>
</div>
</div>
</section>
@* Devices *@
<CollectionEditor TRow="FocasDeviceRow" Items="_devices" Title="Devices" ItemNoun="device"
AnimationDelay=".20s"
NewRow="@(() => new FocasDeviceRow())" Clone="@(r => r.Clone())"
Validate="FocasDeviceRow.ValidateRow">
<HeaderTemplate>
<tr><th>Host address</th><th>CNC series</th><th>Device name</th><th></th></tr>
</HeaderTemplate>
<RowTemplate Context="d">
<td class="mono">@d.HostAddress</td><td>@d.Series</td>
<td>@(string.IsNullOrWhiteSpace(d.DeviceName) ? "—" : d.DeviceName)</td>
</RowTemplate>
<EditTemplate Context="d">
<div class="row g-3">
<div class="col-md-6"><label class="form-label">Host address</label>
<input class="form-control form-control-sm mono" @bind="d.HostAddress"
placeholder="192.168.0.10:8193" /></div>
<div class="col-md-3"><label class="form-label">CNC series</label>
<select class="form-select form-select-sm" @bind="d.Series">
@foreach (var e in Enum.GetValues<FocasCncSeries>()) { <option value="@e">@e</option> }
</select></div>
<div class="col-md-3"><label class="form-label">Device name</label>
<input class="form-control form-control-sm" @bind="d.DeviceName" /></div>
</div>
</EditTemplate>
</CollectionEditor>
<FocasDriverForm @bind-DriverConfigJson="_driverConfigJson"
@bind-ResilienceConfig="_resilienceConfig" />
@* Tags *@
<section class="panel notice rise mt-3" style="animation-delay:.18s">
@@ -225,8 +72,6 @@ else
Pre-declared driver tags have been retired.
</div>
</section>
<DriverResilienceSection @bind-ResilienceConfig="_form.ResilienceConfig" />
</DriverFormShell>
</EditForm>
}
@@ -239,18 +84,13 @@ else
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,
Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() },
};
private FormModel _form = new();
private DriverIdentitySection.DriverIdentityModel _identityModel = new() { DriverType = DriverTypeKey };
private string _driverConfigJson = "{}";
private string? _resilienceConfig;
private byte[] _rowVersion = [];
private DriverInstance? _existing;
private bool _loaded, _busy;
private bool _loaded;
private bool _busy;
private string? _error;
// Address picker state
@@ -259,17 +99,20 @@ else
private void OnAddressPicked(string address) => _pickedAddress = address;
// Held separately because Devices/Tags are collections — edited via the CollectionEditor modal.
private List<FocasDeviceRow> _devices = [];
protected override async Task OnInitializedAsync()
{
await using var db = await DbFactory.CreateDbContextAsync();
if (IsNew)
{
_identityModel = new() { DriverType = DriverTypeKey, Enabled = true };
_form = FormModel.FromOptions(new FocasDriverOptions());
_identityModel = new()
{
DriverInstanceId = "",
Name = "",
DriverType = DriverTypeKey,
Enabled = true,
};
_driverConfigJson = "{}";
}
else
{
@@ -284,11 +127,9 @@ else
DriverType = _existing.DriverType,
Enabled = _existing.Enabled,
};
var opts = TryDeserialize(_existing.DriverConfig) ?? new FocasDriverOptions();
_form = FormModel.FromOptions(opts);
_form.ResilienceConfig = _existing.ResilienceConfig;
_form.RowVersion = _existing.RowVersion;
_devices = opts.Devices.Select(FocasDeviceRow.FromDefinition).ToList();
_driverConfigJson = string.IsNullOrWhiteSpace(_existing.DriverConfig) ? "{}" : _existing.DriverConfig;
_resilienceConfig = _existing.ResilienceConfig;
_rowVersion = _existing.RowVersion;
}
}
_loaded = true;
@@ -299,15 +140,13 @@ else
_busy = true; _error = null;
try
{
var opts = _form.ToOptions(
_devices.Select(r => r.ToDefinition()).ToList());
var configJson = System.Text.Json.JsonSerializer.Serialize(opts, _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;
_error = $"Driver instance '{_identityModel.DriverInstanceId}' already exists.";
return;
}
db.DriverInstances.Add(new DriverInstance
{
@@ -316,8 +155,8 @@ else
Name = _identityModel.Name,
DriverType = DriverTypeKey,
Enabled = _identityModel.Enabled,
DriverConfig = configJson,
ResilienceConfig = string.IsNullOrWhiteSpace(_form.ResilienceConfig) ? null : _form.ResilienceConfig,
DriverConfig = _driverConfigJson,
ResilienceConfig = string.IsNullOrWhiteSpace(_resilienceConfig) ? null : _resilienceConfig,
});
}
else
@@ -325,11 +164,11 @@ 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;
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _rowVersion;
entity.Name = _identityModel.Name;
entity.Enabled = _identityModel.Enabled;
entity.DriverConfig = configJson;
entity.ResilienceConfig = string.IsNullOrWhiteSpace(_form.ResilienceConfig) ? null : _form.ResilienceConfig;
entity.DriverConfig = _driverConfigJson;
entity.ResilienceConfig = string.IsNullOrWhiteSpace(_resilienceConfig) ? null : _resilienceConfig;
}
await db.SaveChangesAsync();
Nav.NavigateTo($"/clusters/{ClusterId}/drivers");
@@ -352,7 +191,7 @@ else
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.Entry(entity).Property(e => e.RowVersion).OriginalValue = _rowVersion;
db.DriverInstances.Remove(entity);
await db.SaveChangesAsync();
Nav.NavigateTo($"/clusters/{ClusterId}/drivers");
@@ -367,136 +206,4 @@ else
}
finally { _busy = false; }
}
private string SerializeCurrentConfig()
=> System.Text.Json.JsonSerializer.Serialize(
_form.ToOptions(
_devices.Select(r => r.ToDefinition()).ToList()),
_jsonOpts);
private static FocasDriverOptions? TryDeserialize(string json)
{
try { return System.Text.Json.JsonSerializer.Deserialize<FocasDriverOptions>(json, _jsonOpts); }
catch { return null; }
}
// Mutable VM for the modal editor — FocasDeviceOptions is an immutable record.
public sealed class FocasDeviceRow
{
public string HostAddress { get; set; } = "";
public FocasCncSeries Series { get; set; } = FocasCncSeries.Unknown;
public string? DeviceName { get; set; }
// Original record (null for newly-added rows). Preserves any fields the editor doesn't
// expose across a load→save.
private FocasDeviceOptions? _source;
public FocasDeviceRow Clone() => (FocasDeviceRow)MemberwiseClone(); // _source is an immutable record ref — safe to share
public static FocasDeviceRow FromDefinition(FocasDeviceOptions d) => new()
{
HostAddress = d.HostAddress, Series = d.Series, DeviceName = d.DeviceName,
_source = d,
};
public FocasDeviceOptions ToDefinition()
{
var baseDef = _source ?? new FocasDeviceOptions(HostAddress.Trim());
return baseDef with
{
HostAddress = HostAddress.Trim(),
Series = Series,
DeviceName = string.IsNullOrWhiteSpace(DeviceName) ? null : DeviceName.Trim(),
};
}
public static string? ValidateRow(FocasDeviceRow row, IReadOnlyList<FocasDeviceRow> all, int? editIndex)
{
if (string.IsNullOrWhiteSpace(row.HostAddress)) return "Host address is required.";
for (var i = 0; i < all.Count; i++)
if (i != editIndex && string.Equals(all[i].HostAddress, row.HostAddress, StringComparison.OrdinalIgnoreCase))
return $"Duplicate device host address '{row.HostAddress}'.";
return null;
}
}
// Flat mutable model — all scalar properties settable for Blazor @bind-Value.
// Collections (Devices, Tags) are kept on the component and passed in on ToOptions().
public sealed class FormModel
{
// Connection
public int TimeoutSeconds { get; set; } = 2;
// Probe
public bool ProbeEnabled { get; set; } = true;
public int ProbeIntervalSeconds { get; set; } = 5;
public int ProbeTimeoutSeconds { get; set; } = 2;
public int AdminProbeTimeoutSeconds { get; set; } = 10;
// Alarm projection
public bool AlarmProjectionEnabled { get; set; } = false;
public int AlarmProjectionPollIntervalSeconds { get; set; } = 2;
// Handle recycle
public bool HandleRecycleEnabled { get; set; } = false;
public int HandleRecycleIntervalMinutes { get; set; } = 60;
// Fixed tree
public bool FixedTreeEnabled { get; set; } = false;
public int FixedTreePollIntervalMs { get; set; } = 250;
public int FixedTreeProgramPollIntervalSeconds { get; set; } = 1;
public int FixedTreeTimerPollIntervalSeconds { get; set; } = 30;
// Common
public string? ResilienceConfig { get; set; }
public byte[] RowVersion { get; set; } = [];
public static FormModel FromOptions(FocasDriverOptions o) => new()
{
TimeoutSeconds = (int)o.Timeout.TotalSeconds,
ProbeEnabled = o.Probe.Enabled,
ProbeIntervalSeconds = (int)o.Probe.Interval.TotalSeconds,
ProbeTimeoutSeconds = (int)o.Probe.Timeout.TotalSeconds,
AdminProbeTimeoutSeconds = o.ProbeTimeoutSeconds,
AlarmProjectionEnabled = o.AlarmProjection.Enabled,
AlarmProjectionPollIntervalSeconds = (int)o.AlarmProjection.PollInterval.TotalSeconds,
HandleRecycleEnabled = o.HandleRecycle.Enabled,
HandleRecycleIntervalMinutes = (int)o.HandleRecycle.Interval.TotalMinutes,
FixedTreeEnabled = o.FixedTree.Enabled,
FixedTreePollIntervalMs = (int)o.FixedTree.PollInterval.TotalMilliseconds,
FixedTreeProgramPollIntervalSeconds = (int)o.FixedTree.ProgramPollInterval.TotalSeconds,
FixedTreeTimerPollIntervalSeconds = (int)o.FixedTree.TimerPollInterval.TotalSeconds,
};
public FocasDriverOptions ToOptions(
IReadOnlyList<FocasDeviceOptions> devices) => new()
{
Timeout = TimeSpan.FromSeconds(TimeoutSeconds),
Probe = new FocasProbeOptions
{
Enabled = ProbeEnabled,
Interval = TimeSpan.FromSeconds(ProbeIntervalSeconds),
Timeout = TimeSpan.FromSeconds(ProbeTimeoutSeconds),
},
ProbeTimeoutSeconds = AdminProbeTimeoutSeconds,
AlarmProjection = new FocasAlarmProjectionOptions
{
Enabled = AlarmProjectionEnabled,
PollInterval = TimeSpan.FromSeconds(AlarmProjectionPollIntervalSeconds),
},
HandleRecycle = new FocasHandleRecycleOptions
{
Enabled = HandleRecycleEnabled,
Interval = TimeSpan.FromMinutes(HandleRecycleIntervalMinutes),
},
FixedTree = new FocasFixedTreeOptions
{
Enabled = FixedTreeEnabled,
PollInterval = TimeSpan.FromMilliseconds(FixedTreePollIntervalMs),
ProgramPollInterval = TimeSpan.FromSeconds(FixedTreeProgramPollIntervalSeconds),
TimerPollInterval = TimeSpan.FromSeconds(FixedTreeTimerPollIntervalSeconds),
},
Devices = devices,
};
}
}
@@ -5,10 +5,10 @@
@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.Forms
@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.Galaxy.Config
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
@inject NavigationManager Nav
@@ -30,7 +30,7 @@ else if (!IsNew && _existing is null)
}
else
{
<EditForm Model="_form" OnValidSubmit="SubmitAsync" FormName="galaxyDriverEdit">
<EditForm Model="_identityModel" OnValidSubmit="SubmitAsync" FormName="galaxyDriverEdit">
<DataAnnotationsValidator />
<DriverFormShell IsNew="IsNew" Busy="_busy" Error="@_error"
CancelHref="@($"/clusters/{ClusterId}/drivers")"
@@ -44,9 +44,7 @@ else
}
<div class="mt-2 mb-3">
<DriverTestConnectButton DriverType="@DriverTypeKey"
GetConfigJson="@SerializeCurrentConfig"
TimeoutSeconds="@_form.Galaxy.ProbeTimeoutSeconds" />
<DriverTestConnectButton DriverType="@DriverTypeKey" GetConfigJson="@(() => _driverConfigJson)" />
<button type="button" class="btn btn-sm btn-outline-secondary mt-2"
@onclick="@(() => _showPicker = true)">
Pick address
@@ -59,147 +57,11 @@ else
OnPickAddress="@OnAddressPicked">
<GalaxyAddressPickerBody CurrentAddress="@_pickedAddress"
CurrentAddressChanged="@((s) => _pickedAddress = s)"
GetConfigJson="@SerializeCurrentConfig" />
GetConfigJson="@(() => _driverConfigJson)" />
</DriverTagPicker>
@* mxaccessgw connection *@
<section class="panel rise mt-3" style="animation-delay:.06s">
<div class="panel-head">mxaccessgw connection</div>
<div style="padding:1rem">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Gateway endpoint</label>
<InputText @bind-Value="_form.Galaxy.GatewayEndpoint" class="form-control form-control-sm mono"
placeholder="https://localhost:5001" />
<div class="form-text">gRPC endpoint of the mxaccessgw process.</div>
</div>
<div class="col-md-6">
<label class="form-label">API key secret ref</label>
<InputText @bind-Value="_form.Galaxy.GatewayApiKeySecretRef" class="form-control form-control-sm mono"
placeholder="env:MX_API_KEY" />
<div class="form-text">Forms: <code>env:NAME</code>, <code>file:PATH</code>, <code>dev:KEY</code>. Cleartext is accepted but triggers a startup warning.</div>
</div>
<div class="col-md-3">
<div class="form-check form-switch mt-4">
<InputCheckbox @bind-Value="_form.Galaxy.GatewayUseTls" class="form-check-input" id="gwTls" />
<label class="form-check-label" for="gwTls">Use TLS</label>
</div>
</div>
<div class="col-md-5">
<label class="form-label">CA certificate path (optional)</label>
<InputText @bind-Value="_form.Galaxy.GatewayCaCertificatePath" class="form-control form-control-sm mono"
placeholder="C:\certs\ca.pem" />
</div>
<div class="col-md-2">
<label class="form-label">Connect timeout (s)</label>
<InputNumber @bind-Value="_form.Galaxy.GatewayConnectTimeoutSeconds" class="form-control form-control-sm" />
<div class="form-text">Default 10 s.</div>
</div>
<div class="col-md-2">
<label class="form-label">Call timeout (s)</label>
<InputNumber @bind-Value="_form.Galaxy.GatewayDefaultCallTimeoutSeconds" class="form-control form-control-sm" />
<div class="form-text">Default 30 s.</div>
</div>
<div class="col-md-2">
<label class="form-label">Stream timeout (s, 0 = unlimited)</label>
<InputNumber @bind-Value="_form.Galaxy.GatewayStreamTimeoutSeconds" class="form-control form-control-sm" />
<div class="form-text">Default 0 (lifetime of driver).</div>
</div>
</div>
</div>
</section>
@* MXAccess *@
<section class="panel rise mt-3" style="animation-delay:.08s">
<div class="panel-head">MXAccess</div>
<div style="padding:1rem">
<div class="row g-3">
<div class="col-md-4">
<label class="form-label">Client name</label>
<InputText @bind-Value="_form.Galaxy.MxClientName" class="form-control form-control-sm"
placeholder="OtOpcUa-Primary" />
<div class="form-text">Must be unique per OtOpcUa instance — redundancy pairs each get a distinct name.</div>
</div>
<div class="col-md-3">
<label class="form-label">Publishing interval (ms)</label>
<InputNumber @bind-Value="_form.Galaxy.MxPublishingIntervalMs" class="form-control form-control-sm" />
<div class="form-text">Default 1000 ms.</div>
</div>
<div class="col-md-2">
<label class="form-label">Write user ID</label>
<InputNumber @bind-Value="_form.Galaxy.MxWriteUserId" class="form-control form-control-sm" />
<div class="form-text">0 = anonymous.</div>
</div>
<div class="col-md-3">
<label class="form-label">Event pump channel capacity</label>
<InputNumber @bind-Value="_form.Galaxy.MxEventPumpChannelCapacity" class="form-control form-control-sm" />
<div class="form-text">Default 50000. Raise if events.dropped appears.</div>
</div>
</div>
</div>
</section>
@* Repository *@
<section class="panel rise mt-3" style="animation-delay:.10s">
<div class="panel-head">Galaxy repository</div>
<div style="padding:1rem">
<div class="row g-3">
<div class="col-md-3">
<label class="form-label">Discover page size</label>
<InputNumber @bind-Value="_form.Galaxy.RepositoryDiscoverPageSize" class="form-control form-control-sm" />
<div class="form-text">Default 5000 objects per page.</div>
</div>
<div class="col-md-3">
<div class="form-check form-switch mt-4">
<InputCheckbox @bind-Value="_form.Galaxy.RepositoryWatchDeployEvents" class="form-check-input" id="watchDeploy" />
<label class="form-check-label" for="watchDeploy">Watch deploy events</label>
</div>
<div class="form-text mt-0">Triggers address-space rebuild on Galaxy re-deploy.</div>
</div>
</div>
</div>
</section>
@* Reconnect *@
<section class="panel rise mt-3" style="animation-delay:.12s">
<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 backoff (ms)</label>
<InputNumber @bind-Value="_form.Galaxy.ReconnectInitialBackoffMs" class="form-control form-control-sm" />
<div class="form-text">Default 500 ms.</div>
</div>
<div class="col-md-3">
<label class="form-label">Max backoff (ms)</label>
<InputNumber @bind-Value="_form.Galaxy.ReconnectMaxBackoffMs" class="form-control form-control-sm" />
<div class="form-text">Default 30000 ms.</div>
</div>
<div class="col-md-3">
<div class="form-check form-switch mt-4">
<InputCheckbox @bind-Value="_form.Galaxy.ReconnectReplayOnSessionLost" class="form-check-input" id="replayOnLost" />
<label class="form-check-label" for="replayOnLost">Replay subscriptions on session lost</label>
</div>
</div>
</div>
</div>
</section>
@* Diagnostics *@
<section class="panel rise mt-3" style="animation-delay:.14s">
<div class="panel-head">Diagnostics</div>
<div style="padding:1rem">
<div class="row g-3">
<div class="col-md-3">
<label class="form-label">Admin UI probe timeout (seconds)</label>
<InputNumber @bind-Value="_form.Galaxy.ProbeTimeoutSeconds" class="form-control form-control-sm" />
<div class="form-text">Max 60. Used by Test Connect. Default 30.</div>
</div>
</div>
</div>
</section>
<DriverResilienceSection @bind-ResilienceConfig="_form.ResilienceConfig" />
<GalaxyDriverForm @bind-DriverConfigJson="_driverConfigJson"
@bind-ResilienceConfig="_resilienceConfig" />
</DriverFormShell>
</EditForm>
}
@@ -212,17 +74,10 @@ else
private bool IsNew => string.IsNullOrEmpty(DriverInstanceId);
private static readonly System.Text.Json.JsonSerializerOptions _jsonOpts = new()
{
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
UnmappedMemberHandling = System.Text.Json.Serialization.JsonUnmappedMemberHandling.Skip,
WriteIndented = false,
Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() },
};
private FormModel _form = new();
private DriverIdentitySection.DriverIdentityModel _identityModel = new() { DriverType = DriverTypeKey };
private string _driverConfigJson = "{}";
private string? _resilienceConfig;
private byte[] _rowVersion = [];
private DriverInstance? _existing;
private bool _loaded;
private bool _busy;
@@ -247,7 +102,7 @@ else
DriverType = DriverTypeKey,
Enabled = true,
};
_form = new FormModel();
_driverConfigJson = "{}";
}
else
{
@@ -262,29 +117,19 @@ else
DriverType = _existing.DriverType,
Enabled = _existing.Enabled,
};
var opts = TryDeserialize(_existing.DriverConfig) ?? CreateDefaultOptions();
_form = new FormModel();
_form.Galaxy = GalaxyFormModel.FromRecord(opts);
_form.ResilienceConfig = _existing.ResilienceConfig;
_form.RowVersion = _existing.RowVersion;
_driverConfigJson = string.IsNullOrWhiteSpace(_existing.DriverConfig) ? "{}" : _existing.DriverConfig;
_resilienceConfig = _existing.ResilienceConfig;
_rowVersion = _existing.RowVersion;
}
}
_loaded = true;
}
private static GalaxyDriverOptions CreateDefaultOptions() => new(
Gateway: new GalaxyGatewayOptions("https://localhost:5001", "env:MX_API_KEY"),
MxAccess: new GalaxyMxAccessOptions("OtOpcUa"),
Repository: new GalaxyRepositoryOptions(),
Reconnect: new GalaxyReconnectOptions());
private async Task SubmitAsync()
{
_busy = true; _error = null;
try
{
var opts = _form.Galaxy.ToRecord();
var configJson = System.Text.Json.JsonSerializer.Serialize(opts, _jsonOpts);
await using var db = await DbFactory.CreateDbContextAsync();
if (IsNew)
{
@@ -299,8 +144,8 @@ else
Name = _identityModel.Name,
DriverType = DriverTypeKey,
Enabled = _identityModel.Enabled,
DriverConfig = configJson,
ResilienceConfig = string.IsNullOrWhiteSpace(_form.ResilienceConfig) ? null : _form.ResilienceConfig,
DriverConfig = _driverConfigJson,
ResilienceConfig = string.IsNullOrWhiteSpace(_resilienceConfig) ? null : _resilienceConfig,
});
}
else
@@ -308,11 +153,11 @@ 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;
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _rowVersion;
entity.Name = _identityModel.Name;
entity.Enabled = _identityModel.Enabled;
entity.DriverConfig = configJson;
entity.ResilienceConfig = string.IsNullOrWhiteSpace(_form.ResilienceConfig) ? null : _form.ResilienceConfig;
entity.DriverConfig = _driverConfigJson;
entity.ResilienceConfig = string.IsNullOrWhiteSpace(_resilienceConfig) ? null : _resilienceConfig;
}
await db.SaveChangesAsync();
Nav.NavigateTo($"/clusters/{ClusterId}/drivers");
@@ -335,7 +180,7 @@ else
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.Entry(entity).Property(e => e.RowVersion).OriginalValue = _rowVersion;
db.DriverInstances.Remove(entity);
await db.SaveChangesAsync();
Nav.NavigateTo($"/clusters/{ClusterId}/drivers");
@@ -350,111 +195,4 @@ else
}
finally { _busy = false; }
}
private string SerializeCurrentConfig()
=> System.Text.Json.JsonSerializer.Serialize(_form.Galaxy.ToRecord(), _jsonOpts);
private static GalaxyDriverOptions? TryDeserialize(string json)
{
try { return System.Text.Json.JsonSerializer.Deserialize<GalaxyDriverOptions>(json, _jsonOpts); }
catch { return null; }
}
public sealed class FormModel
{
public GalaxyFormModel Galaxy { get; set; } = new();
public string? ResilienceConfig { get; set; }
public byte[] RowVersion { get; set; } = [];
}
/// <summary>
/// Mutable flat mirror of <see cref="GalaxyDriverOptions"/> and its nested records.
/// All positional-record fields are flattened with a section prefix to avoid name
/// collisions. <see cref="FromRecord"/> / <see cref="ToRecord"/> handle translation.
/// </summary>
public sealed class GalaxyFormModel
{
// GalaxyGatewayOptions
public string GatewayEndpoint { get; set; } = "https://localhost:5001";
public string GatewayApiKeySecretRef { get; set; } = "env:MX_API_KEY";
public bool GatewayUseTls { get; set; } = true;
public string? GatewayCaCertificatePath { get; set; }
public int GatewayConnectTimeoutSeconds { get; set; } = 10;
public int GatewayDefaultCallTimeoutSeconds { get; set; } = 30;
public int GatewayStreamTimeoutSeconds { get; set; } = 0;
// GalaxyMxAccessOptions
public string MxClientName { get; set; } = "OtOpcUa";
public int MxPublishingIntervalMs { get; set; } = 1000;
public int MxWriteUserId { get; set; } = 0;
public int MxEventPumpChannelCapacity { get; set; } = 50_000;
// GalaxyRepositoryOptions
public int RepositoryDiscoverPageSize { get; set; } = 5000;
public bool RepositoryWatchDeployEvents { get; set; } = true;
// GalaxyReconnectOptions
public int ReconnectInitialBackoffMs { get; set; } = 500;
public int ReconnectMaxBackoffMs { get; set; } = 30_000;
public bool ReconnectReplayOnSessionLost { get; set; } = true;
// GalaxyDriverOptions top-level
public int ProbeTimeoutSeconds { get; set; } = 30;
public static GalaxyFormModel FromRecord(GalaxyDriverOptions r)
{
// Null-coalesce each nested record to its default so that persisted configs
// that pre-date a section (e.g. no Reconnect key, or PascalCase keys that
// don't match the camelCase deserializer) don't cause a NullReferenceException.
var gw = r.Gateway ?? new GalaxyGatewayOptions("https://localhost:5001", "env:MX_API_KEY");
var mx = r.MxAccess ?? new GalaxyMxAccessOptions("OtOpcUa");
var repo = r.Repository ?? new GalaxyRepositoryOptions();
var rc = r.Reconnect ?? new GalaxyReconnectOptions();
return new()
{
GatewayEndpoint = gw.Endpoint,
GatewayApiKeySecretRef = gw.ApiKeySecretRef,
GatewayUseTls = gw.UseTls,
GatewayCaCertificatePath = gw.CaCertificatePath,
GatewayConnectTimeoutSeconds = gw.ConnectTimeoutSeconds,
GatewayDefaultCallTimeoutSeconds = gw.DefaultCallTimeoutSeconds,
GatewayStreamTimeoutSeconds = gw.StreamTimeoutSeconds,
MxClientName = mx.ClientName,
MxPublishingIntervalMs = mx.PublishingIntervalMs,
MxWriteUserId = mx.WriteUserId,
MxEventPumpChannelCapacity = mx.EventPumpChannelCapacity,
RepositoryDiscoverPageSize = repo.DiscoverPageSize,
RepositoryWatchDeployEvents = repo.WatchDeployEvents,
ReconnectInitialBackoffMs = rc.InitialBackoffMs,
ReconnectMaxBackoffMs = rc.MaxBackoffMs,
ReconnectReplayOnSessionLost = rc.ReplayOnSessionLost,
ProbeTimeoutSeconds = r.ProbeTimeoutSeconds,
};
}
public GalaxyDriverOptions ToRecord() => new(
Gateway: new GalaxyGatewayOptions(
Endpoint: GatewayEndpoint,
ApiKeySecretRef: GatewayApiKeySecretRef,
UseTls: GatewayUseTls,
CaCertificatePath: string.IsNullOrWhiteSpace(GatewayCaCertificatePath) ? null : GatewayCaCertificatePath,
ConnectTimeoutSeconds: GatewayConnectTimeoutSeconds,
DefaultCallTimeoutSeconds: GatewayDefaultCallTimeoutSeconds,
StreamTimeoutSeconds: GatewayStreamTimeoutSeconds),
MxAccess: new GalaxyMxAccessOptions(
ClientName: MxClientName,
PublishingIntervalMs: MxPublishingIntervalMs,
WriteUserId: MxWriteUserId,
EventPumpChannelCapacity: MxEventPumpChannelCapacity),
Repository: new GalaxyRepositoryOptions(
DiscoverPageSize: RepositoryDiscoverPageSize,
WatchDeployEvents: RepositoryWatchDeployEvents),
Reconnect: new GalaxyReconnectOptions(
InitialBackoffMs: ReconnectInitialBackoffMs,
MaxBackoffMs: ReconnectMaxBackoffMs,
ReplayOnSessionLost: ReconnectReplayOnSessionLost))
{
ProbeTimeoutSeconds = ProbeTimeoutSeconds,
};
}
}
@@ -5,10 +5,10 @@
@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.Forms
@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
@@ -30,7 +30,7 @@ else if (!IsNew && _existing is null)
}
else
{
<EditForm Model="_form" OnValidSubmit="SubmitAsync" FormName="modbustcpDriverEdit">
<EditForm Model="_identityModel" OnValidSubmit="SubmitAsync" FormName="modbustcpDriverEdit">
<DataAnnotationsValidator />
<DriverFormShell IsNew="IsNew" Busy="_busy" Error="@_error"
CancelHref="@($"/clusters/{ClusterId}/drivers")"
@@ -44,9 +44,7 @@ else
}
<div class="mt-2 mb-3">
<DriverTestConnectButton DriverType="@DriverTypeKey"
GetConfigJson="@SerializeCurrentConfig"
TimeoutSeconds="@_form.AdminProbeTimeoutSeconds" />
<DriverTestConnectButton DriverType="@DriverTypeKey" GetConfigJson="@(() => _driverConfigJson)" />
<button type="button" class="btn btn-sm btn-outline-secondary mt-2"
@onclick="@(() => _showPicker = true)">
Pick address
@@ -61,217 +59,16 @@ else
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>
<section class="panel notice rise mt-3" style="animation-delay:.04s">
<div class="panel-head">Connection endpoint</div>
<div style="padding:1rem" class="text-muted">
Host / Port / Unit ID now live on the driver's <strong>device</strong> (v3 endpoint→DeviceConfig split).
Author them from the Raw tree (<strong>/raw</strong>) device modal.
</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>
<ModbusDriverForm @bind-DriverConfigJson="_driverConfigJson"
@bind-ResilienceConfig="_resilienceConfig" />
<section class="panel notice rise mt-3" style="animation-delay:.18s">
<div class="panel-head">Tags</div>
@@ -280,8 +77,6 @@ else
Pre-declared driver tags have been retired.
</div>
</section>
<DriverResilienceSection @bind-ResilienceConfig="_form.ResilienceConfig" />
</DriverFormShell>
</EditForm>
}
@@ -294,16 +89,10 @@ else
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,
Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() },
};
private FormModel _form = new();
private DriverIdentitySection.DriverIdentityModel _identityModel = new() { DriverType = DriverTypeKey };
private string _driverConfigJson = "{}";
private string? _resilienceConfig;
private byte[] _rowVersion = [];
private DriverInstance? _existing;
private bool _loaded;
private bool _busy;
@@ -328,7 +117,7 @@ else
DriverType = DriverTypeKey,
Enabled = true,
};
_form = new FormModel();
_driverConfigJson = "{}";
}
else
{
@@ -343,10 +132,9 @@ else
DriverType = _existing.DriverType,
Enabled = _existing.Enabled,
};
var opts = TryDeserialize(_existing.DriverConfig) ?? new ModbusDriverOptions();
_form = FormModel.FromOptions(opts);
_form.ResilienceConfig = _existing.ResilienceConfig;
_form.RowVersion = _existing.RowVersion;
_driverConfigJson = string.IsNullOrWhiteSpace(_existing.DriverConfig) ? "{}" : _existing.DriverConfig;
_resilienceConfig = _existing.ResilienceConfig;
_rowVersion = _existing.RowVersion;
}
}
_loaded = true;
@@ -357,7 +145,6 @@ else
_busy = true; _error = null;
try
{
var configJson = System.Text.Json.JsonSerializer.Serialize(_form.ToOptions(), _jsonOpts);
await using var db = await DbFactory.CreateDbContextAsync();
if (IsNew)
{
@@ -373,8 +160,8 @@ else
Name = _identityModel.Name,
DriverType = DriverTypeKey,
Enabled = _identityModel.Enabled,
DriverConfig = configJson,
ResilienceConfig = string.IsNullOrWhiteSpace(_form.ResilienceConfig) ? null : _form.ResilienceConfig,
DriverConfig = _driverConfigJson,
ResilienceConfig = string.IsNullOrWhiteSpace(_resilienceConfig) ? null : _resilienceConfig,
});
}
else
@@ -382,11 +169,11 @@ 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;
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _rowVersion;
entity.Name = _identityModel.Name;
entity.Enabled = _identityModel.Enabled;
entity.DriverConfig = configJson;
entity.ResilienceConfig = string.IsNullOrWhiteSpace(_form.ResilienceConfig) ? null : _form.ResilienceConfig;
entity.DriverConfig = _driverConfigJson;
entity.ResilienceConfig = string.IsNullOrWhiteSpace(_resilienceConfig) ? null : _resilienceConfig;
}
await db.SaveChangesAsync();
Nav.NavigateTo($"/clusters/{ClusterId}/drivers");
@@ -409,7 +196,7 @@ else
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.Entry(entity).Property(e => e.RowVersion).OriginalValue = _rowVersion;
db.DriverInstances.Remove(entity);
await db.SaveChangesAsync();
Nav.NavigateTo($"/clusters/{ClusterId}/drivers");
@@ -424,149 +211,4 @@ else
}
finally { _busy = false; }
}
private string SerializeCurrentConfig()
=> System.Text.Json.JsonSerializer.Serialize(_form.ToOptions(), _jsonOpts);
private static ModbusDriverOptions? TryDeserialize(string json)
{
try { return System.Text.Json.JsonSerializer.Deserialize<ModbusDriverOptions>(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() => new()
{
Host = Host,
Port = Port,
UnitId = (byte)Math.Clamp(UnitId, 0, 255),
Timeout = TimeSpan.FromSeconds(TimeoutSeconds),
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,
};
}
}
@@ -5,10 +5,10 @@
@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.Forms
@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.OpcUaClient
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
@inject NavigationManager Nav
@@ -30,7 +30,7 @@ else if (!IsNew && _existing is null)
}
else
{
<EditForm Model="_form" OnValidSubmit="SubmitAsync" FormName="opcuaclientDriverEdit">
<EditForm Model="_identityModel" OnValidSubmit="SubmitAsync" FormName="opcuaclientDriverEdit">
<DataAnnotationsValidator />
<DriverFormShell IsNew="IsNew" Busy="_busy" Error="@_error"
CancelHref="@($"/clusters/{ClusterId}/drivers")"
@@ -44,9 +44,7 @@ else
}
<div class="mt-2 mb-3">
<DriverTestConnectButton DriverType="@DriverTypeKey"
GetConfigJson="@SerializeCurrentConfig"
TimeoutSeconds="@_form.OpcUa.ProbeTimeoutSeconds" />
<DriverTestConnectButton DriverType="@DriverTypeKey" GetConfigJson="@(() => _driverConfigJson)" />
<button type="button" class="btn btn-sm btn-outline-secondary mt-2"
@onclick="@(() => _showPicker = true)">
Pick address
@@ -59,209 +57,19 @@ else
OnPickAddress="@OnAddressPicked">
<OpcUaClientAddressPickerBody CurrentAddress="@_pickedAddress"
CurrentAddressChanged="@((s) => _pickedAddress = s)"
GetConfigJson="@SerializeCurrentConfig" />
GetConfigJson="@(() => _driverConfigJson)" />
</DriverTagPicker>
@* Endpoint *@
<section class="panel rise mt-3" style="animation-delay:.06s">
<div class="panel-head">Endpoint</div>
<div style="padding:1rem">
<div class="row g-3">
<div class="col-md-8">
<label class="form-label">Endpoint URL</label>
<InputText @bind-Value="_form.OpcUa.EndpointUrl" class="form-control form-control-sm mono"
placeholder="opc.tcp://plc.internal:4840" />
<div class="form-text">Single-endpoint shortcut. When EndpointUrls list is non-empty, this field is ignored.</div>
</div>
<div class="col-md-4">
<label class="form-label">Browse root NodeId (blank = ObjectsFolder)</label>
<InputText @bind-Value="_form.OpcUa.BrowseRoot" class="form-control form-control-sm mono"
placeholder="i=85" />
<div class="form-text">Restrict mirroring to a sub-tree.</div>
</div>
<div class="col-md-4">
<label class="form-label">Application URI</label>
<InputText @bind-Value="_form.OpcUa.ApplicationUri" class="form-control form-control-sm mono" />
</div>
<div class="col-md-4">
<label class="form-label">Session name</label>
<InputText @bind-Value="_form.OpcUa.SessionName" class="form-control form-control-sm" />
</div>
<div class="col-md-4">
<div class="form-check form-switch mt-4">
<InputCheckbox @bind-Value="_form.OpcUa.AutoAcceptCertificates" class="form-check-input" id="autoAcceptCerts" />
<label class="form-check-label" for="autoAcceptCerts">Auto-accept certificates <span class="badge bg-warning text-dark">Dev only</span></label>
</div>
</div>
</div>
<div class="row g-3 mt-1">
<div class="col-md-3">
<label class="form-label">Per-endpoint connect timeout (s)</label>
<InputNumber @bind-Value="_form.OpcUa.PerEndpointConnectTimeoutSeconds" class="form-control form-control-sm" />
<div class="form-text">Default 3 s — failover sweep budget.</div>
</div>
<div class="col-md-3">
<label class="form-label">Operation timeout (s)</label>
<InputNumber @bind-Value="_form.OpcUa.TimeoutSeconds" class="form-control form-control-sm" />
<div class="form-text">Default 10 s — steady-state reads/writes.</div>
</div>
<div class="col-md-3">
<label class="form-label">Session timeout (s)</label>
<InputNumber @bind-Value="_form.OpcUa.SessionTimeoutSeconds" class="form-control form-control-sm" />
<div class="form-text">Default 120 s.</div>
</div>
<div class="col-md-3">
<label class="form-label">Keep-alive interval (s)</label>
<InputNumber @bind-Value="_form.OpcUa.KeepAliveIntervalSeconds" class="form-control form-control-sm" />
<div class="form-text">Default 5 s.</div>
</div>
<div class="col-md-3">
<label class="form-label">Reconnect period (s)</label>
<InputNumber @bind-Value="_form.OpcUa.ReconnectPeriodSeconds" class="form-control form-control-sm" />
<div class="form-text">Initial delay after session drop. Default 5 s.</div>
</div>
<div class="col-md-3">
<label class="form-label">Max discovered nodes</label>
<InputNumber @bind-Value="_form.OpcUa.MaxDiscoveredNodes" class="form-control form-control-sm" />
<div class="form-text">Default 10000.</div>
</div>
<div class="col-md-3">
<label class="form-label">Max browse depth</label>
<InputNumber @bind-Value="_form.OpcUa.MaxBrowseDepth" class="form-control form-control-sm" />
<div class="form-text">Default 10.</div>
</div>
</div>
<div class="row g-3 mt-1">
<div class="col-12">
<CollectionEditor TRow="EndpointUrlRow" Items="_endpoints"
Title="Endpoint URLs" ItemNoun="endpoint" AnimationDelay=".07s"
NewRow="@(() => new EndpointUrlRow())" Clone="@(r => r.Clone())"
Validate="EndpointUrlRow.ValidateRow">
<HeaderTemplate>
<tr><th>Endpoint URL (failover list — first reachable wins)</th><th></th></tr>
</HeaderTemplate>
<RowTemplate Context="e">
<td class="mono">@e.Url</td>
</RowTemplate>
<EditTemplate Context="e">
<label class="form-label">Endpoint URL</label>
<input class="form-control form-control-sm mono" @bind="e.Url"
placeholder="opc.tcp://plc.internal:4840" />
<div class="form-text">When this list is non-empty, the single Endpoint URL above is ignored.</div>
</EditTemplate>
</CollectionEditor>
</div>
</div>
<section class="panel notice rise mt-3" style="animation-delay:.04s">
<div class="panel-head">Connection endpoint</div>
<div style="padding:1rem" class="text-muted">
The single Endpoint URL now lives on the driver's <strong>device</strong> (v3 endpoint→DeviceConfig split).
Author it from the Raw tree (<strong>/raw</strong>) device modal. The failover EndpointUrls list below stays driver-level.
</div>
</section>
@* Security *@
<section class="panel rise mt-3" style="animation-delay:.08s">
<div class="panel-head">Security</div>
<div style="padding:1rem">
<div class="row g-3">
<div class="col-md-4">
<label class="form-label">Security mode</label>
<InputSelect @bind-Value="_form.OpcUa.SecurityMode" class="form-select form-select-sm">
@foreach (var e in Enum.GetValues<OpcUaSecurityMode>())
{
<option value="@e">@e</option>
}
</InputSelect>
</div>
<div class="col-md-4">
<label class="form-label">Security policy</label>
<InputSelect @bind-Value="_form.OpcUa.SecurityPolicy" class="form-select form-select-sm">
@foreach (var e in Enum.GetValues<OpcUaSecurityPolicy>())
{
<option value="@e">@e</option>
}
</InputSelect>
</div>
</div>
</div>
</section>
@* Authentication *@
<section class="panel rise mt-3" style="animation-delay:.10s">
<div class="panel-head">Authentication</div>
<div style="padding:1rem">
<div class="row g-3">
<div class="col-md-4">
<label class="form-label">Auth type</label>
<InputSelect @bind-Value="_form.OpcUa.AuthType" class="form-select form-select-sm">
@foreach (var e in Enum.GetValues<OpcUaAuthType>())
{
<option value="@e">@e</option>
}
</InputSelect>
</div>
@if (_form.OpcUa.AuthType == OpcUaAuthType.Username)
{
<div class="col-md-4">
<label class="form-label">Username</label>
<InputText @bind-Value="_form.OpcUa.Username" class="form-control form-control-sm" />
</div>
<div class="col-md-4">
<label class="form-label">Password</label>
<InputText @bind-Value="_form.OpcUa.Password" type="password" class="form-control form-control-sm" autocomplete="new-password" />
</div>
}
@if (_form.OpcUa.AuthType == OpcUaAuthType.Certificate)
{
<div class="col-md-6">
<label class="form-label">User certificate path (PFX/PEM)</label>
<InputText @bind-Value="_form.OpcUa.UserCertificatePath" class="form-control form-control-sm mono"
placeholder="C:\certs\user.pfx" />
</div>
<div class="col-md-4">
<label class="form-label">Certificate password (if PFX-locked)</label>
<InputText @bind-Value="_form.OpcUa.UserCertificatePassword" type="password" class="form-control form-control-sm" autocomplete="new-password" />
</div>
}
</div>
</div>
</section>
@* Namespace mapping *@
<section class="panel rise mt-3" style="animation-delay:.12s">
<div class="panel-head">Namespace mapping</div>
<div style="padding:1rem">
<div class="row g-3">
<div class="col-md-4">
<label class="form-label">Target namespace kind</label>
<InputSelect @bind-Value="_form.OpcUa.TargetNamespaceKind" class="form-select form-select-sm">
@foreach (var e in Enum.GetValues<OpcUaTargetNamespaceKind>())
{
<option value="@e">@e</option>
}
</InputSelect>
<div class="form-text">Equipment = raw data re-mapped to UNS. SystemPlatform = processed data; hierarchy preserved as-is.</div>
</div>
<div class="col-12">
<label class="form-label">UNS mapping table (read-only — edit via raw JSON import)</label>
<pre class="form-control form-control-sm mono" style="min-height:3rem;overflow:auto;white-space:pre-wrap;">@_unsMappingTableJson</pre>
<div class="form-text">Keys = remote browse-path prefixes; values = UNS Area/Line/Name paths. Required when TargetNamespaceKind = Equipment.</div>
</div>
</div>
</div>
</section>
@* Diagnostics *@
<section class="panel rise mt-3" style="animation-delay:.14s">
<div class="panel-head">Diagnostics</div>
<div style="padding:1rem">
<div class="row g-3">
<div class="col-md-3">
<label class="form-label">Admin UI probe timeout (seconds)</label>
<InputNumber @bind-Value="_form.OpcUa.ProbeTimeoutSeconds" class="form-control form-control-sm" />
<div class="form-text">Max 60. Used by Test Connect. Default 15.</div>
</div>
</div>
</div>
</section>
<DriverResilienceSection @bind-ResilienceConfig="_form.ResilienceConfig" />
<OpcUaClientDriverForm @bind-DriverConfigJson="_driverConfigJson"
@bind-ResilienceConfig="_resilienceConfig" />
</DriverFormShell>
</EditForm>
}
@@ -274,16 +82,10 @@ else
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,
Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() },
};
private FormModel _form = new();
private DriverIdentitySection.DriverIdentityModel _identityModel = new() { DriverType = DriverTypeKey };
private string _driverConfigJson = "{}";
private string? _resilienceConfig;
private byte[] _rowVersion = [];
private DriverInstance? _existing;
private bool _loaded;
private bool _busy;
@@ -295,12 +97,6 @@ else
private void OnAddressPicked(string address) => _pickedAddress = address;
// Held separately because EndpointUrls is a collection — edited via the CollectionEditor modal.
private List<EndpointUrlRow> _endpoints = [];
// Read-only JSON snippet for the UnsMappingTable, which has no list editor yet.
private string _unsMappingTableJson = "{}";
protected override async Task OnInitializedAsync()
{
await using var db = await DbFactory.CreateDbContextAsync();
@@ -314,7 +110,7 @@ else
DriverType = DriverTypeKey,
Enabled = true,
};
_form = new FormModel();
_driverConfigJson = "{}";
}
else
{
@@ -329,13 +125,9 @@ else
DriverType = _existing.DriverType,
Enabled = _existing.Enabled,
};
var opts = TryDeserialize(_existing.DriverConfig) ?? new OpcUaClientDriverOptions();
_form = new FormModel();
_form.OpcUa = OpcUaClientFormModel.FromRecord(opts);
_endpoints = opts.EndpointUrls.Select(EndpointUrlRow.FromUrl).ToList();
_unsMappingTableJson = System.Text.Json.JsonSerializer.Serialize(opts.UnsMappingTable, _jsonOpts);
_form.ResilienceConfig = _existing.ResilienceConfig;
_form.RowVersion = _existing.RowVersion;
_driverConfigJson = string.IsNullOrWhiteSpace(_existing.DriverConfig) ? "{}" : _existing.DriverConfig;
_resilienceConfig = _existing.ResilienceConfig;
_rowVersion = _existing.RowVersion;
}
}
_loaded = true;
@@ -346,8 +138,6 @@ else
_busy = true; _error = null;
try
{
var opts = _form.OpcUa.ToRecord(_endpoints.Select(r => r.ToUrl()).ToList());
var configJson = System.Text.Json.JsonSerializer.Serialize(opts, _jsonOpts);
await using var db = await DbFactory.CreateDbContextAsync();
if (IsNew)
{
@@ -362,8 +152,8 @@ else
Name = _identityModel.Name,
DriverType = DriverTypeKey,
Enabled = _identityModel.Enabled,
DriverConfig = configJson,
ResilienceConfig = string.IsNullOrWhiteSpace(_form.ResilienceConfig) ? null : _form.ResilienceConfig,
DriverConfig = _driverConfigJson,
ResilienceConfig = string.IsNullOrWhiteSpace(_resilienceConfig) ? null : _resilienceConfig,
});
}
else
@@ -371,11 +161,11 @@ 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;
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _rowVersion;
entity.Name = _identityModel.Name;
entity.Enabled = _identityModel.Enabled;
entity.DriverConfig = configJson;
entity.ResilienceConfig = string.IsNullOrWhiteSpace(_form.ResilienceConfig) ? null : _form.ResilienceConfig;
entity.DriverConfig = _driverConfigJson;
entity.ResilienceConfig = string.IsNullOrWhiteSpace(_resilienceConfig) ? null : _resilienceConfig;
}
await db.SaveChangesAsync();
Nav.NavigateTo($"/clusters/{ClusterId}/drivers");
@@ -398,7 +188,7 @@ else
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.Entry(entity).Property(e => e.RowVersion).OriginalValue = _rowVersion;
db.DriverInstances.Remove(entity);
await db.SaveChangesAsync();
Nav.NavigateTo($"/clusters/{ClusterId}/drivers");
@@ -413,142 +203,4 @@ else
}
finally { _busy = false; }
}
private string SerializeCurrentConfig()
=> System.Text.Json.JsonSerializer.Serialize(
_form.OpcUa.ToRecord(_endpoints.Select(r => r.ToUrl()).ToList()), _jsonOpts);
private static OpcUaClientDriverOptions? TryDeserialize(string json)
{
try { return System.Text.Json.JsonSerializer.Deserialize<OpcUaClientDriverOptions>(json, _jsonOpts); }
catch { return null; }
}
public sealed class FormModel
{
public OpcUaClientFormModel OpcUa { get; set; } = new();
public string? ResilienceConfig { get; set; }
public byte[] RowVersion { get; set; } = [];
}
/// <summary>
/// Mutable VM for a single endpoint URL row. EndpointUrls is a plain
/// <c>List&lt;string&gt;</c> (a failover list) so the row is a thin wrapper the
/// <see cref="CollectionEditor{TRow}"/> modal can bind to.
/// </summary>
public sealed class EndpointUrlRow
{
public string Url { get; set; } = "";
public EndpointUrlRow Clone() => (EndpointUrlRow)MemberwiseClone();
public static EndpointUrlRow FromUrl(string u) => new() { Url = u };
public string ToUrl() => Url.Trim();
public static string? ValidateRow(EndpointUrlRow row, IReadOnlyList<EndpointUrlRow> all, int? editIndex)
{
if (string.IsNullOrWhiteSpace(row.Url)) return "URL is required.";
if (!row.Url.Trim().StartsWith("opc.tcp://", StringComparison.OrdinalIgnoreCase))
return "Endpoint URL must start with opc.tcp://";
for (var i = 0; i < all.Count; i++)
if (i != editIndex && string.Equals(all[i].Url.Trim(), row.Url.Trim(), StringComparison.OrdinalIgnoreCase))
return $"Duplicate endpoint '{row.Url}'.";
return null;
}
}
/// <summary>
/// Mutable mirror of <see cref="OpcUaClientDriverOptions"/> with int wrappers for
/// TimeSpan fields so Blazor InputNumber can bind them.
/// EndpointUrls is edited via the CollectionEditor (held on the page as a row list and
/// threaded into <see cref="ToRecord"/>); UnsMappingTable is shown as read-only JSON and
/// survives round-trip via the original deserialized record, re-serialized unchanged.
/// </summary>
public sealed class OpcUaClientFormModel
{
// Connection
public string EndpointUrl { get; set; } = "opc.tcp://localhost:4840";
public string? BrowseRoot { get; set; }
public string ApplicationUri { get; set; } = "urn:localhost:OtOpcUa:GatewayClient";
public string SessionName { get; set; } = "OtOpcUa-Gateway";
public bool AutoAcceptCertificates { get; set; } = false;
public int PerEndpointConnectTimeoutSeconds { get; set; } = 3;
public int TimeoutSeconds { get; set; } = 10;
public int SessionTimeoutSeconds { get; set; } = 120;
public int KeepAliveIntervalSeconds { get; set; } = 5;
public int ReconnectPeriodSeconds { get; set; } = 5;
public int MaxDiscoveredNodes { get; set; } = 10_000;
public int MaxBrowseDepth { get; set; } = 10;
// Security
public OpcUaSecurityMode SecurityMode { get; set; } = OpcUaSecurityMode.None;
public OpcUaSecurityPolicy SecurityPolicy { get; set; } = OpcUaSecurityPolicy.None;
// Authentication
public OpcUaAuthType AuthType { get; set; } = OpcUaAuthType.Anonymous;
public string? Username { get; set; }
public string? Password { get; set; }
public string? UserCertificatePath { get; set; }
public string? UserCertificatePassword { get; set; }
// Namespace mapping
public OpcUaTargetNamespaceKind TargetNamespaceKind { get; set; } = OpcUaTargetNamespaceKind.Equipment;
// Diagnostics
public int ProbeTimeoutSeconds { get; set; } = 15;
// Preserved read-only collection (round-tripped unchanged from original record)
internal IReadOnlyDictionary<string, string> _unsMappingTable = new System.Collections.Generic.Dictionary<string, string>();
public static OpcUaClientFormModel FromRecord(OpcUaClientDriverOptions r) => new()
{
EndpointUrl = r.EndpointUrl,
BrowseRoot = r.BrowseRoot,
ApplicationUri = r.ApplicationUri,
SessionName = r.SessionName,
AutoAcceptCertificates = r.AutoAcceptCertificates,
PerEndpointConnectTimeoutSeconds = (int)r.PerEndpointConnectTimeout.TotalSeconds,
TimeoutSeconds = (int)r.Timeout.TotalSeconds,
SessionTimeoutSeconds = (int)r.SessionTimeout.TotalSeconds,
KeepAliveIntervalSeconds = (int)r.KeepAliveInterval.TotalSeconds,
ReconnectPeriodSeconds = (int)r.ReconnectPeriod.TotalSeconds,
MaxDiscoveredNodes = r.MaxDiscoveredNodes,
MaxBrowseDepth = r.MaxBrowseDepth,
SecurityMode = r.SecurityMode,
SecurityPolicy = r.SecurityPolicy,
AuthType = r.AuthType,
Username = r.Username,
Password = r.Password,
UserCertificatePath = r.UserCertificatePath,
UserCertificatePassword = r.UserCertificatePassword,
TargetNamespaceKind = r.TargetNamespaceKind,
ProbeTimeoutSeconds = r.ProbeTimeoutSeconds,
_unsMappingTable = r.UnsMappingTable,
};
public OpcUaClientDriverOptions ToRecord(IReadOnlyList<string> endpointUrls) => new()
{
EndpointUrl = EndpointUrl,
EndpointUrls = endpointUrls,
BrowseRoot = string.IsNullOrWhiteSpace(BrowseRoot) ? null : BrowseRoot,
ApplicationUri = ApplicationUri,
SessionName = SessionName,
AutoAcceptCertificates = AutoAcceptCertificates,
PerEndpointConnectTimeout = TimeSpan.FromSeconds(PerEndpointConnectTimeoutSeconds),
Timeout = TimeSpan.FromSeconds(TimeoutSeconds),
SessionTimeout = TimeSpan.FromSeconds(SessionTimeoutSeconds),
KeepAliveInterval = TimeSpan.FromSeconds(KeepAliveIntervalSeconds),
ReconnectPeriod = TimeSpan.FromSeconds(ReconnectPeriodSeconds),
MaxDiscoveredNodes = MaxDiscoveredNodes,
MaxBrowseDepth = MaxBrowseDepth,
SecurityMode = SecurityMode,
SecurityPolicy = SecurityPolicy,
AuthType = AuthType,
Username = string.IsNullOrWhiteSpace(Username) ? null : Username,
Password = string.IsNullOrWhiteSpace(Password) ? null : Password,
UserCertificatePath = string.IsNullOrWhiteSpace(UserCertificatePath) ? null : UserCertificatePath,
UserCertificatePassword = string.IsNullOrWhiteSpace(UserCertificatePassword) ? null : UserCertificatePassword,
TargetNamespaceKind = TargetNamespaceKind,
UnsMappingTable = _unsMappingTable,
ProbeTimeoutSeconds = ProbeTimeoutSeconds,
};
}
}
@@ -5,10 +5,10 @@
@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.Forms
@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.S7
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
@inject NavigationManager Nav
@@ -30,7 +30,7 @@ else if (!IsNew && _existing is null)
}
else
{
<EditForm Model="_form" OnValidSubmit="SubmitAsync" FormName="s7DriverEdit">
<EditForm Model="_identityModel" OnValidSubmit="SubmitAsync" FormName="s7DriverEdit">
<DataAnnotationsValidator />
<DriverFormShell IsNew="IsNew" Busy="_busy" Error="@_error"
CancelHref="@($"/clusters/{ClusterId}/drivers")"
@@ -44,9 +44,7 @@ else
}
<div class="mt-2 mb-3">
<DriverTestConnectButton DriverType="@DriverTypeKey"
GetConfigJson="@SerializeCurrentConfig"
TimeoutSeconds="@_form.AdminProbeTimeoutSeconds" />
<DriverTestConnectButton DriverType="@DriverTypeKey" GetConfigJson="@(() => _driverConfigJson)" />
<button type="button" class="btn btn-sm btn-outline-secondary mt-2"
@onclick="@(() => _showPicker = true)">
Pick address
@@ -61,91 +59,17 @@ else
CurrentAddressChanged="@((s) => _pickedAddress = s)" />
</DriverTagPicker>
@* Connection *@
<section class="panel rise mt-3" style="animation-delay:.05s">
<div class="panel-head">Connection</div>
<div style="padding:1rem">
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label" for="s7Host">Host</label>
<InputText id="s7Host" @bind-Value="_form.Host"
class="form-control form-control-sm mono"
placeholder="192.168.0.1" />
<div class="form-text">PLC IP address or hostname.</div>
</div>
<div class="col-md-3 mb-3">
<label class="form-label" for="s7Port">Port</label>
<InputNumber id="s7Port" @bind-Value="_form.Port"
class="form-control form-control-sm" />
<div class="form-text">ISO-on-TCP; usually 102.</div>
</div>
<div class="col-md-3 mb-3">
<label class="form-label" for="s7TimeoutSec">Timeout (seconds)</label>
<InputNumber id="s7TimeoutSec" @bind-Value="_form.TimeoutSeconds"
class="form-control form-control-sm" />
</div>
</div>
<div class="row">
<div class="col-md-4 mb-3">
<label class="form-label" for="s7CpuType">CPU type</label>
<InputSelect id="s7CpuType" @bind-Value="_form.CpuType"
class="form-select form-select-sm">
@foreach (var v in Enum.GetValues<S7CpuType>())
{
<option value="@v">@v</option>
}
</InputSelect>
<div class="form-text">Controls ISO-TSAP slot byte during handshake.</div>
</div>
<div class="col-md-2 mb-3">
<label class="form-label" for="s7Rack">Rack</label>
<InputNumber id="s7Rack" @bind-Value="_form.Rack"
class="form-control form-control-sm" />
<div class="form-text">Almost always 0.</div>
</div>
<div class="col-md-2 mb-3">
<label class="form-label" for="s7Slot">Slot</label>
<InputNumber id="s7Slot" @bind-Value="_form.Slot"
class="form-control form-control-sm" />
<div class="form-text">S7-300/400 = 2; S7-1200/1500 = 0.</div>
</div>
</div>
<section class="panel notice rise mt-3" style="animation-delay:.04s">
<div class="panel-head">Connection endpoint</div>
<div style="padding:1rem" class="text-muted">
Host / Port / Rack / Slot now live on the driver's <strong>device</strong> (v3 endpoint→DeviceConfig split).
Author them from the Raw tree (<strong>/raw</strong>) device modal.
</div>
</section>
@* Probe *@
<section class="panel rise mt-3" style="animation-delay:.08s">
<div class="panel-head">Connectivity probe</div>
<div style="padding:1rem">
<div class="row">
<div class="col-md-3 mb-3">
<div class="form-check form-switch mt-2">
<InputCheckbox id="s7ProbeEnabled" @bind-Value="_form.ProbeEnabled"
class="form-check-input" />
<label class="form-check-label" for="s7ProbeEnabled">Probe enabled</label>
</div>
</div>
<div class="col-md-3 mb-3">
<label class="form-label" for="s7ProbeIntervalSec">Probe interval (s)</label>
<InputNumber id="s7ProbeIntervalSec" @bind-Value="_form.ProbeIntervalSeconds"
class="form-control form-control-sm" />
</div>
<div class="col-md-3 mb-3">
<label class="form-label" for="s7ProbeTimeoutSec">Probe timeout (s)</label>
<InputNumber id="s7ProbeTimeoutSec" @bind-Value="_form.ProbeTimeoutSeconds"
class="form-control form-control-sm" />
</div>
<div class="col-md-3 mb-3">
<label class="form-label" for="s7AdminProbeTimeout">Admin probe timeout (s)</label>
<InputNumber id="s7AdminProbeTimeout" @bind-Value="_form.AdminProbeTimeoutSeconds"
class="form-control form-control-sm" />
<div class="form-text">Test Connect timeout (160 s).</div>
</div>
</div>
</div>
</section>
<S7DriverForm @bind-DriverConfigJson="_driverConfigJson"
@bind-ResilienceConfig="_resilienceConfig" />
@* Tags *@
<section class="panel notice rise mt-3" style="animation-delay:.18s">
<div class="panel-head">Tags</div>
<div style="padding:1rem" class="text-muted">
@@ -153,8 +77,6 @@ else
Pre-declared driver tags have been retired.
</div>
</section>
<DriverResilienceSection @bind-ResilienceConfig="_form.ResilienceConfig" />
</DriverFormShell>
</EditForm>
}
@@ -167,18 +89,13 @@ else
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,
Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() },
};
private FormModel _form = new();
private DriverIdentitySection.DriverIdentityModel _identityModel = new() { DriverType = DriverTypeKey };
private string _driverConfigJson = "{}";
private string? _resilienceConfig;
private byte[] _rowVersion = [];
private DriverInstance? _existing;
private bool _loaded, _busy;
private bool _loaded;
private bool _busy;
private string? _error;
// Address picker state
@@ -193,8 +110,14 @@ else
if (IsNew)
{
_identityModel = new() { DriverType = DriverTypeKey, Enabled = true };
_form = FormModel.FromOptions(new S7DriverOptions());
_identityModel = new()
{
DriverInstanceId = "",
Name = "",
DriverType = DriverTypeKey,
Enabled = true,
};
_driverConfigJson = "{}";
}
else
{
@@ -209,10 +132,9 @@ else
DriverType = _existing.DriverType,
Enabled = _existing.Enabled,
};
var opts = TryDeserialize(_existing.DriverConfig) ?? new S7DriverOptions();
_form = FormModel.FromOptions(opts);
_form.ResilienceConfig = _existing.ResilienceConfig;
_form.RowVersion = _existing.RowVersion;
_driverConfigJson = string.IsNullOrWhiteSpace(_existing.DriverConfig) ? "{}" : _existing.DriverConfig;
_resilienceConfig = _existing.ResilienceConfig;
_rowVersion = _existing.RowVersion;
}
}
_loaded = true;
@@ -223,8 +145,6 @@ else
_busy = true; _error = null;
try
{
var opts = _form.ToOptions();
var configJson = System.Text.Json.JsonSerializer.Serialize(opts, _jsonOpts);
await using var db = await DbFactory.CreateDbContextAsync();
if (IsNew)
{
@@ -239,8 +159,8 @@ else
Name = _identityModel.Name,
DriverType = DriverTypeKey,
Enabled = _identityModel.Enabled,
DriverConfig = configJson,
ResilienceConfig = string.IsNullOrWhiteSpace(_form.ResilienceConfig) ? null : _form.ResilienceConfig,
DriverConfig = _driverConfigJson,
ResilienceConfig = string.IsNullOrWhiteSpace(_resilienceConfig) ? null : _resilienceConfig,
});
}
else
@@ -248,11 +168,11 @@ 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;
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _rowVersion;
entity.Name = _identityModel.Name;
entity.Enabled = _identityModel.Enabled;
entity.DriverConfig = configJson;
entity.ResilienceConfig = string.IsNullOrWhiteSpace(_form.ResilienceConfig) ? null : _form.ResilienceConfig;
entity.DriverConfig = _driverConfigJson;
entity.ResilienceConfig = string.IsNullOrWhiteSpace(_resilienceConfig) ? null : _resilienceConfig;
}
await db.SaveChangesAsync();
Nav.NavigateTo($"/clusters/{ClusterId}/drivers");
@@ -275,7 +195,7 @@ else
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.Entry(entity).Property(e => e.RowVersion).OriginalValue = _rowVersion;
db.DriverInstances.Remove(entity);
await db.SaveChangesAsync();
Nav.NavigateTo($"/clusters/{ClusterId}/drivers");
@@ -290,67 +210,4 @@ else
}
finally { _busy = false; }
}
private string SerializeCurrentConfig()
=> System.Text.Json.JsonSerializer.Serialize(
_form.ToOptions(), _jsonOpts);
private static S7DriverOptions? TryDeserialize(string json)
{
try { return System.Text.Json.JsonSerializer.Deserialize<S7DriverOptions>(json, _jsonOpts); }
catch { return null; }
}
// Flat mutable model — all scalar properties settable for Blazor @bind-Value.
public sealed class FormModel
{
// Connection
public string Host { get; set; } = "127.0.0.1";
public int Port { get; set; } = 102;
public S7CpuType CpuType { get; set; } = S7CpuType.S71500;
public short Rack { get; set; } = 0;
public short Slot { get; set; } = 0;
public int TimeoutSeconds { get; set; } = 5;
// Probe
public bool ProbeEnabled { get; set; } = true;
public int ProbeIntervalSeconds { get; set; } = 5;
public int ProbeTimeoutSeconds { get; set; } = 2;
public int AdminProbeTimeoutSeconds { get; set; } = 5;
// Common
public string? ResilienceConfig { get; set; }
public byte[] RowVersion { get; set; } = [];
public static FormModel FromOptions(S7DriverOptions o) => new()
{
Host = o.Host,
Port = o.Port,
CpuType = o.CpuType,
Rack = o.Rack,
Slot = o.Slot,
TimeoutSeconds = (int)o.Timeout.TotalSeconds,
ProbeEnabled = o.Probe.Enabled,
ProbeIntervalSeconds = (int)o.Probe.Interval.TotalSeconds,
ProbeTimeoutSeconds = (int)o.Probe.Timeout.TotalSeconds,
AdminProbeTimeoutSeconds = o.ProbeTimeoutSeconds,
};
public S7DriverOptions ToOptions() => new()
{
Host = Host,
Port = Port,
CpuType = CpuType,
Rack = Rack,
Slot = Slot,
Timeout = TimeSpan.FromSeconds(TimeoutSeconds),
Probe = new S7ProbeOptions
{
Enabled = ProbeEnabled,
Interval = TimeSpan.FromSeconds(ProbeIntervalSeconds),
Timeout = TimeSpan.FromSeconds(ProbeTimeoutSeconds),
},
ProbeTimeoutSeconds = AdminProbeTimeoutSeconds,
};
}
}
@@ -5,10 +5,10 @@
@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.Forms
@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.TwinCAT
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
@inject NavigationManager Nav
@@ -30,7 +30,7 @@ else if (!IsNew && _existing is null)
}
else
{
<EditForm Model="_form" OnValidSubmit="SubmitAsync" FormName="twincatDriverEdit">
<EditForm Model="_identityModel" OnValidSubmit="SubmitAsync" FormName="twincatDriverEdit">
<DataAnnotationsValidator />
<DriverFormShell IsNew="IsNew" Busy="_busy" Error="@_error"
CancelHref="@($"/clusters/{ClusterId}/drivers")"
@@ -44,9 +44,7 @@ else
}
<div class="mt-2 mb-3">
<DriverTestConnectButton DriverType="@DriverTypeKey"
GetConfigJson="@SerializeCurrentConfig"
TimeoutSeconds="@_form.AdminProbeTimeoutSeconds" />
<DriverTestConnectButton DriverType="@DriverTypeKey" GetConfigJson="@(() => _driverConfigJson)" />
<button type="button" class="btn btn-sm btn-outline-secondary mt-2"
@onclick="@(() => _showPicker = true)">
Pick address
@@ -59,104 +57,20 @@ else
OnPickAddress="@OnAddressPicked">
<TwinCATAddressPickerBody CurrentAddress="@_pickedAddress"
CurrentAddressChanged="@((s) => _pickedAddress = s)"
GetConfigJson="@SerializeCurrentConfig" />
GetConfigJson="@(() => _driverConfigJson)" />
</DriverTagPicker>
@* Options *@
<section class="panel rise mt-3" style="animation-delay:.05s">
<div class="panel-head">Options</div>
<div style="padding:1rem">
<div class="row">
<div class="col-md-3 mb-3">
<label class="form-label" for="tcTimeoutSec">Timeout (seconds)</label>
<InputNumber id="tcTimeoutSec" @bind-Value="_form.TimeoutSeconds"
class="form-control form-control-sm" />
<div class="form-text">Default 2 s per operation.</div>
</div>
<div class="col-md-4 mb-3">
<div class="form-check form-switch mt-4">
<InputCheckbox id="tcNativeNotif" @bind-Value="_form.UseNativeNotifications"
class="form-check-input" />
<label class="form-check-label" for="tcNativeNotif">Use native ADS notifications</label>
</div>
<div class="form-text">Recommended. Disable for AMS routers with notification limits.</div>
</div>
<div class="col-md-4 mb-3">
<div class="form-check form-switch mt-4">
<InputCheckbox id="tcControllerBrowse" @bind-Value="_form.EnableControllerBrowse"
class="form-check-input" />
<label class="form-check-label" for="tcControllerBrowse">Enable controller browse</label>
</div>
<div class="form-text">Walks the symbol table via SymbolLoaderFactory at discovery. Default off.</div>
</div>
</div>
<div class="row">
<div class="col-md-4 mb-3">
<label class="form-label" for="tcNotifDelay">Notification max delay (ms)</label>
<InputNumber id="tcNotifDelay" @bind-Value="_form.NotificationMaxDelayMs"
class="form-control form-control-sm" />
<div class="form-text">0 = push immediately. Increase to coalesce high-churn signals.</div>
</div>
</div>
<section class="panel notice rise mt-3" style="animation-delay:.04s">
<div class="panel-head">Connection endpoint</div>
<div style="padding:1rem" class="text-muted">
Device host addresses (AMS Net Id:port) now live on the driver's <strong>devices</strong>
(v3 endpoint→DeviceConfig split). Author them from the Raw tree (<strong>/raw</strong>) device modal.
</div>
</section>
@* Probe *@
<section class="panel rise mt-3" style="animation-delay:.08s">
<div class="panel-head">Connectivity probe</div>
<div style="padding:1rem">
<div class="row">
<div class="col-md-3 mb-3">
<div class="form-check form-switch mt-2">
<InputCheckbox id="tcProbeEnabled" @bind-Value="_form.ProbeEnabled"
class="form-check-input" />
<label class="form-check-label" for="tcProbeEnabled">Probe enabled</label>
</div>
</div>
<div class="col-md-3 mb-3">
<label class="form-label" for="tcProbeInterval">Probe interval (s)</label>
<InputNumber id="tcProbeInterval" @bind-Value="_form.ProbeIntervalSeconds"
class="form-control form-control-sm" />
</div>
<div class="col-md-3 mb-3">
<label class="form-label" for="tcProbeTimeout">Probe timeout (s)</label>
<InputNumber id="tcProbeTimeout" @bind-Value="_form.ProbeTimeoutSeconds"
class="form-control form-control-sm" />
</div>
<div class="col-md-3 mb-3">
<label class="form-label" for="tcAdminProbe">Admin probe timeout (s)</label>
<InputNumber id="tcAdminProbe" @bind-Value="_form.AdminProbeTimeoutSeconds"
class="form-control form-control-sm" />
<div class="form-text">Test Connect timeout (160 s).</div>
</div>
</div>
</div>
</section>
<TwinCATDriverForm @bind-DriverConfigJson="_driverConfigJson"
@bind-ResilienceConfig="_resilienceConfig" />
@* Devices *@
<CollectionEditor TRow="TwinCATDeviceRow" Items="_devices" Title="Devices" ItemNoun="device"
AnimationDelay=".11s"
NewRow="@(() => new TwinCATDeviceRow())" Clone="@(r => r.Clone())"
Validate="TwinCATDeviceRow.ValidateRow">
<HeaderTemplate>
<tr><th>Host address</th><th>Device name</th><th></th></tr>
</HeaderTemplate>
<RowTemplate Context="d">
<td class="mono">@d.HostAddress</td>
<td>@(string.IsNullOrWhiteSpace(d.DeviceName) ? "—" : d.DeviceName)</td>
</RowTemplate>
<EditTemplate Context="d">
<div class="row g-3">
<div class="col-md-6"><label class="form-label">Host address (AMS Net Id:port)</label>
<input class="form-control form-control-sm mono" @bind="d.HostAddress"
placeholder="192.168.0.1.1.1:851" /></div>
<div class="col-md-6"><label class="form-label">Device name</label>
<input class="form-control form-control-sm" @bind="d.DeviceName" /></div>
</div>
</EditTemplate>
</CollectionEditor>
@* Tags *@
<section class="panel notice rise mt-3" style="animation-delay:.18s">
<div class="panel-head">Tags</div>
<div style="padding:1rem" class="text-muted">
@@ -164,8 +78,6 @@ else
Pre-declared driver tags have been retired.
</div>
</section>
<DriverResilienceSection @bind-ResilienceConfig="_form.ResilienceConfig" />
</DriverFormShell>
</EditForm>
}
@@ -178,18 +90,13 @@ else
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,
Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() },
};
private FormModel _form = new();
private DriverIdentitySection.DriverIdentityModel _identityModel = new() { DriverType = DriverTypeKey };
private string _driverConfigJson = "{}";
private string? _resilienceConfig;
private byte[] _rowVersion = [];
private DriverInstance? _existing;
private bool _loaded, _busy;
private bool _loaded;
private bool _busy;
private string? _error;
// Address picker state
@@ -198,17 +105,20 @@ else
private void OnAddressPicked(string address) => _pickedAddress = address;
// Held separately because Devices is a collection — edited via the CollectionEditor modal.
private List<TwinCATDeviceRow> _devices = [];
protected override async Task OnInitializedAsync()
{
await using var db = await DbFactory.CreateDbContextAsync();
if (IsNew)
{
_identityModel = new() { DriverType = DriverTypeKey, Enabled = true };
_form = FormModel.FromOptions(new TwinCATDriverOptions());
_identityModel = new()
{
DriverInstanceId = "",
Name = "",
DriverType = DriverTypeKey,
Enabled = true,
};
_driverConfigJson = "{}";
}
else
{
@@ -223,11 +133,9 @@ else
DriverType = _existing.DriverType,
Enabled = _existing.Enabled,
};
var opts = TryDeserialize(_existing.DriverConfig) ?? new TwinCATDriverOptions();
_form = FormModel.FromOptions(opts);
_form.ResilienceConfig = _existing.ResilienceConfig;
_form.RowVersion = _existing.RowVersion;
_devices = opts.Devices.Select(TwinCATDeviceRow.FromDefinition).ToList();
_driverConfigJson = string.IsNullOrWhiteSpace(_existing.DriverConfig) ? "{}" : _existing.DriverConfig;
_resilienceConfig = _existing.ResilienceConfig;
_rowVersion = _existing.RowVersion;
}
}
_loaded = true;
@@ -238,10 +146,6 @@ else
_busy = true; _error = null;
try
{
var configJson = System.Text.Json.JsonSerializer.Serialize(
_form.ToOptions(
_devices.Select(r => r.ToDefinition()).ToList()),
_jsonOpts);
await using var db = await DbFactory.CreateDbContextAsync();
if (IsNew)
{
@@ -256,8 +160,8 @@ else
Name = _identityModel.Name,
DriverType = DriverTypeKey,
Enabled = _identityModel.Enabled,
DriverConfig = configJson,
ResilienceConfig = string.IsNullOrWhiteSpace(_form.ResilienceConfig) ? null : _form.ResilienceConfig,
DriverConfig = _driverConfigJson,
ResilienceConfig = string.IsNullOrWhiteSpace(_resilienceConfig) ? null : _resilienceConfig,
});
}
else
@@ -265,11 +169,11 @@ 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;
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _rowVersion;
entity.Name = _identityModel.Name;
entity.Enabled = _identityModel.Enabled;
entity.DriverConfig = configJson;
entity.ResilienceConfig = string.IsNullOrWhiteSpace(_form.ResilienceConfig) ? null : _form.ResilienceConfig;
entity.DriverConfig = _driverConfigJson;
entity.ResilienceConfig = string.IsNullOrWhiteSpace(_resilienceConfig) ? null : _resilienceConfig;
}
await db.SaveChangesAsync();
Nav.NavigateTo($"/clusters/{ClusterId}/drivers");
@@ -292,7 +196,7 @@ else
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.Entry(entity).Property(e => e.RowVersion).OriginalValue = _rowVersion;
db.DriverInstances.Remove(entity);
await db.SaveChangesAsync();
Nav.NavigateTo($"/clusters/{ClusterId}/drivers");
@@ -307,104 +211,4 @@ else
}
finally { _busy = false; }
}
private string SerializeCurrentConfig()
=> System.Text.Json.JsonSerializer.Serialize(
_form.ToOptions(
_devices.Select(r => r.ToDefinition()).ToList()),
_jsonOpts);
private static TwinCATDriverOptions? TryDeserialize(string json)
{
try { return System.Text.Json.JsonSerializer.Deserialize<TwinCATDriverOptions>(json, _jsonOpts); }
catch { return null; }
}
// Mutable VM for the modal editor — TwinCATDeviceOptions is an immutable record.
public sealed class TwinCATDeviceRow
{
public string HostAddress { get; set; } = "";
public string? DeviceName { get; set; }
// Original record (null for newly-added rows). Preserves any fields the editor doesn't
// expose across a load→save.
private TwinCATDeviceOptions? _source;
public TwinCATDeviceRow Clone() => (TwinCATDeviceRow)MemberwiseClone(); // _source is an immutable record ref — safe to share
public static TwinCATDeviceRow FromDefinition(TwinCATDeviceOptions d) => new()
{
HostAddress = d.HostAddress, DeviceName = d.DeviceName,
_source = d,
};
public TwinCATDeviceOptions ToDefinition()
{
var baseDef = _source ?? new TwinCATDeviceOptions(HostAddress.Trim());
return baseDef with
{
HostAddress = HostAddress.Trim(),
DeviceName = string.IsNullOrWhiteSpace(DeviceName) ? null : DeviceName.Trim(),
};
}
public static string? ValidateRow(TwinCATDeviceRow row, IReadOnlyList<TwinCATDeviceRow> all, int? editIndex)
{
if (string.IsNullOrWhiteSpace(row.HostAddress)) return "Host address is required.";
for (var i = 0; i < all.Count; i++)
if (i != editIndex && string.Equals(all[i].HostAddress, row.HostAddress, StringComparison.OrdinalIgnoreCase))
return $"Duplicate device host address '{row.HostAddress}'.";
return null;
}
}
// Flat mutable model — all scalar properties settable for Blazor @bind-Value.
// The Devices collection is kept on the component and passed in on ToOptions().
public sealed class FormModel
{
// Options
public int TimeoutSeconds { get; set; } = 2;
public bool UseNativeNotifications { get; set; } = true;
public bool EnableControllerBrowse { get; set; } = false;
public int NotificationMaxDelayMs { get; set; } = 0;
// Probe
public bool ProbeEnabled { get; set; } = true;
public int ProbeIntervalSeconds { get; set; } = 5;
public int ProbeTimeoutSeconds { get; set; } = 2;
public int AdminProbeTimeoutSeconds { get; set; } = 10;
// Common
public string? ResilienceConfig { get; set; }
public byte[] RowVersion { get; set; } = [];
public static FormModel FromOptions(TwinCATDriverOptions o) => new()
{
TimeoutSeconds = (int)o.Timeout.TotalSeconds,
UseNativeNotifications = o.UseNativeNotifications,
EnableControllerBrowse = o.EnableControllerBrowse,
NotificationMaxDelayMs = o.NotificationMaxDelayMs,
ProbeEnabled = o.Probe.Enabled,
ProbeIntervalSeconds = (int)o.Probe.Interval.TotalSeconds,
ProbeTimeoutSeconds = (int)o.Probe.Timeout.TotalSeconds,
AdminProbeTimeoutSeconds = o.ProbeTimeoutSeconds,
};
public TwinCATDriverOptions ToOptions(
IReadOnlyList<TwinCATDeviceOptions> devices) => new()
{
Timeout = TimeSpan.FromSeconds(TimeoutSeconds),
UseNativeNotifications = UseNativeNotifications,
EnableControllerBrowse = EnableControllerBrowse,
NotificationMaxDelayMs = NotificationMaxDelayMs,
Probe = new TwinCATProbeOptions
{
Enabled = ProbeEnabled,
Interval = TimeSpan.FromSeconds(ProbeIntervalSeconds),
Timeout = TimeSpan.FromSeconds(ProbeTimeoutSeconds),
},
ProbeTimeoutSeconds = AdminProbeTimeoutSeconds,
Devices = devices,
};
}
}