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 @@
+
@@ -1684,7 +1686,12 @@
-
+
+
+
+
+
+
diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs
index 4f55a7528..00ae66f8a 100644
--- a/MainWindow.xaml.cs
+++ b/MainWindow.xaml.cs
@@ -620,7 +620,7 @@ private void NavButton_Click(object sender, RoutedEventArgs e)
{
if (sender is not Button button || !int.TryParse(button.Tag?.ToString(), out var index))
return;
- index = Math.Clamp(index, 0, 5);
+ index = Math.Clamp(index, 0, NativeFatWorkspaceIndex);
MainTabs.SelectedIndex = index;
UpdateNavigationVisuals(index, animate: true);
}
@@ -654,7 +654,21 @@ private void UpdateNavigationVisuals(int index, bool animate)
if (WorkflowPillTranslate == null)
return;
- var target = Math.Clamp(index, 0, 5) * 150d;
+ index = Math.Clamp(index, 0, NativeFatWorkspaceIndex);
+
+ // MainWindow is the single owner of all seven Engineering destinations.
+ // Keep the same density used by the proven workstation shell while allowing
+ // enough width for the Explorer label and the new canonical FAT sibling.
+ var availableWidth = ActualWidth > 0d ? ActualWidth : 1480d;
+ var shellWidth = availableWidth >= 1700d ? 1085d : availableWidth >= 1380d ? 995d : 805d;
+ WorkflowNavShell.Width = shellWidth;
+ WorkflowNavShell.MinWidth = shellWidth;
+
+ var contentWidth = Math.Max(0d, shellWidth - WorkflowNavShell.Padding.Left - WorkflowNavShell.Padding.Right);
+ var cellWidth = contentWidth / 7d;
+ WorkflowPill.Width = Math.Max(1d, cellWidth - 2d);
+ var target = index * cellWidth;
+
if (animate)
{
var animation = new DoubleAnimation(target, TimeSpan.FromMilliseconds(190))
@@ -669,7 +683,16 @@ private void UpdateNavigationVisuals(int index, bool animate)
WorkflowPillTranslate.X = target;
}
- var buttons = new[] { NavExplorerButton, NavLiveButton, NavEventsButton, NavAlarmButton, NavGooseButton, NavDiagnosticsButton };
+ var buttons = new[]
+ {
+ NavExplorerButton,
+ NavLiveButton,
+ NavEventsButton,
+ NavAlarmButton,
+ NavGooseButton,
+ NavDiagnosticsButton,
+ NavNativeFatButton
+ };
for (var i = 0; i < buttons.Length; i++)
buttons[i].Foreground = i == index ? Brushes.White : new SolidColorBrush(Color.FromRgb(71, 84, 103));
}
diff --git a/Models/NativeFatModels.cs b/Models/NativeFatModels.cs
new file mode 100644
index 000000000..f94d7b3a3
--- /dev/null
+++ b/Models/NativeFatModels.cs
@@ -0,0 +1,326 @@
+using System.ComponentModel;
+using System.Runtime.CompilerServices;
+
+namespace ArIED61850Tester.Models;
+
+///
+/// Persistent, acquisition-independent FAT state layered on top of the canonical
+/// Engineering/IED Explorer signal model. Nothing here owns MMS, report-control,
+/// polling, or command runtime state.
+///
+public sealed class NativeFatDeviceState
+{
+ public int SchemaVersion { get; set; } = 1;
+ public string DeviceId { get; set; } = string.Empty;
+ public string IedName { get; set; } = string.Empty;
+ public DateTimeOffset CreatedUtc { get; set; } = DateTimeOffset.UtcNow;
+ public DateTimeOffset UpdatedUtc { get; set; } = DateTimeOffset.UtcNow;
+ public List Signals { get; set; } = new();
+
+ [System.Text.Json.Serialization.JsonIgnore]
+ public string StoragePath { get; set; } = string.Empty;
+}
+
+public sealed class NativeFatSignalState
+{
+ ///
+ /// Stable per-IED identity: normalized IEC object reference + FC. Display labels
+ /// are deliberately excluded so a signal rename does not erase commissioning work.
+ ///
+ public string Key { get; set; } = string.Empty;
+ public string SignalName { get; set; } = string.Empty;
+ public string IecReference { get; set; } = string.Empty;
+ public string FunctionalConstraint { get; set; } = string.Empty;
+ public string DataType { get; set; } = string.Empty;
+ public DateTimeOffset FirstSeenUtc { get; set; } = DateTimeOffset.UtcNow;
+ public DateTimeOffset LastSeenUtc { get; set; } = DateTimeOffset.UtcNow;
+ public bool IsHistorical { get; set; }
+ public NativeFatCapture? Value1 { get; set; }
+ public NativeFatCapture? Value2 { get; set; }
+ public string Result { get; set; } = NativeFatResult.Untested;
+ public List History { get; set; } = new();
+}
+
+public sealed class NativeFatCapture
+{
+ public string Value { get; set; } = "-";
+ public string Quality { get; set; } = "Unknown";
+ public string DeviceTimestamp { get; set; } = "-";
+ public string SourceMode { get; set; } = "Unknown";
+ public long Sequence { get; set; }
+ public DateTimeOffset CapturedUtc { get; set; } = DateTimeOffset.UtcNow;
+}
+
+public sealed class NativeFatHistoryEntry
+{
+ public DateTimeOffset TimestampUtc { get; set; } = DateTimeOffset.UtcNow;
+ public string Action { get; set; } = string.Empty;
+ public string Result { get; set; } = string.Empty;
+ public NativeFatCapture? Value1 { get; set; }
+ public NativeFatCapture? Value2 { get; set; }
+ public string Note { get; set; } = string.Empty;
+}
+
+public static class NativeFatResult
+{
+ public const string Untested = "UNTESTED";
+ public const string Pass = "PASS";
+ public const string Review = "REVIEW";
+ public const string Fail = "FAIL";
+}
+
+public static class NativeFatIdentity
+{
+ public static string BuildKey(SignalDefinition signal)
+ => BuildKey(signal.ObjectReference, signal.FunctionalConstraint);
+
+ public static string BuildKey(Iec61850MonitorPoint point)
+ => BuildKey(point.IecReference, point.FunctionalConstraint);
+
+ public static string BuildKey(string? reference, string? functionalConstraint)
+ {
+ var normalized = NormalizeReference(reference);
+ var fc = (functionalConstraint ?? string.Empty).Trim().ToUpperInvariant();
+ return string.IsNullOrWhiteSpace(fc) ? normalized : $"{normalized}|{fc}";
+ }
+
+ public static string NormalizeReference(string? reference)
+ {
+ var value = (reference ?? string.Empty)
+ .Trim()
+ .Replace('$', '.')
+ .Replace("..", ".", StringComparison.Ordinal);
+ return value.ToUpperInvariant();
+ }
+}
+
+///
+/// Lightweight FAT projection. Engineering identity and current value come directly
+/// from the Explorer's SignalDefinition, with a monitor point used when available for
+/// the richer IEC telegram/acquisition metadata. FAT captures/results remain separate.
+/// Historical rows intentionally have neither live source.
+///
+public sealed class NativeFatSignalRow : INotifyPropertyChanged, IDisposable
+{
+ private SignalDefinition? _sourceSignal;
+ private Iec61850MonitorPoint? _sourcePoint;
+
+ public NativeFatSignalRow(
+ NativeFatSignalState state,
+ SignalDefinition? sourceSignal,
+ Iec61850MonitorPoint? sourcePoint)
+ {
+ State = state ?? throw new ArgumentNullException(nameof(state));
+ AttachSources(sourceSignal, sourcePoint);
+ }
+
+ public NativeFatSignalState State { get; }
+ public SignalDefinition? SourceSignal => _sourceSignal;
+ public Iec61850MonitorPoint? SourcePoint => _sourcePoint;
+ public string Key => State.Key;
+ public string SignalName => _sourceSignal?.Name ?? _sourcePoint?.SignalName ?? State.SignalName;
+ public string IecReference => _sourceSignal?.ObjectReference ?? _sourcePoint?.IecReference ?? State.IecReference;
+ public string IecTelegram => _sourcePoint?.IecTelegram ?? _sourceSignal?.DisplayReference ?? State.IecReference;
+ public string DataType => _sourceSignal?.DataType ?? _sourcePoint?.IecDataType ?? State.DataType;
+ public string FunctionalConstraint => _sourceSignal?.FunctionalConstraint ?? _sourcePoint?.FunctionalConstraint ?? State.FunctionalConstraint;
+ public string LiveValue => _sourcePoint?.DisplayValue ?? _sourceSignal?.Value ?? "-";
+ public string Quality => _sourcePoint?.Quality ?? _sourceSignal?.Quality ?? (IsHistorical ? "Historical" : "Unknown");
+ public string DeviceTimestamp => _sourcePoint?.DeviceTimestamp ?? _sourceSignal?.DeviceTimestamp ?? "-";
+ public string Value1Text => State.Value1?.Value ?? "-";
+ public string Value2Text => State.Value2?.Value ?? "-";
+ public string Result => string.IsNullOrWhiteSpace(State.Result) ? NativeFatResult.Untested : State.Result;
+ public bool IsHistorical => _sourceSignal == null && _sourcePoint == null || State.IsHistorical;
+ public string StatusText => IsHistorical ? "HISTORICAL" : State.Value1 == null && State.Value2 == null ? "READY" : "CAPTURED";
+ public int HistoryCount => State.History?.Count ?? 0;
+ public string HistoryText => HistoryCount == 0 ? "—" : $"{HistoryCount} record{(HistoryCount == 1 ? string.Empty : "s")}";
+ public bool CanCapture => _sourceSignal != null || _sourcePoint != null;
+
+ public event PropertyChangedEventHandler? PropertyChanged;
+ public event EventHandler? StateChanged;
+
+ public void AttachSources(SignalDefinition? signal, Iec61850MonitorPoint? point)
+ {
+ if (!ReferenceEquals(_sourceSignal, signal))
+ {
+ if (_sourceSignal != null)
+ _sourceSignal.PropertyChanged -= SourceSignal_PropertyChanged;
+ _sourceSignal = signal;
+ if (_sourceSignal != null)
+ _sourceSignal.PropertyChanged += SourceSignal_PropertyChanged;
+ }
+
+ if (!ReferenceEquals(_sourcePoint, point))
+ {
+ if (_sourcePoint != null)
+ _sourcePoint.PropertyChanged -= SourcePoint_PropertyChanged;
+ _sourcePoint = point;
+ if (_sourcePoint != null)
+ _sourcePoint.PropertyChanged += SourcePoint_PropertyChanged;
+ }
+
+ RaiseAll();
+ }
+
+ public bool CaptureValue(int slot)
+ {
+ if (!CanCapture || slot is < 1 or > 2)
+ return false;
+
+ var capture = CaptureCurrent();
+ if (slot == 1)
+ State.Value1 = capture;
+ else
+ State.Value2 = capture;
+
+ State.LastSeenUtc = DateTimeOffset.UtcNow;
+ AppendHistory($"Capture Value {slot}");
+ Raise(nameof(Value1Text));
+ Raise(nameof(Value2Text));
+ Raise(nameof(StatusText));
+ Raise(nameof(HistoryCount));
+ Raise(nameof(HistoryText));
+ StateChanged?.Invoke(this, EventArgs.Empty);
+ return true;
+ }
+
+ public void SetResult(string result)
+ {
+ result = result switch
+ {
+ NativeFatResult.Pass => NativeFatResult.Pass,
+ NativeFatResult.Fail => NativeFatResult.Fail,
+ NativeFatResult.Review => NativeFatResult.Review,
+ _ => NativeFatResult.Untested
+ };
+ if (State.Result.Equals(result, StringComparison.OrdinalIgnoreCase))
+ return;
+
+ State.Result = result;
+ State.LastSeenUtc = DateTimeOffset.UtcNow;
+ AppendHistory($"Result {result}");
+ Raise(nameof(Result));
+ Raise(nameof(HistoryCount));
+ Raise(nameof(HistoryText));
+ StateChanged?.Invoke(this, EventArgs.Empty);
+ }
+
+ public void ResetCurrentResult()
+ {
+ if (State.Value1 == null && State.Value2 == null &&
+ State.Result.Equals(NativeFatResult.Untested, StringComparison.OrdinalIgnoreCase))
+ return;
+
+ AppendHistory("Reset current FAT state");
+ State.Value1 = null;
+ State.Value2 = null;
+ State.Result = NativeFatResult.Untested;
+ State.LastSeenUtc = DateTimeOffset.UtcNow;
+ Raise(nameof(Value1Text));
+ Raise(nameof(Value2Text));
+ Raise(nameof(Result));
+ Raise(nameof(StatusText));
+ Raise(nameof(HistoryCount));
+ Raise(nameof(HistoryText));
+ StateChanged?.Invoke(this, EventArgs.Empty);
+ }
+
+ private NativeFatCapture CaptureCurrent()
+ => new()
+ {
+ Value = LiveValue,
+ Quality = Quality,
+ DeviceTimestamp = DeviceTimestamp,
+ SourceMode = _sourcePoint?.SourceMode ?? _sourceSignal?.ReportPlan ?? "Explorer",
+ Sequence = _sourcePoint?.Sequence ?? 0,
+ CapturedUtc = DateTimeOffset.UtcNow
+ };
+
+ private void AppendHistory(string action)
+ {
+ State.History ??= new List();
+ State.History.Add(new NativeFatHistoryEntry
+ {
+ TimestampUtc = DateTimeOffset.UtcNow,
+ Action = action,
+ Result = Result,
+ Value1 = Clone(State.Value1),
+ Value2 = Clone(State.Value2)
+ });
+ }
+
+ private static NativeFatCapture? Clone(NativeFatCapture? source)
+ => source == null ? null : new NativeFatCapture
+ {
+ Value = source.Value,
+ Quality = source.Quality,
+ DeviceTimestamp = source.DeviceTimestamp,
+ SourceMode = source.SourceMode,
+ Sequence = source.Sequence,
+ CapturedUtc = source.CapturedUtc
+ };
+
+ private void SourceSignal_PropertyChanged(object? sender, PropertyChangedEventArgs e)
+ {
+ switch (e.PropertyName)
+ {
+ case nameof(SignalDefinition.Value):
+ if (_sourcePoint == null) Raise(nameof(LiveValue));
+ break;
+ case nameof(SignalDefinition.Quality):
+ if (_sourcePoint == null) Raise(nameof(Quality));
+ break;
+ case nameof(SignalDefinition.DeviceTimestamp):
+ if (_sourcePoint == null) Raise(nameof(DeviceTimestamp));
+ break;
+ default:
+ return;
+ }
+ }
+
+ private void SourcePoint_PropertyChanged(object? sender, PropertyChangedEventArgs e)
+ {
+ switch (e.PropertyName)
+ {
+ case nameof(Iec61850MonitorPoint.Value):
+ case nameof(Iec61850MonitorPoint.DisplayValue):
+ Raise(nameof(LiveValue));
+ break;
+ case nameof(Iec61850MonitorPoint.Quality):
+ Raise(nameof(Quality));
+ break;
+ case nameof(Iec61850MonitorPoint.DeviceTimestamp):
+ Raise(nameof(DeviceTimestamp));
+ break;
+ default:
+ return;
+ }
+ }
+
+ private void RaiseAll()
+ {
+ Raise(nameof(SignalName));
+ Raise(nameof(IecReference));
+ Raise(nameof(IecTelegram));
+ Raise(nameof(DataType));
+ Raise(nameof(FunctionalConstraint));
+ Raise(nameof(LiveValue));
+ Raise(nameof(Quality));
+ Raise(nameof(DeviceTimestamp));
+ Raise(nameof(IsHistorical));
+ Raise(nameof(StatusText));
+ Raise(nameof(CanCapture));
+ }
+
+ private void Raise([CallerMemberName] string? propertyName = null)
+ => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
+
+ public void Dispose()
+ {
+ if (_sourceSignal != null)
+ _sourceSignal.PropertyChanged -= SourceSignal_PropertyChanged;
+ if (_sourcePoint != null)
+ _sourcePoint.PropertyChanged -= SourcePoint_PropertyChanged;
+ _sourceSignal = null;
+ _sourcePoint = null;
+ }
+}
diff --git a/Models/NativeFatReportModels.cs b/Models/NativeFatReportModels.cs
new file mode 100644
index 000000000..0433088e8
--- /dev/null
+++ b/Models/NativeFatReportModels.cs
@@ -0,0 +1,63 @@
+namespace ArIED61850Tester.Models;
+
+///
+/// Immutable report-time projection of one native FAT device. The snapshot deliberately
+/// copies persisted evidence instead of binding a report directly to live Explorer rows,
+/// so preview/export content cannot drift while acquisition continues in the background.
+///
+public sealed record NativeFatReportSnapshot(
+ string DeviceId,
+ string IedName,
+ string IpAddress,
+ int Port,
+ DateTimeOffset GeneratedUtc,
+ IReadOnlyList Rows)
+{
+ public int CurrentCount => Rows.Count(row => !row.IsHistorical);
+ public int HistoricalCount => Rows.Count(row => row.IsHistorical);
+ public int PassCount => Rows.Count(row => !row.IsHistorical && row.Result == NativeFatResult.Pass);
+ public int ReviewCount => Rows.Count(row => !row.IsHistorical && row.Result == NativeFatResult.Review);
+ public int FailCount => Rows.Count(row => !row.IsHistorical && row.Result == NativeFatResult.Fail);
+ public int UntestedCount => Math.Max(0, CurrentCount - PassCount - ReviewCount - FailCount);
+
+ public string SummaryText =>
+ $"Current {CurrentCount} · PASS {PassCount} · REVIEW {ReviewCount} · FAIL {FailCount} · UNTESTED {UntestedCount} · historical {HistoricalCount}";
+}
+
+public sealed record NativeFatReportRow(
+ string SignalName,
+ string IecReference,
+ string FunctionalConstraint,
+ string DataType,
+ bool IsHistorical,
+ string Result,
+ int HistoryCount,
+ string Value1Text,
+ string Value1Quality,
+ string Value1DeviceTimestamp,
+ string Value1SourceMode,
+ DateTimeOffset? Value1CapturedUtc,
+ string Value2Text,
+ string Value2Quality,
+ string Value2DeviceTimestamp,
+ string Value2SourceMode,
+ DateTimeOffset? Value2CapturedUtc)
+{
+ public string ScopeText => IsHistorical ? "HISTORICAL" : "CURRENT";
+ public string HistoryText => HistoryCount == 0 ? "—" : $"{HistoryCount} record{(HistoryCount == 1 ? string.Empty : "s")}";
+
+ public string Value1CapturedText => Value1CapturedUtc?.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss.fff") ?? "—";
+ public string Value2CapturedText => Value2CapturedUtc?.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss.fff") ?? "—";
+
+ public string EvidenceSummaryText =>
+ $"{IecReference} [{FunctionalConstraint}] · {DataType}\n" +
+ $"V1: {Value1Text} · q={Value1Quality} · IED={NormalizeTimestamp(Value1DeviceTimestamp)} · capture={Value1CapturedText} · {NormalizeSource(Value1SourceMode)}\n" +
+ $"V2: {Value2Text} · q={Value2Quality} · IED={NormalizeTimestamp(Value2DeviceTimestamp)} · capture={Value2CapturedText} · {NormalizeSource(Value2SourceMode)}\n" +
+ $"{ScopeText} · {Result} · history {HistoryText}";
+
+ private static string NormalizeTimestamp(string value)
+ => string.IsNullOrWhiteSpace(value) ? "—" : value.Trim();
+
+ private static string NormalizeSource(string value)
+ => string.IsNullOrWhiteSpace(value) ? "Unknown" : value.Trim();
+}
diff --git a/SasOperationalUiPolicy.cs b/SasOperationalUiPolicy.cs
index cd9cd10d7..8c63dbce2 100644
--- a/SasOperationalUiPolicy.cs
+++ b/SasOperationalUiPolicy.cs
@@ -24,7 +24,7 @@ internal static class SasOperationalUiPolicy
private static readonly ConditionalWeakTable