Files
lmxopcua/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/S7DriverPage.razor
T
Joseph Doherty 4b14feb373 fix(drivers): serialize driver-config enums as strings in AdminUI pages + probes
AdminUI driver-instance pages serialized enum config fields (S7 CpuType,
Modbus DataType/Region, AbCip PlcFamily, ...) as JSON *numbers* because each
page's _jsonOpts lacked a JsonStringEnumConverter. The driver factories,
however, deserialize into string-typed DTOs (+ lenient ParseEnum) and throw
when binding a JSON number to a string? — so an AdminUI-authored config
containing any enum field produced a blob the driver could not parse,
faulting the driver on deploy. Proven end-to-end for S7 and Modbus; latent
for AbCip/AbLegacy/TwinCAT/FOCAS/Galaxy/Historian. Only OpcUaClient was safe
(its factory + probe already carried the converter).

Add JsonStringEnumConverter to all 9 driver-instance pages' _jsonOpts and the
8 missing driver probes' _opts (factories unchanged — already string-via-
ParseEnum; strictly more permissive, also lets pages load hand-seeded
string-enum configs back into the form).

Also fix DriverProbeHandshakeE2eTests.AbCip_Green_AgainstSim to probe a real
sim tag (TestDINT) — the no-tags @raw_cpu_type fallback is rejected by the
ab_server sim with ErrorBadParam (a real ControlLogix returns ErrorNotFound,
which the probe treats as reachable; hardware-gated follow-up).

Tests: reflection guard over all driver pages' _jsonOpts (AdminUI.Tests);
factory round-trip + numeric-form-throws guards for S7 and Modbus.

Found by running the never-before-run FB-9/FB-10 live verifies.
2026-06-19 04:52:47 -04:00

439 lines
21 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
@page "/clusters/{ClusterId}/drivers/new/s7"
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
@rendermode RenderMode.InteractiveServer
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.EntityFrameworkCore
@using ZB.MOM.WW.OtOpcUa.AdminUI.Clients
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers
@using ZB.MOM.WW.OtOpcUa.Configuration
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@using ZB.MOM.WW.OtOpcUa.Driver.S7
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
@inject NavigationManager Nav
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">@(IsNew ? "New Siemens S7 driver" : "Edit Siemens S7 driver") &middot; <span class="mono">@ClusterId</span></h4>
<a href="/clusters/@ClusterId/drivers" class="btn btn-outline-secondary btn-sm">Cancel</a>
</div>
<ClusterNav ClusterId="@ClusterId" ActiveTab="drivers" />
@if (!_loaded)
{
<p>Loading…</p>
}
else if (!IsNew && _existing is null)
{
<section class="panel notice rise" style="animation-delay:.02s">
Driver instance <span class="mono">@DriverInstanceId</span> was not found in cluster <span class="mono">@ClusterId</span>.
</section>
}
else
{
<EditForm Model="_form" OnValidSubmit="SubmitAsync" FormName="s7DriverEdit">
<DataAnnotationsValidator />
<DriverFormShell IsNew="IsNew" Busy="_busy" Error="@_error"
CancelHref="@($"/clusters/{ClusterId}/drivers")"
OnDelete="@(IsNew ? null : (EventCallback?)EventCallback.Factory.Create(this, DeleteAsync))">
<DriverIdentitySection Model="_identityModel" Namespaces="_namespaces" IsNew="IsNew" ShowDriverType="false" />
@if (!IsNew && !string.IsNullOrEmpty(DriverInstanceId))
{
<DriverStatusPanel DriverInstanceId="@DriverInstanceId" Enabled="@_identityModel.Enabled" />
}
<div class="mt-2 mb-3">
<DriverTestConnectButton DriverType="@DriverTypeKey"
GetConfigJson="@SerializeCurrentConfig"
TimeoutSeconds="@_form.AdminProbeTimeoutSeconds" />
<button type="button" class="btn btn-sm btn-outline-secondary mt-2"
@onclick="@(() => _showPicker = true)">
Pick address
</button>
</div>
<DriverTagPicker @bind-Visible="_showPicker"
Title="S7 address"
CurrentAddress="@_pickedAddress"
OnPickAddress="@OnAddressPicked">
<S7AddressPickerBody CurrentAddress="@_pickedAddress"
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>
</div>
</section>
@* Probe *@
<section class="panel rise mt-3" style="animation-delay:.08s">
<div class="panel-head">Connectivity probe</div>
<div style="padding:1rem">
<div class="row">
<div class="col-md-3 mb-3">
<div class="form-check form-switch mt-2">
<InputCheckbox id="s7ProbeEnabled" @bind-Value="_form.ProbeEnabled"
class="form-check-input" />
<label class="form-check-label" for="s7ProbeEnabled">Probe enabled</label>
</div>
</div>
<div class="col-md-3 mb-3">
<label class="form-label" for="s7ProbeIntervalSec">Probe interval (s)</label>
<InputNumber id="s7ProbeIntervalSec" @bind-Value="_form.ProbeIntervalSeconds"
class="form-control form-control-sm" />
</div>
<div class="col-md-3 mb-3">
<label class="form-label" for="s7ProbeTimeoutSec">Probe timeout (s)</label>
<InputNumber id="s7ProbeTimeoutSec" @bind-Value="_form.ProbeTimeoutSeconds"
class="form-control form-control-sm" />
</div>
<div class="col-md-3 mb-3">
<label class="form-label" for="s7AdminProbeTimeout">Admin probe timeout (s)</label>
<InputNumber id="s7AdminProbeTimeout" @bind-Value="_form.AdminProbeTimeoutSeconds"
class="form-control form-control-sm" />
<div class="form-text">Test Connect timeout (160 s).</div>
</div>
</div>
</div>
</section>
@* Tags *@
<CollectionEditor TRow="S7TagRow" Items="_tags" Title="Tags" ItemNoun="tag"
AnimationDelay=".11s"
NewRow="@(() => new S7TagRow())" Clone="@(r => r.Clone())"
Validate="S7TagRow.ValidateRow">
<HeaderTemplate>
<tr><th>Name</th><th>Address</th><th>Type</th><th>Writable</th><th></th></tr>
</HeaderTemplate>
<RowTemplate Context="t">
<td class="mono">@t.Name</td><td class="mono">@t.Address</td>
<td>@t.DataType</td><td>@(t.Writable ? "yes" : "no")</td>
</RowTemplate>
<EditTemplate Context="t">
<div class="row g-3">
<div class="col-md-6"><label class="form-label">Name</label>
<input class="form-control form-control-sm" @bind="t.Name" /></div>
<div class="col-md-6"><label class="form-label">Address</label>
<input class="form-control form-control-sm mono" @bind="t.Address"
placeholder="e.g. DB1.DBW0, M0.0, I0.0, QD4" /></div>
<div class="col-md-3"><label class="form-label">Data type</label>
<select class="form-select form-select-sm" @bind="t.DataType">
@foreach (var e in Enum.GetValues<S7DataType>()) { <option value="@e">@e</option> }
</select></div>
<div class="col-md-3"><label class="form-label">String length</label>
<input type="number" class="form-control form-control-sm" @bind="t.StringLength" />
<div class="form-text">Only for String type. Max 254.</div></div>
<div class="col-md-3"><div class="form-check form-switch mt-4">
<input type="checkbox" class="form-check-input" @bind="t.Writable" id="tagWritable" />
<label class="form-check-label" for="tagWritable">Writable</label></div></div>
</div>
</EditTemplate>
</CollectionEditor>
<DriverResilienceSection @bind-ResilienceConfig="_form.ResilienceConfig" />
</DriverFormShell>
</EditForm>
}
@code {
[Parameter] public string ClusterId { get; set; } = "";
[Parameter] public string? DriverInstanceId { get; set; }
private const string DriverTypeKey = "S7";
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 DriverInstance? _existing;
private List<Namespace> _namespaces = new();
private bool _loaded, _busy;
private string? _error;
// Address picker state
private bool _showPicker;
private string _pickedAddress = "";
private void OnAddressPicked(string address) => _pickedAddress = address;
// Held separately because Tags is a collection — edited via the CollectionEditor modal.
private List<S7TagRow> _tags = [];
protected override async Task OnInitializedAsync()
{
await using var db = await DbFactory.CreateDbContextAsync();
_namespaces = await db.Namespaces.AsNoTracking()
.Where(n => n.ClusterId == ClusterId)
.OrderBy(n => n.NamespaceId).ToListAsync();
if (IsNew)
{
_identityModel = new() { DriverType = DriverTypeKey, NamespaceId = _namespaces.FirstOrDefault()?.NamespaceId ?? "", Enabled = true };
_form = FormModel.FromOptions(new S7DriverOptions());
}
else
{
_existing = await db.DriverInstances.AsNoTracking()
.FirstOrDefaultAsync(d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId);
if (_existing is not null)
{
_identityModel = new()
{
DriverInstanceId = _existing.DriverInstanceId,
Name = _existing.Name,
DriverType = _existing.DriverType,
NamespaceId = _existing.NamespaceId,
Enabled = _existing.Enabled,
};
var opts = TryDeserialize(_existing.DriverConfig) ?? new S7DriverOptions();
_form = FormModel.FromOptions(opts);
_form.ResilienceConfig = _existing.ResilienceConfig;
_form.RowVersion = _existing.RowVersion;
_tags = opts.Tags.Select(S7TagRow.FromDefinition).ToList();
}
}
_loaded = true;
}
private async Task SubmitAsync()
{
_busy = true; _error = null;
try
{
var opts = _form.ToOptions(_tags.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;
}
db.DriverInstances.Add(new DriverInstance
{
DriverInstanceId = _identityModel.DriverInstanceId,
ClusterId = ClusterId,
NamespaceId = _identityModel.NamespaceId,
Name = _identityModel.Name,
DriverType = DriverTypeKey,
Enabled = _identityModel.Enabled,
DriverConfig = configJson,
ResilienceConfig = string.IsNullOrWhiteSpace(_form.ResilienceConfig) ? null : _form.ResilienceConfig,
});
}
else
{
var entity = await db.DriverInstances.FirstOrDefaultAsync(
d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId);
if (entity is null) { _error = "Row no longer exists."; return; }
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion;
entity.NamespaceId = _identityModel.NamespaceId;
entity.Name = _identityModel.Name;
entity.Enabled = _identityModel.Enabled;
entity.DriverConfig = configJson;
entity.ResilienceConfig = string.IsNullOrWhiteSpace(_form.ResilienceConfig) ? null : _form.ResilienceConfig;
}
await db.SaveChangesAsync();
Nav.NavigateTo($"/clusters/{ClusterId}/drivers");
}
catch (DbUpdateConcurrencyException)
{
_error = "Another user changed this driver instance while you were editing. Reload to see the latest values, then re-apply your changes.";
}
catch (Exception ex) { _error = ex.Message; }
finally { _busy = false; }
}
private async Task DeleteAsync()
{
if (IsNew) return;
_busy = true; _error = null;
try
{
await using var db = await DbFactory.CreateDbContextAsync();
var entity = await db.DriverInstances.FirstOrDefaultAsync(
d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId);
if (entity is null) { Nav.NavigateTo($"/clusters/{ClusterId}/drivers"); return; }
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion;
db.DriverInstances.Remove(entity);
await db.SaveChangesAsync();
Nav.NavigateTo($"/clusters/{ClusterId}/drivers");
}
catch (DbUpdateConcurrencyException)
{
_error = "Another user changed this driver instance while you were viewing it. Reload before deleting.";
}
catch (Exception ex)
{
_error = $"Delete failed: {ex.Message}. (Likely because equipment/tags still reference this driver — remove them first.)";
}
finally { _busy = false; }
}
private string SerializeCurrentConfig()
=> System.Text.Json.JsonSerializer.Serialize(
_form.ToOptions(_tags.Select(r => r.ToDefinition()).ToList()), _jsonOpts);
private static S7DriverOptions? TryDeserialize(string json)
{
try { return System.Text.Json.JsonSerializer.Deserialize<S7DriverOptions>(json, _jsonOpts); }
catch { return null; }
}
// Mutable VM for the modal editor — S7TagDefinition is an immutable record.
public sealed class S7TagRow
{
public string Name { get; set; } = "";
public string Address { get; set; } = "";
public S7DataType DataType { get; set; } = S7DataType.Int16;
public bool Writable { get; set; } = true;
public int StringLength { get; set; } = 254;
// Original record (null for newly-added rows). Preserves fields the editor doesn't expose
// (WriteIdempotent) across a load→save.
private S7TagDefinition? _source;
public S7TagRow Clone() => (S7TagRow)MemberwiseClone(); // _source is an immutable record ref — safe to share
public static S7TagRow FromDefinition(S7TagDefinition d) => new()
{
Name = d.Name, Address = d.Address, DataType = d.DataType,
Writable = d.Writable, StringLength = d.StringLength,
_source = d,
};
public S7TagDefinition ToDefinition()
{
var baseDef = _source ?? new S7TagDefinition(Name.Trim(), Address.Trim(), DataType);
return baseDef with
{
Name = Name.Trim(),
Address = Address.Trim(),
DataType = DataType,
Writable = Writable,
StringLength = StringLength,
};
}
public static string? ValidateRow(S7TagRow row, IReadOnlyList<S7TagRow> all, int? editIndex)
{
if (string.IsNullOrWhiteSpace(row.Name)) return "Name is required.";
for (var i = 0; i < all.Count; i++)
if (i != editIndex && string.Equals(all[i].Name, row.Name, StringComparison.OrdinalIgnoreCase))
return $"Duplicate tag name '{row.Name}'.";
return null;
}
}
// Flat mutable model — all scalar properties settable for Blazor @bind-Value.
// Collection (Tags) is kept on the component (_tags) and passed in on ToOptions().
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(IReadOnlyList<S7TagDefinition> tags) => 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,
Tags = tags,
};
}
}