diff --git a/.gitignore b/.gitignore
index b0420969e..21323dde3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -414,6 +414,10 @@ build/
.vite/
.cache/
+# Local FAT/IFAT exports and diagnostic captures
+/output/
+/outputs/
+
* text=auto
*.md text eol=lf
diff --git a/App.xaml b/App.xaml
index d31d8bee5..5438a75ce 100644
--- a/App.xaml
+++ b/App.xaml
@@ -106,6 +106,23 @@
+
+
+
+
+
+
+
+
+
+
M8,5 L19,12 L8,19 Z
M6,6 H18 V18 H6 Z
diff --git a/ArIED61850Tester.csproj b/ArIED61850Tester.csproj
index 9cc245280..492e1d190 100644
--- a/ArIED61850Tester.csproj
+++ b/ArIED61850Tester.csproj
@@ -14,9 +14,9 @@
ARSAS
ARSAS - IEC 61850 Engineering Workstation
Open-source Windows IEC 61850 engineering workstation for MMS model discovery, reporting, independent multi-IED monitoring, GOOSE subscription, fault-record file transfer, Sampled Values engineering and evidence export, SCL workflows, diagnostics, sequence of events, and guarded control validation.
- 1.6.19
- 1.6.19.0
- 1.6.19.0
+ 1.6.20
+ 1.6.20.0
+ 1.6.20.0
https://github.com/masarray/arsas
https://github.com/masarray/arsas
git
@@ -40,6 +40,7 @@
+
- 1.6.19
- 1.6.19.0
- 1.6.19.0
- 1.6.19
+ 1.6.20
+ 1.6.20.0
+ 1.6.20.0
+ 1.6.20
diff --git a/IoListTestingWindow.ContextUx.cs b/IoListTestingWindow.ContextUx.cs
index 6166e0e02..d4f0c2e3a 100644
--- a/IoListTestingWindow.ContextUx.cs
+++ b/IoListTestingWindow.ContextUx.cs
@@ -151,14 +151,14 @@ private async void StartSelectedIedSafely_Click(object sender, RoutedEventArgs e
return;
var selectedIed = SelectedIed;
- var preflight = IoTestSessionPreflight.Validate(selectedIed);
- if (!preflight.Succeeded)
+ if (selectedIed == null)
{
- ShowActionResult(preflight, "FAT session scope is not ready");
+ var missingSelection = IoTestSessionPreflight.Validate(null);
+ ShowActionResult(missingSelection, "FAT session scope is not ready");
return;
}
- var enabledReady = selectedIed!.TestPoints
+ var enabledReady = selectedIed.TestPoints
.Where(point => point.TestEnabled && point.ImportReady)
.ToList();
var completedPoints = enabledReady
@@ -188,9 +188,27 @@ private async void StartSelectedIedSafely_Click(object sender, RoutedEventArgs e
explicitRetest = true;
}
- SetPreparingIed(selectedIed, $"Connecting {selectedIed.IedName} · {selectedIed.IpAddress}:102");
+ var protectedPoints = explicitRetest
+ ? Array.Empty()
+ : completedPoints.ToArray();
+
+ // Completed evidence is outside the continuation scope from the first preflight
+ // through live-model preparation and Session.Start. This prevents a completed row
+ // that has since disappeared from the relay model from blocking otherwise-valid
+ // pending rows. The original TestEnabled flags are restored in the outer finally.
+ foreach (var point in protectedPoints)
+ point.TestEnabled = false;
+
try
{
+ var preflight = IoTestSessionPreflight.Validate(selectedIed);
+ if (!preflight.Succeeded)
+ {
+ ShowActionResult(preflight, "FAT session scope is not ready");
+ return;
+ }
+
+ SetPreparingIed(selectedIed, $"Connecting {selectedIed.IedName} · {selectedIed.IpAddress}:102");
if (Owner is MainWindow engineeringWindow)
{
var progress = new Progress(message =>
@@ -214,23 +232,7 @@ private async void StartSelectedIedSafely_Click(object sender, RoutedEventArgs e
}
}
- var protectedPoints = explicitRetest
- ? Array.Empty()
- : completedPoints.ToArray();
- IoTestSessionActionResult result;
- try
- {
- foreach (var point in protectedPoints)
- point.TestEnabled = false;
-
- result = Session.Start(selectedIed);
- }
- finally
- {
- foreach (var point in protectedPoints)
- point.TestEnabled = true;
- }
-
+ var result = Session.Start(selectedIed);
ShowActionResult(result, "FAT evidence session could not start");
RaiseStatusProperties();
RaiseSelectedIedContextProperties();
@@ -258,6 +260,9 @@ private async void StartSelectedIedSafely_Click(object sender, RoutedEventArgs e
}
finally
{
+ foreach (var point in protectedPoints)
+ point.TestEnabled = true;
+
selectedIed.SetPreparationState(false, selectedIed.LiveStatusText);
SetPreparingIed(null, string.Empty);
RaiseSelectedIedContextProperties();
@@ -312,9 +317,9 @@ public sealed class IoFatAllPassedVisibilityConverter : IMultiValueConverter
{
public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture)
{
- var enabled = values.Length > 0 && values[0] is int enabledCount ? enabledCount : 0;
+ var selectedCount = values.Length > 0 && values[0] is int count ? count : 0;
var passed = values.Length > 1 && values[1] is int passedCount ? passedCount : 0;
- var allPassed = enabled > 0 && passed == enabled;
+ var allPassed = selectedCount > 0 && passed >= selectedCount;
if (string.Equals(parameter?.ToString(), "Inverse", StringComparison.OrdinalIgnoreCase))
allPassed = !allPassed;
return allPassed ? Visibility.Visible : Visibility.Collapsed;
diff --git a/IoListTestingWindow.RealPreparationProgress.cs b/IoListTestingWindow.RealPreparationProgress.cs
new file mode 100644
index 000000000..98e6a6b20
--- /dev/null
+++ b/IoListTestingWindow.RealPreparationProgress.cs
@@ -0,0 +1,179 @@
+// Copyright 2026 Ari Sulistiono
+// SPDX-License-Identifier: Apache-2.0
+
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Threading;
+using ArIED61850Tester.Models.IoTesting;
+
+namespace ArIED61850Tester;
+
+public partial class IoListTestingWindow
+{
+ private static readonly bool RealPreparationProgressRegistered = RegisterRealPreparationProgress();
+ private readonly Dictionary _preparationDisplayStates = new();
+ private DispatcherTimer? _preparationProgressTimer;
+
+ private static bool RegisterRealPreparationProgress()
+ {
+ EventManager.RegisterClassHandler(
+ typeof(IoListTestingWindow),
+ FrameworkElement.LoadedEvent,
+ new RoutedEventHandler(RealPreparationProgress_Loaded));
+ return true;
+ }
+
+ private static void RealPreparationProgress_Loaded(object sender, RoutedEventArgs e)
+ {
+ if (sender is IoListTestingWindow window)
+ window.InstallRealPreparationProgress();
+ }
+
+ private void InstallRealPreparationProgress()
+ {
+ if (_preparationProgressTimer != null)
+ return;
+
+ foreach (var ied in Project.Ieds)
+ _preparationDisplayStates[ied] = new PreparationDisplayState();
+
+ _preparationProgressTimer = new DispatcherTimer(DispatcherPriority.Render)
+ {
+ Interval = TimeSpan.FromMilliseconds(50)
+ };
+ _preparationProgressTimer.Tick += PreparationProgressTimer_Tick;
+ _preparationProgressTimer.Start();
+ Closed += RealPreparationProgress_Closed;
+
+ Dispatcher.BeginInvoke(
+ new Action(ApplyDeterminateCardProgressBars),
+ DispatcherPriority.Loaded);
+ }
+
+ private void PreparationProgressTimer_Tick(object? sender, EventArgs e)
+ {
+ foreach (var ied in Project.Ieds)
+ {
+ if (!_preparationDisplayStates.TryGetValue(ied, out var state))
+ {
+ state = new PreparationDisplayState();
+ _preparationDisplayStates[ied] = state;
+ }
+
+ var active = ied.IsPreparing || ReferenceEquals(_preparingIed, ied);
+ if (active && !state.WasActive)
+ state.Reset();
+
+ state.WasActive = active;
+ if (!active)
+ continue;
+
+ var snapshot = Owner is MainWindow engineeringWindow
+ ? engineeringWindow.GetIoFatPreparationProgressSnapshot(ied)
+ : BuildFallbackPreparationSnapshot(ied);
+ state.Target = Math.Max(state.Target, Math.Clamp(snapshot.Percent, 0d, 100d));
+ state.Message = snapshot.Message;
+ state.StepText = snapshot.StepText;
+ state.AdvanceDisplay();
+ }
+
+ ApplyDeterminateCardProgressBars();
+ }
+
+ private void ApplyDeterminateCardProgressBars()
+ {
+ foreach (var progressBar in VisualDescendants(this)
+ .Where(progress => string.Equals(progress.Name, "CardProgress", StringComparison.Ordinal)))
+ {
+ // The XAML fallback is indeterminate so old project binaries remain safe.
+ // Once this behavior is installed every instantiated IED-card bar becomes
+ // determinate and is driven by real connection/discovery/acquisition state.
+ progressBar.IsIndeterminate = false;
+ progressBar.Minimum = 0d;
+ progressBar.Maximum = 100d;
+
+ if (progressBar.DataContext is not IoTestIedPlan ied ||
+ !_preparationDisplayStates.TryGetValue(ied, out var state))
+ {
+ progressBar.Value = 0d;
+ continue;
+ }
+
+ progressBar.Value = state.Display;
+ progressBar.ToolTip = string.Join(
+ " · ",
+ new[]
+ {
+ state.Message,
+ state.StepText,
+ $"{state.Display:0}%"
+ }.Where(value => !string.IsNullOrWhiteSpace(value)));
+ }
+ }
+
+ private static IoFatPreparationProgressSnapshot BuildFallbackPreparationSnapshot(IoTestIedPlan ied)
+ {
+ var message = string.IsNullOrWhiteSpace(ied.PreparationStatusText)
+ ? $"Preparing {ied.IedName}"
+ : ied.PreparationStatusText;
+ var percent = message.Contains("live", StringComparison.OrdinalIgnoreCase)
+ ? 100d
+ : message.Contains("validating", StringComparison.OrdinalIgnoreCase)
+ ? 90d
+ : message.Contains("arming", StringComparison.OrdinalIgnoreCase)
+ ? 80d
+ : message.Contains("matching", StringComparison.OrdinalIgnoreCase)
+ ? 68d
+ : 8d;
+ return new IoFatPreparationProgressSnapshot(message, percent, "Preparing IED");
+ }
+
+ private void RealPreparationProgress_Closed(object? sender, EventArgs e)
+ {
+ Closed -= RealPreparationProgress_Closed;
+ if (_preparationProgressTimer == null)
+ return;
+
+ _preparationProgressTimer.Stop();
+ _preparationProgressTimer.Tick -= PreparationProgressTimer_Tick;
+ _preparationProgressTimer = null;
+ _preparationDisplayStates.Clear();
+ }
+
+ private sealed class PreparationDisplayState
+ {
+ public double Target { get; set; }
+ public double Display { get; private set; }
+ public string Message { get; set; } = string.Empty;
+ public string StepText { get; set; } = string.Empty;
+ public bool WasActive { get; set; }
+
+ public void Reset()
+ {
+ Target = 0d;
+ Display = 0d;
+ Message = string.Empty;
+ StepText = string.Empty;
+ }
+
+ public void AdvanceDisplay()
+ {
+ var remaining = Target - Display;
+ if (remaining <= 0.04d)
+ {
+ if (remaining > 0d)
+ Display = Target;
+ return;
+ }
+
+ // Same visual principle as Engineering discovery cards: 20 FPS,
+ // ease-out movement, bounded minimum speed, and no artificial loop.
+ var completing = Target >= 99.9d;
+ var movement = Math.Clamp(
+ remaining * (completing ? 0.16d : 0.115d),
+ 0.10d,
+ completing ? 2.4d : 1.15d);
+ Display = Math.Min(Target, Display + movement);
+ }
+ }
+}
diff --git a/IoListTestingWindow.WorkspaceModeSwitch.cs b/IoListTestingWindow.WorkspaceModeSwitch.cs
new file mode 100644
index 000000000..a36bfb8bf
--- /dev/null
+++ b/IoListTestingWindow.WorkspaceModeSwitch.cs
@@ -0,0 +1,119 @@
+// Copyright 2026 Ari Sulistiono
+// SPDX-License-Identifier: Apache-2.0
+
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Media;
+
+namespace ArIED61850Tester;
+
+public partial class IoListTestingWindow
+{
+ private const string FatWorkspaceModeTag = "ARSAS_FAT_WORKSPACE_MODE";
+ private static readonly bool FatWorkspaceModeRegistered = RegisterFatWorkspaceMode();
+ private bool _fatWorkspaceModeInstalled;
+
+ private static bool RegisterFatWorkspaceMode()
+ {
+ EventManager.RegisterClassHandler(
+ typeof(IoListTestingWindow),
+ FrameworkElement.LoadedEvent,
+ new RoutedEventHandler(FatWorkspaceMode_Loaded));
+ return true;
+ }
+
+ private static void FatWorkspaceMode_Loaded(object sender, RoutedEventArgs e)
+ {
+ if (sender is IoListTestingWindow window)
+ window.InstallFatWorkspaceModeSwitch();
+ }
+
+ private void InstallFatWorkspaceModeSwitch()
+ {
+ if (_fatWorkspaceModeInstalled)
+ {
+ if (Owner is MainWindow existingOwner)
+ existingOwner.RegisterLoadedIoFatWindow(this);
+ return;
+ }
+
+ var originalEngineeringButton = VisualDescendants