From 844f93f64fe690a44a06f386770ac494b2b6f49b Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 16 Jul 2026 03:26:24 -0400 Subject: [PATCH] feat(adminui): B2-WP3 driver/device config modals + page-to-form refactor Extract the 8 typed driver pages into embeddable DriverForm bodies (channel/protocol config) and add per-driver DeviceForm bodies (connection endpoint -> Device.DeviceConfig, v3 endpoint split). New DriverConfigModal + DeviceModal dispatch by DriverType (DriverTypeNames) for the /raw tree; Test-connect inside DeviceModal builds the merged Driver+Device config transiently via DriverDeviceConfigMerger. Routed pages now host the form bodies and keep working. Round-trip + enum-serialization guard tests for the Modbus driver form + Modbus device model; retargeted the existing page-form serialization tests + the _jsonOpts converter guard to the forms. Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox --- .../Clusters/Drivers/AbCipDriverPage.razor | 278 +----------- .../Clusters/Drivers/AbLegacyDriverPage.razor | 229 +--------- .../Clusters/Drivers/FocasDriverPage.razor | 353 ++------------- .../Clusters/Drivers/GalaxyDriverPage.razor | 300 +------------ .../Clusters/Drivers/ModbusDriverPage.razor | 404 +----------------- .../Drivers/OpcUaClientDriverPage.razor | 396 ++--------------- .../Pages/Clusters/Drivers/S7DriverPage.razor | 207 ++------- .../Clusters/Drivers/TwinCATDriverPage.razor | 262 ++---------- .../Drivers/DeviceForms/AbCipDeviceForm.razor | 54 +++ .../Drivers/DeviceForms/AbCipDeviceModel.cs | 51 +++ .../DeviceForms/AbLegacyDeviceForm.razor | 54 +++ .../DeviceForms/AbLegacyDeviceModel.cs | 51 +++ .../Drivers/DeviceForms/FocasDeviceForm.razor | 54 +++ .../Drivers/DeviceForms/FocasDeviceModel.cs | 51 +++ .../DeviceForms/GalaxyDeviceForm.razor | 34 ++ .../Drivers/DeviceForms/GalaxyDeviceModel.cs | 29 ++ .../DeviceForms/ModbusDeviceForm.razor | 52 +++ .../Drivers/DeviceForms/ModbusDeviceModel.cs | 55 +++ .../DeviceForms/OpcUaClientDeviceForm.razor | 45 ++ .../DeviceForms/OpcUaClientDeviceModel.cs | 50 +++ .../Drivers/DeviceForms/S7DeviceForm.razor | 56 +++ .../Drivers/DeviceForms/S7DeviceModel.cs | 60 +++ .../DeviceForms/TwinCATDeviceForm.razor | 44 ++ .../Drivers/DeviceForms/TwinCATDeviceModel.cs | 45 ++ .../Shared/Drivers/DeviceModal.razor | 224 ++++++++++ .../Shared/Drivers/DriverConfigModal.razor | 201 +++++++++ .../Drivers/Forms/AbCipDriverForm.razor | 215 ++++++++++ .../Drivers/Forms/AbLegacyDriverForm.razor | 170 ++++++++ .../Drivers/Forms/FocasDriverForm.razor | 282 ++++++++++++ .../Drivers/Forms/GalaxyDriverForm.razor | 293 +++++++++++++ .../Drivers/Forms/ModbusDriverForm.razor | 399 +++++++++++++++++ .../Drivers/Forms/OpcUaClientDriverForm.razor | 367 ++++++++++++++++ .../Shared/Drivers/Forms/S7DriverForm.razor | 177 ++++++++ .../Drivers/Forms/TwinCATDriverForm.razor | 194 +++++++++ .../AbCipDriverPageFormSerializationTests.cs | 53 +-- ...bLegacyDriverPageFormSerializationTests.cs | 51 +-- .../DriverPageJsonConverterTests.cs | 64 +-- .../FocasDriverPageFormSerializationTests.cs | 54 +-- .../GalaxyDriverPageFormSerializationTests.cs | 13 +- ...aClientDriverPageFormSerializationTests.cs | 33 +- .../S7DriverPageFormSerializationTests.cs | 5 +- ...TwinCATDriverPageFormSerializationTests.cs | 59 +-- .../Uns/ModbusDeviceModelTests.cs | 70 +++ .../Uns/ModbusDriverFormModelTests.cs | 64 +++ 44 files changed, 3728 insertions(+), 2474 deletions(-) create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceForms/AbCipDeviceForm.razor create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceForms/AbCipDeviceModel.cs create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceForms/AbLegacyDeviceForm.razor create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceForms/AbLegacyDeviceModel.cs create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceForms/FocasDeviceForm.razor create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceForms/FocasDeviceModel.cs create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceForms/GalaxyDeviceForm.razor create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceForms/GalaxyDeviceModel.cs create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceForms/ModbusDeviceForm.razor create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceForms/ModbusDeviceModel.cs create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceForms/OpcUaClientDeviceForm.razor create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceForms/OpcUaClientDeviceModel.cs create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceForms/S7DeviceForm.razor create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceForms/S7DeviceModel.cs create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceForms/TwinCATDeviceForm.razor create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceForms/TwinCATDeviceModel.cs create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceModal.razor create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverConfigModal.razor create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/AbCipDriverForm.razor create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/AbLegacyDriverForm.razor create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/FocasDriverForm.razor create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/GalaxyDriverForm.razor create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/ModbusDriverForm.razor create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/OpcUaClientDriverForm.razor create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/S7DriverForm.razor create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/TwinCATDriverForm.razor create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/ModbusDeviceModelTests.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/ModbusDriverFormModelTests.cs diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/AbCipDriverPage.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/AbCipDriverPage.razor index cb379509..c554aa09 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/AbCipDriverPage.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/AbCipDriverPage.razor @@ -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 { - + - + + + + + + + +} + +@code { + /// Whether the modal is shown (supports @bind-Visible). + [Parameter] public bool Visible { get; set; } + /// Two-way visibility callback. + [Parameter] public EventCallback VisibleChanged { get; set; } + + /// The device to edit; null ⇒ create a new device under . + [Parameter] public string? DeviceId { get; set; } + /// The owning driver instance (required for create; resolved from the device on edit). + [Parameter] public string? DriverInstanceId { get; set; } + /// The driver type (required for create; joined in from the device on edit). + [Parameter] public string? DriverType { get; set; } + + /// Raised after a successful create/update so the tree can refresh. + [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 ?? "{}"; + } + } + + /// Builds the merged Driver+Device probe config from the CURRENT (possibly unsaved) form — + /// same merge as LoadMergedProbeConfigAsync, so Test-connect works before the device is saved. + private string BuildMergedProbeConfig() + => DriverDeviceConfigMerger.Merge( + _parentDriverConfig, + new[] { new DriverDeviceConfigMerger.DeviceRow(_name, _deviceConfigJson) }, + Array.Empty()); + + 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); + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverConfigModal.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverConfigModal.razor new file mode 100644 index 00000000..d2140099 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverConfigModal.razor @@ -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) +{ + + +} + +@code { + /// Whether the modal is shown (supports @bind-Visible). + [Parameter] public bool Visible { get; set; } + /// Two-way visibility callback. + [Parameter] public EventCallback VisibleChanged { get; set; } + + /// The driver instance to edit; null ⇒ create a new driver. + [Parameter] public string? DriverInstanceId { get; set; } + + // --- Create-mode inputs (ignored on edit) --- + /// The owning cluster (required for create). + [Parameter] public string? ClusterId { get; set; } + /// The containing folder, or null for a cluster-root driver (create). + [Parameter] public string? FolderId { get; set; } + /// The driver type to create (required for create; immutable on edit). + [Parameter] public string? DriverType { get; set; } + + /// Raised after a successful create/update so the tree can refresh. + [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); + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/AbCipDriverForm.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/AbCipDriverForm.razor new file mode 100644 index 00000000..2a422476 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/AbCipDriverForm.razor @@ -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 *@ +
+
Operation settings
+
+
+
+ + +
Default libplctag call timeout. Default 2 s.
+
+
+
+ + +
+
Walk the Logix symbol table and surface globals under Discovered/.
+
+
+
+ + +
+
Only enable when UDT member declaration order matches controller compiled layout.
+
+
+
+
+ +@* Alarm projection *@ +
+
Alarm projection
+
+
+
+
+ + +
+
Surfaces ALMD tags as alarm conditions via IAlarmSource. Default off.
+
+
+ + +
Default 1 s.
+
+
+
+
+ +@* Connectivity probe *@ +
+
Connectivity probe
+
+
+
+
+ + +
+
+
+ + +
Default 5 s.
+
+
+ + +
Default 2 s.
+
+
+ + +
Required when probe is enabled. Leave blank and an operator warning is logged.
+
+
+ + +
Max 60. Used by Test Connect. Default 5.
+
+
+
+
+ + + +@code { + /// The driver-level DriverConfig JSON (protocol/operation; endpoints live on the devices). + [Parameter] public string DriverConfigJson { get; set; } = "{}"; + /// Fired (camelCase-serialized) whenever a field changes — enables @bind-DriverConfigJson. + [Parameter] public EventCallback DriverConfigJsonChanged { get; set; } + /// The per-instance resilience-pipeline overrides JSON, or null. + [Parameter] public string? ResilienceConfig { get; set; } + /// Fired when the resilience overrides change. + [Parameter] public EventCallback 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 _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; + } + } + + /// 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). + 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(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 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, + }; + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/AbLegacyDriverForm.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/AbLegacyDriverForm.razor new file mode 100644 index 00000000..58ccebb3 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/AbLegacyDriverForm.razor @@ -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 *@ +
+
Operation settings
+
+
+
+ + +
Default read/write timeout. Default 2 s.
+
+
+
+
+ +@* Connectivity probe *@ +
+
Connectivity probe
+
+
+
+
+ + +
+
+
+ + +
Default 5 s.
+
+
+ + +
Default 2 s.
+
+
+ + +
PCCC file address to read for probe. Default S:0 (status file word 0).
+
+
+ + +
Max 60. Used by Test Connect. Default 5.
+
+
+
+
+ + + +@code { + /// The driver-level DriverConfig JSON (protocol/channel; devices live on separate v3 entities). + [Parameter] public string DriverConfigJson { get; set; } = "{}"; + /// Fired (camelCase-serialized) whenever a channel field changes — enables @bind-DriverConfigJson. + [Parameter] public EventCallback DriverConfigJsonChanged { get; set; } + /// The per-instance resilience-pipeline overrides JSON, or null. + [Parameter] public string? ResilienceConfig { get; set; } + /// Fired when the resilience overrides change. + [Parameter] public EventCallback 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 _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; + } + } + + /// Serializes the current channel/protocol config to camelCase JSON, preserving the driver's + /// existing Devices array (authored as separate v3 entities). + 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(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 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, + }; + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/FocasDriverForm.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/FocasDriverForm.razor new file mode 100644 index 00000000..2cc1e0d4 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/FocasDriverForm.razor @@ -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 *@ +
+
Connection
+
+
+
+ + +
Per-operation timeout. Default 2 s.
+
+
+
+
+ +@* Probe *@ +
+
Connectivity probe
+
+
+
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
Test Connect timeout (1–60 s).
+
+
+
+
+ +@* Alarm projection *@ +
+
Alarm projection
+
+
+
+
+ + +
+
Surfaces FOCAS alarms via IAlarmSource.
+
+
+ + +
One cnc_rdalmmsg2 call per device per tick. Default 2 s.
+
+
+
+
+ +@* Handle recycle *@ +
+
Handle recycle
+
+
+
+
+ + +
+
Proactive FWLIB session recycle to prevent handle pool exhaustion. Default off.
+
+
+ + +
Typical: 30 min (shared pool) or 360 min (single client).
+
+
+
+
+ +@* Fixed tree *@ +
+
Fixed-node tree
+
+
+
+
+ + +
+
Exposes Identity/, Axes/, etc. from cnc_sysinfo/cnc_rdaxisname/cnc_rddynamic2. Default off.
+
+
+ + +
cnc_rddynamic2 cadence per axis. Default 250 ms.
+
+
+ + +
Program/mode info cadence. 0 = disabled. Default 1 s.
+
+
+ + +
Power-on/cutting/cycle timer cadence. 0 = disabled. Default 30 s.
+
+
+
+
+ + + +@code { + /// The driver-level DriverConfig JSON (protocol/channel; per-device endpoint lives in the device). + [Parameter] public string DriverConfigJson { get; set; } = "{}"; + /// Fired (camelCase-serialized) whenever a channel field changes — enables @bind-DriverConfigJson. + [Parameter] public EventCallback DriverConfigJsonChanged { get; set; } + /// The per-instance resilience-pipeline overrides JSON, or null. + [Parameter] public string? ResilienceConfig { get; set; } + /// Fired when the resilience overrides change. + [Parameter] public EventCallback 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 _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; + } + } + + /// Serializes the current channel/protocol config to camelCase JSON, re-attaching the + /// preserved multi-device collection (authored on the Raw tree). + 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(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 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, + }; + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/GalaxyDriverForm.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/GalaxyDriverForm.razor new file mode 100644 index 00000000..17468d7d --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/GalaxyDriverForm.razor @@ -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 *@ +
+
mxaccessgw connection
+
+
+
+ + +
gRPC endpoint of the mxaccessgw process.
+
+
+ + +
Forms: env:NAME, file:PATH, dev:KEY. Cleartext is accepted but triggers a startup warning.
+
+
+
+ + +
+
+
+ + +
+
+ + +
Default 10 s.
+
+
+ + +
Default 30 s.
+
+
+ + +
Default 0 (lifetime of driver).
+
+
+
+
+ +@* MXAccess *@ +
+
MXAccess
+
+
+
+ + +
Must be unique per OtOpcUa instance — redundancy pairs each get a distinct name.
+
+
+ + +
Default 1000 ms.
+
+
+ + +
0 = anonymous.
+
+
+ + +
Default 50000. Raise if events.dropped appears.
+
+
+
+
+ +@* Repository *@ +
+
Galaxy repository
+
+
+
+ + +
Default 5000 objects per page.
+
+
+
+ + +
+
Triggers address-space rebuild on Galaxy re-deploy.
+
+
+
+
+ +@* Reconnect *@ +
+
Reconnect backoff
+
+
+
+ + +
Default 500 ms.
+
+
+ + +
Default 30000 ms.
+
+
+
+ + +
+
+
+
+
+ +@* Diagnostics *@ +
+
Diagnostics
+
+
+
+ + +
Max 60. Used by Test Connect. Default 30.
+
+
+
+
+ + + +@code { + [Parameter] public string DriverConfigJson { get; set; } = "{}"; + [Parameter] public EventCallback DriverConfigJsonChanged { get; set; } + [Parameter] public string? ResilienceConfig { get; set; } + [Parameter] public EventCallback 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(json, _jsonOpts); } + catch { return null; } + } + + public sealed class FormModel + { + public GalaxyFormModel Galaxy { get; set; } = new(); + } + + /// Mutable flat mirror of and its nested records. + 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, + }; + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/ModbusDriverForm.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/ModbusDriverForm.razor new file mode 100644 index 00000000..da9036ba --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/ModbusDriverForm.razor @@ -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 *@ +
+
Protocol
+
+
+
+ + +
Default 2 s.
+
+
+ + + @foreach (var e in Enum.GetValues()) + { + + } + +
+
+ + + @foreach (var e in Enum.GetValues()) + { + + } + +
Only used when Family = MELSEC.
+
+
+
+ + +
+
+
+
+
+ +@* Batch sizes *@ +
+
Batch sizes
+
+
+
+ + +
Spec max 125. Reduce for limited devices.
+
+
+ + +
Spec max 123.
+
+
+ + +
Spec max 2000.
+
+
+ + +
0 = no coalescing. Typical 5–32.
+
+
+
+
+ +@* Write options *@ +
+
Write options
+
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+
+ +@* Keep-alive *@ +
+
TCP keep-alive
+
+
+
+
+ + +
+
+
+ + +
Idle time before first probe. Default 30 s.
+
+
+ + +
Between probes once started. Default 10 s.
+
+
+ + +
Probes before declaring socket dead. Default 3.
+
+
+
+
+ +@* Reconnect backoff *@ +
+
Reconnect backoff
+
+
+
+ + +
0 = immediate retry.
+
+
+ + +
Default 30 s.
+
+
+ + +
Default 2.0 (doubles each step).
+
+
+ + +
Proactive close after idle period. 0 = disabled.
+
+
+
+
+ +@* Probe *@ +
+
Connectivity probe
+
+
+
+
+ + +
+
+
+ + +
Default 5 s.
+
+
+ + +
Default 2 s.
+
+
+ + +
Zero-based. Default 0 (FC03 at register 0).
+
+
+ + +
Max 60. Used by Test Connect. Default 5.
+
+
+ + +
Interval to retry auto-prohibited coalesced ranges.
+
+
+
+
+ + + +@code { + /// The driver-level DriverConfig JSON (protocol/channel; endpoint lives in the device). + [Parameter] public string DriverConfigJson { get; set; } = "{}"; + /// Fired (camelCase-serialized) whenever a channel field changes — enables @bind-DriverConfigJson. + [Parameter] public EventCallback DriverConfigJsonChanged { get; set; } + /// The per-instance resilience-pipeline overrides JSON, or null. + [Parameter] public string? ResilienceConfig { get; set; } + /// Fired when the resilience overrides change. + [Parameter] public EventCallback 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; + } + } + + /// 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). + 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(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, + }; + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/OpcUaClientDriverForm.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/OpcUaClientDriverForm.razor new file mode 100644 index 00000000..53f0611c --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/OpcUaClientDriverForm.razor @@ -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) *@ +
+
Endpoint
+
+
+
+ + +
Restrict mirroring to a sub-tree.
+
+
+ + +
+
+ + +
+
+
+ + +
+
+
+
+
+ + +
Default 3 s — failover sweep budget.
+
+
+ + +
Default 10 s — steady-state reads/writes.
+
+
+ + +
Default 120 s.
+
+
+ + +
Default 5 s.
+
+
+ + +
Initial delay after session drop. Default 5 s.
+
+
+ + +
Default 10000.
+
+
+ + +
Default 10.
+
+
+
+
+ + + Endpoint URL (failover list — first reachable wins) + + + @e.Url + + + + +
Failover list. The per-device Endpoint URL (on the device) is used only when this list is empty.
+
+
+
+
+
+
+ +@* Security *@ +
+
Security
+
+
+
+ + + @foreach (var e in Enum.GetValues()) + { + + } + +
+
+ + + @foreach (var e in Enum.GetValues()) + { + + } + +
+
+
+
+ +@* Authentication *@ +
+
Authentication
+
+
+
+ + + @foreach (var e in Enum.GetValues()) + { + + } + +
+ @if (_form.OpcUa.AuthType == OpcUaAuthType.Username) + { +
+ + +
+
+ + +
+ } + @if (_form.OpcUa.AuthType == OpcUaAuthType.Certificate) + { +
+ + +
+
+ + +
+ } +
+
+
+ +@* Namespace mapping *@ +
+
Namespace mapping
+
+
+
+ + + @foreach (var e in Enum.GetValues()) + { + + } + +
Equipment = raw data re-mapped to UNS. SystemPlatform = processed data; hierarchy preserved as-is.
+
+
+ +
@_unsMappingTableJson
+
Keys = remote browse-path prefixes; values = UNS Area/Line/Name paths. Required when TargetNamespaceKind = Equipment.
+
+
+
+
+ +@* Diagnostics *@ +
+
Diagnostics
+
+
+
+ + +
Max 60. Used by Test Connect. Default 15.
+
+
+
+
+ + + +@code { + [Parameter] public string DriverConfigJson { get; set; } = "{}"; + [Parameter] public EventCallback DriverConfigJsonChanged { get; set; } + [Parameter] public string? ResilienceConfig { get; set; } + [Parameter] public EventCallback 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 _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(json, _jsonOpts); } + catch { return null; } + } + + public sealed class FormModel + { + public OpcUaClientFormModel OpcUa { get; set; } = new(); + } + + /// Mutable VM for a single failover endpoint URL row. + 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 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; + } + } + + /// Mutable mirror of (int wrappers for TimeSpans). + /// EndpointUrl (single) has NO UI — the device form authors it; kept at default so ToRecord is valid. + 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 _unsMappingTable = new Dictionary(); + + 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 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, + }; + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/S7DriverForm.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/S7DriverForm.razor new file mode 100644 index 00000000..d450f73b --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/S7DriverForm.razor @@ -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 *@ +
+
Connection
+
+
+
+ + +
+
+ + + @foreach (var v in Enum.GetValues()) + { + + } + +
Controls ISO-TSAP slot byte during handshake.
+
+
+
+
+ +@* Probe *@ +
+
Connectivity probe
+
+
+
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
Test Connect timeout (1–60 s).
+
+
+
+
+ + + +@code { + /// The driver-level DriverConfig JSON (protocol; endpoint lives in the device). + [Parameter] public string DriverConfigJson { get; set; } = "{}"; + /// Fired (camelCase-serialized) whenever a protocol field changes — enables @bind-DriverConfigJson. + [Parameter] public EventCallback DriverConfigJsonChanged { get; set; } + /// The per-instance resilience-pipeline overrides JSON, or null. + [Parameter] public string? ResilienceConfig { get; set; } + /// Fired when the resilience overrides change. + [Parameter] public EventCallback 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; + } + } + + /// 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). + 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(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, + }; + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/TwinCATDriverForm.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/TwinCATDriverForm.razor new file mode 100644 index 00000000..97c5828f --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/TwinCATDriverForm.razor @@ -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 *@ +
+
Options
+
+
+
+ + +
Default 2 s per operation.
+
+
+
+ + +
+
Recommended. Disable for AMS routers with notification limits.
+
+
+
+ + +
+
Walks the symbol table via SymbolLoaderFactory at discovery. Default off.
+
+
+
+
+ + +
0 = push immediately. Increase to coalesce high-churn signals.
+
+
+
+
+ +@* Probe *@ +
+
Connectivity probe
+
+
+
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
Test Connect timeout (1–60 s).
+
+
+
+
+ + + +@code { + /// The driver-level DriverConfig JSON (protocol/channel; endpoint lives in the device). + [Parameter] public string DriverConfigJson { get; set; } = "{}"; + /// Fired (camelCase-serialized) whenever a channel field changes — enables @bind-DriverConfigJson. + [Parameter] public EventCallback DriverConfigJsonChanged { get; set; } + /// The per-instance resilience-pipeline overrides JSON, or null. + [Parameter] public string? ResilienceConfig { get; set; } + /// Fired when the resilience overrides change. + [Parameter] public EventCallback 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 _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; + } + } + + /// Serializes the current channel/protocol config to camelCase JSON, preserving the Devices + /// collection (authored elsewhere) verbatim. + 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(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 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, + }; + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AbCipDriverPageFormSerializationTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AbCipDriverPageFormSerializationTests.cs index 5ae96fe8..3e171d59 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AbCipDriverPageFormSerializationTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AbCipDriverPageFormSerializationTests.cs @@ -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 { 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 { 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(json, TestJsonOpts)!; back.Devices.Count.ShouldBe(2); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AbLegacyDriverPageFormSerializationTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AbLegacyDriverPageFormSerializationTests.cs index 973db1d1..5c22a9a1 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AbLegacyDriverPageFormSerializationTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AbLegacyDriverPageFormSerializationTests.cs @@ -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 { 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 { 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(json, TestJsonOpts)!; back.Devices.Count.ShouldBe(2); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/DriverPageJsonConverterTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/DriverPageJsonConverterTests.cs index 6726d347..2b05a9af 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/DriverPageJsonConverterTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/DriverPageJsonConverterTests.cs @@ -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; /// /// Regression guard for the systemic driver-config enum-serialization bug found 2026-06-19. -/// Every *DriverPage serialised enum config fields (e.g. S7 CpuType, Modbus -/// DataType/Region, AbCip PlcFamily) as JSON numbers because its -/// private static _jsonOpts had no . The driver -/// factories, however, deserialise into string-typed DTOs (+ lenient ParseEnum) and -/// throw when binding a JSON number to a string? — 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 CpuType, Modbus +/// Family, AbCip PlcFamily) as JSON numbers when its private static +/// _jsonOpts had no . The driver factories, however, +/// deserialise into string-typed DTOs (+ lenient ParseEnum) and throw when binding a +/// JSON number to a string? — 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). +/// v3 Batch-2 (WP3): the config serializer moved from the routed *DriverPage into the +/// embeddable *DriverForm body (shared by the page + the raw-tree DriverConfigModal), so this +/// guard now reflects over the *DriverForm fleet. It fails if any driver form loses its +/// string-enum converter again. /// public sealed class DriverPageJsonConverterTests { - /// Every concrete *DriverPage in the AdminUI assembly that declares a + /// Every concrete *DriverForm in the AdminUI assembly that declares a /// _jsonOpts config serializer. - private static IReadOnlyList DriverPageTypes { get; } = - typeof(S7DriverPage).Assembly.GetTypes() - .Where(t => t.Name.EndsWith("DriverPage", StringComparison.Ordinal) && !t.IsAbstract) + private static IReadOnlyList 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(); - /// xUnit theory source over the driver-page types discovered by reflection. - public static TheoryData DriverPagesWithJsonOpts() + /// xUnit theory source over the driver-form types discovered by reflection. + public static TheoryData DriverFormsWithJsonOpts() { var data = new TheoryData(); - foreach (var t in DriverPageTypes) + foreach (var t in DriverFormTypes) data.Add(t); return data; } - /// Verifies every driver page's config serializer registers a string-enum converter so + /// 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). - /// A driver page component type discovered by reflection. + /// A driver form component type discovered by reflection. [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().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."); } - /// Enforces that EVERY concrete *DriverPage routes config serialization through a - /// _jsonOpts field — otherwise a new page that serialised config a different way would slip + /// Enforces that EVERY concrete *DriverForm routes config serialization through a + /// _jsonOpts 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. [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"); } } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/FocasDriverPageFormSerializationTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/FocasDriverPageFormSerializationTests.cs index 607e83a7..78382bf9 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/FocasDriverPageFormSerializationTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/FocasDriverPageFormSerializationTests.cs @@ -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 { 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 { 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(json, TestJsonOpts)!; back.Devices.Count.ShouldBe(2); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/GalaxyDriverPageFormSerializationTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/GalaxyDriverPageFormSerializationTests.cs index 001fdcc8..8fce86f7 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/GalaxyDriverPageFormSerializationTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/GalaxyDriverPageFormSerializationTests.cs @@ -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(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 /// /// Defence-in-depth: a config that genuinely OMITS a section (no Reconnect key at all) - /// must not throw — must + /// must not throw — must /// null-coalesce the missing section to its default value. /// [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 } /// - /// Confirms that still + /// Confirms that still /// round-trips correctly when all nested records are populated (non-regressed path). /// [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"); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/OpcUaClientDriverPageFormSerializationTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/OpcUaClientDriverPageFormSerializationTests.cs index 60920f4d..5b97d79e 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/OpcUaClientDriverPageFormSerializationTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/OpcUaClientDriverPageFormSerializationTests.cs @@ -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(); - var row = new OpcUaClientDriverPage.EndpointUrlRow { Url = " " }; + var all = new List(); + 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(); - var row = new OpcUaClientDriverPage.EndpointUrlRow { Url = "http://plc:4840" }; + var all = new List(); + 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 + var all = new List { 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 + var all = new List { 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 { "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"); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/S7DriverPageFormSerializationTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/S7DriverPageFormSerializationTests.cs index 69f11487..977571b4 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/S7DriverPageFormSerializationTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/S7DriverPageFormSerializationTests.cs @@ -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"); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/TwinCATDriverPageFormSerializationTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/TwinCATDriverPageFormSerializationTests.cs index 2a3ad37d..2664b942 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/TwinCATDriverPageFormSerializationTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/TwinCATDriverPageFormSerializationTests.cs @@ -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") }; diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/ModbusDeviceModelTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/ModbusDeviceModelTests.cs new file mode 100644 index 00000000..e5609a8a --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/ModbusDeviceModelTests.cs @@ -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; + +/// +/// Round-trip guard for the Modbus device 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. +/// +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(); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/ModbusDriverFormModelTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/ModbusDriverFormModelTests.cs new file mode 100644 index 00000000..c7e47597 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/ModbusDriverFormModelTests.cs @@ -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; + +/// +/// Round-trip + enum-serialization guard for the Modbus driver (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. +/// +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(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 + } +}