diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/FocasDriverPage.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/FocasDriverPage.razor new file mode 100644 index 00000000..c22183af --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/FocasDriverPage.razor @@ -0,0 +1,449 @@ +@page "/clusters/{ClusterId}/drivers/new/focas" +@attribute [Microsoft.AspNetCore.Authorization.Authorize] +@rendermode RenderMode.InteractiveServer +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.EntityFrameworkCore +@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers +@using ZB.MOM.WW.OtOpcUa.Configuration +@using ZB.MOM.WW.OtOpcUa.Configuration.Entities +@using ZB.MOM.WW.OtOpcUa.Driver.FOCAS +@inject IDbContextFactory DbFactory +@inject NavigationManager Nav + +
+

@(IsNew ? "New Fanuc FOCAS driver" : "Edit Fanuc FOCAS driver") · @ClusterId

+ Cancel +
+ + +@if (!_loaded) +{ +

Loading…

+} +else if (!IsNew && _existing is null) +{ +
+ Driver instance @DriverInstanceId was not found in cluster @ClusterId. +
+} +else +{ + + + + + + + @* 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.
+
+
+
+
+ + @* Devices — read-only JSON view *@ +
+
Devices
+
+
+ Each device represents one CNC. Device list editor (with CNC series selector) coming in a follow-up phase. + Format: [{"hostAddress":"192.168.0.10:8193","deviceName":"CNC1","series":"Thirty_i"}] +
+ @if (_form.DevicesJson is not null) + { +
@_form.DevicesJson
+ } + else + { +

No devices configured.

+ } +
+
+ + @* Tags — read-only JSON view *@ +
+
Tags
+
+
+ Tag list editor coming in a follow-up phase. Tags reference device host addresses and FOCAS address strings + (e.g. X0.0, R100, PARAM:1815/0, MACRO:500). +
+ @if (_form.TagsJson is not null) + { +
@_form.TagsJson
+ } + else + { +

No tags configured.

+ } +
+
+ + +
+
+} + +@code { + [Parameter] public string ClusterId { get; set; } = ""; + [Parameter] public string? DriverInstanceId { get; set; } + + private const string DriverTypeKey = "Focas"; + + private bool IsNew => string.IsNullOrEmpty(DriverInstanceId); + + private static readonly System.Text.Json.JsonSerializerOptions _jsonOpts = new() + { + PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase, + UnmappedMemberHandling = System.Text.Json.Serialization.JsonUnmappedMemberHandling.Skip, + WriteIndented = false, + }; + + private FormModel _form = new(); + private DriverIdentitySection.DriverIdentityModel _identityModel = new() { DriverType = DriverTypeKey }; + private DriverInstance? _existing; + private List _namespaces = new(); + private bool _loaded, _busy; + private string? _error; + + protected override async Task OnInitializedAsync() + { + await using var db = await DbFactory.CreateDbContextAsync(); + _namespaces = await db.Namespaces.AsNoTracking() + .Where(n => n.ClusterId == ClusterId) + .OrderBy(n => n.NamespaceId).ToListAsync(); + + if (IsNew) + { + _identityModel = new() { DriverType = DriverTypeKey, NamespaceId = _namespaces.FirstOrDefault()?.NamespaceId ?? "", Enabled = true }; + _form = FormModel.FromOptions(new FocasDriverOptions()); + } + else + { + _existing = await db.DriverInstances.AsNoTracking() + .FirstOrDefaultAsync(d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId); + if (_existing is not null) + { + _identityModel = new() + { + DriverInstanceId = _existing.DriverInstanceId, + Name = _existing.Name, + DriverType = _existing.DriverType, + NamespaceId = _existing.NamespaceId, + Enabled = _existing.Enabled, + }; + var opts = TryDeserialize(_existing.DriverConfig) ?? new FocasDriverOptions(); + _form = FormModel.FromOptions(opts); + _form.ResilienceConfig = _existing.ResilienceConfig; + _form.RowVersion = _existing.RowVersion; + } + } + _loaded = true; + } + + private async Task SubmitAsync() + { + _busy = true; _error = null; + try + { + var opts = _form.ToOptions(); + var configJson = System.Text.Json.JsonSerializer.Serialize(opts, _jsonOpts); + await using var db = await DbFactory.CreateDbContextAsync(); + if (IsNew) + { + if (await db.DriverInstances.AnyAsync(d => d.DriverInstanceId == _identityModel.DriverInstanceId)) + { + _error = $"Driver instance '{_identityModel.DriverInstanceId}' already exists."; return; + } + db.DriverInstances.Add(new DriverInstance + { + DriverInstanceId = _identityModel.DriverInstanceId, + ClusterId = ClusterId, + NamespaceId = _identityModel.NamespaceId, + Name = _identityModel.Name, + DriverType = DriverTypeKey, + Enabled = _identityModel.Enabled, + DriverConfig = configJson, + ResilienceConfig = string.IsNullOrWhiteSpace(_form.ResilienceConfig) ? null : _form.ResilienceConfig, + }); + } + else + { + var entity = await db.DriverInstances.FirstOrDefaultAsync( + d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId); + if (entity is null) { _error = "Row no longer exists."; return; } + db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion; + entity.NamespaceId = _identityModel.NamespaceId; + entity.Name = _identityModel.Name; + entity.Enabled = _identityModel.Enabled; + entity.DriverConfig = configJson; + entity.ResilienceConfig = string.IsNullOrWhiteSpace(_form.ResilienceConfig) ? null : _form.ResilienceConfig; + } + await db.SaveChangesAsync(); + Nav.NavigateTo($"/clusters/{ClusterId}/drivers"); + } + catch (DbUpdateConcurrencyException) + { + _error = "Another user changed this driver instance while you were editing. Reload to see the latest values, then re-apply your changes."; + } + catch (Exception ex) { _error = ex.Message; } + finally { _busy = false; } + } + + private async Task DeleteAsync() + { + if (IsNew) return; + _busy = true; _error = null; + try + { + await using var db = await DbFactory.CreateDbContextAsync(); + var entity = await db.DriverInstances.FirstOrDefaultAsync( + d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId); + if (entity is null) { Nav.NavigateTo($"/clusters/{ClusterId}/drivers"); return; } + db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion; + db.DriverInstances.Remove(entity); + await db.SaveChangesAsync(); + Nav.NavigateTo($"/clusters/{ClusterId}/drivers"); + } + catch (DbUpdateConcurrencyException) + { + _error = "Another user changed this driver instance while you were viewing it. Reload before deleting."; + } + catch (Exception ex) + { + _error = $"Delete failed: {ex.Message}. (Likely because equipment/tags still reference this driver — remove them first.)"; + } + finally { _busy = false; } + } + + private static FocasDriverOptions? TryDeserialize(string json) + { + try { return System.Text.Json.JsonSerializer.Deserialize(json, _jsonOpts); } + catch { return null; } + } + + 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; + + // Collections JSON view (read-only) + public string? DevicesJson { get; set; } + public string? TagsJson { get; set; } + + // Preserved originals (round-tripped unchanged) + private IReadOnlyList _devices = []; + private IReadOnlyList _tags = []; + + // Common + public string? ResilienceConfig { get; set; } + public byte[] RowVersion { get; set; } = []; + + private static readonly System.Text.Json.JsonSerializerOptions _displayOpts = new() + { + WriteIndented = true, + PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase, + }; + + public static FormModel FromOptions(FocasDriverOptions o) + { + var m = new FormModel + { + 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, + _devices = o.Devices, + _tags = o.Tags, + }; + m.DevicesJson = o.Devices.Count == 0 ? null + : System.Text.Json.JsonSerializer.Serialize(o.Devices, _displayOpts); + m.TagsJson = o.Tags.Count == 0 ? null + : System.Text.Json.JsonSerializer.Serialize(o.Tags, _displayOpts); + return m; + } + + public FocasDriverOptions ToOptions() => 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, + Tags = _tags, + }; + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/FocasDriverPageFormSerializationTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/FocasDriverPageFormSerializationTests.cs new file mode 100644 index 00000000..847f8bbb --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/FocasDriverPageFormSerializationTests.cs @@ -0,0 +1,135 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.FOCAS; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests; + +public sealed class FocasDriverPageFormSerializationTests +{ + private static readonly JsonSerializerOptions _opts = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = false, + }; + + [Fact] + public void RoundTrip_PreservesKnownFields() + { + var original = new FocasDriverOptions + { + Timeout = TimeSpan.FromSeconds(3), + Probe = new FocasProbeOptions + { + Enabled = false, + Interval = TimeSpan.FromSeconds(10), + Timeout = TimeSpan.FromSeconds(4), + }, + ProbeTimeoutSeconds = 30, + AlarmProjection = new FocasAlarmProjectionOptions + { + Enabled = true, + PollInterval = TimeSpan.FromSeconds(5), + }, + HandleRecycle = new FocasHandleRecycleOptions + { + Enabled = true, + Interval = TimeSpan.FromMinutes(30), + }, + FixedTree = new FocasFixedTreeOptions + { + Enabled = true, + PollInterval = TimeSpan.FromMilliseconds(500), + ProgramPollInterval = TimeSpan.FromSeconds(2), + TimerPollInterval = TimeSpan.FromSeconds(60), + }, + Devices = [], + Tags = [], + }; + + var json = JsonSerializer.Serialize(original, _opts); + var back = JsonSerializer.Deserialize(json, _opts); + + back.ShouldNotBeNull(); + back.Timeout.ShouldBe(TimeSpan.FromSeconds(3)); + back.Probe.Enabled.ShouldBeFalse(); + back.Probe.Interval.ShouldBe(TimeSpan.FromSeconds(10)); + back.Probe.Timeout.ShouldBe(TimeSpan.FromSeconds(4)); + back.ProbeTimeoutSeconds.ShouldBe(30); + back.AlarmProjection.Enabled.ShouldBeTrue(); + back.AlarmProjection.PollInterval.ShouldBe(TimeSpan.FromSeconds(5)); + back.HandleRecycle.Enabled.ShouldBeTrue(); + back.HandleRecycle.Interval.ShouldBe(TimeSpan.FromMinutes(30)); + back.FixedTree.Enabled.ShouldBeTrue(); + back.FixedTree.PollInterval.ShouldBe(TimeSpan.FromMilliseconds(500)); + back.FixedTree.ProgramPollInterval.ShouldBe(TimeSpan.FromSeconds(2)); + back.FixedTree.TimerPollInterval.ShouldBe(TimeSpan.FromSeconds(60)); + back.Devices.ShouldBeEmpty(); + back.Tags.ShouldBeEmpty(); + } + + [Fact] + public void Deserialize_DropsUnknownFields() + { + var jsonWithExtra = """{"unknownField":"old-value","probeTimeoutSeconds":18}"""; + var optsSkip = new JsonSerializerOptions(_opts) + { + UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip, + }; + var back = JsonSerializer.Deserialize(jsonWithExtra, optsSkip); + back.ShouldNotBeNull(); + back.ProbeTimeoutSeconds.ShouldBe(18); + } + + [Fact] + public void FormModel_RoundTrip_PreservesEditableFields() + { + var opts = new FocasDriverOptions + { + Timeout = TimeSpan.FromSeconds(4), + Probe = new FocasProbeOptions + { + Enabled = true, + Interval = TimeSpan.FromSeconds(8), + Timeout = TimeSpan.FromSeconds(3), + }, + ProbeTimeoutSeconds = 25, + AlarmProjection = new FocasAlarmProjectionOptions + { + Enabled = true, + PollInterval = TimeSpan.FromSeconds(3), + }, + HandleRecycle = new FocasHandleRecycleOptions + { + Enabled = false, + Interval = TimeSpan.FromHours(2), + }, + FixedTree = new FocasFixedTreeOptions + { + Enabled = true, + PollInterval = TimeSpan.FromMilliseconds(200), + ProgramPollInterval = TimeSpan.FromSeconds(5), + TimerPollInterval = TimeSpan.FromSeconds(45), + }, + }; + + var form = ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers + .FocasDriverPage.FormModel.FromOptions(opts); + var roundTripped = form.ToOptions(); + + roundTripped.Timeout.ShouldBe(TimeSpan.FromSeconds(4)); + roundTripped.Probe.Enabled.ShouldBeTrue(); + roundTripped.Probe.Interval.ShouldBe(TimeSpan.FromSeconds(8)); + roundTripped.Probe.Timeout.ShouldBe(TimeSpan.FromSeconds(3)); + roundTripped.ProbeTimeoutSeconds.ShouldBe(25); + roundTripped.AlarmProjection.Enabled.ShouldBeTrue(); + roundTripped.AlarmProjection.PollInterval.ShouldBe(TimeSpan.FromSeconds(3)); + roundTripped.HandleRecycle.Enabled.ShouldBeFalse(); + roundTripped.HandleRecycle.Interval.ShouldBe(TimeSpan.FromHours(2)); + roundTripped.FixedTree.Enabled.ShouldBeTrue(); + roundTripped.FixedTree.PollInterval.ShouldBe(TimeSpan.FromMilliseconds(200)); + roundTripped.FixedTree.ProgramPollInterval.ShouldBe(TimeSpan.FromSeconds(5)); + roundTripped.FixedTree.TimerPollInterval.ShouldBe(TimeSpan.FromSeconds(45)); + } +}