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