diff --git a/IoListTestingWindow.EmbeddedEngineeringHost.cs b/IoListTestingWindow.EmbeddedEngineeringHost.cs new file mode 100644 index 000000000..39421b40c --- /dev/null +++ b/IoListTestingWindow.EmbeddedEngineeringHost.cs @@ -0,0 +1,196 @@ +using System.Runtime.CompilerServices; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Threading; +using ArIED61850Tester.Models; + +namespace ArIED61850Tester; + +/// +/// Hosts the proven IoListTestingWindow production workspace inside MainWindow's FAT tab. +/// The production Window remains loaded-but-hidden so its existing session controller, +/// auto-capture lifecycle, persistence and report-preview code stay the single FAT authority. +/// Only its central workspace + FAT status footer are re-parented into Engineering. +/// +public partial class IoListTestingWindow +{ + private bool _engineeringEmbeddedMountQueued; + private bool _engineeringEmbeddedMounted; + private FrameworkElement? _engineeringEmbeddedSurface; + + [ModuleInitializer] + internal static void RegisterEmbeddedEngineeringFatHost() + { + EventManager.RegisterClassHandler( + typeof(IoListTestingWindow), + FrameworkElement.LoadedEvent, + new RoutedEventHandler(EmbeddedEngineeringFatHost_Loaded), + handledEventsToo: true); + } + + internal void PrepareForEmbeddedEngineeringHost() + { + // WPF rejects Show() when a Window is both non-activating and Maximized. + // The donor only exists long enough to create the proven production visual tree, + // so normalize it before the hidden/off-screen bootstrap and let MainWindow own size. + if (WindowState == WindowState.Maximized) + WindowState = WindowState.Normal; + + ShowActivated = false; + ShowInTaskbar = false; + Opacity = 0d; + WindowStartupLocation = WindowStartupLocation.Manual; + Left = -32000d; + Top = -32000d; + } + + private static void EmbeddedEngineeringFatHost_Loaded(object sender, RoutedEventArgs e) + { + if (sender is not IoListTestingWindow window || + !ReferenceEquals(e.OriginalSource, window) || + window._engineeringEmbeddedMounted || + window._engineeringEmbeddedMountQueued || + window.Owner is not MainWindow owner || + !owner.ProductionFatTabReady) + { + return; + } + + window._engineeringEmbeddedMountQueued = true; + + // MainWindow's legacy launcher still calls Show() on this Window. Make that bootstrap + // surface invisible immediately; the actual production visual is moved into Engineering + // on the next Loaded-priority dispatcher turn, before ContextIdle command-panel work. + window.PrepareForEmbeddedEngineeringHost(); + + window.Dispatcher.BeginInvoke( + DispatcherPriority.Loaded, + new Action(() => window.TryMountIntoEngineering(owner))); + } + + private void TryMountIntoEngineering(MainWindow owner) + { + _engineeringEmbeddedMountQueued = false; + if (_engineeringEmbeddedMounted || !IsLoaded || !ReferenceEquals(Owner, owner) || !owner.ProductionFatTabReady) + return; + + try + { + EnsureProductionFatPresentationForEmbeddedHost(); + DisableLegacyEmbeddedCommandPanel(); + var surface = DetachProductionFatCentralWorkspace(); + if (surface == null) + return; + + _engineeringEmbeddedSurface = surface; + _engineeringEmbeddedMounted = owner.MountProductionFatWorkspace(this, surface); + if (!_engineeringEmbeddedMounted) + return; + + // The central FAT view now belongs to MainWindow. Keep this Window loaded and + // hidden because existing controller/session/event code is intentionally reused. + Hide(); + } + catch (Exception ex) + { + Opacity = 1d; + ShowInTaskbar = true; + ShowActivated = true; + MessageBox.Show( + owner, + $"ARSAS could not embed the production FAT workspace. The standalone FAT window will remain available.\n\n{ex.Message}", + "FAT workspace host", + MessageBoxButton.OK, + MessageBoxImage.Warning); + } + } + + private void EnsureProductionFatPresentationForEmbeddedHost() + { + // P1 normally installs this during Loaded. Calling it explicitly is safe/idempotent + // and guarantees the first embedded frame is the same V2 FAT grid as the old Window. + InstallFatV2WorkspaceUx(); + + // PrintPreview historically installs from OnContentRendered. The bootstrap Window is + // intentionally transparent, so install the exact same production preview explicitly + // before the central workspace is detached. This preserves full-center mode switching. + if (!_printPreviewInstalled) + { + InstallPerIedPrintPreview(); + PropertyChanged += PrintPreviewWindow_PropertyChanged; + Session.PropertyChanged += PrintPreviewSession_PropertyChanged; + Closed += PrintPreviewWindow_Closed; + _printPreviewInstalled = true; + } + + // The embedded center already declares WorkspacePreviewToggle in XAML. Make that + // visible button the production toggle authority instead of the hidden Window header. + if (WorkspacePreviewToggle != null) + _printPreviewToggle = WorkspacePreviewToggle; + } + + private void DisableLegacyEmbeddedCommandPanel() + { + // Engineering already owns one shared Command Dock. Prevent the old FAT window's + // duplicate command panel from being created/refreshed while embedded. A non-null + // sentinel makes the queued legacy installer return immediately; null row/summary + // references make its queued refresh a no-op. No command runtime semantics change. + DetachFatCommandDevice(); + if (_fatCommandPanelShell?.Parent is Grid existingHost) + { + var row = Grid.GetRow(_fatCommandPanelShell); + existingHost.Children.Remove(_fatCommandPanelShell); + if (row >= 0 && row < existingHost.RowDefinitions.Count) + existingHost.RowDefinitions[row].Height = new GridLength(0); + if (row - 1 >= 0 && row - 1 < existingHost.RowDefinitions.Count) + existingHost.RowDefinitions[row - 1].Height = new GridLength(0); + } + + _fatCommandPanelShell ??= new Border { Visibility = Visibility.Collapsed }; + _fatCommandRows = null; + _fatCommandSummary = null; + _fatCommandEmptyState = null; + } + + private FrameworkElement? DetachProductionFatCentralWorkspace() + { + if (Content is not Grid root) + return null; + + var middle = root.Children + .OfType() + .FirstOrDefault(child => Grid.GetRow(child) == 2); + var workspaceBorder = middle?.Children + .OfType() + .FirstOrDefault(child => Grid.GetColumn(child) == 2); + if (middle == null || workspaceBorder == null) + return null; + + var footer = root.Children + .OfType() + .FirstOrDefault(child => Grid.GetRow(child) == 4); + + middle.Children.Remove(workspaceBorder); + if (footer != null) + root.Children.Remove(footer); + + var host = new Grid + { + DataContext = this, + Margin = new Thickness(0) + }; + host.RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Star) }); + if (footer != null) + host.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); + + Grid.SetRow(workspaceBorder, 0); + host.Children.Add(workspaceBorder); + if (footer != null) + { + Grid.SetRow(footer, 1); + host.Children.Add(footer); + } + + return host; + } +} diff --git a/IoListTestingWindow.xaml.cs b/IoListTestingWindow.xaml.cs index 84f37fb94..c1ec2bcea 100644 --- a/IoListTestingWindow.xaml.cs +++ b/IoListTestingWindow.xaml.cs @@ -119,18 +119,28 @@ private void SetAddingFatIeds(bool value) private async void StartSession_Click(object sender, RoutedEventArgs e) { - var selectedIed = SelectedIed; - if (selectedIed?.IsPreparing == true) + // M4 freezes both the IED owner and exact evidence scope before any asynchronous + // Engineering preparation. Explorer navigation may continue, but it cannot redirect + // this transaction or silently widen the production capture scope. + var requestedIed = SelectedIed; + if (requestedIed?.IsPreparing == true) return; - var preflight = IoTestSessionPreflight.Validate(selectedIed); - if (!preflight.Succeeded) + IoFatCaptureTargetLease captureLease; + try + { + captureLease = IoFatProductionControllerAdapter.LatchStartTarget(Project, requestedIed); + } + catch (InvalidOperationException ex) { - ShowActionResult(preflight, "FAT session scope is not ready"); + ShowActionResult( + IoTestSessionActionResult.Failure(ex.Message), + "FAT session scope is not ready"); return; } - PreparationStatusText = $"Connecting {selectedIed!.IedName} · {selectedIed.IpAddress}:102"; + var selectedIed = captureLease.Ied; + PreparationStatusText = $"Connecting {selectedIed.IedName} · {selectedIed.IpAddress}:102"; RaisePreparationProperties(); try { @@ -155,7 +165,10 @@ private async void StartSession_Click(object sender, RoutedEventArgs e) } } - var result = Session.Start(selectedIed); + // Revalidate the frozen identity/configuration only after Engineering has + // completed connection preparation. The adapter delegates to the unchanged + // production session controller, which remains the sole evidence writer. + var result = IoFatProductionControllerAdapter.StartLatched(Project, Session, captureLease); ShowActionResult(result, "FAT evidence session could not start"); RaiseStatusProperties(); if (result.Succeeded) @@ -187,7 +200,8 @@ private async void StartSession_Click(object sender, RoutedEventArgs e) private void PauseSession_Click(object sender, RoutedEventArgs e) { - var result = Session.Pause(); + var targetIed = SelectedIed; + var result = Session.Pause(targetIed); ShowActionResult(result, "FAT session could not pause"); if (result.Succeeded) Storage?.SaveNow(); @@ -195,7 +209,10 @@ private void PauseSession_Click(object sender, RoutedEventArgs e) private void ResumeSession_Click(object sender, RoutedEventArgs e) { - var result = Session.Resume(); + // Continue is explicitly IED-targeted. A later Explorer change cannot make the + // production controller rebind this active evidence session to another device. + var targetIed = SelectedIed; + var result = Session.Resume(targetIed); ShowActionResult(result, "FAT session could not resume"); if (result.Succeeded) Storage?.ScheduleSave(); @@ -203,7 +220,8 @@ private void ResumeSession_Click(object sender, RoutedEventArgs e) private void StopSession_Click(object sender, RoutedEventArgs e) { - var result = Session.Stop(); + var targetIed = SelectedIed; + var result = Session.Stop(targetIed); ShowActionResult(result, "FAT session could not stop"); if (result.Succeeded) Storage?.SaveNow(); diff --git a/MainWindow.IoTesting.cs b/MainWindow.IoTesting.cs index 52f1cfc91..69acfa89f 100644 --- a/MainWindow.IoTesting.cs +++ b/MainWindow.IoTesting.cs @@ -494,6 +494,8 @@ private Task ShowIoTestingWorkspaceAsync(IoTestWorkspaceLaunchResult launch, int var controller = launch.Session; var persistence = launch.Workspace; var window = new IoListTestingWindow(launch.Project, controller, persistence) { Owner = this }; + if (ProductionFatTabReady) + window.PrepareForEmbeddedEngineeringHost(); RegisterLoadedIoFatWindow(window); _activeIoTestSessionController = controller; Interlocked.Exchange(ref _ioTestObservationSequence, DateTime.UtcNow.Ticks); @@ -525,7 +527,8 @@ void WindowClosed(object? sender, EventArgs args) } window.Closed += WindowClosed; - Hide(); + if (!ProductionFatTabReady) + Hide(); window.Show(); return Task.CompletedTask; } diff --git a/MainWindow.NativeFatWorkspace.cs b/MainWindow.NativeFatWorkspace.cs new file mode 100644 index 000000000..e5d4f545e --- /dev/null +++ b/MainWindow.NativeFatWorkspace.cs @@ -0,0 +1,28 @@ +using System; +using System.Windows.Threading; + +namespace ArIED61850Tester; + +/// +/// M7 compatibility bridge for the canonical seventh Engineering destination. +/// +/// MainWindow.xaml owns the FAT tab and navigation button. Production FAT is mounted +/// into that permanent XAML slot by MainWindow.ProductionFatTab.cs. This file deliberately +/// owns no FAT rows, capture commands, evidence state, persistence, preview, export, or timers. +/// It retains only the shared slot index and a deferred navigation-geometry refresh used by +/// the canonical MainWindow shell. +/// +public partial class MainWindow +{ + private const int NativeFatWorkspaceIndex = 6; + + private void QueueNativeFatNavigationGeometry() + { + if (!IsLoaded) + return; + + Dispatcher.BeginInvoke( + DispatcherPriority.ApplicationIdle, + new Action(() => UpdateNavigationVisuals(MainTabs.SelectedIndex, animate: false))); + } +} diff --git a/MainWindow.NavigationLayoutFix.cs b/MainWindow.NavigationLayoutFix.cs index fe2e8325f..55e5b0f0b 100644 --- a/MainWindow.NavigationLayoutFix.cs +++ b/MainWindow.NavigationLayoutFix.cs @@ -10,7 +10,7 @@ namespace ArIED61850Tester; /// /// Owns the responsive geometry of the MainWindow workflow header. /// -/// The original XAML used a 760 px shell split into six equal columns while the +/// The original XAML used a 760 px shell split into seven equal columns while the /// selection pill moved in hard-coded 150 px steps. That was barely large enough for /// short labels and clipped "IEC 61850 Explorer" / "GOOSE Subscriber" once the center /// workspace switch and live connection/status chips were also present. This behavior @@ -93,7 +93,7 @@ private static void OnMainWindowButtonClick(object sender, RoutedEventArgs e) if (sender is not MainWindow window || e.Source is not Button button) return; - if (button.Name is not ("NavExplorerButton" or "NavLiveButton" or "NavEventsButton" or "NavAlarmButton" or "NavGooseButton" or "NavDiagnosticsButton")) + if (button.Name is not ("NavExplorerButton" or "NavLiveButton" or "NavEventsButton" or "NavAlarmButton" or "NavGooseButton" or "NavDiagnosticsButton" or "NavNativeFatButton")) return; // A repeated click on the already-selected tab does not raise SelectionChanged, @@ -195,7 +195,8 @@ private static void ApplyResponsiveLayout(MainWindow window) window.FindName("NavEventsButton") as Button, window.FindName("NavAlarmButton") as Button, window.FindName("NavGooseButton") as Button, - window.FindName("NavDiagnosticsButton") as Button + window.FindName("NavDiagnosticsButton") as Button, + window.FindName("NavNativeFatButton") as Button ]; private static void UpdatePillGeometry(MainWindow window, double shellWidth) @@ -204,9 +205,9 @@ private static void UpdatePillGeometry(MainWindow window, double shellWidth) return; // Border padding owns 10 px horizontally. The nav grid itself is divided into - // six equal star columns, so this is the exact width used by each button cell. + // seven equal star columns, so this is the exact width used by each button cell. var contentWidth = Math.Max(0d, shellWidth - 10d); - var cellWidth = contentWidth / 6d; + var cellWidth = contentWidth / 7d; pill.Width = Math.Max(1d, cellWidth - 2d); pill.Height = 36; pill.HorizontalAlignment = HorizontalAlignment.Left; @@ -279,8 +280,8 @@ private static void PositionPill(MainWindow window, bool animate) if (contentWidth <= 0d) contentWidth = Math.Max(0d, shell.Width - shell.Padding.Left - shell.Padding.Right); - var cellWidth = contentWidth / 6d; - var target = Math.Clamp(tabs.SelectedIndex, 0, 5) * cellWidth; + var cellWidth = contentWidth / 7d; + var target = Math.Clamp(tabs.SelectedIndex, 0, 6) * cellWidth; pill.Width = Math.Max(1d, cellWidth - 2d); translate.BeginAnimation(TranslateTransform.XProperty, null); diff --git a/MainWindow.ProductionFatEngineeringBootstrap.cs b/MainWindow.ProductionFatEngineeringBootstrap.cs new file mode 100644 index 000000000..72238ad5d --- /dev/null +++ b/MainWindow.ProductionFatEngineeringBootstrap.cs @@ -0,0 +1,190 @@ +using System.ComponentModel; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Threading; +using ArIED61850Tester.Services.IoTesting; + +namespace ArIED61850Tester; + +/// +/// Makes the embedded production FAT workspace a projection of the Engineering workspace. +/// Opening SCL in Explorer is sufficient; the expensive FAT projection is created only when +/// the operator actually enters FAT so normal Engineering acquisition remains untouched. +/// +public partial class MainWindow +{ + private bool _productionFatEngineeringBootstrapInstalled; + private bool _productionFatEngineeringBootstrapBusy; + private CancellationTokenSource? _productionFatEngineeringBootstrapCts; + + [ModuleInitializer] + internal static void RegisterProductionFatEngineeringBootstrap() + { + EventManager.RegisterClassHandler( + typeof(MainWindow), + FrameworkElement.LoadedEvent, + new RoutedEventHandler(ProductionFatEngineeringBootstrap_Loaded), + handledEventsToo: true); + } + + private static void ProductionFatEngineeringBootstrap_Loaded(object sender, RoutedEventArgs e) + { + if (sender is not MainWindow window || window._productionFatEngineeringBootstrapInstalled) + return; + + window._productionFatEngineeringBootstrapInstalled = true; + window.MainTabs.SelectionChanged += window.ProductionFatEngineeringBootstrap_SelectionChanged; + window.PropertyChanged += window.ProductionFatEngineeringBootstrap_PropertyChanged; + window.Closed += window.ProductionFatEngineeringBootstrap_Closed; + } + + private void ProductionFatEngineeringBootstrap_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (!ReferenceEquals(e.Source, MainTabs) || MainTabs.SelectedIndex != NativeFatWorkspaceIndex) + return; + QueueProductionFatEngineeringBootstrap(); + } + + private void ProductionFatEngineeringBootstrap_PropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName != nameof(SelectedDevice) || MainTabs.SelectedIndex != NativeFatWorkspaceIndex) + return; + + // A running production FAT session owns its latched IED. The normal embedded-host + // selection bridge handles idle retargeting. Bootstrap is only needed before a FAT + // workspace exists. + if (_productionFatWindow == null && _loadedIoFatWindow == null) + QueueProductionFatEngineeringBootstrap(); + } + + private void QueueProductionFatEngineeringBootstrap() + { + // Never prewarm FAT while the operator is in Explorer/Live Monitor/etc. Creating the + // production FAT projection off-tab caused field regressions by mutating acquisition + // authority and adding UI/runtime work during ordinary Engineering operation. + if (!_productionFatEngineeringBootstrapInstalled || MainTabs.SelectedIndex != NativeFatWorkspaceIndex) + return; + + Dispatcher.BeginInvoke( + DispatcherPriority.ContextIdle, + new Action(async () => await EnsureProductionFatFromEngineeringAsync())); + } + + private async Task EnsureProductionFatFromEngineeringAsync() + { + if (_productionFatEngineeringBootstrapBusy || + MainTabs.SelectedIndex != NativeFatWorkspaceIndex || + !ProductionFatTabReady) + { + return; + } + + if (_productionFatWindow is { IsLoaded: true } || _loadedIoFatWindow is { IsLoaded: true }) + { + SynchronizeProductionFatSelectedIed(); + return; + } + + var selected = SelectedDevice; + if (selected?.SclWorkspace == null || + selected.SclWorkspace.DesignModel.DataSets.Sum(dataSet => dataSet.Members.Count) == 0) + { + SetStatus(selected == null + ? "FAT · select an Engineering IED with a static DataSet." + : $"FAT · {selected.Name} has no static DataSet scope in the Engineering SCL model."); + return; + } + + var engineeringDevices = Devices + .Where(device => device.SclWorkspace != null) + .Where(device => device.SclWorkspace!.DesignModel.DataSets.Sum(dataSet => dataSet.Members.Count) > 0) + .Where(device => !string.IsNullOrWhiteSpace(device.SclSourcePath)) + .ToArray(); + if (engineeringDevices.All(device => !ReferenceEquals(device, selected))) + { + SetStatus($"FAT · Engineering source provenance for {selected.Name} is unavailable; use Open SCL to restore the source authority."); + return; + } + + _productionFatEngineeringBootstrapBusy = true; + _productionFatEngineeringBootstrapCts?.Cancel(); + _productionFatEngineeringBootstrapCts?.Dispose(); + _productionFatEngineeringBootstrapCts = CancellationTokenSource.CreateLinkedTokenSource(_applicationCancellation.Token); + var token = _productionFatEngineeringBootstrapCts.Token; + SetStatus($"FAT · preparing {selected.Name} from the Engineering static DataSet…"); + + try + { + var projection = await IoFatEngineeringWorkspaceProjectionService.BuildAsync( + engineeringDevices, + token); + token.ThrowIfCancellationRequested(); + + // Register the exact same ARIEC workspace instances already owned by Explorer. + // Production FAT preparation can therefore prove shared SCL authority without + // reparsing XML or starting a second model/acquisition stack. + _ioFatSclProjectImportService.AdoptEngineeringRuntimeWorkspaces(projection.RuntimeWorkspaces); + + var launch = await IoTestWorkspaceBootstrapService.OpenSourcesAsync( + projection.Project, + projection.SourceInputs, + IoTestingProjectsRoot(), + IoTestingEvidenceRoot(), + CreateIoTestSession, + token); + token.ThrowIfCancellationRequested(); + + SynchronizeImportedSclFatWithEngineering(launch.Project); + + var retiredManualRows = launch.Project.Ieds.Sum( + IoFatEngineeringSelectionBridge.RetireManualWorkspaceRowsForStaticDataSetMode); + if (retiredManualRows > 0) + { + AddLog( + "INFO", + "FAT", + $"Automatic Static DataSet scope retired {retiredManualRows} manual SCL workspace overlay(s); static membership remains authoritative."); + } + + RegisterSharedSclSourcePaths(launch.Project, launch.Project.Ieds, projection.SourceInputs); + foreach (var ied in launch.Project.Ieds) + { + var device = ResolveIoTestDevice(ied.LiveDeviceId) + ?? ResolveIoTestDevice(ied.IpAddress) + ?? ResolveIoTestDevice(ied.IedName); + if (device is not null) + MarkSharedSelectionAuthority(device); + } + + launch.Workspace.ScheduleSave(); + await ShowIoTestingWorkspaceAsync(launch, importWarningCount: 0); + SynchronizeProductionFatSelectedIed(); + SetStatus($"FAT ready · {selected.Name} · Engineering static DataSet authority reused · no SCL re-import."); + } + catch (OperationCanceledException) + { + // Fast navigation/close is normal. No modal interruption is appropriate here. + } + catch (Exception ex) when (ex is IOException or JsonException or InvalidDataException or UnauthorizedAccessException or ArgumentException or InvalidOperationException) + { + AddLog("WARN", "FAT", $"Automatic Engineering FAT bootstrap unavailable: {ex.Message}"); + SetStatus($"FAT · could not reuse the Engineering static DataSet automatically: {ex.Message}"); + } + finally + { + _productionFatEngineeringBootstrapBusy = false; + } + } + + private void ProductionFatEngineeringBootstrap_Closed(object? sender, EventArgs e) + { + MainTabs.SelectionChanged -= ProductionFatEngineeringBootstrap_SelectionChanged; + PropertyChanged -= ProductionFatEngineeringBootstrap_PropertyChanged; + Closed -= ProductionFatEngineeringBootstrap_Closed; + _productionFatEngineeringBootstrapCts?.Cancel(); + _productionFatEngineeringBootstrapCts?.Dispose(); + _productionFatEngineeringBootstrapCts = null; + } +} diff --git a/MainWindow.ProductionFatNoFlicker.cs b/MainWindow.ProductionFatNoFlicker.cs new file mode 100644 index 000000000..f346e68f2 --- /dev/null +++ b/MainWindow.ProductionFatNoFlicker.cs @@ -0,0 +1,28 @@ +using System.Windows; + +namespace ArIED61850Tester; + +/// +/// P0 field fix for the embedded Engineering FAT transition. +/// +/// The legacy production FAT launcher owns a historical MainWindow.Hide() / child Show() +/// hand-off. That is still required by standalone compatibility flows, but it is wrong for +/// the automatic Engineering -> embedded FAT path because the FAT tab is already visible +/// and the child Window exists only as a hidden controller/lifecycle owner. Suppress that +/// single hide while the automatic embedded bootstrap is in flight so the user never sees +/// the desktop/black frame between two WPF windows. +/// +public partial class MainWindow +{ + public new void Hide() + { + if (ShouldKeepEngineeringVisibleDuringProductionFatBootstrap()) + return; + + base.Hide(); + } + + private bool ShouldKeepEngineeringVisibleDuringProductionFatBootstrap() + => _productionFatEngineeringBootstrapBusy && + ProductionFatTabReady; +} diff --git a/MainWindow.ProductionFatTab.cs b/MainWindow.ProductionFatTab.cs new file mode 100644 index 000000000..bc464d5d2 --- /dev/null +++ b/MainWindow.ProductionFatTab.cs @@ -0,0 +1,185 @@ +using System.Runtime.CompilerServices; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Media; +using System.Windows.Threading; +using ArIED61850Tester.Models; + +namespace ArIED61850Tester; + +/// +/// Engineering FAT pivot: the permanent XAML FAT tab hosts the proven production +/// IoListTestingWindow workspace rather than a second/manual FAT implementation. +/// The global Engineering IED Explorer and shared Command Dock remain authoritative. +/// +public partial class MainWindow +{ + private bool _productionFatTabInstalled; + private IoListTestingWindow? _productionFatWindow; + private FrameworkElement? _productionFatSurface; + private DispatcherTimer? _productionFatInstallRetry; + + internal bool ProductionFatTabReady => _productionFatTabInstalled && NativeFatTab != null; + + [ModuleInitializer] + internal static void RegisterProductionFatTabPivot() + { + EventManager.RegisterClassHandler( + typeof(MainWindow), + FrameworkElement.LoadedEvent, + new RoutedEventHandler(ProductionFatTab_MainWindowLoaded), + handledEventsToo: true); + } + + private static void ProductionFatTab_MainWindowLoaded(object sender, RoutedEventArgs e) + { + if (sender is not MainWindow window || window._productionFatTabInstalled) + return; + + window.Dispatcher.BeginInvoke( + DispatcherPriority.ApplicationIdle, + new Action(window.TryInstallProductionFatTabPivot)); + } + + private void TryInstallProductionFatTabPivot() + { + if (_productionFatTabInstalled || !IsLoaded) + return; + + // M7: MainWindow.xaml is the sole owner of the seventh destination. Wait only + // until the canonical XAML tab is present; there is no native FAT runtime to install. + if (MainTabs.Items.Count <= NativeFatWorkspaceIndex || + !ReferenceEquals(MainTabs.Items[NativeFatWorkspaceIndex], NativeFatTab)) + { + _productionFatInstallRetry ??= new DispatcherTimer(DispatcherPriority.ApplicationIdle) + { + Interval = TimeSpan.FromMilliseconds(120) + }; + _productionFatInstallRetry.Tick -= ProductionFatInstallRetry_Tick; + _productionFatInstallRetry.Tick += ProductionFatInstallRetry_Tick; + _productionFatInstallRetry.Start(); + return; + } + + _productionFatInstallRetry?.Stop(); + _productionFatTabInstalled = true; + NativeFatTab.Content = BuildProductionFatPermanentHost(); + + // Prewarm as soon as the canonical host exists. A valid Engineering SCL/DataSet + // can prepare the exact production surface before the operator first opens FAT. + QueueProductionFatEngineeringBootstrap(); + + // MainWindow.xaml owns both style and click routing for the seventh nav button. + NavNativeFatButton.ToolTip = "Production FAT workspace · automatic Value 1 / Value 2 evidence capture"; + + PropertyChanged += ProductionFat_MainWindowPropertyChanged; + MainTabs.SelectionChanged += ProductionFat_MainTabsSelectionChanged; + Closed += ProductionFat_MainWindowClosed; + + QueueNativeFatNavigationGeometry(); + } + + private void ProductionFatInstallRetry_Tick(object? sender, EventArgs e) + { + _productionFatInstallRetry?.Stop(); + TryInstallProductionFatTabPivot(); + } + + private FrameworkElement BuildProductionFatPermanentHost() + { + // Stable shell slot, never a launcher or alternate FAT workflow. With a valid + // Engineering static DataSet this is replaced by the exact production FAT surface. + var root = new Grid { Margin = new Thickness(0) }; + root.Children.Add(new TextBlock + { + Text = "FAT · awaiting an Engineering IED with static DataSet scope", + FontSize = 12, + Foreground = TryFindResource("Muted") as Brush ?? Brushes.DimGray, + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center + }); + return root; + } + + private void ProductionFat_MainTabsSelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (!ReferenceEquals(e.Source, MainTabs)) + return; + + QueueNativeFatNavigationGeometry(); + if (MainTabs.SelectedIndex == NativeFatWorkspaceIndex) + { + SynchronizeProductionFatSelectedIed(); + _productionFatWindow?.NotifyEmbeddedHostActivated(); + } + else + { + _productionFatWindow?.Storage?.ScheduleSave(); + } + } + + private void ProductionFat_MainWindowPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(SelectedDevice)) + SynchronizeProductionFatSelectedIed(); + } + + private void SynchronizeProductionFatSelectedIed() + => _productionFatWindow?.SelectEngineeringDeviceForEmbeddedFat(SelectedDevice); + + internal bool MountProductionFatWorkspace(IoListTestingWindow window, FrameworkElement surface) + { + ArgumentNullException.ThrowIfNull(window); + ArgumentNullException.ThrowIfNull(surface); + if (!ProductionFatTabReady) + return false; + + _productionFatWindow = window; + _productionFatSurface = surface; + surface.DataContext = window; + NativeFatTab.Content = surface; + if (_persistentWorkbench != null) + _persistentWorkbench.DockExpandedByWorkspace[NativeFatWorkspaceIndex] = true; + + window.Closed -= ProductionFatWindow_Closed; + window.Closed += ProductionFatWindow_Closed; + SynchronizeProductionFatSelectedIed(); + + // Passive mount: prewarming must never navigate, hide/show, activate, or steal + // focus from the operator's current Engineering destination. + window.RegisterEmbeddedHostCloseCleanup(); + QueueNativeFatNavigationGeometry(); + + if (MainTabs.SelectedIndex == NativeFatWorkspaceIndex) + SetStatus($"FAT ready in Engineering tab · {window.Project.Ieds.Count} IED · production auto-capture workflow."); + return true; + } + + internal void UnmountProductionFatWorkspace(IoListTestingWindow window) + { + if (!ReferenceEquals(_productionFatWindow, window)) + return; + + window.Closed -= ProductionFatWindow_Closed; + _productionFatWindow = null; + _productionFatSurface = null; + NativeFatTab.Content = BuildProductionFatPermanentHost(); + } + + private void ProductionFatWindow_Closed(object? sender, EventArgs e) + { + if (sender is IoListTestingWindow window) + UnmountProductionFatWorkspace(window); + } + + private void ProductionFat_MainWindowClosed(object? sender, EventArgs e) + { + PropertyChanged -= ProductionFat_MainWindowPropertyChanged; + MainTabs.SelectionChanged -= ProductionFat_MainTabsSelectionChanged; + Closed -= ProductionFat_MainWindowClosed; + _productionFatInstallRetry?.Stop(); + _productionFatInstallRetry = null; + _productionFatWindow = null; + _productionFatSurface = null; + } +} diff --git a/MainWindow.SharedSclWorkspace.cs b/MainWindow.SharedSclWorkspace.cs index a7a0ef71b..929a57fa6 100644 --- a/MainWindow.SharedSclWorkspace.cs +++ b/MainWindow.SharedSclWorkspace.cs @@ -98,6 +98,10 @@ private void ApplyStaticDataSetSelection(Iec61850MonitorDevice device) // shared monitor to start and never changes acquisition method. LogStaticDataSetReportFeasibility(device); _ = ObserveInitialStaticReportEvidenceAsync(device); + + // FAT is built lazily. Queueing here is harmless because the bootstrap guard only + // runs while the operator is actually on the FAT destination. + QueueProductionFatEngineeringBootstrap(); } private void ClearSharedSignalSelection(Iec61850MonitorDevice device) @@ -116,16 +120,26 @@ private void ClearSharedSignalSelection(Iec61850MonitorDevice device) private void MarkSharedSelectionAuthority(Iec61850MonitorDevice device) { - // The initial FAT import historically reached this helper for both branches. If the - // immediately preceding operator decision was Static DataSet, preserve that explicit - // report-only authority instead of silently demoting it to Hybrid. + // Initial explicit Static DataSet assignment still needs the full materialization path. if (_pendingSharedStaticSelectionAssignments > 0) { ApplyStaticDataSetSelection(device); return; } - // Manual selection restores the normal Smart/Hybrid acquisition contract. + // IMPORTANT: automatic FAT bootstrap revisits already-connected Engineering devices. + // If that device already owns Static DataSet report-only authority, merely register + // the shared selection authority. Never demote it to Hybrid/MMS as a side effect of + // opening FAT; the Engineering acquisition mode is the protocol authority. + if (IsSharedStaticDataSetAuthority(device)) + { + _sharedSclSelectionAuthorityDeviceIds.Add(device.DeviceId); + _sharedSclStaticDataSetAuthorityDeviceIds.Add(device.DeviceId); + SaveSignalSelectionMemory(device); + return; + } + + // Only an explicit/manual selection path restores normal Smart/Hybrid acquisition. _sharedSclStaticDataSetAuthorityDeviceIds.Remove(device.DeviceId); Iec61850MonitoringModeRegistry.UseHybrid(device); _sharedSclSelectionAuthorityDeviceIds.Add(device.DeviceId); @@ -219,10 +233,6 @@ await OpenSignalSelectionWizardAsync( autoStartAfterSave: false, ownerOverride: owner); - // The FAT window is not yet attached during an initial FAT import, so perform - // the same bridge operation explicitly. Selected non-DataSet SCL signals are - // materialized here as persistent FAT rows; existing FAT TEST/disposition state - // is never rewritten by Engineering selection. foreach (var signal in device.Signals) { IoFatEngineeringSelectionBridge.ApplyEngineeringSignalSelection( @@ -235,4 +245,4 @@ await OpenSignalSelectionWizardAsync( MarkSharedSelectionAuthority(device); } } -} \ No newline at end of file +} diff --git a/MainWindow.xaml b/MainWindow.xaml index 205519e97..af057d89c 100644 --- a/MainWindow.xaml +++ b/MainWindow.xaml @@ -121,8 +121,9 @@ + - @@ -151,6 +152,7 @@ +