merge wt/wp3 into v3/batch2-raw-ui (Wave B UI)

This commit is contained in:
Joseph Doherty
2026-07-16 03:27:23 -04:00
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,
};
}
}
@@ -0,0 +1,54 @@
@* Embeddable AB CIP device (connection endpoint) form body — authors HostAddress/PlcFamily into the
Device's DeviceConfig (PascalCase, matching AbCipDeviceOptions). Hosted by DeviceModal. *@
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.DeviceForms
@using ZB.MOM.WW.OtOpcUa.Driver.AbCip
<section class="panel rise" style="animation-delay:.04s">
<div class="panel-head">Connection</div>
<div style="padding:1rem">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Host address</label>
<InputText @bind-Value="_model.HostAddress" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono" placeholder="ab://gateway/1,0" />
</div>
<div class="col-md-3">
<label class="form-label">PLC family</label>
<InputSelect @bind-Value="_model.PlcFamily" @bind-Value:after="EmitAsync" class="form-select form-select-sm">
@foreach (var e in Enum.GetValues<AbCipPlcFamily>())
{
<option value="@e">@e</option>
}
</InputSelect>
</div>
</div>
</div>
</section>
@code {
/// <summary>The device's DeviceConfig JSON (endpoint/connection keys).</summary>
[Parameter] public string DeviceConfigJson { get; set; } = "{}";
/// <summary>Fired (PascalCase HostAddress/PlcFamily) whenever the endpoint changes — enables @bind-DeviceConfigJson.</summary>
[Parameter] public EventCallback<string> DeviceConfigJsonChanged { get; set; }
private AbCipDeviceModel _model = new();
private string? _lastParsed;
protected override void OnParametersSet()
{
if (!string.Equals(_lastParsed, DeviceConfigJson, StringComparison.Ordinal))
{
_model = AbCipDeviceModel.FromJson(DeviceConfigJson);
_lastParsed = DeviceConfigJson;
}
}
/// <summary>Serializes the current endpoint to DeviceConfig JSON.</summary>
public string GetConfigJson() => _model.ToJson();
private async Task EmitAsync()
{
var json = GetConfigJson();
_lastParsed = json;
await DeviceConfigJsonChanged.InvokeAsync(json);
}
}
@@ -0,0 +1,51 @@
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.DeviceForms;
/// <summary>
/// Typed working model for an AB CIP <c>Device</c>'s connection endpoint (the v3 endpoint→DeviceConfig
/// split). Authors the <b>PascalCase</b> <c>HostAddress</c>/<c>PlcFamily</c> keys that
/// <c>AbCipDeviceOptions</c> binds — the exact casing the seed and runtime probe
/// (<c>PropertyNameCaseInsensitive</c>) expect. Preserves unrecognised keys across a load→save.
/// </summary>
public sealed class AbCipDeviceModel
{
/// <summary>libplctag gateway/path host address (e.g. <c>ab://gateway/1,0</c>).</summary>
public string HostAddress { get; set; } = "";
/// <summary>Allen-Bradley PLC family.</summary>
public AbCipPlcFamily PlcFamily { get; set; } = AbCipPlcFamily.ControlLogix;
private System.Text.Json.Nodes.JsonObject _bag = new();
/// <summary>Loads a model from a DeviceConfig JSON string, defaulting absent keys and retaining
/// every original key so fields this editor doesn't expose survive a load→save.</summary>
/// <param name="json">The raw DeviceConfig JSON string, or null for a new/empty device.</param>
/// <returns>The populated <see cref="AbCipDeviceModel"/>.</returns>
public static AbCipDeviceModel FromJson(string? json)
{
var o = TagConfigJson.ParseOrNew(json);
return new AbCipDeviceModel
{
HostAddress = TagConfigJson.GetString(o, "HostAddress") ?? "",
PlcFamily = TagConfigJson.GetEnum(o, "PlcFamily", AbCipPlcFamily.ControlLogix),
_bag = o,
};
}
/// <summary>Serializes this model back to a DeviceConfig JSON string, writing the PascalCase endpoint
/// keys over the preserved key bag.</summary>
/// <returns>The serialised DeviceConfig JSON string.</returns>
public string ToJson()
{
TagConfigJson.Set(_bag, "HostAddress", HostAddress);
TagConfigJson.Set(_bag, "PlcFamily", PlcFamily);
return TagConfigJson.Serialize(_bag);
}
/// <summary>Validation hook; returns an error message or null when the model is valid.</summary>
/// <returns>An error message describing the validation failure, or <c>null</c> when the model is valid.</returns>
public string? Validate()
=> string.IsNullOrWhiteSpace(HostAddress) ? "Host address is required." : null;
}
@@ -0,0 +1,54 @@
@* Embeddable AB Legacy device (connection endpoint) form body — authors HostAddress/PlcFamily into the
Device's DeviceConfig (PascalCase, matching AbLegacyDeviceOptions). Hosted by DeviceModal. *@
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.DeviceForms
@using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies
<section class="panel rise" style="animation-delay:.04s">
<div class="panel-head">Connection</div>
<div style="padding:1rem">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Host address</label>
<InputText @bind-Value="_model.HostAddress" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono" placeholder="10.0.0.10" />
</div>
<div class="col-md-6">
<label class="form-label">PLC family</label>
<InputSelect @bind-Value="_model.PlcFamily" @bind-Value:after="EmitAsync" class="form-select form-select-sm">
@foreach (var e in Enum.GetValues<AbLegacyPlcFamily>())
{
<option value="@e">@e</option>
}
</InputSelect>
</div>
</div>
</div>
</section>
@code {
/// <summary>The device's DeviceConfig JSON (endpoint/connection keys).</summary>
[Parameter] public string DeviceConfigJson { get; set; } = "{}";
/// <summary>Fired (PascalCase HostAddress/PlcFamily) whenever the endpoint changes — enables @bind-DeviceConfigJson.</summary>
[Parameter] public EventCallback<string> DeviceConfigJsonChanged { get; set; }
private AbLegacyDeviceModel _model = new();
private string? _lastParsed;
protected override void OnParametersSet()
{
if (!string.Equals(_lastParsed, DeviceConfigJson, StringComparison.Ordinal))
{
_model = AbLegacyDeviceModel.FromJson(DeviceConfigJson);
_lastParsed = DeviceConfigJson;
}
}
/// <summary>Serializes the current endpoint to DeviceConfig JSON.</summary>
public string GetConfigJson() => _model.ToJson();
private async Task EmitAsync()
{
var json = GetConfigJson();
_lastParsed = json;
await DeviceConfigJsonChanged.InvokeAsync(json);
}
}
@@ -0,0 +1,51 @@
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.DeviceForms;
/// <summary>
/// Typed working model for an AB Legacy <c>Device</c>'s connection endpoint (the v3 endpoint→DeviceConfig
/// split). Authors the <b>PascalCase</b> <c>HostAddress</c>/<c>PlcFamily</c> keys that
/// <c>AbLegacyDeviceOptions</c> binds — the exact casing the seed and runtime probe
/// (<c>PropertyNameCaseInsensitive</c>) expect. Preserves unrecognised keys across a load→save.
/// </summary>
public sealed class AbLegacyDeviceModel
{
/// <summary>PLC host / IP address.</summary>
public string HostAddress { get; set; } = "";
/// <summary>Allen-Bradley legacy PLC family (SLC500 / MicroLogix / PLC-5).</summary>
public AbLegacyPlcFamily PlcFamily { get; set; } = AbLegacyPlcFamily.Slc500;
private System.Text.Json.Nodes.JsonObject _bag = new();
/// <summary>Loads a model from a DeviceConfig JSON string, defaulting absent keys and retaining
/// every original key so fields this editor doesn't expose survive a load→save.</summary>
/// <param name="json">The raw DeviceConfig JSON string, or null for a new/empty device.</param>
/// <returns>The populated <see cref="AbLegacyDeviceModel"/>.</returns>
public static AbLegacyDeviceModel FromJson(string? json)
{
var o = TagConfigJson.ParseOrNew(json);
return new AbLegacyDeviceModel
{
HostAddress = TagConfigJson.GetString(o, "HostAddress") ?? "",
PlcFamily = TagConfigJson.GetEnum(o, "PlcFamily", AbLegacyPlcFamily.Slc500),
_bag = o,
};
}
/// <summary>Serializes this model back to a DeviceConfig JSON string, writing the PascalCase endpoint
/// keys over the preserved key bag.</summary>
/// <returns>The serialised DeviceConfig JSON string.</returns>
public string ToJson()
{
TagConfigJson.Set(_bag, "HostAddress", HostAddress);
TagConfigJson.Set(_bag, "PlcFamily", PlcFamily);
return TagConfigJson.Serialize(_bag);
}
/// <summary>Validation hook; returns an error message or null when the model is valid.</summary>
/// <returns>An error message describing the validation failure, or <c>null</c> when the model is valid.</returns>
public string? Validate()
=> string.IsNullOrWhiteSpace(HostAddress) ? "Host address is required." : null;
}
@@ -0,0 +1,54 @@
@* Embeddable FOCAS device (connection endpoint) form body — authors HostAddress/Series into the
Device's DeviceConfig (PascalCase, matching FocasDeviceOptions). Hosted by DeviceModal. *@
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.DeviceForms
@using ZB.MOM.WW.OtOpcUa.Driver.FOCAS
<section class="panel rise" style="animation-delay:.04s">
<div class="panel-head">Connection</div>
<div style="padding:1rem">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Host address</label>
<InputText @bind-Value="_model.HostAddress" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono" placeholder="192.168.0.10:8193" />
</div>
<div class="col-md-6">
<label class="form-label">CNC series</label>
<InputSelect @bind-Value="_model.Series" @bind-Value:after="EmitAsync" class="form-select form-select-sm">
@foreach (var e in Enum.GetValues<FocasCncSeries>())
{
<option value="@e">@e</option>
}
</InputSelect>
</div>
</div>
</div>
</section>
@code {
/// <summary>The device's DeviceConfig JSON (endpoint/connection keys).</summary>
[Parameter] public string DeviceConfigJson { get; set; } = "{}";
/// <summary>Fired (PascalCase HostAddress/Series) whenever the endpoint changes — enables @bind-DeviceConfigJson.</summary>
[Parameter] public EventCallback<string> DeviceConfigJsonChanged { get; set; }
private FocasDeviceModel _model = new();
private string? _lastParsed;
protected override void OnParametersSet()
{
if (!string.Equals(_lastParsed, DeviceConfigJson, StringComparison.Ordinal))
{
_model = FocasDeviceModel.FromJson(DeviceConfigJson);
_lastParsed = DeviceConfigJson;
}
}
/// <summary>Serializes the current endpoint to DeviceConfig JSON.</summary>
public string GetConfigJson() => _model.ToJson();
private async Task EmitAsync()
{
var json = GetConfigJson();
_lastParsed = json;
await DeviceConfigJsonChanged.InvokeAsync(json);
}
}
@@ -0,0 +1,51 @@
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.DeviceForms;
/// <summary>
/// Typed working model for a FOCAS <c>Device</c>'s connection endpoint (the v3 endpoint→DeviceConfig
/// split). Authors the <b>PascalCase</b> <c>HostAddress</c>/<c>Series</c> keys that
/// <c>FocasDeviceOptions</c> binds — the exact casing the seed and runtime probe
/// (<c>PropertyNameCaseInsensitive</c>) expect. Preserves unrecognised keys across a load→save.
/// </summary>
public sealed class FocasDeviceModel
{
/// <summary>CNC host address (host or host:port, e.g. <c>192.168.0.10:8193</c>).</summary>
public string HostAddress { get; set; } = "";
/// <summary>CNC series, enabling per-series address validation.</summary>
public FocasCncSeries Series { get; set; } = FocasCncSeries.Unknown;
private System.Text.Json.Nodes.JsonObject _bag = new();
/// <summary>Loads a model from a DeviceConfig JSON string, defaulting absent keys and retaining
/// every original key so fields this editor doesn't expose survive a load→save.</summary>
/// <param name="json">The raw DeviceConfig JSON string, or null for a new/empty device.</param>
/// <returns>The populated <see cref="FocasDeviceModel"/>.</returns>
public static FocasDeviceModel FromJson(string? json)
{
var o = TagConfigJson.ParseOrNew(json);
return new FocasDeviceModel
{
HostAddress = TagConfigJson.GetString(o, "HostAddress") ?? "",
Series = TagConfigJson.GetEnum(o, "Series", FocasCncSeries.Unknown),
_bag = o,
};
}
/// <summary>Serializes this model back to a DeviceConfig JSON string, writing the PascalCase endpoint
/// keys over the preserved key bag.</summary>
/// <returns>The serialised DeviceConfig JSON string.</returns>
public string ToJson()
{
TagConfigJson.Set(_bag, "HostAddress", HostAddress);
TagConfigJson.Set(_bag, "Series", Series.ToString());
return TagConfigJson.Serialize(_bag);
}
/// <summary>Validation hook; returns an error message or null when the model is valid.</summary>
/// <returns>An error message describing the validation failure, or <c>null</c> when the model is valid.</returns>
public string? Validate()
=> string.IsNullOrWhiteSpace(HostAddress) ? "Host address is required." : null;
}
@@ -0,0 +1,34 @@
@* Galaxy device form — informational only. Galaxy connects to a single mxaccessgw gateway configured on
the driver, so there is no per-device connection endpoint to author. The DeviceConfig is round-tripped
verbatim (Test-connect uses the driver's gateway settings via the merged config). *@
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.DeviceForms
<section class="panel notice rise" style="animation-delay:.04s">
<div class="panel-head">Connection</div>
<div style="padding:1rem" class="text-muted">
Galaxy connects to a single <strong>mxaccessgw</strong> gateway configured on the driver — there is
no per-device endpoint to author here. Edit the gateway endpoint / API key on the driver's config.
</div>
</section>
@code {
/// <summary>The device's DeviceConfig JSON (round-tripped verbatim — Galaxy has no per-device endpoint).</summary>
[Parameter] public string DeviceConfigJson { get; set; } = "{}";
/// <summary>Fired when the (preserved) DeviceConfig changes — no-op UI, kept for the modal contract.</summary>
[Parameter] public EventCallback<string> DeviceConfigJsonChanged { get; set; }
private GalaxyDeviceModel _model = new();
private string? _lastParsed;
protected override void OnParametersSet()
{
if (!string.Equals(_lastParsed, DeviceConfigJson, StringComparison.Ordinal))
{
_model = GalaxyDeviceModel.FromJson(DeviceConfigJson);
_lastParsed = DeviceConfigJson;
}
}
/// <summary>Serializes the (preserved) DeviceConfig JSON.</summary>
public string GetConfigJson() => _model.ToJson();
}
@@ -0,0 +1,29 @@
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.DeviceForms;
/// <summary>
/// Device model for the AVEVA Galaxy driver. Galaxy connects to a SINGLE mxaccessgw gateway configured
/// on the <b>driver</b> (nested Gateway/MxAccess records) — there is no flat per-device connection
/// endpoint to author, so this model authors no endpoint keys. It simply round-trips the DeviceConfig
/// JSON verbatim (preserving any keys) so the device row stays valid. (Flagged: Galaxy has no v3
/// endpoint→DeviceConfig split; its connection lives on the driver config.)
/// </summary>
public sealed class GalaxyDeviceModel
{
private System.Text.Json.Nodes.JsonObject _bag = new();
/// <summary>Loads a model from a DeviceConfig JSON string, retaining every original key.</summary>
/// <param name="json">The raw DeviceConfig JSON string, or null for a new/empty device.</param>
/// <returns>The populated <see cref="GalaxyDeviceModel"/>.</returns>
public static GalaxyDeviceModel FromJson(string? json)
=> new() { _bag = TagConfigJson.ParseOrNew(json) };
/// <summary>Serializes the (preserved) DeviceConfig back to a JSON string.</summary>
/// <returns>The serialised DeviceConfig JSON string.</returns>
public string ToJson() => TagConfigJson.Serialize(_bag);
/// <summary>Validation hook; Galaxy device rows carry no endpoint, so always valid.</summary>
/// <returns><c>null</c> — no per-device endpoint to validate.</returns>
public string? Validate() => null;
}
@@ -0,0 +1,52 @@
@* Embeddable Modbus device (connection endpoint) form body — authors Host/Port/UnitId into the
Device's DeviceConfig (PascalCase, matching ModbusDriverOptions). Hosted by DeviceModal. *@
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.DeviceForms
<section class="panel rise" style="animation-delay:.04s">
<div class="panel-head">Connection</div>
<div style="padding:1rem">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Host</label>
<InputText @bind-Value="_model.Host" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono" placeholder="127.0.0.1" />
</div>
<div class="col-md-3">
<label class="form-label">Port</label>
<InputNumber @bind-Value="_model.Port" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
</div>
<div class="col-md-3">
<label class="form-label">Unit ID (slave ID)</label>
<InputNumber @bind-Value="_model.UnitId" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
</div>
</div>
</div>
</section>
@code {
/// <summary>The device's DeviceConfig JSON (endpoint/connection keys).</summary>
[Parameter] public string DeviceConfigJson { get; set; } = "{}";
/// <summary>Fired (PascalCase Host/Port/UnitId) whenever the endpoint changes — enables @bind-DeviceConfigJson.</summary>
[Parameter] public EventCallback<string> DeviceConfigJsonChanged { get; set; }
private ModbusDeviceModel _model = new();
private string? _lastParsed;
protected override void OnParametersSet()
{
if (!string.Equals(_lastParsed, DeviceConfigJson, StringComparison.Ordinal))
{
_model = ModbusDeviceModel.FromJson(DeviceConfigJson);
_lastParsed = DeviceConfigJson;
}
}
/// <summary>Serializes the current endpoint to DeviceConfig JSON.</summary>
public string GetConfigJson() => _model.ToJson();
private async Task EmitAsync()
{
var json = GetConfigJson();
_lastParsed = json;
await DeviceConfigJsonChanged.InvokeAsync(json);
}
}
@@ -0,0 +1,55 @@
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.DeviceForms;
/// <summary>
/// Typed working model for a Modbus <c>Device</c>'s connection endpoint (the v3 endpoint→DeviceConfig
/// split). Authors the <b>PascalCase</b> <c>Host</c>/<c>Port</c>/<c>UnitId</c> keys that
/// <c>ModbusDriverOptions</c> binds — the exact casing the seed and runtime probe
/// (<c>PropertyNameCaseInsensitive</c>) expect. Preserves unrecognised keys across a load→save.
/// </summary>
public sealed class ModbusDeviceModel
{
/// <summary>PLC host / IP address.</summary>
public string Host { get; set; } = "127.0.0.1";
/// <summary>Modbus/TCP port (usually 502).</summary>
public int Port { get; set; } = 502;
/// <summary>Modbus unit / slave id (0-255).</summary>
public int UnitId { get; set; } = 1;
private System.Text.Json.Nodes.JsonObject _bag = new();
/// <summary>Loads a model from a DeviceConfig JSON string, defaulting absent keys and retaining
/// every original key so fields this editor doesn't expose survive a load→save.</summary>
/// <param name="json">The raw DeviceConfig JSON string, or null for a new/empty device.</param>
/// <returns>The populated <see cref="ModbusDeviceModel"/>.</returns>
public static ModbusDeviceModel FromJson(string? json)
{
var o = TagConfigJson.ParseOrNew(json);
return new ModbusDeviceModel
{
Host = TagConfigJson.GetString(o, "Host") ?? "127.0.0.1",
Port = TagConfigJson.GetInt(o, "Port", 502),
UnitId = TagConfigJson.GetInt(o, "UnitId", 1),
_bag = o,
};
}
/// <summary>Serializes this model back to a DeviceConfig JSON string, writing the PascalCase endpoint
/// keys over the preserved key bag.</summary>
/// <returns>The serialised DeviceConfig JSON string.</returns>
public string ToJson()
{
TagConfigJson.Set(_bag, "Host", Host);
TagConfigJson.Set(_bag, "Port", Port);
TagConfigJson.Set(_bag, "UnitId", UnitId);
return TagConfigJson.Serialize(_bag);
}
/// <summary>Validation hook; returns an error message or null when the model is valid.</summary>
/// <returns>An error message describing the validation failure, or <c>null</c> when the model is valid.</returns>
public string? Validate()
=> string.IsNullOrWhiteSpace(Host) ? "Host is required." : null;
}
@@ -0,0 +1,45 @@
@* Embeddable OPC UA Client device (connection endpoint) form body — authors EndpointUrl into the
Device's DeviceConfig. Hosted by DeviceModal. *@
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.DeviceForms
<section class="panel rise" style="animation-delay:.04s">
<div class="panel-head">Endpoint</div>
<div style="padding:1rem">
<div class="row g-3">
<div class="col-12">
<label class="form-label">Endpoint URL</label>
<InputText @bind-Value="_model.EndpointUrl" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono" placeholder="opc.tcp://plc.internal:4840" />
<div class="form-text">The upstream server. Used only when the driver's EndpointUrls failover list is empty.</div>
</div>
</div>
</div>
</section>
@code {
/// <summary>The device's DeviceConfig JSON (endpoint/connection keys).</summary>
[Parameter] public string DeviceConfigJson { get; set; } = "{}";
/// <summary>Fired (PascalCase EndpointUrl) whenever the endpoint changes — enables @bind-DeviceConfigJson.</summary>
[Parameter] public EventCallback<string> DeviceConfigJsonChanged { get; set; }
private OpcUaClientDeviceModel _model = new();
private string? _lastParsed;
protected override void OnParametersSet()
{
if (!string.Equals(_lastParsed, DeviceConfigJson, StringComparison.Ordinal))
{
_model = OpcUaClientDeviceModel.FromJson(DeviceConfigJson);
_lastParsed = DeviceConfigJson;
}
}
/// <summary>Serializes the current endpoint to DeviceConfig JSON.</summary>
public string GetConfigJson() => _model.ToJson();
private async Task EmitAsync()
{
var json = GetConfigJson();
_lastParsed = json;
await DeviceConfigJsonChanged.InvokeAsync(json);
}
}
@@ -0,0 +1,50 @@
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.DeviceForms;
/// <summary>
/// Typed working model for an OPC UA Client <c>Device</c>'s connection endpoint. Authors the PascalCase
/// <c>EndpointUrl</c> key that <c>OpcUaClientDriverOptions</c> binds. (The driver-level <c>EndpointUrls</c>
/// failover list stays on the driver config; a device's <c>EndpointUrl</c> is used only when that list
/// is empty.) Preserves unrecognised keys across a load→save.
/// </summary>
public sealed class OpcUaClientDeviceModel
{
/// <summary>The single upstream server endpoint URL (opc.tcp://…).</summary>
public string EndpointUrl { get; set; } = "opc.tcp://localhost:4840";
private System.Text.Json.Nodes.JsonObject _bag = new();
/// <summary>Loads a model from a DeviceConfig JSON string, defaulting absent keys and retaining
/// every original key so fields this editor doesn't expose survive a load→save.</summary>
/// <param name="json">The raw DeviceConfig JSON string, or null for a new/empty device.</param>
/// <returns>The populated <see cref="OpcUaClientDeviceModel"/>.</returns>
public static OpcUaClientDeviceModel FromJson(string? json)
{
var o = TagConfigJson.ParseOrNew(json);
return new OpcUaClientDeviceModel
{
EndpointUrl = TagConfigJson.GetString(o, "EndpointUrl") ?? "opc.tcp://localhost:4840",
_bag = o,
};
}
/// <summary>Serializes this model back to a DeviceConfig JSON string, writing the PascalCase
/// <c>EndpointUrl</c> key over the preserved key bag.</summary>
/// <returns>The serialised DeviceConfig JSON string.</returns>
public string ToJson()
{
TagConfigJson.Set(_bag, "EndpointUrl", EndpointUrl);
return TagConfigJson.Serialize(_bag);
}
/// <summary>Validation hook; returns an error message or null when the model is valid.</summary>
/// <returns>An error message describing the validation failure, or <c>null</c> when the model is valid.</returns>
public string? Validate()
{
if (string.IsNullOrWhiteSpace(EndpointUrl)) return "Endpoint URL is required.";
if (!EndpointUrl.Trim().StartsWith("opc.tcp://", StringComparison.OrdinalIgnoreCase))
return "Endpoint URL must start with opc.tcp://";
return null;
}
}
@@ -0,0 +1,56 @@
@* Embeddable S7 device (connection endpoint) form body — authors Host/Port/Rack/Slot into the
Device's DeviceConfig (PascalCase, matching S7DriverOptions). Hosted by DeviceModal. *@
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.DeviceForms
<section class="panel rise" style="animation-delay:.04s">
<div class="panel-head">Connection</div>
<div style="padding:1rem">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Host</label>
<InputText @bind-Value="_model.Host" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono" placeholder="192.168.0.1" />
</div>
<div class="col-md-2">
<label class="form-label">Port</label>
<InputNumber @bind-Value="_model.Port" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
</div>
<div class="col-md-2">
<label class="form-label">Rack</label>
<InputNumber @bind-Value="_model.Rack" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
</div>
<div class="col-md-2">
<label class="form-label">Slot</label>
<InputNumber @bind-Value="_model.Slot" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
</div>
</div>
</div>
</section>
@code {
/// <summary>The device's DeviceConfig JSON (endpoint/connection keys).</summary>
[Parameter] public string DeviceConfigJson { get; set; } = "{}";
/// <summary>Fired (PascalCase Host/Port/Rack/Slot) whenever the endpoint changes — enables @bind-DeviceConfigJson.</summary>
[Parameter] public EventCallback<string> DeviceConfigJsonChanged { get; set; }
private S7DeviceModel _model = new();
private string? _lastParsed;
protected override void OnParametersSet()
{
if (!string.Equals(_lastParsed, DeviceConfigJson, StringComparison.Ordinal))
{
_model = S7DeviceModel.FromJson(DeviceConfigJson);
_lastParsed = DeviceConfigJson;
}
}
/// <summary>Serializes the current endpoint to DeviceConfig JSON.</summary>
public string GetConfigJson() => _model.ToJson();
private async Task EmitAsync()
{
var json = GetConfigJson();
_lastParsed = json;
await DeviceConfigJsonChanged.InvokeAsync(json);
}
}
@@ -0,0 +1,60 @@
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.DeviceForms;
/// <summary>
/// Typed working model for an S7 <c>Device</c>'s connection endpoint (the v3 endpoint→DeviceConfig
/// split). Authors the <b>PascalCase</b> <c>Host</c>/<c>Port</c>/<c>Rack</c>/<c>Slot</c> keys that
/// <c>S7DriverOptions</c> binds — the exact casing the seed and runtime probe
/// (<c>PropertyNameCaseInsensitive</c>) expect. Preserves unrecognised keys across a load→save.
/// </summary>
public sealed class S7DeviceModel
{
/// <summary>PLC host / IP address.</summary>
public string Host { get; set; } = "127.0.0.1";
/// <summary>ISO-on-TCP port (usually 102).</summary>
public int Port { get; set; } = 102;
/// <summary>PLC rack number (almost always 0).</summary>
public int Rack { get; set; } = 0;
/// <summary>PLC slot number (S7-300/400 = 2; S7-1200/1500 = 0).</summary>
public int Slot { get; set; } = 0;
private System.Text.Json.Nodes.JsonObject _bag = new();
/// <summary>Loads a model from a DeviceConfig JSON string, defaulting absent keys and retaining
/// every original key so fields this editor doesn't expose survive a load→save.</summary>
/// <param name="json">The raw DeviceConfig JSON string, or null for a new/empty device.</param>
/// <returns>The populated <see cref="S7DeviceModel"/>.</returns>
public static S7DeviceModel FromJson(string? json)
{
var o = TagConfigJson.ParseOrNew(json);
return new S7DeviceModel
{
Host = TagConfigJson.GetString(o, "Host") ?? "127.0.0.1",
Port = TagConfigJson.GetInt(o, "Port", 102),
Rack = TagConfigJson.GetInt(o, "Rack", 0),
Slot = TagConfigJson.GetInt(o, "Slot", 0),
_bag = o,
};
}
/// <summary>Serializes this model back to a DeviceConfig JSON string, writing the PascalCase endpoint
/// keys over the preserved key bag.</summary>
/// <returns>The serialised DeviceConfig JSON string.</returns>
public string ToJson()
{
TagConfigJson.Set(_bag, "Host", Host);
TagConfigJson.Set(_bag, "Port", Port);
TagConfigJson.Set(_bag, "Rack", Rack);
TagConfigJson.Set(_bag, "Slot", Slot);
return TagConfigJson.Serialize(_bag);
}
/// <summary>Validation hook; returns an error message or null when the model is valid.</summary>
/// <returns>An error message describing the validation failure, or <c>null</c> when the model is valid.</returns>
public string? Validate()
=> string.IsNullOrWhiteSpace(Host) ? "Host is required." : null;
}
@@ -0,0 +1,44 @@
@* Embeddable TwinCAT device (connection endpoint) form body — authors HostAddress (AMS Net Id:port) into
the Device's DeviceConfig (PascalCase, matching TwinCATDeviceOptions). Hosted by DeviceModal. *@
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.DeviceForms
<section class="panel rise" style="animation-delay:.04s">
<div class="panel-head">Connection</div>
<div style="padding:1rem">
<div class="row g-3">
<div class="col-md-12">
<label class="form-label">Host address (AMS Net Id:port)</label>
<InputText @bind-Value="_model.HostAddress" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono" placeholder="192.168.0.1.1.1:851" />
</div>
</div>
</div>
</section>
@code {
/// <summary>The device's DeviceConfig JSON (endpoint/connection keys).</summary>
[Parameter] public string DeviceConfigJson { get; set; } = "{}";
/// <summary>Fired (PascalCase HostAddress) whenever the endpoint changes — enables @bind-DeviceConfigJson.</summary>
[Parameter] public EventCallback<string> DeviceConfigJsonChanged { get; set; }
private TwinCATDeviceModel _model = new();
private string? _lastParsed;
protected override void OnParametersSet()
{
if (!string.Equals(_lastParsed, DeviceConfigJson, StringComparison.Ordinal))
{
_model = TwinCATDeviceModel.FromJson(DeviceConfigJson);
_lastParsed = DeviceConfigJson;
}
}
/// <summary>Serializes the current endpoint to DeviceConfig JSON.</summary>
public string GetConfigJson() => _model.ToJson();
private async Task EmitAsync()
{
var json = GetConfigJson();
_lastParsed = json;
await DeviceConfigJsonChanged.InvokeAsync(json);
}
}
@@ -0,0 +1,45 @@
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.DeviceForms;
/// <summary>
/// Typed working model for a TwinCAT <c>Device</c>'s connection endpoint (the v3 endpoint→DeviceConfig
/// split). Authors the <b>PascalCase</b> <c>HostAddress</c> key (the AMS Net Id:port host string) that
/// <c>TwinCATDeviceOptions</c> binds — the exact casing the seed and runtime probe
/// (<c>PropertyNameCaseInsensitive</c>) expect. Preserves unrecognised keys across a load→save.
/// </summary>
public sealed class TwinCATDeviceModel
{
/// <summary>AMS Net Id:port host string (e.g. <c>192.168.0.1.1.1:851</c>).</summary>
public string HostAddress { get; set; } = "";
private System.Text.Json.Nodes.JsonObject _bag = new();
/// <summary>Loads a model from a DeviceConfig JSON string, defaulting absent keys and retaining
/// every original key so fields this editor doesn't expose survive a load→save.</summary>
/// <param name="json">The raw DeviceConfig JSON string, or null for a new/empty device.</param>
/// <returns>The populated <see cref="TwinCATDeviceModel"/>.</returns>
public static TwinCATDeviceModel FromJson(string? json)
{
var o = TagConfigJson.ParseOrNew(json);
return new TwinCATDeviceModel
{
HostAddress = TagConfigJson.GetString(o, "HostAddress") ?? "",
_bag = o,
};
}
/// <summary>Serializes this model back to a DeviceConfig JSON string, writing the PascalCase endpoint
/// key over the preserved key bag.</summary>
/// <returns>The serialised DeviceConfig JSON string.</returns>
public string ToJson()
{
TagConfigJson.Set(_bag, "HostAddress", HostAddress);
return TagConfigJson.Serialize(_bag);
}
/// <summary>Validation hook; returns an error message or null when the model is valid.</summary>
/// <returns>An error message describing the validation failure, or <c>null</c> when the model is valid.</returns>
public string? Validate()
=> string.IsNullOrWhiteSpace(HostAddress) ? "Host address is required." : null;
}
@@ -0,0 +1,224 @@
@* Create / edit modal for a raw-tree Device's connection endpoint (v3 endpoint→DeviceConfig split).
Dispatches to the right per-driver device form by DriverType. Test-connect builds the merged
Driver+Device config transiently (so unsaved edits probe correctly), via DriverDeviceConfigMerger —
the same merge LoadMergedProbeConfigAsync uses. The coordinator wires this into RawTree via @bind-Visible. *@
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.DeviceForms
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns
@using ZB.MOM.WW.OtOpcUa.Commons.Types
@using ZB.MOM.WW.OtOpcUa.Core.Abstractions
@inject IRawTreeService Svc
@if (Visible)
{
<div class="modal-backdrop fade show" style="display:block"></div>
<div class="modal fade show" tabindex="-1" role="dialog" style="display:block">
<div class="modal-dialog modal-lg" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">@(_isNew ? "New device" : "Edit device") &middot; <span class="mono">@_driverType</span></h5>
<button type="button" class="btn-close" aria-label="Close" @onclick="CloseAsync"></button>
</div>
<div class="modal-body">
@if (_loadError is not null)
{
<div class="panel notice mb-3" style="border-color:var(--alert)">@_loadError</div>
}
else
{
<div class="row g-3 mb-2">
<div class="col-md-6">
<label class="form-label">Device name</label>
<input class="form-control form-control-sm mono" @bind="_name" placeholder="Device1" />
<div class="form-text">A RawPath segment — letters, digits, dash, underscore.</div>
</div>
<div class="col-md-3">
<div class="form-check form-switch mt-4">
<input type="checkbox" class="form-check-input" id="devEnabled" @bind="_enabled" />
<label class="form-check-label" for="devEnabled">Enabled</label>
</div>
</div>
</div>
@switch (_driverType)
{
case DriverTypeNames.Modbus:
<ModbusDeviceForm @bind-DeviceConfigJson="_deviceConfigJson" />
break;
case DriverTypeNames.S7:
<S7DeviceForm @bind-DeviceConfigJson="_deviceConfigJson" />
break;
case DriverTypeNames.AbCip:
<AbCipDeviceForm @bind-DeviceConfigJson="_deviceConfigJson" />
break;
case DriverTypeNames.AbLegacy:
<AbLegacyDeviceForm @bind-DeviceConfigJson="_deviceConfigJson" />
break;
case DriverTypeNames.TwinCAT:
<TwinCATDeviceForm @bind-DeviceConfigJson="_deviceConfigJson" />
break;
case DriverTypeNames.FOCAS:
<FocasDeviceForm @bind-DeviceConfigJson="_deviceConfigJson" />
break;
case DriverTypeNames.OpcUaClient:
<OpcUaClientDeviceForm @bind-DeviceConfigJson="_deviceConfigJson" />
break;
case DriverTypeNames.Galaxy:
<GalaxyDeviceForm @bind-DeviceConfigJson="_deviceConfigJson" />
break;
default:
<div class="alert alert-warning">No typed device form for driver type <span class="mono">@_driverType</span>.</div>
break;
}
<div class="mt-3">
<DriverTestConnectButton DriverType="@_driverType" GetConfigJson="@BuildMergedProbeConfig" />
</div>
@if (_saveError is not null)
{
<div class="panel notice mt-3" style="border-color:var(--alert)">@_saveError</div>
}
}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" @onclick="CloseAsync" disabled="@_busy">Cancel</button>
@if (_loadError is null)
{
<button type="button" class="btn btn-primary" @onclick="SaveAsync" disabled="@_busy">
@if (_busy) { <span class="spinner-border spinner-border-sm me-1"></span> }
@(_isNew ? "Create device" : "Save changes")
</button>
}
</div>
</div>
</div>
</div>
}
@code {
/// <summary>Whether the modal is shown (supports @bind-Visible).</summary>
[Parameter] public bool Visible { get; set; }
/// <summary>Two-way visibility callback.</summary>
[Parameter] public EventCallback<bool> VisibleChanged { get; set; }
/// <summary>The device to edit; null ⇒ create a new device under <see cref="DriverInstanceId"/>.</summary>
[Parameter] public string? DeviceId { get; set; }
/// <summary>The owning driver instance (required for create; resolved from the device on edit).</summary>
[Parameter] public string? DriverInstanceId { get; set; }
/// <summary>The driver type (required for create; joined in from the device on edit).</summary>
[Parameter] public string? DriverType { get; set; }
/// <summary>Raised after a successful create/update so the tree can refresh.</summary>
[Parameter] public EventCallback OnSaved { get; set; }
private bool _isNew;
private string _driverType = "";
private string? _resolvedDriverInstanceId;
private string _name = "";
private bool _enabled = true;
private string _deviceConfigJson = "{}";
private byte[] _rowVersion = [];
private string _parentDriverConfig = "{}";
private bool _busy;
private string? _loadError;
private string? _saveError;
private bool _openHandled;
protected override async Task OnParametersSetAsync()
{
if (Visible && !_openHandled)
{
_openHandled = true;
await LoadAsync();
}
else if (!Visible)
{
_openHandled = false;
}
}
private async Task LoadAsync()
{
_busy = false; _loadError = null; _saveError = null;
_isNew = string.IsNullOrEmpty(DeviceId);
if (_isNew)
{
_resolvedDriverInstanceId = DriverInstanceId;
_driverType = DriverType ?? "";
_name = "Device1";
_enabled = true;
_deviceConfigJson = "{}";
_rowVersion = [];
}
else
{
var dto = await Svc.LoadDeviceForEditAsync(DeviceId!);
if (dto is null) { _loadError = $"Device '{DeviceId}' was not found."; return; }
_resolvedDriverInstanceId = dto.DriverInstanceId;
_driverType = dto.DriverType;
_name = dto.Name;
_enabled = dto.Enabled;
_deviceConfigJson = string.IsNullOrWhiteSpace(dto.DeviceConfig) ? "{}" : dto.DeviceConfig;
_rowVersion = dto.RowVersion;
}
// The parent driver's DriverConfig — folded with the (possibly unsaved) DeviceConfig for Test-connect.
if (!string.IsNullOrEmpty(_resolvedDriverInstanceId))
{
var drv = await Svc.LoadDriverForEditAsync(_resolvedDriverInstanceId);
_parentDriverConfig = drv?.DriverConfig ?? "{}";
}
}
/// <summary>Builds the merged Driver+Device probe config from the CURRENT (possibly unsaved) form —
/// same merge as <c>LoadMergedProbeConfigAsync</c>, so Test-connect works before the device is saved.</summary>
private string BuildMergedProbeConfig()
=> DriverDeviceConfigMerger.Merge(
_parentDriverConfig,
new[] { new DriverDeviceConfigMerger.DeviceRow(_name, _deviceConfigJson) },
Array.Empty<RawTagEntry>());
private async Task SaveAsync()
{
_busy = true; _saveError = null;
try
{
UnsMutationResult result;
if (_isNew)
{
if (string.IsNullOrEmpty(_resolvedDriverInstanceId))
{
_saveError = "No owning driver instance was supplied.";
return;
}
result = await Svc.CreateDeviceAsync(_resolvedDriverInstanceId, _name, _deviceConfigJson);
}
else
{
result = await Svc.UpdateDeviceAsync(DeviceId!, _name, _deviceConfigJson, _enabled, _rowVersion);
}
if (!result.Ok)
{
_saveError = result.Error ?? "Save failed.";
return;
}
await OnSaved.InvokeAsync();
await SetVisibleAsync(false);
}
catch (Exception ex) { _saveError = ex.Message; }
finally { _busy = false; }
}
private Task CloseAsync() => SetVisibleAsync(false);
private async Task SetVisibleAsync(bool value)
{
Visible = value;
_openHandled = value;
await VisibleChanged.InvokeAsync(value);
}
}
@@ -0,0 +1,201 @@
@* Create / edit modal for a raw-tree DriverInstance's channel/protocol config (endpoint lives on the
device — see DeviceModal). Dispatches to the right per-driver form body by DriverType; the form body
carries its own resilience section. The coordinator wires this into RawTree via @bind-Visible. *@
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Forms
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns
@using ZB.MOM.WW.OtOpcUa.Core.Abstractions
@inject IRawTreeService Svc
@if (Visible)
{
<div class="modal-backdrop fade show" style="display:block"></div>
<div class="modal fade show" tabindex="-1" role="dialog" style="display:block">
<div class="modal-dialog modal-xl" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">@(_isNew ? "New driver" : "Configure driver") &middot; <span class="mono">@_driverType</span></h5>
<button type="button" class="btn-close" aria-label="Close" @onclick="CloseAsync"></button>
</div>
<div class="modal-body">
@if (_loadError is not null)
{
<div class="panel notice mb-3" style="border-color:var(--alert)">@_loadError</div>
}
else
{
<div class="row g-3 mb-2">
<div class="col-md-6">
<label class="form-label">Driver name</label>
<input class="form-control form-control-sm mono" @bind="_name" placeholder="line3-modbus" />
<div class="form-text">A RawPath segment — letters, digits, dash, underscore.</div>
</div>
</div>
@switch (_driverType)
{
case DriverTypeNames.Modbus:
<ModbusDriverForm @bind-DriverConfigJson="_driverConfigJson" @bind-ResilienceConfig="_resilienceConfig" />
break;
case DriverTypeNames.S7:
<S7DriverForm @bind-DriverConfigJson="_driverConfigJson" @bind-ResilienceConfig="_resilienceConfig" />
break;
case DriverTypeNames.AbCip:
<AbCipDriverForm @bind-DriverConfigJson="_driverConfigJson" @bind-ResilienceConfig="_resilienceConfig" />
break;
case DriverTypeNames.AbLegacy:
<AbLegacyDriverForm @bind-DriverConfigJson="_driverConfigJson" @bind-ResilienceConfig="_resilienceConfig" />
break;
case DriverTypeNames.TwinCAT:
<TwinCATDriverForm @bind-DriverConfigJson="_driverConfigJson" @bind-ResilienceConfig="_resilienceConfig" />
break;
case DriverTypeNames.FOCAS:
<FocasDriverForm @bind-DriverConfigJson="_driverConfigJson" @bind-ResilienceConfig="_resilienceConfig" />
break;
case DriverTypeNames.OpcUaClient:
<OpcUaClientDriverForm @bind-DriverConfigJson="_driverConfigJson" @bind-ResilienceConfig="_resilienceConfig" />
break;
case DriverTypeNames.Galaxy:
<GalaxyDriverForm @bind-DriverConfigJson="_driverConfigJson" @bind-ResilienceConfig="_resilienceConfig" />
break;
default:
<div class="alert alert-warning">No typed config form for driver type <span class="mono">@_driverType</span>.</div>
break;
}
<p class="form-text mt-3 mb-0">
The connection endpoint + Test-connect live on the driver's <strong>device</strong> — open the device modal to author host/port and verify connectivity.
</p>
@if (_saveError is not null)
{
<div class="panel notice mt-3" style="border-color:var(--alert)">@_saveError</div>
}
}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" @onclick="CloseAsync" disabled="@_busy">Cancel</button>
@if (_loadError is null)
{
<button type="button" class="btn btn-primary" @onclick="SaveAsync" disabled="@_busy">
@if (_busy) { <span class="spinner-border spinner-border-sm me-1"></span> }
@(_isNew ? "Create driver" : "Save changes")
</button>
}
</div>
</div>
</div>
</div>
}
@code {
/// <summary>Whether the modal is shown (supports @bind-Visible).</summary>
[Parameter] public bool Visible { get; set; }
/// <summary>Two-way visibility callback.</summary>
[Parameter] public EventCallback<bool> VisibleChanged { get; set; }
/// <summary>The driver instance to edit; null ⇒ create a new driver.</summary>
[Parameter] public string? DriverInstanceId { get; set; }
// --- Create-mode inputs (ignored on edit) ---
/// <summary>The owning cluster (required for create).</summary>
[Parameter] public string? ClusterId { get; set; }
/// <summary>The containing folder, or null for a cluster-root driver (create).</summary>
[Parameter] public string? FolderId { get; set; }
/// <summary>The driver type to create (required for create; immutable on edit).</summary>
[Parameter] public string? DriverType { get; set; }
/// <summary>Raised after a successful create/update so the tree can refresh.</summary>
[Parameter] public EventCallback OnSaved { get; set; }
private bool _isNew;
private string _driverType = "";
private string _name = "";
private string _driverConfigJson = "{}";
private string? _resilienceConfig;
private byte[] _rowVersion = [];
private bool _busy;
private string? _loadError;
private string? _saveError;
private bool _openHandled;
protected override async Task OnParametersSetAsync()
{
if (Visible && !_openHandled)
{
_openHandled = true;
await LoadAsync();
}
else if (!Visible)
{
_openHandled = false;
}
}
private async Task LoadAsync()
{
_busy = false; _loadError = null; _saveError = null;
_isNew = string.IsNullOrEmpty(DriverInstanceId);
if (_isNew)
{
_driverType = DriverType ?? "";
_name = "";
_driverConfigJson = "{}";
_resilienceConfig = null;
_rowVersion = [];
}
else
{
var dto = await Svc.LoadDriverForEditAsync(DriverInstanceId!);
if (dto is null) { _loadError = $"Driver '{DriverInstanceId}' was not found."; return; }
_driverType = dto.DriverType;
_name = dto.Name;
_driverConfigJson = string.IsNullOrWhiteSpace(dto.DriverConfig) ? "{}" : dto.DriverConfig;
_resilienceConfig = dto.ResilienceConfig;
_rowVersion = dto.RowVersion;
}
}
private async Task SaveAsync()
{
_busy = true; _saveError = null;
try
{
UnsMutationResult result;
if (_isNew)
{
if (string.IsNullOrEmpty(ClusterId))
{
_saveError = "No owning cluster was supplied.";
return;
}
result = await Svc.CreateDriverAsync(ClusterId, FolderId, _name, _driverType, _driverConfigJson);
}
else
{
result = await Svc.UpdateDriverAsync(DriverInstanceId!, _name, _driverConfigJson, _resilienceConfig, _rowVersion);
}
if (!result.Ok)
{
_saveError = result.Error ?? "Save failed.";
return;
}
await OnSaved.InvokeAsync();
await SetVisibleAsync(false);
}
catch (Exception ex) { _saveError = ex.Message; }
finally { _busy = false; }
}
private Task CloseAsync() => SetVisibleAsync(false);
private async Task SetVisibleAsync(bool value)
{
Visible = value;
_openHandled = value;
await VisibleChanged.InvokeAsync(value);
}
}
@@ -0,0 +1,215 @@
@* Embeddable AB CIP driver (protocol/operation) config form body. The Devices collection moved out to
AbCipDeviceForm in v3 (its devices are separate entities); this form preserves the driver's existing
Devices across a load→save and the merge reconstructs the array from the device rows at deploy/probe
time. Hosted by the routed AbCipDriverPage and by DriverConfigModal. Exposes GetConfigJson() for the
parent to pull at save + Test-connect time, and fires DriverConfigJsonChanged for @bind support. *@
@using System.Text.Json
@using System.Text.Json.Serialization
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
@using ZB.MOM.WW.OtOpcUa.Driver.AbCip
@* 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" @bind-Value:after="EmitAsync" 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" @bind-Value:after="EmitAsync" 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" @bind-Value:after="EmitAsync" 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>
@* 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" @bind-Value:after="EmitAsync" 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" @bind-Value:after="EmitAsync" 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" @bind-Value:after="EmitAsync" 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" @bind-Value:after="EmitAsync" 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" @bind-Value:after="EmitAsync" 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" @bind-Value:after="EmitAsync" 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" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
<div class="form-text">Max 60. Used by Test Connect. Default 5.</div>
</div>
</div>
</div>
</section>
<DriverResilienceSection ResilienceConfig="@ResilienceConfig" ResilienceConfigChanged="OnResilienceChanged" />
@code {
/// <summary>The driver-level DriverConfig JSON (protocol/operation; endpoints live on the devices).</summary>
[Parameter] public string DriverConfigJson { get; set; } = "{}";
/// <summary>Fired (camelCase-serialized) whenever a field changes — enables @bind-DriverConfigJson.</summary>
[Parameter] public EventCallback<string> DriverConfigJsonChanged { get; set; }
/// <summary>The per-instance resilience-pipeline overrides JSON, or null.</summary>
[Parameter] public string? ResilienceConfig { get; set; }
/// <summary>Fired when the resilience overrides change.</summary>
[Parameter] public EventCallback<string?> ResilienceConfigChanged { get; set; }
private static readonly JsonSerializerOptions _jsonOpts = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
WriteIndented = false,
Converters = { new JsonStringEnumConverter() },
};
private FormModel _form = new();
private string? _lastParsed;
// Preserve the driver's existing Devices across a load→save (the device form authors the real endpoints;
// the merge reconstructs the Devices array from the device rows at deploy/probe time).
private IReadOnlyList<AbCipDeviceOptions> _preservedDevices = [];
protected override void OnParametersSet()
{
// Re-parse only when the inbound value actually changed (don't clobber in-progress edits).
if (!string.Equals(_lastParsed, DriverConfigJson, StringComparison.Ordinal))
{
var opts = TryDeserialize(DriverConfigJson) ?? new AbCipDriverOptions();
_form = FormModel.FromOptions(opts);
_preservedDevices = opts.Devices;
_lastParsed = DriverConfigJson;
}
}
/// <summary>Serializes the current protocol/operation config to camelCase JSON, carrying the preserved
/// Devices array forward (the device form authors the real endpoints; the merge reconstructs the array
/// from the device rows at deploy/probe time).</summary>
public string GetConfigJson()
=> JsonSerializer.Serialize(_form.ToOptions(_preservedDevices), _jsonOpts);
private async Task EmitAsync()
{
var json = GetConfigJson();
_lastParsed = json;
await DriverConfigJsonChanged.InvokeAsync(json);
}
private async Task OnResilienceChanged(string? r)
{
ResilienceConfig = r;
await ResilienceConfigChanged.InvokeAsync(r);
}
private static AbCipDriverOptions? TryDeserialize(string json)
{
try { return JsonSerializer.Deserialize<AbCipDriverOptions>(json, _jsonOpts); }
catch { return null; }
}
// Flat mutable model — all scalar properties settable for Blazor @bind-Value.
// Collections (Devices) are preserved 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;
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,
};
}
}
@@ -0,0 +1,170 @@
@* Embeddable AB Legacy driver (channel/protocol) config form body. The Devices collection is authored
as separate v3 entities (the merge rebuilds the Devices array at deploy/probe time), so this form does
NOT edit it — the driver's existing devices are preserved verbatim. Hosted by the routed
AbLegacyDriverPage. Exposes GetConfigJson() for the parent to pull at save + Test-connect time, and
fires DriverConfigJsonChanged for @bind support. *@
@using System.Text.Json
@using System.Text.Json.Serialization
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
@using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy
@using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies
@* 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" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
<div class="form-text">Default read/write timeout. Default 2 s.</div>
</div>
</div>
</div>
</section>
@* 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" @bind-Value:after="EmitAsync" 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" @bind-Value:after="EmitAsync" 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" @bind-Value:after="EmitAsync" 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" @bind-Value:after="EmitAsync" 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" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
<div class="form-text">Max 60. Used by Test Connect. Default 5.</div>
</div>
</div>
</div>
</section>
<DriverResilienceSection ResilienceConfig="@ResilienceConfig" ResilienceConfigChanged="OnResilienceChanged" />
@code {
/// <summary>The driver-level DriverConfig JSON (protocol/channel; devices live on separate v3 entities).</summary>
[Parameter] public string DriverConfigJson { get; set; } = "{}";
/// <summary>Fired (camelCase-serialized) whenever a channel field changes — enables @bind-DriverConfigJson.</summary>
[Parameter] public EventCallback<string> DriverConfigJsonChanged { get; set; }
/// <summary>The per-instance resilience-pipeline overrides JSON, or null.</summary>
[Parameter] public string? ResilienceConfig { get; set; }
/// <summary>Fired when the resilience overrides change.</summary>
[Parameter] public EventCallback<string?> ResilienceConfigChanged { get; set; }
private static readonly JsonSerializerOptions _jsonOpts = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
WriteIndented = false,
Converters = { new JsonStringEnumConverter() },
};
private FormModel _form = new();
private string? _lastParsed;
// Devices are separate v3 entities — this form doesn't edit them, but preserves the driver's existing
// Devices array verbatim across a load→save (the merge rebuilds it at deploy/probe time).
private IReadOnlyList<AbLegacyDeviceOptions> _preservedDevices = [];
protected override void OnParametersSet()
{
// Re-parse only when the inbound value actually changed (don't clobber in-progress edits).
if (!string.Equals(_lastParsed, DriverConfigJson, StringComparison.Ordinal))
{
var opts = TryDeserialize(DriverConfigJson) ?? new AbLegacyDriverOptions();
_form = FormModel.FromOptions(opts);
_preservedDevices = opts.Devices;
_lastParsed = DriverConfigJson;
}
}
/// <summary>Serializes the current channel/protocol config to camelCase JSON, preserving the driver's
/// existing Devices array (authored as separate v3 entities).</summary>
public string GetConfigJson()
=> JsonSerializer.Serialize(_form.ToOptions(_preservedDevices), _jsonOpts);
private async Task EmitAsync()
{
var json = GetConfigJson();
_lastParsed = json;
await DriverConfigJsonChanged.InvokeAsync(json);
}
private async Task OnResilienceChanged(string? r)
{
ResilienceConfig = r;
await ResilienceConfigChanged.InvokeAsync(r);
}
private static AbLegacyDriverOptions? TryDeserialize(string json)
{
try { return JsonSerializer.Deserialize<AbLegacyDriverOptions>(json, _jsonOpts); }
catch { 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,
};
}
}
@@ -0,0 +1,282 @@
@* Embeddable Fanuc FOCAS driver (channel/protocol) config form body. Endpoint fields (per-device
HostAddress/Series) moved to FocasDeviceForm in v3 (they live in the Device's DeviceConfig / the
multi-device Devices collection). Hosted by the routed FocasDriverPage and by DriverConfigModal.
Exposes GetConfigJson() for the parent to pull at save + Test-connect time, and fires
DriverConfigJsonChanged for @bind support. *@
@using System.Text.Json
@using System.Text.Json.Serialization
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
@using ZB.MOM.WW.OtOpcUa.Driver.FOCAS
@* 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" @bind-Value:after="EmitAsync"
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" @bind-Value:after="EmitAsync"
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" @bind-Value:after="EmitAsync"
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" @bind-Value:after="EmitAsync"
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" @bind-Value:after="EmitAsync"
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" @bind-Value:after="EmitAsync"
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" @bind-Value:after="EmitAsync"
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" @bind-Value:after="EmitAsync"
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" @bind-Value:after="EmitAsync"
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" @bind-Value:after="EmitAsync"
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" @bind-Value:after="EmitAsync"
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" @bind-Value:after="EmitAsync"
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" @bind-Value:after="EmitAsync"
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>
<DriverResilienceSection ResilienceConfig="@ResilienceConfig" ResilienceConfigChanged="OnResilienceChanged" />
@code {
/// <summary>The driver-level DriverConfig JSON (protocol/channel; per-device endpoint lives in the device).</summary>
[Parameter] public string DriverConfigJson { get; set; } = "{}";
/// <summary>Fired (camelCase-serialized) whenever a channel field changes — enables @bind-DriverConfigJson.</summary>
[Parameter] public EventCallback<string> DriverConfigJsonChanged { get; set; }
/// <summary>The per-instance resilience-pipeline overrides JSON, or null.</summary>
[Parameter] public string? ResilienceConfig { get; set; }
/// <summary>Fired when the resilience overrides change.</summary>
[Parameter] public EventCallback<string?> ResilienceConfigChanged { get; set; }
private static readonly JsonSerializerOptions _jsonOpts = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
WriteIndented = false,
Converters = { new JsonStringEnumConverter() },
};
private FormModel _form = new();
private string? _lastParsed;
// FOCAS is multi-device; the Devices collection is authored on the Raw tree, not on this form.
// Preserve whatever devices the persisted config carried so a driver-form load→save round-trip
// never drops them.
private IReadOnlyList<FocasDeviceOptions> _preservedDevices = [];
protected override void OnParametersSet()
{
// Re-parse only when the inbound value actually changed (don't clobber in-progress edits).
if (!string.Equals(_lastParsed, DriverConfigJson, StringComparison.Ordinal))
{
var opts = TryDeserialize(DriverConfigJson) ?? new FocasDriverOptions();
_form = FormModel.FromOptions(opts);
_preservedDevices = opts.Devices;
_lastParsed = DriverConfigJson;
}
}
/// <summary>Serializes the current channel/protocol config to camelCase JSON, re-attaching the
/// preserved multi-device collection (authored on the Raw tree).</summary>
public string GetConfigJson()
=> JsonSerializer.Serialize(_form.ToOptions(_preservedDevices), _jsonOpts);
private async Task EmitAsync()
{
var json = GetConfigJson();
_lastParsed = json;
await DriverConfigJsonChanged.InvokeAsync(json);
}
private async Task OnResilienceChanged(string? r)
{
ResilienceConfig = r;
await ResilienceConfigChanged.InvokeAsync(r);
}
private static FocasDriverOptions? TryDeserialize(string json)
{
try { return JsonSerializer.Deserialize<FocasDriverOptions>(json, _jsonOpts); }
catch { 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,
};
}
}
@@ -0,0 +1,293 @@
@* Embeddable AVEVA Galaxy driver config form body. Galaxy connects to a SINGLE mxaccessgw gateway
(nested Gateway/MxAccess/Repository/Reconnect records) — there is no flat per-device endpoint to split
out, so the whole connection stays here (GalaxyDeviceForm is informational only). Hosted by the routed
page + DriverConfigModal. *@
@using System.Text.Json
@using System.Text.Json.Serialization
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
@using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config
@* 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" @bind-Value:after="EmitAsync" 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" @bind-Value:after="EmitAsync" 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" @bind-Value:after="EmitAsync" 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" @bind-Value:after="EmitAsync" 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" @bind-Value:after="EmitAsync" 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" @bind-Value:after="EmitAsync" 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" @bind-Value:after="EmitAsync" 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" @bind-Value:after="EmitAsync" 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" @bind-Value:after="EmitAsync" 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" @bind-Value:after="EmitAsync" 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" @bind-Value:after="EmitAsync" 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" @bind-Value:after="EmitAsync" 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" @bind-Value:after="EmitAsync" 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" @bind-Value:after="EmitAsync" 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" @bind-Value:after="EmitAsync" 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" @bind-Value:after="EmitAsync" 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" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
<div class="form-text">Max 60. Used by Test Connect. Default 30.</div>
</div>
</div>
</div>
</section>
<DriverResilienceSection ResilienceConfig="@ResilienceConfig" ResilienceConfigChanged="OnResilienceChanged" />
@code {
[Parameter] public string DriverConfigJson { get; set; } = "{}";
[Parameter] public EventCallback<string> DriverConfigJsonChanged { get; set; }
[Parameter] public string? ResilienceConfig { get; set; }
[Parameter] public EventCallback<string?> ResilienceConfigChanged { get; set; }
private static readonly JsonSerializerOptions _jsonOpts = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
WriteIndented = false,
Converters = { new JsonStringEnumConverter() },
};
private FormModel _form = new();
private string? _lastParsed;
protected override void OnParametersSet()
{
if (!string.Equals(_lastParsed, DriverConfigJson, StringComparison.Ordinal))
{
var opts = TryDeserialize(DriverConfigJson) ?? CreateDefaultOptions();
_form = new FormModel { Galaxy = GalaxyFormModel.FromRecord(opts) };
_lastParsed = DriverConfigJson;
}
}
public string GetConfigJson()
=> JsonSerializer.Serialize(_form.Galaxy.ToRecord(), _jsonOpts);
private async Task EmitAsync()
{
var json = GetConfigJson();
_lastParsed = json;
await DriverConfigJsonChanged.InvokeAsync(json);
}
private async Task OnResilienceChanged(string? r)
{
ResilienceConfig = r;
await ResilienceConfigChanged.InvokeAsync(r);
}
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 static GalaxyDriverOptions? TryDeserialize(string json)
{
try { return JsonSerializer.Deserialize<GalaxyDriverOptions>(json, _jsonOpts); }
catch { return null; }
}
public sealed class FormModel
{
public GalaxyFormModel Galaxy { get; set; } = new();
}
/// <summary>Mutable flat mirror of <see cref="GalaxyDriverOptions"/> and its nested records.</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)
{
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,
};
}
}
@@ -0,0 +1,399 @@
@* Embeddable Modbus driver (channel/protocol) config form body. Endpoint fields (Host/Port/UnitId)
moved to ModbusDeviceForm in v3 (they live in Device.DeviceConfig). Hosted by the routed
ModbusDriverPage and by DriverConfigModal. Exposes GetConfigJson() for the parent to pull at save +
Test-connect time, and fires DriverConfigJsonChanged for @bind support. *@
@using System.Text.Json
@using System.Text.Json.Serialization
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
@using ZB.MOM.WW.OtOpcUa.Driver.Modbus
@* Family / transport-flags *@
<section class="panel rise mt-3" style="animation-delay:.06s">
<div class="panel-head">Protocol</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" @bind-Value:after="EmitAsync" 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" @bind-Value:after="EmitAsync" 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" @bind-Value:after="EmitAsync" 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" @bind-Value:after="EmitAsync" class="form-check-input" id="autoReconnect" />
<label class="form-check-label" for="autoReconnect">Auto-reconnect</label>
</div>
</div>
</div>
</div>
</section>
@* Batch sizes *@
<section class="panel rise mt-3" style="animation-delay:.08s">
<div class="panel-head">Batch sizes</div>
<div style="padding:1rem">
<div class="row g-3">
<div class="col-md-3">
<label class="form-label">Max registers per read</label>
<InputNumber @bind-Value="_form.MaxRegistersPerRead" @bind-Value:after="EmitAsync" 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" @bind-Value:after="EmitAsync" 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" @bind-Value:after="EmitAsync" 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" @bind-Value:after="EmitAsync" 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" @bind-Value:after="EmitAsync" 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" @bind-Value:after="EmitAsync" 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" @bind-Value:after="EmitAsync" 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" @bind-Value:after="EmitAsync" 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" @bind-Value:after="EmitAsync" 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" @bind-Value:after="EmitAsync" 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" @bind-Value:after="EmitAsync" 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" @bind-Value:after="EmitAsync" 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" @bind-Value:after="EmitAsync" 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" @bind-Value:after="EmitAsync" 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" @bind-Value:after="EmitAsync" 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" @bind-Value:after="EmitAsync" 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" @bind-Value:after="EmitAsync" 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" @bind-Value:after="EmitAsync" 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" @bind-Value:after="EmitAsync" 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" @bind-Value:after="EmitAsync" 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" @bind-Value:after="EmitAsync" 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" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
<div class="form-text">Interval to retry auto-prohibited coalesced ranges.</div>
</div>
</div>
</div>
</section>
<DriverResilienceSection ResilienceConfig="@ResilienceConfig" ResilienceConfigChanged="OnResilienceChanged" />
@code {
/// <summary>The driver-level DriverConfig JSON (protocol/channel; endpoint lives in the device).</summary>
[Parameter] public string DriverConfigJson { get; set; } = "{}";
/// <summary>Fired (camelCase-serialized) whenever a channel field changes — enables @bind-DriverConfigJson.</summary>
[Parameter] public EventCallback<string> DriverConfigJsonChanged { get; set; }
/// <summary>The per-instance resilience-pipeline overrides JSON, or null.</summary>
[Parameter] public string? ResilienceConfig { get; set; }
/// <summary>Fired when the resilience overrides change.</summary>
[Parameter] public EventCallback<string?> ResilienceConfigChanged { get; set; }
private static readonly JsonSerializerOptions _jsonOpts = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
WriteIndented = false,
Converters = { new JsonStringEnumConverter() },
};
private FormModel _form = new();
private string? _lastParsed;
protected override void OnParametersSet()
{
// Re-parse only when the inbound value actually changed (don't clobber in-progress edits).
if (!string.Equals(_lastParsed, DriverConfigJson, StringComparison.Ordinal))
{
var opts = TryDeserialize(DriverConfigJson) ?? new ModbusDriverOptions();
_form = FormModel.FromOptions(opts);
_lastParsed = DriverConfigJson;
}
}
/// <summary>Serializes the current channel/protocol config to camelCase JSON (endpoint excluded from the UI,
/// carried at harmless defaults; the device's DeviceConfig merges its endpoint up at deploy/probe time).</summary>
public string GetConfigJson()
=> JsonSerializer.Serialize(_form.ToOptions(), _jsonOpts);
private async Task EmitAsync()
{
var json = GetConfigJson();
_lastParsed = json;
await DriverConfigJsonChanged.InvokeAsync(json);
}
private async Task OnResilienceChanged(string? r)
{
ResilienceConfig = r;
await ResilienceConfigChanged.InvokeAsync(r);
}
private static ModbusDriverOptions? TryDeserialize(string json)
{
try { return JsonSerializer.Deserialize<ModbusDriverOptions>(json, _jsonOpts); }
catch { return null; }
}
// Flat mutable model — retains Host/Port/UnitId at defaults (no UI; the device form authors the real
// endpoint into DeviceConfig, which merges up and overrides these at deploy/probe time).
public sealed class FormModel
{
// Transport endpoint (no UI — authored via the device form; kept at default so ToOptions is valid)
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 (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;
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,
};
}
}
@@ -0,0 +1,367 @@
@* Embeddable OPC UA Client driver config form body. The single connection Endpoint URL moved to
OpcUaClientDeviceForm (Device.DeviceConfig) in v3; the EndpointUrls FAILOVER LIST stays here (it is a
driver-level policy). Hosted by the routed page + DriverConfigModal. *@
@using System.Text.Json
@using System.Text.Json.Serialization
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
@using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient
@* Endpoint (single Endpoint URL moved to the device form) *@
<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-4">
<label class="form-label">Browse root NodeId (blank = ObjectsFolder)</label>
<InputText @bind-Value="_form.OpcUa.BrowseRoot" @bind-Value:after="EmitAsync" 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" @bind-Value:after="EmitAsync" 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" @bind-Value:after="EmitAsync" 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" @bind-Value:after="EmitAsync" 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" @bind-Value:after="EmitAsync" 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" @bind-Value:after="EmitAsync" 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" @bind-Value:after="EmitAsync" 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" @bind-Value:after="EmitAsync" 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" @bind-Value:after="EmitAsync" 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" @bind-Value:after="EmitAsync" 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" @bind-Value:after="EmitAsync" 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" ItemsChanged="EmitAsync"
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">Failover list. The per-device Endpoint URL (on the device) is used only when this list is empty.</div>
</EditTemplate>
</CollectionEditor>
</div>
</div>
</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" @bind-Value:after="EmitAsync" 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" @bind-Value:after="EmitAsync" 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" @bind-Value:after="EmitAsync" 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" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
</div>
<div class="col-md-4">
<label class="form-label">Password</label>
<InputText @bind-Value="_form.OpcUa.Password" @bind-Value:after="EmitAsync" 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" @bind-Value:after="EmitAsync" 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" @bind-Value:after="EmitAsync" 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" @bind-Value:after="EmitAsync" 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" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
<div class="form-text">Max 60. Used by Test Connect. Default 15.</div>
</div>
</div>
</div>
</section>
<DriverResilienceSection ResilienceConfig="@ResilienceConfig" ResilienceConfigChanged="OnResilienceChanged" />
@code {
[Parameter] public string DriverConfigJson { get; set; } = "{}";
[Parameter] public EventCallback<string> DriverConfigJsonChanged { get; set; }
[Parameter] public string? ResilienceConfig { get; set; }
[Parameter] public EventCallback<string?> ResilienceConfigChanged { get; set; }
private static readonly JsonSerializerOptions _jsonOpts = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
WriteIndented = false,
Converters = { new JsonStringEnumConverter() },
};
private FormModel _form = new();
private List<EndpointUrlRow> _endpoints = [];
private string _unsMappingTableJson = "{}";
private string? _lastParsed;
protected override void OnParametersSet()
{
if (!string.Equals(_lastParsed, DriverConfigJson, StringComparison.Ordinal))
{
var opts = TryDeserialize(DriverConfigJson) ?? new OpcUaClientDriverOptions();
_form = new FormModel { OpcUa = OpcUaClientFormModel.FromRecord(opts) };
_endpoints = opts.EndpointUrls.Select(EndpointUrlRow.FromUrl).ToList();
_unsMappingTableJson = JsonSerializer.Serialize(opts.UnsMappingTable, _jsonOpts);
_lastParsed = DriverConfigJson;
}
}
public string GetConfigJson()
=> JsonSerializer.Serialize(_form.OpcUa.ToRecord(_endpoints.Select(r => r.ToUrl()).ToList()), _jsonOpts);
private async Task EmitAsync()
{
var json = GetConfigJson();
_lastParsed = json;
await DriverConfigJsonChanged.InvokeAsync(json);
}
private async Task OnResilienceChanged(string? r)
{
ResilienceConfig = r;
await ResilienceConfigChanged.InvokeAsync(r);
}
private static OpcUaClientDriverOptions? TryDeserialize(string json)
{
try { return JsonSerializer.Deserialize<OpcUaClientDriverOptions>(json, _jsonOpts); }
catch { return null; }
}
public sealed class FormModel
{
public OpcUaClientFormModel OpcUa { get; set; } = new();
}
/// <summary>Mutable VM for a single failover endpoint URL row.</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"/> (int wrappers for TimeSpans).
/// EndpointUrl (single) has NO UI — the device form authors it; kept at default so ToRecord is valid.</summary>
public sealed class OpcUaClientFormModel
{
// Connection (EndpointUrl authored on the device — no UI here)
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;
public OpcUaSecurityMode SecurityMode { get; set; } = OpcUaSecurityMode.None;
public OpcUaSecurityPolicy SecurityPolicy { get; set; } = OpcUaSecurityPolicy.None;
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; }
public OpcUaTargetNamespaceKind TargetNamespaceKind { get; set; } = OpcUaTargetNamespaceKind.Equipment;
public int ProbeTimeoutSeconds { get; set; } = 15;
internal IReadOnlyDictionary<string, string> _unsMappingTable = new 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,
};
}
}
@@ -0,0 +1,177 @@
@* Embeddable Siemens S7 driver (protocol) config form body. Endpoint fields (Host/Port/Rack/Slot)
moved to S7DeviceForm in v3 (they live in Device.DeviceConfig). Hosted by the routed
S7DriverPage and by DriverConfigModal. Exposes GetConfigJson() for the parent to pull at save +
Test-connect time, and fires DriverConfigJsonChanged for @bind support. *@
@using System.Text.Json
@using System.Text.Json.Serialization
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
@using ZB.MOM.WW.OtOpcUa.Driver.S7
@* 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-4 mb-3">
<label class="form-label" for="s7TimeoutSec">Timeout (seconds)</label>
<InputNumber id="s7TimeoutSec" @bind-Value="_form.TimeoutSeconds" @bind-Value:after="EmitAsync"
class="form-control form-control-sm" />
</div>
<div class="col-md-4 mb-3">
<label class="form-label" for="s7CpuType">CPU type</label>
<InputSelect id="s7CpuType" @bind-Value="_form.CpuType" @bind-Value:after="EmitAsync"
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>
</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" @bind-Value:after="EmitAsync"
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" @bind-Value:after="EmitAsync"
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" @bind-Value:after="EmitAsync"
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" @bind-Value:after="EmitAsync"
class="form-control form-control-sm" />
<div class="form-text">Test Connect timeout (160 s).</div>
</div>
</div>
</div>
</section>
<DriverResilienceSection ResilienceConfig="@ResilienceConfig" ResilienceConfigChanged="OnResilienceChanged" />
@code {
/// <summary>The driver-level DriverConfig JSON (protocol; endpoint lives in the device).</summary>
[Parameter] public string DriverConfigJson { get; set; } = "{}";
/// <summary>Fired (camelCase-serialized) whenever a protocol field changes — enables @bind-DriverConfigJson.</summary>
[Parameter] public EventCallback<string> DriverConfigJsonChanged { get; set; }
/// <summary>The per-instance resilience-pipeline overrides JSON, or null.</summary>
[Parameter] public string? ResilienceConfig { get; set; }
/// <summary>Fired when the resilience overrides change.</summary>
[Parameter] public EventCallback<string?> ResilienceConfigChanged { get; set; }
private static readonly JsonSerializerOptions _jsonOpts = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
WriteIndented = false,
Converters = { new JsonStringEnumConverter() },
};
private FormModel _form = new();
private string? _lastParsed;
protected override void OnParametersSet()
{
// Re-parse only when the inbound value actually changed (don't clobber in-progress edits).
if (!string.Equals(_lastParsed, DriverConfigJson, StringComparison.Ordinal))
{
var opts = TryDeserialize(DriverConfigJson) ?? new S7DriverOptions();
_form = FormModel.FromOptions(opts);
_lastParsed = DriverConfigJson;
}
}
/// <summary>Serializes the current protocol config to camelCase JSON (endpoint excluded from the UI,
/// carried at harmless defaults; the device's DeviceConfig merges its endpoint up at deploy/probe time).</summary>
public string GetConfigJson()
=> JsonSerializer.Serialize(_form.ToOptions(), _jsonOpts);
private async Task EmitAsync()
{
var json = GetConfigJson();
_lastParsed = json;
await DriverConfigJsonChanged.InvokeAsync(json);
}
private async Task OnResilienceChanged(string? r)
{
ResilienceConfig = r;
await ResilienceConfigChanged.InvokeAsync(r);
}
private static S7DriverOptions? TryDeserialize(string json)
{
try { return 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,
};
}
}
@@ -0,0 +1,194 @@
@* Embeddable TwinCAT driver (channel/protocol) config form body. Endpoint/device fields (AMS Net Id:port)
moved to TwinCATDeviceForm in v3 (they live in Device.DeviceConfig); this form never edits the Devices
collection — it preserves it verbatim across a load→save. Hosted by the routed TwinCATDriverPage and by
DriverConfigModal. Exposes GetConfigJson() for the parent to pull at save + Test-connect time, and fires
DriverConfigJsonChanged for @bind support. *@
@using System.Text.Json
@using System.Text.Json.Serialization
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
@using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT
@* 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" @bind-Value:after="EmitAsync"
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" @bind-Value:after="EmitAsync"
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" @bind-Value:after="EmitAsync"
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" @bind-Value:after="EmitAsync"
class="form-control form-control-sm" />
<div class="form-text">0 = push immediately. Increase to coalesce high-churn signals.</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="tcProbeEnabled" @bind-Value="_form.ProbeEnabled" @bind-Value:after="EmitAsync"
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" @bind-Value:after="EmitAsync"
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" @bind-Value:after="EmitAsync"
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" @bind-Value:after="EmitAsync"
class="form-control form-control-sm" />
<div class="form-text">Test Connect timeout (160 s).</div>
</div>
</div>
</div>
</section>
<DriverResilienceSection ResilienceConfig="@ResilienceConfig" ResilienceConfigChanged="OnResilienceChanged" />
@code {
/// <summary>The driver-level DriverConfig JSON (protocol/channel; endpoint lives in the device).</summary>
[Parameter] public string DriverConfigJson { get; set; } = "{}";
/// <summary>Fired (camelCase-serialized) whenever a channel field changes — enables @bind-DriverConfigJson.</summary>
[Parameter] public EventCallback<string> DriverConfigJsonChanged { get; set; }
/// <summary>The per-instance resilience-pipeline overrides JSON, or null.</summary>
[Parameter] public string? ResilienceConfig { get; set; }
/// <summary>Fired when the resilience overrides change.</summary>
[Parameter] public EventCallback<string?> ResilienceConfigChanged { get; set; }
private static readonly JsonSerializerOptions _jsonOpts = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
WriteIndented = false,
Converters = { new JsonStringEnumConverter() },
};
private FormModel _form = new();
private string? _lastParsed;
// TwinCAT is multi-device: this form never edits the Devices collection — it preserves whatever was
// authored (via the device modal on the Raw tree) verbatim across a load→save.
private IReadOnlyList<TwinCATDeviceOptions> _preservedDevices = [];
protected override void OnParametersSet()
{
// Re-parse only when the inbound value actually changed (don't clobber in-progress edits).
if (!string.Equals(_lastParsed, DriverConfigJson, StringComparison.Ordinal))
{
var opts = TryDeserialize(DriverConfigJson) ?? new TwinCATDriverOptions();
_form = FormModel.FromOptions(opts);
_preservedDevices = opts.Devices;
_lastParsed = DriverConfigJson;
}
}
/// <summary>Serializes the current channel/protocol config to camelCase JSON, preserving the Devices
/// collection (authored elsewhere) verbatim.</summary>
public string GetConfigJson()
=> JsonSerializer.Serialize(_form.ToOptions(_preservedDevices), _jsonOpts);
private async Task EmitAsync()
{
var json = GetConfigJson();
_lastParsed = json;
await DriverConfigJsonChanged.InvokeAsync(json);
}
private async Task OnResilienceChanged(string? r)
{
ResilienceConfig = r;
await ResilienceConfigChanged.InvokeAsync(r);
}
private static TwinCATDriverOptions? TryDeserialize(string json)
{
try { return JsonSerializer.Deserialize<TwinCATDriverOptions>(json, _jsonOpts); }
catch { 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,
};
}
}
@@ -2,7 +2,7 @@ using System.Text.Json;
using System.Text.Json.Serialization;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers;
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Forms;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests;
@@ -79,59 +79,20 @@ public sealed class AbCipDriverPageFormSerializationTests
back.ProbeTimeoutSeconds.ShouldBe(10);
}
[Fact]
public void DeviceRow_round_trips_through_definition()
{
var row = new AbCipDriverPage.AbCipDeviceRow
{
HostAddress = "ab://10.0.0.1/1,0", PlcFamily = AbCipPlcFamily.CompactLogix, DeviceName = "PLC-A",
};
var def = row.ToDefinition();
var back = AbCipDriverPage.AbCipDeviceRow.FromDefinition(def);
back.HostAddress.ShouldBe("ab://10.0.0.1/1,0");
back.PlcFamily.ShouldBe(AbCipPlcFamily.CompactLogix);
back.DeviceName.ShouldBe("PLC-A");
}
// v3 Batch-2 (WP3): the embedded-Devices CollectionEditor + AbCipDeviceRow retired — devices are now
// separate Device entities authored via the raw-tree DeviceModal (AbCipDeviceModel, HostAddress/PlcFamily
// into DeviceConfig). The former DeviceRow_* / ValidateDeviceRow_* tests were removed. The channel-config
// FormModel now lives in AbCipDriverForm; the multi-device Devices array it preserves is covered below.
[Fact]
public void DeviceRow_preserves_unedited_fields()
{
var original = new AbCipDeviceOptions(
"ab://10.0.0.1/1,0", AbCipPlcFamily.ControlLogix, "PLC-A",
AllowPacking: true, ConnectionSize: 4002);
var row = AbCipDriverPage.AbCipDeviceRow.FromDefinition(original);
row.HostAddress = "ab://10.0.0.2/1,0";
var back = row.ToDefinition();
back.HostAddress.ShouldBe("ab://10.0.0.2/1,0");
back.AllowPacking.ShouldBe(true);
back.ConnectionSize.ShouldBe(4002);
}
// v3 Batch-1 migration: the pre-declared Tags editor (AbCipDriverPage.AbCipTagRow +
// AbCipDriverOptions.Tags) was retired — tags moved to the Raw tree (/raw, Batch 2). The former
// TagRow_* / ValidateTagRow_* tests and the tag leg of the list-serialization test were removed.
// The multi-device Devices editor is RETAINED, so its coverage (DeviceRow_* + ValidateDeviceRow_*
// + the device-list serialization below, all carrying the AbCipPlcFamily enum) stays.
[Fact]
public void ValidateDeviceRow_rejects_duplicate_host()
{
var rows = new List<AbCipDriverPage.AbCipDeviceRow> { new() { HostAddress = "ab://10.0.0.1/1,0" } };
AbCipDriverPage.AbCipDeviceRow.ValidateRow(new() { HostAddress = "ab://10.0.0.1/1,0" }, rows, null)
.ShouldNotBeNull();
}
[Fact]
public void Device_list_survives_options_serialize_round_trip()
public void Device_list_survives_form_serialize_round_trip()
{
var devices = new List<AbCipDeviceOptions>
{
new("ab://10.0.0.1/1,0", AbCipPlcFamily.ControlLogix, "PLC-1"),
new("ab://10.0.0.2/1,0", AbCipPlcFamily.CompactLogix, "PLC-2"),
};
var opts = new AbCipDriverPage.FormModel().ToOptions(devices);
var opts = new AbCipDriverForm.FormModel().ToOptions(devices);
var json = JsonSerializer.Serialize(opts, TestJsonOpts);
var back = JsonSerializer.Deserialize<AbCipDriverOptions>(json, TestJsonOpts)!;
back.Devices.Count.ShouldBe(2);
@@ -2,7 +2,7 @@ using System.Text.Json;
using System.Text.Json.Serialization;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers;
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Forms;
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
@@ -75,57 +75,20 @@ public sealed class AbLegacyDriverPageFormSerializationTests
back.ProbeTimeoutSeconds.ShouldBe(10);
}
[Fact]
public void DeviceRow_round_trips_through_definition()
{
var row = new AbLegacyDriverPage.AbLegacyDeviceRow
{
HostAddress = "10.0.0.10", PlcFamily = AbLegacyPlcFamily.MicroLogix, DeviceName = "PLC-A",
};
var def = row.ToDefinition();
var back = AbLegacyDriverPage.AbLegacyDeviceRow.FromDefinition(def);
back.HostAddress.ShouldBe("10.0.0.10");
back.PlcFamily.ShouldBe(AbLegacyPlcFamily.MicroLogix);
back.DeviceName.ShouldBe("PLC-A");
}
// v3 Batch-2 (WP3): the embedded-Devices CollectionEditor + AbLegacyDeviceRow retired — devices are now
// separate Device entities authored via the raw-tree DeviceModal (AbLegacyDeviceModel, HostAddress/PlcFamily
// into DeviceConfig). The former DeviceRow_* / ValidateDeviceRow_* tests were removed. The channel-config
// FormModel now lives in AbLegacyDriverForm; the multi-device Devices array it preserves is covered below.
[Fact]
public void DeviceRow_preserves_unedited_fields()
{
var original = new AbLegacyDeviceOptions("10.0.0.10", AbLegacyPlcFamily.Plc5, "PLC-A");
var row = AbLegacyDriverPage.AbLegacyDeviceRow.FromDefinition(original);
row.HostAddress = "10.0.0.20";
var back = row.ToDefinition();
back.HostAddress.ShouldBe("10.0.0.20");
back.PlcFamily.ShouldBe(AbLegacyPlcFamily.Plc5);
back.DeviceName.ShouldBe("PLC-A");
}
// v3 Batch-1 migration: the pre-declared Tags editor (AbLegacyDriverPage.AbLegacyTagRow +
// AbLegacyDriverOptions.Tags) was retired — tags moved to the Raw tree (/raw, Batch 2). The former
// TagRow_* / ValidateTagRow_* tests and the tag leg of the list-serialization test were removed.
// The multi-device Devices editor is RETAINED, so its coverage (DeviceRow_* + ValidateDeviceRow_*
// + the device-list serialization below, all carrying the AbLegacyPlcFamily enum) stays.
[Fact]
public void ValidateDeviceRow_rejects_duplicate_host()
{
var rows = new List<AbLegacyDriverPage.AbLegacyDeviceRow> { new() { HostAddress = "10.0.0.10" } };
AbLegacyDriverPage.AbLegacyDeviceRow.ValidateRow(new() { HostAddress = "10.0.0.10" }, rows, null)
.ShouldNotBeNull();
}
[Fact]
public void Device_list_survives_options_serialize_round_trip()
public void Device_list_survives_form_serialize_round_trip()
{
var devices = new List<AbLegacyDeviceOptions>
{
new("10.0.0.10", AbLegacyPlcFamily.Slc500, "PLC-1"),
new("10.0.0.11", AbLegacyPlcFamily.MicroLogix, "PLC-2"),
};
var opts = new AbLegacyDriverPage.FormModel().ToOptions(devices);
var opts = new AbLegacyDriverForm.FormModel().ToOptions(devices);
var json = JsonSerializer.Serialize(opts, TestJsonOpts);
var back = JsonSerializer.Deserialize<AbLegacyDriverOptions>(json, TestJsonOpts)!;
back.Devices.Count.ShouldBe(2);
@@ -3,67 +3,69 @@ using System.Text.Json;
using System.Text.Json.Serialization;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers;
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Forms;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests;
/// <summary>
/// Regression guard for the systemic driver-config enum-serialization bug found 2026-06-19.
/// Every <c>*DriverPage</c> serialised enum config fields (e.g. S7 <c>CpuType</c>, Modbus
/// <c>DataType</c>/<c>Region</c>, AbCip <c>PlcFamily</c>) as JSON <em>numbers</em> because its
/// private static <c>_jsonOpts</c> had no <see cref="JsonStringEnumConverter"/>. The driver
/// factories, however, deserialise into string-typed DTOs (+ lenient <c>ParseEnum</c>) and
/// <em>throw</em> when binding a JSON number to a <c>string?</c> — so an AdminUI-authored
/// config that contained any enum field produced a blob the driver could not parse, faulting
/// the driver on deploy. The fix makes every page serialise enums as strings (matching the
/// factory + the long-correct OpcUaClient template). This test fails if any driver page loses
/// its string-enum converter again.
/// Every driver-config editor serialised enum config fields (e.g. S7 <c>CpuType</c>, Modbus
/// <c>Family</c>, AbCip <c>PlcFamily</c>) as JSON <em>numbers</em> when its private static
/// <c>_jsonOpts</c> had no <see cref="JsonStringEnumConverter"/>. The driver factories, however,
/// deserialise into string-typed DTOs (+ lenient <c>ParseEnum</c>) and <em>throw</em> when binding a
/// JSON number to a <c>string?</c> — so an AdminUI-authored config that contained any enum field
/// produced a blob the driver could not parse, faulting the driver on deploy. The fix makes every
/// editor serialise enums as strings (matching the factory + the long-correct OpcUaClient template).
/// <para>v3 Batch-2 (WP3): the config serializer moved from the routed <c>*DriverPage</c> into the
/// embeddable <c>*DriverForm</c> body (shared by the page + the raw-tree DriverConfigModal), so this
/// guard now reflects over the <c>*DriverForm</c> fleet. It fails if any driver form loses its
/// string-enum converter again.</para>
/// </summary>
public sealed class DriverPageJsonConverterTests
{
/// <summary>Every concrete <c>*DriverPage</c> in the AdminUI assembly that declares a
/// <summary>Every concrete <c>*DriverForm</c> in the AdminUI assembly that declares a
/// <c>_jsonOpts</c> config serializer.</summary>
private static IReadOnlyList<Type> DriverPageTypes { get; } =
typeof(S7DriverPage).Assembly.GetTypes()
.Where(t => t.Name.EndsWith("DriverPage", StringComparison.Ordinal) && !t.IsAbstract)
private static IReadOnlyList<Type> DriverFormTypes { get; } =
typeof(ModbusDriverForm).Assembly.GetTypes()
.Where(t => t.Name.EndsWith("DriverForm", StringComparison.Ordinal) && !t.IsAbstract)
.Where(t => t.GetField("_jsonOpts", BindingFlags.NonPublic | BindingFlags.Static) is not null)
.OrderBy(t => t.Name)
.ToList();
/// <summary>xUnit theory source over the driver-page types discovered by reflection.</summary>
public static TheoryData<Type> DriverPagesWithJsonOpts()
/// <summary>xUnit theory source over the driver-form types discovered by reflection.</summary>
public static TheoryData<Type> DriverFormsWithJsonOpts()
{
var data = new TheoryData<Type>();
foreach (var t in DriverPageTypes)
foreach (var t in DriverFormTypes)
data.Add(t);
return data;
}
/// <summary>Verifies every driver page's config serializer registers a string-enum converter so
/// <summary>Verifies every driver form's config serializer registers a string-enum converter so
/// enum config fields round-trip as strings (and the driver factory can parse the result).</summary>
/// <param name="pageType">A driver page component type discovered by reflection.</param>
/// <param name="formType">A driver form component type discovered by reflection.</param>
[Theory]
[MemberData(nameof(DriverPagesWithJsonOpts))]
public void Driver_page_json_options_register_string_enum_converter(Type pageType)
[MemberData(nameof(DriverFormsWithJsonOpts))]
public void Driver_form_json_options_register_string_enum_converter(Type formType)
{
var field = pageType.GetField("_jsonOpts", BindingFlags.NonPublic | BindingFlags.Static);
var field = formType.GetField("_jsonOpts", BindingFlags.NonPublic | BindingFlags.Static);
var opts = (JsonSerializerOptions)field!.GetValue(null)!;
opts.Converters.OfType<JsonStringEnumConverter>().ShouldNotBeEmpty(
$"{pageType.Name}._jsonOpts must register a JsonStringEnumConverter; otherwise AdminUI-authored " +
$"{formType.Name}._jsonOpts must register a JsonStringEnumConverter; otherwise AdminUI-authored " +
"enum config fields serialise as numbers and the string-typed driver factory throws on parse.");
}
/// <summary>Enforces that EVERY concrete <c>*DriverPage</c> routes config serialization through a
/// <c>_jsonOpts</c> field — otherwise a new page that serialised config a different way would slip
/// <summary>Enforces that EVERY concrete <c>*DriverForm</c> routes config serialization through a
/// <c>_jsonOpts</c> field — otherwise a new form that serialised config a different way would slip
/// past the converter guard above. Also a floor check so a rename can't silently shrink the set.</summary>
[Fact]
public void Every_driver_page_uses_a_guarded_jsonOpts_serializer()
public void Every_driver_form_uses_a_guarded_jsonOpts_serializer()
{
var allDriverPages = typeof(S7DriverPage).Assembly.GetTypes()
.Where(t => t.Name.EndsWith("DriverPage", StringComparison.Ordinal) && !t.IsAbstract)
var allDriverForms = typeof(ModbusDriverForm).Assembly.GetTypes()
.Where(t => t.Name.EndsWith("DriverForm", StringComparison.Ordinal) && !t.IsAbstract)
.ToList();
allDriverPages.Count.ShouldBeGreaterThanOrEqualTo(8, "reflection should discover the full driver-page fleet");
DriverPageTypes.Count.ShouldBe(allDriverPages.Count,
"every *DriverPage must declare a _jsonOpts config serializer so the string-enum converter guard covers it");
allDriverForms.Count.ShouldBeGreaterThanOrEqualTo(8, "reflection should discover the full driver-form fleet");
DriverFormTypes.Count.ShouldBe(allDriverForms.Count,
"every *DriverForm must declare a _jsonOpts config serializer so the string-enum converter guard covers it");
}
}
@@ -2,7 +2,7 @@ using System.Text.Json;
using System.Text.Json.Serialization;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers;
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Forms;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests;
@@ -119,8 +119,7 @@ public sealed class FocasDriverPageFormSerializationTests
},
};
var form = ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers
.FocasDriverPage.FormModel.FromOptions(opts);
var form = FocasDriverForm.FormModel.FromOptions(opts);
var roundTripped = form.ToOptions([]);
roundTripped.Timeout.ShouldBe(TimeSpan.FromSeconds(4));
@@ -138,57 +137,20 @@ public sealed class FocasDriverPageFormSerializationTests
roundTripped.FixedTree.TimerPollInterval.ShouldBe(TimeSpan.FromSeconds(45));
}
[Fact]
public void DeviceRow_round_trips_through_definition()
{
var row = new FocasDriverPage.FocasDeviceRow
{
HostAddress = "192.168.0.10:8193", Series = FocasCncSeries.Thirty_i, DeviceName = "CNC1",
};
var def = row.ToDefinition();
var back = FocasDriverPage.FocasDeviceRow.FromDefinition(def);
back.HostAddress.ShouldBe("192.168.0.10:8193");
back.Series.ShouldBe(FocasCncSeries.Thirty_i);
back.DeviceName.ShouldBe("CNC1");
}
// v3 Batch-2 (WP3): the embedded-Devices CollectionEditor + FocasDeviceRow retired — devices are now
// separate Device entities authored via the raw-tree DeviceModal (FocasDeviceModel, HostAddress/Series
// into DeviceConfig). The former DeviceRow_* / ValidateDeviceRow_* tests were removed. The channel-config
// FormModel now lives in FocasDriverForm; the multi-device Devices array it preserves is covered below.
[Fact]
public void DeviceRow_preserves_unedited_fields()
{
var original = new FocasDeviceOptions("192.168.0.10:8193", "CNC1", FocasCncSeries.Thirty_i);
var row = FocasDriverPage.FocasDeviceRow.FromDefinition(original);
row.HostAddress = "192.168.0.20:8193";
var back = row.ToDefinition();
back.HostAddress.ShouldBe("192.168.0.20:8193");
back.DeviceName.ShouldBe("CNC1");
back.Series.ShouldBe(FocasCncSeries.Thirty_i);
}
// v3 Batch-1 migration: the pre-declared Tags editor (FocasDriverPage.FocasTagRow +
// FocasDriverOptions.Tags) was retired — tags moved to the Raw tree (/raw, Batch 2). The former
// TagRow_* / ValidateTagRow_* tests and the tag leg of the list-serialization test were removed.
// The multi-device Devices editor is RETAINED, so its coverage (DeviceRow_* + ValidateDeviceRow_*
// + the device-list serialization below, all carrying the FocasCncSeries enum) stays.
[Fact]
public void ValidateDeviceRow_rejects_duplicate_host()
{
var rows = new List<FocasDriverPage.FocasDeviceRow> { new() { HostAddress = "192.168.0.10:8193" } };
FocasDriverPage.FocasDeviceRow.ValidateRow(new() { HostAddress = "192.168.0.10:8193" }, rows, null)
.ShouldNotBeNull();
}
[Fact]
public void Device_list_survives_options_serialize_round_trip()
public void Device_list_survives_form_serialize_round_trip()
{
var devices = new List<FocasDeviceOptions>
{
new("192.168.0.10:8193", "CNC1", FocasCncSeries.Thirty_i),
new("192.168.0.20:8193", "CNC2", FocasCncSeries.Zero_i_F),
};
var opts = new FocasDriverPage.FormModel().ToOptions(devices);
var opts = new FocasDriverForm.FormModel().ToOptions(devices);
var json = JsonSerializer.Serialize(opts, TestJsonOpts);
var back = JsonSerializer.Deserialize<FocasDriverOptions>(json, TestJsonOpts)!;
back.Devices.Count.ShouldBe(2);
@@ -3,13 +3,14 @@ using System.Text.Json.Serialization;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers;
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Forms;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests;
public sealed class GalaxyDriverPageFormSerializationTests
{
// Matches GalaxyDriverPage._jsonOpts (camelCase, no PropertyNameCaseInsensitive).
// Matches GalaxyDriverForm._jsonOpts (camelCase, no PropertyNameCaseInsensitive).
private static readonly JsonSerializerOptions _opts = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
@@ -142,7 +143,7 @@ public sealed class GalaxyDriverPageFormSerializationTests
var driverOpts = JsonSerializer.Deserialize<GalaxyDriverOptions>(seededJson, _pageOpts);
driverOpts.ShouldNotBeNull();
var form = GalaxyDriverPage.GalaxyFormModel.FromRecord(driverOpts!);
var form = GalaxyDriverForm.GalaxyFormModel.FromRecord(driverOpts!);
// Assert REAL seeded values — not defaults.
form.GatewayEndpoint.ShouldBe("http://10.100.0.48:5120");
@@ -155,7 +156,7 @@ public sealed class GalaxyDriverPageFormSerializationTests
/// <summary>
/// Defence-in-depth: a config that genuinely OMITS a section (no Reconnect key at all)
/// must not throw — <see cref="GalaxyDriverPage.GalaxyFormModel.FromRecord"/> must
/// must not throw — <see cref="GalaxyDriverForm.GalaxyFormModel.FromRecord"/> must
/// null-coalesce the missing section to its default value.
/// </summary>
[Fact]
@@ -175,7 +176,7 @@ public sealed class GalaxyDriverPageFormSerializationTests
driverOpts.ShouldNotBeNull();
// FromRecord must not throw even though Reconnect (and other sections) is null.
var form = Should.NotThrow(() => GalaxyDriverPage.GalaxyFormModel.FromRecord(driverOpts!));
var form = Should.NotThrow(() => GalaxyDriverForm.GalaxyFormModel.FromRecord(driverOpts!));
// Omitted Reconnect section falls back to GalaxyReconnectOptions() defaults.
var defaultRc = new GalaxyReconnectOptions();
@@ -185,7 +186,7 @@ public sealed class GalaxyDriverPageFormSerializationTests
}
/// <summary>
/// Confirms that <see cref="GalaxyDriverPage.GalaxyFormModel.FromRecord"/> still
/// Confirms that <see cref="GalaxyDriverForm.GalaxyFormModel.FromRecord"/> still
/// round-trips correctly when all nested records are populated (non-regressed path).
/// </summary>
[Fact]
@@ -216,7 +217,7 @@ public sealed class GalaxyDriverPageFormSerializationTests
ProbeTimeoutSeconds = 20,
};
var form = GalaxyDriverPage.GalaxyFormModel.FromRecord(original);
var form = GalaxyDriverForm.GalaxyFormModel.FromRecord(original);
form.GatewayEndpoint.ShouldBe("https://gw.example.com:5001");
form.GatewayApiKeySecretRef.ShouldBe("env:MY_KEY");
@@ -5,6 +5,7 @@ using System.Text.Json.Serialization;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers;
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Forms;
using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests;
@@ -124,7 +125,7 @@ public sealed class OpcUaClientDriverPageFormSerializationTests
ProbeTimeoutSeconds = 25,
};
var form = OpcUaClientDriverPage.OpcUaClientFormModel.FromRecord(original);
var form = OpcUaClientDriverForm.OpcUaClientFormModel.FromRecord(original);
var result = form.ToRecord(endpointUrls);
result.EndpointUrl.ShouldBe("opc.tcp://fallback:4840");
@@ -159,7 +160,7 @@ public sealed class OpcUaClientDriverPageFormSerializationTests
[Fact]
public void EndpointUrlRow_FromUrl_ToUrl_Trims()
{
var row = OpcUaClientDriverPage.EndpointUrlRow.FromUrl(" opc.tcp://plc:4840 ");
var row = OpcUaClientDriverForm.EndpointUrlRow.FromUrl(" opc.tcp://plc:4840 ");
row.Url.ShouldBe(" opc.tcp://plc:4840 ");
row.ToUrl().ShouldBe("opc.tcp://plc:4840");
@@ -168,10 +169,10 @@ public sealed class OpcUaClientDriverPageFormSerializationTests
[Fact]
public void EndpointUrlRow_ValidateRow_RejectsBlank()
{
var all = new List<OpcUaClientDriverPage.EndpointUrlRow>();
var row = new OpcUaClientDriverPage.EndpointUrlRow { Url = " " };
var all = new List<OpcUaClientDriverForm.EndpointUrlRow>();
var row = new OpcUaClientDriverForm.EndpointUrlRow { Url = " " };
var error = OpcUaClientDriverPage.EndpointUrlRow.ValidateRow(row, all, null);
var error = OpcUaClientDriverForm.EndpointUrlRow.ValidateRow(row, all, null);
error.ShouldBe("URL is required.");
}
@@ -179,10 +180,10 @@ public sealed class OpcUaClientDriverPageFormSerializationTests
[Fact]
public void EndpointUrlRow_ValidateRow_RejectsNonOpcTcpScheme()
{
var all = new List<OpcUaClientDriverPage.EndpointUrlRow>();
var row = new OpcUaClientDriverPage.EndpointUrlRow { Url = "http://plc:4840" };
var all = new List<OpcUaClientDriverForm.EndpointUrlRow>();
var row = new OpcUaClientDriverForm.EndpointUrlRow { Url = "http://plc:4840" };
var error = OpcUaClientDriverPage.EndpointUrlRow.ValidateRow(row, all, null);
var error = OpcUaClientDriverForm.EndpointUrlRow.ValidateRow(row, all, null);
error.ShouldBe("Endpoint URL must start with opc.tcp://");
}
@@ -190,15 +191,15 @@ public sealed class OpcUaClientDriverPageFormSerializationTests
[Fact]
public void EndpointUrlRow_ValidateRow_RejectsDuplicate()
{
var all = new List<OpcUaClientDriverPage.EndpointUrlRow>
var all = new List<OpcUaClientDriverForm.EndpointUrlRow>
{
new() { Url = "opc.tcp://primary:4840" },
new() { Url = "opc.tcp://backup:4840" },
};
// Adding a new row (editIndex null) duplicating the first — case-insensitive, whitespace-insensitive.
var row = new OpcUaClientDriverPage.EndpointUrlRow { Url = " OPC.TCP://primary:4840 " };
var row = new OpcUaClientDriverForm.EndpointUrlRow { Url = " OPC.TCP://primary:4840 " };
var error = OpcUaClientDriverPage.EndpointUrlRow.ValidateRow(row, all, null);
var error = OpcUaClientDriverForm.EndpointUrlRow.ValidateRow(row, all, null);
error.ShouldNotBeNull();
error.ShouldContain("Duplicate endpoint");
@@ -207,15 +208,15 @@ public sealed class OpcUaClientDriverPageFormSerializationTests
[Fact]
public void EndpointUrlRow_ValidateRow_AllowsEditingRowInPlace()
{
var all = new List<OpcUaClientDriverPage.EndpointUrlRow>
var all = new List<OpcUaClientDriverForm.EndpointUrlRow>
{
new() { Url = "opc.tcp://primary:4840" },
new() { Url = "opc.tcp://backup:4840" },
};
// Editing index 0 and keeping the same URL must not flag itself as a duplicate.
var row = new OpcUaClientDriverPage.EndpointUrlRow { Url = "opc.tcp://primary:4840" };
var row = new OpcUaClientDriverForm.EndpointUrlRow { Url = "opc.tcp://primary:4840" };
var error = OpcUaClientDriverPage.EndpointUrlRow.ValidateRow(row, all, 0);
var error = OpcUaClientDriverForm.EndpointUrlRow.ValidateRow(row, all, 0);
error.ShouldBeNull();
}
@@ -228,7 +229,7 @@ public sealed class OpcUaClientDriverPageFormSerializationTests
var endpointUrls = new List<string> { "opc.tcp://primary:4840", "opc.tcp://secondary:4840", "opc.tcp://tertiary:4840" };
var rows = endpointUrls
.Select(OpcUaClientDriverPage.EndpointUrlRow.FromUrl)
.Select(OpcUaClientDriverForm.EndpointUrlRow.FromUrl)
.ToList();
var roundTripped = rows.Select(r => r.ToUrl()).ToList();
@@ -237,7 +238,7 @@ public sealed class OpcUaClientDriverPageFormSerializationTests
roundTripped[1].ShouldBe("opc.tcp://secondary:4840");
roundTripped[2].ShouldBe("opc.tcp://tertiary:4840");
var form = OpcUaClientDriverPage.OpcUaClientFormModel.FromRecord(new OpcUaClientDriverOptions());
var form = OpcUaClientDriverForm.OpcUaClientFormModel.FromRecord(new OpcUaClientDriverOptions());
var result = form.ToRecord(roundTripped);
result.EndpointUrls.Count.ShouldBe(3);
result.EndpointUrls[0].ShouldBe("opc.tcp://primary:4840");
@@ -3,13 +3,14 @@ using System.Text.Json.Serialization;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers;
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Forms;
using ZB.MOM.WW.OtOpcUa.Driver.S7;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests;
// v3 Batch-1 migration: the S7 driver page dropped its pre-declared Tags editor — tags now live in
// the Raw tree (/raw, Batch 2), and S7DriverOptions.Tags (the typed S7TagDefinition list) +
// S7DriverPage.S7TagRow were retired (the driver now consumes RawTags). The former tag-editor and
// S7DriverForm.S7TagRow were retired (the driver now consumes RawTags). The former tag-editor and
// tag-serialization tests (S7TagRow_*, TagList_SerializeRoundTrip_PreservesTags, and the tag legs of
// the round-trip tests) were removed. The connection/protocol-field + enum-serialization round-trip
// coverage (CpuType, S7DataType) is retained below unchanged.
@@ -91,7 +92,7 @@ public sealed class S7DriverPageFormSerializationTests
ProbeTimeoutSeconds = 20,
};
var form = S7DriverPage.FormModel.FromOptions(opts);
var form = S7DriverForm.FormModel.FromOptions(opts);
var roundTripped = form.ToOptions();
roundTripped.Host.ShouldBe("192.168.1.50");
@@ -2,6 +2,7 @@ using System.Text.Json;
using System.Text.Json.Serialization;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Forms;
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests;
@@ -80,8 +81,7 @@ public sealed class TwinCATDriverPageFormSerializationTests
ProbeTimeoutSeconds = 15,
};
var form = ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers
.TwinCATDriverPage.FormModel.FromOptions(opts);
var form = TwinCATDriverForm.FormModel.FromOptions(opts);
var roundTripped = form.ToOptions([]);
roundTripped.Timeout.ShouldBe(TimeSpan.FromSeconds(3));
@@ -94,60 +94,15 @@ public sealed class TwinCATDriverPageFormSerializationTests
roundTripped.ProbeTimeoutSeconds.ShouldBe(15);
}
[Fact]
public void DeviceRow_RoundTrip_PreservesEditableFields()
{
var def = new TwinCATDeviceOptions("192.168.0.1.1.1:851", "PLC1");
var row = ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers
.TwinCATDriverPage.TwinCATDeviceRow.FromDefinition(def);
var back = row.ToDefinition();
back.HostAddress.ShouldBe("192.168.0.1.1.1:851");
back.DeviceName.ShouldBe("PLC1");
}
[Fact]
public void DeviceRow_CarriesThroughUneditedSourceFields()
{
// Edit only DeviceName; HostAddress on the source must survive the round-trip via _source.
var def = new TwinCATDeviceOptions("10.0.0.5.1.1:851", "Original");
var row = ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers
.TwinCATDriverPage.TwinCATDeviceRow.FromDefinition(def);
row.DeviceName = "Renamed";
var back = row.ToDefinition();
back.HostAddress.ShouldBe("10.0.0.5.1.1:851");
back.DeviceName.ShouldBe("Renamed");
}
[Fact]
public void DeviceRow_ValidateRow_RejectsDuplicateHostAddress()
{
var existing = ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers
.TwinCATDriverPage.TwinCATDeviceRow.FromDefinition(new TwinCATDeviceOptions("192.168.0.1.1.1:851"));
var dup = new ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers
.TwinCATDriverPage.TwinCATDeviceRow { HostAddress = "192.168.0.1.1.1:851" };
var all = new[] { existing, dup };
var error = ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers
.TwinCATDriverPage.TwinCATDeviceRow.ValidateRow(dup, all, editIndex: 1);
error.ShouldNotBeNull();
error.ShouldContain("Duplicate");
}
// v3 Batch-1 migration: the pre-declared Tags editor (TwinCATDriverPage.TwinCATTagRow +
// TwinCATDriverOptions.Tags) was retired — tags moved to the Raw tree (/raw, Batch 2). The former
// TagRow_* tests and the tag leg of FormModel_ToOptions_* were removed. The multi-device Devices
// editor is RETAINED, so its coverage (DeviceRow_* + the device-list serialization below) stays.
// v3 Batch-2 (WP3): the embedded-Devices CollectionEditor + TwinCATDeviceRow retired — devices are now
// separate Device entities authored via the raw-tree DeviceModal (TwinCATDeviceModel, HostAddress into
// DeviceConfig). The former DeviceRow_* tests were removed. The channel-config FormModel now lives in
// TwinCATDriverForm; the multi-device Devices array it preserves is covered below.
[Fact]
public void FormModel_ToOptions_SerializesDeviceList()
{
var form = ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers
.TwinCATDriverPage.FormModel.FromOptions(new TwinCATDriverOptions());
var form = TwinCATDriverForm.FormModel.FromOptions(new TwinCATDriverOptions());
var devices = new[] { new TwinCATDeviceOptions("192.168.0.1.1.1:851", "PLC1") };
@@ -0,0 +1,70 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.DeviceForms;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
/// <summary>
/// Round-trip guard for the Modbus <b>device</b> endpoint model — the gate-critical DeviceConfig
/// surface. Endpoint keys MUST be PascalCase (Host/Port/UnitId) so the merged probe config binds via
/// the runtime's case-insensitive deserializer, matching the seed and Wave-B service tests.
/// </summary>
public sealed class ModbusDeviceModelTests
{
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("{}")]
public void FromJson_returns_defaults_for_empty_input(string? json)
{
var m = ModbusDeviceModel.FromJson(json);
m.Host.ShouldBe("127.0.0.1");
m.Port.ShouldBe(502);
m.UnitId.ShouldBe(1);
}
[Fact]
public void Round_trip_preserves_endpoint_fields()
{
var m = new ModbusDeviceModel { Host = "10.100.0.35", Port = 5020, UnitId = 7 };
var json = m.ToJson();
var m2 = ModbusDeviceModel.FromJson(json);
m2.Host.ShouldBe("10.100.0.35");
m2.Port.ShouldBe(5020);
m2.UnitId.ShouldBe(7);
}
[Fact]
public void ToJson_emits_PascalCase_endpoint_keys()
{
var json = new ModbusDeviceModel { Host = "10.1.2.3", Port = 5020, UnitId = 1 }.ToJson();
// Runtime binds ModbusDriverOptions.Host/Port/UnitId from these exact PascalCase keys.
json.ShouldContain("\"Host\":\"10.1.2.3\"");
json.ShouldContain("\"Port\":5020");
json.ShouldContain("\"UnitId\":1");
}
[Fact]
public void FromJson_then_ToJson_preserves_unknown_keys()
{
var json = ModbusDeviceModel
.FromJson("""{"Host":"10.1.2.3","Port":5020,"UnitId":1,"customKey":"keepme"}""")
.ToJson();
json.ShouldContain("customKey");
json.ShouldContain("keepme");
json.ShouldContain("\"Host\":\"10.1.2.3\"");
}
[Fact]
public void Validate_flags_blank_host()
{
new ModbusDeviceModel { Host = "" }.Validate().ShouldNotBeNull();
new ModbusDeviceModel { Host = "127.0.0.1" }.Validate().ShouldBeNull();
}
}
@@ -0,0 +1,64 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Forms;
using ZB.MOM.WW.OtOpcUa.Driver.Modbus;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
/// <summary>
/// Round-trip + enum-serialization guard for the Modbus <b>driver</b> (channel/protocol) form model.
/// Reproduces the historical driver-enum-serialization bug: enums MUST serialize as their name strings
/// (JsonStringEnumConverter) with camelCase keys, or the runtime factory faults on the numeric form.
/// </summary>
public sealed class ModbusDriverFormModelTests
{
private static readonly JsonSerializerOptions JsonOpts = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
WriteIndented = false,
Converters = { new JsonStringEnumConverter() },
};
[Fact]
public void Round_trip_preserves_channel_fields_and_enum()
{
var form = new ModbusDriverForm.FormModel
{
Family = ModbusFamily.MELSEC,
MelsecSubFamily = MelsecFamily.Q_L_iQR,
TimeoutSeconds = 4,
MaxRegistersPerRead = 100,
WriteOnChangeOnly = true,
KeepAliveRetryCount = 5,
ReconnectBackoffMultiplier = 3.0,
ProbeAddress = 40,
};
var json = JsonSerializer.Serialize(form.ToOptions(), JsonOpts);
var back = ModbusDriverForm.FormModel.FromOptions(
JsonSerializer.Deserialize<ModbusDriverOptions>(json, JsonOpts)!);
back.Family.ShouldBe(ModbusFamily.MELSEC);
back.MelsecSubFamily.ShouldBe(MelsecFamily.Q_L_iQR);
back.TimeoutSeconds.ShouldBe(4);
back.MaxRegistersPerRead.ShouldBe(100);
back.WriteOnChangeOnly.ShouldBeTrue();
back.KeepAliveRetryCount.ShouldBe(5);
back.ReconnectBackoffMultiplier.ShouldBe(3.0);
back.ProbeAddress.ShouldBe(40);
}
[Fact]
public void Serialized_config_uses_camelCase_keys_and_enum_names()
{
var form = new ModbusDriverForm.FormModel { Family = ModbusFamily.MELSEC };
var json = JsonSerializer.Serialize(form.ToOptions(), JsonOpts);
json.ShouldContain("\"family\":\"MELSEC\""); // enum-as-name (not a number), camelCase key
json.ShouldNotContain("\"family\":0", Case.Sensitive); // never the numeric enum form
}
}