merge wt/wp3 into v3/batch2-raw-ui (Wave B UI)
This commit is contained in:
+19
-259
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+18
-211
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+30
-323
@@ -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 (1–60 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+19
-281
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+23
-381
@@ -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 5–32.</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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+24
-372
@@ -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<string></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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+32
-175
@@ -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 (1–60 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+33
-229
@@ -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 (1–60 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+54
@@ -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);
|
||||
}
|
||||
}
|
||||
+51
@@ -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;
|
||||
}
|
||||
+54
@@ -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);
|
||||
}
|
||||
}
|
||||
+51
@@ -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;
|
||||
}
|
||||
+54
@@ -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);
|
||||
}
|
||||
}
|
||||
+51
@@ -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;
|
||||
}
|
||||
+34
@@ -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();
|
||||
}
|
||||
+29
@@ -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;
|
||||
}
|
||||
+52
@@ -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);
|
||||
}
|
||||
}
|
||||
+55
@@ -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;
|
||||
}
|
||||
+45
@@ -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);
|
||||
}
|
||||
}
|
||||
+50
@@ -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;
|
||||
}
|
||||
}
|
||||
+56
@@ -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);
|
||||
}
|
||||
}
|
||||
+60
@@ -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;
|
||||
}
|
||||
+44
@@ -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);
|
||||
}
|
||||
}
|
||||
+45
@@ -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") · <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);
|
||||
}
|
||||
}
|
||||
+201
@@ -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") · <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);
|
||||
}
|
||||
}
|
||||
+215
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
+170
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
+282
@@ -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 (1–60 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
+293
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
+399
@@ -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 5–32.</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,
|
||||
};
|
||||
}
|
||||
}
|
||||
+367
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
+177
@@ -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 (1–60 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
+194
@@ -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 (1–60 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
+7
-46
@@ -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);
|
||||
|
||||
+7
-44
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
+8
-46
@@ -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);
|
||||
|
||||
+7
-6
@@ -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");
|
||||
|
||||
+17
-16
@@ -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");
|
||||
|
||||
+7
-52
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user